架构风格
人才,以及合理的人才结构,是软件公司乃至软件业发展的关键
成才,并在企业中承担重要职责,是个人职业发展的关键
一个软企发展的好坏,极大的取决于如下人才因素:
要让软件企业适应发展,需要:
提升个人技能,也是让企业获取人才的合适途径
第一部分 基础概念
第二部分 实践过程
第三部分 模块划分
需要阅读 基础概念篇 + 模块划分专题
需要 体会 “分而治之” 和 “迭代式设计” 这两种关键思想,运用 “逻辑视图+物理视图” 设计一个系统的架构也就不那么难了
用 逻辑视图和物理视图 是从解决问题的不同角度看,从而关注一个解决问题的不同细节。相当于化大问题为小问题。
逻辑视图和物理视图 交替迭代式展开。逻辑视图逐步清晰,促进物理分布设计。反之亦然。
模块划分的四种方式
软件架构关注分割和交互
可以将系统描述为计算机组件和组件之间的交互
软件架构师一系列有层次的决策
架构属于设计,但并非所有设计都属于架构。
架构涉及的决策,往往对整体质量、并行开发、适应变化等方面有着重大影响
CMake就是针对上面问题所设计的工具:它首先允许开发者编写一种平台无关的 CMakeList.txt 文件来定制整个编译流程,然后再根据目标用户的平台进一步生成所需的本地化 Makefile 和工程文件,如 Unix 的 Makefile 或 Windows 的 Visual Studio 工程。从而做到“Write once, run everywhere”。显然,CMake 是一个比上述几种 make 更高级的编译配置工具。一些使用 CMake 作为项目架构系统的知名开源项目有 VTK、ITK、KDE、OpenCV、OSG 等 [1]。
在 linux 平台下使用 CMake 生成 Makefile 并编译的流程如下:
本文将从实例入手,一步步讲解 CMake 的常见用法,文中所有的实例代码可以在这里找到。如果你读完仍觉得意犹未尽,可以继续学习我在文章末尾提供的其他资源。
本节对应的源代码所在目录:Demo1。
假设项目中只有一个文件 main.cc
1 | #include <stdio.h> |
首先编写 CMakeLists.txt 文件,并保存在与 main.cc 源文件同个目录下:
1 | # CMake 最低版本号要求 |
CMakeLists.txt 的语法比较简单,由命令、注释和空格组成,其中命令是不区分大小写的。符号 # 后面的内容被认为是注释。命令由命令名称、小括号和参数组成,参数之间使用空格进行间隔。
对于上面的 CMakeLists.txt 文件,依次出现了几个命令:
之后,在当前目录执行 cmake . ,得到 Makefile 后再使用 make 命令编译得到 Demo1 可执行文件。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[ehome@xman Demo1]$ cmake .
-- The C compiler identification is GNU 4.8.2
-- The CXX compiler identification is GNU 4.8.2
-- Check for working C compiler: /usr/sbin/cc
-- Check for working C compiler: /usr/sbin/cc -- works
-- Detecting C compiler ABI info
-- Detecting C compiler ABI info - done
-- Check for working CXX compiler: /usr/sbin/c++
-- Check for working CXX compiler: /usr/sbin/c++ -- works
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Configuring done
-- Generating done
-- Build files have been written to: /home/ehome/Documents/programming/C/power/Demo1
[ehome@xman Demo1]$ make
Scanning dependencies of target Demo
[100%] Building C object CMakeFiles/Demo.dir/main.cc.o
Linking C executable Demo
[100%] Built target Demo
[ehome@xman Demo1]$ ./Demo 5 4
5 ^ 4 is 625
[ehome@xman Demo1]$ ./Demo 7 3
7 ^ 3 is 343
[ehome@xman Demo1]$ ./Demo 2 10
2 ^ 10 is 1024
本节对应的源代码所在目录:Demo2。
现在假如把 power 函数单独写进一个名为 MathFunctions.cc 的源文件里
将上述工程修改下
MathFunctions.h
1 | /** |
MathFunctions.cc
1 | /** |
main.cc
1 | #include <stdio.h> |
这个时候,CMakeLists.txt 可以改成如下的形式:
1 | # CMake 最低版本号要求 |
文件目录结构如下1
2
3
4
5
6iddddeMac-mini:Demo2 iddd$ tree -L 2
.
|-- CMakeLists.txt
|-- MathFunctions.cc
|-- MathFunctions.h
`-- main.cc
唯一的改动只是在 add_executable 命令中增加了一个 MathFunctions.cc 源文件。
存在问题:
- 如果源文件很多,把所有源文件的名字都加进去将是一件烦人的工作。
更省事的方法是使用 aux_source_directory 命令,该命令会查找指定目录下的所有源文件,然后将结果存进指定变量名。其语法如下:
1 | aux_source_directory(<dir> <variable>) |
因此,可以修改 CMakeLists.txt 如下:
1 | # CMake 最低版本号要求 |
这样,CMake 会将当前目录所有源文件的文件名赋值给变量 DIR_SRCS ,再指示变量 DIR_SRCS 中的源文件需要编译成一个名称为 Demo 的可执行文件。
本节对应的源代码所在目录:Demo3。
现在进一步将 MathFunctions.h 和 MathFunctions.cc 文件移动到 math 目录下。
1 | iddddeMac-mini:Demo3 iddd$ tree -L 2 |
修改main.cc1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16#include <stdio.h>
#include <stdlib.h>
#include "math/MathFunctions.h"
int main(int argc, char *argv[])
{
if (argc < 3){
printf("Usage: %s base exponent \n", argv[0]);
return 1;
}
double base = atof(argv[1]);
int exponent = atoi(argv[2]);
double result = power(base, exponent);
printf("%g ^ %d is %g\n", base, exponent, result);
return 0;
}
对于这种情况,需要分别在项目根目录 Demo3 和 math 目录里各编写一个 CMakeLists.txt 文件。为了方便,我们可以先将 math 目录里的文件编译成静态库再由 main 函数调用。
根目录中的 CMakeLists.txt :
1 | # CMake 最低版本号要求 |
该文件添加了下面的内容: 第3行,使用命令 add_subdirectory 指明本项目包含一个子目录 math,这样 math 目录下的 CMakeLists.txt 文件和源代码也会被处理 。第6行,使用命令 target_link_libraries 指明可执行文件 main 需要连接一个名为 MathFunctions 的链接库 。
子目录中的 CMakeLists.txt:
1 | # 查找当前目录下的所有源文件 |
在该文件中使用命令 add_library 将 src 目录中的源文件编译为静态链接库。
本节对应的源代码所在目录:Demo4。
CMake 允许为项目增加编译选项,从而可以根据用户的环境和需求选择最合适的编译方案。
例如,可以将 MathFunctions 库设为一个可选的库,如果该选项为 ON ,就使用该库定义的数学函数来进行运算。否则就调用标准库中的数学函数库。
我们要做的第一步是在顶层的 CMakeLists.txt 文件中添加该选项:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24# CMake 最低版本号要求
cmake_minimum_required (VERSION 2.8)
# 项目信息
project (Demo4)
# 加入一个配置头文件,用于处理 CMake 对源码的设置
configure_file (
"${PROJECT_SOURCE_DIR}/config.h.in"
"${PROJECT_BINARY_DIR}/config.h"
)
# 是否使用自己的 MathFunctions 库
option (USE_MYMATH
"Use provided math implementation" ON)
# 是否加入 MathFunctions 库
if (USE_MYMATH)
include_directories ("${PROJECT_SOURCE_DIR}/math")
add_subdirectory (math)
set (EXTRA_LIBS ${EXTRA_LIBS} MathFunctions)
endif (USE_MYMATH)
# 查找当前目录下的所有源文件
# 并将名称保存到 DIR_SRCS 变量
aux_source_directory(. DIR_SRCS)
# 指定生成目标
add_executable(Demo ${DIR_SRCS})
target_link_libraries (Demo ${EXTRA_LIBS})
其中:
代码中添加编译选项 宏定义 USE_MYMATH
1 | #include <stdio.h> |
上面的程序值得注意的是第2行,这里引用了一个 config.h 文件,这个文件预定义了 USE_MYMATH 的值。但我们并不直接编写这个文件,为了方便从 CMakeLists.txt 中导入配置,我们编写一个 config.h.in 文件,内容如下:
1 | #cmakedefine USE_MYMATH |
这样 CMake 会自动根据 CMakeLists 配置文件中的设置自动生成 config.h 文件。
现在编译一下这个项目,为了便于交互式的选择该变量的值,可以使用 ccmake 命令, 该命令会提供一个会话式的交互式配置界面。:
1 | ccmake . |
从中可以找到刚刚定义的 USE_MYMATH 选项,按键盘的方向键可以在不同的选项窗口间跳转,按下 enter 键可以修改该选项。修改完成后可以按下 c 选项完成配置,之后再按 g 键确认生成 Makefile 。
ccmake 的其他操作可以参考窗口下方给出的指令提示。
我们可以试试分别将 USE_MYMATH 设为 ON 和 OFF 得到的结果:
config.h
1 | #define USE_MYMATH |
运行结果1
2
3iddddeMac-mini:Demo4 iddd$ ./Demo 4 8
Now we use our own Math library.
4 ^ 8 is 65536
config.h
1 | /* #undef USE_MYMATH */ |
运行结果1
2
3iddddeMac-mini:Demo4 iddd$ ./Demo 4 8
Now we use the standard library.
4 ^ 8 is 65536
本节对应的源代码所在目录:Demo5。
CMake 也可以指定安装规则,以及添加测试。这两个功能分别可以通过在产生 Makefile 后使用 make install 和 make test 来执行。在以前的 GNU Makefile 里,你可能需要为此编写 install 和 test 两个伪目标和相应的规则,但在 CMake 里,这样的工作同样只需要简单的调用几条命令。
首先先在 math/CMakeLists.txt 文件里添加下面两行:
1 | # 指定 MathFunctions 库的安装路径 |
指明 MathFunctions 库的安装路径。之后同样修改根目录的 CMakeLists 文件,在末尾添加下面几行:
1 | # 指定安装路径 |
通过上面的定制,生成的 Demo 文件和 MathFunctions 函数库 libMathFunctions.o 文件将会被复制到 /usr/local/bin 中,而 MathFunctions.h 和生成的 config.h 文件则会被复制到 /usr/local/include 中。
1 | iddddeMac-mini:Demo5 iddd$ make install |
顺带一提的是,这里的 /usr/local/ 是默认安装到的根目录,可以通过修改 CMAKE_INSTALL_PREFIX 变量的值来指定这些文件应该拷贝到哪个根目录。
通过CMake 安装的软件都会有一个安装文件列表
install_manifest.txt 内容
1 | /usr/local/bin/Demo |
只要删除就好1
cat install_manifest.txt |xargs rm
添加测试同样很简单。CMake 提供了一个称为 CTest 的测试工具。我们要做的只是在项目根目录的 CMakeLists 文件中调用一系列的 add_test 命令。
1 | # 启用测试 |
上面的代码包含了四个测试。第一个测试 test_run 用来测试程序是否成功运行并返回 0 值。剩下的三个测试分别用来测试 5 的 平方、10 的 5 次方、2 的 10 次方是否都能得到正确的结果。其中 PASS_REGULAR_EXPRESSION 用来测试输出是否包含后面跟着的字符串。
让我们看看测试的结果:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22iddddeMac-mini:Demo5 iddd$ cmake .
-- Configuring done
-- Generating done
-- Build files have been written to: /Users/iddd/Test/Demo5
iddddeMac-mini:Demo5 iddd$ make test
Running tests...
Test project /Users/iddd/Test/Demo5
Start 1: test_run
1/5 Test #1: test_run ......................... Passed 0.62 sec
Start 2: test_usage
2/5 Test #2: test_usage ....................... Passed 0.00 sec
Start 3: test_5_2
3/5 Test #3: test_5_2 ......................... Passed 0.00 sec
Start 4: test_10_5
4/5 Test #4: test_10_5 ........................ Passed 0.00 sec
Start 5: test_2_10
5/5 Test #5: test_2_10 ........................ Passed 0.00 sec
100% tests passed, 0 tests failed out of 5
Total Test time (real) = 0.63 sec
iddddeMac-mini:Demo5 iddd$
如果要测试更多的输入数据,像上面那样一个个写测试用例未免太繁琐。这时可以通过编写宏来实现:
1 | # 定义一个宏,用来简化测试工作 |
关于 CTest 的更详细的用法可以通过 man 1 ctest 参考 CTest 的文档。
让 CMake 支持 gdb 的设置也很容易,只需要指定 Debug 模式下开启 -g 选项:
1 | set(CMAKE_BUILD_TYPE "Debug") |
之后可以直接对生成的程序使用 gdb 来调试。
本节对应的源代码所在目录:Demo6。
有时候可能要对系统环境做点检查,例如要使用一个平台相关的特性的时候。在这个例子中,我们检查系统是否自带 pow 函数。如果带有 pow 函数,就使用它;否则使用我们定义的 power 函数。
首先在顶层 CMakeLists 文件中添加 CheckFunctionExists.cmake 宏,并调用 check_function_exists 命令测试链接器是否能够在链接阶段找到 pow 函数。
1 | # Project Info |
将上面这段代码放在 configure_file 命令前。
接下来修改 config.h.in 文件,预定义相关的宏变量。
1 | // does the platform provide pow function? |
最后一步是修改 main.cc ,在代码中使用宏和函数:
1 | #ifdef HAVE_POW |
本节对应的源代码所在目录:Demo7。
给项目添加和维护版本号是一个好习惯,这样有利于用户了解每个版本的维护情况,并及时了解当前所用的版本是否过时,或是否可能出现不兼容的情况。
首先修改顶层 CMakeLists 文件,在 project 命令之后加入如下两行:
1 | set (Demo_VERSION_MAJOR 1) |
分别指定当前的项目的主版本号和副版本号。
之后,为了在代码中获取版本信息,我们可以修改 config.h.in 文件,添加两个预定义变量:
1 | // the configured options and settings for Tutorial |
这样就可以直接在代码中打印版本信息了:
1 | #include <stdio.h> |
测试
1 | iddddeMac-mini:Demo7 iddd$ ./Demo |
本节对应的源代码所在目录:Demo8。
本节将学习如何配置生成各种平台上的安装包,包括二进制安装包和源码安装包。为了完成这个任务,我们需要用到 CPack ,它同样也是由 CMake 提供的一个工具,专门用于打包。
首先在顶层的 CMakeLists.txt 文件尾部添加下面几行:
1 | # 构建一个 CPack 安装包 |
上面的代码做了以下几个工作:
添加 License.txt 文件,内容无所谓
1 | BSD 2 |
文件结构1
2
3
4
5
6
7
8
9
10iddddeMac-mini:Demo8 iddd$ tree -L 2
.
|-- CMakeLists.txt
|-- License.txt
|-- config.h.in
|-- main.cc
`-- math
|-- CMakeLists.txt
|-- MathFunctions.cc
`-- MathFunctions.h
接下来的工作是像往常一样构建工程,并执行 cpack 命令。
生成二进制安装包:1
cpack -C CPackConfig.cmake
生成源码安装包1
cpack -C CPackSourceConfig.cmake
我们可以试一下。在生成项目后,执行 cpack -C CPackConfig.cmake 命令:
1 | iddddeMac-mini:Demo8 iddd$ cpack -C CPackConfig.cmake |
我们可以执行其中一个。此时会出现一个由 CPack 自动生成的交互式安装界面:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21iddddeMac-mini:Demo8 iddd$ sh Demo8-1.0.1-Darwin.sh
Demo8 Installer Version: 1.0.1, Copyright (c) Humanity
This is a self-extracting archive.
The archive will be extracted to: /Users/iddd/Test/Demo8
If you want to stop extracting, please press <ctrl-C>.
BSD 2
Do you accept the license? [yn]:
y
By default the Demo8 will be installed in:
"/Users/iddd/Test/Demo8/Demo8-1.0.1-Darwin"
Do you want to include the subdirectory Demo8-1.0.1-Darwin?
Saying no will install in: "/Users/iddd/Test/Demo8" [Yn]:
y
Using target directory: /Users/iddd/Test/Demo8/Demo8-1.0.1-Darwin
Extracting, please wait...
Unpacking finished successfully
完成后提示安装到了 Demo8-1.0.1-Darwin 子目录中,我们可以进去执行该程序:
1 | iddddeMac-mini:Demo8 iddd$ ./Demo8-1.0.1-Darwin/bin/Demo 6 7 |
关于 CPack 的更详细的用法可以通过 man 1 cpack 参考 CPack 的文档。
CMake 可以很轻松地构建出在适合各个平台执行的工程环境。而如果当前的工程环境不是 CMake ,而是基于某个特定的平台,是否可以迁移到 CMake 呢?答案是可能的。下面针对几个常用的平台,列出了它们对应的迁移方案。
qmake converter 可以转换使用 QT 的 qmake 的工程。
为了让逐个编译的过程变成一条命令
在这篇文档中,将以C/C++的源码作为我们基础,所以必然涉及一些关于C/C++的编译的知识,相关于这方面的内容,还请各位查看相关的编译器的文档。这里所默认的编译器是UNIX下的GCC和CC。
一般来说,无论是C、C++、还是pas,首先要把源文件编译成中间代码文件,在Windows下也就是 .obj 文件,UNIX下是 .o 文件,即 Object File,这个动作叫做编译(compile)。然后再把大量的Object File合成执行文件,这个动作叫作链接(link)。
编译时,编译器需要的是语法的正确,函数与变量的声明的正确。对于后者,通常是你需要告诉编译器头文件的所在位置(头文件中应该只是声明,而定义应该放在C/C++文件中),只要所有的语法正确,编译器就可以编译出中间目标文件。一般来说,每个源文件都应该对应于一个中间目标文件(O文件或是OBJ文件)。
链接时,主要是链接函数和全局变量,所以,我们可以使用这些中间目标文件(O文件或是OBJ文件)来链接我们的应用程序。链接器并不管函数所在的源文件,只管函数的中间目标文件(Object File),在大多数时候,由于源文件太多,编译生成的中间目标文件太多,而在链接时需要明显地指出中间目标文件名,这对于编译很不方便,所以,我们要给中间目标文件打个包,在Windows下这种包叫“库文件”(Library File),也就是 .lib 文件,在UNIX下,是Archive File,也就是 .a 文件。
总结一下,源文件首先会生成中间目标文件,再由中间目标文件生成执行文件。在编译时,编译器只检测程序语法,和函数、变量是否被声明。如果函数未被声明,编译器会给出一个警告,但可以生成Object File。而在链接程序时,链接器会在所有的Object File中找寻函数的实现,如果找不到,那到就会报链接错误码(Linker Error),在VC下,这种错误一般是:Link 2001错误,意思说是说,链接器未能找到函数的实现。你需要指定函数的ObjectFile.
为了让逐个编译的过程变成一条命令
Makefile里主要包含了五个东西:显式规则、隐晦规则、变量定义、文件指示和注释。
最后,还值得一提的是,在Makefile中的命令,必须要以[Tab]键开始。
默认的情况下,make命令会在当前目录下按顺序找寻文件名为“GNUmakefile”、“makefile”、“Makefile”的文件,找到了解释这个文件。在这三个文件名中,最好使用“Makefile”这个文件名,因为,这个文件名第一个字符为大写,这样有一种显目的感觉。最好不要用“GNUmakefile”,这个文件是GNU的make识别的。有另外一些make只对全小写的“makefile”文件名敏感,但是基本上来说,大多数的make都支持“makefile”和“Makefile”这两种默认文件名。
当然,你可以使用别的文件名来书写Makefile,比如:“Make.Linux”,“Make.Solaris”,“Make.AIX”等,如果要指定特定的Makefile,你可以使用make的“-f”和“—file”参数,如:make -f Make.Linux或make —file Make.AIX。
在Makefile使用include关键字可以把别的Makefile包含进来,这很像C语言的#include,被包含的文件会原模原样的放在当前文件的包含位置。include的语法是:
1 | include<filename> |
filename可以是当前操作系统Shell的文件模式(可以保含路径和通配符)
在include前面可以有一些空字符,但是绝不能是[Tab]键开始。include和可以用一个或多个空格隔开。举个例子,你有这样几个Makefile:a.mk、b.mk、c.mk,还有一个文件叫foo.make,以及一个变量$(bar),其包含了e.mk和f.mk,那么,下面的语句:
1 | include foo.make *.mk $(bar) |
等价于:
1 | include foo.make a.mk b.mk c.mk e.mk f.mk |
make命令开始时,会把找寻include所指出的其它Makefile,并把其内容安置在当前的位置。就好像C/C++的#include指令一样。如果文件都没有指定绝对路径或是相对路径的话,make会在当前目录下首先寻找,如果当前目录下没有找到,那么,make还会在下面的几个目录下找:
1.如果make执行时,有“-I”或“—include-dir”参数,那么make就会在这个参数所指定的目录下去寻找。
2.如果目录/include(一般是:/usr/local/bin或/usr/include)存在的话,make也会去找。
如果有文件没有找到的话,make会生成一条警告信息,但不会马上出现致命错误。它会继续载入其它的文件,一旦完成makefile的读取,make会再重试这些没有找到,或是不能读取的文件,如果还是不行,make才会出现一条致命信息。如果你想让make不理那些无法读取的文件,而继续执行,你可以在include前加一个减号“-”。如:
-include
其表示,无论include过程中出现什么错误,都不要报错继续执行。和其它版本make兼容的相关命令是sinclude,其作用和这一个是一样的。
如果你的当前环境中定义了环境变量MAKEFILES,那么,make会把这个变量中的值做一个类似于include的动作。这个变量中的值是其它的Makefile,用空格分隔。只是,它和include不同的是,从这个环境变中引入的Makefile的“目标”不会起作用,如果环境变量中定义的文件发现错误,make也会不理。
但是在这里我还是建议不要使用这个环境变量,因为只要这个变量一被定义,那么当你使用make时,所有的Makefile都会受到它的影响,这绝不是你想看到的。在这里提这个事,只是为了告诉大家,也许有时候你的Makefile出现了怪事,那么你可以看看当前环境中有没有定义这个变量。
为了让逐个编译的过程变成一条命令
make命令执行时,需要一个 Makefile 文件,以告诉make命令需要怎么样的去编译和链接程序。
首先,我们用一个示例来说明Makefile的书写规则。以便给大家一个感性认识。这个示例来源于GNU的make使用手册,在这个示例中,我们的工程有8个C文件,和3个头文件,我们要写一个Makefile来告诉make命令如何编译和链接这几个文件。我们的规则是:
只要我们的Makefile写得够好,所有的这一切,我们只用一个make命令就可以完成,make命令会自动智能地根据当前的文件修改的情况来确定哪些文件需要重编译,从而自己编译所需要的文件和链接目标程序。
在讲述这个Makefile之前,还是让我们先来粗略地看一看Makefile的规则。
1 | target... : prerequisites ... |
target也就是一个目标文件,可以是Object File,也可以是执行文件。还可以是一个标签(Label),对于标签这种特性,在后续的“伪目标”章节中会有叙述。
prerequisites就是,要生成那个target所需要的文件或是目标。
command也就是make需要执行的命令。(任意的Shell命令)
这是一个文件的依赖关系,也就是说,target这一个或多个的目标文件依赖于prerequisites中的文件,其生成规则定义在command中。说白一点就是说,prerequisites中如果有一个以上的文件比target文件要新的话,command所定义的命令就会被执行。这就是Makefile的规则。也就是Makefile中最核心的内容。
说到底,Makefile的东西就是这样一点,好像我的这篇文档也该结束了。呵呵。还不尽然,这是Makefile的主线和核心,但要写好一个Makefile还不够,我会以后面一点一点地结合我的工作经验给你慢慢到来。内容还多着呢。:)
【注】:在看别人写的Makefile文件时,你可能会碰到以下三个变量:$@,$^,$<代表的意义分别是:
他们三个是十分重要的三个变量,所代表的含义分别是:
- $@ — 目标文件,
- $^ — 所有的依赖文件,
- $< — 第一个依赖文件。
正如前面所说的,如果一个工程有3个头文件,和8个C文件,我们为了完成前面所述的那三个规则,我们的Makefile应该是下面的这个样子的。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23edit : main.o kbd.o command.o display.o \
insert.o search.o files.o utils.o
cc -o edit main.o kbd.o command.o display.o \
insert.o search.o files.o utils.o
main.o : main.c defs.h
cc -c main.c
kbd.o : kbd.c defs.h command.h
cc -c kbd.c
command.o : command.c defs.h command.h
cc -c command.c
display.o : display.c defs.h buffer.h
cc -c display.c
insert.o : insert.c defs.h buffer.h
cc -c insert.c
search.o : search.c defs.h buffer.h
cc -c search.c
files.o : files.c defs.h buffer.h command.h
cc -c files.c
utils.o : utils.c defs.h
cc -c utils.c
clean :
rm edit main.o kbd.o command.o display.o \
insert.o search.o files.o utils.o
反斜杠(\)是换行符的意思。这样比较便于Makefile的易读。我们可以把这个内容保存在文件为“Makefile”或“makefile”的文件中,然后在该目录下直接输入命令“make”就可以生成执行文件edit。如果要删除执行文件和所有的中间目标文件,那么,只要简单地执行一下“make clean”就可以了。
在这个makefile中,目标文件(target)包含:执行文件edit和中间目标文件(*.o),依赖文件(prerequisites)就是冒号后面的那些 .c 文件和 .h文件。每一个 .o 文件都有一组依赖文件,而这些 .o 文件又是执行文件 edit 的依赖文件。依赖关系的实质上就是说明了目标文件是由哪些文件生成的,换言之,目标文件是哪些文件更新的。
在定义好依赖关系后,后续的那一行定义了如何生成目标文件的操作系统命令,一定要以一个 Tab键作为开头 。记住,make并不管命令是怎么工作的,他只管执行所定义的命令。make会比较targets文件和prerequisites文件的修改日期,如果prerequisites文件的日期要比targets文件的日期要新,或者target不存在的话,那么,make就会执行后续定义的命令。
这里要说明一点的是,clean不是一个文件,它只不过是一个动作名字,有点像C语言中的lable一样,其冒号后什么也没有,那么,make就不会自动去找文件的依赖性,也就不会自动执行其后所定义的命令。要执行其后的命令,就要在make命令后明显得指出这个lable的名字。这样的方法非常有用,我们可以在一个makefile中定义不用的编译或是和编译无关的命令,比如程序的打包,程序的备份,等等。
在默认的方式下,也就是我们只输入make命令。那么,
这就是整个make的依赖性,make会一层又一层地去找文件的依赖关系,直到最终编译出第一个目标文件。在找寻的过程中,如果出现错误,比如最后被依赖的文件找不到,那么make就会直接退出,并报错,而对于所定义的命令的错误,或是编译不成功,make根本不理。make只管文件的依赖性,即,如果在我找了依赖关系之后,冒号后面的文件还是不在,那么对不起,我就不工作啦。
通过上述分析,我们知道,像clean这种,没有被第一个目标文件直接或间接关联,那么它后面所定义的命令将不会被自动执行,不过,我们可以显示要make执行。即命令——“make clean”,以此来清除所有的目标文件,以便重编译。
于是在我们编程中,如果这个工程已被编译过了,当我们修改了其中一个源文件,比如file.c,那么根据我们的依赖性,我们的目标file.o会被重编译(也就是在这个依性关系后面所定义的命令),于是file.o的文件也是最新的啦,于是file.o的文件修改时间要比edit要新,所以edit也会被重新链接了(详见edit目标文件后定义的命令)。
而如果我们改变了“command.h”,那么,kdb.o、command.o和files.o都会被重编译,并且,edit会被重链接。
在上面的例子中,先让我们看看edit的规则:1
2
3
4edit : main.o kbd.o command.o display.o \
insert.o search.o files.o utils.o
cc -o edit main.o kbd.o command.o display.o \
insert.o search.o files.o utils.o
我们可以看到[.o]文件的字符串被重复了两次,如果我们的工程需要加入一个新的[.o]文件,那么我们需要在两个地方加(应该是三个地方,还有一个地方在clean中)。当然,我们的makefile并不复杂,所以在两个地方加也不累,但如果makefile变得复杂,那么我们就有可能会忘掉一个需要加入的地方,而导致编译失败。所以,为了makefile的易维护,在makefile中我们可以使用变量。makefile的变量也就是一个字符串,理解成C语言中的宏可能会更好。
比如,我们声明一个变量,叫objects, OBJECTS, objs, OBJS, obj, 或是 OBJ,反正不管什么啦,只要能够表示obj文件就行了。我们在makefile一开始就这样定义:
1 | objects = main.o kbd.o command.o display.o \ |
于是,我们就可以很方便地在我们的makefile中以“$(objects)”的方式来使用这个变量了,于是我们的改良版makefile就变成下面这个样子:
1 | objects = main.o kbd.o command.o display.o \ |
于是如果有新的 .o 文件加入,我们只需简单地修改一下 objects 变量就可以了。
关于变量更多的话题,我会在后续给你一一道来。
GNU的make很强大,它可以自动推导文件以及文件依赖关系后面的命令,于是我们就没必要去在每一个[.o]文件后都写上类似的命令,因为,我们的make会自动识别,并自己推导命令。
只要make看到一个[.o]文件,它就会自动的把[.c]文件加在依赖关系中,如果make找到一个whatever.o,那么whatever.c,就会是whatever.o的依赖文件。并且 cc -c whatever.c 也会被推导出来,于是,我们的makefile再也不用写得这么复杂。我们的是新的makefile又出炉了。
1 | objects = main.o kbd.o command.o display.o \ |
这种方法,也就是make的“隐晦规则”。上面文件内容中,“.PHONY”表示,clean是个伪目标文件。
关于更为详细的“隐晦规则”和“伪目标文件”,我会在后续给你一一道来。
即然我们的make可以自动推导命令,那么我看到那堆[.o]和[.h]的依赖就有点不爽,那么多的重复的[.h],能不能把其收拢起来,好吧,没有问题,这个对于make来说很容易,谁叫它提供了自动推导命令和文件的功能呢?来看看最新风格的makefile吧。
1 | objects = main.o kbd.o command.o display.o \ |
这种风格,让我们的makefile变得很简单,但我们的文件依赖关系就显得有点凌乱了。鱼和熊掌不可兼得。还看你的喜好了。我是不喜欢这种风格的,一是文件的依赖关系看不清楚,二是如果文件一多,要加入几个新的.o文件,那就理不清楚了。
每个Makefile中都应该写一个清空目标文件(.o和执行文件)的规则,这不仅便于重编译,也很利于保持文件的清洁。这是一个“修养”(呵呵,还记得我的《编程修养》吗)。一般的风格都是:
1 | clean: |
更为稳健的做法是:
1 | .PHONY : clean |
前面说过,.PHONY意思表示clean是一个“伪目标”,。而在rm命令前面加了一个小减号的意思就是,也许某些文件出现问题,但不要管,继续做后面的事。当然,clean的规则不要放在文件的开头,不然,这就会变成make的默认目标,相信谁也不愿意这样。不成文的规矩是——“clean从来都是放在文件的最后”。
上面就是一个makefile的概貌,也是makefile的基础,下面还有很多makefile的相关细节,准备好了吗?准备好了就来。
通过之前章节的学习,我们对Makefile有个基础的认识,现在开始自己动手写Makefile。目前网络上有不少可以自动生成Makefile的工具,但很多项目其实没必要那么复杂,完全可以自己动手写出来。而且对于初学者来说,自己动手写一遍Makefile可以顶看十遍高手写的Makefile,也可以加深对Makefile的理解,将来公司的Makefile有需要修改的时候自己就可以动手搞定,不需要依靠他人,何乐而不为?
介绍在本教程中用于示例的代码很简单,仅仅是在main函数中调用了fun1及fun2函数,而fun1及fun2独立写在fun1.c及fun2.c里。代码如下:
1 | //main.c |
对于我们的示例代码,不通过Makefile编译其实也很简单:gcc main.c fun1.c fun2.c -o app 我们知道,Makefile其实就是按规则一条条的执行。所以,我们完全可以把上面那条命令写成Makefile的一个规则。我们的目标是app,按此写法依赖是main.c fun1.c fun2.c,则最终的Makefile如下:1
2app: main.c fun1.c fun2.c
gcc main.c fun1.c fun2.c -o app
但这个版本的Makefile有两个很重要的不足:
基于此,我们在第一版的基础上优化出第二版。
在第二版Makefile中,为了避免改动任何代码就需要重新编译整个项目的问题,我们将主规则的各个依赖替换成各自的中间文件,即main.c —> main.o,fun1.c —> fun1.o,fun2.c —> fun2.o,再对每个中间文件的生成各自写条规则比如对于main.o,规则为:
1 | main.o: main.c |
这样做的好处是,当有一个文件发生改动时,只需重新编译此文件即可,而无需重新编译整个项目。完整Makefile如下:1
2
3
4
5
6
7
8
9
10
11app: main.o fun1.o fun2.o
gcc main.o fun1.o fun2.o -o app
main.o: main.c
gcc -c main.c -o main.o
fun1.o: fun1.c
gcc -c fun1.c -o fun1.o
fun2.o: fun2.c
gcc -c fun2.c -o fun2.o
第二版Makefile同样具有一些缺陷:
1. 里面存在一些重复的内容,可以考虑用变量代替;2. 后面三条规则非常类似,可以考虑用一条模式规则代替。
基于此,我们在第二版的基础上优化出第三版。
在第三版Makefile中,我们使用变量及模式规则使Makefile更加简洁。使用的三个变量如下:
1 | obj = main.o fun1.o fun2.o |
使用的模式规则为:
1 | %.o: %.c |
这条模式规则表示:所有的.o文件都由对应的.c文件生成。在规则里,我们又看到了两个自动变量:$<和$@。其实自动变量有很多,常用的有三个:
- $<:第一个依赖文件; - $@:目标; - $^:所有不重复的依赖文件,以空格分开
1 | obj = main.o fun1.o fun2.o |
第三版Makefile依然存在一些缺陷:
1. obj对应的文件需要一个个输入,工作量大;2. 文件数目比较少时还好,文件数目一旦很多的话,obj将很长;3. 而且每增加/除一个文件,都需要修改Makefile。
基于此,我们在第二版的基础上优化出第四版。
在第四版Makefile中,我们隆重推出了两个函数:wildcard和patsubst。
扩展通配符,搜索指定文件。在此我们使用src = $(wildcard ./*.c),代表在当前目录下搜索所有的.c文件,并赋值给src。函数执行结束后,src的值为:main.c fun1.c fun2.c。
替换通配符,按指定规则做替换。在此我们使用
1 | obj = $(patsubst %.c, %.o, $(src)) |
代表将src里的每个文件都由.c替换成.o。函数执行结束后,obj的值为main.o fun1.o fun2.o,其实跟第三版Makefile的obj值一模一样,只不过在这里它更智能一些,也更灵活。除了使用patsubst函数外,我们也可以使用模式规则达到同样的效果,比如:
1 | obj = $(src:%.c=%.o) |
也是代表将src里的每个文件都由.c替换成.o。几乎每个Makefile里都会有一个伪目标clean,这样我们通过执行make clean命令就是将中间文件如.o文件及目标文件全部删除,留下干净的空间。一般是如下写法:
1 | .PHONY: clean |
.PHONY代表声明clean是一个伪目标,这样每次执行make clean时,下面的规则都会被执行。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15src = $(wildcard ./*.c)
obj = $(patsubst %.c, %.o, $(src))
#obj = $(src:%.c=%.o)
target = app
CC = gcc
$(target): $(obj)
$(CC) $(obj) -o $(target)
%.o: %.c
$(CC) -c $< -o $@
.PHONY: clean
clean:
rm -rf $(obj) $(target)
Makefile其实也并不难,但关键的是一定要自己动手写,这样才会更加加深理解,否则也容易造成眼高手低。如果实在不知道从何下手,可以尝试按上面的教程,一步步写下来,也只需要写四个版本而已,写完了相信就有了初步的理解。我是良许,世界500强外企 Linux 开发工程师,专业生产 Linux 方面干货,欢迎点赞、收藏!
作者:程序员良许
链接:https://www.zhihu.com/question/23792247/answer/600773044
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
发布者确认是RabbitMQ的扩展,可以实现可靠的发布。在channel上启用发布者确认后,代理将异步确认客户端发布的消息,这意味着他们已在服务器端处理。
发布者确认是AMQP 0.9.1协议的RabbitMQ扩展,因此默认情况下未启用它们。发布者确认是通过ConfirmSelect方法在通道级别启用的:
1 | var channel = connection.CreateModel(); |
必须在希望使用发布者确认的每个频道上调用此方法。确认仅应启用一次,而不是对每个已发布的消息都启用
每条消息发步后,等待确认1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20private static void PublishMessagesIndividually()
{
using (var connection = CreateConnection())
using (var channel = connection.CreateModel())
{
var queueName = channel.QueueDeclare().QueueName;
channel.ConfirmSelect();
var timer = new Stopwatch();
timer.Start();
for (int i = 0; i < MESSAGE_COUNT; i++)
{
var body = Encoding.UTF8.GetBytes(i.ToString());
channel.BasicPublish(exchange: "", routingKey: queueName, basicProperties: null, body: body);
channel.WaitForConfirmsOrDie(new TimeSpan(0, 0, 5));
}
timer.Stop();
Console.WriteLine($"Published {MESSAGE_COUNT:N0} messages individually in {timer.ElapsedMilliseconds:N0} ms");
}
}
在前面的示例中,我们像往常一样发布一条消息,并等待通过Channel#WaitForConfirmsOrDie(TimeSpan)方法进行确认。确认消息后,该方法立即返回。如果未在超时时间内确认该消息或该消息没有被确认(这意味着代理出于某种原因无法处理该消息),则该方法将引发异常。异常的处理通常包括记录错误消息和/或重试发送消息。
此方法非常简单,但也有一个主要缺点:由于消息的确认会阻止所有后续消息的发布,因此它会大大降低发布速度。这种方法不会提供每秒超过数百条已发布消息的吞吐量。但是,对于某些应用程序来说这可能已经足够了。
发布者确认异步吗?
我们在一开始提到代理程序以异步方式确认发布的消息,但是在第一个示例中,代码同步等待直到消息被确认。
客户端实际上异步接收确认,并相应地取消阻止对WaitForConfirmsOrDie的调用 。将WaitForConfirmsOrDie视为依赖于后台异步通知的同步。
为了改进前面的示例,我们可以发布一批消息,并等待整个批次被确认。以下示例使用了100个批次:
1 | private static void PublishMessagesInBatch() |
与等待确认单个消息相比,等待一批消息被确认可以极大地提高吞吐量(对于远程RabbitMQ节点,这最多可以达到20-30倍)。
缺点之一是我们不知道发生故障时到底出了什么问题,因此我们可能必须将整个批处理保存在内存中,以记录有意义的内容或重新发布消息。而且该解决方案仍然是同步的,因此它阻止了消息的发布。
代理异步确认已发布的消息,只需在客户端上注册一个回调即可收到这些确认的通知:
1 | var channel = connection.CreateModel(); |
有2个回调:一个用于确认的消息,另一个用于未确认的消息(代理可以认为丢失的消息)。这两个回调都有一个对应的 EventArgs 参数(ea),其中包含:
可以在消息发布之前通过 Channel#NextPublishSeqNo 获取序列号
1 | var sequenceNumber = channel.NextPublishSeqNo; |
将消息与序列号关联的一种简单方法是使用字典。假设我们要发布字符串,因为它们很容易变成要发布的字节数组。这是一个代码示例,该示例使用字典将发布序列号与消息的字符串主体相关联:
1 | var outstandingConfirms = new ConcurrentDictionary<ulong, string>(); |
现在,发布消息 使用字典来跟踪 消息是否被确认。
我们需要在消息确认回调时清理此字典,并做一些类似在消息丢失警告的操作:
1 | var outstandingConfirms = new ConcurrentDictionary<ulong, string>(); |
1 | using RabbitMQ.Client; |
1 | Published 50,000 messages individually in 5,549 ms |
1 | Published 50,000 messages individually in 231,541 ms |
graph LR; id1([Product]); id2([ExChange]); id3([amq.gen-DjtYso1eaz52eM3mAJToaw]) id4([amq.gen-nLrD6gHpPBMY-oqM-tBVcQ]) id5([C1]) id6([C2]) style id1 fill:#0ff,stroke:#333; style id2 fill:#33c,stroke:#333; style id3 fill:#f00,stroke:#333; style id4 fill:#f00,stroke:#333; style id5 fill:#3cf,stroke:#333; style id6 fill:#3cf,stroke:#333; id1-->id2; id2-->|error|id3; id3-->id5; id2-->|error|id4; id2-->|info|id4; id4-->id6;
注意
这个是一种 完全匹配 只有匹配到的消费者才能消费消息
消息中的路由键值如果和Binding中的binding key 一致,交换机就将消息发送到对应的队列中。
路由键与队列名完全匹配,如果一个队列绑定到交换机要求路由键为”dog”,则只转发routingkey 标记为”dog”的消息,不会转发”dog.puppy”,也不会转发”dog.guard”等等。这个是时 完全匹配、单播的模式
1 | using System; |
1 | using RabbitMQ.Client; |
添加交换机
消费者1 监听 error 和 info 两个路由
1 | $ ./bin/Debug/RoutingMQConsumer1.exe error info |
消费者2 只监听 error 路由1
2$ ./bin/Debug/RoutingMQConsumer1.exe error
生产者 分别对 info/error/warn 路由 各发送一条纤细1
2
3$ ./bin/Debug/RoutingMQProduct.exe info hhh
$ ./bin/Debug/RoutingMQProduct.exe error 11111
$ ./bin/Debug/RoutingMQProduct.exe warn www
如下:
graph TB; C([Client]); mq1([rpc_queue]) mq2([reply_to=amq.gen-..]) S([Server]) style C fill:#3cf,stroke:#333; style mq1 fill:#f00,stroke:#333; style mq2 fill:#f00,stroke:#333; style S fill:#3cf,stroke:#333; C-->|Request reply_to=amq.gen-.. correlation_id=abc |mq1; mq1-->S; S-->mq2; mq2-->|Reply correlation_id=abc|C;
客户端
1 | using System; |
服务端
1 | using RabbitMQ.Client; |
演示
一个生产者对一个消费者
graph LR; id1([Product]); id2([Consumer]); id3([Message Quene]) style id1 fill:#0ff,stroke:#333; style id2 fill:#3cf,stroke:#333; style id3 fill:#f00,stroke:#333; id1-->id3; id3-->id2;
请注意,生产者,消费者和经纪人不必位于同一主机上。实际上,在大多数应用程序中它们不是。一个应用程序既可以是生产者,也可以是消费者。
RabbitMQ使用多种协议。本教程使用AMQP 0-9-1,这是一种开放的通用消息传递协议。RabbitMQ有许多不同语言的客户。我们将使用RabbitMQ提供的.NET客户端。
环境
新建项目 .net35 控制台项目
SimpleMQProduct
1 | using RabbitMQ.Client; |
新建项目 .net35 控制台项目
SimpleMQConsumer
1 | using RabbitMQ.Client; |
通过 ConnectionFactory 设置 RabbitMQ 连接参数1
var factory = new ConnectionFactory() { HostName = "localhost", Port=5672, UserName = "guest", Password = "guest", VirtualHost = "frexport" };
这里的参数
参数 | 参数类型 | 参数说明 | 默认值 |
---|---|---|---|
HostName | string | 主机的IP | |
Port | int | 主机通信端口 | 5672 |
UserName | string | 连接账户 | guest |
Password | string | 连接账户密码 | guest |
VirtualHost | string | 访问的虚拟主机,可以理解为一个应用MQ | / |
对应的RabbitMQ操作
guest用户设置密码为 guest
设置VHost权限,添加guest用户权限
设置后如下
回到User界面
我们使用 frexport 虚拟主机创建一个队列 hello
添加后
Product
Consumer
一个生成者对应多个消费者
之前我们创建了一个工作队列。工作队列背后的假设是,每个任务都恰好交付给一个工人。
在这一部分中,我们将做一些完全不同的事情-我们将消息传达给多个消费者。这种模式称为“发布/订阅”。
为了说明这种模式,我们将构建一个简单的日志记录系统。它包含两个程序-第一个程序将发出日志消息,第二个程序将接收并打印它们。
graph LR; id1([Product]); id2([ExChange]); id3([amq.gen-2G4YaJ2P3JcJEwHHiRL5JA]) id4([amq.gen-tsfVrHogVGKF3vGv6-rPWg]) id5([C1]) id6([C2]) style id1 fill:#0ff,stroke:#333; style id2 fill:#33c,stroke:#333; style id3 fill:#f00,stroke:#333; style id4 fill:#f00,stroke:#333; style id5 fill:#3cf,stroke:#333; style id6 fill:#3cf,stroke:#333; id1-->id2; id2-->id3-->id5; id2-->id4-->id6;
如果消息发送到没有队列绑定的交换机上,那么消息将丢失
1 | using RabbitMQ.Client; |
1 | using RabbitMQ.Client; |
添加交换机
还可以看RabbitMQ 自动生成了两个队列绑定路由
这个模式下,消息会被交换机转发给每个订阅者,每个订阅消费者都会在MQ端有一个Queue队列。
生产者的消息会转到所有绑定交换机的队列上,消费者消费所有队列消息
主题模式类似 路由模式
路由模式是 完全匹配 模式,主题模式匹配 通配符
graph LR;]]>
p([Product]);
ex([ExChange]);
mq1([amq.gen-fMFRcKxaTxM-o_ApPe_AHw])
mq2([amq.gen-jWFR9bCh4_b52j6KUDt1Sw])
mq3([amq.gen-kkOjkWx9if2mQB_3gcfO4w])
mq4([amq.gen-tNqCT75w_QqSJbVKrJapQQ])
c1([C1])
c2([C2])
c3([C3])
c4([C4])style p fill:#0ff,stroke:#333;style ex fill:#33c,stroke:#333;style mq1 fill:#f00,stroke:#333;style mq2 fill:#f00,stroke:#333;style mq3 fill:#f00,stroke:#333;style mq4 fill:#f00,stroke:#333;style c1 fill:#3cf,stroke:#333;style c2 fill:#3cf,stroke:#333;style c3 fill:#3cf,stroke:#333;style c4 fill:#3cf,stroke:#333;p-->ex;ex-->|#|mq1;mq1-->c1;ex-->|kern.*|mq2;mq2-->c2;ex-->|*.critical|mq3;mq3-->c3;ex-->|kern.*|mq4;ex-->|*.critical|mq4;mq4-->c4;</pre>
生产者代码:
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 using RabbitMQ.Client;
using System;
using System.Text;
namespace TopicsMQConsumer
{
class EmitLogTopic
{
static string ExchangeName = "topic_logs";
static void Main(string[] args)
{
var factory = new ConnectionFactory()
{
HostName = "localhost",
VirtualHost = "frexport"
};
using (var connection = factory.CreateConnection())
using (var channel = connection.CreateModel())
{
// 设置交换机以及交换机模式 durable 不设置的话,默认为false
channel.ExchangeDeclare(exchange: ExchangeName,
type: ExchangeType.Topic,
durable: false);
//路由信息
var routingKey = (args.Length > 0) ? args[0] : "anonymous.info";
//消息
var message = (args.Length > 1) ? args[1] : "HelloWorld!";
var body = Encoding.UTF8.GetBytes(message);
channel.BasicPublish(exchange: ExchangeName,
routingKey: routingKey,
basicProperties: null,
body: body);
Console.WriteLine(" [x] Sent '{0}':'{1}'", routingKey, message);
}
Console.WriteLine(" Press [enter] to exit.");
Console.ReadLine();
}
}
}消费者代码
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
60
61
62
63
64
65
66
67
68
69
70 using RabbitMQ.Client;
using RabbitMQ.Client.Events;
using System;
using System.Linq;
using System.Text;
namespace TopicsMQConsumer
{
class ReceiveLogsTopic
{
static string ExchangeName = "topic_logs";
static void Main(string[] args)
{
var factory = new ConnectionFactory()
{
HostName = "localhost",
VirtualHost = "frexport"
};
using (var connection = factory.CreateConnection())
using (var channel = connection.CreateModel())
{
// 设置交换机以及交换机模式 durable 不设置的话,默认为false
channel.ExchangeDeclare(exchange: ExchangeName,
type: ExchangeType.Topic,
durable: false);
var queueName = channel.QueueDeclare().QueueName;
if (args.Length < 1)
{
Console.Error.WriteLine("Usage: {0} [binding_key...]",
Environment.GetCommandLineArgs()[0]);
Console.WriteLine(" Press [enter] to exit.");
Console.ReadLine();
Environment.ExitCode = 1;
return;
}
//消息队列 绑定到 对应交换机的路由上
foreach (var bindingKey in args)
{
channel.QueueBind(queue: queueName,
exchange: ExchangeName,
routingKey: bindingKey);
}
Console.WriteLine(" [*] Waiting for messages. To exit press CTRL+C");
var consumer = new EventingBasicConsumer(channel);
consumer.Received += (model, ea) =>
{
var body = ea.Body.ToArray();
var message = Encoding.UTF8.GetString(body);
var routingKey = ea.RoutingKey;
Console.WriteLine(" [x] Received '{0}':'{1}'",
routingKey,
message);
};
channel.BasicConsume(queue: queueName,
noAck: true,
consumer: consumer);
Console.WriteLine(" Press [enter] to exit.");
Console.ReadLine();
}
}
}
}配置RabbitMQ
添加交换机
测试:
消费者1 监听 “#” 所有消息
1 $ ./bin/Debug/TopicsMQConsumer.exe "#"消费者2 只监听 kern.* 通配符
1 $ ./bin/Debug/TopicsMQConsumer.exe "kern.*"消费者3 只监听 “*.critical” 通配符
1 $ ./bin/Debug/TopicsMQConsumer.exe "*.critical"消费者4 监听 “kern.“ “.critical” 通配符
1 $ ./bin/Debug/TopicsMQConsumer.exe "kern.*" "*.critical"生产者发送消息
1
2
3
4
5
6 $ ./bin/Debug/TopicsMQProduct.exe kern.critic wwww
$ ./bin/Debug/TopicsMQProduct.exe kern.1 wwww
$ ./bin/Debug/TopicsMQProduct.exe kwww wwww
$ ./bin/Debug/TopicsMQProduct.exe kwww.critic wwww
$ ./bin/Debug/TopicsMQProduct.exe kwww.criticical wwww
$ ./bin/Debug/TopicsMQProduct.exe kwww.critical wwww输出结果
RabbitMQ Model介绍
工作队列背后的假设是,每个任务都恰好交付给一个工人
一个生成者对应多个消费者
graph LR; id1([Product]); id2([Message Quene]) id3([Consumer1]); id4([Consumer2]); style id1 fill:#0ff,stroke:#333; style id2 fill:#f00,stroke:#333; style id3 fill:#3cf,stroke:#333; style id4 fill:#3cf,stroke:#333; id1-->id2; id2-->id3; id2-->id4;
将比较复杂比较耗时的任务放在任务队列中,不必立即执行。
任务队列用来管理任务列表,我们在后台的工作可以交给多个线程来完成。
创建两个工程一个作为生产者,一个作为消费者
这个时候的消费者,不能立即处理完一个事情,需要消耗一定时间
我们同时开启多个消费者消费任务。
生产者不停的生产新的任务
以下是代码
1 | using RabbitMQ.Client; |
同一队列可以有多个消费者同时消费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
60
61
62using RabbitMQ.Client;
using RabbitMQ.Client.Events;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
namespace WorkMQConsumer
{
class Worker
{
static string QueueName = "task_queue";
static void Main(string[] args)
{
var factory = new ConnectionFactory() { };
factory.HostName = "localhost";
factory.VirtualHost = "frexport";
factory.UserName = "guest";
factory.Password = "guest";
using (var connection = factory.CreateConnection())
{
using (var channel = connection.CreateModel())
{
channel.QueueDeclare(queue: QueueName,
durable: true,
exclusive: false,
autoDelete: false,
arguments: null);
//同一时刻服务器只发送一条消息给消费端
channel.BasicQos(prefetchCount: 1, prefetchSize: 0, global: false);
Console.WriteLine(" [*] Waiting for message.");
var consumer = new EventingBasicConsumer(channel);
consumer.Received += (sender, ea) =>
{
var body = ea.Body;
var message = System.Text.Encoding.UTF8.GetString(body);
Console.WriteLine(" [x] Receive {0} {1}", message, DateTime.Now);
Thread.Sleep(1000);
//消息消费完给服务器返回确认状态,表示该消息已被消费
channel.BasicAck(deliveryTag: ea.DeliveryTag, multiple: false);
};
channel.BasicConsume(queue: QueueName,
noAck: false,
consumer: consumer);
Console.WriteLine(" Press [enter] to exit.");
Console.ReadLine();
}
}
}
}
}
消费者从消息队列获取消息后,服务端就认为该消息已经成功消费。
1 | var consumer = new EventingBasicConsumer(channel); |
消费者从消息队列获取消息后,服务端并没有标记为成功消费
消费者成功消费后需要将状态返回到服务端
1 | var consumer = new EventingBasicConsumer(channel); |
本文以淘宝作为例子,介绍从一百个并发到千万级并发情况下服务端的架构的演进过程,同时列举出每个演进阶段会遇到的相关技术,让大家对架构的演进有一个整体的认知,文章最后汇总了一些架构设计的原则。
在介绍架构之前,为了避免部分读者对架构设计中的一些概念不了解,下面对几个最基础的概念进行介绍。
系统中的多个模块在不同服务器上部署,即可称为分布式系统,如Tomcat和数据库分别部署在不同的服务器上,或两个相同功能的Tomcat分别部署在不同服务器上。
系统中部分节点失效时,其他节点能够接替它继续提供服务,则可认为系统具有高可用性。
一个特定领域的软件部署在多台服务器上并作为一个整体提供一类服务,这个整体称为集群。
如Zookeeper中的Master和Slave分别部署在多台服务器上,共同组成一个整体提供集中配置服务。
在常见的集群中,客户端往往能够连接任意一个节点获得服务,并且当集群中一个节点掉线时,其他节点往往能够自动的接替它继续提供服务,这时候说明集群具有高可用性。
请求发送到系统时,通过某些方式把请求均匀分发到多个节点上,使系统中每个节点能够均匀的处理请求负载,则可认为系统是负载均衡的。
系统内部要访问外部网络时,统一通过一个代理服务器把请求转发出去,在外部网络看来就是代理服务器发起的访问,此时代理服务器实现的是正向代理;
当外部请求进入系统时,代理服务器把该请求转发到系统中的某台服务器上,对外部请求来说,与之交互的只有代理服务器,此时代理服务器实现的是反向代理。
简单来说,正向代理是代理服务器代替系统内部来访问外部网络的过程,反向代理是外部请求访问系统时通过代理服务器转发到内部服务器的过程。
以淘宝作为例子:在网站最初时,应用数量与用户数都较少,可以把Tomcat和数据库部署在同一台服务器上。
浏览器往www.taobao.com发起请求时,首先经过DNS服务器(域名系统)把域名转换为实际IP地址10.102.4.1,浏览器转而访问该IP对应的Tomcat。
架构瓶颈:随着用户数的增长,Tomcat和数据库之间竞争资源,单机性能不足以支撑业务。
Tomcat和数据库分别独占服务器资源,显著提高两者各自性能。
架构瓶颈:随着用户数的增长,并发读写数据库成为瓶颈。
Tips:欢迎关注微信公众号:Java后端,获取更多技术博文推送。
在Tomcat同服务器上或同JVM中增加本地缓存,并在外部增加分布式缓存,缓存热门商品信息或热门商品的html页面等。通过缓存能把绝大多数请求在读写数据库前拦截掉,大大降低数据库压力。
其中涉及的技术包括:使用memcached作为本地缓存,使用Redis作为分布式缓存,还会涉及缓存一致性、缓存穿透/击穿、缓存雪崩、热点数据集中失效等问题。
架构瓶颈:缓存抗住了大部分的访问请求,随着用户数的增长,并发压力主要落在单机的Tomcat上,响应逐渐变慢。
在多台服务器上分别部署Tomcat,使用反向代理软件(Nginx)把请求均匀分发到每个Tomcat中。
此处假设Tomcat最多支持100个并发,Nginx最多支持50000个并发,那么理论上Nginx把请求分发到500个Tomcat上,就能抗住50000个并发。
其中涉及的技术包括:Nginx、HAProxy,两者都是工作在网络第七层的反向代理软件,主要支持http协议,还会涉及session共享、文件上传下载的问题。
架构瓶颈:反向代理使应用服务器可支持的并发量大大增加,但并发量的增长也意味着更多请求穿透到数据库,单机的数据库最终成为瓶颈。
把数据库划分为读库和写库,读库可以有多个,通过同步机制把写库的数据同步到读库,对于需要查询最新写入数据场景,可通过在缓存中多写一份,通过缓存获得最新数据。
其中涉及的技术包括:Mycat,它是数据库中间件,可通过它来组织数据库的分离读写和分库分表,客户端通过它来访问下层数据库,还会涉及数据同步,数据一致性的问题。
架构瓶颈:业务逐渐变多,不同业务之间的访问量差距较大,不同业务直接竞争数据库,相互影响性能。
把不同业务的数据保存到不同的数据库中,使业务之间的资源竞争降低,对于访问量大的业务,可以部署更多的服务器来支撑。
这样同时导致跨业务的表无法直接做关联分析,需要通过其他途径来解决,但这不是本文讨论的重点,有兴趣的可以自行搜索解决方案。
架构瓶颈:随着用户数的增长,单机的写库会逐渐会达到性能瓶颈。
比如针对评论数据,可按照商品ID进行hash,路由到对应的表中存储;
针对支付记录,可按照小时创建表,每个小时表继续拆分为小表,使用用户ID或记录编号来路由数据。
只要实时操作的表数据量足够小,请求能够足够均匀的分发到多台服务器上的小表,那数据库就能通过水平扩展的方式来提高性能。其中前面提到的Mycat也支持在大表拆分为小表情况下的访问控制。
这种做法显著的增加了数据库运维的难度,对DBA的要求较高。数据库设计到这种结构时,已经可以称为分布式数据库
但这只是一个逻辑的数据库整体,数据库里不同的组成部分是由不同的组件单独来实现的
如分库分表的管理和请求分发,由Mycat实现,SQL的解析由单机的数据库实现,读写分离可能由网关和消息队列来实现,查询结果的汇总可能由数据库接口层来实现等等
这种架构其实是MPP(大规模并行处理)架构的一类实现。
目前开源和商用都已经有不少MPP数据库,开源中比较流行的有Greenplum、TiDB、Postgresql XC、HAWQ等,商用的如南大通用的GBase、睿帆科技的雪球DB、华为的LibrA等等
不同的MPP数据库的侧重点也不一样,如TiDB更侧重于分布式OLTP场景,Greenplum更侧重于分布式OLAP场景
这些MPP数据库基本都提供了类似Postgresql、Oracle、MySQL那样的SQL标准支持能力,能把一个查询解析为分布式的执行计划分发到每台机器上并行执行,最终由数据库本身汇总数据进行返回
也提供了诸如权限管理、分库分表、事务、数据副本等能力,并且大多能够支持100个节点以上的集群,大大降低了数据库运维的成本,并且使数据库也能够实现水平扩展。
架构瓶颈:数据库和Tomcat都能够水平扩展,可支撑的并发大幅提高,随着用户数的增长,最终单机的Nginx会成为瓶颈。
由于瓶颈在Nginx,因此无法通过两层的Nginx来实现多个Nginx的负载均衡。
图中的LVS和F5是工作在网络第四层的负载均衡解决方案,其中LVS是软件,运行在操作系统内核态,可对TCP请求或更高层级的网络协议进行转发,因此支持的协议更丰富,并且性能也远高于Nginx,可假设单机的LVS可支持几十万个并发的请求转发;
F5是一种负载均衡硬件,与LVS提供的能力类似,性能比LVS更高,但价格昂贵。
由于LVS是单机版的软件,若LVS所在服务器宕机则会导致整个后端系统都无法访问,因此需要有备用节点。
可使用keepalived软件模拟出虚拟IP,然后把虚拟IP绑定到多台LVS服务器上,浏览器访问虚拟IP时,会被路由器重定向到真实的LVS服务器
当主LVS服务器宕机时,keepalived软件会自动更新路由器中的路由表,把虚拟IP重定向到另外一台正常的LVS服务器,从而达到LVS服务器高可用的效果。
此处需要注意的是,上图中从Nginx层到Tomcat层这样画并不代表全部Nginx都转发请求到全部的Tomcat
在实际使用时,可能会是几个Nginx下面接一部分的Tomcat,这些Nginx之间通过keepalived实现高可用,其他的Nginx接另外的Tomcat,这样可接入的Tomcat数量就能成倍的增加。
架构瓶颈:由于LVS也是单机的,随着并发数增长到几十万时,LVS服务器最终会达到瓶颈,此时用户数达到千万甚至上亿级别,用户分布在不同的地区,与服务器机房距离不同,导致了访问的延迟会明显不同。
在DNS服务器中可配置一个域名对应多个IP地址,每个IP地址对应到不同的机房里的虚拟IP。
当用户访问www.taobao.com时,DNS服务器会使用轮询策略或其他策略,来选择某个IP供用户访问。此方式能实现机房间的负载均衡
至此,系统可做到机房级别的水平扩展,千万级到亿级的并发量都可通过增加机房来解决,系统入口处的请求并发量不再是问题。
架构瓶颈:随着数据的丰富程度和业务的发展,检索、分析等需求越来越丰富,单单依靠数据库无法解决如此丰富的需求。
当数据库中的数据多到一定规模时,数据库就不适用于复杂的查询了,往往只能满足普通查询的场景。
对于统计报表场景,在数据量大时不一定能跑出结果,而且在跑复杂查询时会导致其他查询变慢
对于全文检索、可变数据结构等场景,数据库天生不适用。因此需要针对特定的场景,引入合适的解决方案。
如对于海量文件存储,可通过分布式文件系统HDFS解决,对于key value类型的数据,可通过HBase和Redis等方案解决
对于全文检索场景,可通过搜索引擎如ElasticSearch解决,对于多维分析场景,可通过Kylin或Druid等方案解决。
当然,引入更多组件同时会提高系统的复杂度,不同的组件保存的数据需要同步,需要考虑一致性的问题,需要有更多的运维手段来管理这些组件等。
架构瓶颈:引入更多组件解决了丰富的需求,业务维度能够极大扩充,随之而来的是一个应用中包含了太多的业务代码,业务的升级迭代变得困难。
按照业务板块来划分应用代码,使单个应用的职责更清晰,相互之间可以做到独立升级迭代。这时候应用之间可能会涉及到一些公共配置,可以通过分布式配置中心Zookeeper来解决。
架构瓶颈:不同应用之间存在共用的模块,由应用单独管理会导致相同代码存在多份,导致公共功能升级时全部应用代码都要跟着升级。
如用户管理、订单、支付、鉴权等功能在多个应用中都存在,那么可以把这些功能的代码单独抽取出来形成一个单独的服务来管理
这样的服务就是所谓的微服务,应用和服务之间通过HTTP、TCP或RPC请求等多种方式来访问公共服务,每个单独的服务都可以由单独的团队来管理。
此外,可以通过Dubbo、SpringCloud等框架实现服务治理、限流、熔断、降级等功能,提高服务的稳定性和可用性。
架构瓶颈:不同服务的接口访问方式不同,应用代码需要适配多种访问方式才能使用服务,此外,应用访问服务,服务之间也可能相互访问,调用链将会变得非常复杂,逻辑变得混乱。
通过ESB统一进行访问协议转换,应用统一通过ESB来访问后端服务,服务与服务之间也通过ESB来相互调用,以此降低系统的耦合程度。
这种单个应用拆分为多个应用,公共服务单独抽取出来来管理,并使用企业消息总线来解除服务之间耦合问题的架构,就是所谓的SOA(面向服务)架构,这种架构与微服务架构容易混淆,因为表现形式十分相似。
个人理解,微服务架构更多是指把系统里的公共服务抽取出来单独运维管理的思想,而SOA架构则是指一种拆分服务并使服务接口访问变得统一的架构思想,SOA架构中包含了微服务的思想。
架构瓶颈:业务不断发展,应用和服务都会不断变多,应用和服务的部署变得复杂,同一台服务器上部署多个服务还要解决运行环境冲突的问题
此外,对于如大促这类需要动态扩缩容的场景,需要水平扩展服务的性能,就需要在新增的服务上准备运行环境,部署服务等,运维将变得十分困难。
目前最流行的容器化技术是Docker,最流行的容器管理服务是Kubernetes(K8S),应用/服务可以打包为Docker镜像,通过K8S来动态分发和部署镜像。
Docker镜像可理解为一个能运行你的应用/服务的最小的操作系统,里面放着应用/服务的运行代码,运行环境根据实际的需要设置好。
把整个“操作系统”打包为一个镜像后,就可以分发到需要部署相关服务的机器上,直接启动Docker镜像就可以把服务起起来,使服务的部署和运维变得简单。
在大促的之前,可以在现有的机器集群上划分出服务器来启动Docker镜像,增强服务的性能
大促过后就可以关闭镜像,对机器上的其他服务不造成影响(在第18节之前,服务运行在新增机器上需要修改系统配置来适配服务,这会导致机器上其他服务需要的运行环境被破坏)。
架构瓶颈:使用容器化技术后服务动态扩缩容问题得以解决,但是机器还是需要公司自身来管理,在非大促的时候,还是需要闲置着大量的机器资源来应对大促,机器自身成本和运维成本都极高,资源利用率低。
系统可部署到公有云上,利用公有云的海量机器资源,解决动态硬件资源的问题
在大促的时间段里,在云平台中临时申请更多的资源,结合Docker和K8S来快速部署服务,在大促结束后释放资源,真正做到按需付费,资源利用率大大提高,同时大大降低了运维成本。
所谓的云平台,就是把海量机器资源,通过统一的资源管理,抽象为一个资源整体
在云平台上可按需动态申请硬件资源(如CPU、内存、网络等),并且之上提供通用的操作系统,提供常用的技术组件(如Hadoop技术栈,MPP数据库等)供用户使用,甚至提供开发好的应用
用户不需要关心应用内部使用了什么技术,就能够解决需求(如音视频转码服务、邮件服务、个人博客等)。
在云平台中会涉及如下几个概念:
IaaS:基础设施即服务。对应于上面所说的机器资源统一为资源整体,可动态申请硬件资源的层面;
PaaS:平台即服务。对应于上面所说的提供常用的技术组件方便系统的开发和维护;
SaaS:软件即服务。对应于上面所说的提供开发好的应用或服务,按功能或性能要求付费。
至此:以上所提到的从高并发访问问题,到服务的架构和系统实施的层面都有了各自的解决方案。
但同时也应该意识到,在上面的介绍中,其实是有意忽略了诸如跨机房数据同步、分布式事务实现等等的实际问题,这些问题以后有机会再拿出来单独讨论。
不是的,以上所说的架构演变顺序只是针对某个侧面进行单独的改进
在实际场景中,可能同一时间会有几个问题需要解决,或者可能先达到瓶颈的是另外的方面,这时候就应该按照实际问题实际解决。
如在政府类的并发量可能不大,但业务可能很丰富的场景,高并发就不是重点解决的问题,此时优先需要的可能会是丰富需求的解决方案。
对于单次实施并且性能指标明确的系统,架构设计到能够支持系统的性能指标要求就足够了,但要留有扩展架构的接口以便不备之需。
对于不断发展的系统,如电商平台,应设计到能满足下一阶段用户量和性能指标要求的程度,并根据业务的增长不断的迭代升级架构,以支持更高的并发和更丰富的业务。
所谓的“大数据”其实是海量数据采集清洗转换、数据存储、数据分析、数据服务等场景解决方案的一个统称,在每一个场景都包含了多种可选的技术
如数据采集有Flume、Sqoop、Kettle等,数据存储有分布式文件系统HDFS、FastDFS,NoSQL数据库HBase、MongoDB等,数据分析有Spark技术栈、机器学习算法等。
总的来说大数据架构就是根据业务的需求,整合各种大数据组件组合而成的架构,一般会提供分布式存储、分布式计算、多维分析、数据仓库、机器学习算法等能力。
而服务端架构更多指的是应用组织层面的架构,底层能力往往是由大数据架构来提供。
- N+1设计:系统中的每个组件都应做到没有单点故障;- 回滚设计:确保系统可以向前兼容,在系统升级时应能有办法回滚版本;- 禁用设计:应该提供控制具体功能是否可用的配置,在系统出现故障时能够快速下线功能;- 监控设计:在设计阶段就要考虑监控的手段;- 多活数据中心设计:若系统需要极高的高可用,应考虑在多地实施数据中心进行多活,至少在一个机房断电的情况下系统依然可用;- 采用成熟的技术:刚开发的或开源的技术往往存在很多隐藏的bug,出了问题没有商业支持可能会是一个灾难;- 资源隔离设计:应避免单一业务占用全部资源;- 架构应能水平扩展:系统只有做到能水平扩展,才能有效避免瓶颈问题;- 非核心则购买:非核心功能若需要占用大量的研发资源才能解决,则考虑购买成熟的产品;- 使用商用硬件:商用硬件能有效降低硬件故障的机率;- 快速迭代:系统应该快速开发小功能模块,尽快上线进行验证,早日发现问题大大降低系统交付的风险;- 无状态设计:服务接口应该做成无状态的,当前接口的访问不依赖于接口上次访问的状态。
]]>对应的RabbitMQ操作
guest用户设置密码为 guest
设置VHost权限,添加guest用户权限
设置后如下
回到User界面
我们使用 frexport 虚拟主机创建一个队列 hello
添加后
RabbitMQ 中的消息 不是直接发送到Queue中的,中间有一个Exchange 做消息分发。
producer甚至都不知道消息发送到哪个队列中去。因此,当Exchange收到message时,必须知道如何准备分发消息。
具体是append 到一定规则的queue,还是append到多个queue中,还是被丢弃?这些都是通过 exchange的类型定义的。
|type|作用|创建vhost时默认创建的exchange的名称|
|-|-|-|
|direct|路由模式|(Empty string) and amq.direct|
|fanout|发布/订阅模式|amq.fanout|
|Topic|主题模式|amq.topic|
|headers||amq.match (and amq.headers in RabbitMQ)|
它处理路由键。需要将一个队列绑定到交换机上,要求该消息与一个特定的路由键完全匹配。这是一个完整的匹配。如果一个队列绑定到该交换机上要求路由键 “dog”,则只有被标记为“dog”的消息才被转发,不会转发dog.puppy,也不会转发dog.guard,只会转发dog。
直接交换通常用于:
它不处理路由键。你只需要简单的将队列绑定到交换机上。一个发送到交换机的消息都会被转发到与该交换机绑定的所有队列上。很像子网广播,每台子网内的主机都获得了一份复制的消息。Fanout交换机转发消息是最快的。
如果N个队列绑定到 Fanout Exchange ,则当向该交换机发布新消息时,将向所有N个队列传递消息的副本。 Fanout Exchange 是广播消息路由的理想选择。
Fanout Exchange 向每个绑定到它的队列传递消息副本,适用场景如下:
它将路由键和某模式进行匹配。此时队列需要绑定要一个模式上。符号“#”匹配一个或多个词,符号“”匹配不多不少一个词。因此“audit.#”能够匹配到 “audit.irs.corporate”,但是“audit.” 只会匹配到“audit.irs”。我在RedHat的朋友做了一张不错的图,来表明topic交换机是如何工作的:
每当问题涉及多个消费者/应用程序,它们有选择地选择它们想要接收哪种类型的消息时,应该考虑使用 Topic Exchange 。
示例用途:
A headers exchange is designed to for routing on multiple attributes that are more easily expressed as message headers than a routing key. Headers exchanges ignore the routing key attribute. Instead, the attributes used for routing are taken from the headers attribute. A message is considered matching if the value of the header equals the value specified upon binding.
It is possible to bind a queue to a headers exchange using more than one header for matching. In this case, the broker needs one more piece of information from the application developer, namely, should it consider messages with any of the headers matching, or all of them? This is what the “x-match” binding argument is for. When the “x-match” argument is set to “any”, just one matching header value is sufficient. Alternatively, setting “x-match” to “all” mandates that all the values must match.
Headers exchanges can be looked upon as “direct exchanges on steroids”. Because they route based on header values, they can be used as direct exchanges where the routing key does not have to be a string; it could be an integer or a hash (dictionary) for example.
不处理路由键。而是根据发送的消息内容中的headers属性进行匹配。在绑定Queue与Exchange时指定一组键值对;当消息发送到RabbitMQ时会取到该消息的headers与Exchange绑定时指定的键值对进行匹配;如果完全匹配则消息会路由到该队列,否则不会路由到该队列。headers属性是一个键值对,可以是Hashtable,键值对的值可以是任何类型。而fanout,direct,topic 的路由键都需要要字符串形式的。
匹配规则x-match有下列两种类型:
x-match = all :表示所有的键值对都匹配才能接受到消息
x-match = any :表示只要有键值对匹配就能接受到消息
它是一种特别的exchange,当你手动创建一个队列时,后台会自动将这个队列绑定到一个名称为空的Direct 类型交换机上,绑定路由名称与队列名称相同。有了这个默认的交换机和绑定,我们就可以像其他轻量级的队列,如Redis那样,直接操作队列来处理消息。不过只是看起来是,实际上在RabbitMQ里直接操作是不可能的。消息始终都是先发送到交换机,由交换级经过路由传送给队列,消费者再从队列中获取消息的。不过由于这个默认交换机和路由的关系,使我们只关心队列这一层即可,这个比较适合做一些简单的应用,毕竟没有发挥RabbitMQ的最大功能,如果都用这种方式去使用的话就真是杀鸡用宰牛刀了。
docker需要先装好
使用 docker-compose
1 | [root@localhost ~]# mkdir rabbitmq |
docker-compose.yml 内容
1 | version: '2' |
启动 docker 容器1
docker-compose up -d
访问15672端口出现下面界面代表RabbitMQ安装成功
账号密码为
1 | RABBITMQ_DEFAULT_USER: "admin" |
使用任务队列的优点之一是可以轻易的进行一步工作。
如果我们现在积压了很多工作,可以通过增加消费者来解决这个问题,使得系统伸缩性更加容易
发布者 RabbitMQ 发送几条消息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
57using RabbitMQ.Client;
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading;
namespace WorkMQProduct
{
class NewTask
{
static string QueueName = "task_queue";
static void Main(string[] args)
{
var factory = new ConnectionFactory()
{
HostName = "localhost",
VirtualHost = "frexport",
UserName = "guest",
Password = "guest"
};
List<int> taskMessages = new List<int> { 2, 7, 2, 6, 5, 2, 2, 3 };
using (var connection = factory.CreateConnection())
using (var channel = connection.CreateModel())
{
channel.QueueDeclare(queue: QueueName,
durable: true,
exclusive: false,
autoDelete: false,
arguments: null);
var properties = channel.CreateBasicProperties();
properties.SetPersistent(true);
for (int i = 0; i < 8; i++)
{
string message = taskMessages[i] + "";
var body = Encoding.UTF8.GetBytes(message);
properties.CorrelationId = i + "";
channel.BasicPublish(exchange: "",
routingKey: QueueName,
basicProperties: properties,
body: body);
Console.WriteLine(" [x] {0} Sent {1}", properties.CorrelationId, message);
}
}
Console.WriteLine(" Press [enter] to exit.");
Console.ReadLine();
}
}
}
<!--more-->
消费者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
58using RabbitMQ.Client;
using RabbitMQ.Client.Events;
using System;
using System.Threading;
namespace WorkMQConsumer
{
class Worker
{
static string QueueName = "task_queue";
static void Main(string[] args)
{
var factory = new ConnectionFactory() { };
factory.HostName = "localhost";
factory.VirtualHost = "frexport";
factory.UserName = "guest";
factory.Password = "guest";
using (var connection = factory.CreateConnection())
{
using (var channel = connection.CreateModel())
{
channel.QueueDeclare(queue: QueueName,
durable: true,
exclusive: false,
autoDelete: false,
arguments: null);
Console.WriteLine(" [*] Waiting for message.");
var consumer = new EventingBasicConsumer(channel);
consumer.Received += (sender, ea) =>
{
var body = ea.Body;
var message = System.Text.Encoding.UTF8.GetString(body);
int x = int.Parse(message);
Console.WriteLine(" [x] Task {0} Receive {1} {2}", ea.BasicProperties.CorrelationId, message, DateTime.Now);
Thread.Sleep(1000*x);
Console.WriteLine(" [x] Done! at {0}", DateTime.Now);
channel.BasicAck(deliveryTag: ea.DeliveryTag, multiple: false);
};
channel.BasicConsume(queue: QueueName,
noAck: false,
consumer: consumer);
Console.WriteLine(" Press [enter] to exit.");
Console.ReadLine();
}
}
}
}
}
先启动两个消费者1
2$ MDConsumer/bin/Debug/MDConsumer.exe
$ MDConsumer/bin/Debug/MDConsumer.exe
再启动一个生产者1
$ MDProduct/bin/Debug/MDProduct.exe
效果
这个地方其实,所有消息会很快传给消费者,虽然没有消息应答
从上述的结果中,我们可以得知,在默认情况下,RabbitMQ不会顾虑消息者处理消息的能力,即使其中有的消费者闲置有的消费者高负荷。RabbitMQ会逐个发送消息到在序列中的下一个消费者(而不考虑每个任务的时长等等,且是提前一次性分配,并非一个一个分配)。平均每个消费者获得相同数量的消息,这种方式分发消息机制称为Round-Robin(轮询)。
您可能已经注意到,任务分发仍然没有完全按照我们想要的那样。比如:现在有2个消费者,所有的奇数的消息都是繁忙的,而偶数则是轻松的。按照轮询的方式,奇数的任务交给了第一个消费者,所以一直在忙个不停。偶数的任务交给另一个消费者,则立即完成任务,然后闲得不行。而RabbitMQ则是不了解这些的。这是因为当消息进入队列,RabbitMQ就会分派消息。它不看消费者为应答的数目,只是盲目的将第n条消息发给第n个消费者。
公平分发,则是根据消费者的处理能力来进行分发处理的。这里主要是通过设置prefetchCount 参数来实现的。这样RabbitMQ就会使得每个Consumer在同一个时间点最多处理规定的数量级个数的Message。换句话说,在接收到该Consumer的ack前,它不会将新的Message分发给它。 比如prefetchCount=1,则在同一时间下,每个Consumer在同一个时间点最多处理1个Message,同时在收到Consumer的ack前,它不会将新的Message分发给它。
graph LR; P([Product]); mq([Message Quene]) C1([Consumer1]); C2([Consumer2]); style P fill:#0ff,stroke:#333; style mq fill:#f00,stroke:#333; style C1 fill:#3cf,stroke:#333; style C2 fill:#3cf,stroke:#333; P-->mq; mq-->|prefetch=1|C1; mq-->|prefetch=1|C2;
修改工作线程1
channel.BasicQos(prefetchCount: 1, prefetchSize: 0, global: false);
注:如果所有的工作者都处于繁忙状态,你的队列有可能被填充满。你可能会观察队列的使用情况,然后增加工作者,或者使用别的什么策略。
还有一点需要注意,使用公平分发,必须关闭自动应答,改为手动应答。
效果
消息每次只会发送一条给消费者,只有消费者处理完成后,才会分发新的消息
按照定义,使用消息传递代理(RabbitMQ)的系统是分布式的。由于不能保证发送的消息可以到达对方或者被其成功处理,因此发布者和消费者都需要一种机制来进行传递和处理确认。
从消费者到RabbitMQ的消息确认被称为消息传递协议的确认
对发布者的去人称为发布者确认。两种功能都基于相同的思想,启发于TCP.
这对于 发布者到RabbitMQ,RabbitMQ到消费者的可靠交付都是必不可少的。 他们对于数据安全至关重要。
RabbitMQ 将消息传递给使用者的时候,需要知道何时消息被处理成功。具体逻辑取决于系统。因此这个是应用程序的决策.
确认消息,重要的是如何识别确认的消息。
注册 消费者后(订阅),RabbitMQ使用 basic.deliver 方法推送消息。该方法带有传递标签,该标签唯一标识通道上的传递。因此交付标签按通道划分范围
交付标签是单调的正整数,并有客户端库标识,确认交付的客户端库方法将交付标签作为参数
由于传递
自动确认模式:
消息视为发送后立即成功传递。这种模式需要权衡更高的吞吐量(只要消费者可以跟上),以降低交付和消费者处理的安全性。此模式通常称为“一劳永逸”。和手动确认模式不同,如果在成功传递前关闭了TCP连接或者通道,则服务器发送的消息将丢失。因此,自动消息确认应该被认为是不安全的,并且不适合所有有负载的工作。
处理网络流量考虑
]]>