Автозагрузка и перезагрузка констант (режим Zeitwerk)

Это руководство документирует, как работает автозагрузка и перезагрузка в режиме zeitwerk.

После его прочтения, вы узнаете:

  • О режимах автоматической загрузки
  • Об соответствующей настройке Rails
  • О структуре проекта
  • Об автоматической загрузке, перезагрузке и нетерпеливой загрузке
  • О наследовании с единой таблицей
  • И еще кое-что

1. Введение

Это руководство документирует автоматическую загрузку в режиме zeitwerk, новинкой Rails 6. Если, вместо этого, вы хотите прочитать о режиме classic, смотрите Автозагрузка и перезагрузка констант (режим Classic).

В обычных классах в программах на Ruby зависимости нужно загружать вручную. Например, следующий контроллер использует классы ApplicationController и Post, и обычно необходимо для них добавить вызовы require:

# НЕ ДЕЛАЙТЕ ЭТОГО.
require 'application_controller'
require 'post'
# НЕ ДЕЛАЙТЕ ЭТОГО.

class PostsController < ApplicationController
  def index
    @posts = Post.all
  end
end

Но это не в случае приложений Rails, когда классы и модули приложения доступны везде:

class PostsController < ApplicationController
  def index
    @posts = Post.all
  end
end

Характерные приложения Rails только используют вызовы require для загрузки вещей из директории lib, стандартной библиотеки Ruby, гемов Ruby, и так далее. То есть того, что не принадлежит путям автозагрузки, описанным ниже.

2. Включение режима Zeitwerk

Режим автозагрузки zeitwerk включен по умолчанию в приложениях Rails 6, запускаемых на CRuby:

# config/application.rb
config.load_defaults 6.0 # enables zeitwerk mode in CRuby

В режиме zeitwerk Rails использует Zeitwerk для автоматической загрузки, перезагрузки и ленивой загрузки. Rails создает и настраивает экземпляр Zeitwerk, управляющий проектом.

Сам Zeitwerk не нужно настраивать вручную в приложении Rails. Скорее, нужно настроить приложение с помощью портируемых конфигурационных пунктов, разъясненных в этом руководстве, а Rails передаст это в Zeitwerk за вас.

3. Структура проекта

В приложении Rails имена файлов должны соответствовать константам, которые они определяют, а директории выступают как пространства имен.

Например, файл app/helpers/users_helper.rb должен определять UsersHelper, а файл app/controllers/admin/payments_controller.rb должен определять Admin::PaymentsController.

По умолчанию Rails настраивает Zeitwerk, чтобы преобразовывать имена файлов с помощью String#camelize. Например, он ожидает, что app/controllers/users_controller.rb определяет константу UsersController, так как

"users_controller".camelize # => UsersController

Раздел Настройка словообразования ниже документирует способы переопределения этих умолчаний.

Подробности в документации Zeitwerk.

4. Пути автоматической загрузки

Мы ссылаемся на список директорий приложения, содержимое которых должно быть автоматически загружено, как пути автозагрузки. Например, app/models. Эти директории представляют корневое пространство имен: Object.

Пути автоматической загрузки называются корневыми директориями в документации Zeitwerk, но в этом руководстве мы будем называть их "путь автозагрузки".

В пределах пути автозагрузки, имена файлов должны соответствовать константам, которые они определяют, как документировано тут.

По умолчанию, пути автозагрузки приложения состоят из всех поддиректорий app, существующих во время загрузки приложения — за исключением assets, javascript, views — плюс пути автозагрузки engine-ов, от которых оно может зависеть.

К примеру, если UsersHelper реализован в app/helpers/users_helper.rb, этот модуль автоматически загружаемый, и вам не нужно писать вызов require для него:

$ bin/rails runner 'p UsersHelper'
UsersHelper

Пути автозагрузки автоматически подхватывают любые пользовательские директории в app. Например, если в вашем приложении есть app/presenters, или app/services и т.д., они будут добавлены в пути автозагрузки.

Массив путей автозагрузки может быть расширен с помощью изменения config.autoload_paths в config/application.rb, но в настоящее время это не рекомендуется.

Пожалуйста, не изменяйте ActiveSupport::Dependencies.autoload_paths, публичный интерфейс для изменения путей автозагрузки — это config.autoload_paths.

5. $LOAD_PATH

Пути автозагрузки добавляются по умолчанию в $LOAD_PATH. Однако, внутренне Zeitwerk использует абсолютные имена файлов, и ваше приложение не должно иметь вызовов require для автоматически загружаемых файлов, таким образом, эти директории фактически тут не нужны. Вы можете их выключить с помощью флажка:

config.add_autoload_paths_to_load_path = false

Это может немного ускорить правильные вызовы require, Поскольку будет меньше поиска. Также, если ваше приложение использует Bootsnap, это спасает библиотеку от построения ненужных индексов, что экономит RAM, которая ему нужна.

6. Перезагрузка

Rails автоматически перезагружает классы и модули, если файлы приложения изменяются.

Если точнее, когда запущен веб сервер, и файлы приложения изменились, Rails выгружает все автоматически загруженные константы непосредственно перед тем, как обрабатывать следующий запрос. Таким образом, классы или модули приложения, используемые в течение этого запроса, будут автоматически загружены, таким образом, будет взята их текущая реализация из файловой системы.

Перезагрузка может быть включена или отключена. Настройкой, контролирующей это поведение, является config.cache_classes, которая по умолчанию false в режиме development (перезагрузка включена), и true по умолчанию в режиме production (перезагрузка выключена).

Rails определяет, что файлы изменились, с помощью событийного монитора файлов (по умолчанию), или проходя по путям автозагрузки, в зависимости от config.file_watcher.

В консоли Rails нет активного наблюдателя файлов, вне зависимости от значения config.cache_classes. Это так, потому что обычно будет сбивать с толку, если код перезагрузится посреди консольной сессии, по аналогии, как мы хотим, чтобы отдельный запрос обслуживался постоянным, неизменным набором классов и модулей приложения.

Однако, можно принудительно перезагрузить, выполнив в консоли reload!:

irb(main):001:0> User.object_id
=> 70136277390120
irb(main):002:0> reload!
Reloading...
=> true
irb(main):003:0> User.object_id
=> 70136284426020

как видите, объект класса, хранимый в константе User, отличается после перезагрузки.

6.1. Перезагрузка и устаревшие объекты

Очень важно понимать, что в Ruby нет способа настоящей перезагрузки классов и методов в памяти, и это отражается везде, где она используется. Технически "выгрузка" класса User означает удаление константы User с помощью Object.send(:remove_const, "User").

Следовательно, код, ссылающийся на объект перезагружаемого класса или модуля, но не выполненный при перезагрузке, становится устаревшим. Давайте рассмотрим следующий пример.

Допустим, у нас есть инициализатор:

# config/initializers/configure_payment_gateway.rb
# НЕ ДЕЛАЙТЕ ТАК.
$PAYMENT_GATEWAY = Rails.env.production? ? RealGateway : MockedGateway
# НЕ ДЕЛАЙТЕ ТАК.

Идея в том, чтобы использовать $PAYMENT_GATEWAY в коде, и позволить инициализатору определить фактическую реализацию в зависимости от среды.

При перезагрузке MockedGateway перезагружается, но $PAYMENT_GATEWAY не обновляется, так как инициализаторы запускаются только при загрузке. Следовательно, изменение не будет отражено.

Есть несколько способов сделать это безопасно. Например, приложение может определить метод PaymentGateway.impl, зависимый от среды; или определить PaymentGateway как имеющий родительский класс или миксин в зависимости от среды; или использовать ту же хитрость с глобальной переменной, но в колбэке перезагрузки, как будет объяснено ниже.

Давайте рассмотрим другие ситуации, связанные с устаревшими объектами класса или модуля.

Вот сессия консоли Rails:

irb> joe = User.new
irb> reload!
irb> alice = User.new
irb> joe.class == alice.class
=> false

joe это экземпляр первоначального класса User. При перезагрузке константа User вычисляется как другой, перезагруженный класс. alice это экземпляр текущего класса, но не joe, его класс устарел. Можно снова определить joe, запустить подсессию IRB или просто запустить новую консоль вместо вызова reload!.

Другой ситуацией, в которой можно обнаружить эту особенность, является создание подкласса перезагружаемого класса в месте, которое не перезагружается:

# lib/vip_user.rb
class VipUser < User
end

если User перезагружается, то, так как VipUser нет, суперклассом VipUser является оригинальный устаревший объект класса.

Вывод: не кэшируйте перезагружаемые классы или модули.

6.2. Автозагрузка при загрузке приложения

Приложения могут безопасно автоматически загружать константы в течение загрузки с помощью колбэка перезагрузки:

Rails.application.reloader.to_prepare do
  $PAYMENT_GATEWAY = Rails.env.production? ? RealGateway : MockedGateway
end

Этот блок запускается пи загрузке приложения, и каждый раз, когда код перезагружается.

по историческим причинам этот колбэк может быть запущен дважды. Запускаемый код обязан быть идемпотентным.

Однако, если нет необходимости перезагружать класс, проще определить его в директории, не принадлежащей путям автозагрузки. Например, идиоматическим выбором является lib, она не принадлежит путям автозагрузки по молчанию, но принадлежит $LOAD_PATH. Затем, там, где этот нужен во время загрузки, просто выполните обычный require, чтобы его загрузить.

Например, нет нужды определять перезагружаемую промежуточную программу Rack, так как в любом случае изменения не будут отражены в экземпляре, хранимом в стеке промежуточных программ. Если lib/my_app/middleware/foo.rb определяет класс промежуточной программы, тогда в config/application.rb напишем:

require "my_app/middleware/foo"
...
config.middleware.use MyApp::Middleware::Foo

Чтобы отразить изменения в этой промежуточной программе, необходимо перезапустить сервер.

7. Нетерпеливая загрузка

В средах, подобных production, обычно лучше загрузить весь код приложения при запуске приложения. Нетерпеливая загрузка помещает все в память для немедленного обслуживания запросов, это также сочетается с механизмом копирования при записи.

Нетерпеливая загрузка контролируется флажком config.eager_load, который по умолчанию включен в режиме production.

Порядок, в котором файлы нетерпеливо загружаются, не определен.

Если определена константа Zeitwerk, Rails вызывает Zeitwerk::Loader.eager_load_all, независимо от режима автоматической загрузки приложения. Это обеспечивает, что зависимости, контролируемые Zeitwerk, будут нетерпеливо загружены.

8. Наследование с единой таблицей

Наследование с единой таблицей является особенностью, не очень сочетающейся с ленивой загрузкой. Причина в том, что его API должен быть способен подсчитать иерархию STI, чтобы работать корректно, в то время как ленивая загрузка откладывает загрузку классов, пока на них не сослались. Невозможно подсчитать то, на что еще не сослались.

В некотором смысле, приложениям нужно нетерпеливо загрузить иерархии STI, независимо от режима загрузки.

Конечно, если приложение нетерпеливо загружается при старте, это уже выполняется. Когда нет, на практике достаточно инициализировать типы, существующие в базе данных, что обычно достаточно в режимах разработки или тестирования. Одним из способов это сделать является помещение этого модуля в директорию lib:

module StiPreload
  unless Rails.application.config.eager_load
    extend ActiveSupport::Concern

    included do
      cattr_accessor :preloaded, instance_accessor: false
    end

    class_methods do
      def descendants
        preload_sti unless preloaded
        super
      end

      # Инициализирует как константу все типы, существующие в базе данных. There might be more on
      # На диске может быть и больше, но на практике это не имеет значения, пока речь идет о STI API.
      #
      # Предполагаем, что store_full_sti_class является true, по умолчанию.
      def preload_sti
        types_in_db = \
          base_class.
            select(inheritance_column).
            unscoped.
            distinct.
            pluck(inheritance_column).
            compact

        types_in_db.each do |type|
          logger.debug("Preloading STI type #{type}")
          type.constantize
        end

        self.preloaded = true
      end
    end
  end
end

и затем включите его в корневые классы STI вашего проекта:

# app/models/shape.rb
require "sti_preload"

class Shape < ApplicationRecord
  include StiPreload # Только в корневом класса.
end
# app/models/polygon.rb
class Polygon < Shape
end
# app/models/triangle.rb
class Triangle < Polygon
end

9. Настройка словообразования

По умолчанию Rails использует String#camelize, чтобы узнать, какую константу должны определять данный файл или директория. Например, posts_controller.rb должен определять PostsController, так как это то, что возвращает "posts_controller".camelize.

Возможны случаи, когда имя определенного файла или директории не преобразуется в то, что вы хотите. Например, по умолчанию от html_parser.rb ожидается определение HtmlParser. Но что, если вы предпочитаете класс HTMLParser? Есть несколько способов настроить это.

Самым простым способом является определение аббревиатур в config/initializers/inflections.rb:

ActiveSupport::Inflector.inflections(:en) do |inflect|
  inflect.acronym "HTML"
  inflect.acronym "SSL"
end

Это глобально влияет на словообразование Active Support. Для некоторых приложений это отлично, но можно также настроить, как camelize отдельные базовые имена, независимо от Active Support, предоставив коллекцию переопределений в преобразователь по умолчанию:

# config/initializers/zeitwerk.rb
Rails.autoloaders.each do |autoloader|
  autoloader.inflector.inflect(
    "html_parser" => "HTMLParser",
    "ssl_error"   => "SSLError"
  )
end

Эта техника все еще зависит от String#camelize, хотя, так как преобразователь по умолчанию использует его как резервный. Если предпочитаете вообще не зависеть от словообразований Active Support, и получить полный контроль над словообразованием, настройте преобразователи быть экземплярами Zeitwerk::Inflector:

# config/initializers/zeitwerk.rb
Rails.autoloaders.each do |autoloader|
  autoloader.inflector = Zeitwerk::Inflector.new
  autoloader.inflector.inflect(
    "html_parser" => "HTMLParser",
    "ssl_error"   => "SSLError"
  )
end

Нет какой-либо глобальной конфигурации, которая может повлиять на указанные экземпляры, они детерминированы.

Можно даже определить собственный преобразователь для полной гибкости. Пожалуйста, обратитесь за подробностями к документации Zeitwerk.

10. Разрешение проблем

Лучшим способом понять, что делают загрузчики, является наблюдение за их активностью.

Простейший способ для этого - закинуть

Rails.autoloaders.log!

в config/application.rb после загрузки умолчаний для фреймворка. Это напечатает трейсы в стандартный вывод.

Если предпочитаете логирование в файл, настройте так:

Rails.autoloaders.logger = Logger.new("#{Rails.root}/log/autoloading.log")

Логгер Rails logger пока еще не готов в config/application.rb, поэтому можно в инициализаторе:

# config/initializers/log_autoloaders.rb
Rails.autoloaders.logger = Rails.logger

11. Rails.autoloaders

Экземпляры Zeitwerk, управляющие вашим приложением, доступны в

Rails.autoloaders.main
Rails.autoloaders.once

Первый — основной. Последний — в основном для обратной совместимости, в случае, если у приложения есть что-то в config.autoload_once_paths (сейчас это не рекомендуется).

Можно проверить, что режим zeitwerk включен, с помощью

Rails.autoloaders.zeitwerk_enabled?

12. Отличия от режима Classic

12.1. Соответствие поиску констант Ruby

Режим classic не может соответствовать семантике поиска констант из-за фундаментальных ограничений техники, на которой он основан, в то время как режим zeitwerk работает, подобно Ruby.

Например, в режиме classic определение классов или модулей в пространствах имен с ограниченными константами следующим образом

class Admin::UsersController < ApplicationController
end

не рекомендовалось, так как резолюция констант внутри их тела была поломанной. Лучше было писать их следующим образом:

module Admin
  class UsersController < ApplicationController
  end
end

В режиме zeitwerk это больше не имеет значения, можно выбрать любой стиль.

Резолюция констант могла зависеть от порядка загрузки, определения объекта класса или модуля могли зависеть от порядка загрузки, были крайние случаи с синглтон-классами, часто нужно было использовать require_dependency как обходной путь, .... Руководство для режима classic документирует эти проблемы.

Все эти проблемы были решены в режиме zeitwerk, он просто работает как ожидается, и require_dependency больше не должен быть использован, он больше не нужен.

12.2. Меньший поиск файлов

В режиме classic каждая отсутствующая константа запускала поиск файла, проходящий по путям автозагрузки.

В режиме zeitwerk есть только один проход. Этот проход осуществляется один раз, а не для отсутствующей константы, и в целом это более производительно. Поддиректории посещаются только тогда, когда используется их пространство имен.

12.3. Underscore vs Camelize

Словообразование идет в противоположные стороны.

В режиме classic, для отсутствующей константы Rails делает underscores на ее имени и осуществляет поиск файла. С другой стороны, режим zeitwerk сперва проверяет файловую систему, и делает camelizes на именах файлов, чтобы узнать константы, которые они ожидаемо определяют.

В то время как для обычных имен эти операции соответствуют, но для аббревиатур или пользовательских правил словообразования, могут не соответствовать. Например, по умолчанию "HTMLParser".underscore это "html_parser", а "html_parser".camelize это "HtmlParser".

12.4. Больше различий

Есть еще несколько небольших отличий, за подробностями обратитесь к этому разделу руководства Апгрейд Ruby on Rails.

13. Режим Classic устарел

К настоящему моменту все еще возможно использовать режим classic. Но classic устарел и со временем будет удален.

Новые приложения должны использовать режим zeitwerk (который по умолчанию), и обновляемые приложениям строго рекомендуется мигрировать на режим zeitwerk. Пожалуйста, за подробностями обратитесь к руководству Апгрейд Ruby on Rails

14. Выключение

Приложения могут загружать умолчания Rails 6 и все еще использовать классический автоматический загрузчик следующим образом:

# config/application.rb
config.load_defaults 6.0
config.autoloader = :classic

Это удобно при обновлении до Rails 6 на различных стадиях, но классический режим не рекомендуется для новых приложений.

Режим zeitwerk не доступен в версиях Rails до 6.0.