0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

# Rails API 開発ベストプラクティス【設計・認証・N+1・バージョニング】

0
Posted at

「とりあえず動く Rails API」から「本番で運用できる Rails API」へ。

Rails で API を作るのは簡単だ。
rails new myapp --api で始められるし、基本的な CRUD はあっという間に動く。
問題は「本番運用した後」にやってくる。

パフォーマンス劣化、認証の設計ミス、フロントとの齟齬、バージョン管理の地獄……
この記事では、Rails API を実際に運用してきた中で身につけた設計・認証・クエリ最適化・バージョニングの実践知をまとめる。


目次

  1. APIモードの構成設計
  2. バージョニング
  3. レスポンス形式の統一
  4. 認証:JWT + Devise の実装と落とし穴
  5. N+1 問題:Serializer との組み合わせで特に注意
  6. エラーハンドリングを集約する
  7. よくある設計ミスと対策

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件取得した場合、usercomments を取るために 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_fromBaseController に集約する。

# 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_fromBaseController に集約
ページネーション 最初から pagy を入れる

「動く API」は簡単に作れる。「運用できる API」にするためにはこれらの積み重ねが必要だ。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?