利用 Azure Redis Cache 作为聊天应用的后端





5.00/5 (4投票s)
如何使用 Azure Redis 缓存构建具有聊天功能的应用程序
- 下载演示 (ZIP) - 6.3 MB
- 下载演示 (RAR) - 5.6 MB
- https://github.com/afzaal-ahmad-zeeshan/azure-redis-chat-sample
引言
我曾有过一些构建聊天应用的经验——大多数时候都失败了,或者我还有其他重要的事情要汇报。几周前,我想检查一下 Microsoft Azure 上的一些服务,Azure Redis Cache 引起了我的注意。它的名字听起来很疯狂,但服务本身却非常出色,并且运行在 Redis 协议上;点击此处阅读更多关于该协议的信息。因此,Azure Redis Cache 是 Microsoft Azure 提供的一项服务,为您提供:
- 用于缓存目的的内存键值数据结构。
- 用于任何目的的发布/订阅实现——我们将用于聊天消息传递。
- 更多服务,例如集群或内容分片,这些超出了本文的范围。
你们中的大多数人可能听说过 Redis 的名字,用于内存缓存服务。然而,我更感兴趣的是 Redis 的发布/订阅模型,而不是内存缓存服务和实用程序。pub-sub 模型允许我们
- 订阅特定主题;Redis 也支持基于模式的订阅,但我不会谈论它!
- 等待有人“发布”消息到该特定主题。
作为订阅者,我们可以预览该消息的内容。这正是我们可以执行最复杂的操作以切片/切块消息并在用户友好的方式下预览消息的地方。
我希望您了解 Redis 的基础知识,以及 C# 编程的一些基础知识。我将使用 WPF 应用程序,因为我在使用简单的控制台应用程序时遇到了一些问题,并且我将解释出了什么问题以及我不得不选择 WPF 的原因;否则,这将是我的 .NET Core 文章。无论如何,我们现在可以深入了解我们将要涵盖的一些技术基础知识,然后继续进行应用程序的开发。
注意:文章中的图片看起来比实际要小,右键单击并在新标签页中打开它们可以正确预览。
Azure Redis Cache 设置
在我请您获取免费 Azure 账户之前,请注意,您也可以使用自己本地部署的 Redis 实例。因此,如果您想在本地安装 Redis 实例,请考虑下载并设置服务器,以便文章的其余部分能够正常工作。访问下载页面,然后继续。设置本地服务器所需的大多数步骤已经在网上得到了很好的解释,您可以查看一些博客来了解如何设置它。此外,需要传递的连接字符串将取决于您的本地实例,而不是我将使用的实例,该实例当然来自 Microsoft Azure。
无论如何,在我的实例中,我将继续在 Azure 门户中设置一个在线的 Azure Redis Cache 实例来访问服务,然后继续进行开发部分。在 Azure 中,搜索 Redis Cache,您将获得在该平台上创建的 Redis Cache 实例。
那里也会有其他服务,但您只需要选择这个——除非当然您在尝试其他服务。一旦进入表单,填写您认为合适的信息,然后创建 Azure 上的服务实例。
Azure 会花一点时间,并在服务部署完成后通知您,以便您开始使用它。需要考虑的一些事项是,Azure 支持 Redis 服务器的集群。我不会这样做,因为这只会增加事情的复杂性。其次,由于我们不会使用缓存,因此我们不一定需要集群和分片服务。但是,存储持久化和其他功能可能很有用,但请参阅**Redis vs Apache Kafka**部分以了解更多关于此主题的信息。
您需要从 Azure 门户获取一些设置,您将使用这些设置来配置 Redis 客户端。
- 密钥
- 使用的端口(Redis 原生不支持 SSL)
- 要连接的端点(您可能已安装本地的,在这种情况下,就是服务器监听的位置)。
我们将使用 StackExchange.Redis
库,这也是 Microsoft 推荐与 Azure 一起使用的库。在下一节,当我们开始进行项目本身的工作时,我将对此进行介绍。
最终结果
我们关注的是一个简单的聊天应用程序,每个人都有一个用户名,他们使用用户名进行通信,将消息转发到通信网格上的特定节点。我们可以使用许多其他解决方案,例如 Node.js 应用上的 socket.io,以及更多解决方案。我发现 Redis 有帮助,因为它本身可以处理许多关键问题——例如,将套接字映射到频道,以及将频道映射到套接字以进行更快的迭代和处理。
对于一个带有 Socket IO 和 Node.js 的示例聊天应用程序,请访问他们自己的网页,他们在那里解释了整个过程:https://socketio.node.org.cn/get-started/chat/。
在我们当前的业务需求和工作流程中,我们希望实现以下工作流程,并支持该平台上的多用户聊天。请记住,这只是一个演示,还有许多其他功能缺失且未实现,因为我没有时间这样做,而且这也不是我的考虑范围。但请珍惜你所拥有的!
现在,让我们深入探讨这个项目的开发,以及解释为什么我认为可以使用 Redis 来完成,特别是通过 WPF。
开发 WPF 客户端
现在让我们开始开发 WPF 应用程序。我之所以选择 WPF,是因为控制台应用程序的效果不是很好。WPF 是开发图形应用程序的良好平台,而不是使用 Windows Forms 应用程序开发平台,原因有很多,我不想在此赘述。在这里,我将 WPF 用作我域的客户端,服务器托管在 Azure 上。
如果将整个客户端应用程序分成几个部分,我们将捕获以下需要开发的方面:
- Redis 客户端
- 用于呈现任何消息的前端
- 用于发布消息以便用户接收消息的服务
所有这些都可以使用简单的控制台应用程序轻松完成。我确实开始了一个 .NET Core 应用程序,作为连接到 Azure 上托管的 Redis 服务的客户端。
控制台应用程序的问题
但控制台应用程序的问题在于,它无法区分您何时输入消息,以及何时从控制台读取 string
。整个程序运行得相当好,直到我引入了四个用户随机聊天。收到消息后,控制台开始显示我不再想记住的消息。
那段代码是这样的,是的,对于两个用户来说,它运行得相当好。您可以肯定尝试一下,没问题。:-)
class Program
{
private static ConnectionMultiplexer redis;
private static ISubscriber subscriber;
static void Main(string[] args)
{
connect();
if (redis != null && subscriber != null)
{
Console.WriteLine("Connected to Azure Redis Cache.");
Console.WriteLine("Enter the message to send;
(enter 'quit' to end program.)");
string message = "";
while (message.ToLower() != "quit")
{
message = Console.ReadLine().Trim();
if (!string.IsNullOrEmpty(message) &&
message.Trim().ToLower() != "quit")
{
sendMessage(message);
}
}
}
else
{
Console.WriteLine("Something went wrong.");
}
Console.WriteLine("Terminating program...");
Console.Read();
}
static void connect()
{
redis = ConnectionMultiplexer.Connect("<your-connection-string>");
subscriber = redis.GetSubscriber();
subscriber.Subscribe("messages", (channel, message) =>
{
Console.WriteLine($"{channel}: {(string)message}.");
});
}
static void sendMessage(string value)
{
subscriber.Publish("messages", value);
}
}
抱歉代码中没有注释,那只是我正在尝试的代码。代码中的错误让我考虑在另一个平台上编写代码。除了 UWP,WPF 是我想到的,我开始将代码从 .NET Core 移植到 .NET Framework 的 WPF 框架。
另外,对于那些已经发现的人:是的,代码不同,并且使用了一个全局频道来向所有人发送消息。这个 WPF 应用程序使用不同的方案,并且只将消息转发给特定用户。
应用程序的架构——想法
控制台应用程序和 WPF 应用程序之间的差异使我能够支持一种不同的聊天消息传递方法。在这种方法中,我能够根据需要将消息发送给特定用户。之前的控制台应用程序利用了我们可以使用单个频道并将消息广播给所有人的事实。进行此更改是为了让事情更清晰。正如您在上面的图像中看到的,我们的应用程序能够仅将消息发送给预期的接收者。Redis 也支持多个订阅者/频道,我们可以肯定地使用它进行“群聊”。
应用程序背后的想法是支持每个成员拥有一个单独的频道来收听。将其想象成他们自己的收件箱,每个人都可以将消息放入该频道。这样,我们可以区分成员以及谁收听特定频道。架构的粗略草图如下所示:
问题是,现在您可以可视化每个用户如何发送消息,但他们更有可能收听他们订阅的流——我们通过他们在应用程序启动时指定的用户名来实现这一点。
最后,作为次要功能,我想确保我们能够将消息发送给正确的收件人。其中一种方法是使用活动客户端列表,然后将消息转发给我们感兴趣的客户端。在 Redis 中太复杂了。为什么?因为 Redis 不支持活动用户列表及其频道或其他可以用来访问活动用户并向其发送消息的信息——除非您是服务管理员,这会破坏聊天应用程序的目的并允许用户访问此类敏感信息。如果我们考虑使用一个负责用户配置文件和身份验证或朋友的中间件,我们可以做到这一点。
在这种情况下,我们将使用用户的 ID 作为频道标题,例如“user_1234
”,然后启用消息传递。这可以帮助我们向平台上已知的任何人发送消息。再次,这并不能保证消息已发送给用户,除非我们处理我们写入的频道,例如从我们的服务器提供的列表中选择用户,该服务器维护我们的联系人列表。因为我们只是在频道上发布消息,所以没有像 TCP 那样的消息确认。但是,但是,但是,您可以在 Redis 协议中捕获并使用的响应,在 StackExchange.Redis
包库中,它是 Publish(Async)
函数的返回类型。该服务由 Redis 协议提供,可用于检查有多少用户收到了消息,在我们的例子中,它应该是一个或两个,具体取决于您的业务逻辑。如果返回的消息为零,我们可以得出结论,收件人离线或不存在。我将在稍后解释这一点。
换句话说,我们需要管理一个数据库,该数据库跟踪我们联系了谁以及我们希望联系的联系人的用户名是什么。Redis 在这部分将无济于事。
编写代码
既然所有解释都已完成,是时候开始编码了。首先,在 Visual Studio 中创建一个新的 WPF 项目。一旦您拥有了正常运行的 WPF 应用程序,就可以修改该项目以模拟我们需要的功能。
应用程序的前端被做得简单整洁。为了响应能力,并且由于我们正在使用 WPF,我使用了网格模板,并通过 Grid
控件而不是 StackPanel
使应用程序具有响应能力。应用程序的前端看起来像这样:
我使用了以下 XAML 代码来构建前端,您可以下载存档或考虑从 GitHub 克隆项目来尝试一下。
<Window x:Class="RedisDemo.Chat.Wpf.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:RedisDemo.Chat.Wpf"
mc:Ignorable="d"
Title="Sample App" Height="500" Width="750">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="45" />
<RowDefinition />
<RowDefinition Height="80" />
</Grid.RowDefinitions>
<StackPanel Orientation="Horizontal" Margin="5">
<TextBlock Text="Username: " Margin="0, 8, 5, 0" />
<TextBox Padding="2" IsEnabled="False" Height="25"
Margin="0, 0, 10, 0" Width="250" Name="username"
TextChanged="username_TextChanged" />
<Button IsEnabled="False" Content="Connect" Height="30"
HorizontalAlignment="Right" Name="setUsernameBtn"
Click="setUsernameBtn_Click" Padding="5" />
</StackPanel>
<Grid Grid.Row="1">
<ListView Name="messagesList">
<ListView.ItemTemplate>
<DataTemplate>
<StackPanel Margin="10, 0, 0, 10">
<TextBlock Text="{Binding Sender}"
FontWeight="SemiBold" FontSize="15" />
<TextBlock Text="{Binding Content}" />
<TextBlock Text="{Binding ReceivedAt}" Foreground="Gray" />
</StackPanel>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
</Grid>
<Grid Grid.Row="2">
<Grid.RowDefinitions>
<RowDefinition />
<RowDefinition Height="25" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition />
<ColumnDefinition Width="70" />
</Grid.ColumnDefinitions>
<TextBox IsEnabled="False" TextChanged="message_TextChanged"
VerticalAlignment="Top" Height="25"
Margin="10" Name="message" />
<Button IsEnabled="False" VerticalAlignment="Top"
Margin="8" Content="Send" Height="30"
Name="sendMessageBtn" Click="sendMessageBtn_Click"
Padding="5" Grid.Column="1" />
<TextBlock Grid.Row="1" Margin="10, 0, 0, 0"
Text="Messages should be forwarded using @username scheme
to be delivered to a specific member." />
</Grid>
</Grid>
</Window>
我使用了 Grid
控件,并创建了列和行,以支持应用程序 UI 的响应式缩放。这样做的好处是,在缩放应用程序时,它看起来更干净。除此之外,我确实使用了一些硬编码的 Margin
值和其他一些东西,但这是为了保持一定程度的一致性。我显示器上的全屏视图看起来是这样的:
主要部分是管理代码并编写后端代码。我的主要兴趣是使代码更清晰,并在性能和效率方面更好。我尽可能使用了 async
/await
修饰符。利用全局变量,而不是局部短生命周期变量,这些变量需要在每次函数调用时重新创建。
代码的片段如下,建立连接所涉及的主要设置以及关闭连接的最终函数如下:
// Variables
private ObservableCollection<Message> collection;
private ConnectionMultiplexer redis;
private ISubscriber subscriber;
public MainWindow()
{
InitializeComponent();
Closing += MainWindow_Closing;
// Set the variables etc.
setThingsUp();
}
private async void MainWindow_Closing
(object sender, System.ComponentModel.CancelEventArgs e)
{
// Terminate the sessions and efficiently close the streams.
if (redis != null)
{
var sub = redis.GetSubscriber();
await sub.UnsubscribeAsync(username.Text.Trim().ToLower());
await redis.CloseAsync();
}
}
// Set the variables and the focus of user.
private void setThingsUp()
{
collection = new ObservableCollection<Message>();
username.IsEnabled = true;
setUsernameBtn.IsEnabled = false;
username.Focus();
// Set the list data source;
messagesList.ItemsSource = collection;
}
现在我们已经定义了基本的工作流程,我们的用户将看到主文本框,它将具有焦点。稍后,他们将输入用户名并使用连接字符串建立到 Azure Redis Cache 服务的连接。一旦用户输入用户名,他们就可以按“连接”按钮并建立连接,代码如下:
private async void setUsernameBtn_Click(object sender, RoutedEventArgs e)
{
// Prevent resubmission of the request.
setUsernameBtn.IsEnabled = false;
// Establish connection, asynchronously.
redis = await ConnectionMultiplexer.ConnectAsync("<connection-string>");
if (redis != null)
{
if (redis.IsConnected)
{
// Subscribe to our username
subscriber = redis.GetSubscriber();
await subscriber.SubscribeAsync(username.Text.Trim().ToLower(),
(channel, value) =>
{
string buffer = value;
var message = JsonConvert.DeserializeObject<Message>(buffer);
message.ReceivedAt = DateTime.Now;
// This function runs on a background thread, thus dispatcher is needed.
Dispatcher.Invoke(() =>
{
collection.Add(message);
});
});
// Enable the messaging buttons and box.
message.IsEnabled = true;
message.Focus();
Title += " : " + username.Text.Trim();
username.IsEnabled = false;
}
else
{
MessageBox.Show("We could not connect to Azure Redis Cache service.
Try again later.");
setUsernameBtn.IsEnabled = true;
}
}
else
{
setUsernameBtn.IsEnabled = true;
}
}
基本检查是为了确保我们能够连接到服务。如果可以,我们将继续并订阅我们的用户名。需要注意的一点是,我们需要检查我们的用户名如何用作频道。
- 修剪任何空格,以避免在频道名称中出现多余空格的问题。
- 将用户名转换为小写(您也可以使用大写,这取决于您!)以便您只将消息推送到同一个用户——我认为 Afzaal、AFZAAL、afzaal 和 aFzaal 是同一个用户,只是打字错误。
所以这是用代码完成的:
await subscriber.SubscribeAsync(username.Text.Trim().ToLower(), ...
代码非常整洁,StackExchange.Redis
的作者在隐式转换 RedisChannel
到 string
和反之亦然方面做得很好,这就是为什么我使用 string
值而不是代表 RedisChannel
对象的对象。在同一个函数内部,我们提供了一个 lambda,当消息发布到我们订阅的频道时,该 lambda 会被调用。
string buffer = value;
var message = JsonConvert.DeserializeObject<Message>(buffer);
message.ReceivedAt = DateTime.Now;
// This function runs on a background thread, thus dispatcher is needed.
Dispatcher.Invoke(() =>
{
collection.Add(message);
});
我使用了一个特殊的类来包含消息。这个类包含显示以下内容的属性:
- 谁——发送者
- 什么——消息
- 何时——时间
用户刚刚收到的消息。Json.NET 库用于在两端反序列化和序列化消息。消息类型定义为以下类型:
public class Message
{
public int Id { get; set; }
public string Sender { get; set; }
public string Content { get; set; }
public DateTime ReceivedAt { get; set; }
}
是不是很简单明了?最后一点是,我们使用了 Dispatcher.Invoke
函数。因为我们的订阅者监听消息并调用在后台线程上运行的 lambda。这就是为什么我们需要使用 Dispatcher
来执行代码。大致就是这样。
现在,要发送消息,我使用了以下代码将消息发布到我感兴趣的用户的频道,使用了 @username
符号。
// Send the message
private async void sendMessageBtn_Click(object sender, RoutedEventArgs e)
{
var content = message.Text.Trim();
// Get the recipient name, e.g. @someone hi there!
var recipient = content.Split(' ')[0].Replace("@", "").Trim().ToLower();
// Create the message payload.
var blob = new Message();
blob.Sender = username.Text.Trim();
blob.Content = content;
// Send the message.
var received = await subscriber.PublishAsync
(recipient, JsonConvert.SerializeObject(blob));
// If no recipient got the message, show the error.
if (received == 0)
{
MessageBox.Show($"Sorry, '{recipient}' is not active at the moment.");
}
message.Text = "";
}
再次,与之前讨论的相同代码。我们正在尝试捕获我们感兴趣的人的用户名,然后我们将消息写到他们的频道。这里需要注意的一点是,Redis 也可以在频道上写入 string
消息。我们没有这样做,而是写入了我们的 Message
类型的序列化对象。这会在另一端反序列化,我们会在屏幕上看到消息。
另外,看看这里:
var received = await subscriber.PublishAsync(recipient,...
我们正在尝试捕获收到我们消息的客户端数量。如果该数字为零,我们将显示一条消息,表明用户已离线。这并不意味着用户不存在,如果我们有一个完整的聊天平台,仅仅意味着用户未在线。看看这里的行为:
这些是我希望大家看看的一些常见方面。到目前为止,Redis 在许多情况下都很有用,并且帮助我们利用框架/平台构建复杂的应用程序环境。
运行程序
既然我们已经完成了所有代码的运行,我们就可以运行程序看看它是如何工作的。代码已经在上面的图像中显示了,问题也在上一节的图像中显示了。您可以在那里查看。
然而,有一件事我想让您考虑的是,您必须始终释放您使用的资源,并且在此应用程序的情况下,除了调用 Dispose 之外,还有很多工作要做。您还需要从 Redis 频道取消订阅,我将在 Closing
事件中这样做。
private async void MainWindow_Closing
(object sender, System.ComponentModel.CancelEventArgs e)
{
// Terminate the sessions and efficiently close the streams.
if (redis != null)
{
var sub = redis.GetSubscriber();
await sub.UnsubscribeAsync(username.Text.Trim().ToLower());
await redis.CloseAsync();
}
}
这是可选的,但可以提供很大帮助。一种帮助方式是,当用户退出应用程序时,它可以提供“零”的结果,其他用户可以知道收件人已离线。否则,就是 Redis 在一段时间后运行检查时考虑关闭套接字。
Redis 或 Apache Kafka
Apache Kafka 是一个很棒的数据流平台。Apache Kafka 更侧重于通过队列进行消息流。类似地,队列可以有多个订阅者和多个发布者。主要特点是,一旦消息被读取,它就可以在 Kafka 的队列中持久化,而在 Redis 中,它会被清除。
Redis 也是一个功能丰富的平台,并支持快速消息转发。Kafka 必须管理和持久化数据,因此可以原谅其在存储和持久化方面的不足,但在许多情况下,Kafka 仍然更快且延迟更低。有关速度基准测试,请参阅这篇博客文章:https://bravenewgeek.com/benchmarking-message-queue-latency/。
对于数据持久化,Redis 支持数据存储,它将数据(缓存数据)存储到硬盘。一旦数据发送给收件人,频道队列就会被清除。在我尝试应用程序时,我在 Azure 上得到了以下图表,告诉我没有消息被存储,并且每次捕获消息时,它都被直接发送而不是缓存。
我需要将此场景复制到 Apache Kafka 中看看效果,我可能会考虑写一个比较场景,但由于我没有关于 Kafka 如何处理此问题的信息,所以没什么可说的。
请参阅 SO 上的这个主题以了解更多信息:https://stackoverflow.com/questions/37990784/difference-between-redis-and-kafka。
下一步?
在这个文章中,我们仅仅触及了构建聊天应用程序的复杂表面。还有很多功能缺失,您可以查看 GitHub 上的源代码并进行一些修改。我可能也会尽快做出一些我认为必要的修改。
用户分组和将其注册为组成员是一项很棒的功能,也应该实现。除此之外,我曾经工作的应用程序中有一个标签页功能。我真的很喜欢那个功能,但我没有时间来做一个如此复杂的聊天应用程序。
除此之外,您需要考虑 Redis 支持的模型。它可以支持多少连接?它可以支持多少频道?此外,它可以提供多少连接/频道或多少频道/连接支持?另外,Azure 在这方面的限制是什么,所有这些问题都可以极大地帮助您理解是否应该考虑 Redis。此外,请参阅上面的 Redis 或 Apache Kafka 部分,以找到 Redis 可以提供帮助的几种方式,或者何时 Kafka 可能是更好的解决方案!
最后,这从来都不是一个伟大的聊天应用程序。仅仅是 Redis 服务的一次试用,以及 Azure 对托管 Redis 缓存实现的解决方案。我希望这在某些方面对您有所帮助,并且您通过演示了解了 Redis 的 pub-sub 如何工作。如果不清楚,请在下面的评论部分与我联系,或在本地机器上安装并设置所有内容并尝试一下。
迫不及待地想看到您的作品!
历史
- 2018 年 3 月 28 日:初始版本