Chapter 8: 資料庫儲存庫 (Database Repository)

歡迎來到 open-notebook 教學系列的最後一章!在上一章 轉換 (Transformations) 中,我們探討了如何定義和應用可重複使用的 AI 文字處理任務,像是摘要或翻譯,來豐富我們的筆記內容。

從第一章開始,我們陸續認識了 物件模型 (ObjectModel) 如何定義資料結構,模型管理器 (ModelManager) 如何管理 AI 模型,使用者介面 (Streamlit UI) 如何提供互動,LangGraph 狀態機 (Graph Workflows) 如何編排複雜流程,提示詞管理器 (Prompter) 如何生成 AI 指令,以及「轉換」如何處理文字。

但你有沒有想過,所有這些我們建立的筆記本、筆記、來源資訊、AI 模型設定,還有那些自訂的「轉換」,它們最終都儲存在哪裡呢?如果關閉應用程式再重新打開,這些資料還會在嗎?答案是肯定的,因為它們都被儲存到了資料庫中。但是,應用程式的各個部分是如何與資料庫溝通的呢?這就是「資料庫儲存庫 (Database Repository)」要解決的問題。

為什麼需要資料庫儲存庫?

想像一下,您正在管理一個龐大的圖書館。圖書館裡有成千上萬的書籍(資料),放在不同的書架(資料庫表格)上。當有人需要找書、登記新書、更新書籍資訊或註銷舊書時,如果每個人都直接跑到書架區自己動手,可能會發生什麼事?

在軟體開發中,如果應用程式的每個部分(例如 UI、物件模型、狀態機)都直接編寫與資料庫互動的程式碼(例如,撰寫 SQL 或 SurrealQL 指令),也會遇到類似的問題:

為了解決這些問題,我們引入了「資料庫儲存庫」。

什麼是資料庫儲存庫 (Database Repository)?

資料庫儲存庫 (Database Repository) 是 open-notebook 中一個專門負責處理應用程式與 SurrealDB 資料庫之間所有互動的元件。

它就像我們前面提到的那位圖書館管理員

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 使用。

讓我們回顧一下 ObjectModelsave() 方法是如何運作的(簡化版):

# 位於 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 {} # 示意

程式碼解釋:

  1. 當您呼叫 notebook.save() 時,ObjectModelsave() 方法會被執行。
  2. 它會準備好要存入資料庫的資料(一個字典 data)。
  3. 它檢查物件是否有 id
  4. 關鍵點:
    • 如果沒有 id,它會呼叫 Database Repository 提供的 repo_create(表格名稱, 資料) 函數。
    • 如果有 id,它會呼叫 repo_update(物件 ID, 資料) 函數。
  5. repo_createrepo_update 函數會負責與 SurrealDB 溝通,執行實際的資料庫操作。
  6. ObjectModel 接收到儲存庫函數的回傳結果,並用它來更新物件自身的屬性(例如,從 repo_create 的結果中獲取新產生的 id)。

同樣地,當您呼叫 Notebook.get(id) 時,ObjectModelget() 方法內部會呼叫 Database Repositoryrepo_query() 函數來執行資料庫查詢。當您呼叫 notebook.delete() 時,它內部會呼叫 repo_delete()

這種設計的好處是:

Database Repository (repository.py) 提供了以下核心函數:

深入探索:資料庫儲存庫的內部機制

現在我們知道 ObjectModel 是如何使用 Database Repository 的,那麼 Database Repository 內部又是如何與 SurrealDB 互動的呢?

執行流程概覽 (以 repo_create 為例)

ObjectModelsave() 方法呼叫 repo_create("notebook", {"name": "我的筆記", ...}) 時:

  1. 呼叫 repo_create ObjectModel 將表格名稱 ("notebook") 和資料字典 (data) 傳遞給 repo_create 函數。
  2. 構建查詢語句: repo_create 函數根據傳入的參數,動態地構建一個 SurrealQL 的 CREATE 語句,例如:CREATE notebook CONTENT {'name': '我的筆記', ...};
  3. 呼叫 repo_query repo_create 函數將構建好的查詢語句傳遞給核心的 repo_query 函數。
  4. 獲取資料庫連接 (db_connection): repo_query 函數使用一個稱為 db_connection 的「內容管理器」(Context Manager) 來獲取一個與 SurrealDB 的連接。
    • db_connection 會讀取環境變數(如 SURREAL_ADDRESS, SURREAL_USER 等)來建立一個 SurrealSyncConnection 物件。
    • 它確保在使用完畢後,連接會被正確關閉。
  5. 執行查詢: repo_query 使用獲取的連接物件 (connection),呼叫其 query() 方法,將 SurrealQL 查詢語句和可能的變數發送給 SurrealDB 執行。
  6. 處理結果/錯誤:
    • 如果 SurrealDB 成功執行查詢,它會返回結果(例如,新建立的紀錄包含其 id)。repo_query 將這個結果返回給 repo_create
    • 如果執行過程中發生錯誤(例如,網路問題、語法錯誤),repo_query 會捕捉錯誤,記錄日誌,並拋出一個異常。
  7. 返回結果: repo_create 接收到 repo_query 的結果,並將其返回給最初呼叫它的 ObjectModelsave() 方法。

以下是一個簡化的序列圖,展示了這個過程:

sequenceDiagram participant ObjectModel as 物件模型 (例如 save()) participant RepoCreate as repo_create 函數 participant RepoQuery as repo_query 函數 participant DBConnection as db_connection (連接管理器) participant SurrealDB as SurrealDB 資料庫 ObjectModel->>RepoCreate: repo_create("notebook", 資料) RepoCreate->>RepoCreate: 構建 SurrealQL (CREATE...) RepoCreate->>RepoQuery: repo_query(查詢語句, None) RepoQuery->>DBConnection: 請求連接 DBConnection-->>RepoQuery: 提供連接物件 RepoQuery->>SurrealDB: connection.query(查詢語句) SurrealDB-->>RepoQuery: 返回執行結果 (含新 ID) RepoQuery->>DBConnection: (隱式) 關閉連接 RepoQuery-->>RepoCreate: 返回結果 RepoCreate-->>ObjectModel: 返回結果

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() 

程式碼解釋:

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 # 將錯誤向上拋出,讓上層處理

程式碼解釋:

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) 

程式碼解釋:

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) 

程式碼解釋:

資料庫遷移 (migrate.py)

除了日常的增刪改查,還有一個重要的方面是資料庫結構的演變。隨著 open-notebook 功能的增加或修改,我們可能需要更改資料庫的「綱要」(Schema),例如:

如果直接修改程式碼,但資料庫結構沒有跟著更新,應用程式就會出錯。反之,如果手動修改了資料庫,但程式碼不知道這些變化,也可能出問題。

這就是資料庫遷移 (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("資料庫結構已是最新版本。")

程式碼解釋:

通常,應用程式在啟動時會檢查 needs_migration,如果為 True,就會執行 run_migration_up(),確保資料庫總是處於最新的、正確的狀態。

總結

在本章,也是我們 open-notebook 教學系列的最後一站,我們深入了解了「資料庫儲存庫 (Database Repository)」。

資料庫儲存庫是 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 的旅程中一切順利!