用 @ApiVersion 注解给 API 增加版本号

通常我们设计 REST 接口时会要求在 API 上增加版本号,以方便在接口升级时保证一定的兼容性。我们假定设计的接口入下:

1
GET /api/v1/user/{userId}

在 Spring MVC 中可以这样这样实现:

1
2
3
4
5
@GetMapping(value = "/api/v1/user/{userId}")
public ResponseEntity<?> getUser(@PathVariable(value = "userId") String userId) {
return WebResponse.create(userService.getUser(userId))
.ok();
}

GetMapping 注解中手动指定了接口版本,但每个接口都指定或指定在类上也是非常不方便的,我们希望有一个注解来简化的约束这个配置的过程

需求

  1. 实现类上注解,统一对 Controller 下的 @RequestMapping 生效
  2. 方法上注解覆盖类上注解,方便后续升级版本

实现

先定义版本注解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Mapping
@RequestMapping(produces = MediaType.APPLICATION_JSON_UTF8_VALUE)
public @interface ApiVersion {
/**
* 接口版本,默认 v1
*/
String value() default "v1";

/**
* 接口前缀,默认 api
*/
String prefix() default "";
}

该注解定义里,value 表示接口的版本,默认我们定义为 v1;prefix 为接口前缀,默认是空。

同时,@Mapping 元注解表示该注解类是 Web Mapping 注解。@RequestMapping 注解配置了使用这个 @ApiVersion 注解时生效的 Mapping 配置,
这里将接口的响应内容格式设定为 application/json;charset=utf-8,因为我们这个约定 API 接口的返回应当是 JSON 格式的。

开发 RequestCondition

RequestCondition

RequestCondition 是 Spring 中用于描述请求映射条件的。当请求进来时,会通过调用 RequestConditiongetMatchingCondition 方法,
验证该方法是否匹配该处理方法。

从请求进入到查询匹配,大概调用链是这样的:

DispatcherServlet.getHandler
-> HandlerMapping(RequestMappingInfoHandlerMapping).getHandlerInternal
-> AbstractHandlerMethodMapping.lookupHandlerMethod
-> RequestMappingInfo.getMatchingCondition
-> RequestCondition.getMatchingCondition

ApiVersionCondition

该类有三个方法需要实现,第一个是 combine 方法。

1
T combine(T other);

该方法是两个 RequestCondition 对象合并时调用,比如同时存在类级和方法级的 @RequestMapping 注解。Lhs 是类级别,rhs 是方法级别。

ApiVersionCondition 中我们需求是忽略类上的,只保留方法上的配置,所以代码应该是

1
2
3
4
5
@Override
public ApiVersionCondition combine(ApiVersionCondition other) {
// 使用方法级别的配置
return new ApiVersionCondition(other.getApiVersion());
}

下一个方法就是前面介绍过的方法,用于判断请求是否匹配,匹配则返回 this,不匹配则返回 null

1
2
@Nullable
T getMatchingCondition(HttpServletRequest request);

由于我们的 @ApiVersion 只修改 URI 的前缀,因此我们主要取出合适的 API 路径作一下比较既可:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Override
public ApiVersionCondition getMatchingCondition(HttpServletRequest request) {
String requestURI = request.getRequestURI();
String contextPath = request.getContextPath();

String apiPath = getApiPath(requestURI, contextPath);

String matchString = getMatchPrefix();

if (StringUtils.startsWithIgnoreCase(apiPath, matchString))
return this;
return null;
}

我们 API 的路径实际是这样的:

1
2
https://domain/contextPath/prefix/path
https://api.imoe.tech/[user-panel]/[api/v1]/uder

contextPath 部分应该是要去掉的,所以我们 getMatchingCondition 方法将其去除后再进行前缀判断。

最后一个要实现的方法是 compareTo。该方法用于和其它的 ApiVersionCondition 进行比较,以选取最匹配的。

1
2
3
4
5
6
7
8
@Override
public int compareTo(ApiVersionCondition other, HttpServletRequest request) {
if (other.getMatchingCondition(request) != null)
return 1;
else if (this.getMatchingCondition(request) != null)
return -1;
return 0;
}

比较方法原则:
在比较方法中,this 相当于 lhs,other 相当于 rhs。

  1. 当 lhs < rhs 时返回 负值 (lhs - rhs < 0);
  2. 当 lhs > rhs 时返回 正值(lhs - rhs > 0);
  3. 相等返回 0(lhs - rhs = 0)

排序的 Natural Ordering:
排序是按照自然排序来排的,也就是 从低到高,从小到大

以下代码是 Mapping 查找方法 lookupHandlerMethod 选取最佳匹配的方式,先用 sort 排序,再选第一个匹配的。

1
2
3
4
// org.springframework.web.servlet.handler.AbstractHandlerMethodMapping#lookupHandlerMethod
// L397-398
matches.sort(comparator);
Match bestMatch = matches.get(0);

按照比较原则,两个 Condition 比较(this 和 other),哪个对象能匹配上,哪个对象就优先(小的优先)。如 other.getMatchingCondition() 返回非 NULL 成功匹配上,所以让其优先。
既 this > other,返回正值。

第二行 this.getMatchingCondition(request) != null 表示 other 的没匹配上,this 的匹配上了,使 this < other,所以返回负值。

最后如果谁都没匹配上,就返回 0。

以上的说明更多的是说明 Comparable 接口和 Arrays.sort 方法的约定逻辑。Sort 方法基于自然排序,将实现了 Comparable 接口的对象按从小到大排序。

扩展 Spring RMHM

默认的 RequestMappingHandlerMapping 类并不能支持我们的自定义 Condition 和自定义注解 @ApiVersion,所以我们还需对 RequestMappingHandlerMapping 做些扩展。

要实现我们需要的功能,只需要扩展以下三个方法,这三个方法也是实现自定义注解处理的常用模板方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class ApiVersionRequestMappingHandlerMapping extends RequestMappingHandlerMapping {
@Override
protected RequestCondition<?> getCustomTypeCondition(Class<?> handlerType) {

}

@Override
protected RequestCondition<?> getCustomMethodCondition(Method method) {

}

@Override
protected RequestMappingInfo getMappingForMethod(Method method, Class<?> handlerType) {

}
}

getCustomTypeCondition 方法用于取得应用在类上的 Condition,一般是注解在类上的 @Mapping 注解,这里我们取出注解在类上的 ApiVersion 注解信息,构造一个 ApiVersionCondition 对象。

1
2
3
4
5
@Override
protected RequestCondition<?> getCustomTypeCondition(Class<?> handlerType) {
ApiVersion annotation = AnnotationUtils.findAnnotation(handlerType, ApiVersion.class);
return createRequestCondition(annotation);
}

getCustomMethodCondition 方法和 getCustomTypeCondition 相同,只是该方法用于取出应用在方法上的 Condition。

1
2
3
4
5
@Override
protected RequestCondition<?> getCustomMethodCondition(Method method) {
ApiVersion annotation = AnnotationUtils.findAnnotation(method, ApiVersion.class);
return createRequestCondition(annotation);
}

getMappingForMethod 方法是实际上是组合以上两个方法的返回的 Condition,合并产生一个 Condition。所以在处理逻辑上还会调用上面的两个方法来获取 Condition。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Override
protected RequestMappingInfo getMappingForMethod(Method method, Class<?> handlerType) {
// 先通过原有的方法生成原有的映射信息
RequestMappingInfo info = super.getMappingForMethod(method, handlerType);

if (info == null)
return null;

// 然后取方法上的注解,并取得方法上 @ApiVersion 设置的 Condition
ApiVersion apiVersion = AnnotationUtils.findAnnotation(method, ApiVersion.class);
RequestCondition<?> condition = getCustomMethodCondition(method);

// 如果方法上不存在该注解,则试着从类上取
if (apiVersion == null) {
apiVersion = AnnotationUtils.findAnnotation(handlerType, ApiVersion.class);
condition = getCustomTypeCondition(handlerType);
}

// 如果方法和类上都取不到注解和 Condition,则使用 Spring 默认的接口配置信息
if (apiVersion == null || condition == null)
return info;

// 如果取得到,则构造一个请求配置信息
RequestMappingInfo lhs = createSimpleRequestMappingInfo(ApiVersionUtils.getMatchPrefix(apiVersion), condition);

// 最后将构造的和 Spring 生成的合并,构成完整接口配置
return lhs.combine(info);
}

上面代码先从 method 取(先调用 getCustomMethodCondition)的原因是我们要求方法上的注解配置优先级比类上高。如果方法上已经注解了,就忽略类上的注解配置。这点和 @RequestMapping
是不一样的,@RequestMapping 会将类上的配置和方法上的配置拼接起来(逻辑在 super.getMappingForMethod 中),显然我们不需要这样做。

最后一行,我们基于 @ApiVersion 构造的 RequestMappingInfo 被当成 lhs,而 Spring 产生的是 rhs。这是因为 Spring 产生的是真正在 Controller 里配置的 @RequestMapping
接口信息,而我们的是 @ApiVersion 配置的信息。最终的接口地址应该是这样的形式:

1
[prefix][version][RequestMapping]

[prefix][version]@ApiVersion 配置的,所以作为 lhs 才能实现我们的要求。

注册到 Spring

开发完 ApiVersionRequestMappingHandlerMapping 后,基本功能已经完成,但是要使之生效还需向 Spring 注册它。可以通过实现 WebMvcRegistrations 接口的 getRequestMappingHandlerMapping 方法,配置默认的
RequestMappingHandlerMappingApiVersionRequestMappingHandlerMapping,这样 Spring Boot 自动配置会自动完成配置。

1
2
3
4
5
6
7
8
9
10
11
12
13
public class ApiVersionRegistration implements WebMvcRegistrations {
private static final RequestMappingHandlerMapping REQUEST_MAPPING_HANDLER_MAPPING = new ApiVersionRequestMappingHandlerMapping();
/**
* Return the custom {@link RequestMappingHandlerMapping} that should be used and
* processed by the MVC configuration.
*
* @return the custom {@link RequestMappingHandlerMapping} instance
*/
@Override
public RequestMappingHandlerMapping getRequestMappingHandlerMapping() {
return REQUEST_MAPPING_HANDLER_MAPPING;
}
}

总结

Spring 提供了很多扩展点帮助我们很轻松实现我们需要的特殊功能,后面有空好好梳理一下 Spring 的各个阶段的流程和实现,也许可以对 Spring 有更深入的理解。

作者

Jakes Lee

发布于

2020-05-20

更新于

2021-11-18

许可协议

评论