RESTful设计:何时使用子资源?

69
设计资源层次结构时,什么情况下应该使用子资源?
我曾经认为,当一个资源不能存在于另一个资源之外时,应该将其表示为其子资源。但是,最近我遇到了这个反例:
- 每位员工在所有公司中都是唯一可识别的。 - 员工的访问控制和生命周期取决于公司。
我将其建模为:/companies/{companyName}/employee/{employeeId} 注意,我不需要查找公司就可以定位员工,那我应该吗?如果我这样做,我就要付出查找我不需要的信息的代价。如果我不这样做,这个URL会错误地返回HTTP 200: /companies/{nonExistingName}/employee/{existingId} 1. 如何表示一个资源“属于”另一个资源? 2. 如何表示一个资源“无法识别”而不依赖于另一个资源? 3. 子资源意味着什么关系,又不适用于哪些关系?

3
尼古拉斯,答案就像美酒一样,你需要慢慢品味。到目前为止,我还没有看到一个明显的胜者,所以我会再等待一段时间,等待新的答案出现。如果没有新的答案,我会选择其中一个现有的答案。 - Gili
6个回答

21
一年后,我得出了以下妥协方案(针对包含唯一标识符的数据库行):
  1. 为所有资源分配一个规范的URI(例如,/companies/{id}/employees/{id})。
  2. 如果一个资源不能没有另一个存在,则应将其表示为子资源;但是,将该操作视为搜索引擎查询。这意味着,而不是立即执行操作,只需返回指向规范URI的HTTP 307("暂时重定向")。这将导致客户端对规范URI重复执行操作。
  3. 您的规范文档应仅公开与您的概念模型匹配的根资源(不依赖于实现细节)。实现细节可能会改变(您的行可能不再是唯一可识别的),但您的概念模型将保持完好无损。在上面的示例中,您会告诉客户端有关/companies但不告诉他们/employees

这种方法具有以下优点:

  1. 它消除了进行不必要的数据库查找的需要。
  2. 它将每个请求的合理性检查数量减少到一个。最多,我必须检查员工是否属于公司,但我不再必须对/companies/{companyId}/employees/{employeeId}/computers/{computerId}执行两次验证检查。
  3. 它对数据库的可扩展性产生了混合影响。一方面,您通过锁定更少的表,在更短的时间内减少了锁定争用。但另一方面,由于每个根资源必须使用不同的锁定顺序,这增加了死锁的可能性。我不知道这是否是净收益或净损失,但我感到安慰的是无法防止数据库死锁,并且所得到的锁定规则更简单易懂和实现。如果有疑问,请选择简单性。
  4. 我们的概念模型保持完好无损。通过确保规范文档仅公开与我们的概念模型匹配的根资源,我们可以自由地在不破坏现有客户端的情况下删除包含实现细节的URI。请记住,只要您的规范声明其结构未定义,就没有任何阻止您在中间URI中公开实现细节。

一个更简单的决策方式是将具有复合键的实体保留为子资源。 - Hossein Shahdoost
@Sub-Zero 这与问题/答案有何关联?我没有看到任何复合键,你看到了吗? - Gili
:D,问题的标题是“何时使用子资源?”,由于资源大多是我们实体的展示,我认为决定是否仅针对具有复合键的实体使用子资源将是一种简单的方法。因为它们需要两个键才能访问,而一个键始终是另一个实体的PK。 - Hossein Shahdoost
1
@Sub-Zero,我认为你的提议存在一些缺点,但无论如何,请将其作为单独的答案发布,而不是在此评论。谢谢。 - Gili
@Gili,规范的重定向不应该是301永久重定向吗?毕竟它总是会重定向,这样可以缓存它。 - Ryall
@Ryall 不一定。今天 /companies/ComputerCentral 可能映射到 /companies/1,但10年后,也许这家公司破产了,一个无关的公司取了它的名字,所以 /companies/ComputerCentral 现在映射到 /companies/2 - Gili

19
这是一个问题,因为它不再明显用户属于哪个公司了。
有时这可能会凸显您的领域模型存在问题。为什么用户要属于一家公司?如果我换公司了,那我就成为一个全新的人了吗?如果我为两家公司工作呢?我是两个不同的人吗?
如果答案是肯定的,那么为什么不采用某个公司唯一的标识来访问用户呢?
例如:用户名: company/foo/user/bar (其中bar是我在特定公司命名空间内唯一的用户名)
如果答案是否定的,那么为什么我不是一个独立的用户(人),而company/users集合只是指向我:<link rel="user" uri="/user/1" />(注意:员工似乎更加适当)。
现在除了您特定的示例之外,当涉及到使用而非所有权时,我认为资源-子资源关系更加合适(这也是为什么您在隐含地为用户标识公司而苦恼的原因)。
我的意思是,users实际上是公司资源的子资源,因为使用是为了定义公司与其雇员之间的关系——另一种说法是:您必须在开始雇佣员工之前定义公司。同样,必须在招聘他们之前定义(出生)用户(人)。

7
希望您的回答更加简洁,但无论如何您已经完美地解答了。关键在于定义:/companies/{companyName}/users/users/{id} 因为查找与公司相关联的用户需要 {companyName} 但查找单个用户则不需要,因此用户是顶级资源。谢谢! - Gili
@Gili:我遇到了类似的问题,你的方法对我很有意义。“/companies/{companyName}/users” 显示属于特定公司的所有用户,“/users” 显示系统中的所有用户。但是,我应该能够将单个用户标识为“/companies/{companyName}/users/{id}”和“/users/{id}”吗?那不是多余的吗? - Daniel
7
@Daniel,这并不是重复的。当有人请求 HTTP GET /companies/{companyName}/users/{id} 时,你应该返回 HTTP 303("See Other") 并指向 /users/{id}。前者是别名,后者是规范URI。 - Gili
3
我甚至不知道那个重定向是否必要。服务器控制URI,客户端不应该知道。如果服务器想在某个点提供直接表示,它可以这样做。这对于客户端来说除了可能的缓存未命中之外并没有任何区别。 - Doug Moscrop

9
您的规则决定资源是否应该被建模为子资源是有效的。您的问题并不是来自错误的概念模型,而是让数据库模型泄漏到REST模型中。
从概念上看,如果一个员工只能存在于公司关系中,则通过组合将其建模为一个公司。因此,只能通过公司来识别员工。现在涉及到数据库,所有员工行都会获得唯一标识符。
我的建议是不要让数据库模型泄漏到概念模型中,因为这样会向API公开基础设施问题。例如,当您决定切换到像MongoDB这样的文档导向数据库时,您可以将员工建模为公司文档的一部分,并且不再具有这个人工唯一ID吗?您想改变API吗?
回答您的额外问题:
如何表示一个资源属于另一个资源?
通过子资源的组成,其他关联通过URL链接。
如何表示一个资源不能没有另一个资源进行标识?
在资源URL中使用两个id值,并确保不让数据库泄漏到API中,通过检查“组合”是否存在。
子资源旨在模拟哪些关系,不是指什么?
子资源非常适合组成,但更普遍地说,它们用于模拟一个资源不能没有父资源存在并始终属于一个父资源的情况。您的规则“当一个资源不能没有另一个资源时,应该将其表示为子资源”是做出此决定的好指导。

8
我几年前曾尝试玩这个游戏,但我不再尝试设计数据库无关的软件。因为成本效益并不划算,我们很少更换数据库,并且总会有一些实现细节泄漏出来(例如,并非所有数据库都使用整数ID)。最好使用当前的数据库尽可能做好工作,这样可以让代码更简单。最后,我可以通过在新数据库架构中存储原始ID来保留向后兼容性。 - Gili
1
如果我的上一条评论给人留下了负面印象,我很抱歉。我同意你的大部分回答。为了性能而牺牲数据库可移植性是一个主观决定,不会真正影响答案。我喜欢你写的有关通过子资源进行组合、通过URL链接进行关联的那一段。 - Gili
“不要让数据库泄漏到API中”是指不要让数据库模型泄漏到我们的API模型中。在这个问题的背景下,这意味着:如果公司->员工的架构最能反映您的概念模型,请不要担心必须加载公司以加载员工。 - saintedlama
没错。我曾担心公司和员工之间存在不必要的耦合,但在这种情况下,它似乎非常适当。 - Gili

6
如果一个子资源在没有其拥有实体的情况下可以唯一识别,那么它就不是子资源,应该有自己的命名空间(例如/users/{user}而不是/companies/{*}/users/{user})。最重要的是:绝对不要使用实体的数据库主键作为资源标识符。这是最常见的错误之一,会让实现细节泄露到外部世界中。您应该始终具有自然业务键(例如用户名或公司编号,而不是用户ID或公司ID)。这样的键的唯一性可以通过唯一约束来强制执行,如果您愿意的话,但实体的主键永远不应离开应用程序的持久层,或者至少不应成为任何服务方法的参数。如果您遵循此规则,您不应该有任何区分组合(/companies/{company}/users/{user})和关联(/users/{user})之间的麻烦,因为如果您的子资源没有自然业务键,以在全局上下文中标识它,则可以非常确定它确实是依赖子资源(或者您必须先创建业务键才能使其在全局上下文中可识别)。

作为一般规则:始终将主键视为持久层的实现细节。 - Kai
16
基于以下原因,我不同意你的看法:1. 社区之所以放弃使用自然业务键是因为它们会随着时间变化而改变。2. 如果您使用非业务键,则没有理由它会发生变化(即使更改了数据库)。3. 自然键使得实现幂等操作变得不可能。某人可以同时调用 PUT /companies/Nintendo/,而另一个人则删除并创建一个新的 Nintendo。如果第一个客户端重试 PUT 操作(幂等性),他无法检测到底层实例已更改。 - Gili
另外,如果您有一个递归资源(考虑标签的层次结构),则不能将业务键用作URL中的键,因为真实的业务键将包含整个路径并可能超过URL长度限制。 - Dave

2
这是您可以解决这种情况的方式之一:

/companies/{companyName}/employee/{employeeId} -> 返回有关员工的数据,还应包括该人的数据

/person/{peopleId} -> 返回有关个人的数据

谈论雇员没有讨论公司是没有意义的,但即使没有公司并且即使他被多家公司雇佣,谈论个人也是有意义的。一个人的存在不取决于他是否被任何公司雇佣,但就业的存在确实取决于公司。

有趣的想法,但它实际上并没有回答问题。这只是因为一个人并不严格等同于员工(这是问题所暗示的),因此您没有回答是否应该能够在没有公司名称的情况下引用员工。 - Gili
@Gili:对于这个问题的答案,不应该;这种区别的重点在于,在谈论员工时,没有谈论公司是没有意义的。与公司没有高度联系的数据应该存在于人力资源中,人力资源不是公司或员工的子资源,而是一个独立的、独立的实体。 - Lie Ryan

1
问题似乎出在没有特定公司,但员工技术上属于某个公司或组织,否则他们可能被称为流浪汉或政客。成为员工意味着在某个地方存在公司/组织关系,但不是具体的。此外,员工可以为多个公司/组织工作。当需要特定的公司上下文时,您的原始作品就是/companies/{companyName}/users/{id}
假设您想知道ira/rsp/pension的EmployerContribution,您将使用: /companies/enron/users/fred/EmployerContribution 您将获得enron贡献的具体金额(或$0)。
如果您想要来自fred所工作过的任何或所有公司的EmployerContribution
您不需要具体的公司才能有意义。/companies/any/employee/fred/EmployerContribution 其中“any”显然是一个抽象或占位符,当员工的公司无关紧要但成为员工时,您需要拦截“公司”处理程序以防止数据库查找(尽管不确定为什么公司不会被缓存?有多少个?)
您甚至可以更改抽象以表示Fred在过去10年中受雇的所有公司。 /companies/last10years/employee/fred/EmployerContribution

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