DIP 实战

依赖倒置原则 (DIP) 自 90 年代初就已经出现,但即使如此,在解决问题的过程中,它似乎很容易被遗忘。在给出一些定义之后,我将介绍我在实际项目中使用 DIP 的一些应用,以便您有一些例子来形成自己的结论。

2013 年 5 月 21 日



我是怎么来到这里的?

我最初接触依赖倒置原则 是在 1994 年左右从 Robert (Bob 叔叔) Martin 那里了解到的。它与大多数 SOLID 原则一样,表述简单,但应用深刻。以下是我最近在实际项目中使用的一些应用程序;我讨论的所有内容都是从 2012 年 6 月开始投入生产的,截至 2013 年年中仍在生产中。其中一些可以追溯到更早的时候,但不断地出现,这提醒我基础知识仍然很重要。

DIP 概要

表达依赖倒置原则有很多种方式

  • 抽象不应该依赖于细节
  • 代码应该依赖于抽象级别相同或更高的东西
  • 高级策略不应该依赖于低级细节
  • 在与领域相关的抽象中捕获低级依赖关系

所有这些的共同点是关于从系统的一部分到另一部分的视图;努力使依赖关系朝着更高级别(更接近您的领域)的抽象发展。

为什么要关心依赖关系?

依赖关系是一种风险。例如,如果我的系统需要安装 Java 运行时环境 (JRE) 但没有安装,我的系统将无法工作。我的系统可能还需要某种操作系统。如果用户通过网络访问系统,则需要用户拥有浏览器。您可以控制或限制其中一些依赖项,而忽略其他依赖项。例如,

  • 对于 JRE 要求,您可以确保部署环境安装了适当版本的 JRE。或者,如果环境是固定的,您可以调整代码以匹配 JRE。您可以使用 Puppet 之类的工具来控制环境,从更简单、已知的启动映像构建环境。无论如何,虽然后果很严重,但它很容易理解,并且有几种选择可以缓解它。(我个人更倾向于 CD 端的频谱。)
  • 当您的系统使用 String 类时,您可能不会反转该依赖关系。例如,如果您将 String 视为一种基本类型(严格来说不是,但足够接近),那么操作多个 String 就会类似于 基本类型痴迷。如果您围绕 String 引入一种类型,并添加对使用这些 String 有意义的方法,而不是简单地公开 String 方法,只要生成的类型比 String 更接近您的领域,那就开始看起来像是一种依赖倒置。
  • 对于浏览器,如果您想要现代体验,则很难支持所有浏览器。您可以尝试允许所有浏览器和版本,将支持限制在相对现代的浏览器,或者引入功能降级。这种依赖关系很复杂,可能需要多方面的方法来解决。

依赖关系代表风险。处理这种风险需要付出一定的代价。通过经验、反复试验或团队的集体智慧,您可以选择明确地降低这种风险,或者不降低。

与什么相比的倒置?

倒置是方向的颠倒,但与什么相比是颠倒的?结构化分析和设计的设计部分。

在结构化分析和设计中,我们从一个高级问题开始,并将其分解成更小的部分。对于任何仍然“太大”的小部分,我们继续将其分解。高级概念/需求/问题被分解成越来越小的部分。高级设计是根据这些越来越小的部分来描述的,因此它直接依赖于更小、更详细的部分。这也称为自顶向下设计。请考虑以下问题描述(经过一定程度的理想化和简化,但在其他方面是在实践中发现的)

  1. 报告节能情况
    1. 收集数据
      1. 打开连接
      2. 执行 Sql
      3. 转换结果集
    2. 计算基线
      1. 确定基线组
      2. 预测时间序列数据
      3. 跨日期范围计算
    3. 生成报告
      1. 确定非基线组
      2. 预测时间序列数据
      3. 跨数据范围计算
      4. 计算与基线的差值
      5. 格式化结果

报告节能情况的业务需求取决于收集数据,而收集数据又取决于执行 Sql。请注意,依赖关系遵循问题的分解方式。事物的细节越多,它就越有可能发生变化。我们有一个高级想法依赖于可能发生变化的东西。此外,这些步骤对更高级别的更改极为敏感,这是一个问题,因为需求往往会发生变化。我们希望相对于这种分解来反转依赖关系。

将其与自底向上组合进行对比。您可以找到领域中存在的逻辑概念,并将它们组合起来以实现高级目标。例如,我们有一些东西在使用电力,我们称之为消费者。我们对它们了解不多,因此我们将通过 消费者存储库 来访问它们。在我们的领域中,有一种叫做基线的东西,需要确定它。消费者可以计算他们的能源使用量,然后我们可以比较基线与所有消费者的能源使用量,以确定节能情况

图 1:自底向上

虽然我们做的工作最初可能是一样的,但在这种重新构想中,有机会通过更多一点的工作来引入不同的方法来完成细节

  • 将存储库切换为不同的存储机制,其接口中没有提到 SQL,因此我们可以使用内存解决方案、NoSql 解决方案或 RESTful 服务。
  • 不要构建基线,而是使用 抽象工厂。 这将为多种基线计算提供支持,这实际上反映了特定领域的现实。

在您阅读本文时,您可能会注意到所有这些内容中都包含 开闭原则 的一些概念。它们当然是有关系的。最初,将您的问题分解成您的领域建议的逻辑块。随着系统的增长,使用这些块或以某种方式扩展它们以适应其他场景。

这一切意味着什么?

在 DIP 指的是抽象的地方,我注意到许多人将抽象与以下内容混淆了

  • 接口
  • 抽象基类
  • 作为约束给出的东西(例如,外部系统架构)
  • 被称为需求的东西,它被表述为一种解决方案

事实上,所有这些都可能产生误导

  • 接口 - 看看 java.sql.Connection,将您的业务领域与 getAutoCommit()createStatement()getHoldability() 等方法进行比较。虽然这些方法对于数据库连接来说可能是合理的,但它们与您的系统用户的操作有什么关系呢?这种联系充其量是微不足道的。
  • 抽象基类 - 抽象基类与接口存在相同的问题。如果这些方法对你的领域有意义,那么它可能是可以接受的。但如果这些方法对软件库有意义,那么它可能就不太合适了。例如,考虑 java.util.AbstractList。想象一个领域,其中包含不断增加的历史事件的有序列表。在这个假设的领域中,从历史记录中 remove() 一个项目永远没有意义。List 抽象提供了解决一般问题的方法,而不是你的问题,它至少提供了一个对你的领域没有意义的功能。你可以继承 AbstractList(或其他一些 List 类),但这样做仍然会暴露一个(可能是多个)对你的类使用没有意义的方法。一旦你妥协并允许客户端看到不必要的方法,你可能就违反了 DIP 和 Liskov 替换原则。
  • 约束/需求 - 当我们被分配工作时,这项工作是提供了动机和目标,还是仅仅说明了如何解决问题?你的需求是谈论必须使用面向消息的中间件进行集成,还是谈论要更新哪些数据库字段才能完成工作?即使你获得了对 参与者 目标的描述,这些目标是否只是重申了当前的现状流程,而你可以构建一个系统来从一开始就消除对这些流程的需求?

你是说依赖倒置原则,对吧?

2004 年,Martin Fowler 发表了一篇关于依赖注入 (DI) 和控制反转 (IoC) 的文章。DIP 与 DI 或 IoC 相同吗?不,但它们可以很好地协同工作。当 Robert Martin 第一次讨论 DIP 时,他将其等同于 开闭原则Liskov 替换原则 的一流组合,其重要性足以保证它拥有自己的名称。以下是一些使用示例对所有三个术语的概要

  • 依赖注入
    • 依赖注入是指一个对象如何知道另一个依赖对象。例如,在“大富翁”游戏中,玩家掷出一对骰子。想象一个软件玩家需要向一对软件骰子发送 roll() 消息。玩家对象如何获取对骰子对象的引用?想象一下,游戏告诉玩家 takeATurn(:Dice) 并将骰子交给玩家。游戏告诉玩家轮到他了并传递骰子是方法级依赖注入的一个例子。想象一个系统,其中 Player 类表达了对 Dice 的需求,并且它是由某种所谓的 IoC 容器(如 Spring)自动连接的。最近的一个例子是我在 2013 年第一季度工作的系统中。它涉及 Spring 配置文件的的使用。我们有 4 个命名的配置文件:demo、test、qa、prod。默认配置文件是 demo,它使用 10 个模拟设备启动系统并启用某些测试点。test 配置文件启动系统时没有模拟设备,并且启用了测试点。qa 和 prod 都启动系统,以便系统通过蜂窝网络连接到真实设备,并且不加载测试点,这意味着如果生产组件尝试使用测试点,系统将无法启动。另一个例子来自一个涉及混合 Java 和 C++ 的应用程序。如果系统是通过 JVM 启动的,那么它被配置为模拟 C++ 层。如果它是通过 C++ 启动的,然后启动 JVM,那么系统被配置为访问 C++ 层。这些都是依赖注入的种类。
  • 控制反转
    • 控制反转是指谁来发起消息。是你的代码调用框架,还是将某些东西插入框架,然后框架再回调?这也被称为 好莱坞法则;不要打电话给我,我会打电话给你。例如,当你为 Swing 创建一个 ButtonListener 时,你提供了一个接口的实现。当按下按钮时,Swing 会注意到这一点并回调到你提供的代码中。想象一下,用多个玩家创建的“大富翁”系统。游戏协调玩家之间的互动。当轮到某个玩家行动时,游戏可能会询问该玩家是否有任何行动前的操作,例如出售房屋或酒店,然后游戏将根据骰子的点数移动该玩家(在现实世界中,玩家掷骰子并移动他或她的棋子,但这只是棋盘游戏不是计算机的产物 - 也就是说,这是对正在发生的事情的现象学描述,而不是本体论描述)。请注意,游戏知道玩家何时可以做出决定并相应地提示玩家,而不是玩家自己做出决定。最后一个例子是,Spring Message bean 或 JEE Message bean 是在容器中注册的接口的实现。当消息到达队列时,容器会调用 bean 来处理消息,容器甚至会根据 bean 的响应来决定是否删除消息。
  • 依赖倒置原则
    • 依赖倒置是指代码所依赖的对象的形状。DIP 与 IoC 和 DI 有什么关系?考虑一下,如果你使用 DI 注入一个低抽象的依赖会发生什么?例如,我可以使用 DI 将 JDBC 连接注入到“大富翁”游戏中,以便它可以使用 SQL 语句从 DB2 中读取“大富翁”棋盘。虽然这是一个 DI 的例子,但它也是注入一个(可能)有问题的依赖的例子,因为它存在于一个明显低于我的问题领域的抽象级别。就“大富翁”而言,它是在 SQL 数据库出现之前的几十年创建的,因此将其耦合到 SQL 数据库会引入不必要的、偶然的依赖。更好的做法是将棋盘仓库注入到“大富翁”中。这种仓库的接口适合于“大富翁”的领域,而不是用 SQL 连接来描述。由于 IoC 是关于谁来发起调用序列的,因此设计不良的回调接口可能会迫使低级细节(框架)进入你编写的代码中,以插入框架。如果是这种情况,请尽量将大部分业务内容保留在回调方法之外,并放在 POJO 中。

DI 是关于一个对象如何获取依赖的。当依赖是从外部提供的,那么系统就在使用 DI。IoC 是关于谁来发起调用的。如果你的代码发起调用,则它不是 IoC,如果容器/系统/库回调到你提供的代码中,则它是 IoC。

另一方面,DIP 是关于从你的代码发送到它正在调用的东西的消息中的抽象级别。可以肯定的是,将 DI 或 IoC 与 DIP 一起使用往往更具表现力、更强大且更符合领域,但它们是关于一个整体问题中的不同维度或力量。DI 是关于连接的,IoC 是关于方向的,而 DIP 是关于形状的。

接下来是什么?

了解了依赖倒置原则的定义后,现在该看看 DIP 在实际应用中的例子了。接下来是几个例子,它们都有一个共同的主线;在系统的需求限制下,提高依赖的抽象级别,使其更接近领域。

灵活性是有代价的

我做过也见过的一件常见的事情是,通过添加比解决当前问题所需更多的方法来使一个类“更易于”使用。这可能是源于“以防万一”的想法,也可能是源于导致代码库难以更改的实践历史,这意味着现在就把东西放进去被认为比以后如果需要再添加更容易。不幸的是,更多的方法会导致更多编写不正确代码的方式、更多需要验证的执行路径、在使用“更容易”的接口时需要更多的纪律等等。类的表面积越大,就越有可能难以正确使用该类。事实上,表面积越大,就越有可能更容易错误地使用该类,而不是正确地使用它。

我应该使用哪种锤子?

考虑一下日志记录。虽然日志记录不一定是运行 DevOps 的最佳方式,但它似乎是一种被大量实践的方式。在我参与的最后几个项目中,日志记录最终都成了一个问题。问题多种多样

  • 太多
  • 不够
  • 对应该记录什么的级别存在分歧
  • 对应该使用哪些日志记录方法存在分歧
  • 对应该使用哪个日志记录框架存在分歧
  • 不一致地使用 Logger 类
  • 在项目中使用的所有开源项目中使用的所有各种开源日志记录库的日志记录配置不正确/不一致
  • 项目中使用的不同开源项目使用多个日志记录框架
  • 日志消息不一致,导致日志难以使用
  • 在此插入你的特殊经历...

虽然这不是一个详尽的列表,但如果你参与过中等规模的项目,并且没有讨论过其中的一些主题,我会感到惊讶。

方法太多

看一下图 2。这包括 JDK 中内置的 Logger 和其他两个常见的开源日志记录框架,这些框架被多个开源项目使用。要关注的关键是每个类中的方法数量。

图 2:现有记录器的复杂性

让我们只考虑 JDK 中的 Logger 类。你是一个团队中的新开发人员。希望你不是一个人在工作,但如果你是一个人,你可能会被告知“查看代码库”,然后任由你自己去处理。当你需要进行一些日志记录时,你会使用 log 方法中的哪一个?

图 3:哪个 Log 方法?

log 首先是正确的方法吗?你可以在代码库中搜索示例,你是采用找到的第一个示例,还是检查是否存在多种方法?

这是一个微不足道的例子。它似乎微不足道。以下是我遵循的一个很好的经验法则

没有 + 没有 + 没有... 最终等于有。

-- Jerry Weinberg(释义)

虽然这一件事真的不是什么大事,但它不会是项目中唯一一件这样的事。知道该使用方法会稍微增加每个开发人员的负担。它还会增加向正在进行的项目或团队中添加人员的难度。这种细节,看似微不足道且不重要,最终会落入 部落知识 的范畴。虽然通过拥有大量的 部落知识 来建立团队身份可能有一些好处,但那些导致不必要的不一致的事情可能不值得付出代价。

性能注意事项

另一个论点是,随着时间的推移,这个论点会变得越来越弱,乍一看可能不太明显。请考虑以下代码示例

Logger logger = Logger.getLogger(getClass().getName());
String message = String.format("%s-%s-%s", "part1", "part2", "part3");
logger.log(Level.INFO, message);

这种记录器的使用看起来很简单,但它有一个问题:无论记录器最终是否在 INFO 级别记录消息,它都会执行字符串连接。这会导致不必要的工作以及额外的垃圾收集。为了“正确地”编写它,它应该更像这样

Logger logger = Logger.getLogger(getClass().getName());
if (logger.isLoggable(Level.INFO)) {
  String message = String.format("%s-%s-%s", "part1", "part2", "part3");
  logger.log(Level.INFO, message);
}

记住这一点是编写者的责任。想象一个有多个日志语句的系统入口点

  • 这段代码将被复制(或者我们希望如此)
  • 这种细节是偶然的,而不是本质的
  • 这增加了查看代码的心理负担
  • 哦,它还违反了 DRY 原则

如果您使用像 Slf4j 这样的现代 API,其中一些问题可以通过一些方法来解决,这些方法可以接受不同数量的参数并在连接之前执行检查。这很好,但我们又回到了有 50 多种方法可供选择的情况。我不记得有哪个超过 3 个人的项目没有讨论过一致的日志记录器使用,所以很明显,方法的数量成为了一个不必要的(偶然的)复杂性来源。

为了解决这个问题,我想要一些可以减少重复和复杂性的东西。以下是我在许多项目中做过的一件事

图 4:缩小 API 范围

现在使用这个新的日志记录器不太可能导致问题

SystemLogger logger = SystemLoggerFactory.get(getClass());
logger.info("%s-%s-%s", "part1", "part2", "part3");

这个特定的实现利用了“现代”Java 1.5 特性

public void info(String message, Object... args) {
  if (logger.isInfoEnabled()) {
    logger.info(String.format(message, args));
  }
}

Martin Fowler 将其称为 网关。我喜欢这个名字,因为它让人联想到传递的概念以及一件事与另一件事的分离。减少灵活性会使事情变得不那么繁琐,因此我们可以将时间花在思考下一段要测试优先编写的代码上。

此解决方案引入了一个额外的方法调用,但与消除出错的可能性相比,方法调用的成本似乎非常值得。在现代运行时,此方法不会被动态调用,它将被优化为在没有虚拟调度的情况下调用。我上次测量方法调用(2008 年)时,每秒可以获得大约 2,000,000,000 次调用,因此在我们可能使用日志记录器的系统上,这种开销可以忽略不计。另一个好处是,如果日志记录有任何配置,则可以在一个地方进行管理,从而使代码更符合 DRY 原则。

结论

日志记录库的灵活性很容易导致使用不一致、代码冗长或基于系统日志记录状态执行不必要工作的代码。从框架作者的角度来看,这是有道理的。从概念上讲,日志记录可能存在于应用程序级别,但框架的日志记录实现需要足够灵活,以支持多个 JVM 版本、各种用途,并满足所有人的需求。特定系统对日志记录的使用可以选择更加集中和一致。日志记录接口通常存在于比我的系统对日志记录器的需求更低的抽象级别。

解决方案抽象化了,但这不是我的问题

使用 SQL 数据库是您系统的必要组成部分吗?实际需求是输入到您系统中的信息需要持久化吗?多久持久化一次?对哪些用户持久化?事实上,这类问题过去更容易回答,因为它们通常不会被问到。

背景

上个世纪,我们担心的是 ACID 事务。即使在那时,我们通常也会用一些不那么强大的东西来交换 ACID(它是悲观的),比如最后一次写入获胜或对象版本控制(它们是乐观的)。现在,随着系统规模越来越大,我们已经转向云计算和具有最终一致性的 NoSQL 解决方案,情况变得更加多样化。

这与 Java 有什么关系?我使用 JDK 1.0.2 开发并部署了我的第一个应用程序。在那个年代,如果你想使用数据库,它看起来像这样

图 5:首先是数据库

Java 对这个问题置之不理,你只能锁定供应商。或者更糟糕的是,您编写的代码可以处理“任何”数据库 - SQL 或面向对象数据库。

Java 1.1 为我们带来了 JDBC。只要我们能找到 JDBC 驱动程序,这就能改进我们对数据库的使用

图 6:JDBC 为我们提供了一种接口

然而,虽然这使得使用数据库更容易,减少了供应商锁定,但这种抽象让事务、预处理语句等内容渗透到您的领域中。JDBC 提高了抽象级别,但级别仍然太低。

JDBC、JDO、ORM、Hibernate 和其他 ORM 以及最近的 JPA 都有一些改进(我忽略了 Spring Data、Hades 等,因为它们没有显著改变现状)。需要注意的是,我们仍然有一堆箭头从系统指向数据库。

图 7:JPA 为我们提供了一个标准的 ORM

就像关于日志记录接口的讨论一样,使用这些接口中的任何一个都可能违反了 DIP。假设您不是在编写数据库,您的业务可能并不__需要__数据库,它可能需要某种持久化的信息。除非您正在编写与数据库直接相关的东西,否则像 SQL 数据库(或 NoSQL 数据库、层次结构数据库、基于对象的数据库等)这样的通用东西与您的业务处于同一级别的可能性很低。

结论

当我们使用 JDBC 时,我们使用了一堆接口。接口是一种抽象。然而,虽然使用某种抽象通常有助于编写良好的代码,但这还不够。抽象应该处于适合您的领域的级别。像 JDBC 这样的通用解决方案不会试图解决您的问题,它试图解决一个通用的问题。这类似于日志记录示例,其中有太多方法。JDBC 的特性解决了在使用数据库时可能需要处理的所有问题的全部范围。典型的领域并不关心所有这些问题,因此可以简化特定领域的使用以符合其需求。

不要接受给定的东西

到目前为止,这些示例都是关于用于解决系统某些部分的抽象层的级别。下一个例子也不例外,但在实践中,它似乎被区别对待。当您得到一个伪装成需求的解决方案时,会发生什么?

提供的解决方案

我们将从我所在团队遇到的一个问题开始下一部分

图 10:给定

这里有一些更详细的信息

  • 某个外部系统使用异步发布/订阅队列广播某个时刻表已更新的事实。
  • 在稍后的某个时间点,我们的系统需要接收该通知并决定是否对其采取行动。例如,它可能已经拥有该特定时刻表,因为时刻表可能会发送多次。在这个特定示例中,系统关心时刻表,因此它将请求获取时刻表。
  • 系统创建一个临时队列,系统将要求发布者将完整时刻表发送到该队列。它向原始发布者发送一条异步消息(实际上是将其发送到另一个队列,该队列在同一进程空间中处理)。
  • 系统在临时队列上等待时刻表传递。它不会永远阻塞,实际上它会每隔一段时间唤醒一次,以防系统决定在此过程中途关闭。它还会在经过属性驱动的几分钟后放弃。
  • 最终(快乐路径),时刻表到达,系统接收时刻表。它进行一些处理,然后系统持久化时刻表。

我们是如何解决这个问题的?

当时团队正在处理这个问题,我们有共享的配对工作站和开放的环境。我听到了正在处理这个问题的那对搭档,并询问他们是否直接依赖于 JMX,或者他们是否遵循了 DIP(转述,但真实的故事)。他们一头扎进了解决问题的过程中,把所有给定的东西都当作是必不可少的,这就是他们正在做的事情

图 11:直接使用一堆接口

这是一个简单、典型且熟悉的回应。细节太多,可能很难看清真正的东西。在这个问题中,异步交互是必不可少的还是偶然的?在这个特定情况下,整个机制都是强加给我们的设计决策。虽然我们必须遵守它(这是一种合理的方法),但我们不必让它的设计成为我们设计中不可磨灭的一部分。我认为在大多数情况下也是如此;一个稍微弱一点的指导方针是,除非被证明是必不可少的,否则就假设它是偶然的。

有没有什么情况下异步性是必不可少的?有的。想象一个工作流,其中一个工作项有一个或多个交接。也就是说,当我完成它时,你就接管它。我完成我的工作,然后结束。虽然我负责的最后一步已经完成,但给定项的整个工作流还没有完成。从概念上讲,我为这种流程设计的接口看起来与一个人一次性完成所有工作的设计不同。然而,设计背后的驱动力应该受到领域的影响。

在这个特定情况下,我们需要做三件主要的事情:获取原始形式的时刻表,将 XML 转换为时刻表,然后持久化它。第二步和第三步在一段时间前就已经写好了,所以当开始这项工作时,我们已经处理了获取。从来没有出现过我们的系统实际需要原始表示的情况,因此获取结果为时刻表比在系统的其他地方看到 XML 表示要好。

快速设想一下就变成了

图 12:异步是给定的,但却是偶然的

请注意,对各种 JMS 接口的依赖并没有消失。它只是隐藏在更深一层的间接层后面。我们的系统级视图是我们有一些东西可以获取时刻表。它究竟是如何做到的,则留给具体的实现。事实上,我们最初在使用 Active MQ 进行初始探索时编写了一个简单的 伪对象。后来,我们还使用了 Mockito 来编写每个测试的 测试桩

由此产生的高级交互更容易理解。

图 13:现在我们的流程遵循我们的消费。

所有这些都变得很重要,原因有很多。

  • 获得 Tibco 访问权限花了一些时间,但我们很早就有一些具体的例子。
  • 从原始格式到计划的转换确实需要一些额外的工作,我们可以不用等待就能完成。
  • 我们必须学习 Spring 3.x 的一些内部工作原理,在等待 Tibco 访问权限的同时,使用 ActiveMQ 完成这项工作可能已经完成了 90%。
  • 我们无法控制 Tibco,它在另一个小组的职责范围内(而且在政治上不会改变)——这是一个巨大的信号,表明 DIP 将是你的朋友。
  • 我们正在实践持续集成,这意味着我们经常运行测试,每天轻松达到 60 多次:最多 5 对,多次签入,在签入之前多次开发人员运行,然后在构建机器上为每次签入运行,以及性能测试等。
  • 测试队列是共享的。
    • 测试队列经常不可用,因为其他一些测试已经填满了它们的缓冲区。
    • 测试队列将具有可能吞下所有消息的消费者,这意味着我们的测试可能会因为我们无法控制的原因而失败。

情况有多糟糕?

所有这些风险使得能够验证我们的大部分逻辑与 Tibco 特定问题没有直接关系变得至关重要。事实上,使用 JMS 的逻辑使得 Tibco 和 ActiveMQ 之间的区别严格来说是一个配置问题,而不是代码问题。当我们使用 ActiveMQ 时,我们指向一个进程内队列。当我们使用 Tibco 时,我们指向多个队列中的一个,具体取决于我们是要使用 QA 队列还是生产队列。虽然存在一些差异(ActiveMQ 更宽容一些),但我们设法编写了一条处理这两个库的路径。

如果这听起来很重量级,其实不然。实际的设计很简单。思考设计不需要花费数天的时间,只需几分钟。实现设计需要大量时间,但这其中大部分时间都花在了探索上,因为我们中的许多人对 JMS 都很生疏(我对它总是很生疏,我靠 Google 为生)。

真正的胜利出现在我们在 QA 和生产复制环境中都完成了这项工作几个月后。在某个时候,我们的系统在 QA 中停止工作,但在其他所有环境中仍然可以工作,包括复制的生产环境。我们立即猜测队列配置不同。我们询问并确信队列配置是相同的。由于我们进行了测试,因此我们可以与某人一起工作并在某人查看队列时逐步执行我们的测试。我们做了尽职调查,最后说虽然我们不确定不是我们,但我们尽可能确定,唯一可识别的变量与使用一个 Tibco 实例与另一个实例有关。大约一周半之后,他们发现 QA 队列的配置不同。在所有这些事情发生的同时,我们的团队并没有停止处理这个问题的各个部分。

结论

通常会给出要实现的解决方案,或者解决方案受到现有外部环境因素的限制。虽然您将编写代码来解决这些给定约束的细节,但这并不意味着这些细节应该扩散到系统的其余部分。将实现隐藏在一个地方,并从您的域目标的角度为其提供一个接口。将细节扫到地毯下。

我一直处于昏迷状态,现在是什么时候?

您是否曾经处理过一个关心日期和/或时间的系统?您是如何获取当前日期的?您是如何处理时间流逝的?大多数系统都关心时间。在 Java 中,有很多方法可以获取当前日期和/或时间,但它们都倾向于使用它们运行的系统上的时间。

我有你的时间表

想象一个系统,它有许多工作项,每个工作项都使用一些资源。每个项目要么计划发生,要么正在发生,要么已经完成。当两个工作项尝试使用相同的资源时,就会发生冲突,您需要确保系统正确处理冲突。您将如何验证您的系统是否能很好地管理冲突?

图 14:工作项需要得到处理

领域分解

此描述中有几个关键概念:工作项、冲突和时间。

  • 工作项相对简单,它们具有名称、描述、开始日期/时间、持续时间和一个或多个资源。
  • 处理冲突听起来像是一个有趣的问题,并且可能有很多方法来处理冲突。最初我们可能采用先到先得的方式,稍后我们可能会采用最有价值先得的方式等。无论如何,我们需要将冲突解决的理念提升为系统中的头等大事。
  • 到目前为止,一切都很好。那么时间呢?

时间是一个有趣的概念。大多数时候,我们认为时间是理所当然的,如果我们 überhaupt 想一想的话。如果我们什么都不做,系统可能会有一段时间,时间会像墙上的时钟一样流逝。它始终存在,它的变化率似乎变化不大。但是,如果我们想让它以与现实不同的速度移动呢?如果我们让日期看起来与实际日期不同呢?跳过整个时间段怎么样?时间的关键在于它在没有用户干预的情况下发生变化,但如果我们想拥有时间呢?那甚至意味着什么?

以不同的速度移动

  • 您有一个时间敏感的系统,其中发生的事情的时间范围为秒、分钟、天。您希望随着时间的推移观察系统,但您不想在每小时顶部发生的事件之间等待一个小时。

日期与当前日期不同

  • 您正在使用特定于日期的真实生产数据在测试台上运行系统。现在,日期是在将来,但您想看看如果是明天、昨天之前一周,甚至是明年会发生什么。您可以更改生产数据的副本,也可以让您的系统认为它的日期与真实日期不同。

跳过整个时间段

  • 在您的系统中,事情发生在离散的时间。您希望确保在正确的时间发生正确的事情。您可以根据您选择运行系统的时间来确保这些事情得到设置,或者您可以设置时间并查看会发生什么。

如何训练你的时间领主

时间像其他商业概念一样吗?我们是否应该将其视为值得尊敬的一等公民?那会是什么样子?那能提供什么?请查看调度示例。

调度示例

功能:处理调度冲突

作为操作员,我希望确保功能冲突由适当的策略管理。

背景

  • 给定一个空的计划
  • 一个名为 Megatron_Torso 的工作项,计划在 10:00 开始,持续 15 分钟,并使用 3d_printer_1
  • 一个名为 Megatron_Head 的工作项,计划在 10:10 开始,持续 5 分钟,并使用 3d_printer_1
  • 一种先到先得的冲突解决方法
  • 业务时间为 9:59

场景:什么都没有发生

  • 那么在 9:59 不应该有任何活动项目

场景:一个项目处于活动状态

  • 那么Megatron_Torso 应该在 10:01 处于活动状态

场景:冲突已处理

  • 那么Megatron_Torso 应该在 10:10 处于活动状态
  • Megatron_Head 应该被阻塞

场景:延迟开始

  • 那么Megatron_Torso 应该在 10:16 完成
  • Megatron_Head 应该处于活动状态

场景:延迟的工作项完成时间较晚

  • 那么Megatron_Head 应该在 10:21 完成

调度示例是一个假设系统的描述,该系统已针对本文进行了重写,它基于我参与过的许多真实系统。这些示例是用一种名为Gherkin的语言编写的,该语言由一个名为Cucumber的工具使用。使用这种特殊的领域特定语言(DDD 和 BDD 社区称之为通用语言),我已经表达了对调度系统应该如何行为的一些期望/事实/示例。

这一系列示例试图描述在给定明确定义的起点和许多后续活动的情况下系统中应该发生什么。例如,根据“什么都没有发生”示例,在 9:59,什么都不应该处于活动状态。稍后,在 10:01,其中一个 WorkItem 应该处于活动状态。第一个冲突发生在 10:10,此时 WorkItem Megatron_Torso 仍在运行,而 Megatron_Head 必须等待共享资源 3d_printer_1 可用。

这种系统验证很常见,尽管这种方法并不常见。在这个领域,时间很重要。大多数时间对这个系统来说并不重要,只有基于工作计划的某些时间才重要。

哪些时间很重要?该示例通过明确说明有一个工作项在 10:00 开始,持续 15 分钟,另一个工作项在 10:10 开始,持续 5 分钟来明确描述这一点。要使用类似于边界测试的东西来验证我的测试,请选择重要时间点附近的时间。

我看到更典型的是选择彼此更近的时间并等待事情发生。例如,我将使用 15 秒而不是 15 分钟。这种测试设置虽然很常见,但指出系统没有掌握一个关键的领域概念:时间。

一个实现这一点的例子

如果您选择使用 Joda time,那么做这种事情就微不足道了。这是一个简单的 Java 类,它可以更改 Joda Time 生成的时​​间

@Component
public class BusinessDateTimeAdjuster {
  public void resetToSystemTime() {
    DateTimeUtils.setCurrentMillisSystem();
  }

  public void setTimeTo(int hour, int minute) {
    DateTimeUtils.setCurrentMillisFixed(todayAt(hour, minute).getMillis());
  }

  DateTime todayAt(int hour, int minute) {
    MutableDateTime dateTime = new MutableDateTime();
    dateTime.setTime(hour, minute, 0, 0);
    DateTime result = dateTime.toDateTime();
    return result;
  }
}

现在,像这样的表达式:And the business time is 9:59,我使用Cucumber-jvm为这个例子执行,执行以下方法

public class ScheduleSteps {
  @Given("^the business time is " + TIME + "$")
  public void the_business_time_is(int hour, int minute) {
    setTimeTo(hour, minute);
  }

  private void setTimeTo(int hour, int minute) {
    BusinessDateTimeFactory.setTimeTo(hour, minute);
    scheduleSystemExample.recalculate();
  }
}

public class BusinessDateTimeFactory {
  public static DateTime now() {
    return new DateTime();
  }

  public static void restoreSystemTime() {
    DateTimeUtils.setCurrentMillisSystem();
  }

  public static DateTime todayAt(int hour, int minute) {
    MutableDateTime dateTime = now().toMutableDateTime();
    dateTime.setTime(hour, minute, 0, 0);
    return dateTime.toDateTime();
  }

  public static void setTimeTo(int hour, int minute) {
    DateTimeUtils.setCurrentMillisFixed(todayAt(hour, minute).getMillis());
  }
}

此代码将时间设置为固定点。Cucumber-jvm 库允许在测试前后执行钩子。在这种情况下,测试后钩子会将时间重置回系统时间。

在实践中,引入像业务时间这样的领域概念的想法听起来可能需要做很多工作,但实际上并非如此。我在项目的后期才开始做这种事情,即使在一个成熟的项目中,引入这种想法所花费的时间也不如我在能够测试系统方面节省的时间那么多。对于一个数据点,引入一个简单的日期工厂可能需要几个小时(我会对其进行测试,因为日期往往很挑剔)。查找代码中出现new Date()或其等效代码的所有位置都需要使用正则表达式和递归搜索。我上次这样做时,代码中的 410 个地方可能需要 2 个小时才能修复。因此,在一个成熟的系统上,半天就够了。如果您使用的是 Joda time,您甚至不需要修复代码中调用new DateTime()的地方。虽然 Joda time 使其变得简单,但我已经使用 Java 中的 Calendar 完成了这项工作。这个想法听起来很大,但它比实际完成并实施它的工作要深远得多。

结论

我们认为很多事情是固定不变的。更糟糕的是,我们甚至没有注意到关键的概念,因为我们已经习惯了不去思考它们。我不记得我在哪里遇到过这个想法。我想这是很多年前一个项目中的观察结果。我们在本地数据库中使用生产数据的副本。生产数据有日期规则。这些日期经常不再是将来的日期,而且我们也经常收到新的生产数据,这些数据有不同的日期(这是一个不同的问题)。我们在时间过去后不断地“修复”日期,我最终意识到这种手动、重复且容易出错的活动完全是浪费时间。我们一直在更改日期,所以很明显我们需要控制日期。我第一次尝试这样做时,花的时间比半天多一点。从那以后,我在 5 个不同的生产项目中至少做了 5 次,现在我早早就开始做了,所以花的时间很少。我和几个质量保证人员发现这种功能很方便,而且非常节省时间。

然而,这个例子说明的是,我们的代码不需要依赖于像时间这样看似真实的东西。更一般的想法是:如果有什么东西导致了问题,就控制它。在这种情况下,控制恰好得到了库的轻松支持,但是如果您看一下最终的代码示例,我仍然引入了 BusinessDateTimeFactory,以便有一个单一的地方来捕获日期和时间的概念,然后依赖于它。

这就是总结

我们已经看到了一些在实际应用中使用 DIP 的例子

  • 采用一个笨拙的 API,它有太多方法,并对其进行驯服。
  • 消除库的抽象级别和域之间的不匹配
  • 拒绝外部约束,这些约束规定了特定的通信方式
  • 掌控时间本身

有些更明显是 DIP 的应用,而另一些则更像是适合其他设计原则。最后,哪种原则更适用于某种情况并不重要。Daniel Terhorst-North 声称所有软件都是一种负债,很好地概括了这一观点。作为一名开发人员,我的目标似乎是编写代码。然而,这就像问牙医你是否需要戴牙套。答案是肯定的,谢谢,我需要再付一笔首付买我的船。

我喜欢编写代码、学习新的编程语言以及所有这些。然而,如果我正在努力解决一个问题,对我来说重要的是要记住,软件通常是达到目的的一种手段,而不是目的本身。这对设计原则和敏捷实践都是如此。有意义的是记住工作的重点,然后让上下文决定什么是有意义的。如果您正在寻找一种方法来构建特定问题的解决方案,那么 DIP 是一个值得了解的工具。

更一般地说,能够帮助我更快地解决特定业务问题的原则和实践对该上下文来说是好的。它们可能不适用于其他情况。我倾向于在长期存在的系统上工作,这些系统通常涉及依赖于多个报告结构完成的工作。这意味着识别有问题的依赖关系并使用像 DIP 这样的设计原则来控制它们对我来说是一个反复出现的主题。这些想法中的任何一个都可能最终对您的特定问题造成可怕的后果。

如果您碰巧正在处理一个软件半衰期很短的东西,那么对您的上下文来说最好的事情可能是直接依赖于那些依赖关系。此外,如果您按照 Robert Martin 的定义来实践TDD(简单地编写自动化测试与 TDD 几乎没有任何关系),那么您可能能够根据需要进行彻底的更改。在这种情况下,DIP 会通知重构而不是预先设计。

识别依赖关系,然后确定是否值得明确处理它们,以及如果值得,在哪里处理,这是一种值得练习的有价值的技能。您可以将这些具体示例作为尝试的内容、在您工作时寻找什么类型事物的指南,甚至可以将它们作为您可以采取的具体措施来控制您的依赖关系。这些示例,或者就此而言的 DIP,是否有帮助或有害,将取决于您试图解决的问题。


重大修订

2013 年 5 月 21 日:添加了“不要接受既定的东西”和“我一直处于昏迷状态,现在是什么时候?”部分。

2013 年 5 月 1 日:首次发布