Skip to content

Instantly share code, notes, and snippets.

@ldlsegovia
Created August 19, 2021 18:58
Show Gist options
  • Save ldlsegovia/00c4e35ce12e7828d546bea76250e136 to your computer and use it in GitHub Desktop.
Save ldlsegovia/00c4e35ce12e7828d546bea76250e136 to your computer and use it in GitHub Desktop.
Rails engines/gems modularización

Modularización

Para modularizar el monolito, estamos usando engines de Rails.

Generador

Para facilitar la tarea de crear nuevos engines y gemas, usamos Gemaker de la siguiente manera:

bin/gemaker new nombre_engine

Ejemplo:

bin/gemaker new 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 Gemaker. 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

Actualmente testeamos nuestras gemas dentro de la app principal.

Initializer

Se utilizan para configurar gemas y establecer dependencias.

El grado de acoplamiento de tu gema, estará marcado por la cantidad de dependencias presentes en el initializer. Intenta mantener esas dependencias al mínimo.

Cada gema tendrá su initializer dentro de /app/config/initializers:

Recruiting.configure do |config|
  config.team_member_model = "TeamMember"
end

Como se puede ver en el código anterior, puedes pasar como dependencia: modelos, comandos, servicios, concerns, etc.

Procura pasar las clases como string. Esto es para que se definan cuando se necesiten y no en el momento de correr el initializer.

Para pasar las dependencias como parámetros, cada gema define en su interior qué atributos puede recibir:

require "recruiting/engine"

module Recruiting
  extend self

  MODULE_DEPENDENCIES = %i{
    team_member_model
  }

  mattr_accessor *MODULE_DEPENDENCIES

  def configure
    yield self
    require "recruiting"
  end

  class << self
    MODULE_DEPENDENCIES.each do |symbol|
      define_method(symbol) do
        class_variable_get(:"@@#{symbol}").constantize
      end
    end
  end
end

Luego, dentro del engine podrás usarlas así:

Recruiting.team_member_model.create!(params)

Referenciar gemas locales en otras gemas/engines locales.

Es común que pase que en un engine que definimos localmente (dentro del monolito) 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 de la gema toolkit:

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 "toolkit", path: "../../gems/toolkit"

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

y en el recruiting.gemspec:

spec.add_dependency "toolkit"

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 /spec/engines/tu_engine

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 en: /engines/tu_engine/app/extensions con un sufijo _ext.

Ten en cuenta que si lo que vas a extender es un modelo, digamos TeamMember, el archivo debería ir dentro del directorio /engines/tu_engine/app/extensions/models/team_member_ext.rb y utilizar class_eval para agregar la nueva funcionalidad.

Recruiting.team_member_model.class_eval do
  def some_new_instance_method
    "Im new!"
  end
end

Recuerda que es obligatorio poner sufijo _ext.rb para que se haga el require de la extensión.

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

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.

Migraciones

Las migraciones del engine se crean dentro del engine. Por ejemplo si corremos en /engines/recruiting el siguiente generador:

rails g model movement doc_id:string capital_cents:integer

Se creará la siguiente migración:

/engines/recruiting/db/migrate/20201130155502_create_recruiting_movements.rb

class CreateRecruitingMovements < ActiveRecord::Migration[5.2]
  def change
    create_table :recruiting_movements do |t|
      t.string :doc_id
      t.integer :capital_cents

      t.timestamps
    end
  end
end

y el siguiente modelo:

/engines/recruiting/app/models/recruiting/movement.rb

module Recruiting
  class Movement < ApplicationRecord
  end
end

Luego, si en la main app (en el directorio root) ejecutamos bundle exec rails db:migrate se correrán las migraciones pendientes de la main app y también las de los engines.

Tener en cuenta que las tablas del engine tendrán como prefijo el nombre del engine. Para que el modelo entienda que tiene que mapear Recruiting::Movement a la tabla recruiting_movements en vez de a movements, se define un prefijo así:

/engines/recruiting/app/models/recruiting/recruiting.rb

module Recruiting
  def self.table_name_prefix
    'recruiting'
  end
end

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

Watson::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_app.recruiting_index_url(subdomain: "recruiting")

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_app)

link_to "Ingresar", main_app.recruiting_new_user_session_path

Webpacker

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

  • app/javascript/packs/watson/watson.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

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

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

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

Vue.component('component-name', () => import('@watson/components/watson/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/watson/app/javascript/components/watson

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

Particularidades de las Gemas

Ubicación

Las gemas se crean dentro de /gems y se agregan automáticamente en el Gemfile de la app principal. Los tests van dentro de /spec/gems/tu_gem

Requerir archivos

Los engines se comportan como una app de Rails normal, es decir se manejan por convención. Las gemas puras de Ruby en cambio, puede ubicar sus archivos en cualquier directorio pero uno debe encargarse de cargarlos/requerirlos. Esto debe hacerse en /gems/tu_gema/lib/tu_gema/dependencies.rb.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment