如何在EFCore中使用Posrgres的Jsonb(1)

@Alex Shuper

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_tabletarget_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