65.9K
CodeProject 正在变化。 阅读更多。
Home

适用于非阻塞服务器/客户端的通用 TCP 套接字类

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.66/5 (116投票s)

2009年3月15日

CPOL

13分钟阅读

viewsIcon

917135

downloadIcon

20546

用于双向 TCP 通信的通用类

引言

事件驱动(非阻塞、异步)的 Winsock 编程是一个非常复杂的话题。绝对适合初学者!
我需要这样的代码,并在网上搜索一个现成的 TCP 通信类。

但所有我找到的(即使在这里 CodeProject 上)要么是 buggy、不成熟的初学者代码,要么对我来说过于复杂。
所以,我花了许多小时,写了自己的类。
我在这里 CodeProject 上提供它,供所有需要可靠且健壮的 TCP 套接字类的人使用。

您在这里下载到的是一个由经验丰富的程序员编写的非常干净的代码,具有适当的错误处理和大量的注释。
通用 C++ 类 cSocket,用纯 C++ 编写,附带一个 MFC 演示应用程序。

cSocket 类非常易于使用,即使是初学者也不会有任何问题。

特点

  • 非常易于使用的专业类
  • 所有代码都包含在一个 C++ 文件中
  • 非常干净的代码  
  • 可用于生产环境
  • 具有客户端服务器功能的 TCP 套接字
  • 服务器允许同时连接62个客户端
  • 支持多个网络适配器
  • cSocket 类是线程安全
    1. 可以创建一个额外的线程来处理事件(FD_READ 等)。
    2. 也可以将整个服务器或客户端运行在单个线程中。整个应用程序可以仅在 GUI 线程中运行!
  • 类中的所有函数都是非阻塞(异步)和事件驱动的。
  • 服务器在客户端连接或断开时会收到事件,客户端在服务器关闭时(以及接收到数据时)会收到事件。
  • 事件处理针对高负载(接近 100% CPU 使用率)的服务器进行了优化。
  • 您不必担心任何有限大小的缓冲区
    • 发送:您可以发送任意大小的数据。
    • 接收:每当收到一个新的数据块时,它都会自动附加到动态接收内存中,数据会在此累积,直到您将其移除(动态 FIFO 缓冲区)。
  • 如果客户端空闲时间超过用户定义的max时间,它将被服务器断开连接。
  • 代码可编译为UNICODE和MBCS。(在 Visual Studio 6、2003、2005 上测试过)
  • 在 ZIP 文件中,您还可以找到我编译成 CHM 文件的Winsock FAQ。这回答了许多关于 Winsock 编程的问题。
  • 可在 Windows CE 上使用。
  • 包含 32 位和 64 位解决方案。 

非功能特性

您可以在互联网上的其他 TCP 项目中找到许多糟糕的东西,但这些在 cSocket不会出现。

  • 不必cSocket 派生类来接收事件。
  • 不必为每个网络事件编写回调函数。
  • 当服务器有 40 个客户端连接时,您不会有 40 个线程在运行。
  • 当发生网络错误时,应用程序会因为糟糕的错误处理而不知道问题所在,这种情况不会发生。
  • 您不必关心管理固定大小的缓冲区。
  • 此项目使用 WSAAsyncSelect,它需要一个窗口句柄来进行通知:cSocket 完全在无窗口模式下运行。
  • 此类会创建任何对其他 MSVCPxx.DLL 的新依赖项,因为它没有使用 STL。

版本

  • 版本 1.0:初始版本
  • 版本 1.1:事件处理针对高负载服务器进行了优化
  • 版本 1.2:添加了对多个网络适配器的支持
  • 版本 1.3:文档扩展了 2 个新章节
  • 版本 1.4:添加了 cSocket::DisconnectClient(),一个空闲套接字被断开连接的最大空闲时间,动态接收内存和 3 种演示模式。
  • 版本 1.5:针对未记录的 Windows CE 错误进行了解决方法。

运行演示应用程序

在同一台计算机上启动 SocketDemo.exe三次
这 3 个应用程序将出现在屏幕的不同位置。
使用左边的作为服务器,点击“Listen”按钮,另外两个作为客户端,点击“Connect”按钮。
然后,您可以使用“Send”按钮传输消息,并查看所有发生的 WSA 事件。

了解工作原理后,您可以尝试在不同的计算机上通过网络进行相同的操作。
请先关闭 Windows 防火墙

多网络适配器

假设您的 PC 有两个网络适配器,那么它也会有两个本地 IP 地址。
启动服务器时,您可以选择它应该监听哪个本地 IP,或者它可以同时监听两个 IP。

如果您将两个服务器绑定到不同的本地 IP,甚至可以将两个服务器运行在同一台机器上,监听相同的端口
这样,一个服务器可以响应一个子网中的客户端,而另一个服务器可以响应另一个子网中的客户端(例如,LAN 和 WAN)。

Winsock 编程

cSocket 类演示了以下 Winsock 命令和事件的正确用法

  • socketbindlistenconnectshutdownclosesocketgethostbynamegethostnamegetpeername
  • WSAAcceptWSAStartupWSACleanupWSARecvWSASendWSACreateEventWSACloseEventWSAEventSelectWSAWaitForMultipleEventsWSAEnumNetworkEventsWSAGetLastError
  • FD_READFD_WRITEFD_ACCEPTFD_CONNECTFD_CLOSE

Winsock 库相当复杂,提供了多种套接字编程方法。
同步=阻塞技术的缺点是服务器在有 40 个客户端连接时必须运行 40 个线程。
本项目的异步、事件驱动技术可以在单个线程中一次性服务 62 个已连接的客户端。
有关更多详细信息,请参阅 ZIP 文件中的 Winsock FAQ!

设置服务器/客户端

使用 cSocket 非常简单

服务器

OnButtonListen()
{
    mi_Socket.Listen(0, ms32_Port,...);
    ProcessEvents();
}

OnButtonSend()
{
    mi_Socket.SendTo(h_Socket, "Hello World", 11);
}

注意

在收到 FD_ACCEPT 事件之前,请勿向套接字发送数据!
通过此事件,您还可以获得以后用于 SendTo() 的套接字句柄。

客户端

OnButtonConnect()
{
    mi_Socket.ConnectTo(mu32_ServerIP, ms32_Port,...);
    ProcessEvents();
}

OnButtonSend()
{
    mi_Socket.SendTo(h_Socket, "Hello World", 11));
} 

注意

在收到 FD_CONNECT 事件之前,请勿向套接字发送数据!
通过此事件,您还可以获得以后用于 SendTo() 的套接字句柄。

服务器和客户端之间的每个 TCP 连接都是双向的,允许读写。

多线程

您可以使用单线程或多线程的 cSocket 类。
演示应用程序演示了这两种模式:在 SocketDemoDlg.h 中,您可以通过 PROCESS_EVENTS_IN_GUI_THREAD 在这两种模式之间切换。

多线程

事件可以在额外的线程中处理

// Set Timeout OFF
mi_Socket.Listen(0, ms32_Port, INFINITE);

while (...) // This loop runs in an additional thread for TCP events
{
    DWORD u32_Event;
    SOCKET  h_Socket;
    // ProcessEvents() blocks until an event is received

    DWORD u32_Error = mi_Socket.ProcessEvents(&u32_Event, &h_Socket, ....);
    if (u32_Event & FD_ACCEPT) { /* A new client has connected */ }
    if (u32_Event & FD_READ)   { /* Data has been received */ } 
    if (u32_Event & FD_CLOSE)  { /* A socket has closed */ }

    if (u32_Error) { /* handle error */ }
};

注意

如果使用多线程,您必须注意不要从 ProcessEvents 线程内部输出到 GUI!
例如,GetWindowText() 会在内部调用 SendMessage(WM_GETTEXT)
但是 SendMessage() 会阻塞调用线程,切换到 GUI 线程,等待 SendMessage() 的返回值,然后将控制权返回给调用线程。
这可能导致应用程序死锁,如果此时 GUI 线程已进入 cSocket::SendTo(),而 Mutex 阻塞了 GUI 线程。
演示应用程序使用定时器来避免此死锁。这种方法也使输出到编辑框的速度快得多。

单线程

PumpMessages() 函数处理应用程序中到达的所有 Windows 消息,因此 GUI 保持响应,尽管代码运行了一个无限循环。

// Set Timeout 50 ms
mi_Socket.Listen(0, ms32_Port, 50);

while (...) // This loop runs in the GUI thread
{
    PumpMessages(); // process Windows messages
       
    DWORD u32_Event;
    SOCKET  h_Socket;
    // ProcessEvents() returns after 50 ms or after an event was received
    DWORD u32_Error = mi_Socket.ProcessEvents(&u32_Event, &h_Socket, ....);

    if (u32_Error == ERROR_TIMEOUT)
        continue;

    if (u32_Event & FD_ACCEPT) { /* A new client has connected */ }
    if (u32_Event & FD_READ)   { /* Data has been received */ }
    if (u32_Event & FD_CLOSE)  { /* A socket has closed */ }

    if (u32_Error) { /* handle error */ }
};

套接字列表

cSocket 使用内部 SocketList 来存储事件、套接字句柄及其(对端)IP 地址,以及写入缓冲区。
事件数组被传递给 WSAWaitForMultipleEvents(),后者等待任何事件被触发。
WSAWaitForMultipleEvents() 最多允许 64 个事件(WSA_MAXIMUM_WAIT_EVENTS)。

以下图表将有助于理解代码

服务器 + 客户端

事件 0 未与套接字关联。它用于同步线程的锁。
锁是通过一个可等待定时器实现的,该定时器释放一个阻塞的 Wait 函数。
同一个定时器也用于关闭空闲的客户端。

服务器

套接字 0 永远不会与客户端连接,它只等待传入的 FD_ACCEPT 事件。
所以只有套接字 1...62 可用于客户端连接 => 最多 62 个客户端。

客户端

客户端永远只使用一个套接字。

如何传输数据

您想编写一个应用程序,将数据块传输到另一台计算机。如何详细操作?

选项 1(HTTP 版本)

您可以像 HTTP 协议一样操作:发送者发送数据,完成后关闭连接。接收者在连接关闭时识别数据块的结束。缺点是

  1. 接收者无法区分连接是发送者故意关闭的还是由于网络问题。这不可靠。
  2. 对于每个数据块(即使只传输 20 字节),您都必须打开一个新的连接到对方。

选项 2(Telnet 版本)

如果您只想传输string消息,发送者会在消息末尾放置一个终止零(或换行符),接收者在收到该字节时识别数据块的结束。

选项 3(序列化数据版本)

查看 PHP 命令 serialize(),它可以优雅地将复杂数据(甚至嵌套数组)序列化为strings。

Array
(
    0 => "Red",
    "Key" => "Value",
    5 => Array(0 => "Sub1", 1 => "Sub2"),
    7 => 77777
)

序列化结果

a:4:{i:0;s:3:"Red";s:3:"Key";s:5:"Value";i:5;a:2:{i:0;s:4:"Sub1";i:1;s:4:"Sub2";}i:7;i:77777;}

另一种选择是序列化为 XML,但这会导致strings更长,解析更复杂。

选项 4(二进制结构版本)

如果您想传输二进制数据,我建议在每个数据块前加上一个 DWORD,告诉接收者接下来的字节数。您可以将几个固定长度的字段放入一个结构中,后面跟着一个可变长度的二进制数据块。

struct kTransfer
{
   DWORD u32_TotalLength;
   char   s8_Command[20];
   char   s8_Param_1[50];
   char   s8_Param_2[50];
   // followed by the variable length binary data
};

选项 5(二进制 BLOB 版本)

如果结构对您来说太静态,您可以发送 BLOBs,它们前面会带有长度和数据类型。

struct kBlobHeader
{
   DWORD u32_Length;
   char   s8_Type;
   // followed by the variable length binary data
};

类型可以是“S”表示字符串,“I”表示整数,“F”表示浮点数,“A”表示数组,“B”表示二进制数据等。使用 BLOBs,您甚至可以发送结构和类(在这种情况下,s8_Type 必须是字符串,例如“User::cClientData”)。多个 BLOBs 可以连接成一条消息。

选项 6(FTP 版本)

如果在数据传输过程中可能需要中止正在进行的传输,那么您需要两个连接。就像 FTP 一样,您需要一个控制端口和一个数据端口。如果控制端口发出“Abort”命令,数据端口必须停止发送数据。

字节序

如果您需要在低字节序机器和高字节序机器之间传输数据,可以在数据块前加上一个验证 DWORD,例如 0x001188FF。如果接收方读取到的是 0xFF881100,则必须在处理接收缓冲区之前对其进行转换。

动态接收缓冲区

当您接收数据时,并非所有数据都会一次性到达。
如果您的程序需要在处理之前完整接收整个数据块,那么接收到的片段必须累积在动态接收内存中,它就像一个 FIFO(先进先出)缓冲区。

有 3 种演示模式,演示了如何管理接收到的数据。

  1. 正常模式
    在正常模式下,演示应用程序会打印从 Winsock 收到的数据。如果 WSARecv() 收到了 3214 字节,那么这 3214 字节将立即打印到屏幕上。
  2. 前缀模式
    在长度前缀模式下,发送方总是先发送一个 DWORD,其中包含将要跟随的整个数据块的长度。接收方在动态内存中累积接收到的片段,直到接收完块的所有字节,然后一次性将它们打印到屏幕上。然后,所有这些字节都将从接收内存中移除。
  3. Telnet 模式
    Telnet 终端的字符一个接一个地以键入的速度接收。它们被累积在动态内存中,直到接收到换行符。然后,整行被打印到屏幕上并从接收内存中移除。

因为 cSocket 负责所有脏活累活,您管理接收数据的代码将非常简单,正如您在 3 个演示函数中所见

  • ProcessReceivedDataNormal()
  • ProcessReceivedDataPrefix()
  • ProcessReceivedDataTelnet()

显而易见,您必须始终将客户端和服务器切换到相同的模式。(例如,您不能在 Prefixed 模式下发送,而在 Normal 模式下接收)此外,显而易见的是,编译为Unicode的演示版本无法与编译为 MBCS 的版本通信。

空闲超时

您可以指定服务器在多久之后自动断开空闲客户端。对于客户端,您也可以指定一个空闲超时。
当超时发生时,连接的套接字将被优雅地关闭,并触发 FD_CLOSE + FD_TIMEOUT 事件。

代码

有关 cSocket 中函数的更多详细信息,请阅读源代码中丰富的详细注释!

Telnet 测试

以服务器模式启动 SocketDemo(端口 23)。(“Listen”按钮)
然后,在命令提示符下,输入“telnet”,然后输入“open localhost”。
然后您将看到您在 Telnet 客户端中键入的所有字符都会出现在 Socketdemo 中。(例如,“Hello” + ENTER)
在正常模式下,字符会立即打印。
在 Telnet 模式下,它们被累积在动态接收内存中,直到收到换行符。

注意

Unicode 编译版本不能用作 Telnet 服务器!

连接 HTTP 服务器

如果您想在 SocketDemo 上玩得更开心,可以连接到 HTTP 服务器。
示例:www.gmx.net,端口 80。
要获取起始页,您必须发送“GET”(大写!)后跟一个换行符。

注意

  • Unicode 编译版本不能连接 HTTP 服务器!
  • 服务器在每次请求后关闭连接是正常的!

连接 FTP 服务器

您也可以连接到 FTP 服务器。示例:home.arcor.de,端口 21。
所有 FTP 命令,如“USER”或“PASS”,都必须跟一个换行符。

注意

  • Unicode 编译版本不能连接 FTP 服务器!
  • 请不要期望通过这种方式看到 FTP 目录列表!
    (FTP 需要同时连接两个端口才能正常工作
    一个传输 FTP 命令(例如 LIST),另一个传输数据。
    这只是一个小测试,最多只能进行登录。)

Wininet.dll

如果您需要支持HTTPSFTP客户端或从互联网下载文件,则不应使用 cSocket

当您需要直接在运行您的应用程序的两个或多个计算机之间交换数据时,cSocket 才有用。
cSocket 是一个低级 Socket 类,用于将原始数据直接从一台计算机传递到另一台计算机。
cSocket 的一个优点是您可以非常轻松地使用强大的加密来更安全地传输数据,而不是通过 HTTPS。

但是,要使用 HTTPS 或 FTP 等复杂的互联网协议,没有必要重新发明轮子。
在这种情况下,请查看Wininet.dll中的功能。
Wininet.dll 是 Internet Explorer 的一部分,因此它在每台 Windows 计算机上都可用:它内置了 HTTP、HTTPS 和 FTP。

使用Wininet.dll的另一个优点是您可以轻松地通过代理服务器
您可以选择使用存储在注册表中的 Internet Explorer 的代理设置。
这样,您的应用程序就可以通过公司的代理服务器下载文件,而无需在代理用户名和密码上打扰用户,如果它们已存储在 Internet Explorer 中。

注意

但是Wininet.dll有一些糟糕的 bug

我用C#编写了一个复杂的FTP 下载器
在这篇文章中,您可以了解如何使用Wininet.dll,并看到我开发的4个解决方法,以便无缝使用Wininet.dll

如果您想要一个C++版本的Wininet.dll实现,请查看我的CabLib项目,它可以下载 HTTP、HTTPS 和 FTP 文件。

Elmu

© . All rights reserved.