こんにちは。ゲームコミュニティ事業部サーバサイドエンジニアの谷脇です。
この記事はTech KAYAC Advent Calendar 2022の2日目です。
私はTonamelというWebサービスを運営しています。Tonamelでは、GoとPerlを用いてサーバサイドアプリケーションを構築しています。
この記事ではTonamelでのパッケージ構成や、DBを使う際に用いているライブラリについて紹介します。
そもそもTonamelって何
パッケージ構成やは、アプリケーションの特性や、実装の複雑さなども考慮するため、前提として作っているものを説明します。
Tonamelとはeスポーツを始めとした競技の大会を開催するときに用いるプラットフォームです。大会主催者と参加者双方が利用します。
この図に挙げているように、『参加者管理』と『トーナメント表』および『大会進行』がコア機能です。ですが、スポンサー機能を始めとした主催者を支援する機能や、主催団体管理など付随して様々な機能があります。また『参加者管理』の中にもエントリーフォームを自由に作れたり、『ゲストプレイヤー』という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
定番ですね。go-sql-driver/mysqlのtipsとしては、mysql.Config
構造体がdatabase/sql.Open
に渡すDSNを組み立てるのに便利です。
私がGo実装を担当したISUCON12予選でも使用していますので御覧ください。
DDLの記述: genddl
のっけから拙作のライブラリです。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
こちらは最近ですと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
拙作の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
)を作り、そのメソッドとしてテーブルから行を取ってくる、挿入を行うなうなどの実装をしています。この場合のTournamentTable
structはメソッドの単なるネームスペースとして利用しています。
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が設計されています。もう一つの特徴としては、コード生成でメソッドを生成しているため、any
やreflect
パッケージが使われておらず、静的型チェックが効くようになっています。
カラムが多くあるテーブルなどでも、テーブル定義を見ずにコード補完に頼ってカラムで絞り込むような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.TournamentStyle
、uint64
が基底型の定義型として記述しています。独自のメソッドを作れるメリットを生かして、以下のようにドメインに紐付いた知識を持たせています。
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つのサービスの粒度が大きいので、単に「サービス」と言ってます↩
- トーナメント表管理サービスはClean Architectureっぽい感じでやっています。理由としてはデータストアがDynamoDBだからというのが大きいです https://techblog.kayac.com/tonamel-double-elimination-1↩
- Tonamelは日本語の他に、英語と韓国語にも対応しています。↩
- https://techblog.kayac.com/golang-orm-xo-to-sqlla↩