138 votes

Comment gérer la versioning de REST API avec spring?

J'ai cherché comment gérer les versions d'une API REST en utilisant Spring 3.2.x, mais je n'ai rien trouvé de facile à maintenir. Je vais d'abord expliquer le problème que j'ai, puis une solution... mais je me demande si je ne suis pas en train de réinventer la roue.

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

Donc, en prenant une API avec des versions 1.0, à 1.8 où un gestionnaire a été introduit en version 1.0 et modifié en v1.7, je voudrais gérer cela de la manière suivante. Imaginez que le code soit à l'intérieur d'un contrôleur, et qu'il y ait du code capable d'extraire la version de l'en-tête. (Ce qui suit est invalide dans Spring)

@RequestMapping(...)
@VersionRange(1.0,1.6)
@ResponseBody
public Object method1() {
   // so something
   return object;
}

@RequestMapping(...) //même annotation de mappage de requête
@VersionRange(1.7)
@ResponseBody
public Object method2() {
   // so something
   return object;
}

Ce n'est pas possible dans Spring car les 2 méthodes ont la même annotation RequestMapping et Spring échoue au chargement. L'idée est que l'annotation VersionRange peut définir une plage de versions ouverte ou fermée. La première méthode est valide des versions 1.0 à 1.6, tandis que la seconde est pour la version 1.7 et suivantes (y compris la dernière version 1.8). Je sais que cette approche échoue si quelqu'un décide de passer la version 99.99, mais c'est quelque chose avec lequel je suis prêt à vivre.

Maintenant, comme ce qui précède n'est pas possible sans une refonte sérieuse de la façon dont Spring fonctionne, je pensais bricoler avec la manière dont les gestionnaires sont associés aux requêtes, en particulier pour écrire ma propre ProducesRequestCondition, et avoir la plage de version là-dedans. 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 façon, je peux avoir des plages de versions ouvertes ou fermées définies dans la partie produces de l'annotation. Je travaille sur cette solution maintenant, avec le problème que j'ai quand même dû remplacer certaines classes principales de Spring MVC (RequestMappingInfoHandlerMapping, RequestMappingHandlerMapping et RequestMappingInfo), ce que je n'aime pas, car cela signifie un travail supplémentaire chaque fois que je décide de passer à une version plus récente de Spring.

Je serais reconnaissant pour toute réflexion... et surtout, toute suggestion pour le faire de manière plus simple, plus facile à maintenir.


Édition

Ajout d'une prime. Pour obtenir la prime, veuillez répondre à la question ci-dessus sans suggérer d'avoir cette logique dans les contrôleurs eux-mêmes. Spring a déjà beaucoup de logique pour sélectionner quelle méthode de contrôleur appeler, et je veux m'appuyer là-dessus.


Édition 2

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

0 votes

1 votes

@flup Je ne comprends pas votre commentaire. Cela dit simplement que vous pouvez utiliser des en-têtes et, comme je l'ai dit, ce que Spring offre par défaut n'est pas suffisant pour prendre en charge des API constamment mises à jour. Pire encore, le lien de cette réponse utilise la version dans l'URL.

0 votes

Peut-être pas exactement ce que vous recherchez, mais Spring 3.2 prend en charge un paramètre "produces" sur RequestMapping. La seule exception est que la liste des versions doit être explicite. Par exemple, produces={"application/json-1.0", "application/json-1.1"}, etc.

78voto

xwoker Points 1523

Indépendamment que le versionnement puisse être évité en apportant des modifications rétrocompatibles (ce qui n'est pas toujours possible lorsque vous êtes contraint par des directives d'entreprise ou que vos clients API sont implémentés de manière défectueuse et risqueraient de se casser même s'ils ne devraient pas), l'exigence abstraite est intéressante :

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

Comme décrit dans la réponse SO, vous pouvez effectivement avoir le même @RequestMapping et utiliser une annotation différente pour différencier lors du routage réel qui se produit pendant l'exécution. Pour ce faire, vous devrez :

  1. Créer une nouvelle annotation VersionRange.
  2. Implémenter une RequestCondition. 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 actuelle.
  3. Implémenter un VersionRangeRequestMappingHandlerMapping basé sur l'annotation et la condition de requête (comme décrit dans la publication Comment implémenter des propriétés personnalisées @RequestMapping).
  4. Configurer spring pour évaluer votre VersionRangeRequestMappingHandlerMapping avant d'utiliser le RequestMappingHandlerMapping par défaut (par exemple, en définissant son ordre sur 0).

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

0 votes

Merci d'avoir ajouté votre commentaire en tant que réponse xwoker. Jusqu'à présent, c'est le meilleur. J'ai implémenté la solution basée sur les liens que vous avez mentionnés et ce n'est pas si mal. Le plus gros problème se manifestera lors de la mise à niveau vers une nouvelle version de Spring car cela nécessitera de vérifier les éventuels changements dans la logique derrière mvc:annotation-driven. Espérons que Spring fournira une version de mvc:annotation-driven dans laquelle on pourra définir des conditions personnalisées.

0 votes

@Augusto, six mois plus tard, comment cela se passe-t-il pour toi? Aussi, je suis curieux, est-ce que tu versions vraiment sur une base par méthode? À ce stade, je me demande s'il ne serait pas plus clair de versionner sur un niveau de granularité par classe/par contrôleur?

1 votes

@SanderVerhagen cela fonctionne, mais nous versionnons l'ensemble de l'API, pas par méthode ou contrôleur (l'API est assez petite car elle est axée sur un aspect du métier). Nous avons un projet considérablement plus grand où ils ont choisi d'utiliser une version différente par ressource et de la spécifier dans l'URL (ainsi, vous pouvez avoir un point de terminaison sur /v1/sessions et une autre ressource sur une version complètement différente, par exemple /v4/orders)... c'est un peu plus flexible, mais cela met plus de pression sur les clients pour savoir quelle version appeler de chaque point de terminaison.

64voto

Benjamin M Points 2206

J'ai créé une solution personnalisée. J'utilise l'annotation @ApiVersion en combinaison avec l'annotation @RequestMapping à l'intérieur des classes @Controller.

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

}

Implémentation :

Annotation ApiVersion.java :

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

ApiVersionRequestMappingHandlerMapping.java (il s'agit principalement d'une copie et d'un collage 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

`Injection dans WebMvcConfigurationSupport :

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

4 votes

J'ai changé le int [] en String [] pour permettre des versions telles que "1.2", et ainsi je peux gérer des mots-clés comme "dernier"

3 votes

Oui, c'est assez raisonnable. Pour les projets futurs, je choisirais une approche différente pour quelques raisons: 1. Les URL représentent des ressources. /v1/aResource et /v2/aResource semblent être des ressources différentes, mais il s'agit en réalité de différentes représentations de la même ressource! 2. L'utilisation des en-têtes HTTP semble meilleure, mais vous ne pouvez pas donner quelqu'un une URL, car l'URL ne contient pas l'en-tête. 3. En utilisant un paramètre d'URL, par exemple /aResource?v=2.1 (au fait: c'est ainsi que Google fait du versionnage). ... Je ne suis toujours pas sûr si je choisirais l'option 2 ou 3, mais je n'utiliserai jamais 1 à nouveau pour les raisons mentionnées ci-dessus.

7 votes

Si vous voulez injecter votre propre RequestMappingHandlerMapping dans votre WebMvcConfiguration, vous devriez écraser createRequestMappingHandlerMapping au lieu de requestMappingHandlerMapping! Sinon, vous rencontrerez des problèmes bizarres (j'ai soudainement eu des problèmes avec l'initialisation paresseuse de Hibernate en raison d'une session fermée)

19voto

elusive-code Points 454

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

Et pour gérer les mises à jour client (comme vous l'avez mentionné en commentaire), vous pouvez utiliser des alias comme 'latest'. Ou avoir une version non versionnée de l'API qui utilise la dernière version (ouais).

En utilisant également des paramètres de chemin, vous pouvez implémenter toute logique de gestion de version complexe, et si vous souhaitez déjà avoir des plages, vous voudrez probablement quelque chose de plus bientô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(){
}

// gère toutes les requêtes 1.*
@RequestMapping({
    "/**/public_api/{version:1\\.\\d+}/method",
})
public void methodManual1(@PathVariable("version") String version){
}

// gère la plage 1.0-1.6, mais un peu moche
@RequestMapping({
    "/**/public_api/{version:1\\.[0123456]?}/method",
})
public void methodManual1(@PathVariable("version") String version){
}

// gestion entièrement manuelle des versions
@RequestMapping({
    "/**/public_api/{version}/method",
})
public void methodManual2(@PathVariable("version") String version){
    int[] versionParts = getVersionParts(version);
    // gestion manuelle des versions
}

public int[] getVersionParts(String version){
    try{
        String[] versionParts = version.split("\\.");
        int[] result = new int[versionParts.length];
        for(int i=0;i

`

Sur la base de la dernière approche, vous pouvez en fait implémenter quelque chose comme ce que vous voulez.

Par exemple, vous pouvez avoir un contrôleur qui contient uniquement des méthodes avec une manipulation de version.

Dans cette manipulation, vous regardez (en utilisant la réflexion/AOP/librairies de génération de code) dans un service/composant Spring ou dans la même classe pour une méthode avec le même nom/signature et la @VersionRange requise et l'invoquer en passant tous les paramètres.

`

9voto

Willie Wheeler Points 8632

L'annotation @RequestMapping prend en charge un élément headers qui vous permet de restreindre les demandes 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 cela ne gère pas directement les plages, mais l'élément supporte également le joker * ainsi que !=. Vous pourriez donc au moins utiliser un joker 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 (si je l'ai fait, je ne m'en souviens pas), donc je vais simplement me baser sur la documentation disponible sur

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

2 votes

Je suis au courant de cela, mais comme vous l'avez noté, sur chaque version, je devrais aller à tous mes contrôleurs et ajouter une version, même s'ils n'ont pas changé. La plage que vous avez mentionnée ne fonctionne que sur le type complet, par exemple application/* et pas sur des parties du type. Par exemple, ce qui suit est invalide dans Spring "Accept=application/vnd.company.app-1.*+json". Cela est lié à la façon dont la classe Spring MediaType fonctionne.

0 votes

@Augusto vous n'avez pas nécessairement besoin de faire cela. Avec cette approche, vous ne versionnez pas l'"API" mais l'"Endpoint". Chaque endpoint peut avoir une version différente. Pour moi, c'est la façon la plus simple de versionner l'API comparé avec la version de l'API. Swagger est également plus simple à mettre en place. Cette stratégie est appelée Versionnement par négociation de contenu.

3voto

codesalsa Points 242

Dans les productions, vous pouvez avoir une négation. Donc pour la méthode 1, dites produces="!...1.7" et dans la méthode 2, avoir le positif.

Les productions sont également un tableau, donc pour la méthode 1, vous pouvez dire produces={"...1.6","!...1.7","...1.8"} etc (acceptez tout sauf 1.7)

Évidemment, ce n'est pas aussi idéal que les plages que vous avez en tête, mais je pense que c'est plus facile à maintenir que d'autres personnalisations si c'est quelque chose d'inhabituel dans votre système. Bonne chance!

0 votes

Merci codesalsa, j'essaie de trouver un moyen facile à maintenir et ne nécessitant pas de mettre à jour chaque point de terminaison à chaque fois que nous devons augmenter la version.

Prograide.com

Prograide est une communauté de développeurs qui cherche à élargir la connaissance de la programmation au-delà de l'anglais.
Pour cela nous avons les plus grands doutes résolus en français et vous pouvez aussi poser vos propres questions ou résoudre celles des autres.

Powered by:

X