Автозагрузка и перезагрузка констант

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

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

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

1. Введение

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

В обычных классах в программах на 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, и так далее. То есть того, что не принадлежит путям автозагрузки, описанным ниже.

Для предоставления этой особенности, Rails управляет рядом загрузчиков Zeitwerk.

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

В приложении 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.

3. config.autoload_paths

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

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

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

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

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

$ bin/rails runner 'p UsersHelper'
UsersHelper

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

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

module MyApplication
  class Application < Rails::Application
    config.autoload_paths << "#{root}/extras"
  end
end

Также engine может добавлять в коде класса engine и в свой config/environments/*.rb.

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

Вы не можете автоматически загружать код в путях автозагрузки, пока приложение загружается. В частности, непосредственно в config/initializers/*.rb. Пожалуйста, обратитесь к Автозагрузка при запуске приложения ниже за правильными способами сделать это.

Пути автозагрузки управляются автозагрузчиком Rails.autoloaders.main.

4. config.autoload_once_paths

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

По умолчанию эта коллекция пустая, но ее можно расширить, добавив к config.autoload_once_paths. Это можно сделать в config/application.rb или config/environments/*.rb. Например:

module MyApplication
  class Application < Rails::Application
    config.autoload_once_paths << "#{root}/app/serializers"
  end
end

Также engine может добавлять в коде класса engine и в свой config/environments/*.rb.

Если app/serializers добавлен config.autoload_once_paths, Rails больше не будет рассматривать его как путь автозагрузки, не смотря на то, что это пользовательская директория внутри app. Эта настройка переопределяет то правило.

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

Например, сериализаторы Active Job сохраняются внутри Active Job:

# config/initializers/custom_serializers.rb
Rails.application.config.active_job.custom_serializers << MoneySerializer

и сам Active Job не перезагружается при перезагрузке, это происходит только для кода приложения и engine-ов в путях автозагрузки.

Если сделать MoneySerializer перезагружаемым, это может быть озадачиваемым, так как перезагрузка отредактированной версии не будет влиять на объект этого класса, сохраненного в Active Job. Разумеется, если бы MoneySerializer был перезагружаемым, начиная с Rails 7 такой инициализатор выдал бы NameError.

Еще одним примером использования являются классы engine для декорирования фреймворка:

initializer "decorate ActionController::Base" do
  ActiveSupport.on_load(:action_controller_base) do
    include MyDecoration
  end
end

Тут объект модуля, хранящегося в MyDecoration, во время запуска инициализатора становится предком ActionController::Base, и перезагрузка MyDecoration бесполезна, она не повлияет на цепочку наследования.

Классы и модули из путей однократной автозагрузки могут быть автоматически загружены в config/initializers. Таким образом, с такой настройкой это будет работать:

# config/initializers/custom_serializers.rb
Rails.application.config.active_job.custom_serializers << MoneySerializer

Технически можно автоматически загружать классы и модули, управляемые автозагрузчиком once, в любом инициализаторе, который запускается после :bootstrap_hook.

Пути однократной автозагрузки управляются Rails.autoloaders.once.

5. $LOAD_PATH{#load_path}

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

config.add_autoload_paths_to_load_path = false

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

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

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

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

Перезагрузка может быть включена или отключена. Настройкой, контролирующей это поведение, является 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").

Например, вот сессия консоли 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 является оригинальный устаревший объект класса.

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

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

Во время запуска приложения могут автоматически загружать из путей однократной автозагрузки, которые управляются автоматическим загрузчиком once. Обратитесь к разделу config.autoload_once_paths выше.

Однако, нельзя автоматически загружать из путей автозагрузки, управляемых автоматическим загрузчиком main. Это применимо к коду инциализаторов в config/initializers как приложения, так и engine-ов.

Почему? Инициализаторы запускаются единожды, при запуске приложения. Если вы перезапускаете сервер, они запускаются снова в новом процессе, но перезагрузка не перезапускает сервер, и инициализаторы не запускаются снова again. Давайте рассмотрим два основных случая использования.

7.1. Случай использования 1: Загрузка перезагружаемого кода во время запуска

Давайте представим, что ApiGateway это перезагружаемый класс из app/services, управляемый автоматическим загрузчиком main, и вам необходимо настроить его узел при запуске приложения:

# config/initializers/api_gateway_setup.rb
ApiGateway.endpoint = "https://example.com" # НЕ ДЕЛАЙТЕ ТАК

в перезагруженном ApiGateway узел будет nil, так как код выше не будет запущен повторно.

В течение запуска все еще можно делать подобные вещи, но их необходимо оборачивать в блок to_prepare, запускаемый при запуске и после каждой перезагрузки:

# config/initializers/api_gateway_setup.rb
Rails.application.config.to_prepare do
  ApiGateway.endpoint = "https://example.com" # ПРАВИЛЬНО
end

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

7.2. Случай использования 2: Загрузка кода, остающегося кэшированным, во время запуска

Некоторые конфигурации принимают объект класса или модуля, и они хранят его в месте, не являющемся перезагружаемым.

Например, промежуточная программа:

config.middleware.use MyApp::Middleware::Foo

При перезагрузке стек промежуточных программ не затрагивается, таким образом, объект, хранимый в MyApp::Middleware::Foo во время запуска, остается там устаревшим.

Другим примером являются сериализаторы Active Job:

# config/initializers/custom_serializers.rb
Rails.application.config.active_job.custom_serializers << MoneySerializer

Тот MoneySerializer, вычисленный во время инициализации, добавляется к пользовательским сериализаторам. Если он был перезагружен, изначальный объект все еще будет внутри Active Job, не отражая ваши изменения.

Еще одним примером являются railties или engine, декорирующие классы фреймворка, добавляя модули. Например, turbo-rails декорирует ActiveRecord::Base следующим образом:

initializer "turbo.broadcastable" do
  ActiveSupport.on_load(:active_record) do
    include Turbo::Broadcastable
  end
end

Это добавляет объект модуля в цепочку наследования для ActiveRecord::Base. Изменения в Turbo::Broadcastable не несут эффекта при перезагрузке, цепочка наследования все еще будет изначальной.

Вывод: Эти классы или модули нельзя перезагружать.

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

Как уже отмечено, другой опцией является нахождение их в директории, которая определена в путях однократной автозагрузки, и их автоматическая загрузка. За подробностями обратитесь к разделу о config.autoload_once_paths.

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

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

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

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

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

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

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

В некотором смысле, приложениям нужно нетерпеливо загрузить иерархии 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

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

По умолчанию 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.

11. Автоматическая загрузка и Engine

Engine запускаются в контексте родительского приложения, и их код автоматически загружается, перезагружается и нетерпеливо загружается родительским приложением. Если приложение запускается в режиме zeitwerk, код engine загружается режимом zeitwerk. Если приложение запускается в режиме classic, код engine загружается режимом classic.

При запуске Rails, директории engine добавляются в пути автозагрузки, и, с точки зрения автозагрузчика, они не отличаются. Основные входные данные автозагрузчиков это пути автозагрузки, а принадлежат ли они дереву исходного кода приложения или дереву исходного кода engine, это не важно.

Например, приложение использует Devise:

% bin/rails runner 'pp ActiveSupport::Dependencies.autoload_paths'
[".../app/controllers",
 ".../app/controllers/concerns",
 ".../app/helpers",
 ".../app/models",
 ".../app/models/concerns",
 ".../gems/devise-4.8.0/app/controllers",
 ".../gems/devise-4.8.0/app/helpers",
 ".../gems/devise-4.8.0/app/mailers"]

Если engine контролирует режим автозагрузки родительского приложения, можно писать engine как обычно.

Однако, если engine поддерживает Rails 6 или Rails 6.1 и не контролирует его родительское приложение, ему необходимо быть готовым запускаться либо в режиме classic, либо в режиме zeitwerk. Нужно принять во внимание:

  • Если режиму classic может понадобиться вызов require_dependency, чтобы убедиться, что некоторая константа загружена в некотором месте, напишите его. Хотя он и не нужен в режиме zeitwerk, ничего страшного, он будет работать и в режиме zeitwerk.

  • Режим classic подчеркивает имена констант ("User" -> "user.rb"), а режим zeitwerk озаглавливает имена файлов ("user.rb" -> "User"). Они совпадают в большинстве случаев, но не всегда, когда есть ряд последовательных заглавных букв, как в "HTMLParser". Простейшим способом обеспечить совместимость является избегание таких имен. В данном случае выберите "HtmlParser".

  • В режиме classic, файлу app/model/concerns/foo.rb можно определять и Foo, и Concerns::Foo. В режиме zeitwerk имеется лишь одна опция: он должен определять Foo. Для совместимости определяйте Foo.

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

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

Простейший способ для этого - включить

Rails.autoloaders.log!

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

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

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

Логгер Rails пока еще не доступен при запуске config/application.rb. Если предпочитаете использовать логгер Rails, сконфигурируйте эту настройку в инициализаторе:

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

13. Rails.autoloaders

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

Rails.autoloaders.main
Rails.autoloaders.once

Предикат

Rails.autoloaders.zeitwerk_enabled?

все еще доступен в приложениях Rails 7, и возвращает true.