为什么进行版本控制
由于需求和业务不断变化,Web API也会随之不断修改。如果直接对原来的接口修改,势必会影响其他系统的正常运行。
那么如何做到在不影响现有调用方的情况下,优雅地更新接口的功能呢?最简单高效的办法就是对Web API进行有效的版本控制。通过增加版本号来区分对应的版本,来满足各个接口调用方的需求。版本号的使用有以下几种方式:
- 1)通过域名进行区分,即不同的版本使用不同的域名,如v1.api.test.com、v2.api.test.com。
- 2)通过请求URL路径进行区分,在同一个域名下使用不同的URL路径,如test.com/api/v1/、test.com/api/v2。
- 3)通过请求参数进行区分,在同一个URL路径下增加version=v1或v2等,然后根据不同的版本选择执行不同的方法。在实际项目开发中,一般选择第二种方式,因为这样既能保证水平扩展,又不影响以前的老版本。
实现版本控制
Spring Boot对RESTful的支持非常全面,因而实现RESTful API非常简单,同样对于API版本控制也有相应的实现方案:
- 1)创建自定义的@APIVersion注解。
- 2)自定义URL匹配规则ApiVersionCondition。
- 3)使用RequestMappingHandlerMapping创建自定义的映射处理程序,根据Request参数匹配符合条件的处理程序。
步骤01
创建自定义注解
package com.qsdbl.malldemo.configuration.apiversion;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* @author: 轻率的保罗
* @since: 2022-11
* @description Web API 版本控制。步骤01、创建自定义注解
*/
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface ApiVersion {
/**
* @return版本号
*/
int value() default 1;
}
在上面的示例中,创建了ApiVersion自定义注解用于API版本控制,并返回了对应的版本号。
步骤02
自定义URL匹配逻辑
package com.qsdbl.malldemo.configuration.apiversion;
import org.springframework.web.servlet.mvc.condition.RequestCondition;
import javax.servlet.http.HttpServletRequest;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* @author: 轻率的保罗
* @since: 2022-11
* @Description: 步骤02、自定义URL匹配逻辑
*/
public class ApiVersionCondition implements RequestCondition<ApiVersionCondition> {
private final static Pattern VERSION_PREFIX_PATTERN = Pattern.compile(".*v(\\d+).*");
private int apiVersion;
ApiVersionCondition(int apiVersion) {
this.apiVersion = apiVersion;
}
private int getApiVersion() {
return apiVersion;
}
@Override
public ApiVersionCondition combine(ApiVersionCondition apiVersionCondition) {
return new ApiVersionCondition(apiVersionCondition.getApiVersion());
}
@Override
public ApiVersionCondition getMatchingCondition(HttpServletRequest httpServletRequest) {
Matcher m = VERSION_PREFIX_PATTERN.matcher(httpServletRequest.getRequestURI());
if (m.find()) {
Integer version = Integer.valueOf(m.group(1));
if (version >= this.apiVersion) {
return this;
}
}
return null;
}
@Override
public int compareTo(ApiVersionCondition apiVersionCondition, HttpServletRequest httpServletRequest) {
return apiVersionCondition.getApiVersion() - this.apiVersion;
}
}
当方法级别和类级别都有ApiVersion注解时,通过ApiVersionRequestCondition.combine方法将二者进行合并。最终将提取请求URL中的版本号,与注解上定义的版本号进行对比,判断URL是否符合版本要求。
步骤03
自定义匹配的处理程序
package com.qsdbl.malldemo.configuration.apiversion;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.mvc.condition.RequestCondition;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
import java.lang.reflect.Method;
/**
* @author: 轻率的保罗
* @since: 2022-11
* @Description: 步骤03、自定义匹配的处理程序
*/
public class ApiRequestMappingHandlerMapping extends RequestMappingHandlerMapping {
private static final String VERSION_FLAG = "{version}";
private static RequestCondition<ApiVersionCondition> createCondition(Class<?> clazz) {
RequestMapping classRequestMapping = clazz.getAnnotation(RequestMapping.class);
if (classRequestMapping == null) {
return null;
}
StringBuilder mappingUrlBuilder = new StringBuilder();
if (classRequestMapping.value().length > 0) {
mappingUrlBuilder.append(classRequestMapping.value()[0]);
}
String mappingUrl = mappingUrlBuilder.toString();
if (!mappingUrl.contains(VERSION_FLAG)) {
return null;
}
ApiVersion apiVersion = clazz.getAnnotation(ApiVersion.class);
return apiVersion == null ? new ApiVersionCondition(1) : new ApiVersionCondition(apiVersion.value());
}
@Override
protected RequestCondition<?> getCustomMethodCondition(Method method) {
return createCondition(method.getClass());
}
@Override
protected RequestCondition<?> getCustomTypeCondition(Class<?> handlerType) {
return createCondition(handlerType);
}
}
步骤04
配置注册自定义的RequestMappingHandlerMapping
package com.qsdbl.malldemo.configuration.apiversion;
import org.springframework.boot.autoconfigure.web.servlet.WebMvcRegistrations;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
/**
* @author: 轻率的保罗
* @since: 2022-11
* @Description: 步骤04、配置注册自定义的RequestMappingHandlerMapping
*/
@Configuration
public class WebMvcRegistrationsConfig implements WebMvcRegistrations {
@Override
public RequestMappingHandlerMapping getRequestMappingHandlerMapping() {
return new ApiRequestMappingHandlerMapping();
}
}
通过以上4步完成API版本控制的配置。代码看起来复杂,其实都是重写Spring Boot内部的处理流程。
步骤05
使用自定义注解ApiVersion改造Controller
package com.qsdbl.malldemo.web;
import com.qsdbl.malldemo.configuration.apiversion.ApiVersion;
import com.qsdbl.malldemo.entity.SysUserEntity;
import com.qsdbl.malldemo.common.dto.DataVo;
import com.qsdbl.malldemo.mapper.SysUserMapper;
import com.qsdbl.malldemo.service.impl.SysUserServiceImp;
import com.qsdbl.malldemo.common.utils.JSONResult;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
/**
* <p>
* 用户表 前端控制器
* </p>
*
* @author 轻率的保罗
* @since 2022-11
*/
@Slf4j
@RestController
@ApiVersion(value = 1)
@RequestMapping("api/{version}/user")
@Api(tags = "用户数据 前端控制器")
public class SysUserController {
/**
* 用户表 Mapper对象
*/
@Autowired
private SysUserMapper userMapper;
/**
* 用户表 Mapper对象
*/
@Autowired
private SysUserServiceImp userService;
@ApiOperation("查询所有用户数据")
@GetMapping("/all")
public DataVo queryAll(){
return JSONResult.ok(userMapper.selectList(null));
}
@ApiOperation(value = "分页查询用户数据",notes = "可添加查询过滤条件,为like模糊查询")
@GetMapping("/data")
public DataVo queryPage(@ApiParam(value = "当前页",required = true) int current,
@ApiParam(value = "页面大小",required = true) int size,
@ApiParam("查询条件,字段模糊查询") SysUserEntity user){
return userService.queryPage(current, size, user);
}
@ApiOperation("根据账号查找用户")
@GetMapping("/{userCode}")
public DataVo queryBycode(@PathVariable @ApiParam("账号") String userCode){
return userService.queryBycode(userCode);
}
}
在Controller上,添加/修改如下注解(可使用在方法级别和类级别):
@ApiVersion(value = 1)
@RequestMapping(“api/{version}/user”)
表名该Controller中的api版本为“1”
,路径中使用的参数{version}
将会被转换为v1
(见步骤2中的配置)。
测试
结论:升级接口时,原有接口不受影响,只关注变化的部分,没有变化的部分自动平滑升级。
添加一个SysUserControllerV2测试。
将api“查询所有用户数据”、“查询所有用户数据”注释掉,修改api“根据账号查找用户”的返回信息。
package com.qsdbl.malldemo.web;
import com.qsdbl.malldemo.common.dto.DataVo;
import com.qsdbl.malldemo.common.utils.JSONResult;
import com.qsdbl.malldemo.configuration.apiversion.ApiVersion;
import com.qsdbl.malldemo.entity.SysUserEntity;
import com.qsdbl.malldemo.mapper.SysUserMapper;
import com.qsdbl.malldemo.service.impl.SysUserServiceImp;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* <p>
* 用户表 前端控制器
* </p>
*
* @author 轻率的保罗
* @since 2022-11
*/
@Slf4j
@RestController
@ApiVersion(value = 2)
@RequestMapping("api/{version}/user")
@Api(tags = "用户数据 前端控制器")
public class SysUserControllerV2 {
/**
* 用户表 Mapper对象
*/
@Autowired
private SysUserMapper userMapper;
/**
* 用户表 Mapper对象
*/
@Autowired
private SysUserServiceImp userService;
// @ApiOperation("查询所有用户数据")
// @GetMapping("/all")
// public DataVo queryAll(){
// return JSONResult.ok(userMapper.selectList(null));
// }
// @ApiOperation(value = "分页查询用户数据",notes = "可添加查询过滤条件,为like模糊查询")
// @GetMapping("/data")
// public DataVo queryPage(@ApiParam(value = "当前页",required = true) int current,
// @ApiParam(value = "页面大小",required = true) int size,
// @ApiParam("查询条件,字段模糊查询") SysUserEntity user){
// return userService.queryPage(current, size, user);
// }
@ApiOperation("根据账号查找用户")
@GetMapping("/{userCode}")
public DataVo queryBycode(@PathVariable @ApiParam("账号") String userCode){
return JSONResult.build(200,"第二版本的api,目前开发中,请先使用第一个版本的api!!!",null);
}
}
现api版本如下:
- 版本1:
- 查询所有用户数据
- 查询所有用户数据
- 根据账号查找用户
- 版本2
- 根据账号查找用户
测试1
访问两个版本的api“根据账号查找用户”
访问版本1:
/malldemo/api/v1/user/admin
响应内容:
{
"code": 200,
"msg": "OK",
"data": [
{
"userCode": "admin",
"userName": "管理员",
"memo": "测试数据!"
}
]
}
访问版本2、3、4:
/malldemo/api/v2/user/admin
/malldemo/api/v3/user/admin
/malldemo/api/v4/user/admin
响应内容:
{
"code": 200,
"msg": "第二版本的api,目前开发中,请先使用第一个版本的api!!!",
"data": null
}
当请求正确的版本地址时,会自动匹配版本的对应接口 - 版本1、版本2均能正常访问。
当请求的版本大于当前版本时,默认匹配最新的版本 - 虽然不存在v3、v4版本,但依然能访问,匹配的是最高的版本v2而不是v1。
测试2
访问两个版本的api“根据账号查找用户”
访问版本1:
/malldemo/api/v1/user/data
# 参数均为
current=1
size=2
响应内容:
{
"code": 200,
"msg": "OK",
"data": {
"records": [
{
"userCode": "gaoshoujun",
"userName": "高守君",
"memo": "测试数据!"
},
{
"userCode": "gongjin",
"userName": "龚金",
"memo": "测试数据!"
}
],
"total": 102,
"size": 2,
"current": 1,
"orders": [],
"optimizeCountSql": true,
"searchCount": true,
"countId": null,
"maxLimit": null,
"pages": 51
}
}
访问版本2、3、4:
/malldemo/api/v2/user/data
/malldemo/api/v3/user/data
/malldemo/api/v4/user/data
# 参数均为
current=1
size=3
响应内容:
{
"code": 200,
"msg": "OK",
"data": {
"records": [
{
"userCode": "gaoshoujun",
"userName": "高守君",
"memo": "测试数据!"
},
{
"userCode": "gongjin",
"userName": "龚金",
"memo": "测试数据!"
},
{
"userCode": "manannan",
"userName": "马楠楠",
"memo": "测试数据!"
}
],
"total": 102,
"size": 3,
"current": 1,
"orders": [],
"optimizeCountSql": true,
"searchCount": true,
"countId": null,
"maxLimit": null,
"pages": 34
}
}
当请求的版本大于当前版本时,默认匹配最新的版本 - 虽然不存在v2、v3、v4版本,但依然能访问,匹配的是最高的版本v1。
小结
实现了旧版本的稳定和新版本的更新。
- 1)当请求正确的版本地址时,会自动匹配版本的对应接口。
- 2)当请求的版本大于当前版本时,默认匹配最新的版本。
- 3)高版本会默认继承低版本的所有接口。实现版本升级只关注变化的部分,没有变化的部分会自动平滑升级,这就是所谓的版本继承。
- 4)高版本的接口的新增和修改不会影响低版本。
说明
本博客中的案例,使用的maven依赖如下:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.5.2</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<!-- 启用web支持-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- 启用lombok(lombok 与 日志@Slf4j)-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- 启用单元测试-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- mybatis-plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.2</version>
</dependency>