
本文是 Solid Book - The Software Design & Architecture Handbook w/ TypeScript + Node.js 的一部分。如果你喜欢这篇文章,请查看它。
单一责任原则需要领域知识吗?
TLDR;是的,我们必须足够关心和了解领域,以便做出明智的设计决策。
SRP是SOLID原则中最难理解的原则,因为每个人对它有不同的解释。
我将尝试解释为什么理解领域可以帮助你理解如何实现SRP。
讨论
在最近的一次讨论中,关于上一篇关于软件设计原则没有被介绍给初级开发人员的文章,有一条评论真的让我印象深刻。
"我认为SOLID本身就有很多深奥的废话,比如单一责任到底是什么。是的,不要给我说什么 "改变的一个理由"。除非你了解你所工作的领域(业务方面),否则这毫无意义。除非你在这个领域有实际的经验,否则你不可能理解这个领域"。
我认为 "理解领域 "完全是 单一责任原则的重点。理解领域是我们能够写出只对一件事负责的代码的唯一方法。
在SOLID原则的指南中,我们说SRP被定义为。
"一个类或函数应该只有一个变化的理由"。
这意味着我们根据使用它的用户的社会结构来拆分代码。所举的例子是一个包含人力资源部门、IT部门和会计部门的应用程序,这些部门都需要报告他们的工作时间并计算他们的工资。
class Employee {
public calculatePay (): number {
// implement algorithm for hr, accounting and it
}
public reportHours (): number {
// implement algorithm for hr, accounting and it
}
public save (): Promise<void> {
// implement algorithm for hr, accounting and it
}
}
而我们意识到,由于每个Employee 的算法可能是不同的,而且更改请求很可能来自每个部门,因此,从一个类中创建和定位一个单一的算法来负责每个不同的角色(HR、IT和会计)是危险的。
我们决定最好将他们的算法分开。
abstract class Employee {
// This needs to be implemented
abstract calculatePay (): number;
// This needs to be implemented
abstract reportHours (): number;
// let's assume THIS is going to be the
// same algorithm for each employee- it can
// be shared here.
protected save (): Promise<void> {
// common save algorithm
}
}
class HR extends Employee {
calculatePay (): number {
// implement own algorithm
}
reportHours (): number {
// implement own algorithm
}
}
class Accounting extends Employee {
calculatePay (): number {
// implement own algorithm
}
reportHours (): number {
// implement own algorithm
}
}
class IT extends Employee {
...
}
这就是一个很好的例子。当我们把Employee 类中的算法分解成独立的算法时,我们就有可能把自己从试图在一个类中维护3个不同的算法(这些算法可能各自独立地容易发生变化)的混乱中拯救出来。
问题是:我们怎么知道我们需要这么做?
我们怎么可能知道这样做是件好事?
这是因为我们在思考领域的问题。
软件设计是对未来的一种有根据的猜测
有时候,我把软件设计等同于足球比赛中的中场。作为一个中场球员,你必须随时注意你周围发生的事情。一个好的中场球员应该在任何时候都试图预测未来3秒内会发生什么。
一名优秀的中场球员对周围的环境有很强的感知力,她经常会在队友需要她的时候出现在球场上,甚至在队友知道他们需要她的时候就已经在那里了。
她能够识别出她的队友是否以及何时会被阻挡并在传球时受到压力,因此她会将自己定位在可以传球的位置上。
软件设计和架构也是如此。我们(通过抽象和接口)对我们预测的未来需要发生的事情做出最佳猜测,而不需要投入所有的前期精力来实现我们不需要的东西(YAGNI)。
我们做出这些明智和有教养的设计决定的唯一方法是什么?
理解我们所处的领域
如果我们不了解我们正在编写代码的领域,我们就注定要制造昂贵的混乱,因为软件需求肯定会随着时间而改变。
因此,我相信如果你了解这个领域,单一责任就能正确完成。在我早期的合作角色中,我写的相当多的糟糕的代码都源于我不关心对领域的理解,而只是想证明我可以写出能用的代码。
不要像我一样。不要成为一个代码🐵。
你花了多少时间与领域专家交谈,加强对领域的理解,并提出问题,这往往关系到从我们的能力手中出来的代码的质量。
这段代码对你来说是单一的责任吗?
我在网上找到了这个Nodejs/JavaScript代码的例子,我想谈谈它。
const UserModel = require('models/user');
const EmailService = require('services/email');
const NotificationService = require('services/notification');
const Logger = require('services/logger');
const removeUserHandler = async (userId) => {
const message = 'Your account has been deleted';
try {
const user = await UserModel.getUser(userId);
await UserModel.removeUser(userId);
await EmailService.send(user.email, message);
await NotificationService.push(userId, message);
return true;
} catch (e) {
console.error(e);
Logger.send('removeUserHandler', e);
};
return true;
};
这段代码对你来说是单一责任吗?
起初,我认为没有,因为它要利用几个不同的服务,而这些服务可能与这段代码可能所在的User 子域无关。但后来我又想了一下。
几乎是
几乎是这样,因为在阅读和理解了这个removeUserHandler use case 1适配器的作用后,它似乎要负责2件事情。
- 删除用户 此外,还有
- 所有这样做的副作用(发送电子邮件、通知用户、在发生故障时记录)。
虽然这不是一个单一的责任,但对我来说,这是一个公平的责任授权。把这两个问题分开是很好的,但如果这不是一个非常复杂的应用,我就不会推动它。
如果明天,我的经理告诉我。
"嘿,哈利勒,我需要你确保在用户被删除后,我们也会从亚马逊S3中删除他们的图片"
我会清楚地知道在哪里添加这段代码,因为有一个地方可以改变删除用户的副作用。此外,唯一需要改变的原因是我们改变了对删除用户后发生的事情的要求。
用领域事件和观察者模式来改进它
使用域事件,我们实际上可以从Users 子域派发一个UserRemoved 域事件,并从Email (以及从Notification 子域派发同样的东西)订阅该事件。
/**
* modules/email/subscriptions/AfterUserRemoved
* This class resides within the Email subdomain (/modules/email)
*/
class AfterUserRemoved implements IHandle<UserRemoved> {
private emailService: IEmailService;
constructor (emailService: IEmailService) {
this.emailService = emailService;
}
private subscribeToDomainEvents (): void {
DomainEvents.register(this.onUserRemoved.bind(this), UserRemoved.name)
}
/**
* @desc onUserRemoved, a handler for the UserRemoved domain event gets called
* when the UserRemoved event is dispatched from the Users subdomain.
* This is an example of the Observer pattern.
* It's also how we can prevent 'God'-classes that know about everything and
* quickly become unmaintainable.
*/
private async onUserRemoved (event: UserRemoved): Promise<any> {
const { userId, email } = event.user;
const message = 'Your account has been deleted';
try {
await this.emailService.send(email, message);
} catch (err) {
console.log(err);
}
}
}
这样我们就不需要在同一个类中同时处理用户的实际移除和这样做的副作用了。2当可能有几个跨架构边界的副作用(针对一个特定的领域事件)时,观察者模式在这里特别有帮助。
事实上,理解领域可以改善我们的代码,使堆栈的每一层的责任都是单一的和集中的。
结论:设计随着领域的启蒙而改善
当我们在架构层面上理解了领域之后
- 我们能够按模块实现包
- 我们能够将代码分割成子域
- 我们能够确定微服务如何独立部署。
当我们在模块层面上理解了这个领域时。
- 我们能够识别出一个代码块不属于那个特定的子域/模块,而更适合放在另一个子域中
当我们了解这个领域时,在class 。
- 我们就能理解这个代码块是否属于一个辅助/实用类,或者是否有必要留在这个类中。
1在鲍勃叔叔的 "清洁架构 "中,他谈到用例是清洁架构的主要结构之一。用例负责从资源库中获取实体,通过领域服务执行业务逻辑,并将这些变化用资源库持久化到系统中。用例是灵活的,以至于它们对外部基础设施层的构造是不可知的。这意味着你可以把它们挂起来,由Web控制器(用于RESTful APIs)、SOAP(如果你需要与传统系统集成)或你能想象到的任何其他类型的协议使用。最常见的用法是把它们挂到RESTful API控制器上。
2如果我们要采用领域驱动设计的事件驱动方法,最初确定你的项目子域可能很难弄清楚。
有一个叫做Event Storming的过程,它可以让你弄清楚你的领域中存在哪些事件,以及它们属于哪个聚合体。这可以帮助弄清楚你的企业中存在哪些子域!