二、SOLID 原则
SOLID 原则并非单纯的 1 个原则,而是由 5
个设计原则组成的,它们分别是:单一职责原则、开闭原则、里式替换原则、接口隔离原则和依赖反转原则 ,依次对应
SOLID 中的 S、O、L、I、D 这 5 个英文字母。
单一职责原则(SRP):A
class or module should have a single reponsibility
我们可以先写一个粗粒度的类,满足业务需求。随着业务的发展,如果粗粒度的类越来越庞大,代码越来越多,这个时候,我们就可以将这个粗粒度的类,拆分成几个更细粒度的类。这就是所谓的持续重构
存疑,在项目变复杂的情况下拆分类似乎代价也不小。
可参考的一些原则:
类中的代码行数、函数或属性过多,会影响代码的可读性和可维护性,我们就需要考虑对类进行拆分;
类依赖的其他类过多,或者依赖类的其他类过多,不符合高内聚、低耦合的设计思想,我们就需要考虑对类进行拆分;
私有方法过多,我们就要考虑能否将私有方法独立到新的类中,设置为public方法,供更多的类使用,从而提高代码的复用性;
比较难给类起一个合适名字,很难用一个业务名词概括,或者只能用一些笼统的
Manager、Context
之类的词语来命名,这就说明类的职责定义得可能不够清晰;
类中大量的方法都是集中操作类中的某几个属性,比如,在
UserInfo 例子中,如果一半的方法都是在操作
address
信息,那就可以考虑将这几个属性和对应的方法拆分出来。
开闭原则(OCP)
software entities(modules, classes, functions, etc.) should be open
for extension , but closed for modification.
开闭原则简单来说就是:添加一个新的功能应该是,在已有代码基础上扩展代码(新增模块、类、方法等),而非修改已有代码(修改模块、类、方法等)。
以以下向用户发送警告信息模块代码为例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 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()方法。具体改动如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 public class Alert { 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() 函数的多个入参封装成 ApiStatInfo 类;
第二部分是引入 handler 的概念,将 if 判断逻辑分散在各个 handler
中。
具体实现如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 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 { 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, "..." ); } } }
以上模块的调用过程如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 public class ApplicationContext { private AlertRule alertRule; private Notification notification; private Alert alert; public void initializeBeans () { alertRule = new AlertRule (); notification = new Notification (); alert = new Alert (); alert.addAlertHandler(new TpsAlertHandler (alertRule, notification)); alert.addAlertHandler(new ErrorAlertHandler (alertRule, notification)); } public Alert getAlert () { return alert; } private static final ApplicationContext instance = new ApplicationContext (); private ApplicationContext () { initializeBeans(); } public static ApplicationContext getInstance () { return instance; } } public class Demo { public static void main (String[] args) { ApiStatInfo apiStatInfo = new ApiStatInfo (); ApplicationContext.getInstance().getAlert().check(apiStatInfo); } }
核心类关系:
Alert 类包含多个 AlertHandler 对象
AlertHandler 是抽象基类,被
TpsAlertHandler 和 ErrorAlertHandler 继承
AlertHandler 依赖于 AlertRule 和
Notification 类
ApiStatInfo中存放各种需要检查的数据,具体的
Handler 类使用 ApiStatInfo 进行数据检查
设计模式:
策略模式:不同的告警处理逻辑封装在不同的 Handler
子类中
单例模式: ApplicationContext 使用饿汉式单例
模板方法模式: AlertHandler
定义了算法框架,子类实现具体检查逻辑
数据流向:
Demo 创建 ApiStatInfo 并设置数据
通过 ApplicationContext 单例获取 Alert 实例
Alert 遍历所有 Handler 调用 check 方法
各 Handler 根据规则判断是否需要发送通知
这样重构之后在需要增加新的功能只需要在原有的
ApiStatInfo中增加需要检查的数据以及创建对应的
AlertHandler、AlertRule 和
Notification 类再将 AlertHandler加入
Alert
中即可,先前的功能不会在之后的维护中进行修改。大大提高了系统的可维护性。
1.
修改代码就意味着违背开闭原则吗?
只要它没有破坏原有的代码的正常运行,没有破坏原有的单元测试,我们就可以说,这是一个合格的代码改动。
比如说,在上述的模块中如果增加功能则会增加 ApiStatinfo
中的参数,改变了 ApiStatinfo
类。但是,这种增加不会影响原有的功能模块,不需要对之前的功能进行修改、测试,因此这种修改并不算是违背了开闭原则。
在 ApplicationContext 类的
initializeBeans() 方法中,往 alert
对象中注册新的 timeoutAlertHandler ;在使用
Alert 类的时候,需要给 check() 函数的入参
apiStatInfo 对象设置 timeoutCount
的值。这部分的改动算是实打实的修改 而不能说是扩展,但这种程度的修改是在所难免且是可以被接受的。
因为在重构之后的 Alert 代码中,我们的核心逻辑集中在
Alert 类及其各个 handler
中,当我们在添加新的告警逻辑的时候, Alert
类完全不需要修改,而只需要扩展一个新 handler 类。如果我们把
Alert 类及各个 handler
类合起来看作一个“模块”,那模块本身在添加新的功能的时候,完全满足开闭原则。
2.
如何做到“对扩展开放、修改关闭”?
对于如何做到开闭原则,靠的是理论知识和实战经验,这些需要慢慢学习和积累。
为了尽量写出扩展性好的代码,我们要时刻具备扩展意识、抽象意识、封装意识。这些“潜意识”可能比任何开发技巧都重要。
在写代码的时候,我们要多花点时间往前多思考一下,这段代码未来可能有哪些需求变更 、如何设计代码结构,事先留好扩展点,以便在未来需求变更的时候,不需要改动代码整体结构、做到最小代码改动的情况下,新的代码能够很灵活地插入到扩展点上,做到“对扩展开放、对修改关闭”。
在识别出代码可变部分和不可变部分之后,我们要将可变部分封装起来,隔离变化,提供抽象化的不可变接口 ,给上层系统使用。当具体的实现发生变化的时候,我们只需要基于相同的抽象接口,扩展一个新的实现,替换掉老的实现即可,上游系统的代码几乎不需要修改。
最常用来提高代码扩展性的方法有:多态、依赖注入、基于接口而非实现编程
比如,我们代码中通过 Kafka
来发送异步消息。使用多态和依赖注入向上游提供不变的接口。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 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; } public void sendNotification (Notification notification, MessageFormatter msgFormatter) { } }
3.
如何在项目中灵活应用开闭原则?
有一句话说得好,“唯一不变的只有变化本身”。即便我们对业务、对系统有足够的了解,那也不可能识别出所有的扩展点,即便你能识别出所有的扩展点,为这些地方都预留扩展点,这样做的成本也是不可接受的。我们没必要为一些遥远的、不一定发生的需求去提前买单,做过度设计。
最合理的做法是,对于一些比较确定的、短期内可能就会扩展,或者需求改动对代码结构影响比较大的情况,或者实现成本不高的扩展点,在编写代码的时候之后,我们就可以事先做些扩展性设计。但对于一些不确定未来是否要支持的需求,或者实现起来比较复杂的扩展点,我们可以等到有需求驱动的时候,再通过重构代码的方式来支持扩展的需求。
有些情况下,代码的扩展性会跟可读性相冲突。很多时候都需要在扩展性和可读性之间做权衡。(也可以花时间把文档写好)
里式替换(LSP)
If S is a subtype of T, then objects of type T may be replaced with
objects of type S, without breaking the program。
中文描述即:子类对象(object of subtype/derived
class)能够替换程序(program)中父类对象(object of base/parent
class)出现的任何地方,并且保证原来程序的逻辑行为(behavior)不变及正确性不被破坏。
也就是说要求保证父类对象出现的任何地方使用子类对象取替换 都不改变原来程序的逻辑性以及正确性不会改变。
如下代码中,父类 Transporter 使用
org.apache.http 库中的 HttpClient
类来传输网络数据。子类 SecurityTransporter 继承父类
Transporter ,增加了额外的功能,支持传输 appId
和 appToken 安全认证信息。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 public class Transporter { private HttpClient httpClient; public Transporter (HttpClient httpClient) { this .httpClient = httpClient; } public Response sendRequest (Request 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 (); Response response = transporter.sendRequest(request); } } Demo demo = new Demo ();demo.demofunction(new SecurityTransporter (););
如上,在 demo 调用中 demofunction()
传入的参数本该是父类,但在这里将一个子类对象传入也完全没有问题,则这里的子类
SecurityTransporter 设计完全符合里式替换原则.
不过刚刚的代码设计似乎就是简单利用了面向对象的多态特性,里式替换原则跟多态看起来确实有点类似,但实际上它们完全是两回事。
我们还是通过刚才这个例子来解释一下。不过,我们需要对
SecurityTransporter 类中 sendRequest()
函数稍加改造一下。改造前,如果 appId 或者
appToken 没有设置,我们就不做校验;改造后,如果
appId 或者 appToken 没有设置,则直接抛出
NoAuthorizationRuntimeException
未授权异常。改造前后的代码对比如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 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); } }
仅仅是增加了校验,然而父类和子类的行为逻辑已经不相同,此时的代码便不符合里氏替换原则。但这样是否会因为没有校验而产生程序bug呢?我的思路是将校验提到demo之外,这样即可以符合里氏替换,又能满足参数校验。
1. 哪些代码明显违背了 LSP?
里式替换原则还有另外一个更加能落地、更有指导意义的描述,那就是“Design
By Contract”,中文翻译就是“按照协议来设计”。
具体来讲就是:子类在设计的时候,要遵守父类的行为约定(或者叫协议)。父类定义了函数的行为约定,那子类可以改变函数的内部实现逻辑,但不能改变函数原有的行为约定。这里的行为约定包括:函数声明要实现的功能;对输入、输出、异常的约定;甚至包括注释中所罗列的任何特殊说明 。实际上,定义中父类和子类之间的关系,也可以替换成接口和实现类之间的关系。
以下为几个违反里氏替换原则的例子:
子类违背父类声明要实现的功能
子类违背父类对输入、输出、异常的约定(传入参数、返回值、异常抛出,子类对输入的数据的校验不能比父类更加严格)
子类违背父类注释中所罗列的任何特殊说明
接口隔离原则(ISP)
Clients should not be forced to depend upon interfaces that they do
not use
“接口”在软件开发中,我们既可以把它看作一组抽象的约定,也可以具体指系统与系统之间的
API 接口,还可以特指面向对象编程语言中的接口等。在 ISP
原则中,我们可以把“接口”理解为下面三种东西:
一组 API 接口集合:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 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 接口或函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 public class Statistics { private Long max; private Long min; private Long average; private Long sum; private Long percentile99; private Long percentile999; } public Statistics count (Collection<Long> dataSet) { Statistics statistics = new Statistics (); return statistics; }
在上面的代码中,count()函数的功能不够单一,包含很多不同的统计功能,比如,求最大值、最小值、平均值等等。按照接口隔离原则,我们应该把count()函数拆成几个更小粒度的函数,每个函数负责一个独立的统计功能。拆分之后的代码如下所示:
1 2 3 4 5 public Long max (Collection<Long> dataSet) { ... }public Long min (Collection<Long> dataSet) { ... } public Long average (Colletion<Long> dataSet) { ... }
OOP 中的接口概念:
假设我们的项目中用到了三个外部系统: Redis 、
MySQL 、 Kafka
。每个系统都对应一系列配置信息,比如地址、端口、访问超时时间等。为了在内存中存储这些配置信息,供项目中的其他模块来使用,我们分别设计实现了三个
Configuration 类: RedisConfig 、
MysqlConfig 、 KafkaConfig
。具体的代码实现如下所示。注意,这里我只给出了 RedisConfig
的代码实现,另外两个都是类似的
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 public class RedisConfig { private ConfigSource configSource; private String address; private int timeout; private int maxTotal; public RedisConfig (ConfigSource configSource) { this .configSource = configSource; } public String getAddress () { return this .address; } public void update () { } } public class KafkaConfig { ...省略... }public class MysqlConfig { ...省略... }
现在,我们有一个新的功能需求,希望支持 Redis 和
Kafka 配置信息的热更新。所谓“热更新(hot
update)”就是,如果在配置中心中更改了配置信息,我们希望在不用重启系统的情况下,能将最新的配置信息加载到内存中(也就是
RedisConfig、KafkaConfig 类中)。但是,因为某些原因,我们并不希望对
MySQL 的配置信息进行热更新。
为了实现这样一个功能需求,我们设计实现了一个
ScheduledUpdater
类,以固定时间频率(periodInSeconds)来调用
RedisConfig、KafkaConfig的
update() 方法更新配置信息。具体的代码实
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 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 ); kafkaConfigUpdater.run(); } }