解决主键不属于您的业务领域的事实


25

在几乎所有情况下,主键都不是您的业务领域的一部分。当然,您可能会拥有一些具有唯一索引的重要的面向用户的对象(UserName针对用户或OrderNumber订单),但是在大多数情况下,除了单个或多个值外,无需通过单个值或一组值公开标识域对象。管理用户。即使在那些特殊情况下,尤其是在使用全局唯一标识符(GUID)的情况下,您也会希望或希望使用备用键而不是公开主键本身。

因此,如果我对域驱动设计的理解是准确的,则不需要主键,因此也不必公开主键,这是很好的方法。他们很丑,使我的风格狭窄。但是,如果我们选择在域模型中不包括主键,则会产生以下结果:

  1. 天真的,仅从域模型组合中得出的数据传输对象(DTO)将没有主键
  2. 传入的DTO将没有主键

因此,可以肯定地说,如果您真的要保持纯洁并在域模型中消除主键,那么您应该准备能够根据该主键上的唯一索引来处理每个请求吗?

换句话说,在删除领域模型中的PK之后,下列哪种解决方案是处理识别特定对象的正确方法?

  1. 能够通过其他属性识别需要处理的对象
  2. 在DTO中获取主键;即,从持久性映射到域时消除PK,然后从域映射到DTO时重新组合PK?

编辑:让我们具体化。

说我的域模型VoIPProvider,其中包括像场NameDescriptionURL,以及引用喜欢ProviderTypePhysicalAddressTransactions

现在让我们说我想构建一个允许特权用户管理VoIPProvider的Web服务。

在这种情况下,用户友好的ID可能没有用;毕竟,VoIP提供商是出于商业原因其名称在计算机意义上趋于不同甚至在人性意义上趋于不同的公司。因此,可以说一个唯一VoIPProvider性完全由决定(Name, URL)。因此,现在让我们说我需要一种方法,PUT api/providers/voip以便特权用户可以更新VoIP提供程序。他们发送一个VoIPProviderDTO,其中包括的很多但不是全部字段VoIPProvider,包括可能会展平的字段。但是,我看不懂他们的想法,他们仍然需要告诉我我们正在谈论的提供商。

看来我有2个(也许3个)选项:

  1. 在我的域模型中包含主键或备用键,并将其发送给DTO,反之亦然
  2. 通过唯一索引确定我们关注的提供商,例如 (Name, Url)
  3. 引入某种总是可以在持久层,域和DTO之间映射的中间对象,而不会暴露有关持久层的实现细节-例如,在从域到DTO并返回时引入内存中的临时标识符,

1
值得深思的是:当存在良好的业务密钥时,如果使用代理PK,通常与领域专家的沟通就会变得贫困。看来我们最终为ORM框架工作,而不是相反。
图兰斯·科尔多瓦

@ user61852很好,无论ORM是什么,即使您确实是底层用户,在数据库层的实现中仍然需要主键。因此,我同意代理PK比特定持久性机制使用的实际PK更具优势,但是,如果该PK确实表示有意义的业务对象,则它必定是唯一的,因此至少具有一个唯一的与业务相关的属性定义不是吗
tacos_tacos_tacos 2014年

1
代理的所有优点都与计算机有关,而与人无关。
图兰斯·科尔多瓦

2
@ user61852:我同意100%(我写了不同的东西吗?)。对于通信方式,请使用“业务密钥”。也为任何业务密钥添加唯一约束。但是,请避免使用业务密钥来实际实现数据库引用。
布朗

2
业务密钥永远是唯一的-直到不是。如果在这种情况下将业务密钥用作主要密钥,则业务规则的更改会破坏更多内容。
psr

Answers:


31

这就是我们解决此问题的方式(自15年来,甚至还没有发明“域驱动设计”一词的时候):

  • 在将域模型映射到特定编程语言的数据库实现或类模型时,您具有简单一致的规则,例如“对于映射到关系表的每个域对象,主键均为” TablenameID”。
  • 这个主键是完全人工的,它始终具有相同的类型,并且没有业务意义-只是一个替代键
  • 域模型的“图形版本”(用于与域专家交谈的版本)不包含主键。您没有将它们直接暴露给专家(但是您将它们暴露给了实际上为系统实现代码的任何人)。

因此,只要您出于技术目的需要主键(例如将关系映射到数据库),就可以使用主键,但是只要您不想“看到它”,就可以将抽象级别更改为“领域专家模型” ”。您不必维护“两个模型”(一个带有PK,一个不带有PK)。相反,仅维护一个没有PK的模型,并使用代码生成器为您的数据库创建DDL,DDL将根据映射规则自动添加PK。

请注意,除了替代,这并不禁止添加任何“业务关键字”,例如附加的“ OrderNumber” OrderID。从技术上讲,这些业务密钥在映射到数据库时成为备用密钥。只是避免使用这些来创建对其他表的引用,如果可能的话总是喜欢使用代理键,这将使事情变得容易得多。

在您的评论中:使用代理键来标识记录不是与业务相关的操作,它纯粹是技术操作。为了清楚起见,请看您的示例:只要不定义其他唯一约束,就可以有两个VoIPProvider对象,它们具有(名称,URL)相同的组合,但是具有不同的VoIPProviderID。


从返回的持久性对象(实体或表行模型等)中获取域对象,转到DTO,然后又将其取回,再回到持久性,该怎么办?是否仅通过需要对每个持久性操作进行解析的代理键(即,面向业务的唯一性定义)来完成?
tacos_tacos_tacos 2014年

1
@tacos_tacos_tacos:让我们继续您的VoIPProvider示例。实际上,我至少在“实现方面”会在您的DTO中添加一个“ VoIPProviderID”(如果您还为您的域专家提供了图形版本,那么我可能不会在此处显示出来)。出于更新目的,标识特定VoIPProvider的标准方法应该是从数据库中提取数据时检索到的“ VoIPProviderID”。如果您的API用户更喜欢使用(名称,URL)标识,请另外提供。...
布朗

...如果性能似乎变成一个实际的,可衡量的问题,则还可以考虑将映射(名称,URL)缓存到VoIPProviderID的某个位置。但是我不建议过早实施这种优化。
布朗

1
自然键有时看起来确实很引人注目,但是它很容易被烧毁(例如,“哎呀,现在我有多个租户,而不再是唯一的”)。
Casey 2014年

1
@corsiKa:在OP要求的情况下,我强烈建议您使用一个纯自动生成的密钥“ OrderID”(不会打印在任何收据上,而仅用于数据库引用之类的内部事物),以及一个单独的业务密钥“ OrderNumber”(例如,可以包含类似于当年的年份,可以用于排序和过滤,之后可以更改/更正,并且可以打印在收据上)。OP要求“域驱动设计”,“ OrderNumber”是域模型的一部分,而“ OrderID”只是实现细节。
布朗

4

您需要能够通过某个唯一索引来识别许多对象,而不是主键是什么(或至少暗示存在一个主键)。

可以使用唯一索引,因此您可以进一步限制数据库架构,而不是批量替换PK。如果您不公开PK,是因为它们很丑陋,但却公开了唯一的密钥……您实际上并没有做任何不同的事情。(我假设您在这里没有混合使用PK和身份列?)


当我要执行涉及持久化域对象的特定实例的操作时,我需要能够显式地(通过某种键,包括主键或唯一的备用用户友好ID)包含在DTO中。到该表,也可能是表上的索引)或隐式地(通过其值唯一标识特定记录的字段的组合)...而且,从实际意义上讲,我需要在DTO中发送如果我要以其他方式进行某种形式的更改跟踪(对于标识记录的所有字段,OriginalVal与NewVal),不是吗?
tacos_tacos_tacos 2014年

显式v隐式问题不是一样的区别吗?您可以拥有跨多个列的PK,就像唯一索引一样。我认为它们之间没有任何区别。
gbjbaanb

当然,我们可以在多个列上使用PK。但是对我而言,它正在泄漏有关数据库(存储)的某些内容,而这些内容与业务实体的灵魂无关。如果某些业务实体字段元组恰好跨越数据库的PK,那就太好了。但这不一定是相反的方式吗?
tacos_tacos_tacos 2014年

您想得太多了。唯一索引与PK一样,是数据库架构的人工产物。这样想吧-PK只是第一个(或主要)唯一索引。之所以称为“特殊”,是因为您通常只需要1个这样的索引。
gbjbaanb 2014年

是的,但是任何有意义的领域对象都应该至少可以从至少一个与业务相关的领域中被识别出来,不是吗?在数据库中定义索引的事实更多是出于性能方面的考虑,而不是为了轻松查询数据库...我宁愿使用一栏式PK而不是使用六栏式唯一索引,并且它们确实具有不同的用途-为了方便DBA / DBD,也可以使用PK(或字段数少的索引),对吗?
tacos_tacos_tacos 2014年

4

前端没有主键,后端就不容易知道您要发送的内容。要解决此问题,您将需要大量的额外工作来解析数据,这将损害性能,并且可能比将键附加到每个项目上要花费更多的时间和丑陋的处理。

例如,假设我要在应用程序中编辑消息;在不附加主键的情况下,应用程序如何知道我要编辑的消息?编辑对象一直都在发生,没有键就几乎不可能做到。但是,如果您有不应该编辑的对象,则如果您认为它会分散注意力,请跳过该键,但是在这里拥有主键可以提高性能。


那么,该消息是一个极端的例子,但即使如此,我们也知道MessageSenderMessageRecipientTimeSent-这应该是唯一的。
tacos_tacos_tacos 2014年

1
@tacos_tacos_tacos,那么如何为其他表创建FK?它应该是MessageSenderId,它可能映射到UserId上的Users表。您不希望将UserName用作表之间的键,因为这可能会更改并成为维护的噩梦。这就是为什么您通常只使用主键而不是另一列来联接表的原因(当然也有例外)。此数据库结构仍必须执行。现在,您始终可以为您的应用程序使用CQRS模型...在这种情况下,规则会更改。特别是如果您还使用事件源。
CaffGeek

4

我们使用与业务无关的PK的原因是为了确保我们的系统具有一种简单而一致的方法来确定用户的需求。

我看到您回复了以下评论:MessageSender,MessageRecipient,TimeSent(用于消息)。您仍然可以通过这种方式保持模棱两可(例如,系统生成的消息在经常发生的事情上触发)。您将如何在此处验证MessageSender和MessageRecipient?假设您使用FirstName,Lastname,DateOfBirth验证了他们,那么最终您将遇到这样的情况,即同一天有两个人出生,并且名字完全相同。更不用说您会遇到这样的情况,您会收到一条名为的消息tacostacostacos-America-1980-Doc Brown-France-1965-23/5/2014-11:43:54.003UTC+200。那是一个名字的怪兽,您仍然不能保证只有其中之一。

我们使用主键的原因是因为我们知道,无论输入什么数据,它在软件的生命周期中都是唯一的,并且我们知道这将是可预测的格式(如果您的上述键带有破折号,会发生什么情况在用户名中?您的整个系统都会变成垃圾)。

您无需向用户显示ID。您可以将其隐藏(如果需要,可以通过URL隐藏)。

PK之所以如此有用的另一个原因是,您可以从上述内容中得出一些结论:PK使得它成为可能,因此您不必强制计算机解释用户生成的代码。如果中文用户使用您的代码并输入一堆中文字符,则您的代码突然不需要内部使用它们,而只需使用系统生成的Guid。如果您有输入阿拉伯文字的阿拉伯用户,则您的系统不必内部进行处理,但基本上可以忽略它们的存在。

正如其他人所说,Guid可以内部存储为固定大小。您知道自己的工作方式,并且可以普遍使用。您无需针对如何创建和保存标识符创建设计规则。如果您的系统仅使用名称的前10个字母,则看不到Michael Guggenheimer和Michael Gugstein之间的任何区别,并且会混淆这2个字符。如果将其剪切为任意长度,则可能会感到困惑。如果限制用户输入,则可能会遇到用户限制问题。

当我查看现有系统(例如Dynamics CRM)时,它们还使用内部密钥(PK)让用户调用单个记录。如果用户的查询不涉及ID,则他们将返回可能的答案数组,并让用户从中进行选择。如果有任何歧义的机会,他们将为用户提供选择。

最后,它的隐蔽性也带来了一定的安全性。如果您不知道记录ID,则唯一的选择就是猜测它。如果该ID容易猜到(因为其组成信息可公开获得),则任何人都可以更改它。您甚至可以指导用户通过经典的CSRF或XSS方法对其进行更改。现在,很明显,在发布实时版本之前,您的安全性应该已经得到考虑和减轻,但是您仍然应该加大其安全性,以免发生潜在的滥用。


1

在发布外部系统的标识符时,您应该仅提供URI,或者提供与URI具有相同属性的键或一组键,而不是直接公开数据库主键(从此以后,我将指代URI或具有与URI相同的属性的密钥或一组密钥,即URI,换句话说,下面的URI不一定表示RFC 3986 URI)。

URI可能包含或可能不包含对象的主键,并且实际上可能包含或可能不包含备用键。没关系。重要的是,只有生成URI的系统才被允许拆分或组合URI,以形成对所引用对象的理解。外部系统应始终使用URI作为不透明标识符。普通用户是否可以识别URI的一部分实际上是数据库代理密钥,还是由多个业务密钥组成一堆,或者实际上是这些值的base64,都没关系。这些无关紧要。重要的是,不需要外部系统来了解标识符使用标识符的含义。永远都不需要外部系统解析标识符中的组件,或者将标识符与其他标识符组合起来以引用系统中的某些内容。

使用GUID可以满足其中的一些条件,但是像GUID这样的标识符即使在您的系统内也很难被重新引用回该对象,因此,即使对于您的系统,像GUID一样,即使对于您的系统来说也是不透明的,才应使用客户端真正解析URI /标识符的键构成安全风险。

回到您的VoIP示例,说VoIP提供商可以通过(VoIPProviderID)或(名称,URL)或(GUID)唯一地确定。当外部系统需要更新VoIP提供程序时,它可以仅传递PUT / provider / by-id / 1234或PUT /provider/foo-voip/bar-domain.comPUT /3F2504E0-4F89-41D3-9A0C-0305E82C3301并且您的系统将了解外部系统想要更新VoIPProvider。这些URI是由您的系统生成的,只有您的系统需要了解它们的含义相同。外部系统应该只将URI中的所有内容都视为a PUT <whatever>

假设您具有用于不同VoIP提供程序的数据,这些数据存储在具有不同架构的不同表中(因此,完全不同的密钥集根据它们存储在哪个表中来标识每个VoIP提供程序)。当您拥有URI时,外部系统可以统一访问它们,而无需考虑系统如何识别特定的VoIP提供商。对于外部系统,它们只是不透明的指针。

当您的系统使用URI以这种方式引用对象时,您不会泄漏有关如何实现系统的任何信息。您生成URI,客户端将其简单地传递回给您。


0

我将不得不针对这一极其不准确和天真的声明:

在这种情况下,可能用户友好的ID可能没有用。毕竟,VoIP提供商是出于商业原因其名称在计算机意义上趋于不同甚至在人类意义上也趋于足够不同的公司。

由于名称经常更改,因此名称作为键很糟糕公司在其一生中可能有很多名字,公司可能会合并,拆分,再次合并,为特定的税收目的创建不同的子公司,该子公司有0名员工,但所有客户随后从完全不同的子公司雇用员工。

然后,我们进入一个事实,即公司名称甚至没有远程唯一性,如具有里程碑意义的Apple vs. Apple所示

一个好的对象关系映射器或框架应该抽象掉主键并使它们不可见,但是它们在那里,通常是唯一标识数据库中对象的唯一方法。

作为参考,我更喜欢django的处理方式:

class VoipProvider(models.Model):
    name=fields.TextField()
    address=fields.TextField()

class Customer(models.Model):
    name=fields.TextField()
    address=fields.TextField()
    voipProvider=fields.ForeignKeyField(VoipProvider)

通过这种方式,可以使用以下代码在代码中访问客户提供者的详细信息:

myCustomer.voipProvider.name #returns the name of the customers VOIP Provider.

虽然主键/外键不可见,但是它们在那儿,可以用来访问项目,但可以抽象出来。


您是绝对正确的,但是我想我的意思是“在某些领域中,也许有一个自然的元组,它们始终是唯一的”。
tacos_tacos_tacos 2014年

0

我认为我们仍然经常从数据库的角度看待这个问题:哦,没有自然键,所以我们需要创建一个代理键。哦,不,我们无法将代理密钥公开回域对象,这是泄漏的。

但是,有时这是一种更好的态度:如果业务(域)对象没有自然键,那么也许应该给它一个自然键。这是一个两方面的业务领域问题:首先,即使在没有数据库的情况下,事物也需要标识。其次,尽管我们试图假装持久性是某些领域不可见的抽象概念,但现实是持久性仍然是一个业务概念。显然,存在一些问题,即数据库不支持所选的自然键作为主键(例如,某些系统上的GUID)-在这种情况下,您将需要添加代理键。

因此,您最终处于一个非常相似的位置,例如,您的客户拥有一个整数ID,但是您不会感到不适,因为它已经从DB泄漏到域,而您却感到高兴,因为企业已同意应为所有客户分配一个ID,并且您必须将其持久保存到数据库中。您仍然可以随意使用代理,例如支持重命名客户ID。

这种方法还意味着,如果域对象到达持久层并且没有ID,则它可能是某种值对象,因此不需要ID。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.