2010/08/06

[C#] .Net Remoting 基本說明 (使用 Interface 和 組態檔)

在說明 .Net Remoting 前, 先推薦 Ingo Rammer 的書: http://www.thinktecture.com/Resources/Books/default.html
我買的是 "Advanced .NET Remoting" 初版, 寫得蠻不錯的, 範例也說得很清楚.
本篇主要說明 .Net Remoting 基本的設計方式與建議的做法, 希望能讓初學者能有一個基本的設計概念, 並避免一些常見的錯誤.

.Net Remoting 最簡單的架構如圖一: 其中綠色圓圈表示 Client-Server 彼此呼叫的介面


 .Net Remoting 基本架構
圖一 .Net Remoting 基本架構
目前大多數的 .Net Remoting 範例都會教一種做法, 步驟如下:
  1. 新增一個類別庫的專案, 例如: RemotingLib.
    1. 在專案中新增一個類別, 例如: RemotingMsg.cs
    2. 設定 RemotingMsg 這個類別繼承 MarshalByRefObject 類別.
    3. 撰寫供 Client 呼叫的函式.
    RemotingMsg.cs 程式碼如下:
    using System;
    
    namespace RemotingLib
    {
      public class RemotingMsg : MarshalByRefObject
      {
        //預設的問候語
        string strHelloMsg = "Hello~";
        /// <summary>
        /// RemotingMsg的建構子(無參數)
        /// </summary>
        public RemotingMsg()
        {
          Console.WriteLine("RemotingMsg started");
        }
    
        /// <summary>
        /// RemotingMsg的建構子(參數是用來設定預設的問候語)
        /// </summary>
        /// <param name="hello"></param>
        public RemotingMsg(string hello)
        {
          this.strHelloMsg = hello;
          Console.WriteLine("RemotingMsg started: set msg " + this.strHelloMsg);
        }
    
        /// <summary>
        /// 取得Remoting Server的時間
        /// </summary>
        /// <returns></returns>
        public DateTime GetServerTime()
        {
          return DateTime.Now;
        }
    
        /// <summary>
        /// 取得Remoting Server的問候訊息
        /// </summary>
        /// <param name="name"></param>
        /// <returns></returns>
        public string GetServerHelloMsg(string name)
        {
          return string.Format("{0}:{1} {2}"
            , DateTime.Now.ToString("yyyy/MM/dd HH:mm:ss")
            , this.strHelloMsg
            , name);
        }
      }
    }
  2. 新增一個主控台應用程式專案 (ex: RemotingServer) 做為 .Net Remoting Server. (用 Windows 服務專案也可以, 這邊為了方便說明, 所以使用主控台應用程式專案)
    1. 在專案中加入三個參考: System.Configuration, System.Runtime.Remoting 和 RemotingLib 專案.
    2. 撰寫提供 Remoting 服務的相關程式碼: 參考資訊如下
      • 採用TcpChannel. (參考 TcpChannel) (其他的 Channel, 可參考 此網頁)
      • 連接埠為7777.
      • objectUri 的名稱為RemotingTest.
      • Remoring 物件的啟動方式為Singleton. (參考 WellKnownObjectMode)
    RemotingServer 專案的 Program.cs 程式碼如下:
    using System;
    using System.Runtime.Remoting;
    using System.Runtime.Remoting.Channels;
    using System.Runtime.Remoting.Channels.Tcp;
    
    namespace RemotingServer
    {
      class Program
      {
        static void Main(string[] args)
        {
          try
          {
            //建立TCP 7777的通道
            TcpChannel tcpChannel = new TcpChannel(7777);
            //註冊此通道(ensureSecurity設定false)
            ChannelServices.RegisterChannel(tcpChannel, false);
            //註冊 Remoting 服務的物件與物件啟動的方式(Singleton)
            RemotingConfiguration.RegisterWellKnownServiceType(typeof(RemotingLib.RemotingMsg)
              , "RemotingTest", WellKnownObjectMode.Singleton);
          }
          catch (Exception e)
          {
            Console.WriteLine(e.ToString());
          }
          string strInput;
          Console.WriteLine("請輸入'exit', 以結束程式.");
          while (!(strInput = Console.ReadLine()).Equals("exit", StringComparison.OrdinalIgnoreCase)) { }
        }
      }
    }
  3. 新增一個主控台應用程式專案 (ex: RemotingClient) 做為 .Net Remoting Client
    1. 在專案中加入三個參考: System.Configuration,  System.Runtime.Remoting 和 RemotingLib 專案.
    2. 設定呼叫 Remoting 物件的 type 和 url , 並於取得 Remoting 物件後, 進行呼叫.
    RemotingClient 專案的 Program.cs 程式碼如下:
    using System;
    using System.Configuration;
    using RemotingLib;
    
    namespace RemotingClient
    {
      class Program
      {
        static void Main(string[] args)
        {
          try
          {
            //透過Activator.GetObject取得指定 url 的 Remoting 物件, 並轉換該物件型別至RemotingLib.RemotingMsg
            //url中的IP與port, 要視 RemotingServer 的設定資訊, 進行調整
            RemotingLib.RemotingMsg imsg = (RemotingLib.RemotingMsg)Activator.GetObject(typeof(RemotingLib.RemotingMsg)
                                              , "tcp://127.0.0.1:7777/RemotingTest");
            //取得 Server 時間
            Console.WriteLine("Get Server Time:");
            Console.WriteLine(imsg.GetServerTime());
            //取得 Server 的問候訊息
            Console.WriteLine("Get Server Message:");
            Console.WriteLine(imsg.GetServerHelloMsg(Environment.MachineName));
          }
          catch (Exception ex)
          {
            Console.WriteLine(ex.ToString());
          }
          string strInput;
          Console.WriteLine("請輸入'exit', 以結束程式.");
          while (!(strInput = Console.ReadLine()).Equals("exit", StringComparison.OrdinalIgnoreCase)) { }
        }
      }
    }
之後, 只要建置各個專案, 然後依序啟動 RemotingServer.exe 和 RemotingClient.exe , 就會產生以下的資訊:

RemotingServer 啟動畫面
圖二 RemotingServer 啟動畫面

RemotingClient 啟動畫面
圖三 RemotingClient 啟動畫面

RemotingServer 被呼叫後的畫面
圖四 RemotingServer 被呼叫後的畫面

*注意: 當 RemotingServer 一開始啟動的時候, Remoting 物件尚未被初始化, 直到有 RemotingClient 第一次呼叫後, 才會開始初始化第一個 Remoting 物件.
以上, 是最基本的 .Net Remoting 範例. 接下來開始就我個人的設計習慣, 說明如何調整, 以及為什麼調整.
  • 首先, 也許有人注意到 RemotingLib 這個專案, 每次修改都需重新把 RemotingLib.dll 佈署到 Server 與 Client .
    此種設計方式不僅造成將來 Remoting 物件實作部分異動後, Client 佈署上的困難, 也間接造成將實作的 dll 曝露在 Client 端的風險.
    為了解決上述的問題, 我們可以新增一個 InterfaceLib 的類別庫專案, 然後讓此專案產生的 InterfaceLib.dll 取代在 Client 端的 RemotingLib.dll , 供 Client 端呼叫.
    修改的程序如下:
    1. 在 InterfaceLib 專案中新增一個介面程式 IMsg.cs, IMsg.cs 程式碼如下: (注意要用 public interface)
      using System;
      
      namespace InterfaceLib
      {
        public interface IMsg
        {
          DateTime GetServerTime();
          string GetServerHelloMsg(string name);
        }
      }
    2. RemotingLib 專案的修改如下:
      1. 將 InterfaceLib 專案加至 RemotingLib 專案的參考.
      2. 在 RemotingMsg.cs 中, 增加 IMsg 的實作.
        補充(2009/8/14): 因為Remoting物件是有生命週期的(參考初始化租用期), 為了不讓這個物件被回收, 一般我都會override InitializeLifetimeService().
        程式碼修改如下: (注意增加了 InterfaceLib 的 using , 及 IMsg 的實作)
        using System;
        using InterfaceLib;
        
        namespace RemotingLib
        {
          public class RemotingMsg : MarshalByRefObject, IMsg
          {
            //預設的問候語
            string strHelloMsg = "Hello~";
        
            /// <summary>
            /// RemotingMsg的建構子(無參數)
            /// </summary>
            public RemotingMsg()
            {
              Console.WriteLine("RemotingMsg started");
            }
        
            /// <summary>
            /// RemotingMsg的建構子(參數是用來設定預設的問候語)
            /// </summary>
            /// <param name="hello"></param>
            public RemotingMsg(string hello)
            {
              this.strHelloMsg = hello;
              Console.WriteLine("RemotingMsg started: set msg " + this.strHelloMsg);
            }
        
            #region IMsg 成員
            /// <summary>
            /// 取得Remoting Server的時間
            /// </summary>
            /// <returns></returns>
            public DateTime GetServerTime()
            {
              return DateTime.Now;
            }
        
            /// <summary>
            /// 取得Remoting Server的問候訊息
            /// </summary>
            /// <param name="name"></param>
            /// <returns></returns>
            public string GetServerHelloMsg(string name)
            {
              return string.Format("{0}:{1} {2}"
                , DateTime.Now.ToString("yyyy/MM/dd HH:mm:ss"), this.strHelloMsg, name);
            }
        
            #endregion
            /// <summary>
            /// 讓此物件永不過期
            /// </summary>
            /// <returns></returns>
            public override Object InitializeLifetimeService()
            {
              return null;
            }
          }
        }
    3. RemotingClient 端的 Program.cs 修改如下:
      1. 移除 RemotingLib 的參考, 將 InterfaceLib 專案加入參考.
      2. 呼叫 Remoting 物件後的轉型, 改為轉型至 IMsg.
        程式碼參考如下:
        using System;
        using System.Configuration;
        using InterfaceLib;
        
        namespace RemotingClient
        {
          class Program
          {
            static void Main(string[] args)
            {
              try
              {
                //透過Activator.GetObject取得指定 url 的 Remoting 物件, 並轉換該物件型別至InterfaceLib.IMsg
                //url中的IP與port, 要視 RemotingServer 的設定資訊, 進行調整
                InterfaceLib.IMsg imsg = (InterfaceLib.IMsg)Activator.GetObject(typeof(InterfaceLib.IMsg)
                                            , "tcp://127.0.0.1:7777/RemotingTest");
                //取得 Server 時間
                Console.WriteLine("Get Server Time:");
                Console.WriteLine(imsg.GetServerTime());
                //取得 Server 的問候訊息
                Console.WriteLine("Get Server Message:");
                Console.WriteLine(imsg.GetServerHelloMsg(Environment.MachineName));
              }
              catch (Exception ex)
              {
                Console.WriteLine(ex.ToString());
              }
              string strInput;
              Console.WriteLine("請輸入'exit', 以結束程式.");
              while (!(strInput = Console.ReadLine()).Equals("exit", StringComparison.OrdinalIgnoreCase)) { }
            }
          }
        }
    如此一來, 只要 InterfaceLib 中的介面沒異動, RemotingLib 專案中的實作要怎麼改, 都跟 Client 參考的組件沒有關係.
    最後的專案結構如圖五. (注意: RemotingClient 只有參考 InterfaceLib 專案)
    Remoting 專案結構
    圖五 Remoting 專案結構

    不過, 如果 Server 端要重新參考新的 RemotingLib.dll, 就需要重新啟動 Server .
    但在 TcpChannel 模式下, 只要 Server 端重新提供服務, Client 端可在不重新啟動的情況下, 使用原本的 Remoting 物件繼續運作. (但要記得 Client 端不要在 try catch 後, 把 Remoting 物件給設定成 null)
    這也是我比較喜愛使用 TcpChannel 模式的一個原因, IpcChannel 在這部分就很麻煩.
  • 第二部分是 RemotingClient 的修改. 這個比較簡單, 純粹是將 Remoting 的部分搬到設定檔.
    1. 在 RemotingClient 專案中, 新增一個"應用程式組態檔" . (此檔案在開發工具中叫作 App.config , 佈署後會叫作 "專案名稱.exe.config" )
    2. 將 RemotingClient 中呼叫的 url , 設定到組態檔中 (App.config):
      <?xml version="1.0" encoding="utf-8" ?>
      <configuration>
        <appSettings>
          <add key="remotingUri" 
               value="tcp://127.0.0.1:7777/RemotingTest"/>
        </appSettings>
      </configuration>
    3. 修改 Remoting 呼叫的方式如下:
      //透過Activator.GetObject取得指定 url 的 Remoting 物件
      //, 並轉換該物件型別至InterfaceLib.IMsg
      //url中的IP與port, 要視 RemotingServer 的設定資訊, 進行調整
      InterfaceLib.IMsg imsg 
            = (InterfaceLib.IMsg)Activator.GetObject(
                 typeof(InterfaceLib.IMsg)
                 , ConfigurationManager.AppSettings["remotingUri"]);
  • 最後一部分是 Server 的調整. 我習慣使用應用程式組態檔做 Remoting 物件的設定.
    1. 在 RemotingServer 專案中, 新增一個"應用程式組態檔" .
    2. 在 App.config 檔案中, 建立如下的 Remoting 資訊:
      <?xml version="1.0" encoding="utf-8" ?>
      <configuration>
        <system.runtime.remoting>
          <application name="ServerHost">
            <service>
              <wellknown type="RemotingLib.RemotingMsg, RemotingLib"
      mode="Singleton" 
      objectUri="RemotingTest"/>
            </service>
            <channels>
              <channel ref="tcp" port="7777" >
                <serverProviders>
                  <formatter ref="binary" typeFilterLevel="Full" />
                </serverProviders>
              </channel>
            </channels>
          </application>
        </system.runtime.remoting>
      </configuration>
      • <channel ref="tcp" port="7777" > : 等同於利用 TcpChannel 註冊一個 port 7777 的通道.
      • <wellknown type="RemotingLib.RemotingMsg, RemotingLib" mode="Singleton" objectUri="RemotingTest"/> : 等同於透過 RemotingConfiguration.RegisterWellKnownServiceType 進行註冊.
        這邊常有人搞錯 type="RemotingLib.RemotingMsg, RemotingLib" 的設定方式. 簡單來說, 逗號 (,) 前是有繼承 MarshalByRefObject 的類別名稱(含命名空間), 逗號 (,) 後是組件的名稱.
        所以, 我們在這邊填入的是 RemotingLib.RemotingMsg 這個類別, 因為它繼承了 MarshalByRefObject ;至於組件名稱就填入 RemotingLib .
        如果你將類別寫在 RemotingServer 專案中, 因為建置後的這個類別會在 RemotingServer.exe 中, 所以組件名稱就要填入 RemotingServer . (基本上這個組件名稱沒說一定是 dll 的名稱)
    3. 接下來修改一下 RemotingServer 專案中的 Program.cs 檔:
      using System;
      using System.Runtime.Remoting;
      
      namespace RemotingServer
      {
        class Program
        {
          static void Main(string[] args)
          {
            try
            {
              //透過 RemotingConfiguration.Configure 將 Remoting 的設定讀入
              RemotingConfiguration.Configure(AppDomain.CurrentDomain.SetupInformation.ConfigurationFile, false);
            }
            catch (Exception e)
            {
              Console.WriteLine(e.ToString());
            }
            string strInput;
            Console.WriteLine("請輸入'exit', 以結束程式.");
            while (!(strInput = Console.ReadLine()).Equals("exit", StringComparison.OrdinalIgnoreCase)) { }
          }
        }
      }
      程式碼看起來簡單多了, 只要一行就完成 Remoting 的設定.
最後, 不知道有沒有人發現, 到目前為止, Remoting 物件都是使用無參數的建構子進行初始化的動作.
也許有人會問:
  • 我可以讓 RemotingMsg 這個類別的另一個建構子(有參數的), 做為 Remoting 預設的服務物件嗎?
  • 我想要在 RemotingServer 端啟動的時候, 這個 Remoting 物件就跟著啟動, 可以嗎?
接下來的步驟, 就可以完成上述的需求.
  1. 修改 RemotingServer 的 App.config 檔: 將 <service> 標籤 mark 掉. (要刪掉也行啦)
    <?xml version="1.0" encoding="utf-8" ?>
    <configuration>
      <system.runtime.remoting>
        <application name="ServerHost">
          <!--<service>
            <wellknown type="RemotingLib.RemotingMsg, RemotingLib" mode="Singleton" objectUri="RemotingTest"/>
          </service>-->
          <channels>
            <channel ref="tcp" port="7777" >
              <serverProviders>
                <formatter ref="binary" typeFilterLevel="Full" />
              </serverProviders>
            </channel>
          </channels>
        </application>
      </system.runtime.remoting>
    </configuration>
  2. 修改 RemotingServer 專案的 Program.cs 的程式: (注意: 一定要先使用 RemotingConfiguration.Configure , 再使用 RemotingServices.Marshal )
    using System;
    using System.Runtime.Remoting;
    
    namespace RemotingServer
    {
      class Program
      {
        static void Main(string[] args)
        {
          try
          {
            //透過 RemotingConfiguration.Configure 將 Remoting 的設定讀入
            RemotingConfiguration.Configure(AppDomain.CurrentDomain.SetupInformation.ConfigurationFile, false);
            //將問候語改成"Hi~"
            RemotingMsg msg = new RemotingMsg("Hi~");
            //透過RemotingServices.Marshal轉換此物件
            //第二個參數 objectUri ,可參考 RemotingClient 的組態檔, 修改成從設定檔讀入
            RemotingServices.Marshal(msg, "RemotingTest", typeof(RemotingMsg));
          }
          catch (Exception e)
          {
            Console.WriteLine(e.ToString());
          }
          string strInput;
          Console.WriteLine("請輸入'exit', 以結束程式.");
          while (!(strInput = Console.ReadLine()).Equals("exit", StringComparison.OrdinalIgnoreCase)) { }
        }
      }
    }
  3. 重新建置後, 依序啟動 RemotingServer.exe 和 RemotingClient.exe , 應該可以得到以下的畫面:

    RemotingServer 啟動畫面2
    圖六 RemotingServer 啟動畫面2 

    RemotingClient 啟動畫面2
    圖七 RemotingClient 啟動畫面2
.Net Remoting 還有一種用法是: Client 可以向 Server 註冊或反註冊事件, 當 Server 有事件時, 會通知有註冊事件的 Client .
礙於內容太多, 此作法留在下一篇.

4 則留言:

令狐大叔 提到...

請問一下,我使用您的方法建立Client與Server,平常運作都正常,但是要是Client斷線要重連就會出現「物件'/Xxx'已經中斷連接或不在伺服器上」,一定要把Server重新啟動Client才能正常連接,請問是為什麼?該怎麼解決?

yilinliu 提到...

http://zaidox.com/net-remote-object-lifetime.html
請確認一下有無override InitializeLifetimeService()

Jason 提到...

請問如果我是寫在ASP.NET上面然後架在電信業者的固定IP伺服器上,這樣我還能夠用Remoting呼叫我的程式傳資料到網頁上嗎?

yilinliu 提到...

To Jason, 可先用telnet [ip] [port], 確認是否可連到, 如果可以連到, 那你的程式應該可以從外面連到.