之前用C#做服务器没搞明白于是从笔者比较熟悉的C++开始入手从头学了一遍,整理一下笔记。
资料来源于《网络多人游戏架构与编程》第三章,这本书讲的很明白,比起网上每篇博客都在介绍的原理,这本书更偏向于代码实现。
代码应该没什么问题,笔者已经成功和C#写的客户端连接上了。
//Windows系统
#include
#include //地址转换库
/*重定义了旧版本的,而默认包含在了文件中
*所以必须保证在调用之前#define宏WIN32_LEAN_ADD_MEAN
**/
//POSIX平台
#include
#include //为了使用部分IPv4特有功能
#include //地址转换库
#include //名称解析库
在POIX平台中,库在默认情况下就是开启状态,而Winsock2需要显示的启动和关闭,并允许用户指定使用什么版本,使用WSAStartup
激活,使用WSACleanup
关闭
int WSAStartup(WORD wVersionRequested, LPWSADATA lpWSAData);
int WSACleanup();
//笔记记录时常用的是2.2版本
WSADATA wsaData;
WSAStartup(MAKEWORD(2, 2), &wsaData);
WSACleanup();
错误报告在各个平台上略有不同,所有平台上大部分函数错误时返回-1
,在Windows系统中,可以使用宏SOCKET_ERROR
代替-1
,因为-1
不能显示错误来源,所以Winsock2提供了获取额外错误代码的函数来得到错误原因。
//注意是Windows系统
int WSAGetLastError();
这个函数仅返回当前运行线程的最近错误代码。
类似的,POSIX兼容库也提供了获取错误信息的方法,但是,它们使用C语言标准库中的全局变量errno报告错误代码,所以需要包含
文件。
SOCKET socket(int af, int type, int protocol);
af
参数表示协议簇,指明socket所使用的网络层协议。
宏 | 含义 |
---|---|
AF_INEX | IPv4 |
AF_INET6 | IPv6 |
type
指明socket发送和接收分组的形式。
宏 | 含义 |
---|---|
SOCK_STREAM | 有序可靠的(适用于TCP) |
SOCK_DGRAM | 离散的(适用于UDP) |
protocol
表示发送数据时应使用的协议,值为0时表示用type默认的形式
//调用如下函数创建IPv4 TCP socket
SOCKET tcpsocket = socket(AF_INET, SOCK_STREAM, 0);
//调用如下函数创建IPv4 UDP socket
SOCKET udpsocket = socket(AF_INET, SOCK_DGRAM, 0);
//无论是TCP还是UDP,最后都需要关闭socket连接
int closesocket(SOCKET sock);
当关闭TCPsocket时,应保证所有发送、接收的数据都已经传输确认完毕,所以应停止SOCKET传输,再关闭socket连接。
int shutdown(SOCKET socket, int how);
shutdown()
参数how:
宏 | 含义 |
---|---|
SD_SEND | 停止发送 |
SD_RECEIVE | 停止接收 |
SD_BOTH | 停止发送和接收 |
每一个网络层数据包都需要一个源地址和一个目的地址,如果数据包封装传输层数据,还需要一个源端口和一个目的端口。为了将地址信息传入和传出socket库,API提供了sockaddr数据类型
struct sockaddr {
uint16_t sa_family; //常数,指定地址类型,应与创建socket时使用的参数af一致
char sa_data[14]; //存储真正的地址
};
虽然可以手动填写sa_data,但是你需要各种地址族的内存布局,为了弥补这一点,API为常用地址族提供了帮助地址初始化的专用数据类型。但是因为C语言莫得多态继承,所以传入地址的时候需要手动把专用数据类型转换成sockaddar类型。
struct sockaddr_in{
short sin_family; //和sockaddr中的sa_family具有相同含义
uint16_t sin_port; //存储地址中的16位端口部分
struct in_addr sin_addr; //存储4字节的IPv4地址
char sin_zero[8]; //不使用,仅为了使sockaddr_in与sockaddr的大小一致,应全为0
};
//不同socket库之间in_addr类型有差异,所以一些平台提供一个结构体以封装这个结构,用于设置不同格式的地址
struct in_addr {
union {
struct {
uint8_t s_b1, s_b2, s_b3, s_b4; //通过设置此处字段可以以人类可读形式输入地址
} S_un_b;
struct {
uint16_t s_w1, s_w2;
} S_un_w;
uint32_t S_addr;
} S_un;
};
当利用4字节整数设置IP地址或者设置端口号时,很重要的一件事情是考虑TCP/IP协议族和主机有可能在多字节数的字节序上采用不用的标准。为了实现将主机字节序转换为网络字节序的功能,socketAPI提供了htons
函数和htonl
函数。
uint16_t htons(uint16_t hostshort);
uint32_t htons(uint32_t hostlong);
当然,你可以用如下函数去将网络字节序转换成你能读懂的字节序
uint16_t ntohs(uint16_t networkshort);
uint32_t ntohs(uint32_t networklong);
下面的代码展示了如何创建一个IP地址为127.0.0.1,端口为80的socket地址
sockaddr_in myAddr;
memset(myAddr.sin_zero, 0, sizeof(myAddr.sin_zero));
myAddr.sin_family = AF_INET;
myAddr.sin_port = htons(80);
myAddr.sin_addr.S_un.S_un_b.s_b1 = 127;
myAddr.sin_addr.S_un.S_un_b.s_b2 = 0;
myAddr.sin_addr.S_un.S_un_b.s_b3 = 0;
myAddr.sin_addr.S_un.S_un_b.s_b4 = 1;
因为socket库最初建立的时候很少考虑类型安全,所以在应用层把基本的socket数据类型和函数封装为自定义的面向对象的结构体是很有帮助的。有助于将socketAPI与你的业务代码中分离出来,以备之后决定将socket库替换成为其它网络库。
向socket地址添加IP地址和端口有一定发的工作量,特别是地址信息很可能来自程序配置文件或者命令行中的一个字符串,如果是将字符串输入sockaddr,你可以不做处理工作,而是使用如下函数
int inet_pton(int af, const char* src, void* dst);
/*
#include
#include
#include
*/
int inet_pton(int af,const PCTSTR src, void* dst); //#include
参数
af
即地址族,即AF_INET或AF_INET6。src
应指向空字符(NULL)结尾的字符串,存储英文句号分割的地址。dst
应该指向待赋值的sockaddr和sin_addr字段。这个函数成功时返回1,源字符串错误返回0,发生其它系统错误返回-1
如下使用inet_pton初始化sockaddr
sockaddr_in myAddr;
myAddr.sin_family = AF_INET;
myAddr.sin_port = htons(80);
inet_pton(AF_INET, "127.0.0.1", &myAddr.sin_addr);
当然这个字符串需要是纯数字的IP地址,如果是域名的话需要将域名解析为地址
int getaddrinfo(
const char* hostname, //以空字符(NULL)结尾的字符串,存储待查找的域名
const char* servname, //以空字符(NULL)结尾的字符串,存储端口号或对应服务名称
const addrinfo* hints, //存储希望收到的结果,可以传入nullptr获取所有匹配的结果
addrinfo** res //指向新分配的addrinfo结构体链表的头部,每个addrinfo表示来自DNS服务器响应的一部分
);
struct addrinfo {
int ai_flags;
int ai_family; //指定从属的地址族(AF_INET)
int ai_socktype;
int ai_protocol;
size_t ai_addrlen; //给出了ai_add指向的sockaddr的大小
char* ai_canonname; //若ai_flags设置了AI_CANONNAME标记,则存储被解析主机的规范名称
sockaddr* ai_addr; //存储给定地址族的sockaddr,指向在调用时由参数hostname指定的主机和servname指定的端口
addrinfo* ai_next; //指向链表中的下一个addrinfo(一个域名可对应多个IPv4或IPv6地址)
};
//因为getaddrinfo分配一个或多个addrinfo结构体,所以在保存了需要的sockaddr后需要释放内存
void freeaddrinfo(addrinfo* ai); //遍历整个链表释放所有addrinfo节点和相关的缓存
注意:getaddrinfo
没有内置的异步操作,会阻塞线程,需要大量的时间(毫秒甚至秒级)。
通知操作系统socket将使用一个特定地址和传输层端口的过程称为绑定。使用bind函数:
int bind(SOCKET sock, const sockaddr* address, int address_len);
bind成功时返回0,失败时返回-1。
通常,你只能将一个socket绑定到一个给定的地址和端口。如果这个地址和端口已经被占用,那么bind返回-1,这种情况下,你可以反复尝试绑定不同端口,知道找到可用端口,地可以给需要绑定的端口赋值为0来自动完成这个操作。
如果一个进程试图使用一个未被绑定的socket发送数据,网络库将自动为这个socket绑定一个可用的端口,因此,手动调用bind函数的唯一原因是指定绑定的地址和端口。
一旦创建好socket,就可以通过UDP socket发送数据,如果没有绑定,网络模块将在动态端口范围内找一个空闲的端口自动绑定,使用sendto函数发送数据:
int sendto(
SOCKET sock, //数据包应使用的socket
const char* buf, //指向待发送数据起始地址的指针,可以是任何能够被转换为cahr*的数据类型
int len, //待发送数据的大小,最好避免发送大于1300字节的数据包
int flags, //对控制发送的标志进行按位或运算的结果,大多数游戏代码中该参数为0
const sockaddr* to, //目标接受者的sockaddr
int tolen //传入参数to的sockaddr大小,对于IPv4,传入sizeof(sockaddr_in)即可
);
如果操作成功,返回等待发送数据的长度,否则返回-1,若返回0,代表数据已经成功进入发送队列,并不代表数据已经成功发出。
使用recvfrom
函数从UDPsocket接收数据:
int recvfrom(SOCKET sock, //查询数据的socket,默认会阻塞直到有数据报到达
char* buf, //接收数据包的缓冲区
int len, //指定参数buf可以存储的最大字节数,超出字节将被丢弃
int flags, //对控制接收的标志进行按位或运算的结果,大多数游戏代码中该参数为0
sockaddr* from, //该函数会在此写入发送者的地址和端口,不需要初始化
int* fromlen //存储参数from所指向的sockaddr的大小
);
如果成功执行,返回复制到buf的字节数,如果发生错误则返回-1
TcpSocket在使用socket和bind函数创建和绑定一个socket之后,需要使用listen函数启动监听:
int listen(
SOCKET sock,
int backlog //队列中允许传入的最大连接数,达到最大值后后续连接将会被丢弃
);
成功时返回0,错误时返回-1
接收传入的连接并继续TCP握手过程的时候,调用accept函数:
SOCKET accept(
SOCKET sock, //接收传入连接的监听socket
sockaddr* addr, //将会被写入请求连接的远程主机地址,不需要初始化
int* addrlen //指向addr缓冲区大小的指针,以字节为单位,真正写入地址之后将更新这个参数
);
若accept执行成功,将创建并返回一个可以与远程主机通信的新socket,这个socket被绑定到与监听socket相同的端口号上。默认情况下,如果没有待接收的传入连接,accept函数将阻塞调用线程,直到收到一个传入的连接,或者超时。
客户端应使用connect函数主动与远程服务器握手:
int connect(
SOCKET sock, //待连接的socket
const sockaddr* addr, //指向目的远程主机的地址指针
int addrlen //addr参数所指向地址的长度
);
函数成功时返回0,错误时返回-1,默认情况下,connect会阻塞线程直到连接被接受或者超时。
连接完毕后,使用send函数通过连接的TCP socket发送数据:
int send(
SOCKET sock,
const char* buf,
int len,
int flags
);
如果send成功,返回发送数据的大小。如果socket的输出缓冲区有一些空余的空间,但不足以容纳整个buf时,这个值可能会比参数len小。如果没有空间,默认情况下,调用线程将被阻塞,直到调用超时或者发送了足够的数据后产生空间。如果发生错误,send函数返回-1。请注意,非零的返回值并不代表数据已经成功发送出去了,只能说明数据被存入队列中等待发送。
调用recv函数从上图一个连接的TCPsocket接收数据
int recv(
SOCKET sock,
char* buf,
int len,
int flags
);
如果recv调用成功,返回接受的数据大小,这个值小于等于len。当len非零时,如果recv返回0,说明连接的另外一段发送了一个FIN数据包,承诺没有更多需要发送的数据(即可断开)。如果发生错误,recv函数返回-1。默认情况下,recv函数会阻塞调用线程,直到数据流中的下一组数据到达,或者超时。