一、Socket基础知识(Socket、TCP和UDP协议、端口含义)
1、socket如何理解
两台计算机相互通信靠的就是socket,类似于2个人要通信靠电话,也就是说socket就是电脑间(程序间)的电话机。
socket英文的原意就是孔、插座,作为进程通信机制,取后一种意思,通常也称为套接字,用于描述IP地址和端口。IP地址指向某台服务器,端口用于连接到某一个应用程序。
socket在通讯过程中所处位置(作用)理解:
释义:男生要到女生宿舍找自己女朋友出去玩,不能直接进入女生宿舍去找,要经过宿管大妈,由宿管大妈打电话告知你的女朋友,电话打通后你们之间再进行通话了。
这里宿管大妈就是负责监听的Socket,如果有男生(客户端发送请求)来了就创建一个负责通信的socket(电话机),从而使该男生(客户端)与对应女生(服务端某应用程序)可以通信了。
socket开始就是服务器端负责监听的(相当于宿管大妈),看有没有客户端发送请求到服务器端,有的话就创建一个负责通信的Socket。
Socket接口是TCP/IP网络最为通用的API,也是在INTERNET上进行应用开发最为通用的API。
2、TCP协议和UDP协议
协议:类似于两个人打电话有一个默认协议就是都说普通话,如果大家都说家乡话,可能都听不懂,在网络中常用的协议有:UDP和TCP协议。
TCP/IP协议:Transmission Control Protocol/Internet Protocol,传输控制协议/网间协议,是一个工业标准的协议集,它是为广域网设计的。
TCP协议属于TCP/IP协议族中的一种,相对于UDP协议安全稳定,在TCP传输过程中要经过3次握手,传输效率相对低。
客户端向服务器发消息(比如问服务器你有空吗?),服务器端回复:我有空,客户端再发回给服务器端:我知道你有空了。经过三次握手后,客户端和服务器端才开始通信。
UDP(User Data Protocol,用户数据协议,是与TCP相对应的协议。它也属于TCP/IP协议族中的一种。它只管发,不管对方有没有接收。UDP协议:快速、效率高、不稳定,容易发生数据丢失。客户端直接发给服务器,不管服务器是否有空接收数据等。……都发给服务器。类似于发电报、电台广播。
两种协议比较,无所谓好坏,各有好处。当数据传输的性能必须让位于数据传输的完整性、可控制性和可靠性时,TCP协议是当然的选择。当强调传输性能而不是传输的完整性时,如:音频和多媒体应用,UDP是最好的选择。也即是说视频传输的时候用UDP(特点:快速效率高),因为视频聊天时更希望看到的是对方流畅不卡,但是清晰度可以低点。
3、端口分类
公认端口:公认端口也被称为常用端口,端口号为0到1023,它们紧密的绑定一些特殊的服务。通常,这些端口的通信明确的表明了某种服务协议,不可再重新定义它的作用对象。如:80端口用于http通讯、23号端口则是Telnet服务专用的。
注册端口:注册端口的端口号为1024到49151,它们松散地绑定一些服务,也即有许多服务绑定于这些端口,这些端口同样用于许多其他目的,且多数没有明确定义对象,不同的程序可以根据需要自己定义。常用于一些大型企业。这些端口对网络的安全十分重要,所以,对于服务器来说一般要关闭这些端口。
动态或私有端口:动态/或私有端口的端口号为49152到65535,理论上不应该把常用服务分配在这些端口上,但实际上有较为特殊的程序,特别是一些木马就非常喜欢使用这些端口,因为这些端口常常不会引起人们的注意,容易隐藏。
我们编程一般也就用这些端口,以免跟其他服务端口冲突。
后面以完成一个聊天程序的实例来讲解Socket通信应用(基于TCP/IP协议)。
二、Socket通信基本流程及如何创建Socket
1、创建服务端界面——聊天程序的服务端
创建一个解决方案,比如名为Socket网络编程,在其中创建一个Windows窗体应用程序(.Net Framework),名为SocketChatServer。窗体名改为FrmSocketServer,如下图所示,各控件名称如图红色字体标识。
2、Socket通信基本流程图
3、编写服务端【开始监听】按钮代码
private void BtnStartListing_Click(object sender, EventArgs e)
{
//1 创建负责监听的Socket
//1.1 参数含义:第1个参数设置网络寻址协议,InterNetwork表示IPV4,不是IPV6;第2个参数设置数据传输方式(Socket类型),这个要根据第3个参数来设置,第2个参数为Stream,则第三个参数设置通信协议,就要为Tcp;第3个参数如果为UDP协议,第2个参数就要为Dgram(报文)
Socket socketWatch = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
//2 获取要监听的IP地址,把IP字符串转为IP地址
IPAddress ip = IPAddress.Parse(txtIP.Text);
//2.1 获取要监听的IP和端口合并为一个变量(创建要监听的IP和端口)
IPEndPoint point = new IPEndPoint(ip, Convert.ToInt32(txtPort.Text));//提问:Convert.ToInt32换int.Parse可否?
//2.2 绑定要监听ip和端口号
socketWatch.Bind(point);
//给出监听提示
ShowMsg("监听成功");
//3 开始监听。设置服务器在一个时间点内最多能够监听的队列数量。如果多了这个数量则在后面排队,类似了公共厕所,假如只有5个蹲位,如果同时来了多于5个人,则需要排队。一般设置10个左右,多个服务器也可能承受不了,那么就要考虑分流
socketWatch.Listen(10);
//4 下面开始等待客户端来连接,同时会创建一个负责通信的socket
Socket socketCommunication = socketWatch.Accept();
//给出连接成功提示信息。格式 IP Port:连接成功
ShowMsg(socketCommunication.RemoteEndPoint.ToString() + ":" + "连接成功");
}
void ShowMsg(string str)
{
#region 不考虑跨线程的访问
//txtLog.AppendText(str + "\r\n");//用下面的写法也可以
txtLog.Text = txtLog.Text+str + "\r\n";
#endregion
#region 考虑跨线程访问
//如果不是创建这个控件的线程来调用它
if (txtLog.InvokeRequired)
{
//跨线程访问
//Invoke方法第一个参数为回调函数【回调函数是一个委托,主线程去执行,此委托需要传入一个字符串,即是追加到文本框中的消息】,第二个参数为传给回调函数的参数
txtLog.Invoke(new Action<string>(s => { txtLog.AppendText(s + "\r\n"); }), str);
}
else
{
txtLog.AppendText(str + "\r\n");
}
#endregion
}
运行测试看下此时的效果,发现一启动运行,窗体就卡死了,然后通过客户端连接进来,窗体就不卡死。注意,这里客户端先通过cmd中输入telnet 192.168.0.103 50000进行连接(由于尚未创建客户端窗体)。192.168.0.103要根据实际服务端电脑IP修改
为什么客户端连接进来了窗体就不卡死呢?因为下面那句代码,即是执行Accept方法,会阻塞当前线程,一直等待客户端的连接,只要一连接上就不阻塞。
//4 下面开始等待客户端来连接,同时会创建一个负责通信的socket
Socket socketCommunication = socketWatch.Accept();
说明:其他电脑也可以通过telnet 服务端IP 端口 连接进来的。在此我用的客户端也是自己电脑,IP是相同的,但是端口是不同的。
补充:如何开启telnet(没有开启需要开启)。方法如下:
找到控制面板——程序——启用或关闭Windows功能,勾选TelNet客户端,然后【确定】。如下图所示。
问题:假如还有客户端需要连接,发现已经连接不上了。原因就是上面黄色底纹2句话执行完毕,即一个客户端已经连接上了,提示信息也已经输出来了,不会再往回执行去等待客户端的连接。因此要解决2个问题:
第一:开始启动时窗体不能卡死——开启新线程执行,即要把等待客户端连接相关代码封装成一个方法,让线程去执行。
第二:要能运行多个客户端的连接——上面黄色底纹代码放入一个循环里面
三、解决窗体卡死等问题及服务端代码梳理
1、封装一个方法Listen——等待客户端的连接,同时创建一个与之通信的socket
/// <summary>
/// 等待客户端的连接,同时创建一个与之通信的socket
/// </summary>
void Listen()
{
//4 下面开始等待客户端来连接,同时会创建一个负责通信的socket
Socket socketCommunication = socketWatch.Accept();
//给出连接成功提示信息。格式 IP Port:连接成功
ShowMsg(socketCommunication.RemoteEndPoint.ToString() + ":" + "连接成功");
}
就直接把上面黄色底纹代码放入Listen方法体中,socketWatch是得不到的。解决方案有2种:
(1)把上面方法中的实例化socketWatch语句(即上面灰色底纹那句)写在方法外面;
(2)通过参数传入,但是参数类型Listen(Socket socketWatch),不能为Socket,因为这个Listen方法最终要被新开启的线程执行,线程执行的方法参数类型只能为object类型,在方法体中再把object类型强转为Socket类型。
继续完善监听Listen方法如下:
/// <summary>
/// 等待客户端的连接,同时创建一个与之通信的socket
/// </summary>
void Listen(object o)
{
Socket socketWatch = o as Socket;
while (true)
{
//4 下面开始等待客户端来连接,同时会创建一个负责通信的socket
Socket socketCommunication = socketWatch.Accept();
//给出连接成功提示信息。格式 IP Port:连接成功
ShowMsg(socketCommunication.RemoteEndPoint.ToString() + ":" + "连接成功");
}
}
同时把private void BtnStartListing_Click(object sender, EventArgs e)方法中最后2句话替换为如下3句话。
Thread thread = new Thread(Listen);
thread.IsBackground = true;
thread.Start(socketWatch);
当然也可以不开启新线程,找线程池里可用线程。
运行测试:
如果上面ShowMsg没有考虑跨线程访问就会出现:线程间操作无效,因为新线程去访问主线程创建的控件了。
解决方案:
(1)程序加载的时候取消跨线程的检查——不建议。
private void FrmSocketServer_Load(object sender, EventArgs e)
{
Control.CheckForIllegalCrossThreadCalls = false;
}
(2)按上面的方法通过回调函数访问——建议
再次运行测试:OK,运行启动时不卡死窗体,也可以多个客户端连接进来。

2、服务端代码梳理
把BtnStartListing_Click方法等代码再分析遍。
四、服务器接收客户端发送过来的消息
至此已写完客户端能连接到服务端了,接下来服务端就要接收来自客户端输入的信息。即继续完善服务端Listen方法。通过负责通信的socket调用Receive方法即可接收到客户端的消息。
socketCommunication.Receive();
此时Listen方法代码如下:
void Listen(object o)
{
Socket socketWatch = o as Socket;
while (true)
{
//4 下面开始等待客户端来连接,同时会创建一个负责通信的socket。
//每个连进来的客服端都要创建一个负责跟其通信的socket,所以写在了循环里面
Socket socketCommunication = socketWatch.Accept();
//给出连接成功提示信息。格式 IP Port:连接成功
ShowMsg(socketCommunication.RemoteEndPoint.ToString() + ":" + "连接成功");
//5 客户端连接成功后,服务端应该接收客户端发来的消息
//定义一个2M大小的字节数组,此句最好移到循环外
byte[] buffer = new byte[1024 * 1024 * 2];
//Receive方法的参数表示接收到的数据存储的位置,需要的是一个字节数组,返回值为int
//返回值r表示实际接收到的有效字节数。buffer数组虽为2M,但是发送过来的也许没有2M
int r = socketCommunication.Receive(buffer);
//此时接收到的是字节数据,看不懂,因此要把它转为字符串
//通过Encoding从buffer字节数组中的0个位置开始,把r个字节转为字符串;当然采用Default编写也可以的
string str = Encoding.UTF8.GetString(buffer,0,r);
//string str1 = Encoding.Default.GetString(buffer, 0, r);
//把接收到的数据放到文本框中,显示格式:客户端IP 端口:接收到的信息 ShowMsg(socketCommunication.RemoteEndPoint.ToString() + ":" + str);
}
}
运行测试看效果:发现现在只能接收到第一次输入的一个字符。原因分析如下:因为上面代码int r = socketCommunication.Receive(buffer);只要按下一个键就接收到相应的字符,然后转为字符串并显示出来,即上面黄色底纹代码执行完毕,然后就回到循环上面,即要执行Socket socketCommunication = socketWatch.Accept();此句,而该句又要等待客户端的连接并同时创建一个负责通信的socket,即原来的负责通信的socket就没有了,所以就不能与原来的客户端通信了。所以还是“死”在了socketWatch.Accept()里面。所以上面黄色底纹代码就要写在一个循环里面,即不断地去接收客户端数据,但是由于是一个死循环,所以又会卡死,因此,又要封装成一个方法(比如Receive()),并开启一个新的线程去执行。为什么参数是object o与上面Listen方法参数一样理解。
/// <summary>
/// <summary>
/// 服务端不停的接收客户度发来的消息
/// </summary>
/// <param name="o"></param>
void Receive(object o)
{
Socket socketCommunication = o as Socket;
//定义一个2M大小的字节数组
byte[] buffer = new byte[1024 * 1024 * 2];
while (true)
{
//5 客户端连接成功后,服务端应该接收客户端发来的消息
//定义一个2M大小的字节数组
//思考:定义2M大小的字节数组放在循坏内好不好,可不可以放到循坏外。——可以,应该放到循坏外,如上绿色底纹所示,下面就注释掉
//byte[] buffer = new byte[1024 * 1024 * 2];
//Receive方法表示接收到的数据存储的位置,需要的是一个字节数组,返回值为int
//返回值r表示实际接收到的有效字节数
int r = socketCommunication.Receive(buffer);
//此时接收到的是字节数据,看不懂,因此要把它转为字符串
//通过Encoding从buffer字节数组中的0个位置开始,把r个字节转为字符串;当然采用Default编写也可以的
string str = Encoding.UTF8.GetString(buffer, 0, r);
//string str1 = Encoding.Default.GetString(buffer, 0, r);
//把接收到的数据放到文本框中,显示格式:客户端IP 端口:接收到的信息
ShowMsg(socketCommunication.RemoteEndPoint.ToString() + ":" + str);
}
}
此时Listen方法代码又变为了如下:
/// <summary>
/// 等待客户端的连接,同时创建一个与之通信的socket
/// </summary>
void Listen(object o)
{
Socket socketWatch = o as Socket;
while (true)
{
//4 下面开始等待客户端来连接,同时会创建一个负责通信的socket。
//每个连进来的客服端都要创建一个负责跟其通信的socket,所以写在了循环里面
Socket socketCommunication = socketWatch.Accept();
//给出连接成功提示信息。格式 IP Port:连接成功
ShowMsg(socketCommunication.RemoteEndPoint.ToString() + ":" + "连接成功");
//开启一个新的线程不停的接收客户端发来的消息//当然也可以不开启新线程,用线程池里可用线程。
Thread thread = new Thread(Receive);
thread.IsBackground = true;//最好设置为后台线程,为什么?
thread.Start(socketCommunication);
}
}
此时运行测试看效果:存在问题与原因分析:
(1)每一个字符都换一行了。这个问题是由于是采用了telnet作为客户端,后续我们winform客户端就不会存在这个问题。
(2)关掉客户端,即客户端下线(即是关掉那个cmd窗口),还会不断的向服务端发送空消息。这是因为在Receive方法中存在一个while循环一直在执行,这时接收到客户端的数据长度r就是0(注意:客户端在线时是发不了空消息的)
所以在Receive方法中返回接收到的实际有效字节数r时先判断r是否为0,为0表示客户端下线了,此时就不再循环了,就要退出循环,即添加下面的黄色底纹if语句。
void Receive(object o)
{
……
int r = socketCommunication.Receive(buffer);
if(r==0)
{
break;
}
……
}
说明:
(1)break;也可以用下面3句话代替,即是关闭退出通信Socket:
//SocketShutdown.Both表示告诉对方我要走了,相当于发一个空消息。Both表示既关闭接收也关闭发送
socketCommunication.Shutdown(SocketShutdown.Both);
socketCommunication.Close();
return;//return不能少,结束方法
//如果socketCommunication加入到了集合中,还要从集合中移到次socketCommunication,代码如下:
//clientCommunicationSocketList.Remove(socketCommunication);
(2)在网络通信过程中容易出现各种异常,比如在客户下线时,非正常退出(断网、停电等),所以一般对网络通信的相关代码对要try catch起来,而且在catch里面什么都不写,这样就不会把异常信息显示给用户,对用户来说就没有异常了。实际上大家可以利用反编译器去看下微软的代码(比如这些通信方法的代码),可以看到使用了很多try catch。
在此我们对BtnStartListing_Click、Listen、Receive方法中通信代码都try catch起来(try住while循环里的代码即可)。
五、客户端给服务器发送消息
接下来先写客户端代码,就不用telnet作为客户端。
1、设计客户端界面
在解决方案下创建一个窗体应用程序(.Net Framework),名称为SocketChatClient,并把默认窗体重命名为FrmSocketClient。窗体上其他控件设计如下图所示。
2、编写客户端【连接】按钮代码
private void BtnStart_Click(object sender, EventArgs e)
{
//创建一个通信的socket
Socket socketCommunication = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
//获得要连接的远程服务器的IP和端口号
IPAddress ip = IPAddress.Parse(txtServer.Text);
IPEndPoint point = new IPEndPoint(ip, Convert.ToInt32(txtPort.Text));
//调用Connection方法去连接远程服务器
socketCommunication.Connect(point);
//提示连接成功
ShowMsg("连接成功");
}
void ShowMsg(string str)
{
txtLog.AppendText(str + "\r\n");//用下面的写法也可以
//txtLog.Text = txtLog.Text+str + "\r\n";
}
测试运行看效果:先启动运行服务端,客户端不再用telnet了,通过右击客户端项目——调试——启用新实例,要启用多个客户端,就多次右击客户端项目——调试——启用新实例。如下图所示,开启服务器,并开启了2个客户端。
接下来就是客户端要向服务端发送消息了。也即是要为客户端的【发送消息】按钮编写代码了。
首先就要拿到负责通信的socketCommunication,才好与服务端通信。负责通信的socketCommunication在【连接】按钮的单击事件中已产生。但是在那是写在了方法体中,因此在【发送消息】的单击事件中无法获取,所以需要修改下BtnStart_Click方法如下:即是上面黄色底纹代码变成了下面青绿色底纹代码。
Socket socketCommunication;
private void BtnStart_Click(object sender, EventArgs e)
{
//创建一个通信的socket
socketCommunication = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
//获得要连接的远程服务器的IP和端口号
IPAddress ip = IPAddress.Parse(txtServer.Text);
IPEndPoint point = new IPEndPoint(ip, Convert.ToInt32(txtPort.Text));
//调用Connection方法去连接远程服务器
socketCommunication.Connect(point);
//提示连接成功
ShowMsg("连接成功");
}
【发送消息】按钮单击事件:
/// <summary>
/// 客户端向服务端发送消息
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void BtnSend_Click(object sender, EventArgs e)
{
//获取输入的消息
string str = txtMsg.Text.Trim();
//把输入的消息转为字节数组(服务端接收客户端的消息用的UTF8编码,所以这里最好也用UFT8编码,如果前面用Default编码,那么这里也就用Default),因为后面的send方法需要的是字节数组
byte[] buffer = Encoding.UTF8.GetBytes(str);
//向服务端发送消息
socketCommunication.Send(buffer);
}
运行测试看效果:正常,如下图所示,而且通过winform窗体客户端不再是一个字符显示一行了。
六、服务端发送消息与客户端接收消息
1、服务器向客户端发送消息
首先就要拿到负责通讯的socket,在Listen方法中Socket socketCommunication = socketWatch.Accept();是产生负责通讯的socket,因此调整Listen方法,让Socket socketCommunication定义在方法外面。调整后的代码如下,变化地方如黄色、灰色底纹所示。
/// <summary>
/// 等待客户端的连接,同时创建一个与之通信的socket
/// </summary>
Socket socketCommunication;
void Listen(object o)
{
Socket socketWatch = o as Socket;
while (true)
{
try
{
//4 下面开始等待客户端来连接,同时会创建一个负责通信的socket。
//每个连进来的客服端都要创建一个负责跟其通信的socket,所以写在了循环里面
//Socket socketCommunication = socketWatch.Accept();
socketCommunication = socketWatch.Accept();
//给出连接成功提示信息。格式 IP Port:连接成功
ShowMsg(socketCommunication.RemoteEndPoint.ToString() + ":" + "连接成功");
//开启一个新的线程不停的接收客户端发来的消息
Thread thread = new Thread(Receive);
thread.IsBackground = false;
thread.Start(socketCommunication);
}
catch
{
}
}
}
/// <summary>
/// 服务器向客户端发送消息
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void BtnSend_Click(object sender, EventArgs e)
{
//首先要拿到负责通讯的socket
//在Listen方法中Socket socketCommunication = socketWatch.Accept();是产生负责通讯的socket,需要改进Listen方法
string str = txtMsg.Text;
//把输入的字符串消息转为字节数组
byte[] buffer = System.Text.Encoding.UTF8.GetBytes(str);
//Send方法需要的是字节数组
socketCommunication.Send(buffer);
}
接下来就是客户端来接收。因此进入客户端写代码了,由于服务端可能是不断的向客户端发送消息,所以在客户端就要不断的去接收。
2、客户端接收服务端发来的消息
定义Receive方法如下:
/// <summary>
/// 不停的接收服务器发来的消息
/// </summary>
void Receive()
{
//因为要不停的去接收,所以写在一个while循环里
byte[] buffer = new byte[1024 * 1024 * 3];
while (true)
{
//不论是发送还是接收消息,都要拿到负责通信的socket
//定义一个3M的字节数组,提到循环外
//byte[] buffer = new byte[1024 * 1024 * 3];
//Receive方法接收到的数据需要存储在一个字节数组中
//r表示实际接收到的有效字节数
int r=socketCommunication.Receive(buffer);
//为防止服务端关掉了,客户端还在不断的接收空消息
if(r==0)
{
break;
}
//从buffer字节数组的0个位置开始,解码r个长度
string s = Encoding.UTF8.GetString(buffer, 0, r);
//在文本框中显示接收到的消息
ShowMsg(socketCommunication.RemoteEndPoint + ":" + s);
}
}
这个方法什么时候开始执行呢?在【连接】上服务器之后就要不停的去接收服务器发来的消息。因此需要完善private void BtnStart_Click(object sender, EventArgs e)方法。为了不卡死主线程,因此又需要去开启一个新的线程去执行Receive方法。添加代码如下黄色底纹所示。
private void BtnStart_Click(object sender, EventArgs e)
{
//创建一个通信的socket
socketCommunication = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
//获得要连接的远程服务器的IP和端口号
IPAddress ip = IPAddress.Parse(txtServer.Text);
IPEndPoint point = new IPEndPoint(ip, Convert.ToInt32(txtPort.Text));
//调用Connection方法去连接远程服务器
socketCommunication.Connect(point);
//提示连接成功
ShowMsg("连接成功");
//开启一个新的线程不断的去接收服务端发来的消息
Thread thread = new Thread(Receive);
thread.IsBackground = true;//提问:为什么设置为和后台线程?
thread.Start();
}
此外还要取消跨线程之间的检查。
private void FrmSocketClient_Load(object sender, EventArgs e)
{
Control.CheckForIllegalCrossThreadCalls = false;
}
测试看效果,服务端和客户端能互相发送消息了。如下图所示。
七、给指定的客户端发送文本消息
目前服务端只能给最后一个连接进来的客户端发送消息。如何给指定的客户端发送消息呢?因为每个连接进来的客户【socketCommunication = socketWatch.Accept();】都会创建一个新的负责通信的socket,原来的负责通信的socket就不存在了(赋值语句覆盖),那也就是服务端只能给最后一个连接进来的客户端发送消息。因此每个客户端连接进来就要把它存储起来,那么如何存储呢?关键就是把每个连接进来的负责通信的socket存储起来,可以考虑用一个集合(存储数据类型为Socket),这里我把客户端对应IP和端口号也存储起来,方便后面显示(显示对方的IP和端口)需要。通过负责通信的socket的RemoteEndPoint属性可以拿到远程客户端的IP和端口号,因此可以用一个存储键值对的集合,键就是IP+端口,值就是通信的socket。然后把集合的键绑定到下拉列表框中,那么在下拉列表框中选择键(IP:端口)就给值对应的客户端【通信socket】发送消息,也就是说每个客户端都对应一个负责通信的socket,可根据IP地址拿到对应socket,因此用键值对集合来存储非常吻合。
//1.定义一个键值对集合,将客户端的IP地址和socket存入集合中。
//此方法定义在于方法并列位置
Dictionary<string, Socket> dicSocket = new Dictionary<string, Socket>();
//2.修改Listen方法
void Listen(object o)
{
……
socketCommunication = socketWatch.Accept();//这句创建好了负责通信的socket
dicSocket.Add(socketCommunication.RemoteEndPoint.ToString(),socketCommunication);//把上面创建好的负责通信的socket存入集合中
//将远程连接的客户端的IP地址和端口号存储到下拉列表框中 cboUsers.Items.Add(socketCommunication.RemoteEndPoint.ToString());
……
}
说明:上面灰色底纹代码会导致跨线程访问。因为cboUsers是由主线程创建的,而此处是由新线程访问。因为要解决跨线程访问问题,同样用回调函数解决。灰色底纹代码改为如下即可。
if (cboUsers.InvokeRequired)
{
//跨线程访问
cboUsers.Invoke(new Action(() => { cboUsers.Items.Add(socketCommunication.RemoteEndPoint.ToString()); }), null);
}
else
{
cboUsers.Items.Add(socketCommunication.RemoteEndPoint.ToString());
}
3.那么接下来在往客户端发送消息的时候就要根据用户下拉框的选择,选择对应的socket
private void BtnSend_Click(object sender, EventArgs e)
{
string str = txtMsg.Text;
byte[] buffer = System.Text.Encoding.UTF8.GetBytes(str);
socketCommunication.Send(buffer);
}
即上面黄色底纹部分改为
string ip = cboUsers.SelectedItem.ToString();
dicSocket[ip].Send(buffer);
思考:如何群发消息。服务器端加一个【群发消息】按钮
(1)定义一个存放负责通讯socket的集合clientCommunicationSocketList,与方法并列位置即可
List clientCommunicationSocketList =new List();
(2)在创建好了负责通讯的socket之后,把它加入到上面集合
void Listen(object o)
{
……
socketCommunication = socketWatch.Accept();//这句创建好了负责通信的socket
clientCommunicationSocketList.Add(socketCommunication);
……
}
(3)群发送代码
private void BtnQunfa_Click(object sender, EventArgs e)
{
foreach (var socket in clientCommunicationSocketList)
{
if (socket.Connected)//如果是连接状态,也就是客户端连接上了服务端
{
string str = txtMsg.Text;
byte[] buffer = Encoding.UTF8.GetBytes(str);
socket.Send(buffer);
}
}
}
八、改造给指定的客户端发送文本消息
现在服务端和客户端能互相发送消息,但仅仅是文本。现在服务端还需要向客户端发送文件,震动(闪屏)等。那么客户端就要区分出是文本、文件还是震动,如果是文本直接显示在文本框,文件的话需要弹出一个对话框来保存文件,震动的话就要震动屏幕。当然服务器端传送的不论是文本、文件还是震动,都是发字节数组,因为我们可自己设计一个“协议”,规定在字节数组第1位为0,表示文本,如果是1表示文件,如果是2表示震动。在客户端接收到字节数组时,先判断第1位是0、1还是2,如果是0按文本处理,如果是1按文件处理,如果是2按震动处理。
那么在下面黄色底纹代码传递给客户端的字节数组的第一位插入0、1或2,但是数组一旦定义了,长度不可改变。那么怎么办呢?有两种思路:
第一、可以申请一个新的数组newBuffer,其长度为buffer.length+1,然后给newBuffer[0]赋值为0、1或2,然后把原来buffer数组的值依次赋给newBuffer,并且从下标为1开始赋值,通过一个循环可以做到,也可以通过Buffer.BlockCopy()方法实现。
第二、集合的长度是可以改变的,并且数组的元素可以赋给集合,集合也可以转为数组。因此可以利用集合来实现,下面利用这两种思路分别实现:
服务端向客户端发送消息代码
/// <summary>
/// 服务端向客户端发送消息
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void BtnSend_Click(object sender, EventArgs e)
{
string str = txtMsg.Text;
byte[] buffer = System.Text.Encoding.UTF8.GetBytes(str);
//socketSend.Send(buffer);
string ip = cboUsers.SelectedItem.ToString();
dicSocket[ip].Send(buffer);
}
上面黄色底纹改为如下:黄色底纹部分
private void BtnSend_Click(object sender, EventArgs e)
{
string str = txtMsg.Text;
byte[] buffer = System.Text.Encoding.UTF8.GetBytes(str);
//以下2种思路用一种即可。
#region 第1种实现思路
byte[] newBuffer = new byte[buffer.Length + 1];
newBuffer[0] = 0;
Buffer.BlockCopy(buffer, 0, newBuffer, 1, buffer.Length);
#endregion
#region 第2种实现思路
List<byte> list = new List<byte>();//定义一个泛型集合
list.Add(0);//泛型集合第一位设置为0
list.AddRange(buffer);//把buffer数组的元素添加到集合
//将泛型集合转为数组
byte[] newBuffer = list.ToArray();//注意新的数组元素就多了一位的
#endregion
//socketSend.Send(buffer);
string ip = cboUsers.SelectedItem.ToString();
//dicSocket[ip].Send(buffer);
dicSocket[ip].Send(newBuffer);
}
接下来处理客户端接收代码:
原始:
/// <summary>
/// 不停的接收服务器发来的消息
/// </summary>
void Receive()
{
while (true)
{
byte[] buffer = new byte[1024 * 1024 * 3];
//实际接收到的有效字节数
int r = socketCommunication.Receive(buffer);
if(r==0)
{
break;
}
string s = Encoding.UTF8.GetString(buffer, 0, r);
ShowMsg(socketCommunication.RemoteEndPoint + ":" + s);
}
}
改为:
void Receive()
{
while (true)
{
byte[] buffer = new byte[1024 * 1024 * 3];
//实际接收到的有效字节数
int r = socketCommunication.Receive(buffer);//这句运行完毕,即是接收完毕,buffer数组里才有数据的
int n = buffer[0];
if (r == 0)
{
break;
}
if (n == 0)
{
string s = Encoding.UTF8.GetString(buffer, 1, r-1);
ShowMsg(socketCommunication.RemoteEndPoint + ":" + s);
}
else if(n==1)
{
}
else if(n==2)
{
}
}
}
发送文本消息就改造完毕。
九、给指定的客户端发送文件
下面处理服务端要向客户端发送文件。
/// <summary>
/// 选择要发送的文件
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void BtnSelect_Click(object sender, EventArgs e)
{
OpenFileDialog ofd = new OpenFileDialog();
ofd.InitialDirectory = @"C:\Users\lenovo\Desktop";
ofd.Title = "请选择要发送的文件";
ofd.Filter = "所有文件|*.*";
ofd.ShowDialog();
txtPath.Text = ofd.FileName;
}
private void BtnSendFile_Click(object sender, EventArgs e)
{
#region 第1种实现思路,使用文件流读写
//获得要发送文件的路径
string path = txtPath.Text;
using(FileStream fsRead=new FileStream(path,FileMode.Open,FileAccess.Read))
{
byte[] buffer = new byte[1024 * 1024 * 5];
//从文件流指向的文件中当前位置位置读取buffer.Length字节数据到buffer数组中,文件中没有buffer.Length字节可读取,就为读取全部,r就表示实际读取到的字节数。
int r = fsRead.Read(buffer, 0, buffer.Length);
List<byte> list = new List<byte>();
list.Add(1);//规定第一位为哦
list.AddRange(buffer);
byte[] newBuffer = list.ToArray();
//这里也直接用newBuffer一个参数行吗?不好,因为实际发送的文件可能不到5M,就这一个参数,那么发送的就是5M多1位。所以指明从当前位置发r+1个字节就够了,此时还要带上第4个参数,因为没有三个参数的重载
//第4个参数:一般设置为none,除非外网与内网通信有些特殊要求之类,根据实际情况设置 dicSocket[cboUsers.SelectedItem.ToString()].Send(newBuffer, 0, r+1, SocketFlags.None);
}
#endregion
#region 第2种实现思路---更好,不需要去单击【选择文件】按钮,界面中存储文件路径的文本框都不需要,不使用文件流方式读取,使用File.ReadAllBytes读取
using(OpenFileDialog ofd=new OpenFileDialog())
{
if(ofd.ShowDialog()!=DialogResult.OK)//没有选择文件
{
return;
}
byte[] buffer = File.ReadAllBytes(ofd.FileName);
byte[] newBuffer = new byte[buffer.Length + 1];
newBuffer[0] = 1;
Buffer.BlockCopy(buffer, 0, newBuffer, 1, buffer.Length);
//这里第2个参数 SocketFlags.None可以不需要,因为这里不存在多发送内容,读取的是文件中的所有字节数 dicSocket[cboUsers.SelectedItem.ToString()].Send(newBuffer,SocketFlags.None);
//SocketFlags一般设置为None,除非外网与内网通信有一些特殊要求
}
#endregion
}
客户端接收文件
/// <summary>
/// 不停的接收服务器发来的消息
/// </summary>
void Receive()
{
while (true)
{
byte[] buffer = new byte[1024 * 1024 * 5];
//实际接收到的有效字节数
int r = socketSend.Receive(buffer);//这句运行完毕,即是接收完毕,buffer数组里才有数据的
int n = buffer[0];
if (r == 0)
{
break;
}
if (n == 0)
{
string s = Encoding.UTF8.GetString(buffer, 1, r-1);
ShowMsg(socketSend.RemoteEndPoint + ":" + s);
}
else if(n==1)
{
#region 第1种实现思路,使用文件流读写
SaveFileDialog sfd = new SaveFileDialog();
sfd.InitialDirectory = @"C:\Users\lenovo\Desktop";
sfd.Title = "请选择要保存的文件位置";
sfd.Filter = "所有文件|*.*";
sfd.ShowDialog(this);
string path = sfd.FileName;
using (FileStream fsWrite = new FileStream(path, FileMode.OpenOrCreate, FileAccess.Write))
{
//把接收到的数据(buffer数组中的数据)从第1个字节位置, r-1个字节的数据写入到流指向的文件中
fsWrite.Write(buffer, 1, r - 1);
}
MessageBox.Show("保存成功");
#endregion
#region 第2种实现思路,不使用文件流写入,使用File.WriteAllBytes
using(SaveFileDialog sfd=new SaveFileDialog())
{
sfd.DefaultExt = "doc";
sfd.Filter = "所有文件|*.*|文本文件(*.txt)|*.txt|word文档(*.doc)|*.doc";
if(sfd.ShowDialog(this)!=DialogResult.OK)
{
return;
}
byte[] newBuffer = new byte[r - 1];
Buffer.BlockCopy(buffer, 1, newBuffer, 0, r - 1);
File.WriteAllBytes(sfd.FileName, newBuffer);
MessageBox.Show("保存成功");
}
#endregion
}
else if(n==2)
{
}
}
}
运行测试,看效果。发现不足之处:(1)现在客户端必须加上相应的文件扩展名,实际上客户端不知道服务端发送过来的是什么文件。当然可以继续设计一个“协议”放在字节数组的第二位,比如0表示文本文件,1表示jpg,2表示gif,3表示doc等等,不过有点麻烦。(2)现在只能发小于5M的文件,如果大于5M就会出问题,这就牵涉到文件断点续传问题,在发送的时候就要把大文件切割为若干个小文件,然后再上传。
以上问题自己查阅资料研究。
十、给指定的客户端发送震动
下面处理发送震动/闪屏
服务端:
/// <summary>
/// 发送震动到客户端,实际上不论发送什么都是发送字节数组到客户端
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void BtnZD_Click(object sender, EventArgs e)
{
byte[] buffer = new byte[1];
buffer[0] = 2;
dicSocket[cboUsers.SelectedItem.ToString()].Send(buffer);
}
客户端:
/// <summary>
/// 不停的接收服务器发来的消息
/// </summary>
void Receive()
{
while (true)
{
byte[] buffer = new byte[1024 * 1024 * 3];
//实际接收到的有效字节数
int r = socketSend.Receive(buffer);//这句运行完毕,即是接收完毕,buffer数组里才有数据的
int n = buffer[0];
if (r == 0)
{
break;
}
if (n == 0)
{
string s = Encoding.UTF8.GetString(buffer, 1, r-1);
ShowMsg(socketSend.RemoteEndPoint + ":" + s);
}
else if(n==1)
{
SaveFileDialog sfd = new SaveFileDialog();
sfd.InitialDirectory = @"C:\Users\lenovo\Desktop";
sfd.Title = "请选择要保存的文件";
sfd.Filter = "所有文件|*.*";
sfd.ShowDialog(this);
string path = sfd.FileName;
using (FileStream fsWrite = new FileStream(path, FileMode.OpenOrCreate, FileAccess.Write))
{
fsWrite.Write(buffer, 1, r - 1);
}
MessageBox.Show("保存成功");
}
else if(n==2)
{
ZD();
}
}
}
/// <summary>
/// 震动,改变窗体作为位置
/// </summary>
void ZD()
{
for (int i = 0; i < 600; i++)
{
this.Location = new Point(200, 200);
this.Location = new Point(260, 260);
}
}
源代码