¿Cómo manejar relaciones de muchos a muchos en una API RESTful?


288

Imagine que tiene 2 entidades, Jugador y Equipo , donde los jugadores pueden estar en varios equipos. En mi modelo de datos, tengo una tabla para cada entidad y una tabla de unión para mantener las relaciones. Hibernate está bien para manejar esto, pero ¿cómo podría exponer esta relación en una API RESTful?

Puedo pensar en un par de formas. Primero, podría hacer que cada entidad contenga una lista de la otra, por lo que un objeto Jugador tendría una lista de Equipos a los que pertenece, y cada objeto Equipo tendría una lista de Jugadores que pertenecen a él. Entonces, para agregar un jugador a un equipo, simplemente PUBLICA la representación del jugador en un punto final, algo así como POST /playero POST /teamcon el objeto apropiado como carga útil de la solicitud. Esto me parece el más "RESTful", pero se siente un poco raro.

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

La otra forma en que se me ocurre hacer esto sería exponer la relación como un recurso por derecho propio. Entonces, para ver una lista de todos los jugadores en un equipo determinado, puede hacer un GET /playerteam/team/{id}o algo así y recuperar una lista de entidades de PlayerTeam. Para agregar un jugador a un equipo, POST /playerteamcon una entidad PlayerTeam adecuadamente construida como carga útil.

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

¿Cuál es la mejor práctica para esto?

Respuestas:


129

En una interfaz RESTful, puede devolver documentos que describan las relaciones entre recursos codificando esas relaciones como enlaces. Por lo tanto, se puede decir que un equipo tiene un recurso de documento ( /team/{id}/players) que es una lista de enlaces a jugadores ( /player/{id}) en el equipo, y un jugador puede tener un recurso de documento (/player/{id}/teams) que es una lista de enlaces a equipos de los que es miembro el jugador. Agradable y simétrico. Puede hacer un mapa de las operaciones en esa lista con bastante facilidad, incluso dando a una relación sus propios ID (podría decirse que tendrían dos ID, dependiendo de si está pensando en la relación equipo primero o jugador primero) si eso facilita las cosas . El único aspecto difícil es que también debe recordar eliminar la relación del otro extremo si la elimina de un extremo, pero manejando esto rigurosamente utilizando un modelo de datos subyacente y luego haciendo que la interfaz REST sea una vista de ese modelo lo hará más fácil.

Los ID de relación probablemente deberían basarse en UUID o algo igualmente largo y aleatorio, independientemente del tipo de ID que use para equipos y jugadores. Eso le permitirá usar el mismo UUID que el componente de ID para cada extremo de la relación sin preocuparse por las colisiones (los enteros pequeños no tienen esa ventaja). Si estas relaciones de membresía tienen otras propiedades además del simple hecho de que relacionan a un jugador y un equipo de manera bidireccional, deben tener su propia identidad que sea independiente de los jugadores y los equipos; un GET en la vista del equipo »jugador ( /player/{playerID}/teams/{teamID}) podría hacer una redirección HTTP a la vista bidireccional ( /memberships/{uuid}).

Recomiendo escribir enlaces en cualquier documento XML que devuelva (si está produciendo XML, por supuesto) utilizando atributos XLink xlink:href .


265

Haga un conjunto separado de /memberships/recursos.

  1. REST se trata de hacer sistemas evolucionables, si nada más. En este momento, es posible que sólo se preocupa de que un determinado jugador está en un equipo determinado, pero en algún momento en el futuro, que va a querer anotar que la relación con más datos: ¿cuánto tiempo han estado en ese equipo, quien los remitió a ese equipo, quién es / fue su entrenador mientras estaba en ese equipo, etc., etc.
  2. REST depende del almacenamiento en caché para la eficiencia, lo que requiere cierta consideración para la atomicidad e invalidación de la memoria caché. Si PUBLICA una nueva entidad en /teams/3/players/esa lista se invalidará, pero no desea que la URL alternativa /players/5/teams/permanezca en caché. Sí, diferentes cachés tendrán copias de cada lista con diferentes edades, y no hay mucho que podamos hacer al respecto, pero al menos podemos minimizar la confusión para el usuario que PUBLICA la actualización limitando el número de entidades que necesitamos invalidar en el caché local de su cliente a uno y solo uno en /memberships/98745(vea la discusión de Helland sobre "índices alternativos" en Life beyond Distributed Transactions para una discusión más detallada).
  3. Puede implementar los 2 puntos anteriores simplemente eligiendo /players/5/teamso /teams/3/players(pero no ambos). Asumamos lo primero. Sin embargo, en algún momento, querrá reservar /players/5/teams/una lista de membresías actuales y, sin embargo, podrá hacer referencia a membresías pasadas en algún lugar. Haga /players/5/memberships/una lista de hipervínculos a los /memberships/{id}/recursos, y luego puede agregarlos /players/5/past_memberships/cuando lo desee, sin tener que romper los marcadores de todos los recursos de membresía individuales. Este es un concepto general; Estoy seguro de que puede imaginar otros futuros similares que sean más aplicables a su caso específico.

11
Los puntos 1 y 2 están perfectamente explicados, gracias, si alguien tiene más carne para el punto 3 en la experiencia de la vida real, eso me ayudaría.
Alain

2
La mejor y más simple respuesta IMO gracias! Tener dos puntos finales y mantenerlos sincronizados tiene una serie de complicaciones.
Venkat D.

77
hola fumanchu Preguntas: En el punto final de descanso / membresías / 98745, ¿qué representa ese número al final de la url? ¿Es una identificación única para la membresía? ¿Cómo interactuaría uno con el punto final de la membresía? Para agregar un jugador, ¿se enviaría una POST que contenga una carga útil con {team: 3, player: 6}, creando así el vínculo entre los dos? ¿Qué tal un GET? ¿enviarías un GET a / memberships? player = y / membersihps? team = para obtener resultados? Esa es la idea? ¿Me estoy perdiendo algo? (Estoy tratando de aprender puntos finales relajantes) En ese caso, ¿es realmente útil el id 98745 en membresías / 98745?
aruuuuu

@aruuuuu se debe proporcionar un punto final separado para una asociación con un PK sustituto. Hace la vida mucho más fácil también en general: / membresías / {memberId}. La clave (playerId, teamId) sigue siendo única y, por lo tanto, se puede utilizar en los recursos que poseen esta relación: / teams / {teamId} / players y / players / {playerId} / teams. Pero no siempre es cuando tales relaciones se mantienen en ambos lados. Por ejemplo, Recetas e ingredientes: casi nunca necesitará usar / ingredientes / {ingredienteId} / recetas /.
Alexander Palamarchuk

65

Mapearía tal relación con los sub-recursos, el diseño / recorrido general sería:

# team resource
/teams/{teamId}

# players resource
/players/{playerId}

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

En términos de descanso, ayuda mucho no pensar en SQL y se une, pero más en colecciones, subcolecciones y recorrido.

Algunos ejemplos:

# 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

Como puede ver, no uso POST para colocar jugadores en equipos, sino PUT, que maneja mejor su relación n: n de jugadores y equipos.


20
¿Qué sucede si team_player tiene información adicional como estado, etc.? ¿Dónde lo representamos en su modelo? ¿podemos promocionarlo a un recurso y proporcionar URL para él, al igual que el juego /, jugador /
Narendra Kamma

Hola, pregunta rápida solo para asegurarme de que estoy haciendo esto bien: GET / teams / 1 / players / 3 devuelve un cuerpo de respuesta vacío. La única respuesta significativa de esto es 200 vs 404. La información de la entidad del jugador (nombre, edad, etc.) NO es devuelta por GET / teams / 1 / players / 3. Si el cliente desea obtener información adicional sobre el jugador, debe OBTENER / players / 3. ¿Es todo esto correcto?
Verdagon

2
Estoy de acuerdo con su mapeo, pero tengo una pregunta. Es cuestión de opinión personal, pero ¿qué opinas sobre POST / teams / 1 / players y por qué no lo usas? ¿Ve alguna desventaja / engaño en este enfoque?
JakubKnejzlik

2
POST no es idempotente, es decir, si haces POST / teams / 1 / players n-times, cambiarías n-times / teams / 1. pero mover un jugador a / teams / 1 n-times no cambiará el estado del equipo, por lo que usar PUT es más obvio.
manuel aldana

1
@NarendraKamma Supongo que solo envío statuscomo parámetro en la solicitud PUT. ¿Hay un inconveniente en ese enfoque?
Traxo

22

Las respuestas existentes no explican los roles de consistencia e idempotencia, lo que motiva sus recomendaciones de UUIDs/ números aleatorios para ID y en PUTlugar de POST.

Si consideramos el caso en el que tenemos un escenario simple como " Agregar un nuevo jugador a un equipo ", nos encontramos con problemas de coherencia.

Debido a que el jugador no existe, necesitamos:

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

Sin embargo, si la operación del cliente falla después del POSTto /players, hemos creado un jugador que no pertenece a un equipo:

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

Ahora tenemos un jugador duplicado huérfano /players/5.

Para solucionar esto, podríamos escribir un código de recuperación personalizado que verifique si hay jugadores huérfanos que coinciden con alguna clave natural (por ejemplo Name). Este es un código personalizado que necesita ser probado, cuesta más dinero y tiempo, etc.

Para evitar la necesidad de un código de recuperación personalizado, podemos implementarlo en PUTlugar de POST.

Desde el RFC :

la intención de PUTes idempotente

Para que una operación sea idempotente, debe excluir datos externos como secuencias de identificación generadas por el servidor. Esta es la razón por la cual las personas recomiendan ambos PUTy UUIDs para Ids juntos.

Esto nos permite volver a ejecutar tanto el /players PUTy el /memberships PUTsin consecuencias:

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

Todo está bien y no tuvimos que hacer nada más que volver a intentarlo por fallas parciales.

Esto es más una adición a las respuestas existentes, pero espero que las ponga en el contexto de la imagen más amplia de cuán flexible y confiable puede ser ReST.


En este punto final hipotético, ¿de dónde sacaste el 23lkrjrqwlej?
cbcoutinho

1
rodar la cara en el teclado: no hay nada especial en el 23lkr ... gobbledegook aparte de eso no es secuencial o significativo
Septiembre

9

Mi solución preferida es la creación de tres recursos: Players, Teamsy TeamsPlayers.

Entonces, para obtener todos los jugadores de un equipo, solo vaya al Teamsrecurso y obtenga todos sus jugadores llamando GET /Teams/{teamId}/Players.

Por otro lado, para obtener todos los equipos que un jugador ha jugado, obtenga el Teamsrecurso dentro del Players. Llamada GET /Players/{playerId}/Teams.

Y, para obtener la llamada de relación de muchos a muchos GET /Players/{playerId}/TeamsPlayerso GET /Teams/{teamId}/TeamsPlayers.

Tenga en cuenta que, en esta solución, cuando llama GET /Players/{playerId}/Teams, obtiene una variedad de Teamsrecursos, que es exactamente el mismo recurso que obtiene cuando llama GET /Teams/{teamId}. Lo contrario sigue el mismo principio, obtienes una variedad de Playersrecursos cuando llamas GET /Teams/{teamId}/Players.

En cualquiera de las llamadas, no se devuelve información sobre la relación. Por ejemplo, no contractStartDatese devuelve, porque el recurso devuelto no tiene información sobre la relación, solo sobre su propio recurso.

Para lidiar con la relación nn, llame a GET /Players/{playerId}/TeamsPlayerso GET /Teams/{teamId}/TeamsPlayers. Estas llamadas devuelven la exactitud de recursos, TeamsPlayers.

Este TeamsPlayersrecurso ha id, playerId, teamIdatributos, así como algunos otros para describir la relación. Además, tiene los métodos necesarios para tratar con ellos. GET, POST, PUT, DELETE, etc. que devolverá, incluirá, actualizará, eliminará el recurso de relación.

El TeamsPlayersrecurso implementa algunas consultas, como GET /TeamsPlayers?player={playerId}devolver todas las TeamsPlayersrelaciones que tiene el jugador identificado {playerId}. Siguiendo la misma idea, use GET /TeamsPlayers?team={teamId}para devolver todo lo TeamsPlayersque ha jugado en el {teamId}equipo. En cualquier GETllamada, TeamsPlayersse devuelve el recurso . Se devuelven todos los datos relacionados con la relación.

Al llamar GET /Players/{playerId}/Teams(o GET /Teams/{teamId}/Players), el recurso Players(o Teams) llama TeamsPlayerspara devolver los equipos (o jugadores) relacionados mediante un filtro de consulta.

GET /Players/{playerId}/Teams funciona así:

  1. Encuentra todos los TeamsPlayers que el jugador tiene id = playerId . ( GET /TeamsPlayers?player={playerId})
  2. Recorre los equipos devueltos
  3. Usando el teamId obtenido de TeamsPlayers , llame GET /Teams/{teamId}y almacene los datos devueltos
  4. Después de que finalice el bucle. Devuelve todos los equipos que se pusieron al día.

Puede usar el mismo algoritmo para obtener todos los jugadores de un equipo, al llamar GET /Teams/{teamId}/Players, pero intercambiando equipos y jugadores.

Mis recursos se verían así:

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

Esta solución se basa únicamente en recursos REST. Aunque algunas llamadas adicionales pueden ser necesarias para obtener datos de jugadores, equipos o su relación, todos los métodos HTTP se implementan fácilmente. POST, PUT, DELETE son simples y directos.

Cada vez que se crea, actualiza o elimina una relación, ambos Playersy los Teamsrecursos se actualizan automáticamente.


realmente tiene sentido presentar el recurso TeamsPlayers. Impresionante
vijay

mejor explicación
Diana

1

Sé que hay una respuesta marcada como aceptada para esta pregunta, sin embargo, así es como podemos resolver los problemas planteados anteriormente:

Digamos por PUT

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

Como ejemplo, los siguientes resultados tendrán el mismo efecto sin necesidad de sincronización porque se realizan en un solo recurso:

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

ahora si queremos actualizar varias membresías para un equipo, podríamos hacer lo siguiente (con las validaciones adecuadas):

PUT    /membership/teams/team1/

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

-3
  1. / players (es un recurso maestro)
  2. / teams / {id} / players (es un recurso de relación, por lo que reacciona diferente que 1)
  3. / membresías (es una relación pero semánticamente complicada)
  4. / jugadores / membresías (es una relación pero semánticamente complicada)

Prefiero 2


2
Quizás no entiendo la respuesta, pero esta publicación no parece responder la pregunta.
BradleyDotNET

Esto no proporciona una respuesta a la pregunta. Para criticar o solicitar una aclaración de un autor, deje un comentario debajo de su publicación: siempre puede comentar sus propias publicaciones, y una vez que tenga suficiente reputación podrá comentar cualquier publicación .
Argumento ilegal

44
@IllegalArgument Es es una respuesta y no tendría sentido como un comentario. Sin embargo, no es la mejor respuesta.
Qix - MONICA FUE MAL TRATADA el

1
Esta respuesta es difícil de seguir y no proporciona razones.
Venkat D.

2
Esto no explica ni responde a la pregunta formulada en absoluto.
Manjit Kumar
Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.