【黑马程序员】Socket 网络编程

来源:互联网 发布:微信投票系统源码 编辑:程序博客网 时间:2024/05/22 10:46

---------------------- Windows Phone 7手机开发、.Net培训、期待与您交流! ---------------------- 

IP与端口:在网络通信中,TCP/IP协议通过IP地址加上端口(Port)号来标识某一台主机上的某个应用程序。

Socket:即插座或套接字,是一种进程通信机制,用于描述IP地址和端口,是一个通信链的句柄。其实就是两个程序通信用的。两个应用程序之间要进行网络通信,都得申请一个socket。在Internet上有很多这样的主机,这些主机一般运行了多个服务软件,同时提供几种服务,每种服务都打开一个Socket,并绑定到一个端口上,不同的端口对应于不同的服务。如http使用80端口,ftp使用21端口,smtp使用23端口。socket有两种类型:基于流式(stream)的,面向连接,即时,安全但是效率低;基于报文的(datagram),是一种无连接的Socket,对应于无连接的UDP服务应用,不安全(丢失,顺序混乱,在接收端要分析重排及要求重发),但效率高。

我们主要学的是基于流式的socket。在建立连接时,客户端和服务器端要进行Three-Way Handshake: 服务端有一个负责接待的套接字在那里监听客户端的连接请求,当成功接收到一个客户端的连接后,服务端就产生一个对应的socket负责和客户端通信,然后负责接待的socket继续监听下一个客户端连接请求。客户端Socket必须指定要连接的服务器端的接待套接字的IP地址和端口,所以服务器端的接待Socket必须绑定到一个IP地址和端口上。

无论是客户端socket还是服务器端的接待socket或通信socket,都是System.Net.Socket类的对象,只是各自所调用的方法不同而已。还涉及到的IPAddress、IPEndPoint以及EndPoint类是System.Net命名空间里面的。一个IPEndPoint只能为一个Socket所用,EndPoint是IPEndPoint的基类。

当服务器端开启监听客户端连接请求时,首先创建一个用于监听的Socket对象,绑定到本机上的某个端口上,绑定成功后,设置监听队列,然后就用一个单独的线程来负责监听客户端连接请求。代码如下:

        private Dictionary<EndPoint, Socket> sockets = new Dictionary<EndPoint, Socket>();        private void btnStart_Click(object sender, EventArgs e)        {            //创建监听Socket对象。            Socket welcome = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);            try            {                //从IP地址框读取IP地址,从端口输入框读取端口号。                string ipStr = txtIP.Text.Trim();                string portStr = txtPort.Text;                IPAddress ip;                int port;                //用IPAddress.TryParse方法将字符串解析成IP地址对象。                if (!IPAddress.TryParse(ipStr, out ip))                    throw new Exception("IP地址格式不正确!");                if (!Int32.TryParse(portStr, out port))                    throw new Exception("端口格式不正确!");                //用IPAddress和端口号初始化一个IPEndPoint对象即网络端点。                IPEndPoint acceptPort = new IPEndPoint(ip, port);                //将监听Socket绑定到这个IPEndPoint上。                welcome.Bind(acceptPort);            }            catch (Exception ex)            {                MessageBox.Show("IP地址或端口号不正确,无法初始化服务器监听端口\n" + ex.Message);                return;            }            //将侦听ocket置于侦听状态,挂起连接队列的最大长度设为10.            welcome.Listen(10);            //开一个新线程来进行侦听,设为后台线程。            Thread thread = new System.Threading.Thread(new ParameterizedThreadStart(Welcome));            thread.IsBackground = true;            //启动线程,将侦听socket作为参数传给线程执行的方法。            thread.Start(welcome);            //在事件框中显示事件消息。            txtEvents.AppendText("启动监听成功~~\r\n");        }        private void Welcome(object obj)        {            Socket waiter = obj as Socket;            //用一个无限循环来不停的侦听客户端连接请求。            while (true)            {                //没有连接请求,Accept()方法一直不结束,直到接受了一个连接请求才返回一个新的Socket实例与对方Socket进行通信。                Socket skComm = waiter.Accept();                txtEvents.AppendText(skComm.RemoteEndPoint+" 已连接.\r\n");                //将客户端EndPoint和与之通信的Socket加入全局变量Dictionary<EndPoint, Socket> sockets中,后续将会用到。                sockets.Add(skComm.RemoteEndPoint, skComm);                //用一个新线程来和客户端通信。                Thread thread = new Thread(Receiving);                thread.IsBackground = true;                thread.Start(skComm);            }        }

客户端连接服务器:

       Socket skConn;        bool isConnected;        private void btnLogin_Click(object sender, EventArgs e)        {            if (isConnected)            {                MessageBox.Show("已经连接,不可重复连接。");                return;            }            skConn = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);            //从配置文件读取服务器ip地址和端口号。            ConfigurationManager.RefreshSection("appSettings");            string ipStr = ConfigurationManager.AppSettings["ip"].Trim();            IPAddress ip;            if (!IPAddress.TryParse(ipStr, out ip))            {                MessageBox.Show("IP地址格式不正确,请检查配置文件。");                return;            }            string portStr = ConfigurationManager.AppSettings["port"];            int port;            if (!int.TryParse(portStr,out port))            {                MessageBox.Show("端口号格式不正确,请检查配置文件。");                return;            }            //初始化服务器侦听Socket的IPEndPoint            IPEndPoint server = new IPEndPoint(ip, port);            try            {                //调用Connect方法连接服务器。                skConn.Connect(server);                isConnected = true;                System.Threading.Thread threadRec = new System.Threading.Thread(Receiving);                threadRec.IsBackground = true;                threadRec.Start();            }            catch (Exception ex)            {                MessageBox.Show("连接失败。" + ex.Message);                skConn.Dispose();            }        }

客户端Socket的连接请求被服务器端Accept后,就可以和所产生的服务器端通信Socket进行通信了,通过Send和Receive方法互相发送并接收消息或文件。数据在网络上是通过字节流传输的,即一个byte[]数组。所以发送之前要先把消息字符串或文件转换成byte[],然后发送出去,接收方要用一个byte[]数组来接收发过来的东东,然后再解码成字符串或写入文件。因为文件本质上就是字节组成的,不同的编码规则,构成了各种格式的文件,通过FileStream.Read方法可直接将文件中的字节读入byte[]数组,FileStream.Write方法则可以直接将byte[]数组中的字节写入文件。 字符串要转换成字节流需要System.Text.Encoding.UTF8.GetBytes(string s)方法,反之需要GetString方法。

客户端发送消息,我自定义的消息格式为“1接收者|消息内容”,其中第一个数字1标记此字节数组是消息。我这个聊天室支持群发和两人之间的悄悄话。无论是群发还是悄悄话,都是先将消息发给服务器,因为客户端和客户端之间没有socket通信。消息内容前面的接收者指定了要发送的对象的IPEndPoint,群发的话就是all. 接收者是从在线列表中选定的。

private void btnSend_Click(object sender, EventArgs e)        {            if (!isConnected)            {                MessageBox.Show("您未登录,不能发送消息。");                return;            }            if (string.IsNullOrEmpty(rtxtMsg.Text))            {                MessageBox.Show("发送内容不能为空");                return;            }            //将消息输入框中的字符串转码成字节流。            Byte[] byteArray = Encoding.UTF8.GetBytes(rtxtMsg.Text);            //指定接收者            string receiver = lbOnline.SelectedIndex == -1 ? "All" : lbOnline.SelectedItem.ToString();            if (receiver.Equals(localEndPoint))            {                MessageBox.Show("不要自己给自己发消息!");                return;            }            byte[] pre = Encoding.UTF8.GetBytes(receiver + "|");            byte[] buffer=new byte[1+pre.Length+byteArray.Length];            byteArray.CopyTo(buffer, pre.Length+1);            pre.CopyTo(buffer, 1);            //标记发送的东西是什么类型,消息还是文件,这里是消息。            buffer[0] = 1;            //发送出去            skConn.Send(buffer);            //清空消息输入框            rtxtMsg.Clear();        }

服务器端接收到数据后,首先对数据进行分析,如果是以1开头,则发过来的是消息,如果是以0开头则发来的是文件。然后进一步分析接收者,消息内容或文件名,在服务器的消息窗口显示相关信息,然后,将消息或文件转发给指定的接收者,同时在前面附上发送者.

       private void Receiveing(object obj)        {            Socket skConn = obj as Socket;            while (true)            {                byte[] buffer = new byte[1024 * 1024 * 6];                int actualBytes;                #region   接收数据                try                {                    //Socket.Receive方法把接收到的字节流写入byte[]数组,返回实际接收到的字节数,                    //如果对方的Socket被Dispose了,将接收到长度为0的空字节流,所以抛出异常。                    if ((actualBytes = skConn.Receive(buffer)) == 0)                     {                        throw new Exception("Off line");                    }                 }                    //如果捕捉到异常,说明客户端已下线。将这个Socket从Dictionary中移除,并从在线列表中移除。                 catch                  {                        txtMsgs.AppendText(skConn.RemoteEndPoint + "已下线。\n");                        sockets.Remove(skConn.RemoteEndPoint);                        lbOnline.Items.Remove(skConn.RemoteEndPoint);                        lbNum.Text = (Convert.ToInt32(lbNum.Text) - 1).ToString();                        skConn.Dispose();                        return;                    }               #endregion                //如果发来的是文件                if (buffer[0] == 0)                {                    //先取出文件名,给谁发的。                    string content = Encoding.UTF8.GetString(buffer, 1, actualBytes - 1);                    string fileName = content.Split('|')[1];                    string receiver = content.Split('|')[0];                    string sender = skConn.RemoteEndPoint.ToString();                    //在消息框中显示谁向谁发送了什么文件                    txtAllMsgs.AppendText(string.Format("{3}  {0}向{1}发送了文件{2}.\r\n", sender, receiver == "All" ? "所有人" : receiver, fileName,DateTime.Now.ToLongTimeString()));                    //计算实际文件内容的起始位置。                    int startIndex = Encoding.UTF8.GetBytes(receiver + "|" + fileName + "|").Length + 1;                    //将实际文件内容拷贝到一个大小刚好和文件字节数相等的数组中。                    byte[] file = new byte[actualBytes - startIndex];                    Array.Copy(buffer, startIndex, file, 0, file.Length);                    //文件内容前面加上发送者,文件名以及标记0,用一个长度刚好与这些加起来的长度一致的数组来存放,来作为要转发给其他客户端的字节数组。                    string pre = String.Join("|", sender, fileName, "");                    byte[] temp = Encoding.UTF8.GetBytes(pre);                    buffer = new byte[1 + temp.Length + file.Length];                    temp.CopyTo(buffer, 1);                    buffer[0] = 0;                    file.CopyTo(buffer, 1 + temp.Length);                    //如果接受者是所有人,则用Dictionary中除与发送者通信的socket(即当前线程的socket)以外的所有socket转发一遍这个文件。                    if (receiver == "All")                        foreach (Socket socket in sockets.Values)                        {                            if (Socket.ReferenceEquals(skConn, socket))                                continue;                            socket.Send(buffer);                        }                        //如果是给某个客户端单独发送的,则先从receiver解析得到接收者的IPEndPoint,然后用相应的socket发送。                    else                    {                        IPEndPoint ep = new IPEndPoint(IPAddress.Parse(receiver.Split(':')[0]), int.Parse(receiver.Split(':')[1]));                        sockets[ep].Send(buffer);                    }                }                    //如果发来的是消息                else                {                    //从收到的内容中分解出接收者和消息内容                    string content = Encoding.UTF8.GetString(buffer, 1, actualBytes-1);                    string receiver = content.Split('|')[0];                    int startIndex = Encoding.UTF8.GetBytes(receiver + "|").Length + 1;                    string msg = Encoding.UTF8.GetString(buffer, startIndex, actualBytes - startIndex);                    string sender = skConn.RemoteEndPoint.ToString();                    //在消息框中显示谁对谁说了什么话。                    txtAllMsgs.AppendText(string.Format("{3}  {0}对{1}说:\n{2}\n", sender, receiver == "All" ? "所有人" : receiver, msg, DateTime.Now.ToLongTimeString()));                    //如果是群发的,则给所有人都发一遍                    if (receiver == "All")                    {                        sender += " to all";                        //调用ToBuffer方法,将消息头部信息和消息内容组装成byte[]数组。                        buffer = ToBuffer(sender, msg);                        //给所有人发一遍,包括发送者,因为要在发送者的消息框中显示自己说的话。                        foreach (Socket socket in sockets.Values)                            socket.Send(buffer);                    }                        //如果是给某个单独的客户端发的,则分别给发送者和接收者发送一次。                    else                    {                        IPEndPoint ep = new IPEndPoint(IPAddress.Parse(receiver.Split(':')[0]), int.Parse(receiver.Split(':')[1]));                        sender = "我 to " + ep;                        buffer = ToBuffer(sender, msg);                        skConn.Send(buffer);                        sender = skConn.RemoteEndPoint + " to You";                        sockets[ep].Send(ToBuffer(sender, msg));                    }                }            }        }        private byte[] ToBuffer(string sender,string msg)        {            string content = sender + "|" + msg;            byte[] temp = Encoding.UTF8.GetBytes(content);            byte[] buffer = new byte[temp.Length + 1];            buffer[0] = 1;            temp.CopyTo(buffer, 1);            return buffer;        }

客户端发送文件,自定义的格式为“0|接收者|文件名|文件内容”,文件大小限定在5M以内。

       private void btnSendFile_Click(object sender, EventArgs e)        {            string path=txtPath.Text.Trim();            System .IO .FileInfo file;            try            {                file = new System.IO.FileInfo(path);            }            catch            {                MessageBox.Show("请输入正确的文件路径");                return;            }            if (!file.Exists)            {                MessageBox.Show("指定的文件不存在。");                return;            }            if (file.Length >= 5 * 1024 * 1024)            {                MessageBox.Show("文件大小不得超过5M");                return;            }            //用一个新线程来发送文件,以防文件大、网速慢时,UI无响应。            System.Threading.Thread thread = new System.Threading.Thread(new System.Threading.ParameterizedThreadStart(SendFile));            thread.IsBackground = true;            thread.Start(file);        }        private void SendFile(object o)        {            System.IO.FileInfo file = o as System.IO.FileInfo;            string path = file.FullName;            //从在线列表的选中项确定接收者,如果未选中则给所有人发送。            string toWhom = lbOnline.SelectedIndex == -1 ? "All" : lbOnline.SelectedItem.ToString();            if (toWhom == skConn.LocalEndPoint.ToString())            {                MessageBox.Show("不可自己给自己发文件!");                return;            }            string fileName = file.Name;            //将头部信息(接收者,文件名)字符串转成字节,计算文件内容起始位置。            byte[] head=Encoding.UTF8.GetBytes(string.Join("|", toWhom, fileName, ""));            int startIndex = 1 + head.Length;            //通过FileInfo对象的Length属性获取文件大小(字节),由于是long类型的,而5M以内的文件大小肯定在int.MaxValue以内,所以转换为int。            int totalLength = startIndex + (int)file.Length;            //声明一个与发送内容总长度一致的byte[]数组。            byte[] buffer = new byte[totalLength];            //先把标记0以及头部信息放入其中。            buffer[0] = 0;            head.CopyTo(buffer, 1);            //再把文件所有字节读入buffer数组。            using (System.IO.FileStream fs = /*System.IO.File.OpenRead(path)*/ new System.IO.FileStream(path, System.IO.FileMode.Open))            {                fs.Read(buffer, startIndex, (int)file.Length);            }            rtxtAllMsg.AppendText("Sending file " + fileName + " to " + toWhom + "...  " + DateTime.Now.ToLongTimeString()+"\n");            //发送出去。            try            {            skConn.Send(buffer);            }            catch            {                rtxtAllMsg.AppendText("文件发送失败。 " + DateTime.Now.ToLongTimeString()+"\n");            }        }

客户端接收消息和文件:客户端接收到的数据有3种,一是从服务器发来的在线列表更新,以“ol”标记,二是服务器转发或服务端的administrator发过来的消息,以1标记,三是服务器转发过来的文件,以0标记。如果是接收到在线列表更新,就刷新在线人数和在线列表并播放好友上线下线提示音;接收到消息,就在消息框中显示,并播放提示音;接收到文件,也在消息框中显示发送者和文件信息并播放提示音,下方的接收文件按钮变为可用状态,可随时点击保存。而且支持多文件接收队列,即一段时间内接收到的没来得及保存的多个文件暂存于内存缓冲区中,让用户一个一个的保存。

        List<FileBuffer> unsavedFiles = new List<FileBuffer>();        private void Receiving()        {            while (true)            {                byte[] buffer = new byte[1024 * 1024 * 6];                int length;                string prefix,content;                try                {                    length = skConn.Receive(buffer);                    content = Encoding.UTF8.GetString(buffer,0,length);                }               //如果客户端接收消息异常,则说明服务器端出了问题,状态改为离线.并释放该socket所占的资源。                catch                {                    lbStatus.Text = "离线";                    skConn.Dispose();                    isConnected = false;                    return;                }                     //如果服务器发来的内容是以"ol"开头,则发来的是在线列表更新。                    if (content.StartsWith("ol"))                        RefreshOlClients(content);                   //如果发来的是以0开头,则发来的是文件,用接收到的字节数组及其长度创建一个自定义的FileBuffer对象,FileBuffer中实现了获取文件名和发送者的属性以及保存文件的方法。                    else if (buffer[0] == 0)                    {                        FileBuffer fb = new FileBuffer(buffer, length);                        //将这个FileBuffer对象加入unsavedFiles集合中,从而实现多个文件发送,一个一个的接收。                        unsavedFiles.Add(fb);                        //在消息框中显示发来文件的信息。                        prefix = string.Format("{0}向你发来文件 {1}",fb.Sender,fb.FileName);                        RefreshMsgBox(prefix, "点击下方按钮接收");                        //使“接收文件”按钮变为可用状态。                        btnRecFile.Enabled = true;                    }                        //如果发来的是消息                    else                    {                        //先将接收到的字节解码为字符串,获取发送者和消息内容。                        content = Encoding.UTF8.GetString(buffer, 1, length - 1);                        prefix = content.Split('|')[0];                        string msg = content.Substring(prefix.Length + 1);                        //在消息框中显示。                        if (prefix.Contains(localEndPoint))                            prefix = prefix.Replace(localEndPoint, "我");                        RefreshMsgBox(prefix, msg);                    }            }        }        private void RefreshMsgBox(string sender, string msg)        {            //如果是别人发来的消息,则播放QQ消息提示音。            if (!sender.Contains("我"))            {                System.Media.SoundPlayer sp = new System.Media.SoundPlayer(@"sounds\msg.wav");                sp.Play();            }            //将发送者信息以及当前时间显示在消息框中,再在下一行显示消息内容。            rtxtAllMsg.AppendText(sender + "  " + DateTime.Now.ToLongTimeString() + "\n");            rtxtAllMsg.AppendText(msg + "\n");        }

自定义的FileBuffer类,FileBuffer中实现了分析buffer字节数组获取文件名和发送者等属性以及保存文件的方法,可直接调用FileBuffer对象的SaveAs方法保存文件。

    class FileBuffer    {        public string Sender        {            get             {                return _sender;            }        }        private string _sender;        public string FileName { get; protected set; }        public readonly byte[] Buffer;        private int startIndex;        private int length;        //构造函数,从buffer中读取文件名,发送者以及文件起始位置和字节数。        public FileBuffer(byte[] buffer, int length)        {            Buffer = buffer;            string content = Encoding.UTF8.GetString(Buffer, 1, length - 1);            _sender = content.Split('|')[0];            FileName = content.Split('|')[1];            startIndex = Encoding.UTF8.GetBytes(string.Join("|", Sender, FileName, "")).Length + 1;            this.length = length - startIndex;        }        //将Buffer中的文件字节内容保存为file文件。        public void SaveAs(string file)        {            using (System.IO.FileStream fileStream = new System.IO.FileStream(file, System.IO.FileMode.Create))            {                fileStream.Write(Buffer, startIndex, length);            }        }    }

客户端保存接收到的文件:

        private void btnRecFile_Click(object sender, EventArgs e)        {            //弹出保存文件对话框,如果点了OK,则保存,如果点了取消,unsavedFiles集合中的未保存文件不变,以后还可保存。            SaveFileDialog dialog=new SaveFileDialog();            dialog.FileName = unsavedFiles[0].FileName; //对话框中的默认文件名为发来的文件名。            if (dialog.ShowDialog() == DialogResult.Cancel)                return;            string file = dialog.FileName;             //调用unsavedFiles集合中的第一个FileBuffer对象的SaveAS方法,即保存先发过来的文件。            unsavedFiles[0].SaveAs(file);            //保存后,从unsavedFiles集合中移除。            unsavedFiles.RemoveAt(0);            MessageBox.Show(string.Format("文件{0}已保存", file));            //如果不再有未保存的文件,则将接收文件按钮置为不可用状态。            if (unsavedFiles.Count==0)                btnRecFile.Enabled = false;        }

更新在线人数列表,服务器端向客户端发送变更后的在线人数列表,格式为“ol在线人数-IPEndPoint1|IPEndPoint2|,,,|”。客户端接收到后,刷新在线人数列表。

        //服务器端在线列表变更时,向客户端发送新的在线人数即在线列表。        private void lbOnline_TextChanged(object sender, EventArgs e)        {            StringBuilder sb=new StringBuilder();            foreach(EndPoint ep in lbOnline .Items)                sb.Append(ep.ToString()+"|");            byte[] content = Encoding.UTF8.GetBytes( "ol"+lbOnline.Items.Count+"-"+sb.ToString());            foreach (Socket socket in sockets.Values)            {                socket.Send(content);            }        }
        //客户端接收到服务器发来的标记为ol的东东后,刷新在线列表        private void RefreshOlClients(string content)        {            //显示在线人数。            lblNum.Text = content.Split('-')[0].Substring(2);            //再分离出各个在线客户端的IPEndPoint的字符串            content = content.Split('-')[1];            string[] endPoints = content.Split(new []{'|'},StringSplitOptions.RemoveEmptyEntries );            //刷新在线列表            lbOnline.Items.Clear();            lbOnline.Items.AddRange(endPoints);            //播放QQ好友上线的敲门声            System.Media.SoundPlayer sp = new System.Media.SoundPlayer(@"sounds\Global.wav");            sp.Play();        }

客户端运行截图:

服务器端截图:



客户端与服务器端:


 

---------------------- Windows Phone 7手机开发、.Net培训、期待与您交流! ---------------------- 详细请查看:http://net.itheima.com/