银行转账业务场景的几种实现思路对比

前言 ps: 由于本篇文章是我早期所写,文中的思想已经和我现在的想法完全不同了。目前我所理解的领域模型,是被调用的,应用层使用领域模型,调用领域模型中的各种构造块完成用例场景。另外,关于银行转账,我们还可以使用另一种更好的实现方案,即最终一致性的方案,通过事件驱动的流程的方式来实现转账。具体实现见ENode框架中的BankTransferSample中的代码实现:https://github.com/tangxuehua/enode
这篇文章希望通过一个银行转账的例子来和大家分享一些我最近想到的关于如何组织业务逻辑的心得和体会。当然,本人的能力和领悟有限,如有不正确之处,还希望各位看官能帮我指出来。我始终坚持一个信念,没有讨论就没有进步,任何一个非盈利为目的的人或组织始终应该把自己所学的知识共享出来与人讨论,这样不管对自己或对他人或对整个社会都大有好处。因为一个人的知识毕竟是有限的,但可以(并且也只能)和别人相互沟通交流学习来弥补这个缺陷。
转账过程简单描述 银行转账的核心业务逻辑大家应该都很熟悉了,主要有这么几步:

  1. 源账户扣除转账金额,当然首先需要先判断源账户余额是否足够,如果不够,则无法转账;
  2. 目标账户增加转账金额;
  3. 为源账户生成一笔转账记录;
  4. 为目标账户生成一笔转账记录;
下面让我们来看看各种实现该业务场景的方法,并且来做一个对比。 事务脚本(Transaction Script、贫血模型)【银行转账业务场景的几种实现思路对比】这种方法的优缺点网上找一下一大堆,我这里也啰嗦列举一些:
  1. 容易理解,符合我们大脑过程化思考的习惯;
  2. 完全没有面向对象的思想,纯粹是面向过程式的一种组织业务逻辑的方式,所有的业务逻辑全部在一个方法中完成;
  3. 对象只包含数据而没有行为,对象只是用来被操作的“数据”,一般我们会设计很多的Item,以及ItemManager;
  4. 结构层次比较清晰,业务逻辑层和其他各层之间单项依赖;业务逻辑层中Item只代表数据,ItemManager则负责所有的业务逻辑实现,ItemManager只依赖于IDAL接口来完成持久化Item或重建Item;
  5. 由于所有的业务逻辑全部写在一个方法内,如果有另外一个需求也需要类似的业务逻辑,通常我们是写一个新的方法来实现,这样就很容易导致相同的业务逻辑出现在两个方法中,导致可维护性降低;虽然可以用一些重构的技巧或设计模式来解决重用的问题,但这往往需要开发人员具有很高的编码水平,并且往往很多时候因为时间紧迫导致不允许我们花很多时间去重构;
  6. 如果业务逻辑一旦改变,我们必须去修改实现该业务逻辑的方法,并且如果该业务逻辑在多个方法中出现,我们必须同时修改多个方法;
演示代码:
银行转账业务场景的几种实现思路对比
文章图片
1public class BankAccountManager
2 {
3private IBankAccountDAL bankAccountDAL;
4
5public BankAccountManager(IBankAccountDAL bankAccountDAL)
6 {
7this .bankAccountDAL = bankAccountDAL;
8 }
9
10///
11/// 该方法完成转账业务逻辑
12///
13public void TransferMoney(Guid fromBankAccountId, Guid toBankAccountId, double moneyAmount)
14 {
15 var fromBankAccount = bankAccountDAL.GetById(fromBankAccountId);
16 var toBankAccount = bankAccountDAL.GetById(toBankAccountId);
17if (fromBankAccount.MoneyAmount < moneyAmount)
18 {
19throw new NotSupportedException( " 账户余额不足。 " );
20 }
21 fromBankAccount.MoneyAmount -= moneyAmount;
22 toBankAccount.MoneyAmount += moneyAmount;
23
24 DateTime transferDate = DateTime.Now;
25 fromBankAccount.TransferHistories.Add( new TransferHistory
26 {
27 FromAccountId = fromBankAccountId,
28 ToAccountId = toBankAccountId,
29 MoneyAmount = moneyAmount,
30 TransferDate = transferDate
31 });
32 toBankAccount.TransferHistories.Add( new TransferHistory
33 {
34 FromAccountId = fromBankAccountId,
35 ToAccountId = toBankAccountId,
36 MoneyAmount = moneyAmount,
37 TransferDate = transferDate
38 });
39 }
40 }
41///
42/// 银行帐号
43///
44public class BankAccount
45 {
46public BankAccount() { this .TransferHistories = new List < TransferHistory > (); }
47public Guid Id { get ; set ; }
48public double MoneyAmount { get ; set ; }
49public IList < TransferHistory > TransferHistories { get ; set ; }
50 }
51///
52/// 转账记录
53///
54public class TransferHistory
55 {
56public Guid FromAccountId { get ; set ; }
57public Guid ToAccountId { get ; set ; }
58public double MoneyAmount { get ; set ; }
59public DateTime TransferDate { get ; set ; }
60 }
61public interface IBankAccountDAL
62 {
63 BankAccount GetById(Guid bankAccountId);
64 } 银行转账业务场景的几种实现思路对比
文章图片
Evans DDD(充血模型) 这种方法的特点在网上也可以找到很多,但我也有一些其他自己的看法,见红色字体的部分:
  1. 基本是一种基于OO思想的开发方法,对象既有属性也有行为,对象之间通过相互引用和方法调用来完成对象之间的交互;
  2. 由于这是一种OO思想的设计方法,所以各种设计原则和模式都可以被充分利用;
  3. Evans对这种开发方法又作了进一步的完善,提出了:聚合、实体、值对象、服务、工厂、仓储、上下文,等这些概念;这确保我们在基于OO的思想组织业务逻辑时有了很好的指导思想;
  4. 需要特别指出的一点是,真正的Evans的DDD领域模型中的聚合根所内聚的所有值对象应该都是只读的,这一点特别重要。
  5. 基于Evans DDD的CQRS架构。这种架构的主要思想是将命令和查询分离,另一个重要的特点就是事件溯源,意思是领域对象不需要有公共的属性,只需要有行为即可,并且在任何一个行为发生后,都会触发一个事件。然后我们持久化的不是对象的状态,而是引起该对象状态改变的所有的事件。当我们需要重建一个领域对象时,只要先创建一个干净的只有唯一标识的对象,然后把和该对象相关的所有领域事件全部重新执行一遍,这样我们就得到了该对象的最终的状态了。说的简单点,就是我们不保存对象本身,而是只保存该对象的操作历史(或者叫操作日志),当我们需要重建该对象时只要”重演历史“即可。当然,为了避免性能的问题,比如因为一个对象可能会有很多的操作历史,如果每次重建该对象都是从头开始应用每个事件,那效率无疑是非常低的。因此我们使用了快照,快照保存了对象某个时刻的二进制形式(即被序列化过了)的状态。所以通常情况下,当我们要重建一个对象时都是从某个最近的快照开始回溯发生在快照之后的事件。
  6. 不管是Evans的DDD也好,CQRS架构也好,虽然都做到了让领域对象不仅有状态,而且有行为,但我觉得这还不够彻底。因为对象的行为总是“被调用”的,当现在有一个业务逻辑需要调用多个对象的一些行为来完成时,我们往往会一个一个地将对象从仓储中取出来,然后调用它们的方法。虽然Evans提出了领域服务(Service)的概念,并将一个领域对象不能完成的事情交给了领域服务去完成。但领域服务内部还是在一个个的取出对象然后调用它们的方法。这个做法在我看来和凭血模型没有本质区别,还是没有真正做到OO。因为贫血模型的情况下,对象是提供了数据让别人去操作或者说被别人使用;而充血模型的情况下,对象则是提供了数据和行为,但还是让别人去操作或者说被别人使用(数据被别人使用或方法被别人调用都是“被别人操作”的一种被动的方式)。所以从这个意义上来看对象时,我觉得贫血模型和充血模型没有本质区别。
下面也给出一个实现了银行转账业务逻辑的充血模型实现:
银行转账业务场景的几种实现思路对比
文章图片
1 ///
2/// 银行帐号, 它是一个Evans DDD中的实体, 并且是聚合根
3///
4public class BankAccount
5 {
6private IList < TransferHistory > transferHistories;
7
8public BankAccount() : this (Guid.NewGuid(), 0D, new List < TransferHistory > ()) { }
9public BankAccount(Guid id, double moneyAmount, IList < TransferHistory > transferHistories)
10 {
11this .Id = id;
12this .MoneyAmount = moneyAmount;
13this .transferHistories = transferHistories;
14 }
15public Guid Id { get ; private set ; }
16public double MoneyAmount { get ; private set ; }
17public IList < TransferHistory > TransferHistories
18 {
19get
20 {
21return transferHistories.ToList().AsReadOnly();
22 }
23 }
24
25public void TransferTo(Guid toBankAccountId, double moneyAmount, DateTime transferDate)
26 {
27if ( this .MoneyAmount < moneyAmount)
28 {
29throw new NotSupportedException( " 账户余额不足。 " );
30 }
31this .MoneyAmount -= moneyAmount;
32this .TransferHistories.Add(
33new TransferHistory( this .Id, toBankAccountId, moneyAmount, transferDate));
34 }
35public void TransferFrom(Guid fromBankAccountId, double moneyAmount, DateTime transferDate)
36 {
37this .MoneyAmount += moneyAmount;
38this .TransferHistories.Add(
39new TransferHistory(fromBankAccountId, this .Id, moneyAmount, transferDate));
40 }
41 }
42///
43/// 转账记录, 它是一个Evans DDD中的值对象
44///
45public class TransferHistory
46 {
47public TransferHistory(Guid fromAccountId,
48 Guid toAccountId,
49double moneyAmount,
50 DateTime transferDate)
51 {
52this .FromAccountId = fromAccountId;
53this .ToAccountId = toAccountId;
54this .MoneyAmount = moneyAmount;
55this .TransferDate = transferDate;
56 }
57
58public Guid FromAccountId { get ; private set ; }
59public Guid ToAccountId { get ; private set ; }
60public double MoneyAmount { get ; private set ; }
61public DateTime TransferDate { get ; private set ; }
62 }
63///
64/// BankAccount聚合根对应的仓储
65///
66public interface IBankAccountRepository
67 {
68 BankAccount GetBankAccount(Guid bankAccountId);
69 }
70///
71/// 转账服务, 它是一个Evans DDD中的领域服务
72///
73public class BankAccountService
74 {
75private IBankAccountRepository bankAccountRepository;
76
77public BankAccountService(IBankAccountRepository bankAccountRepository)
78 {
79this .bankAccountRepository = bankAccountRepository;
80 }
81
82///
83/// 该方法完成转账业务逻辑
84///
85public void TransferMoney(Guid fromBankAccountId, Guid toBankAccountId, double moneyAmount)
86 {
87 var fromBankAccount = bankAccountRepository.GetBankAccount(fromBankAccountId);
88 var toBankAccount = bankAccountRepository.GetBankAccount(toBankAccountId);
89
90 DateTime transferDate = DateTime.Now;
91 fromBankAccount.TransferTo(toBankAccountId, moneyAmount, transferDate);
92 toBankAccount.TransferFrom(fromBankAccountId, moneyAmount, transferDate);
93 }
94 } 银行转账业务场景的几种实现思路对比
文章图片
基于事件驱动(EDA)的设计 这是一种根据我自己的想法而设计出来的一种设计与实现,但是离我理想中的设计还有一些距离。在我看来,真正理想的组织业务逻辑的方法或者说模型应该是这样的:
  1. 当外界需要领域逻辑的“实现模型”(简称领域模型)做某件事情时,会发出一个命令,这个命令可以理解为一个消息或者是一个事件。消息一旦创建出来后就是只读的,因为消息从某种程度上来说就是历史;
  2. 领域模型中的相关领域对象会主动响应该消息;
  3. 需要特别指出的是:我们不可以自己去获取一些相关的领域对象,然后进一步调用它们的方法而实现响应;而是应该所有可能被用到的领域对象必须好像永远已经存在于内存一样的永远在不停的在等待消息并作出响应。以银行转账作为例子,外界发出一个转账的消息,该消息会包含源帐号唯一标识、目标帐号唯一标识、转账金额这些信息。该消息的目的是希望两个两个银行帐号之间能进行转账。好了,外界要做的仅仅是发出这条消息即可。那么领域模型内部该如何去响应该消息呢?一种方法是将两个银行帐号先取出来,然后调用它们的转账方法(如TransferTo方法和TransferFrom方法)以实现转账的目的,前面的Evans的DDD的例子就是这样实现的。但这样做已经违反了我前面所说的理想的情况了。我的理想要求是,这两个银行帐号对象会像已经存在于内存一样可以直接主动去响应转账的消息,而不是转账的那两个方法(TransferTo方法和TransferFrom方法)被我们自己定义的领域服务所调用。
  4. 更加需要着重强调的是,我始终认为,真正的面向对象编程中的对象应该是一个”活“的具有主观能动性的存在于内存中的客观存在,它们不仅有状态而且还有自主行为。这里需要从两方面来解释:1)对象的状态可以表现出来被别人看到,但是必须是只读的,没有人可以直接去修改一个对象的状态,因为对象是一个在内存中的有主观意识的客观存在,它的状态必须是由它自己的行为导致自己的状态的改变。就好像现实生活中的动物或人一样,我不能强制你做什么事情,一定是我通知你(即发送消息给你),你才会做出响应并改变你自己的状态。2)对象的行为就是对象所具有的某种功能。对象的行为本质上应该是对某个消息的主动响应,这里强调的是主动,就是说对象的行为不可以被别人使用,而只能自己主动的去表现出该行为。另外,行为可以表现出来给别人看到,也可以不表现出来给别人看到。实际上,我们永远都不需要将对象的行为表现出来给别人看到,原因是别人不会去使用该行为的,行为永远只能是对象自己去表现出来。
  5. 领域模型这个生态系统中的各个领域对象在运行过程中如果需要和领域模型之外的东西(如数据持久层)交互,也应该通过消息来进行,因为只有这样才能确保领域对象是一个”活“的具有主观能动性的存在于内存中的客观存在。
以上就是我心目中理想的如何设计对象来实现业务逻辑的方式。我想了很久,要完全实现上面的目标实在是太困难了。但也不是不可能,我按照我的能力,经过不断的设计、编码、测试、重构的反复循环过程。基本上设计出了一个令自己基本满意的基础框架出来,基于该框架,以银行转账为例子,我们可以以如下的方式来实现:
银行转账业务场景的几种实现思路对比
文章图片
1public class TransferEvent : DomainEvent
2 {
3public TransferEvent(Guid fromBankAccountId, Guid toBankAccountId, double moneyAmount, DateTime transferDate)
4 {
5this .FromBankAccountId = fromBankAccountId;
6this .ToBankAccountId = toBankAccountId;
7this .MoneyAmount = moneyAmount;
8this .TransferDate = transferDate;
9 }
10public Guid FromBankAccountId { get ; private set ; }
11public Guid ToBankAccountId { get ; private set ; }
12public double MoneyAmount { get ; private set ; }
13public DateTime TransferDate { get ; private set ; }
14 }
15public class BankAccount : DomainObject < Guid >
16 {
17#region Private Variables
18
19private List < TransferHistory > transferHistories;
20
21#endregion
22
23#region Constructors
24
25public BankAccount(Guid customerId)
26 : this (customerId, 0D, new List < TransferHistory > ())
27 {
28 }
29public BankAccount(Guid customerId, double moneyAmount, IEnumerable < TransferHistory > transferHistories)
30 : base (Guid.NewGuid())
31 {
32this .CustomerId = customerId;
33this .MoneyAmount = moneyAmount;
34this .transferHistories = new List < TransferHistory > (transferHistories);
35 }
36
37#endregion
38
39#region Public Properties
40
41public Guid CustomerId { get ; private set ; }
42 [TrackingProperty]
43public IEnumerable < TransferHistory > TransferHistories
44 {
45get
46 {
47return transferHistories.AsReadOnly();
48 }
49 }
50 [TrackingProperty]
51public double MoneyAmount { get ; private set ; }
52
53#endregion
54
55#region Event Handlers
56
57private void TransferTo(TransferEvent evnt)
58 {
59if ( this .Id == evnt.FromBankAccountId)
60 {
61 DecreaseMoney(evnt.MoneyAmount);
62 transferHistories.Add(
63new TransferHistory(
64 evnt.FromBankAccountId,
65 evnt.ToBankAccountId,
66 evnt.MoneyAmount,
67 evnt.TransferDate));
68 }
69 }
70private void TransferFrom(TransferEvent evnt)
71 {
72if ( this .Id == evnt.ToBankAccountId)
73 {
74 IncreaseMoney(evnt.MoneyAmount);
75 transferHistories.Add(
76new TransferHistory(
77 evnt.FromBankAccountId,
78 evnt.ToBankAccountId,
79 evnt.MoneyAmount,
80 evnt.TransferDate));
81 }
82 }
83
84#endregion
85
86#region Private Methods
87
88private void DecreaseMoney( double moneyAmount)
89 {
90if ( this .MoneyAmount < moneyAmount)
91 {
92throw new NotSupportedException( " 账户余额不足。 " );
93 }
94this .MoneyAmount -= moneyAmount;
95 }
96private void IncreaseMoney( double moneyAmount)
97 {
98this .MoneyAmount += moneyAmount;
99 }
100
101#endregion
102 }
103public class TransferHistory : ValueObject
104 {
105#region Constructors
106
107public TransferHistory(Guid fromAccountId,
108 Guid toAccountId,
109double moneyAmount,
110 DateTime transferDate)
111 {
112this .FromAccountId = fromAccountId;
113this .ToAccountId = toAccountId;
114this .MoneyAmount = moneyAmount;
115this .TransferDate = transferDate;
116 }
117
118#endregion
119
120#region Public Properties
121
122public Guid FromAccountId { get ; private set ; }
123public Guid ToAccountId { get ; private set ; }
124public double MoneyAmount { get ; private set ; }
125public DateTime TransferDate { get ; private set ; }
126
127#endregion
128
129#region Infrastructure
130
131protected override IEnumerable < object > GetAtomicValues()
132 {
133yield return FromAccountId;
134yield return ToAccountId;
135yield return MoneyAmount;
136yield return TransferDate;
137 }
138
139#endregion
140 } 银行转账业务场景的几种实现思路对比
文章图片
以上代码是转账事件、银行帐号(实体),以及转账记录(值对象)的实现代码,然后我们可以通过如下的方式来触发TransferEvent事件来让银行帐号”自动“响应。
1 EventProcesser.ProcessEvent( new TransferEvent(bankAccount1.Id, bankAccount2.Id, 1000 , DateTime.Now)); 如果不需要增加其他的任何代码就OK了的话,那可就真美了,应该差不多可以实现我上面的目标了。但理想终归是理想,而现实的情况是:
1)领域对象的行为不可能做到别人不去调用它就能自己主动表现出来的地步,毕竟它不是一个真正的”活“的有主观能动性的人或动物;
2)领域对象并没有存在于内存中,而是在数据持久化介质中,如数据库,因此我们必须去把领域对象从数据库取出来;
那么难道我们只能放弃了吗?只能自己去做这两件事情了吗?不是,我们可以告诉基础框架如下一些信息,有了这些信息,基础框架就可以帮助我们完成上面的这两件事情了。
银行转账业务场景的几种实现思路对比
文章图片
1 RegisterObjectEventMappingItem < TransferEvent, BankAccount > (
2new GetDomainObjectIdEventHandlerInfo < TransferEvent >
3 {
4 GetDomainObjectId = evnt => evnt.FromBankAccountId,
5 EventHandlerName = " TransferTo "
6 },
7new GetDomainObjectIdEventHandlerInfo < TransferEvent >
8 {
9 GetDomainObjectId = evnt => evnt.ToBankAccountId,
10 EventHandlerName = " TransferFrom "
11 }
12 ); 银行转账业务场景的几种实现思路对比
文章图片
上面的代码的意思是告诉框架1)BankAccount会去响应TransferEvent事件;2)BankAccount对象的唯一标识是从TransferEvent事件中的哪个属性中来的;3)因为这里BankAccount会有两个方法可能会响应TransferEvent事件,所以还指定了响应方法的名字从而可以区分。当然一般情况下,我们是不需要指定方法的名字的,因为大部分情况下一个对象对同一个事件只会有一个响应方法。比如下面的代码列出了很多中常见的事件与响应对象的映射信息:
银行转账业务场景的几种实现思路对比
文章图片
1public class DomainLayerObjectEventMapping : ObjectEventMapping
2 {
3protected override void InitializeObjectEventMappingItems()
4 {
5// BankAccount Event Mappings.
6 RegisterObjectEventMappingItem < DepositAccountMoneyEvent, BankAccount > (evnt => evnt.BankAccountId);
7 RegisterObjectEventMappingItem < WithdrawAccountMoneyEvent, BankAccount > (evnt => evnt.BankAccountId);
8 RegisterObjectEventMappingItem < TransferEvent, BankAccount > (
9new GetDomainObjectIdEventHandlerInfo < TransferEvent >
10 {
11 GetDomainObjectId = evnt => evnt.FromBankAccountId,
12 EventHandlerName = " TransferTo "
13 },
14new GetDomainObjectIdEventHandlerInfo < TransferEvent >
15 {
16 GetDomainObjectId = evnt => evnt.ToBankAccountId,
17 EventHandlerName = " TransferFrom "
18 }
19 );
20
21// Topic Event Mappings.
22 RegisterObjectEventMappingItem < DomainObjectAddedEvent < Reply > , Topic > (evnt => evnt.DomainObject.TopicId);
23 RegisterObjectEventMappingItem < DomainObjectRemovedEvent < Reply > , Topic > (evnt => evnt.DomainObject.TopicId);
24
25// ForumUser Event Mappings.
26 RegisterObjectEventMappingItem < PreAddDomainObjectEvent < Topic > , ForumUser > (evnt => evnt.DomainObject.CreatedBy);
27 RegisterObjectEventMappingItem < DomainObjectAddedEvent < Topic > , ForumUser > (evnt => evnt.DomainObject.CreatedBy);
28
29// Reply Event Mappings.
30 RegisterObjectEventMappingItem < DomainObjectRemovedEvent < Topic > , Reply > (evnt => Repository.Find < Reply > ( new FindTopicRepliesEvent(evnt.DomainObject.Id)));
31 }
32 } 银行转账业务场景的几种实现思路对比
文章图片
关于这种组织业务逻辑的方法,大家如果有仔细研究的兴趣,可以下载我的框架源代码和聚合演示例子源代码。
http://files.cnblogs.com/netfocus/EventBasedDDDExample.rar 好了,大家觉得这三种组织业务逻辑的方法如何呢?很想听听大家的声音。我是一个喜欢思考问题、寻找真理的人,期望能和大家多多交流。

    推荐阅读