私の考えた最強にコスパいいバックエンド構成
はじめに
こんにちは!株式会社Showcase GigでEMをしている @yuuuutskです!
この記事ではバックエンドの実装を効率的に進める個人的ベストプラクティスを書いています。
言語としてはGoですが、他の言語でもある程度応用が効くものになっているはずです。
RailsやLaravelを長くやってきている中でGoはイニシャルコストが高そうという理由でなんとなく嫌煙してる方いませんか!?私もそうでした😂
そんなあなたに朗報です!
この記事を読めば、明日からGoを使ってさくさくバックエンド開発ができることでしょう🎉🎉
Go言語について
Goは静的型付け言語で「シンプル」に書くことに特化している言語仕様です。それ故にシンプルに書くことが推奨されています。
https://gihyo.jp/news/report/01/GoCon2014Autumn/0002
Goに触れるととにかく「シンプル」だと言われます。
一つ一つがシンプルになることで、迷わず設計実装が出来る事が大きなメリットと言えるでしょう。
しかし、何も武器なしにGoのバックエンド開発に挑むとシンプルな車輪の再開発を大量にすることになります。
以降、バックエンド開発をコスパよく進めるためのノウハウを解説していきます。
バックエンド開発の主要な構成要素
バックエンドの実装や観点分類すると、以下の要素が挙げられます。
DB操作
DB Migrationツール
APIサーブ+スキーマ駆動開発
CLI
業務ロジック
コアドメイン、サブドメイン、汎用ドメインなど
パフォーマンスや整合性に関する設計
その他
この中で上の3つはそれ自体に事業価値はあまりないが業務ロジックを提供するためには不可欠な要素となっています。
一方で業務ロジックやパフォーマンス・整合性に関する部分は要件に応じて作り込みをしていくべきで、なるべくここに集中できるような設計になっていることが好ましいです。
「集中」すべき部分は自分たちで実装し、そうでない部分は「再利用」するかコードの自動生成するのがいいでしょう!
再利用や自動生成を活用していくために、一つ一つのコードはシンプルでかつ責務が明確にわかれていることが大切になってきます。
そこで、おすすめなのはオニオンアーキテクチャです。レイヤードアーキテクチャにDI(Dependency Inversion)ツールを掛け合わせて、依存性をスッキリさせたというイメージが近いものになると思います。
このようにレイヤー毎に責務を明確に分離することで自動生成にも大きな恩恵が得られます!
ApplicationやDomainレイヤーと比べInfrastructureレイヤーは汎用的な操作しかしなくなるので、コードのほとんどを自動生成に頼ることができます。
これより「集中」すべき部分に集中し、その他は楽をするということができます。
レイヤー化するとレイヤー間で一定の変換処理(バケツリレー)が発生し実装コストが上がりますが変換処理は単純作業なので、 Github Copilot を活用すると良いです。使うとタイピングするコード量が半分以下になるので本当におすすめです!
以降では実際のコードを元に具体例を解説していこうと思います!
この記事のエッセンスを取り入れたバックエンドのリポジトリを公開しました。
自動生成に頼るべきところ
DB操作に関するコード
個人的第一位です!この効果が一番大きいです。
DB操作は複雑なクエリのケースを除いてCRUD(Create,Read,Update,Delete)は構成が似ているため自動生成ツールを間違いなく使うべき所になります。
Goはいくつかツールがありますがおすすめは sqlboilerです。
generateコマンドを実行するとmysqlと接続を行い、スキーマ情報を読み取ります。そこからスキーマ情報を元にCRUD関数を自動生成してくれます。
sqlboiler mysql -o app/infrastracture/dao -p \
dao --no-driver-templates --wipe \
--templates templates/sqlboiler/templates \
--templates templates/sqlboiler/customized_templates
https://github.com/yuuuutsk/gobase-backend/blob/main/Makefile#L58-L61
上記のコマンドからテンプレートを指定して、テーブル毎に以下の様なCRUDの関数と型が定義された.goファイルが自動生成されます。
// All returns all User records from the query.
func (q userQuery) All(ctx context.Context, exec boil.ContextExecutor) (UserSlice, error) {
var o []*User
err := q.Bind(ctx, exec, &o)
if err != nil {
return nil, errors.Wrap(err, "dao: failed to assign all query results to User slice")
}
if len(userAfterSelectHooks) != 0 {
for _, obj := range o {
if err := obj.doAfterSelectHooks(ctx, exec); err != nil {
return o, err
}
}
}
return o, nil
}
// Count returns the count of all User records in the query.
func (q userQuery) Count(ctx context.Context, exec boil.ContextExecutor) (int64, error) {
var count int64
queries.SetSelect(q.Query, nil)
queries.SetCount(q.Query)
err := q.Query.QueryRowContext(ctx, exec).Scan(&count)
if err != nil {
return 0, errors.Wrap(err, "dao: failed to count users rows")
}
return count, nil
}
https://github.com/yuuuutsk/gobase-backend/blob/main/app/infrastracture/dao/users.go
以下が自動生成されたコードを使った絞り込み検索コードになります。
絞り込み部分も自動生成されて程よい型安全の中ですいすいDB操作の実装ができますね🎉
mods := []qm.QueryMod{
dao.UserWhere.FirstName.EQ("hoge"),
}
dtos, err := dao.Users(
mods...,
).All(ctx, repo.db)
if err != nil {
return nil, err
}
result := make([]*model.User, 0, len(dtos))
for i, dto := range dtos {
result[i] = model.RestoreUser(
domain.UserID(dto.ID),
dto.FirstName,
dto.LastName)
}
APIのスキーマ駆動開発
APIのスキーマとコードを一致させることは、今の時代は当たり前になってきてますね!
これも自動生成が使える場合が多いのでツールを活用しましょう!
gRPC・OpenAPI・GraphQLどれも自動生成ツールがあります。
gRPC
OpenAPI
GraphQL
サンプルコードではgqlgenをつかった実装例を用意しています。
スキーマと実装が乖離しているとAPI実装者もAPI利用者も苦労するので、必ず自動生成ツールを使いましょう!
絶対採用すべきツール
DB Migrationツール
DB Migrationツールは言われるまでもなく利用するかと思いますが、その中でもおすすめの考え方を解説します。
大きく二種類あり、フェーズによって使い分けるといいでしょう。
バージョン管理型マイグレーション
Sync型マイグレーション
バージョン管理型マイグレーションツールは一番主流なツールで、本番運用中のサービスは基本的にこれを使うのが安全でいいでしょう。
しかし、初期リリースまではあまりおすすめしないです。
カラム変更の度にマイグレーションファイルを追加する必要があるからです。
また、マイグレーションバージョンを最新のものを一つだけ保持することタイプのものはもう一手間かかります。
チーム開発をしているとマージタイミングによってバージョン番号も更新し直す必要があります。たとえば以下のmigrationファイルがある状態から
20221220101010_create_users.sql
20221223101011_create_user_profiles.sql
Aさんが20221223101012_create_images.sqlを追加するPRを作成。
その後Bさんが20221223101013_create_settings.sqlを追加するPRを作成。
順序としてはAさんのほうが更新したタイミングが早かったのですが、Bさんが先にマージしてしまうと、Aさん以外の人がmigrationを実行してしまうと困ったことが起きます。
バージョンはBさんのバージョンの方が新しいのでAさんのPRをマージしたあと、migrationの実行エラーになってしまいます。
Sync型の場合はこのような面倒事はありません。
Create SQL文を元にmysqlのスキーマに対して差分アップデートをしてくれます。
CREATE TABLE `users` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`first_name` varchar(200) NOT NULL,
`last_name` varchar(200) NOT NULL,
PRIMARY KEY(`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
スキーマ変更はCreate SQLファイルを修正していくだけで、完了です。
見通しがよく、変更が楽ですね!
ツールはmysqldefを使っています。
コマンドもシンプルで一度導入してみるととても管理が楽です😊
cat ./dbschema/*.sql | mysqldef -u testuser -p \
password -h 127.0.0.1 gobase-backend_local
ただ、注意が必要なのは本番運用が始まると意図しないDBアップデート、たとえばカラム削除などのリスクが高くなるので本番運用が始まるまえにバージョン管理型マイグレーションに移行することをおすすめします。
ダウンタイムが許容されるサービスの場合は多少状況は異なるかもしれませんが安全なのはバージョン管理型マイグレーションであると言えます。
その他
テスト
テストは目的とする品質を担保するための、コスパ的手段の一つです。
この考え方が大事だけど意外と実践されてないところが多いと思います。
テストは手動でも自動でも品質担保のための手間(人件費)が同じであればどんな選択肢でもいいはずです。
なので、単体テストだけのカバレッジに固執しすぎるのは本質的ではなく単体テストケースを用意するところと網羅的に動かす結合テストのバランスを設計することが大事です。
基本的には「DBアクセスをモックしないユースケースへの単体テスト」+「E2Eシナリオテスト」この組み合わせが個人的ベストプラクティスです。
レイヤー化していると特にあるあるなのですが、モックした単体テストが多いとAPIの正常系がInternalエラーになることがよくあります。
DBアクセスも自動生成しているのでモックを使わずテストケースを書くのが網羅的にテストをするコツです。
また、結合テストを配置する場所も大切です。
実装→不具合検知→修正→確認 のサイクルが早いほうが開発体験は良く、テストコードは同じコードベース上に配置しローカルで実行できるようにしておきましょう。
CIでの実行時やQA中に気づく状態だと、不具合検知までのリードタイムと反映作業のオーバヘッドがチリツモで増えていくでしょう。
あまり本題ではないので、これ以上は需要あれば別冊します。。
最後に
以上私の経験則と偏見から来る知見を解説していきました。
考え方としてはGoでもその他の言語でも大きくは変わらないのですが、参考になるといいです😋
(でもやっぱりGoはインフラ構築が動的型付け言語と比べて簡単でバイナリポンできますしサーバレスとの相性が良いのでおすすめです!)
今後もいろんな角度で知見を書いていけたらと思います!
追記 技術顧問・エンジニアやチームの育成相談受けてます🙏
参考文献
この記事が気に入ったらサポートをしてみませんか?