C# TCP多线程服务器示例

偶尔善良 提交于 2020-03-14 21:54:49

前言

之前一直很少接触多线程这块。这次项目中刚好用到了网络编程TCP这块,做一个服务端,需要使用到多线程,所以记录下过程。希望可以帮到自己的同时能给别人带来一点点收获~

关于TCP的介绍就不多讲,神马经典的三次握手、四次握手,可以参考下面几篇博客学习了解:

TCP三次握手扫盲

效果预览

客户端是一个门禁设备,主要是向服务端发送实时数据(200ms)。服务端解析出进出人数并打印显示。

 

实现步骤

因为主要是在服务器上监听各设备的连接请求以及回应并打印出入人数,所以界面我设计成这样:

可以在窗体事件中绑定本地IP,代码如下:

       //获取本地的IP地址
            string AddressIP = string.Empty;
            foreach (IPAddress _IPAddress in Dns.GetHostEntry(Dns.GetHostName()).AddressList)
            {
                if (_IPAddress.AddressFamily.ToString() == "InterNetwork")
                {
                    AddressIP = _IPAddress.ToString();
                }
            }
            //给IP控件赋值
            txtIp.Text = AddressIP;

首先我们需要定义几个全局变量

Thread threadWatch = null; // 负责监听客户端连接请求的 线程;
Socket socketWatch = null;Dictionary<string, Socket> dict = new Dictionary<string, Socket>();//存放套接字Dictionary<string, Thread> dictThread = new Dictionary<string, Thread>();//存放线程

然后可以开始我们的点击事件启动服务啦

首先我们创建负责监听的套接字,用到了 System.Net.Socket 下的寻址方案AddressFamily ,然后后面跟套接字类型,最后是支持的协议。

 在Bind绑定后,我们创建了负责监听的线程。代码如下:

       // 创建负责监听的套接字,注意其中的参数;
            socketWatch = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
            // 获得文本框中的IP对象;
            IPAddress address = IPAddress.Parse(txtIp.Text.Trim());
            // 创建包含ip和端口号的网络节点对象;
            IPEndPoint endPoint = new IPEndPoint(address, int.Parse(txtPort.Text.Trim()));
            try
            {
                // 将负责监听的套接字绑定到唯一的ip和端口上;
                socketWatch.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
                socketWatch.Bind(endPoint);
            }
            catch (SocketException se)
            {
                MessageBox.Show("异常:" + se.Message);
                return;
            }
            // 设置监听队列的长度;
            socketWatch.Listen(10000);
            // 创建负责监听的线程;
            threadWatch = new Thread(WatchConnecting);
            threadWatch.IsBackground = true;
            threadWatch.Start();
            ShowMsg("服务器启动监听成功!");

其中 WatchConnecting方法是负责监听新客户端请求的

相信图片中注释已经很详细了,主要是监听到有客户端的连接请求后,开辟一个新线程用来接收客户端发来的数据,有一点比较重要就是在Start方法中传递了当前socket对象

     /// <summary>
        /// 监听客户端请求的方法;
        /// </summary>
        void WatchConnecting()
        {
            ShowMsg("新客户端连接成功!");
            while (true)  // 持续不断的监听客户端的连接请求;
            {
                // 开始监听客户端连接请求,Accept方法会阻断当前的线程;
                Socket sokConnection = socketWatch.Accept(); // 一旦监听到一个客户端的请求,就返回一个与该客户端通信的 套接字;
                var ssss = sokConnection.RemoteEndPoint.ToString().Split(':');
                //查找ListBox集合中是否包含此IP开头的项,找到为0,找不到为-1
                if (lbOnline.FindString(ssss[0]) >= 0)
                {
                    lbOnline.Items.Remove(sokConnection.RemoteEndPoint.ToString());
                }
                else
                {
                    lbOnline.Items.Add(sokConnection.RemoteEndPoint.ToString());
                }
                // 将与客户端连接的 套接字 对象添加到集合中;
                dict.Add(sokConnection.RemoteEndPoint.ToString(), sokConnection);
                Thread thr = new Thread(RecMsg);
                thr.IsBackground = true;
                thr.Start(sokConnection);
                dictThread.Add(sokConnection.RemoteEndPoint.ToString(), thr);  //  将新建的线程 添加 到线程的集合中去。
            }
        }

其中接收数据 RecMsg方法如下:

解释如图,一目了然,代码如下

 void RecMsg(object sokConnectionparn)
        {
            Socket sokClient = sokConnectionparn as Socket;
            while (true)
            {
                // 定义一个缓存区;
                byte[] arrMsgRec = new byte[1024];
                // 将接受到的数据存入到输入  arrMsgRec中;
                int length = -1;
                try
                {
                    length = sokClient.Receive(arrMsgRec); // 接收数据,并返回数据的长度;
                    if (length > 0)
                    {
                        //主业务

                     }
                    else
                    { 
                        // 从 通信套接字 集合中删除被中断连接的通信套接字;
                        dict.Remove(sokClient.RemoteEndPoint.ToString());
                        // 从通信线程集合中删除被中断连接的通信线程对象;
                        dictThread.Remove(sokClient.RemoteEndPoint.ToString());
                        // 从列表中移除被中断的连接IP
                        lbOnline.Items.Remove(sokClient.RemoteEndPoint.ToString());
                        ShowMsg("" + sokClient.RemoteEndPoint.ToString() + "断开连接\r\n");
                        //log.log("遇见异常"+se.Message);
                        break;
                    }
                }
                catch (SocketException se)
                {
                    // 从 通信套接字 集合中删除被中断连接的通信套接字;
                    dict.Remove(sokClient.RemoteEndPoint.ToString());
                    // 从通信线程集合中删除被中断连接的通信线程对象;
                    dictThread.Remove(sokClient.RemoteEndPoint.ToString());
                    // 从列表中移除被中断的连接IP
                    lbOnline.Items.Remove(sokClient.RemoteEndPoint.ToString());
                    ShowMsg("" + sokClient.RemoteEndPoint.ToString() + "断开,异常消息:" + se.Message + "\r\n");
                    //log.log("遇见异常"+se.Message);
                    break;
                }
                catch (Exception e)
                {
                    // 从 通信套接字 集合中删除被中断连接的通信套接字;
                    dict.Remove(sokClient.RemoteEndPoint.ToString());
                    // 从通信线程集合中删除被中断连接的通信线程对象;
                    dictThread.Remove(sokClient.RemoteEndPoint.ToString());
                    // 从列表中移除被中断的连接IP
                    lbOnline.Items.Remove(sokClient.RemoteEndPoint.ToString());
                    ShowMsg("异常消息:" + e.Message + "\r\n");
                    // log.log("遇见异常" + e.Message);
                    break;
                }
            }
        }                    

其中那个ShowMsg方法主要是在窗体中打印当前接收情况和一些异常情况,方法如下:

     void ShowMsg(string str)
        {
            if (!BPS_Help.ChangeByte(txtMsg.Text, 2000))
            {
                txtMsg.Text = "";
                txtMsg.AppendText(str + "\r\n");
            }
            else
            {
                txtMsg.AppendText(str + "\r\n");
            }

        }

其中用到了一个方法判断ChangeByte ,如果文本长度超过2000个字节,就清空再重新赋值。具体实现如下:

     /// <summary>
        /// 判断文本框混合输入长度
        /// </summary>
        /// <param name="str">要判断的字符串</param>
        /// <param name="i">长度</param>
        /// <returns></returns>
        public static bool ChangeByte(string str, int i)
        {
            byte[] b = Encoding.Default.GetBytes(str);
            int m = b.Length;
            if (m < i)
            {
                return true;
            }
            else
            {
                return false;
            }
        }

 

 心得体会:其实整个流程并不复杂,但我遇到一个问题是,客户端每200毫秒发一次连接过来后,服务端会报一个远程主机已经强制关闭连接,开始我以为是我这边服务器线程间的问题或者是阻塞神马的,后来和客户端联调才发现问题,原来是服务器回应客户端心跳包的长度有问题,服务端定义的是1024字节,但是客户端只接受32字节的心跳包回应才会正确解析~所以,对接协议要沟通清楚,沟通清楚,沟通清楚,重要的事情说说三遍 

还有几个点值得注意

1,有时候会遇到窗体间的控件访问异常,需要这样处理

Control.CheckForIllegalCrossThreadCalls = false;

2 多线程调试比较麻烦,可以采用打印日志的方式,例如:

具体实现可以参考我的另一篇博客:点我跳转

3 ,接收解析客户端数据的时候,要注意大小端的问题,比如下面这个第9位和第8位如果解出来和实际不相符,可以把两边颠倒一下。

     public int Get_ch2In(byte[] data)
        {
            var ch2In = (data[9] << 8) | data[8];
            return ch2In;
        }

4 在接收到客户端数据的时候,有些地方要注意转换成十六进制再看结果是否正确

 public int Get_ch3In(byte[] data)
        {
            int ch3In = 0;
            for (int i = 12; i < 14; i++)
            {
                ch3In = int.Parse(ch3In + BPS_Help.HexOf(data[i]));
            }
            return ch3In;
        }

上面这个方法在对data[i]进行了十六进制的转换,转换方法如下:

     /// <summary>
        /// 转换成十六进制数
        /// </summary>
        /// <param name="AscNum"></param>
        /// <returns></returns>
        public static string HexOf(int AscNum)
        {
            string TStr;
            if (AscNum > 255)
            {
                AscNum = AscNum % 256;
            }
            TStr = AscNum.ToString("X");
            if (TStr.Length == 1)
            {
                TStr = "0" + TStr;
            }
            return TStr;
        }

5 还有个可以了解的是将数组转换成结构,参考代码如下:

 /// <summary>
        /// Byte数组转结构体
        /// </summary>
        /// <param name="bytes">byte数组</param>
        /// <param name="type">结构体类型</param>
        /// <returns>转换后的结构体</returns>
        public static object BytesToStuct(byte[] bytes, Type type)
        {
            //得到结构体的大小
            int size = Marshal.SizeOf(type);
            //byte数组长度小于结构体的大小
            if (size > bytes.Length)
            { return null; }
            IntPtr structPtr = Marshal.AllocHGlobal(size);
            Marshal.Copy(bytes, 0, structPtr, size);
            object obj = Marshal.PtrToStructure(structPtr, type);
            //释放内存空间
            Marshal.FreeHGlobal(structPtr);
            return obj;
        }

调用方法如下,注意,此处的package的结构应该和协议中客户端发送的数据结构一致才能转换

如协议中是这样的定义的话:

那在代码中就可以这样定义一个package结构体

  /// <summary>
    /// 数据包结构体
    /// </summary>
    [StructLayoutAttribute(LayoutKind.Sequential, CharSet = CharSet.Ansi, Pack = 1)]
    public struct Package
    {
        /// <summary>
        /// 确定为命令包的标识
        /// </summary>
        public int commandFlag;
        /// <summary>
        /// 命令
        /// </summary>
        public int command;
        /// <summary>
        ///数据长度(数据段不包括包头)
        /// </summary>
        public int dataLength;
        /// <summary>
        /// 通道编号
        /// </summary>
        public short channelNo;
        /// <summary>
        /// 块编号
        /// </summary>
        public short blockNo;
        /// <summary>
        /// 开始标记
        /// </summary>
        public int startFlag;
        /// <summary>
        /// 结束标记0x0D0A为结束符
        /// </summary>
        public int finishFlag;
        /// <summary>
        /// 校验码
        /// </summary>
        public int checksum;
        /// <summary>
        /// 保留 char数组,SizeConst表示数组个数,在转成
        /// byte数组前必须先初始化数组,再使用,初始化
        /// 的数组长度必须和SizeConst一致,例:test=new char[4];
        /// </summary>
        [MarshalAs(UnmanagedType.ByValArray, SizeConst = 4)]
        public char[] reserve;
    }

Demo下载

 TCP多线程服务器及客户端Demo

 点我跳去下载  密码:3hzs

 git一下:我要去Git 

 

收发实体对象

2017.3.11 补充

如果服务器和客户端公用一个实体类,那还好说,如果服务器和客户端分别使用结构相同但不是同一个项目下的实体类,该如何用正确的姿势收发呢?

首先简单看看效果如下:

 

 具体实现:

因为前面提到不在同一项目下,如果直接序列化和反序列化,就会反序列化失败,因为不能对不是同一命名空间下的类进行此类操作,那么解决办法可以新建一个类库Studnet,然后重新生成dll,在服务器和客户端分别引用此dll,就可以对此dll进行序列化和反序列化操作了。

项目结构如下图(这里是作为演示,将客户端和服务器放在同一解决方案下,实际上这种情况解决的就是客户端和服务器是两个单独的解决方案

客户端发送核心代码:

void showClient()
        {
            address = IPAddress.Parse("127.0.0.1");
            endpoint = new IPEndPoint(address, 5000);
            socketClient = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
            try
            {
                socketClient.Connect(endpoint);
                Console.WriteLine("连接服务端成功\r\n准备发送实体Student");
                Student.Studnet_entity ms = new Student.Studnet_entity() { ID = 1, Name = "张三", Phone = "13237157517", sex = 1, Now_Time = DateTime.Now };
                using (MemoryStream memory = new MemoryStream())
                {
                    BinaryFormatter formatter = new BinaryFormatter();
                    formatter.Serialize(memory, ms);
                    Console.WriteLine("发送长度:" + memory.ToArray().Length);
                    socketClient.Send(memory.ToArray());
                    Console.WriteLine("我发送了 学生实体对象\r\n");
                }
            }
            catch (Exception)
            {

                throw;
            }
        }

服务端接收并解析实体对象核心代码

      /// <summary>  
        /// 服务端负责监听客户端发来的数据方法  
        /// </summary>  
        void RecMsg(object socketClientPara)
        {

            byte[] arrMsgRec = new byte[1024];//手动准备空间  
            Socket socketClient = socketClientPara as Socket;
            List<byte> listbyte = new List<byte>();
            while (true)
            {
                //将接受到的数据存入arrMsgRec数组,并返回真正接收到的数据长度  
                int length = socketClient.Receive(arrMsgRec);
                if (length > arrMsgRec.Length)
                {
                    listbyte.AddRange(arrMsgRec);
                }
                else
                {
                    for (int i = 0; i < length; i++)
                        listbyte.Add(arrMsgRec[i]);
                    break;
                }
            }
            //创建内存流
            using (MemoryStream m = new MemoryStream(listbyte.ToArray()))
            {
                //创建以二进制格式对对象进行序列化和反序列化
                BinaryFormatter bf = new BinaryFormatter();
                Console.WriteLine("m.length" + m.ToArray().Length);
                //反序列化
                object dataObj = bf.Deserialize(m);
                //得到解析后的实体对象
                Student.Studnet_entity dt = dataObj as Studnet_entity;
                Console.WriteLine("接收客户端长度:" + listbyte.Count + " 反序列化结果是:ID:" + dt.ID +
                    " 姓名:" + dt.Name + " 当前时间:" + dt.Now_Time);

            }
        }

收发实体对象Demo

点我前去下载Demo   密码:x2ke

 

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!