我一直在寻找如何使用 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 版本,这种方法就会失效,但我可以接受。
现在,由于没有对 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 类(RequestMappingInfoHandlerMapping
、RequestMappingHandlerMapping
和 RequestMappingInfo
),因为这意味着无论何时都需要额外的工作我决定升级到更新版本的spring。
我会很感激任何想法......尤其是任何以更简单、更易于维护的方式执行此操作的建议。
编辑
添加赏金。要获得赏金,请回答上面的问题,而不建议在控制器本身中使用此逻辑。 Spring 已经有很多逻辑来选择调用哪个控制器方法,我想捎带它。
编辑 2
我在 github 中分享了原始 POC(经过一些改进):https://github.com/augusto/restVersioning
不管是否可以通过向后兼容的更改来避免版本控制(当您受到某些公司准则的约束或您的 API 客户端以错误的方式实现并且即使不应该中断也会中断),抽象的需求是一个有趣的一:
如何进行自定义请求映射,对请求中的标头值进行任意评估,而无需在方法主体中进行评估?
如 this SO answer 中所述,您实际上可以拥有相同的 @RequestMapping
并使用不同的注释来区分运行时发生的实际路由。为此,您必须:
创建一个新的注解 VersionRange。实现一个 RequestCondition
这不需要对 Spring 组件进行任何 hacky 替换,而是使用 Spring 配置和扩展机制,因此即使您更新 Spring 版本(只要新版本支持这些机制)它也应该工作。
我刚刚创建了一个自定义解决方案。我将 @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
}
执行:
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");
}
}
/v1/aResource
和 /v2/aResource
看起来像不同的资源,但它只是同一资源的不同表示! 2. 使用 HTTP 标头看起来更好,但是你不能给别人一个 URL,因为 URL 不包含标头。 3. 使用 URL 参数,即 /aResource?v=2.1
(顺便说一句:这是 Google 进行版本控制的方式)。 ...
我仍然不确定我会选择 2 还是 3 选项,但出于某种原因我再也不会使用 1上文提到的。
RequestMappingHandlerMapping
注入到您的 WebMvcConfiguration
中,您应该覆盖 createRequestMappingHandlerMapping
而不是 requestMappingHandlerMapping
!否则你会遇到奇怪的问题(由于会话关闭,我突然遇到了 Hibernates 延迟初始化的问题)
WebMvcConfigurationSupport
,而是扩展 DelegatingWebMvcConfiguration
。这对我有用(见 stackoverflow.com/questions/22267191/…)
我已经实现了一个解决方案,可以完美地处理剩余版本控制的问题。
一般而言,REST 版本控制有 3 种主要方法:
基于路径的方法,其中客户端在 URL 中定义版本: http://localhost:9001/api/v1/user http://localhost:9001/api/v2/user
Content-Type 标头,其中客户端在 Accept 标头中定义版本:http://localhost:9001/api/v1/user 和 Accept: application/vnd.app-1.0+json OR application/vnd.app-2.0+ json
自定义标头,其中客户端在自定义标头中定义版本。
第一种方法的问题是,如果您更改版本,比如说从 v1 -> v2,可能您需要将未更改的 v1 资源复制粘贴到 v2 路径
第二种方法的问题是像http://swagger.io/这样的一些工具无法区分具有相同路径但不同Content-Type的操作(检查问题https://github.com/OAI/OpenAPI-Specification/issues/146)
解决方案
由于我经常使用其他文档工具,因此我更喜欢使用第一种方法。我的解决方案使用第一种方法处理问题,因此您无需将端点复制粘贴到新版本。
假设我们有用户控制器的 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”响应,否则如果我请求 v2、v3 等,我必须接受“用户 V2”响应。
https://i.stack.imgur.com/pCP7v.png
为了在 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 等等,直到我们找到资源的最新版本.
为了看到这个实现的好处,假设我们有两个资源:用户和公司:
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 不同:
https://i.stack.imgur.com/Hbeln.png
胃肠道
解决方案实施:https://github.com/mspapant/restVersioningExample/
if (previousVersion != 0) {
设置为 >0
,并且您需要在 getPreviousVersion()
中捕获异常并返回 -1
2.4.2
,您需要调整创建新请求以:gist.github.com/eisenreich/6ab40616a9e694bc2220c68ec3a01455
我仍然建议使用 URL 进行版本控制,因为在 URL 中 @RequestMapping 支持模式和路径参数,可以使用正则表达式指定格式。
并且要处理客户端升级(您在评论中提到),您可以使用“最新”之类的别名。或者有使用最新版本的未版本化的 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;
}
}
基于最后一种方法,您实际上可以实现您想要的东西。
例如,您可以拥有一个仅包含带有版本处理的方法 stab 的控制器。
在该处理中,您查看(使用反射/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.*),至少您可以避免使用通配符。
我认为我之前实际上没有使用过这个元素(如果我不记得的话),所以我只是离开了文档
application/*
而不是类型的一部分。例如以下在 Spring "Accept=application/vnd.company.app-1.*+json"
中是无效的。这与弹簧类 MediaType
的工作方式有关
我已经尝试使用 URI 版本控制对我的 API 进行版本控制,例如:
/api/v1/orders
/api/v2/orders
但是在尝试完成这项工作时存在一些挑战:如何用不同的版本组织代码?如何同时管理两个(或更多)版本?删除某些版本有什么影响?
我发现最好的替代方案不是对整个 API 进行版本控制,而是控制每个端点上的版本。此模式称为 Versioning using Accept header 或 Versioning through content negotiation:
这种方法允许我们对单个资源表示进行版本控制,而不是对整个 API 进行版本控制,这使我们能够更精细地控制版本控制。它还在代码库中创建了更小的占用空间,因为我们在创建新版本时不必分叉整个应用程序。这种方法的另一个优点是它不需要实现通过 URI 路径进行版本控制引入的 URI 路由规则。
在 Spring 上实现
首先,您创建一个具有 produces
属性的控制器,该属性将默认应用于同一类内的每个端点。
@RestController
@RequestMapping(value = "/api/orders/", produces = "application/vnd.company.etc.v1+json")
public class OrderController {
}
之后,我们可以想象一个可能的场景,您有两个版本(v1 和 v2)用于“创建订单”的端点:
@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 中的所有方法都在同一个版本中更改,因此我不想转到我的每个控制器并更改版本之间未更改的处理程序的任何内容
如前所述,此策略使用其实际版本维护每个控制器和端点。您只需修改已修改并需要新版本的端点。
和招摇?
使用这种策略设置不同版本的 Swagger 也很容易。 See this answer 了解更多详情。
仅使用继承来建模版本控制怎么样?这就是我在我的项目中使用的,它不需要特殊的弹簧配置,并且可以得到我想要的。
@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 中。它还省去了使用版本切换逻辑使源代码复杂化的需要。如果您不在某个版本中编写端点代码,它将默认获取以前的版本。
与其他人所做的相比,这似乎更容易。有什么我想念的吗?
在产生你可以有否定。所以对于 method1 来说 produces="!...1.7"
并且在 method2 中是肯定的。
产品也是一个数组,因此对于方法 1,您可以说 produces={"...1.6","!...1.7","...1.8"}
等(接受除 1.7 之外的所有内容)
当然不像你想象的范围那么理想,但我认为如果这在你的系统中不常见的话,它比其他自定义的东西更容易维护。祝你好运!
可以使用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/
mvc:annotation-driven
背后的逻辑的任何更改。希望 Spring 将提供一个可以定义自定义条件的mvc:annotation-driven
版本。