Para modularizar el monolito, estamos usando engines de Rails.
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.
- 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.
- 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.
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.
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.
Actualmente testeamos nuestras gemas dentro de la app principal.
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)
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.
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
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
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.
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
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:
- Tu job heredará de
TuEngine::ApplicationJob
. En este caso:Recruiting::ApplicationJob
- Se definirá dentro del namespace
TuEngine
. En este caso:Recruiting
.
Lo 2 puntos anteriores se aplican para controllers, modelos, etc.
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
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
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
demain_app
- Punto de entrada a SCSS definidos en
javascript/stylesheets
delengine
- Punto de entrada a Assets definidos en
javascript/assets
delengine
- Componentes Vue definidos en
javascript/components
delengine
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);
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.
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 desdeapp/javascript
como referencia:url('~path/to/asset.png')
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
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
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
.