Статьи

Инструменты для поддержания JavaDoc в актуальном состоянии

Есть много проектов, где документация не актуальна. Легко забыть изменить документацию после изменения кода. Причина довольно понятна. Есть изменение в коде, затем отладка, затем, надеюсь, изменение в тестах (или наоборот в обратном порядке, если у вас больше TDD), а затем радость от новой работающей версии и счастье от нового релиза Вы забыли выполнить громоздкую задачу по обновлению документации.

В этой статье я покажу пример того, как упростить процесс и обеспечить, чтобы документация была как минимум более актуальной.

Инструмент

Инструментом, который я использую в этой статье, является Java :: Geci, который является структурой генерации кода. Первоначальная цель разработки Java :: Geci — предоставить среду, в которой чрезвычайно легко писать генераторы кода, которые внедряют код в уже существующий исходный код Java или генерируют новые исходные файлы Java. Отсюда и название: GEnerate Code Inline или GEnerate Code, Inject.

Что делает инструмент поддержки генерации кода, когда мы говорим о документации?

На самом высоком уровне фреймворка исходный код представляет собой просто текстовый файл. Документация, как и JavaDoc, является текстовой. Документация в структуре исходных каталогов, как и файлы уценки, является текстовой. Копирование и преобразование частей текста в другое место — это особая форма генерации кода. Это именно то, что мы будем делать.

Два использования для документации

Java :: Geci поддерживает документацию несколькими способами. Я опишу один из них в этой статье.

Способ заключается в том, чтобы найти несколько строк в модульных тестах и ​​скопировать содержимое после возможного преобразования в JavaDoc. Я продемонстрирую это на примере текущей основной версии проекта apache.commons.lang после выпуска 3.9. Этот проект довольно хорошо задокументирован, хотя есть возможности для улучшения. Это улучшение должно быть выполнено с минимальными человеческими усилиями, насколько это возможно. (Не потому, что мы ленивы, а потому, что человеческие усилия подвержены ошибкам.)

Важно понимать, что Java :: Geci не является инструментом предварительной обработки. Код попадает в исходный код и обновляется. Java :: Geci не устраняет избыточность кода и текста копирования-вставки. Он управляет им и гарантирует, что код будет копироваться и создаваться снова и снова всякий раз, когда что-то вызывает изменения в результате.

Как работает Java :: Geci в целом

Если вы уже слышали о Java :: Geci, вы можете пропустить эту главу. Для остальных здесь краткая структура структуры.

Java :: Geci генерирует код при запуске модульных тестов. Java :: Geci фактически выполняется как один или несколько модульных тестов. Существует свободный API для настройки фреймворка. По сути, это означает, что модульный тест, который запускает генераторы, представляет собой один оператор утверждения, который создает новый объект Geci , вызывает методы конфигурации и затем вызывает generate() . Этот метод generate() возвращает true, когда он что-то сгенерировал. Если весь сгенерированный код точно такой же, как и в исходных файлах, он возвращает false . Использование Assertion.assertFalse вокруг него провалит тест в случае каких-либо изменений в исходном коде. Просто запустите компиляцию и тесты снова.

Платформа собирает все файлы, которые были настроены для сбора, и вызывает настроенные и зарегистрированные генераторы кода. Генераторы кода работают с абстрактными объектами Source и Segment которые представляют исходные файлы и строки в исходных файлах, которые могут быть перезаписаны сгенерированным кодом. Когда все генераторы закончили свою работу, фреймворк собирает все сегменты, вставляет их в Source объекты и, если какой-либо из них значительно изменился, обновляет файл.

Наконец, фреймворк возвращается к коду модульного теста, который его запустил. Возвращаемое значение — true если был обновлен какой-либо файл исходного кода, и false противном случае.

Примеры в JavaDoc

Пример JavaDoc — это автоматическое включение примеров в документацию по методу org.apache.commons.lang3.ClassUtils.getAbbreviatedName() в библиотеке Apache Commons Lang3. Документация, которая сейчас находится в master ветке:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
/**
*
 
Gets the abbreviated class name from a {@code String}.
 
*
*
 
The string passed in is assumed to be a class name - it is not checked.
 
*
*
 
The abbreviation algorithm will shorten the class name, usually without
* significant loss of meaning.
 
*
 
The abbreviated class name will always include the complete package hierarchy.
* If enough space is available, rightmost sub-packages will be displayed in full
* length.
 
*
*
 
**
*
*
*
*
*
<table><caption>Examples</caption>
<tbody>
<tr>
<td>className</td>
<td>len</td>
<td>return</td>
<td>null</td>
<td>1</td>
<td>""</td>
<td>"java.lang.String"</td>
<td>5</td>
<td>"j.l.String"</td>
<td>"java.lang.String"</td>
<td>15</td>
<td>"j.lang.String"</td>
<td>"java.lang.String"</td>
<td>30</td>
<td>"java.lang.String"</td>
</tr>
</tbody>
</table>
* @param className the className to get the abbreviated name for, may be {@code null}
* @param len the desired length of the abbreviated name
* @return the abbreviated name or an empty string
* @throws IllegalArgumentException if len <= 0
* @since 3.4
*/

Задача, которую мы хотим решить, состоит в том, чтобы автоматизировать ведение примеров. Чтобы сделать это с помощью Java :: Geci, нам нужно сделать три вещи:

  1. Добавьте Java :: Geci как зависимость к проекту
  2. Создать модульный тест, который запускает фреймворк
  3. Отметьте часть в модульном тесте, которая является источником информации
  4. замените вручную скопированный текст примеров на Java :: Geci `Segment`, чтобы Java :: Geci автоматически скопировал текст из теста

зависимость

Java :: Geci находится в репозитории Maven Central. Текущий выпуск 1.2.0 . Он должен быть добавлен в проект в качестве тестовой зависимости. Для окончательной библиотеки LANG нет зависимости, так же как нет зависимости от JUnit или чего-либо еще, используемого для разработки. Есть две явные зависимости, которые необходимо добавить:

01
02
03
04
05
06
07
08
09
10
com.javax0.geci
javageci-docugen
1.2.0
test
 
 
com.javax0.geci
javageci-core
1.2.0
test

Артефакт javageci-docugen содержит генераторы обработки документов. Артефакт javageci-core содержит генераторы ядра. Этот артефакт также javageci-engine артефакты javageci-engine и javageci-api . Движок — это сама структура, а API — это, конечно, API.

Модульный тест

Второе изменение — это новый файл org.apache.commons.lang3.docugen.UpdateJavaDocTest . Этот файл представляет собой простой и очень обычный модульный тест:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/*
* Licensed to the Apache Software Foundation (ASF) ...
*/
package org.apache.commons.lang3.docugen;
 
import *;
 
public class UpdateJavaDocTest {
 
@Test
void testUpdateJavaDocFromUnitTests() throws Exception {
final Geci geci = new Geci();
int i = 0;
Assertions.assertFalse(geci.source(Source.maven())
.register(SnippetCollector.builder().files("\\.java$").phase(i++).build())
.register(SnippetAppender.builder().files("\\.java$").phase(i++).build())
.register(SnippetRegex.builder().files("\\.java$").phase(i++).build())
.register(SnippetTrim.builder().files("\\.java$").phase(i++).build())
.register(SnippetNumberer.builder().files("\\.java$").phase(i++).build())
.register(SnipetLineSkipper.builder().files("\\.java$").phase(i++).build())
.register(MarkdownCodeInserter.builder().files("\\.java$").phase(i++).build())
.splitHelper("java", new MarkdownSegmentSplitHelper())
.comparator((orig, gen) -> !orig.equals(gen))
.generate(),
geci.failed());
}
 
}

Здесь мы видим огромный вызов Assertions.assertFalse . Сначала мы создаем новый объект Geci а затем сообщаем ему, где находятся исходные файлы. Не вдаваясь в подробности, есть много разных способов, как пользователь может указать, где находятся источники. В этом примере мы просто говорим, что исходные файлы находятся там, где они обычно находятся, когда мы используем Maven в качестве инструмента для сборки.

Следующее, что мы делаем, — это регистрируем разные генераторы. Генераторы, особенно генераторы кода, обычно работают независимо, и, таким образом, среда не гарантирует порядок выполнения. В этом случае эти генераторы, как мы увидим позже, очень сильно зависят от действий друг друга. Важно, чтобы они были выполнены в правильном порядке. Рамки позволяют нам достичь этого через этапы. Генераторов спрашивают, сколько фаз им нужно, и на каждом этапе их также спрашивают, нужно ли их вызывать или нет. Каждый объект генератора создается с использованием шаблона компоновщика, и каждому из них сообщается, на какой стадии он должен выполняться. Когда генератор сконфигурирован для работы в фазе i (вызывая .phase(i) ), он сообщит платформе, что ему понадобится как минимум i фаз, а для фаз 1..i-1 он будет неактивен. Таким образом, конфигурация гарантирует, что генераторы будут работать в следующем порядке:

  1. SnippetCollector
  2. SnippetAppender
  3. SnippetRegex
  4. SnippetTrim
  5. SnippetNumberer
  6. SnipetLineSkipper
  7. MarkdownCodeInserter

Технически все это генераторы, но они не «генерируют» код. SnippetCollector собирает фрагменты из исходных файлов. SnippetAppender может добавлять несколько фрагментов вместе, когда для некоторого примера кода требуется текст из разных частей программы. SnippetRegex может изменять фрагменты перед использованием регулярных выражений и выполнять функцию replaceAll (мы увидим это в этом примере). SnippetTrim может удалить ведущие вкладки и пробелы в начале строк. Это важно, когда код глубоко табулирован. В этом случае простой импорт фрагмента в документацию может легко вытолкнуть действительные символы из области печати с правой стороны. SnippetNumberer может SnippetNumberer строки фрагмента в случае, если у нас есть некоторый код, в котором документация ссылается на определенные строки. SnipetLineSkipper может пропускать определенные строки из кода. Например, вы можете настроить его так, чтобы операторы импорта были пропущены.

Наконец, настоящий «генератор», который может изменить исходный код, это MarkdownCodeInserter . Он был создан для вставки фрагментов в файлы формата Markdown, но он работает так же хорошо для исходных файлов Java, когда текст должен быть вставлен в часть JavaDoc.

Последние два, кроме одного вызова конфигурации, говорят каркасу использовать MarkdownSegmentSplitHelper и сравнивать исходные и те строки, которые были созданы после генерации кода с использованием простых equals . Объекты SegmentSplitHelper помогают каркасу находить сегменты в исходном коде. В файлах Java сегменты обычно и по умолчанию между

1
 

и

1
 

линий. Это помогает разделить руководство и сгенерированный код. Редактор также можно свернуть во всех расширенных редакторах, чтобы вы могли сосредоточиться на коде, созданном вручную.

В этом случае, однако, мы вставляем в сегменты, которые находятся внутри комментариев JavaDoc. Эти комментарии JavaDoc больше похожи на Markdown, чем на Java, в том смысле, что они могут содержать некоторую разметку, но также дружественную к HTML. В частности, они могут содержать комментарии XML, которые не будут отображаться в выходном документе. Начало сегмента в этом случае, как определено объектом MarkdownSegmentSplitHelper находится между

1
<!-- snip snipName parameters ... -->

и

1
<!-- end snip -->

линий.

Компаратор должен быть указан по очень конкретной причине. Каркас имеет два встроенных компаратора. Одним из них является компаратор по умолчанию, который сравнивает строки одну за другой и символ за символом. Это используется для всех типов файлов, кроме Java. В случае Java используется специальный компаратор, который распознает, когда был изменен только комментарий или когда код был только переформатирован. В этом случае мы меняем содержимое комментария в Java-файле, поэтому нам нужно указать платформе использовать простой компаратор, иначе мы не будем ссылаться, мы что-то обновили. (Отладка заняла 30 минут, почему сначала не обновлялись файлы.)

Последний вызов — метод generate() который запускает весь процесс.

Отметьте код

Код модульного теста, который документирует этот метод: org.apache.commons.lang3.ClassUtilsTest.test_getAbbreviatedName_Class() . Это должно выглядеть следующим образом:

01
02
03
04
05
06
07
08
09
10
11
@Test
public void test_getAbbreviatedName_Class() {
// snippet test_getAbbreviatedName_Class
assertEquals("", ClassUtils.getAbbreviatedName((Class<?>) null, 1));
assertEquals("j.l.String", ClassUtils.getAbbreviatedName(String.class, 1));
assertEquals("j.l.String", ClassUtils.getAbbreviatedName(String.class, 5));
assertEquals("j.lang.String", ClassUtils.getAbbreviatedName(String.class, 13));
assertEquals("j.lang.String", ClassUtils.getAbbreviatedName(String.class, 15));
assertEquals("java.lang.String", ClassUtils.getAbbreviatedName(String.class, 20));
// end snippet
}

Я не буду здесь представлять оригинал, потому что единственное отличие состоит в том, что были вставлены две строки snippet ... и end snippet . Это триггеры для SnippetCollector собирающие строки между ними и сохраняющие их в «хранилище фрагментов» (ничего загадочного, практически большая хэш-карта).

Определить сегмент

Действительно интересная часть — как модифицируется JavaDoc. В начале статьи я уже представил весь код, как сегодня. Новая версия:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
/**
*
 
Gets the abbreviated class name from a {@code String}.
 
*
*
 
The string passed in is assumed to be a class name - it is not checked.
 
*
*
 
The abbreviation algorithm will shorten the class name, usually without
* significant loss of meaning.
 
*
 
The abbreviated class name will always include the complete package hierarchy.
* If enough space is available, rightmost sub-packages will be displayed in full
* length.
 
*
*
 
**
you can write manually anything here, the code generator will update it when you start it up
*
<table><caption>Examples</caption>
<tbody>
<tr>
<td>className</td>
<td>len</td>
<td>return</td>
<!-- snip test_getAbbreviatedName_Class regex="
replace='/~s*assertEquals~((.*?)~s*,~s*ClassUtils~.getAbbreviatedName~((.*?)~s*,~s*(~d+)~)~);/*
</tr><tr>
<td>{@code $2}</td>
<td>$3</td>
<td>{@code $1}</td>
</tr>
/' escape='~'" --><!-- end snip -->
</tbody>
</table>
* @param className the className to get the abbreviated name for, may be {@code null}
* @param len the desired length of the abbreviated name
* @return the abbreviated name or an empty string
* @throws IllegalArgumentException if len <= 0
* @since 3.4
*/

Важной частью является то, где находятся линии 15… 20. (Видите ли, иногда важно нумеровать строки фрагментов.) Линия 15 сигнализирует о начале сегмента. Имя сегмента — test_getAbbreviatedName_Class и когда ничего не определено, он также будет использоваться как имя фрагмента для вставки. Однако до вставки фрагмента он преобразуется генератором SnippetRegex . Он заменит каждое совпадение регулярного выражения

1
\s*assertEquals\((.*?)\s*,\s*ClassUtils\.getAbbreviatedName\((.*?)\s*,\s*(\d+)\)\);

со строкой

1
2
*
{@code $2}$3{@code $1}

Поскольку эти регулярные выражения находятся внутри строки, которая также находится внутри строки, нам понадобится \\\\ вместо одного \ . Это заставило бы выглядеть наши регулярные выражения ужасными. Поэтому генератор SnippetRegex можно настроить на использование другого персонажа по нашему выбору, который менее подвержен явлению забора. В этом примере мы используем символ тильды, и он обычно работает. Что это в итоге приводит к тому, что мы запускаем это:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<!-- snip test_getAbbreviatedName_Class regex="
replace='/~s*assertEquals~((.*?)~s*,~s*ClassUtils~.getAbbreviatedName~((.*?)~s*,~s*(~d+)~)~);/*
<tr>
<td>{@code $2}</td>
<td>$3</td>
<td>{@code $1}</td>
</tr>
/' escape='~'" -->
*
{@code (Class) null}1{@code ""}
 
*
{@code String.class}1{@code "j.l.String"}
 
*
{@code String.class}5{@code "j.l.String"}
 
*
{@code String.class}13{@code "j.lang.String"}
 
*
{@code String.class}15{@code "j.lang.String"}
 
*
{@code String.class}20{@code "java.lang.String"}
 
<!-- end snip -->

Резюме / Еда на вынос

Обновление документа может быть автоматизировано. Сначала это немного громоздко. Вместо того, чтобы копировать и переформатировать текст, разработчик должен настроить новый модульный тест, отметить фрагмент, отметить сегмент, изготовить преобразование с использованием регулярных выражений. Однако, когда это сделано, любое обновление происходит автоматически. Невозможно забыть обновить документацию после изменения модульных тестов.

Это тот же подход, который мы применяем при создании модульных тестов. Сначала немного неудобно создавать модульные тесты, а не просто отлаживать и запускать код специальным образом и смотреть, действительно ли он ведет себя так, как мы ожидали, глядя на отладчик. Однако, когда это сделано, любое обновление автоматически проверяется. Невозможно забыть проверить старую функциональность, когда код, влияющий на это, изменяется.

По моему мнению, ведение документации должно быть так же автоматизировано, как и тестирование. В целом: все, что может быть автоматизировано при разработке программного обеспечения, должно быть автоматизировано, чтобы сэкономить усилия и уменьшить количество ошибок.

Опубликовано на Java Code Geeks с разрешения Питера Верхаса, партнера нашей программы JCG . Смотрите оригинальную статью здесь: Инструменты для поддержания JavaDoc в актуальном состоянии.

Мнения, высказанные участниками Java Code Geeks, являются их собственными.