はじめに
RailsはMVCフレームワークとしてよく使われていますが、昨今はRailsをAPIモードで利用して、フロントはReactなりVueなりで書くことが多いかと思います。
とはいえその流れはここ数年で、少し前まではRailsデフォルトの方法としてフロント側もerbファイルで書いているプロジェクトがほとんどだったと思います。
そのまま流れで、運用しているプロジェクトもたくさんあると思いますが、パフォーマンスや保守性のため分離したいと考えている人も多いかと思います。
本記事ではなるべく低工数でフロントエンドとバックエンドを分離する方法を考えました。
色々試行錯誤してこういう結論に達したというだけなので、アドバイスあれば是非お願いします!
結論
先に結論だけ書いておくと、「Get系はGraphQL、他はRestAPIで書く」です。
前提条件
MVCで書かれているので、基本的にAPI仕様書はなく、テストもほとんどない状態
ロジック自体はあまりいじりたくないので、可能な限り既存コードを転用したい
採用した施策
GraphQL
Get周りはgraphql-rubyを使って実装します。
GraphQLを使う理由としては、
- 元実装がRestAPIとして綺麗に管理された返り値でない
- API定義書を作るのが大変(後述のgemである程度は自動生成できますが、、)
特に前者の理由が大きく、必要な値をインスタンス変数としてフロントに渡していたので、何がフロントで必要なのかはControllerを見ただけでは分かりませんでした。
例えば、@user = User.first
というインスタンス変数をフロントに渡してて、フロント側で@user.id
しか使ってなかったら他のプロパティは無駄ですし、APIとしてUserインスタンスごと渡していたら、例えば見えてはいけない情報がAPIに含まれていたら困ります。
なので、フロント側で必要な情報だけ指定できるGraphQLは都合が良く、さらに勝手にAPI定義書的なものも生成してくれるので採用に至りました。
あまり、GraphQLの内容を深堀りはしませんが、例えば
User
- id
- email
- password
というモデルがあった場合に、パスワードはレスポンスに含めることがない(基本的にハッシュ化してるはずなので、漏れても問題ないと言えば問題ないですが)とわかっていれば、User型を以下のように定義します。
class UserType < Types::BaseObject
field :id, ID, null: false
field :email, String, null: false
end
このように書くとパスワードが返ることはなく、フロント側のクエリとしては、
query {
user {
id
email
}
}
このように書くことができます。
さらに必要なのがemailだけであれば、以下のように書くことでemailのみ取得できます。
query {
user {
email
}
}
Get以外でGraphQLを採用しなかった理由としては、そのままREST APIとして使いまわした方が早そうだなと判断したためです。
入力はparamsでストロングパラメータ使っているので、その財産をそのまま使いまわせますし、GraphQLのmutationを作るよりは実装コストが少ないと考えました。
もちろん、Write系の処理が少ないサービスであれば全てGraphQLで統一した方がフロント側も分かりやすいかもしれませんが、ある程度使いまわせるコードがあるならWrite系の処理はそのままの方が実装コストは少ないかと思います。
OpenAPI自動生成ツール(rspec-openapi)の導入
API仕様書を一から作るのは大変なので、ある程度自動生成したいと調べたら以下のgemがあったので使用することにしました。
rspec-openapi
こちらはRSpecを書いたら勝手にOpenAPIの定義書を生成してくれるというものです。
ドキュメントの引用ですが、
RSpec.describe 'Tables', type: :request do
describe '#index' do
it 'returns a list of tables' do
get '/tables', params: { page: '1', per: '10' }, headers: { authorization: 'k0kubun' }
expect(response.status).to eq(200)
end
it 'does not return tables if unauthorized' do
get '/tables'
expect(response.status).to eq(401)
end
end
end
このようなテストを書くと、
openapi: 3.0.3
info:
title: rspec-openapi
paths:
"/tables":
get:
summary: index
tags:
- Table
parameters:
- name: page
in: query
schema:
type: integer
example: 1
- name: per
in: query
schema:
type: integer
example: 10
responses:
'200':
description: returns a list of tables
content:
application/json:
schema:
type: array
items:
type: object
properties:
id:
type: integer
name:
type: string
# ...
このようにOpenAPIを自動生成してくれます。
正直、細かい調整等はまだ不十分なところはありますが、最低限のドキュメントはできるので最初にこれを生成して後で多少編集すれば良いかと思います。
ロジックの分離
既存コードはなるべく利用するという前提でしたが、多少のリファクタリングはしたいと思います。
細かい方法はこちらの別記事に書きましたが、簡単に要約すると、
- Controllerは入力のvalidation層にする
- ロジックはServiceクラスにまとめる
- レスポンス整形はgemを使う
このような形です。
サンプルとしては以下のようになります。
こうすれば割とControllerはスッキリします。
def create
email = params[:email]
password = params[:password]
# ここで入力のバリデーションを行う
raise "email cannot be blank" if email.blank?
raise "password cannnot be blank" if password.blank?
# ここでユーザー作成行う(ロジックを集約)
user = UserCreateService.call(email, password)
# ここで返り値の整形
UserSerializer.call(user)
end
もちろん、入力のバリデーションはバリデーション層みたいなのを作って、別に切り分けても問題ありません。
ロジック自体はService層をテストすれば良いですし、Service層の中でさらに細かいロジックに分けることで細かくテストも可能になるかなと思います。
インスタンス変数の撤廃
上記にもありますが、インスタンス変数をフロントに渡してどう使われるか分からないという状態は単純なREST APIとして扱う際には面倒でした。
Get系のAPIはGraphQLを導入することで返り値のことを考えなくてよくなりましたが、その他のAPIでは依然残っています。
元のプロジェクトでは、jbuilderも使っていましたが、別のgem(alba)を利用することにしたので、インスタンス変数は撤廃することにしました。
正しく使えば有用なインスタンス変数ですが、色々なところから呼び出してると責務が分離しづらいので、APIモードでは基本的に不要と考えます。
もちろん使っている箇所もありますが、必要な箇所一部といった感じなので、スコープを限定することで見通しが良くなりました。
学び
最初はrspec-openapiがあるので、今ある資産を全て使いまわそうかと思ったのですが、OpenAPI定義書があまりにも煩雑になりすぎるため諦めました。ちゃんとオブジェクトとか定義できるように設計するのは大事ですね。
とりあえずGraphQL入れておけば大分楽だなと思ったのですが、GraphQLはフロントに負担を強いる形になるので、コミュニケーションを密に取り、自動生成されるGraphQLのAPI仕様書以上の情報を提供しないと結局全体として作業時間が増えてしまいます。
そのため、何か必要なクエリがあったらすぐに対応したり、とりあえず作ってAPI仕様書見るだけで書いてもらうっていうことのないようにした方が良いかと思います。
あとは、インスタンス変数の扱いが厄介ですね。どう使われるか確認するのが大変なので、基本的には使わない方が色々楽でした。
Rubyは動的型付けってこともあって割と簡単にかけてしまうところもあるのですが、後々大変になる(特に後任者が)ってことを再認識したので、リファクタリングしやすいように書くっていうのをちゃんと意識して今後もコーディングしたいと思います。