mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-16 14:13:46 +08:00
mod
This commit is contained in:
115
极客时间专栏/设计模式之美/开源与项目实战:项目实战/90 | 项目实战一:设计实现一个支持各种算法的限流框架(分析).md
Normal file
115
极客时间专栏/设计模式之美/开源与项目实战:项目实战/90 | 项目实战一:设计实现一个支持各种算法的限流框架(分析).md
Normal file
@@ -0,0 +1,115 @@
|
||||
<audio id="audio" title="90 | 项目实战一:设计实现一个支持各种算法的限流框架(分析)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f7/d8/f77d94d7b50786be11d92bdba12df4d8.mp3"></audio>
|
||||
|
||||
结束了开源实战,从今天开始我们正式进入项目实战模块。在开源实战中,我带你一块剖析了几个著名的开源项目,比如Spring、MyBatis、Google Guava等,剖析了它们背后蕴含的设计思想、原则和模式。
|
||||
|
||||
如果说前面讲开源实战是学习别人怎么做,那现在我们讲项目实战就是带你一块做。在这个过程中,我会带你实践之前学过的设计思想、原则和模式,给你展示怎么应用这些理论知识,让你开发出跟前面那些著名开源项目一样优秀的软件。
|
||||
|
||||
在项目实战中,我找了三个稍微有点难度的项目:限流框架、幂等框架、灰度发布组件,带你一起来实现。针对每一个项目,我都会从分析、设计、实现这三个部分来讲解。当然,还是那句老话,项目本身的讲解不是重点,重点还是学习它们背后的开发套路。这才是最有价值的部分。
|
||||
|
||||
接下来的三节课,我们讲第一个实战项目,限流框架。今天,我们先讲其中的分析环节,介绍项目背景,分析项目需求。
|
||||
|
||||
话不多说,让我们正式开始今天的学习吧!
|
||||
|
||||
## 项目背景
|
||||
|
||||
我们先来讲下需求诞生的背景。这个背景跟我们下一个实战项目幂等框架也有关系,所以要从很久很久讲起,希望你能耐心看完,不然后面可能会看不懂。
|
||||
|
||||
公司成立初期,团队人少。公司集中精力开发一个金融理财产品(我们把这个项目叫做X项目)。整个项目只做了简单的前后端分离,后端的所有代码都在一个GitHub仓库中,整个后端作为一个应用来部署,没有划分微服务。
|
||||
|
||||
遇到了行业风口,公司发展得不错,公司开始招更多人,开发更多的金融产品,比如专注房贷的理财产品、专注供应链的产品、专注消费贷的借款端产品等等。在产品形态上,每个金融产品都做成了独立的App。
|
||||
|
||||
对于不同的金融产品,尽管移动端长得不一样,但是后端的很多功能、代码都是可以复用的。为了快速上线,针对每个应用,公司都成立一个新的团队,然后拷贝X项目的代码,在此基础之上修改、添加新的功能。
|
||||
|
||||
这样成立新团队,拷贝老代码,改改就能上线一个新产品的开发模式,在一开始很受欢迎。产品上线快,也给公司赢得了竞争上的优势。但时间一长,这样的开发模式暴露出来的问题就越来越多了。而且随着公司的发展,公司也过了急速扩张期,人招得太多,公司开始考虑研发效率问题了。
|
||||
|
||||
因为所有的项目的代码都是从X项目拷贝来的,多个团队同时维护相似的代码,显然是重复劳动,协作起来也非常麻烦。任何团队发现代码的bug,都要同步到其他团队做相同的修改。而且,各个团队对代码独立迭代,改得面目全非,即便要添加一个通用的功能,每个团队也都要基于自己的代码再重复开发。
|
||||
|
||||
除此之外,公司成立初期,各个方面条件有限,只能招到开发水平一般的员工,而且追求快速上线,所以,X项目的代码质量很差,结构混乱、命名不规范、到处是临时解决方案、埋了很多坑,在烂代码之上不停地堆砌烂代码,时间长了,代码的可读性越来越差、维护成本越来越高,甚至高过了重新开发的成本。
|
||||
|
||||
这个时候该怎么办呢?如果让你出出主意,你有什么好的建议吗?
|
||||
|
||||
我们可以把公共的功能、代码抽离出来,形成一个独立的项目,部署成一个公共服务平台。所有金融产品的后端还是参照MVC三层架构独立开发,不过,它们只实现自己特有的功能,对于一些公共的功能,通过远程调用公共服务平台提供的接口来实现。
|
||||
|
||||
这里提到的公共服务平台,有点类似现在比较火的“中台”或“微服务”。不过,为了减少部署、维护多个微服务的成本,我们把所有公共的功能,放到一个项目中开发,放到一个应用中部署。只不过,我们要未雨绸缪,事先按照领域模型,将代码的模块化做好,等到真的有哪个模块的接口调用过于集中,性能出现瓶颈的时候,我们再把它拆分出来,设计成独立的微服务来开发和部署。
|
||||
|
||||
经过这样的拆分之后,我们可以指派一个团队,集中维护公共服务平台的代码。开发一个新的金融产品,也只需要更少的人员来参与,因为他们只需要开发、维护产品特有的功能和代码就可以了。整体上,维护成本降低了。除此之外,公共服务平台的代码集中到了一个团队手里,重构起来不需要协调其他团队和项目,也便于我们重构、改善代码质量。
|
||||
|
||||
## 需求背景
|
||||
|
||||
对于公共服务平台来说,接口请求来自很多不同的系统(后面统称为调用方),比如各种金融产品的后端系统。在系统上线一段时间里,我们遇到了很多问题。比如,因为调用方代码bug 、不正确地使用服务(比如启动Job来调用接口获取数据)、业务上面的突发流量(比如促销活动),导致来自某个调用方的接口请求数突增,过度争用服务的线程资源,而来自其他调用方的接口请求,因此来不及响应而排队等待,导致接口请求的响应时间大幅增加,甚至出现超时。
|
||||
|
||||
为了解决这个问题,你有什么好的建议呢?我先来说说我的。
|
||||
|
||||
我们可以开发接口限流功能,限制每个调用方对接口请求的频率。当超过预先设定的访问频率后,我们就触发限流熔断,比如,限制调用方app-1对公共服务平台总的接口请求频率不超过1000次/秒,超过之后的接口请求都会被拒绝。除此之外,为了更加精细化地限流,除了限制每个调用方对公共服务平台总的接口请求频率之外,我们还希望能对单独某个接口的访问频率进行限制,比如,限制app-1对接口/user/query的访问频率为每秒钟不超过100次。
|
||||
|
||||
我们希望开发出来的东西有一定的影响力,即便做不到在行业内有影响力,起码也要做到在公司范围内有影响力。所以,从一开始,我们就不想把这个限流功能,做成只有我们项目可用。我们希望把它开发成一个通用的框架,能够应用到各个业务系统中,甚至可以集成到微服务治理平台中。实际上,这也体现了业务开发中要具备的抽象意识、框架意识。我们要善于识别出通用的功能模块,将它抽象成通用的框架、组件、类库等。
|
||||
|
||||
## 需求分析
|
||||
|
||||
刚刚我们花了很大篇幅来介绍项目背景和需求背景,接下来,我们再对需求进行更加详细的分析和整理。
|
||||
|
||||
前面我们已经讲过一些需求分析的方法,比如画线框图、写用户用例、测试驱动开发等等。这里,我们借助用户用例和测试驱动开发的思想,先去思考,如果框架最终被开发出来之后,它会如何被使用。我一般会找一个框架的应用场景,针对这个场景写一个框架使用的Demo程序,这样能够很直观地看到框架长什么样子。知道了框架应该长什么样,就相当于应试教育中确定了考试题目。针对明确的考题去想解决方案,这是我们多年应试教育锻炼之后最擅长做的。
|
||||
|
||||
对于限流框架来说,我们来看下它的应用场景。
|
||||
|
||||
首先我们需要设置限流规则。为了做到在不修改代码的前提下修改规则,我们一般会把规则放到配置文件中(比如XML、YAML配置文件)。在集成了限流框架的应用启动的时候,限流框架会将限流规则,按照事先定义的语法,解析并加载到内存中。我写了一个限流规则的Demo配置,如下所示:
|
||||
|
||||
```
|
||||
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,然后验证是否对此调用方的这个接口请求进行限流。
|
||||
|
||||
```
|
||||
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挂掉了,限流逻辑无法正常运行,这个时候业务接口也要能正常服务才行。
|
||||
|
||||
## 重点回顾
|
||||
|
||||
好了,今天的内容到此就讲完了。我们一块来总结回顾一下,你需要重点掌握的内容。
|
||||
|
||||
今天,我们主要对限流框架做了大的项目背景、需求背景介绍,以及更加具体的需求分析,明确了要做什么,为下两节课的设计和实现做准备。
|
||||
|
||||
从今天的讲解中,不知道你有没有发现,基本的功能需求其实没有多少,但将非功能性需求考虑进去之后,明显就复杂了很多。还是那句老话,**写出能用的代码很简单,写出好用的代码很难。**对于限流框架来说,非功能性需求是设计与实现的难点。怎么做到易用、灵活、可扩展、低延迟、高容错,才是开发的重点,也是我们接下来两节课要讲解的重点。
|
||||
|
||||
除此之外,今天我们还实践了一些需求分析的方法,比如画线框图、写用户用例、测试驱动开发等等。针对限流框架,我们借助用户用例和测试驱动开发的思想,先去思考,如果框架最终被开发出来之后,它会如何被使用。针对具体的场景去做分析,更加清晰直观。
|
||||
|
||||
## 课堂讨论
|
||||
|
||||
在今天介绍项目背景的时候,我讲了公司遇到的一个开发问题,并提出了解决方案,你也可以留言分享一下,你所在公司或者项目中,遇到过哪些比较头疼的开发问题,又是如何解决的?
|
||||
|
||||
欢迎留言和我分享你的想法。如果有收获,也欢迎你把这篇文章分享给你的朋友。
|
||||
@@ -0,0 +1,87 @@
|
||||
<audio id="audio" title="91 | 项目实战一:设计实现一个支持各种算法的限流框架(设计)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d0/71/d018ed6b91e0ba93989da935c5730271.mp3"></audio>
|
||||
|
||||
上一节课,我们介绍了限流框架产生的项目背景,并且对需求做了分析,这其中包括功能性需求和非功能性需求,算是在正式开始设计之前的一个铺垫。
|
||||
|
||||
前面提到,我们把项目实战分为分析、设计、实现三部分来讲解。其中,分析环节跟之前讲过的面向对象分析很相似,都是做需求的梳理。但是,项目实战中的设计和实现,跟面向对象设计和实现就不是一回事儿了。这里的“设计”指的是系统设计,主要是划分模块,对模块进行设计。这里的“实现”实际上等于面向对象设计加实现。因为我们前面讲到,面向对象设计与实现是聚焦在代码层面的,主要产出的是类的设计和实现。
|
||||
|
||||
今天,我们分限流规则、限流算法、限流模式、集成使用这4个模块,来讲解限流框架的设计思路。上节课我们提到,限流框架的基本功能非常简单,复杂在于它的非功能性需求,所以,我们今天讲解的重点是,看如何通过合理的设计,实现一个满足易用、易扩展、灵活、低延时、高容错等非功能性需求的限流框架。
|
||||
|
||||
话不多说,让我们正式开始今天的学习吧!
|
||||
|
||||
## 限流规则
|
||||
|
||||
框架需要定义限流规则的语法格式,包括调用方、接口、限流阈值、时间粒度这几个元素。框架用户按照这个语法格式来配置限流规则。我举了一个例子来说明一下,如下所示。其中,unit表示限流时间粒度,默认情况下是1秒。limit表示在unit时间粒度内最大允许的请求次数。拿第一条规则来举例,它表示的意思就是:调用方app-1对接口/v1/user每分钟的最大请求次数不能超过100次。
|
||||
|
||||
```
|
||||
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超时时间。
|
||||
|
||||
## 集成使用
|
||||
|
||||
前面剖析Spring框架的时候,我们讲到低侵入松耦合设计思想。限流框架也应该满足这个设计思想。因为框架是需要集成到应用中使用的,我们希望框架尽可能低侵入,与业务代码松耦合,替换、删除起来也更容易些。
|
||||
|
||||
除此之外,在剖析MyBatis框架的时候,我们讲到MyBatis框架是为了简化数据库编程。实际上,为了进一步简化开发,MyBatis还提供了MyBatis-Spring类库,方便在使用了Spring框架的项目中集成MyBatis框架。我们也可以借鉴MyBatis-Spring,开发一个Ratelimiter-Spring类库,能够方便使用了Spring的项目集成限流框架,将易用性做到极致。
|
||||
|
||||
## 重点回顾
|
||||
|
||||
好了,今天的内容到此就讲完了。我们一块来总结回顾一下,你需要重点掌握的内容。
|
||||
|
||||
我们将这个限流框架划分为限流规则、限流算法、限流模式、集成使用者这四个模块来分析讲解。除了功能方面的设计之外,我们重点讲了如何满足易用、灵活、易扩展、低延迟、高容错这些非功能性需求。
|
||||
|
||||
针对限流规则,大部分Java程序员已经习惯了Spring的配置方式。基于最小惊奇原则,在限流框架中,我们也延续Spring的配置方式,支持XML、YAML、Properties等几种配置文件格式。同时,借鉴Spring的约定优于配置设计原则,限流框架用户只需要将配置文件按照约定来命名,并且放置到约定的路径下,框架就能按照约定自动查找和加载配置文件。除此之外,为了提高框架的兼容性、易用性,除了本地文件的配置方式之外,我们还希望兼容从其他数据源获取配置的方式,比如Zookeeper或者自研的配置中心。
|
||||
|
||||
针对限流算法,尽管固定时间窗口限流算法没法做到让流量很平滑,但大部分情况下,它已经够用了。默认情况下,框架使用固定时间窗口限流算法做限流。不过,考虑到框架的扩展性,我们需要预先做好设计,预留好扩展点,方便今后扩展其他限流算法。除此之外,为了提高框架的易用性、灵活性,我们将其他几种常用的限流算法也在框架中实现出来,供框架用户根据自己的业务场景自由选择。
|
||||
|
||||
针对限流模式,因为分布式限流基于外部存储Redis,网络通信成本较高,框架的高容错和低延迟的设计,主要是针对基于Redis的分布式限流模式。不能因为Redis的异常,影响到集成框架的应用的可用性和稳定性。不能因为Redis访问超时,导致接口访问超时。
|
||||
|
||||
针对集成使用,我们希望框架低侵入,跟业务代码松耦合。应用集成框架的代码,尽可能集中、不分散,这样删除、替换起来就容易很多。除此之外,为了将框架的易用性做到极致,我们借鉴MyBatis-Spring类库,设计实现一个RateLimiter-Spring类库,方便集成了Spring框架的应用集成限流框架。
|
||||
|
||||
## 课堂讨论
|
||||
|
||||
今天,我们提到配置限流规则的时候,时间粒度不能太大,也不能太小,限流值也要设置得合理,太大起不到限流的作用,太小容易误杀。那请你思考一下,如何选择合理的时间粒度和限流值?如何验证设置的合理性?
|
||||
|
||||
欢迎留言和我分享你的想法。如果有收获,也欢迎你把这篇文章分享给你的朋友。
|
||||
411
极客时间专栏/设计模式之美/开源与项目实战:项目实战/92 | 项目实战一:设计实现一个支持各种算法的限流框架(实现).md
Normal file
411
极客时间专栏/设计模式之美/开源与项目实战:项目实战/92 | 项目实战一:设计实现一个支持各种算法的限流框架(实现).md
Normal file
@@ -0,0 +1,411 @@
|
||||
<audio id="audio" title="92 | 项目实战一:设计实现一个支持各种算法的限流框架(实现)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/3b/3a/3b72220f4afee703742990d9fcdd3d3a.mp3"></audio>
|
||||
|
||||
上一节课,我们介绍了如何通过合理的设计,来实现功能性需求的同时,满足易用、易扩展、灵活、低延迟、高容错等非功能性需求。在设计的过程中,我们也借鉴了之前讲过的一些开源项目的设计思想。比如,我们借鉴了Spring的低侵入松耦合、约定优于配置等设计思想,还借鉴了MyBatis通过MyBatis-Spring类库将框架的易用性做到极致等设计思路。
|
||||
|
||||
今天,我们讲解这样一个问题,针对限流框架的开发,如何做高质量的代码实现。说的具体点就是,如何利用之前讲过的设计思想、原则、模式、编码规范、重构技巧等,写出易读、易扩展、易维护、灵活、简洁、可复用、易测试的代码。
|
||||
|
||||
话不多少,让我们正式开始今天的学习吧!
|
||||
|
||||
## V1版本功能需求
|
||||
|
||||
我们前面提到,优秀的代码是重构出来的,复杂的代码是慢慢堆砌出来的。小步快跑、逐步迭代是我比较推崇的开发模式。所以,针对限流框架,我们也不用想一下子就做得大而全。况且,在专栏有限的篇幅内,我们也不可能将一个大而全的代码阐述清楚。所以,我们可以先实现一个包含核心功能、基本功能的V1版本。
|
||||
|
||||
针对上两节课中给出的需求和设计,我们重新梳理一下,看看有哪些功能要放到V1版本中实现。
|
||||
|
||||
在V1版本中,对于接口类型,我们只支持HTTP接口(也就URL)的限流,暂时不支持RPC等其他类型的接口限流。对于限流规则,我们只支持本地文件配置,配置文件格式只支持YAML。对于限流算法,我们只支持固定时间窗口算法。对于限流模式,我们只支持单机限流。
|
||||
|
||||
尽管功能“裁剪”之后,V1版本实现起来简单多了,但在编程开发的同时,我们还是要考虑代码的扩展性,预留好扩展点。这样,在接下来的新版本开发中,我们才能够轻松地扩展新的限流算法、限流模式、限流规则格式和数据源。
|
||||
|
||||
## 最小原型代码
|
||||
|
||||
上节课我们讲到,项目实战中的实现等于面向对象设计加实现。而面向对象设计与实现一般可以分为四个步骤:划分职责识别类、定义属性和方法、定义类之间的交互关系、组装类并提供执行入口。在[第14讲](https://time.geekbang.org/column/article/171767)中,我还带你用这个方法,设计和实现了一个接口鉴权框架。如果你印象不深刻了,可以回过头去再看下。
|
||||
|
||||
不过,我们前面也讲到,在平时的工作中,大部分程序员都是边写代码边做设计,边思考边重构,并不会严格地按照步骤,先做完类的设计再去写代码。而且,如果想一下子就把类设计得很好、很合理,也是比较难的。过度追求完美主义,只会导致迟迟下不了手,连第一行代码也敲不出来。所以,我的习惯是,先完全不考虑设计和代码质量,先把功能完成,先把基本的流程走通,哪怕所有的代码都写在一个类中也无所谓。然后,我们再针对这个MVP代码(最小原型代码)做优化重构,比如,将代码中比较独立的代码块抽离出来,定义成独立的类或函数。
|
||||
|
||||
我们按照先写MVP代码的思路,把代码实现出来。它的目录结构如下所示。代码非常简单,只包含5个类,接下来,我们针对每个类一一讲解一下。
|
||||
|
||||
```
|
||||
com.xzg.ratelimiter
|
||||
--RateLimiter
|
||||
com.xzg.ratelimiter.rule
|
||||
--ApiLimit
|
||||
--RuleConfig
|
||||
--RateLimitRule
|
||||
com.xzg.ratelimiter.alg
|
||||
--RateLimitAlg
|
||||
|
||||
```
|
||||
|
||||
**我们先来看下RateLimiter类。**代码如下所示:
|
||||
|
||||
```
|
||||
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() {
|
||||
// 将限流规则配置文件ratelimiter-rule.yaml中的内容读取到RuleConfig中
|
||||
InputStream in = null;
|
||||
RuleConfig ruleConfig = null;
|
||||
try {
|
||||
in = this.getClass().getResourceAsStream("/ratelimiter-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) throws InternalErrorException {
|
||||
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()接口)。
|
||||
|
||||
**我们再来看下RuleConfig和ApiLimit两个类。**代码如下所示:
|
||||
|
||||
```
|
||||
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;
|
||||
}
|
||||
//...省略getter、setter方法...
|
||||
}
|
||||
}
|
||||
|
||||
public class ApiLimit {
|
||||
private static final int DEFAULT_TIME_UNIT = 1; // 1 second
|
||||
private String api;
|
||||
private int limit;
|
||||
private int unit = DEFAULT_TIME_UNIT;
|
||||
|
||||
public ApiLimit() {}
|
||||
|
||||
public ApiLimit(String api, int limit) {
|
||||
this(api, limit, DEFAULT_TIME_UNIT);
|
||||
}
|
||||
|
||||
public ApiLimit(String api, int limit, int unit) {
|
||||
this.api = api;
|
||||
this.limit = limit;
|
||||
this.unit = unit;
|
||||
}
|
||||
// ...省略getter、setter方法...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
从代码中,我们可以看出来,RuleConfig类嵌套了另外两个类AppRuleConfig和ApiLimit。这三个类跟配置文件的三层嵌套结构完全对应。我把对应关系标注在了下面的示例中,你可以对照着代码看下。
|
||||
|
||||
```
|
||||
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
|
||||
|
||||
```
|
||||
|
||||
**我们再来看下RateLimitRule这个类。**
|
||||
|
||||
你可能会好奇,有了RuleConfig来存储限流规则,为什么还要RateLimitRule类呢?这是因为,限流过程中会频繁地查询接口对应的限流规则,为了尽可能地提高查询速度,我们需要将限流规则组织成一种支持按照URL快速查询的数据结构。考虑到URL的重复度比较高,且需要按照前缀来匹配,我们这里选择使用Trie树这种数据结构。我举了个例子解释一下,如下图所示。左边的限流规则对应到Trie树,就是图中右边的样子。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1c/6b/1cf3743dd97fe52ccae5ef62c604976b.jpg" alt="">
|
||||
|
||||
RateLimitRule的实现代码比较多,我就不在这里贴出来了,我只给出它的定义,如下所示。如果你感兴趣的话,可以自己实现一下,也可以参看我的另一个专栏《数据结构与算法之美》的[第55讲](https://time.geekbang.org/column/article/80388?utm_term=zeusNGLWQ&utm_source=xiangqingye&utm_medium=geektime&utm_campaign=end&utm_content=xiangqingyelink1104)。在那节课中,我们对各种接口匹配算法有非常详细的讲解。
|
||||
|
||||
```
|
||||
public class RateLimitRule {
|
||||
public RateLimitRule(RuleConfig ruleConfig) {
|
||||
//...
|
||||
}
|
||||
|
||||
public ApiLimit getLimit(String appId, String api) {
|
||||
//...
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**最后,我们看下RateLimitAlg这个类。**
|
||||
|
||||
这个类是限流算法实现类。它实现了最简单的固定时间窗口限流算法。每个接口都要在内存中对应一个RateLimitAlg对象,记录在当前时间窗口内已经被访问的次数。RateLimitAlg类的代码如下所示。对于代码的算法逻辑,你可以看下上节课中对固定时间窗口限流算法的讲解。
|
||||
|
||||
```
|
||||
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 InternalErrorException {
|
||||
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 InternalErrorException("tryAcquire() wait lock too long:" + TRY_LOCK_TIMEOUT + "ms");
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
throw new InternalErrorException("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类提炼抽象接口。
|
||||
|
||||
按照这个修改思路,我们对代码进行重构。重构之后的目录结构如下所示。我对每个类都稍微做了说明,你可以对比着重构前的目录结构来看。
|
||||
|
||||
```
|
||||
// 重构前:
|
||||
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来实现了限流规则配置文件的加载。
|
||||
|
||||
```
|
||||
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 configSource = new FileRuleConfigSource();
|
||||
RuleConfig ruleConfig = configSource.load();
|
||||
this.rule = new TrieRateLimitRule(ruleConfig);
|
||||
}
|
||||
|
||||
public boolean limit(String appId, String url) throws InternalErrorException, InvalidUrlException {
|
||||
//...代码不变...
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们再来看下,从RateLimiter中拆分出来的限流规则加载的逻辑,现在是如何设计的。这部分涉及的类主要是下面几个。我把关键代码也贴在了下面。其中,各个Parser和RuleConfigSource类的设计有点类似策略模式,如果要添加新的格式的解析,只需要实现对应的Parser类,并且添加到FileRuleConfig类的PARSER_MAP中就可以了。
|
||||
|
||||
```
|
||||
com.xzg.ratelimiter.rule.parser
|
||||
--RuleConfigParser(抽象接口)
|
||||
--YamlRuleConfigParser(Yaml格式配置文件解析类)
|
||||
--JsonRuleConfigParser(Json格式配置文件解析类)
|
||||
com.xzg.ratelimiter.rule.datasource
|
||||
--RuleConfigSource(抽象接口)
|
||||
--FileRuleConfigSource(基于本地文件的配置类)
|
||||
|
||||
public interface RuleConfigParser {
|
||||
RuleConfig parse(String configText);
|
||||
RuleConfig parse(InputStream in);
|
||||
}
|
||||
|
||||
public interface RuleConfigSource {
|
||||
RuleConfig load();
|
||||
}
|
||||
|
||||
public class FileRuleConfigSource implements RuleConfigSource {
|
||||
private static final Logger log = LoggerFactory.getLogger(FileRuleConfigSource.class);
|
||||
|
||||
public static final String API_LIMIT_CONFIG_NAME = "ratelimiter-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) {
|
||||
InputStream in = null;
|
||||
try {
|
||||
in = this.getClass().getResourceAsStream("/" + getFileNameByExt(extension));
|
||||
if (in != null) {
|
||||
RuleConfigParser parser = PARSER_MAP.get(extension);
|
||||
return parser.parse(in);
|
||||
}
|
||||
} finally {
|
||||
if (in != null) {
|
||||
try {
|
||||
in.close();
|
||||
} catch (IOException e) {
|
||||
log.error("close file error:{}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String getFileNameByExt(String extension) {
|
||||
return API_LIMIT_CONFIG_NAME + "." + extension;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 重点回顾
|
||||
|
||||
好了,今天的内容到此就讲完了。我们一块来总结回顾一下,你需要重点掌握的内容。
|
||||
|
||||
优秀的代码是重构出来的,复杂的代码是慢慢堆砌出来的。小步快跑、逐步迭代是我比较推崇的开发模式。追求完美主义会让我们迟迟无法下手。所以,为了克服这个问题,一方面,我们可以规划多个小版本来开发,不断迭代优化;另一方面,在编程实现的过程中,我们可以先实现MVP代码,以此来优化重构。
|
||||
|
||||
如何对MVP代码优化重构呢?我们站在Code Reviewer的角度,结合SOLID、DRY、KISS、LOD、基于接口而非实现编程、高内聚松耦合等经典的设计思想和原则,以及编码规范,从代码质量评判标准的角度,来剖析代码在可读性、扩展性、可维护性、灵活、简洁、复用性、可测试性等方面的表现,并且针对性地去优化不足。
|
||||
|
||||
## 课堂讨论
|
||||
|
||||
1. 针对MVP代码,如果让你做code review,你还能发现哪些问题?如果让你做重构,你还会做哪些修改和优化?
|
||||
1. 如何重构代码,支持自定义限流规则配置文件名和路径?如果你熟悉Java,你可以去了解一下Spring的设计思路,看看如何借鉴到限流框架中来解决这个问题?
|
||||
|
||||
欢迎留言和我分享你的想法。如果有收获,也欢迎你把这篇文章分享给你的朋友。
|
||||
149
极客时间专栏/设计模式之美/开源与项目实战:项目实战/93 | 项目实战二:设计实现一个通用的接口幂等框架(分析).md
Normal file
149
极客时间专栏/设计模式之美/开源与项目实战:项目实战/93 | 项目实战二:设计实现一个通用的接口幂等框架(分析).md
Normal file
@@ -0,0 +1,149 @@
|
||||
<audio id="audio" title="93 | 项目实战二:设计实现一个通用的接口幂等框架(分析)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/45/36/45569f45f009202c93eee78b7c2b5e36.mp3"></audio>
|
||||
|
||||
上三节课,我带你分析、设计、实现了一个接口限流框架。在分析阶段,我们讲到需求分析的两大方面,功能性需求分析和非功能性需求分析。在设计阶段,我们讲了如何通过合理的设计,在实功能性需求的前提下,满足易用、易扩展、灵活、高性能、高容错等非功能性需求。在实现阶段,我们讲了如何利用设计思想、原则、模式、编码规范等,编写可读、可扩展等高质量的代码实现。
|
||||
|
||||
从今天开始,我们来实战一个新的项目,开发一个通用的接口幂等框架。跟限流框架一样,我们还是分为分析、设计、实现三个部分,对应三节课来讲解。
|
||||
|
||||
话不多说,让我们正式开始今天的学习吧!
|
||||
|
||||
## 需求场景
|
||||
|
||||
我们先来看下幂等框架的需求场景。
|
||||
|
||||
还记得之前讲到的限流框架的项目背景吗?为了复用代码,我们把通用的功能设计成了公共服务平台。公司内部的其他金融产品的后台系统,会调用公共服务平台的服务,不需要完全从零开始开发。公共服务平台提供的是RESTful接口。为了简化开发,调用方一般使用Feign框架(一个HTTP框架)来访问公共服务平台的接口。
|
||||
|
||||
调用方访问公共服务平台的接口,会有三种可能的结果:成功、失败和超时。前两种结果非常明确,调用方可以自己决定收到结果之后如何处理。结果为“成功”,万事大吉。结果为“失败”,一般情况下,调用方会将失败的结果,反馈给用户(移动端App),让用户自行决定是否重试。
|
||||
|
||||
但是,当接口请求超时时,处理起来就没那么容易了。有可能业务逻辑已经执行成功了,只是公共服务平台返回结果给调用方的时候超时了,但也有可能业务逻辑没有执行成功,比如,因为数据库当时存在集中写入,导致部分数据写入超时。总之,超时对应的执行结果是未决的。那调用方调用接口超时时(基于Feign框架开发的话,一般是收到Timeout异常),该如何处理呢?
|
||||
|
||||
如果接口只包含查询、删除、更新这些操作,那接口天然是幂等的。所以,超时之后,重新再执行一次,也没有任何副作用。不过,这里有两点需要特殊说明一下。
|
||||
|
||||
删除操作需要当心ABA问题。删除操作超时了,又触发一次删除,但在这次删除之前,又有一次新的插入。后一次删除操作删除了新插入的数据,而新插入的数据本不应该删除。不过,大部分业务都可以容忍ABA问题。对于少数不能容忍的业务场景,我们可以针对性的特殊处理。
|
||||
|
||||
除此之外,细究起来,update x = x+delta这样格式的更新操作并非幂等,只有update x=y这样格式的更新操作才是幂等的。不过,后者也存在跟删除同样的ABA问题。
|
||||
|
||||
如果接口包含修改操作(插入操作、update x=x+delta更新操作),多次重复执行有可能会导致业务上的错误,这是不能接受的。如果插入的数据包含数据库唯一键,可以利用数据库唯一键的排他性,保证不会重复插入数据。除此之外,一般我会建议调用方按照这样几种方式来处理。
|
||||
|
||||
**第一种处理方式**是,调用方访问公共服务平台接口超时时,返回清晰明确的提醒给用户,告知执行结果未知,让用户自己判断是否重试。不过,你可能会说,如果用户看到了超时提醒,但还是重新发起了操作,比如重新发起了转账、充值等操作,那该怎么办呢?实际上,对这种情况,技术是无能为力的。因为两次操作都是用户主动发起的,我们无法判断第二次的转账、充值是新的操作,还是基于上一次超时的重试行为。
|
||||
|
||||
**第二种处理方式**是,调用方调用其他接口,来查询超时操作的结果,明确超时操作对应的业务,是执行成功了还是失败了,然后再基于明确的结果做处理。但是这种处理方法存在一个问题,那就是并不是所有的业务操作,都方便查询操作结果。
|
||||
|
||||
**第三种处理方式**是,调用方在遇到接口超时之后,直接发起重试操作。这样就需要接口支持幂等。我们可以选择在业务代码中触发重试,也可以将重试的操作放到Feign框架中完成。因为偶尔发生的超时,在正常的业务逻辑中编写一大坨补救代码,这样做会影响到代码的可读性,有点划不来。当然,如果项目中需要支持超时重试的业务不多,那对于仅有几个业务,特殊处理一下也未尝不可。但是,如果项目中需要支持超时重试的业务比较多,我们最好是把超时重试这些非业务相关的逻辑,统一在框架层面解决。
|
||||
|
||||
对响应时间敏感的调用方来说,它们服务的是移动端的用户,过长的等待时间,还不如直接返回超时给用户。所以,这种情况下,第一种处理方式是比较推荐的。但是,对响应时间不敏感的调用方来说,比如Job类的调用方,我推荐选择后两种处理方式,能够提高处理的成功率。而第二种处理方法,本身有一定的局限性,因为并不是所有业务操作都方便查询是否执行成功。第三种保证接口幂等的处理方式,是比较通用的解决方案。所以,我们针对这种处理方式,抽象出一套统一的幂等框架,简化幂等接口的开发。
|
||||
|
||||
## 需求分析
|
||||
|
||||
刚刚我们介绍了幂等框架的需求背景:超时重试需要接口幂等的支持。接下来,我们再对需求进行更加详细的分析和整理,这其中就包括功能性需求和非功能性需求。
|
||||
|
||||
**不过,在此之前,我们需要先搞清楚一个重要的概念:幂等号。**
|
||||
|
||||
前面多次提到“幂等”,那“幂等”到底是什么意思呢?放到接口调用的这个场景里,幂等的意思是,针对同一个接口,多次发起同一个业务请求,必须保证业务只执行一次。那如何判定两次接口请求是同一个业务请求呢?也就是说,如何判断两次接口请求是重试关系?而非独立的两个业务请求?比如,两次调用转账接口,尽管转账用户、金额等参数都一样,但我们也无法判断这两个转账请求就是重试关系。
|
||||
|
||||
实际上,要确定重试关系,我们就需要给同一业务请求一个唯一标识,也就是“幂等号”!如果两个接口请求,带有相同的幂等号,那我们就判断它们是重试关系,是同一个业务请求,不要重复执行。
|
||||
|
||||
幂等号需要保证全局唯一性。它可以有业务含义,比如,用户手机号码是唯一的,对于用户注册接口来说,我们可以拿它作为幂等号。不过,这样就会导致幂等框架的实现,无法完全脱离具体的业务。所以,我们更加倾向于,通过某种算法来随机生成没有业务含义的幂等号。
|
||||
|
||||
**幂等号的概念搞清楚了,我们再来看下框架的功能性需求。**
|
||||
|
||||
前面也介绍了一些需求分析整理方法,比如画线框图、写用户用例、基于测试驱动开发等。跟限流框架类似,这里我们也借助用户用例和测试驱动开发的思想,先去思考,如果框架最终被开发出来之后,它会如何被使用。我写了一个框架使用的Demo示例,如下所示。
|
||||
|
||||
```
|
||||
///////// 使用方式一: 在业务代码中处理幂等 ////////////
|
||||
// 接口调用方
|
||||
Idempotence idempotence = new Idempotence();
|
||||
String idempotenceId = idempotence.createId();
|
||||
Order order = createOrderWithIdempotence(..., idempotenceId);
|
||||
|
||||
// 接口实现方
|
||||
public class OrderController {
|
||||
private Idempotence idempontence; // 依赖注入
|
||||
|
||||
public Order createOrderWithIdempotence(..., String idempotenceId) {
|
||||
// 前置操作
|
||||
boolean existed = idempotence.check(idempotenceId);
|
||||
if (existed) {
|
||||
// 两种处理方式:
|
||||
// 1. 查询order,并且返回;
|
||||
// 2. 返回duplication operation Exception
|
||||
}
|
||||
idempotence.record(idempotenceId);
|
||||
|
||||
//...执行正常业务逻辑
|
||||
}
|
||||
|
||||
public Order createOrder(...) {
|
||||
//...
|
||||
}
|
||||
}
|
||||
|
||||
///////// 使用方式二:在框架层面处理幂等 //////////////
|
||||
// 接口调用方
|
||||
Idempotence idempotence = new Idempotence();
|
||||
String idempotenceId = idempotence.createId();
|
||||
//...通过feign框架将幂等号添加到http header中...
|
||||
|
||||
// 接口实现方
|
||||
public class OrderController {
|
||||
@IdempotenceRequired
|
||||
public Order createOrder(...) {
|
||||
//...
|
||||
}
|
||||
}
|
||||
|
||||
// 在AOP切面中处理幂等
|
||||
@Aspect
|
||||
public class IdempotenceSupportAdvice {
|
||||
@Autowired
|
||||
private Idempotence idempotence;
|
||||
|
||||
@Pointcut("@annotation(com.xzg.cd.idempotence.annotation.IdempotenceRequired)")
|
||||
public void controllerPointcut() {
|
||||
}
|
||||
|
||||
@Around(value = "controllerPointcut()")
|
||||
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
|
||||
// 从HTTP header中获取幂等号idempotenceId
|
||||
|
||||
// 前置操作
|
||||
boolean existed = idempotence.check(idempotenceId);
|
||||
if (existed) {
|
||||
// 两种处理方式:
|
||||
// 1. 查询order,并且返回;
|
||||
// 2. 返回duplication operation Exception
|
||||
}
|
||||
idempotence.record(idempotenceId)
|
||||
|
||||
Object result = joinPoint.proceed();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
结合刚刚的Demo,从使用的角度来说,幂等框架的主要处理流程是这样的。接口调用方生成幂等号,并且跟随接口请求,将幂等号传递给接口实现方。接口实现方接收到接口请求之后,按照约定,从HTTP Header或者接口参数中,解析出幂等号,然后通过幂等号查询幂等框架。如果幂等号已经存在,说明业务已经执行或正在执行,则直接返回;如果幂等号不存在,说明业务没有执行过,则记录幂等号,继续执行业务。
|
||||
|
||||
**对于幂等框架,我们再来看下,它都有哪些非功能性需求。**
|
||||
|
||||
在易用性方面,我们希望框架接入简单方便,学习成本低。只需编写简单的配置以及少许代码,就能完成接入。除此之外,框架最好对业务代码低侵入松耦合,在统一的地方(比如Spring AOP中)接入幂等框架,而不是将它耦合在业务代码中。
|
||||
|
||||
在性能方面,针对每个幂等接口,在正式处理业务逻辑之前,我们都要添加保证幂等的处理逻辑。这或多或少地会增加接口请求的响应时间。而对于响应时间比较敏感的接口服务来说,我们要让幂等框架尽可能低延迟,尽可能减少对接口请求本身响应时间的影响。
|
||||
|
||||
在容错性方面,跟限流框架相同,不能因为幂等框架本身的异常,导致接口响应异常,影响服务本身的可用性。所以,幂等框架要有高度的容错性。比如,存储幂等号的外部存储器挂掉了,幂等逻辑无法正常运行,这个时候业务接口也要能正常服务才行。
|
||||
|
||||
## 重点回顾
|
||||
|
||||
好了,今天的内容到此就讲完了。我们一块来总结回顾一下,你需要重点掌握的内容。
|
||||
|
||||
今天我们介绍了幂等框架的一个需求场景,那就是接口超时重试。大部分情况下,如果接口只包含查询、删除、更新这些操作,那接口天然是幂等的。除此之外,如果接口包含修改操作(插入操作或update x=x+delta更新操作),保证接口的幂等性就需要做一些额外的工作。
|
||||
|
||||
现在开源的东西那么多,但幂等框架非常少见。原因是幂等性的保证是业务强相关的。大部分保证幂等性的方式都是针对具体的业务具体处理,比如利用业务数据中的ID唯一性来处理插入操作的幂等性。但是,针对每个需要幂等的业务逻辑,单独编写代码处理,一方面对程序员的开发能力要求比较高,另一方面开发成本也比较高。
|
||||
|
||||
为了简化接口幂等的开发,我们希望开发一套统一的幂等框架,脱离具体的业务,让程序员通过简单的配置和少量代码,就能将非幂等接口改造成幂等接口。
|
||||
|
||||
## 课堂讨论
|
||||
|
||||
1. 重试无处不在,比如,Nginx、Dubbo、Feign等重试机制,你还能想到哪些其他的重试场景吗?
|
||||
1. 超时重试只是接口幂等的一个需求场景。除此之外,处理消息队列中消息重复的一种常用方法,就是将消息对应的业务逻辑设计成幂等的。因为业务逻辑是幂等的,所以多次接收重复消息不会导致重复执行业务逻辑。除了这些场景,你还知道有哪些其他场景需要用到幂等设计?
|
||||
|
||||
欢迎留言和我分享你的想法。如果有收获,也欢迎你把这篇文章分享给你的朋友。
|
||||
69
极客时间专栏/设计模式之美/开源与项目实战:项目实战/94 | 项目实战二:设计实现一个通用的接口幂等框架(设计).md
Normal file
69
极客时间专栏/设计模式之美/开源与项目实战:项目实战/94 | 项目实战二:设计实现一个通用的接口幂等框架(设计).md
Normal file
@@ -0,0 +1,69 @@
|
||||
<audio id="audio" title="94 | 项目实战二:设计实现一个通用的接口幂等框架(设计)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ef/0e/ef2f5262282c85c0ec68b388ec07160e.mp3"></audio>
|
||||
|
||||
上一节课,我们介绍了幂等框架的一个重要需求场景,接口超时重试。为了避免同一业务被多次重复执行,接口需要支持幂等特性。同时,我们还对功能性需求和非功能性需求做了梳理。今天,我们来讲解幂等框架的设计思路。
|
||||
|
||||
跟限流框架类似,幂等框架的功能性需求也比较简单,但要考虑处理的异常情况有很多,比如业务代码异常、业务系统宕机、幂等框架异常。今天,我们重点讲解如何应对这些异常情况,设计一个高度容错的幂等框架。
|
||||
|
||||
话不多说,让我们正式开始今天的学习吧!
|
||||
|
||||
## 幂等处理正常流程
|
||||
|
||||
调用方从发起接口请求到接收到响应,一般要经过三个阶段。第一个阶段是调用方发送请求并被实现方接收,第二个阶段是执行接口对应的业务逻辑,第三个阶段是将执行结果返回给调用方。为了实现接口幂等,我们需要将幂等相关的逻辑,添加在这三个阶段中。
|
||||
|
||||
正常情况下,幂等号随着请求传递到接口实现方之后,接口实现方将幂等号解析出来,传递给幂等框架。幂等框架先去数据库(比如Redis)中查找这个幂等号是否已经存在。如果存在,说明业务逻辑已经或者正在执行,就不要重复执行了。如果幂等号不存在,就将幂等号存储在数据库中,然后再执行相应的业务逻辑。
|
||||
|
||||
正常情况下,幂等处理流程是非常简单的,难点在于如何应对异常情况。在这三个阶段中,如果第一个阶段出现异常,比如发送请求失败或者超时,幂等号还没有记录下来,重试请求会被执行,符合我们的预期。如果第三个阶段出现异常,业务逻辑执行完成了,只是在发送结果给调用方的时候,失败或者超时了,这个时候,幂等号已经记录下来,重试请求不会被执行,也符合我们的预期。也就是说,第一、第三阶段出现异常,上述的幂等处理逻辑都可以正确应对。
|
||||
|
||||
但是,如果第二个阶段业务执行的过程出现异常,处理起来就复杂多了。接下来,我们就看下幂等框架该如何应对这一阶段的各种异常。我分了三类异常来讲解,它们分别是业务代码异常、业务系统宕机、幂等框架异常。
|
||||
|
||||
## 业务代码异常处理
|
||||
|
||||
当业务代码在执行过程中抛出异常的时候,我们是否应该认定为业务处理失败,然后将已经记录的幂等号删除,允许重新执行业务逻辑呢?
|
||||
|
||||
对于这个问题,我们要分业务异常和系统异常来区分对待。那什么是业务异常?什么是系统异常呢?我举个例子解释一下。比如,A用户发送消息给B用户,但是查询B用户不存在,抛出UserNotExisting异常,我们把这种业务上不符合预期叫做业务异常。因为数据库挂掉了,业务代码访问数据库时,就会报告数据库异常,我们把这种非业务层面的、系统级的异常,叫做系统异常。
|
||||
|
||||
遇到业务异常(比如UserNotExisting异常),我们不删除已经记录的幂等号,不允许重新执行同样的业务逻辑,因为再次重新执行也是徒劳的,还是会报告异常。相反,遇到系统异常(比如数据库访问异常),我们将已经记录的幂等号删除,允许重新执行这段业务逻辑。因为在系统级问题修复之后(比如数据库恢复了),重新执行之前失败的业务逻辑,就有可能会成功。
|
||||
|
||||
实际上,为了让幂等框架尽可能的灵活,低侵入业务逻辑,发生异常(不管是业务异常还是系统异常),是否允许再重试执行业务逻辑,交给开发这块业务的工程师来决定是最合适的了,毕竟他最清楚针对每个异常该如何处理。而幂等框架本身不参与这个决定,它只需要提供删除幂等号的接口,由业务工程师来决定遇到异常的时候,是否需要调用这个删除接口,删除已经记录的幂等号。
|
||||
|
||||
## 业务系统宕机处理
|
||||
|
||||
刚刚分析的是代码异常,我们再来看下,如果在业务处理的过程中,业务系统宕机了(你可以简单理解为部署了业务系统的机器宕机了),幂等框架是否还能正确工作呢?
|
||||
|
||||
如果幂等号已经记录下了,但是因为机器宕机,业务还没来得及执行,按照刚刚的幂等框架的处理流程,即便机器重启,业务也不会再被触发执行了,这个时候该怎么办呢?除此之外,如果记录幂等号成功了,但是在捕获到系统异常之后,要删除幂等号之前,机器宕机了,这个时候又该怎么办?
|
||||
|
||||
如果希望幂等号的记录和业务的执行完全一致,我们就要把它们放到一个事务中。执行成功,必然会记录幂等号;执行失败,幂等号记录也会被自动回滚。因为幂等框架和业务系统各自使用独立的数据库来记录数据,所以,这里涉及的事务属于分布式事务。如果为了解决这个问题,引入分布式事务,那幂等框架的开发难度提高了很多,并且框架使用起来也复杂了很多,性能也会有所损失。
|
||||
|
||||
针对这个问题,我们还有另外一种解决方案。那就是,在存储业务数据的业务数据库( 比如MySQL)中,建一张表来记录幂等号。幂等号先存储到业务数据库中,然后再同步给幂等框架的Redis数据库。这样做的好处是,我们不需要引入分布式事务框架,直接利用业务数据库本身的事务属性,保证业务数据和幂等号的写入操作,要么都成功,要么都失败。不过,这个解决方案会导致幂等逻辑,跟业务逻辑没有完全解耦,不符合我们之前讲到的低侵入、松耦合的设计思想。
|
||||
|
||||
实际上,做工程不是做理论。对于这种极少发生的异常,在工程中,我们能够做到,在出错时能及时发现问题、能够根据记录的信息人工修复就可以了。虽然看起来解决方案不优雅,不够智能,不够自动化,但是,这比编写一大坨复杂的代码逻辑来解决,要好使得多。所以,我们建议业务系统记录SQL的执行日志,在日志中附加上幂等号。这样我们就能在机器宕机时,根据日志来判断业务执行情况和幂等号的记录是否一致。
|
||||
|
||||
## 幂等框架异常处理
|
||||
|
||||
我们前面提到,限流框架本身的异常,不能导致接口响应异常。那对于幂等框架来说,是否也适用这条设计原则呢?
|
||||
|
||||
对于限流来说,限流框架执行异常(比如,Redis访问超时或者访问失败),我们可以触发服务降级,让限流功能暂时不起作用,接口还能正常执行。如果大量的限流接口调用异常,在具有完善监控的情况下,这些异常很快就会被运维发现并且修复,所以,短暂的限流失效,也不会对业务系统产生太多影响。毕竟限流只是一个针对突发情况的保护机制,平时并不起作用。如果偶尔的极个别的限流接口调用异常,本不应该被放过的几个接口请求,因为限流的暂时失效被放过了,对于这种情况,绝大部分业务场景都是可以接受的。毕竟限流不可能做到非常精确,多放过一两个接口请求几乎没影响。
|
||||
|
||||
对于幂等来说,尽管它应对的也是超时重试等特殊场景,但是,如果本不应该重新执行的业务逻辑,因为幂等功能的暂时失效,被重复执行了,就会导致业务出错(比如,多次执行转账,钱多转了)。对于这种情况,绝大部分业务场景都是无法接受的。所以,在幂等逻辑执行异常时,我们选择让接口请求也失败,相应的业务逻辑就不会被重复执行了。毕竟接口请求失败(比如转钱没转成功),比业务执行出错(比如多转了钱),修复的成本要低很多。
|
||||
|
||||
## 重点回顾
|
||||
|
||||
好了,今天的内容到此就讲完了。我们一块来总结回顾一下,你需要重点掌握的内容。
|
||||
|
||||
今天,我们讲解了幂等框架的设计思路。在正常情况下,幂等框架的处理流程是比较简单的,调用方生成幂等号,传递给实现方,实现方记录幂等号或者用幂等号判重。但是,幂等框架要处理的异常情况很多,这也是设计的复杂之处和难点之处。
|
||||
|
||||
我们针对三种不同类型的异常,讲解了幂等框架的应对思路。
|
||||
|
||||
对于业务代码异常,为了让幂等框架尽可能的灵活,低侵入业务逻辑,发生异常(不管是业务异常还是系统异常),是否允许再重试执行业务逻辑,交给开发这块业务的工程师来决定。
|
||||
|
||||
对于业务系统宕机,对于这种极少发生的异常,在工程中,我们能够做到,在出错时能及时发现问题、能够根据记录的信息人工修复,就可以了。所以,我们建议业务系统记录SQL的执行日志,在日志中附加上幂等号。这样我们就能在机器宕机时,根据日志来判断业务执行情况和幂等号的记录是否一致。
|
||||
|
||||
对于幂等框架异常,跟限流框架异常处理对策不同,在幂等逻辑执行异常时,我们选择让接口请求也失败,相应的业务逻辑就不会被重复执行了,业务就不会出错。毕竟接口请求失败,比业务执行出错,修复的成本要低很多。
|
||||
|
||||
虽然幂等框架要处理的异常很多,但考虑到开发成本以及简单易用性,我们对某些异常的处理在工程上做了妥协,交由业务系统或者人工介入处理。这样就大大简化了幂等框架开发的复杂度和难度。
|
||||
|
||||
## 课堂讨论
|
||||
|
||||
我常说,异常情况考虑是否全面,处理是否得当,很能体现一个程序员的逻辑思维能力、工程能力。除了我们今天讲到的异常,在幂等框架中,你还能想到有哪些其他异常情况会发生?又该如何应对呢?
|
||||
|
||||
欢迎留言和我分享你的想法。如果有收获,也欢迎你把这篇文章分享给你的朋友。
|
||||
236
极客时间专栏/设计模式之美/开源与项目实战:项目实战/95 | 项目实战二:设计实现一个通用的接口幂等框架(实现).md
Normal file
236
极客时间专栏/设计模式之美/开源与项目实战:项目实战/95 | 项目实战二:设计实现一个通用的接口幂等框架(实现).md
Normal file
@@ -0,0 +1,236 @@
|
||||
<audio id="audio" title="95 | 项目实战二:设计实现一个通用的接口幂等框架(实现)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/bb/c7/bbf4c012da80e236879491c736a947c7.mp3"></audio>
|
||||
|
||||
上一节课,我们讲解了幂等框架的设计思路。在正常情况下,幂等框架的处理流程是比较简单的。调用方生成幂等号,传递给实现方,实现方记录幂等号或者用幂等号判重。但是,幂等框架要处理的异常情况很多,这也是设计的复杂之处和难点之处。比如,代码运行异常、业务系统宕机、幂等框架异常。
|
||||
|
||||
虽然幂等框架要处理的异常很多,但考虑到开发成本以及简单易用性,我们对某些异常的处理在工程上做了妥协,交由业务系统或者人工介入处理。这样就大大简化了幂等框架开发的复杂度和难度。
|
||||
|
||||
今天,我们针对幂等框架的设计思路,讲解如何编码实现。跟限流框架的讲解相同,对于幂等框架,我们也会还原它的整个开发过程,从V1版本需求、最小原型代码讲起,然后讲解如何review代码发现问题、重构代码解决问题,最终得到一份易读、易扩展、易维护、灵活、可测试的高质量代码实现。
|
||||
|
||||
话不多说,让我们正式开始今天的学习吧!
|
||||
|
||||
## V1版本功能需求
|
||||
|
||||
上一节课给出的设计思路比较零散,重点还是在讲设计的缘由,为什么要这么设计。今天,我们再重新整理一下,经过上一节课的分析梳理最终得到的设计思路。虽然上一节课的分析很复杂、很烧脑,但思从深而行从简,最终得到的幂等框架的设计思路是很简单的,主要包含下面这样两个主要的功能开发点:
|
||||
|
||||
- 实现生成幂等号的功能;
|
||||
- 实现存储、查询、删除幂等号的功能。
|
||||
|
||||
因为功能非常简单,所以,我们就不再进一步裁剪了。在V1版本中,我们会实现上面罗列的所有功能。针对这两个功能点,我们先来说下实现思路。
|
||||
|
||||
**我们先来看,如何生成幂等号。**
|
||||
|
||||
幂等号用来标识两个接口请求是否是同一个业务请求,换句话说,两个接口请求是否是重试关系,而非独立的两个请求。接口调用方需要在发送接口请求的同时,将幂等号一块传递给接口实现方。那如何来生成幂等号呢?一般有两种生成方式。一种方式是集中生成并且分派给调用方,另一种方式是直接由调用方生成。
|
||||
|
||||
对于第一种生成方式,我们需要部署一套幂等号的生成系统,并且提供相应的远程接口(Restful或者RPC接口),调用方通过调用远程接口来获取幂等号。这样做的好处是,对调用方完全隐藏了幂等号的实现细节。当我们需要改动幂等号的生成算法时,调用方不需要改动任何代码。
|
||||
|
||||
对于第二种生成方式,调用方按照跟接口实现方预先商量好的算法,自己来生成幂等号。这种实现方式的好处在于,不用像第一种方式那样调用远程接口,所以执行效率更高。但是,一旦需要修改幂等号的生成算法,就需要修改每个调用方的代码。
|
||||
|
||||
并且,每个调用方自己实现幂等号的生成算法也会有问题。一方面,重复开发,违反DRY原则。另一方面,工程师的开发水平层次不齐,代码难免会有bug。除此之外,对于复杂的幂等号生成算法,比如依赖外部系统Redis等,显然更加适合上一种实现方式,可以避免调用方为了使用幂等号引入新的外部系统。
|
||||
|
||||
权衡来讲,既考虑到生成幂等号的效率,又考虑到代码维护的成本,我们选择第二种实现方式,并且在此基础上做些改进,由幂等框架来统一提供幂等号生成算法的代码实现,并封装成开发类库,提供给各个调用方复用。除此之外,我们希望生成幂等号的算法尽可能的简单,不依赖其他外部系统。
|
||||
|
||||
实际上,对于幂等号的唯一要求就是全局唯一。全局唯一ID的生成算法有很多。比如,简单点的有取UUID,复杂点的可以把应用名拼接在UUID上,方便做问题排查。总体上来讲,幂等号的生成算法并不难。
|
||||
|
||||
**我们再来看,如何实现幂等号的存储、查询和删除。**
|
||||
|
||||
从现在的需求来看,幂等号只是为了判重。在数据库中,我们只需要存储一个幂等号就可以,不需要太复杂的存储结构,所以,我们不选择使用复杂的关系型数据库,而是选择使用更加简单的、读写更加快速的键值数据库,比如Redis。
|
||||
|
||||
在幂等判重逻辑中,我们需要先检查幂等号是否存在。如果没有存在,再将幂等号存储进Redis。多个线程(同一个业务实例的多个线程)或者多进程(多个业务实例)同时执行刚刚的“检查-设置”逻辑时,就会存在竞争关系(竞态,race condition)。比如,A线程检查幂等号不存在,在A线程将幂等号存储进Redis之前,B线程也检查幂等号不存在,这样就会导致业务被重复执行。为了避免这种情况发生,我们要给“检查-设置”操作加锁,让同一时间只有一个线程能执行。除此之外,为了避免多进程之间的竞争,普通的线程锁还不起作用,我们需要分布式锁。
|
||||
|
||||
引入分布式锁会增加开发的难度和复杂度,而Redis本身就提供了把“检查-设置”操作作为原子操作执行的命令:setnx(key, value)。它先检查key是否存在,如果存在,则返回结果0;如果不存在,则将key值存下来,并将值设置为value,返回结果1。因为Redis本身是单线程执行命令的,所以不存在刚刚讲到的并发问题。
|
||||
|
||||
## 最小原型代码实现
|
||||
|
||||
V1版本要实现的功能和实现思路,现在已经很明确了。现在,我们来看下具体的代码实现。还是跟限流框架同样的实现方法,我们先不考虑设计和代码质量,怎么简单怎么来,先写出MVP代码,然后基于这个最简陋的版本做优化重构。
|
||||
|
||||
V1版本的功能非常简单,我们用一个类就能搞定,代码如下所示。只用了不到30行代码,就搞定了一个框架,是不是觉得有点不可思议。对于这段代码,你可以先思考下,有哪些值得优化的地方。
|
||||
|
||||
```
|
||||
public class Idempotence {
|
||||
private JedisCluster jedisCluster;
|
||||
|
||||
public Idempotence(String redisClusterAddress, GenericObjectPoolConfig config) {
|
||||
String[] addressArray= redisClusterAddress.split(";");
|
||||
Set<HostAndPort> redisNodes = new HashSet<>();
|
||||
for (String address : addressArray) {
|
||||
String[] hostAndPort = address.split(":");
|
||||
redisNodes.add(new HostAndPort(hostAndPort[0], Integer.valueOf(hostAndPort[1])));
|
||||
}
|
||||
this.jedisCluster = new JedisCluster(redisNodes, config);
|
||||
}
|
||||
|
||||
public String genId() {
|
||||
return UUID.randomUUID().toString();
|
||||
}
|
||||
|
||||
public boolean saveIfAbsent(String idempotenceId) {
|
||||
Long success = jedisCluster.setnx(idempotenceId, "1");
|
||||
return success == 1;
|
||||
}
|
||||
|
||||
public void delete(String idempotenceId) {
|
||||
jedisCluster.del(idempotenceId);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## Review最小原型代码
|
||||
|
||||
尽管MVP代码很少,但仔细推敲,也有很多值得优化的地方。现在,我们就站在Code Reviewer的角度,分析一下这段代码。我把我的所有意见都放到代码注释中了,你可以对照着代码一块看下。
|
||||
|
||||
```
|
||||
public class Idempotence {
|
||||
// comment-1: 如果要替换存储方式,是不是很麻烦呢?
|
||||
private JedisCluster jedisCluster;
|
||||
|
||||
// comment-2: 如果幂等框架要跟业务系统复用jedisCluster连接呢?
|
||||
// comment-3: 是不是应该注释说明一下redisClusterAddress的格式,以及config是否可以传递进null呢?
|
||||
public Idempotence(String redisClusterAddress, GenericObjectPoolConfig config) {
|
||||
// comment-4: 这段逻辑放到构造函数里,不容易写单元测试呢
|
||||
String[] addressArray= redisClusterAddress.split(";");
|
||||
Set<HostAndPort> redisNodes = new HashSet<>();
|
||||
for (String address : addressArray) {
|
||||
String[] hostAndPort = address.split(":");
|
||||
redisNodes.add(new HostAndPort(hostAndPort[0], Integer.valueOf(hostAndPort[1])));
|
||||
}
|
||||
this.jedisCluster = new JedisCluster(redisNodes, config);
|
||||
}
|
||||
|
||||
// comment-5: generateId()是不是比缩写要好点?
|
||||
// comment-6: 根据接口隔离原则,这个函数跟其他函数的使用场景完全不同,这个函数主要用在调用方,其他函数用在实现方,是不是应该分别放到两个类中?
|
||||
public String genId() {
|
||||
return UUID.randomUUID().toString();
|
||||
}
|
||||
|
||||
// comment-7: 返回值的意义是不是应该注释说明一下?
|
||||
public boolean saveIfAbsent(String idempotenceId) {
|
||||
Long success = jedisCluster.setnx(idempotenceId, "1");
|
||||
return success == 1;
|
||||
}
|
||||
|
||||
public void delete(String idempotenceId) {
|
||||
jedisCluster.del(idempotenceId);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
总结一下,MVP代码主要涉及下面这样几个问题。
|
||||
|
||||
- **代码可读性问题**:有些函数的参数和返回值的格式和意义不够明确,需要注释补充解释一下。genId()函数使用了缩写,全拼generateId()可能更好些!
|
||||
- **代码可扩展性问题**:按照现在的代码实现方式,如果改变幂等号的存储方式和生成算法,代码修改起来会比较麻烦。除此之外,基于接口隔离原则,我们应该将genId()函数跟其他函数分离开来,放到两个类中。独立变化,隔离修改,更容易扩展!
|
||||
- **代码可测试性问题**:解析Redis Cluster地址的代码逻辑较复杂,但因为放到了构造函数中,无法对它编写单元测试。
|
||||
- **代码灵活性问题**:业务系统有可能希望幂等框架复用已经建立好的jedisCluster,而不是单独给幂等框架创建一个jedisCluster。
|
||||
|
||||
## 重构最小原型代码
|
||||
|
||||
实际上,问题找到了,修改起来就容易多了。针对刚刚罗列的几个问题,我们对MVP代码进行重构,重构之后的代码如下所示。
|
||||
|
||||
```
|
||||
// 代码目录结构
|
||||
com.xzg.cd.idempotence
|
||||
--Idempotence
|
||||
--IdempotenceIdGenerator(幂等号生成类)
|
||||
--IdempotenceStorage(接口:用来读写幂等号)
|
||||
--RedisClusterIdempotenceStorage(IdempotenceStorage的实现类)
|
||||
|
||||
// 每个类的代码实现
|
||||
public class Idempotence {
|
||||
private IdempotenceStorage storage;
|
||||
|
||||
public Idempotence(IdempotenceStorage storage) {
|
||||
this.storage = storage;
|
||||
}
|
||||
|
||||
public boolean saveIfAbsent(String idempotenceId) {
|
||||
return storage.saveIfAbsent(idempotenceId);
|
||||
}
|
||||
|
||||
public void delete(String idempotenceId) {
|
||||
storage.delete(idempotenceId);
|
||||
}
|
||||
}
|
||||
|
||||
public class IdempotenceIdGenerator {
|
||||
public String generateId() {
|
||||
return UUID.randomUUID().toString();
|
||||
}
|
||||
}
|
||||
|
||||
public interface IdempotenceStorage {
|
||||
boolean saveIfAbsent(String idempotenceId);
|
||||
void delete(String idempotenceId);
|
||||
}
|
||||
|
||||
public class RedisClusterIdempotenceStorage implements IdempotenceStorage {
|
||||
private JedisCluster jedisCluster;
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
* @param redisClusterAddress the format is 128.91.12.1:3455;128.91.12.2:3452;289.13.2.12:8978
|
||||
* @param config should not be null
|
||||
*/
|
||||
public RedisIdempotenceStorage(String redisClusterAddress, GenericObjectPoolConfig config) {
|
||||
Set<HostAndPort> redisNodes = parseHostAndPorts(redisClusterAddress);
|
||||
this.jedisCluster = new JedisCluster(redisNodes, config);
|
||||
}
|
||||
|
||||
public RedisIdempotenceStorage(JedisCluster jedisCluster) {
|
||||
this.jedisCluster = jedisCluster;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save {@idempotenceId} into storage if it does not exist.
|
||||
* @param idempotenceId the idempotence ID
|
||||
* @return true if the {@idempotenceId} is saved, otherwise return false
|
||||
*/
|
||||
public boolean saveIfAbsent(String idempotenceId) {
|
||||
Long success = jedisCluster.setnx(idempotenceId, "1");
|
||||
return success == 1;
|
||||
}
|
||||
|
||||
public void delete(String idempotenceId) {
|
||||
jedisCluster.del(idempotenceId);
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
protected Set<HostAndPort> parseHostAndPorts(String redisClusterAddress) {
|
||||
String[] addressArray= redisClusterAddress.split(";");
|
||||
Set<HostAndPort> redisNodes = new HashSet<>();
|
||||
for (String address : addressArray) {
|
||||
String[] hostAndPort = address.split(":");
|
||||
redisNodes.add(new HostAndPort(hostAndPort[0], Integer.valueOf(hostAndPort[1])));
|
||||
}
|
||||
return redisNodes;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
接下来,我再总结罗列一下,针对之前发现的问题,我们都做了哪些代码改动。主要有下面这样几点,你可以结合着代码一块看下。
|
||||
|
||||
**在代码可读性方面,**我们对构造函数、saveIfAbsense()函数的参数和返回值做了注释,并且将genId()函数改为全拼generateId()。不过,对于这个函数来说,缩写实际上问题也不大。
|
||||
|
||||
**在代码可扩展性方面,**我们按照基于接口而非实现的编程原则,将幂等号的读写独立出来,设计成IdempotenceStorage接口和RedisClusterIdempotenceStorage实现类。RedisClusterIdempotenceStorage实现了基于Redis Cluster的幂等号读写。如果我们需要替换新的幂等号读写方式,比如基于单个Redis而非Redis Cluster,我们就可以再定义一个实现了IdempotenceStorage接口的实现类:RedisIdempotenceStorage。
|
||||
|
||||
除此之外,按照接口隔离原则,我们将生成幂等号的代码抽离出来,放到IdempotenceIdGenerator类中。这样,调用方只需要依赖这个类的代码就可以了。幂等号生成算法的修改,跟幂等号存储逻辑的修改,两者完全独立,一个修改不会影响另外一个。
|
||||
|
||||
**在代码可测试性方面,**我们把原本放在构造函数中的逻辑抽离出来,放到了parseHostAndPorts()函数中。这个函数本应该是Private访问权限的,但为了方便编写单元测试,我们把它设置为成了Protected访问权限,并且通过注解@VisibleForTesting做了标明。
|
||||
|
||||
**在代码灵活性方面,**为了方便复用业务系统已经建立好的jedisCluster,我们提供了一个新的构造函数,支持业务系统直接传递jedisCluster来创建Idempotence对象。
|
||||
|
||||
## 重点回顾
|
||||
|
||||
好了,今天的内容到此就讲完了。我们一块来总结回顾一下,你需要重点掌握的内容。
|
||||
|
||||
我们前面花了两节课的时间,用很大的篇幅在讲需求和设计,特别是设计的缘由。而真正到了实现环节,我们只用了不到30行代码,就实现了幂等框架。这就很好体现了“思从深而行从简”的道理。对于不到30行代码,很多人觉得不大可能有啥优化空间了,但我们今天还是提出了7个优化建议,并且对代码结构做了比较大的调整。这说明,只要仔细推敲,再小的代码都有值得优化的地方。
|
||||
|
||||
不过,之前有人建议我举一些大型项目中的例子,最好是上万行代码的那种,不要举这种几十行的小例子。大项目和小项目在编码这个层面,实际上没有太大区别。再宏大的工程、再庞大的项目,也是一行一行写出来的。那些上来就要看上万行代码,分析庞大项目的,大部分都还没有理解编码的精髓。编码本身就是一个很细节的事情,牛不牛也都隐藏在一行一行的代码中。空谈架构、设计、大道理,实际上没有太多意义,对你帮助不大。能沉下心来把细节都做好那才是真的牛!
|
||||
|
||||
## 课堂讨论
|
||||
|
||||
1. 针对MVP代码,我有两个问题留给你思考。其中一个问题是,delete()是应该返回void值还是boolean值?如果删除出错,应该如何处理?另一个问题是,需不需要给幂等号生成算法抽象出一个接口呢?为什么?
|
||||
1. 在后续的版本规划中,你觉得幂等框架还可以继续扩展哪些功能?或者做哪些优化?如果让你规划第二个版本,你会做哪些东西?
|
||||
|
||||
欢迎留言和我分享你的想法。如果有收获,也欢迎你把这篇文章分享给你的朋友。
|
||||
@@ -0,0 +1,110 @@
|
||||
<audio id="audio" title="96 | 项目实战三:设计实现一个支持自定义规则的灰度发布组件(分析)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ad/c5/addd96d5c442c65748339e71d8fd71c5.mp3"></audio>
|
||||
|
||||
到现在为止,我已经带你学习了接口限流框架和接口幂等框架两个实战项目。接下来,我再带你实战一个新的项目:灰度发布组件。这也是我们专栏的最后一个实战项目。还是老套路,我们把它分为分析、设计、实现三个部分、对应三节课来讲解。今天,我们对灰度发布组件进行需求分析,搞清楚这个组件应该具有哪些功能性和非功能性需求。
|
||||
|
||||
话不多说,让我们正式开始今天的学习吧!
|
||||
|
||||
## 需求场景
|
||||
|
||||
还记得我们之前接口限流和幂等框架的项目背景吗?我们开发了一个公共服务平台,提供公共业务功能,给其他产品的后端系统调用,避免重复开发相同的业务代码。
|
||||
|
||||
最初,公共服务平台提供的是,基于某个开源RPC框架的RPC格式的接口。在上线一段时间后,我们发现这个开源RPC框架的Bug很多,多次因为框架本身的Bug,导致整个公共服务平台的接口不可用,但又因为团队成员对框架源码不熟悉,并且框架的代码质量本身也不高,排查、修复起来花费了很长时间,影响面非常大。所以,我们评估下来,觉着这个框架的可靠性不够,维护成本、二次开发成本都太高,最终决定替换掉它。
|
||||
|
||||
对于引入新的框架,我们的要求是成熟、简单,并且与我们现有的技术栈(Spring)相吻合。这样,即便出了问题,我们也能利用之前积累的知识、经验来快速解决。所以,我们决定直接使用Spring框架来提供RESTful格式的远程接口。
|
||||
|
||||
把RPC接口替换成RESTful接口,除了需要修改公共服务平台的代码之外,调用方的接口调用代码也要做相应的修改。除此之外,对于公共服务平台的代码,尽管我们只是改动接口暴露方式,对业务代码基本上没有改动,但是,我们也并不能保证就完全不出问题。所以,为了保险起见,我们希望灰度替换掉老的RPC服务,而不是一刀切,在某个时间点上,让所有的调用方一下子都变成调用新的Resful接口。
|
||||
|
||||
**我们来看下具体如何来做。**
|
||||
|
||||
因为替换的过程是灰度的,所以老的RPC服务不能下线,同时还要部署另外一套新的RESTful服务。我们先让业务不是很重要、流量不大的某个调用方,替换成调用新的RESTful接口。经过这个调用方一段时间的验证之后,如果新的RESTful接口没有问题,我们再逐步让其他调用方,替换成调用新的RESTful接口。
|
||||
|
||||
但是,如果万一中途出现问题,我们就需要将调用方的代码回滚,再重新部署,这就会导致调用方一段时间内服务不可用。而且,如果新的代码还包含调用方自身新的业务代码,简单通过Git回滚代码重新部署,会导致新的业务代码也被回滚。所以,为了避免这种情况的发生,我们就得手动将调用新的RESTful接口的代码删除,再改回为调用老的RPC接口。
|
||||
|
||||
除此之外,为了不影响调用方本身业务的开发进度,调用方基于回滚之后的老代码,来做新功能开发,那替换成新的RESTful接口的那部分代码,要想再重新merge回去就比较难了,有可能会出现代码冲突,需要再重新开发。
|
||||
|
||||
**怎么解决代码回滚成本比较高的问题呢?**你可以先思考一下,再看我的讲解。
|
||||
|
||||
在替换新的接口调用方式的时候,调用方并不直接将调用RPC接口的代码逻辑删除,而是新增调用RESTful接口的代码,通过一个功能开关,灵活切换走老的代码逻辑还是新的代码逻辑。代码示例如下所示。如果callRestfulApi为true,就会走新的代码逻辑,调用RESTful接口,相反,就会走老的代码逻辑,继续调用RPC接口。
|
||||
|
||||
```
|
||||
boolean callRestfulApi = true;
|
||||
|
||||
if (!callRestfulApi) {
|
||||
// 老的调用RPC接口的代码逻辑
|
||||
} else {
|
||||
// 新的调用Resful接口的代码逻辑
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
不过,更改callRestfulApi的值需要修改代码,而修改代码就要重新部署,这样的设计还是不够灵活。优化的方法,我想你应该已经想到了,把这个值放到配置文件或者配置中心就可以了。
|
||||
|
||||
为了更加保险,不只是使用功能开关做新老接口调用方式的切换,我们还希望调用方在替换某个接口的时候,先让小部分接口请求,调用新的RESTful接口,剩下的大部分接口请求,还是调用老的RPC接口,验证没有问题之后,再逐步加大调用新接口的请求比例,最终,将所有的接口请求,都替换成调用新的接口。这就是所谓的“灰度”。
|
||||
|
||||
**那这个灰度功能又该如何实现呢?**同样,你还是先思考一下,再来看我的讲解。
|
||||
|
||||
首先,我们要决定使用什么来做灰度,也就是灰度的对象。我们可以针对请求携带的时间戳信息、业务ID等信息,按照区间、比例或者具体的值来做灰度。我举个例子来解释一下。
|
||||
|
||||
假设,我们要灰度的是根据用户ID查询用户信息接口。接口请求会携带用户ID信息,所以,我们就可以把用户ID作为灰度的对象。为了实现逐渐放量,我们先配置用户ID是918、879、123(具体的值)的查询请求调用新接口,验证没有问题之后,我们再扩大范围,让用户ID在1020~1120(区间值)之间的查询请求调用新接口。
|
||||
|
||||
如果验证之后还是没有问题,我们再继续扩大范围,让10%比例(比例值)的查询请求调用新接口(对应用户ID跟10取模求余小于1的请求)。以此类推,灰度范围逐步扩大到20%、30%、50%直到100%。当灰度比例达到100%,并且运行一段时间没有问题之后,调用方就可以把老的代码逻辑删除掉了。
|
||||
|
||||
实际上,类似的灰度需求场景还有很多。比如,在金融产品的清结算系统中,我们修改了清结算的算法。为了安全起见,我们可以灰度替换新的算法,把贷款ID作为灰度对象,先对某几个贷款应用新的算法,如果没有问题,再继续按照区间或者比例,扩大灰度范围。
|
||||
|
||||
除此之外,为了保证代码万无一失,提前做好预案,添加或者修改一些复杂功能、核心功能,即便不做灰度,我们也建议通过功能开关,灵活控制这些功能的上下线。在不需要重新部署和重启系统的情况,做到快速回滚或新老代码逻辑的切换。
|
||||
|
||||
## 需求分析
|
||||
|
||||
从实现的角度来讲,调用方只需要把灰度规则和功能开关,按照某种事先约定好的格式,存储到配置文件或者配置中心,在系统启动的时候,从中读取配置到内存中,之后,看灰度对象是否落在灰度范围内,以此来判定是否执行新的代码逻辑。但为了避免每个调用方都重复开发,我们把功能开关和灰度相关的代码,抽象封装为一个灰度组件,提供给各个调用方来复用。
|
||||
|
||||
这里需要强调一点,我们这里的灰度,是代码级别的灰度,目的是保证项目质量,规避重大代码修改带来的不确定性风险。实际上,我们平时经常讲的灰度,一般都是产品层面或者系统层面的灰度。
|
||||
|
||||
所谓产品层面,有点类似A/B Testing,让不同的用户看到不同的功能,对比两组用户的使用体验,收集数据,改进产品。所谓系统层面的灰度,往往不在代码层面上实现,一般是通过配置负载均衡或者API-Gateway,来实现分配流量到不同版本的系统上。系统层面的灰度也是为了平滑上线功能,但比起我们讲到的代码层面的灰度,就没有那么细粒度了,开发和运维成本也相对要高些。
|
||||
|
||||
**现在,我们就来具体看下,灰度组件都有哪些功能性需求。**
|
||||
|
||||
我们还是从使用的角度来分析。组件使用者需要设置一个key值,来唯一标识要灰度的功能,然后根据自己业务数据的特点,选择一个灰度对象(比如用户ID),在配置文件或者配置中心中,配置这个key对应的灰度规则和功能开关。配置的格式类似下面这个样子:
|
||||
|
||||
```
|
||||
features:
|
||||
- key: call_newapi_getUserById
|
||||
enabled: true // enabled为true时,rule才生效
|
||||
rule: {893,342,1020-1120,%30} // 按照用户ID来做灰度
|
||||
- key: call_newapi_registerUser
|
||||
enabled: true
|
||||
rule: {1391198723, %10} //按照手机号来做灰度
|
||||
- key: newalgo_loan
|
||||
enabled: true
|
||||
rule: {0-1000} //按照贷款(loan)的金额来做灰度
|
||||
|
||||
```
|
||||
|
||||
灰度组件在业务系统启动的时候,会将这个灰度配置,按照事先定义的语法,解析并加载到内存对象中,业务系统直接使用组件提供的灰度判定接口,给业务系统使用,来判定某个灰度对象是否灰度执行新的代码逻辑。配置的加载解析、灰度判定逻辑这部分代码,都不需要业务系统来从零开发。
|
||||
|
||||
```
|
||||
public interface DarkFeature {
|
||||
boolean enabled();
|
||||
boolean dark(String darkTarget); //darkTarget是灰度对象,比如前面提到的用户ID、手机号码、金额等
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
所以,总结一下的话,灰度组件跟限流框架很类似,它也主要包含两部分功能:灰度规则配置解析和提供编程接口(DarkFeature)判定是否灰度。
|
||||
|
||||
跟限流框架类似,除了功能性需求,我们还要分析非功能性需求。不过,因为前面已经有了限流框架的非功能性需求的讲解,对于灰度组件的非功能性需求,我就留给你自己来分析。在下一节课中,我会再给出我的分析思路,到时候,你可以对比一下。
|
||||
|
||||
## 重点回顾
|
||||
|
||||
好了,今天的内容到此就讲完了。我们一块来总结回顾一下,你需要重点掌握的内容。
|
||||
|
||||
灰度发布可以分为三个不同层面的灰度:产品层面的灰度、系统层面的灰度和代码层面的灰度。我们今天重点讲解代码层面的灰度,通过编程来控制是否执行新的代码逻辑,以及灰度执行新的代码逻辑。
|
||||
|
||||
代码层面的灰度,主要解决代码质量问题,通过逐渐放量灰度执行,来降低重大代码改动带来的风险。在出现问题之后,在不需要修改代码、重新部署、重启系统的情况下,实现快速地回滚。相对于系统层面的灰度,它可以做得更加细粒度,更加灵活、简单、好维护,但也存在着代码侵入的问题,灰度代码跟业务代码耦合在一起。
|
||||
|
||||
灰度组件跟之前讲过的限流框架很相似,主要包含配置的解析加载和灰度判定逻辑。除此之外,对于非功能性需求,我们留在下一节课中讲解。
|
||||
|
||||
## 课堂讨论
|
||||
|
||||
参照限流框架的非功能性需求,分析一下灰度组件的非功能性需求。
|
||||
|
||||
欢迎留言和我分享你的想法。如果有收获,也欢迎你把这篇文章分享给你的朋友。
|
||||
@@ -0,0 +1,81 @@
|
||||
<audio id="audio" title="97 | 项目实战三:设计实现一个支持自定义规则的灰度发布组件(设计)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/22/4c/2212a7eabb7ed8b072a6d3f1c759054c.mp3"></audio>
|
||||
|
||||
上一节课,我们介绍了灰度组件的一个需求场景,将公共服务平台的RPC接口,灰度替换为新的RESTful接口,通过灰度逐步放量,支持快速回滚等手段,来规避代码质量问题带来的不确定性风险。
|
||||
|
||||
跟前面两个框架类似,灰度组件的功能性需求也比较简单。上一节课我们做了简单分析,今天我们再介绍一下,这个组件的非功能性需求,以及如何通过合理的设计来满足这些非功能性需求。
|
||||
|
||||
话不多说,让我们正式开始今天的学习吧!
|
||||
|
||||
## 非功能性需求
|
||||
|
||||
上一节课中,我给你留了一个作业,参照限流框架,分析一下灰度组件的非功能性需求。对于限流框架,我们主要从易用性、扩展性、灵活性、性能、容错性这几个方面,来分析它的非功能性需求。对于灰度组件,我们同样也从这几个方面来分析。
|
||||
|
||||
### 易用性
|
||||
|
||||
在前面讲到限流框架和幂等框架的时候,我们都提到了“低侵入松耦合”的设计思想。因为框架需要集成到业务系统中使用,我们希望它尽可能低侵入,与业务代码松耦合,替换、移除起来更容易些。因为接口的限流和幂等跟具体的业务是无关的,我们可以把限流和幂等相关的逻辑,跟业务代码解耦,统一放到公共的地方来处理(比如Spring AOP切面中)。
|
||||
|
||||
但是,对于灰度来说,我们实现的灰度功能是代码级别的细粒度的灰度,而替代掉原来的if-else逻辑,是针对一个业务一个业务来做的,跟业务强相关,要做到跟业务代码完全解耦,是不现实的。所以,在侵入性这一点上,灰度组件只能做妥协,容忍一定程度的侵入。
|
||||
|
||||
除此之外,在灰度的过程中,我们要不停地修改灰度规则,在测试没有出现问题的情况下,逐渐放量。从运维的角度来说,如果每次修改灰度规则都要重启系统,显然是比较麻烦的。所以,我们希望支持灰度规则的热更新,也就是说,当我们在配置文件中,修改了灰度规则之后,系统在不重启的情况下会自动加载、更新灰度规则。
|
||||
|
||||
### 扩展性、灵活性
|
||||
|
||||
跟限流框架一样,我们希望支持不同格式(JSON、YAML、XML等)、不同存储方式(本地配置文件、Redis、Zookeeper、或者自研配置中心等)的灰度规则配置方式。这一点在限流框架中已经详细讲过了,在灰度组件中我们就不重复讲解了。
|
||||
|
||||
除此之外,对于灰度规则本身,在上一节课的示例中,我们定义了三种灰度规则语法格式:具体值(比如893)、区间值(比如1020-1120)、比例值(比如%30)。不过,这只能处理比较简单的灰度规则。如果我们要支持更加复杂的灰度规则,比如只对30天内购买过某某商品并且退货次数少于10次的用户进行灰度,现在的灰度规则语法就无法支持了。所以,如何支持更加灵活的、复杂的灰度规则,也是我们设计实现的重点和难点。
|
||||
|
||||
### 性能
|
||||
|
||||
在性能方面,灰度组件的处理难度,并不像限流框架那么高。在限流框架中,对于分布式限流模式,接口请求访问计数存储在中心存储器中,比如Redis。而Redis本身的读写性能以及限流框架与Redis的通信延迟,都会很大地影响到限流本身的性能,进而影响到接口响应时间。所以,对于分布式限流来说,低延迟高性能是设计实现的难点和重点。
|
||||
|
||||
但是,对于灰度组件来说,灰度的判断逻辑非常简单,而且不涉及访问外部存储,所以性能一般不会有太大问题。不过,我们仍然需要把灰度规则组织成快速查找的数据结构,能够支持快速判定某个灰度对象(darkTarget,比如用户ID)是否落在灰度规则设定的区间内。
|
||||
|
||||
### 容错性
|
||||
|
||||
在限流框架中,我们要求高度容错,不能因为框架本身的异常,导致接口响应异常。从业务上来讲,我们一般能容忍限流框架的暂时、小规模的失效,所以,限流框架对于异常的处理原则是,尽可能捕获所有异常,并且内部“消化”掉,不要往上层业务代码中抛出。
|
||||
|
||||
对于幂等框架来说,我们不能容忍框架暂时、小规模的失效,因为这种失效会导致业务有可能多次被执行,发生业务数据的错误。所以,幂等框架对于异常的处理原则是,按照fail-fast原则,如果异常导致幂等逻辑无法正常执行,让业务代码也中止。因为业务执行失败,比业务执行出错,修复的成本更低。
|
||||
|
||||
对于灰度组件来说,上面的两种对异常的处理思路都是可以接受的。在灰度组件出现异常时,我们既可以选择中止业务,也可以选择让业务继续执行。如果让业务继续执行,本不应该被灰度到的业务对象,就有可能被执行。这是否能接受,还是要看具体的业务。不过,我个人倾向于采用类似幂等框架的处理思路,在出现异常时中止业务。
|
||||
|
||||
## 框架设计思路
|
||||
|
||||
根据刚刚对灰度组件的非功能性需求分析,以及跟限流框架、幂等框架非功能性需求的对比,我们可以看出,在性能和容错性方面,灰度组件并没有需要特别要处理的地方,重点需要关注的是易用性、扩展性、灵活性。详细来说,主要包括这样两点:支持更灵活、更复杂的灰度规则和支持灰度规则热更新。接下来,我们就重点讲一下,针对这两个重点问题的设计思路。
|
||||
|
||||
**首先,我们来看,如何支持更灵活、更复杂的灰度规则。**
|
||||
|
||||
灰度规则的配置也是跟业务强相关的。业务方需要根据要灰度的业务特点,找到灰度对象(上节课中的darkTarget,比如用户ID),然后按照给出的灰度规则语法格式,配置相应的灰度规则。
|
||||
|
||||
对于像刚刚提到的那种复杂的灰度规则(只对30天内购买过某某商品并且退货次数少于10次的用户进行灰度),通过定义语法规则来支持,是很难实现的。所以,针对复杂灰度规则,我们换个思路来实现。
|
||||
|
||||
我暂时想到了两种解决方法。其中一种是使用规则引擎,比如Drools,可以在配置文件中调用Java代码。另一种是支持编程实现灰度规则,这样做灵活性更高。不过,缺点是更新灰度规则需要更新代码,重新部署。
|
||||
|
||||
对于大部分业务的灰度,我们使用前面定义的最基本的语法规则(具体值、区间值、比例值)就能满足了。对于极个别复杂的灰度规则,我们借鉴Spring的编程式配置,由业务方编程实现,具体如何来做,我们放到下一节课的代码实现中讲解。这样既兼顾了易用性,又兼顾了灵活性。
|
||||
|
||||
之所以选择第二种实现方式,而不是使用Drools规则引擎,主要是出于不想为了不常用的功能,引入复杂的第三方框架,提高开发成本和灰度框架本身的学习成本。
|
||||
|
||||
**其次,我们来看,如何实现灰度规则热更新。**
|
||||
|
||||
规则热更新这样一个功能,并非灰度组件特有的,很多场景下都有类似的需求。在第25、26讲中,讲到性能计数器项目的时候,我们也提到过这个需求。
|
||||
|
||||
灰度规则的热更新实现起来并不难。我们创建一个定时器,每隔固定时间(比如1分钟),从配置文件中,读取灰度规则配置信息,并且解析加载到内存中,替换掉老的灰度规则。需要特别强调的是,更新灰度规则,涉及读取配置、解析、构建等一系列操作,会花费比较长的时间,我们不能因为更新规则,就暂停了灰度服务。所以,在设计和实现灰度规则更新的时候,我们要支持更新和查询并发执行。具体如何来做,我们留在下一节课的实现中详细讲解。
|
||||
|
||||
## 重点回顾
|
||||
|
||||
好了,今天的内容到此就讲完了。我们一块来总结回顾一下,你需要重点掌握的内容。
|
||||
|
||||
今天,我们对比限流框架、幂等框架,讲解了灰度组件的非功能性需求,主要包含易用性、扩展性、灵活性、性能、容错性这样几个方面,并且针对性地解释了对应的设计思路。
|
||||
|
||||
在易用性方面,我们重点讲解了“低侵入、松耦合”的设计思想。限流、幂等因为其跟业务无关,可以做到最大程度跟业务解耦,做到低侵入。但是,我们这里实现的代码层面的灰度,因为跟业务强相关,所以,跟业务代码耦合的比较紧密,比较难做到低侵入。
|
||||
|
||||
在扩展性、灵活性方面,除了像限流框架那样,支持各种格式、存储方式的配置方式之外,灰度组件还希望能支持复杂的灰度规则。对于大部分业务的灰度,我们使用最基本的语法规则(具体值、区间值、比例值)就能满足了。对于极个别复杂的灰度规则,我们借鉴Spring的编程式配置,由业务方编程实现。
|
||||
|
||||
在性能方面,灰度组件没有需要特殊处理的地方。我们只需要把灰度规则组织成快速查找的数据结构,能够支持快速判定某个灰度对象(darkTarget,比如用户ID),是否落在灰度规则设定的区间内。
|
||||
|
||||
在容错性方面,限流框架要高度容错,容忍短暂、小规模的限流失效,但不容忍框架异常导致的接口响应异常。幂等框架正好相反,不容忍幂等功能的失效,一旦出现异常,幂等功能失效,我们的处理原则是让业务也失败。这两种处理思路都可以用在灰度组件对异常的处理中。我个人倾向于采用幂等框架的处理思路。
|
||||
|
||||
## 课堂讨论
|
||||
|
||||
在项目实战这部分中,我们多次讲到“低侵入、松耦合”的设计思路,我们平时使用Logger框架,在业务代码中打印日志,算不算是对业务代码的侵入、耦合?
|
||||
|
||||
欢迎留言和我分享你的想法。如果有收获,也欢迎你把这篇文章分享给你的朋友。
|
||||
@@ -0,0 +1,472 @@
|
||||
<audio id="audio" title="98 | 项目实战三:设计实现一个支持自定义规则的灰度发布组件(实现)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/34/82/3428e6f0005a899ae9f3479e75c1c782.mp3"></audio>
|
||||
|
||||
上两节课,我们讲解了灰度组件的需求和设计思路。不管是之前讲过的限流、幂等框架,还是现在正在讲的灰度组件,这些框架、组件、类库的功能性需求都不复杂,相反,非功能性需求是开发的重点、难点。
|
||||
|
||||
今天,我们按照上节课给出的灰度组件的设计思路,讲解如何进行编码实现。不过今天对实现的讲解,跟前面两个实战项目有所不同。在前面两个项目中,我都是手把手地从最基础的MVP代码讲起,然后讲解如何review代码发现问题、重构代码解决问题,最终得到一份还算高质量的代码。考虑到已经有前面两个项目的学习和锻炼了,你应该对开发套路、思考路径很熟悉了,所以,今天我们换个讲法,就不从最基础的讲起了,而是重点讲解实现思路。
|
||||
|
||||
话不多说,让我们正式开始今天的学习吧!
|
||||
|
||||
## 灰度组件功能需求整理
|
||||
|
||||
针对上两节课给出的开发需求和设计思路,我们还是按照老套路,从中剥离出V1版本要实现的内容。为了方便我讲解和你查看,我把灰度组件的开发需求和设计思路,重新整理罗列了一下,放到了这里。
|
||||
|
||||
### 1.灰度规则的格式和存储方式
|
||||
|
||||
我们希望支持不同格式(JSON、YAML、XML等)、不同存储方式(本地配置文件、Redis、Zookeeper、或者自研配置中心等)的灰度规则配置方式。实际上,这一点跟之前的限流框架中限流规则的格式和存储方式完全一致,代码实现也是相同的,所以在接下来的讲解中,就不重复啰嗦了,你可以回过头去看下[第92讲](https://time.geekbang.org/column/article/243961)。
|
||||
|
||||
### 2.灰度规则的语法格式
|
||||
|
||||
我们支持三种灰度规则语法格式:具体值(比如893)、区间值(比如1020-1120)、比例值(比如%30)。除此之外,对于更加复杂的灰度规则,比如只对30天内购买过某某商品并且退货次数少于10次的用户进行灰度,我们通过编程的方式来实现。
|
||||
|
||||
### 3.灰度规则的内存组织方式
|
||||
|
||||
类似于限流框架中的限流规则,我们需要把灰度规则组织成支持快速查找的数据结构,能够快速判定某个灰度对象(darkTarget,比如用户ID),是否落在灰度规则设定的范围内。
|
||||
|
||||
### 4.灰度规则热更新
|
||||
|
||||
修改了灰度规则之后,我们希望不重新部署和重启系统,新的灰度规则就能生效,所以,我们需要支持灰度规则热更新。
|
||||
|
||||
在V1版本中,对于第一点灰度规则的格式和存储方式,我们只支持YAML格式本地文件的配置存储方式。对于剩下的三点,我们都要进行实现。考虑到V1版本要实现的内容比较多,我们分两步来实现代码,第一步先将大的流程、框架搭建好,第二步再进一步添加、丰富、优化功能。
|
||||
|
||||
## 实现灰度组件基本功能
|
||||
|
||||
在第一步中,我们先实现基于YAML格式的本地文件的灰度规则配置方式,以及灰度规则热更新,并且只支持三种基本的灰度规则语法格式。基于编程实现灰度规则的方式,我们留在第二步实现。
|
||||
|
||||
我们先把这个基本功能的开发需求,用代码实现出来。它的目录结构及其Demo示例如下所示。代码非常简单,只包含4个类。接下来,我们针对每个类再详细讲解一下。
|
||||
|
||||
```
|
||||
// 代码目录结构
|
||||
com.xzg.darklaunch
|
||||
--DarkLaunch(框架的最顶层入口类)
|
||||
--DarkFeature(每个feature的灰度规则)
|
||||
--DarkRule(灰度规则)
|
||||
--DarkRuleConfig(用来映射配置到内存中)
|
||||
|
||||
// Demo示例
|
||||
public class DarkDemo {
|
||||
public static void main(String[] args) {
|
||||
DarkLaunch darkLaunch = new DarkLaunch();
|
||||
DarkFeature darkFeature = darkLaunch.getDarkFeature("call_newapi_getUserById");
|
||||
System.out.println(darkFeature.enabled());
|
||||
System.out.println(darkFeature.dark(893));
|
||||
}
|
||||
}
|
||||
|
||||
// 灰度规则配置(dark-rule.yaml)放置在classpath路径下
|
||||
features:
|
||||
- key: call_newapi_getUserById
|
||||
enabled: true
|
||||
rule: {893,342,1020-1120,%30}
|
||||
- key: call_newapi_registerUser
|
||||
enabled: true
|
||||
rule: {1391198723, %10}
|
||||
- key: newalgo_loan
|
||||
enabled: true
|
||||
rule: {0-1000}
|
||||
|
||||
```
|
||||
|
||||
从Demo代码中,我们可以看出,对于业务系统来说,灰度组件的两个直接使用的类是DarkLaunch类和DarkFeature类。
|
||||
|
||||
**我们先来看DarkLaunch类。**这个类是灰度组件的最顶层入口类。它用来组装其他类对象,串联整个操作流程,提供外部调用的接口。
|
||||
|
||||
DarkLaunch类先读取灰度规则配置文件,映射为内存中的Java对象(DarkRuleConfig),然后再将这个中间结构,构建成一个支持快速查询的数据结构(DarkRule)。除此之外,它还负责定期更新灰度规则,也就是前面提到的灰度规则热更新。
|
||||
|
||||
为了避免更新规则和查询规则的并发执行冲突,在更新灰度规则的时候,我们并非直接操作老的DarkRule,而是先创建一个新的DarkRule,然后等新的DarkRule都构建好之后,再“瞬间”赋值给老的DarkRule。你可以结合着下面的代码一块看下。
|
||||
|
||||
```
|
||||
public class DarkLaunch {
|
||||
private static final Logger log = LoggerFactory.getLogger(DarkLaunch.class);
|
||||
private static final int DEFAULT_RULE_UPDATE_TIME_INTERVAL = 60; // in seconds
|
||||
private DarkRule rule;
|
||||
private ScheduledExecutorService executor;
|
||||
|
||||
public DarkLaunch(int ruleUpdateTimeInterval) {
|
||||
loadRule();
|
||||
this.executor = Executors.newSingleThreadScheduledExecutor();
|
||||
this.executor.scheduleAtFixedRate(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
loadRule();
|
||||
}
|
||||
}, ruleUpdateTimeInterval, ruleUpdateTimeInterval, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
public DarkLaunch() {
|
||||
this(DEFAULT_RULE_UPDATE_TIME_INTERVAL);
|
||||
}
|
||||
|
||||
private void loadRule() {
|
||||
// 将灰度规则配置文件dark-rule.yaml中的内容读取DarkRuleConfig中
|
||||
InputStream in = null;
|
||||
DarkRuleConfig ruleConfig = null;
|
||||
try {
|
||||
in = this.getClass().getResourceAsStream("/dark-rule.yaml");
|
||||
if (in != null) {
|
||||
Yaml yaml = new Yaml();
|
||||
ruleConfig = yaml.loadAs(in, DarkRuleConfig.class);
|
||||
}
|
||||
} finally {
|
||||
if (in != null) {
|
||||
try {
|
||||
in.close();
|
||||
} catch (IOException e) {
|
||||
log.error("close file error:{}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ruleConfig == null) {
|
||||
throw new RuntimeException("Can not load dark rule.");
|
||||
}
|
||||
// 更新规则并非直接在this.rule上进行,
|
||||
// 而是通过创建一个新的DarkRule,然后赋值给this.rule,
|
||||
// 来避免更新规则和规则查询的并发冲突问题
|
||||
DarkRule newRule = new DarkRule(ruleConfig);
|
||||
this.rule = newRule;
|
||||
}
|
||||
|
||||
public DarkFeature getDarkFeature(String featureKey) {
|
||||
DarkFeature darkFeature = this.rule.getDarkFeature(featureKey);
|
||||
return darkFeature;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**我们再来看下DarkRuleConfig类。**这个类功能非常简单,只是用来将灰度规则映射到内存中。具体的代码如下所示:
|
||||
|
||||
```
|
||||
public class DarkRuleConfig {
|
||||
private List<DarkFeatureConfig> features;
|
||||
|
||||
public List<DarkFeatureConfig> getFeatures() {
|
||||
return this.features;
|
||||
}
|
||||
|
||||
public void setFeatures(List<DarkFeatureConfig> features) {
|
||||
this.features = features;
|
||||
}
|
||||
|
||||
public static class DarkFeatureConfig {
|
||||
private String key;
|
||||
private boolean enabled;
|
||||
private String rule;
|
||||
// 省略getter、setter方法
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
从代码中,我们可以看出来,DarkRuleConfig类嵌套了一个内部类DarkFeatureConfig。这两个类跟配置文件的两层嵌套结构完全对应。我把对应关系标注在了下面的示例中,你可以对照着代码看下。
|
||||
|
||||
```
|
||||
<!--对应DarkRuleConfig-->
|
||||
features:
|
||||
- key: call_newapi_getUserById <!--对应DarkFeatureConfig-->
|
||||
enabled: true
|
||||
rule: {893,342,1020-1120,%30}
|
||||
- key: call_newapi_registerUser <!--对应DarkFeatureConfig-->
|
||||
enabled: true
|
||||
rule: {1391198723, %10}
|
||||
- key: newalgo_loan <!--对应DarkFeatureConfig-->
|
||||
enabled: true
|
||||
rule: {0-1000}
|
||||
|
||||
```
|
||||
|
||||
**我们再来看下DarkRule。**DarkRule包含所有要灰度的业务功能的灰度规则。它用来支持根据业务功能标识(feature key),快速查询灰度规则(DarkFeature)。代码也比较简单,具体如下所示:
|
||||
|
||||
```
|
||||
public class DarkRule {
|
||||
private Map<String, DarkFeature> darkFeatures = new HashMap<>();
|
||||
|
||||
public DarkRule(DarkRuleConfig darkRuleConfig) {
|
||||
List<DarkRuleConfig.DarkFeatureConfig> darkFeatureConfigs = darkRuleConfig.getFeatures();
|
||||
for (DarkRuleConfig.DarkFeatureConfig darkFeatureConfig : darkFeatureConfigs) {
|
||||
darkFeatures.put(darkFeatureConfig.getKey(), new DarkFeature(darkFeatureConfig));
|
||||
}
|
||||
}
|
||||
|
||||
public DarkFeature getDarkFeature(String featureKey) {
|
||||
return darkFeatures.get(featureKey);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**我们最后来看下DarkFeature类。**DarkFeature类表示每个要灰度的业务功能的灰度规则。DarkFeature将配置文件中灰度规则,解析成一定的结构(比如RangeSet),方便快速判定某个灰度对象是否落在灰度规则范围内。具体的代码如下所示:
|
||||
|
||||
```
|
||||
public class DarkFeature {
|
||||
private String key;
|
||||
private boolean enabled;
|
||||
private int percentage;
|
||||
private RangeSet<Long> rangeSet = TreeRangeSet.create();
|
||||
|
||||
public DarkFeature(DarkRuleConfig.DarkFeatureConfig darkFeatureConfig) {
|
||||
this.key = darkFeatureConfig.getKey();
|
||||
this.enabled = darkFeatureConfig.getEnabled();
|
||||
String darkRule = darkFeatureConfig.getRule().trim();
|
||||
parseDarkRule(darkRule);
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
protected void parseDarkRule(String darkRule) {
|
||||
if (!darkRule.startsWith("{") || !darkRule.endsWith("}")) {
|
||||
throw new RuntimeException("Failed to parse dark rule: " + darkRule);
|
||||
}
|
||||
|
||||
String[] rules = darkRule.substring(1, darkRule.length() - 1).split(",");
|
||||
this.rangeSet.clear();
|
||||
this.percentage = 0;
|
||||
for (String rule : rules) {
|
||||
rule = rule.trim();
|
||||
if (StringUtils.isEmpty(rule)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (rule.startsWith("%")) {
|
||||
int newPercentage = Integer.parseInt(rule.substring(1));
|
||||
if (newPercentage > this.percentage) {
|
||||
this.percentage = newPercentage;
|
||||
}
|
||||
} else if (rule.contains("-")) {
|
||||
String[] parts = rule.split("-");
|
||||
if (parts.length != 2) {
|
||||
throw new RuntimeException("Failed to parse dark rule: " + darkRule);
|
||||
}
|
||||
long start = Long.parseLong(parts[0]);
|
||||
long end = Long.parseLong(parts[1]);
|
||||
if (start > end) {
|
||||
throw new RuntimeException("Failed to parse dark rule: " + darkRule);
|
||||
}
|
||||
this.rangeSet.add(Range.closed(start, end));
|
||||
} else {
|
||||
long val = Long.parseLong(rule);
|
||||
this.rangeSet.add(Range.closed(val, val));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public boolean enabled() {
|
||||
return this.enabled;
|
||||
}
|
||||
|
||||
public boolean dark(long darkTarget) {
|
||||
boolean selected = this.rangeSet.contains(darkTarget);
|
||||
if (selected) {
|
||||
return true;
|
||||
}
|
||||
|
||||
long reminder = darkTarget % 100;
|
||||
if (reminder >= 0 && reminder < this.percentage) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean dark(String darkTarget) {
|
||||
long target = Long.parseLong(darkTarget);
|
||||
return dark(target);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 添加、优化灰度组件功能
|
||||
|
||||
在第一步中,我们完成了灰度组件的基本功能。在第二步中,我们再实现基于编程的灰度规则配置方式,用来支持更加复杂、更加灵活的灰度规则。
|
||||
|
||||
我们需要对于第一步实现的代码,进行一些改造。改造之后的代码目录结构如下所示。其中,DarkFeature、DarkRuleConfig的基本代码不变,新增了IDarkFeature接口,DarkLaunch、DarkRule的代码有所改动,用来支持编程实现灰度规则。
|
||||
|
||||
```
|
||||
// 第一步的代码目录结构
|
||||
com.xzg.darklaunch
|
||||
--DarkLaunch(框架的最顶层入口类)
|
||||
--DarkFeature(每个feature的灰度规则)
|
||||
--DarkRule(灰度规则)
|
||||
--DarkRuleConfig(用来映射配置到内存中)
|
||||
|
||||
// 第二步的代码目录结构
|
||||
com.xzg.darklaunch
|
||||
--DarkLaunch(框架的最顶层入口类,代码有改动)
|
||||
--IDarkFeature(抽象接口)
|
||||
--DarkFeature(实现IDarkFeature接口,基于配置文件的灰度规则,代码不变)
|
||||
--DarkRule(灰度规则,代码有改动)
|
||||
--DarkRuleConfig(用来映射配置到内存中,代码不变)
|
||||
|
||||
```
|
||||
|
||||
我们先来看下IDarkFeature接口,它用来抽象从配置文件中得到的灰度规则,以及编程实现的灰度规则。具体代码如下所示:
|
||||
|
||||
```
|
||||
public interface IDarkFeature {
|
||||
boolean enabled();
|
||||
boolean dark(long darkTarget);
|
||||
boolean dark(String darkTarget);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
基于这个抽象接口,业务系统可以自己编程实现复杂的灰度规则,然后添加到DarkRule中。为了避免配置文件中的灰度规则热更新时,覆盖掉编程实现的灰度规则,在DarkRule中,我们对从配置文件中加载的灰度规则和编程实现的灰度规则分开存储。按照这个设计思路,我们对DarkRule类进行重构。重构之后的代码如下所示:
|
||||
|
||||
```
|
||||
public class DarkRule {
|
||||
// 从配置文件中加载的灰度规则
|
||||
private Map<String, IDarkFeature> darkFeatures = new HashMap<>();
|
||||
// 编程实现的灰度规则
|
||||
private ConcurrentHashMap<String, IDarkFeature> programmedDarkFeatures = new ConcurrentHashMap<>();
|
||||
|
||||
public void addProgrammedDarkFeature(String featureKey, IDarkFeature darkFeature) {
|
||||
programmedDarkFeatures.put(featureKey, darkFeature);
|
||||
}
|
||||
|
||||
public void setDarkFeatures(Map<String, IDarkFeature> newDarkFeatures) {
|
||||
this.darkFeatures = newDarkFeatures;
|
||||
}
|
||||
|
||||
public IDarkFeature getDarkFeature(String featureKey) {
|
||||
IDarkFeature darkFeature = programmedDarkFeatures.get(featureKey);
|
||||
if (darkFeature != null) {
|
||||
return darkFeature;
|
||||
}
|
||||
return darkFeatures.get(featureKey);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
因为DarkRule代码有所修改,对应地,DarkLaunch的代码也需要做少许改动,主要有一处修改和一处新增代码,具体如下所示,我在代码中都做了注释,就不再重复解释了。
|
||||
|
||||
```
|
||||
public class DarkLaunch {
|
||||
private static final Logger log = LoggerFactory.getLogger(DarkLaunch.class);
|
||||
private static final int DEFAULT_RULE_UPDATE_TIME_INTERVAL = 60; // in seconds
|
||||
private DarkRule rule = new DarkRule();
|
||||
private ScheduledExecutorService executor;
|
||||
|
||||
public DarkLaunch(int ruleUpdateTimeInterval) {
|
||||
loadRule();
|
||||
this.executor = Executors.newSingleThreadScheduledExecutor();
|
||||
this.executor.scheduleAtFixedRate(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
loadRule();
|
||||
}
|
||||
}, ruleUpdateTimeInterval, ruleUpdateTimeInterval, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
public DarkLaunch() {
|
||||
this(DEFAULT_RULE_UPDATE_TIME_INTERVAL);
|
||||
}
|
||||
|
||||
private void loadRule() {
|
||||
InputStream in = null;
|
||||
DarkRuleConfig ruleConfig = null;
|
||||
try {
|
||||
in = this.getClass().getResourceAsStream("/dark-rule.yaml");
|
||||
if (in != null) {
|
||||
Yaml yaml = new Yaml();
|
||||
ruleConfig = yaml.loadAs(in, DarkRuleConfig.class);
|
||||
}
|
||||
} finally {
|
||||
if (in != null) {
|
||||
try {
|
||||
in.close();
|
||||
} catch (IOException e) {
|
||||
log.error("close file error:{}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ruleConfig == null) {
|
||||
throw new RuntimeException("Can not load dark rule.");
|
||||
}
|
||||
|
||||
// 修改:单独更新从配置文件中得到的灰度规则,不覆盖编程实现的灰度规则
|
||||
Map<String, IDarkFeature> darkFeatures = new HashMap<>();
|
||||
List<DarkRuleConfig.DarkFeatureConfig> darkFeatureConfigs = ruleConfig.getFeatures();
|
||||
for (DarkRuleConfig.DarkFeatureConfig darkFeatureConfig : darkFeatureConfigs) {
|
||||
darkFeatures.put(darkFeatureConfig.getKey(), new DarkFeature(darkFeatureConfig));
|
||||
}
|
||||
this.rule.setDarkFeatures(darkFeatures);
|
||||
}
|
||||
|
||||
// 新增:添加编程实现的灰度规则的接口
|
||||
public void addProgrammedDarkFeature(String featureKey, IDarkFeature darkFeature) {
|
||||
this.rule.addProgrammedDarkFeature(featureKey, darkFeature);
|
||||
}
|
||||
|
||||
public IDarkFeature getDarkFeature(String featureKey) {
|
||||
IDarkFeature darkFeature = this.rule.getDarkFeature(featureKey);
|
||||
return darkFeature;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
灰度组件的代码实现就讲完了。我们再通过一个Demo来看下,目前实现的灰度组件该如何使用。结合着Demo,再去理解上面的代码,会更容易些。Demo代码如下所示:
|
||||
|
||||
```
|
||||
// 灰度规则配置(dark-rule.yaml),放到classpath路径下
|
||||
features:
|
||||
- key: call_newapi_getUserById
|
||||
enabled: true
|
||||
rule: {893,342,1020-1120,%30}
|
||||
- key: call_newapi_registerUser
|
||||
enabled: true
|
||||
rule: {1391198723, %10}
|
||||
- key: newalgo_loan
|
||||
enabled: true
|
||||
rule: {0-100}
|
||||
|
||||
// 编程实现的灰度规则
|
||||
public class UserPromotionDarkRule implements IDarkFeature {
|
||||
@Override
|
||||
public boolean enabled() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean dark(long darkTarget) {
|
||||
// 灰度规则自己想怎么写就怎么写
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean dark(String darkTarget) {
|
||||
// 灰度规则自己想怎么写就怎么写
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Demo
|
||||
public class Demo {
|
||||
public static void main(String[] args) {
|
||||
DarkLaunch darkLaunch = new DarkLaunch(); // 默认加载classpath下dark-rule.yaml文件中的灰度规则
|
||||
darkLaunch.addProgrammedDarkFeature("user_promotion", new UserPromotionDarkRule()); // 添加编程实现的灰度规则
|
||||
IDarkFeature darkFeature = darkLaunch.getDarkFeature("user_promotion");
|
||||
System.out.println(darkFeature.enabled());
|
||||
System.out.println(darkFeature.dark(893));
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 重点回顾
|
||||
|
||||
好了,今天的内容到此就讲完了。我们一块来总结回顾一下,你需要重点掌握的内容。
|
||||
|
||||
到今天为止,项目实战环节就彻底结束了。在这一部分中,我们通过限流、幂等、灰度这三个实战项目,带你从需求分析、系统设计、代码实现这三个环节,学习了如何进行功能性、非功能性需求分析,如何通过合理的设计,完成功能性需求的同时,满足非功能性需求,以及如何编写高质量的代码实现。
|
||||
|
||||
实际上,项目本身的分析、设计、实现并不重要,不必对细节过于纠结。我希望通过这三个例子,分享我的思考路径、开发套路,让你借鉴并举一反三地应用到你平时的项目开发中。我觉得这才是最有价值的,才是你学习的重点。
|
||||
|
||||
如果你学完这一部分之后,对于项目中的一些通用的功能,能够开始下意识地主动思考代码复用的问题,考虑如何抽象成框架、类库、组件,并且对于如何开发,也不再觉得无从下手,而是觉得有章可循,那我觉得你就学到了这一部分的精髓。
|
||||
|
||||
## 课堂讨论
|
||||
|
||||
在DarkFeature类中,灰度规则的解析代码设计的不够优雅,你觉得问题在哪里呢?又该如何重构呢?
|
||||
|
||||
欢迎留言和我分享你的想法。如果有收获,也欢迎你把这篇文章分享给你的朋友。
|
||||
Reference in New Issue
Block a user