Spring MVC配置简单,特别是在SpringBoot出现后基本都是开箱即用。在实际项目中通常是需要单独去处理一些特殊的情况,比如统一的异常处理,校验器以及国际化。
为了简化相关的配置和包的引入,例子基于SpringBoot。首先引入相关的依赖包。
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.3.1.RELEASE</version></parent>
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency></dependencies>
然后可以直接创建Controller类,即可实现一个基于SpringMVC的HTTP服务。
@RestControllerpublic class ExampleController {
@RequestMapping("/") String home() { return "Hello World!"; }}
@SpringBootApplicationpublic class Application {
public static void main(String[] args) { SpringApplication.run(Application.class, args); }
}
@RequestMapping
表示当前方法所对应的 Contextpath
@RestController
表示当前类,为Controller类,并且每个方法的ViewResolver都是采用JSON的方式来渲染。
Controller发生了异常该如何处理。直接抛出异常,这是一种不可取得行为,对前端不友好,而且也可能暴露服务端的一些细节,给网络攻击提供一些便利的信息。每个Controller的方法都使用try ... catch 包裹住,这样的话代码的冗余度非常的高。这很容易让人想到了面向切面编程,SpringMVC提供了一个ControllerAdvice
机制来处理这种情况。这个注解的意义是拦截所有你在里面定义的异常。
@ControllerAdvicepublic class CustomErrorController {
private static final Logger LOGGER = LoggerFactory.getLogger(CustomErrorController.class);
@ExceptionHandler({ RuntimeException.class }) @ResponseStatus(HttpStatus.OK) public @ResponseBody HttpResponse<Void> processException(RuntimeException ex) { LOGGER.error(this.getClass().getName(), ex); HttpResponse<Void> response = new HttpResponse<>(); response.fail().setMsg(ex.getMessage()); return response; }
@ExceptionHandler({ Exception.class }) @ResponseStatus(HttpStatus.OK) public @ResponseBody HttpResponse<Void> processException(Exception ex) { LOGGER.error(this.getClass().getName(), ex); HttpResponse<Void> response = new HttpResponse<>(); response.fail().setMsg(ex.getMessage()); return response; }
@ExceptionHandler({ BindException.class }) @ResponseStatus(HttpStatus.OK) public @ResponseBody HttpResponse<Void> processException(BindException ex) {
LOGGER.error(this.getClass().getName(), ex.getMessage());
HttpResponse<Void> response = new HttpResponse<>(); response.fail();
response.setMsg(getErrorMessage(ex)); return response; }}
如上述代码 ExceptionHandler
注解在某个方法上表示的是该方法处理该注解所标识的异常。这里面是统一对异常进行处理返回了自定义的HttpResponse对象。
通过ControllerAdvice能解决请求到达了Controller后的所有的异常,但是如果还未到达业务逻辑所产生的异常同样是会直接抛到前端去,正好SpringMVC框架在处理路由的时候如果没有找到路由是会产生这样的异常。
/** * 未处理错误页面 * * 由于Spring MVC 的 DispatchServlet.throwExceptionIfNoHandler 直接返回了 404错误 * * 404错误还没到Controller,无法被 ControllerAdvice捕获 * 需要单独的错误处理 */@RestControllerpublic class NotFoundController implements ErrorController{
private static final String PATH = "/error"; public String getErrorPath() { return PATH; } @RequestMapping(PATH) public HttpResponse<Void> handler(HttpServletRequest request){ HttpResponse<Void> response = new HttpResponse<>(); Integer statusCode = (Integer) request.getAttribute("javax.servlet.error.status_code"); if(Objects.equal(statusCode, org.apache.http.HttpStatus.SC_NOT_FOUND)){ response.setCode(statusCode); } else{ response.fail().setMsg("unknown error"); } return response; }
}
上述代码定义了一个通用error处理的页面,当框架抛出异常,会转到 /error地址,我们对其进行了定制。
web环境的输入比较复杂,后端需要对输入做好保底的业务正确性校验。Spring MVC 提供了两种方法来对用户的输入数据进行校验,一种是 Spring 自带的 Validation 校验框架,另一种是利用 JRS-303 验证框架进行验证。通常使用 JRS-303 ,代表性的框架为 Hibernate-Validator,它所包含的功能如下表。
注解 | 功能 |
---|---|
@Null | 验证对象是否为null |
@NotNull | 验证对象是否不为null |
@AssertTrue | 验证Boolean对象是否为true |
@AssertTrue | 验证Boolean对象是否为false |
@Max(value) | 验证Number和String对象是否小于等于指定值 |
@Min(value) | 验证Number和String对象是否大于等于指定值 |
@DecimalMax(value) | 验证注解的元素值小于等于@DecimalMax指定的value值 |
@DecimalMin(value) | 验证注解的元素值大于等于@DecimalMin指定的value值 |
@Digits(integer,fraction) | 验证字符串是否符合指定格式的数字,integer指定整数精度,fraction指定小数精度 |
@Size(min,max) | 验证对象长度是否在给定的范围内 |
@Past | 验证Date和Calendar对象是否在当前时间之前 |
@Future | 验证Date和Calendar对象是否在当前时间之后 |
@Pattern | 验证String对象是否符合正则表达式的规则 |
@NotBlank | 检查字符串是不是Null,被Trim的长度是否大于0,只对字符串,且会去掉前后空格 |
@URL | 验证是否是合法的url |
验证是否是合法的邮箱 | |
@CreditCardNumber | 验证是否是合法的信用卡号 |
@Length(min,max) | 验证字符串的长度必须在指定范围内 |
@NotEmpty | 检查元素是否为Null或Empty |
使用这些注解来标注接收参数的表单对象,然后在需要校验的时候使用@Validated
注解进行标注。
@Datapublic class User {
@NotBlank(message = "用户名不能为空") private String username; @NotBlank(message = "密码不能为空") @Length(min = 6, max = 16, message = "密码的长度必须在6~16位之间") private String password; @Range(min = 18, max = 60, message = "年龄必须在18岁到60岁之间") private Integer age; @Pattern(regexp = "^1[3|4|5|7|8][0-9]{9}$", message = "请输入正确格式的手机号") private String phone; @Email(message = "请输入合法的邮箱地址") private String email;}
@RestControllerpublic class UserController {
@RequestMapping("/register") public HttpResponse register(@Validated User user) { // logic }}
当然以上注解在实际项目中远远不够用,有一些业务的校验本身就比较复杂。在参数解析的时候进行校验的话,还需要做很多跟业务相关的逻辑,但是如果把校验逻辑放到Controller或者Service里面又显得很服务非常复杂,并且校验逻辑无法复用。SpringMVC支持我们进行校验器的自定义。
@Documented@Constraint(validatedBy = UserValidaror.class)@Target({ElementType.TYPE})@Retention(RetentionPolicy.RUNTIME)public @interface UserConstraint {
String message() default ""; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; }
@Conpementpublic class UserValidaror implements ConstraintValidator<UserConstraint, User> { public void initialize(UserConstraint constraintAnnotation) { }
public boolean isValid(User value, ConstraintValidatorContext context) { // 比如校验邮箱或者电话的唯一性,或者其他需要通过服务调用 }}
@Data@UserConstraintpublic class User {
@NotBlank(message = "用户名不能为空") private String username; @NotBlank(message = "密码不能为空") @Length(min = 6, max = 16, message = "密码的长度必须在6~16位之间") private String password; @Range(min = 18, max = 60, message = "年龄必须在18岁到60岁之间") private Integer age; @Pattern(regexp = "^1[3|4|5|7|8][0-9]{9}$", message = "请输入正确格式的手机号") private String phone; @Email(message = "请输入合法的邮箱地址") private String email;}
如上述代码,我们需要定义一个UserConstraint 注解,它将使用在表单接收模型User类上面,同时UserConstraint定义的时候即指定其对应的校验器类UserValidaror。UserValidaror实现了ConstraintValidator接口,使用isValid方法进行校验逻辑的业务实现。在使用的时候UserValidaror需要托管到Spring进行实例化。
Constraint 注解都有一个group属性,用来指定校验的分组。因为并不是每一个操作需要校验所有的属性,比如新增和更新 校验的参数不一样。那么我们就可以定义两个分组。
@UserConstraint(groups={Create.class,Update.class})public class User {
@NotBlank(message = "用户名不能为空",groups={Create.class}) private String username; ...}
@RestControllerpublic class UserController {
@RequestMapping("/register") public HttpResponse register(@Validated(Create.class) User user) { // logic } @RequestMapping("/update") public HttpResponse register(@Validated(Update.class) User user) { // logic }}
在使用校验注解的时候指定了该注解的生效分组,如果没有指定的话则全部分组生效。再使用@Validated 指定校验的分组,则可以实现不同类型的操作,校验不同的内容。
在校验环节,我们直接把message放到了代码中。除了调整不方便,每次都需要重新编译和发布版本。还不能支持多语言。Spring Core 本身就有一个MessageSource 接口,用来实现各种消息的翻译。
@Configurationpublic class WebMvcConfs extends WebMvcConfigurerAdapter { @Bean public MessageSource messageSource(){ ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource (); messageSource.setBasename("i18n/message"); messageSource.setCacheSeconds(300); messageSource.setDefaultEncoding("UTF-8"); return messageSource; } // Validator 注入i18n 信息 public Validator getValidator() { LocalValidatorFactoryBean factory = new LocalValidatorFactoryBean(); factory.setValidationMessageSource(messageSource()); return factory; }}
上述代码配置了SpringMVC的 MessageSource实现和对Validator注入 翻译的MessageSource。它会根据Http Header中的Locale 来决定取哪个文件的配置来解析消息。比如locale 是zh_CN那么会取classpath下的i18n/message_zh_CN.properties来查找消息的对应翻译,如果查找不到则使用i18n/message.properties,兜底的文件没有的话则会发生异常,走入异常逻辑处理的环节。
那么要实现一个多语言的网站就比较简单了,只需要在界面上设置一个选择语言的交互界面。选择后设置对应的Locale,后续的请求和返回的内容则可以根据Locale来定制。
Validator 在引入了国际化的内容后,配置会有一些差别。首先我们不需要在配置注解里面写message,而是配置到对应的MessageSource文件里。
public class User { @NotBlank private String username; @Range(min = 18, max = 60) private Integer age;} // i18n/message.propertiesNotBlank.user.username=username can not be blankRange.user.age=age must between {min} in {max} // i18n/message_zh_CN.propertiesNotBlank.user.username=用户名不能为空Range.user.age=年龄必须在{min}岁到{max}岁之间
在定义i18n文件的时候可以使用变量,比如上述的Range注解对应Validate把min和max作为变量传入到校验后的结果中。那么配合国际化的时候我们的自定义注解也是可以做到。
@Conpementpublic class UserValidaror implements ConstraintValidator<UserConstraint, User> { public void initialize(UserConstraint constraintAnnotation) { }
public boolean isValid(User value, ConstraintValidatorContext context) { // 比如校验邮箱或者电话的唯一性,或者其他需要通过服务调用 if (/* some condition */) { HibernateConstraintValidatorContext hibernateValidatorContext = constraintValidatorContext.unwrap(HibernateConstraintValidatorContext.class); hibernateValidatorContext.disableDefaultConstraintViolation(); hibernateValidatorContext.addMessageParameter("age", "some value...").buildConstraintViolationWithTemplate("{Range.user.age}") .addPropertyNode("age").addConstraintViolation(); return false; } }}
除了校验的异常需要进行国际化,服务端使用返回码来提示的业务错误也需要进行国际化消息提醒。那么异常处理可以定义一个ServiceException 统一包装来处理。那么ControllerAdvice
可以增加以下两个处理方法
@ExceptionHandler({ BindException.class })@ResponseStatus(HttpStatus.OK)public @ResponseBody HttpResponse<Void> processException(BindException ex) { HttpResponse<Void> response = new HttpResponse<>(); response.fail(); response.setMsg(getErrorMessage(ex)); return response;}
@ExceptionHandler({ ServiceException.class })@ResponseStatus(HttpStatus.OK)public @ResponseBody HttpResponse<Void> processException(ServiceException ex) { HttpResponse<Void> response = new HttpResponse<>(); response.fail(); response.setMsg(getErrorMsg(ex)); return response;}
/** * 获取参数错误信息 * @param ex * @return */private String getErrorMsg(BindException ex){ String message = null; List<FieldError> fieldErrors = ex.getBindingResult().getFieldErrors(); if(CollectionUtils.isEmpty(fieldErrors)){ return messageSource.getMessage("Param.Error", new Object[]{},"参数错误",RequestContextUtils.getLocale(request)); } FieldError fieldError = fieldErrors.get(0); String[] codes = fieldError.getCodes(); for(String code:codes){ message = messageSource.getMessage(code, fieldError.getArguments(), RequestContextUtils.getLocale(request)); //最明细的消息有的话就直接返回 if(StringUtils.isNotEmpty(message)){ break; } } //如果没有定义i18n 信息 ,则取默认的 if(StringUtils.isEmpty(message)){ message = fieldError.getField() + fieldError.getDefaultMessage(); } return message;}
private String getErrorMsg(ServiceException ex){ String message = message = messageSource.getMessage(ex.getCode(), ex.getArguments(), RequestContextUtils.getLocale(request)); if(StringUtils.isEmpty(message)){ messageSource.getMessage(/**unknow exception code*/, new Obejct[]{}, RequestContextUtils.getLocale(request)); } return message;}
BindException 为 校验器默认的异常,ServiceException为自定义异常。分别对他们的以及内容进行i18n信息的翻译。