那么, 您喜欢编写样板代码?





5.00/5 (7投票s)
真的吗?
引言
在我最近参与的一个 Web 应用程序项目中,我估计项目文件中大约有 12% 的文件和 18% 的单元测试仅仅用于维护与应用程序服务层相关的样板代码。这包括客户端和服务器接口,以及 DTO 和数据契约的映射。
多么西西弗斯式的任务!因此,我决定研究如何改进服务层的典型实现,作为减少或消除样板代码的调查。服务层是最重要的层接口之一,其成功的实现对于高性能和可维护的系统至关重要,而典型的样板代码实现并没有很好地服务于它们。
除了浪费开发者大量时间之外,常见的基于样板代码的架构还有许多其他缺点
- 手动输入样板代码接口容易出错
- 手动样板代码很乏味,可能导致懒惰的做法,例如:您是否真的总是始终如一地实现跨层异常处理?
- 由于跨服务层工作的额外努力,业务逻辑可能会开始侵入客户端层,甚至样板代码本身,从而导致整个架构的解体。
- 手动样板代码会产生一团复杂的代码,难以重构,而这恰恰是重构对于系统性能和可维护性至关重要的部分。
- 手动样板代码难以在不同的跨层传输之间进行切换(与本项目相比,您只需更新一行 .config 文件即可在本地、WCF、WebAPI 和 RabbitMQ 实现之间切换 - 或混合切换)
- 手动样板代码使自定义和优化整个服务层成为一项艰巨的任务,例如升级到现代序列化器
- 手动样板代码使检测整个服务层成为一项艰巨的任务,例如,进行标准分析,如计算服务层调用的频率或数据负载。
- 所有这些额外的样板代码都必须在每个构建周期中进行源代码控制/编译/单元测试。
- 在独立进程中调试比配置开发代码内联调用服务层代码更困难。
背景
服务层快速背景介绍
服务层被设计为在独立进程中工作,通常在独立的专用服务器上。服务层主机通常支持一个或多个域服务;它们不是普通类,而是执行相似功能或与特定业务域关联的方法集合。域服务类通常是无状态的,因为这样更容易管理且更具可伸缩性,尽管某些传输可能提供会话级别选项。域服务方法应该是线程安全的,以实现可伸缩性,因为您不希望多个客户端排队逐个执行。
域服务方法还应屏蔽客户端对数据库和/或实体操作的直接访问。实现这一点的一种方法是创建一个额外的层,实现一个专用的实体服务器,我构建的一个示例是 A Customisable ORM for Multi-tier Applications。
由于域服务方法被频繁调用,它们是常规重构的主要候选者。需要特别注意的一点是管理大量实体数据,尤其是在启用急切加载时(从本地进程延迟加载实现切换到远程服务层的一个常见后果)。我见过一些商业 Web 应用程序,其中一些域服务方法单次方法调用会传输多达 2MB 的数据,所以您不希望在此处出错。
托管域服务有多种方式,包括为每个域服务提供一个主机,为所有域服务提供一个主机,或者多个冗余主机的某种组合,因此服务层配置的灵活性很有用。因此,最好通过服务层而不是直接方法调用来进行不同域服务之间的调用,即使它们目前托管在同一个进程中——如果服务层能够检测到同一主机内的域服务间调用并透明地实现内联方法调用,这一点将得到帮助。
使用代码
使用 servicelayer.config 默认客户端设置为“local”编译解决方案并运行单元测试。然后将默认客户端设置为“wcf”,然后重建所有(将配置文件分发到所有各种组件的 bin 目录);启动 WCF 服务器,单元测试现在应该使用 WCF 运行。RabbitMQ 类似,尽管在这种情况下,您首先需要 设置 RabbitMQ。对于 WebAPI 实现,您可能需要以管理员权限运行托管服务。
对于客户端和服务器,运行程序可能需要管理员权限才能使检测正常工作——如果您计划从中启动各种组件,那么以管理员模式启动 Visual Studio 可能是合适的。通过添加 ServiceLayer 计数器,在性能监视器上查看检测。
要根据自己的需求改编此项目,请删除除 ServiceLayer 项目之外的所有项目(或单独编译它并将 DLL 和配置文件复制到您的项目中)。添加您自己的客户端和服务器项目(和单元测试)。您将需要所有客户端项目(包括自身调用服务层的服务器)包含 T4 文件 (ServiceLayer.tt),并且客户端和服务器项目都包含在同一个解决方案中,并引用 ServiceLayer.dll。不要忘记用 ServiceLayerDefinition
属性标记服务层接口文件,并用 ServiceLayerImplementation
属性标记每个域服务实现。
就是这样。
工作原理
这个样板代码杀手通过动态编译或使用代码模板查找带有自定义属性标记的服务层调用或实现,并透明地设置代理方法调用。
ServiceLayer 项目创建一个带有接口的 DLL,该接口被客户端和服务器使用。它期望每个域服务都向域服务客户端和服务器公开自己的接口,但域服务接口不需要被 ServiceLayer DLL 所知。
每个域服务接口都用 ServiceLayerDefinition
属性标记,每个域服务实现都用 ServiceLayerImplementation
属性标记;这些属性可以通过反射自动扫描,然后访问器可以通过使用静态构造函数进行一次性缓存。
每个服务层方法都被序列化/反序列化到进程边界,转换成一个包含方法名和参数数组的字符串——序列化器是可注入的。
动态编译
服务器端,启动时通过反射扫描 bin 文件夹中的所有程序集,并将任何带有 ServiceLayerImplementation
属性的类添加到动态编译的代理方法列表中。动态编译的少量开销在服务器启动时不会显著,但可以实现高性能和灵活的实现。特别是,当一个服务器托管多个域服务时,它们可以直接相互调用,而无需通过 ServiceLayer 代理。
客户端,一个非常简单直接的实现是要求所有服务器调用显式匹配一个服务层标准,例如
servicelayer.call("myinterface","mymethod",new dynamic[] {paramA, paramB, paramC});
而不是更正常的
domainService.mymethod(paramA, paramB, paramC);
但这实际上是不可接受的,如果您想要清晰度和编译时支持,因此需要某种技术通过自动内联编译将后者转换为前者。对于客户端,“反射”通过扫描客户端项目的源代码确保您不必预编译代码来生成程序集,然后才能通过反射二进制文件进行检查——使构建脚本更简单。还有一些好的副作用,即客户端启动没有开销,客户端也不需要了解服务器程序集或其二进制文件的位置。T4 模板引擎已包含在 Visual Studio 中,并生成 C# 源代码,可以正常编译,并且输出也可以在开发过程中直观地读取以检查正确性。
缺点是,如果语法错误,它可能会变得难以捉摸,并且调试器在显示 T4 正在处理的 COM 对象变量时管理得不好。请注意,T4 只能找到相对于当前解决方案的源文件。如果您想维护多个解决方案,则至少需要两个编译步骤——一个复制或编译接口文件,第二个编译使用第一个步骤结果的客户端代码。T4 代码可以通过外部导入的 DLL 启动内部反射,尽管在这里不需要,因为它是一个单一解决方案。
配置要求
对于客户端(包括在服务器中调用服务层的其他服务器的“客户端”)来说,servicelayer.config 指定了在哪里可以找到每个域服务以及如何与其通信。
对于服务器来说,在一个服务器中支持多个域服务既简单又高效,并且这些域服务将直接相互调用,而不是通过服务层代理。
Fire-and-Forget 方法调用
除了可能的性能增强之外,Fire-and-Forget 方法的真正意义在于,服务层中抛出的任何异常都 **不会** 标记到客户端进程,并且两个异步调用的执行顺序 **不** 保证。
服务层可以选择支持 **Fire-and-Forget** 异步实现单个方法,任何返回 void
的方法都是可能的候选者。FireAndForget 实现非常有用的一个例子是日志记录代码,这可能是一个显著的性能问题。
RabbitMQ 当然自带持久化队列“开箱即用”。WCF 和 WebAPI 需要添加额外代码来持久化服务层 Fire-and-Forget 调用——如果您想确定这些调用最终会运行。
解决方案布局
我在示例实现中包含了三个域服务;目的是说明一个服务器进程有两个域服务,第二个服务器进程运行第三个域服务,该域服务可以由示例客户端和其他两个域服务调用。
第二个服务器的实际应用可能是一个电子邮件域服务,它可能需要在一个具有特殊权限的进程中运行,或者在一个特定的服务器上运行,或者可能只是一个被大量不同的软件应用程序在不同上下文中使用通用的服务。
该解决方案包含以下项目
- SampleClient - 一个示例客户端,其中包含对示例域服务的说明性调用
- Sample Domain Services 文件夹
- SampleServiceDomainServicesAandB - 一个包含两个示例域服务(IDomainServiceA 和 IDomainServiceB)的类库
- SampleServiceDomainServiceC - 一个包含单个示例域服务(IDomainServiceC)的类库,它可以从客户端和服务器进程调用。
- Sample Servers 文件夹
- ServerRabbit1 - RabbitMQ 自托管服务器,用于 SampleServiceDomainServicesAandB
- ServerRabbit2 - RabbitMQ 自托管服务器,用于 SampleServiceDomainServiceC
- ServerWcf1 - WCF 自托管服务器,用于 SampleServiceDomainServicesAandB
- ServerWcf2 - WCF 自托管服务器,用于 SampleServiceDomainServiceC
- ServerWebApi1 - WebApi 自托管服务器,用于 SampleServiceDomainServicesAandB
- ServerWebApi2 - WebApi 自托管服务器,用于 SampleServiceDomainServiceC
- ServiceLayer - 无样板代码库
- UnitTests - 通过调用示例客户端来检查跨层接口的测试
自托管程序实现为控制台程序,尽管在实际应用中它们可能应该实现为 Windows 服务(这就是我将控制台程序安排为使用静态 OnStart
和 OnStop
方法,而不是带有构造函数的可处置类)。
同步和异步实现
实现异步实现并不完全简单。首先,客户端的“异步性”不一定与服务器方法的“异步性”相关——每种都是完全独立的。假定服务器方法遵循在方法名后附加“Async
”的约定,因此,为了区分客户端异步调用服务层方法的情况,这些方法遵循在方法名后附加“_ClientAsync
”的约定。这导致了有些笨拙的约定,可能会将方法命名为“DoSomethingAsync_ClientAsync
”。
其次,是否应该同时公开本质上同步的方法以及异步实现,反之亦然?理想情况下不应该,但作为一个服务库,可能需要提供更广泛的功能,并希望它不会被滥用。
序列化和反序列化
NewtonSoft 和 FastJSON 实现是动态可选的。我发现 NewtonSoft 通常是可靠的,但 FastJSON 无法正确序列化异常或列表的内容。
方法调用首先被处理成一个参数值数组(使用非常有用的 params
关键字),然后可以对其进行序列化。原则上,据我所知,序列化器应该能够接受动态参数值数组并对其进行序列化,以便在反序列化过程中可以恢复其实际类型。Newtonsoft 没有这样的运气,数组中的每个值都被标记为 object
类型,因此我们需要添加一些额外的手动干预,通过明确定义所需类型来确保我们能够恢复到正确的类型。为此,我们需要存储每个方法调用的参数和返回类型。
因此,而不是这段简单的代码
// client void ClientCall (params dynamic[] args) { var string = SerializeArray(args); } // server dynamic[] args = DeserializeArray (mystring); var result = ServiceLayerDelegates[methodname].DynamicInvoke(args); return Serializer.SerializeObject(result);
我们必须通过单独序列化每个值,然后将每个序列化字符串值与分隔符连接起来来手动完成此操作,\0 即可,除非您期望传递包含 \0 的字符串。
// client void ClientCall (params dynamic[] args) { StringBuilder sb = new StringBuilder(methodname); foreach (var param in args) sb.AppendFormat("\0{0}", SerializeObject(param)); return sb.ToString(); } // server string[] json = jsonStrings.Split('\0'); var info = ServiceLayerCallTable[methodname]; var args = new List(); for (int i = 0; i < info.ArgTypes.Length - 1; i++) args.Add(Serializer.DeserializeObject(json[i + 1], info.ArgTypes[i])); return Serializer.SerializeObject(info.ServiceCallDelegate.DynamicInvoke(args.ToArray()));
如果您知道一个支持简单方法的序列化器,请告诉我。
ServiceLayer 项目
ServiceLayer 项目(在 ServiceLayer 解决方案中)提供客户端和服务器接口及实现。有四种示例服务器实现:同一进程中的内联实现、WCF(使用 NetTcp,推荐)、RabbitMQ 和 WebAPI。还有许多其他实现选择,您应该能够轻松自己编写,例如 MSMQ 或 Azure Queues。
假定所有客户端和服务器进程中都存在实现服务层域服务的 DLL,这使得目标可以简单地通过更新配置文件从本地切换到远程服务器,从而将客户端重定向到所需目标。配置文件在系统启动时读取,理论上您可以动态切换,但我看不到任何实际价值。您也可以配置客户端,使某些域服务在本地实现,一些可能通过 WCF 实现,一些可能通过 RabbitMQ 实现——我仍然不确定为什么有人会这样做,但这个选项是存在的。
当然,您可以选择不与客户端一起分发域服务 DLL,并且只要您不将客户端配置为本地选项,这应该可以正常工作。但为了灵活性,甚至可能是弹性,额外的几个 DLL 通常不是什么大问题。
使用此代码并不意味着您不应该考虑自己层实现的要求:例如,大多数 WCF 服务层实现会使用 NetTcp 并 缓存 Channel Factory。此外,WCF 和 Rabbit 都使用单个共享的终结点或队列(分别)实现,而不是为每个托管的域服务都有一个。另外,您期望同步还是异步执行服务层方法——并且它们是串行还是并行执行的?
服务器端异常会通过返回一个 Exception 对象传播回客户端,该对象然后由客户端抛出(当然,对于单向调用无效),所以如果您编写了一个服务层调用来返回一个 Exception 作为其返回值,那就完蛋了!
服务器代码
BaseServiceLayerServer
包含一个静态构造函数,用于公开用 ServiceLayerImplementationAttribute
标记的所有类的所有方法,以供服务层执行。用该属性标记类只是为了提高效率,它避免了在添加新项到接口时忘记设置方法属性,但您也可以选择其他策略,例如公开所有公共方法而不是所有标记的公共方法——这是易用性(前者,不会忘记标记每个方法)和灵活性(对要公开的确切方法有更多控制)之间的权衡。请注意,缓存过多(例如由其他服务器实现的域服务)没有特别的坏处,因为服务器是被动的,只接受客户端向它们请求的内容。
静态构造函数缓存每个已实现的方法名及其每个参数的预期类型。
服务器还控制着自己的服务层客户端;它们可能根本不使用任何服务层调用,或者它们可能有一些本地服务层调用,还有一些托管在别处。
ServiceLayer 配置
Servicelayer.config 是一个文件,由于它位于 ServiceLayer 项目中并设置为“始终复制”,因此它将始终复制到引用该代码的所有项目的 bin 目录中。它有点反常,因为它描述的是构成域服务的配置,而不是服务层项目本身的配置。您可以随意将其从 ServiceLayer 项目中删除,而是将其复制到所有引用项目中——尽管您需要单独维护每个文件。
配置文件指定了服务层调用的默认传输层和序列化器,以及特定于层的配置,如 WCF URL 和 RabbitMQ 队列名称。
调整配置文件以使用不同的传输方式连接到指定的域服务应该很容易,如果您需要这样做(大多数人可能不需要)。
ServiceLayer 支持的可注入传输和序列化器
我选择实现了“local”、WCF、RabbitMQ 和 WebApi 传输以及 NewtonSoft 和 FastJSON 序列化器选项。我选择不实现 Azure Messaging 等,因为这对于示例项目来说更难演示,但它们应该很容易实现。
每个可注入选项都有其自身的优点,并且可能原生实现某些功能
本地“传输”
对开发人员来说非常有用,可以轻松调试。在本地情况下,无需序列化或反序列化,因此堆栈更简单。
WCF
WCF 有许多配置属性,并且还支持一些开箱即用的服务层选项,包括 FireAndForget(“单向”)和异步,您可以添加更多(例如节流)。
RabbitMQ
RabbitMQ 提供无与伦比的队列控制,无论是临时还是持久化的,并且可以轻松控制队列项的多线程。RabbitMQ 还可以配置为将已完成的服务层调用持久化到调试队列,以便稍后进行分析。
WebAPI
WebApi 似乎是最快的远程服务层实现。
单元测试已构建用于检查同步和异步方法调用的正确管理,尤其是异常,并且未设计用于性能测试。但是您可以在下面看到比较结果。
还有一件事……
一些面向对象的构造提供了未来灵活性的选项,而无需提前进行完整实现。我指的是属性实现。
string myProperty {get; set; } // can be easily extended to.. string myProperty { get {return myValue; } set { myValue = value; AndDoOtherStuff(); }}
好吧,在您在不同层之间复制数据对象和 DTO 时,不要立即想到您超负荷的 AutoMapper,为什么不先研究一下 C# 别名呢?
using BookViewModel = Book; // rework later to (IF you need to) AutoMapper.Mapper.CreateMap(); var model = AutoMapper.Mapper.Map (book);
在许多情况下,映射的类将相同或具有相同的字段,但具有不同的属性装饰——将属性“包含”在两个类中并没有什么坏处,它甚至可能阐明用法。因此,您可以使用像这样的通用类来从实体、DTO、数据契约、ViewModel 进行转换,而无需实际进行任何工作。
[DataContract] public class Book { [DisplayName("Book Title")] [Required(ErrorMessage = "Title is required")] [DataMember] public string Title {get; set;} [DataMember] public string Title {get; set;} }
只要您的命名空间不同,使用起来就非常简单。任何命名空间问题,请查看这篇文章。
历史
版本 1
- 本地实现
- WCF 实现
- RabbitMQ 实现
版本 2
- 新的异步功能
- 新的 WebAPI 实现
- 新的检测类
- 改进的 T4 脚本以支持项目文件夹
- 改进的 T4 脚本,仅在代理不存在时生成(同一服务器托管的域服务之间快速调用)
- 服务器使用(快速)调用委托而不是(较慢的)DynamicInvoke 进行动态代码编译
- 使用通用代码简化服务器实现