设计模式之美笔记5

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

实战1:如何做需求分析和设计

对工程师来说,不能只把自己放到执行者的角色,不能只是代码实现者,还要有独立负责一个系统的能力,能端到端(end to end)开发一个完整的系统。其中包括:前期的需求沟通分析、中期的代码设计实现、后期的系统上线维护。

业务开发如何用到设计原则、思想、模式?积分兑换系统的开发案例,展示业务系统从需求分析到上线维护的整个开发套路,一是能举一反三用到其他系统的开发,二是展示看似没技术含量的业务开发,实际蕴含的设计原则、思想、模式。

1. 需求分析

积分是一种常见的营销手段,很多产品通过它促进消费、增加用户粘性,如支付宝积分、信用卡积分、商场的消费积分等。如果你是一家电商平台的工程师,平台暂时没有积分系统。leader希望你来负责开发这样一个系统,如何做?

作为技术人,如何做产品设计?要学会“借鉴”。爱因斯坦说过,“创造的一大秘诀是要懂得如何隐藏你的来源”。要站在巨人肩膀上。可以先找几个类似的产品,如淘宝的积分系统设计,借鉴到我们产品中。可以亲自用用淘宝,看积分如何使用,也可直接百度“淘宝积分规则”。基本上就能摸清积分系统如何设计。此外,也要结合自己公司的产品,做适当的微创新。

总的来说,积分系统有两个大的功能点:

  • 赚积分 包括积分赚取渠道,如下订单、每日签到、评论等;也包括积分兑换规则,如订单金额和积分的兑换比例,每日签到赠送多少积分等。
  • 消费积分 包括积分消费渠道,如抵扣订单金额、兑换优惠券、积分换购、参与活动扣积分等;也包括积分兑换规则,如多少积分可换算为抵扣订单的多少金额,一张优惠券需要多少积分兑换等。

当然还有一些细节,如积分的有效期问题。为了防止遗漏,除了借鉴之外,还有通过产品的线框图、用户用例(user case)或者叫用户故事(user story)来细化业务流程,挖掘比较细节、不易想到的功能点。

用户用例类似单元测试用例,侧重情景化,也就是模拟用户如何使用我们的产品,描述用户在一个特定的应用场景里的一个完整的业务操作流程。包含更多细节,也更易理解。如,有关积分有效期的用户用例,进行如下设计:

  • 用户在获取积分时,会告知积分的有效期
  • 用户在使用积分时,会优先使用快过期的积分
  • 用户查询积分明细时,会显示积分的有效期和状态(是否过期)
  • 用户查询总可用积分时,会排除过期积分

通过上述方法,将功能需求大致搞清楚。

扫描二维码关注公众号,回复: 11579915 查看本文章

积分系统的需求,大致如下

1. 积分赚取和兑换规则

积分的赚取渠道包括:下订单、每日签到、评论等

积分兑换规则比较通用,如签到送10积分;按照订单总金额的10%兑换成积分,也就是100块钱的订单积累10积分。此外,积分兑换规则还能细化,如不同的店铺、不同的商品,可设置不同的积分兑换比例。

对积分的有效期,可根据不同渠道,设置不同的有效期。积分到期后作废;消费积分的时候,优先使用快到期的积分。

2. 积分消费和兑换规则

消费渠道包括:抵扣订单金额、兑换优化券、积分换购、参与活动扣积分等。

根据不同消费渠道,设置不同的积分兑换规则。如积分换算为消费抵扣金额的比例为10%,也就是10积分抵扣1块钱;100积分可兑换15块钱的优惠券等。

3. 积分及明细查询

查询用户的总积分,及赚取积分和消费积分的历史记录。

2. 系统设计

面向对象设计聚焦在代码层面(主要针对类),系统设计聚焦在架构层面(主要针对模块)。面向对象设计的四个步骤,可借鉴做系统设计。

1. 合理的将功能划分为不同模块

面向对象设计的本质就是把合适的代码放到合适的类中。合理的划分代码可实现代码的高内聚、低耦合,类和类之间交互简单清晰,代码整体结构一目了然。类比面向对象设计,系统设计就是将合适的功能放到合适的模块中。合理的划分模块也可做到模块层面的高内聚、低耦合。架构清晰整洁。

对于之前罗列的功能点,有三种模块划分方法:

  • 积分赚取渠道和兑换规则、消费渠道和兑换规则的管理和维护(增删改查),不划分到积分系统,而是放到更上层的营销系统。这样积分系统非常简单,只负责增加积分、减少积分、查询积分、查询积分明细等几个工作。

举例解释,如用户通过下订单赚取积分。订单系统通过异步发送消息或者同步调用接口的方式,告知营销系统订单交易成功。营销系统根据拿到的订单信息,查询订单对应的积分兑换规则(兑换比例、有效期等),计算得到订单可兑换的积分数量,然后调用积分系统的接口给用户增加积分。

  • 积分赚取渠道及兑换规则、消费渠道及兑换规则的管理维护,分散到各个相关业务系统中,如订单系统、评论系统、换购商城、签到系统、优惠券系统等。

还是刚才的下订单赚取积分的例子,这种情况,用户下订单成功后,订单系统根据商品对应的积分兑换比例,计算能兑换的积分数量,然后调用积分系统给用户增加积分。

  • 所有的功能都划分到积分系统中,包括积分赚取渠道及兑换规则、消费渠道及兑换规则的管理维护。

还是同样例子,用户下单成功后,订单系统直接告知积分系统订单交易成功,积分系统根据订单信息查询积分兑换规则,给用户增加积分。


那怎么判断哪种模块划分合理呢?

可以看它是否符合高内聚、低耦合特性判断。如果一个功能的修改或添加,经常跨团队、跨系统才能完成,说明模块划分不够合理,职责不够清晰,耦合过于严重。

此外,为避免业务知识的耦合,让下层系统更通用,一般的话,不希望下层系统(也就是被调用的系统)包含太多上层系统(也就是调用系统)的业务信息,但是,可接受上层系统包含下层系统的业务信息。如,订单系统、优惠券系统、换购商城作为调用积分系统的上层系统,可包含一些积分相关的业务信息。反过来,积分系统最好不要包含太多跟订单、优惠券、换购等相关的信息。

因此,综合考虑,更倾向于第一种和第二种模块划分方式。不管哪种,积分系统负责的工作一样,只包含积分的增减、查询,以及积分明细的记录和查询。

2. 设计模块和模块之间的交互关系

类比面向对象设计,系统设计在系统职责划分好后,就是设计系统之间的交互,也就是确定有哪些系统跟积分系统之间有交互以及如何交互。

常见的交互方式有两种:

  • 同步接口调用 简单直接
  • 利用消息中间件异步调用 解耦效果好

比如,用户下订单成功后,订单系统推送一条消息到消息中间件,营销系统订阅订单成功消息,触发执行相应的积分兑换逻辑。这样订单系统跟营销系统完全解耦。

此外,上下层系统之间的调用倾向于通过接口同步,同层之间的调用倾向于异步消息调用。如营销系统和积分系统是上下层关系,推荐同步接口调用。

3. 设计模块的接口、数据库、业务模型

再看模块本身如何设计。实际上,业务系统本身的设计无外乎三方面工作:接口设计、数据库设计和业务模型设计。

3. 代码实现

1. 业务开发包括哪些工作

三方面:接口设计、数据库设计、业务模型设计(也就是业务逻辑)

数据库和接口的设计非常重要,一旦设计好并投入使用后,不能轻易改动。改动数据库表结构,涉及数据的迁移和适配;改动接口,需要推动接口的使用者做相应的代码修改。相反,业务逻辑代码侧重内部实现,不涉及被外部依赖的接口,也不包含持久化的数据,对改动容忍性更大。

  • 如何设计积分系统的数据库

数据库设计较简单,只需一张记录积分流水明细的表即可。表中记录积分的赚取和消费流水,用户积分的各种统计数据如总积分、总可用积分等都可通过该表计算得到。积分明细表(credit_transaction)的字段如下表:

id 明细id
channel_id 赚取或消费渠道ID
event_id 相关事件ID,如订单ID、评论ID、优惠券换购交易ID
credit 积分(赚取为正值、消费为负值)
create_time 积分赚取或消费时间
expired_time 积分过期时间
  • 再看如何设计积分系统的接口

接口设计要符合单一职责原则,但粒度太小也会带来一些问题。如一个功能的实现要调用多个小接口,一方面如果接口调用走网络(特别是公网),多次远程接口调用会影响性能;另一方面,本该在一个接口完成的原子操作,拆分多个小接口完成,可能会涉及分布式事务的数据一致性问题(一个接口成功,另一个接口执行失败)。为了兼容易用性和性能,借鉴facade外观模式,在职责单一的细粒度接口上,再封装一层粗粒度的接口给外部调用。需要设计的接口如下:

接口 参数 返回值
赚取积分 userId,channelId,eventId,credit,createTime,expiredTime 积分明细ID
消费积分 userId,channelId,eventId,credit,createTime,expiredTime 积分明细ID
查询积分 userId 总可用积分
查询总积分明细 userId+分页参数 id,userId,channelId,eventId,credit,createTime,expiredTime
查询赚取积分明细 userId+分页参数 id,userId,channelId,eventId,credit,createTime,expiredTime
查询消费积分明细 userId+分页参数 id,userId,channelId,eventId,credit,createTime,expiredTime
  • 最后看业务模型的设计

service层就是业务模型。

开发角度说,可把积分系统作为一个独立的项目,独立开发,也可跟其他业务代码(如营销系统)放到同一个项目中开发。从运维角度,可跟其他业务一块部署,也可作为微服务独立部署。具体参考公司的当前技术架构。

实际更倾向于将它跟营销系统放到一个项目中开发部署。只要做好代码的模块化和解耦,让积分相关的业务代码跟其他业务代码之间边界清晰,没有过多耦合。后期需要拆分为独立的项目开发部署,也好重构。

2. 为什么分MVC三层开发

为什么要分层开发?一层代码搞定所有的数据读取、业务逻辑、接口暴露不好吗?

原因:

  • 分层起到代码复用的作用 同一个dao可被多个service调用。
  • 分层起到隔离变化的作用 体现了抽象和封装的思想,service层使用dao层的接口,不关系底层依赖哪种数据库,如果改动数据库时,只需要改动dao层即可。
  • 分层起到隔离关注点的作用 dao层只关注数据的读写;service层只关注业务逻辑,不关注数据来源;controller层只关心和外界打交道,数据校验、封装、格式转换,不关注业务逻辑。分层后,职责分明,代码内聚性更好。
  • 分层提高代码的可测试性 测试service层的代码时,可用mock的数据源替代真实数据库,注入到service层代码
  • 分层能应对系统的复杂性 类或者方法代码过多,可读性和可维护性就会变差,需要拆分。水平方向基于业务拆分,就是模块化;垂直方向基于流程拆分,就是分层

3. BO、VO、Entity的意义

针对三层,分别有BO、VO、Entity三个数据对象。包含重复的字段,甚至字段完全一样。那是否定义一个公共的数据对象更好呢?

更倾向于分别定义,原因:

  • VO、BO、Entity并非完全一样。如UserEnity、UserBo中定义Password字段,但显然不能再UserVo定义password字段,否则会将用户密码暴露出去
  • 虽然三者代码重复,但功能语义不重复,职责上不一样,并不违反DRY原则
  • 为减少每层之间的耦合,职责边界更明确,这样设计虽然有些繁琐,但分层更清晰,而对大型项目,结构清晰第一位

那如何解决代码重复的问题呢?

如果有代码洁癖,可将公共字段定义在父类,用继承解决。此外,也能用组合解决。将公共的字段抽取到公共类,VO、BO、Entity通过组合复用这个类的代码。

不同分层之间的数据对象该如何转化呢?

最简单就是手动复制。java也提供了多种数据对象转化工具,如BeanUtils、Dozer等,简化对象转化工作。

实战2:非业务的通用框架开发的需求分析和设计

1. 项目背景

希望设计开发一个小的框架,能获取接口调用的各种统计信息,如响应时间的最大值max、最小值min、平均值avg、百分位值percentile、接口调用次数count、频率tps等。并支持将统计结果以各种显示格式(如json格式、网页格式、自定义显示格式等)输出到各种终端(console命令行、http网页、email、日志文件、自定义输出终端等),以方便查看。

假设这是真实项目中的一个开发需求,如果让你负责开发这样一个通用的框架,应用到各种业务系统中,支持实时计算、查看数据的统计信息,如何设计和实现呢?

2. 需求分析

性能计数器作为和业务无关的功能,可开发为一个独立的框架或者类库,集成到很多业务系统中。作为可被复用的框架,除了功能性需求外,非功能性需求也很重要。

1. 功能性需求分析

相对于一长串的文字描述,人脑更容易理解短的、罗列规整的、分门别类的列表信息,我们需要拆解为若干条:

  • 接口统计信息:包括接口响应时间的统计信息,以及接口调用次数的统计信息等
  • 统计信息的类型:max、min、avg、percentile、count、tps等
  • 统计信息显示格式:json、html、自定义格式
  • 统计信息显示终端:console、email、http网页、日志、自定义显示终端

此外,借助产品设计使用的线框图,将最终数据的显示样式画出来,更好:

在这里插入图片描述
在这里插入图片描述

在这里插入图片描述

其实,从线框图中,还挖掘出下面几个隐藏的需求:

  • 统计触发方式:包括主动和被动两种
    • 主动表示以一定的频率定时统计数据,并主动推送到显示终端,如邮件推送
    • 被动表示用户触发统计,如用户在网页中选择要统计的时间区间,触发统计,并结果显示给用户
  • 统计时间区间:框架需要支持自定义统计时间区间,如统计最近10分钟的某接口的tps、访问次数,或统计从2020年7月29日14:53:19到2020年7月29日14:53:22之间某接口响应时间的最大值、最小值、平均值等。
  • 统计时间间隔:对主动触发的统计,还要支持指定统计时间间隔,也就是多久触发一次统计显示。如每隔10s统计一次接口信息并显示到命令行,每隔24小时发送一封统计信息邮件

2. 非功能性需求分析

对通用的框架开发,还要考虑很多非功能性的需求,包括:

  • 易用性 是评价产品的标准,要有产品意识。框架是否易集成、易插拔、跟业务代码是否低耦合、提供的接口是否足够灵活等,都要思考和设计。有时,文档写的好坏甚至决定一个框架是否受欢迎。
  • 性能 对需要集成到业务系统的框架来说,不希望框架本身的代码执行效率,对业务系统有太多性能的影响。对性能计数器这个框架来说,一方面希望是低延迟,也就是统计代码不影响或很少影响接口本身的响应时间;另一方面,希望框架本身对内存的消耗不能太大。
  • 扩展性 之前讲的扩展性是从框架代码开发者的角度说的,现在是从框架使用者的角度说,特指使用者在不修改框架源码,甚至不拿到框架源码的情况下,为框架扩展新的功能,类似于给框架开发插件。
  • 容错性 不能因为框架本身的异常导致接口请求出错。对框架可能存在的各种异常情况都考虑全面,对外暴露的接口抛出的所有运行时、非运行时异常都进行捕获处理
  • 通用性 设计的时候,尽可能通用,多思考下,除了接口统计这个需求,还能使用到哪些场景,如是否还可以处理其他事件的统计信息,如SQL请求时间的统计信息、业务统计信息(如支付成功率)等。

对于扩展性,举例说明。feign是个http客户端框架,可不修改框架源码情况下,用如下方式来扩展我们自己的编解码方式、日志、拦截器等。

Feign feign = Feign.builder()
	.logger(new CustomizedLogger())
	.encoder(new FormEncoder(new JacksonEncoder))
	.decoder(new JacksonDecoder)
	.errorDecoder(new ResponseErrorDecoder())
	.requestInterceptor(new RequestHeadersInterceptor()).build();
public class RequestHeadersInterceptor implements RequestInterceptor{
	@Override
	public void apply(RequestTemplate template){
		template.header("appId","...");
		template.header("version","...");
		template.header("timestamp","...");
		template.header("token","...");
		template.header("idempotent-token","...");
		template.header("sequence-id","...");
	}

	public class CustomizedLogger extends feign.Logger{
		//...
	}

	public class ResponseErrorDecoder implements ErrorDecoder{
		@Override
		public Exception decode(String methodKey,Response response){
			//...
		}
	}
}

3. 框架设计

针对需求做框架设计。

先借鉴TDD(测试驱动开发)和prototype(最小原型)的思想,聚焦一个简单的应用场景,基于此设计实现一个简单的原型。尽管这个最小原型系统在功能和非功能特性上都不完善,但比较具体,能有效地帮我们缕清更复杂的设计思路,是迭代设计的基础。

对性能计数器这个框架的开发来说,可以先聚焦一个非常具体、简单的应用场景,如统计用户注册、登录这两个接口的响应时间的最大值和平均值、接口调用次数,并将统计结果以json格式输出到命令行。

现在的需求简单、具体、明确,设计实现难度降低很多。

先给出应用场景的代码:

// 应用场景:统计下面两个接口(注册和登录)的响应时间和访问次数
public class UserController{
	public void register(UserVo user){
		//...
	}

	public UserVo login(String phone,String password){
		//...
	}
}

要输出接口的响应时间的最大值、平均值和接口调用次数,先要采集每次接口请求的响应时间,并存储起来,再按照某个时间间隔做聚合统计,最后输出结果。原型系统的代码实现中,可先把所有代码塞到一个类,怎么简单怎么来。

最小原型的代码实现如下。recordResponseTime()和recordTimestamp()两个方法分别用来记录接口请求的响应时间和访问时间。startRepeatedReport()方法以指定的频率统计数据并输出结果。

public class Metrics{
	//map的key是接口名称,value对应接口请求的响应时间或时间戳
	private Map<String,List<Double>> responseTimes = new HashMap<>();
	private Map<String,List<Double>> timestamp = new HashMap<>();
	private ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();

	public void recordResponseTime(String apiName,double responseTime){
		responseTimes.putIfAbsent(apiName,new ArrayList<>());
		responseTimes.get(apiName).add(responseTime);
	}

	public void recordTimestamp(String apiName,double timestamp){
		timestamps.putIfAbsent(apiName,new ArrayList<>());
		timestamps.get(apiName).add(timestamp);
	}

	public void startRepeatedReport(long period,TimeUnit unit){
		executor.scheduleAtFixedRate(new Runnable(){
			@Override
			public void run(){
				Gson gson = new Gson();
				Map<String,List<Double>> stats = new HashMap<>();
				for(Map.Entry<String,List<Double>> entry:responseTimes.entrySet()){
					String apiName = entry.getKey();
					List<Double> apiRespTimes = entry.getValue();
					stats.putIfAbsent(apiName,new HashMap<>());
					stats.get(apiName).put("max",max(apiRespTimes));
					stats.get(apiName).put("avg",avg(apiRespTimes));
				}

				for(Map.Entry<String,List<Double>> entry:timestamps.entrySet()){
					String apiName = entry.getKey();
					List<Double> apiTimestamps = entry.getValue();
					stats.putIfAbsent(apiName,new HashMap<>());
					stats.get(apiName).put("count",(double)apiTimestamps.size());
				}
				System.out.println(gson.toJson(stats));
			}
		},0,period,unit);
	}

	private double max(List<Double> dataset){//省略代码实现}
	private double avg(List<Double> dataset){//省略代码实现}
}

用不到50行的代码就实现了最小原型,接下来看如何用它统计注册、登录接口的响应时间和访问次数。具体代码如下:

// 应用场景:统计下面两个接口(注册和登录)的响应时间和访问次数
public class UserController{
	private Metrics metrics = new Metrics();

	public UserController(){
		metrics.startRepeatedReport(60,TimeUnit.SECONDS);
	}

	public void register(UserVo user){
		long startTimestamp = System.currentTimeMills();
		metrics.recordTimestamp("register",startTimestamp);
		//...
		long respTime = System.currentTimeMills()-startTimestamp;
		metrics.recordResponseTime("register",respTime);
	}

	public UserVo login(String phone,String password){
		long startTimestamp = System.currentTimeMills();
		metrics.recordTimestamp("login",startTimestamp);
		//...
		long respTime = System.currentTimeMills()-startTimestamp;
		metrics.recordResponseTime("login",respTime);
	}
}

现在基于这个最小原型的代码做最终的框架设计。下面是针对性能计数器框架画的粗略的系统设计图。
在这里插入图片描述

如图,分为四个模块:数据采集、存储、聚合统计、显示

  • 数据采集:负责埋点采集原始数据,包括记录每次接口请求的响应时间和请求时间。数据采集过程要高度容错,不影响接口本身的可用性。此外,这部分暴露给框架使用者,因此,尽量考虑易用性
  • 存储:负责将采集的原始数据保存下来,以便后续做聚合统计。数据存储方式多种,如redis、mysql、Hasee、日志、文件、内存等。数据存储较为耗时,为尽量减少对接口性能的影响,采集和存储过程异步完成
  • 聚合统计:负责将原始数据聚合为统计数据,如max、min、avg、percentile、count、tps等,为支持更多聚合统计规则,代码要尽可能灵活、可扩展
  • 显示:负责将统计数据以某种格式显示到终端,如输出到命令行、邮件、网页、自定义终端等

软件设计开发是个迭代的过程,分析、设计和实现这三个阶段的界限划分并不明显。

4. 小步快跑,逐步迭代

作者写文章的时候,试图去实现上面罗列的所有功能需求,希望写个完美的框架,结果太过烧脑,其实罗马不是一天建成的,对于现在的互联网项目来说,小步快跑、逐步迭代是更好的开发模式。分多个版本逐步完善,第一版先实现基本功能,更高级、更复杂的功能,以及非功能性需求不做过高的要求,后续2.0、3.0版本继续优化。

在V1.0版本,暂时只实现下面这些功能:

  • 数据采集:负责埋点采集原始数据,包括记录每次接口请求的响应时间和请求时间
  • 存储:负责将采集的原始数据保存下来,以便后续做聚合统计。存储方式暂时只支持redis,且采集和存储两个过程同步执行
  • 聚合统计:负责将原始数据聚合为统计数据,包括响应时间的最大值、最小值、平均值、99.9百分位值、99百分位值,以及接口请求的次数和tps
  • 显示:负责将统计数据以某种格式显示到终端,暂时只支持主动推送给命令行和邮件。命令行间隔n秒统计显示上m秒的数据(如间隔60s统计上60s的数据)。邮件每日统计上日的数据。

学会结合具体的需求,做合理的预判、假设、取舍,规划版本的迭代设计开发,是资深工程师必须的能力。

5. 面向对象设计和实现

按照之前讲的面向对象设计的几个步骤,重新划分、设计类。

1. 划分职责进而识别有哪些类

根据需求,先大致识别出下面几个接口或类。

  • MetricsCollector类负责提供API,来采集接口请求的原始数据,可以为MetricsCollector抽象出一个接口,但并不是必须的,因为暂时只能想到一个MetricsCollector的实现方式
  • MetricsStorage接口负责原始数据存储,RedisMetricsStorage类实现MetricsStorage接口,这样做也为今后灵活扩展新的存储方法,如用hbase
  • Aggregator类负责根据原始数据计算统计数据
  • ConsoleReporter类、EmailReporter类分别负责以一定的频率统计并发送统计数据到命令行和邮件。至于他们能否抽象出可复用的抽象类或公共的接口,暂时还不能确定

2. 定义类及类与类之间的关系

大致识别出几个核心的类之后,习惯先在IDE中创建好这几个类,然后试着开始定义它们的属性和方法。在设计类、类与类之间交互的时候,不断的用之前学过的设计原则和思想来审视设计是否合理,如是否满足单一职责原则、开闭原则、依赖注入、KISS原则、DRY原则、迪米特法则,是否符合基于接口而非实现编程思想,代码是否高内聚、低耦合,是否可抽象出可复用代码等。

MetricsCollector类的定义很简单,对比之前最小原型的代码,通过引入RequestInfo类来封装原始数据信息,用一个采集方法代替之前的两个方法。

public class MetricsCollector{
	private MetricsStorage metricsStorage;//基于接口而非实现编程

	//依赖注入
	public MetricsCollector(MetricsStorage metricsStorage){
		this.metricsStorage = metricsStorage;
	}

	//用一个方法代替最小原型中的两个方法
	public void recordRequest(RequestInfo requestInfo){
		if(requestInfo==null || StringUtils.isBlank(requestInfo.getApiName)){
			return;
		}
		metricsStorage.saveRequestInfo(requestInfo);
	}
}

public class RequestInfo{
	private String apiName;
	private double responseTime;
	private long timestamp;
	//...省略constructor/getter/setter方法
}

MetricsStorage类和RedisMetricsStorage类的属性和方法比较明确,具体代码如下。注意,一次性取太长时间区间的数据,可能会拉取太多的数据到内存,可能撑爆内存。对java来说,可能触发OOM,即便不出现OOM,也会因为内存吃紧,导致频繁的FullGC,导致系统接口请求处理变慢,甚至超时。之后会解决这个问题。

public interface MetricsStorage{
	void saveRequestInfo(RequestInfo requestInfo);

	List<RequestInfo> getRequestInfo(String apiName,long startTimeInMills,long endTimeInMillis);

	Map<String,List<RequestInfo>> getRequestInfos(long startTimeInMills,long endTimeInMillis );
}

public class RedisMetricsStorage implements MetricsStorage{
	//...省略属性和构造方法等
	@Override
	public void saveRequestInfo(RequestInfo requestInfo){
		//...
	}

	@Override
	public List<RequestInfo> getRequestInfo(String apiName,long startTimeInMills,long ){
		//...
	}
	
	@Override
	public Map<String,List<RequestInfo>> getRequestInfos(long startTimeInMills,long ){
		//...
	}
}

统计和显示这两个功能有很多设计思路,如果把统计显示所要完成的功能逻辑细分,包括4点:

  1. 根据给定的时间区间,从数据库拉取数据
  2. 根据原始数据,计算得到统计数据
  3. 将统计数据显示到终端(命令行或邮件)
  4. 定时触发以上3个过程的执行

其实,一句话总结的话,面向对象设计和实现要做的事情,就是把合适的代码放到合适的类中。把上述的4个功能逻辑划分到几个类中,划分方法有多种,如把前两个逻辑放到一个类中,第三个逻辑放到另一个类,第4个逻辑作为上帝类(God Class)组合前两个类来触发前3个逻辑的执行。也可以把第二个逻辑单独放到一个类,1,3,4放到另一个类中。

到底选择哪种排列组合方式,判定的标准是,让代码尽量满足低耦合、高内聚、单一职责、对扩展开放对修改关闭等设计原则和思想,让设计满足代码易复用、易读、易扩展、易维护。

暂时将1,3,4逻辑放到COnsoleReporter或EmailReporter类中,第2个逻辑放到Aggeregator类中。其中,Aggregator类负责的逻辑较简单,设计为只包含静态方法的工具类。

public class Aggregator{
	public static RequestStat aggregate(List<RequestInfo> requestInfos,long durationInMills){
		double maxRespTime = Double.MAX_VALUE;
		double minRespTime = Double.MIN_VALUE;
		double avgRespTime = -1;
		double p999RespTime = -1;
		double p99RespTime = -1;
		double sumRespTime = 0;
		long count = 0;
		for(RequestInfo requestInfo:requestInfos){
			count++;
			double respTime = requestInfo.getResponseTime();
			if(maxRespTime<respTime){
				maxRespTime = respTime;
			}
			if(minRespTime>respTime){
				minRespTime = respTime;
			}

			sumRespTime +=respTime;
		}
		if(count!=0){
			avgRespTime = sumRespTime/count;
		}
		long tps  = (long)(count/durationInMills*1000);
		Collections.sort(requestInfos,new Comparator<RequestInfo>(){
			@Override
			public int compare(RequestInfo o1,RequestInfo o2){
				double diff = o1.getResponseTime()-o2.getResponseTime();
				if(diff < 0.0){
					return -1;
				}else if(diff >0.0){
					return 1;
				}else{
					return 0;
				}
			}
		});
		int idx999 = (int)(count * 0.999);
		int idx99 = (int)(count *0.99);
		if(count !=0){
			p999RespTime = requestInfos.get(idx999).getResponseTime();
			p99RespTime = requestInfos.get(idx99).getResponseTime();
		}
		RequestStat requestStat = new RequestStat();
		requestStat.setMaxResponseTime(maxRespTime);
		requestStat.setMinResponseTime(minRespTime);
		requestStat.setAvgResponseTime(avgRespTime);
		requestStat.setP999ResponseTime(p999RespTime);
		requestStat.setP99ResponseTime(p99RespTime);
		requestStat.setCount(count);
		requestStat.setTps(tps);
		return requestStat;
	}
}

public class RequestStat{
	private double maxResponseTime;
	private double minResponseTime;
	private double avgResponseTime;
	private double p999ResponseTime;
	private double p99ResponseTime;
	private long count;
	private long tps;
	//...省略getter/setter方法...
}

ConsoleReporter类相当于一个上帝类,定时根据给定的时间区间,从数据库中取出数据,借助Aggregator类完成统计公祖,并将统计结果输出到命令行。

public class ConsoleReporter{
	private MetricsStorage metricsStorage;
	private ScheduledExecutorService executor;

	public ConsoleReporter(MetricsStorage metricsStorage){
		this.metricsStorage = metricsStorage;
		this.executor = Executors.newSingleThreadScheduledExecutor();
	}

	//第4个代码逻辑:定时触发1,2,3代码逻辑的执行
	public void startRepeatedReport(long periodInSeconds,long durationInSeconds){
		executor.scheduleAtFixedRate(new Runnable(){
			@Override
			public void run(){
				//第1个代码逻辑:根据给定的时间区间,从数据库拉取数据
				long durationInMills = durationInSeconds*1000;
				long endTimeInMills = System.currentTimeMillis();
				long startTimeInMills = endTimeInMills-durationInMills;
				Map<String,List<RequestInfo>> requestInfos = 
					metricsStorage.getRequestInfos(startTimeInMills,endTimeInMills);
				Map<String, RequestStat> stats = new HashMap<>();
				for(Map.Entry<String,List<RequestInfo>> entry:requestInfos.entrySet()){
					String apiName = entry.getKey();
					List<RequestInfo> requestInfosPerApi = entry.getValue();
					//第2个代码逻辑:根据原始数据,计算得到统计数据
					RequestStat requestStat = Aggregator.aggregate(requestInfosPerApi,durationInMills);
					stats.put(apiName,requestStat);
				}
				//第3个代码逻辑:将统计数据显示到终端(命令行或邮件)
				System.out.println("Time Span: ["+startTimeInMills+", "+endTimeInMills);
				Gson gson = new Gson();
				System.out.println(gson.toJson(stats));
			}
		}, 0, periodInSeconds, TimeUnit.SECONDS);
	}
}


public class EmailReporter{
	private static final Long DAY_HOURS_IN_SECONDS = 86400L;

	private MetricsStorage metricsStorage;
	private EmailSender emailSender;
	private List<String> toAddresses = new ArrayList<>();

	public EmailReporter(MetricsStorage metricsStorage){
		this(metricsStorage, new EmailSender(//省略参数));
	}

	public EmailReporter(MetricsStorage metricsStorage,EmailSender emailSender){
		this.metricsStorage = metricsStorage;
		this.emailSender = emailSender;
	}

	public void addToAddress(String address){
		toAddresses.add(address);
	}

	public void startDailyReport(){
		Calendar calendar = Calendar.getInstance();
		calendar.add(Calendar.DATE,1);
		calendar.set(Calendar.HOUR_OF_DAY,0);
		calendar.set(Calendar.MINUTE,0);
		calendar.set(Calendar.SECOND,0);
		calendar.set(Calendar.MILLISECOND,0);
		Date firstTime = calendar.getTime();
		Timer timer = new Timer();
		timer.schedule(new TimerTask(){
			@Override
			public void run(){
				long durationInMillis = DAY_HOURS_IN_SECONDS*1000;
				long endTimeInMillis = System.currentTimeMillis();
				long startTimeInMillis = endTimeInMillis-durationInMillis;
				Map<String,List<RequestInfo>> requestInfos = metricsStorage.getRequestInfos(startTimeInMillis,endTimeInMillis);
				Map<String,RequestStat> stats = new HashMap<>();
				for(Map.Entry<String,List<RequestInfo>> entry:requestInfos.entrySet()){
					String apiName = entry.getKey();
					List<RequestInfo> requestInfosPerApi = entry.getValue();
					RequestStat requestStat = Aggregator.aggregate(requestInfosPerApi,durationInMillis);
					stats.put(apiName,requestStat);
				}
				//TODO 格式化为html格式,并发送邮件
			}
		}, firstTime,DAY_HOURS_IN_SECONDS*1000);
	}
}

3. 将类组装起来并提供执行入口

因为这个框架稍微特殊,有两个执行入口,一个是MetricsCollector类,提供了一组API采集原始数据;另一个是ConsoleReporter类和EmailReporter类,用来触发统计显示。具体的使用方式如下

public class Demo{
	public static void main(String[] args){
		MetricsStorage storage = new RedisMetricsStorage();
		ConsoleReporter consoleReporter = new ConsoleReporter(storage);
		consoleReporter.startRepeatedReport(60,60);

		EmailReporter emailReporter = new EmailReporter(storage);
		emailReporter.addToAddress("[email protected]");
		emailReporter.startDailyReport();

		MetricsCollector collector = new MetricsCollector(storage);
		collector.recordRequest(new RequestInfo("register",123,10234));
		collector.recordRequest(new RequestInfo("register",223,11234));
		collector.recordRequest(new RequestInfo("register",323,12234));
		collector.recordRequest(new RequestInfo("login",23,12434));
		collector.recordRequest(new RequestInfo("login",1123,14434));

		try{
			Thread.sleep(10000);
		}catch(InterruptedException e){
			e.printStackTrace();
		}
	}
}

4. review设计与实现

前面讲过SOLID KISS DRY YAGNI LOD等设计原则,基于接口而非实现编程、多用组合少用继承、高内聚低耦合等设计思想,上面的代码是否符合这些设计原则和思想。

  • MetricsCollector

MetricsCollector负责采集和存储数据,职责相对较为单一。基于接口而非实现编程,通过依赖注入的方式来传递MetricsStorage对象,可在不需要修改代码的情况下,灵活的替换不同的存储方式,符合开闭原则。

  • MetricsStorage RedisMetricsStorage

MetricsStorage RedisMetricsStorage的设计较为简单。需要实现新的存储方式的时候,只要实现MetricsStorage即可。

  • Aggregator

Aggregator类是一个工具类,只有一个静态方法,50行左右的代码,负责各种统计数据的计算,需要扩展新的统计功能时,需要修改aggregate()方法代码,可能存在职责不够单一、不易扩展的缺点,后续版本继续优化

  • ConsoleReporter EmailReporter

ConsoleReporter EmailReporter存在代码重复的问题,显示部分的代码较为复杂(如Email的展示部分),最好抽出来拆分为单独的类。此外,涉及到线程操作,并且调用Aggregator的静态方法,代码可测试性不好。

猜你喜欢

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