攻城狮内功心法之软件架构设计原则(设计模式前言篇)
本来这次想聊聊我们常用的几个设计模式,以及我们当前核心系统适合使用哪几种设计模式去优化(解耦),但是转念一想,更应该先聊一聊软件架构的设计原则,希望对新入行或者已经深耕业务开发多年而渐渐遗忘软件设计原则相关知识点的老司机有所帮助。
软件架构设计的六大设计原则
开闭原则
指的是一个软件实体,比如类、模块或者函数应该对扩展开放,对修改关闭。所谓的开闭是针对扩展和修改两个行为的一个原则,强调的是用抽象构建框架,用实现扩展细节。
开闭原则是面向对设计中最基础的设计原则,目的是提供软件系统可复用性及可维护性,它指导我们如何建立稳定灵活的系统。
在我们现实生活中,开闭原则最直观的体现就是我们的弹性工作时间,每天工作8小时的规定是关闭的,不可修改。但是上班时间和下班时间是开放的,早来的早走,晚来的晚走。
接下来以我们的业务举例,首先创建一个银行机构的接口IBankService
- public interface IBankService {
- /**获取银行编码*/
- String getBankCode();
- /**名称*/
- String getName();
- /**贷款利率*/
- BigDecimal getInterestRate();
- }
我们已对接的银行机构有很多,这里随便创建一个具体的银行对象来实现以上接口,以建设银行为例:
- public class CBCBank implements IBankService {
- private String bankCode;
- private String name;
- private BigDecimal interestRate;
- public CBCBank(String bankCode, String name, BigDecimal interestRate) {
- this.bankCode = bankCode;
- this.name = name;
- this.interestRate = interestRate;
- }
- @Override
- public String getBankCode() {
- return this.bankCode;
- }
- @Override
- public String getName() {
- return this.name;
- }
- @Override
- public BigDecimal getInterestRate() {
- return this.interestRate;
- }
- }
通常情况下建行对大部分地区的贷款利率不会变化,突然有一天,建行推出针对偏远地区中小企业的贷款优惠活动,利率为原来利率的80%,(可以理解为商品打折活动)。那这时候我们该如何满足需求呢?如果直接修改getInterestRate()方法,可能会存在一定风险,影响到其他地方的调用结果。我们如何在不修改原来代码前提下,实现利率优惠呢?那我们写一个处理优惠活动的类继承上面的类,对子类进行扩展。
- public class CBCBankDiscount extends CBCBank{
- public CBCBankDiscount(String bankCode, String name, BigDecimal interestRate) {
- super(bankCode, name, interestRate);
- }
- //原始利率
- public BigDecimal getOriginalInterestRate(){
- return super.getInterestRate();
- }
- //打折后当前利率
- public BigDecimal getInterestRate(){
- return super.getInterestRate().multiply(new BigDecimal("0.8"));
- }
- }
以上就达到了我们的目的,并对开闭原则做了展示。再看一下具体类图结构:
实际上我们系统中的大部分代码都遵循着该原则。面临特殊需求的时候难免会对已有稳定的功能做扩展,该原则还是要牢记。
单一职责原则
约定一个类只做一件事,保证其功能的单一性,要对这个类做出改变的原因也只有一个。假设我们一个Class负责两个职责,一旦发生需求变更,修改其中一个职责的代码,导致另外职责功能发生故障。这样这个Class存在两个导致类变更的原因。如何解决?两个职责拆分成两个独立Class实现,进行解耦。后去需求的变更维护互不影响。(当然拆分粒度需要结合业务、需求变动频繁度等综合分析)。
单一职责原则优点,降低类的复杂度、提供可读性、提供系统可维护性、降低变更引起的风险。
接下来还是看具体例子:
- 方法级别单一职责
假设做一个针对用户修改账号名称以及账号密码的功能。
实现方式一,定义一个修改用户信息的方法,入参:用户对象和修改内容类型。
- public interface IUserOperation {
- /**
- * @date 2020-03-21 15:13
- * @param user, operationType: 0 更新姓名 ,1 更新密码
- * @return boolean
- */
- boolean updateUserInfo(User user, Integer operationType);
- }
- public class UserOperationImpl implements IUserOperation {
- @Override
- public boolean updateUserInfo(User user, Integer operationType) {
- if(0 == operationType){
- //更新账户姓名
- }else if(1 == operationType){
- //更新账户密码
- }
- return true;
- }
- }
这种实现方式,修改姓名和修改密码两个逻辑放到一个方法中,通过operationType参数区分执行哪个逻辑,在使用该方法时稍不注意传错参数,悲剧发生。因此可见这个方法职责是不明确的。
实现方式二,接口中定义两个方法,更新密码和更新用户姓名。
- public interface IUserOperationPlus {
- boolean updatePassword(User user, String password);
- boolean updateUserInfo(User user);
- }
- public class UserOperationPlusImpl implements IUserOperationPlus {
- @Override
- public boolean updatePassword(User user, String password) {
- //更新密码操作
- return false;
- }
- @Override
- public boolean updateUserInfo(User user) {
- //更新 姓名操作
- return false;
- }
- }
这种方式实现修改账户姓名和修改密码两个方法,每个方法的职责单一。调用者可以明确知道方法实现的是什么逻辑。
- 接口级别单一职责
假设一个做家务的场景吧,常见对的家务有买菜、倒垃圾等。我们让王铁锤只负责倒垃圾,让王钢弹负责买菜,买菜回来再让他去洗衣服。
实现方式一:定义一个HomeWork接口,里面包含 买菜、倒垃圾方法。
- public interface HomeWork {
- /**买菜*/
- void buyVegetables();
- /**扔垃圾*/
- void pourGarbage();
- }
- public class Tiechui implements HomeWork {
- @Override
- public void buyVegetables() {
- //从不买菜
- }
- @Override
- public void pourGarbage() {
- System.out.println("去倒垃圾");
- }
- }
- public class Gangdan implements HomeWork {
- @Override
- public void buyVegetables() {
- System.out.println("去菜市场买菜");
- }
- @Override
- public void pourGarbage() {
- //从不丢垃圾
- }
- }
钢弹买菜回来,再去洗衣服,目前的接口不满足了,需要添加WashClothes()的方法,然后钢弹和铁锤都需要实现该方法,只是铁锤不做具体代码实现。这样就会很别扭,不符合单一职责原则了。因为修改一个地方,影响到了不需要做出修改的代码。铁锤说了,“我不洗衣服,也不想接触这个东西”,说罢凿墙而去。
实现方式二,讲HomeWork细化,每项任务创建一个接口。
- //买菜
- public interface BuyVegetables {
- void doBuyVegetables();
- }
- //倒垃圾
- public interface PourGarbage {
- void doPourGarbage();
- }
- //洗衣服
- public interface WashClothes {
- void doWashClothes();
- }
- public class TiechuiPlus implements PourGarbage {
- @Override
- public void doPourGarbage() {
- System.out.println("倒垃圾");
- }
- }
- public class GangdanPlus implements BuyVegetables, WashClothes {
- @Override
- public void doBuyVegetables() {
- System.out.println("买菜");
- }
- @Override
- public void doWashClothes() {
- System.out.println("洗衣服");
- }
- }
这种实现方式,把家务细化隔离,王铁锤和王钢弹各取所需,相互不影响。
- 类级别单一职责
类强行按照单一职责原则创建的话会有点尴尬,因为类的职责范围可大可小,还是主要取决于业务场景和代码逻辑复杂度。比如三个简单的单一职责接口登录、注册、注销实现,可以放到同一个类中;再比如我们使用的工作流,整个流程下来会涉及到多个功能,所有的功能逻辑放到一个类里,会耦合的太紧,不利于系统稳定。类就不写代码举例子了,只能意会一下了各位。
我们在实际开发中的项目会有依赖、组合、聚合等关系存在,另外项目的规模、周期、技术人员的水平等因素影响,系统中的很多类都存在不符合单一职责原则的情况。但是我们还是要尽可能的让接口和方法保持单一职责,以便于我们项目后期的扩展和维护。
依赖倒置原则
指设计代码结构时,通常我们会涉及到多层结构。高层模块不应该依赖低层模块,两者都应该依赖其抽象。
通过依赖倒置,可以减少类之间的耦合性、提高系统稳定性、提高代码的可读性和可维护性。
以上描述不好懂,还是看具体例子吧。
偷个懒儿,我们还是来说做家务的事情吧。创建一个类Xiaohua:
- public class Xiaohua {
- public void doWashClothes(){
- System.out.println("小花洗衣服");
- }
- public void doPourGarbage(){
- System.out.println("小花扔垃圾");
- }
- public static void main(String[] args) {
- Xiaohua xiaohua = new Xiaohua();
- xiaohua.doWashClothes();
- xiaohua.doPourGarbage();
- }
- }
如上代码,运行之后,小花洗衣服之后扔垃圾。但是家务活远不止这些要做,小花之后还要买菜、做饭、刷碗等一系列任务。如果在Xiao类中逐一增加新的方法。这样对已发布功能的系统来说,这个类是不稳定的,会在修改代码发布上线后带来意想不到的风险。接下来要对它就行优化。
创建一个家务活的抽象接口,IHomeWork:
- public interface IHomeWork {
- void doHomeWork();
- }
然后定义几种类型的家务活类,如下:
- public class WashClothesHomeWork implements IHomeWork {
- @Override
- public void doHomeWork() {
- System.out.println("小花洗衣服");
- }
- }
- public class PourGarbageHomeWork implements IHomeWork {
- @Override
- public void doHomeWork() {
- System.out.println("小花扔垃圾");
- }
- }
然后小花做家务活这个类本身也要相应的修改,如下:
- public class XiaohuaPlus {
- public void doHomeWork(IHomeWork homeWork){
- homeWork.doHomeWork();
- }
- public static void main(String[] args) {
- XiaohuaPlus xiaohua = new XiaohuaPlus();
- xiaohua.doHomeWork(new WashClothesHomeWork());
- xiaohua.doHomeWork(new PourGarbageHomeWork());
- }
- }
这时候,无论以后小花有多少任务要做,对于新家务活,我们只需要新建一个类实现IHomeWork接口,通过传参方式告诉小花,不需要修改逻辑主体代码。这种方式其实叫做依赖注入。注入方式还包括构造器方式和setter方式别分看下代码例子。
构造器注入方式:
- public class XiaohuaConstructor {
- private IHomeWork homeWork;
- public XiaohuaConstructor(IHomeWork homeWork){
- this.homeWork = homeWork;
- }
- public void doHomeWork(){
- homeWork.doHomeWork();
- }
- public static void main(String[] args) {
- XiaohuaConstructor xiaohua = new XiaohuaConstructor(new WashClothesHomeWork());
- xiaohua.doHomeWork();
- }
- }
然后这种方式有个缺点,在调用时,每次都要创建一个xiaohua实例。如果xiaohua要用全局单例,我们可以选择用Setter方式来注入,如下:
- public class XiaohuaSetter {
- private IHomeWork homeWork;
- public void setHomeWork(IHomeWork homeWork){
- this.homeWork = homeWork;
- }
- public void doHomeWork(){
- homeWork.doHomeWork();
- }
- public static void main(String[] args) {
- XiaohuaSetter xiaohua = new XiaohuaSetter();
- xiaohua.setHomeWork(new WashClothesHomeWork());
- xiaohua.doHomeWork();
- xiaohua.setHomeWork(new PourGarbageHomeWork());
- xiaohua.doHomeWork();
- }
- }
接口隔离原则
指用多个专门的接口替代单一的总接口,客户端调用者不应该依赖它不需要的接口。这个原则指导我们在设计接口时应该注意以下几点:
- 类与类的依赖应该建立在最小的接口之上。
- 建立单一功能的接口,不要建立庞大臃肿的接口。
- 尽量细化接口,接口中的方法尽量少。(看业务情况,要适度)
接口隔离原则符合高内聚低耦合的设计思想,从而使类具有更好的可读性、可扩展性和可维护性。
这里具体例子可以参考 上面 单一职责原则的接口级别的代码例子。我们通过类结构图比较一下,可以看出下面的结构更利于以后扩展。
迪米特法则(最少知道原则)
指一个对象(类)应该对其他对象(类)保持最少的了解,目的是降低类与类之间的耦合。该法则还有一个更直观的定义,只与直接朋友通信。那么什么是直接朋友?每个对象都会与其他对象有耦合关系,我们就说这两个对象是朋友关系。类耦合的方式包括依赖、关联、组合、聚合等。其中出现成员变量、方法参数、方法返回值中的类为直接朋友关系,而出现在局部变量中的类则是非直接朋友关系。
为了方便理解,还是举例子吧。
假设现在Boss需要查看当前线上的成交订单数,Boss首先找到的肯定是TeamLeader,TeamLeader统计完成后,把结果告知Boss。我们用代码表达一下这个场景。
首先有个订单类,Order
- public class Order {
- }
TeamLeader 类:
- public class TeamLeader {
- public void getTotalOrderNumber(List<Order> orderList){
- System.out.println("目前成交订单数量为"+orderList.size());
- }
- }
Boss类:
- public class Boss {
- public void checkOrderNumber(TeamLeader teamLeader){
- //创建有效订单返回的列表
- List<Order> orderList = new ArrayList<>();
- //模拟100条数据
- for( int i = 0;i<100 ;i++){
- orderList.add(new Order());
- }
- teamLeader.getTotalOrderNumber(orderList);
- }
- }
如上代码示例,我们找出Boss的直接朋友和非直接朋友,首先因为TeamLeader为Boss类方法中的入参,所以它是直接朋友。其次Boss在checkOrderNumber方法中创建了Order 列表对象,Order成了Boss的局部变量。所以它是非直接朋友关系。这样就违反了迪米特法则。
为了使上述示例符合迪米特法则,我们可以做如下修改:
TeamLeader类:
- public class TeamLeader {
- public void getTotalOrderNumber(){
- List<Order> orderList = new ArrayList<>();
- for(int i = 0;i<100;i++){
- orderList.add(new Order());
- }
- System.out.println("目前成交订单数量为"+orderList.size());
- }
- }
Boss类:
- public class Boss {
- public void checkOrderNumber(TeamLeader teamLeader){
- teamLeader.getTotalOrderNumber();
- }
- }
最新类结构:
如上,很明显Boss与Order之间没有依赖了。减少了类与类之间的耦合。但是凡事要有度,该法则也不能过度使用,类与类之间的业务关联都要通过该法则进行约束的话,会产生大量的传递类,导致系统复杂度变大。
里氏替换原则
该原则主要针对子类与父类进行约束。子类与父类最大的关系就是继承。
继承优点:
-
- 子类可以拥有父类的属性和方法,从而提供代码重用性。
- 提高代码可扩展性,子类本身可以自定义独有方法。
缺点:
- 侵入性,子类继承父类就必须拥有父类的属性和方法,这对子类也是一种约束,降低了代码灵活性。
- 高耦合,父类变量或者方法修改,一定会影响到子类。
此时里氏替换原则登场,它作为继承复用的基石,只有当衍生类可以替换基类,软件单位的功能不受到影响时(基类如何改动,对子类不受影响),那么基类才能真正被复用。
具体原则如下:
- 子类可以实现父类的抽象方法,但不能覆盖父类的非抽象方法。
- 子类中可以增加自己特有的方法。
- 当子类的方法重载父类的方法时,方法的前置条件(方法的入参)要比父类的方法入参更宽松。
- 当子类的方法实现父类的方法时(重写/实现抽象方法),方法的后置条件(返回值)要比父类更新严格或者相等。
以上四点,第三点不好理解,这样说有点干巴巴,还是来点例子解解渴吧。
定义一个父类A doSomeThing方法参数HashMap范围小于子类 doSomeThing方法参数Map,此时子类方法入参比父类入参更宽松了。
父类 A :
- public class A {
- public Collection doSomething(HashMap map){
- System.out.println("父类被执行......");
- return map.values();
- }
- }
子类B:
- public class B extends A {
- //重载父类方法
- public Collection doSomething(Map map) {
- System.out.println("子类被执行...");
- return map.values();
- }
- }
测试类执行:
- @Test
- public void invokerTest(){
- A father = new A();
- HashMap map= new HashMap();
- father.doSomething(map);
- }
执行结果输出:
那么根据里氏替换原则,父类出现的地方可以被子类替换,我们把自测类修改如下:
- @Test
- public void invokerTest(){
- B son = new B();
- HashMap map= new HashMap();
- son.doSomething(map);
- }
执行结果输出:
如上符合预期,现在我们尝试将父类和子类的入参范围对换一下,使父类方法的入参范围大于子类方法入参。
父类 A2:
- public class A2 {
- public Collection doSomething(Map map){
- System.out.println("父类被执行......");
- return map.values();
- }
- }
子类B2:
- public class B2 extends A2 {
- //重载父类方法
- public Collection doSomething(HashMap map) {
- System.out.println("子类被执行...");
- return map.values();
- }
- }
直接用子类替换父类执行的自测类如下:
- @Test
- public void invokerTest(){
- B2 son = new B2();
- HashMap map= new HashMap();
- son.doSomething(map);
- }
执行输出结果:
预期输出结果应该是:父类被执行......
显然不符合里氏替换原则,这种程序不易维护,且出现问题也难以排查。
第四点原则,换一种说法就是,比如父类一个方法返回值类型为T,子类重载或者重写相同方法,返回值类型是S,那么此时S必须小于等于T。只有这样才能避免出现问题。
写在最后
以上六个设计原则是设计模式的基础,我们常用流行的开源框架基于各种设计模式为我们做了很多封装。只是我们在业务开发过程中没过多注意。希望这篇文章能让大家回想起这些基础知识点,对大家以后的工作有些帮助作用。以上若有遗漏,敬请各位老师指正。