使用原生 .NET TCP 库创建服务器/客户端应用程序






4.97/5 (15投票s)
在本文中,我将通过编程一个使用 .NET 框架原生 TCP 协议库 `System.Net.Sockets` 命名空间的服务器/客户端应用程序框架的不同阶段。
注意:源代码包在同一个 Visual Studio 解决方案中包含 TcpClient 和 TcpServer 两个项目。
引言
大家好,前几天我一直在思考创建一个服务器/客户端应用程序,它能完全在控制台应用程序下运行,内存占用仅为 <10 MB。我在 .NET 框架中尝试了一些协议处理对象,问题的答案是 `System.Net` 命名空间集合。但主要我们会只引用和讨论 System.Net.Sockets
命名空间。这个命名空间提供了我们可以用来编写使用原生 TCP/IP 协议的应用程序的编程对象,并且可以用来编写可以管理网络功能的应用程序。你可以编写你的应用程序来管理套接字(Internet Sockets、Windows Sockets 或简称为 Socks),处理来自不同客户端的请求,并生成响应。
在本文中,我将介绍 TCP,.NET 框架中有哪些可以用来处理 TCP 协议的内容;传输控制协议。在文章的最后,你将能够使用 .NET 原生的 System.Net.Sockets
命名空间来编写 Web 服务(或者你称之为服务器/客户端应用程序模型)。如果你对这个命名空间有足够的了解,你将不再需要 WCF 框架。虽然 WCF 也使用相同的协议、相同的对象,但它只是抽象了底层的线程和进程处理,以便你可以编写你想执行的操作,而不是管理它是如何执行的。
传输控制协议
让我们对传输控制协议本身进行概述,它是互联网协议套件的一部分,并且是 OSI 模型传输层中广泛使用的协议。OSI 模型是一个为计算机网络以及计算机如何通过计算机网络进行通信而设计的著名模型。OSI 模型是一个标准的模型,它被开发并应用于计算机。它包含 7 层,控制数据如何被转换为字节,如何在网络上共享,以及如何再次从字节转换回来,
- 物理层
- 数据层
- 网络层
- 传输层
- 会话层
- 表示层
- 应用层
这些层在自己独立的抽象环境中工作,并且不干扰其他层。OSI 模型确保传输的数据在另一端总是无误接收,并且会执行排序,如果存在错误则会进行重传(我不会讨论网络攻击,如 TCP 劫持或拒绝服务)。在本文中,作为介绍,我将只讨论传输层。TCP 和 IP 这两个协议协同工作,以确保数据能够无问题地传输给接收者,实际的接收者。但是,对于我们的服务器/客户端应用程序,我们将只讨论 TCP 协议,而忽略 IP 协议。另外,请注意,IP 协议工作在网络层,而TCP 协议工作在传输层。
所有这些层都在管理网络中的不同功能方面执行各自的任务。通常 TCP 协议与 IP 协议一起使用,以确保可靠、有序、无误的数据传输。TCP 协议支持我们日常使用的功能,从万维网(HTTP)、文件传输(FTP)和其他通信模型和协议。TCP 协议的一些关键要点是:
- 建立和终止连接
- 传输数据
1. 可靠的数据传输;管理字节序列和数据包结构。
2. 错误检测;重新发送丢失的数据包。
3. 流控制;估计可以可靠发送数据包的速度。 - 确认和其他标志。
在下一节中,我们将学习如何使用此协议并创建两个示例项目,一个作为服务器,另一个作为客户端。然后我们将使用客户端向服务器发出请求,服务器将根据请求发送的数据执行操作,并将响应返回给客户端。客户端将显示服务器发送的消息。请记住,就像其他协议一样,TCP 只是用于维护两个应用程序、服务器和客户端或其他通过网络通信的类似程序之间的网络连接的协议。
这张图描述了一个应用程序如何与其他应用程序共享其数据。基于协议的数据编码和转换从应用程序层开始,传输层在管理数据、字节排序和错误控制方面起着至关重要的作用,而物理层管理着从一个应用程序传输到另一个应用程序的比特。另一端也发生同样的事情,但方式相反,比特被转换为数据包,运行错误检查,并对字节进行排序,以便它们可以被转换为数据。然后数据与用户共享。这就是网络的工作方式。
我省略了命名这些层,而是将它们命名为类别,如应用程序层(用于应用程序、表示和会话层)、传输层(因为我需要讨论 TCP,必须清楚地描述传输层)和物理层(用于网络、数据和物理层)。你应该始终将它们视为独立的层。
客户端-服务器模型
客户端-服务器(或服务器/客户端)模型在开发领域已经存在很长时间了。客户端-服务器应用程序模型为程序员提供了构建框架的两种不同版本。一个应用程序充当服务器,另一个充当客户端。
- 服务器
服务器是提供网络连接设备资源的应用程序、程序或计算机设备。 - 客户端
客户端是依赖服务器获取资源的应用程序、程序和计算机设备。
在此模型中,客户端可能位于同一台机器或位置,也可能不位于同一台机器或位置。客户端和服务器通过互联网连接,或任何其他允许它们共享资源的计算机网络进行通信。在办公室中,服务器通常配备硬件资源,如打印机等。服务器还拥有运行业务所需的软件应用程序。你可以(从客户端应用程序)发送到服务器,在那里数据被处理并生成一个响应,然后发送回客户端。
客户端-服务器模型的工作原理
客户端-服务器模型的工作原理非常简单,客户端应用程序是依赖服务器的资源、软件应用程序和其他硬件组件的。它可以位于不同的、独立的上下文中,也可以安装在同一服务器上以进行活动。服务器应用程序(设备、程序或机器)是控制硬件资源、软件应用程序和其他业务数据并能够对其进行操作的应用程序。客户端向服务器发送请求,服务器可能会,也可能不会向客户端请求任何额外内容。这完全取决于服务器和客户端的配置方式。一旦客户端连接到服务器,服务器将按照客户端的命令执行操作。不同的命令可以启动和触发服务器上的不同功能。一旦服务器完成了对客户端数据和请求的处理,它就会生成一个响应。客户端可能会,也可能不会显示响应,服务器也可能,也可能不发送响应并关闭连接。一旦连接关闭,其他流量就可以连接并共享它们的数据。
为了进行一些可视化演示,我创建了一个该过程的演示。
这张图演示了客户端和服务器如何相互通信。服务器包含数据源的定义、所有脚本的源代码以及用于运行业务逻辑的其他资源。
通过这种方式,客户端和服务器充当了一个分布式应用程序框架。用户可以从他们所在的任何位置与服务器通信。
客户端-服务器模型的优势
现在你已经了解了客户端-服务器模型,让我指出一些该模型(我发现它很有用)的优势:
- 整个应用程序模型分布在两个不同的项目中。我可以轻松配置它们如何共享数据以及它们如何从网络接收数据。
- 我可以在服务器应用程序上编写业务逻辑,并在其上执行函数。遵循 DRY 原则;不要重复自己。
- 我可以与朋友共享客户端应用程序,并保持我的服务器运行。服务器运行只需要不到 10 MB 的内存;非常适合这种小型紧凑型应用程序。如果我想添加更多功能,例如异步编程来接受多个客户端的请求,我肯定可以做到。
- 跨应用程序的跨平台数据共享。
除了这些好处之外,构建一个将客户端和服务器逻辑分布在两个不同应用程序中的框架还有其他很多好处。你可以让你的服务器启动并运行,随时监听客户端。客户端应用程序不需要一直运行。
同时请记住,不一定需要构建一个服务器资源比客户端更多的应用程序。那是完全错误的,尽管大多数应用程序都是这样构建的。但是,服务器不需要拥有更多的资源。例如,你可以将所有业务逻辑写在客户端应用程序中,运行验证测试,然后将数据发送到服务器以简单地将其存储在文件系统中。你不必在每次按键后都将数据发送到服务器进行验证和执行其他逻辑,这会消耗大量的网络流量。
.NET 中构建客户端-服务器应用程序
.NET 框架包含一个非常高效和强大的框架,用于创建 Web 服务,即 Windows Communication Foundation。WCF 允许你为应用程序创建客户端-服务器模型,你可以用它来创建服务器应用程序(通常使用控制台项目来创建 WCF 中的服务器实例,因为它内存占用小且效率高。你也可以使用 WPF 或 WinForms),并且你可以通过多种方式创建客户端应用程序。
- 控制台应用程序
适用于更简单的客户端应用程序。 - Windows Presentation Foundation (或 Windows Forms)
适用于更复杂和 GUI 导向的应用程序。 - ASP.NET Web 应用程序
适用于可以托管在互联网上的应用程序,以便客户端可以通过互联网连接到应用程序。此选项允许非 Windows 平台的用户使用 Web 服务,例如来自没有 .NET 框架的 Android 设备。
除此以外,.NET 框架还提供了原生 .NET 库和程序集,可用于在 .NET 框架中创建面向网络的应用程序。`System.Net` 命名空间提供了我们可以用来处理 .NET 应用程序中网络和协议的程序集。这个命名空间下有很多命名空间,我只会讨论其中一些。本文中介绍或讨论的命名空间是:
- System.Net
.NET 框架中网络的核心程序集。我们的示例项目也将使用它。 - System.Net.Mail
这个命名空间包含用于发送电子邮件、连接到 SMTP 服务器和其他所需操作的对象。 - System.Net.Security
提供我们用于生成 SSL 流的对象。 - System.Net.Sockets
这个命名空间包含用于 TCP 或 UDP 客户端的对象;监听器和客户端,用于基于这些协议处理网络。
我们将使用上面讨论的命名空间来创建服务器和客户端应用程序。在这两种情况下,我们都将使用控制台项目并编写适当的代码来维护服务器和客户端应用程序。
示例项目
对于我们的示例,我们将创建一个示例项目,该项目接受来自客户端的数据。客户端以 JSON 格式发送数据,将客户端的姓名、电子邮件地址和消息发送给服务器。服务器(在本例中)会向该人发送电子邮件(使用其电子邮件地址)。
客户端会向用户询问姓名、电子邮件和消息。然后你可以将此客户端应用程序共享给网络上的任何人,服务器将接受数据并按要求进行响应。
编写服务器应用程序
我们的服务器应用程序需要被托管,以便接受来自客户端的新请求。要托管我们的应用程序,我们需要提供一个我们的应用程序将监听的地址和端口。我们可以使用 `TcpListener` 对象来创建我们服务器的实例。作为构造函数的参数,我们将传递将用作我们服务器托管地址的 IP 地址终结点。客户端需要知道这个地址,因为他们将使用这个地址连接到我们的服务器应用程序。请注意,我将使用回送地址(**127.0.0.1**)和一个示例端口号(**1234**),因为我将使用我自己的机器作为服务器和客户端。你可以使用不同的 IP 地址或端口来配置你自己的服务器,并为你的网络中的客户端提供对其的访问。
IPEndPoint ep = new IPEndPoint(IPAddress.Loopback, 1234); // Address
TcpListener listener = new TcpListener(ep); // Instantiate the object
listener.Start(); // Start listening...
此时,我们的服务器将启动并运行,并准备好接受来自 TCP 客户端的新请求。请注意,网络是以字节为单位工作的,我们将以字节的形式读取数据。在接受字节后,将进行解码以将数据转换为适当的类型;文本、图像或音频。在本示例中,我将只演示文本,这足够简单。你可以使用其他对象(例如文件)并通过提供格式“**image/jpeg**”等的字节将数据转换为图像类型。
接下来,我们监听一个客户端。在客户端发送请求之前,我们无法继续。为了接受数据,我们需要一个内存位置来存储客户端发送的数据。此外,我们还需要存储消息的字符串表示形式。看看我是怎么做的,
// Just a few variables
const int bytesize = 1024 * 1024; // Constant, not going to change later
string message = null;
byte[] buffer = new byte[bytesize];
/* Can only proceed from here
* if a client makes a request to our application.
* Once receives a request, should proceed from this line. */
var sender = listener.AcceptTcpClient();
// Reads the bytes from the network
sender.GetStream().Read(buffer, 0, bytesize);
上面的代码使我们的服务器能够接受新请求,并读取客户端发送给我们的数据。到目前为止,我们已经配置了应用程序以能够启动、运行并监听来自服务器的新请求。我们的服务器目前是同步的,它一次只能接受一个请求,并且在连接终止之前不会接受其他请求。
你可以使用异步编程模型来接受来自多个客户端的多个请求。在同步模式下,你不能接受多个请求。你必须处理一个请求,完成请求,发送响应或终止连接,然后才能处理下一个请求或客户端。
由于我告诉你只发送或接收字节,其他数据类型只是我们使用的花哨的东西。现在我们需要将这些字节转换成描述性数据。由于我们知道我们的客户端只传输字符串数据,我们可以将数据解码为字符串类型。另外请注意,我们的缓冲区大小非常大,以便我们可以接受任意数量的数据。如果需要,你可以缩短它。如此多的字节数据,在没有找到字符的地方总会包含空字符。空字符会占用控制台上的大量空间。我为此编写了一个函数,用于从字符串中删除空字符。
// Pass byte array as parameter
private static string cleanMessage(byte[] bytes)
{
// Get the string of the message from bytes
string message = System.Text.Encoding.Unicode.GetString(bytes);
string messageToPrint = null;
// Loop through each character in that message
foreach (var nullChar in message)
{
// Only store the characters, that are not null character
if (nullChar != '\0')
{
messageToPrint += nullChar;
}
}
// Return the message without null characters.
return messageToPrint;
}
我们将在服务器和客户端中使用这个函数。用于删除发送或接收的消息中的空字符。除此之外,我还有另外 2 个函数,用于处理我们服务器的不同功能。一个是生成和发送响应,另一个是作为响应发送电子邮件给用户。
发送电子邮件
对于此示例,我们将使用电子邮件作为对用户的响应。我们将向用户发送一封电子邮件,通知他们我们已收到他们发送给我们的数据。我不会解释这个模块是如何完成的,更多关于在 .NET 框架中发送电子邮件的信息,请阅读我写的这篇文章。
// Send an email to user also to notify him of the delivery.
using (SmtpClient client = new SmtpClient("<your-smtp-server>", 25))
{
// A few settings
client.EnableSsl = true;
client.Credentials = new NetworkCredential("<email>", "<password>"); // Authentication
client.Send( // Send the message
new MailMessage("<email-address>", p.Email, // Email addresses
"Thank you for using the Web Service", // Subject
string.Format( // Body construction
@"Thank you for using our Web Service, {0}.
We have recieved your message, '{1}'.", p.Name, p.Message
)
)
);
}
此函数将电子邮件发送到客户端的电子邮件地址,消息内容为。你可以更改此电子邮件地址,或更改服务器在客户端通信时如何行为。
发送响应
一旦所有工作完成,现在是时候生成要发送给客户端的响应了。同样,我们将字节发送给客户端,客户端应用程序随后可以将其转换回文本或其他它知道服务器发送给它的数据。
只有当客户端仍然连接时,我们才能向客户端发送响应。一旦连接丢失,我们就无法向客户端发送响应。由于我们有客户端连接,我们可以创建一个单独的函数来转换字节数据并将其流式传输到客户端。
// Sends the message string using the bytes provided and TCP client connected
private static void sendMessage(byte[] bytes, TcpClient client)
{
// Client must be connected to
client.GetStream() // Get the stream and write the bytes to it
.Write(bytes, 0,
bytes.Length); // Send the stream
}
到目前为止,我们已经创建了一个服务器应用程序,它可以托管、接受消息并发送响应,它还可以通过电子邮件地址通知用户。
完整的服务器程序
我们控制台项目中的完整服务器程序如下:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Net.Sockets;
using System.Net;
using System.Net.Mail;
using Newtonsoft.Json;
namespace TcpServer
{
class Program
{
static void Main(string[] args)
{
IPEndPoint ep = new IPEndPoint(IPAddress.Loopback, 1234);
TcpListener listener = new TcpListener(ep);
listener.Start();
Console.WriteLine(@"
===================================================
Started listening requests at: {0}:{1}
===================================================",
ep.Address, ep.Port);
// Run the loop continously; this is the server.
while (true)
{
const int bytesize = 1024 * 1024;
string message = null;
byte[] buffer = new byte[bytesize];
var sender = listener.AcceptTcpClient();
sender.GetStream().Read(buffer, 0, bytesize);
// Read the message, and perform different actions
message = cleanMessage(buffer);
// Save the data sent by the client;
Person person = JsonConvert.DeserializeObject<Person>(message); // Deserialize
byte[] bytes = System.Text.Encoding.Unicode.GetBytes("Thank you for your message, " + person.Name);
sender.GetStream().Write(bytes, 0, bytes.Length); // Send the response
sendEmail(person);
}
}
private static void sendEmail(Person p)
{
try
{
// Send an email to user also to notify him of the delivery.
using (SmtpClient client = new SmtpClient("<smtp-server>", 25))
{
client.EnableSsl = true;
client.Credentials = new NetworkCredential("<email-address>", "<pass>");
client.Send(
new MailMessage("<your-email>", p.Email,
"Thank you for using the Web Service",
string.Format(
@"Thank you for using our Web Service, {0}.
We have recieved your message, '{1}'.", p.Name, p.Message
)
)
);
}
Console.WriteLine("Email sent to " + p.Email); // Email sent successfully
}
catch (Exception e)
{
Console.WriteLine(e.Message);
}
}
private static string cleanMessage(byte[] bytes)
{
string message = System.Text.Encoding.Unicode.GetString(bytes);
string messageToPrint = null;
foreach (var nullChar in message)
{
if (nullChar != '\0')
{
messageToPrint += nullChar;
}
}
return messageToPrint;
}
// Sends the message string using the bytes provided.
private static void sendMessage(byte[] bytes, TcpClient client)
{
client.GetStream()
.Write(bytes, 0,
bytes.Length); // Send the stream
}
}
class Person
{
public string Name { get; set; }
public string Email { get; set; }
public string Message { get; set; }
}
}
我们的应用程序运行后看起来是这样的:
注意:大多数行是我们控制台中的花哨文本。不要认为它们是必需字段。我将在客户端应用程序中解释 Person
类。我还使用了 Newtonsoft.Json
库将数据从 JSON 转换为我们的对象,我稍后会解释这一点。请继续阅读!
编写客户端应用程序
现在我们的服务器已经启动并运行,我们现在需要创建一个充当客户端的应用程序。客户端将连接到我们的服务器,向其发送一些数据,并等待服务器向我们的客户端应用程序发送响应。在此应用程序中,我们将要求客户端提供他们的姓名、电子邮件地址和一条消息。然后,我们将这些详细信息作为 Person 对象中的成员提交。Person 对象将在服务器上使用,并触发相应的函数。
我们的 Person 对象有 3 个成员,定义如下:
class Person
{
public string Name { get; set; } // Name
public string Email { get; set; } // Email address
public string Message { get; set; } // Some message text
// Create the JSON representation of object
public string ToJSON()
{
string str = "{";
str += "'name': '" + Name;
str += "','email': '" + Email;
str += "','message': '" + Message;
str += "'}";
return str;
}
}
由于这是一个简短的对象,我们可以使用字符串连接。但对于复杂对象,你应该始终考虑使用 **JSON.NET** 库的 `JsonConvert.SerializeObject()` 函数来序列化对象,或者你应该使用 `StringBuilder` 对象来获得一个长字符串的序列化。你不应该为长时间过程串联字符串。字符串是不可变的,因此仅在内存管理方面就会消耗大量周期。连接的字符串也更有可能具有无效的模式。你应该(为你自己的应用程序)将函数的正文语句更改为以下内容,并将 `jsonNotation` 返回给你的程序。
string jsonNotation = JsonConvert.SerializeObject(person); // Person person = new Person();
我们将在服务器上使用这个对象。我之前没有解释它,现在解释是因为我将使用这些成员来生成请求。我们的应用程序会询问用户的姓名、电子邮件地址和他们拥有的消息。然后它会将该消息发送到服务器,服务器将作为响应向他们发送电子邮件。
// Get the details from the user, and store them.
Person person = new Person();
Console.Write("Enter your name: ");
person.Name = Console.ReadLine();
Console.Write("Enter your email address: ");
person.Email = Console.ReadLine();
Console.Write("Enter your message: ");
person.Message = Console.ReadLine();
// Send the message
byte[] bytes = sendMessage(System.Text.Encoding.Unicode.GetBytes(person.ToJSON()));
注意正在使用的 `ToJSON` 函数,它将对象转换为字符串类型的 JSON 符号,并且该字符串将在网络上传输到服务器。在客户端,我还创建了一个函数,用于将消息发送到服务器。在此函数中,我使用服务器运行的地址和端口连接到服务器。然后,它将以字节形式将数据发送到服务器,服务器将以同一函数的形式响应请求,这就是为什么该函数返回字节数组类型的原因。
private static byte[] sendMessage(byte[] messageBytes)
{
const int bytesize = 1024 * 1024;
try // Try connecting and send the message bytes
{
System.Net.Sockets.TcpClient client = new System.Net.Sockets.TcpClient("127.0.0.1", 1234); // Create a new connection
NetworkStream stream = client.GetStream();
stream.Write(messageBytes, 0, messageBytes.Length); // Write the bytes
Console.WriteLine("================================");
Console.WriteLine("= Connected to the server =");
Console.WriteLine("================================");
Console.WriteLine("Waiting for response...");
messageBytes = new byte[bytesize]; // Clear the message
// Receive the stream of bytes
stream.Read(messageBytes, 0, messageBytes.Length);
// Clean up
stream.Dispose();
client.Close();
}
catch (Exception e) // Catch exceptions
{
Console.WriteLine(e.Message);
}
return messageBytes; // Return response
}
在上面的代码中,我们的客户端应用程序尝试连接到服务器,并使用提供的数据将请求发送到服务器。服务器读取数据、处理并以字节形式提供响应字符串。我们返回这些字节,以便应用程序可以清理我们的数据并将其显示在屏幕上。此外,你应该在 try catch 块中使用它,因为服务器可能不可用,而不是破坏应用程序,你只需显示服务器已关闭并关闭应用程序。
运行我们的应用程序后,我们会得到这个屏幕。
现在,提交应用程序后,它将连接并向服务器发送数据。服务器为我们准备了什么,在这个程序中不可见。相反,我们需要切换到我们的服务器应用程序来查看那里发生了什么。
在运行应用程序时,我没有连接到互联网,所以收到了错误消息。连接后,我能够获得第二行成功消息。
在此行代码之后,服务器将把响应发回给客户端。客户端应用程序将能够在控制台屏幕上向用户显示以下消息:
byte[] bytes = System.Text.Encoding.Unicode.GetBytes("Thank you for your message, " + person.Name);
sender.GetStream().Write(bytes, 0, bytes.Length); // Send the response
到目前为止,一切都很棒。服务器和客户端应用程序正在工作。*手机震动* 哦,同时收到了一封电子邮件。
关注点
在本文中,我演示了如何使用 .NET 框架的原生库和程序集来创建 Web 服务或服务器/客户端应用程序。你可以创建使用集中式服务器和分布式客户端应用程序的不同类型的项目。
出于安全目的,你应该始终考虑使用 `System.Net.Security` 命名空间中的 SslStream
对象。
// Accepts Stream object as parameter in constructor using (SslStream secureStream = new SslStream(client.GetStream())) { // Work with the stream }
一旦实例化了这个流,你应该使用这个流来处理。
另外请记住,这种类型的应用程序框架是同步的。一次只能连接一个客户端。如果另一个客户端需要连接,它将等待服务器释放并准备好接受更多 TCP 客户端以进行进一步的请求和处理。你可以使用 `async/await` 操作符将当前模型转换为异步模型。我也会将源代码发布到 MSDN 库供你下载和使用。
整个应用程序(包括客户端和服务器应用程序运行)目前运行在 5 MB 以下。网络请求和其他操作可能会将内存基准提高到 10 MB。尽管如此,我们还是能够创建一个只有 10 MB 的 Web 服务。
TcpClient 是正在运行并向用户询问信息的客户端应用程序。vshost32.exe(如果你对 Visual Studio 有任何了解或经验)是附加到 **TcpServer** 应用程序的调试器。
希望我在这篇文章中有所帮助。:) 我将来会分享更多关于这种框架类型的代码。
历史
本帖的第一个版本。