背景 #
在公司推进DDD中,我发现即使代码按照DDD进行分层,但是底层代码还是阅读性比较差,只不过被分到不同的子服务中。怎么让代码更加整洁规范呢?我觉得可以采用设计模式,所以我花了点时间重新学习了所有的设计模式。
我这边就不详细介绍各种设计模式的方法和实现了,想学习可以看B站视频,完整的介绍了七大设计原则和二十三个设计模式。
但是学完这个之后我还是比较迷茫,到底我什么时候应该使用哪个设计模式?设计模式之间区别是什么?设计原则和设计模式到底有什么关联?这篇博客我就"歪解"整理出来,探索一下大师们的思考过程。
设计模式出现的原因 #
计算机发明出来的原因就是为了去帮助人完成计算,从最简单的针孔计算机,随着计算机能力越来越强,我们的目的也一直都变得越来越复杂,我们的程序也越来越复杂,提要求的人也越来越多。
我们知道越多越大就容易出错,程序本质就是1和0的组合,我们创建了高级语言,不需要用汇编来编写程序了,但是我们的需求也越来越"高级",从开始计算1+1,到后面的人工智能,智能推荐,我们对软件的要求也越来越高了。
逐渐一个软件再也不是一个人就能solo的了,像稍微大点的公司都有几十上百人,大点的像BAT都是上万人。假如大家都在同时在编写同一个软件代码,你想想把上万人放到同一个群聊里面,每个人发一个收到,你手机都得划个几分钟才能划到底。所以设计模式就是在这种情况下产生的,用一种科学的方法去管理软件的变更。
设计模式核心 #
分而治之的思想 #
为了解决上面的问题,设计模式最开始的一个思想就是:“分锅”。将这个软件的开发按照功能给分成各个部分,如用户、订单、客服、商品等等,然后按照这些功能将人分成一个部门,这个部门的人专门管用户,那个部分专门管订单。
这样的分法的好处就是,当boss想提出一个需求,他可以找到对应的部分,只影响这个部门的人,其他部门可以去做他们自己的事情。这个就是设计原则中的单一性原则,每个类只有一种职责。
分层架构的演进 #
按照功能分法虽然好,但是也带来一个问题。我们按照功能将支付功能全分给一个部门的人来负责了,但是我们的用户有安卓用户、苹果用户、电脑用户,每个用户发起支付的时候都是调用支付功能,但是我们知道在不同的设备上发起支付是不一样的,安卓走微信支付,苹果用户只能走iOS支付。我们假如将这些都写在同一个类中,我们每次去修改是不是都会影响三端的用户?
所以在功能分发这一刀下,我们又横切一刀,在功能层上面再建一层平台层,这样每次我们新加一种平台的时候,我们只需要再在这个部门新招一些人来负责对应的平台,完成对应的功能。这个就是开闭原则,对修改关闭,对扩展放开。其实底层核心就是得在同一个功能下定义一个规范,这样新加入的小伙子满足这个规范就能轻松加入这个部门,而且他的加入也不影响他部门其他人。
蛋糕理论 #
我们用一个例子来介绍这个核心思想,这个复杂的软件就是一个正方形蛋糕,我们一口气吃不下这个蛋糕,那怎么办呢?先竖切几刀,将蛋糕分成一根根树状条(单一性原则),然后我们再横切几刀(开闭原则),将蛋糕分成一块块小格子,这样我们就可以悠哉悠哉的将小块蛋糕叉到我们嘴里了。
总结一下,设计模式最核心的原则就是:单一性原则和开闭原则,从高层将软件问题给简单化。在软件领域有个专门的词就是高内聚。但是这种高内聚也带来一个问题就是依赖问题。
如何解决分化带来的依赖问题 #
依赖倒置原则 #
我们上面知道了,横竖两刀将类切成一块一块,但是分的太细会有一个问题。比如我们将系统分成订单类和用户类,在之前未分之前,没啥订单类和用户类,订单类想知道用户信息简单查询一下自己的类属性就好了。但是现在分家了,订单类和用户类被拆开,订单想存下用户信息的话得在自己类中获取用户类的实例,然后再使用用户类实例去查询用户信息。
假如用户系统是一个非常稳定的系统,订单系统可以直接耦合用户系统,因为用户系统基本上不变。但是万一用户系统经常变动,那么订单系统就像一个累赘一样,当用户系统有做不兼容变更的时候,订单系统也得配合,这样非常容易造成系统崩塌。
那怎么解决这个问题呢?依赖倒置,什么是依赖倒置呢?用个生活中的例子解释,一个小孩子嗷嚎大哭哭着要吃冰淇淋,第一天妈妈从冰箱里面拿出来一盒冰淇淋,第二天冰淇淋吃完了妈妈下楼买了一个冰淇淋给孩子吃。我们的类就是这个孩子,我们的类只需要提出需求,具体的实现由别人实现,具体是冰箱里面的还是外面商店里面买的,孩子根本不在乎。
这样的话,即使妈妈不在家了,爸爸在家爸爸也能解决孩子的需求。我们软件中也一样,我们不需要完全依赖实现端,只需要依赖一个标准,这个标准变动的概率非常小,所以别的系统的改动对于我们系统是无感知的,这个我们用专业的术语就是:低耦合。
迪米特法则 #
在依赖倒置的帮助下面,我们恢复了当初的类的通信。但是随着我们类通信越来越多,大家逐渐发现一个问题,有些部门太"能干"了。比如用户部门来了一个卷王,通读其他部门源码,好家伙,提供一个接口非常全能,大家仔细看看他的代码,好家伙被引用的三方的裤子都被脱光了,写出了类似a.getOrder().getB().getC().getD()
代码,乍一眼看起来没问题,某天D服务发布了一个修改,取消了其中一个类对象,上线后用户部门就直接空指针直接炸了,用户服务也得匆促发布了一个修复版本上线。
后面大家复盘的时候发现,这种行为非常不可取,你一个人卷,带动其他部门得和你一起卷了,本来D服务只对C开放,所有只通知了C,但是你这一卷导致现在D服务改动,C和你部门也得跟着动了。
事后大家又在公司规范下面增加了一条:尽量对不熟悉的类依赖(迪米特原则),这条规定发布之后,大家又发现为了践行这条规则,好像大家的类方法又变多了。
最小接口原则 #
本来一个钱包类,只提供展示一个余额,之前都是订单类,直接操作钱包类,将余额自动扣减。现在订单类不允许直接读取余额了,那只能钱包承担订单的需求,扣减余额。随着大家的需求越来越多,钱包类越来越大了,本来方法维护就麻烦。这个时候产品又悄悄的走到你面前,hey咱们公司刚和XX公司合作了,我们这边想让用户拥有一个XX钱包,可以直接调用这个钱包支付,功能可以砍一点,只需要支付,余额可以不展示。
你这个时候一拍脑袋坏了,现在想加这个东西比较难,假如用个子类实现的话,要实现现在所有接口实在太麻烦了,测试都来不及了。要是当初不创建这么一个大的接口就好了,我拆成2个,一个展示接口,一个扣减接口,这样这个XX钱包就只需要实现展示接口就好了,这样我添加一个新的接口对自己和接入方都方便。没办法只能再推业务方再做一次架构调整了。
事后在上面的规范下面有加了一条补充事项:当接口方法不能太大,要适当拆分(最小接口原则)。
总结依赖问题的解决 #
我们现在来总结一下,为了解决分工导致的信息交流不通畅,我们提出了依赖倒置的这个原则松耦合架构,而且增加了两点要求:
- 类尽量减少对其他类了解(迪米特原则)
- 对外提供的接口方法尽量少(最小接口原则)
在这套架构下面,我们实现了一个软件的松耦合。正所谓内外均练才能武功大成,在这套架构下程序运行很流畅,维护的人也走了又走。但是随着时代的变更,虽然内部趋于平稳,软件还是需要和外界一同变化,接下来就讲讲如何在外部变化中的设计原则。
解决未来变化的核心 #
里氏替换原则 #
在升级中最常用的手段就是继承子类。在实践中大家发现总有几个老六不知道咋回事,只为了完成需求,将子类改的面目全非,继承的时候直接将某些接口返回的逻辑改了。一个理论上需要提供加法的作用,给改成了减法。当他将代码上线后,其他老服务炸锅了,好家伙给我接口数据喂毒。在事后的复盘大会上,大家提出来了,子类不应该更改父类的逻辑,即使你升级也得给我兼容老的逻辑,因为只要你在一个子类实现中更改了,后续继承你的子类都会走歪。这个就是里氏替换原则。
合成复用原则 #
在做老系统的时候升级的时候,假如类是一个可插拔的组织架构,我们发现我们完全可以不用新建一个类去改写全部代码升级,而是只需要更换其中过时的老部件,这样整体架构即可升级,我们更改的范围就会大大缩小。这个就是要提出的第二个要点:使用可插拔的组合或聚合结构少用继承(合成复用原则)。这个和里氏替换原则是一个互补的,就是假如你想通过继承实现差异性,你就得考虑你是否满足里氏替换原则,假如不满足的法,给你一个选择就是考虑使用合成复用原则去完成你的差异性,将整体的继承改成子组件的继承。
设计原则总结 #
现在我们总结一下,我们将七大设计原则拆分成三部分:
- 第一部分:切分系统(单一性原则和开闭原则)
- 第二部分:解决类依赖(依赖倒置,迪米特原则,最小接口原则)
- 第三部分:未来扩展(里氏替换原则和合成复用原则)
接下来我们再讲讲如何使用上面七大原则,去解析二十三种设计模式。
创建型模式 #
什么是创建型?很简单,就是去创建一个类对象。在Java中如何创建对象呢?很简单,就是new XX()
就好了。这个有什么问题呢?
第一个就是我们使用这个方法底层是调用类的一个构造器,上面的示例是个无参数构造器。当随着我们类需要的参数越来越多的时候这个就产生了一个问题,耦合其他类的生成。这个其实是属于类耦合的关系,违背了我们依赖倒置原则,所以我们需要使用创建型中的模式去解决它。
1. 生成的对象是相互独立的 #
假如生成的类必须是相互独立,就是每次操作的必须new出来。我们解决方法是:工厂方法(其中包含普通工厂,和工厂方法)。普通工厂是提供一个类的一个固定方法生成一个新的对象,可以是静态的也可以是实例方法。
假如我们工厂类需要越来越多,这个时候我们又违背了一个新的设计原则:开闭原则。我们每次新增一个不同的类实例,需要修改工厂类。那怎么解决呢?非常简单,我们上面说了,开闭原则就是横向扩展,我们只要定义一个工厂接口,让所有工厂都继承这个工厂接口,那么我们新增不同的类实例的时候,只需要新建一个工厂类就好了。所有工厂方法其实是满足了两个设计原则的。
那么抽象工厂是什么呢?抽象工厂其实满足合成复用原则的,是在这两个原则基础上,对类实现进行拆分。抽象工厂方法提供了一个产品,但是同一个工厂生成的东西不一定只有一种,比如汽车工厂还会生产配套的汽车周边产品。与其使用同一个产品类去继承,还不如拆分成多个部分。理论上你可以拆分的越来越细,当然随着你拆的越来越细,又违背了一个最小接口原则,这个时候我们可以考虑不用采用普通工厂模式了。
这个时候你可以采用构建者(Builder)模式,这个模式就是采用合成复用原则,来实现对类继承的解耦。建造者模式非常简单,大家在日常使用的时候也经常使用例如经常使用lombok的@Builder注解来对类进行注释,然后新增字段的时候只需要在build方法中添加对应组件就好了。
工厂方法还有一种特例就是,假如我们每次不需要从0到1去创建一个新的对象。比如我们需要一个盖章,但是我们不需要每次去专门去找创建一个印章,那我们可以保留印章,每次需要盖章的时候,沾上红泥,盖上去。这个我们称为原型模式,我们保留一个模子,需要新的时候从这个模子上复制出一个来。这个原型模式其实我觉得算是一个程序优化手段了,只不过我们通过这个手段间接的满足了依赖倒置原则。
2. 生成的对象不独立 #
假如你不需要生成的对象独立,我们可以使用单例模式来创建一个对象。单例模式我感觉就是普通工厂的一个特例。其实单例模式和原型模式都可以采用开闭原则来进一步扩展,就是你可以采用抽象工厂+单例模式,和抽象工厂+原型模式来组合使用。我们要理解,这些设计模式只是去符合设计原则的一种手段,我们可以用这些手段组合这些设计原则。
创建型模式就是在生成对象的时候的一些可以参考的手段,我们理解前面的设计原则之后就能轻松理解为啥会有这些手段了。接下来我们在进入结构型模式的设计模式学习。
结构型模式 #
什么结构型?结构型简单来说就是涉及到类的结构
类有什么结构?最简单的就是继承了。大家设计的时候大部分优先考虑的就是继承。但是继承我们是不推荐的,所有结构型的设计模式,都是来避免尽量减少继承,并且能够保持满足七大设计原则。
如何解决继承?我们先考虑的方法是采用组合或聚合模式。假如没法使用,比如我们就是需要一个新的实现某个接口的类,那么我们接下来思考如何解决这个问题。
适配器模式 #
我们举个生活例子,我们跨国旅游,我们的充电器只能适配国内的插口。为了解决这个问题,我们有两个方法:
- 第一个是重新买一个适合海外插口的充电器,底层上就是重新继承海外插口
- 第二个方法就是我们买一个中间的插头转换器,将海外三孔转换成两孔
这样你的老插头插上去就能用了。适配器模式底层就是使用了合成复用原则和最小接口原则,在适配器中采用组合模式组合一个国内充电器,然后实现一个海外插头最小版本接口,让国内插头能够从海外插头中获取电源到国内充电器中。
装饰器模式 #
假如接口符合我们要求,但是我只需要增加一部分逻辑,在执行老逻辑的情况下,如何执行历史逻辑?假如我们使用多重继承,那么就不满足里氏替换原则了。我们为了满足这个原则,我们将接口增加拆分成一个个装饰器,每个装饰器都独立。我们可以使用的时候可以部分增强。这个实现方法也满足了我们的开闭原则,将类横向拆分成多个装饰器。
代理模式 #
假如我们只需要拦截特定方法,我们可以使用代理模式。这个模式满足了开闭原则,我们将类拆分成代理层和普通对象。
外观模式 #
如果我们需要隐藏很多内部类,由于java不存在多重继承(当然我们也不推荐继承),我们可以使用外观模式,使其符合迪米特原则。如何实现呢?就是提供一个新类,再使用组合模式,隐藏内部类具体实现。
享元模式 #
享元模式我不太理解,我个人认为这个和单例模式一样,是一种优化手段,目的就是减少内存消耗,和设计模式感觉没啥关联。
桥接模式 #
结构型模式中还有一种模式,叫桥架模式。这个模式我觉得就是开闭原则在结构型模式的一种直接体现,结合依赖倒置。
总结结构型模式 #
总结一下结构型模式也是为了更好的拆分类,在能够使用组合的时候尽量使用组合,不能使用的时候,采用开闭原则,尽量将类拆分成不同的组件。接下来我们就来分析一下行为型设计模式。
行为型设计模式 #
行为型模式就是类和类之间的一种行为,通过总结了11种场景,总结了11种合作方式。这些方式都满足了设计原则,能很大程度上避免需求变化导致影响类具体变动。我们就以生活中的例子来出发,来详细介绍这11种行为模式。
一、行为扩展型 #
通过扩展对象能力管理行为变化
1. 迭代器模式 #
将遍历功能从类中分离,遵循单一职责原则。就像餐厅菜单的翻页功能独立于菜品展示,使得遍历逻辑可复用且不影响数据存储结构。
2. 备忘录模式 #
类似"时光机"机制,通过单一职责原则将对象状态保存与业务逻辑分离。如同游戏存档系统,角色状态备份与游戏进程控制互不干扰,支持随时回档。
二、行为封装类 #
封装可变行为实现解耦
3. 命令模式 #
采用开闭原则将请求封装为独立对象。如同智能家居遥控器:
-
抽象命令接口(开/关)
-
具体命令类(开灯/关空调)
-
支持命令组合(“回家模式”=开灯+开空调)
4. 策略模式 #
基于单一职责+开闭原则封装算法族。以支付系统为例:
-
策略接口定义支付方法
-
具体策略实现(微信/支付宝/银联)
-
运行时动态切换策略,且策略可能改变上下文状态
三、行为沟通型 #
封装类间通信方式
5. 中介者模式 #
通过迪米特原则解耦对象间通信。典型如机场塔台:
-
航班不直接沟通
-
所有协调通过塔台中介
-
新增航班只需对接塔台
6. 观察者模式 #
遵循依赖倒置原则实现发布-订阅机制。如新闻订阅系统:
-
报社(发布者)维护订阅列表
-
读者(观察者)自主订阅/退订
-
新报纸自动推送无需修改报社代码
7. 责任链模式 #
按单一职责原则构建处理链。以客服系统为例:
-
初级客服→技术专家→部门主管
-
每个节点只处理对应级别问题
-
请求沿链传递直至被处理
8. 访问者模式 #
通过依赖倒置分离数据结构与操作。以编译器为例:
-
AST节点(被访问者)保持稳定
-
访问者实现不同操作(格式化/类型检查)
-
新增操作只需添加访问者类
四、行为组合型 #
通过组件组合隔离变化
9. 模板方法模式 #
基于里氏替换原则定义算法骨架。如咖啡制作:
abstract class CoffeeMaker {
// 模板方法
final void makeCoffee() {
boilWater();
brew();
pourInCup();
addCondiments();
}
// 具体步骤实现
abstract void brew();
abstract void addCondiments();
}
10. 状态模式 #
采用组合模式+依赖倒置管理状态。以订单系统为例:
-
订单对象组合状态接口
-
具体状态实现(待支付/已发货/已完成)
-
状态变更自动触发行为变化
11. 解释器模式 #
通过组合模式构建语法解析器。如SQL解析:
-
终结符表达式(SELECT/WHERE)
-
非终结符表达式(查询条件组合)
-
通过表达式组合实现复杂查询解析
设计模式应用实践 #
实际开发中的选择 #
在实际开发中,我们经常会面临设计模式的选择问题。以下是一些实用建议:
- 不要为了用模式而用模式:模式是解决特定问题的工具,不是目标
- 从简单开始:先用最简单的实现,当发现维护困难时再考虑重构
- 关注变化点:识别系统中可能变化的部分,用适当模式封装
- 组合使用:大多数情况下需要组合多个模式解决问题
常见误区 #
- 过度设计:在需求不明确时过早引入复杂模式
- 模式滥用:把简单问题复杂化
- 生搬硬套:不考虑实际场景强行应用模式
- 忽视重构:认为一开始就必须完美设计
设计模式与DDD的结合 #
回到最初的问题:如何在DDD分层架构中应用设计模式提升代码质量?
下面是一些可用的手段
-
领域层:
- 使用工厂模式创建复杂领域对象
- 应用策略模式处理业务规则变化
- 采用访问者实现查询条件组合
-
应用层:
- 命令模式封装用例操作
- 中介者模式协调多个领域服务
-
基础设施层:
- 适配器模式对接外部系统
- 外观模式隐藏外部依赖
总结与展望 #
设计模式不是银弹,但确实是提高代码质量的有效工具。理解设计原则比记住具体模式更重要,因为原则是模式背后的指导思想。在实际开发中:
- 先理解问题本质
- 识别变化点和稳定点
- 选择合适的设计原则
- 应用相应的模式实现
- 持续重构优化
记住,好的设计是演进而来的,不是一次性完成的。随着业务发展和技术演进,我们的设计也需要不断调整。设计模式提供了一套经过验证的解决方案,但最终还是要结合具体场景灵活应用。