2. Ваш первый контекст
Исходный кодВ платформе электронной коммерции различные части кодовой базы тесно связаны между собой, поэтому важно продумывать структуру хорошо определённых модулей. Учитывая это, мы хотим создать API каталога товаров, который будет обрабатывать создание, обновление и удаление товаров, доступных в нашей системе. Начнём с базовой возможности демонстрировать товары, а позже добавим функции корзины покупок. Мы увидим, как прочный фундамент с изолированными границами позволяет приложению естественно развиваться по мере добавления новых возможностей.
Phoenix включает генераторы mix phx.gen.html, mix phx.gen.json, mix phx.gen.live и mix phx.gen.context, которые помогают изолировать функциональность приложения в контекстах. Эти генераторы — отличный способ быстро приступить к работе, а Phoenix поможет вам развивать приложение в правильном направлении. Давайте воспользуемся этими инструментами для создания контекста нашего нового каталога товаров.
При запуске генераторов имя контекста указывать необязательно: Phoenix автоматически использует имя во множественном числе в качестве имени модуля контекста. Благодаря этому можно двигаться дальше, даже если вы только начинаете работу или пока не понимаете, как связаны между собой разные части системы. К счастью, требования к системам электронной коммерции сегодня хорошо известны, поэтому у нас есть отличная основа для продуманного проектирования. Давайте остановимся и подумаем о разных частях нашей системы.
Мы знаем, что у нас будут товары, которые можно будет демонстрировать на страницах и продавать, а также описания, цены и так далее. Помимо продажи товаров, нам понадобится поддержка корзины, оформления заказов и прочего. Хотя покупаемые товары связаны с процессами работы корзины и оформления заказа, демонстрация товара и управление представлением наших товаров существенно отличаются от отслеживания того, что пользователь поместил в корзину, или процесса оформления заказа. Контекст Catalog — естественное место для управления сведениями о товарах и демонстрации товаров, выставленных на продажу.
Начинаем с генераторов
Чтобы быстро создать контекст каталога, воспользуемся mix phx.gen.html. Он создаёт модуль контекста, объединяющий доступ к Ecto для создания, обновления и удаления товаров, а также веб-файлы, например контроллеры и шаблоны веб-интерфейса, в нашем контексте. Выполните следующую команду в корневом каталоге проекта:
$ mix phx.gen.html Catalog Product products title:string \ description:string price:decimal views:integer
После выполнения команды вы должны увидеть примерно такой вывод:
* creating lib/hello_web/controllers/product_controller.ex
* creating lib/hello_web/controllers/product_html/edit.html.heex
* creating lib/hello_web/controllers/product_html/index.html.heex
* creating lib/hello_web/controllers/product_html/new.html.heex
* creating lib/hello_web/controllers/product_html/show.html.heex
* creating lib/hello_web/controllers/product_html/product_form.html.heex
* creating lib/hello_web/controllers/product_html.ex
* creating test/hello_web/controllers/product_controller_test.exs
* creating lib/hello/catalog/product.ex
* creating priv/repo/migrations/20250201185747_create_products.exs
* creating lib/hello/catalog.ex
* injecting lib/hello/catalog.ex
* creating test/hello/catalog_test.exs
* injecting test/hello/catalog_test.exs
* creating test/support/fixtures/catalog_fixtures.ex
* injecting test/support/fixtures/catalog_fixtures.ex
Add the resource to your browser scope in lib/hello_web/router.ex:
resources "/products", ProductController
Remember to update your repository by running migrations:
$ mix ecto.migrate
Phoenix создал веб-файлы, как и ожидалось, в lib/hello_web/. Также видим, что функции контекста были созданы в файле lib/hello/catalog.ex, а файл схемы товара находится в каталоге с таким же именем. Обратите внимание на различие между lib/hello и lib/hello_web. У нас есть модуль Catalog, который служит публичным API для функциональности каталога товаров, а также структура Catalog.Product — схема Ecto для приведения типов и проверки данных о товарах. Phoenix также создал веб-тесты и тесты контекста, а ещё добавил вспомогательные функции для тестов, позволяющие создавать сущности через контекст Hello.Catalog. Мы рассмотрим их позже. А пока следуем инструкциям и добавим маршрут, как указано в консоли, в lib/hello_web/router.ex:
scope "/", HelloWeb do
pipe_through :browser
get "/", PageController, :index
+ resources "/products", ProductController
end
Добавив новый маршрут, Phoenix напоминает обновить репозиторий, выполнив mix ecto.migrate, но сначала нужно внести несколько изменений в созданную миграцию в priv/repo/migrations/*_create_products.exs:
def change do
create table(:products) do
add :title, :string
add :description, :string
- add :price, :decimal
+ add :price, :decimal, precision: 15, scale: 6, null: false
- add :views, :integer
+ add :views, :integer, default: 0, null: false
timestamps(type: :utc_datetime)
end
Мы задали для столбца цены точность 15 и масштаб 6, а также ограничение NOT NULL. Это гарантирует, что мы будем хранить денежные значения с достаточной точностью для любых математических операций, которые могут понадобиться. Затем мы добавили значение по умолчанию и ограничение NOT NULL для счётчика просмотров. Теперь, когда изменения внесены, можно применить миграции к базе данных. Сделаем это:
$ mix ecto.migrate 14:09:02.260 [info] == Running 20250201185747 Hello.Repo.Migrations.CreateProducts.change/0 forward 14:09:02.262 [info] create table products 14:09:02.273 [info] == Migrated 20250201185747 in 0.0s
Прежде чем переходить к созданному коду, запустим сервер с помощью mix phx.server и откроем страницу http://localhost:4000/products. Перейдём по ссылке «Новый товар» и нажмём кнопку «Сохранить», не вводя никаких данных. После отправки формы ошибки проверки отображаются прямо рядом с полями ввода. Отлично! По умолчанию генератор контекста включил поля схемы в шаблон формы, и мы видим, что стандартные проверки обязательных полей работают. Введём пример данных о товаре и отправим форму ещё раз:
Product created successfully. Title: Metaprogramming Elixir Description: Write Less Code, Get More Done (and Have Fun!) Price: 15.000000 Views: 0
Если перейти по ссылке «Назад», мы увидим список всех товаров, в котором должен быть только что созданный товар. Также можно обновить эту запись или удалить её. Теперь, когда мы увидели, как всё работает в браузере, пора взглянуть на созданный код.
Придумывать имена непросто
При создании веб-приложения бывает трудно провести границы или дать имена разным контекстам, особенно если предметная область ещё не так хорошо устоялась, как электронная коммерция.
Поэтому генераторы Phoenix позволяют не указывать имя контекста — это очень полезно, когда вы зашли в тупик или ещё исследуете предметную область. Например, код выше работал бы так же, если бы мы использовали контекст Products по умолчанию для управления товарами. При этом мы всё равно смогли бы постепенно обнаружить другие ресурсы, относящиеся к контексту Products, например категории или галереи изображений.
Мы также советуем не слишком мудрить с именами контекстов. Выбирайте ясное и очевидное имя, понятное всем, кто работает (или будет работать) над проектом. По мере роста приложения и прояснения структуры системы можно просто переименовать контекст или переместить ресурсы. Прелесть модулей Elixir в том, что для их перемещения достаточно переименовать модули и места их вызова (а заодно переименовать файлы для единообразия).
Разбираемся в созданном коде
Эта небольшая команда mix phx.gen.html оказалась на удивление мощной. Мы сразу получили множество возможностей для создания, обновления и удаления товаров в каталоге. Это далеко не полнофункциональное приложение, но помните: генераторы в первую очередь служат учебными инструментами и отправной точкой для создания настоящих возможностей. Генерация кода не решит все ваши проблемы, но поможет разобраться в тонкостях Phoenix и подскажет правильный подход к проектированию приложения.
Для начала посмотрим на ProductController, созданный в lib/hello_web/controllers/product_controller.ex:
defmodule HelloWeb.ProductController do
use HelloWeb, :controller
alias Hello.Catalog
alias Hello.Catalog.Product
def index(conn, _params) do
products = Catalog.list_products()
render(conn, :index, products: products)
end
def new(conn, _params) do
changeset = Catalog.change_product(%Product{})
render(conn, :new, changeset: changeset)
end
def create(conn, %{"product" => product_params}) do
case Catalog.create_product(product_params) do
{:ok, product} ->
conn
|> put_flash(:info, "Product created successfully.")
|> redirect(to: ~p"/products/#{product}")
{:error, %Ecto.Changeset{} = changeset} ->
render(conn, :new, changeset: changeset)
end
end
def show(conn, %{"id" => id}) do
product = Catalog.get_product!(id)
render(conn, :show, product: product)
end
...
end
Мы уже видели, как работают контроллеры, в руководстве по контроллерам, поэтому код, скорее всего, не покажется неожиданным. Стоит обратить внимание на то, как контроллер обращается к контексту Catalog. Видим, что действие index получает список товаров с помощью Catalog.list_products/0, а товары сохраняются в действии create с помощью Catalog.create_product/1. Мы ещё не изучали контекст каталога, поэтому пока не знаем, как именно внутри устроены получение и создание товаров, — но в этом и смысл. Контроллер Phoenix — это веб-интерфейс к нашему приложению в целом. Его не должны волновать подробности получения товаров из базы данных или их сохранения в хранилище. Нам достаточно попросить приложение выполнить нужную работу. Это замечательно, ведь бизнес-логика и детали хранения отделены от веб-уровня приложения. Если позже мы перейдём на полнотекстовое хранилище для поиска товаров вместо SQL-запроса, контроллер менять не придётся. Кроме того, код контекста можно повторно использовать в любом другом интерфейсе приложения: в канале, задаче Mix или долгоживущем процессе импорта данных CSV.
В случае действия create, когда товар успешно создан, мы используем Phoenix.Controller.put_flash/3, чтобы показать сообщение об успехе, а затем перенаправляем пользователя на страницу товара, указанную в маршрутизаторе. Если же Catalog.create_product/1 завершается с ошибкой, мы отображаем шаблон "new.html" и передаём ему changeset Ecto, из которого шаблон извлекает сообщения об ошибках.
Теперь разберёмся подробнее и посмотрим на контекст Catalog в lib/hello/catalog.ex:
defmodule Hello.Catalog do
@moduledoc """
The Catalog context.
"""
import Ecto.Query, warn: false
alias Hello.Repo
alias Hello.Catalog.Product
@doc """
Returns the list of products.
## Examples
iex> list_products()
[%Product{}, ...]
"""
def list_products do
Repo.all(Product)
end
...
end
Этот модуль будет публичным API для всей функциональности каталога товаров в нашей системе. Например, помимо управления сведениями о товарах, мы можем обрабатывать классификацию товаров по категориям и варианты товаров, например размеры, отделку и так далее. Если посмотреть на функцию list_products/0, можно увидеть внутренние детали получения товаров. Всё очень просто: мы вызываем Repo.all(Product). Мы уже рассматривали запросы к репозиторию Ecto в руководстве по Ecto, поэтому этот вызов должен быть знаком. Имя функции list_products обобщённо описывает намерение нашего кода — перечислить товары. Подробности реализации этого намерения, а именно использование Repo для получения товаров из базы данных PostgreSQL, скрыты от вызывающего кода. Эта идея будет часто повторяться при использовании генераторов Phoenix. Phoenix предлагает подумать о том, за что отвечают разные части приложения, а затем объединить их за хорошо названными модулями и функциями, которые ясно выражают назначение кода и скрывают детали реализации.
Теперь мы знаем, как получаются данные, но как сохраняются товары? Посмотрим на функцию Catalog.create_product/1:
@doc """
Creates a product.
## Examples
iex> create_product(%{field: value})
{:ok, %Product{}}
iex> create_product(%{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def create_product(attrs) do
%Product{}
|> Product.changeset(attrs)
|> Repo.insert()
end
Здесь документации больше, чем кода, но важно отметить пару моментов. Во-первых, мы снова видим, что для доступа к базе данных внутри используется Ecto Repo. Возможно, вы также заметили вызов Product.changeset/2. Мы уже говорили о changeset, а теперь видим, как он используется в контексте.
Если открыть схему Product в lib/hello/catalog/product.ex, она сразу покажется знакомой:
defmodule Hello.Catalog.Product do
use Ecto.Schema
import Ecto.Changeset
schema "products" do
field :description, :string
field :price, :decimal
field :title, :string
field :views, :integer
timestamps(type: :utc_datetime)
end
@doc false
def changeset(product, attrs) do
product
|> cast(attrs, [:title, :description, :price, :views])
|> validate_required([:title, :description, :price, :views])
end
end
Это тот же код, что мы видели раньше при запуске mix phx.gen.schema, только здесь над функцией changeset/2 находится @doc false. Это означает, что функцию можно вызывать извне, но она не входит в публичный API контекста. Вызывающий код создаёт changeset через API контекста. Например, Catalog.create_product/1 обращается к Product.changeset/2, чтобы создать changeset из пользовательского ввода. Вызывающий код, например действия контроллера, напрямую не обращается к Product.changeset/2. Всё взаимодействие с changeset товаров осуществляется через публичный контекст Catalog.
Добавление функций каталога
Как мы уже видели, модули контекста предназначены для группировки связанной функциональности и предоставления к ней доступа. Phoenix создаёт общие функции, например list_products и update_product, но они служат лишь основой для дальнейшего развития бизнес-логики и приложения. Добавим одну из базовых возможностей каталога — подсчёт просмотров страниц товаров.
В любой системе электронной коммерции важно знать, сколько раз просматривали страницу товара: эти сведения нужны для маркетинга, рекомендаций, ранжирования и так далее. Мы могли бы попытаться использовать существующую функцию Catalog.update_product, например так: Catalog.update_product(product, %{views: product.views + 1}). Но это не только чревато гонками данных, но и заставило бы вызывающий код знать слишком много о нашей системе Catalog. Чтобы понять, откуда возникает гонка данных, рассмотрим возможную последовательность событий:
Интуитивно можно предположить, что события будут происходить так:
- Пользователь 1 загружает страницу товара со счётчиком 13
- Пользователь 1 сохраняет страницу товара со счётчиком 14
- Пользователь 2 загружает страницу товара со счётчиком 14
- Пользователь 2 сохраняет страницу товара со счётчиком 15
На практике же всё произойдёт так:
- Пользователь 1 загружает страницу товара со счётчиком 13
- Пользователь 2 загружает страницу товара со счётчиком 13
- Пользователь 1 сохраняет страницу товара со счётчиком 14
- Пользователь 2 сохраняет страницу товара со счётчиком 14
Гонки данных делают этот способ ненадёжным для обновления существующей таблицы, поскольку несколько вызывающих сторон могут обновлять устаревшие значения просмотров. Есть способ получше.
Давайте придумаем функцию, описывающую нужное нам действие. Использовать её хотелось бы так:
product = Catalog.inc_page_views(product)
Отлично. Вызывающему коду будет совершенно понятно, что делает эта функция, а мы сможем выполнить инкремент атомарно и избежать гонок данных.
Откройте контекст каталога (lib/hello/catalog.ex) и добавьте новую функцию:
def inc_page_views(%Product{} = product) do
{1, [%Product{views: views}]} =
from(p in Product, where: p.id == ^product.id, select: [:views])
|> Repo.update_all(inc: [views: 1])
put_in(product.views, views)
end
Мы создали запрос для получения текущего товара по его идентификатору и передали его в Repo.update_all. Repo.update_all в Ecto позволяет выполнять пакетные обновления базы данных и отлично подходит для атомарного обновления значений, например счётчика просмотров. Операция репозитория возвращает количество обновлённых записей, а также значения выбранных полей схемы, заданные параметром select. Получив новое число просмотров товара, мы используем put_in(product.views, views), чтобы записать новое значение счётчика в структуру товара.
Теперь, когда функция контекста готова, воспользуемся ею в контроллере товаров. Измените действие show в lib/hello_web/controllers/product_controller.ex, чтобы оно вызывало новую функцию:
def show(conn, %{"id" => id}) do
product =
id
|> Catalog.get_product!()
|> Catalog.inc_page_views()
render(conn, :show, product: product)
end
Мы изменили действие show, передав полученный товар в Catalog.inc_page_views/1, которая возвращает обновлённый товар. Затем мы, как и раньше, отобразили шаблон. Давайте проверим результат. Несколько раз обновите страницу товара и проследите, как увеличивается счётчик просмотров.
Атомарное обновление также можно увидеть в журналах отладки Ecto:
[debug] QUERY OK source="products" db=0.5ms idle=834.5ms UPDATE "products" AS p0 SET "views" = p0."views" + $1 WHERE (p0."id" = $2) RETURNING p0."views" [1, 1]
Отличная работа!
Как мы увидели, проектирование с использованием контекстов создаёт прочную основу для развития приложения. Дискретные, хорошо определённые API, выражающие назначение системы, помогают писать более сопровождаемые приложения с повторно используемым кодом. Теперь, когда мы знаем, как расширять API контекста, давайте разберёмся с обработкой связей внутри контекста.
© 2014 Chris McCord
Licensed under the MIT License.
https://phoenix.hexdocs.pm/your_first_context.html