抽象 TCP/IP 通信,并添加应有的新基础
AbstractTcpLib
引言
AbstractTcpLib
是一个 TCP 通信库,提供了我认为一个 TCP 库应具备的最基本的功能。使用此库,您可以轻松地创建和管理客户端与服务器之间的 TCP 连接,并且不仅可以在客户端和服务器之间通信,还可以在客户端之间通信。此库还提供 AES256 加密通信、Windows 身份验证、与同一客户端的多个并发文件传输,以及对象序列化,以便于在远程机器之间传输数据。
文档
此版本的 AbstractTcpLib
包含一个文档 PDF。如果您已经下载了此库,但只需要或想要文档,则可以从上方单独下载。这个库比 CodeProject 上大多数库都要复杂。如果您想使用它,但难以理解如何以及何时使用各种功能,请查看 PDF。
如果会话可以包含其他会话呢?
当我刚开始接触 TCP 通信时,我非常讨厌从单个应用程序创建多个到服务器的 TCP 连接。这看起来很草率。我也觉得我像是在作弊,因为我认为我应该能够通过一个会话完成我需要的所有通信。毕竟,字节就是字节,对吧?如果你连接到了服务器,你就连接到了服务器。
多年来,我提出了各种逻辑上分离和管理我发送的数据的方法——但没有什么能比拥有一个完整的会话来发送任何你想发送的内容。因此,我没有再想出另一种同步客户端和服务器之间数据的方法,而是将一个会话管理系统构建到了这个库中。在这个库中,**会话真的可以包含其他会话**。它们被称为子会话,这些是您将用于逻辑上分离所需的所有内容的通信通道。这样做的好处是,您的网络硬件和本地机器与远程机器之间的路由器将负责平衡子会话之间发送数据的带宽,因此虽然您的每个子会话的总带宽可能会减少,但它们都会获得交换机和路由器(们)可以提供的尽可能多的带宽,无论它们发送什么。
子会话链接
一旦子会话管理系统到位,我又想到了一点。如果我有一个客户端处理和管理(可能)很多 TCP 会话,那么我是否可以在服务器上逻辑上连接属于不同客户端的两个子会话?如果做得正确,从客户端 1 通过此链接的子会话发送的数据将自然地流向另一个客户端……而另一个客户端将能够向客户端 1 发送数据。我们只需要一个足够智能的服务器在我们要求时执行关联。我们还需要能够获取连接的客户端列表,并在我们要求服务器创建链接的子会话时提供清晰的错误消息,以防万一它因某种原因无法做到。
当然,这是**点对点通信**。在这个库中,我们称之为“子会话链接”。链接的子会话可以在任何两个连接的客户端之间创建。
字节?谁还需要字节?
大多数现有的 TCP 库都允许您发送文本,但如果您想发送其他任何内容,则需要将数据转换为 `string` 或字节数组。我花了很多时间这样做,这很累人。它迫使您进行大量的解析,将 `string` 转换为字节数组,并将类转换为 XML,然后在代码中将 XML 转换回类。您需要发送的数据类型越多,这方面的工作就越多。事情很快就会变得混乱。
这个库在 `Client` 和 `Session` 类中只有一个 `.Send()` 函数用于发送数据。如果您按照我的预期使用它,您将永远不会想到发送字节——因为此库的 `.Send()` 函数接受任何 .NET 可序列化对象。您将 `string` 放入 `Send()` 函数,然后在另一端获得一个 `string`。有一个 `DataTable` 要发送?将其放入 `send` 函数。有您自己的类要发送?将它们标记为可序列化,然后将它们放入 `Send()` 函数——它们就会被发送出去。
因此,我将此库命名为 `AbstractTcpLib`;因为它将您从发送数据的细节中抽象出来。
但我确实想处理字节怎么办?
当然,序列化对象并在远程端反序列化对象会消耗一些 CPU 资源。我使用的是 .NET 二进制序列化器,虽然它相对快速高效,但那些对原始性能感兴趣的人会认为这是一个问题。好消息是字节数组不会被序列化。为什么?因为,嗯——这太傻了。套接字类发送字节数组。序列化字节数组只会白白消耗 CPU 资源,并向字节数组添加几个字节以包含关于字节数组对象的信息——这一切都是徒劳的。
因此,如果您确实想发送 `Byte[]`,因为您想要最佳的网络性能,或者因为某种原因担心二进制序列化器,或者您只是宁愿处理字节——那就去做吧。只需将您的字节数组放入 `.Send()` 函数即可。
请求/响应事务
请求/响应事务提供简单而强大的客户端到服务器或客户端到客户端的问答通信。只需几行代码即可向服务器发送 SQL 查询并获取 `Datatable`。请求服务器一张图片并快速轻松地获取它。定义等待回复的时间,并提供一个委托,在超时时运行。一切都处理得井井有条。
- 创建“请求”:请求包含一个类型 `string`、您提供的请求对象,以及您提供的委托,其中包含响应到达时运行的代码,以及请求是否超时。您还必须指定等待回复的时间。
- 在远程计算机上,您定义 RequestProcessors:当具有匹配类型 `string` 的请求到达时,请求处理器就会运行。您提供类型 `string` 和一个委托,其中包含处理请求和处理已发送请求对象的代码。
- 使用可序列化对象进行响应:如果您选择使用 `.Respond(Object o)` 函数发送响应对象,它将发送回请求客户端,并由您在初始请求中提供的委托处理。
安全 - 加密和身份验证
如今,每个人都关心安全。我为一家门禁和视频监控公司提供咨询,当然,安全是他们优先考虑的重点。一切都必须加密。一切都必须经过身份验证。在当今社会,这非常重要,以至于在我看来,任何通信库都不能被认为没有某种安全功能。在这个库中,我包含了两种实现安全措施的方法:
AES256 加密:服务器类有两种模式——加密模式和混合模式。在加密模式下,所有客户端都必须拥有正确的预共享密钥才能连接。连接的客户端在连接后立即被要求向服务器注册为会话或子会话。如果服务器无法使用其自己的 PSK 解密此注册请求,**连接将被拒绝,客户端将被简单地断开连接,没有任何错误消息**。如果服务器*能够*解密注册请求,它会立即使用 `RNGCryptoServiceProvider` 使用新的随机生成的密钥更改会话 PSK。
LDAP 身份验证:服务器可以配置为需要 Windows 身份验证。在连接客户端之前,使用 `.Login`(username, password) 函数输入凭据。凭据会立即存储为加密字符串,并在注册请求中传递给服务器。服务器将尝试针对*服务器所在的域*对凭据进行身份验证,因此无需与用户名一起发送域(例如:domain\username)。如果服务器不在域中,它将尝试针对其运行的工作站进行身份验证。如果客户端身份验证失败,它将收到“身份验证失败”消息并被断开连接。
混合模式与加密模式:在混合模式下,客户端可以选择创建加密会话或子会话。重要的是要知道,如果服务器配置为需要身份验证,但*不*需要加密,而您创建了非加密的会话或子会话,那么您的 Windows 凭据将作为序列化字符串发送——这*不*安全。如果您将服务器配置为需要身份验证,请同时使用加密。
文件、文件和更多文件:每个客户端的并发文件传输
所以您需要发送一个文件。好的,太棒了——这个库可以满足您的需求。需要发送两个?当然,当然。您想如何发送它们——一次一个,还是同时发送?或者也许同时发送三个或四个?这完全取决于您,以及您认为您的硬件可以承受的。
AbstractTcpLib
通过子会话发送文件。事实上,如果您查看 `Client` 类,您根本不会看到 `SendFile()` 或 `GetFile()` 函数——这些函数位于 `Session` 类中。您通过首先创建一个或两个子会话来创建文件传输。然后,您通过使用 `Client.GetSubSession()` 获取对子会话(它们是 `Session` 对象)的引用,然后调用 `Session.SendFile()` 或 `Session.GetFile()`。
您可以创建任意数量的文件,并且**您可以传输您的客户端和服务器之间的文件,或者您的客户端和另一个连接的客户端之间的文件**。
要在客户端之间传输文件,请先创建一个链接的子会话。然后使用 `Client.GetSubSession()` 获取对它的引用,就像处理任何其他子会话一样。
订阅文件传输事件:在发起或接收文件传输时,有三个委托可供订阅:`TransferProgress(Uint16 percentComplete)`、`TransferError(String errorMessage)` 和 `TransferComplete()`。我认为这不言自明,它们的工作方式正如您所想。如果您有任何疑问,请随时提问。
文件使用 `FileTransfer` 类进行传输。在传输过程中,一个文件传输对象将与子会话的每一端相关联。订阅 `Server.receivingAFile(FileTransfer)` 或 `Client.receivingAFile(FileTransfer)` 委托将使您能够了解何时正在接收文件,您还可以订阅 `fileTransfer` 对象上的 `transferProgress`、`transferError` 和 `transferComplete` 委托,以便跟踪传入文件的进度。如果您不想允许传输继续,您可以在接收端随时 `.Cancel()` 传输,而不是等待它完成。
一些细节
`Client`、`Server` 和 `Session` 类使用 XML 进行通信。因为这个库序列化发送的对象,所以我自己从未需要解析任何 XML。相反,我使用了一个我构建的 XML 解析器,名为 `XmlObject`。使用 `XmlObject` 和此库序列化对象并将它们来回传递的能力,我能够轻松而清晰地创建 `XmlObject`(s),添加参数和其他数据,将它们传递到远程计算机,在那里它们作为 `XmlObject`s 到达,并使用 `XmlObject` 类中可用的工具轻松访问传递的数据。
此库允许您将部分通信过滤上来,以便在会话断开连接、连接和注册自身时、创建子会话时、由于加密密钥不兼容导致连接失败时、身份验证失败时等情况通知您。因此,您的客户端和服务器将在回调中收到 `XmlObject`s。别害怕……它们是你的朋友。
Using the Code
服务器和客户端都使用委托来传递您传入的数据(您发送的对象)。这些委托将单个 `CommData` 对象作为参数。`CommData` 对象只是您传递的对象的一个包装器。它包含传入的序列化字节数组,一个 `deSerialized` 对象,该对象是您放入 sendbytes 的对象,但它不是您的 `String` ——它是一个 `Object`。您可以使用 `typeof(String)` 来测试它是否是您的 `String`,然后将其转换为 `String` 或 `var`,或者使用 `CommData.GetObject()`,如下所示:
this.client = new Client((Core.CommData data) =>
{
// Get the passed object:
var o = data.deSerialized;
if (o.GetType() == typeof(XmlObject) && ((XmlObject)o).Name.Equals("ATcpLib"))
{
XmlObject xml = (XmlObject)data.deSerialized;
String msg = xml.GetAttribute("", "internal");
String originId = xml.GetAttribute("", "id");
// Are we shutting down?
if(msg.Contains("disconnected") && originId.Equals(client.GetConnectionId()))
{
UI(() =>
{
lblStatus.Text = "Disconnected.";
btConnect.Text = "Connect";
lbSubSessions.Items.Clear();
});
}
if (msg.Contains("CreateSubSession") && originId.Equals(client.GetConnectionId())
&& xml.GetAttribute("", "status").Equals("true"))
{
UI(() =>
{
lbSubSessions.Items.Add(xml.GetAttribute("", "subSessionName"));
});
}
// Is a SubSession shutting down?
if (msg.Contains("disconnected") && !originId.Equals(client.GetConnectionId()))
Me.client = New Client(Function(ByVal data As Core.CommData)
Dim o = data.deSerialized
If o.[GetType]() = GetType(XmlObject) _
AndAlso (CType(o, XmlObject)).Name.Equals("ATcpLib") Then
Dim xml As XmlObject = CType(data.deSerialized, XmlObject)
Dim msg As String = xml.GetAttribute("", "internal")
Dim originId As String = xml.GetAttribute("", "id")
If msg.Contains("disconnected") AndAlso originId.Equals(client.GetConnectionId()) Then
UI(Function()
lblStatus.Text = "Disconnected."
btConnect.Text = "Connect"
lbSubSessions.Items.Clear()
End Function)
End If
If msg.Contains("CreateSubSession") AndAlso originId.Equals(client.GetConnectionId()) _
AndAlso xml.GetAttribute("", "status").Equals("true") Then
UI(Function()
lbSubSessions.Items.Add(xml.GetAttribute("", "subSessionName"))
End Function)
End If
If msg.Contains("disconnected") _
AndAlso Not originId.Equals(client.GetConnectionId()) Then
正如您所见,客户端的构造函数接受传入数据委托。您如下连接到服务器:
String errMsg = "";
client.Login(tbUserName.Text, tbPassword.Text);
if (!client.Connect(System.Net.IPAddress.Parse(tbIpAddress.Text.Trim()),
ushort.Parse(tbPort.Text.Trim()), tbSessionId.Text.Trim(),
out errMsg, cbUseEncryption.Checked, tbPsk.Text))
{
MessageBox.Show(errMsg, "Connection failed.", MessageBoxButtons.OK, MessageBoxIcon.Error);
return;
}
Dim errMsg As String = ""
client.Login(tbUserName.Text, tbPassword.Text)
If Not Me.client.Connect(System.Net.IPAddress.Parse(tbIpAddress.Text.Trim()), _
UShort.Parse(tbPort.Text.Trim()), tbSessionId.Text.Trim(), _
errMsg, cbUseEncryption.Checked, tbPsk.Text) Then
client.Close()
MessageBox.Show(errMsg, "Connection failed.", _
MessageBoxButtons.OK, MessageBoxIcon.[Error])
Return
End If
当您连接时,会创建一个 `session` 对象来处理您客户端到服务器的连接,并将其添加到服务器的 `SessionCollection` 中。您可以选择使用您的 `session` 向服务器发送对象,或者您可以创建子会话。
子会话也是会话。当您使用客户端创建子会话时,会创建一个 `Session` 对象并将其添加到您客户端的子会话集合中。在服务器上,您的子会话将被注册并添加到您会话的子会话集合中。
要通过您的客户端会话向服务器(或对等方)发送数据,请使用 `Client.Send()`(或 `Session.Send()`)函数,如下所示:
String errMsg = "";
if(!client.Send(tbMessage.Text, out errMsg))
{
MessageBox.Show(errMsg, "Send failed.", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
Dim errMsg As String = ""
If Not client.Send(tbMessage.Text, errMsg) Then
MessageBox.Show(errMsg, "Send failed.", MessageBoxButtons.OK, MessageBoxIcon.Error)
End If
要通过子会话发送数据,请先使用其名称(`String sessionId`)获取子会话,如下所示:
Session session = null;
String errMsg = "";
if(!client.GetSubSession(lbSubSessions.SelectedItems[0].ToString(), out session, out errMsg))
{
MessageBox.Show(errMsg, "Could not get subsession.", _
MessageBoxButtons.OK, MessageBoxIcon.Error);
} else
{
if(!session.Send(tbMessage.Text, out errMsg))
{
MessageBox.Show(errMsg, "Send failed.", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
Dim session As Session = Nothing
Dim errMsg As String = ""
If Not client.GetSubSession(lbSubSessions.SelectedItems(0).ToString(), session, errMsg) Then
MessageBox.Show(errMsg, "Could not get subsession.", _
MessageBoxButtons.OK, MessageBoxIcon.Error)
Else
If Not session.Send(tbMessage.Text, errMsg) Then
MessageBox.Show(errMsg, "Send failed.", MessageBoxButtons.OK, MessageBoxIcon.Error)
End If
End If
要订阅服务器(或客户端)的传入 `FileTransfer` 委托,请按以下方式执行(此示例取自示例应用程序。因此,它正在使用列表视图更新传输信息):
server.receivingAFile = (FileTransfer transfer) =>
{
ListViewItem lvi = new ListViewItem(transfer.FileName());
FileTransfer.TransferComplete complete = null;
FileTransfer.TransferProgress updateProgress = null;
FileTransfer.TransferError transferError = null;
lvi.SubItems.Add("0%");
lvi.SubItems.Add(transfer.DestinationFolder());
lvi.SubItems.Add("Transferring file");
complete = () =>
{
UI(() => lvi.SubItems[3].Text = "Complete");
transfer.transferComplete -= complete;
transfer.transferError -= transferError;
transfer.transferProgress -= updateProgress;
};
updateProgress = (ushort percentComplete) =>
{
UI(() =>
{
lvIncommingFiles.BeginUpdate();
lvi.SubItems[1].Text = percentComplete.ToString() + "%";
lvIncommingFiles.EndUpdate();
});
};
transferError = (String errorMessage) =>
{
UI(() =>
{
lvi.SubItems[2].Text = "Error: " + errorMessage;
lvi.ForeColor = Color.Red;
});
transfer.transferComplete -= complete;
transfer.transferError -= transferError;
transfer.transferProgress -= updateProgress;
};
transfer.transferComplete += complete;
transfer.transferError += transferError;
transfer.transferProgress += updateProgress;
UI(() => lvIncommingFiles.Items.Add(lvi));
};
server.receivingFile = Sub(transfer As FileTransfer)
Dim lvi As New ListViewItem(transfer.FileName())
Dim complete As FileTransfer.FileTransferComplete = Nothing
Dim updateProgress As FileTransfer.FileTransferProgress = Nothing
Dim transferError As FileTransfer.FileTransferError = Nothing
lvi.SubItems.Add("0%")
lvi.SubItems.Add(transfer.DestinationFolder())
lvi.SubItems.Add("Transferring file")
complete = Sub()
UI(Sub()
lvi.SubItems(3).Text = "Complete"
End Sub)
transfer.transferComplete = _
[Delegate].Remove(transfer.transferComplete, complete)
transfer.transferError = _
[Delegate].Remove(transfer.transferError, transferError)
transfer.transferProgress = _
[Delegate].Remove(transfer.transferProgress, updateProgress)
End Sub
updateProgress = Sub(percentComplete As UShort)
UI(Sub()
lvIncommingFiles.BeginUpdate()
lvi.SubItems(1).Text = percentComplete.ToString() + "%"
lvIncommingFiles.EndUpdate()
End Sub)
End Sub
transferError = Sub(errorMessage As String)
UI(Sub()
lvi.SubItems(2).Text = "Error: " + errorMessage
lvi.ForeColor = Color.Red
End Sub)
transfer.transferComplete = _
[Delegate].Remove(transfer.transferComplete, complete)
transfer.transferError = _
[Delegate].Remove(transfer.transferError, transferError)
transfer.transferProgress = _
[Delegate].Remove(transfer.transferProgress, updateProgress)
End Sub
transfer.transferComplete = _
[Delegate].Combine(transfer.transferComplete, complete)
transfer.transferError = _
[Delegate].Combine(transfer.transferError, transferError)
transfer.transferProgress = _
[Delegate].Combine(transfer.transferProgress, updateProgress)
UI(Function() lvIncommingFiles.Items.Add(lvi))
End Sub
如何发送您自己的自定义对象
最初我没有包含这方面的信息,因为——就像大多数开发者一样,我认为——我只是假设每个人都会自动理解这是如何工作的。嗯,今天我了解到这并不像我所想象的那么直观,所以我要在这里详细说明。
首先,要将您的自定义类从客户端发送到服务器(或反之),您的服务器和客户端都必须了解它们。您不能只在客户端创建一个类并发送它。您的服务器将无法理解它收到的内容。
- 在您的项目中创建一个新的类文件。当我写“您的项目”时,我的意思是您将添加对 `AnstractTcpLib` 的引用的项目,并创建一个 `AbstractTcpLib.Client()` 或 `AbstractTcpLib.Server()`。**如果您想在客户端和服务器之间来回发送您自己的自定义类对象,则必须在创建 `AbstractTcpLib.Client()` 的项目中以及创建 `AbstractTcpLib.Server()` 的项目中创建此自定义类**,**并且它们必须相同**。
- 在您的服务器和客户端应用程序中,将默认命名空间更改为具有描述性和有用性的名称。在此示例项目中,现在有一个“`MyObj`”类。该类位于“`MyObjects`”命名空间中。服务器和客户端应用程序中都有一个相同的 `MyObj` 类,因此您可以看到这是如何完成的。
- 当您的服务器(或客户端)收到一个对象时,您需要使用 `typeof` 进行测试。像这样就可以了:
if (data.type == typeof(MyObjects.MyObject))
{
MyObjects.MyObject obj = (MyObjects.MyObject)data.deSerialized;
// Do something with obj here.
}
If data.type = GetType(MyObjects.MyObject) Then
Dim obj As MyObjects.MyObject = DirectCast(data.deSerialized, MyObjects.MyObject)
' Do something with obj here.
End If
就是这样。如果您尝试发送接收程序集无法理解的类或对象,您将收到一个 `XmlObject`,其中包含反序列化失败时生成的异常信息。
等等——还有别的吗?
当然。请参阅示例应用程序了解您需要了解的任何其他信息。如果您在那里找不到,或者遇到问题,请随时在下方提问。
您将来会添加什么吗?
当然!
首先是协作限流。使用 LDAP 身份验证的客户端的加密注册请求(目前它们仅被压缩,防止明文传输登录凭据),以及在我想到或您要求并且我有时间的情况下进行的更多操作。
发现 bug 了?
这个库很酷……如果我自己这么说的话。我喜欢构建它,并在一定程度上进行了测试。但是这里有很多代码,我不可能像我希望的那样彻底地测试它……而且它是全新的。
我将在未来的项目中使用它,并在出现 bug 时发布修复。如果您发现一个,请随时告诉我。我会尽快修复并发布新版本。
感谢阅读!
变更
以下列表包含此库 **1.5.5** 版本中的更改
- 解决了由 CosmoMaster 发现的内存泄漏。
以下列表包含此库 **1.5.4** 版本中的更改
- 移除了库中用于测试目的的一些代码,这些代码在某些情况下会阻止文件传输。
- 添加了文档 PDF。
以下列表包含此库 **1.5.3** 版本中的更改
- 为会话连接、会话断开连接和一般通知添加了服务器事件处理程序。这大大简化了主服务器回调,并将所有空间都用于处理到达服务器的用户数据。请参阅示例项目,了解如何为您的语言完成此操作。
以下列表包含此库 **1.5.2** 版本中的更改
- 修复了服务器中一个导致崩溃的 bug,该 bug 在服务器配置为仅加密通信时,客户端尝试连接到配置为非加密通信的服务器但具有不正确的 psk 时发生(由 Toron 报告)**。**
- 对新的 RequestResponseTransactions 类进行了一些手动重构。
以下列表包含此库 **1.5.1** 版本中的更改
- 修复了 `Client.Close()` 中的一个 bug,当在尝试连接之前调用 close() 时会引发异常。
- 添加了新功能:`client.connect()` 现在接受字符串作为地址。V4 IP 地址或主机名将被接受并解析或解析为 IP 地址。
以下列表包含此库 **1.5** 版本中的更改
- 修复了一个 bug,该 bug 阻止了具有与先前已关闭的会话相同名称的子会话的创建。
- 添加了新功能:请求/响应事务系统。
以下列表包含此库 **1.4.2** 版本中的更改
- 更改了服务器使用已占用端口启动时的错误响应行为。以前,会抛出异常。现在,当抛出异常时,它会被捕获并通过数据委托作为包含异常数据的 XmlObject 传递给最终用户。
以下列表包含此库 **1.4.1** 版本中的更改
- 纠正了 VB.NET 示例项目中阻止自定义对象在 VB.NET 应用程序之间正确传输的问题。
- 更改了测试对象的名称,使其在 C# 和 VB.NET 示例应用程序之间保持一致。
以下列表包含此库 **1.4** 版本中的更改
- 解决了连接到未配置为接受加密连接的服务器时进行加密连接时的崩溃问题。
- 在 VB.NET 中添加了新的示例客户端和服务器项目。它们的外观和行为与 C# 版本完全相同,只是它们是用 VB.NET 完成的。
以下列表包含此库 **1.3.1** 版本中的更改
- 解决了发送自定义对象时的崩溃问题。
- AbstractTcpLib 会捕获反序列化失败,并使用包含异常信息的 XmlObject 报告它们。
以下列表包含此库 **1.2** 版本中的更改
- 此库将不再接受序列化对象作为注册请求,并将断开任何其他类型的初始连接。
- 内部库通信不再通过序列化对象进行。取而代之的是,XmlObject 被转换为字符串,然后转换为字节数组并压缩后传递,并在远程计算机上解析回 XmlObject。此更改对最终用户是无缝的。
- 这些更改旨在保护配置为仅进行 LDAP 身份验证的服务器免受 DOS 攻击。
以下列表包含此库 **1.1** 版本中的更改
- 此库现在在发送序列化对象之前对其进行压缩,并在反序列化之前对其进行解压缩。