跳转到内容

Ruby 编程/标准库/DRb

来自维基教科书,开放的书籍,开放的世界

分布式 Ruby (DRb) 通过实现 远程过程调用 允许 Ruby 程序之间进行进程间通信。

分布式 Ruby 为 Ruby 启用了远程方法调用。它是标准库的一部分,因此您可以预期它在大多数使用 MRI Ruby 的系统上安装。由于底层对象序列化依赖于 Marshal,它是在 C 中实现的,因此可以预期到很好的速度。

让我们从一个简单的例子开始,这样这个模块的使用就变得清晰了。

这是 server.rb,我们在这里创建一个对象的单个实例(在本例中为 Hash)并在 TCP 端口 9000 上共享它。

# Load the DRb library
require 'drb'

# Create the front object
myhash = { counter: 0 }
def myhash.inc(elem)
  self[elem] += 1
end

# Start the service
DRb.start_service('druby://127.0.0.1:9000', myhash)

# Make the main thread wait for the DRb thread,
# otherwise the script execution would already end here.
DRb.thread.join

这是 client.rb

require 'drb'

# We create a DRbObject instance that is connected to our server.
# All methods executed on this object will be executed to the remote one.
obj = DRbObject.new(nil, 'druby://127.0.0.1:9000')

puts obj[:counter]
obj.inc(:counter)
puts obj[:counter]

puts "Last access time = #{obj[:lastaccess]}"
obj[:lastaccess] = Time.now

在一个 shell 会话中启动服务器(或在后台),并在另一个会话中运行客户端几次

$ ruby client.rb
0
1
Last access time = 
$ ruby client.rb
1
2
Last access time = Fri Oct 22 22:23:59 BST 2004

服务器和客户端不需要在同一台机器上运行。如果您希望服务器监听所有接口(因此也监听远程连接),则需要在 server.rb 中将 'localhost' 更改为 '0.0.0.0'。然后,需要通过在 client.rb 中用服务器的 IP(或主机名)替换 'localhost' 来配置客户端以连接到远程服务器。

即使只是这个简单的例子也极其强大。上面的对象可以用作 Web 服务器上会话数据的共享数据存储。每个网页请求都可以查找和存储此共享对象中的信息。它适用于 Web 页面是通过独立的 CGI 脚本、Webrick 线程、Apache mod_ruby 还是 fcgi/mod_fastcgi 提供服务的。它甚至适用于您拥有 Web 服务器集群的情况。此外,如果重新启动 Apache,会话数据不会丢失。

DRb 的设计实际上相当复杂而优雅,但基本原理非常简单

DRb 将方法调用打包成一个包含方法名和参数的数组,使用 Marshal 库将其转换为字节流,并将其发送到服务器。然后,服务器在前端对象上执行调用以确定结果。收到的返回值和最终异常被放入另一个数组中,转换为字节流并返回给客户端。

由于 DRb 是用 Ruby 编写的,您可以查看代码,其中包含大量注释和示例。您可以在系统上的 /usr/local/lib/ruby/2.1/drb/drb.rb 等位置找到它,或者您可以在 此处 找到文档和示例的解析版本。

安全性

[编辑 | 编辑源代码]

如果您使用 DRb 对象来存储会话数据,请确保只有 Web 服务器可以联系您的 DRb 对象,并且它不能从外部直接访问,否则不受欢迎的访客可以直接操纵其内容。如果您所有客户端都在同一台机器上,则可以将其绑定到 localhost (127.0.0.1);否则,您可以将其放在单独的专用网络上,使用防火墙规则或 DRb ACL 来阻止来自不受欢迎客户端的访问。在调用 DRb.start_service 之前执行此操作很重要。

ACL 的示例用法

require 'drb'
require 'drb/acl'

acl = ACL.new(%w{deny all
                allow localhost
                allow 192.168.1.*})
DRb.install_acl(acl)

DRb.start_service('druby://127.0.0.1:9000', obj)

请注意,每个对象都包含方法,如果被恶意方调用,这些方法可能非常危险。其中一些是私有的(例如 exec、system),DRb 阻止调用这些方法,但还有其他一些公共方法同样危险(例如 send、instance_eval、instance_variable_set)。例如,考虑 obj.instance_eval("`rm -rf /*`")

因此,与整个互联网共享对象是一件危险的事情。如果您要这样做,那么您应该至少使用 $SAFE=1 运行,并且您应该从一个空白状态启动您的对象,而不包含这些危险的方法。您可以像这样实现

class BlankSlate
  safe_methods = [:__send__, :__id__, :object_id, :inspect, :respond_to?, :to_s]
  (instance_methods - safe_methods).each do |method|
    undef_method method
  end
end

class MyService < BlankSlate
  def increase_count
    @count ||= 0
    @count += 1
  end
end

DRb.start_service('druby://127.0.0.1:9000', MyService.new)

请注意,此示例不使用 initialize() 来将 @count 设置为 0。如果它这样做了,客户端将能够通过调用 initialize 方法来重置 @count。

以下是来自 Evil-Ruby 的另一种实现。

# You can derivate your own Classes from this Class
# if you want them to have no preset methods.
#
#   klass = Class.new(KernellessObject) { def inspect; end }
#   klass.new.methods # raises NoMethodError
#
# Classes that are derived from KernellessObject
# won't call #initialize from .new by default.
#
# It is a good idea to define #inspect for subclasses,
# because Ruby will go into an endless loop when trying
# to create an exception message if it is not there.
class KernellessObject
  class << self
    def to_internal_type; ::Object.to_internal_type; end

    def allocate
      obj = ::Object.allocate
      obj.class = self
      return obj
    end

    alias :new :allocate
  end

  self.superclass = nil
end

此外,您可能希望构建一个包装器对象并共享它,而不是共享您的原始对象。包装器对象可以有一组有限的方法(仅您真正想共享的方法),验证传入数据的参数,并在数据被清理后委派给另一个对象。

线程安全性

[编辑 | 编辑源代码]

通过 DRb 共享的对象收到的每个传入方法调用都在一个新线程中执行。如果您考虑一下,这是非常必要的;可能有许多客户端,服务器无法控制客户端何时决定向其发送方法调用。DRb 不序列化请求,因此一个客户端无法阻止其他客户端。

但是,这意味着您必须对 DRb 对象进行与在任何其他线程应用程序中一样多的谨慎。例如,考虑一下如果两个客户端同时决定运行会发生什么

obj[:counter] = obj[:counter] + 1

在同一时间。可能发生两个客户端都会检索 obj[:counter] 并看到相同的值(例如 100),然后独立地加 1,然后都写回 101。如果您希望 :counter 生成唯一的序列号,这可能不是您想要的。

即使页面顶部显示的 myhash.inc 方法也会遇到同样的问题,因为两个客户端可能会决定在同一时间调用 inc(:counter),导致服务器上的两个线程遇到相同的竞争条件。解决方法是用 Mutex 保护递增操作

require 'drb'
require 'thread'

class MyStore
  def initialize
    @hash = { :counter=>0 }
    @mutex = Mutex.new
  end
  def inc(elem)
    # The mutex makes sure that in case there being another thread running the
    # block given to the synchronize method, the current thread will wait until the
    # other thread finishes execution of this part, before it runs the block itself.
    @mutex.synchronize do
      self[elem] = self[elem].succ
    end
  end
  def [](elem)
    @hash[elem]
  end
  def []=(elem,value)
    @hash[elem] = value
  end
end

mystore = MyStore.new
DRb.start_service('druby://127.0.0.1:9000', mystore)
DRb.thread.join

不可复制对象

[编辑 | 编辑源代码]

为什么客户端运行 DRb.start_service

一个很好的问题,它引导我们了解 DRb 的另一个有趣方面。

在正常操作中,DRb 将使用 Marshal 发送方法调用的参数;当它们在服务器端被取消封送时,它将拥有这些对象的副本。对于从方法返回的结果也是如此;它将被封送、发回,客户端将拥有该对象的副本。

在许多简单情况下,对象的复制不是问题,但有几种情况下可能会出现问题

  • 如果服务器对它接收到的本地副本进行更改,那么客户端将不会看到该更改。
  • 参数或响应对象可能非常大,您可能不想来回发送它们(例如,一个对象,它包含对其他对象的引用,形成一棵树)
  • 某些类型的对象根本无法封送:它们包括文件、套接字、proc/块、具有单例类的对象,以及任何间接包含这些对象的(例如,在实例变量中)对象。

在这些情况下,DRb 可以改为发送包含联系信息的“代理对象”,以允许通过 DRb 调用原始对象:即,可以找到原始对象的主机名和端口。这对于任何无法封送的对象都是自动完成的,或者您可以通过在您的对象中包含 DRbUndumped 来强制执行。

我们如何证明这一点?好吧,考虑在以下文件 foo.rb 中定义的类

class Foo
  def initialize(x)
    @x = x
  end
  def inc
    @x = @x.succ
  end
end

现在,让我们创建一个接受对象的服务器并调用它的 'inc' 方法

require 'drb'
require './foo'

class Server
  def update(obj)
    obj.inc
  end
end

server = Server.new
DRb.start_service('druby://127.0.0.1:9001', server)
DRb.thread.join

这是相应的客户端

require 'drb'
require './foo'

DRb.start_service
obj = DRbObject.new(nil, 'druby://127.0.0.1:9001')
a = Foo.new(10)
b = Foo.new(20)
puts a
puts b
obj.update(a)
obj.update(b)
puts a
puts b

现在,如果我们运行它,会发生什么

$ ruby client2.rb
#<Foo:0x817e760 @x=10>
#<Foo:0x817e74c @x=20>
#<Foo:0x817e760 @x=10>
#<Foo:0x817e74c @x=20>

糟糕。我们传递了我们的对象 'a' 和 'b',但由于它们被复制到服务器上,只有本地副本被 'inc' 更新。客户端上的对象不受影响。

现在尝试像这样修改 Foo 的定义

class Foo
  include DRbUndumped
  
  # ... same as before

或者,您可以像这样修改客户端程序

a = Foo.new(10)
b = Foo.new(20)
a.extend DRbUndumped
b.extend DRbUndumped

# ... same as before

现在结果是我们希望看到的

$ ruby client2.rb
#<Foo:0x817e648 @x=10>
#<Foo:0x817e634 @x=20>
#<Foo:0x817e648 @x=11>
#<Foo:0x817e634 @x=21>

所以发生的事情是,我们没有封送 Foo 的实例,而是封送了构建代理对象所需的信息:它包含客户端的主机名、端口和对象 ID,可用于与原始对象进行通信。当我们将 'a' 的代理对象传递给服务器时,它调用 obj.inc,'inc' 方法调用通过 DRb 传回到客户端机器,原始对象 'a' 实际上位于那里。您实际上已经创建了对该对象的远程“引用”,它可以像普通对象引用一样传递,只不过它可以从一台机器传递到另一台机器。通过此引用的方法调用会影响同一个对象。

现在,这就是为什么客户端程序需要运行 DRb.start_service 的原因 - 即使从我们的角度来看它是“客户端”,也可能存在生成这些 DRb 代理“引用”的方法调用参数,此时客户端也会成为这些对象的服务器。

我们在这里没有指定主机或端口,因此 DRb 在系统上选择任何空闲的 TCP 端口,主机是系统根据 'gethostname' 调用确定的主机名 - 例如,如果机器名为 server.example.com,那么 DRb 可能会选择 druby://server.example.com:45123

但是,当两台机器之间存在防火墙时,这些双向方法调用可能会成为问题。您可以在 DRb.start_service 中选择客户端的固定端口,而不是动态选择端口;这样可以让您在防火墙中为 DRb 打开一个洞。但是,如果您在 NAT 防火墙后面,它几乎肯定无法正常工作。

通过 SSH 运行 DRb

[edit | edit source]

解决通过防火墙进行双向方法调用的问题的一种方法是通过 SSH 运行 DRb。您不仅可以通过防火墙进行单个出站 TCP 连接获得双向操作;您的方法调用还被安全加密!

以下是设置方法。

  1. 为客户端端选择一个端口(例如 9000)和一个端口(例如 9001)
  2. 建立一对隧道 ssh 连接:客户端的端口 9001 重定向到服务器端的端口 9001,而服务器端的端口 9000 重定向到客户端端的端口 9000。
    $ ssh -L9001:127.0.0.1:9001 -R9000:127.0.0.1:9000 server.example.com
    -L 标志请求将本地(客户端)端端口 9001 的连接重定向到 ssh 隧道,然后重新连接到服务器端的 127.0.0.1:9001。-R 标志请求将远程(服务器)端端口 9000 的连接重定向回 ssh 隧道,然后连接到客户端端的 127.0.0.1:9000。
  3. 在服务器端,像往常一样执行 DRb.start_service('druby://127.0.0.1:9001', a)
  4. 在客户端端,执行 DRb.start_service('druby://127.0.0.1:9000'),而不是只执行 DRb.start_service。这为我们提供了固定端口号以供使用。
  5. 在客户端端,连接到远程对象作为
obj = DRbObject.new(nil, 'druby://127.0.0.1:9001')

瞧,您已启动并运行。您可以尝试上面提到的 DRbUndumped 示例,客户端位于 NAT 防火墙后面。另外请注意,ssh -L 和 -R 选项默认绑定到 127.0.0.1,因此其他机器上的用户无法连接到隧道端点(当然,同一台机器上的其他用户可以这样做)。

除了从命令行建立 SSH 连接外,还可以使用 Net::SSH,这是一个纯 Ruby 实现的 SSH。如果您还没有安装 Net::SSH,请使用 gem install net-ssh 安装它。要创建连接,请在使用 DRb 之前执行以下操作

require 'net/ssh'
require 'thread'

channel_ready = Queue.new
Thread.new do
  Net::SSH.start('ssh.example.com','username',:port=>22) do |session|
    session.forward.local( 9001, '127.0.0.1', 9001)
    session.forward.remote( 9000, '127.0.0.1', 9000 )
    
    session.open_channel do |channel|
    end
    channel_ready << true

    session.loop
  end
end
channel_ready.pop

之后,您可以在主线程中执行 DRb 代码,就像在之前的 SSH 示例中一样。channel_ready Queue 只会强制主线程等待通道打开。

注意:在使用 SSH 和 DRb 时,请不要将 'localhost' 替换为 '127.0.0.1',这会导致连接被拒绝。

通过 SSL 运行 DRb

[edit | edit source]

SSL 是另一种安全加密连接的方法(注意:SSL 和 SSH *不是*同一件事!)

在线教程:HTTP://segment7.net/projects/ruby/drb/DRbSSL/

通过防火墙运行 DRuby - 仅 Ruby 解决方案(HTTP://www.ruby-talk.org/cgi-bin/scat.rb/ruby/ruby-talk/89976)通常客户端安装了防火墙,因此标准 DRb 将无法进行回调,从而使 block/io/DRbUndumped?参数变得无用。为了确保 DRb 按正常运行,可以使用HTTP://rubyforge.org/projects/drbfireHTTP://drbfire.rubyforge.org/classes/DRbFire.html

来自文档

  1. 从 require 'drb/drbfire' 开始。
  2. 在指定服务器 URL 时使用 drbfire:// 而不是 druby://。
  3. 在客户端上调用 DRb.start_service 时,将服务器的 uri 指定为 uri(与正常用法不同,正常用法是 *不* 指定 uri)。
  4. 在调用 DRb.start_service 时指定正确的配置,特别是使用哪个角色。服务器:DRbFire::ROLE => DRbFire::SERVER 和客户端:DRbFire::ROLE => DRbFire::CLIENT

简单服务器

require 'drb/drbfire'
front = ['a', 'b', 'c']
DRb.start_service('drbfire://some.server.com:5555', front, DRbFire::ROLE => DRbFire::SERVER)
DRb.thread.join

以及一个简单的客户端

require 'drb/drbfire'
DRb.start_service('drbfire://some.server.com:5555', nil, DRbFire::ROLE => DRbFire::CLIENT)
DRbObject?.new(nil, 'drbfire://some.server.com:5555').each do |e|
  puts e
end
[edit | edit source]

关于 DRb 使用的替代教程

DRb 模块的官方 Ruby 文档

华夏公益教科书