web-dev-qa-db-fra.com

Comment gérer REST Versioning API avec spring?

J'ai cherché comment gérer une REST de l'API avec Spring 3.2.x, mais je n'ai rien trouvé qui soit facile à gérer. Je vais d'abord expliquer le problème que j'ai, et ensuite une solution ... mais je me demande si je réinvente la roue ici.

Je souhaite gérer la version en fonction de l'en-tête Accepter et, par exemple, si une demande comporte l'en-tête Accepter application/vnd.company.app-1.1+json, Je veux que spring MVC transmette cela à la méthode qui gère cette version. Et comme toutes les méthodes d'une API ne changent pas dans la même version, je ne souhaite pas accéder à chacun de mes contrôleurs et modifier quoi que ce soit pour un gestionnaire qui n'a pas changé entre les versions. Je ne souhaite pas non plus avoir la logique pour déterminer quelle version utiliser dans le contrôleur eux-mêmes (à l'aide de localisateurs de services), car Spring découvre déjà la méthode à appeler.

Donc, prenons une API avec les versions 1.0 à 1.8 où un gestionnaire a été introduit dans la version 1.0 et modifié dans la version 1.7, je voudrais gérer ceci de la manière suivante. Imaginez que le code se trouve à l'intérieur d'un contrôleur et qu'il existe un code capable d'extraire la version de l'en-tête. (Ce qui suit n'est pas valide au printemps)

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

Ceci n'est pas possible au printemps car les 2 méthodes ont la même annotation RequestMapping et Spring ne parvient pas à se charger. L'idée est que l'annotation VersionRange puisse définir une plage de versions ouverte ou fermée. La première méthode est valable des versions 1.0 à 1.6, la seconde à partir de la version 1.7 (y compris la dernière version 1.8). Je sais que cette approche se casse si quelqu'un décide de passer à la version 99.99, mais c'est une chose avec laquelle je peux vivre.

Maintenant, puisque ce qui précède n’est pas possible sans une refonte sérieuse du fonctionnement de Spring, je pensais bricoler avec la façon dont les gestionnaires s’appliquaient aux demandes, en particulier pour écrire mon propre ProducesRequestCondition et y avoir la gamme de versions. . Par exemple

Code:

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

De cette manière, des plages de versions fermées ou ouvertes peuvent être définies dans la partie de l'annotation produite. Je travaille sur cette solution maintenant, avec le problème que je devais encore remplacer certaines classes de base Spring MVC (RequestMappingInfoHandlerMapping, RequestMappingHandlerMapping et RequestMappingInfo), que je ne ' t like, parce que cela signifie un travail supplémentaire chaque fois que je décide de passer à une version plus récente de Spring.

J'apprécierais toutes les pensées ... et surtout, toute suggestion de le faire d'une manière plus simple, plus facile à maintenir.


Modifier

Ajout d'une prime. Pour obtenir la prime, veuillez répondre à la question ci-dessus sans suggérer d’avoir cette logique dans le contrôleur lui-même. Spring a déjà beaucoup de logique pour sélectionner la méthode de contrôleur à appeler, et je veux me baser dessus.


Modifier 2

J'ai partagé le POC original (avec quelques améliorations) dans github: https://github.com/augusto/restVersioning

103
Augusto

Que la gestion des versions soit évitée en apportant des modifications rétrocompatibles (ce qui pourrait ne pas toujours être possible lorsque vous êtes lié par certaines directives de l'entreprise ou que vos clients API sont implémentés de manière anormale et se cassent même s'ils ne le devraient pas), l'exigence abstraite est intéressante. un:

Comment puis-je effectuer un mappage de demande personnalisé qui effectue des évaluations arbitraires des valeurs d'en-tête à partir de la demande sans effectuer l'évaluation dans le corps de la méthode?

Comme décrit dans this SO answer vous pouvez réellement avoir le même @RequestMapping et utilisez une annotation différente pour différencier les événements lors du routage réel lors de l'exécution. Pour ce faire, vous devrez:

  1. Créez une nouvelle annotation VersionRange.
  2. Implémenter un RequestCondition<VersionRange>. Comme vous aurez quelque chose comme un algorithme de meilleure correspondance, vous devrez vérifier si les méthodes annotées avec d'autres valeurs VersionRange fournissent une meilleure correspondance pour la requête en cours.
  3. Implémentez un VersionRangeRequestMappingHandlerMapping basé sur l'annotation et la condition de demande (comme décrit dans l'article Comment implémenter les propriétés personnalisées @RequestMapping ).
  4. Configurez spring pour évaluer votre VersionRangeRequestMappingHandlerMapping avant d’utiliser le RequestMappingHandlerMapping par défaut (par exemple, en définissant sa commande sur 0).

Cela ne nécessite aucun remplacement des composants Spring, mais utilise les mécanismes de configuration et d'extension de Spring. Il devrait donc fonctionner même si vous mettez à jour votre version de Spring (tant que la nouvelle version prend en charge ces mécanismes).

58
xwoker

Je viens de créer une solution personnalisée. J'utilise le @ApiVersion annotation en combinaison avec @RequestMapping annotation à l'intérieur de @Controller Des classes.

Exemple:

@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

}

La mise en oeuvre:

ApiVersion.Java annotation:

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

ApiVersionRequestMappingHandlerMapping.Java (il s'agit principalement de copier/coller de 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);
    }

}

Injection dans WebMvcConfigurationSupport:

public class WebMvcConfig extends WebMvcConfigurationSupport {
    @Override
    public RequestMappingHandlerMapping requestMappingHandlerMapping() {
        return new ApiVersionRequestMappingHandlerMapping("v");
    }
}
46
Benjamin M

Je recommanderais toujours d'utiliser des URL pour la gestion des versions car, dans les URL, @RequestMapping prend en charge les modèles et les paramètres de chemin, dont le format pourrait être spécifié avec regexp.

Et pour gérer les mises à niveau des clients (que vous avez mentionnées dans un commentaire), vous pouvez utiliser des alias tels que "dernière". Ou avoir une version non versionnée de api qui utilise la dernière version (yeah).

En utilisant également les paramètres de chemin, vous pouvez implémenter toute logique de gestion de version complexe. Si vous souhaitez déjà disposer de plages, vous souhaiterez peut-être quelque chose de plus tôt.

Voici quelques exemples:

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

En fonction de la dernière approche, vous pouvez réellement implémenter quelque chose comme ce que vous voulez.

Par exemple, vous pouvez avoir un contrôleur qui ne contient que des méthodes avec le traitement des versions.

Dans ce traitement, vous regardez (en utilisant les bibliothèques de réflexion/AOP/génération de code) dans un service/composant de ressort ou dans la même classe pour une méthode portant le même nom/la même signature et obligatoire @VersionRange et appelez-le en passant tous les paramètres.

16
elusive-code

J'ai implémenté une solution qui gère [~ # ~] parfaitement [~ # ~] le problème posé par le contrôle des versions.

En termes généraux, il existe 3 approches principales pour la gestion des versions de repos:

  • Approche basée sur le chemin , dans laquelle le client définit la version dans l'URL:

    http://localhost:9001/api/v1/user
    http://localhost:9001/api/v2/user
    
  • Content-Type , dans lequel le client définit la version dans Accepter entête:

    http://localhost:9001/api/v1/user with 
    Accept: application/vnd.app-1.0+json OR application/vnd.app-2.0+json
    
  • En-tête personnalisé , dans lequel le client définit la version dans un en-tête personnalisé.

Le problème avec l'approche en premier est que si vous modifiez la version, dire de v1 -> v2, vous devrez probablement copier-coller les ressources v1 qui n'ont pas changé en chemin v2

Le problème avec l'approche en second lieu est que certains outils comme http://swagger.io/ ne peut pas distinguer des opérations ayant le même chemin mais un type de contenu différent (problème de vérification https://github.com/OAI/OpenAPI-Specification/issues/146 =)

La solution

Comme je travaille beaucoup avec des outils de documentation pour le repos, je préfère utiliser la première approche. Ma solution gère le problème lors de la première approche. Vous n'avez donc pas besoin de copier-coller le noeud final dans la nouvelle version.

Disons que nous avons des versions v1 et v2 pour le contrôleur d'utilisateur:

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

L'exigence est si je demande la v1 de la ressource utilisateur que j'ai prendre le "utilisateur V1" repsonse, sinon si je demande le v2 , v3 et ainsi de suite, je dois prendre le "Utilisateur V2" réponse.

enter image description here

Pour implémenter cela au printemps, nous devons écraser le comportement par défaut 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 ;
    }

}

L'implémentation lit la version dans l'URL et demande à spring de résoudre l'URL. Dans le cas où cette URL n'existe pas (par exemple, le client a demandé v3 ) puis nous essayons avec v2 et ainsi jusqu'à trouver la version la plus récente pour la ressource.

Pour voir les avantages de cette implémentation, supposons que nous ayons deux ressources: Utilisateur et Société:

http://localhost:9001/api/v{version}/user
http://localhost:9001/api/v{version}/company

Supposons que nous ayons modifié le "contrat" ​​de l'entreprise, qui casse le client. Nous avons donc implémenté le http://localhost:9001/api/v2/company et nous demandons au client de passer à la version v2 à la place de la v1.

Les nouvelles demandes du client sont donc:

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

au lieu de:

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

La partie meilleure est qu’avec cette solution le client obtiendra les informations utilisateur de v1 et les informations d’entreprise de v2 . sans la nécessité de créer un nouveau (même) point de terminaison à partir de l'utilisateur v2!

Rest Documentation Comme je l'ai déjà dit, la raison pour laquelle j'ai choisi l'approche de gestion des versions basée sur une URL est que certains outils comme swagger ne documentent pas différemment les points finaux avec la même URL. mais type de contenu différent. Avec cette solution, les deux ordinateurs d'extrémité sont affichés car ils ont une URL différente:

enter image description here

[~ # ~] git [~ # ~]

Mise en œuvre de la solution à: https://github.com/mspapant/restVersioningExample/

11
mspapant

L'annotation @RequestMapping Prend en charge un élément headers qui vous permet de restreindre les requêtes correspondantes. En particulier, vous pouvez utiliser l'en-tête Accept ici.

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

Ce n'est pas exactement ce que vous décrivez, car il ne gère pas directement les plages, mais l'élément prend en charge le caractère générique * ainsi que! =. Vous pouvez donc au moins utiliser un caractère générique pour les cas où toutes les versions prennent en charge le point de terminaison en question, voire toutes les versions mineures d'une version majeure donnée (par exemple, 1. *).

Je ne pense pas avoir déjà utilisé cet élément auparavant (si je ne me souviens pas),

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

8
Willie Wheeler

Qu'en est-il de l'utilisation de l'héritage pour modéliser le versioning? C’est ce que j’utilise dans mon projet et cela ne nécessite aucune configuration de ressort particulière et me donne exactement ce que je veux.

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

Cette configuration permet peu de duplication de code et la possibilité d'écraser des méthodes dans de nouvelles versions de l'API avec peu de travail. Cela évite également de compliquer votre code source avec la logique de changement de version. Si vous ne codez pas de point de terminaison dans une version, la version précédente sera récupérée par défaut.

Comparé à ce que d'autres font, cela semble beaucoup plus facile. Y a-t-il quelque chose qui me manque?

3
Ceekay

J'ai déjà essayé de versionner mon API en utilisant le Versioning URI, comme:

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

Cependant, il est difficile de réussir ce travail: comment organiser votre code avec différentes versions? Comment gérer deux versions (ou plus) en même temps? Quel est l'impact lors de la suppression d'une version?

La meilleure alternative que j'ai trouvée n'était pas la version de l'ensemble de l'API, mais contrôlait la version sur chaque noeud final . Ce modèle s'appelle Gestion des versions à l'aide de l'en-tête Accept ou Gestion des versions par négociation de conten :

Cette approche nous permet de versionner une représentation de ressource unique au lieu de l’API entière, ce qui nous donne un contrôle plus granulaire sur la gestion des versions. Cela crée également une empreinte plus petite dans la base de code, car nous n’avons pas à couper toute l’application lors de la création d’une nouvelle version. Un autre avantage de cette approche est qu’elle ne nécessite pas la mise en œuvre de règles de routage d’URI introduites par la gestion des versions via le chemin d’URI.

Mise en œuvre au printemps

Tout d’abord, vous créez un contrôleur avec un attribut basic produit, qui s’appliquera par défaut à chaque noeud final de la classe.

@RestController
@RequestMapping(value = "/api/orders/", produces = "application/vnd.company.etc.v1+json")
public class OrderController {

}

Après cela, créez un scénario possible dans lequel vous avez deux versions d'un noeud final pour créer une commande:

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

Terminé! Il suffit d’appeler chaque terminal à l’aide de la version souhaitée Http Header:

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

Ou, pour appeler la version deux:

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

De vos soucis:

Et comme toutes les méthodes d'une API ne changent pas dans la même version, je ne souhaite pas accéder à chacun de mes contrôleurs et modifier quoi que ce soit pour un gestionnaire qui n'a pas changé entre les versions.

Comme expliqué précédemment, cette stratégie conserve chaque contrôleur et point final avec sa version actuelle. Vous ne modifiez que le point de terminaison modifié et nécessitant une nouvelle version.

Et le Swagger?

L'installation du Swagger avec différentes versions est également très facile avec cette stratégie. Voir cette réponse pour plus de détails.

1
Dherik

En produit, vous pouvez avoir la négation. Donc pour method1, dites produces="!...1.7" et dans method2 ont le positif.

Le produit est aussi un tableau pour que vous puissiez dire à method1 produces={"...1.6","!...1.7","...1.8"} etc (accepte tout sauf 1.7)

Bien sûr, pas aussi idéal que les gammes que vous avez à l'esprit, mais je pense qu'il est plus facile à gérer que d'autres éléments personnalisés si cela est inhabituel dans votre système. Bonne chance!

1
codesalsa

Vous pouvez utiliser AOP, autour de l'interception

Pensez à avoir un mappage de requête qui reçoit tous les /**/public_api/* et dans cette méthode ne font rien;

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

Après

@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


}

La seule contrainte est que tout doit être dans le même contrôleur.

Pour la configuration AOP, regardez http://www.mkyong.com/spring/spring-aop-examples-advice/

0
hevi