介於 JSON 與 SQLite 的中間選擇 - LiteDB
| | | 2 | |
開發 .NET 桌面小工具時,免不了會有保存資料的需求。若筆數不多查詢不複雜,我會選擇序列化存成 JSON 檔,這是最輕巧簡便的選擇,不需第三方程式庫,資料檔還可直接人工檢視修改;若筆數偏多或涉及複雜查詢、更新或統計,我則會改用 SQLite,免安裝設定就有與 SQL/Oracle/Postgres/MySQL 同等級的關聯式資料庫功能,甚至還能把零散檔案一起放進資料庫,隨資料同步增刪,小缺點是 SQLite 通常要透過 EntityFramework / Dapper 存取,複雜度略高,加上 SQLite 依賴 C 寫的原生程式庫,部署成單一檔案桌面工具或跨平台時要花點心思處理。
介於以上兩種資料規模之間,還有一類需求,偏向文件保存性質,筆數多到存成 JSON 太肥或需要收納零散檔案,但沒有複雜 JOIN 查詢或彙整統計需求,不需動用 SQLite。針對這類情境,有沒有 C# 原生開發的簡單資料庫,提供基本索引功能加速查詢、用單一檔案保存資料及附加檔案、使用起來比 SQLite 簡單且不依賴原生程式庫?
LiteDB 剛好是切中這類情境的優秀選項。
LiteDB 是一套以 C# 開發的 Embedded NoSQL Document Database,從 2010 年代開始發展,定位介於 SQLite 與 MongoDB 之間:跟 SQLite 一樣不需要獨立的資料庫伺服器,以單一檔案儲存資料;但本質上則類似 MongoDB 的 Document 模型,適合 .NET 應用程式保存結構較彈性的資料。LiteDB 從 5.0 版加入 WAL、Transaction、多 Reader、Index 等能力,足以應付日常資料保存需求。
以下是個簡單應用範例,LiteDB 程式寫起來比 SQLite 稍稍簡單一些,但配合 LINQ 查詢時有個重要觀念 - LiteDB 會將資料轉成 BSON 格式儲存,可運用 BSON 查詢語法更有效率地找到資料。但正如有些 LINQ 條件無法被轉成 SQL 語法便不能直接在 IQueryable / EF.Core 使用 參考,LiteDB 有自己一套 Query.EQ、Query.GT... 等方法,若 C# 寫法無法直接轉換成 BSON 查詢,就需改用 BSON 表示式或先載入記憶體再處理境。這部分不難,但要花點時間了解才好上手。參考
補充說明我寫在註解裡,大家直接看 Code 吧!
using LiteDB;
var dbPath = "MyData.db";
if (File.Exists(dbPath)) File.Delete(dbPath); // 每次刪除資料檔重新建立
using var db = new LiteDatabase(dbPath);
var players = db.GetCollection<Player>("players");
var skills = db.GetCollection<Skill>("skills");
// 新增 Skill
skills.Insert(new Skill { Id = "CS", Name = "C#" });
skills.Insert(new Skill { Id = "TS", Name = "TypeScript" });
skills.Insert(new Skill { Id = "JS", Name = "JavaScript" });
skills.Insert(new Skill { Id = "PY", Name = "Python" });
var jeffrey = new Player
{
Name = "Jeffrey",
SkillIds = new string[] { "CS", "JS" },
RegDate = DateTime.Now,
IsActive = true
};
players.Insert(jeffrey);
var retired = new Player {
Name = "Bob",
SkillIds = new string[] { "JS" },
RegDate = DateTime.Now,
IsActive = false
};
players.Insert(retired);
var llm = new Player
{
Name = "LLM",
SkillIds = skills.FindAll().Select(s => s.Id).ToArray(),
RegDate = DateTime.Now,
IsActive = true
};
players.Insert(llm);
// 使用 FindOne 查詢
var found = players.FindOne(p => p.Name == "Jeffrey");
Console.WriteLine($"Found Player: {found.Id}. {found?.Name}");
// 使用 LINQ 查詢所有 Active 玩家姓名
var activePlayers = players.Query().Where(p => p.IsActive).Select(p => p.Name).ToList();
Console.WriteLine($"\nActive Players: {string.Join(", ", activePlayers)}");
// 建立索引
players.EnsureIndex(p => p.SkillIds);
// 使用 Query.EQ 查詢,可善用索引提高查詢效能
var jsPlayers = players.Find(Query.EQ(nameof(Player.SkillIds), "JS")).ToList();
// 注意:不是所有 LINQ 方法都能直接轉換成 LiteDB 的 BSON 查詢,例如:
// players.Query().Where(p => p.SkillIds.Contains("JS")).ToList();
// Contains 不在支援之列,會產生錯誤:
// Method Contains not available to convert to BsonExpression
// (op_Implicit(Convert(p.SkillIds, String[])).Contains("JS")).
// 另個解法是先將資料載入記憶體再查,但效能較差
// players.Query().ToList().Where(p => p.SkillIds.Contains("JS")).ToList();
Console.WriteLine($"\nJS Players: {string.Join(", ", jsPlayers.Select(p => p.Name))}");
// LiteDB 不直接支援 JOIN 查詢,但可在程式端用 LINQ 操作
// LiteDB 會盡可能翻成 BSON 查詢,不行的話會載入記憶體再處理,效能不能跟 SQLite 相提並論
// 建議自己用 Dictionary 處理以確保效能,避免在 LINQ 裡反覆搜尋 LiteDB
var skillNames = skills.FindAll().ToDictionary(s => s.Id, s => s.Name);
var list = players.Query()
.ToList()
.Select(p => new
{
PlayerName = p.Name,
Skills = p.SkillIds
.Where(skillNames.ContainsKey)
.Select(id => skillNames[id])
.ToArray()
})
.ToList();
foreach (var item in list)
{
Console.WriteLine($" - Player [{item.PlayerName}]: {string.Join(", ", item.Skills)}");
}
Console.WriteLine("\nAfter Deletions:");
// 示範刪除及更新
players.Delete(jeffrey.Id);
// 亦可用條件批次刪除
players.DeleteMany(p => p.IsActive == false);
// 若是直接使用 LiteDB 的 Query 物件,效果相同
players.DeleteMany(Query.EQ(nameof(Player.IsActive), false));
var editEnt = players.FindOne(p => p.Name == "LLM");
if (editEnt != null) {
editEnt.Name = "Skynet";
players.Update(editEnt);
}
foreach (var p in players.FindAll())
{
Console.WriteLine($" - Player: {p.Id}. {p.Name}");
}
public class Player
{
// 取名 Id 自動成為 PK,int/long 會自動跳號
public int Id { get; set; }
public required string Name { get; set; }
public required string[] SkillIds { get; set; }
public DateTime RegDate { get; set; }
public bool IsActive { get; set; }
}
public class Skill
{
// LiteDB 支援 int/long/Guid/objectId/string 作為 PK
// 名稱包含 Id 會自動被視為 PK,亦可用 [BsonId] 標註
// 完全不宣告時底層會建立 _id,但無法 FindById/Update/Delete 故不建議
public required string Id { get; set; }
public required string Name { get; set; }
}

提醒:LiteDB 不適合多執行緒同時寫入,不建議用於 Web 等多人存取情境,可能出現無法預期的嚴重錯誤,若無法避免請視同 JSON 檔加上 lock 等互斥鎖定機制。
【延伸閱讀】
LiteDB is a lightweight, C#-native embedded NoSQL database ideal for .NET desktop tools. It stores documents and attachments in a single file, supports indexes and transactions, and avoids native dependencies. This article demonstrates CRUD operations, BSON queries, LINQ limitations, and application-side joins.
Comments
# by 居紫
LiteDB 只能用在範例,不適合使用在 Production,甚至連範例都很無力。 單機、單應用程式使用勉強可以。 做成 web api,放到 app service 之類,只要兩個使用者同時回寫,LiteDB 高機率就會直接死去,資料完全無法救援,仔細觀察 LiteDB 的 db 寫入方式就能理解。 即使單程式,有多執行緒需求,LiteDB 也是直接死。 只能單一循序使用,毫無使用價值。 LiteDB 垃圾,你可以不用但一定要請你的朋友用看看。 這篇有很委婉地提醒。 https://studyhost.blogspot.com/2024/04/litedb.html
# by Jeffrey
to 居紫,同意,要挑適當的情境使用才能展現它最大的威力。桌面小工具只有單人使用很適合它,真的要多緒同時寫入,你的朋友只要把它當成 JSON 檔加上 lock 等互斥鎖定保護,還是可以用得很開心。 謝謝分享,已補上提示了。