MQTT学习(五)--使用MQTTNet在WPF框架下创建MQTT服务端(broker)

在前面几篇文章中实践了如何搭建服务端(broker)以及如何在不同类型的应用中实现MQTT客户端,但是回过头来看看,用Apache Apollo搭建的服务端功能固然强大,但无法将其融入到自有业务系统的代码中,尤其是想更加灵活方便的在业务系统中利用MQTT协议的特性时,那么是否能够构建一个自己的MQTTServer呢?
今天就来试试用MQTTNet构建一个WPF版的MQTTServer。


1.需求分析

首先要确认下这个服务端要实现哪些功能,参考Apache Apollo的后台管理界面,确定了几个简单的需求:

  1. MQTT Server的基本配置(IP地址、端口号等)
  2. 客户端连接状态监测
  3. 订阅主题状态监测
  4. 数据传输监测

2.需求实现

此次MQTTServer 的实现将采用MQTTNet类库,之前曾用这个类库实现过MQTTClient,相关方法有所了解,开发速度会快些。

2.1 数据结构

有了之前编写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;
        /// <summary>
        /// 订阅此主题的客户端数量
        /// </summary>
        public int Count
        {
            get { return count; }
            set
            {
                if (count != value)
                {
                    count = value;
                    this.OnPropertyChanged("Count");
                }
            }
        }

        private List<string> clients;
        /// <summary>
        /// 订阅此主题的客户端
        /// </summary>
        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。

2.2 页面布局

与数据结构相对应,编写前台页面代码用来呈现数据并实现交互。

<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">
                            <Setter Property="VerticalAlignment" Value="Center"></Setter>
                            <Setter Property="HorizontalAlignment"  Value="Right"></Setter>
                        </Style>
                        <Style TargetType="TextBox">
                            <Setter Property="VerticalAlignment" Value="Center"></Setter>
                            <Setter Property="HorizontalAlignment"  Value="Center"></Setter>
                            <Setter Property="Height" Value="30"></Setter>
                            <Setter Property="Width" Value="100"></Setter>
                            <Setter Property="VerticalContentAlignment" Value="Center"></Setter>
                        </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>

从页面上大体分为四部分,分别用于服务端启停控制、客户端状态监测、主题状态监测和数据传输监测,另有一个隐藏的配置页面。页面呈现效果如下:
在这里插入图片描述

2.3 代码实现
数据初始化及绑定
        private MainWindowModel _model;
        public MainWindow()
        {
            InitializeComponent();
            _model = new MainWindowModel();
            this.DataContext = _model;
        }

MQTTServer初始化及生命周期事件

关键的部分在于构建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

MQTTServer客户端连接相关事件
        #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

MQTTServer 主题相关事件
        #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 消息接收事件

由于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

3. 测试实例

现在有了自定义的客户端和自定义的服务端,就来测试下效果如何吧。

在这里插入图片描述

4.后续

从测试的情况来看,最初的需求都已实现。在此基础上就可以根据自己的业务需求不断扩展完善了。
但在后续的测试中也发现了一些问题,就是在默认的配置中只考虑了TCP协议,没有考虑WebSocket协议,导致Web版的MQTT客户端连接失败,这也是后期要解决的问题。


保持乐趣,不断尝试!

源代码地址

  • 3
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 10
    评论
评论 10
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值