· yuta

RDBMSを自作して学ぶ:第2章 Goで始めるデータベース実装の準備

#database#rdbms#go

前回はRDBMSが解決する問題とアーキテクチャの全体像を整理しました。今回から実際にGoでコードを書き始めます。まずはプロジェクトのディレクトリ構成とパッケージ設計方針、テスト戦略を決め、最初の動くコードとして「データをファイルに書いて読む」を実装します。

プロジェクト構成とパッケージ設計方針

Goモジュールの初期化

まずプロジェクトディレクトリを作成し、Goモジュールを初期化します。

mkdir go-rdbms
cd go-rdbms
go mod init github.com/yourname/go-rdbms

ディレクトリ構成

このシリーズでは以下のディレクトリ構成で実装を進めます。各パッケージが1つの責務に対応するように設計します。

go-rdbms/
├── go.mod
├── main.go                        # CLIエントリーポイント(第13章)
│
├── storage/                       # ストレージエンジン(第2部)
│   ├── page/
│   │   ├── page.go                # ページの基本構造(第3章)
│   │   └── slotted_page.go        # スロット付きページ(第5章)
│   ├── disk/
│   │   ├── disk_manager.go        # ファイルへのページ読み書き(第3章)
│   │   └── file_disk_manager.go
│   ├── buffer/
│   │   ├── lru_replacer.go        # ページの追い出しポリシー(第4章)
│   │   └── buffer_pool_manager.go # バッファプールマネージャ(第4章)
│   ├── heap/
│   │   └── heap_file.go           # ヒープファイル(第5章)
│   └── btree/
│       └── btree.go               # B+木インデックス(第6章)
│
├── sql/                           # SQLエンジン(第3部)
│   ├── lexer/
│   │   └── lexer.go               # 字句解析(第7章)
│   ├── parser/
│   │   └── parser.go              # 構文解析・AST生成(第7章)
│   ├── planner/
│   │   └── planner.go             # 実行プラン生成(第8章)
│   ├── executor/
│   │   └── executor.go            # クエリ実行(第8章)
│   └── catalog/
│       └── catalog.go             # テーブル定義管理(第9章)
│
└── transaction/                   # トランザクション管理(第4部)
    ├── transaction_manager.go     # トランザクション管理(第10章)
    ├── lock/
    │   └── lock_manager.go        # ロック管理(第11章)
    └── log/
        ├── log_manager.go         # WALログ管理(第12章)
        └── recovery.go            # リカバリ処理(第12章)

主なファイルだけを載せています。テストは、それぞれのファイルと同じディレクトリに xxx_test.go として置きます。

パッケージと責務の対応をまとめると次のようになります。

パッケージ 責務
storage/disk ファイルへのページ単位の読み書き
storage/buffer ページのメモリキャッシュ(バッファプール)
storage/page ページ・スロット付きページの構造定義
storage/heap ヒープファイル(レコード格納)
storage/btree B+木インデックス
sql/lexer 字句解析
sql/parser 構文解析・AST生成
sql/planner 実行プラン生成
sql/executor クエリ実行
sql/catalog テーブル定義管理
transaction トランザクション管理・ロック・WALログ

パッケージ設計の方針

① 循環インポートを避ける

GoはパッケージAがパッケージBをインポートし、BがAをインポートするような循環依存をコンパイルエラーとして禁止しています。依存関係は常に上位層→下位層の方向になるよう設計します。

例: executor → catalog → heap → buffer → disk → page
    (上位層は下位層をインポートしてよいが、逆は禁止)

下位層から上位層の機能を使いたくなった場合は、下位層の側にインターフェースを定義し、上位層から実装を渡してもらいます(第12章で、ヒープファイルがWALにログを書く部分をこの方法で実装します)。

② インターフェースで疎結合にする

実装を差し替えたくなる境界では、上位層が下位層の具体的な実装ではなくインターフェースに依存するようにします。例えば第3章のDiskManagerは、次のようなインターフェースとして定義し、ファイルを使う実装とは分けます。

// storage/disk/disk_manager.go(第3章で詳しく実装します)
type DiskManager interface {
    ReadPage(pageID page.PageID, p *page.Page) error
    WritePage(pageID page.PageID, p *page.Page) error
    AllocatePage() (page.PageID, error)
    // ...
}

こうしておけば、バッファプールのテストでディスクを使わない実装に差し替える、といったことができます。ただし、すべてをインターフェースにする必要はありません。差し替える予定のないものは具体的な型のまま使い、必要になった箇所だけインターフェースにします。

③ 型の安全性を高める

IDのような値は、生のuint32のままではなく、意味のある名前の型として定義して、誤った値の混入を防ぎます。

type PageID uint32  // ページID(第3章)
type FrameID uint32 // バッファプールのフレームID(第4章)
type TxID uint32    // トランザクションID(第10章)

type PageID uint32 は型定義で、PageID は uint32 とも FrameID とも別の型になります。そのため、ページIDを渡すべき引数にフレームIDを渡すと、コンパイルエラーで気づけます。なお type PageID = uint32 と書くと型エイリアスになり、uint32 と同じ型として扱われるので、この効果は得られません。

テスト戦略

Goのテストの基本

Goはテストが言語仕様に組み込まれています。xxx_test.go というファイルに Test から始まる関数を書き、go test ./... で実行します。

// storage/disk/disk_manager_test.go
package disk_test

import (
    "testing"
    "github.com/yourname/go-rdbms/storage/disk"
)

func TestDiskManager_ReadWrite(t *testing.T) {
    // テストコード
}

このシリーズでは次の3種類のテストを使い分けます。

テスト種別 対象 方針
ユニットテスト 各コンポーネント単体 一時ファイルを作成し、テスト終了後に削除する
テーブル駆動テスト 複数ケースの網羅 構造体のスライスでケースを列挙し、ループで実行する
統合テスト 複数コンポーネントの連携 SQL実行からストレージアクセスまでの一連の流れを検証する

ユニットテスト

各コンポーネントを単体でテストします。外部依存(ファイルシステムなど)はできる限りインターフェースで差し替えられるように設計します。

テストごとに一時ファイルを作成し、テスト終了後に削除する方針を取ります。

func TestDiskManager_ReadWrite(t *testing.T) {
    // 一時ファイルを作成
    f, err := os.CreateTemp("", "test-*.db")
    if err != nil {
        t.Fatal(err)
    }
    f.Close()
    defer os.Remove(f.Name()) // テスト終了後に削除

    dm, err := disk.NewFileDiskManager(f.Name())
    if err != nil {
        t.Fatal(err)
    }
    defer dm.Close()

    // テスト本体
    pageID, err := dm.AllocatePage()
    if err != nil {
        t.Fatal(err)
    }
    var p page.Page
    copy(p.Data(), []byte("hello"))
    if err := dm.WritePage(pageID, &p); err != nil {
        t.Fatal(err)
    }

    var read page.Page
    if err := dm.ReadPage(pageID, &read); err != nil {
        t.Fatal(err)
    }
    if string(read.Data()[:5]) != "hello" {
        t.Errorf("expected 'hello', got '%s'", read.Data()[:5])
    }
}

ここで使っている disk.NewFileDiskManager や page.Page は、次章で実装します。

テーブル駆動テスト

Goのテストでは「テーブル駆動テスト」が慣用的です。複数のケースを構造体のスライスで列挙し、ループで実行します。

func TestTokenize(t *testing.T) {
    tests := []struct {
        input    string
        expected []lexer.TokenType
    }{
        {"SELECT id FROM users", []lexer.TokenType{lexer.SELECT, lexer.IDENT, lexer.FROM, lexer.IDENT, lexer.EOF}},
        {"INSERT INTO users", []lexer.TokenType{lexer.INSERT, lexer.INTO, lexer.IDENT, lexer.EOF}},
    }

    for _, tt := range tests {
        t.Run(tt.input, func(t *testing.T) {
            tokens := lexer.Tokenize(tt.input)
            // tokensの種別がtt.expectedと一致するかを確かめる
        })
    }
}

(Lexerは第7章で実装します。)

統合テスト

後半の章では複数のコンポーネントを組み合わせた統合テストも書きます。たとえば「SQL文を受け取ってストレージからデータを取得し返す」という一連の流れをテストします。

func TestIntegration_SelectFromTable(t *testing.T) {
    database, err := db.Open(db.Config{Dir: t.TempDir()}) // テスト用DBを一時ディレクトリに作る
    if err != nil {
        t.Fatal(err)
    }
    defer database.Close()
    s := database.NewSession()

    s.Exec("CREATE TABLE users (id INT, name VARCHAR(50))")
    s.Exec("INSERT INTO users VALUES (1, 'Alice')")

    res, err := s.Exec("SELECT id, name FROM users WHERE id = 1")
    // 結果を検証
}

(db パッケージは、すべてのコンポーネントを束ねるものとして第13章で実装します。)

テストの実行コマンドは以下の通りです。

# 全テストを実行
go test ./...

# 特定パッケージのテスト
go test ./storage/disk/...

# 詳細出力
go test -v ./...

# カバレッジ確認
go test -cover ./...

最初の “Hello, Database” ── データをファイルに書いて読む

実装に入る前に、Goでファイルにバイナリデータを書き込み、読み込む最小限のコードを書いてみます。これが次章以降で実装するDiskManagerの原型になります。

バイトスライスとバイナリ操作の基礎

データベースではデータをバイト列として扱います。Goでは[]byteがその役割を担います。整数やその他の型をバイト列に変換するにはencoding/binaryパッケージを使います。

package main

import (
    "encoding/binary"
    "fmt"
)

func main() {
    // uint32 をビッグエンディアンで4バイトに変換
    buf := make([]byte, 4)
    binary.BigEndian.PutUint32(buf, 42)
    fmt.Println(buf) // [0 0 0 42]

    // バイト列から uint32 に戻す
    val := binary.BigEndian.Uint32(buf)
    fmt.Println(val) // 42
}

ディスク上の形式にどちらのバイト順を使うかは、データベースによって異なります。例えばSQLiteのファイル形式はビッグエンディアンで、PostgreSQLはCPUのネイティブなバイト順(x86ならリトルエンディアン)でそのまま書き込みます。このシリーズでは、16進ダンプで見たときに数値を読みやすいビッグエンディアンを採用しています。

ファイルへのページ書き込み

データベースはディスクを「ページ」という固定長のブロック単位で読み書きします。このシリーズでは1ページを4096バイト(4KB)とします。

// hello_database/main.go
package main

import (
    "encoding/binary"
    "fmt"
    "os"
)

const PageSize = 4096 // 4KB

func main() {
    // ファイルを作成(存在する場合は上書き)
    f, err := os.Create("test.db")
    if err != nil {
        panic(err)
    }
    defer f.Close()

    // ページ0に書き込む
    page := make([]byte, PageSize)
    // ページの先頭4バイトにマジックナンバーを書き込む
    binary.BigEndian.PutUint32(page[0:4], 0xDEADBEEF)
    // 4バイト目からレコード数を書き込む
    binary.BigEndian.PutUint32(page[4:8], 3)
    // 8バイト目から文字列データを書き込む
    copy(page[8:], []byte("Hello, Database!"))

    if _, err := f.WriteAt(page, 0); err != nil {
        panic(err)
    }

    fmt.Println("ページ0を書き込みました")

    // ページ0を読み込む
    readPage := make([]byte, PageSize)
    if _, err := f.ReadAt(readPage, 0); err != nil {
        panic(err)
    }

    magic := binary.BigEndian.Uint32(readPage[0:4])
    count := binary.BigEndian.Uint32(readPage[4:8])
    msg := string(readPage[8:8+16])

    fmt.Printf("マジックナンバー: 0x%X\n", magic) // 0xDEADBEEF
    fmt.Printf("レコード数: %d\n", count)          // 3
    fmt.Printf("メッセージ: %s\n", msg)             // Hello, Database!
}

実行してみます。

mkdir hello_database
cd hello_database
# 上記コードを main.go として保存
go run main.go

出力:

ページ0を書き込みました
マジックナンバー: 0xDEADBEEF
レコード数: 3
メッセージ: Hello, Database!

WriteAt / ReadAt はファイルの任意のオフセットからバイト列を読み書きする関数です。ページIDとページサイズを掛けることで、任意のページに直接アクセスできます。

ページ0のオフセット: 0    * 4096 = 0
ページ1のオフセット: 1    * 4096 = 4096
ページNのオフセット: N    * 4096 = N * PageSize

複数ページへの書き込み

実際のデータベースでは多数のページを管理します。複数ページへの書き込みを確認します。

// 複数ページを書き込む
for i := uint32(0); i < 5; i++ {
    page := make([]byte, PageSize)
    binary.BigEndian.PutUint32(page[0:4], i) // ページIDをページ先頭に記録
    offset := int64(i) * PageSize
    if _, err := f.WriteAt(page, offset); err != nil {
        panic(err)
    }
}

// ページ3だけを読み込む
page3 := make([]byte, PageSize)
if _, err := f.ReadAt(page3, 3*PageSize); err != nil {
    panic(err)
}
pageID := binary.BigEndian.Uint32(page3[0:4])
fmt.Printf("読み込んだページID: %d\n", pageID) // 3

この「オフセットを計算してWriteAt/ReadAt」という操作が、次章で実装するDiskManagerのコアになります。

現時点のコード全体

package main

import (
    "encoding/binary"
    "fmt"
    "os"
)

const PageSize = 4096

func writePage(f *os.File, pageID uint32, data []byte) error {
    offset := int64(pageID) * PageSize
    _, err := f.WriteAt(data, offset)
    return err
}

func readPage(f *os.File, pageID uint32, buf []byte) error {
    offset := int64(pageID) * PageSize
    _, err := f.ReadAt(buf, offset)
    return err
}

func main() {
    f, err := os.OpenFile("test.db", os.O_RDWR|os.O_CREATE, 0644)
    if err != nil {
        panic(err)
    }
    defer f.Close()

    // 書き込み
    page := make([]byte, PageSize)
    binary.BigEndian.PutUint32(page[0:4], 42)
    if err := writePage(f, 0, page); err != nil {
        panic(err)
    }

    // 読み込み
    buf := make([]byte, PageSize)
    if err := readPage(f, 0, buf); err != nil {
        panic(err)
    }

    val := binary.BigEndian.Uint32(buf[0:4])
    fmt.Printf("読み込んだ値: %d\n", val) // 42
}

writePage と readPage という2つの関数が定義できました。次章ではこれを DiskManager という構造体に整理し、ページ管理を本格的に実装します。

まとめ

次回は本章の writePage / readPage 関数を DiskManager として整理し、ページ管理の基盤を完成させます。