极客时间已完结课程限时免费阅读

25 | 实战二(上):针对非业务的通用框架开发,如何做需求分析和设计?

25 | 实战二(上):针对非业务的通用框架开发,如何做需求分析和设计?-极客时间

25 | 实战二(上):针对非业务的通用框架开发,如何做需求分析和设计?

讲述:冯永吉

时长11:58大小10.96M

上两节课中,我们讲了如何针对一个业务系统做需求分析、设计和实现,并且通过一个积分兑换系统的开发,实践了之前学过的一些设计原则。接下来的两节课,我们再结合一个支持各种统计规则的性能计数器项目,学习针对一个非业务的通用框架开发,如何来做需求分析、设计和实现,同时学习如何灵活应用各种设计原则。
话不多说,让我们正式开始今天的内容吧!

项目背景

我们希望设计开发一个小的框架,能够获取接口调用的各种统计信息,比如,响应时间的最大值(max)、最小值(min)、平均值(avg)、百分位值(percentile)、接口调用次数(count)、频率(tps) 等,并且支持将统计结果以各种显示格式(比如:JSON 格式、网页格式、自定义显示格式等)输出到各种终端(Console 命令行、HTTP 网页、Email、日志文件、自定义输出终端等),以方便查看。
我们假设这是真实项目中的一个开发需求,如果让你来负责开发这样一个通用的框架,应用到各种业务系统中,支持实时计算、查看数据的统计信息,你会如何设计和实现呢?你可以先自己主动思考一下,然后再来看我的分析思路。

需求分析

性能计数器作为一个跟业务无关的功能,我们完全可以把它开发成一个独立的框架或者类库,集成到很多业务系统中。而作为可被复用的框架,除了功能性需求之外,非功能性需求也非常重要。所以,接下来,我们从这两个方面来做需求分析。

1. 功能性需求分析

相对于一大长串的文字描述,人脑更容易理解短的、罗列的比较规整、分门别类的列表信息。显然,刚才那段需求描述不符合这个规律。我们需要把它拆解成一个一个的“干条条”。拆解之后我写在下面了,是不是看起来更加清晰、有条理?
接口统计信息:包括接口响应时间的统计信息,以及接口调用次数的统计信息等。
统计信息的类型:max、min、avg、percentile、count、tps 等。
统计信息显示格式:Json、Html、自定义显示格式。
统计信息显示终端:Console、Email、HTTP 网页、日志、自定义显示终端。
除此之外,我们还可以借助设计产品的时候,经常用到的线框图,把最终数据的显示样式画出来,会更加一目了然。具体的线框图如下所示:
实际上,从线框图中,我们还能挖掘出了下面几个隐藏的需求。
统计触发方式:包括主动和被动两种。主动表示以一定的频率定时统计数据,并主动推送到显示终端,比如邮件推送。被动表示用户触发统计,比如用户在网页中选择要统计的时间区间,触发统计,并将结果显示给用户。
统计时间区间:框架需要支持自定义统计时间区间,比如统计最近 10 分钟的某接口的 tps、访问次数,或者统计 12 月 11 日 00 点到 12 月 12 日 00 点之间某接口响应时间的最大值、最小值、平均值等。
统计时间间隔:对于主动触发统计,我们还要支持指定统计时间间隔,也就是多久触发一次统计显示。比如,每间隔 10s 统计一次接口信息并显示到命令行中,每间隔 24 小时发送一封统计信息邮件。

2. 非功能性需求分析

对于这样一个通用的框架的开发,我们还需要考虑很多非功能性的需求。具体来讲,我总结了以下几个比较重要的方面。
易用性
易用性听起来更像是一个评判产品的标准。没错,我们在开发这样一个技术框架的时候,也要有产品意识。框架是否易集成、易插拔、跟业务代码是否松耦合、提供的接口是否够灵活等等,都是我们应该花心思去思考和设计的。有的时候,文档写得好坏甚至都有可能决定一个框架是否受欢迎。
性能
对于需要集成到业务系统的框架来说,我们不希望框架本身的代码执行效率,对业务系统有太多性能上的影响。对于性能计数器这个框架来说,一方面,我们希望它是低延迟的,也就是说,统计代码不影响或很少影响接口本身的响应时间;另一方面,我们希望框架本身对内存的消耗不能太大。
扩展性
这里说的扩展性跟之前讲到的代码的扩展性有点类似,都是指在不修改或尽量少修改代码的情况下添加新的功能。但是这两者也有区别。之前讲到的扩展是从框架代码开发者的角度来说的。这里所说的扩展是从框架使用者的角度来说的,特指使用者可以在不修改框架源码,甚至不拿到框架源码的情况下,为框架扩展新的功能。这就有点类似给框架开发插件。关于这一点,我举一个例子来解释一下。
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) {
//...
}
}
容错性
容错性这一点也非常重要。对于性能计数器框架来说,不能因为框架本身的异常导致接口请求出错。所以,我们要对框架可能存在的各种异常情况都考虑全面,对外暴露的接口抛出的所有运行时、非运行时异常都进行捕获处理。
通用性
为了提高框架的复用性,能够灵活应用到各种场景中。框架在设计的时候,要尽可能通用。我们要多去思考一下,除了接口统计这样一个需求,还可以适用到其他哪些场景中,比如是否还可以处理其他事件的统计信息,比如 SQL 请求时间的统计信息、业务统计信息(比如支付成功率)等。

框架设计

前面讲了需求分析,现在我们来看如何针对需求做框架设计。
对于稍微复杂系统的开发,很多人觉得不知从何开始。我个人喜欢借鉴 TDD(测试驱动开发)和 Prototype(最小原型)的思想,先聚焦于一个简单的应用场景,基于此设计实现一个简单的原型。尽管这个最小原型系统在功能和非功能特性上都不完善,但它能够看得见、摸得着,比较具体、不抽象,能够很有效地帮助我缕清更复杂的设计思路,是迭代设计的基础。
这就好比做算法题目。当我们想要一下子就想出一个最优解法时,可以先写几组测试数据,找找规律,再先想一个最简单的算法去解决它。虽然这个最简单的算法在时间、空间复杂度上可能都不令人满意,但是我们可以基于此来做优化,这样思路就会更加顺畅。
对于性能计数器这个框架的开发来说,我们可以先聚焦于一个非常具体、简单的应用场景,比如统计用户注册、登录这两个接口的响应时间的最大值和平均值、接口调用次数,并且将统计结果以 JSON 的格式输出到命令行中。现在这个需求简单、具体、明确,设计实现起来难度降低了很多。
我们先给出应用场景的代码。具体如下所示:
//应用场景:统计下面两个接口(注册和登录)的响应时间和访问次数
public class UserController {
public void register(UserVo user) {
//...
}
public UserVo login(String telephone, String password) {
//...
}
}
要输出接口的响应时间的最大值、平均值和接口调用次数,我们首先要采集每次接口请求的响应时间,并且存储起来,然后按照某个时间间隔做聚合统计,最后才是将结果输出。在原型系统的代码实现中,我们可以把所有代码都塞到一个类中,暂时不用考虑任何代码质量、线程安全、性能、扩展性等等问题,怎么简单怎么来就行。
最小原型的代码实现如下所示。其中,recordResponseTime() 和 recordTimestamp() 两个函数分别用来记录接口请求的响应时间和访问时间。startRepeatedReport() 函数以指定的频率统计数据并输出结果。
public class Metrics {
// Map的key是接口名称,value对应接口请求的响应时间或时间戳;
private Map<String, List<Double>> responseTimes = new HashMap<>();
private Map<String, List<Double>> timestamps = 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, Map<String, 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.currentTimeMillis();
metrics.recordTimestamp("regsiter", startTimestamp);
//...
long respTime = System.currentTimeMillis() - startTimestamp;
metrics.recordResponseTime("register", respTime);
}
public UserVo login(String telephone, String password) {
long startTimestamp = System.currentTimeMillis();
metrics.recordTimestamp("login", startTimestamp);
//...
long respTime = System.currentTimeMillis() - startTimestamp;
metrics.recordResponseTime("login", respTime);
}
}
最小原型的代码实现虽然简陋,但它却帮我们将思路理顺了很多,我们现在就基于它做最终的框架设计。下面是我针对性能计数器框架画的一个粗略的系统设计图。图可以非常直观地体现设计思想,并且能有效地帮助我们释放更多的脑空间,来思考其他细节问题。
如图所示,我们把整个框架分为四个模块:数据采集、存储、聚合统计、显示。每个模块负责的工作简单罗列如下。
数据采集:负责打点采集原始数据,包括记录每次接口请求的响应时间和请求时间。数据采集过程要高度容错,不能影响到接口本身的可用性。除此之外,因为这部分功能是暴露给框架的使用者的,所以在设计数据采集 API 的时候,我们也要尽量考虑其易用性。
存储:负责将采集的原始数据保存下来,以便后面做聚合统计。数据的存储方式有多种,比如:Redis、MySQL、HBase、日志、文件、内存等。数据存储比较耗时,为了尽量地减少对接口性能(比如响应时间)的影响,采集和存储的过程异步完成。
聚合统计:负责将原始数据聚合为统计数据,比如:max、min、avg、pencentile、count、tps 等。为了支持更多的聚合统计规则,代码希望尽可能灵活、可扩展。
显示:负责将统计数据以某种格式显示到终端,比如:输出到命令行、邮件、网页、自定义显示终端等。
前面讲到面向对象分析、设计和实现的时候,我们讲到设计阶段最终输出的是类的设计,同时也讲到,软件设计开发是一个迭代的过程,分析、设计和实现这三个阶段的界限划分并不明显。所以,今天我们只给出了比较粗略的模块划分,至于更加详细的设计,我们留在下一节课中跟实现一块来讲解。

重点回顾

今天的内容到此就讲完了。我们来一起总结回顾一下,你需要掌握的重点内容。
对于非业务通用框架的开发,我们在做需求分析的时候,除了功能性需求分析之外,还需要考虑框架的非功能性需求。比如,框架的易用性、性能、扩展性、容错性、通用性等。
对于复杂框架的设计,很多人往往觉得无从下手。今天我们分享了几个小技巧,其中包括:画产品线框图、聚焦简单应用场景、设计实现最小原型、画系统设计图等。这些方法的目的都是为了让问题简化、具体、明确,提供一个迭代设计开发的基础,逐步推进。
实际上,不仅仅是软件设计开发,不管做任何事情,如果我们总是等到所有的东西都想好了再开始,那这件事情可能永远都开始不了。有句老话讲:万事开头难,所以,先迈出第一步很重要。

课堂讨论

今天的课堂讨论题有下面两道。
应对复杂系统的设计实现,我今天讲到了聚焦简单场景、最小原型、画图等几个技巧,你还有什么经验可以分享给大家吗?
今天提到的线框图、最小原型、易用性等,实际上都是产品设计方面的手段或者概念,应用到像框架这样的技术产品的设计上也非常有用。你觉得对于一个技术人来说,产品能力是否同样重要呢?技术人是否应该具备一些产品思维呢?
欢迎在留言区写下你的答案,和同学一起交流和分享。如果有收获,也欢迎你把这篇文章分享给你的朋友。
分享给需要的人,Ta购买本课程,你将得29
生成海报并分享

赞 99

提建议

上一篇
24 | 实战一(下):如何实现一个遵从设计原则的积分兑换系统?
下一篇
26 | 实战二(下):如何实现一个支持各种统计规则的性能计数器?
 写留言

精选留言(94)

  • 辣么大
    2019-12-30
    没有经历过大型系统的全过程(设计,开发,实现,维护)。自己开发一些功能时,比较喜欢“用户故事”,这样能基本能做到一次交付一个可用功能。干就是了!先有一个原型,然后再迭代优化。最后“纸上得来终觉浅”,照着争哥的代码还是自己实现了一下:https://github.com/gdhucoder/Algorithms4/tree/master/designpattern/u025
    共 5 条评论
    88
  •         
    2020-01-03
    https://github.com/Aspire814/DesignPattern.git 因为争哥的简单实现具有代码倾入性,简单实现了了一个基于自定义切面和注解的接口统计服务。供学习参考
    共 2 条评论
    46
  • progyoung
    2019-12-30
    老师,本文中的案例统计时间时对业务代码是侵入式的,有没有非侵入式的案例呀?

    作者回复: 可以使用类似spring aop 做到无侵入

    共 8 条评论
    30
  • 花儿少年
    2020-01-02
    目前在维护和开发营销插件系统,对于产品思维算是有一点感悟吧。 营销插件多种多样,每个插件都有一些共性的地方,也有特性的地方。插件于插件之间还有叠加互斥关系,比如说有了满减送也可以使用优惠券等等。不同的产品定义不同的插件规则,后来就出现了在同一个点上,不同插件的理解都不一一致,商家理解使用费劲。还有针对某个插件定义一些奇怪的功能,极少商家在用,但是还不允许去掉这个功能。这就为后来的优化产生了极大的阻碍,设计和开发的人都走了,奇怪的功能还涉及好几个团队,尝试过沟通,无结果,保持现状。 还有在开发新功能的时候,产品拍脑袋说这个手机壳的颜色要随心情变化,你说这能给做吗。 技术人员一定要有产品思维,代码是你写的也是你维护,某个功能是不是屎坑一定要三思,产品拍脑袋,技术不能拍脑袋,最后掉的是自己的头发,走的也是自己。一定要把风险都给他讲明白,我们不生气,我们讲道理!!!
    展开
    共 4 条评论
    25
  • 一壶浊酒
    2019-12-30
    我觉得技术人需要一些产品的思维,这样即使在做已经设计好的产品的时候,也能提出一些不同的看法和见解,而不是一味的做一个执行者,别人说啥就做啥,而且框架的设计我觉得也是一个产品,需要我们技术人自己去推敲去打磨。
    共 1 条评论
    22
  • Lyre
    2020-01-06
    复杂的系统设计,首先应该梳理出功能点,整理架构设计,画出架构设计图,有了总体的规划,做下去才更顺畅。对吗老师

    作者回复: 是的,先做设计,后写代码。先做顶层设计,再做细分设计。

    15
  • 李小四
    2019-12-30
    设计模式_25: 1. 做事要避免极端,最小原型和场景,是为了避免完美主义,永远开不了头的极端。但另一方面,如果是复杂的系统,避免不了地要花很多时间去思考系统设计的问题,要有思考和记录,这样是为了避免另一个极端,过于简单的架构开发复杂系统,最终导致改不动了。 2. 如果问题是“是否应该有产品意识”,答案是不言而喻的。而且,于是技术能力强的技术人员,对于产品意识的需求就越是迫切,在真实的市场竞争中,用户只会接触到产品,技术可能会成为产品的竞争优势,也可能不会,但技术人员了解产品思维,这样能够更全面地了解自己做的事情,在真实的用户场景中,在发挥着怎样的价值;另外,在做了很久的技术后,我们可能有欲望把自己的一些idea转化成产品,并最终推向市场,面向用户。做成这样的事情,会有更强烈的成就感,离创业也更近了一部。
    展开
    共 1 条评论
    14
  • 北天魔狼
    2019-12-30
    一直没有做过关于统计和监控的项目,希望老师可以出一个小的MVP🙏🙏🙏

    作者回复: 39 40讲 会给出完善的代码

    共 4 条评论
    8
  • Monday
    2019-12-30
    手机听读终觉浅,归来PC撸代码.。 GET完。代码:https://gitee.com/MondayLiu/geek-design.git
    7
  • 小畅
    2019-12-31
    打卡似懂非懂
    共 1 条评论
    6
  • Flynn
    2019-12-30
    老师,后面有TDD相关的内容讲解和练习么

    作者回复: 有单元测试的讲解

    5
  • Ilearning99
    2020-10-07
    我发现很多时候,我想问题都很复杂,快到deadline的时候就会实现一个非常简化的版本
    4
  • 桂城老托尼
    2019-12-30
    感谢争哥分享,先看了第一段过来作答,完了再回到文章验证想法。 统计接口各维度信息的框架设计思路如下, 1,确认框架职责,框架的用例。采集原始数据(标准埋点日志),加工原始数据(时间窗口内),提供外围消费(适配各种style) 2. 细分每一职责,采集原始数据,围绕框架提供能力,确定原始数据标准,甚至原始数据标准的定义也开放给业务系统,解析关键信息的规则由业务系统自己把控。框架负责制定规则的枚举,和规则解析。 3. 加工原始数据,其实就是使用规则对原始数据流进行解析和统计,这里可以给出默认时间窗口和更新周期,业务系统可配置变更。 4. 提供外围系统消费,框架给出指标数据,自己默认展示样式,自定义样式留好扩展,交给业务系统自己扩展,框架也可以管控起来,形成类似于“主题市场”的东西。 总结下来,就是确定职责边界,高内聚框架职责,低耦合业务系统,对修改关闭,对扩展开放。基于这些原则,再往上走就是各种xx设计模式了,这时候就是水到渠成的事儿了。 -----回去再看下文章验证下猜想,不被打脸才好 。
    展开
    3
  • javaadu
    2019-12-30
    还没有看文章的方案,先来留个言: 运行时:框架的接口是注解;通过mq将统计的数据发出到实时计算引擎例如flink,编写udf统计各种特征数据 管理时:核心是数据存储和查询模块;渠道接入放在独立的模块
    3
  • 郑大钱
    2020-09-10
    高级工程师会从易用、扩展、性能、稳定等方面设计架构,这对我来说是很难的。但我也想写出牛逼的代码,怎么办? 最小原型是我的最佳实践。 先聚焦于实现一个单一的功能点,就算实现的代码毫无设计可言,但至少要先让它跑起来。谈易用、谈扩展、谈性能、谈健壮,具体代码比抽象概念更容易。 一定要给你的代码挤出重构的时间,哪怕只有一句代码。对我来说,重构相当于在复盘,这是你学到的设计模式落地的时候,也是真正成长的时候。
    展开
    共 1 条评论
    2
  • 努力呼吸
    2020-02-12
    个人简单实现的无侵入代码 https://github.com/ghaoxiangzZ/design-pattern-code/tree/master/src/main/java/com/ghaoxiang/lesson/twentyfive
    2
  • javaadu
    2020-01-31
    问题1:要重视设计,但是可以采取不同的方法,有的人喜欢用本文中的方法——先写一个简单的原型,在写的过程中理清楚对问题的理解和设计;也有的人喜欢将设计作为一个单独的流程,先用一些抽象的线框图推到清楚再动手。我的观点是,两者不冲突,可以两者一起使用,如果是团队项目,建议先各自有个深度的思考然后再进行碰撞合并,应该可以产出大方向没问题的设计
    2
  • helloworld
    2020-01-16
    在设计一个系统的时候,不需要也不能100%都考虑到了才开始,要先设计一个能简单实现的系统,先编码实现了这个简单系统,然后逐步迭代完善;在这个过程中能用图示画出来的要画出来,图示能给人以十分直观的方式;在学习其他的课程的过程中,通过画系统的流程图就能让自己可以很清晰地了解系统的执行流程,尤其是自己总结流程图很重要!并且当时间长了来回顾复习的时候,流程图更直观,更容易复习
    2
  • 2019-12-30
    像这种统计频次的功能,是通过集成框架去实现好,还是说通过mq由消费服务去实现好
    共 1 条评论
    2
  • 再见孙悟空
    2019-12-30
    使用线框图,采用最小原型模式,先做出一个模型,画出模型图,然后再迭代优化,使抽象的东西变得看得见摸得着,这确实是一个好方法,实际项目中也不知不觉用到了这种思想,做非业务类的需求如此,业务类的也一样。还有留言里说的用户故事也是很不错的方法,通俗点就是技术要有产品的思维,站在使用者的角度看问题。
    2