2.2 资源定位
REST使用URI实现资源定位,从这个角度上讲,对外提供REST式的Web服务的接口就是公布一系列的URI及其参数,这使得REST的实践过程简单到了极致。但是URI形式上的简单并不意味着我们可以将URI的定义信手拈来,正所谓“没有规矩,不成方圆”。
在设计REST式的Web服务过程中,资源地址的设计是非常严谨的,如果设计不得体,不仅REST接口的风格无法统一,使系统的扩展性和易用性降低,也很难实现资源准确地被定位。
资源地址的设计过程是面向资源的,资源名称应是准确描述该资源的名词,资源地址应具有直观的描述性。比如一个班级的资源地址可以是:学校/学院/学级/班级。值得注意的是一个URI资源地址唯一对应一个资源,但是一个资源可以拥有多个URI资源地址。比如Jersey最新版本的文档地址和Jersey2.7版本的文档地址指向同一个资源(本书写作时)。
阅读指南
本节示例源代码地址:https://github.com/feuyeux/jax-rs2-guide-II/tree/master/2.simple-service-3。
相关包:com.example.annotation.param。
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个部分。其中资源地址部分,根据具体部署的不同或有差别,如图2-2所示。
图2-2 资源地址示例
图2-2中,通常使用ContextPath、ServletPath和PathInfo来细分资源地址。ContextPath是上下文名称,通常和部署服务器的配置或者REST服务的web.xml配置有关。ServletPath是Servlet的名称,与REST服务中定义的@ApplicationPath注解或者web.xml的配置有关。JAX-RS2定义了@Path注解来定义资源地址。PathInfo是资源路径信息,与资源类、子类以及类中的方法定义的@Path注解有关。
现在我们对资源地址的层次结构有了认识,此时需要思考一个问题:资源地址是否可以唯一定位一个资源?
答案是否定的。资源地址相同,但HTTP方法不同的两个方法是两个不同的REST接口。HTTP方法和资源地址结合在一起才可以完成对资源的定位。细心的读者也许已经从3.1节的示例中看出端倪。示例中,GET方法用于读取/检索、查询/过滤一个资源,PUT方法用于修改/更新资源、创建客户端维护主键信息的资源,DELETE方法用于删除资源,POST方法用于创建资源。但这些方法的资源地址是相同的,都是"book"。
当上述的标准HTTP方法无法满足业务需求时,比如对于图书资源,除了基本的CRUD之外,若需要公布像借阅、折旧、电子版下载等实际生活中的更新操作的接口时,单单公布一个PUT方法就不够用了。这些操作是动词性的,无法简单地使用一个book名词定位。在路径变量难以准确描述的情况下,一种方案是可以考虑使用动词作为查询参数;另一种方案是可以在REST设计过程中引入RPC风格的POST方法,辅助完成复杂业务的接口设计,这就是REST和RPC混合型的Web服务了。
2. 资源地址和作用域
在路径变量里可以使用标点符号以辅助增强逻辑清晰性。这些辅助符号用在表2-2中的查询字符串,作为资源地址的查询变量,用来表达算法的输入,实现对方法的作用域的约束。下面来逐一讲述这些对资源地址设计至关重要的符号。
(1)问号(?)是用来分隔资源地址和查询字符串的,与符号(&)是用来分隔查询条件的参数的。示例代码如下。
GET /books?start=0&size=10
这行代码中的作用是查询图书列表,开始行参数为0,条目参数为10,即从第0行开始取10条并返回该图书列表。
(2)逗号(,)是用来分隔有次序的作用域信息。需要注意的是逗号分隔的逻辑上的顺序信息,这种顺序可以是约定俗成的,比如先写经度后写纬度;也可以是系统约定的,比如月、日、年的顺序等。举例来说,按时间区间查询图书,日期信息在资源地址中是采用月、年顺序,示例如下。
GET /books/01,2002-12,2014
这行代码中的作用是查询2002年1月到2014年12月这个时间段(出版)的图书。这个例子中还使用了连字符(-),有时候也可以使用下横线(_)来做逻辑上的辅助分隔。
(3)分号(;)是用来分隔无次序的作用域信息。通常这些信息是逻辑上并列存在的,比如并列的查询条件,示例如下所示。
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列出的这些典型的REST接口和资源定位的设计,就可以放手实现了,否则建议回顾本节内容。接下来,我们完成从设计到实现的跨越,看看JAX-RS2标准是如何通过注解来支持资源定位的,并使用Jersey完成上述设计的实践。
2.2.2 @QueryParam注解
查询条件决定了方法的作用域,查询参数组成了查询条件。JAX-RS2定义了@QueryParam注解来定义查询参数,本节使用@QueryParam演示3个REST查询接口的实现示例如表2-3所示。
表2-3 @QueryParam示例列表
接口描述 资源地址
分页查询列表数据 /query-resource/yijings?start=24&size=10
排序并分页查询列表数据 /query-resource/sorted-yijings?limit=5&sort=pronounce
查询单项数据 /query-resource/yijing?id=8
1. 分页查询
分页查询是使用@QueryParam解析参数的基本示例,实现代码如下所示。
public Yijings getByPaging(@QueryParam("start")final int start,
@QueryParam("size")final int size){//关注点1:资源方法入参
...
int listSize = globalList.size();
final int max = size > listSize ? listSize : size;
//关注点2:分页迭代逻辑
for(int i = 0, index = start; i < max; i++) {
final Yijing yijing = globalList.get(index + i);
//关注点3:添加Link以保证REST的连通性
final URI location = ub.clone().queryParam("id", yijing.getSequence()).build();
final Link link =
new Link("detail", location.toASCIIString(), MediaType.APPLICATION_XML);
links.add(link);
yijings.add(yijing);
}
result.setLinks(links);
result.setGuas(yijings);
return result;
}
在这段代码中,getByPaging()方法的输入参数包含了2个使用@QueryParam注解定义的查询参数,分别是起始条目参数"start"和条目数量参数"size",参数的类型是整型,见关注点1。在查询的迭代中使用这两个参数获取图书列表,见关注点2。在迭代中,每个图书资源条目的URI都存储在返回值中,以保证资源的联通性,见关注点3。该URI被封装到Link实例中,在单项查询时使用。
另外,参数的定义使用了final,符合Checkstyle的编程风格,即输入参数只作为逻辑算法的依据使用,其本身不会在这过程中被修改。也许这种不变的变量对提高执行效率并没有多少影响,但跬步积千里、蚁穴溃长堤。推荐Java开发者在REST开发中引入SonarQube平台或者单纯使用Checkstyle工具对静态代码进行质量检测,以帮助我们改进代码的质量。
2. 排序查询
排序查询是在解析参数的基础上,额外处理结果集顺序的示例,代码如下。
public Yijings getByOrder(@QueryParam("limit") final int limit,
@QueryParam("sort") final String sortName) {//关注点1:资源方法入参
...
Collections.sort(list, new Comparator<Yijing>() {
@Override
//关注点2:排序中的比较算法
public int compare(final Yijing o1, final Yijing o2) {
switch (sortName) {
case "sequence":
return o1.getSequence().compareTo(o2.getSequence());
case "name":
return o1.getName().compareTo(o2.getName());
case "pronounce":
return o1.getPronounce().compareTo(o2.getPronounce());
}
return 0;
}
});
在这段代码中,limit参数的用途同分页查询示例,而sortName参数则用于排序,见关注点1;排序接口需要额外解析sortName传递的排序字段,并将其作为数据库查询语句中的排序参数使用。这里实现了Comparator接口的compare()方法来完成根据不同字段对集合的排序,见关注点2。
3. 单项查询
客户端在获得结果集的基础上,根据表述中链接信息,向服务器发起单项查询的示例,代码示例如下所示。
public Yijing getByQuery(@QueryParam("id") final int seqId) {
return ParamCache.find("" + seqId);
}
在这段代码中,使用@QueryParam定义了"id"参数,该参数来自分页查询中返回的URI信息。
注解QueryParam可以和注解DefaultValue一起使用。注解DefaultValue的作用是预置一个默认值,当请求中不包含此参数时使用,示例如下。
@DefaultValue("100") @QueryParam("size") final Integer pageSize
这句话的意思是当请求中不包含分页参数pageSize时,分页参数pageSize的默认值为100。
2.2.3 @PathParam注解
JAX-RS2定义了@PathParam注解来定义路径参数—每个参数对应一个子资源,本节使用@PathParam完成如表2-4所示的REST查询接口。
表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) {
...
在这段代码中,使用@PathParam注解定义的两个参数from和to用以定义查询区间,正则表达式部分是\d+,表示数字。两个参数中间的连接符(-)是路径的格式信息。稍显复杂的例子是:/path-resource/01,2012-12,2014,引入了逗号(,)作为有顺序的日期分隔符号,那么对应的正则表达式为:@Path("{beginMonth:\\d+},{beginYear:\\d+}-{endMonth:\\d+},{endYear:\\d+}")
2. 正则表达式
正则表达式的讲述超出了本书范围,这里只简述示例中用到的正则表达式。刚刚的例子中的\\d+,代表参数应为数字并且至少出现一次。第一个反斜杠是Java中的转义字符,第二个反斜杠是正则表达式的起始,加号(+)是至少出现一次的意思,星号(*)则代表出现至少零次,句号(.)是匹配任何字符,d是匹配数字,w是匹配数字和字母。我们有的放矢,示例中使用的正则表达式如表2-5所示,读者掌握所列的路径含义即可,我们的目的是学习REST API设计,而非正则本身。
表2-5 正则表达式示例
正则表达式 含 义
[a-zA-Z][a-zA-Z_0-9]* 以字母开头,后面是零到多个“字母_数字”格式的字符组合
{region:.+}/{district:\w+} region变量至少包含一个任意字符。
district变量至少包含一个为数字或者字母的字符
3. 路径配查询
查询参数和路径参数在一个接口中配合使用,可以更便捷地完成资源定位,这很像战场上的多兵种协同作战。前述的图书资源的复杂设计就需要两者结合来完成,示例代码如下。
@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;
}
在这段代码中,路径参数user中使用了通配符,方法参数中同时使用@PathParam注解和@QueryParam,定义了user和hometown两个参数。以资源地址:/path-resource/Eric?hometown=Buenos Aires为例,REST容器会将该请求匹配到getUserInfo()方法,其中Eric是路径变量user的值,Buenos Aires作为查询变量hometown的值。
4. 路径区间
路径区间(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("{region:.+}/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);
...
}
在这段代码中,getByAddress()方法用来匹配表的这些资源地址。该方法的region变量是PathSegment类型的数组,以匹配至少出现一个字符的正则表达式(+)。PathSegment如其名字所示,是路径的片段,是子资源的集合。遍历PathSegment集合,对于每一个PathSegment实例,可以通过调用其getPath()方法获取子资源名称。
对于查询参数动态给定的场景,可以定义PathSegment作为参数类型,通过getMatrix-Parameters()方法获取MultivaluedMap类型的查询参数信息,即可将参数条件作为一个整体解析,示例代码如下。
@Path("q/{condition}")
public String getByCondition3(@PathParam("condition") final PathSegment condition) {
...
final MultivaluedMap<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]]。
5. @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) {
在这段代码中,newPassword()方法是@FormParam注解定义了user等4个参数,这些参数是容器从请求中获取并匹配的。相关的客户端测试如图2-3所示。
图2-3 表单示例
图2-3所示的客户端工具是POSTMAN(详见2.6节),使用POSTMAN定义的基本表单信息与newPassword()方法一致。
newPassword()方法的测试代码片段,示例代码如下。
@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);
}
在这段代码中,Form类实例是请求实体,请求实体的类型为MediaType.APPLI-CATION_FORM_URLENCODED_TYPE,即application/x-www-form-urlencoded。这里还需要注意的是@Encoded注解和@DefaultValue注解的使用。
JAX-RS2定义了@Encoded注解用以标识禁用自动解码。示例的测试结果中“%E4%B8%8A%E6%B5%B7”是newPassword()方法的参数值“上海”的编码值,当对newPassword使用@Encoded注解,REST方法得到的参数值就不会被解码,如果将其直接返回,那么客户端得到的值就会是处于编码状态的字符串。
JAX-RS2定义了@DefaultValue注解,用以为客户端没有为其提供值的参数提供默认值。本例的user参数的默认值为feuyeux。
2.2.5 @BeanParam注解
JAX-RS2定义了@BeanParam注解用于自定义参数组合,使REST方法可以使用简洁的参数形式完成复杂的接口设计。@BeanParam注解的使用示例如下所示。
@GET
@Path("{region:.+}/shenyang/{district:\\w+}")
//关注点1:资源方法入参
public String getByAddress(@BeanParam Jaxrs2GuideParam param) {
//关注点2:参数组合
public class Jaxrs2GuideParam {
@HeaderParam("accept")
private String acceptParam;
@PathParam("region")
private String regionParam;
@PathParam("district")
private String districtParam;
@QueryParam("station")
private String stationParam;
@QueryParam("vehicle")
private String vehicleParam;
public void testBeanParam() {
...
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);
//关注点3:查询结果断言
Assert.assertEquals("China/northeast:tiexi:Workers Village:bus", result);
}
//关注点4:复杂的查询请求
http://localhost:9998/ctx-resource/China/shenyang/tiexi?station=Workers+Village&vehicle=bus
在这段代码中,getByAddress()方法只用了一个使用@BeanParam注解定义的Jaxrs2GuideParam类型的参数,见关注点1;Jaxrs2GuideParam类定义了一系列REST方法会用到的参数类型,包括示例中使用的查询参数"station"和路径参数"region"等,从而使得getByAddress()方法可以匹配更为复杂的资源路径,见关注点2;在变长子资源的例子基础上,增加了查询条件,但测试方法testBeanParam()发起的请求的资源地址见关注点4;可以看出这是一个较为复杂的查询请求。其中路径部分包括China/shenyang/tiexi,查询条件包括station=Workers+Village和vehicle=bus。这些条件均在Jaxrs2GuideParam类中可以匹配,因此从关注点3的测试断言中可以看出,该请求响应的预期结果是"China/northeast:tiexi:Workers Village:bus"。
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) {//关注点1:资源方法入参
return longitude + "," + latitude + " population=" + population + ",area=" + area;
@Test
public void testContexts() {
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);
//关注点2:测试结果断言
Assert.assertEquals("123.38,41.8 population=822.8,area=12948", result);
}
在这段代码中,getHeaderParams()方法包含4个使用@CookieParam注解定义的参数,用于匹配Cookie的字段,见关注点1;在测试方法testContexts中,客户端Builder实例填充了相应的cookie键值对信息,其断言是对cookie字段值的验证,见关注点2。
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类是路径信息的上下文,从中可以获取路径参数集合getPath-Parameters()和查询参数集合getQueryParameters()。类似地,我们可以从HttpHeaders类中获取头信息集合getRequestHeaders()。这些业务逻辑处理中常用的辅助信息的获取,要通过@Context注解定义方法的参数或者类的字段来实现。
到此,统一接口和资源定位的设计和实现已经讲述完毕。但是,设计REST接口还需要在此基础上,掌握请求实体和响应实体的传输格式。接下来让我们看看Jersey都支持哪些类型的传输格式。