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

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

话不多说,让我们正式开始今天的内容吧!

项目背景

我们希望设计开发一个小的框架,能够获取接口调用的各种统计信息,比如,响应时间的最大值(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等。为了支持更多的聚合统计规则,代码希望尽可能灵活、可扩展。
  • 显示:负责将统计数据以某种格式显示到终端,比如:输出到命令行、邮件、网页、自定义显示终端等。

前面讲到面向对象分析、设计和实现的时候,我们讲到设计阶段最终输出的是类的设计,同时也讲到,软件设计开发是一个迭代的过程,分析、设计和实现这三个阶段的界限划分并不明显。所以,今天我们只给出了比较粗略的模块划分,至于更加详细的设计,我们留在下一节课中跟实现一块来讲解。

重点回顾

今天的内容到此就讲完了。我们来一起总结回顾一下,你需要掌握的重点内容。

对于非业务通用框架的开发,我们在做需求分析的时候,除了功能性需求分析之外,还需要考虑框架的非功能性需求。比如,框架的易用性、性能、扩展性、容错性、通用性等。

对于复杂框架的设计,很多人往往觉得无从下手。今天我们分享了几个小技巧,其中包括:画产品线框图、聚焦简单应用场景、设计实现最小原型、画系统设计图等。这些方法的目的都是为了让问题简化、具体、明确,提供一个迭代设计开发的基础,逐步推进。

实际上,不仅仅是软件设计开发,不管做任何事情,如果我们总是等到所有的东西都想好了再开始,那这件事情可能永远都开始不了。有句老话讲:万事开头难,所以,先迈出第一步很重要。

课堂讨论

今天的课堂讨论题有下面两道。

  1. 应对复杂系统的设计实现,我今天讲到了聚焦简单场景、最小原型、画图等几个技巧,你还有什么经验可以分享给大家吗?
  2. 今天提到的线框图、最小原型、易用性等,实际上都是产品设计方面的手段或者概念,应用到像框架这样的技术产品的设计上也非常有用。你觉得对于一个技术人来说,产品能力是否同样重要呢?技术人是否应该具备一些产品思维呢?

欢迎在留言区写下你的答案,和同学一起交流和分享。如果有收获,也欢迎你把这篇文章分享给你的朋友。

精选留言

  • 辣么大

    2019-12-30 06:21:11

    没有经历过大型系统的全过程(设计,开发,实现,维护)。自己开发一些功能时,比较喜欢“用户故事”,这样能基本能做到一次交付一个可用功能。干就是了!先有一个原型,然后再迭代优化。最后“纸上得来终觉浅”,照着争哥的代码还是自己实现了一下:https://github.com/gdhucoder/Algorithms4/tree/master/designpattern/u025
  •         

    2020-01-03 18:41:52

    https://github.com/Aspire814/DesignPattern.git
    因为争哥的简单实现具有代码倾入性,简单实现了了一个基于自定义切面和注解的接口统计服务。供学习参考
  • progyoung

    2019-12-30 08:11:44

    老师,本文中的案例统计时间时对业务代码是侵入式的,有没有非侵入式的案例呀?
    作者回复

    可以使用类似spring aop 做到无侵入

    2019-12-30 08:28:12

  • 花儿少年

    2020-01-02 00:15:37

    目前在维护和开发营销插件系统,对于产品思维算是有一点感悟吧。
    营销插件多种多样,每个插件都有一些共性的地方,也有特性的地方。插件于插件之间还有叠加互斥关系,比如说有了满减送也可以使用优惠券等等。不同的产品定义不同的插件规则,后来就出现了在同一个点上,不同插件的理解都不一一致,商家理解使用费劲。还有针对某个插件定义一些奇怪的功能,极少商家在用,但是还不允许去掉这个功能。这就为后来的优化产生了极大的阻碍,设计和开发的人都走了,奇怪的功能还涉及好几个团队,尝试过沟通,无结果,保持现状。
    还有在开发新功能的时候,产品拍脑袋说这个手机壳的颜色要随心情变化,你说这能给做吗。
    技术人员一定要有产品思维,代码是你写的也是你维护,某个功能是不是屎坑一定要三思,产品拍脑袋,技术不能拍脑袋,最后掉的是自己的头发,走的也是自己。一定要把风险都给他讲明白,我们不生气,我们讲道理!!!
  • 一壶浊酒

    2019-12-30 21:40:27

    我觉得技术人需要一些产品的思维,这样即使在做已经设计好的产品的时候,也能提出一些不同的看法和见解,而不是一味的做一个执行者,别人说啥就做啥,而且框架的设计我觉得也是一个产品,需要我们技术人自己去推敲去打磨。
  • Lyre

    2020-01-06 13:25:51

    复杂的系统设计,首先应该梳理出功能点,整理架构设计,画出架构设计图,有了总体的规划,做下去才更顺畅。对吗老师
    作者回复

    是的,先做设计,后写代码。先做顶层设计,再做细分设计。

    2020-01-07 14:49:32

  • 李小四

    2019-12-30 17:31:12

    设计模式_25:

    1. 做事要避免极端,最小原型和场景,是为了避免完美主义,永远开不了头的极端。但另一方面,如果是复杂的系统,避免不了地要花很多时间去思考系统设计的问题,要有思考和记录,这样是为了避免另一个极端,过于简单的架构开发复杂系统,最终导致改不动了。

    2. 如果问题是“是否应该有产品意识”,答案是不言而喻的。而且,于是技术能力强的技术人员,对于产品意识的需求就越是迫切,在真实的市场竞争中,用户只会接触到产品,技术可能会成为产品的竞争优势,也可能不会,但技术人员了解产品思维,这样能够更全面地了解自己做的事情,在真实的用户场景中,在发挥着怎样的价值;另外,在做了很久的技术后,我们可能有欲望把自己的一些idea转化成产品,并最终推向市场,面向用户。做成这样的事情,会有更强烈的成就感,离创业也更近了一部。
  • 北天魔狼

    2019-12-30 06:32:18

    一直没有做过关于统计和监控的项目,希望老师可以出一个小的MVP🙏🙏🙏
    作者回复

    39 40讲 会给出完善的代码

    2019-12-30 08:28:44

  • Monday

    2019-12-30 23:06:50

    手机听读终觉浅,归来PC撸代码.。
    GET完。代码:https://gitee.com/MondayLiu/geek-design.git
  • 小畅

    2019-12-31 09:02:05

    打卡似懂非懂
  • Flynn

    2019-12-30 00:25:51

    老师,后面有TDD相关的内容讲解和练习么
    作者回复

    有单元测试的讲解

    2019-12-30 08:29:11

  • Ilearning99

    2020-10-07 09:24:07

    我发现很多时候,我想问题都很复杂,快到deadline的时候就会实现一个非常简化的版本
  • 努力呼吸

    2020-02-12 00:40:36

    个人简单实现的无侵入代码 https://github.com/ghaoxiangzZ/design-pattern-code/tree/master/src/main/java/com/ghaoxiang/lesson/twentyfive
  • helloworld

    2020-01-16 16:19:25

    在设计一个系统的时候,不需要也不能100%都考虑到了才开始,要先设计一个能简单实现的系统,先编码实现了这个简单系统,然后逐步迭代完善;在这个过程中能用图示画出来的要画出来,图示能给人以十分直观的方式;在学习其他的课程的过程中,通过画系统的流程图就能让自己可以很清晰地了解系统的执行流程,尤其是自己总结流程图很重要!并且当时间长了来回顾复习的时候,流程图更直观,更容易复习
  • 桂城老托尼

    2019-12-30 13:37:06

    感谢争哥分享,先看了第一段过来作答,完了再回到文章验证想法。
    统计接口各维度信息的框架设计思路如下,
    1,确认框架职责,框架的用例。采集原始数据(标准埋点日志),加工原始数据(时间窗口内),提供外围消费(适配各种style)
    2. 细分每一职责,采集原始数据,围绕框架提供能力,确定原始数据标准,甚至原始数据标准的定义也开放给业务系统,解析关键信息的规则由业务系统自己把控。框架负责制定规则的枚举,和规则解析。
    3. 加工原始数据,其实就是使用规则对原始数据流进行解析和统计,这里可以给出默认时间窗口和更新周期,业务系统可配置变更。
    4. 提供外围系统消费,框架给出指标数据,自己默认展示样式,自定义样式留好扩展,交给业务系统自己扩展,框架也可以管控起来,形成类似于“主题市场”的东西。
    总结下来,就是确定职责边界,高内聚框架职责,低耦合业务系统,对修改关闭,对扩展开放。基于这些原则,再往上走就是各种xx设计模式了,这时候就是水到渠成的事儿了。
    -----回去再看下文章验证下猜想,不被打脸才好 。
  • 2019-12-30 11:25:49

    像这种统计频次的功能,是通过集成框架去实现好,还是说通过mq由消费服务去实现好
  • javaadu

    2019-12-30 08:09:00

    还没有看文章的方案,先来留个言:
    运行时:框架的接口是注解;通过mq将统计的数据发出到实时计算引擎例如flink,编写udf统计各种特征数据

    管理时:核心是数据存储和查询模块;渠道接入放在独立的模块
  • 郑大钱

    2020-09-10 10:12:11

    高级工程师会从易用、扩展、性能、稳定等方面设计架构,这对我来说是很难的。但我也想写出牛逼的代码,怎么办?
    最小原型是我的最佳实践。
    先聚焦于实现一个单一的功能点,就算实现的代码毫无设计可言,但至少要先让它跑起来。谈易用、谈扩展、谈性能、谈健壮,具体代码比抽象概念更容易。
    一定要给你的代码挤出重构的时间,哪怕只有一句代码。对我来说,重构相当于在复盘,这是你学到的设计模式落地的时候,也是真正成长的时候。
  • 波波

    2020-04-04 10:59:22

    老师,计算最大响应时间max应该是 响应时间减去请求时间吧,为什么代码中只用到了响应时间呢?
    作者回复

    好像这是两个概念啊,不用减啊

    2020-07-30 20:51:45

  • javaadu

    2020-01-31 12:49:07

    问题1:要重视设计,但是可以采取不同的方法,有的人喜欢用本文中的方法——先写一个简单的原型,在写的过程中理清楚对问题的理解和设计;也有的人喜欢将设计作为一个单独的流程,先用一些抽象的线框图推到清楚再动手。我的观点是,两者不冲突,可以两者一起使用,如果是团队项目,建议先各自有个深度的思考然后再进行碰撞合并,应该可以产出大方向没问题的设计