.NET ORM 倉儲層必備的功能介紹之 FreeSql Repository 實現篇

FreeSql發表於2022-05-06

寫在開頭

2018年11月的某一天,頭腦發熱開啟了 FreeSql 開源專案之旅,時間一晃已經四年多,當初從舒服區走向一個巨大的坑,回頭一看後背一涼。四年時間從無到有,經歷了數不清的日夜奮戰(有人問我花了多長時間投入,答案:全職x2 + 前兩年無休息,以及後面兩年的持續投入)。今天 FreeSql 已經很強大,感謝第一期、第二期、第N期持續提出建議的網友。

FreeSql 現如今已經是一個穩定的版本,主要體現:

  • API 已經確定,不會輕易推翻重作調整,堅持十年不變的原則,讓使用者真真正正的不再關心 ORM 使用問題;
  • 單元測試覆蓋面廣,6336+ 個單元測試,小版本更新升級無須考慮修東牆、補西牆的問題;
  • 經歷四年時間的生產考驗,nuget下載量已超過900K+,平均每日750+;

感嘆:有些人說 .Net 陷入 orm 怪圈,動手的沒幾個,指點江山的一堆,.Net orm 真的如他們所講的簡單嗎?


專案介紹

FreeSql 是 .Net ORM,能支援 .NetFramework4.0+、.NetCore、Xamarin、MAUI、Blazor、以及還有說不出來的執行平臺,因為程式碼綠色無依賴,支援新平臺非常簡單。目前單元測試數量:6336+,Nuget下載數量:900K+。QQ群:4336577(已滿)、8578575(線上)、52508226(線上)

溫馨提醒:以下內容無商吹成份,FreeSql 不打誑語

為什麼要重複造輪子?

FreeSql 主要優勢在於易用性上,基本是開箱即用,在不同資料庫之間切換相容性比較好。作者花了大量的時間精力在這個專案,肯請您花半小時瞭解下專案,謝謝。FreeSql 整體的功能特性如下:

  • 支援 CodeFirst 對比結構變化遷移;
  • 支援 DbFirst 從資料庫匯入實體類;
  • 支援 豐富的表示式函式,自定義解析;
  • 支援 批量新增、批量更新、BulkCopy;
  • 支援 導航屬性,貪婪載入、延時載入、級聯儲存;
  • 支援 讀寫分離、分表分庫,租戶設計;
  • 支援 MySql/SqlServer/PostgreSQL/Oracle/Sqlite/Firebird/達夢/神通/人大金倉/翰高/MsAccess Ado.net 實現包,以及 Odbc 的專門實現包;

5500+個單元測試作為基調,支援10多數資料庫,我們提供了通用Odbc理論上支援所有資料庫,目前已知有群友使用 FreeSql 操作華為高斯、mycat、tidb 等資料庫。安裝時只需要選擇對應的資料庫實現包:

dotnet add packages FreeSql.Provider.MySql

FreeSql.Repository 是 FreeSql 專案的延申擴充套件類庫,支援 .NETFramework4.0+、.NETCore2.0+、.NET5+、Xamarin 平臺。

FreeSql.Repository 除了 CRUD 還有很多實用性功能,不防耐下心花10分鐘看完。


01 安裝

環境1:.NET Core 或 .NET 5.0+

dotnet add package FreeSql.Repository

環境2、.NET Framework

Install-Package FreeSql.DbContext
static IFreeSql fsql = new FreeSql.FreeSqlBuilder()
    .UseConnectionString(FreeSql.DataType.Sqlite, connectionString)
    .UseAutoSyncStructure(true) //自動遷移實體的結構到資料庫
    .UseNoneCommandParameter(true)
    .UseMonitorCommand(cmd => Console.WriteLine(cmd.CommandText))
    .Build(); //請務必定義成 Singleton 單例模式

02 使用方法

方法1、IFreeSql 的擴充套件方法;

var curd = fsql.GetRepository<Topic>();

注意:Repository 物件多執行緒不安全,因此不應在多個執行緒上同時對其執行工作。

  • fsql.GetRepository 方法返回新倉儲例項
  • 不支援從不同的執行緒同時使用同一倉儲例項

以下為了方便測試程式碼演示,我們都使用方法1,fsql.GetRepository 建立新倉儲例項


方法2、繼承實現;

public class TopicRepository: BaseRepository<Topic, int> {
    public TopicRepository(IFreeSql fsql) : base(fsql, null, null) {}

    //在這裡增加 CURD 以外的方法
}

方法3、依賴注入;

public void ConfigureServices(IServiceCollection services)
{
    services.AddSingleton<IFreeSql>(Fsql);
    services.AddFreeRepository(null, this.GetType().Assembly);
}

//在控制器使用
public TopicController(IBaseRepository<Topic> repo) {
}

03 新增資料

repo.Insert 插入資料,適配了各資料庫優化執行 ExecuteAffrows/ExecuteIdentity/ExecuteInserted

1、如果表有自增列,插入資料後應該要返回 id。

var repo = fsql.GetRepository<Topic>();
repo.Insert(topic);

內部會將插入後的自增值填充給 topic.Id

2、批量插入

var repo = fsql.GetRepository<Topic>();
var topics = new [] { new Topic { ... }, new Topic { ... } };
repo.Insert(topics);

3、插入資料庫時間

使用 [Column(ServerTime = DateTimeKind.Utc)] 特性,插入資料時,使用適配好的每種資料庫內容,如 getutcdate()

4、插入特殊型別

使用 [Column(RereadSql = "{0}.STAsText()", RewriteSql = "geography::STGeomFromText({0},4236)")] 特性,插入和讀取時特別處理

5、插入時忽略

使用 [Column(CanInsert = false)] 特性


04 更新資料

1、只更新變化的屬性

var repo = fsql.GetRepository<Topic>();
var item = repo.Where(a => a.Id == 1).First();  //此時快照 item

item.Name = "newtitle";
repo.Update(item); //對比快照時的變化
//UPDATE `Topic` SET `Title` = 'newtitle'
//WHERE (`Id` = 1)

2、手工管理狀態

var repo = fsql.GetRepository<Topic>();
var item = new Topic { Id = 1 };
repo.Attach(item); //此時快照 item

item.Title = "newtitle";
repo.Update(item); //對比快照時的變化
//UPDATE `Topic` SET `Title` = 'newtitle'
//WHERE (`Id` = 1)

3、直接使用 repo.UpdateDiy,它是 IFreeSql 提供的原生 IUpdate 物件,功能更豐富


05 級聯儲存資料

實踐發現,N對1 不適合做級聯儲存。儲存 Topic 的時候把 Type 資訊也儲存?我個人認為自下向上儲存的功能太不可控了,FreeSql 目前不支援自下向上儲存。因此下面我們只講 OneToOne/OneToMany/ManyToMany 級聯儲存。至於 ManyToOne 級聯儲存使用手工處理,更加安全可控。

功能1:SaveMany 手工儲存

完整儲存,對比表已存在的資料,計算出新增、修改、刪除執行。

遞迴儲存導航屬性不安全,不可控,並非技術問題,而是出於安全考慮,提供了手工完整儲存的方式。

var repo = fsql.GetRepository<Type>();
var type = new Type
{
    name = "c#",
    Topics = new List<Topic>(new[]
    {
        new Topic { ... }
    })
};
repo.Insert(type);
repo.SaveMany(type, "Topics"); //手工完整儲存 Topics
  • SaveMany 僅支援 OneToMany、ManyToMany 導航屬性
  • 只儲存 Topics,不向下遞迴追朔
  • 當 Topics 為 Empty 時,刪除 type 存在的 Topics 所有表資料,確認?
  • ManyToMany 機制為,完整對比儲存中間表,外部表只追加不更新

如:

  • 本表 Topic
  • 外部表 Tag
  • 中間表 TopicTag

功能2:EnableCascadeSave 倉儲級聯儲存

DbContext/Repository EnableCascadeSave 可實現儲存物件的時候,遞迴追朔其 OneToOne/OneToMany/ManyToMany 導航屬性也一併儲存,本文件說明機制防止誤用。

1、OneToOne 級聯儲存

v3.2.606+ 支援,並且支援級聯刪除功能(文件請向下瀏覽)

2、OneToMany 追加或更新子表,不刪除子表已存在的資料

var repo = fsql.GetRepository<Type>();
repo.DbContextOptions.EnableCascadeSave = true; //需要手工開啟
repo.Insert(type);
  • 不刪除 Topics 子表已存在的資料,確認?
  • 當 Topics 屬性為 Empty 時,不做任何操作,確認?
  • 儲存 Topics 的時候,還會儲存 Topics[0-..] 的下級集合屬性,向下18層,確認?

向下18層的意思,比如【型別】表,下面有集合屬性【文章】,【文章】下面有集合屬性【評論】。

儲存【型別】表物件的時候,他會向下檢索出集合屬性【文章】,然後如果【文章】被儲存的時候,再繼續向下檢索出集合屬性【評論】。一起做 InsertOrUpdate 操作。

3、ManyToMany 完整對比儲存中間表,追加外部表

完整對比儲存中間表,對比【多對多】中間表已存在的資料,計算出新增、修改、刪除執行。

追加外部表,只追加不更新。

  • 本表 Topic
  • 外部表 Tag
  • 中間表 TopicTag

06 刪除資料

var repo = fsql.GetRepository<Topic>();
repo.Delete(new Topic { Id = 1 }); //有過載方法 repo.Delete(Topic[])

var repo2 = fsql.GetRepository<Topic, int>(); //int 是主鍵型別,相比 repo 物件多了 Delete(int) 方法
repo2.Delete(1);

07 級聯刪除資料

第一種:基於【物件】級聯刪除

比如使用過 Include/IncludeMany 查詢的物件,可以使用此方法級聯刪除它們。

var repo = fsql.GetRepository<Group>();
repo.DbContextOptions.EnableCascadeSave = true; //關鍵設定
repo.Insert(new UserGroup
{
    GroupName = "group01",
    Users = new List<User>
    {
        new User { Username = "admin01", Password = "pwd01", UserExt = new UserExt { Remark = "使用者備註01" } },
        new User { Username = "admin02", Password = "pwd02", UserExt = new UserExt { Remark = "使用者備註02" } },
        new User { Username = "admin03", Password = "pwd03", UserExt = new UserExt { Remark = "使用者備註03" } },
    }
}); //級聯新增測試資料
//INSERT INTO "usergroup"("groupname") VALUES('group01') RETURNING "id"
//INSERT INTO "user"("username", "password", "groupid") VALUES('admin01', 'pwd01', 1), ('admin02', 'pwd02', 1), ('admin03', 'pwd03', 1) RETURNING "id" as "Id", "username" as "Username", "password" as "Password", "groupid" as "GroupId"
//INSERT INTO "userext"("userid", "remark") VALUES(3, '使用者備註01'), (4, '使用者備註02'), (5, '使用者備註03')

var groups = repo.Select
    .IncludeMany(a => a.Users, 
        then => then.Include(b => b.UserExt))
    .ToList();
repo.Delete(groups); //級聯刪除,遞迴向下遍歷 group OneToOne/OneToMany/ManyToMany 導航屬性
//DELETE FROM "userext" WHERE ("userid" IN (3,4,5))
//DELETE FROM "user" WHERE ("id" IN (3,4,5))
//DELETE FROM "usergroup" WHERE ("id" = 1)

第二種:基於【資料庫】級聯刪除,不依賴資料庫外來鍵

根據設定的導航屬性,遞迴刪除 OneToOne/OneToMany/ManyToMany 對應資料,並返回已刪除的資料。此功能不依賴資料庫外來鍵

var repo = fsql.GetRepository<Group>();
var ret = repo.DeleteCascadeByDatabase(a => a.Id == 1);
//SELECT a."id", a."username", a."password", a."groupid" FROM "user" a WHERE (a."groupid" = 1)
//SELECT a."userid", a."remark" FROM "userext" a WHERE (a."userid" IN (3,4,5))
//DELETE FROM "userext" WHERE ("userid" IN (3,4,5))
//DELETE FROM "user" WHERE ("id" IN (3,4,5))
//DELETE FROM "usergroup" WHERE ("id" = 1)

//ret   Count = 7	System.Collections.Generic.List<object>
//  [0]	{UserExt}	object {UserExt}
//  [1]	{UserExt}	object {UserExt}
//  [2]	{UserExt}	object {UserExt}
//  [3]	{User}	    object {User}
//  [4]	{User}	    object {User}
//  [5]	{User}  	object {User}
//  [6]	{UserGroup}	object {UserGroup}

public class Group
{
    [Column(IsIdentity = true)]
    public int Id { get; set; }
    public string GroupName { get; set; }

    [Navigate(nameof(User.GroupId))]
    public List<User> Users { get; set; }
}
public class User
{
    [Column(IsIdentity = true)]
    public int Id { get; set; }
    public string Username { get; set; }
    public string Password { get; set; }
    public int GroupId { get; set; }

    [Navigate(nameof(Id))]
    public UserExt UserExt { get; set; }
}
public class UserExt
{
    [Column(IsPrimary = true)]
    public int UserId { get; set; }
    public string Remark { get; set; }

    [Navigate(nameof(UserId))]
    public User User { get; set; }
}

08 新增或修改資料

var repo = fsql.GetRepository<Topic>();
repo.InsertOrUpdate(item);

如果內部的狀態管理存在資料,則更新。

如果內部的狀態管理不存在資料,則查詢資料庫,判斷是否存在。

存在則更新,不存在則插入

缺點:不支援批量操作

提醒:IFreeSql 也定義了 InsertOrUpdate 方法,兩者實現機制不同,它利用了資料庫特性:

Database Features Database Features
MySql on duplicate key update 達夢 merge into
PostgreSQL on conflict do update 人大金倉 on conflict do update
SqlServer merge into 神通 merge into
Oracle merge into 南大通用 merge into
Sqlite replace into MsAccess 不支援
Firebird merge into
fsql.InsertOrUpdate<T>()
  .SetSource(items) //需要操作的資料
  //.IfExistsDoNothing() //如果資料存在,啥事也不幹(相當於只有不存在資料時才插入)
  .ExecuteAffrows();

09 批量編輯資料

var repo = fsql.GetRepository<BeginEdit01>();
var cts = new[] {
    new BeginEdit01 { Name = "分類1" },
    new BeginEdit01 { Name = "分類1_1" },
    new BeginEdit01 { Name = "分類1_2" },
    new BeginEdit01 { Name = "分類1_3" },
    new BeginEdit01 { Name = "分類2" },
    new BeginEdit01 { Name = "分類2_1" },
    new BeginEdit01 { Name = "分類2_2" }
}.ToList();
repo.Insert(cts);

repo.BeginEdit(cts); //開始對 cts 進行編輯

cts.Add(new BeginEdit01 { Name = "分類2_3" });
cts[0].Name = "123123";
cts.RemoveAt(1);

var affrows = repo.EndEdit(); //完成編輯
Assert.Equal(3, affrows);
class BeginEdit01
{
    public Guid Id { get; set; }
    public string Name { get; set; }
}

上面的程式碼 EndEdit 方法執行的時候產生 3 條 SQL 如下:

INSERT INTO "BeginEdit01"("Id", "Name") VALUES('5f26bf07-6ac3-cbe8-00da-7dd74818c3a6', '分類2_3')

UPDATE "BeginEdit01" SET "Name" = '123123' 
WHERE ("Id" = '5f26bf00-6ac3-cbe8-00da-7dd01be76e26')

DELETE FROM "BeginEdit01" WHERE ("Id" = '5f26bf00-6ac3-cbe8-00da-7dd11bcf54dc')

場景:winform 載入表資料後,一頓新增、修改、刪除操作之後,點選【儲存】

提醒:該操作只對變數 cts 有效,不是針對全表對比更新。


10 弱型別 CRUD

var repo = fsql.GetRepository<object>();
repo.AsType(typeof(Topic));

var item = (object)new Topic { Title = "object title" };
repo.Insert(item);

11 無引數化命令

支援引數化、無引數化命令執行,有一些特定的資料庫,使用無引數化命令執行效率更高哦,並且除錯起來更直觀。

var repo = fsql.GetRepository<object>();
repo.DbContextOptions.NoneParameter = true;

12 工作單元(事務)

UnitOfWork 可將多個倉儲放在一個單元管理執行,最終通用 Commit 執行所有操作,內部採用了資料庫事務。

方法1:隨時建立使用

using (var uow = fsql.CreateUnitOfWork())
{
  var typeRepo = fsql.GetRepository<Type>();
  var topicRepo = fsql.GetRepository<Topic>();
  typeRepo.UnitOfWork = uow;
  topicRepo.UnitOfWork = uow;

  typeRepo.Insert(new Type());
  topicRepo.Insert(new Topic());

  uow.Orm.Insert(new Topic()).ExecuteAffrows();
  //uow.Orm 和 fsql 都是 IFreeSql
  //uow.Orm CRUD 與 uow 是一個事務
  //fsql CRUD 與 uow 不在一個事務

  uow.Commit();
}

方法2:使用 AOP + UnitOfWorkManager 實現多種事務傳播

本段內容引導,如何在 asp.net core 專案中使用特性(註解) 的方式管理事務。

UnitOfWorkManager 支援六種傳播方式(propagation),意味著跨方法的事務非常方便,並且支援同步非同步:

  • Requierd:如果當前沒有事務,就新建一個事務,如果已存在一個事務中,加入到這個事務中,預設的選擇。
  • Supports:支援當前事務,如果沒有當前事務,就以非事務方法執行。
  • Mandatory:使用當前事務,如果沒有當前事務,就丟擲異常。
  • NotSupported:以非事務方式執行操作,如果當前存在事務,就把當前事務掛起。
  • Never:以非事務方式執行操作,如果當前事務存在則丟擲異常。
  • Nested:以巢狀事務方式執行。

第一步:配置 Startup.cs 注入

public void ConfigureServices(IServiceCollection services)
{
    services.AddSingleton<IFreeSql>(fsql);
    services.AddScoped<UnitOfWorkManager>();
    services.AddFreeRepository(null, typeof(Startup).Assembly);
}
UnitOfWorkManager 成員 說明
IUnitOfWork Current 返回當前的工作單元
void Binding(repository) 將倉儲的事務交給它管理
IUnitOfWork Begin(propagation, isolationLevel) 建立工作單元

第二步:定義事務特性

[AttributeUsage(AttributeTargets.Method)]
public class TransactionalAttribute : Attribute
{
    /// <summary>
    /// 事務傳播方式
    /// </summary>
    public Propagation Propagation { get; set; } = Propagation.Requierd;
    /// <summary>
    /// 事務隔離級別
    /// </summary>
    public IsolationLevel? IsolationLevel { get; set; }
}

第三步:引入動態代理庫

在 Before 從容器中獲取 UnitOfWorkManager,呼叫它的 var uow = Begin(attr.Propagation, attr.IsolationLevel) 方法

在 After 呼叫 Before 中的 uow.Commit 或者 Rollback 方法,最後呼叫 uow.Dispose

提醒:動態代理,一定注意處理好非同步 await,否則會出現事務異常的問題

第四步:在 Controller 或者 Service 中使用事務特性

public class TopicService
{
    IBaseRepository<Topic> _repoTopic;
    IBaseRepository<Detail> _repoDetail;

    public TopicService(IBaseRepository<Topic> repoTopic, IBaseRepository<Detail> repoDetail)
    {
        _repoTopic = repoTopic;
        _repoDetail = repoDetail;
    }

    [Transactional]
    public virtual void Test1()
    {
        //這裡 _repoTopic、_repoDetail 所有 CRUD 操作都是一個工作單元
        this.Test2();
    }

    [Transactional(Propagation = Propagation.Nested)]
    public virtual void Test2() //巢狀事務,新的(不使用 Test1 的事務)
    {
        //這裡 _repoTopic、_repoDetail 所有 CRUD 操作都是一個工作單元
    }
}

是不是進方法就開事務呢?

不一定是真實事務,有可能是虛的,就是一個假的 unitofwork(不帶事務)

也有可能是延用上一次的事務

也有可能是新開事務,具體要看傳播模式

示例專案:https://github.com/dotnetcore/FreeSql/tree/master/Examples/aspnetcore_transaction

Autofac 動態代理參考專案:


13 手工分表

FreeSql 原生用法、FreeSql.Repository 倉儲用法 都提供了 AsTable 方法對分表進行 CRUD 操作,例如:

var repo = fsql.GetRepository<Log>();
repo.AsTable(oldname => $"{oldname}_201903"); //對 Log_201903 表 CRUD

repo.Insert(new Log { ... });

跨庫,但是在同一個資料庫伺服器下,也可以使用 AsTable(oldname => $"db2.dbo.{oldname}")

//跨表查詢
var sql = fsql.Select<User>()
    .AsTable((type, oldname) => "table_1")
    .AsTable((type, oldname) => "table_2")
    .ToSql(a => a.Id);

//select * from (SELECT a."Id" as1 FROM "table_1" a) ftb 
//UNION ALL
//select * from (SELECT a."Id" as1 FROM "table_2" a) ftb 

分表總結:

  • 分表、相同伺服器跨庫 可以使用 AsTable 進行 CRUD;
  • AsTable CodeFirst 會自動建立不存在的分表;
  • 不可在分表分庫的實體型別中使用《延時載入》;

v3.2.500 按時間自動分表方案:https://github.com/dotnetcore/FreeSql/discussions/1066


寫在最後

FreeSql 他是免費自由的 ORM,也可以說是寶藏 ORM。更多文件請前往 github wiki 檢視。

FreeSql 已經步入第四個年頭,期待少年的你十年後還能歸來在此貼回覆,兌現當初吹過十年不變的承諾。

多的不說了,希望民間的開源力量越來越強大。

希望作者的努力能打動到你,請求正在使用的、善良的您能動一動小手指,把文章轉發一下,讓更多人知道 .NET 有這樣一個好用的 ORM 存在。謝謝!!

FreeSql 使用最寬鬆的開源協議 MIT https://github.com/dotnetcore/FreeSql ,完全可以商用,文件齊全,甚至拿去賣錢也可以。QQ群:4336577(已滿)、8578575(線上)、52508226(線上)

相關文章