2.1 统一接口
REST服务和RPC服务在接口定义上的区别是:REST使用HTTP协议的通用方法作为统一接口的标准词汇,REST服务所提供的方法信息都在HTTP方法里,而RPC服务所提供的方法信息在SOAP/HTTP信封里(其封装的格式通常是HTTP或SOAP),每一个RPC式的Web服务都会公布一套符合自己商业逻辑的方法词汇。
阅读指南
本节示例源代码地址:https://github.com/feuyeux/jax-rs2-guide-II/tree/master/2.simple-service-3。
相关包:com.example.annotation.method。
每一种HTTP请求方法都可以从安全性和幂等性两方面考虑,这对正确理解HTTP请求方法和设计统一接口具有决定性的意义。换句话说,要定义严谨的REST统一接口,就需要真正理解HTTP方法的安全性和幂等性。
安全性是指外系统对该接口的访问,不会使服务器端资源的状态发生改变;幂等性(idempotence)是指外系统对同一REST接口的多次访问,得到的资源状态是相同的。
阅读指南
这里讨论的安全性对应的英文是Safety而不是Security,系统安全请参考第10章。以下,将从REST统一接口的定义角度,逐个讲述HTTP方法。
2.1.1 GET方法
REST使用HTTP的GET方法获取服务提供的资源。GET方法是只读的,那么它是幂等和安全的吗?答案马上揭晓。
1. 幂等性和安全性
HTTP的GET方法用于读取资源。GET方法是幂等的,因为读取同一个资源,总是得到相同的数据。GET方法也是安全的,因为读取资源不会对其状态做改动。JAX-RS2定义了@GET注解对资源方法定义,使得该方法用于处理GET请求。
值得注意的是,虽然GET方法的特性是幂等和安全的,但这不意味着任何一个定义为处理GET请求的方法都是幂等和安全的。换句话说,设计不良的API有可能违背GET的特性,将一个不该是GET的方法定义为之。
举个例子,在系统B中设计一个REST的API,在客户端调用时读取系统A中x类型的数据,然后将A.x与系统B内的y类型数据做比较,如果两个集合的内容、最后更新时间上有不同,需要执行同步数据,即将A.x追加或者更新到B.y中。最后,将同步结果信息返回给向系统B发起请求的客户端,如图2-1所示。
图2-1 请求资源示意图
从图2-1左侧部分乍看上去,这是一个获取同步信息的API,因此这个API的设计应该使用GET请求方法。但是,稍作分析后即可知道该场景并不具备使用GET的基本条件。因为同步过程中对系统B内的资源有写操作的可能,因此不具备安全性;而写的内容又不是每次相同,因此不具有幂等性。所以,这个例子应该定义的正确的请求方法是POST。
2. 资源方法命名
不妨一起探讨一下图2-1中的同步信息的API该如何命名?既然是同步功能,那就以sync一类的字根作为前缀,这样所有的同步API都具有相同的开头,字迹也很工整。遗憾的是,这样的设计并不符合REST风格。笔者的理解是,从字面上看有两个问题。第一,sync字根具有非名词性的含义,从ROA角度上看,sync是RPC风格的命名:动词、自定义方法名称。第二,这样命名后,资源名称从一个主语变成了宾语,从ROA角度上看,面向的不再是资源,而是要执行的动作。
因此,标准的命名方式应该是单数的同步操作以资源名称命名;批量的同步操作以资源名称的复数名称命名。比如这个API是用于同步设备的,那么命名可以使用device和devices。如果担心与普通查询业务资源地址混淆,可以在资源路径中增加查询或者路径参数,比如device/id=1&source=a_b、device/b/a/等。
3. 抽象层注解资源
JAX-RS2的HTTP方法注解可以定义在接口和POJO中,置于接口中的方法名更具抽象性和通用性。示例代码如下。
@Path("book")
public interface BookResource {
//关注点1:GET注解从抽象类上移到接口
@GET
public String getWeight();
}
public class EBookResourceImpl implements BookResource {
//关注点2:实现类无须GET注解
@Override
public String getWeight() {
return "150M";
}
}
public class GETTest extends JerseyTest {
@Override
protected Application configure() {
//关注点3:加载的是实现类而不是接口
return new ResourceConfig(EBookResourceImpl.class);
}
@Test
public void testGet() {
Response response = target("book").request().get();
Assert.assertEquals("150M", response.readEntity(String.class));
}
}
在这段代码中,资源接口BookResource定义了一个GET方法getWeight(),这个方法使用了HTTP方法注解@GET,见关注点1。资源接口BookResource的实现类EBookResourceImpl实现了getWeight()方法,但没有再次使用@GET注解。也就是说,在接口中抽象地定义了资源的请求方法类型后,其全部实现类都无须再定义。这使得编码更整洁和抽象,见关注点2。最后,需要注意的是,在测试类GETTest中注册的是实现类EBookResourceImpl类型而不是接口BookResource类型,见关注点3。
另外,我们一并介绍下HEAD方法和OPTIONS方法。HEAD方法和GET方法相似,只是服务器端的返回值不包括HTTP实体。因此,HEAD方法是安全的和幂等的。JAX-RS2定义了@HEAD注解来定义相关资源方法。OPTIONS方法和GET方法相似,是安全的和幂等的。OPTIONS用于读取资源所支持的(Allow)所有HTTP请求方法。JAX-RS2定义了@OPTIONS注解来定义相关资源方法。
2.1.2 PUT方法
PUT方法是一种写操作的HTTP请求。REST使用HTTP的PUT方法更新或添加资源。下面讲解一下PUT方法的作用和操作时的媒体类型。
1. 更新资源
因为REST只是风格,不是技术规范或标准,所以有些实现REST的细节没有明确的定义,这对实践而言,不可避免会产生某些误解。比如在创建和更新某个资源的时候,开发者比较迷茫的是何时该用HTTP的PUT方法,何时该使用POST方法。为了解决这一问题,我们首先应该知道PUT方法的特性。PUT方法是幂等的,即多次插入或者更新同一份数据,在服务器端对资源状态所产生的改变是相同的。PUT方法不是安全的,有写动作的HTTP方法都不是安全的。 我们知道,由于使用同一份数据向服务器请求更新某一资源,得到的结果应该总是相同的,因此对于更新操作,使用PUT是没有疑问的。可能读者会想到最后更新时间字段每次提交会不同,但那已经不是同一份数据了。
2. 添加资源
创建操作通常每次得到的结果是不同的,因为服务器端的业务层逻辑通常要求数据的主键字段要么来自于业务平台自增一个逻辑值,要么来自于数据库的主键自增。因此,相同的数据每一次提交到服务器端,都会为数据添加一个新的主键值,也就是创建一个主键值不同的新资源(如果没有业务或者外键冲突)。所以,创建操作通常应当设计为POST方法的API。唯有一种场景应当使用PUT方法来设计API,即客户端在发起创建请求时,在同一份数据中总可以提供唯一的主键值,服务器不会对其进行修改,这样的创建请求确保了幂等性,不应再使用POST方法。JAX-RS2定义了@PUT注解来定义相关资源方法,示例代码如下。
@Path("book")
public interface BookResource {
//关注点1:PUT方法
@PUT
//关注点2:资源方法定义了Produces注解和Consumes注解
@Produces(MediaType.TEXT_PLAIN)
@Consumes(MediaType.APPLICATION_XML)
public String newBook(Book book);
}
public class PutTest extends JerseyTest {
public static AtomicLong clientBookSequence = new AtomicLong();
@Test
public void testNew() {
final Book newBook = new Book(clientBookSequence.incrementAndGet(),
"book-" + System.nanoTime());
MediaType contentTypeMediaType = MediaType.APPLICATION_XML_TYPE;
MediaType acceptMediaType = MediaType.TEXT_PLAIN_TYPE;
final Entity<Book> bookEntity = Entity.entity(newBook, contentTypeMediaType);
final String lastUpdate = target("book").request(acceptMediaType)
.put(bookEntity, String.class);
//关注点3:资源方法定义了Produces注解和Consumes注解
Assert.assertNotNull(lastUpdate);
LOGGER.debug(lastUpdate);
}
}
在这段代码中,资源接口BookResource使用@PUT注解定义了newBook()方法,即该方法用于处理相对资源路径为"book"的PUT请求,见关注点1。单元测试类PutTest对其功能性进行了验证,对lastUpdate使用非空断言,lastUpdate是更新方法newBook()的返回实体的值,代表最后更新时间戳,见关注点3。我们注意到,newBook()方法上,同时定义了@Produces(MediaType.TEXT_PLAIN)注解和@Consumes(MediaType.APPLICATION_XML)注解,见关注点2,下面我们来介绍一下与关注点2相关的媒体类型知识。
3. 媒体类型
PUT方法执行写操作的非安全的HTTP方法,需要考虑请求实体媒体类型和响应实体媒体类型。请求实体媒体类型使用HTTP头的Content Type定义,响应实体媒体类型使用HTTP头的Accept定义。
在服务器端,@Consumes(MediaType.APPLICATION_XML)定义了服务器端要消费的媒体类型,即消费客户端请求实体的媒体类型。@Produces(MediaType.TEXT_PLAIN)定义了服务器端生产的媒体类型,即服务器产生的响应实体的媒体类型。客户端在提交非安全性HTTP请求方法前,在Entity类的实例中,定义该Entity实例的媒体类型,即客户端请求实体的媒体类型。request方法用于定义可接受的HTTP方法的返回媒体类型,即服务器的响应实体的媒体类型。
测试资源方法newBook(),将得到如下所示的请求头信息,从中可以看到请求媒体类型。
public final static String TEXT_PLAIN = "text/plain";
public final static String APPLICATION_XML = "application/xml";
public final static MediaType TEXT_PLAIN_TYPE = new MediaType("text", "plain");
public final static MediaType APPLICATION_XML_TYPE = new MediaType("application", "xml");
1 > PUT http://localhost:9998/book
1 > Accept: text/plain
1 > Content-Type: application/xml
在这段代码中,javax.ws.rs.core.MediaType类是JAX-RS2提供的媒体类型定义类,其中定义了包括示例中使用的MediaType.TEXT_PLAIN,其值为"text/plain"。在MediaType类中,对应的响应实体媒体类型定义为Accept: text/plain;MediaType.APPLICATION_XML值为"application/xml",对应的请求实体媒体类型定义为Content-Type: application/xml。
2.1.3 DELETE方法
DELETE方法是幂等的,即多次删除同一份数据(通常请求中传递的参数是数据的主键值),在服务器端产生的改变是相同的。JAX-RS2定义了@DELETE注解来定义相关资源方法。下面来看看具体示例。
执行删除的资源方法,其返回值可以定义为void,即该方法没有返回值。之所以在删除资源的场景中可以采用这样的方式定义,是因为删除的前提是对该资源信息已经充分了解,没有必要再将其从服务器上传递回来,示例代码如下。
@Path("book")
public interface BookResource {
@DELETE
public void delete(@QueryParam("bookId") final long bookId);
}
在这段代码中,无返回值的资源方法delete()返回的响应实体为空,HTTP状态码为204。该定义可以参考Jersey的源代码中的Response类,示例代码如下。
package javax.ws.rs.core;
public abstract class Response {
public interface StatusType {
public enum Status implements StatusType {
NO_CONTENT(204, "No Content"),
接下来是删除资源方法的单元测试,示例代码如下。
public class DeleteTest extends JerseyTest {
@Test
public void testGet() {
final Response response =target("book").queryParam("bookId", "9527")
.request().delete();
int status = response.getStatus();
LOGGER.debug(status);
Assert.assertEquals(Response.Status.NO_CONTENT.getStatusCode(), status);
}
}
在这段代码中,对REST请求的测试断言不是针对删除资源的实体,而是响应中HTTP状态码。也就是说,删除资源方法的返回值类型可以定义为void,业务逻辑更关注删除操作的结果状态。
2.1.4 POST方法
POST方法是一种写操作的HTTP请求。RPC的所有写操作均使用POST方法,而REST只使用HTTP的POST方法添加资源。
1. 既不幂等也不安全
定义为POST的REST接口用于写数据,POST方法的特性是既不幂等也不安全。由于请求会改变服务器端资源的状态,因此它是不是安全的;由于每次请求对服务器端资源状态的改变并不是相同的,因此它不是幂等的。
2. 两种分类
REST中使用的POST可以称之为POST(a),即用于创建、添加资源的HTTP方法。这是相对于RPC式的Web服务中对POST的使用而言的。
在RPC中使用的POST可以称之为POST(p),即通过重载的POST用于处理某种操作。服务器接收POST(p)的请求后,不是直接处理POST请求,由于真正的方法信息位于信封头或实体主体里,因此需要先解析出执行方法。
JAX-RS2定义了@POST注解来定义相关资源方法。示例代码如下。
@Path("book")
public interface BookResource {
//关注点1:POST方法
@POST
@Produces(MediaType.APPLICATION_XML)
@Consumes(MediaType.APPLICATION_XML)
public Book createBook(Book book);
public class PostTest extends JerseyTest {
@Test
public void testCreate() {
final Book newBook = new Book("book-" + System.nanoTime());
MediaType contentTypeMediaType = MediaType.APPLICATION_XML_TYPE;
MediaType acceptMediaType = MediaType.APPLICATION_XML_TYPE;
final Entity<Book> bookEntity = Entity.entity(newBook, contentTypeMediaType);
final Book book =target("book").request(acceptMediaType).post(bookEntity,
Book.class);
//关注点2:测试POST方法的断言
Assert.assertNotNull(book.getBookId());
LOGGER.debug("Server Id="+book.getBookId());
}
}
在这段代码中,资源接口BookResource定义了createBook()方法,该方法使用@POST注解,表示该方法处理"book"路径下的POST请求,见关注点1。在测试方法testCreate()中,关注请求结果实体的主键是否为空。这是因为在POST请求提交的添加资源操作中,主键的设置是在服务器端完成的,因此客户端成功请求添加资源后,应关注服务器端返回的实体结果是否有主键信息,见关注点2。
到此,我们完成了对HTTP的基本方法的讲述。除了HTTP协议定义的标准方法,还存在来自其他协议中的HTTP方法。接下来,我们一起探讨这些方法对REST服务的影响。
2.1.5 WebDAV扩展方法
WebDAV(Web-based Distributed Authoring and Versioning,基于Web的分布式创作与版本控制)是IETF的RFC4918规范(RFC2518规范的替代规范地址是http://tools.ietf.org/html/rfc4918),是对HTTP1.1协议的一组扩展,该协议允许用户以协作方式编辑和管理远程Web服务器上的文件。WebDAV在HTTP方法的基础上,增加了如下方法(详见RFC4918第9章)。
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扩展方法。有了扩展方法注解,我们就可以在资源类中定义新的方法来支持扩展方法的请求了,示例代码如下。
@Path("book")
public interface BookResource {
@MOVE
public boolean moveBooks(Books books);
}
在这段代码中,资源类BookResource定义了moveBooks方法,该方法使用@MOVE注解定义,表示用于处理"book"路径下的MOVE请求。下面我们来看看相关的测试代码。
public class HttpMethodTest extends JerseyTest {
@Override
protected Application configure() {
ResourceConfig resourceConfig = new ResourceConfig(EBookResourceImpl.class);
return resourceConfig;
}
@Override
protected void configureClient(ClientConfig clientConfig) {
//关注点1:定义Grizzly连接器
clientConfig.connectorProvider(new GrizzlyConnectorProvider());
super.configureClient(clientConfig);
}
@Test
public void testWebDav() {
//关注点2:HTTP MOVE请求
final Response response = target("book").request().method("MOVE");
Boolean result = response.readEntity(Boolean.class);
//关注点3:Move方法测试断言
Assert.assertEquals(Boolean.TRUE, result);
}
}
在这段代码中,测试方法testWebDav()在请求中定义了MOVE请求,见关注点2;断言是针对MOVE方法的返回值,见关注点3;可以看出,使用Jersey实现对WebDav的支持并不困难。
需要注意的是,Jersey默认的连接器只支持HTTP标准方法,因此要使用HTTP的扩展方法就不能直接使用默认的连接器,这里使用了Grizzly连接器。对应的代码行是:clientConfig.connectorProvider(new GrizzlyConnectorProvider()),即为客户端配置实例提供Grizzly连接器,见关注点1。这行代码是Jersey2.5+后的写法,Jersey2.5之前的写法是clientConfig.connector(new GrizzlyConnector(clientConfig))。从中可以看出,Jersey在不断优化中,包括API。这一好处是活跃的社区为用户带来越来越便捷、高效的使用体验,缺点是破坏了向下兼容性。
到此,我们全面掌握了HTTP方法在REST统一接口定义中的作用和实现。明白了REST接口该使用什么样的请求方法非常重要,这决定了其性质。但是这还不够,一个接口如何被请求唯一定位还需要深入掌握REST的资源定位。接下来一节将详述资源定位的细节。