如何在 RESTful API 中处理多对多关系?

351
想象一下你有两个实体,玩家(Player)和团队(Team),其中玩家可以在多个团队中。在我的数据模型中,我为每个实体都有一个表,并有一个连接表来维护它们之间的关系。Hibernate很擅长处理这种情况,但如何在RESTful API中公开这种关系呢?
我可以想到几种方法。首先,我可能会让每个实体包含另一个实体的列表,因此玩家对象将有一个属于它的团队列表,而每个团队对象将有一个属于它的玩家列表。因此,要将玩家添加到团队中,您只需将该玩家的表示法POST到一个端点,例如POST /player或POST /team,并将适当的对象作为请求的有效负载。看起来这是最符合"RESTful" 的方式,但感觉有点奇怪。
/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'
}

另一种我能想到做此事的方法是将关系作为资源单独展示。因此,如果要查看给定团队上所有球员的列表,则可以执行 GET /playerteam/team/{id} 或类似的操作,并返回PlayerTeam实体的列表。要向团队添加球员,请 POST /playerteam 并将正确构建的PlayerTeam实体作为有效负载。
/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'
]

这方面的最佳实践是什么?

7个回答

301

创建一个独立的/memberships/资源集。

  1. 如果没有其他要求,REST至少是关于创建可演化系统。此时,你可能只关心特定球员加入了哪个队伍,但在未来某个时刻,您将想要注释该关系更多数据:他们在这个团队的时间有多长,谁引荐他们到达这个团队,他们在该团队中的教练是谁等等。
  2. 对于效率,REST依赖于缓存,这需要考虑缓存原子性和失效。如果您向/teams/3/players/ POST新实体,则该列表将无效,但您不希望备用URL /players/5/teams/保持缓存。是的,不同的缓存将具有不同年龄的每个列表的副本,我们对此无能为力,但我们至少可以通过将需要使客户端本地缓存中的实体数量最小限度地减少到一个而且只有一个,即/memberships/98745 来限制更新的混乱(有关更详细的讨论,请参见Helland在《超越分布式事务的生活》中对“备用索引”的讨论)。
你可以通过选择/players/5/teams/teams/3/players(但不能同时选择)来实现上述两点。让我们先假定前者。然而,在某个时候,你会想要保留/players/5/teams/以列出当前成员资格,并在其他地方引用过去的成员资格。将/players/5/memberships/设为指向/memberships/{id}/资源的超链接列表,然后你可以随时添加/players/5/past_memberships/,而不必打破每个人对单个成员资格资源的书签。这是一个通用概念。我相信你可以想象出其他类似的未来,更适用于你的特定情况。

14
第1点和第2点已经解释得非常好了,谢谢。如果有人能提供更多关于第3点的现实经验,那将对我有所帮助。 - Alain
2
在我看来,这是最好和最简单的答案,谢谢!拥有两个端点并保持它们同步会带来许多复杂性。 - Venkat D.
9
嗨,fumanchu。问题:在REST端点/memberships/98745中,URL末尾的数字代表什么?它是会员的唯一ID吗?如何与会员资格端点进行交互?要添加玩家,是否应该发送一个包含{ team: 3,player: 6 }的有效载荷的POST请求,从而创建两者之间的链接?那GET呢?是否应该向/memberships?player=和/memberships?team=发送GET以获得结果?这是正确的思路吗?我有什么遗漏的吗?(我正在尝试学习RESTful端点)如果这样,那么在memberships/98745中的ID 98745是否真的有用?回答:在REST端点/memberships/98745中,URL末尾的数字表示成员身份的唯一ID。可以与会员资格端点进行交互,例如使用HTTP方法如POST、GET等进行操作。要添加玩家,请发送一个包含{ team: 3,player: 6 }的有效载荷的POST请求,从而创建两者之间的链接。当然也可以通过GET请求向/memberships?player=和/memberships?team=发送查询参数以获得结果。在/memberships/98745中,ID 98745是非常有用的,因为它可以让您快速找到特定的成员身份记录。 - aruuuuu
@aruuuuu 应该为关联提供单独的终端点,并使用替代主键。这将使生活更加轻松,例如:/memberships/{membershipId}。关键(playerId、teamId)保持唯一,因此可以在拥有此关系的资源上使用:/teams/{teamId}/players 和 /players/{playerId}/teams。但并不总是在两侧维护此类关系。例如:食谱和成分:您几乎永远不会需要使用 /ingredients/{ingredientId}/recipes/。 - Alexander Palamarchuk
@fumanchu,你能回答一下aruuuuu的问题吗?我也有同样的疑问。 - Suraj Jain
3
如果您认为此处的“Membership”与https://dev59.com/_mw15IYBdhLWcg3w6f80#51748211中解释的“TeamPlayers”相等,则这两个答案非常相似和互补,并且都很棒。但是,对于复杂的API,我建议不要对同一概念使用太多名称(例如,将Membership保留给用户成员身份)。 api/teamplayers/<id> 完全可以。 - natbusa

149
在RESTful接口中,您可以通过将关系编码为链接来返回描述资源之间关系的文档。因此,一个团队可以被视为具有文档资源(/team/{id}/players),该资源是指向团队中球员(/player/{id})的链接列表,而球员可以拥有文档资源(/player/{id}/teams),该资源是指向球员所属团队的链接列表。非常对称。您可以轻松地映射该列表上的操作,甚至为关系赋予自己的ID(如果这样做可以使事情更加容易,可以说它们将有两个ID,具体取决于您是首先考虑团队还是球员)。唯一棘手的部分是,如果从一个端点删除了关系,则必须记住从另一端点也删除该关系,但是通过使用底层数据模型严格处理这一点,然后使REST接口成为该模型的视图将使这一点更加容易。
关系ID可能应基于UUID或类似长且随机的东西,而与用于团队和球员的ID类型无关。这将使您能够在不担心冲突的情况下使用相同的UUID作为关系每个端点的ID组件(小整数没有这种优势)。如果这些成员关系除了它们以双向方式关联球员和团队的事实之外还具有任何属性,则它们应具有独立于球员和团队的自己的身份;对球员»团队视图(/player/{playerID}/teams/{teamID})的GET请求可以然后重定向到双向视图(/memberships/{uuid})。

如果你正在生成XML文档,我建议使用XLinkxlink:href属性来编写链接。


76
我会使用子资源来映射这样的关系,一般的设计/遍历方式如下:
# 球队资源
/teams/{teamId}
# 球员资源 /players/{playerId}
# 球队/球员子资源 /teams/{teamId}/players/{playerId}

在RESTful术语中,它有助于我们不再考虑SQL和连接,而更多地考虑集合、子集合和遍历。

以下是一些示例:

# 获取在团队1中的球员3,或者仅检查球员3是否在该团队中(200 vs. 404)
GET /teams/1/players/3
# 获取也在团队3中的球员3 GET /teams/3/players/3
# 将球员3添加到团队2中 PUT /teams/2/players/3
# 获取球员3所有参加的团队 GET /players/3/teams
# 从团队1中撤回球员3(比赛前出现醉酒情况) DELETE /teams/1/players/3
# 团队1找到了一个还未注册联赛的替代球员 POST /players # 从负载中获取id,然后正式将其分配到团队1中 PUT /teams/1/players/44
如您所见,我不使用POST将球员放入团队中,而是使用PUT,这种方法更好地处理了球员和团队的n:n关系。

23
如果team_player有额外的信息,比如状态等,那么在你的模型中该如何表示?我们能否将其提升为资源,并为其提供URL,就像game/、player/一样? - Narendra Kamma
手册,您在需要获取所有或获取一个结果时为什么不使用/players和/player? - jjwdesign
2
我同意您的映射,但有一个问题。这是个人观点的问题,但您认为POST /teams/1/players怎么样?为什么不使用它?您是否认为这种方法存在任何缺点或误导性? - JakubKnejzlik
3
POST不具有幂等性,即如果您进行n次 POST /teams/1/players,您将更改 n 次 /teams/1。但是,将球员移动到 /teams/1 n 次不会更改团队的状态,因此使用PUT更为明显。 - manuel aldana
1
@NarendraKamma 我猜只需将 status 作为 PUT 请求的参数发送即可?这种方法有什么不利之处吗? - Traxo
显示剩余5条评论

35

我建议创建三个资源:PlayersTeamsTeamsPlayers 来解决问题。

要获取一个团队的所有球员,只需访问 Teams 资源,并通过调用 GET /Teams/{teamId}/Players 获取其所有球员。

另一方面,要获取球员曾在哪些团队效力过,需要访问 Players 中的 Teams 资源。调用 GET /Players/{playerId}/Teams

而要获取多对多关系,可以调用 GET /Players/{playerId}/TeamsPlayersGET /Teams/{teamId}/TeamsPlayers

请注意,在此解决方案中,当您调用 GET /Players/{playerId}/Teams 时,您会得到一个 Teams 资源数组,这与调用 GET /Teams/{teamId} 时得到的资源完全相同。反之亦然,当您调用 GET /Teams/{teamId}/Players 时,您会得到一个 Players 资源数组。

在任何一种调用中,不返回有关关系的信息。例如,不返回 contractStartDate,因为所返回的资源仅包含有关自身资源的信息,不包含关系信息。

要处理 n-n 关系,请调用 GET /Players/{playerId}/TeamsPlayersGET /Teams/{teamId}/TeamsPlayers。这些调用将返回精确的资源,TeamsPlayers

这个 TeamsPlayers 资源具有 idplayerIdteamId 属性以及一些其他描述关系的属性。此外,它还具有处理这些属性所需的方法。如 GET、POST、PUT、DELETE 等,这些方法将返回、包括、更新、删除关系资源。

TeamsPlayers 资源实现了一些查询,例如 GET /TeamsPlayers?player={playerId},以返回标识为 {playerId} 的球员拥有的所有 TeamsPlayers 关系。遵循同样的思路,使用 GET /TeamsPlayers?team={teamId} 返回所有曾经在 {teamId} 团队中效力过的 TeamsPlayers

在任何一种 GET 调用中,都会返回关系相关的 TeamsPlayers 资源。

当调用 GET /Players/{playerId}/Teams(或 GET /Teams/{teamId}/Players)时,资源 Players(或 Teams)会通过查询过滤器调用 TeamsPlayers 来返回相关的团队(或球员)。

GET /Players/{playerId}/Teams 的工作原理如下:

  1. 查找所有 player 具有 id = playerIdTeamsPlayers。(调用 GET /TeamsPlayers?player={playerId}
  2. 循环返回的 TeamsPlayers
  3. 使用从 TeamsPlayers 获得的 teamId,调用 GET /Teams/{teamId} 并存储返回的数据
  4. 循环结束后,
    /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',
    }
    

    这个解决方案仅依赖于REST资源。尽管可能需要一些额外的调用来从球员、团队或它们之间获取数据,但所有HTTP方法都很容易实现。POST、PUT、DELETE操作简单直接。

    每当创建、更新或删除关联关系时,PlayersTeams资源都会自动更新。


引入“团队球员资源”确实是有意义的。太棒了! - vijay
最佳解释 - Diana
1
这为什么不是被接受的或者最受欢迎的答案呢?在我看来,这引入了一个新的独立资源,描述了实际解决了所有提到的问题。对吧!现在当我需要更多细节时,我会使用这个新增的资源。除此之外,我会像提到的那样使用简单的一级嵌套资源。谢谢。 - Anddo
非常实用的解释,我会选择这个选项来解决类似的问题。 - Darwin

25

现有的答案没有解释一致性和幂等性的作用-这些推荐了使用UUIDs /随机数来进行标识并使用PUT而不是POST方法。

如果我们考虑像“将新球员添加到团队”这样的简单场景,我们会遇到一致性问题。

因为球员不存在,我们需要:

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

然而,如果在向/players发送POST请求之后,客户端操作失败,我们就创建了一个不属于任何团队的玩家:

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

现在我们有一个孤立的重复玩家在/players/5

为了解决这个问题,我们可以编写自定义恢复代码来检查是否存在与某些自然键(例如Name)匹配的孤立玩家。这是需要测试、花费更多资金和时间等等的自定义代码。

为避免需要自定义恢复代码,我们可以实现PUT而不是POST

RFC中:

PUT的意图是幂等的

为使操作幂等,它需要排除服务器生成的 id 序列等外部数据。这就是人们建议一起使用PUTUUID作为Id的原因。

这样我们就可以重新运行/players/membershipsPUT而没有任何后果:

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

一切都很好,我们只需要重试部分失败即可,无需采取任何其他行动。

这只是对现有答案的补充,我希望它能将它们放入更大的背景中,展示ReST可以有多么灵活和可靠。


在这个假设的端点中,你从哪里得到了 23lkrjrqwlej - cbcoutinho
4
滚键盘 - 关于23lkr...这些胡言乱语,除了它不是连续或有意义的之外,没有什么特别的。 - Seth
如果客户端在重试之前退出怎么办?如果一个玩家没有团队就无法存在,那么这是否应该在服务器上进行事务处理? - Eladian
@Eladian,我认为你在考虑分布式事务锁定的问题。如果没有锁定,那么任何故障和传递容错编排的事务基本上都归结为验证和重试,客户端负责可靠地持久化不完整的系统状态。人们已经采用的一种方法是将消息事务到某个可靠的、持久的队列或事件流中;如果接收者按照我上面提到的方式行事,这非常简单。 你问了一个绝对庞大的问题,有很多关于它的书籍 :) - Seth
1
回答不错,但是你的第一个 POST /players { "Name": "Murray" } 应该返回 201 (已创建) 和 Location 头部,而不是 302。 - fxrobin
感谢评论 - 在2021年这可能是个好点子,我已更新代码(标题始终被省略以增加清晰度)。从2015/2016的角度来看,我会很感激一个POST API返回200、403、404或500之外的任何其他东西。302是一个代码,我可能真的能说服同事使用 :) 202太复杂了。我知道这不是理想的,但这是我的经验。这也是为什么我引用了RFC的原因 - 因为人们认为幂等性是晦涩难懂的巫术。 - Seth

2
尽管有一个被标记为已采纳的答案,但是我知道这个问题还存在一些争议。以下是我们解决之前提出的问题的方法:
假设是对于PUT方法:
PUT    /membership/{collection}/{instance}/{collection}/{instance}/

作为一个例子,以下操作都针对单个资源进行,因此不需要同步即可产生相同的效果:
PUT    /membership/teams/team1/players/player1/
PUT    /membership/players/player1/teams/team1/

现在如果我们想为一个团队更新多个会员,我们可以按照以下方式操作(带有适当的验证):

PUT    /membership/teams/team1/

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

-5
  1. /players(是一个主资源)
  2. /teams/{id}/players(是一个关系资源,因此与1有所不同)
  3. /memberships(是一个关系资源,但语义上比较复杂)
  4. /players/memberships(是一个关系资源,但语义上比较复杂)

我更喜欢第2个。


4
也许我只是不理解答案,但这篇文章似乎没有回答问题。 - BradleyDotNET
这并没有提供问题的答案。如果要批评或请求作者澄清,请在他们的帖子下留言 - 您始终可以在自己的帖子上发表评论,并且一旦您拥有足够的声望,您将能够评论任何帖子 - Illegal Argument
4
“@IllegalArgument”这是一个回答,如果以评论的形式出现就没有意义。然而,这并不是最好的回答。 - Qix - MONICA WAS MISTREATED
1
这个答案难以理解,也没有提供原因。 - Venkat D.
3
这根本没有解释或回答所问的问题。 - Manjit Kumar
如果你理解问题领域,这确实可以完整地回答问题。不幸的是,看起来 MoaLai 不是以英语为母语的人,所以不能很容易地详细说明。 - Seth

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接