LoginSignup
14
10

More than 3 years have passed since last update.

rubyでいいかんじにDIするhabuの紹介

Last updated at Posted at 2019-05-28

TL;DR

rubyでいい感じにDIするgemを作りました。使い方を紹介します。いいなと思ったらStarください。
https://github.com/hanachin/habu

ユーザーがある。

user.rb
User = Struct.new(:name)

新しくユーザーを作るサービスがある。これはuser_repositoryに依存している。コンストラクタ(#initialize)を@Injectでアノテーションする。

new_user_service.rb
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する。

app.rb
require 'habu/setup'
require_relative 'user'
require_relative 'new_user_service'

DIコンテナを作成する。

app.rb
# Create a new container
container = Habu::Container.new

Habu::Container#[]を呼び出しuser_repositoryのつくりかたをブロックで定義する。

app.rb
# Register user_repository service by passing the block as service factory
container[:user_repository] { User }

Habu::Container#newUserServiceクラスを渡すとuser_repositoryをコンストラクタ注入する。いい感じにうごく。

app.rb
# 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を参照できる。

app.rb
# 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

ブロックなしでよびだす、いい感じに動く。

app.rb
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

inject_annotation.rb
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だと思います。

get_inject_location_by_trace_point.rb
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.rb
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_to_get_ast.rb
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_call_super.rb
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

これのinstallhabu/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/

使用例

所感

  • 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

14
10
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
14
10