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