百度360必应搜狗淘宝本站头条
当前位置:网站首页 > 技术教程 > 正文

为粉丝定制的SpringBoot服务端组件,零修改直接上线生产!

mhr18 2025-05-22 11:47 6 浏览 0 评论

前几天,一位粉丝让我为他实现一个基于Spring Boot的后端公共组件,需求如下:

  1. 支持参数校验和分组校验。
  2. 实现全局异常处理。
  3. 接口统一响应,并且返回体需要加密。
  4. 对接口实现版本控制。
  5. 对接口参数进行加签,防止重放攻击,确保接口安全。

本文将详细介绍如何实现这些功能,帮助大家快速搭建符合这些需求的公共组件。

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)。如果sign1sign2不一致,说明请求参数被篡改,后端服务将拒绝该请求。

这种方式能够有效防止数据在传输过程中被篡改,确保接口的完整性和真实性。

5.2 防重放

防重放机制通过nonce(随机字符串)和timestamp(时间戳)来实现。nonce是一个每次请求唯一且仅能使用一次的随机字符串,而timestamp表示请求的时间。防重放的处理逻辑如下:

  1. 时间检查:首先检查请求的timestamp是否超过了预设的接口处理时间限制。如果超时,则认为请求无效。
  2. Redis检查:通过nonce值在Redis中查询是否已经存在与之对应的key (nonce:{nonce}),如果存在,表示该请求是重复请求,属于重放攻击。
  3. 设置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来管理。目的系统初始化工作是一个简单、繁复的工作,从云网得到的虚拟主机只是一个基础的系统环境,...

取消回复欢迎 发表评论: