首页 文章

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

提问于
浏览
94

我一直在寻找如何使用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中进行了修改,我希望以下列方式处理它 . 想象一下,代码在控制器内部,并且有一些代码能够从头部中提取版本 . (以下在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;
}

这在 Spring 天是不可能的,因为2个方法具有相同的 RequestMapping 注释并且Spring无法加载 . 这个想法是 VersionRange 注释可以定义一个开放或封闭的版本范围 . 第一种方法从版本1.0到1.6有效,而第二种方法从版本1.7开始(包括最新版本1.8) . 我知道如果有人决定通过99.99版本,这种方法会中断,但是's something I' m可以接受 .

现在,由于如果没有对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中分享了原始的POC(有一些改进):https://github.com/augusto/restVersioning

8 回答

  • 39

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

    How can I do a custom request mapping that does arbitrary evaluations of header values from the request without doing the evaluation in the method body?

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

    • 创建新注释 VersionRange .

    • 实施 RequestCondition<VersionRange> . 由于您将拥有类似最佳匹配算法的内容,因此您必须检查使用其他 VersionRange 值注释的方法是否为当前请求提供了更好的匹配 .

    • 基于注释和请求条件实现 VersionRangeRequestMappingHandlerMapping (如How to implement @RequestMapping custom properties中所述) .

    • 配置 spring 以在使用默认 RequestMappingHandlerMapping 之前评估 VersionRangeRequestMappingHandlerMapping (例如,通过将其顺序设置为0) .

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

  • 2

    我刚创建了一个自定义解决方案我在 @Controller 类中使用了 @ApiVersion 注释和 @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
    
    }
    

    实施:

    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");
        }
    }
    
  • 9

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

    Based on the last approach you can actually implement something like what you want.

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

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

  • 1

    我已经实现了一个处理 PERFECTLY 其余版本控制问题的解决方案 .

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

    基于

    • Path 的approch,客户端在URL中定义版本:
    http://localhost:9001/api/v1/user
    http://localhost:9001/api/v2/user
    
    • Content-Type 标头,客户端在 Accept 标头中定义版本:
    http://localhost:9001/api/v1/user with 
    Accept: application/vnd.app-1.0+json OR application/vnd.app-2.0+json
    
    • Custom Header ,客户端在自定义标头中定义版本 .

    problemfirst 方法是如果您更改版本让's say from v1 -> v2, probably you need to copy-paste the v1 resources that haven' t更改为v2路径

    使用 second 方法的 problem 是某些工具(如http://swagger.io/)无法区分具有相同路径但内容类型不同的操作(检查问题https://github.com/OAI/OpenAPI-Specification/issues/146

    The solution

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

    假设我们有用户控制器的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";
        }
     }
    

    requirement 如果我请求 v1 为用户资源我必须采取 "User V1" repsonse,否则如果我请求 v2v3 等等我必须采取 "User V2" 响应 .

    enter image description here

    为了在spring中实现这一点,我们需要覆盖默认的 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 ,直到我们找到资源的 most recent version .

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

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

    假设我们改变了公司"contract",打破了客户 . 所以我们实现 http://localhost:9001/api/v2/company 并且我们要求客户端改为v2而不是v1 .

    所以客户的新请求是:

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

    代替:

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

    这里的 best 部分是使用此解决方案,客户端将从v1获取用户信息,从v2 without the need 获取公司信息,以从用户v2创建新的(相同) endpoints !

    Rest Documentation 正如我之前所说的,选择基于URL的版本控制方法的原因是,像swagger这样的工具不会以不同的方式记录具有相同URL但内容类型不同的 endpoints . 使用此解决方案,由于具有不同的URL,因此显示两个 endpoints :

    enter image description here

    GIT

    解决方案实施位于:https://github.com/mspapant/restVersioningExample/

  • 16

    @RequestMapping 注释支持 headers 元素,允许您缩小匹配请求 . 特别是你可以在这里使用 Accept Headers .

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

    这并不是您所描述的,因为它不直接处理范围,但该元素确实支持*通配符以及!= . 因此,至少你可以使用通配符来解决所有版本都支持相关 endpoints 的情况,甚至是给定主要版本的所有次要版本(例如1. *) .

    我不认为我之前实际使用过这个元素(如果我不记得的话),所以我只是在文档中删除

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

  • 6

    如何使用继承来建模版本?这就是我在我的项目中使用的东西,它不需要特殊的 spring 配置,让我得到我想要的东西 .

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

    与其他人相比,这似乎更容易 . 我有什么东西吗?失踪?

  • 50

    在产品中你可以有否定 . 所以对于method1来说 produces="!...1.7" 并且在method2中有正面 .

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

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

  • 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/

相关问题