RABLを捨てつつActiveModelSerializersに移行する

こんにちは、技術開発部の太田です。

FiNCではサーバーサイドにはRuby/Railsを採用し、クライアントからのリクエストに対してJSONを返すAPIを用意しています。 これまではJSONの出力にRABLを使っていましたが、 新たに作成するAPIはActiveModelSerializersを使うように移行しました。

要約

ActiveModelSerializersの良いところ

  • rubyのコードなので学習コストが比較的低い
  • エラーもrubyの普通のエラーになるのでわかりやすい
  • リソース志向に沿うことをある程度強制される

悪いところ

  • Modelを少し加工したJSONを返しにくい
  • 情報が少ない

RABLと共存する時期をどうするか

  • 新しいエンドポイントだけ書き直す
  • シリアライザで部分的にRABLを使う

背景

従来使用していたRABLは記法が独特なため学習の必要があるのと、ドキュメントが整備されていないため学習コストがもの凄く高く、かつ制約がないため好き勝手できてしまうので、ある程度強制され、基本的にrubyで記述することのできるactive_model_seralizersへと移行しました。

また、これまでFiNCでは、各エンドポイントでいい感じに必要なデータを返すように作っており、場所によって形式が違ったり、共通でアクセスするエンドポイントに色々詰め込んで処理が重くなり、かつ後方互換性のために整理するのが難しくなる…といった問題が起きていました。 そこで現在、クライアントからのアクセスを一手に引き受けるフロントエンドサーバを立て、バックエンドはリソースを返すだけにし、クライアントからの要求に応じてフロントエンドサーバがリソースを取ってきて返す形へ移行をしています。 active_model_serializerはある程度リソースに紐付いたレスポンス形式を強制されるため、導入に踏み切ったという背景もあります。

導入方法

インストール

Gemfileに書いてインストールするだけです。

gem 'active_model_serializers', '~> 0.10.0'

使い方

ActiveModelSerializersの基本的な考え方は、ActiveModelを継承したクラスに対して対応するシリアライザークラスを定義し、json render時にシリアライザーに書かれた通りに出力するというものです。

サンプルコード

以下のようなPostモデルがあると仮定します。

# == Schema Information
#
# Table name: posts
#
#  id         :integer          not null, primary key
#  user_id    :integer
#  title      :string
#  body       :string
#  created_at :datetime         not null
#  updated_at :datetime         not null
#

class Post < ApplicationRecord
  def first_letter
    title[0]
  end
end

ActiveModelSerializersはXXXモデルに対してXXXSerializerを探しに行くので、以下のようにPostSerializerを定義します。

class PostSerializer < ActiveModel::Serializer
  attributes :title, :body, :first_letter
end

あとはJSONでレンダリングすると、attributesに指定したものだけが出力されます。

def show
  render json: Post.first
end

=> {"title":"test","body":"text body","first_letter":"t"}

その他

Q. シリアライザーは1対1対応が必須ですか

A. 自動で検索する場合はそうですが、指定する場合は好きな物を使えます。

render json: Post.first, serializer: PostWithUserSerializer

Q. 配列の中の要素に対してシリアライザーを適応できますか

A. できます。配列に要素を積めた場合、配列のシリアライザーを定義する必要は無く、以下のようにeach_serailizerを指定することで、配列内の要素に対してシリアライズ方法を指定できます。

render json: Post.all, each_serializer: PostWithUserSerializer

Q. モデルのカラム以外も出力できますか

A. できます。シリアライザーのattributesに指定したものと同名のメソッドをシリアライザークラスやモデルクラスに定義すればそれが使われます。(上記サンプル参照) また、has_manyやbelongs_toをシリアライザーに書くことで、関連先のデータも出力することができます。

class UserSerializer < ActiveModel::Serializer
  has_many :posts, serializer: PostSerializer
end

render json: User.first, serializer: UserSerializer, include: '**'

移行方法

残念ながら、FiNCには大量のエンドポイントが既に存在するため、一度に移行するのは不可能でした。 (クライアントアプリからアクセスされるもので700以上)

さらに、既存のAPIを新たに書き直すには既存の形式を調べてリソースに分割、フロントエンドサーバ・クライアント側での対応などが必要になるため、現実的にはしばらくの間は既存の形式との併用が必要になります。 その場合に問題になるのが、大半を新しく作る一方で部分的に既存形式と揃えたい場合です。 RABLで書かれているのを全てActiveModelSerializersで書き直すにはコストがかかりますが、複雑なRABLだけどそもそもアクセス回数は少ない場合や、将来的にその機能は消すけど当面は使う…といった風に、実装し直すコストに見合わない場合があります。

この場合、以下のようにシリアライザ中でRABLのrenderを呼び出すことで、既存のRABLとの互換性を維持しつつActiveModelSerializersに移行することができます。 これを利用することで、エンドポイント単位ではなく、さらになだらかにRABLを捨てていくことができます。

class UserPostSerializer < ActiveModel::Serializer
  attributes :id, :old_post_data
  def old_post_data
    Rabl.render(object.posts, 'v1/posts/index',
      view_path: 'app/views/api',
      format: 'hash',
      locals: { username: object.name, next_page: true}
    )
  end
end

未解決課題

現在、以下の課題は良い解決方法を模索中です

シリアライザーのバージョニング

構造が同じだけど少し違うオブジェクトのシリアライザ

  • {page: 1, data: [ {username: ‘aaa’…と{page: 1, data: [ {post_title: 'test’…
  • いい感じに共通化したい
  • 動的にシリアライズ対象を切り替えるとか…?

最後に

FiNCではエンジニアを募集しています! ドキュメントがまともにないDSLを滅ぼしたい人や、それ以外にも興味がある人はお気軽にご連絡ください!

Android → https://www.wantedly.com/projects/54470
iOS → https://www.wantedly.com/projects/59939
Webフロント → https://www.wantedly.com/projects/63233
インフラ/SRE → https://www.wantedly.com/projects/57858
AI/機械学習 → https://www.wantedly.com/projects/57462
分析/データアナリスト → https://www.wantedly.com/projects/57201
QA/テストエンジニア → https://www.wantedly.com/projects/64774
Rubyエンジニア → https://www.wantedly.com/projects/30872