域基元:它们是什么以及如何使用它们来制造更安全的软件

文章

摘自 Dan Bergh Johnsson,Daniel Deogun,Daniel Sawano 设计 安全
___________________________________________________________________

按设计节省37%的费用
只需在manning.com的结帐处的折扣代码框中输入代码fccjohnsson即可
___________________________________________________________________

本文研究域原语:它们是什么,如何定义它们,以及如何将它们用于创建安全软件。

域原语和不变式

域驱动设计中值对象的一些关键属性是其不变性,并且它构成概念上的整体。 我们发现,如果您考虑了价值对象的概念并对其进行了一些微调,同时又考虑到安全性,那么您会得到称为域原语的东西。

当您开始将域原语用作域模型中的最小构建块时,您将能够通过设计方式来创建代码,从而显着降低安全性问题的可能性。 您正在设计精确的代码,几乎没有歧义的余地。 这种类型的代码往往包含较少的错误,因此,较少的安全漏洞。 该代码还易于使用,因为域原语降低了开发人员的认知负担。

域基元是最小的构建块

值对象代表您的域模型中的重要概念。 在对它进行建模时,您决定如何表示值对象及其应具有的名称。 如果您更进一步,还努力确定它是什么,什么不是? 您将对该概念有更深入的了解。 然后,您可以使用该见解来引入必须保持不变的变量,以使值对象被视为有效。

您可以继续说值对象不仅应该可以而且必须支持这些不变量,并且必须在创建时强制它们。 最终得到的是一个值对象,其定义足够严格,因此如果存在,它也是有效的。 如果无效,则不存在。 这种类型的值对象就是我们所说的领域原语。

一个在定义上足够精确的值对象,仅通过其存在就可以表明其有效性,这称为领域原语

域原语类似于域驱动设计中的值对象。 主要区别在于我们要求不变量存在,并且必须在创建时强制它们。 我们还禁止将简单的语言原语或泛型(包括null )用作域模型中概念的表示。

域模型中的任何内容都不应由语言原始类型或泛型类型表示。 每个概念都应建模为领域原语,以在传递时带有含义并保持其不变性。

假设您在域模型中有数量的概念。 数量是客户要在您正在建立的网上商店中购买某种商品的数量。 数量是一个数字,但不是将其表示为整数,而是创建了一个称为“ Quantity的域原语。 在定义Quantity您需要与领域专家讨论在当前领域中什么是有效数量。 讨论表明,有效数量是1到200之间的整数值。零数量是无效的,因为如果客户要购买零个商品,那么该订单根本就不应该存在。 负值无效,这是因为您不能取消购买产品和/或退货是分开处理的。 系统根本不处理200多个项目的订单。 如此大的订单极为罕见,如果确实发生,则需要特殊处理。 通过与销售代表直接联系而不是通过在线商店来处理这些问题。

您还可以封装域原语的重要行为,例如数量的加法和减法。 通过使域原语拥有并控制域操作,可以降低由于缺乏对操作中涉及的概念的详细域知识而导致错误的风险。 它们与概念的距离越远,对概念的了解就越少,并且将所有域操作保持在域原语本身之内是有意义的。 举一个例子,如果您需要能够添加两个数量并创建一个方法add ,则该方法的实现需要考虑一个数量的域规则-请记住,我们不处理普通整数不再。 如果将add方法放置在代码库中的其他位置,例如在一个名为Functions的实用程序类中,那么很容易会潜入一些细微的错误。如果您决定稍微更改Quantity域原语的行为,您还记得还要更新实用程序类中的方法吗? 您可能会忘记—这就是您引入难以发现的错误的原因,这些错误可能导致严重的问题。

完成后,以代码表示时, Quantity域原语看起来像下面的清单。

清单1.数量域原语

 导入静态org.apache.commons.lang3.Validate.inclusiveBetween; 
导入静态org.apache.commons.lang3.Validate.notNull;

公开最终班数量{

私有最终int值; ❶

公共数量(最终int值){
inclusiveBetween(1,200,value); ❷
this.value =值;
}

public int value(){
返回值
}

公共数量添加(最终数量添加){❸
notNull(addend);
返回新的Quantity(值+ addend.value);
}

// equals()hashCode()等...

}

❶实际整数值

of在创建时强制不变量

❸提供域操作以封装行为

这是数量概念的精确且严格的代码表示。 像此处创建的“ Quantity类的域原语消除了某些不诚实用户发送负值的可能性,从而使系统陷入意外行为。 使用域原语可以消除安全漏洞,而无需使用显式对策。

如该建模练习所示,数量不仅是整数。 应该将其建模并实现为域原语,以在传递时带有含义并保持其不变性。

现在,您已经了解了域原语是什么的基础知识。 让我们继续研究定义域原语有效范围的重要性。

上下文边界定义含义

像值对象一样,域基元由它们的值而不是标识来定义。 这意味着相同类型和相同值的两个域原语可以互换。 域原语非常适合表示不适合实体或集合类别的各种域概念。 使用域原语对概念建模时要记住的一个重要方面是,应定义该概念以确切表示该概念在当前域中的含义。

假设您正在构建一个系统,该系统允许用户选择和创建自己的电子邮件地址。 用户可以选择电子邮件地址的本地部分(@左侧的部分),创建后,他们可以使用该地址开始发送和接收消息。 如果用户输入jane.doe则将创建电子邮件地址jane.doe@example.com (假设您的域名为example.com )。 在建模时,您意识到电子邮件地址是域原语的完美示例。 它是由其值定义的,您可以提出一些约束条件,这些条件可以用来断言它是有效的。 首先,您可能倾向于使用电子邮件地址的正式定义来确定什么是有效地址。 尽管就满足RFC的要求而言,这在技术上是正确的,但在当前域的上下文中,它可能不是有效的电子邮件地址(图1)。 作为工程师,这可能会让您感到惊讶,但是请记住,我们关注的是概念在特定领域中的含义,而不是在其他情况下(例如,在全球标准中)的含义。 例如,您的域可能将电子邮件地址定义为不区分大小写,这样用户输入的任何内容都将转换为小写。 您甚至可以进一步说,允许的唯一字符是ASCII字符,数字和点( [a-z0-9.] )。 这是对技术规范的偏离,但是在当前领域的背景下这是一个有效的选择。

有时,您会遇到以下情况:您要建模的概念的名称也在当前上下文之外使用,并且其外部定义很普遍,在您的域模型中重新定义它可能会造成混淆。 电子邮件地址可能是这样的术语,但您了解到,在当前域中重新定义术语“电子邮件”是有意义的。 定义明确的术语的另一个示例是ISBN。 ISBN由国际标准化组织(ISO)标准定义,重新定义该ISBN可能会导致混淆,误解和错误。 这些类型的含义上的细微差异是造成安全问题的常见原因,您要避免使用它们,尤其是在与其他系统或其他域上下文进行交互时(图2)。

很多时候,当您发现自己在重新定义一个著名的术语时,需要进行重新定义是因为该术语用于在您当前的上下文中描述多个事物。 在这种情况下,请尝试将术语分为两个不同的术语,或者提出一个全新的术语。 这个新术语对您当前的上下文来说是唯一的,您可以避免任何误解。 这也很清楚为什么使用某些特定的不变量而不是与外部定义的术语相关联的不变量。 引入新术语的另一个好处是,原始术语可以保持其清晰的定义并保持域原语。 您一直拥有在自己的域中对重要概念建模的完全自由,而不会失去任何模型的准确性。

假设您正在构建使用ISBN识别书籍的书籍管理软件。 很快您就会意识到,您需要一种方法来识别和处理尚未收到ISBN的图书。 一种方法是重新定义“ ISBN”一词,不仅代表真实的ISBN号,而且包括内部分配的标识符,也许使用魔术前缀或类似的东西将它们与真实的ISBN区分开。 为避免重新定义ISO标准时可能造成的混乱,您可以引入一个新术语BookId ,其中包含ISBNUnpublishedBookNumber (图3)。 BookId是标识书籍的内容, UnpublishedBookNumber是内部分配的标识符。

通过引入两个新术语BookIdUnpublishedBookNumber ,您可以保留“ ISBN”的确切且众所周知的定义,同时满足您的业务领域的需求。

构建您的域原始库

现在,您已经利用域原语的多功能性扩展了工具箱,您应该努力在代码中尽可能多地使用它们。 这些是最小的构建基块,它们构成了域模型的基础。 因此,模型中的几乎每个概念(无论其大小如何)都基于一个或多个域原语。 建模完成后,您将拥有一系列域原语,可以将它们视为域原语库来查看。 该库不是通用实用程序类和方法的集合,而是一组定义明确,无处不在的领域概念。 并且由于它们是域原语,因此可以安全地作为代码中的参数传递,如常规值对象。

领域原语降低了开发人员的认知负担,因为无需了解其内部工作原理即可使用它们。 您可以放心使用它们,始终确保它们代表有效值和定义明确的概念。 如果它们无效,它们将不存在。 这也消除了为了确保使用安全而不断重新验证数据的需要。 如果是在您的域中定义的,则可以信任它并自由使用它。

通过使用域原语库强化API

您应该始终努力在编程API中使用域原语。 如果方法的每个参数和返回值在定义上均有效,那么您将在代码库中的每个方法中进行输入和输出验证,而无需进行任何额外的工作。 使用域设计的方式使您可以创建具有极强弹性和健壮性的代码。 这样做的积极副作用是,无效输入数据导致的安全漏洞数量将大大减少。

让我们用一个代码示例来仔细研究一下。 假设您承担了将系统的审核日志发送到中央审核日志存储库的任务。 审核日志包含敏感数据,将它们发送到指定位置进行适当的存储和保护很重要。 将数据发送到错误的位置可能会对业务产生重大负面影响。

如果您在API中创建一个方法,该方法采用当前的审核日志并将其发送到位于给定服务器地址的日志存储库,则最终可能看起来像这样:

  void sendAuditLogsToServerAt(java.net.InetAddress internalIpAddress); 

问题是这样的方法签名允许任何IP地址成为日志的目标。 如果在发送日志之前未能正确验证地址,则可能会将它们发送到不安全的位置并显示敏感数据。 如果改为定义一个域原语InternalIPAddress ,它严格定义内部IP地址是什么,则可以将其作为方法中输入参数的类型。 将其应用于sendAuditLogsToServerAt方法将导致以下清单中的代码。

清单2.使用域原语强化API

  void sendAuditLogsToServerAt(InternalAddress serverAddress){❶ 
notNull(serverAddress);

//检索日志并将其发送到服务器
}

left剩下要执行的唯一输入验证是null检查

现在,您已经设计了方法,使其无法将无效输入传递给它。 就验证IP地址是内部地址而言,剩下的唯一验证形式是确保它不是null

避免公开公开您的域

强化API时要记住的一件事是,如果您有一个充当不同域接口的API,则应避免在该API中公开域模型的对象。 如果这样做,您立即将域模型作为公共API的一部分。 一旦其他域开始使用您的API,就很难迅速独立地更改和发展您的域。

面向不同域的公共API的示例是Internet上公开的REST API,供他人通过客户端软件使用。 如果您在REST API中公开内部域,那么您在不强迫客户端与您一起发展的情况下就无法发展您的域。 如果您的业务依赖于这些客户,那么您就不能无视他们,别无选择,只能以与消费者适应他们的客户相同的速度发展。 更糟糕的是,如果您有多个消费者,那么您不仅会与每个消费者捆绑在一起,而且还会将消费者捆绑在一起。 这种情况不太理想,可以通过公开屏蔽域来避免这种情况。

您要做的是对每个域对象使用不同的表示形式。 可以将其视为一种用于与其他域通信的数据传输对象 (DTO)。 您可以在这些DTO中放置不变式,但这些不会与您的域中存在的约束相同。 相反,它们可以例如是与API定义的通信协议相关的约束。 在这样的API方法中,您要做的第一件事是将DTO转换为相应的域原语,以确保其数据有效。

通过在您的公共API和您的域中的概念之间使用这种转换层,您可以将两者分离。 这使您可以独立地开发API和域。

在本节中,我们讨论了域原语的许多重要方面。 在继续之前,让我们回顾一下关键点:

  • 在创建时检查其不变量。
  • 它们只有在有效时才能存在。
  • 应该始终使用它们代替语言原语或泛型类型。
  • 它们的含义是在当前域的边界内定义的,即使同一术语存在于当前域之外。
  • 您应该使用域原语库来创建安全代码。

您已经了解了不变性,快速失败,验证和域原语,以及这些概念如何通过设计提高安全性。

___________________________________________________________________

如果您想了解有关这本书的更多信息,请在此处免费阅读liveBook的第一章,并查看此幻灯片。
___________________________________________________________________

关于作者:

Dan Bergh JohnssonDaniel DeogunDaniel Sawano共同致力于安全与发展已有数十年。 他们是开发人员的核心,并了解安全性通常是一个侧面问题。 他们还养成了工作习惯,使他们能够以提高安全性的方式开发系统,同时专注于高质量的设计习惯,这对于开发人员在日常工作中更容易记住。 这三位都是公认的国际演讲者,经常出席有关高质量发展与安全的主题会议。

最初发布于 freecontent.manning.com