技術最前線
HTML 訊息模式
Dino Esposito

目錄
真正的 AJAX 架構的特性,是在展示層和服務層之間有明確的分隔。我在上個月談論過,因為 AJAX 前端和後端之間有明確的合約,所以開啟了程式設計全新境界,但是也帶來許多架構性的問題 (請參閱
msdn.microsoft.com/magazine/cc546561)。我在其中有談論到用戶端的資料繫結和範本,也討論了瀏覽器端範本 (Browser-Side Templating,BST) 模式的實作。另外還扼要介紹了 HTML 訊息 (HTML Message,HTM) 模式,這可做為呈現 Web 用戶端使用者介面的替代模型。這個月,我要提供增強的 BST 實作,並且和 HTM 的解決方案進行比較。
AJAX 服務層
我會將標準 AJAX 展示層的伺服器端部分稱為 AJAX 服務層,以便和通常代表標準多層架構的展示層和中間層之間的連絡點服務層做區別。[圖 1] 說明此一模型。
圖 1 使用 AJAX 前端的標準多層系統 (按一下影像以放大圖片)
AJAX 服務層和用戶端前端之間,會透過 Windows® Communication Foundation (WCF) 服務所公開的 HTTP 端點通訊,並由內嵌在用戶端頁面中的 JavaScript Proxy 類別叫用。尤其當 ASP.NET AJAX 是參考平台時,要在 AJAX 服務層中設計和使用服務其實很容易。難題是在建立或更新使用者介面時才會出現。
尤其,您需要功能強大的工具 (例如以 JavaScript 為基礎的資料繫結和範本) 才能有效地操作用戶端上的資料。在此情況下,ASP.NET 部分呈現只是短期的解決方案,並不代表真正的架構改變。不過,ASP.NET 部分呈現可讓您不必以程式設計的方式產生使用者介面。部分呈現會維持檢視狀態和伺服器頁面生命週期,而且可讓您使用控制項和屬性,以宣告的方式設計使用者介面。
對於相對簡單的網站而言,這樣的模型或許仍是最佳的選項,不過對於 AJAX 前端只是更深層服務導向系統之最上層的企業而言,我相信這樣的效果會有所不足。如 [圖 1] 所示,AJAX 服務層代表的是 JavaScript 前端和一組商務服務之間的中繼層。它會新增一個安全性防護機制,以保護客戶對企業 (Customer-to-Business,C2B) 案例中的核心服務,以及操作進出 JavaScript 物件的資料,以排除兩層之間的相異性。
AJAX 服務層在 AJAX 架構中扮演的重要角色,使得架構設計人員和開發人員無法避免必須根據原始資料 (由核心服務傳回並由 AJAX 服務層操作的資料) 來提供 HTML 使用者介面的問題。您要如何才能根據原始的 JavaScript 資料,動態建立和更新瀏覽器使用者介面?
HTML UI 的一般模式
多年來一直在使用 ASP.NET 伺服器控制項,所以可能已模糊掉建置 HTML 使用者介面的概念。如果您有接觸過自訂控制項的開發,可能還記得要將 HTML 標記累積在某個緩衝區,然後再輸出至回應資料流。這種一般模式是固定的方法。僅有的增強功能也只能減少錯誤和簡化管理。因此,累積 HTML 的緩衝區可能是單純的記憶體資料流,讓您在其中寫入 HTML 常值或更複雜的元件階層,再由階層中的每個元件抽象產生一串 HTML。
在典型的 ASP.NET 中,網頁的回應是藉由編排控制項樹狀結構,然後再遞迴使用此樹狀結構。樹狀結構的每一個成員都會收到一個資料流,以在資料流中寫下自己的 HTML 標記。在 AJAX 模型中,要求的回應可能是原始資料序列化為 JavaScript Object Notation (JSON)、XML、同步發行,或您喜好的其他任何形式,以及在伺服器上產生的 HTML。
BST 模式指的情況是,要求會將原始資料傳回用戶端。HTM 模式指的情況則是,要求會傳回即可顯示之標記。
增強的 BST 實作
我在上個月的原始程式碼中建立了一個 JavaScript 類別,此類別會以三個 HTML 範本做為輸入,利用一組資料反覆運算這些輸入,然後傳回產生的 HTML 文字。[圖 2] 中的 JavaScript 程式碼顯示此程式碼的重點。

圖 2 Get 範本
|
function pageLoad()
{
if (builder === null)
{
builder = new Samples.MarkupBuilder();
builder.loadHeader($get("header"));
builder.loadFooter($get("footer"));
builder.loadItemTemplate($get("item"));
}
}
function getLiveQuotes()
{
Samples.WebServices.LiveQuoteService.Update(onDataAvailable);
}
function onDataAvailable(results)
{
var temp = builder.bind(results);
$get("grid").innerHTML = temp;
}
|
HTML 範本可以定義為 HTML 字串,或從散置在網頁中的 XML 資料島載入:
|
<xml id="item">
<tr>
<td align="left">#Symbol</td>
<td align="right">#Quote</td>
<td align="right">#Change</td>
</tr>
</xml>
|
HTML 範本中的任意語法,可以讓您尋找資料繫結項目的預留位置。本文包含的範例程式碼內,#Quote 代表繫結資料項目物件中 Quote 屬性的值。
有幾種方式可以改進此一程式碼,使其更加實用。若可以個別設計項目樣式,就是一大改進。因此,假設下載至用戶端的資料,代表著許多股票的目前報價和價格變化。在此案例中,您可能會要以綠色來呈現上漲的股票,並以紅色呈現下跌的股票。不過,若要達到此效果,您必須在呈現程序中插入一些邏輯。[圖 3] 顯示 Samples.MarkupBuilder 類別的摘錄,這會將一組股票資料集合繫結至幾個 HTML 的現有範本 (本月的下載內容中有完整的程式碼)。
MarkupBuilder 類別的核心是 _generate 方法,負責合併範本和資料。[圖 3] 所示的 _generate 方法,會接受兩個引數:資料 (data) 和回呼 (callback)。在我上個月提供的程式碼中,同一個方法只會接受 data 引數。

圖 3 MarkupBuilder 類別
|
function Samples$MarkupBuilder$_generate(data, itemCallback)
{
var pattern = /#\w+/g; // Finds all #word occurrences
var _builder = new Sys.StringBuilder(this._header);
for(i=0; i<data.length; i++)
{
var dataItem = data[i];
var template = this._itemTemplate;
var matches = template.match(pattern);
for (j=0; j<matches.length; j++)
{
var text = matches[j];
var memberName = text.slice(1);
//Invoke a callback to further modify data to be bound
var memberData = dataItem[memberName];
var temp = memberData;
if (itemCallback !== undefined)
{
temp = itemCallback(memberName, dataItem);
}
template = template.replace(matches[j], temp);
}
_builder.append(template);
}
_builder.append(this._footer);
// Return the markup
var markup = _builder.toString();
return markup;
}
|
身為用戶端開發人員,您還要指定 builder 類別在處理指定項目之範本時,要回呼的 JavaScript 函數。回呼函數的原型應該如下所示:
|
function applyFormatting(memberName, dataItem)
|
第一個引數是開頭沒有 # 符號的預留位置名稱。在大多數的情況下,第一個引數會符合繫結物件的公用屬性名稱。第二個引數是正要繫結至範本之目前執行個體的整個資料項目物件。
整個資料物件的可用性,能夠讓您完全檢查執行階段條件,以判斷標記的任何變化。回呼函數以這種方式設計,在邏輯上等於 ASP.NET 伺服器控制項的伺服器端 DataBound 事件。下列程式碼片段顯示可根據股價漲跌來變更報價色彩所需的 JavaScript 程式碼:
|
function applyFormatting(memberName, dataItem)
{
var temp = dataItem[memberName];
if (memberName == "Change" && x.charAt(0) == "+")
{
return "<span style='color:green;'>" + temp + "</span>";
}
if (memberName == "Change" && x.charAt(0) == "-")
{
return "<span style='color:red;'>" + temp + "</span>";
}
return temp;
}
|
只有針對 #Change 預留位置叫用回呼時,才會套用程式碼中的邏輯。如果針對另一個成員呼叫回呼,回呼只會傳回原始成員值。[圖 4] 顯示網頁的最後效果。
圖 4 瀏覽器中的自訂資料繫結 (按一下影像以放大圖片)
您可能覺得奇怪,[圖 4] 中有些儲存格怎麼有不同的背景色彩。因為我將前面提及的回呼是以資料繫結函數呈現,但是實際上,是針對項目範本中找到的每一個符合項目使用 #xxx 運算式來叫用回呼。這表示只要在範本標記中插入 #xxx 預留位置,即可呼叫回呼,而且幾乎可在任何位置插入 HTML 程式碼。請看以下範例:
|
<xml id="item">
<tr>
<td align="left">#Symbol</td>
<td #Style1 align="right">#Quote</td>
<td align="right">#Change</td>
</tr>
</xml>
|
#Style1 運算式會解譯為要透過回呼處理的預留位置。叫用回呼時,虛擬成員名稱 (在此案例中是 Style1) 和目前資料項目會伴隨。根據提供的資訊,如果要使用儲存格來呈現價格上漲的股票,回呼就可以將主標記的背景變更為淺黃色:
|
function applyFormatting(memberName, dataItem)
{
if (memberName == "Style1")
{
if (dataItem["Change"].charAt(0) == "+")
return "style='background-color:lightyellow;'";
else
return "";
}
...
}
|
在 ASP.NET 中,大部分以範本為基礎的控制項,都不會在頁首和頁尾區域套用資料繫結規則。Samples.MarkupBuilder 元件也不例外。不過,有時候您可能會要在頁尾中顯示從顯示資料衍生的資訊。可如此做的一個快速技巧,是在頁尾 (或頁首) 範本中定義可編寫指令碼的元素:
|
<xml id="footer">
<tr>
<td colspan="3" align="right"
style="background- color:#eeeeee;">
<small><i>provided by <b id="lblProvider"></b></i></small>
</td>
</tr>
</table>
</xml>
|
TD 標記的內容包含具有唯一 ID 的 <b> 標記。這已足以讓該元素可進一步地編寫指令碼。請注意,XML 資料島的內容,會和從遠端 WCF 服務收集的原始資料合併,準備好之後,會再透過 HTML 瀏覽器元素的 innerHTML 屬性插入網頁物件模型中。
如此一來,瀏覽器就會自動剖析 HTML 的內容,並更新文件物件模型 (Document Object Model,DOM)。這樣就可以使包含唯一 ID 字串的任何常值元素,都變成可編寫指令碼。下列範例函數是與提供股票報價之遠端 WCF 服務之呼叫相關聯的回呼:
|
function onDataAvailable(results)
{
// Bind data and update the UI
var temp = builder.bind(results, applyFormatting);
$get("grid").innerHTML = temp;
$get("lblProvider").innerHTML = results[0].ProviderName;
}
|
更新了網頁元素的 innerHTML 屬性,使其包含具有指定 ID (例如 lblProvider) 的元素之後,即可開始為該元素編寫指令碼。
BST 模式會強制您使用 JavaScript 來產生瀏覽器中所需的任何 HTML。這通常算是好事,因為它可以讓您將所有的展示邏輯隔離在一層內,如 [圖 1] 所示。
除此之外,藉由使用範本和 JavaScript 回呼,就可以和 HTML 天生具有的動態性質保持一致,並設法照顧到資料的特性和使用者的期望。範本在兼顧程式碼彈性和維護便捷性方面,會有很大的幫助。此處呈現的一般用途類別 (例如 MarkupBuilder),是完成工作的最後一環。您不能僅依賴 JavaScript 來產生 HTML,而且還要混合產生功能和呈現邏輯。因為這樣會讓程式碼很快就會變得太複雜,難以閱讀,而且必然充滿錯誤。由 Microsoft® Client AJAX 程式庫協助開發的 Helper 類別,可以幫忙解決此問題。
InnerHTML 和 DOM
在我叫用 HTML 訊息模式 (在 AJAX 應用程式中產生 HTML 標記的相反方法) 之前,要先討論 innerHTML。DOM 是標準的 API,瀏覽器會透過它利用程式設計的方式,來公開目前所顯示頁面的內容。DOM 會以元素樹狀結構來呈現網頁。在邏輯樹狀結構中的每一個節點,都會對應至具有已知行為及自己身分的一個作用中物件。
在 DOM 節點上可以完成三種基本作業:尋找節點、建立節點及操作節點。只要您知道對應元素的 ID,要識別特定的節點很容易。API 有提供一個好用的函數:
|
var node = document.getElementById(id);
|
在 ASP.NET AJAX 中,getElementById 函數會由 $get 函數包裝。如果有多個元素的 ID 相同,函數會傳回集合中出現的第一個元素。若要更新 DOM 樹狀子目錄,應該移除不要的元素,並加入新的元素。雖然以設計觀點而言,此方法簡潔又俐落,不過可能會有效能的問題。
幾乎所有的瀏覽器都支援 DOM 元素上的 innerHTML 屬性。它會設定或擷取指定元素之開始 (start) 和結束 (end) 標記間的 HTML。Internet Explorer
® 4.0 的 DHTML 物件模型引進了屬性,但是從未收入官方 DOM API 中。不過和 DOM API 比較起來,innerHTML 快多了,尤其是在建立複雜的元素結構時。請閱讀
go.microsoft.com/fwlink/?LinkId=116828,其中就有比較 innerHTML 和 DOM 的效能。innerHTML 也並非全無問題。請閱讀
go.microsoft.com/fwlink/?LinkId=116827,即可得知需要注意的潛在問題。
HTML 訊息模式
HTM 模式的目標,是讓伺服器產生要在瀏覽器中顯示的 HTML 標記區塊。可能的實作方法包括呼叫遠端 URL (服務或 HTTP 處理常式),以及接收可立即顯示的 HTML 片段。
HTM 的實作會完全依賴您在伺服器上的程式碼,亦即 AJAX 服務層。這是建立 AJAX 專屬中間層的另一個好處,這樣可以將核心服務與 AJAX 和展示需求與考量隔離 (請參閱 [圖 1])。
若要支援 HTML 訊息模式,AJAX 應用程式需要服務,來完成相關工作以及將計算結果轉換為 HTML 片段。[圖 5] 說明此事。
圖 5 支援 HTML 訊息的服務 (按一下影像以放大圖片)
其中的服務複合性原則很明顯。它只是可重複使用性原則的變形。它通常適用於服務導向架構 (Service-Oriented Architecture,SOA),在這種架構中,您會使用商務程序執行語言 (例如 Web Services Business Process Execution Language, WS-BPEL) 等複合語言,來協調商務處理程序,並從其他幾種程序的串連,來取得父服務處理程序。
輸出 HTML 的 AJAX 服務可視為複合物,由取得資料的核心服務以及將資料操作成 HTML 的轉譯器服務所組成。[圖 6] 顯示會傳回股票報價之服務的複合架構。
圖 6 支援 HTML 訊息的股票報價服務 (按一下影像以放大圖片)
在此實作中,我只是在複合類別以取得父系和包裝函式服務。從設計的觀點而言,[圖 6] 中的許多類別都會當成服務來處理 -- 股票報價提供者、資料搜尋工具、輸出轉譯器,還有輸入配接器 (您將會在本專欄稍後看到)。
實作 HTM 模式
[圖 7] 顯示我在 HTML 模式範例實作中,所使用的股票報價服務合約。該服務是以離線和線上資料提供者為中心。離線資料提供者會傳回股票報價和變化的舊值,而線上提供者則會實際連線至金融服務來傳回即時資料。

圖 7 範例股票報價服務的合約
|
namespace Samples.Services.FinanceInfo
{
[ServiceContract(Namespace="Samples.Services",
Name="FinanceInfoService")]
public interface IFinanceInfoService
{
[OperationContract]
StockInfo[] GetQuotes(string symbols, bool isOffline);
[OperationContract(Name="GetQuotesOffline")]
StockInfo[] GetQuotes(string symbols);
[OperationContract(Name="GetQuotesFromConfig")]
StockInfo[] GetQuotes(bool isOffline);
[OperationContract(Name = "GetQuotesFromConfigOffline")]
StockInfo[] GetQuotes();
[OperationContract(Name = "GetQuotesOfflineAsHtml")]
string GetQuotesAsHtml(string symbols, bool isOffline);
[OperationContract(Name = "GetQuotesFromConfigAsHtml")]
string GetQuotesAsHtml(bool isOffline);
[OperationContract(Name = "GetQuotesFromConfigAsHtmlEx")]
string GetQuotesAsHtml(string contextKey);
}
}
|
這兩個提供者都需要依賴內部搜尋工具元件,以取得實際資料。搜尋工具元件是一個介面,其中會從設定檔讀取實際搜尋工具的類別。離線提供者的預設搜尋工具元件則使用 Microsoft .NET Framework Random 類別,其中只會產生隨機數字。離線提供者的搜尋工具元件,可以使用任何可傳回金融資訊的公用 Web 服務。
經由搜尋工具類別取得的任何資料,都會再使用轉譯器類別複合到 HTML 程式碼片段中。轉譯器元件會公開一個介面,只要變更設定檔中的設定即可取代。預設的 HTML 轉譯器會利用硬式編碼的樣式,來建立一個表格。在本月的原始程式碼中,您會發現實際的 HTML 轉譯器是從預設轉譯器衍生的類別,此預設工具也會新增最後更新的標籤。下列程式碼片段顯示搜尋工具和呈現類別的介面:
|
namespace Samples.Services.FinanceInfo
{
public interface IFinanceInfoFinder
{
string ProviderName { get; }
StockInfo[] FindQuoteInfo (string symbols);
}
public interface IFinanceInfoRenderer
{
string GenerateHtml (StockInfo[] stocks);
}
}
|
這些介面可保證順暢的互通性,讓搜尋工具取得的資料可直接流入轉譯器的方法。
在此實作中,轉譯器的 GenerateHtml 方法會根據一些預先定義的設定,來建立一個表格。它通常可以使用從用戶端傳遞的其他任何樣式資訊。不過,股票報價服務的設計,是要取得在伺服器上設定為正式 HTML 產生器的轉譯器之「服務」。此「服務」只需實作前面的 IFinanceInfoRenderer 介面。
下面是使用股票服務來產生標記的範例:
|
function getLiveQuotes()
{
var isOffline = $get("chkOffline").checked;
Samples.Services.FinanceInfoService.GetQuotesFromConfigAsHtml
(isOffline,
onDataAvailable);
}
function onDataAvailable(results)
{
// Update the UI
$get("grid").innerHTML = results;
}
|
getLiveQuotes 函數會附加至用戶端事件,例如按一下按鈕或是計時器回呼。[圖 8] 顯示運作中的範例網頁。HTML 標記會使用 JSON 套件回傳至用戶端。
圖 8 執行 HTML 訊息模式 (按一下影像以放大圖片)
效能和設計考量因素
HTML 訊息模式會將 UI 的產生移至伺服器端,亦即您從用戶端呼叫的服務。此模型有利有弊。這可以讓您使用 Managed 程式碼來實作產生標記所需的任何複雜邏輯。在伺服器上,您可以利用程式設計的強大功能來讀取設定檔、連接到遠端服務,以及存取 HTML 範本的資料庫,這些都是瀏覽器無法提供的。
然而,您無法借助視覺化工具 (例如設計工具),只能自己撰寫產生標記的程式碼。標記的任何變更都必須利用 C# 程式碼處理,而且配置、資料及程式碼後置 (Codebehind) 之間將沒有清楚的分隔。
在伺服器對伺服器的案例中,由服務查詢標記的某些內部 ASP.NET 網頁的定義中可能採用更好的替代方法。這些網頁可以做為範本,使用 Visual Studio® 2008 建立,並部署在裝載 AJAX 服務層的同一個 IIS 應用程式中。如此一來,這些網頁就能夠以程式設計的方式叫用,而且傳回的標記可轉送至用戶端。
HTML 訊息模式產生的流量,通常會高於純呼叫的流量,因為後者所呼叫的服務只會傳回原始資料。不過值得一提的是,HTML 訊息模式所產生的流量會少於部分呈現。您加入的樣式和 HTML 增強功能越多,傳回的封包大小就會變得越大。
基於此,您可以將 HTML 樣式與 HTML 配置分開,只在標記中內嵌用戶端 CSS 類別所參考的樣式。如果將 HTML 標記縮減到只剩配置和資料,除了原始資料以外所傳送的額外內容就會大幅減少。我在試驗過程中有發現到,如果您只需要顯示幾個欄位,並且僅參考 CSS 用戶端樣式的類別,則使用 HTML 訊息建立的網頁所產生的流量,有時甚至會低於使用 BST 建立的網頁。
DynamicPopulate 擴充項
最後,我要花一點時間討論 AJAX Control Toolkit 中的擴充項之一 -- DynamicPopulate 擴充項,此擴充項與 HTML 訊息服務配合的效果很好。
此擴充項繫結至用戶端觸發控制項 (例如,按鈕) 時,會叫用服務方法並將結果附加至 DOM 元素的 innerHTML 屬性。當然,DynamicPopulate 擴充項在 AJAX 服務層中需要 HTML 訊息服務:
|
<act:DynamicPopulateExtender runat="server"
ID="DynamicPopulateExtender1"
BehaviorID="DynamicPopulateExtender1"
ClearContentsDuringUpdate="false"
TargetControlID="grid"
UpdatingCssClass="updating"
ServicePath="LiveQuotes.svc"
ServiceMethod="GetQuotesFromConfigAsHtmlEx"
/>
|
DynamicPopulate 擴充項對於服務也有另一要求,如 [圖 6] 中輸入配接器服務所反白顯示。經由 ServiceMethod 屬性參考的方法,需要有下列原型:
|
string MethodName(string contextKey);
|
contextKey 參數可以包含以任何格式序列化的任何資料,只要服務方法知道如何處理。在輸入配接器服務類別中,您要將輸入字串轉換成特定參數,使服務中的其他類別知道如何處理。
使用擴充項時可能遇到的問題之一,是無法防止使用者按下按鈕後的預設事件。因此按鈕若是 ASP.NET 按鈕,還是會執行回傳,而造成服務呼叫失效。以下是較常見的 DynamicPopulate 擴充項使用方法:
|
<asp:Button runat="server" id="btnRefresh" text="Live Quotes"
onclientclick="invoke();return false;" />
|
附加的 JavaScript 簡單函數會執行下列工作:
|
function invoke()
{
var extender = $find("DynamicPopulateExtender1");
var isOffline = $get("chkOffline").checked;
extender.populate(isOffline.toString());
}
|
根據此段程式碼,會合併 HTML 回應和經由擴充項之 TargetControlID 屬性指定的元素,來更新 UI。
目前的解決方案
讓 HTML 用於展示,並以 XML、JSON 及 RSS 等格式,將伺服器產生的資料移至用戶端,再於用戶端上操作資料以便展示。在 AJAX 的環境中,使用 JavaScript 語言會打亂此模型。因為將資料下載至用戶端之後,只能使用 JavaScript 建立 UI。自訂資料繫結和範本技術形式的 BST 模式,可以幫助您建立需要的 UI。
如果您習慣是以伺服器為中心,就是不喜歡 JavaScript,要怎麼辦?如果 UI 特別複雜,而您偏好使用較可靠且功能較強大的開發和偵錯工具,又該如何呢?如果在伺服器和用戶端上,您都需要複製大型資料結構中的大量演算法,又會怎麼樣呢?以 HTML 回應是否就能消除用戶端上的問題?
大致上,我認為在具備豐富用戶端物件模型的強大控制項集合出現之前,不一定能採用以 HTML 展示並以 JSON 操作資料的理想模式。目前,只要涉及 AJAX 展示,此模型就不一定適用於所有的情況。因此 HTML 訊息模式才值得參考。它不一定適合所有用途,但是確實有其好用之處。