Используем контексты из Phoenix по-другому
Предисловие
Обычно в предисловии я рассказываю, что меня привело к появлению текста статьи. Этот раз — не исключение.
Для тех, кто не знает, что такое контексты в phoenix, вот ссылка.
А побудило меня то, что понадобилось мне в некоторых контекстах использовать одну и ту же модель. Можно использовать подход вида alias MyApp.AnotherContext.MyModel
, но выглядит это как костыль.
Описанный мной подход делает небольшой шаг в сторону разделения архитектурных слоёв. Не то чтобы мы уже там были, но ведь и Рим не один день строился.
Описаний будет мало, в основном листинги.
Так как с момента написания оригинального текста статьи прошло несколько лет, могу с уверенностью сказать, что все описанные подходы работают хорошо.
Верхнеуровнево
Контексты уезжают в папку contexts
, модели уезжают в папку models
, в ту же папку models
складываем всё, что связано с Ecto.Query
.
Это слишком коротко, поэтому сейчас я начну пояснять подробнее приводить примеры.
Увозим модели в models
Пожалуй, самый важный пункт. Остальное — следствия.
Под моделями в данном случае подразумеваются файлы, содержащие схемы данных (Ecto.Schema
) и changeset’ы (про changeset’ы подробно расписано ниже):
# lib/my_app/models/pizza.ex
defmodule MyApp.Models.Pizza do
use Ecto.Schema
import Ecto.Changeset
schema "pizzas" do
field(:name, :string)
field(:description, :string)
field(:discounted, :boolean)
timestamps()
end
def changeset(struct, params) do
struct
|> cast(attrs, [:name, :description])
|> validate_required([:name])
end
end
Такой переезд позволяет нам выделить слой работы с хранилищем данных (то есть описания, требуемые для ORM). И заодно решает проблему использования одной модели в нескольких контекстах.
Создаём файл для функций выборки
Раз уж увозим слой хранения, то увозим его полностью:
# lib/my_app/models/pizza/query.ex
defmodule MyApp.Models.Pizza.Query do
import Ecto.Query, warn: false
require Ecto.Query
def discounted(query) do
from(q in query, where: discounted == true)
end
def non_discounted(query) do
from(q in query, where: discounted != true)
end
def by_id(query) do
from(q in query, order_by: [desc: q.id])
end
end
Тут же про changeset’ы
Changeset’ы тоже можно увезти в отдельный файл (или даже несколько), чтобы не засорять файл со схемой. Например в lib/my_app/models/pizza/changesets.ex
.
Используем это всё в контексте
Контексты же уезжают в свою отдельную папку, формируя слой обработки данных. Назвать это бизнес-логикой у меня язык клавиатура не поворачивается не даёт.
# lib/my_app/contexts/pizzas.ex
defmodule MyApp.Contexts.Pizzas do
alias MyApp.Models.Pizza
alias MyApp.Models.Pizza.Query
alias MyApp.Repo
def list do
list_query()
|> Query.non_discounted()
|> Repo.all()
end
def get!(id) do
Pizza
|> Query.non_discounted()
|> Repo.get!(id)
end
def get_by!(clauses) do
Pizza
|> Query.non_discounted()
|> Repo.get_by!(clauses)
end
def create(params) do
changeset = Pizza.changeset(%Pizza{}, params)
Repo.insert(changeset)
end
def update(%Pizza{} = pizza, params) do
pizza
|> Pizza.changeset(params)
|> Repo.update()
end
end
Про changeset’ы (UPD 2021.10.05)
Если не трогать schemaless changeset’ы, то мой опыт общения с Ecto позволяет выделить два вида changeset’ов: для хранения и для обработки.
Changeset’ы для хранения
Эти changeset’ы нужны по большей части для того, чтобы ошибки СУБД возвращались не исключениями, а ошибками в changeset’ах.
Это место для того, чтобы делать validate_required
для полей, которые помечены как обязательные в схеме БД и делать обработки вроде unique_constraint
, check_constraint
и foreign_key_constraint
.
Нужно ли в этих changeset’ах делать какие-либо cast’ы? Для меня это открытый вопрос, потому что было бы нелогично требовать обязательность полей, но при этом эти поля ничем не заполнять. Однако при обязательном комбинировании обоих видов changeset’ов можно ничего здесь не кастовать.
Эти changeset’ы должны быть по одному на “модель”, принимать на вход только другие changeset’ы (что позволяет нам явно указать на то, что cast
должен делаться где-то в другом месте) и, очевидно, описываться внутри файлов модели.
Пример:
# lib/my_app/models/pizza.ex
defmodule MyApp.Models.Pizza do
use Ecto.Schema
alias Ecto.Changeset
import Ecto.Changeset
schema "pizzas" do
field(:name, :string)
field(:description, :string)
field(:discounted, :boolean)
timestamps()
end
def db_changeset(%Changeset{} = changeset) do
changeset
|> validate_required([:name])
|> unique_constraint(:name, name: :unique_name_index)
end
end
Changeset’ы для обработки
Вот тут всё интереснее.
Эти changeset’ы должны описываться в контестах и использоваться как входные для changeset’ов хранения.
Допустим, в одном месте мы можем редактировать название и описание, а в другом — только описание. В соответствующих контекстах у нас будет следующий (гипотетический) код:
defmodule MyApp.Contexts.Admin.Pizzas do
alias Ecto.Changeset
alias MyApp.Models.Pizza
alias MyApp.Repo
def update(%Pizza{} = pizza, params) do
pizza
|> changeset(params)
|> Repo.update()
end
def changeset(%Pizza{} = pizza, params) do
pizza
|> Changeset.cast(params, [:name, :description])
|> Pizza.db_changeset()
end
end
defmodule MyApp.Contexts.Manager.Pizzas do
alias Ecto.Changeset
alias MyApp.Models.Pizza
alias MyApp.Repo
def update(%Pizza{} = pizza, params) do
pizza
|> changeset(params)
|> Repo.update()
end
def changeset(%Pizza{} = pizza, params) do
pizza
|> Changeset.cast(params, [:description])
|> Changeset.validate_required([:description])
|> Pizza.db_changeset()
end
end
В результате вместо того, чтобы плодить в области хранения данных их обработку в виде def admin_changeset(struct, params)
и def manager_changeset(struct, params)
мы пишем специфичные для каждого пользовательского сценария changeset’ы в том месте, где описываем сам сценарий.
Выглядит это так себе
Потому что одним и тем же словом “changeset” (да и механизмом, если смотреть под капот) мы описываем два совершенно разных действия: работу с базой данных и работу с пользовательским вводом.
Единственное, что я могу придумать в данной ситуации — делать такие названия changeset’ам, чтобы было понятно, какие относятся к БД, а какие — к обработке пользовательских сценариев.
Шлюзы (UPD 2021-10-10)
Ещё немного про структуру проекта.
Многие сервисы взаимодействуют с другими сервисами (HTTP, MQ и т.п.).
Такой слой “взаимодействия” должен быть отделён от всего прочего (по моему мнению). Обычно я использую для этого директорию “lib/my_app/gateways”.
Пример:
# lib/my_app/gateways/some_other_service.ex
defmodule MyApp.Gateways.SomeOtherService do
@items_url "http://example.com/items"
def get_items do
request = {@items_url, []}
with {:ok, { {_, 200, _}, _, raw_response}} <- :httpc.request(:get, request, [], []),
{:ok, response} <- Jason.decode(raw_response) do
response["items"]
end
end
end
# lib/my_app/contexts/items.ex
defmodule MyApp.Contexts.Items do
alias MyApp.Gateways.SomeOtherService, as: SomeOtherServiceGateway
def load_items do
case SomeOtherServiceGateway.get_items() do
{:ok, items} -> save_items(items)
error -> error
end
end
def save_items do
# save items to DB
end
end
Инверсия зависимостей
Ну, почти.
Чаще всего сторонний сервис недоступен с локальной машины, поэтому приходится использовать “подставной” сервис. В таком случае вместо использования модулей шлюзов напрямую могут быть получены модули в зависимости от конфигурации через вызов функции.
Пример:
# config/dev.exs
# ...
config :my_app, :use_fake_other_service, true
# ...
# lib/my_app/gateways.ex
defmodule MyApp.Gateways do
alias MyApp.Gateways.OtherService
alias MyApp.Gateways.OtherService.Fake, as: FakeOtherService
def other_service do
if Application.get_env(:my_app, :use_fake_other_service) do
FakeOtherService
else
OtherService
end
end
end
# lib/my_app/gateways/other_service/fake.ex
defmodule MyApp.Gateways.OtherService.Fake do
def get_items, do: {:ok, [1, 2, 3, 4, 5]}
end
# lib/my_app/contexts/items.ex
defmodule MyApp.Contexts.Items do
alias MyApp.Gateways
def load_items do
case Gateways.other_service().get_items() do
{:ok, items} -> save_items(items)
error -> error
end
end
def save_items do
# save items to DB
end
end