rails
August 7, 2023

ETL в мультитенантной архитектуре

Если данные существуют — значит они нужны бизнесу

В данной статье хотим поделиться рецептом: как сделать сбор, обработку и загрузку продуктовых данных в хранилище на Ruby On Rails для последующего анализа.


Предположим, что у вас есть мультитенантное SaaS приложение, если простыми словами, то это web приложение, где есть возможность обслуживать пользователей из разных организаций независимо друг от друга в рамках одного сервиса, т.е. изолировать подписчиков, чтобы данные одного подписчика были недоступны другому.

Схематичное изображение мультитенантного SaaS приложения

Из-за этой специфики мультитенантной архитектуры, когда данные каждого тенанта (подписчика) изолированы на одном из уровней архитектуры приложения, у вас нет легкой возможности получить объединенную статистику по всем тенантам, например, количество пользователей в вашем сервисе. Вам придется запрашивать эту статистику в каждом тенанте, а потом агрегировать результаты.

Сам процесс сбора, предварительной агрегации, предобработки и загрузки в хранилище называется ETL (Extract, Transform, Load), не путайте с ELT, когда данные обрабатываются после их загрузки в хранилище.

Итак, для начала сделаем отдельное пространство имен в приложении, в дальнейшем вы можете вынести его в отдельный gem и engine или даже микросервис

# app/model/etl.rb

module Etl
end

Для организации хранилища данных будем использовать отдельную базу (например PostgreSQL), которую в дальнейшем будем подключать к инструментам анализа.

Почему не использовать уже имеющиеся базы данных и просто подключить их? В текущей базе наверняка содержатся чувствительные данные, например, персональные данные, при этом для анализа они не нужны, т.к. там вы оперируете статистикой и количественными характеристиками. Кроме этого, текущие базы могут находиться в закрытом контуре, и не всегда возможно организовать прямое подключение внешних потребителей, таких как инструменты BI.

Схема развертывания будет выглядеть примерно так:

Схема развертывания

Воспользуемся возможностью Ruby on Rails подключаться к нескольким базам данных с Active Record и подключим новую базу к нашему приложению.

Для этого добавим в config/database.yml

development:
	warehouse:
	  adapter: postgresql
	  database: warehouse_development
	  migrations_paths: db/warehouse_migrate
    ...

test:
	warehouse:
	  adapter: postgresql
	  database: warehouse_test
	  migrations_paths: db/warehouse_migrate
    ...

production:
	warehouse:
	  adapter: postgresql
	  database: warehouse_production
	  migrations_paths: db/warehouse_migrate
    ...

Теперь наполним наш склад простыми данными (типа пользователи) и бизнес сущностями, которые генерируют пользователи, т.е. теми данными, которые хранятся в обычных таблицах и которые понадобятся для количественного анализа.

Для этого создадим отдельные модели данных, которые будем использовать для извлечения и преобразования записей, а в дальнейшем организуем сохранение в хранилище.

Создадим концерн, в котором включим режим только чтение и отключим STI, чтобы Rails не пыталась заменить класс модели

# app/models/etl/model.rb

module Etl::Model
  extend ActiveSupport::Concern

  included do
    # Отключаем STI, т.к. колонки _type_disabled в таблице не существует, 
    # то механизм STI не сработает
    self.inheritance_column = :_type_disabled
  end

  # Отключим запись, для предотвращения случайной перезаписи
  def readonly?
    true
  end
end

И пример самой модели для чтения данных через прямое наследование

# app/models/etl/user.rb

class Etl::User < User
  include Etl::Model
end

В данном случае сохраняется возможность пользоваться связями и методами реальной модели, например etl_user.messages.count

Или мы можем сделать модель без внутренней логики и связей, для этого используем явное указание table_name

# app/models/etl/user.rb

class Etl::User
  include Etl::Model
  self.table_name = 'users'
end

В данной модели мы можем делать явное преобразование полей и агрегацию связанных данных

# app/models/etl/user.rb

class Etl::User < User
  include Etl::Model

  # Пример агрегации данных
  def messages_count
    self.messages.count
  end
  
  # Пример предобработки полей
  def locked
    locked_at.nil?
  end
end

Теперь после того, как мы умеем читать и преобразовывать данные для последующего анализа, нам нужно загрузить их в хранилище. Для этого в хранилище должно быть специальное место под эти данные.

Создадим миграцию, в которой создадим таблицу для хранения пользователей, но без лишней чувствительной информации

> rails g migration CreateUser id:bigint messages_count:bigint locked:boolean --database warehouse

create db/warehouse_migrate/20230714070604_create_user.rb

Файл с миграцией выглядит так

# db/warehouse_migrate/20230714070604_create_user.rb

class CreateUser < ActiveRecord::Migration[7.0]
  def change
    create_table :users do |t|
      t.bigint :id
      t.bigint :messages_count
      t.boolean :locked

      t.timestamps
    end
  end
end

У нас мультитенантная архитектура, поэтому при загрузке данных из разных тенантов id пользователя может повторяться, поэтому немного модифицируем миграцию и добавим уникальное название тенанта, и можно добавить название окружения, в котором запущено приложение (если у вас их несколько). После изменений миграция будет выглядеть так:

 # db/warehouse_migrate/20230714070604_create_user.rb

class CreateUser < ActiveRecord::Migration[7.0]
  def change
    create_table :users do |t|
      t.string :instance
      t.string :tenant
      t.bigint :tenant_id
      t.bigint :messages_count
      t.boolean :locked

      t.timestamps
    end
  end
end

Соответственно, добавим в модель преобразование поля id в tenant_id и дополнительные поля instance и tenant, т.к. данное действие потребуется всем моделям, просто добавим их сразу в наш концерн

# app/models/etl/model.rb

module Etl::Model
  ...
  
  def tenant_id
    id
  end

  def tenant
    # Индентификатор тенанта, например из переменной окружения
    ENV.fetch('APP_TENANT', 'unkonwn')
  end

  def instance
    # Индентификатор окружения, например из переменной окружения
    ENV.fetch('APP_INSTANCE', 'unkonwn')
  end
end

Теперь создадим модель для записи на склад данных, для этого сначала сделаем базовый класс для моделей склада

# app/models/etl/warehouse/base.rb

class Etl::Warehouse::Base < ActiveRecord::Base
  self.abstract_class = true
  self.inheritance_column = :_type_disabled

  establish_connection :warehouse
end

И модель наших пользователей на складе данных

# app/models/etl/warehouse/user.rb

class Etl::Warehouse::User < Etl::Warehouse::Base
end

Теперь осталось прочитать и сохранить. Можно реализовать колбэк after_save и сохранять данные сразу и на склад или делать полную синхронизацию, например раз в сутки, и заполнять наш склад

class Etl::LoadTables
  def execute
    Etl::User.find_each do |user|
      # Найдем или создадим новую запись на складе
      warehouse_user = Etl::Warehouse::User.find_or_initialize_by(instance: instance, tenant: tenant, tenant_id: user.id)

      # Получим список полей для сохранения из модели склада, в данном случае они будут такие: 
      #   id, instance, tenant, tenant_id, messages_count, locked, created_at, updated_at
      attribuses = warehouse_user.attribute_names - %w[id tenant_id instance tenant]

      # Сохраним уже предобработанные поле из Etl::User в Etl::Warehouse::User
      warehouse_user.assign_attributes(user.slice(attrs))
      warehouse_user.save!
    end
  end
end

Данный класс я упростил, чтобы показать механику сохранения. В боевом виде этот класс можно масштабировать для обработки всех моделей на складе и позаботиться об обработке случая удаления данных на складе в случае удаления их в приложении. Это может быть прямое удаление или добавление специальной пометки.

Вывод

Мы получили процесс ETL для простых данных в мультитенаной архитектуре, мы очистили данные от лишней информации, агрегировали связанные данные в количественные характеристики и наладили ежедневный сбор. Подключив, например, yandex datalens к базе, вы уже можете строить и визуализировать данные.