Java RESTful Web Service实战(第2版) 2.2 资源定位

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都支持哪些类型的传输格式。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值