3.5.URI设计之魂

    本章继续我们的RESTful之旅,先小菜开胃垫个底,聊聊RESTful的URI设计,路径设计在实战开发过程中一直是一个不太提及的话题,因为它自由;可路径设计又是RESTful的必修课,若您不掌握,难免会在开发过程中碰壁——路径设计真正影响的不是开发,而是后期运维,所以良好的路径设计会给一个项目带来非常丰厚的后期回报。

本章概念部分参考《RESTful Web Services Cookbook》进行详细解读和分析,有些部分通过消化过后引用原文中的内容,相当于作者带着读者升级打怪,且本章为支线,对RESTful很了解的读者可直接跳过。

本章术语及翻译:

1. 初赦の熵

示例中key为主键,为了配合React的前端操作,统一将后端主键修正成了key

    如果您有一个实体用户(user),如何设计和该实体相关的接口呢?先看看下边表格中的设计:

    上边的设计有问题吗?从某个角度讲是没有问题的,在最早的Web开发中,项目中往往只能看到POST和GET方法,那个年代对设计没有这么多指导规范,而很多时候开发人员的思维集中在:“谁做了什么”这样简单的语义来描述接口内容,于是很多旧项目中都出现了类似上述URI设计(名词+动词)。而RESTful只是一种指导规范,它并非标准,它的出现也只是为我们提供了一种设计思路,如今这个时代这种思路被广泛接受而已,这里强调一下:架构指导规范标准是两种截然不同的概念:

  • 指导规范:您可以这样做,也可以不按照这个规则来做。

  • 标准:您必须这样做,甚至不能越过某些规则定义。

    由于RESTful释放了HTTP协议的语义,所以上述表格中的例子现在又往往写成如下:

    修改过后的路径定义已经将动作直接抽象到HTTP方法中了,即使是相同的路径,但由于HTTP方法不同,它依然可以标识不同资源,也赋予了它不同的语义。实战过程中,一个系统的实体往往不止一个,而接口有可能成百上千,在这种场景下,URI设计就显得格外重要了,设计时开发人员需要考虑多方面因素。

1.1. 交互可见性

    本章节初讲到过RESTful和传统Web Service的本质区别,HTTP作为应用层协议,是客户端和服务器端之间的桥梁,即在二者之间保持对数据库、服务器、代理、缓存、第三方工具的可见性,该特性是HTTP协议的核心特征。

  • 可见性:一个组件能够对其他两个组件之间的交互进行监视或仲裁的能力。参考:https://roy.gbiv.com/

    RESTful是以资源为中心的协议,为了保持HTTP请求和响应的可见性,设计该协议时,需采用和HTTP协议融合的语义法则,RESTful很多时候采用HTTP协议中的语义来定义其功能,于是前一章节表格中第二个表格的语义更清晰。GET方法表示获取资源、PUT方法表示更新资源、DELETE方法表示删除资源、POST则执行其他不幂等、不安全的操作,从这点思考:语义才是RESTful的灵魂

    接口可见性的设计还需考虑以下几点:

  • 旧版本数据的不可见

    • 查询缓存:当您在查询接口启用了缓存时,一旦资源发生变更,那么旧版本就应该对任何客户端都不可见,可见的只有最新的数据信息,即更新时让缓存自动失效,这种场景下,返回410 Gone比常见的404 Not Found更具业务语义。

    • 写入:当多个客户端同时访问一个资源时,如果是同时读取,仅考虑并发即可;若其中一个客户端要更新资源信息,则需要考虑并发写入的乐观控制,并在操作过程中防止资源发生变更。即:

      • 更新之前所有的客户端都读取到旧版本的一致数据。

      • 更新过程中防止资源执行其他更新而导致双写问题

      • 更新之后所有的客户端都读取到最新版本的一致数据。

  • 内容协商的可见性:RESTful接口在访问时,除了您常用的JSON数据格式,它可以支持其他数据格式的,在一个资源出现多个表征状态(Representational State)时,系统理应做出正确且唯一的选择,只将所需表征下的内容呈现给客户端,简单说:

    • 对一个只能拿到XML格式的客户端,JSON格式对它是不可见的。

    • 对一个只想拿到XML格式的客户端,服务端应该对它请求的格式(检查Accept-?头)做出可见性的判断,返回对应的格式。

  • 安全和幂等:必须确认客户端可以重复、重试等特定的HTTP请求,安全幂等的操作是RESTful设计时必须考虑的内容。

    HTTP协议本身是无状态的,其可见性包含了另外一层含义:接收到请求时其响应的可见性是瞬时的,无关过去、将来,而每一个URI可称为该资源的统一接口,除了路径这个维度以外,RESTful让HTTP方法这个维度更具有语义:您可以在一个统一接口中使用不同的方法表达不同的语义,且这个方法仅有一个资源与之关联,且该语义不具有二义性,例:PUT /api/user/:keyDELETE /api/user/:key虽然共享路径,但语义不同且各自唯一。

1.2. 状态

    我们设计一个RESTful接口,接口本身不保存应用程序状态,但很多场景中系统需要状态:如登录过的用户再次访问某些接口时,应用程序能够判断是哪个用户(已登录的状态)在访问该接口,以前最常用的做法就是启用服务端的会话(Session)功能,而RESTful设计中不应该依赖于服务器内存中的会话。

    RESTful中常用的手段就是传入标识

  • 将应用程序状态编码到URI里。

  • 使用令牌(现在常用的一种方式,如JWT Token、OAuth Token等)。

    实现RESTful状态的难点在服务端,可能它只说了这样两句话:

  1. 这次是张三(侵犯姓名权了哦)在访问我。

  2. 哦,张三,这次还是您在访问我。

    第一步实现就是技术视角提到的开发,而第二步实现必须让服务端具备一个功能:可以通过传入的标识在服务端恢复状态(您只需要通过张三这个标识在服务端恢复它旗下所有所需数据,并不需要客户端传入完整的张三对象)。早期的JSP、JSF等Web框架中,会话的状态一般都会采用第一种办法将状态序列化到表单或URI中,这种方式会有很大的弊端就是隐私性和安全性问题,当这些数据在网络传输中其数据本身是不安全的,所以才有了我们目前的做法:只传标识(或带标识的令牌)——三体中维德的名言:只送大脑。

    虽然一直强调RESTful开发过程中不依赖服务端会话,但服务端在状态管理过程中,依旧会使用一种介质来维护当前会话状态,这种介质可以是数据库、文件系统等持久化的,又或者是缓存、内存(会话)这样非持久化的,而状态管理还需让用户从系统注销时清理掉这些状态,这一层的实现要么交给框架,要么自己按照业务逻辑重新设计,不论哪种途径,作为后端开发工程师都不可以忽略状态的管理问题。

1.3. 幂等

    本小节以PUT和POST为例,再谈谈幂等性问题:PUT和POST的本质区别并非字面理解的创建和更新,而是幂等性(Idempotent)问题,而从技术上讲二者没有区别,都可以用于创建或更新资源,这里用最简单的图示来演示幂等性请求:

  • 在POST请求中,由于它是非幂等方法,所以第一次访问和N次访问有可能得到不同版本的响应,如:一个用户提交了一份表单数据到/api/user创建用户接口,由于表单本身没做防重复提交,用户在等待响应过程中点击了第二次,这样就创建了两个用户,这种情况从业务场景上讲产生了副作用(业务本意是创建一个用户,实际上创建了两个用户)。

  • 在PUT请求中,由于它是幂等方法,当您调用/api/user/:key更新用户接口时,不论你发多少次请求,它最终返回的数据都是最新更新的用户数据,所以第一次请求和第N次请求的语义是一致的,不会有副作用存在,而第N次请求更新的数据会直接覆盖第一次请求的数据,这就是幂等的含义。

    工程师在项目中最容易忽略的就是接口的幂等性问题,所以您需要打开一个简单视角:CRUD中(RESTful的接口不仅仅包含CRUD)的每一个字母都会出现N维的操作,如何设计N维操作中的行为就是守住幂等性这条线,无副作用的开发才是核心,幂等性的开发只需考虑一个问题:如果请求重复,服务端怎么处理

一般前端开发走界面时,您可以直接在前端做防重复提交等各种手段来限制用户的请求发送,但是接口是独立的,它不应该依赖任何第三方的客户端来保证自己是无副作用的,前后端分离的场景中,你自己的前端对接口而言也是第三方客户端,这种场景下,后端接口最早就应该引入幂等设计。

    上述两种场景最简单的做法如:根据业务标识判断创建数据的唯一性,在第一次创建成功后返回200 Ok,之后每次创建相同数据时则直接返回201 Created。还有一层争议是更新数据过程中,幂等性的数据范围问题:对于服务端系统级的数据如更新人、更新时间戳、语言、租户标识、环境标识等所有服务端做系统控制和配置鉴别的属性,除非有必要,否则不将这种类型的属性开放给客户端可见,而这些字段如果发生了变更,也不需让客户端感知,客户端从设计上应该只关注整个业务行为中的业务属性部分,这才是前端真正应该侧重的东西。

1.4. 方法

    RESTful设计中,方法的选取是一场博弈,除了URI以外,HTTP方法是接口的第二维度,选择哪种方法来修饰接口也是难点。Web应用的基础是GET方法的幂等性和安全性,GET方法在HTTP协议中是天生既幂等又安全的,您可以重复发送GET请求而不用去考虑它的副作用,所以GET方法在实战场景中属于高频方法。

注意:HTTP协议中提到的天生特性最终是依赖工程师在服务端的开发和实现,并不是您什么都不做它就支持的功能,这里的天生您可以理解成工程师的六十分作品,您可以在服务端实现十分优雅的具有幂等性的POST方法,您也可以在服务端实现具有非幂等性的GET方法。本文的指导规则是让工程师在实战过程中求极限一样去趋近HTTP协议中的语义而生产高质量的系统,去考虑这些语义的一致性,并非写点简单的代码就万事大吉。

1.4.1. GET

    GET方法容易在实现过程中出现两种实战陷阱场景:

  • 使用POST查询来完成复杂的GET语义:由于GET方法在HTTP协议中是不可传请求体(Body)的,所以对于某些复杂的查询(如条件过多、数据统计、数据分析、复杂业务等),实战过程中有时会采用POST查询代替GET查询,这种场景下,语义层面是GET的语义,而实现层却是POST方法:

    • 这种场景下必须做好文档化,和其他POST方法区分开,让任何开发人员都知道这种POST方法是操作而不是操作。

    • 使用统一的命名规范让开发人员一眼就可以知道这个POST接口是读操作而不是写操作。

    • 不推荐)还有一种做法比较反人类但是也高频,就是修改容器中的GET方法实现,让它可以支持请求体(Body),这种做法虽然违背HTTP协议的设计初衷,但却维持了GET语义的一致性,只是这是一种非正式的解决办法,它让您使用技术手段破坏了GET方法的语义。

  • 不在GET方法中引入非幂等操作:这条规则是GET方法实现过程中的禁忌,但依旧被很多工程师采用,理论上系统开发过程严禁将GET方法实现成写操作,如果您的GET方法可以修改服务端的资源,那么您的GET就不可靠了。如发送请求GET /api/save_user/xxx来更新用户,这种设计对基于HTTP请求的客户端而言操作都是安全的,而对服务器而言都是不安全(或者说不推荐)的。

    GET方法有时候也会遇到良性副作用场景(严格讲也不推荐),如:

  • 一条最新的消息,在点击过后标记成已读

  • 应用要统计某条消息的点击量。

  • 使用某种缓存策略时需要在读取数据前做缓存检查。

    如果您的应用有类似需求,那么需牢记几点:

  1. 添加HTTP头Cache-Control:no-cache来确保当前请求不被缓存。

  2. 确保该操作产生的任何副作用都是良性的,不会改变关键业务数据。

  3. 确定该接口的操作是可重复执行(幂等的)。

总之,在使用GET方法设计RESTful接口时,尽可能从语义上维持它和HTTP协议的一致性,对个人开发而言,它是自由的,可在一个团队若无此约定限制,那么很有可能相互之间会出现理解偏差,而这种偏差有可能引起不必要的争端和系统问题,最后就是让一个团队完全沦陷在一个项目里无法抽身,而每天做的事情就是改无穷尽的因为彼此理解偏差引起的低级BUG。

1.4.2. POST

    POST方法在HTTP协议中是天生不具备安全性和幂等性要求的方法,一般使用它做资源的添加以及很多复杂主逻辑的执行,它的特征是:数据量大、逻辑复杂,您可以遵循以下几点:

  1. 通用的CRUD的C操作一般只使用POST,且POST方法在RESTful设计时不带路径参数

  2. 这个接口执行了复杂的业务逻辑,即一个接口修改了多处资源以达到业务一致性。

  3. 参数特别多、数据量大:如POST查询(前文讲过)。

  4. 常用的批量异步操作,如批量修改、批量推送、批量导入/导出等。

  5. 对于某些安全性高的场景,如果HTML页面发送了Referer头,隐藏某些敏感信息可考虑在POST使用编码方式发送数据,而不是暴露在URI路径中。

    POST方法在处理防重复提交时您可以考虑如下状态代码:

  • 200:一切正常,用该代码表示当前记录已经成功创建(或资源已成功修改)。

  • 201:这是容易被开发人员忽略的代码,它表示该资源已存在,服务端执行了类似保存的操作。

  • 3XX:启用新的HTTP响应头做更友好的内容协商。

    使用POST方法创建资源时,还需考虑几个比较特殊的HTTP响应头的使用:

  • Location:在创建资源后,服务端应该返回一个Location头指定新创建的资源所在的地址,它表示客户端可以重定向到该地址实现资源的读取操作,引入这一层设计会使得您的POST方法有了任意场景下的回音。

  • Content-Location:这个头信息几乎和Location是同源的,如果您的响应正文包含了新创建资源的完整表征,那么您就可以在该头中包含新创建的资源的URI。

    如果使用上述两个头,您需要区分二者的差异:Location指定的是需要将页面重定向的地址,一般只在3xx的响应中具有语义,除此之外,您也可以在201 Created消息中带上该头信息,它用来指向新处理(创建)的资源地址。而Content-Location指向的并非原始资源地址,它指向的是一个已经经过内容协商后的资源的直接地址,不再需要执行进一步的内容协商处理(参考下图)。

1.4.3. PUT

    PUT方法是更新资源的标配,所以业界一直误传:POST方法用于创建资源、PUT方法用于更新资源,这种理解是错误的,PUT和POST方法真正的差别是幂等性问题(参考前边章节)。

  • 两个方法都是向服务端发送数据,既可用于创建新资源,也可用于更新资源。

  • POST方法一般会作用于两种核心资源:

    • 客户端无法决定资源的URI(这就是创建语义的来源)时使用。

    • 作用在一个集合资源之上做批量级操作。

  • PUT方法则是面向具体资源的,是客户端可以决定资源的URI时才使用。

    是不是创建就一定是客户端无法决定的资源?看了上边三点,您心里一定会有这样一个疑问,这不就是换了种方式在表达:POST用于创建,PUT用于更新么?——其实不然,有很多场景在创建资源时客户端是可以决定资源本身的URI的。例如在百度网盘中,一台存储服务器可能会提前为每个客户端分配一个根URI,并让客户端把根URI作为文件系统的根目录已创建新的资源,而此时创建资源客户端是可以直接控制URI的,那么用PUT方法比POST方法更合理。

    当使用POST方法创建资源时,服务器会决定新创建资源的URI,这样可保证它更符合服务端的相关规范,而此时创建的资源本身是由服务端生成的。当使用PUT方法创建资源时,新创建资源的URI是由客户端指定,那么此时需要客户端和服务端协同来决定最终新资源的访问:

  • 服务端告诉客户端:哪种URI合法、哪种URI非法。

  • 客户端还需考虑服务端的URI安全模式、过滤规则等。

    从上述两点可以知道,POST用于创建对客户端的设计更友好,客户端不需要去思考服务端的规范、规则、限制等,直接将数据传递给服务端即可,而使用PUT创建新资源时,客户端需要思考的内容更多。而更新资源满足天生的已知资源模式,所以一般会使用PUT方法更新。还有一种做法就是使用POST方法更新,这种场景在数据量很大时(集合法则)采用,但希望工程师记住:POST方法是既不安全又不幂等的,一旦使用它编写服务端相关接口,就必须考虑得尽善尽美,如何在逻辑上实现它的幂等性也是一大挑战。于是您就明白了为什么我们会说:POST方法用于创建资源、PUT方法用于更新资源。

1.4.4. DELETE

    DELETE方法最简单,此处就不赘述,设计系统时需考虑几点:

  • 删除资源之前检查时,若资源不存在则根据具体业务需求选择响应状态代码:

    • 404 Not Found:资源无法找到。

    • 410 Gone:资源已移除。

    • 423 Locked:资源被锁定。

    • 其他状态代码(推荐是4xx类)。

  • 如果考虑前端的设计粗略,可以在多次请求的幂等操作中返回200,如果前端做过更加精细的设计和处理,则按照上一步的方式执行,推荐返回不同的状态代码,这样操作对客户端更友善。


2. 内容!内容!

    内容设计是URI设计过程中无法避开的话题,本章主要针对和业务相关的内容设计进行详细剖析,希望您在对本章进行阅读后对URI设计中的内容部分有更深入的理解和认识。内容——从技术上讲,关键点就是模型,或者称为资源模型;如果说URI的设计完全不考虑内容,那是不现实的,更多时候内容决定了URI的最终形态,甚至于在大型系统中,内容支撑起了整个系统的序列化/反序列化子系统、集成规范、存储结构,这一切都离不开良好的内容设计。

    有人说内容设计是IT单方面决定的,它和业务无关——这是很大的误区,在常见的信息系统(俗称CRUD系统)中,但凡不考虑业务,单纯码代码和堆技术的做法都是在构造后期难以运维的定时炸弹,只有前人栽树才能后人乘凉,所以内容设计的方向就是:服务于需求

2.1. 标识资源

    当您在系统中设计了一种资源模型,这个模型就诞生了,于是它就拥有了生命,当它身处不同的业务环境中,必须对所有客户端都拥有一个“身份证”,让客户端和服务端交互过程中可以鉴别和分类,这就是标识资源——是的,标识资源和数据库中设置主键唯一键类似,但又有所区别,在简单业务场景中,它可以和数据库的设计对齐,但环境一旦复杂,牵涉多租户、多应用、多语言、云原生、数字化,环境一旦发生变化之后,这个单一维度的数据库标识就显得很惨白。

    所以:资源的标识、媒体类型和格式的选择以及接口本身的统一性和差异性在RESTful设计中一直都是最灵活的部分。

2.1.1. 关于领域模型

    这几年比较火的一个词语是DDD——(Domain Driven Design,领域驱动设计),这种设计很早就有了,作者曾经看过一本原版的《Pojos in Action》,这本书是2006年出版的,可以说是领域模型的鼻祖,它从Java的视角给您讲述了如何在Java中进行领域建模,特别是在系统中对对象进行领域分类。

    什么是领域模型?理解领域模型之前您需要先理解一个概念:领域名词——领域名词就是领域设计过程中最能反应某个领域语义的词汇标记,有了此标记,但凡和该词汇相关的语义载体都会携带该领域的业务信息,广义上讲领域可以简单理解为:从业务视觉理解的系统中对象的分类维度,而领域建模是一套完整的方法体系,它的出现彻底改变了传统软件开发工程师对数据库建模的方式。领域建模的本质是要求工程师理解一个需求的业务背景和相关信息,并且将复杂的业务概念和业务规则提炼成领域知识,然后借助不同建模方法论的范式将这些领域知识抽象成既能被计算机理解又可以反映真实世界的领域模型。

    领域建模数据库建模是不同的概念:

  • 领域建模的主体是模型,又翻译成Model,模型是一个无关底层的抽象概念,它所携带的属性可以是多元化的。

  • 数据库建模的语言主体是实体,又翻译成Entity就是我们说的ER模型中的E的含义,实体往往是一个具象概念,我们说的实体在数据库建模都依赖底层的表支撑,而在简单系统里实体和表几乎是一一对应的。

    面向对象面向领域也是不同的概念:

  • 面向领域的主体是领域知识,它和具体的实现技术无直接关系,同样是一个抽象概念,但是它是围绕需求和业务规则为中心的描述方式,它所存在的意义是对所有的业务需求有一个系统级的明确定位,其设计时的思考方向是在下沉,将所有复杂的规则归并、简化、提炼,最终形成一套能够反映现实业务的体系方法。

  • 面相对象的主体是对象,也可以说是语言对象,它由某一种计算机语言描述,从底层数据结构、逻辑为起点,然后以浮现式的设计方法将最终想要表达的业务内容逐一反映出来,其设计时的思考方向是上浮

    总之,领域模型是建立在领域名词基础上以业务为中心的一套完整的体系化的建模方式,在建模过程中,计算机的实现不可以成为抽象领域知识的约束,而建模的方式(如数学建模)也只是对领域建模的一种补充工具。

    标识规则就是领域建模中模型的身份牌,它可以让系统从众多的领域模型中识别资源集合以及资源相关的系统规则:不论是面向对象还是数据库建模都把领域实体(模型可以由多种不同的实体构成)作为设计的基础,此时您可以使用标识来鉴别资源。举个简单的例子,您现在有这样一个需求(参考自《RESTful Web Services Cookbook》):

    设想一个照片管理的Web服务,客户端可以上传新照片、替换现有照片、查看和删除照片(传统常用的CRUD)。

    在上述例子中,“照片”就是一个应用领域的领域模型(由于业务过于简单,此时也可以称它为领域实体),客户端对这个实体有四个动作:上传(创建)、替换(更新)、查看、删除等,在RESTful设计时会使用以下描述:

    在这个场景中,您可以选择由系统为照片分配标识规则,也可以直接使用业务层面的照片完成之后的URI路径作为标识规则,选择哪种取决于您的具体业务场景。有人会觉得,这不就是传统的CRUD么?没错,它给了我们一种古板的映像觉得REST好像只适合CRUD应用,但实际场景远不如这个例子这么简单,如果您只是局限于这个例子中的“照片”来识别领域资源,那么这样的方式设计系统,这种一成不变的HTTP方法的排兵布阵会存在很大的局限性,在面对复杂场景时,您的系统就崩坏了。——这个例子单纯到您的领域名词和需求动作是可以一对一匹配上的,但对于某些复杂的场景(无法匹配),您不改变那种CRUD的思路,那么您的模型就会散架,请记住:CRUD操作只是接口的一部分,并非领域操作的全部

    上述不匹配的例子如:

  • 寻找重庆到上海在不同交通方式下最节省的方式,节省方式会受到时间、金钱、个人偏好因素的三重影响。

  • 根据两个地区不同人员的作息,计算二者会面的最佳时间和地点。

  • 将三个学校不同的报名表合并到一起并根据要求分发到不同的部门进行处理。

2.1.2. 粒度选择

    RESTful中资源的底层主体是资源模型(使用场景中就是领域模型),如果将领域实体和资源模型完全做映射,那么这样的设计是拙劣的,并且效率很低,如何将模型和实体对应上是设计内容时需要深度思考的一个问题,思考过程中,粒度的选择是核心思路,这和数据分析过程中的特征工程有异曲同工之笔,粒度选择实际就是在抽象最契合业务领域的对象特征,并以此特征为依据来对系统进行建模。

    一个场景中牵涉的领域名词不止一个,不同粒度的选择也会影响名词的个数,主要在于您的侧重点在哪儿,粒度太细会导致系统管理的复杂度过高,容易过度设计,粒度太粗会导致业务契合度过低,容易往和业务需求相悖的方向引导。而在粒度选择时把握两个基本准则:

  1. 一开始不要考虑大而全的需求场景,只是单纯地以系统需要提供的RESTful服务为入口,收起您天马行空的思路,不要一开始本质工作还没开始做,就考虑以后要扩展、就考虑要做性能提升,扩展系统是带有感性认知的灵魂设计方法,性能提升是在保证您系统功能完整的前提下的累积性分析。

  2. 采用浮现式设计,迭代型重构,先把最影响结果的核心特征抽象出来,以此为依据构建模型,然后逐渐引入新特征,在您觉得模型复杂度开始变高时,来一次房间整理模式的重构,拆到最小合到最大,取最合理的一个中间值,产生原始基模型的第二个版本,该版本可以将原始特征和新特征的驾驭持平。

    粒度选择过程中往往影响建模的因素可能来自多个方向,比如:可修改性、修改频率、可变性、可见性等等,都有可能进一步影响您最初模型中的资源粒度,资源粒度选择的最终目的是:找到计算机和人的一个折中满足需求的平衡,从性能视角考虑计算机的压力,从功能视角考虑人的诉求即可。之所以不提倡将领域实体资源模型直接映射,就是因为这种做法只考虑了计算机,没有考虑人,是的,您最终提供的服务满足了需求,那么究竟是优雅实现还是冷暖自知这可以成为设计系统时考虑人需的标准——而且作为开发人员,你别忘了,此处的资源模型是需要您自己开发相关工具来管理的,如果一个模型横跨三个实体,您是选择直接管理模型,还是管理三个实体呢?

    归根到底,就是章北海老爹的那句话:您要多想,仔细设计资源粒度。

2.1.3. 计算、处理、行为

    您装备在手的武器是一把剑,而不是一把飞刀!

    接口设计在项目进度比较赶的情况下是一种暴力美学,用最简单和粗暴的方式对系统进行布点,无脑的CRUD可以生成、可以自动化,什么都行,无视范式、无视规则、无视运维,这种暴力美学的确有它的实用之处,但也引起了不少副作用。当您公司人员有流动,那么这把插在墙上的飞刀就再也没有用武之地了,新来的工程师不敢碰也不能碰,新需求来了,抱歉,道理大家都懂,可是以暴制暴,我们只是在创造第二把飞刀。

    这把飞刀的软件语义就是我们通常提到的:计算、处理、执行、逻辑(下文统称处理函数)等,这类型的处理函数可以直接设计成一把人人都会使用的剑,而不是飞刀,这样您的项目才会有所积累,在某些比较复杂的场景中,我们可以将处理函数视为一个资源,并且用HTTP或RPC的方式来架构。它独立于系统而高于系统,它只单纯接受处理函数的输入而生成输出,它坚守软件设计六原则中的单一职责原则。

    我们习惯了面向对象,会觉得REST只适合用于应用领域中的“事物”或“实体”资源,这是对REST理解不到位,它作为架构指导规范,从软件设计的视角讲是自由的。虽然这样的习惯在大多数场景下是对的,但在牵涉到处理函数这个领域的场景中似乎失效了,其实深度思考这个一点,它也是从SaaS转向FaaS的核心主因,是理解力的一种升华。在设计RESTful系统时,您完全可以将“行为”也处理成资源,它虽然无法构成我们的领域模型,但对计算机而言,它是实际存在的“事物”,所以面对计算和处理,您可以参考以下几个思路:

  • 复用法则:行为和处理必须是可复用的,整个系统就一处代码,没有第二个,哪怕趋近于一个也是合理的。

  • 多义性法则:使用语言级的重载,让某些具体语义的行为和方法出现多种不同的形态(而不是重新命名),而这些形态适配需要它的各种场景,在整个系统中,行为本身具有唯一的语义标识。

  • 单一化法则:某个行为或处理函数不做额外的事,不要将行为耦合在一起形成超集。

  • 多态扩展:在支持面向对象的系统中,合理使用继承和模板模式对行为进行去重,由于Java在8开始引入了lambda,那么行为本身也可以传参,那么您在这种系统中就可以一生二、二生三、三生万物的方式进行系统扩展。

    有了上述思路作为参考,您就可以合理编排系统中的行为了,而对某些特定的行为而言,直接抽象成资源接口,资源接口虽然不直接对应资源模型的某个动作,但它可以是某个资源模型的动作集合,这样当英雄换代、团队更替时,您依旧可以使用这种简单的行为资源,直接消费。上述思路只是作者写框架过程中多次重构迭代的一种心得,并非标尺,提供给大家参考,并仔细思考一个问题:您要设计的资源究竟是一个什么形态行为抽象是在系统设计中的一种费脑子的思路,但带来的福利是很高的,它的思考点是立足于将所有针对资源的操作原子化,单纯从领域模型中似乎看不到这种抽象的好处,于是您可能就直接忽略了,但它可以在一定程度上降低服务端的业务复杂度,并给客户端更自由的组合机会以达到解耦的目的。这种思路近似于将一个完整的业务行为抽象成Monad函数链,而整个函数链上每一个Monad都是可替换的,而这些原子化的行为在整个系统中只有一份代码,您可以将这份代码放在环境、配置、上下文任意位置而和真实业务实现隔离,一旦这些代码出现Bug,那您的活动区域(修Bug)也会单纯到只在这一个抽象中完成,这种抽象在很多场景中可以最大限度减小运维压力。

您的代码从诞生之初,您就要考虑它不会死亡,如果有一天它死了,那不是代码的问题,而是创造它的人的问题。

2.2. 表征设计

    表征——即RESTful单词中R的含义,R的全称是Representational,大部分书籍中会翻译成表述,但我更喜欢表征这个词语,带有特征的语义

    首先,表征并不代表数据,它不是我们在常用的序列化/反序列化子系统中接触到的数据的统称,它包含了一连串数据字节加上用于描述这些字节的元数据,而元数据会存在各种不同的描述方式:

  • 在SOAP协议中,您可以将元数据放在<SOAPHeader/>部分进行描述。

  • 在HTTP中,表征元数据则由实体头的键值对来实现。

  • 在JSON格式中,您可以设计更加优雅的规范化数据结构,为元数据留下描述空间(复杂系统才考虑,简单系统直接用HTTP头就满足了)。

    这些头信息和应用数据本身一样重要,它保证了数据本身的可见性、可发现性、通过代理路由、缓存、乐观并发性,并告诉系统打开数据的正确方式(参考下图)。

    元数据分析并不是什么新东西,框架使用久了会被开发人员遗忘,因为在不需要自定义的场景中,部分元数据分析其框架内部已经帮您完成了,所以我们是忘记了:这个东西一直存在,使用头信息对表征数据进行注解是十分常用的一种方式,而在代码级别,我从来不推荐在核心业务代码中使用if-else的模式来处理元数据,哪怕是使用前端拦截器或请求过滤器(JavaEE设计模式)搭建一个请求的分流器,也比if-else优雅很多,因为对头信息的分析和处理一般是全局规则,最合理处理它的位置就是请求之前。

2.2.1. 请求头

    最常用的描述表征的方式是直接使用HTTP请求头(一般的框架行为),如最常用的几个响应头信息如:

    关于这些头的详细使用在下一个章节内容协商来详细讨论,此处需要说明的是,如果您想要请求变得更具识别性,而又不想自己自定义某些请求头来描述信息,那么您可以直接沿用HTTP协议中的设计:发送方可以用一系列名为实体头的标头来描述表征正文(实体正文/消息正文),有了这些标头,接收方可以在无须查看正文时决定如何处理(启用分流器),它还可以提前处理正文对当前请求执行预判。

Content-Length标头最早是在HTTP 1.0中引入的,接收方用它来判断自己是否从连接中读取了正确的字节数,要发送该标头,发送方需要在发送正文之前计算出表征的大小,而从HTTP 1.1开始,启用了分块转换编码(Chuncked Transfer Encoding),这个标头就显得有些多余,为了兼容遗留系统,这个标头就被保留下来了。还比较特殊的一点是在POST和PUT请求中,即使使用了Transfer-Encoding: chuncked的标头,也要在客户端请求中包含Content-Length,防止某些代理出现拒绝请求的行为。

2.2.2. 编码/格式

    格式和编码是请求方和接收方的一种强制性协议,如果二者未达成一致会造成数据损坏解析错误使得请求无法正确到达。在发送请求表征时,如果媒体类型(MIME)允许使用charset参数,则该参数会指明编码值,该值将使用于将字符串转成字节。

    编码是字符和字节之间的恋爱关系,肉眼是看不见的,所以不要错误理解觉得你看见的是对的,底层编码如何只有系统知晓。

    因为编码问题造成的错误很难发现,当请求使用UTF-8对文本编码成字节,而接收方使用了ISO-8859-1对内容进行解码,有可能就会出现乱码,而使用Windows-1252进行解码,则肉眼层面看不到任何变化,所以在设计表征时一定要考虑编码问题。因为编码不匹配您可以参考https://en.wikipedia.org/wiki/Mojibake,这种现象就称为Mojibake

    其次是媒体类型,现代应用大部分场景都使用了application/json作为标准来定义媒体类型,然而没有哪种类型能满足所有资源和表征,针对所有表征使用同一种类型(JSON或XML)会降低系统的灵活程度。那么如何选择媒体类型和数据格式呢?

  1. 先确定是否有一个标准格式和媒体类型能满足您的用例,可访问Internet Assigned Numbers Authority, IANA媒体类型登记处。

  2. 如果找不到标准媒体类型和格式,可使用诸如XML(application/xml)、Atom Syndication Format(application/atom-xml)、JSON(application/json)扩展性强的格式。

  3. 如果要在请求中传入额外的数据表征,可使用image/png的图片格式或application/vnd.ms-excelapplication/pdf这种富文本格式,使用这些类型的格式需引入下边标头:

    Content-Disposition: attachment; filename=<文件>

达摩克里斯:新类型

    当然在媒体类型选择中,您可以设计全新的文本或二进制格式,带上特定于应用程序的编码、编码规则,并为那些格式分配新的媒体类型;这种方式只适用于您的应用程序数据十分特殊,明显区别于现有相关的媒体类型如音频、视频、文档格式、二进制格式。如果您要创建自己的媒体类型,可以按照如下指导规则:

  • 若媒体类型是基于XML的,直接使用+xml结尾的子类型。

  • 若媒体类型是私有的,则使用vnd.前缀的子类型(vnd = vendor,表示特定厂商/实现的类型)。

  • 若媒体类型是公共的,按RFC 4288向IANA注册您的媒体类型。

    如果媒体类型没有被广泛认可,可能降低工具之间的互操作性,所以:除非希望被广泛使用,否则应该避免引入新的特定于应用程序的媒体类型。自定义新类型是一把完美的达摩克利斯之剑。

2.2.3. 常用思路

    如今的RESTful时代,JSON大道通天、XML临时靠边,几乎是一家独大的局面,尽管如此,合理设计不同格式下的表征结构依旧是工程师逃脱不了的必修课。

    XML表征

  • 在每个表征中包含一个指向资源本身的self链接,如此客户端和服务器能更方便地处理请求并生成响应。——如果处理表征正文的代码不能访问请求过的URI或响应头时,在表征正文中包含的self链接就可能派上用场。

  • 如果表征中的数据包括自然语言部分,请自觉加上xml:lang属性表示元素的本地化语言。

    JSON表征

  • 参考XML,同样在每个表征中包含一个指向该资源的self链接。

  • 参考XML,如果实现本地化,需额外引入一个属性来表示本地化语言下的数据。

  • 而对于组成访问资源的应用程序领域实体,在表征中添加对应的标识规则(身份牌)。

    集合表征(可Json也可XML)

  • 参考XML,在每个表征中包含一个指向该资源的self链接。

  • 如果集合启用了分页,还需额外引入两个链接:

    • 上一页:指向上一页的链接。

    • 下一页:指向下一页的链接。

  • 引入一个指示符标识该集合的大小

    实体标识符:虽然URI是RESTful中Web服务的唯一标识,但应用程序代码仍然需要前文提到过的标识规则用来标识领域模型或实体,在某些大型异构应用程序集合里,来自资源的数据可能横跨多个系统边界,此时实体标识符可用于交叉引用或转换数据,这种场景下对于每个模型或实体,最好包含URN格式的标识符,它的作用如下:

  • 您的客户端和服务器只是某个搭环境中的一部分,这个环境包含了使用RPC、SOAP、异步消息、存储过程、第三方应用的应用程序,实体标识符则是这些系统中唯一的公共命名者(denominator),提供了全局一致的数据标识符。

  • 客户端和服务器可以存储自己的实体副本,而不从URI中解码或把URI作为来对待,此时实体标识符可应付URI发生改变的情况。

  • 并非应用程序领域中所有实体和模型都直接映射到了资源上,当某些资源模型跨领域模型又跨领域实体时,实体标识符可为某些数据提供唯一标识符。

此处的实体标识符可理解为领域模型或领域实体的唯一标识规则,在简单系统中可和资源直接绑定,但复杂系统里就不可同日而语了,读者需要深知:资源模型、领域模型、领域实体三者之间的区别和联系。

2.2.4. 可移植性

    可移植性的最佳思路是:标准统一。在表征中针对单独的特定数据如日期、时间、国家、数字、时区、货币等,可能会因为文化差异使得不同地区的格式有所区别,甚至在处理日期或时间格式时,是否引入夏令时都会导致这些格式造成的互操作性问题。那么应该如何选择合适的格式呢?

    除非文本要呈现给最终用户,系统级运行这些数据避免使用特定语言、地区、国家等相关格式,而应该直接使用标准格式(可移植的格式)取而代之:

  • 使用W3C XML Schema中为格式化数字(包括货币)定义的小数、浮点数和双精度浮点数数据类型。

  • 针对国家、从属地区,使用ISO 3166代码。

  • 针对货币,使用ISO 4217字母或数字代码。

  • 针对表征中使用的日期、时间和日期-时间,使用RFC 3339规范。

  • 使用BCP 47语言(RFC 5646RFC 5645)标签来表示文本语言。

  • 使用Olson时区数据库中时区标识符来表示时区。

    当然因为项目进度或时间成本的原因,一般项目没有办法做如此细粒度的设计,在这种原因驱动下,最好的方式就是采用一种标准先定基调,且这种标准不携带任何地方特性。

2.3. 内容协商

    内容设计另外一个比较重视的话题就是内容协商(Content Negotiation),有时简写成conneg,该处理过程的使用场景如下:如果存在多个可用资源的表征形式,为客户端选择一个最好的出来。和开发人员最接近的就是标明媒体类型(MIME)的偏好,当然它也可以用来标明本地化语言、字符编码以及压缩方面的偏好。

    内容偏好分两类:服务端偏好客户端偏好,在实现时,它表示:

  • 客户端偏好:客户端向服务器标明自己的偏好和处理能力。

  • 服务端偏好:服务器向访问客户端标明自己支持的偏好。

    带偏好的请求的流程如下:

  1. 客户端告诉服务器它能处理的表征格式、偏好语言、字符编码、是否支持压缩。

  2. 如果服务器可满足客户端所有需求并且支持,则直接按偏好将响应发送给客户端。

  3. 如果服务器无法满足客户端偏好,则以另外的常用表征格式返回给客户端,若客户端无法处理则报错。

    服务端偏好和前文提到的Content-前缀标头(Header)的使用如出一辙,此处不赘述,您可以参考下表看服务端常用的媒体类型和规范:

    内容协商过程中服务端可支持的表征处理是固定不变的,当您的服务端程序发布过后就已经定义了它的处理能力,接收到请求过后能处理就处理,处理不好要么升级要么直接告诉客户端请求非法。如此您可能会困惑:协商什么?——协商的基础在于客户端是一个变化量,它的偏好是携带在请求中发送给服务端的。

    客户端发起一个请求时带上Accept头,它的值是一个偏好媒体类型的列表,可以拥有多组,每一组之间用逗号隔开,不仅如此每一组还带有一个核心参数q,这个参数标明了客户端对那些在Accept-*列出的各种媒体类型的偏好程度,若只能处理某些特定格式,那就在头中添加q=0.0,告诉服务器自己无法处理没有列在Accept-中的媒体类型。常用的客户端Accept头如下:

    Accept-头存在的目的是客户端告诉服务端响应表征的偏好,服务端则基于自身支持的能力对其进行评估选择最佳表征返回,由于整个过程依旧是服务器筛选和输出结果,所以又可以称为服务器驱动的内容协商(Server-Driven Negotiation,另外一种是代理驱动的内容协商Agent-Driven Negotiation)。举个例子:

# 请求头
Accept: application/json;q=0.8, application/xml;q=0.6, */*;q=0.0
Accept-Language: cn;q=1.0, en;q=0.5
Accept-Encoding: gzip

    上述片段中,客户端对服务端讲出了自己的需求:

  1. 优先处理JSON格式、XML格式次之。

  2. 优先处理中文语言、英文语言次之。

  3. 只支持gzip的压缩方式。

参数q是一个浮点数,通常带一位小数,不过HTTP 1.1开始允许小数点之后三位,它的值从0.0(无法接受)到1.0(最为理想),默认值是1.0。该参数的支持程度依旧依赖服务器实现,而服务器会从Accept中选择第一个客户端支持而服务端又满足的表征类型。

    如果您理解了q的含义,那么对编码协商和压缩协商就更容易理解了。编码协商使用Accept-Charset头,现代应用常用UTF-8作为默认编码类型使用,而微服务平台常用的application/jsonapplication/xml默认都是使用的UTF-8的编码,您在开发应用过程应该避免使用text/xml这种类型,它的编码默认是US-ASCII,中文小朋友应该很清楚这种格式带来的副作用。服务器一般提供常用的压缩表征格式包括gzip, deflate, compress,这种技术在HTTP协议中称为内容编码(Content Encoding),如果服务器支持压缩响应内容,则从Accept-Encoding中选择一种压缩技术,若没有支持的类型,则直接忽略。

令人困惑的Vary(响应头)

    Vary的翻译为多样化,当服务器使用内容协商来选择表征时,同一个URI资源有可能根据Accept-*的不同产生不同的表征,而Vary头告诉客户端:服务器选择表征过程中使用了哪些请求头,简单说这个响应头是服务器对客户端偏好的一种应答,它们之间的交互如:

  • 客户端:嘿,我能支持这些。 服务端:我根据你的支持选择了A、B、C。 客户端:啊?你给我的Vary中,只使用了Accept-Language? 服务端:对的,这是我这边内容协商的结果。 客户端:那我提供的Accept-Encoding呢? 服务端:哦,那个我搞不定,没使用。 客户端:好的,我知道应该如何区分这几份不同的响应了。

    针对同一个资源,一旦协商结果是多种形式的表征,响应头中必须包含一个Vary头,它的值是一些请求头的列表,不同请求头使用逗号分割开,若服务端除开这些请求头协商过程还是用了额外的请求头,就直接将Vary设置成*

Vary本身是响应头,但它的值是一组请求头,这点不要混淆。

2.4. 优雅地犯错

    HTTP请求在RESTful中实际是客户端和服务端针对表征的交换,对错误来说也是如此。若服务器发生错误,它需要返回一个反应错误状态的表征,且不论这个错误是由客户端引起的还是服务端导致。这个错误包含:

  • 响应状态代码(4xx, 5xx)

  • 响应头

  • 包含错误的正文描述

    基本实现规则如下:

  • 对于客户端输入造成的错误,返回4xx的状态代码表述。

  • 对于由于服务器实现或当前状态造成的错误,则返回5xx状态代码表述。

  • 以上两种情况都应该包含一个Date头,它表示错误发生的时间-日期值。

  • 除非您使用的HTTP方法是HEAD,否则都应该在表述中包含一段正文,使用内容协商的表征结果、适合阅读的HTML、纯文本进行格式化和本地化

  • 若发生错误之后,这个错误您已经拥有了纠错、调试的错误信息作为解决方案背景,则应该包含一个指向该文档的链接(使用Link头)或正文中的链接。

    错误信息大部分时间是人工阅读,所以您尽可能将错误信息的正文设计得更易于调试,不要将太多的系统堆栈、数据库连接、网络异常的系统级信息呈现在响应错误中;另外一种常犯的错就是返回了成功的状态代码(2xx, 3xx),但在消息正文中却包含了错误信息描述,这是大部分RESTful服务懒于设计导致的通病。


3. HTTP状态详解

    本小节枚举一份HTTP状态代码表以及使用场景分析,让您对之前讲解的内容有更直观的感性认识。RESTful设计中推荐让开发人员释放HTTP协议的语义,基于这点您可以根据本表的信息选择更加适合的HTTP状态码来生成相关响应,而不是直接使用简单的400、500、200;虽然HTTP协议的状态码无法对业务应用的响应进行精确描述,但最少它提供了一种分类维度,这种维度可以被广泛的工具和第三方软件所接受。

    HTTP状态码是用以表示服务器超文本传输协议响应状态的3位数字代码,其中HTTP/1.1版本由RFC 2616[^1]规范定义,而最新版的一份语义规范由RFC 9119 HTTP Semantics[^2]定义,在下文中标记了「扩展」的状态码表示该状态码并没在HTTP标准协议中定义。

3.1. 1xx 状态码:二次请求类

100 Continue

只有数据量大时候才使用,此处的数据量的限制依赖于服务端的实现。

    若客户端想要以POST方法发送数据给服务器,在数据量比较大时则启用100 Continue协议,该协议的处理步骤

  1. 客户端已知将会发送数据量大的请求,有两种选择:

    1. 直接将请求(携带数据的真实请求)发送给服务端。

    2. 不发真实请求,直接征询服务端相关状况。

  2. 如果是1.1则服务端直接处理,如果是1.2则服务端发送100 Continue给客户端。

  3. 客户端收到来自服务端的应答,随之发送真实请求。

  4. 若服务端无法处理则抛出相关错误。

    参考下图:

  • 对客户端而言,发送请求之初需做一个简单的判断:1)是否有POST数据要发送?2)发送的POST数据是否量很大?两者都满足时可以考虑使用100 Continue协议,并在请求时带上Expect: 100-Continue请求标头;若任一条件不满足,客户端禁止使用100 Continue协议以防止服务端造成误解。

  • 对服务端而言,实现之初就需定义是否支持100 Continue协议,若不支持该协议需设置timeout时间,客户端在该时间内无法得到征询响应,则直接发送POST数据交给服务端处理。

  • 如果服务端提前收到了POST数据(图中类型1请求线路),则禁止发送100状态码,而切换为直接处理请求数据。

101 Switching Protocols

    这个状态代码用于服务器指示TCP连接将用于不同的协议,最常用的例子就是WebSocket协议,一般WebSocket连接在创建时出于安全考虑会使用HTTP握手,握手成功后服务端会给出101 Switching Protocols状态码。

如请求:

GET /chat HTTP/1.1
Host: ox.engine.cn
Upgrade: websocket
Connection: Upgrade
Origin: http://ox.server.cn
Sec-WebSocket-Key: RcQyRHDntM2sorh1wcLW84bC
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13

若服务端支持该协议,则直接发送101 Switching Protocols响应:

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: cHkBpqZaPtmh5JexijVWbzuG
Sec-WebSocket-Protocol: chat

    除了上述WebSocket例子以外,还可以让服务端提供待切换的可支持的新协议,例如从HTTP/1.1升级到HTTP/2,同样可使用该状态码。

102 Processing「WebDAV扩展」

    该状态码由WebDAV(RFC 2518[^3])扩展提出,它代表服务器已经接受了客户端发送的请求,但服务器依然在执行该请求,尚未完成。这个状态码一般只在服务器执行长时任务时启用,并且在服务端执行完最终请求后必须发送响应回客户端,该状态码对客户端的实现最好执行异步任务流

  1. 先发送请求开启后台异步任务。

  2. 然后在特定场景下查询任务状态看状态码是否102,如果是102则客户端继续等待服务端执行完成。

  3. 任务完成:

    1. 若客户端提前查询到任务完成则执行完成流程。

    2. 服务端启用类似WebSocket协议主动推送最终任务完成的信号给客户端。

3.2. 2xx 状态码:成功类

200 OK

    最终常用的状态码,它表示请求成功响应,注意在200响应时区分GETHEAD请求的区别。

201 Created

    服务端通知客户端该资源已被创建,标准模式下,当您发送201 Created响应状态码给客户端时必须带上Location响应头,并告知客户端已存在的资源读取地址。此状态码主要为CRUD添加操作中的C提供了幂等性参考,只有第一次创建资源时返回200 OK状态码,之后N次相同请求直接返回201 Created状态码,服务端有此实现,即使终端未启用防重复提交也不会引起数据一致性问题。服务端在实现该状态码时,后端参考的标识规则并非直接使用数据库主键唯一键,而是根据您的业务场景设计的标识资源的业务标识规则(参考2.1)。

202 Accepted

    该状态码读者可能有些困惑,和102 Processing什么区别?该状态代码同样表示服务端已接受请求,但尚未处理,如同它可能被拒绝一样,最终该请求可能不会被执行。此状态代码是标准的异步流,而且是HTTP协议中定义的标准状态码,并非「扩展」状态码。它的目的是允许服务器接受其他进程的请求(可能是每天一次的后台任务或批量任务进程),不需要客户端和服务器一直保持连接直到进程结束,同时当服务器生成此响应时需要将当前任务的状态告知客户端。

语义上,102 Processing比202 Accepted更具语义特征,而二者在使用时其最终目的是一样的,推荐开发人员优先考虑202 Accepted,主要原因是202 Accepted是HTTP标准规范中的内容,而102 Processing则是扩展规范中的内容,扩展规范一般是为了某些专业领域的特殊目的而设计,102 Processing就是WebDAV在处理文档管理时的一种扩展,它最初的设计并非为了异步任务。

203 Non-Authoriative Information

    该状态码表示请求已经成功,但包含内容已通过转换代理(Proxy)从原服务器的200 OK响应中修改,它允许代理在应用转换(Transforming Applied)时通知客户端,因为这些修改很有可能影响内容协商结果或内容筛选的决策。例如:

未来对内容的缓存验证请求仅适用于相同请求路径的资源!

    若无特殊说明,203响应可被启发式缓存处理。

启发式缓存:如果一个可以缓存的请求没有设置ExpiresCache-Control,但响应头又包含了Last-Modified信息,这种情况客户端有可能会有一个默认的缓存策略:(Date - Last-Modified)x 0.1,这就是启发式缓存。目前大部分浏览器都实现了启发式缓存,并且只有在服务端没有返回明确缓存策略时才激活浏览器的启发式缓存策略。

204 No Content

    该状态码表示请求已经成功执行(一般用于PUT请求),但响应内容中没有额外内容需发送,响应头中的Metadata表示目标资源和应用请求操作之后的一种选择执行策略。该响应状态码允许服务器指示操作已成功应用于目标资源,同时暗示客户端不需要遍历文档视图(若存在),服务器假设客户端根据自身接口可得到一些成功指示,将任何新的或更新的元数据放在响应表征中。该状态码应用场景如:

  1. 在条件请求(Conditional Request)中,客户端询问服务器是否有更新的资源副本,并且回发它所持有的原始资源信息(从缓存中读取),服务器直接告诉客户端更新内容或者副本是最新的。

    1. 若有更新内容,客户端将收到204 No Content并且带上ETag字段标识目标资源新的实体标记。

    2. 若没有更新,则返回304 Not Modified状态码。

    3. ETag设置了Last-Modified,可触发请求中的If-Modified-SinceIf-None-Match请求字段。

  2. 该状态码还可用于文档编辑界面的“保存”动作,它可以使正在保存的文档仍旧让用户编辑使用。

  3. 该状态码还可以与自动化数据传输接口结合使用,如:分布式版本控制。

    该状态码一般在header节点之后终止,严格讲不能包含任何消息体(Body)。而它虽然表示没有任何额外正文响应,但服务器实现可能会包含部分数据,所以它又允许客户端在处理响应方式上有所不同,在持久化连接中它是可观测的,无效的消息体可能包含对后续请求的策略以及独特处理模式。

205 Reset Content

    该状态码表示服务器成功处理了请求,且响应内容中没有返回任何额外消息体,和204的区别在于该响应要求客户端重置文档视图(Document View),将它重置为从源服务器接收到的原始状态。

文档视图(Document View):HTTP协议最早设计并非为了RESTful和Web服务,它是超文本传输协议,当客户端发送请求到服务器,最终服务器会返回表征数据,而这份数据会在客户端(一般是浏览器)中以文档形式呈现,文档视图就是最终呈现在客户端的可见的文档信息。

206 Partial Content

    该状态码表示服务器通过传输所表示的一个或多个范围成功满足了对目标资源的范围请求,范围请求的报文一般会带上一个Range标头:

GET /ox.jpeg HTTP/1.1
HOST: ox.server.cn
Range: bytes = 2001-4000

    支持范围请求的服务器会尝试满足所有请求范围的数据,在另一个客户端请求剩余数据时,服务器出于自身原因(临时不可用、缓存效率、负载过高)只希望发送剩余请求数据子集(更少的数据),此时就用该响应代码。206 Partial Content具有子描述性,当服务端发送响应时会带上部分元数据描述信息,如:

HTTP/1.1 206 Partial Content
Date: Fri, 29 Aug 2022 12:15:31 GMT
Content-Range: bytes 2001-4000/4000
Content-Lenght: 2000
Content-Type: image/jpeg

    范围格式如:

    客户端处理206响应时,必须检查Content-TypeContent-Range字段以确定当前响应包含了哪些部分,是否需要额外的请求基继续处理。服务端生成206响应时必须生成以下标头(若在同一请求的200 OK中发送则忽略):Date, Cache-Control, ETag, Expires, Content-Location, Vary。206响应中出现的Content-Length表示本次响应内容中的字节数,不表示完整长度,而Content-Range标头中会包含完整长度相关信息。使用场景如:

  • 下载软件(如迅雷)的断点续传功能。

  • 将一个大文档分解为多个下载段同时下载,文件分片功能。

范围请求的范围划定是以字节为单位。

207 Multi-Status「WebDAV扩展」

    该状态码同样由WebDAV(RFC 2518)扩展提出,它代表之后的消息体是一个XML消息体,其格式如:

<?xml version="1.0" encoding="utf-8" ?>
   <multistatus xmlns="DAV:">
     <response>
          <href>http://www.foo.bar/container/</href>
          <propstat>
               <prop xmlns:R="http://www.foo.bar/boxschema/">
                    <R:bigbox/>
                    <R:author/>
                    <creationdate/>
                    <displayname/>
                    <resourcetype/>
                    <supportedlock/>
               </prop>
               <status>HTTP/1.1 200 OK</status>
          </propstat>
     </response>
     <response>
          <href>http://www.foo.bar/container/front.html</href>
          <propstat>
               <prop xmlns:R="http://www.foo.bar/boxschema/">
                    <R:bigbox/>
                    <creationdate/>
                    <displayname/>
                    <getcontentlength/>
                    <getcontenttype/>
                    <getetag/>
                    <getlastmodified/>
                    <resourcetype/>
                    <supportedlock/>
               </prop>
               <status>HTTP/1.1 200 OK</status>
          </propstat>
     </response>
   </multistatus>

    该响应代码可能依赖之前请求数量的不同,包含一系列独立的响应代码(多请求多响应模式),WebDAV中常用的响应状态码如:

208 Already Reported「WebDAV扩展」

    该状态码表示DAV绑定的成员列表已经包含在了前一个响应中,并且不会再次发送,由WebDAV(RFC 5842[^4])扩展提出。它主要用于DAV:(XML名空间)的propstat响应元素,避免枚举内部成员多次绑定到同一个集合,对每次绑定到请求范围内的集合,只有一个会返回200 OK状态,之后所有的DAV:response元素都会返回208状态。

    208 Already Reported状态码只会出现在深度:无穷大的请求语义中,且它是特别重要的,客户端可以在PROPFIND中请求DAV:resource-id属性要求保证他们可以准确地重建绑定,支持多个节点绑定到单个集合的资源结构。

226 IM Used「扩展」

    该状态码表示服务器已经完成了对资源的响应,此响应可用于当前实例的一个或多个操作的共同结果。(参考RFC 3229[^5])。

3.3. 3xx 状态码:重定向类

300 Multiple Choices

    该状态码表示目标资源存在多种表征,每种表征都有自己的标识符,并且提供了和更新资源相关的信息以便客户端可通过某种方式选择首选表征或将请求重定向到这些标识符中的一个或多个——简单说服务器希望客户端参与内容协商以选择所需的表征信息。

  • 如果服务器有首选项,应该生成一个包含了Location标头的响应,用户可通过该值自动重定向。

  • HEAD以外的请求方法,服务器应该在300响应中生成内容,其中包含元数据和URI引用列表,客户端可选择自己偏好的一个;客户端选择时必须是可理解的媒体类型。

301 Moved Permanently

    该状态码表示目标资源已经被分配到了一个新的永久URI,并且任意未来对该资源的引用都应该使用新URI之一,而新的资源地址可以在Location响应标头找到。

  1. 服务器在响应中生成一个Location标头,其中包含了新的永久URI和首选URI引用。

  2. 客户端可以直接使用Location字段进行自动重定向。

  3. 服务端响应的内容中应该包含部分注释,对新的URI进行元数据级的描述。

注:因为历史原因,客户端可以将后续请求的请求方法从POST更改为GET,若想要禁止这种行为,则可改用308 Permanent Redirect替换。

302 Found

    该状态码(历史值:Moved Temporarily)表示目标资源暂时留在不同的URI之下,以后有可能会更改重定向地址,因此客户端应该继续使用目标URI来发送请求,该响应代码的处理流程和301类似。

注:因为历史原因,客户端可以将后续请求的请求方法从POST更改为GET,若想要禁止这种行为,则可改用307 Temporary Redirect替换。

303 See Other

    该状态码表示服务器正在将客户端重定向到不同的资源(如Location标头中URI所示),目的是提供对原始请求的间接响应。客户端可直接执行针对该URI的检索请求(若HTTP则使用GET或HEAD请求),该请求也可能被重定向并将最终结果作为原始请求的应答呈现——Location标头字段中的新URI不视为和目标URI等效。

  • 它允许POST操作的输出将客户端重定向到不同的资源地址,这样做提供了和POST响应相对的信息可单独识别、添加书签、缓存等,POST请求的单独识别可防止触发第二次相同的POST请求(防重复执行)。

  • 对GET而言,303响应表示源服务器没有可直接访问的资源地址,但是您可以访问Location指向的重定向地址作为目标地址访问所需资源,所以在资源上检索地址得到响应并不意味着原始地址有内容,特别是客户端支持自动重定向时,此处地址已经被重定向到另一地址获取资源。

304 Not Modified

    该状态码表示在条件请求GET或HEAD中,条件检测为则会生成200 OK响应,若条件检测为时,服务器默认客户端已经拥有了最新版本的表征数据,不需要传输目标资源的表征数据生成响应——这种行为服务器会将客户端重定向到已经存储(如缓存中)的表征数据中,客户端就像收到了200 OK一样。

    服务端生成304 Not Modified响应时必须携带以下任意一个响应标头:Content-Location、Date、ETag、Vary、Cache-Control、Expires,由于该响应的目的是在接受者已经拥有一个或多个缓存时实现信息传输最小化,因此发送者(客户端)不应该自己生成上述标头来描述元数据。但也有例外:如果想要从客户端丢掉已有缓存,想通过请求指导缓存执行更新时可指定,此时若响应中不包含ETag标头,请求可提供Last-Modified标头告诉服务器客户端需执行缓存更新。

305 Use Proxy / 306 Switch Proxy

    早期版本使用过,如今已弃用,立个碑,拜一拜……

307 Temporary Redirect

    用法同302 Found,不赘述。

308 Permanent Redirect

    用法同301 Moved Permanently,不赘述。

    307/308和301/302两对的区别在于请求过程中对于重定向资源访问的HTTP方法是否执行切换,对于POST请求如果直接重定向有可能会引起资源的二次写入副作用,在这种场景下优先推荐使用301/302(这两个状态码是允许该操作的),若是单纯的GET或HEAD请求直接读取数据,使用307/308是最优方案。

3.4. 4xx 状态码:客户端异常

    到这里,可以说一句主角来了,本小节开始的两类状态码应该是开发人员和架构师聚焦的地方。

400 Bad Request

    该状态码告知服务器异常信息来源于客户端错误,服务器无法处理或不会执行该请求。常见的客户端错误场景如:

  1. 数据类型输入有误 例如:服务端有一属性age要求数值类型,而请求中传入了不兼容的字符串,如字母A

  2. 无法执行序列化和反序列化 例如:客户端传入JSON和XML格式不符合服务端做反序列化的要求。

  3. 验证未通过 例如:邮箱地址不合法、货币格式有错、时间日期非法等业务型验证不通过。

  4. 远程检查失败 例如:客户端传入域名 ox.server.cn,由于调用接口对此域名做了远程检查,无法PING通,也视为非法请求。

  5. 请求中携带攻击性脚本 例如:传入的某个属性存在SQL注入或脚本攻击,视为非法请求。

    该状态代码在HTTP规范中只有一句话,但属于开发过程中的高频状态码,它描述了一个从客户端到服务端数据的规范化流程,曾经JavaEE中几个经典设计模式:前端过滤器、请求拦截器等都是为这种场景量身打造,它的目的就是让服务端您所编写的程序拿到的基础输入是符合规范的。——倘若在程序的输入处都写上一句if来检查参数,这是一种不理智的极端行为,所以在语言实现层内部,编写服务端程序时只在数据流的核心隘口加入参数检查

    您所开发的程序作为一个整体,上层组件和下层组件交互时,它们通信的数据本身应该是合规的,且这种法则只适用于内聚性组件;对于两个需要解耦的组件则反其道而行,检查入口参数是必须的,至于二者是强依赖还是弱依赖取决于最终您所开发的场景。客户端自身提供的数据不合规,就让它自己去解决,不是所有数据都依赖服务端的默认实现,服务端可以提供默认实现,但只是在保证系统正常运行的基础上提供。

401 Unauthorized

    该状态码主要用于资源请求的安全验证。

  • 当应用请求缺乏有效的身份凭据时,服务端生成401响应告知客户端认证未通过,同时服务端需发送一个WWW-Authenticate头给客户端。WWW-Authenticate标头语法如下:

    WWW-Authenticate: <type> realm="xxx" charset="xxx" ...  # 空白分割

    该值主要提示客户端使用何种认证方式,它和401 Unauthorized一起响应,常用属性包括:

    • <type>:指定了验证的类型,常见的如Basic, Bearer, OAuth, Digest等[^6]。

    • realm:指定保护域的描述,若未指定该值客户端默认显示格式化的主机名。

    • charset:告知客户端用户名和密码首选的编码方式:目前为固定值UTF-8,也是唯一允许的值(可省略)。

  • 若应用程序请求中包含了身份凭据,则401响应表示服务端拒绝了这些凭据的授权,客户端可使用新的或更换授权头字段重复该请求,一般安全请求会携带Authorization标头。

    Authorization: <type> <credentials>
    • <type>:上文中提到的验证类型,需和WWW-Authenticate中返回类型一致。

    • <credentials>:用户的身份凭据,身份凭据一般是编码加密过的信息。

402 Payment Required

    该状态码作为保留提供将来使用。

403 Forbidden

    该状态码表示服务器理解了请求但拒绝执行,一般在应用中用于鉴权失败。

  • 若客户端不带身份凭据发送请求,服务器在生成403响应时若存在内容,可以在内容中描述禁止资源公开的原因。

  • 若客户端带身份验证凭据发送请求,则服务器认为它们不足以授予访问权限。

  • 当客户端收到403响应时,不应该使用相同的凭据自动重复请求,但可以使用新的或不同的凭据发送重复请求。

  • 若客户端发送的请求和凭据无关,服务器会直接禁止请求。

除开凭据原因,若服务器希望隐藏当前存在的被禁止的目标资源,源服务器可使用状态代码404 Not Found代替。

404 Not Found

    该状态码表示源服务器没有找到目标资源、或没有找到目标资源对应的表征数据、或不愿意透露已存在的表征数据。

    404 Not Found状态码并没有在语义中表示当前资源缺乏是暂时性的还是永久性的,若源服务器本身已知该资源是永久性缺失(通过可配置的方式或其他服务端计算方式),则410 Gone状态代码优于404 Not Found。

405 Method Not Allowed

    该状态码表示请求行中接收到的HTTP方法是源服务器已知的方法,但请求的目标资源不支持该方法,而源服务器必须在生成的405 Method Not Allowed响应中添加一个Allow标头,它包含目标资源当前支持的方法列表。

406 Not Acceptable

    该状态码可作为内容协商(Content Negotiation)专用状态码:

  1. 客户端提供了Accept-*头发送了带内容协商的请求。

  2. 服务端根据请求中收到的主动协商标头分析,无法在目标资源中找到对应表征数据。

  3. 服务器又不愿意提供默认表征数据。

    服务器应该生成包含可用表征列表、相关资源标识符的内容,此时客户端可以根据服务端提供的需求选择最合适的一个表征。注意:HTTP规范中并没有为此种情况提供任何标准,一切都取决于您具体的业务场景来提供相关实现,有关此状态码的服务端实现需要您自定义。

407 Proxy Authentication Required

    该状态码和401 Unauthorized类似,但使用场景有所区别,该状态码表示客户端要对自己进行身份认证才可以使用代理执行请求。代理发送请求时必须带上一个Proxy-Authenticate标头,该标头中包含了适用于本次请求代理的征询,客户端收到响应后,可使用新的或替换的Proxy-Authenticate标头字段重复请求。

408 Request Timeout

    该状态码表示服务器在准备等待的时间之内并没有收到完整的请求信息:

  • 若客户端在传输中有一个未完成的请求,它可以重复该请求。

  • 若当前连接不可用,则使用新连接。

读者注意区分Request Timeout和Response Timeout的差异:

  • Request Timeout是服务器在等待时间之内没有收到完整的请求信息,这种异常通常出现在服务器接收请求过程中,它的出现意味着请求没有按时接收完

  • Response Timeout是指服务器已经在执行请求内容,但并没在规定的时间内给出响应,这种异常通常出现在服务器执行请求过程中,有可能是数据量过大、也有可能是计算出错、或者是服务端死循环,它的出现意味着服务端没有按时执行完成

409 Conflict

    该状态码表示由于和目标资源当前状态冲突引起的请求无法完成,此代码用于客户端可能能够解决冲突并重新提交请求的情况,服务端应生成包含足够信息让用户识别冲突来源的正文内容,通常服务端在执行PUT请求更新资源时最有可能发生冲突。如:请求中的变更和早期(第三方)请求的更改相冲突,则源服务器应使用409 Conflict响应来指示当前更新请求无法完成,这种情况,响应中可能包含了对基于修订历史合并差异等有用信息。

在Zero框架^7中另一种常见的用法是语言级的冲突异常定义和业务级的异常定义(此类异常以_409为前缀),语义上这种异常包含了部分期望情况和实际情况的冲突并影响了框架的底层运行,期望客户端提供另外的输入来完成后续请求。

410 Gone

    该状态码表示在源服务器上不再可以访问的目标资源,并且这种不可访问的情况是永久性的,若源服务器不知道或无法确定该资源是否永久性不可访问,则应该使用状态码404 Not Found代替;410响应另外一点和404不同的点就是410响应还蕴含语义:该资源过去存在过,只是现在不存在了。410响应设计的主要目的是告知客户端资源是服务器知情的主动不可用,并且服务器所有者希望删除和该资源相关的所有远程链接来协助Web维护任务,如:

  1. 限时促销类服务。

  2. 不再与源服务器相关联的个人信息。

    您可以选择在您的系统中将所有永久不可用的资源标记为“已消失”来指示资源不可用——仅做逻辑删除,但这种做法不推荐,更好的做法是直接将删除的数据做物理删除,并提供类似日志记录一样的历史库,历史库和运行库进行隔离管理。

411 Length Required

    该状态码表示服务器拒绝接受没有定义Content-Length的请求,如果客户端添加了包含请求长度的有效Content-Length标头,则客户端可执行重复请求。那么您是否真正理解了Content-Length标头[^8]?Content-Length表示HTTP消息长度,一般用十进制数字表示八位字节的数目,由于很多框架都包含了这个标头的解析,开发人员一般不会去关注请求的Content-Length是否匹配,可少数情况有可能会发生Content-Length和实际消息长度不一致的情况。

  1. 无响应直到超时。

  2. 请求被截断、下一个请求解析出现错乱。

    Content-Length是标头中常见的一个属性,它应该是精确的,若不匹配就可能导致异常,描述了消息主体的字节大小,该大小是包含了内容编码的长度。如:文本执行了gzip压缩,Content-Length的值是压缩之后的大小而不应该是原始大小。Content-Length比较的对象是实体消息的实际长度,分三种情况:

  1. Content-Length == 实际长度:正确响应。

  2. Content-Length < 实际长度:这种情况首次请求的消息会被截断,如参数:user=lang.yu,若Content-Length值为8,那么参数部分消息会被截断为user=lan

  3. Content-Length > 实际长度:这种情况服务端/客户端读取到消息结尾后,由于长度未达到Content-Length会继续等待处理下一字节,最终引起无响应直到超时。

    若您的请求中无法明确指定Content-Length,不确定消息主体的字节大小,或者在请求处理完成之前无法获取消息长度,此时应该使用Transfer-Encoding: chunked模式。

412 Precondition Failed

    该状态码表示请求标头中给出的一个或多个条件在服务器上测试评估为假,此响应状态代码允许客户端对当前资源状态(表征数据和元数据)设置先决条件,因此,若目标资源处于异常状态时,应阻止该请求。关于HTTP协议中条件请求的部分本章就不详解了,您可查阅协议本身或在线资料摄取更多信息。

413 Content Too Large

    该状态码比较暴力,如果请求内容大于服务器能够处理内容的极限,服务器拒绝处理请求。

  • 若使用的协议版本允许,服务器可直接终止请求。

  • 若协议版本不允许,服务器可直接关闭连接。

  • 若条件是临时的,服务器应生成一个Retry-After标头指明它是临时的,并在什么时间之后让客户端重试。

414 URI Too Long

    该状态码同样是一个暴力码,如果目标URI长度超过了服务器能够解释的极限,服务器拒绝处理请求。不过这种错误比较罕见,一般是客户端错误地将POST请求转换为具有长查询信息的GET请求时才可能发生,而此时客户端可能陷入重定向的死循环,或当服务器受到试图利用潜在安全漏洞的客户端攻击时会发生该错误。

415 Unsupported Media Type

    该状态码表示,由于目标资源上的内容格式不受该方法支持,源服务器拒绝提供服务,和406 Not Acceptable一样,该状态码也可作为内容协商(Content Negotiation)专用状态码。格式问题可能由于请求指定的Content-Type或Content-Encoding不匹配,或是服务端直接检查数据的结果。

  • 如果问题是由不受支持的内容编码引起的,则应该使用Accept-Encoding响应标头来指示请求中将接受哪些内容编码。

  • 另外,若类型是不支持的媒体类型,则应该使用Accept响应标头指示请求中将接受哪些媒体类型。

416 Range Not Satisfiable

    该状态码表示,由于没有一个请求的范围是可满足的,或因为客户端请求了过多的小范围重叠范围(拒绝服务攻击),请求的Range标头域中的范围集被拒绝。每个范围单元都定义了它子集的范围集需要满足的条件,包括使得字节范围集如何满足的原因,服务器对范围请求生成416 Range Not Satisfiable响应时应生成一个Content-Range响应标头以指定所选表征的当前长度。例如:

HTTP/1.1 416 Range Not Satisfiable
Date: Fri, 20 Jan 2012 15:41:54 GMT
Content-Range: bytes */47022

417 Expectation Failed

    该状态码表示最少有一个入站服务器无法满足请求中的Expect标头字段中给出的期望。Expect请求头表明,服务器需要正确处理请求并符合预期,而HTTP规范中唯一定义的一个期望是:Expect: 100-Continue,服务器针对这种期望有两种回应:

  • 100 Continue:若标题中包含的信息足以立即获得成功。

  • 417 Expectation Failed:若它不能达到预期,则返回该错误响应,或者用其他任意的4xx状态代替。

418 Unused

    该状态码是一个人文状态码,开发过程中一定不会使用到此状态码,它是在RFC2324[^9]中提出来的。它又称为一个愚人节的RFC(4月1日),讽刺了HTTP被滥用的各种方式,如同下边的对话:

  • 路人甲:您好,我想要一杯咖啡。 路人乙:您请等一等。 (过了好久……) 路人甲:您好,请问咖啡好了么? 路人乙:对不起,我是一个茶壶。 路人甲:……

    归根到底该状态码为应用特定的418状态码,经常被部署成一个笑话,以至于此代码都不作为保留到未来做任何用途。这个状态码可以用于:选择恐惧症的语义,它保留在了IANA HTTP的状态码注册表中,表示当前无法将该状态码分配给其他应用程序,若未来真有一天要使用它,它可以分配另外的一种用途,当然这种用途最好也是戏谑性的。

421 Misdirected Request

    该状态码(历史值:Too Many Connections)表示请求被定向到无法为目标URI生成权威响应的服务器。源服务器(或网关)发送421 Misdirected Request以拒绝与服务器已配置的源不匹配的目标URI、或不匹配连接上下文的目标URI。无论请求方法是否满足幂等性需求,接受421(错误定向请求)响应的客户端都可以通过不同连接或通过替代服务重试请求;该状态码也可以表示从当前客户端所在的IP地址到服务器的连接数超过了服务器许可的最大范围。

422 Unprocessable Content

    该状态码表示服务器理解了请求内容的媒体类型(格式合法,此时415 Unsupported Media Type状态码是不合适的),并且请求内容的基本语法是正确的,但是不能处理包含的指令信息。最常见的情况是类似XML请求格式正确但XML指令的语义错误,这种场景中可发送422 Unprocessable Content状态码。

    该状态码在动态设计过程中是十分有效的,前文提到了表征数据中包含了数据、元数据,其元数据中还可以包含动态内容,如一段配置好的脚本、一段带有语义定义的配置、一段可分析的申明式语法,当这类语法在请求执行过程中出现了解释错误,您就可以让应用返回422响应,简单说就是数据中的脚本执行有错,您就可以使用422了。

423 Locked「WebDAV扩展」

    该状态码由WebDAV(RFC 4918[^10])扩展提出,它表示资源被锁定,一般用于WebDAV的COPY方法中,该状态码在返回锁定响应时,应该包含lock-token-submitted的前置条件元素描述锁定的详细信息。如:

响应头

HTTP/1.1 423 Locked
Content-Type: application/xml; charset="utf-8"
Content-Length: xxxx

响应体

<?xml version="1.0" encoding="utf-8" ?>
<D:error xmlns:D="DAV:">
    <D:lock-token-submitted>
        <D:href>/locked/</D:href>
    </D:lock-token-submitted>
</D:error>

424 Failed Dependency「WebDAV扩展」

    该状态码表示因为之前的请求导致当前请求发生错误或失败,同样由WebDAV(RFC 4918)扩展提出,它可以简单描述成依赖服务异常,例如,若一个PROPPATCH方法中命令失败会导致其余的命令全部显示424错误响应,其格式如(内容放在407中):

响应头

HTTP/1.1 207 Multi-Status
Content-Type: application/xml; charset="utf-8"
Content-Length: xxxx

响应体

     <?xml version="1.0" encoding="utf-8" ?>
     <D:multistatus xmlns:D="DAV:"
             xmlns:Z="http://ns.example.com/standards/z39.50/">
       <D:response>
         <D:href>http://www.example.com/bar.html</D:href>
         <D:propstat>
           <D:prop><Z:Authors/></D:prop>
           <D:status>HTTP/1.1 424 Failed Dependency</D:status>
         </D:propstat>
         <D:propstat>
           <D:prop><Z:Copyright-Owner/></D:prop>
           <D:status>HTTP/1.1 409 Conflict</D:status>
         </D:propstat>
         <D:responsedescription> Copyright Owner cannot be deleted or
           altered.</D:responsedescription>
       </D:response>
     </D:multistatus>

    该状态码在微服务开发过程中可以赋予特殊的意义,这个是作者在曾经一个实战项目中使用过的场景,一般在微服务环境中,服务和服务之间会存在一定依赖关系,由于彼此分离和独立,它们之间或使用HTTP执行内部通信、或使用gRPC执行内部通信,通信过程中,若依赖服务出现了5xx的内部错误,会导致整个链式调用失效,这种场景下您可以使用424 Failed Dependency来描述依赖服务的错误信息,如此会创造一个语义:但凡内部通信错误状态代码都是424。这样的响应错误可以直接和5xx错误做一个鉴别,那么在调试内部通信时,更容易捕捉由于服务通信带来的异常,而且在服务通信中,服务调用时二者各自扮演了客户端和服务端进行双向通信,使用424作为客户端调用的服务依赖错误也符合4xx状态码的语义。

425 Unordered Collection「WebDAV扩展」

    该状态码在WebDAV Advanced Collections草案中定义,但未出现在《WebDAV顺序集协议》中。

426 Upgrade Required

    该状态码表示:在当前协议下服务器拒绝处理请求,但若客户端升级到响应中所需的协议也愿意这样做,那么服务器就可以处理该请求,而且您需要在服务端响应中添加Upgrade字段指名该资源要求的升级协议,最常用的场景就是WebSocket、HTTP/2.0、HTTP/3.0,响应格式如:

HTTP/1.1 426 Upgrade Required
Upgrade: HTTP/3.0
Connection: Upgrade
Content-Length: 53
Content-Type: text/plain

This service requires use of the HTTP/3.0 protocol.

428 Precondition Required「扩展」

    该状态码表示源服务器要求请求必须是带条件的,由RFC 6585[^11]扩展。它最典型的用途是避免“丢失更新”的问题,当客户端获取资源状态试图对该资源进行修改然后发送回服务器时,有另外一个第三方客户端更改了服务器上的资源导致状态冲突,此时服务端会要求当前请求必须是带条件的以保持客户端拿到的是最新资源副本。

响应头

HTTP/1.1 428 Precondition Required
Content-Type: text/html

响应体

<html>
    <head>
        <title>Precondition Required</title>
    </head>
    <body>
        <h1>Precondition Required</h1>
        <p>This request is required to be conditional;
         try using "If-Match".</p>
    </body>
</html>

429 Too Many Requests「扩展」

    该状态码由RFC 6585扩展,它表示用户在给定的时间内(“速率限制”)发送了过多的请求。

响应头

HTTP/1.1 429 Too Many Requests
Content-Type: text/html
Retry-After: 3600

响应体

<html>
    <head>
        <title>Too Many Requests</title>
    </head>
    <body>
        <h1>Too Many Requests</h1>
        <p>I only allow 50 requests per hour to this Web site per
            logged in user.  Try again soon.</p>
    </body>
</html>

431 Request Header Fields Too Large「扩展」

    该状态码由RFC 6585扩展,它同样表示:由于标头字段太大,服务器拒绝处理当前请求,请尽可能减少请求头字段的大小后重新提交。

响应头

HTTP/1.1 431 Request Header Fields Too Large
Content-Type: text/html

响应体

<html>
    <head>
        <title>Request Header Fields Too Large</title>
    </head>
    <body>
        <h1>Request Header Fields Too Large</h1>
        <p>The "Example" header was too large.</p>
    </body>
</html>

449 Retry With「扩展」

    该状态码由微软扩展,代表请求应该在执行完适当的操作后重试。

    该状态码由IETF在2015核准后增加,表示该请求因为法律限制而被拒绝。

499 Client Closed Request「扩展」

    该状态码表示Nginx使用非标准状态码,表明Nginx正在处理请求时客户端主动关闭了连接。

从客户端异常可知,不论是语义还是分类维度,HTTP协议中定义的状态码和扩展的状态码已经能够满足常用系统的RESTful需求了,您只需要在设计时选择合适的HTTP状态码,并根据状态码的语义带上对应的响应信息就可以实现相对比较规范并且贴近于HTTP协议的标准化响应表征了。

3.5. 5xx 状态码:服务端异常

    最后一类异常是服务器异常,或称为服务端异常。

500 Internal Server Error

    该状态码表示服务器遇到了组织它完成请求的异常情况,该情况多半是服务端代码错,如空指针、转型错误、解析错、状态异常、参数异常等。

501 Not Implemented

    该状态码表示服务器不支持完成请求所需的功能,当服务器无法识别请求方法并且无法支持请求资源时,使用该状态码代替500是合适的响应。该状态码在Zero框架中属于高频状态码,由于Java语言从8开始支持接口中提供默认实现,Zero定义了不同组件的501异常,并在默认实现中返回错误状态的Monad,如此所有实现类可分别实现接口中的方法以完成偏向性比较重的实现,而保留的501响应可预留作为实现类未来的一种选择,这种设计给整个框架带来了很强的扩展性。例如:

    // JsonObject -> JsonObject
    default Future<JsonObject> inJAsync(final JsonObject data, final IxMod in) {
        return Future.failedFuture(new _501NotSupportException(this.getClass()));
    }

    // JsonArray -> JsonArray
    default Future<JsonArray> inAAsync(final JsonArray data, final IxMod in) {
        return Future.failedFuture(new _501NotSupportException(this.getClass()));
    }

    // JsonArray -> JsonObject
    default Future<JsonObject> inAJAsync(final JsonArray data, final IxMod in) {
        return Future.failedFuture(new _501NotSupportException(this.getClass()));
    }

    // JsonObject -> JsonArray
    default Future<JsonArray> inJAAsync(final JsonObject data, final IxMod in) {
        return Future.failedFuture(new _501NotSupportException(this.getClass()));
    }

    其中某个子类只要满足参数条件,代码如下:

class RPkPre implements Pre {
    // ....
    @Override
    public Future<JsonObject> inJAsync(final JsonObject input, final IxMod in) {
        // .... 实现代码,仅实现 JsonObject -> JsonObject
    }
}

    这种设计并非最优,但可让您的子类根据不同的需求而扩展实现方法,比如上述接口根据输入输出在接口中排列组合了四种方法,并非所有的实现类都满足四个方法,于是便有了子类的可选择多态模式,而此时501响应就意味着该方法并未实现,服务端是不支持的,比起500异常更具有导向性,倘若您在系统中看到501异常,意味着您的代码之前并没有此类需求,那么接下来您可以选择是否实现——还有一个福利是这样的代码子类会很干净,按需编程。

502 Bad Gateway

    该状态码表示服务器在充当网关/代理时,尝试执行请求,但却从它访问的入站服务器收到了无效的响应。

503 Service Unavailable

    该状态码表示服务器当前由于临时过载、计划维护而无法处理请求,客户端等待一段时间过后可能会缓解,服务器可以在响应中发送一个Retry-After域来建议客户在重试请求之前等待多久。503状态码的存在并不完全意味着服务器在过载时必须使用它,也有一种可能是服务端本身拒绝连接。

504 Gateway Timeout

    该状态码表示服务器在充当网关/代理时,没有收到来自它需要执行请求的上游服务器的及时响应。

505 HTTP Version Not Supported

    该状态码表示由于使用的HTTP主版本不匹配,服务器不支持该请求或者直接拒绝,服务器生成505 HTTP Version Not Supported响应时需要一个标准化的表征,描述服务端为什么不支持当前版本以及该服务器支持哪些HTTP协议,目前可选择的HTTP协议主要包括:HTTP/1.1、HTTP/2.0、HTTP/3.0,由于1.0和0.9历史已经十分久远,现在已经很少使用了。

506 Variant Also Negotiates「扩展」

    该状态码由《透明内容协商协议》RFC 2295[^12]扩展,它代表服务器存在内部配置错误,被请求的协商元数据资源被配置为在透明内容协商中使用自己,即变体资源配置就是参与透明内容的协商本身,因此无法成为协商过程中的终点。

507 Insufficient Storage「WebDAV扩展」

    该状态码表示服务器无法存储完成请求所必须的内容,该状况被认为是临时的,一般情况是由于服务端无法存储请求表征数据导致。

508 Loop Detected「WebDAV扩展」

    该状态码表示服务器终止操作,它在处理带有Depth:infinity请求时遇到了死循环,也表示整个操作失败,该状态码由RFC 5842扩展。

请求头

PROPFIND /Coll/ HTTP/1.1
Host: www.example.com
Depth: infinity
Content-Type: application/xml; charset="utf-8"
Content-Length: 125

请求体

<?xml version="1.0" encoding="utf-8" ?>
<D:propfind xmlns:D="DAV:">
    <D:prop> 
        <D:displayname/> 
    </D:prop>
</D:propfind>

响应

HTTP/1.1 508 Loop Detected

509 Bandwidth Limit Exceeded「扩展」

    该状态码表示服务器达到了带宽的上限而无法响应请求。

510 Not Extended「扩展」

    该状态码表示获取资源所需要的策略没有被满足,由RFC 2774[^13]扩展。

  • 若访问资源策略未满足要求,服务器应该将所有必要信息响应给客户端而发出扩展引导。

  • 若510响应包含的有关扩展信息在初始请求中不存在,则客户端可根据服务器要求修改请求策略,相信它可以完成延期请求而重复请求。

511 Network Authentication Required「扩展」

    该状态码表示客户端需要认证才能获得网络访问权限,由RFC 6585扩展。响应表征应该包含指向资源的链接并且允许用户提交凭据(如使用HTML)。注意:为了避免和浏览器行为冲突,511响应不应该包含征询或登陆界面本身;而且511状态不应该由源服务器生成,这是插入代理通过拦截控制对网络的访问方法。

599 Network Connect Timeout Error

    该状态码表示它还没有被任何RFC定义,但已经被大部分HTTP代理软件用来表示客户端网络状态连接超时的状态。


4. 路径规范

  • 本章标题为URI设计之魂,而前边三个章节和URI几乎没有关系,但却是设计URI背后必须思考的问题,关于更多深入的话题,读者可参考相关RFC引用或《RESTful Web Services Cookbook》中的描述,有了之前的铺垫,您的钢笔就有了墨水,最后汇总,成品就逐渐浮现出来了。

  • 也许本书有些偏离了Vert.x的主线,不可否认这是一条强支线,作为Web服务开发工程师,如今这个时代,您所面对的就是设计和开发RESTful的Web服务,所谓知己知彼百战不殆,您了解了您面对的敌人后,再使用Vert.x作为武器开发RESTful就更加轻车熟路了。

4.1. 如何设计URI

    URI是跨越Web的资源描述符,通常一个URI由以下几部分内容组成:

<protocol>://<domain>:<port>/<context>
  • protocol:协议,如http, https。

  • domain:主机域名或服务器IP地址。

  • port:Web服务的主机端口号。

  • context:一段或多段路径信息。

    URI看似一个可标识资源的标识符,实际是模糊的资源标识符,客户端在访问Web服务时并不关心服务端是如何设计URI的,但在设计URI时遵循常用惯例却有不少优势:

  1. 遵循惯例的URI容易调试和管理。

  2. 服务器可集中编码,或者使用Metadata Programming,以便从请求URI中提取数据。

  3. 可避免花费宝贵的设计和实现时间来发明、处理URI的新惯例或新规则。

  4. 通过跨越、子域和路径对服务器的URI执行分区,使得负载分配(Distribution)、监控、路由和安全方面更具灵活性。

    设计URI一般有什么惯例?

4.1.1. 域/子域

    针对本地化、分布式、强化多种监控以及安全策略等方面的需求,可以使用域及子域对资源进行合理分组或划分。从逻辑上将URI分成域和子域可以为服务器管理提供很多操作层面的优势,划分URI时保证子域使用合理的名称。

    按本地化表征设计:

http://ox.engine.cn/user/1234       # 中文
http://ox.engine.en/user/1234       # 英文
http://ox.engine.jp/user/1234       # 日文

    按功能化设计:

http://ox.engine.cn/user/123
http://api.engine.cn/user/123       # 内置接口
http://is.engine.cn/user/123        # IS = Integration Service,集成接口

    按终端设计:

http://mobile.engine.cn/user/123    # 手机终端
http://web.engine.cn/user/123       # 网页端

    域和子域的不同,除了URI路径本身,在后端设计时还可以考虑应用不同的监控、安全策略、业务插件等,从语义上就已提供了顶层的分类维度。实现过程中,您可以将不同子域的服务独立部署在不同服务器上,并在域之间实现隔离,最终达到的效果就是互不影响,并不会因为日本区出现宕机导致中国区的服务无法访问,此过程主要的设计在部署结构上而不是用开发手段去实现,开发上您依旧可以独立开发服务。

4.1.2. 斜杠分隔符

    根据惯例,斜杠(/)用于表示层级关系,这不是硬性规定,但大多数用户阅读URI会遵循它。实际上,斜杠是RFC 3986[^14]中唯一提到的符号,语义就是用来表示层级管理。

http://ox.engine.cn/message/123
http://ox.engine.cn/user/order/orderId

    某些Web服务可能会在结尾使用/来表示资源集合,若使用这种方式需格外小心,因为开发框架可能会误删这些斜杠,又或者会在URI做标准化时追加这些斜杠。

4.1.3. 下划线/连字符

    若想让URI更易于人工阅读或解释,单词之间可使用下划线(_)或连字符(-),二者没有优劣之分,但在设计时最好保持一致性:整个系统对URI的分割选择其中一种而不要二者混用(二选一)。

4.1.4. 与符号

    URI的查询部分使用符号&对参数进行分割:

http://ox.engine.cn/execute?model=employee&id=xxx

    上述URI中可解析的参数对如:

model       = employee
id          = xxx

4.1.5. 逗号/分号

    若URI中存在非层次部分,可以考虑使用逗号(,)或分号(;),一般分号用来表示矩阵参数(Matrix Parameters),如:

http://ox.engine.cn/axis;x=0,y=9

    这些符号在URI路径和查询部分是合法的,但如何解析取决于服务端的实现,某些框架可能会不支持这样的参数格式。

4.1.6. 句号

    句号(.)除了可以用在域名里,还可以在URI中分割文档和扩展名。不过,除了历史遗留原因,不要在URI中使用句号,客户端应该使用表征的媒体类型来识别如何处理表征信息,而不是通过句号和扩展名来判断媒体类型,根据扩展名来“检测”媒体类型会造成一定的安全隐患。

    句号的一种特殊用法:您可以针对不同数据格式的表征提供扩展名,如:

http://ox.engine.cn/api/user/profile
http://ox.engine.cn/api/user/profile.yml        # YAML 格式
http://ox.engine.cn/api/user/profile.xml        # XML 格式

    不过这种特殊用法不如特殊参数,所以真正在生产环境依旧不推荐选择这种方式来设计URI。

4.1.7. 空格和大写

    空格和大写都是URI中合法的字符,其中空格会被编码成%20,而媒体类型application/xwww-form-urlencoded媒体类型会将空格编码为加号+,所以对于尚未准备接受这种媒体类型的URI的Web服务,这种不一致性会造成编码错误。

    大写问题则是开发人员需要规避的问题,在URI协议中,域名是大小写不敏感的,而路径部分是大小写敏感的,若资源对接到操作系统中的文件系统,这些URI的路径部分又取决于操作系统,总所周知Windows是大小写不敏感的,而Linux是大小写不敏感的,一旦您使用了大写,可能会造成URI的不一致性,不易于维护。

4.2. 参考范例

    有了设计惯例做基础,本章最后提供一套可用的参考范例。

4.2.1. 子域和路径博弈

    根据终端的不同,我们设计URI的过程中可采取不同的认证模式,系统设计中面临不同的终端访问,我们要么选择部署在不同的子域,要么直接从URI路径上规划不同的终端接口,二者没有优劣,主要思考点如:

  • 优先推荐使用子域的方式来规划不同终端的API,如:

    http://mobile.engine.cn/api/*       # 手机终端
    http://ox.engine.cn/api/*           # 系统终端

    使用子域对小规模和中规模团队存在一定的运维压力,需慎重选择,这和微服务的粒度问题异曲同工。在团队资源不足结构不完善的情况下,是一个需要深思的问题,分太散可能运维压力会指数级上涨、分太粗可能不如单机来得快,量体裁衣——本章的很多思路和设计方法论都是理论指导,若要务实需要在设计上做一定取舍。比如一个四五个人的开发团队,在做终端API的规划时直接使用子域就显得暴殄天物了。

  • 其次可以考虑根据不同的终端在路径上进行规划,该方案可以作为备选方案,例如:

    URI设计中最复杂莫过于如何分类,不仅仅是URI,任何系统设计最难直面的自由度最大的问题就是分类,如何分类才可以照顾得面面俱到是一个永恒话题。子域和路径的思考点如:

  1. 名词前缀法:虽然动词前缀更符合人类阅读,但名词前缀的模式更复合系统的模块化需求,如上述路径中-o表示Open的开放模式,-i表示Integration的集成模式,-m表示Mobile的手机模式,路径本身既干净又简短。

  2. 是否带版本:若您的系统存需要升级或开发过程有所变动,您可以在上述路径之前带上版本前缀,如:

    /api/*              ->      /v1/api/*
    /api-o/*            ->      /v1/api-o/*
  3. 简洁,合理缩写:系统接口取名的过程中,开发人员容易犯两个致命错误:

    1. 为了从简,一切从简,到处都使用缩写,到最后人工维护和阅读起来晦涩难懂。

    2. 索性全部使用全称,看起来一目了然,但运维起来极端冗余,甚至容易出错。 合理使用缩写是开发人员的必修课,单复数、前缀、后缀、简写,这些语法层面的思考都是开发人员需要思考的问题,核心法则就是:不要搞个人主义,尽可能机器易懂、开发易懂、运维易懂以及方便记忆

4.2.2. 实体语义

    URI设计中最大的陷阱就是URI路径引起的冲突问题,如:

GET /api/user/by/:name          # 按名称查询
GET /api/user/by/:type          # 按类型查询

    从人的视角上述路径是不同的,可对系统而言二者会被解析成相同的URI,造成URI重复的冲突,最终会引起解析问题,为了避免这类陷阱,您可以参考作者的设计心得:

  1. 只允许在特殊的规范接口(带文档描述)中使用POST查询,若GET查询可以满足系统需求,优先考虑GET查询。

  2. 若是更新、审批、变更等类型的API,使用PUT方法代替POST方法,尽可能不出现语义级别的冲突。

  3. 将参数的必须和可选,规划成路径参数和查询参数,从技术上路径参数不用检查必须性(一定会在请求中发送),而可选参数视具体的业务逻辑而定。

  4. 批量避免标准复数:这是个人开发过程中的心得,一般使用单词前缀替换掉复数的应用,英文复数应用有三个思考点:

    1. 团队内不同的人英文水平不同会导致复数的运用泛滥,在调试时究竟是user还是users无法鉴别,但您可以根据系统返回值来决定单复数并严格执行:返回单条记录用单数,返回多条记录用复数

    2. 对于标准英文语法的复数格式,变体极度影响人工阅读,如company使用companies够标准,但看起来不那么流畅,而直接使用companys看起来够暴力的复数规则,但总觉得别扭。

    3. 倘若真要使用复数,统一制定规则如直接加s,而不要卖弄英文,您可以很自信,但接手你系统的人可能没那么高的英文造诣,不卖弄的设计才是良好设计。

  5. 合理利用介词:英文介词具有导向性,您可以在路径中使用介词大大简化路径的呈现,如by, on, to, in等这些介词比起通常我们所用的output, input更简洁。

  6. 使用连字符-代替下划线_:这点心得不用多言,下划线在录入路径时会多按一个Shift,其实是没有必要的,既然RESTful允许连字符,直接使用便是。

    在规划实体的操作之前,我们直接拿CRUD开刀,先看下边表格:

    上述表格细化了CRUD需求,在多数系统里存在变数,从这个视角看,规划实体相关的URI就简单多了(参考表格中若使用了users则为开启复数用法的情况)。

    上述表格是一个参考范例,实际Zero本身设计时也没有完全按照上边的范例来整理:

  1. field、meta-field的占位符您可以选择两种方式处理(只能二选一,否则会路径冲突):

    1. 使用抽象方式,直接将其通过传参来完成,这种方式在后端权限控制时需要一定的设计手段,做的是Metadata Programming。

    2. 使用具象方式,如/api/user/{field}/:value,可直接使用/api/user/email/:value/api/user/mobile/:value替换,根据具体业务场景规划URI。

  2. 关于复数:带「复」标记的路径为启用了复数模式的URI路径,这点可以根据个人喜好和团队规范来处理,一旦使用复数那么您的URI路径规划需要对复数做出一致性调整,并且合理利用HTTP方法,单从表格上看复数模式更简洁,但也带来了阅读上的不足:直观语义性(第一眼就可以看到该接口在干什么)不够强。

  3. 预留自由度:整个表格中,POST /api/{entity}/{action}这一类的接口没有做任何抽象,它的自由度是最大的,如:

    POST /api/user/search            # 分页、排序、过滤
    POST /api/user/query             # 不带分页查询
    POST /api/user/exist             # 存在性检查
    POST /api/user/miss              # 丢失性检查
    POST /api/user/import            # 导入
    POST /api/user/export            # 导出

    这样设计的目的是{action}提供了复杂场景的自由度,这种路径不会和其他类型的路径产生冲突,如:

    POST /api/user/lock              # 用户锁定
    POST /api/user/unlock            # 用户解锁

    选择POST方法就是看上了它的既不安全也不幂等,那么安全性和幂等性就靠自己从后端代码来控制,这种语义和编程技术上的双重自由度给您会带来更大的设计思考空间。

  4. meta-field:元数据一般在系统中最高频的两个动作是读取和更新,所以meta-field在此处位于实体名词之前,它主控了分类的维度,而元数据本身拥有维度概念,您可以替换不同的词语来读取特定元数据,例如:

    GET /api/m-model/user/full      # 读取用户模型  区分 GET /api/model/xxx
    GET /api/m-view/user/full       # 读取用户视图  区分 GET /api/view/xxx
    GET /api/m-columns/user/full    # 读取用户列    区分 GET /api/columns/xxx

    Zero设计比较早,整个规范还没有经过深度思考,如今再来修正似乎不太合理,在元数据规划过程中,我还是推荐使用类似m-前缀以区分元数据管理,参考注释。

5. 小结

    本章和Vert.x不直接相关,讲解了URI设计过程中的种种思路以及在开发人员规划RESTful过程中需要考虑的点,所以称为设计之魂。Vert.x归根到底是工具,使用它必须要明白用它来干什么,本章节的核心就是告诉开发人员干什么、怎么干的核心问题,属于设计的内修课。最后强调一点:本章节的内容并非全部原创,更多是一份阅读笔记,主要参考《RESTful Web Services Cookbook》[^15]和RFC规范做的整理、提炼、转译和心得分享。

[^1]: RFC2616: Hypertext Transfer Protocol -- HTTP/1.1

[^2]: RFC9110: HTTP Semantics

[^3]: RFC2518: HTTP Extensions for Distributed Authoring -- WEBDAV

[^4]: RFC5842: Binding Extensions to Web Distributed Authoring and Versioning (WebDAV)

[^5]: RFC3229: Delta encoding in HTTP

[^6]: HTTP Authentication Schemes - IANA

[^8]: https://zhuanlan.zhihu.com/p/81955498 ,作者:Fundebug

[^9]: RFC2324: Hyper Text Coffee Pot Control Protocol (HTCPCP/1.0)

[^10]: RFC4918: HTTP Extensions for Web Distributed Authoring and Versioning (WebDAV)

[^11]: RFC6585: Additional HTTP Status Codes

[^12]: RFC2295: Transparent Content Negotiation in HTTP

[^13]: RFC2774: An HTTP Extension Framework

[^14]: RFC3986: Uniform Resource Identifier (URI): Generic Syntax

[^15]:《RESTful Web Services Cookbook》,作者:Subbu Allamaraju

最后更新于