原文链接:Web API design best practices - Azure Architecture Center | Microsoft Docs
现在网络上已经有了很多服务商的公开API,可以让各类客户端调用,那么怎样才是一个设计优良的web API呢?一般来讲应该具备以下标准:
平台无关性:使用API的可以是任何客户端,它们不用关心API是怎么实现的。这就要求了交互时使用到的协议要标准化,并且要存在一种机制,能确保客户端和服务提供方在数据格式上达成一致。
服务演化: web API可以自行更新迭代自己的功能,使用它的客户端不用做出任何修改就能继续使用这些API。服务端提供的所有功能要具备可发现性,使得客户端能充分使用到它们。
下面来说说设计web API时要考虑的一些关键问题。
什么是REST?
在2000年,Roy Fielding提出使用表述性状态转移(Representational State Transfer ,简称REST)来设计网络服务的构建方法。REST是一种基于超媒体来构建分布式系统的架构风格,它不应关注底层服务如何,也不用跟HTTP绑定,不过大部分REST API的实现还是基于了HTTP协议。让我们先关注下如何使用HTTP来设计REST API接口。
在HTTP上使用REST的好处是它是一个有公开标准的协议,不需要这些API的提供方或使用方依赖任何特定实现方案,服务方和使用方可以用任意语言、工具包来提供REST服务实现,或创建HTTP请求以及解析HTTP响应报文。
基于HTTP设计RESTful API的主要原则有:
- REST API的核心是资源,可以是客户能访问到的任意物体、数据或服务
- 每种资源都要有一个独一无二的URI来作为唯一标识符定位到该资源。比如一种客户订单可以这样描述:
https://adventure-works.com/orders/1
- 客户通过交换资源表述来与服务交互。许多web API使用JSON作为数据转换格式。例如,一个针对上面URI的GET请求可能得到如下内容:
{"orderId":1,"orderValue":99.90,"productId":1,"quantity":1}
REST API使用一套统一的接口,来帮助解耦调用端和服务实现。在HTTP上构建REST API时,统一接口使用标准的HTTP动词来执行资源的操作,经常使用到的操作有GET,POST,PUT,PATCH和DELETE。
REST API是无状态的。由于发出的HTTP请求是独立且无序的,因此没办法在请求间保持这种临时会话状态。能存储信息的地方只能是API资源自身,而每个请求应当是原子操作。这样的约束要求客户和特定服务器间不能保留任何关联,从而使得服务具备了高度的可伸缩性。任意的服务器可以处理任何的客户请求。但是,其他因素可能会限制这种可伸缩性,比如很多服务都要写数据到后端存储中,而这种单一存储就很难扩展。关于这种数据存储该如何扩展的策略方法,可以参考Horizontal, vertical, and functional data partitioning.
REST API的重要驱动核心是其表述内容中包含的超媒体链接。举个例子,下面展示了一个包含订单信息的JSON格式内容,它包含了一些链接,用来获得或更新与该订单关联的客户数据。
{
"orderID":3,
"productID":2,
"quantity":4,
"orderValue":16.60,
"links": [
{"rel":"product","href":"https://adventure-works.com/customers/3", "action":"GET" },
{"rel":"product","href":"https://adventure-works.com/customers/3", "action":"PUT" }
]
}
在2008年,Leonard Richardson提出了一个web API成熟度模型:
- Level 0:仅有一个URI,用来处理所有POST请求 .
- Level 1:不同的资源提供单独的URI请求路径.
- Level 2:使用HTTP的method来定义在资源上的不同操作.
- Level 3:使用了超媒体(HATEOS,下面会提到)
围绕资源设计API
web API需要暴露一些业务实体, 拿电商系统来举例,主要的实体基本上就是客户和订单了。可以发送一个包含了订单信息的HTTP POST请求来创建一个订单,而响应报文应当告知这个订单是否创建成功。请记得,提供出来的这些资源操作URI应尽量使用名词(资源名)来描述,而非动词(操作动作)。
https://adventure-works.com/orders // Good
https://adventure-works.com/create-order // Avoid
一个所谓的资源不一定要是一个真实的物理实体,比如对于订单来说,它可能在内部实现上用到了数张关系型数据库中的表,但是对于客户而言,它就是一个单独的实体。不要创建一些仅仅是把数据库对象做了个简单镜像展示的API。REST的目标是描述实体和可在其上执行的操作,而客户不应接触到内部实现。
实体通常会有其集合形态(一组订单、一些顾客)。相比与其中的单一实体,一个集合也是一种单独的资源,也应提供其独有的访问URI。例如下面这个URI就表示了一组订单:
https://adventure-works.com/orders
使用HTTP GET调用该URI,就会得到一组数据,而其中每一个元素也应有其单独的访问URI,通过HTTP GET方法访问它们,就能得到每一个元素的详细信息。
采用一个统一的命名规范来定义这些URI。通常提供的内容是集合时应该使用名字的复数形式。一个很好的做法是在集合和元素间提供层级结构,比如使用/customers
来代表客户集合,而/customers/5
则指向ID是5的这个单独客户。这样做会让你的web API很直观。并且很多web API框架提供了参数URI路径的支持,所以你可以使用/customers/{id}
来做资源路由。
另外需要考虑的是不同资源间的关系,和你应该如何暴露出这种联系。例如我们知道/customers/5/orders
应该代表了顾客5的所有订单,但是如果路径是从订单开始,列出其关联的所有客户的URI就可能是/orders/99/customer
。然而,这种模式的过分扩展实现起来会很繁杂笨重。一个更好的方案是,在HTTP的响应报文体中提供关联资源的导航链接。我们会在下面章节展开详细探讨。
在一些复杂系统中,像/customers/1/orders/99/products
这样的提供给客户端多级关系路径访问的URI,看起来似乎很方便,但是这样的复杂级别会让维护变得困难,并且难以在将来调整资源间的层级关系。因此,好的做法是让资源URI尽量简单明了,一旦应用程序有了对资源的引用,就应该可以使用这个引用来查找与该资源相关的项。比如前面查找客户1所有订单的查询URI可以替换为/customers/1/orders
,然后通过/orders/99/products
来得到订单中的所有商品。
注意:不要使用比
/collection/item/collection
更复杂的查询URI。
另外要考虑每次请求对服务器的负载影响。请求越多,负载越高。因此,不要提供太多过于细小琐碎的资源接口。这样的接口可能需要客户端执行多次请求才能得到想到的数据。可以考虑把一些相关数据组合到一个资源中,以使得一次查询请求便能满足需求。不过,你还是得做好权衡,来避免拿到过多的无用数据。另外,检索的数据过大,还会让请求变得缓慢,并产生额外的带宽成本。跟多关于性能错误模式的介绍,可参看Chatty I/O 和 Extraneous Fetching.
要避免让web API和底层数据库产生依赖关系,例如,当你使用了关系数据库存储数据,web API并不需要把所有的表都暴露为资源集合,这样设计很不好,合适的做法是,把web API当成是数据库的抽象,或者可以考虑使用一个映射层来建立数据库和web API的映射关系。这样客户端就可以与底层数据库隔离开来,从而在底层数据表变化时不受到影响。
最后,可能不太容易做到将所有web API实现的操作找到合适的资源描述来建立映射关系,一些场景里,发出的HTTP请求只是用来执行了某个函数,然后将结果作为HTTP响应报文返回,比如用作简单加减计算的web API,可能会使用伪资源作为URI,并将查询字符串当作计算的参数。例如,一个URI为/add?operand1=99&operand2的GET请求,会得到一个报文内容为100的返回结果。不过最好还是有节制的使用这种形式的URIs。
通过HTTP方法定义API操作
HTTP协议定义了一些有特殊语义的请求方法,在RESTful接口中最常用到的有:
- GET 获取指定URI表述的资源数据,响应报文里包含请求资源的详细内容。
- POST 使用指定URI创建资源对象,并返回对象的详细内容。注意POST有时也用来触发实际上并不会创建资源的操作。
- PUT 根据请求消息的不同指定,创建或更新指定URI的资源对象
- PATCH 可以在请求体中指定一系列变更内容,来执行对应资源的部分更新动作
- DELETE 使用指定URI删除资源
资源是集合还是单独个体,会使得请求的效果有所不同。下面总结了一些电商系统中常见的RESTful实现惯例。有些请求没有处理 - 这取决于场景。
Resource | POST | GET | PUT | DELETE |
---|---|---|---|---|
/customers | 创建一个新客户 | 检索所有客户 | 批量更新客户 | 删除所有客户 |
/customers/1 | 无 | 检索客户1的详细信息 | 更新客户1的详细信息(若存在) | 删除客户1 |
customers/1/orders | 为客户1创建一个新订单 | 检索客户1的所有订单 | 批量更新客户1的所有订单 | 删除客户1的所有订单 |
强调下POST、PUT、PATCH方法的差异:
- POST请求会创建一个资源,服务端会给新创建的资源分配一个URI,并将其返回给客户端。在REST模式下,经常会对集合使用POST操作,新资源被创建后便被加入到集合中。POST请求也用在提交数据给现存资源来完成一些操作,过程中并不会有新资源被创建。
- PUT请求用来创建新资源或更新已经存在的资源。客户端指定资源的URI,并在请求体中包含资源的完整表示。如果指定的资源已经存在,它便会被请求内容替换掉,否则便会创建一个新资源(如果服务端支持该操作)。PUT请求更多的用在单独资源上,而不是集合资源,比如一个特定的客户。服务端通常会支持PUT请求的更新操作,但不一定会支持创建资源,这取决于客户端是否可以在资源不存在时分派新URI给它。如果不支持,请使用POST来创建新资源,然后用PUT或PATCH来更新它。
- PATCH请求用来执行已存在资源的部分更新。客户端指定资源的URI,并在请求体中携带需要执行变更的内容集合。这种做法会比PUT更有效,鉴于客户端只需要发送变更的部分,而不是整个资源数据。从技术上来说PATCH也可以创建资源,通过把所有资源变更数据都指定为null来实现,不过最终要看服务端是否支持这种做法。
PUT请求必须是幂等的。如果客户端发送了多次同样的请求,得到的结果应该是一样的(同样的资源被变更为了同样的内容)。POST和PATCH则没有这样的要求。
遵守HTTP语义
本节介绍HTTP规范下需要考虑的一些常见注意事项,没有涵盖所有可能细节和场景,如有疑问请参阅HTTP规范。
Media types
上面提到,客户端和服务端会交换资源数据。比如在POST请求中,请求体需包含要创建资源的表述,而GET请求中,响应报文体中也会包含检索资源的表述内容。
在HTTP协议里,格式通过media types来指定,也被称为MIME types。对于非二进制数据,大部分web API提供了JSON(media type = application/json)或者XML(media type = application/xml)格式的支持。
在请求或响应中通过Content-Type消息头来指定数据格式。下面有一个包含了JSON数据的POST请求示例:
POST https://adventure-works.com/orders HTTP/1.1
Content-Type: application/json; charset=utf-8
Content-Length: 57
{"Id":1,"Name":"Gizmo","Category":"Widgets","Price":1.99}
如果服务端不支持请求的media type,那么它应该返回415这个HTTP状态码(Unsupported Media Type)。
客户端请求可以携带一个Accept的消息头来向服务端表明它可以接受报文的media type列表,例如:
GET https://adventure-works.com/orders/2 HTTP/1.1
Accept: application/json
如果服务端不能满足列出的media type请求,它应该返回HTTP状态码406(Not Acceptable)。
GET方法
GET请求成功后通常应返回状态码200(OK)。如果请求的资源不存在,则应返回404(Not Found)。
POST方法
如果POST请求创建了新资源,它应返回HTTP状态码201(Created)。新创建资源的URI应在响应报文头Location中携带,而报文体则应包含该资源的表述。
如果方法做了一些处理,但是并没有创建新资源,它同样可以返回200状态码,并把处理结果放在响应报文中,而当并没有任何处理结果需要返回时,可以使用204状态码(No Content)来代替,并返回空响应报文。
如果客户端在请求中携带了无效数据,服务端应该返回状态码400(Bad Request),响应报文中则可以放入关于错误的详细说明信息,或者提供一个链接以用来查看更多信息。
PUT方法
如果PUT方法创建了新的资源,它会返回201状态码(Created),跟POST一样。如果它更新了一个存在的资源,可以返回200(OK)或者204(No Content)。某些情况下,可能无法更新指定的资源,这时可以考虑返回HTTP状态码409(Conflict)。
可以考虑提供一个批量更新PUT方法,在方法体里放上要更新的所有资源内容,并使用URI指明要更新的资源集合。这样能降低网络开销和提高性能。
PATCH方法
使用PATCH请求时,客户端使用一种 补丁文件 的格式,向已经存在的数据资源发送更新请求。服务端会使用这个补丁文件处理更新。补丁文件并不需要描述资源的所有内容,只需要告诉更新哪些部分即可。PATCH方法的规范 (RFC 5789)并没有定义这个补丁文件要有什么样的特定格式,格式需要根据请求的media type而定。
JSON应该是web API届最通用的数据格式了。而patch方法用到的基于JSON的补丁文件格式有两个,叫做JSON patch和JSON merge patch。
JSON merge patch相对简单一点。它的结构就像是原始资源对象的JSON形式内容,只是它包含的仅仅是需要更新或者添加的数据字段集合,在补丁文件中通过将字段指定为 null
甚至还可以删除字段(不过不适用于原始资源可以显式的包含'null'值的情况)
举个例子,如果原始资源对象的JSON形式内容为:
{
"name":"gizmo",
"category":"widgets",
"color":"blue",
"price":10
}
可能对应的更新文件的内容会是这样:
{
"price":12,
"color":null,
"size":"small"
}
这个文件告诉服务端,要更新 price
,删掉 color
,并添加 size
字段,同时 name
和 category
字段没有任何改动。有关JSON merge patch的更多具体内容可以参考 RFC 7396。它对应的media type为 application/merge-patch+json
。
Merge patch并不适用于原本资源中包含显式'null'值的情况,因为补丁文件中的null有特殊含义(代表删除)。并且补丁文件不能指定更新被执行的顺序,不过这个有没有具体影响要看数据对象的处理逻辑。在RFC 6902中定义的JSON patch,相对就灵活一些。它可以指明具体要执行的操作序列,操作的类型可以是增加、删除、替换、拷贝和测试(用来验证特定值)。它的media type是 application/json-patch+json
。
下表列出了一些处理PATCH请求时可能会遇到的一些典型错误条件,和对应适当的HTTP状态码。
Error condition | HTTP status code |
---|---|
The patch document format isn't supported. | 415 (Unsupported Media Type) |
Malformed patch document. | 400 (Bad Request) |
The patch document is valid, but the changes can't be applied to the resource in its current state. | 409 (Conflict) |
DELETE方法
如果一个删除请求被成功执行,web服务端应该返回一个204状态码(No Content),这代表着执行已经成功,响应报文不需要有任何内容。如果请求的资源不存在,应该得到一个HTTP 404(Not Found)。
异步操作
有时一个POST、PUT、PATCH或者DELETE操作可能会需要执行一段时间,如果在成功相应之前一直等待,过长的延迟可能不太好接受。这时可以考虑将操作改成异步来执行。返回一个202(Accepted)状态码来表明请求已经接受并在处理中,但是还没有完成。
你应该暴露一个端点,来提供异步请求执行状态的查询,这样客户端就能轮询它来监测状态。可以在202报文头中包含这个状态端点的地址,如:
HTTP/1.1 202 Accepted
Location: /api/status/12345
如果客户端向该端点发起了一个GET请求,响应报文中应该包含操作的执行状态。另外,也可以提供其他一些信息,比如预计要消耗的时间和一个可以取消操作的链接。
HTTP/1.1 200 OK
Content-Type: application/json
{
"status":"In progress",
"link": { "rel":"cancel", "method":"delete", "href":"/api/status/12345" }
}
如果异步操作创建了新的资源,那么这个状态端点应该在操作执行完成后返回一个303(See Other),并在响应报文头信息中提供新资源的访问地址。
HTTP/1.1 303 See Other
Location: /api/orders/12345
更多相关信息,可参考 Asynchronous Request-Reply pattern.
数据过滤和分页
提供一个可以查询资源集合的URI,可能会出现客户端其实只需要一小部分数据,但却发起了一个很大数据量的请求。比如,客户程序需要查询所有成本大于某个值的所有订单,那么可能它会先请求 /orders 这个URI拿到所有订单数据,然后在客户端把这些数据作一下过滤。很显然这样处理非常的不效率,而且浪费了贷款和服务器算力。
相对的,这个API也可以提供请求URI时携带查询字符串的支持,比如 /orders?minCost=n
。然后服务端解析处理这个 minCost
查询项,并返回过滤后的结果。
一个对资源集合的GET请求总是有可能会返回一大堆数据,所以最好在你的web API中提供对单次请求返回结果数量的限制。可以考虑提供一个最大数量请求查询参数,来指明能得到的最大结果数量,同时需要提供一个偏移量参数,例如:
/orders?limit=25&offset=50
同时为了避免可能受到的拒绝服务攻击,要对这个最大数量参数设置个上限。另外,为了协助客户应用程序完成分页配置,服务端对GET请求返回分页数据时,要在响应报文中包含一些元数据,用来标明资源可用的总页数等。
类似的,还可以提供一个sort参数用来对请求数据排序,可以使用结果字段中的任意一个,比如 /orders?sort=ProductID
。不过这种做法有一个坏处,因为有些缓存实现用查询字符串作为缓存的key,sort参数的调整可能会让之前的缓存失效。
在查询数据的字段数量特别多时,还可以对返回字段作一下限制。提供一个支持逗号分隔的查询字符串参数来表示需要的字段列表,比如 /orders?fields=ProductID,Quantity
。
对每一个支持的查询字符串参数,提供一个有意义的默认值。例如,如果支持分页,最好将数据条数默认为10,偏移量(页码)默认为0。如果支持排序,将sort列默认为资源的主键,如果支持投影(可选查询列),将参数默认为所有列。
大型二进制文件的部分响应
有些资源可能包含二进制字段,比如图片和文件。这些内容在不稳定网络环境下传输可能会出现问题,比如连接中断,或者处理时间过长,为了克服这些问题,可以考虑分块获取。首先,API需要对GET请求中的Accept-Ranges这个用来请求大文件资源的消息头提供支持,这个消息头表明GET操作支持部分请求,客户端可以发起一个指定了字节数范围的GET请求,来得到一部分资源数据。
同时,最好也实现对这些资源的HTTP HEAD请求处理。HEAD请求类似于GET,只不过它只返回描述资源的HTTP消息头,而消息体是空的。客户程序可以发出HEAD请求来判断是否需要使用GET部分请求方式获取资源。例如:
HEAD https://adventure-works.com/products/10?fields=productImage HTTP/1.1
对应的响应报文为:
HTTP/1.1 200 OK
Accept-Ranges: bytes
Content-Type: image/jpeg
Content-Length: 4580
Content-Length
报文头给出了资源的总大小,而 Accept-Ranges
报文头则表明对应的GET操作支持部分结果的请求。有了这些信息,客户程序便可以把图片分成小块获取。第一个请求使用了 Range
头获取了2500个字节:
GET https://adventure-works.com/products/10?fields=productImage HTTP/1.1
Range: bytes=0-2499
响应报文使用HTTP状态码206来表明这是完整内容的一部分。Content-Length
报文头说明了消息体内的实际字节数(而非资源的完整大小),Content-Range
报文头则表明这是整个资源的哪一部分(完整4580中的0-2499这部分)
HTTP/1.1 206 Partial Content
Accept-Ranges: bytes
Content-Type: image/jpeg
Content-Length: 2500
Content-Range: bytes 0-2499/4580
[...]
客户程序可以发起后续请求来获得资源的剩余部分。
使用资源导航 (HATEOAS)
REST有一个推崇的概念,应当在不需要先了解URI方案的情况下,做到对整个资源集的导航。每一个HTTP GET请求的响应报文,都应该包含一系列超链接信息,用来获取跟请求对象有直接关系的资源,并提供这些资源上的可用操作说明。这个原则被称为 HATEOAS ,即 Hypertext as the Engine of Application State
。这个系统实际上是一种有限状态机,请求的响应报文包含了从一种状态转移到另一种状态的必要信息,跟状态转换无关的内容则不应包含在内。
当前并没有针对HATEOAS原则的通用建模标准,本节示例只展示了一种特定用途下的方案。
比如,处理订单和客户关系时,可以在订单的表现上包含一些链接,用来标识跟这个订单相关客户的一些可用操作。比如:
{
"orderID":3,
"productID":2,
"quantity":4,
"orderValue":16.60,
"links":[
{
"rel":"customer",
"href":"https://adventure-works.com/customers/3",
"action":"GET",
"types":["text/xml","application/json"]
},
{
"rel":"customer",
"href":"https://adventure-works.com/customers/3",
"action":"PUT",
"types":["application/x-www-form-urlencoded"]
},
{
"rel":"customer",
"href":"https://adventure-works.com/customers/3",
"action":"DELETE",
"types":[]
},
{
"rel":"self",
"href":"https://adventure-works.com/orders/3",
"action":"GET",
"types":["text/xml","application/json"]
},
{
"rel":"self",
"href":"https://adventure-works.com/orders/3",
"action":"PUT",
"types":["application/x-www-form-urlencoded"]
},
{
"rel":"self",
"href":"https://adventure-works.com/orders/3",
"action":"DELETE",
"types":[]
}]
}
在这个例子里,links这个数组节点包含了一个链接数据集合,每个链接数据都代表了一个相关实体的可用操作。每个链接数据又包含了关系("customer")、URI(https://adventure-works.com/customers/3)、HTTP方法,和可用的MIME类型。这些信息足够一个客户程序用来执行操作了。
链接数组里还包含了一些自引用信息,跟原本获取到的请求资源有关,它们的关系被标记为 'self'。
根据不同的资源状态,links数组节点中的内容可能会有所不同。这也正是超文本作为 "engine of application state." 的含义。
RESTful web API的版本控制
通常web API不会一成不变,随着业务需求变化,会有更多资源集合出现,资源之间的关系会变化,资源的数据结构也会有所改变。更新web API来处理变化的需求或许没什么难度,但是必须得考虑这些改动对于那些正在使用这些API的客户端程序的影响。设计和维护这些web API的程序员,对这些接口有完全的控制权,但是对那些客户端程序可并不一定是这样,有可能开发它们的是一些远处的第三方。所以关键的是能让现存的客户端程序继续正常运行,同时新的客户端程序可以充分使用这些新功能和资源。
版本控制可以使web API表明它公开的功能和资源,同时能让客户端程序向功能和资源的某个特定版本发起请求。下面几节描述了不同的版本控制方法,各有其优劣。
无版本控制
这是最简单的方法,一些内部调用API可以接受这么做。重大的改动一般体现为新的资源或新链接,对已经存在的资源添加内容一般也不会有什么问题,因为请求端程序遇到期望之外的数据内容,一般会自动忽略掉。
比如,向URI https://adventure-works.com/customers/3
发起请求,会返回一个包含了id、name和address字段的单个客户明细数据,这些数据符合请求端程序的预期:
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{"id":3,"name":"Contoso LLC","address":"1 Microsoft Way Redmond WA 98053"}
简单起见,本节示例响应报文并没有包含
HATEOAS
链接.
如果向客户资源的结构中添加了 DateCreated
字段,响应内容会变为:
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{"id":3,"name":"Contoso LLC","dateCreated":"2014-09-04T12:11:38.0376089Z","address":"1 Microsoft Way Redmond WA 98053"}
已经存在的客户程序会继续正常运行,只要它们可以忽略掉不认识的字段,而新的客户程序可以设计为能够处理这个新添加的字段。不过,如果资源的结构发生了更彻底的改动(比如删除或者重命名了某些字段),或者资源间的关系发生了变化,那就有可能使得这些正在运行的客户程序无法再正确运转。这种情况下,你就得考虑下面的这些方法了。
URI 版本控制
每当对web API或者资源结构进行改动时,可以对每个资源的URI添加一个版本号,先前的URI应继续像之前那样运作,返回原本的资源数据。
对前面的例子作下扩展,如果将 address 字段重构为包含不同组成部分的子字段(比如街道、城市、省份和邮政编码),这个版本的资源可以发布为一个包含了版本号的URI,例如 https://adventure-works.com/v2/customers/3
:
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{"id":3,"name":"Contoso LLC","dateCreated":"2014-09-04T12:11:38.0376089Z","address":{"streetAddress":"1 Microsoft Way","city":"Redmond","state":"WA","zipCode":98053}}
这种版本控制机制非常简单,但是它依赖于服务器能将请求路由到正确的服务端点。而且,随着web API的逐步迭代和服务器提供支持的版本数量增多,它会变得很笨重。另外,从纯粹主义角度去看,返回了同样数据(客户3)的URI,它们的版本不应该是不一样的。这个方案还会让 HATEOAS
的实现更加复杂,因为所有链接都不得不将版本号包含在它们的URI中。
查询字符串版本控制
相比于提供多个不通的URI,更好的做法可能是在HTTP请求上附带一个指定了资源版本的查询字符串参数,比如 https://adventure-works.com/customers/3?version=2
。给这个版本参数一个有意义的默认值,比如1,这样旧的客户程序可以忽略掉它而不受到影响。
这种做法的好处是,在语义上,同样的资源请求用了相同的URI,不过这需要相应的代码正确的解析请求中的查询字符串,并给与正确的HTTP响应。另外此方法同样会遇到跟URI版本控制一样的问题,即实现 HATEOAS
会比较麻烦。
一些旧版浏览器和网络代理不会缓存包含查询字符串的请求响应内容,在其上运行的网络程序调用web API时的性能可能会受到影响。
消息头版本控制
除了将版本号放到查询字符串参数中,还可以实现一个自定义消息头来指明需要的资源版本。这个做法需要客户端程序在所有请求上携带正确的消息头,尽管服务代码可以在请求忽略了这个消息头时给一个默认值(比如版本1)。下面这个例子使用了名为 Custom-Header
的消息头,里面的值指明了web API的版本。
Version 1:
GET https://adventure-works.com/customers/3 HTTP/1.1
Custom-Header: api-version=1
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{"id":3,"name":"Contoso LLC","address":"1 Microsoft Way Redmond WA 98053"}
Version 2:
GET https://adventure-works.com/customers/3 HTTP/1.1
Custom-Header: api-version=2
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{"id":3,"name":"Contoso LLC","dateCreated":"2014-09-04T12:11:38.0376089Z","address":{"streetAddress":"1 Microsoft Way","city":"Redmond","state":"WA","zipCode":98053}}
和前面两种版本控制方案一样,实现 HATEOAS
也需要在所有链接中带上正确的消息头。
媒体类型版本控制
在前面有介绍过,当客户端程序发出一个HTTP GET请求到web服务端时,它应当使用 Accept
消息头明确它可以处理的内容格式。Accept
消息头经常用来指明客户端程序希望得到的响应报文格式应该是XML、JSON或者其他一些通用格式。不过,也可以扩展一些自定义媒体类型,使得客户程序可以在其中加一些信息来指明需要的资源版本。
下面这个例子在 Accept
消息头中指定了 application/vnd.adventure-works.v1+json
,其中的 vnd.adventure-works.v1
就向web服务端表明了它需要的是1这个资源版本,并希望得到json格式的响应内容:
GET https://adventure-works.com/customers/3 HTTP/1.1
Accept: application/vnd.adventure-works.v1+json
处理请求的代码需要处理这个消息头,并尽可能的满足它(客户端程序可能会在 Accept
中指定多个格式,服务端只需要选择其中最合适的一个作为响应格式即可)。Web服务端在响应体中使用 Content-Type
报文头来对数据格式进行确认:
HTTP/1.1 200 OK
Content-Type: application/vnd.adventure-works.v1+json; charset=utf-8
{"id":3,"name":"Contoso LLC","address":"1 Microsoft Way Redmond WA 98053"}
如果 Accept
消息头中指定了无法处理的媒体类型,服务端可以返回HTTP状态码406(Not Acceptable),或者使用一个默认类型来处理。
这种做法被认为是最纯粹的版本控制方案了(译者猜应该是说这种做法可以在连接上只是明确指向资源即可,不需要做其他调整,URI版本方案,和查询字符串版本方案,都会影响这个URI的干净度),并且它原生支持 HATEOAS
,因为可以在链接节点中指明 MIME 类型。
在选择一种版本控制方案时,需要同时考虑到对性能的潜在影响,特别是在web服务端的缓存处理。URI和查询字符串版本控制方案是可以缓存的,因为携带了版本信息的完整URI和查询字符串会作为缓存的唯一标识,同样的版本请求每次都指向同样的数据。
The Header versioning and Media Type versioning mechanisms typically require additional logic to examine the values in the custom header or the Accept header. In a large-scale environment, many clients using different versions of a web API can result in a significant amount of duplicated data in a server-side cache. This issue can become acute if a client application communicates with a web server through a proxy that implements caching, and that only forwards a request to the web server if it does not currently hold a copy of the requested data in its cache.(这段译者未能完全理解,猜其意思,可能跟代理的缓存策略有关,因为Header和MediaType的版本控制方案,在请求不同版本时的URI和query string并没有变化,代理可能会使用不正确的缓存数据直接返回,而不是向web服务端发起新的请求来获取数据)
Open API 计划
Open API 计划是由一个行业联盟创建的,目的是标准化供应商之间的REST API描述。作为这一计划的一部分,Swagger 2.0规范被重新命名为OpenAPI规范(OAS),并纳入了Open API 计划。
如果你想要把自己的web API改造为OpenAPI,可以参考下面几点:
OpenAPI规范附带了一套关于REST API应该如何设计的固执的指导方针。这对互操作性有好处,但在设计API以符合规范时需要更加小心。
OpenAPI提倡契约优先的方法,而不是实现优先的方法。契约优先意味着首先设计API契约(接口),然后编写实现该契约的代码。
Swagger这样的工具可以从API契约直接生成客户端代码或文档。可参考 ASP.NET Web API help pages using Swagger。
More information
Microsoft REST API guidelines. Detailed recommendations for designing public REST APIs.
Web API checklist. A useful list of items to consider when designing and implementing a web API.
Open API Initiative. Documentation and implementation details on Open API.