簡介

是到了該結帳的時候了,就像你在大賣場買完東西後,要到櫃台付錢是一樣的。但是在購物網站買完東西後,總是要填寫一些個人資料,方便商家能夠把東西寄給你,這是虛擬商店比較不同的地方。

而要填寫的資料不外乎是購買人資訊、收貨人資訊等等,這些資訊大部份都能在使用者登入成為會員取得,這通常也是一般會員制購物網站的作法。

不過難題來了,雖然我們建立的是一個會員制購物網站,但是客戶卻希望能讓未註冊的訪客也能夠在這裡先買東西,而到結帳時才選擇是不是要加入會員。當然客戶永遠不會瞭解採取這種方式的難度,他認為你是網站開發人員,你一定會有辦法的。

先不要翻桌子,解決問題是我們的責任。仔細想想,訪客對購物網站的經營者而言,也可能是潛在的消費者;所以如果能提供便利的方式讓這些人轉變為會員,其實還滿重要的。

當然我們得提供畫面讓使用者填寫資料,並且把這些資料做適當的處理。不過後續的處理方式我就不多提了,現在我們把重點先放在結帳表單顯示的頁面流程,來看看物件導向思維如何應用在這上面。

註:這裡我也會略過金流及物流的部份,因為各家金流及物流的實作方式都不太一樣,要寫的話可能又會是落落長。

定義頁面顯示的流程

暫時別想太多,我們先將填寫個人資料表單頁面的產生流程定義下來:

  • 判斷是不是已登入,如果是就取得該會員的資料 (一般是從資料庫取得) 。
  • 如果不是會員 (即訪客) ,顯示空值表單,並顯示一個選項讓訪客決定要不要成為會員;否則表單就預先帶出會員的資料,但不必再填寫購買人的資訊 (收貨人則是一定要填寫的,這是一般結帳原則) 。

而不管使用者是不是會員,我們都先把資料產生出來,放到一個 Dictionary 物件中,以便 HTML 頁面可以用一致的方式顯示資料。

結帳時處理會員的程式如下:

/Checkout.asp

<%
Option Explicit
%>
<!-- #include virtual="/OOASP/functions/GetObject.asp" -->
<%
Session("MemberID") = "A123456789"
' 會員物件
Dim oMember : Set oMember = GetObject("Member")
' 會員初始化
oMember.Init
' HTML 的部份利用上面的 oMember 來顯示表單頁面
%>
<!-- #include file="CheckOut.tpl.asp" -->

因為在 ASP 中我們沒辦法把自訂類別所產生的物件放到 Session 裡,所以這邊我只利用 Session 來記住某個關鍵屬性。假設登入時, Session(“MemberID”) 會記住會員的身份證號碼;而這個 Session(“MemberID”) 是在使用者為登入時,則會是空值。

然後 HTML 頁面的部份我是用 SSI 的 Include 方式,這在小程序上非常好用,以下就是部份的 HTML 碼:

/Checkout.tpl.asp

...
<%
' 如果是訪客就要填寫購買人姓名及身份證號碼
If Not oMember.IsLogin Then
%>
<p>
<label for="shopper_name" class="text">購買人姓名</label>
<input type="text" name="shopper_name" id="shopper_name"
value="" />
</p>
<p>
<label for="shopper_pid" class="text">身份證號碼</label>
<input type="text" name="shopper_pid" id="shopper_pid"
value="" />
</p>
<%
' 如果是會員就不用填寫了
Else
%>
<p>
<label for="shopper_name" class="text">購買人姓名</label>
<span><% = oMember.GetField("Name") %></span>
<input type="hidden" name="shopper_name" id="shopper_name"
value="<% = oMember.GetField("Name") %>" />
</p>
<p>
<label for="shopper_pid" class="text">身份證號碼</label>
<span><% = oMember.GetField("PersonalNumer") %></span>
<input type="hidden" name="shopper_pid" id="shopper_pid"
value="<% = oMember.GetField("PersonalNumer") %>" />
</p>
<%
End If
%>
...
<label for="receiver_name" class="text">收件人姓名</label>
<input type="text" name="receiver_name" id="receiver_name"
value="<% = oMember.GetField("Name") %>" />
...
<label for="receiver_address" class="text">收件人地址</label>
<input type="text" name="receiver_address" id="receiver_address" />
...
<%
' 在表單中加上一個 checkbox
' 讓使用者決定要不要用現在的資料成為會員
If Not oMember.IsLogin Then
%>
<fieldset><legend>選項 (非必填) </legend>
<input type="checkbox" name="change_level" id="change_level" />
<label for="change_level">是否成為會員?</label>
</fieldset>
<%
End If
%>
<input type="submit" value="確定" />
...

註:為了不讓大家頭痛,在 HTML 的部份我省略掉很多東西,像是 JavaScript 的表單驗證等等,請先專注在 ASP 程式上就好。

其實看起來也不會太難嗎?而且不管怎樣,使用者都一定要填寫個人資料;因為如果使用者的身分是會員時,購買人的輸入欄就會變成隱藏欄位,這樣處理資料時就能夠一致了 (除了訪客可能會成為會員時) 。

先來看看顯示的結果好了,如果使用者尚未登入成為會員時,就會顯示以下畫面:

而如果已經登入成會員身份時,就會顯示以下畫面:

這時候會員就不必填寫購買人姓名和身份證了。

註:當然實際應用上的表單會比較複雜,這裡我特意簡化了。

大致瞭解程式與畫面後,我們接下來就可以完成會員類別的程式碼:

/class/Member.asp

<%
' 定義常數
Const LEVEL_GUEST = 1 ' 訪客
Const LEVEL_MEMBER = 2 ' 一般會員
' 會員類別
Class Member
Private Fields
Public IsLogin
' 物件初始化
Private Sub Class_Initialize()
IsLogin = False
' 為了方便說明,我只用了幾個簡單的會員屬性
Set Fields = Server.CreateObject("Scripting.Dictionary")
With Fields
.Add "Level", LEVEL_GUEST ' 預設等級為訪客
.Add "Name", "" ' 預設姓名為空字串
.Add "PersonalNumer", "" ' 預設身份證號碼為空字串
End With
End Sub
' 會員初始化
Public Sub Init
Dim sName : sName = ""
Dim iLevel : iLevel = LEVEL_GUEST
Dim sPersonalNumer
sPersonalNumer = UCase(Trim(Session("MemberID")))
' 從 Session 值判斷是否已登入
If sPersonalNumer = "A123456789" Then
sName = "我是一般會員"
iLevel = LEVEL_MEMBER
IsLogin = True
End If
SetField "Level", iLevel
SetField "Name", sName
SetField "PersonalNumer", sPersonalNumer
End Sub
' 設定會員屬性
Public Sub SetField(sName, vValue)
If Fields.Exists(sName) Then
Fields(sName) = vValue
End If
End Sub
' 取得會員屬性
Public Function GetField(sName)
GetField = Null
If Fields.Exists(sName) Then
GetField = Fields(sName)
End If
End Function
' 物件結束
Private Sub Class_Terminate()
Set Fields = Nothing
End Sub
End Class
%>

註:會員類別做的事當然不僅這些,這裡我簡化掉很多功能,像是 Login 方法等;目前我只需要讓範例能夠成功執行,讓大家能夠專注在頁面的流程上。

流程的改變

不過上面的做法讓我覺得心神不寧,因為我總是認為客戶絕對不會就這樣滿足。果不其然,沒多久客戶就要我們加上 VIP 會員的流程。一樣為了簡化說明,這裡我先把新的流程定義如下:

  • 判斷是不是已登入,如果是就取得該會員的資料 (一般是從資料庫取得) 。
  • 如果不是會員 (即訪客) ,顯示空值表單,並顯示一個選項讓訪客決定要不要成為會員;否則表單就預先帶出會員的資料,但不必再填寫購買人的資訊 (至此都和前面的規則一樣) 。
  • 如果是一般會員,就要顯示升級為 VIP 會員的選項;如果是 VIP 會員,要額外顯示可用折扣額度及折扣輸入的欄位。

註:事實上,我在專案裡所加入的功能遠比單純加上 VIP 會員流程複雜;這裡只是為了讓大家容易進入狀況,我特地以這種比較簡單的方式來說明。

我們先來看看畫面有什麼差別,首先是訪客:

我們可以看到訪客的表單跟前面是一樣的。

接下來是一般會員的表單畫面:

如上圖所示,一般會員多了一個是否升級為 VIP 會員的選項。

最後是新增的 VIP 會員表單畫面:

很清楚地,三種不同等級的使用者都有不同選項,所以我們的程式也要有所因應。

不過會員類別的部份其實可以不用變動,因為實際上會員資料是從資料庫取得的;但為了模擬程式效果,我還是加入了 VIP 會員的常數定義及程式判斷:

/class/Member.asp

' 定義常數
Const LEVEL_GUEST = 1 ' 訪客
Const LEVEL_MEMBER = 2 ' 一般會員
Const LEVEL_VIP = 3 ' VIP 會員
' 會員類別
Class Member
...
' 會員初始化
Public Sub Init
...
' 從 Session 值判斷是否已登入
Select Case sPersonalNumer
Case "A123456789"
sName = "一般會員"
iLevel = LEVEL_MEMBER
IsLogin = True
Case "V123456789"
sName = " VIP 會員"
iLevel = LEVEL_VIP
IsLogin = True
End Select
...
End Sub
...
End Class

至於 Checkout.asp 就暫時不用修改了,主要的變化是在 HTML 頁面上,因此我們可以將 HTML 頁面的選項區修改如下:

/Checkout.tpl.asp

...
<fieldset><legend>選項 (非必填) </legend>
<%
' 在表單中加上一個 checkbox
' 讓使用者決定要不要用現在的資料成為會員
If Not oMember.IsLogin Then
%>
<input type="checkbox" name="change_level" id="change_level" />
<label for="change_level">是否成為會員?</label>
<%
' 在表單中加上一個 checkbox
' 讓一般會員決定要不要升級為 VIP 會員
ElseIf LEVEL_MEMBER = oMember.GetField("Level") Then
%>
<input type="checkbox" name="change_level" id="change_level" />
<label for="change_level">是否升級為 VIP 會員?</label>
<%
' 在表單中加上一個文字欄位
' 讓 VIP 會員使用點數
ElseIf LEVEL_VIP = oMember.GetField("Level") Then
%>
<p>您可用的 ECoupon 點數:100</p>
<label for="ecoupon_number">輸入欲使用的 ECoupon 點數:</label>
<input type="text" name="ecoupon_number" id="ecoupon_number" size="10" />
<%
End If
%>
</fieldset>

但是我仔細思考了一下,如果我們再用 If Else 結構,往後維護肯定很麻煩,而且 Checkout.tpl.asp 的架構會變得非常之龐大,這可不是我樂意見到的。

註:上面程式之所以看起來很簡單的原因,是因為我簡略掉很多細節。

流程抽象化

我重新歸納並且分開三種角色的流程,看看它們到底有什麼不同。

訪客:

  • 建立空的會員資料,購買人欄位為可填。
  • 顯示收貨人的填寫欄位。
  • 加上一個詢問是不是要成為會員的選項。

一般會員:

  • 預先帶出會員的資料,但購買人欄位變為不可填。
  • 顯示收貨人的填寫欄位。
  • 加上升級為 VIP 會員的選項。

VIP 會員:

  • 預先帶出 VIP 會員的資料,但購買人欄位變為不可填。
  • 顯示收貨人的填寫欄位。
  • 顯示可用折扣額度。

看起來只有第二個步驟是一樣的,第一步和第三步好像都不太一樣,但是真的是不一樣嗎?

在三個角色的第一個步驟裡,不管是建立空的會員資料或是從資料庫讀取會員資料,最後不是都會在 HTML 頁面上顯示購買人欄位嗎?差別只是要不要填寫而已。從這個方向去想,其實它們做的事是滿相像的。

同樣地對第三個步驟來說,每種層級都會有不同的附屬畫面,例如訪客有詢問是否要成為會員的選項,而 VIP 會員會有一個額外顯示可折扣額度的功能;其實我們也可以將它視為附屬在顯示表單流程裡。

因此不管是哪一個角色,我們都能得到一個基本的概念流程:

  • 取得角色資料,決定是否讓使用者填寫購買人資訊。
  • 顯示收貨人的填寫欄位。
  • 依照使用者等級決定表單所以顯示的額外資訊。

這就是流程抽象化。

Template Method 模式

什麼是 Template Method

抽象化有什麼好處還記得嗎?沒錯!就是可以用相同的方式來處理不同的邏輯。我們已經將上面三種會員的結帳表單顯示流程歸納出了相同的抽象結構,所以我們是不是能用相同的方式來處理它們呢?這時候就是 Template Method 模式大展身手的時機了。

Template Method 的基本概念就是在父類別 (Base) 的某個方法 (Run) 中定義好一個流程骨架,然後讓子類別 (ConcreteClass) 繼承,再由子類別去實作流程中的每個步驟。我們可以用以下的 UML 圖來表示:

Template Method UML

如你所見, Base 類別的 Run 函式定義好了整個流程骨架,而它就不能被子類別所覆寫,以免打亂了整個流程。這在良好物件導向特性語言裡,可以用 final 關鍵字來指定。

所以子類別 (ConcreteClass) 只需要關心步驟的實作,而執行的順序早已由 Base 類別的 Run 函式決定好了。也因此每個角色的流程雖然都一樣,但透過子類別上各步驟實作上的不同,我們就能夠清楚地分離每個角色該做的事。

如何實作 Template Method

先前提過 ASPUnit 用到了一個 Template Method 模式,但是沒有繼承機制的 ASP 如何實作 Template Method 呢?

Template Method 裡有句名言: Don’t Call Me, I Call You!

意思就是父類別會去呼叫子類別的方法;雖然在似類 Java 的語言裡,實際執行動作的還是子類別的實體 (因為父類別是抽象的) ,但是在 ASP 中卻真的是由父類別的實體去呼叫子類別的實體!

還是實際用前面的例子來示範好了,首先我先把 Checkout.asp 改一下,讓大家清楚程式會怎麼跑:

/Checkout.asp

<%
Option Explicit
%>
<!-- #include virtual="/OOASP/functions/GetObject.asp" -->
<!-- #include file="class/CheckoutForm.asp" -->
<%
Session("MemberID") = "A123456789"
' 會員物件
Dim oMember : Set oMember = GetObject("Member")
' 會員初始化
oMember.Init
' 建立表單顯示物件
' 因為用到了 SSI 指令,所以不能使用 GetObject 來建立物件
Dim oCheckoutForm : Set oCheckoutForm = New CheckoutForm
oCheckoutForm.SetMember oMember
oCheckoutForm.Display
%>

你應該會發現原來的 SSI Include 指令被我換成一個 CheckoutForm 類別物件了,它扮演的角色就是 Template Method 中的父類別,也就是負責定義流程骨架的角色。

接下來是 CheckoutForm 類別,它是用來顯示表單的:

/class/CheckoutForm.asp

<!-- #include file="GuestCheckoutForm.asp" -->
<!-- #include file="MemberCheckoutForm.asp" -->
<!-- #include file="VIPCheckoutForm.asp" -->
<%
Class CheckoutForm
' 實體物件
Private ConcreteObject
' 被繼承的物件
Public Prototype
' 會員
Public Member
' 會員層級的對應表
Private LevelList
' 物件初始化
Private Sub Class_Initialize()
Set ConcreteObject = Me
Set Prototype = Me
Set LevelList = _
Server.CreateObject("Scripting.Dictionary")
With LevelList
.Add LEVEL_GUEST, "Guest"
.Add LEVEL_MEMBER, "Member"
.Add LEVEL_VIP, "VIP"
End With
End Sub
' 設定會員
Public Sub SetMember(oMember)
Set Member = oMember
Set ConcreteObject = Me
On Error Resume Next
Execute "Set ConcreteObject = New " &amp; _
LevelList(oMember.GetField("Level")) &amp; _
"CheckoutForm"
On Error Goto 0
Set ConcreteObject.Prototype = Me
End Sub
' 顯示表單
Public Sub Display()
%><!-- #include file="../CheckOut.tpl.asp" --><%
End Sub
' 顯示購買人欄位
Public Sub DisplayShopperFields()
%><p>
<label for="shopper_name" class="text">購買人姓名</label>
<span><% = Prototype.Member.GetField("Name") %></span>
<input type="hidden" name="shopper_name" id="shopper_name"
value="<% = Prototype.Member.GetField("Name") %>" />
</p>
<p>
<label for="shopper_pid" class="text">身份證號碼</label>
<span><% = Prototype.Member.GetField("PersonalNumer") %></span>
<input type="hidden" name="shopper_pid" id="shopper_pid"
value="<% = Prototype.Member.GetField("PersonalNumer") %>" />
</p><%
End Sub
' 顯示收貨人欄位
Public Sub DisplayReceiverFields()
%><p>
<label for="receiver_name" class="text">收件人姓名</label>
<input type="text" name="receiver_name" id="receiver_name"
value="<% = Prototype.Member.GetField("Name") %>" />
</p>
<p>
<label for="receiver_address" class="text">收件人地址</label>
<input type="text" name="receiver_address" id="receiver_address" />
</p><%
End Sub
' 顯示選擇欄位
Public Sub DisplayOptionFields()
End Sub
' 物件結束
Private Sub Class_Terminate()
Set ConcreteObject = Nothing
Set Prototype = Nothing
Set LevelList = Nothing
Set Member = Nothing
End Sub
End Class
%>

注意,我們在 SetMember 函式裡用了 Factory Method 模式,它會幫助我們自動產生對應的 CheckoutForm 衍生物件。

在 CheckoutForm 類別中, Display 函式就是一個 Template Method ;看起來它只是將原來引入 Checkout.tpl.asp 的動作搬過來而已,但這其實是告訴我們流程已經定義在 Checkout.tpl.asp 裡:

/Checkout.tpl.asp

...
<fieldset><legend>購買人資訊</legend>
<% ConcreteObject.DisplayShopperFields %>
</fieldset>
<fieldset><legend>收件人資訊</legend>
<% ConcreteObject.DisplayReceiverFields %>
</fieldset>
<fieldset><legend>選項 (非必填) </legend>
<% ConcreteObject.DisplayOptionFields %>
</fieldset>
...

而 DisplayShopperFields 、 DisplayReceiverFields 、 DisplayOptionFields 就是我們的步驟函式,也就是說子類別只需要實作這三個函式即可。當然在 CheckoutForm 類別裡,我們也可以把預設的動作寫上去,這樣子類別如果有不想覆寫的步驟,就可以轉交給父類別執行。

可是到底要怎麼模擬繼承呢? ASPUnit 採用了一個很聰明的作法,也就是反過來將子類別的方法或屬性,移交給父類別來執行。

什麼意思呢?首先我們在父類別中定義了兩個屬性: Prototype 及 ConcreteObject ,而在子類別中定義了一個 Prototype 屬性。在 CheckForm 類別實體化時,把 Prototype 及 ConcreteObject 兩個屬性都指向自己 (Me) 。

而在 Factory Method (也就是 SetMember) 執行後,就會把 ConcreteObject 指向真正的子類別 (GuestCheckoutForm 、 MemberCheckoutForm 、 VIPCheckoutForm 其中之一) 的實體;並且同時將子類別的 Prototype 屬性指向 CheckoutForm 的實體 (Me) ,讓雙方都能同時相互參考。

如此一來,父類別就會透過 ConcreteObject 來呼叫子類別;如果子類別有不想實作的函式,就透過 Prototype 屬性把要求轉交給父類別;整個執行的角色就是父類別,而這其中的關鍵就是 SetMember 函式。

換句話說,我們在執行時期只會操作到父類別的實體。至於子類別的實體,則是由父類別實體自行產生並控制。

我想你大概已經昏了吧?直接看看 CheckoutForm 的子類別好了:

/class/GuestCheckoutForm.asp

<%
Class GuestCheckoutForm ' Extends CheckoutForm
' 被繼承的物件
Public Prototype
' 顯示購買人欄位
Public Sub DisplayShopperFields()
%><p>
<label for="shopper_name" class="text">購買人姓名</label>
<input type="text" name="shopper_name" id="shopper_name"
value="" />
</p>
<p>
<label for="shopper_pid" class="text">身份證號碼</label>
<input type="text" name="shopper_pid" id="shopper_pid"
value="" />
</p><%
End Sub
' 顯示收貨人欄位
Public Sub DisplayReceiverFields()
Prototype.DisplayReceiverFields
End Sub
' 顯示選擇欄位
Public Sub DisplayOptionFields()
%><input type="checkbox" name="change_level" id="change_level" />
<label for="change_level">是否成為會員?</label><%
End Sub
End Class
%>

由於 GuestCheckoutForm 的購買人欄位和其他兩個類別不同,所以我覆寫了 CheckoutForm 的 DisplayShopperFields 函式,而 DisplayOptionFields 也是一樣。但是因為 DisplayReceiverFields 用預設的即可,因此我便透過 Prototype 來呼叫父類別實體裡的 DisplayReceiverFields 函式。

另外我把 HTML 頁面中屬於訪客條件區塊的部份集中到 GuestCheckoutForm 裡,這樣我們就能很明確知道訪客應該執行的步驟。

註:這裡用 ASP 模擬繼承時有個缺點,那就是方法必須再宣告一次,然後呼叫父類別的實作來執行。

以此類推, MemberCheckoutForm 及 VIPCheckoutForm 也是一樣的道理。

/class/MemberCheckoutForm.asp

<%
Class MemberCheckoutForm ' Extends CheckoutForm
' 被繼承的物件
Public Prototype
' 顯示購買人欄位
Public Sub DisplayShopperFields()
Prototype.DisplayShopperFields
End Sub
' 顯示收貨人欄位
Public Sub DisplayReceiverFields()
Prototype.DisplayReceiverFields
End Sub
' 顯示選擇欄位
Public Sub DisplayOptionFields()
%><input type="checkbox" name="change_level" id="change_level" />
<label for="change_level">是否升級為 VIP 會員?</label><%
End Sub
End Class
%>

/class/VIPCheckoutForm.asp

<%
Class VIPCheckoutForm ' Extends CheckoutForm
' 被繼承的物件
Public Prototype
' 顯示購買人欄位
Public Sub DisplayShopperFields()
Prototype.DisplayShopperFields
End Sub
' 顯示收貨人欄位
Public Sub DisplayReceiverFields()
Prototype.DisplayReceiverFields
End Sub
' 顯示選擇欄位
Public Sub DisplayOptionFields()
%><p>您可用的 ECoupon 點數:100</p>
<label for="ecoupon_number">輸入欲使用的 ECoupon 點數:</label>
<input type="text" name="ecoupon_number" id="ecoupon_number" size="10" /><%
End Sub
End Class
%>

由上面的程式碼可以看出,我們在子類別只要實作父類別所定義的步驟函式,就能輕鬆地更換流程執行的過程。整個系統架構如下所示:

結論

看完這篇的人,大概十個裡面會有八個說:為什麼要弄得這麼複雜呀!其實這是因為 ASP (VBScript) 不支援繼承的關係;實際上使用支援 OO 特性的語言,要實現 Template Method 就會變得很簡單而且也比較容易懂。當然以上的例子並不能說是非常好,不過我想也許能讓大家瞭解 Template Method 的精神。

Template Method 可以說是把物件導向特性發揮的淋漓盡致:流程的抽象化、步驟的繼承、角色的多型;換句話說,因為 Template Method 已經幫我們定義好了流程骨架,所以如果未來我們有新的相似流程,就不必再跑到 HTML 頁面裡加上一大堆的條件判斷式。而且我們也要清楚地分離流程中的每個步驟,把它們交由子類別來實作;這樣我們就能區隔每個角色應該做的事,而不會被大量的 If Else 給淹沒。

不過應用 Template Method 模式時,還有許多要注意的地方,像是如何切割流程、設計掛勾函式 (Hook) 等,都是很有趣的課題;這些我想很多書上都有寫了,所以我就不再多提了。

還有我其實不應該把 HTML 寫在類別裡面,應該使用樣版引擎將它們分離。不過為了不讓大家搞混,所以我就省略掉了這些枝節。但是我強烈建議實際應用時,不要把 HTML 寫在程式裡面。

註:不要把樣版和 Template Method 搞混了。樣版一般是指 HTML 畫面,而 Template Method 的 Template 有流程骨架的意思。

範例程式下載

範例程式裡面包含了上面每個步驟所介紹的結帳表單原始程式,你可以參考壓縮檔案的 README.txt 來得知如何執行它們。

下載