实验1:利用Socket编写一个聊天程序
0. 摘要
本次实验利用C++语言,编写了基于$TCP$的流式$SOCKET$的简易聊天室。利用$Winsock2.h$头文件编写了一个$WIN32$平台下的程序。该程序可以完成指定人数聊天室的创建,程序主要分为服务器端和客户端,服务器端负责接收客户端消息,解析报文,并将其转发至对应的接收者。在客户端程序中利用多线程编程,可以使消息的收和发之间不相互影响,可以连续发送多条消息,也可以连续接收多条消息。
1. 聊天协议说明
文字编码方式
为了支持中文和英文,使用UNICODE编码
报文格式
本次实验进行了报文格式的简要设计,其内容为发送方+接收方+消息内容,在传输过程中采用字符串char*格式,为了方便解析,我还定义了msg_form
类,方便报文内容的提取:
class msg_form |
注:本次实验只是简要设计,没有加入帧校验码等其它内容,并且只用了一个字节代表不同的客户端。在实际操作过程中,不同的客户端应用不同IP地址和端口号区分。
为了方便msg_form
和char*
之间的转换,定义了两个函数
char* msg_to_string(msg_form a);//用字符串存储报文 |
通过类型转换,我们可以更加方便快捷的设置和提取报文中的信息
聊天过程
设置聊天室人数CLIENTNUM
先开启服务器端(server.cpp),建立socket,绑定ip和端口号,进入监听模式进行等待
开启客户端程序,设置相关信息,对服务器发出connect请求
服务器accept客户端的连接请求,并建立专属的连接通道,返回给客户端编号信息,等到达到预设的聊天人数即可开始聊天
客户端每次输入的消息格式为收信方编号+消息内容,在发送之前,该字符串前会被添加上发送者的编号。所以实际sendBuf中的内容为发送和编号+收信方编号+消息内容
服务器接收到信息后进行解析得到目的地址(本次程序简化为编号而不是ip),并有针对性的进行转发
当用户想要退出聊天室时,输入任意编号+quit即可退出程序,当服务端检测到在线人数为0时,自动退出程序
2. 编程程序说明
本程序基于WinSock2.h进行编写,并且需要链接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类型类型更*方便传输**
)
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程序
- 0号客户向1号发送消息
Hi
,server解析到01Hi
,目的地址是1
,向1号进行转发,1号客户端收到由0发来的消息Hi
。并且可以看到程序支持收发中英文消息
- 如果0号向0号(自己)发送消息,则server回将收到的消息转发回去,而1号不能收到消息
- 同样,1也可以向0发送消息
- 0号发出退出指定,程序结束,server检测到用户0退出
- 1号发出退出指令,退出程序,server检测到1号退出,这时所有连接都已经断开,sesrver自动退出