谈 API 的撰写 - 架构
在 谈 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.
转载:陈天 程序人
谈 API 的撰写 - 架构的更多相关文章
- 谈 API 的撰写 - 子系统
在做一个系统时,有一些子系统几乎是必备的:配置管理,CLI,以及测试框架. 配置管理 我们先说配置管理.一个系统的灵活度,和它的配置管理是离不开的.系统中存在的大量的预置的属性(下文简称 proper ...
- 谈 API 的撰写 - 总览
背景 之前团队主要的工作就是做一套 REST API.我接手这个工作时发现那些API写的比较业余,没有考虑几个基础的HTTP/1.1 RFC(2616,7232,5988等等)的实现,于是我花了些时间 ...
- 浅谈API网关(API Gateway)如何承载API经济生态链
序言 API经济生态链已经在全球范围覆盖, 绝大多数企业都已经走在数字化转型的道路上,API成为企业连接业务的核心载体, 并产生巨大的盈利空间.快速增长的API规模以及调用量,使得企业IT在架构上.模 ...
- 谈一下关于CQRS架构如何实现高性能
CQRS架构简介 前不久,看到博客园一位园友写了一篇文章,其中的观点是,要想高性能,需要尽量:避开网络开销(IO),避开海量数据,避开资源争夺.对于这3点,我觉得很有道理.所以也想谈一下,CQRS架构 ...
- 浅谈HTML5单页面架构(二)——backbone + requirejs + zepto + underscore
本文转载自:http://www.cnblogs.com/kenkofox/p/4648472.html 上一篇<浅谈HTML5单页面架构(一)--requirejs + angular + a ...
- 浅谈大型web系统架构
动态应用,是相对于网站静态内容而言,是指以c/c++.php.Java.perl..net等服务器端语言开发的网络应用软件,比如论坛.网络相册.交友.BLOG等常见应用.动态应用系统通常与数据库系统. ...
- 转:浅谈大型web系统架构
浅谈大型web系统架构 动态应用,是相对于网站静态内容而言,是指以c/c++.php.Java.perl..net等服务器端语言开发的网络应用软件,比如论坛.网络相册.交友.BLOG等常见应用.动态应 ...
- AngularJS进阶(二十五)requirejs + angular + angular-route 浅谈HTML5单页面架构
requirejs + angular + angular-route 浅谈HTML5单页面架构 众所周知,现在移动Webapp越来越多,例如天猫.京东.国美这些都是很好的例子.而在Webapp中,又 ...
- QPS从0到4000请求每秒,谈达达后台架构演化之路(转载)
https://blog.csdn.net/czbing308722240/article/details/52350219 QPS从0到4000请求每秒,谈达达后台架构演化之路 达达是全国领先的 ...
随机推荐
- 核苷酸(evolution)
核苷酸(evolution) 题目描述 生物课是帕特里克最讨厌的课程,没有之一. 相比做一些无聊而又无趣的遗传题,他更喜欢其他所有的科目. 包括英语. 但是今天不同.他被一个关于RNA感染DNA的题目 ...
- 5-Dalvik垃圾收集机制Cocurrent GC
Dalivik垃圾回收收机制Cocurrent GC简介和学习计划 导语: 在C/C++中,开发者需要手动地管理在堆中分配的内存,但是这往往导致很多问题. 1. 内存分配之后忘记释放,造成内存泄漏. ...
- IPFS
http://www.r9it.com/20190412/ipfs-private-net.html IPFS指令集中文版(一) https://www.jianshu.com/p/ce74b32d2 ...
- WPF 自动选择dll,以SQLite为例
在学习sqlite的过程中,发现它的dll是区分32位和64位的,起初觉得很恼火,但是仔细看了下, 发现让程序自行选择dll其实也不是一件很麻烦的事情,如下: 1>创建一个sqlite数据 2& ...
- 三个div向左浮动不在同一行,向右浮动在同一行的解决办法
前几天在写代码的时候发现了一个问题,问题的大致描述如下: 在一个大的div中,同一行有三个小的div,当三个小的div均向左浮动时,会出现换行问题,均向右浮动时却在同一行. 解决这个问题的方法是在:在 ...
- 解决 Mac OS X 下 IntelliJ IDEA、jEdit 等 Java 程序中文标点输入无效的方法
Mac OS X 下基于 Java 的程序(如 IntelliJ IDEA.jEdit 等)会出现中文标点输入无效的问题,在中文输入法状态,可以输入中文字,但输入中文标点最后上去的是英文标点.查阅了相 ...
- FZU2187 回家种地(矩形面积并)
矩形面积并(只覆盖一次的面积)的裸题.好久没写代码debug了我太久,太辛酸了. #pragma warning(disable:4996) #include <iostream> #in ...
- SHELL判断服务是不是正在运行
使用SHELL脚本进行检查服务开启情况 #!/bin/bash #需要首先安装 yum install nmap -y #检查指定端口是否开启 function checkPortStatus() { ...
- springBoot 程序入口
入口类要放在首个package 这样它能扫到所有的包 @SpringBootApplication @EnableScheduling public class App { public static ...
- 在Android 7.0以上PopupWindow.showAsDropDown()不起作用问题
最近优化界面,调用PopupWindow.showAsDropDown()发现,窗口并没有在控件下方显示. 有网友说是7.0以上系统的问题,可以自定义PopupWindow,重写showAsDropD ...