開源地址:https://github.com/tangxuehua/enode
因為enode框架的思想是,一次修改只能新建或修改一個聚合根;那么,如果一個用戶請求要涉及多個聚合根的新建或修改該怎么辦呢?本文的目的就是要分析清楚這個問題在enode框架下是如何解決的。如果想直接通過看代碼的朋友,可以直接下載源代碼,源碼中共有三個例子,BankTransferSagaSample這個例子就是本文所用的例子。
Saga的由來
saga這個術語,可能很多人都還很陌生。saga的提出,最早是為了解決可能會長時間運行的分布式事務(long-running process)的問題。所謂long-running的分布式事務,是指那些企業業務流程,需要跨應用、跨企業來完成某個事務,甚至在事務流程中還需要有手工操作的參與,這類事務的完成時間可能以分計,以小時計,甚至可能以天計。這類事務如果按照事務的ACID的要求去設計,勢必造成系統的可用性大大的降低。試想一個由兩台服務器一起參與的事務,服務器A發起事務,服務器B參與事務,B的事務需要人工參與,所以處理時間可能很長。如果按照ACID的原則,要保持事務的隔離性、一致性,服務器A中發起的事務中使用到的事務資源將會被鎖定,不允許其他應用訪問到事務過程中的中間結果,直到整個事務被提交或者回滾。這就造成事務A中的資源被長時間鎖定,系統的可用性將不可接受。
而saga,則是一種基於補償的消息驅動的用於解決long-running process的一種解決方案。目標是為了在確保系統高可用的前提下盡量確保數據的一致性。還是上面的例子,如果用saga來實現,那就是這樣的流程:服務器A的事務先執行,如果執行順利,那么事務A就先行提交;如果提交成功,那么就開始執行事務B,如果事務B也執行順利,則事務B也提交,整個事務就算完成。但是如果事務B執行失敗,那事務B本身需要回滾,這時因為事務A已經提交,所以需要執行一個補償操作,將已經提交的事務A執行的操作作反操作,恢復到未執行前事務A的狀態。這樣的基於消息驅動的實現思路,就是saga。我們可以看出,saga是犧牲了數據的強一致性,僅僅實現了最終一致性,但是提高了系統整體的可用性。
CQRS架構下的Saga (Process Manager)
上面一段,我們知道了saga的由來,現在我們再看一下CQRS架構下,saga是用來做什么的。雖然都叫saga,但是實際上在CQRS架構下,人們往往用saga來解決DDD中多個聚合或多個bounded context之間的通信問題。DDD中有bounded context的概念。一個bounded context代表一個上下文邊界,一個bounded context中可能包含一個或多個聚合。而saga就是用來實現bounded context之間的通信,或者是聚合之間的通信。在經典DDD中,我們通常用領域服務來實現多個聚合的協調,並最終通過事務的方式來提交所有聚合的修改;這樣做的后果是,1:用到了事務;2.一個事務涉及了多個聚合的更改;這樣做沒什么不好,在條件允許的情況下(比如不會出現分布式事務的情況下或者並發修改的請求數不高的情況下),這樣做沒什么特別大的問題。唯一的問題是,這樣做會增加並發沖突的幾率。現在的web應用,往往都是多用戶在同時向系統發送各種處理請求,所以我們不難想到,一個事務中涉及到的聚合越多,那並發沖突的可能性就越高。不僅如此,如果你的聚合很大,包含了很多的子實體和很多的方法,那該聚合持久化時產生並發沖突的幾率也會相對較高;而系統的並發沖突將直接影響系統的可用性;所以,一般的建議是,我們應該盡量將聚合設計的小,且盡量一次只修改一個聚合;這樣我們就不需要事務,還能把並發沖突的可能性降到最低;當然,單個聚合持久化時也還會存在並發沖突,但這點相對很容易解決,因為單個聚合是數據一致性的最小單元,所以我們可以完全不需要事務,通過樂觀鎖就能解決並發覆蓋的問題;關於這個問題的討論,大家如果還有興趣或者想了解的更加深入,我推薦看一下Effective Aggregate Design這篇論文,共三個部分,其作者是《implementing domain-driven design》一書的作者。
所以,通過上面的分析,我們知道了“聚合應該設計的小,且一次修改只修改一個聚合”這樣一條不成文的原則。當然你一定有很多理由認為不應該這樣,歡迎大家討論。那么如果要遵循這樣的原則,那我們需要一種機制來解決多個聚合之間的通信的問題。在CQRS的架構下,人們也都把這種機制叫做saga,但因為這種CQRS架構下的saga的語義已經不是上面一段中介紹的saga了。所以,微軟也把cqrs架構下的saga叫做process manager,具體可以看一下微軟的一個CQRS架構的開源項目的例子;process manager這個名字我們應該很容易理解,即流程管理器。事實上,一個saga所做的事情就是和一個流程一樣的事情。只不過傳統的流程,都有一個流程定義,當用戶發起一個流程時,就會產生一個流程實例,該流程實例會嚴格按照流程定義的流向來進行流轉,驅動流程流轉的往往是人的操作,比如審批操作。而process manager,也是一個流程,只不過這個流程是由消息驅動的。一個典型的process manager會響應事件,然后產生新的命令去執行下一步操作。用過NServiceBus的人應該知道,NServiceBus中就內置了saga的機制,我們可以很輕松的利用它來實現分布式的消息驅動的long-running process;
如何用ENode框架來實現Saga
為了說明問題,我就以經典的銀行轉賬為例子來講解吧,因為轉賬的核心業務大家都很清楚,所以我們就沒有了業務上理解的不一致,我們就能專心思考如何實現的問題了。但是,為了便於下面的分析,我還是簡單定義一下本例中的銀行轉賬的核心業務流程。注意:實際的轉賬業務流程遠比我定義的復雜,我這里重點是為了分析如何實現一個會涉及多個聚合修改的的業務場景。核心業務描述如下:
- 兩個銀行賬號,一個是源賬號,一個是目標賬號;
- 用戶點擊確認轉賬按鈕后,指定數目的錢會從源賬號轉入到目標賬號;
- 整個轉賬過程有兩個階段:1)錢從源賬號轉出;2)錢轉入到目標賬號;如果一切順利,那轉賬流程就結束了;
- 如果源賬號的當前余額不足,則轉出操作會失敗,系統記錄錯誤日志,轉賬流程結束;
- 如果錢轉入到目標賬號時出現異常,則需要回滾源賬號已轉出的錢,同時記錄錯誤日志,回滾完成后,轉賬流程結束;
思路分析
- 通過上面的需求,我們知道,應該有一個聚合根,表示銀行賬號,我設計為BankAccount;BankAccount有轉出錢和轉入錢的行為職責。另外,根據上面第5條需求,BankAccount可能會有回滾轉出錢的行為職責;另外,當然一個銀行賬號還會有一個表示當前余額的狀態屬性;
- 由於我們是通過saga的思想來實現轉賬流程,那我們具體該如何設計此saga呢?saga在CQRS架構中的作用是響應事件,產生command,從而起到以事件消息驅動的原理來控制流程流轉的作用;轉賬流程如何才能結束會由saga來決定。那么saga要響應的事件哪里來呢?很明顯,就是從流程中涉及到的聚合根里來,本例就是響應BankAccount的事件;當BankAccount的轉出事件或轉入事件發生后,會被saga響應,然后saga會做出響應,決定下一步該怎么走。 saga是聚合根嗎?或者說saga屬於領域層的東西嗎?這個問題很重要,我覺得沒有明確的答案。而且我也沒有從各種資料上明確看到saga是屬於ddd中的應用層還是領域層還是其他層。以下是我個人的一些思考:
- 關於認為saga應該屬於領域層的原因的一些思考:和經典的DDD做類比,經典DDD的書本上,會有一個銀行轉賬的領域服務,該領域服務完成轉賬的核心業務邏輯;而一些外圍的邏輯,如記錄日志、發送郵件或短信通知等功能,則在外圍的應用層服務中做掉;所以按照這個理解,假如我們設計一個saga,來實現轉賬的核心業務邏輯,那我覺得saga也是一個聚合根。因為saga是一個流程,職責是控制轉賬的過程,它有一個全局唯一的流程ID(一次轉賬就會產生一個轉賬流程,流程ID是流程的全局唯一標識),屬於領域層,saga可以理解為是領域中對行為過程的建模。當然saga與普通的聚合根稍有區別,普通的聚合根我們通常是根據名詞法則去識別,而saga則是從交互行為或者流程的角度去識別;這點就好比經典DDD的領域模型中有聚合根和領域服務一樣,聚合根是數據的建模,領域服務是交互行為的建模;
- 關於認為saga不應該屬於領域層的原因的一些思考:按照saga在CQRS架構下的定義,它會接受響應event,然后發送command。而command是應用層的東西,所以就會導致domain層依賴於應用層,顯然不太合理;
- 關於認為saga不應該屬於應用層的原因的一些思考:因為saga是流程,且有流程狀態,有狀態就需要保存,這樣就變成應用層中的saga需要保存狀態,這種做法合理嗎?值得我們深思;另外,按照經典DDD的架構中對應用層的職責定義,應用層應該是很薄的,更加不會出現需要保存狀態的屬於應用層的對象;
- 通過上面第3點的一些討論,我個人會把saga設計在領域層,設計為聚合根,但是,我會對saga的實現做一些調整:1)saga聚合根不會直接響應事件,而是經過一個中間command來過度;2)saga聚合根也不會直接發送command,取而代之的是像普通聚合根一樣也產生事件,這種事件表達的意思是“發送某某command的意圖已下達”,然后外層的event handler接受到這樣的事件后,會發送對應的command給command service;這樣一來,saga聚合根就和普通的聚合根無任何差別,聽上去感覺很不可思議,稍后我們結合代碼一起看一下具體實現吧。
- 如果saga也是一個聚合根,那不是和BankAccount平級了,那BankAccount產生的事件如何傳遞給saga呢?顯然,我們還缺少一樣東西,就是需要把流程中涉及到修改的聚合根產生的事件傳遞給saga聚合根的event handler。這種event handler本身無業務邏輯,他們的職責是監聽聚合根產生的事件,然后將event轉化為command,然后將command發送到command service,從而最后通知到對應的saga,然后saga就開始處理該事件,比如決定接下來該如何處理;
代碼實現
BankAccount聚合根的設計
/// <summary>銀行賬號聚合根 /// </summary> [Serializable] public class BankAccount : AggregateRoot<Guid>, IEventHandler<AccountOpened>, //銀行賬戶已開 IEventHandler<Deposited>, //錢已存入 IEventHandler<TransferedOut>, //錢已轉出 IEventHandler<TransferedIn>, //錢已轉入 IEventHandler<TransferOutRolledback> //轉出已回滾 { /// <summary>賬號(卡號) /// </summary> public string AccountNumber { get; private set; } /// <summary>擁有者 /// </summary> public string Owner { get; private set; } /// <summary>當前余額 /// </summary> public double Balance { get; private set; } public BankAccount() : base() { } public BankAccount(Guid accountId, string accountNumber, string owner) : base(accountId) { RaiseEvent(new AccountOpened(Id, accountNumber, owner)); } /// <summary>存款 /// </summary> /// <param name="amount"></param> public void Deposit(double amount) { RaiseEvent(new Deposited(Id, amount, string.Format("向賬戶{0}存入金額{1}", AccountNumber, amount))); } /// <summary>轉出 /// </summary> /// <param name="targetAccount"></param> /// <param name="processId"></param> /// <param name="transferInfo"></param> public void TransferOut(BankAccount targetAccount, Guid processId, TransferInfo transferInfo) { //這里判斷當前余額是否足夠 if (Balance < transferInfo.Amount) { throw new Exception(string.Format("賬戶{0}余額不足,不能轉賬!", AccountNumber)); } RaiseEvent(new TransferedOut(processId, transferInfo, string.Format("{0}向賬戶{1}轉出金額{2}", AccountNumber, targetAccount.AccountNumber, transferInfo.Amount))); } /// <summary>轉入 /// </summary> /// <param name="sourceAccount"></param> /// <param name="processId"></param> /// <param name="transferInfo"></param> public void TransferIn(BankAccount sourceAccount, Guid processId, TransferInfo transferInfo) { RaiseEvent(new TransferedIn(processId, transferInfo, string.Format("{0}從賬戶{1}轉入金額{2}", AccountNumber, sourceAccount.AccountNumber, transferInfo.Amount))); } /// <summary>回滾轉出 /// </summary> /// <param name="processId"></param> /// <param name="transferInfo"></param> public void RollbackTransferOut(Guid processId, TransferInfo transferInfo) { RaiseEvent(new TransferOutRolledback(processId, transferInfo, string.Format("賬戶{0}取消轉出金額{1}", AccountNumber, transferInfo.Amount))); } void IEventHandler<AccountOpened>.Handle(AccountOpened evnt) { AccountNumber = evnt.AccountNumber; Owner = evnt.Owner; } void IEventHandler<Deposited>.Handle(Deposited evnt) { Balance += evnt.Amount; } void IEventHandler<TransferedOut>.Handle(TransferedOut evnt) { Balance -= evnt.TransferInfo.Amount; } void IEventHandler<TransferedIn>.Handle(TransferedIn evnt) { Balance += evnt.TransferInfo.Amount; } void IEventHandler<TransferOutRolledback>.Handle(TransferOutRolledback evnt) { Balance += evnt.TransferInfo.Amount; } }
轉賬流程TransferProcess聚合根的設計
/// <summary>轉賬流程狀態 /// </summary> public enum ProcessState { NotStarted, Started, TransferOutRequested, TransferInRequested, RollbackTransferOutRequested, Completed, Aborted } /// <summary>轉賬信息值對象,包含了轉賬的基本信息 /// </summary> [Serializable] public class TransferInfo { public Guid SourceAccountId { get; private set; } public Guid TargetAccountId { get; private set; } public double Amount { get; private set; } public TransferInfo(Guid sourceAccountId, Guid targetAccountId, double amount) { SourceAccountId = sourceAccountId; TargetAccountId = targetAccountId; Amount = amount; } } /// <summary>銀行轉賬流程聚合根,負責控制整個轉賬的過程,包括遇到異常時的回滾處理 /// </summary> [Serializable] public class TransferProcess : AggregateRoot<Guid>, IEventHandler<TransferProcessStarted>, //轉賬流程已開始 IEventHandler<TransferOutRequested>, //轉出的請求已發起 IEventHandler<TransferInRequested>, //轉入的請求已發起 IEventHandler<RollbackTransferOutRequested>, //回滾轉出的請求已發起 IEventHandler<TransferProcessCompleted>, //轉賬流程已正常完成 IEventHandler<TransferProcessAborted> //轉賬流程已異常終止 { /// <summary>當前轉賬流程狀態 /// </summary> public ProcessState State { get; private set; } public TransferProcess() : base() { } public TransferProcess(BankAccount sourceAccount, BankAccount targetAccount, TransferInfo transferInfo) : base(Guid.NewGuid()) { RaiseEvent(new TransferProcessStarted(Id, transferInfo, string.Format("轉賬流程啟動,源賬戶:{0},目標賬戶:{1},轉賬金額:{2}", sourceAccount.AccountNumber, targetAccount.AccountNumber, transferInfo.Amount))); RaiseEvent(new TransferOutRequested(Id, transferInfo)); } /// <summary>處理已轉出事件 /// </summary> /// <param name="transferInfo"></param> public void HandleTransferedOut(TransferInfo transferInfo) { RaiseEvent(new TransferInRequested(Id, transferInfo)); } /// <summary>處理已轉入事件 /// </summary> /// <param name="transferInfo"></param> public void HandleTransferedIn(TransferInfo transferInfo) { RaiseEvent(new TransferProcessCompleted(Id, transferInfo)); } /// <summary>處理轉出失敗的情況 /// </summary> /// <param name="transferInfo"></param> public void HandleFailedTransferOut(TransferInfo transferInfo) { RaiseEvent(new TransferProcessAborted(Id, transferInfo)); } /// <summary>處理轉入失敗的情況 /// </summary> /// <param name="transferInfo"></param> public void HandleFailedTransferIn(TransferInfo transferInfo) { RaiseEvent(new RollbackTransferOutRequested(Id, transferInfo)); } /// <summary>處理轉出已回滾事件 /// </summary> /// <param name="transferInfo"></param> public void HandleTransferOutRolledback(TransferInfo transferInfo) { RaiseEvent(new TransferProcessAborted(Id, transferInfo)); } void IEventHandler<TransferProcessStarted>.Handle(TransferProcessStarted evnt) { State = ProcessState.Started; } void IEventHandler<TransferOutRequested>.Handle(TransferOutRequested evnt) { State = ProcessState.TransferOutRequested; } void IEventHandler<TransferInRequested>.Handle(TransferInRequested evnt) { State = ProcessState.TransferInRequested; } void IEventHandler<RollbackTransferOutRequested>.Handle(RollbackTransferOutRequested evnt) { State = ProcessState.RollbackTransferOutRequested; } void IEventHandler<TransferProcessCompleted>.Handle(TransferProcessCompleted evnt) { State = ProcessState.Completed; } void IEventHandler<TransferProcessAborted>.Handle(TransferProcessAborted evnt) { State = ProcessState.Aborted; } }
響應BankAccount聚合根所發生的事件的event handler設計
/// <summary>事件訂閱者,用於監聽和響應銀行賬號聚合根產生的事件 /// </summary> public class BankAccountEventHandler : IEventHandler<AccountOpened>, //銀行賬戶已開 IEventHandler<Deposited>, //錢已存入 IEventHandler<TransferedOut>, //錢已轉出 IEventHandler<TransferedIn>, //錢已轉入 IEventHandler<TransferOutRolledback> //轉出已回滾 { private ICommandService _commandService; public BankAccountEventHandler(ICommandService commandService) { _commandService = commandService; } void IEventHandler<AccountOpened>.Handle(AccountOpened evnt) { Console.WriteLine(string.Format("創建銀行賬戶{0}", evnt.AccountNumber)); } void IEventHandler<Deposited>.Handle(Deposited evnt) { Console.WriteLine(evnt.Description); } void IEventHandler<TransferedOut>.Handle(TransferedOut evnt) { Console.WriteLine(evnt.Description); //響應已轉出事件,發送“處理已轉出事件”的命令 _commandService.Send(new HandleTransferedOut { ProcessId = evnt.ProcessId, TransferInfo = evnt.TransferInfo }); } void IEventHandler<TransferedIn>.Handle(TransferedIn evnt) { Console.WriteLine(evnt.Description); //響應已轉入事件,發送“處理已轉入事件”的命令 _commandService.Send(new HandleTransferedIn { ProcessId = evnt.ProcessId, TransferInfo = evnt.TransferInfo }); } void IEventHandler<TransferOutRolledback>.Handle(TransferOutRolledback evnt) { Console.WriteLine(evnt.Description); //響應轉出已回滾事件,發送“處理轉出已回滾事件”的命令 _commandService.Send(new HandleTransferOutRolledback { ProcessId = evnt.ProcessId, TransferInfo = evnt.TransferInfo }); } }
響應TransferProcess聚合根所發生的事件的event handler設計
/// <summary>事件訂閱者,用於監聽和響應轉賬流程聚合根產生的事件 /// </summary> public class TransferProcessEventHandler : IEventHandler<TransferProcessStarted>, //轉賬流程已開始 IEventHandler<TransferOutRequested>, //轉出的請求已發起 IEventHandler<TransferInRequested>, //轉入的請求已發起 IEventHandler<RollbackTransferOutRequested>, //回滾轉出的請求已發起 IEventHandler<TransferProcessCompleted>, //轉賬流程已完成 IEventHandler<TransferProcessAborted> //轉賬流程已終止 { private ICommandService _commandService; public TransferProcessEventHandler(ICommandService commandService) { _commandService = commandService; } void IEventHandler<TransferProcessStarted>.Handle(TransferProcessStarted evnt) { Console.WriteLine(evnt.Description); } void IEventHandler<TransferOutRequested>.Handle(TransferOutRequested evnt) { //響應“轉出的命令請求已發起”這個事件,發送“轉出”命令 _commandService.Send(new TransferOut { ProcessId = evnt.ProcessId, TransferInfo = evnt.TransferInfo }, (result) => { //這里是command的異步回調函數,如果有異常,則發送“處理轉出失敗”的命令 if (result.Exception != null) { Console.WriteLine(result.Exception.Message); _commandService.Send(new HandleFailedTransferOut { ProcessId = evnt.ProcessId, TransferInfo = evnt.TransferInfo }); } }); } void IEventHandler<TransferInRequested>.Handle(TransferInRequested evnt) { //響應“轉入的命令請求已發起”這個事件,發送“轉入”命令 _commandService.Send(new TransferIn { ProcessId = evnt.ProcessId, TransferInfo = evnt.TransferInfo }, (result) => { //這里是command的異步回調函數,如果有異常,則發送“處理轉入失敗”的命令 if (result.Exception != null) { Console.WriteLine(result.Exception.Message); _commandService.Send(new HandleFailedTransferIn { ProcessId = evnt.ProcessId, TransferInfo = evnt.TransferInfo }); } }); } void IEventHandler<RollbackTransferOutRequested>.Handle(RollbackTransferOutRequested evnt) { //響應“回滾轉出的命令請求已發起”這個事件,發送“回滾轉出”命令 _commandService.Send(new RollbackTransferOut { ProcessId = evnt.ProcessId, TransferInfo = evnt.TransferInfo }); } void IEventHandler<TransferProcessCompleted>.Handle(TransferProcessCompleted evnt) { Console.WriteLine("轉賬流程已正常完成!"); } void IEventHandler<TransferProcessAborted>.Handle(TransferProcessAborted evnt) { Console.WriteLine("轉賬流程已異常終止!"); } }
BankAccount聚合根相關的command handlers
/// <summary>銀行賬戶相關命令處理 /// </summary> public class BankAccountCommandHandlers : ICommandHandler<OpenAccount>, //開戶 ICommandHandler<Deposit>, //存錢 ICommandHandler<TransferOut>, //轉出 ICommandHandler<TransferIn>, //轉入 ICommandHandler<RollbackTransferOut> //回滾轉出 { public void Handle(ICommandContext context, OpenAccount command) { context.Add(new BankAccount(command.AccountId, command.AccountNumber, command.Owner)); } public void Handle(ICommandContext context, Deposit command) { context.Get<BankAccount>(command.AccountId).Deposit(command.Amount); } public void Handle(ICommandContext context, TransferOut command) { var sourceAccount = context.Get<BankAccount>(command.TransferInfo.SourceAccountId); var targetAccount = context.Get<BankAccount>(command.TransferInfo.TargetAccountId); sourceAccount.TransferOut(targetAccount, command.ProcessId, command.TransferInfo); } public void Handle(ICommandContext context, TransferIn command) { var sourceAccount = context.Get<BankAccount>(command.TransferInfo.SourceAccountId); var targetAccount = context.Get<BankAccount>(command.TransferInfo.TargetAccountId); targetAccount.TransferIn(sourceAccount, command.ProcessId, command.TransferInfo); } public void Handle(ICommandContext context, RollbackTransferOut command) { context.Get<BankAccount>(command.TransferInfo.SourceAccountId).RollbackTransferOut(command.ProcessId, command.TransferInfo); } }
TransferProcess聚合根相關的command handlers
/// <summary>銀行轉賬流程相關命令處理 /// </summary> public class TransferProcessCommandHandlers : ICommandHandler<StartTransfer>, //開始轉賬 ICommandHandler<HandleTransferedOut>, //處理已轉出事件 ICommandHandler<HandleTransferedIn>, //處理已轉入事件 ICommandHandler<HandleFailedTransferOut>, //處理轉出失敗 ICommandHandler<HandleFailedTransferIn>, //處理轉入失敗 ICommandHandler<HandleTransferOutRolledback> //處理轉出已回滾事件 { public void Handle(ICommandContext context, StartTransfer command) { var sourceAccount = context.Get<BankAccount>(command.TransferInfo.SourceAccountId); var targetAccount = context.Get<BankAccount>(command.TransferInfo.TargetAccountId); context.Add(new TransferProcess(sourceAccount, targetAccount, command.TransferInfo)); } public void Handle(ICommandContext context, HandleTransferedOut command) { context.Get<TransferProcess>(command.ProcessId).HandleTransferedOut(command.TransferInfo); } public void Handle(ICommandContext context, HandleTransferedIn command) { context.Get<TransferProcess>(command.ProcessId).HandleTransferedIn(command.TransferInfo); } public void Handle(ICommandContext context, HandleFailedTransferOut command) { context.Get<TransferProcess>(command.ProcessId).HandleFailedTransferOut(command.TransferInfo); } public void Handle(ICommandContext context, HandleFailedTransferIn command) { context.Get<TransferProcess>(command.ProcessId).HandleFailedTransferIn(command.TransferInfo); } public void Handle(ICommandContext context, HandleTransferOutRolledback command) { context.Get<TransferProcess>(command.ProcessId).HandleTransferOutRolledback(command.TransferInfo); } }
上面的代碼中都加了詳細的注釋,有不清楚的,直接回復問我吧,呵呵。
