【Rails】WEB APIを長く運用するための仕組み化

f:id:vasilyjp:20170328122358j:plain

こんにちは、バックエンドエンジニアのじょーです。大規模なサービスのAPIを開発する際に、ルールを決めずに開発していると無秩序なコードが散見される運用がしづらいAPIになってしまいます。また、ルールを決めたとしても共有が上手くいかないなどの理由で守られなくなってしまうこともあると思います。 本記事では、APIを運用しやすくするために、ただルールを決定しただけではなく、ルールを守るためにそれぞれ仕組み化をしたことを紹介します。

  1. APIのレスポンスを統一する
    • デコレーターを使ってレスポンスの定義を綺麗に書く
  2. パラメーターを統一する
    • Validatorによりパラメーターの明記を強制する
  3. コーディング規約を守る
    • LinterとSideCIを導入して修正とレビューの自動化
    • Linterのルールを適度に調節する

1. APIのレスポンスを統一する

ここで言うAPIのレスポンスを統一するというのは、返すAPIフォーマットのブレをなくすことです。フォーマットが一定のルールに従うことで使う側はもちろんのこと、作る側にもメリットがあります。

レスポンスを統一するのは当たり前のように感じますが、開発者が複数人いる際は細かい部分にブレが生じてきます。 そこで、APIレスポンスを統一するために実践しているテクニックを紹介します。

デコレーターを使ってレスポンスの定義を綺麗に書く

APIを作る際に、DBのレコードをそのまま加工せずに返す例は少ないです。レコード自体に情報を足して返したり、別の情報を付加して返すことがほとんどだと思います。 その際、レスポンスを定義する処理をモデルに書いているとあっという間にモデルが肥大化してしまいます。場合によっては、同じような処理が乱立してレスポンスの統一が難しくなる可能性があります。

そのような場合、レスポンスを定義するコードはモデルとは別ファイルに切り出した方が処理が一箇所にまとまって見やすくなり、レスポンスを統一することも簡単になります。

弊社では、draperというgemのDraper::Decoratorと、fieldgroupという、レスポンスのまとまりを定義する独自の手法を使ってモデルとは別ファイルに処理を切り出しています。

まず、Draper::Decoratorの処理を簡単に説明します。 Draper::Decoratorを使うと、モデルをwrapして、レスポンスに付加したい情報を定義することができます。 下記は、紐づくショップの情報をブランドのレスポンスに追加したい場合の例です。モデル名+Decoratorという命名規則のクラスを宣言し、Draper::Decoratorを継承して使います。

class BrandDecorator < Draper::Decorator
  # ブランドモデルのレコードをwrapする記述
  delegate_all

  # 付加したい情報の記述
  # 'context'を外部から指定して受け取ることが可能
  def shop
    Rails.logger.info 'shop情報ですよ' if context[:logger] == true
    Shop.find(object.shop_id).attributes
  end
end

このようにクラスを定義すると、Brandモデルのインスタンスに.decorateメソッドを使ってアクセスしブランドの情報を引くことができるようになります。 また、.decorate(context: { logger: true })のように指定すると、メソッドでcontextの内容を受け取ることができます。

# もともとレコードに存在するキーはそのままレコードの内容を返す
> Brand.find(1).decorate.name
=>  'Lawrys Farm'

# Decoratorに宣言したキーはメソッドで定義した内容を返す
> Brand.find(1).decorate.shop
=>  { id: 1, name: '代官山店' }

# contextを指定
> Brand.find(1).decorate(context: { logging: true }).shop
=> 'shop情報ですよ'
=>  { id: 1, name: '代官山店' }

(Draper::Decoratorの機能の詳細はこちらを参照してください。)

このDraper::Decoratorの機能とプラスして、fieldgroupという、レスポンスのまとまりを定義して自由にレスポンスのサイズを変えられる仕組みを独自で作っています。レスポンスのまとまりを1ファイルに定義することで、そのファイルを見るだけでどのようなレスポンスが返るかわかりやすくなったり、レスポンスのキーを足したり減らしたりすることが容易になります。

下記に、ディレクトリ構成と、実際のfieldgroupの実装内容を載せます。

ディレクトリ構成
app/decorators
     - item_decorator.rb
     - user_decorator.rb
     - brand_decorator.rb
class BrandDecorator < Draper::Decorator
  include FieldgroupDefinedable
  delegate_all

  define_fieldgroup :small, [
    :id, :name, :kana, :initial
  ]

  define_fieldgroup :medium, [
    :shop
  ]

  define_fieldgroup :large, [
    :total_item_count
  ]

  def shop
    # 付加したいショップ情報を記述
    Shop.find(object.shop_id).attributes
  end
  
  def total_item_count
    # ブランドにひも付くアイテム数を取得する処理
  end
end

このように、大、中、小(large, medium, small)に分けてレスポンスを定義しています。 リストページなどの少ない情報を返す際にはsmall、詳細ページにはlargeを指定するなどしてシーンに合わせて使い分けることができます。

実際に中、小のグループを引いた例がこちらです。

# フィールドグループsmallを指定

> Brand.find(1).decorate(context: { fieldgroup: 'small' }).to_hash
=> 
{
  id: 1,
  name: 'Lawrys Farm',
  kana: 'ローリーズファーム',
  initial: 'L'
}

# フィールドグループmediumを指定

> Brand.find(1).decorate(context: { fieldgroup: 'medium' }).to_hash
=> 
{
  id: 1,
  name: 'Lawrys Farm',
  kana: 'ローリーズファーム',
  initial: 'L',
  shop: {
    id: 1,
    name: '代官山店'
  }
}

上記では.decorateにプラスして、.to_hashをつけています。.to_hashをつけることでdefine_fieldgroupで定義した内容のまとまりがHashで返るような仕組みを独自開発しています。また、contextにfieldgroupを指定することで指定したサイズのレスポンスが返る実装になっています。 .to_hashでレスポンスをまとめて返す処理と、define_fieldgroupの内部の詳しい実装は以下のようになっています。

module FieldgroupDefinedable
  extend ActiveSupport::Concern

  included do
    class << self
      def define_fieldgroup(name, keys)
        @fieldgroup_keys ||= {}
        @fieldgroup_keys[name] = keys
      end

      def fieldgroup_keys(name)
        @fieldgroup_keys[name]
      end
    end

    # contextで指定されたfieldgroupのサイズを取得する処理
    def hash_group_name
      context[:fieldgroup].present? ? context[:fieldgroup].to_sym : :large
    end

    # 各サイズで定義されたキーの内容を引いてきて、JOINする処理
    def to_hash
      case hash_group_name
      when :small
        to_hash_fieldgroup(:small)
      when :medium
        to_hash_fieldgroup(:small)
          .merge(to_hash_fieldgroup(:medium))
      when :large
        to_hash_fieldgroup(:small)
          .merge(to_hash_fieldgroup(:medium))
          .merge(to_hash_fieldgroup(:large))
      end
    end

    def fieldgroup_keys(name)
      self.class.fieldgroup_keys(name)
    end

    private

    # Draper::Decoratorの機能を利用してfieldgroupに定義された各キーを引き、Hashに直す処理
    def to_hash_fieldgroup(group_name)
      Hash[fieldgroup_keys(group_name).map { |k| [k, send(k)] }]
    end
  end
end

上記のモジュールをデコレーターのクラスにincludeすることで、.to_hashfieldgroupを使って、レスポンスをまとめて返すことが可能になります。

to_hash_fieldgroupメソッドが少しトリッキーな手法を使っていますが、Brand.find(1).decorateオブジェクトに対して、define_fieldgroupに定義されたkey名と値にアクセスするためのメソッド名が同一なことを利用して値を取得し、Hashにしています。

このように、1ファイルにまとめることでどのような項目のレスポンスが返るかが見やすくなり、レスポンスの統一が容易になります。また、レスポンスの変更もスムーズに行うことができるようになります。

2. パラメーターを統一する

APIのパラメーターを統一するというのは、同じ意味を持つパラメーターが違う命名で乱立するのを防ぐことや、受け取るパラメーターのルールをしっかり決めることです。 APIが受け取るパラメーターを統一することで、使う側にメリットがありますし、開発側もデバッグが楽になります。 そこで、パラメーターを統一するために実践しているテクニックを紹介します。

Validatorによりパラメーターの明記を強制する

パラメーターが統一されない一番の原因は、現在受け取るパラメーターが何なのかコードを深く読まないとわからないことだと考えています。 ドキュメントを作成することが強制されていたり、ルールがしっかり決まっていればまだいいかもしれませんが、ドキュメントやルールがある場合でも、更新することを怠ってしまうと網羅的にパラメーターを把握できなくなります。 それにより、現状どのようなパラメーターが使われているかを知ることが大変になり、パラメーターにブレが発生します。 例えば昇順、降順を指定するパラメーター名がordersortでブレてしまったり、それらが受け取る値がASC DESCを受け取れたはずが小文字のasc descしか受け取れないなどです。

そこで、全てのエントリーポイントに共通のValidatorを挟み、YAMLに記述のないパラメーターが渡された場合はエラーが出るようにしています。そうすることで、必ずYAMLに記述するという強制力が生まれます。 また、YAMLにパラメーターに関する細かいルールも一緒に記述できるようにしています。

下記が、実際のValidateの設定YAMLの例です。 YAMLはモデルと対になっています。

show:                      # アクション名
  id:                      # 受け取るparameter
    type: Int              # 型の指定
    required: true         # 必須       
index:  
  id:
    type: Int
    required: true   
  initial:
    type: String    
  limit:
    max: 100                # 上限値         
  page:
  order: 
    within: ['ASC', 'DESC'] # 受け取る値の限定

アクションごとに受け取るパラメーターを列挙し、各パラメーターに対しての条件を記述しています。 受け取れる条件は下記の表のようになっています。

条件 定義
required 必須項目 true
blank 不可 true
format 正規表現でのフォーマット指定 /[<\=>]\s*\$\d+/
is 受け取る値の限定 1
within 受け取る値の限定(複数) ['DESC', 'ASC']
min 最小値 200
max 最大値 2000
min_length 最小長 200
max_length 最大長 2000

こうすることで、細かいルールも含めて現状のパラメーターを知ることができます。

パラメータ管理の手法として、rails_paramのようなgemも存在しています。 しかし、rails_paramだとコントローラーに設定を書かなければならないのでメソッドが長くなってしまうということ、パラメーター設定の記述に対する強制力が弱いことなどから今回は自前の実装にしています。Railsにもともと備わっているpermitも同様にコントローラーに直接書かなければならないことや、書くことに強制力がないので今回は使っていません。

以下が実装例です。

# frozen_string_literal: true
require 'yaml'
require 'exceptions'

module ValidateParams
  class InvalidParameterError < InvalidParameter
    attr_reader :param, :options

    def initialize(message, param = nil, options = nil)
      @param = param
      @options = options
      super(message)
    end
  end

  module_function

  def validate_param(params)
    # YAMLを取得して設定を元にvalidate_inspectionをかける処理
    validate_inspection(params, name, type, options = {})
  end

  def validate_inspection(params, name, type, options = {})
    return unless params.include?(name) || options[:required]

    begin
      param = coerce(params[name], type, options)
      validate(param, options)
      return if options[:no_cast]
      params[name] = param
    rescue InvalidParameterError => e
      raise InvalidParameterError.new(e.message, param, options)
    end
  end

  # YAMLで設定した型チェック処理
  def coerce(param, type, options = {})
    return param if param.is_a?(type) || param.nil?

    if [Integer, Float, String].include?(type)
      coerce_primitive_type(param, type)
    elsif type == Array
      delimiter = options[:delimiter] || ','
      coerce_array(param, type, delimiter)
    elsif [Date, Time, DateTime].include?(type)
      coerce_datetime(param, type)
    elsif [TrueClass, FalseClass, :boolean].include?(type)
      coerce_boolean(param)
    else
      nil
    end
  rescue ArgumentError
    raise InvalidParameterError, "'#{param}' is not a valid #{type}"
  end

  # YAMLで設定した細かいValidate処理
  def validate(param, options)
    options.each do |key, value|
      case key
      when :required
        validate_required(param, value)
      when :blank
        validate_blank(param, value)
      when :format
        validate_format(param, value)
      when :is
        validate_is(param, value)
      when :within
        validate_within(param, value)
      when :min
        validate_min(param, value)
      when :max
        validate_max(param, value)
      when :min_length
        validate_min_length(param, value)
      when :max_length
        validate_max_length(param, value)
      end
    end
  end

  --------以下各型チェック処理---------
  def coerce_primitive_type(param, type)
    Kernel.__send__(type.to_s, param)
  end
  .
  .
  .

  --------以下各validate処理---------
  def validate_required(param, value)
   raise InvalidParameterError, 'Parameter is required' if value && param.nil?
  end
  .
  .
  .

end

こちらをapplication_controllerの最初に挟むことで、すべてのエントリーポイントにおいてValidatorが動作します。

class ApplicatonController < ActionController::Base
  before_action do
    ValidateParams.validate_param(params)
  end
end

このようにしてValidatorを挟むことによりパラメーターを見える化し、次に新しいパラメーターを作る際に同じような役割のパラメーターが以前になかったかYAMLを見るだけで確認することができます。また、各パラメーターが持つルールも一目でわかるようになります。強制力が働いていることによって開発者に依存せずにパラメーターの可視化をすることができます。

3. コーディング規約を守る

コーディング規約を定めて特定のプログラミング作法に従うことは、コードの可読性を高め、間違いを減らす効果があります。 しかし、コーディング規約の自動化にも述べられているように、規約をただ定めたとしても意識のみで守り続けることは難しいことです。 そこで、チームでコーディング規約を守るためにしたことを紹介します。

LinterとSideCIを導入して修正とレビューの自動化

LinterとSideCIを使って、コーディング規約に従って自動的にコードを修正したり、修正するべき内容をGithubのPullRequest上でチームにシェアできるようにしています。 まず、LinterとSideCIそれぞれについて少し説明します。

Linterとは、静的コードを解析してコードを正しい文法や推奨された書き方を指摘してくれるツールです。 弊社では、下記のLinterを導入しています。

linter名 デフォルトconfig 特徴
rubocop rubocop default config Ruby style guideに基いて作られたLinter
reek reek default config 読みづらさや保守しづらいコードを指摘してくれるLinter
brakeman brakeman default config セキュリティチェック系のLinter
rails best practice rails best practice default config Railsのベストプラクティスを基にしたLinter

各Linterの導入は、詳しく書かれている記事が存在しているのでそちらを参照することをお勧めします。

下記のように、Linterにかけたいスクリプトを指定して、Linterのコマンドを叩くと自動的に修正がかかります。また、機械的に修正できない場合は修正すべき箇所を文章で指摘してくれます。 こちらはrubocopの修正をかけた際の例です。

f:id:vasilyjp:20170327215456p:plain

5行目の指摘は、早期リターンを勧めてくれていますが、機械的に修正すると危険な項目なので指摘のみです。 6行目の指摘は、インデントの指摘を自動で修正してくれています。

このように、自動的にコードを見て問題箇所を指摘したり、修正してくれます。

次にSideCIがどういったサービスかについて少し説明します。

SideCIは、GitHubとLinterを利用したコードレビューを行うサービスです。 GitHubのPullRequest上でSideCIの実行結果を受け取る事ができます。

下記は実際にSideCIから指摘が入った例で、PullRequestに自動的に修正コメントを残してくれます。(最近では、指摘の一部に日本語が導入されました!) その際に、どの種類のLinterから指摘が入ったかと指摘内容を一緒に表示してくれます。

f:id:vasilyjp:20170327221150p:plain

Linterを導入するだけでは開発者がLinterをかけ忘れたり怠ったりした場合、レビュワーが気づいて指摘する手間が発生していましたが、SideCIを使うことでPullRequest上に指摘が自動的に可視化され、直すべきなのに直っていない箇所が一目でわかります。

弊社では、SideCIから来たコメントは全て修正しないとPullRequestをマージしないことになっています。また、緊急対応などの特例で指摘を見逃して欲しい場合はその理由をレビュワーに説明したりコメントをPullRequestに残すルールにしています。

以上がSideCIの説明になります。

SideCI上で自分達が使いたいLinterを選択し、プロジェクトのホームに各Linterの設定ファイルを置くだけでLinterとSideCIを組み合わせて使えるようになります。Linterでコードを自動修正し、修正できてない場合はSideCIを通して指摘してもらうことで規約のチェックを自動化しています。

こうすることで、コーディング規約を守ることを個人的な判断のみに任せず、チーム全体でコーディング規約を守ることができます。

Linterのルールを適度に調節する

上記で、SideCIから来た指摘を全て修正しないとマージしないようにするルールと言いましたが、それを実現するためには各Linterの設定が適切である必要があります。

弊社では、最初チーム内で特定のコーディング規約が存在していなかったため、各Linterのデフォルト設定からスタートして徐々にカスタマイズしていきました。コーディング規約は、チームにとって不要なルールが混ざっていたり、設定がキツすぎるとチームメンバーの負担が大きくなり、不満も募ります。(各Linterのデフォルトの設定はかなりキツめになっています。) なので、実践しながらチームにとって適度なコーディング規約の作成をしていくという方法をとりました。

Linterの設定を適度に調節する際に、

  1. SideCIを使ってLinterの指摘箇所をGithubのコメントを通してシェアする
  2. ルールがキツすぎる場合はgithubのissueに議題をあげて議論する
  3. 合意が取れる、または1週間以上反論が出ない場合はissueの内容を適用する

という方法を繰り返してLinterを調節していきました。

下に実際に上がっていたissueの例です。 このように議論しながら適切な設定を作っていきました。

  • 例1

f:id:vasilyjp:20170327225957p:plain

  • 例2

f:id:vasilyjp:20170327230017p:plain

現在はメンバーの合意が取れている適度なルールになって運用されているため、無理なく無駄なくコーディング規約を守ることに成功しています。

まとめ

以上、APIを長く運用するために仕組み化した下記の3つを紹介しました。

  1. APIのレスポンスを統一するために、デコレーターfieldgroupを導入
  2. パラメーターを統一するために全処理共通のValidatorを導入
  3. LinterとSideCIでコーディング規約の徹底

参考になるものがあったら是非試してみてください。

VASILYでは春季インターンの募集を行っています。 APIやバックエンド開発に興味のある方、是非弊社に遊びに来てください!

カテゴリー