数据转换——SOLID 架构的基石。






4.86/5 (18投票s)
用于创建真正的模块化架构的反射和工具。
本文的法文版可在我的博客上获取。
引言
如果说单独工作时编程是一项复杂的活动,那么当程序需要与他人协作时,难度就会大大增加,因为后一种情况通常需要将架构划分为受限区域,以实现概念的良好分离。
尽管这种类型的架构经常被实施,但模块间的相互依赖性是其薄弱环节,迫使开发人员为了达到目标而无视所有良好的分离实践。
在本文中,我将讨论一种先进的“数据转换”解决方案,以确保您的模块化架构尽可能地遵守 SOLID 原则。
背景
将软件分离成多个层或模块在开发领域非常常见。
这种旨在促进代码可维护性、可扩展性和可测试性的实践存在一些陷阱。最常见的发生是,当需要在模块边界处操作对象时,会产生相互依赖。
这个问题甚至更糟,因为大多数框架、工具和示例都促使开发人员陷入这些对于初学者来说似乎几乎不可避免的架构陷阱!
本文将讨论一种特殊的架构,其中数据转换将发挥重要作用。这种机制将允许您的模块之间实现完美分离,同时实现有效通信。
架构的演变
为了向您展示最常见的问题,我将以一个经典的业务应用程序为例,采用三层架构。
表示层包含与视图相关的代码,业务层包含与业务逻辑相关的代码(验证规则、实体、工作流等),数据访问层包含与数据库交互的代码。
理论上这一切似乎提供了很好的分离,但大多数时候它是如何发生的呢?
通常在这种类型的架构应用程序中,DAL(数据访问层)的开发人员负责在数据库中提供和保存业务对象。因此,他们需要引用 BLL(业务逻辑层)才能轻松操作这些对象。
然后,DAL 开发人员很快意识到,如果他们使用框架与数据库交互,他们的工作将大大简化。大多数现代框架(如 Entity Framework)都允许处理从数据模式自动生成的实体。对于开发人员来说,将这些实体用作业务对象似乎更好。
特别是当
- 该框架拥有基于类实例的自动变更检测系统。
- 该框架允许使用 POCO 对象,这些对象应该能够创建不受数据模式约束的类。
选择很快就做出了:BLL将拥有框架生成的实体来操作数据库。
现在转向“表示”层。
负责开发此部分的人员必须允许用户操作业务对象(客户、发票、文档、仪表板、工作流状态等),有时使用非常复杂的图形组件(透视网格、日程表、高级列表、指标等)。
对他们来说幸运的是,市场上有许多此类组件,其中大多数允许您直接链接到数据库。
为此,表示层需要知道它将要处理的对象类型。因此,必须在表示层中引用 BLL。
但是,设置 GUI 不仅限于绘制或安排一些图形组件。还需要编写一些代码来操作对象并使其可呈现。解决此问题(MVC、MVP、MVVM...)的设计模式似乎需要访问 BLL 中的对象。因此,发现一些操作这些实体的 GUI 代码非常常见。
我们现在从一个理论上完美分离的系统演变为这样
模块之间通信的技术选择导致我们的系统变得高度相互依赖。
如果我们从树状结构的角度看项目,一切似乎都分离良好,代码逻辑地存储在目录中。然而,依赖关系图将显示 DLL 都是相互依赖并相互引用的。
如果有人不幸地想要审查架构,他将很快发现由于技术纠缠而无法重新设计代码,原因很简单:业务实体实例被数据访问框架直接使用,并由图形组件处理!当 MSDN 或其他网站上提供的许多代码示例为了简化而突出这些技术时,这种做法似乎更加合理。问题在于初学者将其视为理所当然,并不总是意识到它带来的负面后果,例如不可测试性和紧耦合,这严重抑制了软件的可扩展性并促进了副作用导致的错误。
让我们回到基本原则
将软件分离为称为“层”或“模块”的单元,源于将系统不同职责区域隔离以服务多个目的的需求
- 在一个领域工作,对其他领域没有重大影响。
- 能够用另一种实现替换一个领域的实现。
- 根据其技术特点,独立测试每个区域。
因此,垂直分层架构不太适合。
为了达到这些目标,需要将垂直分层架构替换为更具逻辑性的架构。
让我们从一个小维恩图开始,概览我们的需求
我们清楚地看到,业务领域是我们系统的核心要素。如果仔细思考,如果我们的程序能够自给自足,那么它可能是我们唯一需要的范围。
不幸的是,商业软件必须向用户呈现数据、将数据保存到数据库、与外部设备或系统交互等。所有这些都可能将您的软件变成一个真正的怪物,如果控制不当,很快就会奴役其开发人员。
但请注意,所有这些领域都不应被视为业务领域。它们只是提供输入和输出的组件。
这种区别非常重要,因为区域重叠通常会导致开发人员编写“夹具代码”,这很快就会导致模块紧密耦合。
因此我提出的解决方案是采用具有某些特性的洋葱式架构。
解释
该架构包含三个主要概念
- 业务部分(蓝色)是架构的中心。物理上,它是一个项目(DLL),包含所有用于操作纯业务逻辑的 API。它将包含“发票”、“客户”、“订阅”等类以及用于获取纯业务实体的工厂。
- 契约部分(黄色)是一个没有代码的区域。物理上,这些项目只包含接口和异常类。我们稍后会看到,这些接口必须以特定方式编写,才能符合我们完全独立的原则。
- 外围部分(绿色)包含契约(黄色部分)的实现。每个设备都是一个特定的项目,并且必须实现所有相关的契约。项目开发人员还将负责设置测试环境,以验证代码的正确实现。
尽管这个主题非常有趣,但我不会深入探讨这种架构以及如何实现它,因为我假设您已经了解实现 SOLID 系统的所有良好实践。
相反,我将重点关注一个可能令人惊讶的观点:我们如何从一个区域转移到另一个区域,而我们的模块彼此不了解,只有共同的契约(接口)?
如果我们以业务域和 SQL Server 域之间的互连为例,两者都只被一个项目引用:“数据访问契约”。因此,它们彼此不了解。
这意味着业务部分将有一个“Person
”类,它将与 SQL 部分中的另一个“Person
”类非常相似。
但这到底有什么好处?我们如何才能在不同的世界中操作对象?这难道不是多余的吗?我将尝试回答这些问题...
这一切有什么好处?
这个系统乍一看可能显得非常笨重,但它带来了很多优点
- 分工合作:一个开发人员可以专注于纯业务部分,而另一个则处理设备,互不干扰,也不会产生可能的副作用。多个团队可以垂直地在同一个主题上工作,但处于不同的层面。
- 安全行为变更:您必须将您的 DBMS 迁移到 NoSQL 吗?您的 webservice 必须切换到 LDAP 吗?未来的更新会涉及审查对设备的调用吗?您的 GUI 必须回到 Winform 吗?没问题!由于您的区域之间不存在耦合,更改设备项目的实现不会对您的业务部分(或除了之外的其他设备)产生任何影响。您可以专注于目标区域的重新实现,而不必担心可能的副作用。
- 多重实现:您的软件应在多种类型的数据库上运行吗?或在 Oracle、SQL Server 和本地 Excel 文件之间切换?甚至根据是在桌面还是移动设备上运行来处理多个 GUI?契约将允许您通过依赖注入使用多种实现。
- 研究与 bug 修复:未处理的异常和 bug 能够被快速识别和纠正,因为每个职责区域都有其自己特定的测试库。
我们如何才能在不同的世界中操作对象?
为了回答这个问题,我将首先解释基本原理
当程序员想要在两个领域之间进行真正的分离时,他别无选择,只能为每个领域创建特定的实体。
如果这两个领域必须处理共同的实体,好的实践是有一个契约来定义这些实体的属性。
有了这个契约,我们能够确保当两个领域谈论一个`Person`时,它们都同意它是一个拥有姓名和生日的实体。所有其他属性都只是它们内部逻辑特有的信息,与其他领域无关。
但是,当 `DomainA` 获得 `PersonB` 的实例时,我们如何应用 `PersonA` 的行为呢?这很简单:只需将 `PersonB` 复制到 `PersonA` 的新实例中。将一个对象复制到另一个不同类型以利用其行为的这个操作称为“转换”。这正是本文的主题!
这难道不是多余的吗?
从你编写第一个程序开始,每个人都会不断提醒你总是需要重构代码。这也是 STUPID 首字母缩略词的 D 原则,即重复。那么,创建多个类来表示相同的实体是一种代码重复吗?
一个新手程序员会强烈倾向于创建一个专门的项目来存储所有实体,然后确保每个模块都可以访问这个项目。
这种推理不仅是一个坏主意,而且也违反了几个 SOLID 原则。
代码重复发生在您调用不同的指令来应用相同的行为时。这迫使程序员在需要更改所需行为时修改每组指令。很明显,这种不良实践必须绝对从您的项目中禁止。
但我们的情况呢?您认为业务逻辑中的“Person
”实体与数据访问层中的“Person
”实体具有相同的行为吗?当然不是!
业务领域中的“Person
”具有一些属性,例如注册服务时的年龄、格式正确的电话号码或可能感兴趣的产品列表;而数据访问层中的“Person
”将具有外键、唯一标识符等。
因此,我们并非在处理重复代码,因为我们必须处理不同的行为。对于 SOLID 架构来说,将每个行为分离到不同的类中是完全合理的。
选择合同类型
既然大家明白了这篇文章的动机,那么让我们来谈谈核心主题。我们的目标是复制具有不同结构的对象。
要查看要复制的成员,需要有每个对象的共同属性的描述。这种描述可以有多种形式,称为契约。
有 WCF 经验的人可能已经听说过这个概念,因为这项技术为序列化机制提供了 DataContracts API。
如果您对这些机制感兴趣,您可能已经寻找了更优化的方法来执行此任务,并且您听说过使用契约的其他序列化系统,例如 *protobuff*。
还有很多方法可以通过反射来复制等效字段。
难道这些方法之一不能满足我们的需求吗?
作为一名软件架构师,您的职责是确保您的程序易于理解、可用,并且所有参与项目的开发人员都可以修改。因此,强制使用简单方法,最大限度地减少培训需求,并减少实现错误的空间非常重要。
然而,上述工具提供了什么?
WCF DataContracts 使用属性。使用此方法创建契约包括为序列化需求标记每个属性。基于此原则的架构将强制开发人员维护当前文档,或创建专门用于设计契约的工具。在这两种情况下,此技术的繁重性容易导致遗漏,并且无法从编译器中受益来检测任何错误。
Protobuff 提供了几种技术。其中一种也使用属性。我们不会采用这种方法,原因如上所述。
另一种方法是创建一个描述文件,该文件将通过第三方工具生成特定的代码。
新加入您项目的开发人员必须接受培训才能使用此工具、其语法等。任何代码重新设计都需要通过第三方工具重新编译,这会产生额外的开销。当然,这种技术无法在编译时检测错误。
至于使用反射的通用复制方法,我认为甚至不值得谈论,因为你会明白,它们不需要契约,因为它们依赖于属性名称和类型的匹配来完成工作。想象一下当有人重命名属性或在代码重新设计中审查类层次结构时可能产生的副作用...
那么我们还剩下什么?
我们拥有接口,它们提供了多种优势
- 它们是语言原生的。所有人都能理解,无需额外培训。
- 它们可以被代码操作工具(如 ReSharper)使用,这将允许您生成实现、进行大规模重命名、代码导航等。
- 它们会生成编译器错误,让程序员对如何实现它们很少产生疑问。
- 它们尊重 OOP 原则
作为一名软件架构师,这显然是创建满足我们先决条件的契约的最佳选择。
如何构建接口?
如果您在互联网上搜索,您会发现使用接口来帮助复制对象是非常不常见的。但为什么呢?难道这不是一种简单自然的创建结构描述的方法吗?
不幸的是,它比看起来要复杂得多,我将尝试解释原因。
当我使用接口来描述一个结构以进行复制时,只要我只使用基本类型(string、int、datetime 等),一切都很好。
当我们的结构需要包含其他复杂类型时,问题就开始出现了。
在这种情况下,一种偷懒的实现方式是直接在我们的第一个接口中放置一个新成员,其类型为聚合接口的类型。
示例
public interface IPerson { string FirstName { get; set; } string LastName { get; set; } IAddress Address{ get; set;} } public interface IAddress { string Street { get; set; } string City { get; set; } string Country { get; set; } }
这里,我们的 IPerson
契约必须包含一个地址。因此,我们创建一个 IAddress
契约,并将其作为 IPerson
的成员添加。
这对于任何优秀的 C# 程序员来说都是完全合理的。
让我们在领域 A 中进行这些契约的首次实现
public class PersonA : IPerson { public string FirstName { get; set; } public string LastName { get; set; } public DateTime BirthDay { get; set; } public IAddress Address { get; set; } } public class AddressA : IAddress { public string Street { get; set; } public string City { get; set; } public string Country { get; set; } }
我们可以看到,在 PersonA
的实现中,我们有一个 IAddress
类型的成员。为了方便操作,最好直接将此成员设为 AddressA
类。
不幸的是,这是不可能的,因为接口实现必须具有精确声明的类型。
当然,存在许多技巧,例如声明一个名为 Address
的成员,类型为地址A并将其显式实现,以便能够操作此成员。
public class PersonA : IPerson { public AddressA Address = new AddressA(); public string FirstName { get; set; } public string LastName { get; set; } public DateTime BirthDay { get; set; } IAddress IPerson.Address { get { return Address; } set { if( !(value is AddressA) ) throw new Exception("Cannot assign the property because is does not have the matching type AddressA"); Address = (AddressA)value; } } }
然而,这种技术带来了几个问题。它不仅需要编写多行防御性编程代码,而且会创建重复的属性,并会创建一个最终对复制来说很繁重的机制。
当我们的契约必须包含对象集合时,也会出现相同类型的另一个问题。
由于我们的契约不应该拥有强类型成员(因为我们必须保持独立于所有相邻层),所以想到的解决方案是使用 IEnumerable 来表示我们的集合。
public interface IPerson { string FirstName { get; set; } string LastName { get; set; } DateTime BirthDay { get; set; } IEnumerable<IAddress> Address { get; set; } }
但是,就像聚合一样,这种技术带来了许多重大问题。它不仅需要在内部属性中进行一些重新映射,而且我们无法同步我们的集合,因为我们已将它们在契约中声明为“只读”。
那么,最好的方法是什么?
正确的方法是在接口中使用带有检查约束的泛型类型。
public interface IPerson<TAddress> where TAddress : IAddress { string FirstName { get; set; } string LastName { get; set; } DateTime BirthDay { get; set; } TAddress Address { get; set; } }
所以,当您进行实现时,您只需声明泛型类型即可!
public class PersonA : IPerson<AddressA> { public string FirstName { get; set; } public string LastName { get; set; } public DateTime BirthDay { get; set; } public AddressA Address { get; set; } }
至于集合,我们将采用相同的技术,只不过检查约束应该在 ICollection
类型上。
下面是一个“Person
”类的示例,它必须包含多个电话号码
public interface IPhone { string Number { get; set; } string Type { get; set; } } public interface IPerson<TAddress, TPhone, TCollectionPhone> where TAddress : IAddress where TPhone : IPhone where TCollectionPhone : ICollection<TPhone> { string FirstName { get; set; } string LastName { get; set; } DateTime BirthDay { get; set; } TAddress Address { get; set; } TCollectionPhone Phones { get; set; } }
这种契约类型揭示了它在业务层和 UI 层之间进行数据转换的强大能力,因为在大多数情况下,业务层需要简单的泛型列表,而 GUI 实体需要可观察的集合,以便能够轻松地绑定到视图。
有了这个泛型契约,两个世界都可以通过声明一侧为 `List<BusinessPhone>`,另一侧为 `ObservableCollection<UiPhone>` 来实现接口。这对转换系统来说不是问题!
但如果我们的子类型也包含对应契约的子类型,那该怎么办呢?
在这种情况下,最好的方法是将我们的契约分成两个接口(或更多)。
首先,一个基本接口将声明所有基本类型成员,第二个接口将声明所有泛型类型成员。我们可以应用类型约束(继承自基本接口),这将避免在每次实现中声明泛型类型。
例如,假设我们要为地址添加 GPS 坐标。那么契约将变为
public interface IGpsLocation { double Latitude { get; set; } double Longitude { get; set; } } public interface IAddress { string Street { get; set; } string City { get; set; } string Country { get; set; } } public interface IAddress<TGpsLocation> : IAddress where TGpsLocation : IGpsLocation { TGpsLocation GpsLocation { get; set; } }
以及我们领域 A 中的实现级别
public class GpsLocationA : IGpsLocation { public double Latitude { get; set; } public double Longitude { get; set; } } public class AddressA : IAddress<GpsLocationA> { public string Street { get; set; } public string City { get; set; } public string Country { get; set; } public GpsLocationA GpsLocation { get; set; } } public class PersonA : IPerson<AddressA> { public string FirstName { get; set; } public string LastName { get; set; } public DateTime BirthDay { get; set; } public AddressA Address { get; set; } }
这里,PersonA
类非常简单,尽管契约有一个带有泛型约束的 IAddress
成员。
如果您什么都不懂,那么您必须知道这些
- 契约是接口
- 非原生类型成员必须声明为泛型类型。
- 集合类型成员也必须报告为泛型类型,并对
ICollection
接口进行约束。 - 包含其他泛型接口的泛型接口可以分为两部分。一个包含所有原生成员的基本接口和一个继承自基本接口的泛型接口。因此,契约可以通过只声明基本接口来保持其简洁性。
数据转换系统
既然您已经了解了使用契约隔离层的重要性,以及如何构建这些契约,那么我们将进入有趣的部分:转换系统。
正如我从一开始就解释的那样,目标是将数据从一个源对象复制到不同类型的目标对象中。
不幸的是,商业软件必须与可能包含数百个表的数据库交互,在这种情况下,开发人员的最佳实践是使用 ORM 来实现平稳演进。
然而,大多数 ORM 提供从数据库中实体化的对象,并允许生成对这些对象的更改请求。检测机制直接基于内部上下文维护的实例!
因此,我们的问题不是简单地复制数据,而是为了保持与 ORM 的兼容性而对对象集群进行数据同步。
现在你开始明白为什么软件架构师不愿意真正分离领域了 :)
因此,我将介绍的转换工具名为 **MergeCopy**,因为该系统不仅可以复制符合共同契约的对象,还可以将目标与源同步,而不影响构成它们的实例的完整性。
用法
**MergeCopy** 系统以一个名为 MergeCopy()
的简单扩展方法的形式出现。
要使用它,请在您的契约中添加泛型接口 IMergeableCopy<T>
,其中 T
是对象实例的唯一标识符类型,可通过属性“MergeId
”访问。
这个属性是一个技术成员,允许 MergeCopy
区分相同类型的不同对象实例,以便同步它们。
选择此标识符属性的正确类型由开发人员自行决定。通常,它映射到 DAL 中对象的主键。
让我们重用上一节的示例。
首先,我将 IMergeableCopy<T>
接口添加到我的契约中,假设我的实体将由一个 Guid
唯一标识
public interface IPerson : IMergeableCopy<Guid> { string FirstName { get; set; } string LastName { get; set; } DateTime BirthDay { get; set; } } public interface IPerson<TAddress, TPhone, TCollectionPhone> : IPerson where TAddress : IAddress where TPhone : IPhone where TCollectionPhone : ICollection<TPhone> { TAddress Address { get; set; } TCollectionPhone Phones { get; set; } } public interface IPhone : IMergeableCopy<Guid> { string Number { get; set; } string Type { get; set; } } public interface IAddress : IMergeableCopy<Guid> { string Street { get; set; } string City { get; set; } string Country { get; set; } } public interface IAddress<TGpsLocation> : IAddress where TGpsLocation : IGpsLocation { TGpsLocation GpsLocation { get; set; } } public interface IGpsLocation : IMergeableCopy<Guid> { double Latitude { get; set; } double Longitude { get; set; } }
现在我的契约已明确定义,我可以在两个不同职责的领域中进行实现
- BusinessDomain 将提供创建和操作人员的 API
- 而 StorageDomain 将负责将对象序列化和反序列化到具有特定格式的文本文件中。
别忘了,物理上,BusinessDomain 和 StorageDomain 是两个独立的 DLL,它们被包含我们契约的第三个 DLL 引用。
让我们在业务部分实现所有功能
首先,我将创建一个基类来集中管理 MergeId
public abstract class Business : IMergeableCopy<Guid> { protected Business() { MergeId = Guid.NewGuid(); } public Guid MergeId { get; set; } }
然后,我实现不需要泛型类型的契约(它们不包含复杂类型,如前所述)
public class BusinessPhone : Business, IPhone { public string Number { get; set; } public string Type { get; set; } } public class BusinessGps : Business, IGpsLocation { public double Latitude { get; set; } public double Longitude { get; set; } }
一旦这些类实现完成,我就可以继续实现使用它们的类了。
public class BusinessAddress : Business, IAddress<BusinessGps> { public string Street { get; set; } public string City { get; set; } public string Country { get; set; } public BusinessGps GpsLocation { get; set; } } public class BusinessPerson : Business, IPerson<BusinessAddress, BusinessPhone, List<BusinessPhone>> { public string FirstName { get; set; } public string LastName { get; set; } public DateTime BirthDay { get; set; } public BusinessAddress Address { get; set; } public List<BusinessPhone> Phones { get; set; } }
好的,现在我们来声明一些特定于我们业务对象的行为
public class BusinessPerson : Business, IPerson<BusinessAddress, BusinessPhone, List<BusinessPhone>> { private string _lastName; public string FirstName { get; set; } public string LastName { get { return _lastName; } set { if( string.IsNullOrWhiteSpace(value) ) throw new Exception("The lastname cannot be empty"); _lastName = value.ToUpper(); } } public DateTime BirthDay { get; set; } public BusinessAddress Address { get; set; } public List<BusinessPhone> Phones { get; set; } public int Age { get { return DateTime.Now.Year - BirthDay.Year; } } }
属性“LastName
”不能为空,并且在定义时会自动转换为大写。新增了一个属性“Age
”以根据出生日期计算人的年龄。
现在,让我们来实现存储部分
public class StoragePerson : Storage, IPerson<StorageAddress, StoragePhone, List<StoragePhone>> { public string FirstName { get; set; } public string LastName{ get; set; } public DateTime BirthDay { get; set; } public StorageAddress Address { get; set; } public List<StoragePhone> Phones { get; set; } public override string ToString() { var builder = new StringBuilder(FirstName + "^" + LastName + "^" + BirthDay + "^" + (Address != null ? Address.ToString() : "")); foreach (var storagePhone in Phones) builder.Append("|" + storagePhone); return builder.ToString(); } } public class StorageAddress : Storage, IAddress<StorageGps> { public string Street { get; set; } public string City { get; set; } public string Country { get; set; } public StorageGps GpsLocation { get; set; } public override string ToString() { return Street + "^" + City + "^" + Country + "^" + (GpsLocation != null ? GpsLocation.ToString() : ""); } } public class StoragePhone : Storage, IPhone { public string Number { get; set; } public string Type { get; set; } public override string ToString() { return Number + "^" + Type; } } public class StorageGps : Storage, IGpsLocation { public double Latitude { get; set; } public double Longitude { get; set; } public override string ToString() { return Latitude + "^" + Longitude; } } public abstract class Storage : IMergeableCopy<Guid> { protected Storage() { MergeId = Guid.NewGuid(); } public Guid MergeId { get; set; } }
如您所见,我们这里的目的是为每人生成一行,格式如下:姓氏 名字 ^ ^ ^ 街道 生日 ^ ^ 城市 ^ 国家 纬度 经度 ^ | ^ 类型 电话号码 | 电话号码 类型 ^ | ...
但一个问题出现了:业务端将如何告知存储端保存实体?
确实,本文主要关注数据复制,因为我主要关注模块之间出现的 DTO(数据传输对象)问题。关于如何在模块之间发送命令的问题是一个通用的架构问题,您可以通过使用“服务”和“存储库”找到出色的答案。
在我们的例子中,包含契约的 DLL 也包含定义存储库如何实现的接口。根本区别在于只有存储部分会实现存储库。业务部分可以通过依赖注入机制访问,这不是本文的主题。
对于我们的例子,存储库接口如下所示
public interface IStorageRepository { void SavePerson(string filePath, IPerson person); }
位于存储部分的实现如下所示
public class StorageRepository : IStorageRepository { public void SavePerson(string filePath, IPerson person) { var personToSave = new StoragePerson(); personToSave.MergeCopy(person); File.AppendText("\r\n" + personToSave); } }
在这里,我们可以看到转换的魔力!
一些解释是必要的
存储库的功能是将我们的人员记录到文本文件中,但如您所见,参数是 IPerson
,这意味着传入的对象将不具备存储功能,无法按照上面解释的格式化字符串。
因此,我们需要将此 IPerson
转换为 StoragePerson
。这就是这两行代码的作用:
var personToSave = new StoragePerson(); personToSave.MergeCopy(person);
一旦我们拥有了 StoragePerson
,将其保存到所需文件中就变得轻而易举。
要获得相反的效果,也就是说,从反序列化的 StoragePerson
获得 BusinessPerson
,只需在业务部分使用的服务中执行相同的操作。
例如,如果我将我的存储库更改如下
public interface IStorageRepository { IPerson Load(Guid personId); void SavePerson(string filePath, IPerson person); }
假设存储部分实现了 Load
函数,我将在业务部分创建一个服务来处理存储库
public interface IBusinessService { BusinessPerson GetPerson(Guid id); void SavePerson(BusinessPerson person); } public class BusinessService : IBusinessService { private readonly IStorageRepository _storageRepository; public BusinessService(IStorageRepository storageRepository) { if (storageRepository == null) throw new ArgumentNullException("storageRepository"); _storageRepository = storageRepository; } public BusinessPerson GetPerson(Guid id) { var storageObject = _storageRepository.Load(id); BusinessPerson result = new BusinessPerson(); result.MergeCopy(storageObject); return result; } public void SavePerson(BusinessPerson person) { _storageRepository.SavePerson("d:\\database.txt",person); } }
如您在 `GetPerson()` 方法中看到的,我们从存储中检索一条记录,然后将其转换为 `BusinessPerson`。通过这种策略,我们的服务不仅能够操作 `BusinessObject`。
这个例子使我们建立了以下架构
多亏了完全解耦,我们可以想象几种替代方案,例如
- 用一个模拟对象替换存储部分,以测试业务端。
- 添加第二个并行存储库,将我们的对象保存到数据库而不是文本文件中。
- 通过模拟对象替换服务来测试业务部分的某些内部逻辑。
这种可塑性为软件架构师开辟了许多可能性。如果您对行为驱动开发感兴趣,它为您提供了使用完全虚拟数据库中的某些记录来创建具有测试场景的业务应用程序的机会。这保证了您的软件在任何时候都具有健壮性!
但这仍然是一个新话题...
回到主题!关于 MergeCopy 还有最后几件事要知道
- 对 `MergeId` 的良好管理不仅处理对象复制的情况,还管理指向其父级的递归子级。因此,不必担心其他复制系统中可能出现的无限循环。
- 作为您的中央系统架构,
MergeCopy
拥有多种优化系统,以防止复制过程拖慢您的应用程序。然而,过于庞大或过于复杂的对象如果经常处理,可能会对性能产生影响。请注意这一点。
我们还没有谈到的最后一件事是属性的复制顺序。
有时,为了保持控件的完整性,一个属性依赖于另一个属性,例如
public class BusinessAddress : Business, IAddress<BusinessGps> { private string _city; private string _country; private BusinessGps _gpsLocation; public string Street { get; set; } public string City { get { return _city; } set { if(string.IsNullOrWhiteSpace(Street)) throw new Exception("Define street before the city"); _city = value; } } public string Country { get { return _country; } set { if (string.IsNullOrWhiteSpace(City)) throw new Exception("Define city before the country"); _country = value; } } public BusinessGps GpsLocation { get { return _gpsLocation; } set { if( string.IsNullOrWhiteSpace(Street) || string.IsNullOrWhiteSpace(Country) || string.IsNullOrWhiteSpace(City) ) throw new Exception("define all the properties before the gps location"); _gpsLocation = value; } } }
这里描述的业务规则是:街道在城市之前填写,城市在国家之前填写,并且当所有数据都定义后,GPS 位置才被告知。
如果您不告知 **MergeCopy** 正确的顺序,它将不可避免地失败。
为了克服这个问题,**MergeCopy** 提供了一个名为 `[MergeCopy]` 的属性,用于放置在类的属性上。
此属性可用于多种方式
使用 Order 属性
通过此属性,您可以指定执行复制的精确顺序。多个属性可以具有相同的“order
”。示例
public class BusinessAddress : Business, IAddress<BusinessGps> { private string _city; private string _country; private BusinessGps _gpsLocation; [MergeCopy(Order = 1)] public string Street { get; set; } [MergeCopy(Order = 2)] public string City { get { return _city; } set { if(string.IsNullOrWhiteSpace(Street)) throw new Exception("Define street before the city"); _city = value; } } [MergeCopy(Order = 3)] public string Country { get { return _country; } set { if (string.IsNullOrWhiteSpace(City)) throw new Exception("Define city before the country"); _country = value; } } [MergeCopy(Order = 4)] public BusinessGps GpsLocation { get { return _gpsLocation; } set { if( string.IsNullOrWhiteSpace(Street) || string.IsNullOrWhiteSpace(Country) || string.IsNullOrWhiteSpace(City) ) throw new Exception("define all the properties before the gps location"); _gpsLocation = value; } } }
使用 StackBottom 属性
在某些情况下,您希望一个或多个属性首先或最后被复制。
**MergeCopy** 将对象的所有属性视为堆栈元素。通过使用 `StackBottom` 和 `Order`,您可以指定属性是在堆栈的顶部还是底部复制。
图表
我想你知道所有这些。
结论
这里介绍的方法已在一个医疗保健软件上实现,许多开发人员有机会在其上添加新模块。
通过 **MergeCopy** 带来的完全解耦,不同的团队可以在软件的多个方面工作,这些方面是单个业务功能的相同组件。
契约系统就像开发人员之间的一个规范,允许他们就必须提供的 API 达成一致。因此,任何对契约的修改都会在编译时被检测到,从而在 API 发生变化时促进开发人员之间的沟通。
然而,**MergeCopy** 并非万能。要充分利用它,您必须掌握所有模式,以实现一个良好的 SOLID 架构。
这个主题广阔且充满陷阱,但只有这样,您才能将软件推向市场,并让用户欣赏您提供的每一次更新。