记录学习王争的设计模式之美 课程 笔记和练习代码,以便回顾复习,共同进步
id中文译为标识identifier,如身份证、商品条码、二维码、车牌号、驾照号等。软件开发中,id常用来表示一些业务信息的唯一标识,如订单的单号或数据库的唯一主键。
假设正在参与后端业务系统的开发,为方便在请求出错时排查问题,编写代码时会在关键路径打印日志。某个请求出错后,希望能搜索出这个请求对应的所有日志,以此查找问题原因。实际上,日志文件中,不同的请求的日志会交织在一起。如果没有东西来标识哪些日志属于同一个请求,就无法关联同一个请求的所有日志。
听起来像微服务的调用链追踪,不过,微服务的是服务间的追踪,我们实现的是服务内的追踪。借鉴微服务调用链追踪的实现思路,给每个请求分配一个唯一id,并且保存到请求的上下文context中,如处理请求的工作线程的局部变量中。在java中可将id存储到servlet线程的ThreadLocal中,或者利用slf4j日志框架的MDC(Mapped Diagnostic Contexts)来实现(底层也是基于线程的ThreadLocal)。每次打印日志,从请求上下文取出请求id,跟日志一块输出,这样每个请求的所有日志都包含同样的请求id信息了。
一个简单的id生成器:
public class IdGenerator {
private static final Logger logger = LoggerFactory.getLogger(IdGenerator.class);
public static String generate(){
String id = "";
try{
String hostName = InetAddress.getLocalHost().getHostName();
String[] tokens = hostName.split("\\.");
if (tokens.length > 0){
hostName = tokens[tokens.length - 1];
}
char[] randomChars = new char[8];
int count = 0;
Random random = new Random();
while (count < 8){
int randomAscii = random.nextInt(122);
if (randomAscii >= 48 && randomAscii <= 57){
randomChars[count] = (char)('0' + (randomAscii - 48));
count++;
}else if (randomAscii >=65 && randomAscii <= 90){
randomChars[count] = (cahr)('A' +(randomAscii - 65));
count++;
}else if (randomAscii >= 97 && randomAscii <= 122){
randomChars[count] = (char)('a' +(randomAscii - 97));
count++;
}
}
id = String.format("%s-%d-%s",hostName,System.currentTimeMillis(),new String(randomChars));
}catch (UnknownHostException e){
logger.warn("Failed to get the host name.",e);
}
return id;
}
}
整个id由三部分组成:
对于日志追踪来说,重复概率极低,可接受。
但是这样一份代码,只能说能用,有很多值得优化的地方,如何将60分及格代码优化到80、90分呢?
具体细节,可从以下几个方面审视代码:
除了上述的通用的关注点,针对业务本身特有的功能和非功能需求,还有些checklist,如下:
对照上面的checklist,看id生成器的代码有哪些问题。
首先,IdGenerator代码简单,只有一个类,不涉及到目录设置、模块划分、代码结构等问题,也不违反设计原则,没有用设计模式,不存在不合理使用和过度设计的问题。
其次,IdGenerator设计为实现类而非接口,调用者直接依赖实现而非接口,违反基于接口而非实现编程的设计思想。实际问题不大。但是,如果公司项目中需要同时存在两种ID生成算法,也就是同时存在两个实现类,如需要将这个框架给更多系统用。这时就要定义为接口了。
再次,将IdGenerator的generate()方法定义为静态方法,会影响使用该方法的代码的可测试性。同时,generate()方法的代码实现依赖运行环境(本机名)、时间函数、随机函数,generate()方法的本身可测试性也不好,要做较大的重构。此外,也没编写单元测试代码,要补充。
最后,虽然只包含一个方法,方法的代码行数也不多,但代码的可读性不好,特别是随机字符串生成的部分,一方面,代码没有注释,生成算法比较难读懂;另一个方面,代码有很多魔法数,影响代码的可读性。
再对照业务本身的功能和非功能需求,审视代码:
虽然生成的id并非绝对唯一,但对于追踪打印日志来说,可接受,满足预期的业务需求。不过,获取hostName部分代码逻辑有点问题,没有处理“hostName为空”的情况。此外,尽管对获取不到本机名做了异常处理,但对异常处理是在IdGenerator内部吞掉,打印一条报警日志,并没有继续向上抛出,是否得当?
该代码日志打印得当,日志描述准确,方便debug,只暴露一个generate()接口供使用者调用,不存在不宜用问题。方法的代码中并没有涉及到共享变量,代码线程安全,多线程下不存在并发问题。
性能方面,ID的生成不依赖外部存储,内存中生成,日志打印频率也并不高,性能可以应对目前的场景。不过每次生成id都要获取本机名,较为耗时。还有randomAscii的范围是0122,但可用数字只包含三段子区间(09,az,AZ),极端情况下孙吉生成很多三段区间之外的无效的数字,需要循环多次才能生成随机字符串,可优化。
具体的代码方面,在generate()方法的while循环里,三个if语句内的代码很相似,而且实现稍微复杂,可进一步简化,将三个if合并。
循环渐进、小步快跑。重构每次改动一点点。分为四次重构完成:
先解决最明显、最急需改进的代码可读性问题:
对于生成ID生成器的代码,有下面三种类的命名方式:
接口 | 实现类 | |
---|---|---|
命名方式1 | IdGenerator | LogTraceIdGenerator |
命名方式2 | LogTraceIdGenerator | HostNameMillisIdGenerator |
命名方式3 | LogTraceIdGenerator | RandomIdGenerator |
这三种命名方式:
第一种,最先想到,但如果考虑以后两个类的使用和扩展,就不合理了。
首先,如果扩展新的日志ID生成算法,也就是创建另一个新的实现类,原来的叫LogTraceIdGenerator,命名过于通用,新的实现类不好取名,无法取跟LogTraceIdGenerator平行的名字。
其次,假设没有日志ID扩展需求,但要扩展其他业务的ID生成算法,如UserIdGenerator、OrderIdGenerator,第一种名字也不合理。基于接口而非实现编程,主要目的是为了方便后续灵活的替换实现类。而LogTraceIdGenerator、UserIdGenerator、OrderIdGenerator三个类是完全不同的业务,不存在互相替换的场景。
第二种呢?也不合理。LogTraceIdGenerator合理,但HostNameMillisIdGenerator暴露了太多实现细节,只要代码稍微改动,就可能需要改名字,才能匹配实现。
第三种最推荐,目前的ID生成器代码实现上,生成的ID是一个随机ID,命名较为合理,如果之后要实现一个递增有序的ID生成算法,可命名为SequenceIdGenerator。
更好的命名是:抽象出两个接口,一个是IdGenerator,一个是LogTraceIdGenerator,LogTraceIdGenerator继承IdGenerator。实现类实现接口IdGenerator,命名为RandomIdGenerator、SequenceIdGenerator这样,实现类可复用到很多业务模块,如用户、订单。
重构后的代码:
public class RandomIdGenrator implements IdGenerator{
private static final Logger logger = LoggerFactory.getLogger(RandomIdGenrator.class);
@Override
public String generate() {
String substrOfHostName = getLastfiledOfHostName();
long currentTimeMillis = System.currentTimeMillis();
String randomString = generateRandomAlphameric(8);
String id = String.format("%s-%d-%s",substrOfHostName,currentTimeMillis,randomString);
return id;
}
private String getLastfiledOfHostName(){
String substrOfHostName = null;
try{
String hostName = InetAddress.getLocalHost().getHostName();
String[] tokens = hostName.split("\\.");
substrOfHostName = tokens[tokens.length - 1];
return substrOfHostName;
}catch (UnknownHostException e){
logger.warn("Failed to get the host name.",e);
}
return substrOfHostName;
}
private String generateRandomAlphameric(int length){
char[] randomChars = new char[length];
int count = 0;
Random random = new Random();
while (count < length){
int maxAscii = 'z';
int randomAscii = random.nextInt(maxAscii);
boolean isDigit = randomAscii >= '0' && randomAscii <= '9';
boolean isUppercase = randomAscii >= 'A' && randomAscii <='Z';
boolean isLowercase = randomAscii >= 'a' && randomAscii <='z';
if (isDigit || isUppercase || isLowercase){
randomChars[count] =(char)(randomAscii);
++count;
}
}
return new String(randomChars);
}
}
//代码使用举例
LogTraceIdGenrator logTraceIdGenrator = new RandomIdGenrator();
可测试性包含两个方面:
对第一点,在第一轮重构已解决。改为了普通方法。
对于第二点,需要再重构,主要包含几点改动:
@VisibleForTesting
,这个注解只是起到标识的作用,说明方法本该是private访问权限,提升到protected,只是为了测试,只能用于单元测试public class RandomIdGenrator implements IdGenerator{
private static final Logger logger = LoggerFactory.getLogger(RandomIdGenrator.class);
@Override
public String generate() {
String substrOfHostName = getLastfiledOfHostName();
long currentTimeMillis = System.currentTimeMillis();
String randomString = generateRandomAlphameric(8);
String id = String.format("%s-%d-%s",substrOfHostName,currentTimeMillis,randomString);
return id;
}
private String getLastfiledOfHostName(){
String substrOfHostName = null;
try{
String hostName = InetAddress.getLocalHost().getHostName();
substrOfHostName = getLastSubstrSplittedByDot(hostName);
}catch (UnknownHostException e){
logger.warn("Failed to get the host name.",e);
}
return substrOfHostName;
}
@VisibleForTesting
protected String getLastSubstrSplittedByDot(String hostName){
String[] tokens = hostName.split("\\.");
String substrOfHostName = tokens[tokens.length - 1];
return substrOfHostName;
}
@VisibleForTesting
protected String generateRandomAlphameric(int length){
//...
}
}
打印日志的Logger对象被定义为static final,并在类内部创建,是否影响代码的可测试性?是否应该将Logger对象通过依赖注入的方式注入到类中?
依赖注入之所以提高代码的可测试性,因为这样能通过mock对象替换依赖的真实对象。为什么要mock?因为这个对象参与逻辑执行,但又不可控。不过Logger对象我们只往里面写数据,并不读取数据,不参与业务逻辑的执行,没必要mock Logger对象。
代码存在的明显问题已经解决,为代码补全单元测试。RandomIdGenerator类有4个方法:
public String generate();
private String getLastfiledOfHostName();
@VisibleForTesting
protected String getLastSubstrSplittedByDot(String hostName);
@VisibleForTesting
protected String generateRandomAlphameric(int length)
先看后两个方法,逻辑较复杂,是测试的重点。已经将它们跟不可控的组件(本机名、随机函数、时间函数)进行了隔离,只需要设计测试用例即可。
public class RandomIdGeneratorTest {
@Test
public void testGetLastSubstrSplittedByDot(){
RandomIdGenrator idGenrator = new RandomIdGenrator();
String actualSubstr = idGenrator.getLastSubstrSplittedByDot("field1.field2.field3");
Assert.assertEquals("field3",actualSubstr);
actualSubstr = idGenrator.getLastSubstrSplittedByDot("field1");
Assert.assertEquals("field1",actualSubstr);
actualSubstr = idGenrator.getLastSubstrSplittedByDot("field1#field2#field3");
Assert.assertEquals("field1#field2#field3",actualSubstr);
}
//此单元测试会失败,因为在代码中没有处理hostName为null或空字符串的情况
//之后优化
@Test
public void testGetLastSubstrSplittedByDot_nullOrEmpty(){
RandomIdGenrator idGenrator = new RandomIdGenrator();
String actualSubstr = idGenrator.getLastSubstrSplittedByDot(null);
Assert.assertNull(actualSubstr);
actualSubstr = idGenrator.getLastSubstrSplittedByDot("");
Assert.assertEquals("",actualSubstr);
}
@Test
public void testGenerateRandomAlphameric(){
RandomIdGenrator idGenrator = new RandomIdGenrator();
String actualRandomString = idGenrator.generateRandomAlphameric(6);
Assert.assertNotNull(actualRandomString);
Assert.assertEquals(6,actualRandomString.length());
for (char c: actualRandomString.toCharArray()){
Assert.assertTrue('0' <= c && c <= '9') || ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z'));
}
}
//此单元测试会失败,因为在代码中没有处理length<=0的情况
//之后优化
@Test
public void testGenerateRandomAlphameric_lengthEqualsOrLessThanZero(){
RandomIdGenrator idGenrator = new RandomIdGenrator();
String actualRandomString = idGenrator.generateRandomAlphameric(0);
Assert.assertEquals("",actualRandomString);
actualRandomString = idGenrator.generateRandomAlphameric(-1);
Assert.assertNull(actualRandomString);
}
}
再看generate()方法,这个方法是唯一暴露给外部使用的public方法,它依赖主机名、随机函数、时间函数,如何测试?
要分情况看,单元测试,测试对象是方法定义的功能,而非具体的实现逻辑。那generate()的功能是什么呢?
针对同一份generate()方法的代码实现,有三种不同的功能定义,对应三种不同的单元测试
最后看getLastfiledOfHostName()方法,这个方法不容易测试,因为调用静态方法,且这个静态方法依赖运行环境。但这个方法的实现很简单,肉眼可以排除明显的bug。毕竟不是为了写单元测试而写单元测试。
注释需要写:做什么、为什么、怎么做、怎么用,对一些边界条件、特殊情况进行说明,以及对方法输入、输出、异常进行说明
/**
* Id Generator that is used to generate random IDs.
*
*
* The IDs generated by this class are not absolutely unique,
* but the probability of duplication is very low.
*/
public class RandomIdGenerator implements LogTraceIdGenerator {
private static final Logger logger = LoggerFactory.getLogger(RandomIdGenerator.class);
/**
* Generate the random ID. The IDs may be duplicated only in extreme situation.
*
* @return an random ID
*/
@Override
public String generate() {
//...
}
/**
* Get the local hostname and
* extract the last field of the name string splitted by delimiter '.'.
*
* @return the last field of hostname. Returns null if hostname is not obtained.
*/
private String getLastfieldOfHostName() {
//...
}
/**
* Get the last field of {@hostName} splitted by delemiter '.'.
*
* @param hostName should not be null
* @return the last field of {@hostName}. Returns empty string if {@hostName} is empty string.
*/
@VisibleForTesting
protected String getLastSubstrSplittedByDot(String hostName) {
//...
}
/**
* Generate random string which
* only contains digits, uppercase letters and lowercase letters.
*
* @param length should not be less than 0
* @return the random string. Returns empty string if {@length} is 0
*/
@VisibleForTesting
protected String generateRandomAlphameric(int length) {
//...
}
}
程序的bug往往出现在一些边界条件和异常情况下,异常处理的好坏直接影响代码的健壮性。全面、合理的处理各种异常能有效减少代码bug,保证代码的质量。
返回数据类型,有4种情况
Collections.emptyList()
替代。3种:
首先看:对generate()方法,如果本机名获取失败,方法该返回什么?这样的返回值是否合理?
id是由三部分组成,时间戳和随机数的生成函数不会出错,只有主机名可能获取失败。目前的代码实现上,主机名获取失败,substrOfHostName为null,generate()方法返回类似"null-16723733647-83332ua1"这样的数据,如果主机名获取失败,substrOfHostName为空字符串,返回类似"-16723733647-83332ua1"这样的数据。
这样是否合理呢?要看具体业务需求,更倾向于明确将异常告知调用者,最好抛出受检异常,而非特殊值。
重构后:
public String generate() throws IdGenerationFailureException{
String substrOfHostName = getLastfiledOfHostName();
if (substrOfHostName == null || substrOfHostName.isEmpty()){
throw new IdGenerationFailureException("host name is empty");
}
long currentTimeMillis = System.currentTimeMillis();
String randomString = generateRandomAlphameric(8);
String id = String.format("%s-%d-%s",substrOfHostName,currentTimeMillis,randomString);
return id;
}
对getLastFiledOfHostName()方法,是否该将UnknownHostException异常内部吞掉(try-catch并打印日志)?还是将异常继续往上抛?往上抛出的话,将该异常原封不动的抛出,还是封装为新的异常抛出?
目前的处理是返回null值,获取主机名失败会影响后续逻辑的处理,不是期望的,是异常行为,最好抛出异常,而非返回null值。
直接抛还是封装后抛出,要看方法跟异常是否有业务相关性。该方法获取主机名的最后一个字段,UnknownHostException异常标识主机名获取失败,算是业务相关,可以直接将UnknownHostException抛出,不需要重新包裹
重构后:
private String getLastfiledOfHostName() throws UnknownHostException{
String substrOfHostName = null;
String hostName = InetAddress.getLocalHost().getHostName();
substrOfHostName = getLastSubstrSplittedByDot(hostName);
return substrOfHostName;
}
getLastfiledOfHostName()方法修改后,generate()方法也要做对应的修改,捕获UnknownHostException异常,捕获后怎么处理呢?
按之前分析,id生成失败后,要明确告知调用者,不能在generate()方法中,将UnknownHostException异常吞掉,是否要封装为新异常抛出呢?
要封装为IdGenerateFailureException往上抛出。调用者不care底层的细节。跟上层的业务也无关。
对generate()方法再次重构:
public String generate() throws IdGenerationFailureException{
String substrOfHostName = null;
try {
substrOfHostName = getLastfiledOfHostName();
} catch (UnknownHostException e) {
throw new IdGenerationFailureException("host name is empty.");
}
long currentTimeMillis = System.currentTimeMillis();
String randomString = generateRandomAlphameric(8);
String id = String.format("%s-%d-%s",substrOfHostName,currentTimeMillis,randomString);
return id;
}
对于getLastSubstrSplittedByDot(String hostName)方法,如果hostName为null或者空字符串,应该返回什么?
理论上说,参数传递的正确性应该有程序员保证,无需做null值或者空字符串的判断和特殊处理。调用者不该把null值或空字符串传给getLastSubstrSplittedByDot(),如果传递了就是code bug,需要修复,但万一传了这种,是否做判断呢?
如果方法是private,只在类内部调用,自己保证不会传null值或空字符串即可,不用判断。但如果是public,为保证代码的健壮性,还是做判断。因此,最好加上校验。
对getLastSubstrSplittedByDot(String hostName)方法重构:
@VisibleForTesting
protected String getLastSubstrSplittedByDot(String hostName){
if (hostName == null || hostName.isEmpty()){
throw IllegalArgumentException("...");//运行时异常
}
String[] tokens = hostName.split("\\.");
String substrOfHostName = tokens[tokens.length - 1];
return substrOfHostName;
}
使用这个方法时,也要保证不传递null值或空字符串,getLastFieldOfHostName()方法也要修改代码:
private String getLastfiledOfHostName() throws UnknownHostException{
String substrOfHostName = null;
String hostName = InetAddress.getLocalHost().getHostName();
if (hostName==null || hostName.isEmpty()){//此处做判断
throw new UnknownHostException("...");
}
substrOfHostName = getLastSubstrSplittedByDot(hostName);
return substrOfHostName;
}
对generateRandomAlphameric(int length)方法,如果length小于0或者等于0,方法该返回什么?
先看length<0,这种不符合常规逻辑,是异常行为,抛出IllegalArgumentException异常。
再看length=0的情况,是否为异常?看自己定义。可以定义为异常,抛出IllegalArgumentException异常,可以定义为一种正常行为,让方法在入参length=0时,直接返回空字符串。关键是要在方法注释中,明确告知length=0时,返回什么样的数据。
整个框架的代码分为下面几个类:
先看Aggregator类存在的问题
Aggregator类只有一个静态方法,负责各种统计数据的计算,当要添加新的功能时,需要修改aggregate()方法的代码。统计功能增加后,代码量持续增加,可读性、可维护性变差。需要重构
再看ConsoleReporter和EmailReporter存在的问题
存在代码重复问题,两个类从数据库中取数据、做统计的逻辑相同,可抽取出来复用。否则违反DRY原则。
整个类负责的事情较多,不相关的逻辑杂糅在一起,职责不够单一,特别是显示部分的代码可能较为复杂(如email的显示方式),最好能将这部分显示逻辑剥离出来,为独立的类。
此外,涉及到线程操作,调用Aggregator的静态方法,代码的可测试性有待提高。
Aggregator类和ConsoleReporter、EmailReporter类主要负责统计显示的工作。如果把统计显示要完成的功能逻辑细分,包含4点:
之前的划分方法是将所有的逻辑都放到ConsoleReporter、EmailReporter这两个上帝类中,而Aggregator只是个包含静态方法的工具类。划分存在前面提到的问题。
面向对象设计的最后一步是组装类并提供执行入口,所以,组装前三部分逻辑的上帝类是必须有的。可以将上帝类做的很轻量级。将核心逻辑剥离出来,形成独立的类,上帝类只负责组装类和串联执行流程。这样,代码结构更清晰,底层更易被复用。具体重构包含4个方面:
具体代码:
public class Aggregator {
public Map<String, RequestStat> aggregate(Map<String,List<RequestInfo>> requestInfos, long durationInMills){
Map<String,RequestStat> requestStats = new HashMap<>();
for (Map.Entry<String,List<RequestInfo>> entry:requestInfos.entrySet()){
String apiName = entry.getKey();
List<RequestInfo> requestInfosPerApi = entry.getValue();
RequestStat requestStat = doAggregate(requestInfosPerApi,durationInMills);
requestStats.put(apiName,requestStat);
}
return requestStats;
}
private RequestStat doAggregate(List<RequestInfo> requestInfos,long durationInMillis){
List<Double> respTimes = new ArrayList<>();
for (RequestInfo requestInfo:requestInfos){
double respTime = requestInfo.getResponseTime();
respTimes.add(respTime);
}
RequestStat requestStat = new RequestStat();
requestStat.setMaxResponseTime(max(respTimes));
requestStat.setMinResponseTime(min(respTimes));
requestStat.setAvgResponseTime(avg(respTimes));
requestStat.setP999ResponseTime(percentile999(respTimes));
requestStat.setP99ResponseTime(percentile99(respTimes));
requestStat.setCount(respTimes.size());
requestStat.setTps((long)tps(respTimes.size(),durationInMillis/1000));
return requestStat;
}
//以下的代码的实现暂时忽略
private double max(List<Double> dataset){
return 0.0;
}
private double min(List<Double> dataset){
return 0.0;
}
private double avg(List<Double> dataset){
return 0.0;
}
private double percentile999(List<Double> dataset){
return 0.0;
}
private double percentile99(List<Double> dataset){
return 0.0;
}
private double percentile(List<Double> dataset,double ratio){
return 0.0;
}
private double tps(int count,double duration){
return 0.0;
}
}
public interface StatViewer {
void output(Map<String, RequestStat> requestStats, long startTimeInMillis, long endTimeInMillis);
}
public class ConsoleViewer implements StatViewer {
@Override
public void output(Map<String, RequestStat> requestStats, long startTimeInMillis, long endTimeInMillis) {
System.out.println("Time Span: ["+startTimeInMillis+", "+endTimeInMillis);
Gson gson = new Gson();
System.out.println(gson.toJson(requestStats));
}
}
public class EmailViewer implements StatViewer {
private EmailSender emailSender;
private List<String> toAddresses = new ArrayList<>();
public EmailViewer(){
this.emailSender = new EmailSender();
}
public EmailViewer(EmailSender emailSender){
this.emailSender = emailSender;
}
public void addToAddress(String address){
toAddresses.add(address);
}
@Override
public void output(Map<String, RequestStat> requestStats, long startTimeInMillis, long endTimeInMillis) {
//format the requestStats to HTML style
// send it to email toAddresses
}
}
public class ConsoleReporter {
private MetricsStorage metricsStorage;
private ScheduledExecutorService executor;
private Aggregator aggregator;
private StatViewer viewer;
public ConsoleReporter(MetricsStorage metricsStorage, Aggregator aggregator, StatViewer viewer) {
this.metricsStorage = metricsStorage;
this.executor = Executors.newSingleThreadScheduledExecutor();
this.aggregator = aggregator;
this.viewer = viewer;
}
public void startRepeatedReport(long periodInSeconds, final long durationInSeconds) {
executor.scheduleAtFixedRate(new Runnable() {
@Override
public void run() {
long durationInMills = durationInSeconds * 1000;
long endTimeInMills = System.currentTimeMillis();
long startTimeInMills = endTimeInMills - durationInMills;
Map<String, List<RequestInfo>> requestInfos =
metricsStorage.getRequestInfos(startTimeInMills, endTimeInMills);
Map<String, RequestStat> requestStats = aggregator.aggregate(requestInfos,durationInMills);
viewer.output(requestStats, startTimeInMills, endTimeInMills);
}
}, 0L, periodInSeconds, TimeUnit.SECONDS);
}
}
public class EmailReporter {
private static final Long DAY_HOURS_IN_SECONDS = 86400L;
private MetricsStorage metricsStorage;
private Aggregator aggregator;
private StatViewer viewer;
public EmailReporter(MetricsStorage metricsStorage, Aggregator aggregator, StatViewer viewer) {
this.metricsStorage = metricsStorage;
this.aggregator = aggregator;
this.viewer = viewer;
}
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 = aggregator.aggregate(requestInfos, durationInMillis);
viewer.output(stats, startTimeInMillis, endTimeInMillis);
}
}, firstTime, DAY_HOURS_IN_SECONDS * 1000);
}
}
重构后,框架的使用:
在应用启动时,创建好ConsoleReporter对象,调用它的startRepeatedReporter()方法,启动定时统计并输出数据到终端,同样,创建EmailReporter对象,调用startDailyReport()方法,启动每日统计并输出数据到指定邮件地址。通过MetricsCollector类收集接口的访问情况,收集代码跟业务代码耦合在一起,或者统一放到类似spring aop的切面中完成。
public class PerfCounterTest {
public static void main(String[] args) {
MetricsStorage storage = new RedisMetricsStorage();
Aggregator aggregator = new Aggregator();
//定时触发统计并将结果显示到终端
ConsoleViewer consoleViewer = new ConsoleViewer();
ConsoleReporter consoleReporter = new ConsoleReporter(storage, aggregator, consoleViewer);
consoleReporter.startRepeatedReport(60, 60);
//定时触发统计并将结果输出到邮件
EmailViewer emailViewer = new EmailViewer();
emailViewer.addToAddress("[email protected]");
EmailReporter emailReporter = new EmailReporter(storage, aggregator, emailViewer);
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("login", 23, 12234));
collector.recordRequest(new RequestInfo("login", 1223, 14234));
try {
Thread.sleep(10000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
重构后,MetricsStorage负责存储,Aggregator负责统计,StatViewer(ConsoleViewer EmailViewer)负责显示,三个类各司其职。ConsoleReporter和EmailReporter负责组装三个类,将获取原始数据、聚合统计、显示统计结果到终端的工作串联起来,定时触发执行。
此外,MetricsStorage Aggregator StatViewer三个类的设计符合迪米特法则。只跟自己有直接关系的数据交互。MetricsStorage输出的是RequestInfo相关数据。Aggregator输入的是RequestInfo数据,输出RequestStat数据。StatViewer输入的是RequestStat数据。
上图为代码的整体结构和依赖关系。再看具体每个类的设计。
Aggregator类从一个只包含一个静态方法的工具类,变为一个普通的聚合统计类。通过依赖注入方式,将其组装进ConsoleReporter和EmailReporter类,更容易编写单元测试。
Aggregator类的设计目前还算较为合理,如果统计功能越来越多,可以将统计方法剥离出来,设计为独立的类,解决该类无限膨胀问题。
ConsoleReporter和EmailReporter重构后,代码重复问题变小,但仍没有完美解决。涉及到多线程和时间相关的计算,代码的测试性不够好。
ConsoleReporter和EmailReporter仍存在代码重复问题,可测试性差的问题。此外,也要继续完善框架的功能和非功能需求。如,让原始数据的采集和存储异步执行,解决聚合统计在数据量大的情况下导致内存吃紧的问题,以及提高框架的易用性。
可将ConsoleReporter和EmailReporter中的相同代码逻辑,提取到父类ScheduledReporter中,解决代码重复问题。
public abstract class ScheduledReporter {
protected MetricsStorage metricsStorage;
protected Aggregator aggregator;
protected StatViewer viewer;
public ScheduledReporter(MetricsStorage metricsStorage, Aggregator aggregator, StatViewer viewer) {
this.metricsStorage = metricsStorage;
this.aggregator = aggregator;
this.viewer = viewer;
}
protected void doStatAndReporter(long startTimeInMillis,long endTimeInMillis){
long durationInMillis = endTimeInMillis - startTimeInMillis;
Map<String, List<RequestInfo>> requestInfos = metricsStorage.getRequestInfos(startTimeInMillis, endTimeInMillis);
Map<String, RequestStat> requestStats = aggregator.aggregate(requestInfos,durationInMillis);
viewer.output(requestStats, startTimeInMillis, endTimeInMillis);
}
}
再看代码的可测试性问题,以EmailReporter为例。抽取重复代码后,该类的代码为:
public class EmailReporter extends ScheduledReporter {
private static final Long DAY_HOURS_IN_SECONDS = 86400L;
private MetricsStorage metricsStorage;
private Aggregator aggregator;
private StatViewer viewer;
public EmailReporter(MetricsStorage metricsStorage, Aggregator aggregator, StatViewer viewer) {
super(metricsStorage, aggregator, viewer);
}
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;
doStatAndReporter(startTimeInMillis,endTimeInMillis);
}
}, firstTime, DAY_HOURS_IN_SECONDS * 1000);
}
}
经过重构,EmailReporter的startDailyReport()方法的核心逻辑已经被抽离出来,较复杂的、易出bug的只剩下firstTime的部分代码,可将该部分代码继续抽离,封装为一个方法,再针对该方法写单元测试
public void startDailyReport() {
Date firstTime = trimTimeFieldsToZeroOfNextDay();
Timer timer = new Timer();
timer.schedule(new TimerTask() {
@Override
public void run() {
//...
}
}, firstTime, DAY_HOURS_IN_SECONDS * 1000);
}
@VisibleForTesting
protected Date trimTimeFieldsToZeroOfNextDay(){
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);
return calendar.getTime();
}
代码抽离后更清晰,但可测试性依旧不好,强依赖当前的系统时间。这个问题很普遍,一般解决方案是,将强依赖部分通过参数传递进来
public void startDailyReport() {
//new Date()获取当前时间
Date firstTime = trimTimeFieldsToZeroOfNextDay(new Date());
Timer timer = new Timer();
timer.schedule(new TimerTask() {
@Override
public void run() {
//...
}
}, firstTime, DAY_HOURS_IN_SECONDS * 1000);
}
@VisibleForTesting
protected Date trimTimeFieldsToZeroOfNextDay(Date date){
Calendar calendar = Calendar.getInstance();//这里可以获取当前时间
calendar.setTime(date);//重新设置时间
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);
return calendar.getTime();
}
重构后,比较容易编写单元测试了。
不过,EmailReporter类的startDailyReport()还是涉及多线程,如何单元测试呢?多次重构后,该方法里已经没有多少代码逻辑了,没必要对其写单元测试。不需要为了提高单元测试覆盖率而写单元测试。代码足够简单。
已初步实现功能了
需要考虑非功能性需求:易用性、性能、扩展性、容错性、通用性
就是框架是否好用。从PerfCounterTest看,框架使用较为复杂,需要组装各种类,如创建MetricsStorage对象、Aggregator对象、ConsoleViewer对象,然后注入到ConsoleReporter中,才能用ConsoleReporter。此外,还可能误用,如把EmailViewer传递给ConsoleReporter。总体说,暴露太多细节给用户,过于灵活的同时降低了易用性。
为了让框架更简单,又不失灵活性(可自由组装不同的MetricsStorage实现类、StatViewer实现类到ConsoleReporter货EmailReporter),也不降低代码的可测试性(通过依赖注入来组装类,方便在单元测试中mock),可额外的提供一些封装了默认依赖的构造函数,让使用者自主选择使用哪种构造方法来构造对象。重构后:
public class MetricsCollector {
private MetricsStorage metricsStorage;
//兼顾代码的易用性,新增一个封装了默认依赖的构造函数
public MetricsCollector(){
this(new RedisMetricsStorage());
}
public MetricsCollector(MetricsStorage metricsStorage){
this.metricsStorage = metricsStorage;
}
//省略其他代码...
}
public class ConsoleReporter extends ScheduledReporter {
private ScheduledExecutorService executor;
//兼顾代码的易用性,新增一个封装了默认依赖的构造方法
public ConsoleReporter(){
this(new RedisMetricsStorage(),new Aggregator(),new ConsoleViewer());
}
//兼顾灵活性和代码的可测试性,构造方法继续保留
public ConsoleReporter(MetricsStorage metricsStorage, Aggregator aggregator, StatViewer viewer) {
super(metricsStorage, aggregator, viewer);
this.executor = Executors.newSingleThreadScheduledExecutor();
}
//省略其他代码...
}
public class EmailReporter extends ScheduledReporter {
private static final Long DAY_HOURS_IN_SECONDS = 86400L;
private MetricsStorage metricsStorage;
private Aggregator aggregator;
private StatViewer viewer;
//兼顾代码的易用性,新增一个封装了默认依赖的构造方法
public EmailReporter(List<String> emailToAddresses){
this(new RedisMetricsStorage(),new Aggregator(),new EmailViewer(emailToAddresses));
}
//兼顾灵活性和代码的可测试性,这个构造方法继续保留
public EmailReporter(MetricsStorage metricsStorage, Aggregator aggregator, StatViewer viewer) {
super(metricsStorage, aggregator, viewer);
}
//省略其他代码...
}
再看框架如何使用:
public class PerfCounterTest {
public static void main(String[] args) {
ConsoleReporter consoleReporter = new ConsoleReporter();
consoleReporter.startRepeatedReport(60,60);
List<String> emailToAddresses = new ArrayList<>();
emailToAddresses.add("[email protected]");
com.ai.doc.chonggou1.metricsv2.EmailReporter emailReporter = new EmailReporter(emailToAddresses);
emailReporter.startDailyReport();
MetricsCollector collector = new MetricsCollector();
collector.recordRequest(new RequestInfo("register", 123, 10234));
collector.recordRequest(new RequestInfo("register", 223, 11234));
collector.recordRequest(new RequestInfo("login", 23, 12234));
collector.recordRequest(new RequestInfo("login", 1223, 14234));
try {
Thread.sleep(10000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
当然,RedisMetricsStorage和EmailViewer还需要另外一些配置信息才能构建成功,如Redis的地址,Email邮箱的pop3服务器地址、发送地址。这些配置信息如何获取?
可将配置信息放到配置文件中,在框架启动时,读取配置文件的配置信息到Configuration单例类。RedisMetricsStorage类和EmailViewer类都可从Configuration中获取需要的配置信息构建自己。
对于需要集成到业务系统的框架来说,不希望框架本身代码的执行效率,对业务系统有太多性能的影响。对性能计数器这个框架来说,一方面希望是低延迟,也就是说,统计代码不影响或很少影响接口本身的响应时间;另一方面,希望框架本身对内存的消耗不能太大。
在具体的代码层面,需要解决两个问题,一个是采集和存储要异步执行,因为存储基于外部存储如redis,会比较慢,异步存储可降低对接口响应时间的影响。另一个是当需要聚合统计的数据量较大时,一次性加载太多的数据到内存,可能导致内存吃紧,甚至内存溢出。
针对第一个问题,通过在MetricsCollector中引入Google guava eventBus来解决。实际上,可把EventBus看做“生产者-消费者”模型或“发布-订阅”模型,采集的数据先放入内存共享队列,另一个线程读取共享队列的数据,写入到外部存储如redis。具体的代码实现:
public class MetricsCollector {
private static final int DEFAULT_STORAGE_THREAD_POOL_SIZE = 20;
private MetricsStorage metricsStorage;
private EventBus eventBus;
//兼顾代码的易用性,新增一个封装了默认依赖的构造函数
public MetricsCollector(){
this(new RedisMetricsStorage());
}
public MetricsCollector(MetricsStorage metricsStorage){
this(metricsStorage,DEFAULT_STORAGE_THREAD_POOL_SIZE);
}
public MetricsCollector(MetricsStorage metricsStorage,int thredNumToSaveData){
this.metricsStorage = metricsStorage;
this.eventBus = new AsyncEventBus(Executors.newFixedThreadPool(thredNumToSaveData));
this.eventBus.register(new EventListener() {
});
}
//用一个方法代替最小原型中的两个方法
public void recordRequest(RequestInfo requestInfo){
if(requestInfo==null || StringUtils.isBlank(requestInfo.getApiName())){
return;
}
eventBus.post(requestInfo);
}
public class EventListener {
@Subscribe
public void saveRequestInfo(RequestInfo requestInfo){
metricsStorage.saveRequestInfo(requestInfo);
}}
}
针对第二个问题,解决的思路较简单,但代码实现稍微复杂。统计的时间间隔较大时,需要统计的数据量较大。可将其划分为一些小的时间区间(如10分钟作为一个统计单元)。针对每个小的时间区间分别统计,然后将统计得到的结果再进行聚合,得到整个时间区间的统计结果。不过,这个思路只适合响应时间的max、min、avg,以及接口请求count、tps的统计,对于响应时间的percentile的统计并不适用。
对percentile的统计稍微复杂,具体的解决思路:分批从redis中读取数据,然后存储到文件,再根据响应时间从小到大利用外部排序算法进行排序。完成后, 再从文件读取第count*percentile个数据,就是对应的percentile响应时间。
暂时只给出除了percentile外的统计信息的计算代码。
public abstract class ScheduledReporter {
private static final long MAX_STAT_DURATION_IN_MILLIS = 10*60*1000;//10 MIN
protected MetricsStorage metricsStorage;
protected Aggregator aggregator;
protected StatViewer viewer;
public ScheduledReporter(MetricsStorage metricsStorage, Aggregator aggregator, StatViewer viewer) {
this.metricsStorage = metricsStorage;
this.aggregator = aggregator;
this.viewer = viewer;
}
protected void doStatAndReporter(long startTimeInMillis,long endTimeInMillis){
Map<String,RequestStat> stats = doStat(startTimeInMillis,endTimeInMillis);
long durationInMillis = endTimeInMillis - startTimeInMillis;
Map<String, List<RequestInfo>> requestInfos = metricsStorage.getRequestInfos(startTimeInMillis, endTimeInMillis);
Map<String, RequestStat> requestStats = aggregator.aggregate(requestInfos,durationInMillis);
viewer.output(requestStats, startTimeInMillis, endTimeInMillis);
}
private Map<String, RequestStat> doStat(long startTimeInMillis, long endTimeInMillis) {
Map<String,List<RequestStat>> segmentStats = new HashMap<>();
long segmentStartTimeInMillis = startTimeInMillis;
while (segmentStartTimeInMillis < endTimeInMillis){
long segmentEndTimeInMillis = segmentStartTimeInMillis + MAX_STAT_DURATION_IN_MILLIS;
if (segmentEndTimeInMillis > endTimeInMillis){
segmentEndTimeInMillis = endTimeInMillis;
}
Map<String,List<RequestInfo>> requestInfos = metricsStorage.getRequestInfos(segmentStartTimeInMillis,segmentEndTimeInMillis);
if (requestInfos == null || requestInfos.isEmpty()){
continue;
}
Map<String,RequestStat> segmentStat = aggregator.aggregate(requestInfos,segmentEndTimeInMillis-segmentStartTimeInMillis);
addStat(segmentStats,segmentStat);
segmentStartTimeInMillis += MAX_STAT_DURATION_IN_MILLIS;
}
long durationInMillis = endTimeInMillis - startTimeInMillis;
Map<String,RequestStat> aggregatedStats = aggregateStat(segmentStats,durationInMillis);
return aggregatedStats;
}
private Map<String, RequestStat> aggregateStat(Map<String, List<RequestStat>> segmentStats, long durationInMillis) {
Map<String,RequestStat> aggregatedStats = new HashMap<>();
for (Map.Entry<String,List<RequestStat>> entry:segmentStats.entrySet()){
String apiName = entry.getKey();
List<RequestStat> apiStats = entry.getValue();
double maxRespTime = Double.MAX_VALUE;
double minRespTime = Double.MIN_VALUE;
long count = 0;
double sumRespTime = 0;
for (RequestStat stat:apiStats){
if (stat.getMaxResponseTime() > maxRespTime) maxRespTime = stat.getMaxResponseTime();
if (stat.getMinResponseTime() < minRespTime) minRespTime = stat.getMinResponseTime();
count += stat.getCount();
sumRespTime +=(stat.getCount() * stat.getAvgResponseTime());
}
RequestStat aggregatedStat = new RequestStat();
aggregatedStat.setMaxResponseTime(maxRespTime);
aggregatedStat.setMinResponseTime(minRespTime);
aggregatedStat.setAvgResponseTime(sumRespTime/count);
aggregatedStat.setTps(count/durationInMillis*1000);
aggregatedStats.put(apiName,aggregatedStat);
}
return aggregatedStats;
}
private void addStat(Map<String, List<RequestStat>> segmentStats, Map<String, RequestStat> segmentStat) {
for (Map.Entry<String,RequestStat> entry:segmentStat.entrySet()){
String apiName = entry.getKey();
RequestStat stat = entry.getValue();
List<RequestStat> statList = segmentStats.putIfAbsent(apiName,new ArrayList<RequestStat>());
statList.add(stat);
}
}
}
框架的扩展性有别于代码的扩展性。是从使用者的角度讲的。特指使用者在不修改框架源码,甚至不拿到框架源码的情况下,为框架扩展新的功能。
框架在兼顾易用性的同时,也可以灵活的替换各种类对象。如MetricsStorage、StatViewer。例如,我们让框架基于HBase存储原始数据,而非日的是,只需要设计一个实现MetricsStorage接口的HBaseMetricsStorage类,传递给MetricsCollector和ConsoleReporter、EmailReporter即可。
对框架来说,不能因为框架本身的异常导致接口请求出错,对于框架可能存在的各种异常,要考虑全面。
在现在的框架设计和实现中,采集和存储是异步执行的,即使redis挂掉,不影响接口的正常响应。此外,redis异常,可能影响数据统计显示(也就是ConsoleReporter、EmailReporter负责的工作),但不影响接口的正常响应。
为提高框架的复用性,能灵活应用于各种场景,框架设计时,尽可能通用。多思考,除了接口统计的需求,这个框架还可应用哪些场景。如是否可处理其他事件的统计信息,如SQL请求时间的统计、业务统计(如支付成功率)等。目前版本3暂时没考虑。