ASP.NET MVC 開發心得分享 (12):Model Binder 的陷阱

在使用 Model Binder 繫結資料模型時 (Entity Type) ,大部分情況都是非常方便的,可有效減少 Action 參數的用量,也可大幅降低程式複雜度。但是在我們之前的某個專案就採到一個 Model Binder 的地雷,這個地雷不是 ASP.NET MVC 的 Bug,而是一個開發時應注意的地方,採用標準的寫法準沒錯。

我的同事寫了一個「修改會員資料」的 Action,該 Action 的定義如下:

public ActionResult EditProfile(string id, [Bind(Exclude = "ID")]Member _NewMember)

然後該 Action 會執行以下兩個動作,以將該 Model 物件直接儲存更新至資料庫中 (以下為錯誤示範):

1. 先依據傳入的 id 查出資料庫中該筆資料

var _OrigMember = (from p in db.Member where p.id == id select p).FirstOrDefault();

2. 再利用 .NET 的 反映(Reflection) 機制將傳入的 Model 物件 ( _NewMember ) 逐一更新至 _OrigMember

var PropertyDesc = (from p in TypeDescriptor.GetProperties(_NewMember)
.Cast<PropertyDescriptor>()
where p.Name != "id"
select p);
foreach (var item in PropertyDesc)
{
if (item.GetValue(_NewMember) != null)
item.SetValue(_OrigMember, item.GetValue(_NewMember));
}

雖然看似合理,而且這種寫法也蠻方便的,可以一體適用所有的 LINQ to SQL 模型物件,但卻隱藏著一個致命的殺機!

假設我們有個表格叫做 Member,裡面有個欄位是「是否啟用」,欄位型態是 bit,當網站前端有個表單用來更新會員基本資料的,讓使用者輸入的欄位不包括「是否啟用」欄位,且在 Controller 中也明確指定該參數有排除「是否啟用」欄位的 Model Binding,所以會跳過該欄位的驗證邏輯。

不過,當 DefaultModelBinder 在繫結 Member 參數時,繫結的流程是:

  1. 建立一個空的 Entity 資料物件 (Member)
  2. 將表單傳入的資料(FormCollection)逐項設定至該 Entity 資料物件,若該 Member 參數有宣告 Bind 屬性且設定 Exclude 欄位的話,這些欄位會自動排除

但是,由於 Member 資料表的「是否啟用」欄位型態是 Bit,也就是就算沒有繫結該欄位,該欄位的值也一定會是 True 或 False,這時若資料庫中的資料是 True,而透過上述 Reflection 機制會寫入該 Model 的值 ( False ),在更新資料時該欄位的值就會無故被更新,導致資料錯誤!我們之前就花了好多時間才發現這個潛在的邏輯錯誤(真的很難 DEBUG,所以要快去闖關培養抓 BUG 的敏感度)

還是回歸比較正派的寫法,使用 Controller 內建的 UpdateModel 方法!所以程式會修改成如下:

[AcceptVerbs(HttpVerbs.Post)]
public ActionResult Edit(string id)
{
    var _OrigMember = (from p in db.Member where p.id==id select p)
						.FirstOrDefault();

    if (TryUpdateModel(_OrigMember, new string[] {"Name","Email","Tel"}) 
		&& ModelState.IsValid)
    {
		db.SubmitChanges();
		
        return RedirectToAction("Index");
    }
    else
    {
        return View(_OrigMember);
    }
}

**注意** UpdateModelTryUpdateModel 共有 10 個多載,由於許多一些線上的範例為了簡化說明,所以通常都會用第一個多載方法,但使用這個多載會有「安全性問題」,因為它預設會從所有從 Browser 傳來的資料 ( e.g. FormCollection ) 判斷是否有可以繫結的資料,只要發現可繫結的欄位,就會將資料直接寫入 Model 物件的公開屬性中,當你有些欄位不要更新時,請務必加上「白名單」或「黑名單」!

相關連結

  

此文章由 will 發表於 2009/11/12 下午 04:01:55

永久連結 | 評論 (0) | 此文章的RSSRSS comment feed |

分類: ASP.NET MVC | LINQ

標籤: , , ,

收藏:

SqlCacheDependency 如何套用到有多重表格關聯的情況

我們都知道 SqlCacheDependency 有個很大的限制,就是一次只能用「單一表格」做判斷,如果你有個檢視表(View)或使用的 SQL 語法有 JOIN 兩個以上的表格,就無法利用 SqlCacheDependency 幫你達成快取相依(CacheDependency)的設計,但我們大多的案子很少有「單一表格查詢」的狀況,以導致很多情境下無法使用 SqlCacheDependency 感覺十分懊惱,但我們最近想出了新方法!

假設我們有兩個表格彼此相依,「父表格」為 NewsCategory,「子表格」為 NewsContent,而我們通常只會用 LINQ 取出 NewsCategory 的資料,至於 NewsContent 的資料真正要使用時可以直接用「點表示法」( dot notation ) 將資料取出,所以套用在 LINQ 的環境下,我們就可以很輕易的將 NewsCategory 註冊到 SQL Server 中(利用 SqlCacheDependency )。

而我們遇到主要的問題是,當 NewsContent 內容變更時,我們儲存在 Cache 中的 NewsCategory 資料並不會被 SQL Server 主動通知,所以被快取的資料永遠不會被更新。

要解決這個問題,就必須讓 NewsContent 更新資料時立即更新「父表格」NewsCategory 表格關連的那筆資料,至於更新什麼資料並不重要,只要有 UPDATE 過即可,例如:

UPDATE dbo.NewsCategory SET [ID]=[ID] WHERE ID=@ID

當啟用 SqlCacheDependency 機制時,ASP.NET 會自動在 SQL Server 的資料庫中建立 一個 AspNet_SqlCacheTablesForChangeNotification 表格,用來紀錄每一註冊表格資料的變化,其中有個欄位叫 changeId,只要註冊表格中的資料被異動就會自動 +1,由此可以看出當 NewsContent 異動時 NewsCategory 也跟著變化了,如下圖例:

AspNet_SqlCacheTablesForChangeNotification

這樣的更新動作只要自己寫一組簡單的觸發程序(TRIGGER)就可以解決,範例程式如下:

SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO
CREATE TRIGGER [dbo].[NewsContent_Doggy_SqlCacheNotification_Trigger] 
   ON  [dbo].[NewsContent] 
   AFTER INSERT,DELETE,UPDATE
AS 
BEGIN
	SET NOCOUNT ON;

	Declare @ParentID uniqueidentifier

	 --insert
    IF (select count(*) from inserted) <> 0 and (select count(*) from deleted) = 0
	BEGIN
		DECLARE cur_Insert CURSOR FOR SELECT i.CategoryID FROM Inserted i
			
		OPEN cur_Insert
		
		FETCH NEXT FROM cur_Insert INTO @ParentID
		
		WHILE @@FETCH_STATUS = 0
		BEGIN
			Update dbo.NewsCategory
			Set [ID] = [ID]
			Where ID = @ParentID
			
			FETCH NEXT FROM cur_Insert INTO @ParentID
		END
		
		CLOSE cur_Insert

		DEALLOCATE cur_Insert
	END

	--update	
	IF (select count(*) from inserted) <> 0 and (select count(*) from deleted) <> 0
	BEGIN
		DECLARE cur_Update CURSOR FOR SELECT i.CategoryID FROM Inserted i
			
		OPEN cur_Update
		
		FETCH NEXT FROM cur_Update INTO @ParentID
		
		WHILE @@FETCH_STATUS = 0
		BEGIN
			Update dbo.NewsCategory
			Set [ID] = [ID]
			Where ID = @ParentID
			
			FETCH NEXT FROM cur_Update INTO @ParentID
		END
		
		CLOSE cur_Update

		DEALLOCATE cur_Update
	END
	
	--delete
	IF (select count(*) from inserted) = 0 and (select count(*) from deleted) <> 0 
	BEGIN
		DECLARE cur_Delete CURSOR FOR SELECT i.CategoryID FROM deleted i
			
		OPEN cur_Delete
		
		FETCH NEXT FROM cur_Delete INTO @ParentID
		
		WHILE @@FETCH_STATUS = 0
		BEGIN
			Update dbo.NewsCategory
			Set [ID] = [ID]
			Where ID = @ParentID
			
			FETCH NEXT FROM cur_Delete INTO @ParentID
		END
		
		CLOSE cur_Delete

		DEALLOCATE cur_Delete
	END

END

至於透過 LINQ 讀取 NewsCategory 抓取資料並快取後,為什麼連 NewsContent 的資料也會連帶被快取呢?這問題我仔細思考了一下終於理解。

原來是我們將透過 LINQ 讀取 NewsCategory 資料後 ( List<NewsCategory> ) 直接快取到 Cache 中,而這是一個簡單的 POCO 物件,所以當透過 LINQ 的「點表示法」將資料取出後,事實上所存取的物件還是在 Cache 中的物件,資料取回後寫入的地方正好也是 Cache 中的物件,因此資料也一併存在 Cache 中了,這真是一個美麗的意外阿。^_^

這又再次證明採用 ORM 技術 (LINQ) 絕對沒錯的啦,這顆甜美的果實我們已經吃好久啦,至今還是依然香甜可口!:-)

相關連結

  

此文章由 will 發表於 2009/8/25 下午 11:14:24

永久連結 | 評論 (0) | 此文章的RSSRSS comment feed |

分類: LINQ | .Net | SQL Server

標籤: ,

收藏:

SQL Server: 內部錯誤。提供給讀取資料行值的緩衝區太小。

前陣子用了 SQL Server 2005 中極少人使用的 Query Notification 功能,且我下的 SQL 指令明明就很單純,只是做一個簡單的 SELECT 查詢卻會引來 SQL Server 的內部錯誤,自己奮戰了一個多月無解,最後還是靠微軟的技術支援中心幫我解了這個難題。

如果你安裝的是中文版的 SQL Server 2005 的話訊息的話,那麼訊息就一定會只有中文,我想全世界只有我這篇文章是中文且可參考的資料,查這些資料真是累死我了,早知道就早點打電話尋求支援。

同一種錯誤,我從各方取得各種不同的錯誤訊息如下:

Event Log 得到的錯誤

內部錯誤。提供給讀取資料行值的緩衝區太小。請執行 DBCC CHECKDB,以檢查是否有任何損毀。

將錯誤訊息嘗試翻譯成英文關鍵字才能找到相關資料,完整的英文訊息如下:

Internal error. Buffer provided to read column value is too small. Run DBCC CHECKDB to check for any corruption."

從 InnerException 得到的錯誤:

嚴重錯誤 682 發生於 06 22 2009 5:28PM。請記錄錯誤和時間,並連絡您的系統管理員。

英文錯誤訊息為:

Warning: Fatal error 682 occurred at Feb 8 2007 11:49AM. Note the error and time, and contact your system administrator

透過 SQL Server Profiler 得到的錯誤:

Error: 682, Severity: 22, State: 148

因為且這個錯誤訊息是直接從 SQL Server 傳出來的,所以我直覺的研判是 SQL Server 的 Bug,也確實有查到這個這個錯誤,不過早在 Service Pack 2 就修復了,目前 SQL Server 2005 出到 Service Pack 3,建議開發人員要隨時升級到最新版,避免無謂的時間浪費。如果你的資料庫真的出問題,可以參考這篇文章來救你。

最後,我發現問題出現在我宣告 SqlCommand 的寫法造成了這個錯誤:

foreach (System.Data.Common.DbParameter dbp in dc.GetCommand(q).Parameters)
{
    cmd.Parameters.Add(new SqlParameter(dbp.ParameterName, dbp.Value));
}

最後修正成以下程式碼才解決這個問題:

foreach (System.Data.Common.DbParameter dbp in dc.GetCommand(q).Parameters)
{
    if (dbp.DbType == System.Data.DbType.AnsiString 
	 || dbp.DbType == System.Data.DbType.String)
    {
        SqlParameter pa = new SqlParameter();
        pa.DbType = dbp.DbType;
        pa.ParameterName = dbp.ParameterName;
        pa.Value = dbp.Value;
        pa.Size = 2000;   // 這裡是非常非常重要的關鍵!
        cmd.Parameters.Add(pa);
    }
    else
    {
        cmd.Parameters.Add(new SqlParameter(dbp.ParameterName, dbp.Value));
    }
}

原理說明(以我個人的理解來說,可能並非原本的SQL SERVER 的設計邏輯)

使用 SqlCacheDependency 必須先準備 SqlCommand 物件,所有 SqlCommand 中傳入的參數會變成快取資料表(暫存資料表)的索引欄位,而這些參數會綁定(Binding)該欄位的長度,以確保欄位不會變動。

當 SqlCommand 物件中的 SqlParameter 參數沒有明確指定長度時,預設就會是第一筆回傳值的長度,不過當該欄位屬「變動長度」的欄位時,就可能會發生「第一筆資料欄位長度是 8 個字元,但第二筆的資料長度是 9 個字元」的情況,以導致出現「提供給讀取資料行值的緩衝區太小」的問題。

後記

即便如此,我覺得 SQL Server 應該要能處理這種情況,這種判斷不應該在 AP 層做才對,不知道微軟會不會聽到我的心聲?還是我的觀念有誤?

不過我最近還真是「幸運」,讓我學了這麼多鬼東西。 = =''

相關連結

  

此文章由 will 發表於 2009/7/22 下午 01:01:43

永久連結 | 評論 (0) | 此文章的RSSRSS comment feed |

分類: SQL Server | LINQ | .Net

標籤: ,

收藏:

利用 LINQ to SQL 對資料做分頁時應注意的事 ( 重要 )

熟悉 LINQ to SQL 的朋友應該很清楚如何透過 Skip 與 Take 方法來取得資料的部分集合,但各位可能不知道透過這種方式分頁時有個很有可能出錯的地方,而且這個錯誤可能會讓你覺得這是 LINQ to SQL 的 Bug,有在使用 LINQ to SQL 分頁的人必看此篇文章。

假設我們有兩個表格定義如下:

Table_1

CREATE TABLE [dbo].[Table_1](
    [ID] [int] IDENTITY(1,1) NOT NULL,
    [Name] [nvarchar](50) NOT NULL,
    [CreateTime] [datetime] NOT NULL CONSTRAINT [DF_Table_1_CreateTime]  DEFAULT (getdate()),
 CONSTRAINT [PK_Table_1] PRIMARY KEY CLUSTERED 
(
    [ID] ASC
)WITH (PAD_INDEX  = OFF, STATISTICS_NORECOMPUTE  = OFF, IGNORE_DUP_KEY = OFF, 
       ALLOW_ROW_LOCKS  = ON, ALLOW_PAGE_LOCKS  = ON) ON [PRIMARY]
) ON [PRIMARY]

Table_2

CREATE TABLE [dbo].[Table_2](
    [Name] [nvarchar](50) NOT NULL,
    [ID] [int] IDENTITY(1,1) NOT NULL,
    [CreateTime] [datetime] NOT NULL CONSTRAINT [DF_Table_2_CreateTime]  DEFAULT (getdate()),
 CONSTRAINT [PK_Table_2] PRIMARY KEY CLUSTERED 
(
    [ID] ASC
)WITH (PAD_INDEX  = OFF, STATISTICS_NORECOMPUTE  = OFF, IGNORE_DUP_KEY = OFF, 
       ALLOW_ROW_LOCKS  = ON, ALLOW_PAGE_LOCKS  = ON) ON [PRIMARY]
) ON [PRIMARY]

各位應該可以看到這兩個表格幾乎一模一樣,差別只在於 ID 所在的位置不同而已:

Table_1

  • ID ( P.K. )  int  ( 自動編號 )
  • Name
  • CTime

Table_2

  • Name
  • ID ( P.K. ) int  ( 自動編號 )
  • CTime

然後我個別新增 10 筆測試資料 ( 以下語法執行 10 次 ):

INSERT INTO [TESTDB].[dbo].[Table_1] ([Name]) 
VALUES('User ' + cast(cast(rand() * 100 as int) as varchar(3)))

INSERT INTO [TESTDB].[dbo].[Table_2] ([Name]) 
VALUES('User ' + cast(cast(rand() * 100 as int) as varchar(3)))

然後我寫了個很簡單的測試程式 (Console Application),我設定每 5 筆資料一頁,分別取出兩頁的資料:

using System;
using System.Linq;

namespace ConsoleApplication1
{
    class Program
    {
        static void Main(string[] args)
        {
            MyDataContext db = new MyDataContext();

            db.Log = Console.Out;

            var q1 = from p in db.Table_1 select p;
            var q2 = from p in db.Table_2 select p;

            Console.WriteLine();
            Console.WriteLine("Table_1: Show T-SQL in page1 & page2");
            Console.WriteLine();

            var page1 = q1.Skip(0).Take(5).ToList();
            var page2 = q1.Skip(5).Take(5).ToList();

            Console.WriteLine();
            Console.WriteLine("Table_2: Show T-SQL in page1 & page2");
            Console.WriteLine();

            var t2_page1 = q2.Skip(0).Take(5).ToList();
            var t2_page2 = q2.Skip(5).Take(5).ToList();
        }
    }
}

執行下去你就會知道,在 Table_1 的分頁結果中,第一頁的資料與第二頁的資料是連貫的:

在 Table_1 的分頁結果中,第一頁的資料與第二頁的資料是連貫的

再來,你就會知道,在 Table_2 的分頁結果中,第一頁的資料與第二頁的資料就開始出現重複的資料:

在 Table_2 的分頁結果中,第一頁的資料與第二頁的資料就開始出現重複的資料

從上述結果你可以看到,我將 LINQ to SQL 在 SQL Server 中實際查詢的 T-SQL 指令也都倒出來看,你可以發現當你的 LINQ to SQL 語法沒有先進行排序時,會將依照 SQL Server 中所有欄位的出現順序來進行排序,然後依照這個排序進行分頁取得資料!所以當利用 Skip 與 Take 進行分頁時,就會發現分頁後的資料竟然是錯的,而且只有第一頁的資料與任何超過第二頁的資料會有可能出現重複的資料,而這就是最大的陷阱所在!正在使用 LINQ to SQL 的人務必要特別注意這個小細節!

終極結論

  • 當資料表中的第一個欄位是 Primary Key 時,預設可以不用先排序就可以取得正確的資料。
  • 當資料表中的 Primary Key 不是出現在第一個欄位時,要使用 Skip 與 Take 方法前,記得要先利用 OrderBy 排序過,然後再進行分頁操作,否則就跟我們一樣遇到抓錯資料的狀況。
  

此文章由 will 發表於 2009/6/4 上午 12:46:01

永久連結 | 評論 (1) | 此文章的RSSRSS comment feed |

分類: LINQ | SQL Server | Tips

標籤: ,

收藏:

解決 LINQ to SQL 在建置時會發生 validation errors 的問題

最近有個專案很奇怪,我有個 Visual Studio 方案檔,開啟後會載入好幾個專案(Project),其中有個專案負責所有與 Data Access Layer (DAL) 有關的工作,但我每次剛開啟 Visual Studio 2008 並載入專案後都無法直接按下 F6 直接建置方案( Build Solution ),都一定要先建置(Build)含有 DBML 的那個專案,才能再按下 F6 建置整個方案。

每當直接按下 F6 建置方案時,都會出現以下錯誤訊息:

Build failed due to validation errors in G:\Projects\XXXX\My.dbml. Open the file and resolve the issues in the Error List, then try rebuilding the project.

最後我的解決方式如下:

  1. 先關閉所有 Visual Studio 2008
  2. 點選 [開始] –> [執行] ,並輸入以下指令:
    "C:\Program Files\Microsoft Visual Studio 9.0\Common7\IDE\devenv" /resetskippkgs
  3. 然後再關閉 Visual Studio 2008 並重新開啟原本無法建置的方案即可修復此問題

事實上,Visual Studio 偶爾會出問題,透過這個方式通常可以解決大部分的問題,至於為什麼會發生這些問題我也不大清楚,這類詭異的問題還是需要有 "經驗" 才能解決。

  

此文章由 will 發表於 2009/5/8 下午 10:38:00

永久連結 | 評論 (1) | 此文章的RSSRSS comment feed |

分類: LINQ | Visual Basic

標籤: , ,

收藏: