avatar

计算机网络作业1 利用Socket编写一个聊天程序

实验1:利用Socket编写一个聊天程序

0. 摘要

本次实验利用C++语言,编写了基于$TCP$的流式$SOCKET$的简易聊天室。利用$Winsock2.h$头文件编写了一个$WIN32$平台下的程序。该程序可以完成指定人数聊天室的创建,程序主要分为服务器端和客户端,服务器端负责接收客户端消息,解析报文,并将其转发至对应的接收者。在客户端程序中利用多线程编程,可以使消息的收和发之间不相互影响,可以连续发送多条消息,也可以连续接收多条消息。

1. 聊天协议说明

文字编码方式

为了支持中文和英文,使用UNICODE编码

image-20201030155536084

报文格式

本次实验进行了报文格式的简要设计,其内容为发送方+接收方+消息内容,在传输过程中采用字符串char*格式,为了方便解析,我还定义了msg_form类,方便报文内容的提取:

class msg_form
{
public:

char from_name;//发送方标号
char to_name;//接收方
char msg[BUF_SIZE];//消息内容

注:本次实验只是简要设计,没有加入帧校验码等其它内容,并且只用了一个字节代表不同的客户端。在实际操作过程中,不同的客户端应用不同IP地址和端口号区分。

为了方便msg_formchar*之间的转换,定义了两个函数

char* msg_to_string(msg_form a);//用字符串存储报文
msg_form string_to_msg(char a[]);//将字符串恢复成类

通过类型转换,我们可以更加方便快捷的设置和提取报文中的信息

聊天过程

  • 设置聊天室人数CLIENTNUM

  • 先开启服务器端(server.cpp),建立socket,绑定ip和端口号,进入监听模式进行等待

  • 开启客户端程序,设置相关信息,对服务器发出connect请求

  • 服务器accept客户端的连接请求,并建立专属的连接通道,返回给客户端编号信息,等到达到预设的聊天人数即可开始聊天

  • 客户端每次输入的消息格式为收信方编号+消息内容,在发送之前,该字符串前会被添加上发送者的编号。所以实际sendBuf中的内容为发送和编号+收信方编号+消息内容

  • 服务器接收到信息后进行解析得到目的地址(本次程序简化为编号而不是ip),并有针对性的进行转发

  • 当用户想要退出聊天室时,输入任意编号+quit即可退出程序,当服务端检测到在线人数为0时,自动退出程序

2. 编程程序说明

  • 本程序基于WinSock2.h进行编写,并且需要链接ws2_32.lib库到此项目中

    #include <WinSock2.h>//windows socket 编程头文件
    #pragma comment(lib,"ws2_32.lib")//链接ws2_32.lib库文件到此项目中
  • 加载socket库,并约定使用的socket版本,对其进行初始化

    //加载socket库
    WSADATA wsaData;
    //MAKEWORD(2.2),成功返回0
    if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0)
    {
    cout << "socket初始化失败" << endl;
    return 0;
    }
  • 创建socket

    //创建一个socket,并将该socket绑定到一个特定的传输层
    SOCKET sockSer = socket(AF_INET, SOCK_STREAM, 0);//地址类型(ipv4),服务类型(流式套接字)
  • 端口和ip地址进行初始化

    ip地址可以是本机或者其它任意可以ping通的地址

    //初始化地址
    SOCKADDR_IN addrSer
    addrSer.sin_addr.s_addr = inet_addr("192.168.89.1");//本机ip地址中的一个
    addrSer.sin_family = AF_INET;//使用ipv4
    addrSer.sin_port = htons(PORT);//指定端口号,这是PORT=6666(可以打开的任意端口即可)
  • server端需要将socket和ip、端口等内容进行绑定,当bind函数返回值为-1时,说明绑定出错

    为例方便赋值,addrSer声明时为SOCKADDR_IN类型,但是SOCKADDR内全部为char类型类型更*方便传输**

    image-20201030143400096)image-20201030143417834

    if (bind(sockSer, (SOCKADDR*)&addrSer, sizeof(SOCKADDR)) == -1)
    cout << "bind error" << endl;//强制类型转换,SOCKADDR_IN方便赋值 SOCKADDR方便传输
  • server端使用listen函数进入监听模式,client发起connect请求,server端以此进行accept,同时server端可以统计连接client数目,并为client分配标号

    SOCKET sockConn[CLIENTNUM];//在宏定义中声明CLIENTNUM为2,可以修改宏为多个
    //server
    for (int i = 0; i < CLIENTNUM; i++)
    {
    listen(sockSer, 5);
    //为每个客户端分配一个socket连接,将客户端的相关信息存储在addrCli中
    sockConn[i] = accept(sockSer, (SOCKADDR*)&addrCli, &len);
    if (sockConn[i] != INVALID_SOCKET)//成功建立socket
    {
    cond++;//人数加一
    cout << "用户" << i << "进入聊天" << endl;
    char buf[12]="你的id是:";
    buf[10] = 48 + i;//简化,最多九个人
    buf[11] = 0;
    send(sockConn[i], buf, 50, 0);
    }

    }
    //client
    cout << "connecting" << endl;
    //发出连接请求,addrSer是服务器端的ip地址和端口号
    sockCli = connect(sockClient, (SOCKADDR*)&addrSer, sizeof(SOCKADDR));
    if (sockCli != SOCKET_ERROR)
    {
    cout << "connected" << endl;
    while (1)
    {
    char recvBuf[BUF_SIZE] = {};
    //接受服务器端分配的编号
    recv(sockClient, recvBuf, 50, 0);
    if (recvBuf[0])
    {
    cout << recvBuf << endl;
    //将char型编号转为int格式存储
    ID = recvBuf[10] - 48;
    break;
    Sleep(30);//防止循环快速执行,占用大量资源
    }
    }
    }
  • 当连接人数达到预设数量时,可以开始聊天

  • server端负责接收client发来的消息,解析目的地址并进行转发,为了可以同时接收到不同客户端发来的消息,这里使用多线程编程使得for循环内的线程可以同时执行使用while(1)不断循环执行收发线程,为了减小CPU的负担,设置每个转发线程结束后要等待200ms。

    while (1)
    {
    for (int i = 0; i < CLIENTNUM; i++)
    {
    //线程函数
    hThread = CreateThread(NULL, NULL, handlerRequest, LPVOID(i), 0, &dwThreadId);
    //等待
    WaitForSingleObject(hThread, 200);
    //线程资源释放
    CloseHandle(hThread);
    }
    if (!cond)break;//退出程序,后续详细介绍
    }

    函数参数是在连接时服务器为客户端分配的编号

    DWORD WINAPI handlerRequest(LPVOID lparam)
    {
    //指定一个socket通道(即指定接收一个客户端发来的消息)
    SOCKET sockettemp = sockConn[(int)lparam];
    //生成局部变量recvBuf,并初始化为全0
    char recvBuf[BUF_SIZE] = {};
    //接收client发来的消息
    recv(sockettemp, recvBuf, 1000, 0);
    //如果第一个字符不是'\0’即接收到了消息
    if (recvBuf[0])
    {
    cout << recvBuf << endl;
    //将字符串转为消息格式
    msg_form m = string_to_msg(recvBuf);
    //读取目的地址
    int id = m.to_name-48;
    cout << id << endl;
    //进行对应的消息转发
    send(sockConn[id], recvBuf, 50, 0);
    //退出程序,后面详述
    if (!strcmp(m.msg, "quit"))
    {
    cout << "用户" << (int)lparam << "退出聊天" << endl;
    cond--; if (!cond)return 0;
    }
    }
    return 0;
    }
  • 对于client端,由于消息的收发没有顺序,且用户从键盘输入信息会造成阻塞,所以需要将消息的收和发放在两个不同的线程里,同样,将线程函数放在while(1)循环中以不断执行

    //线程
    HANDLE hThread1, hThread2;
    DWORD dwThreadId1, dwThreadId2;
    while (1)//不断循环知道用户输入quit命令
    {
    //发送消息线程
    hThread1 = CreateThread(NULL, NULL, handlerRequest1, LPVOID(sockClient), 0, &dwThreadId1);
    //接收消息线程
    hThread2 = CreateThread(NULL, NULL, handlerRequest2, LPVOID(sockClient), 0, &dwThreadId2);
    //延迟
    WaitForSingleObject(hThread1, 200);
    WaitForSingleObject(hThread2, 200);
    //释放线程资源
    CloseHandle(hThread2);
    CloseHandle(hThread1);
    //退出
    if (cond) break;
    }
    • 发送消息线程,注意,用户输入消息时,第一位应为消息接收方编号,然后程序自动为其在缓冲区首部添加发送方编号

      DWORD WINAPI handlerRequest1(LPVOID lparam)
      {
      //初始化两个buffer全部为0
      char sendBuf[BUF_SIZE] = {};
      char buffer[BUF_SIZE] = {};
      //接收传进线程函数的参数
      SOCKET socketClient = (SOCKET)(LPVOID)lparam;
      //使用getline函数,防止因为空格等字符终止读入
      cin.getline(buffer, 2048, '\n');
      //发送的消息第一位为发送方编号
      sendBuf[0] = ID + 48;
      //用户从键盘输入消息时,第一位为消息接收方编号
      strcat(sendBuf, buffer);
      //发送消息
      send(socketClient, sendBuf, 2048, 0);
      //分析将要发送的字符串
      msg_form m = string_to_msg(sendBuf);
      //如果message部分是quit则退出程序
      if (!strcmp(m.msg, "quit") || !strcmp(buffer, "quit"))
      cond = 1;
      return 0;
      }
    • 接收消息线程

      DWORD WINAPI handlerRequest2(LPVOID lparam)
      {
      //初始化接收消息缓冲区为全0
      char recvBuf[BUF_SIZE] = {};
      //接收参数
      SOCKET socketClient = (SOCKET)(LPVOID)lparam;
      //接收发来的消息
      recv(socketClient, recvBuf, 2048, 0);
      //如果消息不为空
      if (recvBuf[0])
      {
      //对消息进行解析和打印
      msg_form m = string_to_msg(recvBuf);
      cout << m.from_name << ": " << m.msg << endl;
      }
      return 0;
      }
  • 程序的退出

    • 对于client端,需要由用户手动输入quit指令,为了简化代码,这里依然要使用编号+quit格式,但是编号可以是任意的一位数字

      //状态码
      int cond;//在全局变量区声明,初值为0
      //消息发送函数
      DWORD WINAPI handlerRequest1(LPVOID lparam)
      {
      ......
      //如果消息为“quit,则修改状态码为1
      if (!strcmp(m.msg, "quit") )
      cond = 1;
      return 0;
      }
      int main()
      {
      ......
      while (1)
      {
      hThread1 = CreateThread(NULL, NULL, handlerRequest1, LPVOID(sockClient), 0, &dwThreadId1);
      ......
      //主线程检测到cond值为1,则退出程序
      if (cond) break;
      }
      //关闭socket连接
      closesocket(sockClient);
      //终止Winsock 2 DLL (Ws2_32.dll) 的使用
      WSACleanup();
      return 0;
      }
    • 对于server端,如果需要服务器常开则不需要退出程序,也可以设置为当所有客户端均断开连接时退出

      这里对后者的代码进行展示

      int cond;//全局数据区,初始化为0

      在accept函数执行成功时,执行cond++,当所有客户端都成功连接时,cond==CLIENTNUM

      //转发函数
      DWORD WINAPI handlerRequest(LPVOID lparam)
      {
      ......
      //解析到客户端发来的消息为0
      if (!strcmp(m.msg, "quit"))
      {
      cout << "用户" << (int)lparam << "退出聊天" << endl;
      //已连接数量-1
      cond--; if (!cond)return 0;
      }
      return 0;
      }
      if (cond == CLIENTNUM)
      {
      cout << CLIENTNUM << " clients have connected" << endl;
      while (1)
      {
      ......//转发
      //当主线程检测到在线人数为0时,退出程序
      if (!cond)break;
      }
      }
      closesocket(sockSer);
      WSACleanup();
      return 0;

3. 对话界面展示和对话的退出

  • 启动一个server程序和两个client程序
image-20201030153626308
  • 0号客户向1号发送消息Hi,server解析到01Hi,目的地址是1,向1号进行转发,1号客户端收到由0发来的消息Hi。并且可以看到程序支持收发中英文消息
image-20201030153940014
  • 如果0号向0号(自己)发送消息,则server回将收到的消息转发回去,而1号不能收到消息
image-20201030154052095
  • 同样,1也可以向0发送消息
image-20201030154119832
  • 0号发出退出指定,程序结束,server检测到用户0退出
image-20201030154134653
  • 1号发出退出指令,退出程序,server检测到1号退出,这时所有连接都已经断开,sesrver自动退出

image-20201030154153379

Author: Michelle19l
Link: https://gitee.com/michelle19l/michelle19l/2021/01/15/大三上/计网/计网作业1/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.
Donate
  • 微信
    微信
  • 支付寶
    支付寶