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. Входные данные • B2B SaaS • PostgreSQL • Данные распределены

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

    отдельный сервер базы данных • Хотим предупредить любую возможность доступа к данным чужого аккаунта
  3. Решение «из коробки» 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
  4. Доступные инструменты • Определения баз данных (DATABASES в settings.py) •

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

    для модели (общая или БД аккаунта) • Без явных указаний БД в запросах • Все SQL-запросы, связанные с моделями, должны направляться в соответствующую БД • Должно работать в асинхронных задачах
  6. Реализуем свой 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'
  7. Реализуем свой 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'
  8. account_1 Table A Table B Table C — Schema account_2

    Table A Table B Table C — Schema
  9. settings.py DATABASES = { 'default': { 'ENGINE': 'my_app.path.to.db.wrapper', ... },

    'account': { 'ENGINE': 'my_app.path.to.db.wrapper', ... } }
  10. 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
  11. К этому вернемся позднее 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
  12. Регистрация class SignupApiView(NoAuthApiView): ... def form_valid(self, form): ... account =

    create_account(form) ... account.create_schema() ... login(self.request, user) ...
  13. Создание новой схемы 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()
  14. Создание новой схемы 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()
  15. Запуск миграций class Account(models.Model): def migrate(self, *args, **options):
 options['verbosity'] =

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

    if hasattr(env, 'context_account'): return env.context_account return None
  18. В слое 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 })
  19. Пишем менеджер контекста 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
  20. Миграции БД аккаунтов 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()