读《Ruby元编程》之eval和hooks

《Ruby元编程》第6章编写代码的代码的学习笔记,主要内容是eval和钩子方法。

从需求说起

本章开始用一个boss提出的问题作为引子,我觉得这个例子很好。简述下问题:

创建一个名为attr_checked的类宏,这个类宏要满足两个条件:

  • 接受属性名和代码块,代码块用于验证属性值的有效性
  • 只有当类包含某个模块,比如checkedAttributes时,才可以使用attr_checked

解决思路可以转化成:

  • 如何定义一个类宏方法?【先用eval定义一个内核方法,然后再改造成类宏】
  • 如何给一个类宏方法添加代码块?【method添加&block参数即可】
  • 定义模块checkedAttributes,通过钩子方法为指定的类添加attr_checked

认识下eval

定义自己的类宏方法前,先来认识下Kernel#eval。

eval是内核方法,参数是一段ruby代码文本。相比instance_eval, class_eval,它只能执行代码字符串。那instance_eval, class_eval是否能执行代码字符串?可以。

看个例子:

my_array = [1,2,3]
my_array.instance_eval "self.reduce(&:+)" #= > 6
eval("my_array.inject {|sum, x| sum + x}") #= > 6

这类代码字符串可以携带一个binding对象,然后在该对象的作用域中执行代码。

  • eval与binding

    Binding是一个用对象表示的作用域,可以通过Kernel#binding 来创建。ruby 还提供了TOPLEVEL_BINDING的预定义常量,表示顶级作用域的Binding对象。

    看代码理解下:

    class MyClass
      def my_method
        @x = 1
        binding
      end
    end
    obj = MyClass.new.my_method
    eval("@x",obj) #= > 1
    eval("self", TOPLEVEL_BINDING) #= > main
    
  • eval的麻烦

    最大的问题是安全性,因为执行的是一段字符串,ruby是不会对字符串进行语法检查的,容易引发代码注入攻击。

    看个例子:

    def explore_array(method)
      code = "['a','b','c'].#{method}"
      eval code
    end
    
    explore_array("include?('a')") #= > true
    explore_array("size") #= > 3
    explore_array("object_id; Dir.glob('*')") #= > 列出了该文件所在目录下的所有文件
    

    这里,运行类似explore_array("object_id; Dir.glob('*')")这样的恶意代码可能会带来意想不到的后果。所以,能用代码块就用代码块。

    如何防止代码注入攻击?

    • 解析所有的代码字符串,不现实

    • 改用动态方法和动态派发来替换eval

      比如上面的例子,explore_array可以这样写:

      def explore_array(method, *arguments)
        ['a','b','c'].send(method, *arguments)
      end
      
      explore_array(:include?, 'a') #= > true
      explore_array(:size) #= > 3
      
  • 污染对象和安全级别

    针对eval存在的安全问题,可以采用一些使它变得安全的方法。

    Ruby会自动把不安全的对象标记为污染对象,你可以通过给$SAFE赋值([0,1, 2,3])来设置安全级别,禁止某些潜在的危险操作,其中0最低,3最高。

    通过使用安全级别,可以为eval方法创造了一个可控的环境,像这样的环境称之为沙盒

用eval来定义add_checked_attr

「其实,这里我并不是很理解,为什么要调用eval来定义这个method,然后又去掉它?为什么不直接用class_eval的方式来定义?难道这样绕一圈,仅仅是为了向读者介绍eval?」

不吐糟,直接看解答:

def add_checked_attr(klass, attr)
  eval "
  class #{klass}
    def #{attr}=(value)
      raise 'Invalid attribute' unless value
      @#{attr} = value
    end
    def #{attr}
      @#{attr}
    end
  end
  "
end

去掉eval,加上block

鉴于eval的问题,改用class_eval来打开类,同时给方法添加一个block

def add_checked_attr(klass, attr, &validation)
  klass.class_eval do

    define_method "#{attr}=" do |value|
      raise 'Invalid attribute' unless validation.call(value) # validation 是一个proc
      instance_variable_set("@#{attr}", value)
    end

    define_method attr do
      instance_variable_get("@#{attr}")
    end

  end
end

改造成类宏

为了让add_checked_attr对所有类都可用,可以将它定义在class中,另修改方法名为attr_checked,去掉类参数。

解答如下:

class Class
  def attr_checked(attr, &validation)
    define_method "#{attr}=" do |value|
      raise 'Invalid attribute' unless validation.call(value) # validation 是一个proc
      instance_variable_set("@#{attr}", value)
    end

    define_method attr do
      instance_variable_get("@#{attr}")
    end
  end
end

最后请上我们的hooks。

钩子方法

看个例子:

module M
  def self.included(othermod)
    puts "M was included into #{othermod}"
  end
end

class C
  include M
end
#= > M was included into C

Module#included在一个module被include时,被自动调用,这种方法称之为钩子方法,因为它们像钩子一样,钩住一个特定的事件。类似的方法还有Module#method_added, Module#extend_object等。

想要捕获单件方法的钩子事件,需要使用BasicObject的singleton_xxx方法,比如singleton_method_added。

针对上面的例子,可以覆写include方法得到一样的效果:

module M;end

class C
  def self.include(*modules)
    puts "#{modules} was included into C"
    super
  end
  include M
end
#= > [M] was included into C

注意这里覆写include中,最后调用了super,不然这个module是不会被include的。这种通过覆写添加额外功能的效果,也可以通过环绕别名的方式实现。

包含并扩展类的方式就用到了钩子,看个例子:

module M
  def self.included(klass)
    klass.extend ClassMethods
  end
end

module ClassMethods
  def my_method
    puts "it could be a class method"
  end
end

class D
  include M
end

D.my_method #= > it could be a class method

类D包含了模块M,调用了M的钩子方法:included, 这个钩子方法接着用module ClassMethods扩展了类D,使得module ClassMethods的my_method 成为了类D的类方法。

Rails中ActiveSupport有一个module Concern,就封装了包含并扩展的技巧。

使用这种技巧,完成最后一步。

module CheckedAttributes
  def self.included(base)
    base.extend ClassMethods
  end

  module ClassMethods
    def attr_checked(attr, &validation)
      define_method "#{attr}=" do |value|
        raise 'Invalid attribute' unless validation.call(value) # validation 是一个proc
        instance_variable_set("@#{attr}", value)
      end

      define_method attr do
        instance_variable_get("@#{attr}")
      end
    end
  end
end

OK !

后记

整本书到这里,基本算是走完了大半了,后面第二部分是有关rails中的元编程,篇幅就相对而言少了些,作者挑了几个板块说一下。我的感觉是,如果前面的六章理解的七七八八了,可以看一些gem的源代码,有利于加深理解。你会在其中发现很多元编程的身影。