正常情況下我們開發這類伺服器應用每當修改程式後都需要重新啟動,不過 Ruby on Rails 卻可以在不重新開啟的狀況下不斷修改跟測試,這極大的改善了開發速度。雖然在開發遊戲伺服器中並不是必要的,但在 Rails 6 加入的 Zeitwerk 能夠讓我們用極低的時間成本完成這件事情,因此花費一點時間改善後續開發的體驗也是不錯的。
首先我們要告訴 Zeitwerk 要如何從物件的名稱去判斷要尋找哪些檔案路徑來載入對應的物件。
修改 config/application.rb
在初始化階段增加一些定義:
# config/application.rb
class SimpleRPG
class << self
# ...
def root
@root ||= File.absolute_path('../..', __FILE__)
end
end
def initialize
prepare_loader
@loader.setup
end
# ...
private
def prepare_loader
@loader = Zeitwerk::Loader.new
@autoload_paths = Dir["#{self.class.root}/app/*"]
@autoload_paths.each do |path|
@loader.push_dir(path)
end
end
end
首先我們需要類似 Rails.root
的方法,來告訴我們專案的根目錄在哪裡,因此定義了 SimpleRPG.root
這個方法。接下來在初始化階段我們先把 Loader 初始化,並且定義要被 Autoload 使用的目錄有哪些,可以直接利用 Ruby 內建的 Dir[]
來做簡易的抓取,最後在呼叫 #setup
方法正式將 Autoload 行為啟用。
當我們移除掉專案原本的 require 'app/servers/websocket_server'
並嘗試啟動伺服器的時候,卻發現會出現 SimpleRPG::WebSocketServer
並不存在的錯誤,這是因為我們剛好遇到了不符合規則的情況。
在大多數狀況下 Autoload 會將 web_socket_server
轉換成 WebSocketServer
的形式,其實也就是從 Snake Case 轉乘 Camel Case 的變換,也因此當我們希望用 websocket_server
當作檔名時就會遭遇到困難,這類情況更常出現在我們希望用 api_server
之類的情況,因為 API 算是縮寫所以不應該寫成 a_p_i_server
這樣的檔案名稱。
在 Zeitwerk 提供了 Inflector(變形)機制讓我們客製化這類專有名詞。
在 Ruby on Rails 6 因為改為使用 Zeitwerk 所以有更複雜的封裝,也讓 Autoload 機制能有更複雜的變化應用,以及比 Zeitwerk 原生的 Inflector 更容易使用。
我們需要先增加一個 config/inflector.rb
物件來自定義行為
# frozen_string_literal: true
class Inflector < Zeitwerk::Inflector
def camelize(basename, _abspath)
case basename
when 'websocket_server' then 'WebSocketServer'
else
super
end
end
end
因為 config/
目錄並不在 Autoload 的範圍內,而且這個檔案需要在 Autoload 啟動之前被呼叫進來,因此我們需要調整 config/application.rb
# config/application.rb
# ...
require 'config/inflector'
class SimpleRPG
# ...
def prepare_loader
@loader = Zeitwerk::Loader.new
@loader.inflector = Inflector.new
@autoload_paths = Dir["#{self.class.root}/app/*"]
@autoload_paths.each do |path|
@loader.push_dir(path)
end
end
end
如此一來,我們就可以將 require 'app/servers/websocket_server'
移除掉,並且正常的啟動伺服器。
不過改了程式碼卻沒有更新,還是要重新開啟伺服器是不是用到假的套件?
這是因為 Rails 除了 Autoload 之外,還使用了 Auto-Reloading 的機制,在 Zeitwerk 也提供了 #reload
方法可以幫我們重新載入變更過的物件,不過要在檔案存檔的同時自動重新載入就需要依靠 fs-event 這個 Gem 幫助我們偵測檔案的變更。
繼續對 config/application.rb
修改加入 Auto-Reloading 的配置
# config/application.rb
class SimpleRPG
# ...
def initialize
# ...
prepare_reloading
@loader.setup
end
# ...
def start(server, options = {})
Thread.new { @fsevent.run }
# TODO: Select Server
Rack::Handler.default.run(WebSocketServer, options)
end
private
def prepare_reloading
@loader.enable_reloading
@fsevent = FSEvent.new
@fsevent.watch @autoload_paths do |_path|
@loader.reload
end
end
end
在 Zeitwerk 中我們需要先註明啟用 Reloading 機制,接下來產生一個 FSEvent 物件並且追蹤所有 Autoload 的目錄,如此一來當我們在修改檔案的時候自然就能夠自動重新讀取修改過的物件。
雖然我們已經可以自動更新程式,不過在某些情況下 Auto Reloading 還是不會正常運作的,要知道在什麼情境可以使用跟不能使用就要從 Ruby 如何實現 Reloading 這件事情說起。
首先,我們要先對 Ruby 幾個特性有一個基本的概念:
假設我們想要將 WebSocketServer 重新載入要怎麼實現?基於前面兩點的前提,理論上可以像這樣進行:
require 'websocket_server'
Kernel.const_set('WebSocketServer', nil)
# Reload
require 'websocket_server'
因為常數可以被覆蓋,因此我們只需要從 Kernel
模組往下開始找到要重新載入的物件,並且將它設定為 nil
即可。
在 Ruby 裡面如果不屬於任何模組或物件的話,都會被當作是 Kernel 下的模組或物件。另外直接用
WebSocketServer = nil
是會出現警告訊息,因此要用.const_set
的方式處理,這邊只是展示概念不另外討論 Ruby 本身還會做的一些物件查詢快取機制。
不過,如果物件被重新載入了那麼原本的物件跑去哪裡了?實際上會存在記憶體中等待 GC 回收,我們可以嘗試使用像是 WebSocketServer.object_id
來比較 Reload 前後的物件,實際上會是不同的 Object ID。當原本的物件失去所有參考的對象後,自然就會被垃圾回收。
不過這也是不應該在 Production 環境開啟 Auto-Reloading 的原因,假設我們有一些操作會持續參照某個物件的實例,那麼就會造成無法回收掉原本的物件再多次執行後一直無法被回收最後造成記憶體問題。也因為這樣 Production 環境會選擇使用 Eager Load 的方式一次性的將所有物件載入並且不再觸發任何會載入新的物件的機制,用來避免造成預期外的情況發生。
關於 Autoload 可以參考筆者之前的記錄 Rails 的 Auto Reload 機制所產生的錯誤 和 自由的 Ruby 類別 裡面有提到關於 Reloading 和物件特性的機制。
我的個人部落格是弦而時習之平常會把自己發現的一些新技巧紀錄在上面,也歡迎大家來逛逛。