跳轉到內容

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://: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://: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://: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://: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://: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://:9001', server)
DRb.thread.join

這是相應的客戶端

require 'drb'
require './foo'

DRb.start_service
obj = DRbObject.new(nil, 'druby://: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 文件

華夏公益教科書