Zeitwerk 深入淺出
前言
眾所周知,在寫 Rails 時幾乎沒有使用 require 的機會,這是因為 Rails 有 autoloading 的機制
但前提是有照著它的命名規則。
近期要為公司寫個 API Client 的 Gem,於是參考了 Shopify API Ruby 的實作,對其中如何載入不同版本的 API 感到好奇
因為 Rails 的 File Path Naming Convention,檔案的路徑會與 module/class 的 nested 相關
例如: app/controllers/admin/orders_controller.rb 裡面就會有以下的 code
module Admin
class OrdersController
...
end
module
但在 Shopify API Ruby 中的檔案路徑中卻可以直接使用以下程式碼來呼叫相應版本的 API
shopify-api-ruby gem 檔案結構
lib/shopify_api
...
├── rest
│ └── resources
│ ├── 2023_04
│ │ └── shop.rb
│ ├── 2023_07
│ │ └── shop.rb
│ └── 2023_10
│ └── shop.rb
...直接呼叫
ShopifyAPI::ShopShopifyAPI::Rest::Resources::2023_04::Shop到底是怎麼實作的?
這就要從 Rails 的 Autoloading 說起
Rails 的 Autoloading
這邊我們先簡單介紹 Rails 使用的兩種 Autoloader
Rails 在很早期就透過 Autoload 機制去解決這問題了,Rails 6 以前使用的是原生的 autoloader,之後則是 zeitwerk
有興趣比較兩個 autoloader 的差別可以看這篇
本篇文章主要是介紹 zeitwerk
ZeitWerk 是什麼? 有什麼用?
Zeitwerk is an efficient and thread-safe code loader for Ruby.
如同簡述是個 ruby 程式碼的載入器
若還不知道 Ruby 載入程式碼的方法,可以參考這篇 Ruby 中三種載入程式碼的機制(load, require, autoload)
回到主題,Zeitwerk 是什麼?有什麼用?
原本 Ruby 提供的載入功能都是對檔案
若你有 N 個檔案就要寫 N 次 require
我們可以看看不使用 Autoloader 專案 SketchUp/ruby-api-stubs 的載入方式
# lib/sketchup-api-stubs/sketchup.rb
require 'sketchup-api-stubs/stubs/_top_level.rb'
require 'sketchup-api-stubs/stubs/Array.rb'
require 'sketchup-api-stubs/stubs/Geom.rb'
require 'sketchup-api-stubs/stubs/Geom/BoundingBox.rb'
require 'sketchup-api-stubs/stubs/Geom/Bounds2d.rb'
require 'sketchup-api-stubs/stubs/Geom/LatLong.rb'
require 'sketchup-api-stubs/stubs/Geom/OrientedBounds2d.rb'
require 'sketchup-api-stubs/stubs/Geom/Point2d.rb'
require 'sketchup-api-stubs/stubs/Geom/Point3d.rb'
require 'sketchup-api-stubs/stubs/Geom/PolygonMesh.rb'
require 'sketchup-api-stubs/stubs/Geom/Transformation.rb'
require 'sketchup-api-stubs/stubs/Geom/Transformation2d.rb'
require 'sketchup-api-stubs/stubs/Geom/UTM.rb'
require 'sketchup-api-stubs/stubs/Geom/Vector2d.rb'
require 'sketchup-api-stubs/stubs/Geom/Vector3d.rb'
require 'sketchup-api-stubs/stubs/LanguageHandler.rb'
require 'sketchup-api-stubs/stubs/Layout.rb'
require 'sketchup-api-stubs/stubs/Layout/Entity.rb'
require 'sketchup-api-stubs/stubs/Layout/AngularDimension.rb'
require 'sketchup-api-stubs/stubs/Layout/AutoTextDefinition.rb'
require 'sketchup-api-stubs/stubs/Layout/AutoTextDefinitions.rb'
require 'sketchup-api-stubs/stubs/Layout/ConnectionPoint.rb'
require 'sketchup-api-stubs/stubs/Layout/Document.rb'
require 'sketchup-api-stubs/stubs/Layout/Ellipse.rb'
require 'sketchup-api-stubs/stubs/Layout/Entities.rb'
require 'sketchup-api-stubs/stubs/Layout/FormattedText.rb'
require 'sketchup-api-stubs/stubs/Layout/Grid.rb'
require 'sketchup-api-stubs/stubs/Layout/Group.rb'
require 'sketchup-api-stubs/stubs/Layout/Image.rb'
require 'sketchup-api-stubs/stubs/Layout/Label.rb'
require 'sketchup-api-stubs/stubs/Layout/Layer.rb'
require 'sketchup-api-stubs/stubs/Layout/LayerInstance.rb'
require 'sketchup-api-stubs/stubs/Layout/Layers.rb'
require 'sketchup-api-stubs/stubs/Layout/LinearDimension.rb'
require 'sketchup-api-stubs/stubs/Layout/LockedEntityError.rb'
require 'sketchup-api-stubs/stubs/Layout/LockedLayerError.rb'
require 'sketchup-api-stubs/stubs/Layout/Page.rb'
require 'sketchup-api-stubs/stubs/Layout/PageInfo.rb'
require 'sketchup-api-stubs/stubs/Layout/Pages.rb'
require 'sketchup-api-stubs/stubs/Layout/Path.rb'
require 'sketchup-api-stubs/stubs/Layout/Rectangle.rb'
....看起來真可怕是吧?
使用 Zeitwerk 後這些 require 都可以刪掉了
Zeitwerk 怎麼用?
這裡我們直接看範例
# main.rb
require 'zeitwerk'
loader = Zeitwerk::Loader.new
loader.push_dir("lib")
loader.setup
A.hi
# lib/a.rb
module A
def self.hi
puts 'hi'
end
end如何設計一個類 Zeitwerk 的 Autoloader 工具
以下程式碼可以在 zeitwerk-POC repo 下載
Basic usage
先來個最基本的範例
# poc_loader.rb
class PocLoader
attr_reader :autoload_paths
def initialize
@autoload_paths = []
end
def push_dir(dir)
@autoload_paths << dir
end
def setup
autoload_paths.each do |dir|
Dir.glob("#{dir}/**/*.rb").each do |file|
require_relative file
end
end
end
end
# lib/a.rb
module A
def self.hi
puts 'hi'
end
end
# main.rb
require_relative 'poc_loader'
loader = PocLoader.new
loader.push_dir("lib")
loader.setup
A.hi
# ruby main.rb
# hi這一版可以載入 lib 底下的 .rb 檔案,但並不能辦到以下幾點:
- Reload
- Custom root Namespace
下一階段我們來實作 Reload 的功能。
Reload
前面有提到 require 和 autoload 都只能載入程式碼一次,那有什麼辦法可以跨過這限制呢?
關鍵在於 $LOADED_FEATURES 這個環境變數,它會記得載入過的檔案,因此只要刪掉該檔案就可以重新載入一次
require 'json' # true
require 'json' # false
$LOADED_FEATURES.pop
require 'json' # true# poc_loader.rb
class PocLoader
# 前面跟 basic 一樣
# ...
def reload
autoload_paths.each do |dir|
Dir.glob("#{dir}/**/*.rb").each do |file|
abs_file = File.expand_path(file)
$LOADED_FEATURES.delete(abs_file)
end
end
setup
end
end
# lib/a.rb
module A
def self.hi
puts 'hi'
end
end
# main.rb
require_relative 'poc_loader'
loader = PocLoader.new
loader.push_dir("lib")
loader.setup
while true
loader.reload
A.hi
sleep 1
end
# ruby main.rb
# hi
# hi
# hi
# ----變化 A.hi 的輸出為 hi123
# hi123
# hi123下個階段來嘗試解決這問題。
Custom root Namespace
在 zeitwerk 中,可以指定 Root Namespace,這是什麼意思呢?看一下範例:
require "active_job"
require "active_job/queue_adapters"
loader.push_dir("#{__dir__}/adapters", namespace: ActiveJob::QueueAdapters)
# adapters/my_queue_adapter.rb -> ActiveJob::QueueAdapters::MyQueueAdapter為了實作這個功能,我們不能再使用 require,必須改用 autoload 才能定義到指定的 Root Namespace 上,以下來看看實作
這是檔案目錄
custom_namespace
├── lib
│ └── car
│ └── parts
│ ├── v1
│ │ └── wheel.rb
│ └── v2
│ └── wheel.rb
├── main.rb
├── monkey_patches.rb
├── poc_loader.rb程式碼
# monkey_patches
class String
def remove_rb_extension
self.gsub(/\.rb$/, '')
end
def constantize
Object.const_get(self)
end
def camelize(uppercase_first_letter = true)
string = self
if uppercase_first_letter
string = string.sub(/^[a-z\d]*/) { |match| match.capitalize }
else
string = string.sub(/^(?:(?=\b|[A-Z_])|\w)/) { |match| match.downcase }
end
string.gsub(/(?:_|(\/))([a-z\d]*)/) { "#{$1}#{$2.capitalize}" }.gsub("/", "::")
end
end# lib/car/parts/v2/wheel.rb
module Car
class Wheel
def self.hi
puts 'I am a wheel v2 class'
end
end
end這兩個檔案比較關鍵
# main.rb
require_relative 'poc_loader'
VERSION = "v2"
module Car ;end
loader = PocLoader.new
loader.push_dir("lib/car/parts/#{VERSION}", root_namespace: Car)
loader.setup
while true
loader.reload
Car::Wheel.hi
sleep 1
end
# poc_loader.rb
require_relative './monkey_patches'
class PocLoader
attr_reader :autoload_paths, :root_namespace
def initialize
@autoload_paths = []
end
# autoloadPaths store hash arry
# every element is a hash with key: dir, namespace
def push_dir(dir, root_namespace: Object)
autoload_paths << { dir: dir, root_namespace: root_namespace }
end
def reload
unload
setup
end
def setup
autoload_paths.each do |dir:, root_namespace:|
list_files(dir) do |abs_path, relat_path|
cname = relat_path.remove_rb_extension.camelize
root_namespace.autoload cname, abs_path
end
end
end
private
def unload
autoload_paths.each do |dir:, root_namespace:|
list_files(dir) do |abs_path, relat_path|
cname = relat_path.remove_rb_extension.camelize
$LOADED_FEATURES.delete(abs_path)
end
remove_shallow_level_constants(dir, root_namespace)
end
end
def remove_shallow_level_constants(dir, namespace)
Dir.glob("#{dir}/*").each do |relat_path|
base_path = relat_path.gsub(/#{dir}\//, '')
cname = base_path.remove_rb_extension.camelize
namespace.send(:remove_const, cname)
end
end
def list_files(directory)
Dir.glob(File.join(directory, '**', '*.rb')).each do |file|
abs_path = File.absolute_path(file)
relat_path = file.gsub(/#{directory}\//, '')
yield(abs_path, relat_path) if block_given?
end
end
end值得一提的是 unload 的部分,除了從 $LOADED_FEATURES 移除絕對路徑外,還使用 Object.remove_const 這個 private method 刪除 namespace 上的 const,以應付 reload 後檔案已刪除的情形
但這個版本還是有個問題:
- Implicit Namespace
下一個段落我們來看看這是什麼問題。
Implicit Namespace
看一下官方文件的說明
If a namespace consists only of a simple module without any code, there is no need to explicitly define it in a separate file. Zeitwerk automatically creates modules on your behalf for directories without a corresponding Ruby file. for instance: suppose a project includes an admin directory:
app/controllers/admin/users_controller.rb -> Admin::UsersController那這點要怎麼解決?
第一種方式最簡單直觀,是直接為每個 folder 定義 module,但這就跟 Lazy loading 的原則相悖。
第二種方式則是確確實實的把 folder 放進 autoload_paths
例如 loader.push_dir('app/controllers/admin'),藉由這種方式讓 autoload 知道要新增一個 Admin 的 namespace
而目前的做法是採用第一種並進行一些優化,拆解做法:
- 將
autoload_path底下的子檔案和資料夾進行排序 - 因為排序後子檔案會比資料夾更前面,之後就可以藉由
Object.const_defined?確定 namespace 是否定義過 - 沒定義過的話就自己定義
接著來看看實作吧
檔案結構
implicit_namespace
├── lib
│ ├── car
│ │ └── wheel.rb
│ ├── ship
│ │ └── keel.rb
│ └── ship.rb
├── main.rb
├── monkey_patches.rb
├── poc_loader.rb# main.rb
require_relative 'poc_loader'
loader = PocLoader.new
loader.push_dir("lib")
loader.setup
while true
loader.reload
Car::Wheel.hi
Ship::Keel.hi
sleep 1
end# poc_loader.rb
require_relative './monkey_patches'
require 'set'
class PocLoader
# ...
def setup
autoload_paths.each do |dir:, root_namespace:|
list_files(dir) do |abs_path, relat_path|
cname = relat_path.remove_rb_extension.camelize
namespace = define_namespace(cname, root_namespace) # +++
cname_without_namespace = cname.split('::').last # +++
namespace.autoload cname_without_namespace, abs_path # +++
end
end
end
private
def define_namespace(cname, root_namespace)
namespaces = cname.split('::')
# remove last element
namespaces.pop
namespaces.each do |namespace|
unless root_namespace.const_defined?(namespace)
root_namespace.const_set(namespace, Module.new)
end
root_namespace = root_namespace.const_get(namespace)
end
root_namespace
end
def remove_shallow_level_constants(dir, namespace)
set = Set.new
Dir.glob("#{dir}/*").each do |relat_path|
base_path = relat_path.gsub(/#{dir}\//, '')
cname = base_path.remove_rb_extension.camelize
set << cname.split('::').first
end
set.each do |cname|
namespace.send(:remove_const, cname)
end
end
def list_files(directory)
children_files = Dir.glob(File.join(directory, '**', '*.rb'))
children_files.sort! # +++
children_files.each do |file|
abs_path = File.absolute_path(file)
relat_path = file.gsub(/#{directory}\//, '')
yield(abs_path, relat_path) if block_given?
end
end
# ...
end到這邊基本上我們已掌握了 Zeitwerk 的核心概念。
剩下是一些有想到但還沒實作的內容
- Inflection
- Ignore dir
- thread-safe
- explicitly namespace
- multi loader
- eager load
技術難題總結
參考作者在 RailsConf 2022 - Opening Keynote: The Journey to Zeitwerk by Xavier Noria 提到的五個技術難題:
Module#autoload呼叫 Ruby 內建的require(自 Ruby 1.9 開始require由rubygems這個套件處理,詳情可以參考龍哥寫的文章require是幕等性的, 只在第一次生效- 沒有 API 能刪除
autoload定義過的 const - 隱含的
namespace,例如只有admin/這個資料夾但沒有admin.rb的檔案,並且也沒有事先定義Admin,那要怎麼處理? - 明確的
namespace,參考下面的扣,我們應該先 autoload 哪個?這是個死結的狀態(作者正在處理的問題)
# car.rb
class Car
include Wheel
end
# car/wheel.rb
module Car::Wheel
end除了第 5 點以外基本上我們都解決了。
總結
回到最一開始產生這個疑問的起點,為什麼 shopify-api-ruby 可以依不同版本載入相應的 API 呢?
查看一下他的程式碼
def load_rest_resources(api_version:)
# Unload any previous instances - mostly useful for tests where we need to reset the version
@rest_resource_loader&.setup
@rest_resource_loader&.unload
...
version_folder_name = api_version.gsub("-", "_")
path = "#{__dir__}/rest/resources/#{version_folder_name}"
...
@rest_resource_loader = T.let(Zeitwerk::Loader.new, T.nilable(Zeitwerk::Loader))
T.must(@rest_resource_loader).enable_reloading
T.must(@rest_resource_loader).ignore("#{__dir__}/rest/resources")
T.must(@rest_resource_loader).setup
T.must(@rest_resource_loader).push_dir(path, namespace: ShopifyAPI)
T.must(@rest_resource_loader).reload
end再搭配他的檔案結構
shopify-api-ruby gem 檔案結構
shopify-api-ruby/lib/shopify_api/
├── auth
│ └── oauth
├── clients
│ ├── graphql
│ └── rest
├── errors
├── rest
│ └── resources
│ ├── 2022_04 # 含有 shop, customer 等等的 ruby file
│ ├── 2022_07
│ ├── 2022_10
│ ├── 2023_01
│ ├── 2023_04
│ ├── 2023_07
│ └── 2023_10
├── utils
└── webhooks
└── registrations
這樣子是不是就清楚許多了