這是我最近碰到的一個問題:

我有一個 Windows Forms 應用程式已經開發完成--姑且叫它 WinApp--但我想把其中一些功能抽出來放在 Web 上,讓使用者不用下載和安裝一堆元件,只要透過瀏覽器就能使用這些服務。由於原本在撰寫 WinApp 時,那些核心的服務都是寫成獨立的元件(.DLL 組件)--就叫它 MyDLL 好了--而且都經過詳細的測試,因此在開發 Web 版的應用程式時,並沒有花太多力氣,在 VS2005 裡面測試網站的各項功能(View in Browser)也都完全正常。一切看起來非常順利,直到我將網站部署到 IIS,才發現一個底層的類別有幾個函式無法正常運作...

方便起見,我用一個比較簡單的例子來說明。假設 MyDLL 裡面有一個類別提供了一個 GetInputLanguages 方法來傳回目前系統上已安裝的輸入法名稱,像這樣:

using System.Windows.Forms;

public sealed class MyUtil
{
  public string GetInputLanguages()
  {
    string s = "";
    foreach (InputLanguage inpLang in InputLanguage.InstalledInputLanguages)
    {
       s += inpLang.LayoutName + "  ";               
    }
    return s; 
  }
}
 

原本在 WinApp 裡面呼叫 MyUtil 的 GetInputLanguages 方法時,都能正確傳回目前系統中安裝的輸入法名稱,可是到了 Web 環境,此方法卻傳回空字串。也就是說,沒有發生任何 exception,但也無法得到預期的結果。如果您有興趣的話,不妨將以上程式碼分別放到 Windows Forms 和 ASP.NET 程式裡面執行看看。但請注意,如果利用 VS2005 的虛擬 Web 伺服器來執行,結果是正確的,一旦你將程式部署到 IIS 執行,結果就不同了。

試過的方法

根據以上的情況,研判大概是 security 的問題,因此我先嘗試亂槍打鳥,去更改 IIS 網站設定裏面的各種身分識別,全部都用最高權限的身分去執行,包括 Application Pool 的身分識別,以及網站本身的登入身分。此外,也試過修改 web.config,試圖以 impersonation 的方式解決,甚至去修改  Machine Level 的 .NET Runtime Security Policy(見 Code Access Security 相關文件),結果都徒勞無功(說不定是我自己漏掉了什麼關鍵細節,若有人發現還請不吝告知)。

解決方法

前面胡亂試的方法都無效之後,靜下來想一想....ASP.NET 和 Windows Form 應用程式的差別,就在於 ASP.NET 伺服器端的程式,並不像 Windows 程式那樣,有跟 user 互動的 UI。想必是這個原因,造成存取互動使用者介面的相關函式無法在 ASP.NET 環境下正常運作(其實有些 WinForms 屬性還是運作正常,例如:透過 System.Windows.Forms.Screen 取得螢幕的寬度和高度)。那麼,接下來要處理的問題就是:怎麼樣才能讓 ASP.NET 應用程式存取 Windows UI 資源?

Ok, 我可以把前面的 MyUtil 包在一個 Serviced Component(COM+ 元件)裡面,並以「互動的使用者」身份來執行該元件。那麼在 ASP.NET 程式裡面只要建立並呼叫這個 COM+ 元件,應該就能達到目的。但這種作法卻有個問題,我如果用 VS2005 開發 COM+ 元件, 產生的結果其實是把一個 .NET DLL 組件重新包裝成 COM 相容的介面,等到在 ASP.NET 專案中要加入這個 COM 元件的參考時,VS2005 會拒絕我:不能參考一個以 .NET 包裝成的 COM 元件。也對!既然是 .NET 程式,就直接參考 .NET 組件就好了,何必再包成 COM 元件?但這樣一來又回到原點了。

雖然可以用 Delphi Win32 的版本或其他發工具來建立 COM+ 元件,但我不想用 VS2005 以外的開發工具,以減少維護的麻煩。既然 COM+ 元件這條路行不通,那麼就試試另一項 .NET 分散式應用程式技術:Remoting(終於有機會派上用場啦)。由於 .NET Remoting 的技術細節太多,無法逐一說明,因此,這裡就只說明我的作法,以及一些重點觀念。

利用 Remoting 技術解決此問題時,便涉及了分散式架構,其中的主要元素包括兩個 processes:一個是作為用戶端的 ASP.NET 應用程式,另一個是伺服器端的應用程式。此外,由於兩端都必須知道要呼叫的物件的型別資訊,因此通常還需要一個 DLL 組件。 其運作架構如下圖:

其中的 Remote Object 就是我的 MyDLL 組件裡面的 MyUtil 類別。以下分別說明如何完成這三個元素。

撰寫 Remoting 類別

使用 .NET Remoting 時,原本的 MyDLL 幾乎不用更動,唯一要修改的,是 MyUtil 類別要繼承自 MarshalByRefObject,讓它成為可以在遠端呼叫的物件。像這樣:

public sealed class MyUtil : MarshalByRefObject
{
  .... // 不變
}

撰寫 Hosting Server 應用程式 

接下來,要寫一個 hosting server 應用程式,把 MyUtil 「放進」這個 hosting server 程式裡面。更精確的說,就是這個 hosting server 程式會註冊 MyUtil 型別,並且指定要監聽的 port、傳輸協定、資料封包的格式等等。主要的步驟如下:

  1. New 一個 Windows Forms 應用程式,命名為 HostServer。(既然是 Windows Forms 應用程式,那麼存取 UI 方面自然不會有任何問題了)
  2. 加入組件參考:MyDLL。
  3. 在 Form Load 事件中加入一行程式碼:

    RemotingConfiguration.Configure("HostServer.exe.config", false);
     
  4. 為專案中新增一個 Application Configuration File(App.config),其內容如下:

    <?xml version="1.0" encoding="utf-8" ?>
    <configuration>
      <system.runtime.remoting>
        <application name="HostService">
          <service>
            <wellknown type="MyDll.MyUtil, MyDll"
                       objectUri="RemoteMyUtil"
                       mode="Singleton" />
          </service>
          <channels>
            <channel ref="tcp server" port="8080" />
          </channels>
        </application>
      </system.runtime.remoting>
    </configuration>

這樣就完成了 hosting server 應用程式。請注意 app.config 的內容,這裡我將遠端物件的存取模式設定為 server-activated singleton 物件,並透過 TCP 8080 port 來提供遠端呼叫服務,存取的 URI 是 RemoteMyUtil。也就是說,用戶端要存取此物件時,就是透過以下 URL 來找到這個物件:

  tcp://<伺服器位址>:8080/RemoteMyUtil

撰寫用戶端應用程式 

剩下的部份,就是撰寫 Remoting 用戶端的部份,這部份當然就是寫在 ASP.NET 網頁裡面了。假設我在 Test.aspx 網頁裡面要呼叫該遠端物件,以下是主要步驟:

  1. 在 ASP.NET 專案中加入組件參考:MyDLL。(這步驟當然不可少,否則就不知道 MyUtil 個型別,以及它提供哪些屬性/方法了)
  2. 在 Page Load 事件中註冊 MyUtil 型別。你可以將此步驟視為告訴 .NET runtime:「以後我在程式裡面用到 MyUtil 時,可不是 local 物件,而是遠端物件喔!」程式碼如下:

    protected void Page_Load(object sender, EventArgs e)
    {
      // 註冊通訊管道.
      IChannel tcpChannel = new TcpChannel();
      ChannelServices.RegisterChannel(tcpChannel, false)
      // 註冊型別.
      Type svrType = typeof(MyDll.MyUtil);
      string url = "tcp://localhost:8080/RemoteMyUtil";
      RemotingConfiguration.RegisterWellKnownClientType(svrType, url)
    }
  3. 在需要使用 MyUtil 類別時,用法跟一般在使用 local 物件完全沒差別:

    protected void Button1_Click(object sender, EventArgs e)
    {
      MyDll.MyUtil myUtil = new MyDll.MyUtil();
      Response.Write(myUtil.GetInputLanguages());
    }

這樣就大功告成了!

在實際執行時,仍有一些小缺點,例如:hosting server 應用程式必須先執行起來(監聽 8080 port,看有沒有用戶端要存取它的服務)。因此,最後我是把這個 hosting server 實作成 Windows Service 的形式,這樣每次開機就會自動執行,方便多了。把 Remoting 物件包進 Windows Service 的程式寫法並沒有什麼特殊的地方,只是把註冊遠端物件的動作寫在服務類別的 OnStart 事件而已。唯一要特別注意的,是這個 service 必須以「本機系統帳戶」(Local System)來執行,以確保有足夠的權限,並且要「允許服務與桌面互動」,否則它同樣無法存取 UI 資源(即 MyUtil 的 GetInputLanguages 還是會傳回空字串)。

另外要特別注意的是,雖然用這種方式可以在 Windows Service 程式裡面存取 UI 資源,但切記不可以使用任何會中斷執行緒的函式,例如:MessageBox.Show。否則用戶端就會被 block 住,看起來就像當掉一樣。

結語

問題解決了,也實際領略了 .NET Remoting 技術的通透(transparent)與彈性。你可以看到,在撰寫用戶端程式的第 3 個步驟時,寫法跟呼叫 local 物件根本沒兩樣。如果沒有做第 2 個步驟--即註冊遠端物件的動作--程式就會建立 local 物件。另外,在註冊遠端物件的部份,這裡示範了兩種作法;hosting server 程式是把相關的設定寫在外部的 app.config 裡面,而用戶端程式則是完全以程式碼來完成註冊動作。其實不管是 client 端還是 server 端,都可以完全以程式碼來完成,也可以把註冊的設定寫在外部檔案,這樣一來,以後要更改監聽的 port、傳輸協定、或伺服器位址,程式就不用重新編譯了。

或許還有其他更簡單的方法可以解決這個問題,也歡迎網友提供。最後,順便提一個曾經想過,但沒有實際嘗試的方法:不使用 remoting 技術,只是撰寫單純的 Windows Service,並透過傳遞視窗訊息的方式來解決。不過,既然問題已經解決,而 Keith Brown 也說:「Just don't try to use window message to communicate; it won't work.」那還是別浪費力氣了。

參考資料