我们写代码的过程,事实上就是不断处理异常的过程
if else
if(a.size > 10 && a.size < 100){
Result result = Reuslt.failure("非法参数size , 请检查输入!") ;
return result;
}
if(xxx)
return xxx ;
if (validBusinessData(dataId)) {
Result result = Reuslt.failure("xxx") ;
return result;
}
private boolean validBusinessData(dataId) {
// RPC getByDataId
// 调用结果的处理/判断,balabala...
return Result.isSuccess(data);
}
Bean Validation 2.0 (JSR-380)
- 最新的Bean验证提议编号为
JSR380
,提出了Bean Validation 2.0
规范 Hibernate Validator 6.0.x.Final
就是参考Bean Validation 2.0
实现的- JSR-380规范是bean验证的java api规范,javaEE和javaSE的一部分,使用注解如@NotNull, @Min, and @Max,确保bean属性符合一定条件。
- JSR-380 需要java8或以上版本,利用java8中新增的特性,如注解类型,支持新的类如:Optional 和 LocalDate。
- Bean Validation 主页
- 兼容性表格
Bean Validation | Hibernate Validation | JDK | Spring Boot |
---|---|---|---|
1.1 | 5.4 + | 6+ | 1.5.x |
2.0 | 6.0 + | 8+ | 2.0.x |
集成
- 注意:
Spring Boot 2.3.0
版本后spring-boot-starter-web
和spring-boot-starter-webflux
不再内置validation starter
,需手动引入spring-boot-starter-validation
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
校验注解
Jakarta Bean Validation/JSR提供的所有校验注解
@Null
必须为 null@NotNull
必须不为 null@NotEmpty
必须不为 null 或 空@NotBlank
字符序列必须不为空,以及去空格后的长度必须大于 0。与@NotEmpty
的不同之处在于,此约束只能应用于字符。@AssertTrue
必须为 true@AssertFalse
必须为 false@Digits(integer=, fraction=)
必须最多包含 integer 位整数和 fraction 位小数的数字@Email
必须为有效的电子邮件地址。可选参数 regexp 和 flags 允许指定电子邮件必须匹配的附加正则表达式(包括正则表达式标志)@Min(value)
必须是一个数值,其值必须大于等于指定的最小值@Max(value)
必须是一个数值,其值必须小于等于指定的最大值@DecimalMin(value)
必须是一个数字,其值必须大于等于指定的最小值@DecimalMax(value)
必须是一个数字,其值必须小于等于指定的最大值@Size(max=, min=)
长度必须在指定的范围内(包括),数据类型支持字符
,Collection
,Map
和数组
@Negative
必须为负数,不包含零@NegativeOrZero
必须为负或零@Positive
必须为正数,不包含零@PositiveOrZero
必须为正或零@Past
必须是一个过去的日期@Future
必须是一个将来的日期@FutureOrPresent
必须是现在或将来的日期@PastOrPresent
必须是过去或现在的日期@Pattern(regex=,flag=)
必须符合指定的正则表达式
Hibernate Validator提供的常用校验注解
@CreditCardNumber(ignoreNonDigitCharacters=)
检查是否通过了Luhn校验和测试。此验证旨在检查用户错误,而不是信用卡有效性!ignoreNonDigitCharacters
是否忽略非数字字符,默认值为false
@Currency(value=)
检查货币单位javax.money.MonetaryAmount
是否为指定货币单位的一部分@DurationMax(days=, hours=, minutes=, seconds=, millis=, nanos=, inclusive=)
检查java.time.Duration
元素不大于指定范围。如果将inclusive
标识位设置为true
,则允许相等@DurationMin(days=, hours=, minutes=, seconds=, millis=, nanos=, inclusive=)
检查java.time.Duration
元素不少于指定范围。如果将inclusive
标识位设置为true
,则允许相等@EAN
检查字符序列是有效的EAN条形码。类型默认值为EAN-13@ISBN
检查字符序列是有效的ISBN国际标准书号。类型默认值为ISBN-13@Length(min=, max=)
验证字符序列长度介于(包括)指定的最小值和最大值之间@Range(min=, max=)
检查数字值是否介于(包括)指定的最小值和最大值之间@SafeHtml(whitelistType= , additionalTags=, additionalTagsWithAttributes=, baseURI=)
检查HTML值是否包含潜在的恶意脚本片段,例如<script/>
。通过whitelistType
属性,可以选择预定义的白名单类型,可以通过additionalTags
或进行细化additionalTagsWithAttributes
。前者允许添加没有任何属性的标签,而后者则允许使用注释指定标签和可选的允许属性以及该属性的可接受协议@SafeHtml.Tag
。另外baseURI
允许指定用于解析相对URI的基本URI@UniqueElements
检查集合仅包含唯一元素。使用该equals()
方法确定相等性@URL(protocol=, host=, port=, regexp=, flags=)
根据RFC2396检查带注释的字符序列是否为有效URL。如果指定任何可选参数protocol
,host
或port
时,相应的URL片段必须与指定的值相匹配。可选参数regexp
并flags
允许指定URL必须匹配的其他正则表达式(包括正则表达式标志)
使用示例
校验注解使用示例
public class XBoot {
@Size(max = 16)
@NotNull(message = "不能为空")
private String username;
@Pattern(regexp = "^[1][3,4,5,6,7,8,9][0-9]{9}$", message = "11位手机号格式不正确")
@NotNull(message = "不能为空")
private String mobile;
@Email(message = "邮箱格式不正确")
@NotNull(message = "不能为空")
private String email;
}
@valid 和 @Validated 区别
@Validated:可以用在类型、方法和方法参数上。但是不能用在成员属性(字段)上
@Valid:可以用在方法、构造函数、方法参数和成员属性(字段)上
验证请求参数
验证请求体(RequestBody) ```java @RestController
@RequestMapping(“/api”)
publicclass XBootController {@PostMapping(“/getPerson”)
public Result getPerson(@RequestBody @Validated User user) {return Result.success(user);
}
}
- 验证请求参数(Path Variables 和 Request Parameters)
```java
@RestController
@RequestMapping("/api")
// 注意记得加上该注解
@Validated
public class XBootController {
@GetMapping("/{id}")
public Result<Integer> get(@PathVariable("id") @Validated @Max(value = 999, message = "ID超过指定范围") Integer id,
@RequestParam @Validated @Email(message = "邮箱格式不正确") String email) {
return Result.success(id);
}
}
使用组
- 某些场景下我们需要对不同类有不同的验证规则,例如新增时不需验证ID(系统生成)而修改时需验证,可使用
groups
指定验证生效的类 - 第一步创建两个接口 ```java public interface A { }
public interface B { }
- 第二步指定验证组并使用
```java
@Null(groups = A.class)
@NotNull(groups = B.class)
private String username;
@Service
@Validated
public class XBootService {
@Validated(A.class)
public void validateA(@Validated XBoot xboot) {
...
}
@Validated(B.class)
public void validateB(@Validated XBoot xboot) {
...
}
}
不推荐,易引起歧义
自定义Validator
示例1:校验自定义时间格式如yyyy-MM-dd HH:mm:ss
@Target({FIELD})
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy = {DateValidatorImpl.class})
public @interface DateValidator {
/**
* 必须的属性
*/
String message() default "日期字符格式不匹配";
/**
* 必须的属性
* 用于分组校验
*/
Class[] groups() default {};
Class[] payload() default {};
/**
* 非必须 接收用户校验的时间格式
*/
String dateFormat() default "yyyy-MM-dd HH:mm:ss";
}
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.util.StrUtil;
public class DateValidatorImpl implements ConstraintValidator<DateValidator, String> {
private String dateFormat;
@Override
public void initialize(DateValidator constraintAnnotation) {
// 获取时间格式
this.dateFormat = constraintAnnotation.dateFormat();
}
@Override
public boolean isValid(String value, ConstraintValidatorContext constraintValidatorContext) {
if (StrUtil.isBlank(value)) {
return true;
}
try {
Date date = DateUtil.parse(value, dateFormat);
return date != null;
} catch (Exception e) {
return false;
}
}
}
示例2:比较两个属性是否相等
package cn.fxbin.bubble.fireworks.web.validator;
import javax.validation.Constraint;
import javax.validation.Payload;
import javax.validation.constraints.Positive;
import java.lang.annotation.Documented;
import java.lang.annotation.Repeatable;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import static java.lang.annotation.ElementType.*;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
/**
* EqualField
*
* <p>
* 比较两个属性是否相等
* </p>
*
* @author fxbin
* @version v1.0
* @since 2020/11/15 12:40
*/
@Documented
@Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE})
@Retention(RUNTIME)
@Constraint(validatedBy = {EqualFieldValidator.class})
public @interface EqualField {
/**
* 源属性
*/
String source();
/**
* 目标属性
*/
String target();
String message() default "both fields must be equal";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
package cn.fxbin.bubble.fireworks.web.validator;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.ReflectionUtils;
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
import javax.validation.ValidationException;
import java.lang.reflect.Field;
/**
* EqualFieldValidator 相等值校验器
*
* @author fxbin
* @version v1.0
* @since 2020/11/15 12:40
*/
@Slf4j
public class EqualFieldValidator implements ConstraintValidator<EqualField, Object> {
private String source;
private String target;
@Override
public void initialize(EqualField constraintAnnotation) {
this.source = constraintAnnotation.source();
this.target = constraintAnnotation.target();
}
/**
* isValid
*
* @author fxbin
* @since 2020/11/15 12:48
* @param object 传入值对象
* @param context 上下文
* @return boolean
*/
@Override
public boolean isValid(Object object, ConstraintValidatorContext context) {
Class<?> clazz = object.getClass();
Field srcField = ReflectionUtils.findField(clazz, this.source);
Field dstField = ReflectionUtils.findField(clazz, this.target);
try {
if (srcField == null || dstField == null) {
throw new ValidationException("反射获取变量失败");
}
srcField.setAccessible(true);
dstField.setAccessible(true);
Object src = srcField.get(object);
Object dst = dstField.get(object);
// 其中一个变量为 null 时,则必须两个都为 null 才相等
if (src == null || dst == null) {
return src == dst;
}
// 如果两个对象内存地址相同,则一定相等
if (src == dst) {
return true;
}
// 调用 equals 方法比较
return src.equals(dst);
} catch (Exception e) {
log.warn("EqualFieldValidator 校验异常", e);
return false;
}
}
}
全局异常处理
Springboot 提供了一个 @RestControllerAdvice 注解以及 @ExceptionHandler 注解,前者是用来开启全局的异常捕获,后者则是说明捕获哪些异常,对那些异常进行处理。
- 结合Sentinel的话,业务异常可以交由 sentinel 记录
Tracer.trace(e);
作为熔断等的重要指标项等…伪代码如下 ```java
@Slf4j @RestControllerAdvice public class DefaultGlobalExceptionHandler {
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
@ExceptionHandler(value = ServiceException.class)
public Result<String> exceptionHandler(ServiceException ex) {
log.warn("[ServiceException]", ex);
// 交由Sentinel 记录
Tracer.trace(e);
return Result.failure((ex.getErrcode() == 0 ? ResultCode.FAILURE.getCode() : ex.getErrcode()), ex.getMessage());
}
}
- 全局异常处理器,将 Exception 翻译成 Result + 对应的异常编号,默认 -1 标识处理请求失败,0 标识处理成功,这里有一点歧义,参考各大厂商 (阿里云,腾讯等) API, 通常用 0 标识请求处理成功
- Dubbo RPC 调用异常问题, 需要针对默认实现进行定制化修改,避免被Wrap一层RuntimeException, 丢失原始异常, v2.5.3 源码如下 `com.alibaba.dubbo.rpc.filter.ExceptionFilter`
```java
/*
* Copyright 1999-2011 Alibaba Group.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.alibaba.dubbo.rpc.filter;
import java.lang.reflect.Method;
import com.alibaba.dubbo.common.Constants;
import com.alibaba.dubbo.common.extension.Activate;
import com.alibaba.dubbo.common.logger.Logger;
import com.alibaba.dubbo.common.logger.LoggerFactory;
import com.alibaba.dubbo.common.utils.ReflectUtils;
import com.alibaba.dubbo.common.utils.StringUtils;
import com.alibaba.dubbo.rpc.Filter;
import com.alibaba.dubbo.rpc.Invocation;
import com.alibaba.dubbo.rpc.Invoker;
import com.alibaba.dubbo.rpc.Result;
import com.alibaba.dubbo.rpc.RpcContext;
import com.alibaba.dubbo.rpc.RpcException;
import com.alibaba.dubbo.rpc.RpcResult;
import com.alibaba.dubbo.rpc.service.GenericService;
/**
* ExceptionInvokerFilter
* <p>
* 功能:
* <ol>
* <li>不期望的异常打ERROR日志(Provider端)<br>
* 不期望的日志即是,没有的接口上声明的Unchecked异常。
* <li>异常不在API包中,则Wrap一层RuntimeException。<br>
* RPC对于第一层异常会直接序列化传输(Cause异常会String化),避免异常在Client出不能反序列化问题。
* </ol>
*
* @author william.liangf
* @author ding.lid
*/
@Activate(group = Constants.PROVIDER)
public class ExceptionFilter implements Filter {
private final Logger logger;
public ExceptionFilter() {
this(LoggerFactory.getLogger(ExceptionFilter.class));
}
public ExceptionFilter(Logger logger) {
this.logger = logger;
}
public Result invoke(Invoker<?> invoker, Invocation invocation) throws RpcException {
try {
Result result = invoker.invoke(invocation);
if (result.hasException() && GenericService.class != invoker.getInterface()) {
try {
Throwable exception = result.getException();
// 如果是checked异常,直接抛出
if (! (exception instanceof RuntimeException) && (exception instanceof Exception)) {
return result;
}
// 在方法签名上有声明,直接抛出
try {
Method method = invoker.getInterface().getMethod(invocation.getMethodName(), invocation.getParameterTypes());
Class<?>[] exceptionClassses = method.getExceptionTypes();
for (Class<?> exceptionClass : exceptionClassses) {
if (exception.getClass().equals(exceptionClass)) {
return result;
}
}
} catch (NoSuchMethodException e) {
return result;
}
// 未在方法签名上定义的异常,在服务器端打印ERROR日志
logger.error("Got unchecked and undeclared exception which called by " + RpcContext.getContext().getRemoteHost()
+ ". service: " + invoker.getInterface().getName() + ", method: " + invocation.getMethodName()
+ ", exception: " + exception.getClass().getName() + ": " + exception.getMessage(), exception);
// 异常类和接口类在同一jar包里,直接抛出
String serviceFile = ReflectUtils.getCodeBase(invoker.getInterface());
String exceptionFile = ReflectUtils.getCodeBase(exception.getClass());
if (serviceFile == null || exceptionFile == null || serviceFile.equals(exceptionFile)){
return result;
}
// 是JDK自带的异常,直接抛出
String className = exception.getClass().getName();
if (className.startsWith("java.") || className.startsWith("javax.")) {
return result;
}
// 是Dubbo本身的异常,直接抛出
if (exception instanceof RpcException) {
return result;
}
// 否则,包装成RuntimeException抛给客户端
return new RpcResult(new RuntimeException(StringUtils.toString(exception)));
} catch (Throwable e) {
logger.warn("Fail to ExceptionFilter when called by " + RpcContext.getContext().getRemoteHost()
+ ". service: " + invoker.getInterface().getName() + ", method: " + invocation.getMethodName()
+ ", exception: " + e.getClass().getName() + ": " + e.getMessage(), e);
return result;
}
}
return result;
} catch (RuntimeException e) {
logger.error("Got unchecked and undeclared exception which called by " + RpcContext.getContext().getRemoteHost()
+ ". service: " + invoker.getInterface().getName() + ", method: " + invocation.getMethodName()
+ ", exception: " + e.getClass().getName() + ": " + e.getMessage(), e);
throw e;
}
}
}
逻辑异常
- 封装统一的业务异常类
ServiceException
/BusinessException
,里面有错误码和错误提示,然后进行throws
抛出。 - 封装通用的返回类
Result
,里面有错误码和错误提示,然后进行return
返回。
说一下选择直接返回 Result
的问题:
- Spring 是基于
@Transactional
声明式事务进行回滚的,如果采用Result
的方式,那么事务回滚会很麻烦。PS:关于声明式事务可以查看org.springframework.transaction.PlatformTransactionManager
接口, 参见下图:
- 调用 其它 方法,如果是
Result
,那么需要对每一次调用进行判断,是否成功处理,很麻烦
基于上诉两点原因,个人认为,采取 异常+全局异常处理的方式更好