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.
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
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:
- Utwórz nową adnotację
VersionRange
. - zaimplementuj
RequestCondition<VersionRange>
. Ponieważ będziesz miał coś w rodzaju algorytmu najlepiej dopasowującego, będziesz musiał sprawdzić, czy metody oznaczone innymi wartościamiVersionRange
zapewniają lepsze dopasowanie do bieżącego Prośba. - zaimplementuj
VersionRangeRequestMappingHandlerMapping
na podstawie adnotacji i warunku żądania (jak opisano w poście jak zaimplementować właściwości @RequestMapping ). - Skonfiguruj spring, aby oceniał twoje
VersionRangeRequestMappingHandlerMapping
przed użyciem domyślnegoRequestMappingHandlerMapping
(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).
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");
}
}
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.
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" .
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:
GIT
Implementacja rozwiązania w: https://github.com/mspapant/restVersioningExample/
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
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?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!
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/
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