GoでDBを使ったアプリを書くときみんなどうしてる? Tonamelはどうしているか晒してみます

こんにちは。ゲームコミュニティ事業部サーバサイドエンジニアの谷脇です。

この記事はTech KAYAC Advent Calendar 2022の2日目です。

私はTonamelというWebサービスを運営しています。Tonamelでは、GoとPerlを用いてサーバサイドアプリケーションを構築しています。

この記事ではTonamelでのパッケージ構成や、DBを使う際に用いているライブラリについて紹介します。

そもそもTonamelって何

パッケージ構成やは、アプリケーションの特性や、実装の複雑さなども考慮するため、前提として作っているものを説明します。

tonamel.com

Tonamelとはeスポーツを始めとした競技の大会を開催するときに用いるプラットフォームです。大会主催者と参加者双方が利用します。

Tonamelの機能説明

この図に挙げているように、『参加者管理』と『トーナメント表』および『大会進行』がコア機能です。ですが、スポンサー機能を始めとした主催者を支援する機能や、主催団体管理など付随して様々な機能があります。また『参加者管理』の中にもエントリーフォームを自由に作れたり、『ゲストプレイヤー』というTonamelのアカウントを持っていない方でもトーナメント表に組み込める機能があったりと、多種多様な機能を備えています。

全体のパッケージ構成

Goで何らかのアプリケーションを作る際のパッケージ構成について悩まされた人も多いかと思います。

Tonamelのサーバサイド内部はいくつかのGo製のサービス1で構成されていますが、一部の特殊なサービス2を除くと以下の構成になっています。

パッケージ構成図

矢印は依存の関係です。ざっくりとしたレイヤードアーキテクチャです。ユーザーから同期的に受けるリクエストはGraphQLを使っています。ライブラリはgqlgenを用いていますが。Domain Eventは非同期処理や、他のサービスからの情報同期・処理移譲のメッセージであると考えて下さい。Domain Eventに関しては去年私が書いた記事を読んでください。

Active Recordパターンでやっています。Rowモデルに関するロジックはrecord層の行に直接紐付いたstructに記述しています。一方でデータベースの行に紐づきにくい処理などは、service層で記述しています。ここがビジネスロジック単位の記述になります。そして、各ユースケースごとのinput/outputの変換や、ビジネスロジック間の橋渡しはusecase層で行っています。

トランザクション制御の関係もあり、DBコネクションはusecaseの時点で生成しています。usecaseからclient/databaseへ伸びている依存はこの関係を表しています。直接のDB操作はrecord層が行いますが、このときのDBコネクションはusecaseからメソッド呼び出しの引数として渡されています。

client/に所属するパッケージには他にも、暗号化を担当するclient/encryptionや、多言語化3のため辞書を持つclient/i18nがあります。また、他のサービスへ通信するためのクライアントもここに格納されています。データベース接続を含む外部通信などの腐敗防止層が置かれていると考えて下さい。

さて、今回はclient/database及びrecord層で使われている使用ライブラリや、使い方について具体的に解説していきます。

DBまわりの選択

TonamelではメインのデータベースにAmazon Aurora MySQLを用いています。そのあたりを考慮して選んでいます。

DB driver: go-sql-driver/mysql

github.com

定番ですね。go-sql-driver/mysqlのtipsとしては、mysql.Config構造体がdatabase/sql.Openに渡すDSNを組み立てるのに便利です。

私がGo実装を担当したISUCON12予選でも使用していますので御覧ください。

DDLの記述: genddl

github.com

のっけから拙作のライブラリです。GoのstructでMySQLのDDLを記述できます。

例として以下のようなstructを記述します。

package record

//go:generate genddl -outpath=../sql/mysql.sql -driver=mysql -innerindex -uniquename

type CompetitionID uint64

type TournamentID uint64

// +table: tournament
type Tournament struct {
  ID            TournamentID             `db:"id,primarykey,autoincrement"`,
  CompetitionID TournamentID             `db:"competition_id"`,
  Style         constant.TournamentStyle `db:"style"`
  BlockNum      int64                    `db:"block_num"`
  Description   string                   `db:"description,text"`
  EndAt         sql.NullTime             `db:"end_at"`
}

func (t Tournament) _schemaIndex(methods index.Methods) []index.Definition {
  return []index.Definition{
    methods.Complex(t.CompetitionID),
  }
}

go generate すると、以下のDDLが生成されます。

DROP TABLE IF EXISTS `tournament`;

CREATE TABLE `tournament` (
    `id` BIGINT unsigned NOT NULL PRIMARY KEY,
    `competition_id` BIGINT unsigned NOT NULL,
    `style` BIGINT unsigned NOT NULL
    `block_num` BIGINT NOT NULL,
    `description` TEXT NOT NULL,
    `end_at` DATETIME NULL,
    INDEX competition_id (`competition_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

genddlは後述するORM sqllaとの相性もありますが、何よりもrecord層のRowモデルであるstructの記述と一致させたいために、structから生成しています。

実は社内でもMySQLのDDLからstructを生成する派閥、つまりTonamelの手法とは逆のことをやっているプロジェクトもあります。どちらもメリット・デメリットがありますが、私がgenddlを開発していて、実際のユースケースに合わせた改良がしやすいという面からも、genddlを採用しています。

DDLの適用: schemalex

github.com

こちらは最近ですとsqldefもあるので、こちらでもいいかなと思うのですが、社内の実績からschemalexを使い続けています。

genddlでsql/mysql.sqlにDDLが吐かれている前提で、以下のようなコードをclient/databaseに置き、差分適用できるようにしています。

// Migrate はDBスキーマを更新します
func Migrate(ctx context.Context, conf *config.Database) error {
  schema, err := MigrationSchema(conf)
  if err != nil {
    return errors.Wrap(err, "fail to generate schema diff")
  }
  dsn := conf.DSN()
  if schema == "" {
    return nil
  }

  sqlDB, err := sql.Open("mysql", dsn+"&multiStatements=true")
  if err != nil {
    return errors.Wrapf(err, "error sql.Open. DSN: %s?multiStatements=true", dsn)
  }
  if _, err := sqlDB.ExecContext(ctx, schema); err != nil {
    return errors.Wrap(err, "error migration")
  }

  return nil
}

// MigrationSchema は現在のDBのスキーマとの差分を生成したDDLを返します
func MigrationSchema(conf *config.Database) (string, error) {
  dsn := conf.DSN()
  schema := filepath.Join(application.Home(), "sql", "mysql.sql")
  mysqlSource := schemalex.NewMySQLSource(dsn)
  fileSource, err := schemalex.NewSchemaSource(schema)
  if err != nil {
    return "", errors.Wrap(err, "fail to create File Source")
  }
  dst := &strings.Builder{}
  if err := diff.Sources(dst, mysqlSource, fileSource); err != nil {
    return "", errors.Wrap(err, "fail to diff from sources")
  }
  return dst.String(), nil
}

実際の適用時のスクリプトではdry-runモードも備えています。dry-runモードではMigrationSchema関数のみ実行し、適用予定のDDLを表示するだけで終了します。dry-runモードではないときはMigrate関数を実行し、DBにDDLを適用しています。

sqlla

github.com

拙作のORMです、3年前のAdvent Calendar4でも紹介していますが、Tonamelでの使い方を含めて改めて、紹介します。

genddlの項で記述した以下のstructからsqllaを使うコードも生成します。

package record

//go:generate sqlla

// +table: tournament
type Tournament struct {
  ID            TournamentID             `db:"id,primarykey,autoincrement"`,
  CompetitionID TournamentID             `db:"competition_id"`,
  Style         constant.TournamentStyle `db:"style"`
  BlockNum      int64                    `db:"block_num"`
  Description   string                   `db:"description,text"`
  EndAt         sql.NullTime             `db:"end_at"`
}

すると以下のようなSQLの記述と実行ができます。Tonamelではテーブル全体を表すstruct(以下ではTournamentTable)を作り、そのメソッドとしてテーブルから行を取ってくる、挿入を行うなうなどの実装をしています。この場合のTournamentTablestructはメソッドの単なるネームスペースとして利用しています。

package record

type TournamentTable struct {}

func (t *TournamentTable) GetByID(ctx context.Context, db sqlla.DB, id TournamentID) (*Tournament, error) {
  // SELECT id, competition_id, style, block_num, description, end_at FROM tournament WHERE id = 42
  tournament, err := NewTournamentSQL().Select().ID(42).SingleContext(ctx, db)
  if err != nil {
    return nil, fmt.Errorf("fail to get Tournament: id=%d, %w", id, err)
  }
  return &tournament, nil
}

sqllaはできるだけSQLでの記述順を損なわないようにDSLが設計されています。もう一つの特徴としては、コード生成でメソッドを生成しているため、anyreflectパッケージが使われておらず、静的型チェックが効くようになっています。

カラムが多くあるテーブルなどでも、テーブル定義を見ずにコード補完に頼ってカラムで絞り込むようなSQLが発行できます。

また、TonamelではRowモデルstruct自体にも独自にメソッドを生やして利用しています。

func (t Tournament) End(ctx context.Context, db sqlla.DB) error {
  now := time.Now()
  // UPDATE tournament SET end_at = ? WHERE id = ?
  if _, err := t.Update().
    SetEnd(sql.NullTime{ Valid: true, Time: now }).
    ExecContext(ctx, db); err != nil {
    return fmt.Errorf("fail to update end_at now=%s: %w", t, err)
  }
  return nil
}

TIPS: 定義型を活用する

上記のgenddlおよびsqllaの項で述べたtournamentテーブルのidカラムには、TournamentIDという独自の型を定義して使用しています。これは定義を見ると、

type TournamentID uint64

と、uint64が基底型になっています。genddlとsqllaは、基底型までほどいて内部ではuint64として使用します。

結局uint64として扱うのに、このように記述している理由として、他のテーブルのIDを間違えて入れるのを防ぐためというのがあります。uint64だと、以下のクエリが型チェックで防がれずにコンパイルできてしまいます。

var competitionID uint64
competitionID = 111
NewTournamentSQL().Select().ID(competitionID).SingleContext(ctx, db)

テストを書いていても、IDを全部1にしていると、取り違えてもテスト自体は通ってしまいます。そこでテストではテーブルごとにAUTO INCREMENTをランダムにするなどの対策をしていましたが、今は型である程度防げることに気がついたため、ユーザー定義型を活用しています。

また、定義型には独自にメソッドを作れます。例ではconstant.TournamentStyleuint64が基底型の定義型として記述しています。独自のメソッドを作れるメリットを生かして、以下のようにドメインに紐付いた知識を持たせています。

package constant

type TournamentStyle uint64

const (
  TournamentStyleSingleElimination TournamentStyle = iota + 1
  TournamentStyleDoubleElimination
  TournamentStyleSwissSystem
)

// IsElimination はトーナメント形式がエリミネーション系かどうかを返します
func (t TournamentStyle) IsElimination() bool {
  switch t {
  case TournamentStyleSingleElimination, TournamentStyleDoubleElimination:
    return true
  default:
    return false
  }
}

まとめ

  • Tonamelのパッケージ構造とDB関係の解説をしました
  • Tonamelではgenddl + schemalex + sqllaの組み合わせで実装しています
  • genddlとsqllaは自作なので、ほしいと思ったらすぐにライブラリを変えられるので便利です
  • 定義型を活用すると型の恩恵をより得られます

明日3日目は id:kuroda9029 の 「仕事でSvelteを使った話」です。


  1. 設計当初はマイクロサービスと言っていましたが、最近は1つ1つのサービスの粒度が大きいので、単に「サービス」と言ってます
  2. トーナメント表管理サービスはClean Architectureっぽい感じでやっています。理由としてはデータストアがDynamoDBだからというのが大きいです https://techblog.kayac.com/tonamel-double-elimination-1
  3. Tonamelは日本語の他に、英語と韓国語にも対応しています。
  4. https://techblog.kayac.com/golang-orm-xo-to-sqlla