Lee
Photo by Hal Gatewood
Lee
Lee
Published at Jul 28, 2020 · 4 mins to read

你应该知道的基础软件设计原则

Contents

什么是设计原则

简单来说,设计原则(Design Principles)就是指导软件设计和代码编写的高层准则,它们提供了一套 通用的指导思想,帮助开发者构建更健壮、灵活且易于维护的软件系统。

这些前辈们总结的一套设计原则核心目标就是从 可扩展性可读性可维护性 方面提高代码质量、增强模块化、降低代码耦合度和减少代码冗余。

设计原则和设计模式的关系

设计原则是指导软件设计的指导原则,它们是由经验丰富的软件开发人员总结出来的准则,可以类比成 战略。而设计模式是在特定场景下解决特定问题的一种具体的解决方案,可以用 战术 来类比,设计模式(战术)是由设计原则(战略)指导的。

设计原则设计模式
抽象层级高层指导思想(要做什么)具体解决方案(如何做)
应用范围通用,适合所有范围针对特定场景
关系设计模式通常遵循设计原则实现设计模式是实践设计原则的具体手段

常见的设计原则

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 类中,这就违反了单一职责原则,这样做的缺点就是:

如果按照单一职责原则来设计,我们可以将这两个功能分别封装到两个不同的类中,这样就可以实现单一职责原则:

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)
  }
}

这样做的好处就是:

开闭原则

开闭原则(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

简单来说,要做到对扩展开放,对修改关闭,我们需要:

  1. 将可能变化的部分(不同的字段和校验规则)抽离应用到外部(对拓展开放),设计一个固定数据结构来表示这个可能变化的部分(如新版的 rules
  2. 设计一个固定不变的运行逻辑(如新版的 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 中,里氏替换原则和开闭原则的思想是非常相似的:

前面提到了策略模式是遵循开闭原则的一种具体实现,但其实策略模式才是里氏替换原则的亲儿子,不信你看:

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 原则强调代码的可扩展性和可维护性,避免过早地实现不必要的功能会使代码更易于理解和维护。