之前采用的C/S架构的聊天器效率很低,这里我们换一种方式来做聊天工具。
该软件采用P2P方式,各个客户端之间直接发消息进行会话聊天,服务器在其中只扮演协调者的角色(混合型P2P)。
1.会话流程设计
当一个新用户通过自己的客户端登陆系统后,从服务器获取当前在线的用户信息列表,列表信息包括了系统中每个用户的地址。用户就可以开始独立工作,自主地向其他用户发送消息,而不经过服务器。每当有新用户加入或在线用户退出时,服务器都会及时发消息通知系统中的所有其他用户,以便它们实时地更新用户信息列表。
按照上述思路,设计系统会话流程如下:
(1)用户通过客户端进入系统,向服务器发出消息,请求登陆。
(2)服务器收到请求后,向客户端返回应答消息,表示同意接受该用户加入,并顺带将自己服务线程所在的监听端口号告诉用户。
(3)客户端按照服务器应答中给出的端口号与服务器建立稳定的连接。
(4)服务器通过该连接将当前在线用户的列表信息传给新加入的客户端。
(5)客户端获得了在线用户列表,就可以独立自主地与在线的其他用户通信了。
(6)当用户退出系统时要及时地通知服务器。
2.用户管理
系统中,无论是服务器还是客户端都保存一份在线用户列表,客户端的用户表在一开始登陆时从服务器索取获得。在程序运行的过程中,服务器负责实时地将系统内用户的变动情况及时地通知在线的每个成员用户。
新用户登录时,服务器将用户表传给他,同时向系统内每个成员广播“login”消息,各成员收到后更新自己的用户表。
同样,在有用户退出系统时,服务器也会及时地将这一消息传给各个用户,当然这也就要求每个用户在自己想要退出之前,必须要先告诉服务器。
3.协议设计
3.1 客户端与服务器会话
(1)登陆过程。
客户端用匿名UDP向服务器发送消息:
login,username,localIPEndPoint
消息内容包括3个字段,各字段之间用“,”分隔:“login”表示请求登陆;“username”为用户名;“localIPEndPoint”是客户端本地地址。
服务器收到后以匿名UDP返回如下消息:
Accept,port
其中,“Accept”表示服务器接受了请求;“port”是服务所在端口,服务线程在这个端口上监听可能的客户连接,该连接使用同步的TCP。
连上服务器,获取用户列表:
客户端从上一会话的“port”字段的值服务所在端口,于是向端口发起TCP连接,向服务器索取在线的用户列表,服务器接受连接后将用户列别传输给客户端。
用户列表格式如下:
username1,IPEndPoint1;username2,IPEndPoint2;.....;end
username1,username2.....为用户名,IPEndPoint1,IPEndPoint2....为它们对应的端点。每个用户的信息都有个“用户名+端点”组成,用户信息之间以“;”隔开,整个用户列表以“end”结尾。
3.1 服务器协调管理用户
(1)新用户加入通知。
由于系统中已存在的每个用户都有一份当前用户表,因此当有新成员加入时,服务器无需重复给系统中的每个成员再传送用户表,只要将新加入成员的信息告诉系统内的其他用户,再由他们各自更新自己的用户表就行了。
服务器向系统内用户广播发送如下消息:
端点字段写为“remoteIPEndPoint”,表示是远程某个用户终端登陆了,本地客户线程据此更新用户列表。其实,在这个过程中,服务器只是将受到的“login”消息简单地转发而已。
(2)用户退出。
与新成员加入时一样,服务器将用户退出的消息直接进行广播转发:
logout,username,remoteIPEndPoint
其中,“remoteIPEndPoint”为退出系统的远程用户终端的端点地址。
3.1 用户终端之间聊天
用户聊天时,他们各自的客户端之间是以P2P方式工作的,彼此地位对等,独立,不与服务器发生直接联系。
聊天时发送的信息格式为:
talk,longTime,selfUserName,message
“talk”表明这是聊天内容;“longTime”是长时间格式的当前系统时间;“selfUserName”为自己的用户名;“message”是聊天的内容。
4.系统实现
4.1 服务线程
系统运行后,先有服务器启动服务线程,只需单击“启动”按钮即可。
“启动”按钮的事件过程:
1 //点击开始事件处理函数
2 private void buttonStart_Click(object sender, EventArgs e)
3 {
4 //创建接收套接字
5 serverIp = IPAddress.Parse(textBoxServerIp.Text);
6 serverIPEndPoint = new IPEndPoint(serverIp, int.Parse(textBoxServerPort.Text));
7 receiveUdpClient = new UdpClient(serverIPEndPoint);
8
9 //启动接收线程
10 Thread threadReceive = new Thread(ReceiveMessage);
11 threadReceive.Start();
12 buttonStart.Enabled = false;
13 buttonStop.Enabled = true;
14
15 //随机指定监听端口 N( P+1 ≤ N < 65536 )
16 Random random = new Random();
17 tcport = random.Next(port + 1, 65536);
18
19 //创建监听套接字
20 myTcpListener = new TcpListener(serverIp, tcport);
21 myTcpListener.Start();
22
23 //启动监听线程
24 Thread threadListen = new Thread(ListenClientConnect);
25 threadListen.Start();
26 AddItemToListBox(string.Format("服务线程({0})启动,监听端口{1}",serverIPEndPoint,tcport));
27 }
可以看到,服务器先后启动了两个线程:一个是接收线程threadReceive,它在一个实名UDP端口上,时刻准备着接收客户端发来的会话消息;另一个是监听线程threadListen,它在某个随机指定的端口上监听。
服务器接收线程关联的ReceiveMessage()方法:
1 //接收数据
2 private void ReceiveMessage()
3 {
4 IPEndPoint remoteIPEndPoint = new IPEndPoint(IPAddress.Any, 0);
5 while (true)
6 {
7 try
8 {
9 //关闭receiveUdpClient时此句会产生异常
10 byte[] receiveBytes = receiveUdpClient.Receive(ref remoteIPEndPoint);
11 string message = Encoding.Unicode.GetString(receiveBytes, 0, receiveBytes.Length);
12
13 //显示消息内容
14 AddItemToListBox(string.Format("{0}:[{1}]", remoteIPEndPoint, message));
15
16 //处理消息数据
17 string[] splitString = message.Split(',');
18
19 //解析用户端地址
20 string[] splitSubString = splitString[2].Split(':'); //除去':'
21 IPEndPoint clientIPEndPoint = new IPEndPoint(IPAddress.Parse(splitSubString[0]), int.Parse(splitSubString[1]));
22 switch (splitString[0])
23 {
24 //收到注册关键字"login"
25 case "login":
26 User user = new User(splitString[1], clientIPEndPoint);
27 userList.Add(user);
28 AddItemToListBox(string.Format("用户{0}({1})加入", user.GetName(), user.GetIPEndPoint()));
29 string sendString = "Accept," + tcport.ToString();
30 SendtoClient(user, sendString); //向该用户发送同意关键字
31 AddItemToListBox(string.Format("向{0}({1})发出:[{2}]", user.GetName(), user.GetIPEndPoint(), sendString));
32 for (int i = 0; i < userList.Count; i++)
33 {
34 if (userList[i].GetName() != user.GetName())
35 {
36 //向除刚加入的所有用户发送更新消息
37 SendtoClient(userList[i], message);
38 }
39 }
40 AddItemToListBox(string.Format("广播:[{0}]", message));
41 break;
42
43 //收到关键字"logout"
44 case "logout":
45 for (int i = 0; i < userList.Count; i++)
46 {
47 if (userList[i].GetName() == splitString[1])
48 {
49 AddItemToListBox(string.Format("用户{0}({1})退出", userList[i].GetName(), userList[i].GetIPEndPoint()));
50 userList.RemoveAt(i);
51 }
52 }
53
54 //向所用用户发送更新消息
55 for (int i = 0; i < userList.Count; i++)
56 {
57 SendtoClient(userList[i], message);
58 }
59 AddItemToListBox(string.Format("广播:[{0}]", message));
60 break;
61 }
62 }
63 catch
64 {
65 break;
66 }
67 }
68 AddItemToListBox(string.Format("服务线程({0})终止", serverIPEndPoint));
69 }
接收线程执行该方法,进入while()循环,对每个收到的消息进行解析,根据消息头是“login”或“logout”转入相应的处理。
监听线程对应ListenClientConnect()方法:
1 //接受客户端连接
2 private void ListenClientConnect()
3 {
4 TcpClient newClient = null;
5 while (true)
6 {
7 try
8 {
9 //获得用于传递数据的TCP套接口
10 newClient = myTcpListener.AcceptTcpClient();
11 AddItemToListBox(string.Format("接受客户端{0}的 TCP 请求", newClient.Client.RemoteEndPoint));
12 }
13 catch
14 {
15 AddItemToListBox(string.Format("监听线程({0}:{1})终止", serverIp, tcport));
16 break;
17 }
18
19 //启动发送用户列表线程
20 Thread threadSend = new Thread(SendData);
21 threadSend.Start(newClient);
22 }
23 }
当客户端请求到达后,与之建立TCP连接,然后创建一个新的线程threadSend,他通过执行SendData()方法传送用户列表。
在服务器运行过程中,可随时通过点击“停止”按钮关闭服务线程。
”停止“按钮的事件过程:
1 //当点击关闭按钮的事件处理程序
2 private void buttonStop_Click(object sender, EventArgs e)
3 {
4 myTcpListener.Stop();
5 receiveUdpClient.Close();
6 buttonStart.Enabled = true;
7 buttonStop.Enabled = false;
8 }
这里myTcpListener是TCP监听套接字,而receiveUdpClient是UDP套接字。当执行Stop()方法关闭监听套接字时,myTcpListener.AcceptTcpClient()会产生异常。
运行服务器,先后单击”启动“和”停止“按钮,状态监控屏上就显示出服务线程的工作状态,如下图所示。
图1 服务线程的启动/停止状态
4.2 登陆/注销
(1) 用户对象
为了便于服务器对全体用户的管理,在服务器工程中添加自定义User类。代码如下:
1 using System;
2 using System.Collections.Generic;
3