在许多项目中,文档不是最新的。 更改代码后,很容易忘记更改文档。 原因是可以理解的。 在代码中进行更改,然后调试,然后希望在测试中进行更改(或者,如果您使用的是更多TDD,则以相反的顺序进行更改),然后是新功能版本的喜悦以及对新版本的喜悦您会忘记执行更新文档的繁琐任务。
在本文中,我将显示一个示例,说明如何简化流程并确保文档至少是最新的。
工具
我在本文中使用的工具是Java :: Geci,它是一个代码生成框架。 Java :: Geci的原始设计目标是提供一个框架,在该框架中,编写代码生成器将代码注入到现有的Java源代码中或生成新的Java源文件非常容易。 因此,名称为:GEnerate Code Inline或GEnerate Code,Inject。
当我们谈论文档时,代码生成支持工具会做什么?
在框架的最高级别上,源代码只是一个文本文件。 像JavaDoc一样,文档也是文本。 源目录结构中的文档(例如markdown文件)是文本。 复制文本的一部分并将其转换到其他位置是代码生成的一种特殊形式。 这正是我们将要做的。
文档的两种用途
Java :: Geci支持文档的几种方式。 我将在本文中描述其中之一。
方法是在单元测试中找到一些行,并在可能的转换后将内容复制到JavaDoc中。 我将使用3.9版之后的apache.commons.lang
项目当前主版本的示例进行演示。 尽管有改进的余地,但该项目的文献记录非常丰富。 必须以尽可能少的人力来执行此改进。 (不是因为我们懒惰,而是因为人类的工作容易出错)。
重要的是要了解Java :: Geci不是预处理工具。 该代码进入实际的源代码中,并被更新。 Java :: Geci不能消除复制粘贴代码和文本的冗余。 它对其进行管理,并确保每当导致结果发生更改时,就一遍又一遍地复制和创建代码。
Java :: Geci的一般工作方式
如果您已经听说过Java :: Geci,则可以跳过本章。 对于其他人,这里是框架的简要结构。
Java :: Geci在单元测试运行时生成代码。 Java :: Geci实际上是作为一个或多个单元测试运行的。 有一个流畅的API可以配置框架。 从本质Geci
,这意味着运行生成器的单元测试是一个单独的声明语句,该语句创建一个新的Geci
对象,调用配置方法,然后调用generate()
。 此方法generate()
生成某些内容后返回true。 如果生成的所有代码与源文件中的代码完全相同,则返回false
。 如果源代码发生任何更改,则在其周围使用Assertion.assertFalse
将使测试失败。 只需再次运行编译和测试。
框架收集所有配置为要收集的文件,并调用已配置和注册的代码生成器。 代码生成器与代表源文件的抽象Source
和Segment
对象一起使用,并且源文件中的行可能会被生成的代码覆盖。 当所有生成器完成工作后,框架将收集所有段,将其插入Source
对象,如果其中任何一个发生了重大变化,则它将更新文件。
最后,框架返回到启动它的单元测试代码。 如果更新了任何源代码文件,则返回值为true
否则为false
。
JavaDoc中的示例
JavaDoc示例将自动将示例包含到Apache Commons Lang3库中的方法org.apache.commons.lang3.ClassUtils.getAbbreviatedName()
的文档中。 当前在master
分支中的文档是:
/** * 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>"jlString"</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做到这一点,我们必须做三件事:
- 将Java :: Geci添加为项目的依赖项
- 创建一个运行框架的单元测试
- 在单元测试中标记零件,这是信息的来源
- 用Java :: Geci`Segment`替换手动复制的示例文本,以便Java :: Geci将自动从测试中复制文本
相依性
Java :: Geci在Maven Central存储库中。 当前版本是1.2.0
。 它必须作为测试依赖项添加到项目中。 最终的LANG库没有任何依赖关系,就像对JUnit或用于开发的任何其他内容没有依赖关系一样。 必须添加两个显式依赖项:
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-api
工件。 引擎本身就是框架,API本身就是API。
单元测试
第二个更改是新文件org.apache.commons.lang3.docugen.UpdateJavaDocTest
。 该文件是一个简单且非常常规的单元测试:
/* * 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
,它将处于非活动状态。 这样,配置可确保生成器按以下顺序运行:
- 片段收集器
- SnippetAppender
- 片段正则表达式
- 片段修剪
- 片段编号器
- SnipetLine船长
- MarkdownCodeInserter
从技术上讲,所有这些都是生成器,但它们不会“生成”代码。 SnippetCollector
从源文件中收集片段。 当一些示例代码需要程序不同部分的文本时, SnippetAppender
可以将多个代码片段附加在一起。 SnippetRegex
可以在使用正则表达式和replaceAll功能之前修改代码段(我们将在此示例中看到)。 SnippetTrim
可以从行的开头删除前导制表符和空格。 当代码经过深列表时,这一点很重要。 在这种情况下,只需将摘录片段导入文档中,就可以轻松地将实际字符从右侧的可打印区域中移出。 如果我们有一些代码在文档中引用了某些行,则SnippetNumberer
可以对代码段行进行编号。 SnipetLineSkipper
可以从代码中跳过某些行。 例如,您可以对其进行配置,以便跳过导入语句。
最后,可以更改源代码的真正“生成器”是MarkdownCodeInserter
。 创建它是为了将片段插入以Markdown格式的文件中,但是当需要将文本插入JavaDoc部件中时,它对于Java源文件也同样有效。
最后两个配置调用告诉框架使用MarkdownSegmentSplitHelper
并比较原始行和使用简单的equals
进行代码生成后创建的行。 SegmentSplitHelper
对象可帮助框架在源代码中查找段。 在Java文件中,这些段通常是默认情况下的
和
线。 这有助于将手册和生成的代码分开。 在所有高级编辑器中,该编辑器折叠也是可折叠的,因此您可以专注于手动创建的代码。
但是,在这种情况下,我们将插入到JavaDoc注释内的段中。 这些JavaDoc注释可能包含一些标记,但也友好HTML,因此它们比Java更像Markdown。 尤其是,它们可能包含不会出现在输出文档中的XML注释。 在这种情况下,由MarkdownSegmentSplitHelper
对象定义的片段开始于
<!-- snip snipName parameters ... -->
和
<!-- end snip -->
线。
必须出于非常特殊的原因指定比较器。 该框架具有两个内置的比较器。 一个是默认的比较器,它逐行比较每个字符。 它用于除Java外的所有文件类型。 在Java的情况下,使用了一个特殊的比较器,该比较器可以识别何时仅更改注释或仅重新格式化代码。 在这种情况下,我们将更改Java文件中注释的内容,因此我们需要告诉框架使用简单的比较器,否则它将不会影响我们进行任何更新。 (花了30分钟的时间调试为什么不先更新文件。)
最后一个调用是generate()
,它将启动整个过程。
标记代码
记录此方法的单元测试代码是org.apache.commons.lang3.ClassUtilsTest.test_getAbbreviatedName_Class()
。 外观应如下所示:
@Test public void test_getAbbreviatedName_Class() { // snippet test_getAbbreviatedName_Class assertEquals( "" , ClassUtils.getAbbreviatedName((Class<?>) null , 1 )); assertEquals( "jlString" , ClassUtils.getAbbreviatedName(String. class , 1 )); assertEquals( "jlString" , 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
收集它们之间的线并将其存储在“ snippet store”(没有什么神秘的东西,实际上是一个很大的哈希图)中的触发器。
定义一个细分
真正有趣的部分是如何修改JavaDoc。 在本文开头,我已经介绍了今天的完整代码。 新版本是:
/** * 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
生成器进行转换。 它将替换正则表达式的每个匹配项
\s*assertEquals\((.*?)\s*,\s*ClassUtils\.getAbbreviatedName\((.*?)\s*,\s*(\d+)\)\);
与字符串
* {@code $2}$3{@code $1}
由于这些正则表达式位于字符串内,因此也需要\\\\
而不是单个\
。 那会使我们的正则表达式看起来很糟糕。 因此,可以将生成器SnippetRegex
配置为使用我们选择的其他一些字符,这种字符不太容易出现篱笆现象。 在此示例中,我们使用波浪号字符,并且通常可以使用。 当我们运行它时,最终结果是:
<!-- 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 "jlString" } * {@code String.class}5{@code "jlString" } * {@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 -->
摘要/外卖
文档更新可以自动化。 首先,这有点麻烦。 开发人员不必复制和重新格式化文本,而是必须设置新的单元测试,标记代码段,标记段并使用正则表达式构造转换。 但是,完成后,任何更新都是自动的。 单元测试更改后,不能忘记更新文档。
这与创建单元测试时遵循的方法相同。 首先,创建单元测试而不是只是临时地调试和运行代码,然后查看调试器,以查看其是否确实如我们预期的那样,这有点麻烦。 但是,完成后会自动检查所有更新。 当影响旧代码的代码发生更改时,就不会忘记检查旧功能。
我认为文档维护应与测试一样自动化。 通常,任何可以在软件开发中自动化的东西都必须自动化,以节省工作量并减少错误。
翻译自: https://www.javacodegeeks.com/2019/09/tools-keep-javadoc-date.html