C#Socket编程多客户端基于同一服务端通信

时间:2021-09-18 22:16:07

对于Socket编程,网上资料、博文一抓一大把,不过大多数都是简单讲解单客户端和服务端通信的实例,这里主要给大家展现一下在.net平台上用窗体程序实现的过程,不过比较有特点的是会告诉大家如何实现不同客户端之间的通信,它们如何通过一个服务端准确的找到对方进行通信,包括上线提醒、私信交流、昵称重名处理等功能。
一.服务端架构
1.开启监听
简单来说,服务端首先要创建一个监听线程,检测客户端的连接情况,这个部分的程序封装在一个按钮里。主要实现代码如下:

        Thread threadwatch = null;//负责监听客户端的线程 
//创建一个监听线程
threadwatch = new Thread(watchconnecting);
//将窗体线程设置为与后台同步,随着主线程结束而结束
threadwatch.IsBackground = true;
//启动线程
threadwatch.Start();

如果有仔细看我上面给出的代码细心的观众会发现有个watchconnecting()方法被委托进了监听线程,其实这就是一个监听客户端信息消息的函数,很老套的过程:

            Socket connection = null;
while (true) //持续不断监听客户端发来的请求
{
try
{
connection = socketwatch.Accept();

}

其中

Socket socketwatch = null;//负责监听客户端的套接字

也只是大家异常熟悉的Socket套接字,这部分绑定IP和端口到Point,然后

//监听绑定的网络节点
socketwatch.Bind(point);
//将套接字的监听队列长度限制为20
socketwatch.Listen(20);

的过程应该在前面服务端开启监听线程的按钮里实现,这里就不细说了。
然后创建一个通信线程,对服务端和客户端之间的交流进行一个完善:

//创建一个通信线程 
ParameterizedThreadStart pts = new ParameterizedThreadStart(recv);
Thread thread = new Thread(pts);
thread.IsBackground = true;//设置为后台线程,随着主线程退出而退出
//启动线程
thread.Start(connection);

2.服务端通信过程
由之前代码里创建的通信线程里委托的recv()方法,看字面意思也知道是接收客户端发送消息的接收函数。也是很常规的部分:

            Socket socketServer = socketclientpara as Socket;
while (true)
{

//创建一个内存缓冲区 其大小为1024*1024字节 即1M
byte[] arrServerRecMsg = new byte[1024 * 1024];
//将接收到的信息存入到内存缓冲区,并返回其字节数组的长度
try
{
int length = socketServer.Receive(arrServerRecMsg);
//将机器接受到的字节数组转换为人可以读懂的字符串
string strSRecMsg = Encoding.UTF8.GetString(arrServerRecMsg, 0, length);
//将字符串转化为二进制流进行其他操作
byte[] bytes = System.Text.Encoding.UTF8.GetBytes(strSRecMsg);

注意,将接收到的信息转化为二进制流之后可以Send(bytes)转发消息了,当然,这一块之后会细讲,这里只是先大概介绍下流程。

二.客户端架构
由于客户端基本架构和服务端大体相似,只是在监听部分转为尝试连接部分,大家记住这两个变量:

//创建 1个客户端套接字 和1个负责监听服务端请求的线程
Thread threadclient = null;
Socket socketclient = null;

和服务端大同小异,不过注意要在客户端绑定的是之前在服务端绑定的相同IP和端口才能互通,同样将IP和端口绑定到Point上:

 try
{
//客户端套接字连接到网络节点上,用的是Connect
socketclient.Connect(point);
}

即服务端是Accept,客户端是Conncet,然后同样开启一个监听服务端的线程:

            threadclient = new Thread(recv);

threadclient.IsBackground = true;

threadclient.Start(socketclient);

至于里面的recv,和前面的服务端完全一样,就不重述了。

三.获取本地IP4的方法
大家是否为如何精确获得IP4的IP地址烦恼呢,又不想手动去查,好吧,这里有一个方法送给大家:

 public static string GetLocalIP()
{
try
{
string HostName = Dns.GetHostName(); //得到主机名
IPHostEntry IpEntry = Dns.GetHostEntry(HostName);
for (int i = 0; i < IpEntry.AddressList.Length; i++)
{
//从IP地址列表中筛选出IPv4类型的IP地址
//AddressFamily.InterNetwork表示此IP为IPv4,
//AddressFamily.InterNetworkV6表示此地址为IPv6类型
if (IpEntry.AddressList[i].AddressFamily == AddressFamily.InterNetwork)
{
return IpEntry.AddressList[i].ToString();
}
}
return "";
}
catch (Exception ex)
{
MessageBox.Show("获取本机IP出错:" + ex.Message);
return "";
}
}

四.服务端转发消息
1.转发处理
这个部分即为本文的核心所在,之前铺垫的也都是大家或多或少都了解的,而这个部分我在网上浏览资料时并没有发现比较鲜明的介绍。
首先注意这两个字典集

Dictionary<string, Socket> dic = new Dictionary<string, Socket> { };   //定义一个集合,存储客户端信息
Dictionary<string, string> dicName = new Dictionary<string, string> { }; //昵称与客户端对应

我们的服务端在客户端连接上服务端时是有办法知道客户端的信息的:

                //获取客户端的IP和端口号
IPAddress clientIP = (connection.RemoteEndPoint as IPEndPoint).Address;
//获取客户端的IP和端口号
int clientPort = (connection.RemoteEndPoint as IPEndPoint).Port;
RemoteEndPoint = connection.RemoteEndPoint.ToString(); //客户端网络结点号

至于为什么我会建立两个字典集,dic里保存的是

RemoteEndPoint = connection.RemoteEndPoint.ToString(); //客户端网络结点号
connection = socketwatch.Accept();

RemoteEndPoint是套接字Socket里的一个属性,能唯一的辨识出不同客户端连接时套接字的网络节点,当然,如果有其他类似的属性也可以代替。则RemoteEndPoint 就是Connection的唯一身份标识,那么我们在进行Socket里面一些属性操作时,比如用connection 发送、接收消息都可以以RemoteEndPoint 这个标识来鉴别身份。
不过每个客户端都能自己给自己起个好听的昵称,不然全是网络节点号也记不住,分不清哪个是自己,所以我又用了dicName把网络节点号和客户端的昵称绑在了一起,相当于实现了一个双层的嵌套;
要查找对应信息建议使用LINQ,十分简单,比如:

var name = dicName.Where(q => q.Value == socketServer.RemoteEndPoint.ToString()).Select(q => q.Key);
string leavemsg = name.FirstOrDefault()

就可以轻松找出已知某个网络节点号的昵称。
所以服务端转发消息就是当接收到一个客户端传来的消息时会自动将接受到的任何消息转发到其他所有客户端,这个在字典集里遍历就可以实现,比如:

 //转发消息给其他客户端
byte[] bytes = System.Text.Encoding.UTF8.GetBytes(strSRecMsg);

foreach (string Client in dicName.Values)
{
if (Client != socketServer.RemoteEndPoint.ToString())
dic[Client].Send(bytes); //发送数据
}

要注意的是,将客户端消息存进字典里的代码是要写在服务端开启监听登录的按钮里,这样每次客户端一连接成功就会记录一次。
2.区别客户端
也许有人会问,虽然转发实现了,但是那么多客户端之间发送的消息如何辨别呢,这里有两种处理方法:可以在服务端设置一个变量保存每一个连接时客户端的昵称,在转发消息时将这个变量放在消息头,这样就可以区分;不过我认为这个方法还是比较麻烦的,既然消息来源是客户端,那我们就在客户端发送的消息头加上每个客户端自己的昵称,这样所有客户端发送的消息都是带有自己的昵称的,这样就不需要我们再过多的处理了。

五.上线下线提醒
1.上线
其实方法很简单,只要知道了转发的实现很容易想到,只要在客户端开启监听服务的按钮里写一段发送昵称消息给服务端的代码:

byte[] name = Encoding.UTF8.GetBytes(textBoxName.Text);
//调用客户端套接字发送字节数组
socketclient.Send(name);

服务端监听客户端端登录时,会收到来自客户端发出的昵称消息,服务端将昵称存进字典集里就将昵称转发到其他客户端提醒“XXX,上线了”:

                //上线提醒
string msg = strRecMsg +","+"上线了";
byte[] Msg = Encoding.UTF8.GetBytes(msg);
foreach (string Client in dicName.Values)
{
if (Client != RemoteEndPoint)
dic[Client].Send(Msg); //发送数据
}

2.下线
下线,我的处理是全部放在套接字监听连接中断异常里,即,只要Socket中断或者连接失败,就会在异常里转发下线消息:

 catch (Exception ex)
{
//下线处理

道理和上线差不多,只是代码的位置不同。

六.昵称重名处理
由前面的介绍,我们互道客户端在连接服务端时会发送一个昵称消息给服务端,服务端将昵称存起来后如果发现字典集里有相同的名字则会关闭正在通信Sock,发送一个提示消息给客户端:

                //获取昵称
byte[] RecMsg = new byte[1024 * 1024];
int length = connection.Receive(RecMsg);
string strRecMsg = Encoding.UTF8.GetString(RecMsg, 0, length);

//检查昵称重名
if (dicName.Count>0)
{
string ack = "昵称已存在,请重新输入昵称";
byte[] ackMsg = Encoding.UTF8.GetBytes(ack);
if (dicName.ContainsKey(strRecMsg))
{
Flag = true;
connection.Send(ackMsg);
//离线处理
connection.Close();

相应的,客户端在受到服务端传过来的提示消息时,关闭Sock下线:

                    string strRevMsg = Encoding.UTF8.GetString(arrRecvmsg, 0, length);

string []s=strRevMsg.Split(',');

if (s[0] != textBoxName.Text)
{
if (strRevMsg == "昵称已存在")
{
socketServer.Close();

this.buttonStart.Enabled = true;
break;

这里有两个要特别注意的地方,首先是if (s[0] != textBoxName.Text)是针对之前的上线提醒,在昵称重名时不提醒上线消息,我们接着看一段客户端开启监听的代码:

 try
{
//SocketException exception;
this.buttonStart.Enabled = false;
//定义一个套接字监听
socketclient = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
//获取文本框中的IP地址
IPAddress address = IPAddress.Parse(textBoxIP.Text.Trim());
//将获取的IP地址和端口号绑定在网络节点上
IPEndPoint point = new IPEndPoint(address, int.Parse(textBoxPort.Text.Trim()));

很显然,我们在点击开启监听的按钮后,按钮会置灰,这也是一种将Socket连接状态反应在按钮上的一个同步,所以当Socket断开时,我们需要this.buttonStart.Enabled = true;这样才能重新点击按钮,输入正确要求的昵称再重新连接服务端。

七.私聊
之前服务端转发实现的只是群聊,那么私聊怎么办呢。
其实也很简单,我这里以服务端和其他不同客户端私聊为例。
因为能分辨不同的客户端,那么想和谁私聊不是轻松的很。
我的做法是将dicName的消息展示到一个ListBox里:

 void OnlineList_Disp(string Info)
{
if(!listBoxOnlineList.Items.Contains(Info))
listBoxOnlineList.Items.Add(Info); //在线列表中显示连接的客户端
}

在服务端想私聊时,只要点击列表里一个客户端,将代码写在一个按钮里,详细实现如下:

 string sendMsg = "管理员:"+richTextBoxSend.Text.Trim(); //要发送的信息
byte[] bytes = System.Text.Encoding.UTF8.GetBytes(sendMsg); //将要发送的信息转化为字节数组,因为Socket发送数据时是以字节的形式发送的
if (listBoxOnlineList.SelectedIndex == -1)
{
MessageBox.Show("请选择要发送的客户端!", "提示", MessageBoxButtons.OK, MessageBoxIcon.Stop);
}
else
{
string selectClient = listBoxOnlineList.Text; //选择要发送的客户端
var point = dicName.Where(q => q.Key == selectClient).Select(q => q.Value);
dic[point.FirstOrDefault()].Send(bytes); //发送数据
richTextBoxSend.Clear();
}

如果客户端之间想自己私聊呢,其实完全可以衍变过去不是吗,那我就把客户端接收上线提醒的昵称存到一个字典里进行类似操作不是也可以?所以方法都是通的,甚至可以像QQ、微信那样设计,在点击一个字典里的信息时跳转个页面到大对话框不也是美滋滋。

八.其他
如果有时间,我会尝试突破局域网的限制,实现外网之间Socket通信,目前思路有两个:花生壳内网映射,去阿里云租个外网服务器,当然这些都是后话了,有机会再一起探讨~