【例4-1
】使用P2P
技术设计一个简易聊天程序,要求不使用专用的主服务器,只要将好友添加到好友列表中,就能检测到好友是否在线,并相互发送聊天信息。
(1)
创建一个名为P2PExample
的Windows
应用程序,将Form1.cs
换名为FormP2P.cs
,然后在该设计窗体内设计如图4-1
所示的界面。
这是完成聊天功能的主界面。由于不存在主服务器,所以添加好友时,需要提供好友所用计算机的IP
地址和端口号。为了方便起见,程序中自动生成并显示出本机当前所用的IP
地址和端口,如果程序运行时IP
地址没有显示出来,就无法和别的计算机连接。
(2)
添加命名空间引用:
using System.IO;
using System.Net.Sockets;
using System.Net;
using System.Threading;
(3)
在构造函数上方添加字段声明,并在构造函数中添加代码:
private Thread myThread;
private TcpListener tcpListener;
private IPAddress myIPAddress;
private int myPort;
private System.Diagnostics.Stopwatch secondWatch;
public FormP2P()
{
InitializeComponent();
secondWatch = new System.Diagnostics.Stopwatch();
ColumnHeader ipColumn = new ColumnHeader();
ipColumn.Text = "IP地址";
ipColumn.Width = 136;
ColumnHeader portColumn = new ColumnHeader();
portColumn.Text = "端口号";
ColumnHeader onlineColumn = new ColumnHeader();
onlineColumn.Text = "是否在线";
onlineColumn.Width = 71;
listViewMyFriend.View = View.Details;
listViewMyFriend.Columns.AddRange(
new ColumnHeader[] { ipColumn, portColumn, onlineColumn });
}
(4)
添加Load
事件代码:
private void FormP2P_Load(object sender, EventArgs e)
{
//启动秒表
secondWatch.Start();
timerSecond.Enabled = true;
buttonStartTimer.Enabled = false;
buttonStopTimer.Enabled = true;
//使用代理指定在线程上执行的方法
ThreadStart myThreadStartDelegate = new ThreadStart(Listening);
//创建一个用于监听的线程对象,通过代理执行线程中的方法
myThread = new Thread(myThreadStartDelegate);
//启动线程
myThread.Start();
}
(5)
添加线程执行的方法:
//该方法是通过代理调用执行的
private void Listening()
{
Socket socket = null;
//获取本机第一个可用IP地址
myIPAddress = (IPAddress)Dns.GetHostAddresses(Dns.GetHostName()).GetValue(0);
Random r = new Random();
while (true)
{
try
{
//随机产生一个0-65535之间的端口号
myPort = r.Next(65535);
//创建TcpListener对象,在本机的IP和port端口监听连接到该IP和端口的请求
tcpListener = new TcpListener(myIPAddress, myPort);
tcpListener.Start();
//显示IP地址和端口
ShowLocalIpAndPort();
//在信息窗口中显示成功信息
ShowMyMessage(string.Format("尝试用端口{0}监听成功", myPort));
ShowMyMessage(string.Format(
"<message>[{0}]{1:h点m分s秒}开始在{2}端口监听与本机的连接",
myIPAddress, DateTime.Now, myPort));
break;
}
catch
{
//继续while循环,以便随机找下一个可用端口号,同时显示失败信息
ShowMyMessage(string.Format("尝试用端口{0}监听失败", myPort));
}
}
while (true)
{
try
{
//使用阻塞方式接收客户端连接,根据连接信息创建TcpClient对象
//注意:AcceptSocket接收到新的连接请求才会继续执行其后的语句
socket = tcpListener.AcceptSocket();
//如果往下执行,说明已经根据客户端连接请求创建了套接字
//使用创建的套接字接收客户端发送的信息
NetworkStream stream = new NetworkStream(socket);
StreamReader sr = new StreamReader(stream);
string receiveMessage = sr.ReadLine();
int i1 = receiveMessage.IndexOf(",");
int i2 = receiveMessage.IndexOf(",", i1 + 1);
int i3 = receiveMessage.IndexOf(",", i2 + 1);
string ipString = receiveMessage.Substring(0, i1);
string portString = receiveMessage.Substring(i1 + 1, i2 - i1 - 1);
string messageTypeString = receiveMessage.Substring(i2 + 1, i3 - i2 - 1);
string messageString = receiveMessage.Substring(i3 + 1);
ShowMyMessage(ipString, portString, messageTypeString, messageString);
}
catch
{
//通过停止TcpListener使AcceptSocket()出现异常
//在异常处理中关闭套接字并终止线程
if (socket != null)
{
if (socket.Connected)
{
socket.Shutdown(SocketShutdown.Receive);
}
socket.Close();
}
myThread.Abort();
}
}
}
在第一个while
循环中,首先随机生成一个端口号,然后使用本机第一个IP
地址和产生的端口进行监听,如果不出现异常,说明该IP
地址和端口号可用,退出while
循环;否则继续随机产生下一个端口,直到找到可用的端口为止。
在第二个while
循环中,使用AcceptSocket
方法接收到该端口的连接请求,在接收到连接请求之前,该方法一直处于阻塞方式,一旦接收到新的连接请求,就创建一个对应的套接字对象,然后就可以利用这个套接字对象创建网络流对象,再利用StreamReader
从网络流中一直读取字符,直到遇到回车换行为止。
注意,这里使用回车换行作为每条信息之间的分隔符。在介绍TCP
协议时,读者已经知道TCP
协议是没有消息边界的,如果不考虑这个问题,就无法保证发送的每条信息和接收的每条信息一一对应,例如可能会出现发送的几条信息一块接收的情况。
考虑下面几条语句:
byte[] buffer = new byte[socket.ReceiveBufferSize];
int i = socket.Receive(buffer, buffer.Length, SocketFlags.None);
string receiveMessage = System.Text.Encoding.UTF8.GetString(buffer, 0, i);
如果不仔细考虑,读者可能会认为这几条语句和使用StreamReader
对象从网络流读取方式完成的功能相同,其实这样使用会出现问题的。由于边界问题和网络中传输的影响,使用socket.Receive
方法接收到的不一定刚好是发送的一条信息,可能是一部分,也可能是几条信息都在缓冲区内,而这段代码中只接收了一次,自然无法保证和发送的一条信息完全对应。
另外,如果在一台机器上调试,由于没有网络传输的影响,自然也就无法发现这段代码中存在的问题。即使在一个局域网中调试,网络传输的影响很小,也很难发现边界问题。
而使用StreamReader
对象的ReadLine
方法,则可以一直读取直到遇到回车换行为止,从而保证与发送的以回车换行结尾的每条信息对应。
接收到一条信息以后,根据发送时在字符串中插入的逗号分隔符,将其分开,分别得到对方的IP
地址、端口号、信息类型以及信息。然后调用SendMyMessage
方法分别处理。
还有一点要注意,不能在第二个循环中试图用一个布尔型变量作为是否退出循环的判断标准,这是因为程序执行到AcceptSocket
方法时会处于阻塞方式,无法保证及时响应该布尔值的变化,也就失去了判断的意义。
(6)
添加代理及相应的方法:
delegate void ShowMessageDelegate1(string str);
private void ShowMyMessage(string str)
{
//比较调用的线程和创建的线程是否同一个线程
//如果不是,结果为true
if (this.listBoxMessage.InvokeRequired == true)
{
//如果结果为true,则自动通过代理执行else中语句的功能(注意:是else,不是if)
//这里只需要传入参数str即可
//但是执行的功能会始终与else中的指定的功能相同,区别仅是通过代理完成
ShowMessageDelegate1 messageDelegate = new ShowMessageDelegate1(ShowMyMessage);
this.Invoke(messageDelegate, new object[] { str });
}
else
{
//在这里指定如果是同一个线程需要完成什么功能
//如果是不同线程,系统会自动通过代理实现这里指定的功能
listBoxMessage.Items.Add(str);
}
}
delegate void ShowMessageDelegate2(string ipString, string portString, string messageTypeString, string messageString);
private void ShowMyMessage(string ipString, string portString, string messageTypeString, string messageString)
{
if (this.listBoxMessage.InvokeRequired == true)
{
ShowMessageDelegate2 messageDelegate = new ShowMessageDelegate2(ShowMyMessage);
this.Invoke(messageDelegate, new object[] { ipString, portString, messageTypeString, messageString });
}
else
{
//messageType的含义为:
//<connect>前缀:第i次连接
//<check>前缀:检查接收者是否在线
//<message>前缀:聊天信息
int myfriendIndex = CheckMyFriend(ipString);
switch (messageTypeString)
{
case "connect":
if (myfriendIndex == -1)
{
ListViewItem myFriendItem = new ListViewItem(
new string[] { ipString, portString, "是" });
listViewMyFriend.Items.Add(myFriendItem);
}
listBoxMessage.Items.Add(string.Format("[{0}:{1}]说:{2}",
ipString, portString, messageString));
break;
case "check":
if (myfriendIndex == -1)
{
ListViewItem myFriendItem = new ListViewItem(
new string[] { ipString, portString, "是" });
listViewMyFriend.Items.Add(myFriendItem);
}
//不需要显示
break;
case "message":
listBoxMessage.Items.Add(string.Format("[{0}:{1}]说:{2}",
ipString, portString, messageString));
break;
default:
listBoxMessage.Items.Add(string.Format("什么意思呀:“{0}”", messageString));
break;
}
}
}
delegate void ShowIpAndPortDelegate();
private void ShowLocalIpAndPort()
{
if (this.listBoxMessage.InvokeRequired)
{
ShowIpAndPortDelegate messageDelegate =
new ShowIpAndPortDelegate(ShowLocalIpAndPort);
this.Invoke(messageDelegate);
}
else
{
textBoxLocalIp.Text = myIPAddress.ToString();
textBoxLocalPort.Text = myPort.ToString();
}
}
使用代理的目的是为了解决一个线程无法在托管模式下直接调用另一个线程的控件问题。当然,如果通过非托管模式,也可以直接调用,但可能会引起死锁等问题。
(7)
添加代码,检查好友是否在好友列表中:
private int CheckMyFriend(string remoteIpString)
{
//在listViewMyFriend中检查指定的ip是否存在
ListViewItem item = listViewMyFriend.FindItemWithText(remoteIpString);
if (item == null)
{
return -1;
}
else
{
return item.Index;
}
}
代码中的FindItemWithText
方法检查ListView
中的每一项中是否包含指定的字符串,如果是,则返回该项,否则返回null
。由于好友列表中的其他列不可能有和指定的IP
地址相同的内容,所以这里也就相当于检查第一列是否有指定的IP
。
(8)
添加SendMessage
方法:
private void SendMessage(string remoteIpString, string remotePortString, string strType, string str)
{
IPAddress remoteIP = IPAddress.Parse(remoteIpString);
int remotePort = int.Parse(remotePortString);
NetworkStream networkstream = null;
TcpClient tcpclient = null;
try
{
tcpclient = new TcpClient(remoteIpString, remotePort);
//得到一个用于发送和接收数据的网络流
networkstream = tcpclient.GetStream();
//使用默认编码和缓冲区大小初始化StreamWriter对象
StreamWriter streamWriter = new StreamWriter(networkstream);
//使用回车换行作为每次发送的分隔符
streamWriter.WriteLine(
string.Format("{0},{1},{2},{3}", myIPAddress, myPort, strType, str));
//将缓冲区内容全部发送出去
streamWriter.Flush();
if (strType != "check")
{
listBoxMessage.Items.Add(
string.Format("向[{0}:{1}]发送成功,信息:{2}",
remoteIpString, remotePortString, str));
}
}
catch (Exception err)
{
int i = CheckMyFriend(remoteIP.ToString());
if (i != -1)
{
listViewMyFriend.Items[i].SubItems[2].Text = "否";
}
if (strType != "check")
{
listBoxMessage.Items.Add(
string.Format("向[{0}:{1}]发送信息失败,原因:{2}",
remoteIpString, remotePortString, err.Message));
}
}
finally
{
if (networkstream != null)
{
networkstream.Close();
}
if (tcpclient != null)
{
tcpclient.Close();
}
}
}
这部分代码完成发送字符串功能,代码中首先根据目标IP
和端口号创建一个TcpClient
对象,然后利用该对象的GetStream
方法创建网络流。使用StreamWriter
对象发送的每条信息均以回车换行结束,便于接收方区分是否到了一条信息的结尾。
如果发送的信息类型为check
,说明仅仅是为了检查对方是否在线,如果对方在线,发送中就不会出现异常,否则会出现异常,表明对方已经断开连接。
对于发送信息为文件的情况,解决边界问题的最好办法是先发送该文件的容量,即该文件占用的字节数,然后再发送文件内容。这样,接收方就可以根据文件大小确定什么时候接收完毕。
(9)
双击【添加】按钮,添加Click
事件代码:
private void buttonAddFriend_Click(object sender, EventArgs e)
{
IPAddress myFriendIpAddress;
if (IPAddress.TryParse(textBoxMyFriendIpAddress.Text, out myFriendIpAddress) == false)
{
MessageBox.Show("IP地址格式不正确!");
return;
}
int myFriendPort;
if (int.TryParse(textBoxMyFriendPort.Text, out myFriendPort) == false)
{
MessageBox.Show("端口号格式不正确!");
return;
}
else
{
if (myFriendPort < 1000 || myFriendPort > 65535)
{
MessageBox.Show("端口号范围不正确!,必须在1000-65535之间");
return;
}
}
int i = CheckMyFriend(textBoxMyFriendIpAddress.Text);
if (i != -1)
{
MessageBox.Show("该好友已经在列表中");
}
else
{
ListViewItem friendItem = new ListViewItem(
new string[] { textBoxMyFriendIpAddress.Text, textBoxMyFriendPort.Text, "是" });
listViewMyFriend.Items.Add(friendItem);
//向对方发送连接信息
SendMessage(textBoxMyFriendIpAddress.Text, textBoxMyFriendPort.Text,
"connect", "哈哈,我上线了");
}
}
(10)
双击【发送】按钮,添加Click
事件代码:
private void buttonSendMessage_Click(object sender, EventArgs e)
{
if (listViewMyFriend.SelectedItems.Count > 0)
{
//可以群发
for (int i = 0; i < listViewMyFriend.SelectedItems.Count; i++)
{
if (listViewMyFriend.SelectedItems[i].SubItems[2].Text == "是")
{
string remoteIpString = listViewMyFriend.SelectedItems[i].SubItems[0].Text;
string remotePortString = listViewMyFriend.SelectedItems[i].SubItems[1].Text;
SendMessage(remoteIpString,
remotePortString, "message", textBoxSendMessage.Text);
}
}
}
else
{
MessageBox.Show("请先选择发送的好友", "提示");
}
}
这段代码中使用for
循环依次向对方发送信息,相当于实现了群发功能。
(11)
双击【启动刷新】按钮,添加Click
事件代码:
private void buttonStartTimer_Click(object sender, EventArgs e)
{
secondWatch.Reset();
int i;
if (int.TryParse(textBoxTimeInterval.Text, out i) == true)
{
if (i <= 0)
{
MessageBox.Show("刷新间隔必须是正整数", "范围不正确");
}
else
{
// timerSecond.Interval = i * 1000;
timerSecond.Enabled = true;
secondWatch.Start();
// timer1.Start();
// textBoxTimerStatus.Text = "已经启动定时刷新";
buttonStartTimer.Enabled = false;
buttonStopTimer.Enabled = true;
}
}
else
{
MessageBox.Show("刷新间隔必须是整数", "格式不正确");
}
}
(12)
双击【停止刷新】按钮,添加Click
事件代码:
private void buttonStopTimer_Click(object sender, EventArgs e)
{
timerSecond.Enabled = false;
secondWatch.Stop();
textBoxTimerStatus.Text = "已经停止定时刷新";
buttonStartTimer.Enabled = true;
buttonStopTimer.Enabled = false;
}
(13)
添加每次到指定的时间间隔触发的事件代码:
private void timerSecond_Tick(object sender, EventArgs e)
{
if (secondWatch.ElapsedMilliseconds / 1000 == int.Parse(textBoxTimeInterval.Text))
{
textBoxTimerStatus.Text = "刷新";
timerSecond.Stop();
secondWatch.Reset();
for (int i = 0; i < listViewMyFriend.Items.Count; i++)
{
string remoteIpString = listViewMyFriend.Items[i].SubItems[0].Text;
string remotePortString = listViewMyFriend.Items[i].SubItems[1].Text;
SendMessage(remoteIpString, remotePortString, "check", "看看你还在线没?");
}
timerSecond.Start();
secondWatch.Start();
}
else
{
textBoxTimerStatus.Text = string.Format("{0}", secondWatch.ElapsedMilliseconds / 1000);
}
}
(14)
添加关闭窗体前触发的事件代码:
private void FormP2P_FormClosing(object sender, FormClosingEventArgs e)
{
tcpListener.Stop();
}
(15)
按<F5>
键编译并执行。