Мультитенантность с внешними ключами
Исходный кодВ разделе Мультитенантность с префиксами запросов мы узнали, как настраивать мультитенантные приложения с помощью отдельных префиксов запросов, известных как схемы DDL в PostgreSQL и MSSQL и просто отдельные базы данных в MySQL.
Каждый префикс запросов изолирован и имеет собственные таблицы и данные, что обеспечивает необходимые нам гарантии безопасности. С другой стороны, такой подход к мультитенантности может быть слишком затратным, поскольку каждую схему нужно создавать, мигрировать и версионировать отдельно.
Поэтому некоторые приложения могут предпочесть более дешёвый механизм мультитенантности, основанный на внешних ключах. Идея состоит в том, что большинство — если не все — ресурсы системы принадлежат тенанту. Тенант обычно представляет собой организацию или пользователя, и все ресурсы имеют внешний ключ org_id (или user_id), указывающий непосредственно на него.
В этом руководстве мы покажем, как использовать конструкции Ecto, чтобы гарантировать, что все запросы Ecto в вашем приложении должным образом ограничены выбранным org_id.
Добавление org_id к операциям чтения
Первый шаг в нашей реализации — научить репозиторий учитывать org_id. Мы хотим, чтобы можно было выполнять такие команды:
MyApp.Repo.all Post, org_id: 13
При этом репозиторий будет автоматически ограничивать все публикации организацией с ID=13. Этого можно добиться с помощью обратного вызова репозитория Ecto.Repo.prepare_query/3:
defmodule MyApp.Repo do
use Ecto.Repo, otp_app: :my_app
require Ecto.Query
@impl true
def prepare_query(_operation, query, opts) do
cond do
opts[:skip_org_id] || opts[:ecto_query] in [:schema_migration, :preload] ->
{query, opts}
org_id = opts[:org_id] ->
{Ecto.Query.where(query, org_id: ^org_id), opts}
true ->
raise "expected org_id or skip_org_id to be set"
end
end
end
Теперь мы можем передавать :org_id всем операциям чтения, таким как get, get_by, preload и т. д., а также всем операциям с запросами, таким как all, update_all и delete_all. Обратите внимание, что мы намеренно сделали :org_id обязательным, за исключением двух случаев:
если явно установить
:skip_org_idв значение true, параметр:org_idне потребуется. Это снижает вероятность того, что разработчик забудет ограничить запросы, из-за чего личные данные могут случайно стать доступными другим пользователямесли задан параметр
:ecto_query. Это означает, что операцию репозитория вызвала сама Ecto: со значением:schema_migrationпри миграции базы данных или:preloadпри выполнении запроса предварительной загрузки. В этих случаях мы не хотим применять к нимorg_id
Тем не менее задавать org_id для каждой операции утомительно и чревато ошибками. Лучше, если все операции будут пытаться задать org_id.
Задание org_id по умолчанию
Чтобы операции чтения по умолчанию использовали org_id, мы внесём в репозиторий ещё два изменения.
Во-первых, мы будем хранить org_id в словаре процессов. Словарь процессов — это хранилище, доступное только отдельному процессу. Например, каждый тест в вашем проекте выполняется в отдельном процессе. Каждый запрос в веб-приложении также выполняется в отдельном процессе. У каждого из этих процессов есть собственный словарь, в котором мы будем хранить нужное значение и из которого будем его читать. Добавим следующие функции:
defmodule MyApp.Repo do
...
@tenant_key {__MODULE__, :org_id}
def put_org_id(org_id) do
Process.put(@tenant_key, org_id)
end
def get_org_id() do
Process.get(@tenant_key)
end
end
Мы добавили две новые функции. Первая, put_org_id, сохраняет идентификатор организации в словаре процессов. get_org_id считывает значение из словаря процессов.
Вам нужно вызывать put_org_id в каждом процессе до обращения к репозиторию. Например, в веб-приложении вызывайте MyApp.Repo.put_org_id(params_org_id) для каждого запроса сразу после того, как прочитаете текущую организацию из параметра запроса или сеанса. В тестах следует явно задавать put_org_id или передавать параметр :org_id, как описано в предыдущем разделе.
Второе необходимое изменение — задать org_id в качестве параметра по умолчанию для всех операций репозитория. Значение org_id будет точно соответствовать значению в словаре процессов. Для этого достаточно реализовать обратный вызов default_options:
defmodule MyApp.Repo do
...
@impl true
def default_options(_operation) do
[org_id: get_org_id()]
end
end
После этих изменений мы всегда будем задавать поле org_id в запросах Ecto, если только явно не укажем skip_org_id: true при вызове репозитория. Осталось только убедиться, что поле org_id не допускает значений null в таблицах базы данных, и задавать org_id при каждой вставке данных в базу.
Чтобы лучше понять, как должна выглядеть схема нашей базы данных, рассмотрим другие приёмы, которые помогут усилить поддержку мультитенантности, особенно при работе с ассоциациями.
Работа с мультитенантными ассоциациями
Немного расширим нашу предметную область.
До сих пор мы предполагали, что есть схема организации. Однако вместо того, чтобы называть её первичный ключ id, назовём его org_id, чтобы Repo.one(Org, org_id: 13) работал без дополнительной настройки:
defmodule MyApp.Organization do
use Ecto.Schema
@primary_key {:org_id, :id, autogenerate: true}
schema "orgs" do
field :name
timestamps()
end
end
Предположим также, что в организации может быть несколько публикаций, а у самих публикаций может быть несколько комментариев:
defmodule MyApp.Post do
use Ecto.Schema
schema "posts" do
field :title
field :org_id, :integer
has_many :comments, MyApp.Comment
timestamps()
end
end
defmodule MyApp.Comment do
use Ecto.Schema
schema "comments" do
field :body
field :org_id, :integer
belongs_to :post, MyApp.Post
timestamps()
end
end
Важно помнить, что обратный вызов prepare_query применяется ко всем запросам, но не к соединениям внутри одного запроса. Поэтому, если написать такой запрос:
MyApp.Repo.put_org_id(some_org_id) MyApp.Repo.all( from p in Post, join: c in assoc(p, :comments) )
prepare_query применит org_id только к публикациям, но не к join. Хотя это может показаться проблемой, на практике её нет, потому что при добавлении публикаций и комментариев в базу данных у них всегда будет одинаковый org_id. Если у публикаций и комментариев разные org_id, значит, произошла ошибка: данные либо были повреждены, либо в нашем программном обеспечении есть ошибка при добавлении данных.
К счастью, мы можем использовать внешние ключи базы данных, чтобы гарантировать совпадение org_id у публикаций и комментариев. Первая версия таких миграций схемы будет выглядеть так:
create table(:orgs, primary_key: false) do
add :org_id, :bigserial, primary_key: true
add :name, :string
timestamps()
end
create table(:posts) do
add :title, :string
add :org_id,
references(:orgs, column: :org_id),
null: false
timestamps()
end
create table(:comments) do
add :body, :string
add :org_id, references(:orgs), null: false
add :post_id, references(:posts), null: false
timestamps()
end
Пока единственное заслуживающее внимания отличие от обычной миграции — параметр primary_key: false для таблицы :orgs, поскольку мы хотим воспроизвести первичный ключ схемы org_id. Эта схема работает и гарантирует, что публикации ссылаются на существующую организацию, а комментарии — на существующие публикации и организации. Однако она не гарантирует, что все публикации и связанные с ними комментарии принадлежат одной организации.
Мы можем ужесточить это требование, используя составные внешние ключи и внеся следующие изменения:
create unique_index(:posts, [:id, :org_id])
create table(:comments) do
add :body, :string
# There is no need to define a reference for org_id
add :org_id, :integer, null: false
# Instead define a composite foreign key
add :post_id,
references(:posts, with: [org_id: :org_id]),
null: false
timestamps()
end
Вместо того чтобы определять post_id и org_id как отдельные внешние ключи, мы определяем org_id как обычное целое число, а затем задаём post_id+org_id как составной внешний ключ, передавая параметр :with в Ecto.Migration.references/2. Так мы гарантируем, что комментарии ссылаются на публикации, которые ссылаются на организации, и все значения org_id совпадают.
Поскольку для составных внешних ключей требуется уникальность ссылочных ключей, мы создали уникальный индекс для таблицы публикаций до определения составного внешнего ключа.
Если вы используете PostgreSQL и хотите ещё сильнее ужесточить эти гарантии, можно передать параметр match: :full в references:
references(:posts, with: [org_id: :org_id], match: :full)
Это поможет гарантировать, что ни один из столбцов внешнего ключа не может иметь значение nil.
Итоги
В этом руководстве мы изменили интерфейс репозитория, чтобы гарантировать, что наши запросы всегда ограничены org_id, если только мы явно не отключим это ограничение. Мы также узнали, как использовать возможности базы данных для обеспечения целостности данных.
При работе с ассоциациями следует по возможности использовать составные внешние ключи. Например, представьте, что комментарии принадлежат публикациям (которые принадлежат организации), а также пользователю (который тоже принадлежит организации). Миграцию схемы комментариев следует определить так:
create table(:comments) do
add :body, :string
add :org_id, :integer, null: false
add :post_id,
references(:posts, with: [org_id: :org_id]),
null: false
add :user_id,
references(:users, with: [org_id: :org_id]),
null: false
timestamps()
end
Если во всех схемах есть org_id, все операции будут надёжно ограничены текущим тенантом.
Если у вас есть схемы, не связанные с org_id, можно также хранить их в отдельном префиксе запросов или в отдельной базе данных, чтобы полностью отделить данные, не относящиеся к тенантам, от данных, специфичных для тенантов.
© 2014 Chris McCord
Licensed under the MIT License.
https://ecto.hexdocs.pm/multi-tenancy-with-foreign-keys.html