随着数据模型的稳定,下一步是为您的Web应用创建一个公开的API当我们的API对外发布,对其进行大幅度的更改并同时保持向后兼容性将会变得非常困难,那么这时候会出现一些问题,所以我们今天来探索什么样的设计才是RESTful API 设计最佳实践?尽管网络上有许多关于API设计的指导思想,但并没有我看起来非常好或者说最佳的实践。这引出了几个关键的问题:应该接受哪种数据格式?如何实施认证?是否需要对API进行版本控制?
在为SupportFu(一款轻量级的Zendesk替代方案)设计API时,我会努力提供一些实用的答案。我的目标是设计出一个既易于使用又足够灵活的API,以满足我们用户界面的需求。以下是我在设计过程中的一些考虑因素:
在文章开始之前,我们不妨了解一下Web3 api 可以有帮助于更好的研究学习本篇文章,这里有很多设计比较规范的api,并且有很多api设计的实践案例,可以去学习了解一下再来看本篇文章,从而更有利于学习。
API的关键要求
许多网上的API设计观点往往是学术性的讨论,它们更多地关注于模糊标准的主观解释,而不是实际操作中的具体应用。本文的目标是提供一套实用的最佳实践,旨在为当今的Web应用设计出易于使用且高效的API。如果某些标准感觉不适合我们的具体需求,我不会盲目遵循。为了更好地进行决策,我已经确定了API必须满足的一些核心要求:
- 遵循Web标准:API应在适当的地方采用Web标准,以确保与其他系统的互操作性和一致性。
- 对开发者友好:API应当易于理解和使用,并且能够直接在浏览器地址栏中进行测试和探索。
- 简单、直观和一致:API的设计应当力求简单明了,确保其行为和响应的一致性,让开发者感到舒适和方便。
- 足够的灵活性:API应当具备足够的灵活性来支持和增强SupportFu的用户界面需求。
- 高效性与平衡:API应当高效运行,同时还需要在性能和其他需求之间找到恰当的平衡点。
API本质上是开发者的用户界面(UI)。像任何其他UI一样,确保其用户体验得到认真考虑是非常重要的。通过遵循上述原则,我们可以创建一个既符合行业最佳实践又能够适应SupportFu特定需求的API。这样不仅能够提升开发者的体验,还能确保API在未来能够持续满足业务需求。
使用 RESTful URLs and actions
这些REST的关键原则与将你的 API 分割成逻辑资源紧密相关。使用HTTP请求控制这些资源,其中,这些方法(GET, POST, PUT, PATCH, DELETE)具有特殊含义。
可是我该整出什么样的资源呢?好吧,它们应该是有意义于 API 使用者的名词(不是动词)。虽然内部Model可以简单地映射到资源上,但那不一定是个一对一的映射。这里的关键是不要泄漏与API不相关的实现细节。一些相关的名词可以是 票,用户和小组。
REST 非常棒的是,利用现有的 HTTP 方法在单个的 /tickets 接入点上实现了显著的功能。没有什么方法命名约定需要去遵循,URL 结构是整洁干净的。 REST 太棒了!
keep-it-simple原则可以在此应用。虽然你内在的语法知识会告诉你用复数形式描述单一资源实例是错误的,但实用主义的答案是保持URL格式一致并且始终使用复数形式。不用处理各种奇形怪状的复数形式(比如person/people,goose/geese)可以让API消费者的生活更加美好,也让API提供者更容易实现API(因为大多数现代框架天然地将/tickets和/tickets/12放在同一个控制器下处理)。
但是你该如何处理(资源的)关系呢?如果关系依托于另外一个资源,Restful原则提供了很好的指导原则。让我们来看一个例子。SupportFu的一个ticket包含许多消息(message)。这些消息逻辑上与/tickets接入点的映射关系如下:
- GET /tickets/12/messages – 获取ticket #12下的消息列表
- GET /tickets/12/messages/5 – 获取ticket #12下的编号为5的消息
- POST /tickets/12/messages – 为ticket #12创建一个新消息
- PUT /tickets/12/messages/5 – 更新ticket #12下的编号为5的消息
- PATCH /tickets/12/messages/5 – 部分更新ticket #12下的编号为5的消息
- DELETE /tickets/12/messages/5 – 删除ticket #12下的编号为5的消息
或者如果某种关系不依赖于资源,那么在资源的输出表示中只包含一个标识符是有意义的。API消费者然后除了请求资源所在的接入点外,还得再请求一次关系所在的接入点。但是如果一般情况关系和资源一起被请求,API可以提供自动嵌套关系表示到资源表示中,这样可以防止两次请求API。
如果Action不符合CRUD操作那该怎么办?
这是一个可能让人感到模糊不解的地方。有几种处理方法:
- 重新构造这个Action,使得它像一个资源的field(我理解为部分域或者部分字段)。这种方法在Action不包含参数的情况下可以奏效。例如一个有效的action可以映射成布尔类型field,并且可以通过PATCH更新资源。
- 利用RESTful原则像处理子资源一样处理它。例如,Github的API让你通过PUT /gists/:id/star 来 star a gist ,而通过DELETE /gists/:id/star来进行 unstar 。
- 有时候你实在是没有办法将Action映射到任何有意义的RESTful结构。例如,多资源搜索没办法真正地映射到任何一个资源接入点。这种情况,/search 将非常有意义,虽然它不是一个名词。这样做没有问题 – 你只需要从API消费者的角度做正确的事,并确保所做的一切都用文档清晰记录下来了以避免(API消费者的)困惑。
总是使用 SSH
总是使用SSL,没有例外。今天,您的web api可以从任何地方访问互联网(如图书馆、咖啡店、机场等)。不是所有这些都是安全的,许多不加密通信,便于窃听或伪造,如果身份验证凭证被劫持。
另一个优点是,保证总是使用SSL加密通信简化了认证效果——你可以摆脱简单的访问令牌,而不是让每个API请求签署。
要注意的一点是非SSL访问API URLs。不要重定向这些到对应的SSL。相反,抛出一个系统错误!最后一件你想要的是配置不佳的客户发送请求到一个未加密的端点,只是默默地重定向到实际加密的端点。
文档
API的好坏关键看其文档的好坏. 好的API的说明文档应该很容易被找到,并能公开访问。在尝试任何整合工作前大部分开发者会先查看其文档。当文档被藏于一个PDF之中或要求必须登记信息时,将很难被找到也很难搜索到。
好的文档须提供从请求到响应整个循环的示例。最好的是,请求应该是可粘贴的例子,要么是可以贴到浏览器的链接,要么是可以贴到终端里的curl示例 。 GitHub 和 Stripe 在这方面做的非常出色。
一旦你发布一个公开的API,你必须承诺”在没有通告的前提下,不会更改APIDe功能” .对于外部可见API的更新,文档必须包含任何将废弃的API的时间表和详情。应该通过博客(更新日志)或者邮件列表送达更新说明(最好两者都通知)。
版本控制
必须对API进行版本控制。版本控制可以快速迭代并避免无效的请求访问已更新的接入点。它也有助于帮助平滑过渡任何大范围的API版本变迁,这样可以继续支持旧版本API。
关于API的版本是否应该包含在URL或者请求头中 莫衷一是。从学术派的角度来讲,它应该出现在请求头中。然而版本信息出现在URL中必须保证不同版本资源的浏览器可浏览性(browser explorability),如果从信息安全的角度出发,我觉得这算一种信息泄露,版本号泄露,是可以造成安全隐患的,所以我觉得不应该把版本号弄上去。
我非常赞成 approach that Stripe has taken to API versioning – URL包含一个主版本号(比如http://shonzilla/api/v1/customers/1234)
),但是API还包含基于日期的子版本(比如http://shonzilla/api/v1.2/customers/1234),可以通过配置HTTP请求头来进行选择。这种情况下,主版本确保API结构总体稳定性,而子版本会考虑细微的变化(field deprecation、接入点变化等)。
API不可能完全稳定。变更不可避免,重要的是变更是如何被控制的。维护良好的文档、公布未来数月的deprecation计划,这些对于很多API来说都是一些可行的举措。它归根结底是看对于业界和API的潜在消费者是否合理。
为了保持基本资源URL的简洁性,复杂的过滤、排序和搜索需求通常可以通过添加查询参数到基本URL上来轻松实现。下面是具体的细节:
过滤
- 对于每个字段,可以使用一个唯一的查询参数来实现过滤。例如,当你从“/tickets”端点请求一个票据列表时,你可能只想获取那些状态为“open”的票据。这可以通过发送如下请求来实现: 深色版本
1GET /tickets?state=open
其中,“state”是一个过滤查询参数。
排序
- 类似于过滤,一个通用的排序参数可以用来描述排序规则。为了适应更复杂的排序需求,排序参数可以接受一个由逗号分隔的字段列表,每个字段前都可以加上一个负号来表示降序排序。以下是一些示例: 深色版本
1GET /tickets?sort=-priority // 按“priority”字段降序排序 2GET /tickets?sort=-priority,created_at // 按“priority”字段降序排序;在同一优先级内,按“created_at”字段升序排序
搜索
- 当基本的过滤不足以满足需求时,你可以利用全文搜索的功能。如果你已经在使用Elasticsearch或其他基于Lucene的技术,那么可以将全文搜索能力直接暴露给API,作为资源端点的一个查询参数,通常命名为“q”。搜索类查询可以直接交给搜索引擎处理,并且返回的结果格式应当与普通的列表一致。
结合以上各项,我们可以构建如下查询:
- 获取最近更新的票据: 深色版本
1GET /tickets?sort=-updated_at
- 获取最近更新且状态为关闭的票据: 深色版本
1GET /tickets?state=closed&sort=-updated_at
- 获取包含关键词“return”,优先级最高、最先创建的且状态为开放的票据: 深色版本
1GET /tickets?q=return&state=open&sort=-priority,created_at
一般查询的别名
- 为了提高普通用户的API使用体验,可以考虑将常用的条件封装进易于访问的RESTful路径中。例如,最近关闭的票据查询可以封装成: 深色版本
1GET /tickets/recently_closed
通过这些方法,我们不仅可以让API更加灵活和强大,还能保证它的易用性和一致性。
限制哪些字段由API返回
API的使用者并不总是需要一个资源的完整表示。选择返回字段的功能由来已久,它使得API使用者能够最小化网络阻塞,并加速他们对API的调用。
使用一个字段查询参数,它包含一个用逗号隔开的字段列表。例如,下列请求获得的信息将刚刚足够展示一个在售票的有序列表:
GET /tickets?fields=id,subject,customer_name,updated_at&state=open&sort=-updated_at
更新和创建应该返回一个资源描述
一个 PUT, POST 或者 PATCH 调用可能会对指定资源的某些字段造成更改,而这些字段本不在提供的参数之列 (例如: created_at 或 updated_at 这两个时间戳)。 为了防止API使用者为了获取更新后的资源而再次调用该API,应当使API把更新(或创建)后的资源作为response的一部分来返回。
以一个产生创建活动的 POST 操作为例, 使用一个 HTTP 201 状态代码 然后包含一个 Location header 来指向新生资源的URL。
你是否应该HATEOAS?
(译注:Hypermedia as the Engine of Application State (HATEOAS)超媒体作为应用程序状态引擎)
对于API消费方是否应该创建链接,或者是否应该将链接提供给API,有许多混杂的观点。RESTful的设计原则指定了HATEOAS ,大致说明了与某个端点的交互应该定义在元数据(metadata)之中,这个元数据与输出结果一同到达,并不基于其他地方的信息。
虽然web逐渐依照HATEOAS类型的原则运作(我们打开一个网站首页并随着我们看到的页面中的链接浏览),我不认为我们已经准备好API的HATEOAS了。当浏览一个网站的时候,决定点击哪个链接是运行时做出的。然而,对于API,决定哪个请求被发送是在写API集成代码时做出的,并不是运行时。这个决定可以移交到运行时吗?当然可以,不过顺着这条路没有太多好处,因为代码仍然不能不中断的处理重大的API变化。也是说,我认为HATEOAS做出了承诺,但是还没有准备好迎接它的黄金时间。为了完全实现它的潜能,需要付出更多的努力去定义围绕着这些原则的标准和工具。
目前而言,最好假定用户已经访问过输出结果中的文档&包含资源标识符,而这些API消费方会在制作链接的时候用到。关注标识符有几个优势——网络中的数据流减少了,API消费方存储的数据也减少了(因为它们存储的是小的标识符而不是包含标识符的URLs)。
同样的,在URL中提供本文倡导的版本号,对于在一个很长时间内API消费方存储资源标识符(而不是URLs),它更有意义。总之,标识符相对版本是稳定的,但是表示这一点的URL却不是的!
只返回JSON
是时候在API中丢弃XML了。XML冗长,难以解析,很难读,他的数据模型和大部分编程语言的数据模型 不兼容,而他的可扩展性优势在你的主要需求是必须序列化一个内部数据进行输出展示时变得不相干,只返回json的话,更好一些。
但是,如果你的客户群包括大量的企业客户,你会发现自己不得不支持XML的方式。如果你必须这样,一个新问题出现了:
媒体类型是应该基于Accept头还是基于URL呢 ? 为确保浏览器的浏览性,应该基于URL。这里最明智的选择是在端点URL后面附加 .json 或 .xml 的扩展.
字段名称书写格式的 snake_case vs camelCase
如果你在使用JSON (JavaScript Object Notation) 作为你的主要表示格式,正确的方法是遵守JavaScript命名约定——对字段名称使用camelCase!如果你要走用各种语言建设客户端库的路线,最好使用它们惯用的命名约定—— C# & Java 使用camelCase, python & ruby 使用snake_case。
深思:我一直认为snake_case比JavaScript的camelCase约定更容易阅读。我没有任何证据来支持我的直觉,直到现在,基于从2010年的camelCase 和 snake_case的眼动追踪研究 (PDF),snake_case比驼峰更容易阅读20%!这种阅读上的影响会影响API的可勘探性和文档中的示例。
许多流行的JSON API使用snake_case。我怀疑这是由于序列化库遵从它们所使用的底层语言的命名约定。也许我们需要有JSON序列库来处理命名约定转换。
默认情况下漂亮的打印和支持gzip
为了提高API的用户体验,提供易于阅读的输出至关重要。默认情况下,API应该返回格式化的(即带有空白符的)JSON数据,以便用户能够更容易地理解和调试。虽然这可能会稍微增加数据传输量,但在大多数情况下,这种影响非常小。此外,通过使用GZIP压缩,可以显著减少传输的数据量,从而抵消因格式化带来的额外开销。
默认格式化输出
- 可读性:默认情况下,API应返回格式化的JSON数据,即使这意味着会有少量的额外空格和换行符。这对于调试特别有用。
- GZIP压缩:启用GZIP压缩可以大幅减少传输的数据量,即使是格式化的数据也能达到良好的压缩效果。例如,Twitter在其流式API中启用GZIP压缩后,在某些情况下节省了高达80%的带宽。
不要默认使用大括号封装
- 简化结构:避免默认使用大括号封装(例如
{ "data": { ... } }
),除非在特殊情况下确实需要这样做。现代Web技术,如CORS(跨源资源共享)和RFC 5988中的链接头部,减少了对这种封装的需求。 - 特殊用途封装:对于需要JSONP支持的跨域请求或无法处理HTTP头部信息的客户端,可以使用大括号封装。例如,如果请求中包含了一个
callback
或jsonp
参数,则响应应采用完整的封装形式,并将HTTP状态码和分页信息嵌入到JSON负载中。
使用JSON编码的请求体
- POST, PUT, PATCH:对于这些方法,API应接受JSON编码的请求体,并要求客户端设置
Content-Type: application/json
头部。如果客户端没有设置正确的Content-Type
,API应返回415 Unsupported Media Type错误。 - URL编码的问题:虽然简单且广泛支持,但URL编码缺乏数据类型概念和清晰的层次结构,这使得它不太适合用于复杂的API。
分页
- 链接头部:遵循RFC 5988,使用链接头部来提供分页信息,如
Link
头部,可以方便地告诉客户端如何获取下一页或最后一页的数据。 - 额外信息:除了链接头部外,还可以使用自定义HTTP头部(如
X-Total-Count
)来提供额外的信息,例如结果总数。
自动装载相关资源
- 嵌入字段:允许客户端通过一个查询参数(如
embed
)指定要嵌入的相关资源。例如,GET /ticket/12?embed=customer.name,assigned_user
。 - 性能考量:尽管嵌入相关资源可以提高效率,但要注意避免N+1查询问题,这可能导致数据库性能下降。
重写/覆盖HTTP方法
- 客户端限制:对于只能处理GET和POST请求的客户端,API可以使用
X-HTTP-Method-Override
头来重写HTTP方法,例如,客户端可以在POST请求中设置该头为PUT
或DELETE
。 - 安全实践:重要的是,GET请求不应更改服务器上的数据,因此不应允许通过GET请求重写HTTP方法。
通过这些最佳实践,可以提高API的可用性和性能,同时保持其设计的一致性和优雅性。
速率限制
为了防止滥用,标准的做法是给API增加某种类型的速率限制。RFC 6585 中介绍了一个HTTP状态码429 请求过多来实现这一点。
不论怎样,在用户实际受到限制之前告知他们限制的存在是很有用的。这是一个现在还缺乏标准的领域,但是已经有了一些流行的使用HTTP响应头信息的惯用方法。
最少时包含下列头信息(使用Twitter的命名约定 来作为头信息,通常没有中间词的大写):
- X-Rate-Limit-Limit – 当期允许请求的次数
- X-Rate-Limit-Remaining – 当期剩余的请求次数
- X-Rate-Limit-Reset – 当期剩余的秒数
为什么对X-Rate-Limit-Reset不使用时间戳而使用秒数?
一个时间戳包含了各种各样的信息,比如日期和时区,但它们却不是必需的。一个API使用者其实只是想知道什么时候能再次发起请求,对他们来说一个秒数用最小的额外处理回答了这个问题。同时规避了时钟偏差的问题。
有些API给X-Rate-Limit-Reset使用UNIX时间戳(纪元以来的秒数)。不要这样做!
为什么对X-Rate-Limit-Reset使用UNIX时间戳是不好的做法?
HTTP 规范已经指定使用RFC 1123 的日期格式 (目前被使用在日期, If-Modified-Since & Last-Modified HTTP头信息中)。如果我们打算指定一种使用某种形式时间戳的、新的HTTP头信息,我们应当遵循RFC 1123规定,而不是使用UNIX时间戳。
认证
一个 RESTful API 应当是无状态的。这意味着认证请求应当不依赖于cookie或session。相反,每一个请求都应当携带某种类型的认证凭证。
由于总是使用SSL,认证凭证能够被简化为一个随机产生的访问令牌,里面传入一个使用HTTP Basic Auth的用户名字段。这样做的极大的好处是,它是完全的浏览器可探测的 – 如果浏览器从服务器收到一个401未授权状态码,它仅需要一个弹出框来索要凭证即可。
然而,这种基于基本认证的令牌的认证方法,仅在满足下列情形时才可用,即用户可以把令牌从一个管理接口复制到API使用者环境。当这种情形不能成立时,应当使用OAuth 2来产生安全令牌并传递给第三方。OAuth 2使用了承载令牌(Bearer tokens) 并且依赖于SSL的底层传输加密。
一个需要支持JSONP的API将需要第三种认证方法,因为JSONP请求不能发送HTTP基本认证凭据(HTTP Basic Auth)或承载令牌(Bearer tokens) 。这种情况下,可以使用一个特殊的查询参数access_token。注意,使用查询参数token存在着一个固有的安全问题,即大多数的web服务器都会把查询参数记录到服务日志中。
这是值得的,所有上面三种方法都只是跨API边界两端的传递令牌的方式。实际的底层令牌本身可能都是相同的。
缓存
HTTP 提供了一套内置的缓存框架! 所有你必须做的是,包含一些额外的出站响应头信息,并且在收到一些入站请求头信息时做一点儿校验工作。
有两种方式: ETag和Last-Modified
ETag: 当产生一个请求时, 包含一个HTTP 头,ETag会在里面置入一个和表达内容对应的哈希值或校验值。这个值应当跟随表达内容的变化而变化。现在,如果一个入站HTTP请求包含了一个If-None-Match头和一个匹配的ETag值,API应当返回一个304未修改状态码,而不是返回请求的资源。
Last-Modified: 基本上像ETag那样工作,不同的是它使用时间戳。在响应头中,Last-Modified包含了一个RFC 1123格式的时间戳,它使用If-Modified-Since来进行验证。注意,HTTP规范已经有了 3 种不同的可接受的日期格式 ,服务器应当准备好接收其中的任何一种。
错误
像一个HTML错误页面给访问者展示了有用的错误信息一样,一个API应当以一种已知的可使用的格式来提供有用的错误信息。 错误的表示形式应当和其它任何资源没有区别,只是有一套自己的字段。
API应当总是返回有意义的HTTP状态代码。API错误通常被分成两种类型: 代表客户端问题的400系列状态码和代表服务器问题的500系列状态码。最简情况下,API应当把便于使用的JSON格式作为400系列错误的标准化表示。如果可能(意思是,如果负载均衡和反向代理能创建自定义的错误实体), 这也适用于500系列错误代码。
一个JSON格式的错误信息体应当为开发者提供几样东西 – 一个有用的错误信息,一个唯一的错误代码 (能够用来在文档中查询详细的错误信息) 和可能的详细描述。这样一个JSON格式的输出可能会像下面这样:
{ "code" : 1234, "message" : "Something bad happened :(", "description" : "More details about the error here" }
对PUT, PATCH和POST请求进行错误验证将需要一个字段分解。下面可能是最好的模式:使用一个固定的顶层错误代码来验证错误,并在额外的字段中提供详细错误信息,像这样:
{ "code" : 1024, "message" : "Validation Failed", "errors" : [ { "code" : 5432, "field" : "first_name", "message" : "First name cannot have fancy characters" }, { "code" : 5622, "field" : "password", "message" : "Password cannot be blank" } ] }
总结
一个API是一个给开发者使用的用户接口。要不仅功能上可用,更要用起来要更方便好用,这样才行。