Jak obsługiwać relacje wiele do wielu w interfejsie API RESTful?

288

Wyobraź sobie, że masz 2 podmioty, Gracza i Drużyny , w których gracze mogą należeć do wielu drużyn. W moim modelu danych mam tabelę dla każdej jednostki i tabelę łączenia, aby zachować relacje. Hibernacja radzi sobie z tym dobrze, ale jak mogę ujawnić ten związek w interfejsie API RESTful?

Mogę wymyślić kilka sposobów. Po pierwsze, każda jednostka może mieć listę drugiej, więc obiekt Gracza miałby listę Drużyn, do których należy, a każdy obiekt Drużyny miałby listę Graczy, które do niej należą. Aby dodać gracza do zespołu, wystarczy POST przedstawić reprezentację gracza do punktu końcowego, coś w rodzaju POST /playerlub POST /teamz odpowiednim obiektem jako ładunkiem żądania. Wydaje mi się to najbardziej „ODPOCZYNEK”, ale wydaje mi się trochę dziwne.

/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'
}

Innym sposobem, w jaki mogę to zrobić, byłoby ujawnienie relacji jako odrębnego zasobu. Aby zobaczyć listę wszystkich graczy w danym zespole, możesz zrobić GET /playerteam/team/{id}lub coś w tym stylu i uzyskać listę podmiotów PlayerTeam. Aby dodać gracza do drużyny, POST /playerteamz odpowiednio zbudowaną jednostką PlayerTeam jako ładunek.

/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'        
]

Jaka jest najlepsza praktyka w tym zakresie?

Richard Handworker
źródło

Odpowiedzi:

129

W interfejsie RESTful możesz zwrócić dokumenty opisujące relacje między zasobami, kodując te relacje jako łącza. Można zatem powiedzieć, że zespół ma zasób dokumentu ( /team/{id}/players), który jest listą linków do graczy (/player/{id} ) w zespole, a gracz może mieć zasób dokumentu (/player/{id}/teams), czyli lista linków do drużyn, których członkiem jest gracz. Ładne i symetryczne. Możesz łatwo operować mapą na tej liście, nawet nadając relacjom własne identyfikatory (prawdopodobnie mieliby dwa identyfikatory, w zależności od tego, czy myślisz o relacji najpierw w zespole, czy w pierwszej kolejności), jeśli to ułatwi sprawę . Jedyną trudną kwestią jest to, że musisz pamiętać, aby usunąć relację również z drugiego końca, jeśli usuniesz ją z jednego końca, ale rygorystycznie obsługując to za pomocą bazowego modelu danych, a następnie mając interfejs REST w widoku ten model to ułatwi.

Identyfikatory relacji prawdopodobnie powinny być oparte na identyfikatorach UUID lub czymś równie długim i losowym, niezależnie od tego, jakiego rodzaju identyfikatorów używasz dla drużyn i graczy. To pozwoli ci użyć tego samego UUID jako komponentu ID dla każdego końca relacji bez martwienia się o kolizje (małe liczby całkowite nie mają tej przewagi). Jeśli te relacje członkowskie mają inne właściwości niż fakt, że łączą one gracza i zespół w sposób dwukierunkowy, powinni mieć własną tożsamość, niezależną od graczy i drużyn; polecenie GET w widoku zespołu »gracza ( /player/{playerID}/teams/{teamID}) może następnie przekierować HTTP do widoku dwukierunkowego ( /memberships/{uuid}).

Polecam pisanie linków we wszelkich zwracanych dokumentach XML (jeśli oczywiście tworzysz XML) przy użyciu atrybutów XLink xlink:href .

Donal Fellows
źródło
265

Utwórz osobny zestaw /memberships/zasobów.

  1. REST polega na tworzeniu ewoluujących systemów, jeśli nic więcej. W tej chwili możesz się tylko przejmować, że dany gracz jest w danej drużynie, ale w pewnym momencie w przyszłości będziesz chciał opisać tę relację, dodając więcej danych: jak długo był w tej drużynie, kto ich skierował do tego zespołu, kim był / był ich trener podczas pracy w tym zespole itp. itp.
  2. REST zależy od buforowania pod kątem wydajności, co wymaga pewnej uwagi na atomowość i unieważnienie pamięci podręcznej. Jeśli opublikujesz nowy obiekt na /teams/3/players/tej liście, zostanie on unieważniony, ale nie chcesz, aby alternatywny adres URL /players/5/teams/pozostał w pamięci podręcznej. Tak, różne pamięci podręczne będą miały kopie każdej listy w różnym wieku, i niewiele możemy na to poradzić, ale możemy przynajmniej zminimalizować zamieszanie związane z aktualizacją przez użytkownika POST, ograniczając liczbę jednostek potrzebnych do unieważnienia w lokalnej pamięci podręcznej klienta do jednego i tylko jednego w /memberships/98745( bardziej szczegółowa dyskusja znajduje się w dyskusji Hellanda na temat „alternatywnych wskaźników” w życiu poza rozproszonymi transakcjami ).
  3. Możesz zaimplementować powyższe 2 punkty, po prostu wybierając /players/5/teamslub /teams/3/players(ale nie oba). Załóżmy pierwszy. W pewnym momencie jednak będziesz chciał zarezerwować /players/5/teams/listę aktualnych członkostw, a mimo to mieć możliwość odwoływania się do poprzednich członkostw. Zrób /players/5/memberships/listę hiperłączy do /memberships/{id}/zasobów, a następnie możesz dodawać, /players/5/past_memberships/kiedy chcesz, bez konieczności łamania zakładek wszystkich osób dla poszczególnych zasobów członkowskich. To jest ogólna koncepcja; Jestem pewien, że możesz sobie wyobrazić inne podobne futures, które lepiej pasują do twojego konkretnego przypadku.
fumanchu
źródło
11
Punkty 1 i 2 są doskonale wyjaśnione, dzięki, jeśli ktoś ma więcej mięsa na punkt 3 w prawdziwym życiu, to by mi pomogło.
Alain,
2
Najlepsza i najprostsza odpowiedź IMO dzięki! Posiadanie dwóch punktów końcowych i utrzymywanie ich w synchronizacji ma wiele komplikacji.
Venkat D.
7
cześć fumanchu. Pytania: Co oznacza liczba na końcu adresu URL w pozostałych punktach końcowych / członkostwach / 98745? Czy to unikalny identyfikator członkostwa? Jak można wchodzić w interakcje z punktem końcowym członkostwa? Aby dodać gracza, czy wysłany zostałby test POST zawierający ładunek z {team: 3, player: 6}, tworząc w ten sposób połączenie między nimi? Co powiesz na GET? czy wyślesz GET do / członkostwa? player = i / Membersihps? team =, aby uzyskać wyniki? To jest pomysł? Czy coś mi brakuje? (Próbuję nauczyć się kojących punktów końcowych) Czy w takim przypadku identyfikator 98745 w członkostwie / 98745 jest naprawdę przydatny?
aruuuuu
@aruuuuu oddzielny punkt końcowy dla skojarzenia powinien mieć zastępczą PK. To sprawia, że ​​życie jest o wiele łatwiejsze również: / Memberss / {MembersId}. Klucz (playerId, teamId) pozostaje unikalny i dlatego można go użyć w zasobach, które posiadają tę relację: / drużyn / {teamId} / graczy i / graczy / {playerId} / drużyn. Ale nie zawsze takie relacje utrzymywane są po obu stronach. Na przykład Przepisy i Składniki: prawie nigdy nie będziesz musiał używać / ingredients / {componentId} / recipes /.
Alexander Palamarchuk
65

Zmapowałbym taki związek z podrzędnymi zasobami, ogólny projekt / przejście byłoby wtedy:

# team resource
/teams/{teamId}

# players resource
/players/{playerId}

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

W kategoriach Restful bardzo pomaga to nie myśleć o SQL i dołączać, ale bardziej w kolekcje, subkolekcje i przechodzenie.

Kilka przykładów:

# 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

Jak widać, nie używam POST do umieszczania graczy w drużynach, ale PUT, który lepiej radzi sobie z twoimi relacjami między graczami i drużynami n: n.

Manuel Aldana
źródło
20
Co jeśli team_player ma dodatkowe informacje, takie jak status itp? gdzie reprezentujemy to w twoim modelu? czy możemy wypromować go do zasobu i podać adresy URL, podobnie jak game /, player /
Narendra Kamma
Hej, szybkie pytanie, aby upewnić się, że mam rację: GET / drużyny / 1 / zawodnicy / 3 zwraca pustą treść odpowiedzi. Jedyną sensowną odpowiedzią na to jest 200 vs 404. Informacje podmiotu gracza (imię, wiek itp.) NIE są zwracane przez GET / drużyny / 1 / graczy / 3. Jeśli klient chce uzyskać dodatkowe informacje o graczu, musi uzyskać / graczy / 3. Czy to wszystko jest poprawne?
Verdagon,
2
Zgadzam się z Twoim mapowaniem, ale mam jedno pytanie. To kwestia osobistej opinii, ale co sądzisz o POST / drużynach / 1 / graczach i dlaczego tego nie używasz? Czy widzisz jakieś wady / wprowadzanie w błąd w tym podejściu?
JakubKnejzlik,
2
POST nie jest idempotentny, tzn. Jeśli wykonujesz POST / drużyny / 1 / graczy n-razy, zmienisz n-razy / drużyny / 1. ale przeniesienie gracza do / zespołów / 1 n-razy nie zmieni stanu drużyny, więc użycie PUT jest bardziej oczywiste.
manuel aldana,
1
@NarendraKamma Zakładam, że po prostu wysyłam statusjako parametr w żądaniu PUT? Czy to podejście ma wadę?
Traxo
22

Istniejące odpowiedzi nie wyjaśniają roli spójności i idempotencji - które motywują ich rekomendacje UUIDsliczb losowych dla identyfikatorów i PUTzamiast nich POST.

Jeśli weźmiemy pod uwagę przypadek, w którym mamy prosty scenariusz, taki jak „ Dodaj nowego gracza do zespołu ”, możemy napotkać problemy ze spójnością.

Ponieważ odtwarzacz nie istnieje, musimy:

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

Należy jednak operacja klient nie po POSTdo /players, stworzyliśmy gracza, który nie należy do zespołu:

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

Teraz mamy osieroconego duplikata gracza /players/5.

Aby to naprawić, możemy napisać niestandardowy kod odzyskiwania, który sprawdza osieroconych graczy, którzy pasują do jakiegoś naturalnego klucza (np Name.). Jest to niestandardowy kod, który należy przetestować, kosztuje więcej pieniędzy i czasu itp

Aby uniknąć potrzeby niestandardowego kodu odzyskiwania, możemy go wdrożyć PUTzamiast POST.

Z RFC :

zamiar PUTjest idempotentny

Aby operacja była idempotentna, musi wykluczać dane zewnętrzne, takie jak generowane przez serwer sekwencje identyfikatorów. To dlatego ludzie zalecają zarówno PUTi UUIDs dla Ids razem.

To pozwala nam ponownie uruchomić zarówno te, jak /players PUTi /memberships PUTbez konsekwencji:

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

Wszystko jest w porządku i nie musieliśmy robić nic więcej niż próbować w przypadku częściowych awarii.

Jest to raczej uzupełnienie istniejących odpowiedzi, ale mam nadzieję, że umieści je w kontekście szerszego obrazu tego, jak elastyczna i niezawodna może być ReST.

Seth
źródło
Skąd ten hipotetyczny punkt końcowy 23lkrjrqwlej?
cbcoutinho
1
toczyć twarz na klawiaturze - w 23lkr nie ma nic specjalnego ... gobbledegook poza tym, że nie jest to sekwencyjne ani znaczące
Seth
9

Moje preferowanym rozwiązaniem jest utworzenie trzech zasobów: Players, Teamsi TeamsPlayers.

Tak więc, aby zdobyć wszystkich graczy z drużyny, po prostu przejdź do Teamszasobów i zbierz wszystkich graczy, dzwoniąc GET /Teams/{teamId}/Players.

Z drugiej strony, aby zdobyć wszystkie drużyny, w które grał gracz, zdobądź Teamszasoby w obrębie Players. Zadzwoń GET /Players/{playerId}/Teams.

I, aby uzyskać połączenie wiele-do-wielu GET /Players/{playerId}/TeamsPlayerslub GET /Teams/{teamId}/TeamsPlayers.

Zauważ, że w tym rozwiązaniu, kiedy dzwonisz GET /Players/{playerId}/Teams, otrzymujesz tablicę Teamszasobów, czyli dokładnie taki sam zasób, jak dzwonisz GET /Teams/{teamId}. Odwrotna zasada działa zgodnie z tą samą zasadą, Playerspodczas połączenia otrzymujesz szereg zasobów GET /Teams/{teamId}/Players.

W obu połączeniach nie są zwracane informacje o relacji. Na przykład nie contractStartDatejest zwracane, ponieważ zwrócony zasób nie ma informacji o relacji, tylko o swoim własnym zasobie.

Aby poradzić sobie z nn związku, albo połączenia GET /Players/{playerId}/TeamsPlayerslub GET /Teams/{teamId}/TeamsPlayers. Połączenia te zwracają dokładnie zasobu TeamsPlayers.

Ten TeamsPlayerszasób id, playerId, teamIdatrybuty, a także kilka innych do opisania relacji. Ma także metody niezbędne do radzenia sobie z nimi. GET, POST, PUT, DELETE itp., Które zwrócą, uwzględnią, zaktualizują, usuną zasób relacji.

Te TeamsPlayersnarzędzia zasobów niektórych zapytań, jak GET /TeamsPlayers?player={playerId}zwrócić wszystkie TeamsPlayersrelacje gracz zidentyfikowane przez {playerId}ma. Zgodnie z tym samym pomysłem, użyj, GET /TeamsPlayers?team={teamId}aby zwrócić wszystkie TeamsPlayers, które grały w {teamId}zespole. W obu GETpołączeniach zasób TeamsPlayersjest zwracany. Wszystkie dane związane z relacją są zwracane.

Podczas dzwonienia GET /Players/{playerId}/Teams(lub GET /Teams/{teamId}/Players), zasoby Players(lub Teams) dzwonią , TeamsPlayersaby zwrócić powiązane drużyny (lub graczy) za pomocą filtra zapytań.

GET /Players/{playerId}/Teams działa w ten sposób:

  1. Znajdź wszystkich graczy TeamsPlayers , których gracz ma id = playerId . ( GET /TeamsPlayers?player={playerId})
  2. Zapętlić zwrócone TeamsPlayers
  3. Korzystając z teamId uzyskanego od TeamsPlayers , dzwoń GET /Teams/{teamId}i przechowuj zwrócone dane
  4. Po zakończeniu pętli. Zwróć wszystkie drużyny, które znalazły się w pętli.

Możesz użyć tego samego algorytmu, aby pozyskać wszystkich graczy z drużyny, gdy dzwonisz GET /Teams/{teamId}/Players, ale wymieniaj drużyny i graczy.

Moje zasoby wyglądałyby tak:

/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',
}

To rozwiązanie opiera się tylko na zasobach REST. Chociaż konieczne mogą być dodatkowe połączenia w celu uzyskania danych od graczy, zespołów lub ich relacji, wszystkie metody HTTP można łatwo wdrożyć. POST, PUT, DELETE są proste i jednoznaczne.

Za każdym razem, gdy relacja jest tworzona, aktualizowana lub usuwana, oba zasoby Playersi Teamszasoby są automatycznie aktualizowane.

Haroldo Macedo
źródło
naprawdę warto wprowadzić zasób
TeamsPlayers. Niesamowite
najlepsze wytłumaczenie
Diana
1

Wiem, że odpowiedź na to pytanie została oznaczona jako zaakceptowana, ale oto, w jaki sposób możemy rozwiązać wcześniej podniesione problemy:

Powiedzmy, że dla PUT

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

Na przykład poniższe działania przyniosą ten sam efekt bez potrzeby synchronizacji, ponieważ są wykonywane na jednym zasobie:

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

teraz, jeśli chcemy zaktualizować wiele członkostwa dla jednego zespołu, moglibyśmy zrobić w następujący sposób (z odpowiednią weryfikacją):

PUT    /membership/teams/team1/

{
    membership: [
        {
            teamId: "team1"
            playerId: "player1"
        },
        {
            teamId: "team1"
            playerId: "player2"
        },
        ...
    ]
}
Heidar Pirzadeh
źródło
-3
  1. / graczy (jest zasobem głównym)
  2. / drużyn / {id} / graczy (jest zasobem związku, więc reaguje inaczej niż 1)
  3. / Memberships (jest relacją, ale semantycznie skomplikowaną)
  4. / graczy / członkostwa (to związek, ale semantycznie skomplikowany)

Wolę 2

MoaLaiSkirulais
źródło
2
Być może po prostu nie rozumiem odpowiedzi, ale wydaje się, że ten post nie odpowiada na pytanie.
BradleyDotNET
To nie daje odpowiedzi na pytanie. Aby skrytykować lub poprosić autora o wyjaśnienie, zostaw komentarz pod jego postem - zawsze możesz komentować własne posty, a gdy będziesz mieć wystarczającą reputację , będziesz mógł komentować każdy post .
Nielegalny argument
4
@IllegalArgument Jest to odpowiedź i nie ma sensu jako komentarz. Nie jest to jednak najlepsza odpowiedź.
Qix - MONICA MISTREATED
1
Ta odpowiedź jest trudna do naśladowania i nie zawiera uzasadnienia.
Venkat D.
2
To wcale nie wyjaśnia ani nie odpowiada na zadane pytanie.
Manjit Kumar