大規模なサービスのAPIを開発する際に、ルールを決めずに開発していると無秩序なコードが散見される運用がしづらいAPIになってしまいます。また、ルールを決めたとしても共有が上手くいかないなどの理由で守られなくなってしまうこともあると思います。
本記事では、APIを運用しやすくするために、ただルールを決定しただけではなく、ルールを守るためにそれぞれ仕組み化をしたことを紹介します。
- APIのレスポンスを統一する
- デコレーターを使ってレスポンスの定義を綺麗に書く
- パラメーターを統一する
- Validatorによりパラメーターの明記を強制する
- コーディング規約を守る
- 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_hash
とfieldgroup
を使って、レスポンスをまとめて返すことが可能になります。
to_hash_fieldgroup
メソッドが少しトリッキーな手法を使っていますが、Brand.find(1).decorate
オブジェクトに対して、define_fieldgroup
に定義されたkey名と値にアクセスするためのメソッド名が同一なことを利用して値を取得し、Hashにしています。
このように、1ファイルにまとめることでどのような項目のレスポンスが返るかが見やすくなり、レスポンスの統一が容易になります。また、レスポンスの変更もスムーズに行うことができるようになります。
2. パラメーターを統一する
APIのパラメーターを統一するというのは、同じ意味を持つパラメーターが違う命名で乱立するのを防ぐことや、受け取るパラメーターのルールをしっかり決めることです。
APIが受け取るパラメーターを統一することで、使う側にメリットがありますし、開発側もデバッグが楽になります。
そこで、パラメーターを統一するために実践しているテクニックを紹介します。
Validatorによりパラメーターの明記を強制する
パラメーターが統一されない一番の原因は、現在受け取るパラメーターが何なのかコードを深く読まないとわからないことだと考えています。
ドキュメントを作成することが強制されていたり、ルールがしっかり決まっていればまだいいかもしれませんが、ドキュメントやルールがある場合でも、更新することを怠ってしまうと網羅的にパラメーターを把握できなくなります。
それにより、現状どのようなパラメーターが使われているかを知ることが大変になり、パラメーターにブレが発生します。
例えば昇順、降順を指定するパラメーター名がorder
とsort
でブレてしまったり、それらが受け取る値が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の修正をかけた際の例です。
5行目の指摘は、早期リターンを勧めてくれていますが、機械的に修正すると危険な項目なので指摘のみです。
6行目の指摘は、インデントの指摘を自動で修正してくれています。
このように、自動的にコードを見て問題箇所を指摘したり、修正してくれます。
次にSideCIがどういったサービスかについて少し説明します。
SideCIは、GitHubとLinterを利用したコードレビューを行うサービスです。
GitHubのPullRequest上でSideCIの実行結果を受け取る事ができます。
下記は実際にSideCIから指摘が入った例で、PullRequestに自動的に修正コメントを残してくれます。(最近では、指摘の一部に日本語が導入されました!)
その際に、どの種類のLinterから指摘が入ったかと指摘内容を一緒に表示してくれます。
Linterを導入するだけでは開発者がLinterをかけ忘れたり怠ったりした場合、レビュワーが気づいて指摘する手間が発生していましたが、SideCIを使うことでPullRequest上に指摘が自動的に可視化され、直すべきなのに直っていない箇所が一目でわかります。
SideCIから来たコメントは全て修正しないとPullRequestをマージしないことにして、緊急対応などの特例で指摘を見逃して欲しい場合はその理由をレビュワーに説明したりコメントをPullRequestに残すルールにすればコーディング規約は守られます。
以上がSideCIの説明になります。
SideCI上で自分達が使いたいLinterを選択し、プロジェクトのホームに各Linterの設定ファイルを置くだけでLinterとSideCIを組み合わせて使えるようになります。Linterでコードを自動修正し、修正できてない場合はSideCIを通して指摘してもらうことで規約のチェックを自動化できます。
こうすることで、コーディング規約を守ることを個人的な判断のみに任せず、チーム全体でコーディング規約を守ることができます。
Linterのルールを適度に調節する
上記で、SideCIから来た指摘を全て修正しないとマージしないようにするルールと言いましたが、それを実現するためには各Linterの設定が適切である必要があります。
特定のコーディング規約が存在していない場合、各Linterのデフォルト設定からスタートして徐々にカスタマイズしていくという方法があります。コーディング規約は、チームにとって不要なルールが混ざっていたり、設定がキツすぎるとチームメンバーの負担が大きくなり、不満も募ります。(各Linterのデフォルトの設定はかなりキツめになっています。)
なので、実践しながらチームにとって適度なコーディング規約の作成をしていくことをお勧めします。
Linterの設定を適度に調節する際に、
- SideCIを使ってLinterの指摘箇所をGithubのコメントを通してシェアする
- ルールがキツすぎる場合はgithubのissueに議題をあげて議論する
- 合意が取れる、または1週間以上反論が出ない場合はissueの内容を適用する
という方法を繰り返してLinterを調節していきました。
下に実際に上がっていたissueの例です。
このように議論しながら適切な設定を作っていきました。
- 例1
- 例2
メンバーの合意が取れている適度なルールになって運用されるため、無理なく無駄なくコーディング規約を守ることができます。
まとめ
以上、APIを長く運用するために仕組み化した下記の3つを紹介しました。
- APIのレスポンスを統一するために、
デコレーター
とfieldgroup
を導入 - パラメーターを統一するために全処理共通のValidatorを導入
- LinterとSideCIでコーディング規約の徹底
参考になるものがあったら是非試してみてください。