熟悉FPGA硬件和实时软件开发的团队,一般对消息队列等中间件不是很熟悉。在以前的各类应用中,主要只涉及FPGA/DSP和上位机之间通信,团队积累了不少“点到点”高速吞吐+双向水位控制代码。因此,即使遇到PC之间的交互,也习惯使用自定义的TCP、UDP协议搞定一切。

但此单合同涉及的后续处理流程十分复杂,需要涉及多个独立的可伸缩节点,以及适应未来部署后用户的二次扩展应用。为了避免用户接触过多的底层协议,并降低伸缩节点带来的配置文件修改维护耦合度, 我们新进的软件部负责人建议公司尝试用消息队列取代传统的UDP/TCP协议。经过初步测试,我们认为以Kafka为代表的具备持久化和按需保序功能的消息队列,可取代私有TCP协议完成FPGA输出数据的灵活流转。

根据甲方的要求,所有的生产环境服务器均在windows Server2012下运行,这为项目的推进造成了意想不到的困扰。尤其是Kafka与windows环境、Visual C++、Qt环境的结合,一度牵制了项目的进度。经过不断尝试,工程师们最终找到了在windows C++环境中引入Kafka最为简便的方法。参考代码仓库
https://gitcode.net/coloreaglestdio/qtcpp_demo/-/tree/master/kafka/rdkafka_qt

本文不再探讨消息队列本身,以及Kafka的知识。相关知识建议参考朱忠华先生的著作《深入理解Kafka:核心设计与实践原理》,从简单的搭建到内部原理介绍的比较详细。

1. Kafka在Windows下更名失败崩溃问题

目前,windows并不是Kafka推荐的生产环境。尽管通过官网获取的kafka_2.13-3.1.0版本,在windows下有成套的批处理脚本,但存在严重问题。主要是Topic 手工删除、过期删除会导致进程抛出异常,导致服务崩溃。我们采用Always Up,或者编写批处理文件,进行重启。节点崩溃主要因为改名行为与windows的文件系统不契合,导致zookeeper、kafka文件夹下一些状态文件无法更名。

1.1 临时方法:自动重启

通过重启,可解决此问题。
启动Zookeeper的脚本:

cd /d %~dp0
:LOOP
CALL bin\windows\zookeeper-server-start.bat  config\zookeeper.properties
GOTO :LOOP

启动Kafka的脚本:

cd /d %~dp0
:LOOP
CALL bin\windows\kafka-server-start.bat  config\server.properties
GOTO :LOOP

如此处理后,各个节点即使崩溃,经过几次重启,可以恢复工作。但这种方法带来的坏处是每次清理一周以前的数据时,实时进程都会暂时被打断半分钟左右,非常不优雅。尤其是在大数据流量情况下,1GB文件的时间跨度很小,导致重启不可接受。

1.2 使用虚拟化技术

项目通过Oracle VirtualBox + Centos的方式,解决了该问题。使用VirtualBox 虚拟机,比Docker更为灵活。工作在16TB SSD 阵列上的虚拟机,宿主有64GB内存加持,根本不在乎OS的开销。与Docker相比,VM的重量级虚拟化维护更加傻瓜化,甲方经过稍微培训,即可完成操作。

需要注意的是,网络最好使用桥接。桥接保证了zookeeper和kafka的端口可达性。同时,在一台物理计算机上,不同盘阵中运行多个Kafka隔离器(broker实例), 在降低集群成本的同时,起到一定的备份容灾的作用。

2. 在MSYS2下取得librdkafka库

C++环境使用Kafka,一般用librdkafka。但我们决定只使用C接口。鉴于不同的团队(甲方后续会二次开发)可能使用的编译器不同, C++接口的库要为所有的可能编译器准备特定的二进制版本,维护性很差。要在VC2010,VC2013,VC2017,VC2019、 Mingw32, Mingw64,LLVM,Intel C++等编译器下进行适配,只有C接口是可行的。

librdkafka在windows下,编译也有点讨厌。有没有编译好的库,一股脑解决所有编译器的适配问题呢?答案是肯定的,当然就是 MSYS2环境了。

2.1 获取librdkafka

在MSYS2下,执行

$pacman -S mingw32/mingw-w64-i686-librdkafka mingw64/mingw-w64-x86_64-librdkafka

即可获得二进制库、头文件和动态链接库。

2.2 提取librdkafka文件

使用ldd查看DLL的依赖:

$ ldd librdkafka.dll | grep "mingw64"
        libzstd.dll => /mingw64/bin/libzstd.dll (0x7fff99520000)
        libgcc_s_seh-1.dll => /mingw64/bin/libgcc_s_seh-1.dll (0x7fffc71d0000)
        liblz4.dll => /mingw64/bin/liblz4.dll (0x7fffc7490000)
        libcrypto-1_1-x64.dll => /mingw64/bin/libcrypto-1_1-x64.dll (0x7fffac400000)
        libwinpthread-1.dll => /mingw64/bin/libwinpthread-1.dll (0x7fffc1f80000)
        zlib1.dll => /mingw64/bin/zlib1.dll (0x7fffbb690000)
        libssl-1_1-x64.dll => /mingw64/bin/libssl-1_1-x64.dll (0x7fffc5230000)

拷贝文件到文件夹librdkafka, 文件夹结构:

D:\librdkafka
├─bin32
│      libcrypto-1_1.dll
│      libgcc_s_dw2-1.dll
│      liblz4.dll
│      librdkafka.dll
│      libssl-1_1.dll
│      libwinpthread-1.dll
│      libzstd.dll
│      zlib1.dll
│
├─bin64
│      libcrypto-1_1-x64.dll
│      libgcc_s_seh-1.dll
│      liblz4.dll
│      librdkafka.dll
│      libssl-1_1-x64.dll
│      libwinpthread-1.dll
│      libzstd.dll
│      zlib1.dll
│
├─include
│  └─librdkafka
│          rdkafka.h
│          rdkafka_mock.h
│
├─lib32
│      librdkafka.dll.a
│
└─lib64
        librdkafka.dll.a

3. 应用

一旦得到了完整的库文件,即可在多个编译器下使用了。要注意的是,使用C接口,Visual C++也可以直接使用.a 格式的LIB,不需要额外的操作变成.lib

kafkaui面板参数 kafka图形界面_MSYS2

3.1 使用C++从examples直接封装C接口

尽管使用C接口的librdkafka库,我们还是希望避免冗长的初始化和状态维护,做一些简单的封装。Kafka的官方produce.c, consumer.c是非常直观的例子,我们从这两个文件开始,进行封装。

(1)生产者

namespace KafkaClient {
	class kafka_producer
	{
	public:
		explicit kafka_producer(
				const std::string & brokers,
				const std::string & topic);
		virtual ~kafka_producer();
	public:
		bool write(
				const char * data,
				const int len,
				const int maxTry = 10,
				const char * key = nullptr,
				const int keylen = 0);
	protected:
		bool init();
		bool exit();
	protected:
		rd_kafka_t *rk = nullptr;       /* Producer instance handle */
		std::string m_topic;
		std::string m_brokers;
	};

(2)消费者

namespace KafkaClient {
	class kafka_consumer{
	public:
		explicit kafka_consumer(
				const std::string & brokers,
				const std::vector<std::string> topics,
				const std::string & group);
		virtual ~kafka_consumer();
	protected:
		bool init();
		bool exit();
	public:
		bool stop();
		bool run(std::function<void (rd_kafka_message_t *)> cb );
	protected:
		rd_kafka_t *rk = nullptr;       /* Producer instance handle */
		std::atomic<bool> m_stop;
		std::vector<std::string> m_topics;
		std::string m_brokers;
		std::string m_group;
	};
}

注意的是,我们希望用户在一个独立的线程里进行消费,因此设置了run()函数传入一个回调。

3.2 为图形界面适配printf

官方例子里,大量使用stderr作为状态输出。这对于控制台程序没有问题,对GUI程序就讨厌了。但通过可变参数回调,可以直接替换printf,官方的控制台例子妥妥变成GUI:

//Default printf Callback
	void csprintf (const char * format,...)
	{
		va_list args;
		va_start(args, format);
		vfprintf(stderr,format, args);
		va_end(args);
	}
		
	//GUI printf
	void uiprintf (const char * format,...)
	{
		va_list args;
		va_start(args, format);
		if (instance)
		{
			char buf[1024];
			vsnprintf(buf,1024,format,args);
			buf[1023] = 0;
			GUI()->shootMsg(QString(buf));
		}
		else
			vfprintf(stderr,format, args);
		va_end(args);
	}
	//选择Console 或者GUI
	void (*cbprintf) (const char *,...) = csprintf;

在处理GUI消息时,牵扯到多线程问题。我们对多线程进行了优化,详细情况参考代码库。

3.3 在独立的线程消费

在现代C++下,直接初始化线程进行消费. C++现代标准中的Lambada表达式捕获,显著降低了多线程编程的代码量。

consumer = new kafka_consumer(
					ui->lineEdit_brokers->text().toStdString(),
					topics,
					ui->lineEdit_group->text().toStdString());

		m_runthread = new std::thread([&]()->void{
			consumer->run([&](rd_kafka_message_t * rkm)->void{
				if (rkm)
				{
					/* Proper message. */
					cbprintf("Message on %s [%" PRId32 "] at offset %" PRId64 ":\n",
						   rd_kafka_topic_name(rkm->rkt), rkm->partition,
						   rkm->offset);

					/* Print the message key. */
					if (rkm->key )
						cbprintf(" Key: %.*s\n", (int)rkm->key_len,
							   (const char *)rkm->key);
					/* Print the message value/payload. */
					if (rkm->payload)
						cbprintf(" Value: (%d bytes)\n", (int)rkm->len);
				}
			});
		});

4 范例工程

参考代码仓库

https://gitcode.net/coloreaglestdio/qtcpp_demo/-/tree/master/kafka/rdkafka_qt

kafkaui面板参数 kafka图形界面_kafkaui面板参数_02

该仓库中的范例在 Manjaro 和 Win10 MSYS2 / VC2022下编译通过。