使用SpringMVC创建REST API系列三:提供资源之外的其他内容

微信公众号:Java周刊
欢迎关注、一起学习、一起进步!
最近更新:2018-04-04

@ResponseBody提供了一种很有用的方式,能够将控制器返回的Java对象转换为发送到客户端的资源表述。实际上,将资源表述发送给客户端只是整个过程的一部分。一个好的REST API不仅能够在客户端和服务器之间传递资源,它还能够给客户端提供额外的元数据,帮助客户端理解资源或者在请求中出现了什么情况。

1. 发送错误信息到客户端
例如一个我们获取单个用户对象信息的方法:

@ResponseBody
@RequestMapping(value="/{id}",method=RequestMethod.GET)
public User getById(long id){
return userService.getById(id);
}

如果根据给定的ID,无法找到某个User对象的ID属性能够与之匹配,getById()方法返回null的时候,你觉得会发生什么呢?

结果就是getById()方法会返回null,响应体为空,不会返回任何有用的数据给客户端。同时,响应中默认的HTTP状态码是200(OK),表示所有的事情运行正常。

但是,所有的事情都是不对的。客户端要求User对象,但是它什么都没有得到。它既没有收到User对象也没有收到任何消息表明出现了错误。服务器实际上是在说:“这是一个没用的响应,但是能够告诉你一切都正常!”

现在,我们考虑一下在这种场景下应该发生什么。至少,状态码不应该是200,而应该是404(NotFound),告诉客户端它们所要求的内容没有找到。如果响应体中能够包含错误信息而不是空的话就更好了。

Spring提供了多种方式来处理这样的场景:
1.使用@ResponseStatus注解可以指定状态码;
2.控制器方法可以返回ResponseEntity对象,该对象能够包含更多响应相关的元数据;
3.异常处理器能够应对错误场景,这样处理器方法就能关注于正常的状况。

1.1 使用ResponseEntity
作为@ResponseBody的替代方案,控制器方法可以返回一个ResponseEntity对象。ResponseEntity中可以包含响应相关的元数据(如头部信息和状态码)以及要转换成资源表述的对象。因为ResponseEntity允许我们指定响应的状态码,所以当无法找到User的时候,我们可以返回HTTP 404错误。

如下是新版本的getById(),它会返回ResponseEntity:

@RequestMapping(value="/{id}",method=RequestMethod.GET)
public ResponseEntity<User> getById(long id){
User user = userService.getById(id);
HttpStatus status = user != null ? HttpStatus.OK : HttpStatus.NOT_FOUND;
return new ResponseEntity<User>(user,status);
}

如果返回null的话,状态码设置为HttpStatus.NOT_FOUND,这会转换为HTTP 404。最后,会创建一个的ResponseEntity,它会把User和状态码传送给客户端。

注意这个getById()方法没有使用@ResponseBody注解。除了包含响应头信息、状态码以及负载以外,ResponseEntity还包含了@ResponseBody的语义,因此负载部分将会渲染到响应体中,就像之前在方法上使用@ResponseBody注解一样。如果返回ResponseEntity的话,那就没必要在方法上使用@ResponseBody注解了。

如果所要求的User无法找到的话,客户端能够得到一个合适的状态码。但是在本例中,响应体依然为空。我们可能会希望在响应体中包含一些错误信息。

我们重试一次,首先定义一个包含错误信息的Error对象:

public class Error(){
private int code;
private String message;
public Error(int code,String message){
this.code = code;
this.message = message;
}
public int getCode(){
return code;
}
public String getMessage(){
return message;
}
}

然后,我们可以修改getById(),让它返回Error:

@RequestMapping(value="/{id}",method=RequestMethod.GET)
public ResponseEntity<?> getById(long id){
User user = userService.getById(id);
if(null == user){
Error error = new Error(404,"User ["+id + "] not found");
return new ResponseEntity<Error>(user,status);
}
return new ResponseEntity<User>(user,status);
}

现在,这个方法的行为已经符合我们的预期了。如果找到User的话,就会把返回的对象以及200(OK)的状态码封装到ResponseEntity中。另一方面,如果返回null的话,将会创建一个Error对象,并将其与404(Not Found)状态码一起封装到ResponseEntity中,然后返回。

这还没有结束,首先,这比我们开始的时候更为复杂。涉及到了更多的逻辑,包括条件语句。不过,我们可以借助错误处理器来修正这些问题。

1.2 处理错误
getById()方法中的if代码块是处理错误的,但这是控制器中错误处理器(error handler)所擅长的领域。错误处理器能够处理导致问题的场景,这样常规的处理器方法就能只关心正常的逻辑处理路径了。

我们重构一下代码来使用错误处理器。首先,定义能够对应UserNotFoundException的错误处理器:

@ExceptionHandler(UserNotFoundException.class)
public ResponseEntity<Error> userNotFound(UserNotFoundException e){
long userId = e.getUserId();
Error error = new Error(404,"User ["+id + "] not found");
return new ResponseEntity<Error> (error,HttpStatus.NOT_FOUND);
}

@ExceptionHandler注解能够用到控制器方法中,用来处理特定的异常。这里它表明如果在控制器的任意处理方法中抛出UserNotFoundException异常,就会调用userNotFound()方法来处理异常。

至于UserNotFoundException,它是一个很简单异常类:

public class UserNotFoundException extends RuntimeException{
private long userId;
public UserNotFoundException(long userId){
this.userId = userId;
}
public long getUserId(){
return userId;
}
}

现在,我们可以移除掉getById()方法中大多数的错误处理代码:

@RequestMapping(value="/{id}",method=RequestMethod.GET)
public ResponseEntity<User> getById(long id){
User user = userService.getById(id);
if(null == user){throw new UserNotFoundException(id);}
return new ResponseEntity<User>(user,HttpStatus.OK);
}

这个版本的getById()方法确实干净了很多。除了对返回值进行null检查,它完全关注于成功的场景,也就是能够找到请求的User。 同时,在返回类型中,我们能移除掉奇怪的泛型了。

不过,我们能够让代码更加干净一些。现在我们已经知道getById()将会返回User并且HTTP状态码始终会是200(OK),那么就可以不再使用ResponseEntity,而是将其替换为@ResponseBody:

@RequestMapping(value="/{id}",method=RequestMethod.GET)
@ResponseBody
public User getById(long id){
User user = userService.getById(id);
if(null == user){throw new UserNotFoundException(id);}
return user;
}

当然,如果控制器类上使用了@RestController,我们甚至不再需要@ResponseBody:

@RequestMapping(value="/{id}",method=RequestMethod.GET)
public User getById(long id){
User user = userService.getById(id);
if(null == user){throw new UserNotFoundException(id);}
return user;
}

鉴于错误处理器的方法会始终返回Error,并且HTTP状态码为404(Not Found),那么现在我们可以对userNotFound()方法进行类似的清理:

@ExceptionHandler(UserNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
@ResponseBody
public Error userNotFound(UserNotFoundException e){
long userId = e.getUserId();
return new Error(404,"User ["+id + "] not found");
}

因为userNotFound()方法始终会返回Error,所以使用ResponseEntity的唯一原因就是能够设置状态码。但是通过为userNotFound()方法添加@ResponseStatus(HttpStatus.NOT_FOUND)注解,我们可以达到相同的效果,而且可以不再使用ResponseEntity了。

同样,如果控制器类上使用了@RestController,那么就可以移除掉@ResponseBody,让代码更加干净:

@ExceptionHandler(UserNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
@ResponseBody
public Error userNotFound(UserNotFoundException e){
long userId = e.getUserId();
return new Error(404,"User ["+id + "] not found");
}

在一定程度上,我们已经圆满达到了想要的效果。为了设置响应状态码,我们首先使用ResponseEntity,但是后面我们借助异常处理器以及@ResponseStatus,避免使用ResponseEntity,从而让代码更加整洁。

似乎,我们不再需要使用ResponseEntity了。但是,有一种场景ResponseEntity能够很好地完成,但是其他的注解或异常处理器却做不到。现在,我们看一下如何在响应中设置头部信息。

2. 在响应中设置头部信息
比如通常在saveUser()方法中,我们在处理POST请求的过程中创建了一个新的User资源。但是,很多时候我们无法准确地与客户端交流。比如我们的接口声明的返回值是void。

在saveUser()处理完请求之后,服务器在响应体中包含了User的表述以及HTTP状态码200(OK),将其返回给客户端。这里没有什么大问题,但是还不是完全准确。

当然,假设处理请求的过程中成功创建了资源,状态可以视为OK。但是,我们不仅仅需要说“OK”。我们创建了新的内容,HTTP状态码也将这种情况告诉给了客户端。不过,HTTP 201不仅能够表明请求成功完成,而且还能描述创建了新资源。如果我们希望完整准确地与客户端交流,那么响应是不是应该为201(Created),而不仅仅是200(OK)呢?

根据我们目前所学到的知识,这个问题解决起来很容易。我们需要做的就是为saveUser()方法添加@ResponseStatus(HttpStatus.CREATED)这应该能够完成我们的任务,现在状态码能够精确反应发生了什么情况。它告诉客户端我们新创建了资源。问题已经得以解决!

但这只是问题的一部分。客户端知道新创建了资源,你觉得客户端会不会感兴趣新创建的资源在哪里呢?毕竟,这是一个新创建的资源,会有一个新的URL与之关联。难道客户端只能猜测新创建资源的URL是什么吗?我们能不能以某种方式将其告诉客户端?

当创建新资源的时候,将资源的URL放在响应的Location头部信息中,并返回给客户端是一种很好的方式。

因此,我们需要有一种方式来填充响应头部信息,此时我们的老朋友ResponseEntity就能提供帮助了。

@RequestMapping(method=RequestMethod.POST,consumers="application/json")
public ResponseBody<User> saveUser(User user){
User user = userService.saveUser(user);
HttpHeaders headers = new HttpHeaders();
URI uri = URI.create("http://localhost:8080/应用名称/users/" + user.getId());
headers.setLocation(uri);
ResponseEntity<User> responseEntity = new ResponseEntity<User>(user,headers,HttpStatus.CREATED);
return responseEntity;
}

在这个新的版本中,我们创建了一个HttpHeaders实例,用来存放希望在响应中包含的头部信息值。HttpHeaders是MultiValueMap的特殊实现,它有一些便利的Setter方法(如setLocation()),用来设置常见的HTTP头部信息。在得到新创建User资源的URL之后,接下来使用这个头部信息来创建ResponseEntity。

哇!原本简单的saveUser()方法瞬间变得臃肿了。但是,更值得关注的是,它使用硬编码值的方式来构建Location头部信息。URL中“localhost”和“8080”这两个部分尤其需要注意,因为如果我们将应用部署到其他地方,而不是在本地运行的话,它们就不适用了。

我们其实没有必要手动构建URL,Spring提供了UriComponentsBuilder,可以给我们一些帮助。它是一个构建类,通过逐步指定URL中的各种组成部分(如host、端口、路径以及查询),我们能够使用它来构建UriComponents实例。

借助UriComponentsBuilder所构建的UriComponents对象,我们就能获得适合设置给Location头部信息的URI。

为了使用UriComponentsBuilder,我们需要做的就是在处理器方法中将其作为一个参数,如下代码所示:

@RequestMapping(method=RequestMethod.POST,consumers="application/json")
public ResponseBody<User> saveUser(User user,UriComponentsBuilder ucb){
User user = userService.saveUser(user);
HttpHeaders headers = new HttpHeaders();
URI uri =
ucb.path("/users/")
.path(String.valueOf(user.getId))
.build()
.toUri();
headers.setLocation(uri);
ResponseEntity<User> responseEntity = new ResponseEntity<User>(user,headers,HttpStatus.CREATED);
return responseEntity;
}

在处理器方法所得到的UriComponentsBuilder中,会预先配置已知的信息如host、端口以及Servlet内容。它会从处理器方法所对应的请求中获取这些基础信息。基于这些信息,代码会通过设置路径的方式构建UriComponents其余的部分。注意,路径的构建分为两步。第一步调用path()方法,将其设置为“/users/”,也就是这个控制器所能处理的基础路径。然后,在第二次调用path()的时候,使用了已保存User的ID。我们可以推断出来,每次调用path()都会基于上次调用的结果。在路径设置完成之后,调用build()方法来构建UriComponents对象,根据这个对象调用toUri()就能得到新创建User的URI。

在REST API中暴露资源只代表了会话的一端。如果发布的API没有人关心和使用的话,那也没有什么价值。

通常来讲,移动或JavaScript应用会是REST API的客户端,但是Spring应用也完全可以使用这些资源。

参考资料:1.Spring In Action(FOURTH EDITION)

喜欢本文,欢迎关注《Java周刊》
这里写图片描述

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值