使用 C# 和 .NET 测试 TCP 和 UDP Socket 服务器






4.92/5 (17投票s)
开发 TCP/IP 服务器应用程序时,很容易测试不充分。本文中,我们开发了一个测试框架,它能为你完成大部分繁重工作。
以下源代码是使用 Visual Studio .NET 构建的。预编译的二进制版本应该可以在安装了 .NET Framework 的任何系统上运行。
概述
当你开发 TCP/IP 服务器应用程序时,很容易测试不充分。发送请求到服务器,检查响应,并假设这就足够了,这很容易。即使你使用实际的生产客户端应用程序进行测试,你也可能发现无法在重负载或异常网络条件下充分测试服务器。你可能正在使用两台或更多机器,但你的开发网络可能不会引起你在实际环境中可能遇到的那种数据包碎片和延迟。通常,在开发环境中进行测试时,你的服务器只会收到完整、独立的消息,这可能会让新手开发人员认为情况总是如此。正如我们在之前的文章中指出的,服务器开发人员总是负责将 TCP 字节流分解成特定于协议的块。
幸运的是,创建可插入协议特定知识的通用 TCP/IP 测试框架非常容易。如果合适,该框架可以确保发送到服务器的字节流遭受碎片化消息、多条消息和延迟。协议特定知识可以在应用程序级别测试服务器,而测试框架在字节流处理级别测试服务器。
我们使用 C# 和 .NET 开发了这个测试框架,因为我们喜欢 .NET 框架使使用 XML 进行配置的 Socket 客户端代码开发变得容易,并且允许轻松动态加载协议特定代码的插件。首先,我们将介绍框架的设计,然后我们将介绍如何实现此设计,最后,我们将介绍插件测试工具,允许你测试我们在本系列其他文章中开发的所有服务器。
会话 - 抽象协议
客户端/服务器系统使用许多不同的协议进行通信。我们编写与协议无关的测试工具所面临的挑战是确定所有协议的共同特性,并根据这些特性编写我们的测试工具。通过抽象地思考协议的构成方式,我们可以提供任何协议可能需要的所有功能。为了在我们的设计中锚定这些抽象概念,我们需要给它们命名。
客户端/服务器系统通过互相发送字节序列进行通信。每个不同的字节序列都是一条消息。测试此类系统涉及将消息序列从一个对等方发送到另一个对等方,并确保对消息的回复和对等方收到消息后采取的操作与预期一致。在抽象中,客户端和服务器的“会话”由一次或多次消息和回复交换组成。一个测试由一次或多次会话组成。消息从测试工具发送到被测程序,回复从被测程序发送到测试工具。有些会话可能以回复而不是消息开始,有些消息不产生回复。在消息交换中,消息和回复都是可选的。
基于接口编程
我们的要求指出,我们应该协议无关,并支持插件代码,以允许测试工具的用户开发协议特定代码。我们已将所有协议抽象为“会话”的概念,现在需要将该抽象概念更接近代码。由于具体细节将是协议特定的,因此捕获抽象概念的最佳方法是定义接口,测试工具可以根据协议的抽象定义来操作协议特定的插件代码。然后,测试工具可以使用任何实现适当接口的代码,只要它们以正确的方式实现正确的接口,它就不关心实际涉及的类。
人们常常觉得很难从抽象设计转变为稍微具体的接口世界。最好的方法之一是查看抽象设计并记下每个名词。这些通常是接口的不错选择。所以
一个测试由一个或多个会话组成,每个会话又由一个或多个消息和回复的交换组成。
因此,我们有以下潜在接口
- 测试
- 会话
- 交换
- Message
- 回复
如果我们觉得有帮助,我们可以在这一点上画一两个图,也许像这样
迭代设计
既然我们知道了所涉及的接口,我们就可以开始在代码中充实它们。定义接口将是一个迭代的过程,这是因为我们对正在设计的系统还没有完全了解。期望接口在设计过程中会发生变化,期望添加新接口并删除那些最终没有增加任何价值的接口。此外,期望在接口仍在不断变化的情况下编写使用这些接口的代码,你需要编写一些代码来确定接口是否允许你完成你需要做的事情。接口在开发应用程序时可以而且确实会发生变化,但一旦发布,它们应该保持固定,以确保未来版本的兼容性。
我们接口的首次尝试可能看起来像这样
public interface IMessage
{
}
public interface IReply
{
}
public interface IMessageExchange
{
IMessage GetMessage();
IReply GetReply();
}
public interface IConversation
{
IMessageExchange GetMessageExchange();
}
public interface ISocketServerTest
{
IConversation GetConversation();
}
我们在上面显示的接口中做了一些假设。我们假设你可以多次调用 GetConversation()
,并且最终它将返回 null
以指示不应再进行任何会话。同样,我们假设我们可以多次调用 GetMessageExchange()
,直到检索到所有会话的消息交换。每个 MessageExchange
对象都包含一个可选的消息和一个可选的回复。如果消息存在,则 GetMessage()
返回它,如果不存在,则 GetMessage()
返回 null
。回复也是如此。
处理 TCP 和 UDP 服务器
显然,这些接口远未完善。为了能够发送消息,我们需要以字节序列的形式访问它。处理回复则更复杂。所有优秀的 TCP/IP 程序员都知道,TCP 提供一个字节流接口。应用程序需要从该字节流中获取数据并将其分解为对应用程序有意义的块。这是协议特定的,因此必须在我们的接口中表示为一个抽象概念。能够使用一个测试工具同时支持 TCP 和 UDP 也很好。UDP 以独立的数据报而非流的形式工作。这意味着 TCP 消息交换由消息和要处理的响应流组成,而 UDP 消息交换由消息和响应数据报组成。修改我们的接口以适应这些变化,我们将得到类似以下内容:
public interface IMessage
{
byte[] GetAsBytes();
}
public interface IMessageExchange
{
IMessage GetMessage();
}
public interface IResponseStreamHandler
{
void HandleResponse(IResponseStream responseStream);
}
public interface ITcpMessageExchange : IMessageExchange
{
IResponseStreamHandler GetResponseStreamHandler();
}
public interface IResponseDatagramHandler
{
void HandleResponse(byte[] responseDatagram);
}
public interface IUdpMessageExchange : IMessageExchange
{
IResponseDatagramHandler GetResponseDatagramHandler();
}
请注意,我们如何围绕 TCP 响应流创建了一个抽象。IResponseStream
是测试工具可以实现的一个接口,它可以提供对 .NET NetworkStream
对象的读取端的受控访问。我们当然可以直接将 NetworkStream
对象传递给插件,但那样的话,那些应该只处理回复数据流的代码就可以写入 NetworkStream
,或者关闭它等等。在这种情况下,最好提供一个自定义接口,只允许用户我们希望他们拥有的访问权限。这往往会产生更健壮的软件。
IResponseStream
接口可能看起来像这样
public interface IResponseStream
{
int DefaultTimeoutMillis
{
get;
set;
}
string ReadLine();
string ReadLine(int timeoutMillis);
int Read(byte[] buffer, int offset, int length);
int Read(byte[] buffer, int offset, int length, int timeoutMillis);
byte ReadByte();
byte ReadByte(int timeoutMillis);
void Close();
}
为了方便开发协议特定插件的开发人员,我们提供了多种访问响应流中数据的方法。我们还在所有读取操作上提供了超时功能,这确保了如果未发送数据,测试不会挂起。协议特定代码可以为每个读取操作指定超时,并为所有未明确提供超时的读取操作设置默认超时。我们允许用户关闭响应流,但请注意,这只是关闭了 TCP 连接的接收端。
配置测试
我们将需要能够将配置数据传递给协议特定的插件。此配置可以是 XML 文档的形式,为了使插件与我们获取 XML 的方式隔离,我们只会将要处理的 XML 的根节点传递给它。此数据完全特定于插件,测试工具根本不需要理解它。由于协议插件可以是 TCP 或 UDP(或两者兼有),我们需要配置插件以使用正确的协议。如果插件不支持该协议,则它可能会抛出异常。
处理字节流
我们的测试工具将尝试以非独立字节流的形式发送消息。这测试服务器是否正确地将传入数据处理为字节流,而不是假设它将接收到独立的消息。消息可能有三种到达方式:
- 一条完整的消息。碰巧,消息完整到达,并且在我们调用读取时没有其他可用消息。
- 消息片段。只有前 x 个字节到达,其余的将在稍后到达。
- 多条消息。读取结果包含多条消息,或者几条消息和一个消息片段。
为了帮助我们调试服务器行为不符合预期的情况,我们需要能够控制消息从测试工具发送的方式。由于我们可能希望在协议特定级别执行此操作,我们将允许协议特定插件指示它是否支持分段消息和多条消息。如果插件不支持这些选项,则测试工具将不使用它们。当将多条消息作为单个块一起传输时,对于协议而言,会话中的某些消息可能可以一起传输,而有些则不能。想象一个协议,要求用户登录,然后一旦登录,用户可以上传文件。登录消息永远不会构成多条消息块的一部分,因为在处理回复之前永远不会发送更多数据。然而,文件上传可能包含多条消息,这些消息很容易构成多条消息块的一部分。为了允许插件控制哪些消息可以和不能构成多条消息块的一部分,我们可以稍微调整会话接口。它不是简单地返回消息直到会话中的所有消息都已处理,而是可以返回一个可以构成多条消息块的 messages 数组。因此,会话现在由一个或多个消息交换块组成。如果测试工具以多条消息模式运行,则块中的消息可以作为一次传输发送。和以前一样,当会话中没有更多块时,GetMessages()
返回 null
。
请注意,对于 UDP 测试,发送分片消息的概念不相关,发送多条消息测试的是服务器的不同方面。在 TCP 中,将多条消息一起发送测试服务器如何确定消息边界。在 UDP 中,消息边界是固定的,发送多条消息仅测试服务器如何并发处理多条消息。
允许多线程
我们的测试工具将通过多线程操作来模拟多个并发连接。为每个线程单独加载和配置插件似乎没有多大意义,但我们现有的设计不提供多次请求“第一个”会话的功能。通过添加 ConversationCreator
的概念,我们可以在每个线程中获取一次 ConversationCreator
接口,然后从中请求会话,直到它返回 null
。
一些额外的钩子
某些协议可能需要消息传输时的精确时间信息。为了应对这种可能性,我们可以在 IMessage
接口中添加一个 MessageTransmitted()
方法。此方法将在消息传输后尽快调用,如果此方法必须在消息传输后精确调用,则插件应声明它不支持多条消息,或将消息作为单个消息块呈现给测试工具。同样,了解 Conversation
何时完成,或由 ConversationCreation
提供的所有会话何时完成可能也很有用。
最终的接口看起来像这样,请注意,我们还添加了一个方法,使测试工具能够显示有关其正在使用的插件的一些信息。
public interface IMessage
{
byte[] GetAsBytes();
void MessageTransmitted();
}
public interface IMessageExchange
{
IMessage GetMessage();
}
public interface IResponseStreamHandler
{
void HandleResponse(IResponseStream responseStream);
}
public interface ITcpMessageExchange : IMessageExchange
{
IResponseStreamHandler GetResponseStreamHandler();
}
public interface IResponseDatagramHandler
{
void HandleResponse(byte[] responseDatagram);
}
public interface IUdpMessageExchange : IMessageExchange
{
IResponseDatagramHandler GetResponseDatagramHandler();
}
public interface IConversation
{
IMessageExchange [] GetMessages();
void ConversationComplete();
}
public interface IConversationCreator
{
IConversation GetConversation();
void Completed();
}
public interface ISocketServerTest
{
void Initialise(XmlNode parameters, Protocol protocol);
bool AllowFragments();
bool AllowMultipleMessages();
void DumpInformation(TextWriter outputStream);
IConversationCreator GetConversationCreator();
}
测试工具使用这些接口的方式如下:
- 加载测试配置。配置详细说明要使用的插件以及其他可配置的测试参数。
- 加载指定的插件。
- 找到入口点对象。此对象必须实现
ISocketServerTest
接口。 - 调用
ISocketServerTest
接口上的Initialise()
方法。 - 根据调用
AllowFragments()
和AllowMultiplMessages()
的结果调整测试配置。 - 创建工作线程并将
ISocketServerTest
接口传递给每个线程。 - 等待所有工作线程完成。
在每个工作线程中
- 获取
ConversationCreator
。 - 当
GetConversation()
不返回null
时。 - 使用会话创建正确类型的连接(TCP 或 UDP),并进行通信。
- 调用
Completed()
通过 TCP 进行会话包括:
- 当
GetMessages()
不返回null
时。 - 如果配置为发送多条消息,则随机决定发送多少条消息,否则发送 1 条。
- 计算发送所有消息数据所需的缓冲区大小。
- 如果配置为发送分片消息,则随机决定实际发送多少消息数据。
- 创建所需大小的缓冲区。
- 对于要发送的每条消息,将其尽可能多的字节复制到缓冲区中。
- 发送整个缓冲区。
- 对于发送的每条消息,调用
MessageTransmitted()
。 - 对于发送的每条消息,调用
GetResponseStreamHandler()
,如果非null
,则调用HandleResponse()
- 如果配置了延迟,则添加延迟,然后处理更多消息
通过 UDP 进行会话包括:
- 当
GetMessages()
不返回null
时。 - 如果配置为发送多条消息,则随机决定发送多少条消息,否则发送
1
条。 - 对于要发送的每条消息,发送它并调用
MessageTransmitted()
。 - 对于发送的每条消息,调用
GetResponseStreamHandler()
,如果非null
,则调用HandleResponse()
- 如果配置了延迟,则添加延迟,然后处理更多消息
实现
C# 中测试工具的实现相当简单。.NET 框架库在 System.Net.Sockets
中为我们提供了易于使用的网络类。我们使用 TcpClient
和 UdpClient
作为我们客户端连接类的基础。多线程同样容易。我们使用 System.Threading.Thread
,只需提供我们希望线程执行的函数即可。主线程使用 ManualResetEvent
等待工作线程完成。线程使用 Interlocked
类递减计数器。使用 Interlocked
类意味着每个线程都保证以单个原子操作递减计数器。如果没有它,两个线程同时访问计数器可能会导致意外行为。将计数器移至 0
的线程设置事件,主线程关闭。使用 System.Xml
中的类加载 XML 配置文件并遍历树节点,配置变得轻而易举。
动态加载插件
我们工具的一个主要要求是,协议特定的工作可以通过插件完成,这些插件可以按每次测试运行加载和配置。我们在接口设计中非常小心,尽可能地简化插件的创建。每个插件都开发为独立的 DLL 程序集。插件的入口点是实现 ISocketServerTest
的对象。插件可以包含多个入口点,特定测试运行使用的入口点取决于配置文件。使用 System.Reflection
中的类,加载和配置插件变得容易。
Assembly assembly = Assembly.LoadFrom(Y); // path to assembly dll
Object obj = assembly.CreateInstance(X); // name of object that implements
// ISocketServerTest
if (obj == null)
{
throw new Exception("Entry point not found");
}
ISocketServerTest serverTest = (ISocketServerTest)obj;
serverTest.Initialise(config.TestParameters, config.Protocol);
return serverTest;
}
我们惊喜地发现,使用 C# 和 .NET 将我们的设计转化为代码是多么容易,我们唯一的抱怨是缺乏真正的多重继承。如果能够让接口或从接口派生的类为某些方法(例如 MessageTransmitted()
和 ConversationComplete()
方法)提供默认实现,并有效地为接口用户提供选项,如果他们需要除默认行为之外的其他行为,则自行实现,那将会很好。
配置测试工具
该工具使用 XML 文件进行配置。要使用的配置文件的名称通过命令行传递给该工具。配置文件可能看起来像这样:
<xml version="1.0" encoding="utf-8" ?>
<SocketServerTest>
<Threads>
<Number>350</Number>
<Batch>10</Batch>
<DelayMillis>1000</DelayMillis>
</Threads>
<Protocol>TCP</Protocol>
<Messages>
<Fragments>true</Fragments>
<Multiple>true</Multiple>
<DelayMillis>1000</DelayMillis>
<Messages>
<RandomSeed>112</RandomSeed>
<Test>
<Host>localhost</Host>
<Port>5001</Port>
<Name>LargePacketEchoServerTest.dll</Name>
<EntryPoint>ServerTest</EntryPoint>
<Parameters>
<Conversations>10</Conversations>
<Blocks>10</Blocks>
<Messages>10</Messages>
<MessageSize>8000</MessageSize>
<ShowDebug>false</ShowDebug>
</Parameters>
</Test>
</SocketServerTest>
如您所见,我们可以配置要启动的工作线程数,并根据需要分批启动这些线程,每批之间有延迟。我们可以配置协议(TCP 或 UDP),以及测试工具是否应发送消息片段和多条消息,以及是否应在消息块之间延迟。我们还可以指定随机数生成器的种子,这使我们能够精确地重放测试运行,即使它们包含“随机”元素。这在追踪只在某些消息碎片化情况下出现的错误时特别有用。
<Test>
中的元素指定测试本身的详细信息。要连接的主机和端口定义将被测试的服务器。<Name>
节点详细说明要使用的插件协议测试程序集,<EntryPoint>
节点详细说明测试程序集中作为测试入口点的类的名称。<Parameters>
节点的内容被测试工具视为不透明数据,并在初始化期间简单地传递给插件。
代码结构
我们决定将所有接口放在自己的程序集中。这允许插件和测试工具引用它们,而无需插件引用工具本身。测试工具的代码位于 JetByte.SocketServerTest
命名空间中,接口位于 JetByte.SocketServerTest.Interfaces
命名空间中。
编写插件
既然我们有了一个允许插入协议特定测试的测试工具,我们需要编写一些插件。我们在早期的文章中开发的基于数据包的回显服务器为测试工具及其接口提供了很好的测试。数据包回显服务器使用一个非常简单的协议。连接后,它发送一个欢迎消息。从那时起,它期望接收一个包含数据包长度(包括一个字节的头)的单字节头。服务器读取头指定的字节数,然后将数据包回显给客户端。我们可以使用测试工具通过发送分段消息和多个消息块来确保服务器正确遵守协议,我们可以通过比较发送的每个字节数据与接收到的每个字节数据来检查服务器是否正确运行。
编写协议测试插件时,第一件事是创建一个新的 DLL 程序集。选择“文件”->“新建”->“C# 类库”(当然,您可以使用任何 .NET 语言来创建插件)。我们需要添加对 JetByte.SocketServerTest.Interfaces
程序集的引用,以便我们可以引用其中定义的接口。
协议特定插件的入口点是任何从 ISocketServerTest
接口派生的类。一个插件可以有多个入口点,特定测试使用的入口点由配置文件决定。我们可以将入口点放在命名空间中,但如果不这样做,配置会更容易,所以我们只在全局范围定义一个类。我们的入口点可能看起来像这样:
public class ServerTest : ISocketServerTest
{
public ServerTest()
{
}
// Implement ISocketServerTest
public void Initialise(XmlNode parameters, Protocol protocol)
{
if (protocol != Protocol.TCP)
{
throw new Exception("PacketEchoServerTest only supports TCP");
}
config = new PacketEchoServerTest.Configuration(parameters);
}
public bool AllowFragments()
{
return true;
}
public bool AllowMultipleMessages()
{
return config.MultipleMessages;
}
public void DumpInformation(TextWriter outputStream)
{
outputStream.WriteLine("Packet echo server test");
outputStream.WriteLine("Fragmented Messages: {0}", AllowFragments());
outputStream.WriteLine("Multiple Messages: {0}", AllowMultipleMessages());
outputStream.WriteLine("Conversations {0}", config.Conversations);
outputStream.WriteLine("Blocks per conversation {0}", config.Blocks);
outputStream.WriteLine("Messages per block {0}", config.Messages);
outputStream.WriteLine("Message Size {0} bytes", config.MessageSize);
}
public IConversationCreator GetConversationCreator()
{
return new ConversationCreator(config, Conversation.Type.Simple);
}
private PacketEchoServerTest.Configuration config;
}
其余相关类将在 PacketEchoServerTest
命名空间中定义。请注意,我们有一个 Configuration
类,它为我们处理 XML 文档,并提供验证和基于属性的配置访问方法。Configuration
类使用 JetByte.SocketServerTest.Interfaces
命名空间中定义的一个基类,该基类提供从 XML 节点访问数据的辅助函数。请注意,我们始终允许分段消息,但可以配置是否允许多条消息块。这样做的原因是我们可以使用此测试工具来测试一个确保数据包按接收顺序回显的数据包回显服务器,以及一个不确保的服务器。
我们的 ConversationCreator
相当简单。请记住,这只是一个额外的间接层,以便每个测试线程可以并发创建会话。ConversationCreator
是保存需要在组成测试的 Conversations
之间传递的任何状态的地方。
internal class ConversationCreator : IConversationCreator
{
public ConversationCreator(Configuration config)
{
this.config = config;
}
// Implement IConversationCreator
public IConversation GetConversation()
{
if (numConversations++ < config.Conversations)
{
return new Conversation(config);
}
return null;
}
public void Completed()
{
// Nothing to do here
}
private int numConversations = 0;
private Configuration config;
}
我们的 Conversation
相当直接。当调用 GetMessages()
时,它会生成适当数量的 MessageExchange
对象并将其作为数组返回。请注意,我们如何处理这个特定协议的特殊情况,即 Conversation
以一种消息开始,然后以另一种消息继续。ServerSignOn
消息是一个不发送 Message
的 MessageExchange
类,它只是等待“回复”。这让我们能够处理服务器首先向我们发送数据的事实。当我们连接并开始会话时,我们做的第一件事就是等待服务器的登录“回复”。一旦收到回复,我们就会开始发送和接收回显数据包。数据包本身的大小各不相同。
internal class Conversation : IConversation
{
public Conversation(Configuration config)
{
this.config = config;
}
// Implement IConversation
public IMessageExchange [] GetMessages()
{
IMessageExchange [] messages = null;
if (blocksSent == 0)
{
messages = new IMessageExchange[1];
messages[0] = new ServerSignOn();
}
else if (blocksSent < config.Blocks + 1)
{
messages = new IMessageExchange[config.Messages];
for (int i = 0; i < config.Messages; ++i)
{
int messageSize = config.MessageSize + (i % 55);
messages[i] = new MessageExchange(messageSize);
}
}
blocksSent++;
return messages;
}
public void ConversationComplete()
{
// Nothing to do here
}
private int blocksSent = 0;
Configuration config;
}
ServerSignOn
类是 MessageExchange
对象结构化的典型示例。它实现了 ITcpMessageExchange
接口和 IResponseStreamHandler
接口。当调用 ITcpMessageExchange
接口的 GetResponseStreamHandler()
方法时,它只是返回自身。正如我们将在 echo MessageExchange
类中看到的那样,MessageExchange
对象同时实现 Message
和回复接口是很方便的。当然,接口定义并没有强制要求这样做,它只是恰好是一种方便的实现策略,将响应处理程序和消息生成器放在同一个类中。正如我们将在 echo MessageEchange
对象中看到的那样,这很方便,因为与消息关联的响应处理程序可以轻松访问原始消息数据。ServerSignOn
对象只是从响应流中读取一行并丢弃它。我们可以在这里进行一些检查,以验证服务器响应是否符合预期,但我们没有这样做。
internal class ServerSignOn : ITcpMessageExchange, IResponseStreamHandler
{
// Implement IMessageExchange
public IMessage GetMessage()
{
return null;
}
public IResponseStreamHandler GetResponseStreamHandler()
{
return this;
}
// Implement IResponseStreamHandler
public void HandleResponse(IResponseStream responseStream)
{
responseStream.ReadLine();
}
}
回显 MessageExchange
类稍微复杂一些。首先,我们创建一个指定大小的消息,该消息由单字节头和数据字节组成。当调用 IMessageExchange
的 GetMessage()
方法时,我们只是返回自身,因为我们就是 Message
。然后,当通过调用 GetAsBytes()
请求时,我们可以返回我们的消息字节。为了处理服务器的响应,将在我们的 ITcpMessageExchange
接口上调用 GetResponseStreamHandler()
。同样,我们只是返回自身,因为我们也处理响应。当调用 HandleResponse()
时,我们从响应流中读取一个字节,检查头是否指定了正确的字节数,然后尝试读取消息正文。我们循环直到读取了正确的字节数。如果任何读取超时并返回 0 字节,则 IResponseStream
接口的 Read()
方法将为我们抛出异常。最后,我们将回复的内容与原始消息的内容进行比较。
internal class MessageExchange : ITcpMessageExchange, IMessage,
IResponseStreamHandler
{
public MessageExchange(int size)
{
if (size > 256)
{
throw new Exception("Size must be <= 256");
}
this.size = size;
message = new byte[size];
message[0] = (byte)size;
for (int i = 1; i < size; ++i)
{
message[i] = (byte)(i + 1);
}
}
// Implement IMessageExchange
public IMessage GetMessage()
{
return this;
}
// Implement ITcpMessageExchange
public IResponseStreamHandler GetResponseStreamHandler()
{
return this;
}
// Implement IMessage
public byte[] GetAsBytes()
{
return message;
}
public void MessageTransmitted()
{
// Nothing to do
}
// Implement IResponseStreamHandler
public void HandleResponse(IResponseStream responseStream)
{
// Now, read in size bytes from the stream and compare them to the message
byte[] response = new byte[size];
response[0] = responseStream.ReadByte();
if ((int)response[0] != size)
{
throw new Exception("packetSize != size");
}
int bytesRead = 1;
while (bytesRead != size)
{
bytesRead += responseStream.Read(response, bytesRead, size - bytesRead);
}
for (int i = 0; ok && i < size; ++i)
{
if (message[i] != response[i])
{
throw new Exception("response != message");
}
}
}
private byte[] message;
private int size;
}
我们构建数据包回显服务器测试插件的方式与接口的结构关系不大。我们已经成功地将消息交换所需的各个对象浓缩成一个单一的对象,该对象处理消息、消息交换和响应接口。对于更复杂的协议,消息交换对象可能会链接回会话对象,从而允许一个消息交换的结果影响未来的消息交换——例如在用于测试 POP3 服务器的消息交换之间传递可用邮件消息的数量。
测试
我们刚才描述的插件可用于测试此处提供的数据包回显服务器。该服务器监听两个端口,并且在每个端口上具有略微不同的语义。在端口 5001 上,服务器使用写入序列号来确保所有发出的写入都以正确的序列发生(有关更多信息,请参阅关于读写排序的文章)。可以使用指定在处理回复之前应发送多条消息的配置文件来测试此服务器。由于正在使用写入排序并且每个连接只发布一个 Read()
,我们可以保证服务器将按它们从线路接收的顺序回显数据包。使用 PacketEchoServerTest1.xml 文件来测试此服务器。
监听端口 5002 的服务器不使用写入序列,使用 PacketEchoServerTest1.xml 将在开启多消息的情况下测试此服务器,并且某些测试应该会失败。如果它们没有失败,您很幸运,尝试在多处理器盒上运行服务器... 由于在单个连接上以块的形式接收多条消息,因此该连接上可能存在多条未完成的写入。由于未使用写入序列,因此可能出现读写序列文章中概述的问题,并且测试工具会将此识别为数据包乱序回显。使用 PacketEchoServerTest2.xml 来测试此行为。
对端口 5002 上的服务器运行测试,并且关闭多消息块功能,将成功。这是因为每个连接只能有一个未完成的写入请求,因此写入不可能乱序。使用 PacketEchoServerTest3.xml 来测试此行为。
处理乱序写入的另一种策略是让客户端负责重新排序数据包。我们可以通过在插件中添加第二个协议测试来测试这种策略,并演示在插件中使用多个入口点。这涉及到创建另一个实现 ISocketServerTest
的类,并使用一个 MessageExchange
对象,该对象可以确定响应是针对哪条消息的。我们通过将头部有效地扩展到两个字节来完成这种匹配,尽管服务器对此一无所知。第二个字节是消息号,我们用它来匹配响应。当响应到达时,我们读取长度和消息号以及数据包,然后通过消息号查找原始消息,然后匹配数据。这涉及到 MessageExchange
对象彼此了解,并展示了如何在整个消息块或对话中的消息之间传递状态——例如在用于测试 POP3 服务器的消息交换之间传递可用邮件消息的数量。使用 PacketEchoServerTest4.xml 来测试此行为。
请注意,运行 PacketEchoServerTest1.xml 和 PacketEchoServerTest4.xml 之间的时间差异(或缺乏差异)应持保留态度。要真实地测试将写入排序添加到服务器对性能的影响,您需要测试客户端,它们只读取并丢弃响应,以便在两个服务器测试之间客户端负载具有可比性,我们将此留作读者的练习。
关于源码的注意事项
源代码档案包含一个 Visual Studio .NET 解决方案,该解决方案构建了测试接口程序集、测试工具和协议插件,以实现对迄今为止开发的所有服务器的测试。SocketServerTest
项目引用了插件,但这些引用并非构建测试工具所必需的,它们只是为了在调试时运行工具的方便。通过包含对插件 DLL 的引用,测试工具的构建还会构建插件并将插件复制到测试工具的构建目录中,这使得在调试器中运行测试工具更加容易。该档案还包含所有插件的配置文件。
修订历史
- 2002 年 7 月 15 日 - 初次修订
- 2002 年 7 月 16 日 - 添加了
EchoServer
和EchoServerEx
测试。EchoServerEx
还可以测试 UDP 服务器。