接口规范方案
# 参数校验
一般来说有三种常见的校验方式:
- 业务层校验
- Validator + BindResult校验
- Validator + 自动抛出异常(推荐)
- 业务层校验无需多说,即手动在java的Service层进行数据校验判断。不过这样太繁琐了,光校验代码就会有很多;
- 而使用Validator+ BindingResult已经是非常方便实用的参数校验方式了,在实际开发中也有很多项目就是这么做的,不过这样还是不太方便,因为你每写一个接口都要添加一个BindingResult参数,然后再提取错误信息返回给前端(简单看一下);
@PostMapping("/addUser")
public String addUser(@RequestBody @Validated User user, BindingResult bindingResult) {
// 如果有参数校验失败,会将错误信息封装成对象组装在BindingResult里
List<ObjectError> allErrors = bindingResult.getAllErrors();
if(!allErrors.isEmpty()){
return allErrors.stream()
.map(o->o.getDefaultMessage())
.collect(Collectors.toList()).toString();
}
// 返回默认的错误信息
// return allErrors.get(0).getDefaultMessage();
return validationService.addUser(user);
}
2
3
4
5
6
7
8
9
10
11
12
13
- 重点讲一下第三种Validator + 自动抛出异常的方式
# Validator
依赖:
<dependency>
<!--新版框架没有自动引入需要手动引入-->
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-spring-boot-starter</artifactId>
<!--在引用时请在maven中央仓库搜索最新版本号-->
<version>2.0.2</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
从springboot-2.3开始,校验包被独立成了一个starter组件
# 内置常用参数校验注解
| 注解 | 校验功能 |
|---|---|
| @AssertFalse | 必须是false |
| @AssertTrue | 必须是true |
| @DecimalMax | 小于等于给定的值 |
| @DecimalMin | 大于等于给定的值 |
| @Digits | 可设定最大整数位数和最大小数位数 |
| 校验是否符合Email格式 | |
| @Future | 必须是将来的时间 |
| @FutureOrPresent | 当前或将来时间 |
| @Max | 最大值 |
| @Min | 最小值 |
| @Negative | 负数(不包括0) |
| @NegativeOrZero | 负数或0 |
| @NotBlank | 不为null并且包含至少一个非空白字符 |
| @NotEmpty | 不为null并且不为空 |
| @NotNull | 不为null |
| @Null | 为null |
| @Past | 必须是过去的时间 |
| @PastOrPresent | 必须是过去的时间,包含现在 |
| @PositiveOrZero | 正数或0 |
| @Size | 校验容器的元素个数 |
| @Pattern | 正则校验 |
# 使用示例
@Data
public class User {
@NotNull(message = "用户id不能为空")
private Long id;
@NotNull(message = "用户账号不能为空")
@Size(min = 6, max = 11, message = "账号长度必须是6-11个字符")
private String account;
@NotNull(message = "用户密码不能为空")
@Size(min = 6, max = 11, message = "密码长度必须是6-16个字符")
private String password;
@NotNull(message = "用户邮箱不能为空")
@Email(message = "邮箱格式不正确")
private String email;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
校验规则和错误提示信息配置完毕后,接下来只需要在接口仅需要在校验的参数上加上@Valid注解(去掉BindingResult后会自动引发异常,异常发生了自然而然就不会执行业务逻辑):
@RestController
@RequestMapping("user")
public class ValidationController {
@Autowired
private ValidationService validationService;
@PostMapping("/addUser")
public String addUser(@RequestBody @Validated User user) {
return validationService.addUser(user);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
# 分组校验
分组校验有三步:
- 定义一个分组类(或接口)
public interface Update extends Default {}
- 在校验注解上添加groups属性指定分组
@Data
public class User {
@NotNull(message = "用户id不能为空",groups = Update.class)
private Long id;
......
}
2
3
4
5
6
- Controller方法的
@Validated注解添加分组类
@PostMapping("update")
public String update(@Validated({Update.class}) User user) {
return "success";
}
2
3
4
如果Update不继承Default,@Validated({Update.class})就只会校验属于Update.class分组的参数字段;如果继承了,会校验了其他默认属于Default.class分组的字段。
# 递归校验
对于递归校验(比如类中类),只要在相应属性类上增加@Valid注解即可实现(对于集合同样适用)
@Data
public class TrainExamPaperReq {
@NotNull(message = "考试id不能为空")
private Long examId;
@Valid
@NotEmpty(message = "请配置考题")
private List<QuestionReq> questionList;
@Data
public static class QuestionReq {
@NotNull(message = "考题id不能为空")
private Long questionId;
@NotNull(message = "考题序号不能为空")
private Integer questionNumber;
@NotNull(message = "请输入分值")
@Min(value = 0, message = "分值必须大于0")
@Digits(integer = 5, fraction = 1,
message = "分值格式错误,分值最多支持五位整数和一位小数")
private BigDecimal questionScore;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# 自定义校验
Spring Validation允许用户自定义校验,实现很简单,分两步:
- 自定义校验注解
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy = {HaveNoBlankValidator.class})// 标明由哪个类执行校验逻辑
public @interface HaveNoBlank {
// 校验出错时默认返回的消息
String message() default "字符串中不能含有空格";
Class<?>[] groups() default { };
Class<? extends Payload>[] payload() default { };
/**
* 同一个元素上指定多个该注解时使用
*/
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
@Documented
public @interface List {
NotBlank[] value();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
- 编写校验者类
public class HaveNoBlankValidator implements ConstraintValidator<HaveNoBlank, String> {
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
// null 不做检验
if (value == null) {
return true;
}
// 校验失败
return !value.contains(" ");
// 校验成功
}
}
2
3
4
5
6
7
8
9
10
11
12
# 全局异常处理
注意
如果在多模块使用,全局异常等公共功能抽象成子模块,则在需要的子模块中需要将该模块包扫描加入,@SpringBootApplication(scanBasePackages = {"com.xxx"})
新建一个类,在这个类上加上@ControllerAdvice或@RestControllerAdvice注解,这个类就配置成全局处理类了,这个根据你的Controller层用的是@Controller还是@RestController来决定。
- 使用form data方式调用接口,校验异常抛出 BindException
- 使用 json 请求体调用接口,校验异常抛出 MethodArgumentNotValidException
- 单个参数校验异常抛出ConstraintViolationException
@RestControllerAdvice
@ResponseBody
public class ExceptionControllerAdvice {
/**
* JSON参数校验异常
* @param e 异常
* @return 错误信息
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ResponseEntity<Object> methodArgumentNotValidExceptionHandler(MethodArgumentNotValidException e) {
// 从异常对象中拿到ObjectError对象
ObjectError objectError = e.getBindingResult().getAllErrors().get(0);
String errorMsg = objectError.getDefaultMessage();
// 然后提取错误提示信息进行返回
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Result.error(errorMsg));
}
/**
* 非法参数异常
* @param e 异常
* @return 错误信息
*/
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<Object> handleException(IllegalArgumentException e) {
log.error("[非法参数异常]" + e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Result.error(e.getMessage()));
}
/**
* 重复键异常
* @param e 异常
* @return 错误信息
*/
@ExceptionHandler(DuplicateKeyException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ResponseEntity<Object> handleDuplicateKeyException(DuplicateKeyException e) {
log.error("[重复键异常]" + e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Result.error(e.getMessage()));
}
/**
* 未知异常
* @param e 未知异常
* @return 错误信息
*/
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(Exception.class)
public ResponseEntity<Object> handleException(Exception e) {
// 未知异常打印异常链
log.error("[未知异常]:" + e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Result.error(e.getMessage()));
}
/**
* @param e 参数校验异常
* @return r
*/
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler({MissingServletRequestParameterException.class})
public ResponseEntity<Resp<Object>> handleParamException(Exception e) {
String msg = null;
if (e instanceof MethodArgumentNotValidException){
MethodArgumentNotValidException ex = (MethodArgumentNotValidException) e;
msg = "提示: " + ex.getBindingResult().getAllErrors().get(0).getDefaultMessage().replaceFirst("^.*default message \\[(.*?)]] $", "$1");
}
if (e instanceof MissingServletRequestParameterException){
MissingServletRequestParameterException ex = (MissingServletRequestParameterException) e;
msg = "提示: " + ex.getMessage();
}
logger.error(msg);
Resp<Object> error = Resp.error(ErrorEnum.E504);
error.setMsg(msg);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
# 自定义异常
创建异常类继承RuntimeException
@Getter
public class APIException extends RuntimeException {
private int code;
private String msg;
public APIException() {
this(1001, "接口错误");
}
public APIException(String msg) {
this(1001, msg);
}
public APIException(int code, String msg) {
super(msg);
this.code = code;
this.msg = msg;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
在全局异常处理类中捕获该异常
//自定义的全局异常
@ExceptionHandler(APIException.class)
public String APIExceptionHandler(APIException e) {
return e.getMsg();
}
2
3
4
5
# 接口版本控制
在spring boot项目中,如果要进行restful接口的版本控制一般有以下几个方向:
- 基于path的版本控制
- 基于header的版本控制
在spring MVC下,url映射到哪个method是由RequestMappingHandlerMapping来控制的,那么我们也是通过 RequestMappingHandlerMapping来做版本控制的。
# Path控制实现
首先定义一个注解
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface ApiVersion {
// 默认接口版本号1.0开始,这里我只做了两级,多级可在正则进行控制
String value() default "1.0";
}
2
3
4
5
6
ApiVersionCondition用来控制当前request 指向哪个method
public class ApiVersionCondition implements RequestCondition<ApiVersionCondition> {
private static final Pattern VERSION_PREFIX_PATTERN = Pattern.compile("v(\\d+\\.\\d+)");
private final String version;
public ApiVersionCondition(String version) {
this.version = version;
}
@Override
public ApiVersionCondition combine(ApiVersionCondition other) {
// 采用最后定义优先原则,则方法上的定义覆盖类上面的定义
return new ApiVersionCondition(other.getApiVersion());
}
@Override
public ApiVersionCondition getMatchingCondition(HttpServletRequest httpServletRequest) {
Matcher m = VERSION_PREFIX_PATTERN.matcher(httpServletRequest.getRequestURI());
if (m.find()) {
String pathVersion = m.group(1);
// 这个方法是精确匹配
if (Objects.equals(pathVersion, version)) {
return this;
}
// 该方法是只要大于等于最低接口version即匹配成功,需要和compareTo()配合
// 举例:定义有1.0/1.1接口,访问1.2,则实际访问的是1.1,如果从小开始那么排序反转即可
//if(Float.parseFloat(pathVersion)>=Float.parseFloat(version)){
// return this;
//}
}
return null;
}
@Override
public int compareTo(ApiVersionCondition other, HttpServletRequest request) {
return 0;
// 优先匹配最新的版本号,和getMatchingCondition注释掉的代码同步使用
// return other.getApiVersion().compareTo(this.version);
}
public String getApiVersion() {
return version;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
PathVersionHandlerMapping用于注入spring用来管理
public class PathVersionHandlerMapping extends RequestMappingHandlerMapping {
@Override
protected boolean isHandler(Class<?> beanType) {
return AnnotatedElementUtils.hasAnnotation(beanType, Controller.class);
}
@Override
protected RequestCondition<?> getCustomTypeCondition(Class<?> handlerType) {
ApiVersion apiVersion = AnnotationUtils.findAnnotation(handlerType,ApiVersion.class);
return createCondition(apiVersion);
}
@Override
protected RequestCondition<?> getCustomMethodCondition(Method method) {
ApiVersion apiVersion = AnnotationUtils.findAnnotation(method,ApiVersion.class);
return createCondition(apiVersion);
}
private RequestCondition<ApiVersionCondition>createCondition(ApiVersion apiVersion) {
return apiVersion == null ? null : new ApiVersionCondition(apiVersion.value());
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
WebMvcConfiguration配置类让spring来接管
@Configuration
public class WebMvcConfiguration implements WebMvcRegistrations {
@Override
public RequestMappingHandlerMapping getRequestMappingHandlerMapping() {
return new PathVersionHandlerMapping();
}
}
2
3
4
5
6
7
8
使用:默认是v1.0,如果方法上有注解,以方法上的为准(该方法vx.x在路径任意位置出现都可解析)
@RestController
@ApiVersion
@RequestMapping(value = "/test")
public class TestController {
@GetMapping(value = "one")
public String query(){
return "test api default";
}
@GetMapping(value = "one")
@ApiVersion("1.1")
public String query2(){
return "test api v1.1";
}
@GetMapping(value = "one")
@ApiVersion("3.1")
public String query3(){
return "test api v3.1";
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# header控制实现
总体原理与Path类似,修改ApiVersionCondition即可,之后访问时在header带上X-VERSION参数即可
public class ApiVersionCondition implements RequestCondition<ApiVersionCondition> {
private static final String X_VERSION = "X-VERSION";
private final String version ;
public ApiVersionCondition(String version) {
this.version = version;
}
@Override
public ApiVersionCondition combine(ApiVersionCondition other) {
// 采用最后定义优先原则,则方法上的定义覆盖类上面的定义
return new ApiVersionCondition(other.getApiVersion());
}
@Override
public ApiVersionCondition getMatchingCondition(HttpServletRequest httpServletRequest) {
String headerVersion = httpServletRequest.getHeader(X_VERSION);
if(Objects.equals(version,headerVersion)){
return this;
}
return null;
}
@Override
public int compareTo(ApiVersionCondition apiVersionCondition, HttpServletRequest httpServletRequest) {
return 0;
}
public String getApiVersion() {
return version;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# API接口安全
APP、前后端分离项目都采用API接口形式与服务器进行数据通信,传输的数据被偷窥、被抓包、被伪造时有发生,那么如何设计一套比较安全的API接口方案至关重要,一般的解决方案有以下几点:
提示
- Token授权认证,防止未授权用户获取数据;
- 时间戳超时机制;
- URL签名,防止请求参数被篡改;
- 防重放,防止接口被第二次请求,防采集;
- 采用HTTPS通信协议,防止数据明文传输;
# Token授权认证
因为HTTP协议是无状态的,Token的设计方案是用户在客户端使用用户名和密码登录后,服务器会给客户端返回一个Token,并将Token以键值对的形式存放在缓存(一般是Redis)中,后续客户端对需要授权模块的所有操作都要带上这个Token,服务器端接收到请求后进行Token验证,如果Token存在,说明是授权的请求。
Token生成的设计要求
- 应用内一定要唯一,否则会出现授权混乱,A用户看到了B用户的数据;
- 每次生成的Token一定要不一样,防止被记录,授权永久有效;
- 一般Token对应的是Redis的key,value存放的是这个用户相关缓存信息,比如:用户的id;
- 要设置Token的过期时间,过期后需要客户端重新登录,获取新的Token,如果Token有效期设置较短,会反复需要用户登录,体验比较差,我们一般采用Token过期后,客户端静默登录的方式,当客户端收到Token过期后,客户端用本地保存的用户名和密码在后台静默登录来获取新的Token,还有一种是单独出一个刷新Token的接口,但是一定要注意刷新机制和安全问题;
根据上面的设计方案要求,我们很容易得到Token=md5(用户ID+登录的时间戳+服务器端秘钥)这种方式来获得Token,因为用户ID是应用内唯一的,登录的时间戳保证每次登录的时候都不一样,服务器端秘钥是配置在服务器端参与加密的字符串(即:盐),目的是提高Token加密的破解难度,注意一定不要泄漏;
# 时间戳超时机制
客户端每次请求接口都带上当前时间的时间戳timestamp,服务端接收到timestamp后跟当前时间进行比对,如果时间差大于一定时间(比如:1分钟),则认为该请求失效。时间戳超时机制是防御DOS攻击的有效手段。
例如:http://url/getInfo?id=1&timetamp=1661061696
# URL签名
支付宝或微信支付就是这样处理的,我们只需要将原本发送给server端的明文参数做一下签名,然后在server端用相同的算法再做一次签名,对比两次签名就可以确保对应明文的参数有没有被中间人篡改过。
例如:http://url/getInfo?id=1&timetamp=1559396263&sign=e10adc3949ba59abbe56e057f20f883e
签名生成规则:
- 首先对通信的参数按key进行字母排序放入数组中(一般请求的接口地址也要参与排序和签名,那么需要额外添加
url=http://url/getInfo这个参数) - 对排序完的数组键值对用&进行连接,形成用于加密的参数字符串
- 在加密的参数字符串前面或者后面加上私钥,然后用md5进行加密,得到sign,然后随着请求接口一起传给服务器。服务器端接收到请求后,用同样的算法获得服务器的sign,对比客户端的sign是否一致,如果一致请求有效。(如果觉得md5加密不放心可以换爆破难度更高的加密算法或是加大加密字符串的复杂度)
# 防重放
客户端第一次访问时,将签名sign存放到服务器的Redis中,超时时间设定为跟时间戳的超时时间一致,二者时间一致可以保证无论在timestamp限定时间内还是外URL都只能访问一次,如果被非法者截获,使用同一个URL再次访问,如果发现缓存服务器中已经存在了本次签名,则拒绝服务。
如果在缓存中的签名失效的情况下,有人使用同一个URL再次访问,则会被时间戳超时机制拦截,这就是为什么要求sign的超时时间要设定为跟时间戳的超时时间一致。拒绝重复调用机制确保URL被别人截获了也无法使用(如抓取数据);
方案流程:
- 客户端通过用户名密码登录服务器并获取Token;
- 客户端生成时间戳timestamp,并将timestamp作为其中一个参数;
- 客户端将所有的参数,包括Token和timestamp按照自己的签名算法进行排序加密得到签名sign
- 将token、timestamp和sign作为请求时必须携带的参数加在每个请求的URL后边,例:http://url/request?token=h40adc3949bafjhbbe56e027f20f583a&timetamp=1559396263&sign=e10adc3949ba59abbe56e057f20f883e
- 服务端对token、timestamp和sign进行验证,只有在token有效、timestamp未超时、缓存服务器中不存在sign三种情况同时满足,本次请求才有效;
采用HTTPS通信协议
安全套接字层超文本传输协议HTTPS,为了数据传输的安全,HTTPS在HTTP的基础上加入了SSL协议,SSL依靠证书来验证服务器的身份,并为客户端和服务器之间的通信加密。
HTTPS也不是绝对安全的,比如中间人劫持攻击,中间人可以获取到客户端与服务器之间所有的通信内容。