转载

原 荐 Spring Rest Docs WebTestClient自动生成接口文档Gradle版

文章同步在 个人博客

以前接口文档都是用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'  
    }  
}
原文  https://my.oschina.net/junjunyuanyuankeke/blog/2046114
正文到此结束
Loading...