设计一款“实践派”的 REST API
Roy Thomas Fielding 博士在其著名的论文 Architectural Styles andthe Design of Network-based Software Architectures 中,详细描述了几种常见的软件架构风格,其中第 5 章 Representational State Transfer 就是大名鼎鼎的 REST 风格。
随着 Web 2.0 和微服务的兴起,以及 SOAP Web Services 的没落,REST API 开始大行其道,在 JSON 紧凑、易读、高效率的加持下,使得该 API 风格几乎成为现代 Remoting 通信技术的事实标准。
一万个人眼中就有一万个哈姆雷特。对 REST API 风格的的理解和应用,历来存在许多分歧和五花八门的使用方式,出现了譬如“学院派“设计,一切都要遵循理论并与之严格对齐,当然也涌现了不少反范式的民间设计。
本文结合实际的生产环境经验,试图说明如何设计一款“实践派“的 REST API。
以资源为中心
和面向 RPC 的 SOAP Web Services 不同,REST API 的核心是资源(Resource),掌握了资源就相当于牵住了牛鼻子。
资源是一个广义的概念,可以是业务实体,也可以是一个事件或动作,资源一般用 URI 来描述。
资源的分类及描述,一般和业务领域建模很相似。以电商行业为例,资源可以是用户(User)、商品(Product)、订单(Order)、结算(Checkout)、运输(Shipment)等;
REST API 的核心理念就是围绕资源而进行的一系列操作及状态变化。
公共参数
一款好的 REST API 应该将公共参数和业务参数分离设计,并支持独立变化。公共参数建议以 header 或 query_string 的方式来进行传递。常见的公共参数有:
客户端相关:
· app_key,后端下发给客户端的唯一标识;
· app_secret,与 app_key 强相关,一般不在网络中传输;
· platform,Android|iOS|Web 等 API 使用方的平台识别码;
· channel,安装包渠道编号,一般用于 APP 客户端;
设备相关:
· device_id,客户端的设备号;
· device_os,客户端的设备操作系统;
· device_version,客户端的设备版本;
· androidid/imei/idfa,Android|iOS 等客户端的标识;
· device_mac,网卡物理地址;
用户相关:
· uid,公司统一用户 ID;
版本相关:
· app_version,APP 正式版本;
· gray_version,APP 灰度版本;
· internal_version,APP 内部版本;
地理相关:
· ip,外网 IP 地址,一般客户端拿不到,需服务端从负载均衡获取;
· longitude,GPS 经度;
· latitude,GPS 纬度;
国际化相关:
· language,用户的语言;
· country,用户的国家;
· currency,用户的货币;
网络相关:
· net_status,网络状态,如 3G/4G/5G/Wifi 等;
安全相关:
· signature,API 摘要,防止伪造请求,签名最好加入 app_secret 增加破解难度;
· access_token,用户 token,防止伪造身份,一般由公司 SSO 下发且带 TTL;
· timestamp,时间戳,防止重放;
命名风格
从最佳实践角度来说,URL 命名风格建议遵循以下一些原则:
· 以名词命名。如/api/products,/api/orders,/api/users。
· 以复数命名。这只是一种约定俗成的风格,便于潜在的扩展需要。
· 需要有统一的 API 根。如/api/business1,/api/business2。这样的好处是能做好望文生义,见到 API 即可了解该接口来自哪个服务,也便于监控和日志统计。在微服务的架构设计准则下,API 根尤为重要。
· 尽可能采用蛇形(snake_case)而不是驼峰(camelCase)来命名 URL;
· 采用 HTTP Method 来操作资源。通常采用 GET 来读取资源,POST 创建资源,PUT 创建或全量更新资源,PATCH 局部更新资源,DELETE 删除资源。
值得注意的是,在实际使用场景中,我们并不是真的要坚持这些“理论派“,完全可以不必拘泥于这些约束,可以灵活变通,比如:
· 有些场景没法通过名词来描绘 ,如用户登录。这时可以使用/api/users/login 来命名;
· PUT 和 PATCH 有相似之处,是否可以用 PUT 来代替 PATCH?也未尝不可,更有甚者,可以采用 POST 来代替;
安全性
不安全的 REST API 会导致信息泄露,给不法分子可乘之机,严重的还会损害公司信誉和形象。
一个安全的 REST API 需要具备哪些因素呢?这里列出几种比较实用的安全设计准则:
采用 HTTPS
从现在开始,全体 API 就应该采用 HTTPS 传输。完全不用担心由此带来的资源消耗和耗时增加。API 网关做 SSL 卸载所带来的额外 CPU 开销,完全可以通过增加更多的服务器,通过水平扩展来分摊压力。而耗时的增加相对于业务层延迟来说,几乎可以忽略不计,这种情况更多应该在应用层做耗时调优,而不用理会接入层的开销。
摘要(Digest)
REST API 摘要本质上是一种不可逆的指纹,目的是实现 API 请求不可篡改。
REST API 摘要有时候也被通俗的称为接口签名,注意这里和密码学上的数字签名严格上说意义不太一样,数字签名是指利用私钥加密签发,接收方用公钥解密。
这里介绍一种实用的摘要算法:
· API 提供方(Provider)提供 app_key 和 app_secret 给 API 使用方(Consumer);
· API 使用方将请求参数和 app_key、app_secret、时间戳(timestamp)等按一定的算法进行混合、排序,生成一个字符串;
· API 使用方对上一步生成的字符串,按常见的摘要算法(如 MD5、SHA-256 等)进行哈希计算,得到指纹;
· API 使用方将上一步得到的指纹,以 signature=<摘要>的形式,追加到请求参数中;
· API 提供方接收到请求后,采用同样的方式,进行哈希计算,再和 signature 进行比较,从而达到验证签名的目的;
值得注意的是,app_secret 在 API 使用方仅参与摘要计算,起到一个随机盐的作用,但不随请求传输。这样的好处是防止被截获。另外 API 提供方已经存在 app_secret,自然不需要请求方再次传递。
好了,上面的摘要算法真的固若金汤、牢不可破吗?答案是否定的,没有绝对的安全。
最常见的威胁就是重放攻击。尽管我们已经设置了 timestamp 时间戳,服务端也完全可以利用 timestamp 和当前时间的间隔来限制重复请求,但也会存在两个问题:客户端和服务端可能会存在时钟不一致,另外在有效的时间窗口内依然可以重放。
解决方案有多种,从本质上说应该业务层面做好幂等,限制重复请求带来的副作用。另外也可以考虑服务端给调用方分配一个计数,并在内部记录和校验,该计数只可以递增,每次攻击者试图重放 API 就必须递增该计数,然而摘要算法是不公开的,这就使得重放无法实现。
另外的问题就是如果 API 使用方的代码被反编译,摘要算法和 app_secret 被同时破解,黑客就可以随意伪造 API 请求了。
解决方案是采用动态随机盐,由 API 提供方为每个设备或用户生成动态随机盐并周期性刷新,记录到数据库中进行校验。即使代码被破解,攻击者也无计可施。
认证(Authentication)
REST API 的认证是为了验明用户身份,实现用户身份的不可抵赖。
一种比较实用的做法是采用令牌(access_token)机制。即用户登录后,由 API 提供方(通常是用户或 Passport 中心)颁发一个令牌给使用方,该 token 安全级别较高,全局唯一,不可逆,可周期性更新或支持续借(renew)。
当 API 调用方将 access_token 加入到 API 请求后,API 提供方会将读取该参数,并查询后台进行用户身份确认,从而实现认证功能。
为什么采用 token 机制呢?原因很简单,用户身份一般由用户名和密码构成,API 客户端不可能将这些信息频繁传到服务端,这会增加信息泄密的风险。而 token 是不可理解的密文,且可以更新,故安全级别能得到较大提升。
鉴权(Authorization)
仅身份认证是不够的,REST API 的鉴权是为了解决用户权限问题。
假如没有鉴权,则用户可以操作任何资源,这显然是不安全的。常见做法是检查用户的权限与角色,确认对资源的操作权限。如普通用户只能删除、修改自己发布的内容,管理员则可以操作任何用户的数据。
国际化
在一些国际化的业务中,如跨境电商,往往需要 REST API 支持国际化。常见的实用做法是 API 调用方采集用户的国际化属性,加入 API 请求参数。API 提供方读取这些属性,存储到后台,或是直接在业务层面使用。
常见的国际化属性有:
· 语言。通常会采用 language = EN | ZH | FR 等形式进行参数传递;
· 国家。通常采用 country = CN | US | BR 等形式;
· 货币。通常采用 currency = USD | CNY | EUR 等形式;
· 时区。通常采用 time_zone = GMT+8:00 形式;
兼容性
兼容性是 REST API 开发者的必备素质,很多线上回归错误(往往还很隐晦)都是由于 API 没有考虑向下兼容造成的。因此需要考虑一些设计准则:
· 只加字段,不删除字段,不修改字段名称;
· 任何时候开发新功能,需要考虑到版本控制,即新功能只限于新版本,除非明确老版本也能使用而不受影响;
幂等性
幂等性并不是绝对的。
我们考虑下 HTTP 常见 Method 的幂等性,如 GET /PUT/PATHCH/DELETE,这些操作天然具备幂等性语义,即多次操作,不会产生副作用。而 POST 操作,默认在多次操作下会多次创建资源,因此不是幂等的。
然而,业务实现是靠技术人员实现的,因此完全可以人为控制幂等性。所以,这里并不建议过于教条,而应该根据实际情况来决定幂等性语义。
过滤
一个设计良好的 REST API 应该具备过滤能力。一种实用的做法就是通过传业务参数来控制过滤逻辑,如 id=123456,user_name=xxx 等。
另外一种层面的过滤是指返回的字段,应该可以由 API 调用方来控制,如 fields=user_name, user_age, user_city 等。
这样做的好处是显而易见的,一方面可以减少服务端的资源消耗,特别是存储的查询压力,另一方面也可以减少网络带宽的占用和 API 使用方的反序列化成本。
排序
业务场景往往会有排序的诉求,如商品展示可以按发布时间、热度、销售量进行排序。REST API 应该能支持灵活易用的排序方式。
一种常见的最佳实践就是在 API 请求中增加 sort 字段,并且支持多维度排序。如按时间正向排序,且按积分反向排序,就可以表示成:sort=+time,-score。
分页
几乎所有的产品形态都需要分页,分页功能看似简单,实际上做起来很复杂。比如:
· 如何保证每一页的数据不存在重复或丢失?
· 大数据量的集合(千万级别),如何实现深度分页,且效率不受影响?
· 如何实现跳页,即随机读取某一页数据?
这些问题不在本文讨论范围内,感兴趣的读者可以自行搜寻答案。这里介绍两种常见的分页请求参数方式。
· 采用页码方式,如 offset/limit 或 page_no/page_size。这种方式很常见,通常对于大的数据量来说,随着页码的增加分页效率会递减。当然也可以采用一些优化的技巧,如 MySQL 采用覆盖索引的子查询;
· 采用游标方式,如 cursor。这种方式相对简单,适合单维度、固定排序的数据分页。好处是时间复杂度可以是常量级别,弊端是不可以随机读取,只能从头顺序访问,当然也可以通过其他方式拿到 cursor 直接去访问下一页;
状态码
REST API 基于 HTTP,自然需要定义响应的状态码,常见的预定义状态码大致可参考:
在实际 REST API 设计中,可以灵活运用这些状态码,当然也大可不必拘泥于这些预设的状态码并咬文嚼字。常见的以 200 和 4XX、5XX 使用较多。
除此之外,还可以自定义一些非保留状态码如 6XX、7XX,用于一些特殊使用场景。
业务码
业务码是非常重要的信息表达方式,API 使用方肯定不希望在出错时,只是看到一个笼统的提示:“出错了”,而是可以读取到具体的错误码和对应的提示。
通常,REST API 返回报文会使用一个固定的格式,常见的有:
其中 code 和 messaage 就是业务码和提示信息发挥作用的地方。我们可以定义一个业务码字典和对应的提示信息。如 E000001 代表缺少参数 xxx,E000002 代表订单已被删除等等。
谨记几条原则:
· 永远不要把程序内部异常或错误抛给 API 调用方,而是采用业务码优雅的提示;
· 当输出错误码时,HTTP 状态码也应该同步调整,比如输出 5XX,这样可以让监控系统快速发现问题;
版权声明: 本文为 InfoQ 作者【余朋飞】的原创文章。
原文链接:【http://xie.infoq.cn/article/a2ef4aa858dd62e551705432f】。文章转载请联系作者。
评论