读书笔记,原文链接:http://www.cnblogs.com/loveis715/p/4669091.html,感谢作者!
一、资源表示
1、资源表示:使用 单数 vs. 复数
如果一个URL所对应的资源是使用复数表示的,那么该类型的资源可能有多个。对该URL发送Get请求可能返回该资源的一个列表。
如果一个URL所对应的资源是使用单数表示的,那么该类型的资源将只有一个,对该URL发送Get请求将只返回该资源的一个实例。
举例来说,
一个网站所售卖的商品可能有多种类别,因此需要在URL中使用复数形式:/API/categories。
对于一个该网站的用户只有一个个人偏好设置,因此其URL则需要使用单数形式:/API/users/{user_id}/preference。
如果需要得到具有特定ID的某个实例时,应该对该资源使用复数。
这是因为在通过特定ID访问某个资源的实例实际上就是从该资源的集合中取出特定实例。因此表示该资源集合的URL实际上仍然需要使用复数形式,而其后所使用的ID则标明了其所访问的是资源中的单一实例,因此向这个URL发送Get请求将返回该资源的单一实例。
以“食品”分类为例。该分类所对应的URL为/API/categories/1。该URL中的前半部分/API/categories表示购物网站中所有分类的集合,而1则表示在该分类集合中的ID为1的分类。
2、资源表示:相对路径 vs. 请求参数
另一个经常导致疑惑的地方就是针对资源的某一种特征,到底是将其定义为URL中相对路径的一部分还是作为请求参数。
在购物网站中,售卖的手机主要有苹果,三星等品牌。为这些手机设计URL时,是否需要按照品牌对这些手机进行细分,从而用户只要向/API/mobiles/brands/apple发送请求就能列出所有的苹果手机?还是说,直接将手机的品牌置于请求参数中,从而通过/API/mobiles?brand=apple来列出所有的苹果手机?
在判断到底是使用请求参数还是相对路径时,一般分为下面几步。
- 首先,可选参数一般都应置于请求参数中。仍以购物中的手机为例。在选择手机时,用户可以选择品牌以及颜色。如果将品牌和颜色都定义在相对URL中,那么具有特定品牌和颜色的手机将可以通过两个不同的URL访问:/API/mobiles/brand/{brand}/color/{color}以及/API/mobiles/color/{color}/brand/{brand}。就用户而言,其并无法了解这两个URL所表示的是同一类资源还是不同类型的资源。当然,您可以说,只用/API/mobiles/brand/{brand}/color/{color}。但是该URL将无法处理用户仅仅选择了颜色,却没有选择品牌的情况。
- 其次,不是所有字符都可以在URL中被使用,如汉字,标点。为了处理这种情况,包含这些字符的筛选条件需要置于请求参数中。
- 最后,如果该特征下包含子资源,那么它自身也就是一个资源,因此需要以相对路径的方式展现它。例如在购物网站中,每件商品所属于的分类仅仅是它的一个特征。但是一个分类更包含了属于它的各个品牌以及热搜关键字等众多信息。因此它其实是一个资源,需要在URI路径中表示它。
- 总的来说,既然使用HTTP来构建REST系统,那么就需要遵守URL各组成中的含义:URL中的相对路径将用来标示“What I want”,也既对应着资源;而请求参数则用来标示“How I want”,即查看资源的方式。
3、资源表示:选择适当的表示结构
REST并没有规定其服务中需要使用什么格式来表示资源。表示资源时所可以选取的表示形式实际上是由实现REST所使用的协议决定的。在一个基于HTTP的REST服务中,可以使用JSON,也可以使用XML,甚至是自定义的MIME类型来表示资源。
一个REST服务常常会同时支持多种客户端。这些客户端可能会使用不同的协议来与服务进行沟通。而且就算是使用相同的协议,不同的客户端所可以接受的负载表示形式也会有所不同。因此客户端需要与REST服务协商在通讯过程中所使用的负载。
客户端和服务端对所使用负载类型的协商通常都按照协议所规定的标准协商过程来完成。例如对于一个基于HTTP的REST服务,就需要使用Accept头来标示客户端所可以接受的负载类型:
GET /API/categories
Host: www.购物.com
Authorization: Basic xxxxxxxxxxxxxxxxxxx
Accept: application/json
而在服务端支持的情况下,返回的响应就将使用该MIME类型组织其负载:
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: xxx
二、资源访问:
1、资源访问:使用合适的动词
在知道了如何为每种资源定义URI之后,来看看如何操作这些资源。
首先,在一个资源的生命周期之内常常会发生一系列通用事件(CRUD)。一开始,一个资源并不存在。只有用户或REST服务创建了该资源以后其才存在,也即是上面所列出的通用事件中的C,Create。在一个资源创建完毕以后,用户可能会从服务端请求该资源的表示,也就是上面所列出的通用事件的R,Retrieve。在特定情况下,用户可能决定要更新该资源,因此会使用上面的通用事件中的U,即Update来更新资源。而在资源不再需要的时候,用户可能需要通过通用事件D,即Delete来删除该资源。同时用户有时也需要列出属于特定类型资源的资源实例,即通过List操作来得到属于特定类型的资源的列表。
1.1、GET和DELETE
在REST系统中的每个资源都有一个特定的URI与之对应。HTTP协议提供了多种在URI上操作的动词,如GET,PUT,POST以及DELETE等。因此在一个基于HTTP的REST服务中,需要使用这些HTTP动词来表示如何对这些资源进行CRUD操作。而在什么情况下到底使用哪个动词则是由这些动词本身在HTTP协议中的意义所决定的。
这其中GET和DELETE两个动词的含义较为清晰:
The GET method means retrieve whatever information (in the form of an entity) is identified by the Request-URI.
The DELETE method requests that the origin server delete the resource identified by the Request-URI.
也就是说,
在需要读取某个资源时,向该资源所对应的URI发送一个GET请求即可。
在需要删除一个资源时,只需要向该资源所对应的URI发送一个DELETE请求即可。
在需要得到某类资源列表时,可以直接向该类型资源所对应的URI发送一个GET请求。
1.2、PUT和POST
动词PUT和POST则是较为容易混淆的两个动词。在HTTP规范中,POST的定义如下所示:
The POST method is used to request that the origin server accept the entity enclosed in the request as a new subordinate of the resource identified by the Request-URI in the Request-Line
也就是说,POST动词会在目标URI之下创建一个新的子资源。例如在向服务端发送下面的请求时,REST系统将创建一个新的分类:
POST /API/categories
Host: www.购物.com
Authorization: Basic xxxxxxxxxxxxxxxxxxx
Accept: application/json
{
"label" : "Electronics",
……
}
而PUT的定义则更为晦涩一些:
The PUT method requests that the enclosed entity be stored under the supplied Request-URI. If the Request-URI refers to an already existing resource, the enclosed entity SHOULD be considered as a modified version of the one residing on the origin server. If the Request-URI does not point to an existing resource, and that URI is capable of being defined as a new resource by the requesting user agent, the origin server can create the resource with that URI."
也就是说,PUT则是根据请求创建或修改特定位置的资源。此时向服务端发送的请求的目标URI需要包含所处理资源的ID:
POST /API/categories/8fa866a1-735a-4a56-b69c-d7e79896015e
Host: www.购物.com
Authorization: Basic xxxxxxxxxxxxxxxxxxx
Accept: application/json
{
"label" : "Electronics",
……
}
可以看到,两者都有创建的含义,但是意义却不同。
1.3、PUT和POST的区别
在决定到底是使用PUT还是POST来创建资源的时候,软件开发人员需要考虑一系列问题:
首先就是资源的ID是如何生成的。如果希望客户端在创建资源的时候显式地指定该资源的ID,那么就需要使用PUT。而在由服务端为该资源自动赋予ID的时候,就需要在创建资源时使用POST。在决定使用PUT创建资源的时候,防止资源URI与其它资源所具有的URI重复的任务需要由客户端来保证。在这种情况下,客户端常常使用GUID/UUID作为将资源的ID。但是到底使用GUID/UUID还是由服务端来生成ID不仅仅和REST有关,更会对数据库性能等多个方面产生影响。因此在决定使用它们之前要仔细地考虑清楚。
同时需要注意的是,因为REST要求客户只可以通过服务端返回结果中所包含的信息来得到下一步操作所需要的信息,因此客户端仅仅可以决定资源的ID,而URI中的其它部分则需要从之前得到的响应中取得。
但是软件开发人员常常会进入另外一个误区很多人认为REST服务中的HATEOAS只能通过Hyperlink完成。实际上在Roy对REST的定义中使用的是Hypermedia,即响应中的所有多媒体信息。就像Roy在其个人网站上所说(http://roy.gbiv.com/untangled/2008/rest-APIs-must-be-hypertext-driven):
A REST API must not define fixed resource names or hierarchies (an obvious coupling of client and server). Servers must have the freedom to control their own namespace. Instead, allow servers to instruct clients on how to construct appropriate URIs, such as is done in HTML forms and URI templates, by defining those instructions within media types and link relations.
另外一个需要考虑的因素则是PUT的等幂性是否对REST系统的设计有所帮助。由于在同一个URI上调用两次PUT所得到的结果相同。因此用户在没有接到PUT请求响应时可以放心地重复发送该响应。这在网络丢包较为严重时是一个非常好的功能。反过来,在同一个URI上调用两次POST将可能创建两个独立的子资源。
除此之外,还需要考虑是否将资源的创建和更新归结为一个API可以简化用户对REST服务的使用。用户可以通过PUT动词来同时完成创建和更新一个资源这两种不同的任务。这样的好处在于简化了REST服务所提供的接口,但是反过来也让一个API执行了两种不同的任务,在一定程度上违反了API设计时每个API都需要有明确的意义这一原则。
因此在决定到底使用POST还是PUT来完成资源的创建之前,请考虑上面所列出的三条问题,以确定到底哪个动词更加适合。
1.4、PUT和PATCH
除此之外,另外一对类似的动词则是PUT和PATCH。两者之间的不同则在于PUT是对整个资源的更新,而PATCH则是对部分资源的更新。而该动词的局限性则在于对该动词的支持程度。毕竟在某些类库中并没有提供原生的对PATCH动词的支持。
2、资源访问:使用标准的状态码
在与REST服务进行交互的时候,用户需要通过服务所返回的信息决定其所发送的请求是否被适当地处理。这部分功能是由REST服务实现时所使用的协议所决定的,与REST架构无关。
而在基于HTTP的REST服务中,该功能就由HTTP响应的状态码(Status Code)来完成。因此在设计一个REST服务时,需要额外地注意是否返回了正确的状态码。但是这些预定义的HTTP状态码并不能满足所有的情况。
有时候一个REST服务所希望返回的错误信息能够更加精确地描述问题,例如在用户重设密码时,需要在用户所输入原密码与系统中所记录的密码不匹配时返回“您所输入的密码有误”这样的消息。
HTTP协议中,并没有办法找到一个能够精确地表示该意义的状态码。因此在通常情况下,REST服务都会在响应中额外地提供一个说明性的负载来告知用户到底产生了什么问题。
例如对于上面的重设密码失败的情况,服务端可能会返回如下响应:
HTTP/1.1 400 Bad Request
Content-Type: application/json
Content-Length: xxx
{
"error_id" : "100045",
"header" : "Reset password failed",
"description" : "The original password is not correct"
}
上面的示例响应中主要包含以下的说明性信息:
- 服务端响应的状态码。页面逻辑可以通过判断该状态码是否是4XX或5XX来判断是否请求出错,从而在页面中展示一个警告对话框。
- 服务所提供的内部错误ID。通常情况下,该内部错误ID也需要在警告对话框中展示出来。从而允许软件用户根据内部错误ID来获取支持服务。
- 错误的标题及简述。通过该错误的标题及简述,软件用户能够了解系统内部到底发生了什么,并在是用户输入错误的时候允许用户自行修改错误并重新发送正确的请求。
在该错误中,最关键的当属服务端的响应代码。一个响应代码不仅仅标示了请求是否成功,更有用户该如何操作的含义。例如:
- 401 Unauthorized响应代码而言,其表示该响应没有提供一个合法的身份凭证,因此需要用户首先执行登陆操作以得到一个合法的身份凭证,然后该资源可能就可以被访问了。
- 403 Forbidden响应代码则表示当前请求已经提供了一个合法的身份凭证,但是该身份凭证并没有访问该资源的权限,因此使用该身份凭证登陆重新登陆系统等操作并不能解决问题。
因此在返回错误信息之前,软件开发人员首先需要考虑清楚在响应中到底应该使用什么样的响应代码。而正确地选择响应代码则建立在软件开发人员对这些响应代码拥有一个正确的理解的前提下。
当然,要将所有的响应代码完全理解也需要大量的工作,而且REST服务的用户也可能并没有那么多的领域知识来了解所有的响应代码的含义。
- 因此在很多基于HTTP的REST系统中,系统在标示错误时只使用一系列常用的响应代码,如400,401,403,404,405,500,503等。
- 在用户请求被处理时,系统将返回200 OK,表示请求已经被处理。
- 而在处理时发生错误时则尽量使用这些响应代码来表示。如果一个错误较为复杂,那么直接返回400或500,并在响应的负载中提供具体的错误信息。
不得不说的是,这种做法有时显得简单粗暴,尤其是对于一个开放平台而言则更是致命的。当一个第三方厂商为一个开放平台开发一个应用软件,却每次只能得到一个400错误,那么其内部应用逻辑将无法判断到底是哪里出了问题。为了能让用户知道这里产生了错误,该第三方软件只能将开放平台所给出的信息直接显示给用户。但是这些信息实际上是建立在开放平台这个语境下的,因此对于第三方厂商的用户而言,这些信息晦涩难懂,甚至可能一点帮助也没有。
到底如何组织这些响应代码需要用户根据所编写的项目决定,尤其是该产品的使用者来决定。在定义一个平台时,尽量使用更多的HTTP响应代码,因为用户极有可能通过该平台编写自己的第三方软件。而在为一个普通的产品定义REST API时,将响应代码定得非常专业可能反而导致易用性的下降。
另外一点需要说明的是,个人不建议使用Wikipedia查找各个状态码的含义,而应该使用RFC所描述的各状态码的定义。 IANA提供了一张各个状态码所对应的RFC协议的列表,从而可以很容易地找到各个状态码所对应的RFC协议以及其所在的章节。该列表的地址为:http://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml
之所以不建议使用Wikipedia的原因主要有两点:
描述不够详细。在RFC定义中,每个状态码都对应着一段或多段文字,并且解释非常清晰。而在Wikipedia中,每个状态码常常只有一句话。不够准确。在Wikipedia的Reference节中,可以看到一系列特定平台所定义的状态码,如Spring Framework所定义的420 Method Failure等。这非常具有误导性。
3、资源访问:负载的自描述性
REST系统中所传递的各个消息的负载需要提供足够的用于操作该资源的信息,如如何对资源进行添加,删除以及修改等操作,并可以根据负载中所包含的对其它各资源的引用来访问各个资源。这也对负载的自描述性提出了更高的要求。
首先让回头看看购物电子商务网站对食品分类的描述:
{
"uri" : "/API/categories/1",
"label" : "Food",
"items_url" : "/API/items?category=1",
"brands" : [
{
"label" : "友臣",
"brand_key" : "32073",
"url" : "/API/brands/32073"
}, {
"label" : "乐事",
"brand_key" : "56632",
"url" : "/API/brands/56632"
}
...
],
"hot_searches" : …
}
第一个要讲解的是url域。该域用来标示该资源所对应的URL。可能您会问:既然是从这个URL返回的该资源,那么为什么还要在该资源中保存一个它所对应的URL呢?首先这是因为在统一接口约束中要求每个资源都拥有一个资源标识。在这里使用URL作为标识。
而另一些基于HTTP的REST系统中,用来作为资源标识的常常是该资源的ID。个人更倾向于使用URL原因是:在某些情况下,如对某个资源定时刷新以进行监控的时候,URL可以直接被使用。
接下来是label域。其用来记录用于展示给用户的分类名。
items_url域则用来表示取得属于该分类物品列表的URL。注意这里使用了后缀_url以明确标明其是一个URL,需要通过跳转来取得实际的数据。
下一个域brands则用来表示属于该分类的著名商品品牌。这里使用了一个数组,而数组中的每个元素都表示了一个品牌。每个品牌的表示都包含了一个展示给用户的label,在搜索时所使用的键,以及该品牌所对应的url。您可能会怀疑为什么仅仅提供了这么少的域。这是因为他们仅仅是对这个品牌的引用,而并非是把该资源的详细信息都包含进来了的缘故。在用户希望查看该品牌的详细信息的时候,他需要向该品牌引用中所标明的品牌的URL发送一个GET请求。
而由于hot_searches域的组成及使用基本上与brands域类似。
在大致地了解了食品分类的JSON表示中各个域的含义后,就将开始讲解如何自行定义资源的JSON表示。
对于一个简单的,不包含任何子资源以及对其它资源的引用的资源,只需要通过一个包含简单属性的JSON来表示它。
例如对于一个品牌,可能仅仅提供了一系列描述性信息:品牌的名称,以及对品牌的简单描述。那么它所对应的JSON表示可以表示为:
{
"uri" : "/API/brands/32059",
"label" : "Dole",
"description" : "An American-based agricultural multinational corporation."
}
而在另一个资源中,可能包含了对其它资源的引用。在这种情况下,就需要在表示对其它资源进行引用的域中通过URL来标明被引用资源的位置。
例如一件Dole果汁中,可能就需要包含对品牌Dole的引用:
{
"uri" : "/API/items/1438299",
"label" : "Dole Grape Juice",
"price" : "$3.99",
"brand" : {
"label" : "Dole"
"uri" : "/API/brands/32059"
}
……
}
在上面的Dole果汁的表示中,可以看到它的brand域就是对品牌的引用。该引用中包含了该品牌的品牌名称以及一个指向该品牌的URL。
在一个基于HTTP的REST系统中,常常在资源的引用中包含一定量的描述信息。这主要因为两点:
- 提高性能。在一个对资源的引用中添加了用于显示的属性后,客户端页面可以避免再次通过url发送请求得到资源的具体描述,以得到用于显示的信息。
- 自描述性的要求。如果一个资源中包含了一个对其它资源进行引用的数组,那么用户就需要通过该标签来决定到底访问哪个被引用的资源。
当然,如果需要在展示Dole果汁的页面中需要Dole这个品牌的完整信息,也可以将它直接嵌到Dole果汁的表示中:
{
"uri" : "/API/items/1438299",
"label" : "Dole Grape Juice",
"price" : "$3.99",
"brand" : {
"uri" : "/API/brands/32059",
"label" : "Dole",
"description" : "An American-based agricultural multinational corporation."
}
……
}
当然,如果一个资源的表示太过复杂,而且有些属性实际上是相互关联的,那么也可以通过一个属性将它们归结在一起:
{
"uri" : "/API/items/1438299",
"label" : "Dole Grape Juice",
"price" : "$3.99",
"brand" : {
"uri" : "/API/brands/32059",
"label" : "Dole",
"description" : "An American-based agricultural multinational corporation."
}
"nutrient component" : {
"sugar" : "14.5",
"protein" : "0.3",
"fat" : "0.1"
}
……
}
在上面的Dole果汁的表示中,使用域nutrient component来表示所有的营养成分,而该域内部的各个子域则用来表示一系列相关的营养成分所占比例。
另外,在不同的情况下,还可能对同一个资源提供不同的表现形式。例如在一个资源极为复杂,其JSON表示甚至可以达到几百K的时候,可以为该资源提供一个简化版本,以在非必要的情况下减少传输的数据量。
例如在购物中,会将某些物美价廉的商品置于它的首页上,以吸引用户购买。在用户将鼠标移动到某个商品上并停留一段时间时,会为用户展示一个Tooltip,并在该Tooltip中展示该商品的一部分信息。在这种情况下,向服务端请求该商品的所有信息以展示Tooltip便显得有些效率低下了。
有时候,一个资源可能并不支持特定用户执行某个操作。例如一个管理员所创建的资源可能对普通用户只读。在这种情况下,需要禁止普通用户对该资源的修改和删除。为了能明确地告知用户他所具有的权限,需要一个能显式地标示用户可以在一个资源上所执行操作的组成。在REST响应中,这种组成被称为Hypermedia Controls。例如对于一个普通用户,其从购物中所返回的分类列表将如下所示:
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: xxx
[
{
"label" : "Food",
"uri" : "/API/categories/1",
"actions" : ["GET"]
}, {
"label" : "Clothes",
"uri" : "/API/categories/2",
"actions" : ["GET"]
}
...
{
"label" : "Electronics",
"uri" : "/API/categories/25",
"actions" : ["GET"]
}
]
可以看到,在上面的分类列表中,通过actions域显式地标示了用户可以在各个类别上所能执行的操作。而对于管理员,其还可以执行修改,删除等操作:
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: xxx
[
{
"label" : "Food",
"uri" : "/API/categories/1",
"actions" : ["GET", "PUT", "DELETE"]
}, {
"label" : "Clothes",
"uri" : "/API/categories/2",
"actions" : ["GET", "PUT", "DELETE"]
}
...
{
"label" : "Electronics",
"uri" : "/API/categories/25",
"actions" : ["GET", "PUT", "DELETE"]
}
]
而在一系列较为著名的REST系统中,如Sun Cloud API,其更是通过Hypermedia Controls定义了除CRUD之外的动词。如对于一个虚拟机,其在运行状态下可以执行停止命令,而在停止状态下可以执行启动命令:
{
"vms" : [
{
"id" : "1",
......
"status" : "stopped",
"links" : [
{
"rel" : "start",
"method" : "post",
"uri" : "vms/1?op=start"
}
]
}, {
"id" : "2",
......
"status" : "started",
"links" : [
{
"rel" : "stop",
"method" : "post",
"uri" : "vms/2?op=stop"
}
]
}
]
}
但是一个常见的观点是:如果一个资源需要除CRUD之外的额外的动词,那么这种需求常常表示对于某个资源的定义并不是十分合理。因此在遇到这种情况时,软件开发人员首先需要考虑为资源添加额外的动词是否合适。
4、无状态约束
在Roy Fielding的论文中,其为REST添加了一个无状态约束:
We next add a constraint to the client-server interaction: communication must be stateless in nature … such that each request from client to server must contain all of the information necessary to understand the request, and cannot take advantage of any stored context on the server. Session state is therefore kept entirely on the client.
从上面的陈述中可以看到,在一个REST系统中,用户的状态会随着请求在客户端和服务端之间来回传递。这也便是REST这个缩写中ST(State Transfer)的来历。
为REST系统添加这个约束有什么好处呢?主要还是基于集群扩展性的考虑。如果REST服务中记录了用户相关的状态,那么在集群中,这些用户相关的状态就需要及时地在集群中的各个服务器之间同步。对用户状态的同步将会是一个非常棘手的问题:当一个用户的相关状态在一个服务器上发生了更改,那么在什么时候,什么情况下对这些状态进行同步?如果该状态同步是同步进行的,那么同时刷新多个服务器上的用户状态将导致对用户请求的处理变得异常缓慢。如果该同步是异步的,那么用户在发送下一个请求时,其它服务器将可能由于用户状态不同步的原因无法正确地处理用户的请求。除此之外,如果集群进行了不停机的横向扩展,那么用户状态的同步需要如何完成?这些实际上都是非常难以处理的问题。
但是现有的很多较为流行的技术及规范实际上都没有限制用户的请求是无状态的。相信您知道,一个技术或规范实际上都拥有一个生态圈。在该生态圈之内的各技术之间可以较好地契合在一起。尤其是,有些技术实际上就会以该生态圈中的核心技术或规范所建立的假设之上来实现自己的功能。如果希望禁止该假设,那么让某些技术工作起来就是非常困难的事情了。
就以搭建基于HTTP的REST服务为例。在HTTP中,一个重要的功能就是Cookie和Session的使用(RFC6265)。该功能会在服务器里保留一个状态。因此在一个基于HTTP的REST系统中,常常需要避免使用这些在服务器里面保留状态的技术。但是某些技术,如用户的登陆,实际上常常需要在服务器中添加一个状态。