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

时间:2013-11-25 16:37:49

标签: java spring rest spring-mvc versioning

我一直在搜索如何使用Spring 3.2.x管理REST API版本,但我找不到任何易于维护的版本。我先解释一下我遇到的问题,然后解决一个问题...但我想知道我是否在这里重新发明了这个问题。

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

所以采用版本1.0到1.8的API,其中版本1.0中引入了处理程序并在v1.7中进行了修改,我想以下面的方式处理它。想象一下,代码在控制器内部,并且有一些代码能够从头部中提取版本。 (以下在春季无效)

@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;
}

这在春天是不可能的,因为2个方法具有相同的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已经有很多逻辑来选择调用哪个控制器方法,我想捎带它。


编辑2

我在github上分享了原始的POC(有一些改进):https://github.com/augusto/restVersioning

9 个答案:

答案 0 :(得分:57)

无论是通过执行向后兼容的更改(当您受到某些公司指南约束时可能并不总是可能,或者您的API客户端以错误的方式实现并且即使它们不应该会破坏)也无法避免版本控制是一个有趣的:

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

this SO answer中所述,您实际上可以使用相同的@RequestMapping并使用不同的注释来区分在运行时期间发生的实际路由。为此,您必须:

  1. 创建新的注释VersionRange
  2. 实施RequestCondition<VersionRange>。由于您将拥有类似最佳匹配算法的内容,因此您必须检查使用其他VersionRange值注释的方法是否为当前请求提供更好的匹配。
  3. 根据注释和请求条件实施VersionRangeRequestMappingHandlerMapping(如帖子 How to implement @RequestMapping custom properties 中所述)。
  4. 配置弹簧以在使用默认VersionRangeRequestMappingHandlerMapping之前评估您的RequestMappingHandlerMapping(例如,将其顺序​​设置为0)。
  5. 这不需要任何Spring组件的hacky替换,但使用Spring配置和扩展机制,因此即使您更新Spring版本它也应该工作(只要新版本支持这些机制)。

答案 1 :(得分:46)

我刚创建了一个自定义解决方案。我在@ApiVersion类中使用了@RequestMapping注释和@Controller注释。

实施例

@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

}

实现:

ApiVersion.java 注释:

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

ApiVersionRequestMappingHandlerMapping.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");
    }
}

答案 2 :(得分:16)

我仍然建议使用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;
    }
}

根据最后一种方法,你可以实际实现你想要的东西。

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

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

答案 3 :(得分:10)

我已经实施了一个解决方案,可以处理完美问题与其他版本控制。

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

    基于
  • 路径的approch,其中客户端在URL中定义版本:

    http://localhost:9001/api/v1/user
    http://localhost:9001/api/v2/user
    
  • 内容类型标头,其中客户端在接受标头中定义版本:

    http://localhost:9001/api/v1/user with 
    Accept: application/vnd.app-1.0+json OR application/vnd.app-2.0+json
    
  • 自定义标题,客户端在自定义标题中定义版本。

使用第一个方法的问题是,如果您更改版本,请说出来自v1 - &gt; v2,可能需要将未更改的v1资源复制粘贴到v2路径

使用第二方法的问题http://swagger.io/之类的某些工具无法区分具有相同路径但内容类型不同的操作(请检查问题{{ 3}})

解决方案

由于我正在使用其他文档工具,我更喜欢使用第一种方法。我的解决方案使用第一种方法处理问题,因此您无需将端点复制粘贴到新版本。

假设我们有用户控制器的v1和v2版本:

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” repsonse,否则如果我请求 v2 v3 等等我必须采取“用户V2”响应。

https://github.com/OAI/OpenAPI-Specification/issues/146

为了在春季实现这一点,我们需要覆盖默认的 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不存在(例如客户端请求 v3 ),那么我们尝试使用 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

解决方案实施: enter image description here

答案 4 :(得分:8)

@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

答案 5 :(得分:3)

使用继承来模拟版本控制怎么样?这就是我在我的项目中使用的东西,它不需要特殊的弹簧配置,并且让我得到我想要的东西。

@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中。它还节省了使用版本切换逻辑使源代码复杂化的需要。如果您没有在版本中编写端点,它将默认获取以前的版本。

与其他人相比,这似乎更容易。有什么我想念的吗?

答案 6 :(得分:1)

在产品中你可以有否定。因此对于method1说produces="!...1.7"并且在method2中有积极的。

产品也是一个数组,所以你可以为method1说produces={"...1.6","!...1.7","...1.8"}等(接受1.7以外的所有)

当然,并不像你想到的那样理想,但我觉得比其他自定义的东西更容易维护,如果这在你的系统中是不常见的。祝你好运!

答案 7 :(得分:1)

我已经尝试使用 URI Versioning 对API进行版本控制,例如:

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

但是在尝试使之工作时会遇到一些挑战:如何组织具有不同版本的代码?如何同时管理两个(或多个)版本?删除某些版本会有什么影响?

我发现最好的替代方法不是对整个API进行版本控制,而是在每个端点上控制版本。此模式称为Versioning using Accept headerVersioning through content negotiation

  

这种方法使我们可以对单个资源表示形式进行版本控制   而不是对整个API进行版本控制,这使我们更加细化   控制版本控制。它还在   代码库,因为我们不必在需要时派生整个应用程序   创建一个新版本。这种方法的另一个优点是   不需要实施由引入的URI路由规则   通过URI路径进行版本控制。

春季实施

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

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

}

在那之后,创建一个可能的场景,其中有两个版本的端点可以用来创建订单:

@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

或者,将其称为第二版:

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

关于您的后顾之忧:

  

由于在同一发行版中,并非API中的所有方法都发生了变化,因此我   不想去我的每个控制器并为   版本之间未更改的处理程序

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

那招子吗?

使用此策略,使用不同版本设置Swagger也非常容易。 See this answer了解更多详情。

答案 8 :(得分:0)

您可以在拦截

周围使用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/