rabbitmq 基础

This commit is contained in:
heibaiying 2019-08-01 17:48:14 +08:00
parent 2080ef17c1
commit 4f58944a9c
4 changed files with 278 additions and 224 deletions

View File

@ -30,5 +30,10 @@
定义:应该优先使用组合、聚合等关联关系来实现复用,其次才是考虑使用继承关系。 定义:应该优先使用组合、聚合等关联关系来实现复用,其次才是考虑使用继承关系。
在软件设计当中应该尽量遵守这七大原则,它们之间要求的侧重点不同:开闭原则是总纲,它告诉我们要对扩展开放,对修改关闭;里氏替换原则告诉我们不要破坏继承体系;依赖倒置原则告诉我们要面向接口编程;单一职责原则告诉我们实现类要职责单一;接口隔离原则告诉我们在设计接口的时候要精简单一;迪米特法则告诉我们要降低耦合度;合成复用原则告诉我们要优先使用组合或者聚合关系复用,少用继承关系复用。 ### 总结
开闭原则是总纲,它告诉我们要对扩展开放,对修改关闭;里氏替换原则告诉我们不要破坏继承体系;依赖倒置原则告诉我们要面向接口编程;单一职责原则告诉我们实现类要职责单一;接口隔离原则告诉我们在设计接口的时候要精简单一;迪米特法则告诉我们要降低耦合度;合成复用原则告诉我们要优先使用组合或者聚合关系复用,少用继承关系复用。
## 二、工厂模式

View File

@ -1,85 +1,108 @@
# RabbitMQ 简介 # RabbitMQ 基础
## 一、简介 ## 一、消息队列
RabbitMQ 是目前最为广泛使用的开源消息中间件,它具备以下的特点 消息队列中间件 (Message Queue Middleware简称 MQ) 是指利用高效可靠的消息传递机制进行与平台无关的数据交流,它可以在分布式环境下扩展进程间的数据通信,并基于数据通信来进行分布式系统的集成。它还可以运用于以下场景
支持多种消息传递协议,拥有丰富的交换器类型,能够将消息灵活路由到各个队列; + **项目解耦**:不同的项目或模块可以使用消息中间件进行数据的传递,从而可以保证模块的相对独立性,实现解耦。
+ **流量削峰**:可以将突发的流量 (如秒杀数据) 写入消息中间件,然后由多个消费者进行异步处理。
+ **弹性伸缩**:可以通过对消息中间件进行横向扩展来提高系统的处理能力和吞吐量。
+ **发布订阅**:可以用于需要进行数据的发布和订阅通知的场景。
+ **异步处理**:某些时候我们可能并不需要立即对数据进行处理,或者不关心处理的结果,此时可以使用中间件进行异步处理。
+ **冗余存储**:消息中间件可以对数据进行持久化存储,直到你消费完成后再进行删除。
轻量级,支持多种部署方式,易于部署在公共云和私有云中; ## 二、AMQP协议
支持跨语言开发Java.NETPHPPythonJavaScriptRubyGo AMQP (Advanced Message Queuing Protocol) 是一个提供统一消息服务的应用层通讯协议,为消息中间件提供统一的开发规范。不同客户端可以将消息投递到中间件上,或从上面获取消息,发送消息和接收消息的客户端可以采用不同的语言开发、不同的技术实现,但必须遵循相同的 AMQP 协议。AMQP 协议本身包括以下三层:
可以通过集群来实现高可用性和高吞吐,还可以通过 Federation 插件来连接不同的服务; - **Module Layer**:位于协议最高层,主要定义了一些供客户端调用的命令,客户端可以利用这些命令实现自己的业务逻辑。例如:可以使用 Queue.Declare 命令声明一个队列或者使用 Basic.Consume 订阅消费一个队列中的消息。
- **Session Layer**:位于中间层,主要负责将客户端的命令发送给服务器,再将服务端的应答返回给客户端,主要为客户端与服务器之间的通信提供可靠性同步机制和错误处理。
- **Transport Layer**:位于最底层,主要传输二进制数据流 ,提供帧的处理、信道复用、错误检测和数据表示等。
插拔式的身份验证和授权支持TLS和LDAP ## 三、RabbitMQ 简介
支持持续集成,能够使用各种工具和插件进行灵活地扩展; RabbitMQ 完全实现了 AMQP 协议并基于相同的模型架构。RabbitMQ 在实现 `AMQP 0-9-1` 的基础上还进行了额外拓展,并可以通过插件来支持 `AMQP 1.0`。所以在某种程度上而言, RabbitMQ 就是 AMQP 在 Erlang 语言上的实现。RabbitMQ 基于众多优秀的特性成为了目前最为广泛使用的消息中间件,它的主要特性如下:
能够使用多种方式进行监控和管理,如 HTTP-API命令行工具和 UI 界面。 - 支持多种消息传递协议,除了 AMQP 外,还可以通过插件支持所有版本的 STOMP 协议和 MQTT 3.1 协议;
- 拥有丰富的交换器类型,可以满足绝大部分的使用需求;
- 支持多种部署方式,易于部署;
## 二、模型架构 - 支持跨语言开发Java.NETPHPPythonJavaScriptRubyGo
![rabbitmq-架构](D:\Full-Stack-Notes\pictures\rabbitmq-架构.jpg) - 可以通过集群来实现高可用性和高吞吐,还可以通过 Federation 插件来连接不同的服务节点;
### 2.1 核心概念 - 插拔式的身份验证和授权,支持 TLS 和 LDAP
**1. Exchange交换器** - 支持持续集成,能够使用各种插件进行灵活地扩展;
负责将消息路由到一个或者多个队列中 - 能够使用多种方式进行监控和管理,如 HTTP API命令行工具和 UI 界面
**2. Queue消息队列**
用于存储消息。多个消费者可以订阅同一个消息队列,此时候队列中的消息会被平均分配给多个消费者进行处理。 ## 四、模型架构
**3. BindingKey (绑定键)** ![rabbitmq-架构](D:\Full-Stack-Notes\pictures\rabbitmq-架构.png)
交换器与队列通过 BindingKey 进行绑定。 ### 1. Producer生产者
**4. Routingkey (路由键)** 生产者负责生产消息并将其投递到指定的交换器上。
生产者将消息发给交换器的时候,一般会指定一个 RountingKey用来指定这个消息的路由规则。当 RountingKey 与 BindingKey 在规则上相符合时,消息被路由到对应的队列中。 ### 2. Message消息
### 2.2 运转流程 消息由消息头和消息体组成。消息头用于存储元信息:如目标交换器的名字 (exchange_name) 、路由键 (RountingKey) 和其他可选配置 (properties) 等信息。消息体为实际需要传递的业务信息。
**生产者发送消息过程** ### 3. Exchange交换器
1. 生产者连接到RabbitMQ Broker , 建立一个连接Connection, 开启一个信道Channel 交换器负责接收来自生产者的消息,并将将消息路由到一个或者多个队列中,如果路由不到,则返回给生产者或者直接丢弃,这取决于交换器的 immediate 属性:
2. 生产者声明一个交换器,并设置相关属性;
3. 生产者声明一个队列并设置相关属性;
4. 生产者通过路由键将交换器与队列绑定起来;
5. 生产者发送消息至 RabbitMQ Broker ,其中包含路由键、交换器等信息;
6. 相应的交换器根据接收到的路由键查找相应的匹配队列;
7. 如果找到,则将从生产者发送过来的消息存入相应的队列中;
8. 如果没有找到,则根据生产者配置的属性选择丢弃还是回退给生产者;
9. 关闭信道;
10. 关闭连接。
**消费者接收消息的过程** + 当 mandatory 为 true 时:如果交换器无法根据自身类型和路由键找到一个符合条件的队列,则会将该消息返回给生产者;
+ 当 mandatory 为 false 时:如果交换器无法根据自身类型和路由键找到一个符合条件的队列,则会直接丢弃该消息。
1. 消费者连接到 RabbitMQ Broker, 建立一个连接Connection, 开启一个信道Channel ### 4. BindingKey (绑定键)
2. 消费者向 RabbitMQ Broker 请求消费相应队列中的消息,可能会设置相应的回调函数;
3. 等待 RabbitMQ Broker 回应并投递相应对列中的消息,消费者接收消息;
4. 消费者确认ack接收到的消息
5. RabbitMQ 从队列中删除相应已经被确认的消息;
6. 关闭信道;
7. 关闭连接。
### 2.3 连接与信道 交换器与队列通过 BindingKey 建立绑定关系。
生产者和消费者都需要与 Broker 建立 TCP 连接,但由于创建和销毁 TCP 的操作非常昂贵,所以 RabbitMQ 采用了连接复用的方式,在同一个 TCP 连接上建立多个互相独立的信道。 ### 5. Routingkey路由键
## 三、交换器类型 生产者将消息发给交换器的时候,一般会指定一个 RountingKey用来指定这个消息的路由规则。当 RountingKey 与 BindingKey 基于交换器的类型相匹配时,消息被路由到对应的队列中。
### 3.1 fanout ### 6. Queue消息队列
用于存储路由过来的消息。多个消费者可以订阅同一个消息队列,此时候队列中的消息会被平均分配给多个消费者进行处理,而不是每个消费者都收到所有消息并处理。
### 7. Consumer消费者
消费者订阅感兴趣的队列并负责消费存储在队列中的消息。为了保证消息能够从队列可靠的到达消费者RabbitMQ 提供了消息确认机制 (message acknowledgement),并通过 autoAck 参数来进行控制:
+ 当 autoAck 为 true 时:此时消息发送出去 (写入TCP套接字) 后就认为消费成功,而不管消费者是否真正消费到这些消息。当 TCP 连接或 channel 因意外而关闭,或者消费者在消费过程之中意外宕机,则对应的消息就丢失。因此这种模式可以提高吞吐量,但会存在数据丢失的风险。
+ 当 autoAck 为 false 时需要用户在数据处理完成后进行手动确认只有用户手动确认完成后RabbitMQ 才认为这条消息已经被成功处理。这可以保证数据的可靠性投递,但会降低系统的吞吐量。
### 8. Connection连接
用于传递消息的 TCP 连接。
### 9. Channel信道
RabbitMQ 采用类似 NIO (非阻塞式 IO ) 的设计,通过 Channel 来复用 TCP 连接,并确保每个 Channel 的隔离性,就像拥有的是独立的 Connection 连接。当数据流量不是很大时,采用连接复用技术可以避免创建过多的 TCP 连接而导致昂贵的性能开销。
### 10. Virtual Host虚拟主机
RabbitMQ 支持多租户,并通过虚拟主机来实现逻辑分组和资源隔离,一个虚拟主机就是一个小型的 RabbitMQ 服务器,拥有独立的队列、交换器和绑定关系。用户可以按照不同业务场景建立不同的虚拟主机,虚拟主机之间是完全独立的,你无法将 vhost1 上的交换器与 vhost2 上的队列进行绑定,这可以极大的保证业务之间的隔离性和数据安全。默认的虚拟主机为 `/`
### 11. Broker
一个真实部署运行的 RabbitMQ 服务。
## 五、交换器类型
### 5.1 fanout
把消息路由到与该交换器绑定的所有队列中。 把消息路由到与该交换器绑定的所有队列中。
### 3.2 direct ### 5.2 direct
把消息路由到 BindingKey 和 RountingKey 完全一样的队列中。 把消息路由到 BindingKey 和 RountingKey 完全一样的队列中。
### 3.3 topic ### 5.3 topic
将消息路由到 BindingKey 和 RountingKey 相匹配的队列中,匹配规则如下: 将消息路由到 BindingKey 和 RountingKey 相匹配的队列中,匹配规则如下:
@ -90,23 +113,50 @@ RabbitMQ 是目前最为广泛使用的开源消息中间件,它具备以下
![topic-exchange](D:\Full-Stack-Notes\pictures\topic-exchange.png) ![topic-exchange](D:\Full-Stack-Notes\pictures\topic-exchange.png)
+ 路由键为`lazy.orange.elephant`的消息会发送给所有队列; + 路由键为 `lazy.orange.elephant` 的消息会发送给所有队列;
+ 路由键为`quick.orange.fox`的消息只会发送给第一个队列; + 路由键为 `quick.orange.fox` 的消息只会发送给第一个队列;
+ 路由键为`lazy.brown.fox`的消息只会发送给第二个队列; + 路由键为 `lazy.brown.fox` 的消息只会发送给第二个队列;
+ 路由键为`lazy.pink.rabbit`的消息只会发送给第二个队列; + 路由键为 `lazy.pink.rabbit` 的消息只会发送给第二个队列;
+ 路由键为`quick.brown.fox`的消息与任何绑定都不匹配; + 路由键为 `quick.brown.fox` 的消息与任何绑定都不匹配;
+ 路由键为`orange``quick.orange.male.rabbit`的消息也与任何绑定都不匹配。 + 路由键为 `orange` `quick.orange.male.rabbit` 的消息也与任何绑定都不匹配。
### 3.4 headers ### 5.4 headers
在 Queue 与 Exchange 进行绑定时可以指定一组键值对作为 BindingKey在发送消息的 headers 中的可以指定一组键值对属性,当这些属性与 BindingKey 相匹配时,则将消息路由到该队列。同时还可以使用 `x-match`指定匹配模式: 在 Queue 与 Exchange 进行绑定时可以指定一组键值对作为 BindingKey在发送消息的 headers 中的可以指定一组键值对属性,当这些属性与 BindingKey 相匹配时,则将消息路由到该队列。同时还可以使用 `x-match`指定匹配模式:
- **x-match = all** :所有的键值对都相同才算匹配成功; - **x-match = all** :所有的键值对都相同才算匹配成功;
- **x-match = any**:只要有一个键值对相同就算匹配成功。 - **x-match = any**:只要有一个键值对相同就算匹配成功。
## 四、死信队列 ## 六、死信队列
RabbitMQ 中另外一个比较常见的概念是死信队列。当消息在一个队列中变成死信 (dead message) 之后,它可以被重新被发送到另一个类型为 DLX 的交换器上。DLX 全称为 Dead-Letter-Exchange即死信交换器绑定 DLX 的队列就称之为死信队列。一个正常的消息变成死信一般是由于以下三个原因:
- 消息被拒绝 (Basic.Reject/Basic.Nack) ,井且设置重回队列的参数 requeue 为 false
- 消息过期;
- 队列达到最大长度。
我们可以通过在 channel.queueDeclare 方法中设置 x-dead-letter-exchange 参数来为指定队列添加 DLX示例如下
```java
// 创建一个死信交换器
channel.exchangeDeclare("some.exchange.name", "direct");
Map<String, Object> args = new HashMap<>();
args.put("x-dead-letter-exchange", "some.exchange.name");
// 为名为 myqueue 的队列指定死信交换器
channel.queueDeclare("myqueue", false, false, false, args);
```
除此之外,您还可以重新指定死信的路由键,如果没有指定,则默认使用原有的路由键,重新设置方法如下:
```shell
args.put("x-dead-letter-routing-key", "some-routing-key");
```
## 参考资料

View File

@ -1,5 +1,6 @@
# RabbitMQ 单机环境搭建 # RabbitMQ 单机环境搭建
<nav>
<nav>
<a href="#一前置条件">一、前置条件</a><br/> <a href="#一前置条件">一、前置条件</a><br/>
<a href="#二Erlang-安装">二、Erlang 安装</a><br/> <a href="#二Erlang-安装">二、Erlang 安装</a><br/>
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<a href="#21-下载并解压">2.1 下载并解压</a><br/> &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<a href="#21-下载并解压">2.1 下载并解压</a><br/>
@ -17,170 +18,168 @@
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<a href="#43-使用新账户登录">4.3 使用新账户登录</a><br/> &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<a href="#43-使用新账户登录">4.3 使用新账户登录</a><br/>
</nav> </nav>
## 一、前置条件 ## 一、前置条件
RabbitMQ 由 Erlang 语言所编写,所以在安装 RabbitMQ 前需要安装 Erlang 。两者的版本兼容关系如下。本篇文章选用的 RabbitMQ 版本为 3.7.15 Erlang 版本为 22.0 。 RabbitMQ 由 Erlang 语言所编写,所以在安装 RabbitMQ 前需要安装 Erlang 。两者的版本兼容关系如下。本篇文章选用的 RabbitMQ 版本为 3.7.15 Erlang 版本为 22.0 。
| RabbitMQ version | Minimum required Erlang/OTP | Maximum supported Erlang/OTP | | RabbitMQ version | Minimum required Erlang/OTP | Maximum supported Erlang/OTP |
| :----------------------------------------------------------- | :-------------------------- | :--------------------------- | | :----------------------------------------------------------- | :-------------------------- | :--------------------------- |
| 3.7.15 | 20.3.x | 22.0.x | | 3.7.15 | 20.3.x | 22.0.x |
| 3.7.7 ~ 3.7.14 | 20.3.x | 21.3.x | | 3.7.7 ~ 3.7.14 | 20.3.x | 21.3.x |
| 3.7.0 ~ 3.7.6 | 19.3 | 20.3.x | | 3.7.0 ~ 3.7.6 | 19.3 | 20.3.x |
> 表格来源https://www.rabbitmq.com/which-erlang.html > 表格来源https://www.rabbitmq.com/which-erlang.html
## 二、Erlang 安装 ## 二、Erlang 安装
### 2.1 下载并解压 ### 2.1 下载并解压
Erlang 源码包下载地址http://erlang.org/download/ ,下载后进行解压: Erlang 源码包下载地址http://erlang.org/download/ ,下载后进行解压:
```shell ```shell
# 下载 # 下载
wget http://erlang.org/download/otp_src_22.0.tar.gz wget http://erlang.org/download/otp_src_22.0.tar.gz
# 解压 # 解压
tar -zxvf otp_src_22.0.tar.gz tar -zxvf otp_src_22.0.tar.gz
``` ```
### 2.2 编译和安装 ### 2.2 编译和安装
Erlang 的编译过程中使用到了 `ncurses-devel` 库,需要预先安装: Erlang 的编译过程中使用到了 `ncurses-devel` 库,需要预先安装:
``` ```
yum install ncurses-devel yum install ncurses-devel
``` ```
进入解压后的根目录: 进入解压后的根目录:
```shell ```shell
# 配置安装目录 # 配置安装目录
./configure --prefix=/usr/app/erlang ./configure --prefix=/usr/app/erlang
# 编译 # 编译
make make
# 安装 # 安装
make install make install
``` ```
### 2.3 验证安装结果 ### 2.3 验证安装结果
进入安装目录的 bin 目录下,执行 `erl`命令,出现对应的版本号信息则代表安装成功: 进入安装目录的 bin 目录下,执行 `erl`命令,出现对应的版本号信息则代表安装成功:
```shell ```shell
[root@hadoop001 bin]# ./erl [root@hadoop001 bin]# ./erl
Erlang/OTP 22 [erts-10.4] [source] [64-bit] [smp:2:2] [ds:2:2:10] [async-threads:1] Erlang/OTP 22 [erts-10.4] [source] [64-bit] [smp:2:2] [ds:2:2:10] [async-threads:1]
Eshell V10.4 (abort with ^G) Eshell V10.4 (abort with ^G)
``` ```
### 2.4 配置环境变量 ### 2.4 配置环境变量
```she ```she
vim /etc/profile vim /etc/profile
``` ```
配置环境变量: 配置环境变量:
```shell ```shell
export ERLANG_HOME=/usr/app/erlang export ERLANG_HOME=/usr/app/erlang
export PATH=$PATH:$ERLANG_HOME/bin export PATH=$PATH:$ERLANG_HOME/bin
``` ```
使得配置的环境变量立即生效: 使得配置的环境变量立即生效:
```shell ```shell
source /etc/profile source /etc/profile
``` ```
## 三、RabbitMQ 安装 ## 三、RabbitMQ 安装
### 3.1 下载并解压 ### 3.1 下载并解压
从 RabbitMQ 的 GitHub 仓库进行下载地址为https://github.com/rabbitmq/rabbitmq-server/releases/ 从 RabbitMQ 的 GitHub 仓库进行下载地址为https://github.com/rabbitmq/rabbitmq-server/releases/
```shell ```shell
# 下载 # 下载
wget https://github.com/rabbitmq/rabbitmq-server/releases/download/v3.7.15/rabbitmq-server-generic-unix-3.7.15.tar.xz wget https://github.com/rabbitmq/rabbitmq-server/releases/download/v3.7.15/rabbitmq-server-generic-unix-3.7.15.tar.xz
# 解压 # 解压
tar -Jxf rabbitmq-server-generic-unix-3.7.15.tar.xz tar -Jxf rabbitmq-server-generic-unix-3.7.15.tar.xz
```
```
### 3.2 配置环境变量
### 3.2 配置环境变量
```she
```she vim /etc/profile
vim /etc/profile ```
```
配置环境变量:
配置环境变量:
```shell
```shell export RABBITMQ_HOME=/usr/app/rabbitmq_server-3.7.15
export RABBITMQ_HOME=/usr/app/rabbitmq_server-3.7.15 export PATH=$PATH:$RABBITMQ_HOME/sbin
export PATH=$PATH:$RABBITMQ_HOME/sbin ```
```
使得配置的环境变量立即生效:
使得配置的环境变量立即生效:
```shell
```shell source /etc/profile
source /etc/profile ```
```
### 3.3 启动 RabbitMQ 服务
### 3.3 启动 RabbitMQ 服务
以后台守护进程的方式启动 RabbitMQ ,命令如下:
以后台守护进程的方式启动 RabbitMQ ,命令如下:
```shell
```shell rabbitmq-server start -detached
rabbitmq-server start -detached ```
```
### 3.4 查看服务状态
### 3.4 查看服务状态
```shell
```shell rabbitmqctl status
rabbitmqctl status ```
```
## 四、Web UI界面
## 四、Web UI界面
### 4.1 启动 Web UI
### 4.1 启动 Web UI
想要使用 RabbitMQ 的 Web UI 界面,需要启动管理插件,命令如下:
想要使用 RabbitMQ 的 Web UI 界面,需要启动管理插件,命令如下:
```shell
```shell rabbitmq-plugins enable rabbitmq_management
rabbitmq-plugins enable rabbitmq_management ```
```
访问端口为 `15672`。默认的用户名和密码都是 `guest` 。如果你所用浏览器和 RabbitMQ 服务不在同一台主机上,此时应该无法登录,并出现下面的提示
访问端口为 `15672`。默认的用户名和密码都是 `guest` 。如果你所用浏览器和 RabbitMQ 服务不在同一台主机上,此时应该无法登录,并出现下面的提示
<div align="center"> <img src="https://github.com/heibaiying/Full-Stack-Notes/blob/master/pictures/RabbitMQ-访问限制.png"/> </div>
<div align="center"> <img src="https://github.com/heibaiying/Full-Stack-Notes/blob/master/pictures/RabbitMQ-访问限制.png"/> </div> 之所以会出现这个提示是因为出于安全考虑RabbitMQ 只允许在本机使用默认的`guest`用户名登录。想要在其他主机上登录,可以使用自定义的账户。
之所以会出现这个提示是因为出于安全考虑RabbitMQ 只允许在本机使用默认的`guest`用户名登录。想要在其他主机上登录,可以使用自定义的账户。 ### 4.2 新增账户
### 4.2 新增账户 新增用户,用户名和密码都是 root
新增用户,用户名和密码都是 root ```sh
rabbitmqctl add_user root root
```sh ```
rabbitmqctl add_user root root
``` 赋予用户在默认的名为 `/` 的 Virtual Host 上的所有权限:
赋予用户在默认的名为 `/` 的 Virtual Host 上的所有权限: ```shell
rabbitmqctl set_permissions -p / root '.*' '.*' '.*'
```shell ```
rabbitmqctl set_permissions -p / root '.*' '.*' '.*'
``` 设置用户的角色为管理员:
设置用户的角色为管理员: ```
rabbitmqctl set_user_tags root administrator
``` ```
rabbitmqctl set_user_tags root administrator
``` ### 4.3 使用新账户登录
### 4.3 使用新账户登录 登录后可以查看到RabbitMQ 和 Erlang 的版本号,以及对应的账户信息:
登录后可以查看到RabbitMQ 和 Erlang 的版本号,以及对应的账户信息:
<div align="center"> <img src="https://github.com/heibaiying/Full-Stack-Notes/blob/master/pictures/rabbitmq-管控台.png"/> </div> <div align="center"> <img src="https://github.com/heibaiying/Full-Stack-Notes/blob/master/pictures/rabbitmq-管控台.png"/> </div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB