🚚

【Rails】シンプルなDTOの実装

2023/10/11に公開

概要

APIサーバの開発において、リクエストとレスポンスをDTO(Data Transfer Object)で構造化したかったので、シンプルなDTOを実装してみました。

RailsにはデフォルトでDTOはありません。
ModelとDTOを分けて実装することで、データの移し替えの手間は増えますが、

  • 通信フォーマットを一元化できる
  • フロントエンド/バックエンド(クライアント/サーバー)を分担しやすくなる

といったメリットがあると考えています。

今回実装したDTOには以下の機能を持たせました。

  • Hash変換
    • DTOをHashオブジェクトに変換する
      • 変換したHashをJSONなりMessagePackなり、任意のフォーマットでrenderする想定
  • ネスト可能
    • DTO内に別のDTO、またはDTOの配列を保持することが可能
  • 自動キャスト
    • DTOのattributeを適切な型に自動でキャストする

実装

DTO

基底クラスではActiveModel::ModelActiveModel::Attributesをincludeしました。
具象クラスではattributeメソッドで項目と型を定義します。
attributeメソッドに指定するdtodto_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)が、キャスト時に再帰的に行われるようにしました。

config/initializers/active_model_types.rb
# 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::ModelActiveModel::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::ModelActiveModel::Attributesを利用してRailsの恩恵を受けたいな、と思いました。

参考

DTOの実装にあたり、以下の記事を参考にさせて頂きました。
ありがとうございます。
https://gitpress.io/@rhiroe/active_model_attributes

Happy Elements

Discussion