Modelli per la gestione delle operazioni batch nei servizi Web REST?


170

Quali modelli di progettazione comprovati esistono per le operazioni batch sulle risorse all'interno di un servizio Web in stile REST?

Sto cercando di trovare un equilibrio tra ideali e realtà in termini di prestazioni e stabilità. Al momento disponiamo di un'API in cui tutte le operazioni vengono recuperate da una risorsa elenco (ad esempio: GET / utente) o su una singola istanza (PUT / utente / 1, ELIMINA / utente / 22, ecc.).

Ci sono alcuni casi in cui desideri aggiornare un singolo campo di un intero insieme di oggetti. Sembra molto inutile inviare l'intera rappresentazione per ogni oggetto avanti e indietro per aggiornare un campo.

In un'API di stile RPC, potresti avere un metodo:

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

Qual è l'equivalente REST qui? O va bene scendere a compromessi di tanto in tanto. Rovina il design da aggiungere in alcune operazioni specifiche in cui migliora davvero le prestazioni, ecc.? Il client in tutti i casi in questo momento è un browser Web (applicazione javascript sul lato client).

Risposte:


77

Un semplice modello RESTful per i batch consiste nell'utilizzare una risorsa di raccolta. Ad esempio, per eliminare più messaggi contemporaneamente.

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

È un po 'più complicato aggiornare in batch risorse parziali o attributi di risorse. Ossia, aggiorna ogni attributo marcatoAsRead. Fondamentalmente, invece di considerare l'attributo come parte di ogni risorsa, lo trattate come un bucket in cui inserire le risorse. Un esempio è già stato pubblicato. L'ho aggiustato un po '.

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

Fondamentalmente, stai aggiornando l'elenco dei messaggi contrassegnati come letti.

Puoi anche usarlo per assegnare più elementi alla stessa categoria.

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

È ovviamente molto più complicato eseguire aggiornamenti parziali in batch in stile iTunes (ad es. Artista + albumTitle ma non trackTitle). L'analogia del bucket inizia a non funzionare.

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

A lungo termine, è molto più semplice aggiornare una singola risorsa parziale o attributi delle risorse. Basta fare uso di una risorsa secondaria.

POST /mail/0/markAsRead
POSTDATA: true

In alternativa, è possibile utilizzare risorse con parametri. Questo è meno comune nei modelli REST, ma è consentito nelle specifiche URI e HTTP. Un punto e virgola divide i parametri correlati orizzontalmente all'interno di una risorsa.

Aggiorna diversi attributi, diverse risorse:

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

Aggiorna diverse risorse, un solo attributo:

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

Aggiorna diversi attributi, una sola risorsa:

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

La creatività RESTful abbonda.


1
Si potrebbe sostenere che la tua eliminazione dovrebbe effettivamente essere un post poiché non sta effettivamente distruggendo quella risorsa.
Chris Nicola,

6
Non è necessario POST è un metodo di modello di fabbrica, è meno esplicito e ovvio di PUT / DELETE / GET. L'unica aspettativa è che il server decida cosa fare a seguito del POST. Il POST è esattamente quello che è sempre stato, invio i dati del modulo e il server fa qualcosa (si spera ci si aspetti) e mi dà qualche indicazione sul risultato. Non siamo tenuti a creare risorse con POST, spesso lo scegliamo. Posso facilmente creare una risorsa con PUT, devo solo definire l'URL della risorsa come mittente (spesso non ideale).
Chris Nicola,

1
@nishant, in questo caso, probabilmente non è necessario fare riferimento a più risorse nell'URI, ma semplicemente passare tuple con i riferimenti / valori nel corpo della richiesta. ad es. 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
il punto e virgola è riservato a questo scopo.
Alex,

1
Sorpreso dal fatto che nessuno abbia sottolineato che l'aggiornamento di più attributi su una singola risorsa è ben coperto da PATCH, non c'è bisogno di creatività in questo caso.
LB2

25

Niente affatto - penso che l'equivalente REST sia (o almeno una soluzione lo sia) quasi esattamente - un'interfaccia specializzata progettata per ospitare un'operazione richiesta dal client.

Mi viene in mente uno schema menzionato nel libro Ajax in Action di Crane e Pascarello (un libro eccellente, tra l'altro - altamente raccomandato) in cui illustrano l'implementazione di un tipo di oggetto CommandQueue il cui compito è mettere in coda le richieste in batch e quindi pubblicarli periodicamente sul server.

L'oggetto, se ricordo bene, essenzialmente conteneva una serie di "comandi" - ad esempio, per estendere il tuo esempio, ognuno un record contenente un comando "markAsRead", un "messageId" e forse un riferimento a un callback / handler funzione - e quindi in base a una pianificazione o ad alcune azioni dell'utente, l'oggetto comando verrebbe serializzato e registrato sul server e il client gestirà la conseguente post-elaborazione.

Non mi capita di avere i dettagli a portata di mano, ma sembra che una coda di comandi di questo tipo sarebbe un modo per gestire il tuo problema; ridurrebbe sostanzialmente la chattiness generale e astraggerebbe l'interfaccia lato server in un modo che potresti trovare più flessibile lungo la strada.


Aggiornamento : Aha! Ho trovato un pezzo di quel libro online, completo di esempi di codice (anche se suggerisco ancora di prendere il libro reale!). Dai un'occhiata qui , a partire dalla sezione 5.5.3:

Questo è facile da codificare ma può comportare un numero molto ridotto di traffico verso il server, che è inefficiente e potenzialmente confuso. Se vogliamo controllare il nostro traffico, possiamo acquisire questi aggiornamenti e metterli in coda localmente e quindi inviarli al server in batch a nostro piacimento. Una semplice coda di aggiornamento implementata in JavaScript è mostrata nell'elenco 5.13. [...]

La coda mantiene due matrici. queued è una matrice indicizzata numericamente, alla quale vengono aggiunti nuovi aggiornamenti. sent è un array associativo, contenente quegli aggiornamenti che sono stati inviati al server ma in attesa di risposta.

Ecco due funzioni pertinenti: una responsabile per l'aggiunta di comandi alla coda ( addCommand) e una responsabile per la serializzazione e l'invio al server ( 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
        }
    }
}

Questo dovrebbe farti andare. In bocca al lupo!


Grazie. È molto simile alle mie idee su come andrei avanti se mantenessimo le operazioni batch sul client. Il problema è il tempo di andata e ritorno per l'esecuzione di un'operazione su un gran numero di oggetti.
Mark Renouf,

Hm, ok - Pensavo che volessi eseguire l'operazione su un gran numero di oggetti (sul server) tramite una richiesta leggera. Ho frainteso?
Christian Nunciato,

Sì, ma non vedo come quell'esempio di codice eseguirà l'operazione in modo più efficiente. Raggruppa le richieste ma le invia comunque al server una alla volta. Sto interpretando male?
Mark Renouf,

In realtà li raggruppa e poi li invia tutti in una volta: quello per loop in fireRequest () essenzialmente raccoglie tutti i comandi in sospeso, li serializza come una stringa (con .toRequestString (), ad es. "Method = markAsRead & messageIds = 1,2,3 , 4 "), assegna quella stringa a" dati "e dati POST al server.
Christian Nunciato,

20

Mentre penso che @Alex sia sulla buona strada, concettualmente penso che dovrebbe essere il contrario di ciò che viene suggerito.

L'URL è in effetti "le risorse a cui ci stiamo rivolgendo", quindi:

    [GET] mail/1

significa ottenere il record dalla posta con ID 1 e

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

significa patchare il record di posta con ID 1. La stringa di query è un "filtro", che filtra i dati restituiti dall'URL.

    [GET] mail?markAsRead=true

Quindi qui stiamo richiedendo tutta la posta già contrassegnata come letta. Quindi [PATCH] su questo percorso direbbe "patch i record già contrassegnati come veri" ... che non è quello che stiamo cercando di ottenere.

Quindi un metodo batch, seguendo questo pensiero dovrebbe essere:

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

ovviamente non sto dicendo che questo è vero REST (che non consente la manipolazione di record batch), piuttosto segue la logica già esistente e in uso da REST.


Risposta interessante! Per il tuo ultimo esempio, non sarebbe più coerente con il [GET]formato da fare [PATCH] mail?markAsRead=true data: [{"id": 1}, {"id": 2}, {"id": 3}](o anche solo data: {"ids": [1,2,3]})? Un altro vantaggio di questo approccio alternativo è che non ti imbatterai in errori "414 URI richiesta troppo lunghi" se stai aggiornando centinaia / migliaia di risorse nella raccolta.
Rinogo,

@rinogo - in realtà no. Questo è il punto che stavo sollevando. La stringa di query è un filtro per i record su cui vogliamo agire (ad es. [GET] mail / 1 ottiene il record di posta con un ID di 1, mentre [GET] mail? MarkasRead = true restituisce la posta dove markAsRead è già vera). Non ha senso applicare lo stesso URL (ad es. "Rattoppare i record in cui markAsRead = true") quando in realtà vogliamo patchare determinati record con ID 1,2,3, INDIPENDENTE dello stato corrente del campo markAsRead. Da qui il metodo che ho descritto. D'accordo, c'è un problema con l'aggiornamento di molti record. Costruirei un endpoint meno strettamente accoppiato.
fezfox,

11

La tua lingua, " Sembra molto dispendiosa ...", per me indica un tentativo di ottimizzazione prematura. A meno che non si possa dimostrare che l'invio dell'intera rappresentazione di oggetti è un importante risultato prestazionale (stiamo parlando inaccettabili per gli utenti con> 150 ms), non ha senso tentare di creare un nuovo comportamento API non standard. Ricorda, più semplice è l'API, più è facile da usare.

Per le eliminazioni inviare quanto segue in quanto il server non ha bisogno di sapere nulla sullo stato dell'oggetto prima che si verifichi l'eliminazione.

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

L'idea successiva è che se un'applicazione sta riscontrando problemi di prestazioni relativi all'aggiornamento in blocco degli oggetti, dovrebbe essere considerata la possibilità di suddividere ogni oggetto in più oggetti. In questo modo il payload JSON è una frazione delle dimensioni.

Ad esempio, quando si invia una risposta per aggiornare gli stati "letto" e "archiviato" di due e-mail separate, è necessario inviare quanto segue:

PUT /emails
POSTDATA: [
            {
              id:1,
              to:"someone@bratwurst.com",
              from:"someguy@frommyville.com",
              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:"someone@bratwurst.com",
              from:"someguy@frommyville.com",
              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"]
            }
            ]

Dividerei i componenti mutabili dell'email (leggi, archiviati, importanza, etichette) in un oggetto separato poiché gli altri (a, da, oggetto, testo) non verrebbero mai aggiornati.

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

Un altro approccio da adottare è quello di sfruttare l'uso di un PATCH. Indicare esplicitamente quali proprietà si intende aggiornare e che tutte le altre devono essere ignorate.

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

Le persone affermano che PATCH dovrebbe essere implementato fornendo una serie di modifiche contenenti: azione (CRUD), percorso (URL) e modifica del valore. Questa può essere considerata un'implementazione standard, ma se si esamina l'intera API REST è una tantum non intuitivo. Inoltre, l'implementazione sopra è come GitHub ha implementato PATCH .

Per riassumere, è possibile aderire ai principi RESTful con azioni batch e avere comunque prestazioni accettabili.


Concordo sul fatto che PATCH abbia più senso, il problema è che se si dispone di un altro codice di transizione di stato che deve essere eseguito quando tali proprietà cambiano, diventa più difficile implementarlo come un semplice PATCH. Non credo che il REST accolga davvero qualsiasi tipo di transizione di stato, dato che si suppone che sia apolide, non gli importa cosa sta passando da e verso, solo quello che è lo stato attuale.
BeniRose,

Ehi BeniRose, grazie per aver aggiunto un commento, mi chiedo spesso se le persone vedono alcuni di questi post. Mi rende felice di vedere che lo fanno le persone. Le risorse relative alla natura "senza stato" di REST lo definiscono una preoccupazione per il server che non deve mantenere lo stato tra le richieste. Pertanto, non mi è chiaro quale problema stavi descrivendo, puoi elaborare con un esempio?
justin.hughey,

8

L'API di Google Drive ha un sistema davvero interessante per risolvere questo problema ( vedi qui ).

Quello che fanno è sostanzialmente raggruppare diverse richieste in una Content-Type: multipart/mixedrichiesta, con ogni singola richiesta completa separata da un delimitatore definito. Le intestazioni e i parametri di query della richiesta batch vengono ereditati dalle singole richieste (ad es. Authorization: Bearer some_token) A meno che non vengano sovrascritte nella singola richiesta.


Esempio : (tratto dai loro documenti )

Richiesta:

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":"example@appsrocks.com",
  "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--

Risposta:

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

1

Sarei tentato in un'operazione come quella del tuo esempio di scrivere un parser di intervallo.

Non è molto fastidioso creare un parser in grado di leggere "messageIds = 1-3,7-9,11,12-15". Aumenterebbe sicuramente l'efficienza per le operazioni coperte che coprono tutti i messaggi ed è più scalabile.


Buona osservazione e buona ottimizzazione, ma la domanda era se questo stile di richiesta potesse mai essere "compatibile" con il concetto REST.
Mark Renouf,

Ciao, sì, ho capito. L'ottimizzazione rende il concetto più RESTful e non volevo tralasciare il mio consiglio solo perché stava vagando un po 'lontano dall'argomento.

1

Ottimo post. Ho cercato una soluzione per alcuni giorni. Ho trovato una soluzione per utilizzare il passaggio di una stringa di query con ID gruppo separati da virgole, come:

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

... quindi passandolo a una WHERE INclausola nel mio SQL. Funziona alla grande, ma mi chiedo cosa ne pensano gli altri di questo approccio.


1
Non mi piace davvero perché introduce un nuovo tipo, la stringa che usi come elenco in cui si trova. Preferirei invece analizzarlo con un tipo specifico di lingua e quindi posso usare lo stesso metodo nel allo stesso modo in più parti diverse del sistema.
softarn,

4
Un promemoria per essere cauti negli attacchi SQL injection e purificare sempre i dati e utilizzare i parametri di bind quando si adotta questo approccio.
justin.hughey,

2
Dipende dal comportamento desiderato di DELETE /books/delete?id=1,2,3quando il libro n. 3 non esiste - la WHERE INvolontà ignorerà silenziosamente i record, mentre di solito mi aspetterei DELETE /books/delete?id=3di 404 se il 3 non esiste.
chbrown

3
Un problema diverso che potresti incontrare utilizzando questa soluzione è il limite di caratteri consentiti in una stringa URL. Se qualcuno decide di eliminare in blocco 5.000 record, il browser potrebbe rifiutare l'URL o il server HTTP (ad esempio Apache) potrebbe rifiutarlo. La regola generale (che si spera stia cambiando con server e software migliori) è stata quella di andare con una dimensione massima di 2 KB. Dove con il corpo di un POST puoi andare fino a 10 MB. stackoverflow.com/questions/2364840/...
justin.hughey

0

Dal mio punto di vista, penso che Facebook abbia la migliore implementazione.

Viene effettuata una singola richiesta HTTP con un parametro batch e uno per un token.

In batch viene inviato un json. che contiene una raccolta di "richieste". Ogni richiesta ha una proprietà del metodo (get / post / put / delete / etc ...) e una proprietà relative_url (uri dell'endpoint), inoltre i metodi post e put consentono una proprietà "body" in cui i campi devono essere aggiornati sono inviati .

maggiori informazioni su: API batch di Facebook

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.