在 谈 API 的撰写 - 总览 里我们谈到了做一个 API 系统的基本思路和一些组件的选型,今天谈谈架构。
部署
首先要考虑的架构是部署的架构。部署的方案往往会深刻影响着系统的结构。我们需要问自己一个问题:从宏观上看,这个系统我们希望如何进行部署?
很多 API 系统是这样部署的(方案一):
(load balancer 和 nginx proxy (web server) 可能是同一个 cluster。这里逻辑上把他们划分开来。)
这是很典型的做法,所有的 API 在一套系统里部署,简单,高效,比较容易上手。然而,随着时间的推移,功能的复杂,这样的系统会越来越不堪重负。比如说我们做一个内容发布平台的 API 系统(类似于知乎日报),起初我们可能只需要内容相关的 API,渐渐地你要加入统计(tracking)相关的 API,然后我们又需要用户行为相关的 API,它们各自访问不同的数据源,行为方式也大不相同(内容相关的 API 可以做 cache,而统计和用户行为相关的 API 不能 cache)等等,当这些逻辑结构各异的 API 被揉进一个系统里时,这个系统会越来越难以维护。
所以,这样的部署方案会演进成下面的部署方案(方案二):
我们把 API 按照功能做了拆分,load balancer / nginx proxy 之后是一个个 API application。它们有自己的 load balancer / nginx proxy,可以按照每个 API application 本身的规模进行 scale up / down。比如说内容相关的 API,访问量(折合成运算量)是用户相关的 API 的 5 倍,那么,部署的时候我们可以把资源按照 5:1 的比例部署;再比如在高峰期整个系统负载过大,可以把统计 API 关掉,在 proxy 侧直接返回 503,把节省的资源配置到其他地方。
这里谈到的部署方案刻意忽略了一些细节,比如说日志如何收集和管理,服务本身的监控和信息的收集(APM)等没有提及。它们是部署方案中的关键环节,但毕竟本文不是专门讲部署的,故而忽略。
显而易见地,方案一和方案二的软件架构也会有所不同。方案二中,两个 API application 间的访问会通过 RPC(也可以使用 HTTP,但效率略低)完成,而方案一种,可能直接就是一个 function call 或者直接访问对方的数据库。方案二是一种分治的思想,把大的问题变成一条公共路径上若干相似的小问题的解决。
Pipeline
接下来的文章中,我们以方案二为蓝本,描述一个 API application 的架构。之前我们提到了这些目标:
- A well defined pipeline to process requests
- REST API done right (methods, status code and headers)
- Validation made easy
- Security beared in mind
- Policy based request throttling
- Easy to add new APIs
- Easy to document and test
- Introspection
除了后面三个,其他都和 API 处理的 pipeline 有关。我们知道,一个 API 的执行,从 request 到 response,整个 pipeline 能够划分成几个阶段:request -> pre-processing -> processing -> post-processing -> response。其中,"processing" 指的是 API 路由真正执行的代码。好的架构应该尽可能把 API 执行路径上的各种处理都抽象出来,放到公共路径(或者叫中间件,middleware)之中,为 API 的撰写者扫清各种障碍,同时能够促使 API 更加标准化。
下图是我构思的一个 pipeline,它并不是最好的,但最能反映我的思想:
我们详细说说这个 pipeline 下的每个组件:
- throttling:API 应该有最基本的访问速度的控制,比如,对同一个用户,发布 tweet 的速度不可能超过一个阈值,比如每秒钟 1 条(实际的平均速度应该远低于这个)。超过这个速度,就是滥用(abuse),需要制止并返回 429 Too many requests。throttling 可以使用 leaky bucket 实现(restify 直接提供)。
- parser / validation:接下来我们要解析 HTTP request 包含的 headers,body 和 URL 里的 querystring,并对解析出来的结果进行 validation。这个过程可以屏蔽很多服务的滥用,并提前终止服务的执行。比如你的 API 要求调用者必须提供 X-Client-Id,没有提供的,或者提供的格式不符合要求的,统统拒绝。这个步骤非常重要,如同我们的皮肤,将肮脏的世界和我们的器官隔离开来。
- ACL:除了基本的 throttling 和 validation 外,控制资源能否被访问的另一个途径是 ACL。管理员应该能够配置一些规则,这些规则能够进一步将不合法 / 不合规的访问过滤掉。比如说:路径为 "/topic/19805970" 的知乎话题,北京时间晚上10点到次日早上7点的时间端,允许在中国大陆显示。这样的规则可以是一个复杂的表达式,其触发条件(url)可以被放置在一个 bloom filter 里,满足 filter 的 url 再进一步在 hash map 里找到其对应的规则表达式,求解并返回是否允许显示。
- normalization:顾名思义,这个组件的作用是把请求的内容预处理,使其统一。normalization 可以被进一步分为多个串行执行的 strategy,比如:
- paginator:把 request 里和 page / sort 相关的信息组合起来,生成一个 paginator。
- client adapter:把 API client 身份相关的信息(device id,platform,user id,src ip,...)组合成一个 adapter。
- input adapter:输入数据的适配。这是为处女座准备的。很多时候,输入数据的格式和语言处理数据的格式不一样,这对处女座程序员是不可接受的。比如说 API 的输入一般是 snake case(show_me_the_money),而在某些语言里面(如: javascript),约定俗成的命名规则是 showMeTheMoney,所以把输入的名称转换有利于对代码有洁癖的程序员。
- authentication:用户身份验证。这个不多说,主要是处理 "Authorization" 头。对于不需要验证的 API,可以跳过这一步。做 API,身份验证一定不要使用 cookie/session based authentication,而应该使用 token。现有的 token base authentication 有 oauth, jwt 等。如果使用 jwt,要注意 jwt 是 stateless 的 token,一般不需要服务器再使用数据库对 token 里的内容校验,所以使用 jwt 一定要用 https 保护 token,并且要设置合适的超时时间让 token 自动过期。
- authorization:用户有了身份之后,我们进一步需要知道用户有什么样的权限访问什么样的资源。比如:uid 是 9527 的用户对 "POST /topic/"(创建一个新的话题),"PUT /topic/:id"(修改已有的话题)有访问权限,当他发起 "DELETE /topic/1234" 时,在 authorization 这一层直接被拒绝。authorization 是另一种 ACL(role based ACL),处理方式也类似。
- conditional request:在访问的入口处,如果访问是 PUT/PATCH 这样修改已有资源的操作,好的 API 实现会要求客户端通过 conditional request(if-match / if-modified)做 concurrent control,目的是保证客户端要更新数据时,它使用的是服务器的该数据的最新版本,而非某个历史版本,否则返回 412 precondition failed。
- preprocessing hook:稍后讲。
- processing:API 本身的处理。这个一般是 API 作者提供的处理函数。
- postprocessing:稍后讲。
- conditional request:在访问的出口处,如果访问的是 GET 这样的操作,好的 API 实现会支持客户端的 if-none-match/if-not-modified 请求。当条件匹配,返回 200 OK 和结果,否则,返回 304 Not Modified。304 Not Modified 对客户端来说如同瑰宝,除了节省网络带宽之外,客户端不必刷新数据。如果你的 app 里面某个类别下有五十篇文章,下拉刷新的结果是 304 Not Modified,客户端不必重绘这 50 篇文章。当然,有不少 API 的实现是通过返回的数据中的一个自定义的状态码来决定,这好比「脱裤子放屁」—— 显得累赘了。
- response normalization:和 request 阶段的 normalization 类似,在输出阶段,我们需要将结果转换成合适的格式返回给用户。response normalization 也有很多 strategy,比如:
- output adapter:如果说 input adapter 是为有洁癖的程序员准备的,可有可无,那么 output adapter 则并非如此。它能保持输出格式的一致和统一。比如你的数据库里的字段是 camel case,你的程序也都是用 camel case,然而 API 的输出需要统一为 snake case,那么,在 output adapter 这个阶段统一处理会好过每个 API 自己处理。
- aliasing:很多时候,你获得的数据的名称和定义好的 API 的接口的名称并不匹配,如果在每个 API 里面单独处理非常啰嗦。这种处理可以被抽取出来放在 normalization 的阶段完成。API 的撰写者只需要定义名称 A 需要被 alias 成 B 就好,剩下的由框架帮你完成。
- partial response:partial response 是 google API 的一个非常有用的特性(见:https://developers.google.com/+/web/api/rest/#partial-response ),他能让你不改变 API 实现的情况下,由客户端来决定服务器返回什么样的结果(当前结果的一个子集),这非常有利于节省网络带宽。
- serialization:如果 API 支持 content negotiation,那么服务器在有可能的情况下,优先返回客户端建议的输出类型。同一个 API,android 可以让它返回 application/msgpack;web 可以让它返回 application/json,而 xbox 可以获得 application/xml 的返回,各取所需。
- postserialization:这也是个 hook,在数据最终被发送给客户端前,API 调用者可以最后一次 inject 自己想要的逻辑。一般而言,一些 API 系统内部的统计数据可以在此收集(所有的出错处理路径和正常路径都在这里交汇)。
多说两句 response normalization,如果在这一层做得好,很多 API 里面啰啰嗦嗦处理的事情都能被处理的很干净。你只需要一套严格测试过的代码,就可以让所有的 API 在输出时大为受益。比如:
在经过 response normalization:
- output adapter 把 camel case 变成 snake case,所以 errorName -> error_name
- aliasing(如果定义了 error_name -> err_name)把 error_name 转换为 err_name
- 如果客户端访问时只想要 err_name / err_msg,那么 partial response 只返回这两个域
返回结果如下:
这样的一个 pipeline 从具体的 API 的行为中抽象化出了一个 API 处理的基本流程,并且很容易在几个 hook 处进行扩展。
以上的描述基本上和语言,框架无关。回到 node 和 restify 本身,我们会发现,有些事情并不好处理。比如说,在 restify 里,一个路由的 action 往往就会直接调用 res.send()
发送数据,那么,post-processing 的各种行为如何能够注入?如果是从头开始构建一个框架,那么,pipeline 里的每个组件返回一个 Promise 或者 Observable,将其串联起来就可以了,但在 restify 里,你无法这么干。对于这样一个具体的问题,我采用的方法是使用 python 中 wraps
类似的方式:
然后通过监听 ‘beforeSend‘,‘afterSend‘ 两个事件来起到注入逻辑的效果。这样虽说是个 hack,但是是眼下可能最好的解。
在 node.js 这样的异步系统里还要注意,event emit 的监听函数如果是异步的,处理起来的顺序可能并非如你所愿,为此,我开发了一个 eventasync
库,对 node.js 的 event emitter 做 monkey patch,使其支持 async listerner。
接口
理顺了 pipeline,整个架构基本就清晰了,接下来要考虑提供一个什么样的接口让 API 的写作能够高效。restify 提供的接口:
虽然很简单,但是很难满足我们对于 pipeline 的需求,比如说,validation。如何做 validation 只能是某个 API 的作者来做决策,框架来收集这些决策信息并在 pre-processing 阶段执行。所以,我们必须在路由初始化之前收集这一信息;此外,还有很多信息,如一条路由是否需要 authentication,如何做 alias,这些信息都需要 API 的撰写者提供给框架,而框架来收集。所以,作为一个框架,我们需要一个更好的 interface 提供给 API 的撰写者。这是我的 proposal:
这个接口包含几重信息:
- 路由接受 POST method
- 路由的 path 是
/logout
- 路由有一个很详细的 markdown 撰写的文档(还记得我们的需求是:easy to document 么?)
- 其接受一个参数为 (req, res, next) 的 action function(也可以是多个)
- 其对 body 提供一个 joi validator(除 body 外,也可以对 header,param 和 query 做 validation)
- 使用这个 API 需要 authentication,调用完毕后要记录 audit trail
通过这样一个接口,我们把 API 系统区隔为「编译时」和「运行时」。这个接口写出来的 API,更像是一个等待编译的源文件。在 API 系统启动的时候,会经历一个「编译」的过程,把所有的 route
汇总起来,生成 restify 认识的路由形式,同时,收集里面的各种信息(比如 validator,authentication),供框架的各个 middleware 使用。
不要小看这样一个接口上的改变和「编译时」/「运行时」的区分,它除了可以让 API 的各个信息无缝地和 pipeline 对接,还能够实现我们期望的 introspection:
(通过 route
生成的 swagger 文档,供 API 使用者使用)
(通过 route
生成的 cli 文档,供 API 开发者 introspection)
相信通过这个接口,你能够更好地理解 David Wheeler 的那句:
All problems in computer science can be solved by another level of indirection.
转载:陈天 程序人