架构必备「RESTful API」设计技巧经验总结
【译者注】本文是作者在自己的工作经验中总结出来的RESTful API设计技巧,虽然部分技巧仍有争议,但总体来说还是有一定的参考价值的。以下是译文。
简单说一下代码重用
记得在Ken Rogers的Medium博客里曾经见过这么一句话(原文出自海明威):
我们都是手艺学徒,没有人会成为大师。
在我写这篇文章的时候,我不禁笑了起来,因为从这件事情的背后看到了一个伟大的类比,那就是从其他人那里引用了海明威的话。也就是说,我不需要为了得到类似的功能和结果而花费精力自己去创建一个与众不同的东西,上面提到的海明威的话正是代码重用在文学上的例子。
但是,我在这里不会写代码包的好处,而是更多地提一些我的感受,这些感受会在当前以及未来的项目中积极地得到实现。我还总结了一套API规则和原语,包括了功能和实现细节。
使用API版本控制
如果你要开发一个提供客户端服务的API,你需要为最后可能的修改而做好准备。最好的办法就是通过为RESTful API提供“版本命名空间”来实现。
我们只需将版本号作为前缀添加到所有的URL里即可。
GET www.myservice.com/api/v1/posts
然而,在我研究了其他的API实现之后发现,我喜欢上了这种较短的URL样式,它把api作为是子域名的一部分,并从路由中删除了/api,这样更短、更简洁。
GET api.myservice.com/v1/posts
跨域资源共享(CORS)
需要重点关注的是,如果你打算在www.myservice.com上托管你的前端站点,而将API放在另外一个不同的子域上,例如api.myservice.com,那么你需要在后端实现CORS,这样才能使得AJAX调用不会抛出 No Access-Control-Allow-Origin header is present
这样的错误。
使用复数形式
当你从/posts请求多个帖子的时候,这样的URL看起来更明了:
// 复数形式看起来更一致,更有意义GET /v1/posts/:id/attachments/:id/comments// 不能有歧义// 这只是一个评论? 还是一个表格?GET /v1/post/:id/attachment/:id/comment
更多有关混合类型的信息,请看下文:“使用根级别的'me’端点(URL)”。
避免查询字符串
查询字符串的作用是对关系数据库返回的记录集做进一步地过滤。
/projects/:id/collections 优于 /collections?projectId=:id。/projects/:id/collections/:id/items 优于 /items?projectId=:id&collectionId=:id。
更多信息请看下文:“避免对嵌套路由的操作”。
使用HTTP方法
我们可使用下面这些HTTP方法:
GET 用于获取数据。
POST 用于添加数据。
PUT 用于更新数据(整个对象)。
PATCH 用于更新数据(附带对象的部分信息)。
DELETE 用于删除数据。
补充一点,对于修改对象的部分内容的请求来说,我认为PATCH是减少请求包大小的一个好的方法,并且它也能很好的跟自动提交/自动保存字段配合起来用。
一个很好的例子是Tumblr的“仪表盘设置”屏幕,其中,“服务的用户体验”的一些非关键性选项可以单独地编辑和保存,而不需要点最下面的提交按钮。
对于POST,PUT或PATCH的成功响应消息,应该返回更新后的对象,而不是只返回一个null。点击这里有一篇http1.0和2.0的对比。
有关响应的其他内容,请阅读下文:“JSON格式的响应和请求”。
使用封包
“我不喜欢数据封包。它只是引入了另一个键来浏览数据树。元信息应该包含在包头中。”
最初,我坚持认为封包数据是不必要的,HTTP协议已经提供了足够的“封包”来传递响应消息。
然而,根据Reddit上的回复所述,如果不封包为JSON数组,则可能会出现各种漏洞和潜在的黑客攻击。
现在建议使用封包,你应该把数据封包后再应答!
// 已封包,最顶级的对象既安全又简洁{ data: [ { ... }, { ... }, // ... ]}// 未封包,存在安全风险[ { ... }, { ... }, // ...]
同样要重点关注的是,不像其他语言那样,JavaScript之类的语言将会将空对象认为是true! 因此,在下面这种情况下,不要返回空的对象来作为响应的一部分:
// 从payload中提取封包和错误const { data, error } = payload// 错误处理if (error) { throw ... }// 否则const normalizedData = normalize(data, schema)
JSON格式的响应和请求
所有东西都应该被序列化成JSON。如果你期待从服务器上获取JSON格式的数据,那么请客气一点,请发送JSON格式的内容给服务器。请两边保持一致!
某些情况下,如果动作执行成功(例如DELETE),那我并没有什么需要返回的。但是,在某些语言(如Python)中返回一个空对象可能被认为是false,并且在开发人员调试程序的时候,这种情况并不容易发现。因此,我喜欢返回“OK”,尽管这是一个字符串,但是在返回的时候会被包装成一个简单的响应对象。
DELETE /v1/posts/:id// response - HTTP 200{ "message": "OK"}
使用HTTP状态码和错误响应
因为我们使用了HTTP方法,所以我们应当使用HTTP状态码。
我喜欢使用这些状态码:
对于数据错误
400:请求信息不完整或无法解析。
422:请求信息完整,但无效。
404:资源不存在。
409:资源冲突。
对于鉴权错误
401:访问令牌没有提供,或者无效。
403:访问令牌有效,但没有权限。
对于标准状态
200: 所有的都正确。
500: 服务器内部抛出错误。
假设要创建一个新帐户,我们提供了email和password两个值。我们希望让客户端应用程序能够阻止任何无效的电子邮件或密码太短的请求,但外部人员可以像我们的客户端应用程序一样在需要的时候直接访问API。
如果email字段丢失,则返回400。
如果password字段太短,则返回422。
如果email字段不是有效的电子邮件,则返回422。
如果email已经被使用,返回一个409。
从上面这些情况来看,有两个错误会返回422,不过他们的原因是不同的。这就是为什么我们需要一个错误码,甚至是一个错误描述。要区分代码和描述,我打算将error(代码)作为机器可识别的常量,将description作为可更改的用于人类识别的字符串。点击这里有一篇http1.0和2.0的对比。
字段校验错误
对于字段的错误,可以这样返回:
POST /v1/register// 请求{ "email": "end@@user.comx" "password": "abc"}// 响应 - 422{ "error": { "status": 422, "error": "FIELDS_VALIDATION_ERROR", "description": "One or more fields raised validation errors." "fields": { "email": "Invalid email address.", "password": "Password too short." } }}
操作校验错误
对于返回操作校验错误:
POST /v1/register// 请求{ "email": "end@user.com", "password": "password"}// 响应 - 409{ "error": { "status": 409, "error": "EMAIL_ALREADY_EXISTS", "description": "An account already exists with this email." }}
这样,你的程序的错误提取逻辑要当心非200的错误了,你可以直接从响应中检查error字段,然后将其与客户端中相应的逻辑进行比较。
status这个字段似乎也很有用,如果你不想检查响应里的元数据,那你可以在需要的时候有条件地添加这个字段。
description可作为备用的用户可读的错误消息。
密码规则
在做了很多密码规则的研究之后,我比较赞同《密码规则是废话》(https://blog.codinghorror.com/password-rules-are-bullshit/)和《NIST禁止做的事情》(https://nakedsecurity.sophos.com/2016/08/18/nists-new-password-rules-what-you-need-to-know/)这两篇帖子的观点。
整理了一些处理密码的规则:
1. 执行unicode密码的最小长度策略(最小8-10位)。
2. 检查常见的密码(例如“password12345”)
3. 检查密码熵(不允许使用“aaaaaaaaaaaaa”)。
4. 不要使用密码编写规则(至少包含其中一个字符“!@#$%&”)。
5. 不要使用密码提示(“assword”这样的)。
6. 不要使用基于知识的认证。
7. 不要超期不修改密码。
8. 不要使用短信进行双认证。
9. 使用32位以上的密码盐(salt)。
在某种程度上,所有这些规则能使密码验证更容易!
使用访问和刷新令牌
现代的无状态、RESTful API一般会使用令牌来实现身份认证。这消除了在无状态服务器上处理会话和Cookie的需要,并且可以很容易地使用Authorization头(或access_token查询参数)来调试网络请求。点击这里有一篇JWT生成token实战。
访问令牌用于认证所有未来的API请求,生命期短,不会被取消。
刷新令牌在初始登录的响应中返回,然后跟过期时间戳和与使用者的关系一起进行散列计算后存储到数据库中。这个长生命期的像密码一样的密钥,可以被用来请求新的短生命期的JWT访问令牌。刷新令牌也可以用于续订并延长其使用寿命,这意味着如果用户持续使用该服务,则无需再次登录。
但是,如果API希望签订一个不同的“密钥”,JWT就会被取消,但是这将使所有当前发出的令牌全部无效,但因为这些令牌是短生命期的,所以这并没有关系。
登录
在我的程序实现中,正常的登录过程如下所示:
1. 通过/login接收邮件和密码。
2. 检查数据库的电子邮件和密码哈希。
3. 创建一个新的刷新令牌和JWT访问令牌。
4. 返回以上两个数据。
续订令牌
正常的续订验证流程如下所示:
1. 尝试从客户端创建请求时,JWT已经过期。
2. 将刷新令牌提交到/renew。
3. 通过将刷新令牌进行哈希与数据库中保存的进行匹配。
4. 成功后,创建新的JWT访问令牌并延长到期时间。
5. 返回访问令牌。
验证令牌
通过检查到期日期和签名哈希可以校验JWT访问令牌的有效性。如果校验失败,则认为是一个无效的令牌。
如果验证通过,则JWT的有效载荷中包含了一个uid,它用于在API响应的上下文中传递一个对应的user对象来检查权限/角色,并相应地创建/读取/更新/删除数据。
终止会话
由于刷新令牌存储在数据库中,因此可以将其删除来“终止会话”。这为用户提供了一个控制方法,即他们可以通过主动的刷新令牌“会话”来保护自己的帐户,并且通过这种方法来进行多次重复认证(通过调整超时时间戳来实现)。
让JWT保持小巧
在把信息序列化到JWT访问令牌中时,请尽可能地让这个信息小巧,身份验证令牌的生命期不需要很长,因此没必要。如果可以的话,只序列化用户的uid(id)就可以了,其余的可以通过“GET /me”来传递。点击这里有一篇JWT生成token实战。
还值得注意的是,存储在JWT有效载荷中的任何敏感信息并不安全,因为它只是一个经过base64编码的字符串。
使用根级别的“Me”端点(URL)
一般人会使用/profile这个URL来提供自身的基本属性。但是,我也看到过比较混论的实现,例如对于/users/:id这种接受整数的URL,它竟然允许传入字符串me来指向自身的属性。
通过/me访问自身信息的更深层次的URL,例如/me的/settings或者/billing信息,而通过users/:id/billing访问其他用户的信息。
// 不推荐GET /v1/users/me// 推荐,因为更短,没有把整数和字符串混在一起GET /v1/me
避免对嵌套路由的操作
有一个采用了以上一些设计理念的重构的项目,最后却设计出了一个难用的URL系统:
// 一个长长的URLPATCH /v1/projects/:id/collections/:id/items/:id/attachments
如果要POST上传一个附件,这个URL可能看起来还行,但是如果在开发客户端应用程序时想要实现像对附件标星号这么一个简单操作的功能的话,那你就需要重写相关的代码。相关代码如下:
const apiRoot = 'https://api.myservice.com/v1'const starAttachment = (projectId, collectionId, itemId, attachmentId, starred) => { fetch( `${apiRoot}/projects/${projectId}/collections/${collectionId}/items/${itemId}/attachments/${attachmentId}`, { method: 'PATCH', body: JSON.stringify({ starred }), // ... }}
attachments.js
助手函数的代码如下:
import { starAttachment } from './actions/attachments.js'class MyComponent extends React.Component { doStarAttachment = (id, starred) => { // now all the "boilerplate" for starring the attachment const { projectId, collectionsId, itemId } = this.props.entities.attachments[id] // now actually plugging in all that information starAttachment(projectId, collectionId, itemId, id, starred) } // ...}
MyComponent.js
如果你把获取附件属性这个功能委派给服务器来实现,并且只使用根级别的URL,这样不是更好吗?
const apiRoot = 'https://api.myservice.com/v1'const starAttachment = (id, starred) => { fetch( `${apiRoot}/attachments/${id}`, { method: 'PATCH', body: JSON.stringify({ starred }), // ... }}
attachments.js
import { starAttachment } from './actions/attachments.js'class MyComponent extends React.Component { doStarAttachment = (id, starred) => { // simple as, and you could even easily call it from a gallery-like list starAttachment(id, starred) } // ...}
MyComponent.js
总的来说,我认为这两种方法各有各的优势,而我倾向于用一个长的路径来创建/提取资源,用一个短的路径来更新/删除资源。
提供分页功能
分页很重要,因为你不会想让一个简单的请求就获得数千行的记录。这个问题似乎很明显,但是还是会有许多人忽略这个功能。
有多种方法来实现分页:
“From”参数
可以说这是最容易实现的,API接受一个from查询字符串参数,然后从这个偏移量开始返回有限数量的结果(通常返回20个结果)。
另外最好提供一个limit参数来限制最大记录数,例如Twitter,最大限制为1000,而默认限制为200。
“下一页”令牌
如果每页20个结果之外还有其他的结果,谷歌的Places API就会在响应中返回next_page_token。然后,服务器在新的请求中接收到这个令牌后,就会返回更多的结果,并附带新的next_page_token,直到所有的结果全部都返回给客户端。
Twitter使用参数next_cursor实现了类似的功能。
实现“健康检查”URL
很有必要提供一种方法来输出一个简单的响应,以此来表明API实例是活着的,不需要重新启动。这个功能也很有用,通过它可以很方便地检查某个时间点的某台服务器上的API是什么版本,而这无需通过认证。
GET /v1// response - HTTP 200{ "status": "running", "version": "fdb1d5e"}
我提供了status和version这两个值。另外值得一提的是,这个值是从version.txt文件读取到的,如果读取错误或者文件不存在,则默认值为 :__UNKNOWN__
。
原文:https://medium.com/@petertboyer/learn-restful-api-design-ideals-c5ec915a430f
作者:Peter Boyer
翻译:雁惊寒