TL;DR
rubyでいい感じにDIするgemを作りました。使い方を紹介します。いいなと思ったらStarください。
https://github.com/hanachin/habu
ユーザーがある。
User = Struct.new(:name)
新しくユーザーを作るサービスがある。これはuser_repository
に依存している。コンストラクタ(#initialize
)を@Inject
でアノテーションする。
class NewUserService
@Inject
def initialize(user_repository)
@user_repository = user_repository
end
def call(*params)
@user_repository.new(*params)
end
end
まずはじめに先頭でrequire "habu/setup"
する。必要なソースをrequire
する。
require 'habu/setup'
require_relative 'user'
require_relative 'new_user_service'
DIコンテナを作成する。
# Create a new container
container = Habu::Container.new
Habu::Container#[]
を呼び出しuser_repository
のつくりかたをブロックで定義する。
# Register user_repository service by passing the block as service factory
container[:user_repository] { User }
Habu::Container#new
にUserService
クラスを渡すとuser_repository
をコンストラクタ注入する。いい感じにうごく。
# Call Habu::Container#new to get instance
new_user = container.new(NewUserService).call("hanachin")
# => #<struct User name="hanachin">
Habu::Container#[]
に渡されたブロックは引数としてHabu::Container
自身をうけとる。
Habu::Container#[]
をブロックなしで呼び出すと定義時に渡されたブロックを呼び出した結果を返す。
これによりnew_user
の定義時にuser_repository
を参照できる。
# Factory block take a container as argument
container[:new_user] do |c|
# You can get the service object by calling Container#[](service_name)
NewUserService.new(c[:user_repository])
end
ブロックなしでよびだす、いい感じに動く。
new_user = container[:new_user].call("hanachin")
# => #<struct User name="hanachin">
DIコンテナをHabu::Container#to_refinements
でRefinements化してusing
する。
@Inject
したコンストラクタがオーバーライドされているので単に.new
するだけで依存性が注入された状態で呼び出せる。
Habu::Container#new
経由でインスタンス化する必要はない。
# Using container as refinements for shorthand for container.new
using container.to_refinements
new_user = NewUserService.new.call("hanachin")
# => #<struct User name="hanachin">
動機
「Ruby on Railsの正体と向き合い方」
https://speakerdeck.com/yasaichi/what-is-ruby-on-rails-and-how-to-deal-with-it?slide=59
https://youtu.be/ecpq0U4zkWE?t=1648
密結合→疎結合に移行できると良いのではみたいな文脈でLaravelのDIコンテナに言及があった。
あるとべんりそうだしつくるか〜。
DIとは
他でいい感じの記事よんでください。
https://scala-text.github.io/scala_text/advanced-trait-di.html#%E4%BE%9D%E5%AD%98%E6%80%A7%E3%81%AE%E6%B3%A8%E5%85%A5%E3%81%A8%E3%81%AF%EF%BC%9F
†闇の動機†
Javaの引数がないアノテーションはrubyだとインスタンス変数なのでSyntax OK
class C
@Inject
def initialize(c)
@c = c
end
end
% ruby -c inject_annotation.rb
Syntax OK
インスタンス変数による注釈が実装できるとPythonのデコレータの実装もできる。
Rubyをキメると気持ちいい
@Inject
でRubyキメたい。
†注釈術士†になりたい。
rubyで@Inject
の位置を取得する
TracePoint
まずみなさんが試すのがTracePointだと思います。
TracePoint.trace(:line) do |tp|
raise if File.read(tp.path).lines[tp.lineno-1].match?(/@Inject/)
end
class C
@Inject
def initialize(c)
@c = c
end
end
残念ながら取れません。
% ruby get_inject_location_by_trace_point.rb
%
InstructionSequence
TracePointを使っても実行時に取れないということは? InstructionSequenceを覗いてみましょう。
値を返すときはgetinstancevariable
がある。
% ruby --dump=insns -e '@Inject'
== disasm: #<ISeq:<main>@-e:1 (1,0)-(1,7)> (catch: FALSE)
0000 getinstancevariable :@Inject, <is:0> ( 1)[Li]
0003 leave
返さないときはgetinstancevariable
がない。
% ruby --dump=insns -e '@Inject; nil'
== disasm: #<ISeq:<main>@-e:1 (1,0)-(1,12)> (catch: FALSE)
0000 putnil ( 1)[Li]
0001 leave
残念ながら取れません。
RubyVM::AbstractSyntaxTree
InstructionSequenceにコンパイルされる前なら取れる。
% ruby --dump=parsetree -e '@Inject; nil'
###########################################################
## Do NOT use this node dump for any purpose other than ##
## debug and research. Compatibility is not guaranteed. ##
###########################################################
# @ NODE_SCOPE (line: 1, location: (1,0)-(1,12))
# +- nd_tbl: (empty)
# +- nd_args:
# | (null node)
# +- nd_body:
# @ NODE_BLOCK (line: 1, location: (1,0)-(1,12))
# +- nd_head (1):
# | @ NODE_IVAR (line: 1, location: (1,0)-(1,7))*
# | +- nd_vid: :@Inject
# +- nd_head (2):
# @ NODE_NIL (line: 1, location: (1,9)-(1,12))*
InstructionSequenceのコンパイル時にフックを仕掛けてASTを取得する
ASTの状態ならアノテーション(と勝手に呼んでいるただのインスタンス変数)が取れることがわかりました。
コンパイルされる前に @Inject
を取る方法を紹介します。
RubyVM::InstructionSequence.load_iseq
を使います。
参考: YARV Maniacs 【第 13 回】 事前コンパイルへの道
.load_iseq
は引数でファイル名を取ります。
次のような感じでファイル名を出力したあとRubyVM::InstructionSequence.compile_file
でファイルをコンパイルし返す処理をprepend
で差し込んでみます。
iseq_patch = Module.new do
def load_iseq(fname)
p fname
RubyVM::InstructionSequence.compile_file(fname)
end
end
RubyVM::InstructionSequence.singleton_class.prepend(iseq_patch)
pp true
フックできたようですね。
% ruby iseq_patch.rb
"/home/sei/.rbenv/versions/myruby/lib/ruby/2.7.0/pp.rb"
"/home/sei/.rbenv/versions/myruby/lib/ruby/2.7.0/prettyprint.rb"
true
ASTを取得してみます。
iseq_patch = Module.new do
def load_iseq(fname)
p RubyVM::AbstractSyntaxTree.parse_file(fname)
RubyVM::InstructionSequence.compile_file(fname)
end
end
RubyVM::InstructionSequence.singleton_class.prepend(iseq_patch)
pp true
無事ASTがとれました。
% ruby iseq_patch_to_get_ast.rb
#<RubyVM::AbstractSyntaxTree::Node:SCOPE@1:0-593:3>
#<RubyVM::AbstractSyntaxTree::Node:SCOPE@1:0-556:3>
true
ちなみにsuper
は呼べません。
iseq_patch = Module.new do
def load_iseq(fname)
super
end
end
RubyVM::InstructionSequence.singleton_class.prepend(iseq_patch)
pp true
% ruby iseq_patch_call_super.rb
Traceback (most recent call last):
4: from iseq_patch_call_super.rb:8:in `<main>'
3: from <internal:prelude>:210:in `pp'
2: from /home/sei/.rbenv/versions/myruby/lib/ruby/2.7.0/rubygems/core_ext/kernel_require.rb:54:in `require'
1: from /home/sei/.rbenv/versions/myruby/lib/ruby/2.7.0/rubygems/core_ext/kernel_require.rb:54:in `require'
iseq_patch_call_super.rb:3:in `load_iseq': super: no superclass method `load_iseq' for RubyVM::InstructionSequence:Class (NoMethodError)
ASTから@Inject
されているクラスの名前を取る
なおnestingや上の階層のモジュールは考慮しないものとする。
Refinementsでおもむろに探索用のメソッドを生やすとべんり。
参考: akaza/ast_ext.rb at master · pocke/akaza
module Habu
module AnnotationCollectorHelper
refine(Object) do
def traversable?
false
end
end
refine(RubyVM::AbstractSyntaxTree::Node) do
def traversable?
true
end
def traverse(&block)
block.call(self)
children.each { |child| child.traverse(&block) if child.traversable? }
end
end
end
end
case in用のdeconstruct
を生やしていい感じに探索する。
module Habu
module AnnotationCollectorHelper
refine(RubyVM::AbstractSyntaxTree::Node) do
def deconstruct
[type, *children]
end
def each_constructor_annotations(&block)
klass_name = nil
inject_annotation_found = false
traverse do |ast|
case ast
in :CLASS, [:COLON2, nil, klass_name], *_
# TODO: Support namespace
in :IVAR, :@Inject
inject_annotation_found = true
in :DEFN, :initialize, *_ if inject_annotation_found == true
block.call(klass_name)
inject_annotation_found = false
else
inject_annotation_found = false
end
end
end
end
end
end
こうすると直前に出てきたクラス定義のクラス名がeach_constructor_annotations
にわたしたブロックの引数として渡される。
あとはいい感じに溜め込んでいけばよいだけ。load_iseqの中からAnnnotationCollector
のインスタンスのインスタンス変数につっこんでいく。define_method
とmethod reference operatorを組み合わせるとこういう、prependしたモジュールから他のオブジェクトのインスタンスに副作用をおこすようなモジュールが手軽にかけてべんり。
require 'habu/annotation_collector_helper'
module Habu
class AnnotationCollector
using ::Habu::AnnotationCollectorHelper
attr_reader :constructor_annotations
def initialize
@constructor_annotations = []
@iseq_interceptor = new_iseq_interceptor
end
def install
RubyVM::InstructionSequence.singleton_class.prepend(@iseq_interceptor)
end
private
def add_constructor_annotation(klass)
@constructor_annotations << klass
end
def new_iseq_interceptor
add_constructor_annotation = self.:add_constructor_annotation
Module.new do
define_method :load_iseq do |fname|
ast = RubyVM::AbstractSyntaxTree.parse_file(fname)
ast.each_constructor_annotations(&add_constructor_annotation)
RubyVM::InstructionSequence.compile_file(fname)
end
end
end
end
end
これのinstall
はhabu/setup
のなかで行う。アノテーションの収集は実質グローバルなのでHabu.annotation_collector
経由でどこからでもアクセスできるようにしておく。
require 'habu'
module Habu
module Setup
class << self
def install(annotation_collector)
::Habu.annotation_collector = annotation_collector
::Habu.annotation_collector.install
end
end
end
end
Habu::Setup.install(::Habu::AnnotationCollector.new)
DIコンテナの実装
定義・参照
雑にこうした。#[]
は友達。
module Habu
class Container
def initialize
@factories = {}
end
def [](key, &block)
if block
@factories[key] = block
else
@factories.fetch(key).call(self)
end
end
end
end
参照方法についてはHabu::Container#cache
を用意して常に同じインスタンスを参照できるようにしたり、メソッドも合わせて定義したりとかやり方が色々ありそう。定義の方法についてはブロック渡す一択でよいと思う。#[]
以外のエイリアスを与えるのもあり。
インスタンス化
メソッド引数との一致を見る。
module Habu
class Container
def new(klass, &block)
params = klass.instance_method(:initialize).parameters
klass.new(*params.filter_map { @1 == :req && self[@2] }, &block)
end
end
end
コンテナ経由でのインスタンス化は面倒
直接newして手軽に使えたほうがべんりなので@Inject
されてるクラスのnew
を全部上書きしてコンテナに移譲していく。
module Habu
class Container
def to_refinements
refinements = Module.new
refinements.instance_exec(self) do |container|
Habu.annotation_collector.constructor_annotations.each do |klass_name|
klass = const_get(klass_name)
refine(klass.singleton_class) do
define_method(:new) do |&block|
container.new(klass, &block)
end
end
end
end
refinements
end
end
end
将来的にやりたいこと
-
@Inject
されてるメソッドに関しては静的解析ではなくTracePointを使ってメソッド定義時に動的に取得したい - コンストラクタインジェクション以外のサポート
- メソッド呼び出し形式での参照のサポート
- シングルトンで参照するようなコンテナの追加
真似をしたいrubyでDIライブラリ
Habu実装したあとから調べてみると dry-container/dry-auto_inject がよさそうだた。
やっぱりcontainerに参照できるメソッドを生やすのはよさそう。API真似していきたい。
ただし個人的にはmixinしなくてもdelegate_to :container
みたいな形で移譲できるだけで十分なんじゃないかなと思う。
https://dry-rb.org/gems/dry-container/
https://dry-rb.org/gems/dry-auto_inject/
使用例
- dry-containerとdry-auto_injectでDIコンテナを作る - blog.kymmt.com
- Architecture of hanami applications - RubyKaigi 2018
所感
- habu gemを使うとRefinementsでいいかんじに依存性を注入できる
- Rubyのインスタンス変数は実質アノテーション
- Rubyではソースコード中に書かれた式がRubyVMで実行されるとは限らない
- RubyVMで実行されないような式もASTなら取れる
-
RubyVM::InstructionSequence.load_iseq
を上書きするとRubyVMがソースコードをコンパイルする前にフックできる -
#to_refinements
でRefinementsのモジュールを動的に作って返す手法はRubyキメた感があってよい
最後に
rubyで@Inject
でキメるgemを作ったので使ってみてください。いいなとおもったらStarボタンよろしくお願いします。
https://github.com/hanachin/habu
Qiitaの他にGitHubもやっているのでよかったらフォローしてください。
https://github.com/hanachin