8. 在SpringMVC框架中统一处理异常
在SpringMVC框架中提供了统一处理异常的机制(当然,在SpringBoot框架中也可以直接使用),使得每种异常只需要被处理1次即可,即使某种异常在多种请求中都会出现,也不需要反复处理!其核心是开发人员调用了可能抛出异常的方法时,在控制器中,直接将异常再次抛出,则SpringMVC在调用控制器的方法时,就会捕获到对应的异常对象,并且,如果开发人员定义了统一处理异常的方法,则SpringMVC框架就会自动调用该方法来处理异常!
关于统一处理异常的方法:
-
默认情况下,该方法只能作用于当前控制器类中的相关请求,例如,将该方法写在
UserController
中,只能作用了UserController
中处理的各个请求,如果在其它控制器的方法执行过程中出现了异常,是不会被处理的!关于这个问题,可选择的解决方案有2种:- 将处理异常的方法写在控制器类的基类中,各控制器类都继承自该基类即可;
- 将处理异常的方法定义在任意类中,并在这个类的声明之前添加
@ControllerAdvice
或@RestControllerAdvice
注解,各控制器类不需要继承自该类;
-
统一处理异常的方法必须添加
@ExceptionHandler
注解; -
应该使用
public
权限; -
返回值的类型,可参考处理请求的方法的返回值的设计原则;
-
方法名称可以自定义;
-
方法的参数列表至少需要添加异常类型的参数,用于表示被框架捕获的异常对象,关于参数的异常类型,要求能够表示任何将被处理的异常;方法的参数列表中还可以添加其它参数,但是,只能添加SpringMVC框架允许的几种参数,例如
HttpServletRequest
、HttpServletResponse
等,不能像处理请求的方法那样随意!
可以在项目的cn.tedu.straw.portal.controller
包中创建GlobalExceptionHandler
类,用于统一处理异常,在类的声明之前添加@RestControllerAdvice
,使得该类中处理异常的方法能作用于整个项目,并在这个类中添加方法来处理异常:
package cn.tedu.straw.portal.controller;
import cn.tedu.straw.portal.service.ex.ClassDisabledException;
import cn.tedu.straw.portal.service.ex.InsertException;
import cn.tedu.straw.portal.service.ex.InviteCodeException;
import cn.tedu.straw.portal.service.ex.PhoneDuplicateException;
import cn.tedu.straw.portal.vo.R;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler
public R handleException(Throwable e) {
if (e instanceof InviteCodeException) {
return R.failure(12, e);
} else if (e instanceof ClassDisabledException) {
return R.failure(13, e);
} else if (e instanceof PhoneDuplicateException) {
return R.failure(14, e);
} else if (e instanceof InsertException) {
return R.failure(15, e);
} else {
return R.failure(9999, e);
}
}
}
为了便于统一管理错误代号,并增加代码的可读性,应该将这些错误代号声明为静态常量,同时,为了便于声明和管理这些静态常量,可以在R
类中使用静态内部接口来声明:
package cn.tedu.straw.portal.vo;
import lombok.Data;
import lombok.experimental.Accessors;
@Data
@Accessors(chain=true)
public class R {
private Integer state;
private String message;
public static R ok() {
return new R().setState(State.OK);
}
public static R failure(Integer state, String message) {
return new R().setState(state).setMessage(message);
}
public static R failure(Integer state, Throwable e) {
return failure(state, e.getMessage());
}
public static interface State {
int OK = 0;
int ERR_INVITE_CODE = 4001;
int ERR_CLASS_DISABLED = 4002;
int ERR_PHONE_DUPLICATE = 4003;
int ERR_INSERT = 4004;
int ERR_UNKNOWN = 9999;
}
}
然后,在处理异常时,错误代号就使用这些静态常量:
package cn.tedu.straw.portal.controller;
import cn.tedu.straw.portal.service.ex.ClassDisabledException;
import cn.tedu.straw.portal.service.ex.InsertException;
import cn.tedu.straw.portal.service.ex.InviteCodeException;
import cn.tedu.straw.portal.service.ex.PhoneDuplicateException;
import cn.tedu.straw.portal.vo.R;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler
public R handleException(Throwable e) {
if (e instanceof InviteCodeException) {
return R.failure(R.State.ERR_INVITE_CODE, e);
} else if (e instanceof ClassDisabledException) {
return R.failure(R.State.ERR_CLASS_DISABLED, e);
} else if (e instanceof PhoneDuplicateException) {
return R.failure(R.State.ERR_PHONE_DUPLICATE, e);
} else if (e instanceof InsertException) {
return R.failure(R.State.ERR_INSERT, e);
} else {
return R.failure(R.State.ERR_UNKNOWN, e);
}
}
}
9. 请求参数验证
对于服务器端的开发而言,所有由客户端提交的请求参数都应该将其视为是不可靠的,例如“用户名”可能是1个字母,或其它基本格式不正确(长度、组成字符)的问题,即使客户端本身就存在检查的机制也是不可靠的,毕竟客户端存在被篡改的可能性,或者非浏览器的客户端也可能存在用户使用的版本没有更新而导致请求参数格式有误的问题!所以,服务器端在接收到请求参数的第一时间就应该检查这些参数的有效性!
注意:即使服务器端进行了所有参数的检查,客户端的检查也是必须存在的!主要是将绝大部分错误的请求拦截下来,以减少服务器端的压力!
实现服务器端检查时,可以使用hibernate-validation
来实现,目前,它已经被整合到spring-boot-starter-validation
了,所以,先在项目中添加该依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
关于这个验证机制,其做法是针对某个对象的属性进行验证,在需要验证的属性之前可以添加一些注解表示验证规则,常用的注解有:
@NotNull
:不允许没有值,即不允许是null
;@NotEmpty
:不允许为空字符串值,即字符串的长度必须大于0;@NotBlank
:不允许为空白,即字符串中必须包含除了空白以外的字符,例如" "
也是错的;@Pattern
:可以在注解参数中定义验证时使用的正则表达式;@Size
:验证字符串值的长度是否在某个区间范围之内;- 其它……
例如,可以在User
类的属性之前添加验证相关的注解,例如,先在password
属性之前添加验证的注解:
/**
* 密码
*/
@TableField("password")
@NotBlank(message = "密码不允许为空!")
@Size(min = 4, max = 16, message = "密码必须是4~16个字符!")
private String password;
然后,需要在控制器类中,在处理请求的方法的参数列表中,在被验证的对象之前添加@Valid
或@Validated
注解,之后添加BindingResult
参数,在处理请求的方法体中,判断BindingResult
参数以得到验证结果:
// http://localhost:8080/portal/user/student/register?inviteCode=JSD2003-111111&nickname=Hello&phone=13800138002&password=1234
@RequestMapping("/student/register")
public R studentRegister(String inviteCode,
@Validated User user, BindingResult bindingResult) {
if (inviteCode == null || inviteCode.length() < 4 || inviteCode.length() > 20) {
throw new ParameterValidationException("邀请码必须是4~20个字符!");
}
if (bindingResult.hasErrors()) {
String errorMessage = bindingResult
.getFieldError().getDefaultMessage();
log.debug("validation has error : {}", errorMessage);
throw new ParameterValidationException(errorMessage);
}
userService.registerStudent(user, inviteCode);
return R.ok();
}
关于以上验证:
- 被验证的必须是1个对象;
- 封装验证结果的
BindingResult
必须声明在被验证的参数之后; - 验证框架并不能完成所有验证需求,如果某些验证规则是验证框架无法做法的,则自己编写验证规则即可;
- 如果验证过程中出现错误,并且在控制器中并没有使用
BindingResult
接收错误信息,就会抛出BindException
,在统一处理异常的代码中直接处理这个异常也是可以的; - 以上演示代码还会涉及
R
和GlobalExceptionHandler
这2个类中和其它相关的内容。
10. 注册前端页面测试
为了避免Spring Security拦截异步请求,需要自定义配置类,继承自WebSecurityConfigurerAdapter
,重写protected void configure(HttpSecurity http)
,调用参数对象的csrf().disable()
方法,网页中才可以正常提交AJAX请求!
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable();
}
前端将使用Vue访问页面元素,使用AJAX提交异步请求并处理结果,关于这2个前端框架的基本使用,演示如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TIEQWxDD-1595003772435)(HTML_AND_VUE.png)]
测试页面代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>学生注册</title>
<script src="jquery.min.js"></script>
<script src="vue.js"></script>
<style>
.err { color: red; }
</style>
</head>
<body>
<h1>学生注册</h1>
<div id="register">
<form id="form-register" v-on:submit.prevent="register" action="/portal/user/student/register">
<table>
<tr>
<td>邀请码</td>
<td><input name="inviteCode"><span class="err" v-text="inviteCodeMessage"></span></td>
</tr>
<tr>
<td>手机号码</td>
<td><input name="phone"><span class="err" v-text="phoneMessage"></span></td>
</tr>
<tr>
<td>昵称</td>
<td><input name="nickname"></td>
</tr>
<tr>
<td>密码</td>
<td><input name="password"></td>
</tr>
<tr>
<td>确认密码</td>
<td><input name="confirmPassword"></td>
</tr>
<tr>
<td> </td>
<td><input value="用户注册" type="submit"></td>
</tr>
</table>
</form>
</div>
<script>
// 创建Vue数据模型
let app = new Vue({
el: '#register',
data:{
inviteCodeMessage: null,
phoneMessage: null
},
methods: {
register: function () {
// alert("准备注册!");
app.inviteCodeMessage = null;
app.phoneMessage = null;
$.ajax({
url: '/portal/user/student/register',
data: $('#form-register').serialize(),
type: 'post',
dataType: 'json',
success: function(json) {
console.log(json);
if (json.state == 2000) {
alert("注册成功!");
} else if (json.state == 4001 || json.state == 4002) {
app.inviteCodeMessage = json.message;
} else if (json.state == 4003) {
app.phoneMessage = json.message;
} else {
alert("注册失败!" + json.message);
}
}
});
}
}
});
</script>
</body>
</html>
关于实际使用的页面,可以通过https://robinliu.3322.org:8888/download/demo/straw_v1.4.zip下载,将下载得到的页面及相关文件复制到项目的src/main/resources/static文件夹下即可(如果static文件夹不存在,可自行创建)。
关于实际使用的注册页面,需要调整的位置有:
-
第30行:为
<div>
标签添加v-bind:class="{'d-block': hasError}"
,表示通过Vue的hasError
属性来控制CSS样式中名为d-block
的值,这个d-block
表示display block
,即将当前标签以区块的形式显示出来,它是由Bootstrap定义的,取值是布尔值; -
第31行:为
<span>
标签添加v-text="errorMessage"
,表示该标签显示的文本绑定到Vue的errorMessage
属性; -
第34行:为
<form>
标签添加id="form-register"
和v-on:submit.prevent="register"
,前者将用于AJAX请求中序列化请求参数,后者表示绑定表单的提交事件到Vue的register
方法(函数),其中的prevent
表示阻止原有的提交方法,在这个<form>
标签中原有的其它属性都可以删除; -
第37行:将
<input>
标签中的v-model="inviteCode"
删除,否则可能报错; -
自行添加
<script>
标签处理页面:-
<script> let app = new Vue({ el: '#app', data: { hasError: false, errorMessage: null }, methods: { register: function () { app.hasError = false; app.errorMessage = null; $.ajax({ url: '/portal/user/student/register', data: $('#form-register').serialize(), type: 'post', dataType: 'json', success: function (json) { console.log(json); if (json.state == 2000) { alert("注册成功!"); // location.href = '某个页面'; } else { app.hasError = true; app.errorMessage = json.message; } } }); } } }); </script>
-
11. 注册成功后的收尾工作
当前user
数据表的设计中,关于密码字段是char(68)
,但是,实际存入的密码长度只有60位,其实,在存入密码之前,应该在加密结果之前添加{bcrpyt}
前缀,这个前缀的作用是声明当前密文的加密方式是通过BCrpyt
算法实现的,Spring Security可以根据算法类型自动调用匹配的算法进行密码验证!
所以,在UserServiceImpl
类中,在加密方法中添加补全前缀:
/**
* 执行密码加密
*
* @param rawPassword 原密码
* @return 根据原密码执行加密得到的密文
*/
private String encode(String rawPassword) {
String encodePassword = passwordEncoder.encode(rawPassword);
encodePassword = "{bcrypt}" + encodePassword;
return encodePassword;
}
由于改变了密码规则,原有的密码就不合适了,甚至原本还存在更多的错误数据,应该将这些数据全部删除:
delete from user;
或:
truncate user;
12. 阶段小结
关于使用到的技术:
- SpringBoot;
- Lombok:使得开发人员不必再显式的声明Setters、Getters、toString()等代码,还可以更加便捷的使用
@Slf4j
注解来输出日志; - Slf4j:输出日志,自定义日志级别,输出时可以使用占位符避免反复拼接字符串;
- MyBatis Plus:已经完成了许多常规的增删改查,使得开发人员不必自行编写相关代码,简化了持久层的开发,当然,MyBatis Plus已经完成的功能并不能满足所有的需求,甚至某些方法可能不太易用,如果开发人员需要自定义其它数据访问功能,也可以参考MyBatis的使用方式来开发新的数据访问功能;
- MyBatis Plus Generator:用于自动生成一些项目中的文件,包括:实体类、持久层接口、持久层XML、业务层接口、业务层实现类、控制器类,它是基于数据表的字段设计来自动生成的;
- Spring Security:未完待续;
- Spring Validation:验证请求参数的有效性;
- 自定义异常和统一处理异常。
输出日志,自定义日志级别,输出时可以使用占位符避免反复拼接字符串;
- MyBatis Plus:已经完成了许多常规的增删改查,使得开发人员不必自行编写相关代码,简化了持久层的开发,当然,MyBatis Plus已经完成的功能并不能满足所有的需求,甚至某些方法可能不太易用,如果开发人员需要自定义其它数据访问功能,也可以参考MyBatis的使用方式来开发新的数据访问功能;
- MyBatis Plus Generator:用于自动生成一些项目中的文件,包括:实体类、持久层接口、持久层XML、业务层接口、业务层实现类、控制器类,它是基于数据表的字段设计来自动生成的;
- Spring Security:未完待续;
- Spring Validation:验证请求参数的有效性;
- 自定义异常和统一处理异常。