· 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
├── go.sum
├── main.go                  # CLIエントリーポイント(第13章)

├── storage/                 # ストレージエンジン(第2部)
│   ├── disk/
│   │   ├── disk_manager.go  # ファイルへのページ読み書き
│   │   └── disk_manager_test.go
│   ├── buffer/
│   │   ├── buffer_pool.go   # バッファプールマネージャ
│   │   └── buffer_pool_test.go
│   ├── page/
│   │   ├── page.go          # ページの基本構造
│   │   └── slotted_page.go  # スロット付きページ
│   ├── heap/
│   │   ├── heap_file.go     # ヒープファイル
│   │   └── heap_file_test.go
│   └── btree/
│       ├── btree.go         # B+木インデックス
│       └── btree_test.go

├── sql/                     # SQLエンジン(第3部)
│   ├── lexer/
│   │   ├── lexer.go         # 字句解析
│   │   └── lexer_test.go
│   ├── parser/
│   │   ├── parser.go        # 構文解析・AST生成
│   │   └── parser_test.go
│   ├── planner/
│   │   ├── planner.go       # 実行プラン生成
│   │   └── planner_test.go
│   ├── executor/
│   │   ├── executor.go      # クエリ実行
│   │   └── executor_test.go
│   └── catalog/
│       ├── catalog.go       # テーブル定義管理
│       └── catalog_test.go

└── transaction/             # トランザクション管理(第4部)
    ├── transaction.go       # トランザクション管理
    ├── lock/
    │   ├── lock_manager.go  # ロック管理
    │   └── lock_manager_test.go
    └── log/
        ├── log_manager.go   # WALログ管理
        ├── recovery.go      # リカバリ処理
        └── log_manager_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をインポートするような循環依存をコンパイルエラーとして禁止しています。依存関係は常に上位層→下位層の方向になるよう設計します。

sql / transaction → storage
storage の各パッケージ同士は最小限の依存に留める

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

上位層が下位層の具体的な実装に直接依存すると、テストやリファクタリングが難しくなります。上位層が下位層のインターフェースに依存するように設計します。

// storage/disk/disk_manager.go
type PageReader interface {
    ReadPage(pageID PageID, data []byte) error
    WritePage(pageID PageID, data []byte) error
}

③ 型の安全性を高める

生のint[]byteではなく、意味のある型エイリアスを定義して、誤った値の混入を防ぎます。

type PageID uint32
type SlotID uint16
type TransactionID 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.NewDiskManager(f.Name())
    if err != nil {
        t.Fatal(err)
    }
    defer dm.Close()

    // テスト本体
    page := make([]byte, disk.PageSize)
    copy(page, []byte("hello"))

    if err := dm.WritePage(0, page); err != nil {
        t.Fatal(err)
    }

    readBuf := make([]byte, disk.PageSize)
    if err := dm.ReadPage(0, readBuf); err != nil {
        t.Fatal(err)
    }

    if string(readBuf[:5]) != "hello" {
        t.Errorf("expected 'hello', got '%s'", readBuf[:5])
    }
}

テーブル駆動テスト

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

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

    for _, tt := range tests {
        t.Run(tt.input, func(t *testing.T) {
            tokens := Tokenize(tt.input)
            // アサーション
        })
    }
}

統合テスト

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

func TestIntegration_SelectFromTable(t *testing.T) {
    db := setupTestDB(t)      // テスト用DBをセットアップ
    defer db.Cleanup()

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

    rows := db.Query("SELECT id, name FROM users WHERE id = 1")
    // 結果を検証
}

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

# 全テストを実行
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
}

データベースでは通常リトルエンディアンが使われますが(x86アーキテクチャに合わせるため)、このシリーズでは読みやすさを優先してビッグエンディアンを採用しています。

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

データベースはディスクを「ページ」という固定長のブロック単位で読み書きします。このシリーズでは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
}

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

まとめ

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