什么是pub/sub以及如何在C#.NET中应用它来构建一个聊天应用程序

720 阅读14分钟

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

你会学到

What is pub/sub and how to apply it in C# .NET to build a chat app

TLDR:这里是GitHub的Repo,有完成的项目。

随着基于云和分布式系统的兴起,消息传递解决方案似乎无处不在。与直接的客户-服务器通信相比,消息传递和事件驱动架构允许松散耦合的组件相互通信,从而使系统具有更好的可扩展性。

自1997年微软消息队列(MSMQ)被用来在应用程序之间可靠地传递消息以来,微软领域的消息传递解决方案就一直存在。但如今,人们使用基于云的消息传递组件,如Azure Service BusSignalRWeb PubSub等等。在这篇文章中,我将详细介绍另一个基于云的、高度可扩展的消息传递解决方案Ably,并使用它在.NET 6中构建一个基于控制台的聊天应用程序。

什么是pub/sub?

发布/订阅(pub/sub)模式是一种允许消息从一个实体("发布者 "或 "生产者")发送到其他实体("订阅者 "或 "消费者")的模式,它们之间没有直接联系。通信是通过使用一个消息代理来完成的,它从发布者那里接收消息,然后将它们分发给相关的订阅者。

为了确定每条消息应该发送给谁,使用了主题(也被称为渠道)的前提。这是一个代表通信集合的ID,发布者可以向其发布,而订阅者可以向其订阅。一个例子是有一个叫做 "体育 "的主题,发布者将发布体育更新,而订阅者将订阅上述体育更新。

发布者没有必要只做发布者,订阅者也没有必要只做订阅者。许多用例,如聊天应用程序,需要客户端既发布消息又订阅消息。主要的概念是,所有的通信都被发送到经纪商,由一个主题ID来识别,然后再发送给订阅了该主题的任何客户端。

了解更多关于发布和订阅的信息:

What is pub/sub and how to apply it in C# .NET to build a chat app

什么时候应该使用pub/sub?

当应用程序使用pub/sub模式时:

  • 需要向多个客户端发送消息。
  • 不需要客户端的直接(同步)响应。

典型的用例是:

  • 聊天;每个用户在聊天频道中既是发布者又是订阅者。
  • 位置跟踪;运输车辆的GPS数据被广播给正在等待交货的人。
  • 事件通知;一个新闻机构发布了一条新闻,新闻应用的所有用户都会收到通知。
  • 分布式缓存;客户端使用本地数据存储以达到最佳性能。这些本地数据存储通过发布者发送的变化事件进行更新。

What is pub/sub and how to apply it in C# .NET to build a chat app

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.前提条件

在创建控制台应用程序之前,你需要具备以下条件:

2.创建一个新的控制台应用程序

  1. 打开一个终端,首先创建一个名为ConsoleChat 的新文件夹:

    mkdir ConsoleChat
    
  2. 导航到该文件夹:

    cd ConsoleChat
    
  3. 使用dotnet CLI来创建一个新的控制台应用程序:

    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应用程序将以三个命令参数启动:

  • pubsub:表示控制台将被用于发布或订阅。
  • 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的第二个相关代码块涉及到存在感。客户端可以订阅存在事件,如进入一个频道,离开一个频道,或更新他们的用户数据。在这种情况下,临场感也被用来跟踪哪个客户在使用哪种颜色的信息。switch block 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

现在,PublishCommandSubscribeCommand 已经到位,我们需要一种方法来调用它们。
让我们用以下代码更新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将显示三个输入参数的提示:

What is pub/sub and how to apply it in C# .NET to build a chat app

10.构建和运行

为了测试这个应用程序,至少需要两个运行的实例,一个是发布消息的实例,一个是订阅消息的实例。

让我们首先建立这个项目以创建可执行文件:

  1. 打开一个终端,确保你和ConsoleChat.csproj文件在同一个文件夹里。

  2. 运行 dotnet build命令。

    dotnet build
    
  3. 该可执行文件应该位于bin/Debug/net6.0 文件夹中。导航到这个文件夹。

  4. 首先通过运行.\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
    
  5. 启动一个将用于订阅消息的实例:

    .\ConsoleChat.exe sub pubsub1 <API_KEY>
    
    • pubsub1 是你要订阅的频道的名称,你可以自由地使用任何其他名称,因为该频道将在运行时被创建。
    • <API_KEY> 应该是你在先决条件步骤中创建的Ably API密钥。
    • 按照控制台应用程序的说明进行操作。
  6. 启动一个将用于发布的实例:

    .\ConsoleChat.exe pub pubsub1 <API_KEY>
    
    • pubsub1 是你要发布的频道的名称,确保该名称与订阅控制台应用程序中使用的名称相同。
    • <API_KEY> 应该是你在先决条件步骤中创建的Ably API密钥。
    • 按照控制台应用程序中的指示操作。

最后,在发布者应用中开始输入,你将看到信息出现在订阅者应用中。你甚至可以在同一个频道中添加更多的订阅者和发布者实例。

11.发布应用程序

如果你想发布和分发这个控制台应用程序,与你的同事或朋友一起使用,你需要做以下工作:

  1. 确保你在csproj文件所在的文件夹中。

  2. 运行 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目录

  3. 该应用程序的发布版本现在可以在src\ConsoleChat\bin\Release\net6.0\<RUNTIME_IDENTIFIER>\publish 文件夹中找到。

总结

总的来说,pub/sub是一个强大的模式,在许多大型的消息传递场景中经常使用。通过将发布者和订阅者分开,它可以将复杂的通信问题转化为更容易管理的块状,并且在使用上特别通用。

你已经了解了什么是pub/sub,它如何使你的.NET应用程序受益,以及如何通过使用Ably .NET SDK应用它。

我鼓励你尝试一下GitHub上的ConsoleChat项目,看看你是否可以扩展它。如果你有任何与该项目有关的问题或建议,请不要犹豫,在Twitter上与我联系,或加入我们的Discord服务器

进一步阅读