Не буду давать определение мультитенантности, об этом уже несколько раз писали тут и тут. А лучше напрямик перейдем к теме статьи и начнем с таких вопросов:
Почему приложение не делают сразу мультитенантным?
Бывает, что приложение изначально разрабатывают для инсталляции только на стороне клиента. Можно назвать такое приложение коробочным или software as a product. Клиент покупает коробку и разворачивает приложение на своих серверах (примеров таких приложений много).
Но со временем компания разработчик может задуматься, что хорошо бы разместить приложение в облаке, чтобы его арендовали (software as a service). Этот способ развертывания имеет плюсы и для клиентов, и для компании разработчика. Клиенты могут быстро получить работающую систему и не задумываться о развертывании и администрировании. При аренде приложение не требуется больших единовременных капиталовложений.
А компания разработчик получит новых клиентов, а так же новые задачи: развертывание приложения в облаке, администрирование, обновление на новые версии, миграцию данных при обновлении, бэкап данных, мониторинг скорости работы и ошибок, исправление проблем в случае их появления.
Почему приложение в облаке должно быть мультитенантным?
Чтобы разместить приложение в облаке необязательно делать его мультитенантным. Но тогда будет следующая проблема: для каждого клиента придется развернуть выделенный стенд в облаке с арендуемым приложением, а это уже затратно, как с точки зрения потребления ресурсов облачного стенда, так и с точки зрения администрирования. Выгоднее реализовать в приложении мультитенантность, чтобы один экземпляр мог обслуживать нескольких клиентов (организаций).
Если приложение тянет 1000 одновременно работающих пользователей, то выгодно сгруппировать клиентов (организации) так, чтобы суммарно они дали желаемую нагрузку в 1000 пользователей на один экземпляр приложения. И тогда будет самое оптимальное потребление облачных ресурсов.
Предположим, что приложение арендует организация для работы 20 пользователей (сотрудники организации). Тогда нужно сгруппировать 50 таких организаций, чтобы выйти на нужную нагрузку. При этом важно изолировать организации друг от друга. Организация арендует приложение, пускает туда только своих сотрудников, хранит только свои данные, и не видит, что другие организации тоже обслуживаются этим же приложением.
Реализация мультитенантности не означает, что приложение больше нельзя развернуть локально на сервере организации. Можно поддерживать два способа развертывания одновременно:
- мультитенантное приложение в облаке;
- однотенантное приложение на сервере клиента.
Наше приложение прошло подобный путь: от не тенантного к мультитенантному. И в этой статье я поделюсь некоторыми подходами в разработке мультитенантности.
Как реализовать мультитенантность в приложении, которое спроектировано как не тенантное?
Сразу ограничим тему, будем рассматривать только разработку, не будем затрагивать вопросы тестирования, выпуск версии, развертывания, администрирования. Во всех этих областях тоже нужно учитывать появление мультитенантности, но сейчас поговорим только о разработке.
Чтобы понимать, что из себя представляет приложение, которое было не тенантым и стало мультитеннатным, опишу его назначение, список сервисов и используемых технологий.
Это ECM-система (DirectumRX), которая состоит из 10 сервисов (5 монолитных сервисов и 5 микросервисов). Все эти сервисы можно разместить либо на одном мощном сервере, либо на нескольких серверах.
- Веб-сервис – для обслуживания веб-клиентов (браузеры).
- WCF-сервис – для обслуживания десктоп-клиентов (WPF-приложения).
- Сервис для мобильных приложений.
- Сервис для выполнения фоновых процессов.
- Сервис для планирования фоновых процессов.
- Сервис выполнения схем Workflow
- Сервис выполнения блоков Workflow
- Сервис хранения документов (бинарные данные).
- Сервис конвертации документов в html (предпросмотр в браузере).
- Сервис хранения результатов конвертации в html
Cтек используемых технологий:
.NET + SQLServer/Postgres + NHibernate + IIS + RabbitMQ + Redis
Итак, что сделать, чтобы сервисы стали мультитенантными? Для этого нужно доработать следующие механизмы в сервисах, а именно добавить знание о тенантах в:
- хранение данных;
- ORM;
- кеширование данных;
- обработка запросов;
- обработка сообщений очереди;
- конфигурирование;
- логирование;
- выполнение фоновых задач;
- взаимодействие с микросервисами;
- взаимодействие с брокером сообщений.
В случае нашего приложения, это были основные места, которые потребовали доработок. Рассмотрим их отдельно.
Выбор способа хранения данных
Когда читаешь статьи про мультитенантность, то самое первое что разбирают – это как организовать хранение данных. Действительно, пункт важный.
Для нашей ECM-системы основное хранилище – это реляционная БД, в которой около 100 таблиц. Как организовать хранение данных множества организаций, чтобы организация А ни в коем случаем не увидела данные организации Б?
Известно несколько схем (про эти схемы написано уже много):
- создать свою БД под каждую организацию (под каждый тенант);
- использовать одну БД для всех организаций, но под каждую организацию сделать свою схему в БД;
- использовать одну БД для всех организаций, но в каждой таблице добавить колонку "ключ тенанта/организации".
Выбор схемы не случаен. В нашем случае достаточно рассмотреть кейсы администрирования системы, чтобы понять предпочтительный вариант. Кейсы такие:
- добавить тенант (новая организация арендует систему);
- удалить тенант (организация отказалась от аренды);
- перенести тенант на другой облачный стенд (перераспределяем нагрузку между облачными стендами, когда один стенд перестает справляться с нагрузкой).
Рассмотрим кейс переноса тенанта. Основная задача переноса – это перенести данные организации на другой стенд. Перенос не сложно сделать, если у тенанта своя БД, но будет головной болью, если смешать данные разных организаций в 100 таблицах. Попробуйте вытащить из таблиц только нужные данные, перенести их в другую БД, где уже есть данные других тенантов, и чтобы их идентификаторы не пересеклись.
Следующий кейс — добавление нового тенанта. Кейс тоже не простой. Добавление тенанта – это необходимость минимально заполнить системные справочники, пользователей, права, чтобы в систему вообще можно было зайти. Эту задачу лучше решать за счет клонирования эталонной БД, в которой уже есть все что надо.
Кейс удаления тенанта очень легко решается за счет отключения БД тенанта.
По этим причинам мы выбрали схему: один тенант — одна БД.
ORM
Способ хранения данных выбрали, следующий же вопрос: как научить ORM работать с выбранной схемой?
Мы используем Nhibernate. Требовалось, чтобы Nhibernate работал с нескольким БД и периодически переключался на нужную, например, в зависимости от http-запроса. Если обрабатываем запрос организации А, то использовалась БД А, а если запрос от организации Б, то БД Б.
У NHibernate есть такая возможность. Нужно переопределить реализацию NHibernate.Connection.DriverConnectionProvider. Всякий раз, когда NHibernate хочет открыть соединение с БД он обращается к DriverConnectionProvider, чтобы получить строку соединения. Вот тут мы и подменим ее на нужную:
public class MyDriverConnectionProvider : DriverConnectionProvider
{
protected override string ConnectionString
=> TenantRegistry.Instance.CurrentTenant.ConnectionString;
}
Что такое TenantRegistry.Instance.CurrentTenant я расскажу немного позже.
Кеширование данных
Сервисы часто кешируют данные с целью минимизировать запросы к БД или не вычислять одно и то же по многу раз. Проблема в том, что кеши должны быть в разрезе тенантов, если кешируются данные тенантов. Недопустимо, чтобы кеш данных одной организации использовался при обработке запроса другой организации. Самое простое решение – это добавлять идентификатор тенанта в ключ каждого кеша:
var tenantCacheKey = cacheKey + TenantRegistry.Instance.CurrentTenant.Id;
Об этой проблеме надо помнить при создании каждого кеша. В наших сервисах кешей очень много. Чтобы не забыть в каждом учесть идентификатор тенанта, лучше унифицировать работу с кешами. Например, сделать общий механизм кеширования, который из коробки будет кешировать в разрезе тенантов.
Логирование
Рано или поздно в системе что-то пойдет не так, потребуется открыть файл логов и начать изучать его. Первый же вопрос: от имени какого пользователя и какой организации были совершены эти действия?
Удобно, когда в каждой строке лога есть идентификатор тенанта и имя пользователя тенанта. Эта информация становится такой же необходимой, как например, время сообщения:
2019-05-24 17:05:27.985 <message> [User2 :Tenant1]
2019-05-24 17:05:28.126 <message> [User3 :Tenant2]
2019-05-24 17:05:28.173 <message> [User4 :Tenant3]
Разработчик не должен задумываться какой тенант записать в лог, это должно быть автоматизировано, скрыто "под капотом" системы логирования.
Мы используем NLog, поэтому приведу пример на нем. Самый простой способ залогировать идентификатор тенанта – это создать NLog.LayoutRenderers.LayoutRenderer, который позволит получить идентификатор тенанта для каждой записи в лог:
[LayoutRenderer("tenant")]
public class TenantLayoutRenderer : LayoutRenderer
{
protected override void Append(StringBuilder builder, LogEventInfo logEvent)
{
builder.Append(TenantRegistry.Instance.CurrentTenant.Id);
}
}
А затем использовать этот LayoutRenderer в шаблоне лога:
<target layout="${odate} ${message} [${user} :${tenant}]"/>
Выполнение кода
В примерах выше я часто использовал такой код:
TenantRegistry.Instance.CurrentTenant
Пришло время рассказать, что это означает. Но прежде нужно понять подход, которого мы придерживаемся в сервисах:
Любое выполнение кода (обработка http-запроса, обработка сообщения очереди, выполнение фоновой задачи в отдельном потоке) должно быть связано с каким-либо тенантом.
Это значит, что в любом месте выполнения кода можно спросить: "Для какого тенанта работает этот поток?" или по-другому "Какой тенант текущий?".
TenantRegistry.Instance.CurrentTenant – это текущий тенант для текущего потока. Поток и тенант можно связывать в нашем приложений. Связываются они временно, например, на время обработки http-запроса или на время обработки сообщения из очереди. Один из способов выполнить привязку тенанта к потоку делается так:
// Привязать тенант к потоку.
using (TenantRegistry.Instance.SwitchTo(tenantId))
{
// Тенант, который привязан к текущему потоку.
var tenant = TenantRegistry.Instance.CurrentTenant;
// Строка подключения к БД тенанта.
var connectionString = tenant.ConnectionString;
// Идентификатор тенанта.
var id = tenant.Id;
}
Привязанный к потоку тенант можно получить в любом месте выполнения кода, обратившись к TenantRegistry – это синглтон, точка доступа для работы с тенантами. Поэтому Nhibernate и NLog могут обращаться к этому синглтону (в точках расширениях), чтобы узнать строку подключения или идентификатор тенанта.
Фоновые задачи
У сервисов часто бывают фоновые задачи, которые надо выполнять по таймеру. Фоновые задачи могут обращаться к БД организации, и тогда фоновую задачу надо выполнять для каждого тенанта. Для этого не обязательно стартовать на каждый тенант отдельный таймер или поток. Можно в рамках одного потока/таймера выполнить последовательно задачу в разных тенантах. Для этого в обработчике таймера перебираем тенанты, каждый тенант связываем с потоком и выполняем фоновую задачу:
// Пробегаемся по всем тенантам.
foreach (var tenant in TenantRegistry.Instance.Tenants)
{
// Привязваем тенант к потоку.
using (TenantRegistry.Instance.SwitchTo(tenant.Id))
{
// Выполнение фоновую задачу для тенанта.
}
}
К потоку не может быть привязано два тенанта одновременно, если привязываем один, то другой отцепляется от потока. Этот подход мы активно используем, чтобы не плодить потоки/таймеры для фоновых задач.
Как соотнести http-запрос c тенантом
Чтобы обработать http-запрос клиента, надо знать от какой организации он пришел. Если пользователь уже аутентифицирован, то идентификатор тенанта можно хранить в аутентификационной куке (если работа с приложением выполняется через браузер) или в JWT-токене. Но что делать, если пользователь еще не прошел аутентификацию? Например, анонимный пользователь открыл веб-сайт приложения и хочет пройти аутентификацию. Для этого он отправляет запрос с передачей логина и пароля. В БД какой организации искать этого пользователя?
Также анонимные запросы будут на получение страницы входа в приложение, а она может отличаться для разных организаций, например, языком локализации.
Чтобы решить проблему соотнесения анонимного http-запроса и организации (тенанта), мы используем поддомены для организаций. Имя поддомена формируем по имени организации. Пользователи должны использовать поддомен для работы с системой:
https://company1.service.com
https://company2.service.com
По этим адресам доступен один и тот же мультитенантный веб-сервис. Но теперь сервис понимает, от какой организации придет анонимный http-запрос, ориентируясь на доменное имя.
Привязка доменного имени и тенанта выполняется в конфигурационном файле веб-сервиса:
<tenant name="company1"
db="database1"
host="company1.service.com" />
<tenant name="company2"
db="database2"
host="company2.service.com" />
Про конфигурирование сервисов будет рассказано ниже.
Микросервисы. Хранение данных
Когда я говорил, что ECM-системе требуется 100 таблиц, я говорил о монолитных сервисах. Но бывает, что микросервису требуется реляционное хранилище, в котором нужны 2-3 таблицы для хранения его данных. В идеале, у каждого микросервиса свое хранилище, к которому только он имеет доступ. И микросервис сам решает, как хранить данные в разрезе тенантов.
Но мы пошли другим путем: все данные организации мы решили хранить в одной БД. Если микросервису требуется реляционное хранилище, то он использует существующую БД организации, чтобы данные не были разбросаны по разным хранилищам, а были собраны в одну БД. Эту же БД используют монолитные сервисы.
Микросервисы работают только со своими таблицами в БД, и не пытаются работать с таблицами мононолита или другого микросервиса. У этого подхода есть плюсы и минусы.
Плюсы:
- данные организации в одном месте;
- легко бэкапить и восстановаливать данные организации;
- в бэкапе данные всех сервисов согласованы.
Минусы:
- одна БД для всех сервисов – это узкое горлышко при масштабировании (повышаются требования к ресурсам СУБД);
- микросервисы имеют физически доступ к таблицам друг друга, но не используют эту возможность.
Микросервисы. Знание о тенантах не всегда требуется
Микросервис может не знать, что он работает в мультитенантной среде. Рассмотрим один наш сервис, который занимается конвертацией документов в html.
Что делает сервис:
- Берет из очереди RabbitMQ сообщение на конвертацию документа.
- достает из сообщения идентификатор документа и идентификатор тенанта
- Скачивает документ из сервиса хранения документов.
- для этого формирует запрос, в котором передает идентификатор документа и идентификатор тенанта
- Конвертирует документ в html.
- Отдает html в сервис хранения результатов конвертаций.
Сервис не хранит документы и не хранит результаты конвертаций. Знание о тенантах у него косвенное: через сервис транзитом проходит идентификатор тенанта.
Микросервисы. Поддомены не нужны
Выше я писал, что поддомены помогают решить проблему анонимных http-запросов:
https://company1.service.com
https://company2.service.com
Но не все сервисы работают с анонимными запросами, большинству требуется уже пройденная аутентификация. Поэтому микросервисам, которые работают по http, часто не важно с какого HostName пришел запрос, всю информацию о тенанте они получают из JWT-токена или аутентификационной куки, которая приходит с каждым запросом.
Конфигурирование
Сервисы нужно конфигурировать, чтобы они знали о тенантах. А именно:
- указать строки подключения к БД тенантов;
- привязать доменные имена к тенантам;
- указать дефолтный язык и часовой пояс тенанта.
Настроек у тенантов может быть много. Для своих сервисов мы задаем настройки тенантов в конфигурационных xml-файлах. Это не web.config и не app.config. Это отдельный xml-файл, изменения которого нужно уметь подхватывать без перезагрузки сервисов, чтобы добавление нового тенанта не перезапустило всю систему.
Список настроек примерно такой:
<!-- Список тенантов. -->
<block name="TENANTS">
<tenant name="Jupiter"
db="DirectumRX_Jupiter"
login="admin"
password="password"
hyperlinkUriScheme="jupiter"
hyperlinkFileExtension=".jupiter"
hyperlinkServer="http://jupiter-rx.directum.ru/Sungero"
helpAddress="http://jupiter-rx.directum.ru/Sungero/help"
devHelpAddress="http://jupiter-rx.directum.ru/Sungero/dev_help"
language="Ru-ru"
isAttributesSignatureAbsenceAllowed="false"
endorsingSignatureLocksSignedProperties="false"
administratorEmail ="admin@jupiter-company.ru"
feedbackEmail="support@jupiter-company.ru"
isSendFeedbackAllowed="true"
serviceUserPassword="password"
utcOffset="5"
collaborativeEditingEnabled="false"
collaborativeEditingForced="false" />
<tenant name="Mars"
db="DirectumRX_Mars"
login="admin"
password="password"
hyperlinkUriScheme="mars"
hyperlinkFileExtension=".mars"
hyperlinkServer="http://mars-rx.directum.ru/Sungero"
helpAddress="http://mars-rx.directum.ru/Sungero/help"
devHelpAddress="http://mars-rx.directum.ru/Sungero/dev_help"
language="Ru-ru"
isAttributesSignatureAbsenceAllowed="false"
endorsingSignatureLocksSignedProperties="false"
administratorEmail ="root@mars-ooo.ru"
feedbackEmail="support@mars-ooo.ru"
isSendFeedbackAllowed="true"
serviceUserPassword="password"
utcOffset="-1"
collaborativeEditingEnabled="false"
collaborativeEditingForced="false" />
</block>
Когда новая организация арендует сервис, для нее нужно добавить новый тенант в конфигурационный файл. И желательно, чтобы другие организации этого не почувствовали. В идеале перезапуска сервисов быть не должно.
У нас не все сервисы умеют подхватывать конфиг без перезапуска, но самые критичные сервисы (монолиты) умеют это делать.
Итог
Когда приложение становиться мультитенантным, кажется, что сложность разработки резко выросла. Но потом привыкаешь к мультитенантности, и относишься к ее поддержке как к обычному требованию.
Также стоит помнить, что мультитенантность — это не только разработка, но и тестирование, администрирование, развертывание, обновление, бэкапы, миграции данных. Но про них лучше в другой раз.