在 WinForm 中完整支持在多級目錄中保存的 ASP.NET (轉)

大概半年前曾寫過一個在 WinForm 程序中嵌入 ASP.NET 的簡單例子,《在WinForm程序中嵌入ASP.NET》。因爲是試驗性質的工作,所以當時偷懶直接使用系統自帶的 SimpleWorkerRequest 完成 ASP.NET 頁面請求的處理工作。使用自帶工具類在實現上雖然簡單,但受到系統的諸多功能限制,如後面有朋友提到無法直接處理多級子目錄的問題等等。(如虛擬目錄爲 "/" 時無法處理 "/help/about.aspx" 類型的頁面請求)
    對於此類需求,一個最好的實現實例就是 www.asp.net 提供的 Cassini。這個例子完整地演示瞭如何實現一個支持 ASP.NET 的簡單 Web 服務器功能,並被 Borland 的 Delphi.NET 等許多開源項目,當作調試用 Web 服務器。雖然只有幾十 K 的源代碼,但麻雀雖小五臟俱全,還是非常值得一看的。但因爲 Cassini 是爲處理 Web 服務而設計,因此需要在瞭解其結構的基礎上,做一些定製來滿足我們的需求。

    首先來看看 Cassini 的程序結構。

    與我前文例子中採用的結構類似,Cassini 包括界面(CassiniForm)、服務器(Server)、宿主(Host)和請求處理器(Request)等幾個主要部分,並通過 Connection 等幾個工具類,完成 Web 請求的解析與應答功能。

    總體工作流程圖如下:
以下內容爲程序代碼:

    +-------+ [1] +-------------+ [2] +--------+
    | Admin |---->| CassiniForm |---->| Server |
    +-------+     +-------------+     +--------+
                                          | [3]
                                          V
                       +--------+ [4] +------+
                       | Client |---->| Host |
                       +--------+     +------+
                           ^              | [5]
                           |              V
                           |        +------------+ [6] +---------+
                        [7]|        | Connection |---->| Request |--+
                           |        +------------+     +---------+  | [7]
                           +----------------------------------------+

    [1] Cassini 的管理者(Admin)首先通過 CassiniForm 的界面,設定 Web 服務器端口、頁面物理目錄和虛擬目錄等配置信息;
    [2] 然後以配置信息構造 Server 對象,並調用 Server.Start 方法啓動 Web 服務器;
以下內容爲程序代碼:

public class CassiniForm : Form
{
  private void Start()
  {
    // ...
    try {
        _server = new Cassini.Server(portNumber, _virtRoot, _appPath);
        _server.Start();
    }
    catch {
      // 顯示錯誤信息
    }
    // ...
  }
}

    [3] Server 對象在建立時,將獲取或自動初始化 ASP.NET 的註冊表配置。這個工作是通過 Server.GetInstallPathAndConfigureAspNetIfNeeded 方法完成的。工作原理是通過 HttpRuntime 所在 Assembly (System.Web.dll) 的版本獲得合適的 ASP.NET 版本;然後從註冊表中查詢 HKEY_LOCAL_MACHINESOFTWAREMicrosoftASP.NET 下是否有正確的 ASP.NET 的安裝路徑;如果有則返回之;否則會根據 System.Web.dll 的版本,以及 HKEY_LOCAL_MACHINESOFTWAREMicrosoft.NETFramework 下 .NET Framework 按照目錄等信息,動態構造一個合適的 ASP.NET 註冊表配置。進行這個工作的原因是 ASP.NET 可以在按照 .NET Framework 後,使用 aspnet_regiis.exe 手工註銷掉,而運行支持 ASP.NET 的 Web 服務器,又必須有合適的設置。
    在完成配置和 ASP.NET 安裝路徑後,Server 將建立並配置 Host 對象作爲 ASP.NET 的宿主。
以下內容爲程序代碼:

public class Server : MarshalByRefObject
{
  private void CreateHost() {
    _host = (Host)ApplicationHost.CreateApplicationHost(typeof(Host), _virtualPath, _physicalPath);
    _host.Configure(this, _port, _virtualPath, _physicalPath, _installPath);
  }

  public void Start() {
    if (_host != null)
        _host.Start();
  }
}

    [4] Host 類作爲 ASP.NET 的宿主類,主要完成三部分工作:配置 ASP.NET 的運行時環境、響應客戶端(Client)發起的 Web 頁面請求、以及判斷客戶端請求的有效性。
    配置 ASP.NET 的運行時環境主要工作是,爲 ASP.NET 的執行和後面請求有效性的判斷獲取足夠的配置信息。例如 Server 能夠提供的 Web 服務端口、頁面虛擬路徑、頁面物理路徑以及 ASP.NET 程序安裝路徑等等,以及 Host 根據這些信息計算出的 ASP.NET 客戶端腳本的虛擬和物理路徑等等。此外還會接管線程所在 AppDomain 的卸載事件 AppDomain.DomainUnload,在 Web 服務器停止的時候自動終止 Web 服務。
    響應客戶端(Client)發起的 Web 頁面請求功能,是通過建立 Socket 監聽 Server 對象指定的 Web 服務 TCP 端口來完成的。Host.Start 方法建立 Socket,並通過線程池異步調用 Host.OnStart 方法在後臺監聽請求;Host.OnStart 方法則在 接收到 Web 請求後,通過線程池異步調用 Host.OnSocketAccept 方法完成請求的響應工作;Host.OnSocketAccept 則負責在處理 Web 請求的時候,建立 Connection 對象,並進一步調用 Connection.ProcessOneRequest 方法處理 Web 請求。雖然 Host 沒有使用複雜的請求分配算法,但因爲線程池的靈活使用,使得其性能完全不受處理瓶頸的限制,也是線程池使用的良好範例。
以下內容爲程序代碼:

internal class Host : MarshalByRefObject
{
  public void Start() {
    if (_started)
      throw new InvalidOperationException();

    // 建立 Socket 監聽 Web 服務端口
    _socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
    _socket.Bind(new IPEndPoint(IPAddress.Any, _port));
    _socket.Listen((int)SocketOptionName.MaxConnections);

    _started = true;
    ThreadPool.QueueUserWorkItem(_onStart); // 通過線程池異步調用
  }

  private void OnStart(Object unused) {
    while (_started) {
      try {
        Socket socket = _socket.Accept(); // 響應客戶端請求
        ThreadPool.QueueUserWorkItem(_onSocketAccept, socket); // 通過線程池異步調用
      }
      catch {
        Thread.Sleep(100);
      }
    }
    _stopped = true;
  }

  private void OnSocketAccept(Object acceptedSocket) {
    Connection conn =  new Connection(this, (Socket)acceptedSocket);
    conn.ProcessOneRequest(); // 處理客戶端請求
  }
}

    最後,判斷客戶端請求的有效性的功能,是通過三個重載的 Host.IsVirtualPathInApp 方法,提供給 Connection 在具體處理客戶端請求時調用,來判斷請求的有效性,下面討論 Connection 時再詳細解釋。

    [5] Host 在建立 Connection 對象並調用其 ProcessOneRequest 方法處理用戶請求時,Connection 對象會首先等待客戶端請求數據(WaitForRequestBytes),然後創建 Request 對象,並調用 Request.Process 方法處理請求。而其自身,則通過一堆 WaitXXX 函數,爲 Request 類提供支持。
以下內容爲程序代碼:

internal class Connection {
  public void ProcessOneRequest() {
    // wait for at least some input
    if (WaitForRequestBytes() == 0) { // 等待客戶端請求數據
      WriteErrorAndClose(400); // 發送 HTTP 400 錯誤給客戶端
      return;
    }

    Request request = new Request(_host, this);
    request.Process();
  }

  private int WaitForRequestBytes() {
    int availBytes = 0;

    try {
      if (_socket.Available == 0) {
        // poll until there is data
        _socket.Poll(100000 /* 100ms */, SelectMode.SelectRead); // 等待客戶端數據 100ms 時間
        if (_socket.Available == 0 && _socket.Connected)
          _socket.Poll(10000000 /* 10sec */, SelectMode.SelectRead);
      }

      availBytes = _socket.Available;
    }
    catch {
    }

    return availBytes;
  }


    [6] Request 在接收到 Connection 的請求後,將從客戶端讀取請求內容,並按照 HTTP 協議進行分析。因爲本文不是做 HTTP 協議的分析工作,所以這部分代碼就不詳細討論了。
    在 Request.ParseRequestLine 函數分析 HTTP 請求獲得請求頁面路徑後,會調用前面提到過的 Host.IsVirtualPathInApp 函數判斷此路徑是否在 Web 服務器提供的虛擬路徑下級,並且返回此虛擬路徑是否指向 ASP.NET 的客戶端腳本。如果 Web 請求的虛擬路徑以 "/" 結尾,則調用 Request.ProcessDirectoryListingRequest 方法返回列目錄的響應;否則調用 HttpRuntime.ProcessRequest 方法完成實際的 ASP.NET 請求處理工作。
    HttpRuntime 通過 Request 的基類 HttpWorkerRequest 提供的統一接口,採用 IoC 的策略獲取最終頁面的所在。與我前面文章中使用的 SimpleWorkerRequest 實現最大不同在於 Request.MapPath 完成了一個較爲完整的虛擬目錄到物理目錄映射機制。
    SimpleWorkerRequest.MapPath 實現相對簡陋:
以下內容爲程序代碼:

public override string SimpleWorkerRequest.MapPath(string path)
{
  if (!this._hasRuntimeInfo)
  {
    return null;
  }

  string physPath = null;
  string appPhysPath = this._appPhysPath.Substring(0, (this._appPhysPath.Length - 1)); // 去掉末尾斜槓

  if (((path == null) || (path.Length == 0)) || path.Equals("/"))
  {
    physPath = appPhysPath;
  }

  if (path.StartsWith(this._appVirtPath))
  {
    physPath = appPhysPath + path.Substring(this._appVirtPath.Length).Replace('/', '/');
  }

  InternalSecurityPermissions.PathDiscovery(physPath).Demand();

  return physPath;
}

    Request.MapPath 的實現則相對要完善許多,考慮了很多 SimpleWorkerRequest 無法處理的情況,使得 Request 的適應性更強。
以下內容爲程序代碼:

public override String Request.MapPath(String path) {
  String mappedPath = String.Empty;

  if (path == null || path.Length == 0 || path.Equals("/")) {
    // asking for the site root
    if (_host.VirtualPath == "/") {
      // app at the site root
      mappedPath = _host.PhysicalPath;
    }
    else {
      // unknown site root - don't point to app root to avoid double config inclusion
      mappedPath = Environment.SystemDirectory;
    }
  }
  else if (_host.IsVirtualPathAppPath(path)) {
    // application path
    mappedPath = _host.PhysicalPath;
  }
  else if (_host.IsVirtualPathInApp(path)) {
    // inside app but not the app path itself
    mappedPath = _host.PhysicalPath + path.Substring(_host.NormalizedVirtualPath.Length);
  }
  else {
    // outside of app -- make relative to app path
    if (path.StartsWith("/"))
      mappedPath = _host.PhysicalPath + path.Substring(1);
    else
      mappedPath = _host.PhysicalPath + path;
  }

  mappedPath = mappedPath.Replace('/', '/');

  if (mappedPath.EndsWith("/") && !mappedPath.EndsWith(":/"))
    mappedPath = mappedPath.Substring(0, mappedPath.Length-1);

  return mappedPath;
}


    關於 Cassini 的進一步討論,可以參考 www.asp.net 上的討論專版

    [7] 在 HttRuntime 完成具體的 ASP.NET 頁面處理工作後,會通過 Request.SendResponseFromXXX 系列函數,將頁面結果返回給客戶端。

    雖然 SimpleWorkerRequest.MapPath 方法實現簡單,但理論上完全可以處理多級目錄的情況。之所以在使用 SimpleWorkerRequest 時,無法處理嵌套目錄,是因爲 SimpleWorkerRequest 在構造函數中錯誤地分解了請求的頁面所在虛擬目錄等信息。
    SimpleWorkerRequest 的兩個構造函數,在將請求頁面虛擬路徑(如"/help/about.aspx")保存後,都調用了 ExtractPagePathInfo 方法對頁面路徑做進一步的分解工作。
以下內容爲程序代碼:

private void SimpleWorkerRequest.ExtractPagePathInfo()
{
  int idx = this._page.IndexOf('/');
  if (idx >= 0)
  {
    this._pathInfo = this._page.Substring(idx);
    this._page = this._page.Substring(0, idx);
  }
}

    this._pathInfo 是爲實現 HttpWorkerRequest.GetPathInfo 提供的存儲字段。而 GetPathInfo 將返回 URL 中在頁面後的路徑信息,例如對 "path/virdir/page.html/tail" 將返回 "/tail"。早期的許多 HTTP 客戶端程序,如 Delphi 中 WebAction 的分發,都利用了這個路徑信息的特性,在 Web 頁面或 ISAPI 一級之後,再次進行請求分發。但因爲 SimpleWorkerRequest 實現上或者設計上的限制,導致在處理 PathInfo 時會將 "/help/about.aspx" 類似的多級 url 錯誤切斷。最終返回給 HttpRuntime 的 this._path 將變成空字符串,而 this._pathInfo 被設置爲 "/help/about.aspx",而單級路徑如 "about.aspx" 不受影響。
    知道了這個原理後,就可以對 SimpleWorkerRequest 稍作修改,重載受到 ExtractPagePathInfo 影響的幾個方法,即可完成對多級目錄結構下頁面的支持。如果需要進一步的映射支持,如同時支持多個虛擬子目錄,可以參照 Cassini 的 Request 實現 MapPath 等方法。
以下內容爲程序代碼:

public class Request : SimpleWorkerRequest
{
  private string _appPhysPath;
  private string _appVirtPath;

  private string _page;
  private string _pathInfo;

  public Request(string page, string query, TextWriter output) : base(page, query, output)
  {
    this._appPhysPath = Thread.GetDomain().GetData(".appPath").ToString();
    this._appVirtPath = Thread.GetDomain().GetData(".hostingVirtualPath").ToString();

    this._page = page;

    // TODO: 從 page 中進一步解析 Path Info
  }

  public override string GetPathInfo()
  {
    if (this._pathInfo == null)
    {
      return string.Empty;
    }
    return this._pathInfo;
  }

  private string GetPathInternal(bool includePathInfo)
  {
    string path = (_appVirtPath.Equals("/") ? _page : _appVirtPath + _page);

    if (includePathInfo && (_pathInfo != null))
    {
      return path + this._pathInfo;
    }
    else
    {
      return path;
    }
  }

  public override string GetUriPath()
  {
    return GetPathInternal(true);
  }

  public override string GetFilePath()
  {
    return GetPathInternal(false);
  }

  public override string GetRawUrl()
  {
    string query = this.GetQueryString();

    if ((query != null) && (query.Length > 0))
    {
      return GetPathInternal(true) + "?" + query;
    }
    else
    {
      return GetPathInternal(true);
    }
  }

  public override string GetFilePathTranslated()
  {
    return _appPhysPath + _page.Replace('/', '/');
  }

  public override string MapPath(string path)
  {
    string physPath = null;

    if (((path == null) || (path.Length == 0)) || path.Equals("/"))
    {
      physPath = this._appPhysPath;
    }

    if (path.StartsWith(this._appVirtPath))
    {
      physPath = this._appPhysPath + path.Substring(this._appVirtPath.Length).Replace('/', '/');
    }

    return physPath;
  }
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章