跨越邊界: 對 Rails 進行擴展 分析 acts_as 插件

簡介: Java™ 編程語言一直以來都是一個很出色的“熔爐”,它具有用於集成的豐富和強大的功能 —— 從用於集成企業庫的依賴性注入容器,到 Enterprise JavaBeans (EJB) 技術,再到 Eclipse 的組件模型。通過使用大量這樣的理念和架構,Java 開發人員率先採用新的方法將完全不同的軟件庫和組件組合成一個整體。但是 Java 開發人員並沒有對優秀的集成技術造成壟斷。本文通過審視一個名爲 acts_as_state_machine 的流行插件來了解 Ruby on Rails 插件的工作原理。

 

 

在我撰寫這篇文章的時候,德克薩斯州和俄克拉荷馬州正從一場持久的暴風雪的影響中慢慢緩解過來。司機們又開始出現在馬路上,他們不僅僅擔心路面上的冰,還害怕其他性急的駕車者。三天的 “冬眠” 之後,我的生活開始慢慢恢復正常。但當從使用 Java 語言轉換到使用 Ruby 後,不久,我便體驗到了另一種“寒意”。當我使用 Java 項目時,總是可以找到能夠解決一些小範圍問題的特殊的 Spring 庫或 Eclipse 組件。當 Ruby on Rails 剛出現的時候,我經常需要親自編寫它。令人高興的是,這種“寒意”也開始慢慢消失,這要歸功於一種有效的插件架構,很多人使用它來對 Rails 進行擴展。

關於本系列

在 跨越邊界系列 文章中,作者 Bruce Tate 提出這樣一種觀點,即當今的 Java 程序員們可以通過學習其他方法和語言很好地武裝自己。自從 Java 技術明顯成爲所有開發項目最好的選擇以來,編程前景已經發生了改變。其他框架影響着 Java 框架的構建方式,從其他語言學到的概念也可以影響 Java 編程。您編寫的 Python(或 Ruby、Smalltalk 等語言)代碼可以改變編寫 Java 代碼的方式。

本系列介紹與 Java 開發完全不同的編程概念和技術,但是這些概念和技術也可以直接應用於 Java 開發。在某些情況下,需要集成這些技術來利用它們。在其他情況下,可以直接應用概念。具體的工具並不重要,重要的是其他語言和框架可以影響 Java 社區中的開發人員、框架,甚至是基本方式。

如果您曾經花了些時間研究過 Rails,那麼一定會注意到 ActiveRecord 中的 acts_as 命令。儘管 ActiveRecord 與處理持久性有關,您總是希望將行爲添加到類中,而不僅僅進行數據庫存儲和檢索。比方說,通過使用 acts_as_tree,可以將類似樹的行爲添加到具有 parent_id 屬性的類中。通過使用 ActiveRecord 模型中的 acts_as_tree,可以動態地添加方法來管理樹,比如檢索父記錄或子記錄的方法。在過去一個月裏,我可以找到能夠解決投票、版本化、Ajax、複合鍵以及不受基本 Rails 支持的所有特性的插件。

Rails 中的擴展模型是使用 Ruby 語言構建在特性之上的,與使用 Java 語言構建的模型完全不同。在本文中,我將剖析 acts_as 插件,您可以瞭解擴展模型的內部。我沒有構建端對端場景,而是提供了部分產品示例以涵蓋更多方面,並使您認識真實的插件以及如何在真正的生產代碼中使用它們。

狀態機 API

正如很多人所瞭解的那樣,狀態機 是一種關於系統狀態的數學表達式。狀態機混合了表示狀態的節點以及節點間的轉換。在任何一個給定的時間,狀態機具有一個活動狀態,也稱爲當前狀態事件 觸發狀態之間的轉換。爲解釋這個概念,我將展示我當前工作的一個組成部分:開發和維護 ChangingThePresent.org (CTP),這是一個非盈利性組織和捐贈者的平臺(參見 參考資料)。CTP 讓非盈利性組織可以提交關於其組織的信息和一些捐贈品(例如癌症研究員的一小時義診時間或者捐贈給一名學生的書本),這就 使捐贈者僅使用一輛購物車就可將其慈善捐贈作爲禮物轉到他人的名下。收集所有這些信息會導致邏輯問題,所以我選擇使用狀態機來簡化工作流程。

要解決這個問題,我使用了第三方插件,由 Scott Barron 編寫,名爲 acts_as_state_machine(參見 參考資料)。像很多 Rails 插件一樣,acts_as_state_machine 結合使用了 Ruby 的功能和 Rails 專有的特性,不僅提供了一個庫,還提供了特定於域的語言 (DSL),從而爲用戶提供了良好的體驗。

一個用戶向 CTP 提交內容(submitted 狀態)。然後 CTP 管理員接收該內容並進行編輯(processing 狀態)。如果 CTP 進行內容編輯,非盈利組織應該允許這些更改(nonprofit_reviewing 狀態)。當 CTP 或非盈利性組織接受這些內容時,CTP 就會將這些內容顯示在站點上(accepted 狀態)。圖 1 顯示了狀態機的圖形表示:


圖 1. CTP 狀態機
CTP 狀態機 

使用這個插件,我可以直接對我的類對象進行裝飾,使用 DSL 表示不同的狀態,在狀態之間進行轉換,而且事件會觸發這些轉換。清單 1 顯示了我用來在 CTP 中管理非盈利組織的狀態機的簡化版本:


清單 1. 示例狀態機

				
class Nonprofit < ActiveRecord::Base

  acts_as_state_machine :initial => :created, :column => 'status'
  
  # These are all of the states for the existing system. 
  state :submitted            
  state :processing           
  state :nonprofit_reviewing  
  state :accepted             
  
  event :accept do
    transitions :from => :processing, :to => :accepted
    transitions :from => :nonprofit_reviewing, :to => :accepted
  end
  
  event :receive do
    transitions :from => :submitted, :to => :processing
  end

  # either a CTP  or nonprofit user edits the entry, requiring a review
  event :send_for_review do   
    transitions :from => :processing, :to => :nonprofit_reviewing
    transitions :from => :nonprofit_reviewing, :to => :processing
    transitions :from => :accepted, :to => :nonprofit_reviewing
  end  


您以前可能沒有見過所有這些 Ruby 特性,但是這種語言可以非常好地描述狀態機工作流程。您可以看到每一個狀態的描述,然後是狀態機支持的事件,這之後,是每個事件將要觸發的一系列轉換。

每一條語句表示有效的 Ruby 語法。類定義之後,將看到 acts_as_state_machine :initial => :created, :column => 'status'。作爲一名 Java 開發人員,您可能覺得查找方法調用而不是方法定義有些奇怪。Ruby 在類層次上將這些方法調用引用爲。Ruby 通常在加載類時使用宏來爲類添加功能。事實上,方法定義 —— def —— 僅僅是 Ruby 的宏。

接下來,將會看到一系列狀態,例如 state :submitted。這些都是方法調用,每一個都將符號 作爲一個單獨的參數。(符號是用戶定義的名稱。)event 命令也是一個方法調用,使用 符號(定義事件名)和閉包(定義轉換)作爲參數。

每個轉換都是在散列表之後的一個方法調用。在 Ruby 中,使用 key => value 對錶示散列映射,用逗號分隔,並用括號 {} 括起來。當將散列映射用作函數調用的最後一個參數時,括號是可選的。可以看到這個方法 —— 狀態、轉換和事件 —— 與閉包和散列映射結合起來,可以形成一個良好的 DSL。

要使用狀態機,可以實例化一個 Nonprofit 對象並在其上爲每個事件調用方法,後跟一個 !,如清單 2 所示:


清單 2. 操作狀態機

				
>> np = Nonprofit.find(2)
=> ...
>> np.current_state
=> :submitted
>> np.receive!                              
=> true
>> np.accept!
=> true
>> np.current_state
=> :accepted


! 是方法在一個步驟中修改和保存屬性的 Rails 約定。所以對狀態機插件的要求非常明顯。我需要:

  • 能夠方便放置狀態機代碼的位置。
  • 指定我的類方法的方式(DSL 需要使用類方法)。
  • 將實例方法附加到 Nonprofit 或任何其他目標類的方法。

本文接下來的內容將向您介紹插件。如果您希望下載代碼並繼續學習,請下載 acts_as_state_machine 插件。(參見 參考資料 中到 Scott Barron 站點的鏈接,並按照他的指導通過 Subversion 獲得插件。)導航到 trunk/lib,將看到 acts_as_state_machine.rb 文件。在 trunk/init.rb 中找到初始化代碼。我們只需要這兩個文件。

Acts_as plug-ins

原則上講,所有的 acts_as 插件工作原理相同。始終執行下面的步驟構建一個 acts_as 模塊:

  1. 創建一個模塊。以 acts_as_ 作爲名字的開頭。
  2. 在某些初始化代碼中,打開 ActiveRecord 基類並添加 acts_as_ 模塊。
  3. 在 acts_as_ 函數(比如 acts_as_state_machine)中擴展目標類的行爲。

快速瀏覽一下 init.rb 中的初始化代碼,如清單 3 所示:


清單 3. acts_as_state_machine 的初始化代碼

				
require 'acts_as_state_machine'

ActiveRecord::Base.class_eval do
  include ScottBarron::Acts::StateMachine
end


代碼將打開核心 ActiveRecord 類(ActiveRecord::Base)並添加 acts_as_state_machineclass_eval 方法打開類並在 class. Whew 上下文中運行類中的閉包。這看起來有些過於複雜,實際應用中,這個概念很簡單:代碼打開 ActiveRecord 基類並在ScottBarron::Acts::StateMachine 模塊中混合。在 Ruby 中,可以快速打開並重新定義任何類。

由於增加了靈活性,這種功能是 Ruby 最好的優點之一。但是這個功能同時也是一種缺點。太多的靈活性將導致代碼難以理解和維護,所以要謹慎使用。現在,打開 acts_as_state_machine.rb 文件來查看都混合了什麼代碼。

初始化模塊

現在,我將避開實現狀態機的具體細節,將主要介紹狀態機與插件的接口。清單 4 顯示了模塊定義和狀態機本身的接口:


清單 4. 模塊結構

				
module Acts                        #:nodoc:
  module StateMachine              #:nodoc:
    class InvalidState < Exception #:nodoc:
    end
    class NoInitialState < Exception #:nodoc:
    end
    
    def self.included(base)        #:nodoc:
      base.extend ActMacro
    end
    
    module SupportingClasses
      class State
        attr_reader :name
      
        def initialize
          ...
        end
        
        def entering
          ...
        end
        
        ...
      end
      
      class StateTransition
        attr_reader :from, :to, :opts
        
        def initialize
          ...
        end
        
        def perform
          ...
        end
        ...
      end
      class Event
      ...
        def fire
          ...
        end
        
        def transitions
          ...
        end
        ...
      end


在 清單 4 的頂部,可以看到一個嵌套的模塊定義,但是沒有基繼承層次結構。相反,可以將模塊附加到任何現有的 Ruby 類。如果對這個概念還比較陌生的話,可以將模塊看作是一個接口,外加該接口的實現。關於模塊的一個好處就是可以將其功能附加到任何已有的 Ruby 類,並且可以根據您的需要添加,沒有數量限制。還可以使用類的已有功能。這種技術叫做 mixing in。C++ 使用多種繼承來提供與之類似的功能,但是非常複雜。Java 的創建者通過消除多重繼承解決了這種複雜性。通過使用模塊,可以享受到多重繼承的優點而無需面對令人頭痛的複雜性。諸如 Smalltalk 和 Python 這樣的語言也支持 mix-in 繼承。

清單 4 其餘的部分展示了深入實現狀態機的一般細節。您只需要知道這些類提供了狀態機的獨立實現。其餘的代碼更加有趣,因爲它將狀態機的接口公開給插件的客戶機。

Acts_as 模塊

回顧一下插件製作者需要的三個條件:放置實現的位置,公開 DSL(類方法)的方法以及爲狀態機公開實例方法的方法。這些包括清單 3 中起作用的事件方法。清單 4 提供了放置實現的位置。下一個代碼片段將處理 DSL。

acts_as 插件架構具有一個定位點:acts_as 宏。 acts_as 插件的客戶機將通過方法調用將這個方法引入到目標類中。在本文的示例中,我調用了 清單 1 中 Nonprofit 類的 acts_as,使用了下面的代碼:

acts_as_state_machine :initial => :created, :column => 'status'


現在看一下清單 5,它爲 acts_as_state_machine 提供了 ActMacro。該類處理模塊屬性並引入不同的類和實例方法。


清單 5. 添加 acts_as

				
module ActMacro
  # Configuration options are
  #
  # * +column+ - specifies the column name to use for keeping the state (default: state)
  # * +initial+ - specifies an initial state for newly created objects (required)
  def acts_as_state_machine(opts)
    self.extend(ClassMethods)
    raise NoInitialState unless opts[:initial]
    
    write_inheritable_attribute :states, {}
    write_inheritable_attribute :initial_state, opts[:initial]
    write_inheritable_attribute :transition_table, {}
    write_inheritable_attribute :event_table, {}
    write_inheritable_attribute :state_column, opts[:column] || 'state'
    
    class_inheritable_reader    :initial_state
    class_inheritable_reader    :state_column
    class_inheritable_reader    :transition_table
    class_inheritable_reader    :event_table
    
    self.send(:include, ScottBarron::Acts::StateMachine::InstanceMethods)

    before_create               :set_initial_state
    after_create                :run_initial_state_actions
  end
end


清單 5 中的模塊具有一個方法:acts_as_state_machine。該方法執行下面五個任務:

  • 引入類方法
  • 處理狀態機異常
  • 管理屬性
  • 引入實例方法
  • 處理 before 和 after 過濾器

acts_as_state_machine 方法首先引入類方法。(可以在清單 6 中看到詳細的方法清單。)接下來,該方法處理異常。在本例中,客戶機沒有指定初始狀態時會發生異常(惟一的情況)。簡單跳過繼承屬性(我將稍後深入介紹)。self.send 方法引入實例方法。(清單 7 顯示具體細節。)before 和 after 過濾器是 ActiveRecord 宏,可以在 ActiveRecord 創建記錄之前和之後調用set_initial_state 和 run_initial_state_actions

回到 write_inheritable_attribute 和 class_inheritable_reader 宏。您可能想知道爲什麼模型不使用簡單的繼承方法。原因很簡單:模型具有自己的繼承層次結構。這些宏允許模型將這些屬性投影到目標類中 —— 本例中爲 Nonprofit。其中最重要的屬性是state_column 以及一系列包含狀態、事件和轉換的轉換表。現在讓我們添加形成 DSL 的類方法。

添加類和實例方法

在清單 6 中,終於看到了魔術般地引入了 DSL:


清單 6. acts_as_state_machine 的類方法

				
module ClassMethods
  def states
    read_inheritable_attribute(:states).keys
  end
  
  def event(event, opts={}, &block)
    tt = read_inheritable_attribute(:transition_table)
    
    et = read_inheritable_attribute(:event_table)
    e = et[event.to_sym] = SupportingClasses::Event.new(event, opts, tt, &block)
    define_method("#{event.to_s}!") { e.fire(self) }
  end
  
  def state(name, opts={})
    state = SupportingClasses::State.new(name.to_sym, opts)
    read_inheritable_attribute(:states)[name.to_sym] = state
  
    define_method("#{state.name}?") { current_state == state.name }
  end
  ...


event 和 state 宏是名副其實的簡單方法,在 ClassMethods 模塊中進行了定義。event 方法讀取轉換表的屬性,然後讀取事件表屬性。該方法將事件添加到事件表中,然後爲事件動態定義方法,將新方法連接到 event 上的 fire 方法。

event 方法之後,模塊定義 state 方法。該方法讀取狀態表並添加新的狀態。然後,它將一個方便的方法添加到目標類中,如果實例位於當前狀態則返回 true。比如,如果狀態標記是 submitted 的話,nonprofit.submitted? 將返回 true。現在,DSL 得到了完全支持。

實例方法工作起來與類方法十分相似。清單 7 展示了實例方法:


清單 7. acts_as_state_machine 的實例方法

				
module InstanceMethods
  def set_initial_state
    write_attribute self.class.state_column, self.class.initial_state.to_s
  end

  ...
  
  def current_state
    self.send(self.class.state_column).to_sym
  end
  
  ...
end


ActMacro 打開類並添加它們。不需要通過 read_inheritable_attribute 宏來使用屬性,因爲這是 ActiveRecord 定義的類實例變量。我只展示了一個方法,該方法設置初始狀態並返回當前狀態。其餘的方法與此相同。

清單 7 中的第一個方法設置了初始狀態,更新已有的 ActiveRecord 列。回顧一下,我在調用 ActMacro 時設置了列名。current_state 方法僅返回實例變量的值。send 方法調用由一個符號參數命名的方法,在本例中其名稱是 state_column

結束語

您可能會想,僅僅構建一個狀態機並將它當作庫來使用豈不更加簡單。與之相比,acts_as 插件更加好用。它使您可以有效地將一個狀態機列添加到數據庫中。其他插件則可以讓您完成版本化、構建審計記錄、處理圖像以及執行大量其他的簡單任務,就如同這些任務是 Rails 環境和數據庫間的無縫集成一樣。

您可能使用過 Java 語言來將 Eclipse 插件、Ant 任務或者 Spring 庫集成到您的代碼庫中,或者使用過 Java 語言引入 EJB 組件。Java 社區的很多理念改變了開發人員對擴展的認識。這篇有關 Rails 的 acts_as 插件的簡短介紹展示了一種新的認識方法。Ruby 語言的靈活性改變了我對擴展的認識。acts_as 插件允許新一代開發人員嘗試自己編寫擴展,這將爲 Rails 帶來新的擴展浪潮,通過面向方面編程或字節碼增強,很多這種技術也可爲 Java 開發人員所用。

下一次,我將就使用 Ruby 與利用我在 Java 平臺方面的經驗解決棘手問題進行深入比較,並以此結束本系列。在那之前,請繼續跨越邊界。

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章