寫 .NET 資料庫相關程式該用 EF/ORM 還是自己寫 SQL?就像手排車 vs 自排車,各有優劣及擅長的場合,亦各有支持者,我自己則是瀨尿牛丸派,單純 CRUD 用 EF (或自製 ORM) 享受強型別保護及不沾 SQL 的清爽,至於複雜查詢、批次更新刪除,則回歸自己寫 SQL 以確保執行效能。(這個議題的深入討論可參考舊文:閒聊:用 LINQ 還是自己寫 SQL?)

遇到需要自己寫 SQL 的場合,EF Core 3+ 提供了 FromSqlRaw()、ExecuteSqlRaw()、FromSqlInterpolated() 參考 等方法,可使用 SQL 字串內嵌 @paramName、{0}、 整合參數直接下 SQL 指令,例如:

var user = "johndoe";
var blogs = context.Blogs
    .FromSqlRaw("EXECUTE dbo.GetMostPopularBlogsForUser {0}", user)
    .ToList();

var user = "johndoe";
var blogs = context.Blogs
    .FromSqlInterpolated($"EXECUTE dbo.GetMostPopularBlogsForUser {user}", user)
    .ToList();

var user = new SqlParameter("user", "johndoe");
var blogs = context.Blogs
    .FromSqlRaw("EXECUTE dbo.GetMostPopularBlogsForUser @user", user)
    .ToList();

注意:前兩種寫法 EF Core 會將 {0}、 轉為 DB 參數,並非直接展開字串,絕不可自作主張改成 String.Format() 或 $"......",否則會搞出 SQL Injection 神仙難救。

不過,講到寫 SQL 指令整合,Dapper一出,誰與爭鋒。Query()、Execute() 在彈性與功能遙遙領先 EF Core 的 FromSqlRaw() 及 ExecuteSqlRaw(),若不介意專案多參照一個程式庫,直上 Dapper 是更明智的選擇。

在 EF Core DbContext 要使用 Dapper,起手式是用 DbContext.Database.GetConnection() 取得現在的連線字串,剩下就跟一般寫法相同,不需額外學習。我唯一遇到的問題,如果 Dapper 動作要跟 DbContext SaveChanges() 更新包成 Transaction,該怎麼做?

在 EF Core 可用 DbContext.Database.BeginTransaction() 啟用交易,將多個 SaveChanges() 包成交易。(參考:.NET Core 實驗室 - EF Core 與 Transaction) 沿用同一觀念,我們在交易範圍內 DbContext.Database.GetConnection() 取得 IDbConnection,由 EF Core 交易物件 IDbContextTransaction.GetDbTransaction() 取得 DbTransaction (需 using Microsoft.EntityFrameworkCore.Storage 取得擴充方法),就能將 Dapper 動作納入交易範圍。

範例程式如下:

using System.ComponentModel.DataAnnotations;
using Microsoft.EntityFrameworkCore;
using Dapper;
using Microsoft.EntityFrameworkCore.Storage;

namespace Models
{
    public class BlogDbContext : DbContext
    {
        public DbSet<Blog> Blogs { get; set; }

        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        {
            optionsBuilder.UseSqlServer(
                @"Server=(localdb)\mssqllocaldb;Initial Catalog=Blogging;Integrated Security=SSPI");
        }

        public void TestEFCoreTransactionWithDapper(bool commit = false) 
        {
            using (var trn = Database.BeginTransaction()) 
            {
                var blog = new Blog{
                    Url = "https://blog.darkthread.net",
                    Blogger = "darkthread"
                };
                Blogs.Add(blog);
                SaveChanges();
                // 呼叫 Database.GetDbConnection() 取得 IDbConnection 物件
                var cn = Database.GetDbConnection();
                // using Microsoft.EntityFrameworkCore.Storage 以取得 GetDbTransaction() 擴充方法
                // 取得 IDbTransaction 物件作為 cn.Execute 或 cn.Query 參數
                cn.Execute("INSERT INTO Blogs (Url, BLogger) VALUES (@url, @blogger)", new {
                    url = "https://www.darkthread.net",
                    blogger = "Jeffrey"
                }, transaction: trn.GetDbTransaction()); 
                if (commit) trn.Commit();
                else trn.Rollback();
            }
        }

    }
    public class Blog 
    {
        public int BlogId { get; set; }
        [Required]
        [MaxLength(1024)]
        public string Url { get; set; }
        [Required]
        [MaxLength(64)]
        public string Blogger { get; set; }
    }
}

不過,這樣子每次要 GetDbConnection()、GetDbTransaction() 太囉嗦了,於是我寫了擴充方法為 DbContext.Database 加上 DapperQuery<T>() 與 DapperExecute(),試著讓生活更美好。

using Dapper;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage;
using System.Data;

namespace Drk.AspNetCore.Extensions
{
    /// <summary>
    /// EF Core Database Extensions for Dapper
    /// </summary>
    public static class EFCoreExtensions
    {
        /// <summary>
        /// Execute with Dapper in EF Core, support transaction if enabled
        /// </summary>
        /// <param name="database">DatabaseFacade</param>
        /// <param name="commandText">The SQL to execute for the query.</param>
        /// <param name="param">The parameters to pass, if any.</param>

        /// <param name="commandTimeout">The command timeout (in seconds).</param>
        /// <param name="commandType">The type of command to execute.</param>
        /// <returns>The number of rows affected.</returns>
        public static int DapperExecute(this DatabaseFacade database, string commandText, object param = null, int? commandTimeout = null, CommandType? commandType = null)
        {
            var cn = database.GetDbConnection();
            IDbTransaction trn = database.CurrentTransaction?.GetDbTransaction()!;
            return cn.Execute(commandText, param, trn, commandTimeout, commandType);
        }
        /// <summary>
        /// Query with Dapper in EF Core, support transaction if enabled
        /// </summary>
        /// <typeparam name="T"></typeparam>
        /// <param name="database">DatabaseFacade</param>
        /// <param name="commandText">The SQL to execute for this query.</param>
        /// <param name="param">The parameters to use for this query.</param>
        /// <param name="buffered">Whether to buffer the results in memory.</param>
        /// <param name="commandTimeout">The command timeout (in seconds).</param>
        /// <param name="commandType">The type of command to execute.</param>
        /// <returns></returns>
        public static IEnumerable<T> DapperQuery<T>(this DatabaseFacade database, string commandText, object param, bool buffered = true, int? commandTimeout = null, CommandType? commandType = null)
        {
            var cn = database.GetDbConnection();
            IDbTransaction trn = database.CurrentTransaction?.GetDbTransaction()!;
            return cn.Query<T>(commandText, param, trn, buffered, commandTimeout, commandType);
        }
    }
}

如此,原本的

var cn = Database.GetDbConnection();
cn.Execute("INSERT INTO Blogs (Url, BLogger) VALUES (@url, @blogger)", new {
    url = "https://www.darkthread.net",
    blogger = "Jeffrey"
}, transaction: trn.GetDbTransaction()); 

可簡化成

Database.DapperExecute("INSERT INTO Blogs (Url, BLogger) VALUES (@url, @blogger)", new {
    url = "https://www.darkthread.net",
    blogger = "Jeffrey"
}); 

是不是優雅許多?DapperQuery<T>() 與 DapperExecute() 會偵測 DbContext 是否已啟用 Transaction (由 Database.CurrentTransaction 是否為 null 判斷),若已啟用還會自動加入 Transaction 範圍,非常方便。

打通這個環節,有種豁然開朗的感覺,未來在 EF Core 要使用 Dapper 就順手囉。

This article shows how to use Dapper smoothly inside EF Core DbContext, including join the transaction.


Comments

# by Ru

感謝黑大 拯救我的人生

# by Ho.Chun

請問一下 Q1. Model 的 Data Anntation (ex. [Required]) 如何透過指令產生 ? 我使用 dotnet ef dbcontext scaffold -d 一樣還是會走 Fluent API - Q2. IDbTransaction trn = database.CurrentTransaction?.GetDbTransaction()!; 最後的驚嘆號(!),是什麼意思呢 ?

# by Jeffrey

to Ho.Chun, Q1 - [Required] 我都是自己加上的,不知道用指令產生的做法 Q2 - ! 是 C# 8 加入的 Null-Forgiving Operator 參考:https://blog.darkthread.net/blog/efcore-6-nullable/

# by Ho.Chun

我找到使用指令產生 Data Annotation 的方法了,如下 1. dotnet tool install --global dotnet-ef 2. dotnet add package Microsoft.EntityFrameworkCore.Design 3. dotnet ef dbcontext scaffold <connection-string> <provider> --data-annotations - 注意 : --data-annotations 如果簡寫為 -d 會沒有效果 (Issue) 參考 : https://stackoverflow.com/questions/72164483/dotnet-ef-dbcontext-scaffold-command-data-annotations-or-d-command-line-param

# by kerry

感謝黑大 ! 一直是我學習路上的良師

# by Ho.Chun

Q1. 請問這樣改用 Dapper 後,還需要手動關閉連線嗎 ? Q2. 使用 EFCore 搭配生命週期為 scoped,還需要手動關閉連線嗎 ?

# by Jeffrey

to Ho.Chun, Q1. 這裡 Dapper 會共用 EF Core 所建的連線,其生命週期由 EFCore 管理,不需介入 Q2. 同上,EFCore 的連線開啟與關閉由其負責,呼叫端不需特別處理

# by Ho.Chun

了解! 不過既然 EFCore 會幫我們管理連線的開啟與關閉 那麼為何還有些地方會看到這樣寫 using var context = new ProductContext(); 🤔 ex. https://learn.microsoft.com/en-us/ef/core/#querying

# by Jeffrey

to Ho.Chun,那是自己管理 DbContext 的應用情境,例如在 Console Application 裡,應該沒有在 MVC 裡這樣用的範例吧? 若是寫在 ASP.NET Core MVC Action,DbContext Instance 由建構式參數獲得,生命週期由底層管理,基本上我們不需插手。

# by Ho.Chun

豁然開朗 🤣 謝謝黑大,的確是這樣 👍

# by fes

transaction: trn.GetDbTransaction()); 這裡transaction會被畫紅線顯示錯誤喔

# by Jeffrey

to fes, 包成可重現問題的Github專案,大家比較能幫忙。

Post a comment