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

概述

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

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

本章,我们针对幂等框架的设计思路,讲解如何编码实现。跟限流框架一样,对于幂等框架,我们也会还原它的整个开发过程,从 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是不是可以传递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.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
     */
    @Override
    public boolean saveIfAbsent(String idempotenceId) {
        Long success = jedisCluster.setnx(idempotenceId, "1");
        return success == 1;
    }

    @Override
    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.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.mfbz.cn/a/585582.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

JavaScript底层原理(栈、堆、主线程、任务队列、事件循环机制)

1. 栈(heap)和堆(stack) 栈是栈内存的简称&#xff0c;堆是堆内存的简称。顾名思义&#xff0c;内存是干啥的&#xff1f;内存就是用来存放数据的。 栈 栈只有一个入口&#xff0c;同时也是出口&#xff0c;数据遵循先进后出、后进先出的原则。 栈用于存放基本类型数据和引用…

vue 模板字符串

1.模板字符串换行问题 white-space: pre-wrap; 2. 鼠标移入 显示提示框 点击手动隐藏 myChart.on("mouseover", function (params) {myChart.dispatchAction({type: "downplay",}); }); tooltip: {show: true, //是否显示提示框组件&#xff0c;包括…

安卓获取SHA

1&#xff1a;安卓通过签名key获取SHA 方式有两种&#xff0c; 1、电脑上来存在eclipse的用户或正在使用此开发工具的用户就简单了&#xff0c;直接利用eclipse 走打包流程&#xff0c;再打包的时候选择相应的签名&#xff0c;那么在当前面板的下面便会出现签名的相关信息。 2、…

牛客热题:合并K个升序链表

&#x1f4df;作者主页&#xff1a;慢热的陕西人 &#x1f334;专栏链接&#xff1a;力扣刷题日记 &#x1f4e3;欢迎各位大佬&#x1f44d;点赞&#x1f525;关注&#x1f693;收藏&#xff0c;&#x1f349;留言 文章目录 牛客热题&#xff1a;合并K个升序链表题目链接&#…

【刷题篇】动态规划-完全背包问题(十一)

文章目录 1、完全背包2、零钱兑换3、零钱兑换 II4、完全平方数 1、完全背包 #include <iostream> #include<vector> using namespace std;int main() {int n,v;cin>>n>>v;vector<int> V(n1);vector<int> W(n1);for(int i1;i<n;i){cin&g…

【消息队列】延迟消息

延时消息 延迟消息死信交换机延迟消息的插件 延迟消息 生产者发送消息时指定一个时间&#xff0c;消费者不会立刻收到消息&#xff0c;而在指定时间之后才收到消息 比如说演唱会的票&#xff0c;抢上了但是迟迟未支付&#xff0c;但是库存已经占用&#xff0c;就需要用到延迟消…

【STM32】F405/407的模块总览图,记录查看

从STM32F405/407数据手册中提取&#xff0c;方便以后查看。主要是什么外设连接在什么总线上&#xff0c;时钟频率是多少。 TIM2、3、4、5、12、13、14在APB1上&#xff0c;最大频率84M TIM1、8、9、10、11在APB2上&#xff0c;最大频率168M

WEB攻防-PHP特性-piwigoCMS审计实例

前置知识&#xff1a;PHP函数缺陷 测试环境 &#xff1a;piwigo CMS 漏洞URL&#xff1a; 漏洞文件位置&#xff1a;\include \functions_rate.inc.php 漏洞产生入口文件&#xff1a;/picture.php picture.php中接受了一个GET方法action参数&#xff0c;作为switch...case.…

架设WebSocket的最后一环,如何设置好nginx反向代理

WebScoket都已经完工快一个月&#xff0c;经过一段时间的测试&#xff0c;公司还是准备把服务器换到鹅厂&#xff0c;用EO来解决CDN内容分发和DDOS防护问题&#xff0c;由于EO并不支持URL 路径转发&#xff0c;只支持转发到一个站点的80或则443端口&#xff0c;如果想做路径分发…

Python urllib 爬虫入门(2)

本文为Python urllib类库爬虫更入门的一些操作和爬虫实例及源码。 目录 模拟浏览器请求 简单模拟 设置随机user-agent 请求超时 HTTP请求类型 Get请求 Post请求 抓取网页动态请求 封装ajax请求 调用 循环调用 抓取小说 封装请求函数 把html写入本地分析 调用 正…

Arthas进阶

这里写自定义目录标题 六、class和classloader6、dump7、classloader 七、monitor/watch/trace/stack等核心命令的使用1、monitor2、watch3、trace4、stack5、tt6、option7、profiler 六、class和classloader 6、dump 将已加载类的字节码文件保存到特定目录&#xff1a;logs/…

【IR 论文】HyDE:让 LLM 对 query 做查询改写来改进 Dense Retrieval

论文&#xff1a;Precise Zero-Shot Dense Retrieval without Relevance Labels ⭐⭐⭐⭐ CMU, ACL 2023, arXiv:2212.10496 Code: github.com/texttron/hyde 文章目录 论文速读总结 论文速读 在以往的 dense retrieval 思路中&#xff0c;需要对 input query 做 encode 来得到…

C语言【动态内存】

1.为什么要有动态内存 我们现在掌握的内存开辟方法有&#xff1a; int val 20;//在栈空间开辟4个字节 char str[10]{0};//在栈空间开辟10个字节的连续的空间但是上述的方式有两个点要注意&#xff1a; 1.空间开辟的大小是固定的 2.数组在申明的时候&#xff0c;一定要指定数…

格雷希尔E10系列大电流测试连接器,在新能源汽车大电流接插件的电气测试方案

在新能源汽车的电驱动、电池包等设备的电测试处理中&#xff0c;格雷希尔E10系列电测试连接器具有显著的优势。E10系列的核心设计——插孔/插针&#xff0c;可以达到实验室10万次的插拔寿命&#xff0c;相比传统公母电接头500次左右的连接寿命&#xff0c;E10系列无疑大大减少测…

Golang错误处理机制

文章目录 Golang错误处理机制panic异常recover捕获异常自定义错误 Golang错误处理机制 panic异常 panic异常 Go的类型系统会在编译时捕获很多错误&#xff0c;但有些错误只能在运行时检查&#xff0c;比如除零错误、数组访问越界、空指针引用等&#xff0c;这些运行时错误会引…

实验15 MVC

二、实验项目内容&#xff08;实验题目&#xff09; 编写代码&#xff0c;掌握MVC的用法。 三、源代码以及执行结果截图&#xff1a; inputMenu.jsp&#xff1a; <% page contentType"text/html" %> <% page pageEncoding "utf-8" %> &…

day15 学一下Tailwindcss(java转ts全栈/3r教室)

目前距离全栈差得最多的是前端&#xff0c;而对于前端主要是CSS一直不熟悉&#xff0c;觉得很复杂写起来总是不上道&#xff0c;所以特别关注下Tailwindcss吧&#xff0c;其他前端框架可以先放放&#xff0c;多说无益直接用tailwindcss做个页面试试 看下文档&#xff1a;Tailwi…

【统计推断】-01 抽样原理之(四):中心极限定律

文章目录 一、说明二、样本均值的抽样分布三、两个重要公理四、中心极限定理4.1 定义4.2 中心极限定理的特点4.3 中心极限定理的条件 五、一个举例5.1 一个连续分布示例5.2 样本容量变化的对比 六、结论 关键词&#xff1a;    Central Limit Theorem    Law of Large Numb…

linux部署java1.8(java17)

两种方式&#xff1a; 方式一 1.输入查找命令&#xff1a; yum -y list java*2.输入安装命令&#xff1a; yum install -y java-1.8.0-openjdk.x86_643.测试是否已经安装&#xff1a; java -version方式二&#xff1a; 点击链接进入官网&#xff1a;https://www.oracle.com/…

mysql-sql练习-5-行列互转

目录 成绩单 简单互转 需求 多行转多列 分组 判断 聚合 理解 分组 合并 逆向需求 多列转多行 输出 合并 abc 去重 合并 拆分 需求 建表 多行转多列 逆向需求 多列转多行 拆分 按长度 拆分 按个数 成绩单 简单互转 需求 多行转多列 分组 判断 聚合 with tmp as(--…