构建下一代 HTTP API - OpenAPI spec 和解析器

2021-01-04 15:03:55 浏览数 (1)

程序员的主要工作就是不断地构建解析器,把一种数据转化成另外一种数据。- 程序君

这是我在很多文章中都阐释过的一个观点。你可以在我之前的文章回顾这一观点:

  • 如何愉快地写个小parser
  • 抽象的能力

为什么 Parser 如此重要?

在 抽象的能力 一文结尾的地方,我简单谈到了做 feed 的一些心得。当时我接手这个工作的时候,之前的工程师已经留下了好几万行 php 代码,这些代码处理几十个来自不同厂商的 feed,把里面的内容提取出来存在数据库中。因为 feed 的格式不尽相同,有 XML,有 JSON,同样表述一个数据,大家的字段名有时也不太一样,比如同样是 video url,有的叫 url,有的叫 media_url,它们在 XML/JSON 里所处的层级也不尽然相同。所以之前的代码为每个 feed 写了一个类。有新需求(比如新的 feed)时,找一个最类似的代码,copy & paste,然后在好几百行粘贴出来的代码中根据差异一点点修改,最终形成新的 feed 的处理代码。

估计大部分程序员在处理这样的问题时,都会采用这样的解决问题的路径。它直白,不易出错。

然而这代码的可维护性很糟糕,并且,随着时间的推移,它会越来越冗长,当 feed 更新其结构时,往往意味着开发者要从某个 class 里的好几百行甚至上千行代码中找到要修改的地方,然后逐一修改,非常费时。

我接手后做的处理是,定义一个简单的 feed parsing language,把我们希望从各个 feed 获取的数据的最终形态定义出来,然后每一个域声明如何从 feed 里取出这个域的值的类似 xpath 的表达式,比如:

代码语言:javascript复制
{
    "video_name": "name",
    "description": "longDescription",
    "video_source_path": ["renditions.#max_renditions.url", "FLVURL"],
    "thumbnail_source_path": "videoStillURL",
    "duration": "length.#ms2s",
    "video_language": "",
    "video_type_id": "",
    "published_date": "publishedDate",
    "breaks": "",
    "director": "",
    "cast": "",
    "height": "renditions.#max_renditions.frameHeight",
    "width": "renditions.#max_renditions.frameWidth"
}

这里面,video_name 是最终我们需要的域的名字,表达式 name 指从 feed 当前位置找名为 name 的域,然后将其值取出来,放入 video_name 的值。而像 video_source_path 这样对应的表达式是一个数组,则表明数据依次从数组里描述的路径去取,取到直接返回,取不到继续往后走。为了便于扩展,这里面还允许自定义函数,比如 #max_renditions 指以当前的值(renditions)去调用函数 max_renditions。所以整个表达式是说:

  1. 先找 renditions,取出里面的值交给 max_renditions 函数运行,得到一个对象,从这个对象里取 url
  2. 如果找不到,从当前位置取 FLVURL

当我们定义了这样一种语言去描述我们如何从 feed 里获取想要得到的数据时,剩下的问题就是:

  1. 写一个 parser,能够处理这个语言
  2. 使用我们定义的语言为每个 feed 撰写这样一个配置

最后,我们把问题精简成 1 个 parser X 个配置文件,代码量也就是几百行。而配置文件,培训一下几乎是个程序员就能写出来。其结构如下:

日后的维护就是如何去扩充 parser 的一些辅助性函数,以及为新的 feed 撰写配置。

如果你看懂了这个例子,就会发现:很多问题都可以化简为定义一个承上启下的中间语言(DSL),然后为其构建一个 parser。

而 OpenAPI,恰恰是这样一个在 API 客户端和 API 服务器之间的中间语言。我们利用好它的程序属性,可以做很多自动化(客户端代码生成,服务端代码生成,服务端测试生成,etc.)。

OpenAPI spec 简介

Open API spec 3.1.0 全文一万四千字,A4 纸打印的话要八十多页,是个庞大但并不复杂的 spec。要能够通过它来生成代码,第一步我们需要将其正确解析,处理好各种各样的情况,也就是说,我们要为其写一个 parser。而要撰写这样一个 parser,我们首先需要读懂 OpenAPI spec。好在 OpenAPI spec 并不复杂,很容易读。

首先,OpenAPI 所有数据结构的验证都使用 JSON schema(略有扩展),所以这部分我们只要大致了解一下,等具体使用的时候再详细看。JSON schema 有很久的历史了,所以相关的包也很多,各种语言的社区都找得到。在 Quenya 里,我就「暂时」使用了 ExJsonSchema 这个库。

然后,我们关注几大核心对象即可。

Server Object

Server object 在 swagger UI 里很有用,它定义了 API 可以通过什么 API 访问,以及 base path 是什么。比如:http://localhost:4000/api/v1 ,这里声明了 API 运行在 localhost,使用 http访问,端口是 4000,base path 是 /api/v1。这些信息都会被用在 Quenya 生成 API 代码时。比如:

代码语言:javascript复制
servers:
  - url: http://localhost:4000/api/v1
    description: Local dev server
  - url: https://api.todo.mvc/v1
    description: Production server

OpenAPI Spec 不对 Server Object 做任何限制,但在 Quenya 里,至少要声明一个 localhost,Quenya 会用这里的信息来正确生成本地运行的服务器监听的端口以及正确的路由,比如:

代码语言:javascript复制
get("/swagger/main.json", to: SwaggerPlug, init_opts: [app: :todo])
get("/swagger", to: SwaggerPlug, init_opts: [spec: "/swagger/main.json"])
forward "/api/v1", to: Todo.Gen.ApiRouter, init_opts: []

match(_, to: Quenya.Plug.MathAllPlug, init_opts: [])

这个很重要,我们要确保生成的 API 服务可以无需任何修改就能在 swagger 里运行。

Path / Operation object

在任何 API 项目中,最核心的部分就是每条路由及其处理函数的定义。在 OpenAPI spec 中,这是由 Path 以及 Path 内部的 operation 对象定义的。比如如下的 path todo/{todoId} 包含一个 patch operation,它的定义如下:

代码语言:javascript复制
/todo/{todoId}:
  patch:
    operationId: updateTodo
    tags: [todo]
    description: update todo status
    security:
      - token: []
    parameters:
      - name: todoId
        in: path
        required: true
        description: The id of the pet to retrieve
        schema:
          $ref: "#/components/schemas/todoId"
    requestBody:
      description: todo item status
      required: true
      content:
        application/json:
          schema:
            type: object
            properties:
              status:
                $ref: "#/components/schemas/status"
    responses:
      "200":
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/Todo"
      default:
        $ref: "#/components/responses/Error"

这个定义,即便你没有看过 OpenAPI spec 的介绍,也大概能够了解:

  1. 当用户发送 PATCH /todo/{todoId} 时,触发这条路由
  2. 路由使用了名为 token 的 security scheme
  3. 路由中使用了一个 path parameter,叫 todoId,它是必须的,且使用了 #/components/schemas/todoId 这个 schema
  4. 使用该路由时需要传入 content-typeapplication/jsonrequestBody。其它的 content-type 一律应该返回 error。requestBody 的 schema 是 #/components/schemas/Todo
  5. 当 API 成功返回时,它返回 200,其 content 支持 application/json,使用 #/components/schemas/Todo 里定义的 schema
  6. 否则,返回 schema 是 #/components/responses/Error 的 response,response code 可以随意(这里未定义,所以可能是 404,可能是 400,甚至 500)

这个路由使用的名为 token 的 security scheme 定义如下:

代码语言:javascript复制
securitySchemes:
  token:
    type: http
    scheme: bearer
    bearerFormat: JWT

可见,它是一个 bearer JWT token。RFC7235 定义了 http authentication 所支持的 Authorization header。RFC7519 定义了 JWT token。

最后,这样一条 path/operation 的定义,会生成如下的路由:

代码语言:javascript复制
patch("/todo/:todoId",
  to: RoutePlug,
  init_opts: [
    preprocessors: [{Quenya.Plug.JwtPlug, []}, {Todo.Gen.UpdateTodo.RequestValidator, []}],
    handlers: [{Todo.Gen.UpdateTodo.FakeHandler, []}],
    postprocessors: [{Todo.Gen.UpdateTodo.ResponseValidator, []}]
  ]
)

如果我们熟悉了 path/operation object,那么基本上 OpenAPI 主要的 object 都被涉及到了:

  • Header/Parameter/RequestBody/Response object:处理 request header/parameter/body 和 response body/header 的,上例中已出现
  • Media type object:主要是 media type 对应一个 schema,上例中的 application/json 对应的 map 就是。

值得注意的是:

  • 在 OpenAPI 中,很多对象都可以用 $ref 来引用,你可以把 ref 当成一个指针,它指向当前文档(或者其他文档)对应位置的对象。
  • operationId 不是必须的,它是当前 operation 的唯一 ID。然而在 Quenya 里,operationId 必须存在,因为 Quenya 生成代码时需要用它作为对应模块的名字。
  • parameters 可以是在 path,query,还有 header。前面已经说过,所有数据结构的 schema 使用 JSON schema 定义。
  • security 是可选的,requestBody 也是可选的。

Security scheme object

Security scheme 定义了 API 如何使用各种方式来授权 API 的使用,它支持几种类型:

  • http:http auth,见下面的定义。
  • mutualTLS:服务器和客户端做 mutual TLS 的验证(服务器也要验证客户端的 cert 是否合法)
  • oauth2:使用 oauth2
  • openIdConnect:使用 openId

如果 security scheme 类型是 http,那么可以支持下面几种 auth scheme:basic(RFC7617),bearer(RFC6750),digest(RFC7616),scram-sha-256(RFC7804)等。

而对于 Oauth2,支持这些 flow:implicit,password,clientCredentials 和 authorizationCode。这几种 flow 中,authorizationCode 是相对最安全的,而如果要进一步增强安全,可以使用定义于 RFC7636 的 PKCE(Proof Key for Code Exchange)。Quenya 目前还不支持 Oauth2,未来会支持,但为了安全性考虑,计划仅支持 authorizationCode PKCE。

以上的安全手段如果没有合适的,或者你有自己特殊的 security scheme,可以用 OpenAPI 扩展。OpenAPI 的很多 object 都可以添加 x- 开头的扩展域。比如我们要支持 aws 的 lambda authorizer,可以这样设置 security scheme:

代码语言:javascript复制
securitySchemes:
  lambda-authorizer:
    type: apiKey
    name: Authorization
    in: header
    x-amazon-apigateway-authorizer:
      type: request
      identitySource: "$request.header.Authorization"
      authorizerUri: "arn:aws:apigateway:us-west-2:lambda:path/2015-03-31/functions/arn:aws:lambda:us-west-2:xxxxx:function:function-name/invocations
      authorizerPayloadFormatVersion: 2.0
      authorizerResultTtlInSeconds: 300
      enableSimpleResponses: true

其中 x-amazon-apigateway-authorizer 是一个扩展域。

其它

我们只介绍了 OpenAPI 所有 object 的一小部分,但通过这些对象我们已经可以了解 OpenAPI 最核心的功能,如果你想了解 OpenAPI 的全部能力,可以参考 OpenAPI spec 3.1.0。

Quenya 的 OpenAPI parser

Quenya 对 OpenAPI 的主要 object 做了一一映射,在 parser 加载 open API yaml 文件时,会解析整个文档,并生成对应的数据结构。这里面有两个要注意的:

  1. remote ref 的处理。因为 OpenAPI 的结构可以十分松散,一个数据结构的 schema 可以在另外一个文件中定义(remote ref),所以 Quenya 在遇到 remote ref 时,会将其文件名和文件内容缓存在一个字典里,这样在遇到新的 remote ref 时,会先看看这个文件是否已经缓存,避免多次读取相同的文件。另外, Quenya 会将读过的 remote ref 转成 local ref,以方便处理。
  2. local ref 的处理。OpenAPI spec 为了复用,所以到处可以有 ref 这样的指针指来指去,但这样在后续处理时会很不方便,我们希望任何一个数据结构都包含所有信息。所以我们会把遇到的所有的 local ref 都展开,将其内容拷贝到 ref 所在的地方。这样,大大方便了后续的处理。

Quenya parser 处理过的 spec 是一个 OpenApi 对象,里面包含所有 Quenya 关心的 objects:

之后的代码生成,都围绕着这个对象进行。

为什么不生成一个 IR/AST?

目前 Quenya 还没有开始构建客户端代码生成的部分,而实现服务器端代码生成和服务器端测试生成时,现有的数据结构足够使用。随着客户端代码生成工作的进行,也许,一个进一步解析和预处理的 IR/AST 会对生成不同语言的客户端 SDK 有很大的帮助。

Parser 是编译时的工具,为什么生成的 API 项目需要引入 parser?

如果你使用 Quenya 生成了 API 项目,你会发现 parser 是这个项目的依赖。原因很简单,我们不希望生成的代码是一次性的,而是每次开发者修改 spec 或者配置文件,之后都可以使用 quenya 重新生成代码:

代码语言:javascript复制
$ mix compile.quenya

因而,在这个阶段,使用 parser 是必须的。然而,在运行时,你的 API 项目并不需要对 OpenAPI spec 做任何解析。所以,尽管项目中引入了 Quenya parser,它依旧是编译时的工作。

熟悉 Elixir 的朋友也许会问,你都提供一个 Quenya compiler 了,为何不将其集成在生成的项目中,就像 Phoenix framework 的项目那样:

代码语言:javascript复制
compilers: [:phoenix, :gettext]    Mix.compilers()

Quenya 项目也可以:

代码语言:javascript复制
compilers: [:quenya]    Mix.compilers()

原因很简单,我还没有对 Quenya compiler 做优化,目前 compile 一次还是秒级(毕竟根据 API 的多寡,一次可能要生成好几十个文件),并且只要运行 mix compile quenya,就会无脑生成代码。所以我们不希望每次运行 mix compile 就会调用 Quenya compiler 做代码生成。你也许觉得 mix compile 不是经常用,但如果你的 vscode 集成了 Elixir language server,那么几乎每时每刻代码都会被编译(为了代码的自动完成,以及发现代码中的错误),所以这个时候,你的编译器反应会很慢。当然这个未来是可以优化的。

0 人点赞