Jak zarządzać wersjonowaniem REST API za pomocą springa?

Szukałem sposobu zarządzania wersjami REST API przy użyciu Spring 3.2.x, ale nie znalazłem nic łatwego w utrzymaniu. Najpierw wyjaśnię problem, a potem rozwiązanie... ale zastanawiam się, czy ponownie wymyślam koło.

Chcę zarządzać wersją w oparciu o nagłówek Accept i na przykład, jeśli żądanie ma nagłówek Accept application/vnd.company.app-1.1+json, chcę, aby Spring MVC przekazało to do metody, która obsługuje tę wersję. A ponieważ nie wszystkie metody w API zmieniają się w to samo wydanie, nie chcę iść do każdego z moich kontrolerów i zmieniać czegokolwiek na Handlera, który nie zmienił się między wersjami. Nie chcę też mieć logiki, aby dowiedzieć się, którą wersję użyć w samym kontrolerze (używając lokalizatorów usług), ponieważ Spring już odkrywa, którą metodę wywołać.

Tak wziÄ ... Ĺ 'em API z wersjami 1.0, do 1.8 gdzie handler zostaĹ' wprowadzony w wersji 1.0 i zmodyfikowany w v1.7, chciaĹ 'bym obsĹ' ugiwaÄ ‡ to w nastÄ ™ pujÄ ... cy sposĂłb. Wyobraź sobie, że kod znajduje się wewnątrz kontrolera, i że jest jakiś kod, który jest w stanie wyodrębnić wersję z nagłówka. (Poniższy tekst jest nieprawidłowy na wiosnę)

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

Nie jest to możliwe w springu, ponieważ 2 metody mają tę samą adnotację RequestMapping i Spring nie ładuje się. Chodzi o to, że adnotacja VersionRange może definiować otwarty lub zamknięty zakres wersji. Pierwsza metoda jest ważna od wersji 1.0 do 1.6, podczas gdy druga dla wersji 1.7 począwszy (włącznie z najnowszą wersją 1.8). Wiem o tym. takie podejście łamie się, jeśli ktoś zdecyduje się przejść wersję 99.99, ale to jest coś, z czym mogę żyć.

Teraz, ponieważ powyższe nie jest możliwe bez poważnej przeróbki, jak działa sprężyna, myślałem o majstrowaniu nad sposobem, w jaki Handlery dopasowują się do żądań, w szczególności o napisaniu własnego ProducesRequestCondition, i o zakresie wersji tam. Na przykład

Kod:

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

W ten sposób mogę mieć zamknięte lub otwarte zakresy wersji zdefiniowane w części produkcji adnotacji. Pracuję teraz nad tym rozwiązaniem, z problemem, że wciąż musiałem zastąpić niektóre klasy core Spring MVC (RequestMappingInfoHandlerMapping, RequestMappingHandlerMapping i RequestMappingInfo), co mi się nie podoba, ponieważ oznacza to dodatkową pracę za każdym razem, gdy zdecyduję się na upgrade do nowszej wersji springa.

Będę wdzięczny za wszelkie przemyślenia... a zwłaszcza wszelkie sugestie, aby zrobić to w prostszy, łatwiejszy do utrzymania sposób.

Edytuj

Dodanie nagrody. Aby otrzymać nagrodę, proszę odpowiedzieć na powyższe pytanie bez sugerowania się mieć tę logikę w samym kontrolerze. Spring ma już dużo logiki, aby wybrać metodę kontrolera do wywołania, a ja chcę się tym zająć.


Edycja 2

Podzieliłem się oryginalnym POC (z pewnymi ulepszeniami) w GitHubie: https://github.com/augusto/restVersioning

Author: Augusto, 2013-11-25

8 answers

Niezależnie od tego, czy można uniknąć wersjonowania, wprowadzając wstecznie kompatybilne zmiany (co może nie zawsze być możliwe, gdy jesteś związany wytycznymi korporacyjnymi lub klienci API są zaimplementowani w błędny sposób i złamaliby nawet jeśli nie powinni), abstrakcyjny wymóg jest interesujący:

Jak mogę zrobić niestandardowe mapowanie żądania, które wykonuje dowolne oceny wartości nagłówka z żądania bez dokonywania oceny w metodzie ciało?

Jak opisano w to więc odpowiedź W rzeczywistości możesz mieć tę samą @RequestMapping i użyć innej adnotacji do rozróżnienia podczas rzeczywistego routingu, który dzieje się podczas wykonywania. Aby to zrobić, musisz:

  1. Utwórz nową adnotację VersionRange.
  2. zaimplementuj RequestCondition<VersionRange>. Ponieważ będziesz miał coś w rodzaju algorytmu najlepiej dopasowującego, będziesz musiał sprawdzić, czy metody oznaczone innymi wartościami VersionRange zapewniają lepsze dopasowanie do bieżącego Prośba.
  3. zaimplementuj VersionRangeRequestMappingHandlerMapping na podstawie adnotacji i warunku żądania (jak opisano w poście jak zaimplementować właściwości @RequestMapping ).
  4. Skonfiguruj spring, aby oceniał twoje VersionRangeRequestMappingHandlerMapping przed użyciem domyślnego RequestMappingHandlerMapping (np. ustawiając jego kolejność na 0).

Nie wymagałoby to żadnych hakerskich wymian elementów sprężyny, ale wykorzystuje konfigurację sprężyny i mechanizmy rozszerzenia, więc powinno działać nawet jeśli zaktualizujesz swoją wersję sprężyny (jako o ile nowa wersja wspiera te mechanizmy).

 50
Author: xwoker,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2018-02-22 05:59:15

Właśnie stworzyłem niestandardowe rozwiązanie. Używam adnotacji @ApiVersion w połączeniu z adnotacją @RequestMapping wewnątrz klas @Controller.

Przykład:

@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

}

Realizacja:

/ align = "left" / java adnotacja:

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

/ align = "center" bgcolor = "# E0ffe0 " / cesarz chin / / align = center / java (jest to głównie kopiowanie i wklejanie z 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);
    }

}

Wtrysk do WebMvcConfigurationSupport:

public class WebMvcConfig extends WebMvcConfigurationSupport {
    @Override
    public RequestMappingHandlerMapping requestMappingHandlerMapping() {
        return new ApiVersionRequestMappingHandlerMapping("v");
    }
}
 37
Author: Benjamin M,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2017-02-09 16:41:52

Nadal zalecałbym używanie adresów URL do wersjonowania, ponieważ w Url @RequestMapping obsługuje wzorce i parametry ścieżki, których format można określić za pomocą wyrażenia regularnego.

I do obsługi aktualizacji klienta (o których wspomniałeś w komentarzu) możesz używać aliasów typu 'latest'. Lub mieć niesprawdzoną wersję api, która używa najnowszej wersji (tak).

Używając również parametrów ścieżki możesz zaimplementować dowolną skomplikowaną logikę obsługi wersji, a jeśli już chcesz mieć zakresy, bardzo dobrze może wkrótce będziesz chciał czegoś więcej.

Oto kilka przykładów:

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

Na podstawie ostatniego podejścia można rzeczywiście wdrożyć coś takiego, co chcesz.

Na przykład możesz mieć kontroler, który zawiera tylko metody z obsługą wersji.

W tej obsłudze patrzysz (używając bibliotek reflection / AOP / code generation) w jakimś serwisie/komponencie spring lub w tej samej klasie dla metody o tej samej nazwie/podpisie i wymaganej @ VersionRange i wywołaj go przekazując wszystkie parametry.

 16
Author: elusive-code,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2013-12-01 07:58:15

Zaimplementowałem rozwiązanie, które doskonale obsługuje problem z wersjonowaniem rest.

Mówiąc ogólnie istnieją 3 główne podejścia do wersjonowania reszty:

  • Path - oparty na approchu, w którym Klient definiuje wersję w URL:

    http://localhost:9001/api/v1/user
    http://localhost:9001/api/v2/user
    
  • Content-Type header, w którym Klient definiuje wersję w Accept header:

    http://localhost:9001/api/v1/user with 
    Accept: application/vnd.app-1.0+json OR application/vnd.app-2.0+json
    
  • Niestandardowy nagłówek , w którym klient definiuje wersję w niestandardowym nagłówku.

Problem z pierwszym podejściem jest to, że jeśli zmienisz wersję powiedzmy z v1 -> v2, prawdopodobnie musisz skopiować i wkleić zasoby v1, które nie zmieniły się na ścieżkę v2

Problem z podejściemsecond polega na tym, że niektóre narzędzia jak http://swagger.io/ nie można rozróżniać operacji z tą samą ścieżką, ale z innym typem zawartości (problem z zaznaczeniem https://github.com/OAI/OpenAPI-Specification/issues/146 )

Rozwiązanie

Ponieważ dużo pracuję z narzędziami do dokumentacji rest, wolę użyć pierwszego podejścia. Moje rozwiązanie obsługuje problem przy pierwszym podejściu, więc nie musisz kopiować i wklejać punktu końcowego do nowej wersji.

Załóżmy, że mamy wersje v1 i v2 dla kontrolera użytkownika:

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

Wymóg jest jeśli zażądam v1 dla zasobu użytkownika muszę wziąć "User V1" repsonse, w przeciwnym razie jeśli zażądam v2, v3 i tak dalej muszę przyjąć odpowiedź "User V2" .

Tutaj wpisz opis obrazka

Aby zaimplementować to na wiosnę, musimy nadpisać domyślne RequestMappingHandlerMapping zachowanie:

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

}

Implementacja odczytuje wersję z adresu URL i prosi od springa o rozwiązanie adresu URL .w przypadku, gdy ten adres URL nie istnieje (na przykład klient zażądał v3) następnie próbujemy z v2 i tak jeden, aż znajdziemy najnowszą wersję dla zasobu.

Aby zobaczyć korzyści z tej implementacji, powiedzmy, że mamy dwa zasoby: użytkownik i firma:

http://localhost:9001/api/v{version}/user
http://localhost:9001/api/v{version}/company
Powiedzmy, że zmieniliśmy "umowę" firmy, która łamie klienta. Dlatego implementujemy http://localhost:9001/api/v2/company i prosimy Klienta o zmianę na v2 zamiast na v1.

Więc nowe prośby od klienta są:

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

Zamiast:

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

Najlepsza część polega na tym, że dzięki temu rozwiązaniu Klient otrzyma informacje o użytkowniku z v1, a informacje o firmie z V2 bez potrzeby tworzenia nowego (tego samego) punktu końcowego z user v2!

Dokumentacja Rest Jak powiedziałem wcześniej powodem, dla którego wybieram podejście do wersjonowania oparte na adresach URL, jest to, że niektóre narzędzia, takie jak swagger, nie dokumentują inaczej punktów końcowych z tym samym adresem URL, ale inną zawartością Typ. Dzięki temu rozwiązaniu oba punkty końcowe są wyświetlane, ponieważ mają inny adres URL:

Tutaj wpisz opis obrazka

GIT

Implementacja rozwiązania w: https://github.com/mspapant/restVersioningExample/

 8
Author: mspapant,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2016-08-21 16:00:43

Adnotacja @RequestMapping obsługuje element headers, który pozwala zawęzić pasujące żądania. W szczególności możesz użyć nagłówka Accept tutaj.

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

Nie jest to dokładnie to, co opisujesz, ponieważ nie obsługuje bezpośrednio zakresów, ale element obsługuje również * wildcard !=. Tak więc przynajmniej można ujść na sucho za pomocą wieloznacznej karty w przypadkach, gdy wszystkie wersje obsługują dany punkt końcowy, a nawet wszystkie mniejsze wersje danej głównej wersji (np. 1.*).

Chyba nie używałem wcześniej tego elementu (o ile pamiętam), więc odchodzę z Dokumentacji na

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

 6
Author: Willie Wheeler,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2013-12-01 08:57:14

A co z użyciem dziedziczenia do wersjonowania modelu? To jest to, czego używam w moim projekcie i nie wymaga specjalnej konfiguracji sprężyny i daje mi dokładnie to, czego chcę.

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

Ta konfiguracja pozwala na niewielkie powielanie kodu i możliwość nadpisywania metod do nowych wersji api przy niewielkim nakładzie pracy. Oszczędza to również konieczność komplikowania kodu źródłowego z logiką przełączania wersji. Jeśli nie zakodujesz punktu końcowego w wersji, pobierze on poprzednią wersję przez default.

W porównaniu z tym, co robią inni, wydaje się to o wiele łatwiejsze. Coś mi umyka?
 2
Author: Ceekay,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2016-02-05 22:58:30

W generowaniu można mieć negację. Więc dla method1 powiedz produces="!...1.7" i w method2 mieć dodatni.

The produces jest również tablicą więc dla method1 można powiedzieć produces={"...1.6","!...1.7","...1.8"} etc (accept all except 1.7)

Oczywiście nie tak idealne jak zakresy, które masz na myśli, ale myślę, że łatwiejsze w utrzymaniu niż inne niestandardowe rzeczy, jeśli jest to coś rzadkiego w Twoim systemie. Powodzenia!

 1
Author: codesalsa,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2013-11-27 23:07:50

Możesz użyć AOP, wokół przechwytywania

Rozważ mapowanie żądania, które odbiera wszystkie /**/public_api/* i w tej metodzie nic nie rób;

@RequestMapping({
    "/**/public_api/*"
})
public void method2(Model model){
}

Po

@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


}

Jedynym ograniczeniem jest to, że wszystko musi znajdować się w tym samym kontrolerze.

Dla konfiguracji AOP spójrz na http://www.mkyong.com/spring/spring-aop-examples-advice/

 0
Author: hevi,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2013-12-04 15:56:39