Zeitwerk 深入淺出

前言

眾所周知,在寫 Rails 時幾乎沒有使用 require 的機會,這是因為 Rails 有 autoloading 的機制

但前提是有照著它的命名規則。

近期要為公司寫個 API Client 的 Gem,於是參考了 Shopify API Ruby 的實作,對其中如何載入不同版本的 API 感到好奇

因為 Rails 的 File Path Naming Convention,檔案的路徑會與 module/classnested 相關 例如: 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::Shop

而不是

ShopifyAPI::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 檔案,但並不能辦到以下幾點:

  1. Reload
  2. Custom root Namespace

下一階段我們來實作 Reload 的功能。

Reload

前面有提到 requireautoload 都只能載入程式碼一次,那有什麼辦法可以跨過這限制呢? 關鍵在於 $LOADED_FEATURES 這個環境變數,它會記得載入過的檔案,因此只要刪掉該檔案就可以重新載入一次

require 'json' # true require 'json' # false $LOADED_FEATURES.pop require 'json' # true

接著我們來改寫 PocLoader 使其能夠支援 Reload

# 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

在這個版本中,執行 main.rb 後,會不斷呼叫 A.hi,從而印出 hi,若此時修改 A.hi 內部的實作,將會讓印出的字串發生改變 不過這版還是有些問題:

  1. Custom root Namespace
  2. 假如檔案被刪除,該 constant 也應該被刪除

下個階段來嘗試解決這問題。

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

在這版本中,我們使用 autoload 取代了 require,並且善用 autoload 可自由決定 namespace 的特性來完成 Custom Root Namespace。

值得一提的是 unload 的部分,除了從 $LOADED_FEATURES 移除絕對路徑外,還使用 Object.remove_const 這個 private method 刪除 namespace 上的 const,以應付 reload 後檔案已刪除的情形

但這個版本還是有個問題:

  1. 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

白話文來說就是檔案中間的 namespace 沒有定義過(因為沒有一個檔案叫做 app/controllers/admin.rb),所以直接載入最底端的檔案app/controllers/admin/users_controller.rb會出現找不到 Admin 的問題

那這點要怎麼解決?

第一種方式最簡單直觀,是直接為每個 folder 定義 module,但這就跟 Lazy loading 的原則相悖。

第二種方式則是確確實實的把 folder 放進 autoload_paths

例如 loader.push_dir('app/controllers/admin'),藉由這種方式讓 autoload 知道要新增一個 Admin 的 namespace

而目前的做法是採用第一種並進行一些優化,拆解做法:

  1. autoload_path 底下的子檔案和資料夾進行排序
  2. 因為排序後子檔案會比資料夾更前面,之後就可以藉由 Object.const_defined? 確定 namespace 是否定義過
  3. 沒定義過的話就自己定義

接著來看看實作吧

檔案結構

implicit_namespace
├── lib
│   ├── car
│   │   └── wheel.rb
│   ├── ship
│   │   └── keel.rb
│   └── ship.rb
├── main.rb
├── monkey_patches.rb
├── poc_loader.rb

程式碼 monkey_patches 就不放了

# 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

在這一版中,我們做了一些變更

  1. PocLoader#list_fileschildren_files 排序,讓較少層目錄的檔案優先於較多層的
  2. setup 中多加呼叫 #define_namespace,確保上層的 namespace 已被宣告
  3. 修改 remove_shallow_level_constants,因可能刪除相同的 const 兩次導致錯誤,所以用 Set 確保每個 const 只會被刪一次

到這邊基本上我們已掌握了 Zeitwerk 的核心概念。

剩下是一些有想到但還沒實作的內容

  1. Inflection
  2. Ignore dir
  3. thread-safe
  4. explicitly namespace
  5. multi loader
  6. eager load

技術難題總結

參考作者在 RailsConf 2022 - Opening Keynote: The Journey to Zeitwerk by Xavier Noria 提到的五個技術難題:

  1. Module#autoload 呼叫 Ruby 內建的 require(自 Ruby 1.9 開始 requirerubygems 這個套件處理,詳情可以參考龍哥寫的文章
  2. require 是幕等性的, 只在第一次生效
  3. 沒有 API 能刪除 autoload 定義過的 const
  4. 隱含的 namespace ,例如只有 admin/ 這個資料夾但沒有 admin.rb 的檔案,並且也沒有事先定義 Admin,那要怎麼處理?
  5. 明確的 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

這樣子是不是就清楚許多了

References