Ruby meta-api: 让后端开发者不用再写文档

yetrun · 2023年08月05日 · 951 次阅读

meta-api 框架历经长时间的迭代,终于在今天发布了第一个稳定的版本 0.1 版了。借此机会,我再次向 Ruby 社区发表下这个项目,如有打扰之处还请见谅。这次的文章比较长,不知道 Ruby China 论坛支不支持发表长文章,会不会产生字数限制导致截断的情况。也许长文章会劝退某些 Ruby 伙伴,没事那就跳着看到最后吧。这次我将介绍下我对后端 API 开发的看法,介绍 meta-api 框架文档化支持的能力和它的与众不同之处。这次我还将它与 Rails、Grape 框架做个简单的比较。当然,欢迎大家来交流,我在文末列出交流的途径,希望能与有志同道合的同志一起讨论,当然更希望大家一起参与建设。

目录

背景简介

现在前后端分离已经成为了 Web 项目开发的主要模式。一个项目组内分为前端开发人员和后端开发人员,后端开发者负责产出 Restful 接口,前端开发者通过调用接口获取数据或操作。在前后端交流接口的过程中,在写 API 文档上,后端开发者一般持有三种态度:

  1. 不想写文档,压根不打算产出 API 文档;
  2. 愿意写文档,但产出的文档很粗糙,经常跟接口的实现对不上;
  3. 很细致地写文档,产出的文档也很好,但写文档的过程非常地繁琐,也有少部分与接口的实现对不上的情况发生。

我没有完全调研大家编写文档的方式,据我所知有单独写一个 Markdown 文件的,有用 ApiFox 之类可视化工具的,有用代码注释或注解产生 API 文档的,不一一列举了。但这些工具都有一个共同点:分心。程序员在百忙之中不得不抽出相当一部份时间专门处理文档的事情,让自己的工作在写文档和写代码之间不断地跳跃。

除了写文档的繁琐之外,国内开发者对待文档的浮躁也是一个方面。项目组内往往是铺天盖地的讨论声,前端一会儿问问后端这个接口怎么用,那个接口怎么会报错?后端也乐于解答这些问题。他们在开发中往往不去参考文档。有些项目组可能会生产文档,但是如果文档的表达能力不够好,开发过程中经常地弃之不用,那么即使产出了文档也起不到一丁点作用。

项目组到底需不需要写文档呢?如果你的回答是不需要,那也许你找到了适合你们项目组开发的最佳途径。但总体上,生产 API 文档至少有几个好处:

  1. 减少前后端联调和沟通的成本,当然前提是文档的质量要足够好。
  2. 从 API 文档了解项目的设计结构,文档可作为项目组沟通的媒介。
  3. 如果你开发的是一个开放 API 项目,API 文档不可或缺。

我一直在想,让人们讨论要不要产出文档这个话题,是不是因为生产文档的额外工作让人厌烦。所以,如果文档的工作足够简单,成为了开发代码自然产生的附属品,那么纠结它重不重要的话题还有意义吗?

meta-api 工具简介

这篇文章阐述了一个想法,介绍了一种工具,或者是一个框架,目的是为了让后端开发者简化写文档的工作。甚至达到一个终点,后端开发者不用再写文档了,因为文档就在那里,就在你的代码里。

这里介绍的一个新的框架,是我历时一年半左右的大大小小的时间里积累而完成的。现在我认为它可以达到发布一个较为稳定的版本时机了,至少从实践上我探索出了代码同步文档的可行性。总结起来,它最大的特点是:

  1. 写代码时同步产生文档(结合配套的 OpenAPI 工具基本达到实时的效果),开发者不用再单独考虑文档的生成问题;
  2. 文档与接口的逻辑是一致的,包括文档中定义了几个字段,接口就接受几个字段,不多也不少;
  3. 只需要在一个地方定义实体,就可以到处使用,包括参数中、返回中、列表接口、详情接口,其中提供了一个简单的机制在以上不同的场景下产生不同的字段。

写代码时同步产出文档

我录了一个视频,我在左边写代码,等我把代码写完之后,右边就实时出现更新后的文档了:

发布后发现 Ruby China 不能直接显示视频,大家伙可以点击下面的链接访问视频

同步生成文档

左边是一个 Vim 编辑器,右边同步产生文档。当我们在代码中将接口的语义用各种宏命令定义好后,保存文件,右边的 API 文档预览同步生成更新后的效果。所以,你完全可以一边写代码一边检查文档;或者当你写完代码后统一检查文档,哪里需要调整的再回过头去调整即可。

文档与接口的逻辑是一致的

创建用户

其实当你看到视频的最后,再截一张图时,就可以发现端倪了。编辑器里的代码,都能对应上 OpenAPI 文档里的一部份。甚至是字段都能对应上。仔细看右边的文档,同样引用的 ArticleEntity 这个实体,但在文档显示时却能正确地说明参数包括 titlecontent 字段,渲染返回时包括 idtitlecontent 三个字段。

一处编写,到处使用

还是这个例子,你通篇看到对 ArticleEntity 的引用。其实,对于文章这个实体的参数和响应值,我就写在了这一个类里,然后就几乎用在所有的地方,包括参数、返回值,也包括列表接口、详情接口。所谓一处编写,到处使用,你将享受到统一管理的便利。

框架与 Rails 和 Grape 的比较

首先,meta-api 是纯 API 框架,接受 JSON 参数,生产 JSON 实体;Rails 是全栈框架。它们的应用场景不一样。全栈开发(也就是通常所说的一个人单撸整个项目),适合用 Rails 开发,中间没必要生产 API 文档。前后端分离的开发,中间需要生产 API 文档,适合 meta-api 开发。

Rails 有纯 API 模式,但仍是全栈的思维(MVC),我个人认为开发起来还是有诸多不便。而且,它不能生产文档。通过 rswag 生产的文档,没有一致性可言。你完全可以在文档中写是一套,在实际逻辑中运行时又是一套。

Grape 是纯 API 框架,也能生产 Swagger 文档。但它不是面向文档优先的框架,一致性做得不足。它在参数方面的文档一致性做得很好,但在渲染方面却欠佳。而且,它无法做到一处编写到处使用,参数定义和返回值定义用了两套不同的语法。

meta-api 框架天生地就是面向文档优先的,所以目前来说,一致性方面做得最足。这是它与其他框架目前的最大区别。如果你是 Rails 全栈开发者,暂时用不到 meta-api,保持原有技术栈就好。如果你是 Grape 框架使用者,我真心建议你试用下 meta-api 框架,它已经有了 Grape 框架大约 80% 的同等表达能力;而且,它还是一个文档友好的独一无二的框架。

框架开发进展情况

如何试用和上手

首先,它的上手难度不见得比 Grape 高,因为二者都是啥也不依赖的微型框架。最简单也是最好的上手方案,直接使用我提供的脚手架:

https://github.com/yetrun/web-frame-example

请按照它的说明文档一步一步去做就好。如果需要访问 API 文档,可使用我的 OpenAPI 工具作实时预览。在浏览器地址栏输入:

http://openapi.yet.run/playground

然后内里还有一个地址栏,输入 ws://localhost:9292/api_spec 就好。(前提是项目先用 Rackup 启动了)

关于脚手架的说明:

这是一个纯 Ruby 脚手架,没有用到 Rails. 在使用微型框架时,没有必要引入 Rails,只需要基于 Rack 即可。这个脚手架除了可用于 meta-api 之外,也可以用于 Grape、Sinatra 等,只需要将 meta-api 的部分对应地替换掉即可。

我么摒弃 Rails 的 MVC,不是说要完全摒弃 Rails. 像 ActiveRecord,放在哪里都能用的,而且本身也可作为组件拆分出来单独使用。脚手架集成了几个好用的组件,像 ActiveRecord、Pundit、FactoryBot 等。

上线的项目情况

由于知名度真的很低,再加上我很少宣传,现在项目的使用情况很少。主要是包括我公司的项目,和我业余开发的两个项目:

场景举例

接口的语义和职责

可以这么说,meta-api 这个框架是我这么多年接口开发的理念的总结,其中蕴涵了一些我对接口职责的看法。有些东西通过产品是看不出来的,但通过接口可能暴露出来,从而给系统带来隐患。如果我们一直以一种不负责任、能用即用的态度开发接口,那么系统的安全问题迟早会暴露出来。

举个简单的例子,对于返回用户信息这样的接口(GET /user),密码这个字段不应该在返回实体内列出来的。如果我们只是简单地使用类似

render user.to_json

这样的代码,就会暴露出 user 对象实例能够暴露的所有字段,这样是不安全的。因而,对返回字段的控制是一个框架应该考虑的事情。

但仅仅这样也不够,如果能很好地和文档结合就让事情更加友好了。如果一个接口返回这个字段,我们就在文档中包含它;如果一个接口不返回这个字段,我们就在文档中去掉它。这样,我们只要扫一眼接口文档,就可以知道接口的参数和返回值定义得对或不对,是否合理,有没有隐患等等。无论是后端开发者、前端开发者、系统安全员、架构师都可以通过用与实际代码逻辑一致的接口文档进行交流。

meta-api 框架就是天生为这些使用场景而考虑的。具体来讲,是框架内提供的 JsonSchema 模块来定义接口的实体,包括参数和响应值。例如,对于上面的 GET /user 接口,只需要在字段定义处简单地加上标记 render: false 就可以在返回时去掉 password 字段了:

class UserEntity < Meta::Entity
  property :id, type: 'integer', description: 'ID', param: false
  property :username, type: 'string', description: '用户名'
  property :password, type: 'string', description: '密码', render: false
end

meta-api 框架的 JsonSchmea 模块在设计之初,就秉承了两个理念:

  1. 参数和返回值统一定义;
  2. 提供了场景的概念,实体统一定义,使用时通过场景予以区分。

这样做只为了一个效果,不作重复性的劳动。就是这样,meta-api 不仅让开发者避免写文档,也要避免开发过程中的无谓的重复劳动。做到了这样,就更有效地保证了接口逻辑与文档的一致性。

篇幅有限,说不了太多。JsonSchema 其实是一个很完备的模式定义工具,它支持各种 JSON 类型以及深层次的嵌套,还包括类型转换、数据验证、当前环境访问等特性。

区分参数和返回值

上面的 UserEntity 实体已经给出了这个示例了。我们在实体定义中,注意到 id 字段,我们不让它作为参数;password 字段,我们不让它作为返回值。这样,无论是参数定义还是实体定义,我们都能引用它了:

post '/users' do
  params do
    param :user, ref: UserEntity, required: true
  end
  status 200 do
    expose :user, ref: UserEntity
  end
end

生成的文档即这样,可以观察一下参数和返回值的字段有什么区别:

创建用户

这么做保护了你的数据。将 id 字段从参数里去除,不会因为偶然因素导致 id 被莫名地更改。将 password 字段从返回字段里去除,防止别人通过接口窥探到隐私数据。

详情接口返回完整字段,列表接口返回概要字段

详情接口和列表接口返回的字段应该是不一样的。

例如查看文章列表 GET /articles,它的返回消息体可能是:

{
  "articles": [
    { "id": 1, "title": "Article One" },
    { "id": 2, "title": "Article Two" },
    { "id": 3, "title": "Article Three" }
  ]
}

而查看文章详情的接口 GET /articles/1,它的返回消息体应包括富文本内容,也就是 content 字段:

{
  "article": {
    "id": 1,
    "title": "Article One",
    "content": "A long article content..."
  }
}

做到这一点我们也只需要定义一种实体,但要借助场景(scope)辅助实现:

class ArticleEntity < Meta::Entity
  property :id
  property :title
  property :content, scope: 'details'
end

scope 选项定义需要外部提供 details 场景才能用到这个字段。外部提供场景?额,是的……

get '/articles' do
  title '返回文章列表'
  status 200 do
    expose :articles, type: 'array', ref: ArticleEntity
  end
end

列表接口(GET /articles)啥都没干,因此按照默认条件它是无法得到 content 字段的。

返回文章列表

get '/articles/:id' do
  scope 'details'
  title '返回文章详情'
  status 200 do
    expose :article, ref: ArticleEntity
  end
end

详情接口(GET /articles/:id)定义了 scopedetails,则与 ArticleEntity 内部定义不谋而合,这种场景下它会返回 content 字段:

返回文章详情

这样做的考虑是保证接口限制消息体的大小。如果一个消息体过大,它会过度地占用带宽从而影响到服务器的响应能力。我亲自遇到过一个接口包含了富文本的详情后导致系统卡顿不能响应的真实情况。

场景是什么?

场景(scope)是 JsonSchema 模块的核心理念,它是静态生成文档能力的最主要工具。因此,它的功能是很强大的。你可以在接口层定义 scope,它同时应用到参数和返回值;你也可以在参数和返回值上单独应用 scope;上层的 scope 会继承到下层(试想一下在 /admin 空间的顶层定义一个 scope: 'admin' 之后会有什么妙用吧);你甚至可以在运行时传递 scope(但我不建议这么做,这会让它失去文档正确的表达能力)……总之,有什么问题,来问我吧。

创建和更新动作应用不同的参数

你知道吗?你的接口在不同的场景下请求参数的字段应该是不一样的。因此,无脑接受一样的参数类型,而要求前端来区分和传递真的是正确的习惯吗?

现在我说一下场景,还是 UserEntity,我们要求创建用户时包含 password 这个字段,而在更新时不要包含这个字段。现实是很合理的,我们一般把修改密码放在另一个接口去做。

这次我反过来,先定义接口并列举出场景,再来写 UserEntity. 我们定义两个接口,创建用户和更新用户:

post '/users' do
  scope 'create'
  title '创建用户'
  params do
    param :user, ref: UserEntity, required: true
  end
end

put '/user' do
  scope 'update'
  title '更新用户'
  params do
    param :user, ref: UserEntity, required: true
  end
end

下面是我们希望产生的文档:创建用户接口有 password 参数,更新用户接口没有 password 参数。

创建用户 更新用户

定义 UserEntity 实体时,我们只需要根据 scope 来规定字段即可。为了避免干扰,我们只关注 password 字段怎么定义:

class UserEntity < Meta::Entity
  property :password, param: { scope: 'create' }, render: false
end

scope: 'create' 我们已经熟悉了,它只接受创建接口。我们还将这一切包装在 param 选项下,因为只有作为参数时我们才做这样的区分。请记住,你可以为参数和返回两种情况定义不同的 scope.

meta-api 框架为每个路由的方法自动创建了 scope,可以直接在实体中引用。例如,UserEntity 中的 password 可以直接改为:

property :password, scope: '$post'

这样,因为创建用户的接口是 POST,其自动传递 scope: '$post',因此,不需要在路由定义 scope 'create' 也能让用例跑得通。

用局部更新还是全局替换

如果你了解 PUTPATCH 的语义区别,你就知道什么是局部更新,什么是全局替换。meta-api 可以在声明时定义解析参数时使用局部更新还是全局替换的,默认情况是全局替换。

为实体调用 locked(discard_missing: true),就将行为调整为局部更新:

put '/articles/:id' do
  params do
    param :article, ref: ArticleEntity
  end
end

patch '/articles/:id' do
  params do
    param :article, ref: ArticleEntity.locked(discard_missing: true)
  end
end

在实际调用中,如果传递的参数是(JSON)

{
  "article": {
    "title": "New Article"
  }
}

那么 PUT 方法获取的参数是(Ruby)

{
  article: {
    title: "New Article",
    content: nil
  }
}

PUT 方法获取的参数是(Ruby)

{
  article: {
    title: "New Article"
  }
}

Meta 框架支持为全局情况使用 discard_missing: true 的配置,这样就可以避免在每个路由参数引用实体底下设置 locked(discard_missing: true) 的调用了。不过我还是喜欢做下区分,即创建类的接口用 discard_missing: false,更新类的接口用 discard_missing: true.

场景是可以向下传递的

这里举出一个例子,将 /admin 锚点下的所有接口都添加一个共同的 scope,这样挂载在它下面的所有接口都能应用这个 scope,实现一些共同的目的。

namespace '/admin' do
  meta do
    scope 'admin'
  end

  get '/admin/dashboard' do
    # 将直接应用 'admin' 场景
  end

  # 通过模块引入的接口也能继承场景 'admin'
  apply API::Users
  apply API::Articles
end

这样,我们可以实现一些特殊的需求,比如只有 admin 情况下才能操作和看到的字段:

class ArticleEntity < Meta::Entity
  property :published, type: 'boolean', description: '是否发布上线', scope: 'admin'
end

动态 if:

拥有和 Grape Entity 提供的类似的 if: 选项,用于动态渲染字段。

接着上面的例子,如果你将管理员的接口和普通群众的接口放在同一个锚点下(比如都在 /articles 下),希望根据身份决定是否渲染字段,这个时候 if: 选项将派上用场了。

class ArticleEntity < Meta::Entity
  property :published, type: 'boolean', description: '是否发布上线', if: ->{ current_user.admin? }
end

需要注意的是,虽然渲染(包括参数解析)是动态的,但文档只能保持静态的能力,其字段将会在 API 文档中一直显示出来。你的文档描述中最好加上一段解释:

class ArticleEntity < Meta::Entity
  property :published, type: 'boolean', 
                       description: '是否发布上线,仅管理员可操作', 
                       if: ->{ current.admin? }
end

更多的例子

我其实想举出更多的例子,但我知道一篇文章能够表达得有限。我其实想说的是,无论你用什么语言,用什么框架,都要小心细致地定义你的接口。让接口正确地表达,永远应该成为你的中心任务。

我经常在网络上听到什么诸如“能用就行”,“这也不用管,那也不用管”,“Restful 是垃圾”之类的论断。但是,拒绝不需要的参数,隐藏需要保护的信息,和生成与行为一致的文档,真的只是几句潦草的“能用就行”之类的论句就能盖之的吗?我很疑惑。

如果只是因为做到这些很繁杂,让你觉得时间成本上不划算,我认为那是情有可原。但如果打心眼里排斥正确、精准的 API 表达,我就实在想不通。

meta-api 框架就是致力于用最简单的方式生产表达一致的 API. 虽然它现在可能不够好,但我相信,随着它的发展和大家的支持,它的表达方式会越来越简单,它的表达结果会越来越一致。

篇尾

项目地址

说了这么多,我还没有说 meta-api 框架的基本情况。

它的 GitHub 地址是,希望大家能投上一票(Star):

https://github.com/yetrun/web-frame

这篇文章我只介绍了 meta-api 框架与文档相关的能力,但这并不代表它仅限于此。作为一个框架,它的基本素养是齐全的,包括路由组织和定义、异常拦截、数据验证等。它还包含了一个非常友好的错误栈提示,让开发者在遇到问题时容易对应到出错的代码上。

我目前只在国内推广,用中文文档,对国人友好。我希望能补充一下国内 Ruby 框架的空白吧。

作为 Rails 的插件

框架还打算提供一个 Rails 插件的方案,让 Rails 纯 API 开发者享受到 JsonSchema 文档化的便利。使用 Rails 插件的方案,可以复用现有的框架和代码,可作为一个渐进的兼容方案,显得没那么跳脱。

由于开发初期没有作为主要目标,所支持的配套可能尚不完善。我之前也发布过有关 Rails 插件的方案,没有得到响应和回复。也许 Rails 开发者用的还是全栈的较多吧。有需要的同志可以艾特我,我将加速这方面的进程,后续也可能作为一个主要主题予以开发。

希望能得到来自更多伙伴的支持

现如今的框架开发着实没什么市场了,开发这样的一个项目也是因为我当初有需求,正好当时项目组也需要。因为我们是完全的前后端分离的开发组,而我个人比较讨厌就具体字段情况回答太多的问题,我就这样开发了一个新的框架。

目前框架的发展受到了限制,而我个人开发确实耗时耗力。如果能得到大家的帮助,那就更好了,也能作为我继续坚持下去的动力。当然,我更希望有人能和我一起开发,共同维护一个项目。

加入的途径不限,你可以提出 Issue,对教程或代码提出改进意见;或提出 Pull Request,或加群一起讨论提问。

如果你能使用它并正式投产,像我公司那样,我将不胜感激,并倾尽全力为你解决使用上的任何麻烦。

我坐标位于上海,如果有机会,可以组织一次线下的讨论和交流。我可以就框架的实现思路,以及框架的适用场景作出分享。

欢迎大家进群唠嗑

针对项目的前景和发展的方向,我热忱地欢迎大家伙一起来聊聊,因为我本人也是迷茫的。我也担心我的路走偏,听听大伙的心声有助于我避免重复的、无用的工作。(群号是 489579810)

或者使用 GitHub Discussion?看大家觉得哪个地方更好了,可在帖子下留言通知我。

另外,我很乐意接受应用场景的提供。你可以进群来,提出一些场景,我来答复我的框架是不是能够很好地支持。这样既是帮助了你,也是帮助了我,我将不胜感激。

暂无回复。
需要 登录 后方可回复, 如果你还没有账号请 注册新账号