Jtoss Jtoss
首页
  • 数据结构与算法

    • 数据结构与算法 - 概述
    • 数据结构与算法 - 复杂度分析
    • 数据结构 - 线性表
    • 算法 - 常见排序算法
  • 代码规范

    • 代码简洁之道
    • 阿里巴巴开发手册
    • 谷歌Java编程风格指南
  • 设计模式

    • 编写高质量代码概述
    • 面向对象
    • 设计原则
    • 设计模式-创建型
    • 设计模式-结构型
    • 设计模式-行为型(上)
    • 设计模式-行为型(下)
    • 浅析框架源码中的设计模式
    • 业务框架实战案例
  • MySQL 基础

    • MySQL - 数据库设计规范
    • MySQL - 必知必会
  • MySQL 进阶

    • MySQL - 基础架构
    • MySQL - InnoDB存储引擎
    • MySQL - InnoDB缓冲池
    • MySQL - 事务与锁
    • MySQL - 索引
    • MySQL - 查询执行计划
    • MySQL - 性能优化
  • Redis 系列

    • Redis入门 - 基础相关
    • Redis进阶 - 数据结构
    • Redis进阶 - 持久化RDB和AOF
    • Redis进阶 - 事件机制
    • Redis进阶 - 事务
    • Redis进阶 - 高可用高可扩展
    • Redis进阶 - 缓存问题
    • Redis进阶 - 性能调优
  • Java 基础

    • Java 基础 - 知识点
    • Java 基础 - 面向对象
    • Java 基础 - Q/A
  • Java 进阶 - 集合框架

    • Java 集合框架详解
  • Java 进阶 - 多线程与并发

    • Java 并发 - 理论基础
    • Java 并发 - 线程基础
    • Java 并发 - 各种锁
    • Java 并发 - 关键字 volatile
    • Java 并发 - 关键字 synchronized
    • JUC - CAS与原子操作
    • JUC - 锁核心类AQS
    • JUC - 锁接口和类简介
    • JUC - 并发容器简介
    • JUC - 通信工具类
    • JUC - Fork-Join框架
    • JUC - 线程池
  • Java 进阶 - JVM

    • JVM - 概述
    • JVM - 类加载机制
    • JVM - 内存结构
    • JVM - 垃圾回收机制
    • JVM - 性能调优
  • Maven系列

    • Maven基础知识
    • Maven项目构建
    • Maven多模块配置
  • Spring 框架

    • Spring 框架 - 框架介绍
    • Spring 框架 - IOC详解
    • Spring 框架 - AOP详解
    • Spring 框架 - SpringMVC详解
  • Spring Boot 系列

    • Spring Boot - 开发入门
    • Spring Boot - 接口相关
  • Spring Cloud 系列
  • Mybatis 系列

    • Mybatis - 总体框架设计
    • Mybatis - 初始化基本过程
    • Mybatis - sqlSession执行过程
    • Mybatis - 插件机制
    • Mybatis - 事务管理机制
    • Mybatis - 缓存机制
  • 业务常见问题

    • Java 业务开发常见错误(一)
    • Java 业务开发常见错误(二)
    • Java 业务开发常见错误(三)
    • Java 业务开发常见错误(四)
    • Java 业务开发常见错误(五)
    • Java 业务开发常见错误(六)
  • IDEA系列

    • IDEA 2021开发环境配置
    • IDEA 快捷键
  • Git系列

    • git status中文乱码
  • 其他

    • Typora+Picgo 自动上传图片
    • hsdis 和 jitwatch
  • 实用技巧
  • 收藏
  • 摄影
  • 学习
  • 标签
  • 归档

Jason Huang

后端程序猿
首页
  • 数据结构与算法

    • 数据结构与算法 - 概述
    • 数据结构与算法 - 复杂度分析
    • 数据结构 - 线性表
    • 算法 - 常见排序算法
  • 代码规范

    • 代码简洁之道
    • 阿里巴巴开发手册
    • 谷歌Java编程风格指南
  • 设计模式

    • 编写高质量代码概述
    • 面向对象
    • 设计原则
    • 设计模式-创建型
    • 设计模式-结构型
    • 设计模式-行为型(上)
    • 设计模式-行为型(下)
    • 浅析框架源码中的设计模式
    • 业务框架实战案例
  • MySQL 基础

    • MySQL - 数据库设计规范
    • MySQL - 必知必会
  • MySQL 进阶

    • MySQL - 基础架构
    • MySQL - InnoDB存储引擎
    • MySQL - InnoDB缓冲池
    • MySQL - 事务与锁
    • MySQL - 索引
    • MySQL - 查询执行计划
    • MySQL - 性能优化
  • Redis 系列

    • Redis入门 - 基础相关
    • Redis进阶 - 数据结构
    • Redis进阶 - 持久化RDB和AOF
    • Redis进阶 - 事件机制
    • Redis进阶 - 事务
    • Redis进阶 - 高可用高可扩展
    • Redis进阶 - 缓存问题
    • Redis进阶 - 性能调优
  • Java 基础

    • Java 基础 - 知识点
    • Java 基础 - 面向对象
    • Java 基础 - Q/A
  • Java 进阶 - 集合框架

    • Java 集合框架详解
  • Java 进阶 - 多线程与并发

    • Java 并发 - 理论基础
    • Java 并发 - 线程基础
    • Java 并发 - 各种锁
    • Java 并发 - 关键字 volatile
    • Java 并发 - 关键字 synchronized
    • JUC - CAS与原子操作
    • JUC - 锁核心类AQS
    • JUC - 锁接口和类简介
    • JUC - 并发容器简介
    • JUC - 通信工具类
    • JUC - Fork-Join框架
    • JUC - 线程池
  • Java 进阶 - JVM

    • JVM - 概述
    • JVM - 类加载机制
    • JVM - 内存结构
    • JVM - 垃圾回收机制
    • JVM - 性能调优
  • Maven系列

    • Maven基础知识
    • Maven项目构建
    • Maven多模块配置
  • Spring 框架

    • Spring 框架 - 框架介绍
    • Spring 框架 - IOC详解
    • Spring 框架 - AOP详解
    • Spring 框架 - SpringMVC详解
  • Spring Boot 系列

    • Spring Boot - 开发入门
    • Spring Boot - 接口相关
  • Spring Cloud 系列
  • Mybatis 系列

    • Mybatis - 总体框架设计
    • Mybatis - 初始化基本过程
    • Mybatis - sqlSession执行过程
    • Mybatis - 插件机制
    • Mybatis - 事务管理机制
    • Mybatis - 缓存机制
  • 业务常见问题

    • Java 业务开发常见错误(一)
    • Java 业务开发常见错误(二)
    • Java 业务开发常见错误(三)
    • Java 业务开发常见错误(四)
    • Java 业务开发常见错误(五)
    • Java 业务开发常见错误(六)
  • IDEA系列

    • IDEA 2021开发环境配置
    • IDEA 快捷键
  • Git系列

    • git status中文乱码
  • 其他

    • Typora+Picgo 自动上传图片
    • hsdis 和 jitwatch
  • 实用技巧
  • 收藏
  • 摄影
  • 学习
  • 标签
  • 归档
  • 数据结构与算法

  • 代码规范与质量

  • 设计模式

    • 编写高质量代码概述
    • 面向对象
    • 设计原则
    • 规范与重构
    • 设计模式 - 创建型
    • 设计模式 - 结构型
    • 设计模式 - 行为型(上)
    • 设计模式 - 行为型(下)
    • 浅析 JDK 源码中的设计模式
    • 浅析 Google Guava
    • 浅析 Spring 框架
    • 浅析 Mybatis 框架
    • 实现一个支持各种算法的限流框架
    • 设计实现一个通用的接口幂等框架
      • 1. 需求背景
      • 2. 需求分析
        • 2.1 幂等号
        • 2.2 功能性需求
        • 2.3 非功能性需求
      • 3. 框架设计
        • 3.1 幂等处理正常流程
        • 3.2 业务代码异常
        • 3.3 业务系统宕机
        • 3.4 幂等框架异常
      • 4. 框架实现
        • 4.1 V1 版本功能需求
        • 4.2 最小原型代码实现
        • 4.3 Review 最小原型代码
        • 4.4. 重构最小原型代码
    • 设计实现一个支持自定义规则的灰度发布组件
  • 编程基础
  • 设计模式
Jason
目录

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

# 设计实现一个通用的接口幂等框架

来源:极客时间《设计模式之美》 (opens new window)专栏笔记

# 1. 需求背景

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

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

如果接口只包含查询、删除、更新这些操作,那接口天然是幂等的。所以超时之后,重新再执行一次,也没有任何副作用。不过,这里有两点需要特殊说明一下。删除操作需要当心 ABA 问题。删除操作超时了,又触发一次删除,但在这次删除之前,又有一次新的插入。后一次删除操作删除了新插入的数据,而新插入的数据本不应该删除。不过,大部分业务都可以容忍 ABA 问题。除此之外,update x = x+delta 这样格式的更新操作并非幂等,只有 update x=y 这样格式的更新操作才是幂等的。不过后者也存在跟删除同样的 ABA 问题。

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

第一种处理方式是:调用方访问公共服务平台接口超时时,返回清晰明确的提醒给用户,告知执行结果未知,让用户自己判断是否重试。不过如果用户看到了超时提醒,但还是重新发起了操作,对这种情况,技术是无能为力的。因为两次操作都是用户主动发起的,无法判断第二次是新的操作,还是基于上一次超时的重试行为。

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

第三种处理方式是,调用方在遇到接口超时之后,直接发起重试操作。这样就需要接口支持幂等。可以选择在业务代码中触发重试,也可以将重试的操作放到 Feign 框架中完成。如果项目中需要支持超时重试的业务比较多,最好是把超时重试这些非业务相关的逻辑,统一在框架层面解决。

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

# 2. 需求分析

# 2.1 幂等号

接口调用的这个场景里,幂等的意思是,针对同一个接口,多次发起同一个业务请求,必须保证业务只执行一次。那如何判定两次接口请求是同一个业务请求呢?要确定重试关系,就需要给同一业务请求一个唯一标识,也就是“幂等号”!如果两个接口请求,带有相同的幂等号,那就判断它们是重试关系,是同一个业务请求,不要重复执行。

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

# 2.2 功能性需求

借助用户用例和测试驱动开发的思想,先去思考,如果框架最终被开发出来之后,它会如何被使用。

///////// 使用方式一: 在业务代码中处理幂等 ////////////
// 接口调用方
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;
  }
}
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

从使用的角度来说,幂等框架的主要处理流程是这样的。接口调用方生成幂等号,并且跟随接口请求将幂等号传递给接口实现方。接口实现方接收到接口请求之后,按照约定,从 HTTP Header 或者接口参数中解析出幂等号,然后通过幂等号查询幂等框架。如果幂等号已经存在,说明业务已经执行或正在执行,则直接返回;如果幂等号不存在,说明业务没有执行过,则记录幂等号,继续执行业务。

# 2.3 非功能性需求

在易用性方面,希望框架接入简单方便,学习成本低。只需编写简单的配置以及少许代码,就能完成接入。除此之外,框架最好对业务代码低侵入松耦合,在统一的地方(比如 Spring AOP 中)接入幂等框架,而不是将它耦合在业务代码中。

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

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

# 3. 框架设计

# 3.1 幂等处理正常流程

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

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

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

但如果第二个阶段业务执行的过程出现异常,处理起来就复杂多了。可分为三类异常,分别是业务代码异常、业务系统宕机、幂等框架异常。

# 3.2 业务代码异常

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

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

# 3.3 业务系统宕机

如果幂等号已经记录下了,但是因为机器宕机,业务还没来得及执行,按照刚刚的幂等框架的处理流程,即便机器重启,业务也不会再被触发执行了,这个时候该怎么办呢?

如果希望幂等号的记录和业务的执行完全一致,就要把它们放到一个事务中。简单的解决方式是在存储业务数据的业务数据库( 比如 MySQL)中,建一张表来记录幂等号。幂等号先存储到业务数据库中,然后再同步给幂等框架的 Redis 数据库。

对于业务系统宕机这种极少发生的异常,在工程中能够做到在出错时能及时发现问题、能够根据记录的信息人工修复,就可以了。所以建议业务系统记录 SQL 的执行日志,在日志中附加上幂等号。这样就能在机器宕机时根据日志来判断业务执行情况和幂等号的记录是否一致。

# 3.4 幂等框架异常

对于幂等框架异常,跟限流框架异常处理对策不同,在幂等逻辑执行异常时,选择让接口请求也失败,相应的业务逻辑就不会被重复执行了,业务就不会出错。毕竟接口请求失败,比业务执行出错,修复的成本要低很多。

# 4. 框架实现

# 4.1 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 本身是单线程执行命令的,所以不存在刚刚讲到的并发问题。

# 4.2 最小原型代码实现

MVP 代码如下:

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

# 4.3 Review 最小原型代码

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

总结一下,MVP 代码主要涉及下面这样几个问题。

  • 代码可读性问题:有些函数的参数和返回值的格式和意义不够明确,需要注释补充解释一下。genId() 函数使用了缩写,全拼 generateId() 可能更好些!
  • 代码可扩展性问题:按照现在的代码实现方式,如果改变幂等号的存储方式和生成算法,代码修改起来会比较麻烦。除此之外,基于接口隔离原则,应该将 genId() 函数跟其他函数分离开来,放到两个类中。独立变化,隔离修改,更容易扩展!
  • 代码可测试性问题:解析 Redis Cluster 地址的代码逻辑较复杂,但因为放到了构造函数中,无法对它编写单元测试。
  • 代码灵活性问题:业务系统有可能希望幂等框架复用已经建立好的 jedisCluster,而不是单独给幂等框架创建一个 jedisCluster。

# 4.4. 重构最小原型代码

// 代码目录结构
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;
  }
}
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
71
72
73
74
75
76
77

在代码可读性方面,对构造函数、saveIfAbsense() 函数的参数和返回值做了注释,并且将 genId() 函数改为全拼 generateId()。

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

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

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

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

上次更新: 2023-02-25
实现一个支持各种算法的限流框架
设计实现一个支持自定义规则的灰度发布组件

← 实现一个支持各种算法的限流框架 设计实现一个支持自定义规则的灰度发布组件→

最近更新
01
开始
01-09
02
AI工具分享
01-09
03
AI 导读
01-07
更多文章>
Theme by Vdoing | Copyright © 2022-2025 Jason Huang | 闽ICP备2025088096号-1
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式