什么时候原始的迷恋不是代码的味道?


22

最近,我读了很多文章,将原始的痴迷描述为一种代码气味。

避免原始痴迷有两个好处:

  1. 它使域模型更加明确。例如,我可以与业务分析师讨论邮政编码,而不是包含邮政编码的字符串。

  2. 所有验证都放在一个地方,而不是整个应用程序。

那里有很多文章描述什么是代码气味。例如,我可以看到为这样的邮政编码除去原始的困扰的好处:

public class Address
{
    public ZipCode ZipCode { get; set; }
}

这是ZipCode的构造函数:

public ZipCode(string value)
    {
        // Perform regex matching to verify XXXXX or XXXXX-XXXX format
        _value = value;
    }

您将打破DRY原则,将验证逻辑放在所有使用邮政编码的地方。

但是,以下对象呢?

  1. 出生日期:检查是否大于预期且小于今天。

  2. 薪金:检查是否大于或等于零。

您将创建DateOfBirth对象和Salary对象吗?好处是您可以在描述域模型时谈论它们。但是,这是过度工程的一种情况,因为没有太多的验证。是否有一条规则描述了何时以及何时不消除原始的困扰,或者如果可能的话,您应该始终这样做吗?

我想我可以创建一个类型别名而不是一个类,这将有助于上面的第一点。


8
“您将打破DRY原则,将验证逻辑放在任何使用邮政编码的地方。” 那是不对的。数据输入到模块中后,应立即进行验证。如果有一个以上的“切入点”验证应该是一个可重复使用的单元,并且不需要为(也不应该)的DTO ...
蒂莫西脚轮

1
您如何为DateOfBirth的构造函数提供“ mindate”和“ today's date”以进行检查?
卡莱斯(Caleth)'18

11
创建自定义类型的另一个好处是类型安全。如果您有SalaryDistance反对的对象,则不能意外地将它们互换使用。如果它们都是type,则可以double
Scroog18年

3
@ w0051977您的声明(据我了解)暗示,除在DTO构造函数中进行验证外,其他任何行为都将违反DRY。实际上,验证应该在DTO之外...
Timothy Truckle,

2
对我来说,这只是范围问题。如果您为原语提供广泛的范围,那么可以通过多种方式来滥用和处理原语。因此,您通常希望为他们提供更狭窄的范围,而做到这一点的一种方法是使用一个私有地存储为内部的原语来设计一个代表概念的类来实现它。现在,原语的范围很狭窄,不太可能被滥用/错误处理,并且您可以有效地维护不变式。但是,如果从一开始就限制原语的范围,这可能会过大,并引入许多额外的耦合和代码来维护。

Answers:


17

原始痴迷使用原始数据类型表示领域思想。

相反的是“领域建模”,或者也许是“过度工程”。

您将创建DateOfBirth对象和Salary对象吗?

引入Salary对象可能是一个好主意,原因如下:数字在域模型中很少单独存在,它们几乎总是具有维和单位。如果我们给时间或质量增加长度,通常不会建模任何有用的东西,并且当我们混合米和英尺时,我们很少会得到好的结果

至于DateOfBirth,可能-有两个问题需要考虑。首先,创建非原始日期可为您提供围绕日期数学的所有怪异问题的中心。许多语言开箱即用。DateTimejava.util.Date。这些是日期的领域不可知的实现,但它们不是原始的。

第二,DateOfBirth实际上不是约会时间;在美国这里,“出生日期”是一种文化构造/法律小说。我们倾向于从当地人的出生日期开始计算出生日期;出生于加利福尼亚的鲍勃的出生日期可能比出生于纽约的爱丽丝的出生日期“更早”,尽管他是两个年龄中的较小者。

是否有一条规则描述了何时以及何时不消除原始的困扰,或者如果可能的话,您应该始终这样做。

当然并非总是如此;在边界,应用程序不是面向对象的。看到用于描述测试行为的原语是相当普遍的。


1
顶部引号后面的第一条评论似乎是不义之词。此外,它只是重申了问题的主题。否则,这是一个很好的答案,但我发现这确实让人分心。
JimmyJames

C#DateTime和java.util.Date都不是DateOfBirth的适当基础类型。
凯文·克莱恩

也许替换java.util.Datejava.time.LocalDate
Koray Tugay

7

老实说:这取决于。

总会有过度设计代码的风险。DateOfBirth和Salary将被广泛使用吗?您将只在三个紧密耦合的类中使用它们,还是在整个应用程序中使用它们?您是将它们“封装”在自己的类型/类中以强制执行一个约束,还是可以考虑更多实际属于那里的约束/函数?

让我们以Salary为例:您是否有任何使用“ Salary”的操作(例如,处理不同的货币,或者可能是toString()函数)?当您不将Salary看作简单的原语时,请考虑一下Salary是/做什么。Salary很有可能成为自己的类。


类型别名是一个很好的选择吗?
w0051977

@ w0051977我同意charonx,并且可以使用别名类型
techagrammer

如果主要目的是强制执行严格的键入,以明确声明某个特定值(工资),以避免意外将“浮动美元”(每小时,每周,每月)分配给@ w0051977,则可以使用类型别名作为替代方式“浮动工资”(每月?每年?)。这实际上取决于您的需求。
CharonX '18年

@CharonX,我认为薪水应使用小数而不是浮点数。你同意吗?
w0051977 '18

@ w0051977如果您有一个好的十进制类型,那么那将是可取的,是的。(我现在正在开发C ++项目,因此布尔值,整数和浮点数在我的脑海中排在前列)
CharonX

5

可能的经验法则可能取决于程序的层。对于(DDD)或实体层(Martin,2018),这也可能是“避免表示域/业务概念的任何事物的原语”。理由由OP指出:更具表现力的领域模型,业务规则验证,使隐含概念明确化(Evans,2004年)。

类型别名可以是轻量级的替代方案(Ghosh,2017年),并在需要时重构为实体类。例如,我们可能首先要求使用Salarybe >=0,然后再决定禁止 $100.33333上述任何内容$10,000,000(这会使客户破产)。使用 Nonnegative基元表示Salary和其他概念会使这种重构复杂化。

避免使用原语也可能有助于避免过度设计。假设我们需要将薪水出生日期合并 到一个数据结构中:例如,要减少方法参数或在模块之间传递数据。然后我们可以使用类型为的元组(Salary, DateOfBirth)。的确,带有原始图元的元组(Nonnegative, Nonnegative)是没有信息的,而有些肿的 class EmployeeData将在其他字段中隐藏必填字段。中的签名calcPension(d: (Salary, DateOfBirth))比中的签名更具针对性calcPension(d: EmployeeData),这违反了接口隔离原则。同样,专业人士 class SalaryAndDateOfBirth似乎很尴尬,可能是一个过大的杀伤力。以后,我们可以选择定义一个数据类。元组和元素域类型让我们推迟这样的决定。

在外层(例如GUI)中,将实体“剥离”到其组成原语(例如放入DAO)可能是有意义的。正如Martin(2018)所提倡的,这可以防止将域抽象泄漏到外层。

参考文献
E. Evans,“域驱动设计”,2004
D. Ghosh,“功能和反应域建模”,2017
RC Martin,“清洁架构”,2018


+1为所有参考。
w0051977 '18

4

更好地遭受原始痴迷还是成为一名建筑宇航员

两种情况都是病态的,一种情况下您的抽象太少,导致重复并容易将苹果误认为橙子,而另一种情况下,您忘记先停下来再开始做事,很难完成任何事情。

与往常一样,您需要节制,这是一个经过深思熟虑的中间方法。

请记住,除了类型之外,属性还具有名称。同样,如果始终以相同的方式将地址分解成其组成部分,可能会过于狭窄。并非全世界都在纽约市中心。


3

如果您确实有Salary类,则可以使用ApplyRaise之类的方法。

另一方面,您的ZipCode类不必进行内部验证,以避免在可以注入ZipCodeValidator类的任何地方重复进行验证,因此,如果系统要在美国和英国地址上运行,则只需注入正确的验证器,当您还必须处理AUS地址时,您只需添加一个新的验证器即可。

另一个问题是,如果您必须通过EntityFramework将数据写入数据库,则它将需要知道如何处理Salary或ZipCode。

对于应该如何定义智能类之间的界限,没有明确的答案,但是我会说,我倾向于将业务逻辑(如验证)转移到数据类为纯数据的业务逻辑类,这似乎与EntityFramework更好地配合。

至于使用类型别名,成员/属性名称应提供有关内容所需的所有信息,因此我不会使用类型别名。


类型别名是一个很好的选择吗?
w0051977

2

(问题可能实际上是什么)

什么时候使用原始类型不是代码的味道?

(回答)

当参数中没有规则时-使用原始类型。

对以下类型使用原始类型:

htmlEntityEncode(string value)

使用object来实现以下目的:

numberOfDaysSinceUnixEpoch(SimpleDate value)

后者的例子有在它的规则,即,对象SimpleDate是由YearMonth,和Day。通过在这种情况下使用对象,SimpleDate可以将有效概念封装在对象中。


1

除了该问题其他地方给出的电子邮件地址或邮政编码的规范示例外,我发现从原始痴迷中重构可能对实体ID特别有用(请参阅https://andrewlock.net/using-strongly-typed-entity -ids-to-primitive-obsession-part-1 /,以获取有关如何在.NET中进行操作的示例)。

由于一个方法具有如下签名,我无法计算错误爬虫的次数:

int leaveId = 12345;
int submitterId = 23456;
int approverId = 34567;

SubmitLeaveApplication(leaveId, approverId, submitterId);

public void SubmitLeaveApplication(int leaveId, int submitterId, int approverId) {
  // implementation here
}

编译就很好,如果您对单元测试的要求不严格,它也可以通过。但是,将这些实体ID重构为特定于域的类,然后会遇到编译时错误:

LeaveId leaveId = 12345;
SubmitterId submitterId = 23456;
ApproverId approverId = 34567;

SubmitLeaveApplication(leaveId, approverId, submitterId);

public void SubmitLeaveApplication(LeaveId leaveId, SubmitterId submitterId, ApproverId approverId) {
  // implementation here
}

想象一下该方法可以扩展到最多10个或更多参数,所有参数都是int数据类型(不要管长参数列表代码的味道),当您使用诸如AutoMapper之类的方法在域对象和DTO之间进行交换时,情况变得更加糟糕不会被自动魔术贴图拾取。


0

您将打破DRY原则,将验证逻辑放在所有使用邮政编码的地方。

另一方面,在与许多不同的国家/地区和不同的邮政编码系统打交道时,这意味着除非您知道所涉及的国家/地区,否则您无法验证邮政编码。因此,您的ZipCode班级也需要存储国家/地区。

但是,您是否随后分别将国家/地区存储为Address邮政编码的一部分(邮政编码也是一部分)和邮政编码的一部分(用于验证)?

  • 如果这样做,也违反了DRY。即使您不称其为DRY违规(因为每个实例有不同的用途),它仍然会不必要地占用额外的内存,并且在两个国家/地区的值不同时还可以打开错误的大门(从逻辑上讲,它们永远不应该这样做)是)。
    • 或者,这可能导致您需要同步两个数据点以确保它们始终相同,这建议您无论如何都应将这些数据真正存储在单个点中,从而无法达到目的。
  • 如果您不这样做,那么它不是一个ZipCode类,而是一个Address类,该类又将包含一个string ZipCode,这意味着我们已经全面发展。

例如,我可以与业务分析师讨论邮政编码,而不是包含邮政编码的字符串。

好处是您可以在描述域模型时谈论它们。

我不理解您的基本主张,即当一条信息具有给定的变量类型时,无论何时与业务分析师交谈,您都必须以某种方式提及该类型。

为什么?为什么您不能简单地谈论“邮政编码”并完全省略特定类型?您与业务分析师进行的是哪种讨论(不是技术性的!),而房屋的类型对于对话是最典型的?

我来自哪里,邮政编码始终是数字。因此,我们可以选择将其存储为intstring。我们倾向于使用字符串,因为对数据没有数学运算的期望,但是从未有业务分析师告诉我它必须是字符串。这个决定留给开发人员(或者可以说是技术分析师,尽管根据我的经验,他们并不直接处理棘手的问题)。

只要应用程序执行预期的操作,业务分析师就不会在意数据类型。


验证是一个棘手的难题,因为它取决于人类的期望。

一方面,我不同意验证论证作为显示为什么应避免原始痴迷的一种方式,因为我不同意(作为普遍真理)始终需要始终验证数据。

例如,如果这是一个更复杂的查找,该怎么办?如果您的验证需要联系外部API并等待响应,该怎么办而不是简单的格式检查?您是否真的要强制您的应用程序为ZipCode实例化的每个对象调用此外部API ?
也许这是一个严格的业务要求,那么这当然是合理的。但这不是普遍真理。在很多用例中,这比解决方案要负担更多。

再举一个例子,以表格形式输入地址时,通常在您所在国家/地区之前输入邮政编码。虽然在UI中有即时验证反馈很不错,但是如果应用程序向我提示“错误的”邮政编码格式,这实际上会对我(作为用户)构成障碍,因为问题的真正根源是(例如)我的国家/地区不是默认选择的国家/地区,因此验证发生在错误的国家/地区。
这是错误的错误消息,它分散了用户的注意力并引起不必要的混乱。

就像永久验证不是普遍真理一样,我的示例也是如此。它是上下文相关的。某些应用程序域首先需要数据验证。其他域并没有将验证放在优先级列表上,因为它带来的麻烦与实际优先级冲突(例如,用户体验或最初存储错误数据的能力,因此可以对其进行纠正而不是永远不允许这样做)储存)

出生日期:检查是否大于预期且小于当前日期。
薪金:检查是否大于或等于零。

这些验证的问题在于它们不完整,多余或表明存在更大的问题

检查日期是否大于提示。从字面上看,意味深长的日期是可能的最小日期。此外,您在哪里划界线?阻止DateTime.MinDate但允许有DateTime.MinDate.AddSeconds(1)什么意义?您正在挑选一个与许多其他值相比并没有特别错误的特定值。

我的生日是1978年1月2日(不是,但是假设是)。但是,假设您的应用程序中的数据有误,而是说我的生日是:

  • 1978年1月1日
  • 1722年1月1日
  • 2355年1月1日

所有这些日期都是错误的。他们中没有一个比另一个更“正确”。但是,您的验证规则就只能抓一个的这三个例子。

您还完全省略了如何使用此数据的上下文。如果将其用于例如生日提醒机器人,我会说验证是没有意义的,因为填写错误的日期并没有特别严重的后果。
另一方面,如果这是政府数据,并且您需要出生日期来验证某人的身份(并且不这样做会导致不良后果,例如,拒绝某人的社会保障),那么数据的正确性至关重要,您需要完全验证数据。您现在所建议的验证是不够的。

对于薪水,存在一些常识,即它不能为负。但是,如果您实际上希望输入的是无意义的数据,则建议您调查这些无意义的数据的来源。因为如果不能信任他们输入敏感数据,那么您也就不能信任他们输入正确的数据。

如果相反,薪水是由您的应用程序计算的,并且以某种方式最终以负数(正确的数字)结尾,那么更好的方法是Math.Max(myValue, 0)将负数转换为0,而不是使验证失败。因为如果您的逻辑确定结果为负数,那么验证失败将意味着必须重做计算,并且没有理由认为第二次将得出不同的数字。
而且,如果确实提供了不同的数字,那将再次导致您怀疑计算不一致,因此无法信任。

这并不是说验证没有用。但是,毫无意义的验证是不好的,既因为它花了很多力气却没有真正解决问题,还给了人们一种错误的安全感。


如果婴儿现在在已经跳到第二天的时区出生,那么实际上某人的出生日期可能会超过当前日期。医院可以在数据库中存储“预期的出生日期”,这可能是未来几个月。您想要其他类型吗?
gnasher729

@ gnasher729:我不太确定我是否遵循,似乎您同意我的观点(验证是上下文相关的,并非普遍正确),但是您的评论措词表明您认为我不同意。还是我看错了?
放平
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.