[java] Spring으로 REST API 버전 관리를 관리하는 방법은 무엇입니까?

Spring 3.2.x를 사용하여 REST API 버전을 관리하는 방법을 찾고 있었지만 유지 관리하기 쉬운 것을 찾지 못했습니다. 먼저 내가 가진 문제를 설명하고 그 다음 해결책을 설명하겠습니다.하지만 여기서 바퀴를 다시 발명하고 있는지 궁금합니다.

Accept 헤더를 기반으로 버전을 관리하고 싶습니다. 예를 들어 요청에 Accept 헤더가있는 경우 application/vnd.company.app-1.1+jsonSpring MVC에서이 버전을 처리하는 메서드로 전달하기를 원합니다. API의 모든 메서드가 동일한 릴리스에서 변경되는 것은 아니기 때문에 각 컨트롤러로 이동하여 버전간에 변경되지 않은 핸들러에 대해 아무것도 변경하고 싶지 않습니다. 또한 Spring이 이미 호출 할 메서드를 발견하고 있으므로 컨트롤러 자체에서 사용할 버전 (서비스 로케이터 사용)을 파악하는 논리를 갖고 싶지 않습니다.

따라서 버전 1.0에서 핸들러가 도입되고 v1.7에서 수정 된 버전 1.0에서 1.8까지의 API를 가져 오면 다음과 같이 처리하고 싶습니다. 코드가 컨트롤러 내부에 있고 헤더에서 버전을 추출 할 수있는 코드가 있다고 상상해보십시오. (다음은 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 이로드되지 않기 때문에 이것은 Spring에서 가능하지 않습니다. 아이디어는 VersionRange주석이 개방형 또는 폐쇄 형 버전 범위를 정의 할 수 있다는 것입니다 . 첫 번째 방법은 버전 1.0에서 1.6까지 유효하고 두 번째 방법은 버전 1.7 이상 (최신 버전 1.8 포함)에서 유효합니다. 누군가가 99.99 버전을 통과하기로 결정하면이 접근 방식이 깨진다는 것을 알고 있지만, 그게 제가 살아도 괜찮습니다.

이제 스프링 작동 방식을 심각하게 재 작업하지 않고는 위의 작업을 수행 할 수 없기 때문에 핸들러가 요청에 일치하는 방식, 특히 내를 작성 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, RequestMappingHandlerMappingRequestMappingInfo) 를 교체해야하는 문제가 있습니다 . 새로운 버전으로 업그레이드하기로 결정할 때마다 추가 작업을 의미하기 때문입니다. 봄.

나는 어떤 생각이라도 고맙게 생각하고 … 특히 더 간단하고 유지하기 쉬운 방법으로 이것을 수행하는 제안.


편집하다

현상금 추가. 현상금을 받으려면 컨트롤러 자체에이 로직이 있다는 제안없이 위의 질문에 답하십시오. Spring은 이미 호출 할 컨트롤러 메서드를 선택하는 많은 논리를 가지고 있으며 이에 대해 피기 백하고 싶습니다.


편집 2

github에서 원본 POC (일부 개선 사항 포함)를 공유했습니다 : https://github.com/augusto/restVersioning



답변

이전 버전과 호환되는 변경을 수행하여 버전 관리를 피할 수 있는지 여부에 관계없이 (일부 기업 지침에 묶여 있거나 API 클라이언트가 버그가있는 방식으로 구현되어서는 안 되더라도 깨질 수있는 경우 항상 가능하지는 않을 수 있음) 추상화 된 요구 사항은 흥미 롭습니다. 하나:

메서드 본문에서 평가를 수행하지 않고 요청에서 헤더 값을 임의로 평가하는 사용자 지정 요청 매핑을 수행하려면 어떻게해야합니까?

이 SO 답변 에서 설명한 것처럼 실제로는 동일 @RequestMapping하고 다른 주석을 사용하여 런타임 중에 발생하는 실제 라우팅 중에 구별 할 수 있습니다 . 이렇게하려면 다음을 수행해야합니다.

  1. 새 주석을 만듭니다 VersionRange.
  2. 을 구현합니다 RequestCondition<VersionRange>. 가장 일치하는 알고리즘과 같은 것이 있으므로 다른 VersionRange값으로 주석이 달린 메서드 가 현재 요청에 대해 더 나은 일치를 제공 하는지 확인해야합니다 .
  3. 구현 VersionRangeRequestMappingHandlerMapping(포스트에 설명 된대로 주석과 요구 조건에 따라 @RequestMapping 사용자 지정 속성을 구현하는 방법
    ).
  4. VersionRangeRequestMappingHandlerMapping기본값을 사용하기 전에 평가하도록 spring을 구성하십시오 RequestMappingHandlerMapping(예 : 순서를 0으로 설정).

이것은 Spring 컴포넌트의 해키 교체가 필요하지 않지만 Spring 구성 및 확장 메커니즘을 사용하므로 Spring 버전을 업데이트하더라도 작동합니다 (새 버전이 이러한 메커니즘을 지원하는 한).


답변

방금 맞춤형 솔루션을 만들었습니다. 클래스 내부의 주석 @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");
    }
}


답변

URL에서 @RequestMapping은 regexp로 지정할 수있는 형식과 경로 매개 변수를 지원하기 때문에 버전 관리에 URL을 사용하는 것이 좋습니다.

그리고 클라이언트 업그레이드 (댓글에서 언급 했음)를 처리하기 위해 ‘최신’과 같은 별칭을 사용할 수 있습니다. 또는 최신 버전을 사용하는 버전이없는 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 만 포함하는 컨트롤러를 가질 수 있습니다.

해당 처리에서 일부 스프링 서비스 / 컴포넌트 또는 동일한 이름 / 서명 및 필수 @VersionRange를 가진 메서드에 대한 동일한 클래스에서 (반사 / AOP / 코드 생성 라이브러리 사용)보고 모든 매개 변수를 전달하여 호출합니다.


답변

완벽 하게 처리하는 솔루션을 구현했습니다.나머지 버전 관리 문제를 .

일반 말하기 나머지 버전 관리에는 세 가지 주요 접근 방식이 있습니다.

  • 클라이언트가 URL에서 버전을 정의하는 경로 기반 접근 방식 :

    http://localhost:9001/api/v1/user
    http://localhost:9001/api/v2/user
  • 클라이언트가 Accept 헤더 의 버전을 정의하는 Content-Type 헤더 :

    http://localhost:9001/api/v1/user with 
    Accept: application/vnd.app-1.0+json OR application/vnd.app-2.0+json
  • 사용자 지정 헤더 클라이언트가 사용자 정의 헤더의 버전을 정의하는.

문제첫 번째 방법은 당신이 버전을 변경하는 경우의가 V1에서 생각한 것입니다 -> V2, 아마 당신은 v2의 경로로 변경되지 않은 V1 자원을 복사하여 붙여 넣어야

문제두 번째 방법은 같은 몇 가지 도구입니다 http://swagger.io/ 동일한 경로하지만 서로 다른 내용 유형 (체크 문제와 작업 사이 수 구별하지 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” 내가 요청 그렇지 않은 경우, repsonse을 V2 , V3를 그래서 내가 가지고 가야에서 “사용자 V2” 응답을.

여기에 이미지 설명 입력

이를 봄에 구현하려면 기본 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에서 버전을 읽고 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

여기서 가장 좋은 점은이 솔루션을 사용하면 클라이언트가 사용자 v2에서 새 (동일한) 엔드 포인트를 만들 필요없이 v1에서 사용자 정보를 가져오고 v2에서 회사 정보를 얻을 수 있다는 것입니다 !

나머지 문서화
내가 URL 기반 버전 관리 방식을 선택한 이유 전에 말했듯이 swagger와 같은 일부 도구는 동일한 URL을 사용하지만 콘텐츠 유형이 다른 엔드 포인트를 다르게 문서화하지 않기 때문입니다. 이 솔루션을 사용하면 URL이 다르기 때문에 두 끝 점이 표시됩니다.

여기에 이미지 설명 입력

GIT

솔루션 구현 :
https://github.com/mspapant/restVersioningExample/


답변

@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


답변

모델 버전 관리에 상속을 사용하는 것은 어떻습니까? 그것이 내 프로젝트에서 사용하고 있으며 특별한 스프링 구성이 필요하지 않으며 내가 원하는 것을 정확하게 얻습니다.

@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로 메서드를 덮어 쓸 수있는 기능을 제공합니다. 또한 버전 전환 논리로 소스 코드를 복잡하게 만들 필요가 없습니다. 버전에서 엔드 포인트를 코딩하지 않으면 기본적으로 이전 버전을 가져옵니다.

다른 사람들이하는 일에 비해 훨씬 쉬운 것 같습니다. 내가 놓친 것이 있습니까?


답변

이미 다음과 같이 URI Versioning을 사용하여 API의 버전을 지정 하려고했습니다 .

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

그러나이 작업을 수행 할 때 몇 가지 문제가 있습니다. 다른 버전으로 코드를 구성하는 방법은 무엇입니까? 동시에 두 개 이상의 버전을 관리하는 방법은 무엇입니까? 일부 버전을 제거하면 어떤 영향이 있습니까?

내가 찾은 최고의 대안은 전체 API 버전이 아니라 각 엔드 포인트의 버전을 제어하는 ​​것 입니다. 이 패턴 을 Accept 헤더를 사용한 Versioning 또는 Content Negotiation을 통한 Versioning 이라고합니다 .

이 접근 방식을 사용하면 전체 API를 버전 관리하는 대신 단일 리소스 표현을 버전 관리 할 수 ​​있으므로 버전 관리를보다 세밀하게 제어 할 수 있습니다. 또한 새 버전을 생성 할 때 전체 애플리케이션을 포크 할 필요가 없기 때문에 코드베이스에 더 작은 공간을 생성합니다. 이 접근 방식의 또 다른 장점은 URI 경로를 통한 버전 관리로 도입 된 URI 라우팅 규칙을 구현할 필요가 없다는 것입니다.

Spring에서 구현

먼저 기본 생성 속성을 사용하여 컨트롤러를 생성합니다.이 속성은 기본적으로 클래스 내의 각 엔드 포인트에 적용됩니다.

@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 헤더 버전을 사용하여 각 엔드 포인트를 호출하기 만하면됩니다 .

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

또는 버전 2를 호출하려면 :

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

당신의 걱정 :

API의 모든 메서드가 동일한 릴리스에서 변경되는 것은 아니기 때문에 각 컨트롤러로 이동하여 버전간에 변경되지 않은 핸들러에 대해 아무것도 변경하고 싶지 않습니다.

설명했듯이이 전략은 실제 버전으로 각 컨트롤러와 엔드 포인트를 유지합니다. 수정 사항이 있고 새 버전이 필요한 끝점 만 수정합니다.

그리고 Swagger?

이 전략을 사용하면 다른 버전으로 Swagger를 설정하는 것도 매우 쉽습니다. 자세한 내용은이 답변 을 참조하십시오.