201706 Java RESTful Web Service实战 (第2版) 读书笔记 - xiaoxianfaye/Learning GitHub Wiki

Cover

本书系统、深度地讲解了如何基于Java标准规范实现REST风格的Web服务。不仅深刻解读了最新的JAX-RS标准和其API设计,以及Jersey的使用要点和实现原理,还系统讲解了REST的基本理论,更重要的是从实践角度深度讲解了如何基于Jersey实现完整的、安全的、高性能的REST式的Web服务。

全书共10章,包括JAX-RS2入门、REST API设计、REST请求处理、REST服务与异步、REST客户端、REST测试、微服务、容器化、JAX-RS调优、REST安全等内容。

第1版序——REST开发的理想与现实(李琨)

REST是一种分布式应用的架构风格,也是一种大流量分布式应用的设计方法论。REST是由(构成了Web基础架构的)HTTP、URI等规范的主要设计者Roy Fielding博士在其2000年博士论文(中文版名为《架构风格与基于网络应用软件的架构设计》)中提出的。到目前为止,关于REST最系统、最全面的论述,仍然是Fielding的博士论文。

REST就是Web(World Wide Web,简称Web或WWW)本身的架构风格,是设计、开发Web相关规范、Web应用、Web服务的指导原则。不符合REST风格要求的架构和技术,很难在Web这个生态系统中得到繁荣发展。在序作者看来,Roy Fielding博士就是15年以来对于分布式应用架构设计理论贡献最大的人。Fielding在HTTP规范的设计过程中,并没有采用当时大行其道的DO(Distributed Object,分布式对象)风格,而是另辟蹊径,提出了一整套新的设计方法论。Fielding的开创性工作,极大地推动了分布式应用设计理论的发展。

有趣的是,其实基于SOAP/WSDL的“大Web Service”(以下简称Web Service),几乎是与REST同时发展起来的。虽然在Web Service中也使用了对象,但是Web Service其实是RPC风格的,而不是DO风格的。Web Service在最初几年发展很快,很大原因是它解决了DO风格难以解决的异构系统(不同的硬件系统、不同的操作系统、不同的编程语言,等等)之间的互操作性的问题。

然而遗憾的是,设计Web Service协议栈的核心人员,几乎都是来自于企业应用阵营的,尤其是来自于IBM和微软两家公司的人。这些企业应用的专家们没有充分认识到Web基础架构的巨大优点,甚至可以说没有理解HTTP协议就究竟是用来做什么的、为何要如此设计。在Web Service协议栈的设计之中,仍然有着深深的企业应用痕迹。Web Service虽然宣称能够很好地支持互操作,然而因为协议栈的复杂性很高,在实战中互操作性并不好(例如升级过程困难而且复杂)。此外,Web Service仅仅将HTTP协议当做一种传输协议来使用,还依赖XML这种冗余度很高的文本格式,导致Web Service应用性能低下。在面向互联网的大流量Web应用(包括Web服务在内)这种运行环境中,Web Service在复杂性、互操作性、性能、可伸缩性等短板更加突出。因此,设计今日面向互联网的API,已经很少有人会考虑Web Service。这使得Web Service的使用被局限在企业应用运行环境之中。

在Java世界中,与大Web Service相对应的规范就是JAX-WS。在大Web Service已经成为明日黄花之后,Java世界急需一套新的规范来取代JAX-WS。这套新的规范就是JAX-RS:Java世界开发RESTful Web Service(与RESTful API含义相同,可混用)的规范。

从Java EE 6开始,JAX-RS在Java EE版图中,作为最重要的组成部分之一,逐步取代了JAX-WS的地位。在所有Java EE相关规范中,JAX-RS是有点很突出的一个。例如,完全基于POJO、很容易做单元测试、将HTTP作为一种应用协议而不是可替代的传输协议(因此提高了性能)、优秀的IDE集成,等等。可以说,在大多数场合,JAX-RS完全可以取代JAX-WS,作为Java Web Service开发的主要技术。JAX-RS同样也可以完全取代Hessian等基于HTTP协议的RPC风格远程调用协议。毕竟HTTP本身就是一种REST风格的应用协议,以REST风格来使用HTTP,才是最高效的使用方式。

Jersey、CXF等支持JAX-RS规范的REST开发框架还支持输出WADL(Web Application Description Language,Web应用描述语言)。WADL支持客户端代码自动生成,还可以将WADL导入到SoapUI等测试工具中,然后做自动化集成测试。从开发Java企业应用、取代JAX-WS的角度来看,JAX-RS已经做得很棒了。

尽管如此,不可不提的是,JAX-RS这套规范,仍然存在着很多遗憾。需要特别指出的是,JAX-RS规范并不等于REST架构风格本身,REST的内涵要比JAX-RS广泛得多。学会了使用JAX-RS了,并不等于就完全理解了REST,开发者仍然需要下功夫认真学习一下本源的REST究竟是什么。

例如,JAX-RS规范对于应该如何定义一个资源,以及应该如何使用HTTP作为一个统一接口来操作资源,显然缺乏必要的指导。虽然开发者使用了JAX-RS规范,但开发方式完全是RPC风格的,可以说于REST风格没有任何关系。

此外,JAX-RS规范目前尚不支持HATEOAS(将超文本作为应用状态的引擎,REST风格的核心特征之一),从著名的Richardson成熟度模型(由《RESTful Web APIs》的作者Richardson提出)来衡量,基于JAX-RS规范实现的RESTful API仅仅能够达到成熟度模型的第二级,即支持资源抽象、统一接口的“CRUD式Web服务”。

可以这样说,JAX-RS规范与真正的REST风格,覆盖的范围其实是不同的。JAX-RS覆盖的是简单基于HTTP协议(没有使用SOAP/WSDL)的各种远程调用需求,很多需求对于可伸缩性、松耦合的要求并不高,仅仅是希望使用HTTP本身来取代大Web Service作为一种轻量级、容易测试的远程调用协议。REST架构风格的严格要求,在这些场合并不是非常重要。

如果按照Roy Fielding博士的严格要求(REST APIs must be hyper-text driven),那么包括JAX-RS规范在内都不能算是真正的RESTful。然而,从实战角度,序作者认为革命部分先后,只要能够达到Richardson成熟度模型第一级,即有清晰的资源抽象,就可以认为是RESTful了。如果连第一级都达不到,所涉及的架构根本就不是面向资源的,那八成还是RPC风格的,就没有必要非要往RESTful API阵营里面挤了。RPC在企业应用的大多数场合其实都非常有效,只是不适合面向互联网的大流量Web应用而已。

因此,能够完美支持HATEOAS,攀登到成熟度模型第三级,是一种理想情况(当然也是值得追求的)。而通过部分拥抱REST风格的要求,来更好地解决手头的问题,是更多开发者所面对的现实情况。JAX-RS反映的正是这种现实情况,从实战的角度,它是一套非常有用也很好用的规范。

第1版序二(RedHat 姜宁)

Apache CXF作为JAX-WS以及JAX-RS规范的实现框架,已经成为很多Web服务开发者必选的开发框架。序作者作为这一框架的开发维护者之一,他的日常工作经常需要熟悉这些JSR规范,并实现JSR所定义的API,解决最终用户的使用问题。

熟悉Java的人大多都听说过JSR(Java Specification Requests)、JCP(Java Community Process),通过JSR可以就Java某一方面的应用定义一组标准的API或者服务。对于最终用户来说,他们的代码只需要调用JSR定义的标准API,不做任何修改就可以调用不同的JSR实现。之类常见的例子就是Java Servlet应用,用户开发的Web应用可以不做任何修改就部署到Tomcat、JBoss等不同的容器中。

JAX-RS是JCP为Java RESTful Web Service定义的一套API。由于Web服务的描述模型与Java类和接口有一定的差距,JAX-RS定义了很多annotation,通过这些annotation我们可以很方便地将Java类描述成为相关的REST服务。由于RESTful Web Service通常需要部署到Web容器中,JAX-RS也定义了相关服务来发现部署到容器中的JAX-RS应用。

如果我们想要对JAX-RS规范有一个比较快速并且全面的了解应该怎么办呢?一般来说我们可以通过JSR的相关的参考实现入手,我们不但可以通过运行相关的参考实现的例子快速入门,还可以通过跟踪相关的代码对实现细节有一个全面的了解。韩陆的这本新作以JAX-RS的参考实现Jersey为蓝本,由浅入深地向大家介绍了JAX-RS的由来,以及与RESTful Web服务开发的相关API,并结合实例分享了作者的实战经验。

第1章 JAX-RS2入门

REST概念、REST服务、JAX-RS标准和Jersey项目这四者之间的联系是:REST是一种跨平台、跨语言的架构风格;REST式的Web服务是对REST在Web领域的实现;JAX-RS标准是Java领域对REST式的Web服务制定的实现标准,Jersey是JAX-RS标准的参考实现,是Java EE参考实现项目GlassFish的成员项目。

1.1 解读REST

REST(Representational State Transfer)翻译为表述性状态转移,源自Roy Thomas Fielding博士在2000年就读加州大学欧文分校期间发表的一片学术论文《Architectural Styles and the Design of Network-based Software Architectures》。REST之父在该论文中提出了REST的6个特点,分别是:客户端-服务器的、无状态的、可缓存的、统一接口、分层系统和按需编码。

REST具有跨平台、跨语言的优势。从其诞生开始,就得到了诸多语言的快速支持,最著名的是ROR(Ruby on Rails)框架。新兴的语言(比如NodeJs、Golang)、工具平台(Docker、Spark)和公有云,更是将REST默认为服务的开放形式。

1.1.1 一种架构风格

REST是一种架构风格。在这种架构风格中,对象被视为一种资源(resource),通常使用概念清晰地名词命名。

表述性状态是指资源数据在某个瞬时的状态快照。资源可以有多种表述(representation),表述状态具有描述性,包括资源数据的内容、表述格式(比如XML、JSON、Atom)等信息。

REST的资源是可寻址的,通过HTTP1.1协议(RFC 2616)定义的通用动词方法(比如GET、PUT、DELETE、POST),使用URI协议(RFC 3305)来唯一标识某个资源公布出来的接口。

请求一个资源的过程可以理解为访问一个具有指定性和描述性的URI,通过HTTP协议,将资源的表述从服务器“转移”到客户端或者相反方向。

【REST不是一种技术(technology),也不是一个标准(standard)/协议(protocol),而是一种使用既有标准:HTTP+URI+XML (XML似乎成为了数据格式的借指,不仅代表XML本身)来实现其要求的架构风格。因此,与之对应的不是SOAP协议,而是像RPC这样的架构风格。】

1.1.2 基本实现形式

HTTP+URI+XML是REST的基本实现形式,但不是唯一的实现形式。REST一开始便使用已有的HTTP协议(RFC 2616)、URI协议(RFC 3305)来描述其特征,而对如何使用一种编程语言来实现,并没有进行任何描述和规定,甚至应该包含哪些传输类型或者数据格式也没有描述,但通常的实现至少包含XML格式。

具体而言,HTTP协议和URI用于统一接口和定位资源,文本、二进制流、XML和JSON等格式用来作为资源的表述。正如采用已有技术XMLHttpRequest+JavaScript+XML(XML后来几乎被JSON替代)实现Ajax一样,使用HTTP+URI+XML实现REST的好处是让开发者持有这些已知的技术来开发REST的入门门槛较低,关注点更容易放到REST的核心概念和业务逻辑上。

【以HTTP+URI+XML实现的应用并不一定是REST服务,但对于Ajax,这个逆命题是城里的。因为Ajax是一种技术,而REST是一种架构风格。学习和使用REST的关键是掌握这种思想,而不是具体的实现形式。】

1.2 解读REST服务

RESTful对应的中文是REST式的,RESTful Web Service的准确翻译应该是REST式的Web服务,我们通常简称为REST服务。RESTful的应用或者Web服务是最常见的两种REST式的项目部署、存在的方式。

1.2.1 REST式的Web服务

RESTful Web Service是一种遵守REST风格的Web服务。REST服务是一种ROA(Resource-Oriented Architecture,面向资源的架构)应用。其主要特点是方法信息存在于HTTP协议的方法中(比如GET、PUT),作用域存在于URI中。例如,在一个获取设备资源列表的GET请求中,方法信息是GET,作用域信息是URI中包含的对设备资源的过滤分页和排序等条件。

1.2.2 对比RPC风格

相比Web服务领域广为流行的RPC(Remote Procedure Call Protocol,远程过程调用协议)风格,REST风格更轻量和快速。从方法信息角度看,REST采用标准的HTTP方法,而RPC请求都是HTTP协议的POST方法,其方法信息包含于SOAP协议包或HTTP协议包中,方法名称不具有通用性。从作用域角度看,REST采用URI显式定义作用域,而RPC的这一信息同样包含于协议包中,不能直观呈现。

RPC风格的开发关注于服务器-客户端之间的方法调用,而不关注基于哪个网络层的哪种协议。也就是说,RPC是面向方法调用过程的,相比而言,REST是面向资源状态的。RPC风格的两个代表是XML-RPC和大Web服务。

  1. XML-RPC

XML-RPC是一种使用XML格式封装方法调用,并使用HTTP协议作为传送机制的RPC风格的实现。XML-RPC请求方法都是HTTP协议的POST方法,请求和响应的数据格式均为XML。

XML-RPC的数据格式和使用XML作为资源的表述的REST的外观上很相似,但数据的内容则大相径庭。REST格式的XML信息的主体是对一个资源状态的表达,无须包含方法信息,因为其请求的HTTP方法就已经决定了这一点。XML-RPC的请求数据结构额外包含方法调用信息和参数信息。

对于响应信息的内容两者也截然不同。REST式通常会包含响应实体信息,以及HTTP状态码和可选的异常信息,而XML-RPC的返回信息仅仅是对方法调用的响应信息。

XML-RPC作为一种遗留技术,已经被SOAP取代。在Java领域,JAX-RPC标准已经并入JAX-WS2标准。XML-RPC的应用依然存在。

  1. 大Web服务

大Web服务(Big Web Service)是Leonard Richardson和Sam Ruby在其所著的《RESTful Web Services》一书中,对基于SOAP+WSDL+UDDI+WS-标准栈等技术实现RPC风格的大型Web服务的统称。事实上,“大Web服务”这一说法也被Java EE 7的布道者在多次演讲中使用。在Java领域,对应的标准主要是JAX-WS 2.0/2.1/2.2 (JSR 224)。相较REST式的Web服务,大Web服务功能更强大,设计更复杂。大Web服务同样是跨平台、跨语言的,对复杂的数据类型的支持也非常好。大Web服务是基于RPC风格的重量设计,因此方法和作用域无法通过直观断定,需要在定义的消息中,而且方法名不是同一和通用的。同时,大Web服务走HTTP协议时,请求都是基于POST方法的。

对比RPC风格的Web服务,REST式的Web服务形式更简单、设计更轻量、实现更快捷。REST无须引入SOAP消息传输层,无须注册服务,也没有客户端stub的概念等。但是,REST式的Web服务并没有像大Web服务那样提供诸如安全策略等全面的标准规范。

大Web服务和REST式的Web服务各有优势,并不是一种替换关系。在实际开发中,两者共存于一个项目中也是一种解决方案。

1.2.3 对比MVC风格

MVC风格的出现将模型、视图、控制解耦,其亮点是从前到后的一致性,其结构整洁、逻辑清晰,易于扩展和增强。MVC在Java领域的普遍实现方式是在Web前端使用标签库来对应服务端的模型类实例和控制类实例,标签库和服务端依赖库可以是松散的耦合,比如Spring生态系统,也可以是全栈式的统一体系,比如JSF体系。但无论如何实现,在Web前端的开发过程中,必须时刻考虑页面标签和服务端的映射关系,包括模型类的匹配和转换、数据结构、控制类的输入和输出的参数类型和数量等。

因此,MVC风格偏重于解决服务器端的逻辑分层问题,以及客户端是逻辑分层的延伸问题。MVC的标签库虽然其形态已经和HTML页面融合,但本质上还是Java编写的装饰模式的类实例,对应的是服务器端使用Java编写的模型类或者控制器类,因此MVC很难实现跨语言解耦。而REST风格偏重于统一接口,因此具体实现就可以跨平台和跨语言。REST推动了Web开发的新时代,使用平庸的纯HTML作为客户端,没有服务器端和客户端的耦合。显而易见,使用纯HTML开发的REST客户端和使用Java开发的REST服务器端不存在语言上的耦合。

MVC和REST式并不是互斥的,如Spring的MVC模块已经开始支持REST式的开发。Jersey作为JAX-RS标准的实现,也实现了MVC的功能,请参考相关模块:jersey-mvc、jersey-mvc-freemarker和jersey-mvc-jsp。

1.3 解读JAX-RS标准

JAX-RS是Java领域的REST式的Web服务的标准规范,是使用Java完成REST服务的基本约定。

1.3.1 JAX-RS2标准

Java领域中的Web Service是指实现SOAP协议的JAX-WS。直到Java EE 6(发布于2008年9月)通过JCP(Java Community Process)组织定义的JSR311,才将REST在Java领域标准化。

JSR311名为The Java API for RESTful Web Service,即JAX-RS,其参考实现是GlassFish项目中的Jersey1.0。伺候,JSR311进行了一次升级(2009年9月),即JAX-RS1.1。JAX-RS诞生后,时隔5年(2013年5月)发布的Java EE 7包含了JSR339,将JAX-RS升级到JAX-RS2。JAX-RS 2.0在前面版本的基础上增加了很多实用性的功能,比如对REST客户端API的定义,异步REST等,对REST的支持更加完善和强大。

表1-1 JAX-RS标准和Jersey版本信息

JSR标准 JSR名称 标准发布时间 JSR实现
JSR311 JAX-RS 1.0 2008年9月8日 Jersey1.x
JSR311 JAX-RS 1.1 2009年9月17日 Jersey1.x
JSR339 JAX-RS 2.0 2013年5月22日 Jersey2.x

1.3.2 JAX-RS2目标

要掌握一项技术,先要掌握它背后标准的定义。

JAX-RS2标准(即JSR339)中定义了目标、非目标和元素等内容。首先来看看JAX-RS2的目标。

  1. 基于POJO:JAX-RS2的API提供一组注解(annotation)和相关的接口、类,并定义了POJO(Plain Ordinary Java Object)对象的生命周期和作用域。规定使用POJO来公布Web资源。
  2. 以HTTP为中心:JAX-RS2采用HTTP协议,并提供清晰的HTTP和统一资源定位(URI)元素来映射相关的API类和注解。JAX-RS2的API不但支持通用的HTTP使用模式,还对WebDAV和Atom等扩展协议提供灵活的而支持。
  3. 格式独立性:JAX-RS2对传输数据(HTTP Entity)的类型/格式的支持非常宽泛,允许在标准风格之上使用额外的数据类型。
  4. 容器独立性:JAX-RS2的应用可以部署在各种Servlet容器中,比如Tomcat/Jetty,也可以部署在支持JAX-WS的容器中,比如GlassFish。
  5. 内置于Java EE:JAX-RS2是Java EE规范的一部分,它定义了在一个Java EE容器内的Web资源类的内部,如何使用Java EE的功能和组件。

【WebDAV(Web-based Distributed Authoring and Versioning,基于Web的分布式创作和版本控制)是IETF组织的RFC2518协议。WebDAV基于并扩展了HTTP1.1,在HTTP标准方法以外添加了一下内容。

  • Mkcol:创建集合。
  • PropFind/PropPatch:针对资源和集合检索和设置属性。
  • Copy/Move:管理命名空间上下文中的集合和资源。
  • Lock/Unlock:改写保护,支持文件的版本控制。 针对在REST风格的Web服务中是否应该使用WebDAV,业内的声音并不一致,持反对意见的主要观点是WebDAV带来了非统一的接口,这违背了REST的初衷。】

1.3.3 非JAX-RS2的目标

那么哪些不是JAX-RS2的目标呢?

  1. 对J2SE 6.0之前版本的支持:JAX-RS2中大量使用了注解(annotation),需要J2SE 6.0以及更新的版本,因此不提供对J2SE 6.0以下版本的支持。
  2. 对服务的描述、注册和探测:JAX-RS2没有定义也无须支持任何服务的描述(description)、服务的注册(registration)和服务的探测(discovery)。
  3. HTTP协议栈:JAX-RS2没有定义新的HTTP协议栈。承载JAX-RS2应用的容器提供对HTTP协议的支持。
  4. 数据类型/格式类:JAX-RS2没有定义处理实体内容的类,它将这一类型的类交由使用JAX-RS2的应用中的类去实现。

1.3.4 解读JAX-RS元素

最后,我们来看看JAX-RS2中定义了哪些元素。

  1. 资源类:使用JAX-RS注解来实现相关Web资源的Java类。如果使用MVC的三层结构来解读,那么资源类位于最前端,用于接收请求和返回响应。通常,但不是约定,我们使用resource作为报名,三层的包定义形如:resource-service-dao。
  2. 根资源类:使用@Path注解,提供资源类树的根资源及其子资源的访问。资源类分为根资源类和子资源类,由于Jersey默认提供WADL,每个应用公布的全部资源接口可以通过WADL页面查阅。
  3. 请求方法标识符:使用运行期注解@HttpMethod,用来标识处理资源的HTTP请求方法。该方法将使用资源类的相应方法处理,标准的方法包括DELETE、GET、HEAD、OPTIONS、POST、PUT。
  4. 资源方法:资源类中定义的方法使用了请求方法标识符,用来处理相关资源的请求。就是上面提到的资源类的相应方法。
  5. 子资源标识符:资源类中定义的方法,用来定位相关资源的子资源。
  6. 子资源方法:资源类中定义的方法,用来处理相关资源的子资源的请求。
  7. Provider:一种JAX-RS扩展接口的实现类,扩展了JAX-RS运行期的能力。
  8. Filter:一种用于过滤请求和响应的Provider。
  9. Entity Interceptor:一种用于处理拦截消息读写的Provider。
  10. Invocation:一种用于配置发布HTTP请求的客户端API对象。
  11. WebTarget:一种是用URI标识的Invocation容器对象。
  12. Link:一种携带元数据的URI,包括媒体类型、关系和标题等。

1.4 Jersey项目概要

Jersey是JAX-RS标准的参考实现,是Java领域中最纯正的REST服务开发框架。Jersey项目是GlassFish项目的一个子项目,专门用来实现JAX-RS(JSR311 & JSR339)标准,并提供了扩展特性。

Jersey官网:https://jersey.github.io/

1.5 快速实现Java REST服务

1.5.1 第一个REST服务

Jersey提供了Maven原型(archetype)来快速创建REST服务项目。

  1. 创建项目

书上创建REST服务项目的命令:

mvn archetype:generate -DarchetypeArtifactId=jersey-quickstart-grizzly2 -DarchetypeGroupId=org.glassfish.jersey.archetypes -DinteractiveMode=false -DgroupId=my.restful -DartifactId=my-first-service -Dpackage=my.restful -DarchetypeVersion=2.22.1

jersey官网上创建REST服务项目的命令(20170601):

mvn archetype:generate -DarchetypeGroupId=org.glassfish.jersey.archetypes -DarchetypeArtifactId=jersey-quickstart-grizzly2 -DarchetypeVersion=2.26-b04

我一开始直接用jersey官网上的命令,结果执行到下面的“mvn package”步骤时,单元测试失败。看了一下官网http://mvnrepository.com/artifact/org.glassfish.jersey.archetypes/jersey-quickstart-grizzly2,目前(20170601)最新的稳定版本是2.25.1,用2.25.1版本并用交互式的方式创建项目:

mvn archetype:generate -DarchetypeGroupId=org.glassfish.jersey.archetypes -DarchetypeArtifactId=jersey-quickstart-grizzly2 -DarchetypeVersion=2.25.1
[INFO] ----------------------------------------------------------------------------
[INFO] Using following parameters for creating project from Old (1.x) Archetype: jersey-quickstart-g
rizzly2:2.25.1
[INFO] ----------------------------------------------------------------------------
[INFO] Parameter: basedir, Value: D:\22.EMS\Projects
[INFO] Parameter: package, Value: my.restful
[INFO] Parameter: groupId, Value: my.restful
[INFO] Parameter: artifactId, Value: my-first-service
[INFO] Parameter: packageName, Value: my.restful
[INFO] Parameter: version, Value: 1.0-SNAPSHOT
[INFO] project created from Old (1.x) Archetype in dir: D:\22.EMS\Projects\my-first-service
[INFO] ------------------------------------------------------------------------
  1. 运行服务 执行如下命令构建和启动服务:
cd my-first-service
mvn package
mvn exec:java

...
Jun 01, 2017 1:51:15 PM org.glassfish.grizzly.http.server.NetworkListener start
INFO: Started listener bound to [localhost:8080]
Jun 01, 2017 1:51:15 PM org.glassfish.grizzly.http.server.HttpServer start
INFO: [HttpServer] Started.
Jersey app started with WADL available at http://localhost:8080/myapp/application.wadl
Hit enter to stop it...

通过访问application.wadl,可以获取当前REST服务公布的接口:

<application xmlns="http://wadl.dev.java.net/2009/02">
    <doc xmlns:jersey="http://jersey.java.net/" jersey:generatedBy="Jersey: 2.25.1 2017-01-19 16:23:50"/>
    <doc xmlns:jersey="http://jersey.java.net/" jersey:hint="This is simplified WADL with user and core resources only. To get full WADL with extended resources use the query parameter detail. Link: http://localhost:8080/myapp/application.wadl?detail=true"/>
    <grammars/>
    <resources base="http://localhost:8080/myapp/">
        <resource path="myresource">
            <method id="getIt" name="GET">
                <response>
                    <representation mediaType="text/plain"/>
                </response>
            </method>
        </resource>
    </resources>
</application>

这里定义了一个资源路径myresource,在该路径下,定义了一个GET方法getIt,表述类型为text/plain。

  1. 访问服务

用Postman访问服务

http://localhost:8080/myapp/myresource

成功返回"Got it!"。

1.5.2 第一个Servlet容器服务

更多情况下,我们希望得到的是一个可以以war包形式部署到Servlet容器的轻量级Java EE项目。jersey-quickstart-webapp原型会为我们生成Servlet容器服务。

  1. 创建项目

用2.25.1版本并用交互式的方式创建项目

jersey官网上创建REST服务项目的命令(20170601):

mvn archetype:generate -DarchetypeGroupId=org.glassfish.jersey.archetypes -DarchetypeArtifactId=jersey-quickstart-webapp -DarchetypeVersion=2.25.1
[INFO] ----------------------------------------------------------------------------
[INFO] Using following parameters for creating project from Old (1.x) Archetype: jersey-quickstart-w
ebapp:2.25.1
[INFO] ----------------------------------------------------------------------------
[INFO] Parameter: basedir, Value: D:\22.EMS\Projects
[INFO] Parameter: package, Value: my.restful
[INFO] Parameter: groupId, Value: my.restful
[INFO] Parameter: artifactId, Value: my-first-webapp
[INFO] Parameter: packageName, Value: my.restful
[INFO] Parameter: version, Value: 1.0-SNAPSHOT
[INFO] project created from Old (1.x) Archetype in dir: D:\22.EMS\Projects\my-first-webapp
[INFO] ------------------------------------------------------------------------
  1. 运行服务

由于这是一个Web项目,没有main函数,我们必须将其部署到Servlet容器(比如Tomcat、Jetty)中,才能将其运行。在开发阶段,我们无需真正将其部署,而是使用Maven插件这种更轻量级的方式启动服务。在pom.xml中,增加如下定义来添加插件,在build -> plugins元素下添加。

<plugin>
    <groupId>org.eclipse.jetty</groupId>
    <artifactId>jetty-maven-plugin</artifactId>
    <version>9.4.5.v20170502</version>
</plugin>

执行如下命令编译和启动服务:

cd my-first-webapp
mvn jetty:run

...
[INFO] Configuring Jetty for project: my-first-webapp
[INFO] webAppSourceDirectory not set. Trying src\main\webapp
[INFO] Reload Mechanic: automatic
[INFO] Classes = D:\22.EMS\Projects\my-first-webapp\target\classes
[INFO] Logging initialized @59471ms to org.eclipse.jetty.util.log.Slf4jLog
[INFO] Context path = /
[INFO] Tmp directory = D:\22.EMS\Projects\my-first-webapp\target\tmp
[INFO] Web defaults = org/eclipse/jetty/webapp/webdefault.xml
[INFO] Web overrides =  none
[INFO] web.xml file = file:///D:/22.EMS/Projects/my-first-webapp/src/main/webapp/WEB-INF/web.xml
[INFO] Webapp directory = D:\22.EMS\Projects\my-first-webapp\src\main\webapp
[INFO] jetty-9.4.5.v20170502
[INFO] Scanning elapsed time=381ms
[INFO] DefaultSessionIdManager workerName=node0
[INFO] No SessionScavenger set, using defaults
[INFO] Scavenging every 600000ms
[INFO] Started o.e.j.m.p.JettyWebAppContext@1c7f96b1{/,file:///D:/22.EMS/Projects/my-first-webapp/sr
c/main/webapp/,AVAILABLE}{file:///D:/22.EMS/Projects/my-first-webapp/src/main/webapp/}
[INFO] Started ServerConnector@73e776b7{HTTP/1.1,[http/1.1]}{0.0.0.0:8080}
[INFO] Started @61139ms
[INFO] Started Jetty Server

如果我们要对示例项目进行断点调试,应在服务启动前设置监听端口等信息。这里以IntelliJ IDEA所使用的5050端口为例,点击View->Tool Windows->Maven Projects菜单,打开Maven Projects选项卡。右键点击my-first-webapp->Plugins->jetty->jetty:run打开右键菜单,选择Debug 'my-first-webapp'。

  1. 访问服务

在浏览器中访问服务

http://localhost:8080/webapi/myresource

页面上显示"Got it!"。

  1. 扩展项目

增加两个资源方法,分别用来新增和查询资源。

MyResource类:

package my.restful;

import javax.ws.rs.Consumes;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;
import java.util.concurrent.ConcurrentHashMap;

/**
 * Root resource (exposed at "myresource" path)
 */
@Path("myresource")
public class MyResource {

    /**
     * Method handling HTTP GET requests. The returned object will be sent
     * to the client as "text/plain" media type.
     *
     * @return String that will be returned as a text/plain response.
     */
    @GET
    @Produces(MediaType.TEXT_PLAIN)
    public String getIt() {
        return "Got it!";
    }

    private static ConcurrentHashMap<String, MyDomain> map=new ConcurrentHashMap<>();

    @GET
    @Path("{key}")
    @Produces(MediaType.APPLICATION_XML)
    public MyDomain getMy(@PathParam("key") final String key) {
        final MyDomain myDomain = map.get(key);
        if (myDomain == null) {
            return new MyDomain();
        }
        return myDomain;
    }

    @POST
    @Consumes(MediaType.APPLICATION_XML)
    public void addMy(final MyDomain myDomain) {
        map.put(myDomain.getName(), myDomain);
    }
}

MyDomain类:

package my.restful;

import javax.xml.bind.annotation.XmlAttribute;
import javax.xml.bind.annotation.XmlRootElement;

@XmlRootElement
public class MyDomain {
    private String name;
    private String value;

    @XmlAttribute
    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    @XmlAttribute
    public String getValue() {
        return value;
    }

    public void setValue(String value) {
        this.value = value;
    }
}

用Postman测试:

  1. 先增加一条记录
POST http://localhost:8080/webapi/myresource
Headers Content-Type application/xml
Body raw XML(application/xml)
     <myDomain name="Allen" value="[email protected]"/>
  1. 查询新增记录
GET http://localhost:8080/webapi/myresource/Allen
Headers Content-Type application/xml

1.6 快速了解Java REST服务

1.6.1 REST工程类型

在REST服务中,资源类是接收REST请求并完成响应的核心类,而资源类是由REST服务的“提供者”来调度的。这一概念类似其他框架中自定义的Servlet类,该类会将请求分派给指定的Controller/Action类来处理。REST中的提供者是JAX-RS2中定义的Application以及Servlet。

Application类在JAX-RS2(JSR339)标准中定义为javax.ws.rs.core.Application,相当于JAX-RS2服务的入口。如果REST服务没有自定义Application的子类,容器将默认生成一个javax.ws.rs.core.Application。

根据JAX-RS2规范第2章中对REST服务场景的定义,将REST服务氛围四种类型:

  • 类型一:当服务中没有Application子类时,容器会查找Servlet的子类来做入口,如果Servlet的子类也不存在,则REST服务类型为类型一。
  • 类型二:当服务中没有Application子类时,存在Servlet的子类,则REST服务类型为类型二。
  • 类型三:服务中定义了Application的子类,而且这个Application的子类使用了@ApplicationPath注解,则REST服务类型为类型三。
  • 类型四:服务中定义了Application的子类,但是这个Application的子类没有使用@ApplicationPath注解,则REST服务类型为类型四。
  1. REST服务类型一

服务中同时不存在Application的子类和Servlet子类。在JAX-RS2(JSR339)中定义这种情况应作如下处理:为REST服务动态生成一个名为javax.ws.rs.core.Application的Servlet实例,并自动探测匹配资源。与此同时,需要根据Servlet的不同版本,在web.xml定义REST请求处理的Servlet为这个动态生成的Servlet,并定义该Servlet对资源路径的匹配。在没有Application的子类存在的情况下,在web.xml中定义Servlet是必不可少的配置。

  1. REST服务类型二

服务中不存在Application的子类但存在Servlet的子类。Servlet子类继承自org.glassfish.jersey.servlet.ServletContainer类,这是Jersey2中Servlet的积累,继承自HttpServlet。

在自定义Servlet3.x子类的场景下,web.xml可以省略,但需要修改Maven的maven-war-plugin插件的配置,添加failOnMissingWebXml为false,这样编译时才不会报错。

  1. REST服务类型三

服务中存在Application的子类并且定义了@ApplicationPath注解。

AirApplication类继承自Application类,覆盖了getClasses()方法,注册了资源类MyResource,这样在服务启动后,MyResource类提供的资源路径将被映射到内存,以便请求处理时匹配相关的资源类和方法。

AirResourceConfig类在构造子中提供了扫描包的全名,这样在服务启动后,com.example包内资源类所提供的资源路径将被映射到内存。

  1. REST服务类型四

服务中存在Application的子类,不存在Servlet子类、不存在或者不允许使用注解@ApplicationPath。

AirApplication是Application的子类,但是该类没有定义@ApplicationPath注解,因此我们需要在web.xml中配置Servlet和映射资源路径。在servlet-name中使用自定义的Application子类com.example.AirApplication的全名作为Servlet名称,并在url-pattern中映射资源路径。

1.6.2 REST应用描述

REST应用的描述:以XML格式展示当前REST环境中所提供的REST服务接口。这种XML格式的描述就是WADL(Web Application Description Language, Web应用描述语言)。

WADL是用来描述基于HTTP协议的REST式Web服务部署情况的。它采用XML格式,支持多种数据类型的描述。WADL由Sun公司提出,尚未成为W3C或者OASIS的标准,JAX-RS标准中并没有关于WADL的定义和说明。Jersey作为JAX-RS2的参考实现默认支持服务的WADL。通过浏览器访问“服务根路径/application.wadl”即可打开该服务的WADL内容。相对于REST服务,WSDL更为人们所熟知,WSDL是RPC风格的基于SOAP的Web服务的描述语言。两者缩写类似而且都使用XML格式,此外共性不多。

@GET
@Produces(MediaType.TEXT_PLAIN)
public String getIt() {
    return "Got it!";
}

getIt方法定义为GET请求方法,@Produces中定义的媒体类型是MediaType.TEXT_PLAIN,即响应过程中生产的数据,其表述性状态以text/plain媒体类型转移。

1.7 Java领域的其他REST实现

Java领域存在很多REST实现,我们以是否遵循JAX-RS标准,将它们分为两组。前者是JAX-RS标准参考实现之外的厂商实现,后者要么是因为出现较JAX-RS早,要么干脆跳出了JAX-RS标准的定义,以自身框架一致性的目标,实现了一套独有的对REST开发的支持。

1.7.1 JAX-RS的其他实现

JAX-RS标准发布后,诸多厂商推出了自己的基于JAX-RS标准的实现。其中最有影响力的当属来自JBoss社区的RESTEasy和来自Apache社区的CXF。

  1. JBoss的RESTEasy

RESTEasy是JBoss社区提供的JAX-RS项目。JBoss这一名词已经不再代表Java EE容器,曾经的JBoss已经更名为WildFly。现在,JBoss特指RedHat公司旗下的开源社区。RESTEasy自2009年1月第一个GA版本以来,发展到3.0.x,从版本3.0.0.Final开始支持JAX-RS2.0。

官方网站:http://resteasy.jboss.org

  1. Apache的CXF

CXF是Apache开源社区提供的JAX-RS项目,CXF的名称由Celtix项目和XFire项目合并而来。其中Celtix由IONA Technologies开发,XFire来自Codehaus。CXF是JAX-WS的著名实现,同时实现了JAX-RS,从版本2.7.0开始几乎全面支持JAX-RS2.0全部特性。从版本3.0.0开始实现JAX-RS2客户端API。

官方网站:http://cxf.apache.org

1.7.2 其他的REST实现

介绍Java领域,没有遵循JAX-RS规范的REST式Web服务开发工具,包括Restlet、LinkedIn的Rest.li以及Spring MVC。

  1. Restlet

Restlet是一款遵从REST风格的、基于Java平台电的轻量级框架。Restlet许可为免费开源,提供REST开发的完整支持。

官方网站:http://restlet.org

  1. LinkedIn的Rest.li

Rest.li是社交网站LinkedIn开发的REST+JSONREST式服务框架。

官方网站:http://rest.li

  1. Spring MVC项目

Spring框架使用Gradle构建和管理项目,使用GIT管理源代码,地址为https://github.com/spring-projects/spring-framework,其中MVC模块位于spring-framework/spring-webmvc目录下。

Spring从版本3.0开始提供了对REST式应用开发的支持,但Spring目前并没有也没必要推出一个实现JAX-RS标准的模块。MVC模块提供的REST功能并没有采用JAX-RS提出的标准。本质上,Spring MVC控制流程是使用Controller处理Model在某种动词性的业务逻辑操作,而JAX-RS的控制流程是使用资源类Resource处理名词性的资源表述。

1.8 REST调试工具

在REST开发过程中,需要对请求资源地址、资源所支持的数据媒体类型和返回值等进行调试和测试。

1.8.1 命令行调试工具

cURL(http://curl.haxx.se)是非常易用、强大的基于URL标准(RFC 3986)的命令行工具,通过命令行即可完成多种协议(比如HTTP)的请求,并可以将请求的响应信息输出在终端/控制台上,因此对于调试和测试REST请求非常方便。

HTTPie(http://httpie.org)是和cURL非常类似的命令行工具,相比cURL有更良好的用户体验。

命令行工具易于在自动化脚本中使用。

1.8.2 基于浏览器的图形化调试插件

  1. Simple REST Client插件

基于Chrome浏览器的扩展。该项目的地址是http://github.com/jeremys/Simple-Rest-Client-Chrome-Extension

  1. Advance REST Client插件

Advance REST Client插件可以看作是Simple REST Client的增强版。该项目的地址是https://code.google.com/p/chrome-rest-client

  1. Postman-REST Client插件

Postman-REST Client插件是基于Simple REST Client源代码编写的专门针对REST的插件。该项目的地址是https://github.com/a85/POSTMan-Chrome-Extension。插件的下载地址http://www.getpostman.com

  1. FireFox插件

相对于Chrome浏览器,Firefox的REST插件功能类似,其中常用的插件有REST-Easy和RESTClient。REST-Easy的项目地址是https://github.com/nathan-osman/RET-Easy,RESTClient的项目地址是http://restclient.net

第2章 REST API设计

设计和开发REST式的Web服务除了要掌握JAX-RS2标准,还要对统一接口、资源定位以及请求处理过程中REST风格的传输数据的格式、响应信息等有良好的认知。此外,设计良好的REST API应当对内容协商、资源地址信息(link)有良好的支持。

2.1 统一接口

REST服务和RPC服务在接口定义上的区别是:REST使用HTTP协议的通用方法作为统一接口的标准词汇,REST服务所提供的方法信息都在HTTP方法里,而RPC服务所提供的方法信息都在SOAP/HTTP信封里(其封装的格式通常是HTTP或SOAP),每一个RPC式的Web服务都会公布一套符合自己商业逻辑的方法词汇。

每一种HTTP请求方法都可以从安全性和幂等性两方面考虑,这对正确理解HTTP请求方法和设计统一接口具有决定性的意义。换句话说,要定义严谨的REST统一接口,就需要真正理解HTTP方法的安全性和幂等性。

安全性是指外系统对该接口的访问,不会使服务器端资源的状态发生改变;幂等性(idempotence)是指外系统对统一REST接口的多次访问,得到的资源状态是相同的。

【这里讨论的安全性对应的英文是Safety而不是Security。】

2.1.1 GET方法

REST使用HTTP的GET方法获取服务提供的资源。

  1. 幂等性和安全性

HTTP的GET方法用于读取资源。GET方法是幂等的,因为读取同一个资源,总是得到相同的数据。GET方法也是安全的,因为读取资源不会对其状态做改动。JAX-RS2定义了@GET注解对资源方法定义,使得该方法用于处理GET请求。

值得注意的是,虽然GET方法的特性是幂等和安全的,但这不意味着任何一个定义为处理GET请求的方法都是幂等和安全的。换句话说,设计不良的API有可能违背GET的特性,将一个不该是GET的方法定义为之。

  1. 资源方法命名

标准的命名方式应该是单数的同步操作以资源名称命名;批量的同步操作以资源名称的复数名称命名。比如这个API是用于同步设备的,那么命名可以使用device和devices、如果担心与普通查询业务资源地址混淆,可以在资源路径中增加查询或者路径参数。

  1. 抽象层注解资源

JAX-RS2的HTTP方法注解可以定义在接口和POJO中,置于接口中的方法名更具抽象性和通用性。

在接口中抽象地定义了资源的请求方法类型后,其全部实现类都无须再定义。这使得编码更整洁和抽象。

HEAD方法和GET方法相似,只是服务器端的返回值不包括HTTP实体。因此,HEAD方法是全兴和幂等的。JAX-RS2定义了@HEAD注解来定义相关资源方法。OPTIONS方法和GET方法相似,是安全和幂等的。OPTIONS用于读取资源所支持的(Allow)所有HTTP请求方法。JAX-RS2定义了@OPTIONS注解来定义相关资源方法。

2.1.2 PUT方法

PUT方法是一种写操作的HTTP请求。REST使用HTTP的PUT方法更新或添加资源。

  1. 更新资源

因为REST只是风格,不是技术规范或标准,所以有些实现REST的细节没有明确的定义,这对实践而言,不可避免会产生某些误解。比如在创建和更新某个资源的时候,开发者比较迷茫的是何时该用HTTP的PUT方法,何时该使用POST方法。为了解决这一问题,我们首先应该知道PUT方法的特性。PUT方法是幂等的,即多次插入或者更新同一份数据,在服务器端对资源状态所产生的改变是相同的。PUT方法不是安全的,有些动作的HTTP方法都不是安全的。我们知道,由于使用同一份数据向服务器请求更新某一资源,得到的结果应该总是相同的,因此对于更新操作,使用PUT是没有疑问的。可能有人会想到最后更新时间字段每次提交会不同,但那已经不是同一份数据了。

  1. 添加资源

创建操作通常每次得到的结果是不同的,因为服务器端的业务层逻辑通常要求数据的主键字段要么来自于业务平台自增一个逻辑值,要么来自于数据库的主键自增。因此,相同的数据每一次提交到服务器端,都会为数据添加一个新的主键值,也就是创建一个主键值不同的新资源(如果没有业务或者外键冲突)。所以,创建操作通常应当设计为POST方法的API。唯有一种场景应当使用PUT方法来设计API,即客户端在发起创建请求时,在同一份数据中总可以提供唯一的主键值,服务器不会对其进行修改,这样的创建请求确保了幂等性,不应再使用POST方法。JAX-RS2定义了@PUT注解来定义相关资源方法。

  1. 媒体类型

PUT方法执行写操作的非安全的HTTP方法,需要考虑请求实体媒体类型和响应实体媒体类型。请求实体媒体类型使用HTTP头的Content Type定义,响应实体媒体类型使用HTTP头的Accept定义。

在服务器端,@Consumes(MediaType.APPLICATION_XML)定义了服务器端要消费的媒体类型,即消费客户端请求实体的媒体类型。@Produces(MediaType.TEXT_PLAIN)定义了服务器端生产的媒体类型,即服务器产生的响应实体的媒体类型。客户端在提交非安全性HTTP请求方法前,在Entity类的实例中,定义了该Entity实例的媒体类型,即客户端请求实体的媒体类型。request方法用于定义可接受的HTTP方法的返回媒体类型,即服务器的响应实体的媒体类型。

2.1.3 DELETE方法

DELETE方法是幂等的,即多次删除同一份数据(通常请求中传递的参数是数据的主键值),在服务器端产生的改变是相同的。JAX-RS2定义了@DELETE注解来定义相关资源方法。

执行删除的资源方法,其返回值可以定义为void,即该方法没有返回值。之所以在删除资源的场景中可以采用这样的方式定义,是因为删除的前提是对该资源信息已经充分了解,没有必要再将其从服务器上传递回来。业务逻辑更关注删除操作的结果状态。

无返回值的资源方法delete()返回的响应实体为空,HTTP状态码为204,表示“No Content”。

2.1.4 POST方法

POSt方法是一种写操作的HTTP请求。RPC的所有写操作均使用POST方法,而REST只使用HTTP的POST方法添加资源。

  1. 既不幂等也不安全

定义为POST的REST接口用于写数据,POST方法的特性是既不幂等也不安全。由于请求会改变服务器端资源的状态,因此它是不安全的;由于每次请求对服务器端资源状态的改变并不是相同的,因此它不是幂等的。

  1. 两种分类

REST中使用的POST可以称之为POST(a),即用于创建、添加资源的HTTP方法。这是相对于RPC式的Web服务中对POST的使用而言的。

在RPC中使用的POST可以称之为POST(p),即通过重载的POST用于处理某种操作。服务器接收POST(p)的请求后,不是直接处理POST请求,由于真正的方法信息位于信封头或实体主体里,因此需要先解析出执行方法。JAX-RS2定义了@POST注解来定义相关资源方法。

在POST请求提交的添加资源操作中,主键的设置是在服务器端完成的,因此客户端成功请求添加资源后,应关注服务器端返回的实体结果是否有主键信息。

2.1.5 WebDAV扩展方法

WebDAV(Web-based Distributed Authoring and Versioning,基于Web的分布式创作与版本控制)是IETF的RFC4918规范,是对HTTP1.1协议的一组扩展,该协议允许用户以协作方式编辑和管理远程Web服务器上的文件。WebDAV在HTTP方法的基础上,增加了如下方法:

  • PROPFIND方法:用于从Web资源中查询存储为XML格式的属性数据,或者重载为从一个远程系统中查询目录结构的数据。
  • PROPPATCH方法:用于原子地更改和删除一个资源的多个属性。
  • MKCOL方法:用于创建目录。
  • COPY方法:用于将资源从一个URI资源地址复制到另一个URI资源地址。
  • MOVE方法:用于将资源从一个URI资源地址移动到另一个URI资源地址。
  • LOCK方法:用于锁定一个资源。WebDAV支持共享锁和独占锁。
  • UNLOCK方法:用于解锁一个资源。

虽然WebDAV对HTTP方法做出了功能性扩展,使之提供更强大服务,但是从ROA角度讲,因为WebDAV在HTTP标准方法的基础上增加了特殊的方法名称,WebDAV破坏了统一接口的原则。因此,对是否应该在REST式的Web服务中支持WebDAV,业内的观点并不一致。

作者的观点是如果遵从ROA,那么就不使用HTTP标准方法之外的方法。如果业务需求确实超出了标准方法所及,那么可以使用如下注解实现对WebDAV的支持。JAX-RS2规范没有阐述对WebDAV提供支持的文字,但是JAX-RS2定义了@HttpMethod注解来定义相关的资源方法。在Jersey应用中,可以使用@HttpMethod注解定义HTTP标准方法之外的方法名称来支持WebDAV。示例代码如下:

@Target({ElementType.METHOD})
@Retention({RetentionPolicy.RUNTIME})
@HttpMethod(value = "MOVE")
@Documented
public @interface MOVE {
}

这段代码是对@MOVE注解的定义,使用@HttpMethod注解定义了名为MOVE的HTTP扩展方法。

需要注意的是,Jersey默认的连接器只支持HTTP标准方法,因此要使用HTTP的扩展方法就不能直接使用默认的连接器。

2.2 资源定位

REST使用URI实现资源定位,从这个角度上讲,对外提供REST式的Web服务的接口就是公布一系列的URI及其参数,这使得REST的实践过程简单到了极致。但是URI形式上的简单并不意味着我们可以将URI的定义信手拈来,正所谓“没有规矩,不成方圆”。

在设计REST式的Web服务过程中,资源地址的设计是非常严谨的,如果设计不得体,不仅REST接口的风格无法统一,使系统的扩展性和易用性降低,也很难实现资源准确地被定位。

资源地址的设计过程是面向资源的,资源名称应是准确描述该资源的名词,资源地址应具有直观的描述性。值得注意的是一个URI资源地址唯一对应一个资源,但是一个资源可以拥有多个URI资源地址。

2.2.1 资源地址设计

资源地址的设计对整个REST式的Web服务至关重要,涉及系统的可用性、可维护性和可扩展性等诸多方面的表现。

  1. 资源路径概览

资源地址的路径变量是用来表达逻辑上的层次结构的,资源和子资源的形式是自左至右、斜杠分割的名词。它们的关系可以是从整体到局部,比如学校到班级,城市到乡镇;也可以是从一般到具体,比如一个生物的“门、纲、目、科、属、种”的资源路径。资源地址具体可以分为5个部分,以scheme://host:port/path?queryString为例,如表2-1所示。

表2-1 资源地址路径分解

元素 描述
scheme 协议名称,通常是HTTP和HTTPS
host (DNS)主机名称或者IP地址
port 服务端口
path 资源地址,使用“/”符号来分隔逻辑上的层次结构
? 用来分隔资源地址和查询字符串符号
queryString 查询字符串,方法作用域信息。使用“&”符号来分隔查询条件;使用逗号分隔有次序的作用域信息;使用分号分隔无次序的作用域信息

一个典型的URI如表2-1所示,包括协议名称、主机名称、服务端口、资源地址和查询字符串等5个部分。其中资源地址部分,根据具体部署的不同或有差别。

通常使用ContextPath、ServletPath和PathInfo来细分资源地址。ContextPath是上下文名称,通常和部署服务器的配置或者REST服务的web.xml有关。ServletPath是Servlet名称,与REST服务中定义的@ApplicationPath注解或者web.xml的配置有关。JAX-RS2定义了@Path注解来定义资源地址。PathInfo是资源路径信息,与资源类、子类以及类中的方法定义的@Path注解有关。来看一个资源地址示例。

http://localhost:8080/simple-service-webapp-spring-jpa-jquery/webapi/books/book?id=1

“simple-service-webapp-spring-jpa-jquery”是ContextPath。“webapi”是ServletPath。“books/book”是PathInfo。ContextPath、ServletPath和PathInfo合起来是requestURI。“http://localhost:8080/”加上requestURI是requestURL。

资源地址不能唯一定位一个资源。资源地址相同,但HTTP方法不同的两个方法是两个不同的REST接口。HTTP方法和资源地址结合在一起才可以完成对资源的定位。GET方法用于读取/检索、查询/过滤一个资源,PUT方法用于修改/更新资源、创建客户端维护主键信息的资源,DELTE方法用于删除资源,POST方法用于创建资源。

  1. 资源地址和作用域

在路径变量里可以使用标点符号以辅助增强逻辑清晰性。这些辅助符号用在表2-2中的查询字符串,作为资源地址的查询变量,用来表达算法的输入,实现对方法作用域的约束。下面来逐一讲述这些对资源地址设计至关重要的符号。

  1. 问号(?)是用来分隔资源地址和查询字符串的,与符号(&)是用来分隔查询条件的参数的。示例代码如下:
GET /books?start=0&size=10

这行代码中的作用是查询图书列表,开始行参数0,条目参数为10,即从第0行开始取10条并返回该图书列表。

  1. 逗号(,)是用来分隔有次序的作用域信息。需要注意的是逗号分隔的逻辑上的顺序信息,这种顺序可以是约定俗成的,比如先写经度后写纬度;也可以是系统约定的,比如用月、日、年的顺序等。举例来说,按时间区间查询图书,日期信息在资源地址中是采用月、年顺序,示例如下:
GET /books/01,2002-12,2014

这行代码中的作用是查询2002年1月到2014年12月这个时间段(出版)的图书。这个例子中还是用了连字符(-),有时候也可以使用下横线(_)来做逻辑上的辅助分隔。

  1. 分号(;)是用来分隔无次序的作用域信息。通常这些信息是逻辑上并列存在的,比如并列的查询条件,示例如下所示。
GET /books/restful;program=java;type=web

这行代码中的作用是查询满足图书内容为restful的、使用的编程语言是Java的、讲述的类型是Web的图书列表。这样的逻辑没有顺序,互换顺序的查询条件不会影响资源的表述。

基于上述理论,列出常用的资源地址设计示例如表2-2所示。

表2-2 资源地址设计

功能 资源地址
添加/创建 POST /books PUT /books/{id}
删除 DELETE /books/{id}
修改/更新 PUT /books/{id}
查询全部 GET /books HTTP1.1
主键查询 GET /books/{id} HTTP1.1 GET /books?id=12345678
分页作用域查询 GET /books?start=0&size=10 GET /books/01,2002-12,2014 GET /books/restful;program=java;type=web GET /books?limit=100&sort=bookname

2.2.2 @QueryParam注解

查询条件决定了方法的作用域,查询参数组成了查询条件。JAX-RS2定义了QueryParam注解来定义查询参数。

表2-3 @QueryParam示例列表

接口描述 资源地址
分页查询列表数据 /query-resource/yijings?start=24&size=10
排序并分页查询列表数据 /query-resource/sorted-yijings?limit=5&sort=pronounce
查询单项数据 /query-resource/yijing?id=8

注解QueryParam可以和注解DefaultValue一起使用。注解DefaultValue的作用是预置一个默认值,当请求中不包含此参数时使用,示例如下。

@DefaultValue(100) @QueryParam("size") final Integer pageSize

这句话的意思是当请求中不包含分页参数pageSize时,分页参数PageSize的默认值为100。

###2.2.3 @PathParam注解 JAX-RS2定义了@PathParam注解来定义路径参数——每个参数对应一个子资源。

表2-4 @PathParam示例列表

接口描述 资源地址
基本路径参数 /path-resource/Eric
结合查询参数 /path-resource/Eric?hometown=Buenos Aires
带有标点符号的资源路径 /path-resource/199-1999 /path-resource/01,2012-12,2014
子资源变长的资源路径 /path-resource/Asia/China/northeast/liaoning/shenyang/huangu /path-resource/q/restful;program=java;type=web /path-resource/q2/restful;program=java;type=web
  1. @Path注解

JAX-RS2定义了@Path注解来定义资源路径。@Path接收一个value参数来解析原路径地址。该参数除了前面示例中的books这种静态定义的方式外,也可以使用动态变量的方式,其格式为:{参数名称:正则表达式}。这个接口的功能和查询参数实现的query-resource/yijings?start=24&size=10相似,也是用于分页查询,其资源地址形如:/path-resource/199-1999,参考示例如下:

@GET
@Path("{from:\\d+}-{to:\\d+}")
public String getByCondition(@PathParam("from") final Integer from, @PathParam("to") final Integer to)

/path-resource/01,2012-12,2014对应的正则表达式为:

@Path("{beginMonth:\\d+},{beginYear:\\d+}-{endMonth:\\d+},{endYear:\\d+}")
  1. 正则表达式

  2. 路径配查询

查询参数和路径参数在一个接口中配合使用,可以更便捷地完成资源定位。示例代码如下:

@Path("{user: [a-zA-Z] [a-zA-Z_0-9]*}")
@Produces(MediaType.TEXT_PLAIN)
public String getUserInfo(@PathParam("user") final String user, @DefaultValue("Shen Yang") @QueryParam("hometown") final String hometown) {
    return user + ":" + hometown;
}

以资源地址:/path-resource/Eric?hometown= Buenos Aires为例,REST容器会将请求匹配到getUserInfo()方法,其中Eric是路径变量user的值,Buenos Aires作为查询变量hometown的值。

  1. 路径区间

路径区间(PathSegment)是对资源地址更灵活的支持,使资源类的一个方法可以支持更广泛的资源地址的请求。我们从下面定义的资源地址列表来走近PathSegment。

/path-resource/Asia/China/northeast/liaoning/shenyang/huangu
/path-resource/China/northeast/liaoning/shenyang/tiexi
/path-resource/China/shenyang/huangu

如上所示的资源地址中含有固定子资源(shenyang)和动态子资源两部分。对于动态匹配变长的子资源资源地址,PathSegment类型的参数结合正则表达式将大显身手,示例代码如下。

@GET
@Path("{regin:.+}/shenyang/{district:\\w+}")
public String getByAddress(@PathParam("region") final List<PathSegment> region, @PathParam("district") final String district) {
    final StringBuilder result = new StringBuilder();
    for (final PathSegment pathSegment : region) {
        result.append(pathSegment.getPath()).append("-");
    }
    result.append("shenyang-" + district);
...
}

对于查询参数动态给定的场景,可以定义PathSegment作为参数类型,通过getMatrixParameters()方法获取MutivaluedMap类型的查询参数信息,即可将参数条件作为一个整体解析,示例代码如下。

@Path("q/{condition}")
public String getByCondition3(@PathParam("condition") final PathSegment condition) {
...
    final MutivaluedMap<String, String> matrixParameters = condition.getMatrixParameters();
    final Iterator<Entry<String, List<String>>> iterator = matrixParameters.entrySet().iterator();
    while (iterator.hasNext()) {
        final Entry<String, List<String>> entry = iterator.next();
        conds.append(entry.getKey()).append("=");
        conds.append(entry.getValue()).append(" ");
    }
    return conds.toString();
}

在这段代码中,getByCondition3()方法中只有一个PathSegment类型的参数condition,该参数包含了查询条件中携带的全部参数列表。举例来说,资源地址为/path-resource/q/restful;program=java;type=web的请求可以匹配到getByCondition3()方法,其中,MultivaluedMap类型的实例matrixParameters的值为[program=[java],type=[web]]。

  1. @MatrixParam注解 上例中,通过编程方式,调用PathSegment类的getMatrixParameters()方法来获取查询参数信息。还有一种方式式通过@MatrixParam注解来逐一定义参数,即通过声明方式来获取,示例代码如下:
@Path("q2/{condition}")
public String getByCondition4(@PathParam("condition") final PathSegment condition, @MatrixParam("program") final String program, @MatrixParam("type") final String type) {
    return condition.getPath() + " program=[" + program + "] type=[" + type + "]";
}

在这段代码中,使用@MatrixParam注解分别定义了“program”和“type”两个参数。与上例相比,这段代码更能清晰地表达可接收的参数名称和类型,缺点是缺乏对请求资源地址更灵活的支持。

2.2.4 @FormParam注解

JAX-RS2定义了@FormParam注解来定义表单参数,相应的REST方法用以处理请求实体媒体类型为Content-Type:application/x-www-form-urlencoded的请求,示例代码如下。

@Path("form-resource")
public class FormResource {
    @POST
    public String newPassword(
        @DefaultValue("feuyeux") @FormParam(FormResource.USER) final String user,
        @Encoded @FormParam(FormResource.PW) final String password,
        @Encoded @FormParam(FormResource.NPW) final String newPassword,)
        @FormParam(FormResource.VNPW) final String verification) {
        ...
    }
}

@Test
public void testPost2() {
    final Form form = new Form();
    form.param(FormResource.USER, "feuyeux");
    form.param(FormResource.PW, "北京");
    form.param(FormResource.NPW, "上海");
    form.param(FormResource.VNPW, "上海");
    final String result = target("form-resource").request().post(Entity.entity(form, MediaType.APPLICATION_FORM_URLENCODED_TYPE), String.class);
    FormTest.LOGGER.debug(result);
    Assert.assertEquals("encoded should let it to disable decoding", "feuyeux:%E5%8C%97%E4%BA%AC:%E4%B8%8A%E6%B5%B7:上海", result);
}

JAX-RS2定义了@Encoded注解用以标识禁用自动解码。当对newPassword使用@Encoded注解,REST方法得到的参数值就不会被解码,如果将其直接返回,那么客户端得到的值就会是处于编码状态的字符串。

2.2.5 @BeanParam注解

JAX-RS2定义了@BeanParam注解用于自定义参数组合,使REST方法可以使用简洁的参数形式完成复杂的接口设计。示例如下。

    @GET
    @Path("{region:.+}/shenyang/{district:\\w+}")
    @Produces(MediaType.TEXT_PLAIN)
    public String getByAddress(@BeanParam Jaxrs2GuideParam param) {
        return param.getRegionParam() + ":" + param.getDistrictParam() + ":" + param.getStationParam() + ":" + param.getVehicleParam();
    }

public class Jaxrs2GuideParam {
    @HeaderParam("accept")
    private String acceptParam;
    @PathParam("region")
    private String regionParam;
    @PathParam("district")
    private String districtParam;
    @QueryParam("station")
    private String stationParam;
    @QueryParam("vechicle")
    private String vehicleParam;
}

@Test
public void testBeanParam() {
    final String path = "bean-resource";
    String result;

    /*http://localhost:9998/ctx-resource/China/shenyang/tiexi?station=Workers+Village&vehicle=bus*/
    final WebTarget queryTarget = target(path).path("China").path("northeast").path("shenyang").path("tiexi").queryParam("station", "Workers Village")
            .queryParam("vehicle", "bus");
    result = queryTarget.request().get().readEntity(String.class);
    LOGGER.debug(result);
    Assert.assertEquals("China/northeast:tiexi:Workers Village:bus", result);
}
http://localhost:9998/ctx-resource/China/shenyang/tiexi?station=Workers+Village@vehicle=bus

在这段代码中,getByAddress()方法只用了一个使用@BeanParam注解定义的Jaxrs2GuideParam类型的参数;Jaxrs2GuideParam定义了一系列REST方法会用到的参数类型,包括示例中使用的查询参数“station”和路径参数“region”等,从而使得getByAddress()方法可以匹配更为复杂的资源路径。可以看出这是一个较为复杂的查询请求。其中路径部分包括China/shenyagn/tiexi,查询条件包括station=Workers+Village和vehicle=bus。这些条件均在Jaxrs2GuideParam类中可以匹配。

2.2.6 @CookieParam注解

JAX-RS2定义了@CookieParam注解用以匹配Cookie中的键值对信息。示例如下。

    @GET
    public String getHeaderParams(@CookieParam("longitude") final String longitude, @CookieParam("latitude") final String latitude, @CookieParam("population") final double population, @CookieParam("area") final int area) {
        return longitude + "," + latitude + " population=" + population + ",area=" + area;
    }

    @Test
    public void testContexts() {
        final String path = "kuky-resource";
        String result;

        /*http://localhost:9998/kuky-resource*/
        final Builder request = target(path).request();
        request.cookie("longitude", "123.38");
        request.cookie("latitude", "41.8");
        request.cookie("population", "822.8");
        request.cookie("area", "12948");
        result = request.get().readEntity(String.class);
        CookieTest.LOGGER.debug(result);
        Assert.assertEquals("123.38,41.8 population=822.8,area=12948", result);
    }

在这段代码中,getHeaderParams方法包含4个使用@CookieParam注解定义的惨呼,用于匹配Cookie的字段。

2.2.7 @Context注解

JAX-RS2定义了@Context注解来解析上下文参数,JAX-RS2中有多种元素可以通过@Context注解作为上下文参数使用。示例代码如下:

    public String getByAddress(
            @Context final Application application,
            @Context final Request request,
            @Context final javax.ws.rs.ext.Providers provider,
            @Context final UriInfo uriInfo,
            @Context final HttpHeaders headers) {
        ...
    }

在这段代码中,分别定义了Application、Request、Providers、UriInfo和HttpHeaders等5种类型的上下文实例。从这些实例中可以获取请求过程中的重要参数信息,示例代码如下:

final MultivaluedMap<String, String> pathMap = uriInfo.getPathParameters();
final MultivaluedMap<String, String> queryMap = uriInfo.getQueryParameters();
final List<PathSegment> segmentList = uriInfo.getPathSegments();
final MultivaluedMap<String, String> headerMap = headers.getRequestHeaders();

在这段代码中,UriInfo类是路径信息的上下文,从中可以获取路径参数集合getPathParameters()和查询参数集合getQueryParameters()。类似地,我们可以从HttpHeaders类中获取头信息集合getRequestHeaders()。这些业务逻辑处理中常用的辅助信息的获取,要通过@Context注解定义方法的参数或者类的字段来实现。

2.3 传输格式

本节要考虑的就是如何设计表述,即传输过程中数据采用什么样的数据格式。通常,REST接口会以XML和JSON作为主要的传输格式。

2.3.1 基本类型

Java的基本类型又叫原生类型,包括4种整型(byte、short、int、long)、2种浮点类型(float、double)、Unicode编码的字符(char)和布尔类型(boolean)。

Jersey支持全部的基本类型,还支持与之相关的引用类型。

2.3.2 文件类型

Jersey支持传输File类型的数据,以方便客户端直接传递File类实例给服务器端。文件类型的请求,默认使用的媒体类型是Content-Type:text/html。

2.3.3 InputStream类型

Jersey支持Java的两大读写模式,即字节流和字符流。这一节展示字节流作为REST方法参数。

2.3.4 Reader类型

这一节展示字符流作为REST方法参数。

2.3.5 XML类型

XML类型是使用最广泛的数据类型。Jersey对XML类型的数据处理,支持Java领域的两大标准,即JAXP(Java API for XML Processing, JSR-206)和JAXB(Java Architecture for XML Binding, JSR-222)。

  1. JAXP标准

JAXP包含了DOM、SAX和StAX 3种解析XML的技术标准。

  • DOM是面向文档解析的技术,要求将XML数据全部加载到内存,映射为树和节点模型以实现解析。
  • SAX是事件驱动的流解析技术,通过监听注册事件,触发回调方法以实现解析。
  • StAX是拉式流解析技术,相对于SAX的事件驱动推送技术,拉式解析使得读取过程可以主动推进当前XML位置的指针而不是被动获得解析中的XML数据。

对应的,JAXP定义了3种标准类型的输入接口Source(DOMSource,SAXSource,StreamSource)和输出接口Result(DOMResult,SAXResult,StreamResult)。Jersey可以使用JAXP的输入类型作为REST方法的参数。示例代码如下。

    @POST
    @Path("stream")
    @Consumes(MediaType.APPLICATION_XML)
    @Produces(MediaType.APPLICATION_XML)
    public StreamSource getStreamSource(javax.xml.transform.stream.StreamSource streamSource) {
        return streamSource;
    }

    @POST
    @Path("sax")
    @Consumes(MediaType.APPLICATION_XML)
    @Produces(MediaType.APPLICATION_XML)
    public SAXSource getSAXSource(javax.xml.transform.sax.SAXSource saxSource) {
        return saxSource;
    }

    @POST
    @Path("dom")
    @Consumes(MediaType.APPLICATION_XML)
    @Produces(MediaType.APPLICATION_XML)
    public DOMSource getDOMSource(javax.xml.transform.dom.DOMSource domSource) {
        return domSource;
    }

    @POST
    @Path("doc")
    @Consumes(MediaType.APPLICATION_XML)
    @Produces(MediaType.APPLICATION_XML)
    public Document getDocument(org.w3c.dom.Document document) {
        return document;
    }

在这段代码中,资源方法getStreamSource()使用StAX拉式流解析技术支持输入输出类型为StreamSource的请求。getSAXSource()方法使用SAX是事件驱动的流解析技术支持输入输出类型为SAXSource的请求。getDOMSource()方法和getDocument()方法使用DOM面向文档解析的技术,支持输入输出类型分别为DOMSource和Document的请求。

  1. JAXB标准

JAXP的缺点是需要编码解析XML,这增加了开发成本,但对业务逻辑的实现并没有实质的贡献。JAXB只需要在POJO中定义相关的注解(早期人们使用XML配置文件来做这件事),使其和XML的Schema对应,无须对XML进行程序式解析,弥补了JAXP的这一缺点。

JAXB通过序列化和反序列化实现了XML数据和POJO对象的自动转换过程。在运行时,JAXB通过编组(marshall)过程将POJO序列化成XML格式的数据,通过解编(unmarshall)过程将XML格式的数据反序列化为Java对象。JAXB的注解位于javax.xml.bind.annotation包中。

需要指出的是,从理论上讲,JAXB解析XML的性能不如JAXP,但使用JAXB的开发效率很高。

Jersey支持使用JAXBElement作为REST方法参数的形式,也支持直接使用POJO作为REST方法参数的形式,后一种更为常用,示例代码如下。

    @POST
    @Path("jaxb")
    @Consumes(MediaType.APPLICATION_XML)
    @Produces(MediaType.APPLICATION_XML)
    public Book getEntity(JAXBElement<Book> bookElement) {
        Book book = bookElement.getValue();
        LOGGER.debug(book.getBookName());
        return book;
    }

    @POST
    @Consumes({MediaType.APPLICATION_XML, MediaType.APPLICATION_JSON})
    @Produces(MediaType.APPLICATION_XML)
    public Book getEntity(Book book) {
        LOGGER.debug(book.getBookName());
        return book;
    }

POJO类的字段是作为XML的属性组织起来的,详见如下book实体类的定义。

@XmlRootElement
public class Book implements Serializable {
    @XmlAttribute(name = "bookId")
    public Long getBookId() {
        return bookId;
    }

    @XmlAttribute(name = "bookName")
    public Long getBookName() {
        return bookName;
    }

    @XmlAttribute(name = "publisher")
    public Long getPublisher() {
        return publisher;
    }
}
  1. property和element

本例的POJO类Book的字段都定义为XML的属性(property)来组织,POJO的字段也可以作为元素(element)组织。如何定义通常取决于对接系统的设计。需要注意的是,如果REST请求的传输数据量很大,并且无须和外系统对接的场景,建议使用属性来组织XML,这样可以极大地减少XML格式的数据包的大小。

  1. XML_SECURITY_DISABLE

Jersey默认设置了XMLConstants.FEATURE_SECURE_PROCESSING(http://javax.xml.XMLConstants/feature/secure-processing)属性,当属性或者元素过多时,会报“well-formednesserror”这样的警告信息。如果业务逻辑确实需要设计一个繁琐的POJO,可以通过设置MessageProperties.XML_SECURE_DISABLE参数值为TRUE来屏蔽。

2.3.6 JSON类型

JSON类型已经成为Ajax技术中数据传输的实际标准。Jersey提供了4种处理JSON数据的媒体包。表2-6展示了4种技术对3种解析流派(基于POJO的JSON绑定、基于JAXB的JSON绑定以及低级的(逐字的)JSON解析和处理)的支持情况。MOXy和Jackson的处理方式相同,它们都不支持以JSON对象方式解析JSON数据,而是以绑定方式解析。Jettison支持以JSON对象方式解析数据,同时支持JAXB方式的绑定。JSON-P就只支持JSON对象方式解析这种方式了。

表2-6 Jersey对JSON的处理方式列表

解析方式\JSON支持包 MOXy JSON-P Jackson Jettison
POJO-based JSON Binding
JAXB-based JSON Binding
Low-level JSON parsing & processing
  1. 使用MOXy处理JSON MOXy是EclipseLink项目的一个模块,其官方网站http://www.eclipse.org/eclipselink/moxy.php宣称EclipseLink的MOXy组件是使用JAXB和SDO作为XML绑定的技术基础。MOXy实现了JSR 222标准(JAXB2.2)和JSR 235标准(SDO2.1.1),这使得使用MOXy的Java开发者能够高效地完成Java类和XML的绑定,所要花费的只是使用注解来定义它们之间的对应关系。同时MOXy是下了JSR-353标准(Java API for Processing JSON1.0),以JAXB为基础来实现对JSR353的支持。

  2. 使用JSON-P处理JSON

JSON-P的全称是Java API for JSON Processing(Java的JSON处理API),而不是JSON with padding(JSONP),两者只是名称相仿,用途大相径庭。JSON-P是JSR 353标准规范,用于统一Java处理JSON格式数据的API,其生产和消费的JSON数据以流的形式,类似StAX处理XML,并未JSON数据建立Java对象模型,类似DOM。而JSONP适用于异步请求中传递脚本的回调函数来解决跨域问题。

  1. 使用Jackson处理JSON

Jackson是一种流行的JSON支持技术,其源代码托管于Github,地址是:https://github.com/FasterXML/jackson。Jackson提供了3种JSON解析方式。

  • 第一种是基于流式API的增量式解析/生成JSON的方式,读写JSON内容的过程是通过离散事件触发的,其底层基于StAX API读取JSON使用org.codehaus.jackson.JsonParser,写入JSON使用org.codehaus.jackson.JsonGenerator。
  • 第二种是基于树型结构的内存模型,提供一种不变式的JsonNode内存树模型,类似DOM树。
  • 第三种是基于数据绑定的方式,org.codehaus.jackson.map.ObjectMapper解析,使用JAXB的注解。

下面讲述使用Jackson实现在REST应用中解析JSON的完整过程。

  1. 定义依赖

使用Jackson方式处理JSON类型的数据,需要在项目的Maven配置中声明如下依赖。

<!-- media type -->
<dependency>
    <groupId>org.glassfish.jersey.media</groupId>
    <artifactId>jersey-media-json-jackson</artifactId>
</dependency>
  1. 定义Application

使用Jackson的应用,需要在其Application中注册JacksonFeature。同时,如果有必要根据不同的实体类做详细的解析,可以注册ContextResolver的实现类,示例代码如下。

@ApplicationPath("/api/*")
public class JsonResourceConfig extends ResourceConfig {
    public JsonResourceConfig() {
        register(BookResource.class);
        register(JacksonFeature.class);
        //关注点1:注册ContextProvider的实现类JsonContextProvider
        register(JsonContextProvider.class);
    }
}

在这段代码中,注册了ContextResolver的实现类JsonContextProvider,用于提供JSON数据的上下文,见关注点1。有关ContextResovler详细信息参考3.2节。

  1. 定义POJO

本例定义了3种不同方式的POJO,以演示Jackson处理JSON的多种方式。分别是JsonBook,JsonHybridBook和JsonNoJaxbBook。

第一种方式是禁用JAXB注解的普通的POJO,实例类JsonBook如下。

@XmlRootElement
@XmlType(propOrder = {"bookId", "bookName", "chapters"})
public class JsonBook {
    private String[] chapters;
    private String bookId;
    private String bookName;

    public JsonBook() {
        bookId = "1";
        bookName = "Java Restful Web Services实战";
        chapters = new String[0];
    }

    ...
}

第二种方式是将JAXB的注解和Jackson提供的注解混合使用的POJO,示例类JsonHybridBook如下。

//关注点1:使用JAXB注解
@XmlRootElement
public class JsonHybridBook {
    //关注点2:使用Jackson注解
    @JsonProperty("bookId")
    private String bookId;

    @JsonProperty("bookName")
    private String bookName;

    public JsonHybridBook() {
        bookId = "2";
        bookName = "Java Restful Web Services实战";
    }
}

在这段代码中分别使用了JAXB的注解javax.xml.bind.annotation.XmlRootElement,见关注点1,和Jackson的注解org.codehaus.jackson.annotate.JsonProperty,见关注点2,定义XML根元素和XML属性。

第三种方式是不实用任何注解的POJO,示例类JsonNoJaxbBook如下。

public class JsonNoJaxbBook {
    private String[] chapters;
    private String bookId;
    private String bookName;

    public JsonNoJaxbBook() {
        bookId = "3";
        bookName = "Java Restful Web Services实战";
        chapters = new String[0];
    }

    ...
}

这样的3种POJO如何使用Jackson处理来处理呢?

  1. 定义资源类

资源类BookResource用于演示Jackson对上述3种不同POJO的支持,示例代码如下。

@Path("books")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public class BookResource {
    @Path("/emptybook")
    @GET
//关注点1:支持第一种方式的POJO类型
    public JsonBook getEmptyArrayBook() {
        return new JsonBook();
    }

    @Path("/hybirdbook")
    @GET
//关注点2:支持第二种方式的POJO类型    
    public JsonHybridBook getHybirdBook() {
        return new JsonHybridBook();
    }

    @Path("/nojaxbbook")
    @GET
//关注点3:支持第三种方式的POJO类型  
    public JsonNoJaxbBook getNoJaxbBook() {
        return new JsonNoJaxbBook();
    }

    ...
}

在这段代码中,资源类BookResource定义了路径不同的3个GET方法,返回类型分别对应上述的3种POJO,见关注点1到3。有了这样的资源类,就可以向其发送GET请求,并获取不同类型的JSON数据,以研究Jackson是如何支持者3种POJO的JSON转换。

  1. 上下文解析实现类

JsonContextProvider是ContextResovler(上下文解析器)的实现类,其作用是根据上下文提供的POJO类型,粉笔提供两种解析方式。第一种是默认的方式,第二种是混合使用Jackson和Jaxb。两种解析方式的实例代码如下:

@Provider
public class JsonContextProvider implements ContextResolver<ObjectMapper> {
    final ObjectMapper d;
    final ObjectMapper c;

    public JsonContextProvider() {
        //关注点1:实例化ObjectMapper
        d = createDefaultMapper();
        c = createCombinedMapper();
    }

    private static ObjectMapper createCombinedMapper() {
        return new ObjectMapper()
                .configure(SerializationFeature.WRAP_ROOT_VALUE, true)
                .configure(DeserializationFeature.UNWRAP_ROOT_VALUE, true)
                .setAnnotationIntrospector(createIntrospector());
    }

    private static ObjectMapper createDefaultMapper() {
        ObjectMapper result = new ObjectMapper();
        result.enable(SerializationFeature.INDENT_OUTPUT);
        return result;
    }

    private static AnnotationIntrospector createIntrospector() {
        AnnotationIntrospector p = new JacksonAnnotationIntrospector();
        AnnotationIntrospector s = new JaxbAnnotationIntrospector();
        return AnnotationIntrospector.pair(p, s);
    }

    @Override
    public ObjectMapper getContext(Class<?> type) {
        //关注点2:判断POJO类型返回相应的ObjectMapper实例        
        if (type == JsonHybridBook.class) {
            return c;
        } else {
            return d;
        }
    }
}

在这段代码中,JsonContextProvider定义并实例化了两种类型ObjectMapper,见关注点1;在实现接口方法getContext()中,通过判断当前POJO的类型,返回两种ObjectMapper实例之一,见关注点2。通过这样的实现,当流程获取JSON上下文时,即可使用Jackson依赖包完成对相关POJO的处理。

  1. 单元测试

单元测试类BookResourceTest的目的是对支持上述3种POJO的资源地址发起请求并测试结果,实例如下。

public class BookResourceTest extends JerseyTest {
    private static final Logger LOGGER = Logger.getLogger(BookResourceTest.class);

    @Override
    protected ResourceConfig configure() {
//关注点1:服务器端配置        
        enable(TestProperties.LOG_TRAFFIC);
        enable(TestProperties.DUMP_ENTITY);
        ResourceConfig resourceConfig = new ResourceConfig(BookResource.class);
//关注点2:注册JacksonFeature        
        resourceConfig.register(JacksonFeature.class).register(JsonContextProvider.class);
        return resourceConfig;
    }

    @Override
    protected void configureClient(ClientConfig config) {
//关注点3:注册JacksonFeature        
        config.register(new JacksonFeature()).register(JsonContextProvider.class);
    }

    @Test
//关注点4:测试出参为JsonBook类型的资源方法
    public void testEmptyArray() {
        JsonBook book = target("books").path("emptybook").request(MediaType.APPLICATION_JSON).get(JsonBook.class);
        LOGGER.debug(book);
    }

    @Test
//关注点5:测试出参为JsonHybridBook类型的资源方法
    public void testHybrid() {
        JsonHybridBook book = target("books").path("hybirdbook").request(MediaType.APPLICATION_JSON).get(JsonHybridBook.class);
        LOGGER.debug(book);
    }

    @Test
//关注点6:测试出参为JsonNoJaxbBook类型的资源方法    
    public void testNoJaxb() {
        JsonNoJaxbBook book = target("books").path("nojaxbbook").request(MediaType.APPLICATION_JSON).get(JsonNoJaxbBook.class);
        LOGGER.debug(book);
    }
}

在这段代码中,首先要在服务器端注册支持Jackson功能,见关注点2;同时在客户端也要注册支持Jackson功能并注册JsonContextProvider,见关注点3;该测试类包含了用于测试3种类型POJO的测试用例,见关注点4到6;注意,configure()方法是覆盖测试服务器实例行为,configureClient()方法是覆盖测试客户端实例行为,见关注点1。

我自己尝试了一下这个例子。

  1. 创建一个REST服务的代码框架
mvn archetype:generate -DarchetypeGroupId=org.glassfish.jersey.archetypes -DarchetypeArtifactId=jersey-quickstart-grizzly2 -DarchetypeVersion=2.25.1
[INFO] ----------------------------------------------------------------------------
[INFO] Using following parameters for creating project from Old (1.x) Archetype: jersey-quickstart-g
rizzly2:2.25.1
[INFO] ----------------------------------------------------------------------------
[INFO] Parameter: basedir, Value: D:\22.EMS\Projects
[INFO] Parameter: package, Value: my.restful
[INFO] Parameter: groupId, Value: my.restful
[INFO] Parameter: artifactId, Value: simple-service-jackson
[INFO] Parameter: packageName, Value: my.restful
[INFO] Parameter: version, Value: 1.0-SNAPSHOT
[INFO] project created from Old (1.x) Archetype in dir: D:\22.EMS\Projects\simple-service-jackson
[INFO] ------------------------------------------------------------------------
  1. 定义依赖,修改pom.xml,在项目的Maven配置中声明如下依赖。
<dependency>
    <groupId>org.glassfish.jersey.media</groupId>
    <artifactId>jersey-media-json-jackson</artifactId>
</dependency>
  1. 定义Application

JsonResourceConfig类,跟书上不太一样,只注册了JacksonFeature,没有注册ContextResolver的实现类,packages()从Main挪到了这里,集中一个地方进行初始化。

package my.restful.resource;

import org.glassfish.jersey.jackson.JacksonFeature;
import org.glassfish.jersey.server.ResourceConfig;

import javax.ws.rs.ApplicationPath;

@ApplicationPath("/api/*")
public class JsonResourceConfig extends ResourceConfig{
    public JsonResourceConfig() {
        packages("my.restful");
        register(JacksonFeature.class);
    }
}
  1. 定义POJO

JsonBook类:

package my.restful.jackson;

import javax.xml.bind.annotation.XmlRootElement;
import javax.xml.bind.annotation.XmlType;

@XmlRootElement
@XmlType(propOrder = {"bookId", "bookName", "chapters"})
public class JsonBook {
    private String[] chapters;
    private String bookId;
    private String bookName;

    public JsonBook() {
        bookId = "1";
        bookName = "Java RESTful Web Services in Action 1";
        chapters = new String[0];
    }

    public String[] getChapters() {
        return chapters;
    }

    public void setChapters(String[] chapters) {
        this.chapters = chapters;
    }

    public String getBookId() {
        return bookId;
    }

    public void setBookId(String bookId) {
        this.bookId = bookId;
    }

    public String getBookName() {
        return bookName;
    }

    public void setBookName(String bookName) {
        this.bookName = bookName;
    }
}

JsonHybridBook类:

package my.restful.jackson;

import com.fasterxml.jackson.annotation.JsonProperty;

import javax.xml.bind.annotation.XmlRootElement;

@XmlRootElement
public class JsonHybridBook {
    @JsonProperty("bookId")
    private String bookId;

    @JsonProperty("bookName")
    private String bookName;

    public JsonHybridBook() {
        bookId = "2";
        bookName = "Java RESTful Web Services in Action 2";
    }
}

JsonNoJaxbBook类:

public class JsonNoJaxbBook {
    private String[] chapters;
    private String bookId;
    private String bookName;

    public JsonNoJaxbBook() {
        bookId = "3";
        bookName = "Java RESTful Web Services in Action 3";
        chapters = new String[0];
    }

    public String[] getChapters() {
        return chapters;
    }

    public void setChapters(String[] chapters) {
        this.chapters = chapters;
    }

    public String getBookId() {
        return bookId;
    }

    public void setBookId(String bookId) {
        this.bookId = bookId;
    }

    public String getBookName() {
        return bookName;
    }

    public void setBookName(String bookName) {
        this.bookName = bookName;
    }
}
  1. 定义资源类

BookResouce类:

package my.restful.resource;

import my.restful.jackson.JsonBook;
import my.restful.jackson.JsonHybridBook;
import my.restful.jackson.JsonNoJaxbBook;

import javax.ws.rs.Consumes;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;

@Path("books")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public class BookResource {

    @Path("/emptybook")
    @GET
    public JsonBook getEmptyBook() {
        return new JsonBook();
    }

    @Path("/hybridbook")
    @GET
    public JsonHybridBook getHybridBook() {
        return new JsonHybridBook();
    }

    @Path("/nojaxbbook")
    @GET
    public JsonNoJaxbBook getNoJaxbBook() {
        return new JsonNoJaxbBook();
    }
}
  1. 修改Main

Main类,修改了BASE_URI,在startServer()中初始化JsonResourceConfig。

package my.restful;

import my.restful.resource.JsonResourceConfig;
import org.glassfish.grizzly.http.server.HttpServer;
import org.glassfish.jersey.grizzly2.httpserver.GrizzlyHttpServerFactory;

import java.io.IOException;
import java.net.URI;

/**
 * Main class.
 *
 */
public class Main {
    // Base URI the Grizzly HTTP server will listen on
    public static final String BASE_URI = "http://localhost:8080/api/";

    /**
     * Starts Grizzly HTTP server exposing JAX-RS resources defined in this application.
     * @return Grizzly HTTP server.
     */
    public static HttpServer startServer() {
        // create a resource config that scans for JAX-RS resources and providers
        // in my.restful package
        final JsonResourceConfig jrc = new JsonResourceConfig();

        // create and start a new instance of grizzly http server
        // exposing the Jersey application at BASE_URI
        return GrizzlyHttpServerFactory.createHttpServer(URI.create(BASE_URI), jrc);
    }

    /**
     * Main method.
     * @param args
     * @throws IOException
     */
    public static void main(String[] args) throws IOException {
        final HttpServer server = startServer();
        System.out.println(String.format("Jersey app started with WADL available at "
                + "%sapplication.wadl\nHit enter to stop it...", BASE_URI));
        System.in.read();
        server.stop();
    }
}
  1. 运行服务 执行如下命令构建和启动服务:
cd simple-service-jackson
mvn package
mvn exec:java

...
Jun 08, 2017 10:40:48 AM org.glassfish.grizzly.http.server.NetworkListener start
INFO: Started listener bound to [localhost:8080]
Jun 08, 2017 10:40:48 AM org.glassfish.grizzly.http.server.HttpServer start
INFO: [HttpServer] Started.
Jersey app started with WADL available at http://localhost:8080/api/application.wadl
Hit enter to stop it...
  1. 集成测试

用Postman访问服务 http://localhost:8080/api/books/emptybook

{
  "bookId": "1",
  "bookName": "Java RESTful Web Services in Action 1",
  "chapters": []
}

http://localhost:8080/api/books/hyridbook

{
  "bookId": "2",
  "bookName": "Java RESTful Web Services in Action 2"
}

http://localhost:8080/api/books/nojaxbbook

{
  "chapters": [],
  "bookId": "3",
  "bookName": "Java RESTful Web Services in Action 3"
}
  1. 使用Jettison处理JSON

Jettison是一种使用StAX来解析JSON的实现。项目地址是:http://jettison.codehaus.org。Jettison项目起初用于为CXF提供基于JSON的Web服务,在XStream的Java对象的序列化中也使用了Jettison。Jettison支持两种JSON映射到XML的方式。Jersey默认使用MAPPED方式,另一种叫做BadgerFish方式。

2.4 连通性

REST的一个重要的特性就是连通性。Web Link和HATEOAS以不同方式实现了REST式服务的连通性。

  • Web Link定义在IETF RFC 5988 (Web Linking),是通过在HTTP头中定义链接信息,以描述当前页面与链接页面之间的关系。Web Link是一种过渡型链接(Transitional Links)。JAX-RS 2.0引入了javax.ws.rs.core.Link类,用来处理Web Link的表述。
  • HATEOAS(Hypermedia as the Engine of Application State, 超媒体作为应用程序状态引擎)。HATEOAS的形式是包含链接信息的超媒体文档,HATEOAS的核心是“引擎”,该引擎的目的是通过请求的响应实体将超媒体信息返回给客户端,超媒体信息可以告诉用户,如果接下来选择去往某个链接(或者链接列表中的某个链接),应用的状态就会如超媒体描述的那样发生转变。HATEOAS是一种结构型链接(Structural Links)。Jersey2中可以使用XML实现HATEOAS的结构要求。

2.4.1 过渡型链接

Web Link通过使用HTTP的头信息来传递操作链接,在Jersey中使用javax.ws.rs.core.Link类可以非常简洁地实现支持Web Link的资源类。

@Path("weblink-resource")
public class WebLinkResource {
    @Context
    UriInfo uriInfo;

    @POST
    @Produces(MediaType.APPLICATION_XML)
    @Consumes({MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML, MediaType.TEXT_XML})
    public Response saveBook(final Book book) {
        final long newId = System.nanoTime();
        book.setBookId(newId);
        LinkCache.map.put(newId, book);
        /**
         UriInfo的 resolve(java.net.URI)使用应用上下文将相对路径转换成绝对路径
         UriInfo.relativize(java.net.URI)将绝对路径转换成相对路径
         UriBuilder 相对路径模板
         **/
//关注点1:通过UriInfo实例获取资源路径         
        final UriBuilder ub = uriInfo.getAbsolutePathBuilder();
        final URI location = ub.path("" + newId).build();

//关注点2:通过模板获取资源路径
        final String uriTemplate = "http://{host}:{port}/{path}/{param}";
        final URI location2 = UriBuilder.fromUri(uriTemplate).resolveTemplate("host", "localhost").resolveTemplate("port", "9998")
                .resolveTemplate("path", "weblink-resource").resolveTemplate("param", newId).build();

//关注点3:通过模板方法获取资源路径
        final UriBuilder ub3 = uriInfo.getAbsolutePathBuilder();
        final URI location3 = ub3.scheme("http").host("localhost").port(9998).path("weblink-resource").path("" + newId).build();

//关注点4:为响应实例添加路径信息
        return Response.created(location).link(location2, "view1").link(location3, "view2").entity(book).build();
    }
}    

在这段代码中,使用了3种方式构建URI实例。第一种方式是通过调用UriInfo实例的getAbsolutePathBuilder()方法可以获取当前请求的绝对路径,然后基于此路径添加资源id信息,见关注点1;第二种方式式为UriBuilder提供路径模板,然后链式调用resolveTemplate()方法传递并解析模板参数,最后通过UriBuilder的build方法生成URI实例,见关注点2;第三种方式和第二种类似,不同的是模板信息被具体方法替代。最后,这3个与Link相关的URI实例由Response构建,作为返回值响应给客户端,见关注点4。

2.4.2 结构型链接

HATEOAS用以代替聚集数据并避免描述膨胀,通常使用Atom格式在实体字段中提供链接信息。本来使用XML格式来支持HATEOAS,折中的设计是在POJO中额外定义一个链接字段。支持HATEOAS的资源类示例如下。

@Path("hateoas-resource")
public class HATEOASResource {
    @Context
    UriInfo uriInfo;

    @POST
    @Produces({MediaType.APPLICATION_XML})
    @Consumes({MediaType.APPLICATION_XML})
    public BookWrapper saveBook(final Book book) {
        final long newId = System.nanoTime();
        book.setBookId(newId);
        LinkCache.map.put(newId, book);
//关注点1:通过UriInfo实例获取资源路径
        final UriBuilder ub = uriInfo.getAbsolutePathBuilder();
        final URI uri = ub.path("" + newId).build();
        BookWrapper b = new BookWrapper();
        b.setBook(book);
//关注点2:将资源路径赋给资源实体
        b.setLink(uri.toString());
        return b;
    }
}    

在这段代码中,URI实例由上下文UriInfo中获取的绝对路径和资源ID组成,见关注点1;该链接信息被赋值到POJO实例的link属性中,以实现HATEOAS,见关注点2。

【REST连通性的实践手段非常多,推荐从成熟的产品中学习设计。如果有可能,这里推荐Jenkins和RallyDev两个敏捷开发中常用的平台,它们提供了比较舒适的连通性实践。】

2.5 处理响应

REST的响应处理结果应高阔响应头中的HTTP状态码,响应实体中媒体参数类型和返回值类型,以及异常情况处理。JAX-RS2支持4种返回值类型的响应,分别是无返回值、返回Response类实例、返回GenericEntity类实例和返回自定义类实例。

2.5.1 返回类型

  1. void

在返回值类型是void的响应中,其响应实体为空,HTTP状态码为204。

@DELETE
@Path("{s}")
//关注点1:无返回值的DELETE方法
public void delete(@PathParam("s") final String s) {
    LOGGER.debug(s);
}

因为delete操作无须返回更多的关于资源表述的信息,因此该方法没有返回值,即返回值类型为void,见关注点1。

  1. Response

在返回值类型为Response的响应中,响应实体为Response类的entity()方法定义的实体类实例。如果该内容为空,则HTTP状态码为204,否则HTTP状态为200 OK。

@POST
@Path("c")
public Response get(final String s) {
    LOGGER.debug(s);
    //Response.noContent().build();
//关注点1:构建无返回值的响应实例
    return Response.ok().entity("char[]:" + s).build();
}

在这段代码中,Response首先定义了HTTP的状态码为ok,然后填充实体信息,最后调用build()方法构建Response实例,见关注点1。

  1. GenericEntity

通用实体类型作为返回值的情况并不常用。其形式是构造一个统一的实体实例并将其返回,实体实例作为第一个参数、该实体类型作为第二个参数。

@POST
@Path("b")
public Response get(final byte[] bs) {
    for (final byte b : bs) {
        LOGGER.debug(b);    
    }    
    return "byte[]:" + new String(bs);
}

public GenericEntity<String> get(final byte[] bs) {
    for (final byte b : bs) {
        LOGGER.debug(b);    
    }
//关注点1:构建GenericEntity实例
    return new GenericEntity<String>("byte[]:" + new String(bs), String.class);
}

在这段代码中,GenericEntity的第一个参数是由byte数组实例作为参数构建的字符串实例,第二个参数是字符串类,见关注点1。

  1. 自定义类型

JDK中的类(比如File、String等)都可以作为返回值类型,更常用的是返回自定义的POJO类型。

@POST
@Path("f")
//关注点1:GET方法的返回类型为File
public File get(final File f) throws FileNotFoundException, IOException {
    try (BufferedReader br = new BufferedReader(new FileReader(f))) {
        String s;
        do {
            s = br.readLine();
            LOGGER.debug(s);
        } while (s != null);
        return f;
    }
}

@POST
@Consumes({MediaType.APPLICATION_XML, MediaType.APPLICATION_JSON})
@Produces(MediaType.APPLICATION_XML)
//关注点2:POST方法的返回值是自定义类Book
public Book getEntity(Book book) {
    LOGGER.debug(book.getBookName());
    return book;
}

@POST
@Consumes({MediaType.APPLICATION_XML, MediaType.APPLICATION_JSON})
@Produces(MediaType.APPLICATION_XML)
//关注点3:POST方法的返回值是自定义类Book
public Book getEntity(JAXBElement<Book> bookElement) {
    Book book = bookElement.getValue();
    LOGGER.debug(book.getBookName());
    return book;
}

在这段代码中,返回值类型有来自JDK的File类型,见关注点1,也有自定义的POJO类型,见关注点2和关注点3。

2.5.2 处理异常

实现REST的资源方法时应使其具有良好的异常处理能力,这包括异常的定义和错误状态码的正确返回。

  1. 处理状态码

首先通过表2-7了解下REST中常用的HTTP状态码,应当在处理异常的同时,为REST请求的客户端提供对应的错误码。

表2-7 HTTP常用状态码列表

状态码 含义
200 OK 服务器正常响应
201 Created 创建新实体,响应头Location指定访问该实体的URL
202 Accepted 服务器接受请求,处理尚未完成。可用于异步处理机制
204 No Content 服务器正常响应,但响应实体为空
301 Moved Permanently 请求资源的地址发生永久变动,响应头Location指定新的URL
302 Found 请求资源的地址发生临时变动
304 Not Modified 客户端缓存资源依然有效
400 Bad Request 请求信息出现语法错误
401 Unauthorized 请求资源无法授权给未验证用户
403 Forbidden 请求资源未授权当前用户
404 Not Found 请求资源不存在
405 Method Not Allowed 请求方法不匹配
406 Not Acceptable 请求资源的媒体类型不匹配
500 Internal Server Error 服务器内部错误,意外终止响应
501 Not Implemented 服务器不支持当前请求

JAX-RS2规定的REST式的Web服务的基本异常类型为运行时异常WebApplicationException类。该类包含3个主要的子类分别对应如下内容:

  • HTTP状态码为3xx的重定向类RedirectionException;
  • HTTP状态码为4xx的请求错误类ClientErrorException;
  • HTTP状态码为5xx的服务器错误类ServerErrorException。

它们各自的子类对照HTTP状态码再细分,比如常见的HTTP状态码404错误,对应的错误类为NotFoundException。

除了Jersey提供的标准异常类型,我们也可以根据业务需要自定义相关的业务异常类,实例如下。

//关注点1:定义WebApplicationException接口实现类
public class Jaxrs2GuideNotFoundException extends WebApplicationException {
//关注点2:定义HTTP状态
    public Jaxrs2GuideNotFoundException() {
        super(javax.ws.rs.core.Response.Status.NOT_FOUND);
    }

    public Jaxrs2GuideNotFoundException(String message) {
        super(message);
    }
}

在这段代码中,Jaxrs2GuideNotFoundException类继承自JAX-RS2的WebApplicationException类,见关注点1。其默认构造子提供了HTTP状态码,其值为Response.Status.NOT_FOUND,见关注点2。

  1. ExceptionMapper

Jersey框架为我们提供了更为通用的异常处理方式。通过实现ExceptionMapper接口并使用@Provider注解将其定义为一个Provider,可以实现通用的异常的面向切面处理,而非针对某一个资源方法的异常处理,示例如下。

@Provider
public class EntityNotFoundMapper implements ExceptionMapper<Jaxrs2GuideNotFoundException> {
//关注点1:定义ExceptionMapper接口实现类
    @Override
    public Response toResponse(final Jaxrs2GuideNotFoundException ex) {
//关注点2:拦截并返回新的响应实例        
        return Response.status(404).entity(ex.getMessage()).type("text/plain").build();
    }
}

在这段代码中,EntityNotFoundMapper实现了ExceptionMapper接口,并提供了泛型类型为前面刚定义的Jaxrs2GuideNotFoundException类,见关注点1;当响应中发生了Jaxrs2GuideNotFoundException类型的异常,响应流程就会被拦截并补充HTTP状态码和异常消息,以文本作为媒体格式返回给客户端,见关注点2。

2.6 内容协商

一个资源可以有不同格式的表述,表述(即响应实体)的内容是人类可识别的信息,服务器很难使用一种表述来适应所有用户。conneg(HTTP Content Negotiation,内容协商)是指在服务器提供的多种表述中,为特定的请求选择最好的一种表述的处理过程。那么什么是最好,又怎样做到最好呢?服务器和客户端/浏览器之间的往复通信来协商用于交换数据的内容格式等信息,达成一致即为最好。内容协商定义在RFC2616的第12节。

客户端/浏览器通过使用HTTP Accept、Accept-Charset、Accept-Language和Accept-Encoding头来定义接收头的信息,将其所期待的格式或MIME类型告知服务器,服务器根据协商算法,返回客户端/浏览器可接受的数据信息。内容协商不只是数据格式协商,还包括语言、编码、字符集等信息。Accept用于数据类型协商;Accept-Language用于语言协商;Accept-Charset用于字符集协商;Accept-Encoding用于压缩算法协商。

JAX-RS2对内容协商的支持,是通过@Produces实现的,其他协商没有从架构上提供支持,可以通过编码从请求头中获取信息并处理。

2.6.1 @Produces注解

注解@Produces用于定义方法的响应实体的书类型,可以定义一个或多个,同时可以为每种类型定义质量因素(qualityfactor)。质量因素是取值范围从0到1的小数值。如果不定义质量因素,那么该类型的质量因素默认为1。示例代码如下。

@Path("conneg-resource")
public class ConnegResource {
    @GET
    @Path("{id}")
//关注点1:媒体类型为XML    
    @Produces(MediaType.APPLICATION_XML)
    public Book getJaxbBook(@PathParam("id") final Long bookId) {
        return new Book(bookId);
    }


    @GET
    @Path("{id}")
//关注点1:媒体类型为JSON    
    @Produces(MediaType.APPLICATION_JSON)
    public Book getJsonBook(@PathParam("id") final Long bookId) {
        return new Book(bookId);
    }

    ...
}    

在这段代码中,getJaxbBook()和getJsonBook()是同等质量因素、资源地址相同的两个GET方法,一个定义响应实体格式为XML,一个定义响应实体格式为JSON,见关注点1和2。那么对同一个资源的访问,JAX-RS2改如何选择处理方法呢?如果请求中明确定义可接受的数据类型为两者之一,处理方法应该是定义相应数据类型的方法。如果两者都定义了,处理方法应该是质量因素高的方法。如果两者都定义,而且数据类型的质量因素是相等的或者没有定义Accept,XML的方法会被优先选择。

现在我们清楚了两个同等方法的场景,再来看一个方法中多种数据类型的场景。示例代码如下:

@GET
@Produces({"application/json; qs=.9", "application/xml; qs=.5"})
@Path("book/{id}")
public Book getBook(@PathParam("id") final Long bookId) {
    return new Book(bookId);
}

在这段代码中,getBook()定义了XMl和JSON两种表述类型,XML的质量因素是0.5(0可以省略),JSON的是0.9。

因此,可以推断,如果客户端请求中,明确接收的数据类型是两者之一,响应实体使用指定类型。如果没有定义或者两者都定义且JSON的质量因素大于或者等于XML,则返回JSON类型。还有一种用例是,两者都定义但JSON的质量因素小于XML,该如何处理请求方法呢?答案是:内容协商的结果按照客户端的喜好选择响应实体的数据类型,即选择XML格式。

2.6.2 @Consumes注解

注解@Consumes用于定义方法的请求实体的数据类型,和@Produces不同的是,@Consumes的数据类型的定义只用于JAX-RS2匹配请求处理的方法,不做内容协商使用。如果匹配不到,服务器会返回HTTP状态码415(Unsupported Media Type)。示例代码如下。

@POST
//关注点1:@Consumes注解定义了XML和JSON两种格式
@Consumes({MediaType.APPLICATION_XML, MediaType.APPLICATION_JSON})
@Produces(MediaType.APPLICATION_XML)
public Book getEntity(Book book) {
    LOGGER.debug(book.getBookName());
    return book;
}

final Builder request = target(path).request();
//关注点2
final Book result = request.post(Entity.entity(book, MediaType.APPLICATION_XML, Book.class));

在这段代码中,getEntity()方法定义了@Consumes媒体类型为XML格式和JSON格式,见关注点1;那么,在客户端请求中,如果请求实体的数据类型定义时两者之一,该方法会被选择为处理请求的方法,否则查找是否有定义为相应数据类型的方法,如果没有抛出javax.ws.rs.NotSupportedException异常,则使用该方法处理请求,见关注点2。

第3章 REST请求处理

设计良好的REST API除了要符合关于统一接口和资源定位等要求,还要详细考虑通用的请求处理过程中每个步骤的特殊处理,并设计出符合业务规范的处理流程。

3.1 Jersey的AOP机制

AOP对增强REST服务的功能性、安全性和可扩展性等方面都具有深远意义,因此,完整的REST风格的框架都从容器级别支持AOP功能。Jersey自身支持AOP,可以不依赖于Spring等支持AOP的框架。

【AOP(Aspect Oriented Programming,面向切面编程)的典型应用场景有权限管理、日志记录、统计记录、事务以及异常处理等。其实现原理是代理被调用的方法,在其被执行的方法前后,增加额外业务功能。AOP的实现机制是通过注解或者XML配置,一句这些配置,动态生成字节码(bytecode),使被调用代码对应的字节码被环绕注入新的功能;或者使用Java的动态代理机制,完成对被调用方法的增强。】

Jersey提供的REST过滤器和拦截器为开发者提供了很贴心的切面扩展点,开发者无须像在Spring中为了针对某个类的方法进行AOP扩展而写配置文件。在Jersey中只要实现相应扩展点的接口,即可实现REST请求流程中特定事件点的拦截、扩展,其他工作由底层的HK2帮我们做。典型的应用包括请求和响应的过滤和读写拦截。

3.2 Providers详解

在2.3节我们讲述了Jersey支持的各种传输格式。Jersey之所以可以支持那么多种表述的类型,即响应实体的传输格式,是因为其底层实体Providers具备的对不同格式的处理能力。Jersey内部提供了非常丰富的MessageBodyReader接口和MessageBodyWriter接口实现类,用于处理不同格式的表述,比如字节数组、XML、文件和流等。

  1. MessageBodyReader

消息体读处理器接口MessageBodyReader用于将传输流转换为Java类型的对象。MessageBodyReader接口定义了一个泛型,接口的实现类为这个泛型定义一个具体类型,该类型即是该实现类所支持的转换类型。实现类被业务系统启用有两种方式。一是使用注解@Provider定义实现类,业务系统在启动时自动探测并加载。另一种方式式通过编码注册到Application类或其子类中,业务系统在启动时,加载Application类或其子类时一并加载。

MessageBodyReader接口定义了两个方法。第一个方法isReadable()是用来判断实现类是否支持将当前请求的数据类型反序列化。第二个方法readFrom()是用于处理反序列化,是处理读取流并转换为Java类型对象的核心方法。

  1. MessageBodyReader

消息体写处理器接口MessageBodyWriter用于将Java类型的对象转换为流,它是序列化的过程和MessageBodyReader接口实现的反序列化过程的逆过程。两个接口的设计原理是相同的。对应地,MessageBodyWriter定义了两个方法isWriteable()和writeTo()。其实,解析一种传输类型的Provider类通常会同时实现MessageBodyReader和MessageBodyWriter这两个接口。

  1. MessageBodyWorkers

实体读写接口的实现类非常多,编写选择哪个实现类作为当前请求的读写处理器的算法是非常繁重的工作,MessageBodyWorkers接口旨在抽象这一遴选工作,其实现类可以通过@Context依赖注入到使用MessageBodyWorkers的类中。MessageBodyFactory是MessageBodyWorkers接口的实现类。

【实体读写接口定义在JAX-RS2的javax.ws.rs.ext包中,MessageBodyWorkers定义在Jersey核心包Jersey-Common的org.glassfish.jersey.message包中,前者是规范定义的接口,后者是参考实现定义的借口。不要混淆。】

3.2.2 上下文Providers

除了处理实体的Provider,处理上下文的Provider也非常重要。ContextResolver接口适用于提供资源类和其他Provider上下文信息的即可欧。ContextResolver定义了一个方法getContext(),输入参数是表述对象的类型,输出是上下文泛型。

除了上述两种类型的Provider,JAX-RS2还定义了处理异常的Providr,请参考2.5.2。

3.3 REST请求流程

继续讲述两种在面向切面编程中非常重要的特殊Provider:过滤器和拦截器。在进入这两个主题之前,我们需要对REST请求处理的流程这条线有明确的认识,才会知道这些点都处于流程中的什么位置。只有这样才能清楚地实现对扩展点的开发和调试。

请求流程中存在3种角色,分别是用户、REST客户端和REST服务器。请求始于请求的发送,止于调用Response类的readEntity()方法,获取响应实体。

  1. 用户提交请求数据,客户端接收请求,进入第一个扩展点:“客户端请求过滤器ClientRequestFilter实现类”的filter()方法。

  2. 请求过滤处理完毕后,流程进入第二个扩展点:“客户端写拦截器WriterInterceptor实现类”的aroundWriteTo()方法,实现对客户端序列化操作的拦截。

  3. “客户端消息体写处理器MessageBodyWriter”执行序列化,流程从客户端过渡到服务器端。

  4. 服务器接收请求,流程进入第三个扩展点:“服务器前置请求过滤器ContainerRequestFilter实现类”的filter()方法。

  5. 过滤处理完毕后,服务器根据请求匹配资源方法,如果匹配到相应的资源方法,流程进入第四个扩展点:“服务器后置请求过滤器ContainerRequestFilter实现类”的filter()方法。

  6. 后置请求过滤处理完毕后,流程进入第五个扩展点:“服务器读拦截器ReaderInterceptor实现类”的aroundReadFrom()方法,拦截服务器反序列化操作。

  7. “服务器消息体读处理器MessageBodyReader”完成对客户端数据流的反序列化。服务器执行匹配的资源方法。

  8. REST请求资源的处理完毕后,流程进入第六个扩展点:“服务器响应过滤器ContainerResponseFilter实现类”的filter()方法。

  9. 过滤处理完毕后,流程进入第七个扩展点:“服务器写拦截器WriterInterceptor实现类”的aroundWriteTo()方法,对服务器端序列化到客户端这个操作的拦截。

  10. “服务器消息写处理器MessageBodyWriter”执行序列化,流程返回到客户端一侧。

  11. 客户端接收响应,流程进入第八个扩展点:“客户端响应过滤器ClientResponseFilter实现类”的filter()方法。

  12. 过滤处理完毕后,客户端响应实例response返回到用户一侧,用户执行response.readEntity()流程进入第九个扩展点:“客户端读拦截器ReaderInterceptor实现类”的aroundReadFilter()方法,对客户端反序列化进行拦截。

  13. “客户端消息体读处理器MessageBodyReader”执行反序列化,将Java类型的对象最终作为readEntity()方法的返回值。到此,一次REST请求处理的完成流程完毕。这期间,如果出现异常或者资源不匹配等情况,会从出错点开始结束流程。

3.4 REST过滤器

从上一节的流程讲述中,我们了解JAX-RS2定义的4种过滤器扩展点(Extension Point)接口,供开发者实现业务逻辑,按请求处理流程的先后顺序为:客户端请求过滤器(ClientRequestFilter)->服务器请求过滤器(ContainerRequestFilter)->服务器响应过滤器(ContainerResponseFilter)->客户端响应过滤器(ClientResponseFilter)。

3.4.1 ClientRequestFilter

客户端请求过滤器(ClientRequestFilter)定义的过滤方法filter()包含一个输入参,是客户端请求的上下文类ClientRequestContext。从该上下文中可以获取请求信息,典型的示例包括获取请求方法context.getMethod(),获取请求资源地址context.getUri()和获取请求头信息context.getHeaders()等。过滤器的实现类中可以利用这些信息,覆写该方法以实现该类特有的过滤功能。

3.4.2 ContainerRequestFilter

针对过滤切面,服务器请求过滤器接口ContainerRequestFilter的实现类可以定义为预处理和后处理。默认情况下,采用后处理方式。即先执行容器接收操作请求,当服务器接收并处理请求后,流程才进入过滤器实现类的filter()方法。而预处理是在服务器处理接收到的请求之前就执行过滤。如果希望实现一个预处理的过滤器实现类,需要在类名上定义注解@PreMatching。

服务器请求过滤器定义的过滤方法filter()包含一个输入参数,即容器请求上下文类ContainerRequestContext。

3.4.3 ContainerResponseFilter

服务器响应过滤器接口ContainerResponseFilter定义的过滤方法filter()包含两个输入参数,一个是容器请求上下文类ContainerRequestContext,另一个是容器响应上下文磊ContainerResponseContext。

3.4.4 ClientResponseFilter

客户端响应过滤器(ClientResponseFilter)定义的过滤方法filter()包含两个输入参数,一个是客户端请求的上下文类ClientRequestContext,另一个是客户端响应的上下文磊ClientResponseContext。

3.5 REST拦截器

拦截器和过滤器的相同点都是一种在请求——响应模型中,用做切面处理的Provider。两者的不同除了功能性上的差异(一个用于过滤消息,一个用于拦截处理)之外,形式上也不同。拦截器通常读写成对,而且没有服务器端和客户端的区分。

  1. ReaderInterceptor

读拦截器接口ReaderInterceptor定义的拦截方法是aroundReadFrom(),该方法包含一个输入参数,即读拦截器的上下文接口ReaderInterceptorContext,从中可以获取头信息、输入流以及父接口InterceptorContext提供的媒体类型等上下文信息。

  1. WriterInterceptor

写拦截器接口WriterInterceptor定义的拦截方法是aroundWriteTo(),该方法包含一个输入参数,写拦截器上下文接口WriterInterceptorContext,从中可以获取头信息、输出流以及父接口InterceptorContext提供的媒体类型等上下文信息。

  1. 编解码约束拦截器

编解码约束拦截器类ContentEncoder是一个位于org.glassfish.jersey.spi包中的烂机器,SPI包下的工具是可插拔的。ContentEncoder拦截器用于约束序列化和反序列化的过程中,编解码的内容协商。

3.6 绑定机制

在了解了面向切面的Providers的功能后,需要掌握它们是如何加载的,以及其作用域。这些容器级别的Providers,通常使用编码的方式注册到Application中,但这不是唯一的办法。本节将详细讨论Providers的绑定机制。

默认情况下,过滤器和拦截器都是全局绑定的。也就是说,如下之一的过滤器或拦截器是全局有效的。

  • 通过手动注册到Application或Configuration;
  • 注解为@Provider,被自动检测。

下面介绍其他的绑定机制。

3.6.1 名称绑定

过滤器或拦截器可以使用特定的注解来指定其作用范围,这种特定的注解被称为名称绑定。

  1. 名称绑定注解

使用@NamingBinding注解可以定义一个运行时的自定义注解,该注解用于定义类级别名称和类的方法名。代码示例如下。

@NameBinding
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(value = RetentionPolicy.RUNTIME)
public @interface AirLog {

}

在这段代码中,自定义注解AirLog使用了@NamingBinding,在运行时该注解将被解析为一个名称绑定的注解。

  1. 绑定Provider

在定义了@AirLog注解后,即可以在Provider中使用该注解。示例代码如下。

//关注点1:使用自定义注解@AirLog
@AirLog
@Priority(Priorities.USER)
public class AirNameBindingFilter implements ContainerRequestFilter, ContainerResponseFilter {
    private static final Logger LOGGER = Logger.getLogger(AirNameBindingFilter.class);

    public AirNameBindingFilter() {
        LOGGER.info("Air-NameBinding-Filter initialized");
    }

    @Override
//关注点2:filter实现访问日志
    public void filter(final ContainerRequestContext containerRequest) throws IOException {
        LOGGER.debug("Air-NameBinding-ContainerRequestFilter invoked:" + containerRequest.getMethod());
        LOGGER.debug(containerRequest.getUriInfo().getRequestUri());
    }

    @Override
//关注点3:filter实现访问日志    
    public void filter(ContainerRequestContext containerRequest, ContainerResponseContext responseContext) throws IOException {
        LOGGER.debug("Air-NameBinding-ContainerResponseFilter invoked:" + containerRequest.getMethod());
        LOGGER.debug("status=" + responseContext.getStatus());
    }
}

在这段代码中,过滤器类AirNameBindingFitler使用了自定义注解@AirLog,这样AirNameBindingFilter类就实现了名称绑定,见关注点1。该类实现了容器的请求和响应过滤器接口,功能是记录访问日志,见关注点2。

  1. 绑定方法

接下来,我们在资源方法级别使用自定义注解@AirLog,来实现在资源类的指定方法上启用AirNameBindingFilter过滤器。示例代码如下。

@Path("books")
public class BookResource {
//关注点1:绑定方法    
    @AirLog
    @GET
    @Produces({MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML})
    public Books getBooks() {
        ...
        return books;
    }
    ...
}

在这段代码中,资源类BookResource包含多个方法,我们只在getBooks()方法上使用了注解@AirLog,而其他方法并没有绑定,见关注点1。

只有使用注解@AirLog定义的方法,才会在请求流程中启用响应的Provider。

3.6.2 动态绑定

名称绑定需要通过自定义的注解名称来绑定Provider和扩展点方法或者类,相比而言,动态绑定无须新增注解,而是使用编码的方式,实现动态特征接口javax.ws.rs.container.DynamicFeature,定义扩展点方法的名称、请求方法类型等匹配信息。在运行期,一旦Provider匹配当前处理类或方法,面向切面的Provider方法即被触发。

  1. 定义绑定Provider

AirDynamicFeature类实现了DynamicFeature接口,示例代码如下:

public class AirDynamicFeature implements DynamicFeature {

    @Override
    public void configure(final ResourceInfo resourceInfo, final FeatureContext context) {
        boolean classMatched = BookResource.class.isAssignableFrom(resourceInfo.getResourceClass());
        boolean methodNameMatched = resourceInfo.getResourceMethod().getName().contains("getBookBy");
        boolean methodTypeMatched = resourceInfo.getResourceMethod().isAnnotationPresent(POST.class);
//关注点1:匹配成功才注册AirDynamicBindingFilter     
        if (classMatched && (methodNameMatched || methodTypeMatched)) {
            context.register(AirDynamicBindingFilter.class);
        }
    }
}

public class AirDynamicBindingFilter implements ContainerRequestFilter {
    ...

    @Override
    public void filter(final ContainerRequestContext requestContext) throws IOException {
        AirDynamicBindingFilter.LOGGER.debug("Air-Dynamic-Binding-Filter invoked");
    }

    ...
}

在这段代码中,在AirDynamicFeature的配置方法中,启用了如下匹配规则:

  1. 类匹配:对BookResource类及其子类的匹配。

  2. 方法名称匹配:方法名包含getBookBy的匹配。

  3. 请求方法类型匹配:与POST方法的匹配。

只有当匹配成功时,才会注册AirDynamicBindingFilter。对于Provider的实现类,并没有特殊的要求。

3.7 优先级

不同扩展点的Provider在请求处理流程中的顺序在3.3节已经阐述,对于同一个扩展点的国歌Provider的执行的先后顺序是靠优先级排序的。优先级的定义使用注解@Priority,优先级的值是一个整型值,常量定义在javax.ws.rs.Priorities类中。对于ContainerRequest、PreMatchContainerRequest、ClientRequest和读写拦截器该数值采用升序策略,即数值越小优先级越高;对于ContainterResponse和ClientResponse该数值采用降序策略,即数越大优先级越高。示例代码如下所示:

@Priority(Priorites.USER)
public class AirNameBindingFilter {}
@Priority(Priorites.USER + 1)
public class AirNameBindingFilter2 {}

在这段代码中,AirNameBindingFilter2的Priority数值大于AirNameBindingFilter。在请求过程中,AirNameBindingFilter优先执行;在响应过程中,AirNameBindingFilter2被优先执行。

第4章 REST服务与异步

4.1 为什么使用异步机制

启用异步机制的前提是同步运行时资源存在空置。接下来,从服务器和客户端两个角度来思考引入异步要解决的问题以及如何来解决。

4.1.1 服务器一步机制

服务器端使用异步机制的主要目的是将“处理连接”与“处理请求”解耦。

对于服务器而言,如果处理连接的线程被一个需要较长时间才能处理完毕的任务阻塞,那么服务器处理连接的能力就会下降,而此时服务器的资源很有可能是空闲的。此时,我们考虑将处理连接和处理请求任务解耦,处理连接的线程接收请求后,将其分派给处理请求任务的线程。这样一来,即使任务需要较长时间完成,处理连接的线程也无需阻塞等待了,服务器因此可以重用连接线程,从而提供更高的吞吐率。处理请求的线程相对于处理连接的线程是异步执行的,当任务结束后,服务器会从上下文中找到当前连接,并将处理结果返回,作为该连接请求的响应。

我们进一步来看这个场景。处理任务的线程在任务处理过程中是同步阻塞的,如果任务是可分解的,那么我们可以考虑使用多线程来同时分别处理分解后的任务,最后将其合并。这样一来,处理任务的线程在任务处理过程中,就处于异步阻塞状态,其所用时间由分解任务中最长的子任务处理时间决定。

从这个角度上看,异步设计的确可以提高服务器的性能。但是,并非异步就一定能提高性能。因为,异步可以提高性能的前提如下。

  • 任务执行的时间要远大于创建异步线程所消耗的时间。
  • 服务器资源在同步阻塞情况下是有资源空闲的。

4.1.2 客户端异步机制

对于客户端而言,无论服务器是同步还是异步,如果客户端需要等待请求响应后,才处理其他事情(比如浏览器的行为是渲染HTML页面),那么客户端的行为都是同步且阻塞的。

为了让客户端的流程不受服务器处理过程的阻塞,可以在客户端启用异步机制。在请求发出前,先注册事件通知(使用观察者模式实现的回调机制),请求发出后,流程继续执行而不等待。当响应到达后,客户端处理响应信息,更新状态。

接下来,我们将详述在JAX-RS2中如何实现异步服务和异步请求。

4.2 JAX-RS2的异步机制

在Java领域,Java语言的并发处理,Java SE中5.0是一个里程碑;而Java EE的并发支持,Java EE 7.0是一个重要的版本。容器级别上有了对并发的支持,客户端等待服务器的响应就可以由一个Future实现,感觉上就像Java SE开发中,等待同一个JVM的另一个线程一样。在JAX-RS2的异步实现过程中,线程是由容器管理的,这是Java EE 7中JSR235规范定义的功能。

4.2.1 服务端实现

对于JAX-RS的服务器端,实现异步主要包括两个技术点:一个是资源方法中对AsyncResponse的使用,另一个是对异步机制中CompletionCallback和TimeoutHandler接口的实现。

4.2.2 客户端实现和测试

相应地,JAX-RS为客户端提供了用于执行异步请求的API。开发者使用这套API可以轻松地实现对服务器端的异步请求。

4.3 基于HTTP1.1的异步通信

本节从服务器和客户端的通信角度,全面介绍基于HTTP1.1协议的异步通信方案。

4.3.1 Polling技术

服务器——浏览器通信技术的第一种解决方案是客户端轮询技术,即Polling。

  1. 简述

客户端轮询技术(Polling)相对其他方案,是最原始、易行的。即浏览器周期性地主动访问服务器的特定地址,以获取服务器端数据状态的变化。通常,在浏览器端使用JavaScript脚本启动一个定时任务,该任务向服务器发送请求并获取资源状态。如果服务器端特定数据发生变化,会将变化信息响应给客户端,客户端使用响应的数据渲染界面,为用户做出及时的反馈。

Polling异步通信方案可以结合HATEOAS和Web Link技术,将服务器端的轮询状态地址返回给客户端,服务器端会在接收请求后立即(以HATEOAS或者WebLink技术)返回给客户端一个查询处理结果的资源地址,并结束这一次的请求——响应流程,HTTP连接关闭,HTTP状态码为202(注意:不是HTTP状态200 OK,202代表服务器已接收请求但尚未处理)。客户端通过轮询机制,向新的REST地址发起请求并获得该处理的进度状态(完成状态为HTTP状态码100,如果请求过期或者资源地址错误则HTTP状态为404即找不到),并最终在获取处理完信息后结束轮询。

  1. 优点与缺点

优点:这种解决方案比起同步处理的优点是客户端可以即时得到服务器的反馈,并在获得最终结果之前,有机会处理后续业务。另外,Polling技术不需要对服务器和客户端使用额外的第三方支持包,开发者容易使用现有技术和工具就可以实现。客户端轮询技术对设计没有注入性污染。选择技术架构和设计、实现业务逻辑时,这种方式可以即查即拔,不会污染业务平台中结构性的代码。

缺点:客户端轮询技术的缺点是显而易见的,轮询中的每次请求——响应的过程都需要建立新的HTTP连接并在结束时关闭该链接。这就造成两大问题。

第一,如果服务器端的业务数据在两次定时任务发起的请求过程中没有变化,后一次请求的做功实际为负数,也就浪费了服务器端的带宽,没有获得有效负载。

第二,也是最让开发者纠结的痛点,即浏览器端的定时器间隔时间参数的设置。由于需要及时获取服务器端的业务数据的状态,这个定时间隔参数设置不宜过长,但是果断优惠经常发生第一个问题。因此,间隔时间的设置时隔尴尬的坑,因为在编码和调试阶段定义并运行完好的参数,很难和生产环境吻合,甚至开发阶段有可能疏漏或无法覆盖到全部生产环境中的业务场景。还有一个让人难受的地方是这种请求的代码很难抽象出来,因为不同业务的定时间隔都是一个独立的经验值。

4.3.2 Comet技术

Comet是反向Ajax的技术集,包括长轮询(Long Polling)和流(Stream)两种实现。

  1. 简述

什么是反向Ajax呢?要了解反向Ajax,我们先从Ajax说起。

Ajax技术是指从浏览器端想服务器端发起的异步请求。前面所述的Polling就是Ajax的实践。概括地说就是浏览器发起的请求是通过脚本实现的,页面并没有提交或者跳转,请求由服务器处理并返回响应后,浏览器处理响应数据并将这一变化渲染到HTML中的DOM树,HTML页面的标签值得到了更新,实现了页面的局部刷新。

反向Ajax(Reverse Ajax)技术从请求方向看并没有做到反向,因为基于请求——响应模式下的HTTP请求本质上无法做到反向。这个“反向”是从实现结果上看的,即从服务器端(通过保持连接的HTTP通道)向客户端发送数据,以实现低延迟地通知客户端的技术。反向Ajax技术是服务器——浏览器通信技术的第二种解决方案,其底层实现依赖于HTTP连接不断开这一前提条件。长轮询(Long Polling)和流(Stream)技术是反向Ajax的两种技术手段,通信原理相同。

在长轮询(Long Polling)中,长连接通过keepAlive使HTTP连接得以保持。为什么要保持连接呢?因为在请求发出后的一定时间内,服务器一直没有做出响应,该连接会因连接超时而断开。Comet利用HTTP1.1的keepAlive持久性连接技术,在浏览器发出请求后,通过keepAlive保持服务器向浏览器做出响应的通信。这样一来,就解决了连接超时断开的问题。那么连接的关闭就只有两种情况,一种是浏览器主动断开,一种是服务器端特定数据发生变化,并将这一信息响应给浏览器,主动断开连接完成请求——响应模式的一次请求。

实现Comet比起Polling要困难得多,服务器端和浏览器端都需要第三方的库来支持这一技术。Atmosphere库和CometD库是实现Comet技术的第三方工具包,Jersey自身并没有提供支持Comet实现的包,而是将其交由Servlet容器来支持。

  1. 优点与缺点

优点:Comet解决方案使异步通信可以在一次请求——响应模型中完成。反向Ajax的技术解决了Polling低效地消耗服务器的网络带宽和系统负载的缺点。同时,由于服务器主动向浏览器发送数据,因此有很好的低延迟性。

缺点:Comet需要服务器端额外的技术实现来支持,同时需要在服务器和浏览器两端引入第三方工具包。实现相对复杂。

4.3.3 Web Hook异步通信

  1. 简述

Web Hook解决方案是指在客户端发送请求时,将一个回调地址同时发送给服务器,服务器接收响应后,异步处理请求并对本次请求即刻做出响应,客户端随机处理其他业务并监听回调。服务器端在响应客户端后,继续以异步的方式处理方才的请求,在处理完毕后通过回调地址通知客户端处理结果。

  1. 优点与缺点

优点:Web Hook解决方案具备Polling方案的优点,易于实现,无需引入第三方技术,并且没有Polling方案中无效的轮询负载。

缺点:Web Hook这种方案无法在浏览器作为客户端的场景中实施。因为浏览器无从提供一个回调地址给服务器。因此,该方案适用于另外一个服务器做客户端的场景。另外,与随后介绍的SSE相比,这种解决方案还是多出了一次服务器会掉客户端的HTTP连接,并且客户端回调线程和请求线程之间,存在多线程问题,并且需要具备相应的状态监控机制,需要开发者留意。

4.3.4 SSE技术

SSE(Server-Sent Events)是HTML5技术集的一部分,定义了服务器推送技术的标准规范。

  1. 简述

SSE规范的地址是http://dev.w3.org/html5/eventsource。其核心是基于EventSource接口的事件监听机制,包括onopen、onmessage和onerror3个事件监听器。SSE服务器端响应数据的媒体类型(Content-Type)是text/event-stream。Jersey的媒体库提供了对SSE的支持。

  1. 优点与缺点

优点:SSE是标准规范——HTML5标准之一,具备编程语言的无关性。首先SSE支持跨语言开发,无论具体使用什么语言和框架,只要按照以EventSource接口为中心,完成事件监听机制即可实现SSE。其次SSE支持跨语言的调用,这点很好理解,基于标准接口和标准事件监听,第七层协议上的HTTP包很容易被各系统彼此阅读。SSE的代码实现和交互逻辑相对简单,在Java EE生态环境中,得到了Jersey提供的支持。

缺点:由于请求——响应模型的限制,SSE和Comet一样,是一种从服务器端到浏览器端的单向通信,浏览器无法在同一条连接上做出二次请求或者对服务器的响应做出“响应”,这一缺点无法支持复杂的交互需求。另外,SSE标准和Jersey-sse支持包都在不断完善中。

4.4 基于HTML5的异步通信

我们研究异步通信的主旨是提高REST服务的性能,而HTML5技术栈的服务器端推送事件(SSE)技术,正是其中最佳的方案。

4.4.1 SSE的原理

Jersey的SSE支持包jersey-media-sse,基于HTML5的SSE规范,提供了一套支持SSE规范的完整的API。

Jersey的SSE支持包提供两种通信模式,发布——订阅模式和广播模式。前一种是一种端到端的通信,后一种是多播通信。

  1. 发布——订阅模式

根据SSE标准规范定义的EventSource接口,Jersey SSE定义了EventListener接口及其实现类EventSource。SSE的实现流程描述如下:

第一步,在客户端创建EventSource实例并覆盖onEvent()方法。该方法用于处理服务器端推送的事件,输入参数为InboundEvent(进站事件)。EventSource的构造函数包含一个输入参数,是WebTarget端点,指向服务器端的资源路径为sse的GET方法,服务器端的这个资源方法会使用注解@Produces(SseFeature.SERVER_SENT_EVENTS)。在EventSource实例化的过程中,客户端会从这个端点向服务器发出请求,并在请求头中指定接收数据的媒体类型为SseFeature.SERVER_SENT_EVENTS,即Accept头信息声明为text/event-stream类型。

第二步,请求被服务器接收后,开启SSE事件通信通道,方向是从服务器端向客户端并返回响应给客户端。此时,HTTP连接被保持着,并没有关闭。

此时,在SSE事件通信通道的客户端一端,会建立EventInput信道,用于读取InboundEvent(进站事件)。EventInput类继承自ChunkedInput,ChunkedInput允许将数据在一条信道中分次传输;EventInput类的泛型类型为InboundEvent,即上述的onEvent方法待处理的类型。当进站事件到达时,MessageBodyReader的SSE实现类InboundEventReader会解析数据,并反序列化为InboundEvent类型的数据。

同时,在SSE事件通信通道的服务器一端,会建立EventOutput信道,用于写入OutboundEvent(出站事件)。EventOutput类继承自ChunkedOutput,ChunkedOutput允许在发送出站事件后,HTTP连接通过HTTP1.1的Keep-Alive保持连接,出站事件被写入HTTP响应头后,EventOutput信道将等待更多的服务器推送事件。出站事件的序列化写入,由MessageBodyWriter的SSE实现类OutboundEventWriter完成。

接下来的三个步骤就是在这个信道上完成的。

第三步,客户端向服务器发送POST请求。

第四步,服务器端接收后,会向EventOutput信道写入数据。

最后一步,客户端监听到信道中有数据到达,将读取并处理推送事件。

到此,服务器推送事件的流程走完一遍。信道的连接可以由服务器主动关闭或者由客户端请求关闭。

  1. 广播模式

广播模式与发布——订阅模式相比,客户端的实现相同,服务器端推送事件的写入,由端到端的EventInput信道,换成了多点广播类SseBroadcaster。SseBroadcaster继承自Broadcaster类,泛型为OutboundEvent(出站事件)。SseBroadcaster类提供了一次关闭多点信道的方法closeAll(),可以根据业务需要,在完成广播事件后执行。

4.4.2 发布——订阅模式的实现

4.4.4 WebSocket技术

HTML5包含了SSE和Web Socket。SSE用于服务器推送事件,但并不仅限于这样使用。Web Socket无疑非常适用于服务器推送的场景。WebSocket(RFC 6455)是HTML5技术集的一部分,它提供了一个双向的、在一条TCP信道中的客户端和服务器之间全双工的通信。

  1. 简述

WebSocket消除了所有与HTTP连接的无状态特性相关的限制。Java EE 7已经支持WebSocket,其参考实现是Glassfish项目的Tyrus子项目。

  1. 优点与缺点

优点:WebSocket技术的优点是标准规范——HTML5标准之一,并逐渐成为流行趋势。它的功能强大、性能突出并采用双向、双工通信。

缺点:相对于SSE的实现,WebSocket较为复杂。

与WebSocket非常类似的是HTTP2(前身是SPDY),两者的共同点是解决HTTP1.1无法双向通信的问题。所不同的是,前者借助HTT1.1的握手,而后者是对HTTP1.1的全面升级。

⚠️ **GitHub.com Fallback** ⚠️