通用 TCP/IP 客户端/服务器
一个通用的 TCP/IP 客户端/服务器。
引言
在本文中,我希望展示一种非常有用的架构,用于构建基于 TCP/IP 通信的网络服务器。我知道市面上有很多 TCP 和 UDP 服务器可供购买,还有一些可以免费下载,但问题是,那些可以购买的通常会给你一个“黑匣子”。它们还花费金钱(好的服务器花费很多钱)。另一个问题是,它们被设计用于资源丰富的重型机器。而你可以下载的其他服务器似乎直接进入了一个特定的架构,这个架构通常是不正确的,或者至少,它们使用的架构我认为不是榨取机器性能的最佳方式。我的一位主管曾经告诉我,“好”的敌人是“卓越”。嗯,显然,他是对的!但是,我仍然想要卓越,你不是也有同感吗?
好了,闲话少说,让我们来分析一下问题
套接字通信有三种模式
- 同步通信 - 使用阻塞模式,它会暂停执行直到套接字发生某些事情。
- 同步通信 - 不使用阻塞模式 - 但是,这意味着我们会遇到错误,并且需要捕获它们。
- 异步通信 - 它会神奇地为每个套接字活动打开一个线程,从而不会暂停执行。
当然,如果你觉得生活中的幽默感还不够,可以混合使用同步调用和异步调用。
首先要考察的是,为什么以及何时我们会想使用异步通信
由于异步调用会为每个套接字操作打开一个线程,因此它是客户端程序的绝佳解决方案。它可以在不暂停其他过程的情况下管理通信,经过测试,而且绝对不适合服务器。为什么?使用异步调用的服务器将为每个套接字方法打开一个线程,这意味着我们的服务器最终将拥有比所需更多的线程,此外,在某个时候添加更多线程将导致所有其他线程运行得越来越慢,直到硬件需要升级。现在,无论我们实现什么逻辑,这种情况都会发生;问题是它何时会发生?我们打开的线程越多,我们越早需要硬件升级,所以目前,如果我们想在服务器中实现低资源浪费和最大优化,使用异步调用是可以排除的。
因此,我们的目标是使用最少数量的线程,只要我们的延迟定义(也称为滞后)符合我们的设计要求。是的,我知道你们中的一些人会说:“你在说什么,大多数服务器都使用异步通信!”他们是正确的——所以,如果情况如我在上一段中所解释的那样,为什么这么多服务器使用异步架构呢?答案在于一个不再成立的假设,以及一个不优雅的、强制执行的解决方法,我将稍后解释。在这个架构背后,有一个假设是客户端会不断与服务器通信,导致线程不断关闭和打开,因为像接收数据或发送数据这样的操作是一个短暂的操作,而且因为它是短暂的,所以它只占用几纳秒的资源,然后就会释放,所以如此多的线程可以打开和关闭而不会真正以持续的方式浪费资源,这实际上是非常真实的(到目前为止)。
但是,如果我例如从我的客户端连接到这样的服务器,并且我决定不向该服务器发送数据,这意味着该服务器已经打开了一个 BeginRecieve
方法,正在等待我的客户端发送某些内容,只要我不发送任何内容,等待数据的线程就会保持打开状态,并且不会关闭。如果我们有 2000 个客户端这样做,服务器将陷入困境,设计它的人将失业,或者可能被提升为部门经理——这取决于你工作的地方。为了解决这个问题,该架构需要一个非空闲策略,以便线程可以关闭,恶意客户端也不能利用该漏洞。其中一个策略可以是一个关闭空闲客户端的定时器,另一个可以基于 ping 机制,还有一些人会选择更智能的解决方案,例如跟踪资源并在资源变低时才断开空闲客户端。但是,所有这些都是**解决方法**,它们表明它们处理的是症状而不是导致它们的问题!对于异步服务器架构,正如我所解释的,这不适合。
另一种方法是使用阻塞模式的同步调用我们的套接字——这意味着执行将暂停,因此为了解决这个问题,我们可以为每个客户端打开一个线程。但是,我们系统中仍然会有许多线程在运行,而这正是我们已经否定的服务器解决方案。
最后剩下的选择是使用非阻塞模式的同步调用。问题是,连接到我们机器的 2000 个客户端需要被反复扫描,而扫描起来会太慢。
解决方案
所以,解决方案是使用少数几个线程来扫描我们的客户端——例如,每 100 个客户端由一个线程扫描。然而,我们对在不同机器上扫描 100 个客户端需要多长时间毫不知情。另一个问题是,限制为 100 是一个任意的定义,这意味着我们不会是动态的。
这时,我创建了一个我称之为 ServerNode
的类。ServerNode
存储客户端,并返回扫描的延迟信息(扫描节点中的所有客户端需要多长时间)。在服务器基类中,我创建了一个名为 RequiredLatencyPerClient
的属性。从那时起,事情就变得清晰多了。
当服务器客户端列表接收到一个新客户端时,服务器将扫描所有节点并决定将新客户端添加到哪个节点。如果没有任何节点的延迟符合 RequiredLatencyPerClient
,服务器将创建一个新节点并将新客户端添加到该节点。
领班设计模式 - 这是一个新的设计模式吗?(如果不是,我有一个新的、非常通用的想法——我将称之为“轮子”!)
好吧,到目前为止还不错,但在编写代码时,我忍不住想到,我在做一些在许多情况下都非常通用的事情。需要将客户端组织在节点中以维持一定延迟的想法,在许多情况下都很有用。所以,我认为最好将这个工作模式从服务器中提取出来,并独立实现它,以便它可以动态地在其他情况中使用。
我查阅了书籍和互联网,看看是否有已知的设计模式确实是这样做的——并发现没有,所以如果没人反对,我将赋予自己称之为领班设计模式的权利。
但是……有什么事情困扰着我。如果有些客户端需要与其他人不同的延迟怎么办?嗯……如果我想动态地更改客户端延迟怎么办?
所以,抓耳挠腮,我终于意识到一个简单的解决方案一直呈现在我眼前!领班将没有 RequiredLatencyPerClient
。相反,每个客户端(我们现在称之为工作者,因为我们正在调试一个设计模式,而不仅仅是通信服务器)都将拥有一个延迟信息,领班将根据其延迟要求将工作者放入合适的节点。
基于领班设计模式,我可以构建一个非常酷的通信服务器。
所以,在构建了领班之后,我开始编写一个简单的代码来封装套接字通信选项。我打开了一个项目,并称之为*NetworkSocketManager*——它将在其他两个项目(一个服务器和一个客户端)中使用。*NetworkSocketManager* 将允许我们选择套接字工作方法,并将封装所有同步和异步通信选项(包括阻塞模式)。
完成这部分工作后,我编写了一个简单的客户端,其中包含*NetworkSocketManager*。该项目名为*NetworkClient*。
现在,客户端本身也非常友好,因为它已经实现了一个基本的应用程序级别传输控制。将要发送的任何数据都会被封装在一个信封中,该信封包含:**| 前缀长度 = 1 位数字 | 前缀 = 数据长度 | 数据 |**。因此,在接收时,只有当通过套接字累积了完整数据后,才会发生接收消息的事件。如果我不实现应用程序级别的传输控制,可能会出现接收到部分数据的情况。例如,如果服务器的输出缓冲区设置为 1024,接收缓冲区设置为 256——这会导致部分数据进来,或者全数据在 4 个应用程序级别的包中进来。但是,正如我所说,这个问题已经解决了。
如果你希望对数据进行加密,请确保在将数据传递给发送之前进行加密;如果你希望在发送消息之前对其进行操作(例如,如果你需要与另一个服务器集成),你将不得不继承通信类并覆盖通信函数以满足你的需求。
下一项任务是构建一个使用前面提到的 ForemanDP
的服务器。所以我又打开了一个项目,称之为*NetworkServer*。在其中,我将 ForemapDP
工作者类派生为 CommunicationWorker
类,服务器类则派生自 Foreman 的 BasicServer
。瞧!如果到目前为止一切都清楚,你就能理解我的代码以及它为何如此强大。
这是一个强大的代码实现,因为程序员只需要派生一个工作者和一个服务器,就可以获得一个现成的、运行的、开源的客户端/服务器架构,它使用了正确的资源方法。
与经典 IOCP 比较
正如我们所见,基于领班的模型将更好地“榨取”机器的性能,因为所需工作者(客户端)延迟的定义对于谁需要机器更多关注具有意义。更准确地说,需要延迟 < 100 的客户端在不同的线程中处理。这与经典的 IOCP 不同,经典的 IOCP 中所有客户端都在同一个线程池中处理——这是一种浪费,因为需要 1000(1 秒)延迟的客户端会导致其他排队的客户端一直等待直到它完成处理。
但是,ForemanDP
知道如何处理这种情况:如果客户端延迟不满足要求,将打开一个新的线程(客户端节点),并将该客户端移到其中,以确保它能获得所需的延迟。
Using the Code
好的,那么我们如何开始呢?
步骤 1
首先,要么只添加引用,要么将项目添加到你的解决方案中。对于需要服务器的人,你需要 NetworkSocketManager
、ForemanDP
和 NetworkServer
。对于需要客户端的人,你需要 NetworkSocketManager
和 NetworkClient
。对于两者都需要的人,只需全部添加……
第二步
服务器开发人员:在你的类中,编写你的代码以包含 NetworkServerBase
(或你的派生类)。
private NetworkServerBase MyServer = null;
接下来是实例化服务器。你想把它放在你的类的 Form_Load
事件或构造函数中。
MyServer = new NetworkServerBase(new TimeSpan(1000));
// This line will add a hook for events in your class
MyServer.OnServerNotify +=
new ForemanDP.BasicServer.DelegateServerNotification(MyServer_OnServerNotify);
// Note that following line will not work becuase you need to replace the bolded area
// with the ip you want to link.
MyServer.AddListener(
new NetworkServerClientListener(new TimeSpan(2000),
new System.Net.IPEndPoint("replace this with the ip you want", 5500), 100));
最后一行很有意思,因为它实际上是向服务器添加了一个监听器,其所需延迟为 2 秒。
NetworkServerClientListener
派生自 NetworkClientWorker
,而 NetworkClientWorker
派生自 ForemanDP
工作者。这一行代码将端口 5500 设置为监听端口,并为需要连接的排队客户端提供了 100 个积压空间。你可以添加更多监听器——你想要的数量都可以!对那一行进行循环,或者只是复制粘贴它,无论你需要什么。
从那时起,到该端口的所有通信事件都将通过 MyServer_OnServerNotify
进行管道传输。
void MyServer_OnServerNotify(ForemanDP.BasicWorker worker, object data)
{
switch (((NetworkServerClientWorker.NetworkClientNotification)data).EventType)
{
case NetworkClient.NetworkClientBase.ClientEventTypeEnum.Accepted:
connectedUsers++;
break;
case NetworkClientBase.ClientEventTypeEnum.Disconnected:
connectedUsers--;
break;
}
}
你可以从该事件中捕获消息和其他你需要的任何内容。
客户端:在你的类中,编写你的代码以包含 NetworkClient
。
private NetworkClientBase clientCommunication = null;
---- your constuctor or Form_Load -----
clientCommunication = new NetworkClientBase(
new CommunicationSettings(
CommunicationSettings.SocketOperationFlow.Asynchronic,
CommunicationSettings.SocketOperationFlow.Asynchronic,
CommunicationSettings.SocketOperationFlow.Asynchronic,
CommunicationSettings.SocketOperationFlow.Asynchronic,
CommunicationSettings.SocketOperationFlow.Asynchronic, false, 64));
// and an event hooker!
clientCommunication.OnClientEvent +=
new NetworkClientBase.DelegateClientEventMethod(clientCommunication_OnClientEvent);
:
:
:
---------------------------------------
//and then ...
private void clientCommunication_OnClientEvent(
NetworkClientBase.ClientEventTypeEnum EventType, object EventData)
{
switch (EventType)
{
case NetworkClientBase.ClientEventTypeEnum.None:
break;
case NetworkClientBase.ClientEventTypeEnum.Accepted:
break;
case NetworkClientBase.ClientEventTypeEnum.Connected:
break;
case NetworkClientBase.ClientEventTypeEnum.RawDataRecieved:
break;
case NetworkClientBase.ClientEventTypeEnum.MessageRecieved:
break;
:
:
:
and so on to catch what ever you need or want.
最新版本附加信息
例如,我添加了一个简单的聊天实现。要运行它
- 在 Visual Studio 中,点击绿色三角形按钮(F5)——这将运行聊天服务器。
- 当聊天服务器运行时,返回到 Visual Studio,右键单击聊天客户端项目——将出现一个弹出菜单。从弹出菜单中,选择 Debug -> Start New Instance。
你可以重复步骤 2 来创建和运行任意数量的实例。
*注意*:聊天客户端/服务器只是一个如何使用整个架构的例子。它不是本文的重点,也不打算做到无 bug。
基准程序补充
我已经修复了 TCPSocket
层中的许多问题,这些问题在某些情况下会导致问题。如果出现更多 bug,请告诉我,以便我能修复它们(在我的机器上,它们运行良好)。
基准测试程序会打开一个服务器和多个异步客户端与服务器进行通信。服务器会将每个传入的数据包回显给发送它的客户端(很像 ping!)。
由于需要识别每个异步客户端,我创建了另一个客户端,其中包含一个 NetworkClientBase
并为其添加了身份。通常,你不需要这样的客户端,除非你想在没有服务器或主节点管理它们的情况下与多个客户端进行交互。
Bug 修复 (2008.10.28)
感谢本论坛的 BeAsT[Ru],在用于发送数据的接收器内部使用的队列中发现并修复了一个 bug。发现队列中存在不对称处理,并在代码中实现了关键部分。经过多次测试,我现在可以断定这个 bug 已经不存在了。
Bug 修复 (2008.12.20)
感谢 NapCrisis 修复了聊天示例中的一个小 bug。
最后
大多数程序员相信他们拥有只有上帝才能匹配完美的最佳代码。我也一样!但是,为了真正学到东西,我们必须把我们的自尊心远远地放在一边。所以,请告诉我你的想法,并随时批评。
服务器中的连接器区域未使用——我还没完成——但是,除非你想有一个可以主动与其他服务器通信(例如代理服务器)的服务器,否则它不会困扰你。我保证如果你 CodeProject 的人们想要,我会完成它。
最后一点:有些人可能会问,为什么不用 WCF 代替这一切?WCF 是一种非常高级的请求-响应架构,尽管它很先进,但它无法提供“始终在线的实时连接”。查看此链接:MSDN,如果你不相信我。