在前面几篇文章中实践了如何搭建服务端(broker)以及如何在不同类型的应用中实现MQTT客户端,但是回过头来看看,用Apache Apollo搭建的服务端功能固然强大,但无法将其融入到自有业务系统的代码中,尤其是想更加灵活方便的在业务系统中利用MQTT协议的特性时,那么是否能够构建一个自己的MQTTServer呢?
今天就来试试用MQTTNet构建一个WPF版的MQTTServer。
首先要确认下这个服务端要实现哪些功能,参考Apache Apollo的后台管理界面,确定了几个简单的需求:
此次MQTTServer 的实现将采用MQTTNet类库,之前曾用这个类库实现过MQTTClient,相关方法有所了解,开发速度会快些。
有了之前编写WPF版MQTT客户端的经验,在数据结构的搭建上也驾轻就熟了。
public class MainWindowModel : INotifyPropertyChanged
{
public MainWindowModel()
{
hostIP = "127.0.0.1";//绑定的IP地址
hostPort = 12345;//绑定的端口号
timeout = 3000;//连接超时时间
username = "admin";//用户名
password = "password";//密码
allTopics = new ObservableCollection<TopicModel>();//主题
allClients = new ObservableCollection<string>();//客户端
addTopic = "";
}
private ObservableCollection<TopicModel> allTopics;
//所有主题
public ObservableCollection<TopicModel> AllTopics
{
get { return allTopics; }
set
{
if (allTopics != value)
{
allTopics = value;
this.OnPropertyChanged("AllTopics");
}
}
}
private ObservableCollection<string> allClients;
//所有客户端
public ObservableCollection<string> AllClients
{
get { return allClients; }
set
{
if (allClients != value)
{
allClients = value;
this.OnPropertyChanged("AllClients");
}
}
}
private string hostIP;
//IP地址
public string HostIP
{
get { return hostIP; }
set
{
if (hostIP != value)
{
hostIP = value;
this.OnPropertyChanged("HostIP");
}
}
}
private int hostPort;
//端口号
public int HostPort
{
get { return hostPort; }
set
{
if (hostPort != value)
{
hostPort = value;
this.OnPropertyChanged("HostPort");
}
}
}
private int timeout;
//超时时间
public int Timeout
{
get { return timeout; }
set
{
if (timeout != value)
{
timeout = value;
this.OnPropertyChanged("Timeout");
}
}
}
private string username;
//用户名
public string UserName
{
get { return username; }
set
{
if (username != value)
{
username = value;
this.OnPropertyChanged("UserName");
}
}
}
private string password;
//密码
public string Password
{
get { return password; }
set
{
if (password != value)
{
password = value;
this.OnPropertyChanged("Password");
}
}
}
private string addTopic;
public string AddTopic
{
get { return addTopic; }
set
{
if (addTopic != value)
{
addTopic = value;
this.OnPropertyChanged("AddTopic");
}
}
}
public event PropertyChangedEventHandler PropertyChanged;
protected virtual void OnPropertyChanged(string propertyName = null)
{
var handler = PropertyChanged;
if (handler != null) handler(this, new PropertyChangedEventArgs(propertyName));
}
}
//主题扩展类
public class TopicModel : TopicFilter,INotifyPropertyChanged
{
public TopicModel(string topic, MqttQualityOfServiceLevel qualityOfServiceLevel) : base(topic, qualityOfServiceLevel)
{
clients = new List<string>();
count = 0;
}
private int count;
///
/// 订阅此主题的客户端数量
///
public int Count
{
get { return count; }
set
{
if (count != value)
{
count = value;
this.OnPropertyChanged("Count");
}
}
}
private List<string> clients;
///
/// 订阅此主题的客户端
///
public List<string> Clients
{
get { return clients; }
set
{
if (clients != value)
{
clients = value;
this.OnPropertyChanged("Clients");
}
}
}
protected virtual void OnPropertyChanged(string propertyName = null)
{
var handler = PropertyChanged;
if (handler != null) handler(this, new PropertyChangedEventArgs(propertyName));
}
public event PropertyChangedEventHandler PropertyChanged;
}
值得注意的是,这个数据模型和之前在WPF版的MQTT客户端中的数据模型有相似之处,这是因为客户端和服务端的参数一致才能确保两端联通。其中TopicModel 主题扩展类继承了MQTTNet命名空间下的TopicFilter类。此外为了更好的利用WPF的绑定机制,MainWindowModel类和TopicModel类都继承了接口INotifyPropertyChanged。
与数据结构相对应,编写前台页面代码用来呈现数据并实现交互。
<metro:MetroWindow x:Class="MqttDemo.WPFServer.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:metro="http://metro.mahapps.com/winfx/xaml/controls"
Title="MainWindow"
Height="480"
Width="800">
<metro:MetroWindow.RightWindowCommands>
<metro:WindowCommands>
<Button x:Name="btnConfig" Click="btnConfig_Click">配置Button>
metro:WindowCommands>
metro:MetroWindow.RightWindowCommands>
<metro:MetroWindow.Flyouts>
<metro:FlyoutsControl>
<metro:Flyout x:Name="flyConfig" AnimateOpacity="True" CloseButtonIsCancel="True" IsModal="True" Theme="Light" Position="Right" Header="订阅主题" Width="300">
<Grid Margin="10">
<Grid.Resources>
<Style TargetType="TextBlock">
"VerticalAlignment" Value="Center">
"HorizontalAlignment" Value="Right">
Style>
<Style TargetType="TextBox">
"VerticalAlignment" Value="Center">
"HorizontalAlignment" Value="Center">
"Height" Value="30">
"Width" Value="100">
"VerticalContentAlignment" Value="Center">
Style>
Grid.Resources>
<Grid.RowDefinitions>
<RowDefinition>RowDefinition>
<RowDefinition>RowDefinition>
<RowDefinition>RowDefinition>
<RowDefinition>RowDefinition>
<RowDefinition>RowDefinition>
<RowDefinition>RowDefinition>
<RowDefinition>RowDefinition>
<RowDefinition>RowDefinition>
<RowDefinition>RowDefinition>
<RowDefinition>RowDefinition>
Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="2*">ColumnDefinition>
<ColumnDefinition Width="3*">ColumnDefinition>
Grid.ColumnDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0">绑定IP地址TextBlock>
<TextBox Grid.Row="0" Grid.Column="1" Text="{Binding Path=HostIP,Mode=TwoWay}">TextBox>
<TextBlock Grid.Row="1" Grid.Column="0">绑定端口号TextBlock>
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding Path=HostPort,Mode=TwoWay}">TextBox>
<TextBlock Grid.Row="2" Grid.Column="0">连接超时时间TextBlock>
<TextBox Grid.Row="2" Grid.Column="1" Text="{Binding Path=Timeout,Mode=TwoWay}">TextBox>
<TextBlock Grid.Row="3" Grid.Column="0">用户名设置TextBlock>
<TextBox Grid.Row="3" Grid.Column="1" Text="{Binding Path=UserName,Mode=TwoWay}">TextBox>
<TextBlock Grid.Row="4" Grid.Column="0">密码设置TextBlock>
<TextBox Grid.Row="4" Grid.Column="1" Text="{Binding Path=Password,Mode=TwoWay}">TextBox>
Grid>
metro:Flyout>
metro:FlyoutsControl>
metro:MetroWindow.Flyouts>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="60">RowDefinition>
<RowDefinition>RowDefinition>
Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition>ColumnDefinition>
<ColumnDefinition>ColumnDefinition>
<ColumnDefinition>ColumnDefinition>
Grid.ColumnDefinitions>
<WrapPanel Grid.ColumnSpan="3" VerticalAlignment="Center" HorizontalAlignment="Center">
<Button x:Name="btnStart" Click="btnStart_Click">启动Button>
<Button x:Name="btnStop" Click="btnStop_Click" Margin="10,0,0,0">停止Button>
WrapPanel>
<GroupBox Grid.Row="1" Grid.Column="0" Header="Client" Margin="5">
<ListBox ItemsSource="{Binding Path=AllClients,Mode=TwoWay}">
<ListBox.ItemTemplate>
<DataTemplate>
<Label Content="{Binding}">Label>
DataTemplate>
ListBox.ItemTemplate>
ListBox>
GroupBox>
<GroupBox Grid.Row="1" Grid.Column="1" Header="Topic" Margin="0,5">
<Grid>
<Grid.RowDefinitions>
<RowDefinition>RowDefinition>
<RowDefinition Height="50">RowDefinition>
Grid.RowDefinitions>
<DataGrid ItemsSource="{Binding Path=AllTopics,Mode=TwoWay}" AutoGenerateColumns="False">
<DataGrid.Columns>
<DataGridTextColumn Width="*" Header="Name" Binding="{Binding Topic}">DataGridTextColumn>
<DataGridTextColumn Width="*" Header="Level" Binding="{Binding QualityOfServiceLevel}">DataGridTextColumn>
<DataGridTextColumn Width="*" Header="Count" Binding="{Binding Count}">DataGridTextColumn>
DataGrid.Columns>
DataGrid>
<WrapPanel Grid.Row="1" VerticalAlignment="Center" HorizontalAlignment="Center">
<TextBox Width="150" Text="{Binding Path=AddTopic,Mode=TwoWay}">TextBox>
<Button x:Name="btnAddTopic" Click="btnAddTopic_Click" Margin="10,0,0,0">添加主题Button>
WrapPanel>
Grid>
GroupBox>
<GroupBox Grid.Row="1" Grid.Column="2" Header="Log" Margin="5">
<RichTextBox x:Name="txtRich" ToolTip="右键清理内容">
<RichTextBox.ContextMenu>
<ContextMenu>
<MenuItem x:Name="menuClear" Click="menuClear_Click" Header="清空内容">MenuItem>
ContextMenu>
RichTextBox.ContextMenu>
RichTextBox>
GroupBox>
Grid>
metro:MetroWindow>
从页面上大体分为四部分,分别用于服务端启停控制、客户端状态监测、主题状态监测和数据传输监测,另有一个隐藏的配置页面。页面呈现效果如下:
private MainWindowModel _model;
public MainWindow()
{
InitializeComponent();
_model = new MainWindowModel();
this.DataContext = _model;
}
关键的部分在于构建Server配置项,MQTTNet提供了多个可供修改的参数用于服务端的配置,比较常用的有
参数名 | 用途 |
---|---|
WithDefaultEndpointBoundIPAddress | 默认终结点绑定的IP地址 |
WithDefaultEndpointPort | 默认终结点端口号 |
WithDefaultCommunicationTimeout | 默认连接超时时间 |
WithConnectionValidator | 连接验证器(可验证用户名、密码、客户端标识等) |
#region 启动按钮事件
private async void btnStart_Click(object sender, RoutedEventArgs e)
{
//构建Server配置项
var optionBuilder = new MqttServerOptionsBuilder().WithDefaultEndpointBoundIPAddress(System.Net.IPAddress.Parse(_model.HostIP)).WithDefaultEndpointPort(_model.HostPort).WithDefaultCommunicationTimeout(TimeSpan.FromMilliseconds(_model.Timeout)).WithConnectionValidator(t =>
{
if (t.Username!=_model.UserName||t.Password!=_model.Password)
{
t.ReturnCode = MqttConnectReturnCode.ConnectionRefusedBadUsernameOrPassword;
}
t.ReturnCode = MqttConnectReturnCode.ConnectionAccepted;
});
var option = optionBuilder.Build();
//实例化
server = new MqttFactory().CreateMqttServer();
server.ApplicationMessageReceived += Server_ApplicationMessageReceived;//绑定消息接收事件
server.ClientConnected += Server_ClientConnected;//绑定客户端连接事件
server.ClientDisconnected += Server_ClientDisconnected;//绑定客户端断开事件
server.ClientSubscribedTopic += Server_ClientSubscribedTopic;//绑定客户端订阅主题事件
server.ClientUnsubscribedTopic += Server_ClientUnsubscribedTopic;//绑定客户端退订主题事件
server.Started += Server_Started;//绑定服务端启动事件
server.Stopped += Server_Stopped;//绑定服务端停止事件
//启动
await server.StartAsync(option);
}
#endregion
#region 停止按钮事件
private async void btnStop_Click(object sender, RoutedEventArgs e)
{
if (server != null)
{
await server.StopAsync();
}
}
#endregion
#region 服务端停止事件
private void Server_Stopped(object sender, EventArgs e)
{
WriteToStatus("服务端已停止!");
}
#endregion
#region 服务端启动事件
private void Server_Started(object sender, EventArgs e)
{
WriteToStatus("服务端已启动!");
}
#endregion
#region 客户端断开事件
private void Server_ClientDisconnected(object sender, MqttClientDisconnectedEventArgs e)
{
this.Dispatcher.Invoke(() =>
{
//客户端断开时从客户端列表中移除
_model.AllClients.Remove(e.ClientId);
var query = _model.AllTopics.Where(t => t.Clients.Contains(e.ClientId));
if (query.Any())
{
var tmp = query.ToList();
foreach (var model in tmp)
{
//更新主题
_model.AllTopics.Remove(model);
model.Clients.Remove(e.ClientId);
model.Count--;
_model.AllTopics.Add(model);
}
}
});
WriteToStatus("客户端" + e.ClientId + "断开");
}
#endregion
#region 客户端连接事件
private void Server_ClientConnected(object sender, MqttClientConnectedEventArgs e)
{
this.Dispatcher.Invoke(() =>
{
_model.AllClients.Add(e.ClientId);
});
WriteToStatus("客户端" + e.ClientId + "连接");
}
#endregion
#region 客户端退订主题事件
private void Server_ClientUnsubscribedTopic(object sender, MqttClientUnsubscribedTopicEventArgs e)
{
this.Dispatcher.Invoke(() =>
{
if (_model.AllTopics.Any(t => t.Topic == e.TopicFilter))
{
TopicModel model = _model.AllTopics.First(t => t.Topic == e.TopicFilter);
_model.AllTopics.Remove(model);
model.Clients.Remove(e.ClientId);
model.Count--;
if (model.Count > 0)
{
_model.AllTopics.Add(model);
}
}
});
WriteToStatus("客户端" + e.ClientId + "退订主题" + e.TopicFilter);
}
#endregion
#region 客户端订阅主题事件
private void Server_ClientSubscribedTopic(object sender, MqttClientSubscribedTopicEventArgs e)
{
this.Dispatcher.Invoke(() =>
{
if (_model.AllTopics.Any(t => t.Topic == e.TopicFilter.Topic))
{
TopicModel model = _model.AllTopics.First(t => t.Topic == e.TopicFilter.Topic);
_model.AllTopics.Remove(model);
model.Clients.Add(e.ClientId);
model.Count++;
_model.AllTopics.Add(model);
}
else
{
TopicModel model = new TopicModel(e.TopicFilter.Topic, e.TopicFilter.QualityOfServiceLevel)
{
Clients = new List<string> { e.ClientId },
Count = 1
};
_model.AllTopics.Add(model);
}
});
WriteToStatus("客户端" + e.ClientId + "订阅主题" + e.TopicFilter.Topic);
}
#endregion
由于MQTTServer 扮演着消息中转站的角色,所以可以在消息接收事件中对特定消息进行拦截,并按照自定义的规则进行处理后再转发出去。
这里举了一个小例子,从现场设备传来的是实际温度值,对特定主题进行拦截,判断温度是否超出上限或低于下限,然后将消息重新组装,转发给最终用户。
#region 消息接收事件
private void Server_ApplicationMessageReceived(object sender, MqttApplicationMessageReceivedEventArgs e)
{
if (e.ApplicationMessage.Topic == "/environ/temp")
{
string str = System.Text.Encoding.UTF8.GetString(e.ApplicationMessage.Payload);
double tmp;
bool isdouble = double.TryParse(str, out tmp);
if (isdouble)
{
string result = "";
if (tmp > 40)
{
result = "温度过高!";
}
else if (tmp < 10)
{
result = "温度过低!";
}
else
{
result = "温度正常!";
}
MqttApplicationMessage message = new MqttApplicationMessage()
{
Topic = e.ApplicationMessage.Topic,
Payload = Encoding.UTF8.GetBytes(result),
QualityOfServiceLevel = e.ApplicationMessage.QualityOfServiceLevel,
Retain = e.ApplicationMessage.Retain
};
server.PublishAsync(message);//重新发布
}
}
WriteToStatus("收到消息" + e.ApplicationMessage.ConvertPayloadToString() + ",来自客户端" + e.ClientId + ",主题为" + e.ApplicationMessage.Topic);
}
#endregion
现在有了自定义的客户端和自定义的服务端,就来测试下效果如何吧。
从测试的情况来看,最初的需求都已实现。在此基础上就可以根据自己的业务需求不断扩展完善了。
但在后续的测试中也发现了一些问题,就是在默认的配置中只考虑了TCP协议,没有考虑WebSocket协议,导致Web版的MQTT客户端连接失败,这也是后期要解决的问题。
保持乐趣,不断尝试!
源代码地址