实验1:利用Socket,编写一个聊天程序
实验要求
要求
1.给出聊天协议的完整说明;
2.利用 C 或 C++ 语言,使用基本的 Socket 函数完成程序。不允许使用 CSocket 等封装后的类编写程序;
3.使用流式套接字、采用多线程(或多进程)方式完成程序;
4.程序应该有基本的对话界面,但可以不是图形界面。程序应该有正常的退出方式;
5.完成的程序应该支持多人聊天,支持英文和中文聊天;
6.编写的程序应该结构清晰,具有较好的可读性;
7.在实验中观察是否有数据丢失,提交可执行文件、程序源码和实验报告。
概念解析
什么是聊天协议?
聊天协议是一种定义了客户端和服务器之间如何进行通信的规则集合。它规定了消息格式、编码方式、传输机制等,确保了双方能够正确地发送和接收信息。一个简单的聊天协议可能包括以下几个方面:
1.连接建立:客户端向服务器发起连接请求。
2.身份验证:用户登录时需要提供用户名(或昵称)以及密码等信息,服务器验证这些信息的有效性。
3.消息格式:定义消息的数据结构,比如使用JSON、XML或者自定义二进制格式来表示一条消息。
4.命令类型:除了文本消息外,还可能有系统命令如加入房间、退出房间、获取在线用户列表等。
5.错误处理:定义当发生错误时的消息格式及应对措施。
6.断开连接:客户端可以正常关闭连接或异常断开,服务器需要妥善处理这种情况。
基本的 Socket 函数
- socket
功能:创建一个新的套接字。
原型:int socket(int domain, int type, int protocol);
参数:
domain:地址族,如 AF_INET(IPv4)或 AF_INET6(IPv6)。
type:套接字类型,如 SOCK_STREAM(TCP)或 SOCK_DGRAM(UDP)。
protocol:协议类型,通常为0,表示使用默认协议。
返回值:成功时返回套接字描述符,失败时返回-1。 - bind
功能:将套接字绑定到一个特定的地址和端口。
原型:int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
参数:
sockfd:套接字描述符。
addr:指向包含地址信息的结构体指针(如 struct sockaddr_in)。
addrlen:地址结构体的长度。
返回值:成功时返回0,失败时返回-1。 - listen
功能:将套接字设置为监听状态,准备接受连接请求。
原型:int listen(int sockfd, int backlog);
参数:
sockfd:套接字描述符。
backlog:等待连接队列的最大长度。
返回值:成功时返回0,失败时返回-1。 - accept
功能:从监听队列中接受一个连接请求。
原型:int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
参数:
sockfd:监听套接字描述符。
addr:指向存储客户端地址信息的结构体指针。
addrlen:指向存储地址结构体长度的变量的指针。
返回值:成功时返回新的套接字描述符,失败时返回-1。 - connect
功能:发起一个连接请求到指定的服务器。
原型:int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
参数:
sockfd:套接字描述符。
addr:指向包含服务器地址信息的结构体指针。
addrlen:地址结构体的长度。
返回值:成功时返回0,失败时返回-1。 - send 和 recv
功能:发送和接收数据。
原型:
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
参数:
sockfd:套接字描述符。
buf:指向要发送/接收的数据缓冲区的指针。
len:要发送/接收的数据长度。
flags:选项标志,通常为0。
返回值:成功时返回实际发送/接收的字节数,失败时返回-1。 - sendto 和 recvfrom
功能:用于无连接的套接字(如UDP),发送和接收数据。
原型:
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen);
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);
参数:
sockfd:套接字描述符。
buf:指向要发送/接收的数据缓冲区的指针。
len:要发送/接收的数据长度。
flags:选项标志,通常为0。
dest_addr / src_addr:指向目标/源地址信息的结构体指针。
addrlen / *addrlen:地址结构体的长度。
返回值:成功时返回实际发送/接收的字节数,失败时返回-1。 - close
功能:关闭套接字。
原型:int close(int fd);
参数:
fd:文件描述符(套接字描述符)。
返回值:成功时返回0,失败时返回-1。
什么是流式套接字?什么是多线程?
流式套接字(Stream Sockets)是一种提供面向连接的、可靠的、双向的、基于字节流的通信服务的套接字类型。在TCP/IP协议族中,流式套接字通常使用传输控制协议(TCP)来实现。
主要特点
1.面向连接:在数据传输之前,必须先建立一个连接。这意味着客户端和服务器需要通过三次握手过程来建立一个会话。
2.可靠传输:TCP协议提供了可靠的数据传输,确保数据包按顺序到达,并且没有丢失或损坏。如果数据包在传输过程中丢失或损坏,TCP会自动重传这些数据包。
3.双向通信:一旦连接建立,双方都可以发送和接收数据。
4.字节流:数据以字节流的形式传输,没有消息边界。应用程序需要自己处理消息的分界和重组。
多线程是一种编程和执行模型,它允许一个程序同时执行多个线程。每个线程都是程序中的一个独立执行路径,可以并行运行。多线程的主要目的是提高程序的性能和响应性,特别是在处理I/O密集型或计算密集型任务时。
实验过程
语言:C++
聊天协议
消息格式
消息内容:实际的消息内容。
消息类型
默认是普通聊天信息,输入quit退出。
消息结构
每条消息包含客户端 ID 和实际消息内容
连接建立
三次握手:客户端与服务器之间通过 TCP 的三次握手建立连接。
客户端连接:客户端发送连接请求到服务器。
服务器响应:服务器接受连接请求,并分配一个唯一的客户端 ID。
消息传输
发送消息:客户端发送消息时,先将消息格式化为 ID:消息内容\n 的形式,然后通过 send 函数发送给服务器。
接收消息:服务器接收消息后,解析出客户端 ID 和消息内容,然后广播给所有其他客户端。
广播消息:服务器将消息转发给所有已连接的客户端(不包括发送者)。
连接关闭
四次挥手:客户端或服务器主动关闭连接时,通过 TCP 的四次挥手断开连接。
退出命令:客户端发送 quit 命令时,服务器关闭对应的套接字,并从客户端列表中移除该客户端。
服务器
1.监听客户端连接请求。
2.为每个新连接创建一个新的线程来处理该客户端的消息。
3.维护一个客户端列表,用于管理和广播消息。
#define _WINSOCK_DEPRECATED_NO_WARNINGS
//函数inet_addr过时,会有弃用警告
//这里定义 WINSOCK_DEPRECATED_NO_WARNINGS 宏,禁用弃用警告
#define _CRT_SECURE_NO_WARNINGS
//函数sprintf不安全,在处理字符串时可能会导致缓冲区溢出,从而引发安全问题。
#include<iostream>
#include <winsock2.h>
#include <windows.h>
#pragma comment(lib, "ws2_32.lib")
//#pragma comment(lib, "ws2_32.lib") 是一个预处理指令,用于在编译时自动链接指定的库文件。
//在这个例子中,它告诉编译器在链接阶段链接 ws2_32.lib 库,这是 Windows Sockets 2 (Winsock 2) 的库文件。
using namespace std;
#define NUM 1024
SOCKET clientSocket[NUM];
int c;//如果变量名为count会报错,std命名空间里有std::count,与全局变量count冲突
void communication(int index)
{
int r;
char buff[56];
char temp[80];
while (1)
{
r = recv(clientSocket[index], buff, 55, NULL);
/*
如果未发生错误, recv 将返回收到的字节数, buf 参数指向的缓冲区将包含接收的此数据。
如果连接已正常关闭,则返回值为零。
否则,将返回值 SOCKET_ERROR,并且可以通过调用 WSAGetLastError 来检索特定的错误代码。
*/
if (r > 0)
{
buff[r] = 0;//添加结束符号
cout << "客户端 " << index << " :" << buff << endl;
}
// 检查是否接收到 "quit"
if (strcmp(buff, "quit") == 0)
{
cout << "客户端 " << index << " 退出" << endl;
break; // 跳出循环
}
memset(temp, 0, 80);//使用 memset 清空 temp 缓冲区
sprintf(temp, "%d:%s", index, buff);
//发给当前所有连上服务器的客户端
for (int i = 0; i < c; i++)
{
send(clientSocket[i], temp, strlen(temp), NULL);
}
//遍历所有客户端套接字 clientSocket,使用 send 函数将格式化后的消息 temp 广播给每个客户端。
}
}
int main()
{
c = 0;
//1.确定网络协议版本
WSADATA wsaData;
WSAStartup(MAKEWORD(2, 2), &wsaData);//选中WSAStartup,按F1,查看在线帮助文档
if (LOBYTE(wsaData.wVersion) != 2 || HIBYTE(wsaData.wVersion) != 2)
{
cout << ("确定网络协议版本失败: ") << GetLastError() << endl;
system("pause");
return -1;
}
cout << ("确定网络协议版本成功") << endl;
//2.创建socket
SOCKET serverSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (SOCKET_ERROR == serverSocket)
{
cout << ("创建socket失败: ") << GetLastError() << endl;
//清除协议版本信息
WSACleanup();
system("pause");
return -1;
}
cout << ("创建socket成功") << endl;
//3.确定服务器协议地址簇
SOCKADDR_IN addr = { 0 };
addr.sin_family = AF_INET;
addr.sin_addr.S_un.S_addr = inet_addr("127.0.0.1");//IP地址(区分计算机)
//inet_addr 是一个用于将点分十进制格式的 IPv4 地址字符串
//转换为 32 位无符号长整型(in_addr_t)网络字节序表示的函数。
//在 Windows Sockets (Winsock) API 中定义,通常用于网络编程中处理 IP 地址。
addr.sin_port = htons(9527);//端口号(区分同一计算机上不同软件)
//4.绑定
int r = bind(serverSocket, (sockaddr*)&addr, sizeof addr);
if (-1 == r)
{
cout << ("绑定失败: ") << GetLastError() << endl;
//关闭socket
closesocket(serverSocket);
//清除协议版本信息
WSACleanup();
system("pause");
return -1;
}
cout << ("绑定成功") << endl;
//5.监听
r = listen(serverSocket, 10);
if (-1 == r)
{
cout << ("监听失败: ") << GetLastError() << endl;
//关闭socket
closesocket(serverSocket);
//清除协议版本信息
WSACleanup();
system("pause");
return -1;
}
cout << ("监听成功") << endl;
//6.接受客户端连接
for (int i = 0; i < NUM; i++)
{
clientSocket[i] = accept(serverSocket, (sockaddr*)NULL, NULL);
if (-1 == r)
{
cout << ("服务器崩溃: ") << GetLastError() << endl;
//关闭socket
closesocket(serverSocket);
//清除协议版本信息
WSACleanup();
system("pause");
return -1;
}
cout << "客户端 " << i << " 接入服务器" << endl;
c++;
CreateThread(NULL, NULL, (LPTHREAD_START_ROUTINE)communication, (LPVOID)i, NULL, NULL);
}
//7.通信
//char buff[56];
//while (1)
//{
// r = recv(clientSocket[1], buff, 55, NULL);
// if (r > 0)
// {
// buff[r] = 0;//添加结束符号
// cout << ">>" << buff << endl;
// }
//}
//8.关闭socket
closesocket(serverSocket);
//9.清理协议版本信息
WSACleanup();
while (0);
return 0;
}
客户端
1.连接到服务器。
2./在一个线程中读取用户输入并发送给服务器。
3.在另一个线程中接收并显示来自服务器的消息。
#define _WINSOCK_DEPRECATED_NO_WARNINGS
//函数inet_addr过时,会有弃用警告
//这里定义 WINSOCK_DEPRECATED_NO_WARNINGS 宏,禁用弃用警告
#define _CRT_SECURE_NO_WARNINGS
//scanf 函数不安全,可能会导致缓冲区溢出等安全问题
#include<iostream>
#include<stdio.h>
#include <winsock2.h>
#include <graphics.h>
#pragma comment(lib, "ws2_32.lib")
using namespace std;
SOCKET serverSocket;
void mySend()
{
//循环接受用户输入,发送给服务器
char buff[56];
while (1)
{
if (fgets(buff, sizeof(buff), stdin) != NULL) {
// 去除换行符
buff[strcspn(buff, "\n")] = 0;
// 显示输入的字符串
//printf("你输入的是: %s\n", buff);
}
else {
// 读取失败
printf("输入失败,请确保输入的是有效的字符串。\n");
}
//scanf("%s", buff);
send(serverSocket, buff, strlen(buff), NULL);
if (strcmp(buff, "quit") == 0)
{
exit(0);
}
}
}
int main()
{
initgraph(400, 600,SHOWCONSOLE);//窗口
int len = 0;
//1.确定网络协议版本
WSADATA wsaData;
WSAStartup(MAKEWORD(2, 2), &wsaData);//选中WSAStartup,按F1,查看在线帮助文档
if (LOBYTE(wsaData.wVersion) != 2 || HIBYTE(wsaData.wVersion) != 2)
{
cout << ("确定网络协议版本失败: ") << GetLastError() << endl;
system("pause");
return -1;
}
cout << ("确定网络协议版本成功") << endl;
//2.创建socket
serverSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (SOCKET_ERROR == serverSocket)
{
cout << ("创建socket失败: ") << GetLastError() << endl;
//清除协议版本信息
WSACleanup();
system("pause");
return -1;
}
cout << ("创建socket成功") << endl;
//3.确定服务器协议地址簇
SOCKADDR_IN addr = { 0 };
addr.sin_family = AF_INET;
addr.sin_addr.S_un.S_addr = inet_addr("127.0.0.1");//IP地址(区分计算机)
//inet_addr 是一个用于将点分十进制格式的 IPv4 地址字符串
//转换为 32 位无符号长整型(in_addr_t)网络字节序表示的函数。
//在 Windows Sockets (Winsock) API 中定义,通常用于网络编程中处理 IP 地址。
addr.sin_port = htons(9527);//端口号(区分同一计算机上不同软件)
//4.连接服务器
int r = connect(serverSocket, (sockaddr*)&addr, sizeof addr);
if (-1 == r)
{
cout << ("连接服务器失败: ") << GetLastError() << endl;
//关闭socket
closesocket(serverSocket);
//清除协议版本信息
WSACleanup();
system("pause");
return -1;
}
cout << ("连接服务器成功") << endl;
cout << "==============================================" << endl;
cout << "欢迎来到仙境之桥聊天室!(输入 quit 退出聊天室)" << endl;
cout << "==============================================" << endl;
//5.通信
CreateThread(NULL, NULL, (LPTHREAD_START_ROUTINE)mySend, NULL, NULL, NULL);
char temp[60];
while(1)
{
//接受服务器发来的数据并显示
r = recv(serverSocket, temp, 59, NULL);
if (r > 0)
{
temp[r] = 0;
outtextxy(1, len * 20, temp);
len++;
}
}
//6.关闭socket
closesocket(serverSocket);
//7.清理协议版本信息
WSACleanup();
while (0);
return 0;
}
总结
测试观察
测试环境:在本地机器上运行服务器和多个客户端进行测试。
测试用例:
1.多个客户端同时连接并发送消息。
2.客户端发送长消息和短消息。
3.客户端发送中文和英文消息。
4.客户端正常退出和异常退出。
观察结果:
1.所有消息都能正确地在客户端之间传递。
2.中文和英文消息都能正确显示。
3.客户端退出时,服务器能正确处理并更新客户端列表。
4.未观察到数据丢失现象。
反思
成功点:
1.成功实现了基于 TCP 协议的多人聊天系统。
2.支持多用户同时在线聊天,支持英文和中文消息。
3.使用多线程处理客户端连接,提高了系统的并发性能。
改进点:
1.可以增加更多的错误处理和日志记录功能,提高系统的健壮性。
2.可以优化消息缓冲机制,进一步减少消息分割的可能性。
3.可以增加图形界面或更友好的文本界面,提升用户体验。
通过本次实验,我掌握了基本的 Socket 编程技术和多线程编程方法,理解了 TCP 协议的工作原理,并成功实现了一个简单的多人聊天系统。
我是彩笔,仅供参考!