Estructurando un plugin para el autoloading de Rails

Muchos plugins incluyen muchas definiciones de clases dentro de plugin.rb, o usan require_relative para cargar archivos ruby. Eso funciona, pero conlleva algunas desventajas:

  1. No hay recarga automática de cambios en desarrollo. Cualquier cambio requiere un reinicio completo del servidor
  2. Poner las llamadas a require en el orden correcto puede ser complicado
  3. Si se cargan (require’d) fuera del bloque after_initialize, otras clases/módulos cargados automáticamente pueden no estar disponibles

¡Hay una solución! Los plugins pueden apoyarse en el sistema de carga automática estándar de Rails. Para plugins nuevos, todo lo que necesitas está definido en el plugin-skeleton. Este tema describe cómo adaptar un plugin existente y extender la configuración.

1. Definir un módulo y un Rails::Engine para tu plugin

En plugin.rb, define un módulo para tu plugin con un PLUGIN_NAME único, y añade una línea require_relative para cargar el archivo del motor que estamos a punto de crear.

# name: my-plugin-name
# ...

module ::MyPluginModule
  PLUGIN_NAME = "my-plugin-name"
end

require_relative "lib/my_plugin_module/engine"

Ahora crea {plugin}/lib/my_plugin_module/engine.rb:

module ::MyPluginModule
  class Engine < ::Rails::Engine
    engine_name PLUGIN_NAME
    isolate_namespace MyPluginModule
  end
end

Cosas importantes a notar:

  1. En plugin.rb, debes incluir :: antes del nombre de tu módulo para definirlo en el namespace raíz (de lo contrario, se definiría bajo Plugin::Instance)

  2. require_relative "lib/.../engine" debe estar en la raíz del archivo plugin.rb, no dentro de un bloque after_initialize

  3. Poner el motor en su propio archivo bajo lib/ es importante. Definirlo directamente en el archivo plugin.rb no funcionará. (Rails usa la presencia de un directorio lib/ para determinar la raíz del motor)

  4. La ruta del archivo debe incluir el nombre del módulo, de acuerdo con las reglas de Zeitwerk

  5. El engine_name se utiliza como prefijo para las tareas rake y cualquier ruta definida por el motor (:link: documentación de rails)

  6. isolate_namespace ayuda a prevenir que cosas se filtren entre el núcleo y el plugin (:link: documentación de Rails)

2. Definir archivos ruby en la estructura de directorios correcta

El motor ahora cargará automáticamente todos los archivos en {plugin}/app/{type}/*. Por ejemplo, podemos definir un controlador

{plugin}/app/controllers/my_plugin_module/examples_controller.rb

module ::MyPluginModule
  class ExamplesController < ::ApplicationController
    requires_plugin PLUGIN_NAME

    def index
      render json: { hello: "world" }
    end
  end
end

Esto se cargará automáticamente cada vez que algo en Rails intente acceder a ::MyPluginModule::MyController. Para probar cosas, intenta acceder a esa clase desde la consola de rails.

Para que la carga automática funcione correctamente, las rutas de los archivos deben coincidir con la jerarquía completa del módulo/clase de acuerdo con las reglas definidas por Zeitwerk.

3. Definir rutas en el motor del plugin

Crea un archivo {plugin}/config/routes.rb

MyPluginModule::Engine.routes.draw do
  get "/examples" => "examples#index"
  # define routes here
end

Discourse::Application.routes.draw do
  mount ::MyPluginModule::Engine, at: "my-plugin"
end

Este archivo se cargará automáticamente por el motor, y los cambios tendrán efecto sin reiniciar el servidor. En este caso, la acción del controlador estaría disponible en /my-plugin/examples.json.

4. Añadir más rutas cargadas automáticamente

A veces te puede gustar introducir directorios adicionales de archivos Ruby cargables automáticamente. El ejemplo más común es el directorio lib/ en un plugin.

Modifica la definición de tu motor para añadir lib/ a las rutas de carga automática del motor:

class Engine < ::Rails::Engine
  engine_name PLUGIN_NAME
  isolate_namespace MyPluginModule
  config.autoload_paths << File.join(config.root, "lib")
end

Ahora puedes definir un módulo lib como
{plugin}/lib/my_plugin_module/some_lib_module.rb

module ::MyPluginModule::SomeLibModule
end

Y ahora cualquier referencia a ::MyPluginModule::SomeLibModule cargará automáticamente el módulo desde este archivo.

5. ¡Beneficio!

Todos estos archivos ahora se cargarán automáticamente sin ninguna llamada deliberada a require. Los cambios serán capturados automáticamente por rails y recargados en su lugar sin reiniciar el servidor.


Este documento está controlado por versiones: sugiere cambios en github.

17 Me gusta

David, ¿qué motivó esta documentación? ¿Un cambio anterior?

No recuerdo haber tenido que preocuparme por mantener una estructura particular para lograr la recarga automática, ¿o simplemente tuve la suerte de seguir la estructura adecuada? …

1 me gusta

Sin cambios en el upstream; solo documentamos una estructura que hemos utilizado en algunos plugins (por ejemplo, anonymous-moderators, client-performance), y nos gustaría empezar a usarla en más de nuestros plugins.

Si aceptas las tres desventajas en la parte superior del OP, entonces cualquier estructura funcionará. Si sigues esta estructura, esos problemas se resolverán (¡y la experiencia del desarrollador debería ser mucho más fluida!).

2 Me gusta

Sí, la recarga automática es muy deseable.

¡Gracias por la aclaración y la documentación!

1 me gusta

Para que quede claro también: cuando digo “autocarga”, me refiero a la capacidad de Rails (a través de Zeitwerk) de incorporar cambios de código en un proceso en ejecución casi al instante, sin necesidad de reiniciar. Si tiene llamadas a load o require en su archivo plugin.rb, casi con toda seguridad su plugin no está utilizando la autocarga.

Por separado, Discourse tiene una ayuda que detecta cambios en archivos ruby no recargables y realiza un reinicio completo del servidor automáticamente. Esto tarda unos segundos. Cuando este sistema se activa, verá algo como esto en la consola:

[DEV]: Se han editado archivos que no se autocargan. Reiniciando el servidor...
       - plugins/discourse-follow/plugin.rb
2 Me gusta

¡Ah! Esa es la distinción que me faltaba, ¡gracias! Estaba contento solo con el reinicio automático, ¡pero esto es realmente genial!

2 Me gusta

David:

Tuve un problema interesante, que creo que he resuelto, ¿me preguntaba si tendrías tiempo para comentarlo?

Tenía una constante que solo se definía una vez en mi módulo de plugin dentro de /lib/locations

En cada reconstrucción, recibía la siguiente advertencia advertencia:

image

advertencia: constante ya inicializada Locations::REQUEST_PARTS
advertencia: ya definición previa de REQUEST_PARTS estaba aquí

Mover esto a plugin.rb de la siguiente manera, aparentemente resuelve el problema:

Pero… ¿por qué?

Mi opinión es que evalúa plugin.rb solo una vez, pero ¿cualquier cosa en /lib/plugin-module podría evaluarse más de una vez…? Pero entonces, ¿por qué no se queja de otro código?

1 me gusta

Sí, exactamente.

Cuando Rails/Zeitwerk recarga código, hace remove_const :Blah y luego carga el archivo con el nombre relevante. Así que para tu archivo lib/locations/geocode.rb, el autoloader está haciendo algo como

Locations.remove_const :Geocode
load "lib/locations/geocode.rb"

Por eso no obtienes un error sobre que la constante Geocode ya estaba definida; Rails/Zeitwerk la elimina automáticamente antes de recargar el archivo.

Sin embargo, no hay forma de que el autoloader sepa que tu archivo también estaba definiendo Locations::REQUEST_PARTS, así que no hizo remove_const antes de cargar.

Entonces, si quisieras mantener la constante en el archivo geocode.rb, podrías mover REQUEST_PARTS dentro de la clase Geocode (Locations::Geocode::REQUEST_PARTS).

Pero si quieres mantenerla en Locations::REQUEST_PARTS, entonces creo que moverla a plugin.rb tiene sentido. O, si quisieras hacerla completamente autocargable, podrías mover la definición module ::Locations, incluyendo la constante REQUEST_PARTS, a su propio archivo como lib/locations.rb.

3 Me gusta

Tiene perfecto sentido, ¡gracias!

¿También es justo decir que es innecesario cargar explícitamente estos archivos en plugin.rb como en:

after_initialize do
  %w(
    ../app/models/location_country_default_site_setting.rb
    ../app/models/location_geocoding_language_site_setting.rb
    ../app/models/locations/user_location.rb
    ../app/models/locations/topic_location.rb
**    ../lib/locations/user_location_process.rb**
**    ../lib/locations/topic_location_process.rb**
**    ../lib/locations/country.rb**
**    ../lib/locations/geocode.rb**
**    ../lib/locations/helper.rb**
**    ../lib/locations/map.rb**
    ../lib/users_map.rb
    ../app/serializers/locations/geo_location_serializer.rb
    ../app/controllers/locations/geocode_controller.rb
    ../app/controllers/locations/users_map_controller.rb
  ).each do |path|
    load File.expand_path(path, __FILE__)
  end

¿porque están dentro de la estructura también manejada por el autoloading?

Sí, dado que /lib está incluido en la configuración de carga automática, no debería ser necesario cargarlos manualmente :100:

2 Me gusta

¡A quién no le encanta una buena simplificación de código?! :chefs_kiss:

1 me gusta