Come gestire le relazioni molti-a-molti in un’API RESTful?

Immagina di avere 2 quadro, Giocatore e Squadra , in cui i giocatori possono essere su più squadre. Nel mio modello di dati, ho una tabella per ogni quadro e una tabella di join per mantenere le relazioni. Hibernate va bene nel gestire questo, ma come potrei esporre questa relazione in un’API RESTful?

Posso pensare ad un paio di modi. In primo luogo, potrei avere ogni quadro contenente una lista dell’altro, quindi un object giocatore dovrebbe avere un elenco di squadre a cui appartiene, e ogni object della squadra avrebbe un elenco di giocatori che ne fanno parte. Quindi, per aggiungere un giocatore a una squadra, basta POSTARE la rappresentazione del giocatore a un endpoint, qualcosa come POST /player o POST /team con l’object appropriato come payload della richiesta. Questo mi sembra il più “RESTful” per me, ma mi sembra un po ‘strano.

 /api/team/0: { name: 'Boston Celtics', logo: '/img/Celtics.png', players: [ '/api/player/20', '/api/player/5', '/api/player/34' ] } /api/player/20: { pk: 20, name: 'Ray Allen', birth: '1975-07-20T02:00:00Z', team: '/api/team/0' } 

L’altro modo in cui posso pensare di farlo sarebbe quello di esporre la relazione come una risorsa a sé stante. Quindi per vedere un elenco di tutti i giocatori di una determinata squadra, potresti fare un GET /playerteam/team/{id} o qualcosa del genere e recuperare un elenco di quadro PlayerTeam. Per aggiungere un giocatore a una squadra, POST /playerteam con un’ quadro PlayerTeam appropriatamente costruita come payload.

 /api/team/0: { name: 'Boston Celtics', logo: '/img/Celtics.png' } /api/player/20: { pk: 20, name: 'Ray Allen', birth: '1975-07-20T02:00:00Z', team: '/api/team/0' } /api/player/team/0/: [ '/api/player/20', '/api/player/5', '/api/player/34' ] 

Qual è la migliore pratica per questo?

In un’interfaccia RESTful, è ansible restituire documenti che descrivono le relazioni tra risorse codificando tali relazioni come collegamenti. Quindi, si può dire che una squadra abbia una risorsa di documento ( /team/{id}/players ) che è una lista di link a giocatori ( /player/{id} ) nella squadra, e un giocatore può avere una risorsa di documento ( /player/{id}/teams ) che è una lista di collegamenti a squadre di cui il giocatore è membro. Bello e simmetrico. È ansible eseguire le operazioni sulla mappa in tale elenco abbastanza facilmente, anche fornendo a una relazione i propri ID (probabilmente avranno due ID, a seconda che si stia pensando al team di relazioni prima o al giocatore prima) se ciò rende le cose più facili . L’unico problema è che devi ricordare di eliminare la relazione anche dall’altra parte se la elimini da un lato, ma gestendola rigorosamente utilizzando un modello di dati sottostante e avendo l’interfaccia REST come vista di quel modello lo renderà più facile.

Probabilmente gli ID di relazione dovrebbero essere basati su UUID o qualcosa di ugualmente lungo e casuale, indipendentemente dal tipo di ID che usi per squadre e giocatori. Questo ti consentirà di utilizzare lo stesso UUID del componente ID per ciascuna estremità della relazione senza preoccuparti delle collisioni (i piccoli numeri interi non hanno questo vantaggio). Se queste relazioni di appartenenza hanno proprietà diverse dal semplice fatto che riguardano un giocatore e una squadra in modo bidirezionale, dovrebbero avere una propria id quadro indipendente da giocatori e squadre; un GET sul giocatore »la vista della squadra ( /player/{playerID}/teams/{teamID} ) potrebbe quindi eseguire un reindirizzamento HTTP alla vista bidirezionale ( /memberships/{uuid} ).

Raccomando di scrivere collegamenti in qualsiasi documento XML che restituisci (se ti capita di produrre XML ovviamente) usando XLink xlink:href attributi xlink:href .

Crea un insieme separato di /memberships/ resources.

  1. REST si occupa di rendere sistemi evolvibili se non altro. In questo momento, ti può interessare solo che un dato giocatore si trovi in ​​una determinata squadra, ma ad un certo punto nel futuro, vorrai annotare quella relazione con più dati: quanto tempo sono stati in quella squadra, chi li ha indirizzati a quella squadra, chi è / è stato il loro allenatore in quella squadra, ecc. ecc.
  2. REST dipende dal caching per l’efficienza, che richiede una certa considerazione per l’atomicità della cache e l’invalidazione. Se POST una nuova quadro in /teams/3/players/ quell’elenco sarà invalidata, ma non vuoi che l’URL /players/5/teams/ alternativo rimanga nella cache. Sì, cache diverse avranno copie di ciascuna lista con diverse fasce di età, e non c’è molto che possiamo fare al riguardo, ma possiamo almeno minimizzare la confusione per l’utente che POST esegue l’aggiornamento limitando il numero di quadro che dobbiamo invalidare nella cache locale del loro cliente a uno e solo a /memberships/98745 (vedi la discussione di Helland sugli “indici alternativi” nella vita oltre le transazioni distribuite per una discussione più dettagliata).
  3. Potresti implementare i 2 punti sopra elencati semplicemente scegliendo /players/5/teams o /teams/3/players (ma non entrambi). Assumiamo il primo. Ad un certo punto, tuttavia, vorrai riservare /players/5/teams/ per un elenco di iscrizioni attuali , e tuttavia essere in grado di fare riferimento alle iscrizioni passate da qualche parte. Crea /players/5/memberships/ un elenco di collegamenti ipertestuali a /memberships/{id}/ risorse, e poi puoi aggiungere /players/5/past_memberships/ quando vuoi, senza dover rompere i segnalibri di tutti per le singole risorse di appartenenza. Questo è un concetto generale; Sono sicuro che puoi immaginare altri futuri simili che sono più applicabili al tuo caso specifico.

Vorrei mappare tale relazione con le risorse secondarie, design / attraversamento generale sarebbe quindi:

# team resource /teams/{teamId} # players resource /players/{playerId} # teams/players subresource /teams/{teamId}/players/{playerId}
# team resource /teams/{teamId} # players resource /players/{playerId} # teams/players subresource /teams/{teamId}/players/{playerId} 

In Restful-terms aiuta molto a non pensare a SQL e join ma più a collezioni, sotto-collezioni e attraversamenti.

Qualche esempio:

# getting player 3 who is on team 1 # or simply checking whether player 3 is on that team (200 vs. 404) GET /teams/1/players/3 # getting player 3 who is also on team 3 GET /teams/3/players/3 # adding player 3 also to team 2 PUT /teams/2/players/3 # getting all teams of player 3 GET /players/3/teams # withdraw player 3 from team 1 (appeared drunk before match) DELETE /teams/1/players/3 # team 1 found a replacement, who is not registered in league yet POST /players # from payload you get back the id, now place it officially to team 1 PUT /teams/1/players/44
# getting player 3 who is on team 1 # or simply checking whether player 3 is on that team (200 vs. 404) GET /teams/1/players/3 # getting player 3 who is also on team 3 GET /teams/3/players/3 # adding player 3 also to team 2 PUT /teams/2/players/3 # getting all teams of player 3 GET /players/3/teams # withdraw player 3 from team 1 (appeared drunk before match) DELETE /teams/1/players/3 # team 1 found a replacement, who is not registered in league yet POST /players # from payload you get back the id, now place it officially to team 1 PUT /teams/1/players/44 

Come vedi, non uso POST per mettere i giocatori in squadra ma PUT, che gestisce meglio la tua relazione n: n tra giocatori e squadre.

Le risposte esistenti non spiegano i ruoli di coerenza e idempotenza – che motivano le loro raccomandazioni di UUIDs / numeri casuali per ID e PUT invece di POST .

Se consideriamo il caso in cui abbiamo uno scenario semplice come ” Aggiungi un nuovo giocatore a una squadra “, riscontriamo problemi di coerenza.

Perché il giocatore non esiste, dobbiamo:

 POST /players { "Name": "Murray" } //=> 302 /players/5 POST /teams/1/players/5 

Tuttavia, se l’operazione del client dovesse fallire dopo il POST di /players , abbiamo creato un giocatore che non appartiene a una squadra:

 POST /players { "Name": "Murray" } //=> 302 /players/5 // *client failure* // *client retries naively* POST /players { "Name": "Murray" } //=> 302 /players/6 POST /teams/1/players/6 

Ora abbiamo un giocatore duplicato orfano in /players/5 .

Per risolvere questo problema potremmo scrivere codice di recupero personalizzato che controlla la presenza di giocatori orfani che corrispondono a una chiave naturale (ad es. Name ). Questo è un codice personalizzato che deve essere testato, costa più soldi e tempo ecc ecc

Per evitare di richiedere codice di ripristino personalizzato, possiamo implementare PUT anziché POST .

Dalla RFC :

l’intento di PUT è idempotente

Affinché un’operazione possa essere idempotente, è necessario escludere dati esterni come sequenze di ID generate dal server. Questo è il motivo per cui le persone raccomandano sia PUT che UUID per Id s insieme.

Questo ci consente di rieseguire sia PUT che i /memberships PUT senza conseguenze:

 PUT /players/23lkrjrqwlej { "Name": "Murray" } //=> 200 OK // *client failure* // *client YOLOs* PUT /players/23lkrjrqwlej { "Name": "Murray" } //=> 200 OK PUT /teams/1/players/23lkrjrqwlej 

Tutto è a posto e non abbiamo avuto bisogno di fare altro che riprovare per guasti parziali.

Questo è più di un addendum alle risposte esistenti, ma spero che le collochino nel contesto di un quadro più ampio di come può essere la RST flessibile ed affidabile.

So che c’è una risposta contrassegnata come accettata per questa domanda, tuttavia, ecco come possiamo risolvere i problemi precedentemente sollevati:

Diciamo per PUT

 PUT /membership/{collection}/{instance}/{collection}/{instance}/ 

Ad esempio, i seguenti risultati generano lo stesso effetto senza necessità di sincronizzazione perché vengono eseguiti su una singola risorsa:

 PUT /membership/teams/team1/players/player1/ PUT /membership/players/player1/teams/team1/ 

ora se vogliamo aggiornare più abbonamenti per un team potremmo fare come segue (con le opportune validazioni):

 PUT /membership/teams/team1/ { membership: [ { teamId: "team1" playerId: "player1" }, { teamId: "team1" playerId: "player2" }, ... ] } 

La mia soluzione preferita è quella di creare tre risorse: Players , Teams e TeamsPlayers .

Quindi, per ottenere tutti i giocatori di una squadra, basta andare alla risorsa Teams e ottenere tutti i suoi giocatori chiamando GET /Teams/{teamId}/Players .

D’altra parte, per ottenere tutte le squadre giocate da un giocatore, ottenere la risorsa Teams all’interno dei Players . Chiama GET /Players/{playerId}/Teams .

E, per ottenere la relazione molti-a-molti chiama GET /Players/{playerId}/TeamsPlayers o GET /Teams/{teamId}/TeamsPlayers .

Nota che, in questa soluzione, quando chiami GET /Players/{playerId}/Teams , ottieni una serie di risorse Teams , che è esattamente la stessa risorsa che ottieni quando chiami GET /Teams/{teamId} . Il contrario segue lo stesso principio, ottieni una serie di risorse dei Players quando chiami GET /Teams/{teamId}/Players .

In entrambe le chiamate, non viene restituita alcuna informazione sulla relazione. Ad esempio, non viene restituito contractStartDate , poiché la risorsa restituita non ha informazioni sulla relazione, ma solo sulla propria risorsa.

Per gestire la relazione nn, chiama GET /Players/{playerId}/TeamsPlayers o GET /Teams/{teamId}/TeamsPlayers . Queste chiamate restituiscono esattamente la risorsa, TeamsPlayers .

Questa risorsa TeamsPlayers ha attributi id , playerId , teamId , così come altri per descrivere la relazione. Inoltre, ha i metodi necessari per affrontarli. GET, POST, PUT, DELETE ecc. Che restituiranno, includeranno, aggiorneranno, rimuoveranno la risorsa di relazione.

La risorsa TeamsPlayers implementa alcune query, come GET /TeamsPlayers?player={playerId} per restituire tutte TeamsPlayers relazioni TeamsPlayers che il giocatore identificato da {playerId} ha. Seguendo la stessa idea, usa GET /TeamsPlayers?team={teamId} per restituire tutti i TeamsPlayers che hanno giocato nel team {teamId} . In entrambe le chiamate GET , viene restituita la risorsa TeamsPlayers . Tutti i dati relativi alla relazione vengono restituiti.

Quando chiami GET /Players/{playerId}/Teams (o GET /Teams/{teamId}/Players ), la risorsa Players (o Teams ) chiama TeamsPlayers per restituire i relativi team (o giocatori) usando un filtro di query.

GET /Players/{playerId}/Teams funziona così:

  1. Trova tutti i TeamsPlayers che il giocatore ha id = playerId . ( GET /TeamsPlayers?player={playerId} )
  2. Effettua il loop dei TeamsPlayers restituiti
  3. Utilizzando il teamId ottenuto da TeamsPlayers , chiama GET /Teams/{teamId} e memorizza i dati restituiti
  4. Al termine del ciclo. Restituisci tutte le squadre che sono state inserite nel ciclo.

È ansible utilizzare lo stesso algoritmo per ottenere tutti i giocatori da una squadra, quando si chiama GET /Teams/{teamId}/Players , ma si scambiano squadre e giocatori.

Le mie risorse dovrebbero assomigliare a questo:

 /api/Teams/1: { id: 1 name: 'Vasco da Gama', logo: '/img/Vascao.png', } /api/Players/10: { id: 10, name: 'Roberto Dinamite', birth: '1954-04-13T00:00:00Z', } /api/TeamsPlayers/100 { id: 100, playerId: 10, teamId: 1, contractStartDate: '1971-11-25T00:00:00Z', } 

Questa soluzione si basa solo sulle risorse REST. Sebbene alcune chiamate extra possano essere necessarie per ottenere dati dai giocatori, dai team o dalla loro relazione, tutti i metodi HTTP sono facilmente implementabili. POST, PUT, DELETE sono semplici e diretti.

Ogni volta che una relazione viene creata, aggiornata o cancellata, entrambe le risorse Players e Teams vengono aggiornate automaticamente.

  1. / giocatori (è una risorsa principale)
  2. / teams / {id} / players (è una risorsa di relazione, quindi reagisce diversamente che 1)
  3. / membership (è una relazione ma semanticamente complicata)
  4. / giocatori / tessere (è una relazione ma semanticamente complicata)

Preferisco 2