天天看點

事件總線知多少(1)

轉自部落格:https://www.cnblogs.com/sheng-jie/p/6970091.html

1. 引言

事件總線這個概念對你來說可能很陌生,但提到觀察者(釋出-訂閱)模式,你也許就很熟悉。事件總線是對釋出-訂閱模式的一種實作。它是一種集中式事件處理機制,允許不同的元件之間進行彼此通信而又不需要互相依賴,達到一種解耦的目的。

我們來看看事件總線的處理流程:

了解了事件總線的基本概念和處理流程,下面我們就來分析下如何去實作事件總線。

2.回歸本質

在動手實作事件總線之前,我們還是要追本溯源,探索一下事件的本質和釋出訂閱模式的實作機制。

2.1.事件的本質

我們先來探讨一下事件的概念。都是讀過書的,應該都還記得記叙文的六要素:時間、地點、人物、事件(起因、經過、結果)。

我們拿注冊的案例,來解釋一下。

使用者輸入使用者名、郵箱、密碼後,點選注冊,輸入無誤校驗通過後,注冊成功并發送郵件給使用者,要求使用者進行郵箱驗證激活。

這裡面就涉及了兩個主要事件:

  1. 注冊事件:起因是使用者點選了注冊按鈕,經過是輸入校驗,結果是是否注冊成功。
  2. 發送郵件事件:起因是使用者使用郵箱注冊成功需要驗證郵箱,經過是郵件發送,結果是郵件是否發送成功。

其實這六要素也适用于我們程式中事件的處理過程。開發過WinForm程式的都知道,我們在做UI設計的時候,從工具箱拖入一個注冊按鈕(btnRegister),輕按兩下它,VS就會自動幫我們生成如下代碼:

void btnRegister_Click(object sender, EventArgs e)
{
 // 事件的處理
}      

其中

object sender

指代發出事件的對象,這裡也就是button對象;

EventArgs e

 事件參數,可以了解為對事件的描述 ,它們可以統稱為事件源。其中的代碼邏輯,就是對事件的處理。我們可以統稱為事件處理。

說了這麼多,無非是想透過現象看本質:事件是由事件源和事件處理組成。

2.2. 釋出訂閱模式

定義對象間一種一對多的依賴關系,使得每當一個對象改變狀态,則所有依賴于它的對象都會得到通知并被自動更新。 ——釋出訂閱模式

釋出訂閱模式主要有兩個角色:

  • 釋出方(Publisher):也稱為被觀察者,當狀态改變時負責通知所有訂閱者。
  • 訂閱方(Subscriber):也稱為觀察者,訂閱事件并對接收到的事件進行處理。

釋出訂閱模式有兩種實作方式:

  • 簡單的實作方式:由Publisher維護一個訂閱者清單,當狀态改變時循環周遊清單通知訂閱者。
  • 委托的實作方式:由Publisher定義事件委托,Subscriber實作委托。

總的來說,釋出訂閱模式中有兩個關鍵字,通知和更新。

被觀察者狀态改變通知觀察者做出相應更新。

解決的是當對象改變時需要通知其他對象做出相應改變的問題。

如果畫一個圖來表示這個流程的畫,圖形應該是這樣的:

3 實作釋出訂閱模式

相信通過上面的解釋,對事件和釋出訂閱模式有了一個大概的印象。都說理論要與實踐相結合,是以我們還是動動手指敲敲代碼比較好。

我将以『觀察者模式』來釣魚這個例子為基礎,通過重構的方式來完善一個更加通用的釋出訂閱模式。

先上代碼:

/// <summary>
/// 魚的品類枚舉
/// </summary>
public enum FishType
{
    鲫魚,
    鯉魚,
    黑魚,
    青魚,
    草魚,
    鲈魚
}      

釣魚竿的實作:

/// <summary>
 ///     魚竿(被觀察者)
 /// </summary>
 public class FishingRod
 {
     public delegate void FishingHandler(FishType type); //聲明委托
     public event FishingHandler FishingEvent; //聲明事件

     public void ThrowHook(FishingMan man)
     {
         Console.WriteLine("開始下鈎!");

         //用随機數模拟魚咬鈎,若随機數為偶數,則為魚咬鈎
         if (new Random().Next() % 2 == 0)
         {
             var type = (FishType) new Random().Next(0, 5);
             Console.WriteLine("鈴铛:叮叮叮,魚兒咬鈎了");
             if (FishingEvent != null)
                 FishingEvent(type);
         }
     }
 }      

垂釣者:

/// <summary>
///     垂釣者(觀察者)
/// </summary>
public class FishingMan
{
    public FishingMan(string name)
    {
        Name = name;
    }

    public string Name { get; set; }
    public int FishCount { get; set; }

    /// <summary>
    /// 垂釣者自然要有魚竿啊
    /// </summary>
    public FishingRod FishingRod { get; set; }

    public void Fishing()
    {
        this.FishingRod.ThrowHook(this);
    }

    public void Update(FishType type)
    {
        FishCount++;
        Console.WriteLine("{0}:釣到一條[{2}],已經釣到{1}條魚了!", Name, FishCount, type);
    }
}      

場景類也很簡單:

//1、初始化魚竿
var fishingRod = new FishingRod();

//2、聲明垂釣者
var jeff = new FishingMan("聖傑");

//3.配置設定魚竿
jeff.FishingRod = fishingRod;

//4、注冊觀察者
fishingRod.FishingEvent += jeff.Update;

//5、循環釣魚
while (jeff.FishCount < 5)
{
    jeff.Fishing();
    Console.WriteLine("-------------------");
    //睡眠5s
    Thread.Sleep(5000);
}      

代碼很簡單,相信你一看就明白。但很顯然這個代碼實作僅适用于目前這個釣魚場景,假如有其他場景也想使用這個模式,我們還需要重新定義委托,重新定義事件處理,豈不很累。本着”Don't repeat yourself“的原則,我們要對其進行重構。

結合我們對事件本質的探讨,事件是由事件源和事件處理組成。針對我們上面的案例來說,

public delegate void FishingHandler(FishType type);

這句代碼就已經說明了事件源和事件處理。事件源就是

FishType type

,事件處理自然是注冊到

FishingHandler

上面的委托執行個體。

問題找到了,很顯然是我們的事件源和事件處理不夠抽象,是以不能通用,下面咱們就來動手改造。

3.1. 提取事件源

事件源應該至少包含事件發生的時間和觸發事件的對象。

我們提取

IEventData

接口來封裝事件源:

/// <summary>
/// 定義事件源接口,所有的事件源都要實作該接口
/// </summary>
public interface IEventData
{
    /// <summary>
    /// 事件發生的時間
    /// </summary>
    DateTime EventTime { get; set; }

    /// <summary>
    /// 觸發事件的對象
    /// </summary>
    object EventSource { get; set; }
}      

自然我們應該給一個預設的實作

EventData

/// <summary>
/// 事件源:描述事件資訊,用于參數傳遞
/// </summary>
public class EventData : IEventData
{
    /// <summary>
    /// 事件發生的時間
    /// </summary>
    public DateTime EventTime { get; set; }

    /// <summary>
    /// 觸發事件的對象
    /// </summary>
    public Object EventSource { get; set; }

    public EventData()
    {
        EventTime = DateTime.Now;
    }
}      

針對Demo,擴充事件源如下:

public class FishingEventData : EventData
{
    public FishType FishType { get; set; }
    public FishingMan FisingMan { get; set; }
}      

完成後,我們就可以去把在

FishingRod

聲明的委托參數類型改為

FishingEventData

類型了,即

public delegate void FishingHandler(FishingEventData eventData); //聲明委托

然後修改

FishingMan

Update

方法按委托定義的參數類型修改即可,代碼我就不放了,大家自行腦補。

到這一步我們就統一了事件源的定義方式。

3.2.提取事件處理器

事件源統一了,那事件處理也得加以限制。比如如果随意命名事件處理方法名,那在進行事件注冊的時候還要去按照委托定義的參數類型去比對,豈不麻煩。

我們提取一個

IEventHandler

接口:

/// <summary>
 /// 定義事件處理器公共接口,所有的事件處理都要實作該接口
 /// </summary>
 public interface IEventHandler
 {
 }      

事件處理要與事件源進行綁定,是以我們再來定義一個泛型接口:

/// <summary>
 /// 泛型事件處理器接口
 /// </summary>
 /// <typeparam name="TEventData"></typeparam>
 public interface IEventHandler<TEventData> : IEventHandler where TEventData : IEventData
 {
     /// <summary>
     /// 事件處理器實作該方法來處理事件
     /// </summary>
     /// <param name="eventData"></param>
     void HandleEvent(TEventData eventData);
 }      

你可能會納悶,為什麼先定義了一個空接口?這裡就留給自己思考吧。

至此我們就完成了事件處理的抽象。我們再繼續去改造我們的Demo。我們讓

FishingMan

實作

IEventHandler

接口,然後修改場景類中将

fishingRod.FishingEvent += jeff.Update;

改為

fishingRod.FishingEvent += jeff.HandleEvent;

即可。代碼改動很簡單,同樣在此略去。

至此你可能覺得我們完成了對Demo的改造。但事實上呢,我們還要弄清一個問題——如果這個

FishingMan

訂閱的有其他的事件,我們該如何處理?

聰穎如你,你立馬想到了可以通過事件源來進行區分處理。

public class FishingMan : IEventHandler<IEventData>
{
    //省略其他代碼
    public void HandleEvent(IEventData eventData)
    {
        if (eventData is FishingEventData)
        {
            //do something
        }

        if(eventData is XxxEventData)
        {
            //do something else
        }
    }
}      

至此,這個模式實作到這個地步基本已經可以通用了。

4. 實作事件總線

通用的釋出訂閱模式不是我們的目的,我們的目的是一個集中式的事件處理機制,且各個子產品之間互相不産生依賴。那我們如何做到呢?同樣我們還是一步一步的進行分析改造。

4.1.分析問題

思考一下,每次為了實作這個模式,都要完成以下三步:

  1. 事件釋出方定義事件委托
  2. 事件訂閱方定義事件處理邏輯
  3. 顯示的訂閱事件

雖然隻有三步,但這三步已經很繁瑣了。而且事件釋出方和事件訂閱方還存在着依賴(展現在訂閱者要顯示的進行事件的注冊和登出上)。而且當事件過多時,直接在訂閱者中實作

IEventHandler

接口處理多個事件邏輯顯然不太合适,違法單一職責原則。這裡就暴露了三個問題:

  1. 如何精簡步驟?
  2. 如何解除釋出方與訂閱方的依賴?
  3. 如何避免在訂閱者中同時處理多個事件邏輯?

帶着問題思考,我們就會更接近真相。

想要精簡步驟,那我們需要尋找共性。共性就是事件的本質,也就是我們針對事件源和事件處理提取出來的兩個接口。

想要解除依賴,那就要在釋出方和訂閱方之間添加一個中介。

想要避免訂閱者同時處理過多事件邏輯,那我們就把事件邏輯的處理提取到訂閱者外部。

思路有了,下面我們就來實施吧。

4.2.解決問題

本着先易後難的思想,我們下面就來解決以上問題。

4.2.1. 實作IEventHandler

我們先解決上面的第三個問題:如何避免在訂閱者中同時處理多個事件邏輯?

自然是針對不同的事件源

IEventData

實作不同的

IEventHandler

。改造後的釣魚事件處理邏輯如下:

/// <summary>
/// 釣魚事件處理
/// </summary>
public class FishingEventHandler : IEventHandler<FishingEventData>
{
    public void HandleEvent(FishingEventData eventData)
    {
        eventData.FishingMan.FishCount++;

        Console.WriteLine("{0}:釣到一條[{2}],已經釣到{1}條魚了!",
            eventData.FishingMan.Name, eventData.FishingMan.FishCount, eventData.FishType);

    }
}      

這時我們就可以移除在

FishingMan

中實作的

IEventHandler

接口了。

然後将事件注冊改為

fishingRod.FishingEvent += new FishingEventHandler().HandleEvent;

即可。

4.2.2. 統一注冊事件

上一個問題的解決,有助于我們解決第一個問題:如何精簡流程?

為什麼呢,因為我們是根據事件源定義相應的事件處理的。也就是我們之前說的可以根據事件源來區分事件。

然後呢?反射,我們可以通過反射來進行事件的統一注冊。

FishingRod

的構造函數中使用反射,統一注冊實作了

IEventHandler<FishingEventData>

類型的執行個體方法

HandleEvent

public FishingRod()
{
    Assembly assembly = Assembly.GetExecutingAssembly();

    foreach (var type in assembly.GetTypes())
    {
        if (typeof(IEventHandler).IsAssignableFrom(type))//判斷目前類型是否實作了IEventHandler接口
        {
            Type handlerInterface = type.GetInterface("IEventHandler`1");//擷取該類實作的泛型接口
            Type eventDataType = handlerInterface.GetGenericArguments()[0]; // 擷取泛型接口指定的參數類型

            //如果參數類型是FishingEventData,則說明事件源比對
            if (eventDataType.Equals(typeof(FishingEventData)))
            {
                //建立執行個體
                var handler = Activator.CreateInstance(type) as IEventHandler<FishingEventData>;
                //注冊事件
                FishingEvent += handler.HandleEvent;
            }
        }
    }
}      

這樣,我們就可以移出場景類中的顯示注冊代碼

fishingRod.FishingEvent += new FishingEventHandler().HandleEvent;

4.2.3. 解除依賴

如何解除依賴呢?其實答案就在本文的兩張圖上,仔細對比我們可以很直覺的看到,Event Bus就相當于一個介于Publisher和Subscriber中間的橋梁。它隔離了Publlisher和Subscriber之間的直接依賴,接管了所有事件的釋出和訂閱邏輯,并負責事件的中轉。

Event Bus終于要粉墨登場了!!!

分析一下,如果EventBus要接管所有事件的釋出和訂閱,那它則需要有一個容器來記錄事件源和事件處理。那又如何觸發呢?有了事件源,我們就自然能找到綁定的事件處理邏輯,通過反射觸發。代碼如下:

/// <summary>
/// 事件總線
/// </summary>
public class EventBus
{
    public static EventBus Default => new EventBus();

    /// <summary>
    /// 定義線程安全集合
    /// </summary>
    private readonly ConcurrentDictionary<Type, List<Type>> _eventAndHandlerMapping;

    public EventBus()
    {
        _eventAndHandlerMapping = new ConcurrentDictionary<Type, List<Type>>();
        MapEventToHandler();
    }

    /// <summary>
    ///通過反射,将事件源與事件處理綁定
    /// </summary>
    private void MapEventToHandler()
    {
        Assembly assembly = Assembly.GetEntryAssembly();
        foreach (var type in assembly.GetTypes())
        {
            if (typeof(IEventHandler).IsAssignableFrom(type))//判斷目前類型是否實作了IEventHandler接口
            {
                Type handlerInterface = type.GetInterface("IEventHandler`1");//擷取該類實作的泛型接口
                if (handlerInterface != null)
                {
                    Type eventDataType = handlerInterface.GetGenericArguments()[0]; // 擷取泛型接口指定的參數類型

                    if (_eventAndHandlerMapping.ContainsKey(eventDataType))
                    {
                        List<Type> handlerTypes = _eventAndHandlerMapping[eventDataType];
                        handlerTypes.Add(type);
                        _eventAndHandlerMapping[eventDataType] = handlerTypes;
                    }
                    else
                    {
                        var handlerTypes = new List<Type> { type };
                        _eventAndHandlerMapping[eventDataType] = handlerTypes;
                    }
                }
            }
        }
    }

    /// <summary>
    /// 手動綁定事件源與事件處理
    /// </summary>
    /// <typeparam name="TEventData"></typeparam>
    /// <param name="eventHandler"></param>
    public void Register<TEventData>(Type eventHandler)
    {
        List<Type> handlerTypes = _eventAndHandlerMapping[typeof(TEventData)];
        if (!handlerTypes.Contains(eventHandler))
        {
            handlerTypes.Add(eventHandler);
            _eventAndHandlerMapping[typeof(TEventData)] = handlerTypes;
        }
    }

    /// <summary>
    /// 手動解除事件源與事件處理的綁定
    /// </summary>
    /// <typeparam name="TEventData"></typeparam>
    /// <param name="eventHandler"></param>
    public void UnRegister<TEventData>(Type eventHandler)
    {
        List<Type> handlerTypes = _eventAndHandlerMapping[typeof(TEventData)];
        if (handlerTypes.Contains(eventHandler))
        {
            handlerTypes.Remove(eventHandler);
            _eventAndHandlerMapping[typeof(TEventData)] = handlerTypes;
        }
    }

    /// <summary>
    /// 根據事件源觸發綁定的事件處理
    /// </summary>
    /// <typeparam name="TEventData"></typeparam>
    /// <param name="eventData"></param>
    public void Trigger<TEventData>(TEventData eventData) where TEventData : IEventData
    {
        List<Type> handlers = _eventAndHandlerMapping[eventData.GetType()];

        if (handlers != null && handlers.Count > 0)
        {
            foreach (var handler in handlers)
            {
                MethodInfo methodInfo = handler.GetMethod("HandleEvent");
                if (methodInfo != null)
                {
                    object obj = Activator.CreateInstance(handler);
                    methodInfo.Invoke(obj, new object[] { eventData });
                }
            }
        }
    }
}      

事件總線主要定義三個方法,注冊、取消注冊、事件觸發。還有一點就是我們在構造函數中通過反射去進行事件源和事件處理的綁定。

代碼注釋已經很清楚了,這裡就不過多解釋了。

下面我們就來修改Demo,修改

FishingRod

的事件觸發:

/// <summary>
/// 下鈎
/// </summary>
public void ThrowHook(FishingMan man)
{
    Console.WriteLine("開始下鈎!");

    //用随機數模拟魚咬鈎,若随機數為偶數,則為魚咬鈎
    if (new Random().Next() % 2 == 0)
    {
        var a = new Random(10).Next();
        var type = (FishType)new Random().Next(0, 5);
        Console.WriteLine("鈴铛:叮叮叮,魚兒咬鈎了");
        if (FishingEvent != null)
        {
            var eventData = new FishingEventData() { FishType = type, FishingMan = man };
            //FishingEvent(eventData);//不再需要通過事件委托觸發
            EventBus.Default.Trigger<FishingEventData>(eventData);//直接通過事件總線觸發即可
        }
    }
}      

至此,事件總線的雛形已經形成!

5.事件總線的總結

通過上面一步一步的分析和實踐,發現事件總線也不是什麼高深的概念,隻要我們自己善于思考,勤于動手,也能實作自己的事件總線。

根據我們的實作,大概總結出以下幾條:

  1. 事件總線維護一個事件源與事件處理的映射字典;
  2. 通過單例模式,確定事件總線的唯一入口;
  3. 利用反射完成事件源與事件處理的初始化綁定;
  4. 提供統一的事件注冊、取消注冊和觸發接口。

最後,以上事件總線的實作隻是一個雛形,還有很多潛在的問題。有興趣的不妨思考完善一下,我也會繼續更新完善,盡情期待!

參考資料

ABP EventBus

DDD~領域事件與事件總線

DDD事件總線的實作

繼續閱讀