Engines - Modularización en Rails

💡 Antes de arrancar, quiero aclarar que la siguiente guía habla desde la perspectiva de https://github.com/platanus/nest ya que es el único proyecto donde estamos experimentando con engines.

Para modularizar nest, estamos usando engines de Rails.

Se debe tener en cuenta que el concepto es el mismo que el de la guía de rails pero en nest estamos poniendo los módulos/engines dentro del repositorio de la main app.

También es importante aclarar que consideramos la "main app" a todo lo que está directamente bajo el root del proyecto. Por ej: un modelo en app/models/user.rb es un modelo de la main app. En cambio un modelo en engines/recruiting/app/models/recruiting/user.rb es un modelo del engine de reclutamiento.

Generador

Para facilitar la tarea de crear nuevos engines usamos Metagem de la siguiente manera:

bin/rails g metagem:new_engine engine_name

Ejemplo:

bin/rails g metagem:new_engine recruiting

Contesta las preguntas y terminarás teniendo la estructura base de tu engine.

Si modificas algo en algún engine, por favor traspasa ese conocimiento a Metagem. La idea de tener un generador es que este vaya recogiendo el conocimiento común y así evitar que otros devs tengan que enfrentarse a los mismos problemas.

Conceptos generales

Diferencia entre engines y gemas

Gemas

  • Son código encapsulado Ruby. Una librería.

  • Se utilizan para extraer alguna funcionalidad específica.

  • Se puede utilizar en proyectos que no sean Rails.

  • NO tienen código Rails: controllers, modelos, etc.

  • Ejemplos de gemas: httparty, remove_bg, pry, etc.

Engines

  • Los engines también son gemas de Ruby.

  • Se utilizan para extender Rails.

  • Tienen la misma estructura de directorios (app, config, etc) que una app Rails.

  • En Platanus las utilizamos para extraer funcionalidades (módulos) completas.

  • Ejemplos de engines: devise, paranoia, active admin, etc.

Estructura

Main App

Es donde está todo el código que es común a todos los módulos y aquí es donde se conectarán todos los feature engines.

Feature Engine

Existirán n feature engines. Uno por cada funcionalidad independiente de Platanus. Los feature engines deben pensarse como funcionalidad que se puede desconectar de la main app sin romperla. Por ejemplo el módulo de reclutamiento es un ejemplo de feature engine.

Testing

Los tests de una funcionalidad de la main app van dentro de /spec en cambio los tests de una funcionalidad de un engine va dentro de engines/engine_name/spec. Por ej: engines/recruiting/spec/models/recruiting/process_spec.rb es el test de un modelo del módulo/engine de reclutamiento

Initializer

Se utilizan para configurar gemas. Para agregar configuración al engine, primero deberás definir el atributo en engines/tu_engine/lib/tu_engine.rb. Por ejemplo: supongamos que queremos pasar un token al engine SlackUtils.

Primero agregamos el atributo api_token en engines/slack_utils/lib/slack_utils.rb así:

require "slack_utils/engine"

module SlackUtils
  extend self

  attr_accessor :api_token

  def configure
    yield self
    require "slack_utils"
  end
end

Luego, en el initializer del engine (ubicado en /app/config/initializers/slack_utils.rb siguiendo el ejemplo):

EnginesUtil.initialize_engine(:slack_utils) do |config|
  config.api_token = "XXX"
end

Después, dentro del engine, podrás usarlo así:

SlackUtils.api_token #=> "XXX"

Referenciar gemas locales en otras gemas/engines locales.

Es común que pase que en un engine que definimos localmente (dentro de la main app) queramos poner como dependencia a otro engine o gema local. Para explicarles cómo se hace supongamos que tengo el engine de reclutamiento (recruiting) que requiere el engine slack_utils:

Primero, en el Gemfile de recruiting agregaremos la referencia a la gema así:

source '<https://rubygems.org>'
git_source(:github) { |repo| "<https://github.com/#{repo}.git>" }

gemspec

gem "slack_utils", path: "../../engines/slack_utils"

Como se puede ver, se hace una referencia a un path local.

y en el recruiting.gemspec:

spec.add_dependency "slack_utils"

De esta manera, al hacer bundle install, el Gemfile.lock referenciará al path local en vez de a una gema publicada en rubygems.

Es importante destacar que las dependencias entre engines se hacen a nivel de gemspec/Gemfile y a través del initializer.

Particularidades de los Engines

Ubicación

Los engines se crean dentro de /engines y se agregan automáticamente en el Gemfile de la app principal. Los tests van dentro de engines/tu_engine/spec

Isolated namespace

Los engines ejecutan un método isolate_namespace MyEngine así:

module Recruiting
  class Engine < ::Rails::Engine
    isolate_namespace Recruiting
    # ...

Ese método se encarga de aislar controllers, models, routes, etc. dentro de un namespace para evitar colisiones de nombres y overrides. Por ejemplo, al crear un nuevo modelo, este se creará dentro del namespace y la tabla tendrá como prefijo el nombre del engine.

Se creará:

module Recruiting
  class ProcessType < ApplicationRecord
    # ...

en vez de:

class ProcessType < ApplicationRecord
  # ...

y la tabla se llamará recruiting_process_types en vez de simplemente process_types

Extender clases

Es común que el engine extienda models, controllers, etc. existentes. Estas extensiones se agregan dentro del engine en el directorio que corresponda. Por ejemplo: si dentro del engine fdbk quiero extender el modelo TeamMember esa extensión debería ponerla acá: engines/nombre_engine/app/models/nombre_engine/nombre_modelo_ext.rb -> engines/fdbk/app/models/fdbk/team_member_ext.rb. Si en cambio quiero extender por ej un observer, debería ponerlo acá: engines/nombre_engine/app/observers/nombre_engine/nombre_observer_ext.rb -> engines/fdbk/app/observers/fdbk/team_member_observer_ext.rb.

Por ej, para extener el modelo TeamMember los pasos a seguir son:

  1. Hacer que el modelo nos indique que está listo para ser extendido.

    Esto se hace dispatcheando el evento **al final **de la clase definida en app/models/team_member.rb

    class TeamMember < ApplicationRecord
      #...
    
      ActiveSupport.run_load_hooks("TeamMember", self)
    end
  2. Definir la extensión.

    Como es un modelo, debo agregarla acá: engines/tu_engine/app/models/tu_engine/team_member_ext.rb y se ve algo así:

    module TuEngine
      module TeamMemberExt
        extend ActiveSupport::Concern
    
        included do
          def hola
            puts "soy un método que agregó el engine"
          end
        end
      end
    end
  3. Extender la funcionalidad.

    Se hace dentro de engines/tu_engine/lib/tu_engine/extensions.rb

    ActiveSupport.on_load("TeamMember") do
      include TuEngine::TeamMemberExt
    end

    El código anterior indica que una vez que se ejecute el evento TeamMember, extienda la funcionalidad del modelo TeamMember incluyendo la extensión TuEngine::TeamMemberExt.

Pasa saber si una funcionalidad va dentro de la main app o de una extensión tienen que imaginarse que ponen ese método en la main app y contestarse la siguiente pregunta: "si borrara el engine el método que quedó en la main app sigue funcionando o se rompe". Si la respuesta es: "se rompe" es que va en una extensión. Por ej: supongamos que agrego la siguiente relación a TeamMember:

class TeamMember < ApplicationRecord
  has_many :fdbk_schedules, class_name: "::Fdbk::Schedule", dependent: :nullify
end

Como ven es una relación que apunta a una tabla del engine Fdbk. Entonces me hago la pregunta: "¿Si borro el engine Fdbk esa relación se rompe?" la respuesta en este caso sería que sí ya que al borrar el engine se borraría la tabla fbdk_schedules y por lo tanto se rompería la relación. Para que esto no suceda, la extensión se coloca dentro del engine Fdbk y si el día de mañana se borrara (junto a la tabla fbdk_schedules) se borraría también la relación has_many :fdbk_schedules y todo (lo de la main app) quedaría intacto.

Activar código de un feature engine en la main app (core)

Para eso se puede utilizar el helper EnginesUtil.

# como bloque
EnginesUtil.with_available_engine(:nombre_de_tu_engine) do
  # ejecuto acá código que debe correrse solo si el engine está cargado
end
# como condicional
if EnginesUtil.available_engine?(:nombre_de_tu_engine)
  # ejecuto acá código que debe correrse solo si el engine está cargado
end

Ejemplo:

EnginesUtil.with_available_engine(:recruiting) do
  # ...
end

En general cuando estoy trabajando en un engine debo intentar por todos los medios poner vistas, modelos, etc. dentro del engine. El problema es que muchas veces eso no se podría hacer. Supongamos el caso de una navbar que vive en la main app. Si quiero agregar un link a alguna vista de reclutamiento no me queda otra que agregarlo dentro de la main app, no? Bueno, en casos como estos se puede usar los métodos mencionados anteriormente. Por ej: en el dashboard de active admin que vive dentro de la main app, se agrega info de los diferentes engines solo si están activos. Este es el código:

ActiveAdmin.register_page "Dashboard" do
  menu priority: 1, label: proc { I18n.t("active_admin.dashboard") }


  content title: proc { I18n.t("active_admin.dashboard") } do
    panel I18n.t("active_admin.pages.dashboard.search_platanus_github") do
      render partial: 'search_github_form'
    end

    if EnginesUtil.available_engine?(:recruiting)
      render("admin/dashboard/recruiting_processes")
    end

    if EnginesUtil.available_engine?(:fdbk)
      render("admin/dashboard/fdbk_sessions")
    end

    panel I18n.t("active_admin.pages.dashboard.tasks") do
      render partial: 'tasks'
    end
  end
end

Con el código anterior, si se borrara el engine de reclutamiento, esta vista admin/dashboard/recruiting_processes se borarría también. Si no existiera la condición if EnginesUtil.available_engine?(:recruiting) daría un error. Como si existe la condición, simplemente no se mostrará info relacionada al reclutamiento y ya.

Agregar modelos, controllers, etc.

Los engines tienen la misma estructura que una rails app. Entonces si quieres que tu engine agregue por ejemplo un job, harás lo mismo que harías en la main app pero dentro del engine. Es decir, lo agregarás dentro de: /engines/recruiting/app/jobs/recruiting/assignation_job.rb

module Recruiting
  class AssignationJob < Recruiting::ApplicationJob
    # ...
  end
end

Ten en cuenta que:

  1. Tu job heredará de TuEngine::ApplicationJob. En este caso: Recruiting::ApplicationJob

  2. Se definirá dentro del namespace TuEngine. En este caso: Recruiting.

Lo 2 puntos anteriores se aplican para controllers, modelos, etc.

Creación de modelos y migraciones

Los modelos para los engines se crean ejecutanto el siguiente comando en el root del proyecto:

bin/rails g model engine_name/model_name --engine-model

Como se puede ver, la única diferencia con la creación de un modelo normal de Rails es la opción --engine-model

Por ej:

bin/rails g model guides/media_file type:string name:string identifier:string file_data:text section:references  --engine-model

Algo a tener en cuenta es que, como metagem está en desarrollo, hoy en día los modelos colocan las migraciones donde corresponde pero si luego creamos más migraciones, estas se crearán en la main app y deberemos moverlas a mano a engines/nombre_engine/db/migrate/xxx.rb -> ej: engines/guides/db/migrate/20220927140455_create_guides_sections.rb

Rutas

Las rutas de un engine se definen dentro del engine pero, para que estén disponibles dentro de la main app, deberás montarlas en /config/routes.rb así:

Rails.application.routes.draw do
  mount Recruiting::Engine, at: '/recruiting'

  #...

En el engine /engines/recruiting/config/routes.rb

Recruiting::Engine.routes.draw do
  get  '/dashboard' => 'dashboard#index'
  root 'dashboard#index'
end

Un detalle importante a considerar es que desde main app se tiene acceso a los helpers de los engines disponibles según su nombre de ruta que se puede obtener con los comandos anteriores. Por ejemplo:

# app/views/shared/navbar/_right_menu.html.erb (desde main_app)

link_to recruiting.recruiting_index_url

Y, también, desde los engines, se tiene acceso a main_app, con lo que se tiene acceso directo a los helpers de rutas del main_app. Por ejemplo:

# engines/recruiting/app/views/recruiting/shared/_main_header.html.erb (desde recruiting)

link_to "Ingresar", main_app.recruiting_new_user_session_path

Testing

  • Al ejecutar export ENABLED_ENGINES='' bin/rspec spec se correrán solo los tests de la main app con todos los engines prendidos.

  • Al ejecutar export ENABLED_ENGINES=false bin/rspec spec se correrán solo los tests de la main app con todos los engines apagados.

  • Al ejecutar export ENABLED_ENGINES='engine_name' bin/rspec engines/engine_name/spec -> ej: export ENABLED_ENGINES='recruiting' bin/rspec engines/recruiting/spec se correrán los test de un engine específico. En este caso los de reclutamiento.

  • Al ejecutar export ENABLED_ENGINES='guides' bin/rspec engines/recruiting/spec **veremos el error **uninitialized constant Recruiting porque estamos tratando de correr los tests para reclutamiento activando solo el engine guides.

Quizás la forma más cómoda de correr los tests localmente es dejar la variable de entorno seteada en vacío -> ENABLED_ENGINES='' para que estén todos los engines habilitados ya que igual, en CircleCI, los tests correrán de manera independiente para asegurar que no tienen dependencias indebidas.

Si hacemos esto último entonces:

  • Al ejecutar bin/rspec spec se correrán solo los tests de la main app con todos los engines prendidos.

  • Al ejecutar bin/rspec engines/recruiting/spec se correrán solo los tests del engine de reclutamiento con todos los engines prendidos.

También puedes usar bin/guard para que los tests corran cuando modifiques las clases o sus tests. Recuerda tener ENABLED_ENGINES=''.

Las siguientes secciones no están muy chequeadas ya que todavía estamos explorando el tema modularización y engines. Solo se han dejado en esta guía como futura referencia.

Webpacker

Cada engine debe tener su propio pack en main_app, como, por ejemplo:

  • app/javascript/packs/recruiting/recruiting.js

Ahí, debe definir las siguientes cosas:

  • Dependencias JS definidas en package.json de main_app

  • Punto de entrada a SCSS definidos en javascript/stylesheets del engine

  • Punto de entrada a Assets definidos en javascript/assets del engine

  • Componentes Vue definidos en javascript/components del engine

Es bueno definir un custom alias del directorio javascript del engine:

// config/webpack/custom.js

'@recruiting': path.resolve(__dirname, '..', '..', 'engines/recruiting/app/javascript')

Para así, luego usar de forma más limpia en el Pack del engine:

// Pack: app/javascript/packs/recruiting/recruiting.js (partial)
import '@recruiting/stylesheets/recruiting/recruiting';

Vue.component('component-name', () => import('@recruiting/components/recruiting/component-name'));
// Pack: app/javascript/packs/recruiting/recruiting.js (partial)
require.context('@recruiting/assets/recruiting', true);

Componentes Vue

Los componentes Vue deben quedar en la carpeta javascript/components del engine.

Ejemplo:

  • engines/recruiting/app/javascript/components/recruiting

Y deben ser referenciados desde su Pack particular como se mencionó anteriormente.

SCSS

Un detalle importante al escribir SCSS, es utilizar apropiadamente las referencias a assets.

Por ejemplo, para usar url() debes considerar lo siguiente:

  • Si el asset está en el engine, debes usar un path relativo, como: url('../../path/to/asset.png')

  • Pero, si el asset está en main_app, debes usar este tipo de path partiendo desde app/javascript como referencia: url('~path/to/asset.png')

Assets

Assets en el pipeline de Sprockets, se pueden usar directamente igual que en main_app. Siguiendo la misma estructura de directorio y configurando el initializer respectivo.

Como ejemplo, considera el caso de Discovery:

  • engines/recruiting/config/initializers/assets.rb

  • engines/recruiting/app/assets/images/defaults/recruiting/topic/image.png

Última actualización