on
如何在EFCore中使用Posrgres的Jsonb(1)
Postgres
中有一個特殊的Jsonb
型別,可以用來存放Json
格式的資料,資料庫會對Json
的節點做一定的整理。使用這個型別能夠在關聯式資料庫嚴格的限制下取得一定的彈性,讓我們在享有SQL
的保證時同時享受NoSQL
的彈性。然而針對EFCore
這種對資料庫抽象化的ORM
框架來說,Jsonb
的查詢與寫入屬於Postgres
的特化功能,需要靠額外的方式來達到支援。因為最近專案中又使用到Jsonb
來保存部份資料,重新看一次文件後稍微調整了一下這次的作法。
專案需求
我們這次專案遇到的狀況是用戶希望能夠開發一個預約修改的功能,希望能夠在指定的時間對不同的資料做變更,因為種種原因我們決定將預約資訊存入資料庫中,於是設計了這樣的資料表:
CREATE TABLE schedule (
id INTEGER NOT NULL,
execute_date_time TIMESTAMP,
target_id INTEGER NOT NULL,
target_table VARCHAR,
status VARCHAR,
process_data JSONB,
CONSTRAINT schedule_pk PRIMARY KEY (id)
);
當增加一筆預約的時候,會把要修改的表與指定資料存到target_table
與target_id
中,並把預計修改的結果以json格式存到process_data
裡面。
預設行為
因為團隊使用DB First
的方式由資料庫生成應用程式中使用的Entity
,在不做任何設定的情況下會把Jsonb
轉換成string
:
public class Schedule
{
public long Id { get; set; }
public DateTime ExecuteDateTime { get; set; }
public long TargetId { get; set; }
public string TargetTable { get; set; }
public string Status { get; set; }
public string ProcessData { get; set; }
}
然後當我們要新增或修改資料的時候就會是:
// 新增排程
var schedule = new Schedule
{
ExecuteDateTime = DateTime.UtcNow.AddMinutes(10),
TargetId = 123,
TargetTable = nameof(Product),
Status = nameof(ScheduleStatus.Waiting),
ProcessData = JsonSerializer.Serialize(new UpdatePrice(100))
};
dbContext.Schedules.Add(schedule);
await dbContext.SaveChangesAsync();
// 修改排程資料
var data = JsonSerializer.Deserialize<UpdatePrice>(schedule.ProcessData);
data = data with { Price: data.Price - 10 };
schedule.ProcessData = JsonSerializer.Serialize(data);
await dbContext.SaveChangesAsync();
在進行SaveChangesAsync
的時候EFCore
的行為是將整個Jsonb
欄位更新,雖然Postgres
本身支援部份欄位更新的語法,但是要讓ORM追蹤這種非強型別的資料異動是一件非常困難的事情。接下來我要把string
換成JsonDocument
,在寫入與更新上差異不大,但在查詢上卻會方便很多。
JsonDocument
Npgsql.EntityFrameworkCore.PostgreSQL
其實支援使用自定義的型別來解析Jsonb
,就可以享有強型別的好處,但如果可以預定義型別的話其實直接在資料表上開一個欄位即可,因此這個作法並不常用。但是Npgsql
也支援使用JsonDocument
來解析Jsonb
,就可以在使用上增加很多彈性。
首先要注意的一件事情是 JsonDocument
有實做IDisposable
,需要手動釋放,因此我們需要對Schedule
做一些改造:
public class Schedule : IDisposable
{
public long Id { get; set; }
public DateTime ExecuteDateTime { get; set; }
public long TargetId { get; set; }
public string TargetTable { get; set; }
public string Status { get; set; }
public string ProcessData { get; set; }
public void Dispose => ProcessData.Dispose();
}
這樣EFCore
就會在釋放context
的時候一併把ProcessData
回收掉,現在我們來看一下新增跟更新:
// 新增排程
var schedule = new Schedule
{
ExecuteDateTime = DateTime.UtcNow.AddMinutes(10),
TargetId = 123,
TargetTable = nameof(Product),
Status = nameof(ScheduleStatus.Waiting),
ProcessData = JsonSerializer.SerializeToDocument(
new UpdatePrice(100))
};
dbContext.Schedules.Add(schedule);
await dbContext.SaveChangesAsync();
// 修改排程資料
using schedule.ProcessData;
var data = schedule.ProcessData.Deserialize<UpdatePrice>();
data = data with { Price: data.Price - 10 };
schedule.ProcessData = JsonSerializer.SerializeToDocument(data);
await dbContext.SaveChangesAsync();
JsonDocument
是唯讀的,因此要修改時需要將原來的實例置換掉,另外就是當更新資料的時候,schedule.ProcessData
會從原來的實例指向新的實例,舊的實例就不受EFCore
管理,因此需要手動釋放。
小結
關聯式資料庫對於Json
格式的支援是比較新的功能,而ORM
的整合也會跟過去的經驗不同,EFCore
中可以使用JsonDocument
來操作,只是JsonDocument
的一些特性會需要特別注意(對,就是在講資源釋放)。下一篇要介紹查詢的部份,說實話這才是解放Jsonb
的功能阿!
補充說明
我這邊測試了一下沒有釋放會發生什麼事
using System.Text.Json;
const int numObjects = 1000000;
var random = new Random();
Console.WriteLine("With using statement:");
foreach (var _ in Enumerable.Range(0, numObjects))
{
using var document = JsonSerializer.SerializeToDocument(new { Id = Guid.NewGuid(), number = random.Next() });
}
Console.WriteLine("Without using statement:");
foreach (var _ in Enumerable.Range(0, numObjects))
{
var document = JsonSerializer.SerializeToDocument(new { Id = Guid.NewGuid(), number = random.Next() });
}
基本上不釋放不會造成記憶體使用量爆增,只是GC
會很忙而已,下面是用DotMemory
分析記憶體使用狀況,黃色點的部份是GC
出動的紀錄。可以看到七秒以後沒有手動釋放的部份GC出動的頻率多非常多。
另外也可以參考這一篇issue