如何使用 Spring 管理 REST API 版本控制?

我一直在寻找如何使用 Spring 3.2.x 来管理 REST API 版本,但是我没有找到任何易于维护的东西。我会先解释我的问题,然后是解决方案... 但我不知道我是不是在重新发明轮子。

我希望基于 Accept 头来管理这个版本,例如,如果一个请求有 Accept 头 application/vnd.company.app-1.1+json,我希望 Spring MVC 将其转发到处理这个版本的方法。由于 API 中的所有方法并非都在同一个版本中发生变化,因此我不希望转到每个控制器,为一个在不同版本之间没有变化的处理程序更改任何内容。我也不想用逻辑来确定在控制器中使用哪个版本(使用服务定位器) ,因为 Spring 已经发现了要调用哪个方法。

因此,使用版本1.0的 API 到版本1.8,其中在版本1.0中引入了一个处理程序,并在版本1.7中进行了修改,我希望用以下方式处理这个问题。假设代码在一个控制器中,并且有一些代码能够从头部提取版本。(以下内容在 Spring 中无效)

@RequestMapping(...)
@VersionRange(1.0,1.6)
@ResponseBody
public Object method1() {
// so something
return object;
}


@RequestMapping(...) //same Request mapping annotation
@VersionRange(1.7)
@ResponseBody
public Object method2() {
// so something
return object;
}

这在春季是不可能的,因为这两个方法有相同的 RequestMapping注释,而 Spring 无法加载。其思想是 VersionRange注释可以定义一个打开或关闭的版本范围。第一种方法适用于1.0到1.6版本,而第二种方法适用于1.7以后的版本(包括最新的1.8版本)。我知道,如果有人决定通过99.99版本,这种方法就会失效,但我可以接受。

现在,如果没有对 Spring 的工作原理进行认真的修改,上面的内容是不可能实现的,我正在考虑修改处理程序与请求匹配的方式,特别是编写我自己的 ProducesRequestCondition,并在其中设置版本范围。比如说

密码:

@RequestMapping(..., produces = "application/vnd.company.app-[1.0-1.6]+json)
@ResponseBody
public Object method1() {
// so something
return object;
}


@RequestMapping(..., produces = "application/vnd.company.app-[1.7-]+json)
@ResponseBody
public Object method2() {
// so something
return object;
}

通过这种方式,我可以在注释的生成部分中定义关闭或打开的版本范围。我现在正在研究这个解决方案,问题是我仍然需要替换一些核心的 Spring MVC 类(RequestMappingInfoHandlerMappingRequestMappingHandlerMappingRequestMappingInfo) ,这是我不喜欢的,因为当我决定升级到一个新版本的 Spring 时,这意味着额外的工作。

我会很感激任何想法... 特别是,任何建议这样做,在一个更简单,更容易维护的方式。


剪辑

增加赏金。要获得奖金,请回答上面的问题,而不要建议在控制器本身有这种逻辑。Spring 已经有很多逻辑来选择调用哪个控制器方法,我想借鉴一下。


编辑2

我已经在 github: https://github.com/augusto/restVersioning中分享了原始的 POC (做了一些改进)

118140 次浏览

在生产中可以有否定,所以在方法1中说 produces="!...1.7",在方法2中有正。

它们也是一个数组,所以对于 method1,可以说 produces={"...1.6","!...1.7","...1.8"}等等(接受除1.7以外的所有数组)

当然不像您想象中的范围那么理想,但是我认为如果这在您的系统中是不常见的,那么维护起来比其他自定义的东西更容易。祝你好运!

我仍然建议使用 URL 进行版本控制,因为在 URL 中@RequestMapping 支持模式和路径参数,这些格式可以用 regexp 指定。

为了处理客户端升级(你在评论中提到过) ,你可以使用别名,比如“最新”。或者有使用最新版本的未版本 API (是的)。

还可以使用路径参数实现任何复杂的版本处理逻辑,如果您已经希望拥有范围,那么很可能很快就需要更多内容。

下面是一些例子:

@RequestMapping({
"/**/public_api/1.1/method",
"/**/public_api/1.2/method",
})
public void method1(){
}


@RequestMapping({
"/**/public_api/1.3/method"
"/**/public_api/latest/method"
"/**/public_api/method"
})
public void method2(){
}


@RequestMapping({
"/**/public_api/1.4/method"
"/**/public_api/beta/method"
})
public void method2(){
}


//handles all 1.* requests
@RequestMapping({
"/**/public_api/{version:1\\.\\d+}/method"
})
public void methodManual1(@PathVariable("version") String version){
}


//handles 1.0-1.6 range, but somewhat ugly
@RequestMapping({
"/**/public_api/{version:1\\.[0123456]?}/method"
})
public void methodManual1(@PathVariable("version") String version){
}


//fully manual version handling
@RequestMapping({
"/**/public_api/{version}/method"
})
public void methodManual2(@PathVariable("version") String version){
int[] versionParts = getVersionParts(version);
//manual handling of versions
}


public int[] getVersionParts(String version){
try{
String[] versionParts = version.split("\\.");
int[] result = new int[versionParts.length];
for(int i=0;i<versionParts.length;i++){
result[i] = Integer.parseInt(versionParts[i]);
}
return result;
}catch (Exception ex) {
return null;
}
}

基于上一种方法,您实际上可以实现您想要的东西

例如,您可以有一个只包含具有版本处理的方法选项卡的控制器。

在这种处理中,您可以(使用反射/AOP/代码生成库)在某个 Spring 服务/组件或同一个类中查找具有相同名称/签名和所需@VersionRange 的方法,并调用它传递所有参数。

@RequestMapping注释支持一个 headers元素,该元素允许您缩小匹配请求的范围。特别是您可以在这里使用 Accept头文件。

@RequestMapping(headers = {
"Accept=application/vnd.company.app-1.0+json",
"Accept=application/vnd.company.app-1.1+json"
})

这不完全是您所描述的,因为它不直接处理范围,但元素确实支持 * 通配符以及!=.因此,对于所有版本都支持所讨论的端点的情况,甚至对于给定主版本的所有次要版本(例如1) ,您至少可以使用通配符。*).

我认为我以前没有真正使用过这个元素(如果有的话,我不记得了) ,所以我只是在

Http://docs.spring.io/spring/docs/current/javadoc-api/org/springframework/web/bind/annotation/requestmapping.html

无论版本控制是否可以通过向后兼容的变更来避免(当你受到某些公司指导方针的约束或者你的 API 客户端以一种错误的方式实现时,这可能并不总是可能的) ,抽象的需求是一个有趣的需求:

如何进行自定义请求映射,在不进行方法体中的计算的情况下,对来自请求的头值进行任意计算?

正如在 这么回答中所描述的,您实际上可以拥有相同的 @RequestMapping并使用不同的注释来区分在运行时发生的实际路由。要做到这一点,你必须:

  1. 创建一个新的注释 VersionRange
  2. 实现 RequestCondition<VersionRange>。因为您将拥有类似于最佳匹配算法的东西,所以必须检查用其他 VersionRange值注释的方法是否为当前请求提供了更好的匹配。
  3. 根据注释和请求条件实现一个 VersionRangeRequestMappingHandlerMapping(如文章 < a href = “ https://stackoverflow. com/a/10336769/2615437”> 如何实现@RequestMapping 自定义属性 ).
  4. 在使用默认的 RequestMappingHandlerMapping之前,配置 spring 来计算 VersionRangeRequestMappingHandlerMapping(例如,通过将其顺序设置为0)。

这不需要替换 Spring 组件,但使用 Spring 配置和扩展机制,因此即使更新 Spring 版本(只要新版本支持这些机制) ,它也应该可以工作。

你可以在拦截的时候使用 AOP

考虑有一个接收所有 /**/public_api/*的请求映射,在这个方法中什么也不做;

@RequestMapping({
"/**/public_api/*"
})
public void method2(Model model){
}

之后

@Override
public void around(Method method, Object[] args, Object target)
throws Throwable {
// look for the requested version from model parameter, call it desired range
// check the target object for @VersionRange annotation with reflection and acquire version ranges, call the function if it is in the desired range




}

唯一的限制是所有的控制器都必须在同一个控制器中。

对于 AOP 配置,可以查看 http://www.mkyong.com/spring/spring-aop-examples-advice/

我刚刚创建了一个自定义解决方案。我将 @ApiVersion注释与 @Controller类中的 @RequestMapping注释结合使用。

例如:

@Controller
@RequestMapping("x")
@ApiVersion(1)
class MyController {


@RequestMapping("a")
void a() {}         // maps to /v1/x/a


@RequestMapping("b")
@ApiVersion(2)
void b() {}         // maps to /v2/x/b


@RequestMapping("c")
@ApiVersion({1,3})
void c() {}         // maps to /v1/x/c
//  and to /v3/x/c


}

实施方法:

Java 注释:

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface ApiVersion {
int[] value();
}

Java (主要是从 RequestMappingHandlerMapping复制粘贴) :

public class ApiVersionRequestMappingHandlerMapping extends RequestMappingHandlerMapping {


private final String prefix;


public ApiVersionRequestMappingHandlerMapping(String prefix) {
this.prefix = prefix;
}


@Override
protected RequestMappingInfo getMappingForMethod(Method method, Class<?> handlerType) {
RequestMappingInfo info = super.getMappingForMethod(method, handlerType);
if(info == null) return null;


ApiVersion methodAnnotation = AnnotationUtils.findAnnotation(method, ApiVersion.class);
if(methodAnnotation != null) {
RequestCondition<?> methodCondition = getCustomMethodCondition(method);
// Concatenate our ApiVersion with the usual request mapping
info = createApiVersionInfo(methodAnnotation, methodCondition).combine(info);
} else {
ApiVersion typeAnnotation = AnnotationUtils.findAnnotation(handlerType, ApiVersion.class);
if(typeAnnotation != null) {
RequestCondition<?> typeCondition = getCustomTypeCondition(handlerType);
// Concatenate our ApiVersion with the usual request mapping
info = createApiVersionInfo(typeAnnotation, typeCondition).combine(info);
}
}


return info;
}


private RequestMappingInfo createApiVersionInfo(ApiVersion annotation, RequestCondition<?> customCondition) {
int[] values = annotation.value();
String[] patterns = new String[values.length];
for(int i=0; i<values.length; i++) {
// Build the URL prefix
patterns[i] = prefix+values[i];
}


return new RequestMappingInfo(
new PatternsRequestCondition(patterns, getUrlPathHelper(), getPathMatcher(), useSuffixPatternMatch(), useTrailingSlashMatch(), getFileExtensions()),
new RequestMethodsRequestCondition(),
new ParamsRequestCondition(),
new HeadersRequestCondition(),
new ConsumesRequestCondition(),
new ProducesRequestCondition(),
customCondition);
}


}

注入 WebMvcConfigurationSupport:

public class WebMvcConfig extends WebMvcConfigurationSupport {
@Override
public RequestMappingHandlerMapping requestMappingHandlerMapping() {
return new ApiVersionRequestMappingHandlerMapping("v");
}
}

那么仅仅使用继承来建模版本控制呢?这就是我在我的项目中使用的,它不需要特殊的弹簧配置,可以得到我想要的东西。

@RestController
@RequestMapping(value = "/test/1")
@Deprecated
public class Test1 {
...Fields Getters Setters...
@RequestMapping(method = RequestMethod.GET)
@Deprecated
public Test getTest(Long id) {
return serviceClass.getTestById(id);
}
@RequestMapping(method = RequestMethod.PUT)
public Test getTest(Test test) {
return serviceClass.updateTest(test);
}


}


@RestController
@RequestMapping(value = "/test/2")
public class Test2 extends Test1 {
...Fields Getters Setters...
@Override
@RequestMapping(method = RequestMethod.GET)
public Test getTest(Long id) {
return serviceClass.getAUpdated(id);
}


@RequestMapping(method = RequestMethod.DELETE)
public Test deleteTest(Long id) {
return serviceClass.deleteTestById(id);
}
}

这种设置允许很少的代码复制,并且能够轻松地将方法覆盖到 API 的新版本中。它还节省了使用版本切换逻辑使源代码复杂化的需要。如果您没有在某个版本中编写端点代码,那么默认情况下它会获取以前的版本。

与其他人做的相比,这似乎容易多了。我是不是遗漏了什么?

我已经实现了一个解决方案,处理 非常好的问题与休息版本。

一般说来,静息版本管理有三种主要的方法:

  • 基于 Path 的方法,客户端在 URL 中定义版本:

    http://localhost:9001/api/v1/user
    http://localhost:9001/api/v2/user
    
  • Content-Type header, in which the client defines the version in Accept header:

    http://localhost:9001/api/v1/user with
    Accept: application/vnd.app-1.0+json OR application/vnd.app-2.0+json
    
  • Custom Header, in which the client defines the version in a custom header.

The problem with the first approach is that if you change the version let's say from v1 -> v2, probably you need to copy-paste the v1 resources that haven't changed to v2 path

The problem with the second approach is that some tools like http://swagger.io/ cannot distinct between operations with same path but different Content-Type (check issue https://github.com/OAI/OpenAPI-Specification/issues/146)

The solution

Since i am working a lot with rest documentation tools, i prefer to use the first approach. My solution handles the problem with the first approach, so you don't need to copy-paste the endpoint to the new version.

Let's say we have v1 and v2 versions for the User controller:

package com.mspapant.example.restVersion.controller;


import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;


/**
* The user controller.
*
* @author : Manos Papantonakos on 19/8/2016.
*/
@Controller
@Api(value = "user", description = "Operations about users")
public class UserController {


/**
* Return the user.
*
* @return the user
*/
@ResponseBody
@RequestMapping(method = RequestMethod.GET, value = "/api/v1/user")
@ApiOperation(value = "Returns user", notes = "Returns the user", tags = {"GET", "User"})
public String getUserV1() {
return "User V1";
}


/**
* Return the user.
*
* @return the user
*/
@ResponseBody
@RequestMapping(method = RequestMethod.GET, value = "/api/v2/user")
@ApiOperation(value = "Returns user", notes = "Returns the user", tags = {"GET", "User"})
public String getUserV2() {
return "User V2";
}
}

规定是如果我请求用户资源的 V1,我必须采取 “用户 V1”响应,否则,如果我请求 V2第三版等,我必须采取 “用户 V2”响应。

enter image description here

为了在春天实现这一点,我们需要覆盖默认的 RequestMappingHandlerMapping行为:

package com.mspapant.example.restVersion.conf.mapping;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;


import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;


public class VersionRequestMappingHandlerMapping extends RequestMappingHandlerMapping {


@Value("${server.apiContext}")
private String apiContext;


@Value("${server.versionContext}")
private String versionContext;


@Override
protected HandlerMethod lookupHandlerMethod(String lookupPath, HttpServletRequest request) throws Exception {
HandlerMethod method = super.lookupHandlerMethod(lookupPath, request);
if (method == null && lookupPath.contains(getApiAndVersionContext())) {
String afterAPIURL = lookupPath.substring(lookupPath.indexOf(getApiAndVersionContext()) + getApiAndVersionContext().length());
String version = afterAPIURL.substring(0, afterAPIURL.indexOf("/"));
String path = afterAPIURL.substring(version.length() + 1);


int previousVersion = getPreviousVersion(version);
if (previousVersion != 0) {
lookupPath = getApiAndVersionContext() + previousVersion + "/" + path;
final String lookupFinal = lookupPath;
return lookupHandlerMethod(lookupPath, new HttpServletRequestWrapper(request) {
@Override
public String getRequestURI() {
return lookupFinal;
}


@Override
public String getServletPath() {
return lookupFinal;
}});
}
}
return method;
}


private String getApiAndVersionContext() {
return "/" + apiContext + "/" + versionContext;
}


private int getPreviousVersion(final String version) {
return new Integer(version) - 1 ;
}

}

实现读取 URL 中的版本,并从 Spring 请求解析 URL。如果这个 URL 不存在(例如客户端请求的 第三版) ,那么我们尝试使用 V2等等,直到找到资源的 最近的版本

为了看到这个实现的好处,假设我们有两个资源: 用户和公司:

http://localhost:9001/api/v{version}/user
http://localhost:9001/api/v{version}/company

假设我们改变了公司的“合同”,违背了客户的意愿。因此,我们实现了 http://localhost:9001/api/v2/company,并要求客户机在 v1上更改为 v2。

因此,来自客户端的新请求是:

http://localhost:9001/api/v2/user
http://localhost:9001/api/v2/company

而不是:

http://localhost:9001/api/v1/user
http://localhost:9001/api/v1/company

这里的 最好的部分是,有了这个解决方案,客户机将从 v1获得用户信息,从 v2获得公司信息,从而从用户 v2创建一个新的(相同的)端点!

休息文件 正如我之前所说,我选择基于 URL 的版本控制方法的原因是,一些像 swagger 这样的工具不会用相同的 URL 但不同的内容类型来记录不同的端点。使用这种解决方案,将显示两个端点,因为它们具有不同的 URL:

enter image description here

GIT

解决方案的实现: Https://github.com/mspapant/restversioningexample/

我已经尝试过使用 URI 版本控制来编写 API 的版本,比如:

/api/v1/orders
/api/v2/orders

但是,在尝试实现这一点时,存在一些挑战: 如何使用不同版本组织代码?如何同时管理两个(或更多)版本?删除某个版本的影响是什么?

我发现最好的替代方案不是整个 API 的版本,而是 控制每个端点上的版本。这种模式被称为 使用 Accept 头进行版本控制通过内容协商进行版本控制:

这种方法允许我们对单个资源表示进行版本控制 而不是对整个 API 进行版本控制,这样我们就可以得到更细粒度的 版本控制。它还创建了一个更小的内存占用 代码库,因为我们不必在以下情况下分叉整个应用程序 创建一个新版本。这种方法的另一个优点是 引入的 URI 路由规则 通过 URI 路径进行版本控制。

在 Spring 上的实现

首先,创建一个具有 produces属性的 Controller,该属性将在默认情况下应用于同一个类中的每个端点。

@RestController
@RequestMapping(value = "/api/orders/", produces = "application/vnd.company.etc.v1+json")
public class OrderController {


}

在此之后,我们可以想象一个可能的场景,其中有两个版本(V1V2)的端点用于“创建订单”:

@Deprecated
@PostMapping
public ResponseEntity<OrderResponse> createV1(
@RequestBody OrderRequest orderRequest) {


OrderResponse response = createOrderService.createOrder(orderRequest);
return new ResponseEntity<>(response, HttpStatus.CREATED);
}


@PostMapping(
produces = "application/vnd.company.etc.v2+json",
consumes = "application/vnd.company.etc.v2+json")
public ResponseEntity<OrderResponseV2> createV2(
@RequestBody OrderRequestV2 orderRequest) {


OrderResponse response = createOrderService.createOrder(orderRequest);
return new ResponseEntity<>(response, HttpStatus.CREATED);
}

完成! 只需使用所需的 Http Header版本调用每个端点:

Content-Type: application/vnd.company.etc.v1+json

或者,可以称之为 v2:

Content-Type: application/vnd.company.etc.v2+json

关于你的担忧:

而且由于 API 中的所有方法在同一个版本中并不都会发生变化,所以我 不想去我的每个控制器和改变任何东西 在不同版本之间没有更改的处理程序

如前所述,此策略使用实际版本维护每个 Controller 和端点。您只需修改具有修改且需要新版本的端点。

那斯威格呢?

设置不同版本的斯威格也很容易使用这个策略。