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

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

前几天,一位粉丝让我为他实现一个基于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)。如果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来防止重放攻击和篡改数据。
责任编辑:武晓燕 来源: JAVA日知录
相关推荐

2024-11-05 18:18:48

2024-04-10 10:09:07

2016-03-18 09:04:42

swift服务端

2023-07-26 10:21:26

服务端组件客户端

2021-10-19 08:58:48

Java 语言 Java 基础

2013-03-25 10:08:44

PHPWeb

2012-03-02 10:38:33

MySQL

2010-03-19 18:17:17

Java Server

2024-03-06 14:58:52

客户端微服务架构

2010-08-03 09:59:30

NFS服务

2016-11-03 09:59:38

kotlinjavaspring

2021-10-14 08:39:17

Java Netty Java 基础

2016-08-04 14:41:21

架构java服务端开发

2021-05-25 08:20:37

编程技能开发

2021-10-21 08:21:10

Java Reflect Java 基础

2021-04-26 13:20:06

Vue服务端渲染前端

2023-08-08 08:17:23

VasDolly服务端参数

2010-03-18 18:09:36

Java Socket

2010-03-04 16:09:58

Ubuntu apac

2010-11-19 14:22:04

oracle服务端
点赞
收藏

51CTO技术栈公众号