编写代码可以像浇筑混凝土一样。
如果你过去写的东西不适合你今天的需要,那么改变的代价可能很高。
而改变是不可避免的。
因此,如果我们要写代码,我们需要写出可以改变的代码。
这比听起来要难得多。
我最初写代码的方法是:"如果我有足够的时间去做,我就可以把它砍下来,让它工作。
我意识到,第一次就把事情做好很容易。这只是蛮力,真的。
但是在现有的代码中添加功能?让它第二次、第三次、第四次都能工作?对某件事情进行多次迭代而不引入bug是很难做到的。
我应该把它改成*"如果我有足够的时间去做,我可以把它黑在一起,让任何东西都工作[一次]"*。
在18万行的JavaScript项目中,我发现很难快速迭代。迭代和渐进式改进是敏捷的全部内容。
最终,我厌倦了向用户推送错误,并陷入自己制造的混乱之中。我意识到有些事情需要改变。
我开始学习TypeScript和研究软件设计。
当我们刚开始编码的时候,我们往往很少考虑软件设计。
我们为什么要这样做呢?在我们实现了对编程语言的灵巧运用后,我们把精力集中在对抗未定义的错误上,这意味着什么,以及其他一些恼人的事情。
更不用说,现在要进入JavaScript世界是相当有挑战性的。
JavaScript开发者有很多事情要弄清楚,就像现在这样(浏览器、转译、构建工具,以及现在的TypeScript)。
与Java和C#开发人员相比,JavaScript开发人员的学习路线图可能要简单得多。
所以今天,我在这里给那些不能容忍再写一个脆弱的Node.js后端的JavaScript开发者带来设计方面的意识。
我们都做过,而且很痛苦......慢慢地。
为了走得快,我们需要走得好
我认为答案就是鲍勃叔叔的SOLID原则。
什么是SOLID原则?
SOLID原则是一套软件设计原则,教我们如何构造我们的函数和类,以便尽可能的健壮、可维护和灵活。
快速的历史
SOLID原则是由Robert C. Martin(Bob叔叔)在2004年左右推广的一个记忆性缩写词。它们代表了
- [S:单一责任原则]
- [O:开放-封闭原则]
- [L: Liskov-Substitution原则]
- [I: 接口隔离原则]
- [D: 依赖性反转原则]
主要好处
如果你问我,熟知SOLID原则的主要好处是,你将学会如何。
- 编写可测试的代码
- 写出容易理解的代码
- 写出的代码中,东西都在预期的位置上
- 编写的代码中的类只做它们想做的事情
- 编写可以快速调整和扩展的代码
- 编写可以快速调整和扩展而不产生错误的代码
- 编写将政策(规则)与细节(实现)分开的代码
- 编写允许更换实现的代码(考虑更换电子邮件API、ORM或网络服务器框架)。
所以,让我们来看看他们
S:单一责任原则
"一个类或函数应该只有一个变化的理由"。
因此,我们不应该认为我们应该把代码拆开,因为它在一个文件中看起来更干净,而是根据使用它的用户的社会结构来拆开代码。因为这才是真正决定变化的原因。
如果我们在一个企业应用程序中有一个人力资源部门、一个会计部门和一个IT部门,这些部门负责计算工资、报告和节省工时,我们最好确保我们已经拆分(或抽象)了每个部门 最有可能改变的操作。
这里有一个违反SRP的行为。
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<any> {
// implement algorithm for hr, accounting and it
}
}
这很糟糕,非常糟糕。每个部门的算法都位于同一个类中。如果一个部门要求改变他们各自的算法之一,那么它就有更大的可能波及到另一个部门的算法。
要做到这一点,需要一些讨厌的if 或switch 语句。
设计提示:如果我们在switch语句中看到大量的代码,这应该是一个信号,告诉我们有可能从一个switch 语句重构为几个类。
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<any> {
// 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 {
...
}
要好得多。这个社会结构中的每个员工都有一个地方,我们可以去那里调整他们各自最可能改变的算法。
最关键的是要根据使用应用程序的用户的社会结构来分离责任。
开放-封闭原则
部分由Bertrand Meyer在20世纪80年代写的,Bob叔叔称这是 "面向对象设计的最重要原则"。
"一个软件工件应该为扩展而开放,但为修改而封闭"。
一般来说,这个原则就是把你的代码写成这样,当你需要增加新的功能时,不应该需要改变现有的代码。
这就是我们在软件架构中所追求的😤。能够设计你的软件,使其在从A点到B点的过程中,需要改变的代码量最少。
为了做到这一点,我们编写接口和抽象类,以规定需要实现的高层策略,然后我们用具体的类来 实现这个策略。
电子邮件服务实例
比方说,我们的老板告诉我们,他希望我们使用SendGrid来发送电子邮件。
于是我们就去编写了一个具体的 SendGridService 类,连接到Sendgrid API。
class SendGridService {
constructor (sendgridInstance) {
this.sg = sendgridInstance;
}
sendMail (from, to, body) {
// format the mail object to the sendgrid api shape
// send it
// create a result object
// return the result (success, failure, bounded, etc)
}
}
3个月后,他告诉我们,他希望我们用MailChimp来代替,因为SendGrid太贵了。
所以我们就去编码一个具体的 MailChimpService 。但是为了连接它,我们必须改变并可能破坏很多代码。
我们怎么能把它设计得更好呢?
按照OCP,我们可以定义一个interface ,指定一个邮件服务可以做 什么,而把实际的实现留给单独的人去想。
主要的想法是将策略与细节分开,以便实现松散耦合。
高层组件受到保护,不受低层组件变化的影响。
这与依赖接口而不是具体化的 **依赖反转原则是相辅相成的,并且与Liskov替代原则**密切相关,只要依赖的是相同的类型/接口,就可以交换实现。
从架构的角度来看
当我们考虑到软件架构的大局时,这个原则仍然有很大的意义。
想象一下,这是一个通用的应用程序,有一个控制器处理请求,将其传递给Use Case(实际执行业务逻辑的东西),然后将响应映射到Web App和移动App的两个不同的视图。
更高层次的组件是什么?
是 "用例"!
如果用例改变了,它可能会影响到数据库、控制器和数据映射器,这些数据映射器会创建传递给Web和移动的视图。
但是,如果网络或移动视图发生变化,这个需求就不太可能转化为影响用例的东西,因为用例包含业务逻辑和实体。
再说一遍。
高层组件受到保护,不受低层组件变化的影响。
Liskov-Substition原则
由Barbara Liskov在20世纪80年代提出的,她说。
让Φ(x)是一个关于类型T的对象x的可证明的属性。那么Φ(y)对于S类型的对象y应该是真的,其中S是T的一个子类型。
诚然,这很让人困惑。
最简单的解释是,我们应该能够把一个实现换成另一个。
在鲍勃叔叔的 "清洁架构 "中,他说。
"要用可互换的部件来构建软件系统,这些部件必须遵守一个契约,允许这些部件被替换成另一个。
他说的是使用接口和抽象类。
我认为我们刚才举的Mail的例子是思考这个问题的最好方式。
既然我们已经定义了一个IMailService 接口。
type TransmissionResult = 'Success' | 'Failure' | 'Bounced'
interface IEmailTransmissionResult {
result: TransmissionResult;
message?: string;
}
interface IMailService {
sendMail(email: IMail): Promise<IEmailTransmissionResult>
}
我们可以实现各种电子邮件服务,只要它们实现了IMailService 接口和所需的sendMail(email: Mail) 方法。
class SendGridEmailService implements IMailService {
sendMail(email: IMail): Promise<IEmailTransmissionResult> {
// algorithm
}
}
class MailChimpEmailService implements IMailService {
sendMail(email: IMail): Promise<IEmailTransmissionResult> {
// algorithm
}
}
class MailGunEmailService implements IMailService {
sendMail(email: IMail): Promise<IEmailTransmissionResult> {
// algorithm
}
}
...
然后我们可以将其*"依赖注入 "*到我们的类中,确保我们引用它所属的接口,而不是其中的一个具体实现。
class CreateUserController extends BaseController {
private emailService: IEmailService;
constructor (emailService: IEmailService) { // like this 😇
this.emailService = emailService;
}
protected executeImpl (): void {
// handle request
// send mail
const mail = new Mail(...)
this.emailService.sendMail(mail);
}
}
现在,所有这些都是有效的。
const mailGunService = new MailGunEmailService();
const mailchimpService = new MailChimpEmailService();
const sendgridService = new SendGridEmailService();
// any of these are valid
const createUserController = new CreateUserController(mailGunService);
// or
const createUserController = new CreateUserController(mailchimpService);
// or
const createUserController = new CreateUserController(sendgridService);
因为我们可以互换我们传入的IEmailService 的实现,我们遵守了LSP。
接口隔离原则
防止类依赖它们不需要的东西
为了防止这种情况,我们应该确保将独特的功能真正分割成接口。
也就是:我们 "隔离接口"。
而我们应该按照 依赖反转原则,只依赖接口或抽象类。
这里有一个著名的例子。
3个用户操作的例子
假设我们有3个不同的User 类,它们在同一个Operations 类上使用了3个不同的方法,而对于每个User 类,我们都要依赖2个我们不需要的额外操作。
对于这一点,它可能看起来不是什么大问题。但是,如果Operations 类的构造函数要求我们注入所有不同种类的依赖,以满足其他2个我们不需要的操作,这可能是个大问题。
class Operations implements U1Ops, U2Ops, U3Ops {
constructor (
userRepo: IUserRepo,
emailService: IEmailService,
authService: IAuthService,
redisService: IRedisService,
... // and more
)
...
}
这时,它就真的是一个响亮的禁忌了。
在CRUD优先的设计中,这确实偶尔会出现服务,但要解决这个问题,我们可以从确保我们依靠抽象的东西来满足我们的需要开始。
像这样...
然后,如果创建Operations 类很麻烦,因为它依赖于很多东西,以User1 为例,我们就可以创建一个只实现U1Ops 的类。
class User1Operations implements U1Ops {
constructor (userRepo: IUserRepo) {
...
}
...
}
现在User1 只需要User1Operations ,而不需要Operations 的其他垃圾。
依赖性反转原则
抽象不应该依赖于细节。细节应该依赖于抽象。
什么又是抽象?一个接口或抽象类。
什么又是细节?一个[具体的类]。
抽象不应该依赖于细节。好的,所以这意味着,尽量不要这样做。
interface IMailService {
// refering to concrete "PrettyEmail" and "ShortEmailTransmissionResult" from an abstraction
sendMail(email: PrettyEmail): Promise<ShortEmailTransmissionResult>
}
但相反,要这样做。
class SendGridEmailService implements IMailService {
// concrete class relies on abstractions
sendMail(email: IMail): Promise<IEmailTransmissionResult> {
}
}
你通常可以从微软的代码风格中看出我在使用interface (至少在我的代码中),它建议我们在接口前加上一个 "I"。
而这已经是你在这篇文章中所看到的了。我们一直在提及抽象(接口和抽象类)而不是具体的。
主要组件/细节组件
我们不可能永远引用具体的类。这就是我们如何真正地把东西挂起来,让事情发生。
比如[连接Express.js的路由器]。
// user/http/router/index.js
import * as express from 'express'
import { Router } from 'express'
import { CreateUserController } from '../controllers'
import { MailGunEmailService } from '../services'
const mailGunService = new MailGunEmailService();
const createUserController = new CreateUserController(mailGunService);
const userRouter = Router();
userRouter.post('/new', (req, res) => createUserController.exec(req, res))
export {
userRouter
}
我们需要引用那些具体的CreateUserController 和MailGunEmailService 类,以便把它们挂起来。
但我们只是为了把它们连接起来而这样做。鲍勃叔叔称这些为主要组件。它们很乱,但它们是必要的。
然而,我们不应该从另一个具体类中直接引用具体类。
这就是让我们有能力测试代码的原因,因为我们把权力留给了实现者,如果我们不想进行API调用或依赖我们目前没有兴趣测试的东西,就可以传入一个模拟的依赖关系。
因此,要这样做。
class CreateUserController extends BaseController {
private emailService: IEmailService; // <- abstraction
constructor (emailService: IEmailService) { // <- abstraction
this.emailService = emailService;
}
protected executeImpl (): void {
// handle request
// send mail
const mail = new Mail(...)
this.emailService.sendMail(mail);
}
}
不是这个。
class CreateUserController extends BaseController {
// we're limiting ourselves to a particlar concrete class.
private emailService: SendGridService; // <- concretion
constructor (emailService: SendGridService) { // <- concretion
this.emailService = emailService;
}
protected executeImpl (): void {
// handle request
// send mail
const mail = new Mail(...)
this.emailService.sendMail(mail);
}
}
而且绝对不是这样。
import { SendGridService } from '../../services'; // <- source code dependency
class CreateUserController extends BaseController {
// impossible to mock for tests
private emailService: SendGridService = new SendGridService(); // <- concretion
constructor () {
}
protected executeImpl (): void {
// handle request
// send mail
const mail = new Mail(...)
this.emailService.sendMail(mail);
}
}
依赖性注入框架
如果像这样把依赖关系挂到类上就会失去控制,可能会有数百个依赖关系,我们可以寻求一个依赖注入框架来帮助自动解决依赖关系。
结论
我们刚刚经历了所有的SOLID原则。
如果你正在使用TypeScript,为了让你真正从你的面向对象的软件设计工作中获得最大的收益,我建议重读这篇文章几次,并在你编写TypeScript代码时参考它,问自己 "这是不是SOLID"?