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_files` 將 `children_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