Meta API —— 契约式的开发框架

395 阅读10分钟

这篇文章是为了推广我新写的一个框架:meta-api.

在前后端开发人员之间相互打交道的时候,你是否会遇到以下的问题:

  1. 后端给出的 API 接口没有文档;
  2. 后端给出的 API 接口有文档,但与实现总会出现不一致的情况;
  3. 后端人员编写 API 文档特别费劲,通常是一边编写文档,一边编写实现,来回倒腾,重复定义。

一段契约开发之旅

Meta API 框架就是为了解决这个问题的。它用宏语言的形式开发,好像是在写文档,其实相关逻辑已经实现了。

比如,你可以像下面这样定义一个接口:

# 代码
post '/users' do
  title '创建一个用户'
  params do
    param :name, type: 'string', description: '姓名'
    param :age, type: 'integer', description: '年龄'
  end
  status 200 do
    expose :id, type: 'integer', description: 'ID'
    expose :name, type: 'string', description: '姓名'
    expose :age, type: 'integer', description: '年龄'
  end
end

虽然没有加入任何业务实现,但 API 文档相关的逻辑都已经实现了。

参数的过滤和验证

首先,你会获得一个参数字段的自动过滤,它会将你的参数传递中多余的字段过滤出去,只保留 name 和 age 两个字段。这有什么好处呢?这可以保证你的数据不被无意间篡改。

例如,前端传递这样一个参数格式:

// json
{
  "name": "Jim",
  "age": 18,
  "created_at": "2023-03-08 08:00:00",
  "updated_at": "2023-03-08 08:00:00"
}

注意参数中传递了多余的 created_at 和 updated_at 字段,如果没有参数过滤这两个字段会被前端传递的参数无意间篡改掉,令人困惑。

除了过滤参数之外,以上定义还包括了类型验证的逻辑。我们看到 name 字段为 string 类型,age 字段为 integer 类型。首先,它会尝试作一次类型转化。假如前端提供了如下的数据:

// json
{
  "name": 18,
  "age": "18"
}

由于类型转化起作用,后端实际获取到的参数将是:

// json
{
  "name": "18",
  "age": 18
}

只有当类型转化失败时,才会向前端报错。例如以下的数据是无法通过的:

// json
{
  "name": "Jim",
  "age": "x18" // 转化失败,age 并不是一个数字类型
}

渲染也有过滤和验证

你看到的这一行代码:status 200 do ... end,它告诉我们,这个接口将返回一个状态码为 200 的 HTTP 消息体,并且规定了消息体的字段和类型。

同参数验证,渲染也会有类型转化和参数验证,这对帮助我们开发很有用。但由于渲染的数据一般来说比较可信和规整,因此类型转化和参数验证这两个步骤不是必要的。因此,框架提供了一个选项,可在渲染时关闭验证。

代码的实现和文档能够保持一致

我们在定义 name 参数时,除了提供它的类型之外,还多加了一个选项 description: '姓名',这个有什么用呢?可以明确的一点是,这个对框架的执行没有起任何作用,但可以在生成文档时提供了描述说明。你可以看到下一张截图生成的效果:

编辑

添加图片注释,不超过 140 字(可选)

框架生成的文档格式为标准的 OpenAPI/Swagger 3.0 格式,因此它可以在任何支持 OpenAPI/Swagger 的视图上显示。如果你懒得去找,也懒得自己搭建,可以直接使用我的:

openapi.yet.run/playground

在输入框内输入文档的地址即可预览。

提示:如果想在 Chrome 浏览器内预览本地地址(localhost),请先在 Chrome 内禁用 Block insecure private network requests 选项。

使用实体合并参数和渲染时的写法

还有一个问题,那就是我们发现参数定义的 name 、age 字段和响应实体定义的 name、age 字段,同样的内容我们写了两次,这着实是一种不必要。我们可以将字段放到一个实体里,然后到处引用:

# 代码
class UserEntity < Meta::Entity
  property :id, type: 'integer', description: 'ID', param: false
  property :name, type: 'string', description: '姓名'
  property :age, type: 'integer', description: '年龄'
  property :password, type: 'string', description: '密码', render: false
end

注意我们将 User 模块能用到的参数和字段都放在同一个实体。另外我们注意 param: false 和 render: false 这两个选项,它们表示不可作参数和不可再响应中返回。这符合我们的要求。

然后,接口定义中只需要引用即可:

# 代码
post '/users' do
  title '创建一个用户'
  params do
    param :user, using: UserEntity
  end
  status 200 do
    expose :user, using: UserEntity
  end
end

注意与第一个接口有个稍稍不一致的地方,我们将参数和响应实体都包裹在一个 user 字段内部,这是我最常用的风格,并且也推荐你这么用。它的一个好处是,在你增加字段时会更从容,你可以将额外字段放在 user 字段的外部。

// 请求接受的参数(JSON)
{
  "user": {
    "name": "Jim",
    "age": 18,
    "password": "123456"
  }
}// 响应返回的实体(JSON)
{
  "user": {
    "id": 1,
    "name": "Jim",
    "age": 18
  }
}

其他的一些能力

这里列举了一些其他的能力。Meta 的一些隐藏技能挺多的,开发中都能够用得上,我这里只列举其中一部分。更多更详情的用法,可参考教程

如果你觉得这一节有点烦,可跳过去阅读下一部分。也可以阅读完全文后再回过来阅读这一部分。

多态的支持

现在举一个有关多态实体的例子,这是最近开发出来的。目前 Grape 等框架还缺失对多态实体的支持。

OpenAPI 文档定义中是将多态作为 oneOf 选项暴露出来的。Meta 框架应用了这一点,让属性可使用动态的实体类型:

property :animal, using: {
  one_of: [CatEntity, DogEntity, PigEntity], # 仅用于文档生成
  resolve: ->(value) { value.animal_type.constantize } # 根据 `animal_type` 的值返回实体类型
}

举例:

{
  "animal_type": "CatEntity",
  "animal": { ... } // 使用 CatEntity 渲染
}

利用 scope 编写不同场景下的接口

当我们想写接口时,同样的实体不同场景下需要返回不同的字段。比如,列表页接口,我们希望返回一个概要数据:

[
  { id, title, abstract }
]

而对于详情接口,我们希望返回完整的数据:

{ id, title, content }

那么以何种思路来控制呢?我们可以这样定义一个实体:

class ArticleEntity < Meta::Entity
  property :id
  property :title
  property :abstract, scope: 'abstract', 
                      value: ->(parent) { parent.content.substr(0, 100) } # 取前 100 个字符作为概要
  property :content, scope: 'full'
end

列表页和详情页我们都可以引用同一个实体,在调用时传递不同的 scope 加以控制:

get '/articles' do
  title '获取文章列表'
  status 200 do
    expose :articles, type: 'array', using: ArticleEntity
  end
  action do
    render :articles, articles, scope: 'abstract'
  end
end

get '/article' do
  title '获取文章详情'
  status 200 do
    expose :article, using: ArticleEntity
  end
  action do
    render :article, article, scope: 'full'
  end
end

或者我们可以在定义时将 scope 锁住,这样调用时就不用加以区分了:

get '/articles' do
  title '获取文章列表'
  status 200 do
    expose :articles, type: 'array', using: ArticleEntity.lock_scope('abstract')
  end
  action do
    render :articles, articles
  end
end

get '/article' do
  title '获取文章详情'
  status 200 do
    expose :article, using: ArticleEntity.lock_scope('abstract')
  end
  action do
    render :article, article
  end
end

我用 Ruby 开发的这个框架

也许你在看了上面的介绍后能够理解它表达的意思,但在接触到细节时会显得云里雾里。这是因为以上的代码的示例是用 Ruby 语言编写的。

如果你熟悉 Ruby 语言,可跳过这一章节。

为什么使用 Ruby 语言来编写这个框架呢?我对比了一下其他的语言,发现能够表现成这种 宏命令 格式的,Ruby 是实现起来最方便的语言之一。而国内比较流行的 Java 语言,通过注解定义很难表现出这样的效果。因此,我最终使用了 Ruby 语言作为我这套框架的实现。

如果你使用 Java 语言,也可以将本框架通过 JRuby 作为前端,内部的业务代码仍然用 Java 语言编写。

给想快速尝试的人看的

不管你有无 Ruby 语言的经验,都可以通过以下步骤看到框架生成的效果。

准备工作

首先,你需要安装 Ruby 3.0 版本及以上。安装后如果执行 ruby -v 和 bundle -v 命令成功,说明安装成功。

启动项目

请依次执行如下命令,确保每一条命令都能成功。

# 克隆项目
$ git clone https://github.com/yetrun/web-frame-example.git# 安装 Gems
$ bundle install# 准备数据库
$ bundle exec rake db:setup# 执行单元测试
$ bundle exec rspec# 启动服务器
$ bundle exec rackup
​
启动服务器后通过以下路径访问资源:
​
    API: http://localhost:9292/*.
    OpenAPI 文档:http://localhost:9292/api_spec.
​

打开文档

访问 openapi.yet.run/playground,… http://localhost:9292/api_spec,再点击“提交”按钮。

如果你是 Chrome 浏览器,还要允许 localhost 跨域访问。方法是在 Chrome 内打开 chrome://flags/#block-insecure-private-network-requests,将 Block insecure private network requests. 选项置为 “Disable”.

与 Ruby 其他的框架有哪些不同

这一节仅给熟悉 Ruby 语言的开发者阅读

与 Rails 的比较

在 Ruby 领域,最负盛名的开发框架是 Ruby on Rails(以下简称 Rails)。Rails 是一个全栈框架,可以快速地开发-应用和原型。

Meta 框架与 Rails 框架最大的不同,在于 Rails 是一个全栈框架,而 Meta 是一个纯 API 框架。如果你是要单人橹一整个系统,或者你的团队的人员都是精通前后端的,能够熟练地使用 Rails 框架开发将是妥妥的优势。在这种情况下我就不再建议你使用 Meta 框架,因为它提供的文档生成和实现一致性对你而言没有作用。

然而,如果使用 Rails 框架作为纯 API 框架,我个人认为是不如 Meta 框架的。

  1. 它没有一个较为完善的参数和返回值验证机制。
  2. 它没有为 API 生成文档提供支持。

归根结底是因为,Rails 是一个全栈框架,而全栈框架在开发时是不需要这些东西的。Rails 数据库处理能力很强,很多逻辑验证不是通过前端或者接口层面校验的,而是直接在数据端完成。用 Rails 只需要每个人能够独立完整地完成他那一部分模块就可以,不需要提供前后端之间的接口。

与 Grape 的比较

Ruby 纯 API 框架目前完成度最好的是 Grape 框架。虽然 Grape 历史悠久,经久耐用,但我还是觉得 Meta 框架仍有一些强于 Grape 的地方。

  1. Meta 将参数实体和渲染实体写在一个实体内,而 Grape 目前很难做到这一点,大部分情况下你要写两遍甚至更多遍。
  2. Grape 可以通过挂载 grape-swagger 插件生成文档,但在生成文档方面还是存在一些割裂感,尤其是在实体渲染的层面。
  3. Grape 生成的文档是 Swagger 2.0 的,Meta 生成的文档规格是 OpenAPI 3.0 的。

归根结底还是因为 Grape 更多的还是按照应用框架的思维开发的,而不是将文档生成作为内建的第一要务予以支持;而 Meta 框架则将生成文档作为框架能力的第一要务,文档和实现始终尽最大可能保持一致性。

我的脚手架 web-frame-example 是应用了 Rack、Active Record、环境配置等特性的开箱即用代码仓库,如果你对 Meta 框架不感兴趣,只想要使用 Grape 框架,可参考我的代码示例修改。它抽取了很多 Rails 的组件,比如 Active Record、常量自动重载等,但没有完整地引入 Rails. 如果你还是使用在 Rails 下挂载 Grape 的方式,不妨试试这个模式吧。

Meta 框架现在有哪些应用

目前的一个上线应用我在前面提到:openapi.yet.run. 这是一个使用 Meta 框架作为后端,React 作为前端的开源项目,可完整地查看 Meta 框架的应用能力。项目开源地址:

gitee.com/yetrun/open…

来自你的支持

如果你能够读到这一段,我真心地表示感谢。这是全篇文章中最重要的一节。

最为重要的是,我希望得到来自你的纠错。如果我在行文中有任何信息错误的,或者表达不得体的地方,请你务必帮我指出,我在此表达感谢。

然后,希望你能够试一试我的框架,按照上上节的说明,或者直接进入 web-frame-example 仓库去查看说明。先让项目跑起来,比什么教程都要有用的多。

如果你还想继续了解这个框架,或者说你是想学习并使用它,请进入 web-frame 仓库(别忘了投个小星星)。你将在那里找到所有的资料,包括教程

最后,遇到的一切问题都欢迎交流。交流方式可通过 GitHub 的 ISSUES;或者你想更快一点知道答案的话,可加入 QQ 群 489579810.