为粉丝定制的SpringBoot服务端组件,零修改直接上线生产!
mhr18 2025-05-22 11:47 6 浏览 0 评论
前几天,一位粉丝让我为他实现一个基于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 代码实现
1. 创建自定义过滤器
在自定义组件中,我们可以创建一个接口过滤器,拦截并验证请求的安全性:
@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);
}
}
}
2. 配置类注入过滤器
接下来,创建配置类来注入这个过滤器,并指定需要拦截的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来防止重放攻击和篡改数据。
来源:
https://mp.weixin.qq.com/s/_lC2V7LMll8ERwesj2O-Hg
相关推荐
- 几种 TCP 连接中出现 RST 的情况
-
现在是一个网络时代了。应该不少程序员在编程中需要考虑多机、局域网、广域网的各种问题。所以网络知识也是避免不了学习的。而且笔者一直觉得TCP/IP网络知识在一个程序员知识体系中必需占有一席之地的。在...
- Redis连接使用报RDB error错误
-
该错误信息:Errorinexecution;nestedexceptionisio.lettuce.core.RedisCommandExecutionException:MISC...
- lua 语法介绍与 NGINX lua 高级用法实战操作
-
一、概述lua是一种轻量小巧的脚本语言,用标准C语言编写并以源代码形式开放,其设计目的是为了嵌入应用程序中,从而为应用程序提供灵活的扩展和定制功能。官网:https://www.lua.org/二、l...
- Python教程——20.协程 - 2
-
异步编程asyncio.Future对象Task继承Future,Task对象内部中的await结果的处理基于Future对象来的在Future对象中会保存当前执行的这个协程任务的状态,如果当...
- “我的足迹”、“浏览历史”,Redis如何快速记录与展示?
-
咱们在网上“买买买”、“逛逛逛”的时候,总会留下各种各样的“足迹”。无论是电商APP里你最近浏览过的商品,视频网站上你刚刚看过的剧集,还是新闻客户端里你点开过的文章……这些“历史记录”,有时候还真挺有...
- 你手机上的“消息推送”,Redis可能参与其中
-
手机上那些时不时就“叮咚”一下的消息推送,确实是咱们数字生活里不可或缺的一部分。这篇咱们就来聊聊,Redis这位“消息灵通人士”,是如何在这场“信息接力赛”中大显身手,确保那些重要的、有趣的通知,能够...
- 短视频APP的“附近的人”,Redis如何快速匹配?
-
刷短视频,除了看各种搞笑段子、才艺展示,有时候是不是也想看看“同城”或者“附近”的人都在发些啥有意思的内容?或者,平台也会时不时地给你推荐一些“附近正在直播”的主播,让你感觉一下子拉近了和这个虚拟世界...
- 微信朋友圈的点赞、评论,Redis在背后默默付出
-
微信朋友圈,这片小小的“自留地”,承载了我们多少喜怒哀乐、生活点滴啊!一张精心修饰的照片,一段随感而发的文字,发出去之后,最期待的是什么?那必须是屏幕下方不断冒出来的小红心和一条条真诚(或者商业互吹)...
- 网站登录老是掉线?Redis帮你记住你是谁!
-
有没有过这样的糟心体验?你好不容易登录了一个网站,刚看了两篇帖子,或者购物车里刚加了几件宝贝,结果一刷新页面,或者稍微离开了一会儿,回来就发现——“哎?我怎么又退出了?!”又得重新输入用户名、密码、...
- 你常用的APP,哪些地方可能用到了Redis?(猜想与分析)
-
咱们现在的生活,简直是离不开各种各样的手机APP了!从早上睁眼刷新闻,到中午点外卖,再到晚上刷短视频、玩游戏,一天到头,指尖在屏幕上就没停过。这些APP为了让我们用得爽、用得顺心,背后可是使出了浑身解...
- Redis是啥?为啥程序员天天挂嘴边?小白也能看懂!
-
这Redis到底是何方神圣?为啥那些天天在电脑前敲代码的程序员小哥哥小姐姐们,老是把它挂在嘴边,好像离了它地球都不转了似的?别担心,咱们今天不说那些听了就头大的代码和术语,就用大白话,保证你听完一拍大...
- 面试官:请你说说Redis为什么这么快?
-
1)Redis是基于内存的存储数据库,绝大部分的命令处理只是纯粹的内存操作,内存的读写速度非常快。2)Redis是单进程线程的服务(实际上一个正在运行的RedisServer肯定不止一个线程,但只有...
- 有了强大的关系型数据库,为什么还需要Redis?
-
在数字世界的浩瀚海洋中,关系型数据库,例如我们熟知的MySQL、PostgreSQL或Oracle,无疑是那些承载着核心业务数据、坚如磐石的“国家图书馆”或“银行金库”。它们以严谨的结构、强大的事务处...
- Java 中间件数据可靠性串讲:从 MQ 、MySQL、Redis 不丢失的保障之道
-
引言在现代分布式系统中,中间件扮演着至关重要的角色,它们是构建高可用、高性能、高可扩展应用架构的基石。消息队列(MQ)、数据库(如MySQL)、缓存(如Redis)等是其中最具代表性的组件。然而,...
- 运维部署方式之——虚机部署
-
标准化使用作業系统:LinuxCentOS7自动化方式通过Ansible系统初始化playbook来管理。目的系统初始化工作是一个简单、繁复的工作,从云网得到的虚拟主机只是一个基础的系统环境,...
你 发表评论:
欢迎- 一周热门
- 最近发表
- 标签列表
-
- oracle位图索引 (63)
- oracle批量插入数据 (62)
- oracle事务隔离级别 (53)
- oracle 空为0 (50)
- oracle主从同步 (55)
- oracle 乐观锁 (51)
- redis 命令 (78)
- php redis (88)
- redis 存储 (66)
- redis 锁 (69)
- 启动 redis (66)
- redis 时间 (56)
- redis 删除 (67)
- redis内存 (57)
- redis并发 (52)
- redis 主从 (69)
- redis 订阅 (51)
- redis 登录 (54)
- redis 面试 (58)
- 阿里 redis (59)
- redis 搭建 (53)
- redis的缓存 (55)
- lua redis (58)
- redis 连接池 (61)
- redis 限流 (51)