【Rails】シンプルなDTOの実装
概要
APIサーバの開発において、リクエストとレスポンスをDTO(Data Transfer Object)で構造化したかったので、シンプルなDTOを実装してみました。
RailsにはデフォルトでDTOはありません。
ModelとDTOを分けて実装することで、データの移し替えの手間は増えますが、
- 通信フォーマットを一元化できる
- フロントエンド/バックエンド(クライアント/サーバー)を分担しやすくなる
といったメリットがあると考えています。
今回実装したDTOには以下の機能を持たせました。
- Hash変換
- DTOをHashオブジェクトに変換する
- 変換したHashをJSONなりMessagePackなり、任意のフォーマットでrenderする想定
- DTOをHashオブジェクトに変換する
- ネスト可能
- DTO内に別のDTO、またはDTOの配列を保持することが可能
- 自動キャスト
- DTOのattributeを適切な型に自動でキャストする
実装
DTO
基底クラスではActiveModel::Model
とActiveModel::Attributes
をincludeしました。
具象クラスではattribute
メソッドで項目と型を定義します。
attribute
メソッドに指定するdto
とdto_array
は後述する自作の定義で、これによりネストしたDTOやDTO配列をキャストします。
class DtoBase
include ActiveModel::Model
include ActiveModel::Attributes
# Hash変換を直感的に呼べるようaliasを定義
alias to_h attributes
alias to_hash attributes
end
class User < DtoBase
attribute :id, :integer
attribute :name, :string
end
class UserRelation < DtoBase
attribute :parent, :dto
attribute :children, :dto_array, default: []
end
ActiveModel::Typeの登録
initializersでattribute用のTypeを作成し、:dto
、:dto_array
で指定できるようにしました。
:dto
ではDTOのto_h
が、:dto_array
ではmap(&:to_h)
が、キャスト時に再帰的に行われるようにしました。
# ActiveModel::Attributesで使う独自のType定義
module AttributeType
class Dto < ActiveModel::Type::Value
def cast(value)
value.to_h
end
end
class DtoArray < ActiveModel::Type::Value
def cast(value)
value.map(&:to_h)
end
end
end
# symbolで指定した型をActiveModel::Attributesで使えるように登録
ActiveModel::Type.register(:dto, AttributeType::Dto)
ActiveModel::Type.register(:dto_array, AttributeType::DtoArray)
動かしてみる
以下のコードで再帰的なHash変換と自動キャストが行われることを確認します。
# ネストされたDTOが再帰的にHashに変換される
user1 = User.new(id: 1, name: 'parent')
user2 = User.new(id: 2, name: 'child1')
user3 = User.new(id: 3, name: 'child2')
dto = UserRelation.new(parent: user1, children: [user2, user3])
dto.to_h
=> {"parent"=>{"id"=>1, "name"=>"parent"}, "children"=>[{"id"=>2, "name"=>"child1"}, {"id"=>3, "name"=>"child2"}]}
# 定義された型への自動キャストも行われる
user1 = User.new(id: '1', name: :parent)
user2 = User.new(id: '2', name: 123)
user3 = User.new(id: '3', name: 456)
dto = UserRelation.new(parent: user1, children: [user2, user3])
dto.to_h
=> {"parent"=>{"id"=>1, "name"=>"parent"}, "children"=>[{"id"=>2, "name"=>"123"}, {"id"=>3, "name"=>"456"}]}
いい感じに動いてますね。
おまけ
ActiveModel::Model
やActiveModel::Attributes
を利用することによるパフォーマンスへの影響が気になったので、以下の最小限の実装でベンチマークを取得しました。
class PlainUser
def initialize(id:, name:)
@id = id
@name = name
end
def to_h
{ id: @id, name: @name }
end
end
class PlainUserRelation
def initialize(parent:, children:)
@parent = parent
@children = children
end
def to_h
{ parent: @parent.to_h, children: @children.map(&:to_h) }
end
end
ベンチマーク
loop_cnt = 10000
Benchmark.bm 10 do |r|
r.report "DTO" do
loop_cnt.times do
user1 = User.new(id: 1, name: 'parent')
user2 = User.new(id: 2, name: 'child1')
user3 = User.new(id: 3, name: 'child2')
dto = UserRelation.new(parent: user1, children: [user2, user3])
dto.to_h
end
end
r.report "PlainObject" do
loop_cnt.times do
user1 = PlainUser.new(id: 1, name: 'parent')
user2 = PlainUser.new(id: 2, name: 'child1')
user3 = PlainUser.new(id: 3, name: 'child2')
dto = PlainUserRelation.new(parent: user1, children: [user2, user3])
dto.to_h
end
end
end
結果
user system total real
DTO 0.161257 0.001118 0.162375 ( 0.162421)
PlainObject 0.010550 0.000734 0.011284 ( 0.011284)
やはり素のオブジェクトと比べると結構な差がありますね。
ただ、実装量が増える、型キャストがされなくなる、といったデメリットもあるので、シビアな性能要件がない限りはActiveModel::Model
とActiveModel::Attributes
を利用してRailsの恩恵を受けたいな、と思いました。
参考
DTOの実装にあたり、以下の記事を参考にさせて頂きました。
ありがとうございます。
Discussion