$30 off During Our Annual Pro Sale. View Details »

Goバックエンド標準化プロジェクトの取り組み

 Goバックエンド標準化プロジェクトの取り組み

QualiArtsでは、2019年頃からGoをバックエンドのメインの言語として採用しており、これまでに複数のプロジェクトをGoで開発してきました。その中で、Goにおけるノウハウが溜まり、開発基盤やツールにおいて実装方法が成熟した部分が出てきていました。

一方で、ゲーム開発に限った話ではありませんが、基盤自体に手を入れるのは一定開発が進んだ段階では難しくなります。基盤の修正を行いたくても影響が広いため手を付けづらく、ゲーム自体の機能開発も行う必要があるため、おのずと優先度は下がってしまいます。

また、ゲーム運用のための管理ツールは、マスタデータの更新やユーザーデータの調査等、必要となる機能数も多く、0から作ると最低でも半年(6人月)程度は必要となります。ですが、管理ツールの開発はゲーム自体の開発の合間で行われることが多く、ある程度開発の後半に差し掛かっても、管理ツール機能の追加や改善が必要になる状況も珍しくはありません。

このような状況の中で、「バックエンド標準化プロジェクト」は始動しました。具体的には、新規プロジェクトを立ち上げる際に、テンプレートとすることができるリポジトリを構築する取り組みです。
このプロジェクトはGoで実装され、Goでの複数プロジェクト開発を経て、安定した基盤や管理ツール機能をテンプレートとして提供します。このテンプレートリポジトリは開発基盤や運用管理ツールのみを対象としているため、ゲーム仕様を意識すること無く調整・拡張することが可能です。

QualiArts

June 07, 2024
Tweet

More Decks by QualiArts

Other Decks in Programming

Transcript

  1. 島田 裕司 / Shimada Yuji - 2013年 株式会社サイバーエージェント入社 - 現在はQualiArtsにてスマートフォン向けゲーム

    開発を担当 - Go歴は5年程度 - 社内のGoコミュニティ「SGE.go」を運営 The Go gopher was designed by Renée French.
  2. テンプレートリポジトリの提供機能 • 認証 • 管理操作ログ • ダッシュボード • 開発環境管理 •

    マスターデータCRUD • スプレッドシート インポート/エクスポート • マスターデータ同期 • マスターデータタグ • トランザクションデータ CRUD • ユーザーデータ • ユーザー検索 • IAMロール • マスターバリデーション • Webviewエディタ • テーブル定義 / Enum定義 / API定義 参照 • アセットマネージャー • 課金残高履歴参照 • クライアントマスター参照 • サーバーマスター参照 • ユーザースナップショットマネージャー • APIテスター 運用管理ツール
  3. テンプレートリポジトリの提供機能 共通基盤 • エラー • トレース・メトリクス • ロギング • コンテキスト

    • CORSミドルウェア • ファイルサーバー • バリデーションインターセプター • アクセスログインターセプター • 認証トークンキャッシュ • ランキング • ロック • 報酬 / 消費 • 条件 API基盤 • マスタ取得 • 認証 • ユーザー作成 • ユーザーログイン デバッグ基盤 • コマンド一覧 • コマンド実行
  4. なぜライブラリ/FWではないのか ✔ 過去には多くの共通ライブラリ/FWを輩出 ✔ 同時にその衰退も経験 ・プロジェクト固有仕様に対応する無理な拡張 ・技術トレンドの変化 ・メイン開発者の不在 ✔ メンテナンスのコストが肥大化

    ✔ ゲーム開発の大規模化 ✔ 開発期間は2,3年以上、開発人数も100人を超える ✔ 並行して複数プロジェクトを進める機会が減少 → 共通ライブラリ/FWに縛られるより、良い部分をテンプレートに入れて次に活かす
  5. 標準化プロジェクトにおけるADRの一例 ✔ 技術選定 ・通信フレームワークにconnect-goを使用する ・DIツールにuber-go/fxを使用する ・定義とコードジェネレーターにProtocol Buffersを使用する ✔ パッケージ構成・コーディング規約 ・パッケージ名は標準パッケージと被らない名前にする

    ・ユースケース層のファイル配置を /usecase/xxx/usecase.go とする ・パッケージ名・ディレクトリ名はスネークケースにする ✔ テンプレートリポジトリの運用 ・技術選定時には過去プロジェクトの資産も考慮に入れる ・GitHub Issuesには明確にやることのみを起票する ・IssueとPRをReferencesで紐づける
  6. 背景2. TDTと相性が良くないテスト ✔ ゲームのコアロジック ✔ 1ケースのモック呼び出しで数百行に及ぶ ✔ モックの制御を含めたTDTの記述が難しい ✔ ケース毎に処理の分岐を行うテストが爆誕

    ✔ util関数等のシンプルな処理 ✔ 1テストケースが1行で済むのでTDTで記述するまでもない → ✔ 無理にTDTとしない ✔ モック呼び出しが複雑な場合、テスト関数内で複数サブテストを羅列 ✔ 処理がシンプルな場合、サブテストを使わず各ケースを羅列
  7. func TestUsecase_Login(t *testing.T) { t.Parallel() // モック呼び出しが複雑な場合、テスト関数内で複数サブテストを羅列 t.Run("新規ユーザー", func(t *testing.T)

    { t.Parallel() ... assert.Equal(t, want, got) }) t.Run("既存ユーザー", func(t *testing.T) { t.Parallel() ... assert.Equal(t, want, got) }) } テスト関数内で複数サブテストを羅列
  8. func TestSplitComma(t *testing.T) { t.Parallel() // 処理がシンプルな場合、サブテストを使わず各ケースを羅列 assert.Equal(t, []string{"a"}, SplitComma("a"))

    assert.Equal(t, []string{"a", "b", "c"}, SplitComma("a,b,c")) assert.Equal(t, []string{}, SplitComma("")) } サブテストを使わず各ケースを羅列
  9. テストのフォーマット統一 課題/目的 ✔ 対象 テストコード ✔ 課題・理想状態 ・書き手に依るテストコードのブレがなく、フォーマットが統一されている状態 ・コーディング/リーディング の負担を抑える

    ・新規メンバーがどのテストを参考にするか迷わないようにする ✔ 目的 ・テストコード記述方式の統一 ・TDテストによるデータと処理の分離、処理の共通化
  10. // Case テストケースの構造体 type Case[args, mocks, returns any] struct {

    Name string Args args Prepare func(args args, mocks *mocks) Returns returns } // Cases テストケース群の構造体 type Cases[args, mocks, returns any] []Case[args, mocks, returns] テストケースのジェネリクス定義 testutil.go
  11. // mocks モックの一覧 type mocks struct { cardService *card.MockService consumptionService

    *consumption.MockService rwTx *database.MockRWTx } // newWithMocks テスト対象と各種モックを生成 func newWithMocks(t *testing.T) (Usecase, *mocks) { m := &mocks{ cardService: card.NewMockService(t), consumptionService: consumption.NewMockService(t), rwTx: database.NewMockRWTx(t), } return NewUsecase(m.cardService, m.consumptionService), m } テスト対象と各種モックの生成
  12. 引数と戻り値の定義 func TestUsecase_LevelUp(t *testing.T) { t.Parallel() type args struct {

    ctx context.Context userID string cardID string level int } type returns struct { err error } // テストケースの記述とテスト実行 ... }
  13. cases := testutil.Cases[args, mocks, returns]{ { Name: "正常", Args: args{

    ctx: context.Background(), userID: "userID", cardID: "cardID", level: 10, }, Prepare: func(args args, m *mocks) { m.cardService.EXPECT(). LevelUp(args.ctx, m.rwTx, args.userID, args.cardID, args.level).Return(dto.Resources{}, nil).Times(1) m.consumptionService.EXPECT(). Consume(args.ctx, m.rwTx, args.userID, dto.Resources{}).Return(nil, nil).Times(1) }, Returns: returns{}, }, } テストケースの記述
  14. for _, tc := range cases { t.Run(tc.Name, func(t *testing.T)

    { t.Parallel() u, m := newWithMocks(t) tc.Prepare(tc.Args, m) err := u.LevelUp(tc.Args.ctx, tc.Args.userID, tc.Args.cardID, tc.Args.level) testutil.EqualError(t, tc.Returns.err, err) // 戻り値があればアサーション e.g. assert.Equal(t, tc.Returns.xxx, xxx) }) } テストの実行