Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Степан Родионов. Multi-tenant Django

Степан Родионов. Multi-tenant Django

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

Python Community Chelyabinsk

October 21, 2017
Tweet

More Decks by Python Community Chelyabinsk

Other Decks in Programming

Transcript

  1. Multi-tenant Django
    Родионов Степан, Antida software

    View Slide

  2. Входные данные
    • B2B SaaS
    • PostgreSQL
    • Данные распределены неравномерно —
    некоторые аккаунты могут быть сильно 

    больше остальных
    • Решение в качестве эксперимента

    View Slide

  3. Постановка задачи
    • Хотим иметь возможность вынести
    определенный аккаунт на отдельный сервер
    базы данных
    • Хотим предупредить любую возможность
    доступа к данным чужого аккаунта

    View Slide

  4. Классическая схема
    Django App
    Shared DB

    View Slide

  5. Нужная схема
    Django App
    Shared DB
    Account DB Account DB Account DB
    Account DB
    Account DB

    View Slide

  6. Как Django работает с БД
    ORM API
    Backend database
    Python DB driver
    RDBMS

    View Slide

  7. Решение «из коробки»
    ORM API
    Database routing
    DB Definition DB Definition DB Definition
    DB Backend DB Backend DB Backend
    Python DB
    Driver
    Python DB
    Driver
    Python DB
    Driver
    RDBMS RDBMS RDBMS

    View Slide

  8. Доступные инструменты
    • Определения баз данных (DATABASES в settings.py)
    • Параметр --database=
    • Роутинг баз данных (Database routers)
    • Вызов using()

    View Slide

  9. Требования к решению
    • Должен быть простой способ определения БД
    для модели (общая или БД аккаунта)
    • Без явных указаний БД в запросах
    • Все SQL-запросы, связанные с моделями,
    должны направляться в соответствующую БД
    • Должно работать в асинхронных задачах

    View Slide

  10. Хак Решение

    View Slide

  11. settings.py
    DATABASES = {
    'default': {
    ...
    },
    'account': {
    ...
    }
    }

    View Slide

  12. class Account(models.Model):
    ...
    is_shared = True
    ...

    View Slide

  13. Реализуем свой DB Router
    class MyAppDbRouter(object):
    def _is_internal(self, model):
    return model.__module__.startswith('my_app')
    def _is_shared(self, model):
    return hasattr(model, 'is_shared') and model.is_shared
    def db_for_read(self, model):
    if self._is_internal(model):
    if self._is_shared(model):
    return 'default'
    else:
    return 'account'
    return 'default'

    View Slide

  14. Реализуем свой DB Router
    class MyAppDbRouter(object):
    def _is_internal(self, model):
    return model.__module__.startswith('my_app')
    def _is_shared(self, model):
    return hasattr(model, 'is_shared') and model.is_shared
    def db_for_read(self, model):
    if self._is_internal(model):
    if self._is_shared(model):
    return 'default'
    else:
    return 'account'
    return 'default'

    View Slide

  15. settings.py
    DATABASE_ROUTERS = [
    'my_app.path.to.MyAppDbRouter'
    ]

    View Slide

  16. Погружаемся в работу
    с базой данных

    View Slide

  17. View Slide

  18. PostgreSQL’ schemas
    Cluster
    DB DB
    Schema Schema Schema Schema
    Table Table Table Table Table Table Table Table

    View Slide

  19. account_1
    Table A Table B Table C
    — Schema account_2
    Table A Table B Table C
    — Schema

    View Slide

  20. Как выбрать схему для
    отправки запроса?

    View Slide

  21. settings.py
    DATABASES = {
    'default': {
    'ENGINE': 'my_app.path.to.db.wrapper',
    ...
    },
    'account': {
    'ENGINE': 'my_app.path.to.db.wrapper',
    ...
    }
    }

    View Slide

  22. from django.db.backends.postgresql_psycopg2.base import DatabaseWrapper
    from my_app import get_context_account
    class DatabaseWrapper(DatabaseWrapper):
    def __init__(self, *args, **kwargs):
    super(DatabaseWrapper, self).__init__(*args, **kwargs)
    def _cursor(self):
    cursor = super(DatabaseWrapper, self)._cursor()
    if self.alias != 'default':
    cursor.execute(
    'SET search_path = %s' %
    get_context_account().get_schema_name()
    )
    return cursor

    View Slide

  23. К этому вернемся позднее
    def _cursor(self):
    cursor = super(DatabaseWrapper, self)._cursor()
    if self.alias != 'default':
    cursor.execute(
    'SET search_path = %s' %
    get_context_account().get_schema_name()
    )
    return cursor

    View Slide

  24. Пройдем весь путь
    • От поступления запроса на регистрацию
    • До ввода нового аккаунта в работу

    View Slide

  25. Регистрация
    class SignupApiView(NoAuthApiView):
    ...
    def form_valid(self, form):
    ...
    account = create_account(form)
    ...
    account.create_schema()
    ...
    login(self.request, user)
    ...

    View Slide

  26. Создание новой схемы
    from django.db import connection
    ...
    class Account(models.Model):
    def create_schema(self):
    with connection.cursor() as cursor:
    cursor.execute(
    'CREATE SCHEMA %s; COMMIT; BEGIN' % (
    self.get_schema_name()
    )
    )
    self.migrate()

    View Slide

  27. Создание новой схемы
    from django.db import connection
    ...
    class Account(models.Model):
    def create_schema(self):
    with connection.cursor() as cursor:
    cursor.execute(
    'CREATE SCHEMA %s; COMMIT; BEGIN' % (
    self.get_schema_name()
    )
    )
    self.migrate()

    View Slide

  28. Запуск миграций
    class Account(models.Model):
    def migrate(self, *args, **options):

    options['verbosity'] = 0


    call_command('migrate', *args,
    database='account',

    interactive=False, **options)

    View Slide

  29. Вернемся к контекстному
    аккаунту
    def _cursor(self):
    cursor = super(DatabaseWrapper, self)._cursor()
    if self.alias != 'default':
    cursor.execute(
    'SET search_path = %s' %
    get_context_account().get_schema_name()
    )
    return cursor

    View Slide

  30. Контекстный аккаунт
    import threading
    env = threading.local()
    ...
    def get_context_account():
    if hasattr(env, 'context_account'):
    return env.context_account
    return None

    View Slide

  31. В слое middleware
    class MyEnvironmentMiddleware(object):
    def process_request(self, request):
    account = None
    if 'account_id' in request.session:
    account = Account.objects.get(
    id=request.session[‘account_id’]
    )
    env.__dict__.update({
    'context_account': account
    })

    View Slide

  32. Результат
    Регистрируется
    пользователь
    Создается схема
    Запускаются
    миграции
    Авторизация
    Все запросы за
    данными аккаунта будут
    идти в БД аккаунта
    — создается аккаунт

    View Slide

  33. Что насчет
    асинхронных задач?
    … где нет контекста Django

    View Slide

  34. Пишем менеджер контекста
    class UseAccount(object):
    def __init__(self, account_id):
    self.account = Account.objects.get(id=account_id)
    self.current_account = None
    def __enter__(self):
    if hasattr(env, 'context_account'):
    self.current_account = env.context_account
    env.context_account = self.account
    def __exit__(self, exc_type, exc_val, exc_tb):
    env.context_account = self.current_account

    View Slide

  35. @periodic_task(run_every=timedelta(minutes=60))
    def run_for_all_accounts():
    for account in Account.objects.all():
    with UseAccount(account.id):
    ...

    View Slide

  36. Миграции БД аккаунтов
    class Command(BaseCommand):
    help = 'Run migrations for accounts databases'
    def handle(self, *args, **options):
    for account in Account.objects.all():
    with UseAccount(account.id):
    account.migrate()

    View Slide

  37. Вопросы?
    Степан Родионов
    [email protected]

    View Slide