こんにちは、hachi8833です。
先ごろRubyWorld ConferenceでRuby Prize 2017を受賞された@solnicことPiotr Solnicaさんが近年力を入れているのがdry-rbシリーズです。
@solnicさんはあのVirtusの作者でもあるのですが、現在VirtusのREADMEには、Virtusの機能を分割改良してdry-rbにしたとあります。
Virtusを作ったことで、Rubyにおけるデータの扱い、中でもcoercion/型安全/バリデーションについて多くのことを学べました。このプロジェクトは成功を収めて多くの人々が役立ててくれましたが、私はさらによいものを作ることを決心しました。その結果、dry-types、dry-struct、dry-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シリーズの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::Time
やTypes::Maybe::Strict::Int
やTypes::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_ARRAY
やUndefined
など) - 機能の非推奨化サポート
- 指定したタイミングでの拡張の読み込み
# 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"