使用 ENet 进行网络编程
跨平台网络编程变得简单。
创建多人游戏可能非常有趣,但处理 IP 网络编程的复杂性可能会让人头疼。 这句话听起来有点奇怪,但这两者是息息相关的。 如果没有某种基于网络的通信,你就无法编写多人游戏,而与游戏相关的网络编程引入了一些在更简单的应用程序中不常见的难题。 例如,大多数游戏开发者都关注带宽利用率和节流。 还需要处理玩家会话管理。 此外,还有消息分片、确认和排序的问题。 哦,而且你真的希望你的游戏能够在 Linux 和 Windows 上运行。 对于可能更关心编写游戏而不是成为跨平台网络编程专家的开发者来说,这是一个艰巨的任务。 幸运的是,ENet 库 (http://enet.bespin.org) 解决了这些细节,并为开发者提供了一个简单、灵活且一致的 API。
ENet 的事件驱动编程模型使客户端会话管理变得非常简单。 当对等方连接或断开连接,以及从对等方收到消息时,库会分派一个事件。 开发者只需编写事件处理程序来处理资源的初始化和释放,以及对传入消息采取行动。 这意味着您不必担心分叉、预分叉、线程或非阻塞调用 connect() 和 accept() 以处理多个连接的复杂性。 使用 ENet,您只需定期调用其事件分派器并处理传入的事件即可。
ENet 提供可靠和不可靠的传输。 然而,网络行业真的需要找到一个比“不可靠”更好的术语。 不可靠意味着数据包将被发送出去,但接收端不会被期望确认收到数据包。 在“可靠”协议中,每个数据包都必须在收到后确认。 如果对等方发送一个数据包并请求确认,但在及时的时间内没有收到确认,则数据包将被自动重发,直到它被确认,或者对等方被视为无法访问。 ENet 的优点在于,同一个 API 提供了可靠和不可靠的语义。
ENet 还通过为您处理数据包分片和排序任务,使编写实时客户端-服务器应用程序变得容易。 简单来说,分片和重组是自动完成的,并且对开发者是透明的。 排序也是透明处理的,并且可以随意开启和关闭。 ENet 的通信通道概念与排序有关。 ENet 分别缓冲每个通道,并按数字顺序清空每个缓冲区。 最终结果是您可以传输多个数据流,并且编号较小的通道具有更高的优先级。 例如,您可以将所有实时游戏更新数据包放入通道 1,而系统状态数据包可以放在优先级较低的通道中。
为了演示,我将讨论一个简单聊天程序的客户端和服务器。 我正在使用的代码是基于我有限的空闲时间正在编写的 3D 视频游戏。 然而,在将代码简化到其基本原理时,我遗漏了一些东西,并且无法使其工作。 因此,我在 ENet 电子邮件列表中发布了我的问题描述和代码片段。 在一个小时内,我收到了回复,向我展示了如何解决我的问题。 谢谢,Nuno。
在本例中,用户启动客户端并提供用户名作为命令参数。 一旦创建客户端会话,客户端应将用户名告诉服务器,作为从客户端发送的第一个消息。 然后,用户输入的任何内容都将被发送到服务器。 来自服务器的任何消息都将显示在客户端屏幕上。 因为所有用户输入都是以阻塞方式进行的,所以用户实际上在按下 Enter 键之前不会看到任何传入消息。 这不是理想的情况,但代码的重点是演示 ENet 库,而不是 STDIN 上的非阻塞 I/O 和必要的光标控制。 (在实际情况下,您的程序无论如何都会实时生成消息,例如玩家移动和弹丸撞击。) 如果用户只是按下 Enter 键,则不会向服务器发送任何消息,但任何排队的消息都将显示出来。 如果用户输入字母 q 并按下 Enter 键,则客户端断开连接并终止。
服务器也非常简单。 当客户端连接时,服务器等待客户端识别用户。 然后,服务器发送广播消息,宣布新用户的连接。 当客户端断开连接时,该事实也会广播给所有连接的用户。 当客户端发送消息时,该消息将发送给每个连接的客户端,除了发送消息的客户端。 就像我说的那样,这是一个非常简单的聊天系统。
让我们看一些代码。 首先,让我们解决一些 #define。 查看列表 1 中显示的 config.h。
列表 1. config.h
#define HOST "localhost"
#define PORT (7000)
#define BUFFERSIZE (1000)
这非常简单明了,所以让我们看看列表 2 中显示的客户端代码。
列表 2. 客户端代码
1 #include <stdio.h>
2 #include <string.h>
3 #include <enet/enet.h>
4 #include "config.h"
5 #include <unistd.h>
6 char buffer[BUFFERSIZE];
7 ENetHost *client;
8 ENetAddress address;
9 ENetEvent event;
10 ENetPeer *peer;
11 ENetPacket *packet;
12 int main(int argc, char ** argv) {
13 int connected=0;
14 if (argc != 1) {
15 printf("Usage: client username\n");
16 exit;
17 }
18 if (enet_initialize() != 0) {
19 printf("Could not initialize enet.\n");
20 return 0;
21 }
22 client = enet_host_create(NULL, 1, 2, 5760/8, 1440/8);
23 if (client == NULL) {
24 printf("Could not create client.\n");
25 return 0;
26 }
27 enet_address_set_host(&address, HOST);
28 address.port = PORT;
29 peer = enet_host_connect(client, &address, 2, 0);
30 if (peer == NULL) {
31 printf("Could not connect to server\n");
32 return 0;
33 }
34 if (enet_host_service(client, &event, 1000) > 0 &&
35 event.type == ENET_EVENT_TYPE_CONNECT) {
36 printf("Connection to %s succeeded.\n", HOST);
37 connected++;
38 strncpy(buffer, argv[1], BUFFERSIZE);
39 packet = enet_packet_create(buffer, strlen(buffer)+1,
ENET_PACKET_FLAG_RELIABLE);
40 enet_peer_send(peer, 0, packet);
41 } else {
42 enet_peer_reset(peer);
43 printf("Could not connect to %s.\n", HOST);
44 return 0;
45 }
46 while (1) {
47 while (enet_host_service(client, &event, 1000) > 0) {
48 switch (event.type) {
49 case ENET_EVENT_TYPE_RECEIVE:
50 puts( (char*) event.packet->data);
51 break;
52 case ENET_EVENT_TYPE_DISCONNECT:
53 connected=0;
54 printf("You have been disconnected.\n");
55 return 2;
56 }
57 }
58 if (connected) {
59 printf("Input: ");
60 gets(buffer);
61 if (strlen(buffer) == 0) { continue; }
62 if (strncmp("q", buffer, BUFFERSIZE) == 0) {
63 connected=0;
64 enet_peer_disconnect(peer, 0);
65 continue;
66 }
67 packet = enet_packet_create(buffer, strlen(buffer)+1,
ENET_PACKET_FLAG_RELIABLE);
68 enet_peer_send(peer, 0, packet);
69 }
70 }
71 enet_deinitialize();
72 }
第 1-17 行是样板代码。 请注意,在第 3 行,我包含的是 enet/enet.h 而不是简单的 enet.h。 ENet 文档表明 enet.h 可能在某些系统上冲突,因此必须使用 enet 目录。 第 6 行定义的全局缓冲区是我将放置用户输入的地方。 第 7-11 行只是定义 ENet 库需要的一些变量。
真正的 ENet 代码从第 18 行开始,调用 enet_initialize()。 初始化库后,我在第 22 行创建客户端主机。 正如您将看到的,客户端和服务器都是通过调用 enet_host_create() 创建的。 唯一的区别是对于客户端,您将 NULL 作为第一个参数发送。 对于服务器,此参数告诉 ENet 要绑定到的地址。 因为客户端不必绑定,所以您传入 NULL。 第二个参数设置了为多少个连接预留空间的限制。 此示例客户端将仅连接到一个服务器,因此我传入 1。 这两个参数是创建客户端和服务器之间唯一的区别!
第三个参数指示我期望使用的通道数,从 0 开始索引。 最后,最后两个参数分别指示上传和下载的带宽限制(以比特/秒为单位)。 当然,我检查调用 enet_host_create() 是否成功并继续。
第 27-33 行告诉客户端服务器的地址和端口,并尝试连接到它。 如果客户端无法连接,它将终止。
如果程序到达第 34 行,则表示已连接到服务器,现在是识别用户的时候了。 enet_host_service() 函数是 ENet 的事件分派器,但这将在服务器代码中更清楚地说明。 现在,请理解我所做的只是等待服务器确认连接,以便您可以识别自己。 如果您没有看到 ENET_EVENT_TYPE_CONNECT,您就知道您没有真正连接,应该终止。 在第 38-41 行,我创建并向服务器发送一个数据包,其中仅包含用户名。 (当我检查服务器代码时,我将更多地谈论数据包。)
程序的其余部分,从第 46 行开始,是主事件循环。 (当我讨论服务器时,我将更详细地讨论 enet_host_service() 和随后的 switch 语句。) 从第 58 行开始的代码非常简单。 在这里,我从用户那里获取输入并检查它是否是空行,或者是否是仅包含 q 的行。 否则,我创建一个数据包并发送它。 显然,我永远不会真正到达第 71 行,但我包含它是为了教学目的。
客户端实际上并没有那么难编写和理解。 正如您即将看到的,服务器代码几乎完全相同。 让我们看一下列表 3 中的服务器代码。
列表 3. 服务器代码
1 #include <stdio.h>
2 #include <string.h>
3 #include <stdlib.h>
4 #include <enet/enet.h>
5 #include "config.h"
6 ENetAddress address;
7 ENetHost *server;
8 ENetEvent event;
9 ENetPacket *packet;
10 char buffer[BUFFERSIZE];
11 int main(int argc, char ** argv) {
12 int i;
13 if (enet_initialize() != 0) {
14 printf("Could not initialize enet.");
15 return 0;
16 }
17 address.host = ENET_HOST_ANY;
18 address.port = PORT;
19 server = enet_host_create(&address, 100, 2, 0, 0);
20 if (server == NULL) {
21 printf("Could not start server.\n");
22 return 0;
23 }
24 while (1) {
25 while (enet_host_service(server, &event, 1000) > 0) {
26 switch (event.type) {
27 case ENET_EVENT_TYPE_CONNECT:
28 break;
29 case ENET_EVENT_TYPE_RECEIVE:
30 if (event.peer->data == NULL) {
31 event.peer->data =
malloc(strlen((char*) event.packet->data)+1);
32 strcpy((char*) event.peer->data, (char*)
event.packet->data);
33 sprintf(buffer, "%s has connected\n",
(char*) event.packet->data);
34 packet = enet_packet_create(buffer,
strlen(buffer)+1, 0);
35 enet_host_broadcast(server, 1, packet);
36 enet_host_flush(server);
37 } else {
38 for (i=0; ipeerCount; i++) {
39 if (&server->peers[i] != event.peer) {
40 sprintf(buffer, "%s: %s",
41 (char*) event.peer->data, (char*)
event.packet->data);
42 packet = enet_packet_create(buffer,
strlen(buffer)+1, 0);
43 enet_peer_send(&server->peers[i], 0,
packet);
44 enet_host_flush(server);
45 } else {
46 }
47 }
48 }
49 break;
50 case ENET_EVENT_TYPE_DISCONNECT:
51 sprintf(buffer, "%s has disconnected.", (char*)
event.peer->data);
52 packet = enet_packet_create(buffer, strlen(buffer)+1, 0);
53 enet_host_broadcast(server, 1, packet);
54 free(event.peer->data);
55 event.peer->data = NULL;
56 break;
57 default:
58 printf("Tick tock.\n");
59 break;
60 }
61 }
62 }
63 enet_host_destroy(server);
64 enet_deinitialize();
65 }
正如您所看到的,前 24 行代码几乎与客户端代码中的代码相同,但有两个明显的例外。 在第 17-19 行,我告诉服务器绑定到默认 IP 地址 0.0.0.0,并为最多 100 个客户端连接分配空间。 在这种情况下,我没有设置任何带宽利用率限制。
在第 25 行,我调用 enet_host_service() 直到它返回 0。 每次 enet_host_service() 返回非零值时,我就知道发生了某些事情,并且随后的 switch 语句用于确定发生了什么。 请注意,第三个参数指示等待事件发生多少毫秒。 如果我在这个参数中传递了 0,则对 enet_host_service() 的调用将是完全非阻塞的。
ENET_EVENT_TYPE_CONNECT 事件指示客户端已连接到服务器。 通常,您会想要为客户端初始化资源。 但在这种情况下,在客户端识别自己之前,没有什么可做的。 我保留此案例是为了教学目的。
当服务器收到来自客户端的消息时,将分派 ENET_EVENT_TYPE_RECEIVE 事件。 对于此事件,有两种可能的场景
-
客户端尚未识别自己,这是我从他们那里收到的第一条消息。
-
客户端已被识别,这是一个正常的聊天消息。
我在第 30 行的条件中检查是哪种情况。 这一行还指出了不时在论坛中出现的问题,因此我将更详细地解释一下。
大多数服务器应用程序必须存储至少一些关于每个客户端的信息。 通常,他们使用结构数组来存储此信息。 直觉告诉您,您应该在给定客户端的结构中存储的一件事是指向允许您与其通信的任何数据类型的指针。 但是对于 ENet,这种直觉是错误的。 相反,ENet 的对等数据类型提供了一个字段 data,您可以使用它来存储指针。 此指针大概会指向客户端的信息结构。 因此,它几乎与您期望的相反。 但是,这是一种优雅的解决方案; ENet 管理其数据,您分别管理您的数据。
您关心的唯一客户端数据是与给定客户端关联的用户名。 如果您还没有用户名,并且您收到来自他或她的客户端的消息,您可以假设客户端正在向您识别自己,您应该存储用户名。 我在第 31 和 32 行中执行此操作。 然后,在第 33-36 行中,我向其余客户端宣布新用户。 请注意,我在第 34 行创建了一个数据包,但对 enet_host_broadcast() 的调用会释放它。 自己释放该数据结构是一个重大错误。
在第 37-49 行中,您可以看到客户端已被识别的情况。 您所要做的就是向其他客户端发送一条消息,指示“发言者”的姓名以及他或她“说”了什么。 为此,您循环遍历 ENet 的对等方列表。 对于每个对等方,检查它是否是发送消息的同一对等方。 如果是,则跳过它,因为人们真的不希望自己的消息被回显给他们。 否则,您创建一个消息并发送它。 这样,每个客户端都知道说了什么,以及是谁说的。
ENET_EVENT_TYPE_DISCONNECT 事件指示客户端已断开连接。 在这种情况下,您宣布用户已断开连接并释放用于存储用户名的空间。 在第 55 行,我将数据指针设置回 NULL,以防 ENet 决定在另一个客户端连接时重用此结构。
如果没有收到任何事件,则执行默认情况,服务器只是在控制台上打印“Tick tock”,以确保它仍在运行。
就这样,您就拥有了一个 72 行代码的聊天客户端和一个 65 行代码的多用户聊天服务器,并且大部分代码都是相同的。 事实上,在以此代码为基础的程序中,我实际上对客户端和服务器都使用了相同的代码。 我没有在 switch 语句中设置代码块,而是简单地调用一个事件处理程序,该处理程序在单独的代码模块中实现,一个用于客户端,一个用于服务器。 然后,根据我链接到哪个模块,我可以拥有一个客户端或一个服务器。 这还具有将所有 ENet 特定代码隔离在一个源文件中的额外好处。
正如您所看到的,ENet 库几乎非常容易使用,但它封装了复杂的网络通信功能。 当然,这实际上意味着它使用起来很有趣。
网络图片 via Shutterstock.com