我们将在这里分享来自指令集小伙伴们的行业经验、技术讨论,构建一个高品质的交流平台。在这里,你可以开启物联网奥秘的大门;在这里,可以点燃技术起飞的引信。

is 百脑汇

今日主题:

从API版本控制说起,

实现SpringBoot 一种版本控制的方式(上篇)

标签

Spring、SpringBoot、版本控制、RequestMappingHandlerMapping

涉及知识点

·     接口版本约束目的;

·     接口版本控制实现的常见方式;

·     springboot 注解原理;

·     spring 请求mapping的注册与匹配;

·     springboot 常用的mapping注解。

 


接口版本约束

    随着软件工程学地发展,软件开发设计也出现了各种新的方式,比如早些年开始的 MVC 模式解耦业务模型及视图,近些年出现微服务业务拆解,前后端分离开发等等。其中前后端分离开发目前很多互联网公司采取的方式,前后端分离后,涉及两前后端工作很重要的连接点就是接口 API,对 API 的规范及信息一致性非常重要。

   另一方面,有些平台性质的产品,对外提供 API 调用,对 API 的可用性、稳定性及持续的服务能力也需要比较高的要求,毕竟 API 调不通了,产品功能是直接受影响的。

  其中API版本约束就是管理的一项重要手段,如果你的产品只有一个小团队维护,而且API修改涉及面不是特别广,比如调整下,只要跟相关的同学协调修改下就可以,那么这种场景就不需要版本管理。但是如果开发团队特别多,产品会有多种不同时间迭代,或者是平台性质的,需要持续提供接口服务的,如果接口有变动,甚至只是增加一个字段属性返回,也会影响调用方的使用,因为谁也不能保护使用你接口的人会做什么事。

   对此,常用的手段,就是对接口设定一个版本号,如果需要提供新特性,旧版本保持不变,新版本增加特性,如果调用方使用新接口,得升级版本号,并做相关功能测试,不升级也不影响原有功能。

版本管理常见方式

1. 使用统一API版本

   使用一个API版本,即没有版本信息,只有一种接口,每次变更需要考虑向后兼容性,基本原则:只能在原来的结构基础上增加属性,而不能删除,同时需要客户端进行配合。

   比如:有个返回值是int类型,如果要变成String类型,不能改变原来的字段值,只能增加一个新的属性,这样才能保证老版本的正常使用,或者修改让调用方也一起修改,保持提供和使用方都一致。

   缺点:如果调用方多话,不修改。那么提供方返回数据过于冗余,不好判断哪个属性是哪个版本在用,对功能实现也会越来越重。如果调用方也修改,涉及到协同工作量问题。

  优点:接口管理量最小。

2. URL中设置版本信息

    这个也有两种实现方式:

  • 版本号写入URL中路径中,例如http://example.com/v2/item/list

  • 版本号作为参数传入,例如http://example.com/item/list?version=2

    这种方式都在请求资源上做了设置,第一种符合restful风格设定,在Springboot里面也比较适合使用注解实现(本文第二篇会讲到)。但是第一种作为合restful风格来说,版本本身不是对某种资源的访问,在使用上存在争议。第二种由于参数指定,在Springboot需要判断参数值要求,接口方法侵入太大,例如上面的接口,对Springboot方法入参需要设定:

@RequestMapping(value = "/item")
public String list(@RequestParam(name="version",value = "2")  intversion){
//..
return "";
}

缺点:这两种形式,在对业务使用上,都有一定的侵入性。不符合RestFull原则(说实话,原则不能当饭吃,解决问题为主),版本过多会不好控制。不过这种方式业界上使用地不少。   

  优点:比较直观,调试方便;

3. HTTP请求头设置版本信息   

也有两种方式使用:

  • 在Header中添加自定义参数,例如:isc-api-version: 2;

  • 在ContentType中添加版本号,例如:Accept: application/item.list.v2+json 或 Accept: application/item.list+json; version=2.0;

优点:符合RestFull原则,保持各版本的URL一致,版本和业务实现边界清晰;

缺点:不够直观,调试得使用一些工具如postman。对服务端实现需要单独处时版本路由,需要对版本统一实现。

版本过多也会难于维护,一般设定一个版本使用周期,比如只维护三个大版本,或者5个小版本同时使用,定期下架过期版本。

接下来本文基于目前工作中需要用到的Springboot来实现通过请求头版本设定,实现版本控制的方式。


  前文讲到接口版本管理的必要性及常见的几种接口版本规范方式,由于工作产品是一个平台性质的,需要业界规范使用restful接口,本实现采用在Header中添加自定义参数来实现服务端的接口版本路由:isc-api-version: 2;

Springboot已经提供自定义接口mapping方式,只要指定header头版本信息即可:

  @GetMapping(value = "/item", headers = "isc-api-version=1.1")
public String item() {
return "1.0-1";
}

由于产品涉及应用比较多,一方面很多接口需要版本管理,另一方面,从代码优雅度来说,对于“isc-api-version”常量,没有抽出来统一定义,对于代码洁癖的人来说,不太爽。当然也可以使用配置化来实现:

    @GetMapping(value = "/item", headers = "{$version_key}=1.1")
public String item() {
return "1.0-1";
}

然后在application.yml里统一定义version_key的值 :

  version_key:isc-api-version

这种方式本身Springboot 定义mapping是不支持的,无法从容器变量里获取值,去替换注解里的变量,需要实现一个定义的mapping注册方式。

/**
* 版本号匹配器
* @author 凌封
*/
public class VersionRequestMappingHandlerMapping2 extendsRequestMappingHandlerMapping {
   private RequestMappingInfo.BuilderConfiguration config = newRequestMappingInfo.BuilderConfiguration();
/**
 * 覆写父类的createRequestMappingInfo方法,在设置headers时,同path一样,支持变量定义
 */
public RequestMappingInfo createRequestMappingInfo(
RequestMapping requestMapping, @Nullable RequestCondition<?>customCondition) {

RequestMappingInfo.Builder builder = RequestMappingInfo
.paths(resolveEmbeddedValuesInPatterns(requestMapping.path()))
.methods(requestMapping.method())
.params(requestMapping.params())
.headers(resolveEmbeddedValuesInPatterns(requestMapping.headers()))
.consumes(requestMapping.consumes())
.produces(requestMapping.produces())
.mappingName(requestMapping.name());
if (customCondition != null) {
builder.customCondition(customCondition);
}
return builder.options(this.config).build();
}
...

关键之处就是

.headers(resolveEmbeddedValuesInPatterns(requestMapping.headers())),使用框架已有的resolveEmbeddedValuesInPatterns方法,封装header信息。

 另外,对于很多需要定义接口版本来说,需要版本定义,都需要编写这些字符代码:headers = "{$version_key}=1.1" ,感觉效率比较低。

有没有更优雅地实现方式呢?答案是肯定的,只要接口实现方法上增加版本注解,例如:

@GetMapping(value = "/item")
@ApiVersion(2.0)
public String item() {
return "2.0-1";
  }

这种方式,即减少了编码量,也减少了因误写错某些字符,导致bug的产生,因为ApiVersion注解入参,已指定了是double的入参属性。

接入下讲讲这种实现方式,这种实现方式,已经有人这么做了,但是只实现了部分,还有部分,比如启动时检查是否存在接口复重定义没有实现。本文把这部分的实现也追加上去。

在实现之前,得了解下,Springboot的请求流程,严格来说,是SpringMVC的实现方式,Springboot只是做了注解和bean的加载过程而已,这个过程一般使用SpringMVC的同学都比较清楚:

从图中看出找到最终的方法实现是通过HandlerMapping里找到对应mapping的过程,只要通过请求头找到mapping定义的注解匹配上即可。实现思路基本上是这样,同时Springboot也提供了RequestMappingHandlerMapping的自定义实现。

在实现之前,先了解下Springboot框架几个定义:

  • Controller

  控制器 Controller 负责处理由 DispatcherServlet 分发的请求。在这个时候,就先不考虑Model、ModelMap和ModelAndView之类的东东,大多数时候根本用不上这三个东东的,Spring提供的方法很简洁的

  • Mapping

RequestMapping是mapping的基本类型,另外还有GetMapping、PostMapping、PutMapping、DeleteMapping、PatchMapping。

各种mapping的匹配及实现方式,源码可以在org.springframework.web.bind.annotation包里查看定义,及org.springframework.web.servlet.mvc.method.RequestMappingInfo里面实现规则匹配方式:

public final class RequestMappingInfo implementsRequestCondition<RequestMappingInfo> {

@Nullable
private final String name;
private final PatternsRequestCondition patternsCondition;
private final RequestMethodsRequestCondition methodsCondition;
private final ParamsRequestCondition paramsCondition;
private final HeadersRequestCondition headersCondition;
private final ConsumesRequestCondition consumesCondition;
private final ProducesRequestCondition producesCondition;
private final RequestConditionHolder customConditionHolder;
...

从源码上,Springboot提供了customCondition 用户自定义匹配规则的方式,为实现自定义mapping提供了前提。下面来讲讲具体的实现。

首先实现注解@ApiVersion定义,由于版本可能需要在方法指定,也可以一系列方法统一定义,所以Target应该是支持方法或者类型的,如下:

/**
* 版本控制声明版本号
* @author 凌封
*/
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Mapping
public @interface ApiVersion {
   /**
    * 标识版本号
    * @return
    */
   double value();
}

然后实现自定义mapping的绑定,及绑定的条件:

/**
* 版本号匹配器
* @author 凌封
*/
public class VersionRequestMappingHandlerMapping extendsRequestMappingHandlerMapping {
   /**
      自定义类型注解匹配,即Controller接口类匹配
   **/
   @Override protected RequestCondition<ApiVersionCondition>getCustomTypeCondition(Class<?> handlerType) {
       ApiVersion apiVersion = AnnotationUtils.findAnnotation(handlerType,ApiVersion.class);
       return createCondition(apiVersion);
  }
   /**
      自定义方法注解匹配,即具体方法级别的注解匹配
   **/ 
   @Override protected RequestCondition<ApiVersionCondition>getCustomMethodCondition(Method method) {
       ApiVersion apiVersion = AnnotationUtils.findAnnotation(method,ApiVersion.class);
       return createCondition(apiVersion);
  }
   private RequestCondition<ApiVersionCondition>createCondition(ApiVersion apiVersion) {
       return apiVersion == null ? null : newApiVersionCondition(apiVersion.value());
  }
}

最后实现配条件ApiVersionCondition:

/**
* 版本号匹配筛选条件
* @author 凌封
*
*/
public class ApiVersionCondition implementsRequestCondition<ApiVersionCondition> {
   /**
    * OAS0.6标准协议指定 请求header头需带上版本号isc-api-version
    */
   private static final String HEADER_VERSION = "isc-api-version";
   /**
    * 为了兼容旧版本无请求头,默认从1.0版本开始
    */    
   private static final double DEFAULT_VERSION = 1.0;
   private Double apiVersion;
   public ApiVersionCondition(double apiVersion) {
       this.apiVersion = apiVersion;
  }
   @Override
   public ApiVersionCondition combine(ApiVersionCondition other) {
       //如果已有定义,返回原先的即可。
  if(this.apiVersion==other.getApiVersion())return this;
       return other;
  }

   @Override 
   public ApiVersionCondition getMatchingCondition(HttpServletRequestrequest) {
  String v = request.getHeader(HEADER_VERSION);
  Double version = DEFAULT_VERSION;
  if(StringUtil.isNotBlank(v)) {
  version = Double.valueOf(v);
  }
       // 如果请求的版本号等于配置版本号, 则满足
if(version==this.apiVersion.doubleValue())return this;

return null;
  }
   
   /**
    * 如果匹配到两个都符合版本需求的(理论上不应该有)
    */
   @Override
   public int compareTo(ApiVersionCondition other, HttpServletRequestrequest) {
  return 0;
  // 优先匹配最新的版本号,业务上暂不需要此功能
       //return Double.compare(other.getApiVersion(), this.apiVersion);
  }

   public double getApiVersion() {
       return apiVersion;
  }
}

我这需要的是接口版本精确匹配,有些场景可能需要未找到版本号以最新版本为准,或者指定一人请求版本号,以最接近的最大版本号为准。

例如:系统提供1.1、2.1版本接口,发送请求接口请求头是1.5版本,那么响应的是2.1版本mapping到。这个可以稍调整下ApiVersionCondition即可实现,网上已有例子;

最后把RequestMappingHandlerMapping注册到SpringMVC容器里里:

/**
* @author 凌封
* @create 2020-07-07 11:34
**/
@Configuration
public class VersionConfiguration implements WebMvcRegistrations{
   @Bean
   protected RequestMappingHandlerMappingcustomRequestMappingHandlerMapping() {
       return new VersionRequestMappingHandlerMapping();
  }
public RequestMappingHandlerMappinggetRequestMappingHandlerMapping() {
RequestMappingHandlerMapping handlerMapping = newVersionRequestMappingHandlerMapping();
handlerMapping.setOrder(0);
return handlerMapping;
}
}

写个测试接口:

@RestController
@RequestMapping(value = "/api/list")

public class TestController2{

@GetMapping(value = "/item")
@ApiVersion(1.0)
public String item() {
return "1.0";
  }
@GetMapping(value = "/item")
@ApiVersion(2.0)
public String send1() {
return "2.0";
  }
}

启动应用,可以实现请求头,指定版本调用了:

不指定版本(默认1.0)

  指定版本:

 找不到版本(未处理异常):

以上基本基于Springboot mapping实现了用header头指定API版本的请求mapping功能,如果出现相关的mapping定义,启动应用会提示报错,如:

@RestController
@RequestMapping(value = "/api/list")

public class TestController2{

@GetMapping(value = "/item")
public String item() {
return "1.0";
  }
@GetMapping(value = "/item")
public String send1() {
return "1.0";
  }
}

启动应用时会提示已存在接口定义异常,从而杜绝应用在运行期出现的问题:

但是采用自定义@ApiVersion定义请求头mapping时,启动时并不提示报错,只有在调用时,因为两个版本一样,会出错:

@RestController
@RequestMapping(value = "/api/list")

public class TestController2{

@GetMapping(value = "/item")
@ApiVersion(1.0)
public String item() {
return "1.0";
  }
@GetMapping(value = "/item")
@ApiVersion(1.0)
public String send1() {
return "1.0";
  }

}

请求/api/list/item 时请求头isc-api-version都是1.0时会报错,因为本身两个方法都匹配得上,让线程怎么选择:

作为软件开发,我们希望越把问题发现放在前面越好,而不要提交测试阶段才发现,那么怎么做到和Springboot框架一样,在启动应用时,就提示有mapping得重复的异常呢?

解决这个问题需要从Spirngboot 加载mapping的原理讲起,请期待《从API版本控制说起,实现SpringBoot 一种版本控制的方式(下篇)》。

扫二维码|关注指令集技术站

更多来自指令集小伙伴的

技术分享 | 一手掌握

扫二维码|关注指令集招聘

更多公司福利与员工活动

招聘信息 | 一手掌握

Logo

权威|前沿|技术|干货|国内首个API全生命周期开发者社区

更多推荐