JAVA设计模式第一讲:设计原则

设计模式(design pattern)是对软件设计中普遍存在的各种问题,所提出的解决方案。本文以面试题作为切入点,介绍了设计模式的常见问题。我们需要掌握各种设计模式的原理、实现、设计意图和应用场景,搞清楚能解决什么问题。本文是设计模式第一讲:设计原则

设计模式从入门到精通:设计原则

推荐书籍

推荐书籍:

书籍语言难易程度
《大话设计模式》java学起来最简单
《Head First 设计模式》java自学设计模式最好的教材,学起来简单,缺点是缺乏实际工程实践
《图解设计模式》java适合入门学习
《编写可读代码的艺术》入门
《设计模式》(刘伟,清华大学出版社)java入门教材
《人人都懂设计模式:从生活中领悟设计模式:Python实现》python
《设计模式:可复用面向对象软件的基础》GOF基于C++枯燥,适合理论提高
《重构 - 改善既有代码设计》代码坏味道和相应代码的最佳实践。
《人月神话》这本书可能也有点过时了。但还是经典书
《代码整洁之道》细节之处的效率,完美和简单。

推荐课程:


1、学习设计模式的意义?

1、编写高质量代码

  • 避免在工作中写出烂代码,坑人坑己

2、提高复杂代码的设计和开发能力

  • 即便做与业务无关的框架类复杂设计,也能应对自如
  • 可以写出高拓展、易维护的代码

3、让读源码、学框架事半功倍

  • 更轻松地读懂开源项目

4、为你的职场发展做铺垫

  • 场景1:当一个项目开发完后,如果客户提出增新功能,怎么办?
  • 场景2:项目开发完后,原来程序员离职,你接手维护该项目怎么办?
  • 场景3:经验丰富后,需要指导新入职的同事写好代码及Code Review。

5、应对面试中设计模式相关问题 从功利的角度

Action1:请问在你过往的项目中,有没有使用过设计模式,在什么场景下使用的?解决了什么问题?

  • 场景1: 使用枚举创建全局唯一的对象 单例模式

    • enum Singleton {
          INSTANCE; //属性
          public void sayOK() {
            	System.out.println("ok");
          }
      }
      
    • 为啥枚举创建单例优秀:1、无法通过new来随意创建对象,其构造函数为private. 2、枚举本质上是个final类 3、避免反射创建单例对象 4、避免通过序列化创建单例对象

    • 能保证线程安全

  • 场景2:使用工厂模式实现 Spring BeanFactory?

  • 场景3:使用@Builder Builder 模式用来创建复杂对象

    • 使用ES使用构造者模式来生成参数
  • 场景4:原型模式:通过对象的序列化实现深拷贝 Spring的 BeanUtil 反射 Json序列化和反序列化

  • 场景5:通过AOP代理模式记录操作日志,清理缓存,实现多数据源

  • 场景6:使用适配器模式做接口兼容升级

  • 场景7:使用观察者模式解耦业务 Spring Event / Guava EventBus / MQ

  • 场景8:使用模板方法模式,提供拓展点,让子类去实现,优化开放平台代码,巡检项目

    • 通过模版方法模式 + 状态机模式解决了订单状态流转的问题
  • 场景9:使用策略模式为框架提供扩展点,降低代码耦合,附件项目

    • 使用策略模式解决车队费用编辑保存问题
  • 场景10:使用职责链模式优化商品规则校验引擎

    • 项目中各种使用Spring Intercepter / Mybatis Intercepter 的场景

2、如何编写高质量代码

2.1 设计模式的目的(高内聚,松耦合)

① 代码重用性(相同功能代码,不用多次编写)

  • 例如对账逻辑,原本是在车队财务和货代财务都存在,现在全部在巡检项目中实现

②可读性 (编程规范性, 便于其他程序员阅读和理解)

  • 《编写可读代码的艺术》这本书对”可读性“的评价是:只有在核心领域为了效率才可以放弃可读性,否则可读性是第一位

③可扩展性 (当需要增加新功能时,非常方便,称为可维护)

  • 代码预留了一些功能拓展点,可以把新功能代码,直接插到拓展点上

④可靠性 (当我们增加新功能后,对原来的功能没有影响)

Action1:代码的评价标准,你心目中优秀的代码是怎么样的?

要满足编码规范:以公司开发规约、静态代码规约为前提,是否遵守了编码规范,是否遵循了最佳实践。除了形式上的要求外,更重要的是命名规范。目标是提高代码的可读性,降低代码可维护性成本。

代码质量高:因为代码质量好坏直接决定了软件的可维护性成本的高低。代码质量应该更多的应该从可测性,可读性,可理解性,容变性等代码可维护性维度去衡量

2.2 如何写出高质量代码

如何编写高质量代码如图所示:

分为如下几个方面:

  • 要写出满足这些评价标准的高质量代码,我们需要掌握一些更加细化、更加能落地的编程方法论,包括面向对象设计思想、设计原则、编码规范、重构技巧、设计模式等。而所有这些编程方法论的最终目的都是为了 编写出高质量的代码
    在这里插入图片描述

具体而言:

  • ① 使用面向对象中的继承、多态能让我们写出可复用的代码;

    • 它是很多设计原则、设计模式的基础
  • 设计原则中的 单一职责、DRY(Don’t RepeatYourself)、基于接口而非实现、里式替换原则 等,可以让我们写出可复用、灵活、可读性好、易扩展、易维护的代码;

    • 是很多设计模式的指导思想
  • 编码规范能让我们写出可读性好的代码

    • 编码规范:可以参考这几本书《重构 - 改善既有代码设计》《代码大全》《代码整洁之道》《编写可读代码的艺术》
  • ④ 持续重构可以时刻保持代码的可维护性

    • 重构技巧:持续重构是保持代码质量不下降的有效手段,能有效避免代码腐化到无可救药的地步
  • 设计模式可以让我们写出易扩展的代码


3、常用的设计原则(SOLID 7大原则)

设计原则是各类设计模式的基础

对于每一种设计原则,我们需要掌握它的设计初衷,能解决哪些编程问题,有哪些应用场景。只有这样,我们才能在项目中灵活恰当地应用这些原则。

1、单一职责原则

  • 设计初衷:对类来说的,即一个类或模块应该只负责一项职责

    • 定义了类的粒度,不要设计大而全的类,要设计粒度小、功能单一的类
  • 解决了哪些编程问题:

    • 对于单一职责原则,如何判定某个类的职责是否够“单一”?
      • 我们可以先写一个粗粒度的类,满足业务需求。随着业务的发展,如果粗粒度的类越来越庞大,代码越来越多,这个时候,我们就可以将这个粗粒度的类,拆分成几个更细粒度的类。这就是所谓的持续重构
  • 有哪些应用场景

2、接口隔离原则

  • 设计初衷:客户端不应该依赖它不需要的接口,即一个类对另一个类的依赖应该建立在最小的接口

    • 不要让子类实现不必要的接口 --之前记录日志模板时出过这样的问题
  • 解决了哪些编程问题:todo

  • 有哪些应用场景:todo

3、依赖倒转原则 (面向接口编程)

  • 设计初衷:高层模块不应该依赖低层模块,应该依赖其抽象

    • 抽象不应该依赖细节
  • 建议:低层模块尽量都要有抽象类或接口。

  • 解决了哪些编程问题:todo

  • 有哪些应用场景:todo

4、里式替换原则

  • 设计初衷:教你如何正确的使用继承 (引用基类的地方必须能透明地使用其子类的对象)

    • 父类定义了函数 的“约定”(或者叫协议),那子类可以改变函数的内部实现逻辑,但不能改变函数原有 的“约定”
    • 为了满足里式替换,我们定义一个更为抽象的基类,实现类采用组合的方式 替换原有的继承关系。
  • 解决了哪些编程问题:todo

  • 有哪些应用场景:todo

5、开闭原则最基础、最重要

  • 设计初衷:模块、类、方法等 对扩展开放、对修改关闭

    • 当程序变更时,尽量是通过扩展程序的行为实现变化,而不是修改已有的代码来实现变化
  • 解决了哪些编程问题:

    • 修改代码就一定意味着违反‘开闭原则’吗?
  • 有哪些应用场景:见第4个标题

6、迪米特原则

  • 设计初衷:最少知道原则,即一个类对自己依赖的类知道的越少越好

  • 陌生的类最好不要以局部变量的形式出现在类的内部。

  • 高内聚 低耦合

    • “高内聚”用来指导类本身的设计,“松耦合”用来指导类与类之间依赖关系的设计
  • 解决了哪些编程问题:todo

  • 有哪些应用场景:见第5个标题

7、合成复用原则

  • 设计初衷:尽量使用组合的方式,而不是使用继承 来达到复用的原则
  • 原因:继承会让两个类的耦合性增强,即组合比继承具有更高的灵活性
  • 解决了哪些编程问题:todo
  • 有哪些应用场景: 为何说要多用组合少用继承?如何决定该用组合还是继承? 见第6个标题

4、如何做到对扩展开放、对修改关闭?深入理解开闭原则

理论:越抽象、越顶层、越脱离具体某一实现的设计,越能提高代码的灵活性,越能应对未来的需求变化好的代码设计,不仅能应对当下的需求,而且在将来需求发生变化的时候,仍然能够在不破坏原有代码设计的情况下灵活应对

添加一个新的功能应该是,在已有代码基础上扩展代码(新增模块、类、方法等),而非修改已有代码(修改模块、类、方法等)。

案例: API 接口监控告警的代码
AlertRule 存储告警规则,可以自由设置。Notification 是告警通知类,支持邮件、短信、微信、手机等多种通知渠道。NotificationEmergencyLevel 表示通知的紧急程度,包括 SEVERE(严重)、URGENCY(紧急)、NORMAL(普通)、TRIVIAL(无关紧要),不同的紧急程度对应不同的发送渠道。

public class Alert {
     private AlertRule rule;
     private Notification notification;
     /* 使用构造函数来初始化,可以防止npe */
     public Alert(AlertRule rule, Notification notification) {
       	this.rule = rule;
       	this.notification = notification;
    }

    public void check(String api, long requestCount, long errorCount, long durationOfSeconds) {
        long tps = requestCount / durationOfSeconds;
        if (tps > rule.getMatchedRule(api).getMaxTps()) {
           	notification.notify(NotificationEmergencyLevel.URGENCY, "...");
        }
        if (errorCount > rule.getMatchedRule(api).getMaxErrorCount()) {
           	notification.notify(NotificationEmergencyLevel.SEVERE, "...");
        }
    }
}

问题:业务逻辑主要集中在 check() 函数中。当接口的 TPS 超过某个预先设置的最大值时,以及当接口请求出错数大于某个最大允许值时,就会触发告警,通知接口的相关负责人或者团队。现在,如果我们需要添加一个功能,当每秒钟接口超时请求个数,超过某个预先设置的最大阈值时,我们也要触发告警发送通知。这个时候,我们该如何改动代码呢?

主要的改动有两处:第一处是修改 check() 函数的入参,添加一个新的统计数据 timeoutCount,表示超时接口请求数;第二处是在 check() 函数中添加新的告警逻辑。

public class Alert {
    // ...省略AlertRule/Notification属性和构造函数...

    // 改动一:添加参数timeoutCount
    public void check(String api, long requestCount, long errorCount, long timeoutCount, long durationOfSeconds) {
        long tps = requestCount / durationOfSeconds;
        if (tps > rule.getMatchedRule(api).getMaxTps()) {
            notification.notify(NotificationEmergencyLevel.URGENCY, "...");
        }
        if (errorCount > rule.getMatchedRule(api).getMaxErrorCount()) {
            notification.notify(NotificationEmergencyLevel.SEVERE, "...");
        }
        // 改动二:添加接口超时处理逻辑
        long timeoutTps = timeoutCount / durationOfSeconds;
        if (timeoutTps > rule.getMatchedRule(api).getMaxTimeoutTps()) {
            notification.notify(NotificationEmergencyLevel.URGENCY, "...");
        }
    }
}

存在的问题:

  • 一方面,我们对接口进行了修改,这就意味着调用这个接口的代码都要做相应的修改
  • 另一方面,修改了check() 函数,相应的单元测试都需要修改

我们先重构一下之前的 Alert 代码,让它的扩展性更好一些
重构的内容主要包含两部分:

  • 第一部分是将 check() 函数的多个入参封装成 ApiStatInfo 类;
  • 第二部分是引入handler的概念,将 if 判断逻辑分散在各个 handler(抽象类) 中。

具体实现逻辑如下所示

public class Alert {
    private List<AlertHandler> alertHandlers = new ArrayList<>();
    public void addAlertHandler(AlertHandler alertHandler) {
      	this.alertHandlers.add(alertHandler);
    }

    public void check(ApiStatInfo apiStatInfo) {
        for (AlertHandler handler : alertHandlers) {
          	handler.check(apiStatInfo);
        }
    }
}

public class ApiStatInfo {
    //省略constructor/getter/setter方法
    private String api;
    private long requestCount;
    private long errorCount;
    private long durationOfSeconds;
}

public abstract class AlertHandler {
    protected AlertRule rule;
    protected Notification notification;
    public AlertHandler(AlertRule rule, Notification notification) {
        this.rule = rule;
        this.notification = notification;
    }
    public abstract void check(ApiStatInfo apiStatInfo);
}

public class TpsAlertHandler extends AlertHandler {
    public TpsAlertHandler(AlertRule rule, Notification notification) {
        super(rule, notification);
    }
    @Override
    public void check(ApiStatInfo apiStatInfo) {
        long tps = apiStatInfo.getRequestCount()/ apiStatInfo.getDurationOfSeconds();
        if (tps > rule.getMatchedRule(apiStatInfo.getApi()).getMaxTps()) {
            notification.notify(NotificationEmergencyLevel.URGENCY, "...");
        }
    }
}

public class ErrorAlertHandler extends AlertHandler {
    public ErrorAlertHandler(AlertRule rule, Notification notification){
      	super(rule, notification);
    }
    @Override
    public void check(ApiStatInfo apiStatInfo) {
        if (apiStatInfo.getErrorCount() > rule.getMatchedRule(apiStatInfo.getApi()).getMaxErrorCount()) {
          	notification.notify(NotificationEmergencyLevel.SEVERE, "...");
        }
    }
}

再来看下,重构之后的 Alert 该如何使用呢?

ApplicationContext 是一个单例类,负责 Alert 的创建、组装(alertRule 和 notification 的依赖注入)、初始化(添加 handlers)工作。

public class ApplicationContext {
    private AlertRule alertRule;
    private Notification notification;
    private Alert alert;

    public void initializeBeans() {
        alertRule = new AlertRule(/*.省略参数.*/); //省略一些初始化代码
        notification = new Notification(/*.省略参数.*/); //省略一些初始化代码
        alert = new Alert();
        alert.addAlertHandler(new TpsAlertHandler(alertRule, notification));
        alert.addAlertHandler(new ErrorAlertHandler(alertRule, notification));
    }
    public Alert getAlert() { return alert; }

    // 饿汉式单例
    private static final ApplicationContext instance = new ApplicationContext();
    private ApplicationContext() {
      	initializeBeans();
    }
    public static ApplicationContext getInstance() {
      	return instance;
    }
}

public class Demo {
    public static void main(String[] args) {
        ApiStatInfo apiStatInfo = new ApiStatInfo();
        // ...省略设置apiStatInfo数据值的代码
        ApplicationContext.getInstance().getAlert().check(apiStatInfo);
    }
}

补充:更好的方式,借助Spring IOC的依赖注入功能

@Component
public class AlertFactory {
    // 关键功能 Spring 会自动将 AlertHandler 接口的类注入到这个Map中
    @Autowired
    private Map<String, AlertHandler> alertHandleryMap;
   	public AlertRule getBy(String alertEnum) {
        return alertHandleryMap.get(alertEnum);
    }
}

现在,我们再来看下,基于重构之后的代码,如果再添加上面讲到的那个新功能,每秒钟接口超时请求个数超过某个最大阈值就告警,我们又该如何改动代码呢?
主要的改动有下面四处。

  • 第一处改动是:在 ApiStatInfo 类中添加新的属性 timeoutCount。
  • 第二处改动是:添加新的TimeoutAlertHander类。
  • 第三处改动是:在 ApplicationContext 类的 initializeBeans() 方法中,往alert对象中注册新的 timeoutAlertHandler。
  • 第四处改动是:在使用Alert类的时候,需要给check()函数的入参 apiStatInfo 对象设置 timeoutCount 的值。
public class Alert { // 代码未改动... }

public class ApiStatInfo {//省略constructor/getter/setter方法
    private String api;
    private long requestCount;
    private long errorCount;
    private long durationOfSeconds;
    private long timeoutCount; // 改动一:添加新字段
}
public abstract class AlertHandler { //代码未改动... }
public class TpsAlertHandler extends AlertHandler {//代码未改动...}
public class ErrorAlertHandler extends AlertHandler {//代码未改动...}

// 改动二:添加新的handler
public class TimeoutAlertHandler extends AlertHandler {//省略代码...}

public class ApplicationContext {
    private AlertRule alertRule;
    private Notification notification;
    private Alert alert;

    public void initializeBeans() {
        alertRule = new AlertRule(/*.省略参数.*/); //省略一些初始化代码
        notification = new Notification(/*.省略参数.*/); //省略一些初始化代码
        alert = new Alert();
        alert.addAlertHandler(new TpsAlertHandler(alertRule, notification));
        alert.addAlertHandler(new ErrorAlertHandler(alertRule, notification));
        // 改动三:注册handler
        alert.addAlertHandler(new TimeoutAlertHandler(alertRule, notification));
    }
    //...省略其他未改动代码...
}

public class Demo {
    public static void main(String[] args) {
        ApiStatInfo apiStatInfo = new ApiStatInfo();
        // ...省略apiStatInfo的set字段代码
        apiStatInfo.setTimeoutCount(289); // 改动四:设置tiemoutCount值
        ApplicationContext.getInstance().getAlert().check(apiStatInfo);
    }
}

重构之后的代码更加灵活和易扩展如果我们要想添加新的告警逻辑,只需要基于扩展的方式创建新的 handler 类即可,不需要改动原来的 check() 函数的逻辑。而且,我们只需要为新的 handler 类添加单元测试,老的单元测试都不会失败,也不用修改。

Action1:修改代码就意味着违背开闭原则吗?

  • 我们要认识到,添加一个新功能,不可能任何模块、类、方法的代码都不“修改”,这个是做不到的。类需要创建、组装、并且做一些初始化操作,才能构建成可运行的的程序,这部分代码的修改是在所难免的。我们要做的是尽量让修改操作更集中、更少、更上层,尽量让最核心、最复杂的那部分逻辑代码满足开闭原则

Action2:如何在开发中做到“对扩展开放、对修改关闭” ?

指导思想:为了尽量写出扩展性好的代码,我们要时刻具备扩展意识、抽象意识、封装意识。这些“潜意识”可能比任何开发技巧都重要。

在写代码的时候后,我们要多花点时间往前多思考一下,这段代码未来可能有哪些需求变更、如何设计代码结构,事先留好扩展点,以便在未来需求变更的时候,不需要改动代码整体结构、做到最小代码改动的情况下,新的代码能够很灵活地插入到扩展点上,做到“对扩展开放、对修改关闭”。

在众多的设计原则、思想、模式中,最常用来提高代码扩展性的方法有:多态、依赖注入、基于接口而非实现编程,以及大部分的设计模式(比如,装饰、策略、模板、职责链、状态等)

Action3:如何在项目中灵活应用开闭原则?

对于一些比较确定的、短期内可能就会扩展,或者需求改动对代码结构影响比较大的情况,或者实现成本不高的扩展点,在编写代码的时候之后,我们就可以事先做些扩展性设计。但对于一些不确定未来是否要支持的需求,或者实现起来比较复杂的扩展点,我们可以等到有需求驱动的时候,再通过重构代码的方式来支持扩展的需求


5、如何做到“高内聚低耦合”?

什么是高内聚?

  • 高内聚,就是指相近的功能应该放到同一个类中,不相近的功能不要放到同一个类中。相近的功能往往会被同时修改,放到同一个类中,修改会比较集中,代码容易维护。
    • 单一职责原则

什么是低耦合?

  • 在代码中,类与类之间的依赖关系简单清晰。即使两个类有依赖关系,一个类的代码改动不会或者很少导致依赖类的代码改动;
  • 依赖注入、接口隔离、基于接口而非实现编程,迪米特法则,都是为了实现代码的松耦合。

不该有直接依赖关系的类之间,不要有依赖;有依赖关系的类之间,尽量只依赖必要的接口

案例:实现了简化版的搜索引擎爬取网页的功能。代码中包含三个主要的类。其中

  • NetworkTransporter 类负责底层网络通信,根据请求获取数据;
  • HtmlDownloader 类用来通过URL获取网页;
  • Document 表示网页文档,后续的网页内容抽取、分词、索引都是以此为处理对象。
public class NetworkTransporter {
	// 省略属性和其他方法...
	public Byte[] send(HtmlRequest htmlRequest) {
		//...
	}
} 
public class HtmlDownloader {
	private NetworkTransporter transporter;// 通过构造函数或 IOC 注入
	public Html downloadHtml(String url) {
		Byte[] rawHtml = transporter.send(new HtmlRequest(url));
		return new Html(rawHtml);
	}
} 
public class Document {
	private Html html;
	private String url;
	public Document(String url) {
		this.url = url;
		HtmlDownloader downloader = new HtmlDownloader();
		this.html = downloader.downloadHtml(url);
	}
	//...
}

缺陷有哪些?
1、首先,我们来看NetworkTransporter类。作为一个底层网络通信类,我们希望它的功能
尽可能通用,而不只是服务于下载 HTML,所以,我们不应该直接依赖太具体的发送对象
HtmlRequest;

  • 我们应该把 address 和content 交给 NetworkTransporter,而非是直接把 HtmlRequest 交给NetworkTransporter, 代码入下所示
public class NetworkTransporter {
	// 省略属性和其他方法...
	public Byte[] send(String address, Byte[] data) {
		//...
	}
}

2、我们再来看 HtmlDownloader 类。这个类的设计没有问题。不过,我们修改了NetworkTransporter的send()函数的定义,而这个类用到了send()函数,所以我们需要对它做相应的修改;

public class HtmlDownloader {
	private NetworkTransporter transporter;// 通过构造函数或IOC注入
	// HtmlDownloader 这里也要有相应的修改
	public Html downloadHtml(String url) {
		HtmlRequest htmlRequest = new HtmlRequest(url);
		Byte[] rawHtml = transporter.send(htmlRequest.getAddress(), htmlRequest.getContent().getBytes());
		return new Html(rawHtml);
	}
}

3、我们来看下 Document 类。问题主要有三点。

  • 第一,构造函数中的 downloader.downloadHtml() 逻辑复杂,耗时长,不应该放到构造函数中,会影响代码的可测试性。
  • 第二,HtmlDownloader 对象在构造函数中通过 new 来创建,违反了基于接口而非实现编程的设计思想,也会影响到代码的可测试性。
  • 第三,从业务含义上来讲,Document 网页文档没必要依赖 HtmlDownloader 类,违背了迪米特法则
public class Document {
	private Html html;
	private String url;
	public Document(String url, Html html) {
		this.html = html;
		this.url = url;
	}
	//...
}
// 通过一个工厂方法来创建 Document
public class DocumentFactory {
	private HtmlDownloader downloader;
	public DocumentFactory(HtmlDownloader downloader) {
		this.downloader = downloader;
	} 
	public Document createDocument(String url) {
		Html html = downloader.downloadHtml(url);
		return new Document(url, html);
	}
}

6、为何说要多用组合少用继承?如何决定该用组合还是继承?

背景:合成复用原则的设计初衷是:尽量使用组合的方式,而不是使用继承 来达到复用的原则

原因:继承会让两个类的耦合性增强,即组合比继承具有更高的灵活性

案例

假设我们要设计一个关于鸟的类。我们将“鸟类”这样一个抽象的事物概念,定义为一个抽 象类 AbstractBird。所有更细分的鸟,比如麻雀、鸽子、乌鸦等,都继承这个抽象类。

然后我们提供一个 fly() 方法,让子类实现。尽管大部分鸟都会飞,但也有特例,比如鸵鸟就不会飞。鸵鸟继承具有 fly() 方法的父类,那鸵鸟就具有“飞”这样的行为,这显然不符合我们对现实世界中事物的认识。那么我在鸵鸟这个子类中重写 fly() 方法,让它抛出 UnSupportedMethodException 异常不就可以了吗?具体的代码实现如下所示

public class AbstractBird {
    //... 省略其他属性和方法...
    public void fly() { 
      	//... 
    }
}

// 鸵鸟
public class Ostrich extends AbstractBird { 
    //... 省略其他属性和方法...
    public void fly() {
    		throw new UnSupportedMethodException("I can't fly.'");
    }
}

这种设计思路虽然可以解决问题,但不够优美。因为除了鸵鸟之外,不会飞的鸟还有很多, 比如企鹅。对于这些不会飞的鸟来说,我们都需要重写 fly() 方法,抛出异常。这样的设计,一方面,徒增了编码的工作量;另一方面,也违背了迪米特法则,暴露不该暴露的接口给外部,增加了类使用过程中被误用的概率。

方法:那我们再通过 AbstractBird 类派生出两个更加细分的抽象类:

  • 会飞的鸟类 AbstractFlyableBird 和不会飞的鸟类 AbstractUnFlyableBird,
    • 让麻雀、乌鸦这些会飞的 鸟都继承 AbstractFlyableBird,
    • 让鸵鸟、企鹅这些不会飞的鸟,都继承 AbstractUnFlyableBird 类

在这里插入图片描述

如果我们还关注“鸟会不会叫”,那这个时候,我们又该如何设计类之间的继承关系呢?

在这里插入图片描述

如果我们还需要考虑“是否会下蛋”这样一个行为,那估计就要组合爆炸了。

继承最大的问题就在于:继承层次过深或者继承关系过于复杂会影响到代码的可读性和可维护性。

怎么解决呢?

  • 我们使用组合(composition)、接口、委托(delegation)三个技术手段,一块儿来解决刚刚继承存在的问题。

  • 针对“会飞”这样一个行为特 性,我们可以定义一个 Flyable 接口,只让会飞的鸟去实现这个接口。对于会叫、会下蛋这 些行为特性,我们可以类似地定义 Tweetable 接口、EggLayable 接口

public interface Flyable {
		void fly();
}
public interface Tweetable {
		void tweet();
}
public interface EggLayable {
		void layEgg();
}
// 鸵鸟
public class Ostrich implements Tweetable, EggLayable {
    //... 省略其他属性和方法...
    @Override
    public void tweet() { 
      	//... 
    }
    @Override
    public void layEgg() { 
      	//... 
    }
}
      
// 麻雀
public class Sparrow implements Flayable, Tweetable, EggLayable {
    //... 省略其他属性和方法...
    @Override
    public void fly() { 
      	//... 
    }
    @Override
    public void tweet() { 
      	//... 
    }
    @Override
    public void layEgg() { 
      	//... 
    }
}

代码重复的问题该怎么解决呢?

  • 我们可以针对三个接口再定义三个实现类,它们分别是:实现了 fly() 方法的 FlyAbility 类、实现了 tweet() 方法的 TweetAbility 类、实现了 layEgg() 方法的 EggLayAbility 类。 然后,通过组合和委托技术来消除代码重复
public interface Flyable {
		void fly()}

public class FlyAbility implements Flyable {
    @Override
    public void fly() { 
      //... 
    }
}
// 省略 Tweetable/TweetAbility/EggLayable/EggLayAbility

// 鸵鸟
public class Ostrich implements Tweetable, EggLayable {
    private TweetAbility tweetAbility = new TweetAbility(); // 组合
    private EggLayAbility eggLayAbility = new EggLayAbility(); // 组合
    //... 省略其他属性和方法...
    @Override
    public void tweet() {
    		tweetAbility.tweet(); // 委托
    }
    @Override
    public void layEgg() {
    		eggLayAbility.layEgg(); // 委托
    }
}

Aciton1:如何判断该用组合还是继承?

尽管我们鼓励多用组合少用继承,但组合也并不是完美的,继承也并非一无是处。在实际的项目开发中,我们还是要根据具体的情况,来选择该用继承还是组合。如果类之间的继承结构稳定,层次比较浅,关系不复杂,我们就可以大胆地使用继承。反之,我们就尽量使用组合来替代继承。除此之外,还有一些设计模式、特殊的应用场景,会固定使用继承或者组合。

7、DDD(即领域驱动设计),充血模式和贫血模式深度对比

  • 越抽象、越顶层、越脱离具体某一实现的设计,越能提高代码的灵活性,越能应对未来的需求变化。好的代码设计,不仅能应对当下的需求,而且在将来需求发生变化的时候,仍然能够在不破坏原有代码设计的情况下灵活应对。

问题背景–什么是基于贫血模型的开发模式

  • 传统的 MVC 结构分为 Model 层、Controller 层、View 层这三层。不过,在做前后端分离之后,三层结构在后端开发中,会稍微有些调整,被分为 Controller 层、Service 层、Repository 层。Controller 层负责暴露接口给前端调用,Service 层负责核心业务逻辑,Repository 层负责数据读写。而在每一层中,我们又会定义相应的 VO(View Object)、BO(Business Object)、Entity。一般情况下,VO、BO、Entity 中只会定义数据,不会定义方法,所有操作这些数据的业务逻辑都定义在对应的 Controller 类、Service 类、Repository 类中。这就是典型的面向过程的编程风格。

传统编程模型案例:

// Controller+VO(View Object) //
public class UserController {
	private UserService userService; // 通过构造函数或者 IOC 框架注入
	public UserVo getUserById(Long userId) {
		UserBo userBo = userService.getUserById(userId);
		UserVo userVo = [...convert userBo to userVo...];
		return userVo;
	}
}
public class UserVo {// 省略其他属性、get/set/construct 方法
	private Long id;
	private String name;
	private String cellphone;
}
 
// Service+BO(Business Object) //
public class UserService {
	private UserRepository userRepository; // 通过构造函数或者 IOC 框架注入
	public UserBo getUserById(Long userId) {
		UserEntity userEntity = userRepository.getUserById(userId);
		UserBo userBo = [...convert userEntity to userBo...];
		return userBo;
	}
} 
public class UserBo {// 省略其他属性、get/set/construct 方法
	private Long id;
	private String name;
	private String cellphone;
} 

// Repository+Entity //
public class UserRepository {
	public UserEntity getUserById(Long userId) { //... }
} 
public class UserEntity {// 省略其他属性、get/set/construct 方法
	private Long id;
	private String name;
	private String cellphone;
}

像 UserBo 这样,只包含数据,不包含业务逻辑的类,就叫作贫血模型(Anemic Domain Model)。同理,UserEntity、UserVo 都是基于贫血模型设计的。

什么是基于充血模型的 DDD 开发模式?

  • 充血模型(Rich Domain Model),数据和对应的业务逻辑被封装到同一个类中
  • 在基于贫血模型的传统开发模式中,Service 层包含 Service 类和 BO 类两部分,BO 是贫血模型,只包含数据,不包含具体的业务逻辑。业务逻辑集中在 Service 类中。在基于充血模型的 DDD 开发模式中,Service 层包含 Service 类和 Domain 类两部分。Domain就相当于贫血模型中的 BO。不过,Domain 与 BO 的区别在于它是基于充血模型开发的,既包含数据,也包含业务逻辑。而 Service 类变得非常单薄。总结就是,基于贫血模型的传统的开发模式,重 Service 轻 BO;基于充血模型的 DDD 开发模式,轻 Service 重Domain

示例代码:

@Data
public class VirtualWallet {
	private Long id;
	private Long createTime = System.currentTimeMillis();
	//余额
	private BigDecimal balance = BigDecimal.ZERO; 
	// 是否允许超支
	private boolean isAllowedOverdraft = true;
 	// 超支金额
	private BigDecimal overdraftAmount = BigDecimal.ZERO;
	// 冻结总额
	private BigDecimal frozenAmount = BigDecimal.ZERO;
	public VirtualWallet(Long preAllocatedId) {
		this.id = preAllocatedId;
	} 
	public void freeze(BigDecimal amount) { ... }
	public void unfreeze(BigDecimal amount) { ...}
	public void increaseOverdraftAmount(BigDecimal amount) { ... }
	public void decreaseOverdraftAmount(BigDecimal amount) { ... }
	public void closeOverdraft() { ... }
	public void openOverdraft() { ... }
	public BigDecimal balance() {
		return this.balance;
	} 
	
	// 获取余额
	public BigDecimal getAvaliableBalance() {
		BigDecimal totalAvaliableBalance = this.balance.subtract(this.frozenAmount)
		if (isAllowedOverdraft) {
			totalAvaliableBalance += this.overdraftAmount;
		}
		return totalAvaliableBalance;
	} 
	
	// 借记
	public void debit(BigDecimal amount) {
		BigDecimal totalAvaliableBalance = getAvaliableBalance();
		if (totoalAvaliableBalance.compareTo(amount) < 0) {
			throw new InsufficientBalanceException(...);
		}
		this.balance.subtract(amount);
	} 
	
	// 信贷 
	public void credit(BigDecimal amount) {
		if (amount.compareTo(BigDecimal.ZERO) < 0) {
			throw new InvalidAmountException(...);
		}
		this.balance.add(amount);
	}
}

为什么基于贫血模型的传统开发模式如此受欢迎?

  • 第一点原因是,大部分情况下,我们开发的系统业务可能都比较简单,简单到就是基于SQL 的 CRUD 操作,所以,我们根本不需要动脑子精心设计充血模型
  • 第二点原因是,充血模型的设计要比贫血模型更加有难度,我们从一开始就要设计好针对数据要暴露哪些操作,定义哪些业务逻辑;
  • 第三点原因是思维已固化,转型有成本。基于贫血模型的传统开发模式经历了这么多年,已经深得人心、习以为常。

我对充血模型的看法就是:

  • 1、它可以把原来最重的service逻辑拆分并且转移一部分逻辑,可以使得代码可读性略微提高;
  • 2、模型充血以后,基于模型的业务抽象在不断的迭代之后会越来越明确,业务的细节会越来越精准,通过阅读模型的充血行为代码,能够极快的了解系统的业务,对于开发来说能说明显的提升开发效率

亮点1:将充血模型用在类目属性代码中,业务不停在拓展,如何高效兼容现有业务
问题1:值不值得变为充血模型。
demo如下所示:

问题2:后续拓展的需求,如何在DDD上补充代码呢?

DDD辩证思考与灵活应用

Action1:在基于充血模型的 DDD 开发模式中,将业务逻辑移动到Domain 中,Service类变得很薄,但在我们的代码设计与实现中,并没有完全将Service 类去掉,这是为什么?或者说,Service 类在这种情况下担当的职责是什么?哪些功能逻辑会放到 Service 类中?

Service 类主要有下面这样几个职责

1.Service 类负责与 Repository 交流。

  • 不是让领域模型 xxx 与 Repository 打交道,那是因为我们想保持领域模型的独立性,不与任何其他层的代码(Repository 层的代码)或开发框架(比如 Spring、MyBatis)耦合在一起,将流程性的代码逻辑(比如从 DB 中取数据、映射数据)与领域模型的业务逻辑解耦,让领域模型更加可复用;

2、Service 类负责跨领域模型的业务聚合功能

3、Service 类负责一些非功能性及与三方系统交互的工作。比如幂等、事务、发邮件、发消息、记录日志、调用其他系统的 RPC 接口等,都可以放到 Service 类中。

Action2:在基于充血模型的 DDD 开发模式中,尽管 Service 层被改造成了充血模型,但是 Controller 层和 Repository 层还是贫血模型,是否有必要也进行充血领域建模呢?

答案是没有必要。Controller 层主要负责接口的暴露,Repository 层主要负责与数据库打交道,这两层包含的业务逻辑并不多,如果业务逻辑比较简单,就没必要做充血建模,即便设计成充血模型,类也非常单薄,看起来也很奇怪。

对于Repository 层Entity,一般来讲,我们把它传递到 Service 层之后,就会转化成 BO 或者 Domain 来继续后面的业务逻辑。Entity 的生命周期到此就结束了,所以也并不会被到处任意修改;

对于Controller 层VO,它主要是作为接口的数据传输承载体,将数据发送给其他系统。从功能上来讲,它理应不包含业务逻辑、只包含数据。


8、如何应对庞大而复杂的项目开发?

从设计原则和思想的角度来看

1、分层与模块化

  • 把容易复用、跟具体业务关系不大的代码,尽量下沉到下层,把容易变动、跟具体业务强相关的代码,尽量上移到上层。

2、基于接口通信

  • 在设计模块(module)或者层(layer)要暴露的接口时,要学会隐藏实现,接口从命名到定义都要抽象一些,尽量少涉及具体的实现细节

3、高内聚、松耦合

  • 一个比较通用的设计思想,在修改或者阅读代码时,聚集到在一个小范围的模块或者类中,不需要了解太多其他模块或类的代码

4、为扩展而设计

  • 提前思考项目中未来可能会有哪些功能需要扩展,提前预留好扩展点;
  • 识别出代码可变部分和不可变部分,将可变部分封装起来,隔离变化,提供抽象化的不可变接口,供上层系统使用。

5、KISS(Keep it Simple, Stupid) 首要原则

  • 简单清晰、可读性好,是任何大型软件开发要遵循的首要原则。如果你对现有代码的逻辑似懂非懂,抱着尝试的心态去修改代码,引入 bug 的可能性就会很大

6、最小惊奇原则

  • 在做设计或者编码的时候要 遵守统一的开发规范,避免反直觉的设计

从研发管理和开发技巧的角度来看

1、 吹毛求疵般地执行编码规范

  • 严格执行代码规范 推荐这两本书 《重构 - 改善既有代码的设计》 《编写可读代码的艺术》
  • 重视细节

2、编写高质量的单元测试

  • 单元测试是最容易执行且对提高代码质量见效最快的方法之一

3、不流于形式的 Code Review

4、编写技术文档

5、持续重构、重构、重构

  • 持续的小重构,它是保证代码质量、防止代码腐化的有效手段

6、对项目与团队进行拆分

  • 代码可以拆分,比如模块化、分层等。
  • 大团队也可以拆成几个小团队,每个小团队对应负责一个小的项目(模块、微服务等)

通过 Code Reviwe 保持项目的代码质量
1、为什么要进行 Code Review?Code Review 的价值?☆

  • 三人行必有我师
  • 能有效提高代码可读性
  • 是技术传帮带的有效途径
  • 保证代码不止一个人熟悉
  • 能打造良好的技术氛围
    • 能增进技术交流,活跃技术氛围,以及对代码质量的追求
  • 能提高团队的自律性

2、如何在团队中落地执行 Code Review?

  • 合理安排时间,切勿工期紧,今天改的代码,明天就要上;
  • 明确告诉 Code Review 的重要性,要严格执行
    • 可以跟 Google 一样,将 Code Review 间接地跟 KPI、升职等联系在一块,高级工程师有义务做 Code Review,就像有义务做技术面试一样。

3、Code Review的重点?

  1. 确认代码功能:代码实现的功能满足产品需求,逻辑的严谨和合理性是最基本的要求。同时需要考虑适当的扩展性,在代码的可扩展性和过度设计做出权衡,不编写无用逻辑和一些与代码功能无关的附加代码。

  2. 编码规范:以集团开发规约、静态代码规约为前提,是否遵守了编码规范,遵循了最佳实践。除了形式上的要求外,更重要的是命名规范。目标是提高代码的可读性,降低代码可维护性成本。

  3. 潜在的BUG:可能在最坏情况下出现问题的代码,包括常见的线程安全、业务逻辑准确性、系统边界范围、参数校验,以及存在安全漏洞(业务鉴权、灰产可利用漏洞)的代码

  4. 文档和注释:过少(缺少必要信息)、过多(没有信息量)、过时的文档或注释,总之文档和注释要与时俱进,与最新代码保持同步。其实很多时候个人觉得良好的变量、函数命名是最好的注释,好的代码胜过注释。

  5. 重复代码:当一个项目在不断开发迭代、功能累加的过程中,重复代码的出现几乎是 不可避免的,通常可以通过PMD工具进行检测。类型体系之外的重复代码处理通常可以封装到对应的Util类或者Helper类中,类体系之内的重复代码通常可以通过继承、模板模式等方法来解决

  6. 复杂度:代码结构太复杂(如圈复杂度高),难以理解、测试和维护。

  7. 监控与报警:基于产品的需求逻辑,需要有些指标来证明业务是正常工作的,如果发生异常需要有监控、报警指标通知研发人员处理,review业务需求对应的监控与报警指标也是Code Review的重点事项。

  8. 测试覆盖率:编写单元测试,特别是针对复杂代码的测试覆盖是否足够。

Action1:存储方案和底层数据结构的设计获得评审一致通过,并沉淀成为文档。

说明: 有缺陷的底层数据结构容易导致系统风险上升, 可扩展性下降, 重构成本也会因历史数据迁移和系统平滑过渡而
陡然增加, 所以, 存储方案和数据结构需要认真地进行设计和评审, 生产环境提交执行后, 需要进行 double check。
正例: 评审内容包括存储介质选型、 表结构设计能否满足技术方案、 存取性能和存储空间能否满足业务发展、 表或字段
之间的辩证关系、 字段名称、 字段类型、 索引等;数据结构变更(如在原有表中新增字段)也需要在评审通过后上线

Action2:在需求分析阶段,如果与系统交互的 User 超过一类并且相关的 UserCase 超过 5 个,使用用例图来表达更加清晰的结构化需求。

Action3:如果某个业务对象的状态超过 3 个,使用状态图来表达并且明确状态变化的各个触发条件。

说明:状态图的核心是对象状态,首先明确对象有多少种状态,然后明确两两状态之间是否存在直接转换关系,再明确触发状态转换的条件是什么。
正例:淘宝订单状态有已下单、待付款、已付款、待发货、已发货、已收货等。比如已下单与已收货这两种状态之间是不可能有直接转换关系的。

Action4:如果系统中某个功能的调用链路上的涉及对象超过 3 个,使用时序图来表达并且明确各调用环节的输入与输出。

说明:时序图反映了一系列对象间的交互与协作关系,清晰立体地反映系统的调用纵深链路。

Action5:如果系统中模型类超过 5 个,且存在复杂的依赖关系,使用类图来表达并且明确类之间的关系。

说明:类图像建筑领域的施工图,如果搭平房,可能不需要,但如果建造蚂蚁 Z 空间大楼,肯定需要详细的施工图。

Action6:如果系统中超过 2 个对象之间存在协作关系,并需要表示复杂的处理流程,使用活动图来表示。

说明:活动图是流程图的扩展,增加了能够体现协作关系的对象泳道,支持表示并发等。

Action7:系统设计时要准确识别出弱依赖,并针对性地设计降级和应急预案,保证核心系统正常可用。

说明:系统依赖的第三方服务被降级或屏蔽后,依然不会影响主干流程继续进行,仅影响信息展示、或消息通知等非关键功能,那么这些服务称为弱依赖。
正例:当系统弱依赖于多个外部服务时,如果下游服务耗时过长,则会严重影响当前调用者,必须采取相应降级措施,比如,当调用链路中某个下游服务调用的平均响应时间或错误率超过阈值时,系统自动进行降级或熔断操作,屏蔽弱依赖负面影响,保护当前系统主干功能可用。
反例:某个疫情相关的二维码出错: “服务器开了点小差,请稍后重试” ,不可用时长持续很久,引起社会高度关注,原因可能为调用的外部依赖服务 RT 过高而导致系统假死,而在显示端没有做降级预案,只能直接抛错给用户

Action8:系统架构设计时明确以下目标:

  • 确定系统边界。 确定系统在技术层面上的做与不做。
  • 确定系统内模块之间的关系。 确定模块之间的依赖关系及模块的宏观输入与输出。
  • 确定指导后续设计与演化的原则。 使后续的子系统或模块设计在一个既定的框架内和技术方向上继续演化。
  • 确定非功能性需求。 非功能性需求是指安全性、 可用性、 可扩展性等。

Action9:需求分析与系统设计在考虑主干功能的同时, 需要充分评估异常流程与业务边界。

Action10:类在设计与实现时要符合单一原则。

说明:单一原则最易理解却是最难实现的一条规则,随着系统演进,很多时候,忘记了类设计的初衷

Action11:谨慎使用继承的方式来进行扩展,优先使用聚合/组合的方式来实现。

说明:不得已使用继承的话,必须符合里氏代换原则,此原则说父类能够出现的地方子类一定能够出现,比如,“把钱交出来” , 钱的子类美元、欧元、人民币等都可以出现。

Action12:系统设计阶段,根据依赖倒置原则,尽量依赖抽象类与接口,有利于扩展与维护。

说明:低层次模块依赖于高层次模块的抽象,方便系统间的解耦。

Action13:系统设计阶段,注意对扩展开放,对修改闭合。

说明:极端情况下,交付的代码是不可修改的,同一业务域内的需求变化,通过模块或类的扩展来实现

Action14:系统设计阶段,共性业务或公共行为抽取出来公共模块、公共配置、公共类、 公共方法等,在系统中不出现重复代码的情况,即 DRY 原则(Don’t Repeat Yourself) 。

说明:随着代码的重复次数不断增加,维护成本指数级上升。随意复制和粘贴代码,必然会导致代码的重复,在维护代码时,需要修改所有的副本,容易遗漏。必要时抽取共性方法,或者抽象公共类,甚至是组件化。
正例:一个类中有多个 public 方法,都需要进行数行相同的参数校验操作,这个时候请抽取:private boolean checkParam(DTO dto) {...}

Action15:避免如下误解:敏捷开发=讲故事+编码+发布。

说明:敏捷开发是快速交付迭代可用的系统,省略多余的设计方案,摒弃传统的审批流程,但核心关键点上的必要设计和文档沉淀是需要的。
反例:某团队为了业务快速发展,敏捷成了产品经理催进度的借口,系统中均是勉强能运行但像面条一样的代码,可维护性和可扩展性极差,一年之后,不得不进行大规模重构,得不偿失

Action16:设计文档的作用是明确需求、理顺逻辑、后期维护,次要目的用于指导编码。

说明:避免为了设计而设计,系统设计文档有助于后期的系统维护和重构,所以设计结果需要进行分类归档保存。

Action17:可扩展性的本质是找到系统的变化点,并隔离变化点。

说明:世间众多设计模式其实就是一种设计模式:即隔离变化点的模式
正例:极致扩展性的标志,就是需求的新增,不会在原有代码交付物上进行任何形式的修改

Action18:设计的本质就是识别和表达系统难点。

说明:识别和表达完全是两回事,很多人错误地认为识别到系统难点在哪里,表达只是自然而然的事情,但是大家在设计评审中经常出现语焉不详,甚至是词不达意的情况。准确地表达系统难点需要具备如下能力:表达规则和表达工具的熟练性。抽象思维和总结能力的局限性。基础知识体系的完备性。深入浅出的生动表达力。

Action19:代码即文档的观点是错误的,清晰的代码只是文档的某个片断,而不是全部。

说明:代码的深度调用,模块层面上的依赖关系网,业务场景逻辑,非功能性需求等问题要相应的文档来完整地呈现。

Action20:在做无障碍产品设计时,需要考虑到:

  • 所有可交互的控件元素必须能被 tab 键聚焦,并且焦点顺序需符合自然操作逻辑。
  • 用于登录校验和请求拦截的验证码均需提供图形验证以外的其它方式。
  • 自定义的控件类型需明确交互方式。

正例:登录场景中,输入框的按钮都需要考虑 tab 键聚焦,符合自然逻辑的操作顺序如下,“输入用户名,输入密码,输入验证码,点击登录”,其中验证码实现语音验证方式。如有自定义标签实现的控件设置控件类型可使用 role 属性。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

程序员 jet_qi

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值