為粉絲定制的SpringBoot服務端組件,零修改直接上線生產!
前幾天,一位粉絲讓我為他實現一個基于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來防止重放攻擊和篡改數據。