当前位置:首页 > 技术分析 > 正文内容

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

ruisui883个月前 (02-04)技术分析13

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

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

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

1. 参数校验及分组校验

在Spring Boot中,我们可以通过引入
spring-boot-starter-validation
来实现参数校验。这允许我们在模型类上使用如@NotNull@Email等注解,进行基础的校验。为了实现更细粒度的参数校验(如分组校验),我们可以自定义校验组。

1.1 引入依赖

首先,在pom.xml中加入
spring-boot-starter-validation
依赖:

  
    org.springframework.boot  
    spring-boot-starter-validation  

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 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 handleAbstractException(HttpServletRequest request, AbstractException ex) {  
        ...
        return ResultFactory.fail(ex);  
    }  
  
    // 兜底处理  
    @ExceptionHandler(value = Throwable.class)  
    public Result 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 {
    
    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  Result success(T data) {
        return new Result()
                .setCode(SUCCESS_CODE)
                .setMessage(SUCCESS_MESSAGE)
                .setData(data)
                .setTimestamp(System.currentTimeMillis());
    }
    
    public static Result fail(String code, String message) {
        return new Result()
                .setCode(code)
                .setMessage(message)
                .setTimestamp(System.currentTimeMillis());
    }
    
}

3.3 返回体加密

为了保证数据安全,我们可以通过ResponseBodyAdvice对返回结果进行加密处理:

@Slf4j
@RestControllerAdvice
public class ResponseBodyEncryptAdvice implements ResponseBodyAdvice {
    
    //加解密算法策略
    private final ResponseBodyEncoder responseBodyEncoder;

    public ResponseBodyEncryptAdvice(ResponseBodyEncoder responseBodyEncoder) {
        this.responseBodyEncoder = responseBodyEncoder;
    }

    @Override
    public boolean supports(MethodParameter returnType, Class> aClass) {
       return true;
    }

    @Override
    public Object beforeBodyWrite(Object body, MethodParameter methodParameter, MediaType mediaType, Class> 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 test() {
        Map 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 {

    
    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 VERSION_LIST = Collections.unmodifiableList(
            Arrays.asList(VERSION_PREFIX_PATTERN_1, VERSION_PREFIX_PATTERN_2, VERSION_PREFIX_PATTERN_3)
    );

    private static final ConcurrentMap 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 testv2() {
        Map 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 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 initParametersMap =  new HashMap<>();

    @Bean
    public FilterRegistrationBean contextFilterRegistrationBean() {
        initParametersMap.put("signMaxTime",signMaxTime);

        FilterRegistrationBean 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

扫描二维码推送至手机访问。

版权声明:本文由ruisui88发布,如需转载请注明出处。

本文链接:http://www.ruisui88.com/post/1645.html

标签: objectmapper
分享给朋友:

“为粉丝定制的SpringBoot服务端组件,零修改直接上线生产!” 的相关文章

总结了Vue3的七种组件通信方式,别再说不会组件通信了

写在前面本篇文章是全部采用的<script setup>这种组合式API写法,相对于选项式来说,组合式API这种写法更加自由,具体可以参考Vue文档对两种方式的描述。本篇文章将介绍如下七种组件通信方式:propsemitv-modelrefsprovide/injecteventBusv...

Git分布式系统---Gitlab多人工作流程

前言在上一次推文中,我们已经很清楚的讲解了如何创建本地仓库、提交(push)项目到远程仓库以及从远程仓库clone(克隆)项目到本地的相关操作。大家可以先去看前面的推文(快速掌握Git分布式系统操作)点击查看目前无论你是否步入社会还是在校学生,都会使用Gitlab来进行团队的代码管理。(可以这样说:...

高效使用 Vim 编辑器的 10 个技巧

在 Reverb,我们使用 MacVim 来标准化开发环境,使配对更容易,并提高效率。当我开始使用 Reverb 时,我以前从未使用过 Vim。我花了几个星期才开始感到舒服,但如果没有这样的提示,可能需要几个月的时间。这里有十个技巧可以帮助你在学习使用 Vim 时提高效率。1. 通过提高按键重复率来...

一套代码,多端运行——使用Vue3开发兼容多平台的小程序

介绍Vue3发布已经有一段时间了,从目前来看,其生态还算可以,也已经有了各种组件库给予了支持,但是不管是Vue3还是Vue2都无法直接用来开发小程序,因此国内一些技术团队针对Vue开发了一些多端兼容运行的开发框架,今天来体验一下使用Taro来体验一下使用Vue3开发多平台运行的小程序,以便于兼容各大...

thinkphp8+vue3微信小程序商城,发布公众号App+SAAS+多商户

项目介绍三勾小程序商城基于thinkphp8+vue3+element-ui+uniapp打造的面向开发的小程序商城,方便二次开发或直接使用,可发布到多端,包括微信小程序、微信公众号、QQ小程序、支付宝小程序、字节跳动小程序、百度小程序、android端、ios端。支持主题色+自定义头部导航+自定义...

三勾点餐系统java+springboot+vue3,开源系统小程序点餐系统

项目简述前台实现:用户浏览菜单、菜品分类筛选、查看菜品详情、菜品多属性、菜品加料、添加购物车、购物车结算、个人订单查询、门店自提、外卖配送、菜品打包等。后台实现:菜品管理、订单管理、会员管理、系统管理、权限管理等。 项目介绍三勾点餐系统基于java+springboot+element-plus+u...