Fundamentos de Multi tenant Node.js y PostgreSQL

¿Cómo funciona una arquitectura multi tenant?

Bueno, básicamente tienes una base de código ejecutándose en una infraestructura compartida pero manteniendo aislada una base de datos para cada cliente. Piensa en Jira, Jira es la herramienta en línea más popular para la gestión de tareas de proyectos, seguimiento de errores y problemas, y para la gestión operativa de proyectos donde cada organización tiene su propio panel de control al que se accede a través de un subdominio personalizado donde A y B tienen acceso a las mismas características, reciben las mismas actualizaciones, pero los problemas, tickets, comentarios, usuarios, etc. de A no pueden ser accedidos por B y viceversa. Slack es otro ejemplo de multi tenant y funciona de la misma forma que Jira… por supuesto en este caso hablaremos de usuarios, canales, MP, notificaciones, etc.

¿Cuándo debe utilizar multi tenant?

Imagina que llevas mucho tiempo trabajando en una aplicación impresionante que se puede ofrecer como SaaS, hay diferentes formas de ofrecer una aplicación SaaS pero si tu software necesita mantener una base de datos aislada, pero proporcionando las mismas características a cada cliente, entonces lo necesita.

¿Por qué?

Uno de los beneficios de la aplicación multi tenant es la mantenibilidad de la base de código porque el código siempre será el mismo para todos los clientes, si un cliente informa de un problema, la solución se aplicará a sus otros 999 clientes. Sólo hay que tener en cuenta que si se introduce un error, también se aplicará a todos los clientes. Y qué pasa con la administración de la base de datos, quizás podría ser un poco más complicado, pero siguiendo los patrones y convenciones adecuadas, todo irá bien, hay diferentes enfoques para administrar bases de datos (segregación en servidores distribuidos, bases de datos de conjuntos de datos separados, una base de datos pero esquemas separados, aislamiento de filas) y por supuesto cada uno tiene pros y contras.

¿Qué tal un ejemplo práctico?

Seleccioné el enfoque de bases de datos separadas como base de datos porque creo que es más fácil para este ejemplo, también, debido a que sequelize requiere mucha configuración, utilicé knex en su lugar.

Voy a centrarme en los archivos específicos necesarios para hacer el flujo de trabajo de multi tenant con Node.js y PostgreSQL.

Multi tenant con Node.js y PostgreSQL

Crear la base de datos común para gestionar los inquilinos

CREATE DATABASE tenants_app;

CREATE TABLE tenants (
  id SERIAL PRIMARY KEY,
  uuid VARCHAR(255) UNIQUE NOT NULL,
  db_name VARCHAR(100) UNIQUE NOT NULL,
  db_username VARCHAR(100),
  db_password TEXT,
  created_at TIMESTAMP DEFAULT NOW(),
  updated_at TIMESTAMP DEFAULT NOW()
);

database.js: Establece la conexión con la base de datos principal

const knex = require('knex')
const config = {
  client: process.env.DB_CLIENT,
  connection: {
    user: process.env.DB_USER,
    host: process.env.DB_HOST,
    port: process.env.DB_PORT,
    database: process.env.DB_DATABASE,
    password: process.env.DB_PASSWORD
  }
}
const db = kenx(config)
module.exports = { db, config }

connection-service.js: Se utiliza para preparar la conexión a la base de datos del tenant, es decir, la conexión utilizada para ejecutar consultas en la base de datos adecuada

const knex = require('knex')
const { getNamespace } = require('continuation-local-storage')
const { db, config } = require('../config/database') let tenantMapping

const getConfig = (tenant) => {
  const { db_username: user, db_name: database, db_password: password } = tenant
  return {
    ...config,
    connection: {
      ...config.connection,
      user,
      database,
      password
    }
  }
}

const getConnection = () => getNamespace('tenants').get('connection') || null

const bootstrap = async () => {
  try {
    const tenants = await db
      .select('uuid', 'db_name', 'db_username', 'db_password')
      .from('tenants')

    tenantMapping = tenants.map((tenant) => ({
      uuid: tenant.uuid,
      connection: knex(getConfig(tenant))
    }))
 } catch (e) {
   console.error(e)
 }
}

const getTenantConnection = (uuid) => {
  const tenant = tenantMapping.find((tenant) => tenant.uuid === uuid)

  if (!tenant) return null

  return tenant.connection
}

tenant-service.js: se utiliza para crear una base de datos para cada nuevo cliente, utilizando la misma estructura de base de datos y se utiliza para eliminarla si es necesario.

const Queue = require('bull')
const { db } = require('../config/database')
const migrate = require('../migrations')
const seed = require('../seeders')
const { bootstrap, getTennantConnection } = require('./connection')

const up = async params => {
  const job = new Queue(
    `setting-up-database-${new Date().getTime()}`,
    `redis://${process.env.REDIS_HOST}:${process.env.REDIS_PORT}`
  )
  job.add({ ...params })
  job.process(async (job, done) => {
    try {
      await db.raw(`CREATE ROLE ${params.tenantName} WITH LOGIN;`) // Postgres requiere un rol o usuario por cada tenant
      await db.raw(`GRANT ${params.tenantName} TO ${process.env.POSTGRES_ROLE};`) // proporciona permisos al usuario administrador para permitir la manipulación de la base de datos
      await db.raw(`CREATE DATABASE ${params.tenantName};`)
      await db.raw(`GRANT ALL PRIVILEGES ON DATABASE ${params.tenantName} TO ${params.tenantName};`)
      await bootstrap() // actualiza las conexiones para incluir la nueva tan pronto esta disponible
      const tenant = getTenantConnection(params.uuid)
      await migrate(tenant) // crea todas las tablas de la base de datos en el tenant actual
      await seed(tenant) // inserta datos de prueba
    } catch (e) {
      console.error(e)
    }
  })
}

tenant.js: controlador utilizado para gestionar la solicitud de listado, creación o eliminación de un tenant

const { db } = require('../config/database')
const { v4: uuidv4 } = require('uuid')
const generator = require('generate-password')
const slugify = require('slugify')
const { down, up } = require('../services/tenant-service')

// index

const store = async (req, res) => {
  const {
    body: { organization }
  } = req

  const tenantName = slugify(organization.toLowerCase(), '_')
  const password = generator.generate({ length: 12, numbers: true })
  const uuid = uuidv4()
  const tenant = {
    uuid,
    db_name: tenantName,
    db_username: tenantName,
    db_password: password
  }
  await db('tenants').insert(tenant)
  await up({ tenantName, password, uuid })

  return res.formatter.ok({ tenant: { ...tenant } })
}

const destroy = async (req, res) => {
  const {
    params: { uuid }
  } = req

  const tenant = await db
    .select('db_name', 'db_username', 'uuid')
    .where('uuid', uuid)
    .from('tenants')

  await down({
    userName: tenant[0].db_username,
    tenantName: tenant[0].db_name,
    uuid: tenant[0].uuid
  })
  await db('tenants').where('uuid', uuid).del()

  return res.formatter.ok({ message: 'tenant was deleted successfully' })
}

module.exports = {
  // index,
  store,
  destroy
}

Como se puede ver en las imágenes de abajo ahora la API es capaz de crear múltiples tenants, compartiendo los servicios, puntos finales y otras cosas, pero manteniendo aisladas las bases de datos.

step1
step2
step3
step4

¡Increíble!

Así es, el multi tenant con Node.js y PostgreSQL no es tan complicados como suena, por supuesto, hay muchas cosas a considerar como infraestructura, CI/CD, mejores prácticas, patrones de software, pero solo maneja cada uno a la vez y todo estará bien. Y como puedes ver, esta arquitectura puede ayudar a tu negocio a escalar tan alto como quieras porque la nube es el límite, y la nube no tiene límites por ahora. Por supuesto si quieres consultar el código completo puedes encontrarlo aquí.

Actualización:

He creado una rama para aplicar este concepto usando MySQL como base de datos, además, intentaré añadir soporte para Mongoose lo antes posible.