开发者博客 – IT技术 尽在开发者博客

开发者博客 – 科技是第一生产力


  • 首页

  • 归档

  • 搜索

面试高频:MySQL是如何保证主从库数据一致性的? 介绍 写

发表于 2021-09-24

微信搜 欢少的成长之路

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。

介绍

大家好,我是Leo。前面文章我们介绍了WAL的安全机制。可以保证数据的安全性。通过安全性我们分析了binlog,redolog日志的写入机制。今天我们分析一下主从库的实现原理!MySQL是如何保证主从库的数据是一致的呢?

image.png

写作思路

根据读者与朋友的反馈,每篇文章我会加一块写作思路。让读者能更好的吸收相关知识,以及判断是否是自己所需要的知识。

image.png

步入正题

主从同步的基本流程

如下图所示,这是主从库的状态图。

image.png

  • 状态1:用户端访问MySQLA,A是主库,B是从库,B同步A的数据。
  • 状态2:用户端访问MySQLB,B是主库,A是从库,A同步B的数据。

主从在需要切换的时候就是由状态1转变成状态2的这个过程。

数据在从A同步B或者B同步到A。同步的线程具有超级管理员的权限。所以建议把从库设置成readonly模式的。因为这样可以避免主从同步的一个 “坑” 就是下面的双写。所以设置readonly百利而无一害。

  1. 可以防止其他运营的类的查询语句的误操作。造成数据不一致的问题
  2. 可以防止状态1和状态2在切换的时候也会有一些逻辑性的BUG问题

接下来我们把流程的每一步分析一下,如下图所示

image.png

  1. 在备库 B 上通过 change master 命令,设置主库 A 的 IP、端口、用户名、密码,以及要从哪个位置开始请求 binlog,这个位置包含文件名和日志偏移量
  2. 在备库 B 上执行 start slave 命令,这时候备库会启动两个线程,就是图中的 io_thread 和 sql_thread。其中 io_thread 负责与主库建立连接。
  3. 主库 A 校验完用户名、密码后,开始按照备库 B 传过来的位置,从本地读取 binlog,发给 B。
  4. 备库 B 拿到 binlog 后,写到本地文件,称为中转日志(relay log)。
  5. sql_thread 读取中转日志,解析出日志里的命令,并执行。

sql_thread线程我们在今后的文章中会详细介绍。这里就不做过多解释了!

根据上面的流程,我们一点一点剖析底层的流程。先来了解一下binlog传输吧

binlog格式的华山论剑

说到binlog传输的话,我们肯定要聊到它的格式问题。binlog常见的格式有两种,一种是statement,一种是row。还有一种格式叫作mixed。这种格式是前面两种格式的混合体。

下面我们举例论述一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
sql复制代码mysql> CREATE TABLE `t` (
`id` int(11) NOT NULL,
`a` int(11) DEFAULT NULL,
`t_modified` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `a` (`a`),
KEY `t_modified`(`t_modified`)
) ENGINE=InnoDB;

insert into t values(1,1,'2018-11-13');
insert into t values(2,2,'2018-11-12');
insert into t values(3,3,'2018-11-11');
insert into t values(4,4,'2018-11-10');
insert into t values(5,5,'2018-11-09');

我们先简单的执行一条删除语句,查看一下对应的binlog日志到底是什么样的。

1
sql复制代码mysql> delete from t /*comment*/  where a>=4 and t_modified<='2018-11-10' limit 1;

当binlog格式属于第一种情况时。 statement

binlog里面记录的是SQL语句的原文。可以用 mysql> show binlog events in 'master.000001'; 查看

image.png

分析图上的结果。

  • 第一行 SET @@SESSION.GTID_NEXT=’ANONYMOUS’你可以先忽略,后面文章我们会在介绍主备切换的时候再提到;
  • 第二行是一个 BEGIN,跟第四行的 commit 对应,表示中间是一个事务;
  • 第三行就是真实执行的语句了。可以看到,在真实执行的 delete 命令之前,还有一个“use ‘test’”命令。这条命令不是我们主动执行的,而是 MySQL 根据当前要操作的表所在的数据库,自行添加的。这样做可以保证日志传到备库去执行的时候,不论当前的工作线程在哪个库里,都能够正确地更新到 test 库的表 t。use ‘test’命令之后的 delete 语句,就是我们输入的 SQL 原文了。可以看到,binlog“忠实”地记录了 SQL 命令,甚至连注释也一并记录了。
  • 最后一行是一个 COMMIT。你可以看到里面写着 xid=61。

还记得xid是啥意思吗,我们一起回顾一下吧。

xid是binlog与redo log共同的数据字段,崩溃恢复的时候,会按顺序扫描redo log

  • 如果碰到既有 prepare、又有 commit 的 redo log,就直接提交;
  • 如果碰到只有 parepare、而没有 commit 的 redo log,就拿着 XID 去 binlog 找对应的事务。

image.png

为了说明 statement 和 row 格式的区别,我们来看一下这条 delete 命令的执行效果图

image.png

可以看到,这条delete产生了一条warning。是因为当前binlog设置的是statement格式的。并且delete带有limit,很可能会出现主从库数据不一致的情况。比如上面这个例子。

  1. 如果 delete 语句使用的是索引 a,那么会根据索引 a 找到第一个满足条件的行,也就是说删除的是 a=4 这一行;
  2. 但如果使用的是索引 t_modified,那么删除的就是 t_modified=’2018-11-09’也就是 a=5 这一行。

由于 statement 格式下,记录到 binlog 里的是语句原文,因此可能会出现这样一种情况:在主库执行这条 SQL 语句的时候,用的是索引 a;而在备库执行这条 SQL 语句的时候,却使用了索引 t_modified。因此,MySQL 认为这样写是有风险的。

那么,如果我把 binlog 的格式改为 binlog_format=‘row’, 是不是就没有这个问题了呢?我们先来看看这时候 binog 中的内容吧。

image.png

可以看到,与 statement 格式的 binlog 相比,前后的 BEGIN 和 COMMIT 是一样的。但是,row 格式的 binlog 里没有了 SQL 语句的原文,而是替换成了两个 event:Table_map 和 Delete_rows。

image.png

  • Table_map event,用于说明接下来要操作的表是 test 库的表 t;
  • Delete_rows event,用于定义删除的行为。

把格式改成row的话,我们是看不到详细信息的。还需要借助mysqlbinlog工具,用下面这个命令解析和查看binlog中的内容。从上图可以得知,这个事务的binlog是从8900这个位置开始的。所以可以用 start-position 参数来指定从这个位置的日志开始解析。

mysqlbinlog -vv data/master.000001 --start-position=8900;

image.png

  • server id 1,表示这个事务是在 server_id=1 的这个库上执行的。
  • 每个 event 都有 CRC32 的值,这是因为我把参数 binlog_checksum 设置成了 CRC32。
  • Table_map event 跟在图 5 中看到的相同,显示了接下来要打开的表,map 到数字 226。现在我们这条 SQL 语句只操作了一张表,如果要操作多张表呢?每个表都有一个对应的 Table_map event、都会 map 到一个单独的数字,用于区分对不同表的操作。
  • 我们在 mysqlbinlog 的命令中,使用了 -vv 参数是为了把内容都解析出来,所以从结果里面可以看到各个字段的值(比如,@1=4、 @2=4 这些值)。
  • binlog_row_image 的默认配置是 FULL,因此 Delete_event 里面,包含了删掉的行的所有字段的值。如果把 binlog_row_image 设置为 MINIMAL,则只会记录必要的信息,在这个例子里,就是只会记录 id=4 这个信息。
  • 最后的 Xid event,用于表示事务被正确地提交了。

你可以看到,当 binlog_format 使用 row 格式的时候,binlog 里面记录了真实删除行的主键 id,这样 binlog 传到备库去的时候,就肯定会删除 id=4 的行,不会有主备删除不同行的问题。

miexed是啥,给binlog起到了哪些作用

想要解决这个问题, 就需要说明一下row格式的binlog与statement格式的binlog有啥优缺点!

statement
记录的是大概的信息,几乎是我们的执行信息,我们看不到具体的逻辑是什么。所以如果同步到从库上,很容易会发现数据不一致的情况,所以出现了row格式。

row
row格式解决了statement的缺点。可以查到执行的详细信息,但是缺点也是相应暴露了出来,过于详细导致内存占用过大。比如删除一个几万的数据。row格式的binlog会记录每个数值记录。这样不仅会占用过多的空间,还会占用磁盘IO,影响整个MySQL的执行效率

miexed
横空出世,解决了statement不一致的问题,同时也解决了row格式的占用内存过大的缺点。主要的实现就是他会判断一下,这个binlog会不会引起数据不一致这个问题。如果会引起,那么久采用row格式的。如果不会引起,那么久采用statement格式的日志。

因此,如果你的线上 MySQL 设置的 binlog 格式是 statement 的话,那基本上就可以认为这是一个不合理的设置。你至少应该把 binlog 的格式设置为 mixed。

比如我们这个例子,设置为 mixed 后,就会记录为 row 格式;而如果执行的语句去掉 limit 1,就会记录为 statement 格式。

image.png

接下来,我们就分别从 delete、insert 和 update 这三种 SQL 语句的角度,来看看数据恢复的问题。

如果我执行的是 delete 语句,row 格式的 binlog 也会把被删掉的行的整行信息保存起来。所以,如果你在执行完一条 delete 语句以后,发现删错数据了,可以直接把 binlog 中记录的 delete 语句转成 insert,把被错删的数据插入回去就可以恢复了。

如果你是执行错了 insert 语句呢? 那就更直接了。row 格式下,insert 语句的 binlog 里会记录所有的字段信息,这些信息可以用来精确定位刚刚被插入的那一行。这时,你直接把 insert 语句转成 delete 语句,删除掉这被误插入的一行数据就可以了。

如果执行的是 update 语句的话,binlog 里面会记录修改前整行的数据和修改后的整行数据。所以,如果你误执行了 update 语句的话,只需要把这个 event 前后的两行信息对调一下,再去数据库里面执行,就能恢复这个更新操作了。

其实,由 delete、insert 或者 update 语句导致的数据操作错误,需要恢复到操作之前状态的情况,也时有发生。MariaDB 的Flashback工具就是基于上面介绍的原理来回滚数据的。

案例问题

虽然 mixed 格式的 binlog 现在已经用得不多了,但这里我还是要再借用一下 mixed 格式来说明一个问题,来看一下这条 SQL 语句 mysql> insert into t values(10,10, now());

如果我们把 binlog 格式设置为 mixed,你觉得 MySQL 会把它记录为 row 格式还是 statement 格式呢?

image.png

由输出结果得知,走的是statement格式。那如果传给主库同步的话,那里的时间肯定是不准的,造成主从库数据不一致啊。

接下来我们拿xid 用mysqlbinlog工具看一下

image.png

这里多了一个指令:SET TIMESTAMP=1546103491 它用 SET TIMESTAMP 命令约定了接下来的 now() 函数的返回时间。

因此,不论这个 binlog 是 1 分钟之后被备库执行,还是 3 天后用来恢复这个库的备份,这个 insert 语句插入的行,值都是固定的。也就是说,通过这条 SET TIMESTAMP 命令,MySQL 就确保了主备数据的一致性。

error: 一定不要用mysqlbinlog工具解析出数据,然后直接把里面的statement语句直接拷贝出来执行。 这样的操作是有风险的。所以一定要把整个结构都发给MySQL执行。

主从同步的循环复制问题

在我们真实的开发场景中,往往主库不会一直是主库,从库不会一直是从库。为了保证安全性。往往是这样设计的。

image.png

这样的就会出现另一个问题。业务逻辑在节点 A 上更新了一条语句,然后再把生成的 binlog 发给节点 B,节点 B 执行完这条更新语句后也会生成 binlog。(我建议你把参数 log_slave_updates 设置为 on,表示备库执行 relay log 后生成 binlog)。

那么,如果节点 A 同时是节点 B 的备库,相当于又把节点 B 新生成的 binlog 拿过来执行了一次,然后节点 A 和 B 间,会不断地循环执行这个更新语句,也就是循环复制了。这个要怎么解决呢?

解决方案:

  1. 规定两个库的 server id 必须不同,如果相同,则它们之间不能设定为主备关系;
  2. 一个备库接到 binlog 并在重放的过程中,生成与原 binlog 的 server id 相同的新的 binlog;
  3. 每个库在收到从自己的主库发过来的日志后,先判断 server id,如果跟自己的相同,表示这个日志是自己生成的,就直接丢弃这个日志。

按照这个逻辑,如果我们设置了双 M 结构,日志的执行流就会变成这样:

  1. 从节点 A 更新的事务,binlog 里面记的都是 A 的 server id;
  2. 传到节点 B 执行一次以后,节点 B 生成的 binlog 的 server id 也是 A 的 server id;
  3. 再传回给节点 A,A 判断到这个 server id 与自己的相同,就不会再处理这个日志。所以,死循环在这里就断掉了。

image.png

image.png

image.png

总结

这篇文章,我们介绍了MySQL是怎么保证主从库数据一致的原因,实现流程,binlog三种格式的优缺点,线上场景的MySQL主从库应用配置,主从库互相切换的循环复制问题以及解决方案。

知道的越多,不知道的就越多!愿今后的岁月,不忘初心,努力学习!都有一个不辜负的人生!

有任何问题都可以在一起讨论。 点赞+评论+关注是对博主最好的支持!

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

我用Python爬取英雄联盟的皮肤,隔壁家的小弟弟都馋哭了

发表于 2021-09-24

在这里插入图片描述

一、推理原理

1.先去《英雄联盟》官网找到英雄及皮肤图片的网址:

lol.qq.com/data/info-h…
在这里插入图片描述

2.从上面网址可以看到所有英雄都在,按下F12查看源代码,发现英雄及皮肤图片并没有直接给出,而是隐藏在JS文件中。这时候需要点开Network,找到js窗口,刷新网页,就看到一个champion.js的选项,点击可以看到一个字典——里面就包含了所有英雄的名字(英文)以及对应的编号。

在这里插入图片描述

3.但是只有英雄的名字(英文)以及对应的编号并不能找到图片地址,于是回到网页,随便点开一个英雄,跳转页面后发现英雄及皮肤的图片都在,但要下载还需要找到原地址,这是鼠标右击选择“在新标签页中打开”,新的网页才是图片的原地址。

在这里插入图片描述
4.图中红色框就是我们需要的图片地址,经过分析知道:每一个英雄及皮肤的地址只有编号不一样(ossweb-img.qq.com/images/lol/…
在这里插入图片描述

二、推理代码

第一步:获取js字典

1
2
3
4
5
6
7
8
python复制代码def path_js(url_js):
res_js = requests.get(url_js, verify = False).content
html_js = res_js.decode("gbk")
pat_js = r'"keys":(.*?),"data"'
enc = re.compile(pat_js)
list_js = enc.findall(html_js)
dict_js = eval(list_js[0])
return dict_js

第二步:从 js字典中提取到key值生成url列表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
python复制代码def path_url(dict_js):
pic_list = []
for key in dict_js:
for i in range(20):
xuhao = str(i)
if len(xuhao) == 1:
num_houxu = "00" + xuhao
elif len(xuhao) == 2:
num_houxu = "0" + xuhao
numStr = key+num_houxu
url = r'http://ossweb-img.qq.com/images/lol/web201310/skin/big'+numStr+'.jpg'
pic_list.append(url)
print(pic_list)
return pic_list

第三步:从 js字典中提取到value值生成name列表

1
2
3
4
5
6
7
python复制代码def name_pic(dict_js, path):
list_filePath = []
for name in dict_js.values():
for i in range(20):
file_path = path + name + str(i) + '.jpg'
list_filePath.append(file_path)
return list_filePath

在这里插入图片描述

第四步:下载并保存数据

1
2
3
4
5
6
7
8
9
10
python复制代码def writing(url_list, list_filePath):
try:
for i in range(len(url_list)):
res = requests.get(url_list[i], verify = False).content
with open(list_filePath[i], "wb") as f:
f.write(res)

except Exception as e:
print("下载图片出错,%s" %(e))
return False

第五步:执行主程序

1
2
3
4
5
6
7
python复制代码if __name__ == '__main__':
url_js = r'http://lol.qq.com/biz/hero/champion.js'
path = r'./data/' #图片存在的文件夹
dict_js = path_js(url_js)
url_list = path_url(dict_js)
list_filePath = name_pic(dict_js, path)
writing(url_list, list_filePath)

运行后会在控制台打印出每一张图片的网址:
在这里插入图片描述

在文件夹中可以看到图片已经下载好
如图:
在这里插入图片描述
在这里插入图片描述

点击领取.福利💗干货满满

Python开发环境安装教程

Python400集自学视频

软件开发常用词汇

Python学习路线图

3000多本Python电子书

以上就是我的分享,如果有什么不足之处请指出,感谢观看。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

告别Kafka Stream,让轻量级流处理更加简单

发表于 2021-09-24

简介: 还在花精力去选型Kafka组件去做清洗转化?来试试Kafka ETL任务功能!

一说到数据孤岛,所有技术人都不陌生。在 IT 发展过程中,企业不可避免地搭建了各种业务系统,这些系统独立运行且所产生的数据彼此独立封闭,使得企业难以实现数据共享和融合,并形成了”数据孤岛”。

由于数据散落在不同数据库、消息队列中,计算平台直接访问这些数据时可能遇到可用性、传输延迟,甚至系统吞吐问题。如果上升到业务层面,我们会发现这些场景随时都会遇到:汇总业务交易数据、旧系统数据迁移到新系统中、不同系统数据整合。因此,为了能让数据更加实时、高效的融合并支持各业务场景,企业通常选择使用各种 ETL 工具以达到上述目的。

因此,我们可以看到企业自行探索的各种解决方案,比如使用自定义脚本,或使用服务总线(Enterprise Service Bus,ESB)和消息队列(Message Queue,MQ),比如使用企业应用集成(Enterprise application integration,EAI)通过底层结构的设计来横贯企业异构系统、应用、数据源等,实现数据的无缝共享与交换。

尽管以上手段都算实现了有效实时处理,但也给企业带来更难决断的选择题:实时,但不可扩展,或可扩展。但批处理。与此同时,随着数据技术、业务需求的不断发展,企业对 ETL 的要求也不断提升:

  • 除了支持事务性数据,也需要能够处理诸如 Log、Metric 等类型越来越丰富的数据源;
  • 批处理速度需要进一步提升;
  • 底层技术架构需要支持实时处理,并向以事件为中心演进。

可以看到,流处理/实时处理平台作为事件驱动交互的基石。它向企业提供了全局化的数据/事件链接、即时数据访问、单一系统统管全域数据以及持续索引/查询能力。也正是面对以上技术与业务需求,Kafka 提供了一个全新思路:

  • 作为实时、可扩展消息总线,不再需要企业应用集成;
  • 为所有消息处理目的地提供流数据管道;
  • 作为有状态流处理微服务的基础构建块。

我们以购物网站数据分析场景为例,为了实现精细化运营,运营团队以及产品经理需要将众多用户行为、业务数据以及其他数据数据进行汇总,这其中包括但不限于:

  1. 用户各类点击、浏览、加购、登陆等行为数据;
  1. 基础日志数据;
  1. APP 主动上传数据;
  1. 来自 db 中的数据;
  1. 其他。

这些数据汇集到 Kafka,然后数据分析工具统一从 Kafka 中获取所需的数据进行分析计算。由于 Kafka 采集的数据源非常多且格式也各种各样。在数据进入下游数据分析工具之前,需要进行数据清洗,例如过滤、格式化。在这里研发团队有两个选择:(1)写代码去消费 Kafka 中的消息,清洗完成后发送到目标 Kafka Topic。(2)使用组件进行数据清洗转换,例如:Logstash、Kafka Stream、Kafka Connector、Flink等。

看在这里,大家肯定会有疑问:Kafka Stream 作为流式处理类库,直接提供具体的类给开发者调用,整个应用的运行方式主要由开发者控制,方便使用和调试。这有什么问题吗?虽然以上方法确实能够很快解决问题,但其问题也显而易见。

  • 研发团队需要自行编写代码,且需要后期持续维护,运维成本较大;
  • 对于很多轻量或简单计算需求,引入一个全新组件的技术成本过高,需要进行技术选型;
  • 在某组件选定后,需要研发团队进行学习并持续维护,这就带来了不可预期的学习成本、维护成本。

为了解决问题,我们提供了一个更加轻量的解决方案:Kafka ETL 功能。

使用 Kafka ETL 功能后,只需通过 Kafka 控制台进行简单配置,在线写一段清洗代码,即可实现 ETL 的目的。可能存在的高可用、维护等问题,完全交由 Kafka。

那么接下来,我们为大家展示如何快速的创建数据 ETL 任务,仅需 3 步即可。

Step 1 : 创建任务

选择 Kafka 来源实例、来源 Topic,以及对应的选择 Kafka 目标实例、目标 Topic。并配置消息初始位置、失败处理以及创建资源方式。

Step 2:编写ETL主逻辑

我们可以选择 Python3 作为函数语言。与此同时,这里提供了多种数据清洗、数据转化模板,比如规则过滤、字符串替换、添加前/后缀等常用函数。

Step 3:设置任务运行、异常参数配置,并执行

可以看到,无需额外的组件接入或者复杂的配置,更轻量、更低成本的 Kafka ETL 仅需 3-5 步的可视化配置,即可开始 ETL 任务。对于数据 ETL 要求相对简单的团队而言,Kafka ETL 成为最佳选择,可以将更多精力放在业务研发上。

原文链接

本文为阿里云原创内容,未经允许不得转载。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Go 并发读写 syncmap 的强大之处

发表于 2021-09-24

大家好,我是煎鱼。

在之前的 《为什么 Go map 和 slice 是非线程安全的?》 文章中,我们讨论了 Go 语言的 map 和 slice 非线程安全的问题,基于此引申出了 map 的两种目前在业界使用的最多的并发支持的模式。

分别是:

  • 原生 map + 互斥锁或读写锁 mutex。
  • 标准库 sync.Map(Go1.9及以后)。

有了选择,总是有选择困难症的,这两种到底怎么选,谁的性能更加的好?我有一个朋友说 标准库 sync.Map 性能菜的很,不要用。我到底听谁的…

今天煎鱼就带你揭秘 Go sync.map,我们先会了解清楚什么场景下,Go map 的多种类型怎么用,谁的性能最好!

接着根据各 map 性能分析的结果,针对性的对 sync.map 进行源码解剖,了解 WHY。

一起愉快地开始吸鱼之路。

sync.Map 优势

在 Go 官方文档中明确指出 Map 类型的一些建议:

图片

  • 多个 goroutine 的并发使用是安全的,不需要额外的锁定或协调控制。
  • 大多数代码应该使用原生的 map,而不是单独的锁定或协调控制,以获得更好的类型安全性和维护性。

同时 Map 类型,还针对以下场景进行了性能优化:

  • 当一个给定的键的条目只被写入一次但被多次读取时。例如在仅会增长的缓存中,就会有这种业务场景。
  • 当多个 goroutines 读取、写入和覆盖不相干的键集合的条目时。

这两种情况与 Go map 搭配单独的 Mutex 或 RWMutex 相比较,使用 Map 类型可以大大减少锁的争夺。

性能测试

听官方文档介绍了一堆好处后,他并没有讲到缺点,所说的性能优化后的优势又是否真实可信。我们一起来验证一下。

首先我们定义基本的数据结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
go复制代码// 代表互斥锁
type FooMap struct {
 sync.Mutex
 data map[int]int
}

// 代表读写锁
type BarRwMap struct {
 sync.RWMutex
 data map[int]int
}

var fooMap *FooMap
var barRwMap *BarRwMap
var syncMap *sync.Map

// 初始化基本数据结构
func init() {
 fooMap = &FooMap{data: make(map[int]int, 100)}
 barRwMap = &BarRwMap{data: make(map[int]int, 100)}
 syncMap = &sync.Map{}
}

在配套方法上,常见的增删改查动作我们都编写了相应的方法。用于后续的压测(只展示部分代码):

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
go复制代码func builtinRwMapStore(k, v int) {
 barRwMap.Lock()
 defer barRwMap.Unlock()
 barRwMap.data[k] = v
}

func builtinRwMapLookup(k int) int {
 barRwMap.RLock()
 defer barRwMap.RUnlock()
 if v, ok := barRwMap.data[k]; !ok {
  return -1
 } else {
  return v
 }
}

func builtinRwMapDelete(k int) {
 barRwMap.Lock()
 defer barRwMap.Unlock()
 if _, ok := barRwMap.data[k]; !ok {
  return
 } else {
  delete(barRwMap.data, k)
 }
}

其余的类型方法基本类似,考虑重复篇幅问题因此就不在此展示了。

压测方法基本代码如下:

1
2
3
4
5
6
7
8
9
css复制代码func BenchmarkBuiltinRwMapDeleteParalell(b *testing.B) {
 b.RunParallel(func(pb *testing.PB) {
  r := rand.New(rand.NewSource(time.Now().Unix()))
  for pb.Next() {
   k := r.Intn(100000000)
   builtinRwMapDelete(k)
  }
 })
}

这块主要就是增删改查的代码和压测方法的准备,压测代码直接复用的是大白大佬的 go19-examples/benchmark-for-map 项目。

也可以使用 Go 官方提供的 map_bench_test.go,有兴趣的小伙伴可以自己拉下来运行试一下。

压测结果

1)写入:

方法名 含义 压测结果
BenchmarkBuiltinMapStoreParalell-4 map+mutex 写入元素 237.1 ns/op
BenchmarkSyncMapStoreParalell-4 sync.map 写入元素 509.3 ns/op
BenchmarkBuiltinRwMapStoreParalell-4 map+rwmutex 写入元素 207.8 ns/op

在写入元素上,最慢的是 sync.map 类型,其次是原生 map+互斥锁(Mutex),最快的是原生 map+读写锁(RwMutex)。

总体的排序(从慢到快)为:SyncMapStore < MapStore < RwMapStore。

2)查找:

方法名 含义 压测结果
BenchmarkBuiltinMapLookupParalell-4 map+mutex 查找元素 166.7 ns/op
BenchmarkBuiltinRwMapLookupParalell-4 map+rwmutex 查找元素 60.49 ns/op
BenchmarkSyncMapLookupParalell-4 sync.map 查找元素 53.39 ns/op

在查找元素上,最慢的是原生 map+互斥锁,其次是原生 map+读写锁。最快的是 sync.map 类型。

总体的排序为:MapLookup < RwMapLookup < SyncMapLookup。

3)删除:

方法名 含义 压测结果
BenchmarkBuiltinMapDeleteParalell-4 map+mutex 删除元素 168.3 ns/op
BenchmarkBuiltinRwMapDeleteParalell-4 map+rwmutex 删除元素 188.5 ns/op
BenchmarkSyncMapDeleteParalell-4 sync.map 删除元素 41.54 ns/op

在删除元素上,最慢的是原生 map+读写锁,其次是原生 map+互斥锁,最快的是 sync.map 类型。

总体的排序为:RwMapDelete < MapDelete < SyncMapDelete。

场景分析

根据上述的压测结果,我们可以得出 sync.Map 类型:

  • 在读和删场景上的性能是最佳的,领先一倍有多。
  • 在写入场景上的性能非常差,落后原生 map+锁整整有一倍之多。

因此在实际的业务场景中。假设是读多写少的场景,会更建议使用 sync.Map 类型。

但若是那种写多的场景,例如多 goroutine 批量的循环写入,那就建议另辟途径了,性能不忍直视(无性能要求另当别论)。

sync.Map 剖析

清楚如何测试,测试的结果后。我们需要进一步深挖,知其所以然。

为什么 sync.Map 类型的测试结果这么的 “偏科”,为什么读操作性能这么高,写操作性能低的可怕,他是怎么设计的?

数据结构

sync.Map 类型的底层数据结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
go复制代码type Map struct {
 mu Mutex
 read atomic.Value // readOnly
 dirty map[interface{}]*entry
 misses int
}

// Map.read 属性实际存储的是 readOnly。
type readOnly struct {
 m       map[interface{}]*entry
 amended bool
}
  • mu:互斥锁,用于保护 read 和 dirty。
  • read:只读数据,支持并发读取(atomic.Value 类型)。如果涉及到更新操作,则只需要加锁来保证数据安全。
  • read 实际存储的是 readOnly 结构体,内部也是一个原生 map,amended 属性用于标记 read 和 dirty 的数据是否一致。
  • dirty:读写数据,是一个原生 map,也就是非线程安全。操作 dirty 需要加锁来保证数据安全。
  • misses:统计有多少次读取 read 没有命中。每次 read 中读取失败后,misses 的计数值都会加 1。

在 read 和 dirty 中,都有涉及到的结构体:

1
2
3
rust复制代码type entry struct {
 p unsafe.Pointer // *interface{}
}

其包含一个指针 p, 用于指向用户存储的元素(key)所指向的 value 值。

在此建议你必须搞懂 read、dirty、entry,再往下看,食用效果会更佳,后续会围绕着这几个概念流转。

查找过程

划重点,Map 类型本质上是有两个 “map”。一个叫 read、一个叫 dirty,长的也差不多:

图片

sync.Map 的 2 个 map

当我们从 sync.Map 类型中读取数据时,其会先查看 read 中是否包含所需的元素:

  • 若有,则通过 atomic 原子操作读取数据并返回。
  • 若无,则会判断 read.readOnly 中的 amended 属性,他会告诉程序 dirty 是否包含 read.readOnly.m 中没有的数据;因此若存在,也就是 amended 为 true,将会进一步到 dirty 中查找数据。

sync.Map 的读操作性能如此之高的原因,就在于存在 read 这一巧妙的设计,其作为一个缓存层,提供了快路径(fast path)的查找。

同时其结合 amended 属性,配套解决了每次读取都涉及锁的问题,实现了读这一个使用场景的高性能。

写入过程

我们直接关注 sync.Map 类型的 Store 方法,该方法的作用是新增或更新一个元素。

源码如下:

1
2
3
4
5
6
7
go复制代码func (m *Map) Store(key, value interface{}) {
 read, _ := m.read.Load().(readOnly)
 if e, ok := read.m[key]; ok && e.tryStore(&value) {
  return
 }
  ...
}

调用 Load 方法检查 m.read 中是否存在这个元素。若存在,且没有被标记为删除状态,则尝试存储。

若该元素不存在或已经被标记为删除状态,则继续走到下面流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
scss复制代码func (m *Map) Store(key, value interface{}) {
 ...
 m.mu.Lock()
 read, _ = m.read.Load().(readOnly)
 if e, ok := read.m[key]; ok {
  if e.unexpungeLocked() {
   m.dirty[key] = e
  }
  e.storeLocked(&value)
 } else if e, ok := m.dirty[key]; ok {
  e.storeLocked(&value)
 } else {
  if !read.amended {
   m.dirtyLocked()
   m.read.Store(readOnly{m: read.m, amended: true})
  }
  m.dirty[key] = newEntry(value)
 }
 m.mu.Unlock()
}

由于已经走到了 dirty 的流程,因此开头就直接调用了 Lock 方法上互斥锁,保证数据安全,也是凸显性能变差的第一幕。

其分为以下三个处理分支:

  • 若发现 read 中存在该元素,但已经被标记为已删除(expunged),则说明 dirty 不等于 nil(dirty 中肯定不存在该元素)。其将会执行如下操作。
  • 将元素状态从已删除(expunged)更改为 nil。
  • 将元素插入 dirty 中。
  • 若发现 read 中不存在该元素,但 dirty 中存在该元素,则直接写入更新 entry 的指向。
  • 若发现 read 和 dirty 都不存在该元素,则从 read 中复制未被标记删除的数据,并向 dirty 中插入该元素,赋予元素值 entry 的指向。

我们理一理,写入过程的整体流程就是:

  • 查 read,read 上没有,或者已标记删除状态。
  • 上互斥锁(Mutex)。
  • 操作 dirty,根据各种数据情况和状态进行处理。

回到最初的话题,为什么他写入性能差那么多。究其原因:

  • 写入一定要会经过 read,无论如何都比别人多一层,后续还要查数据情况和状态,性能开销相较更大。
  • (第三个处理分支)当初始化或者 dirty 被提升后,会从 read 中复制全量的数据,若 read 中数据量大,则会影响性能。

可得知 sync.Map 类型不适合写多的场景,读多写少是比较好的。

若有大数据量的场景,则需要考虑 read 复制数据时的偶然性能抖动是否能够接受。

删除过程

这时候可能有小伙伴在想了。写入过程,理论上和删除不会差太远。怎么 sync.Map 类型的删除的性能似乎还行,这里面有什么猫腻?

源码如下:

1
2
3
4
5
6
7
8
go复制代码func (m *Map) LoadAndDelete(key interface{}) (value interface{}, loaded bool) {
 read, _ := m.read.Load().(readOnly)
 e, ok := read.m[key]
 ...
  if ok {
  return e.delete()
 }
}

删除是标准的开场,依然先到 read 检查该元素是否存在。

若存在,则调用 delete 标记为 expunged(删除状态),非常高效。可以明确在 read 中的元素,被删除,性能是非常好的。

若不存在,也就是走到 dirty 流程中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
scss复制代码func (m *Map) LoadAndDelete(key interface{}) (value interface{}, loaded bool) {
 ...
 if !ok && read.amended {
  m.mu.Lock()
  read, _ = m.read.Load().(readOnly)
  e, ok = read.m[key]
  if !ok && read.amended {
   e, ok = m.dirty[key]
   delete(m.dirty, key)
   m.missLocked()
  }
  m.mu.Unlock()
 }
 ...
 return nil, false
}

若 read 中不存在该元素,dirty 不为空,read 与 dirty 不一致(利用 amended 判别),则表明要操作 dirty,上互斥锁。

再重复进行双重检查,若 read 仍然不存在该元素。则调用 delete 方法从 dirty 中标记该元素的删除。

需要注意,出现频率较高的 delete 方法:

1
2
3
4
5
6
7
8
9
10
11
go复制代码func (e *entry) delete() (value interface{}, ok bool) {
 for {
  p := atomic.LoadPointer(&e.p)
  if p == nil || p == expunged {
   return nil, false
  }
  if atomic.CompareAndSwapPointer(&e.p, p, nil) {
   return *(*interface{})(p), true
  }
 }
}

该方法都是将 entry.p 置为 nil,并且标记为 expunged(删除状态),而不是真真正正的删除。

注:不要误用 sync.Map,前段时间从字节大佬分享的案例来看,他们将一个连接作为 key 放了进去,于是和这个连接相关的,例如:buffer 的内存就永远无法释放了…

总结

通过阅读本文,我们明确了 sync.Map 和原生 map +互斥锁/读写锁之间的性能情况。

标准库 sync.Map 虽说支持并发读写 map,但更适用于读多写少的场景,因为他写入的性能比较差,使用时要考虑清楚这一点。

另外我们针对 sync.Map 的性能差异,进行了深入的源码剖析,了解到了其背后快、慢的原因,实现了知其然知其所以然。

经常看到并发读写 map 导致致命错误,实在是令人忧心。大家觉得如果本文不错,欢迎分享给更多的 Go 爱好者 :)

若有任何疑问欢迎评论区反馈和交流,最好的关系是互相成就,各位的点赞就是煎鱼创作的最大动力,感谢支持。

文章持续更新,可以微信搜【脑子进煎鱼了】阅读,本文 GitHub github.com/eddycjy/blo… 已收录,欢迎 Star 催更。

参考

  • Package sync
  • 踩了 Golang sync.Map 的一个坑
  • go19-examples/benchmark-for-map
  • 通过实例深入理解sync.Map的工作原理

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

文档在线预览解决方案——kkFileView

发表于 2021-09-24

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。

最近工作项目需要有文档在线查看功能,刚开始用了自己老项目的pdf.js,后来移动端也要用,用户上传的文档还有excel和ppt,头疼。。。。

在网上找了一圈,又有兄弟推荐了kkFileView,便尝试下。后来证实的确好用!

1.kkFileView的简介

官网地址kkfileview.keking.cn/zh-cn/index…

kkFileView为文件文档在线预览解决方案,该项目使用流行的spring boot搭建,易上手和部署,基本支持主流办公文档的在线预览,如doc,docx,xls,xlsx,ppt,pptx,pdf,txt,zip,rar,图片,视频,音频等等

image.png

这看着简介就很诱人,不需要其他操作,直接相当于一个服务启动就可以了。

2.下载部署

下载地址就直接从官网的跳转到码云上下载最新的,或直接去码云下载gitee.com/kekingcn/fi…

下载的过程正好看下官方文档,有环境要求的。

image.png

本地看下都已经有了。

查看java环境的方法 开始菜单->运行->cmd->java -version

image.png

就可以看到本地的java环境了。如果没有的话就去官网下载jdk安装就行。

这个时候kkFileView的在安装包也下载好了,解压后的文件

image.png

我们直接找到bin下的startup脚本文件,右键以管理员身份运行即可(Linux以root用户运行startup.sh)。

运行后出现下面的截图

image.png
然后我们本地浏览器访问本机8012端口http://127.0.0.1:8012

image.png
这样本地部署就好了,下面我们项目中测试下吧。

3.项目使用

根据文档项目中使用的方法很简单,代码如下:

1
2
javascript复制代码 var url = 'http://127.0.0.1:8080/file/test.txt'; //要预览文件的访问地址
window.open('http://127.0.0.1:8012/onlinePreview?url='+encodeURIComponent(Base64.encode(url)));

简简单单两行代码搞定。
然后移动端的同事也试下,搞定。
不仅ppt、excel支持,就连zip等压缩文件都可以,实在太强大了。就是excel的速度稍微慢点。

4.过程中的问题

4.1服务器部署

我们的服务器是Windows Server 2008 的。本来以为系统自带了office等插件就没管,后来运行的时候一直报错。就想当然的分别安装了wps,修复了office套件还是无果,最后下载了LiberOffice才可以。

4.2配置文件

配置文件是config下的application。
这里可以修改端口号、文件上传的大小、水印等。端口号修改后记得服务器对应的端口要开发哦,当时就是忘记开放了,搞了好久。

image.png

image.png

至此文档在线预览就简单搞定了,里面还有很多其他强大的功能等待XDM去发掘哦。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

mybatis-plus 自定义UpdateWrapper(

发表于 2021-09-24

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。

前言

crud业务中难免会有列的自增、自减,如果项目中集成的是mybatis-plus的话不做任何修改大概只有两种方案

  • 使用UpdateWrapper拼接
  • 直接写原生sql到xml中

但是两种方法都不优雅,因为都需要写死列名字(如果优雅我还写啥文章。。。)
那么我就尝试能够实现自定义的LambdaUpdateWrapper

1
2
3
4
5
xml复制代码<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.0.1</version>
</dependency>

源码分析

1
2
3
4
5
6
java复制代码public LambdaUpdateWrapper<T> set(boolean condition, SFunction<T, ?> column, Object val) {
if (condition) {
sqlSet.add(String.format("%s=%s", columnToString(column), formatSql("{0}", val)));
}
return typedThis;
}

可以看到非常简单
相当于把 SFunction转成 数据库字段名,然后拼接字符串,比如说有个User有个属性name
set(true, User::getName, 'new name')
转出来的sql应该是
set name = 'new name'

那么我们也可以依葫芦画瓢,直接继承LambdaUpdateWrapper

代码实现

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
java复制代码public class MyLambdaUpdateWrapper<T> extends LambdaUpdateWrapper<T> {

public MyLambdaUpdateWrapper(Class<T> entityClass) {
super(entityClass);
}

/**
* 指定列自增
* @param columns 列引用
* @param value 增长值
*/
public MyLambdaUpdateWrapper<T> incrField(SFunction<T, ?> columns, Object value) {
String columnsToString = super.columnToString(columns);

String format = String.format("%s = %s + %s", columnsToString,columnsToString, formatSql("{0}", value));

setSql(format);

return this;
}

/**
* 指定列自减
* @param columns 列引用
* @param value 减少值
*/
public MyLambdaUpdateWrapper<T> descField(SFunction<T, ?> columns, Object value) {
String columnsToString = super.columnToString(columns);

String format = String.format("%s = %s - %s", columnsToString,columnsToString, formatSql("{0}", value));

setSql(format);

return this;
}




}

在service中调用代码如下

1
2
3
4
5
6
java复制代码MyLambdaUpdateWrapper<AgentInfo> updateWrapper = new MyLambdaUpdateWrapper(AgentInfo.class);

updateWrapper.incrField(AgentInfo::getRealBalance, amount);
updateWrapper.eq(AgentInfo::getId, agentId);
// 调用父类 ServiceImpl 的update(。。)
return update(updateWrapper);

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

便利蜂 门店网络与 Rust 落地实践 便利蜂 门店

发表于 2021-09-24

便利蜂 | 门店网络与 Rust 落地实践

作者:刘凯,裴振 / 后期编辑:张汉东

编者按: 便利蜂门店相信很多人都去过,也享受过其便利的自助服务。但谁能想到,Rust 已在便利蜂100% 支撑店内网络已达 2 年时间,稳定性达 99.9999% 呢? 功能虽然不太复杂,但这个是 Rust 在嵌入式领域落地的一个非常接地气的场景了!


背景

关于便利蜂

便利蜂是一家以新型便利店为主体的科技创新零售企业,公司以科技为核心驱动运营,以“品质生活 便利中国”为己任,怀抱“小小幸福 在你身边”的初心,为中国消费者提供优质、健康、安心的产品和高效、便捷、满意的服务。目前全国开业的门店已超2000家。

网络在门店的作用

便利蜂店内大部分设备都已智能化,依赖网络的设备多达数十种,覆盖了从订单支付到店内运营的方方面面。
店内琐碎的事务中,背后有若干算法在支撑,从订货、陈列、盘点、废弃、热餐制作,到实时变价、自助咖啡、订单结算,每一个环节都有着复杂的网络交互。
要支撑住门店的正常运营,门店网络的稳定非常重要。

门店网络架构

image.png

上图是便利蜂门店网络拓扑。
在便利蜂门店场景中,要特别关注稳定性与成本的平衡。
对于网络稳定性,拓扑中可以看到,互联网出口以主线为主4G作为兜底,由算法控制主线恶化程度后决定是否启用4G网络,从而保证门店网络的可靠。
关于成本,便利店生意很注重规模效应,大规模的场景通常对单套成本是非常敏感的。拆解开看,成本项主要有三部分,第一是设备成本,第二是互联网接入成本,第三是运维人力成本。关于设备成本,在后续章节有所提及。关于互联网接入,通常我们会使用最低廉的宽带做为主线接入。

难点分析

基于上述要求,我们要达成【高质量的一人管千店】,那么会面临几个难题:

  1. 如何定义高质量;
  2. 如何解决多品牌设备问题;
  3. 这么多设备配置,因为各种原因,每天都有可能出现网络中断的情况,如何做到一人管千店。

其中最关键的因素在网关,它负责重要的线路逃生决策,还兼具各种智能检测、信息采集等任务,汇聚信息到中心,最终会在监控中实时分析全国门店的网络情况。
所以网关是整个管理系统的眼睛和手,总部系统是大脑。

方案选择

硬件和系统选型

硬件选型要满足如下条件:

  1. 不能单一供应商,风险太大;
  2. 多供应商带来的复杂性不能伤害一人管千店的目标;
  3. 硬件稳定性不输给大厂设备;

目前我们选型的策略如下:

  1. 品牌 or ODM(设备制造商);
    1. 品牌往往Console界面互不兼容且不具备编程能力,灵活度大打折扣;
    2. ODM往往出货量远低于大品牌,需要对硬件有一定了解谨慎选择;
    3. 我们目前倾向ODM;
  2. 高通 or MTK;
    1. 从sdk成熟度来讲,高通占优,但若选MTK需要接受使用低版本Kernel;
    2. 从成本来讲,MTK占优;
    3. 我们目前倾向MTK;
  3. 系统用 OpenWRT 官方 or 厂商 SDK;
    1. 如果对新版本有洁癖,且具备一定的 Kernel debug 能力,建议用 OpenWRT 官方,我们在这条路上有过探索,是完全可以走通的;
    2. 综合考虑后,我们目前倾向厂商SDK。

从以上策略出发,便利蜂必然会是一个多系统并存的状态。

开发语言为什么选 Rust

我们的嵌入式硬件有三种,两种 ARM 和一种 MIPS,其中最低的配置为 MT7621 CPU,有 880MHz MIPS CPU、512M内存(可用400M)、370M Flash,属于嵌入式环境。

兼顾嵌入式环境和成熟度,入选的语言有:Golang、C、Lua、Shell、Rust。分析各语言利弊如下:

  1. Golang:
    1. 优势:支持多种平台的移植、强大的异步编程能力、并且开发快速;
    2. 缺点:需要 Runtime,内存和CPU占用较高,且测试中发现MIPS版本有内存泄漏;
    3. 结论:排除;
  2. C:
    1. 优势:代码简洁、轻量高效,执行效率高,可移植性好;
    2. 缺点:开发效率不高、需要面临内存安全性问题;
    3. 结论:备选;
  3. Lua:
    1. 优势:Openwrt 因为有 luci,是最佳选择,并且足够的轻量,可以完美的和C系语言做粘合;
    2. 缺点:其他OS移植sdk的工作量较大;
    3. 结论:排除;
  4. Shell:
    1. 优势:轻量、开发快捷、上手难度低、系统自带;
    2. 缺点:对类型的定义和检查不严格,不适合做大型项目的构建,对于高质量交付对人要求较高;
    3. 结论:排除;
  5. Rust:
    1. 优势:运行速度快、内存安全、没有Runtime和GC(零成本抽象)、跨平台;
    2. 缺点:学习曲线陡峭、上手难度大、比较新的语言、许多基础库待完善;
    3. 结论:备选。

最终在 C 和 Rust 中做选择,做过一些尝试后最终决定使用 Rust, 它的高质量交付是我们最关心的优势。

Rust 实践

网络质量定义

衡量一个门店网络质量的好坏最直接的方法就是通过Http检测或者ICMP检测,根据检测结果的丢包率、延迟等相关指标来评定。我们根据门店的已有监控数据计算了一个合理的Ping值检测定级区间,具体分级如下:

  • A 级:延迟 <= 200ms or 丢包 <= 10%
  • B 级:延迟 <= 500ms or 丢包 <= 20%
  • C 级:延迟 <= 600ms or 丢包 <= 40%
  • D 级:延迟 > 600ms or 丢包 > 40%

A级网络质量基本不会对门店产生影响;B级基本会造成门店网络出现卡顿以及服务短暂失效等影响;C级就是比较大的影响了,门店网络属于短暂不可用;D级则代表门店网络不可用状态严重影响到了门店业务。

线路逃生的难题

因为低成本的宽带和分散的接入方式,一些门店的网络环境甚至比家用网络更不可靠,所以网关会配有一个 4G 路由器来作 Standby。在主线中断时候切换到备线,在主线恢复后切换回主线。保证门店的网络不受影响。同时,店内实时采集监控数据到中心,并通过流式处理实时监控全局状态,当单店出问题时,会有工单生成一线运维介入,当整体A级比例下降时,网络组二线会第一时间收到电话告警并介入处理。

理想情况下,主线质量始终要优于备线质量,所以即使主线和备线同等级别,也应该优先保证线路在主线。其余情况则是谁好在谁则路由保持在哪里。

但实际情况不止如此。主线通常不会直接中断,而是处于弱网状态,大多数设备此时只能眼看网络变差不能切换,我们会把质量经过ABCD规则匹配并防抖后第一时间做切换。但这又引出新问题,主线故障会频繁在A和D之间抖动,这时候就会出现主备切换的 flapping 状态,因为路由切换的瞬间会影响到业务 http 访问,所以我们通过指数退避的方式尽可能的减少 flapping 的发生。然而又会暴露一个问题:在退避期间,由于抑制了频繁切换,但是实际线路已经无法使用,就会导致全店网络中断情况的发生。这时候就需要采用异常介入模式,避免网络处于瘫痪状态。

快速调研,扫清障碍

既然明确了需求、明确了选型,剩下的就是 POC 的工作了。

首先是并发库方面。在 Rust 社区中,我们有几个选型可以参考:

  • crossbeam (多线程)
  • async-std (异步)
  • tokio (异步)

crossbeam 这个库非常之优秀,完全可以满足程序的功能设计,但是我们需要考虑到我们的设备硬件资源限制条件,CPU 更是能省则省,所以我们更倾向于选择一个异步运行时来解决我们的问题。

tokio 和 async-std 都是比较好的选择,在最初开发的时候 async-std 项目刚刚开始起步,很多功能还没有完善,并且 tokio 库已经在很多项目中进行过验证,稳定性各方面要相对于 async-std 更好一些。所以我们最终决定采用 tokio 来作为我们整个程序的异步运行时。

其次是网络检测方面。网络质量检测不仅需要检测主备线质量、还有若干业务的质量检测,ip数量一般能达到20~30左右。并且这些检测的频率、Size、Interface也各不相同。我们的网络设备款式比较多,Ping 命令的输出格式不统一,且由于 Ping 命令本身是一个 blocking 的操作,放在异步运行时中需要单开 worker 来执行这些操作,后来开始调研其他的 Ping 实现,如果要做到多设备兼容需要适配各种设备上安装的 Ping 命令的输出结果,并且和程序嵌套会繁琐,开销较大;

我们对Ping的使用主要有这几个需求:

  • 每个地址发送的频率不一致,大小不一致
    • 大包低频:对一些比较重的资源地址采用了大包检测,但同时要保证门店网络的带宽不受影响所以作为低频包
    • 小包高频:对一些敏感资源地址,采用小包高频策略,这样可以保证每分钟相对均匀的检测
    • 小包低频:对一些不敏感,仅做链路检测的做小包低频,能节省带宽
  • 发送Ping包需要与Interface接口绑定
    • 由于需要支持主备线路由检测,需要绑定 Interface 来做(backup:静态路由)
  • 最好支持Traceroute功能(Icmp)
    • 需要上传重点监测线路的完整 traceroute 信息到机房来做后期故障分析
  • 能与Tokio的 Runtime 结合并且支持mips、arm、aarch64等架构
    • 网络多元化,拥有多款设备需要做到跨平台,并且对资源占用有要求
  • 能控制 Sequence Number 起始数
  • 可编程
    • 可个性化定制、符合人体工程学

社区已有几个实现:

  • oping: oping 是一个 Rust 绑定 C 的实现,还是一个 blocking 的操作,但是能提升一些编程体验;
  • fastping: fastping 是类似 go 的 fastping 实现,可以同时 Ping 多个地址,但是我们的需求是在 Ping 很多地址的前提下 duration 不同、size 不同、绑定的网络接口不同,所以虽然也是 batch 类的 Ping,但是不太适用;
  • tokio-ping: tokio-ping 其实最开始是最适合我们项目的,但是 rust 异步生态那会刚好处在新老交替的阶段,tokio-ping 处于异步的上一个阶段,没法跟我们的项目完美兼容,需要一些 compat 才能使用,并且作者基本已经不维护了。

基于以上的情况,我们实现了一套适用于自己的异步 Ping 程序surge-ping 完全贴合我们的使用需求。

代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
rust复制代码use std::time::Duration;

use surge_ping::Pinger;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut pinger = Pinger::new("114.114.114.114".parse()?)?;
pinger.bind_device(Some("eth0".as_bytes()))?;
// pinger.set_ttl(20)?;
pinger.timeout(Duration::from_secs(1));
for seq_cnt in 0..10 {
let (reply, dur) = pinger.ping(seq_cnt).await?;
println!(
"{} bytes from {}: icmp_seq={} ttl={:?} time={:?}",
reply.size, reply.source, reply.sequence, reply.ttl, dur
);
}
Ok(())
}

程序结构

image.png

网关 Agent 的组织图如上所示,我们对每个门店都部署了 Agent 来监测门店网络质量,通过将采集的数据上传到监控系统中对门店网络进行实时监控,在故障发生时能做到尽快发现减少门店损失。

多设备兼容

目前我们门店由于网络设备比较多元化,所以会涉及到跨平台的需求,以目前我们主要的两个平台 ARM64 和 MIPS 举例展现下 Rust 的跨平台能力。

MIPS交叉编译

这种需要借助厂商的编译 sdk 来做,由于我们使用的是一款老 Rom(mipsel-uclibc)的,这种在 rust 的编译阶级里属于第三层,没有预设在 rust 官方支持的 target 列表中,所以要借用xargo来实现编译。

先确定设备的三元组信息({arch}-{vendor}-{sys}-{abi}),一般通过 toolchain 的目录基本可以确定 libc 版本,比如我们的:toolchain-mipsel_24kec_gcc-4.8-linaro_uClibc,我们看到 Arch 是mipsel,ABI 是 uclibc,vendor 的话一般是unknown,system 为Linux,所以我们的三元组信息为mipsel-unknown-linux-uclibc.

操作如下:

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
shell复制代码# step1 配置环境变量
export STAGING_DIR="/path/to/staging_dir"
export PATH="$STAGING_DIR/toolchain-mipsel_1004kc_gcc-4.8-linaro_uClibc/bin/:$PATH"
export OPENSSL_DIR='/path/to/openssl'
export AR_mipsel_unknown_linux_musl=mipsel-openwrt-linux-uclibc-ar
export CC_mipsel_unknown_linux_musl=mipsel-openwrt-linux-uclibc-gcc
export CXX_mipsel_unknown_linux_musl=mipsel-openwrt-linux-uclibc-g++

# step2 指定编译选项
cat ~/.cargo/config
[target.mipsel-unknown-linux-uclibc]
linker = "mipsel-openwrt-linux-gcc"

# step3 安装Xargo
cargo install xargo

# step4
# 向Cargo.toml中加入如下内容,因为panic有栈回溯, 某些平台不支持这个语法;取消panic展开异常堆栈信息有利于减小程序体积(https://doc.rust-lang.org/edition-guide/rust-2018/error-handling-and-panics/aborting-on-panic.html)
[profile.dev]
panic = "abort"

[profile.release]
panic = "abort"

# step5
cat Xargo.toml
[target.mipsel-unknown-linux-uclibc.dependencies.std]
features = []

# step6 编译
xargo build --target mipsel-unknown-linux-uclibc --release

TIPS:
在早期编译期间遇到了很多libc库支持不全的问题,这时候需要去查 uclibc 的代码来协助完善 rust 的libc库。

ARM64 交叉编译

需要用到 Cross 项目: github.com/rust-embedd…

使用 Cross 的坑不多,大部分 unix 类的 OS 都可以使用它,它默认使用 docker 进行编译。使用方法:

1
2
sh复制代码cargo install cross
cross build --target aarch64-unknown-linux-gnu --release

对于 OpenSSL,非 MIPS 架构可直接使用 rustls-tls 库,使用方法如下:

1
2
toml复制代码# reqwest使用rustls
reqwest = { version = "0.11", default-features = false, features = ["rustls-tls", "json"]}

若使用 mips + openssl,需要将 OpenSSL 在 Dockerfile 中注明编译。Dockerfile 如下:

1
2
3
4
5
6
7
8
9
10
Dockerfile复制代码# File: docker/Dockerfile.aarch64-gnu
# openssl: https://github.com/rust-embedded/cross/blob/c183ee37a9dc6b0e6b6a6ac9c918173137bad4ef/docker/openssl.sh
FROM rustembedded/cross:aarch64-unknown-linux-gnu-0.2.1

COPY openssl.sh /
RUN bash /openssl.sh linux-aarch64 aarch64-linux-gnu-

ENV OPENSSL_DIR=/openssl \
OPENSSL_INCLUDE_DIR=/openssl/include \
OPENSSL_LIB_DIR=/openssl/lib

然后进行编译

1
2
3
4
5
6
sh复制代码docker build -t kolapapa/aarch64-gnu:0.1 docker/ -f docker/Dockerfile.aarch64-gnu
cat > Cross.toml <<EOF
[target.aarch64-unknown-linux-gnu]
image = "kolapapa/aarch64-gnu:0.1"
EOF
cross build --target aarch64-unknown-linux-gnu --release

总结

Rust 非常适合此类门店嵌入式场景,其完整易用的工具链、高质量社区、安全的内存管理,能大量缩短上线时间、提高交付质量。
目前 Rust 在便利蜂已 100% 支撑店内网络 2 年时间,稳定性达 99.9999%,需求频次每半个月会做一次迭代。

开源社区贡献

我们在实践的过程中也对社区有一些小的贡献:

  • github.com/rust-lang/l… 帮助libc库完善了对于 mips-uclibc 的支持;
  • github.com/rust-lang/s… 支持了绑定interface的功能,并且添加了对mipsel-uclibc的支持;
  • github.com/kolapapa/su… 一个异步Ping的实现,也可以用作traceroute (ICMP版);

作者简介

刘凯,便利蜂运维开发工程师,主要负责 监控报警系统 以及 门店网络多元化项目 的开发和维护。

裴振,便利蜂运维负责人。

最后,便利蜂正在寻找优秀的伙伴,每一份简历我们都会认真对待,期待遇见。

  • 邮箱地址:zhen.pei@bianlifeng.com

招聘官网

bianlifeng.gllue.me/portal/home…

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

❤️《计算机基础知识》(二)(建议收藏)❤️

发表于 2021-09-24

​

)​

上一篇:

❤️《计算机基础知识》(一)❤️

51、 Access是一种____数据库管理系统。

A、发散型 B、集中型 C、关系型 D、逻辑型

52、 用高级程序设计语言编写的程序,要转换成等价的可执行程序,必须经过____。

A、汇编 B、编辑 C、解释 D、编译和连接

53、 一般用高级语言编写的应用程序称为____。

A、编译程序 B、编辑程序 C、连接程序 D、源程序

54、 若微机系统需要热启动,应同时按下组合键____。

A、Ctrl+Alt+Break B、Ctrl+Esc+Del C、Ctrl+Alt+Del D、Ctrl+Shift+Break

55、 启动Windows系统时,要想直接进入最小系统配置的安全模式,按____。

A、F7键 B、F8键 C、F9键 D、F10键

56、 在“记事本”或“写字板”窗口中,对当前编辑的文档进行存储,可以用____快捷键。

A、Alt+F B、Alt+S C、Ctrl+S D、Ctrl+F

57、 Windows的目录结构采用的是____。

A、树形结构 B、线形结构 C、层次结构 D、网状结构

58、 在Windows中,如果想同时改变窗口的高度或宽度,可以通过拖放____来实现。

A、 窗口边框 B、 窗口角 C、 滚动条 D、 菜单栏

59、 对于Windows,下面以____为扩展名的文件是不能运行的。

A.COM B .EXE C .BAT D.TXT

60、 在 Windows 中有两个管理系统资源的程序组,它们是____。

A、“我的电脑”和“控制面板” B、“资源管理器”和“控制面板”

C、“我的电脑”和“资源管理器” D、“控制面板”和“开始”菜单

61、 在Windows中,为了查找文件名以”A”字母打头的所有文件,应当在查找名称框内输入____。

A、A B、A* C、A? D、A#

62、 在Windows中,为了查找文件名以”A”字母打头,后跟一字母的所有文件,应当在查找名称框内输入____。

A、A B、A* C、A? D、A#

63、 控制面板的主要作用是____。

A、 调整窗口 B、设置系统配置 C、管理应用程序 D、设置高级语言

64、 合键 ____可以打开“开始”菜单。

A、 + B、 +

C、+<空格键> D、+

65、 Word程序启动后就自动打开一个名为____的文档。

A、Noname B、Untitled C、文件1 D、文档1

66、 Word程序允许打开多个文档,用____菜单可以实现各文档窗口之间的切换。

A、编辑 B、窗口 C、视图 D、工具

67、 下列带有通配符的文件名,能表示文件ABC、TXT的是____。

A、BC、? B、A?. C、?BC、* D、?.?

68、 为了保证任务栏任何时候在屏幕上可见,应在”任务栏属性”对话框的”任务栏选项”标签中选择____。

A、不被覆盖 B、总在最前 C、自动隐藏 D、显示时钟

69、 使用“开始”菜单中的查找命令,要查找的文件名中可以使用____。

A、通配符? B、通配符* C、两者都可以 D、两者都不可以

70、 Windows xp中,当屏幕上有多个窗口时,那么活动窗口____。

A、可以有多个窗口

B、只能是固定的窗口

C、是没有被其他窗口盖住的窗口

D、是有一个标题栏颜色与众不同的窗口

71、 WINDOWS资源管理器中,反向选择若干文件的方法是____。

A、CTRL+单击选定需要的文件

B、SHIFT+单击选定需要的文件,再单击反向选择

C、用鼠标直接单击选择

D、CTRL+单击选定不需要的文件,再单击编辑菜单中反向选择

72、 对WINDOWS应用程序窗口快速重新排列[平铺或层叠]的方法是: ____。

A、可通过工具栏按钮实现 B、可通过任务栏快捷菜单实现

C、可用鼠标调整和拖动窗口实现 D、可通过[开始]菜单下的[设置]命令实现

73、 通常把计算机网络定义为____。

A、以共享资源为目标的计算机系统,称为计算机网络

B、能按网络协议实现通信的计算机系统,称为计算机网络

C、把分布在不同地点的多台计算机互联起来构成的计算机系统,称为计算机网络

D、把分布在不同地点的多台计算机在物理上实现互联,按照网络协议实现相互间的通信,共享硬件、软件和数据资源为目标的计算机系统,称为计算机网络。

74、 计算机网络技术包含的两个主要技术是计算机技术和____。

A、微电子技术 B、通信技术

C、数据处理技术 D、自动化技术

75、 计算机技术和____技术相结合,出现了计算机网络。

A、自动化 B、通信 C、信息 D、电缆

76、 计算机网络是一个____系统。

A、管理信息系统 B、管理数据系统

C、编译系统 D、在协议控制下的多机互联系统

77、 计算机网络中,可以共享的资源是____。

A、硬件和软件 B、软件和数据 C、外设和数据 D、硬件、软件和数据

78、 计算机网络的目标是实现____。

A、数据处理 B、文献检索 C、资源共享和信息传输 D、信息传输

79、 计算机网络的特点是____。

A、运算速度快 B、精度高 C、资源共享 D、内存容量大

80、 关于Internet的概念叙述错误的是____。

A、Internet即国际互连网络 B、 Internet具有网络资源共享的特点

C、在中国称为因特网 D、 Internet是局域网的一种

81、 下列4项内容中,不属于Internet(因特网)提供的服务的是____。

A、电子邮件 B、文件传输 C、远程登录 D、实时监测控制

82、 万维网WWW以____方式提供世界范围的多媒体信息服务。

A、文本 B、信息 C、超文本 D、声音

83、 计算机用户有了可以上网的计算机系统后,一般需找一家____注网入网。

A、软件公司 B、系统集成商 C、ISP D、电信局

84、 因特网上每台计算机有一个规定的“地址”,这个地址被称为____地址。

A、TCP B、IP C、Web D、HTML

85、 每台计算机必须知道对方的____ 才能在Internet上与之通信。

A、电话号码 B、主机号 C、IP地址 D、邮编与通信地址

86、 当前使用的IP地址是一个____ 的二进制地址。

A、 8位 B、16位 C、32位 D、128位

87、 下列关于IP的说法错误的是____。

A、IP地址在Internet上是唯一的 B、IP地址由32位十进制数组成

C、IP地址是Internet上主机的数字标识 D、IP地址指出了该计算机连接到哪个网络上

88、 IP地址是一串难以记忆的数字,人们用域名来代替它,完成IP地址和域名之间转换工作的是____服务器。

A、DNS B、URL C、UNIX D、ISDN

89、 IP地址用4个十进制整数表示时,每个数必须小于____。

A、128 B、64 C、1024 D、256

90、 以____将网络划分为广域网、城域网和局域网。

A、 接入的计算机多少 B、 接入的计算机类型 C、拓朴类型 D、接入的计算机距离

91、 下列哪些计算机网络不是按覆盖地域划分的____。

A、局域网 B、都市网 C、广域网 D、星型网

92、 Internet是____类型的网络。

A、 局域网 B、城域网 C、广域网 D、企业网

93、 Internet起源于____。

A、美国 B、英国 C、德国 D、澳大利亚

94、 一般情况下,校园网属于_____。

A、LAN B、WAN C、MAN D、GAN

95、 在计算机网络中WAN表示____。

A、有线网 B、无线网 C、局域网 D、广域网

96、 下列关于局域网特点的叙述中,不正确的是____。

A、局域网的覆盖范围有限 B、误码率高

C、有较高的传输速率 D、相对于广域网易于建立、管理、维护和扩展

97、 在计算机网络系统中,WAN指的是____。

A、城域网 B、局域网 C、广域网 D、以太网

98、 下列电子邮件地址中正确的是____。

A、zhangsan&sina、com B、lisi!126、com

C、zhang$san@qq、com D、lisi_1982@sohu、com

99、 电子邮件到达时,如果并没有开机,那么邮件将_____。

A、 退回给发件人 B、 开机时对方重新发送

C、 该邮件丢失 D、 保存在服务商的E-mail服务器上

100、 要在Web浏览器中查看某一公司的主页,必须知道____。

A、该公司的电子邮件地址 B、该公司所在的省市 C、该公司的邮政编码 D、该公司的WWW地址

​

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

设计模式在电商业务下的实践——状态模式

发表于 2021-09-24

专栏

  • 设计模式在电商业务下的实践——策略模式
  • 设计模式在电商业务下的实践——状态模式
  • 设计模式在电商业务下的实践——责任链模式
  • 设计模式在电商业务下的实践——模版方法模式
  • 设计模式在电商业务下的实践——适配器模式
  • ……

持续更新中。

背景

以订单业务为例,存在多种业务操作,订单创建,订单支付,发货,收货等等。而这些操作对应了不同的订单状态,只有在指定的订单状态才能进行指定的订单业务,例如如下订单状态机:

image.png

假如一开始只能在待发货状态下买家才能申请售后退款,得出如下代码

初始代码

订单状态枚举

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
java复制代码@Getter
@AllArgsConstructor
public enum OrderStateEnum {
WAIT_PAY(0, "待支付"),
WAIT_DELIVER(1, "待发货"),
WAIT_RECEIVE(2, "待收货"),
REFUNDING(3, "退款中"),

FINISH(10, "已完成"),
REFUNDED(11, "已退款"),
;

private Integer code;
private String desc;

public static OrderStateEnum getEnumByCode(Integer code) {
for (OrderStateEnum stateEnum : values()) {
if (stateEnum.getCode().equals(code)) {
return stateEnum;
}
}
throw new RuntimeException("code非法");
}
}

业务处理service类

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
java复制代码import java.util.Objects;

@Service
public class OrderService {

@Autowired
private OrderRepository orderRepository;

//模拟获取订单号,这里简单做,一般都是全局唯一的分布式id
private static Long orderSn = 1L;
public static String generateOrderSn() {
return String.valueOf(orderSn++);
}

/**
* 创建订单
* @param buyerId
* @param skuId
* @return
*/
public String create(Long buyerId, Long skuId) {
Order order = new Order();
order.setOrderSn(generateOrderSn());
order.setBuyerId(buyerId);
order.setSkuId(skuId);
order.setStatus(OrderStateEnum.WAIT_PAY.getCode());
orderRepository.insert(order);
return order.getOrderSn();
}

/**
* 发起支付
* 订单发货
* 订单收货
* 与订单退款写法类似,暂时忽略...
*/

/**
* 售后申请
* @param orderSn
*/
void refund(String orderSn) {
Order order = orderRepository.get(orderSn);
//判断是否是待收货状态
if (!Objects.equals(order.getStatus(), OrderStateEnum.WAIT_DELIVER.getCode())) {
throw new RuntimeException("该状态下不支持该操作");
}
OrderStateEnum newState = OrderStateEnum.REFUNDING;
//操作收货,并更新数据库
order.setStatus(newState.getCode());
orderRepository.update(order);
}
}

迭代代码

随着业务的迭代,不止待发货状态可以申请售后了,待收货状态也可以申请售后,即状态机改为:

image.png

那refund方法中相应的状态判断也要做出改变,甚至处理流程很可能是不一样的,这里简单考虑假设一样。

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
java复制代码import java.util.Objects;

@Service
public class OrderService {

@Autowired
private OrderRepository orderRepository;

//模拟获取订单号,这里简单做,一般都是全局唯一的分布式id
private static Long orderSn = 1L;
public static String generateOrderSn() {
return String.valueOf(orderSn++);
}

/**
* 创建订单
* @param buyerId
* @param skuId
* @return
*/
public String create(Long buyerId, Long skuId) {
Order order = new Order();
order.setOrderSn(generateOrderSn());
order.setBuyerId(buyerId);
order.setSkuId(skuId);
order.setStatus(OrderStateEnum.WAIT_PAY.getCode());
orderRepository.insert(order);
return order.getOrderSn();
}

/**
* 发起支付
* 订单发货
* 订单收货
* 与订单退款写法类似,暂时忽略...
*/

/**
* 售后申请
* @param orderSn
*/
void refund(String orderSn) {
Order order = orderRepository.get(orderSn);
//判断是否是待收货状态
if (!Objects.equals(order.getStatus(), OrderStateEnum.WAIT_DELIVER.getCode())
&& !Objects.equals(order.getStatus(), OrderStateEnum.WAIT_RECEIVE.getCode())) {
throw new RuntimeException("该状态下不支持该操作");
}
OrderStateEnum newState = OrderStateEnum.REFUNDING;
//操作收货,并更新数据库
order.setStatus(newState.getCode());
orderRepository.update(order);
}
}

可以看出,这里我们违背了开闭原则,直接改了之前已经严格测试的代码,导致后续必须进行回归测试。虽然目前例子中的情况看上去还不算太糟,但是随着业务的迭代,状态机只会越来越复杂,如果每次增加新的逻辑都得改动原来的代码,上线风险性会很高,而且代码可读性也会越来越差(if-else不宜太多)。为此我们尝试用状态模式来优化该订单业务的代码逻辑编写。

定义

状态(State)模式的定义:对有状态的对象,把复杂的“判断逻辑”提取到不同的状态对象中,允许状态对象在其内部状态发生改变时改变其行为。我们通过看每一个状态对象的实现,可以清晰了解该状态下可以执行的操作已经流转到下一个状态的集合。

模式的结构

状态模式包含以下主要角色。

  • 环境类(Context)角色:也称为上下文,它定义了客户端需要的接口,内部维护一个当前状态,并负责具体状态的切换。
  • 抽象状态(State)角色:定义一个接口,用以封装环境对象中的特定状态所对应的行为,可以有一个或多个行为。
  • 具体状态(Concrete State)角色:实现抽象状态所对应的行为,并且在需要的情况下进行状态切换。

UML图

状态模式的结构图

模式基本实现

上下文类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
java复制代码public class Context {
private State state;

public Context(State state) {
this.state = state;
}

public State getState() {
return state;
}

public void setState(State state) {
this.state = state;
}

public void handle() {
state.handle(this);
}
}

抽象状态类

1
2
3
4
java复制代码public abstract class State {

public abstract void handle(Context context);
}

具体状态类A

1
2
3
4
5
6
7
java复制代码public class AState extends State{

public void handle(Context context) {
System.out.println(this.getClass().getSimpleName() + "将流转到BState");
context.setState(new BState());
}
}

具体状态类B

1
2
3
4
5
6
7
java复制代码public class BState extends State{

public void handle(Context context) {
System.out.println(this.getClass().getSimpleName() + "将流转到AState");
context.setState(new AState());
}
}

测试Client类

1
2
3
4
5
6
7
8
java复制代码public class ClientTest {

public static void main(String[] args) {
Context context = new Context(new AState());
context.handle();
context.handle();
}
}

执行的结果展示,第一次执行handle是由AState执行的,执行完后状态流转到B,第二次执行handle是由BState执行的,执行完后状态流转到A

1
2
复制代码AState将流转到BState
BState将流转到AState

上面的基本代码中有一个问题,那就是AState和BState每次切换状态的时候都new了一个新的实例,其实没有必要,这里可以把实例保存下来,例如保存Context中,整个程序执行的生命周期中所有线程共享这些状态实例

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
java复制代码public class ShareContext {

private static Map<String, State> shareStateMap = new HashMap<>();

private State state;

static {
shareStateMap.put(AState.class.getSimpleName(), new AState());
shareStateMap.put(BState.class.getSimpleName(), new BState());
}

public ShareContext() {}

public State getState() {
return state;
}

public void setState(State state) {
this.state = state;
}

//读取状态
public static State getState(String key) {
return shareStateMap.get(key);
}

public void handle() {
state.handle(this);
}
}

具体状态A的实现变成,

1
2
3
4
5
6
java复制代码public class AState extends State{
public void handle(ShareContext context) {
System.out.println(this.getClass().getSimpleName() + "将流转到BState");
context.setState(ShareContext.getState("AState"));
}
}

具体状态B和测试Client类同理,new一个State的操作都改为从map中获取。

优化订单状态流转

模式基本代码

以上面的模板代码为例可轻易写出优化订单状态流转的基本代码,但是现在一般我们都是在SpringBoot的框架下编写代码了,所以这里给出状态模式在SpringBoot下的代码实现。

模式与SpringBoot结合代码

完整代码见:…

定义订单状态枚举

与之前一致

定义抽象状态类

一开始默认所有的方法都是不可操作的,每个状态下实现自己可以操作的方法,这样状态流转也一目了然

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
java复制代码public abstract class AbstractOrderState {

public abstract Enum type();

/**
* 发起支付
*
* @param context
* @param order
*/
public void pay(OrderStateContext context, Order order) {
throw new RuntimeException("该状态下不支持该操作");
}

/**
* 订单发货
*
* @param context
* @param order
*/
public void deliver(OrderStateContext context, Order order) {
throw new RuntimeException("该状态下不支持该操作");
}

/**
* 订单收货
*
* @param context
* @param order
*/
public void receive(OrderStateContext context, Order order) {
throw new RuntimeException("该状态下不支持该操作");
}

/**
* 售后申请
*
* @param context
* @param order
*/
public void applyRefund(OrderStateContext context, Order order) {
throw new RuntimeException("该状态下不支持该操作");
}

/**
* 退款完成
*
* @param context
* @param order
*/
public void finishRefund(OrderStateContext context, Order order) {
throw new RuntimeException("该状态下不支持该操作");
}
}

定义具体状态类

以待发货状态为例,按状态机描述,需要实现发货方法和申请售后方法

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
java复制代码@Component
public class WaitDeliverOrderState extends AbstractOrderState {

@Autowired
private OrderRepository orderRepository;

@Override
public Enum type() {
return OrderStateEnum.WAIT_DELIVER;
}

/**
* 发货
* @param context
* @param order
*/
public void deliver(OrderStateContext context, Order order) {
OrderStateEnum newState = OrderStateEnum.WAIT_RECEIVE;
//操作发货,并更新数据库
order.setStatus(newState.getCode());
orderRepository.update(order);
//更新上下文状态
context.setOrderState(OrderStateFactory.getState(newState));
System.out.println("订单号:"+ order.getOrderSn() + " 发货成功!状态流转至:" + newState.getDesc());
}

/**
* 申请售后
* @param context
* @param order
*/
public void applyRefund(OrderStateContext context, Order order) {
OrderStateEnum newState = OrderStateEnum.REFUNDING;
//操作发货,并更新数据库
order.setStatus(newState.getCode());
orderRepository.update(order);
//更新上下文状态
context.setOrderState(OrderStateFactory.getState(newState));
System.out.println("订单号:"+ order.getOrderSn() + " 申请售后!状态流转至:" + newState.getDesc());
}
}

定义上下文类

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
java复制代码public class OrderStateContext {

private AbstractOrderState orderState;

public OrderStateContext() {}

public AbstractOrderState getOrderState() {
return orderState;
}

public void setOrderState(AbstractOrderState orderState) {
this.orderState = orderState;
}

/**
* 发起支付
*
* @param order
*/
void pay(Order order) {
orderState.pay(this, order);
}

/**
* 订单发货
*
* @param order
*/
void deliver(Order order) {
orderState.deliver(this, order);
}

/**
* 订单收货
*
* @param order
*/
void receive(Order order) {
orderState.receive(this, order);
}

/**
* 申请售后
*
* @param order
*/
void applyRefund(Order order) {
orderState.applyRefund(this, order);
}

/**
* 退款完成
*
* @param order
*/
void finishRefund(Order order) {
orderState.finishRefund(this, order);
}
}

封装状态实例工厂

封装状态实例工厂,可达到共享具体状态实例的效果,避免浪费内存

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码@Component
public class OrderStateFactory implements ApplicationContextAware {

private static final Map<Enum, AbstractOrderState> stateMap = new HashMap<>(OrderStateEnum.values().length);

@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
Map<String, AbstractOrderState> beans = applicationContext.getBeansOfType(AbstractOrderState.class);
beans.values().forEach(item -> stateMap.put(item.type(), item));
}

public static AbstractOrderState getState(Enum orderStateEnum) {
return stateMap.get(orderStateEnum);
}
}

代码测试

这里模拟写一个OrderService,同样对应了不同操作,只是这里做了简化,一般情况下如支付操作前需要校验参数,支付后可能会需要发消息通知下游等等。

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
java复制代码@Service
public class OrderService {

@Autowired
private OrderRepository orderRepository;

/**
* 创建订单,和之前一样
*/
public String create(Long buyerId, Long skuId) {
//...
}

/**
* 发起支付
*
* @param orderSn
*/
public void pay(String orderSn) {
Order order = orderRepository.get(orderSn);
OrderStateContext context = new OrderStateContext();
AbstractOrderState currentState = OrderStateFactory.getState(OrderStateEnum.getEnumByCode(order.getStatus()));
context.setOrderState(currentState);
context.pay(order);
}

/**
* 订单发货
*
* @param orderSn
*/
public void deliver(String orderSn) {
Order order = orderRepository.get(orderSn);
OrderStateContext context = new OrderStateContext();
AbstractOrderState currentState = OrderStateFactory.getState(OrderStateEnum.getEnumByCode(order.getStatus()));
context.setOrderState(currentState);
context.deliver(order);
}

/**
* 订单收货
* 申请售后
* 退款完成
* 与上述代码基本一致,完整代码见github
*/
}

Client测试,模拟外部按钮操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
java复制代码@SpringBootApplication(exclude = { DataSourceAutoConfiguration.class, DataSourceTransactionManagerAutoConfiguration.class })
public class StateClientTest {

public static void main(String[] args) {
SpringApplication.run(StateClientTest.class, args);
OrderService orderService = SpringContextUtil.getBean(OrderService.class);

//1.创建一笔订单
String orderSn = orderService.create(1L, 1L);

//2.执行支付操作
orderService.pay(orderSn);

//3.执行发货操作
orderService.deliver(orderSn);

//4.执行收货操作
orderService.receive(orderSn);

//5.尝试执行下支付操作,会失败
orderService.pay(orderSn);
}
}

执行结果

image.png

优缺点

优点

  1. 每一个具体状态中能直观看出当前状态下能执行的操作以及会流向的状态
  2. 通过定义新的子类很容易地增加新的状态和转换,较好的适应了开闭原则

缺点

  1. 当状态过多时可能系统中的类会变得很多
  2. 当状态过多时操作也会变多,导致抽象状态类和上下文context中的方法定义可能会变得很多,这里其实可以做下分层,操作分为正向和逆向,把取消和售后的操作都定义到逆向中

总结

当状态确定比较少并且后续也不会扩展时,其实一般不一定需要使用状态模式来过度设计,少许的if-else看起来也很清晰。
另外,状态模式对于状态流转之后,会直接进行一部分操作,比如上述代码中会更新数据库,有时候其实不太好,因为一般一个订单操作中对于数据库的更新肯定不止一个表,为了保证事务,是否都放在具体状态的方法中去更新有时候就很头疼。还有一种状态管理的实现方式是不直接做操作,只有一个getNextState方法, 传入当前状态和动作,返回流转到的下一个状态,也就是有限状态机,关于数据库以及其他的操作都放在外部实现,后续会单独写一篇文章介绍。


参考

状态模式(详解版)

解构电商产品——订单系统(一)

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

【Git】warning LF will be repla

发表于 2021-09-23

你有没有遇到过如下问题:

1
2
bash复制代码warning: LF will be replaced by CRLF in xxx.
The file will have its original line endings in your working directory.

警告:LF 和 CRLF 都是换行符,意思就是说 git 要把你的 LF 换行符全部换成 CRLF 这种换行符。


产生原因:首先问题出在不同操作系统所使用的换行符是不一样的。

  • Unix/Linux 采用换行符 LF 表示下一行(LF:LineFeed,中文意思是换行)
  • Windows 采用 回车 + 换行 CRLF 表示下一行(CRLF:CarriageReturn LineFeed,中文意思是回车换行)
  • Mac OS 采用回车 CR 表示下一行(CR:CarriageReturn,中文意思是回车)

查看状态:

1
bash复制代码$ git config core.autocrlf

当你输入这个命令的时候,会得到三种结果:

  1. true:当我们操作系统为 Windows
  2. false:文本文件保持其原来的样子
  3. input:add 时 git 会把 CRLF 转换成为 LF,签出时依旧为 LF

⭐当为 true 时,git 会将你暂存(git add)文件认为是文本文件,把换行符的 CRLF 转换成 LF,而签出这些文件的时候又会变成 CRLF 格式,所以会警告你,这虽然只是一个小问题,但是会干扰跨平台多人合作开发。

🌊举个例子:你同事用的是 Mac 或 Linux 系统,而你用的是 Windows 系统,最后提交时就会造成冲突!


解决办法:

1
bash复制代码$ git config --global core.autocrlf false

但如果不是跨平台开发而且是 Windows 系统,基本上忽略这个警告就可以啦!

希望本文对你有所帮助🧠

欢迎在评论区留下你的看法🌊,我们一起讨论与分享🔥

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

1…521522523…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%