歡迎來到 open-notebook
教學系列的最後一章!在上一章 轉換 (Transformations) 中,我們探討了如何定義和應用可重複使用的 AI 文字處理任務,像是摘要或翻譯,來豐富我們的筆記內容。
從第一章開始,我們陸續認識了 物件模型 (ObjectModel) 如何定義資料結構,模型管理器 (ModelManager) 如何管理 AI 模型,使用者介面 (Streamlit UI) 如何提供互動,LangGraph 狀態機 (Graph Workflows) 如何編排複雜流程,提示詞管理器 (Prompter) 如何生成 AI 指令,以及「轉換」如何處理文字。
但你有沒有想過,所有這些我們建立的筆記本、筆記、來源資訊、AI 模型設定,還有那些自訂的「轉換」,它們最終都儲存在哪裡呢?如果關閉應用程式再重新打開,這些資料還會在嗎?答案是肯定的,因為它們都被儲存到了資料庫中。但是,應用程式的各個部分是如何與資料庫溝通的呢?這就是「資料庫儲存庫 (Database Repository)」要解決的問題。
想像一下,您正在管理一個龐大的圖書館。圖書館裡有成千上萬的書籍(資料),放在不同的書架(資料庫表格)上。當有人需要找書、登記新書、更新書籍資訊或註銷舊書時,如果每個人都直接跑到書架區自己動手,可能會發生什麼事?
在軟體開發中,如果應用程式的每個部分(例如 UI、物件模型、狀態機)都直接編寫與資料庫互動的程式碼(例如,撰寫 SQL 或 SurrealQL 指令),也會遇到類似的問題:
為了解決這些問題,我們引入了「資料庫儲存庫」。
資料庫儲存庫 (Database Repository) 是 open-notebook
中一個專門負責處理應用程式與 SurrealDB 資料庫之間所有互動的元件。
它就像我們前面提到的那位圖書館管理員:
get
)、「幫我登記這本新書」(新增 create
)、「更新這本書的描述」(更新 update
)或「把這本書下架」(刪除 delete
)。Database Repository
提供了一組標準化的函數(例如 repo_query
, repo_create
, repo_update
, repo_delete
),這些函數封裝了與 SurrealDB 互動的具體指令。應用程式的其他部分(特別是 ObjectModel
)透過呼叫這些標準函數來操作資料庫,從而隱藏了底層資料庫的複雜細節。
這個元件的主要程式碼位於 open_notebook/database/repository.py
。
在 open-notebook
的設計中,應用程式的大部分元件(例如 Streamlit UI 頁面、LangGraph 節點)通常不會直接呼叫 Database Repository
的函數。
取而代之的是,它們會透過我們在第一章學習的 物件模型 (ObjectModel) 來與資料互動。例如,當您想儲存一個新的筆記本時,您會呼叫 notebook.save()
。
那麼 Database Repository
在哪裡呢?它在幕後被 ObjectModel
使用。
讓我們回顧一下 ObjectModel
的 save()
方法是如何運作的(簡化版):
# 位於 open_notebook/domain/base.py 的 ObjectModel 類別中 (極簡化示意)
from open_notebook.database.repository import repo_create, repo_update
class ObjectModel(BaseModel):
# ... 其他屬性 ...
table_name: ClassVar[str] = ""
id: Optional[str] = None
def save(self) -> None:
try:
# 1. 準備要儲存的資料 (將物件屬性轉成字典)
data = self._prepare_save_data()
data["updated"] = datetime.now() # 設定更新時間
# 2. 判斷是新增還是更新
if self.id is None: # 如果沒有 ID,是新增
data["created"] = datetime.now() # 設定建立時間
# 3. 呼叫儲存庫的新增函式!
repo_result = repo_create(self.__class__.table_name, data)
else: # 如果有 ID,是更新
# 3. 呼叫儲存庫的更新函式!
repo_result = repo_update(self.id, data)
# 4. 用資料庫回傳的結果更新物件自身 (例如設定 ID)
# ... (更新 self.id 等屬性) ...
except Exception as e:
# ... (錯誤處理) ...
raise DatabaseOperationError(e)
def _prepare_save_data(self) -> Dict[str, Any]:
# ... (將物件轉為字典) ...
return {} # 示意
程式碼解釋:
notebook.save()
時,ObjectModel
的 save()
方法會被執行。data
)。id
。id
,它會呼叫 Database Repository
提供的 repo_create(表格名稱, 資料)
函數。id
,它會呼叫 repo_update(物件 ID, 資料)
函數。repo_create
或 repo_update
函數會負責與 SurrealDB 溝通,執行實際的資料庫操作。ObjectModel
接收到儲存庫函數的回傳結果,並用它來更新物件自身的屬性(例如,從 repo_create
的結果中獲取新產生的 id
)。同樣地,當您呼叫 Notebook.get(id)
時,ObjectModel
的 get()
方法內部會呼叫 Database Repository
的 repo_query()
函數來執行資料庫查詢。當您呼叫 notebook.delete()
時,它內部會呼叫 repo_delete()
。
這種設計的好處是:
ObjectModel
負責資料的業務邏輯和驗證,而 Database Repository
專注於與資料庫的互動。ObjectModel
提供的 save()
, get()
, delete()
等方法,而不需要關心底層的 repo_*
函數或資料庫語法。Database Repository
內部的實作,而不需要改動 ObjectModel
或其他使用 ObjectModel
的地方。Database Repository
(repository.py
) 提供了以下核心函數:
repo_query(query_str, vars)
:執行任意的 SurrealQL 查詢。repo_create(table, data)
:在指定表格中建立一筆新紀錄。repo_update(id, data)
:更新指定 ID 紀錄的內容。repo_upsert(table, data)
:如果紀錄已存在則更新,不存在則建立 (不常用於 ObjectModel)。repo_delete(id)
:刪除指定 ID 的紀錄。repo_relate(source, relationship, target, data)
:在兩個紀錄之間建立關係。現在我們知道 ObjectModel
是如何使用 Database Repository
的,那麼 Database Repository
內部又是如何與 SurrealDB 互動的呢?
repo_create
為例)當 ObjectModel
的 save()
方法呼叫 repo_create("notebook", {"name": "我的筆記", ...})
時:
repo_create
: ObjectModel
將表格名稱 ("notebook"
) 和資料字典 (data
) 傳遞給 repo_create
函數。repo_create
函數根據傳入的參數,動態地構建一個 SurrealQL 的 CREATE
語句,例如:CREATE notebook CONTENT {'name': '我的筆記', ...};
。repo_query
: repo_create
函數將構建好的查詢語句傳遞給核心的 repo_query
函數。db_connection
): repo_query
函數使用一個稱為 db_connection
的「內容管理器」(Context Manager) 來獲取一個與 SurrealDB 的連接。db_connection
會讀取環境變數(如 SURREAL_ADDRESS
, SURREAL_USER
等)來建立一個 SurrealSyncConnection
物件。repo_query
使用獲取的連接物件 (connection
),呼叫其 query()
方法,將 SurrealQL 查詢語句和可能的變數發送給 SurrealDB 執行。id
)。repo_query
將這個結果返回給 repo_create
。repo_query
會捕捉錯誤,記錄日誌,並拋出一個異常。repo_create
接收到 repo_query
的結果,並將其返回給最初呼叫它的 ObjectModel
的 save()
方法。以下是一個簡化的序列圖,展示了這個過程:
repository.py
的核心程式碼片段讓我們看看 open_notebook/database/repository.py
中的關鍵部分:
1. 資料庫連接管理器 (db_connection
)
# open_notebook/database/repository.py (簡化片段)
import os
from contextlib import contextmanager
from sblpy.connection import SurrealSyncConnection # SurrealDB Python 函式庫
@contextmanager # 標示這是一個內容管理器
def db_connection():
"""管理 SurrealDB 連線的內容管理器"""
# 從環境變數讀取資料庫連線資訊
connection = SurrealSyncConnection(
host=os.environ["SURREAL_ADDRESS"],
port=int(os.environ["SURREAL_PORT"]),
user=os.environ["SURREAL_USER"],
password=os.environ["SURREAL_PASS"],
namespace=os.environ["SURREAL_NAMESPACE"],
database=os.environ["SURREAL_DATABASE"],
# ... 其他設定 ...
)
try:
# yield 關鍵字將連接物件提供給 'with' 區塊使用
yield connection
finally:
# 無論 'with' 區塊是否發生錯誤,最終都會關閉連接
connection.socket.close()
程式碼解釋:
@contextmanager
是一個 Python 裝飾器,讓這個函數可以用在 with
語句中。with db_connection() as connection:
時:SurrealSyncConnection(...)
會根據環境變數建立一個到 SurrealDB 的連接。yield connection
會將這個建立好的 connection
物件提供給 with
區塊內部使用。with
區塊結束時(無論是正常結束還是因為錯誤跳出),finally:
區塊中的 connection.socket.close()
都會被執行,確保資料庫連接被妥善關閉,避免資源洩漏。2. 核心查詢函數 (repo_query
)
# open_notebook/database/repository.py (簡化片段)
from typing import Any, Dict, Optional
from loguru import logger # 用於記錄日誌
def repo_query(query_str: str, vars: Optional[Dict[str, Any]] = None):
"""執行 SurrealQL 查詢並處理錯誤"""
# 使用 db_connection 內容管理器來獲取和管理連接
with db_connection() as connection:
try:
# 使用連接物件執行查詢,傳入查詢字串和變數
result = connection.query(query_str, vars)
return result # 返回資料庫的原始結果
except Exception as e:
# 如果發生錯誤,記錄詳細資訊並拋出異常
logger.critical(f"查詢失敗: {query_str}")
logger.exception(e)
raise # 將錯誤向上拋出,讓上層處理
程式碼解釋:
query_str
) 和一個可選的變數字典 (vars
)。with db_connection() as connection:
來安全地獲取資料庫連接。try
區塊中,它呼叫 connection.query()
將查詢發送到 SurrealDB。connection.query()
拋出任何異常,except
區塊會捕捉它,使用 loguru
記錄錯誤訊息和查詢語句,然後重新 raise
這個異常。3. 新增記錄函數 (repo_create
)
# open_notebook/database/repository.py (簡化片段)
def repo_create(table: str, data: Dict[str, Any]):
"""在指定表格建立新紀錄"""
# 動態構建 SurrealQL 的 CREATE 語句
# 注意:直接格式化 data 可能有風險,實際 SBLPy 可能有更安全的方法
query = f"CREATE {table} CONTENT {data};"
# 呼叫核心的 repo_query 函數來執行構建好的查詢
return repo_query(query)
程式碼解釋:
table
) 和包含紀錄內容的字典 (data
)。CREATE ... CONTENT ...
查詢語句。repo_query()
來執行這個語句,並返回 repo_query()
的結果。4. 更新記錄函數 (repo_update
)
# open_notebook/database/repository.py (簡化片段)
def repo_update(id: str, data: Dict[str, Any]):
"""更新指定 ID 的紀錄"""
# 構建 SurrealQL 的 UPDATE 語句,使用 $id 和 $data 作為變數佔位符
query = "UPDATE $id CONTENT $data;"
# 準備一個字典,將實際的 id 和 data 傳遞給查詢變數
vars = {"id": id, "data": data}
# 呼叫 repo_query,這次傳入了查詢語句和變數字典
return repo_query(query, vars)
程式碼解釋:
id
) 和新的內容字典 (data
)。UPDATE ... CONTENT ...
查詢語句。這次使用了 $id
和 $data
作為查詢變數。這是一種更安全的做法,可以防止 SQL 注入(或 SurrealQL 注入)攻擊。vars
字典,將 Python 變數 id
和 data
映射到查詢變數 $id
和 $data
。repo_query()
,同時傳遞查詢語句和 vars
字典。repo_query
內部的 connection.query()
會負責將變數安全地傳遞給 SurrealDB。migrate.py
)除了日常的增刪改查,還有一個重要的方面是資料庫結構的演變。隨著 open-notebook
功能的增加或修改,我們可能需要更改資料庫的「綱要」(Schema),例如:
Notebook
表格增加一個 priority
欄位。Source
表格的 content
欄位建立索引以加快搜尋速度。Tag
來管理標籤。如果直接修改程式碼,但資料庫結構沒有跟著更新,應用程式就會出錯。反之,如果手動修改了資料庫,但程式碼不知道這些變化,也可能出問題。
這就是資料庫遷移 (Database Migration) 的作用。open-notebook
使用 sblpy
函式庫(一個 SurrealDB 的 Python 函式庫,看起來 open-notebook
可能與其有關聯或使用了它)提供的遷移功能。
open_notebook/database/migrate.py
中的 MigrationManager
負責處理這件事:
# open_notebook/database/migrate.py (簡化示意)
from loguru import logger
from sblpy.migrations.db_processes import get_latest_version
from sblpy.migrations.runner import MigrationRunner
# ... (匯入 Migration 和 SurrealSyncConnection) ...
class MigrationManager:
def __init__(self):
# ... (設定資料庫連線 self.connection) ...
# 定義所有按順序執行的遷移檔案
self.up_migrations = [
Migration.from_file("migrations/1.surrealql"), # 第 1 版
Migration.from_file("migrations/2.surrealql"), # 第 2 版
# ... 可能還有更多 ...
]
# ... (可能還有 down_migrations 用於降級) ...
# 建立遷移執行器
self.runner = MigrationRunner(
up_migrations=self.up_migrations, ..., connection=self.connection
)
def get_current_version(self) -> int:
"""查詢資料庫目前處於哪個遷移版本"""
# sblpy 提供函式來查詢資料庫中的版本紀錄
return get_latest_version(...)
@property
def needs_migration(self) -> bool:
"""檢查是否需要執行遷移"""
current_version = self.get_current_version()
# 如果資料庫的當前版本 小於 我們定義的最新版本,就需要遷移
return current_version < len(self.up_migrations)
def run_migration_up(self):
"""執行資料庫遷移"""
if self.needs_migration:
logger.info("偵測到需要資料庫遷移,開始執行...")
try:
self.runner.run() # 執行 sblpy 的遷移
logger.info("資料庫遷移成功!")
except Exception as e:
logger.error(f"資料庫遷移失敗: {e}")
else:
logger.info("資料庫結構已是最新版本。")
程式碼解釋:
MigrationManager
會讀取 migrations/
資料夾下的 .surrealql
檔案。每個檔案代表一個資料庫結構的變更版本(例如 1.surrealql
可能定義了初始的表格,2.surrealql
可能增加了一個欄位)。get_current_version()
會去資料庫查詢一個特殊的紀錄,看看目前資料庫套用了哪個版本的遷移。needs_migration
屬性會比較程式碼中定義的最新遷移版本和資料庫的當前版本,判斷是否需要更新。run_migration_up()
會在需要時,呼叫 self.runner.run()
。這個執行器會自動執行所有尚未套用到資料庫的遷移檔案(從 current_version + 1
開始),確保資料庫結構與程式碼期望的一致。通常,應用程式在啟動時會檢查 needs_migration
,如果為 True
,就會執行 run_migration_up()
,確保資料庫總是處於最新的、正確的狀態。
在本章,也是我們 open-notebook
教學系列的最後一站,我們深入了解了「資料庫儲存庫 (Database Repository)」。
Repository
提供的標準化函數 (repo_create
, repo_query
等) 來操作資料,而無需關心底層的 SurrealQL 語法。Repository
的內部機制,包括如何使用 db_connection
安全地管理資料庫連接,以及 repo_query
, repo_create
, repo_update
等函數如何構建和執行 SurrealQL 查詢。migrate.py
) 的重要性,它確保了資料庫結構能隨著應用程式的發展而同步更新。資料庫儲存庫是 open-notebook
架構中一個 foundational 的部分,它為上層的資料模型和業務邏輯提供了一個穩定可靠的資料存取層,使得整個應用程式更加清晰、健壯且易於維護。
教學系列總結
恭喜您完成了 open-notebook
的核心概念教學系列!
我們從定義資料結構的 物件模型 (ObjectModel) 開始,學習了如何管理 AI 能力的 模型管理器 (ModelManager),探索了與使用者互動的 使用者介面 (Streamlit UI),深入了解了自動處理內容的 內容處理流程 (Content Processing Graph) 和驅動它的 LangGraph 狀態機 (Graph Workflows),掌握了生成 AI 指令的 提示詞管理器 (Prompter),認識了可重用的 AI 任務 轉換 (Transformations),最後到達了負責與資料庫溝通的 資料庫儲存庫 (Database Repository)。
希望這個系列能幫助您理解 open-notebook
專案背後的設計理念和核心元件。現在,您應該對如何閱讀、理解甚至貢獻 open-notebook
的程式碼有了更堅實的基礎。
感謝您的學習,祝您在探索和使用 open-notebook
的旅程中一切順利!