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

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

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

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

49c3bfded3cf5f5100ef423140676288?s=128

Python Community Chelyabinsk

October 21, 2017
Tweet

More Decks by Python Community Chelyabinsk

Other Decks in Programming

Transcript

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

  2. Входные данные • B2B SaaS • PostgreSQL • Данные распределены

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

    отдельный сервер базы данных • Хотим предупредить любую возможность доступа к данным чужого аккаунта
  4. Классическая схема Django App Shared DB

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

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

    DB driver RDBMS
  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
  8. Доступные инструменты • Определения баз данных (DATABASES в settings.py) •

    Параметр --database= • Роутинг баз данных (Database routers) • Вызов using()
  9. Требования к решению • Должен быть простой способ определения БД

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

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

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

  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'
  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'
  15. settings.py DATABASE_ROUTERS = [ 'my_app.path.to.MyAppDbRouter' ]

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

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

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

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

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

    'account': { 'ENGINE': 'my_app.path.to.db.wrapper', ... } }
  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
  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
  24. Пройдем весь путь • От поступления запроса на регистрацию •

    До ввода нового аккаунта в работу
  25. Регистрация class SignupApiView(NoAuthApiView): ... def form_valid(self, form): ... account =

    create_account(form) ... account.create_schema() ... login(self.request, user) ...
  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()
  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()
  28. Запуск миграций class Account(models.Model): def migrate(self, *args, **options):
 options['verbosity'] =

    0
 
 call_command('migrate', *args, database='account',
 interactive=False, **options)
  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
  30. Контекстный аккаунт import threading env = threading.local() ... def get_context_account():

    if hasattr(env, 'context_account'): return env.context_account return None
  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 })
  32. Результат Регистрируется пользователь Создается схема Запускаются миграции Авторизация Все запросы

    за данными аккаунта будут идти в БД аккаунта — создается аккаунт
  33. Что насчет асинхронных задач? … где нет контекста Django

  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
  35. @periodic_task(run_every=timedelta(minutes=60)) def run_for_all_accounts(): for account in Account.objects.all(): with UseAccount(account.id): ...

  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()
  37. Вопросы? Степан Родионов rs@antidasoftware.com