Calvert's murmur

Rails Engine 入門

2018-06-11

約 28166 字 / 需 156 分鐘閱讀

原文:Ruby on Rails GuidesGetting Started with Engines

Rails::Engine 允許你包裝特定的 Rails 應用程式或功能子集,並與其他應用程式共享或在更大的封裝應用程式中。每個 Rails::Application 都只是一個引擎,它允許簡單的功能及應用程式共享。

任何 Rails::Engine 也是一個 Rails::Railtie,所以在 railties 可用的相同方法(如 rake_tasksgenerators)和配置選項也可用於引擎。

在本指南中,你將瞭解引擎以及如何透過乾淨且非常易於使用的介面,為主應用程式提供附加功能。

閱讀本指南後,你將知道:

  • 是什麼構成了引擎。
  • 如何產生引擎。
  • 如何為引擎建立功能。
  • 如何將引擎掛載到應用程式中。
  • 如何在應用程式中覆寫引擎功能。
  • 透過載入和配置 Hook 避免載入 Rails 框架。

1. 什麼是引擎?

引擎可以看成是為主應用程式提供功能的微型應用程式。Rails 應用程式實際上只是一個「增壓」引擎,Rails::Application 類別繼承了許多來自 Rails::Engine 的行為。

因此,引擎和應用程式可以被認為是幾乎相同的東西,只有存在細微的差異,你將在本指南中看到。引擎和應用程式也共享一個公用的結構。

引擎也與外掛程式密切相關。兩者共享一個公用的 lib 目錄結構,並且都是使用 rails plugin new 產生器產生的。不同之處在於,引擎被 Rails 視為一個「完整外掛程式」(如傳遞給產生器指令的 --full 選項所示)。我們實際上會在此處使用 --mountable 選項,其中包含 --full 的所有功能,甚至更多。本指南全文將這些「完整外掛程式」簡稱為「引擎」。引擎可以是外掛程式,外掛程式可以是引擎。

將在本指南中建立的引擎稱為「blorgh」。此引擎將為其主應用程式提供部落格功能,允許建立新文章和評論。在本指南的開頭,你將獨自在引擎內工作,但在後面的章節中,你將看到如何將其掛載到應用程式中。

引擎也可以與主應用程式隔離。意思是應用程式可以擁有路由輔助方法提供的路徑,如 articles_path,並使用一個也提供了名為 articles_path 路徑的引擎,而且兩者不會發生衝突。除此之外,控制器、模型和資料表名稱也具有命名空間。本指南稍後將介紹如何執行此操作。

重要的是在任何時候牢記這一點,應用程式應該總是優先於其引擎。應用程式是對其環境中發生的事情有最終決定權的物件。引擎應該只是加強它,而不是徹底改變它。

查看其他引擎的範例,查看 Devise,一個為其父應用程式提供身份驗證的引擎,或 Thredded,一個提供論壇功能的引擎。還有 Spree 提供電子商務平台,以及 Refinery CMS,一個 CMS 引擎。

最後,如果沒有 James Adam、Piotr Sarnacki、Rails 核心團隊和其他一些人的工作,引擎是不可能實現的。如果你見過他們,別忘了說聲謝謝!

2. 產生一個引擎

要產生引擎,你需要執行外掛程式產生器並根據需求將選項傳遞給它。對於「blorgh」範例,你需要建立一個「可掛載的」引擎,在終端機中執行此指令:

$ rails plugin new blorgh --mountable

輸入以下內容即可看到外掛程式產生器的完整選項清單:

$ rails plugin --help

--mountable 選項告訴產生器你想建立一個「可掛載的」且命名空間隔離的引擎。此產生器將提供與 --full 選項相同的骨架結構。--full 選項告訴產生器你想建立一個引擎,包含提供以下內容的骨架結構:

  • 一個 app 目錄樹

  • 一個 config/routes.rb 檔案:

    Rails.application.routes.draw do
    end
  • 一個在 lib/blorgh/engine.rb 的檔案,其功能與標準 Rails 應用程式的 config/application.rb 檔案相同:

    module Blorgh
      class Engine < ::Rails::Engine
      end
    end

--mountable 選項會加入 --full 選項:

  • 靜態資源清單檔案(application.jsapplication.css

  • 一個具有命名空間的 ApplicationController

  • 一個具有命名空間的 ApplicationHelper

  • 引擎的佈局視圖樣板

  • 命名空間隔離的 config/routes.rb

    Blorgh::Engine.routes.draw do
    end
  • 命名空間隔離的 lib/blorgh/engine.rb

    module Blorgh
      class Engine < ::Rails::Engine
        isolate_namespace Blorgh
      end
    end

此外,--mountable 選項透過將以下內容加到虛擬應用程式的路由檔案 test/dummy/config/routes.rb,告訴產生器將引擎掛載到位於 test/dummy 的虛擬測試應用程式中:

mount Blorgh::Engine => "/blorgh"

2.1 引擎內部

2.1.1 關鍵檔案

在這個全新引擎的根目錄有個 blorgh.gemspec 檔案。當你稍後將引擎掛載到應用程式中時,你將在 Rails 應用程式的 Gemfile 中使用此行進行操作:

gem 'blorgh', path: 'engines/blorgh'

不要忘記像往常一樣執行 bundle install。透過在 Gemfile 中指定它作為一個 gem,Bundler 將載入它,解析此 blorgh.gemspec 檔案並載入 lib 目錄中的一個名為 lib/blorgh.rb 的檔案。這個檔案載入了 blorgh/engine.rb 檔案(位於 lib/blorgh/engine.rb)並定義了一個名為 Blorgh 的基本模組。

require "blorgh/engine"

module Blorgh
end

有些引擎選擇使用這個檔案為其引擎提供全域配置選項。這是一個比較好的想法,所以如果你想提供配置選項,那麼定義引擎 module 的檔案就是完美的選擇。把方法放到模組裡面就可以了。

lib/blorgh/engine.rb 是引擎的基底類別:

module Blorgh
  class Engine < ::Rails::Engine
    isolate_namespace Blorgh
  end
end

透過繼承 Rails::Engine 類別,這個 gem 會通知 Rails 在指定路徑上有一個引擎,並且將引擎正確地掛載到應用程式,執行諸如將引擎的 app 目錄加到模型、Mailer、控制器和視圖的載入路徑。

這裡的 isolate_namespace 方法值得特別注意。這個呼叫負責將控制器、模型、路由和其他東西隔離到自己的命名空間中,使其遠離應用程式內部的類似元件。沒有這個,引擎的元件就有可能「洩漏」到應用程式,造成不必要的中斷,或者重要的引擎元件可能會被應用程式中類似名稱的東西覆蓋。這種衝突的其中一個例子就是輔助方法。在不呼叫 isolate_namespace 的情況下,引擎的輔助方法將被包含在應用程式的控制器中。

高度推薦在 Engine 類別定義中保留 isolate_namespace。如果沒有它,引擎中產生的類別可能 會與應用程式發生衝突。

此命名空間的隔離意思是透過呼叫 bin/rails g model 產生的模型,如 bin/rails g model article,名稱不是 Article,而是具有命名空間的名稱 Blorgh::Article。另外,模型的資料表也具有命名空間,成為 blorgh_articles,而不僅僅是 articles。與模型命名空間類似,一個名為 ArticlesController 的控制器變成了 Blorgh::ArticlesController,並且該控制器的視圖不會在 app/views/articles,而是 app/views/blorgh/articles。Mailer 也具有命名空間。

最後,路由也被隔離在引擎內。這是命名空間中最重要的部分之一,稍後將在本指南的路由部分中進行討論。

2.1.2 app 目錄

在 app 目錄中有標準的 assetscontrollershelpersmailersmodelsviews 目錄,你應該已從應用程式對這些目錄瞭若指掌。helpersmailersmodels 目錄是空的,因此在本節中沒有描述它們。當我們編寫引擎時,我們將在未來的章節中更深入地研究模型。

app/assets 目錄中,有 imagesjavascriptsstylesheets 目錄,你應該早已熟悉這些目錄,因為它們與應用程式類似。然而,這裡的一個不同之處在於,每個目錄都包含一個帶有引擎名稱的子目錄。因為這個引擎具有命名空間,它的靜態資源也應該如此。

app/controllers 目錄中有一個 blorgh 目錄,其中包含了一個名為 application_controller.rb 的檔案。該檔案將為引擎的控制器提供任何公用功能。blorgh 目錄是存放引擎的其它控制器的地方。透過將它們放在這個命名空間的目錄中,可以防止它們與其它引擎甚至是應用程式中的同名控制器發生衝突。

引擎內的 ApplicationController 類別命名就像一個 Rails 應用程式,以便更容易將應用程式轉換為引擎。

因為 Ruby 不斷尋找的方式,你可能會遇到一種狀況,你的控制器繼承自主應用程式控制器,而不是你的引擎應用程式控制器。Ruby 能夠解析 ApplicationController 常數,因此不會觸發自動載入機制。有關更多詳細資訊,請查看自動載入和重新載入常數指南中的常數什麼時候不被忽略章節。預防這種狀況發生最好的方法是使用 require_dependency 來確保引擎的應用程式控制器已被載入。例如:

# app/controllers/blorgh/articles_controller.rb:
require_dependency "blorgh/application_controller"

module Blorgh
  class ArticlesController < ApplicationController
    ...
  end
end

不要使用 require,因為它會中斷開發環境中類別的自動重新載入,使用 require_dependency 確保類別以正確的方式載入和卸除。

最後,app/views目錄中有一個 layouts 資料夾,其中包含了一個檔案在 blorgh/application.html.erb。該檔案允許你指定引擎的佈局。如果此引擎要作為獨立引擎使用,那麼你可以在此檔案中加入任何對佈局的客製化,而不是應用程式的 app/views/layouts/application.html.erb 檔案。

如果不希望對引擎的使用者強制佈局,那麼可以刪除此檔案並在引擎的控制器中引用不同的佈局。

2.1.3 bin 目錄

這個目錄包含一個 bin/rails 檔案,它讓你能夠像在應用程式中一樣使用 rails 子指令和產生器。也就是說,你可以很容易地透過執行這樣的指令來產生新的控制器和模型:

$ bin/rails g model

請記住,在 Engine 類別中具有 isolate_namespace 的引擎內部使用這些指令產生的任何東西,都將具有命名空間。

2.1.4 test 目錄

test 目錄是用來存放引擎測試的地方。為了測試引擎,在 test/dummy 中嵌入了一個精簡版的 Rails 應用程式。這個應用程式會將引擎掛載到 test/dummy/config/routes.rb 檔案:

Rails.application.routes.draw do
  mount Blorgh::Engine => "/blorgh"
end

該行將引擎掛載到 /blorgh 路徑上,這將使應用程式僅能透過該路徑存取它。

在測試目錄裡面有 test/integration 目錄,引擎的整合測試應該放在這裡。也可以在 test 目錄中建立其它目錄。例如,你可能希望為模型測試建立一個 test/models 目錄。

3. 提供引擎功能

本指南涵蓋的引擎提供了送交文章和評論的功能,遵循與入門指南類似的思路,並伴隨一些新的觀點。

3.1 產生 Article 資源

產生部落格引擎的第一件事是 Article 模型和相關的控制器。要快速建立這個,你可以使用 Rails 鷹架產生器。

$ bin/rails generate scaffold article title:string text:text

該指令會輸出以下資訊:

invoke  active_record
create    db/migrate/[timestamp]_create_blorgh_articles.rb
create    app/models/blorgh/article.rb
invoke    test_unit
create      test/models/blorgh/article_test.rb
create      test/fixtures/blorgh/articles.yml
invoke  resource_route
 route    resources :articles
invoke  scaffold_controller
create    app/controllers/blorgh/articles_controller.rb
invoke    erb
create      app/views/blorgh/articles
create      app/views/blorgh/articles/index.html.erb
create      app/views/blorgh/articles/edit.html.erb
create      app/views/blorgh/articles/show.html.erb
create      app/views/blorgh/articles/new.html.erb
create      app/views/blorgh/articles/_form.html.erb
invoke    test_unit
create      test/controllers/blorgh/articles_controller_test.rb
invoke    helper
create      app/helpers/blorgh/articles_helper.rb
invoke  test_unit
create    test/application_system_test_case.rb
create    test/system/articles_test.rb
invoke  assets
invoke    js
create      app/assets/javascripts/blorgh/articles.js
invoke    css
create      app/assets/stylesheets/blorgh/articles.css
invoke  css
create    app/assets/stylesheets/scaffold.css

鷹架產生器所做的第一件事是呼叫 active_record 產生器,它為資源產生遷移和模型。但是請注意,遷移被命名為 create_blorgh_articles,而不是常見的 create_articles。這是由於 Blorgh::Engine 類別的定義中呼叫 isolate_namespace 方法造成的。這裡的模型也具有命名空間,由於 Engine 類別中呼叫 isolate_namespace,所以放在 app/models/blorgh/article.rb 而不是 app/models/article.rb

接下來,為此模型呼叫 test_unit 產生器,在 test/models/blorgh/article_test.rb(而不是 test/models/article_test.rb)產生一個模型測試,並在 test/fixtures/blorgh/articles.yml(而不是 test/fixtures/articles.yml)產生測試資料。

然後,插入一行資源路由到引擎的 config/routes.rb 檔案。這一行只是 resources :articles,將引擎的 config/routes.rb 檔案變成這樣:

Blorgh::Engine.routes.draw do
  resources :articles
end

這裡請注意,路由是被定義在 Blorgh::Engine 物件上,而不是 YourApp::Application 類別。這是為了使引擎路由被限制在引擎本身,並且如測試目錄部分所示可以掛載在特定點上。它還會使引擎的路由與應用程式內的路由隔離。本指南的路由部分有詳細的說明。

接下來,scaffold_controller 產生器被呼叫,產生一個名為 Blorgh::ArticlesController(在 app/controllers/blorgh/articles_controller.rb)的控制器及其相關視圖在 app/views/blorgh/articles。此產生器還為控制器產生測試(test/controllers/blorgh/articles_controller_test.rb)和輔助方法(app/helpers/blorgh/articles_helper.rb)。

這個產生器建立的所有東西都具有命名空間。控制器的類別被定義在 Blorgh 模組:

module Blorgh
  class ArticlesController < ApplicationController
    ...
  end
end

ArticlesController 類別繼承自 Blorgh::ApplicationController,而不是應用程式的 ApplicationController

app/helpers/blorgh/articles_helper.rb 中的輔助方法也具有命名空間:

module Blorgh
  module ArticlesHelper
    ...
  end
end

這有助於防止與任何其它可能具有文章資源的引擎或應用程式發生衝突。

最後,產生了兩個靜態資源檔案:app/assets/javascripts/blorgh/articles.jsapp/assets/stylesheets/blorgh/articles.css。稍後你會看到如何使用這些東西。

你可以透過在引擎的根目錄執行 bin/rails db:migrate 來執行由鷹架產生器產生的遷移,然後在 test/dummy 中執行 rails server 來查看引擎的內容。當你開啟 http://localhost:3000/blorgh/articles 時,將看到已產生的預設鷹架。點看看吧!你剛剛產生了你的第一個引擎的第一個功能。

如果你更喜歡使用主控台,也可以像 Rails 應用程式一樣使用 rails console。記住:Article 模型是具有命名空間的,因此要引用它,你必須稱呼它為 Blorgh::Article

>> Blorgh::Article.find(1)
=> #<Blorgh::Article id: 1 ...>

最後一件事是這個引擎的 articles 資源應該是引擎的根。每當有人進入掛載引擎的根路徑時,應該顯示文章列表。如果將此行插入引擎內的 config/routes.rb 檔案中,便可以實現這一點:

root to: "articles#index"

現在人們只需要到引擎的根目錄來查看所有文章,而不是訪問 /articles。意思是現在你只需要去 http://localhost:3000/blorgh,而不是 http://localhost:3000/blorgh/articles

3.2 產生 Comments 資源

現在,引擎可以建立新的文章,那麼加上評論功能也是有意義的。為此,你需要建立評論模型、評論控制器,然後修改文章鷹架以顯示評論並允許人們建立新評論。

從應用程式根目錄執行模型產生器。告訴它產生一個 Comment 模型,資料表有兩欄:integer 型別的 article_id 和 text 型別的 text 欄位。

$ bin/rails generate model Comment article_id:integer text:text

這將輸出以下內容:

invoke  active_record
create    db/migrate/[timestamp]_create_blorgh_comments.rb
create    app/models/blorgh/comment.rb
invoke    test_unit
create      test/models/blorgh/comment_test.rb
create      test/fixtures/blorgh/comments.yml

該產生器呼叫將產生它所需的必要模型檔案,將檔案放在命名空間 blorgh 目錄下,並建立一個名為 Blorgh::Comment 的模型類別。現在執行遷移來建立我們的 blorgh_comments 資料表:

$ bin/rails db:migrate

要在文章上顯示評論,編輯 app/views/blorgh/articles/show.html.erb 並在「編輯」連結前加上此行:

<h3>Comments</h3>
<%= render @article.comments %>

這一行需要在 Blorgh::Article 模型中定義一個 has_many 關聯的評論,不過現在還沒有。要定義它,開啟 app/models/blorgh/article.rb 並將這一行加到模型中:

has_many :comments

將模型變成:

module Blorgh
  class Article < ApplicationRecord
    has_many :comments
  end
end

因為 has_many 是在 Blorgh 模組的一個類別中定義的,Rails 會知道你想為這些物件使用 Blorgh::Comment 模型,所以不需要在這裡指定使用 :class_name 選項。

接下來,需要有一個表單,以便可以在文章上建立評論。要加上它,在 app/views/blorgh/articles/show.html.erb 中呼叫呈現 @article.comments 的下方加上這一行:

<%= render "blorgh/comments/form" %>

接下來,這一行將呈現的部分視圖需要存在。建立一個新目錄在 app/views/blorgh/comments,並且建立一個名為 _form.html.erb 的新檔案,該檔案包含這些內容以便建立所需的部分視圖:

<h3>New comment</h3>
<%= form_with(model: [@article, @article.comments.build], local: true) do |form| %>
  <p>
    <%= form.label :text %><br>
    <%= form.text_area :text %>
  </p>
  <%= form.submit %>
<% end %>

送交此表單時,它將嘗試對引擎內的 /articles/:article_id/comments 路由執行 POST 請求。此路由目前不存在,但是可以透過將 config/routes.rb 中的 resources :articles 更改為以下幾行來建立:

resources :articles do
  resources :comments
end

這會為評論建立一個表單所需要的巢狀路由。

路由現在已經存在了,但是此路由所去的控制器還不存在。要建立它,從應用程式根目錄執行這個指令:

$ bin/rails g controller comments

該指令會輸出以下資訊:

create  app/controllers/blorgh/comments_controller.rb
invoke  erb
 exist    app/views/blorgh/comments
invoke  test_unit
create    test/controllers/blorgh/comments_controller_test.rb
invoke  helper
create    app/helpers/blorgh/comments_helper.rb
invoke  assets
invoke    js
create      app/assets/javascripts/blorgh/comments.js
invoke    css
create      app/assets/stylesheets/blorgh/comments.css

該表單會向 /articles/:article_id/comments 發出 POST 請求,對應到 Blorgh::CommentsController 中的 create 動作。這個動作需要被建立,可以透過在 app/controllers/blorgh/comments_controller.rb 的類別定義中放入以下幾行來完成:

def create
  @article = Article.find(params[:article_id])
  @comment = @article.comments.create(comment_params)
  flash[:notice] = "Comment has been created!"
  redirect_to articles_path
end

private
  def comment_params
    params.require(:comment).permit(:text)
  end

這是讓新評論表單運作所需的最後一步。然而,顯示評論還不太正確。如果你現在要建立評論,則會看到此錯誤:

Missing partial blorgh/comments/_comment with {:handlers=>[:erb, :builder],
:formats=>[:html], :locale=>[:en, :en]}. Searched in:   *
"/Users/ryan/Sites/side_projects/blorgh/test/dummy/app/views"   *
"/Users/ryan/Sites/side_projects/blorgh/app/views"

引擎無法找到呈現評論所需的部分視圖。Rails 會先尋找應用程式的(test/dummyapp/views目錄,然後在引擎的 app/views 目錄中尋找。當找不到它時,便會拋出這個錯誤。引擎知道要尋找 blorgh/comments/_comment,因為它接收的模型物件是來自 Blorgh::Comment 類別。

目前,這個部分視圖僅負責呈現評論文字。在 app/views/blorgh/comments/_comment.html.erb 建立新一個檔案,並將此行放入其中:

<%= comment_counter + 1 %>. <%= comment.text %>

comment_counter 區域變數是由呼叫 <%= render @article.comments %> 給我們的,它會自動定義並在迭代每個評論時遞增計數器。在這個例子中,它用於在每個評論建立時顯示一個小數字。

完成了部落格引擎的評論功能。現在是時候在應用程式中使用它了。

4. 安裝到應用程式

在應用程式中使用引擎非常簡單。本節會介紹如何將引擎掛載到應用程式和所需的初始設定,以及將引擎連結到由應用程式提供的 User 類別,以便為引擎中的文章和評論提供所有權。

4.1 掛載引擎

首先,需要在應用程式的 Gemfile 中指定引擎。如果沒有應用程式可以方便地進行測試,可以像這樣在引擎目錄外使用 rails new 指令產生一個:

$ rails new unicorn

通常,可以像一般的 gem 一樣,透過在 Gemfile 內將引擎指定在內來完成。

gem 'devise'

但是,因為你正在本機上開發 blorgh 引擎,你需要在 Gemfile 指定 :path 選項:

gem 'blorgh', path: 'engines/blorgh'

然後執行 bundle 來安裝 gem。

如先前所述,透過將 gem 放在 Gemfile 中,它將會在 Rails 載入時被載入。它會先從引擎載入 lib/blorgh.rb,然後是 lib/blorgh/engine.rb,它是定義引擎主要功能的檔案。

要讓引擎的功能可以從應用程式存取,需要將它掛載到應用程式的 config/routes.rb 檔案:

mount Blorgh::Engine, at: "/blog"

這一行會將引擎掛載到應用程式中的 /blog。當應用程式以 rails server 執行時,可以在 http://localhost:3000/blog 上訪問它。

其他引擎,如 Devise,透過讓你在路由中指定自訂輔助方法(例如 devise_for)來處理這一點。這些輔助方法完成相同的事,將引擎的部分功能掛載到預先定義可客製化的路徑上。

4.2 安裝引擎

引擎包含需要在應用程式資料庫中建立的 blorgh_articlesblorgh_comments 資料表的遷移,以便引擎的模型可以正確查詢它們。要將這些遷移複製到應用程式中,請從 Rails 引擎的 test/dummy 目錄執行以下指令:

$ bin/rails blorgh:install:migrations

如果你有多個需要複製遷移的引擎,請改用 railties:install:migrations

$ bin/rails railties:install:migrations

第一次執行這個指令時,將複製引擎中的所有遷移。下次執行時,它只會複製尚未被複製的遷移。第一次執行指令會輸出以下資訊:

Copied migration [timestamp_1]_create_blorgh_articles.blorgh.rb from blorgh
Copied migration [timestamp_2]_create_blorgh_comments.blorgh.rb from blorgh

第一個時間戳記([timestamp_1])將是目前時間,第二個時間戳記([timestamp_2])將是目前時間加上一秒。之所以這樣做是為了在應用程式中任何現有的遷移之後執行引擎的遷移。

要在應用程式中執行這些遷移,只需執行:bin/rails db:migrate。當透過 http://localhost:3000/blog 訪問引擎時,文章會是空的。這是因為在應用程式建立的資料表與引擎內建立的資料表不同。繼續,玩玩新掛載的引擎吧。你會發現它和只有一個引擎時是相同的。

如果你只想從一個引擎執行遷移,可以透過指定 SCOPE 來完成:

bin/rails db:migrate SCOPE=blorgh

如果你想在刪除引擎之前還原引擎的遷移,這可能很有用。要還原 blorgh 引擎的所有遷移,你可以執行以下指令:

bin/rails db:migrate SCOPE=blorgh VERSION=0

4.3 使用應用程式提供的類別

4.3.1 使用應用程式提供的模型

引擎建立後,可能需要使用應用程式中的特定類別來提供引擎和應用程式之間的連結。以 blorgh 引擎的情況來說,讓文章和評論有作者會很有意義。

一個典型的應用程式可能有一個 User 類別,用於表示文章或評論的作者。但可能會出現這樣的情況,應用程式以不同名稱命名此類別,如 Person。因此,引擎不應該明確的為 User 類別寫死關聯。

為了在這種情況下保持簡單,應用程式將有一個名為 User 的類別,來表示應用程式的使用者(我們將進一步進行配置)。它可以在應用程式中使用這個指令產生:

$ bin/rails g model user name:string

需要在這裡執行 bin/rails db:migrate 指令,以確保我們的應用程式有 users 資料表供將來使用。

同樣地,為了簡單起見,文章表單會有一個名為 author_name 的新文字欄位,使用者可以選擇放置他們的名字。引擎將使用這個名稱建立一個新的 User 物件或者尋找一個擁有該名稱的物件。引擎會將文章與找到或建立的 User 物件建立關聯。

首先,author_name 文字欄位需要被加到引擎內的 app/views/blorgh/articles/_form.html.erb 部分視圖。可以在 title 欄位上方加入以下程式碼:

<div class="field">
  <%= form.label :author_name %><br>
  <%= form.text_field :author_name %>
</div>

接下來,我們需要更新 Blorgh::ArticleController#article_params 方法來允許新的表單參數:

def article_params
  params.require(:article).permit(:title, :text, :author_name)
end

Blorgh::Article 模型應該有一些程式碼將 author_name 欄位轉換為一個實際的 User 物件,並在儲存文章前將其關聯為該文章的 author。它還需要為此欄位設定一個 attr_accessor,以便為其定義設值(setter)方法和取值(getter)方法。

為此,你需要在 app/models/blorgh/article.rbauthor_name 加上 attr_accessor、作者的關聯和 before_validation 呼叫。author 關聯會暫時寫死到 User 類別。

attr_accessor :author_name
belongs_to :author, class_name: "User"

before_validation :set_author

private
  def set_author
    self.author = User.find_or_create_by(name: author_name)
  end

透過用 User 類別表示 author 關聯的物件,在引擎和應用程式之間建立了一個連結。需要有一種方法將 blorgh_articles 資料表中的記錄與 users 資料表中的記錄關聯起來。因為該關聯名為 author,所以應該在 blorgh_articles 資料表中加上 author_id 欄位。

要產生這個新欄位,請在引擎中執行這個指令:

$ bin/rails g migration add_author_id_to_blorgh_articles author_id:integer

由於遷移的名稱和後面的欄位說明,Rails 會自動知道你想要將欄位加到特定資料表,並將其寫入遷移中。因此不用手動編寫遷移。

這個遷移需要在應用程式上執行。為此,首先必須使用這個指令複製它:

$ bin/rails blorgh:install:migrations

請注意,這裡只複製了一個遷移。這是因為前兩次遷移已經在第一次執行這個指令時被複製了。

NOTE Migration [timestamp]_create_blorgh_articles.blorgh.rb from blorgh has been skipped. Migration with the same name already exists.
NOTE Migration [timestamp]_create_blorgh_comments.blorgh.rb from blorgh has been skipped. Migration with the same name already exists.
Copied migration [timestamp]_add_author_id_to_blorgh_articles.blorgh.rb from blorgh

使用以下指令執行遷移:

$ bin/rails db:migrate

現在,一切都已經到位,我們完成了作者(用 users 資料表中的記錄表示)與文章(用引擎的 blorgh_articles 資料表中的記錄表示)的關聯。

最後,作者的名稱應該會顯示在文章頁面。在 app/views/blorgh/articles/show.html.erb 內的「標題」輸出上方加上以下程式碼:

<p>
  <b>Author:</b>
  <%= @article.author.name %>
</p>
4.3.2 使用應用程式提供的控制器

預設情況下,由於 Rails 控制器通常會透過繼承 ApplicationController 共享程式碼,例如身份驗證和存取 session 變數。然而,Rails 引擎的作用域與主應用程式是隔離的,因此每個引擎的 ApplicationController 都具有獨立的命名空間。獨立的命名空間可避免程式碼衝突,但是引擎的控制器常常需要存取主應用程式的 ApplicationController 中的方法。提供存取的簡單方法是讓引擎具有命名空間的 ApplicationController 繼承自主應用程式的 ApplicationController。對於我們的 Blorgh 引擎,可以透過以下改變 app/controllers/blorgh/application_controller.rb 來完成:

module Blorgh
  class ApplicationController < ::ApplicationController
  end
end

所以,在做出這個改變之後,他們可以訪問主應用程序的ApplicationController,就好像它們是主應用程序的一部分。
預設情況下,引擎的控制器繼承自 Blorgh::ApplicationController。因此透過上述修改,它們將能夠存取主應用程式的 ApplicationController,就好像它們是主應用程式的一部分。

此修改的前提是,引擎需要執行在具有 ApplicationController 的 Rails 應用程式。

4.4 設定引擎

本節介紹如何讓 User 類別成為可配置的,然後介紹引擎的基本配置注意事項。

4.4.1 在應用程式中設定配置

下一步是讓引擎可以客製化在應用程式中所使用的 User 類別。如先前所述,該類別並非總是 User。為了使這個配置可客製化,引擎將有一個名為 author_class 的配置,它將用於指定哪個類別代表應用程式中的使用者。

要定義此配置,你應該在引擎的 Blorgh 模組中使用 mattr_accessor。將此行加到引擎內的 lib/blorgh.rb 中:

mattr_accessor :author_class

這個方法的工作原理與 attr_accessorcattr_accessor 類似,會根據指定名稱為模組提供設值(setter)方法和取值(getter)方法。使用時直接呼叫 Blorgh.author_class 即可。

下一步是將 Blorgh::Article 模型切換到這個新設定。修改這個模型(app/models/blorgh/article.rb)的 belongs_to 關聯:

belongs_to :author, class_name: Blorgh.author_class

Blorgh::Article 模型中的 set_author 方法也應該使用此類別:

self.author = Blorgh.author_class.constantize.find_or_create_by(name: author_name)

為了避免每次都要對 author_class 結果呼叫 constantize,我們可以在 lib/blorgh.rb 檔案中覆寫 Blorgh 模組的 author_class 取值(getter)方法,在回傳結果之前呼叫 constantize

def self.author_class
  @@author_class.constantize
end

此時,上述的 set_author 程式碼將變為:

self.author = Blorgh.author_class.find_or_create_by(name: author_name)

修改後的結果更為簡短,行為更加明確。author_class 方法應該總是回傳 Class 物件。

因為修改後的 author_class 方法回傳 Class 而不是 String,我們還需要修改 Blorgh::Article 模型中的 belongs_to 定義:

belongs_to :author, class_name: Blorgh.author_class.to_s

要在應用程式設定此配置,應該使用初始化程式。透過初始化程式,配置會在應用程式啟動且呼叫引擎的模型前完成設定,這可能取決於現有的配置設定。

在安裝 blorgh 引擎的應用程式中,建立一個新的初始化程式在 config/initializers/blorgh.rb,並放入以下內容:

Blorgh.author_class = "User"

重要的是這裡使用字串版本的類別,而不是直接使用類別。如果我們直接使用類別,Rails 會嘗試載入該類別並引用相關資料表。如果資料表不存在,可能會導致問題產生。因此,應該使用字串,然後在引擎中透過 constantize 將其轉換為類別。

接下來嘗試新增一篇文章。你會發現過程和之前相同,不過這次引擎使用的是 config/initializers/blorgh.rb 內配置設定的類別。

現在,不用在意使用者類別到底是什麼,只需要確認使用者類別是否實作所需的 API。引擎只要求使用者類別實作了 find_or_create_by 方法,此方法回傳了使用者類別的物件,以便在建立文章時與其相關聯。當然,這個物件應該有某種可以被引用的識別碼。

4.4.2 引擎基本配置

有時候你可能會想在引擎中使用初始化程式、國際化或其它配置選項。這些事情是可以的,因為 Rails 引擎和 Rails 應用程式共享很多相同的功能。事實上,Rails 應用程式的功能實際上是由引擎提供的功能的超集合!

如果你希望使用初始化程式(載入引擎前應該執行的程式碼),儲存的位置是在它的 config/initializers 資料夾。此目錄的功能在配置指南的初始化程式章節有說明,並且與應用程式中的 config/initializers 目錄完全相同。如果你想使用標準的初始化程式,也是一樣的。

對於語系設定,只需將語系檔案放在 config/locales 目錄,如同在應用程式中一樣。

5. 測試引擎

當引擎建立後,會在 test/dummy 中建立一個小型的虛擬應用程式。這個應用程式被用作引擎的裝載點,使引擎測試非常簡單。你可以透過在目錄中產生控制器、模型或視圖來擴充此應用程式,然後用它們來測試你的引擎。

test 目錄和典型的 Rails 測試環境一樣,支援單元測試、功能測試和整合測試。

5.1 功能測試

在編寫功能測試時,一件值得考慮的事是測試會在 test/dummy 應用程式上執行,而不是引擎。這是由測試環境的設定決定的,引擎需要裝載在應用程式才能測試其主要功能,尤其是控制器。也就是說,如果你在控制器的功能測試中像這樣為控制器編寫一個典型的 GET 請求:

module Blorgh
  class FooControllerTest < ActionDispatch::IntegrationTest
    include Engine.routes.url_helpers

    def test_index
      get foos_url
      ...
    end
  end
end

它的功能還無法正確運作。這是因為應用程式不知道如何將這些請求傳遞到引擎,除非你明確的告訴它如何處理。為此,你必須在程式中將 @routes 實例變數設定為引擎的路由:

module Blorgh
  class FooControllerTest < ActionDispatch::IntegrationTest
    include Engine.routes.url_helpers

    setup do
      @routes = Engine.routes
    end

    def test_index
      get foos_url
      ...
    end
  end
end

這告訴應用程式你想對此控制器的 index 動作執行一個 GET 請求,但你想使用引擎的路由,而不是應用程式的路由。

這也確保了引擎的 URL 輔助方法能夠在測試中正常運作。

6. 改進引擎功能

本節介紹如何在 Rails 主應用程式中加入或覆寫引擎的 MVC 功能。

6.1 覆寫模型和控制器

在 Rails 主應用程式中,可以透過打開類別來擴充引擎模型和控制器類別(因為模型和控制器類別只是繼承 Rails 特定功能的 Ruby 類別)。透過打開類別,可以根據主應用程式的需求重新定義引擎的類別。通常會透過裝飾器模式來實作。

對於簡單的類別修改,可以使用 Class#class_eval。對於複雜的類別修改,可以考慮使用 ActiveSupport::Concern

6.1.1 使用裝飾器和載入代碼時的注意事項

因為這些裝飾器沒有被 Rails 應用程式本身引用,Rails 的自動載入系統不會載入你的裝飾器。也就是說,你需要自己載入它們。

以下是執行此操作的一些範例程式碼:

# lib/blorgh/engine.rb
module Blorgh
  class Engine < ::Rails::Engine
    isolate_namespace Blorgh

    config.to_prepare do
      Dir.glob(Rails.root + "app/decorators/**/*_decorator*.rb").each do |c|
        require_dependency(c)
      end
    end
  end
end

這不僅適用於裝飾器,也適用於任何加入到引擎中但未被主應用程式引用的東西。

6.1.2 使用 Class#class_eval 實作裝飾器模式

加入 Article#time_since_created

# MyApp/app/decorators/models/blorgh/article_decorator.rb

Blorgh::Article.class_eval do
  def time_since_created
    Time.current - created_at
  end
end
# Blorgh/app/models/article.rb

class Article < ApplicationRecord
  has_many :comments
end

覆寫 Article#summary

# MyApp/app/decorators/models/blorgh/article_decorator.rb

Blorgh::Article.class_eval do
  def summary
    "#{title} - #{truncate(text)}"
  end
end
# Blorgh/app/models/article.rb

class Article < ApplicationRecord
  has_many :comments
  def summary
    "#{title}"
  end
end
6.1.3 使用 ActiveSupport::Concern 實作裝飾器模式

使您可以顯著模塊化您的代碼。
使用 Class#class_eval 非常適合簡單的調整,但是對於更複雜的類別修改,你可能需要考慮使用 ActiveSupport::Concern。ActiveSupport::Concern 能管理互相關聯相依模組和類別執行時的載入順序,讓你可以放心模組化你的程式碼。

加入 Article#time_since_created覆寫 Article#summary

# MyApp/app/models/blorgh/article.rb

class Blorgh::Article < ApplicationRecord
  include Blorgh::Concerns::Models::Article

  def time_since_created
    Time.current - created_at
  end

  def summary
    "#{title} - #{truncate(text)}"
  end
end
# Blorgh/app/models/article.rb

class Article < ApplicationRecord
  include Blorgh::Concerns::Models::Article
end
# Blorgh/lib/concerns/models/article.rb

module Blorgh::Concerns::Models::Article
  extend ActiveSupport::Concern

  # 'included do' 內的程式碼可以在被載入的地方(article.rb)執行,而不是在
  # 模組內(blorgh/concerns/models/article)被執行。
  included do
    attr_accessor :author_name
    belongs_to :author, class_name: "User"

    before_validation :set_author

    private
      def set_author
        self.author = User.find_or_create_by(name: author_name)
      end
  end

  def summary
    "#{title}"
  end

  module ClassMethods
    def some_class_method
      'some class method string'
    end
  end
end

6.2 覆寫視圖

當 Rails 在尋找要呈現的視圖時,它首先會在應用程式的 app/views 目錄中尋找。如果找不到,接著就會在所有引擎的 app/views目錄中尋找。

當應用程式要呈現 Blorgh::ArticlesController 的 index 動作的視圖時,它會先在應用程式中尋找 app/views/blorgh/articles/index.html.erb 檔案。如果找不到,接著會在引擎中尋找。

你可以透過在應用程式中建立 app/views/blorgh/articles/index.html.erb 檔案來覆寫這個視圖。然後可以完全改變這個視圖輸出的內容。

現在試著建立 app/views/blorgh/articles/index.html.erb 檔案並放入以下內容:

<h1>Articles</h1>
<%= link_to "New Article", new_article_path %>
<% @articles.each do |article| %>
  <h2><%= article.title %></h2>
  <small>By <%= article.author %></small>
  <%= simple_format(article.text) %>
  <hr>
<% end %>

6.3 路由

預設情況下,引擎與應用程式的路由是隔離的。這是透過在 Engine 類別中呼叫 isolate_namespace 完成的。也就是說,應用程式及其引擎可以具有相同名稱的路由,並且它們不會發生衝突。

config/routes.rb 檔案中,可以在 Engine 類別上定義引擎的路由:

Blorgh::Engine.routes.draw do
  resources :articles
end

因為路由是隔離的,如果你想從應用程式中連結到引擎的某個位置時,必須使用引擎的路由代理方法。呼叫一般路由方法如 articles_path 可能會產生非預期的連結,因為應用程式和引擎可能都定義了這個輔助方法。

例如,就以下範例來說,如果是從應用程式呈現樣板,就會指向應用程式的 articles_path,如果是從引擎呈現樣板,則會指向引擎的 articles_path

<%= link_to "Blog articles", articles_path %>

要確保使用的是引擎的 articles_path 路由輔助方法,我們必須透過與引擎相同名稱的路由代理方法來呼叫這個輔助方法。

<%= link_to "Blog articles", blorgh.articles_path %>

如果你想用類似的方式在引擎內引用應用程式,可以使用 main_app 輔助方法:

<%= link_to "Home", main_app.root_path %>

如果你在引擎中使用上述程式碼,它會總是指向應用程式的根目錄。若不使用 main_app 「路由代理」方法,它可能會指向引擎或應用程式的根目錄,取決於從何處呼叫它。

如果嘗試從引擎中呈現的樣板使用應用程式的路由輔助方法,可能會導致呼叫未定義的方法。如果遇到此類問題,請確保你沒有嘗試在引擎內沒有 main_app 前綴的情況下呼叫應用程式的路由輔助方法。

6.4 靜態資源

引擎內的資源與完整應用程式的工作方式相同。由於引擎類別繼承自 Rails::Engine,應用程式會知道在引擎的 app/assetslib/assets 目錄中尋找靜態資源。

和引擎的所有其它元件一樣,靜態資源應該具有命名空間。也就是說,如果你有一個名為 style.css 的靜態資源,它應該被放在 app/assets/stylesheets/[engine name]/style.css,而不是 app/assets/stylesheets/style.css。如果靜態資源不具有命名空間,主應用程式可能會有一個相同名稱的靜態資源,在這種情況下,應用程式的靜態資源會具有較高的優先權,引擎的靜態資源將被忽略。

想像一下,你有一個靜態資源位於 app/assets/stylesheets/blorgh/style.css,要在應用程式中載入此檔案,只需使用 stylesheet_link_tag 並引用靜態資源即可:

<%= stylesheet_link_tag "blorgh/style.css" %>

你也可以使用 Asset Pipeline 的 require 語法來載入引擎中的靜態資源:

/*
 *= require blorgh/style
*/

記住,若要使用 Sass 或 CoffeeScript 等語言,你應該將相關函式庫加到引擎的 .gemspec

6.5 獨立的靜態資源和預先編譯

有時候,主應用程式不需要載入引擎的靜態資源。例如,你建立了僅存在於引擎中的管理功能。在這種情況下,主應用程式不需要載入 admin.cssadmin.js。只有 gem 的管理後台才需要這些靜態資源。主應用程式在其樣式表中載入 blorgh/admin.css 是沒有意義的。在這種情況下,你應該明確定義這些需要預先編譯的靜態資源。這會告訴 Sprockets 在觸發 bin/rails assets:precompile 時加入你的引擎靜態資源。

你可以在 engine.rb 定義需要預先編譯的靜態資源:

initializer "blorgh.assets.precompile" do |app|
  app.config.assets.precompile += %w( admin.js admin.css )
end

更多相關資訊,請參閱 Asset Pipeline 指南。

6.6 其它相依 Gem

引擎的相依套件應該在引擎跟目錄下的 .gemspec 檔案中指定。因為我們可能會以 gem 的方式安裝引擎。如果在引擎的 Gemfile 指定相依套件,那麼 gem install 將無法識別這些相依關係,因此它們不會被安裝,從而導致引擎發生故障。

要指定當 gem install 時應該和引擎一起安裝的相依套件,只需要在引擎中 .gemspec 檔案的 Gem::Specification 區塊內指定:

s.add_dependency "moo"

還可以像這樣指定僅用於開發環境的相依套件:

s.add_development_dependency "moo"

在應用程式內執行 bundle install 時,這兩種相依套件都會被安裝。用於開發環境的相依套件只有在執行引擎測試時才會使用。

注意,如果要在引擎載入時立即載入相依套件,你應該在引擎初始化前就載入它們。例如:

require 'other_engine/engine'
require 'yet_another_engine/engine'

module MyEngine
  class Engine < ::Rails::Engine
  end
end

所以當你過早載入某些框架時(例如,ActiveRecord::Base),你就違反了使用 Rails 應用程式的使用慣例

7. Active Support 的 on_load Hook

Active Support 是 Ruby on Rails 元件,負責提供 Ruby 語言擴充套件和公用程式。

Rails 程式碼通常可以在應用程式載入時引用。Rails 負責這些框架的載入順序,所以當你過早載入某些框架時(例如 ActiveRecord::Base),就違反了 Rails 應用程式的使用慣例。此外,透過在應用程式啟動時載入如 ActiveRecord::Base 等程式碼,你正在載入整個框架,這可能會降低啟動時間,並可能導致載入順序與應用程式啟動發生衝突。

on_load API 能夠讓我們在初始化的流程中掛載你所需要的功能,而不違反 Rails 所制定的規則。這也將緩解啟動效能下降並避免衝突。

8. 什麼是 on_load Hook?

由於 Ruby 是動態語言,因此某些程式碼會導致載入相關的 Rails 元件。以此程式碼片段為例:

ActiveRecord::Base.include(MyActiveRecordHelper)

當這段程式碼片段被載入時,發現有 ActiveRecord::Base,因此 Ruby 會去尋找該常數的定義並載入它。這將導致整個 Active Record 框架在啟動時被載入。

ActiveSupport.on_load 是一種延遲載入程式碼的機制,直到真正需要時才載入。上述代碼可以修改為:

ActiveSupport.on_load(:active_record) { include MyActiveRecordHelper }

新的程式碼片段會在 ActiveRecord::Base 載入後才載入 MyActiveRecordHelper

9. 它是如何運作的?

在 Rails 框架中,載入特定函式庫時會呼叫這些 hook。例如,當 ActionController::Base 被載入時,會呼叫 :action_controller_base hook。也就是說,ActiveSupport.on_load 呼叫設定用的 :action_controller_base hook 會在 ActionController::Base 環境中被呼叫。(因此 selfActionController::Base 的實例變數)。

10. 修改程式碼,使用 on_load Hook

修改程式碼很簡單。如果你有一行引用 Rails 框架的程式碼,例如 ActiveRecord::Base,你可以將該程式碼包裝在 on_load hook 中。

10.1 範例 1

ActiveRecord::Base.include(MyActiveRecordHelper)

改為

ActiveSupport.on_load(:active_record) { include MyActiveRecordHelper } # self 在這裡指的是 ActiveRecord::Base,所以可以直接呼叫 #include

10.2 範例 2

ActionController::Base.prepend(MyActionControllerHelper)

改為

ActiveSupport.on_load(:action_controller_base) { prepend MyActionControllerHelper } # self 在這裡指的是 ActionController::Base,所以可以直接呼叫 #prepend

10.3 範例 3

ActiveRecord::Base.include_root_in_json = true

改為

ActiveSupport.on_load(:active_record) { self.include_root_in_json = true } # self 在這裡指的是 ActiveRecord::Base

11. 可用的 Hook

這些是可以在程式碼中使用的 hook。

要接上以下某個類別的初始化過程,請使用可用的 hook。

類別 可用的 Hook
ActionCable action_cable
ActionController::API action_controller_api
ActionController::API action_controller
ActionController::Base action_controller_base
ActionController::Base action_controller
ActionController::TestCase action_controller_test_case
ActionDispatch::IntegrationTest action_dispatch_integration_test
ActionDispatch::SystemTestCase action_dispatch_system_test_case
ActionMailer::Base action_mailer
ActionMailer::TestCase action_mailer_test_case
ActionView::Base action_view
ActionView::TestCase action_view_test_case
ActiveJob::Base active_job
ActiveJob::TestCase active_job_test_case
ActiveRecord::Base active_record
ActiveSupport::TestCase active_support_test_case
i18n i18n

12. 配置 Hook

這些是可用的配置 hook。它們並沒有涉及到任何特定的框架,而是在整個應用程式中執行。

Hook 使用案例
before_configuration 第一個執行的配置區塊。在所有初始化程式執行前呼叫。
before_initialize 第二個執行的配置區塊。框架初始化前呼叫。
before_eager_load 第三個執行的配置區塊。config.eager_load 設為 false 時不執行。
after_initialize 最後執行的配置區塊。框架初始化完成後呼叫。

12.1 範例

config.before_configuration { puts 'I am called before any initializers' }