文章同步在 个人博客
以前接口文档都是用swagger2在线文档,最近升级为spring boot2 + webflux反应式编程后,swagger2不支持webflux,无法使用,因此文档生成改为官方的spring rest docs,实践过程中因为英文不咋地走了一些弯路,在这里记录一下吧。
#### 1. 首先就是依赖和gradle插件
官方的gradle依赖, 要注意的地方是plugins的位置 。本文是markdown书写缺失一些asciidoc的显示
plugins { id "org.asciidoctor.convert" version "1.5.3" } dependencies { asciidoctor 'org.springframework.restdocs:spring-restdocs-asciidoctor:2.0.2.RELEASE' testCompile "org.springframework.restdocs:spring-restdocs-webtestclient:2.0.2.RELEASE" //如果为mvc换成mvc测试相关依赖 } ext { snippetsDir = file('build/generated-snippets') } test { outputs.dir snippetsDir } asciidoctor { inputs.dir snippetsDir dependsOn test } //spring boot打jar包的时候将生成的html5资源加入 bootJar { dependsOn asciidoctor from ("${asciidoctor.outputDir}/html5") { into 'static/public/docs' } } //Apply the Asciidoctor plugin. //Add a dependency on spring-restdocs-asciidoctor in the asciidoctor configuration. This will automatically configure the snippets attribute for use in your .adoc files to point to build/generated-snippets. It will also allow you to use the operation block macro. //Add a dependency on spring-restdocs-mockmvc in the testCompile configuration. If you want to use REST Assured rather than MockMvc, add a dependency on spring-restdocs-restassured instead. //Configure a property to define the output location for generated snippets. //Configure the test task to add the snippets directory as an output. //Configure the asciidoctor task //Configure the snippets directory as an input. //Make the task depend on the test task so that the tests are run before the documentation is created.
#### 2. 开始编写接口单元测试
代码如下,其中responseFields和requestFields的相关字段约束参考[官网]( https://docs.spring.io/spring-restdocs/docs/2.0.2.RELEASE/reference/html5/#documenting-your-api-request-response-payloads )
import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.google.common.collect.Lists; import lombok.extern.slf4j.Slf4j; import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.reactive.SpringBootWebTestClientBuilderCustomizer; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.web.codec.CodecCustomizer; import org.springframework.context.ApplicationContext; import org.springframework.restdocs.JUnitRestDocumentation; import org.springframework.restdocs.constraints.ConstraintDescriptions; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.junit4.SpringRunner; import org.springframework.test.web.reactive.server.WebTestClient; import reactor.core.publisher.Mono; import java.util.Collection; import java.util.Map; import java.util.stream.Collectors; import static org.springframework.restdocs.payload.PayloadDocumentation.*; import static org.springframework.restdocs.snippet.Attributes.key; import static org.springframework.restdocs.webtestclient.WebTestClientRestDocumentation.document; import static org.springframework.restdocs.webtestclient.WebTestClientRestDocumentation.documentationConfiguration; @RunWith(SpringRunner.class) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @ActiveProfiles("dev") @Slf4j public class DocsGen { @Rule public final JUnitRestDocumentation restDocumentation = new JUnitRestDocumentation(); @Autowired public ApplicationContext context; private WebTestClient webTestClient; @Before public void setUp() { this.webTestClient = WebTestClient.bindToApplicationContext(context) .configureClient().baseUrl("http://api.example.com/") .filter(documentationConfiguration(restDocumentation)) .build(); } @Test public void loginTest() { final ReactiveSecurityConfig.BUser bUser = new ReactiveSecurityConfig.BUser(); bUser.setUsername("super"); bUser.setPassword("12345"); ConstraintDescriptions userConstraints = new ConstraintDescriptions(ReactiveSecurityConfig.BUser.class); webTestClient .post().uri("/api/login") .body(Mono.just(bUser), ReactiveSecurityConfig.BUser.class) .exchange().expectStatus().isOk() .expectBody() .consumeWith(document("login", //生成adoc文档所在文件夹名称 requestFields(fieldWithPath("username") .description("用户名") .attributes(key("constraints").value(userConstraints.descriptionsForProperty("username"))), fieldWithPath("password") .description("用户密码").attributes(key("constraints").value(userConstraints.descriptionsForProperty("password"))) ), responseFields(fieldWithPath("id").description("id").attributes(key("constraints").value("")), fieldWithPath("username").description("用户名").attributes(key("constraints").value("")), fieldWithPath("agentId").description("企业号应用名称").attributes(key("constraints").value("")), fieldWithPath("lastUpdatedAt").description("最近登录时间").attributes(key("constraints").value("")), fieldWithPath("status").description("状态").attributes(key("constraints").value("")), fieldWithPath("enabled").description("是否启用").attributes(key("constraints").value("")), fieldWithPath("accountNonExpired").description("是否过期").attributes(key("constraints").value("")), fieldWithPath("handler").description("null").attributes(key("constraints").value("")), fieldWithPath("authorities/[/].authority").description("授权信息").attributes(key("constraints").value("")), fieldWithPath("accountNonLocked").description("是否没有被锁").attributes(key("constraints").value("")), fieldWithPath("credentialsNonExpired").description("认证是否过期").attributes(key("constraints").value(""))))); } }
跑一下单元测试试试,跑完后看一下build文件夹目录如下,框框里面就是生成的adoc文件,adoc文件是一种毕markdown更强档的书写文档格式,spring官网文档和很多大公司的文档都由其编写,adoc文档参考[AsciiDoc 语法快速参考]( https://asciidoctor.cn/docs/asciidoc-syntax-quick-reference/#markdown-compatibility )
![]( https://www.jgayb.cn/wp-content/uploads/2018/09/1536556548857.jpg )
request-fields.adoc和response-fields.adoc默认没有,需要在增加如图配置。具体内容基本相同
![]( https://www.jgayb.cn/wp-content/uploads/2018/09/1536558462309.jpg )
request-fields.adoc和response-fields.adoc具体内容
|=== |路径|类型|描述|约束 {{#fields}} |{{#tableCellContent}}`{{path}}`{{/tableCellContent}} |{{#tableCellContent}}`{{type}}`{{/tableCellContent}} |{{#tableCellContent}}{{description}}{{/tableCellContent}} |{{#tableCellContent}}{{constraints}}{{/tableCellContent}} {{/fields}} |===
打开http-request.adoc、http-response.adoc看看,发现json都是未格式化的,很不美观。没关系改下配置让其美化一下吧,美化的关键是在ObjectMapper的打印配置,修改如下:
- 先全局序列化消息配置
@Configuration @EnableScheduling public class AppConfig implements ApplicationContextAware { @Override public void setApplicationContext(ApplicationContext applicationContext) { AppContextUtils.setCtx(applicationContext); } /** /* XML报文序列化器 /* JsonInclude.Include.NON_NULL 序列化是忽略null字段 /* SerializationFeature.FAIL/_ON/_EMPTY_BEANS, false懒加载异常消除 * /* @return xmlMapper */ @Bean public XmlMapper xmlMapper() { final XmlMapper xmlMapper = new XmlMapper(); xmlMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); xmlMapper.configure(SerializationFeature.FAIL/_ON/_EMPTY_BEANS, false); xmlMapper.enable(SerializationFeature.INDENT_OUTPUT); return xmlMapper; } /** /* Json报文序列化器 /* JsonInclude.Include.NON_NULL 序列化是忽略null字段 /* SerializationFeature.FAIL/_ON/_EMPTY_BEANS, false懒加载异常消除 * /* @return xmlMapper */ @Bean public ObjectMapper objectMapper() { return Jackson2ObjectMapperBuilder.json() .serializationInclusion(JsonInclude.Include.NON_NULL) .build() .configure(SerializationFeature.FAIL/_ON/_EMPTY_BEANS, false) .enable(SerializationFeature.INDENT_OUTPUT);//美化json字符串打印输出 } /** /* 默认就有多种httpMessage消息序列化器,这里自定义json和xml转换器 * /* @return CodecCustomizer 自定义转换器 */ @Bean @ConditionalOnBean(ObjectMapper.class) public CodecCustomizer jacksonCodecCustomizer(@Qualifier("jsonEncoder") Jackson2JsonEncoder jackson2JsonEncoder, Jackson2JsonDecoder jackson2JsonDecoder, CustomJaxb2XmlDecoder jaxb2XmlDecoder, @Qualifier("xmlEncoder") Jackson2JsonEncoder xmlEncoder) { return (configurer) -> { CodecConfigurer.DefaultCodecs defaults = configurer.defaultCodecs(); /* json反序列化器注册 */ defaults.jackson2JsonDecoder( jackson2JsonDecoder); /* json序列化器注册 */ defaults.jackson2JsonEncoder( jackson2JsonEncoder); final CodecConfigurer.CustomCodecs customCodecs = configurer.customCodecs(); /* xml序列化反序列化器注册 */ customCodecs.decoder(jaxb2XmlDecoder); customCodecs.encoder(xmlEncoder); }; } @Bean("jsonEncoder") public Jackson2JsonEncoder jackson2JsonEncoder(ObjectMapper objectMapper) { return new Jackson2JsonEncoder(objectMapper, this.jsonMimeTypes()); } @Bean public Jackson2JsonDecoder jackson2JsonDecoder(ObjectMapper objectMapper) { return new Jackson2JsonDecoder(objectMapper, this.jsonMimeTypes()); } @Bean public CustomJaxb2XmlDecoder jaxb2XmlDecoder() { return new CustomJaxb2XmlDecoder(this.xmlMimeTypes()); } @Bean("xmlEncoder") public Jackson2JsonEncoder xmlEncoder(XmlMapper xmlMapper) { return new Jackson2JsonEncoder(xmlMapper, this.xmlMimeTypes()); } private MimeType/[/] xmlMimeTypes() { return new MimeType/[/]{MimeTypeUtils.APPLICATION/_XML, MimeTypeUtils.TEXT/_HTML, MimeTypeUtils.TEXT_XML}; } private MimeType/[/] jsonMimeTypes() { return new MimeType/[/]{ new MimeType("application", "json", StandardCharsets.UTF_8), new MimeType("application", "*+json", StandardCharsets.UTF_8), MimeTypeUtils.TEXT_PLAIN }; } }
- 在将配置应用到WebTestClient,只需修改DocsGen的setUp()方法:
//注入上届的配置 @Autowired private CodecCustomizer codecCustomizer; private WebTestClient webTestClient; @Before public void setUp() { final WebTestClient.Builder builder = WebTestClient.bindToApplicationContext(context) .configureClient(); final SpringBootWebTestClientBuilderCustomizer builderCustomizer = new SpringBootWebTestClientBuilderCustomizer(Lists.newArrayList(codecCustomizer)); //将自定义的序列化配置应用到webTestCliend builderCustomizer.customize(builder); this.webTestClient = builder.baseUrl("http://laprairie-enterprise.d.d1miao.com/") .filter(documentationConfiguration(restDocumentation)) .build(); }
重跑一边单元测试,查看上面的文件request-body.adoc,美化的json字符串好看多了。
/[source,options="nowrap"/] /-/-/-/- { "username" : "admin", "password" : "12345" } /-/-/-/-
#### 3. 编写入口index.adoc和生成hmtl文件
上面生成了很多.adoc的文件,现在根据asciidoc德与法编写入口index文件。gradle项目在src目录下新建docs/asciidoc目录(maven项目在其他目录),然后新建两个文件如下图,后面跑完脚本会生成对应的html页面
![]( https://www.jgayb.cn/wp-content/uploads/2018/09/1536557666944.jpg )
login.adoc内容如下,主要是将build/generated-snippets下生成的文件导入进来
== /*Backend user login:/* include::{snippets}/login/curl-request.adoc/[/] === Request using HTTPie: include::{snippets}/login/httpie-request.adoc/[/] === HTTP request: include::{snippets}/login/http-request.adoc/[/] === Request body: include::{snippets}/login/request-body.adoc/[/] ==== Request fields: include::{snippets}/login/request-fields.adoc/[/] === HTTP response: include::{snippets}/login/http-response.adoc/[/] === Response body: include::{snippets}/login/response-body.adoc/[/] ==== Response fields: include::{snippets}/login/response-fields.adoc/[/]
然后编写index.adoc文件,导入上面的login.adoc,如果还有其他adoc文件也可以都导入,按顺序编写导入
= XXX项目api文档 Jone Wang; :toc: left :toc-title: 章节 :doctype: book :icons: font :source-highlighter: highlightjs include::login.adoc/[/]
现在执行gradle命令编译
./gradlew -Dorg.gradle.daemon=false -Dtest.enabled=true clean test asciidoctor bootJar
pc平台去掉最前面的' ./ '' 因为我test.enabled默认配置未disable所以加了加了参数-Dtest.enabled=true
![]( https://www.jgayb.cn/wp-content/uploads/2018/09/1536556407124.jpg )
编译完毕后在build/asciiadoc/html5找到生成的html文件
![]( https://www.jgayb.cn/wp-content/uploads/2018/09/1536558027667.jpg )
用浏览器打开index.html看看,非常漂亮的api文档。
![]( https://www.jgayb.cn/wp-content/uploads/2018/09/1536558184376.jpg )
最后提示spring boot默认指定static为静态资源目录,如有修改需要重新配置gradle的bootJar,将into
改成自定义的目录
bootJar { dependsOn asciidoctor from ("${asciidoctor.outputDir}/html5") { into 'static/public/docs' } }