2010/08/06

[C#] .Net Remoting 事件派送

過去使用 .Net Remoting 大多都只用到 [C#] .Net Remoting 基本說明 (使用 Interface 和 組態檔) 所述的架構.
不過 .Net Remoting 還有一種事件派送的架構, 就是讓 Client 可向 Server 註冊事件, 當 Server 有事件發生時, 再通知 Client. (大多都以聊天室為例子)
這樣的架構對我來說, 可以應用在派送網站資料 (包括: 圖檔, RSS 資料, 等).
假設現在網站供使用者訂閱的新聞 RSS 資料是放在資料庫裡, 而網站伺服器共有三台 (NLB).
若直接讓這三台網站伺服器各自從資料庫中讀取資料, 其讀取次數將會是 [網站數] * [RSS 頻道數] . (如果沒加入網頁快取機制, 可能要再乘上 [使用者連線數] )

所以為了降低資料庫的存取次數, 過去的做法是像圖一那樣, 由一個 Remoting Client 負責處理資料庫中的資料, 處理完畢後, 再將資料一一送到各台的 Remoting Server .

傳統的 Client-Server 架構
圖一 傳統的 Client-Server 架構


圖一架構的優缺點如下:
  • 優點: 由 Client 對資料庫做存取, 並做資料處理, 可降低前台的資料庫存取次數.
  • 缺點: Client 要記錄每個 Server 的 objectUri , 若增加或減少 Server , Client 程式要再重新啟動(如果此設定放在 App.Config 中).
為了解決上述的缺點, 我希望的架構如圖二: (其中, Server 是以事件派送的方式將資料送給 Client , 以解決 Client 數量不固定(包括暫時停機)的問題. )

事件派送的 Client-Server 架構
圖二 事件派送的 Client-Server 架構
.Net Remoting 的事件派送做法如下: (最後會說明防火牆的部分)
  1. 建立一個類別庫的專案 (InterfaceLib), 當做 Client 與 Server 端溝通的介面. (原則上還是盡量讓 Client 端僅留存"介面", 不放跟 Server 端相關的程式碼)
    在專案中加入一個程式檔: IEventDispatch.cs
    using System;
    
    namespace InterfaceLib
    {
      public delegate void RssEventHandler(string strMsg);
    
      public interface IEventDispatch
      {
        event RssEventHandler RssUpdate;
        DateTime GetServerStartUpTime();
      }
    
      public abstract class IRemoteDelegatableObject : MarshalByRefObject
      {
        public void RssCallback(string strMsg)
        {
          RssCallbackInClient(strMsg);
        }
    
        protected abstract void RssCallbackInClient(string str);
      }
    }
    • IEventDispatch: 這個介面是 Server 與 Client 共同會使用到的.
    • IRemoteDelegatableObject: 這個抽象類別主要是要讓 Client 端去實作 RssCallbackInClient 這個函式.
  2. 建立一個實作 IEventDispatch 的專案 (RemotingLib) , 並將第一步 InterfaceLib 專案加入參考.
    此專案是後續要在 Server 端引用的組件 (不另建專案, 寫在 Server 端的專案中也 OK ).
    加入一個 EventDispatch.cs , 程式碼如下:
    using System;
    using InterfaceLib;
    
    namespace RemotingLib
    {
      public class EventDispatch : MarshalByRefObject, IEventDispatch
      {
        private DateTime dtStartUpTime = DateTime.Now;
    
        public EventDispatch()
        {
          Console.WriteLine("EventDispatch Startup");
        }
        /// <summary>
        /// 讓此物件永遠存在
        /// </summary>
        /// <returns></returns>
        public override object InitializeLifetimeService()
        {
          return null;
        }
    
        #region IEventDispatch 成員
    
        public event InterfaceLib.RssEventHandler RssUpdate;
    
        /// <summary>
        /// 回傳此物件啟動的時間
        /// </summary>
        /// <returns></returns>
        public DateTime GetServerStartUpTime()
        {
          return this.dtStartUpTime;
        }
    
        #endregion
    
        /// <summary>
        /// 事件派送
        /// </summary>
        /// <param name="strMsg"></param>
        public void SendRss(string strMsg)
        {
          if (RssUpdate != null)
          {
            RssEventHandler handler = null;
            try
            {
              foreach (Delegate delObj in RssUpdate.GetInvocationList())
              {
                handler = (RssEventHandler)delObj;
                handler(strMsg);
              }
            }
            catch (Exception e)
            {
              RssUpdate -= handler;
              Console.WriteLine("Dispatching Exception...");
            }
          }
        }
      }
    }
    其中, 事件派送的部分也可以直接用 RssUpdate(strMsg); .
    不過, 用上述的程式碼, 可知道現在有多少個對象要派送, 也較方便偵錯.
  3. 建立一個 RemotingServer 專案, 我用主控台應用程式, 以方便說明.
    在此專案中將第一與第二步的 InterfaceLib 與 RemotingLib 兩個專案加入參考, 並將 System.Configuration 與 System.Runtime.Remoting 也加入參考.
    接著, 在此專案新增一個應用程式組態檔 (App.config) , 該組態檔的內容如下:
    <?xml version="1.0"?>
    <configuration>
      <appSettings>
        <add key="objectUri" value="RemotingTest"/>
      </appSettings>
      <system.runtime.remoting>
        <application>
          <service>
          </service>
          <channels>
            <channel ref="tcp" port="7777">
              <serverProviders>
                <formatter ref="binary" typeFilterLevel="Full"/>
              </serverProviders>
            </channel>
          </channels>
        </application>
      </system.runtime.remoting>
    </configuration>
    請注意, 如果後續 Client 啟動有發生以下的 Exception :
    "System.Security.SecurityException: 這個安全性層級不允許將型別 System.DelegateSerializationHolder 以及從它衍生的型別 (例如 System.DelegateSerializationHolder) 還原序列化. "
    請確定 <formatter ref="binary" typeFilterLevel="Full"/> 這個 typeFilterLevel 屬性要定為 "Full" .

    這邊不宣告啟始的 Remoting 物件, 是因為要在程式中自行建立並控制.
    RemotingServer 專案預設的 Program.cs 檔修改如下:
    (由於是透過 EventDispatch 這個類別去實作 IEventDispatch 所以 Client 不會知道 EventDispatch 實際的運作方式)
    using System;
    using System.Configuration;
    using System.Runtime.Remoting;
    using RemotingLib;
    
    namespace RemotingServer
    {
      class Program
      {
        static void Main(string[] args)
        {
          //讀入 Remoting 的設定檔
          RemotingConfiguration.Configure(AppDomain.CurrentDomain.SetupInformation.ConfigurationFile, false);
          //建立一個 Remoting 物件
          EventDispatch objDispatch = new EventDispatch();
          //註冊一個 Remoting 物件的宣告至 RemotingServices 中
          RemotingConfiguration.RegisterWellKnownServiceType(
    typeof(EventDispatch)
    , ConfigurationManager.AppSettings["objectUri"]
    , WellKnownObjectMode.Singleton);
          //將此物件註冊至 RemotingServices 中
          RemotingServices.Marshal(objDispatch
    , ConfigurationManager.AppSettings["objectUri"]
    , typeof(EventDispatch));
          Console.WriteLine("Server Started.");
          string strMsg = string.Empty;
          Console.WriteLine("請輸入'exit', 以結束程式.");
          while ((strMsg = Console.ReadLine()) != "exit")
          {
            //將 Console 輸入的字串送到 Client 端
            objDispatch.SendRss(strMsg);
          }
        }
      }
    }
  4. 建立一個 RemotingClient 專案(主控台應用程式), 並將 InterfaceLib 專案加入參考.
    在專案中新增一個應用程式組態檔 (App.config) , 該組態檔的內容如下:
    <?xml version="1.0"?>
    <configuration>
      <appSettings>
        <add key="rssRemoting" 
    value="tcp://192.168.3.99:7777/RemotingTest"/>
      </appSettings>
      <system.runtime.remoting>
        <application>
          <channels>
            <channel ref="tcp" port="0">
              <serverProviders>
                <formatter ref="binary" 
    typeFilterLevel="Full"/>
              </serverProviders>
            </channel>
          </channels>
        </application>
      </system.runtime.remoting>
    </configuration>
    這邊要加入一個 channel 的設定, 做為 Client 端被呼叫時的設定.
    port 設為 0 表示不指定 port number.

    加入一個繼承 IRemoteDelegatableObject 的類別 EventCallback.cs , 程式碼如下:
    using System;
    using InterfaceLib;
    
    namespace RemotingClient
    {
      public class EventCallback : IRemoteDelegatableObject
      {
        public EventCallback() { Console.WriteLine("EventCallback Started"); }
        
        /// <summary>
        /// 讓此物件永遠存在
        /// </summary>
        /// <returns></returns>
        public override object InitializeLifetimeService()
        {
          return null;
        }
    
        /// <summary>
        /// 處理事件
        /// </summary>
        /// <param name="strMsg">訊息字串</param>
        protected override void RssCallbackInClient(string strMsg)
        {
          Console.WriteLine("Client:" + strMsg);
        }
      }
    }
    修改專案預設的 Program.cs 如下:
    using System;
    using System.Configuration;
    using System.Runtime.Remoting;
    using InterfaceLib;
    using System.Timers;
    
    namespace RemotingClient
    {
      class Program
      {
        IEventDispatch dispatcher = null;
        EventCallback callback = null;
        Timer timer = null;
        DateTime dtClientRegisterTime;
    
        Program()
        {
          try
          {
            //讀入 Remoting 設定檔
            RemotingConfiguration.Configure(AppDomain.CurrentDomain.SetupInformation.ConfigurationFile, false);
            this.dispatcher = (IEventDispatch)Activator.GetObject(typeof(IEventDispatch), ConfigurationManager.AppSettings["rssRemoting"]);
            this.callback = new EventCallback();
            //此 Timer 用來定時監測 Server 是否有重新啟動
            this.timer = new Timer(5000);
            this.timer.Elapsed += new ElapsedEventHandler(timer_Elapsed);        
          }
          catch (Exception e)
          {
            Console.WriteLine(e.ToString());
          }
        }
    
        /// <summary>
        /// 對 Server 做 polling 的動作 (避免 Server 重啟, 需重新註冊事件)
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        void timer_Elapsed(object sender, ElapsedEventArgs e)
        {
          Console.WriteLine(DateTime.Now.ToString("yyyy/MM/dd HH:mm:ss"));
          try
          {
            //比對 Server 的啟動時間與 Client 端註冊的時間
            //若 Server 在事件註冊後有重新啟動 -> 重新再註冊一次事件
            if (this.dtClientRegisterTime < this.dispatcher.GetServerStartUpTime())
            {
              Connect();
              Console.WriteLine("ReRegister");
            }
          }
          catch (Exception ex)
          {
            Disconnect();
          }
        }
    
        /// <summary>
        /// 事件註冊
        /// </summary>
        public void Connect()
        {
          try
          {
            dispatcher.RssUpdate += new RssEventHandler(callback.RssCallback);
            this.dtClientRegisterTime = DateTime.Now;
            Console.WriteLine("Connect");
          }
          catch (Exception e)
          {
            Console.WriteLine("Connect: " + e.Message);
          }
          finally
          {
            this.timer.Start();
          }
        }
    
        /// <summary>
        /// 事件的反註冊
        /// </summary>
        public void Disconnect()
        {
          try
          {
            if (this.dispatcher != null)
            {
              dispatcher.RssUpdate -= new RssEventHandler(callback.RssCallback);
              Console.WriteLine("Disconnect");
            }
          }
          catch (Exception e)
          {
            Console.WriteLine("Disconnect: " + e.Message);
          }
          finally
          {
            this.timer.Stop();
          }
        }
    
        static void Main(string[] args)
        {
          Program prog = new Program();
          prog.Connect();
          string strInput;
          Console.WriteLine("請輸入'exit', 以結束程式.");
          while (!(strInput = Console.ReadLine()).Equals("exit", StringComparison.OrdinalIgnoreCase)) { }
          prog.Disconnect();
        }
      }
    }
    Client 的主程式說明如下:
    • 在建構子中, 先從 RemotingServer 中取得一個 IEventDispatch 介面, 並在主程式中建立一個負責處理事件的 EventCallback 物件.
    • 為了避免當 RemotingServer 重啟時, 當初跟 RemotingServer 註冊的事件會失效, 所以加入一個 Timer , 以 polling 的方式確認 RemotingServer 啟動的時間.
    • 透過 Connect() 和 Disconnect() 來設定事件的註冊與反註冊, 若是寫 Windows 服務, 可以把 Connect() 放在 OnStart() 和 OnContinue() , Disconnect() 放在 OnStop() 和 OnPause() .
最後的方案結構如圖三:

方案的結構圖
圖三 方案的結構圖


執行結果如下:

RemotingServer 的執行畫面
圖四 RemotingServer 的執行畫面



RemotingClient 的執行畫面
圖五 RemotingClient 的執行畫面

其中, 我刻意將 RemotingServer 重啟, 以確定 RemotingClient 可在重新註冊事件後繼續運作.
另外, 可以觀察一下 RemotingServer 和 RemotingClient 之間使用的 port :
  • Client 端未指定 port (<channel ref="tcp" port="0">) : (192.168.3.99 是 Server ;192.168.3.104 是 Client)
    Client 端未指定 port 時的 Server 端狀態
    圖六 Client 端未指定 port 時的 Server 端狀態

    Client 端未指定 port 時的 Client 端狀態
    圖七 Client 端未指定 port 時的 Client 端狀態
  • 當Client 端指定 port 為 9999 (<channel ref="tcp" port="9999">) 時:
    Client 端指定 port 為 9999 時的 Server 端狀態
    圖八 Client 端指定 port 為 9999 時的 Server 端狀態

    Client 端指定 port 為 9999 時的 Server 端狀態
    圖九 Client 端指定 port 為 9999 時的 Server 端狀態
以上資訊供防火牆設定用, 希望對有需設定防火牆的開發者有幫助.

2 則留言:

Ericpoon 提到...

是否在Client端將被實現用於EventHandler的abstract class一定要繼承 MarshalOjbect?

yilinliu 提到...

Hi,
可參考 MSDN 上的說明:
http://msdn.microsoft.com/zh-tw/library/system.marshalbyrefobject(v=vs.80).aspx
"當跨越應用程式定義域界限來使用型別時,型別必須繼承自 MarshalByRefObject,並且不可以複製物件的狀態,因為物件的成員無法在建立成員的所在應用程式定義域外部使用。"
所以習慣上要在Client/Server間傳遞的類別就繼承吧.