前几天,一位粉丝让我为他实现一个基于Spring Boot的后端公共组件,需求如下:
- 支持参数校验和分组校验。
- 实现全局异常处理。
- 接口统一响应,并且返回体需要加密。
- 对接口实现版本控制。
- 对接口参数进行加签,防止重放攻击,确保接口安全。
本文将详细介绍如何实现这些功能,帮助大家快速搭建符合这些需求的公共组件。
1. 参数校验及分组校验
在Spring Boot中,我们可以通过引入spring-boot-starter-validation来实现参数校验。这允许我们在模型类上使用如@NotNull、@Email等注解,进行基础的校验。为了实现更细粒度的参数校验(如分组校验),我们可以自定义校验组。
1.1 引入依赖
首先,在pom.xml中加入spring-boot-starter-validation依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
1.2 实现分组校验
我们可以创建一个自定义接口让其继承javax.validation.groups.Default类,用来定义不同的校验分组:
public interface ValidGroup extends Default {
interface Update extends ValidGroup{
}
interface Create extends ValidGroup{
}
interface Query extends ValidGroup{
}
interface Delete extends ValidGroup{
}
}
使用时,可以在字段上指定校验分组:
@NotNull(groups = ValidGroup.Update.class, message = "应用ID不能为空")
private String appId;
这样,我们就能根据不同的场景进行灵活的参数校验。
2. 全局异常响应
为了统一处理项目中的异常,我们可以创建一个全局异常处理类,并使用@RestControllerAdvice注解进行标注。在Spring Boot组件中,我们需要通过spring.factories文件进行配置,确保Spring Boot自动识别并加载该配置类。
2.1 创建全局异常处理类
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
// 处理参数验证异常
@SneakyThrows
@ExceptionHandler(value = {MethodArgumentNotValidException.class, BindException.class, ValidationException.class})
public Result<Void> handleValidException(HttpServletRequest request, Exception e) {
...
logError(request.getMethod(), getUrl(request),exceptionStr);
return ResultFactory.fail(ResultCode.CLIENT_ERROR, exceptionStr);
}
// 处理自定义异常
@ExceptionHandler(value = {AbstractException.class})
@ResponseStatus(code = HttpStatus.BAD_REQUEST)
public Result<Void> handleAbstractException(HttpServletRequest request, AbstractException ex) {
...
return ResultFactory.fail(ex);
}
// 兜底处理
@ExceptionHandler(value = Throwable.class)
public Result<Void> handleThrowable(HttpServletRequest request, Throwable throwable) {
return ResultFactory.fail(ResultCode.SERVICE_ERROR, "系统异常,请联系管理员!");
}
//记录日志
private void logError(String method, String requestUrl, String exceptionStr){
log.error("[{}] {} [ex] {}", method, requestUrl, exceptionStr);
}
}
2.2 注册异常处理类
在组件的配置类中进行注册:
@SpringBootConfiguration
@ConditionalOnWebApplication
public class WebAutoConfiguration {
@Bean
@ConditionalOnMissingBean(GlobalExceptionHandler.class)
public GlobalExceptionHandler globalExceptionHandler() {
return new GlobalExceptionHandler();
}
}
在spring.factories文件中指定配置类路径:
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.lxjk.core.web.configuration.WebAutoConfiguration
粉丝SpringBoot版本使用的是2.3,而在SpringBoot2.7以后路径变成resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports/
3. 接口统一响应及返回体加密
为了统一返回接口响应体,并实现返回体加密,我们可以定义一个统一的返回类型Result,并通过ResponseBodyAdvice进行加密处理。
3.1 定义返回结果类
@Data
@Accessors(chain = true)
public class Result<T> {
public static final String SUCCESS_CODE = "OK";
public static final String SUCCESS_MESSAGE = "操作成功";
private String code;
private String message;
private T data;
private long timestamp;
}
3.2 创建工具类
@Slf4j
public class ResultFactory {
public static <T> Result<T> success(T data) {
return new Result<T>()
.setCode(SUCCESS_CODE)
.setMessage(SUCCESS_MESSAGE)
.setData(data)
.setTimestamp(System.currentTimeMillis());
}
public static Result<Void> fail(String code, String message) {
return new Result<Void>()
.setCode(code)
.setMessage(message)
.setTimestamp(System.currentTimeMillis());
}
}
3.3 返回体加密
为了保证数据安全,我们可以通过ResponseBodyAdvice对返回结果进行加密处理:
@Slf4j
@RestControllerAdvice
public class ResponseBodyEncryptAdvice implements ResponseBodyAdvice<Object> {
//加解密算法策略
private final ResponseBodyEncoder responseBodyEncoder;
public ResponseBodyEncryptAdvice(ResponseBodyEncoder responseBodyEncoder) {
this.responseBodyEncoder = responseBodyEncoder;
}
@Override
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> aClass) {
return true;
}
@Override
public Object beforeBodyWrite(Object body, MethodParameter methodParameter, MediaType mediaType, Class<? extends HttpMessageConverter<?>> aClass, ServerHttpRequest serverHttpRequest, ServerHttpResponse serverHttpResponse) {
if(body == null){
return JsonUtils.obj2String(ResultFactory.success(""));
}
if (body instanceof String) {
// 当响应体是String类型时,使用ObjectMapper转换,因为Spring默认使用StringHttpMessageConverter处理字符串,不会将字符串识别为JSON
String encryptBody = responseBodyEncoder.encode((String) body);
return JsonUtils.obj2String(ResultFactory.success(encryptBody));
}
if (body instanceof Result<?>) {
// 已经包装过的结果无需再次包装
return body;
}
String s = responseBodyEncoder.encode(JsonUtils.obj2String(body));
return ResultFactory.success(s);
}
}
这段代码做了两件事: 1、自动将返回结果包装成Result对象 2、对于返回内容通过ResponseBodyEncoder接口进行加密
在这里ResponseBodyEncoder是一个接口,在本项目中采用的是AES算法进行加密,由于依赖的是接口也可以很方便替换成sm2、sm3等国密算法。
图片
3.4 在配置类中注入ResponseBodyEncryptAdvice
@SpringBootConfiguration
@ConditionalOnWebApplication
public class WebAutoConfiguration {
@Value("${lxjk.response.aes.secretKey}")
private String secretKey;
/**
* 响应体加密算法
*/
@Bean
public ResponseBodyEncoder bodyEncoder() {
return new AesResponseBodyEncoder(secretKey);
}
/**
* 接口自动包装
*/
@Bean
@ConditionalOnMissingBean(ResponseBodyEncryptAdvice.class)
public ResponseBodyEncryptAdvice dailyMartGlobalResponseBodyAdvice() {
return new ResponseBodyEncryptAdvice(bodyEncoder());
}
}
3.5 控制器示例
@RequestMapping("api/user")
@RestController
@Slf4j
public class UserV1Controller {
@GetMapping("/test")
public Map<String,String> test() {
Map<String,String> map = new HashMap<>();
map.put("name","jianzh5");
map.put("nickName","Java日知录");
return map;
}
}
返回结果如下:
{
"code": "OK",
"message": "操作成功",
"data": "6zscPzSDXFFHjicgwHc7vMkBDknHhoPfFsgjK8ZdchgAjtem3iR/cu96CXorIfLJ",
"timestamp": 1735281442972
}
4. 接口版本控制
在Spring Boot项目中,接口版本控制是一个常见的需求,特别是当API接口不断迭代时。版本控制可以帮助不同版本的API并存,同时避免影响到旧版用户。我们可以通过路径或请求头的方式来实现接口版本控制:
- 基于Path控制实现
http://example.com/v1/user 与 http://example.com/v1/user 分别对应一个接口的不同版本。
- 基于Header控制实现
访问相同接口时在请求头中携带不同的参数如X-VERSION控制访问不同的接口。
本文将重点介绍基于路径的接口版本控制方法。
4.1 创建版本控制注解
首先,我们需要创建一个自定义注解@ApiVersion,用于标注API接口的版本。这个注解可以在控制器类或方法级别使用。
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface ApiVersion {
String value() default "v1";
}
该注解有一个value属性,表示接口的版本,默认为v1。
4.2 创建版本条件类
接下来,我们需要定义一个RequestCondition实现类,用于处理版本条件。在该类中,我们将根据请求的URL路径判断接口版本,并与@ApiVersion注解中的版本进行匹配。
@Getter
@Slf4j
public class ApiVersionCondition implements RequestCondition<ApiVersionCondition> {
private static final Pattern VERSION_PREFIX_PATTERN_1 = Pattern.compile("/v\\d\\.\\d\\.\\d/");
private static final Pattern VERSION_PREFIX_PATTERN_2 = Pattern.compile("/v\\d\\.\\d/");
private static final Pattern VERSION_PREFIX_PATTERN_3 = Pattern.compile("/v\\d/");
private static final List<Pattern> VERSION_LIST = Collections.unmodifiableList(
Arrays.asList(VERSION_PREFIX_PATTERN_1, VERSION_PREFIX_PATTERN_2, VERSION_PREFIX_PATTERN_3)
);
private static final ConcurrentMap<String, String> VERSION_CACHE = new ConcurrentHashMap<>();
private final String apiVersion;
public ApiVersionCondition(String apiVersion) {
this.apiVersion = apiVersion;
}
@Override
public ApiVersionCondition combine(ApiVersionCondition other) {
return new ApiVersionCondition(other.apiVersion);
}
@Override
public ApiVersionCondition getMatchingCondition(HttpServletRequest request) {
String requestUri = request.getRequestURI();
String cachedVersion = VERSION_CACHE.get(requestUri);
if (cachedVersion != null && Objects.equals(cachedVersion, this.apiVersion)) {
return this;
}
for (Pattern pattern : VERSION_LIST) {
Matcher m = pattern.matcher(request.getRequestURI());
if (m.find()) {
String version = m.group(0).replace("/", "");
//推荐使用精确匹配版本号
//如果选择降低版本匹配,如有两个版本1.1和1.2 访问1.5 自动跳转到1.2,不仅会影响匹配性能并且会导致版本不准确,容易产生误解
if (Objects.equals(version, this.apiVersion)) {
VERSION_CACHE.put(requestUri, version);
return this;
}
}
}
return null;
}
@Override
public int compareTo(ApiVersionCondition other, HttpServletRequest httpServletRequest) {
return 0;
}
}
4.3 自定义HandlerMapping实现接口版本控制
为了让Spring识别并根据版本条件处理请求,我们需要自定义一个HandlerMethod实现版本匹配逻辑。这一部分的关键是通过RequestCondition来判断请求是否符合该版本。
public class ApiVersionRequestMappingHandler extends RequestMappingHandlerMapping {
@Override
protected RequestCondition<?> getCustomTypeCondition(Class<?> handlerType) {
ApiVersion apiVersion = AnnotationUtils.findAnnotation(handlerType, ApiVersion.class);
return null == apiVersion ? super.getCustomTypeCondition(handlerType) : new ApiVersionCondition(apiVersion.value());
}
@Override
protected RequestCondition<?> getCustomMethodCondition(Method method) {
ApiVersion apiVersion = AnnotationUtils.findAnnotation(method, ApiVersion.class);
return null == apiVersion ? super.getCustomMethodCondition(method) : new ApiVersionCondition(apiVersion.value());
}
}
4.4 完成配置
在Spring Boot应用的配置类中,我们需要确保API版本控制逻辑生效。我们可以通过@Configuration注解将自定义的Handlermapping加入到Spring的RequestMappingHandlerMapping中。
@SpringBootConfiguration
public class ApiMappingRegistration implements WebMvcRegistrations {
@Override
public RequestMappingHandlerMapping getRequestMappingHandlerMapping() {
return new ApiVersionRequestMappingHandler();
}
}
4.5 控制器实例
在控制器中,我们可以根据版本来定义不同的接口,默认版本号是v1,如果方法和类上都有注解,以方法上的为准。
@Api(tags = "用户API")
@RequestMapping("api/{v}/user")
@RestController
@Slf4j
public class UserV1Controller {
@ApiVersion("v1")
@ApiOperation("test1")
@GetMapping("/test")
public String testv1() {
return "this is v1.0.0 user";
}
@ApiVersion("v2")
@ApiOperation("test2")
@GetMapping("/test")
public Map<String,String> testv2() {
Map<String,String> map = new HashMap<>();
map.put("name","jianzh5");
map.put("nickName","Java日知录");
return map;
}
}
4.6 兼容Swagger接口文档
在实现了接口版本控制后,我们会遇到一个问题:Swagger文档中显示的接口路径仍为api/{v}/user,其中的{v}占位符未被替换为实际的版本号,这不利于在线调试。
图片
为了解决这个问题,我们需要在ApiVersionRequestMappingHandler类中重写registerHandlerMethod方法,动态替换路径中的{v}占位符为实际的版本号。
public class ApiVersionRequestMappingHandler extends RequestMappingHandlerMapping {
@Override
protected void registerHandlerMethod(Object handler, Method method, RequestMappingInfo mapping) {
//获取方法上的ApiVersion注解
ApiVersion apiVersion = method.getAnnotation(ApiVersion.class);
if (apiVersion == null) {
//获取类上的ApiVersion注解
apiVersion = AnnotationUtils.findAnnotation(method.getDeclaringClass(), ApiVersion.class);
}
if (apiVersion != null) {
String version = apiVersion.value();
PatternsRequestCondition apiPattern = new PatternsRequestCondition(
mapping.getPatternsCondition().getPatterns().stream()
.map(pattern -> pattern.replace("{v}", version))
.toArray(String[]::new)
);
mapping = new RequestMappingInfo(
mapping.getName(),
apiPattern,
mapping.getMethodsCondition(),
mapping.getParamsCondition(),
mapping.getHeadersCondition(),
mapping.getConsumesCondition(),
mapping.getProducesCondition(),
mapping.getCustomCondition()
);
}
super.registerHandlerMethod(handler, method, mapping);
}
}
通过这种方式,我们能够动态地将路径中的{v}占位符替换为对应的版本号。例如,当接口的版本为v1时,接口路径就会变为api/v1/user,从而解决了Swagger接口文档中的占位符问题。
图片
5. 接口安全管理
为了确保暴露在外网的API接口的安全性,我们需要实现防篡改和防重放机制。这两个措施能够有效保护接口免受恶意攻击和滥用。
5.1 防篡改
防篡改机制通常通过参数签名来实现。具体而言,调用方将请求参数按照字典顺序排序后进行加密,得到签名(sign1)。然后,调用方将参数和签名一同发送给后端服务。后端服务在接收到请求后,使用相同的排序规则和加密算法对参数进行签名,得到另一个签名(sign2)。如果sign1与sign2不一致,说明请求参数被篡改,后端服务将拒绝该请求。
这种方式能够有效防止数据在传输过程中被篡改,确保接口的完整性和真实性。
5.2 防重放
防重放机制通过nonce(随机字符串)和timestamp(时间戳)来实现。nonce是一个每次请求唯一且仅能使用一次的随机字符串,而timestamp表示请求的时间。防重放的处理逻辑如下:
- 时间检查:首先检查请求的timestamp是否超过了预设的接口处理时间限制。如果超时,则认为请求无效。
- Redis检查:通过nonce值在Redis中查询是否已经存在与之对应的key (nonce:{nonce}),如果存在,表示该请求是重复请求,属于重放攻击。
- 设置Redis Key过期时间:如果nonce未曾使用,则在Redis中设置该nonce值,并为其设置过期时间,过期时间通常与timestamp的有效期一致。
通过这种方式,防止了攻击者利用截获的请求包进行重放,确保每次请求都是唯一且有效的。
图片
5.3 代码实现
- 创建自定义过滤器
在自定义组件中,我们可以创建一个接口过滤器,拦截并验证请求的安全性:
@Slf4j
public class SignatureFilter implements Filter {
//从filter配置中获取sign过期时间
private Long signMaxTime;
private final Map<String,String> nonceMap = new HashMap<>();
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) servletRequest;
HttpServletResponse httpResponse = (HttpServletResponse) servletResponse;
log.info("过滤URL:{}", httpRequest.getRequestURI());
HttpServletRequestWrapper requestWrapper = new SignRequestWrapper(httpRequest);
RequestHeader requestHeader =buildRequestHeader(httpRequest);
//Step1. 验证请求头是否存在
if (!validateRequestHeader(requestHeader, httpResponse)) return;
//Step2. 验证时间戳是否过期
if (!validateTimestamp(requestHeader, httpResponse)) return;
//Step3. 验证nonce是否被使用过
if (!validateNonce(requestHeader, httpResponse)) return;
//Step4. 验证签名是否正确
if (validateSignature(httpRequest, requestWrapper, requestHeader)) {
filterChain.doFilter(requestWrapper, servletResponse);
} else {
responseFail(httpResponse, ResultCode.SIGNATURE_ERROR);
}
}
}
- 配置类注入过滤器
接下来,创建配置类来注入这个过滤器,并指定需要拦截的URL路径。
@SpringBootConfiguration
public class SignatureFilterConfiguration {
@Value("${lxjk.sign.maxTime:60}")
private String signMaxTime;
//filter中的初始化参数
private final Map<String, String> initParametersMap = new HashMap<>();
@Bean
public FilterRegistrationBean<SignatureFilter> contextFilterRegistrationBean() {
initParametersMap.put("signMaxTime",signMaxTime);
FilterRegistrationBean<SignatureFilter> registration = new FilterRegistrationBean<>();
registration.setFilter(signatureFilter());
registration.setInitParameters(initParametersMap);
registration.addUrlPatterns("/api/pv/*");
registration.setName("SignatureFilter");
// 设置过滤器被调用的顺序
registration.setOrder(1);
return registration;
}
@Bean
public SignatureFilter signatureFilter() {
return new SignatureFilter();
}
}
6. 总结
本文介绍了如何通过Spring Boot实现常见的后端公共功能,包括:
- 参数校验:通过注解和分组校验进行数据验证。
- 全局异常处理:通过@RestControllerAdvice实现统一的异常处理。
- 接口统一响应与加密:通过ResponseBodyAdvice进行返回体加密,确保接口数据的安全性。
- 接口版本控制:使用自定义注解和条件判断来实现版本控制。
- 接口签名与防重放攻击:通过Md5加密、签名验证和nonce来防止重放攻击和篡改数据。