ADZ 學習筆記

Ruby/Rails, Startup, Life

rails 筆記 - 實作包裝 business logic in gem (model 篇)

| Comments

這篇是繼上篇 用 gem 包裝 business logic 的構想 的實作。

經過一些觀察發現 gem 通常被設計成 require 'gem_name' 即可使用,即使 gem 很大拆了很多不同的 classes, modules,也是由 lib/gem_name.rb 這支檔案負責組織整個 library。

A. 把 models 移到 gem 內

一開始我把所有原本 app 上的 model 都移到了 gem 內的 /app/models 底下,然後再 lib/gem_name.rb 內增加了以下程式:

# 讓 require 可以從 app folder 底下開始

# ex: require 'models/user'

$LOAD_PATH << File.dirname(__FILE__) + "/../app"

class GemName
  # 略 ..

end

# 找出所有 models 並 require 進來

Dir.glob("#{File.dirname(__FILE__)}/../app/models/*.rb").each { |file| require file }

這樣一來我只需要 require 'gem_name' 就可以把 model 都載進來,不過馬上就出現問題了,User model 出現找不到 devise method 的錯誤,經過了一番折騰,最後找到錯誤原因是:

devise 設計成在 rails application 的執行期才把 devise 相關的功能掛到 ActiveRecord::Base 底下,所以在還沒載入 rails application 環境前就在 model 定義 devise 是會出現錯誤的。

而其他 gem 像是 simple_enum 也有一樣的問題,這些 gem 可能是需要用到 application context 所以才選擇在 rails 的 initial 階段才動態載入吧。

註:application context 意思是指,使用該 gem 的 app 環境,例如:app 的 database configuration, application settings ... etc

解法

使用 Rails::Railtie 把這類於載入期之後才可使用的 gem 如:devise, simple_enum 的設定拆成 concerns,並且於 rails initial 階段進行 mixin。

lib/gem_name/railtie.rb
module GemName
  class Railtie < Rails::Railtie
    config.to_prepare do
      ::User.include(::User::Base)
      ::Article.include(::Article::Base)
      # ... (略)

    end
  end
end

並在 lib/gem_name.rb 中載入:

lib/gem_name.rb
require 'gem_name/railtie' if defined?(Rails)

詳細 railtie 的用法可參考 rails document:

http://api.rubyonrails.org/v4.1.4/classes/Rails/Railtie.html

B. 為 models 預先定義功能

上篇提到:在不同使用的 app 中,model 會有一點小差異,如 scope, instance methods ... etc,我希望可以透過以下方式定義不同平台的 model 細節:

pseudo code
class Product < ActiveRecord::Base
  
  platform :api do # API 端的 product model

    default_scope { where(deleted: false) }

    def full_title
            @full_title ||= "#{label} #{category.title} #{title}"
    end
  end
  
  platform :admin do # Admin 端的 product model

    default_scope { order(id: :desc) }
    scope :popular_list, { order(views: :desc) }
  end
  
  platform :reseller do # Reseller 端的 product model

    scope :popular_list, -> (reseller) { # .. (略) }

  end
end

於是,我需要一個能夠定義 platform 的 module 來混入 Activerecrod::Base 中:

lib/gem_name/platforms.rb
module GemName::Platforms
  extend ActiveSupport::Concern

  mattr_accessor :platforms
  @@platforms ||= {}

  module ClassMethods
    def platform(platform, &block)
      GemName::Platforms.platforms[platform] ||= []
      GemName::Platforms.platforms[platform].push(block)
    end
  end
end
ActiveRecord::Base.include(GemName::Platforms)

並在 gem_name.rb 載入這個 platforms module 並且實作一個 load_platform 來執行定義好的 model details。

lib/gem_name.rb
$LOAD_PATH << File.dirname(__FILE__) + "/../app"

module GemName  
  def self.load_platform(platform)
    Platforms.platforms[platform] ||= []
    Platforms.platforms[platform].each { |proc| proc.call }
  end
end

require 'gem_name/platforms'

Dir.glob("#{File.dirname(__FILE__)}/../app/models/*.rb").each { |file| require file }

這樣一來我就可以在 app/initializers/gem_name.rb 中使用 GemName.load_platform :api 來設定我的 model 要用哪組預先定義好的 model。

C. 調整

由於 GemName.load_platform :api 執行的時候已經包含了 rails app context,所以第一點捨棄了 Railtie 的方法並做了一點修正:

app/models/user.rb
class User < ActiveRecord::Base

  platform :shared do
    devise :database_authenticatable, :validatable, authentication_keys: [ :username ]
    as_enum :level, vip: 0, normal: 1
  end
  
  platform :api do
        # .. (略)

  end
  
  platform :admin do
        # .. (略)

  end
end

並把 GemName.load_platform 也一併把 shared 一起 load 進來,完成 :)

相關文章

Comments

comments powered by Disqus