你应该知道的基础软件设计原则
Contents
什么是设计原则
简单来说,设计原则(Design Principles)就是指导软件设计和代码编写的高层准则,它们提供了一套 通用的指导思想,帮助开发者构建更健壮、灵活且易于维护的软件系统。
这些前辈们总结的一套设计原则核心目标就是从 可扩展性、可读性 和 可维护性 方面提高代码质量、增强模块化、降低代码耦合度和减少代码冗余。
设计原则和设计模式的关系
设计原则是指导软件设计的指导原则,它们是由经验丰富的软件开发人员总结出来的准则,可以类比成 战略。而设计模式是在特定场景下解决特定问题的一种具体的解决方案,可以用 战术 来类比,设计模式(战术)是由设计原则(战略)指导的。
设计原则 | 设计模式 | |
---|---|---|
抽象层级 | 高层指导思想(要做什么) | 具体解决方案(如何做) |
应用范围 | 通用,适合所有范围 | 针对特定场景 |
关系 | 设计模式通常遵循设计原则实现 | 设计模式是实践设计原则的具体手段 |
常见的设计原则
- SOLID
- 单一职责 (Single Responsibility Principle, SRP)
- 开闭原则 (Open-Closed Principle, OCP)
- 里氏替换 (Liskov Substitution Principle, LSP)
- 接口隔离 (Interface Segregation Principle, ISP)
- 依赖倒置 (Dependency Inversion Principle, DIP)
- DRY (Don't Repeat Yourself)
- KISS (Keep It Simple, Stupid)
- YAGNI (You Ain't Gonna Need It)
SOLID 原则
其中 SOLID 原则是最常见的设计原则,我们可以通过一些例子来理解它们。
单一职责原则
单一职责原则(Single Responsibility Principle, SRP)是指一个类或模块应该只有一个引起它变化的原因。简单来说,就是一个类或模块应该只负责一项职责,而不是承担多项职责。
比如在用户注册的场景下,我们需要实现两个功能:
- 用户注册
- 发送注册成功的邮件
class AuthService {
constructor(user, email) {
this.user = user
this.email = email
}
sendEmail(message) {
// send email logic
}
saveToDatabase() {
// save user info to database logic
}
}
这里我们把这两个功能都封装到了 AuthService
类中,这就违反了单一职责原则,这样做的缺点就是:
- 当我们需要修改用户注册的逻辑时,我们需要修改
AuthService
类中的代码,这样代码过于耦合就有可能会影响到发送注册成功的邮件的逻辑。 - 同样,当我们需要修改发送注册成功的邮件的逻辑时,我们需要修改
AuthService
类中的代码,这样也会提高可能影响到用户注册逻辑的概率。
如果按照单一职责原则来设计,我们可以将这两个功能分别封装到两个不同的类中,这样就可以实现单一职责原则:
class UserService {
constructor(user) {
this.create(user)
}
create(user) {
// save user info to database logic
// db.users.create(user)
}
}
class EmailService {
constructor(email) {
this.email = email
this.send({
email,
subject: `Sign Up Successfully`,
content: `Congrats, you have registered successfully.`,
})
}
send(message) {
// send email logic
}
}
class AuthService {
constructor(user, email) {
new UserService(user)
new EmailService(email)
}
}
这样做的好处就是:
- 当我们需要修改用户注册的逻辑时,我们只需要修改
UserService
类中的代码,而不需要修改AuthService
类中的代码,进而不会影响到发送注册成功的邮件的逻辑。 - 当我们需要修改发送注册成功的邮件的逻辑时,我们只需要修改
EmailService
类中的代码,而不需要修改AuthService
类中的代码,自然也不会影响到用户注册的逻辑。
开闭原则
开闭原则(Open-Closed Principle, OCP)是指软件实体(类、模块、函数等)应该 对扩展开放,对修改关闭。换句话说,当需要添加新功能时,应该通过扩展现有代码来实现,而不是修改内部已有的代码。
表单校验就很适合用开闭原则思想来优化,下面是一个优化前的表单校验:
class FormValidator {
constructor(form) {
this.form = form
}
validate() {
const { username, password } = this.form
if (!username || !password) {
return false
}
if (username.length < 6 || password.length < 6) {
return false
}
return true
}
}
当我们需要新增后修改验证规则时,我们需要修改 validate
方法来实现,这就违反了开闭原则。
开闭原则的核心思想是 对扩展开放,对修改关闭,如果我们按照开闭原则思想来优化,我们可以将可能变化的部分(不同的校验规则)抽离到外部,并设计一个固定数据结构来表示不同的校验规则:
class FormValidator {
constructor(form = {}, rules = {}) {
this.validate(form, rules)
}
validate(form, rules) {
for (const key of rules) {
const rule = rules[key]
const value = form[key]
if (!rule.required && !value) {
continue
}
if (rule.required && !value) {
return false
}
if (rule.minLength && value.length < rule.minLength) {
return false
}
if (rule.maxLength && value.length > rule.maxLength) {
return false
}
if (rule.pattern && !rule.pattern.test(value)) {
return false
}
}
return false
}
}
// Usage:
const form = {
username: `heylee`,
password: `123456`,
email: `me@banli.co`, // new field, new validation rule
}
const rules = {
username: {
required: true,
minLength: 6,
maxLength: 10,
pattern: /^[a-zA-Z0-9]+$/,
},
password: {
required: true,
minLength: 6,
maxLength: 10,
pattern: /^[a-zA-Z0-9]+$/,
},
email: {
// new validation rule
required: true,
pattern: /^[a-zA-Z0-9]+@[a-zA-Z0-9]+\.[a-zA-Z0-9]+$/,
},
}
const validator = new FormValidator(form, rules)
console.log(validator.validate(form, rules)) // true
简单来说,要做到对扩展开放,对修改关闭,我们需要:
- 将可能变化的部分(不同的字段和校验规则)抽离应用到外部(对拓展开放),设计一个固定数据结构来表示这个可能变化的部分(如新版的
rules
) - 设计一个固定不变的运行逻辑(如新版的
validate
)保留在应用内部(对修改关闭),用来运行表示可能变化数据的固定数据结构
这样当我们需要新增后修改验证规则时,我们只需要修改 rules
这个外部固定的数据结构即可,而不需要修改 validate
函数内部的代码。
前面提到了设计模式是设计原则的具体实现,目的是为了解决特定场景下具体的问题,而表单验证就是一个具体的问题,我们用开闭原则的思想来指导我们设计表单验证的应用,其实上面的优化示例就是应用非常广泛的 策略模式。
里氏替换原则
里氏替换原则(Liskov Substitution Principle, LSP)指的是子类能够替换其父类,而不会影响程序的正确性,核心就是:
- 子类必须完全实现父类的抽象行为
- 子类可以扩展父类的功能(通过组合的方式),但不能破坏父类的原有逻辑
可以说是 SOLID 中最难理解的一项原则了,用代码来解释比较直观,下面是一个违反里氏替换原则的反例:
class Payment {
pay(amount) {
if (amount < 0) {
throw new Error(`Invalid amount`)
}
}
}
class CreditPayment extends Payment {
pay(amount) {
if (amount > 1000) {
throw new Error(`Amount exceeds limit`) // Breaking parent contract
}
// common logic
}
}
上面的例子中,CreditPayment
类违反了里氏替换原则,因为破坏了(没有实现父类对 amount < 0
的判断)父类的原有逻辑。为了遵循里氏替换原则,我们可以在子类 pay
逻辑中的第一行先执行父类的 pay
方法:
class CreditPayment extends Payment {
pay(amount) {
super.pay(amount)
if (amount > 1000) throw new Error(`Amount exceeds limit`)
// additional credit logic
}
}
上面例子中,CreditPayment
类遵循了里氏替换原则,因为子类 pay
方法的第一行执行了父类 pay
方法,保证了子类的行为不会破坏父类的原有逻辑。
其实在 SOLID 中,里氏替换原则和开闭原则的思想是非常相似的:
- 开闭原则:对扩展开放、对修改关闭
- 里氏替换原则:子类能够正确地替换父类,使得拓展在不修改原有逻辑的情况下进行(拓展
CreditPayment.pay
并没有修改Payment.pay
的逻辑)。
前面提到了策略模式是遵循开闭原则的一种具体实现,但其实策略模式才是里氏替换原则的亲儿子,不信你看:
class StripePayment {
constructor({ email }) {
this.email = email
}
pay(amount) {
super.pay(amount)
if (!this.validate()) throw new Error(`Invalid email`)
// stripe payment logic
}
validate(): boolean {
// validate email logic
return true
}
}
继续拓展一个 StripePayment
支付方式,上面两段代码就是策略模式的具体实现,下面是利用不同的支付方式(策略)进行支付:
class Account {
constructor(payment) {
this.payment = payment
}
pay(amount) {
this.payment.pay(amount)
}
switchPaymentTo(payment) {
this.payment = payment
}
}
const credit = new CreditPayment()
const stripe = new StripePayment({
email: `hi@banli.co`,
})
const account = new Account(credit)
account.pay(100) // credit payment logic
account.switchPaymentTo(stripe)
account.pay(100) // stripe payment logic
在电商交易系统中,利用策略模式可以确保我们在引入新的支付方式时,不会破坏已有系统的稳定性。例如后面拓展的 StripePayment
支付方式就不影响原有的支付逻辑。
接口隔离原则
接口隔离原则(Interface Segregation Principle, ISP)是指客户端不应该依赖于它不需要的接口。也就是说,客户端只依赖于它所需要的接口,它需要什么接口就提供什么接口,把不需要的接口剔除掉。
依赖倒置原则
依赖倒置原则(Dependency Inversion Principle, DIP)是指高层模块不应该依赖于低层模块,而应该依赖于抽象。换句话说,高层模块应该依赖于抽象,而不是依赖于具体实现。
DRY (Don't Repeat Yourself)
DRY 原则是指不要重复自己(Don't Repeat Yourself),即不要编写重复的代码。DRY 原则强调代码的可维护性和可扩展性,避免重复的代码会使代码更易于理解和维护。
KISS (Keep It Simple, Stupid)
KISS 原则是指保持简单,不要过度设计。KISS 原则强调代码的可读性和可维护性,避免过度设计会使代码更易于理解和维护。
YAGNI (You Ain't Gonna Need It)
YAGNI 原则是指不要过早地实现不必要的功能。YAGNI 原则强调代码的可扩展性和可维护性,避免过早地实现不必要的功能会使代码更易于理解和维护。