透過職責分離的技巧,可以讓 ASP.NET WebForm 更好一點。
參考資料
用了 ASP.NET WebForm 好久,近年在各大群組逛街交流時,發現其實不少人的習慣只要改一下,可以讓 ASP.NET WebForm 更好用。
在開始之前提一下,我並不是支持 ASP.NET MVC 的開發者,因…為…我不會 ASP.NET MVC XD 。是的,我最多就是看過 W3School 教學和幾本書的程度,所以或許內容有些偏頗,還請大家諒解。
抱怨第一名中,大概就是把 DB 存取放在頁面中了,會這麼做的人代表沒有遇到過大案子,所以東西一概複製貼上就可以過日子了。如果公司允許你這麼做,恭喜你找到很包容的公司,但若想要進步,今天就跟著我練習把存取層抽出來。
我們先看最糟的等級,透過 SQL DataSource 和 GridView 等控制項,直接把 SQL 以及連線都放到頁面上,雖然伺服器端的程式不會跑到 UI ,但之後要改還真是會死人…
程式將 .aspx.cs 中的程式都搬到 .aspx 中,但這是為了方便我編輯,正式環境請不要這麼做。大致上程式如下:
<%@ Page Language="C#" AutoEventWireup="true" CodeBehind="Demo1.aspx.cs" Inherits="WebApplication1.Demo1" %>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head runat="server">
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/><title></title></head>
<body>
<form id="form1" runat="server">
<asp:GridView ID="GridView1" runat="server" AutoGenerateColumns="False" DataKeyNames="ProductNo" DataSourceID="SqlDataSource1">
<Columns>
<asp:BoundField DataField="ProductNo" HeaderText="ProductNo" ReadOnly="True" SortExpression="ProductNo" />
<asp:BoundField DataField="Ean13" HeaderText="Ean13" SortExpression="Ean13" />
<asp:BoundField DataField="ProductType" HeaderText="ProductType" SortExpression="ProductType" />
<asp:BoundField DataField="Temp" HeaderText="Temp" SortExpression="Temp" />
<asp:BoundField DataField="Expire" HeaderText="Expire" SortExpression="Expire" />
<asp:BoundField DataField="Length" HeaderText="Length" SortExpression="Length" />
<asp:BoundField DataField="Width" HeaderText="Width" SortExpression="Width" />
<asp:BoundField DataField="Height" HeaderText="Height" SortExpression="Height" />
<asp:BoundField DataField="Weight" HeaderText="Weight" SortExpression="Weight" />
</Columns>
</asp:GridView>
<asp:SqlDataSource ID="SqlDataSource1" runat="server" ConnectionString="<%$ ConnectionStrings:DemoConnectionString %>" SelectCommand="SELECT * FROM [Product]"></asp:SqlDataSource>
</form>
</body>
</html>
嗯…額頭都痛起來了呢,先講講有什麼缺點吧,最基本的是這樣的程式無法經得起變更,一旦 Product 資料表改成別的名字,或取出欄位更名,當頁數一多時,會無法確定修改範疇在哪裡。
讓我們先做一件事,先把程式改成這樣。 (0 => 0.1)
<%@ Page Language="C#" AutoEventWireup="true" CodeBehind="Demo4.aspx.cs" Inherits="WebApplication1.Demo4" %>
<%@ Import Namespace="System.Data.SqlClient" %>
<%@ Import Namespace="System.Data" %>
<%@ Import Namespace="System.Configuration" %>
<script runat="server">
protected void Page_Load(object sender, EventArgs e)
{
try
{
string connStr = ConfigurationManager.ConnectionStrings["DemoConnectionString"].ConnectionString;
using (SqlConnection conn = new SqlConnection(connStr))
{
SqlCommand cmd = new SqlCommand("SELECT * FROM Product", conn);
conn.Open();
var dt = new DataTable();
SqlDataReader dr = cmd.ExecuteReader();
dt.Load(dr);
this.GridView1.DataSource = dt;
this.GridView1.DataBind();
}
}
catch (Exception ex)
{
// do something
}
}
</script>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head runat="server">
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/><title></title></head>
<body>
<form id="form1" runat="server">
<asp:GridView ID="GridView1" runat="server" AutoGenerateColumns="False" DataKeyNames="ProductNo">
<Columns>
<asp:BoundField DataField="ProductNo" HeaderText="ProductNo" ReadOnly="True" SortExpression="ProductNo" />
<asp:BoundField DataField="Ean13" HeaderText="Ean13" SortExpression="Ean13" />
<asp:BoundField DataField="ProductType" HeaderText="ProductType" SortExpression="ProductType" />
<asp:BoundField DataField="Temp" HeaderText="Temp" SortExpression="Temp" />
<asp:BoundField DataField="Expire" HeaderText="Expire" SortExpression="Expire" />
<asp:BoundField DataField="Length" HeaderText="Length" SortExpression="Length" />
<asp:BoundField DataField="Width" HeaderText="Width" SortExpression="Width" />
<asp:BoundField DataField="Height" HeaderText="Height" SortExpression="Height" />
<asp:BoundField DataField="Weight" HeaderText="Weight" SortExpression="Weight" />
</Columns>
</asp:GridView>
</form>
</body>
</html>
這樣改其實差不多糟,但至少達到一件事,我們讓控制能力提昇了,原本完全相依於 SQL DataSource 及 GridView 中,但現在是相依於 DataTable 和程式,需要時就改掉程式即可。這個很小的改變讓 UI 不再直接相依於資料庫了。雖然只讓自己進步一點點,總是個開始。
再來我們在專案中建立個 Class ,把讀取資料庫的方法放進去,這裡的範例是建立在 Project Root > DataObject > DataAccess.cs ,程式如下 (0.1 => 0.3):
using System.Data;
using System.Data.SqlClient;
using System.Configuration;
namespace WebApplication1.DataObject
{
internal class DataAccess
{
internal DataTable getProducts()
{
string connStr = ConfigurationManager.ConnectionStrings["DemoConnectionString"].ConnectionString;
using (SqlConnection conn = new SqlConnection(connStr))
{
SqlCommand cmd = new SqlCommand("SELECT * FROM Product", conn);
conn.Open();
var dt = new DataTable();
SqlDataReader dr = cmd.ExecuteReader();
dt.Load(dr);
return dt;
}
}
}
}
此時,請將頁面改為以下內容:
<%@ Page Language="C#" AutoEventWireup="true" CodeBehind="Demo5.aspx.cs" Inherits="WebApplication1.Demo5" %>
<%@ Import Namespace="System.Data" %>
<script runat="server">
protected void Page_Load(object sender, EventArgs e)
{
WebApplication1.DataObject.DataAccess da = new WebApplication1.DataObject.DataAccess();
DataTable dt = da.getProducts();
this.GridView1.DataSource = dt;
this.GridView1.DataBind();
}
</script>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head runat="server">
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
<title></title>
</head>
<body>
<form id="form1" runat="server">
<asp:GridView ID="GridView1" runat="server" AutoGenerateColumns="false">
<Columns>
<asp:BoundField DataField="ProductNo" HeaderText="ProductNo" ReadOnly="True" SortExpression="ProductNo" />
<asp:BoundField DataField="Ean13" HeaderText="Ean13" SortExpression="Ean13" />
<asp:BoundField DataField="ProductType" HeaderText="ProductType" SortExpression="ProductType" />
<asp:BoundField DataField="Temp" HeaderText="Temp" SortExpression="Temp" />
<asp:BoundField DataField="Expire" HeaderText="Expire" SortExpression="Expire" />
<asp:BoundField DataField="Length" HeaderText="Length" SortExpression="Length" />
<asp:BoundField DataField="Width" HeaderText="Width" SortExpression="Width" />
<asp:BoundField DataField="Height" HeaderText="Height" SortExpression="Height" />
<asp:BoundField DataField="Weight" HeaderText="Weight" SortExpression="Weight" />
</Columns>
</asp:GridView>
</form>
</body>
</html>
這真是個進步,我們總算達成了最最最基本的資料和介面分離,若有多個頁面呼叫 DataAccess.getProducts ,未來哪天更動欄位時,只需修改一處即可。為養成好習慣,避免過渡曝露程式庫的內部資訊,物件和方法都是 internal ,編譯完後,外界是無法存取這個物件和方法的。
到這裡先停停,我們來談談 MVC ,許多人會直覺想到 ASP.NET MVC ,或其它語言的主流框架。並不是這樣。其實 MVC 只是個設計手法,重點放在行為 (Controller)、資料 (Model)、介面 (View) 的分離。這也只是個大概念,可以再設計出自己的 C 或是 M ,甚至 M 都不一定對應到 DB 中。依需求決定使用的框架,並持續調整才是重點 (完美的為自己的不上進找藉口 XD)。
回頭來檢視剛才做了什麼,第一個範例中,沒有任何控制程式碼在裡面,連 (C) 都沒有,完全由 (V) 主導了畫面、資料、控制。第二個範例中,我們試圖切割出 (C) ,為它賦予的職責就是讀取產品清單,並呈現到畫面,此時 (V) 由主動改為被動。第三個範例,我們試圖將 (M) 層也切割出來,目前僅一個簡單的資料庫存取,但若其它程式也需要產品清單時,便可復用。當然,這完全是一個不合格的解決方案,甚至連 MVC 都稱不上。但這是很好的第一步,我們開始試著區分負責不同事情的模組了。
第四個範例,我們來改寫剛才的資料存取程式 DataAccess.cs 。 DataTable 是個很棒的資料容器,可是欄位弱型別的特性讓開發者難以確認內容。用它當回傳值,將為程式帶來不確定性。現在我們試著撰寫資料承載物件,明訂回傳結果的欄位和格式。
首先建立資料容器類別,檔案建立在 Project Root > DataObject > ProductValueObject.cs 內容如下:
要注意我的範例 DB 所有欄位都不允許為 NULL ,但現實狀況往往不同,這點要視自己專案而訂。
namespace WebApplication1.DataObject
{
public class ProductValueObject
{
public string ProductNo { get; set; }
public string Ean13 { get; set; }
public string ProductType { get; set; }
public string Temp { get; set; }
public string Expire { get; set; }
public double Length { get; set; }
public double Width { get; set; }
public double Height { get; set; }
public double Weight { get; set; }
}
}
原本回傳 DataTable 的程式進行改寫如下:
using System.Data;
using System.Data.SqlClient;
using System.Configuration;
using System.Collections.Generic;
namespace WebApplication1.DataObject
{
internal class DataAccess
{
private ProductValueObject parseDataToObject(IDataReader row)
{
var returnObj = new ProductValueObject();
returnObj.ProductNo = row["ProductNo"].ToString();
returnObj.Ean13 = row["Ean13"].ToString();
returnObj.ProductType = row["ProductType"].ToString();
returnObj.Temp = row["Temp"].ToString();
returnObj.Expire = row["Expire"].ToString();
returnObj.Length = (double)row["Length"];
returnObj.Width = (double)row["Width"];
returnObj.Height = (double)row["Height"];
returnObj.Weight = (double)row["Weight"];
return returnObj;
}
internal List<ProductValueObject> getProduct()
{
string connStr = ConfigurationManager.ConnectionStrings["DemoConnectionString"].ConnectionString;
var returnList = new List<ProductValueObject>();
using (SqlConnection conn = new SqlConnection(connStr))
{
SqlCommand cmd = new SqlCommand("SELECT * FROM Product", conn);
conn.Open();
var dt = new DataTable();
SqlDataReader reader = cmd.ExecuteReader();
while (reader.Read())
{
var productItem = this.parseDataToObject(reader);
returnList.Add(productItem);
}
}
return returnList;
}
}
}
在網頁中則只修改一行,從:
DataTable dt = da.getProducts();
改為
List<WebApplication1.DataObject.ProductDataAccess> dt = da.getProducts();
再順便將 dt 變數改名為 list 就好。
可以了,今天的改造先到這裡 (0.3 => 0.4) ,來談談剛才做了什麼吧。
為了避免弱型別在程式開發中的不確定性,定義了一個物件承載類別,它名叫 ProductValueObject ,其中所有欄位都直接對應資料庫。
從 DataReader 讀取資料塞到 ProductValueObject 的屬性中時,可以由 parseDataToObject 統一處理資料轉換的細節,例如 NULL 判斷,或將 int 轉為 Enum 。這個小變化在綁定至控制項時感覺不出來,但若是自己寫程式取得清單時,可以讓你安心確定該欄位就是個 double 或其它型別 ,且保證不為 NULL 。再處理得精細點,甚至可以訂出值上下限,避免出現考試分數滿分 100 卻顯示 150 分的奇怪狀況。
解釋一下為何欄位使用 Property 而不是 Field 。有兩個理由,第一是 .net 環境中,眾多控制項、 OpenSource 都優先支援處理 Property ,而不是 Field ,為保證修改幅度為最小,所以採用。第二是 Property 其實可以為它動點手腳,我們看看以下兩個等義的寫法:
public string Property1 { get; set; }
private string _field2 = string.Empty;
public string Property2
{
get { return this._field2; }
set { this._field2 = value; }
}
表面上看起來沒什麼差異,讓我們動點手腳後再看看。
private string _field2 = string.Empty;
public string Property2
{
get { return this._field2; }
set { this._field2 = value; }
}
public string Property3
{
get
{
if (this._field2.Length > 50)
return this._field2.Substring(0, 50) + "...";
else
return this._field2;
}
}
Property2 保留原文字,但 Property3 卻是個唯讀的屬性,它回傳和 Property2 相同的值,卻能做文字長度限制。在討論區程式中,討論串通常不會顯示全文,這時就很有用,我們可以在 DB 中保留原始資料,只在顯示時提示仍有更多內容。
今天的改造就做到這裡,完成這樣的改造後, WebForm 頁面減少了資料庫讀取、資料處理的程式,應該可以瘦身不少,在可靠性上也提升了一些。
重要的是收納了資料庫讀取產品清單至統一方法中,以後還需要讀取產品清單時,只需要寫一行,光是以後可以提早下班一點,也可以減少找 BUG 的時間一點就值得了。
附帶一提,「錯誤處理」很重要,我只有第二個範例寫了 Try Catch ,但後面都忘記了。習慣真糟啊。大家不要學我哦。任何可能發生問題的地方都記得要處理一下,即使只將錯誤寫到文字檔也好。