让我们通过介绍最著名的单一职责原则开始软件开发中的基本原则之旅。
在我的职业生涯中,有一段时间我是一个“代码猴子”,不仅仅是在开始的时候,甚至在完成我的学业之后,我也没有成为一名软件工程师。
有一年夏天,我想反思一下我不断得到的反馈。“你知道,你的代码可以工作。你写得超级快。但是,你知道,没有人能理解里面发生了什么。你怎么能期望我扩展它?”
所以,我花了整个夏天阅读关于软件制作的书籍。是的,制作。我花了一段时间才有了突破,现在我有了。
我用SOLID原则取得了这样的突破,这些原则第一次出现在Uncle Bob的文档中,后来他在他的书《Clean Architecture》中进一步阐述了这些原则。
在这篇文章中,我计划通过在 TypeScript 中提供示例来开始我的 SOLID 原则之旅。列表中的第一个,代表 SOLID 中的字母 S,是单一责任原则。
违反单一责任原则
单一责任原则(SRP)规定每个软件模块应该有一个且只有一个原因来更改。
上面这句话是鲍勃大叔写的。它的含义是最初绑定到一个模块,并通过将它们映射到组织的日常工作来划分责任。
如今,SRP 的应用范围很广,它涉及到软件的不同方面,我们可以在类、函数和模块中使用它的目的。
这个原则如何应用到代码中呢?让我们检查下面的示例,其中我们违反了SRP。
违反单一责任原则
class EmailService {
constructor(
private manager: EntityManager,
private smtpHost: string,
private smtpPassword: string,
private smtpPort: number
) {}
public async send(from: string, to: string, subject: string, message: string) {
// 1. store new Email record in the database
await this.manager.save(new EmailDB(
from,
to,
subject,
message,
));
// 2. send new email via SMTP protocol
const transporter = nodemailer.createTransport({
host: this.smtpHost,
port: this.smtpPort,
auth: {
user: from,
pass: this.smtpPassword
}
});
await transporter.sendMail({
from,
to,
subject,
text: message
});
}
}
在上面的例子中,有一个EmailService
,只有一个方法,Send
,EmailService
的职责是将电子邮件消息存储在数据库中,并通过 SMTP 协议发送。
一旦描述某个代码块的责任时不可避免地需要使用“and”这个词,它就打破了单一责任原则。
EmailService
误用的后果:
-
当我们改变表结构或存储类型时,我们需要改变负责发送电子邮件的代码。
-
当我们想要集成 Mailgun 或 Mailjet 时,我们还需要更改负责存储数据的代码。
-
如果我们选择在应用程序中发送电子邮件的不同集成,每个集成都需要有逻辑来在数据库中存储数据。
-
如果我们将应用程序的责任分成两个团队,一个负责维护数据库,另一个负责集成电子邮件提供商,他们将工作在相同的代码上。
-
这个服务对单元测试不友好。
我们如何尊重单一责任原则
为了在这种情况下分担责任,使代码块只存在一个原因,我们应该为它们中的每一个定义一个类。
这实际上意味着有一个单独的类用于在某个存储中存储数据,另一个类则通过与电子邮件提供商集成来发送电子邮件。
存储库
interface EmailRepository {
save(from: string, to: string, subject: string, message: string);
}
class EmailDBRepository {
constructor(private manager: EntityManager) {}
public async send(from: string, to: string, subject: string, message: string) {
await this.manager.save(new EmailDB(
from,
to,
subject,
message,
));
}
}
发送方
interface EmailSender {
send(from: string, to: string, subject: string, message: string);
}
class EmailSMTPSender {
constructor(
private smtpHost: string,
private smtpPassword: string,
private smtpPort: number
) {}
public async send(from: string, to: string, subject: string, message: string) {
const transporter = nodemailer.createTransport({
host: this.smtpHost,
port: this.smtpPort,
auth: {
user: from,
pass: this.smtpPassword
}
});
await transporter.sendMail({
from,
to,
subject,
text: message
});
}
}
服务
class EmailService {
constructor(
private repository: EmailRepository,
private sender: EmailSender
) {}
public async send(from: string, to: string, subject: string, message: string) {
await this.repository.save(from, to, subject, message);
await this.sender.send(from, to, subject, message);
}
}
这里我们提供两个新类。第一个是EmailDBRepository
,作为 EmailRepository
接口的实现。它包括对在底层数据库中持久化数据的支持。
第二个是 EmailSMTPSender
,它实现了 EmailSender
接口,只负责通过 SMPT 协议发送电子邮件。
最后,新的 EmailService
包含上面的接口,并委托发送电子邮件的请求。
EmailService
是否仍然具有多重职责,因为它仍然包含存储和发送电子邮件的逻辑?
EmailService
不负责存储和发送电子邮件,而是将这些任务委托给下面的接口。它的职责是将处理电子邮件的请求委托给底层服务。
在保持和委托责任之间存在差异。如果对特定代码的改编可以消除责任的整个目的,我们就谈论保持。如果即使在消除特定代码后,责任仍然存在,那么我们就谈论委托。
单一职责原则应用于功能
单一职责原则适用于编码的许多不同方面,而不仅仅是类。为了更好地了解其在函数中的应用,请查看下面的示例:
一个具有多重职责的函数
import jwt_decode from "jwt-decode";
const extractUsername = (request: http.ClientRequest): string => {
try {
// 1. extract JWT token from headers
const raw = request.getHeader('Authorization');
const claims = jwt_decode(raw);
// 2. extract username claim from JWT token
if (claims.hasOwnPropery('username')) {
return token['username'];
}
return ''
} catch (e) {
return ''
}
}
函数 extractUsername
没有太多行,它提供从 HTTP 头提取原始 JWT 令牌的支持,如果用户名在其中,则返回用户名的值。
你能看到,这个方法有多重职责。下面是新代码的建议:
重构代码
import jwt_decode from "jwt-decode";
const extractUsername = (request: http.ClientRequest): string => {
const raw = extractRawToken(request);
const claims = extractClaims(raw);
if (claims.hasOwnPropery('username')) {
return token['username'];
}
}
const extractRawToken = (request: http.ClientRequest): string => {
return request.getHeader('Authorization');
}
const extractClaims = (raw: string): Record<string, string> => {
try {
return jwt_decode(raw) ?? {};
} catch (e) {
return {};
}
}
在上面的例子中有两个新函数,第一个是 extractRawToken
,负责从HTTP头部提取原始JWT令牌。
如果我们改变头部中保存令牌的键,我们应该只触及一个方法,第二个是 extractClaims
,这个方法负责从原始 JWT 令牌中提取声明。
最后,我们的旧函数 extractUsername
在将令牌提取请求委托给底层方法之后,从 claims 中获取特定的值。
应用于属性的单一责任原则
还有更多的例子,其中许多是我们日常使用的,我们使用其中一些是因为一些框架规定了错误的方法,或者我们太懒惰了,没有提供适当的实现。
混合不同的实体模式
class User extends BaseEntity {
@PrimaryGeneratedColumn()
id: string;
@Column({ name: 'username' })
username: string;
@Column({ name: 'birthdate' })
birthdate: Date;
//
// some more fields
//
public get isAdult(): boolean {
const modified = new Date();
modified.setFullYear(modified.getFullYear() - 18);
return modified >= this.birthdate;
}
}
上面的例子展示了活动记录模式与域驱动设计中的实体模式混合的典型实现。
为了正确地交付代码,我们需要提供单独的类:一个用于在数据库中持久化数据( UserDB
),第二个用于扮演实体的角色( User
)。
单一职责原则甚至可以在不明确提供任何功能的情况下被打破:
一个万能的类
class Transaction extends BaseEntity {
@Min(0)
@ApiProperty()
@Column({ name: 'amount' })
amount: number;
//
// some more fields
//
}
我们不能提供一个更小的类,但却要承担更多的责任。
Transaction
描述了到数据库表的映射,并且它是 REST API 中 JSON 响应的容器,但由于验证部分,它也可以是 API 请求的 JSON 主体。
One class to rule them all.
为了再次遵循这个原则,我们需要将 Transaction 拆分为两个类:
-
TransactionDTO
来表示 API 响应或请求,并带有所有必要的验证。 -
TransactionDB
来表示数据库映射。
结论
单一职责原则是SOLID原则中的第一条,它代表了SOLID这个单词中的字母S,它声称一个代码结构必须只有一个存在的理由。
欢迎关注公众号:文本魔术,了解更多