Day 26 「一个巨星的诞生」Entity、Repository 与单元测试

通常一个活动,最後登场的都是主角吧?理应如此,笔者记得有一年的金马奖颁奖典礼,主办单位不知道哪根筋打到,突然就把「最佳男主角」跟「最佳女主角」的奖项,挪到典礼中半段颁发,可想而知,颁完後,大家纷纷离场,後面的奖项场面变得异常冷清,当然转播的收视率也就惨不忍睹了。

这故事告诉我们,主角还是要压轴登场才好。

於是,今天来聊 Clean Architecture 中,最核心的 Entity,以及负责存取资料 的 Repository。

一个 Entity 的诞生

Entity 负责系统最核心的逻辑,这大家都知道,但到底什麽是最核心的逻辑?却不是那麽容易想像。於是,有人就这麽做:「把资料库的 Table 拿来,一对一地翻成程序物件,并称它们为 Entity」。

咦,我都是这麽做的呀,这样不对吗?

嗯,也不能说完全不对,但就是哪里怪怪的,应该说,有可能到最後做出来的东西不会差很多,但方向不对了,或者更精确地说:误把资料库错当成系统的核心了。资料库的工作是储存资料,对系统来说,就是个放东西的地方。Uncle Bob 认为这些东西虽重要,但最多就是个「重要的细节」,因为 Oracle 靠资料库赚钱、MySQL 靠资料库赚,但你不是,你靠你的 Solution 赚钱。所以 DB Table 决定 Entity 样貌是不合理的,再怎样也应该让 Entity 决定 DB Table 样貌才对。

好吧,那到底该由谁来决定 Entity 样貌?

笔者认为,应该由「Domain Model」来决定比较合适。Domain Model 就是你对 Problem Domain 的描绘,而 Solution 拿来解决 Problem Domain 的问题,且 Entity 是 Solution 的一部份,因此由 Domain Model 来决定 Entity 的样貌比较合理。

还是有点抽象耶!能再具体一点吗?

可以唷!「去问 Use Case 吧!」在提出 Solution 来解决真实世界的问题时,我们会与 Stakeholders (也可能是PO、PM 等)讨论一些重要「场景」,譬如申请奖学金、选课、期中退选等等,这些场景在 Clean Architecture 中,被定义为 Use Case 的工作。於是,在 Use Case 实现的过程中,我们会对一些关键的参与角色有一些期待。可能期待它们提供一些资料,可能期待它们做一些事情。这些「期待」,便定义了 Entity 该做的事。随着被定义的 Use Case 越来越多,Entity 们的样貌也就能愈趋完整。

Entity 有行为?我有没有听错?

你没有听错。在物件导向的世界里,物件的「封装」,就是期待物件之件尽量少拿对方资料,而多叫对方做事。这样,物件自己身上要以什麽方式,存放什麽资料,呼叫方就不用管,尔後当资料要换个方式存放,甚或是要再转包出去给别人存放时,只要对外行为不变,呼叫方也就完全不会受影响,也就自然达到解耦合了。

举例:重构出好用的 Entity

在前一篇中,我们发现了申请奖学金的 Service 有一个坏味道:Feature Envy。还记得我们说了为什麽暂时不处理吗?就是为了留到现在,我们来看看 Entity 怎麽为了 Service 好用而「演化」出来。先看原程序:

private void checkDeadline(Scholarship scholarship) throws ClientSideErrorException {
    LocalDate deadline = scholarship.getDeadline();
    LocalDate now = LocalDate.now();
    if (now.isAfter(deadline)) {
        throw new ClientSideErrorException("application over time", 374);
    }
}

private void checkProgramIsPhD(Student student) throws ClientSideErrorException {
    if (!student.getProgram().equals("PhD")) {
        throw new ClientSideErrorException("this scholarship is for PhD students only", 375);
    }
}

上面对时间的检查 checkDeadline 中,deadline 是放在 Scholarship 这个 Entity 中的,而检查身份的 checkProgramIsPhD 也不是 Service 的工作,我们也认为这是因为「这个 Scholarship」是 PhD 专用的,应是属於 Scholarship 自已定义的逻辑,而比较适合放在 Scholarship 中(尽管後者还没有明显的 Feature Envy)。

因此,我们利用 Move Method 的手法,把这两个工作,搬到 Scholarship 中,只留下流程,原方法就成了:

private void checkDeadline(Scholarship scholarship) throws ClientSideErrorException {
    if (scholarship.checkDeadline()) {
        throw new ClientSideErrorException("application over time", 374);
    }
}

private void checkProgramIsPhD(Scholarship scholarship, Student student) throws ClientSideErrorException {
    if (!scholarship.checkQualification(student)) {
        throw new ClientSideErrorException("this scholarship is for PhD students only", 375);
    }
}

搬过去时,我们也顺便把「检查资格」的方法,改名为 checkQualification,这下「检查学生是否符合此奖学金申请资格」的工作,就落到 Scholarship 身上,成为 Entity 要提供给 Use Case 使用的「核心逻辑」了。

我们来看看新介面,现在这个检查介面暗示我们「奖学金会依自己的设定,去检查学生的资格」,听起来蛮合理的,这证明这个重构是有助於理解的,我们可以放心了。

至此,我们把一般 Clean Architecture 初学者最常搞混的 Use Case 与 Entity 权责理清楚了,单就「检查资格」这件事来看,这两者的工作分别是:

  • Entity:根据 Domain Model 的定义,检查 Student 是否符合资格
  • Use Case:在适当时机叫 Scholarship 检查

尔後,随着系统开发的进行,Use Case 一直增加,Entity 的样貌自然就能慢慢堆叠出来。至於 Entity 身上该怎麽存放资料,怎麽处理「大学生」与「硕士生」的事情…这个嘛,就又回到我们更早之前讲的,Solid、多型、注入、设计模式…等的基本功,不需要 Clean Architecture 出马了。

注意:你发现了吗?这时,我们都还不需要决定 DB 的样貌,甚至还不用决定是否需要一个 DB 唷!别忘了,「延迟决定」也是敏捷开发重要的手段之一。

为 Service 服务的 Repository

至於在系统中,担任「档案管理员」的 Repository,在 Controller、Service、Entity 都完成後,我们就知道该怎麽设计它了。因为这时「使用者们」都已经做好了嘛!照着介面刻,应是不会出什麽大乱子。

Repository 在系统中,大多时间在处理「黑手」的工作,如果资料放在 DB,它就去操作 DB,如果资料放在第三方 API,它就去操作第三方 API,以此类推,它就是一个「为 Service 服务,代替 Service 处理 I/O 细节」的无名英雄。


无名英雄示意图,截自 YouTube

有了 Repository,Service 就不用管 I/O 的操作细节,今天就算把 Database 从 MySQL 换成 Oracle,或是加个 Redis 当 Cache,Service 一概不用知道,如此,Service 就可以专心当它的「流程管理者」,权责就分开了。

DIP 与 Repository

Repository 身为「档案管理员」,根据 Uncle Bob 的规划,应该放在 Clean Architecture 的 Interface Adapter 层,直接与框架和 I/O 接触,但 Service 身处较内层的 Use Case 层,想要操作 Repository,又不能违反 Clean Architecture 的「依赖原则 - 内层不依赖於外层」,这该怎麽办呢?

其实答案也在先前讲过了,要请 DIP:Dependency Inversion Principle 来帮忙。在这个场景下,Service 是需求方,它要订好一个介面,让身在外层的 Repository 去实作,此外层依赖於内层是没有问题的,而这个介面本身则与 Service 一起放在 Use Case 层,这样一来同层依赖也没有问题,事情就解决了。

读者可以参考一下 GitHub Repository 中,ApplyScholarshipService 所依赖的三个 Interface,会比较具体一些。

结论

我们讨论了 Clean Arcitecture 的分层原则中,位於最核心的 Entity,以及存取 Entity 的无名英雄 Repository,并再一次复习了如何同时遵守 Uncle Bob 提出的另外两个原则:依赖原则与跨层原则。至此,我们算是对系统的一个 Command,从头到尾完整地走了一遍。笔者的经验,如果每个 Command 都能像这样按部就班地安排,相信架构要乱也乱不到哪里去。

当然,会有点麻烦。

麻烦是麻烦了点,但是也没办法,为了不要让不乾净的程序码使我们「越来越慢」,我们只能时时注意,养成好习惯。才能防范於末然。

谜之声:「习惯了,也就好了。」

Reference

  1. 物件的封装:https://en.wikipedia.org/wiki/Encapsulation_(computer_programming)
  2. 搞笑谈软工 - 再谈Clean Architecture三原则:http://teddy-chen-tw.blogspot.com/2020/08/clean-architecture.html
  3. GitHub Repository:https://github.com/bearhsu2/ithelp2021.git
tags: ithelp2021

<<:  {DAY 14} NumPy 学习笔记(下)

>>:  day 18 - graceful shutdown 优雅地退场

Day2 专案成立,来谈谈花钱的艺术

再来,谈到专案终於成案,老板放行以後,当然是很想好好大展身手。但是各路英雄好汉啊,有一个天敌,叫做一...

第 6 集:CSS 社交距离(下)

此篇会介绍使用 text-align、vertical-align 对齐时的注意事项以及常见问题,最...

[Golang]宣告变数的方式

第一种: 基本变数宣告 package main import "fmt" fu...

Day 14 「不残而废」单元测试、Code Smell 与重构 - Data Class 篇

图片来源:Wikipedia 大家听过「帕拉林匹克运动会(帕奥)」吗?它是自 1960 ~ 70 ...

Day13 Redis应用实战-List操作

Redis 资料型态List List是有顺序(透过index 进行存取),可重复的资料结构(val...