RailsでAPIを作るときのエラー処理について

    RailsでAPIを雑に書いていたんだけど, コントローラとかをどう書くとエラー処理しやすくなっていいかなーと考えていて, 個人的に考えがまとまったのでブログ書いた.

    ※9/1に追記書いた.

    良いエラー処理について

    個人的にAPIを書く上で(API書くに限らない気はするけど)どういうふうにエラー処理を行うと良いかなーと考えてみると

    • コントローラ内では基本的に, ある関数の処理が失敗して, 次の処理が行えない場合はすべて例外を投げる
    • 例外は各々のコントローラ内で例外のキャッチは行わず, すべてApplicationControllerなど, 親コントローラ内の1メソッドで完結させる

    かなーと思う. APIのエラー処理は, Envelopeにステータスコードとエラーメッセージを書いて, APIのフォーマットを統一するほうがクライアントが作りやすそうだし, またこのように処理することで, エラー処理での条件分岐の必要がなくなり, コントローラの可読性の向上にもつながる.

    Grape vs Rails

    APIつくるんだったら, Grapeサイコーという意見が多い.

    確かにGrapeのDSLは直感的に書けるし, バリデーションなど便利メソッドが多いけど, 個人的には素のRailsでAPIを書くほうがセンスが良いと感じる. というのもRackベースなので, ルーティングなど独自のものが多く, せっかくRailsが提供してるRakeのタスクや, ジェネレータがそのまま使えないからである.

    SinatraとかでAPI納品するんだったら, Grapeとかいれるのはすごい良さそう.

    ただ, そのままのRailsではJSONやXMLをいい感じの構造で返す仕組みが貧弱なので, RABLを導入するのが便利. これはJSONやXMLをいい感じに生成するためのテンプレートエンジンで, DSLを用いて直感的にAPI出力を定義できる.

    また, RailsのLayoutsにも対応しており, views/layouts/application.rablとかを定義しておくことで, Envelopeみたいなのを簡単に実現できる.

    コントローラ内でのエラー処理

    上記に上げたとおり, コントローラ内でモデルのCRUDなどの処理が失敗した場合は例外を投げてApplicationControllerに処理を渡す.

    例えばshowメソッドでは以下のように処理する.

    def show
      @piyo = Piyo.find_by!(:id, params[:id])
    end

    ApplicationControllerでのエラー処理

    以下のようなConcernを定義し, ApplicationControllerから読み込むことでエラー処理を行う.

    module Api::ErrorHandlers
      extend ActiveSupport::Concern
    
      attr_accessor :status, :message
    
      included do
        before_filter :setup
        rescue_from StandardError, :with => :rescue_exception
      end
    
      private
    
      def rescue_exception(e)
        @message = e.message
        if rescuable?(e)
          re = e.is_a?(Api::Exceptions::RescuableException) ? e : RESCUABLE_EXCEPTIONS[e.to_s.to_sym]
          @status = re.status
        else
          @status = 500
          @message = e.message
        end
    
        render 'api/errors/base'
      end
    
      def rescuable?(e)
        return e.is_a?(Api::Exceptions::RescuableException) || RESCUABLE_EXCEPTIONS.has_key?(e.to_s.to_sym)
      end
    
      def setup
        @status = 200
        @message = "OK"
      end
    end

    ポイントはすべての例外処理をrescue_exceptionで受け取るところである. このrescue_exceptionは投げられた例外によって, 適切なステータスコードとエラーメッセージをビューに渡すメソッドで, それらはEnvelopeとして出力される. 例えばRablのLayoutsで以下のように定義することでエラー出力する.

    {
      "status": <%= @status.to_json.html_safe %>,
      "message": <%= @message.to_json.html_safe %>,
      "data": <%= yield %>
    }

    ここで, 例外に対応するステータスコードを以下のように引く.

    1. 独自の例外の場合は, その例外クラスにステータスを保持させる
    2. 組み込みの例外(例えばActiveRecordのNotFoundException)の場合は, 例外に対応するステータスコードの対応表から引く
    3. それ以外の例外の場合は500を返す

    1の場合は, Api::Exceptions::RescuableExceptionを作成して, それを継承した独自の例外クラスを投げて対応する.

    module Api::Exceptions
      class RescuableException < StandardError
        attr_accessor :status
    
        def initialize(status = 500, message = "Error")
          super(message)
          @status = status
        end
      end
    
      class UnAuthenticationException < RescuableException
        def initialize(message = "Unauthorized")
          super(401, message)
        end
      end
    end

    2の場合は, RESCUEABLE_EXCEPTIONSみたいなハッシュを作って対応する.

    RESCUABLE_EXCEPTIONS = {
      ActiveRecord::RecordNotFound.to_s.to_sym => Api::Exceptions::RescuableException.new(404, "Record Not Found")
    }

    3の場合は, 上に2つの条件を満たさない場合に500を返すようにrescue_exceptionメソッドを書くことで対応する.

    まとめ

    ApplicationControllerでApi::ErrorHandlersを定義し, rescue_exceptionで例外処理することで, 開発速度が上がって良さそうだという個人的なエラー処理のまとめを書いてみた.

    追記

    @r7kamuraさんに, 以下のリプライを頂いて

    確かに, Rack middlewareのこととか全く考慮できてなくてダメダメって感じだった.

    そして起きたらRailsでAPIをつくるときのエラー処理っていうすごい知見がまとめられていた.


    Yuichiro MUKAI
    Yuichiro MUKAIGame & Web Programmer

    シブヤで働くゲームプログラマー. C#(For Unity)をメインに, 趣味でPHPなどを書きます.

    Twitter / Facebook