响应迅速、可伸缩且可重用的 SOA 服务流式服务实验
一项实验性的流式实现,旨在降低响应时间并提高高度可组合/可伸缩的 Web 服务的吞吐量。
引言
本文介绍了一种实验性实现,用于构建响应迅速的 Web 服务,这些服务可以相互调用以实现服务重用。该实现基于 SOA Magazine 上发表的“流式服务”思想。该实现展示了如何通过使用面向流的通信替代面向消息的通信,来使服务免受响应时间增加和可伸缩性问题的困扰,从而利用分布式架构中客户端和服务器之间的并行性。此实现绝不是一种参考或实现此类服务的最佳方式。它仅作为一个玩具项目,供架构师和开发人员观察和理解此类分布式架构在单个机器上的行为。当然,在现实世界中,当许多分布式服务被组合以创建企业中的新服务以及未来更大规模的系统时,这种方法将非常有利。
背景
在实现 Web 服务时,我们通常会忽略它们未来可能被重用的可能性。这在一定程度上是由于我们目光短浅,被当下的紧迫需求所蒙蔽。另一个原因是,使用当今面向消息的服务,由于技术限制,服务重用和可组合性是不可行的。当一个服务调用另一个服务时,响应时间会累积,吞吐量会下降,可伸缩性会降低,有时甚至会实际消失。如果服务调用像内存中的方法调用一样便宜,会怎么样?不难想象,人们将更有动力进行服务重用,从而通过减少重复实现来增加当前投资的价值。SOA 鼓励服务重用,但并未说明如何实现。基于这些事实,我提出了一种服务开发者的建议,即采用面向流的方法来减少服务响应时间并重叠服务处理。这将确保服务委托不会给总响应时间增加大量的延迟。这可以通过使用请求/响应或请求/响应模式来实现,而不是请求/回复模式。也就是说,服务已知会返回多个元素,这通常是这种情况。因此,服务可以在产生结果的同时将数据推送到其客户端,而客户端可以在接收到流式元素的同时处理数据。这非常类似于视频/音频流,但更块状。这将使我们能够利用客户端和服务器之间的并行性,而不是仅仅阻塞它们直到各自完成。我开发了针对面向服务架构的想法,并在 SOA Magazine 上发表了一篇题为“流式服务”的文章,您可以在此处阅读。
场景
示例代码演示了一个场景,其中客户端调用一个服务,该服务又调用另一个服务,最后调用另一个服务,因此调用深度为 4 级,以获取一组患者。您可能会说这不现实。但请记住,关键在于使这种调用链更加实用和高效,以便能够实现这种程度的重用。
示例应用程序允许您启动任意数量的服务来模拟给定的调用深度。然后,您可以调用服务以返回给定数量的元素。您还可以使用提供的文本框为每个启动的服务主机设置初始延迟和每元素处理延迟。启动服务后,我们将比较两种类型的服务调用:缓冲模式和重叠模式,它们都返回 1000 名患者。缓冲模式是我们今天开发常规 Web 服务时所采用的最佳实践的模拟。另一方面,重叠模式在生成数据时使用流式传输来写入数据,并在接收数据时消耗数据。
启动服务
构建解决方案后,确保将 TestLauncher
选为启动项目并运行它。如果您在 VS.NET 外部运行它,请确保以管理员身份运行应用程序,因为它将从端口 9000 开始托管服务。
在“启动”选项卡中,为“服务数量”文本框输入 4。

然后单击“启动”按钮以实际启动服务主机进程。

每个服务都托管在不同的端口上,并由大蓝色矩形中显示的数字标识。客户端应用程序知道每个服务及其进程在哪里,因此可以停止并启动新一组服务主机。为此,只需更改“服务数量”值,然后再次单击“启动”按钮。
在缓冲模式下调用服务(老式服务调用)
所有服务都启动后,我们可以从 TestLauncher
的“调用”选项卡调用服务。在“要返回的患者数量”文本框中,输入 1000。取消选中“重叠”复选框。在调用服务之前,让我们了解调用链的处理方式。

当我们单击“调用”按钮时,客户端调用服务 0。收到调用后,服务 0 立即调用服务 1,服务 1 调用服务 2,服务 2 调用服务 3。服务 3 是此请求的最终目的地,并执行实际工作以生成结果。服务 3 将返回请求的虚拟患者记录数。实际上,数据可以从任何数据源中提取。由于我们在同一台计算机上,我们实际上不想占用 CPU 时间进行处理。这消除了在不同机器上运行每个服务以获取我们想要利用的真实并行性的需求。由于每个服务不使用多少 CPU,它们几乎会立即返回。因此,我们只需通过引入响应时间和每元素响应时间来模拟处理时间。
随着每个服务处理元素,它会显示其进度。如上图所示,每个服务将按相反的顺序依次完成,从服务 3 开始。客户端测量获取服务的第一条记录的初始响应时间,然后测量完成整个调用的总时间。

客户端在 35 秒内收到服务的第一条元素,整个服务调用在 45 秒内完成。考虑到要处理的元素数量、每元素处理时间以及服务必须按顺序执行而没有任何并行性,这是正常的。
在流式模式下调用服务(流式服务调用)
当我们使用流式传输执行相同类型的处理时,我们会看到响应时间得到显著改善。这次勾选“重叠”复选框,然后再次单击“调用”按钮。

服务以相同的委托顺序从服务 0 调用到服务 3。但这次,服务不会仅仅等待其依赖项完成。它们一旦接收到部分可用数据就开始处理,并在结果可用时立即推送到响应流。客户端也以相同的方式工作。它在数据可用时消耗结果。请注意,所有服务现在实际上都在并行运行。它们在数据流经它们时进行处理。这种行为类似于流体在管道中流动。这种设计的优势显而易见,无需过多解释。首先,客户端可以在不到一秒的时间内(771 毫秒)接收到第一条记录。因此,尽管服务实现返回 1000 条记录需要 4 级调用深度,但我们几乎立即获得结果。其次,总处理时间显然会低得多,因为几乎所有处理都已在时间上重叠。

如上图所示,总调用时间约为 18 秒,仅为缓冲调用的 40%(45 秒)。更重要的是,这种设计在有效载荷和调用深度方面都具有更好的可伸缩性。后者在 SOA 文献中提及不多,但我认为原因同样是服务重用的高水平不可行性。这很重要,以便在开发新服务时,我们知道重用现有服务来创建新服务不会成为巨大的性能问题,并且我们的服务也可以在不显著降低性能的情况下被调用。
在流式服务设计中,初始响应时间完全不受流中元素总数的影响。如果服务仅消耗有限数量的元素并且不真正处理所有数据,那么甚至可以取消剩余的部分,从而大大减少总时间。即使数据量增长,响应能力和吞吐量也将大大提高。
不受调用深度和有效载荷大小的影响

上图显示了一个调用深度为 8 的场景,以说明初始响应时间和总响应时间如何受到影响。毫不奇怪,由于流式服务的重叠特性,两者都没有明显变化。这确实是使用流式服务的第二大好处,因为它本质上消除了服务组合和重用带来的延迟障碍,从而增加了组织的服务的资产价值。
在上述场景中,中间服务除了接收、睡眠以模拟延迟并将数据推回调用者之外,并没有对数据进行任何实际处理。但您可以想象它可以进行各种处理,这些处理作用于部分数据。当一个服务需要完整数据集来进行处理时,它的流动性就会消失。实际上,它就变成了一个缓冲服务。仍然有一些使用流的好处,但仅限于序列化数据的 IO 重叠,而不是整个处理管道的完全重叠。因此,如果可能,应避免造成这种缓冲。例如,如果您必须对数据进行排序,您基本上需要整个数据集来完成此操作。解决此问题的一种方法是将数据排序推迟到尽可能靠近最终使用者,以便不再需要进一步排序。因此,在其中一个服务(后端或前端)中进行缓冲实际上并不会破坏此设计的优势。但是,如果链中的所有服务由于编程逻辑或网络中间件而缓冲,那么它将基本上变成一个完全缓冲的服务架构,就像当今的服务一样。另一个可能更有效的解决方案是开发可以处理部分数据并与其他处理步骤并发工作的组件。想象一下,聚合器、过滤步骤、数据转换器和重新排序器被管道化以创建服务实现。然后,可以将此类服务管道化到其他以类似方式设计且也处理部分数据且从不缓冲的服务中。因此,这种设计确实需要非常小心以避免这些陷阱。我们可以设想一个未来,其中重叠服务处理的模式会出现,甚至可以作为库和框架提供给开发人员,以便他们轻松实现其代码。
流式服务针对有效载荷大小和调用深度的可伸缩性比较
我进行了一系列测试来收集实际数据。尽管是在模拟环境中而不是真实的分布式环境中,但结果应该非常接近实际行为。每个服务假定有 10 毫秒的初始延迟,加上每元素 10 毫秒的处理延迟。有效载荷大小通过服务返回的元素数量来衡量。在此分析中,我们关注单个调用时整个调用的初始响应时间和总响应时间。我们不关心观察吞吐量,但您可以想象吞吐量特性也因响应时间缩短而受到积极影响。
以下图表显示了为**初始响应时间**收集的原始数据。
用红色字母和粗框标记的区域是我们的关注点,因为它显示了在初始响应时间和总响应时间方面相对较大的增益。
以下图表是解释上述数据的另一种方式。它仅显示时间因子(缓冲/重叠)作为**初始响应时间**的改进度量。
以下图表显示了为**总响应时间**收集的原始数据。
以下图表是解释上述数据的另一种方式。它仅显示时间因子(缓冲/重叠)作为**总响应时间**的改进度量。
现在让我们可视化并观察重叠服务与缓冲服务如何针对有效载荷大小进行扩展。

在上图中,对于 4 的调用深度,初始响应时间和总响应时间都比缓冲服务扩展得更好。初始响应时间尤其出色,因为它几乎恒定。另一方面,总响应时间随总有效载荷大小的增加而增加,但它从未乘以调用深度,因此斜率要低得多。

上图显示了调用深度为 16 时的相同比较。仔细观察,缓冲服务和重叠服务之间的差异现在更加明显。重叠服务在重用和被其他服务调用方面表现出色,因为它们的响应时间特性不会受到负面影响。一种处理 1000 个元素的 16 个链式服务重叠处理的流式架构仍然可以在 6 秒内响应,并在 25 秒内完成,而缓冲设计则需要超过 180 秒。
为了了解流式服务在调用深度方面的扩展性,从而决定其重用价值,让我们分析以下图表。

在上图中,对于 100 个元素的有效载荷,重叠调用的响应时间增长速度远慢于缓冲调用。事实上,上图表明,由于初始响应时间增长迅速,服务重用超过 4 个级别可能并不实用。另一方面,重叠设计允许更高程度的重用,因为现在可以忽略附加服务调用的成本。

此图显示了 1000 个元素有效载荷大小的相同比较。现在,重叠服务和缓冲服务之间的差异更加明显。重叠调用可以在 16 个级别调用深度和 1000 个元素有效载荷大小的情况下运行,而不会 bogged down。另一方面,将缓冲服务链接到此调用深度级别是不可行的,尤其是在有效载荷大小可以增长的情况下,而随着系统使用量的增加,这种情况通常会发生。
如何实现流式服务
流式服务可以在不同的平台、语言和协议上实现。此示例使用 .NET WCF,利用 BodyWriter
类和 XmlSerializer
的流式传输功能。
在现实生活中,没有人会期望您处理编写此类服务的全部细节。这只会使一切复杂化。但是,如果这些功能被封装在框架中,可能利用新的语言功能,就可以使其对应用程序服务开发人员更加易于访问。
所以,以下是此示例代码实现的要点。
要开发服务,我们首先设计一个可以对响应正文进行自定义序列化的接口。
[ServiceContract]
public interface IGetPatients
{
[OperationContract(Action = "GetPatientsFluidWriter",
ReplyAction = "GetPatientsFluidWriter")]
Message GetPatientsFluidWriter(PatientRequest request);
服务实现会创建一个 Message
对象,该对象在方法调用后立即创建并返回。
Message IGetPatients.GetPatientsFluidWriter(PatientRequest request)
{
try
{
Notifications.Instance.OnCallReceived(this);
ThreadSleep(ServiceBehaviorConfig.Instance.ResponseTime);
Message message = Message.CreateMessage(MessageVersion.Soap11,
"GetPatientsFluidWriter", new FluidBodyWriter(typeof(Patient),
"patients", GetPatientsImpl(request)));
return message;
}
catch (Exception ex)
{
Debug.WriteLine("Error: " + ex.ToString());
throw;
}
finally
{
}
}
这确保我们能够完全控制何时以及如何发生序列化。我们想要做的是,我们将此序列化与服务处理重叠。
FluidBodyWriter
对象将在服务方法返回后立即调用。它将序列化为 XML 的数据实际上是由 GetPatientsImpl()
方法生成的,该方法返回一个 IEnumerable<Patient>
。
public class FluidBodyWriter : BodyWriter
{
...
/// <summary>
/// Write body contents while iterating on the fluid data stream
/// </summary>
protected override void OnWriteBodyContents(XmlDictionaryWriter writer)
{
XmlSerializer serializer = new XmlSerializer(elementType);
writer.WriteStartElement(this.containerNode);
int count = 0;
try
{
foreach (object elem in this.fluidDataStream)
{
Patient patient = (Patient)elem;
serializer.Serialize(writer, elem);
Debug.WriteLine("Written " + patient.PatientID);
count++;
}
Debug.WriteLine(string.Format("{0} items sent", count));
writer.WriteEndElement();
}
...
}
这段代码所做的就是获取枚举器中的一个项目并立即对其进行序列化。您可以想象数据可能来自另一个流,例如流式服务或异步数据库查询。此服务将仅在数据到达或生成时对其进行序列化。
客户端执行类似的操作。它只是在接收到的 Message
对象上创建一个读取器。Message
对象不包含完全形成的响应,而是可以用于在从响应流中读取对象时反序列化它们。
private IEnumerable<Patient> ReadPatients(Message message, string localName)
{
XmlReader reader = message.GetReaderAtBodyContents();
if (reader.LocalName != localName)
{
throw new Exception("Invalid container node");
}
XmlSerializer serializer = new XmlSerializer(typeof(Patient));
reader.ReadStartElement(localName);
while (!reader.EOF && reader.LocalName == "Patient")
{
Patient patient = (Patient)serializer.Deserialize(reader);
yield return patient;
}
reader.ReadEndElement();
}
使这一切奏效的是,XmlSerializer
对象足够智能,可以在开始反序列化给定类型之前等待接收到足够数量的字符。
最后,您需要配置客户端和服务器以使用流式传输模式。
<basicHttpBinding>
<binding name="basicHttp" maxReceivedMessageSize="67108864"
transferMode="StreamedResponse"
sendTimeout="0:01:1" maxBufferSize="16384" closeTimeout="0:00:1" />
</basicHttpBinding>
结论
本文及附带的示例代码表明,不仅“流式服务”概念实际上可行,而且它甚至不需要大量的编程。所有需要的是特别注意确保分布式服务最大限度地利用其固有的并行性。我在此公开邀请框架开发人员开始考虑这种类型的架构,并围绕它提出可能的模式。我们确实需要一个一致且易于使用的框架,同时引导开发人员以正确的方式实现高效的异步和重叠处理。当然,这个想法还不成熟,还有很多需要改进的地方,例如添加内存中的管道化阶段并行处理、元素流的部分处理模式、安全、调用取消以及对面向服务模式的重新评估。这就是为什么我宁愿编写一个玩具项目而不是一个通用框架。我还想强调,此示例并未侧重于“调用取消”方面,这可能会为流式服务的扩展创造一种新方式。但这确实需要单独的讨论。此示例通过直接断开服务连接提供了一种调用取消的方式,这显然不是一种有效的实现方式。我们所分析的任何场景都没有考虑到这一点。但是,通过一种支持有效调用取消的设计,可以创建允许服务器资源更优雅使用的系统,因为客户端将能够决定消耗多少数据,而不是必须始终消耗所有数据。
我真心希望本文能激发架构师和开发人员采用这种设计,提出模式、框架、库、工具,并最终创造一种能够应对未来 SOA 系统可伸缩性和响应能力要求的全新编程范例。
历史
- 2010 年 9 月 3 日:初始版本