文章同步在個人博客
以前接口文檔都是用swagger2在線文檔,最近升級為spring boot2 + webflux反應(yīng)式編程后,swagger2不支持webflux,無法使用,因此文檔生成改為官方的spring rest docs,實(shí)踐過程中因?yàn)橛⑽牟徽Φ刈吡艘恍澛?,在這里記錄一下吧。
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測試相關(guān)依賴
}
ext {
snippetsDir = file('build/generated-snippets')
}
test {
outputs.dir snippetsDir
}
asciidoctor {
inputs.dir snippetsDir
dependsOn test
}
//spring boot打jar包的時候?qū)⑸傻膆tml5資源加入
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的相關(guān)字段約束參考官網(wǎng)
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("企業(yè)號應(yīng)用名稱").attributes(key("constraints").value("")),
fieldWithPath("lastUpdatedAt").description("最近登錄時間").attributes(key("constraints").value("")),
fieldWithPath("status").description("狀態(tài)").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("授權(quán)信息").attributes(key("constraints").value("")),
fieldWithPath("accountNonLocked").description("是否沒有被鎖").attributes(key("constraints").value("")),
fieldWithPath("credentialsNonExpired").description("認(rèn)證是否過期").attributes(key("constraints").value("")))));
}
}
跑一下單元測試試試,跑完后看一下build文件夾目錄如下,框框里面就是生成的adoc文件,adoc文件是一種畢markdown更強(qiáng)檔的書寫文檔格式,spring官網(wǎng)文檔和很多大公司的文檔都由其編寫,adoc文檔參考AsciiDoc 語法快速參考

request-fields.adoc和response-fields.adoc默認(rèn)沒有,需要在增加如圖配置。具體內(nèi)容基本相同

request-fields.adoc和response-fields.adoc具體內(nèi)容
|===
|路徑|類型|描述|約束
{{#fields}}
|{{#tableCellContent}}`{{path}}`{{/tableCellContent}}
|{{#tableCellContent}}`{{type}}`{{/tableCellContent}}
|{{#tableCellContent}}{{description}}{{/tableCellContent}}
|{{#tableCellContent}}{{constraints}}{{/tableCellContent}}
{{/fields}}
|===
打開http-request.adoc、http-response.adoc看看,發(fā)現(xiàn)json都是未格式化的,很不美觀。沒關(guān)系改下配置讓其美化一下吧,美化的關(guān)鍵是在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字符串打印輸出
}
/**
* 默認(rèn)就有多種httpMessage消息序列化器,這里自定義json和xml轉(zhuǎn)換器
*
* @return CodecCustomizer 自定義轉(zhuǎn)換器
*/
@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
};
}
}
- 在將配置應(yīng)用到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));
//將自定義的序列化配置應(yīng)用到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的文件,現(xiàn)在根據(jù)asciidoc德與法編寫入口index文件。gradle項(xiàng)目在src目錄下新建docs/asciidoc目錄(maven項(xiàng)目在其他目錄),然后新建兩個文件如下圖,后面跑完腳本會生成對應(yīng)的html頁面

login.adoc內(nèi)容如下,主要是將build/generated-snippets下生成的文件導(dǎo)入進(jìn)來
== *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文件,導(dǎo)入上面的login.adoc,如果還有其他adoc文件也可以都導(dǎo)入,按順序編寫導(dǎo)入
= XXX項(xiàng)目api文檔
Jone Wang;
:toc: left ?
:toc-title: 章節(jié)
:doctype: book
:icons: font
:source-highlighter: highlightjs
include::login.adoc[]
? 目錄在左側(cè)
現(xiàn)在執(zhí)行g(shù)radle命令編譯
./gradlew -Dorg.gradle.daemon=false -Dtest.enabled=true clean test asciidoctor bootJar
pc平臺去掉最前面的'./'' 因?yàn)槲襱est.enabled默認(rèn)配置未disable所以加了加了參數(shù)-Dtest.enabled=true

編譯完畢后在build/asciiadoc/html5找到生成的html文件

用瀏覽器打開index.html看看,非常漂亮的api文檔。

最后提示spring boot默認(rèn)指定static為靜態(tài)資源目錄,如有修改需要重新配置gradle的bootJar,將into
改成自定義的目錄
bootJar {
dependsOn asciidoctor
from ("${asciidoctor.outputDir}/html5") {
into 'static/public/docs'
}
}