Winsock是Windows操作系统上的套接字API,用于在网络上进行数据通信。套接字通信是一种允许应用程序在计算机网络上进行实时数据交换的技术。通过使用Windows提供的API,应用程序可以创建一个套接字来进行数据通信。这个套接字可以绑定到一个端口,以允许其他应用程序连接它。另外,Winsock可以使用TCP/IP、UDP等协议来完成不同类型的数据传输任务。在网络应用程序开发中,套接字通信可以帮助应用程序开发者实现客户端/服务端模型,并实现数据的可靠传输。
一般套接字通信需要经历,创建套接字(Socket),绑定(Bind),监听(Listen),接受(Accept),连接(Connect),发送数据(Send),接收数据(Receive),关闭(Close)等几个关键步骤,当读者需要使用网络通信时需引入winsock2.h
头文件,并通过#pragma comment(lib,"ws2_32.lib")
包含对应库,需要注意的是该头文件与windows.h
头冲突,如果两者同时存在则会出现编译不通过的情况;
14.1.1 服务端通信
(1)WSAStartup(MAKEWORD(2, 0), &WSAData)
当读者需要使用套接字编程时,不论是服务端还是客户端都需要调用WSAStartup
初始化套接字库,该函数接受两个参数传递,第一个参数一般默认会传递MAKEWORD(2, 0)
它是一个宏,用于将两个8位的字节合并成一个16位的字,在MAKEWORD(2, 0)
中,括号内的数字分别代表高位字节(2)
和低位字节(0)
,宏会将它们合并成一个16位的无符号short
整型数据,即0000001000000000
(二进制),表示Winsock
的版本号为2.0
。第二个参数WSADATA
结构体,用于Winsock
初始化时存储相关的信息,一般会在全局WSADATA WSAData;
直接定义得到。
c
#include <iostream>
#include <winsock2.h>
#include <WS2tcpip.h>
#pragma comment(lib,"ws2_32.lib")
// 定义结构体
WSADATA WSAData;
// 启动winsock中的WSAStartup()函数对Winsock DLL进行初始化
if (WSAStartup(MAKEWORD(2, 0), &WSAData) == SOCKET_ERROR)
{
std::cout << "WSA动态库初始化失败" << std::endl;
return 0;
}
(2)socket(AF_INET, SOCK_STREAM, 0)
通信的第二步则是调用Socket()
函数,该函数是用于创建一个套接字的系统调用。在该函数中,给定三个参数,分别为地址族(Address Family)
、套接字类型(Socket Type)
和协议(Protocol)
,套接字在初始化并完成时会返回一个SOCKET
类型的文件描述符句柄,此处我们将该句柄存储至server_socket
变量内。AF_INET
用于指定套接字地址族为IPv4
类型,SOCK_STREAM
则用于指定该套接字的类型为流式套接字,用于面向连接的可靠数据传输(TCP协议)。
c
// 服务进程创建套接字句柄(用于监听)
SOCKET server_socket;
// 调用socket()函数创建一个流套接字,参数(网络地址类型,套接字类型,网络协议)
if ((server_socket = socket(AF_INET, SOCK_STREAM, 0)) == ERROR)
{
std::cout << "Socket 创建失败" << std::endl;
WSACleanup();
return 0;
}
(3)bind(server_socket, (LPSOCKADDR)&ServerAddr, sizeof(ServerAddr))
套接字编程的第三步则是绑定,套接字的绑定需要调用bind()
函数实现,该函数接受三个参数传递,第一个参数是socket()
中创建的套接字文件描述符句柄,该参数用于指定针对哪一个套接字进行操作,第二个参数则是sockaddr_in
类型的结构体,该结构体内用于指定需要绑定套接字的具体类型参数等信息,在如下代码中我们通过ServerAddr.sin_family = AF_INET;
将套接字类型设置为了互联网域模式,通过ServerAddr.sin_port = htons(9999);
指定了需要绑定的端口号,而ServerAddr.sin_addr.s_addr = inet_addr("0.0.0.0");
则用于指定了要绑定本机的那个网口,一般而言如果读者需要在本机使用此处可填入127.0.0.1
而如果侦听任意一个网口则可使用0.0.0.0
,第三个参数则是传入结构体的长度,此处通过sizeof(ServerAddr)
方法得到,最终将结构体ServerAddr
直接填入绑定函数即可实现对网络套接字的绑定。
c
// 结构sockaddr_in用来标识TCP/IP协议下的地址,可强制转换为sockaddr结构
struct sockaddr_in ServerAddr;
// 字段sin_family必须设为AF_INET,表示该Socket处于Internet域
ServerAddr.sin_family = AF_INET;
// 字段sin_port用于指定服务端口,注意避免冲突
ServerAddr.sin_port = htons(9999);
// 字段sin_addr用于把一个IP地址保存为一个4字节,无符号长整型,根据不同用法还可表示本地或远程IP地址
// 该字段可以直接使用INADDR_ANY代表侦听所有地址,也可指定地址
ServerAddr.sin_addr.s_addr = inet_addr("0.0.0.0");
// 调用bind()函数将本地地址绑定到所创建的套接字上,以在网络上标识该套接字
if (bind(server_socket, (LPSOCKADDR)&ServerAddr, sizeof(ServerAddr)) == SOCKET_ERROR)
{
std::cout << "绑定套接字失败" << std::endl;
closesocket(server_socket);
WSACleanup();
return 0;
}
(4)listen(server_socket, 10)
当套接字被绑定后,接下来则是侦听套接字,通过调用listen()
函数将套接字置入监听模式并准备接受连接请求,该函数需要传入两个参数,参数1为套接字套接字句柄,参数二为侦听套接字最大连接数,如果进入侦听状态则说明该套接字是等待连接状态,一旦服务器接受了连接,它可以使用返回的套接字对象与发起连接的客户端进行通信。
c
// 将 ServerAddr.sin_addr 网络字节序,转为本机侦听IP地址
char local_address[20];
inet_ntop(AF_INET, &ServerAddr.sin_addr, local_address, 16);
std::cout << "侦听本地地址: " << local_address << " 侦听本地端口: " << ntohs(ServerAddr.sin_port) << std::endl;
// 参数(已捆绑未连接的套接字描述字,正在等待连接的最大队列长度)
if (listen(server_socket, 10) == SOCKET_ERROR)
{
std::cout << "侦听套接字失败" << std::endl;
closesocket(server_socket);
WSACleanup();
return 0;
}
(5)accept(server_socket, (LPSOCKADDR)0, (int*)0)
当一个套接字进入侦听状态后则下一步是需要等待有客户端连接到本端,当服务器通过调用listen()
函数开始监听连接请求时,客户端可以通过使用connect()
函数尝试与服务器建立连接。一旦客户端发送连接请求,服务器将收到通知。然后服务器可以使用accept()
函数接受连接请求并创建一个新的套接字对象,该对象可以用于与客户端进行通信。
accept() 函数通常在一个循环中使用,以便服务器可以在等待新连接时继续处理已连接的客户端。每次调用accept()
函数时,如果有连接请求,则函数将阻塞直到一个连接请求被接受。一旦连接请求被接受,函数将返回一个新的套接字对象和客户端的地址信息。
在接受连接请求并创建新的套接字对象之后,服务器可以使用该对象与客户端进行通信。同时,服务器可以使用原始的server_socket
套接字对象来等待更多的连接请求,以便能够接受更多的客户端连接。
如下的代码中当accept()
接收到等待消息时,则会将该句柄保存至message_socket
变量内,此时用户只需要向该指针中发送recv()
或接收send()
数据即可,此时套接字通信即可正式被建立起来。
c
// 数据接收缓冲区
SOCKET message_socket;
char buf[8192] = {0};
while (1)
{
// 进入监听状态后,调用accept()函数接收客户端的连接请求,并把连接传给msgsock套接字
// 原sock套接字继续监听其他客户机连接请求
if ((message_socket = accept(server_socket, (LPSOCKADDR)0, (int*)0)) == INVALID_SOCKET)
{
continue;
}
// 初始化数据接收缓冲区
memset(buf, 0, sizeof(buf));
// 接收客户端发送过来的数据
bool ref = recv(message_socket, buf, 8192, 0);
if (ref != 0)
{
std::cout << "接收数据: " << buf << std::endl;
}
// 关闭子套接字
closesocket(message_socket);
}
至此我们的服务端将被运行起来,需要注意的是服务端程序如果需要结束本次会话则需要手动调用closesocket(server_socket);
关闭一个套接字句柄,当整个进程执行结束后读者还需要调用WSACleanup()
终止对Winsock DLL的使用,并释放资源。
14.1.2 客户端通信
对于客户端通信而言其流程与服务端通信基本保持一致,该流程分别是,创建套接字,连接到服务器,建立连接,发送数据,关闭连接,对于初始化部分客户端通信与服务端没有任何区别,唯一的区别在于对于服务端而言一般是使用listen()
函数侦听套接字,而对于客户端而言则是使用connect()
函数连接到服务端,一旦连接建立成功,客户端可以通过向服务器发送数据来与服务器进行通信。
在调用connect(socket_addr)
时,需要传递一个参数sockaddr
。sockaddr 是一个结构体,包含了客户端与服务器的地址信息,包括其IP
地址和端口号。在C/C++
中,sockaddr 结构体通常被定义为sockaddr_in
结构体,包含了IP
地址和端口号等信息。如果连接建立成功,connect() 函数将返回 0。如果连接失败,则会返回一个错误代码,其中最常见的错误是连接超时或目标主机拒绝连接。
一旦连接建立成功,客户端可以使用新创建的套接字对象向服务器发送数据,并使用recv()
函数从服务器接收数据。一般来说,在与服务器进行通信之前,客户端套接字需要使用bind()
函数指定一个本地地址和端口,以确保数据可以正确地传输。
c
int main(int argc, char* argv[])
{
char buf[8192] = { 0 };
while (1)
{
std::cout << "发送数据: ";
int inputLen = 0;
memset(buf, 0, sizeof(buf));
// 输入以回车键为结束标识
while ((buf[inputLen++] = getchar()) != '\n'){ ; }
// 初始化
WSADATA WSAData;
if (WSAStartup(MAKEWORD(2, 0), &WSAData) == SOCKET_ERROR)
{
continue;
}
// 创建套接字
SOCKET client_socket;
if ((client_socket = socket(AF_INET, SOCK_STREAM, 0)) == SOCKET_ERROR)
{
WSACleanup();
continue;
}
// 填充通信结构体
struct sockaddr_in ClientAddr;
ClientAddr.sin_family = AF_INET;
ClientAddr.sin_port = htons(9999);
ClientAddr.sin_addr.s_addr = inet_addr("127.0.0.1");
// 连接到服务端
if (connect(client_socket, (LPSOCKADDR)&ClientAddr, sizeof(ClientAddr)) == SOCKET_ERROR)
{
closesocket(client_socket);
WSACleanup();
continue;
}
// 向服务端发送数据
send(client_socket, buf, 8192, 0);
// 关闭套接字
closesocket(client_socket);
WSACleanup();
}
return 0;
}
读者可自行运行上述程序,启动服务端与客户端,并发送测试数据观察变化,当发送数据后读者应该能看到如下图所示的提示信息;