引言:

前面专题中介绍了UDP、TCP和P2P编程,并且通过一些小的示例来让大家更好的理解它们的工作原理以及怎样.Net类库去实现它们的。为了让大家更好的理解我们平常中常见的软件QQ的工作原理,所以在本专题中将利用前面专题介绍的知识来实现一个类似QQ的聊天程序。

 一、即时通信系统

在我们的生活中经常使用即时通信的软件,我们经常接触到的有:QQ、阿里旺旺、MSN等等。这些都是属于即时通信(Instant Messenger,IM)软件,IM是指所有能够即时发送和接收互联网消息的软件。

在前面专题P2P编程中介绍过P2P系统分两种类型——单纯型P2P和混合型P2P(QQ就是属于混合型的应用),混合型P2P系统中的服务器(也叫索引服务器)起到协调的作用。在文件共享类应用中,如果采用混合型P2P技术的话,索引服务器就保存着文件信息,这样就可能会造成版权的问题,然而在即时通信类的软件中, 因为客户端传递的都是简单的聊天文本而不是网络媒体资源,这样就不存在版权问题了,在这种情况下,就可以采用混合型P2P技术来实现我们的即时通信软件。前面已经讲了,腾讯的QQ就是属于混合型P2P的软件。

因此本专题要实现一个类似QQ的聊天程序,其中用到的P2P技术是属于混合型P2P,而不是前一专题中的采用的单纯型P2P技术,同时本程序的实现也会用到TCP、UDP编程技术。

二、程序实现的详细设计

本程序采用P2P方式,各个客户端之间直接发消息进行聊天,服务器在其中只是起到协调的作用,下面先理清下程序的流程:

2.1 程序流程设计

当一个新用户通过客户端登陆系统后,从服务器获取当在线的用户信息列表,列表信息包括系统中每个用户的地址,然后用户就可以单独向其他发消息。如果有用户加入或者在线用户退出时,服务器就会及时发消息通知系统中的所有其他客户端,达到它们即时地更新用户信息列表。

根据上面大致的描述,我们可以把系统的流程分为下面几步来更好的理解(大家可以参考QQ程序将会更好的理解本程序的流程):

1.用户通过客户端进入系统,向服务器发出消息,请求登陆

2.服务器收到请求后,向客户端返回回应消息,表示同意接受该用户加入,并把自己(指的是服务器)所在监听的端口发送给客户端

3.客户端根据服务器发送过来的端口号和服务器建立连接

4.服务器通过该连接 把在线用户的列表信息发送给新加入的客户端。

5.客户端获得了在线用户列表后就可以自己选择在线用户聊天。(程序中另外设计一个类似QQ的聊天窗口来进行聊天)

6.当用户退出系统时也要及时通知服务器,服务器再把这个消息转发给每个在线的用户,使客户端及时更新本地的用户信息列表。 

2.2 通信协议设计

所谓协议就是约定,即服务器和客户端之间会话信息的内容格式进行约定,使双方都可以识别,达到更好的通信。

下面就具体介绍下协议的设计:

1. 客户端和服务器之间的对话

(1)登陆过程

① 客户端用匿名UDP的方式向服务器发出下面的信息:

login, username, localIPEndPoint

 消息内容包括三个字段,每个字段用 “,”分割,login表示的是请求登陆;username表示用户名;localIPEndPint表示客户端本地地址。

② 服务器收到后以匿名UDP返回下面的回应:

Accept, port

其中Accept表示服务器接受请求,port表示服务器所在的端口号,服务器监听着这个端口的客户端连接

③ 连接服务器,获取用户列表

客户端从上一步获得了端口号,然后向该端口发起TCP连接,向服务器索取在线用户列表,服务器接受连接后将用户列表传输到客户端。用户列表信息格式如下:

 username1,IPEndPoint1;username2,IPEndPoint2;...;end

username1、username2表示用户名,IPEndPoint1,IPEndPoint2表示对应的端点,每个用户信息都是由"用户名+端点"组成,用户信息以“;”隔开,整个用户列表以“end”结尾。

(2)注销过程

用户退出时,向服务器发送如下消息:

logout,username,localIPEndPoint

这条消息看字面意思大家都知道就是告诉服务器 username+localIPEndPoint这个用户要退出了。

2. 服务器管理用户

(1)新用户加入通知

  因为系统中在线的每个用户都有一份当前在线用户表,因此当有新用户登录时,服务器不需要重复地给系统中的每个用户再发送所有用户信息,只需要将新加入用户的信息通知其他用户,其他用户再更新自己的用户列表。

服务器向系统中每个用户广播如下信息:login,username,remoteIPEndPoint

在这个过程中服务器只是负责将收到的"login"信息转发出去。

(2)用户退出

  与新用户加入一样,服务器将用户退出的消息进行广播转发:logout,username,remoteIPEndPoint

3. 客户端之间聊天

用户进行聊天时,各自的客户端之间是以P2P方式进行工作的,不与服务器有直接联系,这也是P2P技术的特点。

聊天发送的消息格式如下:talk, longtime, selfUserName, message

其中,talk表明这是聊天内容的消息;longtime是长时间格式的当前系统时间;selfUserName为发送发的用户名;message表示消息的内容。

协议设计介绍完后,下面就进入本程序的具体实现的介绍的。

注:协议是本程序的核心,也是所有软件的核心,每个软件产品的协议都是不一样的,QQ有自己的一套协议,MSN又有另一套协议,所以使用的QQ的用户无法和用MSN的朋友进行聊天。 

三、程序的实现

 服务器端核心代码:

// 启动服务器
    // 根据博客中协议的设计部分
    // 客户端先向服务器发送登录请求,然后通过服务器返回的端口号
    // 再与服务器建立连接
    // 所以启动服务按钮事件中有两个套接字:一个是接收客户端信息套接字和
    // 监听客户端连接套接字
    private void btnStart_Click(object sender, EventArgs e)
    {
      // 创建接收套接字
      serverIp = IPAddress.Parse(txbServerIP.Text);
      serverIPEndPoint = new IPEndPoint(serverIp, int.Parse(txbServerport.Text));
      receiveUdpClient = new UdpClient(serverIPEndPoint);
      // 启动接收线程
      Thread receiveThread = new Thread(ReceiveMessage);
      receiveThread.Start();
      btnStart.Enabled = false;
      btnStop.Enabled = true;

      // 随机指定监听端口
      Random random = new Random();
      tcpPort = random.Next(port + 1, 65536);

      // 创建监听套接字
      tcpListener = new TcpListener(serverIp, tcpPort);
      tcpListener.Start();

      // 启动监听线程
      Thread listenThread = new Thread(ListenClientConnect);
      listenThread.Start();
      AddItemToListBox(string.Format("服务器线程{0}启动,监听端口{1}",serverIPEndPoint,tcpPort));
    }

    // 接收客户端发来的信息
    private void ReceiveMessage()
    {
      IPEndPoint remoteIPEndPoint = new IPEndPoint(IPAddress.Any, 0);
      while (true)
      {
        try
        {
          // 关闭receiveUdpClient时下面一行代码会产生异常
          byte[] receiveBytes = receiveUdpClient.Receive(ref remoteIPEndPoint);
          string message = Encoding.Unicode.GetString(receiveBytes, 0, receiveBytes.Length);

          // 显示消息内容
          AddItemToListBox(string.Format("{0}:{1}",remoteIPEndPoint,message));

          // 处理消息数据
          // 根据协议的设计部分,从客户端发送来的消息是具有一定格式的
          // 服务器接收消息后要对消息做处理
          string[] splitstring = message.Split(',');
          // 解析用户端地址
          string[] splitsubstring = splitstring[2].Split(':');
          IPEndPoint clientIPEndPoint = new IPEndPoint(IPAddress.Parse(splitsubstring[0]), int.Parse(splitsubstring[1]));
          switch (splitstring[0])
          {
            // 如果是登录信息,向客户端发送应答消息和广播有新用户登录消息
            case "login":
              User user = new User(splitstring[1], clientIPEndPoint);
              // 往在线的用户列表添加新成员
              userList.Add(user);
              AddItemToListBox(string.Format("用户{0}({1})加入", user.GetName(), user.GetIPEndPoint()));
              string sendString = "Accept," + tcpPort.ToString();
              // 向客户端发送应答消息
              SendtoClient(user, sendString);
              AddItemToListBox(string.Format("向{0}({1})发出:[{2}]", user.GetName(), user.GetIPEndPoint(), sendString));
              for (int i = 0; i < userList.Count; i++)
              {
                if (userList[i].GetName() != user.GetName())
                {
                  // 给在线的其他用户发送广播消息
                  // 通知有新用户加入
                  SendtoClient(userList[i], message);
                }
              }

              AddItemToListBox(string.Format("广播:[{0}]", message));
              break;
            case "logout":
              for (int i = 0; i < userList.Count; i++)
              {
                if (userList[i].GetName() == splitstring[1])
                {
                  AddItemToListBox(string.Format("用户{0}({1})退出",userList[i].GetName(),userList[i].GetIPEndPoint()));
                  userList.RemoveAt(i); // 移除用户
                }
              }
              for (int i = 0; i < userList.Count; i++)
              {
                // 广播注销消息
                SendtoClient(userList[i], message);
              }
              AddItemToListBox(string.Format("广播:[{0}]", message));
              break;
          }
        }
        catch
        {
          // 发送异常退出循环
          break;
        }
      }
      AddItemToListBox(string.Format("服务线程{0}终止", serverIPEndPoint));
    }

    // 向客户端发送消息
    private void SendtoClient(User user, string message)
    {
      // 匿名方式发送
      sendUdpClient = new UdpClient(0);
      byte[] sendBytes = Encoding.Unicode.GetBytes(message);
      IPEndPoint remoteIPEndPoint =user.GetIPEndPoint();
      sendUdpClient.Send(sendBytes,sendBytes.Length,remoteIPEndPoint);
      sendUdpClient.Close();
    }
    
    // 接受客户端的连接
    private void ListenClientConnect()
    {
      TcpClient newClient = null;
      while (true)
      {
        try
        {
          newClient = tcpListener.AcceptTcpClient();
          AddItemToListBox(string.Format("接受客户端{0}的TCP请求",newClient.Client.RemoteEndPoint));
        }
        catch
        {
          AddItemToListBox(string.Format("监听线程({0}:{1})", serverIp, tcpPort));
          break;
        }

        Thread sendThread = new Thread(SendData);
        sendThread.Start(newClient);
      }
    }

    // 向客户端发送在线用户列表信息
    // 服务器通过TCP连接把在线用户列表信息发送给客户端
    private void SendData(object userClient)
    {
      TcpClient newUserClient = (TcpClient)userClient;
      userListstring = null;
      for (int i = 0; i < userList.Count; i++)
      {
        userListstring += userList[i].GetName() + ","
          + userList[i].GetIPEndPoint().ToString() + ";";
      }

      userListstring += "end";
      networkStream = newUserClient.GetStream();
      binaryWriter = new BinaryWriter(networkStream);
      binaryWriter.Write(userListstring);
      binaryWriter.Flush();
      AddItemToListBox(string.Format("向{0}发送[{1}]", newUserClient.Client.RemoteEndPoint, userListstring));
      binaryWriter.Close();
      newUserClient.Close();
    }

客户端核心代码:

// 登录服务器
    private void btnlogin_Click(object sender, EventArgs e)
    {
      // 创建接受套接字
      IPAddress clientIP = IPAddress.Parse(txtLocalIP.Text);
      clientIPEndPoint = new IPEndPoint(clientIP, int.Parse(txtlocalport.Text));
      receiveUdpClient = new UdpClient(clientIPEndPoint);
      // 启动接收线程
      Thread receiveThread = new Thread(ReceiveMessage);
      receiveThread.Start();

      // 匿名发送
      sendUdpClient = new UdpClient(0);
      // 启动发送线程
      Thread sendThread = new Thread(SendMessage);
      sendThread.Start(string.Format("login,{0},{1}", txtusername.Text, clientIPEndPoint));

      btnlogin.Enabled = false;
      btnLogout.Enabled = true;
      this.Text = txtusername.Text;
    }

    // 客户端接受服务器回应消息 
    private void ReceiveMessage()
    {
      IPEndPoint remoteIPEndPoint = new IPEndPoint(IPAddress.Any,0);
      while (true)
      {
        try
        {
          // 关闭receiveUdpClient时会产生异常
          byte[] receiveBytes = receiveUdpClient.Receive(ref remoteIPEndPoint);
          string message = Encoding.Unicode.GetString(receiveBytes,0,receiveBytes.Length);

          // 处理消息
          string[] splitstring = message.Split(',');

          switch (splitstring[0])
          {
            case "Accept":
              try
              {
                tcpClient = new TcpClient();
                tcpClient.Connect(remoteIPEndPoint.Address, int.Parse(splitstring[1]));
                if (tcpClient != null)
                {
                  // 表示连接成功
                  networkStream = tcpClient.GetStream();
                  binaryReader = new BinaryReader(networkStream);
                }
              }
              catch
              {
                MessageBox.Show("连接失败", "异常");
              }

              Thread getUserListThread = new Thread(GetUserList);
              getUserListThread.Start();
              break;
            case "login":
              string userItem = splitstring[1] + "," + splitstring[2];
              AddItemToListView(userItem);
              break;
            case "logout":
              RemoveItemFromListView(splitstring[1]);
              break;
            case "talk":
              for (int i = 0; i < chatFormList.Count; i++)
              {
                if (chatFormList[i].Text == splitstring[2])
                {
                  chatFormList[i].ShowTalkInfo(splitstring[2], splitstring[1], splitstring[3]);
                }
              }

              break;
          }
        }
        catch
        {
          break;
        }
      }
    }

    // 从服务器获取在线用户列表
    private void GetUserList()
    {
      while (true)
      {
        userListstring = null;
        try
        {
          userListstring = binaryReader.ReadString();
          if (userListstring.EndsWith("end"))
          {
            string[] splitstring = userListstring.Split(';');
            for (int i = 0; i < splitstring.Length - 1; i++)
            {
              AddItemToListView(splitstring[i]);
            }

            binaryReader.Close();
            tcpClient.Close();
            break;
          }
        }
        catch
        {
          break;
        }
      }
    }
  // 发送登录请求
    private void SendMessage(object obj)
    {
      string message = (string)obj;
      byte[] sendbytes = Encoding.Unicode.GetBytes(message);
      IPAddress remoteIp = IPAddress.Parse(txtserverIP.Text);
      IPEndPoint remoteIPEndPoint = new IPEndPoint(remoteIp, int.Parse(txtServerport.Text));
      sendUdpClient.Send(sendbytes, sendbytes.Length, remoteIPEndPoint);
      sendUdpClient.Close();
    }

程序的运行结果:

首先先运行服务器窗口,在服务器窗口点击“启动”按钮来启动服务器,然后客户端首先指定服务器的端口号,修改用户名(这里也可以不修改,使用默认的也可以),然后点击“登录”按钮来登陆服务器(也就是告诉服务器本地的客户端地址),然后从服务器端获得在线用户列表,界面演示如下:

然后用户可以双击在线用户进行聊天(此程序支持与多人进行聊天),下面是功能的演示图片:

双方进行聊天时,这里没有实现像QQ一样,有人发信息来在对应的客户端就有消息提醒的功能的, 所以双方进行聊天的过程中,每个客户端都需要在在线用户列表中点击聊天的对象来激活聊天对话框(意思就是从图片中可以看出“天涯”客户端想和剑痴聊天的话,就在“在线用户”列表双击剑痴来激活聊天窗口,同时“剑痴”客户端也必须双击“天涯”来激活聊天窗口,这样双方就看到对方发来的信息了,(不激活窗口,也是发送了信息,只是没有一个窗口来进行显示)),而且从图片中也可以看出——此程序支持与多人聊天,即天涯同时与“剑痴”和"大地"同时聊天。

本程序的源代码链接:demo

四、总结

 本专题介绍了如何去实现一个类似QQ的聊天程序,一方面让大家可以巩固前面专题的内容,另一方面让大家更好的理解即时通信软件(腾讯QQ)的工作原理和软件协议的设计。

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持悠悠之家。

点赞(131)

评论列表共有 0 条评论

立即
投稿
返回
顶部