Aoite 系列(04) - 強勁的 CommandModel 開發模式(上篇)


Aoite 是一個適於任何 .Net Framework 4.0+ 項目的快速開發整體解決方案。Aoite.CommandModel 是一種開發模式,我把它成為“命令模型”,這是一種非常有意思的開發模式。

【Aoite 系列 目錄】

趕緊加入 Aoite GitHub 的大家庭吧!!

1. 概述

CommandModel 的架構並不復雜,核心四大組件分別是:命令(Command)、執行器(Executor)、上下文(Context)和事件(Event)。

CommandModel 核心是剝離所有運行期的所有依賴,注入執行。它可以運用至傳統的三層架構,也可以運用到 DDD(CQRS)架構。

不是只能應用到三層架構,只是以最傳統最簡單的三層架構作為比較。CommandModel 支持任何架構、模式,無論是 Web 和 Winform,亦或者 ASP.NET 和 MVC,亦或者三層架構或領域驅動。請看官不要糾結這些問題。

傳統三層架構是這樣的(實體層意義上包含 數據庫實體視圖模型 ):

傳統三層架構

如果將 CommandModel 加入三層架構,那么它將變成以下架構:

CommandModel+三層架構

注入 CommandModel 模式以后,原本的數據訪問層不見了,變成了命令層,而命令層是由一個或多個命令(以及對應的一個或多個執行器)組成的集合。

也就是說,CommandModel 其實是將數據訪問層進行粒度分解

CommandModel 的優點:

  • 簡化單元測試工作量。傳統三層架構(或延伸的各種結構),在單元測試模擬時,往往需要實現整個接口。通過 CommandModel 可以實現非常細粒度的單元測試。
  • 基於服務容器的依賴注入。可以針對每個命令的執行前和執行后進行攔截處理。
  • 支持命令級的緩存。例如:獲取積分排行前十的用戶列表。
  • 支持命令集級的事務。

1.1 命令(Command)###

命令是一個符合單一職責的設計原則。通過命令的名稱(Name)、參數(Properties)和返回結果(Result),它應該非常直觀的表達出命令的目的。比如“查詢用戶編號為?的用戶信息”,這就是一個典型的命令。

以下代碼則是一個典型的命令(命令的名稱可以以 Command 結尾,也可以不以 Command 結尾,這並非強制性的規則,並且兩種方式都支持):

public class FindUserById : ICommand<User>
{
	//- 輸入參數
    public long Id { get; set; }
	//- 輸出參數
    public User ResultValue { get; set; }
}

具有返回值的命令實現 ICommand<TResultValue> 接口,沒有返回值則直接實現 ICommand 接口

1.2 執行器(Executor)###

如果把命令比作一個方法簽名,顯然執行器對應的則是方法實現。從這個角度來看,命令(Command)和執行器(Executor)是相互依賴的。

執行器是單例模式。一個命令若執行了無數次,執行器只會初始化一次。

比如對應 1.1 節代碼的執行器應該是這樣:

public class FindUserByIdExecutor : IExecutor<FindUserById>
{
    public void Execute(IContext context, FindUserById command)
    {
        //- 業務代碼, context 和 command 參數永不為 null 值
    }
}

每一個執行器都必須實現 IExecutor<TCommand> 接口。

如果該命令具有返回值,方法實現內部應該有 command.ResultValue = ... 的代碼。

關於命令和執行器是如何綁定關系,請往下查看第 2 節的內容。

1.3 上下文(Context)

上下文在每一次命令的執行都會產生新的實例。其接口的定義如下所示:

// 摘要: 
//     定義一個執行命令模型的上下文。
public interface IContext : IContainerProvider
{
    // 摘要: 
    //     獲取正在執行的命令模型。
    ICommand Command { get; }
    //
    // 摘要: 
    //     獲取執行命令模型的其他參數,參數名稱若為字符串則不區分大小寫的序號字符串比較。
    HybridDictionary Data { get; }
    //
    // 摘要: 
    //     獲取上下文中的 System.IDbEngine 實例。該實例應不為 null 值,且線程唯一。
    //     * 不應在執行器中開啟事務。
    IDbEngine Engine { get; }
    //
    // 摘要: 
    //     獲取執行命令模型的用戶。該屬性可能返回 null 值。
    [Dynamic]
    dynamic User { get; }

    // 摘要: 
    //     獲取或設置鍵的值。
    //
    // 參數: 
    //   key:
    //     鍵。
    //
    // 返回結果: 
    //     返回一個值。
    object this[object key] { get; set; }
}
  • Command:上下文中的抽象命令。
  • Data:臨時數據存儲的字典,生命周期僅限命令執行期間。
  • Engine:在當前命令模型上下文中的線程上下文引擎上下文。簡單的說,就是在當前線程中唯一的數據庫操作引擎。
  • User:在整個運行環境中,假設用戶已登錄授權,這里存儲的便是已授權的用戶信息。若想實現此功能,必須實現 IUserFactory 接口。

上下文(Context)在整個 CommandModel 中具有非常特殊的意義。比如通過事件(Event)提前定義特殊數據存儲在 Context.Data,執行器再根據不同的特殊數據處理不同的業務邏輯。亦或者,它允許了在同一線程里執行若干個命令,而不會重復、多余打開數據庫連接;也可以將定義一個事務范圍,控制所有的命令執行有效性。

1.4 事件(Event)

事件可以讓每一個命令的執行得到有效控制,其的意義類似 HTTP 中 BeginRequestEndRequest

事件可以做的事情非常多,它讓 CommandModel 具備無限擴展的可能。比如常見的命令攔截執行、修改命令參數、命令緩存和日志管理等等……

2. 快速入門

上面說了很多概念性的東西,現在讓我們實際操作一下,看看 CommandModel 是如何運用的。

2.1 普通命令

業務上定義了一個目的:查詢用戶編號為?的用戶信息。完整代碼如下所示:

public class User
{
    public string Username { get; set; }
    public string Password { get; set; }
}

public class FindUserById : ICommand<User>
{
    public long Id { get; set; }
    public User ResultValue { get; set; }

    class Executor : IExecutor<FindUserById>
    {
        public void Execute(IContext context, FindUserById command)
        {
            if(command.Id == 1)
            {
                command.ResultValue = new User() { Username = "admin", Password = "123456" };
            }
        }
    }
}

我們通過控制台來試着執行這個命令:

var container = new IocContainer();
var bus = new CommandBus(container);
var result = bus.Execute(new FindUserById { Id = 1 }).ResultValue;
Console.WriteLine("{0}\t{1}", result.Username, result.Password);

以上代碼最終輸出

admin   123456

2.2 泛型命令

泛型命令是一個具有非常大擴展性的功能。我們來定義幾個實體:

public interface IPerson
{
    string Name { get; set; }
}

public class Student : IPerson
{
    public string Name { get; set; }
}

public class Teacher : IPerson
{
    public string Name { get; set; }
}

創建命令:

class PersonModify<T> : ICommand where T : IPerson
{
    public T Person { get; set; }

    class Executor : IExecutor<PersonModify<T>>
    {
        public void Execute(IContext context, PersonModify<T> command)
        {
            if(command.Person is Teacher)
            {
                command.Person.Name = command.Person.Name + "老師";
            }
            else if(command.Person is Student)
            {
                command.Person.Name = command.Person.Name + "學生";
            }
        
        }
    }
}

測試代碼:

var container = new IocContainer();
var bus = new CommandBus(container);
var person = new Student { Name = "張三" };
bus.Execute(new PersonModify<Student> { Person = person });
Console.WriteLine(person.Name);

最終輸出結果便是:張三學生

3 緩存

CommandModel 默認實現了緩存的功能,支持內存緩存(容器范圍內)和 Redis 緩存。由於緩存的示例代碼較多,並且其十分重要,所以我單獨拿出一個篇章描述緩存。

使用緩存需要知道的三個重要內容“

  • CacheAttribute:命令必須包含此特性,表示這是具有緩存功能的命令。它還要求使用者提供一個關鍵參數 group,這是一個不能為空的參數。它的作用是用於區分 key。比如根據部門編號進行緩存,那么 group 則是 Dept,而 key 則是 Id
  • ICommandCache:命令必須實現此接口,此接口有三個作用:獲取緩存策略、設置緩存值和獲取緩存值。
  • ICommandCacheStrategy:緩存策略,在實現接口 ICommandCache 接口的 CreateStrategy(IContext context) 方法返回值。默認接口實現 CommandCacheStrategy,其特點是:支持絕對間隔過期方式、支持滑動間隔過期方式、支持基於內存的緩存、支持 Redis 的緩存。可以繼承這個類,來進行更多的擴展。

3.1 創建具有緩存效果的命令

[Cache("User")]
public class GetDate : ICommand<DateTime>, ICommandCache
{
	//- 根據傳入的用戶編號,獲取一個時間
    public long UserId { get; set; }

    public DateTime ResultValue { get; set; }

    class Executor : IExecutor<GetDate>
    {
        public void Execute(IContext context, GetDate command)
        {
            command.ResultValue = DateTime.Now.AddDays(command.UserId); //- 當前時間加上 UserId 值的天數
        }
    }
	//- 緩存策略,彈性 3 秒內緩存
    ICommandCacheStrategy ICommandCache.CreateStrategy(IContext context)
    {
        return new CommandCacheStrategy(UserId.ToString(), TimeSpan.FromSeconds(3), this, context);
    }

	//- 返回需緩存的內容
    object ICommandCache.GetCacheValue()
    {
        return this.ResultValue;
    }

	//- 設置緩存值,若值不合法必須返回 false,否則執行器永不會執行
    bool ICommandCache.SetCacheValue(object value)
    {
        if(value is DateTime)
        {
            this.ResultValue = (DateTime)value;
            return true;
        }
        return false;
    }

3.2 緩存測試代碼

var container = new IocContainer();
var bus = new CommandBus(container);

for(int i = 0; i < 6; i++)
{
    //- 0、1、2
    Console.WriteLine("{0} -> {1}", i % 3, bus.Execute(new GetDate() { UserId = i % 3 }).ResultValue);
}

Console.WriteLine("開始休眠 3 秒...");
System.Threading.Thread.Sleep(TimeSpan.FromSeconds(3));
Console.WriteLine("結束休眠 3 秒...");

for(int i = 0; i < 6; i++)
{
    //- 0、1、2
    Console.WriteLine("{0} -> {1}", i % 3, bus.Execute(new GetDate() { UserId = i % 3 }).ResultValue);
}

Console.WriteLine("測試 5 次,每次間隔 2 秒...");
for(int i = 0; i < 5; i++)
{
    Console.WriteLine("{0} -> {1}", 99, bus.Execute(new GetDate() { UserId = 99 }).ResultValue);
    Console.WriteLine("開始休眠 2 秒,避免緩沖過期...");
    System.Threading.Thread.Sleep(TimeSpan.FromSeconds(2));
}

最終輸出結果:

0 -> 2015/2/6 16:40:46
1 -> 2015/2/7 16:40:46
2 -> 2015/2/8 16:40:46
0 -> 2015/2/6 16:40:46
1 -> 2015/2/7 16:40:46
2 -> 2015/2/8 16:40:46
開始休眠 3 秒...
結束休眠 3 秒...
0 -> 2015/2/6 16:40:49
1 -> 2015/2/7 16:40:49
2 -> 2015/2/8 16:40:49
0 -> 2015/2/6 16:40:49
1 -> 2015/2/7 16:40:49
2 -> 2015/2/8 16:40:49
測試 5 次,每次間隔 2 秒...
99 -> 2015/5/16 16:40:49
開始休眠 2 秒,避免緩沖過期...
99 -> 2015/5/16 16:40:49
開始休眠 2 秒,避免緩沖過期...
99 -> 2015/5/16 16:40:49
開始休眠 2 秒,避免緩沖過期...
99 -> 2015/5/16 16:40:49
開始休眠 2 秒,避免緩沖過期...
99 -> 2015/5/16 16:40:49
開始休眠 2 秒,避免緩沖過期...

3.2 使用 Redis 作為緩存提供程序

非常簡單,只要往 Container(服務容器)添加 IRedisProvider,即刻支持 Redis!默認實現的 RedisProvider 取得是 Aoite.Redis.RedisManager.Context

4. 進階內容

進階內容包含了更多關於 CommandModel 的內容。

提醒在多線程中使用了 System.Db.ContextAoite.Redis.RedisManager.Context,你應該在線程結束中調用 GA.ResetContexts。比如說,在 HTTP Application 中,每一個請求結束,都應當調用 GA.ResetContexts(如果你使用了 Aoite.Web 框架,則不需要手工調用)。

4.1 命令和執行器的映射

一個命令是如何與執行器進行映射的,其映射的優先級和規則如下:

  1. 命令包含了 BindingExecutorAttribute 特性。此特性可以指定執行器的數據類型(也可以是一個泛型)。
  2. 命令的嵌套類型,並且類型名稱為“Executor”。這是推薦的用法
  3. 相同命名空間下,命令名稱(若以 Command 為后綴則會去掉 Command)加上“Executor”。

示例1:命令以 Command 結尾。

class Simple1Command : ICommand {}
class Simple1Executor : IExecutor<Simple1Command> {}

示例2:命令不以 Command 結尾。

class Simple2 : ICommand {}
class Simple2Executor : IExecutor<Simple2> {}

示例3:泛型+嵌套執行器。

class Simple3<T1, T2> : ICommand
{
	//....
    class Executor : IExecutor<Simple3<T1, T2>>
    {
        //....
    }
}

示例5:特性+泛型,可以看出執行器的名稱是“不符合”規則的。

[BindingExecutor(typeof(TestSimple4<,>))]
class Simple4<T1, T2> : ICommand { }
class TestSimple4<T1, T2> : IExecutor<Simple4<T1, T2>>{}

4.2 用戶工廠(UserFactory)

表示當前用戶的方式有兩種:第一種是通過命令參數(將當前用戶信息作為參數);第二種則是通過執行器的 context.User 屬性獲取用戶信息。本節要講解的就是如何利用 context.User 獲取上下文中的用戶。

假設我們定義了以下命令。

public class GetUsername : ICommand<string>
{
	//-目的:獲取當前用戶的賬號。
    public string ResultValue { get; set; }

    class Executor : IExecutor<GetUsername>
    {
        public void Execute(IContext context, GetUsername command)
        {
			//- 模擬:編號為 1 返回 admin,否則返回 user
            if(context.User.Id == 1) command.ResultValue = "admin";
            else command.ResultValue = "user";
        }
    }
}

然后添加測試代碼:

var container = new IocContainer();

object user = new { Id = 1 };
container.AddService<IUserFactory>(new UserFactory(c => user));

var bus = new CommandBus(container);
Console.WriteLine(bus.Execute(new GetUsername()).ResultValue);
user = new { Id = 2 };
Console.WriteLine(bus.Execute(new GetUsername()).ResultValue);

以上代碼的輸出內容是

admin
user

4.3 事件(Event)

事件由兩個部分組成,分別是:事件倉庫(EventStore)和事件(Event)。事件倉庫負責全局的事件(比如你想對所有命令進行執行前捕獲和執行后捕獲),事件則針對固定命令類型進行捕獲。如果你要全局事件,在程序運行開始就應該手工注冊 IEventStore 類型,並繼承 EventStore 或實現 IEventStore

var container = new IocContainer();

object user = new SimpleUser { Id = 1 };
container.AddService<IUserFactory>(new UserFactory(c => user));
container.GetService<IEventStore>().Register<GetUsername>(new MockEvent<GetUsername>((context, command) =>
{
    if(context.User.Id == 1) context.User.Id = 2;
    else if(context.User.Id == 2) context.User.Id = 1;

    return true;
}, (context, command, exception) =>
{
    Console.WriteLine("執行后結果 {0}", command.ResultValue);
}));

var bus = new CommandBus(container);
Console.WriteLine(bus.Execute(new GetUsername()).ResultValue);
user = new SimpleUser { Id = 2 };
Console.WriteLine(bus.Execute(new GetUsername()).ResultValue);

經過事件的干擾以后,輸出內容變成

執行后結果 user
user
執行后結果 admin
admin

從執行器可以看出,預期輸出的第一個選項應該是 admin,第二個才是 user。通過事件的攔截和處理,CommandModel 可以有效的對數據進行校驗、捕獲和處理等工作。

4.4 命令的事務機制

本節的事務更多指的是 ADO.NET 的事務。 ADO.NET 的事務實現方式有兩種方式:第一種是利用 System.Data.Common.DbTransaction 的派生類,第二種則是利用 System.Transactions.TransactionScope 實現事務機制。

結合 Db.Context 數據庫上下文,CommandModel 巧妙的運用第二種方式進行事務的控制,具體代碼請看下篇內容。

5.結束

下篇內容主要利用命令模型服務(CommandModelServiceBase)做一個完整的示例(含數據庫和單元測試Aoite.CommandModel.CommandModelServiceBase 是一個默認 CommandModel 服務(業務邏輯層)的實現(若采用 Aoite.Web 框架,可以通過繼承 System.Web.Mvc.XControllerBaseSystem.Web.Mvc.XWebViewPageBase)。

命令模型服務(CommandModelServiceBase)的主要成員:

  • ICommandBus Bus { get; }:命令總線。
  • IIocContainer Container { get; set; }:服務容器。
  • dynamic User { get; }:執行命令模型的用戶。
  • IDisposable AcquireLock(key, timeout = null):一個全局鎖的功能,如果獲取鎖超時將會拋出異常。
  • long Increment(key, increment ):獲取指定鍵的原子遞增序列。
  • ITransaction BeginTransaction():開始事務模式。
  • TCommand Execute<TCommand>(command, executing, executed):執行一個命令模型。
  • Task<TCommand> ExecuteAsync<TCommand>(command, executing, executed):以異步的方式執行一個命令模型。

關於 Aoite.CommandModel 的上篇內容,就到此結束了,如果你喜歡這個框架,不妨點個推薦吧!如果你非常喜歡這個框架,那請順便到Aoite GitHub Star 一下 :)

點此下載本文的所有示例代碼。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM