RESTful接口的应用现在非常普遍,这是有原因的。RESTful接口已经成为服务于其他应用程序的Web服务的事实标准。
REST (REpresentational State Transfer,表现层状态转换)是Roy Fielding在2000年的一篇博士论文中定义的,它以HTTP标准为基础,创建了一种软件架构风格的定义。
一个系统要被认为是RESTful的,需要符合这些规范:
❍ 客户端-服务器架构 。它基于远程调用机制来运行。
❍ 无状态 。所有与特定请求相关的信息都应该包含在请求本身中,使其独立于服务该请求的特定服务器。
❍ 缓存能力 。响应的缓存能力必须明确,要么是可缓存的,要么是不可缓存的。
❍ 分层系统 。客户端无法知道他们是直接连到最终服务器,还是通过中间服务器进行的连接。
❍ 统一接口 。有四个先决条件:
● 请求资源识别 。意味着资源被明确地表示出来,而且其呈现是独立的。
● 基于表现层来操纵资源 。使得客户端在具备表现层权限时,即掌握了所有需要的信息来对资源进行修改。
● 自我描述的信息 。意味着信息本身是完整的。
● 用超媒体作为应用状态引擎 。意味着客户端可以使用引用的超链接来遍历系统。
❍ 按需代码 。这是一个可选的要求,通常不使用。意味着服务器可以提交响应的代码,以帮助执行操作或改进客户端。例如,提交JavaScript以在浏览器中执行。
这是非常正式的定义。正如你所看到的,它不一定基于HTTP请求。为了便于使用,我们需要在一定程度上对其进行约束,并建立一个通用的框架。
一般提到RESTful接口时,往往将其理解为基于HTTP资源、使用JSON格式请求的接口。这与我们之前看到的定义是一致的,但需要考虑到某些关键因素。
这些关键因素有时会被忽视,从而导致伪RESTful接口的出现,而这些接口并不具备相同的属性。
最主要的是, URI (Uniform Resource Identifiers,统一资源标识符)应当描述确定的资源,以及HTTP方法和基于 CRUD (Create Retrieve Update Delete,增删改查;亦称增删查改)方法来对其执行的操作。
CRUD接口有利于这些操作的执行:创建(Create;保存一个新条目)、检索(Retrieve;读取)、更新(Update;覆盖)和删除(Delete)条目。这些是任何持久性存储系统都具备的基本操作。
URI有两种,无论是描述单一资源还是描述资源的集合,从下表中可以看出:
这种设计的关键要点在于将一切都定义为资源,正如我们之前看到的。资源是由其URI定义的,URI包含了资源的分层视图,比如说:
/books/1/cover定义了ID为1的书籍的封面图像资源。
为简单起见,我们会在本章中使用整数ID来识别各项资源。在实际的系统中则不建议这么做,因为这样的整数完全不具备任何意义。更糟糕的是,它们有时会泄露系统中元素的数量或其内部顺序的信息。例如,竞争者可以估计出每周有多少个新条目被添加。为了撇开所有与内部相关联的信息,在可能的情况下,尽量都使用外部的自然密钥,例如书籍的ISBN号码,或者创建一个随机的 UUID Universally Unique Identifier,通用唯一标识符)。
用连续的整数标识资源的另一个问题是,很有可能系统无法正确地创建它们,因为不可能同时创建两个ID相同的对象。这种情况也许会给系统规模增长带来限制。
大多数资源的输入和输出都将以JSON格式表示。例如,下面分别为请求、响应的示例,用于检索某个用户的数据:
这里的响应数据是用JSON格式表示的,正如在示例中Content-Type属性所指明的那样。因此能很方便地对其进行自动解析和分析。请注意,在响应数据中,封面图像cover字段返回了一个指向另一资源的超链接。这使得API接口具有适应性,从而减少了客户端需要事先准备的信息量。
这是设计RESTful接口时最容易被遗忘的属性之一。最好是返回资源的完整URI,而不是间接引用诸如无上下文信息的ID等内容。
例如,当创建一个新资源时,在HTTP响应中Location头部信息内包含新的URI。
用HTTP PUT方法发送新的数据覆盖原值,也需使用同样的数据格式。注意,有些元素可能是只读的,比如封面图像cover,且非必填字段:
输入和输出数据应该使用相同的表示方法 ,这样客户端就能很容易地检索到资源,进行修改,然后重新提交。
这种方法确实很方便,且具备一致性,在实现客户端操作时很值得推荐。在测试时,应尽量确保数据检索、重新提交功能可用,且不会产生问题。
当资源直接以二进制内容表示时,也能以适当的格式返回数据,可在HTTP头部信息的Content-Type属性中指定。例如,检索图像资源cover将返回一个图像文件:
同样,当创建或更新一个新的图像时,它也应当以适当的格式发送给服务器。
虽然RESTful接口的初衷是能支持多种数据格式,例如,XML和JSON都能支持,但这在实践中并不常见。总的来说,JSON是现在最标准的格式。不过,有些系统可能会从多种格式支持中受益。
另一个重要的属性是,确保某些操作是 幂等的 (idempotent),而另一些则不具备幂等性。幂等操作可以重复多次,且都产生相同的结果,而重复非幂等操作则会产生不同的结果。显然,这里所说的前提是,操作本身是一样的。
一个显而易见的例子是创建一个新元素。如果我们提交两个相同的POST请求,用于创建新的资源列表元素,请求提交后将创建出两个新的元素。例如,提交两本具有相同名称和作者的书,则将创建两个完全一样的关于书的元素。
这里假设对资源的内容没有做限制。如果有的话,第二个请求会失败,而且在任何情况下都会产生与第一个请求不同的结果。
另一方面,两个GET请求会产生相同的结果。PUT或DELETE请求也是如此,因为它们会覆盖或“再次删除”相关资源。
唯一的非幂等请求是POST操作,这样就大大简化了针对问题处理的流程设计,当存在是否应该进行故障重试的问题时。任何时候幂等请求都可以安全地进行重试,因而简化了对网络问题等错误的处理。
HTTP协议有一个重要细节有时会被忽视,就是它的头部信息和状态码。
头部信息包含了关于请求或响应的元数据。其中有些信息是自动添加的,如请求或响应HTTP正文(HTTP body,亦称HTTP主体)的大小。以下是一些值得关注的有趣的HTTP头部字段:
一个良好设计的API会利用HTTP头部来传递相关的信息,例如,正确地设置Content-Type(正文内容类型)或尽可能地接受缓存参数。
完整的HTTP头部字段列表可参见:https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers。
另一个需关注的重要细节是,充分利用有效的状态码。状态码会提供有关出现什么情况的重要信息,对每种情况尽可能使用最详细的信息进行说明,有利于实现一个更好的API接口。
部分常见的状态码如下:
(续)
一般来说,非描述性的错误代码,如400 Bad Request和500 Server Error,应当用于处理常见的情况。然而,如果有一个更好的、更具描述性的状态码,则建议用新的代码来将其替换。
例如,一个用于改写参数的HTTP PATCH请求,如果受某些原因影响而使参数未正确设置,此时应返回400 Bad Request,但如果是没有找到资源URI,则应返回404 Not Found。
还有其他状态码。可以到这里查看完成的清单,包括每一个的细节:https://httpstatuses.com/。
在返回状态码时,注意尽可能将额外的信息反馈给用户,并说明其原因。对状态的一般性描述符有助于处理意外情况,并简化对问题的调试过程。
这种做法对状态码为4XX的错误特别有用,因为能帮助访问API的用户修复他们程序中的bug,并迭代改进他们软件整合的操作。
例如,前面提到的HTTP PATCH请求可能会返回如下HTTP正文:
返回的信息中给出了关于该问题的具体细节。还可在其他选项中包含错误代码,在有多种可能导致错误的情况下则返回多项提示信息,还可以将状态码复制到返回的HTTP正文信息中。
RESTful API中的可用操作仅限于CRUD操作。因此,资源是API的基本构造单元。
把一切都变成资源有助于创建用途非常明确的API,并有利于满足RESTful接口的无状态要求。
无状态服务意味着完成请求所需的所有信息要么由调用者提供,要么从外部获取,通常是从数据库中获取。这样就排除了其他保存信息的方式,比如在同一服务器的本地硬盘中存储信息。这使得任何服务器都有能力处理每一个请求,这种机制对于实现系统的可扩展性至关重要。
能够创建并执行各种不同操作的组件,可被分为不同的资源。例如,一个模拟pen(笔)的界面可能需要以下组件:
❍打开、关闭pen。
❍写点什么。只有处于打开状态的pen才能进行写操作。
在某些API实现(比如面向对象的API)中,通常需要创建一个pen对象并改变其状态:
而在一个RESTful API中,我们需要为pen和它的状态创建不同的资源:
这看起来好像有点麻烦,但RESTful API通常定位于比典型OOP(面向对象编程)API更高的层次。要么直接创建文字,要么创建一个pen,然后再创建其文字,而不需要执行打开/关闭操作。
请记住,RESTful API用于远程调用的场景。这意味着它不会位于低层,因为与本地API相比,每次调用都意味着较大的资源开销,所以操作过程中其时间消耗是可以接受的。
还要注意的是,各组件及其步骤都会在系统中注册,并且拥有各自的一套标识符,所以可以实现对其寻址。这比OOP中的内部状态更加明确。正如前文所述,我们希望它是无状态的,而OOP中的对象是需要状态的。
请记住,资源无须直接转换为数据库对象。需要的是逆向思维,即从存储到API。而且,你可以做的不止于此,还可以将从多个来源获取到的信息,或者不适合直接转换的资源,组合成新的资源。我们将在下一章中看到示例。
如果之前熟悉比较传统的OOP环境,那么掌握资源的使用可能需要一段时间来适应,但资源是一种相当灵活的工具,可以为其分配多种方式来执行操作。
虽然一切都是资源,但有些组件作为与资源进行交互的参数会更有意义。在修改资源的时候,这是很自然的。修改后所有发生变化的内容都需要提交,以更新资源。但是,在特定情况下,某些资源可能受其他原因影响而被修改,最常见的情况是在执行搜索操作时。
典型的搜索端点会定义一个搜索资源并检索其结果。然而,没有参数过滤功能的搜索其实是没法用的,所以需要用额外的参数来设定搜索,例如:
这些参数被存储在查询参数中,是检索功能的自然延伸。
一般来说,应当只针对GET请求使用查询参数。针对其他类型的请求方法,则应将参数放在HTTP请求正文中。
当包含查询参数时,GET请求也可很容易地实现缓存。如果是幂等请求,则搜索操作对每个请求都将返回相同的值,完整的URI及所含的查询参数都可以实现缓存,甚至从第三方进行查询时也可以。
按照惯例,所有存储GET请求的日志都将存储查询参数,而位于HTTP请求头部或在请求正文中发送的那些参数则不会被记录。这种机制会影响到系统的安全性,例如,密码等所有敏感信息都不应该作为查询参数发送出去。
有时候,这就是创建POST操作的原因,这些操作通常是由GET请求来完成的,但出于安全考虑,将其改为在HTTP请求正文中设置参数,而不是使用查询参数的方式。虽然HTTP协议允许在GET请求正文中设置参数,但这种情况无疑是罕见的。
这类安全风险的案例之一是,通过电话号码、电子邮件或其他个人信息执行搜索操作时,中间人可拦截并了解这些信息。
使用POST请求的另一个原因是为了给参数留出更大的空间,因为包括查询参数在内的完整的URL,其尺寸通常被限制在2K以内,而HTTP请求正文的尺寸限制则要宽松得多。
在RESTful接口中,所有返回的达到一定元素数量的LIST(列表)请求都应当被分页。
这意味着可以在请求中调整元素和页面的数量,返回只包含特定数量元素的页面。这样就限定了请求数据的范围,避免出现响应缓慢、浪费传输带宽的情况。
例如,可以使用参数page(页号)和size(每页元素数量)来发送请求:
构建良好的响应通常采用类似这样的格式:
该响应返回的内容包含了result(请求结果)列表字段,以及next(下一页)字段、previous(上一页)字段,这些字段是指向下一页和上一页内容的超链接,如果没有的话,则其值为null(空)。这样就可以很方便地浏览所有的查询结果。
设定一个sort(排序参数)对于确保页面的连贯性也很有用。
这种技术还允许并行地检索多个页面,从而加快信息的下载速度,可以通过几个小的请求而不是一个大的来实现。不过,分页的主要目的是通过提供足够的过滤参数,使得一般情况下请求不会返回太多的信息,从而实现只检索所需的相关信息。
分页机制存在一个问题,就是收集的数据在多次检索请求之间可能会发生变化,特别是在检索许多页面时。问题的过程是这样的:
第二页现在有一个重复的元素,该元素之前是在第一页,但现在移到了第二页,这样就导致有一个元素没有被返回。通常情况下,新资源未被返回并不是什么大事情,因为毕竟信息的检索操作是在该资源创建之前进行的。然而,同一资源被返回两次则有问题。
为了避免这种情况的发生,返回数据时可以默认按照创建日期或类似的条件来对数据进行排序。这样一来,任何新的资源都会被添加到分页的末尾,并且能保持数据连贯性。
对于始终返回“新”元素的资源,比如通知信息或类似的内容,可以在发送检索请求时添加updated_since参数,从而只检索最近一次访问之后的新资源。这种检索实现方式加快了访问速度,并且只检索需关注的信息。
创建一个灵活的分页系统可以增加API的实用性,在进行系统设计时要注意确保对分页的定义在所有不同的资源中是一致的。
设计RESTful API的最好方法是明确地规定资源,然后对它们进行描述,包括以下内容:
❍ 描述 :描述所要执行的操作。
❍ 资源 URI:注意,这可能会被几个操作共享,并通过方法来区分(例如,用GET请求实现检索,用DELETE请求实现删除)。
❍ 适用的方法 :在此定义操作中所要用到的HTTP方法。
❍ (仅用于相关场合)输入的正文 :请求时输入的正文。
❍ 正文中的预期结果 :返回的结果。
❍ 可能出现的错误 :根据具体错误返回状态码。
❍ 描述 :描述所要执行的操作。
❍ (仅用于相关场合)输入查询参数 :针对附加功能添加到URI中的查询参数。
❍ (仅用于相关场合)相关的头部信息 :支持的HTTP头部信息。
❍ (仅用于相关场合)返回非正常的状态码(200和201) :与错误状态的场景不同,其用在状态码被当作操作成功的不常见的状况时。例如,重定向操作成功时返回的状态码。
这些就足以创建一个可以被其他工程师理解的设计文档,并使得他们可以基于此接口开展工作。
尽管如此,好的做法应该是从那些URI和方法的初稿开始,在不涉及太多细节的情况下,快速浏览一遍系统中的所有资源,例如正文描述或错误。这样有助于发现遗漏的资源缺陷或API中其他类型的矛盾问题。
例如,本章中描述的API包含以下操作:
这里可以对其中几个细节进行调整和改进:
❍看起来我们忘了在创建pen之后添加删除pen的操作。
❍应该添加几个GET操作,用于检索已创建资源的信息。
❍在PUT操作中,添加/text似乎有点多余。
有了这些反馈,我们可以再次将API描述如下(修改处有一个箭头指示):
请注意资源层次结构的内容组成,它有助于我们看清所有的元素,并找到第一眼可能看不出来的缺陷或元素间的关联。
接下来就可以进行细节设计了。可以使用本节开头提及的模板,或者任意其他适当的模板。例如,我们可以定义端点,用于创建新pen并读取系统中的pen:
创建一个新pen:
❍ 描述 :创建一个新pen,并指定其颜色。
❍ 资源URI :/pens
❍ 方法 :POST
❍ 输入正文 :
❍ 错误 :
正文中的错误,如无法识别的颜色,重复的名称,或错误的格式。
检索现有的pen:
❍ 描述 :检索一个现有的pen。
❍ 资源 URI:/pens/<pen id>
❍ 方法 :GET
❍ 返回正文 :
❍ 错误 :
这些简单的模板对当前场景很有用。你可以随意调整,在错误或细节问题上不用过于追求完美,关键是能用它们解决问题。例如,此时添加一个405 Method Not Allowed(该方法不被允许)的消息可能是多余的。
也可以使用Postman(www.postman.com)等工具来设计API,这是一个API平台,可以用来设计或测试/调试现有的API。虽然工具很管用,但能够在没有外部工具的情况下设计一个API是很有必要的,以防不时之需,而且这样可以迫使你专注于设计而不是一定要依靠工具。后面我们还将看到如何使用Open API,它更多地关注API设计,而不是提供一个测试环境。
设计和定义API时也可以基于标准的方式组织其结构,以便后续工作中可以享受工具带来的便利。
可以使用工具来实现更加结构化的API设计,如Open API(https://www.openapis.org/)。Open API是一个通过YAML或JSON文档来定义RESTful API的规范。通过它能实现在API定义过程中与其他工具的互动,并自动为API生成文档。
Open API让各组件的定义过程可以重复进行,包括输入组件和输出组件,以便建立一致且可重复使用的对象。它还可通过一些方法实现组件之间的相互继承或组合,以创造出丰富的接口。
详细描述整个Open API规范超出了本书的讨论范围。大多数常见的Web框架都支持与之集成,可自动生成YAML文件或我们以后会看到的Web文档。Open API以前被称为Swagger,它的网站(https://swagger.io/)上有一个非常有用的编辑器和其他资源。
例如,这里有一个YAML文件,描述了上述的两个端点。该文件可在GitHub上找到:https://github.com/PacktPublishing/Python-Architecture-Patterns/blob/main/pen_example.yaml:
上述YAML文件中,在components(组件)部分,定义了Pen对象并用于两个端点。这里可以看到POST/pens和GET/pens/{pen_id}这两个端点是如何定义的,并描述了预期的输入和输出,同时考虑了可能会出现的各种错误。
Open API最令人感兴趣的特性之一,是能够自动生成一个包含所有信息的文档页,以便于后续的API实现。生成的文档如图2-1所示。
图2-1 Swagger Pens对象的文档
如果YAML文件恰当而准确地描述了所设计的接口,就会很管用。在某些情况下,这些文档有助于从YAML到API的实现过程。先生成YAML文件,再基于此,分步骤在前端和后端两个方向开展工作。对于API优先的设计思路来说,这是很有意义的。甚至还可以用多种语言自动创建客户端和服务端的框架,例如,用Python Flask或Spring创建服务端,用Java或Angular创建客户端。
请记住,能否做到准确匹配API的实现与定义取决于你自己。现有的这些框架还需要相当的工作量才能使它们正常运转起来。Open API能简化这一过程,但也不至于神奇到能解决所有的问题。
每一个端点都包含了更深层次的信息,甚至还可以在同一文档中进行测试,因而能给要使用该API的第三方开发人员提供有效帮助,如图2-2所示。
图2-2 Swagger Pens对象的扩充文档
服务器生成的这种自动化文档,对其进行确认是非常简便的,鉴于此,即使API的设计不是基于Open API YAML文件开始的,采用这种方式也非常不错,因为它可以创建自生成的文档。