API基盤開発記
はじめに
こんにちは。プロダクトエンジニアリング本部ソフトウェアデベロップメントグループでグループマネージャを務めている、@nakaearthです。最近、暑すぎて日課の早朝散歩ができず、若干運動不足気味ですが、皆様はいかがお過ごしでしょうか?
私は仕事中ずっと立っているので、時々筋トレなどをして体を動かしています。
さて、前置きはこのくらいにして本題に入ります。
前回は@fqqkがAPI仕様書を起点とした開発について紹介しましたが、今回はそのAPI基盤の開発背景についてご紹介します。
目次
1. API基盤開発の背景・課題
2024年10月頃にプロジェクトとしてはスタートしたのですが、そもそも何故、新規でAPI基盤を作ることにしたのか、その背景からご説明します。
弊社の主軸プロダクトである「ecforce」では既にAPIを公開していますが、以下のようにいくつか課題がありました。
-
パフォーマンス
APIの利用が増えると、APIだけでなくショップのフロント画面や管理画面のパフォーマンスにも影響を及ぼす懸念がありました。そのため影響範囲を限定できるよう既存のecforceと新規のAPI基盤はインフラ環境を切り離しておきたいという考えがありました。そしてパフォーマンスに問題が出た場合に柔軟に対応出来る仕組みにしておきたいと考えました。 -
使用しているRuby/Ruby on Railsのバージョンが古い
使用している言語やフレームワークのバージョンアップへの追随が遅れている課題がありました。 -
技術的な改善
ecforceはサービス開始から数年が経過し、多くの機能追加が行われてきました。その過程で技術的に改善すべき点が蓄積されてきたため、それらを解消する取り組みの一貫として新規に作るAPI基盤ではその改善課題を引き継ぐことなく開発する必要がありました。
以上のような課題を解決すべく、社内で議論を重ねました。その中で私は3つの対応方針を示し、それぞれメリット・デメリットを明確にして方針の検討を行いました。
1.1 対応方針の決定の流れ
どのような対応方針があったか、そして結果どの案になったか説明します。
案としては以下の3つがありました。
- 案1) API基盤を新規で作る
- 案2) 既存のecforceを拡張して当面必要なAPIを作り、並行して新規API基盤を作る。そしてタイミングをみてAPIを移植する
- 案3) 既存のecforceに新規APIのコードを追加していく
では、それぞれの案についてメリット・デメリットを整理していきます。
案1のメリット・デメリット
- メリット
- 技術的負債を引き継がない
- 言語、フレームワーク、各gemの最新化ができる
- パフォーマンスの課題に対する対策が打ちやすい
- デメリット
- 開発工数が掛かる
案2のメリット・デメリット
- メリット
- 開発立ち上げの工数が少なくて済む
- 将来的に移植することで、最終的には負債を引き継がずに済む
- デメリット
- 開発する上で既存に依存するため、フレームワーク・言語、各種gemを最新化できない
- 移行のコストと、その際のリスクが発生する
案3のメリット・デメリット
- メリット
- 開発立ち上げの工数は不要
- デメリット
- 技術的負債が残る
- パフォーマンスが十分に出ない可能性がある
- 開発する上で既存に依存するため、フレームワーク・言語、各種gemを最新化できない
以上の3つの案から、開発計画や将来的な展望を考慮に入れて社内で議論しました。まずはパフォーマンスと技術的負債の解消という点から、案3は選択肢から外れました。次に、最初から新規の基盤を作っていくか、どこかのタイミングで切り替えるかを考えました。案2の場合は移行コストが掛かるということから、それならば開発に時間が掛かることを許容し、最初から新規で作り込む「案1」で進めようということになりました。
2. プロジェクトを進めるにあたり意識したこと
本プロジェクトを進めるにあたり、私たちは4つのミッションを定めています。
- アプリケーションコードの一貫性を保つ
- テストを確実に書く
- パフォーマンス面で不安のないものにする
- 適度に技術的な挑戦をする
アプリケーションのコードの一貫性を保つ
これは今後のメンテナンス性を見据えてのことです。どこにどういう処理を書くか。どういうクラス設計で作るか。明確な設計指針がないまま実装者の手に全てを委ねてしまうと、さまざまな思想が入り乱れたカオスなシステムになってしまいます。今後長い間メンテナンスし続けることを考えると、そういうカオスなシステムは苦痛でしかありません。ですので、基本的な設計指針は作成しチーム内に共有するようにしました。これは一度話しただけでなく、何度かチームメンバー間で話をしたと思います。あるいは、コードレビューで地道に指摘したりもしました。そうした積み重ねを繰り返したことで、今ではチームに浸透していると思います。
テストを確実に書く
テストの有効性は多くの方が理解されていると思います。ただ、システムの立ち上げ時期はテストを後回しにしてしまうことが多いのではないでしょうか。そして、ある時期になってテストを追加しようと思っても、既存コードのテストを書くのが苦痛で進まなかったり、書いても十分ではなかったりします。そうなることを防ぎたくて、最初からテストはしっかり書くことを徹底しました。
テストがあることでデグレ防止にもなりますが、それだけでなくリファクタリングのしやすさも担保されました。長い間運用することを考えるとリファクタリングは不可欠です。ですので、リファクタリングがしやすいようにしておくことは大事なことでした。
パフォーマンス面で不安がないものにする
上の課題でも挙げましたが、現状のecforceはDBへの不要な問い合わせを始めとする性能面における技術的負債が多くあります。API基盤ではそういった問題が発生しないように、コードの書き方にも注意するようにしました。更にパフォーマンステストも行い、現状のパフォーマンスを把握するようにしました。予想されるデータ数、想定されるリクエスト数をそれぞれ算出し、データ量を大・中・小と三段階に分けてそれぞれの環境に対しツールを使ってパフォーマンス検証をしました。これを実施したことで、リリース初期の頃はどのようなスペックでスタートするのが良いか決めることが出来ました。
適度に技術的な挑戦をする
新しく作るのですから、今まで不便だ、良くないと思っていたものは変えていくつもりでした。ただ、一気に色々変えてしまうと問題が起きた時のリカバリが大変になります。そのため、変化を起こすにしても「適度な」変化が良いと考えました。この「適度」というのは人により差があると思いますが、不確定要素は少ない方がプロジェクトの成功確率が上がるので、良く考えて判断するようにしていました。
ただ、何かしら挑戦の要素があるとメンバーのモチベーションも上がります。ですから、今回もいくつかメンバーが技術的な挑戦が出来る機会を作るように心掛けました。
3. 技術選定
今回の技術選定の観点は以下の通りです。
- 言語やフレームワークは何を選択するか?
弊社の主力サービスであるecforceはRuby on Railsで作られています。そのため、Ruby on Railsで開発するか、それとも他の言語にするかの選択になりました。 - インフラはAWSで構築する方針で問題ないか?
私としてはECSを使って構築したかったので、その選択で進められるかをインフラ担当の方と相談しました。
今回は以下の技術を採用しました。
- 言語: Ruby
- フレームワーク: Ruby on Rails
- データベース: TiDB
- インフラ: AWS、ECS
なぜRubyやRuby on Railsを選択したのか?
まず言語については、以下が候補に挙がりました。
- Ruby
- Go
- Javaなどその他
今回言語を選ぶにあたり、解決したい課題がありました。それはパフォーマンスです。既存のecforceはRuby on Railsで実装されていますが、パフォーマンスの課題が浮き彫りになってきました。そのため、今回新規でAPI基盤を作るにあたりパフォーマンス問題は解決しておきたい課題でした。
ただ、実際今起きているパフォーマンスの問題は言語による問題というよりも、アプリケーションの作り方に問題があると考えました。N+1や余分なSQLの呼び出し、これらがどこでどのように発生しているのか理解し、どういう実装の仕方を採用するのが良いかをちゃんと考えて作れば、言語、フレームワークの差はそれ程大きくないだろうと思います。
ですから今回は、開発する際にデータベースへの問い合わせ/作成/更新/削除をどこの層で行い、それ以降の層では行わないことを開発の規約として定めました。それを決めておかないとRuby on Railsでは、どの階層(View/Controller/Helperなど)からもモデルを呼び出せてしまい、それにより想定以上にSQLの実行が多く発生し、結果として知らないうちにN+1や無駄なSQLの実行を組み込んでしまいます。それを防ぐために、クラス構成とそれぞれどこで何を書くかを明確化し、それをチームメンバー間で認識合わせして進めました。
更に、Ruby on Railsは正しく使えば非常に優秀なフレームワークです。テストが書きやすい、エコシステムがしっかりしている、継続的に進化しているなど、Webアプリケーション開発をするのに良い面が多くあるフレームワークです。
一方でGoやJavaなどを採用することもメリットはあると思いました。ただ、メンバーのスキルセットや言語・フレームワークをガラッと変えることのリスクを考えると、今回は選択しない方が良いと考えました。
以上の点から、言語はRubyで、フレームワークはRuby on Railsを採用しました。
なぜインフラはAWS(ECS)を選択したのか?
インフラは、AWSで構築しECSを使うことにしました。弊社のシステムはAWSで構築されているものばかりなので、AWS以外を選択するメリットは全くありませんでした。そしてECSについては、主にパフォーマンスに問題があった時にタスク数の調整がしやすいということと、デプロイのしやすさからAWS ECSを選択しました。
デプロイは既存の仕組みから変更してGitHub Actionsで行うようにしました。既存のデプロイの仕組みは複雑で難しさがありましたので、今回そこも解決したいと考えていました。 GitHub Actionsにしたことで、デプロイの流れも分かりやすく、仕組みもシンプルになりました。
また、仮に問題があってロールバックする必要が出た場合でも、ECSのタスクを前のタスクに戻すだけでロールバックが可能になります。その容易さもECSに変えた動機の一つです。
詳細はここでは省略しますが、結果として懸念していた課題は解決出来ました。
4. 技術的な課題
それではここで、今回API基盤を作るにあたり解決しないといけない技術課題の一つについて触れたいと思います。
このAPI基盤は複数のショップから利用されることを想定しています。各ショップはそれぞれ独立したデータベースを持っているため、リクエスト毎に接続先のデータベースを動的に切り替える必要がありました。この複数データベース対応をどうやったかを、簡単なコード例を載せて紹介したいと思います。
今回はフレームワークとしてRuby on Railsを使う決定をしていましたので、この課題に対してもRuby On Railsの仕組みを使った実現方法を考えました。gemを使って対応する方法もありますが、幸いRuby on Railsには複数データベース対応がバージョン7から組み込まれました。将来Ruby on Railsのアップデートをすることを考えると、Ruby on Railsに既にある仕組みを使う方がgemで実現するよりもリスクが小さいと判断しました。
では、実際どのように作ればやりたいことが実現できるか説明していきます。
用途毎に複数のデータベースに分かれている場合
Ruby on Railsの複数データベース対応で、マニュアルに最初に書かれている例は用途毎にデータベースを分けて、それに対して自動で接続を切り替える例です。
どういう事か簡単な例を使って説明します。以下のように用途毎に分かれたデータベースがあると仮定します。
- ショップ管理社用データベース:
admin_db
と命名 - ショップ一般ユーザ用データベース:
shop_db
と命名 - 共通マスター用データベース:
common_db
と命名
上記設定に合わせたdatabase.yml
は次のようになります。
production:
admin_db:
database: admin_database
username: <%= ENV['DATABASE_USERNAME'] %>
password: <%= ENV['DATABASE_PASSWORD'] %>
adapter: mysql2
shop_db:
database: shop_database
username: <%= ENV['DATABASE_USERNAME'] %>
password: <%= ENV['DATABASE_PASSWORD'] %>
adapter: mysql2
common_db:
database: common_database
username: <%= ENV['DATABASE_USERNAME'] %>
password: <%= ENV['DATABASE_PASSWORD'] %>
adapter: mysql2
次に、それぞれのデータベースに存在するテーブルに相当するmodelを作成していきます。各データベースへの接続情報を定義した抽象クラスを用意し、各モデルはそれぞれ所属するデータベースの設定を定義したクラスを継承していきます。
class ApplicationRecord < ActiveRecord::Base
primary_abstract_class
end
class AdminDbApplicationRecord < ApplicationRecord
self.abstract_class = true
connects_to database: { writing: :admin_db, reading: :admin_db }
end
class ShopDbApplicationRecord < ApplicationRecord
self.abstract_class = true
connects_to database: { writing: :shop_db, reading: :shop_db }
end
class CommonDbApplicationRecord < ApplicationRecord
self.abstract_class = true
connects_to database: { writing: :common_db, reading: :common_db }
end
class AdminUser < AdminDbApplicationRecord
# admin_dbに自動的に接続する
end
class ShopUser < ShopDbApplicationRecord
# shop_dbに自動的に接続する
end
class ProductCategory < CommonDbApplicationRecord
# common_dbに自動的に接続する
end
connects_to
メソッドで書き込み時(writing)と読み込み時(reading)にどのデータベースに接続するかを定義します。親クラスで接続先のデータベースを定義をしておき、各テーブルに相当するモデルクラスでそれぞれ親クラスを継承することで、データベースの切り替えを意識することなく行えます。
ただ今回やりたい事は、これとは違います。各ショップ毎にデータベースを分けていて、リクエスト毎に該当するショップのデータベースに接続先を切り替えたいので、この方法は使えません。別の方法になります。そこでconnects_to
メソッドの別の使い方、水平シャーディングの仕組みを採用することにしました。
ショップ毎に複数のデータベースに分かれている場合
まず、以下のように各ショップ毎の接続情報をdatabase.yml
に定義します。
production:
shop_a_db:
database: shop_a_database
username: <%= ENV['DATABASE_USERNAME'] %>
password: <%= ENV['DATABASE_PASSWORD'] %>
adapter: mysql2
shop_b_db:
database: shop_b_database
username: <%= ENV['DATABASE_USERNAME'] %>
password: <%= ENV['DATABASE_PASSWORD'] %>
adapter: mysql2
shop_c_db:
database: shop_c_database
username: <%= ENV['DATABASE_USERNAME'] %>
password: <%= ENV['DATABASE_PASSWORD'] %>
adapter: mysql2
次に、これら接続情報を元にconnects_to
メソッドの定義をモデルに追記します。
class ApplicationRecord < ActiveRecord::Base
primary_abstract_class
connects_to shards: {
default: { writing: :shop_a_db, reading: :shop_a_db },
shop_a: { writing: :shop_a_db, reading: :shop_a_db },
shop_b: { writing: :shop_b_db, reading: :shop_b_db },
shop_c: { writing: :shop_c_db, reading: :shop_c_db }
}
end
そして、Controller側でリクエストが来たときにどのデータベースに接続すれば良いかをリクエストヘッダーやパラメータから情報を取得し、それをもとにデータベースに接続をします。
class ApplicationController < ActionController::API
around_action :determine_shop_tidb_cluster
~~~~~~
~~~~~~~~~
private
def determine_shop_tidb_cluster
shop_id = request.env['shop_id'] || 'shop_a'
raise ::DatabaseNotFoundError if shop_id.empty?
ActiveRecord::Base.connected_to(shard: shop_id.to_sym) do
ActiveRecord::Base.prohibit_shard_swapping do
yield
end
end
end
end
上に挙げたコードはあくまでも例ですが、実際にやった事の大まかな流れは紹介した通りになります。
実際は認証処理などもあり、それらの処理をControllerに達する前に実行したかったので、Rackミドルウェアを作成し、そこで接続先の判定に必要な情報を取得するなどしました。
以上のようにconnected_to
メソッド、connects_to
メソッドを使うことで、リクエスト毎のデータベース接続切り替えを自動で行うようにしました。
読者の皆様の参考になれば幸いです。
※ Rackについて
5. まとめ
今回はAPI基盤の開発で、どのような事を考えて進めていったかをご紹介させていただきました。新しいサービスを立ち上げる際は色々と考慮することがあると思います。何を解決したいのか、どのような手段が取りうるのか、取った手段によりどのようなトレードオフが発生するのか。
それぞれ複数の答えがあると思います。その中でどれを選ぶかは、メンバーや会社のフェーズによって変わるでしょう。ただ、そういう中でも共通して大事だと思うのは「将来の変更に備え、品質を担保できる仕組みを作っておくこと」だと私は考えています。例えば、自動テストを最初から書いておくことだったり、シンプルなアーキテクチャにしておくなどです。(今回も、いきなりマイクロサービスで実装せず、まずはモノリシックな構成で開発しました)
初期段階における品質への配慮は、言わずもがな、できる限りのことをするのが定石と考え、今回のAPI基盤の開発に取り組んでいます。
後からテストを追加しようとしても、既にコードが複雑になってしまい、影響範囲も見えず、結果リファクタリングしたくても出来ないということが発生してしまいます。
ですから、最初から真剣に品質を維持することに向き合い取り組んでいれば、リファクタリングも頻繁に実施することが出来て比較的、品質を維持しやすくなります。将来の自分がコードを見て「全てを捨て去りたい」という気持ちになるのを防げます。
初めの一歩が今後の未来を決める一歩になると言っても過言ではありません。
SUPER STUDIOの採用について
SUPER STUDIOでは、エンジニアを採用しています。
少しでも興味がありましたら、以下をご覧ください。
下記の記事は、SUPER STUDIOのキックオフイベントで表彰されたエンジニアのインタビュー記事です。
SUPER STUDIOのエンジニア組織をより理解できる内容となっておりますので、ご一読ください。
Discussion