「とりあえず動く Rails API」から「本番で運用できる Rails API」へ。
Rails で API を作るのは簡単だ。
rails new myapp --api で始められるし、基本的な CRUD はあっという間に動く。
問題は「本番運用した後」にやってくる。
パフォーマンス劣化、認証の設計ミス、フロントとの齟齬、バージョン管理の地獄……
この記事では、Rails API を実際に運用してきた中で身につけた設計・認証・クエリ最適化・バージョニングの実践知をまとめる。
目次
- APIモードの構成設計
- バージョニング
- レスポンス形式の統一
- 認証:JWT + Devise の実装と落とし穴
- N+1 問題:Serializer との組み合わせで特に注意
- エラーハンドリングを集約する
- よくある設計ミスと対策
1. APIモードの構成設計 {#structure}
api-only モードで始める
rails new myapp --api --database=mysql
API モードは不要なミドルウェア(Cookie、セッション、ビューなど)を除外する。
レスポンスタイムが短縮され、メモリ使用量も削減される。
ディレクトリ構成
バージョニングを前提にした構成を最初から採用する。
app/
├── controllers/
│ ├── api/
│ │ ├── v1/
│ │ │ ├── base_controller.rb # 認証・共通処理
│ │ │ ├── users_controller.rb
│ │ │ └── posts_controller.rb
│ │ └── v2/
│ │ └── ...
│ └── application_controller.rb
├── serializers/
│ └── api/
│ └── v1/
│ ├── user_serializer.rb
│ └── post_serializer.rb
└── services/ # ビジネスロジックを分離
└── post_create_service.rb
コントローラは薄く保つのが鉄則。
ビジネスロジックは services/ に切り出し、コントローラはリクエスト受付・レスポンス返却に専念させる。
2. バージョニング {#versioning}
URL バージョニングを採用する
ヘッダー・クエリパラメータよりも URL パス(/api/v1/) が最もシンプルで運用しやすい。
フロントエンドチームとの疎通確認が簡単で、ログでの追跡も容易。
# config/routes.rb
Rails.application.routes.draw do
namespace :api do
namespace :v1 do
resources :users, only: [:index, :show, :create, :update, :destroy]
resources :posts
end
namespace :v2 do
resources :users
end
end
end
V2 が必要になるタイミング
破壊的変更(レスポンスの構造変更・フィールド削除)が必要になったときだけ新バージョンを切る。
フィールド追加程度であれば、既存バージョンに後方互換を保って追加できる場合が多い。
# app/controllers/api/v1/base_controller.rb
module Api
module V1
class BaseController < ApplicationController
before_action :authenticate_user!
private
def authenticate_user!
# JWTの検証処理(後述)
end
def render_success(data, status: :ok)
render json: { data: data }, status: status
end
def render_error(message, status: :unprocessable_entity, code: nil)
render json: { error: { message: message, code: code } }, status: status
end
end
end
end
3. レスポンス形式の統一 {#response}
API の品質はレスポンス形式の統一で大きく変わる。
成功・エラーの形式が揃っていないと、フロントエンド側の処理が複雑になる。
推奨レスポンス構造
// 成功(単一リソース)
{
"data": {
"id": 1,
"type": "user",
"attributes": {
"name": "斉藤太郎",
"email": "saitou@example.com"
}
}
}
// 成功(コレクション)
{
"data": [...],
"meta": {
"total": 100,
"page": 1,
"per_page": 20
}
}
// エラー
{
"error": {
"code": "VALIDATION_ERROR",
"message": "バリデーションエラーが発生しました",
"details": [
{ "field": "email", "message": "は不正な値です" }
]
}
}
Serializer に BlueprintHash か jsonapi-serializer を使う
active_model_serializers は開発が停滞気味。
jsonapi-serializer(旧 fast_jsonapi) が速度・メンテナンス面で現在は安定している。
# Gemfile
gem 'jsonapi-serializer'
# app/serializers/api/v1/user_serializer.rb
class Api::V1::UserSerializer
include JSONAPI::Serializer
attributes :name, :email, :created_at
has_many :posts, serializer: Api::V1::PostSerializer
end
# コントローラでの使い方
def show
user = User.includes(:posts).find(params[:id])
render json: Api::V1::UserSerializer.new(user).serializable_hash
end
4. 認証:JWT + Devise の実装と落とし穴 {#auth}
devise-jwt を使う構成
devise + devise-jwt の組み合わせが現状のデファクト。
# Gemfile
gem 'devise'
gem 'devise-jwt'
# config/initializers/devise.rb の重要設定
Devise.setup do |config|
config.navigational_formats = [] # API only では必須。これを忘れると HTML レスポンスが返ってくる
config.jwt do |jwt|
jwt.secret = ENV['DEVISE_JWT_SECRET_KEY']
jwt.dispatch_requests = [['POST', %r{^/api/v1/users/sign_in$}]]
jwt.revocation_requests = [['DELETE', %r{^/api/v1/users/sign_out$}]]
jwt.expiration_time = 1.day.to_i
end
end
# User モデル(トークン失効にJTI マッチャーを使う)
class User < ApplicationRecord
include Devise::JWT::RevocationStrategies::JTIMatcher
devise :database_authenticatable,
:registerable,
:jwt_authenticatable,
jwt_revocation_strategy: self
end
よくある設定ミス3選
① navigational_formats を空にし忘れる
API モードで Devise を使うと、認証失敗時に HTML を返そうとしてエラーになる。
config.navigational_formats = [] を必ず設定する。
② CORS で Authorization ヘッダーを expose し忘れる
JWT トークンはレスポンスヘッダーで返すが、デフォルトでは CORS がブロックする。
# config/initializers/cors.rb
Rails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
origins '*' # 本番では適切なオリジンを指定
resource '*',
headers: :any,
methods: [:get, :post, :put, :patch, :delete, :options, :head],
expose: ['Authorization'] # ← これを忘れるとフロントがトークンを受け取れない
end
end
③ トークン失効を実装しない
ログアウト時にトークンを無効化しないと、JWTが有効期限まで悪用される。
JTIMatcher を使えば DB でトークンの失効管理ができる(上記モデル参照)。
5. N+1 問題:Serializer との組み合わせで特に注意 {#n1}
Rails API の N+1 問題で最も気づきにくいのが Serializer 内での関連付けアクセスだ。
問題のあるコード
# controller
def index
@posts = Post.all # ← N+1 の温床
render json: Api::V1::PostSerializer.new(@posts).serializable_hash
end
# serializer
class Api::V1::PostSerializer
include JSONAPI::Serializer
attributes :title, :body
belongs_to :user # ← ここで N 回クエリが走る
has_many :comments # ← さらに N 回
end
Post.all で 100件取得した場合、user と comments を取るために 200回追加クエリが走る。
正しい書き方
# controller - includes で eager loading
def index
@posts = Post.includes(:user, :comments)
render json: Api::V1::PostSerializer.new(@posts, include: [:user, :comments]).serializable_hash
end
Bullet gem で検出する
開発環境で N+1 を自動検出するために bullet gem を導入する。
# Gemfile
gem 'bullet', group: :development
# config/environments/development.rb
config.after_initialize do
Bullet.enable = true
Bullet.rails_logger = true
Bullet.add_footer = true
end
bullet がログに AVOID eager loading detected または USE eager loading detected を出してくれる。
これを見てコントローラの includes を調整する習慣をつける。
includes / preload / eager_load の使い分け
| メソッド | 挙動 | 向いているケース |
|---|---|---|
includes |
Rails が自動で最適な方法を選択 | 基本はこれ |
preload |
別クエリで取得(メモリ効率が良い) | 大量データの関連付け |
eager_load |
LEFT OUTER JOIN で一括取得 | WHERE で関連テーブルを絞る場合 |
# WHERE で関連テーブルを条件にするなら eager_load
Post.eager_load(:user).where(users: { active: true })
# ただの関連取得なら preload の方がメモリ効率が良い
Post.preload(:comments)
6. エラーハンドリングを集約する {#error}
例外ごとに各コントローラで rescue するのは DRY に反する。
rescue_from を BaseController に集約する。
# app/controllers/api/v1/base_controller.rb
module Api
module V1
class BaseController < ApplicationController
rescue_from ActiveRecord::RecordNotFound, with: :not_found
rescue_from ActiveRecord::RecordInvalid, with: :unprocessable_entity
rescue_from ActionController::ParameterMissing, with: :bad_request
private
def not_found(e)
render json: { error: { code: 'NOT_FOUND', message: e.message } }, status: :not_found
end
def unprocessable_entity(e)
render json: {
error: {
code: 'VALIDATION_ERROR',
message: 'バリデーションエラー',
details: e.record.errors.map { |err| { field: err.attribute, message: err.message } }
}
}, status: :unprocessable_entity
end
def bad_request(e)
render json: { error: { code: 'BAD_REQUEST', message: e.message } }, status: :bad_request
end
end
end
end
7. よくある設計ミスと対策 {#pitfalls}
① コントローラに find を直書きする
# NG
def show
@user = User.find(params[:id])
# RecordNotFound が各コントローラで処理されていない
end
# OK:rescue_from で集約し、コントローラは単純に保つ
def show
@user = User.find(params[:id]) # 例外は BaseController が拾う
render json: Api::V1::UserSerializer.new(@user).serializable_hash
end
② Strong Parameters を書かない
# NG
User.create(params[:user]) # マスアサインメント脆弱性
# OK
def user_params
params.require(:user).permit(:name, :email, :password)
end
③ before_action でのパラメータ取得を乱用する
before_action :set_user でリソースをセットするパターンは便利だが、
アクションごとに必要な includes が異なる場合に N+1 が発生しやすい。
アクション内で明示的に取得する方が安全な場面も多い。
④ ページネーションを後付けする
ページネーションなしで設計すると、後から追加したときに API インターフェースが変わる。
最初から kaminari または pagy を入れておく。
# Gemfile
gem 'pagy'
# controller
include Pagy::Backend
def index
@pagy, @posts = pagy(Post.all, items: 20)
render json: {
data: Api::V1::PostSerializer.new(@posts).serializable_hash[:data],
meta: pagy_metadata(@pagy)
}
end
まとめ
| 項目 | ポイント |
|---|---|
| 構成 |
--api モード、services/ でロジック分離 |
| バージョニング | URL パスで /api/v1/、破壊的変更のときだけ v2 へ |
| レスポンス |
data / error キーで形式を統一 |
| 認証 |
devise-jwt + navigational_formats = [] + CORS の expose
|
| N+1 | Serializer の関連付けを includes で必ず対処、bullet で検出 |
| エラー |
rescue_from を BaseController に集約 |
| ページネーション | 最初から pagy を入れる |
「動く API」は簡単に作れる。「運用できる API」にするためにはこれらの積み重ねが必要だ。