MQTT学习(二)--使用MQTTNet在WPF框架下搭建MQTT客户端

在上篇文章中利用Apollo创建了MQTT服务端,但仅有一个服务端是没有意义的,只有将服务端和客户端结合起来使用才能发挥MQTT协议的特性,所以本篇的内容是创建MQTT客户端。由于本人对.Net平台相对熟悉,所以将使用MQTTNet类库结合WPF创建一个客户端。

MQTT学习(一)–使用Apache Apollo在Windows环境下搭建MQTT服务器


1.需求分析

MQTT协议的基本特性是使用发布/订阅消息模式,提供一对多的消息发布,解除应用程序耦合,同时基于TCP/IP能够提供多种类型的网络传输模式。
为了能够体现MQTT协议的特性,同时也为了能够有一些简单的操作交互和直观的界面体现,对这个客户端提出了几个简单的需求。

  1. 订阅消息主题的管理
  2. 发布消息主题的管理
  3. 客户端基本信息的管理
  4. 状态数据变化的输出显示

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>
                            
                            <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">
                "TextAlignment" Value="Center">
            Style>
            <Style TargetType="TextBlock">
                "VerticalAlignment" Value="Center">
                "HorizontalAlignment" Value="Center">
            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>

界面呈现效果如下:
MQTT学习(二)--使用MQTTNet在WPF框架下搭建MQTT客户端_第1张图片
MQTT学习(二)--使用MQTTNet在WPF框架下搭建MQTT客户端_第2张图片

2.3逻辑实现

逻辑实现包含基本的数据管理和界面触发事件。

MQTT基本方法
        /// 
        /// 初始化
        /// 
        /// 
        /// 
        /// 
        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);
        }

        /// 
        /// 客户端与服务端断开连接
        /// 
        /// 
        /// 
        private void _client_Disconnected(object sender, MqttClientDisconnectedEventArgs e)
        {
            _model.IsConnected = false;
            _model.IsDisConnected = true;
            WriteToStatus("与服务端断开连接!");
        }

        /// 
        /// 客户端与服务端建立连接
        /// 
        /// 
        /// 
        private void _client_Connected(object sender, MqttClientConnectedEventArgs e)
        {
            _model.IsConnected = true;
            _model.IsDisConnected = false;
            WriteToStatus("与服务端建立连接");
        }
        /// 
        /// 客户端收到消息
        /// 
        /// 
        /// 
        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;
        }
        /// 
        /// 数据初始化
        /// 
        /// 
        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;
        }
        /// 
        /// 数据模型转换
        /// 
        /// 
        /// 
        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;
        }

服务端连接与断开
        /// 
        /// 连接服务端
        /// 
        /// 
        /// 
        private void btnStart_Click(object sender, RoutedEventArgs e)
        {
            if (_model.ServerUri!=null&&_model.ServerPort>0)
            {
                InitClient(_model.ClientID, _model.ServerUri, _model.ServerPort);
            }
            else
            {
                ShowDialog("提示", "服务端地址或端口号不能为空!");
            }
        }
        /// 
        /// 断开服务端
        /// 
        /// 
        /// 
        private void btnStop_Click(object sender, RoutedEventArgs e)
        {
            if (_client != null)
            {
                _client.DisconnectAsync();
            }
        }

订阅主题
        /// 
        /// 打开订阅主题面板
        /// 
        /// 
        /// 
        private void btnSub_Click(object sender, RoutedEventArgs e)
        {
            this.flySub.IsOpen = !this.flySub.IsOpen;
        }
        /// 
        /// 保存订阅的主题
        /// 
        /// 
        /// 
        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("提示", "请连接服务端后订阅主题!");
            }
        }

发布主题
        /// 
        /// 发布主题
        /// 
        /// 
        /// 
        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辅助功能

辅助功能包括状态信息输出到界面和提示框显示。

        /// 
        /// 状态输出
        /// 
        /// 
        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");
        }
        /// 
        /// 提示框
        /// 
        /// 
        /// 
        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地址和端口号,并点击“连接”按钮,可以在服务端的管理界面中看到客户端列表刷新。
MQTT学习(二)--使用MQTTNet在WPF框架下搭建MQTT客户端_第3张图片

在客户端中共初始化了三个不同的主题,三个客户端分别选择3、2、1个主题进行订阅,点击订阅面板中的“保存”按钮,可以看到服务端的管理界面中主题列表刷新了。
MQTT学习(二)--使用MQTTNet在WPF框架下搭建MQTT客户端_第4张图片

在任意客户端中选择任一主题并输入要发布的内容,点击“发布”按钮,发现只有订阅了相应主题的客户端才会收到内容,同时在服务端的管理界面也能看到数据传输的状态变化。
MQTT学习(二)--使用MQTTNet在WPF框架下搭建MQTT客户端_第5张图片

以上测试实例说明了客户端功能正常,符合了最初的需求,也体现了MQTT协议的特性。


不断深入,精益求精,才能有所收获。

源代码地址

你可能感兴趣的:(WPF,MQTT学习实践)