Spec-Zone.ru › Phoenix

Мультитенантность с внешними ключами

Исходный код

В разделе Мультитенантность с префиксами запросов мы узнали, как настраивать мультитенантные приложения с помощью отдельных префиксов запросов, известных как схемы 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, можно также хранить их в отдельном префиксе запросов или в отдельной базе данных, чтобы полностью отделить данные, не относящиеся к тенантам, от данных, специфичных для тенантов.

← Предыдущая страница Мультитенантность с префиксами запросов
Следующая страница → Самоссылочная связь «многие ко многим»

Пакет Hex Предпросмотр Hex (текущий файл) Просмотреть llms.txt Скачать версию ePub

Создано с помощью ExDoc (v0.40.2) для языка программирования Elixir

© 2014 Chris McCord
Licensed under the MIT License.
https://ecto.hexdocs.pm/multi-tenancy-with-foreign-keys.html

Spec-Zone.ru

Настройки Оффлайн Что нового Помощь О нас
Spec-Zone .ru
спецификации, руководства, описания, API