sviluppiamo comunicazione con weLaika Advertising

Chiacchiere da condominio - Architettura multi-tenant

Tags: rails, tenants, architecture, ruby, pattern
Stefano Pau -
Stefanopau

È così difficile riunire tutti in una riunione di condominio, c'è sempre qualcosa che non va e si finisce per non andare d'accordo. è lo stesso disagio che si prova quando abbiamo a che fare con un'architettura multi-tenant. Che significa? Far convivere più applicativi molto simili tra loro, sotto uno stesso tetto.

Supponiamo di dover sviluppare una serie di e-commerce con Ruby on Rails, che condividono quasi tutto il codice, ma con piccole personalizzazioni che non rendono possibile l'utilizzo completo di tutta la codebase. L'approccio che avrei adottato in passato sarebbe stato quello di utilizzare una branch nel mio repository per ogni personalizzazione, usando la branch principale solo per modifiche comuni a tutti i tenant.

Una cosa del genere:

1
2
3
4
5
6
master
  |__shop_01
  |__shop_02
  |__shop_03
  ...
  |__shop_30

Questo crea principalmente 2 problemi: 1. è una situazione gestibile per pochi tenant, se apriamo una feature generica da master, poi dovremmo fare il merge verso ogni ambiente. Prima o poi si perderà il controllo della situazione. 2. i test devono essere lanciati singolarmente prima di ogni deploy. Con trenta tenant potrebbero volerci giorni prima di riuscire a fare un nuovo rilascio completo.

Che approccio adottare allora? Beh, Rails è un enorme monolite, sfruttiamo questa cosa a nostro vantaggio e teniamo tutti i tenant in un unico applicativo. Un enorme condominio con tutti i suoi inquilini. Individuiamo i principali gruppi che sicuramente richiederanno un intervento: * environment vars * assets * locales * views * business logic * test

ENV VARS

Iniziamo con le variabili d'ambiente, sarà sicuramente uno dei problemi principali con cui dovrete confrontarvi all'inizio. Ho risolto il problema utilizzando la gemma Chamber . Non dirò altro su questa gemma, andate a leggere la documentazione. È stato un elemento fondamentale della mia implementazione.

Per prima cosa dobbiamo dichiarare tra le variabili d'ambiente in quale tenant ci troviamo:

1
TENANT=shop05

Tutta la logica girerà intorno a questa variabile d'ambiente.

ASSETS

Prendiamo lo stylesheets del nostro ipotetico e-commerce:

1
2
3
4
5
6
7
app/
  |__assets/
      |__stylesheets/
      |__partials/
          |__ _chart.sass
          |__ _product.sass
          |__application.sass

Quindi dentro partials/ ci sono i parziali che verranno poi importati in application.sass. È una situazione canonica.

1
2
3
4
// application.sass
...
@import partials/chart.sass
@import partials/products.sass

Inseriamo ora le nostre customizzazioni per il tenant. Non abbiamo bisogno di replicare codice, manteniamoci sul principio DRY e applichiamo le personalizzazioni replicando meno codice possibile.

1
2
3
4
5
6
7
8
9
10
11
12
13
app/
  |__assets/
      |__stylesheets/
      |__partials/
          |__ _chart.sass
          |__ _product.sass
          |__application.sass
          |__tenants/
              |__shop05/
                  |__partials/
                      |__ _chart.sass
                      |__ _banner.sass
                  |__application.sass

Ho inserito le mie personalizzazioni di _chart.sass e ho creato un nuovo stylesheets _banner.sass. Quello che devo fare ora è ricreare l'albero di importazione dei parziali nell’ application.sass del tenant.

1
2
3
4
5
6
7
8
// tenant application.sass
...
@import '../../partials/chart.sass'
@import 'partials/chart.sass'

@import '../../partials/products.sass'

@import 'partials/banner.sass'

Può sembrare seccante dover ricreare application.sass per ogni tenant, ma l'ordine di importazione dei parziali in SASS è molto importante e non possiamo semplicemente appendere le modifiche in coda all’ application.sass principale.

La stessa logica va applicata anche agli asset Javascript. Anche per le immagini atteniamoci a ciò che è già stato detto.

1
2
3
4
5
6
7
8
9
10
app/
  |__assets/
      |__images/
      |__icons/
          |__icon01.jpg
          |__icon02.jpg
          |__tenants
              |__shop05/
                  |__icons/
                      |__icon02.jpg

va posta particolare attenzione al metodo image_tag. È possibile fare un override o scrivere un helper che prenda in considerazione le immagini del tenant. Il metodo dovrà quindi restituire il path dell'immagine del tenant corrente qualora questa dovesse esistere, in caso contrario, restituirà il path dell'immagine presente tra i nostri assets più generici.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// view helper

def format_path (img_path)
  return nil unless img_path

  # se il tenant non è dichiarato, restituiamo il path fornito dalla vista
  return img_path if ENV['TENANT'].blank?

  # se il tenant + stato dichiarato, ricostruiamo il path dell'ìmmagine personalizzata per il tenant
  new_path = ['tenants', ENV['TENANT'], img_path].join('/')

  # se l'immagine per il tenant esiste realmente, ritorniamo il suo path...
  return img_path unless File.exist?(Rails.root.join(new_path))

  # ...altrimenti ritorniamo il path dell'immagine originale
  new_path
end

infine, la call del nostro image_tag sarà la seguente:

1
= image_tag format_path('path/of/image')

LOCALES

Le traduzioni rappresentano probabilmente il motivo fondamentale per cui è conveniente lavorare in regime di multi-tenants piuttosto che creare applicazioni isolate. È fastidioso gestire una grossa quantità di YAML, spesso condiviso per almeno l'80% tra tutte le applicazioni. Duplicare di volta in volta tutta questa mole di testo comporta stress e naturali errori di copiatura, battitura e distrazione.

Facciamo il minor sforzo possibile seguendo DRY come principio cardine: sovrascriviamo solo le chiavi che vogliamo sostituire, tutto il resto resterà condiviso. Come nel caso degli stylesheets, le chiavi più recenti sostituiscono le precedenti e questo va a nostro favore. Ci basterà creare le nostre traduzioni dedicate per i tenant e accodarle alle traduzioni generali nel nostro application.rb.

1
2
3
4
5
if ENV['TENANT'].present?
  config.i18n.load_path += Dir[
    Rails.root.join('config', 'locales', 'tenants', ENV['TENANT'], '*.{rb,yml}').to_s
  ]
end

VIEWS

Con le viste il discorso è molto più semplice, possiamo intervenire a livello di controller per dirottare il render verso la vista che ci interessa facendo questo semplice override:

1
2
3
4
5
def prepend_view_paths
    subdomain = ENV['TENANT'].presence
    prepend_view_path "app/views/tenants/#{subdomain}" if subdomain
end

il nostro compito è rispettare il percorso che abbiamo deciso di inserire nel metodo prepend_view_paths e quindi:

1
"app/views/tenants/#{subdomain}
1
2
3
4
5
6
7
8
9
app/
  |__views/
      |__products/
      |__form.html.slim
          |__new.html.slim
      |__tenants
          |__shop05/
              |__products/
              |__form.html.slim

in questo caso, verrà caricata la vista views/tenants/shop05/products/form.html.slim solo in presenza del tenant shop05, in tutti gli altri casi verrà utilizzata la vista di default.

Per la renderizzazione delle viste parziali, si può operare come nel caso delle immagini e creare un helper di vista che modifichi dinamicamente il path da passare al metodo render:

1
= render partial: format_view_path('path/of/partial_view')

BUSINESS LOGIC

Cosa facciamo nel caso in cui un tenant richieda necessariamente della logica ad-hoc? Beh, il primo approccio dovrebbe essere quello di integrare la logica in modo tale da renderla valida per tutti i tenant, se anche questo risulta impossibile, non ci resta altro da fare che creare un override della suddetta logica. Ho pensato di racchiudere questa logica all'interno di lib/extensions, questo perchè non ritengo che questi piccoli override facciano realmente parte dell'applicazione, sono più dei casi eccezionali. Il codice Ruby presente in lib non viene automaticamente caricato dall'auto-loader del framework, questo ci da il vantaggio di gestire in modo semplice ed efficace il caricamento della logica che riguarda solo il tenant corrente. Quindi, all'interno di config/initializers/, carichiamo selettivamente le nostre estensioni:

1
2
3
Dir[
  Rails.root.join('lib', 'extensions', 'tenants', ENV['TENANT'], '**', '*.rb')
].sort.each { |l| require l }

L'approccio è il medesimo utilizzato per gli assets, se voglio fare l'override di un metodo del modello Chart in app/models/chart.rb per il tenant shop05, posso inserire il modulo di override in lib/extensions/tenants/shop05/models/chart.rb .

1
2
3
4
5
6
7
module TenantAwesomeModule
  module Chart
    def my_method_override
    [...]
    end
  end
end

Applico quindi l'override al modello Chart:

1
2
3
4
5
class Chart
  prepend TenantAwesomeModule::Chart if defined? TenantAwesomeModule::Chart

  [...]
end

Molto semplice, ma la cosa migliore da fare è evitare il più possibile questo genere di cose: i tenant dovrebbero differenziarsi solo per gli asset, non per la logica ruby.

TEST

Ora che hai la tua logica dedicata per il tenant shop05 devi dire alla tua applicazione di non testare il tenant shop05 con i test generici, ma con quelli dedicati (se ne hai scritto qualcuno). Ovviamente questo vale solo esclusivamente per i test che non possono essere condivisi col tenant.

Aggiungo questa riga al tuo rails_helper.rb per far eseguire i test del tuo tenant:

1
2
3
Dir[
  "./tenants_test/#{ENV['TENANT']}/**/*.rb"
].sort.each { |f| require f } if ENV['TENANT'].present?

Usando RSpec, questo filtro diventa fondamentale:

1
config.filter_run_excluding  tenant_test: true

A questo punto RSpec eseguirà sia ti test generici che quelli relativi al tenant. Impediamo allora ad RSpec di eseguire il test generico in favore di quello del tenant:

1
2
3
RSpec.describe 'Chart', type: :model, tenant_test: false do
  [...]
end

Il filtro dichiarato prima ci permette di escludere selettivamente l'esecuzione di un test.

CONCLUSIONE

A questo punto la tua applicazione è pronta per il deploy. Non dirò niente a riguardo, dovrai trovare la strada migliore per il tuo stack. Nel mio caso specifico ho utilizzato un ambiente diverso per ogni tenant.

Quando dovrai confrontarti con molti tenant, noterai che l'asset precompile diventerà molto lento (questo perchè viene compilato tutto sempre e comunque). Possiamo aggiornare le nostre configurazioni in assets.rb per rendere la compilazione più smart e quindi facendo compilare solo gli asset generici e quelli per il tenant che ci interessa.

Iniziamo con un drastico override per impedire che Sprockets compili tutto:

1
2
3
4
5
6
7
8
9
10
MY_ASSETS = lambda do |logical_path, filename|
  filename.start_with?(::Rails.root.join('app/assets').to_s) && !(
  filename.start_with?(::Rails.root.join('app/assets/stylesheets/tenants').to_s) ||
  filename.start_with?(::Rails.root.join('app/assets/javascripts/tenants').to_s) ||
  filename.start_with?(::Rails.root.join('app/assets/images/tenants').to_s)
  ) && !['.js', '.css', ''].include?(File.extname(logical_path))
end

Rails.application.config.assets.precompile = [APP_ASSETS]

A questo punto carichiamo solo gli asset che ci interessano:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
tenant_prefix = "tenants/#{ENV['TENANT']}"

if ENV['TENANT'].present? &&
   File.directory?(Rails.root.join("app/assets/stylesheets/#{tenant_prefix}"))

  prefix = tenant_prefix
else
  prefix = ''
end

Rails.application.config.assets.precompile += %w[application.js]
asset_check = File.exist?(::Rails.root.join("app/assets/javascripts/tenants/#{ENV['TENANT']}/application.js"))
Rails.application.config.assets.precompile += ["#{prefix}application.js"] if asset_check
Rails.application.config.assets.precompile += ["#{prefix}application.css"]
Rails.application.config.assets.precompile += ["#{prefix}*.jpg", "#{prefix}*.png", "#{prefix}*.gif", "#{prefix}*.svg"]

Questo mi ha permesso di ridurre drasticamente i tempi di compilazione degli asset ormai diventati proibitivi.

Per concludere, è l'approccio che ritengo ideale se si vuole gestire una situazione con molti tenant senza dover fare interventi lato database (meglio utilizzare la gemma Apartment in quel caso). Permette di ridurre notevolmente la duplicazione di codice e spinge quanto più possibile lo sviluppatore a creare codice da condividere per tutti i tenant, riducendo al minimo i casi particolari.