Как управлять версиями REST API с помощью spring?

Я искал, как управлять версиями API REST с помощью Spring 3.2.x, но я не нашел ничего, что легко поддерживать. Сначала я объясню возникшую проблему, а затем решение... но я действительно задаюсь вопросом, не изобретаю ли я здесь колесо.

Я хочу управлять версией на основе заголовка Accept, и, например, если запрос имеет заголовок Accept application/vnd.company.app-1.1+json, я хочу, чтобы Spring MVC пересылал это методу, который обрабатывает эту версию. И поскольку не все методы в API изменяются в той же версии, я не хочу обращаться к каждому из моих контроллеров и ничего менять для обработчика, который не изменился между версиями. Я также не хочу, чтобы логика определяла, какую версию использовать в самом контроллере (используя локаторы службы), поскольку Spring уже обнаруживает, какой метод вызывать.

Итак, взятый API с версиями 1.0, 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;
}

Это невозможно в 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

Я поделился исходным POC (с некоторыми улучшениями) в github: https://github.com/augusto/restVersioning

Ответ 1

Независимо от того, можно ли избежать контроля версий путем внесения обратно совместимых изменений (что не всегда возможно, когда вы связаны какими-то корпоративными правилами или ваши API-клиенты реализованы с ошибками и нарушат работу, даже если они не должны), абстрагированное требование является интересным один:

Как я могу сделать пользовательское сопоставление запроса, которое выполняет произвольные оценки значений заголовка из запроса, не делая оценку в теле метода?

Как описано в этом SO-ответе, вы на самом деле можете иметь один и тот же @RequestMapping и использовать другую аннотацию для дифференциации во время реальной маршрутизации, которая происходит во время выполнения. Для этого вам необходимо:

  1. Создайте новую аннотацию VersionRange.
  2. RequestCondition<VersionRange>. Поскольку у вас будет что-то вроде алгоритма наилучшего совпадения, вам придется проверить, обеспечивают ли методы, помеченные другими значениями VersionRange, лучшее соответствие для текущего запроса.
  3. Реализовать VersionRangeRequestMappingHandlerMapping на основе аннотаций и запрос состоянии (как описано в посте Как реализовать @RequestMapping пользовательских свойств).
  4. Сконфигурируйте spring для оценки вашего VersionRangeRequestMappingHandlerMapping перед использованием по умолчанию RequestMappingHandlerMapping (например, установив его порядок в 0).

Это не потребует какой-либо хакерской замены компонентов Spring, но использует механизмы конфигурации и расширения Spring, поэтому он должен работать, даже если вы обновите версию Spring (если новая версия поддерживает эти механизмы).

Ответ 2

Я только что создал собственное решение. Я использую аннотацию @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");
    }
}

Ответ 3

Я по-прежнему рекомендую использовать 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;
    }
}

Основываясь на последнем подходе, вы можете реально реализовать что-то вроде того, что хотите.

Например, у вас может быть контроллер, содержащий только удары с обработкой версии.

В этой обработке вы смотрите (с использованием библиотек генерации отражений/AOP/кода) в некотором spring службе/компоненте или в том же классе для метода с тем же именем/сигнатурой и требуемом @VersionRange и вызывать его передачу всех параметров.

Ответ 4

Я реализовал решение, которое обрабатывает PERFECTLY проблему с версией управления версиями.

В общем, есть три основных подхода к управлению версиями отдыха:

  • Путь, основанный на утверждении, в котором клиент определяет версию в URL:

    http://localhost:9001/api/v1/user
    http://localhost:9001/api/v2/user
    
  • Заголовок Content-Type, в котором клиент определяет версию в заголовке Принять:

    http://localhost:9001/api/v1/user with 
    Accept: application/vnd.app-1.0+json OR application/vnd.app-2.0+json
    
  • Пользовательский заголовок, в котором клиент определяет версию в пользовательском заголовке.

Проблема с подходом first заключается в том, что если вы измените версию, скажем, из v1 → v2, возможно, вам нужно скопировать-вставить ресурсы v1, которые имеют 't изменен на путь v2

Проблема с подходом второй заключается в том, что некоторые инструменты, такие как http://swagger.io/ не может различаться между операциями с одним и тем же путем, но с другим Content-Type (check issue 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" .

введите описание изображения здесь

Чтобы реализовать это в 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, и мы просим от клиента перейти на 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

лучшая часть заключается в том, что с этим решением клиент получит информацию пользователя из v1 и информацию о компании из v2 без необходимости, чтобы создать новую (такую ​​же) конечная точка пользователя v2!

Документация для отдыха Как я уже говорил, причина, по которой я выбираю подход, основанный на использовании URL-адреса, заключается в том, что некоторые инструменты, такие как swagger, не документируют по-разному конечные точки с одним и тем же URL-адресом, но с другим типом контента. С этим решением отображаются обе конечные точки, так как имеют разные URL:

введите описание изображения здесь

GIT

Реализация решения: https://github.com/mspapant/restVersioningExample/

Ответ 5

Аннотация @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

Ответ 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 с небольшой работой. Это также избавляет вас от необходимости усложнять исходный код логикой переключения версий. Если вы не кодируете конечную точку в версии, она по умолчанию будет использовать предыдущую версию.

По сравнению с тем, что делают другие, это выглядит проще. Есть что-то, что мне не хватает?

Ответ 7

В творениях вы можете отрицать. Таким образом, для метода1 говорят produces="!...1.7", а в методе2 - положительные.

Выпускает также массив, поэтому вы можете использовать метод1, который вы можете сказать produces={"...1.6","!...1.7","...1.8"} и т.д. (принять все, кроме 1.7)

Конечно, не так идеально, как диапазоны, которые вы имеете в виду, но я считаю, что их легче поддерживать, чем другие пользовательские вещи, если это что-то необычное в вашей системе. Удачи!

Ответ 8

Я уже пытался создать версию своего API с использованием версий URI, например:

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

Но есть некоторые проблемы при попытке заставить это работать: как организовать свой код с различными версиями? Как управлять двумя (или более) версиями одновременно? Как повлияет удаление какой-либо версии?

Лучшей альтернативой, которую я нашел, была не версия всего API, а контроль версии на каждой конечной точке. Этот шаблон называется версионированием с использованием заголовка Accept или версионированием через согласование содержимого:

Этот подход позволяет нам создавать версии одного представления ресурса вместо управления версиями всего API, что дает нам более детальный контроль над версиями. Это также создает меньшую площадь в базе кода, поскольку нам не нужно разбирать приложение целиком при создании новой версии. Другое преимущество этого подхода состоит в том, что он не требует реализации правил маршрутизации URI, введенных путем управления версиями через путь URI.

Реализация на весну

Сначала вы создаете контроллер с базовым атрибутом продукции, который будет применяться по умолчанию для каждой конечной точки внутри класса.

@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

Или, чтобы назвать версию два:

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

О ваших заботах:

И поскольку не все методы в API меняются в одном выпуске, я не хочу переходить к каждому из моих контроллеров и менять что-либо для обработчика, который не менялся между версиями.

Как объяснено, эта стратегия поддерживает каждый контроллер и конечную точку с его актуальной версией. Вы изменяете только конечную точку, которая имеет модификации и нуждается в новой версии.

И чванство?

Настроить Swagger с различными версиями также очень легко, используя эту стратегию. Смотрите этот ответ для более подробной информации.

Ответ 9

Вы можете использовать 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/