Modèles de gestion des opérations par lots dans les services Web REST?

170

Quels modèles de conception éprouvés existent pour les opérations par lots sur les ressources au sein d'un service Web de style REST?

J'essaie de trouver un équilibre entre les idéaux et la réalité en termes de performance et de stabilité. Nous avons actuellement une API où toutes les opérations sont récupérées à partir d'une ressource de liste (par exemple: GET / user) ou sur une seule instance (PUT / user / 1, DELETE / user / 22, etc.).

Dans certains cas, vous souhaitez mettre à jour un seul champ d'un ensemble complet d'objets. Il semble très inutile d'envoyer la représentation complète de chaque objet dans les deux sens pour mettre à jour le champ.

Dans une API de style RPC, vous pourriez avoir une méthode:

/mail.do?method=markAsRead&messageIds=1,2,3,4... etc. 

Quel est l'équivalent REST ici? Ou est-il acceptable de faire des compromis de temps en temps. Cela ruine-t-il la conception d'ajouter quelques opérations spécifiques où cela améliore vraiment les performances, etc.? Le client dans tous les cas est actuellement un navigateur Web (application javascript côté client).

Mark Renouf
la source

Réponses:

77

Un modèle RESTful simple pour les lots consiste à utiliser une ressource de collection. Par exemple, pour supprimer plusieurs messages à la fois.

DELETE /mail?&id=0&id=1&id=2

Il est un peu plus compliqué de mettre à jour par lots des ressources partielles ou des attributs de ressources. Autrement dit, mettez à jour chaque attribut marquéAsRead. Fondamentalement, au lieu de traiter l'attribut comme faisant partie de chaque ressource, vous le traitez comme un compartiment dans lequel placer des ressources. Un exemple a déjà été publié. Je l'ai ajusté un peu.

POST /mail?markAsRead=true
POSTDATA: ids=[0,1,2]

En gros, vous mettez à jour la liste des e-mails marqués comme lus.

Vous pouvez également l'utiliser pour affecter plusieurs éléments à la même catégorie.

POST /mail?category=junk
POSTDATA: ids=[0,1,2]

Il est évidemment beaucoup plus compliqué de faire des mises à jour partielles par lots de style iTunes (par exemple, artiste + albumTitle mais pas trackTitle). L'analogie du seau commence à s'effondrer.

POST /mail?markAsRead=true&category=junk
POSTDATA: ids=[0,1,2]

À long terme, il est beaucoup plus facile de mettre à jour une seule ressource partielle ou des attributs de ressource. Utilisez simplement une sous-ressource.

POST /mail/0/markAsRead
POSTDATA: true

Vous pouvez également utiliser des ressources paramétrées. Ceci est moins courant dans les modèles REST, mais est autorisé dans les spécifications URI et HTTP. Un point-virgule divise les paramètres liés horizontalement au sein d'une ressource.

Mettez à jour plusieurs attributs, plusieurs ressources:

POST /mail/0;1;2/markAsRead;category
POSTDATA: markAsRead=true,category=junk

Mettez à jour plusieurs ressources, un seul attribut:

POST /mail/0;1;2/markAsRead
POSTDATA: true

Mettez à jour plusieurs attributs, une seule ressource:

POST /mail/0/markAsRead;category
POSTDATA: markAsRead=true,category=junk

La créativité RESTful abonde.

Alex
la source
1
On pourrait dire que votre suppression devrait en fait être une publication car elle ne détruit pas réellement cette ressource.
Chris Nicola
6
Ce n'est pas nécessaire. POST est une méthode de modèle d'usine, elle est moins explicite et évidente que PUT / DELETE / GET. La seule attente est que le serveur décidera quoi faire à la suite du POST. POST est exactement ce qu'il a toujours été, je soumets des données de formulaire et le serveur fait quelque chose (j'espère que cela est attendu) et me donne une indication sur le résultat. Nous ne sommes pas obligés de créer des ressources avec POST, nous le choisissons souvent. Je peux facilement créer une ressource avec PUT, il me suffit de définir l'URL de la ressource en tant qu'expéditeur (pas souvent idéal).
Chris Nicola
1
@nishant, dans ce cas, vous n'avez probablement pas besoin de référencer plusieurs ressources dans l'URI, mais simplement de passer des tuples avec les références / valeurs dans le corps de la requête. par exemple, POST / mail / markAsRead, BODY: i_0_id = 0 & i_0_value = true & i_1_id = 1 & i_1_value = false & i_2_id = 2 & i_2_value = true
Alex
3
le point-virgule est réservé à cet effet.
Alex
1
Surpris que personne n'ait souligné que la mise à jour de plusieurs attributs sur une seule ressource est bien couverte par PATCH- pas besoin de créativité dans ce cas.
LB2
25

Pas du tout - je pense que l'équivalent REST est (ou au moins une solution est) presque exactement cela - une interface spécialisée conçue pour s'adapter à une opération requise par le client.

Je me souviens d'un modèle mentionné dans le livre de Crane and Pascarello Ajax in Action (un excellent livre, au fait - hautement recommandé) dans lequel ils illustrent la mise en œuvre d'une sorte d'objet CommandQueue dont le travail consiste à mettre les requêtes en file d'attente en lots et puis publiez-les périodiquement sur le serveur.

L'objet, si je me souviens bien, contenait essentiellement un tableau de "commandes" - par exemple, pour étendre votre exemple, chacun un enregistrement contenant une commande "markAsRead", un "messageId" et peut-être une référence à un callback / handler fonction - et ensuite selon un calendrier, ou sur une action de l'utilisateur, l'objet de commande serait sérialisé et posté sur le serveur, et le client gérerait le post-traitement conséquent.

Je n'ai pas les détails à portée de main, mais il semble qu'une file d'attente de commandes de ce type serait un moyen de gérer votre problème; cela réduirait considérablement le bavardage global et résumerait l'interface côté serveur d'une manière que vous pourriez trouver plus flexible sur la route.


Mise à jour : Aha! J'ai trouvé un extrait de ce livre en ligne, avec des exemples de code (bien que je suggère toujours de récupérer le livre!). Jetez un œil ici , en commençant par la section 5.5.3:

Ceci est facile à coder mais peut entraîner beaucoup de très petits bits de trafic vers le serveur, ce qui est inefficace et potentiellement déroutant. Si nous voulons contrôler notre trafic, nous pouvons capturer ces mises à jour et les mettre en file d'attente localement , puis les envoyer au serveur par lots à notre guise. Une simple file d'attente de mise à jour implémentée en JavaScript est présentée dans la liste 5.13. [...]

La file d'attente gère deux tableaux. queued est un tableau indexé numériquement, auquel de nouvelles mises à jour sont ajoutées. sent est un tableau associatif, contenant les mises à jour qui ont été envoyées au serveur mais qui attendent une réponse.

Voici deux fonctions pertinentes - une responsable de l'ajout de commandes à la file d'attente ( addCommand), et une responsable de la sérialisation puis de leur envoi au serveur ( fireRequest):

CommandQueue.prototype.addCommand = function(command)
{ 
    if (this.isCommand(command))
    {
        this.queue.append(command,true);
    }
}

CommandQueue.prototype.fireRequest = function()
{
    if (this.queued.length == 0)
    { 
        return; 
    }

    var data="data=";

    for (var i = 0; i < this.queued.length; i++)
    { 
        var cmd = this.queued[i]; 
        if (this.isCommand(cmd))
        {
            data += cmd.toRequestString(); 
            this.sent[cmd.id] = cmd;

            // ... and then send the contents of data in a POST request
        }
    }
}

Cela devrait vous faire avancer. Bonne chance!

Christian Nunciato
la source
Merci. C'est très similaire à mes idées sur la façon dont j'irais de l'avant si nous conservions les opérations par lots sur le client. Le problème est le temps d'aller-retour pour effectuer une opération sur un grand nombre d'objets.
Mark Renouf le
Hm, ok - Je pensais que vous vouliez effectuer l'opération sur un grand nombre d'objets (sur le serveur) au moyen d'une requête légère. Ai-je mal compris?
Christian Nunciato le
Oui, mais je ne vois pas comment cet exemple de code effectuerait l'opération plus efficacement. Il regroupe les demandes mais les envoie toujours au serveur une par une. Suis-je mal interprété?
Mark Renouf
En fait, il les regroupe puis les envoie tous à la fois: la boucle for dans fireRequest () rassemble essentiellement toutes les commandes en suspens, les sérialise sous forme de chaîne (avec .toRequestString (), par exemple, "method = markAsRead & messageIds = 1,2,3 , 4 "), assigne cette chaîne à" data "et POST les données au serveur.
Christian Nunciato le
20

Bien que je pense que @Alex est sur la bonne voie, conceptuellement, je pense que cela devrait être l'inverse de ce qui est suggéré.

L'URL est en effet "les ressources que nous ciblons" d'où:

    [GET] mail/1

signifie obtenir l'enregistrement du courrier avec l'ID 1 et

    [PATCH] mail/1 data: mail[markAsRead]=true

signifie patcher l'enregistrement de courrier avec l'ID 1. La chaîne de requête est un "filtre", filtrant les données renvoyées par l'URL.

    [GET] mail?markAsRead=true

Nous demandons donc ici tous les e-mails déjà marqués comme lus. Donc, pour [PATCH] sur ce chemin, il faudrait dire "patcher les enregistrements déjà marqués comme vrais" ... ce qui n'est pas ce que nous essayons de réaliser.

Donc, une méthode par lots, suivant cette réflexion devrait être:

    [PATCH] mail/?id=1,2,3 <the records we are targeting> data: mail[markAsRead]=true

bien sûr, je ne dis pas que c'est vrai REST (qui ne permet pas la manipulation d'enregistrements par lots), mais il suit plutôt la logique déjà existante et utilisée par REST.

fezfox
la source
Réponse intéressante! Pour votre dernier exemple, ne serait-il pas plus cohérent avec le [GET]format à faire [PATCH] mail?markAsRead=true data: [{"id": 1}, {"id": 2}, {"id": 3}](ou même juste data: {"ids": [1,2,3]})? Un autre avantage de cette approche alternative est que vous ne rencontrerez pas d'erreurs «414 Request URI too long» si vous mettez à jour des centaines / milliers de ressources dans la collection.
rinogo
@rinogo - en fait non. C'est ce que je voulais dire. La chaîne de requête est un filtre pour les enregistrements sur lesquels nous voulons agir (par exemple, [GET] mail / 1 obtient l'enregistrement de courrier avec un identifiant de 1, alors que [GET] mail? MarkasRead = true renvoie le courrier où markAsRead est déjà vrai). Cela n'a aucun sens de patcher sur cette même URL (c'est-à-dire "patcher les enregistrements où markAsRead = true") alors qu'en fait nous voulons patcher des enregistrements particuliers avec les identifiants 1, 2, 3, quel que soit l'état actuel du champ markAsRead. D'où la méthode que j'ai décrite. Je reconnais que la mise à jour de nombreux enregistrements pose un problème Je construirais un point de terminaison moins étroitement couplé.
fezfox
11

Votre langage, "Cela me semble très inutile ...", indique pour moi une tentative d'optimisation prématurée. À moins qu'il ne puisse être démontré que l'envoi de la représentation entière des objets est un impact majeur sur les performances (nous parlons d'inacceptable pour les utilisateurs comme> 150 ms), il est inutile d'essayer de créer un nouveau comportement d'API non standard. N'oubliez pas que plus l'API est simple, plus elle est facile à utiliser.

Pour les suppressions, envoyez ce qui suit car le serveur n'a pas besoin de savoir quoi que ce soit sur l'état de l'objet avant la suppression.

DELETE /emails
POSTDATA: [{id:1},{id:2}]

L'idée suivante est que si une application rencontre des problèmes de performances concernant la mise à jour en masse des objets, il faut envisager de diviser chaque objet en plusieurs objets. De cette façon, la charge utile JSON est une fraction de la taille.

À titre d'exemple, lors de l'envoi d'une réponse pour mettre à jour les statuts «lu» et «archivé» de deux e-mails distincts, vous devrez envoyer ce qui suit:

PUT /emails
POSTDATA: [
            {
              id:1,
              to:"[email protected]",
              from:"[email protected]",
              subject:"Try this recipe!",
              text:"1LB Pork Sausage, 1 Onion, 1T Black Pepper, 1t Salt, 1t Mustard Powder",
              read:true,
              archived:true,
              importance:2,
              labels:["Someone","Mustard"]
            },
            {
              id:2,
              to:"[email protected]",
              from:"[email protected]",
              subject:"Try this recipe (With Fix)",
              text:"1LB Pork Sausage, 1 Onion, 1T Black Pepper, 1t Salt, 1T Mustard Powder, 1t Garlic Powder",
              read:true,
              archived:false,
              importance:1,
              labels:["Someone","Mustard"]
            }
            ]

Je diviserais les composants modifiables de l'e-mail (lu, archivé, importance, étiquettes) en un objet séparé car les autres (vers, depuis, sujet, texte) ne seraient jamais mis à jour.

PUT /email-statuses
POSTDATA: [
            {id:15,read:true,archived:true,importance:2,labels:["Someone","Mustard"]},
            {id:27,read:true,archived:false,importance:1,labels:["Someone","Mustard"]}
          ]

Une autre approche à adopter consiste à tirer parti de l'utilisation d'un PATCH. Pour indiquer explicitement les propriétés que vous envisagez de mettre à jour et que toutes les autres doivent être ignorées.

PATCH /emails
POSTDATA: [
            {
              id:1,
              read:true,
              archived:true
            },
            {
              id:2,
              read:true,
              archived:false
            }
          ]

Les gens déclarent que PATCH doit être implémenté en fournissant un tableau de modifications contenant: action (CRUD), chemin (URL) et changement de valeur. Cela peut être considéré comme une implémentation standard, mais si vous regardez l'intégralité d'une API REST, il s'agit d'une mise en œuvre ponctuelle non intuitive. En outre, la mise en œuvre ci-dessus est de savoir comment GitHub a implémenté PATCH .

Pour résumer, il est possible d'adhérer aux principes RESTful avec des actions par lots tout en conservant des performances acceptables.

justin.hughey
la source
Je conviens que PATCH a le plus de sens, le problème est que si vous avez un autre code de transition d'état qui doit s'exécuter lorsque ces propriétés changent, il devient plus difficile à implémenter comme un simple PATCH. Je ne pense pas que REST s'adapte vraiment à toute sorte de transition d'état, étant donné qu'il est censé être apatride, il ne se soucie pas de savoir de quoi il est en train de faire la transition, seulement de quel est son état actuel.
BeniRose
Salut BeniRose, merci d'avoir ajouté un commentaire, je me demande souvent si les gens voient certains de ces messages. Cela me rend heureux de voir que les gens le font. Les ressources concernant la nature «sans état» de REST le définissent comme un problème avec le serveur n'ayant pas à maintenir l'état à travers les demandes. En tant que tel, je ne sais pas quelle question vous décriviez, pouvez-vous élaborer avec un exemple?
justin.hughey
8

L'API google drive dispose d'un système vraiment intéressant pour résoudre ce problème ( voir ici ).

En gros, ils regroupent différentes demandes en une seule Content-Type: multipart/mixeddemande, chaque demande complète individuelle étant séparée par un délimiteur défini. Les en-têtes et le paramètre de requête de la demande par lots sont hérités des demandes individuelles (c'est-à-dire Authorization: Bearer some_token) à moins qu'ils ne soient remplacés dans la demande individuelle.


Exemple : (tiré de leurs documents )

Demande:

POST https://www.googleapis.com/batch

Accept-Encoding: gzip
User-Agent: Google-HTTP-Java-Client/1.20.0 (gzip)
Content-Type: multipart/mixed; boundary=END_OF_PART
Content-Length: 963

--END_OF_PART
Content-Length: 337
Content-Type: application/http
content-id: 1
content-transfer-encoding: binary


POST https://www.googleapis.com/drive/v3/files/fileId/permissions?fields=id
Authorization: Bearer authorization_token
Content-Length: 70
Content-Type: application/json; charset=UTF-8


{
  "emailAddress":"[email protected]",
  "role":"writer",
  "type":"user"
}
--END_OF_PART
Content-Length: 353
Content-Type: application/http
content-id: 2
content-transfer-encoding: binary


POST https://www.googleapis.com/drive/v3/files/fileId/permissions?fields=id&sendNotificationEmail=false
Authorization: Bearer authorization_token
Content-Length: 58
Content-Type: application/json; charset=UTF-8


{
  "domain":"appsrocks.com",
   "role":"reader",
   "type":"domain"
}
--END_OF_PART--

Réponse:

HTTP/1.1 200 OK
Alt-Svc: quic=":443"; p="1"; ma=604800
Server: GSE
Alternate-Protocol: 443:quic,p=1
X-Frame-Options: SAMEORIGIN
Content-Encoding: gzip
X-XSS-Protection: 1; mode=block
Content-Type: multipart/mixed; boundary=batch_6VIxXCQbJoQ_AATxy_GgFUk
Transfer-Encoding: chunked
X-Content-Type-Options: nosniff
Date: Fri, 13 Nov 2015 19:28:59 GMT
Cache-Control: private, max-age=0
Vary: X-Origin
Vary: Origin
Expires: Fri, 13 Nov 2015 19:28:59 GMT

--batch_6VIxXCQbJoQ_AATxy_GgFUk
Content-Type: application/http
Content-ID: response-1


HTTP/1.1 200 OK
Content-Type: application/json; charset=UTF-8
Date: Fri, 13 Nov 2015 19:28:59 GMT
Expires: Fri, 13 Nov 2015 19:28:59 GMT
Cache-Control: private, max-age=0
Content-Length: 35


{
 "id": "12218244892818058021i"
}


--batch_6VIxXCQbJoQ_AATxy_GgFUk
Content-Type: application/http
Content-ID: response-2


HTTP/1.1 200 OK
Content-Type: application/json; charset=UTF-8
Date: Fri, 13 Nov 2015 19:28:59 GMT
Expires: Fri, 13 Nov 2015 19:28:59 GMT
Cache-Control: private, max-age=0
Content-Length: 35


{
 "id": "04109509152946699072k"
}


--batch_6VIxXCQbJoQ_AATxy_GgFUk--
Aides
la source
1

Je serais tenté dans une opération comme celle de votre exemple d'écrire un analyseur de plage.

Ce n'est pas très compliqué de créer un analyseur capable de lire "messageIds = 1-3,7-9,11,12-15". Cela augmenterait certainement l'efficacité des opérations générales couvrant tous les messages et est plus évolutif.


la source
Une bonne observation et une bonne optimisation, mais la question était de savoir si ce style de requête pouvait un jour être "compatible" avec le concept REST.
Mark Renouf
Salut, ouais je comprends. L'optimisation rend le concept plus reposant et je ne voulais pas laisser de côté mes conseils simplement parce qu'il s'éloignait un peu du sujet.
1

Très bonne publication. Je cherche une solution depuis quelques jours. J'ai proposé une solution consistant à passer une chaîne de requête avec un ensemble d'identifiants séparés par des virgules, comme:

DELETE /my/uri/to/delete?id=1,2,3,4,5

... puis en passant cela à une WHERE INclause dans mon SQL. Cela fonctionne très bien, mais demandez-vous ce que les autres pensent de cette approche.

Roberto
la source
1
Je ne l'aime pas vraiment car cela introduit en quelque sorte un nouveau type, la chaîne que vous utilisez comme liste dans où. Je préfère l'analyser en un type spécifique à un langage à la place, puis je peux utiliser la même méthode dans le de la même manière dans plusieurs parties différentes du système.
softarn
4
Un rappel pour être prudent des attaques par injection SQL et toujours nettoyer vos données et utiliser des paramètres de liaison lorsque vous adoptez cette approche.
justin.hughey
2
Cela dépend du comportement souhaité du DELETE /books/delete?id=1,2,3moment où le livre n ° 3 n'existe pas - WHERE INil ignorera silencieusement les enregistrements, alors que je m'attendrais généralement DELETE /books/delete?id=3à 404 si 3 n'existe pas.
chbrown
3
Un problème différent que vous pouvez rencontrer en utilisant cette solution est la limite de caractères autorisés dans une chaîne d'URL. Si quelqu'un décide de supprimer en masse 5 000 enregistrements, le navigateur peut rejeter l'URL ou le serveur HTTP (Apache par exemple) peut la rejeter. La règle générale (qui, espérons-le, évolue avec de meilleurs serveurs et logiciels) a été de choisir une taille maximale de 2 Ko. Où avec le corps d'un POST, vous pouvez aller jusqu'à 10 Mo. stackoverflow.com/questions/2364840/…
justin.hughey
0

De mon point de vue, je pense que Facebook a la meilleure implémentation.

Une seule requête HTTP est effectuée avec un paramètre de lot et une pour un jeton.

Dans le lot, un json est envoyé. qui contient une collection de "demandes". Chaque requête a une propriété de méthode (get / post / put / delete / etc ...), et une propriété relative_url (uri du point de terminaison), en plus les méthodes post et put permettent une propriété "body" où les champs à mettre à jour sont envoyés .

plus d'informations sur: Facebook batch API

Leonardo Jauregui
la source