设计模式之美笔记3

记录学习王争的设计模式之美 课程 笔记和练习代码,以便回顾复习,共同进步

经典设计原则

包括SOLID、KISS、YAGNI、DRY、LOD等。

目的:了解设计原则的定义,设计的初衷,能够解决的问题,有哪些应用场景。

1. 单一职责原则SRP

1. 概念

SOLID原则为5个设计原则组成的:单一职责原则、开闭原则、里式替换原则、接口隔离原则和依赖反转原则,依次对应SOLID的S O L I D5个字母。

单一职责原则Single Responsibility Principle,英文描述:A class or module should have a single responsibility。也即是一个类或者模块只负责完成一个职责(或功能)。

两个对象类class和模块module,有两种理解方式。一种是:把模块看做比类更抽象的概念,类也可以看做模块。另一种理解:把模块看做比类更粗粒度的代码块,模块中包含多个类,多个类组成一个模块。

一个类只负责完成一个职责或功能,如果包含两个或两个以上业务不相关的功能,就说职责不够单一,应拆分为多个功能更单一、粒度更细的类。如一个类既包含订单的一些操作,也包含用户的一些操作,而订单和用户是两个独立的业务领域模型,需要拆分为订单类和用户类。

2. 如何判断 是否足够单一

大部分情况,类的方法是归为同一类功能,还是归为不相关的两类功能,不容易判定,很难拿捏。

如在一个社交产品中,用下面的UserInfo类来记录用户的信息

public class UserInfo{
	private long UserId;
	private String username;
	private String email;
	private String phone;
	private long createTime;
	private long lastLoginTime;
	private String avatarUrl;
	private String provinceOfAddress;
	private String cityOfAddress;
	private String regionOfAddress;
	private String detailedAddress;
	// ...省略其他属性和方法
}

UserInfo类是否满足单一职责原则呢?

一种观点是:UserInfo类包含的都是跟用户相关的信息,所有的属性和方法都属于用户这个业务模型,满足单一职责原则;另一种认为,地址信息在UserInfo中,所占比重较高,可继续拆分为独立的UserAddress类,UserInfo只保留Address之外的其他信息。

哪种更对?需要结合具体的应用场景。如果在这个社交产品中,用户的地址信息跟其他信息一样,只是单纯用于展示,设计就是合理的。如果该社交产品发展的较好,之后又添加了电商模块,用户的地址信息也会用在电商物流中,最好将地址信息从UserInfo中拆分开来,独立为用户物流信息。

更进一步,如果该社交产品的公司发展的越来越好,公司内部孵化了其他app,希望支持统一账号系统,这时,就需要对UserInfo进行拆分,将跟身份认证相关的信息如email、phone抽取为独立的类。

因此,不同的应用场景、不同阶段的需求背景下,对同一个类的职责是否单一的判断,可能都不同。此外,不同的业务层面看待同一个类的设计,对类是否职责单一,也会有不同的认知,如UserInfo类,从“用户”这个业务层面,满足;从更加细分的“用户展示信息”、“地址信息”、“登录认证信息”等更细的粒度看,应该继续拆分。

因此,没必要过度设计,先写粗粒度的类,满足当前业务需求,之后持续重构。

判断原则:

  • 类中的代码行数、方法或属性过多,会影响代码的可读性和可维护性,需要考虑对类的拆分
  • 类依赖的其他类过多,或者依赖类的其他类过多,不符合高内聚、低耦合的设计思想,考虑对类进行拆分
  • 私有方法过多,考虑能否将私有方法独立到新的类中,设置为public方法,供更多的类使用,提高复用性
  • 比较难给类起个合适的名字,很难用一个业务名词概括,或者只能用一些笼统的context、manager之类的词命名,说明职责定义不够清晰
  • 类中大量的方法都是集中操作类中的某几个属性,如UserInfo中,如果一半方法都是在操作address信息,可以考虑将这几个属性和对应的方法拆分出来

项目做多了,代码写多了,在开发过程中慢慢“品尝”,自然就知道什么是“放盐少许”了,这就是所谓的“专业第六感”。

3. 类的职责是否设计的越单一越好

答案是否定的。如Serialization类实现一个简单协议的序列化和反序列化功能,不必要拆分为两个类。

我们最终目的是为了提高代码的可读性、可扩展性、复用性、可维护性等。以此作为最终的考量标准。

2. 开闭原则

1. 概念

开闭原则Open Closed Principle,OCP,英文描述:software entities(modules,classes,functions,etc.) should be open for extension, but closed for modification。软件实体(模块、类、方法等)应该“对扩展开放、对修改关闭”。

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

2. 案例

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

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.SERVER,"...");
		}
	}
}

上面代码很简单,业务逻辑集中在check()方法中,当接口的tps超过某个预先设置的最大阈值,以及当借口请求出错数大于某个最大允许值时,就会触发告警,通知接口的相关负责人或团队。

现在有个新需求,要添加一个功能,当每秒接口超时请求个数,超过某个预先设置的最大阈值时,也要触发告警发送通知,如何改动代码?

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

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

	//改动1:添加参数 timeoutCount
	public void check(String api,long requestCount,long errorCount,long durationOfSeconds,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.SERVER,"...");
		}

		// 改动2:添加接口超时处理逻辑
		long timeoutTps = timeoutCount/durationOfSeconds;
		if(timeoutTps > rule.getMatchedRule(api).getMaxTimeoutTps()){
			notification.notify(NotificationEmergencyLevel.URGENCY,"...");
		}
	}
}

这样的改动实际存在很多问题。

  1. 对接口进行修改,意味着调用这个接口的代码都要做相应的修改。
  2. 修改了check()方法,相应的单元测试都要修改

如何遵循开闭原则,通过扩展的方式,实现相同的功能呢?

先重构下之前的Alert代码,让其扩展性更好一些。重构内容两部分

  1. 将check()方法的多个入参封装为ApiStatInfo类
  2. 引入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 alertHandler: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.SERVER,"...");
		}
	}
}

重构后的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);
	}
}

基于重构后的代码,如果添加上面的新需求,每秒接口超时请求个数超过某个最大阈值就告警,该如何改动代码?有下面四个地方:

  1. 在ApiStatInfo类中添加新的属性timeoutCount
  2. 添加新的TimeoutAlertHandler类
  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;//改动1:添加新字段
}


public abstract class AlertHandler{
  // 不改动
}


public class TpsAlertHandler extends AlertHandler{
  // 不改动
}

public class ErrorAlertHandler extends AlertHandler{
  // 不改动
}

//改动2:添加新的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));
		//改动3:注册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); // 改动4:设置timeoutCount值
		ApplicationContext.getInstance().getAlert().check(apiStatInfo);
	}
}

重构后更加灵活和易扩展。想添加新的告警逻辑,只需要基于扩展的方式创建新的handler类即可。而且老的单元测试都不会失败,不用修改。

3. 修改代码意味着违反开闭原则吗

  • 先看改动1:在ApiStatInfo类中添加新的属性timeoutCount

不仅添加了属性,还添加对应的getter、setter方法。那这样算修改还是扩展?

实际没必要纠结某个代码改动是修改还是扩展,开闭原则的设计初衷:只要它没有破坏原有的代码的正常运行,没有破坏原有的单元测试,就可以说,是个合格的代码改动

  • 再看改动3和4,在ApplicationContext类的initializeBeans()方法中,往alert对象中注册新的timeoutAlertHandler;在使用Alert类的时候,给check()方法的入参apiStatInfo对象设置timeoutCount的值

重构后,核心逻辑集中在Alert类及其各个handler中,添加新的告警逻辑时,Alert类完全不用修改,只需要扩展一个新的handler类。如果把Alert类及其各个handler类合起来作为一个模块,模块本身在添加新功能时,完全满足开闭原则。

4. 如何做到“对扩展开放、对修改 关闭”

开闭原则讲的就是代码的扩展性问题,是判断一段代码是否易扩展的“金标准”。该问题粗略等同于,如何写出扩展性好的代码。

  • 在具体的方法论之前,先看一些更偏向顶层的指导思想。为了尽量写出扩展性好的代码,需要时刻具备扩展意识、抽象意识、封装意识。这些可能比任何开发技巧都重要。

写代码时,多花时间往前多思考一步。对未来可能有哪些需求变更、如何设计代码结构,事先留好扩展点,以便在未来需求变更时,做到最小代码改动的情况下,新代码灵活的插入扩展点。另外,识别出可变部分和不可变部分后,将可变部分封装,隔离变化,提供抽象化的不可变接口,给上层调用。具体的实现发生变化时,只需基于相同的抽象接口,扩展一个新的实现,替换老的实现即可。

  • 下面看具体的方法论。

23种经典设计模式,大部分都是为了解决代码的扩展性问题而总结出来,以开闭原则为指导原则的。

在众多的设计原则、思想、模式中,最长用来提高代码扩展性的方法有:多态、依赖注入、基于接口而非实现编程,以及大部分的设计模式(如装饰、策略、模板、职责链、状态等)。实际上,多态、依赖注入、基于接口而非实现编程,以及前面提到的抽象意识,说的都是同一种设计思路,从不同的角度、不同的层面来阐述而已。这也体现了“很多设计原则、思想、模式都是相通的”这一思想。

例如,我们代码中通过kafka发送异步消息。对于这样一个功能的开发,要学会将其抽象成一组跟具体消息队列无关的异步消息接口。所有上层系统都依赖这组抽象的接口编程,并通过依赖注入的方式调用。要替换kafka为rocketMQ时,很方便拔掉老的消息队列实现,插入新的消息队列实现。具体代码如下

//这一部分体现了抽象意识
public interface MessageQueue{ 
	//...
}

public class KafkaMessageQueue implements MessageQueue{
	//...
}
public class RocketMessageQueue implements MessageQueue{
	// ...
}

public interface MessageFormatter{
	//...
}
public class JsonMessageFormatter implements MessageFormatter{
	//...
}
public class ProtoBufMessageFormatter implements MessageFormatter{
	//...
}

public class Demo{
	private MessageQueue msgQueue;//基于接口而非实现编程
	public Demo(MessageQueue msgQueue){
		//依赖注入
		this.msgQueue = msgQueue;
	}

	//msgFormatter 多态、依赖注入
	public void sendNotification(Notification notification,MessageFormatter msgFormatter){
		//...
	}
}

5. 如何在项目中灵活运用开闭原则

关键是预留扩展点。如何识别所有可能的扩展点?

如果开发的是业务导向的系统,如金融系统、电商系统、物流系统等,要想识别尽可能多的扩展点,需要对业务有足够的了解,能够知道当下及未来可能要支持的业务需求。如果开发的是跟业务无关的、通用的、偏底层的系统,如框架、组件、类库等,要了解“他们会被如何使用?今后打算添加哪些功能?使用者未来会有哪些更多的需求?”等问题。

当然,“唯一不变的只有变化本身”。没必要为了一些遥远的、不一定发生的需求去提前买单,做过度设计。最合理的是,对一些比较确定的、短期内可能就会扩展,或者需求改动对代码结构影响较大的情况,或者实现成本不高的扩展点,编写代码时,可以事先做扩展性设计。对不确定未来是否支持,或者实现较复杂的扩展点,等到有需求驱动,再重构代码。

另外,开闭原则也需适度。有些情况下,代码的扩展性和可读性有冲突。如之前的Alert的例子。重构后理解更有难度,需要权衡。如果告警规则不多,也不复杂,最初的实现就比较合理;否则,告警规则很多,很复杂,check()方法的if语句和代码逻辑就会很多、很复杂,代码行数太多,影响可读性、可维护性,第二种代码思路就更合理。总之,需要因地制宜。

3. 里式替换原则

1. 概念

英文:Liskov Substitution Principle,LSP。Matin在SOLID原则中重新阐释:Functions that use pointers of references to base classes must be able to use objects of derived classes without knowing it。子类对象能够替换程序中父类对象出现的任何地方,并保证原来程序的逻辑行为不变及正确性不被破坏。

2. 案例

举例说明,父类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){
		Request request = new Request();
		//...省略设置request中数据值的代码...
		Response response = transporter.sendRequest(request);
		//...省略其他逻辑...
	}
}

//里式替换原则
Demo demo = new Demo();
demo.demoFunction(new SecurityTransporter());//省略参数

上述代码,子类的设计完全符合里式替换原则,可以替换父类出现的任何位置,且原来的代码的逻辑行为不变且正确性没被破坏。

我们将代码改造下。对于SecurityTransporter的sendRequest()方法,如果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()方法的是子类SecurityTransporter对象,可能会抛异常,整个程序的逻辑行为改变了。从设计思路上说,SecurityTransporter的设计不符合里式替换原则。

虽然从定义描述和代码实现说,多态和里式替换有点类似,但角度不同。多态是面向对象编程的一大特性,也是面向对象编程语言的一种语法。是一种代码实现的思路。而里式替换是一种设计原则,用来指导继承关系中子类该如何设计,子类要保证替换父类的时候不改变原有程序的逻辑以及不破坏原有程序的正确性。

3. 哪些代码明显违反了LSP

里式替换原则还有个更落地的描述,“design by contract”,中文“按照协议来设计”。子类在设计的时候要遵守父类的行为约定(或者说协议)。这里的行为约定包括:方法声明要实现的功能;对输入、输出、异常的约定;甚至包括注释中罗列的任何特殊说明。其实,这种关系,也可替换为接口和实现类的关系。

违反原则的案例:

  1. 子类违反父类声明要实现的功能。 父类提供的sortOrdersByAmount()订单排序方法按金额大小排序,子类重写后按创建时间排序。
  2. 子类违反父类对输入、输出、异常的约定 如SecurityTransporter的案例
  3. 子类违反父类注释中罗列的任何特殊说明 如父类定义的withdraw()提现方法的注释为:“用户的提现金额不得超过账户余额…”,而子类重写后,针对VIP账号实现透支提现的功能。也不符合。

其实,反过来说,父类/接口就要设计的更通用,且可扩展,这样实现类就可根据具体场景选择具体实现逻辑,不必担心破坏顶层的接口规范。

里式替换是方法层级的开闭原则,子类完美继承父类的设计初衷,并做增强。也就是保证兼容的前提下做扩展和调整,如spring的发展

4. 接口隔离原则

1. 概念

接口隔离原则,Interface Segregation Principle,ISP。Robert Martin的SOLID原则中定义:Clients should not be forced to depend upon interfaces that they do not use。客户端不应该强迫依赖它不需要的接口。客户端,可理解为接口的调用者或使用者。

接口,可理解为:

  • 一组API接口集合
  • 单个API接口或方法
  • OOP中的接口概念

2. 把接口理解为一组API接口集合

例如,微服务用户系统提供了一组跟用户相关的API给其他系统使用,如注册、登记、获取用户信息等。

public interface UserService{
	boolean register(String cellphone,String passwd);
	boolean login(String cellphone,String passwd);
	UserInfo getUserInfoById(long id);
	UserInfo getUserInfoByCellphone(String cellphone);
}

public class UserServiceImpl implements UserService{
	//...
}

现在有个需求,后台管理系统要实现删除用户的功能,希望用户系统提供一个删除用户的接口,该如何做?第一反应就是在UserService中天啊及一个deleteUserByCellphone()或deleteUserById()接口,这样可以解决问题,但也隐藏安全隐患。

删除用户是个非常慎重的操作,我们只希望通过后台管理系统执行,该接口只限于给后台管理系统使用。如果放到UserService,所有用到它的系统,都可以调用该接口,可能导致误删用户。

当然,最好的解决方案是,从架构设计的层面,通过接口鉴权限制接口的调用。不过,如果暂时没有鉴权框架,我们还能从代码设计的层面,避免接口被误用。参考接口隔离原则,调用者不应该强迫依赖它不需要的接口,将删除接口单独放到另一个接口RestrictedUserService中,将其只打包提供给后台管理系统调用。

public interface UserService{
	boolean register(String cellphone,String passwd);
	boolean login(String cellphone,String passwd);
	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{
	//...
}

上述案例,把接口隔离原则中的接口,理解为一组接口集合,可以是某个微服务的接口,也可以是某个类库的接口等。在设计微服务或类库接口时,如果部分接口只被部分调用者使用,就需要将这部分接口隔离出来,单独给对应的调用者使用,不能强迫其他调用者也依赖这部分不会被用到的接口。

3. 把接口理解为单个API接口或方法

换种理解方式,理解为单个接口或方法,那接口隔离原则可以理解为:方法的设计要功能单一,不要将多个不同的功能逻辑在一个方法中实现。

public class Statistics{
	private Long max;
	private Long min;
	private Long average;
	private Long sum;
	private long percentile99;
	//...省略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(Collection<Long> dataSet);
//...省略其他统计方法...

当然,判定功能是否单一,还要结合具体的场景,如果在项目中,对每个统计需求,Statistics定义的那几个统计信息都有涉及,count()方法的设计就是合理的。相反,如果每个统计需求只涉及Statistics罗列的统计信息的一部分,如有的只需用到max、min、average这三类信息,有的只需用到average、sum。这样就不合理了,应拆分更细粒度。

接口隔离原则和单一职责原则的区别。单一职责原则针对的是模块、类、接口的设计。而接口隔离原则相对于单一职责原则,一方面更侧重接口的设计,另一方面思考角度不同,提供了一种判断接口是否职责单一的标准:通过调用者如何使用接口来间接的判定。如果调用者只使用部分接口或接口的部分功能,接口的设计就不够职责单一。

4. 把接口理解为OOP中的接口概念

可以把接口理解为OOP中的接口概念,如java中的interface。

例如我们的项目用到三个外部系统:redis、mysql、kafka。每个系统都对应一系列的配置信息,如地址、端口、访问超时时间等。为了在内存中存储这些配置信息,供项目的其他模块使用,我们分别设计实现三个Configuration类:RedisConfig、MysqlConfig、KafkaConfig。具体如下:

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 implements Updater{
	//...省略其他属性和方法...
	@Override
	public void update(){
		//...
	}
}

public class KafkaConfig implements Updater{
	//...省略其他属性和方法...
	@Override
	public void update(){
		//...
	}
}

public class KafkaConfig{
	//...省略其他属性和方法...
}

public class ScheduledUpdater{
	private final ScheduledExecutorService executor = Executors.newSingleThread();
	private long initialDelayInSeconds;
	private long periodInSeconds;
	private Updater updater;

	public ScheduledUpdater(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 KafkaConfig(configSource);
	public static final MySqlConfig mysqlConfig = new MySqlConfig(configSource);

	public static void main(String[] args){
		ScheduledUpdater redisConfigUpdater = new ScheduledUpdater(redisConfig,3000);
		redisConfigUpdater.run();

		ScheduledUpdater kafkaConfigUpdater = new ScheduledUpdater(kafkaConfig,3000);
		kafkaConfigUpdater.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 implements 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)){
			vieweers.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 KafkaConfig(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();

		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接口,也满足接口隔离原则。

我们如果不遵守接口隔离原则,不设计这两个小接口,而是设计一个大而全的Config接口,让RedisConfig、KafkaConfig和MysqlConfig都实现该接口,并将原来传递给ScheduledUpdater的Updater和传递给SimpleHttpServer的Viewer,都替换为Config,会有什么问题呢?

首先,第一种设计思路更灵活、易扩展、易复用。因为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 KafkaConfig(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",apiConfig);
		simpleHttpServer.addViewer("/metrics",dbConfig);
		simpleHttpServer.run();
	}
}

其次,第二种设计思路在代码实现上做了一些无用功。Config接口包含两类不相关的接口。因此,RedisConfig、KafkaConfig、MysqlConfig必须同时实现Config的所有接口方法(update、output、outputInPlainText)。此外,如果要往Config中继续添加新接口,所有的实现类都要改动。相反,如果我们的接口粒度比较小,涉及到改动的类就比较少。

猜你喜欢

转载自blog.csdn.net/wjl31802/article/details/107627006