文章目录

  • 介绍
  • 单一职责原则(SRP)
    • 问题
    • 解决方案
  • 开闭原则(OCP)
    • 问题
    • 解决方案
  • 里氏替换原则(LSP)
    • 问题
    • 解决方案
  • 接口隔离原则(ISP)
    • 问题
    • 解决方案
  • 依赖反转原则(DIP)
    • 问题
    • 解决方案
  • 结论

原文

在这篇文章里,我们将了解什么时候和为什么软件开发要使用SOLID原则。

首先,我们会了解在软件设计中,为什么要考虑设计原则。然后,我们将列出使用每条设计原则随之而来的问题和解决办法。

请注意文中的例子都是经过简化的。他们的作用只是简单示范当违反设计原则时相关的问题。

介绍

SOLID是马丁(Robert·Martin)提出的五条设计原则的首字母缩写。我强烈推荐阅读他的书《架构整洁之道》(CLean Architecture)。

这里列出5条原则:

  • 单一职责原则(SRP)
  • 开闭原则(OCP)
  • 里氏替换原则(LSP)
  • 接口隔离原则(ISP)
  • 依赖反转原则(DIP)

这些原则的主要目的是使软件修改更灵活,扩展和维护更容易。

软件变更需要花费时间,这是最昂贵的资源。业务需要对市场变更快速响应,所以开发时间是非常宝贵的。你能越快的修改代码完成新的需求,你的业务也会更容易发展壮大。

单一职责原则(SRP)

每个模块有且仅有一个修改的理由。

SRP原则是最难理解的原则,因为很多开发者认为,它的意思是每个模块只负责完成一件事情。这在我们设计函数时是适用的。例如:我们把一个函数拆分成多个更小的函数,我们要确保每个函数只做一件事。

但是SRP原则的本质不是这样。为了更清楚一点,我们可以将原理解释如下:
每个模块仅对有且只有的一个用户或利益相关者负责。

修改软件是为了满足用户或利益相关者的需求。因此,这些用户和利益相关者是改变的原因。在这种情况下,模块是一个相关函数和数据结构的集合。

问题

在下面的示例中,我们有一个违反SRP原理的类 AdsAccount

class AdsAccount {public startCampaign() { ... }public calculateCampaignStats() { ... }public save() { ... }
}

此模块有多个原因会导致更改:

  • 由营销团队定义的 startCampaign 方法的实现
  • 由分析团队定义的 calculateCampaignStats 方法的实现
  • 由开发团队定义的 save 方法的实现

如果将这些方法的实现放在一个类中,则营销团队的新需求很可能会影响分析团队的业务逻辑。

假设 startCampaigncalculateCampaignStats 方法使用通用方法 getCampaignImpressions
营销团队改进了印象计算逻辑,他们希望将其应用于新的广告系列。
开发人员更新并测试了 startCampaigngetCampaignImpressions 方法。
市场营销团队批准了更改,并部署了新版本。
一段时间后,出现了广告统计信息的使用了新的计算方式,分析团队使用了不一致的数据。

解决方案

解决方案非常简单。我们只需要将我们的逻辑拆分为单独的类。

class AdsAccount {constructor() {this.statsCalculator = new StatsCalculator()this.campaignLauncher = new CampainLauncher()this.campaignSaver = new CampaignSaver()}public startCampaign() {this.campaignLauncher.startCampaign(...)}public calculateCampaignStats() {this.statsCalculator.calculateCampaignStats(...)}public save() {this.campaignSaver.save(...)}
}class StatsCalculator {public calculateCampaignStats() { ... }private getCampaignImpressions() { ... }
}class CampaignLauncher {public startCampaign() { ... }private getCampaignImpressions() { ... }
}class CampaignSaver {public save() { ... }
}

现在我们有了遵守SRP原则的三个类:StatsCalculatorCampaignLauncher,和 CampaignSaver。每个新类都不依赖于其他类。我们有单独的私有方法来获取广告印象——每个团队有自己的逻辑。

AdsAccount 现在起着门面作用。它负责创建几个新类的实例和委托方法调用。

开闭原则(OCP)

每个模块都应该对扩展开发,对修改关闭。

对扩展开放意味着可以仅通过提供新模块就可以向应用程序添加新功能。

对修改关闭意味着扩展现有模块不应导致你需要对依赖的模块进行修改。

该原则的目标是使系统易于扩展并免受更改的影响。

如果对应用程序逻辑的简单扩展导致其他模块中的一系列更改,则你可能违反了OCP原则。

将此原则与单一职责原则和依赖反转原理一起应用,可以防止出现对其中一个类进行更改时,还需要修改它所有依赖的模块。

让我们深入研究该示例。

问题

假设,您正在创建一个送餐应用程序。您有一个移动应用程序,您的用户可以在其中创建和查看订单。

常见的解决方案是你创建一个模块(即控制器),该模块从数据库中加载模型数据,将其转换为HTTP响应负载,然后将其发送给客户端。

在上图中,我们有一个 OrderController,其持有 OrderDatabaseOrderPresenterOrderDatabase 负责加载和保留订单模型。OrderPresenter 负责将订单模型映射到HTTP响应。

假设利益相关者要求实现通过电子邮件发送订单。我们会快速更新控制器代码,以便在创建新订单时向用户发送电子邮件。

让我们看看下面的伪代码:

class OrderController {constructor() {this.mailServer = new MailServer()this.orderPresenter = new OrderPresenter()}public createOrder(): Promise<OrderView> {const order = new Order(...)await order.save()const orderView = this.orderPresenter.present(order)this.mailServer.send(orderView)return orderView}
}

过了一段时间,出现了新的需求。我们需要在电子邮件中显示订单总金额。换句话说,我们需要更新订单视图。

要了解的重要一点是,订单是通过两种不同的视图呈现的:通过手机屏幕和通过电子邮件。对于新需求,每个视图以不同的方式显示订单详情。

我们可以通过多种方式来解决这个问题。我们可以扩展 OrderPresenter ,增加一个用于创建电子邮件视图的新方法。我们也可以为电子邮件创建另一个表现层模块。在这两种情况下,我们都必须更新控制器以匹配新的表现层API。

看起来我们违反了OCP原则了吗?每次引入新需求时,我们如何避免我们的控制器和表现层更改?

解决方案

让我们将演示逻辑分为两个模块。每个模块将负责将订单模型转换为特定视图所需的数据。

这样好多了。电子邮件表现层的更改,如添加新字段等不会影响移动表现层。但是,控制器的问题仍然存在。更改演示者API后,我们如何组织代码以避免影响到控制器?
为此,我们需要通过反转依赖使控制器独立于表现层。我们的控制器模块应为表现层定义一个接口。所有的表现层都应该实现此接口。

使用这种架构,控制器对表现层的实际实现一无所知。所有表现层都必须遵守接口定义的协议。让我们看一下更新的代码示例:

export interface IEmailPresenter {present(order: Order): EmailView
}export interface IMobilePresenter {present(order: Order): MobileView
}class OrderController {constructor(emailPresenter: IEmailPresenter, mobilePresenter: IMobilePresenter) {this.emailPresenter = orderPresenterthis.mobilePresenter = mobilePresenterthis.mailServer = new MailServer()}public createOrder(): Promise<MobileView> {const order = new Order(...)await order.save()const emailView = this.emailPresenter.present(order)  this.mailServer.send(emailView)const mobileView = this.mobilePresenter.present(order)return mobileView}
}

结果,我们使用接口避免了 OrderController 被修改。此外,我们可以说我们的表现层可以扩展。当我们需要添加另一种类型的功能时,我们提供了一个新模块。

这就是OCP如何起作用。您拆分应用程序,考虑如何,何时以及为什么需要对其进行更改。之后,将所有内容组织到组件层次结构中。需要防止更改的那些组件定义了频繁更改的组件所使用的接口。

里氏替换原则(LSP)

子类应以不会破坏对外暴露的功能的方式覆盖父类方法。

换句话说,软件模块使用父类的地方应该能够使用子类替换,而无需更改代码。

这种父类可以被两个子类替换的设计就是LSP。

但是什么是父类和子类?父类可以是具有继类子类的类,也可以是带有作为实现的子类的接口。

为了更好地理解LSP背后的思想,让我们看一下示例。

问题

假设我们需要实现一个电子钱包应用程序,在这里我们可以将钱从一张卡转移到另一张卡。一个抽象的解决方案可能像这样:

class BankCard {topup() { ... }withdraw() { ... }
}class Wallet {transfer(amount: number, sender: BankCard, receiver: BankCard) {// ...}
}const wallet = new Wallet()
const senderBankCard = new BankCard()
const receiverBankCard = new BankCard()wallet.transfer(1000, senderBankCard, receiverBankCard)

在这里,我们有一个 Wallet 实例,该实例使用 BankCard 实例进行转账。

一段时间后,我们的电子钱包应用程序被加了新的需求。我们的钱包用户必须能够从储蓄卡转账。这些卡是虚拟的。它们无法手动充钱,因此没有 topup 方法。

现在,我们的转账者可以是银行卡或储蓄卡。

class BankCard {topup() { ... }withdraw() { ... }
}
class SavingsCard {withdraw() { ... }
}class Wallet {transfer(amount: number, sender: BankCard | SavingsCard, receiver: BankCard) {if (sender instanceof BankCard) {// ...}if (sender instanceof SavingsCard) {// ...}}
}const wallet = new Wallet()
const senderSavingsCard = new SavingsCard()
const receiverBankCard = new BankCard()wallet.transfer(1000, senderSavingsCard, receiverBankCard)

这样可以工作,但是无论何时引入新卡,我们都必须在参数中添加新类型和新的 if 语句。我们无法将替换 BankCardSavingsCard,因此我们违反了LSP。

解决方案

要遵循LSP,我们需要为卡添加一个父类以支持交互性。这种方法保证了我们将始终拥有 withdraw 方法。

interface IWithdrawable {withdraw(): void
}interface IRechargeable {topup(): void
}class BankCard implements IRechargable, IWithdrawable {topup() { ... }withdraw() { ... }
}class SavingsCard implements IWithdrawable {withdraw() { ... }
}class Wallet {transfer(amount: number, sender: IWithdrawable, receiver: IRechargable) {// ...}
}const wallet = new Wallet()
const senderSavingsCard = new SavingsCard()
const receiverBankCard = new BankCard()wallet.transfer(1000, senderSavingsCard, receiverBankCard)

现在我们可以说上述解决方案遵循了LSP。我们有一个父类:IWithdrawable,它可以用子类 BankCardSavingsCard 替换。我们的方法使用了父类,因此无需在每次引入新卡时都对其进行修改。新卡的唯一要求是它是我们父类的子类。

接口隔离原则(ISP)

不应强迫软件模块依赖不使用的接口。

ISP表明,我们应该编写可以被子类实现的一系列更小、更具体的接口。每个接口都提供单个行为。换句话说,许多特定的接口比一个通用的接口要好。

违反该原则时,软件模块将被迫实现其不使用的方法。此外,接口中方法签名的任何更改都会导致相关类的更改。这不利于模块之间的解耦。

问题

例如,您正在创建一个博客。您有不同类型的用户。

IBaseUser 是我们的通用接口。对于每种继承类,它都有一组通用方法。在我们的示例中,AdminWriter 具有编辑博客文章的通用方法。但是读者不能————他们只能阅读帖子。另外,只有管理员可以屏蔽帖子。

interface IBaseUser {viewPost(): voideditPost(): voidblockPost(): void
}class Admin implements IBaseUser {public viewPost() { ... }public editPost() { ... }public blockPost() { ... }
}class Writer implements IBaseUser {public viewPost() { ... }public editPost() { ... }public blockPost() { throw new Error('writer can not block posts') }
}class Reader implements IBaseUser {public viewPost() { ... }public editPost() { throw new Error('reader can not edit post!') }public blockPost() { throw new Error('reader can not block posts') }
}

ReaderWriter 类必须实现他们不使用的方法————因此,他们违反了ISP。

当业务发展时,其功能也会发展。新种类的用户添加后,也会带来与之相关的一组方法。我们的 IBaseUser 接口变得越来越复杂。实现 IBaseUser 的所有子类代码都相应增长。那么我们如何解决这个问题呢?

解决方案

我们可以通过接口拆分为独立的小接口来解决此问题。

如果某个客户想要这两种行为,那么他们可以实现这两种接口。让我们看一下代码示例:

interface IReader {readPost(): void
}interface IWriter {editPost(): void
}interface IAdmin {blockPost(): void
}class Admin implements IReader, IWriter, IAdmin {public viewPost() { ... }public editPost() { ... }public blockPost() { ... }
}class Writer implements IReader, IWriter {public viewPost() { ... }public editPost() { ... }
}class Reader extends IReader {public viewPost() { ... }
}

遵循ISP可以降低开发和维护应用程序的复杂性。使用的接口越小越简单,它的实现子类消耗的资源就越少。

依赖反转原则(DIP)

上层模块不应该依赖于底层模块。两者都应该依赖抽象。抽象不应依赖细节。细节应取决于抽象。

提供具体功能的底层模块修改时,应不影响提供业务逻辑的上层模块。为此,我们需要引入一种抽象例如接口,使模块彼此分离。

DIP是严格遵循开闭原则和里氏替换原则的结果。

问题

例如,我们需要创建一个模块来接收用户,对用户的密码进行编码,然后将其保存在数据库中。让我们看一下图。

UserService 是我们的上层模块。它包含业务逻辑。它直接依赖于 CryptoPasswordEncoderUsersRepository 这两个底层模块。

class UserService {constructor() {this.repository = new UsersRepository()this.passwordEncoder = new CryptoPasswordEncoder()}public registerUser(data: RawUser): void {const userEntity = new UserEntity({email: data.email,passwordHash: this.passwordEncoder.encode(data.password)})this.repository.save(userEntity)}
}class CryptoPasswordEncoder {public encode(password: string): string { ... }
}class UsersRepository {public save(userEntity: UserEntity): void { ... }
}

但是这种设计看起来并不灵活。假设在安全审核之后,我们被要求使用更安全的库,例如crypto

考虑到这一点,我们创建 BcryptPasswordEncoder 类并更改 UserService 构造方法。如果方法签名也被更改,我们还需要修改使用该模块的所有位置。

此外, 由于与 CryptoPasswordEncoderUsersRepository 高度耦合,很难对 UserService 进行测试。

解决方案

正如DIP所说,我们的模块应该依赖抽象。一种好方法就是添加一个接口作为抽象。

interface IPasswordEncoder {encode(password: string): string
}interface IUsersRepository {save(userEntity: UserEntity): void
}class UserService {constructor(repository: IUsersRepository, passwordEncoder: IPasswordEncoder) {this.repository = repositorythis.passwordEncoder = passwordEncoder}public registerUser(data: RawUser): void {const userEntity = new UserEntity({email: data.email,passwordHash: this.passwordEncoder.encode(data.password)})this.repository.save(userEntity)}
}class BcryptPasswordEncoder implements IPasswordEncoder {public encode(password: string): string { ... }
}class UsersRepository implements IUsersRepository {public save(userEntity: UserEntity): void { ... }
}

现在,我们的高级模块 UserService 依赖于两个抽象———— IPasswordEncoderIUsersRepository。我们的 BcryptPasswordEncoderUsersRepository 类实现了这些接口。

我们实现的最重要的事情就是依赖关系的倒置。我们底层模块的箭头指向另一个方向。因此,我们将 UserServiceCryptoPasswordEncoderUsersRepository 解耦。

每次我们需要更改第三方库或修改底层模块中的现有逻辑时,我们只需要遵循在接口中定义的约定即可。该接口必须由上层模块提供。

除此之外,遵循DIP会迫使我们注入依赖关系。在代码示例中,我们传递 repositorypasswordEncoder 作为参数,通过 constructor 方法实现。这种方法简化了单元测试。

应用DIP可以使上层模块免受修改影响。因为这些模块包含业务逻辑,即应用程序的核心功能,因此避免在那里进行任何更改对我们非常有益。

结论

如您所见,所有这些原则均旨在简化软件维护和扩展。通过提供更精细的抽象,我们提高了应用程序的灵活性。

有了灵活性,我们就可以成功地应对可能改变需求的业务。


因个人能力和时间有限,文章难免有纰漏之处,如有发现,欢迎指正。

设计模式的5个常见问题及解决办法相关推荐

  1. fetch使用的常见问题及解决办法

    fetch使用的常见问题及解决办法 参考文章: (1)fetch使用的常见问题及解决办法 (2)https://www.cnblogs.com/wonyun/p/fetch_polyfill_time ...

  2. 中控消费机一直显示连接服务器,中控消费机培训及常见问题的解决办法

    <中控消费机培训及常见问题的解决办法>由会员分享,可在线阅读,更多相关<中控消费机培训及常见问题的解决办法(28页珍藏版)>请在人人文库网上搜索. 1.用拼搏的精神做事业 用感 ...

  3. 路由器常见问题及解决办法

    路由器常见问题及解决办法:

  4. zebra扫码枪复位_zebra斑马条码打印机常见问题和解决办法

    zebra斑马条码打印机常见问题和解决办法 条码打印机在使用过程中常常遇到一些问题,下面是达创条码在长期销售.使用斑马条码打印机中,遇到的一些常见问题,总结列表如下,并给出调整办法,希望能对你使用斑马 ...

  5. 东东助手显示无法连接服务器,《东东助手》模拟器常见问题及解决办法

    <东东助手>模拟器常见问题及解决办法 常见问题是指大多数人在使用东东助手模拟器都会出现的问题,如果你在使用东东助手模拟器,也出现了不同程度的小问题,不妨先来看看下面小编带来的这篇文章. 模 ...

  6. hdmi线接计算机HDMI接口松动,HDMI线连接电视电脑常见问题及解决办法

    原标题:HDMI线连接电视电脑常见问题及解决办法 繁忙的工作.学习的压力,让看电视成为了上班族和学生党舒缓压力的方式之一,为了更好的视觉效果和体验,很多朋友都会采用HDMI高清线,但是在使用过程中也会 ...

  7. 中国研究生招生信息网登陆服务器错误,2019考研网上报名填写信息常见问题及解决办法...

    网上报名的步骤很多,需要填写的内容也很多,对于初次考研报名的考生来说总是会有些战战兢兢,生怕填错信息影响报名,为此,东湖武大考研网特意整理收集了2018年考研网上报名填写信息常见问题及解决办法,这些解 ...

  8. 找不到认证服务器 是否网卡选择错误,锐捷上网认证常见问题及解决办法

    锐捷上网认证常见问题及解决办法 一.共享WIFI后锐捷频繁掉线 原因:使用360WiFi.猎豹WiFi等无线共享软件会自动启用Windows系统自带的认证客户端,触发802.1x认证流程导致锐捷认证频 ...

  9. 总结 | Revit安装失败的常见问题及解决办法

    Revit作为最常用的BIM软件之一,由于体量大.不同电脑软硬件情况不同等原因,安装经常是不那么顺利的. 特别是初次安装的系统,经常会安装失败.下面就来总结一下Revit安装失败的常见问题及解决办法, ...

最新文章

  1. RHEL 5服务篇—部署DNS域名解析服务(一)BIND软件
  2. java location_JAVA setlocation()方法并不能定义组件的位置,setBounds()方法无法改变组件的大小。小白一枚...
  3. 2017.4.7 e.toString() 与 e.getMessage()的区别
  4. django--模板继承、组件、自定义标签
  5. 网站改成静态页面打不开_稳定网站排名的基本条件 - 最蜘蛛池外推快速收录
  6. 一个小清新搜索引擎试用感受 - 多吉搜索引擎(dogegoge)
  7. CentOS 7.2安装zabbix 3.0 LTS
  8. 用SSE加速CPU蒙皮计算
  9. 基础篇:3.3.2)压铸件-材料选择
  10. Java多线程再理解(synchronized)
  11. Python4班平均成绩统计_回首过去,展望未来 | 欢迎大家来到E班第一次主题班会!...
  12. 高通driver模块编译方法
  13. FPGA设计标准I2S协议音频编解码器
  14. android第三方库进程,Android 第三方库AgentWeb的使用
  15. C#调用Outlook发送邮件
  16. linux定时关闭系统at,『学了就忘』Linux系统管理 — 8.系统定时任务(at命令)
  17. Qt tableview 悬浮提示
  18. 使用函数求余弦函数的近似值 (15 分)
  19. 最全分布式文件系统 HDFSYARNMapReduce详讲
  20. 当x大于时,y等于1;当x等于0时,y等于0;当x小于0时,y等于-1。

热门文章

  1. 评测 N200和n100选哪个 N200和n100参数对比
  2. html 文本框去掉边框
  3. 基于JavaEE的海鲜水产销售管理系统的设计
  4. 全民上云季,云服务器有哪些优势和特点?
  5. 腾讯IM-uniapp离线推送接入-以华为推送为例
  6. CTF|密码学常用网站超详细汇总
  7. 数论:从同余的观点出发
  8. 人工智能教育在中国遍地开花
  9. Filter Response Normalization Layer(FRN)
  10. UE4 爆炸云特效材质