基于 RabbitMQ 实现跨语言的消息调度

微服务的盛行,使我们由原来的单机”巨服务“的项目拆分成了不同的业务相对独立的模块,以及与业务不相关的中间件模块。这样我们免不了在公司不同的模块项目使用不同的团队,而各自的团队所擅长的开发语言也会不一致(当然,我想大多数都是统一了语言体系)。但是在微服务体系下,使用各自语言的优势开发对应的模块是最合适也是合理的诉求。

现在以消息中间件为例子,我们用 rabbitmq 将 .NET 和 Golang 连接起来。

前提

RabbitMQ 的准备工作这里省略,用 docker 可以很快的搭建出来,详情请移步谷歌。这里我也给一个我查资料的记录:Docker 安装运行 Rabbitmq

.NET

关于 .NET 的 RabbitMQ 的消息中间件组件我们使用 EasyNetQ 对消息进行管理调度。我们以新建一个 MQ.EasyNetQ.Producer api 项目。我们根据 EasyNetQ 官方文档的 Quick-Start 的例子在 Program.cs 新建一个 RabbitMQ 连接并推送消息:

using (var bus = RabbitHutch.CreateBus("host=localhost:5672;username=guest;password=guest"))
{
var input = "";
Console.WriteLine("Enter a message. 'Quit' to quit.");
while ((input = Console.ReadLine()) != "Quit")
{
bus.Publish(new TextMessage
{
Text = input
});
}
}

然后新建一个消费端项目 MQ.EasyNETQ.Customer,继续在 Program.cs 建立与 RabbitMQ 的连接并开启订阅:

using (var bus = RabbitHutch.CreateBus("host=localhost:5672;username=guest;password=guest"))
{
bus.PubSub.Subscribe<TextMessage>("test", HandleTextMessage);
} static void HandleTextMessage(TextMessage textMessage)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine("Got message: {0}", textMessage.Text);
Console.ResetColor();
}

运行发现没有问题。

需要注意一下,安装成功之后 RabbitMQ 自带消息重试,以及持久化的错误消息队列,以便后续的消息恢复。具体详见 RabbitMQ 的官方文档

ok,.NET 这块对 RabbitMQ 消息的调度管理初步成功。接下来我们尝试用 Go

Go

Go 下的 RabbitMQ 组件我们用官方推荐的 amqp 库。同样我们新建一个生产者在 src/producer 文件夹下的 producer.go 下。

由于本身 go 的一些限制还有为了方便起见,我把两个项目放在同一个目录下以不同的文件夹命名来区分。

同样我们根据资料以及官方示例 demo 很容易入门在 main 函数写下如下代码片段:

conn, err := amqp.Dial("amqp://guest:guest@localhost:5672")
failOnError(err, "RabbitMQ 连接失败!")
defer conn.Close() ch, err := conn.Channel()
failOnError(err, "打开通信通道失败!")
defer ch.Close() // 申明队列
queue, err := declareQueue(ch)
failOnError(err, "队列申明失败")
// 申明交换机
declareExchange(ch)
// 绑定交换机
err = ch.QueueBind(queue.Name, queue.Name, "MQ.Shared.Messages.CreateUserMessage, MQ.Shared", false, nil)
failOnError(err, "绑定队列失败")
// 发送消息
err = publish(ch, queue, &src.CreateUserMessage{"marsonshine", 27, true, "marson@163.com", time.Now()})
failOnError(err, "发送消息失败")

如何申明交换机和队列以及绑定操作我这里就省略了,然后是发送消息函数

func publish(ch *amqp.Channel, queue amqp.Queue, body interface{}) error {
var network bytes.Buffer
gob.Register(src.CreateUserMessage{})
enc := gob.NewEncoder(&network)
err := enc.Encode(body)
if err != nil {
return err
} err = ch.Publish(
"",
queue.Name,
false,
false,
amqp.Publishing{
ContentType: "application/json",
Body: network.Bytes(),
})
log.Printf("[x] 发送消息 %s", body)
return err
}

这里我用的高性能的序列化插件 encoding/gob,这里就是我后面与 .NET 交互时候遇到的问题,后续在说明。

借来是消费端,代码路径在 src/customer/customer.go

conn, err := amqp.Dial("amqp://guest:guest@localhost:5672")
... 省略
ch, err := conn.Channel()
declareQueue(ch)
// 消费队列信息
err = consumer(ch, queue)
failOnError(err, "接受消息失败")

消费消息代码如下:

func consumer(ch *amqp.Channel, queue amqp.Queue) error {
msgs, err := ch.Consume(queue.Name, "", true, false, false, false, nil)
failOnError(err, "消费者注册失败")
forever := make(chan bool)
go func() {
for d := range msgs {
buf := bytes.NewBuffer(d.Body)
dec := gob.NewDecoder(buf)
var user = src.CreateUserMessage{}
err := dec.Decode(&user)
if err != nil {
log.Printf("接受消息失败: %s", err.Error())
} else {
log.Printf("Received a message: %v", user)
}
}
}()
log.Printf(" [*] Waiting for messages. To exit press CTRL+C")
<-forever return err
}

运行项目发现也没有问题。

在使用两边各自的 RabbitMQ 客户端组件没有问题之后,我们开始考虑处理下一个核心问题:如何实现 Go 段服务发消息,应用端 .NET 如何消费。这理论上是很好解决的,因为 .Net 与 Golang 用的消息中间件都是 RabbitMQ,只要.Net 与 Golang 都实现了 RabbitMQ 的消息协议(比如 AMQP 协议)就能完成一方消息的推送,另一方消费的目的。

考虑这个问题并不是空穴来风,因为 Go 是用作处理底层平台 rpc 模块,除了底层平台级不同模块之间的通信外,各大应用端也要订阅平台的基础数据。

Go 推送消息,Net 消费及其出现的问题

到这一步的时候,出现问题了,登录 RabbitMQ 管理 UI 发现 Go 有正常发出消息,queue 以及 exchange 都是对应上的,在 .NET 的订阅方式就如上面写的代码一样。在 queue 中的消息在重试一段时间之后如果还是失败,EasyNetQ 会将无法正常消费的消息转到错误队列中去。并且可以查看发生具体的错误消息,结果发现都是报 ArgumentNullException:typeName is null 类型错误。奇怪的是我断点调试也进不来断点,说明 EasyNetQ 在消费消息的时候压根没有运行这段订阅代码:

using (var bus = RabbitHutch.CreateBus("host=localhost:5672;username=guest;password=guest"))
{
bus.PubSub.Subscribe<CreateUserMessage>("test", HandleCreateUserMessage);
}
static void HandleCreateUserMessage(CreateUserMessage message) {
Logger.LogInformation($"接收消息:{JsonSerializer.Serialize(message)} 时间:{DateTimeOffset.Now}");
}

后来也去翻 EasyNetQ 源码,得知是因为还没到我写的这个订阅阶段的代码,而是在这段订阅代码 IDisposable Consume(IQueue queue, MessageHandler onMessage, Action<IConsumerConfiguration> configure)。这里面有个核心的参数就是 onMessage,从建立连接到消费具体队列的消息,这个参数是一直传递下去的。EasyNetQ 会根据初始化与 RabbitMQ 连接的参数来创建消费,比如建立队列时传递 isExclusive = true 就会创建一个瞬时消费者,只有当前连接能访问,并且关闭时会自动删除。EasyNetQ 默认会初始化一个持久化消费者 PersistentConsumer,然后触发内部消费者构造一个 BasicConsumer 共给 RabbitMQ.Client 调用触发方法 HandleBasicDeliver,由 RabbitMQ.Client 调用传递所需要的参数,而报的错误也是在这里,因为从 Go 发出的消息,.NET 接收无法解析到对应的元数据信息,所以获取的 IBasicProperties 对象是空的,由此触发了参数检查造成报错。

我们把消费端改成这样就能发现 content 能正常接收

bus.Advanced.Consume(queue, (body, properties, info) =>
{
string content = Encoding.UTF8.GetString(body);
var userMessage = System.Text.Json.JsonSerializer.Deserialize<CreateUserMessage>(body);
Logger.LogInformation($"接收消息:{System.Text.Json.JsonSerializer.Serialize(userMessage)} 时间:{DateTimeOffset.Now}");
});

断点能进来了,就能继续往下进行了,随后就会又碰到序列化失败的问题,因为 content 接收的内容是乱码的,跨语言之间经常出现的问题就是编码,所以我把目光又瞄向了 Go,现在我们再来看下 Go 的发消息的那段代码:

var network bytes.Buffer
gob.Register(src.CreateUserMessage{})
enc := gob.NewEncoder(&network)
err := enc.Encode(body)
...
err = ch.Publish(
"",
queue.Name,
false,
false,
amqp.Publishing{
ContentType: "application/json",
Body: network.Bytes(),
})
...

Go 编码库 encoding/gob

我首先在网上查资料发现 gob 这个库编码是用的 gbk 编码,实则不然,翻看源码就知道是用的 utf-8,并且也查明 gob 这个库是不能指定编码格式的。无论我是改 ContentType 的类型,在 .Net 消费端依旧无法正常接收。难道只能用 json 序列化传递消息?为了弄明白这个,我开始查阅这个 gob 库是否支持跨语言,也就是说 gob 这个库是否实现了外界公共协议。最后在官网博客下查到了,encoding/gob 只适用于 Go 语言环境,所以在性能方面非常突出。在这里我贴出博客中的一小段原话,引自 https://blog.golang.org/gob

First, and most obvious, it had to be very easy to use. First, because Go has reflection, there is no need for a separate interface definition language or "protocol compiler". The data structure itself is all the package should need to figure out how to encode and decode it. On the other hand, this approach means that gobs will never work as well with other languages, but that's OK: gobs are unashamedly Go-centric.

既然不支持跨语言,那就心安理得的用 json 了,如果用不了 gob,想追求高性能的化,那么其实还可以用 protobuf 协议或是其它二进制协议来序列化,核心就是双方语言协议格式统一即可。现在的 publish 函数如下

func publish(ch *amqp.Channel, queue amqp.Queue, body interface{}) error {
buffer, err := json.Marshal(body)
if err != nil {
return err
}
err = ch.Publish(
"",
queue.Name,
false,
false,
amqp.Publishing{
ContentType: "applicaton/json",
Body: buffer,
})
log.Printf("[x] 发送消息 %s", body)
return err
}

这样 .NET 消费端就能成功接收消息了。

封装 EasyNetQ 与最佳实践

从前面的使用来看,我们把业务处理都放在 Program 明显是不合适的,这里应该只关心模块,与业务无关的。

幸好 EasyNetQ 考虑到了这点,提供了自动订阅机制。虽然官网只给出了 Windsor 的例子,但是也很容易就能做到类似下面的封装代码

// EasyNetRabbitMQICollectionExtensions.cs
public static RabbitMQEasyNetBuilder EasyNetRabbitMQBuilder(this IServiceCollection services, IConfiguration configuration)
{
string username = configuration["RabbitMQ:UserName"];
string password = configuration["RabbitMQ:Password"];
var connectionString = (ConnectionString)$"host={configuration["RabbitMQ:Server"]},{configuration["RabbitMQ:Server"]}:5673;username={username};password={password}";
// publisherConfirms = true 为开启推送消息确认,建议开启,性能刚高
// 因为不加上则当 rabbitmq 不可用时,发送消息会系统错误,而开启发送确认则不会,更具有伸缩性
connectionString.Append("publisherConfirms=true"); var bus = RabbitHutch.CreateBus(connectionString);
services.AddSingleton(bus);
return new RabbitMQEasyNetBuilder(services);
}

然后开启自动订阅:

// RabbitMQEasyNetBuilder.cs
public void UseAutoSubscriber(string subscriptionIdPrefix)
{
_services.AddSingleton<MessageDispatcher>();
_services.AddSingleton<AutoSubscriber>(provider =>
{
var subscriber = new AutoSubscriber(provider.GetRequiredService<IBus>(), subscriptionIdPrefix)
{
AutoSubscriberMessageDispatcher = provider.GetRequiredService<MessageDispatcher>()
};
return subscriber;
});
}

这里注入的 MessageDispatcher 类跟 WindsorMessageDispatcher 差不多,依葫芦画瓢。

最后在提供 Configure 触发自动订阅:

// IApplicationBuilderExtensions.cs
public static void UseAutoSubscriber(this IApplicationBuilder app,Assembly[] assemblies)
{
var subscriber = app.ApplicationServices.GetService<AutoSubscriber>();
subscriber.Subscribe(assemblies);
...
}

这样我们就可以直接定义 IConsumer<Message> 的处理程序类即可,完全解耦了业务:

public class UserMessageHandler : IConsumeAsync<CreateUserMessage>
{
private readonly ILoggerFactory _loggerFactory;
public UserMessageHandler(ILoggerFactory loggerFactory)
{
_loggerFactory = loggerFactory;
}
public ILogger Logger => _loggerFactory.CreateLogger<UserMessageHandler>();
[ForTopic(Consts.Topic.User)]
public async Task ConsumeAsync(CreateUserMessage message, CancellationToken cancellationToken = default)
{
Logger.LogInformation($"接收消息:{JsonSerializer.Serialize(message)} 时间:{DateTimeOffset.Now}");
//throw new NotSupportedException();
await Task.Yield();
}
}

还没结束,除了这种推送订阅方式,EasyNetQ 还提供了 Request/Response,RPC 模式。本质上还是通过 exchange 对 queue 进行消息调度。只是 EasyNetQ 内部做了很多工作,以至于让我们使用非常方便。那么针对这种模式也是可以做到完全解耦的,重点来了,这个是官网没有的姿势啊,且看下面代码

public interface IResponder
{
void Subscribe();
}
public abstract class ResponderBase : IResponder
{
private readonly IBus _bus;
private ILogger _logger; public IBus Bus => _bus; public ILogger Logger
{
get { return _logger ??= NullLogger.Instance; }
set { _logger = value; }
} protected ResponderBase(IBus bus)
{
_bus = bus;
} public abstract void Subscribe();
}

先建立一个规约 IResponder,并给一个基类实现。然后在拓展方法 IApplicationBuilderExtensions.UseAutoSubscriber 中加入如 AutoSubscriber 机制的代码即可,完整的方法如下:

public static void UseAutoSubscriber(this IApplicationBuilder app,Assembly[] assemblies)
{
var subscriber = app.ApplicationServices.GetService<AutoSubscriber>();
subscriber.Subscribe(assemblies); var requests = app.ApplicationServices.GetServices<IResponder>();
foreach (var request in requests)
{
request.Subscribe();
} var advancedSubscribers = app.ApplicationServices.GetServices<IAdvancedSubscriber>();
foreach (var advanced in advancedSubscribers)
{
advanced.Subscribe();
}
}

这样 Request/Response 与 EasyNetQ 高级 API 都能与业务很好的解耦了。只需要定义各自的 MessageHandler 即可。

最后

总体来说虽然踩坑了(明确来说不是库的坑,而是对其不熟导致的),但是也如愿解决了问题点。在实施多语言交互时,一定要注意彼此之间的差异,要定义好规范协议,在解决基本的交互问题之后,就开始继续深入进行重构。虽然目前只是项目演示阶段,等项目真正执行下去肯定还会碰到更多问题,特别是 Go,才接触一星期,公司决定用 Go 作为底层核心 rpc 模块,我个人还是很担心的,因为我的 go 之道还有很有很长的路要走。

整个 mq 示例源码地址托管在 https://github.com/MS-Practice/mq

参考资料

基于 RabbitMQ-EasyNetQ 实现.NET与Go的消息调度交互的更多相关文章

  1. C#基于RabbitMQ实现客户端之间消息通讯实战演练

    一.背景介绍和描述 MQ消息队列已经逐渐成为企业IT系统内部通信的核心手段.它具有低耦合.可靠投递.广播.流量控制.最终一致性等一系列功能,成为异步RPC的主要手段之一.何时需要消息队列?当你需要使用 ...

  2. ASP.NET Core Web API下事件驱动型架构的实现(三):基于RabbitMQ的事件总线

    在上文中,我们讨论了事件处理器中对象生命周期的问题,在进入新的讨论之前,首先让我们总结一下,我们已经实现了哪些内容.下面的类图描述了我们已经实现的组件及其之间的关系,貌似系统已经变得越来越复杂了. 其 ...

  3. 微服务实战(三):落地微服务架构到直销系统(构建基于RabbitMq的消息总线)

    从前面文章可以看出,消息总线是EDA(事件驱动架构)与微服务架构的核心部件,没有消息总线,就无法很好的实现微服务之间的解耦与通讯.通常我们可以利用现有成熟的消息代理产品或云平台提供的消息服务来构建自己 ...

  4. NET Core2基于RabbitMQ对Web前端实现推送功能

    NET Core2基于RabbitMQ对Web前端实现推送功能 https://www.cnblogs.com/Andre/p/10012329.html 在我们很多的Web应用中会遇到需要从后端将指 ...

  5. 基于RabbitMQ的跨平台RPC框架

    RabbitMQRpc protocobuf RabbitMQ 实现RPC https://www.cnblogs.com/LiangSW/p/6216537.html 基于RabbitMQ的RPC ...

  6. 重温.NET下Assembly的加载过程 ASP.NET Core Web API下事件驱动型架构的实现(三):基于RabbitMQ的事件总线

    重温.NET下Assembly的加载过程   最近在工作中牵涉到了.NET下的一个古老的问题:Assembly的加载过程.虽然网上有很多文章介绍这部分内容,很多文章也是很久以前就已经出现了,但阅读之后 ...

  7. Go/Python/Erlang编程语言对比分析及示例 基于RabbitMQ.Client组件实现RabbitMQ可复用的 ConnectionPool(连接池) 封装一个基于NLog+NLog.Mongo的日志记录工具类LogUtil 分享基于MemoryCache(内存缓存)的缓存工具类,C# B/S 、C/S项目均可以使用!

    Go/Python/Erlang编程语言对比分析及示例   本文主要是介绍Go,从语言对比分析的角度切入.之所以选择与Python.Erlang对比,是因为做为高级语言,它们语言特性上有较大的相似性, ...

  8. SpringBoot | 第三十八章:基于RabbitMQ实现消息延迟队列方案

    前言 前段时间在编写通用的消息通知服务时,由于需要实现类似通知失败时,需要延后几分钟再次进行发送,进行多次尝试后,进入定时发送机制.此机制,在原先对接银联支付时,银联的异步通知也是类似的,在第一次通知 ...

  9. 一个基于RabbitMQ的可复用的事务消息方案

    前提 分布式事务是微服务实践中一个比较棘手的问题,在笔者所实施的微服务实践方案中,都采用了折中或者规避强一致性的方案.参考Ebay多年前提出的本地消息表方案,基于RabbitMQ和MySQL(JDBC ...

随机推荐

  1. Java学习的第三十天

    1.遇到打印文件使用打印流PrintStream 使用PrintStream写入数据 2.没有问题 3.明天学习用RandomAccessFile随机访问文件

  2. 正式班D23

    2020.11.05星期四 正式班D23 目录 12.3.3 HUP信号 12.3.3 HUP信号 在关闭终端时,终端会收到Linux HUP信号(hangup信号),关闭其所有子进程. 想让进程一直 ...

  3. mns: Money Never Sleeps! 自己开发的一款 IDEA 插件介绍.

    一边敲代码, 一边关注股票/基金行情, 还不怕同事盯到自己的屏幕! 对于一个关注股市跟基金的研发人员来说, 莫过于一天到晚写代码, 而不能及时的查看股市行情跟基金走势了吧. 写代码的时候比较容易忘记看 ...

  4. 动态规划——用二进制表示集合的状态压缩DP

    动态规划当中有非常常见的一个分支--状态压缩动态规划,很多人对于状态压缩畏惧如虎,但其实并没有那么难,希望这文章能带你们学到这个经典的应用. 二进制表示状态 在讲解多重背包问题的时候,我们曾经讲过二进 ...

  5. Redux学习day1

    01.React介绍 Redux是一个用来管理管理数据状态和UI状态的JavaScript应用工具.随着JavaScript单页应用(SPA)开发日趋复杂,JavaScript需要管理比任何时候都要多 ...

  6. linux磁盘已满,查看那个目录文件最占磁盘空间并解决没有内存不耗费资源删除

    df -Th查看磁盘空间占用情况 [root@IntelRC-Nginx-N023 ~]# df -Th Filesystem Type Size Used Avail Use% Mounted on ...

  7. vi&vim 基本使用方法

    vi/&vim 基本使用方法 本文介绍了vi (vim)的基本使用方法,但对于普通用户来说基本上够了!i/vim的区别简单点来说,它们都是多模式编辑器,不同的是vim 是vi的升级版本,它不仅 ...

  8. 数据采集与融合第四次作业:多线程以及scrapy框架的使用

    数据采集第四次作业:多线程以及scrapy框架的使用 任务一:单多线程的使用 单线程代码: from bs4 import BeautifulSoup from bs4 import UnicodeD ...

  9. 图解 Spring 循环依赖,写得太好了!

    Spring如何解决的循环依赖,是近两年流行起来的一道Java面试题. 其实笔者本人对这类框架源码题还是持一定的怀疑态度的. 如果笔者作为面试官,可能会问一些诸如"如果注入的属性为null, ...

  10. .NET 5 带来的新特性 [MemberNotNull] 与 [MemberNotNullWhen]

    MemberNotNullAttribute是 .NET 5 的新增特性,位于System.Diagnostics.CodeAnalysis.该特性用于显式声明,调用此方法后该值不再为 Null.示例 ...