Tech Racho エンジニアの「?」を「!」に。
  • 開発

Ruby: Dry-rb gemシリーズのラインナップと概要

こんにちは、hachi8833です。

先ごろRubyWorld ConferenceRuby Prize 2017を受賞された@solnicことPiotr Solnicaさんが近年力を入れているのがdry-rbシリーズです。

@solnicさんはあのVirtusの作者でもあるのですが、現在VirtusのREADMEには、Virtusの機能を分割改良してdry-rbにしたとあります。

Virtusを作ったことで、Rubyにおけるデータの扱い、中でもcoercion/型安全/バリデーションについて多くのことを学べました。このプロジェクトは成功を収めて多くの人々が役立ててくれましたが、私はさらによいものを作ることを決心しました。その結果、dry-typesdry-structdry-validationが誕生しました。これらのプロジェクトはVirtusの後継者と考えるべきであり、考慮点やより優れた機能がさらにうまく分割されています。Virtusが解決しようとした同種の現代的な問題について関心がおありの方は、ぜひdryシリーズのプロジェクトもチェックしてみてください。

@solnic
https://github.com/solnic/virtus より抄訳

Virtusは長く使われていて枯れているせいか、何年も更新されていません。
また、Virtusの動作が確認されているのはRuby 1.9.3/2.0.0/2.1.2/jrubyであるとのことなので、今後はdry-rbを使うのがよさそうです。そこで、とりあえずどんなgemがあるのかというレベルで少し調べてみました。

なお、dry-rbにはDiscourseで作られたフォーラムがあり、質問にはこちらを使って欲しいそうです。

dry-rbシリーズのラインナップ


dry-rb.orgより

dry-rbシリーズのgem同士の依存関係だけ、とりあえず.gemspecファイルを元にがんばって図にしてみました(draw.ioで作成)。ざっと見た限りでは、dry-validationとdry-structを使えばVirtusとだいたい同じことができそうです。これらをインストールすると、dry-coreやdry-typeなどの依存gemもインストールされます。

以下はhttp://dry-rb.org/gems/の記載順です。多くのgemは互いに依存関係にあります。11月7日時点のものなので、今後変わる可能性があります。gem名直後のかっこ内はバージョン番号です。

dry-validation(0.11.1)

述語論理に基づいたバリデーションを行うgemです。ドキュメントには、ActiveRecord/ActiveModel::Validationとstrong_parametersより数倍速いとあります(使うかどうかはまた別の問題です)。以下のようなユースケースを含む多くの使いみちがあります。

  • Form params
  • “GET” params
  • JSON/YAMLドキュメント
  • アプリの設定(ENVに保存されているなど)
  • ActiveRecord/ActiveModel::Validationsの置き換え
  • strong-parametersの置き換え
# dry-rb.orgより
schema = Dry::Validation.Form do
  required(:name).filled
  required(:age).filled(:int?, gt?: 18)
end

schema.("name" => "Jane", "age" => "30").to_h
# => {name: "Jane", age: 30}

schema.("name" => "Jane", "age" => "17").messages
# => {:age=>["must be greater than 18"]}

依存gem

  • 'concurrent-ruby', '~> 1.0'
  • 'dry-configurable', '~> 0.1', '>= 0.1.3'
  • 'dry-equalizer', '~> 0.2'
  • 'dry-logic', '~> 0.4', '>= 0.4.0'
  • 'dry-types', '~> 0.12.0'
  • 'dry-core', '~> 0.2', '>= 0.2.1'

dry-types(0.12.2)

さまざまなビルトイン型を用いて柔軟な型システムを提供します。利用できる型リストにはTypes::Form::TimeTypes::Maybe::Strict::IntTypes::Maybe::Coercible::Arrayといった膨大な種類の型が含まれています。

# dry-rb.orgより
require 'dry-types'
require 'dry-struct'

module Types
  include Dry::Types.module
end

class User < Dry::Struct
  attribute :name, Types::String
  attribute :age,  Types::Int
end

User.new(name: 'Bob', age: 35)
# => #<User name="Bob" age=35>

依存gem

  • 'concurrent-ruby', '~> 1.0'
  • 'dry-core', '~> 0.2', '>= 0.2.1'
  • 'dry-container', '~> 0.3'
  • 'dry-equalizer', '~> 0.2'
  • 'dry-configurable', '~> 0.1'
  • 'dry-logic', '~> 0.4', '>= 0.4.2'
  • 'inflecto', '~> 0.0.0', '>= 0.0.2'

dry-struct(0.4.0)

structに似たオブジェクトを扱える属性DSLです。constructor_typeで以下を指定することでさまざまな挙動のstructを定義できます。

  • :permissive: デフォルト
  • :schema
  • :strict
  • :strict_with_defaults
  • :weak:symbolized

機能が絞り込まれており、属性のwriterは提供されていません(データオブジェクトとしての利用)。そうした目的にはdry-typesが向いているそうです。

# dry-rb.orgより
require 'dry-struct'

module Types
  include Dry::Types.module
end

class User < Dry::Struct
  attribute :name, Types::String.optional
  attribute :age, Types::Coercible::Int
end

user = User.new(name: nil, age: '21')

user.name # nil
user.age # 21

依存gem

  • 'dry-equalizer', '~> 0.2'
  • 'dry-types', '~> 0.12', '>= 0.12.2'
  • 'dry-core', '~> 0.4', '>= 0.4.1'
  • 'ice_nine', '~> 0.11'

dry-transaction(0.10.2)

ビジネストランザクションを記述するDSLを提供します。トランザクションをモジュール化して再利用したり、トランザクションに引数でステップを追加したりできます。

# dry-rb.orgより
class CreateUser
  include Dry::Transaction(container: Container)

  step :process, with: "operations.process"
  step :validate, with: "operations.validate"
  step :persist, with: "operations.persist"
end

依存gem

  • "dry-container", ">= 0.2.8"
  • "dry-matcher", ">= 0.5.0"
  • "dry-monads", ">= 0.0.1"
  • "wisper", ">= 1.6.0"

dry-container(0.6.0)

dry-auto_inject gemと組み合わせることで依存関係逆転の法則を利用できる、シンプルでスレッドセーフなIoC(制御の反転)コンテナです。ソフトウェアの密結合を避けるのに使います。

# dry-rb.orgより
container = Dry::Container.new
container.register(:parrot) { |a| puts a }

parrot = container.resolve(:parrot)
parrot.call("Hello World")
# Hello World
# => nil

依存gem

  • 'concurrent-ruby', '~> 1.0'
  • 'dry-configurable', '~> 0.1', '>= 0.1.3'

dry-auto_inject(0.4.4)

コンテナへの依存性注入を支援するmixinです。dry-containerとの組み合わせが良好ですが、#[]に応答できるコンテナなら何でもinjectionできます。

# dry-rb.orgより
# Set up a container (using dry-container here)
class MyContainer
  extend Dry::Container::Mixin

  register "users_repository" do
    UsersRepository.new
  end

  register "operations.create_user" do
    CreateUser.new
  end
end

# Set up your auto-injection mixin
Import = Dry::AutoInject(MyContainer)

class CreateUser
  include Import["users_repository"]

  def call(user_attrs)
    users_repository.create(user_attrs)
  end
end

create_user = MyContainer["operations.create_user"]
create_user.call(name: "Jane")

依存gem

  • 'dry-container', '>= 0.3.4'

dry-equalizer(0.0.11)

等しいかどうかをチェックする各種メソッドを追加します。依存しているgemはありません。コアファイルはequalizer.rb 1つだけと、dry-rbシリーズの中では最もシンプルなgemのようです。RubyのModule Builderパターン #2で紹介されているように、equalizer.rbではModule Builderパターンが使われています。

# dry-rbより
class GeoLocation
  include Dry::Equalizer(:latitude, :longitude)

  attr_reader :latitude, :longitude

  def initialize(latitude, longitude)
    @latitude, @longitude = latitude, longitude
  end
end

point_a = GeoLocation.new(1, 2)
point_b = GeoLocation.new(1, 2)
point_c = GeoLocation.new(2, 2)

point_a.inspect    # => "#<GeoLocation latitude=1 longitude=2>"

point_a == point_b           # => true
point_a.hash == point_b.hash # => true
point_a.eql?(point_b)        # => true
point_a.equal?(point_b)      # => false

point_a == point_c           # => false
point_a.hash == point_c.hash # => false
point_a.eql?(point_c)        # => false
point_a.equal?(point_c)      # => false

dry-system(0.8.1)

システム設定の自動読み込みや依存関係の自動解決などを行います。以下のコードを見てもdry-containerやdry-auto_injectが使われているらしいことがわかります。
このライブラリはdry-webにも使われています。

# dry-rb.orgより
require 'dry/system/container'

class Application < Dry::System::Container
  configure do |config|
    config.root = Pathname('./my/app')
  end
end

# now you can register a logger
require 'logger'
Application.register('utils.logger', Logger.new($stdout))

# and access it
Application['utils.logger']

依存gem

  • 'inflecto', '>= 0.0.2'
  • 'concurrent-ruby', '~> 1.0'
  • 'dry-core', '>= 0.3.1'
  • 'dry-equalizer', '~> 0.2'
  • 'dry-container', '~> 0.6'
  • 'dry-auto_inject', '>= 0.4.0'
  • 'dry-configurable', '~> 0.7', '>= 0.7.0'
  • 'dry-struct', '~> 0.3'

dry-configurable(0.7.0)

スレッドセーフな設定機能をクラスに追加するmixinです。settingというマクロで設定します。

# dry-rb.orgより
class App
  extend Dry::Configurable

  # Pass a block for nested configuration (works to any depth)
  setting :database do
    # Can pass a default value
    setting :dsn, 'sqlite:memory'
  end
  # Defaults to nil if no default value is given
  setting :adapter
  # Pre-process values
  setting(:path, 'test') { |value| Pathname(value) }
  # Passing the reader option as true will create attr_reader method for the class
  setting :pool, 5, reader: true
  # Passing the reader attributes works with nested configuration
  setting :uploader, reader: true do
    setting :bucket, 'dev'
  end
end

App.config.database.dsn
# => "sqlite:memory"

App.configure do |config|
  config.database.dsn = 'jdbc:sqlite:memory'
end

App.config.database.dsn
# => "jdbc:sqlite:memory"
App.config.adapter
# => nil
App.config.path
# => #<Pathname:test>
App.pool
# => 5
App.uploader.bucket
# => 'dev'

依存gem

  • 'concurrent-ruby', '~> 1.0'

dry-initializer(2.3.0)

パラメータやオプションの初期化を定義するDSLです。Rails向けのdry-initializer-railsもあります。依存gemはなく、これも非常にシンプルなソースです(initializer.rb)。

# dry-rb.orgより
require 'dry-initializer-rails'

class CreateOrder
  extend Dry::Initializer
  extend Dry::Initializer::Rails

  # Params and options
  param  :customer, model: 'Customer' # use either a name
  option :product,  model: Product    # or a class

  def call
    Order.create customer: customer, product: product
  end
end

customer = Customer.find(1)
product  = Product.find(2)

order = CreateOrder.new(customer, product: product).call
order.customer # => <Customer @id=1 ...>
order.product  # => <Product @id=2 ...>

dry-logic(0.4.2)

組み立て可能な述語論理機能を提供します。dry-typeやdry-validationでも使われています。

# dry-rb.orgより
require 'dry/logic'
require 'dry/logic/predicates'

include Dry::Logic

# Rule::Predicate will only apply its predicate to its input, that’s all

user_present = Rule::Predicate.new(Predicates[:key?]).curry(:user)
# here curry simply curries arguments, so we can prepare
# predicates with args without the input
# translating this into words: check the if input has the key `:user`

min_18 = Rule::Predicate.new(Predicates[:gt?]).curry(18)
# check the value is greater than 18

has_min_age = Operations::Key.new(min_18, name: [:user, :age])
# in this example the name options is been use for accessing
# the value of the input

user_rule = user_present & has_min_age

user_rule.(user: { age: 19 }).success?
# true

user_rule.(user: { age: 18 }).success?
# false

user_rule.(user: { age: 'seventeen' })
# ArgumentError: comparison of String with 18 failed

user_rule.(user: { })
# NoMethodError: undefined method `>' for nil:NilClass

user_rule.({}).success?
# false

依存gem

  • 'dry-core', '~> 0.2'
  • 'dry-container', '~> 0.2', '>= 0.2.6'
  • 'dry-equalizer', '~> 0.2'

dry-matcher(0.6.0)

柔軟で高い表現力を持つパターンマッチャーを提供します。依存gemはありません。
dry-monads gemと組み合わせるとEitherMatcherというものも使えるようになります。

# dry-rb.orgより
require "dry-monads"
require "dry/matcher/either_matcher"

value = Dry::Monads::Either::Right.new("success!")

result = Dry::Matcher::EitherMatcher.(value) do |m|
  m.success do |v|
    "Yay: #{v}"
  end

  m.failure do |v|
    "Boo: #{v}"
  end
end

result # => "Yay: success!"

dry-monads(0.3.1)

Rubyの例外ハンドリングとは別のエラーハンドリングをモナド(monad)で提供します。
モナドはHaskellで中心となる概念だそうです。

# dry-rb.orgより
require 'dry-monads'

M = Dry::Monads

maybe_user = M.Maybe(user).bind do |u|
  M.Maybe(u.address).bind do |a|
    M.Maybe(a.street)
  end
end

# If user with address exists
# => Some("Street Address")
# If user or address is nil
# => None()

# You also can pass a proc to #bind

add_two = -> (x) { M.Maybe(x + 2) }

M.Maybe(5).bind(add_two).bind(add_two) # => Some(9)
M.Maybe(nil).bind(add_two).bind(add_two) # => None()

依存gem

  • 'dry-equalizer'
  • 'dry-core', '~> 0.3', '>= 0.3.3'

dry-view(0.4.0)

Webのビューをビューコントローラ/レイアウト/テンプレートの3つの構成で記述できます。

  • ビューコントローラ
# dry-rb.orgより
require "dry-view"

class HelloView < Dry::View::Controller
  configure do |config|
    config.paths = [File.join(__dir__, "templates")]
    config.layout = "app"
    config.template = "hello"
  end

  expose :greeting
end
  • レイアウト (templates/layouts/app.html.erb)
<html>
  <body>
    <%= yield %>
  </body>
</html>
  • テンプレート (templates/hello.html.erb):
<h1>Hello!</h1>
<p><%= greeting %></p>

これで、レンダリングするビューコントローラを#callします。

view = HelloView.new
view.(greeting: "Greetings from dry-rb")
# => "<html><body><h1>Hello!</h1><p>Greetings from dry-rb!</p></body></html>

依存gem

  • "tilt", "~> 2.0"
  • "dry-core", "~> 0.2"
  • "dry-configurable", "~> 0.1"
  • "dry-equalizer", "~> 0.2"

dry-core(0.4.1)

dry-rbシリーズやROM(rom-rb: Ruby Object Mapper)共通のサポート用モジュールです。

  • キャッシュ
  • クラス属性(下サンプルコード)
  • クラスビルダー
  • 特殊定数(EMPTY_ARRAYUndefinedなど)
  • 機能の非推奨化サポート
  • 指定したタイミングでの拡張の読み込み
# dry-rb
require 'dry/core/class_attributes'

class MyClass
  extend Dry::Core::ClassAttributes

  defines :one, :two

  one 1
  two 2
end

class OtherClass < MyClass
  two 'two'
end

MyClass.one # => 1
MyClass.two # => 2

OtherClass.one # => 1
OtherClass.two # => 'two'

依存gem

  • 'concurrent-ruby', '~> 1.0'

dry-web-roda(0.9.1)

dry-rbとrom-rb(データベース永続化用)をルーティングツリーキットのRoda gemと連携させたシンプルなWebスタックです。

本筋ではありませんが、ちょっとだけ動かしてみました。
以下を実行すると、Railsっぽくプロジェクトが生成されます(構成はだいぶ違いますが)。

gem install dry-web-roda
dry-web-roda new my_app

bundle install --path vendor/bundleしてからshotgun -p 3001 -o 0.0.0.0 config.ruするとhttp://0.0.0.0:3001/でアプリが開きました。shotgunって何だろうと思ったら、Rackを自動でリロードするgemでした。

依存gem

  • "dry-configurable", "~> 0.2"
  • "inflecto", "~> 0.0"
  • "roda", "~> 2.14"
  • "roda-flow", "~> 0.3"
  • "thor", "~> 0.19"

関連記事

Railsコードを改善する7つの素敵なGem(翻訳)

Rails: Form ObjectとVirtusを使って属性をサニタイズする(翻訳)

RubyのModule Builderパターン #2 Module Builderパターンとは何か(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。