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 full-size slide

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

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size 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 full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size 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 full-size 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 full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

  21. 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 full-size slide

  22. К этому вернемся позднее
    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 full-size slide

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

    View full-size slide

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

    View full-size slide

  25. Создание новой схемы
    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 full-size 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 full-size slide

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

    options['verbosity'] = 0


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

    interactive=False, **options)

    View full-size slide

  28. Вернемся к контекстному
    аккаунту
    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 full-size slide

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

    View full-size slide

  30. В слое 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 full-size slide

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

    View full-size slide

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

    View full-size slide

  33. Пишем менеджер контекста
    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 full-size slide

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

    View full-size slide

  35. Миграции БД аккаунтов
    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 full-size slide

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

    View full-size slide