7.3 处理异常
不管发生什么事情,不管是好的还是坏的,Servlet请求的输出都是一 个Servlet响应。如果在请求处理的时候,出现了异常,那它的输出依然会是Servlet响应。异常必须要以某种方式转换为响应。Spring提供了多种方式将异常转换为响应:
- 特定的Spring异常将会自动映射为指定的HTTP状态码;
- 异常上可以添加@ResponseStatus注解,从而将其映射为某一 个HTTP状态码;
- 在方法上可以添加@ExceptionHandler注解,使其用来处理 异常。
处理异常的最简单方式就是将其映射到HTTP状态码上,进而放到响应之中。
7.3.1 将异常映射为HTTP状态码
在默认情况下,Spring会将自身的一些异常自动转换为合适的状态 码。表7.1列出了这些映射关系。
表7.1 Spring的一些异常会默认映射为HTTP状态码
表7.1中的异常一般会由Spring自身抛出,作为DispatcherServlet 处理过程中或执行校验时出现问题的结果。例如,如果 DispatcherServlet无法找到适合处理请求的控制器方法,那么 将会抛出NoSuchRequestHandlingMethodException异常,最 终的结果就是产生404状态码的响应(Not Found)。
尽管这些内置的映射是很有用的,但是对于应用所抛出的异常它们就 无能为力了。幸好,Spring提供了一种机制,能够通过 @ResponseStatus注解将异常映射为HTTP状态码。
请参考SpittleController中如下的请求处理方法,它可能会产生HTTP 404状态(但目前还没有实现):
@RequestMapping(value="/{spittleId}", method=RequestMethod.GET)
public String spittle(
@PathVariable long spittleId,
Model model) {
Spittle spittle = spittleRepository.findOne(spittleId);
if (spittle == null) {
throw new SpittleNotFoundException();
}
model.addAttribute(spittle);
return "spittle";
}
相应地,JdbcSpittleRepository类的findOne()方法也修改为:
@Override
public Spittle findOne(long id) {
// TODO Auto-generated method stub
List<Spittle> spittles = jdbc.query(
"select id, message, created_at, latitude, longitude" +
" from Spittle" +
" where id = ?",
new SpittleRowMapper(), id);
return spittles.size() > 0 ? spittles.get(0) : null;
}
在这里,会从SpittleRepository中,通过ID检索Spittle对 象。如果findOne()方法能够返回Spittle对象的话,那么会将Spittle放到模型中,然后名为spittle的视图会负责将其渲染到响应之中。但是如果findOne()方法返回null的话,那么将会抛 出SpittleNotFoundException异常。现在SpittleNotFoundException就是一个简单的非检查型异常:
package spittr.web;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(value=HttpStatus.NOT_FOUND, reason="Spittle Not Found")
public class SpittleNotFoundException extends RuntimeException {
/**
*
*/
private static final long serialVersionUID = 1L;
}
如果调用spittle()方法来处理请求,并且给定ID获取到的结果为空,那么SpittleNotFoundException(默认)将会产生500状态 码(Internal Server Error)的响应。实际上,如果出现任何没有映射的异常,响应都会带有500状态码,但是,我们可以通过映 射SpittleNotFoundException对这种默认行为进行变更。 当抛出SpittleNotFoundException异常时,这是一种请求资源没有找到的场景。如果资源没有找到的话,HTTP状态码404是最为精确的响应状态码。所以,我们要使用@ResponseStatus注解 将SpittleNotFoundException映射为HTTP状态码404。
7.3.2 编写异常处理的方法
如果我们想在响应中不仅要包括状态码,还要包 含所产生的错误,那该怎么办呢?此时的话,我们就不能将异常视为 HTTP错误了,而是要按照处理请求的方式来处理异常了。
作为样例,假设用户试图创建的Spittle与已创建的Spittle文本完全相同,那么SpittleRepository的save()方法将会抛出 DuplicateSpittle Exception异常。这意味着SpittleController的saveSpittle()方法可能需要处理这个异常。
@RequestMapping(method=RequestMethod.POST)
public String saveSpittle(SpittleForm form, Model model) {
try {
spittleRepository.save(new Spittle(null, form.getMessage(), new Date(),
form.getLongitude(), form.getLatitude()));
return "redirect:/spittles";
} catch (DuplicateSpittleException e) {
return "error/duplicate";
}
}
程序清单 SpittleForm:
package spittr.form;
import javax.validation.constraints.Max;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
public class SpittleForm {
@NotNull
@Size(min=1, max=140)
private String message;
@Min(-180)
@Max(180)
private Double longitude;
@Min(-90)
@Max(90)
private Double latitude;
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
public Double getLongitude() {
return longitude;
}
public void setLongitude(Double longitude) {
this.longitude = longitude;
}
public Double getLatitude() {
return latitude;
}
public void setLatitude(Double latitude) {
this.latitude = latitude;
}
}
它运行起来没什么问题,但是这个方法有些复杂。该方法可以有两个路径,每个路径会有不同的输出。如果能让saveSpittle()方法只关注正确的路径,而让其他方法处理异常的话,那么它就能简单一 些。
首先将saveSpittle()方法中的异常处理方法剥离掉:
@RequestMapping(method=RequestMethod.POST)
public String saveSpittle(SpittleForm form, Model model) {
spittleRepository.save(new Spittle(null, form.getMessage(), new Date(),
form.getLongitude(), form.getLatitude()));
return "redirect:/spittles";
}
现在,我们为SpittleController添加一个新的方法,它会处理 抛出DuplicateSpittleException的情况:
@ExceptionHandler(DuplicateSpittleException.class)
public String handleNotFound() {
return "error/duplicate";
}
handleDuplicateSpittle()方法上添加了 @ExceptionHandler注解,当抛出DuplicateSpittleException异常的时候,将会委托该方法来处理。它返回的是一个String,这与处理请求的方法是一致的,指定了要渲染的逻辑视图名,它能够告诉用户他们正在试图创建一条重复的条目。
对于@ExceptionHandler注解标注的方法来说,比较有意思的一 点在于它能处理同一个控制器中所有处理器方法所抛出的异常。所以,尽管我们从saveSpittle()中抽取代码创建了 handleDuplicateSpittle()方法,但是它能够处理SpittleController中所有方法所抛出的 DuplicateSpittleException异常。我们不用在每一个可能抛出 DuplicateSpittleException的方法中添加异常处理代码,这一 个方法就涵盖了所有的功能。
7.4 为控制器添加通知
如果要在多个控制器中处理异常, 那@ExceptionHandler注解所标注的方法是很有用的。不过,如果多个控制器类中都会抛出某个特定的异常,那么你可能会发现要在所有的控制器方法中重复相同的@ExceptionHandler方法。或者,为了避免重复,我们会创建一个基础的控制器类,所有控制器类要扩展这个类,从而继承通用的@ExceptionHandler方法。
Spring 3.2为这类问题引入了一个新的解决方案:控制器通知。控制器通知(controller advice)是任意带有@ControllerAdvice注解的类,这个类会包含一个或多个如下类型的方法:
- @ExceptionHandler注解标注的方法;
- @InitBinder注解标注的方法;
- @ModelAttribute注解标注的方法。
在带有@ControllerAdvice注解的类中,以上所述的这些方法会运用到整个应用程序所有控制器中带有@RequestMapping注解的方法上。
@ControllerAdvice注解本身已经使用了@Component,因此@ControllerAdvice注解所标注的类将会自动被组件扫描获取到,就像带有@Component注解的类一样。 @ControllerAdvice最为实用的一个场景就是将所有的 @ExceptionHandler方法收集到一个类中,这样所有控制器的异常就能在一个地方进行一致的处理。例如,我们想将DuplicateSpittleException的处理方法用到整个应用程序的所有控制器上。如下的程序清单展现的 AppWideExceptionHandler就能完成这一任务,这是一个带有@ControllerAdvice注解的类。
现在,如果任意的控制器方法抛出了 DuplicateSpittleException,不管这个方法位于哪个控制器 中,都会调用这个duplicateSpittleHandler()方法来处理异常。我们可以像编写@RequestMapping注解的方法那样来编写 @ExceptionHandler注解的方法。
7.5 跨重定向请求传递数据
在第5章,在控制器方法返回的视图名称中,我们借助了“redirect:”前缀的力量。当控制器方法返回的String值以“redirect:“开头的话,那么这个String不是用来查找视图的,而是用来指导浏览器进行重定向的路径。可以看到processRegistration()方法返回的“redirect:String”如下所示:
return "redirect:/spitter/" + spitter.getUsername();
Spring很难再让重定向功能变得更简单了。但是,Spring为重定向功能还提供了一些其他的辅助功能。 具体来讲,正在发起重定向功能的方法该如何发送数据给重定向的目 标方法呢?一般来讲,当一个处理器方法完成之后,该方法所指定的模型数据将会复制到请求中,并作为请求中的属性,请求会转发 (forward)到视图上进行渲染。因为控制器方法和视图所处理的是同一个请求,所以在转发的过程中,请求属性能够得以保存。
但是,如图7.1所示,当控制器的结果是重定向的话,原始的请求就结束了,并且会发起一个新的GET请求。原始请求中所
带有的模型数据也就随着请求一起消亡了。在新的请求属性中,没有任何的模型数据,这个请求必须要自己计算数据。
图7.1 模型的属性是以请求属性的形式存放在请求中的,在重定向后无法存活
显然,对于重定向来说,模型并不能用来传递数据。但是我们也有一些其他方案,能够从发起重定向的方法传递数据给处理重定向方法中:
- 使用URL模板以路径变量和/或查询参数的形式传递数据;
- 通过flash属性发送数据
7.5.1 通过URL模板进行重定向
通过路径变量和查询参数传递数据看起来非常简单。例如:
return "redirect:/spitter/" + spitter.getUsername();
我们以路径变量的形式传递了新创建Spitter的 username。但是按照现在的写法,username的值是直接连接到重 定向String上的。这能够正常运行,但是还远远不能说没有问题。 当构建URL或SQL查询语句的时候,使用String连接是很危险的。
除了连接String的方式来构建重定向URL,Spring还提供了使用模板的方式来定义重定向URL。例如,processRegistration()方法的最后一行可以改写为如下的形式:
@RequestMapping(value="/register", method=RequestMethod.POST)
public String processRegistration(
Model model,
@Valid SpitterForm spitterForm,
Errors errors) throws IllegalStateException, IOException {
if (errors.hasErrors()) {
return "registerForm";
}
Spitter spitter = spitterForm.toSpitter();
spitterRepository.save(spitter);
MultipartFile profilePicture = spitterForm.getProfilePicture();
profilePicture.transferTo(new File(spitter.getUsername() + ".jpg"));
model.addAttribute("username", spitter.getUsername());
return "redirect:/spitter/{username}";
}
现在,username作为占位符填充到了URL模板中,而不是直接连接 到重定向String中,所以username中所有的不安全字符都会进行 转义。这样会更加安全,这里允许用户输入任何想要的内容作为username,并会将其附加到路径上。
运行结果如下:
注册成功后重定向:
除此之外,模型中所有其他的原始类型值都可以添加到URL中作为查询参数。作为样例,假设除了username以外,模型中还要包含新创建 Spitter对象的id属性,那processRegistration()方法可以改写为如下的形式:
@RequestMapping(value="/register", method=RequestMethod.POST)
public String processRegistration(
Model model,
@Valid SpitterForm spitterForm,
Errors errors) throws IllegalStateException, IOException {
if (errors.hasErrors()) {
return "registerForm";
}
Spitter spitter = spitterForm.toSpitter();
System.out.println("保存用户信息");
spitterRepository.save(spitter);
MultipartFile profilePicture = spitterForm.getProfilePicture();
System.out.println("保存图片");
System.out.println("上传图片名字:" + profilePicture.getOriginalFilename());
profilePicture.transferTo(new File(spitter.getUsername() + ".jpg"));
model.addAttribute("username", spitter.getUsername());
model.addAttribute("id", spitter.getId());
return "redirect:/spitter/{username}";
}
所返回的重定向String并没有太大的变化。但是,因为模型中的 spitterId属性没有匹配重定向URL中的任何占位符,所以它会自动以查询参数的形式附加到重定向URL上。 如果username属性的值是habuma并且spitterId属性的值是42, 那么结果得到的重定向URL路径将会是“/spitter/habuma? spitterId=42”。
通过路径变量和查询参数的形式跨重定向传递数据是很简单直接的方式,但它也有一定的限制。它只能用来发送简单的值,如String和数字的值。在URL中,并没有办法发送更为复杂的值,但这正是flash属性能够提供帮助的领域。
7.5.2 使用flash属性
假设我们不想在重定向中发送username或ID了,而是要发送实际的Spitter对象。如果我们只发送ID的话,那么处理重定向的方法还需要从数据库中查找才能得到Spitter对象。但是,在重定向之前,我们其实已经得到了Spitter对象。为什么不将其发送给处理重定向的方法,并将其展现出来呢?
Spitter对象要比String和int更为复杂。因此,我们不能像路径变量或查询参数那么容易地发送Spitter对象。它只能设置为模型中的属性。但是,模型数据最终是以请求参数的形式复制到请求中的,当重定向发生的时候,这些数据就会丢失。因此,我们需要将Spitter对象放到一个位置,使其能够在重定向的过程中存活下来。
有个方案是将Spitter放到会话中。会话能够长期存在,并且能够跨多个请求。所以我们可以在重定向发生之前将Spitter放到会话中,并在重定向后,从会话中将其取出。当然,我们还要负责在重定 向后在会话中将其清理掉。
实际上,Spring也认为将跨重定向存活的数据放到会话中是一个很不错的方式。但是,Spring认为我们并不需要管理这些数据,相 反,Spring提供了将数据发送为flash属性(flash attribute)的功能。 按照定义,flash属性会一直携带这些数据直到下一次请求,然后才会消失。
Spring提供了通过RedirectAttributes设置flash属性的方法,这是Spring 3.1引入的Model的一个子接口。RedirectAttributes提 供了Model的所有功能,除此之外,还有几个方法是用来设置flash属性的。
RedirectAttributes提供了一组 addFlashAttribute()方法来添加flash属性。重新看一 下processRegistration()方法,我们可以使用addFlashAttribute()将Spitter对象添加到模型中:
@RequestMapping(value="/register", method=RequestMethod.POST)
public String processRegistration(
RedirectAttributes redirectAttributes,
@Valid SpitterForm spitterForm,
Errors errors) throws IllegalStateException, IOException {
if (errors.hasErrors()) {
return "registerForm";
}
Spitter spitter = spitterForm.toSpitter();
spitterRepository.save(spitter);
MultipartFile profilePicture = spitterForm.getProfilePicture();
profilePicture.transferTo(new File(spitter.getUsername() + ".jpg"));
redirectAttributes.addAttribute("username", spitter.getUsername());
redirectAttributes.addFlashAttribute("spitter", spitter);
return "redirect:/spitter/{username}";
}
redirectAttributes.addFlashAttribute("spitter", spitter);
return "redirect:/spitter/{username}";
}
在这里,我们调用了addFlashAttribute()方法,并将spitter 作为key,Spitter对象作为值。另外,我们还可以不设置key参 数,让key根据值的类型自行推断得出:因为我们传递了一个Spitter对象给addFlashAttribute()方 法,所以推断得到的key将会是spitter。
在重定向执行之前,所有的flash属性都会复制到会话中。在重定向后,存在会话中的flash属性会被取出,并从会话转移到模型之中。处理重定向的方法就能从模型中访问Spitter对象了,就像获取其他的模型对象一样。图7.2阐述了它是如何运行的。
为了完成flash属性的流程,如下展现了更新版本的showSpitterProfile()方法,在从数据库中查找之前,它会首先从模型中检查Spitter对象:
@RequestMapping(value="/{username}", method=RequestMethod.GET)
public String showSpitterProfile(@PathVariable String username, Model model) {
if(!model.containsAttribute("spitter")) {
System.out.println("模型中不包含spitter属性");
model.addAttribute(spitterRepository.findByUsername(username));
}
return "profile";
}
showSpitterProfile()方法所做的第一件事就是检查是否存有key为spitter的model属性。如果模型中包含spitter属性,那就什么都不用做了。这里面包含的Spitter对象将会传递到 视图中进行渲染。但是如果模型中不包含spitter属性的话,那么showSpitterProfile()将会从Repository中查找Spitter,并将其存放到模型中。