从贫血模型到DDD的重构

栏目: Java · 发布时间: 6年前

内容简介:我们将重构一个简单的问题跟踪应用程序,通过典型的层隔离,根据领域驱动的战术设计模式进行建模。这个问题跟踪应用程序非常简单。您可以使用它执行多项业务操作 - 全部通过REST API,并且所有操作都完全由集成测试覆盖(请参阅某些操作具有验证规则:

我们将重构一个简单的问题跟踪应用程序,通过典型的层隔离,根据领域驱动的战术 设计模式 进行建模。

这个问题跟踪应用程序非常简单。您可以使用它执行多项业务操作 - 全部通过REST API,并且所有操作都完全由集成测试覆盖(请参阅 此处的 测试)。您可以:

  • 创造一个新问题
  • 得到所有问题
  • 评论一个问题
  • 改变问题状态

某些操作具有验证规则:

  • 可用的状态转换是:new - > in_progress,in_progress - > done
  • 问题的注释只能添加到状态为new或in_progress的问题中

第一个实施 - 贫血模型

我们的第一个实现 是非常常见的。我们有4个包负责我们的应用程序的给定层。所以我们有一个带有IssueController 的控制器包,我们处理所有的http请求。我们的问题还有一个模型包,它是JPA实体,以及IssueComment。最后,有服务类IssueService,以及与存储库包中的实体相关的两个存储库。

典型的请求调用往返非常简单:

  • 在控制器中我们处理http请求,从url或从请求内容中获取参数并将它们传递给服务
  • 服务是我们应用程序的核心 - 所有逻辑都在这里。我们使用存储库加载实体,执行一些业务操作,并在修改后返回对象(如果需要)
  • Controller在调用服务后检索域对象,并将其(如果有)转换为json

服务可以更改问题状态:

<b>public</b> <b>void</b> update(Long issueId, IssueStatus newStatus) {
    Issue issue = issueRepository.findOne(issueId);
    <b>if</b> (issue.getStatus() == DONE && newStatus == NEW || issue.getStatus() == NEW && newStatus == DONE) {
        <b>throw</b> <b>new</b> RuntimeException(String.format(<font>"Cannot change issue status from %s to %s"</font><font>, issue.getStatus(), newStatus));
    }
    issue.setStatus(newStatus);
}
</font>

你可以 在这里 找到所有的服务操作,控制器调用它 看起来像这样。

让我们尝试将此应用程序重构为领域驱动设计。

重构实体 - 丰富其行为

根据DDD概念,我们需要考虑我们的域模型及其不变量,识别 实体,值对象 以及 聚合根。 我们的实体候选人是Issue和IssueComment,因为这些对象是我们系统中需要识别的对象。事实上,IssueComment不必是一个实体 - 我们不使用它的id,也不需要区分这些对象。我们将其建模为具有id的JPA实体,以简化ORM映射。所以在DDD世界中,“问题Issue”成为唯一的实体,也成为聚合根 - 它包含对注释的引用,但在修改时我们将它们视为一个单元。

如果我们知道我们的聚合根,那么很容易开始重构。所有改变聚合状态的操作都需要在其中。因此,我们需要改变状态并将注释方法从服务添加到“问题Issue”模型中。

@Entity
<b>public</b> <b>class</b> Issue {
    <font><i>// some mapping</i></font><font>
    <b>public</b> <b>void</b> changeStatusTo(IssueStatus newStatus) {
        <b>if</b> (<b>this</b>.status == IssueStatus.DONE && newStatus == IssueStatus.NEW || <b>this</b>.status == IssueStatus.NEW && newStatus == IssueStatus.DONE) {
            <b>throw</b> <b>new</b> RuntimeException(String.format(</font><font>"Cannot change issue status from %s to %s"</font><font>, <b>this</b>.status, newStatus));
        }
        <b>this</b>.status = newStatus;
    }

    <b>public</b> <b>void</b> addComment(String comment) {
        <b>if</b> (status == IssueStatus.DONE) {
            <b>throw</b> <b>new</b> RuntimeException(</font><font>"Cannot add comment to done issue"</font><font>);
        }
        comments.add(<b>new</b> IssueComment(comment));
    }
}
</font>

当然要实现这一点,我们需要稍微调整一下hibernate映射。我们改变了评论字段:

@Transient
<b>private</b> List comments = <b>new</b> ArrayList<>();

改为:

@OneToMany(cascade = CascadeType.MERGE)
<b>private</b> List comments;

我们使用延迟加载和级联,替代另外通过存储库加载,由于这个原因,我们的聚合可以修改其不变量(字段),而无需加载任何其他资源。

此外,所有可用的操作现在都在问题Issue类中,它至少有3个优点:

  • 所有验证逻辑现在都可以放在发生更改的对象中
  • 我们能一下子看到了“问题Issue”API - 这使我们能够非常快速地了解从业务角度可以解决的问题
  • 没有人可以引入我们对象的不一致状态,因为没有公共修饰符(如setStatus)可用

另一个不那么明显的好处是聚合内部的操作只能修改其不变量。让我们假设一个需求,需要对问题Issue发表评论,如果采取在服务中修改实体的办法,我们只要将UserRepository注入到IssueService中,添加评论后我们更改User模型并保存它。在DDD模型中没有办法做到这一点 - 我们没有任何机制在Issue实体内来加载和修改用户User模型。

重构服务 - 简化

由于业务逻辑从服务转移到实体,现在简化了服务。它只做3件事:

  • 从存储库加载聚合
  • 在加载的聚合上调用方法来修改它
  • 保存修改后的对象

服务的更新状态方法示例是:

<b>public</b> <b>void</b> update(String issueId, IssueStatus newStatus) {
    Issue issue = issueRepository.findBy(IssueId.from(issueId));
    issue.changeStatusTo(newStatus);
    issueRepository.save(issue);
}

所有的业务逻辑都归于问题Issue模型。如果服务没有执行其他操作,比如在其他聚合根上发送事件或操作,我们可以做更多。摆脱服务并在控制器中完成所有逻辑。

重构存储库 - 独立于具体实现

为了与DDD存储库概念保持一致,我们需要对其进行一些重构。在贫血模型中,我们使用了2个存储库 - 一个用于Issue,第二个用于IssueComment。都通过创建扩展CrudRepository的接口,使用spring-data存储库创建存储库。这种方案有一些缺点。

首先,它直接与具体实现耦合。如果我们想要更改它(例如测试时使用内存保存),我们需要做一些模拟或提供一些自定义bean,其中包含我们在CrudRepository中实现的所有方法。

其次,使用spring-data存储库,我们得到了许多我们不想要的方法的默认实现,比如count,exists或deleteAll。

因此,我们将存储库重构为一个满足我们的希望只拥有一些方法的接口。

<b>public</b> <b>interface</b> IssueRepository {
  List findAll();
  Issue save(Issue issue);
  Issue findBy(IssueId issueId);
}

此外,您可以看到现在使用IssueId值对象而不是Long来查找问题。这样我们就避免了从不同的实体提供一些不同的Long的错误。

此接口的实现使用下面的spring data存储库,但当然您可以根据使用情况轻松地将其替换为您想要的任何内容。

重构包

最后值得一提的是在从贫血模型迁移到ddd时我们的应用程序的重新打包。我们从4个分组开始分组。在DDD模型中,我们有3个包:应用程序,域和基础结构。

  • 域包含我们的实体和值对象以及存储库接口(我们在这里也有IssueIdSequenceGenerator,但它是另一个我们将在另一篇文章中描述的故事)。所有的业务逻辑都属于这里。
  • 应用程序具有控制器和与从json转换为模型和返回相关的所有内容。它还包含应用程序服务(我们的IssueService)。应用程序使用域对象来检测它们(加载聚合,调用业务操作)。
  • 最后一个包是基础结构,它包含域中使用的所有接口的实现,以及内部用于提供此实现的所有类(例如CrudIssueRepository)。

多亏了这样的重新打包,我们在定位新内容的位置方面没有任何问题,这会在新的业务需求到来时出现。问题可能出现在哪里放置新类,例如,如果我们想要引入用户user的模块'。我们是否应该在应用程序,域和基础架构中添加新软件包,并在每个软件包下放置当前问题模型“模块”内部的问题包中,并创建新用户模块?

当然不是。根据DDD概念,用户'模块'是一个不同的 有界上下文, 所以我们应该创建单独的模块(maven one)或者至少创建2个不同的根包:

DDD值得做吗?

我们刚刚经历了从 贫血模型DDD的 迁移过程,正如您所看到的,它并不那么简单。在更大的应用程序中,它可能非常困难,甚至也许是不可能实现的。值得做吗?当然答案是:这取决于:

DDD不是银弹。对于简单的CRUD应用程序或具有很少业务逻辑的应用程序,它可能是一种过度杀伤力。一旦您的应用程序变得非常大,DDD值得考虑。再次指出使用DDD可以获得的主要好处:

  • 通过有意义的方法更好地表达域对象中的业务逻辑;
  • 域对象通过仅操作其内部来封闭事务边界,这简化了业务逻辑实现,
  • 非常简单的包结构
  • 更好地区分领域和持久性机制

以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

代码之美

代码之美

Grey Wilson / 聂雪军 / 机械工业出版社 / 2008年09月 / 99.00元

《代码之美》介绍了人类在一个奋斗领域中的创造性和灵活性:计算机系统的开发领域。在每章中的漂亮代码都是来自独特解决方案的发现,而这种发现是来源于作者超越既定边界的远见卓识,并且识别出被多数人忽视的需求以及找出令人叹为观止的问题解决方案。 《代码之美》33章,有38位作者,每位作者贡献一章。每位作者都将自己心目中对于“美丽的代码”的认识浓缩在一章当中,张力十足。38位大牛,每个人对代码之美都有自......一起来看看 《代码之美》 这本书的介绍吧!

RGB转16进制工具
RGB转16进制工具

RGB HEX 互转工具

在线进制转换器
在线进制转换器

各进制数互转换器

URL 编码/解码
URL 编码/解码

URL 编码/解码