设计模式-设计实现一个通用的接口幂等框架

分析

需求场景

我们先来看下幂等框架的需求场景。

为了复用代码,我们把通用的功能设计成了公共服务平台。公司内部的其他金融产品的后台系统,会调用公共服务平台的服务,不需要完全从零开始开发。公共服务平台提供的是 RESTful 接口。为了简化开发,调用方一般使用 Feign 框架(一个 HTTP 框架)来访问公共服务平台的接口。

调用方访问公共服务平台的接口,会有三种可能的结果:成功、失败和超时。前两种结果非常明确,调用方可以自己决定收到结果之后如何处理。结果为“成功”,万事大吉。结果为“失败”,一般情况下,调用方会将失败的结果,反馈给用户(移动端 App),让用户自行决定是否重试。

但是,当接口请求超时时,处理起来就没那么容易了。有可能业务逻辑已经执行成功了,只是公共服务平台返回结果给调用方的时候超时了,但也有可能业务逻辑没有执行成功,比如,因为数据库当时存在集中写入,导致部分数据写入超时。总之,超时对应的执行结果是未决的。那调用方调用接口超时时(基于 Feign 框架开发的话,一般是收到 Timeout 异常),该如何处理呢?

如果接口只包含查询、删除、更新这些操作,那接口天然是幂等的。所以,超时之后,重新再执行一次,也没有任何副作用。不过,这里有两点需要特殊说明一下。

删除操作需要当心 ABA 问题。删除操作超时了,又触发一次删除,但在这次删除之前,又有一次新的插入。后一次删除操作删除了新插入的数据,而新插入的数据本不应该删除。不过,大部分业务都可以容忍 ABA 问题。对于少数不能容忍的业务场景,我们可以针对性的特殊处理。

除此之外,细究起来,update x = x+delta 这样格式的更新操作并非幂等,只有 update x=y 这样格式的更新操作才是幂等的。不过,后者也存在跟删除同样的 ABA 问题。

如果接口包含修改操作(插入操作、update x=x+delta 更新操作),多次重复执行有可能会导致业务上的错误,这是不能接受的。如果插入的数据包含数据库唯一键,可以利用数据库唯一键的排他性,保证不会重复插入数据。除此之外,一般我会建议调用方按照这样几种方式来处理。

第一种处理方式是,调用方访问公共服务平台接口超时时,返回清晰明确的提醒给用户,告知执行结果未知,让用户自己判断是否重试。不过,你可能会说,如果用户看到了超时提醒,但还是重新发起了操作,比如重新发起了转账、充值等操作,那该怎么办呢?实际上,对这种情况,技术是无能为力的。因为两次操作都是用户主动发起的,我们无法判断第二次的转账、充值是新的操作,还是基于上一次超时的重试行为。

第二种处理方式是,调用方调用其他接口,来查询超时操作的结果,明确超时操作对应的业务,是执行成功了还是失败了,然后再基于明确的结果做处理。但是这种处理方法存在一个问题,那就是并不是所有的业务操作,都方便查询操作结果。

第三种处理方式是,调用方在遇到接口超时之后,直接发起重试操作。这样就需要接口支持幂等。我们可以选择在业务代码中触发重试,也可以将重试的操作放到 Feign 框架中完成。因为偶尔发生的超时,在正常的业务逻辑中编写一大坨补救代码,这样做会影响到代码的可读性,有点划不来。当然,如果项目中需要支持超时重试的业务不多,那对于仅有几个业务,特殊处理一下也未尝不可。但是,如果项目中需要支持超时重试的业务比较多,我们最好是把超时重试这些非业务相关的逻辑,统一在框架层面解决。

对响应时间敏感的调用方来说,它们服务的是移动端的用户,过长的等待时间,还不如直接返回超时给用户。所以,这种情况下,第一种处理方式是比较推荐的。但是,对响应时间不敏感的调用方来说,比如 Job 类的调用方,我推荐选择后两种处理方式,能够提高处理的成功率。而第二种处理方法,本身有一定的局限性,因为并不是所有业务操作都方便查询是否执行成功。第三种保证接口幂等的处理方式,是比较通用的解决方案。所以,我们针对这种处理方式,抽象出一套统一的幂等框架,简化幂等接口的开发。

需求分析

刚刚我们介绍了幂等框架的需求背景:超时重试需要接口幂等的支持。接下来,我们再对需求进行更加详细的分析和整理,这其中就包括功能性需求和非功能性需求。

不过,在此之前,我们需要先搞清楚一个重要的概念:幂等号。

前面多次提到“幂等”,那“幂等”到底是什么意思呢?放到接口调用的这个场景里,幂等的意思是,针对同一个接口,多次发起同一个业务请求,必须保证业务只执行一次。那如何判定两次接口请求是同一个业务请求呢?也就是说,如何判断两次接口请求是重试关系?而非独立的两个业务请求?比如,两次调用转账接口,尽管转账用户、金额等参数都一样,但我们也无法判断这两个转账请求就是重试关系。

实际上,要确定重试关系,我们就需要给同一业务请求一个唯一标识,也就是“幂等号”!如果两个接口请求,带有相同的幂等号,那我们就判断它们是重试关系,是同一个业务请求,不要重复执行。

幂等号需要保证全局唯一性。它可以有业务含义,比如,用户手机号码是唯一的,对于用户注册接口来说,我们可以拿它作为幂等号。不过,这样就会导致幂等框架的实现,无法完全脱离具体的业务。所以,我们更加倾向于,通过某种算法来随机生成没有业务含义的幂等号。
幂等号的概念搞清楚了,我们再来看下框架的功能性需求。

前面也介绍了一些需求分析整理方法,比如画线框图、写用户用例、基于测试驱动开发等。跟限流框架类似,这里我们也借助用户用例和测试驱动开发的思想,先去思考,如果框架最终被开发出来之后,它会如何被使用。我写了一个框架使用的 Demo 示例,如下所示。

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
///////// 使用方式一: 在业务代码中处理幂等 ////////////
// 接口调用方
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 中)接入幂等框架,而不是将它耦合在业务代码中。

在性能方面,针对每个幂等接口,在正式处理业务逻辑之前,我们都要添加保证幂等的处理逻辑。这或多或少地会增加接口请求的响应时间。而对于响应时间比较敏感的接口服务来说,我们要让幂等框架尽可能低延迟,尽可能减少对接口请求本身响应时间的影响。

在容错性方面,跟限流框架相同,不能因为幂等框架本身的异常,导致接口响应异常,影响服务本身的可用性。所以,幂等框架要有高度的容错性。比如,存储幂等号的外部存储器挂掉了,幂等逻辑无法正常运行,这个时候业务接口也要能正常服务才行。

设计

幂等处理正常流程

调用方从发起接口请求到接收到响应,一般要经过三个阶段。第一个阶段是调用方发送请求并被实现方接收,第二个阶段是执行接口对应的业务逻辑,第三个阶段是将执行结果返回给调用方。为了实现接口幂等,我们需要将幂等相关的逻辑,添加在这三个阶段中。

正常情况下,幂等号随着请求传递到接口实现方之后,接口实现方将幂等号解析出来,传递给幂等框架。幂等框架先去数据库(比如 Redis)中查找这个幂等号是否已经存在。如果存在,说明业务逻辑已经或者正在执行,就不要重复执行了。如果幂等号不存在,就将幂等号存储在数据库中,然后再执行相应的业务逻辑。

正常情况下,幂等处理流程是非常简单的,难点在于如何应对异常情况。在这三个阶段中,如果第一个阶段出现异常,比如发送请求失败或者超时,幂等号还没有记录下来,重试请求会被执行,符合我们的预期。如果第三个阶段出现异常,业务逻辑执行完成了,只是在发送结果给调用方的时候,失败或者超时了,这个时候,幂等号已经记录下来,重试请求不会被执行,也符合我们的预期。也就是说,第一、第三阶段出现异常,上述的幂等处理逻辑都可以正确应对。

但是,如果第二个阶段业务执行的过程出现异常,处理起来就复杂多了。接下来,我们就看下幂等框架该如何应对这一阶段的各种异常。我分了三类异常来讲解,它们分别是业务代码异常、业务系统宕机、幂等框架异常。

业务代码异常处理

当业务代码在执行过程中抛出异常的时候,我们是否应该认定为业务处理失败,然后将已经记录的幂等号删除,允许重新执行业务逻辑呢?

对于这个问题,我们要分业务异常和系统异常来区分对待。那什么是业务异常?什么是系统异常呢?我举个例子解释一下。比如,A 用户发送消息给 B 用户,但是查询 B 用户不存在,抛出 UserNotExisting 异常,我们把这种业务上不符合预期叫做业务异常。因为数据库挂掉了,业务代码访问数据库时,就会报告数据库异常,我们把这种非业务层面的、系统级的异常,叫做系统异常。

遇到业务异常(比如 UserNotExisting 异常),我们不删除已经记录的幂等号,不允许重新执行同样的业务逻辑,因为再次重新执行也是徒劳的,还是会报告异常。相反,遇到系统异常(比如数据库访问异常),我们将已经记录的幂等号删除,允许重新执行这段业务逻辑。因为在系统级问题修复之后(比如数据库恢复了),重新执行之前失败的业务逻辑,就有可能会成功。

实际上,为了让幂等框架尽可能的灵活,低侵入业务逻辑,发生异常(不管是业务异常还是系统异常),是否允许再重试执行业务逻辑,交给开发这块业务的工程师来决定是最合适的了,毕竟他最清楚针对每个异常该如何处理。而幂等框架本身不参与这个决定,它只需要提供删除幂等号的接口,由业务工程师来决定遇到异常的时候,是否需要调用这个删除接口,删除已经记录的幂等号。

业务系统宕机处理

刚刚分析的是代码异常,我们再来看下,如果在业务处理的过程中,业务系统宕机了(你可以简单理解为部署了业务系统的机器宕机了),幂等框架是否还能正确工作呢?

如果幂等号已经记录下了,但是因为机器宕机,业务还没来得及执行,按照刚刚的幂等框架的处理流程,即便机器重启,业务也不会再被触发执行了,这个时候该怎么办呢?除此之外,如果记录幂等号成功了,但是在捕获到系统异常之后,要删除幂等号之前,机器宕机了,这个时候又该怎么办?

如果希望幂等号的记录和业务的执行完全一致,我们就要把它们放到一个事务中。执行成功,必然会记录幂等号;执行失败,幂等号记录也会被自动回滚。因为幂等框架和业务系统各自使用独立的数据库来记录数据,所以,这里涉及的事务属于分布式事务。如果为了解决这个问题,引入分布式事务,那幂等框架的开发难度提高了很多,并且框架使用起来也复杂了很多,性能也会有所损失。

针对这个问题,我们还有另外一种解决方案。那就是,在存储业务数据的业务数据库( 比如 MySQL)中,建一张表来记录幂等号。幂等号先存储到业务数据库中,然后再同步给幂等框架的 Redis 数据库。这样做的好处是,我们不需要引入分布式事务框架,直接利用业务数据库本身的事务属性,保证业务数据和幂等号的写入操作,要么都成功,要么都失败。不过,这个解决方案会导致幂等逻辑,跟业务逻辑没有完全解耦,不符合我们之前讲到的低侵入、松耦合的设计思想。

实际上,做工程不是做理论。对于这种极少发生的异常,在工程中,我们能够做到,在出错时能及时发现问题、能够根据记录的信息人工修复就可以了。虽然看起来解决方案不优雅,不够智能,不够自动化,但是,这比编写一大坨复杂的代码逻辑来解决,要好使得多。所以,我们建议业务系统记录 SQL 的执行日志,在日志中附加上幂等号。这样我们就能在机器宕机时,根据日志来判断业务执行情况和幂等号的记录是否一致。

幂等框架异常处理

我们前面提到,限流框架本身的异常,不能导致接口响应异常。那对于幂等框架来说,是否也适用这条设计原则呢?

对于限流来说,限流框架执行异常(比如,Redis 访问超时或者访问失败),我们可以触发服务降级,让限流功能暂时不起作用,接口还能正常执行。如果大量的限流接口调用异常,在具有完善监控的情况下,这些异常很快就会被运维发现并且修复,所以,短暂的限流失效,也不会对业务系统产生太多影响。毕竟限流只是一个针对突发情况的保护机制,平时并不起作用。如果偶尔的极个别的限流接口调用异常,本不应该被放过的几个接口请求,因为限流的暂时失效被放过了,对于这种情况,绝大部分业务场景都是可以接受的。毕竟限流不可能做到非常精确,多放过一两个接口请求几乎没影响。

对于幂等来说,尽管它应对的也是超时重试等特殊场景,但是,如果本不应该重新执行的业务逻辑,因为幂等功能的暂时失效,被重复执行了,就会导致业务出错(比如,多次执行转账,钱多转了)。对于这种情况,绝大部分业务场景都是无法接受的。所以,在幂等逻辑执行异常时,我们选择让接口请求也失败,相应的业务逻辑就不会被重复执行了。毕竟接口请求失败(比如转钱没转成功),比业务执行出错(比如多转了钱),修复的成本要低很多。

实现

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 行代码,就搞定了一个框架,是不是觉得有点不可思议。对于这段代码,你可以先思考下,有哪些值得优化的地方。

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
package com.monochrome.idempotence;

import org.apache.commons.pool2.impl.GenericObjectPoolConfig;
import redis.clients.jedis.HostAndPort;
import redis.clients.jedis.JedisCluster;

import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
/**
* @author monochrome
* @date 2022/10/26
*/
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 的角度,分析一下这段代码。我把我的所有意见都放到代码注释中了,你可以对照着代码一块看下。

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
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。

重构最小原型代码

1
2
3
4
5
6
// 代码目录结构
com.xzg.cd.idempotence
--Idempotence
--IdempotenceIdGenerator(幂等号生成类)
--IdempotenceStorage(接口:用来读写幂等号)
--RedisClusterIdempotenceStorage(IdempotenceStorage的实现类)
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
package com.monochrome.idempotence;

import com.monochrome.idempotence.storage.IdempotenceStorage;

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

public class Idempotence {

IdempotenceStorage idempotenceStorage;

public Idempotence(IdempotenceStorage idempotenceStorage) {
this.idempotenceStorage = idempotenceStorage;
}

public boolean saveIfAbsent(String idempotenceId) {
return idempotenceStorage.saveIfAbsent(idempotenceId);
}

public void delete(String idempotenceId) {
idempotenceStorage.delete(idempotenceId);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
package com.monochrome.idempotence;

import java.util.UUID;

/**
* @author monochrome
* @date 2022/10/26
*/
public class IdempotenceIdGenerator {
public String generateId() {
return UUID.randomUUID().toString();
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
package com.monochrome.idempotence.storage;

/**
* @author monochrome
* @date 2022/10/26
*/
public interface IdempotenceStorage {

boolean saveIfAbsent(String idempotenceId);

void delete(String idempotenceId);

}
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.idempotence.storage;

import org.apache.commons.pool2.impl.GenericObjectPoolConfig;
import redis.clients.jedis.HostAndPort;
import redis.clients.jedis.JedisCluster;

import java.util.HashSet;
import java.util.Set;

/**
* @author monochrome
* @date 2022/10/26
*/
public class RedisIdempotenceStorage 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);
}

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;
}

/**
* Save { @idempotenceId } into storage if it does not exist.
* @param idempotenceId 幂等ID
* @return true if the { @idempotenceId } is saved, otherwise return false
*/
@Override
public boolean saveIfAbsent(String idempotenceId) {
long result = jedisCluster.setnx(idempotenceId, "1");
return 1 == result;
}

@Override
public void delete(String idempotenceId) {
jedisCluster.del(idempotenceId);
}
}

接下来,我再总结罗列一下,针对之前发现的问题,我们都做了哪些代码改动。主要有下面这样几点,你可以结合着代码一块看下。

在代码可读性方面,我们对构造函数、saveIfAbsense() 函数的参数和返回值做了注释,并且将 genId() 函数改为全拼 generateId()。不过,对于这个函数来说,缩写实际上问题也不大。

在代码可扩展性方面,我们按照基于接口而非实现的编程原则,将幂等号的读写独立出来,设计成 IdempotenceStorage 接口和RedisClusterIdempotenceStorage 实现类。RedisClusterIdempotenceStorage 实现了基于 Redis Cluster 的幂等号读写。如果我们需要替换新的幂等号读写方式,比如基于单个 Redis 而非 Redis Cluster,我们就可以再定义一个实现了 IdempotenceStorage 接口的实现类:RedisIdempotenceStorage。

除此之外,按照接口隔离原则,我们将生成幂等号的代码抽离出来,放到 IdempotenceIdGenerator 类中。这样,调用方只需要依赖这个类的代码就可以了。幂等号生成算法的修改,跟幂等号存储逻辑的修改,两者完全独立,一个修改不会影响另外一个。

在代码可测试性方面,我们把原本放在构造函数中的逻辑抽离出来,放到了 parseHostAndPorts() 函数中。这个函数本应该是 Private 访问权限的,但为了方便编写单元测试,我们把它设置为成了 Protected 访问权限,并且通过注解 @VisibleForTesting 做了标明。

在代码灵活性方面,为了方便复用业务系统已经建立好的 jedisCluster,我们提供了一个新的构造函数,支持业务系统直接传递 jedisCluster 来创建 Idempotence 对象。