美文网首页DTeam团队日志python
Spec Driven API开发思想在Grails REST工

Spec Driven API开发思想在Grails REST工

作者: 冯宇Ops | 来源:发表于2019-03-29 23:39 被阅读118次

    前言

    在前后台分离的开发中,后端需要维护一套REST API。在开发中API少不了要有变更。在传统的开发中,通常我们是需要手写大量的API说明文档交给前端开发,或者暴露出去交给第三方开发者使用。编写说明文档需要耗费大量的精力,并且有更新不及时和错漏的问题,而且如果API量一大又很难发现,给使用者造成困扰。如果连API文档都没有,只靠口头交流,那么效率低下可想而知,大量的时间都将花费在前后端开发在交流API的用法上。而本文我们将讨论如何一步步改善这种做法,让API的编写变得更及时更有效。

    API的规范说明书(API Specification)

    在早年的开发的时候,我们在开发完服务端的时候,通常都会手写一份API文档交给用户,告诉他们怎么使用,有哪些接口,请求参数是什么?响应参数是什么等等,比如像下面这样的:

    曾经的API文档

    幸运的是,借助Markdown, Asciidoc这一类的轻量级标记语言,我们不需要完完整整的写HTML这么麻烦,配合上比较通用的REST接口请求描述,也能像模像样的写出来这样一份API说明文档交给用户。

    但是总这么写也不是事啊,这样一份文档整理出来,也要花不少时间,而且可能存在错漏。对于一个已完成的项目还好说,对于一个还在开发中的项目来说,非常容易造成更新不及时的问题,而且更新也比较费时费力。不同人写出来的文档还可能风格不统一。

    由于Restful的风格有一些列通用的特点,基于此,市面上诞生了一系列API规范,用简单的配置或语言(通常是JSON和YAML)描述API的特征,我们称之为API Specification。这些规范的好处是人类易读易理解,机器可读可解析,便于转换为多种统一规范的格式。以后我们只需要写个简单的规范说明书,剩下的交给机器去干就可以了。

    目前市面上流行的几种规范主要有RAML, OpenAPI, API Blueprint等。简单的互比参考: https://modeling-languages.com/modeling-web-api-comparing/

    最终我们基于流行度和工具支持情况来看,最终选择了OpenAPI 3作为我们的API规范。

    OpenAPI 3

    OpenAPI规范定义了一个标准的,语言无关的Restful API描述说明。OpenAPI 3.0规范基于Swagger 2.0规范改进而来,在Swagger 2.0的基础上扩充了大量的新特性。OpenAPI 3支持JSON和YAML配置格式。由于YAML相比JSON更易读,因此下面我们都用YAML配置。

    OpenAPI 3的教程详见Swagger教程和OpenAPI 3规范官方文档,以及这个教程

    参考OpenAPI 3的规范,我手写了一份登录接口和刷新JWT接口的描述:

    openapi: 3.0.2
    info:
      title: 我的测试
      description: 获取当前项目的API
      version: '1.0'
    servers:
      - url: 'http://locahost'
        description: 项目服务器地址
    paths:
      /api/login:
        post:
          tags:
            - login
          summary: 登录
          description: 用户登录接口
          requestBody:
            content:
              application/json:
                schema:
                  type: object
                  required:
                    - username
                    - password
                  properties:
                    username:
                      type: string
                    password:
                      type: string
                  example:
                    username: '17711111111'
                    password: '888888'
          responses:
            '200':
              description: 登录成功
              content:
                application/json:
                  schema:
                    $ref: '#/components/schemas/loginSuccess'
            '401':
              $ref: '#/components/responses/UnauthorizedError'
      /oauth/access_token:
        post:
          tags:
            - login
          summary: 刷新JWT
          description: 刷新JWT的接口
          requestBody:
            content:
              application/x-www-form-urlencoded:
                schema:
                  type: object
                  required:
                    - grant_type
                    - refresh_token
                  properties:
                    grant_type:
                      type: string
                      description: 必填(refresh_token)
                    refresh_token:
                      type: string
                      description: 之前登陆成功返回内容中的refresh_token字段
                example:
                  grant_type: refresh_token
                  refresh_token: eyJlbmMiOiJBMTI4R0NNIiwiYWxnIjoiUlNBLU9BRVAifQ.xxxx.xxxx
          responses:
            '200':
              description: 刷新JWT成功
              content:
                application/json:
                  schema:
                    $ref: '#/components/schemas/loginSuccess'
            '401':
              $ref: '#/components/responses/UnauthorizedError'
      /api/self:
        get:
          security:
            - bearerAuth: []
          tags:
            - user
          summary: 用户信息
          description: 获取用户个人信息
          responses:
            '200':
              description: 成功获取个人信息
              content:
                application/json:
                  schema:
                    $ref: '#/components/schemas/userDetailInfo'
    components:
      schemas:
        userSimpleInfo:
          type: object
          required:
            - id
            - username
            - displayName
          properties:
            id:
              type: integer
              description: 用户id
              example: 1
            username:
              type: string
              description: 用户登录手机号
              example: 13500000001
            displayName:
              type: string
              description: 用户昵称
              example: 我的昵称
        userDetailInfo:
          allOf:
            - $ref: '#/components/schemas/userSimpleInfo'
            - type: object
              properties:
                dateCreated:
                  type: string
                  format: date-time
                  description: 用户创建时间
                  example: '2018-11-01T00:00:00'
                passwordExpired:
                  type: boolean
                  description: 密码是否过期
                  example: false
                rate:
                  type: integer
                  description: 用户排名
                  example: 10
        loginSuccess:
          type: object
          required:
            - username
            - roles
            - userId
            - displayName
            - token_type
            - access_token
            - expires_in
            - refresh_token
          properties:
            username:
              type: string
              description: 用户名
              example: '17711111111'
            roles:
              type: array
              description: 角色
              items:
                type: string
                enum:
                  - ROLE_ADMIN
                  - ROLE_KF
                  - ROLE_BUYER
                  - ROLE_SELLER
              example:
                - ROLE_BUYER
                - ROLE_KF
            userId:
              type: integer
              description: 用户id
              example: 1
            displayName:
              type: string
              description: 展示名
              example: 我的用户
            token_type:
              type: string
              default: Bearer
              description: JWT类型(默认Bearer)
              example: Bearer
            access_token:
              type: string
              description: JWT主体内容
              example: eyJlbmMiOiJBMTI4R0NNIiwiYWxnIjoiUlNBLU9BRVAifQ.xxxx.xxxx
            expires_in:
              type: integer
              default: 3600
              description: 过期时间
              example: 3600
            refresh_token:
              type: string
              description: 刷新token,用于访问refresh接口
              example: eyJlbmMiOiJBMTI4R0NNIiwiYWxnIjoiUlNBLU9BRVAifQ.xxxx.xxxx
      responses:
        UnauthorizedError:
          description: JWT未发送、非法,或已过期
      securitySchemes:
        bearerAuth:
          type: http
          scheme: bearer
          bearerFormat: JWT
          description: 通过*login*接口获取到的*access_token*字段
    

    不需要像以前手写markdown那样写一堆,我们只写一个这样的配置文件就行了。这样我们在很多支持OpenAPI 3的UI或工具中就可以预览、转换,或直接生成更完整的html文档,甚至有些服务商还提供根据OpenAPI 3规范直接生成Mock服务器的功能。比如在Swagger Editor就可以直接预览:

    Swagger Editor预览

    stoplight.io中预览的效果如下:

    stoplight.io

    明显可以看到这种方式产生的API文档比手写的交互性更强,表现形式更丰富(比如Swagger Editor支持直接生成vertx服务端源码,stoplight.io支持生成客户端片段代码等功能),而且文档风格统一。

    使用代码生成OpenAPI 3规范

    上述例子可以看到使用统一的API规范好处非常大。但是上述例子仍没有摆脱手写的弊端——工作量太大、缺乏验证、修改之后容易忘记手工同步。因此就有些人想出了一些办法了,能不能在开发阶段中就产生对应的接口文档呢?这样不但不容易错漏,而且容易修改,由机器生成,避免了全手工书写API Specification造成的错漏。于是Swagger项目就在这个方向上大放光彩。

    对于spring boot项目可以直接使用springfox项目,进一步减少手写量,可以参考官方Demo

    在Application入口初始化Bean,在controller上直接加入注解,在项目编译的时候就可以产生对应的API配置。减少了手写的错漏,一定程度上也可以减少很多工作量。

    Spec Driven API Development

    上述在业务代码中通过注解生成OpenAPI规范的做法是很多人容易想到的,也是容易上手使用的一种方式。但仍然没有解决以下的问题:

    • 缺乏测试
    • 在功能代码中夹杂着太多的OpenAPI生成部分的配置和注解。这些代码与功能无关
    • 大量的注解跟代码注释有同样的弊端——没人想到维护,愿意维护了

    比如这样的:

    @Path("/{userName}")
    @Produces(MediaType.APPLICATION_JSON)
    @ApiOperation(value = "Returns user details", notes = "Returns a complete list of users details with date of last modification.", response = User.class)
    @ApiResponses(value = {
     @ApiResponse(code = 200, message = "Successful retrieval of user detail", response = User.class),
     @ApiResponse(code = 404, message = "User does not exist"),
     @ApiResponse(code = 500, message = "Internal server error")}
    )
    public Response getUser(@ApiParam(name = "userName", value = "Alphanumeric login to the application", required = true) @PathParam("userName") String userName) {
     ...
    }
    

    功能代码被淹没在大量的注解当中,和大段注释一样,最终就成了代码垃圾,徒增产品包容量而已。

    于是有另一种思想产生了: Spec Driven API Development

    这种实践的一些理论基础可以参见这篇文档,写的比较好,有比较详细的论述: https://dzone.com/articles/api-best-practices-plan-your-0

    这种思想与上述注解方案的区别在于:

    • 不使用任何注解,不在功能代码中产生API文档
    • 通过集成测试阶段反向生成API文档,保证每个生成的API接口文档都是经过测试的

    这样就不会因为要生成OpenAPI而在产品打包编译过程中引入额外的依赖,自然也不需要在功能代码中引入很多奇奇怪怪的配置。而且是跑在集成测试中,通过集成测试中接口的请求和响应产生OpenAPI文档,这样可以最大限度的保障产生的API文档都经过测试的,接口修改之后也能第一时间在测试中发现问题,及时修改接口文档。

    Sping全家桶中就有一个Spring REST Docs项目,基于这种思想理论开发的项目。

    Spring REST Docs简介

    官方就有Grails使用的范例,详细的使用看范例就好了,这里做一下简单的说明。

    Spring REST Docs期望用户手写一部分文档(使用Asciidoc或Markdown,官方推荐首选Asciidoc),将接口请求和响应部分使用模板引用替代。在集成测试结束后,Spring REST Docs将生成这部分的asciidoc文档,填充到你的手册部分,这样整个文档就完成了。 效果类似于这样的:

    Spring REST Docs

    Spring REST Docs当前(2.0以上版本)支持的REST client有三个Spring MVC’s test framework,Spring WebFlux’s WebTestClientREST Assured 3

    在集成测试中初始化任意一个client之后,在测试中产生对应API描述文档的范例代码类似于这样(Spring MVC test framework):

    private MockMvc mockMvc;
    
    @Autowired
    private WebApplicationContext context;
    
    @BeforeMethod
    public void setUp(Method method) {
        this.mockMvc = MockMvcBuilders.webAppContextSetup(this.context)
                .apply(documentationConfiguration(this.restDocumentation))
                .build();
        this.restDocumentation.beforeTest(getClass(), method.getName());
    }
    
    this.mockMvc.perform(get("/").accept(MediaType.APPLICATION_JSON)) 
        .andExpect(status().isOk()) 
        .andDo(document("index")); 
    

    测试通过就会根据客户端的测试处理,生成文档片段。

    Spring REST Docs API specification Integration

    如果理解了Spring REST Docs的套路,让我们再更进一步。很容易发现Spring REST Docs在使用前还得先写一堆类似于README的标记型文档,恐怕没几个程序员喜欢写这么一大段README一样的东西。而且生成的也不是OpenAPI 3 Spec,而是标记文档,基本只能进一步转换成html或者PDF,不像OpenAPI那样还能在UI中进行丰富的交互。因此,诞生了Spring REST Docs API specification Integration这个项目。

    该项目基于Spring REST Docs,在此之上进行封装,不是生成Asciidoc或Markdown,而是生成OpenAPI 2, OpenAPI 3, Postman的Spec。

    我们的grails-rest-seed项目就使用了这个插件产生OpenAPI文档。比如产生获取阿里云OSS上传签名的文档部分代码如下(使用REST Assured 3测试):

    package top.dteam.earth.backend.operation
    
    import com.epages.restdocs.apispec.ResourceSnippetParameters
    import grails.gorm.transactions.Rollback
    import grails.testing.mixin.integration.Integration
    import io.restassured.builder.RequestSpecBuilder
    import io.restassured.http.ContentType
    import io.restassured.specification.RequestSpecification
    import org.junit.Rule
    import org.springframework.restdocs.JUnitRestDocumentation
    import org.springframework.restdocs.payload.FieldDescriptor
    import spock.lang.Specification
    import top.dteam.earth.backend.user.User
    import top.dteam.earth.backend.utils.TestUtils
    
    import static com.epages.restdocs.apispec.ResourceDocumentation.headerWithName
    import static com.epages.restdocs.apispec.ResourceDocumentation.resource
    import static com.epages.restdocs.apispec.RestAssuredRestDocumentationWrapper.document
    import static io.restassured.RestAssured.given
    import static org.springframework.http.HttpHeaders.AUTHORIZATION
    import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint
    import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath
    import static org.springframework.restdocs.restassured3.RestAssuredRestDocumentation.documentationConfiguration
    
    // TODO: 写一个BaseApiDocSpec模板类或trait,自动初始化REST Assured
    @Integration
    @Rollback
    class AliyunOSSApiDocSpec extends Specification {
    
        @Rule
        JUnitRestDocumentation restDocumentation = new JUnitRestDocumentation()
    
        private RequestSpecification documentationSpec
    
        FieldDescriptor[] response = [
                fieldWithPath("accessKeyId").description("OSS的access key id")
                , fieldWithPath("policy").description("OSS的权限矩阵")
                , fieldWithPath("signature").description("OSS认证成功后的签名")
                , fieldWithPath("dir").description("有权限上传的目录")
                , fieldWithPath("host").description("OSS访问主机")
                , fieldWithPath("expire").description("授权过期时间")
                , fieldWithPath("cdnUrl").description("用于外部访问的CDN URL(可空)").optional()
        ]
    
        void setup() {
            this.documentationSpec = new RequestSpecBuilder()
                    .addFilter(documentationConfiguration(restDocumentation)
                            .operationPreprocessors().withResponseDefaults(prettyPrint()))
                    .setPort(serverPort)
                    .build()
            TestUtils.initEnv()
        }
    
        void cleanup() {
            TestUtils.clearEnv()
        }
    
        void '所有登录用户均有权限获取上传权限 - apidoc'() {
            setup:
            User.withNewTransaction {
                TestUtils.createUser('ROLE_ADMIN', '13500000001')
            }
            String jwt = TestUtils.login(serverPort, '13500000001', '13500000001')
    
            expect:
            given(this.documentationSpec).accept(ContentType.JSON)
                    .filter(document("getUploadAuthority"
                            , resource(ResourceSnippetParameters.builder()
                            .summary('获取阿里云OSS上传权限')
                            .description('获取阿里云OSS的上传权限的签名字符串及目录等配置,具体用法请参考[服务端签名后直传](https://help.aliyun.com/document_detail/31926.html)')
                            .responseFields(response)
                            .requestHeaders(headerWithName(AUTHORIZATION).description('JWT'))
                            .tags('operation')
                            .build())))
                    .header(AUTHORIZATION, "Bearer ${jwt}")
                    .when().get("/api/getUploadAuthority")
                    .then().assertThat().statusCode(200)
        }
    }
    

    为了不影响以前的功能测试,单独产生一个独立的测试运行类AliyunOSSApiDocSpec专门用于产生OpenAPI 3的文档。最终产生的OpenAPI 3 Spec如下:

    openapi: 3.0.1
    info:
      title: Grails-rest-seed API
      description: Grails-rest-seed API文档
      version: "1.0"
    servers:
    - url: http://localhost:8080
    tags:
    - name: operation
      description: 后台操作部分的相关接口
    paths:
      /api/getUploadAuthority:
        get:
          tags:
          - operation
          summary: 获取阿里云OSS上传权限
          description: 获取阿里云OSS的上传权限的签名字符串及目录等配置,具体用法请参考[服务端签名后直传](https://help.aliyun.com/document_detail/31926.html)
          operationId: getUploadAuthority
          parameters:
          - name: Authorization
            in: header
            description: JWT
            required: true
            schema:
              type: string
            example: Bearer eyJhbGciOiJIUzI1NiJ9.eyJwcmluY2lwYWwiOiJINHNJQUFBQUFBQUFBSldTdjI4VE1SVEhmYUZWS2lKQml3UVNRMWtvRWtMSW9WUk1tZm9MQkxxbWlKQ2xTQ0RuXC9IcDE2N01QMjlmbUZwUUpoZzVCUUNVa1Joajdsd0FMZndDQ2diVnpKeVNlTDJrdm1TcTgzTmwrN1wvTjk3K3QzZUVRbXJTRzNuRTRwZDhBU0NzeTRMZHBoMFE0b1RqTUxocTdsYmZ5c2dHTkNXdVArM3Z4NjNQOWNJVUZJYWx6WVZMSzh5Ukp3NUZLNHpYWlpYVElWMTF2T0NCVTN1b1lzeE1ibjBWUm1zVkRVcHY3Q1FwUVo0ZkpDZ0FcL0k5RUVSNkxYSVlBV0ZTRVh3Y2ZaNlp4c2k1OWwzdFltSHhFMkROZXhwczBOUDJaRTJNQ1pRb29NdkZWTGRJRE1zaW5TbVhGT3IxVzRxRFBBTk1sMmVoUnBkd0tQTEVkNkFjb0pKT3hwYUJjVTZFamdhd1RLM3BWRlZnSFhrNHFEWXpBbFpiNEZyaEdRcVpkWmlkZndsZWVWN212SmxLYXk0MkhmVEFCZSt3dzJmUjMwZVhkWlNZcGRDS3p2WFZvbm1ZbE40TWVUMVp0XC85NkhcL3F0U3VFb0FlM3o4NHB6Njh1a2Q2MzU4ZlhDbU9EeUpFckk2V1dZWTF1aXRYTWxPU25Ccnp5ejQrUDN4OGN2WGwyRHBWOXhQM1wvOTM5dWNlaFV2cXlUbEJubTlNaWJJSFp2d3Y4amZPbHMrSW5yT1cySkpKV0FFNlFjOEZPSkVvenRUaGd0VFwveDI1UHlUOVhEMXhlTEsyc09tMzlibUYrN2RHYXg1MUw1UXRPNm5qWVlhNTNqXC96OXZ2XC9ldVwva1BPSVRPNHltUUU2UDEwR05iT2tBK2IxNGNGczdjUHZcL2FLUDRReFh4OW5cL0FQckFjVUZ4QXdBQSIsInN1YiI6IjEzNTAwMDAwMDAxIiwicm9sZXMiOlsiUk9MRV9BRE1JTiJdLCJpc3MiOiJTcHJpbmcgU2VjdXJpdHkgUkVTVCBHcmFpbHMgUGx1Z2luIiwiZXhwIjoxNTUzNzg3MDAwLCJpYXQiOjE1NTM3ODM0MDB9.HOfTfxF519uhAhewNH2_5KbQOxfBlZucOWhsXZc_88w
          responses:
            200:
              description: "200"
              content:
                application/json:
                  schema:
                    $ref: '#/components/schemas/api-getUploadAuthority2026114897'
                  examples:
                    getUploadAuthority:
                      value: "{\r\n  \"accessKeyId\" : \"mock\",\r\n  \"policy\" : \"\
                        eyJleHBpcmF0aW9uIjoiMjAxOS0wMy0yOFQxNDozNTowNi4zMTFaIiwiY29uZGl0aW9ucyI6W1siY29udGVudC1sZW5ndGgtcmFuZ2UiLDAsMTA0ODU3NjAwMF0sWyJzdGFydHMtd2l0aCIsIiRrZXkiLCIxMzUwMDAwMDAwMSJdXX0=\"\
                        ,\r\n  \"signature\" : \"JRQ9/xJ2aGAsQ5D2vh8IRFWh29I=\",\r\n \
                        \ \"dir\" : \"13500000001\",\r\n  \"host\" : \"https://mock.oss-cn-hangzhou.aliyuncs.com\"\
                        ,\r\n  \"expire\" : 1553783706311,\r\n  \"cdnUrl\" : \"mock\"\r\
                        \n}"
          security:
          - bearerAuthJWT: []
    components:
      schemas:
        api-getUploadAuthority2026114897:
          type: object
          properties:
            accessKeyId:
              type: string
              description: OSS的access key id
            signature:
              type: string
              description: OSS认证成功后的签名
            cdnUrl:
              type: string
              description: 用于外部访问的CDN URL(可空)
            expire:
              type: number
              description: 授权过期时间
            host:
              type: string
              description: OSS访问主机
            dir:
              type: string
              description: 有权限上传的目录
            policy:
              type: string
              description: OSS的权限矩阵
      securitySchemes:
        bearerAuthJWT:
          type: http
          scheme: bearer
          bearerFormat: JWT
    

    在Swagger Editor中也能预览:

    image.png

    总结

    本章节中我们总结了一些REST服务端在开发中产生API文档的一些实践,以及一种新的开发思路Spec-Driven Development。并且在实际的项目中成功运用这种思路解决实际的问题,大大提高了实际开发的效率。

    相关文章

      网友评论

        本文标题:Spec Driven API开发思想在Grails REST工

        本文链接:https://www.haomeiwen.com/subject/miqdbqtx.html