mid - 注册参数校验 & 登录 · SpringBoot3
用户注册接口的参数合法性校验
注册参数校验 & 全局异常处理
由于用户输入未必是合法性字符, 所以我们需要加以约束和校验, 但是每次都手写验证代码太冗长且繁杂, 于是我们引入Spring Validation模块简化验证步骤 —> spring-boot-starter-validation 依赖
参数校验
基础校验 & 常用注解
参数前加 @Pattern注解 Controller类前添加 @Validated
以下是常用属性
| 属性 | 含义 |
|---|---|
regexp | regular expression,正则表达式规则 |
message | 校验失败时返回的提示信息 |
Not类型
| 注解 | 作用 | 适用类型 | 特点 |
|---|---|---|---|
@NotNull | 值不能为 null | 任意对象类型 | 只校验是否为 null,不管内容是否为空 |
@NotEmpty | 值不能为空 | String、集合、数组、Map | 不能为 null,长度或元素个数必须大于 0 |
@NotBlank | 字符串不能为空白 | String | 不能为 null,且去掉首尾空格后长度必须大于 0 |
| 输入值 | @NotNull | @NotEmpty | @NotBlank |
|---|---|---|---|
null | ❌ | ❌ | ❌ |
"" | ✅ | ❌ | ❌ |
" " | ✅ | ✅ | ❌ |
"abc" | ✅ | ✅ | ✅ |
长度数值
| 注解 | 作用 | 适用类型 | 常用属性 | 特点 |
|---|---|---|---|---|
@Size | 限制长度或元素个数 | String、集合、数组、Map | min、max | 通用性最强 |
@Min | 最小值限制(≥) | 数值类型 | value | 包含边界 |
@Max | 最大值限制(≤) | 数值类型 | value | 包含边界 |
@Range | 数值范围限制 | 数值类型 | min、max | Hibernate 提供,等价 Min+Max |
正则类
| 注解 | 作用 | 适用类型 | 常用属性 | 特点 |
|---|---|---|---|---|
@Pattern | 用正则表达式校验格式 | String | regexp、message | 最通用,用户名密码手机号都能管 |
@Email | 校验邮箱格式 | String | message | 语义更直接,本质也是格式校验 |
valid & validated
@Valid
@Valid 是 Java Bean Validation 标准里的注解,
作用是:
👉 告诉 Spring:对这个对象执行校验
一般写在方法参数前:
代码块收起展开
public Result register(@RequestBody @Valid RegisterDTO dto)意思是:
- 接收到请求体
- 封装成
RegisterDTO - 然后按 DTO 上的校验注解执行校验
- 只做默认校验, 不支持分组校验
@Validated
支持分组校验
@Validated 是 Spring 提供的增强注解,
作用也是开启校验,但功能更强,支持分组校验。
常见写法有两种:
写在类上
用于开启方法参数校验:
代码块收起展开
@Validated
@RestController
public class UserController {
}写在参数上
也可以直接写在对象参数前:
代码块收起展开
public Result register(@RequestBody @Validated RegisterDTO dto)隐藏注解
返回用户信息时有些字段需要隐藏, 可以在实体类的相关字段上添加@JsonIgnore
它们怎么搭配
这块你可以这样写:
场景 1:普通对象校验
一般直接这样:
代码块收起展开
public Result register(@RequestBody @Valid RegisterDTO dto)场景 2:方法参数校验
比如直接校验普通参数:
代码块收起展开
@Validated
@RestController
public class UserController {
@PostMapping("/register")
public Result register(
@Pattern(regexp = "^\\S{5,16}$") String username,
@Pattern(regexp = "^\\S{5,16}$") String password) {
return Result.success();
}
}这里通常要在类上加 @Validated,不然方法参数上的约束可能不生效。
分组校验
- 不分组就是默认分组, 分组可以继承!!!! 同一个对象,在不同业务场景下,校验规则不一样。
比如一个 UserDTO
- 新增用户时:
id不需要传 - 更新用户时:
id必须传 - 注册时:要校验
username、password
method
- 按照需要写出多个实体类来接收前端参数, 这样太过于繁琐
- 分组校验机制
代码块收起展开
UserDTO
├─ username: 注册时必填 -> RegisterGroup
├─ password: 注册时必填 -> RegisterGroup
├─ id: 更新时必填 -> UpdateGroup
└─ nickname: 修改资料时可校验 -> ProfileGroup1.定义分组接口
(无逻辑纯标签)
代码块收起展开
public interface AddGroup {
}
public interface UpdateGroup {
}2. 在字段注解上指定groups
代码块收起展开
public class UserDTO {
@NotNull(message = "更新时id不能为空",groups = UpdateGroup.class)
private Long id;
@NotBlank(message = "新增时用户名不能为空",groups =AddGroup.class)
private String username;
@NotBlank(message = "新增时密码不能为空", groups = AddGroup.class)
private String password;
}3.Controller里用@Validated
代码块收起展开
@PostMapping("/add")
public Result add
(@RequestBody @Validated(AddGroup.class) UserDTO userDTO) {
return Result.success();
}更新下面的接口添加@Validated(xxxGroup.class)
代码块收起展开
@PostMapping("/update")
public Result update
(@RequestBody @Validated(UpdateGroup.class) UserDTO userDTO) {
return Result.success();
}自定义校验
这些场景,官方注解就不够优雅了:
- 用户名只能是 6 到 20 位字母数字下划线
- 手机号必须符合中国大陆格式
- 两次密码要一致
- 文章分类 id 必须存在
- 不能包含敏感词 这时候就该上 自定义校验注解。
定义注解
代码块收起展开
@Documented
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = UsernameValidator.class)
public @interface Username {
String message() default "用户名格式不正确";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}定义校验器
代码块收起展开
public class UsernameValidator implements ConstraintValidator<Username, String> {
// 它校验的注解是 `Username`
// 它校验的字段类型是 `String`
@Override
public void initialize(Username constraintAnnotation) {
// 可选:初始化注解参数
}
//实现ConstraintValidator<inf,type>接口并重写isValid方法
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null) {
return false;
}
return value.matches("^[a-zA-Z0-9_]{6,20}$");
}
}
使用注解
代码块收起展开
public class UserDTO {
@Username
private String username;
}全局异常处理
由于 @Validated 校验注解失败后不会走方法的逻辑语句, 所以我们必须添加全局异常处理器进行全局异常捕捉
原理
“参数校验失败时异常在 Controller 执行前被抛出,
随后由 Spring MVC 的全局异常处理机制
根据 @ExceptionHandler 的异常类型匹配规则分发到对应的处理方法”。
异常往上抛给 DispatcherServlet
异常抛出后,请求处理链中断,
Spring MVC 不会继续执行你的业务逻辑,而是进入:
👉 异常处理流程
Spring 会去找有没有合适的 异常解析器HandlerExceptionResolver 来处理这个异常。
@RestControllerAdvice 被注册成全局异常处理器, 这是一个 面向所有 @RestController 的全局异常处理增强类
ExceptionHandler(Exception.class) 表明: 如果系统里抛出了 Exception 及其子类异常,可以交给这个方法处理
代码块收起展开
@RestControlleAdvice //被注册为全局异常处理器
public class GlobalExceptionHandler{
@ExceptionHandler(Exception.class)//抛出此种异常交给此方法处理
public Result handlerException(Exception e){
e.printStackTrace();
return Result.error(
StringUtil.isNUllOrEmpty(e.getMessage()) ? "系统繁忙, 请稍后重试" : e.getMessage()));
}
}登录认证
代码块收起展开
登录成功一次
↓
后续请求不能反复查账号密码
↓
需要一个可验证的身份凭证用户信息获取
一次请求里:
代码块收起展开
拦截器 -> Controller -> Service -> DAO你在拦截器里拿到了 userId
后面层层传参麻烦 ----> ThreadLocal.set(userId);
ThreadLocal
- 解决同一线程调用链路中数据共享的问题,避免层层传参。
- 给每个线程单独放一份变量副本, 不加锁 , 实现线程隔离
- 使用完必须调用remove(), 否则可能造成内存泄漏和脏数据
- 数据不存在ThreadLocal对象里, 而是在thread中
代码块收起展开
Thread
└── ThreadLocalMap
└── entry(ThreadLocal -> value)工具类编写
代码块收起展开
public class ThreadLocalUtil {
private static final ThreadLocal<Long> THREAD_LOCAL = new ThreadLocal<>();
public static void set(Long userId) {
THREAD_LOCAL.set(userId);
}
public static Long get() {
return THREAD_LOCAL.get();
}
public static void remove() {
THREAD_LOCAL.remove();
}
}拦截器放数据
代码块收起展开
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
Long userId = 1L; // 假设从 token 中解析出来
ThreadLocalUtil.set(userId);
return true;
}业务放数据
代码块收起展开
Long userId = ThreadLocalUtil.get();请求结束清理
代码块收起展开
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
ThreadLocalUtil.remove();
}主要流程
代码块收起展开
登录拦截器 preHandle
-> 解析 token
-> 拿到 userId
-> 存入 ThreadLocal
Controller / Service
-> 随时 get 当前用户
请求结束 afterCompletion
-> remove令牌
常见做法是服务端生成一个令牌 Token 返回给前端,前端在后续请求中携带该令牌。 服务端只需要校验令牌是否合法,就能确认当前用户身份。
JWT
- JWT = JSON Web Token
- 为什么叫 JSON:因为载荷部分通常存的是 JSON 数据
- 为什么叫 Token:因为它本质是令牌,代表一次身份凭证
组成
-
Header(头) : 记录令牌类型、签名算法等。 例如:{“alg”:“HS256”, “type”:“JWT”}.
-
Payload(有效载荷) : 携带一些自定义信息、默认信息等。 例如:{“id”:“1”,“username”:“Tom”}.
-
Signature(签名) : 防止Token被篡改、确保安全性。 将header、payload,并加入指定秘钥,通过指定签名算法计算.
Base64:是一种基于64个可打印字符 A-Z a-z 0-9 + / 来表示二进制数据的编码方式
代码块收起展开
private static final String KEY = "QueSera";
//接收业务数据, 生成token并返回
public static String getToken(Map<String,Object> claims){
return JWT.create()
.withClaim("claims",claims)//往JWT的payload里放自定义声明字段
.withExpiresAt( //12个小时
new Date(System.currentTimeMills()+1000*60*60*12))
.sign(Algorithm.HMAC256(KEY));
}
//解析token, 验证token并返回业务数据
public static Map<String,Object> parseToken(String token){
return JWT.require(Algorithm.HMAC256(KEY))
.build()
.verify(tokeb)
.getClaim("claims")
.asMap();
}