avatar

大兜

右手寫程式,左手寫音樂

標籤:Ruby on Rails

留言

Rails on webpack

記得筆者在去年於 RubyConf Taiwan 講的題目「還給前端工程師一片天空」中提到如何整合 Rails 與 Node.js 世界的工具,投影片在此。雖然 webpack 正夯,但礙於筆者當時沒去研究,使用的工具仍是上一代的 gulp、bower,所幸議程也有相關的講題,如何澤清前輩的「gem 'webpack-rails'」。

只是經過筆者幾番研究之後,覺得整合這兩樣東西似乎不需要像網路上找到的各種教學文或是 gem 搞的那樣複雜,所以想藉這篇文章分享自己的做法(但並不保留 sprockets)。在那之前,先分享一些小知識:

asset_path 知多少?

#asset_path 是 Rails 中其中一個底層 API,許多 helper 如 #image_tag 等都會調用,而若沒有特別查閱原始碼,大概不多人知道 asset_path('/app.js')asset_path('app.js') 差了一個斜線會影響結果,至於差在哪就讓我們節錄部份原始碼:

def asset_path(source, options = {})
  # ...
  if source[0] != ?/
    source = compute_asset_path(source, options)
  end
  # ...
end

sprockets 其實有偷偷去複寫 #compute_asset_path,也就是說當傳入 "/app.js",assets pipeline 是不會運作的,但若傳入 "app.js",在 sprockets #compute_asset_path 的加持下結果會長的像 /assets/app-c5bd5cb45ee76432b26a5dfb28e01b59.js?body=1;反之若 "app.js" 檔案不存在,或者根本就沒安裝 sprockets,那就退回原型得到 "/app.js"

所以只要我們複寫的 #compute_asset_path 能算出 webpack 產生在 public/assets 中的正確路徑,其實不用更改任何 API,就可以輕鬆整合 webpack。而 #javascript_include_tag#stylesheet_link_tag#image_tag 等 helper 都可以照常使用。

webpack –json

sprockets 在編譯後會產生 manifest.json,裡面有 asset 原始路徑與其計算後的路徑資訊(例如 app.js 對應到 /assets/app-c5bd5cb45ee76432b26a5dfb28e01b59.js,以 Hash 儲存),好處是 Rails 可以透過讀檔取得計算後的路徑結果,不須透過 sprockets,所以為了增加效能,這個檔案在 production 環境中是必要的,而在 development 環境中,由於 assets 的路徑是及時透過 sprockets 得出,所以並不需要這個檔案。

此外 sprockets 在 controller 與 router 中也動了手腳,這也是為什麼專案中 public/assets 明明沒東西,而在 development 環境下中送出像是 /assets/ooxx.js 的請求卻仍可以正常運作的原因。

只是 webpack 畢竟是 Node.js 世界的產物,無法篡改 Rails controller 與 router,好消息是 webpack --json 會產生一個跟 manifest.json 類似的檔案(官方稱之為 stats file)。所以只要在 webpack.config.js 中加入產生 stats 的 plugin,再用 webpack --watch 來開發就已經綽綽有餘了,例如我們在專案的根目錄下產生 stats.json

plugins = [
  function() {
    this.plugin('done', function(stats) {
      require('fs').writeFileSync(__dirname + '/stats.json', JSON.stringify(stats.toJson()))
    })
  }
]

範例專案

筆者在 Github 實作了一個範例,重點只在兩個檔案: app/helpers/webpack_helper.rblib/webpack_stats.rb

webpack_stats.rb 負責載入 webpack 產生的 stats.json,經過處理之後產生一個 assets hash,例如:

{
  'app.js' => '/assets/app.js',
  'app.css' => '/assets/app-c5bd5cb45ee76432b26a5dfb28e01b59.css' # 也支援 hash 尾綴
}

(限制是 webpack 必須遵守檔名格式為 filename-hash.extname 或是 filename.extname。)

而這個 assets hash 將被用在 #compute_asset_path

# app/helpers/webpack_helper.rb
require 'webpack_stats'
module WebpackHelper
  def compute_asset_path source, options = {}
    WebpackStats.assets[source] || super
  end
end

大功告成!老實說這樣就已經能完美運作了,不用安裝什麼 gem,實測用來寫 react 也不用安裝 react-rails。而剩下的問題已經不關整合的事,像要加入 SASS、CoffeeScript 或是 Font Awesome 等,或是在 production 下要壓縮 JavaScript 並且分離 CSS 檔案等,這取決開發者對 webpack 的掌握。

至於只在 production 中分離 CSS 檔案,裡面可以這樣寫:

<%= stylesheet_link_tag 'application', media: 'all' if Rails.env.production? %>

至於為何要這樣做,可參考 extract-text-webpack-plugin 的 README 提到的優缺點。

我就是要 gem

其實筆者也是有做啦⋯⋯如果真的覺得那兩個檔案很麻煩,可以安裝 webpack_stats,這除了可以用在 Rails,也可以當一般 webpack stats 的 loader 使用(用在 Rails 之外的地方)。

繼續閱讀

留言

Rails 4.2 重點介紹

Rails 團隊終於要在聖誕節的同時釋出 Rails 4.2 版了,這次更新的重點有以下項目:

  • Active Job
  • Asynchronous mails
  • Adequate Record
  • Web Console
  • Foreign key support

Active Job

一個網站常有些較繁重的工作,並不希望在使用者提出請求時立即執行,以寄出一萬封信為例,這也許需要幾分鐘的時間,當使用者點下寄信按鈕時如果還需要等個幾分鐘才可以看到回傳頁面,這將造成糟糕的使用者體驗。

正規的作法是將這類需要長時間的工作丟到工作佇列去排程,並在背景中執行多個 worker 程序,每個 worker 都會不斷重複從佇列中取得新的工作去執行。

Rails 已經有許多 gem 可以解決這個問題,著名項目包括 ResqueSidekiqDelayedJob,其中 Resque 與 Sidekiq 使用 Redis 存放工作住列,DelayedJob 則用關聯式資料庫。

Active Job 並不是提出了一個新的實作,換句話說,使用 Rails 4.2 並不代表未來就不用安裝 Resque 之類的 gem。

它的真正意義在於統一使用介面,讓開發者在不同的 gem 之間切換時,可以不用受到 gem 的不同 API 而影響,因而降低重新改寫的成本。

看到這裡是否覺得這種作法很熟悉?它其實就是適配器模式(Adapter pattern),早在 Active Record 誕生的時候就已經使用相同的技巧,Rails 之所以能以相同的 API 介面在不同的資料庫之間遊走也是拜此所賜。

目前支援的 gem 有:

$ ls -1 activejob/lib/active_job/queue_adapters
backburner_adapter.rb
delayed_job_adapter.rb
inline_adapter.rb
qu_adapter.rb
que_adapter.rb
queue_classic_adapter.rb
resque_adapter.rb
sidekiq_adapter.rb
sneakers_adapter.rb
sucker_punch_adapter.rb
test_adapter.rb

除了 test_adapter.rb 僅用於測試,以及 inline_adapter.rb 為預設(立即執行,不會丟入背景),以外都有相對的 gem 需要安裝。

使用方式

工作的內容必須定義在 app/jobs/ 下,並繼承自 ActiveJob::Base,不過 Rails 4.2 提供了產生器,並不一定要手動新增:

$ rails g job execute_simulate
      invoke  test_unit
      create    test/jobs/execute_simulate_job_test.rb
      create  app/jobs/execute_simulate_job.rb
# app/jobs/execute_simulate_job.rb
class ExecuteSimulateJob < ActiveJob::Base
  queue_as :default

  def perform(*args)
    # Do something later
  end
end

queue_as 可以設定將此工作排進特定的佇列,預設是 default,可以透過 --queue
參數修改:

$ rails g job execute_simulate --queue urgent

使用起來像是這樣:

# 將工作丟進佇列
ExecuteSimulateJob.perform_later record

# 排程明天中午再執行
ExecuteSimulateJob.set(wait_until: Date.tomorrow.noon).perform_later(record)

# 排程一週後執行
ExecuteSimulateJob.set(wait: 1.week).perform_later(record)

# 指定特定的佇列
ExecuteSimulateJob.set(queue: :important).perform_later(record)

設定要使用的 gem:

# config/application.rb
module YourApp
  class Application < Rails::Application
    # 請確保 Gemfile 已經有安裝所要使用的 gem
    config.active_job.queue_adapter = :resque
  end
end

回呼(Callback)

共有以下 6 個註冊點:

  • before_enqueue
  • around_enqueue
  • after_enqueue
  • before_perform
  • around_perform
  • after_perform

使用方式與 controller、model 中的回呼寫法是一樣的:

class ExecuteSimulateJob < ActiveJob::Base
  queue_as :default
 
  before_enqueue do |job|
    # 在佇列前執行
  end
 
  around_perform do |job, block|
    # 在工作開始前執行
    block.call
    # 在工作完成後執行
  end
 
  def perform
    # Do something later
  end
end

Asynchronous Mails

如果寄信工作也要丟到工作佇列,先別急著寫工作檔,Rails 4.2 的 Action Mailer 內建了 DeliveryJob 類別,並提供 deliver_later 方法將寄信工作推進佇列。

你可以像這樣使用:

# 使用 #deliver_later 透過 DeliveryJob 來寄信
MyMailer.welcome(@user).deliver_later

# 若不想丟到工作住列,也有 #deliver_now 可以使用
MyMailer.welcome(@user).deliver_now

Adequate Record

由 Aaron Patterson 所作,用於提高 #find#find_by 等一些常用查詢指令的速度,可以提升 Active Record 約莫兩倍的效能。

主要是因為 Active Record 在產生 SQL 過程有很多重複的片段不斷被重新製造,這其實可利用快取將重複的片段保存起來。細節請參考 Aaron Patterson 的網誌

Web Console

如果你有用過 better_errors gem,那麼這就是類似的東西了。Rails 4.2 在開發環境下的錯誤頁面會多出一個 rails console 命令窗可以使用,除了一般 irb 的功能以外,也可以存取到該次請求中定義的實體與區域變數。

Rails 4.2 Web Console

但不只有錯誤頁面才有命令窗可以使用,也可以在任何 view 的檔案中的任何位置加入 <%= console %>,只要渲染到該檔案,就有命令窗可以使用。

外鍵(Foreign Keys)

Rails 4.2 遷移指令支援了 SQL 的外鍵定義功能,目前只有 mysql、mysql2 與 postgresql 可用。

# 將 `articles.author_id` 定義為參考 `authors.id` 的外鍵
add_foreign_key :articles, :authors

# 若命名沒有按照慣例,也可以透過設定達到
# 例如將 `articles.author_id` 定義為參考 `users.lng_id` 的外鍵
add_foreign_key :articles, :users, column: :author_id, primary_key: "lng_id"

# 刪除 `accounts.branch_id` 外鍵
remove_foreign_key :accounts, :branches
 
# 刪除 `accounts.owner_id` 外鍵
remove_foreign_key :accounts, column: :owner_id

這個功能在 migration 與 model 等會產生遷移檔的產生器,當使用到 references 型別時也會自動的被使用,例如:

$ rails g migration add_user_to_posts user:references
      invoke  active_record
      create    db/migrate/20141222180048_add_user_to_posts.rb
class AddUserToPosts < ActiveRecord::Migration
  def change
    add_reference :posts, :user, index: true
    add_foreign_key :posts, :users # Rails 4.2 功能
  end
end
繼續閱讀

留言

My Rails Template

Why

We engineers always hate repeating doing the same thing.

Take me for instance, everytime I create a new rails project, I edit GemFile to install lots of useful gems such as devise, carrierwave, etc, and then download Twitter Bootstrap with newest version, extract it into vendor/assets/images, vendor/assets/javascripts and vendor/assets/stylesheets, finally, replace ../img/xxx.png into xxx.png in bootstrap.css and bootstrap.min.css.

What

My template does two things:

  1. Automatically install the following gems:

    • devise
    • cancan
    • carrierwave
    • simple_form
    • dynamic_form
    • will_paginate
    • rdiscount
    • rails-i18n
  2. Download Twitter Boostrap with the newest version, and correspondingly extract files into vendor/assets/

Usage

rails new myapp -m=https://raw.github.com/gist/4010690

Alternatively, if you encounter some SSL problem during the previous command, please download the file directly and run:

rails new myapp -m=filename

Source Code

{% gist 4010690 %}

繼續閱讀

留言

params["key"] 和 params[:key]

今天在寫學長託付的 API Server 時,突然對 controller 中用到的 params 變數感到好奇,我想知道為什麼 params[:id]params["id"] 都可以 access 同一份資料,於是展開了 trace code 奇幻之旅。

我臨時寫了一個程式,先 trace...

繼續閱讀

留言

將現有資料庫導入 Rails ActiveModel

官方說明文件上沒有這方面的說明,網路上爬到的解大多過時,或者根本不正確,於是作此文分享我目前的方法。

當使用 rails 指令產生新的 Model 時,這樣的寫法很常見:

$ rails g model my_model
      invoke  active_record
      create    db/migrate/yyyymmddhhMMss_create_my_models.rb
      create    app/models/my_model.rb
      invoke    test_unit
      create      test/unit/my_model_test.rb
      create      test/fixtures/my_models.yml
class CreateMyModels < ActiveRecord::Migration
  def self.up
    create_table :my_models do |t|
      t.timestamps
    end
  end
 
  def self.down
    drop_table :my_models
  end
end
# app/models/my_model.rb
class MyModel < ActiveRecord::Base
end

當然,如果對於要產生新的 schema 來說,這的確是個好方法。但有有時候我們需要使用資料庫上既有的 table(例如接管某個 legacy database,尤其是這個 database 原先不是架構在 RoR 之上),如此 migratoin 顯得多餘,如果只是單純想要有個 model 介面來撈資料,可能需要解決以下問題:

  1. 不要產生 migration

    因為我們只是想去撈資料,而非更動 table,自然不需要 migrate

  2. 並非所有 table 都是複數

    指令產生的 migration 可見 table name 預設為 model name 的複數型,但我們的 table 未必如此

  3. 並非所有的 primary key 都叫 id

    schema 裡頭除了定義使用者自訂的 column 之外,rails 預設會加上 id 和時間戳記(created_at 和 updated_at),而 id 同時也是 primary key 且 預設為 auto_increment,此外 primary key 也有可能是字串

  4. 有些 primary key 包涵多個 column(composite primary key)

    又稱「組合鍵」,即主鍵由多個欄位組成。遺憾的,RoR 並沒有支援 composite primary key

以下聽我娓娓道來。

不要產生 Migration

那就是直接建立 app/models/my_model.rb

# app/models/my_model.rb
class MyModel < ActiveRecord::Base
end

但是建議最好還是以指令方式產生比較省時,記得加上 –skip-migration 參數即可,除此還可以得到 test unit。

$ rails g model my_model --skip-migration
      invoke  active_record
      create    app/models/my_model.rb
      invoke    test_unit
      create      test/unit/my_model_test.rb
      create      test/fixtures/my_models.yml

設定 Table Name

第一步已經完成了,假設我們想撈資料的 table name 為 my_table,我們產生的 model name 為 my_model,按照 Rails convention 法則,應該要有 table 名為 my_models 才是。但假設我們卻想掛羊頭賣狗肉地把 my_model 裝在 my_table 上:

app/models/my_model.rb
class MyModel < ActiveRecord::Base
  set_table_name :my_table
end

試試看,成功了:

$ rails c
Loading development environment (Rails 3.0.9)
irb(main):001:0> MyModel
=> MyModel(id: integer, title: string, description: text)

但這只有在 id column 跟好是 primary key 才會這麼順利,萬一 primary key 是別的 column,光這樣這定還不夠。

設定 Primary Key

假設 primary key 是 title:

# app/models/my_model.rb
class MyModel < ActiveRecord::Base
  set_table_name :my_table
  set_primary_key :title # or 'self.primary_key = :title'
  # This method is also aliased as primary_key=
end

正常情況下,Active Record 會在我們產生新的 record 時自動產生 primary key,名為 id ,且是會遞增的整數。然而,如果我們在 table 上使用其他 column name 做為 primary key 的名字,我們同時有責任要重新定義該 record 上的 primary key,正如上方展示的程式碼。

神奇的事發生了,我們仍可以透過 id 方法去存取的 primary key。這是因為 Active Record 慣於將 primary key 的操作綁在 id 和 id= 等方法,而 set_primary_key 只是投射 id 會指向的 column name。下方程式碼展示了在 primary key 是 "title" 的情況下用 id 去存取 primary key:

irb(main):010:0> MyModel.first.stuid
=> "A Title"
irb(main):011:0> MyModel.first.id # same as MyModel.first.title
=> "A Title"
irb(main):012:0> MyModel.find("A Title")
=> #<MyModel id: "A Title", title: "A Title", description: "A Description">
irb(main):013:0>

相當乾淨俐落地解決了第三個問題,至於第四個問題,composite primary key,該怎麼辦?假設該 composite primary keys 分別為 title 和 description。恩,加個's':

class MyModel < ActiveRecord::Base
  set_table_name :my_table
  set_primary_keys :title, :description
end

然後你會免費被贈送一個 NoMethodError Exception。在你一邊擦螢幕時,請聽我說:既然RoR都說不支援了,哪有這麼簡單的事情讓你加個's'就解決呢?這個問題目前的解決方案是透過 plugin 來實現。你可以參考這一頁。你會發現,裝完這個 plugin 之後,確實就只要加上一個 s。XD

繼續閱讀