Мульти-тенант в Django

2a336e64def52293019beb68cb4dface.jpg

Привет, Хабр!

Мульти-тенант (multi-tenancy) — это подход, который позволяет одному экземпляру приложения обслуживать множество клиентов или арендаторов (тенатов). Каждый арендатор изолирован от других, имея возможность кастомизации под свои нужды, при этом основной кодовой базой и инфраструктурой делится между всеми.

Когда применять эту замечательную концепцию? Если говорить простыми словами, то мульти-тенант подход наиболее ценен для SaaS-продуктов, когда одно и то же приложение предоставляется разным клиентам, и каждый клиент работает со своим набором данных. Все это серьезно экономит ресурсы на обслуживание инфраструктуры, тк все изменения вносятся централизованно и мгновенно становятся доступны всем клиентам.

В Django мульти-тенант реализовывается довольно часто и для этого есть библиотека django-multitenant.

Установим и настроим

Установим django-multitenant через пип:

pip install django-multitenant

После установки пора добавить django_multitenant в INSTALLED_APPS проекта Django в settings.py.

Необходимо обновить настройки БД, используя 'ENGINE': 'django_tenants.postgresql_backend', чтобы включить поддержку, к примеру схем PostgreSQL:

DATABASES = {
    'default': {
        'ENGINE': 'django_tenants.postgresql_backend',
        'NAME': 'your_db_name',
        'USER': 'your_db_user',
        'PASSWORD': 'your_db_password',
        'HOST': 'your_db_host',
        'PORT': 'your_db_port',
    }
}

Нужно также добавить django_tenants в список установленных приложений:

INSTALLED_APPS = [
    ...
    'django_tenants',
    ...
]

Работа с тенантами

Для создания тенанта и связанных с ним доменов необходимо определить модели Tenant и Domain, как было описано в базовых настройках:

# models.py
from django_tenants.models import TenantMixin, DomainMixin

class Client(TenantMixin):
    name = models.CharField(max_length=100)

class Domain(DomainMixin):
    pass

После создания моделей, можно программно добавлять новых тенантов и домены:

# добавление нового тенанта
from your_app.models import Client, Domain

tenant = Client(schema_name='new_tenant', name='New Tenant')
tenant.save()  # сначала сохраняем тенанта

# добавление домена для тенанта
domain = Domain(domain='newtenant.example.com', tenant=tenant, is_primary=True)
domain.save()

Для применения миграций к схеме конкретного тенанта есть команда migrate_schemas:

python manage.py migrate_schemas --schema=new_tenant

В некоторых случаях может потребоваться динамическое создание тенантов:

def create_tenant(user):
    new_schema_name = generate_schema_name(user)
    new_tenant = Client(schema_name=new_schema_name, name=user.company_name)
    new_tenant.save()

    domain = Domain(domain=f"{new_schema_name}.yourdomain.com", tenant=new_tenant, is_primary=True)
    domain.save()

Можно использовать яmiddleware, которые помогают определить, к какому тенанту относится текущий запрос:

MIDDLEWARE = [
    'django_tenants.middleware.main.TenantMainMiddleware',
    # другие middleware...
]

В django-tenants есть изоляция static и media файлов между тенантами. Настройка путей для этих файлов производится в settings.py:

STATIC_URL = '/static/'
MEDIA_URL = '/media/'

# django-tenants для управления файлами
MULTITENANT_RELATIVE_MEDIA_ROOT = '/tenant_media/'

Тесты

django-tenants предоставляет класс TenantTestCase, который является подклассом django.test.TestCase. TenantTestCase автоматически создает публичного тенанта перед выполнением тестов и удаляет его после:

from django_tenants.test.cases import TenantTestCase

class YourTenantTest(TenantTestCase):
    def test_something(self):
        # тестовый код

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

from django_tenants.utils import tenant_context

class MultiTenantTest(TenantTestCase):
    def test_multi_tenant_interaction(self):
        # создание нового тенанта для теста
        new_tenant = self.create_tenant(domain_url='newtenant.test.com', schema_name='newtenant')
        # использование контекста тенанта для тестирования взаимодействия
        with tenant_context(new_tenant):
            # тестовый код

Можно управлять миграциями в тестовых сценариях:

from django_tenants.test.cases import FastTenantTestCase

class YourMigrationTest(FastTenantTestCase):
    def test_migration(self):
        # тест

Также есть FastTenantTestCase который более быстрей TenantTestCase за счет минимизации операций с базой данных.

Можно работать с DNS

В settings.py проекта можно определить, какие приложения являются общими для всех тенантов SHARED_APPS и какие приложения уникальны для каждого тенанта TENANT_APPS:

# settings.py

SHARED_APPS = [
    'django_tenants',  # обязательно
    'your_app',  # здесь указывается имя приложения
    # другие общие приложения
]

TENANT_APPS = [
    'django.contrib.contenttypes',
    # приложения специфичные для тенантов
]

INSTALLED_APPS = list(SHARED_APPS) + [app for app in TENANT_APPS if app not in SHARED_APPS]

Для обработки запросов к разным тенантам нужно настроить URL-конфигурацию соответствующим образом, используя django-tenants URL router:

# urls.py
from django.conf.urls import url
from django_tenants.utils import tenant_urlpatterns

urlpatterns = [
    # URL-конфигурации
]

urlpatterns += tenant_urlpatterns([
    # URL-конфигурации специфичные для тенантов
])

На стороне DNS для поддоменов, представляющих разные тенанты, необходимо создать соответствующие записи A или CNAME, указывающие на IP-адрес сервера, где размещен Django-проект.

Например, если основной домен example.com, для тенанта tenant1 будет такая запись:

При создании нового тенанта можно автоматически добавлять доменные записи для него, используя сигналы Django или переопределяя метод save модели тенанта:

# models.py
from django.db.models.signals import post_save
from django.dispatch import receiver

@receiver(post_save, sender=Client)
def create_domain_for_new_tenant(sender, instance, created, **kwargs):
    if created:
        Domain.objects.create(domain='{}.example.com'.format(instance.name.lower()), tenant=instance, is_primary=True)

Более подробно с документацией можно ознакомиться здесь.

Подробнее про архитектуру приложений вы можете узнать в рамках онлайн-курсов от практикующих экспертов отрасли. Подробности в каталоге.

© Habrahabr.ru