Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Ruby method interception

I want to intercept method calls on a ruby-class and being able to do something before and after the actual execution of the method. I tried the following code, but get the error:

MethodInterception.rb:16:in before_filter': (eval):2:inalias_method': undefined method say_hello' for classHomeWork' (NameError) from (eval):2:in `before_filter'

Can anybody help me to do it right?

class MethodInterception

  def self.before_filter(method)
    puts "before filter called"
    method = method.to_s
    eval_string = "
      alias_method :old_#{method}, :#{method}

      def #{method}(*args)
        puts 'going to call former method'
        old_#{method}(*args)
        puts 'former method called'
      end
    "
    puts "going to call #{eval_string}"
    eval(eval_string)
    puts "return"
  end
end

class HomeWork < MethodInterception
  before_filter(:say_hello)

  def say_hello
    puts "say hello"
  end

end
like image 984
elasticsecurity Avatar asked Sep 23 '10 14:09

elasticsecurity


2 Answers

I just came up with this:

module MethodInterception
  def method_added(meth)
    return unless (@intercepted_methods ||= []).include?(meth) && !@recursing

    @recursing = true # protect against infinite recursion

    old_meth = instance_method(meth)
    define_method(meth) do |*args, &block|
      puts 'before'
      old_meth.bind(self).call(*args, &block)
      puts 'after'
    end

    @recursing = nil
  end

  def before_filter(meth)
    (@intercepted_methods ||= []) << meth
  end
end

Use it like so:

class HomeWork
  extend MethodInterception

  before_filter(:say_hello)

  def say_hello
    puts "say hello"
  end
end

Works:

HomeWork.new.say_hello
# before
# say hello
# after

The basic problem in your code was that you renamed the method in your before_filter method, but then in your client code, you called before_filter before the method was actually defined, thus resulting in an attempt to rename a method which doesn't exist.

The solution is simple: Don't Do That™!

Well, okay, maybe not so simple. You could simply force your clients to always call before_filter after they have defined their methods. However, that is bad API design.

So, you have to somehow arrange for your code to defer the wrapping of the method until it actually exists. And that's what I did: instead of redefining the method inside the before_filter method, I only record the fact that it is to be redefined later. Then, I do the actual redefining in the method_added hook.

There is a tiny problem in this, because if you add a method inside of method_added, then of course it will immediately get called again and add the method again, which will lead to it being called again, and so on. So, I need to guard against recursion.

Note that this solution actually also enforces an ordering on the client: while the OP's version only works if you call before_filter after defining the method, my version only works if you call it before. However, it is trivially easy to extend so that it doen't suffer from that problem.

Note also that I made some additional changes that are unrelated to the problem, but that I think are more Rubyish:

  • use a mixin instead of a class: inheritance is a very valuable resource in Ruby, because you can only inherit from one class. Mixins, however, are cheap: you can mix in as many as you want. Besides: can you really say that Homework IS-A MethodInterception?
  • use Module#define_method instead of eval: eval is evil. 'Nuff said. (There was absolutely no reason whatsoever to use eval in the first place, in the OP's code.)
  • use the method wrapping technique instead of alias_method: the alias_method chain technique pollutes the namespace with useless old_foo and old_bar methods. I like my namespaces clean.

I just fixed some of the limitations I mentioned above, and added a few more features, but am too lazy to rewrite my explanations, so I repost the modified version here:

module MethodInterception
  def before_filter(*meths)
    return @wrap_next_method = true if meths.empty?
    meths.delete_if {|meth| wrap(meth) if method_defined?(meth) }
    @intercepted_methods += meths
  end

  private

  def wrap(meth)
    old_meth = instance_method(meth)
    define_method(meth) do |*args, &block|
      puts 'before'
      old_meth.bind(self).(*args, &block)
      puts 'after'
    end
  end

  def method_added(meth)
    return super unless @intercepted_methods.include?(meth) || @wrap_next_method
    return super if @recursing == meth

    @recursing = meth # protect against infinite recursion
    wrap(meth)
    @recursing = nil
    @wrap_next_method = false

    super
  end

  def self.extended(klass)
    klass.instance_variable_set(:@intercepted_methods, [])
    klass.instance_variable_set(:@recursing, false)
    klass.instance_variable_set(:@wrap_next_method, false)
  end
end

class HomeWork
  extend MethodInterception

  def say_hello
    puts 'say hello'
  end

  before_filter(:say_hello, :say_goodbye)

  def say_goodbye
    puts 'say goodbye'
  end

  before_filter
  def say_ahh
    puts 'ahh'
  end
end

(h = HomeWork.new).say_hello
h.say_goodbye
h.say_ahh
like image 141
Jörg W Mittag Avatar answered Nov 16 '22 23:11

Jörg W Mittag


Less code was changed from original. I modified only 2 line.

class MethodInterception

  def self.before_filter(method)
    puts "before filter called"
    method = method.to_s
    eval_string = "
      alias_method :old_#{method}, :#{method}

      def #{method}(*args)
        puts 'going to call former method'
        old_#{method}(*args)
        puts 'former method called'
      end
    "
    puts "going to call #{eval_string}"
    class_eval(eval_string) # <= modified
    puts "return"
  end
end

class HomeWork < MethodInterception

  def say_hello
    puts "say hello"
  end

  before_filter(:say_hello) # <= change the called order
end

This works well.

HomeWork.new.say_hello
#=> going to call former method
#=> say hello
#=> former method called
like image 24
Shinya Avatar answered Nov 16 '22 22:11

Shinya