设计模式-设计实现一个支持各种算法的限流框架

分析

项目背景

公司成立初期,团队人少。公司集中精力开发一个金融理财产品(我们把这个项目叫做 X 项目)。整个项目只做了简单的前后端分离,后端的所有代码都在一个 GitHub 仓库中,整个后端作为一个应用来部署,没有划分微服务。

遇到了行业风口,公司发展得不错,公司开始招更多人,开发更多的金融产品,比如专注房贷的理财产品、专注供应链的产品、专注消费贷的借款端产品等等。在产品形态上,每个金融产品都做成了独立的 App。

对于不同的金融产品,尽管移动端长得不一样,但是后端的很多功能、代码都是可以复用的。为了快速上线,针对每个应用,公司都成立一个新的团队,然后拷贝 X 项目的代码,在此基础之上修改、添加新的功能。

这样成立新团队,拷贝老代码,改改就能上线一个新产品的开发模式,在一开始很受欢迎。产品上线快,也给公司赢得了竞争上的优势。但时间一长,这样的开发模式暴露出来的问题就越来越多了。而且随着公司的发展,公司也过了急速扩张期,人招得太多,公司开始考虑研发效率问题了。

因为所有的项目的代码都是从 X 项目拷贝来的,多个团队同时维护相似的代码,显然是重复劳动,协作起来也非常麻烦。任何团队发现代码的 bug,都要同步到其他团队做相同的修改。而且,各个团队对代码独立迭代,改得面目全非,即便要添加一个通用的功能,每个团队也都要基于自己的代码再重复开发。

除此之外,公司成立初期,各个方面条件有限,只能招到开发水平一般的员工,而且追求快速上线,所以,X 项目的代码质量很差,结构混乱、命名不规范、到处是临时解决方案、埋了很多坑,在烂代码之上不停地堆砌烂代码,时间长了,代码的可读性越来越差、维护成本越来越高,甚至高过了重新开发的成本。

我们可以把公共的功能、代码抽离出来,形成一个独立的项目,部署成一个公共服务平台。所有金融产品的后端还是参照 MVC 三层架构独立开发,不过,它们只实现自己特有的功能,对于一些公共的功能,通过远程调用公共服务平台提供的接口来实现。

这里提到的公共服务平台,有点类似现在比较火的“中台”或“微服务”。不过,为了减少部署、维护多个微服务的成本,我们把所有公共的功能,放到一个项目中开发,放到一个应用中部署。只不过,我们要未雨绸缪,事先按照领域模型,将代码的模块化做好,等到真的有哪个模块的接口调用过于集中,性能出现瓶颈的时候,我们再把它拆分出来,设计成独立的微服务来开发和部署。

经过这样的拆分之后,我们可以指派一个团队,集中维护公共服务平台的代码。开发一个新的金融产品,也只需要更少的人员来参与,因为他们只需要开发、维护产品特有的功能和代码就可以了。整体上,维护成本降低了。除此之外,公共服务平台的代码集中到了一个团队手里,重构起来不需要协调其他团队和项目,也便于我们重构、改善代码质量。

需求背景

对于公共服务平台来说,接口请求来自很多不同的系统(后面统称为调用方),比如各种金融产品的后端系统。在系统上线一段时间里,我们遇到了很多问题。比如,因为调用方代码 bug 、不正确地使用服务(比如启动 Job 来调用接口获取数据)、业务上面的突发流量(比如促销活动),导致来自某个调用方的接口请求数突增,过度争用服务的线程资源,而来自其他调用方的接口请求,因此来不及响应而排队等待,导致接口请求的响应时间大幅增加,甚至出现超时。

我们可以开发接口限流功能,限制每个调用方对接口请求的频率。当超过预先设定的访问频率后,我们就触发限流熔断,比如,限制调用方 app-1 对公共服务平台总的接口请求频率不超过 1000 次 / 秒,超过之后的接口请求都会被拒绝。除此之外,为了更加精细化地限流,除了限制每个调用方对公共服务平台总的接口请求频率之外,我们还希望能对单独某个接口的访问频率进行限制,比如,限制 app-1 对接口 /user/query 的访问频率为每秒钟不超过 100 次。

我们希望开发出来的东西有一定的影响力,即便做不到在行业内有影响力,起码也要做到在公司范围内有影响力。所以,从一开始,我们就不想把这个限流功能,做成只有我们项目可用。我们希望把它开发成一个通用的框架,能够应用到各个业务系统中,甚至可以集成到微服务治理平台中。实际上,这也体现了业务开发中要具备的抽象意识、框架意识。我们要善于识别出通用的功能模块,将它抽象成通用的框架、组件、类库等。

需求分析

这里,我们借助用户用例和测试驱动开发的思想,先去思考,如果框架最终被开发出来之后,它会如何被使用。我一般会找一个框架的应用场景,针对这个场景写一个框架使用的 Demo 程序,这样能够很直观地看到框架长什么样子。知道了框架应该长什么样,就相当于应试教育中确定了考试题目。针对明确的考题去想解决方案,这是我们多年应试教育锻炼之后最擅长做的。

对于限流框架来说,我们来看下它的应用场景。

首先我们需要设置限流规则。为了做到在不修改代码的前提下修改规则,我们一般会把规则放到配置文件中(比如 XML、YAML 配置文件)。在集成了限流框架的应用启动的时候,限流框架会将限流规则,按照事先定义的语法,解析并加载到内存中。我写了一个限流规则的 Demo 配置,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
configs:
- appId: app-1
limits:
- api: /v1/user
limit: 100
- api: /v1/order
limit: 50
- appId: app-2
limits:
- api: /v1/user
limit: 50
- api: /v1/order
limit: 50

在接收到接口请求之后,应用会将请求发送给限流框架,限流框架会告诉应用,这个接口请求是允许继续处理,还是触发限流熔断。如果我们用代码来将这个过程表示出来的话,就是下面这个 Demo 的样子。如果项目使用的是 Spring 框架,我们可以利用 Spring AOP,把这段限流代码放在统一的切面中,在切面中拦截接口请求,解析出请求对应的调用方 APP ID 和 URL,然后验证是否对此调用方的这个接口请求进行限流。

1
2
3
4
5
6
7
8
9
String appId = "app-1"; // 调用方APP-ID
String url = "http://www.eudemon.com/v1/user/12345";// 请求url
RateLimiter ratelimiter = new RateLimiter();
boolean passed = ratelimiter.limit(appId, url);
if (passed) {
// 放行接口请求,继续后续的处理。
} else {
// 接口请求被限流。
}

结合刚刚的 Demo,从使用的角度来说,限流框架主要包含两部分功能:配置限流规则和提供编程接口(RateLimiter 类)验证请求是否被限流。不过,作为通用的框架,除了功能性需求之外,非功能性需求也非常重要,有时候会决定一个框架的成败,比如,框架的易用性、扩展性、灵活性、性能、容错性等。

对于限流框架,我们来看它都有哪些非功能性需求。

易用性方面,我们希望限流规则的配置、编程接口的使用都很简单。我们希望提供各种不同的限流算法,比如基于内存的单机限流算法、基于 Redis 的分布式限流算法,能够让使用者自由选择。除此之外,因为大部分项目都是基于 Spring 开发的,我们还希望限流框架能非常方便地集成到使用 Spring 框架的项目中。
扩展性、灵活性方面,我们希望能够灵活地扩展各种限流算法。同时,我们还希望支持不同格式(JSON、YAML、XML 等格式)、不同数据源(本地文件配置或 Zookeeper 集中配置等)的限流规则的配置方式。

性能方面,因为每个接口请求都要被检查是否限流,这或多或少会增加接口请求的响应时间。而对于响应时间比较敏感的接口服务来说,我们要让限流框架尽可能低延迟,尽可能减少对接口请求本身响应时间的影响。

容错性方面,接入限流框架是为了提高系统的可用性、稳定性,不能因为限流框架的异常,反过来影响到服务本身的可用性。所以,限流框架要有高度的容错性。比如,分布式限流算法依赖集中存储器 Redis。如果 Redis 挂掉了,限流逻辑无法正常运行,这个时候业务接口也要能正常服务才行。

设计

分析环节跟之前讲过的面向对象分析很相似,都是做需求的梳理。但是,项目实战中的设计和实现,跟面向对象设计和实现就不是一回事儿了。这里的“设计”指的是系统设计,主要是划分模块,对模块进行设计。这里的“实现”实际上等于面向对象设计加实现。因为我们前面讲到,面向对象设计与实现是聚焦在代码层面的,主要产出的是类的设计和实现。

我们分限流规则、限流算法、限流模式、集成使用这 4 个模块,来讲解限流框架的设计思路。看如何通过合理的设计,实现一个满足易用、易扩展、灵活、低延时、高容错等非功能性需求的限流框架。

限流规则

框架需要定义限流规则的语法格式,包括调用方、接口、限流阈值、时间粒度这几个元素。框架用户按照这个语法格式来配置限流规则。我举了一个例子来说明一下,如下所示。其中,unit 表示限流时间粒度,默认情况下是 1 秒。limit 表示在 unit 时间粒度内最大允许的请求次数。拿第一条规则来举例,它表示的意思就是:调用方 app-1 对接口 /v1/user 每分钟的最大请求次数不能超过 100 次。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
configs:
- appId: app-1
limits:
- api: /v1/user
limit: 100
unit:60
- api: /v1/order
limit: 50
- appId: app-2
limits:
- api: /v1/user
limit: 50
- api: /v1/order
limit: 50

对于限流时间粒度的选择,我们既可以选择限制 1 秒钟内不超过 1000 次,也可以选择限制 10 毫秒内不超过 10 次,还可以选择限制 1 分钟内不超过 6 万次。虽然看起来这几种限流规则是等价的,但过大的时间粒度会达不到限流的效果。比如,有可能 6 万次请求集中在 1 秒中到达,限制 1 分钟不超过 6 万次,就起不到保护的作用;相反,因为接口访问在细时间粒度上随机性很大,并不会很均匀。过小的时间粒度,会误杀很多本不应该限流的请求。所以,尽管越细的时间粒度限流整形效果越好,流量曲线越平滑,但也并不是时间粒度越小越合适。

我们知道,Spring 框架支持各种格式的配置文件,比如 XML、YAML、Porperties 等。除此之外,基于约定优于配置原则,Spring 框架用户只需要将配置文件按照约定来命名,并且放置到约定的路径下,Spring 框架就能按照约定自动查找和加载配置文件。

大部分 Java 程序员已经习惯了 Spring 的配置方式,基于我们前面讲的最小惊奇原则,在限流框架中,我们也延续 Spring 的配置方式,支持 XML、YAML、Properties 等几种配置文件格式,同时,约定默认的配置文件名为 ratelimiter-rule.yaml,默认放置在 classpath 路径中。

除此之外,为了提高框架的兼容性、易用性,除了刚刚讲的本地文件的配置方式之外,我们还希望兼容从其他数据源获取配置的方式,比如 Zookeeper 或者自研的配置中心。

限流算法

常见的限流算法有:固定时间窗口限流算法、滑动时间窗口限流算法、令牌桶限流算法、漏桶限流算法。其中,固定时间窗口限流算法最简单。我们只需要选定一个起始时间起点,之后每来一个接口请求,我们都给计数器(记录当前时间窗口内的访问次数)加一,如果在当前时间窗口内,根据限流规则(比如每秒钟最大允许 100 次接口请求),累加访问次数超过限流值(比如 100 次),就触发限流熔断,拒绝接口请求。当进入下一个时间窗口之后,计数器清零重新计数。

不过,固定时间窗口的限流算法的缺点也很明显。这种算法的限流策略过于粗略,无法应对两个时间窗口临界时间内的突发流量。我们来举一个例子。假设我们限流规则为每秒钟不超过 100 次接口请求。第一个 1 秒时间窗口内,100 次接口请求都集中在最后的 10 毫秒内,在第二个 1 秒时间窗口内,100 次接口请求都集中在最开始的 10 毫秒内。虽然两个时间窗口内流量都符合限流要求 (小于等于 100 个接口请求),但在两个时间窗口临界的 20 毫秒内集中有 200 次接口请求,固定时间窗口限流算法没法对这种情况进行限流,集中在这 20 毫秒内的 200 次请求有可能会压垮系统。

为了让流量更加平滑,于是就有了更加高级的滑动时间窗口限流算法、令牌桶限流算法和漏桶限流算法。

尽管固定时间窗口限流算法没法做到让流量很平滑,但大部分情况下,它已经够用了。默认情况下,框架使用固定时间窗口限流算法做限流。不过,考虑到框架的扩展性,我们需要预先做好设计,预留好扩展点,方便今后扩展其他限流算法。除此之外,为了提高框架的易用性、灵活性,我们最好将其他几种常用的限流算法,也在框架中实现出来,供框架用户根据自己业务场景自由选择。

限流模式

我们把限流模式分为两种:单机限流和分布式限流。所谓单机限流,就是针对单个实例的访问频率进行限制。注意这里的单机并不是真的一台物理机器,而是一个服务实例,因为有可能一台物理机器部署多个实例。所谓的分布式限流,就是针对某个服务的多个实例的总的访问频率进行限制。我举个例子来解释一下。

假设我们开发了一个用户相关的微服务,为了提高服务能力,我们部署了 5 个实例。我们限制某个调用方,对单个实例的某个接口的访问频率,不能超过 100 次 / 秒。这就是单机限流。我们限制某个调用方,对 5 个实例的某个接口的总访问频率,不能超过 500 次 / 秒。这就是所谓的分布式限流。

从实现的角度来分析,单机限流和分布式限流的主要区别在接口访问计数器的实现。单机限流只需要在单个实例中维护自己的接口请求计数器。而分布式限流需要集中管理计数器(比如使用 Redis 存储接口访问计数),这样才能做到多个实例对同一个计数器累加计数,以便实现对多个实例总访问频率的限制。

前面我们讲到框架要高容错,不能因为框架的异常,影响到集成框架的应用的可用性和稳定性。除此之外,我们还讲到框架要低延迟。限流逻辑的执行不能占用太长时间,不能或者很少影响接口请求本身的响应时间。因为分布式限流基于外部存储 Redis,网络通信成本较高,实际上,高容错、低延迟设计的主要场景就是基于 Redis 实现的分布式限流。

对于 Redis 的各种异常情况,我们处理起来并不难,捕获并封装为统一的异常,向上抛出或者吞掉就可以了。比较难处理的是 Redis 访问超时。Redis 访问超时会严重影响接口的响应时间,甚至导致接口请求超时。所以,在访问 Redis 时,我们需要设置合理的超时时间。一旦超时,我们就判定为限流失效,继续执行接口请求。Redis 访问超时时间的设置既不能太大也不能太小,太大可能会影响到接口的响应时间,太小可能会导致太多的限流失效。我们可以通过压测或者线上监控,获取到 Redis 访问时间分布情况,再结合接口可以容忍的限流延迟时间,权衡设置一个较合理的 Redis 超时时间。

集成使用

限流框架也应该满足低侵入松耦合设计思想。因为框架是需要集成到应用中使用的,我们希望框架尽可能低侵入,与业务代码松耦合,替换、删除起来也更容易些。

我们还可以开发一个 Ratelimiter-Spring 类库,能够方便使用了 Spring 的项目集成限流框架,将易用性做到极致。

实现

V1 版本功能需求

在 V1 版本中,对于接口类型,我们只支持 HTTP 接口(也就 URL)的限流,暂时不支持 RPC 等其他类型的接口限流。对于限流规则,我们只支持本地文件配置,配置文件格式只支持 YAML。对于限流算法,我们只支持固定时间窗口算法。对于限流模式,我们只支持单机限流。

尽管功能“裁剪”之后,V1 版本实现起来简单多了,但在编程开发的同时,我们还是要考虑代码的扩展性,预留好扩展点。这样,在接下来的新版本开发中,我们才能够轻松地扩展新的限流算法、限流模式、限流规则格式和数据源。

最小原型代码

项目实战中的实现等于面向对象设计加实现。而面向对象设计与实现一般可以分为四个步骤:划分职责识别类、定义属性和方法、定义类之间的交互关系、组装类并提供执行入口。

在平时的工作中,大部分程序员都是边写代码边做设计,边思考边重构,并不会严格地按照步骤,先做完类的设计再去写代码。而且,如果想一下子就把类设计得很好、很合理,也是比较难的。过度追求完美主义,只会导致迟迟下不了手,连第一行代码也敲不出来。所以,我的习惯是,先完全不考虑设计和代码质量,先把功能完成,先把基本的流程走通,哪怕所有的代码都写在一个类中也无所谓。然后,我们再针对这个 MVP 代码(最小原型代码)做优化重构,比如,将代码中比较独立的代码块抽离出来,定义成独立的类或函数。

我们按照先写 MVP 代码的思路,把代码实现出来。它的目录结构如下所示。代码非常简单,只包含 5 个类,接下来,我们针对每个类一一讲解一下。

1
2
3
4
5
6
7
8
com.monochrome.ratelimiter
--RateLimiter
com.monochrome.ratelimiter.rule
--ApiLimit
--RuleConfig
--RateLimitRule
com.monochrome.ratelimiter.alg
--RateLimitAlg
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
package com.monochrome.ratelimiter;

import com.monochrome.ratelimiter.alg.RateLimitAlg;
import com.monochrome.ratelimiter.rule.ApiLimit;
import com.monochrome.ratelimiter.rule.RateLimitRule;
import com.monochrome.ratelimiter.rule.RuleConfig;
import com.sun.org.slf4j.internal.Logger;
import com.sun.org.slf4j.internal.LoggerFactory;
import org.yaml.snakeyaml.Yaml;

import java.io.IOException;
import java.io.InputStream;
import java.util.concurrent.ConcurrentHashMap;

/**
* @author monochrome
* @date 2022/10/23
*/

public class RateLimiter {
private static final Logger log = LoggerFactory.getLogger(RateLimiter.class);
// 为每个api在内存中存储限流计数器
private ConcurrentHashMap<String, RateLimitAlg> counters = new ConcurrentHashMap<>();
private RateLimitRule rule;

public RateLimiter() {
// 将限流规则配置文件rate-limiter-rule.yaml中的内容读取到RuleConfig中
InputStream in = null;
RuleConfig ruleConfig = null;
try {
in = this.getClass().getResourceAsStream("./rate-limiter-rule.yaml");
if (in != null) {
Yaml yaml = new Yaml();
ruleConfig = yaml.loadAs(in, RuleConfig.class);
}
} finally {
if (in != null) {
try {
in.close();
} catch (IOException e) {
log.error("close file error:{}", e);
}
}
}

// 将限流规则构建成支持快速查找的数据结构RateLimitRule
this.rule = new RateLimitRule(ruleConfig);
}

public boolean limit(String appId, String url) {
ApiLimit apiLimit = rule.getLimit(appId, url);
if (apiLimit == null) {
return true;
}

// 获取api对应在内存中的限流计数器(rateLimitCounter)
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;
}
}

// 判断是否限流
return rateLimitCounter.tryAcquire();
}
}

RateLimiter 类用来串联整个限流流程。它先读取限流规则配置文件,映射为内存中的 Java 对象(RuleConfig),然后再将这个中间结构构建成一个支持快速查询的数据结构(RateLimitRule)。除此之外,这个类还提供供用户直接使用的最顶层接口(limit() 接口)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
package com.monochrome.ratelimiter.rule;

import java.util.List;

/**
* @author monochrome
* @date 2022/10/23
*/

public class RuleConfig {
private List<AppRuleConfig> configs;

public List<AppRuleConfig> getConfigs() {
return configs;
}

public void setConfigs(List<AppRuleConfig> configs) {
this.configs = configs;
}

public static class AppRuleConfig {
private String appId;
private List<ApiLimit> limits;

public AppRuleConfig() {}

public AppRuleConfig(String appId, List<ApiLimit> limits) {
this.appId = appId;
this.limits = limits;
}

public String getAppId() {
return appId;
}

public void setAppId(String appId) {
this.appId = appId;
}

public List<ApiLimit> getLimits() {
return limits;
}

public void setLimits(List<ApiLimit> limits) {
this.limits = limits;
}
}
}

从代码中,我们可以看出来,RuleConfig 类嵌套了另外两个类 AppRuleConfig 和 ApiLimit。这三个类跟配置文件的三层嵌套结构完全对应。我把对应关系标注在了下面的示例中,你可以对照着代码看下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
configs:          #<!--对应RuleConfig-->
- appId: app-1 #<!--对应AppRuleConfig-->
limits:
- api: /v1/user #<!--对应ApiLimit-->
limit: 100
unit: 60
- api: /v1/order
limit: 50
- appId: app-2
limits:
- api: /v1/user
limit: 50
- api: /v1/order
limit: 50

你可能会好奇,有了 RuleConfig 来存储限流规则,为什么还要 RateLimitRule 类呢?这是因为,限流过程中会频繁地查询接口对应的限流规则,为了尽可能地提高查询速度,我们需要将限流规则组织成一种支持按照 URL 快速查询的数据结构。考虑到 URL 的重复度比较高,且需要按照前缀来匹配,我们这里选择使用 Trie 树这种数据结构。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package com.monochrome.ratelimiter.rule;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
* @author monochrome
* @date 2022/10/23
*/

public class RateLimitRule {

Map<String, ApiLimit> apiLimitMap = new HashMap<>();

public RateLimitRule(RuleConfig ruleConfig) {
List<RuleConfig.AppRuleConfig> configs = ruleConfig.getConfigs();
for (RuleConfig.AppRuleConfig config : configs) {
String appId = config.getAppId();
List<ApiLimit> limits = config.getLimits();
for (ApiLimit limit : limits) {
apiLimitMap.putIfAbsent(appId + ":" + limit.getApi(), limit);
}
}
}

public ApiLimit getLimit(String appId, String api) {
return apiLimitMap.get(appId + ":" + api);
}

}

最后,我们看下 RateLimitAlg 这个类。

这个类是限流算法实现类。它实现了最简单的固定时间窗口限流算法。每个接口都要在内存中对应一个 RateLimitAlg 对象,记录在当前时间窗口内已经被访问的次数。RateLimitAlg 类的代码如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
package com.monochrome.ratelimiter.alg;

import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Stopwatch;

import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

/**
* @author monochrome
* @date 2022/10/23
*/

public class RateLimitAlg {
/* timeout for {@code Lock.tryLock() }. */
private static final long TRY_LOCK_TIMEOUT = 200L; // 200ms.
private Stopwatch stopwatch;
private AtomicInteger currentCount = new AtomicInteger(0);
private final int limit;
private Lock lock = new ReentrantLock();

public RateLimitAlg(int limit) {
this(limit, Stopwatch.createStarted());
}

@VisibleForTesting
protected RateLimitAlg(int limit, Stopwatch stopwatch) {
this.limit = limit;
this.stopwatch = stopwatch;
}

public boolean tryAcquire() throws RuntimeException {
int updatedCount = currentCount.incrementAndGet();
if (updatedCount <= limit) {
return true;
}

try {
if (lock.tryLock(TRY_LOCK_TIMEOUT, TimeUnit.MILLISECONDS)) {
try {
if (stopwatch.elapsed(TimeUnit.MILLISECONDS) > TimeUnit.SECONDS.toMillis(1)) {
currentCount.set(0);
stopwatch.reset();
}
updatedCount = currentCount.incrementAndGet();
return updatedCount <= limit;
} finally {
lock.unlock();
}
} else {
throw new RuntimeException("tryAcquire() wait lock too long:" + TRY_LOCK_TIMEOUT + "ms");
}
} catch (InterruptedException e) {
throw new RuntimeException("tryAcquire() is interrupted by lock-time-out.", e);
}
}
}

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 类提炼抽象接口。

按照这个修改思路,我们对代码进行重构。重构之后的目录结构如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 重构前:
com.xzg.ratelimiter
--RateLimiter
com.xzg.ratelimiter.rule
--ApiLimit
--RuleConfig
--RateLimitRule
com.xzg.ratelimiter.alg
--RateLimitAlg

// 重构后:
com.xzg.ratelimiter
--RateLimiter(有所修改)
com.xzg.ratelimiter.rule
--ApiLimit(不变)
--RuleConfig(不变)
--RateLimitRule(抽象接口)
--TrieRateLimitRule(实现类,就是重构前的RateLimitRule)
com.xzg.ratelimiter.rule.parser
--RuleConfigParser(抽象接口)
--YamlRuleConfigParser(Yaml格式配置文件解析类)
--JsonRuleConfigParser(Json格式配置文件解析类)
com.xzg.ratelimiter.rule.datasource
--RuleConfigSource(抽象接口)
--FileRuleConfigSource(基于本地文件的配置类)
com.xzg.ratelimiter.alg
--RateLimitAlg(抽象接口)
--FixedTimeWinRateLimitAlg(实现类,就是重构前的RateLimitAlg)

其中,RateLimiter 类重构之后的代码如下所示。代码的改动集中在构造函数中,通过调用 RuleConfigSource 来实现了限流规则配置文件的加载。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class RateLimiter {
private static final Logger log = LoggerFactory.getLogger(RateLimiter.class);
// 为每个api在内存中存储限流计数器
private ConcurrentHashMap<String, RateLimitAlg> counters = new ConcurrentHashMap<>();
private RateLimitRule rule;

public RateLimiter() {
// 主要修改在这儿,调用RuleConfigSource类来实现配置加载,支持多种配置文件的读取
RuleConfigSource ruleConfigSource = new FileRuleConfigSource();
RuleConfig ruleConfig = ruleConfigSource.load();
// 将限流规则构建成支持快速查找的数据结构RateLimitRule
this.rule = new RateLimitRule(ruleConfig);
}

public boolean limit(String appId, String url) {
//...
}
}

我们再来看下,从 RateLimiter 中拆分出来的限流规则加载的逻辑,现在是如何设计的。这部分涉及的类主要是下面几个。我把关键代码也贴在了下面。其中,各个 Parser 和 RuleConfigSource 类的设计有点类似策略模式,如果要添加新的格式的解析,只需要实现对应的 Parser 类,并且添加到 FileRuleConfig 类的 PARSER_MAP 中就可以了。

1
2
3
4
5
6
7
com.xzg.ratelimiter.rule.parser
--RuleConfigParser(抽象接口)
--YamlRuleConfigParser(Yaml格式配置文件解析类)
--JsonRuleConfigParser(Json格式配置文件解析类)
com.xzg.ratelimiter.rule.datasource
--RuleConfigSource(抽象接口)
--FileRuleConfigSource(基于本地文件的配置类)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
package com.monochrome.ratelimiter.rule.parser;

import com.monochrome.ratelimiter.rule.RuleConfig;

import java.io.InputStream;

/**
* @author monochrome
* @date 2022/10/25
*/
public interface RuleConfigParser {
RuleConfig parse(String configText);
RuleConfig parse(InputStream inputStream);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package com.monochrome.ratelimiter.rule.parser;

import com.monochrome.ratelimiter.rule.RuleConfig;
import org.yaml.snakeyaml.Yaml;

import java.io.InputStream;

/**
* @author monochrome
* @date 2022/10/25
*/
public class YamlRuleConfigParser implements RuleConfigParser {
@Override
public RuleConfig parse(String configText) {
Yaml yaml = new Yaml();
return yaml.loadAs(configText, RuleConfig.class);
}

@Override
public RuleConfig parse(InputStream inputStream) {
Yaml yaml = new Yaml();
return yaml.loadAs(inputStream, RuleConfig.class);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
package com.monochrome.ratelimiter.rule.datasource;

import com.monochrome.ratelimiter.rule.RuleConfig;

/**
* @author monochrome
* @date 2022/10/25
*/
public interface RuleConfigSource {

RuleConfig load();

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
package com.monochrome.ratelimiter.rule.datasource;

import com.monochrome.ratelimiter.rule.RuleConfig;
import com.monochrome.ratelimiter.rule.parser.JsonRuleConfigParser;
import com.monochrome.ratelimiter.rule.parser.RuleConfigParser;
import com.monochrome.ratelimiter.rule.parser.YamlRuleConfigParser;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.io.InputStream;
import java.util.HashMap;
import java.util.Map;

/**
* @author monochrome
* @date 2022/10/25
*/
public class FileRuleConfigSource implements RuleConfigSource{

private static final Logger log = LoggerFactory.getLogger(FileRuleConfigSource.class);
public static final String API_LIMIT_CONFIG_NAME = "rate-limiter-rule";
public static final String YAML_EXTENSION = "yaml";
public static final String YML_EXTENSION = "yml";
public static final String JSON_EXTENSION = "json";
private static final String[] SUPPORT_EXTENSIONS = new String[]{YAML_EXTENSION, YML_EXTENSION, JSON_EXTENSION};
private static final Map<String, RuleConfigParser> PARSER_MAP = new HashMap<>();

static {
PARSER_MAP.put(YAML_EXTENSION, new YamlRuleConfigParser());
PARSER_MAP.put(YML_EXTENSION, new YamlRuleConfigParser());
PARSER_MAP.put(JSON_EXTENSION, new JsonRuleConfigParser());
}

@Override
public RuleConfig load() {
for (String extension : SUPPORT_EXTENSIONS) {
try (InputStream in = this.getClass().getResourceAsStream("/" + getFileNameByExt(extension))) {
if (in != null) {
RuleConfigParser ruleConfigParser = PARSER_MAP.get(extension);
return ruleConfigParser.parse(in);
}
} catch (IOException e) {
log.error("close file error:{}", e);
}
}
return null;
}

private String getFileNameByExt(String extension) {
return API_LIMIT_CONFIG_NAME + "." + extension;
}
}

写个UT测试下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
configs:          #<!--对应RuleConfig-->
- appId: app-1 #<!--对应AppRuleConfig-->
limits:
- api: /v1/user #<!--对应ApiLimit-->
limit: 50
unit: 60
- api: /v1/order
limit: 50
- appId: app-2
limits:
- api: /v1/user
limit: 50
- api: /v1/order
limit: 50
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package com.monochrome.ratelimiter;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

/**
* @author monochrome
* @date 2022/10/23
*/
class RateLimiterTest {

private RateLimiter rateLimiter;

@BeforeEach
void setUp() {
rateLimiter = new RateLimiter();
}

@Test
void limit() {
for (int i = 0; i < 50; i++) {
boolean limit = rateLimiter.limit("app-1", "/v1/user");
assertThat(limit).isTrue();
}
boolean limit2 = rateLimiter.limit("app-1", "/v1/user");
assertThat(limit2).isFalse();

}
}