ADZ 學習筆記

Ruby/Rails, Startup, Life

rails 筆記 - php vs ruby 寫法差異

| Comments

語言特性

最近在教一些原本寫 php 的人寫 rails,發現寫 rails 優雅有很大是 ruby 語言/物件特性的功勞,以 php 的 method 設計的例子來說:

model
class Orders extends Table_Abstract {
  public function calculate_total_amount() {
    // heavy calculation
  }
}

在 php 通常使用上都會先拿一個變數,先把 calculate_total_amount 的結果存起來,再去做其他事情,導致定義了一堆 block variables 來暫存跑出來的結果。

即使試圖把 calculate_total_amount result 自己 cache 起來,但在 php 的 instance 在內部使用上還是要用 $this-> 來呼叫,這樣多寫那麼多行 code 並沒有乾淨到哪去,而既然 cache method 並不會讓 code 變乾淨,當然這種寫法在 php 就沒那麼流行。

另一點是既然 method cache 不流行,又怕把 method 命名成 total_amount 會被誤以為是很 light 的 method 而被其他人重複大量使用,最後導致 php 的命名要包含更多 details。

反觀 ruby 不需要先行宣告 instance variables、呼叫 instance 內部 method 也不強制 receiver 這些特點,反而讓 method cache 變得很方便又很 clean,命名也可以不囉唆的切入重點:

lib/spin/profit_calculator.rb
class ProfitCalculator
  # ... (略)


  def dispatched_amount_max
    @dispatched_amount_max ||= begin
      # .... very heavy calculation

    end
  end

  def dispatched_amount
    @dispatched_amount ||= if dispatched_amount_max > 0
      dispatched_amount_max * rand(0..max_rate)
    else
      current_profit
    end
  end

  def max_rate
    @max_rate ||= # .... very heavy calculation

  end
  
  def current_profit
    @current_profit ||= # .... very heavy calculation

  end
end

正規化 & 單一 API 入口

另外觀察到 rails 提供的 API 有花一些功夫做 normalization 的處理,例如: controller render redirect_to,view helper render link_to,單一化入口讓感覺上你只需要學習一個 API 而不需要從一堆 method 中找一個適合自己的來用,然後也不知道自己找到的是不是最好的方法。

這種設計的好處除了有 好學的假像之外,用起來很 high level 讀起來不會失去耐性,只能說開發 rails 的人真的很強。然後剛剛看了 zend framework 做一個 redirect + flash message 的程式碼 ....

controller
$flashMessenger = $this->_helper->getHelper('FlashMessenger');
$flashMessenger->addMessage('We did something in the last request');
$this->_helper->_redirector('/');

OMG

rails 筆記 - awesome_nested_set 搭配 activerecord-mysql-index-hint

| Comments

最近拿需要做多階層的資料儲存,除了使用 activerecord association 做 自己關聯自己 外,還需要一些進階的查詢,例如跨階層、或查詢整條祖父樹。所以這時候只存 parent_id 就不夠用了,於是找了一套專門處理這種儲存方式的 gem 叫做 awesome_nested_set 這個 gem 在實際儲存資料時增加了欄位 rgt ltf,來記錄祖父、子孫樹的範圍區間,詳細說明可參考這個連結:

http://threebit.net/tutorials/nestedset/tutorial1.html

使用了這個 gem 將會有以下 API 可以做跨層的查詢:

rails c
@category = Category.find(3)

@category.self_and_ancestors # 查詢該筆 record 所有父親包含自己

=> "SELECT `categories`.* FROM `categories` 
WHERE (`categories`.`lft` >= 3) AND (`categories`.`lft` < 4)  
ORDER BY `categories`.`lft`"

@category.descendants # 查詢該筆 record 所有父親

=> "SELECT `categories`.* FROM `categories` 
WHERE (`categories`.`lft` >= 3) AND (`categories`.`lft` < 4) AND `categories`.`id` != 3  
ORDER BY `categories`.`lft`"

@category.self_and_ancestors # 查詢該筆 record 所有子孫包含自己

=> "SELECT `categories`.* FROM `categories` 
WHERE (`categories`.`lft` <= 3) AND (`categories`.`rgt` >= 4)  
ORDER BY `categories`.`lft`"

@category.ancestors # 查詢該筆 record 所有父親

=> "SELECT `categories`.* FROM `categories` 
WHERE (`categories`.`lft` <= 3) AND (`categories`.`rgt` >= 4) AND `categories`.`id` != 3 
ORDER BY `categories`.`lft`"

更多詳細 API: https://github.com/collectiveidea/awesome_nested_set/wiki/Awesome-nested-set-cheat-sheet#advanced-usage

MySQL Index

依照 README.md 上的說明,每次 insert 資料時都需要 query rgt 這個欄位,並且以上 query 都會需要搜尋 lft,於是分別建立了 rgt lft parent_id 這三個 index。

不過利用 explain 實際測試 self_and_ancestors descendants self_and_ancestors ancestors 後發現這些 query 根本不會用到我們設定的 index。雖然說 mysql 提供了 USE INDEX FORCE INDEX 讓我們可以強制、或建議 index 給 mysql,但 activerecord 內並沒有針對 mysql 實作這些功能。

於是找到一個 gem activerecord-mysql-index-hint,它擴充了 mysql 的 index scope 到 activerecord,使用方法如下:

rails c
@category = Category.find(3)
@category.self_and_ancestors.force_index(:index_categories_on_lft) # 強制指定 index

=> "SELECT `categories`.* FROM `categories` FORCE INDEX (`index_categories_on_lft`) 
WHERE (`categories`.`lft` <= 3) AND (`categories`.`rgt` >= 4) AND `categories`.`id` != 3 
ORDER BY `categories`.`lft`"

@category.ancestors.use_index(:index_categories_on_lft, :index_categories_on_rgt) # 建議 index

=> "SELECT `categories`.* FROM `categories` USE INDEX (`index_categories_on_lft`, `index_categories_on_rgt`) 
WHERE (`categories`.`lft` <= 3) AND (`categories`.`rgt` >= 4) AND `categories`.`id` != 3 
ORDER BY `categories`.`lft`"

為了最佳化這幾個 query,經過一番測試後,最後的 index 是這樣建:

migration
add_index  :categories, :parent_id
add_index  :categories, :rgt # for insertion (index_categories_on_rgt)

add_index  :categories, [:lft, :rgt] # for tree query (index_categories_on_lft_and_rgt)

一開始的時候為了方便 override 了 tree query 的 method:

app/model/category.rb
class Category < ActiveRecord::Base
  TREE_INDEX = :index_categories_on_lft_and_rgt
  def self_and_descendants(*)
    super.force_index(TREE_INDEX)
  end

  def descendants(*)
    super.force_index(TREE_INDEX)
  end

  def self_and_ancestors(*)
    super.force_index(TREE_INDEX)
  end

  def ancestors(*)
    super.force_index(TREE_INDEX)
  end 
end

不過之後馬上想到一個問題,如果我呼叫的是: @category.self_and_descendants.find(params[:id]) 這種情況應該使用 PRIMARY key 效能是最好的。但由於我們無法確定這些 query 後面會接其他 scope,所以應該用 use_index 取代 force_index

class Category < ActiveRecord::Base
  TREE_INDEX = :index_categories_on_lft_and_rgt
  def self_and_descendants(*)
    super.use_index(TREE_INDEX, :PRIMARY)
  end
  # .. (略)

end

這樣修改後,無論使用 @category.self_and_descendants@category.self_and_descendants.find(params[:id]) 都會使用速度最快的 index。

不過以上方法僅適用這個 case,因為該 table 的索引單純,只需要 :PRIMARY:index_categories_on_lft_and_rgt 則一即可。如果 index 比較複雜的情況,還需考量各種不同的 query。

最後檢查

由於 descendants 會用 id != 來過濾掉自己,怕 mysql 不夠聰明,有 != 就使用 PRIMARY key 造成效能低落,所以做了以下測試:

rails c
@category.descendants # 使用 :index_categories_on_lft_and_rgt

=> "SELECT `categories`.* FROM `categories` USE INDEX (`index_categories_on_lft_and_rgt`, `PRIMARY`) 
WHERE (`categories`.`lft` >= 3) AND (`categories`.`lft` < 4) AND `categories`.`id` != 3 
ORDER BY `categories`.`lft`"

@category.descendants.find(6) # 使用 PRIMARY KEY

=> "SELECT `categories`.* FROM `categories` USE INDEX (`index_categories_on_lft_and_rgt`, `PRIMARY`) 
WHERE (`categories`.`lft` >= 3) AND (`categories`.`lft` < 4) AND `categories`.`id` != 3 AND `categories`.`id` = 6  
ORDER BY `categories`.`lft`"

兩個 query 都正常使用預期的 index。

rails 筆記 - refactor 遊戲商業邏輯心得

| Comments

最近用 rails 寫一個遊戲 api service 在寫的過程除了包 gem 時更了解 rails 外,比較值得拿來分享的大概就 refactor 的心得吧,不過由於 NDA 不能透露太多,所以這裡這篇會用 pseudo 情境來說明。

需求是:

  1. 玩家可以用武器攻擊另外一個玩家
  2. 玩家同時間只能手拿一把武器、可切換不同的武器
  3. 每把武器可能有自己的客制化事件 (ex: 雙重攻擊、無視防禦力、讓對方中毒、擴散攻擊打到附近玩家)
  4. 可隨著遊戲版本而彈性新增武器、防具 .. etc

有以下 models

  1. Player - 遊戲玩家 (has_one :weapon)
  2. Weapon - 武器
  3. Armor - 防具
  4. AttackLog - 攻擊記錄 (belongs_to :attacker (player), :defender (player), :weapon)

分析了需求以後歸納 攻擊 這個動作實際程式流程大概會是:

  1. 先準備好資料,包含攻擊者、被攻擊者的資料、攻擊者的武器、被攻擊者的防具 ... etc
  2. 先 lock db 一些資料避免 race condition
  3. 檢驗一些傳入資料的正確性 (ex: 攻擊者、與被攻擊者的距離超過一定範圍則不能攻擊 ... etc)
  4. 如果武器含有事件是無視防禦力,先把防禦力變數設定成 0
  5. 開始計算攻擊力、防禦力綜合計算後決定被攻擊者要損多少血
  6. 如果武器有雙重攻擊就把剛剛計算的損血量 * 2
  7. 如果武器特性是讓對方中毒,就更新被攻擊者的 status 變成 poisoning
  8. 如果會擴散攻擊,就拿被攻擊者的 location 去搜尋附近 2x2 位置的所有 user
  9. 最後把上述所有計算資料寫入 attack_logs 並扣掉拿以上計算值更新被攻擊者
  10. 送出 websocket events

其實以上不是重點,那只是一開始歸納出來,會有 "好多好多好多" 事情要做,但不論程式多複雜,至少希望在使用上可以簡單,於是就是使用單一入口的 service object,複雜的東西就封裝在 service 裡面,於是我設計了以下的 interface。

# POST /attackes.json

def create
  @defender = Player.find(params[:player_id])
  @attack_response = Player::Attack.call(current_player, @defender)
  # 然後 render create.json.jbuilder 把內容印出來

end

1. 每個部分都需要同樣的 Context

從一開始實作的時候我就打算要把程式拆細,一旦亂到無法整理就完了,於是我寫了很多個 class 把攻擊這個 service 拆成不同的 part,不過過程中綁手綁腳,class 要丟很多變數給另外一個 class,好像拆開反而更複雜,思考的過程有點忘了,不過從 class 拆法換成 module 後歸納出原來問題是在 context 上。

意思是說:每一個 module 都會需要 attacker, defender, attacker.weapon, defender.armor 這些資料,而除了這些資料外,也要能夠隨時 override 已經算好的資料 (被攻擊的人開損多少血 ... etc)。

於是第一次 refactor 把檔案結構改成以下,並把相關的 methods 做個歸類,並由 attack.rb 來組織所有流程:

  1. attack.rb
  2. concerns/prepare.rb # 準備資料的 methods
  3. concerns/validation.rb # 使用 activerecord::validation 寫的 custom validation method
  4. concerns/attack_calculator.rb # 計算攻擊、防禦的傷害值
  5. concerns/weapon_events.rb # 武器事件的 methods
  6. concerns/attack_logs_handler.rb # 最後儲存資料 (attack_logs, 扣血量 ...) 的 method
  7. concerns/websockets.rb # 最後處理 realtime 的 methods

於是我的 attack.rb 變成了類似:

class Player::Attack
  [Prepare, Validation, AttackCalculator ....].each { |m| include m }
  
  ValidFaildError = Class.new(StandError)
  
  def call!
    ActiveRecord::Base.transaction do
      prepare_player_data! # in prepare.rb

      lock_data! # in prepare.rb

      
      raise ValidFaildError if valid? # in validation.rb

      
      @defense = defender.defense # 防禦力

      @attack = attacker.attack # 防禦力

      @extend_status = []
      
      @defense = 0 if weapon_ignore_armor?
      
      @damage_hp = calculating_damage
      @damage_hp *= 2 if weapon_with_double_attack?
      @extend_status.push(:poisoning) if weapon_with_poisoning?
      
      damage_defender(@damage_hp, @extend_status)
      save_all! # 儲存 attack logs, attacker, defender

    end
    Websocket::API.broadcast!('some channels', @attack_log)
    true
  end
end

2. 使用 ActiveSupport::Callback

以上程式雖然把 methods 都丟到 module 內,但還是有一個問題,如果我的需求不斷增加,那我的 call! method 不是寫到爆炸了嗎? 觀察了這些流程,發現武器這塊很棘手,他圍繞在 calculating_damage 前後 間接直接 影響著失血計算,如果不考量武器,其他的部分算是攻擊的 基本流程,如果能把讓主要程式只負責基本的攻擊功能,像是 capistrano 提供基本的 tasks 由 hooks 去組織出個人化的流程,就可以在未來新增各種不同 module 的時候完全不動到主結構。

所以我用 activesupport 進行了第二次大 refactor 提供了像是 activerecord callback 的功能:

  1. before_validation # 檢驗前
  2. after_validation # 檢驗後
  3. before_prepare_calculating_data # 初始化計算基礎資料 (攻擊力、防禦力、距離 .. 其他因素 )
  4. after_prepare_calculating_data # 初始化完成後 (設定完成後)
  5. before_calculating_damage # 以初始化資料進行傷害計算前
  6. after_calculating_damage # 以初始化資料進行傷害計算後
  7. before_commit # 儲存資料前
  8. after_commit # 最後統一儲存 attack_logs 和攻擊者、被攻擊者後

由以上 callback 我能夠把除了主攻擊邏輯之外的邏輯放在 module 內,類似這樣的 code

module Player::Attack::WeaponEvents
  extend ActiveSupport::Concern
  included do
    after_calculating_damage {
      @extend_status.push(:poisoning) if weapon_with_poisoning?
    }
  end
end

有了以上架構,我甚至還能擴充更多不同的武器事件,例如:

  1. 有一定機率觸發 2 被攻擊
  2. 當被攻擊者寫少於 100 時,觸發 xxxx yyyy 事件
  3. 吸血功能武器

3. 把武器事件拆出

雖然 callbacks 的設計方式讓組織流程更順利,module 間各自獨立並提升了可讀性,不過另一個問題是:難道我有 100 個武器事件都要寫在 WeaponEvents 嗎? 而且當武器事件增加,就顯得 WeaponEvents 有過多無謂的運算。

於是也許我們可以為每個武器各寫一個 class,由 weapon::base 裡面提供定義遊戲事件的功能。

class Weapon::Base
  class << self
    def before_calculating_damage(&block)
      # .. 略

    end
    
    def run_before_calculating_damage(context)
      # .. 略

    end
    
    def after_calculating_damage(&block)
      # .. 略

    end
   
    def run_aftr_calculating_damage(context)
      # .. 略

    end
  end
end


class Weapon::Gun < Weapon::Base
  before_calculating_damage do |context|
    # 70% 機率對手防禦力 - 10

    context.defense -= 10 if rand(100) >= 30
  end
  
  after_calculating_damage do |context|
    # 40% 機率傷害增加 3 倍

    context.damage_hp *= 3 if rand(100) >= 60 
  end
end

再由 weapon 記錄該武器是哪個 class 的。

class Weapon < ActiveRecord::Base
  def weapon_model
    # weapon_model ex: `Weapon::Knife`, `Weapon::Gun` .. etc

    @weapon_model ||= weapon_model_name.constantize
  end
end

class Player < ActiveRecord::Base
  has_one :weapon
  
  delegate :weapon_model, to: :weapon
end

這樣一來我就可以避免 WeaponEvents 裡面有太多 implementation,取而代之只需要提供 helper 就好

module Player::Attack::WeaponEvents
  extend ActiveSupport::Concern
  included do
    before_calculating_damage { @attacker.weapon_model.run_before_calculating_damage(self) }
    after_calculating_damage { @attacker.weapon_model.run_after_calculating_damage(self) }
  end
  
  # helper methods .. (略)

end

5. 成果

Refactor 到這個地步的成果是:每增加一款武器加上特殊事件,只需要 20-30 分鐘就能完成包括補上 spec。

6. 雞蛋裏挑骨頭

不過還是希望在使用上有 rails 社群專有優雅感,於是我放棄了 service object 的想法而把 Player::Attack 改成了 module mix 到武器的 class 上,再搭配 ActiveRecord delegate,最後使用的方式變成:

@player = Player.find(2)
@player2 = Player.find(3)
@player.attack!(@player2)

修改則是把 Player::Atack 改名成為 Weapon::Attack 所有武器的 class 改成:

class Weapon::Gun < Weapon::Base
  include Attack
  # ... (略)

end

這樣改的好處是:之前的做法太囉唆了,還要讓 WeaponEvents 把 context 傳到 Weapon classes,但 武器事件 跟整個 攻擊流程 有同樣的 context 本來就很合理,但壞處是武器 class 如果很多,同樣的 module 一直 mixin 到不同的 classes 不知道記憶體會不會爆炸 (如果有人知道這種情況請告訴我,感謝)。

7. 最後

從一開始接觸到這需求很頭痛,到 refactor 到目前的版本,最大的體會是:想一次到位是很不切實際的想法,很多 idea 是借由整理程式碼再加上時間才能萃取出來的。

其他感想就是:寫文章跟 refactor 很像,應該切成多個 part 分批進行。

rails 筆記 - [Gem] visionbundles 無痛 deploy

| Comments

這個專案終於有機會將 rails project deploy 到多檯機器上,最近也花了點時間把之前寫的 capistrano recipes 整理了一下。

我覺得 Capistrano 是架構非常棒的一個 deploy 工具,你可以透過 hook, override task 的方式 custom made 自己的 deploy 流程和架構,當然彈性的代價就是,有非常高的學習曲線。除了要了解 rails production 都用什麼服務外,還得搞懂每個 service 的 configuration。

不過 capistrano 只是一個 deploy 的基礎框架,而我想提供的是一個 deploy template、或者說是一個 deploy solution,即使是單純的 single server 到 mutiple stack,都能透過簡單易懂的 config 來定義。

[gem] visionbundles (support rails 4.0.0 - 4.0.10)

gem 裡面有已經寫好的 deploy task,使用上不需要重新組織這些 task,需要什麼載入什麼即可,而 recipes 包含 (nginx, puma, db, secret, fast_assets, dev)。

不過在使用之前,必須先安裝好 server 上的 nginx, database 和 deploy user + rvm,不會安裝的人可參考以下的安裝腳本

https://github.com/afunction/ubuntu-rails-app-installer

BTW: 不熟 shellscript 所以可能會有些小 bug 如有問題請發 issue 或直接 PR。

完整的設定範例

config/deploy.rb
require 'bundler/capistrano'
require 'rvm/capistrano'
require 'visionbundles'

# RVM Settings

set :rvm_ruby_string, '2.1.0'
set :rvm_type, :user
$:.unshift(File.expand_path('./lib', ENV['rvm_path']))

# Recipes Settings

include_recipes :nginx, :puma, :db, :dev, :fast_assets

# Nginx

set :nginx_vhost_domain, '111.222.33.44'
set :nginx_upstream_via_sock_file, false
set :nginx_app_servers, ['127.0.0.1:9290']

# Puma

set :puma_bind_for, :tcp
set :puma_bind_to, '127.0.0.1'
set :puma_bind_port, '9290'
set :puma_thread_min, 32
set :puma_thread_max, 32
set :puma_workers, 3

# Role Settings

server '11.222.33.44', :web, :app, :db, primary: true

# Capistrano Base Setting

set :application, 'my-project-name'
set :user, 'rails'
set :deploy_to, "/home/#{user}/apps/#{application}"
set :deploy_via, :remote_cache
set :use_sudo, false
set :rails_env, 'test'

# Git Settings

set :scm, :git
set :repository, "git@github.com:username/#{application}.git"
set :branch, 'develop'

# Others

default_run_options[:pty] = true
ssh_options[:forward_agent] = true

# Deploy Flow

after 'deploy', 'deploy:cleanup' # keep only the last 5 releases

效能

1. Compile assets locally + Sync to remote

Capistrano 在多 servers 的狀況,預設是 app & web roles 都會自己 compile 一次,但 app server 實際上只需要 mainfest file 而已。

過程間精簡了網路傳輸,僅上傳 assets manifest 檔給 app server,完整 assets 則上傳 web,在多台 app server 的情況可減輕伺服器負擔,尤其是如果 server 只有 1G 記憶體,compile assets 很容易記憶體吃光中斷。

2. Sync Assets to CDN

原本使用 asset_sync 做同步上 CDN,不過該 gem 僅是把 sync task 綁在 assets compile 之後,當有多台 app server 時,每台 app server 都會對 CDN 進行上傳動作。

visionbundles 把 asset_sync 整合進來,compile locally and upload from local. 當你選擇 CDN 模式時,完整的 assets 僅會上傳 CDN、web 則不上傳。

其他

有時候覺得 deploy.rb 內的架構資料太敏感,或是同一個專案 deploy 到不同網站,設定要怎麼快速切換?

在 deploy.rb 內可以使用 yaml 來設定你的架構資訊,再把 yaml 設定檔 list 進 .gitignore 裡。config 裡可以用 namespace 區隔不同網站的環境。

最後 ..

不過目前尚未跳新版,所以請使用最新程式碼: gem 'visionbundles', github: 'afunction/visionbundles'

詳細使用方式可以參考:https://github.com/afunction/visionbundles

rails 筆記 - capistrano 2.x role: :db 真正的意思

| Comments

capistrano 在設定 server 的地方有一個 role 叫做 :db

deploy.rb
server '0.0.0.0', :web, :db, :app, primary: true

以前 deploy 一台機器的時候還沒有發覺哪裡奇怪,不過最近想到一個問題,database server 上應該很單純只是負責跑 mysql,應該沒有什麼 task 是要跑在 database 的。

Google 了一下找到 stackoverflow 找到 這篇討論capistrano source code

原來這裡的 role: :db 指的不是 database 這檯機器,而是需要搭配 primary: true 來指定哪檯機器負責跑 db:migrate,不過這設計也滿奇怪的,因為 capistrano 裡面除了跑 db migration 外,並沒有其他使用到 role: :db 這個角色,而且命名也滿誤導人的。

總之,結論是在多台機器的情況,負責跑 migration 的機器,就是要同時設定 role db 和 primary: true 就對了。

rails 筆記 - 實作包裝 business logic in gem (Db Migration & Test)

| Comments

繼上篇 移植 business logic model 到 gem 後 也把 model spec 都 copy 過來,不過因為之前提到的 devise, simple_enum 的執行要到 rails initialize 階段才會載入,所以我們應該要在 spec_helper.rb 把不足的東西先處理好,但是在這之前,我想先處理 migration 後在一併把 spec_helper 設定好:

A. Migration

再開始實作 shared migration 前,還沒什麼想法,直到找到這篇文章:Leave your migrations in your Rails engines

簡單來說,夠過 Rails::Engine 能讓使用的 application 增加 db/migration 應該要讀取的 path,讓你的 app 擁有以下功能:

  1. 輸入 rake your_engine_name:install:migrations 會自動 copy migrations file 到你的 app 內。
  2. 輸入 rake db:migrate 也會包含 gem 內的 migrations。

但我的需求是,有多個平台會共用一個 database,所以我們只需要其中一個 project 來跑 migration,所以我們做了以下判斷,將 primary_migration_repo 留給使用的專案來設定:

lib/gem_name/engine.rb
module GemName
  class Engine < ::Rails::Engine
    initializer :prepend_migrations do |app|
      if app.config.respond_to?(:primary_migration_repo) && app.config.primary_migration_repo == true
        config.paths["db/migrate"].expanded.each do |prepend_path|
          app.config.paths["db/migrate"].unshift(prepend_path)
        end
      end
    end
  end
end

然後要 require

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

負責跑 migration 的專案 application.rb 需要設定 primary_migration_repo=true

/config/application.rb
# ... (略)

module Project
  class Application < Rails::Application
    # ... (略)

    config.primary_migration_repo = true
  end
end

最後,把原本專案內的 migration 以相同的目錄結構丟到 gem 內,在專案內 run rake db:migrate 成功了,專案內讀取的到 gem 的 migration file。

B. Model Test

完成了 model, migration,剩下我們要移植 model spec 到 gem 裡,在跑 spec 前,我們應該要完成三件事情:

1. 設定 Test Database

因為不想再開台 mysql 來測,於是直接使用 sqlite,於是再 gem_name.gem_spec 加入 sqlite3

gem_name.gem_spec
Gem::Specification.new do |spec|
  # .. (略)

  spec.add_development_dependency 'sqlite3'
  # .. (略)

end

2. Run migration

這裡,我有看到兩種 run migration 的方式:

為了簡單方便,直接使用第二種方式:先在 app 跑完 migraion,然後輸入 rake db:schema:dump 再把 schema.rb copy 到 gem 的 spec 資料夾下,然後寫一隻 support/settings.rb 來負責 database configuration 和跑 migration 等等事情:

spec/support/settings.rb
require 'active_record'
ActiveRecord::Base.establish_connection adapter: "sqlite3", database: ":memory:"

require 'schema' # 載入 schema

並在 spec_helper 內設定 require settings

spec/spec_helper.rb
require 'bundler/setup'
Bundler.setup
require 'support/settings'

RSpec.configure do |config|
    # .. (略)

end

3. 想辦法讓 devise, simple_enum 在 ActiveRecord::Base 上被混入。

一開始想說 devise, simple_enum 把 mix 動作設定再 initializer 階段,應該有其用意,我是否也需要模擬一個 rails application 才做得到? 於是我在 simple_enum 的 spec 找到這段 support。

https://github.com/lwe/simple_enum/blob/master/spec/support/active_record_support.rb#L26

原來直接 mix 就好了,於是我把 setting.rb 改成

spec/support/settings.rb
require 'active_record'
ActiveRecord::Base.establish_connection adapter: "sqlite3", database: ":memory:"

require 'schema' # 載入 schema


# Extend libraries

require 'devise'
require 'devise/orm/active_record' # 這支檔案會 mix devise modules 到 activerecord

require 'simple_enum'

# 手動 mix simple_enum 到 activerecord

ActiveRecord::Base.send :extend, SimpleEnum::Attribute
ActiveRecord::Base.send :extend, SimpleEnum::Translation

require 'gem_name' # require all gem


GemName.load_platform :api # 載入 API platform models

這裡即使沒有 rails app context 還是可以手動將 devise, simple_enum 的一些 module mixin ActiveRecord::Base,然後 run rspc 全部綠燈,完成 :)

後記:

1. 尚未解決的問題

不過目前 platform 那段程式,只要呼叫 load_platform 就會 mix modules 到 model 中沒辦法 rollback 或 reset,有試著找過暴力 overwrite 的方式,不過好像行不太通,如果有人知道請告訴我。

2. Rails::Railtie 和 Rails::Engine

一開始有點疑惑這兩個有什麼不一樣,看了一些文件還是覺得有點模糊,不過實測上發現,migration path 寫在 Rails::Railtie 裡面會找不到 app,推論是 Engine 比 Railtie 能獲得更多 application context。

相關連結:

看完很多程式碼後才發現,原來有 rails plugin 的 template 可以用,早知道一開始就不用那麼辛苦了,果然看官方資料還是最快的。

相關文章

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 進來,完成 :)

相關文章

rails 筆記 - capistrano deploy multiple stacks 心得

| Comments

今天要試著 deploy 專案到 multiple stacks 上,分成了 web, app, db 三檯機器,在 deploy 時發現了一些問題,記錄一下。

nginx 負責 web proxy 再由 upstream 透過 private networking 到一或多個 app server 很好理解、感覺應該很快就能搞定,但是在 deploy 時,一直卡在 deploy:assets:update_asset_mtimes 這個 task 找不到 database configuration file。

deploy:assets:update_asset_mtimes

找了很久後發現,原來 deploy:assets:update_asset_mtimes 這支預設是 run 在 web role 上:

https://github.com/capistrano/capistrano/blob/legacy-v2/lib/capistrano/recipes/deploy/assets.rb#L8

所以其實可以透過 deploy.rb 指定 assets_role 的方式選擇由哪些角色來做 assets compile,原本想說配置是 web 專心跑 web 該做的事情,不做其他事情,於是一開始先把 assets_role 設定成 app role 來負責。

這樣設定運作的很順利,不過睡覺前突然想到:web 上的 nginx root 指向的位置是沒有 compile 完的 assets,只是因為我有用 CDN 存放 assets file 才沒有出錯,而不一定所有人都會把 assets 放到其他 CDN 下,想一想後發現拿 web 來 compile assets 也滿合理的。

現在的問題是 deploy:assets:update_asset_mtimes 在 web 上沒有 database.yml 會錯,經過測試後發現,其實不需要正確的 database configuration,只需要有這個檔案,和 format 即可。

所以改了 deploy task 後順利解決這個問題了。

Assets manifest

解決完以上問題,打開網站發現,所有 css, js 都不見了,發現除了沒有用 CDN web 需要 compile 一次 assets,app 角色也需要,因為所有 compile 完的 assets filename 後面都會被帶上一些碼來避免 browser cache 問題,而 application server 需要 compile 完的 manifest 擋,最後在 deploy.rb 加上以下這行,讓 web & app 都 compile assets 就解決問題了:

set :assets_role, [:web, :app]

總結

mutiple stack with CDN

set :assets_role , [:app]

mutiple stack without CDN

set :assets_role , [:web, :app]

rails 筆記 - 用 gem 包裝 business logic 的構想

| Comments

最近做的一個遊戲平台專案,需要拆成三個 rails application,除了 models 的 scope 和 virtual attributes 在不同 application 會有一點點差異外,其他的商業邏輯包含 forms & validations, service objects, shared concerns, libraries, 還有一部分的 i18n 都是一樣的。

在剛開始進行專案的時候,只寫在 lib/ 內,不過慢慢開始出現一些奇怪問題:

  1. 三個專案用同一個 database,那 migration 要寫在哪?
  2. 三個專案都有同一份 forms / services 那 test 要寫在哪?
  3. 每次修改其中一個專案就要 copy 來 copy 去非常不方便。

如果專案持續擴充會出現更多維護上的問題,於是這幾天參考一些 gem 的設計 rails, devise, sidekiq-web, public_activity, settingslogic ...

初步構想是:

  1. 把所有共用的 library, concerns 搬到 gem 內
  2. 把共用的 models / forms / service 搬到 gem 內,但不使用 gem 的 namespace
  3. 把共用 i18n files 搬到 gem 內,使用的專案會一併載入
  4. 把 migration file 移到 gem 內 (由使用的專案決定需不需要 include 這些 migration files)
  5. 把 test 移到 gem 內
  6. models 的小差異,在 gem 內透過 DSL 預先定義,由使用的 app 決定要載入哪個環境的 models

目的是要讓 application 的工作降低成只負責 組織流程,而 test 部分只須要寫 integration test 確保流程操作是正確的。

而 gem 則變成一個 application engine。裡面的 test 則是大小不一的 integration test & unit test 須確保資料存取的正確性。

這樣的好處是,以後需要同樣的資料儲存架構,只需要使用該 gem 就能組織出新的 application (參考自 devise),甚至把流程抽象化,也許能發展到透過定義的方式組織流程。

rails 筆記 - Gem Memoist

| Comments

有一種情況是 method 裡面有些計算,重複呼叫時不需要重複計算,這時候我們會想要把 method 的結果 cache 讓程式碼 clean 一點,這時我們會使用 ||= 搭配 instance variable 來實現 method cache。

class Cart
  ... ()
  def total_amount
    # 如果是 nil 則執行後方運算並回傳,否則就直接回傳。

    @total_amount ||= items.map { |item| item.price * item.qty }.sum 
  end
  ... ()
end

不過以上程式碼有兩個問題:

  1. nil 無法 cache (如果複雜計算後的結果是 nil,下次呼叫還是執行計算)
  2. method with argument 的狀況

第二點的 method with argument 也是可以實作,不過 code 會變得不是那麼 clean,例如:

class Cart
  def total_amount(cost=1)
    @total_amount ||= {}
    @total_amount[cost] = items.map { |item| item.price * item.qty }.sum * cost
  end
end

這些問題事在看 codeschool 的時候的發現解法的,原來 rails 內建有一個 module 叫做 ActiveSupport::Memoizable 作用也是實作 method cache,不過跟以上的方式不太一樣,解決了 nil 無法 cache 跟 method with argument 的問題,並且使用上很簡單:

class Cart
  extend ActiveSupport::Memoizable # 要先 extend 這個 module

  
  def total_amount(cost=1)
    items.map { |item| item.price * item.qty }.sum * cost
  end
  memoize :total_amount # 加上這行就搞定

end

這裡是 ActiveSupport::Memoizable 的 source code:

https://github.com/rails/rails/blob/36253916b0b788d6ded56669d37c96ed05c92c5c/activesupport/lib/active_support/memoizable.rb#L66

不幸的是,這支 module 似乎在 2011 年就被棄用了,但是有人把這隻 module 重新包裝成一隻新 gem 叫做 memoist

https://github.com/matthewrudy/memoist

使用方式看起來跟 ActiveSupport::Memoizable 一樣,有興趣的人研究看看吧 :)