极客时间已完结课程限时免费阅读

49 | 桥接模式:如何实现支持不同类型和渠道的消息推送系统?

49 | 桥接模式:如何实现支持不同类型和渠道的消息推送系统?-极客时间

49 | 桥接模式:如何实现支持不同类型和渠道的消息推送系统?

讲述:冯永吉

时长09:18大小7.44M

上一节课我们学习了第一种结构型模式:代理模式。它在不改变原始类(或者叫被代理类)代码的情况下,通过引入代理类来给原始类附加功能。代理模式在平时的开发经常被用到,常用在业务系统中开发一些非功能性需求,比如:监控、统计、鉴权、限流、事务、幂等、日志。
今天,我们再学习另外一种结构型模式:桥接模式。桥接模式的代码实现非常简单,但是理解起来稍微有点难度,并且应用场景也比较局限,所以,相对于代理模式来说,桥接模式在实际的项目中并没有那么常用,你只需要简单了解,见到能认识就可以,并不是我们学习的重点。
话不多说,让我们正式开始今天的学习吧!

桥接模式的原理解析

桥接模式,也叫作桥梁模式,英文是 Bridge Design Pattern。这个模式可以说是 23 种设计模式中最难理解的模式之一了。我查阅了比较多的书籍和资料之后发现,对于这个模式有两种不同的理解方式。
当然,这其中“最纯正”的理解方式,当属 GoF 的《设计模式》一书中对桥接模式的定义。毕竟,这 23 种经典的设计模式,最初就是由这本书总结出来的。在 GoF 的《设计模式》一书中,桥接模式是这么定义的:“Decouple an abstraction from its implementation so that the two can vary independently。”翻译成中文就是:“将抽象和实现解耦,让它们可以独立变化。”
关于桥接模式,很多书籍、资料中,还有另外一种理解方式:“一个类存在两个(或多个)独立变化的维度,我们通过组合的方式,让这两个(或多个)维度可以独立进行扩展。”通过组合关系来替代继承关系,避免继承层次的指数级爆炸。这种理解方式非常类似于,我们之前讲过的“组合优于继承”设计原则,所以,这里我就不多解释了。我们重点看下 GoF 的理解方式。
GoF 给出的定义非常的简短,单凭这一句话,估计没几个人能看懂是什么意思。所以,我们通过 JDBC 驱动的例子来解释一下。JDBC 驱动是桥接模式的经典应用。我们先来看一下,如何利用 JDBC 驱动来查询数据库。具体的代码如下所示:
Class.forName("com.mysql.jdbc.Driver");//加载及注册JDBC驱动程序
String url = "jdbc:mysql://localhost:3306/sample_db?user=root&password=your_password";
Connection con = DriverManager.getConnection(url);
Statement stmt = con.createStatement();
String query = "select * from test";
ResultSet rs=stmt.executeQuery(query);
while(rs.next()) {
rs.getString(1);
rs.getInt(2);
}
如果我们想要把 MySQL 数据库换成 Oracle 数据库,只需要把第一行代码中的 com.mysql.jdbc.Driver 换成 oracle.jdbc.driver.OracleDriver 就可以了。当然,也有更灵活的实现方式,我们可以把需要加载的 Driver 类写到配置文件中,当程序启动的时候,自动从配置文件中加载,这样在切换数据库的时候,我们都不需要修改代码,只需要修改配置文件就可以了。
不管是改代码还是改配置,在项目中,从一个数据库切换到另一种数据库,都只需要改动很少的代码,或者完全不需要改动代码,那如此优雅的数据库切换是如何实现的呢?
源码之下无秘密。要弄清楚这个问题,我们先从 com.mysql.jdbc.Driver 这个类的代码看起。我摘抄了部分相关代码,放到了这里,你可以看一下。
package com.mysql.jdbc;
import java.sql.SQLException;
public class Driver extends NonRegisteringDriver implements java.sql.Driver {
static {
try {
java.sql.DriverManager.registerDriver(new Driver());
} catch (SQLException E) {
throw new RuntimeException("Can't register driver!");
}
}
/**
* Construct a new driver and register it with DriverManager
* @throws SQLException if a database error occurs.
*/
public Driver() throws SQLException {
// Required for Class.forName().newInstance()
}
}
结合 com.mysql.jdbc.Driver 的代码实现,我们可以发现,当执行 Class.forName(“com.mysql.jdbc.Driver”) 这条语句的时候,实际上是做了两件事情。第一件事情是要求 JVM 查找并加载指定的 Driver 类,第二件事情是执行该类的静态代码,也就是将 MySQL Driver 注册到 DriverManager 类中。
现在,我们再来看一下,DriverManager 类是干什么用的。具体的代码如下所示。当我们把具体的 Driver 实现类(比如,com.mysql.jdbc.Driver)注册到 DriverManager 之后,后续所有对 JDBC 接口的调用,都会委派到对具体的 Driver 实现类来执行。而 Driver 实现类都实现了相同的接口(java.sql.Driver ),这也是可以灵活切换 Driver 的原因。
public class DriverManager {
private final static CopyOnWriteArrayList<DriverInfo> registeredDrivers = new CopyOnWriteArrayList<DriverInfo>();
//...
static {
loadInitialDrivers();
println("JDBC DriverManager initialized");
}
//...
public static synchronized void registerDriver(java.sql.Driver driver) throws SQLException {
if (driver != null) {
registeredDrivers.addIfAbsent(new DriverInfo(driver));
} else {
throw new NullPointerException();
}
}
public static Connection getConnection(String url, String user, String password) throws SQLException {
java.util.Properties info = new java.util.Properties();
if (user != null) {
info.put("user", user);
}
if (password != null) {
info.put("password", password);
}
return (getConnection(url, info, Reflection.getCallerClass()));
}
//...
}
桥接模式的定义是“将抽象和实现解耦,让它们可以独立变化”。那弄懂定义中“抽象”和“实现”两个概念,就是理解桥接模式的关键。那在 JDBC 这个例子中,什么是“抽象”?什么是“实现”呢?
实际上,JDBC 本身就相当于“抽象”。注意,这里所说的“抽象”,指的并非“抽象类”或“接口”,而是跟具体的数据库无关的、被抽象出来的一套“类库”。具体的 Driver(比如,com.mysql.jdbc.Driver)就相当于“实现”。注意,这里所说的“实现”,也并非指“接口的实现类”,而是跟具体数据库相关的一套“类库”。JDBC 和 Driver 独立开发,通过对象之间的组合关系,组装在一起。JDBC 的所有逻辑操作,最终都委托给 Driver 来执行。
我画了一张图帮助你理解,你可以结合着我刚才的讲解一块看。

桥接模式的应用举例

第 16 节中,我们讲过一个 API 接口监控告警的例子:根据不同的告警规则,触发不同类型的告警。告警支持多种通知渠道,包括:邮件、短信、微信、自动语音电话。通知的紧急程度有多种类型,包括:SEVERE(严重)、URGENCY(紧急)、NORMAL(普通)、TRIVIAL(无关紧要)。不同的紧急程度对应不同的通知渠道。比如,SERVE(严重)级别的消息会通过“自动语音电话”告知相关人员。
在当时的代码实现中,关于发送告警信息那部分代码,我们只给出了粗略的设计,现在我们来一块实现一下。我们先来看最简单、最直接的一种实现方式。代码如下所示:
public enum NotificationEmergencyLevel {
SEVERE, URGENCY, NORMAL, TRIVIAL
}
public class Notification {
private List<String> emailAddresses;
private List<String> telephones;
private List<String> wechatIds;
public Notification() {}
public void setEmailAddress(List<String> emailAddress) {
this.emailAddresses = emailAddress;
}
public void setTelephones(List<String> telephones) {
this.telephones = telephones;
}
public void setWechatIds(List<String> wechatIds) {
this.wechatIds = wechatIds;
}
public void notify(NotificationEmergencyLevel level, String message) {
if (level.equals(NotificationEmergencyLevel.SEVERE)) {
//...自动语音电话
} else if (level.equals(NotificationEmergencyLevel.URGENCY)) {
//...发微信
} else if (level.equals(NotificationEmergencyLevel.NORMAL)) {
//...发邮件
} else if (level.equals(NotificationEmergencyLevel.TRIVIAL)) {
//...发邮件
}
}
}
//在API监控告警的例子中,我们如下方式来使用Notification类:
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, "...");
}
}
}
Notification 类的代码实现有一个最明显的问题,那就是有很多 if-else 分支逻辑。实际上,如果每个分支中的代码都不复杂,后期也没有无限膨胀的可能(增加更多 if-else 分支判断),那这样的设计问题并不大,没必要非得一定要摒弃 if-else 分支逻辑。
不过,Notification 的代码显然不符合这个条件。因为每个 if-else 分支中的代码逻辑都比较复杂,发送通知的所有逻辑都扎堆在 Notification 类中。我们知道,类的代码越多,就越难读懂,越难修改,维护的成本也就越高。很多设计模式都是试图将庞大的类拆分成更细小的类,然后再通过某种更合理的结构组装在一起。
针对 Notification 的代码,我们将不同渠道的发送逻辑剥离出来,形成独立的消息发送类(MsgSender 相关类)。其中,Notification 类相当于抽象,MsgSender 类相当于实现,两者可以独立开发,通过组合关系(也就是桥梁)任意组合在一起。所谓任意组合的意思就是,不同紧急程度的消息和发送渠道之间的对应关系,不是在代码中固定写死的,我们可以动态地去指定(比如,通过读取配置来获取对应关系)。
按照这个设计思路,我们对代码进行重构。重构之后的代码如下所示:
public interface MsgSender {
void send(String message);
}
public class TelephoneMsgSender implements MsgSender {
private List<String> telephones;
public TelephoneMsgSender(List<String> telephones) {
this.telephones = telephones;
}
@Override
public void send(String message) {
//...
}
}
public class EmailMsgSender implements MsgSender {
// 与TelephoneMsgSender代码结构类似,所以省略...
}
public class WechatMsgSender implements MsgSender {
// 与TelephoneMsgSender代码结构类似,所以省略...
}
public abstract class Notification {
protected MsgSender msgSender;
public Notification(MsgSender msgSender) {
this.msgSender = msgSender;
}
public abstract void notify(String message);
}
public class SevereNotification extends Notification {
public SevereNotification(MsgSender msgSender) {
super(msgSender);
}
@Override
public void notify(String message) {
msgSender.send(message);
}
}
public class UrgencyNotification extends Notification {
// 与SevereNotification代码结构类似,所以省略...
}
public class NormalNotification extends Notification {
// 与SevereNotification代码结构类似,所以省略...
}
public class TrivialNotification extends Notification {
// 与SevereNotification代码结构类似,所以省略...
}

重点回顾

好了,今天的内容到此就讲完了。我们一块来总结回顾一下,你需要重点掌握的内容。
总体上来讲,桥接模式的原理比较难理解,但代码实现相对简单。
对于这个模式有两种不同的理解方式。在 GoF 的《设计模式》一书中,桥接模式被定义为:“将抽象和实现解耦,让它们可以独立变化。”在其他资料和书籍中,还有另外一种更加简单的理解方式:“一个类存在两个(或多个)独立变化的维度,我们通过组合的方式,让这两个(或多个)维度可以独立进行扩展。”
对于第一种 GoF 的理解方式,弄懂定义中“抽象”和“实现”两个概念,是理解它的关键。定义中的“抽象”,指的并非“抽象类”或“接口”,而是被抽象出来的一套“类库”,它只包含骨架代码,真正的业务逻辑需要委派给定义中的“实现”来完成。而定义中的“实现”,也并非“接口的实现类”,而是一套独立的“类库”。“抽象”和“实现”独立开发,通过对象之间的组合关系,组装在一起。
对于第二种理解方式,它非常类似我们之前讲过的“组合优于继承”设计原则,通过组合关系来替代继承关系,避免继承层次的指数级爆炸。

课堂讨论

在桥接模式的第二种理解方式的第一段代码实现中,Notification 类中的三个成员变量通过 set 方法来设置,但是这样的代码实现存在一个明显的问题,那就是 emailAddresses、telephones、wechatIds 中的数据有可能在 Notification 类外部被修改,那如何重构代码才能避免这种情况的发生呢?
public class Notification {
private List<String> emailAddresses;
private List<String> telephones;
private List<String> wechatIds;
public Notification() {}
public void setEmailAddress(List<String> emailAddress) {
this.emailAddresses = emailAddress;
}
public void setTelephones(List<String> telephones) {
this.telephones = telephones;
}
public void setWechatIds(List<String> wechatIds) {
this.wechatIds = wechatIds;
}
//...
}
欢迎留言和我分享你的思考和疑惑。如果有收获,也欢迎你把这篇文章分享给你的朋友。
分享给需要的人,Ta购买本课程,你将得29
生成海报并分享

赞 61

提建议

上一篇
48 | 代理模式:代理在RPC、缓存、监控等场景中的应用
下一篇
50 | 装饰器模式:通过剖析Java IO类库源码学习装饰器模式
 写留言

精选留言(161)

  • zhengyu.nie
    2020-04-29
    举个很简单的例子,现在有两个纬度 Car 车 (奔驰、宝马、奥迪等) Transmission 档位类型 (自动挡、手动挡、手自一体等) 按照继承的设计模式,Car是一个Abstract基类,假设有M个车品牌,N个档位一共要写M*N个类去描述所有车和档位的结合。 而当我们使用桥接模式的话,我首先new一个具体的Car(如奔驰),再new一个具体的Transmission(比如自动档)。然后奔驰.set(手动档)就可以了。 那么这种模式只有M+N个类就可以描述所有类型,这就是M*N的继承类爆炸简化成了M+N组合。 public abstract class AbstractCar { protected Transmission gear; public abstract void run(); public void setTransmission(Transmission gear) { this.gear = gear; } } 所以桥接模式解决的应该是继承爆炸问题。 可以看作是两个abstract组合在一起,独立去拓展,在运行之前将两个具体实现组合到一起。 遵循以下原则 ·依赖倒置原则 ·迪米特法则 ·里氏替换原则 ·接口隔离原则 ·单一职责原则 ·开闭原则
    展开

    作者回复: 是我说的第二种理解方式

    共 25 条评论
    202
  • 业余爱好者
    2020-03-04
    桥接看着就像是面向接口编程这一原则的原旨---将实现与抽象分离。让我迷惑的是,让两者独立变化的说法,接口不是应该稳定吗,为什么要变化? 多个纬度独立变化那个解释倒是比较容易理解。文中举的警报的例子很贴切。紧急程度和警报的方式可以是两个不同的纬度。可以有不同的组合方式。这与slf4j这一日志门面的设计有异曲同工之妙。slf4j其中有三个核心概念,logger,appender和encoder。分别指这个日志记录器负责哪个类的日志,日志打印到哪里以及日志打印的格式。三个纬度上可以有不同的实现,使用者可以在每一纬度上定义多个实现,配置文件中将各个纬度的某一个实现组合在一起就ok了。 行文至此,开头的那个问题也有了答案。一句话就是,桥接就是面向接口编程的集大成者。面向接口编程只是说在系统的某一个功能上将接口和实现解藕,而桥接是详细的分析系统功能,将各个独立的纬度都抽象出来,使用时按需组合。
    展开
    共 12 条评论
    201
  • 下雨天
    2020-02-24
    课后题:可以考虑使用建造者模式来重构!参见46讲中 建造者使用场景: 1.构造方法必填属性很多,需要检验 2.类属性之间有依赖关系或者约束条件 3.创建不可变对象(此题刚好符合这种场景)
    展开
    共 11 条评论
    80
  • 蹦哒
    2020-06-13
    老师请问是否可以这样理解:代理模式是一个类与另一个类的组合,桥接模式是一组类和另外一组类的组合

    作者回复: 有点那个意思~👍

    共 2 条评论
    80
  • 松花皮蛋me
    2020-02-24
    这个模式和策略模式的区别是?
    共 14 条评论
    31
  • 忆水寒
    2020-02-24
    参数不多的情况可以在构造函数初始化,如果参数较多 就可以使用建造者模式初始化。
    21
  • 李朝辉
    2020-03-06
    一点思考:如果notification类针对一次告警,需要同时在微信、电话、邮件上发送通知,当前的Notification类定义就没办法满足条件了,可以将组合的MsgSender变成一个list或者set,将不同渠道的sender注册进去,这样,就可以在调用notify的时候,将list或set内的sender,都调用一遍send
    共 3 条评论
    20
  • 攻城拔寨
    2020-02-28
    我觉得桥接模式解释成: 一个类存在不同纬度的变化,可以通过组合的方式,让它们独自扩展。 栗子:白色圆形,白色正方形,黑色圆形,黑色正方形。 抽象成 颜色 跟 形状 两个纬度去搞,就是桥接模式啦。 至于 jdbc 的,我水平有限啊,还是理解不了~
    共 8 条评论
    17
  • 小晏子
    2020-02-24
    “emailAddresses、telephones、wechatIds 中的数据有可能在 Notification 类外部被修改”的原因是对外暴露了修改接口set*,如果不想被修改那么就不要暴露set接口,这样的话初始化这些email,telephone和wechat的工作就放到构造函数里,用构造函数去初始化这些变量,这样初始化之后正常情况下外面没法修改。
    共 1 条评论
    12
  • 冰激凌的眼泪
    2020-02-25
    在桥接模式中,所谓抽象就是要干什么,所谓实现就是怎么去干,但是这俩是没有抽象与实现的意义的。
    共 2 条评论
    12
  • humor
    2020-02-26
    我觉得Notification没有必要再分成三个子类了,直接传入MsgSender子类就可以了吧
    共 4 条评论
    9
  • Ryan24G
    2021-01-08
    每次举得例子都无比难理解,能不能先举个简单有共性的例子让大家知道是怎么回事,然后再加一些实用实战的源码之类的进行拓展?
    共 2 条评论
    7
  • Heaven
    2020-03-10
    对于题目,由于这三个类之间不具有任何的依赖关系,所以没必要去使用我们的建造者模式,有些过度设计,直接使用构造函数就可以了 对于桥接模式,我个人认为,就是一个类中有多个属性,我们可以将这些属性分开来设计,彼此之间不具有关联,这些个属性就可以认为是多个维度,可以说,就是所谓的抽象,而这个类,就是讲这些属性连接起来的桥,这就是桥接模式,也就是真正实现这个类时候,属性需要注入真正的实现类.例如:一个商品可以对应的多种属性,不同属性就是多个维度,这些属性在这个商品中可以是个抽象的概念,但是在扩展的时候,扩展出了实现类,而这个商品负责连接他们,做到了真正意义上的解耦.突然想到了小岛秀夫的死亡搁浅,所谓的桥接型游戏
    展开
    6
  • 2020-02-24
    set 方法里拷贝一份值,而不是直接赋值。
    6
  • 王喜春
    2021-08-06
    Notification和MsgSender的场景很形象,不过还是不过瘾。如能基于这个场景, 先IfElse地爆炸实现、实际工作中评估不全、造成线上事故等问题, 再引申出设计大神的桥接模式救场,再复盘地研究下, 桥接模式究竟是哪些天赋异禀造就了神奇的解耦效果, 就更好了。 不过, 这些内容, 可以留在升级课程中提供一对一的工作坊式教学。 感谢老师
    展开
    4
  • Geek_pillar6699
    2021-03-08
    补充参考: https://refactoringguru.cn/design-patterns/bridge
    4
  • tt
    2020-02-28
    类庞大,在单一职责原则的基础上,是因为 1、非功能需求,如缓存、日志、鉴权等需求引起的,这时使用代理模式,增强原始类或函数的非相关功能,调用代理类,实现类规模的降低。 2、功能类需求,但还要求可扩展性。使用桥接模式,将可扩展的部分委托给实现类。比如JDBC使用桥接模式使得JDBC可扩展、可配置;Notification类使用桥接模式实现不同的消息发送渠道的可扩展。 3、拓展原功能的某一侧面,如缓存。这里侧面就是某个方法,增强这个接口,首先它必须有实现,所以采用继承抽象类而不是实现某个接口的方式。使用装饰器模式,覆盖这个接口,强化某一个侧面。 上述后两点,都是和原功能需求相关,被委托或组合的类都实现或继承自同一个接口或抽象类,这一点将其和一般的组合区分开来,一般的组合不要求被组合的类和原类继承自同一个父类。
    展开
    4
  • 陈尧东
    2020-02-24
    老师,有个疑问,重构后SevereNotification类依赖的都是接口MessageSender,没有依赖具体的实现,哪其它几个XxxNotification实现与其有何区别?
    共 3 条评论
    4
  • Jxin
    2020-02-24
    1.防止引用类型成员变量内的属性或元素被外部程序修改。可以在set时赋值 目标参数的深拷贝对象,以保证当前引用类型成员变量的作用范围尽在当前类(同时,对引用类型成员变量的所有修改操作,也应以对象方法的方式,限定在当前类的对象上)。 2.防止成员变量本身被修改。为成员变量加final标识(增强语意),如此一来,其赋值操作将被限制在构造器构造的时候完成,不会出现被二次修改的场景。
    展开
    4
  • 大力水手Jerry
    2020-12-29
    推荐大家看这篇文章:https://golangbyexample.com/bridge-design-pattern-in-go/
    2