【RabbitMQ】 RabbitMQ 消息的延迟 —— 深入探索 RabbitMQ 的死信交换机,消息的 TTL 以及延迟队列
文章目录
- 一、死信交换机
- 1.1 什么是死信和死信交换机
- 1.2 死信交换机和死信队列的创建方式
- 二、消息的 TTL
- 2.1 什么是消息的 TTL
- 2.2 基于死信交换机和 TTL 实现消息的延迟
- 三、基于 DelayExchang 插件实现延迟队列
- 3.1 安装 DelayExchang 插件
- 3.2 DelayExchang 实现消息延迟的原理
- 3.3 使用 DelayExchang 实现消息的延迟
消息队列是现代分布式应用中的关键组件,用于实现异步通信、解耦系统组件以及处理高并发请求。消息队列可以用于各种应用场景,包括任务调度、事件通知、日志处理等。在消息队列的应用中,有时需要实现消息的延迟处理、处理未能成功消费的消息等功能。
本文将介绍一些与消息队列相关的关键概念和技术,包括死信交换机(Dead Letter Exchange)、消息的 TTL(Time To Live,生存时间)、以及使用 DelayExchange 插件实现消息的延迟处理。通过深入理解这些概念和技术,将能帮助我们更好地设计和构建具有高可用性和可靠性的消息队列系统。
首先,我将介绍死信交换机以及它的作用,然后讨论如何创建死信交换机和死信队列。随后,将深入研究消息的TTL,了解它的作用和如何配置。最后,将探讨如何使用 DelayExchange 插件来实现消息的延迟处理,以满足各种应用需求。
一、死信交换机
1.1 什么是死信和死信交换机
在了解什么是死信交换机之前,让我们首先来了解一下什么是死信。 在消息队列系统中,死信(Dead Letter)是指未能被成功消费的消息。这些消息通常由于多种原因而变为死信,一些主要的原因如下:
-
消费失败: 当消息被消费者(consumer)拒绝(
reject
)或未能被确认(acknowledge
),并且针对与处理失败的消息没有设置重新入队(requeue
)参数时,它们可能成为死信。这可能是因为消息格式错误、业务处理失败、或者其他原因导致消费者无法处理消息。 -
消息超时: 消息在队列中等待消费,但在一定时间内未被消费者处理。这个时间限制通常由消息的 TTL(Time To Live,生存时间)来定义。当消息超过其 TTL 后,它就变为死信。
-
队列堆积满: 当消息队列积累了大量消息,无法容纳更多消息时,最早的消息可能成为死信,因为它们无法被及时处理。
因此为了处理这些死信消息,消息队列系统引入了 死信交换机(Dead Letter Exchange)。死信交换机是一个特殊的交换机,它接收死信消息,并根据规则将这些消息路由到死信队列。通过使用死信交换机,系统可以将死信消息从正常队列中分离出来,以便进一步处理或分析。
死信交换机通常与队列绑定,当队列中的消息变为死信时,它们会被发送到与之相关联的死信交换机,然后再路由到死信队列。这种机制使得系统能够更好地处理消息的异常情况,确保消息不会被永久丢失。
给队列绑定死信交换机的方法:
- 给队列设置
dead-letter-exchange
属性,指定一个交换机; - 给队列设置
dead-letter-routing-key
属性,设置死信交换机与死信队列的RoutingKey
。
如下图所示:
在上图中,simple.queue
就与死信交换机 dl.direct
绑定,最后路由到死信队列dl.queue
,后续就可以编写其他逻辑来处理死信队列中的消息。
死信和死信交换机是构建可靠消息处理系统的重要组成部分,它们能够帮助我们跟踪和处理未能成功消费的消息,确保数据不会遗失,同时提供更好的可用性和可维护性。
1.2 死信交换机和死信队列的创建方式
- 使用
@Bean
的方式创建:
// 声明普通的 simple.queue 队列,并且为其指定死信交换机:dl.direct
@Bean
public Queue simpleQueue(){return QueueBuilder.durable("simple.queue") // 指定队列名称,并持久化.deadLetterExchange("dl.direct") // 指定死信交换机.build();
}// 声明死信交换机 dl.direct
@Bean
public DirectExchange dlExchange(){return new DirectExchange("dl.direct", true, false);
}// 声明存储死信的队列 dl.queue
@Bean
public Queue dlQueue(){return new Queue("dl.queue", true);
}// 将死信队列 与 死信交换机绑定
@Bean
public Binding dlBinding(){return BindingBuilder.bind(dlQueue()).to(dlExchange()).with("simple");
}
- 使用
@RabbitListener
注解的方式创建:
@RabbitListener(bindings = @QueueBinding(value = @Queue(name = "dl.queue", durable = "true"),exchange = @Exchange(name = "dl.direct"),key = "dl"
))
public void listenDLQueue(String msg) {log.info("消费者接收到 dl.queue 的延迟消息:" + msg);
}
在这种情况下,注意需要在创建 simple.queue
是时候,绑定死信交换机。
二、消息的 TTL
2.1 什么是消息的 TTL
消息的TTL,全称为"Time To Live",是消息队列系统中的一个重要概念。它定义了消息在队列中存活的时间,也就是消息在被发送到队列后,允许存留在队列中的时间长度。一旦消息的TTL超过设定的时间,消息将被认为已过期,消息队列系统将会将其标记为死信(Dead Letter)并将其路由到相关的死信队列。
在消息队列中,消息的超时分为两种情况:
-
消息所在的队列设置了储存消息的超时时间;
-
消息本身设置了超时时间;
但是不管哪种情况,一定消息超时了,都会成为死信,如下图所示:
对上图的简单解释:
- 上图中,设置了
ttl.queue
的超时时间为 10000 毫秒,意味着一个消息在该队列中储存的时间不会超过这么长的时间; - 另外,也可以在发送消息的时候给这个消息设置在队列中的超时时间,例如 5000 毫秒。
- 无论是哪种情况,一旦消息超时了,都会发送到死信交换机,然后再路由死信队列,最后由处理死信的逻辑处理这些消息。
2.2 基于死信交换机和 TTL 实现消息的延迟
根据上面的死信交换机和 TTL 的特点,我们可以实现延迟处理消息的功能,TTL 和 死信的交换机及其队列的结构图示如下:
下面就使用 Spring AMQP 来声明和实现这些交换机和队列:
-
首先通过
@RabbitListener
注解声明一组死信交换机和死信队列,并指定处理死信的逻辑:@RabbitListener(bindings = @QueueBinding(value = @Queue(name = "dl.queue", durable = "true"),exchange = @Exchange(name = "dl.direct"),key = "dl" )) public void listenDLQueue(String msg) {log.info("消费者接收到 dl.queue 的延迟消息:" + msg); }
-
然后通过
@Bean
的方式声明一组 TTL 的交换机和队列/*** 声明 TTL 交换机*/ @Bean public DirectExchange ttlDirectExchange() {return new DirectExchange("ttl.direct", true, false); }/*** 声明 TTL 队列* 1. 指定消息的 TTL* 2. 指定死信交换机* 3. 指定死信交换机的 RoutingKey*/ @Bean public Queue ttlQueue() {return QueueBuilder.durable("ttl.queue") // 指定队列的名称.ttl(10_000) // 指定 TTL 为 10 秒.deadLetterExchange("dl.direct") // 指定死信交换机.deadLetterRoutingKey("dl") // 指定死信交换机的 RoutingKey.build(); }/*** 绑定 TTL 交换机和队列*/ @Bean public Binding ttlBinding() {return BindingBuilder.bind(ttlQueue()).to(ttlDirectExchange()).with("ttl"); }
-
最后,在
publisher
中编写发送消息的逻辑@Test public void testTTLMessage() {// 1. 创建消息Message message = MessageBuilder.withBody("hello, ttl message".getBytes(StandardCharsets.UTF_8)).setDeliveryMode(MessageDeliveryMode.PERSISTENT).build();// 2. 创建消息IDCorrelationData correlationData = new CorrelationData(UUID.randomUUID().toString());// 3. 发送消息rabbitTemplate.convertAndSend("ttl.direct", "ttl", message, correlationData);log.info("发送延迟消息成功!消息ID: {}", correlationData.getId()); }
- 验证延迟消息
在创建ttl.queue
的时候,指定了消息在队列中的 TTL 不超过 10 秒,因此预测当发送消息 10s 后,才会被消费者接收:
首先启动 consumer
,并清除控制台日志,然后再发送消息:
通过对比控制台日志的时间,可以发现成功将消息延迟了 10 秒。
另外,也可以在发送消息时设置超时时间,可以通过 MessageBuilder
中的 setExpiration
设置消息的超时时间,这里设置为 5 秒:
再次发送消息,并对比观察控制台日志的输出时间:
可以发现,此时消息延迟了 5 秒,通过上面的对比演示可以得出结论:那就是在同时指定了消息的过期时间以及队列的超时时间,将会以短的那个时间为准。
三、基于 DelayExchang 插件实现延迟队列
3.1 安装 DelayExchang 插件
- 下载插件
RabbitMQ 有一个官方的插件社区,地址为:https://www.rabbitmq.com/community-plugins.html。其中包含各种各样的插件,包括我们要使用的 DelayExchange 插件:
这里我选择的是 3.8.9 的版本:
- 上传插件
这里我的 RabbitMQ 是基于 Docker 安装的,因此需要先查看 RabbitMQ 的插件目录对应的数据卷:
然后,直接进入数据卷挂载点目录:
可以发现这个目录下其实以及有很多的插件的了,然后上传刚才下载的插件到这个目录:
- 安装插件
最后就是安装了,安装时需要进入 MQ 容器内部来执行安装。我的容器名为mq
,所以执行下面命令:
docker exec -it mq bash
然后执行安装的命令:
rabbitmq-plugins enable rabbitmq_delayed_message_exchange
最后出现下面的日志,就说明安装 DelayExchang 插件成功了:
3.2 DelayExchang 实现消息延迟的原理
DelayExchange 是一个用于实现消息延迟发送的插件,可以在消息队列系统中非常有用。其工作原理如下:
-
创建 DelayExchange:首先,需要创建一个 DelayExchange,这是一个特殊的交换机,用于处理延迟消息。通常,可以使用消息队列系统的管理工具或API(如 Spring AMQP 的API)来声明和配置 DelayExchange。
-
发送消息到 DelayExchange:当需要发送一个延迟消息时,将消息发送到 DelayExchange,而不是直接发送到目标队列。在发送消息时,需要为消息设置一个属性,通常称为
x-delay
,它表示消息的延迟时间。这个属性的值通常以毫秒为单位,定义了消息应该延迟多长时间才会被投递。 -
DelayExchange 检查 x-delay 属性:当消息到达DelayExchange时,它会检查消息的
x-delay
属性。如果该属性存在,说明这是一个延迟消息。DelayExchange会将消息持久化到硬盘,并记录x-delay
的值作为延迟时间。 -
返回 Routing Not Found:DelayExchange 会向消息的发送者返回 “Routing Not Found” 的响应,意味着消息当前没有目标队列可以接收。这是因为消息不会立即被投递,而是需要等待一定的延迟时间。因此如果设置了生产者消息确认的
publisher-return
的ReturnCallback
,就需要进行额外的处理以避免错误的提示。 -
延迟时间到期:经过预定的延迟时间后,DelayExchange 会重新检查已存储的消息,查看是否有消息已经到达或超过了其设定的延迟时间。
-
重新投递消息:一旦消息的延迟时间到期,DelayExchange将重新投递消息到指定的目标队列,允许消费者最终接收和处理消息。
通过 DelayExchange 的这一机制,可以实现消息的延迟发送,非常适合需要进行任务调度、处理延迟任务或者在时间敏感任务的应用中使用。它有助于减轻系统负载,提高消息传递的可靠性,以及更好地满足特定的应用需求。
3.3 使用 DelayExchang 实现消息的延迟
-
首先,使用
@RabbitListener
注解声明一组延迟交换机和队列,以及延迟消息的处理逻辑。@RabbitListener(bindings = @QueueBinding(value = @Queue(name = "delay.queue", durable = "true"),exchange = @Exchange(name = "delay.direct", delayed = "true"),key = "delay" )) public void listenDelayExchange(String msg) {log.info("消费者接收到了 delay.queue 的消息:" + msg); }
这里使用 @RabbitListener
注解声明交换机和队列和前面的操作基本一致,唯一的区别在于声明交换机的时候,额外设置了一个 delayed
参数,表明声明的是一个延迟交换机。
-
在
publisher
中发送延迟消息@Test public void testDelayMessage() {// 1. 创建消息Message message = MessageBuilder.withBody("hello, delay message".getBytes(StandardCharsets.UTF_8)).setDeliveryMode(MessageDeliveryMode.PERSISTENT).setHeader("x-delay", 5000) // 添加 x-delay 头信息.build();// 2. 创建消息IDCorrelationData correlationData = new CorrelationData(UUID.randomUUID().toString());// 3. 发送消息rabbitTemplate.convertAndSend("delay.direct", "delay", message, correlationData);log.info("通过延迟交换机发送延迟消息成功!消息ID: {}", correlationData.getId()); }
同样,此处发送消息的逻辑也和前面基本一致,只是在 MessageBuilder
中使用 setHeader
额外设置了一个x-delay
的头信息,表明了该消息是延迟消息,同时也指定了消息的超时时间。
- 验证延迟消息
同样的,首先启动 consumer
,清除控制台日志,然后向延迟交换机发送消息:
通过日志可以看到,成功发送了延迟消息,但是却出现了错误的日志信息,告诉我们是delay.direct
交换机没有成功将消息路由到 delay.queue
中,但是通过 consumer
的控制台在延迟 5 秒后发现成功接收并处理了这个消息:
出现上面错误日志的原则在上文的 DelayExchang 实现消息延迟的原理中的第 4 点已经提到了,使用 DelayExchang 实现消息的延迟,是会在达到了设置延迟时间,再将消息发送给队列的。但是,由于交换机在收到消息的时候,没有立即路由给队列,在返回确认消息给生产者的就是“Routing Not Found”,因此就会使得生产者误以为路由失败了。
另外,在上面的错误日志中,可以发现有一个 receivedDelay
参数的值是 5000,也就是延迟的时间,我们可以根据这个参数,在 RetuenCallback
中排除发送延迟消息时产生的的错误提示:
然后,再次发送延迟消息到延迟交换机,就不会出现上面的错误提示了:
至此,我们便成功使用 DelayExchang 实现了发送延迟消息的功能。可以发现,使用 DelayExchang 插件实现延迟消息比前面使用死信交换机和 TTL 来实现延迟消息更加的简单。
相关文章:

【RabbitMQ】 RabbitMQ 消息的延迟 —— 深入探索 RabbitMQ 的死信交换机,消息的 TTL 以及延迟队列
文章目录 一、死信交换机1.1 什么是死信和死信交换机1.2 死信交换机和死信队列的创建方式 二、消息的 TTL2.1 什么是消息的 TTL2.2 基于死信交换机和 TTL 实现消息的延迟 三、基于 DelayExchang 插件实现延迟队列3.1 安装 DelayExchang 插件3.2 DelayExchang 实现消息延迟的原理…...

CVE-2023-34040 Kafka 反序列化RCE
漏洞描述 Spring Kafka 是 Spring Framework 生态系统中的一个模块,用于简化在 Spring 应用程序中集成 Apache Kafka 的过程,记录 (record) 指 Kafka 消息中的一条记录。 受影响版本中默认未对记录配置 ErrorHandlingDeserializer,当用户将容…...

全局变量和局部变量在for循环的使用
imageloc字典作为全局变量,然后添加到全局的列表中,每次for循环都会将最新的元素改变之前for循环添加的元素。而imageloc字典作为局部变量,则不会影响。 import numpy as np originaljson [{"joints_vis": [1,1,1,1,1,1,1,1,1,1,…...

pytorch collate_fn测试用例
collate_fn 函数用于处理数据加载器(DataLoader)中的一批数据。在PyTorch中使用 DataLoader 时,通过设置collate_fn,我们可以决定如何将多个样本数据整合到一起成为一个 batch。在某些情况下,该函数需要由用户自定义以满足特定需求。 import …...

【qemu逃逸】HITB2017-babyqemu 2019数字经济-qemu
前言 由于本地环境问题,babyqemu 环境都没有起起,这里仅仅做记录,exp 可能不正确。 HITB2017-babyqemu 设备逆向 设备定位啥的就不说了,先看下实例结构体: 其中 dma_state 结构体如下: 这里看字段猜测…...

Docker Compose学习笔记
Docker Compose用来做什么? Docker Compose 是Docker官方的开源项目。 Compose is a tool for defining and running multi-container Docker applications. With Compose, you use a YAML file to configure your application’s services. Then, with a single …...

基于树 二叉树的回溯搜索算法(DPLL)
1)全称:Davis-Putnam-Logemann-Loveland 2)思想:基于树/二叉树的回溯搜索算法,主要基于两种策略。 单子句规则:如果一个CNF范式中存在单子句L(含有一个文字的子句),取L为…...

【嵌入式】适用于ESP32/ESP8266远程自动烧录工具
文章目录 介绍开始使用下载项目开启服务端开始远程烧录 后记 介绍 esp_remote_flash_tool 是一款基于 esptool.py 的远程自动烧录工具,支持 ESP32 和 ESP8266。 使用场景 基于 ESP-IDF 、ESP8266 NONO SDK、ESP8266 RTOS SDK 进行开发的项目项目代码存储在 Linux…...

服务器遭受攻击如何处理(记录排查)
本文的重点是介绍如何鉴别安全事件以及保护现场的方法,以确保服务器负责人能够在第一时间对安全攻击做出反应,并在最短时间内抵御攻击或减少攻击所带来的影响。 在服务器遭遇疑似安全事件时,通常可以从账号、进程、网络和日志四个主要方面进…...

分享81个工作总结PPT,总有一款适合您
分享81个工作总结PPT,总有一款适合您 PPT下载链接:https://pan.baidu.com/s/13hyrlZo2GhRoQjI-6z31-w?pwd8888 提取码:8888 Python采集代码下载链接:采集代码.zip - 蓝奏云 学习知识费力气,收集整理更不易。知识付…...

什么是DITA?从百度的回答说起
▲ 搜索“大龙谈智能内容”关注GongZongHao▲ 什么是DITA? 把这个问题输入百度,获得以下回答: DITA 是“Darwin Information Typing Architecture”(达尔文信息类型化体系结构)的缩写,它是IBM 公司为OASIS 所支持…...

线扫相机DALSA软件开发套件有哪些
Win10和Win7系统完整SDK目录截图: Sapera Configuration 缓存与内存管理,以及通信端口配置工具,部分功能等效于Detection(查找相机)内的Settings。 Sapera Log Viewer 打开Log Viewer后会显示之前发生过的所有与Sapera LT软件有关的运行信息…...

Scala集合操作
1 集合简介 Scala 中拥有多种集合类型,主要分为可变的和不可变的集合两大类: 可变集合: 可以被修改。即可以更改,添加,删除集合中的元素; 不可变集合类:不能被修改。对集合执行更改,…...

SQL备忘--特殊状态“未知“以及“空值NULL“的判断
一、新逻辑状态:未知 对于大多数其他语言的逻辑判断,一般只有两种结果:真(TURE)或假(FALSE)但在SQL中,还会有第三种判断结果:未知(UNKNOWN),表示无法判断出真或者假。 未知状态会影响传统逻辑运算&#x…...

《Pytorch新手入门》第一节-认识Tensor
《Pytorch新手入门》第一节-认识Tensor 一、认识Tensor1.1 Tensor定义1.2 Tensor运算操作1.3 Tensor与numpy转换 参考《深度学习框架PyTorch:入门与实践_陈云(著)》 一、认识Tensor 1.1 Tensor定义 Tensor 是 PyTorch 中重要的数据结构,可认为是一个高…...

【JAVA学习笔记】55 - 集合-Map接口、HashMap类、HashTable类、Properties类、TreeMap类(难点)
项目代码 https://github.com/yinhai1114/Java_Learning_Code/tree/main/IDEA_Chapter14/src/com/yinhai/map_ Map接口 一、Map接口的特点(难点) 难点在于对Node和Entry和EntrySet的关系 注意:这里讲的是JDK8的Map接口特点 Map java 1) Map与Collect…...

Pytorch图像模型转ONNX后出现色偏问题
本篇记录一次从Pytorch图像处理模型转换成ONNX模型之后,在推理过程中出现了明显色偏问题的解决过程。 问题描述:原始pytorch模型推理正常,通过torch.onnx.export()函数转换成onnx之后,推理时出现了比较明显的颜色偏差。 原始模型…...

插值表达式 {{}}
前言 持续学习总结输出中,今天分享的是插值表达式 {{}} Vue插值表达式是一种Vue的模板语法,我们可以在模板中动态地用插值表达式渲染出Vue提供的数据绑定到视图中。插值表达式使用双大括号{{ }}将表达式包裹起来。 1.作用: 利用表达式进行…...

白雪公主
前言 #define 皇后 王后 在很久很久以前,有一个国王,由于王后难产致死,导致生下的孩子没母,由于缺爱,变的非常的刻薄 由于公主过于刻薄,以至于见到她的人都面色煞白感到空中飘雪 37C 的嘴怎能说出如此刻薄的话语。为了…...

宏观角度认识递归之合并两个有序链表
21. 合并两个有序链表 - 力扣(LeetCode) 依旧是利用宏观角度来看待问题,其中最主要的就是要找到重复的子问题; 题目中要求把两个有序链表进行合并,同时不能够创建新的节点,并返回链表的起始点:因…...

Leetcode-509 斐波那契数列
使用循环 class Solution {public int fib(int n) {if(n 0){return 0;}if(n 1){return 1;}int res 0;int pre1 1;int pre2 0;for(int i 2; i < n; i){res pre1 pre2;pre2 pre1;pre1 res;}return res;} }使用HashMap class Solution {private Map<Integer,Int…...

解密 docker 容器内 DNS 解析原理
背景 这几天在使用 docker 中,碰到了在容器中 DNS 解析的一些问题。故花些时间弄清了原理,写此文章分享。 1. docker run 命令启动的容器 以启动一个 busybox 容器为例: rootubuntu20:~# docker run -itd --name u1 busybox 63b59ca8aeac…...

故障诊断模型 | Maltab实现SVM支持向量机的故障诊断
效果一览 文章概述 故障诊断模型 | Maltab实现SVM支持向量机的故障诊断 模型描述 Chinese: Options:可用的选项即表示的涵义如下 -s svm类型:SVM设置类型(默认0) 0 – C-SVC 1 --v-SVC 2 – 一类SVM 3 – e -SVR 4 – v-SVR -t 核函数类型:核函…...

开源的网站数据分析统计平台——Matomo
Matomo 文章目录 Matomo前言一、环境准备1. 整体安装流程2.安装PHP 7.3.303.nginx配置4.安装matomo4.1 访问安装页面 http://192.168.10.45:8088/index.php4.2 连接数据库4.3 设置管理员账号4.4 生成js跟踪代码4.5 安装完成4.6 警告修改4.7 刷新页面,就可以看到登陆…...

linux入门到地狱
linux—001入门 IT圈必备(前端工作者用的比较少) 老旧电脑跑linux不容易卡 我代码没保存windows闪退,僵停(vs2019卡掉线),重启更新,占用cpu内存服务报错pip各种bug 出来生态环境友好其他的全是bug(bug时间成本超过了windows快捷友好生态) 那就说明wind…...

架构”4+1“视图
1995年Kruchten提出了著名的“41”视图,用来描述软件系统的架构。在“41”视图中,(物理视图 )用来描述系统软硬件之间的映射关系,这个视图往往(系统工程人员)最为关注;(逻…...

『精』Vue 组件如何模块化抽离Props
『精』Vue 组件如何模块化抽离Props 文章目录 『精』Vue 组件如何模块化抽离Props一、为什么要抽离Props二、选项式API方式抽离三、组合式API方式抽离3.1 TypeScript类型方式3.2 文件分离方式3.3 对文件分离方式优化 参考资料💘推荐博文🍗 一、为什么要抽…...

JavaScript字符串字面量详细解析与代码实例
JavaScript字符串字面量是一种表示字符串值的语法结构,通常用双引号或单引号括起来。 var str1 "Hello World!"; var str2 Hello World!;另外,如果需要在字符串中包含双引号或单引号,可以使用转义字符\来实现。 var str3 &quo…...

Android java Handler sendMessage使用Parcelable传递实例化对象,我这里传递Bitmap 图片数据
一、Bundle给我们提供了一个putParcelable(key,value)的方法。专门用于传递实例化对象。 二、我这里传递Bitmap 图片数据,实际使用可以成功传统图像数据。 发送:Bundle bundle new Bundle();bundle.putParcelable("bitmap",bitmap);msg.setD…...

CTF工具PDF隐写神器wbStego4open安装和详细使用方法
wbStego4open安装和详细使用方法 1.wbStego4open介绍:2.wbStego4open下载:3.wbStego4open原理图:4.wbStego4open使用教程:第一步:第二步:第三步:第四步:第五步: 5.wbSteg…...