RESTful API 入门二(JAX-RS实现)

为什么要用Provider

  provider是在程序运行时自动调用的一些类,起到配置与修改http请求与回应的作用,是JAX-RS里不可或缺的一项组件。从上一讲可以看到,Resource组件已经能够完全实现RESTful API,在JAX-RS标准里无非就是添加一些注解的事。然而,实现功能只是最基本的要求,要做出企业级的应用,必须符合一些标准和规范,才能最大程度地提高团队的开发效率,并从最佳实践的角度去吻合市场的需求。RESTful API的一些设计规范,就必须要使用到provider去做配置。

几种provider的介绍

  所有类名上添加了@provider注解的类,都属于provider组件。下面将介绍一下常用的provider。

ContainerRequestFilter,ContainerResponseFilter

  过滤器,顾名思义,是对进入服务器与从服务器输出的数据进行过滤。由于RESTful API实际上是操作http协议,那么过滤器便是对http request或http response的header或body进行过滤,作出一些修改,或判断是否拒绝该http request或http response。

  containerRequestFilter的核心是filter方法,接收ContainerRequestContext对象,这个对象封装了http request。一般在这个方法里对header进行操作。ContainerResponseFilter的核心也是filter方法,接收ContainerResponseContext和ContainerResponseContext对象,这个对象封装的则是http response,也主要针对其heaer操作。

  下面从两个主要的设计规范点出发,看看怎么用JAX-RS的过滤器来实现。

  • HTTP方法重载。上一讲提到,RESTful API涉及的HTTP方法有GET,POST,PUT,PATCH,DELETE这几种,而有些http proxy只能用GET和POST方法。一般的解决方法是将PUT,PATCH和DELETE请求也用POST方法发出,但在header里添加X-HTTP-Method-Override这个key,来存储真正的方法名。那么服务器端怎么利用http header,来进行方法重新匹配的呢?这就需要用到ContainerRequestFilter了。下面请看代码:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    @Provider
    @PreMatching
    public class HttpMethodOverrideEnabler implements ContainerRequestFilter {
    @Override
    public void filter(ContainerRequestContext requestContext) throws IOException {
    String override = requestContext.getHeaders()
    .getFirst("X-HTTP-Method-Override");
    if (override != null) {
    requestContext.setMethod(override);
    }
    }
    }

  先来看@PreMatching注解。所有的provider都对应特定的资源类或者资源方法,当http request到达,会首先根据其URI和HTTP方法类型去匹配相应的Resource组件类的某个方法,这在上一讲已经讲过。一般情况下所有ContainerRequestFilter的执行时机是在方法匹配之后,但是我们的需求是在方法匹配之前对方法进行重载,根据X-HTTP-Method-Override里的方法名进行重匹配。这就需要用到@PreMatching注解了,它能把过滤器的执行时机提前到方法匹配之前。filter方法里代码也很简单,就是从requestContext里提取header里的X-HTTP-Method-Override字段,如果不为空则用setMethod方法进行重匹配。

  • 对访问频率进行限制。我们提供的RESTful API服务允许开发者访问,但必须设置一定的安全措施,防止被滥用,被过度调用,被恶意攻击,因此必须对资源的访问频率进行限制。在限制的同时,我们也应该提供给调用者一些信息,让他们获知每周期的调用限制数,每周期内的剩余次数,以及到下一个周期的剩余时间。这些的实现同样可以用过滤器以及header来实现。请看代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Provider
public class HttpLimitRequestFilter implements ContainerRequestFilter {
@Override
public void filter(ContainerRequestContext requestContext) throws IOException {
if (requestContext.getCookies().get("reset") != null && requestContext.getCookies().get("timeout") != null) {
String timeOut = requestContext.getCookies().get("timeout").getValue();
String reset = requestContext.getCookies().get("reset").getValue();
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
try {
Date timeOutDate = sdf.parse(timeOut);
if (timeOutDate.after(new Date()) && Integer.parseInt(reset) <= 0) {
throw new WebApplicationException(Response.status(429).build());
}
} catch (ParseException e) {
e.printStackTrace();
}
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
@Provider
@Tracked
public class HttpLimitResponseFilter implements ContainerResponseFilter {
private final static int LIMIT = 100;
private final static int TERM = 60;
@Override
public void filter(ContainerRequestContext requestContext, ContainerResponseContext responseContext) throws IOException {
if (requestContext.getCookies().get("reset") == null || requestContext.getCookies().get("timeout") == null) {
addOrReplaceCookies(responseContext);
}
else {
String timeOut = requestContext.getCookies().get("timeout").getValue();
String reset = requestContext.getCookies().get("reset").getValue();
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
try {
Date timeOutDate = sdf.parse(timeOut);
Date now = new Date();
if (timeOutDate.before(now)) {
addOrReplaceCookies(responseContext);
} else {
long interval = (timeOutDate.getTime() - now.getTime())/1000;
if(Integer.parseInt(reset) > 0){
NewCookie cookie = new NewCookie("reset", String.valueOf(Integer.parseInt(reset) - 1), "/sampleweb", "", "comment", 100, false);
responseContext.getHeaders().add("Set-Cookie", cookie);
responseContext.getHeaders().add("X-Rate-Limit-Remaining", Integer.parseInt(reset) - 1);
}else{
NewCookie cookie = new NewCookie("reset", String.valueOf(0), "/sampleweb", "", "comment", 100, false);
responseContext.getHeaders().add("Set-Cookie", cookie);
responseContext.getHeaders().add("X-Rate-Limit-Remaining", 0);
}
responseContext.getHeaders().add("X-Rate-Limit-Limit", LIMIT);
responseContext.getHeaders().add("X-Rate-Limit-Reset", (int)interval);
}
} catch (ParseException e) {
e.printStackTrace();
}
}
}
private void addOrReplaceCookies(ContainerResponseContext responseContext) {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
Calendar cal = Calendar.getInstance();
cal.add(Calendar.MINUTE, 1);
NewCookie reset = new NewCookie("reset", String.valueOf(LIMIT - 1), "/sampleweb", "", "comment", 100, false);
NewCookie remain = new NewCookie("timeout", sdf.format(cal.getTime()), "/sampleweb", "", "comment", 100, false);
responseContext.getHeaders().add("Set-Cookie", remain);
responseContext.getHeaders().add("Set-Cookie", reset);
responseContext.getHeaders().add("X-Rate-Limit-Limit", LIMIT);
responseContext.getHeaders().add("X-Rate-Limit-Remaining", LIMIT - 1);
responseContext.getHeaders().add("X-Rate-Limit-Reset", TERM);
}
}

  由于http协议是无状态的,而用户的访问频率是需要保存的状态,因此需用cookie来实现。cookie主要存储两个值,一个是本周期内还可以访问的次数,一个是本周期结束的时间。利用这两个值,在ContainerRequestFilter内判断是否接受这次请求,并在ContainerResponseFilter内更新每次请求过后cookie的值。这里有一个坑需要特别注意,ContainerResponseContext用getCookies()方法获取的cookie是只读的,不能修改,如果要更新cookie必须直接在http response的header里写入Set-Cookie字段。这也是http response携带cookie的规范做法。并且,NewCookie对象在创建时候必须指定路径。因为相同路径和域名的cookie是保存在同一个文件夹里的,一般是取工程路径,以表示相同工程内使用的cookie,不然在添加cookie的时候即便key一样也会插入新数据,而不会覆盖原来的cookie,达不到更新的效果。

  反馈给用户的信息是保存在response的header里的,X-Rate-Limit-Limit表示每个累计周期内的访问次数最大是多少;X-Rate-Limit-Remaining表示目前的累计周期内还剩余多少次可以用;X-Rate-Limit-Reset表示还有多少秒钟累计周期就到期了,可以开始下一个周期了。这些header信息都是RESTful API的设计规范里包含的。

  如果用户访问频率超出限制,需要抛出error code为429的http response,429在http标准里是表示Too Many Requests。这也是RESTful API的规范之一。充分利用错误码来表达操作的结果,设计RESTful API的时候对每一条http response都必须返回相应的错误码,不得有误。更详细的error code规范会在后面介绍,这里先提一下。

MessageBodyWriter和MessageBodyReader

  这两类是进行对象的序列化与反序列化的。在基本的RESTful API开发中并不常用,因为如上一讲所说,利用@XmlRootElement这个JAXB注解便可轻松解决xml与json的序列化与反序列化,只有更高层次的要求才需要自己实现这两个类。我也并没有怎么使用过,对这两个类的认识也仅停留在官方文档的介绍,日后如果有机会实践过后,有更深入的理解再行补充。

ContextResolver

  前面说过,RESTful API规范里数据传输的格式是json,而JAX-RS序列化后的json格式往往是没有缩进和换行的,这就使得json文本不方便阅读。RESTful API规范要求json必须按照pretty print的方式输出。实现pretty print一种比较简单的方法是使用Jackson框架,利用ObjectMapper类将对象映射成相应的json文本,并且其pretty print模式。下面请看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Provider
@Produces(MediaType.APPLICATION_JSON)
public class JacksonContextResolver implements ContextResolver<ObjectMapper> {
private ObjectMapper objectMapper;
public JacksonContextResolver() throws Exception {
this.objectMapper = new ObjectMapper();
this.objectMapper
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.configure(SerializationFeature.INDENT_OUTPUT, true);
}
@Override
public ObjectMapper getContext(Class<?> objectType) {
return objectMapper;
}
}

  其中objectMapper.configure(SerializationFeature.INDENT_OUTPUT, true)便是让输出使用pretty print。注意的是,使用Jackson框架必须要在Application的实现类里注册相应资源。

1
2
3
4
5
6
7
@Override
public Set<Class<?>> getClasses() {
Set<Class<?>> resources = new java.util.HashSet<>();
addRestResourceClasses(resources);
resources.add(JacksonFeature.class);
return resources;
}

  resources.add(JacksonFeature.class)这一句是不能省的。

WriteInterceptor,ReadInterceptor

  拦截器,与过滤器的作用有很多相似的地方。不同之处在于,过滤器一般用来处理header,拦截器则用来处理http request与response的body里的entity的输入输出流。可以利用拦截器对输入流进行统一的解码或解压缩,或者对输出流进行统一的编码或压缩。

  RESTful API规范里要求对response同一使用GZIP压缩,把pretty print处理后的json文本压缩处理,节省带宽,节约流量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Provider
public class GZIPWriterInterceptor implements WriterInterceptor {
@Override
public void aroundWriteTo(WriterInterceptorContext context)
throws IOException, WebApplicationException {
MultivaluedMap<String,Object> headers = context.getHeaders();
headers.add("Content-Encoding", "gzip");
final OutputStream outputStream = context.getOutputStream();
context.setOutputStream(new GZIPOutputStream(outputStream));
context.proceed();
}
}

  上面的代码是利用WriteInterceptor进行GZIP压缩的示例,十分简单,ReadInterceptor类似,只是换成操作输入流。只需要在输出流外再包装一层GZIPOutputStream的包装流便可。注意必须在header里添加Content-Encoding字段,这样浏览器才知道怎么解压缩。

  上面最值得一提的是context.proceed()这个方法。所有拦截器必须调用这个方法,调用这个方法后会自动调用下一个拦截器,直到到达拦截器链末端的最后一个拦截器,此时该拦截器调用proceed()方法后会调用MessageBodyWriter或MessageBodyReader去进行序列化或反序列化。

  拦截器以及MessageBodyWriter和MessageBodyReader都是在entity不为空的情况下才会调用的,若response不返回数据或者request的body不携带数据,则这些类都不会被使用。

provider的调用顺序
  1. pre-matching ContainerRequestFilters
  2. post-matching ContainerRequestFilters
  3. ReaderInterceptor
  4. MessageBodyReader
  5. ContainerResponseFilters
  6. WriterInterceptor
  7. MessageBodyWriter
资源绑定

 默认情况下所有的http请求的到来都会让所有provider类自动运行相应的方法,但我们可以通过资源绑定的方式,让某些provider只会被某些request触发。主要有两种方式:Name binding和Dynamic binding

  1. Name binding

通过@NameBinding注解实现:

  • 定义注解,例如:
1
2
3
4
5
@NameBinding
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
public @interface Tracked {
}
  • 为provider添加该注解:
1
2
3
@Provider
@Tracked
public class HttpLimitResponseFilter implements ContainerResponseFilter {
  • 为相应的资源方法或资源类添加该注解:
1
2
3
4
5
6
@GET
@Path("current_user")
@PermitAll
@Produces({"application/json"})
@Tracked
public JsonObject getCurrentUserInfo(@Context HttpServletRequest request) {
1
2
3
4
5
@Stateless
@RolesAllowed({"operator"})
@Path("message")
@Tracked
public class MessageFacadeREST extends AbstractFacade<Message> {

  当访问带有@Tracked注解的方法或类时,带有@Tracked的provider便会在相应的资源方法调用前后自动触发。从而实现资源绑定。

  1. Dynamic binding

  动态绑定是通过DynamicFeature接口实现的,代码如下:

1
2
3
4
5
6
7
8
9
10
11
public class CompressionDynamicBinding implements DynamicFeature {
@Override
public void configure(ResourceInfo resourceInfo, FeatureContext context) {
if (HelloWorldResource.class.equals(resourceInfo.getResourceClass())
&& resourceInfo.getResourceMethod()
.getName().contains("VeryLongString")) {
context.register(GZIPWriterInterceptor.class);
}
}
}

  configure()方法里接收两个参数,ResourceInfo对象用来对request要访问的资源进行过滤,FeatureContext对象用来对相应的provider进行动态注册。

异常处理以及返回Response对象

  如果没有错误正常返回,一般直接返回结果对象或者什么也不返回即可,这时不需要额外配置response的header或status code。但是,若抛出异常或者结果不正确,则需要返回相应的status code。这时候便需要我们构造Response对象返回。

  JAX-RS的异常处理建议统一使用WebApplicationException类或者使用ExceptionMapper类来实现。

WebApplicationException

  直接抛出WebApplicationException:

1
throw new WebApplicationException(Response.status(429).build());

  接收的参数可以是用相应status code构造的Response对象,也可以直接用status code作为参数。此时JAX-RS便会构造出相应status code的http response报文。

ExceptionMapper

  如果在程序运行期间抛出异常,导致不能返回正确结果,可以用ExceptionMapper的实现类来捕捉特定的异常。代码如下:

1
2
3
4
5
6
7
8
9
@Provider
public class AccessLocalExceptionMapper implements ExceptionMapper<AccessLocalException>{
@Override
public Response toResponse(AccessLocalException exception) {
return Response.status(Response.Status.UNAUTHORIZED).build();
}
}

  ExceptionMapper可以定义成任何异常类的处理类,在toResponse()方法里进行异常处理,并构造Response对象返回。有这么一个机制来统一进行异常处理,使得代码管理十分清晰,行文十分优雅。

正常返回Response对象

  即便是在资源方法里正常返回,但如果除了传输数据外,还需要携带header,cookie等信息,则也需要构造Response对象返回,正常数据则作为Response对象里的GenericEntity对象保存。

  在正常的业务逻辑里,我们读取一些数据的时候往往需要翻页信息,页数以及每页的数据项数在RESTful API规范里往往是作为http request的query params传输的,而相应的http response也通常会在header里返回上一页,下一页,第一页以及最后一页数据的URI,方便用户继续访问。下面就以这一个规范点作为需求,看看代码里怎么构造翻页信息的Response对象的:

1
2
3
4
5
6
7
8
GenericEntity entity = new GenericEntity<List<MessageDTO>>(messageRepo){};
double count = (double)messageService.countDangerousMessage();
response.setHeader("next", "https://localhost:8080/sampleweb/api/message/danger-unsolved?page=" + (page + 1));
response.setHeader("prev", "https://localhost:8080/sampleweb/api/message/danger-unsolved?page=" + (page - 1));
response.setHeader("first", "https://localhost:8080/sampleweb/api/message/danger-unsolved?page=1");
response.setHeader("last", "https://localhost:8080/sampleweb/api/message/danger-unsolved?page=" + (int)Math.ceil(count / perPage));
response.setHeader("X-Total-Count",String.valueOf((int)count));
return Response.ok(entity).build();

  GenericEntity保存需要返回的body里的数据。在header里添加上相应页面资源的URI,并且让X-Total-Count返回总的数据项数。这些信息都是提供给用户的。Response的ok()方法接收entity参数,并且构造status code为200的http response返回给用户。

总结

  这一讲主要讲的是JAX-RS的provider组件,并涉及了一些异常处理和构造Response对象的内容,其实主要就是围绕http request和http response展开的。希望读者能够体会到,RESTful API归根结底就是http报文,http报文又可以分为header和body。对header和body,RESTful API都有一定的规范对其进行限制,而实现这些规范的手段便是provider,异常处理以及Response对象。RESTful API必须对每一个接口的request和response的header和body有清楚的定义。手段方法是其次,RESTful API的思想与规范才是根本。下一讲会把一些还没涉及的设计规范讲一下,但用到的技术基本就是这一讲和上一讲Resource和Provider组件的内容了。