Estructuración de un plugin para auto cargado en Rails

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

  1. No se recargan automáticamente los cambios en desarrollo. Cualquier cambio requiere un reinicio completo del servidor.
  2. Conseguir las llamadas require en el orden correcto puede ser doloroso.
  3. Si se cargan con require fuera del bloque after_initialize, es posible que otras clases/módulos cargados automáticamente no estén 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 esqueleto del plugin. Este tema describe cómo adaptar un plugin existente y extender la configuración.

1. Define 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 vamos a crear.

# nombre: mi-nombre-de-plugin
# ...

module ::MiModuloDelPlugin
  PLUGIN_NAME = "mi-nombre-de-plugin"
end

require_relative "lib/mi_modulo_del_plugin/engine"

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

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

Cosas importantes a tener en cuenta:

  1. En plugin.rb, debes incluir :: antes del nombre de tu módulo para definirlo en el espacio de nombres 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, según las reglas de Zeitwerk.

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

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

2. Define 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/mi_modulo_del_plugin/examples_controller.rb

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

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

Esto ahora se cargará automáticamente cada vez que algo en Rails intente acceder a ::MyPluginModule::MyController. Para probar, 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 de módulos/clases según las reglas definidas por Zeitwerk.

3. Define 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 surtirá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 de carga automática

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/mi_modulo_del_plugin/some_lib_module.rb

module ::MiModuloDelPlugin::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 llamadas require deliberadas. Rails detectará automáticamente los cambios y los recargará en su lugar sin necesidad de 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