问题中提供的代码存在许多问题,我决定在我的回答的几个部分中解决这些问题。
值得注意的是,代码来自大约六年前发布的blog post(一种简短的教程),并且已经展示了此答案中解决的一些问题。
1.为什么服务器程序立即退出?
服务器程序立即退出的原因比较简单:当Main方法退出时,程序的整个进程都被关闭了,包括任何属于这个进程的线程(比如启动的线程)在 Server 类的构造函数中)。要解决这个问题,需要防止 Main 方法退出。
一种常见的方法是在 Main 方法的最后一行添加Console.ReadKey() 或Console.ReadLine(),这不会让服务器程序退出,直到用户提供一些键盘输入。
然而,对于这里给出的特定代码,这种方法不是那么容易解决的,因为客户端连接处理程序方法 (HandleClientComm) 也会读取控制台键盘输入(这本身可能会成为一个问题,请参阅第 5 节下面)。
由于我不想从详细说明键盘输入开始我的回答,我建议一个不同的并且公认更原始的解决方案:在 Main 方法的末尾添加一个无限循环(这也已包含在已编辑的问题中):
static void Main(string[] args)
{
Server srv = new Server();
for (;;) {}
}
由于服务器的教程代码本质上是一个控制台应用程序,它仍然可以通过按CTRL+C来终止。
2.服务器似乎仍然没有做任何事情。为什么?
.NET 框架的类和方法中发生的大多数(如果不是全部)错误或问题是通过 .NET 的异常机制报告的。
这里的教程代码在处理客户端连接的服务器方法中犯了一个严重错误:
try
{
//blocks until a client sends a message
bytesRead = clientStream.Read(message, 0, 4096);
}
catch
{
//a socket error has occured
break;
}
try-catch 块在做什么?好吧,它唯一能做的就是捕捉任何异常——这可以回答问题为什么?——
然后默默地毫不客气地丢弃这些有用的信息。 呃……!
当然,在客户端连接处理程序线程中捕获异常是有意义的。否则,未捕获的异常不仅会导致失败的客户端连接处理程序线程关闭,还会导致整个服务器控制台应用程序关闭。但需要以有意义的方式处理这些异常,以免丢失有助于解决问题的信息。
为了在我们的简单教程代码中提供有价值的异常信息,catch 块会将异常信息输出到控制台(并且还负责输出任何可能的“内部异常”)。
此外,using 语句被用来确保 NetworkStream 和 TcpClient 对象都被正确关闭/即使在异常导致客户端连接线程退出的情况下也是如此。
private void HandleClientComm(object client)
{
using ( TcpClient tcpClient = (TcpClient) client )
{
EndPoint remoteEndPoint = tcpClient.Client.RemoteEndPoint;
try
{
using (NetworkStream clientStream = tcpClient.GetStream() )
{
byte[] message = new byte[4096];
for (;;)
{
//blocks until a client sends a message
int bytesRead = clientStream.Read(message, 0, 4096);
if (bytesRead == 0)
{
//the client has disconnected from the server
Console.WriteLine("Client at IP address {0} closed connection.", remoteEndPoint);
break;
}
//message has successfully been received
Console.WriteLine(Encoding.ASCII.GetString(message, 0, bytesRead));
// Console.ReadLine() has been removed.
// See last section of the answer about why
// Console.ReadLine() was of little use here...
}
}
}
catch (Exception ex)
{
// Output exception information
string formatString = "Client IP address {2}, {0}: {1}";
do
{
Console.WriteLine(formatString, ex.GetType(), ex.Message, remoteEndPoint);
ex = ex.InnerException;
formatString = "\tInner {0}: {1}";
}
while (ex != null);
}
}
}
(您可能会注意到代码在 remoteEndPoint 中记住了客户端的(公共)IP 地址。原因是 tcpClient 属性中的 Socket 对象。 Client 将在 NetworkStream 被关闭时被释放 - 当相应 using 语句的范围被离开时会发生这种情况,这反过来又会使它变得不可能之后在 catch 块中访问 tcpClient.Client.RemoteEndPoint。)
通过让服务端在控制台输出异常信息,当客户端尝试发送消息时,我们将能够从服务端看到以下信息:
无法从传输连接读取数据:现有连接被远程主机强行关闭。
这是一个非常强烈的迹象,表明客户端出现问题或某些网络设备出现了一些奇怪的问题。碰巧的是,O/P 使用 IP 地址“127.0.0.1”在同一台计算机上运行客户端和服务器软件,这使得担心网络设备故障成为一个没有实际意义的论点,而是指向客户端软件的问题。
3.客户怎么了?
运行客户端不会抛出任何异常。查看源代码,可能会错误地认为代码似乎大部分都没问题:与服务器建立连接,字节缓冲区填充消息并写入 NetworkStream,然后 em>NetworkStream 被刷新。但是,NetworkStream 并未关闭——这肯定与问题有关。
但是即使 NetworkStream 没有被明确关闭,刷新流应该已经将消息发送到服务器,或者......?
上一段包含两个错误的假设。第一个错误假设与 NetworkStream 未正确关闭的问题有关。客户端代码中发生的情况是,在将消息写入 NetworkStream 之后,客户端程序直接退出。
当客户端程序退出时,会向服务器发送一个“连接终止”信号(简而言之)。如果在那个时间点,消息仍然停留在发送方的某个 TCP/IP 相关缓冲区中,则该缓冲区将被简单地丢弃并且不再发送消息。但即使消息已被服务器端 TCP/IP 堆栈接收并保存在与 TCP/IP 相关的接收缓冲区中,紧随其后的“连接终止”信号或多或少仍会使此信号无效在 TCP/IP 堆栈确认收到此消息之前接收缓冲区,因此 NetworkStream.Read() 因上述错误而失败。
另一个错误的假设(并且很容易被没有在 .NET 中进行常规网络相关编程的人忽略)是代码如何尝试利用 NetworkStream.Flush() 来尝试强制传输消息。
我们来看看MSDN documentation of NetworkStream.Flush()的“备注”部分:
Flush 方法实现了 Stream.Flush 方法;但是,由于 NetworkStream 没有缓冲,对网络流没有影响。
是的,您没看错:NetworkStream.Flush() 确实... 没什么!
(...显然,因为 NetworkStream 不缓冲任何数据)
所以,为了让客户端程序正常工作,需要做的就是正确关闭客户端连接(这也确保了在连接断开之前服务器正在发送和接收消息)。正如上面的服务器代码中已经展示的那样,我们将使用 using 语句,该语句将负责正确关闭和处置 NetworkStream 和 任何情况下的 TcpClient 对象。此外,正在删除 NetworkStream.Flush() 的误导性和无用调用:
static void Main(string[] args)
{
IPEndPoint serverEndPoint = new IPEndPoint(IPAddress.Parse("127.0.0.1"), 3000);
using ( TcpClient client = new TcpClient() )
{
client.Connect(serverEndPoint);
using ( NetworkStream clientStream = client.GetStream() )
{
byte[] buffer = Encoding.ASCII.GetBytes("Hello Server!");
clientStream.Write(buffer, 0, buffer.Length);
}
}
}
4.关于从/向 NetworkStream 读取和写入消息的建议
始终应牢记的是,NetworkStream.Read(...) 不保证一次读取完整的消息。
根据消息的大小,NetworkStream.Read(...) 可能只读取消息的片段。其原因与客户端软件如何发送数据以及 TCP/IP 如何管理通过网络的数据传输有关。
(当然,对于像 “Hello world!”这样的短消息,您不太可能不会遇到这种情况。但是,根据您的服务器和客户端操作系统以及使用的 NIC + 驱动程序,您如果消息变得大于 500 字节,则可能会开始观察消息被碎片化。)
因此,为了使服务器更健壮且不易出错,应更改服务器代码以适应零碎的消息。
如果您坚持使用 ASCII 编码,您可能会选择 Alexander Brevig 的回答中所示的方法——只需将接收到的带有 ASCII 字节的消息片段写入 StringBuilder。但是,这种方法只能可靠地工作,因为任何 ASCII 字符都由单个字节表示。
一旦您使用另一种可以将单个字符编码为多个字节的编码(例如,任何 UTF 编码),这种方法将不再可靠地工作。字节数据的可能碎片可能会拆分多字节字符的字节序列,使得一个 NetworkStream.Read 调用仅读取此类字符的第一个字节,并且仅读取后续调用NetworkStream.Read 将获取此多字节字符的剩余字节。如果将单独解码每个消息片段,这将扰乱字符解码。因此,在进行任何文本解码之前,将 complete 消息存储在单字节缓冲区(数组)中通常更安全。
读取不同长度的消息仍然存在一个问题。服务器如何知道完整的消息何时发送?现在,在此处给出的教程中,客户端只发送一条消息然后断开连接。因此,服务器仅通过客户端断开连接就知道已收到完整的消息。
但是如果您的客户端想要发送多条消息,或者如果服务器需要向客户端发送响应怎么办?在这两种情况下,客户端都不能在发送第一条消息后简单地断开连接。那么,服务器如何知道消息何时发送完毕?
一个非常简单可靠的方法是让客户端在消息前添加一个短整数值(2字节)或整数值(4字节)来指定消息长度(以字节为单位)。因此,在接收到这 2 个字节(或 4 个字节)后,服务器将知道还需要读取多少字节才能获得完整的消息。
现在,您不需要自己实现这样的机制。好消息是 .NET 已经为类提供了为您完成所有这些工作的方法:BinaryWriter 和 BinaryReader,这使得发送由不同数据类型组成的消息几乎与众所周知的漫步一样简单和愉快公园。
客户端使用BinaryWriter.Write(string):
static void Main(string[] args)
{
IPEndPoint serverEndPoint = new IPEndPoint(IPAddress.Parse("127.0.0.1"), 3000);
using ( TcpClient client = new TcpClient() )
{
client.Connect(serverEndPoint);
using ( BinaryWriter writer = new BinaryWriter(client.GetStream(), Encoding.ASCII) )
{
writer.Write("Hello Server!");
}
}
}
服务器的客户端连接处理程序使用BinaryReader.ReadString():
private void HandleClientComm(object client)
{
using ( TcpClient tcpClient = (TcpClient) client )
{
EndPoint remoteEndPoint = tcpClient.Client.RemoteEndPoint;
try
{
using ( BinaryReader reader = new BinaryReader(tcpClient.GetStream(), Encoding.ASCII) )
{
for (;;)
{
string message = reader.ReadString();
Console.WriteLine(message);
}
}
}
catch (EndOfStreamException ex)
{
Console.WriteLine("Client at IP address {0} closed the connection.", remoteEndPoint);
}
catch (Exception ex)
{
string formatString = "Client IP address {2}, {0}: {1}";
do
{
Console.WriteLine(formatString, ex.GetType(), ex.Message, remoteEndPoint);
ex = ex.InnerException;
formatString = "\tInner {0}: {1}";
}
while (ex != null);
}
}
}
您可能会注意到 EndOfStreamException 异常的特殊处理。 BinaryReader 使用此异常来指示已到达流的末尾;即,连接已被客户端关闭。
不是像任何其他异常一样将其打印出来(因此可能会被误解为错误 - 它确实可能在不同的应用场景中),而是在控制台上打印出一条特定消息以使连接被关闭的事实非常清除。
(旁注:如果您打算让您的客户端软件连接并与第 3 方服务器软件交换数据,BinaryWriter.Write(string) 可能是也可能不是一个可行的选项,因为它使用ULEB128 对字符串的字节长度进行编码。
在 BinaryWriter.Write(string) 不可行的情况下,您很可能仍然可以充分利用 BinaryWriter.Write(short)/BinaryWriter 的组合。 Write(int) 与 BinaryWriter.Write(byte[]) 一起使用 messageLength 值添加消息字节数据。)
5.控制台键盘输入
问题代码中的客户端连接处理程序方法 HandleClientComm 在收到来自客户端的消息后等待键盘输入,然后它将继续等待并读取下一条消息。
这是毫无意义的,因为 HandleClientComm 可以继续等待下一条消息而无需显式键盘输入。
但也许您的意图是使用控制台键盘输入作为将发送回客户端的响应——我不知道。只要你只是玩弄一个简单的客户端,我想这种方法就可以了。
但是,一旦您有两个或多个客户端同时访问服务器,即使是一些简单的测试/玩具场景也可能需要您采取措施确保多个客户端连接处理程序线程不会交叉其控制台输出和访问以易于理解的方式管理这些线程的控制台键盘输入。也就是说,这真的取决于你想详细做什么——这很可能也只是一个非问题......