92 | 项目实战一:设计实现一个支持各种算法的限流框架(实现)
下载APP
关闭
渠道合作
推荐作者
92 | 项目实战一:设计实现一个支持各种算法的限流框架(实现)
2020-06-03 王争 来自北京
《设计模式之美》
课程介绍
讲述:冯永吉
时长12:44大小11.65M
上一节课,我们介绍了如何通过合理的设计,来实现功能性需求的同时,满足易用、易扩展、灵活、低延迟、高容错等非功能性需求。在设计的过程中,我们也借鉴了之前讲过的一些开源项目的设计思想。比如,我们借鉴了 Spring 的低侵入松耦合、约定优于配置等设计思想,还借鉴了 MyBatis 通过 MyBatis-Spring 类库将框架的易用性做到极致等设计思路。
今天,我们讲解这样一个问题,针对限流框架的开发,如何做高质量的代码实现。说的具体点就是,如何利用之前讲过的设计思想、原则、模式、编码规范、重构技巧等,写出易读、易扩展、易维护、灵活、简洁、可复用、易测试的代码。
话不多少,让我们正式开始今天的学习吧!
V1 版本功能需求
我们前面提到,优秀的代码是重构出来的,复杂的代码是慢慢堆砌出来的。小步快跑、逐步迭代是我比较推崇的开发模式。所以,针对限流框架,我们也不用想一下子就做得大而全。况且,在专栏有限的篇幅内,我们也不可能将一个大而全的代码阐述清楚。所以,我们可以先实现一个包含核心功能、基本功能的 V1 版本。
针对上两节课中给出的需求和设计,我们重新梳理一下,看看有哪些功能要放到 V1 版本中实现。
在 V1 版本中,对于接口类型,我们只支持 HTTP 接口(也就 URL)的限流,暂时不支持 RPC 等其他类型的接口限流。对于限流规则,我们只支持本地文件配置,配置文件格式只支持 YAML。对于限流算法,我们只支持固定时间窗口算法。对于限流模式,我们只支持单机限流。
尽管功能“裁剪”之后,V1 版本实现起来简单多了,但在编程开发的同时,我们还是要考虑代码的扩展性,预留好扩展点。这样,在接下来的新版本开发中,我们才能够轻松地扩展新的限流算法、限流模式、限流规则格式和数据源。
最小原型代码
上节课我们讲到,项目实战中的实现等于面向对象设计加实现。而面向对象设计与实现一般可以分为四个步骤:划分职责识别类、定义属性和方法、定义类之间的交互关系、组装类并提供执行入口。在第 14 讲中,我还带你用这个方法,设计和实现了一个接口鉴权框架。如果你印象不深刻了,可以回过头去再看下。
不过,我们前面也讲到,在平时的工作中,大部分程序员都是边写代码边做设计,边思考边重构,并不会严格地按照步骤,先做完类的设计再去写代码。而且,如果想一下子就把类设计得很好、很合理,也是比较难的。过度追求完美主义,只会导致迟迟下不了手,连第一行代码也敲不出来。所以,我的习惯是,先完全不考虑设计和代码质量,先把功能完成,先把基本的流程走通,哪怕所有的代码都写在一个类中也无所谓。然后,我们再针对这个 MVP 代码(最小原型代码)做优化重构,比如,将代码中比较独立的代码块抽离出来,定义成独立的类或函数。
我们按照先写 MVP 代码的思路,把代码实现出来。它的目录结构如下所示。代码非常简单,只包含 5 个类,接下来,我们针对每个类一一讲解一下。
我们先来看下 RateLimiter 类。代码如下所示:
RateLimiter 类用来串联整个限流流程。它先读取限流规则配置文件,映射为内存中的 Java 对象(RuleConfig),然后再将这个中间结构构建成一个支持快速查询的数据结构(RateLimitRule)。除此之外,这个类还提供供用户直接使用的最顶层接口(limit() 接口)。
我们再来看下 RuleConfig 和 ApiLimit 两个类。代码如下所示:
从代码中,我们可以看出来,RuleConfig 类嵌套了另外两个类 AppRuleConfig 和 ApiLimit。这三个类跟配置文件的三层嵌套结构完全对应。我把对应关系标注在了下面的示例中,你可以对照着代码看下。
我们再来看下 RateLimitRule 这个类。
你可能会好奇,有了 RuleConfig 来存储限流规则,为什么还要 RateLimitRule 类呢?这是因为,限流过程中会频繁地查询接口对应的限流规则,为了尽可能地提高查询速度,我们需要将限流规则组织成一种支持按照 URL 快速查询的数据结构。考虑到 URL 的重复度比较高,且需要按照前缀来匹配,我们这里选择使用 Trie 树这种数据结构。我举了个例子解释一下,如下图所示。左边的限流规则对应到 Trie 树,就是图中右边的样子。
RateLimitRule 的实现代码比较多,我就不在这里贴出来了,我只给出它的定义,如下所示。如果你感兴趣的话,可以自己实现一下,也可以参看我的另一个专栏《数据结构与算法之美》的第 55 讲。在那节课中,我们对各种接口匹配算法有非常详细的讲解。
最后,我们看下 RateLimitAlg 这个类。
这个类是限流算法实现类。它实现了最简单的固定时间窗口限流算法。每个接口都要在内存中对应一个 RateLimitAlg 对象,记录在当前时间窗口内已经被访问的次数。RateLimitAlg 类的代码如下所示。对于代码的算法逻辑,你可以看下上节课中对固定时间窗口限流算法的讲解。
Review 最小原型代码
刚刚给出的 MVP 代码,虽然总共也就 200 多行,但已经实现了 V1 版本中规划的功能。不过,从代码质量的角度来看,它还有很多值得优化的地方。现在,我们现在站在一个 Code Reviewer 的角度,来分析一下这段代码的设计和实现。
结合 SOLID、DRY、KISS、LOD、基于接口而非实现编程、高内聚松耦合等经典的设计思想和原则,以及编码规范,我们从代码质量评判标准的角度重点剖析一下,这段代码在可读性、扩展性等方面的表现。其他方面的表现,比如复用性、可测试性等,这些你可以比葫芦画瓢,自己来进行分析。
首先,我们来看下代码的可读性。
影响代码可读性的因素有很多。我们重点关注目录设计(package 包)是否合理、模块划分是否清晰、代码结构是否高内聚低耦合,以及是否符合统一的编码规范这几点。
因为涉及的代码不多,目录结构前面也给出了,总体来说比较简单,所以目录设计、包的划分没有问题。
按照上节课中的模块划分,RuleConfig、ApiLimit、RateLimitRule 属于“限流规则”模块,负责限流规则的构建和查询。RateLimitAlg 属于“限流算法”模块,提供了基于内存的单机固定时间窗口限流算法。RateLimiter 类属于“集成使用”模块,作为最顶层类,组装其他类,提供执行入口(也就是调用入口)。不过,RateLimiter 类作为执行入口,我们希望它只负责组装工作,而不应该包含具体的业务逻辑,所以,RateLimiter 类中,从配置文件中读取限流规则这块逻辑,应该拆分出来设计成独立的类。
如果我们把类与类之间的依赖关系图画出来,你会发现,它们之间的依赖关系很简单,每个类的职责也比较单一,所以类的设计满足单一职责原则、LOD 迪米特法则、高内聚松耦合的要求。
从编码规范上来讲,没有超级大的类、函数、代码块。类、函数、变量的命名基本能达意,也符合最小惊奇原则。虽然,有些命名不能一眼就看出是干啥的,有些命名采用了缩写,比如 RateLimitAlg,但是我们起码能猜个八九不离十,结合注释(限于篇幅注释都没有写,并不代表不需要写),很容易理解和记忆。
总结一下,在最小原型代码中,目录设计、代码结构、模块划分、类的设计还算合理清晰,基本符合编码规范,代码的可读性不错!
其次,我们再来看下代码的扩展性。
实际上,这段代码最大的问题就是它的扩展性,也是我们最关注的,毕竟后续还有更多版本的迭代开发。编写可扩展代码,关键是要建立扩展意识。这就像下象棋,我们要多往前想几步,为以后做准备。在写代码的时候,我们要时刻思考,这段代码如果要扩展新的功能,那是否可以在尽量少改动代码的情况下完成,还是需要要大动干戈,推倒重写。
具体到 MVP 代码,不易扩展的最大原因是,没有遵循基于接口而非实现的编程思想,没有接口抽象意识。比如,RateLimitAlg 类只是实现了固定时间窗口限流算法,也没有提炼出更加抽象的算法接口。如果我们要替换其他限流算法,就要改动比较多的代码。其他类的设计也有同样的问题,比如 RateLimitRule。
除此之外,在 RateLimiter 类中,配置文件的名称、路径,是硬编码在代码中的。尽管我们说约定优于配置,但也要兼顾灵活性,能够让用户在需要的时候,自定义配置文件名称、路径。而且,配置文件的格式只支持 Yaml,之后扩展其他格式,需要对这部分代码做很大的改动。
重构最小原型代码
根据刚刚对 MVP 代码的剖析,我们发现,它的可读性没有太大问题,问题主要在于可扩展性。主要的修改点有两个,一个是将 RateLimiter 中的规则配置文件的读取解析逻辑拆出来,设计成独立的类,另一个是参照基于接口而非实现编程思想,对于 RateLimitRule、RateLimitAlg 类提炼抽象接口。
按照这个修改思路,我们对代码进行重构。重构之后的目录结构如下所示。我对每个类都稍微做了说明,你可以对比着重构前的目录结构来看。
其中,RateLimiter 类重构之后的代码如下所示。代码的改动集中在构造函数中,通过调用 RuleConfigSource 来实现了限流规则配置文件的加载。
我们再来看下,从 RateLimiter 中拆分出来的限流规则加载的逻辑,现在是如何设计的。这部分涉及的类主要是下面几个。我把关键代码也贴在了下面。其中,各个 Parser 和 RuleConfigSource 类的设计有点类似策略模式,如果要添加新的格式的解析,只需要实现对应的 Parser 类,并且添加到 FileRuleConfig 类的 PARSER_MAP 中就可以了。
重点回顾
好了,今天的内容到此就讲完了。我们一块来总结回顾一下,你需要重点掌握的内容。
优秀的代码是重构出来的,复杂的代码是慢慢堆砌出来的。小步快跑、逐步迭代是我比较推崇的开发模式。追求完美主义会让我们迟迟无法下手。所以,为了克服这个问题,一方面,我们可以规划多个小版本来开发,不断迭代优化;另一方面,在编程实现的过程中,我们可以先实现 MVP 代码,以此来优化重构。
如何对 MVP 代码优化重构呢?我们站在 Code Reviewer 的角度,结合 SOLID、DRY、KISS、LOD、基于接口而非实现编程、高内聚松耦合等经典的设计思想和原则,以及编码规范,从代码质量评判标准的角度,来剖析代码在可读性、扩展性、可维护性、灵活、简洁、复用性、可测试性等方面的表现,并且针对性地去优化不足。
课堂讨论
针对 MVP 代码,如果让你做 code review,你还能发现哪些问题?如果让你做重构,你还会做哪些修改和优化?
如何重构代码,支持自定义限流规则配置文件名和路径?如果你熟悉 Java,你可以去了解一下 Spring 的设计思路,看看如何借鉴到限流框架中来解决这个问题?
欢迎留言和我分享你的想法。如果有收获,也欢迎你把这篇文章分享给你的朋友。
分享给需要的人,Ta购买本课程,你将得29元
生成海报并分享
赞 30
提建议
© 版权归极客邦科技所有,未经许可不得传播售卖。 页面已增加防盗追踪,如有侵权极客邦将依法追究其法律责任。
上一篇
91 | 项目实战一:设计实现一个支持各种算法的限流框架(设计)
下一篇
93 | 项目实战二:设计实现一个通用的接口幂等框架(分析)
精选留言(30)
- HuaMax2020-06-03stopwatch.reset()之后要调用stopwatch.start()重新开始,或者stopwatch.stop().start(),亲入坑。。。21
- Jie2020-06-05https://github.com/wangzheng0822/ratelimiter4j 老师忘记在专栏里面放自己项目的地址了么,翻看隔壁算法之美发现的
作者回复: 感谢你的补充!
共 3 条评论13 - jaryoung2020-06-03课后习题二: 如何重构代码,支持自定义限流规则配置文件名和路径? public static final String DEFAULT_API_LIMIT_CONFIG_NAME = "ratelimiter-rule"; private final String customApiLimitConfigPath; public FileRuleConfigSource(String configLocation) { this.customApiLimitConfigPath = configLocation; } private String getFileNameByExt(String extension) { return StringUtils.isEmpty(customApiLimitConfigPath) ? DEFAULT_API_LIMIT_CONFIG_NAME + "." + extension : customApiLimitConfigPath; } Spring boot 如何实现配置文件约定和扫描?可以去看看ConfigFileApplicationListener 这个类,如何跑起来,请去debug,不懂怎么debug,请新建窗口输入 google.com展开11
- 高源2020-06-03老师今天讲的骨架,有代码吗,我想结合你讲的自己再多考虑和分析,学习其中的方法解决的问题
作者回复: 代码都在文章里了,你也可以看下我github上的一个比较老的版本的,但写的比较详细 https://github.com/wangzheng0822
6 - leezer2020-06-03RatelimitAlg在重构后应该是可支持多种算法形式,那么在limit调用的时候应该不是直接new出来,可以通过策略形式进行配置,而算法的选取应该包含默认和指定,也可以配置到文件规则里面。4
- bucher2021-01-04感谢争哥,写的很棒。根据url查找限流规则使用了trie树这块是不是属于过度设计呢?一个app下的api个数不多的情况,直接用map存就可以了吧(map的key使用url名)共 2 条评论3
- djfhchdh2020-06-041、RateLimiter类中,构建api对应在内存中的限流计数器(RateLimitAlg)这个逻辑可以独立出来,初始化的过程中,就将api和相应RateLimitAlg实现类的对应关系建立好; 2、可以使用DI框架,FileRuleConfigSource构建时,从bean配置文件读取构造参数,如果没有提供构造参数就用默认值3
- 龙猫2020-10-01tryAcquire 为什么要 调用两次 currentCount.incrementAndGet()方法呢?没太看懂,有大佬解释一下吗共 3 条评论2
- Geek_5aae472020-09-24第二次的updatedCount = currentCount.incrementAndGet()没太看懂放在if外面的用意,是否放入stopwatch.reset()之后会好一点。2
- Tobias2020-08-03课后习题: 让使用者通过注解方式,在项目启动是加载配置。配置可以像这样@Ratelimiter(datasourcetype="local", datasourcepath='xxx', parser='json')2
- 杨杰2020-06-181、在配置文件中是否应该指定默认的限流算法和每个api(或appid)对应的算法,在加载配置文件的时候自动生成这个配置算法的实例 2、在RateLimiter中的limit方法里面添加每个api对应的限流算法这个地方感觉有点儿不太好,如果每个API对应的限流算法都不一样会导致大量的If else 判断,是不是应该像第一点说的那样初始化的时候就自动生成了。展开
作者回复: 可以把选择哪种限流算法配置到配置文件中,但没有必要给每个appid都配置不同的限流算法吧
2 - Jxin2020-06-031.随手写都如此牛逼。。。 2.还是有个git代码仓好点,这样手机看难受。 3.为什么要懒加载,直接在初始化时,将算法规则与算法实例绑定,将api与限流算法实例绑定。对于这个限流框架的应用场景不是更合适吗。如此便可以把懒加载的代码抽离,使业务聚焦业务而不用关心实例创建。 4.还得考虑动态限流配置调整的功能。展开共 1 条评论2
- 马以2020-06-03哈哈,新鲜出炉2
- Heaven2020-06-031.可以将配置类和实际的拦截器接口实现类进行相分离,然后在实现类里面去执行查找接口拦截规则并执行对应接口的Alg,对于Alg实现类,抽取出接口,方便自定义算法,并且在内部实现诸如漏桶算法的实现,利用用户配置和策略模式来进行实现 2.对于这个问题,可以参考Spring给出的Resource接口,并给出了基于不同的读取方式的实现类,而且为了简化开发,给出ResourceLoader,并且还有着DefaultResourceLoader,可以根据传入前缀,来创建不同的Resource,对于字符串查找树这个实现,我是真的没想到,不过可以在这个基础上,借鉴HashMap的实现,在api接口足够少的时候,使用简单的map保存,多了再转为树 再往深了说,BeanFactory需要传入资源生成对应的实体Bean,而为了简化开发,一般是使用ApplicationContext来初始化Bean,需要传入一个资源给ApplicationContext,并在里面动态解析生成Bean对象,这样的流程,值得我们的框架借鉴点有很多展开1
- Liam2020-06-03Ralimiter#tryAcquire 方法,前三行,先更新count是否有问题,当前时间窗口可能会累积上一个时间窗口的计数,导致统计不准确1
- 傲慢与偏执,2020-06-03学习学习1
- Geek_7e0e832022-12-28 来自广东问题1.我列出了几点优化的点 1.重构前类的名称RateLimitAlgorithm不够清晰和具体,可以变为具体的实现名称 比如 FixedTimeWindowRateLimit。当然作者重构后就改造了 2.在RateLimitAlgorithm的实现类中 优化 获取锁超时时间为20ms,我们可以估计一下临界区内执行的时间估计在ms级别以下,快的话在ns级别。这样可以缩短问题持续的时间,尽快暴露。 3.在ApiLimit类里面,这里不要赋予默认值 而是在构造函数去赋值 既然有DEFAULT_TIME_UNIT 就有自定义的时间粒度 4.RateLimiter里面可以 可以由工厂来构造实例化 具体的 限流算法 不硬编码new 出固定时间窗口的类 具体的代码实现 可以参考https://github.com/yukunqi/designPattern/tree/master/src/main/java/com/ratelimit 问题2.可以参考spring-boot的@ConfigurationProperties spring的@PropertySource使用注解的方式来自定义配置文件的名称和文件位置。然后利用独立的类来完成配置加载的工具,对使用方透明。提高效率展开
- Sherk2022-11-06 来自美国RateLimiter类中的44 行代码有问题() String counterKey = appId + ":" + apiLimit.getApi(); RateLimitAlg rateLimitCounter = counters.get(counterKey); if (rateLimitCounter == null) { RateLimitAlg newRateLimitCounter = new RateLimitAlg(apiLimit.getLimit()); rateLimitCounter = counters.putIfAbsent(counterKey, newRateLimitCounter); if (rateLimitCounter == null) { rateLimitCounter = newRateLimitCounter; // 这应该是直接从counters里面取。 因为其他线程已经设置成功,就应该获取已经存在的限流器。 } }展开
- 突围2022-10-21 来自北京讲得不错,颇有收益
- 江南一笑2022-01-02站在code reviewer的角度,感觉代码的看着有点乱,命名不舒服,inline comments 太少了。。