2022/08/27

使用 FluentValidation 建立可重複利用的自訂驗證規則

當我們在進行表單填寫內容的格式或輸入值驗證時,ASP.NET / ASP.NET Core MVC 框架內建的模型驗證功能可說非常方便。但隨著生態圈的變化,現在使用 ASP.NET Core 開發的情境越來越多。雖說是承襲過去 .NET Framework 的 MVC 5,但實際進行開發時仍會發現後進者與先進相比仍有些許的差異。譬如在多語系的 Web API 內實體化一個模型,並在沒有 POST 的動作下進行驗證時,若 Model 內包含子模型,子模型的驗證失敗訊息無法多語系化,最後會得到一堆字詞的語系代碼。後來經由網路前輩們的建議,改用 FluentValidation 即解決了這個問題。

FluentValidation 完美實現驗證邏輯抽離的情境,我們再也不用在 Model 內各屬性上方設定一堆 DataAnnotations 規則,不但可針對各種模型的驗證規則進行統一維護,Model 的定義上也不會因為制定太多規則而顯得雜亂。不過由於 FluentValidation 是伺服器端的驗證,雖然有提供基礎的前端驗證

data-val-*
屬性綁定,但驗證規則若包含
When()
Must()
等內建條件式規則或自己開發的可重複利用性的複雜式驗證規則,前端的驗證屬性綁定就得自己來了。

由於 FluentValidation 10 至 11 有許多破壞性更新,而 11.1 至 11.2 光是整合至 ASP.NET 的註冊方式又有些許的異動,加上網路上查了許久的教學範例研判都是版本 9 之前的版本,光是驗證規則使用的 API 語法與當今最新的 11.2 版本就有很大的差異。在官方範例及文件乏善可陳且諸多異動都藏在 GitHub Issues 內的情況下,經過多方研究與嘗試,好不容易終於完成了適用於 11.2 版本的實作(累~),所以以下就來記錄一下完整的開發範例,造福一下跟我遇到相同狀況的迷途羔羊。


範例情境

現在 Model 內有 Length 與 LengthLimit 兩個屬性。有效的驗證規則為 Length 的填入值必須小於 LengthLimit 的輸入值。由於 Length 的最大值需自 LengthLimit 取當下的值,所以無法使用內建的 LessThan() 規則。這時,我們必須撰寫一個名為 LowerThanValidator 的伺服器及客戶端的自訂驗證規則。

 

前置作業

 

1. 安裝套件

首先,至 nuget 安裝 11.2 以上版本的 FluentValidation.AspNetCore 套件,或直接於套件主控管理器執行:

Install-Package FluentValidation.AspNetCore -Version 11.2.1

 

2. 接著進行 Model 定義

// Model.cs

/// <summary>範例用模型</summary>
/// <remarks>驗證邏輯參閱 <see cref="LengthModelValidator"/> 設定</remarks>
public class LengthModel
{
    /// <summary>識別碼</summary>
    [Key]
    public long Key { get; set; }
    /// <summary>標題</summary>
    public string Title { get; set; } = null!;
    /// <summary>長度</summary>
    public int Length { get; set; } = 0;
    /// <summary>長度填寫最大限制值</summary>
    public int LengthLimit { get; set; } = 100;
}

 

Server 端

完成前置作業後,來開發伺服器端的自訂驗證規則。

1. 先制定介面以方便在後續進行 ASP.NET Core 註冊自訂客戶端驗證時使用

// Validator.cs

/// <summary>驗證:數值須小於同個模型中指定屬性的值</summary>
public interface ILowerThanValidator : IPropertyValidator
{
    /// <summary>同模型中,與之比較的指定屬性的名稱</summary>
    public string DependentProperty { get; }
}

 

2. 接著開始開發伺服器端的自訂驗證器

 // Validator.cs

/// <summary><see cref="ILowerThanValidator"/> 實作</summary>
public class LowerThanValidator<T> : PropertyValidator<T, int>, ILowerThanValidator
{
    public string DependentProperty { get; }

    public LowerThanValidator(string dependentProperty)
    {
        DependentProperty = dependentProperty;
    }

    //建立驗證器名稱
    public override string Name => "LowerThanValidator";

    //建立驗證規則邏輯
    public override bool IsValid(ValidationContext<T> context, int value)
    {
        //取得指定屬性的設定值
        int dependentPropertyValue = context.InstanceToValidate.GetValue(DependentProperty, 0);

        //替換錯誤訊息字串中 {PropertyName}、{LessThanValue} 的顯示文字
        context.MessageFormatter.AppendArgument("PropertyName", DependentProperty);
        context.MessageFormatter.AppendArgument("LessThanValue", dependentPropertyValue);
        return value < dependentPropertyValue;
    }

    //建立預設的錯誤訊息
    //若驗證規則設定時未使用 .WithMessage("...") 設定錯誤訊息,將回傳本方法預設的錯誤訊息
    protected override string GetDefaultMessageTemplate(string errorCode)
        => "The value of {PropertyName} must less than the value of {DependentProperty}.";
}

 

3. 以自訂的驗證器建立驗證規則 "LowerThan()",以方便後續屬性驗證設定時將規則套用

// Validator.cs

/// <summary>自訂的驗證規則</summary>
public static class CustomFluentValidationExtensions
{
    /// <summary>
    /// 數值須小於同個模型中指定屬性的值
    /// </summary>
    /// <typeparam name="T">泛型類型實例</typeparam>
    /// <param name="ruleBuilder">驗證規則生成器介面實體</param>
    /// <param name="dependentProperty">同模型中,與之比較的指定屬性的名稱</param>
    /// <returns>驗證規則生成器介面實體 (IRuleBuilder<T, TProperty>)</returns>
    public static IRuleBuilderOptions<T, int> LowerThan<T>(
        this IRuleBuilder<T, int> ruleBuilder, string dependentProperty)
    {
        return ruleBuilder.SetValidator(new LowerThanValidator<T>(dependentProperty));
    }
}

 

4. 建立 Model 的驗證規則,並將自訂的驗證規則 LowerThan() 套用至 Length 屬性

/// <summary>範例用模型的驗證定義</summary>
public class LengthModelValidator : AbstractValidator<LengthModel>
{
    public LengthModelValidator(CascadeMode cascadeMode = CascadeMode.Continue)
    {
        ValidatorOptions.Global.DefaultClassLevelCascadeMode = cascadeMode;

        int title_maxlength = 50;
        RuleFor(x => x.Title)
            .NotNull().WithMessage("必填")
            .NotEmpty().WithMessage("必填")
            .MaximumLength(title_maxlength).WithMessage(string.Format("最大長度為 {0} 個字元", title_maxlength))
            ;        
        RuleFor(x => x.Length)
            .NotNull().WithMessage("需填入數字")
            .NotNull().WithMessage("需填入數字")
            //套用自訂的驗證規則 "LowerThan()",
            //並指定 Length 的值須與 LengthLimit 的值進行比較
            .LowerThan(nameof(LengthModel.LengthLimit)).WithMessage("error_msg")
            ;
    }
}

 

Client 端

由於 FluentValidation 會自動將綁定驗證規則的屬性在產出 HTML 時建立 "data-val-*" 綁定屬性,所以我們必須額外替自訂的伺服器端驗證器加入客戶端驗證器。在本例中,預期應該產出以下 data-val-* 的 HTML 內容:

<input
  id="txt_length"
  data-val="true"
  data-val-lowerthan="The value of Length must less than the value of LengthLimit."
  data-val-lowerthan-dependentproperty="LengthLimit"
  data-val-required="需填入數字"
  ......
/>

 

那麼,開始開發客戶端驗證器。

1. 建立客戶端驗證器

// Validator.cs

/// <summary>客戶端驗證:數值須小於同個模型中指定屬性的值</summary>
public class LowerThanClientValidator : ClientValidatorBase
{
    ILowerThanValidator validator => (ILowerThanValidator)Validator;

    public LowerThanClientValidator(IValidationRule rule, IRuleComponent component) : base(rule, component) { }

    //在此建立要綁定在 HTML 上的驗證屬性,供 JavaScript 進行驗證綁定
    public override void AddValidation(ClientModelValidationContext context)
    {
        MergeAttribute(context.Attributes, "data-val", "true");
        //設定錯誤訊息
        MergeAttribute(context.Attributes, "data-val-LowerThan", GetErrorMessage(context));
        //所需相依的屬性名稱。
        //設定項目依伺服器端所需傳入的參數為依據
        MergeAttribute(context.Attributes, "data-val-LowerThan-dependentproperty", validator.DependentProperty);
    }
    
    //取得錯誤訊息
    private string GetErrorMessage(ClientModelValidationContext context)
    {
        //自伺服器端驗證實體中,取得預設的錯誤訊息,並處理須替代的參數值
        var cfg = context.ActionContext.HttpContext.RequestServices.GetRequiredService<ValidatorConfiguration>();
        var formatter = cfg.MessageFormatterFactory()
            .AppendPropertyName(Rule.GetDisplayName(null))
            .AppendArgument("PropertyName", context.ModelMetadata.DisplayName)
            .AppendArgument("DependentProperty", validator.DependentProperty);

        string message;
        try
        {
            message = Component.GetUnformattedErrorMessage();
        }
        catch (NullReferenceException)
        {
            message = "設定值有問題";
        }
        return formatter.BuildMessage(message);
    }
}

 

2. 完成後,建立客戶端 JavaScript 驗證規則擴充

目前最常用的客戶端驗證為 jQuery.Validate.Unobtrusive,時至距今,在前端界日漸擺脫 jQuery 依賴趨勢下,已有另一套不依賴 jQuery、針對 ASP.NET Client 端驗證需求開發,且完全可作為 jQuery.Validate.Unobtrusive 替代的 aspnet-client-validation 可使用。以下將實作兩種 Client 端自訂驗證擴充。

使用 aspnet-client-validation(推薦):

// CustomValidation.js

<script src="~/lib/aspnet-client-validation/dist/aspnet-validation.min.js"></script>
<script type="text/javascript">
    //建立驗證實體    
    var v = new aspnetValidation.ValidationService();
    
    //新增 LowerThan() 的客戶端驗證擴充
    v.addProvider('lowerthan', (value, element, params) => {
        if (!value || isNaN(value)) {
            return true;
        }
        var dependentProperty = 'txt' + params.dependentproperty;
        var dependentControl = document.getElementById(dependentProperty);
        if (dependentControl) {
            var targetvalue = dependentControl.value;
            if (!isNaN(targetvalue) && parseInt(targetvalue) > parseInt(value)) {
                return true;
            }
            return false;
        }
        return true;
    });
    
    v.bootstrap
<script>

 

使用 jQuery.Validate.Unobtrusive:

// CustomValidation.js

//須載入 jQuery.js、jquery.validate.js 及 jquery.validate.unobtrusive.js
<script src="~/lib/jquery-validation/dist/jquery.validate.js"></script>
<script src="~/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.js"></script>

<script type="text/javascript">
    //擴充客製驗證
    $.validator.unobtrusive.adapters.add('LowerThan', ['dependentproperty'], function (options) {
        options.rules['LowerThan'] = {
            dependentproperty: options.params['dependentproperty']
        };
        options.messages['LowerThan'] = options.message;
    });

    $.validator.addMethod('LowerThan', function (value, element, parameters) {
        var dependentProperty = '#txt' + parameters['dependentproperty'];
        var dependentControl = $(dependentProperty);
        if (dependentControl) {
            var targetvalue = dependentControl.val();
            if (parseInt(targetvalue) > parseInt(value)) {
                return true;
            }
            return false;
        }
        return true;
    });
</script>

 

ASP.NET Core 註冊

完成伺服器端及客戶端的自訂驗證器開發後,接著要將其註冊至 ASP.NET Core 才能使用。11.2 最大的變化,在於原先官方建議的 service.AddFluentValidation() 方法已添加「已取代」標籤,這意外著將來的版本會將這個已被替代的方法剃除。為了日後維護穩定性,將以官方建議的方法進行註冊,並提供舊版方式供參考。

建議的註冊方法:

// Program.cs

//註冊 FluentValidation
builder.Services
    //將組建中所有的伺服器端驗證規則自動進行註冊
    .AddValidatorsFromAssembly(Assembly.GetExecutingAssembly())
    //FluentValidation 11.2 官方建議的註冊方法
    .AddFluentValidationAutoValidation()
    .AddFluentValidationClientsideAdapters(cfg =>
    {
        //註冊自訂的 LowerThanValidator 客戶端驗證
        cfg.ClientValidatorFactories.Add(typeof(ILowerThanValidator),
                                         (context, rule, component) => new LowerThanClientValidator(rule, component));
    });

 

舊版註冊方法:

// Program.cs

//註冊 FluentValidation (以下為即將被淘汰的註冊方法,不建議使用)
builder.Services
    //已被 services.AddFluentValidationAutoValidation().AddFluentValidationClientsideAdapters 替代
    .AddFluentValidation(cfg =>
    {
        //將組建中所有的伺服器端驗證規則自動進行註冊
        //(RegisterValidatorsFromAssembly 已被 services.AddValidatorsFromAssembly() 替代)
        cfg.RegisterValidatorsFromAssembly(Assembly.GetExecutingAssembly());
        //註冊自訂的客戶端驗證規則
        //(ConfigureClientsideValidation 已被 services.AddFluentValidationClientsideAdapters() 替代)
        cfg.ConfigureClientsideValidation(cs =>
        {
            //註冊自訂的 LowerThanValidator 客戶端驗證
            cs.ClientValidatorFactories.Add(typeof(ILowerThanValidator),
                                            (context, rule, component) => new LowerThanClientValidator(rule, component));
        });
    });

 

完成上述設定後,相信對於自訂擴充 FluentValidation 驗證規則應該有所心得吧。這裡奉上實作後的畫面,希望上述的內容能幫助到跟我一樣卡關兩天的苦主同業朋友。


參考資料

  1. Using Fluent Validation in ASP.NET Core applications - Part 3 - Client side validation
    (此為經過 Google Translate 文章,原文為波斯文)
  2. Extending Client Side Validation with FluentValidation and jQuery Unobtrusive in an ASP.NET Core Application
  3. 第47章 表单验证之DataAnnotations与FluentValidation
  4. Announcement: Deprecation of RegisterValidator... methods within calls to AddFluentValidation #1963 
  5. Announcement: Changes to ASP.NET Integration registration methods (AddFluentValidation()) #1965 
     


2021/05/19

[SQL] INSERTED

最近開發需求中,須建立一個主鍵為
uniqueidentifier
型別的表,且當新增一筆資料後須將新建資料的主鍵傳回。由於主鍵不再是
IDENTITY
型態的數值,無法使用
SELECT SCOPE_IDENTITY() AS NewID
方式取得,因此直覺改法就是在執行
INSERT
指令前先透過
NEWID()
產生 GUID,最後再將該筆 GUID 字串
SELECT
回去。


爬了一些網路上的教學後發現:若要將 GUID 作為主鍵,建議使用
NEWSEQUENTIALID()
取代
NEWID()
。不過替換過程是有代價。由於
NEWSEQUENTIALID()
只能使用在
DEFAULT
運算式,無法在預存程序程式碼中產生並指派到參數內,因此若新增資料後要回傳自動透過
NEWSEQUENTIALID()
產生的 GUID 值,需用點小技巧。此時,
INSERTED
指令就派上用場了。


--Table: NewSeqIdDemo

ID                                     FirstName    LastName
-------------------------------------  -----------  -----------
35881e9e-99b8-eb11-80d7-00155d321b03   Felix        Huang
e950efba-99b8-eb11-80d7-00155d321b03   Lanny        Huang

上表中,若執行
INSERT
指令後想取得剛才新增該筆紀錄的特定欄位,可以在
VALUES
的指令前加入
OUTPUT INSERTED.<欄位名稱>
,如:

INSERT INTO NewSeqIdDemo (FirstName, LastName)
OUTPUT INSERTED.FirstName, INSERTED.LastName
VALUES ('Vincent', 'Huang')

這指令很方便,任何欄位值都可以回傳,不過可惜的是無法一一將各欄位取出的值塞進已宣告的參數內。若現在想將 ID 塞進某個已宣告的參數內,方法很簡單,只需要將取出來的欄位值塞進一個資料表內,再透過
SELECT
指令,就可以輕鬆取得並賦值到指定的參數了。

DECLARE @ID AS UNIQUEIDENTIFIER
--宣告一個承接 INSERTED 取出值的臨時資料表
DECLARE @table TABLE (ID UNIQUEIDENTIFIER)

INSERT INTO NewSeqIdDemo (FirstName, LastName)
OUTPUT INSERTED.ID INTO @table
VALUES ('Vincent', 'Huang')

SELECT @ID = ID FROM @table




參考來源:

  1. NEWSEQUENTIALID (Transact-SQL)
  2. Return the uniqueidentifier generated by a default on insert
  3. SQL 下完 Insert Into 之後,取得剛剛 Insert 的欄位值 (指定返回欄位)

2019/01/04

在 IIS Express 使用自訂網域

在網站開發階段時,有時會遇到「使用自訂網域測試」的情境。若站台已架到 IIS,透過修正系統 Host 資訊,將自訂網域指到
127.0.0.1
即可在本機環境中進行測試。但若遇到系統還沒開發完成,系統尚未發佈到 IIS 的情況時,使用 Visual Studio 自帶的 IIS Express 則是最便利的測試方式。然而運行在 IIS Express 的系統在進行自訂網域測試時,並非直接修改系統 Host 資訊即可,接下來將紀錄相關的設定步驟。

Step.1 開啟 Visual Studio
雖然這是廢話,但接下來很重要:請務必使用「系統管理者身分」執行 Visual Studio

Step.2  設定專案屬性
專案開啟後,打開專案屬性設定,進行以下三個步驟設定:

  1. 設定開啟的預設路徑,可大膽使用自訂的網域。(不過此步驟其實可以省略)
  2. 依據原本的設定就好,Port 號自訂。
  3. 若有異動 Port 號,記得點選「建立虛擬目錄」按鈕
Step.3 編輯 IIS Express 設定檔
開啟 IIS Express 的設定檔,在眾多(若方案中有多個專案的話)site 節點中找尋此次測試的專案設定,並在
bindings
節點中,將原先的
binding
子結點複製後重新貼上,並將
bindingInformation
屬性值內的 localhost 刪除(如下圖反白處所示)。若沒有添加
*:<port>:
,後續設定自訂網域仍會無法對應到。


至於 IIS Express 設定檔要在哪找呢?在 Visual Studio 2013 以前版本中,設定檔放置在
%USERPROFILE%\My Documents\IISExpress\config\applicationhost.config
路徑,而 Visual Studio 2015/2017 以後版本則是跟隨方案位置放置,因此只要在專案所屬的方案資料夾內找到 .vs 隱藏資料夾,便可在
.vs/config/applicationhost.config
找到設定資訊。

若開啟設定檔後仍未看到此次測試專案的 IIS Express 設定資訊,只要在專案的屬性頁面中點選「建立虛擬目錄」,VS 自動就會幫你將設定資訊添加上去。當然若想展現自我實力,自己手打也是可以啦。

Step.4 編輯 Host 設定
最後,開啟主機的 Host 檔(路徑:
C:\Windows\System32\drivers\etc
),添加 IP 與自訂網域對應(如:
127.0.0.1   dev.example.com
),接下來就可透過 IIS Express 進行自訂網域的測試作業了。


參考資料:
Using Custom Domains With IIS Express

2018/06/26

[C#] 替 Model 狀態訊息添加多國語系吧

在 MVC 中使用多國語系其實沒有很大的問題。但若將 Model 的狀態訊息 (如: Display, ErrorMessage) 也套用多國語系,這倒是第一次嘗試,因此這篇就來記錄一下。

Step.1 建立語系檔

首先在 Visual Stuio 中建立語系片語檔。除了可用 VS 內建的工具外,推薦安裝 ResXManager 套件,可在一個視窗內同時編輯所有語系內容,功能可說是非常強大呢。

建立兩個語系、兩個片語

Step.2 建立 Model 及語系

接著建立 SignIn 資料模型,並將 Model 驗證的錯誤訊息進行多語系設定。
using System.ComponentModel.DataAnnotations;

namespace WebApplication1.Models
{
    public class SignIn
    {
        [Required(ErrorMessageResourceType =typeof(MsgResource), ErrorMessageResourceName = "AccountIsRequired")]
        public string Account { get; set; }

        [Required(ErrorMessageResourceType = typeof(MsgResource), ErrorMessageResourceName = "PasswordIsRequired")]
        public string Password { get; set; }
    }
}


其實這樣基本上就大功告成了。不過實際運行時卻發現:雖然語系有正確設定,但 Model 驗證回傳的錯誤訊息卻還是回傳預設語系 (英文)。原來 Web.Config 中 system.web 區段內的 Globalization 還需要額外設定:
<globalization culture="auto" uiCulture="auto" enableClientBasedCulture="true" />

搞定!

[JS] Replace 字串內所有符合的舊字串

在 Javascript 中,若想要將一段字串內的特定字串替換成新的字串,很直覺會使用
replace()
方法這麼處理:
var sentence = 'Felix, Lanny, Felix, Felix, John';
console.log(sentence.replace('Felix', 'Vincent'));
//結果: Vincent, Lanny, Felix, Felix, John

結果發現:只有第一個符合的舊字串被取代
若要像 C# 般將句子內所有符合的舊字串都被取代,需要搭配正規式處理:
//方法1
var newSentence = sentence.replace(/Felix/g, "Vincent");
console.log(newSentence);
//結果: Vincent, Lanny, Vincent, Vincent, John

//方法2
var newSentence2 = sentence.replace(new RegExp("Felix", "g"), "Marry");
console.log(newSentence2);
//結果: Marry, Lanny, Marry, Marry, John;


好懶~那就寫成擴充方法吧!

若不想每次遇到以上需求時都得寫成這麼複雜的運算式 (懶~哈),可將上述方法寫成
String
擴充方法:
String.prototype.replaceAll = function(oldString, newString) {
    var target = this;
    return target.replace(new RegExp(oldString, 'g'), newString);
};

這麼一來,以後只要使用
sentence.replaceAll('Felix', 'Peter')
就搞定囉。

  參考來源:

2017/12/18

初始化 AutoMapper 的建議,以避免「Mapper 已初始化錯誤」

先前簡單寫過 AutoMapper 的使用方法,這對需要進行 Model/ ViewModel 資料對映作業來說,確實方便許多。過去常寫 Web,因此至今尚未遇到什麼讓人摸不著頭緒的問題。而最近開始寫 API,雖然開發的流程跟過去寫 Web 的 MVC 沒有什麼差異,但過去初始化 AutoMapper 的方法卻在 API 的專案中發現問題。

Mapper.Initialize(x => x.CreateMap());
var model = Mapper.Map(_ViewModel);

上面是過去在 Web 專案中的初始化方法。這種寫法在 API 專案中同樣可以使用,但當使用者重新呼叫相同的 API 後,會跳出這個錯誤訊息:
Mapper already initialized.You must call Initialize once per application domain/ process.
(Mapper 已經初始化。一個 Domain 或程序只能初始化一次)

會產生這問題,最關鍵的地方,在於這是用「靜態方式」初始化 Mapper 的方法。

由於在 Web 中,每呼叫一次 View,便會產生一次實體,因此用靜態方法初始化 Mapper 不會遇到這種問題。然而 API 則不同,因此初始化的方法勢必得跟著調整為下面方式:
var mapper = new MapperConfiguration(cfg => cfg.CreateMap<UserViewModel, UserModel>()
                                            .ForMember(x => x.EntityReference, opt => opt.Ignore())).CreateMapper();
var _model = mapper.Map<ContactModel>(_ViewModel);

若仍要使用靜態方法設定初始化,首先可先在 App_Start 資料夾內建立 AutoMapperConfig.cs 進行初始化設定:
public class AutoMapperConfig
{
  public static void Initialize()
  {
    Mapper.Initialize(cfg =>
    {
      cfg.CreateMap<UserViewModel, UserModel>();
      //...
    });
  }
}

再到 Global.cs 中的
Application_Start()
呼叫定義好的方法:
protected void Application_Start()
{
  //...
  App_Start.AutoMapperConfig.Initialize();
}

現在,可以在 Controller 中透過
AutoMapper.Mapper.Map(...)
對映 Model/ViewModel 了。

※以上強烈建議採用 AutoMapper 6.2.0 以上版本使用新的方法呼叫。

參考來源:
  1. Getting Started Guide (Official Guildline docs)
  2. Automapper - Mapper already initialized error

使用 AutoMapper 後的 Entity 更新,須注意 EntityState 的異動

使用 EF 及 AutoMapper,最方便的地方在於對應 Model 及 ViewModel 時,不用徒手煉鋼一筆筆將屬性值分別對應,只要透過簡單的語法,即可快速將內容進行對映。而這次在更新實體並寫入資料庫的情境下,卻發生再怎麼執行,資料庫的資料總是沒有更新的異常狀態。以下是超直覺的寫法:
//利用 EF 方式取出預計要異動的 _user 實體
User _user = context.User.Find(user.UserId);

//AutoMapper 設定,並處理對映邏輯
var mapper = new MapperConfiguration(cfg => cfg.CreateMap()
                        .BeforeMap(
                            (src, dest) =>
                            {
                                /* …異動邏輯處理… */
                            }
                            )).CreateMapper();

//進行 Mapping 後,取得異動後的 _user 實體
_user = mapper.Map<user>(user);

這段程式碼很直覺,將來源資料
_user
及 ViewModel
user
透過 AutoMapper 進行對映,並更新
_user
實體內容,接著透過
SaveChanged()
方法更新資料表。不過這段程式碼執行後並不會更新任何異動,直覺反應應該是實體狀態 (EntityState) 出現了問題,因此接著在
SaveChanges()
加入以下程式碼:
var entry = context.Entry(_user);

若有下中斷點,會發現原先預估 EntityState 應為 Modified (物件中的一個純量變數已修改),結果卻得到 Detached (物件存在,但是沒追蹤此物件):


這下大致上知道出錯原因在哪了。原來
_user
經過 AutoMapper 異動後,EntityState 已被移除追蹤,難怪 EF 再怎麼儲存異動都沒有任何作用。為了解決這個問題,此時又出現另一個直覺:「只要把狀態更改至正確的狀態,問題就解決了嘛!」,因此在
SaveChange()
前加了以下程式碼:
var entry = context.Entry(_user);

if (entry.State == EntityState.Detached)
{
  //把 _user 的狀態由 "Detached" 改為 "Modified"
  _user.State = EntityState.Modified;
}

接著出現了這個錯誤訊息:
Attaching an entity of type 'Project.Entities.User' failed because another entity of the same type already has the same primary key value. This can happen when using the 'Attach' method or setting the state of an entity to 'Unchanged' or 'Modified' if any entities in the graph have conflicting key values. This may be because some entities are new and have not yet received database-generated key values. In this case use the 'Add' method or the 'Added' entity state to track the graph and then set the state of non-new entities to 'Unchanged' or 'Modified' as appropriate.
由於我們作業一開始已自
context
先將
_user
內容取出,之後因為作業因素,再將透過 Post 或其他方式取得的 ViewModel 加到
context
裡,此時
context
便會有兩筆相同 PKey 的資料,違反了資料表準則,因此就會出現這個錯誤。

為了排除這個問題,可透過以下程式碼直接將
_user
的 EntityState 進行變更:
//Mapper 後判斷與調整實體狀態
var entry = context.Entry(_user);

if (entry.State == EntityState.Detached)
{
  var set = context.Set<User>();
  User attachedEntity = set.Find(_user.UserId);

  if (attachedEntity != null)
  {
    var attachedEntry = context.Entry(attachedEntity);
    attachedEntry.CurrentValues.SetValues(_user);
  }
  else
  {
    entry.State = EntityState.Modified;
  }
}

context.SaveChanges();

首先跟先前錯誤的直覺程式碼一樣,資料 Mapper 後先判斷當前
_user
的 EntityState。若發現是 Detached (未追蹤) 狀態,先至
context
重新取得 User 內容 (也就是
attachedEntity
),若
attachedEntity
有內容,便將目前已 Mapper 後但未有追蹤狀態的
_user
內容指派到新建立的
attachedEntry
實體,最後透過
attachedEntry
的 StateEntity 進行異動更新,這時便大功告成了。

完整範例程式碼如下:
//利用 EF 方式取出預計要異動的 _user 實體
User _user = context.User.Find(user.UserId);

//AutoMapper 設定,並處理對映邏輯
var mapper = new MapperConfiguration(cfg => cfg.CreateMap<User, User>()
                        .BeforeMap(
                            (src, dest) =>
                            {
                                /* …異動邏輯處理… */
                            }
                            )).CreateMapper();

//進行 Mapping 後,取得異動後的 _user 實體
_user = mapper.Map<User>(user);

//Mapper 後判斷與調整實體狀態
var entry = context.Entry(_user);
if (entry.State == EntityState.Detached)
{
  var set = context.Set<User>();
  User attachedEntity = set.Find(_user.UserId);

  if (attachedEntity != null)
  {
    var attachedEntry = context.Entry(attachedEntity);
    attachedEntry.CurrentValues.SetValues(_user);
  }
  else
  {
    entry.State = EntityState.Modified;
  }
}

context.SaveChanges();

參考文件
  1.  EntityState Enumeration
  2.  An object with the same key already exists in the ObjectStateManager. The ObjectStateManager cannot track multiple objects with the same key
  3.  Entity Framework 更新時出現「ObjectStateManager 中已經有具有相同索引鍵的物件。ObjectStateManager 無法追蹤多個具有相同索引鍵的物件。」錯誤