diff --git a/Chapter1 - iOS/1.110.md b/Chapter1 - iOS/1.110.md index f8be542..7317cb8 100644 --- a/Chapter1 - iOS/1.110.md +++ b/Chapter1 - iOS/1.110.md @@ -1,19 +1,21 @@ ## 妙用设计模式来设计一个客户端校验器 -> 订单在提交的时候会面临不同的校验规则,不同的校验规则会有不同的处理。假设这个处理就是弹窗。 -> -> 有的时候会命中规则1,则弹窗1,有的时候同时命中规则1、2、3,但由于存在规则的优先级,则会处理优先级最高的弹窗1。 -> -> 老的业务背景下,弹窗优先级或者说校验规则是统一的。直接用函数翻译实现,写多个 if 问题不大。 -> -> 但在新业务背景下,不同的条件,弹窗优先级不一致,之前的写法需要写大量的嵌套判断,代码难以维护。 -> -> 所以问题抽象为:如何设计一个校验器 +> 业务逻辑千变万化,弹窗优先级不断改变,代码冗余问题和难以维护问题如何解决? +> 本篇文章从设计模式角度出发,讨论责任链设计模式和工厂设计模式2个方式,如何去设计一个校验器,同时解决代码冗余和难以维护的问题 ## 问题背景 +订单在提交的时候会面临不同的校验规则,不同的校验规则会有不同的处理。假设这个处理就是弹窗。 +有的时候会命中规则1,则弹窗1,有的时候同时命中规则1、2、3,但由于存在规则的优先级,则会处理优先级最高的弹窗1。 + +老的业务背景下,弹窗优先级或者说校验规则是统一的。直接用函数翻译实现,写多个 if 问题不大。 +但在新业务背景下,不同的条件,弹窗优先级不一致,之前的写法需要写大量的嵌套判断,代码难以维护。 + +所以问题抽象为:如何设计一个校验器 + + 为了清晰说明问题,假设线上的弹窗校验规则为:A -> B -> C ```Plain @@ -193,7 +195,7 @@ Node 洋葱模式:发送一个 Request 一层层中间件去处理,比如添 采用责任链设计模式。基类 `OrderSubmitBaseValidator` 声明接口,是一个抽象类: - 有一个属性 `nextValidator` 用于指向下一个校验器 -- 有一个方法 `- (void)validate:(id)params;` 用于处理校验,内部默认实现是传递给下一个校验器。 +- 有一个方法 `- (void)validate:(id)params;` 用于处理校验,内部利用模版模式,默认实现是传递给下一个校验器 ```Shell //.h @@ -294,7 +296,7 @@ let validateType = [OrderSubmitValidator generateTypeWithParams:params]; [OrderSubmitValidator validateWith:validateType]; ``` -`validateWith` 方法内部根据 validateType 去组装 Map 的 key,然后从 Map 中取出具体规则组合,然后依次迭代遍历执行 +利用策略模式 `validateWith` 方法内部根据 validateType 去组装 Map 的 key,然后从 Map 中取出具体规则组合,然后依次迭代遍历执行 ``` let rulesMap = { @@ -303,13 +305,16 @@ let rulesMap = { !isVIP: [a-c-d-b], } ``` +这部分策略的生成也可以单独抽取出去,比如 ValidateStrategyFactory 去根据不同的信息,生成不同的策略。 优点: 1. 解决了现在的错误弹窗的隐含逻辑,后续人接手,弹窗优先级清晰可见,提高可维护性,减少出错概率 -2. 对于判断(校验)的增减都无需关心其他的校验规则。类似维护链表,仅在一开始指定即可,符合“开闭原则 +2. 对于判断(校验)的增减都无需关心其他的校验规则。类似维护链表,仅在一开始指定即可,符合“开闭原则” 3. 对于现有校验规则的修改足够收口,每个规则都有自己的 validator 和 validate 方法 -4. 目前弹窗优先级针对 EVA 、BTC 存在不同优先级顺序,如果按照现有的方案实施,则会存在很多冗余代码 +4. 目前弹窗优先级针对 isVIP、isCharged 存在不同优先级顺序,如果按照现有的方案实施,则会存在很多冗余代码 +5. 按照策略模式,不同的校验规则,组装不同的策略,也可以单独抽取出去,独立维护,更清晰 +6. validate 内部按照模版模式,调用 `isValidate` 方法,每个单独的 Validator 不需要额外去调用 next,设计更加健壮,防止别人漏写 @@ -396,5 +401,5 @@ OrderSumitValidatorFactory { - 优先级的关系维护在不同的子类中,各司其职,独立维护 +最后选什么?组合优于继承,个人倾向使用责任链模式去组织代码。关于责任链设计模式的文章也可以看这篇[文章](./../Chapter6%20-%20Design%20Pattern/6.23.md) -最后选什么?组合优于继承,个人倾向使用责任链模式去组织代码。 diff --git a/Chapter1 - iOS/1.48.md b/Chapter1 - iOS/1.48.md index 5c611a1..f019f80 100644 --- a/Chapter1 - iOS/1.48.md +++ b/Chapter1 - iOS/1.48.md @@ -32,8 +32,12 @@ ``` ### 类别的作用 - -拓展当前类,为类添加方法 +可以把类的实现分开在几个不同的源文件里,所以好处是: +- 减少耽搁文件的代码行数 +- 可以把不痛的功能组织到不同的 category 里 +- 可以由多个开发者共同完成一个大的类,方便协作 +- 拓展当前类,为类添加方法 +- 声明私有方法 ### 类别的局限性 diff --git a/Chapter6 - Design Pattern/6.10.md b/Chapter6 - Design Pattern/6.10.md new file mode 100644 index 0000000..49de7fa --- /dev/null +++ b/Chapter6 - Design Pattern/6.10.md @@ -0,0 +1,314 @@ +# 工厂模式 + +> 什么时候该用工厂模式?相对于直接 new 来创建对象,用工厂模式来创建究竟有什么好处呢? + +## 简单工厂(Simple Factory) +一般情况下,工厂模式分为三种更加细分的类型:简单工厂、工厂方法和抽象工厂。不过,在 GoF 的《设计模式》一书中,它将简单工厂模式看作是工厂方法模式的一种特例,所以工厂模式只被分成了工厂方法和抽象工厂两类。实际上,前面一种分类方法更加常见。 + +我们根据配置文件的后缀(json、xml、yaml、properties),选择不同的解析器(JsonRuleConfigParser、XmlRuleConfigParser......),将存储在文件中的配置解析成内存对象 RuleConfig。 +``` +public class RuleConfigSource { + public RuleConfig load(String ruleConfigFilePath) { + String ruleConfigFileExtension = getFileExtension(ruleConfigFilePath); + IRuleConfigParser parser = null; + if ("json".equalsIgnoreCase(ruleConfigFileExtension)) { + parser = new JsonRuleConfigParser(); + } else if ("xml".equalsIgnoreCase(ruleConfigFileExtension)) { + parser = new XmlRuleConfigParser(); + } else if ("yaml".equalsIgnoreCase(ruleConfigFileExtension)) { + parser = new YamlRuleConfigParser(); + } else if ("properties".equalsIgnoreCase(ruleConfigFileExtension)) { + parser = new PropertiesRuleConfigParser(); + } else { + throw new InvalidRuleConfigException("Rule config file format is not supported: " + ruleConfigFilePath) + } + String configText = ""; + //从ruleConfigFilePath文件中读取配置文本到configText中 + RuleConfig ruleConfig = parser.parse(configText); + return ruleConfig; + } + + private String getFileExtension(String filePath) { + //...解析文件名获取扩展名,比如rule.json,返回json + return "json"; + } +} +``` +在“规范和重构”那一部分中,我们有讲到,为了让代码逻辑更加清晰,可读性更好,我们要善于将功能独立的代码块封装成函数。按照这个设计思路,我们可以将代码中涉及 parser 创建的部分逻辑剥离出来,抽象成 createParser() 函数。重构之后的代码如下所示: + +``` +public RuleConfig load(String ruleConfigFilePath) { + String ruleConfigFileExtension = getFileExtension(ruleConfigFilePath); + IRuleConfigParser parser = createParser(ruleConfigFileExtension); + if (parser == null) { + throw new InvalidRuleConfigException("Rule config file format is not supported: " + ruleConfigFilePath) + } + String configText = ""; + //从ruleConfigFilePath文件中读取配置文本到configText中 + RuleConfig ruleConfig = parser.parse(configText); + return ruleConfig; +} +private String getFileExtension(String filePath) { + //...解析文件名获取扩展名,比如rule.json,返回json + return "json"; +} + +private IRuleConfigParser createParser(String configFormat) { + IRuleConfigParser parser = null; + if ("json".equalsIgnoreCase(configFormat)) { + parser = new JsonRuleConfigParser(); + } else if ("xml".equalsIgnoreCase(configFormat)) { + parser = new XmlRuleConfigParser(); + } else if ("yaml".equalsIgnoreCase(configFormat)) { + parser = new YamlRuleConfigParser(); + } else if ("properties".equalsIgnoreCase(configFormat)) { + parser = new PropertiesRuleConfigParser(); + } + return parser; +} +``` +为了让类的职责更加单一、代码更加清晰,我们还可以进一步将 createParser() 函数剥离到一个独立的类中,让这个类只负责对象的创建。而这个类就是我们现在要讲的简单工厂模式类。具体的代码如下所示: +``` +public class RuleConfigParserFactory { + public static IRuleConfigParser createParser(String configFormat) { + IRuleConfigParser parser = null; + if ("json".equalsIgnoreCase(configFormat)) { + parser = new JsonRuleConfigParser(); + } else if ("xml".equalsIgnoreCase(configFormat)) { + parser = new XmlRuleConfigParser(); + } else if ("yaml".equalsIgnoreCase(configFormat)) { + parser = new YamlRuleConfigParser(); + } else if ("properties".equalsIgnoreCase(configFormat)) { + parser = new PropertiesRuleConfigParser(); + } + return parser; + } +} +``` +大部分工厂类都是以 Factory 结尾的,这样子标准些,见名知意。 + +在上面的代码实现中,我们每次调用 RuleConfigParserFactory 的 createParser() 的时候,都要创建一个新的 parser。实际上,如果 parser 可以复用,为了节省内存和对象创建的时间,我们可以将 parser 事先创建好缓存起来。当调用 createParser() 函数的时候,我们从缓存中取出 parser 对象直接使用 + +这有点类似单例模式和简单工厂模式的结合,具体的代码实现如下所示。在接下来的讲解中,我们把上一种实现方法叫作简单工厂模式的第一种实现方法,把下面这种实现方法叫作简单工厂模式的第二种实现方法 + +``` +public class RuleConfigParserFactory { + private static final Map cachedParsers = new HashMap(); + static { + cachedParsers.put("json", new JsonRuleConfigParser()); + cachedParsers.put("xml", new XmlRuleConfigParser()); + cachedParsers.put("yaml", new YamlRuleConfigParser()); + cachedParsers.put("properties", new PropertiesRuleConfigParser()); + } + public static IRuleConfigParser createParser(String configFormat) { + if (configFormat == null || configFormat.isEmpty()) { + return null;//返回null还是IllegalArgumentException全凭你自己说了算 + } + IRuleConfigParser parser = cachedParsers.get(configFormat.toLowerCase()); + return parser; + } +} +``` + +对于上面两种简单工厂模式的实现方法,如果我们要添加新的 parser,那势必要改动到 RuleConfigParserFactory 的代码,那这是不是违反开闭原则呢?实际上,如果不是需要频繁地添加新的 parser,只是偶尔修改一下 RuleConfigParserFactory 代码,稍微不符合开闭原则,也是完全可以接受的。 + +除此之外,在 RuleConfigParserFactory 的第一种代码实现中,有一组 if 分支判断逻辑,是不是应该用多态或其他设计模式来替代呢?实际上,如果 if 分支并不是很多,代码中有 if 分支也是完全可以接受的。应用多态或设计模式来替代 if 分支判断逻辑,也并不是没有任何缺点的,它虽然提高了代码的扩展性,更加符合开闭原则,但也增加了类的个数,牺牲了代码的可读性。 + +总结一下,尽管简单工厂模式的代码实现中,有多处 if 分支判断逻辑,违背开闭原则,但**权衡扩展性和可读性**,这样的代码实现在大多数情况下(比如,不需要频繁地添加 parser,也没有太多的 parser)是没有问题的。 + +## 工厂方法(Factory Method) +如果我们非得要将 if 分支逻辑去掉,那该怎么办呢?比较经典处理方法就是利用多态。按照多态的实现思路,对上面的代码进行重构。重构之后的代码如下所示 + +``` +public interface IRuleConfigParserFactory { + IRuleConfigParser createParser(); +} +public class JsonRuleConfigParserFactory implements IRuleConfigParserFactory { + @Override + public IRuleConfigParser createParser() { + return new JsonRuleConfigParser(); + } +} +public class XmlRuleConfigParserFactory implements IRuleConfigParserFactory { + @Override + public IRuleConfigParser createParser() { + return new XmlRuleConfigParser(); + } +} +public class YamlRuleConfigParserFactory implements IRuleConfigParserFactory { + @Override + public IRuleConfigParser createParser() { + return new YamlRuleConfigParser(); + } +} +public class PropertiesRuleConfigParserFactory implements IRuleConfigParserFactory + @Override + public IRuleConfigParser createParser() { + return new PropertiesRuleConfigParser(); + } +} +``` +实际上,这就是工厂方法模式的典型代码实现。这样当我们新增一种 parser 的时候,只需要新增一个实现了 IRuleConfigParserFactory 接口的 Factory 类即可。所以,**工厂方法模式比起简单工厂模式更加符合开闭原则** + +从上面的工厂方法的实现来看,一切都很完美,但是实际上存在挺大的问题。问题存在于这些工厂类的使用上。接下来,我们看一下,如何用这些工厂类来实现 RuleConfigSource 的 load() 函数。具体的代码如下所示 +``` +public class RuleConfigSource { + public RuleConfig load(String ruleConfigFilePath) { + String ruleConfigFileExtension = getFileExtension(ruleConfigFilePath); + IRuleConfigParserFactory parserFactory = null; + if ("json".equalsIgnoreCase(ruleConfigFileExtension)) { + parserFactory = new JsonRuleConfigParserFactory(); + } else if ("xml".equalsIgnoreCase(ruleConfigFileExtension)) { + parserFactory = new XmlRuleConfigParserFactory(); + } else if ("yaml".equalsIgnoreCase(ruleConfigFileExtension)) { + parserFactory = new YamlRuleConfigParserFactory(); + } else if ("properties".equalsIgnoreCase(ruleConfigFileExtension)) { + parserFactory = new PropertiesRuleConfigParserFactory(); + } else { + throw new InvalidRuleConfigException("Rule config file format is not supported: " + ruleConfigFilePath) + } + IRuleConfigParser parser = parserFactory.createParser(); + String configText = ""; + //从ruleConfigFilePath文件中读取配置文本到configText中 + RuleConfig ruleConfig = parser.parse(configText); + return ruleConfig; + } + private String getFileExtension(String filePath) { + //...解析文件名获取扩展名,比如rule.json,返回json + return "json"; + } +} +``` +从上面的代码实现来看,工厂类对象的创建逻辑又耦合进了 load() 函数中,跟我们最初的代码版本非常相似,引入工厂方法非但没有解决问题,反倒让设计变得更加复杂了。那怎么来解决这个问题呢? + +我们可以为工厂类再创建一个简单工厂,也就是工厂的工厂,用来创建工厂类对象 +``` +public class RuleConfigSource { + public RuleConfig load(String ruleConfigFilePath) { + String ruleConfigFileExtension = getFileExtension(ruleConfigFilePath); + IRuleConfigParserFactory parserFactory = RuleConfigParserFactoryMap.getPars + if (parserFactory == null) { + throw new InvalidRuleConfigException("Rule config file format is not supported: " + ruleConfigFilePath) + } + IRuleConfigParser parser = parserFactory.createParser(); + String configText = ""; + //从ruleConfigFilePath文件中读取配置文本到configText中 + RuleConfig ruleConfig = parser.parse(configText); + return ruleConfig; + } + private String getFileExtension(String filePath) { + //...解析文件名获取扩展名,比如rule.json,返回json + return "json"; + } +} +//因为工厂类只包含方法,不包含成员变量,完全可以复用, +//不需要每次都创建新的工厂类对象,所以,简单工厂模式的第二种实现思路更加合适。 +public class RuleConfigParserFactoryMap { //工厂的工厂 + private static final Map cachedFactories = + static { + cachedFactories.put("json", new JsonRuleConfigParserFactory()); + cachedFactories.put("xml", new XmlRuleConfigParserFactory()); + cachedFactories.put("yaml", new YamlRuleConfigParserFactory()); + cachedFactories.put("properties", new PropertiesRuleConfigParserFactory()) + } + public static IRuleConfigParserFactory getParserFactory(String type) { + if (type == null || type.isEmpty()) { + return null; + } + IRuleConfigParserFactory parserFactory = cachedFactories.get(type.toLowerCa + return parserFactory; + } +} +``` +当我们需要添加新的规则配置解析器的时候,我们只需要创建新的 parser 类和 parserfactory 类,并且在 RuleConfigParserFactoryMap 类中,将新的 parser factory 对象添加到 cachedFactories 中即可。代码的改动非常少,基本上符合开闭原则。 + +实际上,对于规则配置文件解析这个应用场景来说,工厂模式需要额外创建诸多 Factory 类,也会增加代码的复杂性,而且,每个 Factory 类只是做简单的 new 操作,功能非常单薄(只有一行代码),也没必要设计成独立的类,所以,在这个应用场景下,简单工厂模式简单好用,比工方法厂模式更加合适。 + +什么时候该用工厂方法模式,而非简单工厂模式呢? + +之所以将某个代码块剥离出来,独立为函数或者类,原因是这个代码块的逻辑过于复杂,剥离之后能让代码更加清晰,更加可读、可维护。但是,如果代码块本身并不 +复杂,就几行代码而已,我们完全没必要将它拆分成单独的函数或者类。 + +基于这个设计思想,当对象的创建逻辑比较复杂,不只是简单的 new 一下就可以,而是要组合其他类对象,做各种初始化操作的时候,我们推荐使用工厂方法模式,将复杂的创建逻辑拆分到多个工厂类中,让每个工厂类都不至于过于复杂。而使用简单工厂模式,将所有的创建逻辑都放到一个工厂类中,会导致这个工厂类变得很复杂。 + +除此之外,在某些场景下,如果对象不可复用,那工厂类每次都要返回不同的对象。如果我们使用简单工厂模式来实现,就只能选择第一种包含 if 分支逻辑的实现方式。如果我们还想避免烦人的 if-else 分支逻辑,这个时候,我们就推荐使用工厂方法模式。 + +## 抽象工厂(Abstract Factory) +在简单工厂和工厂方法中,类只有一种分类方式。比如,在规则配置解析那个例子中,解析器类只会根据配置文件格式(Json、Xml、Yaml......)来分类。但是,如果类有两种分类方式,比如,我们既可以按照配置文件格式来分类,也可以按照解析的对象(Rule 规则配置还是 System 系统配置)来分类,那就会对应下面这 8 个 parser 类。 + +针对规则配置的解析器:基于接口IRuleConfigParser +- JsonRuleConfigParser +- XmlRuleConfigParser +- YamlRuleConfigParser +- PropertiesRuleConfigParser +针对系统配置的解析器:基于接口ISystemConfigParser +- JsonSystemConfigParser +- XmlSystemConfigParser +- YamlSystemConfigParser +- PropertiesSystemConfigParser + +针对这种特殊的场景,如果还是继续用工厂方法来实现的话,我们要针对每个 parser 都编写一个工厂类,也就是要编写 8 个工厂类。如果我们未来还需要增加针对业务配置的解析器(比如 IBizConfigParser),那就要再对应地增加 4 个工厂类。而我们知道,过多的类 +也会让系统难维护。这个问题该怎么解决呢? + +抽象工厂就是针对这种非常特殊的场景而诞生的。我们可以让一个工厂负责创建多个不同类型的对象(IRuleConfigParser、ISystemConfigParser 等),而不是只创建一种 parser 对象。这样就可以有效地减少工厂类的个数。具体的代码实现如下所示: +``` +public interface IConfigParserFactory { + IRuleConfigParser createRuleParser(); + ISystemConfigParser createSystemParser(); + //此处可以扩展新的parser类型,比如IBizConfigParser +} + +public class JsonConfigParserFactory implements IConfigParserFactory { + @Override + public IRuleConfigParser createRuleParser() { + return new JsonRuleConfigParser(); + } + @Override + public ISystemConfigParser createSystemParser() { + return new JsonSystemConfigParser(); + } +} + +public class XmlConfigParserFactory implements IConfigParserFactory { + @Override + public IRuleConfigParser createRuleParser() { + return new XmlRuleConfigParser(); + } + @Override + public ISystemConfigParser createSystemParser() { + return new XmlSystemConfigParser(); + } +} +//... +``` + +## 场景 +当创建逻辑比较复杂,是一个“大工程”的时候,我们就考虑使用工厂模式,封装对象的创建过程,将对象的创建和使用相分离。何为创建逻辑比较复杂呢? +第一种情况:类似规则配置解析的例子,代码中存在 if-else 分支判断,动态地根据不同的类型创建不同的对象。针对这种情况,我们就考虑使用工厂模式,将这一大坨 if-else 创建对象的代码抽离出来,放到工厂类中。 + +还有一种情况,尽管我们不需要根据不同的类型创建不同的对象,但是,单个对象本身的创建过程比较复杂,比如前面提到的要组合其他类对象,做各种初始化操作。在这种情况下,我们也可以考虑使用工厂模式,将对象的创建过程封装到工厂类中。 + +对于第一种情况,当每个对象的创建逻辑都比较简单的时候,我推荐使用简单工厂模式,将多个对象的创建逻辑放到一个工厂类中。当每个对象的创建逻辑都比较复杂的时候,为了避免设计一个过于庞大的简单工厂类,我推荐使用工厂方法模式,将创建逻辑拆分得更细,每个对象的创建逻辑独立到各自的工厂类中。同理,对于第二种情况,因为单个对象本身的创建逻辑就比较复杂,所以,我建议使用工厂方法模式。 + +一个 high-level 的视觉分析,什么场景下需使用工厂模式: +- 封装变化:创建逻辑有可能变化,封装成工厂类之后,创建逻辑的变更对调用者透明。 +- 代码复用:创建代码抽离到独立的工厂类之后可以复用。 +- 隔离复杂性:封装复杂的创建逻辑,调用者无需了解如何创建对象。 +- 控制复杂度:将创建代码抽离出来,让原本的函数或类职责更单一,代码更简洁。 + + +## 如何设计实现一个 Dependency Injection 框架 +赖注入框架,或者叫依赖注入容器(Dependency Injection Container),简称 DI 容器。需要搞清楚这样几个问题: +- DI 容器跟我们讲的工厂模式又有何区别和联系? +- DI 容器的核心功能有哪些 +- 如何实现一个简单的 DI 容器? + +### 工厂模式和 DI 容器有何区别? +**DI 容器底层最基本的设计思路就是基于工厂模式的**。DI 容器相当于一个大的工厂类,负责在程序启动的时候,根据配置(要创建哪些类对象,每个类对象的创建需要依赖哪些其他类对象)事先创建好对象。当应用程序需要使用某个类对象的时候,直接从容器中获取即可。正是因为它持有一堆对象,所以这个框架才被称为“容器”。 + +DI 容器相对于我们上节课讲的工厂模式的例子来说,它处理的是更大的对象创建工程。一个工厂类只负责某个类对象或者某一组相关类对象(继承自同一抽 +象类或者接口的子类)的创建,而 DI 容器负责的是整个应用中所有类对象的创建。除此之外,DI 容器负责的事情要比单纯的工厂模式要多。比如,它还包括配置的解析、对象生命周期的管理。接下来,我们就详细讲讲,一个简单的 DI 容器应该包含哪些核心功能。 + +### DI 容器的核心功能有哪些? +总结一下,一个简单的 DI 容器的核心功能一般有三个:配置解析、对象创建和对象生命周期管理 + diff --git a/Chapter6 - Design Pattern/6.11.md b/Chapter6 - Design Pattern/6.11.md new file mode 100644 index 0000000..870d8a7 --- /dev/null +++ b/Chapter6 - Design Pattern/6.11.md @@ -0,0 +1,193 @@ +# 建造者模式 +Builder 模式,中文翻译为建造者模式或者构建者模式,也有人叫它生成器模式。弄清楚建造者模式需要搞定下面2个问题: +- 直接使用构造函数或者配合 set 方法就能创建对象,为什么还需要建造者模式来创建呢? +- 建造者模式和工厂模式都可以创建对象,那它们两个的区 + +## 为什么需要建造者模式? +创建一个对象最常用的方式是,使用 new 关键字调用类的构造函数来完成。我的问题是,什么情况下这种方式就不适用了,就需要采用建造者模式来创建对象呢?你可以先思考一下,下面我通过一个例子来带你看一下。 + +设计面试题:我们需要定义一个资源池配置类 ResourcePoolConfig。这里的资源池,你可以简单理解为线程池、连接池、对象池等。在这个资源池配置类中,有以下几个成员变量,也就是可配置项。现在,请你编写代码实现这个 ResourcePoolConfig 类。 +- name:资源名称,必填,没有默认值 +- maxTotal:最大总资源数量,不是必填,默认值8 +- maxIdle:最大空闲资源数量,不是必填,默认值8 +- minIdle:最小空闲资源数量,不是必填,默认值0 +设计一下这个类 +``` +public class ResourcePoolConfig { + private static final int DEFAULT_MAX_TOTAL = 8; + private static final int DEFAULT_MAX_IDLE = 8; + private static final int DEFAULT_MIN_IDLE = 0; + private String name; + private int maxTotal = DEFAULT_MAX_TOTAL; + private int maxIdle = DEFAULT_MAX_IDLE; + private int minIdle = DEFAULT_MIN_IDLE; + public ResourcePoolConfig(String name, Integer maxTotal, Integer maxIdle, Integer minIdle) { + if (StringUtils.isBlank(name)) { + throw new IllegalArgumentException("name should not be empty."); + } + this.name = name; + if (maxTotal != null) { + if (maxTotal <= 0) { + throw new IllegalArgumentException("maxTotal should be positive."); + } + this.maxTotal = maxTotal; + } + if (maxIdle != null) { + if (maxIdle < 0) { + throw new IllegalArgumentException("maxIdle should not be negative."); + } + this.maxIdle = maxIdle; + } + if (minIdle != null) { + if (minIdle < 0) { + throw new IllegalArgumentException("minIdle should not be negative."); + } + this.minIdle = minIdle; + } + } + //...省略getter方法... +} +``` +现在,ResourcePoolConfig 只有 4 个可配置项,对应到构造函数中,也只有 4 个参数,参数的个数不多。但是,如果可配置项逐渐增多,变成了 8 个、10 个,甚至更多,那继续沿用现在的设计思路,构造函数的参数列表会变得很长,代码在可读性和易用性上都会变差。在使用构造函数的时候,我们就容易搞错各参数的顺序,传递进错误的参数值,导致非常隐蔽的 bug。 + +``` +// 参数太多,导致可读性差、参数可能传递错误 +ResourcePoolConfig config = new ResourcePoolConfig("dbconnectionpool", 16, ...); +``` + +方法二:解决这个问题的办法你应该也已经想到了,那就是用 set() 函数来给成员变量赋值,以替代冗长的构造函数。我们直接看代码,具体如下所示。其中,配置项 name 是必填的,所以我们把它放到构造函数中设置,强制创建类对象的时候就要填写。其他配置项 maxTotal、maxIdle、minIdle 都不是必填的,所以我们通过 set() 函数来设置,让使用者自主选择填写或者不填写。 + +``` +public class ResourcePoolConfig { + private static final int DEFAULT_MAX_TOTAL = 8; + private static final int DEFAULT_MAX_IDLE = 8; + private static final int DEFAULT_MIN_IDLE = 0; + private String name; + private int maxTotal = DEFAULT_MAX_TOTAL; + private int maxIdle = DEFAULT_MAX_IDLE; + private int minIdle = DEFAULT_MIN_IDLE; + public ResourcePoolConfig(String name) { + if (StringUtils.isBlank(name)) { + throw new IllegalArgumentException("name should not be empty."); + } + this.name = name; + } + public void setMaxTotal(int maxTotal) { + if (maxTotal <= 0) { + throw new IllegalArgumentException("maxTotal should be positive."); + } + this.maxTotal = maxTotal; + } + + //... +} +``` +接下来,我们来看新的 ResourcePoolConfig 类该如何使用。我写了一个示例代码,如下所示。没有了冗长的函数调用和参数列表,代码在可读性和易用性上提高了很多。 +``` +// ResourcePoolConfig使用举例 +ResourcePoolConfig config = new ResourcePoolConfig("dbconnectionpool"); +config.setMaxTotal(16); +config.setMaxIdle(8); +``` + +问题改变了,假设配置项之间有一定的依赖关系,比如,如果用户设置了 maxTotal、maxIdle、minIdle 其中一个,就必须显式地设置另外两个;或者配置项之间有一定的约束条件,比如,maxIdle 和 minIdle 要小于等于 maxTotal。如果我们继续使用现在的设计思路,那这些配置项之间的依赖关系或者约束条件的校验逻辑就无处安放了。 + +如果我们希望 ResourcePoolConfig 类对象是不可变对象,也就是说,对象在创建好之后,就不能再修改内部的属性值。要实现这个功能,我们就不能在ResourcePoolConfig 类中暴露 set() 方法。 + +这个时候建造者模式就应运而生了。 + +我们**可以把校验逻辑放置到 Builder 类中,先创建建造者,并且通过 set() 方法设置建造者的变量值,然后在使用 build() 方法真正创建对象之前,做集中的校验,校验通过之后才会创建对象**。除此之外,我们把 ResourcePoolConfig 的构造函数改为 private 私有权限。这样我们就只能通过建造者来创建ResourcePoolConfig 类对象。并且,ResourcePoolConfig 没有提供任何 set() 方法,这样我们创建出来的对象就是不可变对象了。 + +``` +public class ResourcePoolConfig { + private String name; + private int maxTotal; + private int maxIdle; + private int minIdle; + private ResourcePoolConfig(Builder builder) { + this.name = builder.name; + this.maxTotal = builder.maxTotal; + this.maxIdle = builder.maxIdle; + this.minIdle = builder.minIdle; + } + //...省略getter方法... + //我们将Builder类设计成了ResourcePoolConfig的内部类。 + //我们也可以将Builder类设计成独立的非内部类ResourcePoolConfigBuilder。 + public static class Builder { + private static final int DEFAULT_MAX_TOTAL = 8; + private static final int DEFAULT_MAX_IDLE = 8; + private static final int DEFAULT_MIN_IDLE = 0; + private String name; + private int maxTotal = DEFAULT_MAX_TOTAL; + private int maxIdle = DEFAULT_MAX_IDLE; + private int minIdle = DEFAULT_MIN_IDLE; + public ResourcePoolConfig build() { + // 校验逻辑放到这里来做,包括必填项校验、依赖关系校验、约束条件校验等 + if (StringUtils.isBlank(name)) { + throw new IllegalArgumentException("..."); + } + if (maxIdle > maxTotal) { + throw new IllegalArgumentException("..."); + } + if (minIdle > maxTotal || minIdle > maxIdle) { + throw new IllegalArgumentException("..."); + } + return new ResourcePoolConfig(this); + } + public Builder setName(String name) { + if (StringUtils.isBlank(name)) { + throw new IllegalArgumentException("..."); + } + this.name = name; + return this; + } + public Builder setMaxTotal(int maxTotal) { + if (maxTotal <= 0) { + throw new IllegalArgumentException("..."); + } + this.maxTotal = maxTotal; + return this; + } + public Builder setMaxIdle(int maxIdle) { + if (maxIdle < 0) { + throw new IllegalArgumentException("..."); + } + this.maxIdle = maxIdle; + return this; + } + public Builder setMinIdle(int minIdle) { + if (minIdle < 0) { + throw new IllegalArgumentException("..."); + } + this.minIdle = minIdle; + return this; + } + } +} + +// 这段代码会抛出IllegalArgumentException,因为minIdle>maxIdle +ResourcePoolConfig config = new ResourcePoolConfig.Builder() +.setName("dbconnectionpool") +.setMaxTotal(16) +.setMaxIdle(10) +.setMinIdle(12) +.build(); +``` + +**使用建造者模式创建对象,还能避免对象存在无效状态**。比如我们定义了一个长方形类,如果不使用建造者模式,采用先创建后 set 的方式,那就会 +导致在第一个 set 之后,对象处于无效状态。具体代码如下所示 +``` +Rectangle r = new Rectange(); // r is invalid +r.setWidth(2); // r is invalid +r.setHeight(3); // r is valid +``` +为了避免这种无效状态的存在,我们就需要使用构造函数一次性初始化好所有的成员变量。如果构造函数参数过多,我们就需要考虑使用建造者模式,先设置建造者的变量,然后再一次性地创建对象,让对象一直处于有效状态。 + +``` +Rectangle r = new Rectange.Builder().setWidth(2).setHeight(3).build(); +``` + +## 建造者模式和工厂模式有何异同 +建造者模式是让建造者类来负责对象的创建工作。工厂模式,是由工厂类来负责对象创建的工作。那它们之间有什么区别呢? + +实际上,工厂模式是用来创建不同但是相关类型的对象(继承同一父类或者接口的一组子类),由给定的参数来决定创建哪种类型的对象。建造者模式是用来创建一种类型的复杂对象,通过设置不同的可选参数,“定制化”地创建不同的对象。 \ No newline at end of file diff --git a/Chapter6 - Design Pattern/6.12.md b/Chapter6 - Design Pattern/6.12.md new file mode 100644 index 0000000..ea899ac --- /dev/null +++ b/Chapter6 - Design Pattern/6.12.md @@ -0,0 +1,37 @@ +# 原型模式 +对于创建型模式,之前的文章已经讲了单例模式、工厂模式、建造者模式,今天我们来讲最后一个:原型模式。 + +对于熟悉 JavaScript 语言的前端程序员来说,原型模式是一种比较常用的开发模式。这是因为,有别于 Java、C++ 等基于类的面向对象编程语言,JavaScript 是一种基于原型的面向对象编程语言。即便 JavaScript 现在也引入了类的概念,但它也只是基于原型的语法糖而已。不过,如果你熟悉的是 Java、C++ 等这些编程语言,那在实际的开发中,就很少用到原型模式了 + +## 原型模式的原理与应用 +如果对象的创建成本比较大,而同一个类的不同对象之间差别不大(大部分字段都相同),在这种情况下,我们可以利用对已有对象(原型)进行复制(或者叫拷贝)的方式来创建新对象,以达到节省创建时间的目的。这种基于原型来创建对象的方式就叫作原型设计模式 (Prototype Design Pattern),简称原型模式。 + +## 何为“对象的创建成本比较大”? +创建对象包含的申请内存、给成员变量赋值这一过程,本身并不会花费太多时间,或者说对于大部分业务系统来说,这点时间完全是可以忽略的。应用一个复杂的模式,只得到一点点的性能提升,这就是所谓的过度设计,得不偿失。 + +但是,如果对象中的数据需要经过复杂的计算才能得到(比如排序、计算哈希值),或者需要从 RPC、网络、数据库、文件系统等非常慢速的 IO 中读取,这种情况下,我们就可以利用原型模式,从其他已有对象中直接拷贝得到,而不用每次在创建新对象的时候,都重复执行这些耗时的操作。 + +举个例子: +假设数据库中存储了大约 10 万条“搜索关键词”信息,每条信息包含关键词、关键词被搜索的次数、信息最近被更新的时间等。系统 A 在启动的时候会加载这份数据到内存中,用于处理某些其他的业务需求。为了方便快速地查找某个关键词对应的信息,我们给关键词建立一个散列表索引。 + +不过,我们还有另外一个系统 B,专门用来分析搜索日志,定期(比如间隔 10 分钟)批量地更新数据库中的数据,并且标记为新的数据版本。比如,在下面的示例图中,我们对 v2 版本的数据进行更新,得到 v3 版本的数据。这里我们假设只有更新和新添关键词,没有删除关键词的行为 + +为了保证系统 A 中数据的实时性(不一定非常实时,但数据也不能太旧),系统 A 需要定期根据数据库中的数据,更新内存中的索引和数据。 + +要求,任何时刻,系统 A 的所有数据都是一个版本的,要么都是版本 a,要么都是版本 b,不能有的是版本 a,有的是版本 b。那刚刚的更新方式就不能满足这个要求了。除此之外,我们还要求:在更新内存数据的时候,系统 A不能处于不可用状态,也就是不能停机更新数据 + +方案:我们把正在使用的数据的版本定义为“服务版本”,当我们要更新内存中的数据的时候,我们并不是直接在服务版本(假设是版本 a 数据)上更新,而是重新创建另一个版本数据(假设是版本 b 数据),等新的版本数据建好之后,再一次性地将服务版本从版本 a 切换到版本 b。这样既保证了数据一直可用,又避免了中间状态的存在。 + +可以利用语言提供的 Java 的 clone 或者 OC 的 copy 来实现复制一个对象。但存在深拷贝和浅拷贝2个概念。 + +## 原型模式的实现方式:深拷贝和浅拷贝 +浅拷贝只会复制对象中基本数据类型数据和引用对象的内存地址,不会递归地复制引用对象,以及引用对象的引用对象......而深拷贝得到的是一份完完全全独立的对象。所以,深拷贝比起浅拷贝来说,更加耗时,更加耗内存空间。 + +那如何实现深拷贝呢?总结一下的话,有下面两种方法。 + +第一种方法:递归拷贝对象、对象的引用对象以及引用对象的引用对象......直到要拷贝的对象只包含基本数据类型数据,没有引用对象为止 +第二种方法:先将对象序列化,然后再反序列化成新的对象。 + + + +风险:如果要拷贝的对象是不可变对象,浅拷贝共享不可变对象是没问题的,但对于可变对象来说,浅拷贝得到的对象和原始对象会共享部分数据,就有可能出现数据被修改的风险,也就变得复杂多了 diff --git a/Chapter6 - Design Pattern/6.13.md b/Chapter6 - Design Pattern/6.13.md new file mode 100644 index 0000000..dd4adc3 --- /dev/null +++ b/Chapter6 - Design Pattern/6.13.md @@ -0,0 +1,178 @@ +# 代理模式 +接下来要开始学习另外一种类型的设计模式:结构型模式。结构型模式主要总结了一些类或对象组合在一起的经典结构,这些经典的结构可以解决特定应用场景的问题。结构型模式包括:代理模式、桥接模式、装饰器模式、适配器模式、门面模式、组合模式、享元模式。今天我们要讲其中的代理模式。它也是在实际开发中经常被用到的一种设计模式。 + +## 原理解析 +代理模式(Proxy Design Pattern)的原理和代码实现都不难掌握。它在不改变原始类(或叫被代理类)代码的情况下,通过引入代理类来给原始类附加功能 + +开发了一个 MetricsCollector 类,用来收集接口请求的原始数据,比如访问时间、处理时长等。在业务系统中,我们采用如下方式来使用这个 MetricsCollector 类: + +``` +public class UserController { + //...省略其他属性和方法... + private MetricsCollector metricsCollector; // 依赖注入 + public UserVo login(String telephone, String password) { + long startTimestamp = System.currentTimeMillis(); + // ... 省略login逻辑... + long endTimeStamp = System.currentTimeMillis(); + long responseTime = endTimeStamp - startTimestamp; + RequestInfo requestInfo = new RequestInfo("login", responseTime, startTimes + metricsCollector.recordRequest(requestInfo); + //...返回UserVo数据... + } + public UserVo register(String telephone, String password) { + long startTimestamp = System.currentTimeMillis(); + // ... 省略register逻辑... + long endTimeStamp = System.currentTimeMillis(); + long responseTime = endTimeStamp - startTimestamp; + RequestInfo requestInfo = new RequestInfo("register", responseTime, startTi + metricsCollector.recordRequest(requestInfo); + //...返回UserVo数据... + } +} +``` +上面代码存在2个问题: +1. 性能计数器框架代码侵入到业务代码中,跟业务代码高度耦合。如果未来需要替换这个框架,那替换的成本会比较大 +2. 收集接口请求的代码跟业务代码无关,本就不应该放到一个类中。业务类最好职责更加单一,只聚焦业务处理。 + +改进:为了将框架代码和业务代码解耦,代理模式就派上用场了。代理类 UserControllerProxy 和原始类 UserController 实现相同的接口IUserController。UserController 类只负责业务功能。代理类 UserControllerProxy 负责在业务代码执行前后附加其他逻辑代码,并通过委托的方式调用原始类来执行业务代码 + +``` +public interface IUserController { + UserVo login(String telephone, String password); + UserVo register(String telephone, String password); +} +public class UserController implements IUserController { + //...省略其他属性和方法... + @Override + public UserVo login(String telephone, String password) { + //...省略login逻辑... + //...返回UserVo数据... + } + @Override + public UserVo register(String telephone, String password) { + //...省略register逻辑... + //...返回UserVo数据... + } +} +public class UserControllerProxy implements IUserController { + private MetricsCollector metricsCollector; + private UserController userController; + public UserControllerProxy(UserController userController) { + this.userController = userController; + this.metricsCollector = new MetricsCollector(); + } + + @Override + public UserVo login(String telephone, String password) { + long startTimestamp = System.currentTimeMillis(); + // 委托 + UserVo userVo = userController.login(telephone, password); + long endTimeStamp = System.currentTimeMillis(); + long responseTime = endTimeStamp - startTimestamp; + RequestInfo requestInfo = new RequestInfo("login", responseTime, startTimestamp); + metricsCollector.recordRequest(requestInfo); + return userVo; + } + @Override + public UserVo register(String telephone, String password) { + long startTimestamp = System.currentTimeMillis(); + UserVo userVo = userController.register(telephone, password); + long endTimeStamp = System.currentTimeMillis(); + long responseTime = endTimeStamp - startTimestamp; + RequestInfo requestInfo = new RequestInfo("register", responseTime, startTimestamp); + metricsCollector.recordRequest(requestInfo); + return userVo; + } +} +//UserControllerProxy使用举例 +//因为原始类和代理类实现相同的接口,是基于接口而非实现编程 +//将UserController类对象替换为UserControllerProxy类对象,不需要改动太多代码 +IUserController userController = new UserControllerProxy(new UserController()) +``` + +参照基于接口而非实现编程的设计思想,将原始类对象替换为代理类对象的时候,为了让代码改动尽量少,在刚刚的代理模式的代码实现中,代理类和原始类需要实现相同的接口。但是,如果原始类并没有定义接口,并且原始类代码并不是我们开发维护的(比如它来自一个第三方的类库),我们也没办法直接修改原始类,给它重新定义一个接口。在这种情况下,我们该如何实现代理模式呢? + +对于这种外部类的扩展,我们一般都是采用继承的方式。这里也不例外。我们让代理类继承原始类,然后扩展附加功能。原理很简单,不需要过多解释,你直接看代码就能明白。具体代码如下所示: + +``` +public class UserControllerProxy extends UserController { + private MetricsCollector metricsCollector; + public UserControllerProxy() { + this.metricsCollector = new MetricsCollector(); + } + public UserVo login(String telephone, String password) { + long startTimestamp = System.currentTimeMillis(); + UserVo userVo = super.login(telephone, password); + long endTimeStamp = System.currentTimeMillis(); + long responseTime = endTimeStamp - startTimestamp; + RequestInfo requestInfo = new RequestInfo("login", responseTime, startTimestamp); + metricsCollector.recordRequest(requestInfo); + return userVo; + } + public UserVo register(String telephone, String password) { + long startTimestamp = System.currentTimeMillis(); + UserVo userVo = super.register(telephone, password); + long endTimeStamp = System.currentTimeMillis(); + long responseTime = endTimeStamp - startTimestamp; + RequestInfo requestInfo = new RequestInfo("register", responseTime, startTimestamp); + metricsCollector.recordRequest(requestInfo); + return userVo; + } +} +//UserControllerProxy使用举例 +UserController userController = new UserControllerProxy(); +``` + +## 动态代理 +不过,刚刚的代码实现还是有点问题。一方面,我们需要在代理类中,将原始类中的所有的方法,都重新实现一遍,并且为每个方法都附加相似的代码逻辑。另一方面,如果要添加的附加功能的类有不止一个,我们需要针对每个类都创建一个代理类。 + +我们可以使用动态代理来解决这个问题。所谓动态代理(Dynamic Proxy),就是我们不事先为每个原始类编写代理类,而是在运行的时候,动态地创建原始类对应的代理类,然后在系统中用代理类替换掉原始类 + +具有动态特性的语言可以实现这个功能,比如 OC、Java 的反射。 + +``` +public class MetricsCollectorProxy { + private MetricsCollector metricsCollector; + public MetricsCollectorProxy() { + this.metricsCollector = new MetricsCollector(); + } + public Object createProxy(Object proxiedObject) { + Class[] interfaces = proxiedObject.getClass().getInterfaces(); + DynamicProxyHandler handler = new DynamicProxyHandler(proxiedObject); + return Proxy.newProxyInstance(proxiedObject.getClass().getClassLoader(), in + } + private class DynamicProxyHandler implements InvocationHandler { + private Object proxiedObject; + public DynamicProxyHandler(Object proxiedObject) { + this.proxiedObject = proxiedObject; + } + @Override + public Object invoke(Object proxy, Method method, Object[] args) { + long startTimestamp = System.currentTimeMillis(); + Object result = method.invoke(proxiedObject, args); + long endTimeStamp = System.currentTimeMillis(); + long responseTime = endTimeStamp - startTimestamp; + String apiName = proxiedObject.getClass().getName() + ":" + method.getName; + RequestInfo requestInfo = new RequestInfo(apiName, responseTime, startTimestamp); + metricsCollector.recordRequest(requestInfo); + return result; + } + } +} +//MetricsCollectorProxy使用举例 +MetricsCollectorProxy proxy = new MetricsCollectorProxy(); +IUserController userController = (IUserController) proxy.createProxy(new UserController) +``` +实际上,Spring AOP 底层的实现原理就是基于动态代理。用户配置好需要给哪些类创建代理,并定义好在执行原始类的业务代码前后执行哪些附加功能。Spring 为这些类创建动态代理对象,并在 JVM 中替代原始类对象。原本在代码中执行的原始类的方法,被换作执行代理类的方法,也就实现了给原始类添加附加功能的目的。 + + +## 总结 +### 代理模式的原理与实现 +在不改变原始类(或叫被代理类)的情况下,通过引入代理类来给原始类附加功能。一般情况下,我们让代理类和原始类实现同样的接口。但是,如果原始类并没有定义接口,并且原始类代码并不是我们开发维护的。在这种情况下,我们可以通过让代理类继承原始类的方法来实现代理模式 + +### 动态代理的原理与实现 +静态代理需要针对每个类都创建一个代理类,并且每个代理类中的代码都有点像模板式的“重复”代码,增加了维护成本和开发成本。对于静态代理存在的问题,我们可以通过动态代理来解决。我们不事先为每个原始类编写代理类,而是在运行的时候动态地创建原始类对应的代理类,然后在系统中用代理类替换掉原始类。 + +### 代理模式的应用场景 +代理模式常用在业务系统中开发一些非功能性需求,比如:监控、统计、鉴权、限流、事务、幂等、日志。我们将这些附加功能与业务功能解耦,放到代理类统一处理,让程序员只需要关注业务方面的开发。除此之外,代理模式还可以用在 RPC、缓存等应用场景中 + diff --git a/Chapter6 - Design Pattern/6.14.md b/Chapter6 - Design Pattern/6.14.md new file mode 100644 index 0000000..3cdf4dc --- /dev/null +++ b/Chapter6 - Design Pattern/6.14.md @@ -0,0 +1,89 @@ +# 桥接模式 + +## 概念理解 + +桥接模式也叫作桥梁模式,英文是 Bridge Design Pattern。这个模式可以说是 23 种设计模式中最难理解的模式之一了。我查阅了比较多的书籍和资料之后发现,对于这个模式有两种不同的理解方式。 + +这其中“最纯正”的理解方式,当属 GoF 的《设计模式》一书中对桥接模式的定义。毕竟,这 23 种经典的设计模式,最初就是由这本书总结出来的。在 GoF 的《设计模式》一书中,桥接模式是这么定义的:“Decouple an abstraction from its implementation so that the two can vary independently。”翻译成中文就是:“**将抽象和实现解耦,让它们可以独立变化**” + +很多书籍、资料中,还有另外一种理解方式:“一个类存在两个(或多个)独立变化的维度,我们通过组合的方式,让这两个(或多个)维度可以独立进行扩展。”通 +过组合关系来替代继承关系,避免继承层次的指数级爆炸。这种理解方式非常类似于,我们之前讲过的“组合优于继承”设计原则 + +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 registeredDrivers = new + //... + static { + loadInitialDrivers(); + println("JDBC DriverManager initialized"); + } + //... + public static synchronized void registerDriver(java.sql.Driver driver) throws + if (driver != null) { + registeredDrivers.addIfAbsent(new DriverInfo(driver)); + } else { + throw new NullPointerException(); + } + } + public static Connection getConnection(String url, String user, String password) { + 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 来执行。 + + +## 总结 +桥接模式有两种理解方式。第一种理解方式是“将抽象和实现解耦,让它们能独立开发”。这种理解方式比较特别,应用场景也不多。另一种理解方式更加简单,类似“组合优于继承”设计原则,这种理解方式更加通用,应用场景比较多。 \ No newline at end of file diff --git a/Chapter6 - Design Pattern/6.15.md b/Chapter6 - Design Pattern/6.15.md new file mode 100644 index 0000000..6a36121 --- /dev/null +++ b/Chapter6 - Design Pattern/6.15.md @@ -0,0 +1,42 @@ +# 装饰器模式 + +装饰器模式,它的代码结构跟桥接模式非常相似,不过,要解决的问题却大不相同。 + +## Java IO 类的“奇怪”用法 +Java IO 类库非常庞大和复杂,有几十个类,负责 IO 数据的读取和写入。如果对 Java IO 类做一下分类,我们可以从下面两个维度将它划分为四类。具体如下所示 + +| |字节流|字符流| +|-|-|-| +|输入流| InputStream| Reader| +|输出流| OutputStream| Writer| + + +针对不同的读取和写入场景,Java IO 又在这四个父类基础之上,扩展出了很多子类。比如字节流的 InputStream 有 ByteArrayInputStream、PipedInputStream... +比如下面的代码 +``` +InputStream in = new FileInputStream("/user/wangzheng/test.txt"); +InputStream bin = new BufferedInputStream(in); +byte[] data = new byte[128]; +while (bin.read(data) != -1) { + //... +} +``` +是不是觉得 JavaIO 很麻烦,创建 FileInputStream 对象,然后再传递给 BufferedInputStream 对象来使用。我在想,Java IO 为什么不设计一个继承 FileInputStream 并且支持缓存的 BufferedFileInputStream 类呢?这样我们就可以像下面的代码中这样,直接创建一个 BufferedFileInputStream 类对象,打开文件读取数据,用起来岂不是更加简单? + +## 基于继承的设计方案 +如果 InputStream 只有一个子类 FileInputStream 的话,那我们在 FileInputStream 基础之上,再设计一个孙子类 BufferedFileInputStream,也算是可以接受的,毕竟继承结构还算简单。但实际上,继承 InputStream 的子类有很多。我们需要给每一个 InputStream 的子类,再继续派生支持缓存读取的子类 + +在这种情况下,如果我们继续按照继承的方式来实现的话,就需要再继续派生出 DataFileInputStream、DataPipedInputStream 等类。如果我们还需要既支持缓存、又支持按照基本类型读取数据的类,那就要再继续派生出 BufferedDataFileInputStream、BufferedDataPipedInputStream 等 n 多类。这还只是附加了两个增强功能,如果我们需要附加更多的功能,则会导致组合爆炸,类的继承结构变得很复杂,代码不好维护。 + + + +装饰器模式相对于简单的组合关系,还有两个比较特殊的地方: +1. 第一个比较特殊的地方是:装饰器类和原始类继承同样的父类,这样我们可以对原始类“嵌套”多个装饰器类。 +2. 第二个比较特殊的地方是:装饰器类是对功能的增强,这也是装饰器模式应用场景的一个重要特点。 +符合“组合关系”这种代码结构的设计模式有很多,比如之前讲过的代理模式、桥接模式,还有现在的装饰器模式。尽管它们的代码结构很相似,但是每种设计模式的意图是不同的。就拿比较相似的代理模式和装饰器模式来说吧,代理模式中,代理类附加的是跟原始类无关的功能,而在装饰器模式中,装饰器类附加的是跟原始类相关的增强功能。 + +## 价值 +装饰器模式主要解决继承关系过于复杂的问题,通过组合来替代继承。它主要的作用是给原始类添加增强功能。这也是判断是否该用装饰器模式的一个重要的依据。除此之外,装饰器模式还有一个特点,那就是可以对原始类嵌套使用多个装饰器。为了满足这个应用场景,在设计的时候,装饰器类需要跟原始类继承相同的抽象类或者接口。 + +到底是该用代理模式还是装饰器模式呢? +对于添加缓存这个应用场景使用哪种模式,要看设计者的意图,如果设计者不需要用户关注是否使用缓存功能,要隐藏实现细节,也就是说用户只能看到和使用代理类,那么就使用 proxy 模式;反之,如果设计者需要用户自己决定是否使用缓存的功能,需要用户自己新建原始对象并动态添加缓存功能,那么就使用 decorator 模式。 diff --git a/Chapter6 - Design Pattern/6.16.md b/Chapter6 - Design Pattern/6.16.md new file mode 100644 index 0000000..fa51545 --- /dev/null +++ b/Chapter6 - Design Pattern/6.16.md @@ -0,0 +1,239 @@ +# 适配器模式 + +## 适配器模式的原理 + +适配器模式的英文翻译是 Adapter Design Pattern。顾名思义,这个模式就是用来做适配的,它将不兼容的接口转换为可兼容的接口,让原本由于接口不兼容而不能一起工作的类可以一起工作。举个现实的例子:USB 转接头充当适配器,把两种不兼容的接口,通过转接变得可以一起工作 + +## 适配器模式的实现 +适配器模式有两种实现方式: +- 类适配器,使用继承关系来实现 +- 对象适配器,对象适配器使用组合关系来实现 +举个例子: +- ITarget 表示要转化成的接口定义 +- Adaptee 是一组不兼容 ITarget 接口定义的接口 +- Adaptor 将 Adaptee 转化成一组符合 ITarget 接口定义的接口 + +类适配器: 基于继承 +``` +public interface ITarget { + void f1(); + void f2(); + void fc(); +} +public class Adaptee { + public void fa() { //... } + public void fb() { //... } + public void fc() { //... } +} +public class Adaptor extends Adaptee implements ITarget { + public void f1() { + super.fa(); + } + public void f2() { + //...重新实现f2()... + } + // 这里fc()不需要实现,直接继承自Adaptee,这是跟对象适配器最大的不同点 +} +``` +对象适配器:基于组合 +``` +public interface ITarget { + void f1(); + void f2(); + void fc(); +} +public class Adaptee { + public void fa() { //... } + public void fb() { //... } + public void fc() { //... } +} +public class Adaptor implements ITarget { + private Adaptee adaptee; + public Adaptor(Adaptee adaptee) { + this.adaptee = adaptee; + } + public void f1() { + adaptee.fa(); //委托给Adaptee + } + public void f2() { + //...重新实现f2()... + } + public void fc() { + adaptee.fc(); + } +} +``` +针对这两种实现方式,在实际的开发中,到底该如何选择使用哪一种呢?判断的标准主要有两个,一个是 Adaptee 接口的个数,另一个是 Adaptee 和 ITarget 的契合程度: +- 如果 Adaptee 接口并不多,那两种实现方式都可以。 +- 如果 Adaptee 接口很多,而且 Adaptee 和 ITarget 接口定义大部分都相同,推荐使用类适配器,因为 Adaptor 复用父类 Adaptee 的接口,比起对象适配器的实现方式,Adaptor 的代码量要少一些。 +- 如果 Adaptee 接口很多,而且 Adaptee 和 ITarget 接口定义大部分都不相同,推荐使用对象适配器,因为组合结构相对于继承更加灵活 + +## 适配器模式应用场景总结 +适配器模式可以看作一种“补偿模式”,用来补救设计上的缺陷。应用这种模式算是“无奈之举”。如果在设计初期,我们就能协调规避接口不兼容的问题,那这种模式就没有应用的机会了 + +适配器模式的应用场景是“接口不兼容”。那在实际的开发中,什么情况下才会出现接口不兼容呢? + +### 封装有缺陷的接口设计 +假设我们依赖的外部系统在接口设计方面有缺陷(比如包含大量静态方法),引入之后会影响到我们自身代码的可测试性。为了隔离设计上的缺陷,我们希望对外部系统提供的接口进行二次封装,抽象出更好的接口设计,这个时候就可以使用适配器模式了。 +``` +public class CD { //这个类来自外部sdk,我们无权修改它的代码 + //... + public static void staticFunction1() { //... } + public void uglyNamingFunction2() { //... } + public void tooManyParamsFunction3(int paramA, int paramB, ...) { //... } + public void lowPerformanceFunction4() { //... } +} +// 使用适配器模式进行重构 +public class ITarget { + void function1(); + void function2(); + void fucntion3(ParamsWrapperDefinition paramsWrapper); + void function4(); + //... +} +// 注意:适配器类的命名不一定非得末尾带Adaptor +public class CDAdaptor extends CD implements ITarget { + //... + public void function1() { + super.staticFunction1(); + } + public void function2() { + super.uglyNamingFucntion2(); + } + public void function3(ParamsWrapperDefinition paramsWrapper) { + super.tooManyParamsFunction3(paramsWrapper.getParamA(), ...); + } + public void function4() { + //...reimplement it... + } +} +``` +### 统一多个类的接口设计 +某个功能的实现依赖多个外部系统(或者说类)。通过适配器模式,将它们的接口适配为统一的接口定义,然后我们就可以使用多态的特性来复用代码逻辑。 + +假设我们的系统要对用户输入的文本内容做敏感词过滤,为了提高过滤的召回率,我们引入了多款第三方敏感词过滤系统,依次对用户输入的内容进行过滤,过滤掉尽可能多的敏感词。但是,每个系统提供的过滤接口都是不同的。这就意味着我们没法复用一套逻辑来调用各个系统。这个时候,我们就可以使用适配器模式,将所有系统的接口适配为统一的接口定义,这样我们可以复用调用敏感词过滤的代码。 + +``` +public class ASensitiveWordsFilter { // A敏感词过滤系统提供的接口 + //text是原始文本,函数输出用***替换敏感词之后的文本 + public String filterSexyWords(String text) { + // ... + } + public String filterPoliticalWords(String text) { + // ... + } +} +public class BSensitiveWordsFilter { // B敏感词过滤系统提供的接口 + public String filter(String text) { + //... + } +} +public class CSensitiveWordsFilter { // C敏感词过滤系统提供的接口 + public String filter(String text, String mask) { + //... + } +} +// 未使用适配器模式之前的代码:代码的可测试性、扩展性不好 +public class RiskManagement { + private ASensitiveWordsFilter aFilter = new ASensitiveWordsFilter(); + private BSensitiveWordsFilter bFilter = new BSensitiveWordsFilter(); + private CSensitiveWordsFilter cFilter = new CSensitiveWordsFilter(); + public String filterSensitiveWords(String text) { + String maskedText = aFilter.filterSexyWords(text); + maskedText = aFilter.filterPoliticalWords(maskedText); + maskedText = bFilter.filter(maskedText); + maskedText = cFilter.filter(maskedText, "***"); + return maskedText; + } +} + +// 使用适配器模式进行改造 +public interface ISensitiveWordsFilter { // 统一接口定义 + String filter(String text); +} +public class ASensitiveWordsFilterAdaptor implements ISensitiveWordsFilter { + private ASensitiveWordsFilter aFilter; + public String filter(String text) { + String maskedText = aFilter.filterSexyWords(text); + maskedText = aFilter.filterPoliticalWords(maskedText); + return maskedText; + } +} + +//...省略BSensitiveWordsFilterAdaptor、CSensitiveWordsFilterAdaptor... + +// 扩展性更好,更加符合开闭原则,如果添加一个新的敏感词过滤系统, +// 这个类完全不需要改动;而且基于接口而非实现编程,代码的可测试性更好。 +public class RiskManagement { + private List filters = new ArrayList<>(); + public void addSensitiveWordsFilter(ISensitiveWordsFilter filter) { + filters.add(filter); + } + public String filterSensitiveWords(String text) { + String maskedText = text; + for (ISensitiveWordsFilter filter : filters) { + maskedText = filter.filter(maskedText); + } + return maskedText; + } +} +``` +### 替换依赖的外部系统 +当我们把项目中依赖的一个外部系统替换为另一个外部系统的时候,利用适配器模式,可以减少对代码的改动。 + +``` +// 外部系统A +public interface IA { + //... + void fa(); +} +public class A implements IA { + //... + public void fa() { //... } +} + +// 在我们的项目中,外部系统A的使用示例 +public class Demo { + private IA a; + public Demo(IA a) { + this.a = a; + } + //... +} +Demo d = new Demo(new A()); + +// 将外部系统A替换成外部系统B +public class BAdaptor implemnts IA { + private B b; + public BAdaptor(B b) { + this.b= b; + } + public void fa() { + //... + b.fb(); + } +} +// 借助 BAdaptor,Demo 的代码中,调用 IA 接口的地方都无需改动, +// 只需要将BAdaptor如下注入到Demo即可。 +Demo d = new Demo(new BAdaptor(new B())); +``` + +### 兼容老版本 +在做版本升级的时候,对于一些要废弃的接口,我们不直接将其删除,而是暂时保留,并且标注为 deprecated,并将内部实现逻辑委托为新的接口实现。这样做的好处是,让使用它的项目有个过渡期,而不是强制进行代码修改。这也可以粗略地看作适配器模式的一个应用场景 + +### 适配不同格式的数据 +前面我们讲到,适配器模式主要用于接口的适配,实际上,它还可以用在不同格式的数据之间的适配。比如,把从不同征信系统拉取的不同格式的征信数据,统一为相同的格式,以方便存储和使用。再比如,Java 中的 Arrays.asList() 也可以看作一种数据适配器,将数组类型的数据转化为集合容器类型。 + +``` +List stooges = Arrays.asList("Larry", "Moe", "Curly"); +``` + +## 代理、桥接、装饰器、适配器 4 种设计模式的区别 +代理、桥接、装饰器、适配器,这 4 种模式是比较常用的结构型设计模式。它们的代码结构非常相似。笼统来说,它们都可以称为 Wrapper 模式,也就是通过 Wrapper 类二次封装原始类 + +尽管代码结构相似,但这 4 种设计模式的用意完全不同,也就是说要解决的问题、应用场景不同,这也是它们的主要区别 +- 代理模式:不改变原始类接口,为原始类定义一个代理类,主要的目的是为了访问控制,隔离原始代码。而非增加功能 +- 桥接模式:为了接口和实现分离,做到更好的解耦,可以让类更好、更容易的独立改变 +- 装饰器模式:在不改变类原始接口的情况下,对类的功能进行加强,并且支持多个装饰器的嵌套使用 +- 适配器模式:类似一个事后补救策略,提供跟原始类不同的接口,主要为了抹平不同接口的差异性,做到一致性。 + diff --git a/Chapter6 - Design Pattern/6.17.md b/Chapter6 - Design Pattern/6.17.md new file mode 100644 index 0000000..928742a --- /dev/null +++ b/Chapter6 - Design Pattern/6.17.md @@ -0,0 +1,58 @@ +# 门面模式 + +> 如何设计合理的接口粒度以兼顾接口的易用性和通用性 + +## 定义 +门面模式原理和实现都特别简单,应用场景也比较明确,主要在接口设计方面使用。 + +为了保证接口的可复用性(或者叫通用性),我们需要将接口尽量设计得细粒度一点,职责单一一点。但是,如果接口的粒度过小,在接口的使用者开发一个业务功能时,就会导致需要调用 n 多细粒度的接口才能完成。调用者肯定会抱怨接口不好用 + +反,如果接口粒度设计得太大,一个接口返回 n 多数据,要做 n 多事情,就会导致接口不够通用、可复用性不好。接口不可复用,那针对不同的调用者的业务需求,我们就需要开发不同的接口来满足,这就会导致系统的接口无限膨胀。解决方案就是门面模式 + + +门面模式,也叫外观模式,英文全称是 Facade Design Pattern。在 GoF 的《设计模式》一书中,门面模式是这样定义的 +> Provide a unified interface to a set of interfaces in a subsystem. Facade Pattern defines a higher-level interface that makes the subsystem easier to use. + +翻译成中文就是:门面模式为子系统提供一组统一的接口,定义一组高层接口让子系统更易用 + + +假设有一个系统 A,提供了 a、b、c、d 四个接口。系统 B 完成某个业务功能,需要调用 A 系统的 a、b、d 接口。利用门面模式,我们提供一个包裹 a、b、d 接口调用的门面接口 x,给系统 B 直接使用。 +不知道你会不会有这样的疑问,让系统 B 直接调用 a、b、d 感觉没有太大问题呀,为什么还要提供一个包裹 a、b、d 的接口 x 呢? + +假设我们刚刚提到的系统 A 是一个后端服务器,系统 B 是 App 客户端。App 客户端通过后端服务器提供的接口来获取数据。我们知道,App 和服务器之间是通过移动网络通信的,网络通信耗时比较多,为了提高 App 的响应速度,我们要尽量减少 App 与服务器之间的网络通信次数。 + +假设,完成某个业务功能(比如显示某个页面信息)需要“依次”调用 a、b、d 三个接口,因自身业务的特点,不支持并发调用这三个接口。如果我们现在发现 App 客户端的响应速度比较慢,排查之后发现,是因为过多的接口调用过多的网络通信。针对这种情况,我们就可以利用门面模式,让后端服务器提供一个包裹 a、b、d 三个接口调用的接口 x。App 客户端调用一次接口 x,来获取到所有想要的数据,将网络通信的次数从 3 次减少到 1 次,也就提高了 App 的响应速度。 + +## 应用场景 +门面模式定义中的“子系统(subsystem)”也可以有多种理解方式。它既可以是一个完整的系统,也可以是更细粒度的类或者模块 + +### 解决易用性问题 +门面模式可以用来封装系统的底层实现,隐藏系统的复杂性,提供一组更加简单易用、更高层的接口。比如,Linux 系统调用函数就可以看作一种“门面”。它是 Linux 操作系统暴露给开发者的一组“特殊”的编程接口,它封装了底层更基础的 Linux 内核调用。再比如,Linux 的 Shell 命令,实际上也可以看作一种门面模式的应用。它继续封装系统调用,提供更加友好、简单的命令,让我们可以直接通过执行命令来跟操作系统交互。 + +设计原则、思想、模式很多都是相通的,是同一个道理不同角度的表述。实际上,从隐藏实现复杂性,提供更易用接口这个意图来看,门面模式有点类似之前讲到的迪米特法则(最少知识原则)和接口隔离原则:两个有交互的系统,只暴露有限的必要的接口。除此之外,门面模式还有点类似之前提到封装、抽象的设计思想,提供更抽象的接口,封装底层实现细节。 + +### 解决性能问题 +通过将多个接口调用替换为一个门面接口调用,减少网络通信成本,提高 App 客户端的响应速度。 + +讨论一下这样一个问题:从代码实现的角度来看,该如何组织门面接口和非门面接口? +如果门面接口不多,我们完全可以将它跟非门面接口放到一块,也不需要特殊标记,当作普通接口来用即可。如果门面接口很多,我们可以在已有的接口之上,再重新抽象出一层,专门放置门面接口,从类、包的命名上跟原来的接口层做区分。如果门面接口特别多,并且很多都是跨多个子系统的,我们可以将门面接口放到一个新的子系统中 + +### 解决分布式事务问题 +在一个金融系统中,有两个业务领域模型,用户和钱包。这两个业务领域模型都对外暴露了一系列接口,比如用户的增删改查接口、钱包的增删改查接口。假设有这样一个业务场景:在用户注册的时候,我们不仅会创建用户(在数据库 User 表中),还会给用户创建一个钱 +包(在数据库的 Wallet 表中)。 + +对于这样一个简单的业务需求,我们可以通过依次调用用户的创建接口和钱包的创建接口来完成。但是,用户注册需要支持事务,也就是说,创建用户和钱包的两个操作,要么都成功,要么都失败,不能一个成功、一个失败。 + +要支持两个接口调用在一个事务中执行,是比较难实现的,这涉及分布式事务问题。虽然我们可以通过引入分布式事务框架或者事后补偿的机制来解决,但代码实现都比较复杂。而最简单的解决方案是,利用数据库事务或者 Spring 框架提供的事务(如果是 Java 语言的话),在一个事务中,执行创建用户和创建钱包这两个 SQL 操作。这就要求两个 SQL 操作要在一个接口中完成,所以,我们可以借鉴门面模式的思想,再设计一个包裹这两个操作的新接口,让新接口在一个事务中执行两个 SQL 操作。 + + +## 总结 +类、模块、系统之间的“通信”,一般都是通过接口调用来完成的。接口设计的好坏,直接影响到类、模块、系统是否好用。所以,我们要多花点心思在接口设计上。我经常说,完成接口设计,就相当于完成了一半的开发任务。只要接口设计得好,那代码就差不到哪里去 + +接口粒度设计得太大,太小都不好。太大会导致接口不可复用,太小会导致接口不易用。在实际的开发中,接口的可复用性和易用性需要“微妙”的权衡。针对这个问题,我的一个基本的处理原则是,尽量保持接口的可复用性,但针对特殊情况,允许提供冗余的门面接口,来提供更易用的接口 + + +## 思考 +适配器模式和门面模式(外观模式)的共同点都是将不好用的接口适配成好用的接口。那区别是什么? +- 适配器模式强调的是接口转换,一些三方、二方设计不好的接口包装成符合设计预期的接口,解决的是原接口和目标接口不匹配的问题 +- 门面模式强调的是接口的设计,将几个小接口包装成一个大接口,方便调用(不用去关心那么多小接口),解决的是多接口调用的问题 \ No newline at end of file diff --git a/Chapter6 - Design Pattern/6.18.md b/Chapter6 - Design Pattern/6.18.md new file mode 100644 index 0000000..dd1dcaf --- /dev/null +++ b/Chapter6 - Design Pattern/6.18.md @@ -0,0 +1,172 @@ +# 组合模式 + +## 定义 +在 GoF 的《设计模式》一书中,组合模式是这样定义的: +> Compose objects into tree structure to represent part-whole hierarchies.Composite lets client treat individual objects and compositions of objects uniformly. +翻译成中文就是:将一组对象组织(Compose)成树形结构,以表示一种“部分 - 整体”的层次结构。组合让客户端(在很多设计模式书籍中,“客户端”代指代码的使用者。)可以统一单个对象和组合对象的处理逻辑 + +## 应用场景 + +假设我们有这样一个需求:设计一个类来表示文件系统中的目录,能方便地实现下面这些功 +能: +我们把文件和目录统一用 FileSystemNode 类来表示,并且通过 isFile 属性来区分。动态地添加、删除某个目录下的子目录或文件;统计指定目录下的文件个数;统计指定目录下的文件总大小。在下面的代码实现中,我们把文件和目录统一用 FileSystemNode 类来表示,并且通过 isFile 属性来区分 + +``` +public class FileSystemNode { + private String path; + private boolean isFile; + private List subNodes = new ArrayList<>(); + public FileSystemNode(String path, boolean isFile) { + this.path = path; + this.isFile = isFile; + } + public int countNumOfFiles() { + if (isFile) { + return 1; + } + int numOfFiles = 0; + for (FileSystemNode fileOrDir : subNodes) { + numOfFiles += fileOrDir.countNumOfFiles(); + } + return numOfFiles; + } + public long countSizeOfFiles() { + if (isFile) { + File file = new File(path); + if (!file.exists()) return 0; + return file.length(); + } + long sizeofFiles = 0; + for (FileSystemNode fileOrDir : subNodes) { + sizeofFiles += fileOrDir.countSizeOfFiles(); + } + return sizeofFiles; + } + public String getPath() { + return path; + } + public void addSubNode(FileSystemNode fileOrDir) { + subNodes.add(fileOrDir); + } + public void removeSubNode(FileSystemNode fileOrDir) { + int size = subNodes.size(); + int i = 0; + for (; i < size; ++i) { + if (subNodes.get(i).getPath().equalsIgnoreCase(fileOrDir.getPath())) { + break; + } + } + if (i < size) { + subNodes.remove(i); + } + } +} +``` + +单纯从功能实现角度来说,上面的代码没有问题,已经实现了我们想要的功能。但是,如果我们开发的是一个大型系统,从扩展性(文件或目录可能会对应不同的操作)、业务建模(文件和目录从业务上是两个概念)、代码的可读性(文件和目录区分对待更加符合人们对业务的认知)的角度来说,我们最好对文件和目录进行区分设计,定义为 File 和 Directory 两个类。 + +按照这个设计思路,我们对代码进行重构。重构之后的代码如下所示 + +``` +public abstract class FileSystemNode { + protected String path; + public FileSystemNode(String path) { + this.path = path; + } + public abstract int countNumOfFiles(); + public abstract long countSizeOfFiles(); + public String getPath() { + return path; + } +} +public class File extends FileSystemNode { + public File(String path) { + super(path); + } + @Override + public int countNumOfFiles() { + return 1; + } + @Override + public long countSizeOfFiles() { + java.io.File file = new java.io.File(path); + if (!file.exists()) return 0; + return file.length(); + } +} + +public class Directory extends FileSystemNode { + private List subNodes = new ArrayList<>(); + public Directory(String path) { + super(path); + } + @Override + public int countNumOfFiles() { + int numOfFiles = 0; + for (FileSystemNode fileOrDir : subNodes) { + numOfFiles += fileOrDir.countNumOfFiles(); + } + return numOfFiles; + } + @Override + public long countSizeOfFiles() { + long sizeofFiles = 0; + for (FileSystemNode fileOrDir : subNodes) { + sizeofFiles += fileOrDir.countSizeOfFiles(); + } + return sizeofFiles; + } + public void addSubNode(FileSystemNode fileOrDir) { + subNodes.add(fileOrDir); + } + public void removeSubNode(FileSystemNode fileOrDir) { + int size = subNodes.size(); + int i = 0; + for (; i < size; ++i) { + if (subNodes.get(i).getPath().equalsIgnoreCase(fileOrDir.getPath())) { + break; + } + } + if (i < size) { + subNodes.remove(i); + } + } +} +``` +文件和目录类都设计好了,我们来看,如何用它们来表示一个文件系统中的目录树结构。具体的代码示例如下所示 +``` +public class Demo { + public static void main(String[] args) { + Directory fileSystemTree = new Directory("/"); + Directory node_my = new Directory("/meiying/"); + Directory node_lbp = new Directory("/my/"); + fileSystemTree.addSubNode(node_lbp); + fileSystemTree.addSubNode(node_lbp); + File node_lbp_a = new File("/meiying/a.txt"); + File node_lbp_b = new File("/meiying/b.txt"); + Directory node_lbp_movies = new Directory("/meiying/movies/"); + node_lbp.addSubNode(node_lbp_a); + node_lbp.addSubNode(node_lbp_b); + node_lbp.addSubNode(node_lbp_movies); + File node_lbp_movies_c = new File("/meiying/movies/c.avi"); + node_lbp_movies.addSubNode(node_lbp_movies_c); + Directory node_lbp_docs = new Directory("/xzg/docs/"); + node_lbp.addSubNode(node_lbp_docs); + File node_lbp_docs_d = new File("/xzg/docs/d.txt"); + node_lbp_docs.addSubNode(node_lbp_docs_d); + System.out.println("/ files num:" + fileSystemTree.countNumOfFiles()); + System.out.println("/meiying/ files num:" + node_lbp.countNumOfFiles()); + } +} +``` +对照例子,重新审视下组合模式:将一组对象(文件和目录)组织成树形结构,以表示一种“部分-整体”的层次结构(目录与子目录的嵌套结构)。组合模 +式让客户端可以统一单个对象(文件)和组合对象(目录)的处理逻辑(递归遍历) + + +实际上,刚才讲的这种组合模式的设计思路,与其说是一种设计模式,倒不如说是对业务场景的一种数据结构和算法的抽象。其中,数据可以表示成树这种数据结构,业务需求可以通过在树上的递归遍历算法来实现。 + +## 思考 +组合模式的设计思路,与其说是一种设计模式,倒不如说是对业务场景的一种数据结构和算法的抽象。其中,数据可以表示成树这种数据结构,业务需求可以通过在树上的递归遍历算法来实现。 + +组合模式,将一组对象组织成树形结构,将单个对象和组合对象都看做树中的节点,以统一处理逻辑,并且它利用树形结构的特点,递归地处理每个子树,依次简化代码实现。使用组合模式的前提在于,你的业务场景必须能够表示成树形结构。所以,组合模式的应用场景也比较局限,它并不是一种很常用的设计模式。 + diff --git a/Chapter6 - Design Pattern/6.19.md b/Chapter6 - Design Pattern/6.19.md new file mode 100644 index 0000000..7e375de --- /dev/null +++ b/Chapter6 - Design Pattern/6.19.md @@ -0,0 +1,223 @@ +# 享元模式 + +## 定义 +“享元”,顾名思义就是被共享的单元。享元模式的意图是复用对象,节省内存,前提是享元对象是不可变对象。 + +当一个系统中存在大量重复对象的时候,如果这些重复的对象是不可变对象,我们就可以利用享元模式将对象设计成享元,在内存中只保留一份实例,供多处代码引用。这样可以减少内存中对象的数量,起到节省内存的目的。实际上,不仅仅相同对象可以设计成享元,对于相似对象,我们也可以将这些对象中相同的部分(字段)提取出来,设计成享元,让这些大量相似对象引用这些享元。 + +不可变对象”指的是,一旦通过构造函数初始化完成之后,它的状态(对象的成员变量或者属性)就不会再被修改了。所以,不可变对象不能暴露任何 set() 等修改内部状态的方法。之所以要求享元是不可变对象,那是因为它会被多处代码共享使用,避免一处代码对享元进行了修改,影响到其他使用它的代码。 + +## 实现 +享元模式的代码实现非常简单,主要是通过工厂模式,在工厂类中,通过一个 Map 或者 List 来缓存已经创建好的享元对象,以达到复用的目的 + +假设我们在开发一个棋牌游戏(比如象棋)。一个游戏厅中有成千上万个“房间”,每个房间对应一个棋局。棋局要保存每个棋子的数据,比如:棋子类型(将、相、士、炮等)、棋子颜色(红方、黑方)、棋子在棋局中的位置。利用这些数据,我们就能显示一个完整的棋盘给玩家。具体的代码如下所示。其中,ChessPiece 类表示棋子,ChessBoard 类表示一个棋局,里面保存了象棋中 30 个棋子的信息 +``` +public class ChessPiece {//棋子 + private int id; + private String text; + private Color color; + private int positionX; + private int positionY; + public ChessPiece(int id, String text, Color color, int positionX, int position) { + this.id = id; + this.text = text; + this.color = color; + this.positionX = positionX; + this.positionY = positionX; + } + + public static enum Color { + RED, BLACK + } + // ...省略其他属性和getter/setter方法... +} + +public class ChessBoard {//棋局 + private Map chessPieces = new HashMap<>(); + public ChessBoard() { + init(); + } + private void init() { + chessPieces.put(1, new ChessPiece(1, "車", ChessPiece.Color.BLACK, 0, 0)); + chessPieces.put(2, new ChessPiece(2,"馬", ChessPiece.Color.BLACK, 0, 1)); + //...省略摆放其他棋子的代码... + } + public void move(int chessPieceId, int toPositionX, int toPositionY) { + //...省略... + } +} +``` +为了记录每个房间当前的棋局情况,我们需要给每个房间都创建一个 ChessBoard 棋局对象。因为游戏大厅中有成千上万的房间(实际上,百万人同时在线的游戏大厅也有很多),那保存这么多棋局对象就会消耗大量的内存。有没有什么办法来节省内存呢?这个时候,享元模式就可以派上用场了。像刚刚的实现方式,在内存中会有大量的相似对象。这些相似对象的 id、text、color 都是相同的,唯独 positionX、positionY 不同。实际上,我们可以将棋子的 id、text、color 属性拆分出来,设计成独立的类,并且作为享元供多个棋盘复用。这样,棋盘只需要记录每个棋子的位置信息就可以了。具体的代码实现如下所示 +``` +// 享元类 +public class ChessPieceUnit { + private int id; + private String text; + private Color color; + public ChessPieceUnit(int id, String text, Color color) { + this.id = id; + this.text = text; + this.color = color; + } + public static enum Color { + RED, BLACK + } + // ...省略其他属性和getter方法... +} + +public class ChessPieceUnitFactory { + private static final Map pieces = new HashMap<>(); + static { + pieces.put(1, new ChessPieceUnit(1, "車", ChessPieceUnit.Color.BLACK)); + pieces.put(2, new ChessPieceUnit(2,"馬", ChessPieceUnit.Color.BLACK)); + //...省略摆放其他棋子的代码... + } + public static ChessPieceUnit getChessPiece(int chessPieceId) { + return pieces.get(chessPieceId); + } +} + +public class ChessPiece { + private ChessPieceUnit chessPieceUnit; + private int positionX; + private int positionY; + public ChessPiece(ChessPieceUnit unit, int positionX, int positionY) { + this.chessPieceUnit = unit; + this.positionX = positionX; + this.positionY = positionY; + } + // 省略getter、setter方法 +} +public class ChessBoard { + private Map chessPieces = new HashMap<>(); + public ChessBoard() { + init(); + } + private void init() { + chessPieces.put(1, new ChessPiece( + ChessPieceUnitFactory.getChessPiece(1), 0,0)); + chessPieces.put(1, new ChessPiece( + ChessPieceUnitFactory.getChessPiece(2), 1,0)); + //...省略摆放其他棋子的代码... + } + public void move(int chessPieceId, int toPosintionX, int toPositionY) { + // ... + } +} +``` + +在上面的代码实现中,我们利用工厂类来缓存 ChessPieceUnit 信息(也就是 id、text、color)。通过工厂类获取到的 ChessPieceUnit 就是享元。所有的 ChessBoard 对象共享这 30 个 ChessPieceUnit 对象(因为象棋中只有 30 个棋子)。在使用享元模式之前,记录 1 万个棋局,我们要创建 30 万(30*1 万)个棋子的 ChessPieceUnit 对象。利用享元模式,我们只需要创建 30 个享元对象供所有棋局共享使用即可,大大节省了内存。 + +那享元模式的原理讲完了,我们来总结一下它的代码结构。实际上,它的代码实现非常简单,主要是通过工厂模式,在工厂类中,通过一个 Map 来缓存已经创建过的享元对象,来达到复用的目的。 + + +## 场景 +### 享元模式在 Java Integer 中的应用 +``` +Integer i1 = 56; +Integer i2 = 56; +Integer i3 = 129; +Integer i4 = 129; +System.out.println(i1 == i2); // true +System.out.println(i3 == i4); // false +``` +如果不熟悉 Java 语言,你可能会觉得,i1 和 i2 值都是 56,i3 和 i4 值都是 129,i1 跟 i2 值相等,i3 跟 i4 值相等,所以输出结果应该是两个 true。这样的分析是不对的,主要还是因为你对 Java 语法不熟悉。要正确地分析上面的代码,我们需要弄清楚下面两个问题 + +如何判定两个 Java 对象是否相等(也就代码中的“==”操作符的含义)?什么是自动装箱(Autoboxing)和自动拆箱(Unboxing)? + +所谓的自动装箱,就是自动将基本数据类型转换为包装器类型。所谓的自动拆箱,也就是自动将包装器类型转化为基本数据类型。具体的代码示例如下所示: +``` +Integer i = 56; //自动装箱 +int j = i; //自动拆箱 +``` +数值 56 是基本数据类型 int,当赋值给包装器类型(Integer)变量的时候,触发自动装箱操作,创建一个 Integer 类型的对象,并且赋值给变量 i。其底层相当于执行了下面这条语句: +``` +Integer i = 59;底层执行了:Integer i = Integer.valueOf(59); +``` +反过来,当把包装器类型的变量 i,赋值给基本数据类型变量 j 的时候,触发自动拆箱操作,将 i 中的数据取出,赋值给 j。其底层相当于执行了下面这条语句: +``` +int j = i; 底层执行了:int j = i.intValue(); +``` +弄清楚了自动装箱和自动拆箱,我们再来看,如何判定两个对象是否相等?不过,在此之前,我们先要搞清楚,Java 对象在内存中是如何存储的。我们通过下面这个例子来说明一下。 +``` +User a = new User(123, 23); // id=123, age=23 +``` +a 存储的值是 User 对象的内存地址,a 是一个指针,a 的值就是对象的地址值。 + +当我们通过“==”来判定两个对象是否相等的时候,实际上是在判断两个局部变量存储的地址是否相同,换句话说,是在判断两个局部变量是否指向相同的对象。 + +前 4 行赋值语句都会触发自动装箱操作,也就是会创建 Integer 对象并且赋值给 i1、i2、i3、i4 这四个变量。根据刚刚的讲解,i1、i2 尽管存储的数值相同,都是 56,但是指向不同的 Integer 对象,所以通过“==”来判定是否相同的时候,会返回 false。同理,i3==i4 判定语句也会返回 false + + +不过,上面的分析还是不对,答案并非是两个 false,而是一个 true,一个 false。看到这里,你可能会比较纳闷了。实际上,这正是因为 Integer 用到了享元模式来复用对象,才导致了这样的运行结果。当我们通过自动装箱,也就是调用 valueOf() 来创建 Integer 对象的时候,如果要创建的 Integer 对象的值在 -128 到 127 之间,会从 IntegerCache 类中直接返回,否则才调用 new 方法创建。看代码更加清晰一些,Integer 类的 valueOf() 函数的具体代码如下所示 +``` +public static Integer valueOf(int i) { + if (i >= IntegerCache.low && i <= IntegerCache.high) + return IntegerCache.cache[i + (-IntegerCache.low)]; + return new Integer(i); +} +``` +实际上,这里的 IntegerCache 相当于,我们上一节课中讲的生成享元对象的工厂类,只不过名字不叫 xxxFactory 而已。我们来看它的具体代码实现。这个类是 Integer 的内部类,你也可以自行查看 JDK 源码 +``` +** +* Cache to support the object identity semantics of autoboxing for values betw +* -128 and 127 (inclusive) as required by JLS. +* +* The cache is initialized on first usage. The size of the cache +* may be controlled by the {@code -XX:AutoBoxCacheMax=} option. +* During VM initialization, java.lang.Integer.IntegerCache.high property +* may be set and saved in the private system properties in the +* sun.misc.VM class. +*/ +private static class IntegerCache { + static final int low = -128; + static final int high; + static final Integer cache[]; + static { + // high value may be configured by property + int h = 127; + String integerCacheHighPropValue = + sun.misc.VM.getSavedProperty("java.lang.Integer.IntegerCache.high") + if (integerCacheHighPropValue != null) { + try { + int i = parseInt(integerCacheHighPropValue); + i = Math.max(i, 127); + // Maximum array size is Integer.MAX_VALUE + h = Math.min(i, Integer.MAX_VALUE - (-low) -1); + } catch( NumberFormatException nfe) { + // If the property cannot be parsed into an int, ignore it. + } + } + high = h; + cache = new Integer[(high - low) + 1]; + int j = low; + for(int k = 0; k < cache.length; k++) + cache[k] = new Integer(j++); + // range [-128, 127] must be interned (JLS7 5.1.7) + assert IntegerCache.high >= 127; + } + private IntegerCache() {} +} +``` +回到问题,56处于-128和127之间,i1 和 i2 会指向相同的享元对象,所以第一个为 true,而129大于127,不会被缓存,所以每次都是一个新的对象,也就是i3和i4指向2个不同的 Integer 对象,所以第二个为 false。 + + +## 对比 +在上面的讲解中,我们多次提到“共享”“缓存”“复用”这些字眼,那它跟单例、缓存、对象池这些概念有什么区别呢? + +### 享元模式跟单例的区别 +在单例模式中,一个类只能创建一个对象,而在享元模式中,一个类可以创建多个对象,每个对象被多处代码引用共享。实际上,享元模式有点类似于之前讲到的单例的变体:多例。 + +我们前面也多次提到,区别两种设计模式,不能光看代码实现,而是要看设计意图,也就是要解决的问题。尽管从代码实现上来看,享元模式和多例有很多相似之处,但从设计意图上来看,它们是完全不同的。应用享元模式是为了对象复用,节省内存,而应用多例模式是为了限制对象的个数 + +### 享元模式跟缓存的区别 +在享元模式的实现中,我们通过工厂类来“缓存”已经创建好的对象。这里的“缓存”实际上是“存储”的意思,跟我们平时所说的“数据库缓存”“CPU 缓存”“MemCache 缓存”是两回事。我们平时所讲的缓存,主要是为了提高访问效率,而非复用 + +### 享元模式跟对象池的区别 +你可能对连接池、线程池比较熟悉,对对象池比较陌生,所以,这里我简单解释一下对象池。像 C++ 这样的编程语言,内存的管理是由程序员负责的。为了避免频繁地进行对象创建和释放导致内存碎片,我们可以预先申请一片连续的内存空间,也就是这里说的对象池。每次创建对象时,我们从对象池中直接取出一个空闲对象来使用,对象使用完成之后,再放回到对象池中以供后续复用,而非直接释放掉。 + +虽然对象池、连接池、线程池、享元模式都是为了复用,但是,如果我们再细致地抠一抠“复用”这个字眼的话,对象池、连接池、线程池等池化技术中的“复用”和享元模式中的“复用”实际上是不同的概念。 + +池化技术中的“复用”可以理解为“重复使用”,主要目的是节省时间(比如从数据库池中取一个连接,不需要重新创建)。在任意时刻,每一个对象、连接、线程,并不会被多处使用,而是被一个使用者独占,当使用完成之后,放回到池中,再由其他使用者重复利用。享元模式中的“复用”可以理解为“共享使用”,在整个生命周期中,都是被所有使用者共享的,主要目的是节省空间。 + + diff --git a/Chapter6 - Design Pattern/6.2.md b/Chapter6 - Design Pattern/6.2.md new file mode 100644 index 0000000..3f8b414 --- /dev/null +++ b/Chapter6 - Design Pattern/6.2.md @@ -0,0 +1,162 @@ +# 面向对象 + + +## 封装、抽象、继承、多态分别可以解决什么编程问题 + +### 封装 +封装也叫作信息隐藏或者数据访问保护。类通过暴露有限的访问接口,授权外部仅能通过类提供的方式(或者叫函数)来访问内部信息或者数据。 + +对于封装这个特性,我们需要编程语言本身提供一定的语法机制来支持。这个语法机制就是访问权限控制。private、public 等关键字就是 Java 语言中的访问权限控制语法。private 关键字修饰的属性只能类本身访问,可以保护其不被类之外的代码直接访问。如果 Java 语言没有提供访问权限控制语法,所有的属性默认都是 public 的,那任意外部代码都可以通过类似 wallet.id=123; 这样的方式直接访问、修改属性,也就没办法达到隐藏信息和保护数据的目的了,也就无法支持封装特性了。 + +如果我们对类中属性的访问不做限制,那任何代码都可以访问、修改类中的属性,虽然这样看起来更加灵活,但从另一方面来说,过度灵活也意味着不可控,属性可以随意被以各种奇葩的方式修改,而且修改逻辑可能散落在代码中的各个角落,势必影响代码的可读性、可维护性。 + +除此之外,类仅仅通过有限的方法暴露必要的操作,也能提高类的易用性。如果我们把类属性都暴露给类的调用者,调用者想要正确地操作这些属性,就势必要对业务细节有足够的了解。而这对于调用者来说也是一种负担。相反,如果我们将属性封装起来,暴露少许的几个必要的方法给调用者使用,调用者就不需要了解太多背后的业务细节,用错的概率就减少很多。这就好比,如果一个冰箱有很多按钮,你就要研究很长时间,还不一定能操作正确。相反,如果只有几个必要的按钮,比如开、停、调节温度,你一眼就能知道该如何来操作,而且操作出错的概率也会降低很多。(迪米特法则、最小知识原则) + + +### 抽象 +封装主要讲的是如何隐藏信息、保护数据,而抽象讲的是如何隐藏方法的具体实现,让调用者只需要关心方法提供了哪些功能,并不需要知道这些功能是如何实现的。 + +在面向对象编程中,我们常借助编程语言提供的接口类(比如 Java 中的 interface 关键字语法)或者抽象类(比如 Java 中的 abstract 关键字语法)这两种语法机制,来实现抽象这一特性。 + +换一个角度来考虑,我们在定义(或者叫命名)类的方法的时候,也要有抽象思维,不要在方法定义中,暴露太多的实现细节,以保证在某个时间点需要改变方法的实现逻辑的时候,不用去修改其定义。举个简单例子,比如 getAliyunPictureUrl() 就不是一个具有抽象思维的命名,因为某一天如果我们不再把图片存储在阿里云上,而是存储在私有云上,那这个命名也要随之被修改。相反,如果我们定义一个比较抽象的函数,比如叫作 getPictureUrl(),那即便内部存储方式修改了,我们也不需要修改命名。 + +客户端 SDK 在设计对外暴露的属性的时候,也遵循这个原则,对外提供通用的 api 协议,而不是具体的方法,后期业务变动或者底层实现在做替换的时候,不需要改方法名,对调用者的影响最小。 + +### 继承 +继承是用来表示类之间的 is-a 关系,比如猫是一种哺乳动物。从继承关系上来讲,继承可以分为两种模式,单继承和多继承。单继承表示一个子类只继承一个父类,多继承表示一个子类可以继承多个父类,比如猫既是哺乳动物,又是爬行动物 + +继承最大的一个好处就是代码复用。假如两个类有一些相同的属性和方法,我们就可以将这些相同的部分,抽取到父类中,让两个子类继承父类。这样,两个子类就可以重用父类中的代码,避免代码重复写多遍。 + +某些编程语言不支持多重继承的原因?具有副作用,菱形继承问题(钻石问题、决议问题、二义性问题) +假设类 B、类C继承自类 A,且都重写了类 A 的同一个方法,而类 D 同时继承了类B、类C,那么此时类 D 会继承类B、C重写的A的方法,类D会选择继承哪一个呢?会产生歧义。Java8 的 interface 可以有默认方法实现,曲线救国。 + +Objective-C 没有多继承。为什么?如何实现? +消息机制名字查找发生在运行时而非编译时,很难解决多个基类可能导致的二义性问题 + +可以利用以下方式实现多继承 +- 组合 +- 协议 +- Category +- Runtime 消息转发 +- NSProxy + +### 多态 +多态是指,子类可以替换父类,在实际的代码运行过程中,调用子类的方法实现。多态可以提高代码可拓展性和复用性。 + + +## 基于接口而非实现编程 +越抽象、越顶层、越脱离具体某一实现的设计,越能提高代码的灵活性,越能应对未来的需求变化。好的代码设计,不仅能应对当下的需求,而且在将来需求发生变化的时候,仍然能够在不破坏原有代码设计的情况下灵活应对。 + +什么情况下需要思考设计接口? +这条原则的设计初衷是,将接口和实现相分离,封装不稳定的实现,暴露稳定的接口。上游系统面向接口而非实现编程,不依赖不稳定的实现细节,这样当实现发生变化的时候,上游系统的代码基本上不需要做改动,以此来降低代码间的耦合性,提高代码的扩展性。 + +从这个设计初衷上来看,如果在我们的业务场景中,某个功能只有一种实现方式,未来也不可能被其他实现方式替换,那我们就没有必要为其设计接口,也没有必要基于接口编程,直接使用实现类就可以了。 + +除此之外,越是不稳定的系统,我们越是要在代码的扩展性、维护性上下功夫。相反,如果某个系统特别稳定,在开发完之后,基本上不需要做维护,那我们就没有必要为其扩展性, + + +## 多用组合少用继承? +继承是面向对象的四大特性之一,用来表示类之间的 is-a 关系,可以解决代码复用的问题。虽然继承有诸多作用,但继承层次过深、过复杂,也会影响到代码的可维护性。 + +举个例子 + +假设我们要设计一个关于鸟的类。我们将“鸟类”这样一个抽象的事物概念,定义为一个抽象类 AbstractBird。所有更细分的鸟,比如麻雀、鸽子、乌鸦等,都继承这个抽象类。 + +我们知道,大部分鸟都会飞,那我们可不可以在 AbstractBird 抽象类中,定义一个 fly() 方法呢?答案是否定的。尽管大部分鸟都会飞,但也有特例,比如鸵鸟就不会飞。鸵鸟继承具有 fly() 方法的父类,那鸵鸟就具有“飞”这样的行为,这显然不符合我们对现实世界中事物的认识。当然,你可能会说,我在鸵鸟这个子类中重写(override)fly() 方法,让它抛出 UnSupportedMethodException 异常不就可以了吗?具体的代码实现如下所示 + +``` +public class AbstractBird { +//... 省略其他属性和方法... +public void fly() { //... } +} + +public class Ostrich extends AbstractBird { // 鸵鸟 + //... 省略其他属性和方法... + public void fly() { + throw new UnSupportedMethodException("I can't fly.'"); + } +} +``` + +这种设计思路虽然可以解决问题,但不够优美。因为除了鸵鸟之外,不会飞的鸟还有很多,比如企鹅。对于这些不会飞的鸟来说,我们都需要重写 fly() 方法,抛出异常。这样的设计,一方面,徒增了编码的工作量;另一方面,也违背了我们之后要讲的最小知识原则(Least Knowledge Principle,也叫最少知识原则或者迪米特法则),暴露不该暴露的接口给外部,增加了类使用过程中被误用的概率。 + +你可能又会说,那我们再通过 AbstractBird 类派生出两个更加细分的抽象类:会飞的鸟类 AbstractFlyableBird 和不会飞的鸟类 AbstractUnFlyableBird,让麻雀、乌鸦这些会飞的鸟都继承 AbstractFlyableBird,让鸵鸟、企鹅这些不会飞的鸟,都继承 AbstractUnFlyableBird 类,不就可以了吗?具体的继承关系如下图所示: +![](./../assets/oop-mixBetterThanSuper.png) + + +从图中我们可以看出,继承关系变成了三层。不过,整体上来讲,目前的继承关系还比较简单,层次比较浅,也算是一种可以接受的设计思路。我们再继续加点难度。在刚刚这个场景中,我们只关注“鸟会不会飞”,但如果我们还关注“鸟会不会叫”,那这个时候,我们又该如何设计类之间的继承关系呢? + +是否会飞?是否会叫?两个行为搭配起来会产生四种情况:会飞会叫、不会飞会叫、会飞不会叫、不会飞不会叫。如果我们继续沿用刚才的设计思路,那就需要再定义四个抽象类(AbstractFlyableTweetableBird、AbstractFlyableUnTweetableBird、AbstractUnFlyableTweetableBird、AbstractUnFlyableUnTweetableBird)。 + +会不会下蛋、会不会打猎,是不是组合就爆炸了? +总之,继承最大的问题就在于:继承层次过深、继承关系过于复杂会影响到代码的可读性和可维护性。这也是为什么业界不推荐使用继承而推荐组合。 + +实际上,我们可以利用组合(composition)、接口、委托(delegation)三个技术手段,一块儿来解决刚刚继承存在的问题。 + +我们前面讲到接口的时候说过,接口表示具有某种行为特性。针对“会飞”这样一个行为特性,我们可以定义一个 Flyable 接口,只让会飞的鸟去实现这个接口。对于会叫、会下蛋这些行为特性,我们可以类似地定义 Tweetable 接口、EggLayable 接口。我们将这个设计思路翻译成 Java 代码的话,就是下面这个样子 +``` +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 impelents Flayable, Tweetable, EggLayable {// 麻雀 + //... 省略其他属性和方法... + @Override + public void fly() { //... } + @Override + public void tweet() { //... } + @Override + public void layEgg() { //... } +} +``` + +不过,我们知道,接口只声明方法,不定义实现。也就是说,每个会下蛋的鸟都要实现一遍 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(); // 委托 + } +} +``` +我们知道继承主要有三个作用:表示 is-a 关系,支持多态特性,代码复用。而这三个作用都可以通过其他技术手段来达成。比如 is-a 关系,我们可以通过组合和接口的 has-a 关系来替代;多态特性我们可以利用接口来实现;代码复用我们可以通过组合和委托来实现。所以,从理论上讲,通过组合、接口、委托三个技术手段,我们完全可以替换掉继承,在项目中不用或者少用继承关系,特别是一些复杂的继承关系。 + + +如何判断该用组合还是继承? +尽管我们鼓励多用组合少用继承,但组合也并不是完美的,继承也并非一无是处。在实际的项目开发中,我们还是要根据具体的情况,来选择该用继承还是组合。如果类之间的继承结构稳定,层次比较浅,关系不复杂,我们就可以大胆地使用继承。反之,我们就尽量使用组合来替代继承。除此之外,还有一些设计模式、特殊的应用场景,会固定使用继承或者组合。 + + diff --git a/Chapter6 - Design Pattern/6.20.md b/Chapter6 - Design Pattern/6.20.md new file mode 100644 index 0000000..47ccd4e --- /dev/null +++ b/Chapter6 - Design Pattern/6.20.md @@ -0,0 +1,486 @@ +# 观察者模式 + +> 我们常把 23 种经典的设计模式分为三类:创建型、结构型、行为型。前面我们已经学习了创建型和结构型,从今天起,我们开始学习行为型设计模式。我们知道,创建型设计模式主要解决“对象的创建”问题,结构型设计模式主要解决“类或对象的组合或组装”问题,那行为型设计模式主要解决的就是“类或对象之间的交互”问题。 + +## 定义 +观察者模式(Observer Design Pattern)也被称为发布订阅模式(Publish-Subscribe Design Pattern)。在 GoF 的《设计模式》一书中,它的定义是这样的 +> Define a one-to-many dependency between objects so that when one object changes state, all its dependents are notified and updated automatically. +在对象之间定义一个一对多的依赖,当一个对象状态改变的时候,所有依赖的对象都会自动收到通知。 + +实际上,观察者模式是一个比较抽象的模式,根据不同的应用场景和需求,有完全不同的实现方式,待会我们会详细地讲到。现在,我们先来看其中最经典的一种实现方式。这也是在讲到这种模式的时候,很多书籍或资料给出的最常见的实现方式。具体的代码如下所示: + +``` +public interface Subject { + void registerObserver(Observer observer); + void removeObserver(Observer observer); + void notifyObservers(Message message); +} + +public interface Observer { + void update(Message message); +} + +public class ConcreteSubject implements Subject { + private List observers = new ArrayList(); + @Override + public void registerObserver(Observer observer) { + observers.add(observer); + } + @Override + public void removeObserver(Observer observer) { + observers.remove(observer); + } + @Override + public void notifyObservers(Message message) { + for (Observer observer : observers) { + observer.update(message); + } + } +} + +public class ConcreteObserverOne implements Observer { + @Override + public void update(Message message) { + //TODO: 获取消息通知,执行自己的逻辑... + System.out.println("ConcreteObserverOne is notified."); + } +} +public class ConcreteObserverTwo implements Observer { + @Override + public void update(Message message) { + //TODO: 获取消息通知,执行自己的逻辑... + System.out.println("ConcreteObserverTwo is notified."); + } +} +public class Demo { + public static void main(String[] args) { + ConcreteSubject subject = new ConcreteSubject(); + subject.registerObserver(new ConcreteObserverOne()); + subject.registerObserver(new ConcreteObserverTwo()); + subject.notifyObservers(new Message()); + } +} +``` +实际上,上面的代码算是观察者模式的“模板代码”,只能反映大体的设计思路。在真实的软件开发中,并不需要照搬上面的模板代码。观察者模式的实现方法各式各样,函数、类的命名等会根据业务场景的不同有很大的差别,比如 register 函数还可以叫作 attach, remove 函数还可以叫作 detach 等等。不过,万变不离其宗,设计思路都是差不多的。 + +## 场景 + +假设我们在开发一个 P2P 投资理财系统,用户注册成功之后,我们会给用户发放投资体验金。代码实现大致是下面这个样子的: +``` +public class UserController { + private UserService userService; // 依赖注入 + private PromotionService promotionService; // 依赖注入 + public Long register(String telephone, String password) { + //省略输入参数的校验代码 + //省略userService.register()异常的try-catch代码 + long userId = userService.register(telephone, password); + promotionService.issueNewUserExperienceCash(userId); + return userId; + } +} +``` +虽然注册接口做了两件事情,注册和发放体验金,违反单一职责原则,但是,如果没有扩展和修改的需求,现在的代码实现是可以接受的。如果非得用观察者模式,就需要引入更多的类和更加复杂的代码结构,反倒是一种过度设计。 + +相反,如果需求频繁变动,比如,用户注册成功之后,不再发放体验金,而是改为发放优惠券,并且还要给用户发送一封“欢迎注册成功”的站内信。这种情况下,我们就需要频繁地修改 register() 函数中的代码,违反开闭原则。而且,如果注册成功之后需要执行的后续操作越来越多,那 register() 函数的逻辑会变得越来越复杂,也就影响到代码的可读性和可维护性 + +用观察者模式进行改造 +``` +public interface RegObserver { + void handleRegSuccess(long userId); +} +public class RegPromotionObserver implements RegObserver { + private PromotionService promotionService; // 依赖注入 + @Override + public void handleRegSuccess(long userId) { + promotionService.issueNewUserExperienceCash(userId); + } +} +public class RegNotificationObserver implements RegObserver { + private NotificationService notificationService; + @Override + public void handleRegSuccess(long userId) { + notificationService.sendInboxMessage(userId, "Welcome..."); + } +} +public class UserController { + private UserService userService; // 依赖注入 + private List regObservers = new ArrayList<>(); + // 一次性设置好,之后也不可能动态的修改 + public void setRegObservers(List observers) { + regObservers.addAll(observers); + } + public Long register(String telephone, String password) { + //省略输入参数的校验代码 + //省略userService.register()异常的try-catch代码 + long userId = userService.register(telephone, password); + for (RegObserver observer : regObservers) { + observer.handleRegSuccess(userId); + } + return userId; + } +} +``` +当我们需要添加新的观察者的时候,比如,用户注册成功之后,推送用户注册信息给大数据征信系统,基于观察者模式的代码实现,UserController 类的 register() 函数完全不需要修改,只需要再添加一个实现了 RegObserver 接口的类,并且通过 setRegObservers()函数将它注册到 UserController 类中即可 + +当我们把发送体验金替换为发送优惠券的时候,需要修改 RegPromotionObserver 类中 handleRegSuccess() 函数的代码,这还是违反开闭原则呀?你说得没错,不过,相对于 register() 函数来说,handleRegSuccess() 函数的逻辑要简单很多,修改更不容易出错,引入 bug 的风险更低。 + +**设计模式要干的事情就是解耦。创建型模式是将创建和使用代码解耦,结构型模式是将不同功能代码解耦,行为型模式是将不同的行为代码解耦,具体到观察者模式,它是将观察者和被观察者代码解耦。借助设计模式,我们利用更好的代码结构,将一大坨代码拆分成职责更单一的小类,让其满足开闭原则、高内聚松耦合等特性,以此来控制和应对代码的复杂性,提高代码的可扩展性**。 + + +## 实现方式 +观察者模式的应用场景非常广泛,小到代码层面的解耦,大到架构层面的系统解耦,再或者一些产品的设计思路,都有这种模式的影子,比如,邮件订阅、RSS Feeds,本质上都是观察者模式 + +不同的应用场景和需求下,这个模式也有截然不同的实现方式,开篇的时候我们也提到,有同步阻塞的实现方式,也有异步非阻塞的实现方式;有进程内的实现方式,也有跨进程的实现方式 + +之前讲到的实现方式,从刚刚的分类方式上来看,它是一种同步阻塞的实现方式。观察者和被观察者代码在同一个线程内执行,被观察者一直阻塞,直到所有的观察者代码都执行完成之后,才执行后续的代码。对照上面讲到的用户注册的例子,register() 函数依次调用执行每个观察者的 handleRegSuccess() 函数,等到都执行完成之后,才会返回结果给客户端。 + +如果注册接口是一个调用比较频繁的接口,对性能非常敏感,希望接口的响应时间尽可能短,那我们可以将同步阻塞的实现方式改为异步非阻塞的实现方式,以此来减少响应时间。具体来讲,当 userService.register() 函数执行完成之后,我们启动一个新的线程来执行观察者的 handleRegSuccess() 函数,这样userController.register() 函数就不需要等到所有的 handleRegSuccess() 函数都执行完成之后才返回结果给客户端。userController.register() 函数从执行 3 个 SQL 语句才返回,减少到只需要执行 1 个 SQL 语句就返回,响应时间粗略来讲减少为原来的 1/3。 + +如何实现一个异步非阻塞的观察者模式呢?简单做法有两种实现方式。其中一种是:在每个 handleRegSuccess() 函数中创建一个新的线程执行代码逻辑;另一种是:在 UserController 的 register() 函数中使用线程池来执行每个观察者的 handleRegSuccess() 函数。两种实现方式的具体代码如下所示 +``` +// 第一种实现方式,其他类代码不变,就没有再重复罗列 +public class RegPromotionObserver implements RegObserver { + private PromotionService promotionService; // 依赖注入 + @Override + public void handleRegSuccess(long userId) { + Thread thread = new Thread(new Runnable() { + @Override + public void run() { + promotionService.issueNewUserExperienceCash(userId); + } + }); + thread.start(); + } +} + +// 第二种实现方式,其他类代码不变,就没有再重复罗列 +public class UserController { + private UserService userService; // 依赖注入 + private List regObservers = new ArrayList<>(); + private Executor executor; + public UserController(Executor executor) { + this.executor = executor; + } + public void setRegObservers(List observers) { + regObservers.addAll(observers); + } + public Long register(String telephone, String password) { + //省略输入参数的校验代码 + //省略userService.register()异常的try-catch代码 + long userId = userService.register(telephone, password); + for (RegObserver observer : regObservers) { + executor.execute(new Runnable() { + @Override + public void run() { + observer.handleRegSuccess(userId); + } + }); + } + return userId; + } +} +``` +对于第一种实现方式,频繁地创建和销毁线程比较耗时,并且并发线程数无法控制,创建过多的线程会导致堆栈溢出。第二种实现方式,尽管利用了线程池解决了第一种实现方式的问题,但线程池、异步执行逻辑都耦合在了 register() 函数中,增加了这部分业务代码的维护成本。 + +框架的作用有:隐藏实现细节,降低开发难度,做到代码复用,解耦业务与非业务代码,让程序员聚焦业务开发。针对异步非阻塞观察者模式,我们也可以将它抽象成框架来达到这样的效果,而这个框架就是接下来要研究的 EventBus(借鉴 Google Guava EventBus 框架的设计思想,手把手带你开发一个支持异步非阻塞的 EventBus 框架)。它可以复用在任何需要异步非阻塞观察者模式的应用场景中。 + +刚刚讲到的两个场景,不管是同步阻塞实现方式还是异步非阻塞实现方式,都是进程内的实现方式。如果用户注册成功之后,我们需要发送用户信息给大数据征信系统,而大数据征信系统是一个独立的系统,跟它之间的交互是跨不同进程的,那如何实现一个跨进程的观察者模式呢? + +如果大数据征信系统提供了发送用户注册信息的 RPC 接口,我们仍然可以沿用之前的实现思路,在 handleRegSuccess() 函数中调用 RPC 接口来发送数据。但是,我们还有更加优雅、更加常用的一种实现方式,那就是基于消息队列(Message Queue,比如 ActiveMQ)来实现。 + +当然,这种实现方式也有弊端,那就是需要引入一个新的系统(消息队列),增加了维护成本。不过,它的好处也非常明显。在原来的实现方式中,观察者需要注册到被观察者中,被观察者需要依次遍历观察者来发送消息。而基于消息队列的实现方式,被观察者和观察者解耦更加彻底,两部分的耦合更小。被观察者完全不感知观察者,同理,观察者也完全不感知被观察者。被观察者只管发送消息到消息队列,观察者只管从消息队列中读取消息来执行相应的逻辑。 + +## EventBus 框架功能需求介 +EventBus 翻译为“事件总线”,它提供了实现观察者模式的骨架代码。我们可以基于此框架,非常容易地在自己的业务场景中实现观察者模式,不需要从零开始开发。其中,Google Guava EventBus 就是一个比较著名的 EventBus 框架,它不仅仅支持异步非阻塞模式,同时也支持同步阻塞模式 + +``` +public class UserController { + private UserService userService; // 依赖注入 + private EventBus eventBus; + private static final int DEFAULT_EVENTBUS_THREAD_POOL_SIZE = 20; + public UserController() { + //eventBus = new EventBus(); // 同步阻塞模式 + eventBus = new AsyncEventBus(Executors.newFixedThreadPool(DEFAULT_EVENTBUS_ + } + public void setRegObservers(List observers) { + for (Object observer : observers) { + eventBus.register(observer); + } + } + public Long register(String telephone, String password) { + //省略输入参数的校验代码 + //省略userService.register()异常的try-catch代码 + long userId = userService.register(telephone, password); + eventBus.post(userId); + return userId; + } +} + +public class RegPromotionObserver { + private PromotionService promotionService; // 依赖注入 + @Subscribe + public void handleRegSuccess(long userId) { + promotionService.issueNewUserExperienceCash(userId); + } +} +public class RegNotificationObserver { + private NotificationService notificationService; + @Subscribe + public void handleRegSuccess(long userId) { + notificationService.sendInboxMessage(userId, "..."); + } +} +``` +功能分析: +- register:利用 EventBus 框架实现的观察者模式,跟从零开始编写的观察者模式相比,从大的流程上来说,实现思路大致一样,都需要定义 Observer,并且通过 register() 函数注册 Observer +- post:需要通过调用某个函数(比如,EventBus 中的 post() 函数)来给 Observer 发送消息(在 EventBus 中消息被称作事件 event) + +但在实现细节方面,它们又有些区别。基于 EventBus,我们不需要定义 Observer 接口,任意类型的对象都可以注册到 EventBus 中,通过 @Subscribe 注解来标明类中哪个函数可以接收被观察者发送的消息。 + +我们详细地讲一下,Guava EventBus 的几个主要的类和函数。 +Guava EventBus 对外暴露的所有可调用接口,都封装在 EventBus 类中。其中,EventBus 实现了同步阻塞的观察者模式,AsyncEventBus 继承自 EventBus,提供了异步非阻塞的观察者模式。具体使用方式如下所示: +``` +EventBus eventBus = new EventBus(); // 同步阻塞模式 +EventBus eventBus = new AsyncEventBus(Executors.newFixedThreadPool(8)); // 异步非阻塞方式 +``` +EventBus 类提供了 register() 函数用来注册观察者。具体的函数定义如下所示。它可以接受任何类型(Object)的观察者。而在经典的观察者模式的实现中,register() 函数必须接受实现了同一 Observer 接口的类对象。 +``` +public void register(Object object); +``` +相对于 register() 函数,unregister() 函数用来从 EventBus 中删除某个观察者。 +``` +public void unregister(Object object); +``` +EventBus 类提供了 post() 函数,用来给观察者发送消息 +``` +public void post(Object event); +``` +跟经典的观察者模式的不同之处在于,当我们调用 post() 函数发送消息的时候,并非把消息发送给所有的观察者,而是发送给可匹配的观察者。所谓可匹配指的是,能接收的消息类型是发送消息(post 函数定义中的 event)类型的父类。我举个例子来解释一下。 + +比如,AObserver 能接收的消息类型是 XMsg,BObserver 能接收的消息类型是 YMsg,CObserver 能接收的消息类型是 ZMsg。其中,XMsg 是 YMsg 的父类。当我们如下发送消息的时候,相应能接收到消息的可匹配观察者如下所示: +``` +XMsg xMsg = new XMsg(); +YMsg yMsg = new YMsg(); +ZMsg zMsg = new ZMsg(); +post(xMsg); => AObserver接收到消息 +post(yMsg); => AObserver、BObserver接收到消息 +post(zMsg); => CObserver接收到消息 +``` +EventBus 最特别的一个地方,那就是 @Subscribe 注解。来标记每个 Observer 能接收消息类型的能力 +码如下所示。在 DObserver 类中,我们通过 @Subscribe 注解了两个函数 f1()、f2()。 +``` +public DObserver { + //...省略其他属性和方法... + @Subscribe + public void f1(PMsg event) { //... } + @Subscribe + public void f2(QMsg event) { //... } +} +``` +当通过 register() 函数将 DObserver 类对象注册到 EventBus 的时候,EventBus 会根据 @Subscribe 注解找到 f1() 和 f2(),并且将两个函数能接收的消息类型记录下来(PMsg->f1,QMsg->f2)。当我们通过 post() 函数发送消息(比如 QMsg 消息)的时候,EventBus 会通过之前的记录(QMsg->f2),调用相应的函数(f2)。 + +## 实现 EventBus 框架 +EventBus 中两个核心函数 register() 和 post() 的实现原理。弄懂了它们,基本上就弄懂了整个 EventBus 框架。下面两张图是这两个函数的实现原理图。 + +![](./../assets/EventBus-ObserverRegisterTable.png) +![](./../assets/EventBus-Post.png) + +最关键的一个数据结构是 Observer 注册表,记录了消息类型和可接收消息函数的对应关系。当调用 register() 函数注册观察者的时候,EventBus 通过解析 +@Subscribe 注解,生成 Observer 注册表。 + +当调用 post() 函数发送消息的时候,EventBus 通过注册表找到相应的可接收消息的函数,然后通过 Java 的反射语法来动态地创建对象、执行函数。对于同步阻塞模式,EventBus 在一个线程内依次执行相应的函数。 + +对于异步非阻塞模式,EventBus 通过一个线程池来执行相应的函数。 + +整个小框架的代码实现包括 5 个类:EventBus、AsyncEventBus、Subscribe、ObserverAction、ObserverRegistry。 + +### Subscribe +Subscribe 是一个注解,用于标明观察者中的哪个函数可以接收消息。 +``` +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +@Beta +public @interface Subscribe {} +``` + +### ObserverAction +ObserverAction 类用来表示 @Subscribe 注解的方法,其中,target 表示观察者类, method 表示方法。它主要用在 ObserverRegistry 观察者注册表 +中。 +``` +public class ObserverAction { + private Object target; + private Method method; + public ObserverAction(Object target, Method method) { + this.target = Preconditions.checkNotNull(target); + this.method = method; + this.method.setAccessible(true); + } + public void execute(Object event) { // event是method方法的参数 + try { + method.invoke(target, event); + } catch (InvocationTargetException | IllegalAccessException e) { + e.printStackTrace(); + } + } +} +``` +### ObserverRegistry +ObserverRegistry 类就是前面讲到的 Observer 注册表,是最复杂的一个类,框架中几乎所有的核心逻辑都在这个类中。这个类大量使用了 Java 的反射语法,不过代码整体来说都不难理解,其中,一个比较有技巧的地方是 CopyOnWriteArraySet 的使用。 + +CopyOnWriteArraySet,顾名思义,在写入数据的时候,会创建一个新的 set,并且将原始数据 clone 到新的 set 中,在新的 set 中写入数据完成之后,再用新的 set 替换老的 set。这样就能保证在写入数据的时候,不影响数据的读取操作,以此来解决读写并发问题。除此之外,CopyOnWriteSet 还通过加锁的方式,避免了并发写冲突。具体的作用你可以去查看一下 CopyOnWriteSet 类的源码,一目了然 + +``` +作者:大志说编程 +链接:https://www.zhihu.com/question/485740418/answer/2507532102 +来源:知乎 +著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 + +public class ObserverRegistry { + /** + * key:事件类型,value:观察者包装对象 + **/ + private ConcurrentHashMap, CopyOnWriteArraySet> + registry = new ConcurrentHashMap<>(); + + /** + * 注册观察者 + * + * @param observer 观察者 + */ + public void register(Object observer) { + //获取观察者类所有的观察者方法信息 + Map, Collection> observerActions = findAllObserverActions(observer); + + for (Map.Entry, Collection> entry : observerActions.entrySet()) { + Class eventType = entry.getKey(); + + Collection eventActions = entry.getValue(); + CopyOnWriteArraySet registeredEventActions = registry.get(eventType); + + if (registeredEventActions == null) { + registry.putIfAbsent(eventType, new CopyOnWriteArraySet<>()); + registeredEventActions = registry.get(eventType); + } + //将观察者方法信息填充到map中 + registeredEventActions.addAll(eventActions); + } + } + + /** + * 获取监听器中所有的标注了@Subscribe注解的方法,并进行包装 + */ + private Map, Collection> findAllObserverActions(Object observer) { + Map, Collection> observerActions = new HashMap<>(); + + Class clazz = observer.getClass(); + + for (Method method : getAnnotatedMethods(clazz)) { + Class[] parameterTypes = method.getParameterTypes(); + //取第一个参数作为监听事件的类型 + Class eventType = parameterTypes[0]; + + if (!observerActions.containsKey(eventType)) { + observerActions.put(eventType, new ArrayList<>()); + } + observerActions.get(eventType).add(new ObserverAction(observer, method)); + } + + return observerActions; + } + + /** + * 获取标注了@Subscribe注解的方法列表 + * + * @param clazz 观察者类的class + * @return 方法列表 + */ + private List getAnnotatedMethods(Class clazz) { + List annotatedMethods = new ArrayList<>(); + + for (Method method : clazz.getDeclaredMethods()) { + if (method.isAnnotationPresent(Subscribe.class)) { + Class[] parameterTypes = method.getParameterTypes(); + Preconditions.checkArgument(parameterTypes.length == 1, + "方法 %s, 有%s个参数,@Subscribe 注解标注的方法至少含有一个参数", method, parameterTypes.length); + annotatedMethods.add(method); + } + } + return annotatedMethods; + } + + /** + * 获取匹配的监听器列表 + * + * @param event 事件类型 + * @return 监听器列表 + */ + public List getMatchObserverActions(Object event) { + List matchedObservers = new ArrayList<>(); + + Class postedEventType = event.getClass(); + + for (Map.Entry, CopyOnWriteArraySet> entry : registry.entrySet()) { + Class eventType = entry.getKey(); + Collection eventActions = entry.getValue(); + + //类是否继承自这个事件类型 + if (postedEventType.isAssignableFrom(eventType)) { + matchedObservers.addAll(eventActions); + } + } + return matchedObservers; + } + +} +``` +### EventBus +EventBus 实现的是阻塞同步的观察者模式。看代码你可能会有些疑问,这明明就用到了线程池 Executor 啊。实际上,MoreExecutors.directExecutor() 是 Google Guava 提供的工具类,看似是多线程,实际上是单线程。之所以要这么实现,主要还是为了跟 AsyncEventBus 统一代码逻辑,做到代码复用 +``` +public class EventBus { + + private ObserverRegistry registry = new ObserverRegistry(); + + private Executor executor; + + public EventBus() { + + } + + public EventBus(Executor executor) { + this.executor = executor; + } + + /** + * 注册观察者 + */ + public void register(Object observer) { + registry.register(observer); + } + + /** + * 发布者-发送消息 + */ + public void post(Object event) { + List observerActions = registry.getMatchedObserverActions(event); + for (ObserverAction observerAction : observerActions) { + if (executor == null) { + observerAction.execute(event); + } else { + executor.execute(() -> { + observerAction.execute(event); + }); + } + } + } +} +``` + +### AsyncEventBus +有了 EventBus,AsyncEventBus 的实现就非常简单了。为了实现异步非阻塞的观察者模式,它就不能再继续使用 MoreExecutors.directExecutor() 了,而是需要在构造函数中,由调用者注入线程池 +``` +public class AsyncEventBus extends EventBus { + public AsyncEventBus(Executor executor) { + super(executor); + } +} +``` diff --git a/Chapter6 - Design Pattern/6.21.md b/Chapter6 - Design Pattern/6.21.md new file mode 100644 index 0000000..88eb293 --- /dev/null +++ b/Chapter6 - Design Pattern/6.21.md @@ -0,0 +1,208 @@ +# 模板模式 + +## 定义 +模板模式,全称是模板方法设计模式,英文是 Template Method Design Pattern。在 GoF 的《设计模式》一书中,它是这么定义的: +> Define the skeleton of an algorithm in an operation, deferring some steps to subclasses. Template Method lets subclasses redefine certain steps of an algorithm without changing the algorithm’s structure. + +翻译成中文就是:模板方法模式在一个方法中定义一个算法骨架,并将某些步骤推迟到子类中实现。模板方法模式可以让子类在不改变算法整体结构的情况下,重新定义算法中的某些步骤 + +这里的“算法”,我们可以理解为广义上的“业务逻辑”,并不特指数据结构和算法中的“算法”。这里的算法骨架就是“模板”,包含算法骨架的方法就是“模板方法”,这也是模板方法模式名字的由来。 + +板模式有两大作用:复用和扩展 + +## 实现 + +原理很简单,代码实现就更加简单,我写了一个示例代码,如下所示。templateMethod() 函数定义为 final,是为了避免子类重写它。method1() 和 method2() 定义为 abstract,是为了强迫子类去实现。不过,这些都不是必须的,在实际的项目开发中,模板模式的代码实现比较灵活,待会儿讲到应用场景的时候,我们会有具体的体现。 + +``` +public abstract class AbstractClass { + public final void templateMethod() { + //... + method1(); + //... + method2(); + //... + } + protected abstract void method1(); + protected abstract void method2(); +} + +public class ConcreteClass1 extends AbstractClass { + @Override + protected void method1() { + //... + } + @Override + protected void method2() { + //... + } +} +public class ConcreteClass2 extends AbstractClass { + @Override + protected void method1() { + //... + } + @Override + protected void method2() { + //... + } +} +AbstractClass demo = ConcreteClass1(); +demo.templateMethod(); +``` + +## 应用场景 +板模式有两大作用:复用和扩展 + +### 复用 +Java InputStream +Java IO 类库中,有很多类的设计用到了模板模式,比如 InputStream、OutputStream、Reader、Writer。我们拿 InputStream 来举例说明一下。 + +把 InputStream 部分相关代码贴在了下面。在代码中,read() 函数是一个模板方法,定义了读取数据的整个流程,并且暴露了一个可以由子类来定制的抽象方法。不过这个方法也被命名为了 read(),只是参数跟模板方法不同。 +``` +public abstract class InputStream implements Closeable { + //...省略其他代码... + public int read(byte b[], int off, int len) throws IOException { + if (b == null) { + throw new NullPointerException(); + } else if (off < 0 || len < 0 || len > b.length - off) { + throw new IndexOutOfBoundsException(); + } else if (len == 0) { + return 0; + } + int c = read(); + if (c == -1) { + return -1; + } + b[off] = (byte)c; + int i = 1; + try { + for (; i < len ; i++) { + c = read(); + if (c == -1) { + break; + } + b[off + i] = (byte)c; + } + } catch (IOException ee) { + + } + return i; + } + public abstract int read() throws IOException; +} + +public class ByteArrayInputStream extends InputStream { + //...省略其他代码... + @Override + public synchronized int read() { + return (pos < count) ? (buf[pos++] & 0xff) : -1; + } +} +``` + +### 拓展 +模板模式的第二大作用的是扩展。这里所说的扩展,并不是指代码的扩展性,而是指框架的扩展性,有点类似我们之前讲到的控制反转 +基于这个作用,模板模式常用在框架的开发中,让框架用户可以在不修改框架源码的情况下,定制化框架的功能 + +JUnit 框架也通过模板模式提供了一些功能扩展点(setUp()、tearDown() 等),让框架用户可以在这些扩展点上扩展功能 + +在使用 JUnit 测试框架来编写单元测试的时候,我们编写的测试类都要继承框架提供的TestCase 类。在 TestCase 类中,runBare() 函数是模板方法,它定义了执行测试用例的整体流程:先执行 setUp() 做些准备工作,然后执行 runTest() 运行真正的测试代码,最后执行 tearDown() 做扫尾工作。 + +TestCase 类的具体代码如下所示。尽管 setUp()、tearDown() 并不是抽象函数,还提供了默认的实现,不强制子类去重新实现,但这部分也是可以在子类中定制的,所以也符合模板模式的定义。 + +``` +public abstract class TestCase extends Assert implements Test { + public void runBare() throws Throwable { + Throwable exception = null; + setUp(); + try { + runTest(); + } catch (Throwable running) { + exception = running; + } finally { + try { + tearDown(); + } catch (Throwable tearingDown) { + if (exception == null) exception = tearingDown; + } + } + if (exception != null) throw exception; + } + /** + * Sets up the fixture, for example, open a network connection. + * This method is called before a test is executed. + */ + protected void setUp() throws Exception { + } + + /** + * Tears down the fixture, for example, close a network connection. + * This method is called after a test is executed. + */ + protected void tearDown() throws Exception { + } +} +``` + +在 iOS 2.0 开始,App 可以通过重载 UIView 类中的`-(void)drawRect:(CGRect)rect;` 方法来执行定制绘图 +这个方法的默认实现什么也不做,UIView 的子类如果需要真的自己绘制视图,就可以重载这个方法,这个方法也是钩子方法 +当需要改变屏幕上的视图时,这个方法会被调用,框架会处理所有底层的苦差事,以实现这一点。由 UIView 处理绘图过程的部分会调用 drawRect。如果这个方法内有代码,那么会被调用。 + + +## 思考:模板模式与Callback回调函数有何区别和联系 +复用和扩展是模板模式的两大作用,实际上,还有另外一个技术概念,也能起到跟模板模式相同的作用,那就是回调(Callback)。今天我们今天就来看一下,回调的原理、实现和应用,以及它跟模板模式的区别和联系 + +### 回调的原理解析 +相对于普通的函数调用来说,回调是一种双向调用关系。A 类事先注册某个函数 F 到 B 类,A 类在调用 B 类的 P 函数的时候,B 类反过来调用 A 类注册给它的 F 函数。这里的 F 函数就是“回调函数”。A 调用 B,B 反过来又调用 A,这种调用机制就叫作“回调”。 + +A 类如何将回调函数传递给 B 类呢?不同的编程语言,有不同的实现方法。C 语言可以使用函数指针,Java 则需要使用包裹了回调函数的类对象,我们简称为回调对象。这里用Java 语言举例说明一下。代码如下所示: +``` +public interface ICallback { + void methodToCallback(); +} +public class BClass { + public void process(ICallback callback) { + //... + callback.methodToCallback(); + //... + } +} +public class AClass { + public static void main(String[] args) { + BClass b = new BClass(); + b.process(new ICallback() { //回调对象 + @Override + public void methodToCallback() { + System.out.println("Call back me."); + } + }); + } +} +``` +上面就是 Java 语言中回调的典型代码实现。从代码实现中,我们可以看出,回调跟模板模式一样,也具有复用和扩展的功能。除了回调函数之外,BClass 类的 process() 函数中的逻辑都可以复用。如果 ICallback、BClass 类是框架代码,AClass 是使用框架的客户端代码,我们可以通过 ICallback 定制 process() 函数,也就是说,框架因此具有了扩展的能力 + +实际上,回调不仅可以应用在代码设计上,在更高层次的架构设计上也比较常用。比如,通过三方支付系统来实现支付功能,用户在发起支付请求之后,一般不会一直阻塞到支付结果返回,而是注册回调接口(类似回调函数,一般是一个回调用的 URL)给三方支付系统,等三方支付系统执行完成之后,将结果通过回调接口返回给用户。 + +回调可以分为同步回调和异步回调(或者延迟回调)。同步回调指在函数返回之前执行回调函数;异步回调指的是在函数返回之后执行回调函数。上面的代码实际上是同步回调的实现方式,在 process() 函数返回之前,执行完回调函数 methodToCallback()。而上面支付的例子是异步回调的实现方式,发起支付之后不需要等待回调接口被调用就直接返回。从应用场景上来看,同步回调看起来更像模板模式,异步回调看起来更像观察者模式 + +实际场景的例子 +在客户端开发中,我们经常给控件注册事件监听器,比如下面这段代码,就是在 Android 应用开发中,给 Button 控件的点击事件注册监听器。 +``` +Button button = (Button)findViewById(R.id.button); +button.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + System.out.println("I am clicked."); + } +}); +``` +从代码结构上来看,事件监听器很像回调,即传递一个包含回调函数(onClick())的对象给另一个函数。从应用场景上来看,它又很像观察者模式,即事先注册观察者(OnClickListener),当用户点击按钮的时候,发送点击事件给观察者,并且执行相应的 onClick() 函数。 + +我们前面讲到,回调分为同步回调和异步回调。这里的回调算是异步回调,我们往 setOnClickListener() 函数中注册好回调函数之后,并不需要等待回调函数执行。这也印证了我们前面讲的,异步回调比较像观察者模式 + +## 总结 +模板方法模式在一个方法中定义一个算法骨架,并将某些步骤推迟到子类中实现。模板方法模式可以让子类在不改变算法整体结构的情况下,重新定义算法中的某些步骤。这里的“算法”,我们可以理解为广义上的“业务逻辑”,并不特指数据结构和算法中的“算法”。这里的算法骨架就是“模板”,包含算法骨架的方法就是“模板方法”,这也是模板方法模式名字的由来 + +模板模式有两大作用:复用和扩展。其中,复用指的是,所有的子类可以复用父类中提供的模板方法的代码。扩展指的是,框架通过模板模式提供功能扩展点,让框架用户可以在不修改框架源码的情况下,基于扩展点定制化框架的功能 + diff --git a/Chapter6 - Design Pattern/6.22.md b/Chapter6 - Design Pattern/6.22.md new file mode 100644 index 0000000..4f798df --- /dev/null +++ b/Chapter6 - Design Pattern/6.22.md @@ -0,0 +1,384 @@ +# 策略模式 +> 如何避免冗长的if-else-switch分支判断代码 + +## 定义 +策略模式,英文全称是 Strategy Design Pattern。在 GoF 的《设计模式》一书中,它是这样定义的: +> Define a family of algorithms, encapsulate each one, and make them interchangeable. Strategy lets the algorithm vary independently from clients that use it. +翻译成中文就是:定义一族算法类,将每个算法分别封装起来,让它们可以互相替换。策略模式可以使算法的变化独立于使用它们的客户端(这里的客户端代指使用算法的代码)。 + +**工厂模式是解耦对象的创建和使用,观察者模式是解耦观察者和被观察者。策略模式跟两者类似,也能起到解耦的作用,不过,它解耦的是策略的定义、创建、使用**。 + +## 策略的定义 +策略类的定义比较简单,包含一个策略接口和一组实现这个接口的策略类。因为所有的策略类都实现相同的接口,所以,客户端代码基于接口而非实现编程,可以灵活地替换不同的策略。示例代码如下所示: + +``` +public interface Strategy { + void algorithmInterface(); +} +public class ConcreteStrategyA implements Strategy { + @Override + public void algorithmInterface() { + //具体的算法... + } +} +public class ConcreteStrategyB implements Strategy { + @Override + public void algorithmInterface() { + // 具体的算法... + } +} +``` +## 策略的创建 +因为策略模式会包含一组策略,在使用的时候,一般会通过类型(type)来判断创建哪个策略来使用,为了封装创建逻辑,我们需要对客户端代码屏蔽创建细节,所以可以把 type 创建策略部分的逻辑抽取出来,放到工厂类中 +``` +public class StrategyFactory { + private static final Map strategies = new HashMap<>(); + static { + strategies.put("A", new ConcreteStrategyA()); + strategies.put("B", new ConcreteStrategyB()); + } + public static Strategy getStrategy(String type) { + if (type == null || type.isEmpty()) { + throw new IllegalArgumentException("type should not be empty."); + } + return strategies.get(type); + } +} +``` +一般来讲,如果策略类是无状态的,不包含成员变量,只是纯粹的算法实现,这样的策略对象是可以被共享使用的,不需要在每次调用 getStrategy() 的时候,都创建一个新的策略对象。针对这种情况,我们可以使用上面这种工厂类的实现方式,事先创建好每个策略对象,缓存到工厂类中,用的时候直接返回 + +相反,如果策略类是有状态的,根据业务场景的需要,我们希望每次从工厂方法中,获得的都是新创建的策略对象,而不是缓存好可共享的策略对象,那我们就需要按照 +如下方式来实现策略工厂类。 + +``` +public class StrategyFactory { + public static Strategy getStrategy(String type) { + if (type == null || type.isEmpty()) { + throw new IllegalArgumentException("type should not be empty."); + } + if (type.equals("A")) { + return new ConcreteStrategyA(); + } else if (type.equals("B")) { + return new ConcreteStrategyB(); + } + return null; + } +} +``` + +## 策略的使用 +我们知道,策略模式包含一组可选策略,客户端代码一般如何确定使用哪个策略呢?最常见的是运行时动态确定使用哪种策略,这也是策略模式最典型的应用场景。 +这里的“运行时动态”指的是,我们事先并不知道会使用哪个策略,而是在程序运行期间,根据配置、用户输入、计算结果等这些不确定因素,动态决定使用哪种策略。接下来,我们通过一个例子来解释一下 +``` +// 策略接口:EvictionStrategy +// 策略类:LruEvictionStrategy、FifoEvictionStrategy、LfuEvictionStrategy... +// 策略工厂:EvictionStrategyFactory +public class UserCache { + private Map cacheData = new HashMap<>(); + private EvictionStrategy eviction; + public UserCache(EvictionStrategy eviction) { + this.eviction = eviction; + } + //... +} +// 运行时动态确定,根据配置文件的配置决定使用哪种策略 +public class Application { + public static void main(String[] args) throws Exception { + Properties props = new Properties(); + props.load(new FileInputStream("./config.properties")); + String type = props.getProperty("eviction_type"); + evictionStrategy = EvictionStrategyFactory.getEvictionStrategy(type); + UserCache userCache = new UserCache(evictionStrategy); + //... + } +} +// 非运行时动态确定,在代码中指定使用哪种策略 +public class Application { + public static void main(String[] args) { + //... + EvictionStrategy evictionStrategy = new LruEvictionStrategy(); + UserCache userCache = new UserCache(evictionStrategy); + //... + } +} +``` +从上面的代码中,我们也可以看出,“非运行时动态确定”,也就是第二个 Application 中的使用方式,并不能发挥策略模式的优势。在这种应用场景下,策略模式实际上退化成了“面向对象的多态特性”或“基于接口而非实现编程原则”。 + +## 如何利用策略模式避免分支判断? +实际上,能够移除分支判断逻辑的模式不仅仅有策略模式,后面我们要讲的状态模式也可以。对于使用哪种模式,具体还要看应用场景来定。 策略模式适用于根据不同类型待动态,决定使用哪种策略这样一种应用场景。 + +我们先通过一个例子来看下,if-else 或 switch-case 分支判断逻辑是如何产生的。具体的 代码如下所示。在这个例子中,我们没有使用策略模式,而是将策略的定义、创建、使用直接耦合在一起。 +``` +public class OrderService { + public double discount(Order order) { + double discount = 0.0; + OrderType type = order.getType(); + if (type.equals(OrderType.NORMAL)) { // 普通订单 + //...省略折扣计算算法代码 + } else if (type.equals(OrderType.GROUPON)) { // 团购订单 + //...省略折扣计算算法代码 + } else if (type.equals(OrderType.PROMOTION)) { // 促销订单 + //...省略折扣计算算法代码 + } + return discount; + } +} +``` +如何来移除掉分支判断逻辑呢?那策略模式就派上用场了。我们使用策略模式对上面的代码重构,将不同类型订单的打折策略设计成策略类,并由工厂类来负责创建策略对象。具体的代码如下所示 +``` +/ 策略的定义 +public interface DiscountStrategy { + double calDiscount(Order order); +} +// 省略 NormalDiscountStrategy、GrouponDiscountStrategy、PromotionDiscountStrateg + + +// 策略的创建 +public class DiscountStrategyFactory { + private static final Map strategies = new HashMa + static { + strategies.put(OrderType.NORMAL, new NormalDiscountStrategy()); + strategies.put(OrderType.GROUPON, new GrouponDiscountStrategy()); + strategies.put(OrderType.PROMOTION, new PromotionDiscountStrategy()); + } + public static DiscountStrategy getDiscountStrategy(OrderType type) { + return strategies.get(type); + } +} + +// 策略的使用 +public class OrderService { + public double discount(Order order) { + OrderType type = order.getType(); + DiscountStrategy discountStrategy = DiscountStrategyFactory.getDiscountStra + return discountStrategy.calDiscount(order); + } +} +``` +重构之后的代码就没有了 if-else 分支判断语句了。实际上,这得益于策略工厂类。在工厂类中,我们用 Map 来缓存策略,根据 type 直接从 Map 中获取对应的策略,从而避免 if-else 分支判断逻辑。等后面讲到使用状态模式来避免分支判断逻辑的时候,你会发现,它们使用的是同样的套路。本质上都是借助“查表法”,根据 type 查表(代码中的 strategies 就是表)替代根据 type 分支判断。 + +但是,如果业务场景需要每次都创建不同的策略对象,我们就要用另外一种工厂类的实现方式了 +``` +public class DiscountStrategyFactory { + public static DiscountStrategy getDiscountStrategy(OrderType type) { + if (type == null) { + throw new IllegalArgumentException("Type should not be null."); + } + if (type.equals(OrderType.NORMAL)) { + return new NormalDiscountStrategy(); + } else if (type.equals(OrderType.GROUPON)) { + return new GrouponDiscountStrategy(); + } else if (type.equals(OrderType.PROMOTION)) { + return new PromotionDiscountStrategy(); + } + return null; + } +} +``` +这种实现方式相当于把原来的 if-else 分支逻辑,从 OrderService 类中转移到了工厂类中,实际上并没有真正将它移除 + +## 一个实际场景:如何实现一个支持给不同大小文件排序的小程序 +设计原则和思想其实比设计模式更加普适和重要,掌握了代码的设计原则和思想,我们甚至可以自己创造出来新的设计模式 + +假设有这样一个需求,希望写一个小程序,实现对一个文件进行排序的功能。文件中只包含整型数,并且,相邻的数字通过逗号来区隔。如果由你来编写这样一个小程序,你会如何来实现呢? + +### 问题与解决思路 +这不是很简单嘛,只需要将文件中的内容读取出来,并且通过逗号分割成一个一个的数字,放到内存数组中,然后编写某种排序算法(比如快排),或者直接使用编程语言提供的排序函数,对数组进行排序,最后再将数组中的数据写入文件就可以了。 + +但是,如果文件很大呢?比如有 10GB 大小,因为内存有限(比如只有 8GB 大小),我们没办法一次性加载文件中的所有数据到内存中,这个时候,我们就要利用外部排序算法了。 + +如果文件更大,比如有 100GB 大小,我们为了利用 CPU 多核的优势,可以在外部排序的基础之上进行优化,加入多线程并发排序的功能,这就有点类似“单机版”的 MapReduce。 + +如果文件非常大,比如有 1TB 大小,即便是单机多线程排序,这也算很慢了。这个时候,我们可以使用真正的 MapReduce 框架,利用多机的处理能力,提高排序效率 + +### 代码实现与分析 +简易版本 +``` +public class Sorter { + private static final long GB = 1000 * 1000 * 1000; + public void sortFile(String filePath) { + // 省略校验逻辑 + File file = new File(filePath); + long fileSize = file.length(); + if (fileSize < 6 * GB) { // [0, 6GB) + quickSort(filePath); + } else if (fileSize < 10 * GB) { // [6GB, 10GB) + externalSort(filePath); + } else if (fileSize < 100 * GB) { // [10GB, 100GB) + concurrentExternalSort(filePath); + } else { // [100GB, ~) + mapreduceSort(filePath); + } + } + private void quickSort(String filePath) { + // 快速排序 + } + private void externalSort(String filePath) { + // 外部排序 + } + private void concurrentExternalSort(String filePath) { + // 多线程外部排序 + } + private void mapreduceSort(String filePath) { + // 利用MapReduce多机排序 + } +} +public class SortingTool { + public static void main(String[] args) { + Sorter sorter = new Sorter(); + sorter.sortFile(args[0]); + } +} +``` +问题分析: +在“编码规范”那一部分我们讲过,函数的行数不能过多,最好不要超过一屏的大小。所以,为了避免 sortFile() 函数过长,我们把每种排序算法从 sortFile() 函数中抽离出来,拆分成 4 个独立的排序函数。 + +如果只是开发一个简单的工具,那上面的代码实现就足够了。毕竟,代码不多,后续修改、扩展的需求也不多,怎么写都不会导致代码不可维护。但是,如果我们是在开发一个大型项目,排序文件只是其中的一个功能模块,那我们就要在代码设计、代码质量上下点儿功夫了。只有每个小的功能模块都写好,整个项目的代码才能不差。 + +### 代码优化与重构 +设计原则和思想,针对上面的问题,即便我们想不到该用什么设计模式来重构,也应该能知道该如何解决,那就是将 Sorter 类中的某些代码拆分出来,独立成职责更加单一的小类。实际上,拆分是应对类或者函数代码过多、应对代码复杂性的一个常用手段。按照这个解决思路,我们对代码进行重构。 +``` +public interface ISortAlg { + void sort(String filePath); +} +public class QuickSort implements ISortAlg { + @Override + public void sort(String filePath) { + //... + } +} +public class ExternalSort implements ISortAlg { + @Override + public void sort(String filePath) { + //... + } +} +public class ConcurrentExternalSort implements ISortAlg { + @Override + public void sort(String filePath) { + //... + } +} +public class MapReduceSort implements ISortAlg { + @Override + public void sort(String filePath) { + //... + } +} + +public class Sorter { + private static final long GB = 1000 * 1000 * 1000; + public void sortFile(String filePath) { + // 省略校验逻辑 + File file = new File(filePath); + long fileSize = file.length(); + ISortAlg sortAlg; + if (fileSize < 6 * GB) { // [0, 6GB) + sortAlg = new QuickSort(); + } else if (fileSize < 10 * GB) { // [6GB, 10GB) + sortAlg = new ExternalSort(); + } else if (fileSize < 100 * GB) { // [10GB, 100GB) + sortAlg = new ConcurrentExternalSort(); + } else { // [100GB, ~) + sortAlg = new MapReduceSort(); + } + sortAlg.sort(filePath); + } +} +``` +问题分析: +经过拆分之后,每个类的代码都不会太多,每个类的逻辑都不会太复杂,代码的可读性、可维护性提高了。除此之外,我们将排序算法设计成独立的类,跟具体的业务逻辑(代码中的 if-else 那部分逻辑)解耦,也让排序算法能够复用。这一步实际上就是策略模式的第一步,也就是将策略的定义分离出来。 + +实际上,上面的代码还可以继续优化。每种排序类都是无状态的,我们没必要在每次使用的时候,都重新创建一个新的对象。所以,我们可以使用工厂模式对对象的创建进行封装。按照这个思路,我们对代码进行重构。重构之后的代码如下所示 +``` +public class SortAlgFactory { + private static final Map algs = new HashMap<>(); + static { + algs.put("QuickSort", new QuickSort()); + algs.put("ExternalSort", new ExternalSort()); + algs.put("ConcurrentExternalSort", new ConcurrentExternalSort()); + algs.put("MapReduceSort", new MapReduceSort()); + } + public static ISortAlg getSortAlg(String type) { + if (type == null || type.isEmpty()) { + throw new IllegalArgumentException("type should not be empty."); + } + return algs.get(type); + } +} +public class Sorter { + private static final long GB = 1000 * 1000 * 1000; + public void sortFile(String filePath) { + // 省略校验逻辑 + File file = new File(filePath); + long fileSize = file.length(); + ISortAlg sortAlg; + if (fileSize < 6 * GB) { // [0, 6GB) + sortAlg = SortAlgFactory.getSortAlg("QuickSort"); + } else if (fileSize < 10 * GB) { // [6GB, 10GB) + sortAlg = SortAlgFactory.getSortAlg("ExternalSort"); + } else if (fileSize < 100 * GB) { // [10GB, 100GB) + sortAlg = SortAlgFactory.getSortAlg("ConcurrentExternalSort"); + } else { // [100GB, ~) + sortAlg = SortAlgFactory.getSortAlg("MapReduceSort"); + } + sortAlg.sort(filePath); + } +} +``` +问题分析: +经过上面两次重构之后,现在的代码实际上已经符合策略模式的代码结构了。我们通过策略模式将策略的定义、创建、使用解耦,让每一部分都不至于太复杂。不过,Sorter 类中的 sortFile() 函数还是有一堆 if-else 逻辑。这里的 if-else 逻辑分支不多、也不复杂,这样写完全没问题。但如果你特别想将 if-else 分支判断移除掉,那也是有办法的。我直接给出代码,你一看就能明白。实际上,这也是基于查表法来解决的,其中的“algs”就是“表”。 +``` +public class Sorter { + private static final long GB = 1000 * 1000 * 1000; + private static final List algs = new ArrayList<>(); + static { + algs.add(new AlgRange(0, 6*GB, SortAlgFactory.getSortAlg("QuickSort"))); + algs.add(new AlgRange(6*GB, 10*GB, SortAlgFactory.getSortAlg("ExternalSort + algs.add(new AlgRange(10*GB, 100*GB, SortAlgFactory.getSortAlg("ConcurrentE + algs.add(new AlgRange(100*GB, Long.MAX_VALUE, SortAlgFactory.getSortAlg("Ma + } + public void sortFile(String filePath) { + // 省略校验逻辑 + File file = new File(filePath); + long fileSize = file.length(); + ISortAlg sortAlg = null; + for (AlgRange algRange : algs) { + if (algRange.inRange(fileSize)) { + sortAlg = algRange.getAlg(); + break; + } + } + sortAlg.sort(filePath); + } + + private static class AlgRange { + private long start; + private long end; + private ISortAlg alg; + public AlgRange(long start, long end, ISortAlg alg) { + this.start = start; + this.end = end; + this.alg = alg; + } + public ISortAlg getAlg() { + return alg; + } + public boolean inRange(long size) { + return size >= start && size < end; + } + } +} +``` + +## 总结 +一提到 if-else 分支判断,有人就觉得它是烂代码。如果 if-else 分支判断不复杂、代码不多,这并没有任何问题,毕竟 if-else 分支判断几乎是所有编程语言都会提供的语法,存在即有理由。遵循 KISS 原则,怎么简单怎么来,就是最好的设计。非得用策略模式,搞出 n 多类,反倒是一种过度设计。 + +一提到策略模式,有人就觉得,它的作用是避免 if-else 分支判断逻辑。实际上,这种认识是很片面的。策略模式主要的作用还是解耦策略的定义、创建和使用,控制代码的复杂度,让每个部分都不至于过于复杂、代码量过多。除此之外,对于复杂代码来说,策略模式还能让其满足开闭原则,添加新策略的时候,最小化、集中化代码改动,减少引入 bug 的风险。 + +策略模式定义一族算法类,将每个算法分别封装起来,让它们可以互相替换。策略模式可以使算法的变化独立于使用它们的客户端(这里的客户端代指使用算法的代码) +策略模式用来解耦策略的定义、创建、使用。实际上,一个完整的策略模式就是由这三个部分组成的。 +- 策略类的定义比较简单,包含一个策略接口和一组实现这个接口的策略类 +- 策略的创建由工厂类来完成,封装策略创建的细节。 +- 策略模式包含一组策略可选,客户端代码如何选择使用哪个策略,有两种确定方法:编译时静态确定和运行时动态确定。其中,“运行时动态确定”才是策略模式最典型的应用场景。 +除此之外,我们还可以通过策略模式来移除 if-else 分支判断。实际上,这得益于策略工厂类,更本质上点讲,是借助“查表法”,根据 type 查表替代根据 type 分支判断。 \ No newline at end of file diff --git a/Chapter6 - Design Pattern/6.23.md b/Chapter6 - Design Pattern/6.23.md new file mode 100644 index 0000000..d457044 --- /dev/null +++ b/Chapter6 - Design Pattern/6.23.md @@ -0,0 +1,171 @@ +# 职责链模式 + +## 定义 +职责链模式的英文翻译是 Chain Of Responsibility Design Pattern。在 GoF 的《设计模式》中,它是这么定义的: +> Avoid coupling the sender of a request to its receiver by giving more than one object a chance to handle the request. Chain the receiving objects and pass the request along the chain until an object handles it. + +翻译成中文就是:将请求的发送和接收解耦,让多个接收对象都有机会处理这个请求。将这些接收对象串成一条链,并沿着这条链传递这个请求,直到链上的某个接收对象能够处理它为止 + +在职责链模式中,多个处理器(也就是刚刚定义中说的“接收对象”)依次处理同一个请求。一个请求先经过 A 处理器处理,然后再把请求传递给 B 处理器,B 处理器处理完后再传递给 C 处理器,以此类推,形成一个链条。链条上的每个处理器各自承担各自的处理职责,所以叫作职责链模式。 + +有些抽象,可以类比下 Node 的洋葱模型,是不是形象多了 + + +## 实现 + +### 方法1 +Handler 是所有处理器类的抽象父类,handle() 是抽象方法。每个具体的处理器类(HandlerA、HandlerB)的 handle() 函数的代码结构类似,如果它能处理该请求,就不继续往下传递;如果不能处理,则交由后面的处理器来处理(也就是调用 successor.handle())。 +HandlerChain 是处理器链,从数据结构的角度来看,它就是一个记录了链头、链尾的链表。其中,记录链尾是为了方便添加处理器。 + +``` +public abstract class Handler { + protected Handler successor = null; + public void setSuccessor(Handler successor) { + this.successor = successor; + } + public abstract void handle(); +} +public class HandlerA extends Handler { + @Override + public boolean handle() { + boolean handled = false; + //... + if (!handled && successor != null) { + successor.handle(); + } + } +} + +public class HandlerB extends Handler { + @Override + public void handle() { + boolean handled = false; + //... + if (!handled && successor != null) { + successor.handle(); + } + } +} +public class HandlerChain { + private Handler head = null; + private Handler tail = null; + public void addHandler(Handler handler) { + handler.setSuccessor(null); + if (head == null) { + head = handler; + tail = handler; + return; + } + tail.setSuccessor(handler); + tail = handler; + } + public void handle() { + if (head != null) { + head.handle(); + } + } +} + +// 使用举例 +public class Application { + public static void main(String[] args) { + HandlerChain chain = new HandlerChain(); + chain.addHandler(new HandlerA()); + chain.addHandler(new HandlerB()); + chain.handle(); + } +} +``` + +问题分析:上面的代码实现不够优雅。处理器类的 handle() 函数,不仅包含自己的业务逻辑,还包含对下一个处理器的调用,也就是代码中的 successor.handle()。一个不熟悉这种代码结构的程序员,在添加新的处理器类的时候,很有可能忘记在 handle() 函数中调用 successor.handle(),这就会导致代码出现 bug + +我们对代码进行重构,**利用模板模式,将调用 successor.handle() 的逻辑从具体的处理器类中剥离出来,放到抽象父类中**。这样具体的处理器类只需要实现自己的业务逻辑就可以了。重构之后的代码如下所示 + +``` +public abstract class Handler { + protected Handler successor = null; + public void setSuccessor(Handler successor) { + this.successor = successor; + } + public final void handle() { + boolean handled = doHandle(); + if (successor != null && !handled) { + successor.handle(); + } + } + protected abstract boolean doHandle(); + } + public class HandlerA extends Handler { + @Override + protected boolean doHandle() { + boolean handled = false; + //... + return handled; + } +} + +public class HandlerB extends Handler { + @Override + protected boolean doHandle() { + boolean handled = false; + //... + return handled; + } +} +// HandlerChain和Application代码不变 +``` +### 方法2 +我们再来看第二种实现方式,代码如下所示。这种实现方式更加简单。HandlerChain 类用数组而非链表来保存所有的处理器,并且需要在 HandlerChain 的 handle() 函数中,依次调用每个处理器的 handle() 函数。 + +``` +public interface IHandler { + boolean handle(); +} +public class HandlerA implements IHandler { + @Override + public boolean handle() { + boolean handled = false; + //... + return handled; + } +} +public class HandlerB implements IHandler { + @Override + public boolean handle() { + boolean handled = false; + //... + return handled; + } +} +public class HandlerChain { + private List handlers = new ArrayList<>(); + public void addHandler(IHandler handler) { + this.handlers.add(handler); + } + public void handle() { + for (IHandler handler : handlers) { + + boolean handled = handler.handle(); + if (handled) { + break; + } + } + } +} + +// 使用举例 +public class Application { + public static void main(String[] args) { + HandlerChain chain = new HandlerChain(); + chain.addHandler(new HandlerA()); + chain.addHandler(new HandlerB()); + chain.handle(); + } +} +``` +在 GoF 给出的定义中,如果处理器链上的某个处理器能够处理这个请求,那就不会继续往下传递请求。实际上,职责链模式还有一种变体,那就是请求会被所有的处理器都处理一遍,不存在中途终止的情况。 + +## 使用场景 +在之前的文章利用[责任链模式设计了一套校验器](./../Chapter1%20-%20iOS/1.110.md) +再举个例子敏感词过滤的例子。 + diff --git a/Chapter6 - Design Pattern/6.3.md b/Chapter6 - Design Pattern/6.3.md new file mode 100644 index 0000000..aac1359 --- /dev/null +++ b/Chapter6 - Design Pattern/6.3.md @@ -0,0 +1,106 @@ +# SOLID之单一职责 SRP + +单一职责原则的英文是 Single Responsibility Principle,缩写为 SRP。这个原则的英文描 +述是这样的:A class or module should have a single reponsibility。如果我们把它翻译 +成中文,那就是:一个类或者模块只负责完成一个职责(或者功能)。 + + +一个类里既包含订单的一些操作,又包含用户的一些操作。而订单和用户是两个独立的业务领域模型,我们将两个不相干的功能放到同一个类中,那就违反了单一职责原则。为了满足单一职责原则,我们需要将这个类拆分成两个粒度更细、功能更加单一的两个类:订单类和用户类。 + +## 如何判断类的职责是否足够单一? +在一个社交产品中,我们用下面的 UserInfo 类来记录用户的信息。你觉得,UserInfo 类的 +设计是否满足单一职责原则呢? + +``` +public class UserInfo { + private long userId; + private String username; + private String email; + private String telephone; + private long createTime; + private long lastLoginTime; + private String avatarUrl; + private String provinceOfAddress; // 省 + private String cityOfAddress; // 市 + private String regionOfAddress; // 区 + private String detailedAddress; // 详细地址 + // ... 省略其他属性和方法... +} +``` +对于这个问题,有两种不同的观点。一种观点是,UserInfo 类包含的都是跟用户相关的信息,所有的属性和方法都隶属于用户这样一个业务模型,满足单一职责原则;另一种观点是,地址信息在 UserInfo 类中,所占的比重比较高,可以继续拆分成独立的 UserAddress 类,UserInfo 只保留除 Address 之外的其他信息,拆分之后的两个类的职责更加单一。 + +哪种观点更对呢?实际上,要从中做出选择,我们不能脱离具体的应用场景。如果在这个社交产品中,用户的地址信息跟其他信息一样,只是单纯地用来展示,那 UserInfo 现在的设计就是合理的。但是,如果这个社交产品发展得比较好,之后又在产品中添加了电商的模块,用户的地址信息还会用在电商物流中,那我们最好将地址信息从 UserInfo 中拆分出来,独立成用户物流信息(或者叫地址信息、收货信息等)。 + +我们再进一步延伸一下。如果做这个社交产品的公司发展得越来越好,公司内部又开发出了跟多其他产品(可以理解为其他 App)。公司希望支持统一账号系统,也就是用户一个账号可以在公司内部的所有产品中登录。这个时候,我们就需要继续对 UserInfo 进行拆分,将跟身份认证相关的信息(比如,email、telephone 等)抽取成独立的类。 + +从刚刚这个例子,我们可以总结出,不同的应用场景、不同阶段的需求背景下,对同一个类的职责是否单一的判定,可能都是不一样的。在某种应用场景或者当下的需求背景下,一个类的设计可能已经满足单一职责原则了,但如果换个应用场景或着在未来的某个需求背景下,可能就不满足了,需要继续拆分成粒度更细的类。 + + +评价一个类的职责是否足够单一,我们并没有一个非常明确的、可以量化的标准,可以说,这是件非常主观、仁者见仁智者见智的事情。实际上,在真正的软件开发中, +我们也没必要过于未雨绸缪,过度设计。所以,我们可以先写一个粗粒度的类,满足业务需求。随着业务的发展,如果粗粒度的类越来越庞大,代码越来越多,这个时候,我们就可以将这个粗粒度的类,拆分成几个更细粒度的类。这就是所谓的持续重构。 + + +## 类的职责是否设计得越单一越好? +为了满足单一职责原则,是不是把类拆得越细就越好呢?答案是否定的。我们还是通过一个例子来解释一下。Serialization 类实现了一个简单协议的序列化和反序列功能,具体代码如下: +``` +/** +* Protocol format: identifier-string;{gson string} +* For example: UEUEUE;{"a":"A","b":"B"} +*/ +public class Serialization { + private static final String IDENTIFIER_STRING = "UEUEUE;"; + private Gson gson; + public Serialization() { + public class Serialization { + this.gson = new Gson(); + } + public String serialize(Map object) { + StringBuilder textBuilder = new StringBuilder(); + textBuilder.append(IDENTIFIER_STRING); + textBuilder.append(gson.toJson(object)); + return textBuilder.toString(); + } + public Map deserialize(String text) { + if (!text.startsWith(IDENTIFIER_STRING)) { + return Collections.emptyMap(); + } + String gsonStr = text.substring(IDENTIFIER_STRING.length()); + return gson.fromJson(gsonStr, Map.class); + } +} +``` +如果我们想让类的职责更加单一,我们对 Serialization 类进一步拆分,拆分成一个只负责序列化工作的 Serializer 类和另一个只负责反序列化工作的 Deserializer 类。拆分后的具体代码如下所示: +``` +public class Serializer { + private static final String IDENTIFIER_STRING = "UEUEUE;"; + private Gson gson; + public Serializer() { + this.gson = new Gson(); + } + public String serialize(Map object) { + StringBuilder textBuilder = new StringBuilder(); + textBuilder.append(IDENTIFIER_STRING); + textBuilder.append(gson.toJson(object)); + return textBuilder.toString(); + } +} + +public class Deserializer { + private static final String IDENTIFIER_STRING = "UEUEUE;"; + private Gson gson; + public Deserializer() { + this.gson = new Gson(); + } + + public Map deserialize(String text) { + if (!text.startsWith(IDENTIFIER_STRING)) { + return Collections.emptyMap(); + } + String gsonStr = text.substring(IDENTIFIER_STRING.length()); + return gson.fromJson(gsonStr, Map.class); + } +} +``` +虽然经过拆分之后,Serializer 类和 Deserializer 类的职责更加单一了,但也随之带来了新的问题。如果我们修改了协议的格式,数据标识从“UEUEUE”改为“DFDFDF”,或者序列化方式从 JSON 改为了 XML,那 Serializer 类和 Deserializer 类都需要做相应的修改,代码的内聚性显然没有原来 Serialization 高了。而且,如果我们仅仅对 Serializer 类做了协议修改,而忘记了修改 Deserializer 类的代码,那就会导致序列化、反序列化不匹配,程序运行出错,也就是说,拆分之后,代码的可维护性变差了。实际上,不管是应用设计原则还是设计模式,最终的目的还是提高代码的可读性、可扩展性、复用性、可维护性等。我们在考虑应用某一个设计原则是否合理的时候,也可以以此作为最终的考量标准。 + + diff --git a/Chapter6 - Design Pattern/6.4.md b/Chapter6 - Design Pattern/6.4.md new file mode 100644 index 0000000..8a68d60 --- /dev/null +++ b/Chapter6 - Design Pattern/6.4.md @@ -0,0 +1,224 @@ +# SOLID之开闭原则 + +如何做到“对扩展开放、修改关闭”?扩展和修改各指什么? + + +## 如何理解“对扩展开放、修改关闭”? +开闭原则的英文全称是 Open Closed Principle,简写为 OCP。它的英文描述是:Software entities (modules, classes, functions, etc.) should be open for extension , +but closed for modification。 + +解释一下就是:添加一个新的功能应该是,在已有代码基础上扩展代码(新增模块、类、方法等),而非修改已有代码(修改模块、类、方法等)。 + +举个场景例子,之前在做天网报警系统的时候,有一段监控告警代码。 + +其中 AlertRule 存储告警规则,有个 mPaaS 平台的可视化页面,基于不同业务线自由设置。Notification 是告警通知类,支持邮件、短信、微信、手机等多种通知渠道。NotificationEmergencyLevel 表示通知的紧急程度:Error、Warning、Info、Normal。 + +``` +public class Alert { + private AlertRule rule; + private Notification notification; + + 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 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() 函数,相应的单元测试都需要修改 +因为从本质上来讲,上面的实现是基于修改的方式来实现的新功能,如果遵循开闭原则,该如何实现呢? +1. 将 check 函数所需要的多个参数封装为 ApiStateInfo 对象 +2. 引入 handler 的概念,将 if 的具体判断逻辑分散到各个 handler 中去 + +``` +public class ApiStatInfo {// 省略 constructor/getter/setter 方法 + private String api; + private long requestCount; + private long errorCount; + private long durationOfSeconds; +} +public class Alert { + private List 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 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()) + notification.notify(NotificationEmergencyLevel.SEVERE, "..."); + } + } +} +``` +使用的地方,创建一个 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() { + instance.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); + } +} +``` +重构之后的代码要实现:添加一个新的功能,每秒钟接口超时请求个数超过最大阈值就告警,该如何改动? +1. 在 ApiStatInfo 类中添加新的属性 timeoutCount。 +2. 添加新的 TimeoutAlertHander 类,编写 check 方法 +3. 在 ApplicationContext 类的 initializeBeans() 方法中,往 alert 对象中注册新的 timeoutAlertHandler。 +4. 在使用 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 类添加单元测试,老的单元测试都不会失败,也不用修改。 + +## 修改代码就意味着违背开闭原则吗? +看了上面重构之后的代码,你可能还会有疑问:在添加新的告警逻辑的时候,尽管改动二(添加新的 handler 类)是基于扩展而非修改的方式来完成的,但改动一、三、四貌似不是基于扩展而是基于修改的方式来完成的,那改动一、三、四不就违背了开闭原则吗? + +第一::往 ApiStatInfo 类中添加新的属性 timeoutCount。我们不仅往 ApiStatInfo 类中添加了属性,还添加了对应的 getter/setter 方法。 +那这个问题就转化为:给类中添加新的属性和方法,算作“修改”还是“扩展”? + +开闭原则的定义:软件实体(模块、类、方法等)应该“对扩展开放、对修改关闭”。从定义中,我们可以看出,开闭原则可以应用在不同粒度的代码中,可以是模块,也可以类,还可以是方法(及其属性)。同样一个代码改动,在粗代码粒度下,被认定为“修改”,在细代码粒度下,又可以被认定为“扩展”。比如,改动一,添加属性和方法相当于修改类,在类这个层面,这个代码改动可以被认定为“修改”;但这个代码改动并没有修改已有的属性和方法,在方法(及其属性)这一层面,它又可以被认定为“扩展”。 + +实际上,我们也没必要纠结某个代码改动是“修改”还是“扩展”,更没必要太纠结它是否违反“开闭原则”。我们回到这条原则的设计初衷:只要它没有破坏原有的代码的正常运行,没有破坏原有的单元测试,我们就可以说,这是一个合格的代码改动。 + +第三和第四:在 ApplicationContext 类的 initializeBeans() 方法中,往 alert 对象中注册新的 timeoutAlertHandler;在使用 Alert 类的时候,需要给 check() 函数的入参 apiStatInfo 对象设置 timeoutCount 的值。 + +这两处改动都是在方法内部进行的,不管从哪个层面(模块、类、方法)来讲,都不能算是“扩展”,而是地地道道的“修改”。不过,有些修改是在所难免的,是可以被接受的。 + +为什么这么说呢?我来解释一下。在重构之后的 Alert 代码中,我们的核心逻辑集中在 Alert 类及其各个 handler 中,当我们在添加新的告警逻辑的时候,Alert 类完全不需要修改,而只需要扩展一个新 handler 类。如果我们把 Alert 类及各个 handler 类合起来看作一个“模块”,那模块本身在添加新的 +功能的时候,完全满足开闭原则。而且,我们要认识到,添加一个新功能,不可能任何模块、类、方法的代码都不“修改”,这个是做不到的。类需要创建、组装、并且做一些初始化操作,才能构建成可运行的的程序,这部分代码的修改是在所难免的。我们要做的是尽量让修改操作更集中、更少、更上层,尽量让最核心、最复杂的那部分逻辑代码满足开闭原则。 + + +## 如何做到“对扩展开放、修改关闭” +为了尽量写出扩展性好的代码,我们要时刻具备扩展意识、抽象意识、封装意识。这些“潜意识”可能比任何开发技巧都重要。 + +在写代码的时候后,我们要多花点时间往前多思考一下,这段代码未来可能有哪些需求变更、如何设计代码结构,事先留好扩展点,以便在未来需求变更的时候,不需要改动代码整体结构、做到最小代码改动的情况下,新的代码能够很灵活地插入到扩展点上,做到“对扩展开放、对修改关闭”。 + +还有,在识别出代码可变部分和不可变部分之后,我们要将可变部分封装起来,隔离变化,提供抽象化的不可变接口,给上层系统使用。当具体的实现发生变化的时候,我们只需要基于相同的抽象接口,扩展一个新的实现,替换掉老的实现即可,上游系统的代码几乎不需要修改。 diff --git a/Chapter6 - Design Pattern/6.5.md b/Chapter6 - Design Pattern/6.5.md new file mode 100644 index 0000000..35b4be9 --- /dev/null +++ b/Chapter6 - Design Pattern/6.5.md @@ -0,0 +1,96 @@ +# SOLID之里氏替换 + +里式替换原则的英文翻译是:Liskov Substitution Principle,缩写为 LSP。这个原则最早 +是在 1986 年由 Barbara Liskov 提出,他是这么描述这条原则的: + +> If S is a subtype of T, then objects of type T may be replaced with objects of type S, without breaking the program。 + +在 1996 年,Robert Martin 在他的 SOLID 原则中,重新描述了这个原则,英文原话是这 +样的: + +> Functions that use pointers of references to base classes must be able to use objects of derived classes without knowing it。 + +子类对象(object of subtype/derived class)能够替换程序(program)中父类对(object of base/parent class)出现的任何地方,并且保证原来程序的逻辑行为(behavior)不变及正确性不被破坏。 + +父类 Transporter 使用 org.apache.http 库中的 HttpClient 类来传输网络数据。子类 SecurityTransporter 继承父类 Transporter,增加了额外的功能,支持传输 appId 和 appToken 安全认证信息。 + +``` +public class Transporter { + private HttpClient httpClient; + public Transporter(HttpClient httpClient) { + this.httpClient = httpClient; + } + public Response sendRequest(Request request) { + // ...use httpClient to send request + } +} + + +public class SecurityTransporter extends Transporter { + private String appId; + private String appToken; + public SecurityTransporter(HttpClient httpClient, String appId, String appToken) { + super(httpClient); + this.appId = appId; + this.appToken = appToken; + } + + @Override + public Response sendRequest(Request request) { + if (StringUtils.isNotBlank(appId) && StringUtils.isNotBlank(appToken)) { + request.addPayload("app-id", appId); + request.addPayload("app-token", appToken); + } + return super.sendRequest(request); + } +} + +public void demoFunction(Transporter transporter) { + Reuqest request = new Request(); + //... 省略设置 request 中数据值的代码... + Response response = transporter.sendRequest(request); +} + +// 里式替换原则 +Demo demo = new Demo(); +demo.demofunction(new SecurityTransporter(/* 省略参数 */);); +``` +在上面的代码中,子类 SecurityTransporter 的设计完全符合里式替换原则,可以替换父类出现的任何位置,并且原来代码的逻辑行为不变且正确性也没有被破坏。看上去里氏替换原则和多态没啥差别。继续看下面的例子 + +SecurityTransporter 类中 sendRequest() 函数稍加改造一下。改造前,如果 appId 或者 appToken 没有设置,我们就不做校验;改造后,如果 appId 或者 appToken 没有设置,则直接抛出 NoAuthorizationRuntimeException 未授权异常 +``` +public class SecurityTransporter extends Transporter { + //... 省略其他代码.. + @Override + public Response sendRequest(Request request) { + if (StringUtils.isBlank(appId) || StringUtils.isBlank(appToken)) { + throw new NoAuthorizationRuntimeException(...); + } + request.addPayload("app-id", appId); + request.addPayload("app-token", appToken); + return super.sendRequest(request); + } +} +``` +在改造之后的代码中,如果传递进 demoFunction() 函数的是父类 Transporter 对象,那 demoFunction() 函数并不会有异常抛出,但如果传递给 demoFunction() 函数的是子类 SecurityTransporter 对象,那 demoFunction() 有可能会有异常抛出。尽管代码中抛出的是运行时异常(Runtime Exception),我们可以不在代码中显式地捕获处理,但子类替换父类传递进 demoFunction 函数之后,整个程序的逻辑行为有了改变。 + +虽然从定义描述和代码实现上来看,多态和里式替换有点类似,但它们关注的角度是不一样的。多态是面向对象编程的一大特性,也是面向对象编程语言的一种语法。它是一种代码实现的思路。而里式替换是一种设计原则,是用来指导继承关系中子类该如何设计的,子类的设计要保证在替换父类的时候,不改变原有程序的逻辑以及不破 +坏原有程序的正确性。 + + +## 如何判断是不是违背里氏替换原则 + +里式替换原则还有另外一个更加能落地、更有指导意义的描述,那就是“DesignBy Contract”,中文翻译就是“按照协议来设计”。 + +子类在设计的时候,要遵守父类的行为约定(或者叫协议)。父类定义了函数的行为约定,那子类可以改变函数的内部实现逻辑,但不能改变函数原有的行为约定。这里的行为约定包括:函数声明要实现的功能;对输入、输出、异常的约定;甚至包括注释中所罗列的任何特殊说明。实际上,定义中父类和子类之间的关系,也可以替换成接口和实现类之间的关系。 + +### 子类违背父类声明要实现的功能 +父类中提供的 sortOrdersByAmount() 订单排序函数,是按照金额从小到大来给订单排序的,而子类重写这个 sortOrdersByAmount() 订单排序函数之后,是按照创建日期来给订单排序的。那子类的设计就违背里式替换原则。 +### 子类违背父类对输入、输出、异常的约定 +在父类中,某个函数约定:运行出错的时候返回 null;获取数据为空的时候返回空集合(empty collection)。而子类重载函数之后,实现变了,运行出错返回异常(exception),获取不到数据返回 null。那子类的设计就违背里式替换原则。在父类中,某个函数约定,输入数据可以是任意整数,但子类实现的时候,只允许输入数据是正整数,负数就抛出,也就是说,子类对输入的数据的校验比父类更加严格,那子类的设计就违背了里式替换原则。在父类中,某个函数约定,只会抛出 ArgumentNullException 异常,那子类的设计实现中只允许抛出 ArgumentNullException 异常,任何其他异常的抛出,都会导致子类违背里式替换原则。 + + +### 子类违背父类注释中所罗列的任何特殊说明 +父类中定义的 withdraw() 提现函数的注释是这么写的:“用户的提现金额不得超过账户余额......”,而子类重写 withdraw() 函数之后,针对 VIP 账号实现了透支提现的功能,也就是提现金额可以大于账户余额,那这个子类的设计也是不符合里式替换原则的。 + +以上便是三种典型的违背里式替换原则的情况。除此之外,判断子类的设计实现是否违背里式替换原则,还有一个小窍门,那就是拿父类的单元测试去验证子类的代码。如果某些单元测试运行失败,就有可能说明,子类的设计实现没有完全地遵守父类的约定,子类有可能违背了里式替换原则。 \ No newline at end of file diff --git a/Chapter6 - Design Pattern/6.6.md b/Chapter6 - Design Pattern/6.6.md new file mode 100644 index 0000000..2e99d95 --- /dev/null +++ b/Chapter6 - Design Pattern/6.6.md @@ -0,0 +1,58 @@ +# SOLID之接口隔离原则 + +## 定义 +接口隔离原则“Interface Segregation Principle”,ISP。Robert Martin 在 SOLID 原则中是这样定义它的:“Clients should not be forced to depend upon interfaces that they do not use。”直译成中文的话就是:客户端不应该强迫依赖它不需要的接口。其中的“客户端”,可以理解为接口的调用者或者使用者。 + +微服务用户系统提供了一组跟用户相关的 API 给其他系统使用,比如:注册、登录、获取用户信息等。具体代码如下所示: + +``` +public interface UserService { + boolean register(String cellphone, String password); + boolean login(String cellphone, String password); + UserInfo getUserInfoById(long id); + UserInfo getUserInfoByCellphone(String cellphone); +} +public class UserServiceImpl implements UserService { + //... +} +``` + +如果需要一个删除账户的功能,如何处理?是不是直接给 UserService 中添加一个 deleteUserByPhone 就可以。但存在一个安全隐患。 + +删除用户是一个非常慎重的操作,我们只希望通过后台管理系统来执行,所以这个接口只限于给后台管理系统使用。如果我们把它放到 UserService 中,那所有使用到 UserService 的系统,都可以调用这个接口。不加限制地被其他业务系统调用,就有可能导致误删用户。 + +当然,最好的解决方案是从架构设计的层面,通过接口鉴权的方式来限制接口的调用。不过,如果暂时没有鉴权框架来支持,我们还可以从代码设计的层面,尽量避免接口被误用。 + +我们参照接口隔离原则,调用者不应该强迫依赖它不需要的接口,将删除接口单独放到另外一个接口 RestrictedUserService 中,然后将 RestrictedUserService 只打包提供给后台管 +理系统来使用。具体的代码实现如下所示: +``` +public interface UserService { + boolean register(String cellphone, String password); + boolean login(String cellphone, String password); + UserInfo getUserInfoById(long id); + UserInfo getUserInfoByCellphone(String cellphone); +} +public interface RestrictedUserService { + boolean deleteUserByCellphone(String cellphone); + boolean deleteUserById(long id); +} +public class UserServiceImpl implements UserService, RestrictedUserService { + // ... 省略实现代码... +} +``` + +在刚刚的这个例子中,我们把接口隔离原则中的接口,理解为一组接口集合,它可以是某个微服务的接口,也可以是某个类库的接口等等。在设计微服务或者类库接口的时候,如果部分接口只被部分调用者使用,那我们就需要将这部分接口隔离出来,单独给对应的调用者使用,而不是强迫其他调用者也依赖这部分不会被用到的接口。 + +## 理解 +如何理解“接口隔离原则”? + +如果把“接口”理解为一组接口集合,可以是某个微服务的接口,也可以是某个类库的接口等。如果部分接口只被部分调用者使用,我们就需要将这部分接口隔离出来,单独给这部分调用者使用,而不强迫其他调用者也依赖这部分不会被用到的接口。 + +如果把“接口”理解为单个 API 接口或函数,部分调用者只需要函数中的部分功能,那我们就需要把函数拆分成粒度更细的多个函数,让调用者只依赖它需要的那个细粒度函数。 + +如果把“接口”理解为 OOP 中的接口,也可以理解为面向对象编程语言中的接口语法。那接口的设计要尽量单一,不要让接口的实现类和调用者,依赖不需要的接口函数。 + +接口隔离原则与单一职责原则的区别? + +单一职责原则针对的是模块、类、接口的设计。接口隔离原则相对于单一职责原则,一方面更侧重于接口的设计,另一方面它的思考角度也是不同的。接口隔离原则提供了一种判断接口的职责是否单一的标准:通过调用者如何使用接口来间接地判定。如果调用者只使用部分接口或接口的部分功能,那接口的设计就不够职责单一。 + diff --git a/Chapter6 - Design Pattern/6.7.md b/Chapter6 - Design Pattern/6.7.md new file mode 100644 index 0000000..4549072 --- /dev/null +++ b/Chapter6 - Design Pattern/6.7.md @@ -0,0 +1,7 @@ +# SOLID之依赖反转原则 +依赖反转原则的英文翻译是 Dependency Inversion Principle,缩写为 DIP。中文翻译有时候也叫依赖倒置原则。 + +> High-level modules shouldn’t depend on low-level modules. Both modules should depend on abstractions. In addition, abstractions shouldn’t depend on details. Details depend on abstractions. + +高层模块不应该依赖低层模块,高层模块和低层模块都应通过抽象来互相依赖。抽象不要依赖具体的实现细节,具体的实现细节应该依赖抽象。 + diff --git a/Chapter6 - Design Pattern/6.8.md b/Chapter6 - Design Pattern/6.8.md new file mode 100644 index 0000000..c3266b8 --- /dev/null +++ b/Chapter6 - Design Pattern/6.8.md @@ -0,0 +1,82 @@ +# 聊聊重构 + +> 什么情况下要重构?到底重构什么?又该如何重构?为了保证重构不出错,有哪些有效的技术手段? + +重构代码对一个工程师能力的要求,要比单纯写代码高得多。重构需要你能洞察出代码存在的坏味道或者设计上的不足,并且能合理、熟练地利用设计思想、原则、模式、编程规范等理论知识解决这些问题。 + + + +## 重构的目的:为什么要重构(why)? +软件设计大师 Martin Fowler 是这样定义重构的:“重构是一种对软件内部结构的改善,目的是在不改变软件的可见行为的情况下,使其更易理解,修改成本更低。” + +重构不改变外部的可见行为”。我们可以把重构理解为,在保持功能不变的前提下,利用设计思想、原则、模式、编程规范等理论来优化代码,修改设计上的不足,提高代码质量。 + + +## 为什么要进行代码重构? +重构是时刻保证代码质量的一个极其有效的手段,不至于让代码腐化到无可救药的地步。项目在演进,代码不停地在堆砌。如果没有人为代码的质量负责任,代码总是会往越来越混乱的方向演进。当混乱到一定程度之后,量变引起质变,项目的维护成本已经高过重新开发一套新代码的成本,想要再去重构,已经没有人能做到了。 + +优秀的代码或架构不是一开始就能完全设计好的,就像优秀的公司和产品也都是迭代出来的。我们无法 100% 遇见未来的需求,也没有足够的精力、时间、资源为遥远的未来买单,所以,随着系统的演进,重构代码也是不可避免的。 + +最后,重构是避免过度设计的有效手段。在我们维护代码的过程中,真正遇到问题的时候,再对代码进行重构,能有效避免前期投入太多时间做过度的设计,做到有的放矢 + +重构对一个工程师本身技术的成长也有重要的意义。重构实际上是对我们学习的经典设计思想、设计原则、设计模式、编程规范的一种应用。重构实际上就是将这些理论知识,应用到实践的一个很好的场景,能够锻炼我们熟练使用这些理论知识的能力。除此之外,平时堆砌业务逻辑,你可能总觉得没啥成长,而将一个比较烂的代码重构成一个比较好的代码,会让你很有成就感。 + +## 重构的对象:到底重构什么(what)? +根据重构的规模,我们可以笼统地分为大规模高层次重构(以下简称为“大型重构”)和小规模低层次的重构(以下简称为“小型重构”)。 + +大型重构指的是对顶层代码设计的重构,包括:系统、模块、代码结构、类与类之间的关系等的重构,重构的手段有:分层、模块化、解耦、抽象可复用组件等等。这类重构的工具就是我们学习过的那些设计思想、原则和模式。这类重构涉及的代码改动会比较多,影响面会比较大,所以难度也较大,耗时会比较长,引入 bug 的风险也会相对比较大。 + +小型重构指的是对代码细节的重构,主要是针对类、函数、变量等代码级别的重构,比如规范命名、规范注释、消除超大类或函数、提取重复代码等等。小型重构更多的是利用我们能后面要讲到的编码规范。这类重构要修改的地方比较集中,比较简单,可操作性较强,耗时会比较短,引入 bug 的风险相对来说也会比较小。你只需要熟练掌握各种编码规范,就可以做到得心应手。 + + +## 重构的时机:什么时候重构(when)? +代码烂到一定程度之后才去重构吗?当然不是。因为当代码真的烂到出现“开发效率低,招了很多人,天天加班,出活却不多,线上 bug 频发。这时候可能就是积重难返了。 + +所以推崇在项目开发或者系统开发的过程中,保持设计和一些思想,写出优雅的代码。持续不断的小重构。 + +要有 Owner 意识,平时项目不那么紧张的时候,就可以看看项目中有哪些写得不够好的、可以优化的代码,主动去重构一下。或者,在修改、添加某个功能代码的时候,可以顺手把不符合编码规范、不好的设计重构一下。总之,就像把单元测试、Code Review 作为开发的一部分,我们如果能把持续重构也作为开发的一部分,成为一种开发习惯,对项目、对自己都会很有好处。 + +尽管我们说重构能力很重要,但持续重构意识更重要。我们要正确地看待代码质量和重构这件事情。技术在更新、需求在变化、人员在流动,代码质量总会在下降,代码总会存在不完美,重构就会持续在进行。时刻具有持续重构意识,才能避免开发初期就过度设计,避免代码维护的过程中质量的下降。而那些看到别人代码有点瑕疵就一顿乱骂,或者花尽心思去构思一个完美设计的人,往往都是因为没有树立正确的代码质量观,没有持续重构意识。 + +## 如何重构? +按照重构的规模,重构可以笼统地分为大型重构和小型重构。对于这两种不同规模的重构,我们要区别对待。 + +对于大型重构来说,因为涉及的模块、代码会比较多,如果项目代码质量又比较差,耦合比较严重,往往会牵一发而动全身,本来觉得一天就能完成的重构,你会发现越改越多、越改越乱,没一两个礼拜都搞不定。而新的业务开发又与重构相冲突,最后只能半途而废,revert 掉所有的改动,很失落地又去堆砌烂代码了 + +所以最好事先做好重构规划,每个节点交付小的重构结果,进行测试、验证,趁着项目的测试资源,没问题之后进行下一阶段的重构,保证代码持续可运行、可测试、逻辑正确的状态。按照规划,一小部分进行重构,可以保证项目正常交付、也可以趁着测试资源、也不会 delay 正常项目,不会和正在开发的新 feature 进行冲突。 + + +## 重构的质量保证 +除了 QA 测试之外,最有效、可落地的测试就是单元测试可。当重构完成后,如果新的代码可通过单元测试所有 case,那么说明之前的逻辑没有被破坏,原有系统的行为、外部可见性没有改变。 + +那 iOS 侧如何开展单元测试,可以见[这篇文章](./../Chapter1%20-%20iOS/1.75.md) + +## 代码是否需要“解耦”? +间接的衡量标准有很多,比如,看修改代码是否牵一发而动全身。直接的衡量标准是把模块与模块、类与类之间的依赖关系画出来,根据依赖关系图的复杂性来判断是否需要解耦重构。 + +## 如何解耦 +软件设计与开发最重要的工作之一就是应对复杂性。人处理复杂性的能力是有限的。过于复杂的代码往往在可读性、可维护性上都不友好。那如何来控制代码的复杂性呢?手段有很多,我个人认为,最关键的就是解耦,保证代码松耦合、高内聚。如果说重构是保证代码质量不至于腐化到无可救药地步的有效手段,那么利用解耦的方法对代码重构,就是保证代码不至于复杂到无法控制的有效手段。 + +代码“高内聚、松耦合”,也就意味着,代码结构清晰、分层和模块化合理、依赖关系简单、模块或类之间的耦合小,那代码整体的质量就不会差。即便某个具体的类或者模块设计得不怎么合理,代码质量不怎么高,影响的范围是非常有限的。我们可以聚焦于这个模块或者类,做相应的小型重构。而相对于代码结构的调整,这种改动范围比较集中的小型重构的难度就容易多了。 + +1. 封装与抽象 +2. 中间层 +3. 模块化 +4. 其他设计思想和原则 + +单一职责原则 + +内聚性和耦合性并非独立的。高内聚会让代码更加松耦合,而实现高内聚的重要指导原则就是单一职责原则。模块或者类的职责设计得单一,而不是大而全,那依赖它 +的类和它依赖的类就会比较少,代码耦合也就相应的降低了。 + +基于接口而非实现编程 +基于接口而非实现编程能通过接口这样一个中间层,隔离变化和具体的实现。这样做的好处是,在有依赖关系的两个模块或类之间,一个模块或者类的改动,不会影响到另一个模块或类。实际上,这就相当于将一种强依赖关系(强耦合)解耦为了弱依赖关系(弱耦合)。 + +依赖注入 +跟基于接口而非实现编程思想类似,依赖注入也是将代码之间的强耦合变为弱耦合。尽管依赖注入无法将本应该有依赖关系的两个类,解耦为没有依赖关系,但可以让耦合关系没那么紧密,容易做到插拔替换。 + +多用组合少用继承 +继承是一种强依赖关系,父类与子类高度耦合,且这种耦合关系非常脆弱,牵一发而动全身,父类的每一次改动都会影响所有的子类。相反,组合关系是一种弱依赖关系,这种关系更加灵活,所以,对于继承结构比较复杂的代码,利用组合来替换继承,也是一种解耦的有效手段。 + +迪米特法则 +迪米特法则讲的是,不该有直接依赖关系的类之间,不要有依赖;有依赖关系的类之间,尽量只依赖必要的接口。从定义上,我们明显可以看出,这条原则的目的就是为了实现代码的松耦合。 diff --git a/Chapter6 - Design Pattern/6.9.md b/Chapter6 - Design Pattern/6.9.md new file mode 100644 index 0000000..bf38f5e --- /dev/null +++ b/Chapter6 - Design Pattern/6.9.md @@ -0,0 +1,209 @@ +# 单例模式 + +> 为什么要使用单例? +单例存在哪些问题? +单例与静态类的区别? +有何替代的解决方案? + +## 为什么要使用单例? + +创建型模式主要解决对象的创建问题,封装复杂的创建过程,解耦对象的创建代码和使用代码。其中单例模式、工厂模式、建造者模式、原型模式都是创建型模式。 + +单例设计模式(Singleton Design Pattern)理解起来非常简单。一个类只允许创建一个对象(或者实例),那这个类就是一个单例类,这种设计模式就叫作单例设计模式,简称单例模式。 + +### 处理资源访问冲突 +我们先来看第一个例子。在这个例子中,我们自定义实现了一个往文件中打印日志的 Logger 类。具体的代码实现如下所示: +``` +public class Logger { + private FileWriter writer; + public Logger() { + File file = new File("/Users/meiying/log.txt"); + writer = new FileWriter(file, true); //true表示追加写入 + } + public void log(String message) { + writer.write(mesasge); + } +} +// Logger类的应用示例: +public class UserController { + private Logger logger = new Logger(); + public void login(String username, String password) { + // ...省略业务逻辑代码... + logger.log(username + " logined!"); + } +} +public class OrderController { + private Logger logger = new Logger(); + public void create(OrderVo order) { + // ...省略业务逻辑代码... + logger.log("Created an order: " + order.toString()); + } +} +``` +在上面的代码中,我们注意到,所有的日志都写入到同一个文件 /Users/meiying/log.txt 中。在 UserController 和 OrderController 中,我们分别创 +建两个 Logger 对象。在 Web 容器的 Servlet 多线程环境下,如果两个 Servlet 线程同时分别执行 login() 和 create() 两个函数,并且同时写日志到 log.txt 文件中,那就有可能存在日志信息互相覆盖的情况 + +那如何来解决这个问题呢?我们最先想到的就是通过加锁的方式:给 log() 函数加互斥锁(Java 中可以通过 synchronized 的关键字),同一时刻只允许一个线程调用执行 log()函数。具体的代码实现如下所示: + +``` +public class Logger { + private FileWriter writer; + public Logger() { + File file = new File("/Users/wangzheng/log.txt"); + writer = new FileWriter(file, true); //true表示追加写入 + } + public void log(String message) { + synchronized(this) { + writer.write(mesasge); + } + } +} +``` +可以解决问题吗?不会。因为这种锁是一个对象级别的锁,一个对象在不同的线程下同时调用 log() 函数,会被强制要求顺序执行。但是,不同的对象之间并不共享同一把锁。在不同的线程下,通过不同的对象调用执行 log() 函数,锁并不会起作用,仍然有可能存在写入日志互相覆盖的问题 + +实际上,要想解决这个问题也不难,我们只需要把对象级别的锁,换成类级别的锁就可以了。让所有的对象都共享同一把锁。这样就避免了不同对象之间同时调用 log() 函数,而导致的日志覆盖问题。具体的代码实现如下所 + +``` +public void log(String message) { + synchronized(Logger.class) { // 类级别的锁 + writer.write(mesasge); + } +} +``` +除了使用类级别锁之外,实际上,解决资源竞争问题的办法还有很多,分布式锁是最常听到的一种解决方案。不过,实现一个安全可靠、无 bug、高性能的分布式锁,并不是件容易的事情。除此之外,并发队列(比如 Java 中的 BlockingQueue)也可以解决这个问题:多个线程同时往并发队列里写日志,一个单独的线程负责将并发队列中的数据,写入到日志文件。这种方式实现起来也稍微有点复杂。 + +相对于这两种解决方案,单例模式的解决思路就简单一些了。单例模式相对于之前类级别锁的好处是,不用创建那么多 Logger 对象,一方面节省内存空间,另一方面节省系统文件句柄(对于操作系统来说,文件句柄也是一种资源,不能随便浪费)。 + +我们将 Logger 设计成一个单例类,程序中只允许创建一个 Logger 对象,所有的线程共享使用的这一个 Logger 对象,共享一个 FileWriter 对象,而 FileWriter 本身是对象级别线程安全的,也就避免了多线程情况下写日志会互相覆盖的问题。按照这个设计思路,我们实现了 Logger 单例类。具体代码如下所示: +``` +public class Logger { + private FileWriter writer; + private static final Logger instance = new Logger(); + private Logger() { + File file = new File("/Users/wangzheng/log.txt"); + writer = new FileWriter(file, true); //true表示追加写入 + } + public static Logger getInstance() { + return instance; + } + public void log(String message) { + writer.write(mesasge); + } +} + +// Logger类的应用示例: +public class UserController { + public void login(String username, String password) { + // ...省略业务逻辑代码... + Logger.getInstance().log(username + " logined!"); + } +} +public class OrderController { + private Logger logger = new Logger(); + public void create(OrderVo order) { + // ...省略业务逻辑代码... + Logger.getInstance().log("Created a order: " + order.toString()); + } +} +``` + +### 表示全局唯一类 +如果有些数据在系统中只应保存一份,那就比较适合设计为单例类。比如,配置信息类。在系统中,我们只有一个配置文件,当配置文件被加载到内存之后,以 +对象的形式存在,也理所应当只有一份。 + +再比如,唯一递增 ID 号码生成器。如果程序中有两个对象,那就会存在生成重复 ID 的情况,所以,我们应该将 ID 生成器类设计为单例 + + +## 如何实现一个单例 +### 饿汉式 +饿汉式的实现方式比较简单。在类加载的时候,instance 静态实例就已经创建并初始化好了,所以,instance 实例的创建过程是线程安全的。不过,这样的实现方式不支持延迟加载(在真正用到 IdGenerator 的时候,再创建实例),从名字中我们也可以看出这一点。具体的代码实现如下所示: +``` +public class IdGenerator { + private AtomicLong id = new AtomicLong(0); + private static final IdGenerator instance = new IdGenerator(); + private IdGenerator() {} + public static IdGenerator getInstance() { + return instance; + } + public long getId() { + return id.incrementAndGet(); + } +} +``` +有人觉得这种实现方式不好,因为不支持延迟加载,如果实例占用资源多(比如占用内存多)或初始化耗时长(比如需要加载各种配置文件),提前初始化实例是一种浪费资源的行为。最好的方法应该在用到的时候再去初始化 + +如果初始化耗时长,那我们最好不要等到真正要用它的时候,才去执行这个耗时长的初始化过程,这会影响到系统的性能(比如,在响应客户端接口请求的时候,做这个初始化操作,会导致此请求的响应时间变长,甚至超时)。采用饿汉式实现方式,将耗时的初始化操作,提前到程序启动的时候完成,这样就能避免在程序运行的时候,再去初始化导致的性能问题。 + +如果实例占用资源多,按照 fail-fast 的设计原则(有问题及早暴露),那我们也希望在程序启动时就将这个实例初始化好。如果资源不够,就会在程序启动的时候触发报错(比如Java 中的 PermGen Space OOM),我们可以立即去修复。这样也能避免在程序运行一段时间后,突然因为初始化这个实例占用资源过多,导致系统崩溃,影响系统的可用性 + +### 懒汉式 +懒汉式相对于饿汉式的优势是支持延迟加载。具体的代码实现如下所示: +``` +public class IdGenerator { + private AtomicLong id = new AtomicLong(0); + private static IdGenerator instance; + private IdGenerator() {} + public static synchronized IdGenerator getInstance() { + if (instance == null) { + instance = new IdGenerator(); + } + return instance; + } + public long getId() { + return id.incrementAndGet(); + } +} +``` +不过懒汉式的缺点也很明显,我们给 getInstance() 这个方法加了一把大锁(synchronzed),导致这个函数的并发度很低。量化一下的话,并发度是 1,也就相当于串行操作了。而这个函数是在单例使用期间,一直会被调用。如果这个单例类偶尔会被用到,那这种实现方式还可以接受。但是,如果频繁地用到,那频繁加锁、释放锁及并发度低等问题,会导致性能瓶颈,这种实现方式就不可取了 + + +### 双重检测 +饿汉式不支持延迟加载,懒汉式有性能问题,不支持高并发。那我们再来看一种既支持延迟加载、又支持高并发的单例实现方式,也就是双重检测实现方式。 +在这种实现方式中,只要 instance 被创建之后,即便再调用 getInstance() 函数也不会再进入到加锁逻辑中了。所以,这种实现方式解决了懒汉式并发度低的问题。具体的代码实现如下所示: + +``` +public class IdGenerator { + private AtomicLong id = new AtomicLong(0); + private static IdGenerator instance; + private IdGenerator() {} + public static IdGenerator getInstance() { + if (instance == null) { + synchronized(IdGenerator.class) { // 此处为类级别的锁 + if (instance == null) { + instance = new IdGenerator(); + } + } + } + return instance; + } + public long getId() { + return id.incrementAndGet(); + } +} +``` +网上有人说,这种实现方式有些问题。因为指令重排序,可能会导致 IdGenerator 对象被new 出来,并且赋值给 instance 之后,还没来得及初始化(执行构造函数中的代码逻辑),就被另一个线程使用了。 + +要解决这个问题,我们需要给 instance 成员变量加上 volatile 关键字,禁止指令重排序才行。实际上,只有很低版本的 Java 才会有这个问题。我们现在用的高版本的 Java 已经在 JDK 内部实现中解决了这个问题(解决的方法很简单,只要把对象 new 操作和初始化操作设计为原子操作,就自然能禁止重排序 + +### 静态内部类 +我们再来看一种比双重检测更加简单的实现方法,那就是利用 Java 的静态内部类。它有点类似饿汉式,但又能做到了延迟加载。具体是怎么做到的呢?我们先来看它的代码实现。 +``` +public class IdGenerator { + private AtomicLong id = new AtomicLong(0); + private IdGenerator() {} + private static class SingletonHolder { + private static final IdGenerator instance = new IdGenerator(); + } + public static IdGenerator getInstance() { + return SingletonHolder.instance; + } + public long getId() { + return id.incrementAndGet(); + } +} +``` +SingletonHolder 是一个静态内部类,当外部类 IdGenerator 被加载的时候,并不会创建 SingletonHolder 实例对象。只有当调用 getInstance() 方法时,SingletonHolder 才会被加载,这个时候才会创建 instance。insance 的唯一性、创建过程的线程安全性,都由 JVM 来保证。所以,这种实现方法既保证了线程安全,又能做到延迟加载 + +## 如何理解单例模式的唯一性 +“一个类只允许创建唯一一个对象(或者实例),那这个类就是一个单例类,这种设计模式就叫作单例设计模式,简称单例模式。定义中提到,“一个类只允许创建唯一一个对象”。那对象的唯一性的作用范围是什么呢?是指线程内只允许创建一个对象,还是指进程内只允许创建一个对象?答案是后者,也就是说,单例模式创建的对象是进程唯一的 + diff --git a/Chapter6 - Design Pattern/chapter6.md b/Chapter6 - Design Pattern/chapter6.md index e18dfdd..b41c5aa 100644 --- a/Chapter6 - Design Pattern/chapter6.md +++ b/Chapter6 - Design Pattern/chapter6.md @@ -4,5 +4,29 @@ 第六部分主要介绍设计模式相关的概念和思考 * [1、声明式与命令式](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter6%20-%20Design%20Pattern/6.1.md) + * [2、面向对象 ](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter6%20-%20Design%20Pattern/6.2.md) + * [3、SOLID之单一职责 SRP](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter6%20-%20Design%20Pattern/6.3.md) + * [4、SOLID之开闭原则](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter6%20-%20Design%20Pattern/6.4.md) + * [5、SOLID之里氏替换](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter6%20-%20Design%20Pattern/6.5.md) + * [6、SOLID之接口隔离原则](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter6%20-%20Design%20Pattern/6.6.md) + * [7、SOLID之依赖反转原则](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter6%20-%20Design%20Pattern/6.7.md) + * [8、聊聊重构](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter6%20-%20Design%20Pattern/6.8.md) + * [9、单例模式](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter6%20-%20Design%20Pattern/6.9.md) + * [10、工厂模式](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter6%20-%20Design%20Pattern/6.10.md) + * [11、建造者模式](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter6%20-%20Design%20Pattern/6.11.md) + * [12、原型模式](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter6%20-%20Design%20Pattern/6.12.md) + * [13、代理模式](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter6%20-%20Design%20Pattern/6.13.md) + * [14、桥接模式](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter6%20-%20Design%20Pattern/6.14.md) + * [15、装饰器模式](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter6%20-%20Design%20Pattern/6.15.md) + * [16、适配器模式](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter6%20-%20Design%20Pattern/6.16.md) + * [17、门面模式](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter6%20-%20Design%20Pattern/6.17.md) + * [18、组合模式](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter6%20-%20Design%20Pattern/6.18.md) + * [19、享元模式](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter6%20-%20Design%20Pattern/6.19.md) + * [20、观察者模式](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter6%20-%20Design%20Pattern/6.20.md) + * [21、模板模式](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter6%20-%20Design%20Pattern/6.21.md) + * [22、模板模式](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter6%20-%20Design%20Pattern/6.22.md) + * [23、职责链模式](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter6%20-%20Design%20Pattern/6.23.md) + + \ No newline at end of file diff --git a/SUMMARY.md b/SUMMARY.md index 3cdb1fc..f20e6ce 100644 --- a/SUMMARY.md +++ b/SUMMARY.md @@ -184,6 +184,28 @@ * [Chapter6 - Design Pattern](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter6%20-%20Design%20Pattern/chapter7.md) * [1、声明式与命令式](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter6%20-%20Design%20Pattern/6.1.md) + * [2、面向对象 ](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter6%20-%20Design%20Pattern/6.2.md) + * [3、SOLID之单一职责 SRP](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter6%20-%20Design%20Pattern/6.3.md) + * [4、SOLID之开闭原则](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter6%20-%20Design%20Pattern/6.4.md) + * [5、SOLID之里氏替换](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter6%20-%20Design%20Pattern/6.5.md) + * [6、SOLID之接口隔离原则](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter6%20-%20Design%20Pattern/6.6.md) + * [7、SOLID之依赖反转原则](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter6%20-%20Design%20Pattern/6.7.md) + * [8、聊聊重构](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter6%20-%20Design%20Pattern/6.8.md) + * [9、单例模式](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter6%20-%20Design%20Pattern/6.9.md) + * [10、工厂模式](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter6%20-%20Design%20Pattern/6.10.md) + * [11、建造者模式](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter6%20-%20Design%20Pattern/6.11.md) + * [12、原型模式](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter6%20-%20Design%20Pattern/6.12.md) + * [13、代理模式](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter6%20-%20Design%20Pattern/6.13.md) + * [14、桥接模式](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter6%20-%20Design%20Pattern/6.14.md) + * [15、装饰器模式](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter6%20-%20Design%20Pattern/6.15.md) + * [16、适配器模式](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter6%20-%20Design%20Pattern/6.16.md) + * [17、门面模式](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter6%20-%20Design%20Pattern/6.17.md) + * [18、组合模式](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter6%20-%20Design%20Pattern/6.18.md) + * [19、享元模式](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter6%20-%20Design%20Pattern/6.19.md) + * [20、观察者模式](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter6%20-%20Design%20Pattern/6.20.md) + * [21、模板模式](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter6%20-%20Design%20Pattern/6.21.md) + * [22、模板模式](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter6%20-%20Design%20Pattern/6.22.md) + * [23、职责链模式](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter6%20-%20Design%20Pattern/6.23.md) * [Chapter7 - Geek Talk](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter7%20-%20Geek%20Talk/chapter7.md) * [1、命令行文件查找](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter7%20-%20Geek%20Talk/7.1.md) diff --git a/assets/EventBus-ObserverRegisterTable.png b/assets/EventBus-ObserverRegisterTable.png new file mode 100644 index 0000000..fd00140 Binary files /dev/null and b/assets/EventBus-ObserverRegisterTable.png differ diff --git a/assets/EventBus-Post.png b/assets/EventBus-Post.png new file mode 100644 index 0000000..b084d4e Binary files /dev/null and b/assets/EventBus-Post.png differ diff --git a/assets/oop-mixBetterThanSuper.png b/assets/oop-mixBetterThanSuper.png new file mode 100644 index 0000000..0573ead Binary files /dev/null and b/assets/oop-mixBetterThanSuper.png differ