REST架构指导方案

引言

只有了解Rest的架构论文和RFC文档,才能正确应用REST架构风格的唯一手段。

欢迎加入技术交流群186233599讨论交流,也欢迎关注技术公众号:风火说。

何为REST

在2014年之后,社区中关于RESTFUL风格的文章开始渐渐多起,大多数RESTFUL的文章都是在阐述一种HTTP URL路径的写法风格。简单总结来说,这些文章归纳的点主要是:

  1. URL路径应该是名词而非动词。
  2. 通过HTTP几个动词:GET,POST,PUT,DELETE来对“资源”进行CURD操作。

但是为何要是名字,又为何非得通过Http 方法动词来完成CURD操作,往往语焉不详。因此,想要完整正确的理解REST,仍然要从该名词的诞生处, Roy Thomas Fielding 博士关于REST架构的论文《Architectural Styles and the Design of Network-based Software Architectures》(架构风格与基于网络的软件架构设计,以下简称设计) 中寻找答案。

在设计一文中,首先对基于网络的软件架构提炼和归纳了几种有明显特征的设计风格,诸如有:

  1. 流式数据风格,例子有:数据过滤器模式,统一接口的数据过滤器模式。该风格强调的是数据在类似“管道”的概念中流动,并且流动的过程中不断被处理和转换。
  2. 复制风格,例子有:多数据源模式,缓存模式。该风格强调的是所要求访问的数据,存在多于一个的存储点,通过增加存储点来提升整体性能。
  3. 层次风格,例子有:CS模式,分层模式,远端Session模式,远程数据访问模式。该风格强调将系统划分为不同的层次,每一个层次完成特定的功能,并且隐藏其之后(之下)层次的复杂性。
  4. 移动代码风格,例子有:虚拟机模式,按需编码(编码下发)模式。该模式强调的是通过改变处理流程和数据源之间的“距离”来提升系统的扩展性。比较形象的例子有早期Java Applet应用。
  5. 点对点风格,例子有:基于事件集成模式,分布式对象模式。该模式强调的是形成系统的组件之间,彼此之间通过某种“连接方法”直接交互。整个系统内部的交互成网状结构。

对于何为“架构风格”,设计一文中对架构风格的定义是:架构风格是特定的一组约束的名字描述。换句话说,风格是一个特定的约束集合。而REST风格并不是凭空产生的,他是根据现代WEB架构所期望实现的一些属性或者目标,从空集合中逐步添加不同的约束,进而不断满足WEB架构的目标,最终得到的一个成果。实际上,有些架构约束,在现代看来,属于司空见惯的做法。下面,从论文的角度,来逐步看看REST风格的约束是如何添加的。

首先,REST风格要求系统是基于CS模式。WEB浏览器本身就是一个弱化的,非特定的瘦客户端。在WEB系统中谈这个似乎显得比较奇怪,但是需要注意的是,REST架构是针对基于网络的应用而言的,而不是基于HTTP而言的。比方说FTP模式,也是基于网络的应用,但是其设计结构就不符合REST的要求。因此,CS模式,成为REST风格的第一个约束。该约束,实际上期望的效果是基于网络的软件在不同的层次上独立演化,client和server互不干扰。

其次,REST风格要求应用交互本身是无状态的。这就意味着,每一次组件之间(比如浏览器到服务端)的请求,都包含了完整的信息和语义,服务端不存储任何客户端的状态信息。这个约束的目的是为了提升服务端的扩展性。由于服务端不保存客户端状态信息,因此服务端可以通过扩展自身数量来提高性能和可靠性。客户端可以将请求发往任意的服务端节点而不必担心无法收到正确的响应。在现实中,有一个明显的反例就是WEB服务器的Session机制,由于保存了客户端状态信息,因此横向扩展就成了问题。

再次,REST风格要求请求的响应本身支持缓存标记,如果响应被标记为可缓存的,则客户端后续会直接使用该响应,不会再次发起网络请求。该约束主要是为了减少组件交互次数,提升应用的整体性能。比较常见的例子是图片服务器,由于图片变化不频繁,通常而言,都会在响应头中标记本次响应可缓存和有效时长。

再次,REST风格要求组件之间通过统一的接口进行交互。统一的接口可以通过反例来理解,比方说有一个系统,其对外提供了Http API访问,也提供了Dubbo的接口访问;这对于客户端而言,就同时存在两种接口模式。这就违反了统一接口约束。实际上,由于HTTP协议的普遍性,其天然适合成为统一接口。因此,采用REST风格的系统,都采用HTTP协议作为统一接口对外开放能力。

再次,REST风格要求系统整体设计上,组件按照需要进行分层,每一层只能看到与其相邻的层的内容。分层的主要好处在于给内部组件划分了明确的边界,方便其独立演化和部署。通过分层,也降低了组件之间的耦合性。比如常见的流量网关Nginx,业务网关API Gateway,都是分层约束的产物。

最后,REST风格要求系统支持按需编码(编码下发)约束。以例子来描述,就是客户端可以通过下载代码或者脚本的形式,来扩展客户端的功能,比如Java Applet。这一点在前端开发中较为常见,加载不同的js脚本,实现不同的功能。通过对脚本的升级,应用在“客户端”侧的能力得到了扩展。

从约束合集的角度来看,REST风格包含了上面提到的6种基本约束。而从架构元素的角度来看,REST风格包含了三种元素:

  • 数据元素
  • 连接器
  • 组件

数据元素对整个架构的指导意义,放在后面重点说明,先说说说连接器和组件这两个元素。实际上,这两个元素在当今以Http协议为主的WEB应用中,基本已经约定俗成了。

首先来说下连接器,连接器是对资源的表示的获取和转移活动的封装,其代表的是组件通信活动的抽象表达。在REST中的连接器主要有:

连接器 示例
客户端 Libcurl
服务器 tomcat
缓存 浏览器缓存
解析器 DNS查找库
隧道 SSL

最常见的连接器就是客户端和服务器。两者的主要区别是客户端通过发起请求来获得资源的表示,而服务器则监听请求进而响应某一资源的表示。部分组件可能同时具备两种连接器类型,比如流量网关Nginx。

接着来看看组件。组件是根据在整个系统中的角色来定位的。总结来说有以下四种

组件 示例
来源服务器 tomcat
网关 nginx
代理 VPN
用户代理 浏览器

组件很好理解,就不展开了。下面来说下三大元素中对架构影响最大的数据元素。

数据元素细分之下可以分为2种:

  • 资源
  • 资源的表示

资源是一个概念,是对一个实体,一组实体,甚至一个服务的概念映射。一个具体的文档,一组文档,甚至于当前温度的在线查询服务都可以是一个资源。需要重点明确,资源是一个映射到实体的映射关系,而不是实体本身。举个例子,git或者svn中的最新版本,或者master版本,这个定义是一个资源;而这个资源所指向的具体代码版本是不固定的,可能每次获取都不同(因为其他人提交了),每次获取到的实体不同。而版本1.0指向的代码版本,则是静态的,无论何时访问,都会得到相同的响应。在这个例子中,master版本和1.0版本就是2个完全不同的资源。资源对应的值可能是会变化的,但是定义资源的语义本身,是静态的。诸如master版本,这个描述对应的含义是不会变化的,永远都是“主干版本”,而主干版本拉取的值本身则是不断变化的。

资源是一个概念,因此通过连接器传输的实际上资源的表示。表示是一组二进制数据以及描述这些二进制数据的元数据,或者说,表示是由数据,描述数据的元数据,以及描述元数据的元数据构成。REST风格的核心,就是在不同的组件之间,传输资源的表示。并且通过资源表示的传输来实现具体的功能。

在REST风格中,系统的状态体现在2个地方:

  • 资源的当前状态
  • 客户端的状态

资源的表示,可以被认为是对资源的当前状态的一种快照或捕获。因此获取资源的表示,相当于获取了资源的当前状态。而应用本身可以通过获取资源的表示来改变自身的状态。举个例子,浏览器通过访问网站,获取到了文档,图片等资源的表示(具体而言就是下载了html文本,css样式表,图片二进制数据),在全部资源表示获取完毕后,浏览器此时处于一个稳定状态。该稳定状态会一直维持直到下一次点击并且尝试获取新的资源表示。并且客户端可以通到将新的资源表示发送给远端要求更新远端的资源表示,进而更新资源的状态。

综上,REST风格是一组架构约束的合集,限定在基于网络的应用架构,是一种风格指导而非具体的实践。其核心架构要素在于对资源的描述和通过对资源表示的传输来改变系统整体的状态。

在WEB系统中应用REST风格

应用约束

在WEB系统中应用REST风格,首先从架构约束开始实践。这里面最重要,也影响如今设计最大的约束在于REST风格要求服务端不保存客户端状态,该约束是提高服务端扩展性的关键举措。HTTP协议本身是无状态的,在单机应用中,为了保存客户端状态都是采用容器内Session的方案,这样就成了有状态的服务端。去状态的做法也很简单,将容器内的会话信息保存至第三方的组件,比如说共享KV缓存上。客户端通过传输token标记来标识自身身份,业务处理服务器仅仅处理业务逻辑,而对客户端身份的校验,获取都可以由公共的身份校验服务完成,从而提升了业务处理服务器的横向扩展性。针对这一点,可以采用的方案很多,比如JWT或者单纯的将标识符存放在共享KV存储上。

对资源应用正确的动词语义

REST风格中,最重要的元素就是资源和资源的表示。将整个系统都按照资源的方式去规划并不简单,但是却是比较高效的一种抽象;这种规划方式要求将系统看成是不同的静态语义的集合,系统的功能通过静态语义的状态变化来提供。

资源不是一个动作,而是概念,这也是很多文章中提到的,REST是名词而非动词的原因。系统对外提供的功能总体上分为两类:

  1. 通过获取资源的当前表示来得到数据(读)
  2. 通过发送资源的表示来更新资源的状态(写)

读的动作可以包括:获取资源的表示,获取资源表示的元数据;写的动作可以包括:更新资源的某一表示的全部内容,更新资源的某一表示的部分内容,删除资源的某一表示。

以上这些动作,都可以由Http的方法动词来担任,RFC7231规定了8个动词,其中比较容易使用到的是

  • GET:获取(传输)目标资源的当前表示。
  • HEAD:获取(传输)目标资源的元数据信息。该方法与GET相同,但是不传递内容体。
  • POST:要求目标资源按照其语义处理请求体当中的表示。
  • PUT:要求目标资源的状态依据提交的表示所代表的状态被创建或者改变。
  • DELETE:要求删除资源及其相关功能。

剩余的connect,trace,options主要用于获取诊断,追踪,以及功能协商部分,较为少用,这里不展开。

GET、HEAD、DELETE很好理解。主要容易引起困惑的地方在于POST和PUT。

POST方法要求目标资源去处理提交的表示。这里的“处理”是一个宽泛的概念,只要做出合适的响应就算是处理。RFC7231对POST举出了几个例子:

  • 提交一个表单
  • 创建一个或多个尚未被源服务器识别的新资源,并提交到源服务器。
  • 追加数据到资源当前的表示中。

提交表单可以是用以查询数据;创建新资源并且提交到源服务器可以认为是新增数据;追加数据到资源当前表示可以认为是对资源的修改。因此post方法是一个十分宽泛以致有点滥用的方法,这也和其定义相关,毕竟“处理”的语义过于宽广。从场景来应用的角度来说,post可以被应用在:

  • 通过提交表单来实现复杂查询
  • 通过提交表示来新增资源,比如登录动作,其效果就是新增当前在线用户
  • 通过追加数据来修改资源,比如部分更新实体的属性。

在RFC7231定义中,post方法是非幂等的。

再来看看PUT方法。put方法的定义是用提交的表示来替换目标资源的表示。这里的替换有两种情况:

  • 目标资源当前没有表示。这其实意味着目标自愿当前还不存在,此时的替换,从空变更为有,实际上就是新增资源。
  • 目标资源存在一个当前状态的表示。这意味着资源当前已经存在一个具体的表示的状态,此时的替换,从旧变更为新,实际上就是对资源的全量更新。

看上去,PUT和POST都有新增和更新的能力,两者的区别主要是在语义上。post要求的是目标资源处理提交的表示内容;而put则要求目标资源的表示被提交的表示所替换。从语义上来说,put是幂等的,而post为非幂等。从使用场景来看,put只能全量更新资源,而post可以部分更新资源。

为了实现资源的更新操作,RFC5789中定义了一个新的方法PATCH。PATCH方法的定义是对目标资源提交一组变更描述,并应用于资源。如果目标资源不存在,此时的变更描述则可以创建新的资源。

可以看到PUT和PATCH都可以创建和更新资源,二者的主要区别在于PUT方法提交的内容体是目标资源的修改版本;PATCH方法提交的内容体是对目标资源的修改描述。形象的来说,PUT方法的作用可以是令a=1,而PATCH的作用即可以是令a=1,也可以是令a=a+1。后者,是put语义不具备的。也正是因为后者的语义,patch方法是非幂等的。

而POST和PATCH的区分则更加困难。由于两者都具备部分更新的能力。在RFC5789文档中也不能提供较好的建议,只能模糊的建议,如果对资源的修改是十分明确且可预测的,应该使用PATCH,其余情况,使用POST。不过有一点需要注意的就是,大多数的web容器,其实并不支持PATCH方法。因此在实战中,POST用于表达更新操作更为常见。

名词性的URI地址

URI用于定位一个资源,资源的表现是可以获取的,显然,符合REST风格的URI是一个名词性的描述。这也是为何大多数文章中提到的,restful路径是一个名词而非动词。

RESTFUL的URL路径实践

上文说到,REST是一种风格,因此我们将WEB系统中URL规划符合REST风格的称之为restful。REST不是具体的架构,因此我们可以说某一个系统比另外的系统更restful,却难以说一个系统是restful而另外的系统不是。

回归到路径规划这一问题上,有一些最佳实践可供参考。

单一资源的路径制定

以用户信息为例,通过唯一标识符,比如userid来获取用户信息,可以将url制定为

/xxx/user/{userid}

此时可以通过get方法来获取该userid的用户信息,可以通过post更新用户的部分信息,而put方法,既可用于用户数据的全量更新(除了userid,因为它是定位信息的一部分),也可以用于新增用户,如果此时该用户id不存在。put方法是新增用户可以从这个角度去理解:put方法将具体userid的用户用户信息的表示从空变更到了其提交的表示数据。

复杂查询的路径制定

提交需要参数进行查询获得数据是一个常见的业务需求。一般认为查询都是get方法,而参数则是跟在?后面进行拼接,比如有这种url:/xxx/queryUser?name={name}&age={age}&address={address}。这种写法有两个地方违背了REST风格:

  1. url用于定位资源,其本身是不变的。而?后面跟参数显然不满足这个实践。
  2. url用于定位资源,其本身是名词性的,不应该出现动宾短语,如queryUser。

复杂查询的路径制定要从资源的角度去看待。首先,资源可以是一个服务,而查询服务显然是可以认为是一个资源的。其次,可以通过提交资源更新请求来获得资源的最新状态的表示。

以这两者为改造思路,首先将url改造为一个服务的名词路径,如/xxx/userQuery或者/xxx/userQueryService。然后通过post方法来提交资更新请求来获得查询服务的最新表示,换句话说,就是将查询参数通过post发送,来获得查询结果。

总结一下,复杂查询的路径制定首先是将查询本身看成是查询服务,进而以资源的方式表示,可以表示为xxQuery或者xxQueryService。而后通过post提交资源更新请求来获得查询服务资源更新后表示,也就是查询结果。

复数资源的路径制定

复数资源的获取其实和复杂查询本质是一样的,以用户为例,可以将路径规划为/xxx/users。将users看成是一个资源,通过post来提交要查询的id数组来更新该资源的状态进而获得更新后的表示,也就是复数情况下的查询结果。

动宾操作的路径制定

有一些场景,动宾结构或者说动词路径是存在已久,也符合直觉认知的。比如登录动作,常见的都会规划为/xxx/userlogin或者/xxx/login。

将动宾路径规划为rest路径的思路和复杂查询的思路一致,一个动词可以看成是对一个服务的更新或者新增要求,因此很容易从动词的效果上去抽象服务的资源标识。比如登录,其动词的效果是在线用户新增了,因此可以将路径规划为/xxx/onlineUsers。通过post方法,向该资源更新部分表示内容,更新的表示内容就是要登录的用户信息(可能是用户名和密码之类的)。

版本号位置

存在版本号的实体非常常见,而版本号的位置规划存在两种方式:

  1. 实体具备不同的版本,不同的版本可以认为是同一个资源的不同表示。而url路径是用来定位资源的,资源的不同表示不在URI定位的职责内,从这个角度出发,版本号应当存放在http header中。
  2. 将特定版本的实体认为是一个资源。而URI要定位该资源,则需要在URI中体现版本,此时可以规划如/xx/v1/user/{userid}的路径

具体选择哪一种方式并无优劣之别,只要整体系统保持一致即可。

公共参数位置

在系统设计中,除了业务参数外,往往还存在一些公共参数,用于表达身份,流量控制,鉴权等等非功能性需求,这部分需求的参数,不适合放在Http内容体中,因为往往在传输的过程中,网关等组件都需要根据公共参数调整自己的行为,因此公共参数应当放在Http header中传递。

代码指导

Java系的后端应用大多采用Spring作为框架,而SpringMVC本身对REST也提供了一定的支持,主要是动词原语和路径提取两方面。

动词原语支持

有两种方式,一种是使用org.springframework.web.bind.annotation.RequestMapping注解,该注解中有一个属性method,该属性就是代表对动词原语的支持。还有一种直接使用固定了原语的注解,诸如org.springframework.web.bind.annotation.PostMapping,org.springframework.web.bind.annotation.GetMapping等等。

路径参数提取

//路径中使用${}进行参数包围,方法入参中使用注解@PathVariable进行定位和提取
@GetMapping(path = "/user/id/${id}")
public UserInfo queryUser(@PathVariable("id") String Id)
 
 
//删除时使用DeleteMapping映射delete动词
@DeleteMapping(path = "/user/id/${id}")
public boolean deleteUser(@PathVariable("id") String Id)
 
 
//更新时,路径中包含ID信息,Http方法体则以json形式传递参数。方法入参中使用@RequestParam可以自动完成Json的解析(Spring内置功能),并且解析为一个实例对象。json中键值对的key的名称与对象的属性名称一致时,则完成转化
@PutMapping(path = "/user/id/${id}")
public boolean updateUser(@PathVariable("id") String Id,@RequestParam UserInfo userInfo)
 
//新增时,使用PostMapping来映射Post方法。与Put相同,Http方法体中以json形式传递参数。
@PostMapping(path = "/user/id/${id}")
public UserInfo addUser(@PathVariable("id") String Id, @RequestParam UserInfo userInfo)
 

风火
15 声望2 粉丝

博思软件技术架构部技术经理,8年研发经验。福建省省级标准《双离线场景二维码标准》撰写人,多年大型项目研发经验。关注团队效率,高并发等方面。