赞
踩
单一职责原则(Single Responsibility principle),这个名字非常容易让我们望文生义,我们可能会理解成,一个类只干一件事,这看起来似乎是一项再合理不过的要求了。因为,几乎所有的程序员都知道“高内聚、低耦合”,都知道该把相关的代码放到一起。
所以,如果我们随便拿一个模块去问他的作者,这个模块是不是只做了一件事,他们的答案几乎都会是一样的:是的,只做了一件事。那么,既然这个设计原则如此通用,以至于所有的人都可以做到,那我们为什么还要有这样一个设计原则呢?
原因就在于,我们一开始的理解就是错的,我们把单一职责理解成了有关如何组合的原则,但实际上,单一职责是关于如何分解的。
那到底什么是单一职责原则呢?
正如 Robert Martin 所说,单一职责的定义经历了一些变化。在《敏捷软件开发:原则、实践与模式》中其定义是,“一个模块应该有且仅有一个变化的原因”;而到了《架构整洁之道》中,其定义就变成了“一个模块应该对一类且仅对一类行为者(actor)负责”。
单一职责原则和一个类只干一件事之间,最大的差别就是,将变化纳入了考量。
我们先分析第一个定义:一个模块应该有且仅有一个变化的原因。变化是我们最不愿意面对却不得不面对的事,因为变化会引发新的不确定性,可能是新增功能自身的稳定问题,也可能是旧有功能遭到破坏带来的问题。
所以,一个模块最理想的状态是不改变,其次是少改变,它可以成为一个模块设计好坏的衡量标准。
在真实项目中,一个模块之所以会频繁变化,关键点就在于能引起它改变的原因太多了。
怎么理解呢?我们来看一个例子。假设我们要开发一个项目管理的工具,自然少不了一个用户的类,我们可能设计出这样一个用户类:
// 用户类
class User {
// 修改密码
void changePassword(String password);
// 加入一个项目
void joinProject(Project project);
// 接管一个项目,成为管理员
void takeOverProject(Project project);
...
}
看上去,这个类设计得还挺合理,有用户信息管理、有项目管理等等。没过多久,新的需求来了,要求每个用户能够设置电话号码,所以,你给它增加了一个新的方法:
void changePhoneNumber(PhoneNumber phoneNumber):
过了几天,又来了新需求,要查看一个用户加入了多少项目:
int countProject();
就这样,左一个需求,右一个需求,几乎每个需求都要改到这个类。那会导致什么结果呢?一方面,这个类会不断膨胀;另一方面,内部的实现会越来越复杂。按照我们提出的衡量标准,这个类变动的频繁程度显然是不理想的,主要原因就在于它引起变动的需求太多了:
为什么要增加电话号码呢?因为这是用户管理的需求。用户管理的需求还会有很多,比如,用户实名认证、用户组织归属等等;
为什么要查看用户加入多少项目呢?这是项目管理的需求。项目管理的需求还会有很多,比如,团队管理、项目权限等等。
这就是两种完全不同的需求,但它们都改到了同一个类,所以,这个 User 类就很难稳定下来。解决这种问题,最好的办法就是把不同的需求引起的变动拆分开来。针对这里的用户管理和项目管理两种不同需求,我们完全可以把这个 User 类拆成两个类。比如,像下面这样,把用户管理类的需求放到 User 类里,把项目
管理类的需求放到 Member 类里:
// 用户类
class User {
// 修改密码
void changePassword(String password);
...
}
// 项目成员类
class Member
// 加入一个项目
void joinProject(Project project);
// 接管一个项目,成为管理员
void takeOverProject(Project project);
...
}
如此一来,用户管理的需求只要调整 User 类就好,而项目管理的需求只要调整 Member 类即可,二者各自变动的理由就少了一些。
1.变化的来源
确实是这样的,想要更好地理解单一职责原则,重要的就是要把不同的关注点分离出来。在上面这个例子中,分离的是不同的业务关注点。所以,理解单一职责原则本质上就是要理解分离关注点。
按照之前的说法,分离关注点,应该是发现的关注点越多越好,粒度越小越好。如果你能看到的关注点越多,就可以构建出更多的类,但每个类的规模相应地就会越小,与之相关的需求变动也会越少,它能够稳定下来的几率就会越大。我们代码库里稳定的类越多越好,这应该是我们努力的一个方向。
不过,也许你会想,如果将这种思路推演到极致,一个类应该只有一个方法,这样,它受到的影响应该是最小的。的确如此,但我们在真实项目中,一个类通常都不只有一个方法,如果我们要求所有人都做到极致,显然也是不现实的。
那应该把哪些内容组织到一起呢?这就需要我们考虑单一职责原则定义的升级版,也就是第二个定义:一个模块应该对一类且仅对一类行为者负责。
如果说第一个定义将变化纳入了考量,那这个升级版的定义则将变化的来源纳入了考量。
需求为什么会改变?因为有各种提出需求的人,不同的人提出的需求,其关注点是不同的。在前面的那个关于用户的讨论中,关心用户管理和关心项目管理的可能就是两拨完全不同的人,至少他们在提需求的时候扮演的是两种不同的角色。
两种不同角色的人,两件不同的事,到了代码里却混在了一起,这是不合理的。所以,分开才是一个好选择。用户管理的人,我和他们聊 User,项目管理的人,我们来讨论 Member。
康威定律:一个组织设计出的系统,其结构受限于其组织的沟通结构。
Robert Martin 说,单一职责原则是基于康威定律的一个推论:一个软件系统的最佳结构高度依赖于使用这个软件的组织的内部结构。如果我们的软件结构不能够与组织结构对应,就会带来一系列麻烦,前面的那个例子只是一个小例子。
实际上,当我们更新了对于单一职责原则的理解,你会发现,它的应用范围不仅仅可以放在类这样的级别,也可以放到更大的级别。
我给你举个例子。我曾经接触过一个交易平台,其中有一个关键模型:手续费率,就是交易一次按什么比例收取佣金。平台可以利用手续费率做不同的活动,比如,给一些人比较低的手续费率,鼓励他们来交易,不同的手续费率意味着对不同交易行为的鼓励。
所以,对运营人员来说,手续费率是一个可以玩出花的东西。然而,对交易系统而言,稳定高效是重点。显然,经常修改的手续费率和稳定的系统之间存在矛盾。
经过分析,我们发现,这是两类不同的行为者。所以,在设计的时候,我们把手续费率设置放到运营子系统,而交易子系统只负责读取手续费率。当运营子系统修改了手续费率,会把最新的结果更新到交易子系统中。至于各种手续费率设置的花样,交易子系统根本不需要关心。
你看,单一职责原则也可以指导我们在不同的子系统之间进行职责分配。所以,单一职责原则这个看起来最简单的原则,实际上也蕴含着很多值得挖掘的内容。要想理解好单一职责原则:
我们需要理解封装,知道要把什么样的内容放到一起;
我们需要理解分离关注点,知道要把不同的内容拆分开来;
我们需要理解变化的来源,知道把不同行为者负责的代码放到不同的地方。
实际上,在真正的软件开发中,我们也没必要过于未雨绸缪,过度设计。所以,我们可以先写一个粗粒度的类,满足业务需求。随着业务的发展,如果粗粒度的类
越来越庞大,代码越来越多,这个时候,我们就可以将这个粗粒度的类,拆分成几个更细粒度的类。这就是所谓的持续重构。
下面这几条判断原则,比起很主观地去思考类是否职责单一,要更有指导意义、更具有可执行性:
类中的代码行数、函数或属性过多,会影响代码的可读性和可维护性,我们就需要考虑对类进行拆分;
类依赖的其他类过多,或者依赖类的其他类过多,不符合高内聚、低耦合的设计思想,我们就需要考虑对类进行拆分;
私有方法过多,我们就要考虑能否将私有方法独立到新的类中,设置为 public 方法,供更多的类使用,从而提高代码的复用性;
比较难给类起一个合适名字,很难用一个业务名词概括,或者只能用一些笼统的 Manager、Context 之类的词语来命名,这就说明类的职责定义得可能不够清晰;
类中大量的方法都是集中操作类中的某几个属性,比如,在 UserInfo 例子中,如果一半的方法都是在操作 address 信息,那就可以考虑将这几个属性和对应 的方法拆分出来。
2. 类的职责是否设计得越单一越好
为了满足单一职责原则,是不是把类拆得越细就越好呢?答案是否定的。我们还是通过一个例子来解释一下。Serialization 类实现了一个简单协议的序列化和反序
列功能,具体代码如下:
public class Serialization {
private static final String IDENTIFIER_STRING = "UEUEUE;";
private Gson gson;
public Serialization() {
this.gson = new Gson();
}
public String serialize(Map<String, String> object) {
StringBuilder textBuilder = new StringBuilder();
textBuilder.append(IDENTIFIER_STRING);
textBuilder.append(gson.toJson(object));
return textBuilder.toString();
}
public Map<String, String> 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<String, String> 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<String, String> 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 类的代码,那就会导致序列化、反序列化不匹配,程序运行出错,也就是说,拆分之后,代码的可维护性变差了。
实际上,不管是应用设计原则还是设计模式,最终的目的还是提高代码的可读性、可扩展性、复用性、可维护性等。我们在考虑应用某一个设计原则是否合理的时候,也可以以此作为最终的考量标准。
总结
1.如何理解单一职责原则(SRP)?
一个类只负责完成一个职责或者功能。不要设计大而全的类,要设计粒度小、功能单一的类。单一职责原则是为了实现代码高内聚、低耦合,提高代码的复用性、可读性、可维护性。
2.如何判断类的职责是否足够单一?
不同的应用场景、不同阶段的需求背景、不同的业务层面,对同一个类的职责是否单一,可能会有不同的判定结果。实际上,一些侧面的判断指标更具有指导意义
和可执行性,比如,出现下面这些情况就有可能说明这类的设计不满足单一职责原则:
类中的代码行数、函数或者属性过多;
类依赖的其他类过多,或者依赖类的其他类过多;
私有方法过多;
比较难给类起一个合适的名字;
类中大量的方法都是集中操作类中的某几个属性。
3.类的职责是否设计得越单一越好?
单一职责原则通过避免设计大而全的类,避免将不相关的功能耦合在一起,来提高类的内聚性。同时,类职责单一,类依赖的和被依赖的其他类也会变少,减少了代码的耦合性,以此来实现代码的高内聚、低耦合。但是,如果拆分得过细,实际上会适得其反,反倒会降低内聚性,也会影响代码的可维护性。
1.不修改代码
开放封闭原则是这样表述的:
软件实体(类、模块、函数)应该对扩展开放,对修改封闭。
这个说法是 Bertrand Meyer 在其著作《面向对象软件构造》(Object-Oriented Software Construction)中提出来的,它给软件设计提出了一个极高的要求:不
修改代码。
或许你想问,不修改代码,那我怎么实现新的需求呢?答案就是靠扩展。用更通俗的话来解释,就是新需求应该用新代码实现。
开放封闭原则向我们描述的是一个结果,就是我们可以不修改代码而仅凭扩展就完成新功能。但是,这个结果的前提是要在软件内部留好扩展点,而这正是需要我
们去设计的地方。因为每一个扩展点都是一个需要设计的模型。
举个例子,假如我们正在开发一个酒店预订系统,针对不同的用户,我们需要计算出不同的房价。比如,普通用户是全价,金卡是 8 折,银卡是 9 折,代码写出来可能是这样的:
class HotelService {
public double getRoomPrice(final User user, final Room room) {
double price = room.getPrice();
if (user.getLevel() == Level.GOLD) {
return price * 0.8;
}
if (user.getLevel() == Level.SILVER) {
return price * 0.9;
}
return price;
}
}
这时,新的需求来了,要增加白金卡会员,给出 75 折的优惠,如法炮制的写法应该是这样的:
class HotelService {
public double getRoomPrice(final User user, final Room room) {
double price = room.getPrice();
if (user.getLevel() == UserLevel.GOLD) {
return price * 0.8;
}
if (user.getLevel() == UserLevel.SILVER) {
return price * 0.9;
}
if (user.getLevel() == UserLevel.PLATINUM) {
return price * 0.75;
}
return price;
}
}
显然,这种做法就是修改代码的做法,每增加一个新的类型就要修改一次代码。但是,一个有各种级别用户的酒店系统肯定不只是房价有区别,提供的服务也可能
有区别。可想而知,每增加一个用户级别,我们要改的代码就漫山遍野。
那应该怎么办呢?我们应该考虑如何把它设计成一个可以扩展的模型。在这个例子里面,既然每次要增加的是用户级别,而且各种服务的差异都体现在用户级别
上,我们就需要一个用户级别的模型。在前面的代码里,用户级别只是一个简单的枚举,我们可以给它丰富一下:
interface UserLevel {
double getRoomPrice(Room room);
}
class GoldUserLevel implements UserLevel {
public double getRoomPrice(final Room room) {
return room.getPrice() * 0.8;
}
}
class SilverUserLevel implements UserLevel {
public double getRoomPrice(final Room room) {
return room.getPrice() * 0.9;
}
}
我们原来的代码就可以变成这样:
class HotelService {
public double getRoomPrice(final User user, final Room room) {
return user.getRoomPrice(room);
}
}
class User {
private UserLevel level;
...
public double getRoomPrice(final Room room) {
return level.getRoomPrice(room);
}
}
这样一来,再增加白金用户,我们只要写一个新的类就好了:
class PlatinumUserLevel implements UserLevel {
public double getRoomPrice(final Room room) {
return room.getPrice() * 0.75;
}
之所以我们可以这么做,是因为我们在代码里留好了扩展点:UserLevel。在这里,我们把原来的只支持枚举值的 UserLevel 升级成了一个有行为的 UserLevel。
经过这番改造,HotelService 的 getRoomPrice 这个方法就稳定了下来,我们就不需要根据用户级别不断地调整这个方法了。至此,我们就拥有了一个稳定的构造
块,可以在后期的工作中把它当做一个稳定的模块来使用。
当然,在这个例子里,这个方法是比较简单的。而在实际的项目中,业务方法都会比较复杂。
2.构建扩展点
好,现在我们已经对开放封闭原则有了一个基本的认识。其实,我们都知道修改是不好的,道理我们都懂,就是在代码层面,有人就糊涂了。我做个类比你就知道
了,比如说,如果我问你,你正在开发的系统有问题吗?相信大部人的答案都是有。
那我又问你,那你会经常性主动调整它吗?大部人都不会。为什么呢?因为它在线上运行得好好的,万一我调整它,调整坏了怎么办。是啊!你看,道理就是这么
个道理,放在系统层面人人都懂,而在代码层面,却总是习惯性被忽视。
所以,我们写软件就应该提供一个又一个稳定的小模块,然后,将它们组合起来。一个经常变动的模块必然是不稳定的,用它去构造更大的模块,就是将隐患深埋其中。
你可能会说,嗯,我懂了,可我还是做不好啊!为什么我们懂了道理后,依旧过不好“这一关”呢?因为阻碍程序员们构造出稳定模块的障碍,其实是构建模型的能力。你可以回顾一下前面那段代码,看看让这段代码产生变化的 UserLevel 是如何升级成一个有行为的 UserLevel 的。
在讲封装的时候,我说过,封装的要点是行为,数据只是实现细节,而很多人习惯性的写法是面向数据的,这也是导致很多人在设计上缺乏扩展性思考的一个重要原因。
构建模型的难点,首先在于分离关注点,这个我们之前说过很多次了,不再赘述,其次在于找到共性。
在多态那一讲,我们说过,要构建起抽象就要找到事物的共同点,有了这个理解,我们看前面的例子应该还算容易理解。而在一个业务处理的过程中,发现共性这件事对很多人来说就已经开始有难度了。
我们再来看个例子,下面是一个常见的报表服务,首先我们取出当天的订单,然后生成订单的统计报表,还要把统计结果发送给相关的人等:
class ReportService {
public void process() {
// 获取当天的订单
List<Order> orders = fetchDailyOrders();
// 生成统计信息
OrderStatistics statistics = generateOrderStatistics(orders);
// 生成统计报表
generateStatisticsReport(statistics);
// 发送统计邮件
sendStatisticsByMail(statistics);
}
}
很多人在日常工作中写出的代码都是与此类似的,但这个流程肯定是比较僵化的。出现一个新需求就需要调整这段代码。我们这就有一个新需求,把统计信息发给另外一个内部系统,这个内部系统可以把统计信息展示出来,供外部合作伙伴查阅。该怎么做呢?
我们先分析一下,发送给另一个系统的内容是统计信息,在原有的代码里,前面两步分别是获取源数据和生成统计信息,后面两步分别是,生成报表和将统计信息通过邮件发送出去。
也就是说,后两步和即将添加的步骤有一个共同点,都使用了统计信息,这样我们就找到了它们的共性,所以,我们就可以用一个共同的模型去涵盖它们,比如,
OrderStatisticsConsumer:
interface OrderStatisticsConsumer {
void consume(OrderStatistics statistics);
}
class StatisticsReporter implements OrderStatisticsConsumer {
public void consume(OrderStatistics statistics) {
generateStatisticsReport(statistics);
}
}
class StatisticsByMailer implements OrderStatisticsConsumer {
public void consume(OrderStatistics statistics) {
sendStatisticsByMail(statistics);
}
}
class ReportService {
private List<OrderStatisticsConsumer> consumers;
void process() {
// 获取当天的订单
List<Order> orders = fetchDailyOrders();
// 生成统计信息
OrderStatistics statistics = generateOrderStatistics(orders);
for (OrderStatisticsConsumer consumer: consumers) {
consumer.consume(statistics);
}
}
}
如此一来,我们的新需求也只要添加一个新的类就可以实现了:
class StatisticsSender implements OrderStatisticsConsumer {
public void consume(final OrderStatistics statistics) {
sendStatisticsToOtherSystem(statistics);
}
}
你能看出来,在这个例子里,我们第一步做的事情还是分解,就是把一个一个的步骤分开,然后找出步骤之间相似的地方,由此构建出一个新的模型。
真实项目里的代码可能比这个代码要复杂,但其实,并不一定是业务逻辑复杂,而是代码本身写得复杂了。所以,我们要先根据上一讲的单一职责原则,将不同需
求来源引起的变动拆分到不同的方法里,形成一个又一个的小单元,再来做我们这里的分析。
通过这个例子你也可以看出,在真实的项目中,想要达到开放封闭原则的要求并不是一蹴而就的。这里我们只是因为有了需求的变动,才提取出一个
OrderStatisticsConsumer。
未来可能还会有其他的变动,比如,生成报表的逻辑。到那时,也许我们还会提取出一个新的 OrderStatisticsGenerator 的接口。但总的来说,我们每做一次这种
模型构建,最核心的类就会朝着稳定的方向迈进一步。
所以,好的设计都会提供足够的扩展点给新功能去扩展。在《Unix 编程艺术》一书中,Unix 编程就提倡“提供机制,而不是策略”,这就是开放封闭原则的一种体现。
同样的,我们知道很多系统是有插件机制的,比如,很多人使用的 VIM 和 Emacs,离我们比较近的还有 Eclipse 和 Visual Studio Code,它们都体现着开放封闭原则。去了解它们的接口,我们就可以看到这个软件给我们提供的各种能力,这也是一种很好的学习方式。
开放封闭原则还可以帮助我们改进自己的系统,我们可以通过查看自己的源码控制系统,找出那些最经常变动的文件,它们通常都是没有满足开放封闭原则的,而这可以成为我们改进系统的起点。
3.实例2
这是一段 API 接口监控告警的代码。
其中,AlertRule 存储告警规则,可以自由设置。Notification 是告警通知类,支持邮件、短信、微信、手机等多种通知渠道。NotificationEmergencyLevel 表示
通知的紧急程度,包括 SEVERE(严重)、URGENCY(紧急)、NORMAL(普通)、TRIVIAL(无关紧要),不同的紧急程度对应不同的发送渠道。关于 API 接口
监控告警这部分,更加详细的业务需求分析和设计,我们会在后面的设计模式模块再拿出来进一步讲解,这里你只要简单知道这些,就够我们今天用了。
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 durationOfSeconds) {
long tps = requestCount / durationOfSeconds;
if (tps > rule.getMatchedRule(api).getMaxTps()) {
notification.notify(NotificationEmergencyLevel.URGENCY, "...");
}
if (errorCount > rule.getMatchedRule(api).getMaxErrorCount()) {
notification.notify(NotificationEmergencyLevel.SEVERE, "...");
}
// 改动二:添加接口超时处理逻辑
long timeoutTps = timeoutCount / durationOfSeconds;
if (timeoutTps > rule.getMatchedRule(api).getMaxTimeoutTps()) {
notification.notify(NotificationEmergencyLevel.URGENCY, "...");
}
}
}
这样的代码修改实际上存在挺多问题的。一方面,我们对接口进行了修改,这就意味着调用这个接口的代码都要做相应的修改。另一方面,修改了 check() 函数,
相应的单元测试都需要修改(关于单元测试的内容我们在重构那部分会详细介绍)。
上面的代码改动是基于“修改”的方式来实现新功能的。如果我们遵循开闭原则,也就是“对扩展开放、对修改关闭”。那如何通过“扩展”的方式,来实现同样的功能
呢?
我们先重构一下之前的 Alert 代码,让它的扩展性更好一些。重构的内容主要包含两部分:
第一部分是将 check() 函数的多个入参封装成 ApiStatInfo 类;
第二部分是引入 handler 的概念,将 if 判断逻辑分散在各个 handler 中。
public class Alert {
private List<AlertHandler> alertHandlers = new ArrayList<>();
public void addAlertHandler(AlertHandler alertHandler) {
this.alertHandlers.add(alertHandler);
}
public void check(ApiStatInfo apiStatInfo) {
for (AlertHandler handler : alertHandlers) {
handler.check(apiStatInfo);
}
}
}
public class ApiStatInfo {// 省略 constructor/getter/setter 方法
private String api;
private long requestCount;
private long errorCount;
private long durationOfSeconds;
}
public abstract class AlertHandler {
protected AlertRule rule;
protected Notification notification;
public AlertHandler(AlertRule rule, Notification notification) {
this.rule = rule;
this.notification = notification;
}
public abstract void check(ApiStatInfo apiStatInfo);
}
public class TpsAlertHandler extends AlertHandler {
public TpsAlertHandler(AlertRule rule, Notification notification) {
super(rule, notification);
}
@Override
public void check(ApiStatInfo apiStatInfo) {
long tps = apiStatInfo.getRequestCount()/ apiStatInfo.getDurationOfSeconds();
if (tps > rule.getMatchedRule(apiStatInfo.getApi()).getMaxTps()) {
notification.notify(NotificationEmergencyLevel.URGENCY, "...");
}
}
}
public class ErrorAlertHandler extends AlertHandler {
public ErrorAlertHandler(AlertRule rule, Notification notification){
super(rule, notification);
}
@Override
public void check(ApiStatInfo apiStatInfo) {
if (apiStatInfo.getErrorCount() > rule.getMatchedRule(apiStatInfo.getApi()).getMaxErrorCount()) {
notification.notify(NotificationEmergencyLevel.SEVERE, "...");
}
}
}
上面的代码是对 Alert 的重构,我们再来看下,重构之后的 Alert 该如何使用呢?具体的使用代码我也写在这里了。
其中,ApplicationContext 是一个单例类,负责 Alert 的创建、组装(alertRule 和 notification 的依赖注入)、初始化(添加 handlers)工作。
public class ApplicationContext {
private AlertRule alertRule;
private Notification notification;
private Alert alert;
public void initializeBeans() {
alertRule = new AlertRule(/*. 省略参数.*/); // 省略一些初始化代码
notification = new Notification(/*. 省略参数.*/); // 省略一些初始化代码
alert = new Alert();
alert.addAlertHandler(new TpsAlertHandler(alertRule, notification));
alert.addAlertHandler(new ErrorAlertHandler(alertRule, notification));
}
public Alert getAlert() { return alert; }
// 饿汉式单例
private static final ApplicationContext instance = new ApplicationContext();
private ApplicationContext() {
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);
}
}
现在,我们再来看下,基于重构之后的代码,如果再添加上面讲到的那个新功能,每秒钟接口超时请求个数超过某个最大阈值就告警,我们又该如何改动代码呢?
主要的改动有下面四处。
第一处改动是:在 ApiStatInfo 类中添加新的属性 timeoutCount。
第二处改动是:添加新的 TimeoutAlertHander 类。
第三处改动是:在 ApplicationContext 类的 initializeBeans() 方法中,往 alert 对象中注册新的 timeoutAlertHandler。
第四处改动是:在使用 Alert 类的时候,需要给 check() 函数的入参 apiStatInfo 对象设置 timeoutCount 的值。
改动之后的代码如下所示:
public class Alert { // 代码未改动... }
public class ApiStatInfo {// 省略 constructor/getter/setter 方法
private String api;
private long requestCount;
private long errorCount;
private long durationOfSeconds;
private long timeoutCount; // 改动一:添加新字段
}
public abstract class AlertHandler { // 代码未改动... }
public class TpsAlertHandler extends AlertHandler {// 代码未改动...}
public class ErrorAlertHandler extends AlertHandler {// 代码未改动...}
// 改动二:添加新的 handler
public class TimeoutAlertHandler extends AlertHandler {// 省略代码...}
public class ApplicationContext {
private AlertRule alertRule;
private Notification notification;
private Alert alert;
public void initializeBeans() {
alertRule = new AlertRule(/*. 省略参数.*/); // 省略一些初始化代码
notification = new Notification(/*. 省略参数.*/); // 省略一些初始化代码
alert = new Alert();
alert.addAlertHandler(new TpsAlertHandler(alertRule, notification));
alert.addAlertHandler(new ErrorAlertHandler(alertRule, notification));
// 改动三:注册 handler
alert.addAlertHandler(new TimeoutAlertHandler(alertRule, notification));
}
//... 省略其他未改动代码...
}
public class Demo {
public static void main(String[] args) {
ApiStatInfo apiStatInfo = new ApiStatInfo();
// ... 省略 apiStatInfo 的 set 字段代码
apiStatInfo.setTimeoutCount(289); // 改动四:设置 tiemoutCount 值
ApplicationContext.getInstance().getAlert().check(apiStatInfo);
}
重构之后的代码更加灵活和易扩展。如果我们要想添加新的告警逻辑,只需要基于扩展的方式创建新的 handler 类即可,不需要改动原来的 check() 函数的逻辑。
而且,我们只需要为新的 handler 类添加单元测试,老的单元测试都不会失败,也不用修改。
如何做到对扩展开放、修改关闭?
在刚刚的例子中,我们通过引入一组 handler 的方式来实现支持开闭原则。如果你没有太多复杂代码的设计和开发经验,你可能会有这样的疑问:这样的代码设计
思路我怎么想不到呢?你是怎么想到的呢?
先给你个结论,之所以我能想到,靠的就是理论知识和实战经验,这些需要你慢慢学习和积累。对于如何做到“对扩展开放、修改关闭”,我们也有一些指导思想
和具体的方法论,我们一块来看一下。
实际上,开闭原则讲的就是代码的扩展性问题,是判断一段代码是否易扩展的“金标准”。如果某段代码在应对未来需求变化的时候,能够做到“对扩展开放、对修改
关闭”,那就说明这段代码的扩展性比较好。所以,问如何才能做到“对扩展开放、对修改关闭”,也就粗略地等同于在问,如何才能写出扩展性好的代码。
在讲具体的方法论之前,我们先来看一些更加偏向顶层的指导思想。为了尽量写出扩展性好的代码,我们要时刻具备扩展意识、抽象意识、封装意识。这些“潜意
识”可能比任何开发技巧都重要。
在写代码的时候后,我们要多花点时间往前多思考一下,这段代码未来可能有哪些需求变更、如何设计代码结构,事先留好扩展点,以便在未来需求变更的时候,不需要改动代码整体结构、做到最小代码改动的情况下,新的代码能够很灵活地插入到扩展点上,做到“对扩展开放、对修改关闭”。
还有,在识别出代码可变部分和不可变部分之后,我们要将可变部分封装起来,隔离变化,提供抽象化的不可变接口,给上层系统使用。当具体的实现发生变化的
时候,我们只需要基于相同的抽象接口,扩展一个新的实现,替换掉老的实现即可,上游系统的代码几乎不需要修改。
接下来,我就通过一个例子来解释一下,如何利用这几个设计思想或原则来实现“对扩展开放、对修改关闭”。注意,依赖注入后面会讲到,如果你对这块不了解,可以暂时先忽略这个概念,只关注多态、基于接口而非实现编程以及抽象意识。
比如,我们代码中通过 Kafka 来发送异步消息。对于这样一个功能的开发,我们要学会将其抽象成一组跟具体消息队列(Kafka)无关的异步消息接口。所有上层系统都依赖这组抽象的接口编程,并且通过依赖注入的方式来调用。当我们要替换新的消息队列的时候,比如将 Kafka 替换成 RocketMQ,可以很方便地拔掉老的消息队列实现,插入新的消息队列实现。具体代码如下所示:
// 这一部分体现了抽象意识
public interface MessageQueue { //... }
public class KafkaMessageQueue implements MessageQueue { //... }
public class RocketMQMessageQueue implements MessageQueue {//...}
public interface MessageFromatter { //... }
public class JsonMessageFromatter implements MessageFromatter {//...}
public class ProtoBufMessageFromatter implements MessageFromatter {//...}
public class Demo {
private MessageQueue msgQueue; // 基于接口而非实现编程
public Demo(MessageQueue msgQueue) { // 依赖注入
this.msgQueue = msgQueue;
}
// msgFormatter:多态、依赖注入
public void sendNotification(Notification notification, MessageFormatter msgFormatter) {
//...
}
}
如何在项目中灵活应用开闭原则?
前面我们提到,写出支持“对扩展开放、对修改关闭”的代码的关键是预留扩展点。那问题是如何才能识别出所有可能的扩展点呢?
如果你开发的是一个业务导向的系统,比如金融系统、电商系统、物流系统等,要想识别出尽可能多的扩展点,就要对业务有足够的了解,能够知道当下以及未来可能要支持的业务需求。如果你开发的是跟业务无关的、通用的、偏底层的系统,比如,框架、组件、类库,你需要了解“它们会被如何使用?今后你打算添加哪些功能?使用者未来会有哪些更多的功能需求?”等问题。
不过,有一句话说得好,“唯一不变的只有变化本身”。即便我们对业务、对系统有足够的了解,那也不可能识别出所有的扩展点,即便你能识别出所有的扩展点,为这些地方都预留扩展点,这样做的成本也是不可接受的。我们没必要为一些遥远的、不一定发生的需求去提前买单,做过度设计。
最合理的做法是,对于一些比较确定的、短期内可能就会扩展,或者需求改动对代码结构影响比较大的情况,或者实现成本不高的扩展点,在编写代码的时候之后,我们就可以事先做些扩展性设计。但对于一些不确定未来是否要支持的需求,或者实现起来比较复杂的扩展点,我们可以等到有需求驱动的时候,再通过重构代码的方式来支持扩展的需求。
而且,开闭原则也并不是免费的。有些情况下,代码的扩展性会跟可读性相冲突。比如,我们之前举的 Alert 告警的例子。为了更好地支持扩展性,我们对代码进行了重构,重构之后的代码要比之前的代码复杂很多,理解起来也更加有难度。很多时候,我们都需要在扩展性和可读性之间做权衡。在某些场景下,代码的扩展性很重要,我们就可以适当地牺牲一些代码的可读性;在另一些场景下,代码的可读性更加重要,那我们就适当地牺牲一些代码的可扩展性。
在我们之前举的 Alert 告警的例子中,如果告警规则并不是很多、也不复杂,那 check() 函数中的 if 语句就不会很多,代码逻辑也不复杂,代码行数也不多,那最初的第一种代码实现思路简单易读,就是比较合理的选择。相反,如果告警规则很多、很复杂,check() 函数的 if 语句、代码逻辑就会很多、很复杂,相应的代码行数也会很多,可读性、可维护性就会变差,那重构之后的第二种代码实现思路就是更加合理的选择了。总之,这里没有一个放之四海而皆准的参考标准,全凭实际的应用场景来决定。
1. 如何理解里氏替换原则?
子类对象(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 class Demo {
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.isNotBlank(appId) && StringUtils.isNotBlank(appToken)) {
request.addPayload("app-id", appId);
request.addPayload("app-token", appToken);
}
return super.sendRequest(request);
}
}
// 改造后:
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 函数之后,整个程序的逻辑行为有了改变。
虽然改造之后的代码仍然可以通过 Java 的多态语法,动态地用子类 SecurityTransporter 来替换父类 Transporter,也并不会导致程序编译或者运行报错。但是,从设计思路上来讲,SecurityTransporter 的设计是不符合里式替换原则的。
好了,我们稍微总结一下。虽然从定义描述和代码实现上来看,多态和里式替换有点类似,但它们关注的角度是不一样的。多态是面向对象编程的一大特性,也是面向对象编程语言的一种语法。它是一种代码实现的思路。而里式替换是一种设计原则,是用来指导继承关系中子类该如何设计的,子类的设计要保证在替换父类的时候,不改变原有程序的逻辑以及不破坏原有程序的正确性。
2.哪些代码明显违背了 LSP ?
实际上,里式替换原则还有另外一个更加能落地、更有指导意义的描述,那就是“Design By Contract”,中文翻译就是“按照协议来设计”。
看起来比较抽象,我来进一步解读一下。子类在设计的时候,要遵守父类的行为约定(或者叫协议)。父类定义了函数的行为约定,那子类可以改变函数的内部实现逻辑,但不能改变函数原有的行为约定。这里的行为约定包括:函数声明要实现的功能;对输入、输出、异常的约定;甚至包括注释中所罗列的任何特殊说明。
实际上,定义中父类和子类之间的关系,也可以替换成接口和实现类之间的关系。
为了更好地理解这句话,我举几个违反里式替换原则的例子来解释一下。
1. 子类违背父类声明要实现的功能
父类中提供的 sortOrdersByAmount() 订单排序函数,是按照金额从小到大来给订单排序的,而子类重写这个 sortOrdersByAmount() 订单排序函数之后,是按照创建日期来给订单排序的。那子类的设计就违背里式替换原则。
2. 子类违背父类对输入、输出、异常的约定
在父类中,某个函数约定:运行出错的时候返回 null;获取数据为空的时候返回空集合(empty collection)。而子类重载函数之后,实现变了,运行出错返回异常(exception),获取不到数据返回 null。那子类的设计就违背里式替换原则。
在父类中,某个函数约定,输入数据可以是任意整数,但子类实现的时候,只允许输入数据是正整数,负数就抛出,也就是说,子类对输入的数据的校验比父类更加严格,那子类的设计就违背了里式替换原则。
在父类中,某个函数约定,只会抛出 ArgumentNullException 异常,那子类的设计实现中只允许抛出 ArgumentNullException 异常,任何其他异常的抛出,都会导致子类违背里式替换原则。
3. 子类违背父类注释中所罗列的任何特殊说明
父类中定义的 withdraw() 提现函数的注释是这么写的:“用户的提现金额不得超过账户余额……”,而子类重写 withdraw() 函数之后,针对 VIP 账号实现了透支提现的功能,也就是提现金额可以大于账户余额,那这个子类的设计也是不符合里式替换原则的。
以上便是三种典型的违背里式替换原则的情况。除此之外,判断子类的设计实现是否违背里式替换原则,还有一个小窍门,那就是拿父类的单元测试去验证子类的代码。如果某些单元测试运行失败,就有可能说明,子类的设计实现没有完全地遵守父类的约定,子类有可能违背了里式替换原则。
实际上,你有没有发现,里式替换这个原则是非常宽松的。一般情况下,我们写的代码都不怎么会违背它。所以,只要你能看懂我今天讲的这些,这个原则就不难掌握,也不难应用。
SRP 告诉我们,一个类的变化来源应该是单一的;OCP 说,不要随意修改一个类;LSP 则教导我们应该设计好类的继承关系。
而在面向对象的设计中,接口设计也是一个非常重要的组成部分。我们一直都在强调面向接口编程,想实现 OCP 也好,或者是 DIP 也罢,都是要依赖于接口实现的。
也许你会说,接口不就是一个语法吗?把需要的方法都放到接口里面,接口不就出来了吗?顶多是 Java 用 interface,C++ 都声明成纯虚函数。这种对于接口的理解,显然还停留在语法的层面上。这样设计出来的只能算作是有了一个接口,但想要设计出好的接口,还要有在设计维度上的思考。
那什么样的接口算是一个好接口呢?这就需要我们了解接口隔离原则。
1.接口隔离原则
接口隔离原则(Interface segregation principle,简称 ISP)是这样表述的:
不应强迫使用者依赖于它们不用的方法。
No client should be forced to depend on methods it does not use.
这个表述看上去很容易理解,就是指在接口中,不要放置使用者用不到的方法。站在使用者的角度,这简直再合理不过了。每个人都会觉得,我怎么会依赖于我不用的方法呢?相信作为设计者,你也会同意这种观点。然而,真正在设计的时候,却不是人人都能记住这一点的。
首先,很多程序员分不清使用者和设计者两个是不同的角色。因为在很多人看来,接口的设计和使用常常是由同一个人完成。这就是角色区分意识的缺失,这种缺失导致我们不能把两种不同的角色区分开来,本质上来说,这也是分离关注点没有做好的一种体现。
实际上,很多程序员在开发过程中,其实是两种角色都没有的,他们根本没有思考过接口的问题,因为他们更关心的是一个个的具体类。只有到了必须的时候,接口才作为语法选项使用一次,这种做法干脆就是没在设计上进行思考。
然而,你不设计接口,并不代表没有接口。
在做软件设计的时候,我们经常考虑的是模型之间如何交互,接口只是一个方便描述的词汇,为了让我们把注意力从具体的实现细节中抽离出来。但是,如果没有设计特定的接口,你的一个个具体类就变成它的接口。同设计不好的接口一样,这样的“接口”往往也是存在问题的。
那接口设计不好会有什么问题呢?典型的问题就是接口过“胖”,什么叫接口过“胖”呢?我给你举个例子。
胖接口减肥
假设有一个银行的系统,对外提供存款、取款和转账的能力。它通过一个接口向外部系统暴露了它的这些能力,而不同能力的差异要通过请求的内容来区分。所以,我们在这里设计了一个表示业务请求的对象,像下面这样:
class TransactionRequest {
// 获取操作类型
TransactionType getType() {
...
}
// 获取存款金额
double getDepositAmount() {
...
}
// 获取取款金额
double getWithdrawAmount() {
...
}
// 获取转账金额
double getTransferAmount() {
...
}
}
每种操作类型都对应着一个业务处理的模块,它们会根据自己的需要,去获取所需的信息,像下面这样:
interface TransactionHandler {
void handle(TransactionRequest request);
}
class DepositHandler implements TransactionHandler {
void handle(final TransactionRequest request) {
double amount = request.getDepositAmount();
...
}
}
class WithdrawHandler implements TransactionHandler {
void handle(final TransactionRequest request) {
double amount = request.getWithdrawAmount();
...
}
}
class TransferHandler implements TransactionHandler {
void handle(final TransactionRequest request) {
double amount = request.getTransferAmount();
...
}
}
这样一来,我们只要在收到请求之后,做一个业务分发就好了:
TransactionHandler handler = handlers.get(request.getType());
if (handler != null) {
handler.handle(request);
}
一切看上去都很好,不少人在实际工作中也会写出类似的代码。然而,在这个实现里,有一个接口就太“胖”了,它就是 TransactionRequest。
TransactionRequest 这个类包含了相关的请求内容,虽然这是无可厚非的。但是在这里,我们容易直觉地把它作为参数传给 TransactionHandler。于是,它作为一个请求对象,摇身一变,变成了业务处理接口的一部分。
正如我在前面所说的,虽然你没有设计特定的接口,但具体类可以变成接口。不过,作为业务处理中的接口,TransactionRequest 就显得“胖”了:
getDepositAmount 方法只在 DepositHandler 里使用;
getWithdrawAmount 方法只在 WithdrawHandler 里使用;
getTransferAmount 只在 TransferHandler 使用。
然而,传给它们的 TransactionRequest 却包含所有这些方法。
也许你会想,这有什么问题吗?问题就在于,一个“胖”接口常常是不稳定的。比如说,现在要增加一个生活缴费的功能,TransactionRequest 就要增加一个获取生活缴费金额的方法:
class TransactionRequest {
...
// 获取生活缴费金额
double getLivingPaymentAmount() {
...
}
}
相应地,还需要增加业务处理的方法:
class LivingPaymentHandler implements TransactionHandler {
void handle(final TransactionRequest request) {
double amount = request.getLivingPaymentAmount();
...
}
}
虽然这种做法看上去还挺符合 OCP 的,但实际上,由于 TransactionRequest 的修改,前面几个写好的业务处理类:DepositHandler、WithdrawHandler、TransferHandler 都会受到影响。为什么这么说呢?
如果我们用的是一些现代的程序设计语言,你的感觉可能不明显。假如这段代码是用 C/C++ 这些需要编译链接的语言写成的,TransactionRequest 的修改势必会导致其它几个业务处理类重新编译,因为它们都引用了 TransactionRequest。
实际上,C/C++ 的程序在编译链接上常常需要花很多时间,除了语言本身的特点之外,因为设计没做好,造成本来不需要重新编译的文件也要重新编译的现象几乎是随处可见的。
你可以理解为,如果一个接口修改了,依赖它的所有代码全部会受到影响,而这些代码往往也有依赖于它们实现的代码,这样一来,一个修改的影响就传播出去了。用这种角度去评估,你就会发现,不稳定的“胖”接口影响面是非常之广的,所以,我们说“胖”接口不好。
怎样修改这段代码呢?既然这个接口是由于“胖”造成的,给它减肥就好了。根据 ISP,只给每个使用者提供它们关心的方法。所以,我们可以引入一些“瘦”接口:
interface TransactionRequest {
}
interface DepositRequest extends TransactionRequest {
double getDepositAmount();
}
interface WithdrawRequest extends TransactionRequest {
double getWithdrawAmount();
}
interface TransferRequest extends TransactionRequest {
double getTransferAmount();
}
class ActualTransactionRequest implements DepositRequest, WithdrawRequest, TransferRequest {
...
}
这里,我们把 TransactionRequest 变成了一个接口,目的是给后面的业务处理进行统一接口,而 ActualTransactionRequest 则对应着原来的实现类。我们引入了 DepositRequest、WithdrawRequest、TransferRequest 等几个“瘦”接口,它们就是分别供不同的业务处理方法使用的接口。有了这个基础,我们也可以改造对应的业务处理方法了:
interface TransactionHandler<T extends TransactionRequest> {
void handle(T request);
}
class DepositHandler implements TransactionHandler<DepositRequest> {
void handle(final DepositRequest request) {
double amount = request.getDepositAmount();
...
}
}
class WithdrawHandler implements TransactionHandler<WithdrawRequest> {
void handle(final WithdrawRequest request) {
double amount = request.getWithdrawAmount();
...
}
}
class TransferHandler implements TransactionHandler<TransferRequest> {
void handle(final TransferRequest request) {
double amount = request.getTransferAmount();
...
}
}
经过这个改造,每个业务处理方法就只关心自己相关的业务请求。那么,新增生活缴费该如何处理呢?你可能已经很清楚了,就是再增加一个新的接口:
interface LivingPaymentRequest extends TransactionRequest {
double getLivingPaymentAmount();
}
class ActualTransactionRequest implements DepositRequest, WithdrawRequest, TransferRequest, LivingPaymentRequest {
}
然后,再增加一个新的业务处理方法:
class LivingPaymentHandler implements TransactionHandler<LivingPaymentRequest> {
void handle(final LivingPaymentRequest request) {
double amount = request.getLivingPaymentAmount();
...
}
}
我们可以对比一下两个设计,只有 ActualTransactionRequest 做了修改,而因为这个类表示的是实际的请求对象,在现在的结构之下,它是无论如何都要修改的。而其他的部分因为不存在依赖关系,所以,并不会受到这次需求增加的影响。相对于原来的做法,新设计改动的影响面变得更小了。
2.把接口理解为单个API接口或函数
现在我们再换一种理解方式,把接口理解为单个接口或函数(以下为了方便讲解,我都简称为“函数”)。那接口隔离原则就可以理解为:函数的设计要功能单一,
不要将多个不同的功能逻辑在一个函数中实现。接下来,我们还是通过一个例子来解释一下。
public class Statistics {
private Long max;
private Long min;
private Long average;
private Long sum;
private Long percentile99;
private Long percentile999;
//... 省略 constructor/getter/setter 等方法...
}
public Statistics count(Collection<Long> dataSet) {
Statistics statistics = new Statistics();
//... 省略计算逻辑...
return statistics;
}
在上面的代码中,count() 函数的功能不够单一,包含很多不同的统计功能,比如,求最大值、最小值、平均值等等。按照接口隔离原则,我们应该把 count() 函数拆成几个更小粒度的函数,每个函数负责一个独立的统计功能。拆分之后的代码如下所示:
public Long max(Collection<Long> dataSet) { //... }
public Long min(Collection<Long> dataSet) { //... }
public Long average(Colletion<Long> dataSet) { //... }
// ... 省略其他统计函数...
不过,你可能会说,在某种意义上讲,count() 函数也不能算是职责不够单一,毕竟它做的事情只跟统计相关。我们在讲单一职责原则的时候,也提到过类似的问题。实际上,判定功能是否单一,除了很强的主观性,还需要结合具体的场景。
如果在项目中,对每个统计需求,Statistics 定义的那几个统计信息都有涉及,那 count() 函数的设计就是合理的。相反,如果每个统计需求只涉及 Statistics 罗列的统计信息中一部分,比如,有的只需要用到 max、min、average 这三类统计信息,有的只需要用到 average、sum。而 count() 函数每次都会把所有的统计信息计算一遍,就会做很多无用功,势必影响代码的性能,特别是在需要统计的数据量很大的时候。所以,在这个应用场景下,count() 函数的设计就有点不合理了,我们应该按照第二种设计思路,将其拆分成粒度更细的多个统计函数。
不过,你应该已经发现,接口隔离原则跟单一职责原则有点类似,不过稍微还是有点区别。单一职责原则针对的是模块、类、接口的设计。而接口隔离原则相对于单一职责原则,一方面它更侧重于接口的设计,另一方面它的思考的角度不同。它提供了一种判断接口是否职责单一的标准:通过调用者如何使用接口来间接地判定。如果调用者只使用部分接口或接口的部分功能,那接口的设计就不够职责单一。
3. 把接口理解为 OOP 中的接口概念
除了刚讲过的两种理解方式,我们还可以把“接口”理解为 OOP 中的接口概念,比如 Java 中的 interface。我还是通过一个例子来给你解释。
假设我们的项目中用到了三个外部系统:Redis、MySQL、Kafka。每个系统都对应一系列配置信息,比如地址、端口、访问超时时间等。为了在内存中存储这些配置信息,供项目中的其他模块来使用,我们分别设计实现了三个 Configuration 类:RedisConfig、MysqlConfig、KafkaConfig。具体的代码实现如下所示。注意,这里我只给出了 RedisConfig 的代码实现,另外两个都是类似的,我这里就不贴了。
public class RedisConfig {
private ConfigSource configSource; // 配置中心(比如 zookeeper)
private String address;
private int timeout;
private int maxTotal;
// 省略其他配置: maxWaitMillis,maxIdle,minIdle...
public RedisConfig(ConfigSource configSource) {
this.configSource = configSource;
}
public String getAddress() {
return this.address;
}
//... 省略其他 get()、init() 方法...
public void update() {
// 从 configSource 加载配置到 address/timeout/maxTotal...
}
}
public class KafkaConfig { //... 省略... }
public class MysqlConfig { //... 省略... }
现在,我们有一个新的功能需求,希望支持 Redis 和 Kafka 配置信息的热更新。所谓“热更新(hot update)”就是,如果在配置中心中更改了配置信息,我们希望在不用重启系统的情况下,能将最新的配置信息加载到内存中(也就是 RedisConfig、KafkaConfig 类中)。但是,因为某些原因,我们并不希望对 MySQL 的配置信息进行热更新。
为了实现这样一个功能需求,我们设计实现了一个 ScheduledUpdater 类,以固定时间频率(periodInSeconds)来调用 RedisConfig、KafkaConfig 的 update() 方法更新配置信息。具体的代码实现如下所示:
public interface Updater {
void update();
}
public class RedisConfig implemets Updater {
//... 省略其他属性和方法...
@Override
public void update() { //... }
}
public class KafkaConfig implements Updater {
//... 省略其他属性和方法...
@Override
public void update() { //... }
}
public class MysqlConfig { //... 省略其他属性和方法... }
public class ScheduledUpdater {
private final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();;
private long initialDelayInSeconds;
private long periodInSeconds;
private Updater updater;
public ScheduleUpdater(Updater updater, long initialDelayInSeconds, long periodInSeconds) {
this.updater = updater;
this.initialDelayInSeconds = initialDelayInSeconds;
this.periodInSeconds = periodInSeconds;
}
public void run() {
executor.scheduleAtFixedRate(new Runnable() {
@Override
public void run() {
updater.update();
}
}, this.initialDelayInSeconds, this.periodInSeconds, TimeUnit.SECONDS);
}
}
public class Application {
ConfigSource configSource = new ZookeeperConfigSource(/* 省略参数 */);
public static final RedisConfig redisConfig = new RedisConfig(configSource);
public static final KafkaConfig kafkaConfig = new KakfaConfig(configSource);
public static final MySqlConfig mysqlConfig = new MysqlConfig(configSource);
public static void main(String[] args) {
ScheduledUpdater redisConfigUpdater = new ScheduledUpdater(redisConfig, 300, 300);
redisConfigUpdater.run();
ScheduledUpdater kafkaConfigUpdater = new ScheduledUpdater(kafkaConfig, 60, 60);
redisConfigUpdater.run();
}
}
刚刚的热更新的需求我们已经搞定了。现在,我们又有了一个新的监控功能需求。通过命令行来查看 Zookeeper 中的配置信息是比较麻烦的。所以,我们希望能有一种更加方便的配置信息查看方式。
我们可以在项目中开发一个内嵌的 SimpleHttpServer,输出项目的配置信息到一个固定的 HTTP 地址,比如:http://127.0.0.1:2389/config 。我们只需要在浏览器中输入这个地址,就可以显示出系统的配置信息。不过,出于某些原因,我们只想暴露 MySQL 和 Redis 的配置信息,不想暴露 Kafka 的配置信息。
为了实现这样一个功能,我们还需要对上面的代码做进一步改造。改造之后的代码如下所示:
public interface Updater {
void update();
}
public interface Viewer {
String outputInPlainText();
Map<String, String> output();
}
public class RedisConfig implemets Updater, Viewer {
//... 省略其他属性和方法...
@Override
public void update() { //... }
@Override
public String outputInPlainText() { //... }
@Override
public Map<String, String> output() { //...}
}
public class KafkaConfig implements Updater {
//... 省略其他属性和方法...
@Override
public void update() { //... }
}
public class MysqlConfig implements Viewer {
//... 省略其他属性和方法...
@Override
public String outputInPlainText() { //... }
@Override
public Map<String, String> output() { //...}
}
public class SimpleHttpServer {
private String host;
private int port;
private Map<String, List<Viewer>> viewers = new HashMap<>();
public SimpleHttpServer(String host, int port) {//...}
public void addViewers(String urlDirectory, Viewer viewer) {
if (!viewers.containsKey(urlDirectory)) {
viewers.put(urlDirectory, new ArrayList<Viewer>());
}
this.viewers.get(urlDirectory).add(viewer);
}
public void run() { //... }
}
public class Application {
ConfigSource configSource = new ZookeeperConfigSource();
public static final RedisConfig redisConfig = new RedisConfig(configSource);
public static final KafkaConfig kafkaConfig = new KakfaConfig(configSource);
public static final MySqlConfig mysqlConfig = new MySqlConfig(configSource);
public static void main(String[] args) {
ScheduledUpdater redisConfigUpdater =
new ScheduledUpdater(redisConfig, 300, 300);
redisConfigUpdater.run();
ScheduledUpdater kafkaConfigUpdater =
new ScheduledUpdater(kafkaConfig, 60, 60);
redisConfigUpdater.run();
SimpleHttpServer simpleHttpServer = new SimpleHttpServer(“127.0.0.1”, 2389);
simpleHttpServer.addViewer("/config", redisConfig);
simpleHttpServer.addViewer("/config", mysqlConfig);
simpleHttpServer.run();
}
}
至此,热更新和监控的需求我们就都实现了。我们来回顾一下这个例子的设计思想。
我们设计了两个功能非常单一的接口:Updater 和 Viewer。ScheduledUpdater 只依赖 Updater 这个跟热更新相关的接口,不需要被强迫去依赖不需要的 Viewer 接口,满足接口隔离原则。同理,SimpleHttpServer 只依赖跟查看信息相关的 Viewer 接口,不依赖不需要的 Updater 接口,也满足接口隔离原则。
你可能会说,如果我们不遵守接口隔离原则,不设计 Updater 和 Viewer 两个小接口,而是设计一个大而全的 Config 接口,让 RedisConfig、KafkaConfig、MysqlConfig 都实现这个 Config 接口,并且将原来传递给 ScheduledUpdater 的 Updater 和传递给 SimpleHttpServer 的 Viewer,都替换为 Config,那会有什么问题呢?我们先来看一下,按照这个思路来实现的代码是什么样的。
public interface Config {
void update();
String outputInPlainText();
Map<String, String> output();
}
public class RedisConfig implements Config {
//... 需要实现 Config 的三个接口 update/outputIn.../output
}
public class KafkaConfig implements Config {
//... 需要实现 Config 的三个接口 update/outputIn.../output
}
public class MysqlConfig implements Config {
//... 需要实现 Config 的三个接口 update/outputIn.../output
}
public class ScheduledUpdater {
//... 省略其他属性和方法..
private Config config;
public ScheduleUpdater(Config config, long initialDelayInSeconds, long periodInSeconds) {
this.config = config;
//...
}
//...
}
public class SimpleHttpServer {
private String host;
private int port;
private Map<String, List<Config>> viewers = new HashMap<>();
public SimpleHttpServer(String host, int port) {//...}
public void addViewer(String urlDirectory, Config config) {
if (!viewers.containsKey(urlDirectory)) {
viewers.put(urlDirectory, new ArrayList<Config>());
}
viewers.get(urlDirectory).add(config);
}
public void run() { //... }
}
这样的设计思路也是能工作的,但是对比前后两个设计思路,在同样的代码量、实现复杂度、同等可读性的情况下,第一种设计思路显然要比第二种好很多。为什么这么说呢?主要有两点原因。
首先,第一种设计思路更加灵活、易扩展、易复用。因为 Updater、Viewer 职责更加单一,单一就意味了通用、复用性好。比如,我们现在又有一个新的需求,
开发一个 Metrics 性能统计模块,并且希望将 Metrics 也通过 SimpleHttpServer 显示在网页上,以方便查看。这个时候,尽管 Metrics 跟 RedisConfig 等没有任何关系,但我们仍然可以让 Metrics 类实现非常通用的 Viewer 接口,复用 SimpleHttpServer 的代码实现。具体的代码如下所示:
public class ApiMetrics implements Viewer {//...}
public class DbMetrics implements Viewer {//...}
public class Application {
ConfigSource configSource = new ZookeeperConfigSource();
public static final RedisConfig redisConfig = new RedisConfig(configSource);
public static final KafkaConfig kafkaConfig = new KakfaConfig(configSource);
public static final MySqlConfig mySqlConfig = new MySqlConfig(configSource);
public static final ApiMetrics apiMetrics = new ApiMetrics();
public static final DbMetrics dbMetrics = new DbMetrics();
public static void main(String[] args) {
SimpleHttpServer simpleHttpServer = new SimpleHttpServer(“127.0.0.1”, 2389);
simpleHttpServer.addViewer("/config", redisConfig);
simpleHttpServer.addViewer("/config", mySqlConfig);
simpleHttpServer.addViewer("/metrics", apiMetrics);
simpleHttpServer.addViewer("/metrics", dbMetrics);
simpleHttpServer.run();
}
}
其次,第二种设计思路在代码实现上做了一些无用功。因为 Config 接口中包含两类不相关的接口,一类是 update(),一类是 output() 和 outputInPlainText()。理论上,KafkaConfig 只需要实现 update() 接口,并不需要实现 output() 相关的接口。同理,MysqlConfig 只需要实现 output() 相关接口,并需要实现 update() 接口。但第二种设计思路要求 RedisConfig、KafkaConfig、MySqlConfig 必须同时实现 Config 的所有接口函数(update、output、outputInPlainText)。除此之外,如果我们要往 Config 中继续添加一个新的接口,那所有的实现类都要改动。相反,如果我们的接口粒度比较小,那涉及改动的类就比较少。
谁依赖谁
依赖倒置原则(Dependency inversion principle,简称 DIP)是这样表述的:
高层模块不应依赖于低层模块,二者应依赖于抽象。
High-level modules should not depend on low-level modules. Both should depend on abstractions.
抽象不应依赖于细节,细节应依赖于抽象。
Abstractions should not depend on details. Details (concrete implementations) should depend on abstractions.
我们学习这个原则,最重要的是要理解“倒置”,而要理解什么是“倒置”,就要先理解所谓的“正常依赖”是什么样的。
讲结构化编程时,我们曾经说过结构化编程解决问题的思路是自上而下地进行功能分解,这种解决问题的思路很自然地就会延续到很多人的编程习惯中。按照分解的结果,进行组合。所以,我们很自然地就会写出类似下面的这种代码:
class CriticalFeature {
private Step1 step1;
private Step2 step2;
...
void run() {
// 执行第一步
step1.execute();
// 执行第二步
step2.execute();
...
}
}
但是,这种未经审视的结构天然就有一个问题:高层模块会依赖于低层模块。在上面这段代码里,CriticalFeature 类就是高层类,Step1 和 Step2 就是低层模块,而且 Step1 和 Step2 通常都是具体类。虽然这是一种自然而然的写法,但是这种写法确实是有问题的。
在实际的项目中,代码经常会直接耦合在具体的实现上。比如,我们用 Kafka 做消息传递,我们就在代码里直接创建了一个 KafkaProducer 去发送消息。我们就可能会写出这样的代码:
class Handler {
private KafkaProducer producer;
void execute() {
...
Message message = ...;
producer.send(new KafkaRecord<>("topic", message);
...
}
}
也许你会问,我就是用了 Kafka 发消息,创建一个 KafkaProducer,这有什么问题吗?其实,这个问题我们在课程中已经讲过了,就是说我们需要站在长期的角度去看,什么东西是变的、什么东西是不变的。Kafka 虽然很好,但它并不是系统最核心的部分,我们在未来是可能把它换掉的。
你可能会想,这可是我实现的一个关键组件,我怎么可能会换掉它呢?你还记得吗,软件设计需要关注长期、放眼长期,所有那些不在自己掌控之内的东西,都是有可能被替换的。其实,在我前面讲的很多内容里,你也可以看到,替换一个中间件是经常发生的。所以,依赖于一个可能会变的东西,从设计的角度看,并不是一个好的做法。
那我们应该怎么做呢?这就轮到倒置登场了。
**所谓倒置,就是把这种习惯性的做法倒过来,让高层模块不再依赖于低层模块。**那要是这样的话,我们的功能又该如何完成呢?计算机行业中一句名言告诉了我们答案:
计算机科学中的所有问题都可以通过引入一个间接层得到解决。
All problems in computer science can be solved by another level of indirection
—— David Wheeler
是的,引入一个间接层。这个间接层指的就是 DIP 里所说的抽象。也就是说,这段代码里面缺少了一个模型,而这个模型就是这个低层模块在这个过程中所承担角色。
既然这个模块扮演的就是消息发送者的角色,那我们就可以引入一个消息发送者(MessageSender)的模型:
interface MessageSender {
void send(Message message);
}
class Handler {
private MessageSender sender;
void execute() {
...
Message message = ...;
sender.send(message);
...
}
}
有了消息发送者这个模型,那我们又该如何把 Kafka 和这个模型结合起来呢?那就要实现一个 Kafka 的消息发送者:
class KafkaMessageSender implements MessageSender {
private KafkaProducer producer;
public void send(final Message message) {
this.producer.send(new KafkaRecord<>("topic", message));
}
}
这样一来,高层模块就不像原来一样直接依赖低层模块,而是将依赖关系“倒置”过来,让低层模块去依赖由高层定义好的接口。这样做的好处就在于,将高层模块
与低层实现解耦开来。
如果未来我们要替换掉 Kafka,只要重写一个 MessageSender 就好了,其他部分并不需要改变。这样一来,我们就可以让高层模块保持相对稳定,不会随着低层
代码的改变而改变。
2.依赖于抽象
理解了 DIP 的第一部分后,我们已经知道了要建立起模型(抽象)的概念。
你有没有发现,我们学习的所有原则都是在讲,尽可能把变的部分和不变的部分分开,让不变的部分稳定下来。我们知道,模型是相对稳定的,实现细节则是容易
变动的部分。所以,构建出一个稳定的模型层,对任何一个系统而言,都是至关重要的。
那接下来,我们再来分析 DIP 的第二个部分:抽象不应依赖于细节,细节应依赖于抽象。
其实,这个可以更简单地理解为一点:依赖于抽象,从这点出发,我们可以推导出一些更具体的指导编码的规则:
任何变量都不应该指向一个具体类;
任何类都不应继承自具体类;
任何方法都不应该改写父类中已经实现的方法。
我们在讲多态时,提到过一个 List 声明的例子,其实背后遵循的就是这里的第一条规则:
List<String> list = new ArrayList<>();
在实际的项目中,这些编码规则有时候也并不是绝对的。如果一个类特别稳定,我们也是可以直接用的,比如字符串类。但是,请注意,这种情况非常少。因为大多数人写的代码稳定度并没有那么高。所以,上面几条编码规则可以成为覆盖大部分情况的规则,出现例外时,我们就需要特别关注一下。
到这里,你已经理解了在 DIP 的指导下,具体类还是能少用就少用。但还有一个问题,最终,具体类我们还是要用的,毕竟代码要运行起来不能只依赖于接口。那具体类应该在哪用呢?
我们讨论的这些设计原则,核心的关注点都是一个个的业务模型。此外,还有一些代码做的工作是负责把这些模型组装起来,这些负责组装的代码就需要用到一个一个的具体类。
是不是说到这里,感觉话题很熟悉呢?是的,我们在第五讲讨论过 DI 容器的来龙去脉,在 Java 世界里,做这些组装工作的就是 DI 容器。
因为这些组装工作几乎是标准化的,而且非常繁琐。如果你常用的语言中,没有提供 DI 容器,最好还是把负责组装的代码和业务模型放到不同的代码里。
DI 容器在最初的讨论中有另外一个说法叫 IoC 容器,这个 IoC 是 Inversion of Control 的缩写,你会看到 IoC 和 DIP 中的 I 都是 inversion,二者表现的意图实际上是一致的。
理解了 DIP,再来使用 DI 容器,你会觉得一切顺理成章,因为依赖之所以可以注入,是因为我们的设计遵循了 DIP。而只知道 DI 容器不了解 DIP,时常会出现让
你觉得很为难的模型组装,根本的原因就是设计没有做好。
关于 DIP,还有一个形象的说法,称为好莱坞规则:“Don’t call us, we’ll call you”。放在设计里面,这个翻译应该是“别调用我,我会调你的”。显然,这是一个框架才会有的说法,有了一个稳定的抽象,各种具体的实现都应该是由框架去调用。
是的,如果你想去编写一个框架,理解 DIP 是非常重要的。毫不夸张地说,不理解 DIP 的程序员,就只能写功能,不能构建出模型,也就很难再上一个台阶。在前面讨论程序库时,我建议每个程序员都去锻炼编写程序库,这其实就是让你去锻炼构建模型的能力。
有了对 DIP 的讨论,我们再回过头看上一讲留下的疑问,为什么说一开始 TransactionRequest 是把依赖方向搞反了?因为最初的 TransactionRequest 是一个具体类,而 TransactionHandler 是业务类。
我们后来改进的版本里引入一个模型,把 TransactionRequest 变成了接口,ActualTransactionRequest 实现这个接口,TransactionHandler 只依赖于接口,而
原来的具体类从这个接口继承而来,相对来说,比原来的版本好一些。
对于任何一个项目而言,了解不同模块的依赖关系是一件很重要的事。你可以去找一些工具去生成项目的依赖关系图,然后,你就可以用 DIP 作为一个评判标准,去衡量一下你的项目在依赖关系上表现得到底怎么样了。很有可能,你就找到了项目改造的一些着力点。
理解了 DIP,再来看一些关于依赖的讨论,我们也可以看到不同的角度。比如,循环依赖,有人会说从技术上要如何解决它,但实际上,循环依赖就是设计没有做好的结果,把依赖关系弄错了,才可能会出现循环依赖,先把设计做对,把该有的接口提取出来,依赖就不会循环了。
至此,SOLID 的五个原则,我们已经讲了一遍。有了前面对于分离关注点和面向对象基础知识的铺垫,相信你理解这些原则的难度也会相应的降低了一些。
你会看到,理解这些原则,关键的第一步还是分离关注点,把不同的内容区分开来。然后,用这些原则再把它们组合起来。而当你理解了这些原则,再回头去看,也能加深对面向对象特点的认识,现在你应该更能深刻体会到多态在面向对象世界里发挥的作用了。
1.控制反转(IOC)
我们先通过一个例子来看一下,什么是控制反转。
public class UserServiceTest {
public static boolean doTest() {
// ...
}
public static void main(String[] args) {// 这部分逻辑可以放到框架中
if (doTest()) {
System.out.println("Test succeed.");
} else {
System.out.println("Test failed.");
}
}
}
在上面的代码中,所有的流程都由程序员来控制。如果我们抽象出一个下面这样一个框架,我们再来看,如何利用框架来实现同样的功能。具体的代码实现如下所示:
public abstract class TestCase {
public void run() {
if (doTest()) {
System.out.println("Test succeed.");
} else {
System.out.println("Test failed.");
}
}
public abstract void doTest();
}
public class JunitApplication {
private static final List<TestCase> testCases = new ArrayList<>();
public static void register(TestCase testCase) {
testCases.add(testCase);
}
public static final void main(String[] args) {
for (TestCase case: testCases) {
case.run();
}
}
}
把这个简化版本的测试框架引入到工程中之后,我们只需要在框架预留的扩展点,也就是 TestCase 类中的 doTest() 抽象函数中,填充具体的测试代码就可以实现之前的功能了,完全不需要写负责执行流程的 main() 函数了。 具体的代码如下所示:
public class UserServiceTest extends TestCase {
@Override
public boolean doTest() {
// ...
}
}
// 注册操作还可以通过配置的方式来实现,不需要程序员显示调用 register()
JunitApplication.register(new UserServiceTest();
刚刚举的这个例子,就是典型的通过框架来实现“控制反转”的例子。框架提供了一个可扩展的代码骨架,用来组装对象、管理整个执行流程。程序员利用框架进行开发的时候,只需要往预留的扩展点上,添加跟自己业务相关的代码,就可以利用框架来驱动整个程序流程的执行。
这里的“控制”指的是对程序执行流程的控制,而“反转”指的是在没有使用框架之前,程序员自己控制整个程序的执行。在使用框架之后,整个程序的执行流程可以通过框架来控制。流程的控制权从程序员“反转”到了框架。
实际上,实现控制反转的方法有很多,除了刚才例子中所示的类似于模板设计模式的方法之外,还有马上要讲到的依赖注入等方法,所以,控制反转并不是一种具体的实现技巧,而是一个比较笼统的设计思想,一般用来指导框架层面的设计。
2.依赖注入 (DI)
接下来,我们再来看依赖注入。依赖注入跟控制反转恰恰相反,它是一种具体的编码技巧。依赖注入的英文翻译是 Dependency Injection,缩写为 DI。对于这个
概念,有一个非常形象的说法,那就是:依赖注入是一个标价 25 美元,实际上只值 5 美分的概念。也就是说,这个概念听起来很“高大上”,实际上,理解、应用起来非常简单。
那到底什么是依赖注入呢?我们用一句话来概括就是:不通过 new() 的方式在类内部创建依赖类对象,而是将依赖的类对象在外部创建好之后,通过构造函数、函数参数等方式传递(或注入)给类使用。
我们还是通过一个例子来解释一下。在这个例子中,Notification 类负责消息推送,依赖 MessageSender 类实现推送商品促销、验证码等消息给用户。我们分别用依赖注入和非依赖注入两种方式来实现一下。具体的实现代码如下所示:
// 非依赖注入实现方式
public class Notification {
private MessageSender messageSender;
public Notification() {
this.messageSender = new MessageSender(); // 此处有点像 hardcode
}
public void sendMessage(String cellphone, String message) {
//... 省略校验逻辑等...
this.messageSender.send(cellphone, message);
}
}
public class MessageSender {
public void send(String cellphone, String message) {
//....
}
}
// 使用 Notification
Notification notification = new Notification();
// 依赖注入的实现方式
public class Notification {
private MessageSender messageSender;
// 通过构造函数将 messageSender 传递进来
public Notification(MessageSender messageSender) {
this.messageSender = messageSender;
}
public void sendMessage(String cellphone, String message) {
//... 省略校验逻辑等...
this.messageSender.send(cellphone, message);
}
}
// 使用 Notification
MessageSender messageSender = new MessageSender();
Notification notification = new Notification(messageSender);
通过依赖注入的方式来将依赖的类对象传递进来,这样就提高了代码的扩展性,我们可以灵活地替换依赖的类。这一点在我们之前讲“开闭原则”的时候也提到过。
当然,上面代码还有继续优化的空间,我们还可以把 MessageSender 定义成接口,基于接口而非实现编程。改造后的代码如下所示:
public class Notification {
private MessageSender messageSender;
public Notification(MessageSender messageSender) {
this.messageSender = messageSender;
}
public void sendMessage(String cellphone, String message) {
this.messageSender.send(cellphone, message);
}
}
public interface MessageSender {
void send(String cellphone, String message);
}
// 短信发送类
public class SmsSender implements MessageSender {
@Override
public void send(String cellphone, String message) {
//....
}
}
// 站内信发送类
public class InboxSender implements MessageSender {
@Override
public void send(String cellphone, String message) {
//....
}
}
// 使用 Notification
MessageSender messageSender = new SmsSender();
Notification notification = new Notification(messageSender);
实际上,你只需要掌握刚刚举的这个例子,就等于完全掌握了依赖注入。尽管依赖注入非常简单,但却非常有用,在后面的章节中,我们会讲到,它是编写可测试性代码最有效的手段。
3.依赖注入框架 (DI Framework)
弄懂了什么是“依赖注入”,我们再来看一下,什么是“依赖注入框架”。我们还是借用刚刚的例子来解释。
在采用依赖注入实现的 Notification 类中,虽然我们不需要用类似 hard code 的方式,在类内部通过 new 来创建 MessageSender 对象,但是,这个创建对象、组装(或注入)对象的工作仅仅是被移动到了更上层代码而已,还是需要我们程序员自己来实现。具体代码如下所示:
public class Demo {
public static final void main(String args[]) {
MessageSender sender = new SmsSender(); // 创建对象
Notification notification = new Notification(sender);// 依赖注入
notification.sendMessage("13918942177", " 短信验证码:2346");
}
}
在实际的软件开发中,一些项目可能会涉及几十、上百、甚至几百个类,类对象的创建和依赖注入会变得非常复杂。如果这部分工作都是靠程序员自己写代码来完
成,容易出错且开发成本也比较高。而对象创建和依赖注入的工作,本身跟具体的业务无关,我们完全可以抽象成框架来自动完成。
你可能已经猜到,这个框架就是“依赖注入框架”。我们只需要通过依赖注入框架提供的扩展点,简单配置一下所有需要创建的类对象、类与类之间的依赖关系,就可以实现由框架来自动创建对象、管理对象的生命周期、依赖注入等原本需要程序员来做的事情。
实际上,现成的依赖注入框架有很多,比如 Google Guice、Java Spring、Pico Container、Butterfly Container 等。不过,如果你熟悉 Java Spring 框架,你可能会说,Spring 框架自己声称是控制反转容器(Inversion Of Control Container)。
实际上,这两种说法都没错。只是控制反转容器这种表述是一种非常宽泛的描述,DI 依赖注入框架的表述更具体、更有针对性。因为我们前面讲到实现控制反转的方式有很多,除了依赖注入,还有模板模式等,而 Spring 框架的控制反转主要是通过依赖注入来实现的。不过这点区分并不是很明显,也不是很重要,你稍微了解一下就可以了。
4.依赖反转原则 (DIP)
所谓高层模块和低层模块的划分,简单来说就是,在调用链上,调用者属于高层,被调用者属于低层。在平时的业务代码开发中,高层模块依赖底层模块是没有任何问题的。实际上,这条原则主要还是用来指导框架层面的设计,跟前面讲到的控制反转类似。我们拿 Tomcat 这个 Servlet 容器作为例子来解释一下。
Tomcat 是运行 Java Web 应用程序的容器。我们编写的 Web 应用程序代码只需要部署在 Tomcat 容器下,便可以被 Tomcat 容器调用执行。按照之前的划分原则,Tomcat 就是高层模块,我们编写的 Web 应用程序代码就是低层模块。Tomcat 和应用程序代码之间并没有直接的依赖关系,两者都依赖同一个“抽象”,也就是 Sevlet 规范。Servlet 规范不依赖具体的 Tomcat 容器和应用程序的实现细节,而 Tomcat 容器和应用程序依赖 Servlet 规范。
1.理论解读与代码实战一
我们先来看这条原则中的前半部分,“不该有直接依赖关系的类之间,不要有依赖”。我举个例子解释一下。
这个例子实现了简化版的搜索引擎爬取网页的功能。代码中包含三个主要的类。其中,NetworkTransporter 类负责底层网络通信,根据请求获取数据;
HtmlDownloader 类用来通过 URL 获取网页;Document 表示网页文档,后续的网页内容抽取、分词、索引都是以此为处理对象。具体的代码实现如下所示:
public class NetworkTransporter {
// 省略属性和其他方法...
public Byte[] send(HtmlRequest htmlRequest) {
//...
}
}
public class HtmlDownloader {
private NetworkTransporter transporter;// 通过构造函数或 IOC 注入
public Html downloadHtml(String url) {
Byte[] rawHtml = transporter.send(new HtmlRequest(url));
return new Html(rawHtml);
}
}
public class Document {
private Html html;
private String url;
public Document(String url) {
this.url = url;
HtmlDownloader downloader = new HtmlDownloader();
this.html = downloader.downloadHtml(url);
}
//...
}
首先,我们来看 NetworkTransporter 类。作为一个底层网络通信类,我们希望它的功能尽可能通用,而不只是服务于下载 HTML,所以,我们不应该直接依赖太具体的发送对象 HtmlRequest。从这一点上讲,NetworkTransporter 类的设计违背迪米特法则,依赖了不该有直接依赖关系的 HtmlRequest 类。
我们应该如何进行重构,让 NetworkTransporter 类满足迪米特法则呢?我这里有个形象的比喻。假如你现在要去商店买东西,你肯定不会直接把钱包给收银员,让收银员自己从里面拿钱,而是你从钱包里把钱拿出来交给收银员。这里的 HtmlRequest 对象就相当于钱包,HtmlRequest 里的 address 和 content 对象就相当于钱。我们应该把 address 和 content 交给 NetworkTransporter,而非是直接把 HtmlRequest 交给 NetworkTransporter。根据这个思路,
NetworkTransporter 重构之后的代码如下所示:
public class NetworkTransporter {
// 省略属性和其他方法...
public Byte[] send(String address, Byte[] data) {
//...
}
}
**我们再来看 HtmlDownloader 类。**这个类的设计没有问题。不过,我们修改了 NetworkTransporter 的 send() 函数的定义,而这个类用到了 send() 函数,所以我们需要对它做相应的修改,修改后的代码如下所示:
public class HtmlDownloader {
private NetworkTransporter transporter;// 通过构造函数或 IOC 注入
// HtmlDownloader 这里也要有相应的修改
public Html downloadHtml(String url) {
HtmlRequest htmlRequest = new HtmlRequest(url);
Byte[] rawHtml = transporter.send(
htmlRequest.getAddress(), htmlRequest.getContent().getBytes());
return new Html(rawHtml);
}
}
最后,我们来看下 Document 类。这个类的问题比较多,主要有三点。第一,构造函数中的 downloader.downloadHtml() 逻辑复杂,耗时长,不应该放到构造函数中,会影响代码的可测试性。代码的可测试性我们后面会讲到,这里你先知道有这回事就可以了。第二,HtmlDownloader 对象在构造函数中通过 new 来创建,违反了基于接口而非实现编程的设计思想,也会影响到代码的可测试性。第三,从业务含义上来讲,Document 网页文档没必要依赖 HtmlDownloader 类,违背了迪米特法则。
虽然 Document 类的问题很多,但修改起来比较简单,只要一处改动就可以解决所有问题。修改之后的代码如下所示:
public class Document {
private Html html;
private String url;
public Document(String url, Html html) {
this.html = html;
this.url = url;
}
//...
}
// 通过一个工厂方法来创建 Document
public class DocumentFactory {
private HtmlDownloader downloader;
public DocumentFactory(HtmlDownloader downloader) {
this.downloader = downloader;
}
public Document createDocument(String url) {
Html html = downloader.downloadHtml(url);
return new Document(url, html);
}
}
2. 理论解读与代码实战二
现在,我们再来看一下这条原则中的后半部分:“有依赖关系的类之间,尽量只依赖必要的接口”。我们还是结合一个例子来讲解。下面这段代码非常简单,Serialization 类负责对象的序列化和反序列化。
public class Serialization {
public String serialize(Object object) {
String serializedResult = ...;
//...
return serializedResult;
}
public Object deserialize(String str) {
Object deserializedResult = ...;
//...
return deserializedResult;
}
}
单看这个类的设计,没有一点问题。不过,如果我们把它放到一定的应用场景里,那就还有继续优化的空间。假设在我们的项目中,有些类只用到了序列化操作,
而另一些类只用到反序列化操作。那基于迪米特法则后半部分“有依赖关系的类之间,尽量只依赖必要的接口”,只用到序列化操作的那部分类不应该依赖反序列化接口。同理,只用到反序列化操作的那部分类不应该依赖序列化接口。
根据这个思路,我们应该将 Serialization 类拆分为两个更小粒度的类,一个只负责序列化(Serializer 类),一个只负责反序列化(Deserializer 类)。拆分之后,使用序列化操作的类只需要依赖 Serializer 类,使用反序列化操作的类只需要依赖 Deserializer 类。拆分之后的代码如下所示:
public class Serializer {
public String serialize(Object object) {
String serializedResult = ...;
...
return serializedResult;
}
}
public class Deserializer {
public Object deserialize(String str) {
Object deserializedResult = ...;
...
return deserializedResult;
}
}
不知道你有没有看出来,尽管拆分之后的代码更能满足迪米特法则,但却违背了高内聚的设计思想。高内聚要求相近的功能要放到同一个类中,这样可以方便功能修改的时候,修改的地方不至于过于分散。对于刚刚这个例子来说,如果我们修改了序列化的实现方式,比如从 JSON 换成了 XML,那反序列化的实现逻辑也需要
一并修改。在未拆分的情况下,我们只需要修改一个类即可。在拆分之后,我们需要修改两个类。显然,这种设计思路的代码改动范围变大了。
如果我们既不想违背高内聚的设计思想,也不想违背迪米特法则,那我们该如何解决这个问题呢?实际上,通过引入两个接口就能轻松解决这个问题,具体的代码如下所示。实际上,我们在第 18 节课中讲到“接口隔离原则”的时候,第三个例子就使用了类似的实现思路,你可以结合着一块儿来看。
public interface Serializable {
String serialize(Object object);
}
public interface Deserializable {
Object deserialize(String text);
}
public class Serialization implements Serializable, Deserializable {
@Override
public String serialize(Object object) {
String serializedResult = ...;
...
return serializedResult;
}
@Override
public Object deserialize(String str) {
Object deserializedResult = ...;
...
return deserializedResult;
}
}
public class DemoClass_1 {
private Serializable serializer;
public Demo(Serializable serializer) {
this.serializer = serializer;
}
//...
}
public class DemoClass_2 {
private Deserializable deserializer;
public Demo(Deserializable deserializer) {
this.deserializer = deserializer;
}
//...
}
尽管我们还是要往 DemoClass_1 的构造函数中,传入包含序列化和反序列化的 Serialization 实现类,但是,我们依赖的 Serializable 接口只包含序列化操作,
DemoClass_1 无法使用 Serialization 类中的反序列化接口,对反序列化操作无感知,这也就符合了迪米特法则后半部分所说的“依赖有限接口”的要求。
实际上,上面的的代码实现思路,也体现了“基于接口而非实现编程”的设计原则,结合迪米特法则,我们可以总结出一条新的设计原则,那就是“基于最小接口而非最大实现编程”。有些同学之前问,新的设计模式和设计原则是怎么创造出来的,实际上,就是在大量的实践中,针对开发痛点总结归纳出来的套路。
1.KISS
KISS 原则,是“Keep it simple, stupid”的缩写,也就是保持简单、愚蠢的意思。它告诫我们,对于大多数系统而言,和变得复杂相比,保持简单能够让系统运行得更好。
很多程序员都知道这条原则,然而,很少人知道这条原则其实是出自美国海军。所以,它的适用范围远比我们以为的程序员社区要广泛得多。无论是制定一个目
标,还是设计一个产品,抑或是管理一个公司,我们都可以用 KISS 作为一个统一的原则指导自己的工作。
这个原则看起来有点抽象,每个人对它都会有自己理解的角度,所以,每个人都会觉得它很有道理,而且,越是资深的人越会觉得它有道理。因为资深的人通常都是在自己的工作领域中,见识过因为复杂而引发的各种问题。比如说,堆了太多的功能,调整起来很费劲这样的情况。我们在专栏前面讲过的各种问题,很多时候都是由于复杂引起的。
所以,对资深的人来说,保持简单是一个再好不过的指引了。其实,每个人都可以针对自己的工作场景给出自己的阐释,比如:
如果有现成的程序库,就不要自己写;
能用文本做协议就别用二进制;
方法写得越小越好;
能把一个基本的流程打通,软件就可以发布,无需那么多的功能;
……
这种级别的原则听上去很有吸引力,但问题是,你并不能用它指导具体的工作。因为,怎么做叫保持简单,怎么做就叫复杂了呢?这个标准是没办法确定的。所
以,有人基于自己的理解给出了一些稍微具体一点的原则。比如,在软件开发领域,你可能听说过的 YAGNI 和 DRY 原则。
2.YAGNI
YAGNI 是“You aren’t gonna need it”的缩写,也就是,你用不着它。这个说法来自于极限编程社区(Extreme Programming,简称 XP),我们可以把它理解成:如非必要,勿增功能。
我们在开篇词里就说过,软件设计对抗的是需求规模。一方面,我们会通过自己的努力,让软件在需求规模膨胀之后,依然能有一个平稳的发展;另一方面,我们还应该努力地控制需求的规模。
YAGNI 就告诫我们,其实很多需求是不需要做的。很多产品经理以为很重要的功能实际上是没什么用的。人们常说二八原则,真正重要的功能大约只占 20%,
80% 的功能可能大多数人都用不到。做了更多的功能,并不会得到更多的回报,但是,做了更多的功能,软件本身却会不断地膨胀,变得越发难以维护。
所以,在现实世界中,我们经常看到一些功能简单的东西不断涌现,去颠覆更复杂的东西。比如,虽然 Word 已经很强大了,但对于很多人而言,它还只是一个写字的工具,甚至它的重点排版功能都用得非常少。
于是,这就给了 Markdown 一个机会。它可以让我们专注写内容,而且简单的排版标记在日常沟通中也完全够用。至少,我已经不记得自己上一次用 Word 写东西是什么时候了。
YAGNI 是一种上游思维,就是尽可能不去做不该做的事,从源头上堵住。从某种意义上说,它比其他各种设计原则都重要。
3.DRY
DRY 是“Don’t repeat yourself”的缩写,也就是,不要重复自己。这个说法源自 Andy Hunt 和 Dave Thomas 的《程序员修炼之道》(The Pragmatic
Programmer)。这个原则的阐述是这样的:
在一个系统中,每一处知识都必须有单一、明确、权威地表述。
Every piece of knowledge must have a single, unambiguous, authoritative representation within a system.
每个人对于 DRY 原则的理解是千差万别的,最浅层的理解就是“不要复制粘贴代码”。不过,两个作者在二十年后的第二版特意强调,这个理解是远远不够的。DRY 针对的是你对知识和意图的复制。它强调的是,在两个不同地方的两样东西表达的形式是不同的,但其要表达的内容却可能是相同的。
我从《程序员修炼之道》中借鉴了一个例子,看看我们怎么在实际的工作中运用 DRY 原则。下面是一段打印账户信息的代码,这种写法在实际的工作中也非常常
见:
public void printBalance(final Account account) {
System.out.printf("Debits: %10.2f\n", account.getDebits());
System.out.printf("Credits: %10.2f\n", account.getCredits());
if (account.getFees() < 0) {
System.out.printf("Fees: %10.2f-\n", -account.getFees());
} else {
System.out.printf("Fees: %10.2f\n", account.getFees());
}
System.out.printf(" ----\n");
if (account.getBalance() < 0) {
System.out.printf("Balance: %10.2f-\n", -account.getBalance());
} else {
System.out.printf("Balance: %10.2f\n", account.getBalance());
}
}
然而,在这段代码中,隐藏着一些重复。比如,对负数的处理显然是复制的,可以通过增加一个方法消除它:
String formatValue(final double value) {
String result = String.format("%10.2f", Math.abs(value));
if (value < 0) {
return result + "-";
} else {
return result + " ";
}
}
void printBalance(final Account account) {
System.out.printf("Debits: %10.2f\n", account.getDebits());
System.out.printf("Credits: %10.2f\n", account.getCredits());
System.out.printf("Fees:%s\n", formatValue(account.getFees()));
System.out.printf(" ----\n");
System.out.printf("Balance:%s\n", formatValue(account.getBalance()));
}
还有,数字字段格式也是反复出现的,不过,格式与我们抽取出来的方法是一致的,所以,可以复用一下:
String formatValue(final double value) {
String result = String.format("%10.2f", Math.abs(value));
if (value < 0) {
return result + "-";
} else {
return result + " ";
}
}
void printBalance(final Account account) {
System.out.printf("Debits: %s\n", formatValue(account.getDebits()));
System.out.printf("Credits: %s\n", formatValue(account.getCredits()));
System.out.printf("Fees:%s\n", formatValue(account.getFees()));
System.out.printf(" ----\n");
System.out.printf("Balance:%s\n", formatValue(account.getBalance()));
}
再有,这里面的打印格式其实也是重复的,如果我要在标签和金额之间加一个空格,相关的代码都要改,所以,这也是一个可以消除的重复:
String formatValue(final double value) {
String result = String.format("%10.2f", Math.abs(value));
if (value < 0) {
return result + "-";
} else {
return result + " ";
}
}
void printLine(final String label, final String value) {
System.out.printf("%-9s%s\n", label, value);
}
void reportLine(final String label, final double value) {
printLine(label + ":", formatValue(value));
}
void printBalance(final Account account) {
reportLine("Debits", account.getDebits());
reportLine("Credits", account.getCredits());
reportLine("Fees", account.getFees());
System.out.printf(" ----\n");
reportLine("Balance", account.getBalance());
}
经过这样的修改,如果我们要改金额打印的格式,就去改 formatValue 方法;如果我们要改标签的格式,就去改 reportLine 方法。
可能对于有的人来说,这种调整的粒度太小了。不过,我想说的是,如果你的感觉是这样的话,证明你看问题的粒度太大了。
如果仔细品味这个修改,你就能从中感觉到它与我们之前说的分离关注点和单一职责原则有异曲同工的地方,没错,确实是这样的。在讲分离关注点和单一职责原则的时候,我强调的重点也是**粒度要小。**这个例子从某种程度上说,也是为它们增加了注脚。
虽然我们在这里讲的是代码,但 DRY 原则并不局限于写代码,比如:
注释和代码之间存在重复,可以尝试把代码写得更清晰;
内部 API 在不同的使用者之间存在重复,可以通过中立格式进行 API 的定义,然后用工具生成文档、模拟 API 等等;
开发人员之间做的事情存在重复,可以建立沟通机制降低重复;
……
所有这些努力都是在试图减少重复,同时也是为了减少后期维护的成本。
4.简单设计
简单设计之所以叫简单设计,因为它只包含了 4 条规则:
通过所有测试;
消除重复;
表达出程序员的意图;
让类和方法的数量最小化。
这 4 条规则看起来很简单,但想做到,对于很多人来说,是一个非常大的挑战。Kent Beck 是极限编程这种工作方式的创始人,所以,想满足他提出的简单设计原则,最好要做到与之配套的各种实践。
我们来逐一地看下每条规则。第 1 条是保证系统能够按照预期工作,其实,这一点对于大多数项目而言,已经是很高的要求了。怎么才能知道系统按照预期工作,那就需要有配套的自动化测试。大多数项目并不拥有自己的自动化测试,更何况是在开发阶段使用的单元测试,尤其是还得保证测试覆盖了大多数场景。
在 XP 实践中,想要拥有这种测试,最好是能够以测试驱动开发(Test Driven Development,简称 TDD)的方式工作。而你要想做好 TDD,最根本的还是要懂设计,否则,你的代码就是不可测的,想给它写测试就是难上加难的事情。
后面 3 条规则其实说的是重构的方向,而重构也是 XP 的重要实践。第 2 条,消除重复,正如前面讲 DRY 原则所说的,你得能够发现重复,这需要你对分离关注点有着深刻的认识。第 3 条,表达出程序员的意图,我们需要编写有表达性的代码,这也需要你对“什么是有表达性的代码”有认识。我们在讲 DSL 曾经说过,代码要说明做什么,而不是怎么做。
第 4 条,让类和方法的数量最小化,则告诉我们不要过度设计,除非你已经看到这个地方必须要做一个设计,比如,留下适当的扩展点,否则,就不要做。
但是,有一点我们需要知道,能做出过度设计的前提,是已经懂得了设计的各种知识,这时才需要用简单设计的标准对自己进行约束。所以,所谓的简单设计,对
大多数人而言,并不“简单”。
我们前面说了,简单设计的理念来自于极限编程社区,这是一个重要的敏捷流派。谈到敏捷,很多人以为做敏捷是不需要设计的,其实这是严重的误解。在敏捷实
践的工程派,也就是 XP 这一派中,如果单看这些实践的步骤,你都会觉得都非常简单,无论是 TDD 也好,抑或是重构也罢,如果你没有对设计的理解,任何一个实践你都很难做好。
没有良好的设计,代码就没有可测试的接口,根本没有办法测试,TDD 也就无从谈起。不懂设计,重构就只是简单的提取方法,改改名字,对代码的改进也是相当有限的。
简单设计,是 Kent Beck 这样的大师级程序员在经历了足够的积累,返璞归真之后提出的设计原则,它确实可以指导我们的日常工作,但前提是,我们需要把基础打牢。片面地追求敏捷实践,而忽视基本功,往往是舍本逐末的做法。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。