<b>2.2 资源定位</b>
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。
<b>2.2.1 资源地址设计</b>
资源地址的设计对整个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?limit=100&sort=bookname
如果读者可以轻松领会表2-2列出的这些典型的rest接口和资源定位的设计,就可以放手实现了,否则建议回顾本节内容。接下来,我们完成从设计到实现的跨越,看看jax-rs2标准是如何通过注解来支持资源定位的,并使用jersey完成上述设计的实践。
<b>2.2.2 @queryparam注解</b>
查询条件决定了方法的作用域,查询参数组成了查询条件。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. 排序查询
排序查询是在解析参数的基础上,额外处理结果集顺序的示例,代码如下。
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":
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。
<b>2.2.3 @pathparam注解</b>
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)
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类型的参数结合正则表达式将大显身手,示例代码如下。
@path("{region:.+}/shenyang/{district:\\w+}")
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}")
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}")
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"两个参数。与上例相比,这段代码更能清晰地表达可接收的参数名称和类型,缺点是缺乏对请求资源地址更灵活的支持。
<b>2.2.4 @formparam注解</b>
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。
<b>2.2.5 @beanparam注解</b>
jax-rs2定义了@beanparam注解用于自定义参数组合,使rest方法可以使用简洁的参数形式完成复杂的接口设计。@beanparam注解的使用示例如下所示。
//关注点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"。
<b>2.2.6 @cookieparam注解</b>
jax-rs2定义了@cookieparam注解用以匹配cookie中的键值对信息,示例如下。
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;
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",
在这段代码中,getheaderparams()方法包含4个使用@cookieparam注解定义的参数,用于匹配cookie的字段,见关注点1;在测试方法testcontexts中,客户端builder实例填充了相应的cookie键值对信息,其断言是对cookie字段值的验证,见关注点2。
<b>2.2.7 @context注解</b>
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();
querymap = uriinfo.getqueryparameters();
final list<pathsegment> segmentlist =
uriinfo.getpathsegments();
headermap = headers.getrequestheaders();
在这段代码中,uriinfo类是路径信息的上下文,从中可以获取路径参数集合getpath-parameters()和查询参数集合getqueryparameters()。类似地,我们可以从httpheaders类中获取头信息集合getrequestheaders()。这些业务逻辑处理中常用的辅助信息的获取,要通过@context注解定义方法的参数或者类的字段来实现。
到此,统一接口和资源定位的设计和实现已经讲述完毕。但是,设计rest接口还需要在此基础上,掌握请求实体和响应实体的传输格式。接下来让我们看看jersey都支持哪些类型的传输格式。