在这篇文章中,我将解释发布/订阅(pub/sub)模式,以及你如何在.NET 6控制台应用程序中应用它来制作一个聊天应用程序。
你会学到

TLDR:这里是GitHub的Repo,有完成的项目。
随着基于云和分布式系统的兴起,消息传递解决方案似乎无处不在。与直接的客户-服务器通信相比,消息传递和事件驱动架构允许松散耦合的组件相互通信,从而使系统具有更好的可扩展性。
自1997年微软消息队列(MSMQ)被用来在应用程序之间可靠地传递消息以来,微软领域的消息传递解决方案就一直存在。但如今,人们使用基于云的消息传递组件,如Azure Service Bus、SignalR和Web PubSub等等。在这篇文章中,我将详细介绍另一个基于云的、高度可扩展的消息传递解决方案Ably,并使用它在.NET 6中构建一个基于控制台的聊天应用程序。
什么是pub/sub?
发布/订阅(pub/sub)模式是一种允许消息从一个实体("发布者 "或 "生产者")发送到其他实体("订阅者 "或 "消费者")的模式,它们之间没有直接联系。通信是通过使用一个消息代理来完成的,它从发布者那里接收消息,然后将它们分发给相关的订阅者。
为了确定每条消息应该发送给谁,使用了主题(也被称为渠道)的前提。这是一个代表通信集合的ID,发布者可以向其发布,而订阅者可以向其订阅。一个例子是有一个叫做 "体育 "的主题,发布者将发布体育更新,而订阅者将订阅上述体育更新。
发布者没有必要只做发布者,订阅者也没有必要只做订阅者。许多用例,如聊天应用程序,需要客户端既发布消息又订阅消息。主要的概念是,所有的通信都被发送到经纪商,由一个主题ID来识别,然后再发送给订阅了该主题的任何客户端。
了解更多关于发布和订阅的信息:

什么时候应该使用pub/sub?
当应用程序使用pub/sub模式时:
- 需要向多个客户端发送消息。
- 不需要客户端的直接(同步)响应。
典型的用例是:
- 聊天;每个用户在聊天频道中既是发布者又是订阅者。
- 位置跟踪;运输车辆的GPS数据被广播给正在等待交货的人。
- 事件通知;一个新闻机构发布了一条新闻,新闻应用的所有用户都会收到通知。
- 分布式缓存;客户端使用本地数据存储以达到最佳性能。这些本地数据存储通过发布者发送的变化事件进行更新。

pub/sub对你的.NET应用程序的好处
pub/sub的异步性增加了可扩展性、可靠性,并提高了发布者的响应速度。发布者可以快速地向一个主题发送消息,然后返回到它的其他职责。消息传递基础设施负责将消息传递给订阅者。
Pub/Sub消息传递能够独立管理发布者和订阅者系统,因为它们是解耦的。pub和sub应用程序可以独立开发,使用不同的语言、框架,甚至是通信协议。
pub/sub模式为你的应用程序提供了关注点的分离,实现了微服务架构。每个应用程序可以专注于其核心业务能力,而消息传递基础设施则处理可靠的消息路由到多个订阅者。
超越pub/sub
除了pub/sub可以带来的一般好处外,Ably还提供了一些功能,可以用来为你的应用程序提供更丰富的功能。 能够检索旧的消息,看到谁是当前活跃的,以及通过推送通知得到通知,都是这类功能的例子。
历史
对于某些用例,看到过去用户离线时发送的信息是很有好处的。以聊天为例,当用户上线时,应用程序不仅可以检索到当前的信息,还可以检索到一个频道的历史信息。
存在感
添加到pub/sub中的一个流行功能是检查谁在一个主题上存在的能力。虽然在许多情况下,发布者在发布前不需要关心谁订阅了,但有时知道这一点很有用。在聊天应用中,知道谁在线对用户来说很有用,可以确定某人是否可以交谈。
推送通知
人们普遍希望设备能收到更新和通知,即使他们的应用程序在后台操作或关闭。在后台,iOS和Android通常会将任何通信搁置,直到应用程序再次打开,只允许自己的推送通知互动。
正因为如此,能够在需要的地方发送通知是很重要的,将这种通信嵌入到你现有的消息系统中是有意义的。pub/sub可以完美地实现这一点,因为它将发布者和消费者分开。发布者可以以完全相同的方式发布消息,但订阅者可以向经纪人表明它想如何接收这些消息。
这可以进一步扩展,允许消息的发布者使用与订阅者完全不同的协议。一个发布者可以使用经纪商的REST端点来发布消息,然后你可以让一些订阅者使用MQTT,一些SSE和一些WebSockets来订阅。经纪人负责翻译并确保所有这些不同的系统和协议能够无缝交互。
使用Ably .NET SDK的Pub/Sub
在Ably,我们使用一个建立在WebSockets之上的pub/sub协议。为了证明它有多简单,下面是你如何用Ably创建一个客户端:
var ably = new AblyRealtime(settings.AblyApiKey);
这是你如何订阅一个频道以接收消息:
var channel = ably.Channels.Get(settings.Channel);
channel.Subscribe(message =>
{
var chatMessage = (string)message.Data;
// Do something with the message
});
这里是你如何向一个通道发布消息的:
await channel.PublishAsync("chat", "hello world!");
在C# .NET控制台应用程序中实现pub/sub
pub/sub的一个流行用例是聊天应用程序,所以为了展示pub/sub和C#的力量,让我们在一个.NET 6控制台应用程序中创建一个聊天程序。控制台可以开始发布消息或订阅消息。控制台应该提示用户的名字和将用于消息的颜色。
要看最终结果,你可以看一下pubsub-demo-dotnet资源库。如果你想从头开始建立这个解决方案,请按照以下说明进行。
1.前提条件
在创建控制台应用程序之前,你需要具备以下条件:
- 一个Ably账户,可以免费注册。
- .NET 6 SDK。
- 一个代码编辑器,如VSCode。
2.创建一个新的控制台应用程序
-
打开一个终端,首先创建一个名为
ConsoleChat的新文件夹:mkdir ConsoleChat -
导航到该文件夹:
cd ConsoleChat -
使用
dotnetCLI来创建一个新的控制台应用程序:dotnet new console
在你的代码编辑器中打开创建的ConsoleChat应用程序。
3.添加依赖性
ConsoleChat应用程序将有两个外部依赖项。
- Spectre.Console,一个用于创建漂亮的控制台应用程序的库。
- Ably .NET,一个用于建立实时信息传递应用程序的库。
通过dotnet CLI安装这些软件包:
dotnet add package spectre.console
dotnet add package ably.io
ConsoleChat.csproj现在看起来应该是这样的:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net6.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ably.io" Version="1.2.7" />
<PackageReference Include="spectre.console" Version="0.43.0" />
</ItemGroup>
</Project>
4.添加全局使用
为了保持C#类的整洁,让我们添加一个GlobalUsings.cs文件,其中包含以下内容:
global using Spectre.Console;
global using Spectre.Console.Cli;
global using IO.Ably;
global using System.Diagnostics.CodeAnalysis;
global using Newtonsoft.Json.Linq;
这些global using 语句将适用于我们所有将成为项目一部分的C#类。
5.添加设置
ConsoleChat应用程序将以三个命令参数启动:
- pub或sub:表示控制台将被用于发布或订阅。
- channel:要发布或订阅的频道的名称。
- ablyApiKey:创建一个新的Aply Realtime客户端所需的Aply API密钥。
使用Spectre.Console的好处是,这个库包含许多有用的对象,只需几步就能创建一个好看的控制台应用程序。
创建一个名为Settings.cs的新文件并添加以下代码:
public sealed class Settings : CommandSettings
{
public Settings(string channel, string ablyApiKey)
{
Channel = channel;
AblyApiKey = ablyApiKey;
}
[CommandArgument(0, "<channel>")]
public string Channel { get; }
[CommandArgument(1, "<ablyApiKey>")]
public string AblyApiKey { get; }
}
上述代码确保通道和ablyApiKey值作为Spectre.Console的一部分在应用程序中可用Settings 。
6.添加PublishCommand
让我们添加发布消息的功能。这将通过继承AsyncCommand<Settings> ,作为Spectre.Console Command 来实现。创建一个名为PublishCommand.cs的新文件并添加以下代码:
public sealed class PublishCommand : AsyncCommand<Settings>
{
public override async Task<int> ExecuteAsync([NotNull] CommandContext context, [NotNull] Settings settings)
{
(string Name, string Color) input = DrawConsoleAndGetInput(settings);
var clientOptions = new ClientOptions(settings.AblyApiKey) { ClientId = input.Name };
var ably = new AblyRealtime(clientOptions);
var channel = ably.Channels.Get(settings.Channel);
channel.Presence.Enter(input.Color);
while (true)
{
var text = AnsiConsole.Ask<string>($"[{input.Color}]{input.Name }: [/]");
var result = await channel.PublishAsync("chat", text);
if (result.IsFailure)
{
AnsiConsole.MarkupLine($"[red]{result.Error.Message}[/]");
}
}
return 0;
}
private static (string name, string color) DrawConsoleAndGetInput(Settings settings)
{
var intro = new FigletText(FigletFont.Default, "Welcome to Console Chat!").Color(Color.Yellow).Centered();
AnsiConsole.Write(intro);
var channelInfo = new Rule($"You're publishing to the {settings.Channel} channel.")
.Centered();
AnsiConsole.Write(channelInfo);
var name = AnsiConsole.Ask<string>("What is your name?");
var color = AnsiConsole.Prompt(
new SelectionPrompt<string>()
.Title("Select a color for your messages:")
.AddChoices(new[] {
"red",
"green",
"blue"
}));
return (name, color);
}
}
有两个代码块与Ably有关:
-
在第一个代码块中,创建一个新的
AblyRealtime客户端实例,按要求创建一个通道,并输入用户存在。一个可选的输入(input.Color)与存在一起被提供,以表明该用户在用户端渲染消息时将使用的颜色:var clientOptions = new ClientOptions(settings.AblyApiKey) { ClientId = input.Name }; var ably = new AblyRealtime(clientOptions); var channel = ably.Channels.Get(settings.Channel); channel.Presence.Enter(input.Color); -
第二个块处理发布消息的问题。
PublishAsync方法需要一个事件名称和有效载荷。result状态被检查,并且可以获得基本的错误以告知用户失败的原因。关于发布方法的更多信息可以在Ably文档中找到:var result = await channel.PublishAsync("chat", text); if (result.IsFailure) { ... }
7.添加SubscribeCommand
让我们来添加订阅消息的功能。这也将通过继承Command<Settings> ,作为Spectre.Console Command 。创建一个名为SubscribeCommand.cs的新文件并添加以下代码:
public sealed class SubscribeCommand : Command<Settings>
{
private Dictionary<string, string> clientColors = new Dictionary<string, string?>();
private record ConsoleMessage(string Name, string Message, string Color);
public override int Execute([NotNull] CommandContext context, [NotNull] Settings settings)
{
var name = DrawConsoleAndGetName(settings);
var clientOptions = new ClientOptions(settings.AblyApiKey) { ClientId = name };
var ably = new AblyRealtime(clientOptions);
var channel = ably.Channels.Get(
settings.Channel,
new ChannelOptions
{
Params = new ChannelParams { { "rewind", "2m" } }
});
var consoleMessageQueue = new Queue<ConsoleMessage>();
channel.Presence.Subscribe(member => {
clientColors.Add(member.ClientId, (string)member.Data);
var color = GetColorForClient(member.ClientId);
ConsoleMessage? presenceMessage = null;
switch (member.Action)
{
case PresenceAction.Enter:
presenceMessage = new ConsoleMessage(string.Empty, $"{member.ClientId} has joined.", color);
break;
case PresenceAction.Leave:
presenceMessage = new ConsoleMessage(string.Empty, $"{member.ClientId} has left.", color);
break;
default:
break;
}
if (presenceMessage != null)
{
consoleMessageQueue.Enqueue(presenceMessage);
}
});
channel.Presence.Enter();
channel.Subscribe(message =>
{
var color = GetColorForClient(message.ClientId);
var consoleMessage = new ConsoleMessage(message.ClientId, (string)message.Data, color);
consoleMessageQueue.Enqueue(consoleMessage);
});
while (true)
{
if (consoleMessageQueue.TryDequeue(out ConsoleMessage? consoleMessage))
{
var panel = new Panel(consoleMessage.Message)
.Header(new PanelHeader(consoleMessage.Name, Justify.Left))
.BorderColor(ConvertStringToColor(consoleMessage.Color));
AnsiConsole.Write(panel);
}
}
return 0;
}
private static string DrawConsoleAndGetName(Settings settings)
{
var intro = new FigletText(FigletFont.Default, "Welcome to Console Chat!")
.Color(Color.Yellow)
.Centered();
AnsiConsole.Write(intro);
var channelInfo = new Rule($"You're subscribing to the {settings.Channel} channel.")
.Centered();
AnsiConsole.Write(channelInfo);
var name = AnsiConsole.Ask<string>("What is your name?");
return name;
}
private string GetColorForClient(string clientId)
{
return clientColors.TryGetValue(clientId,out string? messageColor) ? messageColor : "White";
}
private Color ConvertStringToColor(string color)
{
if (Enum.TryParse<ConsoleColor>(color, true, out ConsoleColor consoleColor))
{
return Color.FromConsoleColor(consoleColor);
}
return Color.White;
}
}
这个类的下半部分处理Spectre.Console的具体代码,即如何渲染控制台和使用哪些颜色。让我们专注于这个类的上半部分:
-
第一个与Ably相关的代码块与
PublishCommand,其中一个新的AblyRealtime实例被创建。不同的是在哪里获得通道。除了频道名称,ChannelOptions,包括ChannelParams,"rewind","2m"。这指示客户端将频道的历史倒退到建立连接时的最后两分钟。这使得客户端可以看到在客户端连接之前发布的聊天信息。关于倒退的更多信息可以在Ably文档中找到。var clientOptions = new ClientOptions(settings.AblyApiKey) { ClientId = name }; var ably = new AblyRealtime(clientOptions); var channel = ably.Channels.Get( settings.Channel, new ChannelOptions { Params = new ChannelParams { { "rewind", "2m" } } }); -
Ably的第二个相关代码块涉及到存在感。客户端可以订阅存在事件,如进入一个频道,离开一个频道,或更新他们的用户数据。在这种情况下,临场感也被用来跟踪哪个客户在使用哪种颜色的信息。
switchblock onmember.Action是用来在一个客户进入或离开频道时创建一个特定的消息。客户端可以通过使用channel.Presence.Enter(),明确地宣布他们在频道中的存在,正如在这个代码块的最后一行所看到的。关于存在的更多信息可以在Ably文档中找到:channel.Presence.Subscribe(member => { clientColors.Add(member.ClientId, (string)member.Data); var color = GetColorForClient(member.ClientId); ConsoleMessage? presenceMessage = null; switch (member.Action) { case PresenceAction.Enter: presenceMessage = new ConsoleMessage(string.Empty, $"{member.ClientId} has joined.", color); break; case PresenceAction.Leave: presenceMessage = new ConsoleMessage(string.Empty, $"{member.ClientId} has left.", color); break; default: break; } if (presenceMessage != null) { consoleMessageQueue.Enqueue(presenceMessage); } }); channel.Presence.Enter(); -
最后一个Ably代码块是关于订阅消息的。使用了
channel.Subscribe()方法,需要一个Action<Message>作为其参数。这里,message.ClientId被用来查询这个用户的颜色。一个新的ConsoleMessage,其中包含用户名、消息文本和颜色。这个ConsoleMessage被放在一个队列中,稍后将被取消队列并渲染到控制台。关于订阅方法的更多信息可以在Ably文档中找到:channel.Subscribe(message => { var color = GetColorForClient(message.ClientId); var consoleMessage = new ConsoleMessage(message.ClientId, (string)message.Data, color); consoleMessageQueue.Enqueue(consoleMessage); });
8.更新Program.cs
现在,PublishCommand 和SubscribeCommand 已经到位,我们需要一种方法来调用它们。
让我们用以下代码更新Program.cs
文件:
public class Program
{
public static async Task<int> Main(string[] args)
{
var app = new CommandApp();
app.Configure(config =>
{
config.AddCommand<PublishCommand>("pub")
.WithDescription("Publish messages to a channel.")
.WithExample(new[] { "pub", "channel1", "AblyApiKey" });
config.AddCommand<SubscribeCommand>("sub")
.WithDescription("Subscribe to a channel.")
.WithExample(new[] { "sub", "channel1", "AblyApiKey" });
config.SetApplicationName("ConsoleChat.exe");
});
return await app.RunAsync(args);
}
}
在上面的代码示例中,一个新的CommandApp() (来自Spectre.Console库)被实例化并配置了之前创建的命令。这允许用"pub" 或"sub" 参数调用控制台应用程序,该参数将执行相应的命令。
9 调试
在进行开发时,能够调试任何应用程序是至关重要的。由于我们正在开发一个需要输入参数的控制台应用程序,我们需要一个地方在应用程序以调试模式启动时提供这些参数。
如果你使用VSCode,你可以用以下配置更新位于.vscode 文件夹中的launch.json文件:
{
"version": "0.2.0",
"configurations": [
{
"name": ".NET (console)",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
"program": "${workspaceFolder}/src/ConsoleChat/bin/Debug/net6.0/ConsoleChat.dll",
"args": ["${input:type}", "${input:ablyChannel}", "${input:ablyApiKey}"],
"cwd": "${workspaceFolder}/src/ConsoleChat",
"console": "integratedTerminal",
"stopAtEntry": false
},
{
"name": ".NET Attach",
"type": "coreclr",
"request": "attach"
}
],
"inputs": [
{
"id": "type",
"type": "pickString",
"options": ["pub", "sub"],
"description": "Select if your going to publish or subscribe",
},
{
"id": "ablyChannel",
"type": "promptString",
"description": "Enter a channel name",
},
{
"id": "ablyApiKey",
"type": "promptString",
"description": "Enter the Ably API key",
}
]
}
现在,当按下F5开始调试时,VSCode将显示三个输入参数的提示:

10.构建和运行
为了测试这个应用程序,至少需要两个运行的实例,一个是发布消息的实例,一个是订阅消息的实例。
让我们首先建立这个项目以创建可执行文件:
-
打开一个终端,确保你和ConsoleChat.csproj文件在同一个文件夹里。
-
运行
dotnet build命令。dotnet build -
该可执行文件应该位于
bin/Debug/net6.0文件夹中。导航到这个文件夹。 -
首先通过运行
.\ConsoleChat.exe,在没有任何参数的情况下启动ConsoleChat应用程序。这应该是输出结果:USAGE: ConsoleChat.exe [OPTIONS] <COMMAND> EXAMPLES: ConsoleChat.exe pub channel1 AblyApiKey ConsoleChat.exe sub channel1 AblyApiKey OPTIONS: -h, --help Prints help information -v, --version Prints version information COMMANDS: pub <channel> <ablyApiKey> Publish messages to a channel sub <channel> <ablyApiKey> Subscribe to a channel -
启动一个将用于订阅消息的实例:
.\ConsoleChat.exe sub pubsub1 <API_KEY>pubsub1是你要订阅的频道的名称,你可以自由地使用任何其他名称,因为该频道将在运行时被创建。<API_KEY>应该是你在先决条件步骤中创建的Ably API密钥。- 按照控制台应用程序的说明进行操作。
-
启动一个将用于发布的实例:
.\ConsoleChat.exe pub pubsub1 <API_KEY>pubsub1是你要发布的频道的名称,确保该名称与订阅控制台应用程序中使用的名称相同。<API_KEY>应该是你在先决条件步骤中创建的Ably API密钥。- 按照控制台应用程序中的指示操作。
最后,在发布者应用中开始输入,你将看到信息出现在订阅者应用中。你甚至可以在同一个频道中添加更多的订阅者和发布者实例。
11.发布应用程序
如果你想发布和分发这个控制台应用程序,与你的同事或朋友一起使用,你需要做以下工作:
-
确保你在csproj文件所在的文件夹中。
-
运行
dotnet publish命令。dotnet publish -c Release -r <RUNTIME_IDENTFIER> --self-contained=false /p:PublishSingleFile=true例子为windows x64机器。
dotnet publish -c Release -r win-x64 --self-contained=false /p:PublishSingleFile=true关于可用的运行时标识符的更多信息,请参阅.NET RID目录。
-
该应用程序的发布版本现在可以在
src\ConsoleChat\bin\Release\net6.0\<RUNTIME_IDENTIFIER>\publish文件夹中找到。
总结
总的来说,pub/sub是一个强大的模式,在许多大型的消息传递场景中经常使用。通过将发布者和订阅者分开,它可以将复杂的通信问题转化为更容易管理的块状,并且在使用上特别通用。
你已经了解了什么是pub/sub,它如何使你的.NET应用程序受益,以及如何通过使用Ably .NET SDK应用它。
我鼓励你尝试一下GitHub上的ConsoleChat项目,看看你是否可以扩展它。如果你有任何与该项目有关的问题或建议,请不要犹豫,在Twitter上与我联系,或加入我们的Discord服务器。