设计模式学习笔记 - 项目实战二:设计实现一个通用的接口幂等框架(实现)

概述

上篇文章,我们讲解了幂等框架的设计思路。在正常情况下,幂等框架的处理流程是比较简单的。调用方生成幂等号,传递给实现方,实现方记录幂等号或者用幂等号判重。但是,幂等框架要处理的异常情况很多,这也是设计的复杂之处和难点之处。比如,代码运行异常、业务系统宕机、幂等框架异常。

虽然幂等框架要处理的异常很多,但考虑开发成本以及简单易用性,我们对某些异常的处理在工程师做了妥协,交由业务系统或者人工介入处理。这样就打打简化了幂等框架开发的复杂度和难度。

本章,我们针对幂等框架的设计思路,讲解如何编码实现。跟限流框架一样,对于幂等框架,我们也会还原它的整个开发过程,从 V1 版本需求、最小原型代码将其,然后 review 代码发现问题、重构代码解决问题,最终得到一份易读、易扩展、灵活、可测试的高质量代码实现。


V1 版本功能需求

上篇文章给出的设计思路比较零散,重点还是在讲设计的缘由,为什么要这么设计。本章,我们在重新整理一下,经过上篇文章的分析梳理最终得到的设计思路。虽然上篇文章分析的很复杂、很烧脑,但思从深而行从简,最终得到的幂等框架的设计思路是很简单的,主要包括下面这样两个主要的功能开发点:

  • 实现生成幂等号功能
  • 实现存储、查询、删除幂等号功能。

因为功能非常简单,所以这里就不再进一步剪裁了。在 V1 版本中,我们会实现上面罗列的所有功能。针对这两个功能点,先来说下实现思路。

先看下,如何生成幂等号

幂等号用来标识两个接口请求是否是同一个业务,换句话说,两个接口请求是否是重试关系,而非独立的两个请求。接口调用方在发送接口请求时,将幂等号一块传递给接口实现方。那如何来生成幂等号呢?一般有两种方式。一种方式是集中生成并分派给调用方,另一种方式是直接由调用方生成。

对于第一种方式,我们要部署一套幂等号生成系统,并且提供相应的远程接口(RESTful 后者 RPC 接口),调用方通过远程接口来获取幂等号。这样做的好处是,对调用方完全隐藏了幂等号的实现细节。当我们需要改动幂等号的生成算法时,调用方不需要改动任何代码。

对于第二种生成方式,调用方按照跟接口实现方式预先商量好的算法,自己来生成幂等号。这种实现方式的好处在于,不用像第一种方式那样调用远程接口,所以执行效率高。但是,一旦需要修改幂等号的生成算法,就需要修改每个调用方的代码。

并且,每个调用方自己实现幂等号的生成算法也有问题。一方面,重复开发,违反 DRY 原则。另一方面,工程师的开发水平参差不齐,代码难免会由 bug。此外,对于复杂的幂等号生成算法,比如依赖外部系统 Redis 等,显然更加适合上一种实现方式,可以避免调用方为了使用幂等号引入新的外部系统。

权衡来讲,即考虑到生成幂等号的效率,又考虑到代码维护的成本,我们选择第二种实现方式,并且在此基础上做些改进,由幂等框架来统一提供幂等号生成算法的代码实现,并封装成开发类库,提供给各个调用方复用。此外,我们希望生成的幂等号的算法尽可能的简单,不依赖其他外部系统。

实际上,对于幂等号的唯一要求就是全局唯一。全局唯一的 ID 生成算法有很多。比如,简单点的有 UUID,复杂点的可以把应用名拼接在 UUID 上,方面做问题排查。总体上来讲,幂等号的生成算法并不难。

在看下,如何实现幂等号的存储、查询和删除

从现在的需求来看,幂等号只是为了判重。在数据库中,我们只需要存储一个幂等号就可以,不需要太复杂的数据结构,所以,我们不选择使用复杂的关系型数据库,而是选择使用更加简单的、读写更快速的兼职数据库,比如 Redis。

在幂等判重逻辑中,我们需要先检查幂等号是否存在。如果不存在,再将幂等号存储进 Redis。多个线程(同一个业务实例的多个线程)或或进程(多个业务实例)同时执行刚刚的 “检查 - 设置” 逻辑时,就会存在竞争关系。比如,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.parseInt(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是不是可以传递nullpublic 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.parseInt(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.example.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;128.91.12.3:3453;...* @param config should not be null*/public RedisClusterIdempotenceStorage(String redisClusterAddress, GenericObjectPoolConfig config) {Set<HostAndPort> redisNodes = parseHostAndPorts(redisClusterAddress);this.jedisCluster = new JedisCluster(redisNodes, config);}public RedisClusterIdempotenceStorage(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 retyrn false*/@Overridepublic boolean saveIfAbsent(String idempotenceId) {Long success = jedisCluster.setnx(idempotenceId, "1");return success == 1;}@Overridepublic void delete(String idempotenceId) {jedisCluster.del(idempotenceId);}@VisibleForTestingprotected 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.parseInt(hostAndPort[1])));}return redisNodes;}
}

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

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

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

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

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

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

总结

前面花了两篇文章,用很大的篇幅在讲需求设计,特别是设计的缘由。而真正到了实现环节,只用了 30 行代码,就实现了幂等框架。这就很好地体现了 “思从深而行从简” 的道理,对于不到 30 行代码,很多人觉得不大可能有啥优化的空间了,但我们今天还是提出了 7 个优化建议,并且对代码结构做了比较大的调整。这说明,只要仔细推敲,再小的代码都有值得优化的地方。

大项目和小项目,在编码这个层面,实际上没有太大的区别。再宏达的工程、再庞大的项目,也是一行一行写出来的。那些上来就要看上万行代码,分析庞大项目的人,大部分都还没有理解编码的精髓。编码本身就是一个很细节的事情,牛不牛也都隐藏在一行一行的代码中。空谈架构、设计、大道理,实际上没有太大意义,对你帮助不大。能沉下心来把细节都做好那才是真的牛!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/web/5091.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

零知识证明与同态加密:隐私计算的双剑

PrimiHub一款由密码学专家团队打造的开源隐私计算平台&#xff0c;专注于分享数据安全、密码学、联邦学习、同态加密等隐私计算领域的技术和内容。 在数字时代&#xff0c;隐私保护已成为全球关注的焦点。隐私计算作为解决数据隐私问题的关键技术&#xff0c;其核心目标是在不泄…

一款可视化正则表达式工具

regex-vis是一款在线免费且可视化的正则表达式工具 界面图&#xff1a; 只能输入由26个英文字母组成的字符串 ^[A-Za-z]$ 只能输入数字 ^[0-9]*$测试错误 测试正确 快来感受一下叭 官方网址&#xff1a; Regex VisRegex visualizer & editor, make the regular expr…

小红书聚光里的流量洼地:N个百万级别的捡钱机会

小红书聚光里的流量洼地&#xff1a;N个百万级别的捡钱机会 一、前言 在最近的分享会上&#xff0c;笔者主要围绕小红书聚光投流的经验和对其他行业赛道的调研&#xff0c;与大家探讨了小红书的红利机会。在这个竞争激烈的市场中&#xff0c;如何抓住小红书这一流量洼地&…

ESLint 和 Prettier 各自的作用及区别

ESLint 和 Prettier 各自的作用及区别 什么是 ESLint什么是 Prettiereslint 和 prettier 有哪些区别 什么是 ESLint 官方解释&#xff1a;ESLint 是一个可配置的 JavaScript linter。它可以帮助你发现并修复 JavaScript 代码中的问题。问题可以是任何事情&#xff0c;从潜在的运…

在Xshell中利用命令行工具rz和sz实现在无Xftp情况下高效上传与下载文件

在Xshell中利用命令行工具rz和sz实现在无Xftp情况下高效上传与下载文件 引言一、安装lrzsz套件二、应用场景与使用示例1. 上传文件至Linux服务器2. 从Linux服务器下载文件至本地 三、注意事项结语 引言 在日常运维工作中&#xff0c;使用Xshell作为Windows平台下连接Linux服务器…

CPU vs TPU vs GPU vs DPU:一文彻底搞懂这些到底是什么

嘿&#xff0c;科技爱好者们&#xff01;有没有想过在科技世界里到处飞舞的那些缩写是什么意思呢&#xff1f;&#x1f680; CPU、TPU、GPU&#xff0c;现在还有 DPU —— 听起来像是字母汤&#xff0c;对吧&#xff1f;别担心&#xff01;今天&#xff0c;我们就来深入了解这些…

回溯法——(1)装载问题(C语言讲解)

目录 一、装载问题 1.问题概括&#xff1a; 2.解决方案&#xff08;思路&#xff09;&#xff1a; 3.图片讲解&#xff08;超详细&#xff09;&#xff1a; 4.代码分析&#xff1a; 二、算法改进&#xff1a;引入上界函数 1.问题概念&#xff1a; 2.图片讲解&#xff1a…

程序设计:C语言 UNIX/LINUX 环境变量替换

初级代码游戏的专栏介绍与文章目录-CSDN博客 我的github&#xff1a;codetoys&#xff0c;所有代码都将会位于ctfc库中。已经放入库中我会指出在库中的位置。 这些代码大部分以Linux为目标但部分代码是纯C的&#xff0c;可以在任何平台上使用。 很多时候程序希望对配置参数做…

(提示词之家、ora.ai、科大讯飞、豆包、百科Ai)分享好用的ChatGPT

目录 1、提示词之家 2、ChatGPT | ora.ai 3、讯飞星火大模型-AI大语言模型-星火大模型-科大讯飞

【设计模式】工厂方法模式(Factory Method Pattern)

目录标题 工厂方法设计模式详解1. 介绍2. 结构3. 实现步骤3.1 创建抽象产品接口3.2 创建具体产品类3.3 创建抽象工厂接口3.4 创建具体工厂类3.5 客户端使用 4. 好处与优点5. 坏处与缺点6. 适用场景7. 总结 工厂方法设计模式详解 1. 介绍 工厂方法模式是一种创建型设计模式&am…

SpringCloud学习笔记(一)微服务介绍、服务拆分和RestTemplate远程调用、Eureka注册中心

文章目录 1 认识微服务1.1 单体架构1.2 分布式架构1.3 微服务1.4 SpringCloud1.5 总结 2 服务拆分与远程调用2.1 服务拆分原则2.2 服务拆分示例2.2.1 搭建项目2.2.2 创建数据库和表2.2.3 实现远程调用2.2.3.1 需求描述2.2.3.2 注册RestTemplate2.2.3.3 实现远程调用 2.2.4 提供…

strtok,perror,strerror函数·

strtok函数 strtok函数是C语言中的一个字符串函数&#xff0c;用于将一个字符串根据特定的分隔符拆分成多个子字符串。它的函数原型如下&#xff1a; char *strtok(char *str, const char *delim); 在这个函数中&#xff0c;str表示要进行拆分的字符串&#xff0c;delim表示…

Spark01 —— Spark基础

文章目录 Spark01 —— Spark基础一、为什么选择Spark&#xff1f;1.1 MapReduce编程模型的局限性1.2 Spark与MR的区别1.3 版本1.4 优势1.5 Spark其他知识1、多种运行模式2、技术栈3、spark-shell&#xff1a;Spark自带的交互式工具4、Spark服务 二、Spark的基础配置三、Spark实…

Spring-Mybatis-Xml管理(动态sql语句,sql语句复用)

目录 前置条件 动态SQL语句 动态删除数据 1.集合类型:数组 2.集合类型: List 型 SQL语句重用 说明 &#x1f9e8;前置条件 已经创建了实体类(这边举个例子) 实体类User表 表中的字段名User实体类的属性值id (bigint auto increment) 长整型 自动增长private Long iduser…

前端性能优化篇之如何对项目中的图片进行优化?常见的图片格式及使用场景

目录 如何对项目中的图片进行优化&#xff1f;常见的图片格式及使用场景 如何对项目中的图片进行优化&#xff1f; 优化项目中的图片对于提高网页性能和用户体验至关重要。 减少图片使用&#xff1a; 如果可能的话&#xff0c;尽量用 CSS 来代替一些修饰性图片&#xff0c;这样…

day17-day20_项目实战项目部署

万信金融 项目部署 目标&#xff1a; 理解DevOps概念 能够使用Docker Compose部署项目 理解持续集成的作用 会使用Jenkins进行持续集成 1 DevOps介绍 1.1 什么是DevOps DevOps是Development和Operations两个词的缩写&#xff0c;引用百度百科的定义&#xff1a; DevOps…

《C语言深度解剖》(10):数组指针、指针数组和数组指针数组

&#x1f921;博客主页&#xff1a;醉竺 &#x1f970;本文专栏&#xff1a;《C语言深度解剖》《精通C指针》 &#x1f63b;欢迎关注&#xff1a;感谢大家的点赞评论关注&#xff0c;祝您学有所成&#xff01; ✨✨&#x1f49c;&#x1f49b;想要学习更多C语言深度解剖点击专栏…

重学java 26.面向对象 内部类⭐

“别担心&#xff0c;你一定能如愿。” —— 24.4.29 1.什么时候使用内部类&#xff1a; 当一个事物的内部&#xff0c;还有一个部分需要完整的结构去描述&#xff0c;而内部的完整结构又只为外部事物提供服务&#xff0c;那么整个内部的完整结构最好使用内部类 比如&#xff1…

人工智能论文:BERT和GPT, GPT-2, GPT-3 的简明对比和主要区别

在BERT的论文里面&#xff1a; 2018.10 BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding&#xff0c;BERT已经解释了BERT&#xff0c;GPT&#xff0c;ELMo的区别。 *ELMo为双向RNN&#xff0c;请忽略。 主要区别&#xff1a; BERT使用的是…

SpringBoot整合Mybatis实现多数据源配置

文章目录 I Mybatis1.1 数据库连接相关配置1.2 定义读取文件名1.3 为单个SqlSession动态设置隔离级别1.4 mybatis-plus代码生成器II pagehelper分页2.1 pagehelper配置2.2 使用方法III mybatis-plus多数据源配置IV 数据库连接池4.1 Druid的属性配置4.2 监控配置4.3 展示Druid的…