はじめに
Railsで運用されているAPIサーバーのシリアライザとSwaggerスキーマをリファクタリングした話と、そこで当たった問題(余談)の紹介です。
現状の状態
社内のサービスはAPIサーバーとしてRailsを用いて運用されているのですが、長く運用されているため、手動で作成されるSwaggerスキーマとそれに対応したシリアライザが無秩序な状態にあり、新規APIの実装や修正が入るたびに各人の裁量でスキーマおよびシリアライザを使用・作成していて、運用しづらくなっていました。また、ActiveModel::Serializerなどは使っていなく、独自で実装を行なっています。
実際にどのような構成になっていたかの例を大雑把に。
例
例えば、以下のようなモデルがあったとして
class Member < ActiveRecord::Migration
def change
create_table :members do |t|
t.string :user_name
t.string :nick_name
t.string :role
t.string :badge
t.string :image
t.integer :sex
t.string :status
t.string :code
t.boolean :is_displayed
t.timestamps null: false
end
end
end
上のようなモデルがあったとして、このメンバー情報を表示したいフロントの画面が以下のように3パターンあった場合、
- メンバー一覧画面
- 表示するのはユーザー名とニックネームとステータスのみ
- メンバー詳細画面
- 表示するのはメンバー情報一通り、だが必要ないものもある
- メンバー詳細と、そのメンバーが執筆した記事を表示する画面
- MemberモデルのアソシエーションにArticleモデルがあったとして、それを表示する画面
何も考えず用途に合わせてシリアライザを実装すると以下のようになるかと思います(実際になっていました)
例がだいぶ微妙ですが、許してください
メンバー一覧画面のシリアライザ
表示するのはユーザー名とニックネームのみであり、それ以外は使わないので削ります。
class MemberSmallSerializer < Serializer
attribute :id, :integer
attribute :user_name, :string
attribute :nick_name, :string
attribute :status, :string
def self.serialize(member)
new(
id: member.id,
user_name: member.user_name,
nick_name: member.nick_name,
status: member.status,
)
end
end
メンバー詳細画面のシリアライザ
Memberモデルのほとんどの情報を返しますが、必要ないものもあるので、それは削ります。
class MemberSerializer < Serializer
attribute :id, :integer
attribute :user_name, :string
attribute :nick_name, :string
attribute :role, :string
attribute :image, :string
attribute :sex, :integer
def self.serialize(member)
new(
id: member.id,
user_name: member.user_name,
nick_name: member.nick_name,
role: member.role,
image: member.image,
sex: member.sex,
)
end
end
メンバー詳細と、そのメンバーが執筆した記事を表示する画面のシリアライザ
class MemberLargeSerializer < Serializer
attribute :id, :integer
attribute :user_name, :string
attribute :nick_name, :string
attribute :role, :string
attribute :image, :string
attribute :sex, :integer
attribute :articles, array: :object
def self.serialize(member)
new(
id: member.id,
user_name: member.user_name,
nick_name: member.nick_name,
role: member.role,
image: member.image,
sex: member.sex,
# articlesというアソシエーションがあるとする
articles: member.articles.map { |article| ArticleSerializer.serialize(article) }
)
end
end
何も考えず1画面ずつ作っていくとこうなるかなーという例でした。
これの問題点
メンバー情報を必要とする画面がもうこれ以上増えないとしたらこのままでもいいのですが、以下のような疑問・問題が残ります。
- member_smallシリアライザが必要か?
- 冷静に考えて、member_smallシリアライザとmemberシリアライザはどちらもメンバー情報しか扱っていないため、メンバー情報取得の際に発行されるクエリは1つであり、返すカラムを絞るメリットがほとんどない
- むしろ、新しくメンバー情報が必要な画面が増えた時、どちらのシリアライザを使うべきか選ぶ必要が出てくる
- member_smallにはstatusがあるが、memberにはstatusがない、みたいな歯抜けの状態であり、よろしくない
- Memberテーブルに新しいカラムが追加になった場合、どうする?
- 現状、メンバー情報を返すシリアライザは3つあり、新しいカラムが追加された場合、全てのシリアライザを修正する必要がある
- member_largeシリアライザってなんだ?
- 名前だけ見ると一見汎用性のありそうなシリアライザだが、中身を見るとArticleアソシエーションを使用しており、他のシリアライザで安易に使うと余計なアソシエーションが走ってしまうのでNGという状態
と、トラップがいくつか生まれてしまいます。
今回の例ではカラム数が少なかったり、アソシエーションが少なかったり、メンバーを使ったシリアライザが3つだったりしますが、実際はもっとカオスです。
どう改善したか
これらを解決すべく、まずMemberモデルの全ての情報を返す完全なmemberシリアライザを作ります。
class MemberSerializer < Serializer
attribute :id, :integer
attribute :user_name, :string
attribute :nick_name, :string
attribute :role, :string
attribute :image, :string
attribute :sex, :integer
attribute :status, :string
attribute :code, :string
attribute :is_displayed, :boolean
def self.serialize(member)
new(
id: member.id,
user_name: member.user_name,
nick_name: member.nick_name,
role: member.role,
image: member.image,
sex: member.sex,
status: member.status,
code: member.code,
is_displayed: member.is_displayed,
)
end
end
メンバー情報が必要な画面は必ずこのシリアライザを使うようすることで、smallや旧memberシリアライザのようなものは必要なくなりました
また、メンバーとそれに関連した情報が欲しい場合は、
class MemberWithArticlesSerializer < MemberSerializer
attribute :articles, object: :array
def self.serialize(member)
new(
**super(member).attributes,
articles: member.articles.map { |article| ArticleSerializer.serialize(article) }
)
end
end
このように、member_serializerを継承することで、新しくMemberテーブルにカラムが追加になり、memberシリアライザが修正されたとしても変更箇所は一つで済むようになりました。
今後、新たなテーブルを作成したときも、これと同じようにやっていくことで、シリアライザにある程度の秩序が生まれるかなと思います。
スキーマではどう表現する?
これらを、Swaggerスキーマ(OpenAPI)でも表現するには、allOfという大変便利なものがあるので、それを使います。
member:
type: object
additionalProperties: false
nullable: true
required:
- id
- user_name
- nick_name
- role
- image
- sex
- status
- code
- is_displayed
properties:
id:
type: string
user_name:
type: string
nick_name:
type: string
role:
type: string
image:
type: string
sex:
type: integer
status:
type: string
code:
type: string
is_displayed:
type: string
このようなメンバースキーマがあるとして、member_with_articles.ymlは以下のように定義することで、シリアライザと同じように実現できます。
member_with_articles:
type: object
additionalProperties: false
nullable: true
allOf:
- "$ref": "#/components/schemas/member"
- type: object
required:
- articles
properties:
articles:
type: array
items:
"$ref": "#/components/schemas/article"
余談
余談で、滅多にないケースですが、上記のようなシリアライザでは型のオーバーライドも可能(rubyのコードなので当たり前ですが)で、例えば、memberシリアライザのstatusはstringですが、member_with_articlesシリアライザではstatusはobjectで返したい、というケースがあった場合、以下のように書いたりします。(どんなケースだよ、って思いますが実際ありました)
class MemberWithArticlesSerializer < MemberSerializer
attribute :status, :object
attribute :articles, object: :array
def self.serialize(member)
new(
**super(member).attributes,
status: StatusSerializer(member.status)
articles: member.articles.map { |article| ArticleSerializer.serialize(article) }
)
end
end
このように書くとstatusの型が上書きされ、stringではなくobjectとして返す、という表現になるわけですが、これをスキーマで表現すると少し問題があります。
スキーマで表現すると以下のようになります
member_with_articles:
type: object
additionalProperties: false
nullable: true
allOf:
- "$ref": "#/components/schemas/member"
- type: object
required:
- status
- articles
properties:
status:
"$ref": "#/components/schemas/status"
articles:
type: array
items:
"$ref": "#/components/schemas/article"
これ自体はエラーにならず、SwaggerUIでもちゃんと上書きされた型で表示されるのですが、openapi-parserというgemでバリデーションを行っている場合、この書き方ではバリデーションエラーになってしまいます。
理由としては、openapi-parserの実装上、allOfにて先に読み込まれるmemberスキーマのstatusの型(string)と、実際にstatusが返す値(object)を比較してしまい、エラーとなります。
なのでこれを回避したい場合、member_with_articlesスキーマではallOfを使わず、memberスキーマを直で記述する必要があります。
さいごに
少し特殊な環境かつニッチな内容になってしまいましたが、こんな事例もあるんだな程度で見てもらえたら幸いです。