在上篇文章中利用Apollo创建了MQTT服务端,但仅有一个服务端是没有意义的,只有将服务端和客户端结合起来使用才能发挥MQTT协议的特性,所以本篇的内容是创建MQTT客户端。由于本人对.Net平台相对熟悉,所以将使用MQTTNet类库结合WPF创建一个客户端。
1.需求分析
MQTT协议的基本特性是使用发布/订阅消息模式,提供一对多的消息发布,解除应用程序耦合,同时基于TCP/IP能够提供多种类型的网络传输模式。
为了能够体现MQTT协议的特性,同时也为了能够有一些简单的操作交互和直观的界面体现,对这个客户端提出了几个简单的需求。
- 订阅消息主题的管理
- 发布消息主题的管理
- 客户端基本信息的管理
- 状态数据变化的输出显示
2.需求实现
首先第一步是创建一个空的WPF项目,兼顾一点小小的UI要求,使用了MahApps.Metro界面库(也可以不使用,不影响功能实现)。
2.1数据结构
为了满足上述需求,通过创建C#类来体现数据结构。
主题信息类-TopicModel
属性包含了主题名称Topic、主题描述Describe及是否选中IsSelected,同时继承了INotifyPropertyChanged接口以便于后续的数据绑定。
public class TopicModel: INotifyPropertyChanged
{
public TopicModel()
{
}
public TopicModel(string topic,string describe)
{
_isSelected = false;
_topic = topic;
_describe = describe;
}
private bool? _isSelected;
public bool? IsSelected
{
get { return _isSelected; }
set
{
if (_isSelected!=value)
{
_isSelected = value;
OnPropertyChanged("IsSelected");
}
}
}
private string _topic;
public string Topic
{
get { return _topic; }
set
{
if (_topic!=value)
{
_topic = value;
OnPropertyChanged("Topic");
}
}
}
private string _describe;
public string Describe
{
get { return _describe; }
set
{
if (_describe!=value)
{
_describe = value;
OnPropertyChanged("Describe");
}
}
}
public event PropertyChangedEventHandler PropertyChanged;
protected virtual void OnPropertyChanged(string propertyName = null)
{
var handler = PropertyChanged;
if (handler != null) handler(this, new PropertyChangedEventArgs(propertyName));
}
}
主窗体信息类-MainWindowModel
属性包含了所有主题AllTopics、已选中主题SelectedTopics、服务端地址ServerUri、服务端端口号ServerPort,客户端标识ClientID、当前选择的主题CurrentTopic、是否建立连接IsConnected、是否断开连接IsDisConnected、用户名UserName、密码Password,同样为了数据绑定继承了接口INotifyPropertyChanged。
public class MainWindowModel: INotifyPropertyChanged
{
private List<TopicModel> _allTopics;
public List<TopicModel> AllTopics
{
get { return _allTopics; }
set
{
if (_allTopics!=value)
{
_allTopics = value;
OnPropertyChanged("AllTopics");
}
}
}
private List<TopicFilter> _selectedTopics;
public List<TopicFilter> SelectedTopics
{
get { return _selectedTopics; }
set
{
if (_selectedTopics!=value)
{
_selectedTopics = value;
OnPropertyChanged("SelectedTopics");
}
}
}
private string _serverUri;
public string ServerUri
{
get { return _serverUri; }
set
{
if (_serverUri!=value)
{
_serverUri = value;
OnPropertyChanged("ServerUri");
}
}
}
private int _serverPort;
public int ServerPort
{
get { return _serverPort; }
set
{
if (_serverPort!=value)
{
_serverPort = value;
OnPropertyChanged("ServerPort");
}
}
}
private string _clientId;
public string ClientID
{
get { return _clientId; }
set
{
if (_clientId!=value)
{
_clientId = value;
OnPropertyChanged("ClientID");
}
}
}
private TopicFilter _currentTopic;
public TopicFilter CurrentTopic
{
get { return _currentTopic; }
set
{
if (_currentTopic!=value)
{
_currentTopic = value;
OnPropertyChanged("CurrentTopic");
}
}
}
private bool? _isConnected=false;
public bool? IsConnected
{
get { return _isConnected; }
set
{
if (_isConnected!=value)
{
_isConnected = value;
OnPropertyChanged("IsConnected");
}
}
}
private bool _isDisConnected=true;
public bool IsDisConnected
{
get { return _isDisConnected; }
set
{
if (_isDisConnected != value)
{
_isDisConnected = value;
this.OnPropertyChanged("IsDisConnected");
}
}
}
private string _userName="admin";
public string UserName
{
get { return _userName; }
set
{
if (_userName != value)
{
_userName = value;
this.OnPropertyChanged("UserName");
}
}
}
private string _password="password";
public string Password
{
get { return _password; }
set
{
if (_password != value)
{
_password = value;
this.OnPropertyChanged("Password");
}
}
}
public event PropertyChangedEventHandler PropertyChanged;
protected virtual void OnPropertyChanged(string propertyName = null)
{
var handler = PropertyChanged;
if (handler != null) handler(this, new PropertyChangedEventArgs(propertyName));
}
}
2.2界面布局
在界面布局上有服务端信息的输入、订阅主题的选择、发布主题的选择、发布内容的输入及状态信息的日志输出。在进行界面布局的同时已经将页面控件与后台数据进行了绑定。代码如下:
<Controls:MetroWindow x:Class="MqttDemo.MetroClient.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:Controls="http://metro.mahapps.com/winfx/xaml/controls"
Title="MQTTClient"
Height="480"
Width="800">
<Controls:MetroWindow.RightWindowCommands>
<Controls:WindowCommands>
<Button x:Name="btnSub" Click="btnSub_Click">订阅</Button>
</Controls:WindowCommands>
</Controls:MetroWindow.RightWindowCommands>
<Controls:MetroWindow.Flyouts>
<Controls:FlyoutsControl>
<Controls:Flyout x:Name="flySub" AnimateOpacity="True" CloseButtonIsCancel="True" IsModal="True" Theme="Light" Position="Right" Header="订阅主题" Width="300">
<Grid>
<Grid.RowDefinitions>
<RowDefinition></RowDefinition>
<RowDefinition Height="60"></RowDefinition>
</Grid.RowDefinitions>
<DataGrid Grid.Row="0" x:Name="dgSub" AutoGenerateColumns="False" ItemsSource="{Binding Path=AllTopics,Mode=TwoWay}" CanUserAddRows="False">
<DataGrid.Columns>
<DataGridTemplateColumn Width="1*">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<WrapPanel HorizontalAlignment="Center" VerticalAlignment="Center">
<CheckBox IsChecked="{Binding IsSelected}"></CheckBox>
</WrapPanel>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<!--<DataGridCheckBoxColumn Binding="{Binding IsSelected}" Width="1*"></DataGridCheckBoxColumn>-->
<DataGridTextColumn Binding="{Binding Topic}" Header="主题" Width="2*"></DataGridTextColumn>
<DataGridTextColumn Binding="{Binding Describe}" Header="描述" Width="2*"></DataGridTextColumn>
</DataGrid.Columns>
</DataGrid>
<WrapPanel Grid.Row="1" VerticalAlignment="Center" HorizontalAlignment="Center">
<Button x:Name="btnSave" Click="btnSave_Click">保存</Button>
</WrapPanel>
</Grid>
</Controls:Flyout>
</Controls:FlyoutsControl>
</Controls:MetroWindow.Flyouts>
<Grid>
<Grid.Resources>
<Style TargetType="TextBox">
<Setter Property="TextAlignment" Value="Center"></Setter>
</Style>
<Style TargetType="TextBlock">
<Setter Property="VerticalAlignment" Value="Center"></Setter>
<Setter Property="HorizontalAlignment" Value="Center"></Setter>
</Style>
</Grid.Resources>
<Grid.RowDefinitions>
<RowDefinition Height="80"></RowDefinition>
<RowDefinition></RowDefinition>
<RowDefinition Height="60"></RowDefinition>
</Grid.RowDefinitions>
<WrapPanel Grid.Row="0" VerticalAlignment="Center" HorizontalAlignment="Center">
<TextBox Width="200" Text="{Binding ServerUri}" Controls:TextBoxHelper.Watermark="IP"></TextBox>
<TextBox Width="80" Text="{Binding ServerPort}" Controls:TextBoxHelper.Watermark="Port" Margin="5,0,0,0"></TextBox>
<Button x:Name="btnStart" Click="btnStart_Click" IsEnabled="{Binding IsDisConnected}" Margin="10,0">连接</Button>
<Button x:Name="btnStop" Click="btnStop_Click" IsEnabled="{Binding IsConnected}">断开</Button>
</WrapPanel>
<RichTextBox x:Name="txtRich" Grid.Row="1" Margin="10"></RichTextBox>
<WrapPanel Grid.Row="2" VerticalAlignment="Center" HorizontalAlignment="Center">
<ComboBox x:Name="comboTopics" ItemsSource="{Binding Path=AllTopics,Mode=TwoWay}" DisplayMemberPath="Topic" SelectedValuePath="Topic" Width="120"></ComboBox>
<TextBox x:Name="txtContent" Width="240" Margin="10,0"></TextBox>
<Button x:Name="btnPublish" Click="btnPublish_Click">发布</Button>
</WrapPanel>
</Grid>
</Controls:MetroWindow>
界面呈现效果如下:
2.3逻辑实现
逻辑实现包含基本的数据管理和界面触发事件。
MQTT基本方法
/// <summary>
/// 初始化
/// </summary>
/// <param name="id"></param>
/// <param name="url"></param>
/// <param name="port"></param>
private void InitClient(string id,string url = "127.0.0.1", int port = 1883)
{
var options = new MqttClientOptions()
{
ClientId = id
};
options.ChannelOptions = new MqttClientTcpOptions()
{
Server = url,
Port = port
};
options.Credentials = new MqttClientCredentials()
{
Username=_model.UserName,
Password=_model.Password
};
options.CleanSession = true;
options.KeepAlivePeriod = TimeSpan.FromSeconds(100);
options.KeepAliveSendInterval = TimeSpan.FromSeconds(10000);
if (_client != null)
{
_client.DisconnectAsync();
_client = null;
}
_client = new MQTTnet.MqttFactory().CreateMqttClient();
_client.ApplicationMessageReceived += _client_ApplicationMessageReceived;
_client.Connected += _client_Connected;
_client.Disconnected += _client_Disconnected;
_client.ConnectAsync(options);
}
/// <summary>
/// 客户端与服务端断开连接
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void _client_Disconnected(object sender, MqttClientDisconnectedEventArgs e)
{
_model.IsConnected = false;
_model.IsDisConnected = true;
WriteToStatus("与服务端断开连接!");
}
/// <summary>
/// 客户端与服务端建立连接
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void _client_Connected(object sender, MqttClientConnectedEventArgs e)
{
_model.IsConnected = true;
_model.IsDisConnected = false;
WriteToStatus("与服务端建立连接");
}
/// <summary>
/// 客户端收到消息
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void _client_ApplicationMessageReceived(object sender, MQTTnet.MqttApplicationMessageReceivedEventArgs e)
{
WriteToStatus("收到来自客户端" + e.ClientId + ",主题为" + e.ApplicationMessage.Topic + "的消息:" + Encoding.UTF8.GetString(e.ApplicationMessage.Payload));
}
数据初始化及绑定
private MainWindowModel _model;
private IMqttClient _client;
public MainWindow()
{
InitializeComponent();
_model = new MainWindowModel()
{
AllTopics = InitTopics(),
SelectedTopics = new List<TopicFilter>(),
ServerUri = "127.0.0.1",
CurrentTopic = null,
ServerPort=61613,
ClientID = Guid.NewGuid().ToString("N")
};
this.DataContext = _model;
}
/// <summary>
/// 数据初始化
/// </summary>
/// <returns></returns>
private List<TopicModel> InitTopics()
{
List<TopicModel> topics = new List<TopicModel>();
topics.Add(new TopicModel("/environ/temp","环境-温度"));
topics.Add(new TopicModel("/environ/hum","环境-湿度"));
topics.Add(new TopicModel("/data/alarm", "数据-报警"));
topics.Add(new TopicModel("/data/message", "数据-消息"));
return topics;
}
/// <summary>
/// 数据模型转换
/// </summary>
/// <param name="topics"></param>
/// <returns></returns>
private List<TopicFilter> ConvertTopics(List<TopicModel> topics)
{
//MQTTnet.TopicFilter
List<TopicFilter> filters = new List<TopicFilter>();
foreach (TopicModel model in topics)
{
TopicFilter filter = new TopicFilter(model.Topic,MQTTnet.Protocol.MqttQualityOfServiceLevel.AtLeastOnce);
filters.Add(filter);
}
return filters;
}
服务端连接与断开
/// <summary>
/// 连接服务端
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void btnStart_Click(object sender, RoutedEventArgs e)
{
if (_model.ServerUri!=null&&_model.ServerPort>0)
{
InitClient(_model.ClientID, _model.ServerUri, _model.ServerPort);
}
else
{
ShowDialog("提示", "服务端地址或端口号不能为空!");
}
}
/// <summary>
/// 断开服务端
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void btnStop_Click(object sender, RoutedEventArgs e)
{
if (_client != null)
{
_client.DisconnectAsync();
}
}
订阅主题
/// <summary>
/// 打开订阅主题面板
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void btnSub_Click(object sender, RoutedEventArgs e)
{
this.flySub.IsOpen = !this.flySub.IsOpen;
}
/// <summary>
/// 保存订阅的主题
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void btnSave_Click(object sender, RoutedEventArgs e)
{
List<TopicModel> topics = _model.AllTopics.Where(t => t.IsSelected == true).ToList();
_model.SelectedTopics = ConvertTopics(topics);
this.flySub.IsOpen = !this.flySub.IsOpen;
SubscribeTopics(_model.SelectedTopics);
}
private void SubscribeTopics(List<TopicFilter> filters)
{
if (_client!=null)
{
_client.SubscribeAsync(filters);
string tmp = "";
foreach (var filter in filters)
{
tmp += filter.Topic;
tmp += ",";
}
if (tmp.Length>1)
{
tmp = tmp.Substring(0, tmp.Length - 1);
}
WriteToStatus("成功订阅主题:"+tmp);
}
else
{
ShowDialog("提示", "请连接服务端后订阅主题!");
}
}
发布主题
/// <summary>
/// 发布主题
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void btnPublish_Click(object sender, RoutedEventArgs e)
{
if (_client!=null)
{
if (this.comboTopics.SelectedIndex<0)
{
ShowDialog("提示", "请选择要发布的主题!");
return;
}
if (string.IsNullOrEmpty(txtContent.Text))
{
ShowDialog("提示", "消息内容不能为空!");
return;
}
string topic = comboTopics.SelectedValue as string;
string content = txtContent.Text;
MqttApplicationMessage msg = new MqttApplicationMessage
{
Topic=topic,
Payload=Encoding.UTF8.GetBytes(content),
QualityOfServiceLevel=MQTTnet.Protocol.MqttQualityOfServiceLevel.AtLeastOnce,
Retain=false
};
_client.PublishAsync(msg);
WriteToStatus("成功发布主题为" + topic+"的消息!");
}
else
{
ShowDialog("提示", "请连接服务端后发布消息!");
return;
}
}
2.4辅助功能
辅助功能包括状态信息输出到界面和提示框显示。
/// <summary>
/// 状态输出
/// </summary>
/// <param name="message"></param>
public void WriteToStatus(string message)
{
if (!(txtRich.CheckAccess()))
{
this.Dispatcher.Invoke(() =>
WriteToStatus(message)
);
return;
}
string strTime = "[" + System.DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + "] ";
txtRich.AppendText(strTime + message + "\r");
}
/// <summary>
/// 提示框
/// </summary>
/// <param name="title"></param>
/// <param name="content"></param>
private void ShowDialog(string title, string content)
{
var mySetting = new MetroDialogSettings()
{
AffirmativeButtonText = "确定",
//NegativeButtonText = "Go away!",
//FirstAuxiliaryButtonText = "Cancel",
ColorScheme = this.MetroDialogOptions.ColorScheme
};
MessageDialogResult result = this.ShowModalMessageExternal(title, content, MessageDialogStyle.Affirmative, mySetting);
}
3.测试实例
经过上一步的过程,一个功能相对完备的MQTT客户端已经搭建完成,下面就试试客户端与服务端联通的效果。启动Apollo服务端实例并打开管理界面,为了体现MQTT特性打开三个客户端实例。
在客户端上输入服务端的IP地址和端口号,并点击“连接”按钮,可以在服务端的管理界面中看到客户端列表刷新。
在客户端中共初始化了三个不同的主题,三个客户端分别选择3、2、1个主题进行订阅,点击订阅面板中的“保存”按钮,可以看到服务端的管理界面中主题列表刷新了。
在任意客户端中选择任一主题并输入要发布的内容,点击“发布”按钮,发现只有订阅了相应主题的客户端才会收到内容,同时在服务端的管理界面也能看到数据传输的状态变化。
以上测试实例说明了客户端功能正常,符合了最初的需求,也体现了MQTT协议的特性。
不断深入,精益求精,才能有所收获。