Эта часть продолжит парадигму «Обучение на практике» с другим распространенным типом приложений: веб-фреймворком. Эта часть будет опираться на информацию из первых двух частей. Чаще всего веб-приложение создается с помощью фреймворка. К счастью, в экосистеме Crystal есть из чего выбирать. Хотя лучшая платформа для использования варьируется от варианта использования к варианту использования, мы собираемся сосредоточиться на Athena Framework.
Эта часть содержит следующие главы:
Глава 8. Использование внешних библиотек
Глава 9. Создание веб-приложения с помощью Athena
Уменьшение дублирования за счет совместного использования кода является практическим правилом во многих языках программирования. Сделать это в рамках одного проекта достаточно легко. Однако, когда вы хотите поделиться чем-то между несколькими проектами, это становится немного сложнее. К счастью для нас, большинство языков также предоставляют свои собственные менеджеры пакетов, которые позволяют нам устанавливать в наши проекты другие библиотеки в качестве зависимостей, чтобы использовать определенный в них код.
Чаще всего эти внешние проекты называются просто библиотеками или пакетами, но в некоторых языках для них есть уникальные имена, например драгоценные камни Ruby gems.. Crystal следует шаблону Ruby и называет свои проекты Crystal Shards. В этой главе мы собираемся изучить мир внешних библиотек, в том числе способы их поиска, установки, обновления и управления ими. Мы рассмотрим следующие темы:
• Использование Crystal Shards
• Поиск Shards
Требования к этой главе следующие:
• Рабочая установка Кристалла.
Инструкции по настройке Crystal можно найти в Главе 1 «Введение в Crystal».
Все примеры кода, использованные в этой главе, можно найти в папке Chapter 08 на GitHub: https://github.com/PacktPublishing/Crystal-Programming/tree/main/Chapter08.
Если вы помните Главу 4 «Изучение Crystal посредством написания интерфейса командной строки», когда мы впервые создавали проект, в рамках этого процесса был создан файл shard.yml, но мы не особо вникали в то, что он собой представляет. был за. Пришло время более подробно изучить назначение этого файла. Суть в том, что этот файл содержит различные метаданные об осколке, такие как его имя, версия и какие внешние зависимости у него есть (если таковые имеются). Напомню, что файл shard.yml из этого проекта выглядел так:
name: transform
version: 0.1.0
authors:
- George Dietrich
crystal: ~> 1.4.0
license: MIT
targets:
transform:
main: src/transform_cli.cr
Подобно тому, как мы до сих пор взаимодействовали с нашими приложениями Crystal, используя двоичный файл Crystal, существует специальный двоичный файл для взаимодействия с Crystal Shards, метко названный Shards. Мы немного использовали это в начале проекта CLI для создания двоичного файла проекта, но он также может делать гораздо больше. Хотя команду сборки
shards build можно реплицировать с помощью нескольких команд crystal build, команда shards также предоставляет некоторые уникальные функции, в основном связанные с установкой, обновлением, сокращением или проверкой внешних зависимостей. Хотя файл shard.yml чаще всего создается как часть команды crystal init, которую мы использовали несколько глав назад, он также может быть создан с помощью команды shards init, которая будет формировать только этот файл, а не весь проект.
Говоря о зависимостях, проект может иметь два типа:
• Зависимости от среды выполнения
• Зависимости от разработки
Основными зависимостями будет все, что необходимо для запуска проекта в производственной среде. Однако зависимости разработки не требуются в производстве, но необходимы при разработке самого проекта. Хорошим примером могут быть любые дополнительные инструменты тестирования или статического анализа, используемые в проекте.
Оба этих типа зависимостей могут быть указаны в файле shard.yml с помощью сопоставлений
dependency и development_dependenties соответственно. Пример таких сопоставлений следующий:
dependencies:
shard1:
github: owner/shard1
version: ~> 1.1.0
shard2:
github: owner/shard2
commit: 6471b2b43ada4c41659ae8cfe1543929b3fdb64c
development_dependencies:
shard3:
github: dev-user/shard3
version: '>= 0.14.0'
В этом примере есть две основные зависимости и одна зависимость разработки. Ключи на карте представляют имя зависимости, а значение каждого ключа — это еще одно сопоставление, определяющее информацию о том, как ее разрешить. Чаще всего вы можете использовать один из вспомогательных ключей:
github, bitbucket или gitlab в форме владельца/репо в зависимости от того, где размещена зависимость. Дополнительные ключи для каждой зависимости можно использовать для выбора конкретной версии, диапазона версий, ветки или фиксации, которые следует установить. В дополнение к вспомогательным ключам URL-адрес репозитория может быть предоставлен для Git, Mercurial или Fossil с помощью ключей git, hg и fossil соответственно. Ключ пути также можно использовать для загрузки зависимости по определенному пути к файлу, но его нельзя использовать с другими параметрами, включая версию, ветку или фиксацию.
Настоятельно рекомендуется указывать версии ваших зависимостей. Если вы этого не сделаете, то по умолчанию будет использоваться последняя версия, которая может незаметно вывести из строя ваше приложение, если вы позднее обновитесь до версии, включающей критические изменения. Использование оператора
~> может быть полезно в этом отношении, чтобы разрешить обновления, но не предыдущие определенные второстепенные или основные версии. В этом примере ~> 1.1.0 будет эквивалентно >= 1.1.0 и < 1.2, а ~> 1.2 будет эквивалентно >= 1.2 и < 2.
Однако в некоторых случаях вы можете захотеть использовать изменение, которое еще не выпущено. Чтобы справиться с этим, вы также можете прикрепить зависимость к определенной ветке или коммиту. В зависимости от конкретного контекста обычно предпочтительнее фиксация, чтобы предотвратить внесение неожиданных изменений в последующие обновления.
Как только вы обновите файл shard.yml со всеми зависимостями, которые потребуются вашему проекту, вы можете продолжить и установить их с помощью команды установки
shards. Это позволит определить версию каждой зависимости и установить их в папку lib/. Отсюда вы можете запросить код, выполнив require “shard1” или любое другое имя осколка в вашем проекте.
Возможно, вы заметили, что Crystal может найти осколок в папке lib/, хотя обычно это приводит к ошибке, поскольку его нигде нет в src/. Причина, по которой это работает, связана с переменной среды
CRYSTAL_PATH. Эта переменная определяет местоположение(я), в которых Crystal будет искать необходимые файлы за пределами текущей папки. Например, для меня запуск crystal env CRYSTAL_PATH выводит lib:/usr/lib/crystal. Здесь мы видим, что сначала он пробует папку lib/, а затем стандартную библиотеку Crystal, используя стандартные правила поиска в каждом месте.
В процессе установки также будет создан еще один файл с именем
shard.lock. Цель этого файла — обеспечить воспроизводимые сборки путем блокировки версий каждой установленной зависимости, чтобы будущие вызовы shards install приводили к установке тех же версий. Это в первую очередь предназначено для конечных приложений, а не для библиотек, поскольку зависимости библиотеки также будут заблокированы в файле блокировки приложения. Файл блокировки по умолчанию игнорируется системами контроля версий для библиотек, например, при создании нового проекта через crystal init lib lib_name.
Опцию
--frozen также можно передать в программу установки shards, что заставит ее установить только то, что находится в файле shard.lock, и выдаст ошибку, если оно не существует. По умолчанию при запуске shards install также будут установлены зависимости разработки. Опцию --without-development можно использовать только для установки основных зависимостей. Опцию --production также можно использовать для объединения этих двух вариантов поведения.
Хотя большинство зависимостей предоставляют только тот код, который может потребоваться, некоторые могут также собрать и предоставить двоичный файл в папке bin/ вашего проекта. Такое поведение можно включить для библиотеки, добавив в ее сегмент что-то похожее на shard.yml файл:
scripts:
postinstall: shards build
executables:
- name_of_binary
Хук
postinstall представляет собой команду, которая будет вызвана после установки осколка. Чаще всего это просто shards build, но мы также можем вызвать Makefile для более сложных сборок. Однако при использовании перехватчиков postinstall и особенно файлов Makefile необходимо помнить о совместимости. Например, если перехватчик запущен на машине без make или одного из требований сборки, вся команда shards build завершится неудачно.
Затем массив исполняемых файлов представляет, какие из собранных двоичных файлов следует скопировать в проект установки, имена которых соответствуют именам локально созданных двоичных файлов. Параметры
--skip-postinstall и --skip-executables, которые можно передать при установке шардов, также существуют, если вы не хотите выполнять один или оба этих шага.
Далее давайте выясним, почему необходимо проявлять особую осторожность, когда проект зависит от кода C.
До сих пор предполагалось, что устанавливаемые Шарды представляют собой чистые реализации Crystal. Однако, как мы узнали ранее в Главе 7 «Взаимодействие C», Crystal может связываться с существующими библиотеками C и использовать их. Шарды не поддерживают установку библиотек C, необходимых для привязок Crystal. Пользователь, использующий Shard, может установить их, например, через менеджер пакетов своей системы.
Хотя Shards не обеспечивает их установку за вас, он поддерживает ключ информационных библиотек в shard.yml. Пример этого выглядит следующим образом:
libraries:
libQt5Gui:
libQt5Help: "~> 5.7" libQtBus: ">= 4.8"
Глядя на это, кто-то, пытающийся использовать Shard, может узнать, какие библиотеки необходимо установить, основываясь на библиотеках C, на которые ссылается Shard. Еще раз: это чисто информационный характер, но вам все равно рекомендуется включить его, если ваш шард привязан к каким-либо библиотекам C.
В большинстве проектов установленные зависимости, скорее всего, со временем устареют, в результате чего приложение потеряет потенциально важные исправления ошибок или новые функции. Давайте посмотрим, как обновить Shards дальше.
Программное обеспечение постоянно развивается и меняется. По этой причине библиотеки часто выпускают новые версии кода, включающие новые функции, улучшения и исправления ошибок. Хотя может возникнуть соблазн слепо обновить ваши зависимости до последних версий при каждом выпуске новой версии, необходимо соблюдать некоторую осторожность. Новые версии библиотеки могут быть несовместимы с предыдущими версиями, что может привести к поломке вашего приложения.
Всем шардам предлагается подписаться на https://semver.org. Следуя этому стандарту, мы позволяем оператору
~> работать, поскольку можно предположить, что в минорную версию или исправленную версию не будут внесены никакие критические изменения. Или, если да, то выйдет еще один патч, исправляющий регрессию.
Если вы не версионировали свои зависимости, а следующий выпуск зависимости является серьезным ударом, то вам придется либо вернуться к предыдущей версии, либо приступить к работе по обеспечению совместимости вашего приложения с новой версией зависимости. Именно по этой причине я снова настоятельно рекомендую правильно версионировать ваши зависимости, а также следить за обновлениями и читать журналы изменений для ваших зависимостей, чтобы вы знали, чего ожидать при их обновлении.
Предполагая, что вы это сделали и ваши зависимости имеют версии, вы можете обновить их, выполнив команду
shards update. Это позволит разрешить и установить последние версии ваших зависимостей в соответствии с вашими требованиями. Он также обновит файл shard.lock новыми версиями.
В некоторых случаях вы можете просто захотеть убедиться, что все необходимые зависимости установлены, не устанавливая ничего нового. В этом случае можно использовать команду проверки осколков. Он установит ненулевой код выхода, если все зависимости не установлены, а также выведет на терминал некоторую текстовую информацию. Аналогично, команду
shards outdated можно использовать для проверки актуальности ваших зависимостей в соответствии с вашими требованиями.
Команду
shards prune также можно использовать для удаления неиспользуемых зависимостей из папки lib/. Осколок считается неиспользованным, если он больше не присутствует в файле shard.lock.
Возвращаясь к предыдущему разделу этой главы, как определить, какие осколки доступны для установки в первую очередь? Именно эту тему мы собираемся рассмотреть в следующем разделе. Давайте начнем.
В отличие от некоторых менеджеров зависимостей на других языках, у Shards нет централизованного репозитория, из которого их можно установить. Вместо этого шарды устанавливаются из соответствующего исходного источника напрямую путем проверки проекта Git или создания символической ссылки, если используется опция
path.
Поскольку нет центрального репозитория с обычными функциями поиска и обнаружения, найти осколки может быть немного сложнее. К счастью, существуют различные веб-сайты, которые либо автоматически собирают с хостингов шарды, либо курируются вручную.
Как и в любой библиотеке, независимо от языка, некоторые библиотеки могут быть заброшены, забыты или стать неактивными. По этой причине стоит потратить некоторое время на изучение всех доступных осколков, чтобы определить, какой из них будет лучшим вариантом, а не просто найти один и предположить, что он сработает.
Ниже приведены некоторые из наиболее популярных и полезных ресурсов для поиска осколков:
• Awesome Crystal: https://github.com/veelenga/awesome-crystal — это реализация https://github.com/sindresorhus/awesome/blob/main/awesome.md для Crystal. Это составленный вручную список осколков кристаллов и других связанных ресурсов в различных категориях. Это хороший ресурс, поскольку он включает в себя различные популярные шарды в экосистеме.
• Shardbox: https://shardbox.org/ — это база данных осколков, созданная вручную, которая немного более сложна, чем Awesome Crystal. Он включает в себя функции поиска и тегирования, информацию о зависимостях и метрики для всех осколков в его базе данных.
• Shards.info: в отличие от двух предыдущих ресурсов, https://shards.info/ — это автоматизированный ресурс, который периодически очищает репозитории из GitHub и GitLab, ориентируясь на репозитории, которые были активны в течение последнего года и чей язык это Кристалл. Это полезный ресурс для поиска новых осколков, но вы также можете столкнуться с некоторыми, которые еще не готовы к производству.
Если вы ищете что-то конкретное, вы сможете найти это, используя один из этих ресурсов. Однако, если вы не можете найти осколок, соответствующий вашим целям, другой вариант — обратиться к сообществу: https://crystal-lang.org/community/#chat. Спросить тех, кто знаком с языком, обычно является отличным источником информации.
Crystal является относительно новым по сравнению с другими языками, такими как Ruby или Python. Из-за этого экосистема Crystal не такая большая, что может привести к тому, что нужный вам осколок устареет или вообще отсутствует. В этом случае либо возрождение старого шарда, либо внедрение собственной версии с открытым исходным кодом может помочь экосистеме расти и позволить другим повторно использовать код.
Теперь, когда мы довольно хорошо понимаем, как использовать и находить осколки, давайте потратим немного времени и рассмотрим более реальный пример. Допустим, вы разрабатываете приложение и хотите использовать TOML как средство его настройки. Вы просматриваете документацию по API Crystal и видите, что она не включает модуль для обработки анализа TOML. Из-за этого вам придется либо написать свою собственную реализацию, либо установить чью-либо реализацию в качестве шарда.
Вы начинаете просматривать список Awesome Crystal и замечаете, что в категории «Форматы данных» есть осколок toml.cr. Однако, прочитав файл readme, вы решаете, что он не будет работать, поскольку вам требуется поддержка TOML 1.0.0, а Shard предназначен для версии 0.4.0. Чтобы получить больший выбор осколков, вы решаете перейти на shard.info.
При поиске TOML вы находите toml.cr, который предоставляет привязки C к библиотеке синтаксического анализа TOML, совместимой с TOML 1.0.0, и решаете использовать эту. Просматривая выпуски на GitHub, вы замечаете, что Shard еще не имеет версии
1.0.0, а последняя версия — 0.2.0. Чтобы не допустить, чтобы критические изменения вызывали проблемы из-за непреднамеренных обновлений, вы решаете установить версию ~> 0.2.0, чтобы она допускала версию 0.2.x, но не 0.3.x. В конечном итоге вы добавляете в свой файл shard.yml следующее:
dependencies:
ctoml-cr:
github: syeopite/ctoml-cr
version: ~> 0.2.0
Отсюда вы можете запустить
shards install, затем запросить шард с помощью команды require “toml-cr" и сразу вернуться к коду вашего собственного проекта.
Как мы видели здесь, шарды могут быть важной частью поддержания эффективности разработки, когда дело доходит до написания программы. Вместо того, чтобы тратить время, которое потребовалось бы для реализации синтаксического анализа TOML, вы можете легко использовать надежную существующую реализацию и вместо этого потратить это время на работу над собственной программой. Однако, как мы видели в этом примере и упоминали ранее, при выборе осколков необходимо проявлять некоторую осторожность. Не все из них равны, будь то с точки зрения их статуса разработки/зрелости, базовой зависимости, от которой они запрограммированы, или функций, которые они предоставляют. Потратьте некоторое время и проведите исследование, чтобы выяснить, какой Shard будет соответствовать вашим требованиям.
Знание того, как устанавливать внешние библиотеки и управлять ими, является невероятно полезным инструментом при разработке любого приложения, над которым вы, возможно, будете работать в будущем. Обнаружение существующего шарда может значительно ускорить время разработки ваших проектов, устраняя необходимость самостоятельной реализации этого кода. Это также облегчит поддержку вашего проекта, поскольку вам не придется поддерживать код самостоятельно. Обязательно следите за списками и базами данных, о которых мы говорили, для осколков, которые могут быть полезны в ваших проектах!
В следующей главе мы собираемся использовать некоторые внешние библиотеки для создания веб-приложения с использованием Athena.
Сходство Crystal с Ruby сделало его весьма популярным как веб-язык в надежде побудить некоторых пользователей Ruby on Rails, а также других фреймворков, перейти на Crystal. Crystal может похвастаться довольно большим количеством популярных фреймворков: от простых маршрутизаторов до полнофункционального стека и всего, что между ними. В этой главе мы рассмотрим, как создать приложение с использованием одной из этих платформ в экосистеме Crystal под названием Athena Framework. Хотя мы будем активно использовать эту структуру, мы также рассмотрим более общие темы, которые можно использовать независимо от того, какую структуру вы в конечном итоге выберете. К концу главы мы рассмотрим следующие темы:
• Понимание архитектуры Athena.
• Начало работы с Athena
• Реализация взаимодействия с базой данных.
• Использование согласования содержания
Требования к этой главе следующие:
• Рабочая установка Crystal.
• Возможность запуска сервера PostgreSQL, например, через Docker.
• Способ отправки HTTP-запросов, например cURL или Postman.
• Установленная и работающая версия https://www.pcre.org/ (libpcre2).
Инструкции по настройке Crystal можно найти в Главе 1 «Введение в Crystal». Есть несколько способов запустить сервер, но я буду использовать Docker Compose и включу используемый мной файл в папку главы.
Все примеры кода, использованные в этой главе, можно найти на GitHub: https://github.com/PacktPublishing/Crystal-Programming/tree/main/ Chapter09.
В отличие от других платформ Crystal, Athena Framework в первую очередь черпает вдохновение из не-Ruby-фреймворков, таких как Symfony PHP или Spring Java. Из-за этого он обладает некоторыми уникальными функциями/концепциями, которых нет больше нигде в экосистеме. Со временем он постоянно совершенствовался и имеет прочную основу для поддержки будущих функций/концепций.
Athena Framework — это результат интеграции различных компонентов более крупной экосистемы Athena в единую связную структуру. Каждый компонент предоставляет различные функции платформы, такие как сериализация, проверка, обработка событий и т. д. Эти компоненты также можно использовать независимо, например, если вы хотите использовать их функции в другой платформе или даже использовать их для создания своей собственной платформы. Однако их использование в Athena Framework обеспечивает наилучшие возможности/интеграцию. Некоторые из основных моментов включают следующее:
• На основе аннотаций
• Соблюдает принципы проектирования SOLID:
• S – принцип единой ответственности
• O – принцип открыт-закрыт.
• L - принцип замены Лискова
• I – принцип разделения интерфейса.
• D – принцип инверсии зависимостей
• На основе событий
• Гибкая основа
Аннотации являются основной частью Athena, поскольку они, помимо прочего, являются основным способом определения и настройки маршрутов. Например, они используются для указания того, какой HTTP-метод и путь обрабатывает действие контроллера, какие параметры запроса следует читать и любую пользовательскую логику, которую вы хотите, с помощью определяемых пользователем аннотаций. При таком подходе вся логика, связанная с действием, централизована в самом действии, а не в одном файле, а логика маршрутизации — в другом. Хотя Athena широко использует аннотации, мы не собираемся углубляться в них, поскольку они будут рассмотрены более подробно в Главе 11 «Введение в аннотации».
Поскольку Crystal является объектно-ориентированным (ОО) языком, Athena рекомендует следовать лучшим практикам объектно-ориентированного программирования, таким как SOLID. Эти принципы, особенно принцип инверсии зависимостей, весьма полезны при разработке приложения, которое легко поддерживать, тестировать и настраивать за счет интеграции сервисного контейнера внедрения внешних зависимостей (DI). Каждый запрос имеет собственный контейнер со своим набором сервисов, что позволяет обмениваться состоянием, не беспокоясь о потере состояния между запросами. Использование контейнера службы DI за пределами самой Athena возможно при использовании этого компонента отдельно, однако то, как лучше всего реализовать/использовать его в проекте, немного выходит за рамки этой главы.
Athena — это платформа, основанная на событиях. Вместо использования цепочки
HTTP::Handler в течение жизненного цикла запроса создаются различные события. Эти события и связанные с ними прослушиватели используются для реализации самой платформы, но пользовательские прослушиватели также могут использовать те же события. В конечном итоге это приводит к очень гибкой основе. Поток запроса изображен на следующем рисунке:
Рисунок 9.1 - Схема жизненного цикла запроса
Прослушиватели этих событий можно использовать для чего угодно: от обработки CORS, возврата ответов об ошибках, преобразования объектов в ответ посредством согласования содержимого или чего-либо еще, что может понадобиться вашему приложению. Пользовательские события также могут быть зарегистрированы. См. https://athenaframework.org/comComponents/ для более подробного изучения каждого события и того, как они используются.
Хотя это может показаться очевидным, важно отметить, что Athena Framework — это платформа. Другими словами, его основная цель — предоставить вам строительные блоки, используемые для создания вашего приложения. Фреймворк также использует эти строительные блоки внутри себя для построения основной логики фреймворка. Athena старается быть максимально гибкой, позволяя вам использовать только те функции/компоненты, которые вам нужны. Это позволяет вашему приложению быть настолько простым или сложным, насколько это необходимо.
У Athena также есть несколько других компонентов, которые выходят за рамки этой главы, чтобы их более подробно изучить. К ним относятся следующие, ссылки на которые приведены в разделе «Дополнительная литература» в конце главы:
• EventDispatcher — обеспечивает работу прослушивателей и основанную на событиях природу Athena.
• Console — позволяет создавать команды на основе CLI, аналогичные задачам rake.
• Routing. Эффективная и надежная маршрутизация HTTP.
Кроме того, посетите https://athenaframework.org/, чтобы узнать больше о платформе и ее функциях. Не стесняйтесь зайти на сервер Athena Discord, чтобы задать любые вопросы, сообщить о любых проблемах или обсудить возможные улучшения платформы.
Но хватит разговоров. Давайте приступим к написанию кода и посмотрим, как все происходит на практике. В этой главе мы рассмотрим создание простого приложения для блога.
Подобно тому, что мы делали при создании нашего приложения CLI в Главе 4 «Изучение Crystal посредством написания интерфейса командной строки», мы собираемся использовать команду
crystal init для формирования каркаса нашего приложения. Однако, в отличие от прошлого раза, когда мы создавали библиотеку, мы собираемся инициализировать приложение. Основная причина этого в том, что мы также получаем файл shard.lock, позволяющий воспроизводить установку, как мы узнали в предыдущей главе. Полная команда в конечном итоге будет выглядеть как блог приложения crystal init.
Теперь, когда наше приложение создано, мы можем добавить Athena в качестве зависимости, добавив в файл shard.yml следующее, обязательно после этого запустив
shards install:
dependencies:
athena:
github: athena-framework/framework
version: ~> 0.16.0
И это все, что нужно для установки Athena. Он спроектирован так, чтобы быть ненавязчивым, поскольку не требует каких-либо внешних зависимостей за пределами Shards, Crystal и их необходимых системных библиотек для установки и запуска. Также нет необходимости в структурах каталогов или файлах, которые в конечном итоге сокращают количество шаблонов до тех, которые необходимы в зависимости от ваших требований.
С другой стороны, это означает, что нам нужно будет определить, как мы хотим организовать код нашего приложения. Для целей этой главы мы собираемся использовать простую группировку папок, например, все контроллеры находятся в одной папке, все шаблоны HTML — в другой и так далее. Для более крупных приложений может иметь смысл иметь папки для каждой функции приложения в src/, а затем группировать их по типу каждого файла. Таким образом, типы более тесно связаны с функциями, которые их используют.
Поскольку наше приложение основано на создании статей в блоге, давайте начнем с возможности создания новой статьи. После этого мы могли бы выполнить итерацию, чтобы сохранить ее в базе данных, обновить статью, удалить статью и получить все или определенные статьи. Однако прежде чем мы сможем создать конечную точку, нам нужно определить, что на самом деле представляет собой статья.
Следуя нашей организационной стратегии, давайте создадим новую папку и файл, скажем, src/entities/article.cr. Наша сущность статьи начнется как класс, определяющий свойства, которые мы хотим отслеживать. В следующем разделе мы рассмотрим, как повторно использовать сущность статьи для взаимодействия с базой данных. Это может выглядеть так:
class Blog::Entities::Article include JSON::Serializable
def initialize(@title : String, @body : String); end
getter! id : Int64
property title : String
property body : String
getter! updated_at : Time
getter! created_at : Time
getter deleted_at : Time?
end
Этот объект определяет некоторые основные точки данных, связанные со статьей, такие как ее идентификатор, заголовок и текст. Он также имеет некоторые метаданные, например, когда он был создан, обновлен и удален.
Мы используем версию макроса
getter для обработки идентификатора и создания/обновления свойств. Этот макрос создает переменную экземпляра, допускающую значение nilable, и два метода, которыми в случае нашего свойства ID будут #id и #id?. Первый повышается, если значение равно nil. Это хорошо работает для столбцов, которые на практике будут иметь значения большую часть времени, но не будут иметь их, пока они не будут сохранены в базе данных.
Поскольку наше приложение будет в первую очередь служить API, мы также включаем
JSON::Serializable для обработки (де)сериализации. Компонент сериализатора Athena имеет аналогичный модуль ASR::Serializable, который работает таким же образом, но с дополнительными функциями. На данный момент нам особо не нужны никакие дополнительные возможности. Мы всегда можем вернуться к нему, если возникнет необходимость. См. https://athenaframework.org/Serializer/ для получения дополнительной информации.
Теперь, когда у нас есть смоделированная сущность статьи, мы можем перейти к созданию конечной точки, которая будет обрабатывать ее создание на основе тела запроса. Как и в случае с типом статьи, давайте создадим наш контроллер в специальной папке, например src/controllers/article_controller.cr.
Athena — это платформа Model View Controller (MVC), в которой контроллер — это класс, который содержит один или несколько методов, которым сопоставлены маршруты. Например, добавьте следующий код в наш файл контроллера:
class Blog::Controllers::ArticleController < ATH::Controller
@[ARTA::Post("/article")]
def create_article : ATH::Response
ATH::Response.new(
Blog::Entities::Article.new("Title", "Body").to_json,
headers: HTTP::Headers{"content-type" =>
"application/ json"}
)
end
end
Здесь мы определяем наш класс контроллера, обязательно наследуя от
ATH::Controller. При желании можно использовать пользовательские классы абстрактных контроллеров, чтобы обеспечить общую вспомогательную логику для всех экземпляров контроллера. Затем мы определили метод экземпляра #create_article, который возвращает ATH::Response. К этому методу применена аннотация ARTA::Post, которая указывает, что эта конечная точка является конечной точкой POST, а также путь, по которому должно обрабатываться это действие контроллера. Что касается тела метода, мы создаем экземпляр и преобразуем жестко закодированный экземпляр нашего объекта статьи в JSON, чтобы использовать его в качестве тела нашего ответа. Мы также устанавливаем заголовок типа контента ответа. Отсюда давайте подключим все и убедимся, что все работает как положено.
Возвращаясь к первоначально созданному файлу src/blog.cr, замените все его текущее содержимое следующим:
require "json"
require "athena"
require "./controllers/*"
require "./entities/*"
module Blog
VERSION = "0.1.0"
module Controllers; end
module Entities; end
end
Здесь нам просто нужна Athena, модуль JSON Crystal, а также папки контроллера и сущностей. Мы также определили здесь пространства имен
Controllers и Entities, чтобы в будущем к ним можно было добавлять документацию.
Далее давайте создадим еще один файл, который будет служить точкой входа в наш блог, скажем, src/server.cr со следующим содержимым:
require "./blog"
ATH.run
Такой подход гарантирует, что сервер не запустится автоматически, если мы просто хотим запросить исходный код где-то еще, например, в нашем коде спецификации.
ATH.run по умолчанию запустит наш сервер Athena на порту 3000.
Теперь, когда сервер запущен, если бы мы выполнили следующий запрос, используя cURL, например,
curl --request POST 'http://localhost:3000/article', мы получили бы следующий ответ, как ожидал:
{
"title": "Title",
"body": "Body"
}
Однако, поскольку мы хотим, чтобы наш API возвращал JSON, есть более простой способ сделать это. Мы можем обновить действие нашего контроллера, чтобы напрямую возвращать экземпляр нашего объекта статьи. Афина позаботится о его преобразовании в JSON и настройке необходимых заголовков. Теперь метод выглядит так:
def create_article : Blog::Entities::Article
Blog::Entities::Article.new "Title", "Body"
end
Если вы отправите еще один запрос, вы увидите тот же ответ. Причина, по которой это работает, связана с Рис. 9.1, приведенным ранее в этой главе. Если действие контроллера возвращает
ATH::Response, этот ответ возвращается клиенту в том виде, в каком он есть. Если возвращается что-то еще, генерируется событие просмотра, задачей которого является преобразование возвращаемого значения в ATH::Response.
Athena также предоставляет некоторые более специализированные подклассы
ATH::Response. Например, ATH::RedirectResponse можно использовать для обработки перенаправлений, а ATH::StreamedResponse можно использовать для потоковой передачи данных клиенту посредством фрагментированного кодирования в тех случаях, когда в противном случае данные ответа были бы слишком большими, чтобы поместиться в памяти. Дополнительную информацию об этих подклассах см. в документации API: https://athenaframework.org/Framework/.
Предполагая, что наш API будет обслуживать отдельную базу кода внешнего интерфейса, нам нужно будет настроить CORS, чтобы внешний интерфейс мог получить доступ к данным. Athena поставляется в комплекте с прослушивателем, который его обрабатывает, и его нужно просто включить и настроить.
Чтобы все было организованно, давайте создадим новый файл src/config.cr и добавим следующий код, обязательно потребовав его и в src/blog.cr:
def ATH::Config::CORS.conРисунок : ATH::Config::CORS?
new(
allow_credentials: true,
allow_origin: ["*"],
)
end
В идеале значение источника должно быть фактическим доменом вашего приложения, например https://app.myblog.com. Однако в этой главе мы просто позволим все что угодно. Athena также поддерживает концепцию параметров, которые можно использовать для настройки независимо от окружающей среды. Дополнительную информацию см. на https://athenaframework.org/Components/config/.
Мы также используем не слишком широко известную функцию Crystal, чтобы сделать нашу логику настройки более краткой. Определению может быть присвоен префикс типа и точка перед именем метода в качестве ярлыка при определении метода класса для определенного типа. Например, предыдущий пример будет эквивалентен следующему:
struct ATH::Config::CORS
def self.conРисунок : ATH::Config::CORS?
new(
allow_credentials: true, allow_origin: ["*"],
)
end
end
Помимо того, что сокращенный синтаксис является более кратким, он устраняет необходимость выяснять, является ли тип структурой или классом. На этом этапе мы можем сделать запрос и получить обратно созданную статью, но, учитывая, что статья, возвращаемая из этой конечной точки, жестко запрограммирована, это бесполезно. Давайте проведем рефакторинг, чтобы мы могли создать статью на основе тела запроса.
Как мы видели ранее, поскольку мы включили
JSON::Serializable в нашу сущность, мы можем преобразовать его в представление JSON. Мы также можем сделать обратное: создать экземпляр на основе строки JSON или I/O. Для этого мы можем обновить действие нашего контроллера, обновив его так:
def create_article(request : ATH::Request) :
Blog::Entities::Article
if !(body = request.body) || body.peek.try &.empty?
raise ATH::Exceptions::BadRequest.new "Request does not have a body."
end
Blog::Entities::Article.from_json body
end
Параметры действия контроллера, например параметры пути маршрута или запроса, передаются действию в качестве аргументов метода. Например, если путь действия был
"/add/{val1}/{val2}", метод действия контроллера будет следующим: def add(val1 : Int32, val2 : Int32) : Int32, где разрешаются два добавляемых значения. из пути, преобразуются в ожидаемые типы и передаются методу. Аргументы действия также могут поступать из значений по умолчанию, аргументов типа ATH::Request или атрибутов запроса.
В этом примере мы используем типизированный параметр
ATH::Request для получения доступа к телу запроса и его десериализации. Также технически возможно, что запрос не имеет тела, поэтому мы проверяем его существование, прежде чем продолжить, возвращая ответ об ошибке, если оно равно nil или если тело запроса отсутствует. Мы также выполняем десериализацию непосредственно из I/O тела запроса, поэтому не нужно создавать промежуточную строку, что приводит к более эффективному использованию памяти.
Обработка ошибок в Athena очень похожа на любую другую программу Crystal, поскольку для представления ошибок она использует исключения. Athena определяет набор общих типов исключений в пространстве имен
ATH::Exceptions. Каждое из этих исключений наследуется от Athena::Exceptions::HTTPException, который представляет собой особый тип исключения, используемый для возврата ответов об ошибках HTTP. Например, если тела не было, оно будет возвращено клиенту с кодом состояния 400:
{
"code": 400,
"message": "Request does not have a body."
}
Базовый тип или дочерний тип также могут быть унаследованы для сбора дополнительных данных или добавления дополнительных функций. Любое возникающее исключение, не являющееся экземпляром
Athena::Exceptions::HTTPException, рассматривается как внутренняя ошибка сервера 500. По умолчанию эти ответы об ошибках сериализуются в формате JSON, однако это поведение можно настроить. См. https://athenaframework.org/Framework/ErrorRendererInterface/ для получения дополнительной информации.
Теперь, когда мы убедились, что есть тело, мы можем продолжить и создать экземпляр нашей статьи, вернув тело
Blog::Entities::Article.from_json. Если бы вы сделали тот же запрос, что и раньше, но с этой полезной нагрузкой, вы бы увидели, что все, что вы отправляете, вы получите обратно в ответ:
{
"title": "My Title",
"body": "My Body"
}
Соответствующая команда cURL будет выглядеть следующим образом:
curl --request POST 'http://localhost:3000/article' \
--header 'Content-Type: application/json' \
--data-raw '{
"title": "My Title",
"body": "My Body"
}'
Отлично! Но так же, как существовал лучший способ вернуть ответ, Athena предлагает довольно удобный способ упростить десериализацию тела ответа. У Athena есть уникальная концепция, называемая преобразователями параметров. Конвертеры параметров позволяют применять собственную логику для преобразования необработанных данных из запроса в более сложные типы. См. https://athenaframework. org/Framework/ParamConverter/ для получения дополнительной информации.
Примеры преобразователей параметров включают следующее:
• Преобразование строки даты и времени в экземпляр времени.
• Десериализация тела запроса в определенный тип.
• Преобразование параметра пути идентификатора пользователя в реальный экземпляр пользователя.
Athena предоставляет первые два в качестве встроенных преобразователей, но когда дело доходит до определения пользовательских конвертеров, нет предела. Давайте воспользуемся преобразователем параметров, чтобы упростить действие контроллера создания статьи. Обновите метод следующим образом:
@[ARTA::Post("/article")]
@[ATHA::ParamConverter("article", converter:
ATH::RequestBodyConverter)]
def create_article(article : Blog::Entities::Article) :
Blog::Entities::Article
article
end
Нам удалось сжать действие контроллера в одну строку! Основным нововведением здесь является аннотация
ATHA::ParamConverter, а также обновление метода для приема экземпляра статьи вместо запроса. Первый позиционный аргумент в аннотации представляет, какой параметр действия контроллера будет обрабатывать преобразователь параметров. Для преобразования нескольких параметров аргументов действия можно применять несколько аннотаций преобразователя параметров. Мы также указываем, что он должен использовать ATH::RequestBodyConverter, который фактически десериализует тело запроса.
Преобразователь определяет тип, в который он должен десериализоваться, на основе ограничения типа соответствующего параметра метода. Если этот тип не включает
JSON::Serializable или ASR::Serializable, выдается ошибка времени компиляции. Мы можем подтвердить, что все еще работает, сделав еще один запрос, подобный предыдущему, и утверждая, что мы получили тот же ответ, что и раньше.
Однако есть проблема с этой реализацией. Наш API в настоящее время с радостью принимает пустые значения как для свойств заголовка, так и для тела. Вероятно, нам следует предотвратить это, проверив тело запроса, чтобы мы могли быть уверены в его корректности к тому моменту, когда оно дойдет до действия контроллера. К счастью для нас, мы можем использовать компонент Validator Athena.
Компонент Athena Validator — это надежная и гибкая среда для проверки как объектов, так и значений. Его основной API предполагает применение аннотаций, представляющих ограничения, которые вы хотите проверить. Экземпляр этого объекта затем может быть проверен с помощью экземпляра валидатора, который вернет, возможно, пустой список нарушений. У компонента слишком много функций, чтобы их можно было охватить в этой главе, поэтому мы сосредоточимся на том, что необходимо для проверки наших статей. См. https://athenaframework.org/Validator/ для получения дополнительной информации.
Что касается наших статей, то главное, чего мы хотим избежать, — это пустые значения. Мы также можем ввести требования к минимальной и максимальной длине, гарантируя, что они не содержат определенных слов или фраз или чего-либо еще, что вы захотите сделать. В любом случае, первое, что нужно сделать, — это включить
AVD::Validatable в наш тип Article. Отсюда мы можем затем применить ограничение NotBlank к заголовку и телу, добавив аннотацию @[Assert::NotBlank], например:
@[Assert::NotBlank]
property title : String
@[Assert::NotBlank]
property body : String
Если вы попытаетесь использовать пустые значения
POST, будет возвращен ответ об ошибке 422, в котором будут указаны нарушения и свойство, к которому они относятся. UUID кода ошибки — это машиночитаемое представление конкретного нарушения, которое можно использовать для проверки определенных ошибок без необходимости анализа сообщения, которое можно настроить, например:
{
"code": 422,
"message": "Validation failed",
"errors": [
{
"property": "body",
"message": "This value should not be blank.",
"code": "0d0c3254-3642-4cb0-9882-46ee5918e6e3"
}
]
}
Это работает «из коробки», поскольку
ATH::RequestBodyConverter проверит, является ли десериализованный объект проверяемым после его десериализации, и проверит его, если это так. Компонент валидатора имеет множество ограничений, но также можно определить собственные. См. https://athenaframework.org/Validator/Constraints/ и https://athenaframework.org/comComponents/validator/#custom-constraints для получения дополнительной информации соответственно.
Следующим в списке вопросов, на которые следует обратить внимание, является то, что в настоящее время наша конечная точка для создания статьи по сути просто возвращает то, что ей было предоставлено. Чтобы можно было просмотреть все статьи, нам нужно настроить возможность сохранения их в базе данных.
Любому приложению, которому необходимо сохранять данные, чтобы их можно было получить позже, необходима база данных той или иной формы. Наш блог ничем не отличается, поскольку нам понадобится способ хранения статей, составляющих блог. Существуют различные типы баз данных, такие как NoSQL или реляционные и другие, каждая из которых имеет свои плюсы и минусы. В нашем блоге мы собираемся упростить задачу и использовать реляционную базу данных, такую как MySQL или PostgreSQL. Не стесняйтесь использовать базу данных по вашему выбору, которая лучше всего соответствует потребностям вашего приложения, но для целей этой главы я буду использовать PostgreSQL.
Crystal предоставляет сегмент абстракции базы данных https://github.com/crystallang/crystal-db, который определяет высокоуровневый API для взаимодействия с базой данных. Каждая реализация базы данных использует это в качестве основы и реализует способ получения данных из базового хранилища. Это обеспечивает унифицированный API и общие функции, которые могут использовать все реализации баз данных. В нашем случае мы можем использовать https://github.com/will/crystal-pg для взаимодействия с нашей базой данных PG.
Давайте начнем с добавления этой зависимости в раздел зависимостей shard.yml, который теперь должен выглядеть следующим образом:
dependencies:
athena:
github: athena-framework/framework
version: ~> 0.16.0
pg:
github: will/crystal-pg
version: ~> 0.26.0
Обязательно запустите
shards install еще раз и добавьте require "pg" в src/blog.cr. При этом будет установлен сегмент абстракции базы данных Crystal вместе с драйвером для Postgres. Crystal также имеет несколько ORM, которые можно использовать для простого взаимодействия с базой данных. Однако для наших целей я собираюсь просто использовать абстракции базы данных по умолчанию, чтобы упростить задачу. ORM, по сути, являются обертками того, что предоставляется драйвером, поэтому полезно иметь представление о том, как они работают под капотом.
Базовый сегмент абстракции предоставляет модуль
DB::Serializable, который мы можем использовать это, чтобы немного облегчить себе жизнь. Этот модуль работает аналогично JSON::Serializable, но для запросов к базе данных, что позволяет нам создавать экземпляр нашего типа из сделанного нами запроса. Стоит отметить, что этот модуль не сохраняет экземпляр в базу данных, а только читает из нее. Поэтому нам придется справиться с этим самостоятельно или, возможно, даже реализовать некоторые из наших собственных абстракций.
Прежде чем мы приступим к настройке регистрации пользователей, нам необходимо настроить базу данных. Есть несколько способов сделать это, но самый простой, который я нашел, — это использовать docker-compose, который позволит нам развернуть сервер Postgres, которым будет легко управлять, и при необходимости его можно будет отключить. Файл compose, который я использую, выглядит следующим образом:
version: '3.8'
services:
pg:
image: postgres:14-alpine
container_name: pg
ports:
- "5432:5432"
environment:
- POSTGRES_USER=blog_user
- POSTGRES_PASSWORD=mYAw3s0meB!log
volumes:
- pg-data:/var/lib/postgresql/data
- ./db:/migrations
volumes:
pg-data:
Хотя я не буду вдаваться в подробности, суть в том, что мы определяем контейнер pg, который будет использовать Postgres 14, доступный через порт по умолчанию, используя переменные среды для настройки пользователя и базы данных. и, наконец, создание тома, который позволит данным сохраняться между его запуском и выключением. Мы также добавляем папку db/ в качестве тома. Это сделано для того, чтобы у нас был доступ к нашим файлам миграции внутри контейнера — подробнее об этом позже. Эту папку следует создать перед первым запуском сервера, что можно сделать через
mkdir db или любой другой файловый менеджер, который вы используете. Запуск docker-compose up запустит сервер. Опцию -d можно использовать, если вы хотите запустить ее в фоновом режиме.
Теперь, когда ваша база данных работает, нам нужно настроить параметры базы данных, а также создать схему для нашей таблицы статей. Существует несколько сегментов для управления миграциями, однако я собираюсь просто сохранить и запустить SQL вручную. Если в вашем проекте будет больше нескольких таблиц, использование инструмента миграции может быть очень полезным, особенно для проектов, которые вы планируете сохранить в течение некоторого времени. Давайте создадим новую папку db/ для хранения наших файлов миграции, создав db/000_setup.sql со следующим содержимым:
CREATE SCHEMA IF NOT EXISTS "test" AUTHORIZATION "blog_user";
Технически нам это пока не нужно, однако это понадобится позже, в Главе 14 «Тестирование». Далее давайте создадим db/001_users.sql со следующим содержимым:
CREATE TABLE IF NOT EXISTS "articles"
(
"id" BIGINT GENERATED ALWAYS AS IDENTITY NOT NULL
PRIMARY KEY,
"title" TEXT NOT NULL,
"body" TEXT NOT NULL,
"created_at" TIMESTAMP NOT NULL,
"updated_at" TIMESTAMP NOT NULL,
"deleted_at" TIMESTAMP NULL
);
Мы просто храним некоторые стандартные значения вместе с временными метками и целочисленным первичным ключом с автоинкрементом.
Поскольку наш сервер Postgres работает внутри контейнера Docker, нам нужно использовать команду docker для запуска файлов миграции из контейнера:
docker exec -it pg psql blog_user -d postgres -f /migrations/ 000_setup.sql
docker exec -it pg psql blog_user -d postgres -f /migrations /001_articles.sql
Продолжая с того места, на котором мы остановились в предыдущем разделе, мы работали над сохранением наших статей в базе данных.
Первое, что нам нужно сделать, это включить модуль
DB::Serializable в нашу сущность Article. Как упоминалось ранее, этот модуль позволяет нам создать его экземпляр из DB::ResultSet, который представляет собой результат запроса, сделанного к базе данных.
Поскольку у нас есть несколько вещей, которые должны произойти, прежде чем статья будет фактически сохранена, давайте продолжим и создадим несколько абстракций для решения этой проблемы. Конечно, если бы мы использовали ORM, у нас были бы встроенные способы сделать это, но будет полезно увидеть, как это можно сделать довольно легко, а также это станет хорошим переходом к другой функции Athena — DI.
Учитывая, что все, что нам нужно, это запустить некоторую логику перед сохранением чего-либо, мы можем просто создать метод с именем
#before_save, который мы можем вызывать. Как вы уже догадались — перед тем, как мы сохраним объект в базу данных. В конечном итоге это будет выглядеть так:
protected def before_save : Nil
if @id.nil?
@created_at = Time.utc
end
@updated_at = Time.utc
end
Я сделал метод защищенным, поскольку он более внутренний и не является частью общедоступного API. В случае новой записи, когда идентификатора еще нет, мы устанавливаем созданную временную метку. Свойство
update_at обновляется при каждом сохранении, учитывая, что именно для этого и предназначена эта временная метка.
В некоторых Crystal ORM, а также в Ruby
ActiveRecord обычно имеется метод #save непосредственно на объекте, который обрабатывает его сохранение в базе данных. Лично я не являюсь поклонником этого подхода, поскольку считаю, что он нарушает принцип единой ответственности SOLID, поскольку он обрабатывает как моделирование того, что представляет собой статья, так и сохранение ее в базе данных. Вместо этого подхода мы собираемся создать другой тип, который будет обеспечивать сохранение экземпляров DB::Serializable.
Этот тип будет простым, но определенно может быть намного более сложным, поскольку чем больше абстракций вы добавляете, тем больше вы, по сути, создаете свой собственный ORM. Эти дополнительные абстракции не потребуются для нашего блога об одной сущности/таблице, но могут быть очень полезны для более крупных приложений. Однако в этот момент, возможно, стоит рассмотреть возможность использования ORM. В конце концов, все зависит от вашего конкретного контекста, поэтому делайте то, что имеет наибольший смысл.
Суть этого нового типа будет заключаться в предоставлении метода
#persist, который принимает экземпляр DB::Serializable. Затем он вызовет метод #before_save, если он определен, и, наконец, вызовет метод #save, где будет внутренняя перегрузка для нашей сущности статьи. Таким образом, все будут счастливы, и мы придерживаемся наших SOLID принципов. Давайте создадим этот тип как src/services/entity_manager.cr. Обязательно добавьте require “./services/*" в src/blog.cr. Реализация этого будет выглядеть так:
@[ADI::Register]
class Blog::Services::EntityManager
@@connection : DB::Database = DB.open ENV["DATABASE_URL"]
def persist(entity : DB::Serializable) : Nil
entity.before_save if entity.responds_to? :before_save
entity.after_save self.save entity
end
private def save(entity : Blog::Entities::Article) : Int64
@@database.scalar(
%(INSERT INTO "articles" ("title", "body", "created_at",
"updated_at", "deleted_at") VALUES ($1, $2, $3, $4, $5)
RETURNING "id";),
entity.title,
entity.body,
entity.created_at,
entity.updated_at,
entity.deleted_at,
).as Int64
end
end
Чтобы упростить запуск нашего кода на разных машинах, мы собираемся использовать переменную среды для URL-адреса соединения. Назовем это DATABASE_URL. Мы можем экспортировать это с помощью следующего:
export DATABASE_URL=postgres://blog_user:mYAw3s0meB\
!log@localhost:5432/postgres?currentSchema=public
Поскольку объекту не известен автоматически сгенерированный идентификатор из базы данных, нам нужен способ установить это значение. Метод
#save возвращает идентификатор, чтобы мы могли применить его к объекту после сохранения с помощью другого внутреннего метода, называемого #after_save. Этот метод принимает идентификатор сохраняемого объекта и устанавливает его в экземпляре. Реализация этого метода по сути заключается в следующем:
protected def after_save(@id : Int64) : Nil
end
Если бы мы имели дело с большим количеством сущностей, мы, конечно, могли бы создать еще один модуль, включающий
DB::Serializable, и добавить некоторые из этих дополнительных вспомогательных методов, но, поскольку у нас есть только один, это не дает особой пользы.
Наконец, что наиболее важно, мы используем аннотацию
ADI::Register в самом классе. Как упоминалось в первом разделе, Athena активно использует DI через контейнер сервисов, который уникален для каждого запроса, то есть сервисы внутри него уникальны для каждого запроса. Это предотвращает утечку состояния внутри ваших сервисов между запросами, что может произойти, если вы используете такие вещи, как переменные класса. Однако это не означает, что использование переменной класса всегда плохо. Все зависит от контекста. Например, наш менеджер сущностей использует его для хранения ссылки на DB::Database. В данном случае это нормально, поскольку оно остается закрытым внутри нашего класса и представляет собой пул соединений. Благодаря этому каждый запрос может при необходимости получить собственное соединение с базой данных. Мы также не храним в нем какое-либо состояние, специфичное для запроса, поэтому оно остается чистым.
Аннотация
ADI::Register сообщает контейнеру службы, что этот тип следует рассматривать как службу, чтобы его можно было внедрить в другие службы. Функции DI Athena невероятно мощны, и я настоятельно рекомендую прочитать более подробный список их возможностей.
В нашем контексте на практике это означает, что мы можем заставить логику DI Athena внедрять экземпляр этого типа везде, где нам может понадобиться сохранить объект, например контроллер или другой сервис. Основное преимущество этого заключается в том, что это упрощает тестирование типов, которые его используют, поскольку мы можем внедрить макетную реализацию в наши модульные тесты, чтобы гарантировать, что мы не тестируем слишком много. Это также помогает обеспечить централизацию и возможность повторного использования кода.
Теперь, когда у нас есть все необходимые условия, мы можем, наконец, настроить постоянство статей, причем первым шагом будет предоставление нашему менеджеру объектов доступа к
ArticleController. Для этого мы можем сделать контроллер службой и определить инициализатор, который создаст переменную экземпляра типа Blog::Services::EntityManager, например:
@[ADI::Register(public: true)]
class Blog::Controllers::ArticleController < ATH::Controller
def initialize(@entity_manager : Blog::Services::
EntityManager);
end
# ...
end
По причинам реализации служба должна быть общедоступной, следовательно, поле
public: true в аннотации. Разрешено извлекать общедоступную службу непосредственно по типу или имени из контейнера, а не только через конструктор DI.. Это может измениться в будущем. Как только мы это сделаем, мы сможем ссылаться на нашего менеджера сущностей, как и на любую другую переменную экземпляра.
На данный момент нам действительно нужно добавить только одну строку, чтобы сохранить наши статьи. Метод
#create_article теперь должен выглядеть так:
def create_article(article : Blog::Entities::Article) :
Blog::Entities::Article
@entity_manager.persist article
article
end
Хотя действие контроллера выглядит простым, под капотом происходит немалое:
• Преобразователь тела запроса будет обрабатывать десериализацию и выполнять проверки.
• Менеджер объектов сохраняет десериализованный объект.
• Сущность можно просто вернуть напрямую, поскольку для нее будет установлен идентификатор и сериализована в формате JSON, как и ожидалось.
Давайте повторим наш запрос cURL ранее:
curl --request POST 'http://localhost:3000/article' \
--header 'Content-Type: application/json' \
--data-raw '{
"title": "Title",
"body": "Body"
}'
Это приведет к ответу, подобному этому:
{
"id": 1,
"title": "Title",
"body": "Body",
"updated_at": "2022-04-09T04:47:09Z",
"created_at": "2022-04-09T04:47:09Z"
}
Прекрасно! Теперь мы правильно храним наши статьи. Следующий наиболее очевидный вопрос — как читать список сохраненных статей. Однако в настоящее время менеджер сущностей обрабатывает только существующие сущности, а не запросы. Давайте поработаем над этим дальше!
Хотя мы могли бы просто добавить к нему несколько методов для обработки запросов, было бы лучше иметь выделенный тип
Repository, специфичный для запросов, который мы могли бы получить через диспетчер сущностей. Давайте создадим src/entities/article_repository.cr со следующим содержимым:
class Blog::Entities::Article::Repository
def initialize(@database: DB::Database); end
def find?(id : Int64) : Blog::Entities::Article?
@database.query_one?(%(SELECT * FROM "articles" WHERE "id"
= $1 AND "deleted_at" IS NULL;), id, as:
Blog::Entities::Article)
end
def find_all : Array(Blog::Entities::Article)
@database.query_all %(SELECT * FROM "articles" WHERE
"deleted_at" IS NULL;), as: Blog::Entities::Article
end
end
Это довольно простой объект, который принимает
DB::Database и действует как место для всех запросов, связанных со статьей. Нам нужно предоставить это из типа менеджера объектов, что мы можем сделать, добавив следующий метод:
def repository(entity_class : Blog::Entities::Article.class) :
Blog::Entities::Article::Repository
@@article_repository ||=
Blog::Entities::Article ::Repository.new
@@database
end
Этот подход позволит добавить перегрузку
#repository для каждого класса сущности, если в будущем будут добавлены другие. Опять же, мы могли бы, конечно, реализовать что-то более изысканным и надежным способом, но, учитывая, что у нас будет только одна сущность, использование перегрузок при кэшировании репозитория в переменной класса будет достаточно хорошим. Как говорится, преждевременная оптимизация — корень всех зол.
Теперь, когда у нас есть возможность получать все статьи, а также отдельные статьи по идентификатору, мы можем перейти к созданию конечных точек, добавив в контроллер статей следующие методы:
@[ARTA::Get("/article/{id}")]
def article(id : Int64) : Blog::Entities::Article
article = @entity_manager.repository(Blog::Entities::Article)
.find? Id
if article.nil?
raise ATH::Exceptions::NotFound.new "An item with the provided ID could not be found."
end
article
end
@[ARTA::Get("/article")]
def articles : Array(Blog::Entities::Article)
@entity_manager.repository(Blog::Entities::Article).find_all end
Первая конечная точка вызывает
#find? метод для возврата статьи с предоставленным идентификатором. Если он не существует, он возвращает более полезный ответ об ошибке 404. Следующая конечная точка возвращает массив всех сохраненных статей.
Как и раньше, когда мы начали с конечной точки
#create_article и узнали об ATH::RequestBodyConverter, существует лучший способ обработки чтения конкретной статьи из базы данных. Мы можем определить наш собственный преобразователь параметров, который будет использовать параметр пути идентификатора, извлекать его из базы данных и передавать в действие, при этом он будет достаточно универсальным, чтобы его можно было использовать для других имеющихся у нас объектов. Создайте src/param_converters/database.cr со следующим содержимым, гарантируя, что этот новый каталог также необходим в src/blog.cr:
@[ADI::Register]
class Blog::Converters::Database < ATH::ParamConverter
def initialize(@entity_manager : Blog::Services
::EntityManager);
end
# :inherit:
def apply(request : ATH::Request, configuration :
Configuration(T)) : Nil forall T
id = request.attributes.get "id", Int64
unless model = @entity_manager.repository(T).find? Id
raise ATH::Exceptions::NotFound.new "An item with the provided ID could not be found."
end
request.attributes.set configuration.name, model, T
end
end
Как и в случае с предыдущим прослушивателем, нам нужно сделать прослушиватель сервисом с помощью аннотации
ADI::Register. Фактическая логика включает в себя извлечение параметра пути идентификатора из атрибутов запроса, использование его для поиска связанного объекта, если таковой имеется, и установку объекта в атрибутах запроса.
Если объект с предоставленным идентификатором не найден, мы возвращаем ответ об ошибке
404.
Последняя ключевая часть того, как это работает, относится к ранее в главе, когда мы изучали, как Athena предоставляет аргументы для каждого действия контроллера. Одним из таких способов разрешения аргументов является использование атрибутов запроса, которые можно рассматривать как хранилище ключей/значений для произвольных данных, связанных с запросом, к которым автоматически добавляются параметры пути и запроса.
В контексте нашего конвертера метод configuration.name представляет имя параметра действия, к которому относится конвертер, на основе значения, указанного в аннотации. Мы используем это, чтобы установить имя атрибута, например,
article, для разрешенного объекта. Затем Athena увидит, что это действие контроллера имеет параметр с именем article, проверит, существует ли атрибут с таким именем, и предоставит его действию, если он существует. Используя этот конвертер, мы можем обновить действие #article следующим образом:
@[ARTA::Get("/article/{id}")]
@[ATHA::ParamConverter("article", converter:
Blog::Converters::Database)]
def article(article : Blog::Entities::Article) :
Blog::Entities::Article
article
end
Та-да! Простой способ предоставления объектов базы данных непосредственно в качестве аргументов действия через их идентификаторы. Хотя на данный момент у нас уже довольно много конечных точек, связанных со статьями, нам все еще не хватает способа обновить или удалить статью. Давайте сначала сосредоточимся на том, как обновить статью.
На первый взгляд обновление записей базы данных может показаться простым, но на самом деле оно может быть довольно сложным из-за характера процесса. Например, чтобы обновить сущность, сначала необходимо получить ее текущий экземпляр, а затем применить к нему изменения. Изменения обычно представляются в виде тела запроса к конечной точке
PUT с включенным идентификатором объекта, в отличие от конечной точки POST. Проблема заключается в том, как применить изменения из нового тела запроса к существующей сущности.
Сериализатор Athena имеет концепцию конструкторов объектов, которые управляют тем, как сначала инициализируется десериализуемый объект. По умолчанию они создаются обычным способом с помощью метода
.new. Он предлагает возможность определять собственные объекты, что мы могли бы сделать, чтобы получить объект из базы данных на основе свойства ID в теле запроса. Затем мы применим остальную часть тела запроса к полученной записи. Это гарантирует правильную обработку скрытых значений базы данных, а также выполнение сложной части применения изменений к объекту.
Однако, поскольку это немного усложняет работу сериализатора Athena, а в нашей статье есть только два свойства, мы не собираемся это реализовывать. Если вам интересно, как это будет выглядеть, или вы хотите попробовать реализовать это самостоятельно, ознакомьтесь с рецептом кулинарной книги: https://athenaframework.org/cookbook/object_constructors/#db. Он использует Granite ORM, но переключить его на наш EntityManager должно быть довольно просто.
Вместо использования конструктора объекта мы просто собираемся вручную сопоставить значения из тела запроса и применить их к объекту, полученному из базы данных. Прежде чем мы сможем это сделать, нам сначала нужно обновить менеджер сущностей для обработки обновлений. Первым шагом является обновление
#persist, чтобы проверить, установлен ли идентификатор с помощью следующего:
def persist(entity : DB::Serializable) : Nil
entity.before_save if entity.responds_to? :before_save
if entity.id?.nil?
entity.after_save self.save entity
else
self.update entity
end
Где метод
#update выглядит следующим образом:
private def update(entity : Blog::Entities::Article) : Nil
@@connection.exec(
%(UPDATE "articles" SET "title" = $1, "body" = $2,
"updated_at" = $3, "deleted_at" = $4 WHERE "id" = $5;),
entity.title,
entity.body,
entity.updated_at,
entity.deleted_at,
entity.id
)
end
Отсюда мы можем обновить нашу конечную точку
#update_article, чтобы она выглядела следующим образом:
@[ARTA::Put("/article/{id}")] @[ATHA::ParamConverter("article_entity", converter:
Blog::Converters::Database)]
@[ATHA::ParamConverter("article", converter:
ATH::RequestBodyConverter)]
def update_article(article_entity : Blog::Entities::Article,
article : Blog::Entities::Article) : Blog::Entities::Article
article_entity.title = article.title
article_entity.body = article.body
@entity_manager.persist article_entity
article_entity
end
В этом примере мы используем два преобразователя параметров. Первый извлекает реальную сущность статьи из базы данных, а второй создает ее на основе тела запроса. Затем мы применяем статью тела запроса к сущности статьи и передаем ее в
#persist. Допустим, мы делаем такой запрос:
curl --request PUT 'http://localhost:3000/article/1' \ --header 'Content-Type: application/json' \
--data-raw '{
"title": "New Title",
"body": "New Body",
"updated_at": "2022-04-09T05:13:30Z",
"created_at": "2022-04-09T04:47:09Z"
}'
Это приведет к такому ответу:
{
"id": 1, "title": "New Title",
"body": "New Body",
"updated_at": "2022-04-09T05:22:44Z",
"created_at": "2022-04-09T04:47:09Z"
}
Прекрасно!
title, body, и updated_at были обновлены, как и ожидалось, тогда как временные метки id и create_at из базы данных не были изменены.
И последнее, но не менее важное: нам нужна возможность удалить статью.
Мы можем обрабатывать удаления, еще раз обновив наш менеджер сущностей, включив в него метод
#remove, а также метод #on_remove для наших сущностей, который будет обрабатывать настройку свойства delete_at. Затем мы могли бы использовать преобразователь параметров базы данных на конечной точке DELETE и просто предоставить #remove разрешенному объекту.
Начните с добавления этого в менеджер сущностей:
def remove(entity : DB::Serializable) : Nil
entity.on_remove if entity.responds_to? :on_remove
self.update entity
end
А это к нашей статье:
protected def on_remove : Nil
@deleted_at = Time.utc
end
Наконец, действие контроллера будет выглядеть так:
@[ARTA::Delete("/article/{id}")]
@[ATHA::ParamConverter("article", converter:
Blog::Converters::Database)]
def delete_article(article : Blog::Entities::Article) : Nil
@entity_manager.remove article
end
Затем мы могли бы сделать запрос, например,
curl --request DELETE 'http:// localhost:3000/article/1' и увидеть в базе данных, что столбец delete_at установлен. Потому что метод #find? также отфильтровывает удаленные элементы, поэтому попытка удалить ту же статью еще раз приведет к ошибке 404.
В некоторых случаях API может потребоваться поддержка возврата не только JSON. Athena предоставляет несколько способов улучшить согласование контента, обрабатывая несколько форматов ответов с помощью единственного возвращаемого значения из действия контроллера. Давайте взглянем.
На данный момент наш блог действительно собирается вместе. Мы можем создавать, получать, обновлять и удалять статьи. У нас также есть несколько довольно надежных абстракций, которые помогут будущему росту. Как упоминалось ранее в этой главе, если действия контроллера напрямую возвращают объект, это может помочь в обработке нескольких форматов ответов. Например, предположим, что мы хотели расширить наше приложение, разрешив ему возвращать статью как в формате HTML, так и в формате JSON, в зависимости от заголовка принятия запроса.
Чтобы справиться с генерацией HTML, мы могли бы использовать встроенную функцию Crystal (ECR), которая по сути похожа на шаблонизацию во время компиляции. Однако было бы полезно иметь что-то более гибкое, похожее на PHP Twig, Python Jinja или Embedded Ruby (ERB). На самом деле существует кристальный порт Джинджи под названием Crinja, который мы можем использовать. Итак, сначала добавьте следующее в качестве зависимости к вашему shard.yml, обязательно запустив
shards install и потребовав ее в src/blog.cr:
crinja:
github: straight-shoota/crinja
version: ~> 0.8.0
В Crinja есть модуль
Crinja::Object, который можно включить, чтобы обеспечить доступ к определенным свойствам/методам этого типа в шаблоне. Он также имеет подмодуль Auto, который работает во многом аналогично JSON::Serializable. Поскольку это модуль, он также позволит нам проверить, доступен ли конкретный объект для визуализации, чтобы мы могли обработать случай ошибки при попытке отобразить объект, который невозможно отобразить.
План установки такой:
1. Настройте согласование содержимого, чтобы конечная точка
GET /article/{id} отображалась как в формате JSON, так и в формате HTML.
2. Включите и настройте
Crinja::Object::Auto в нашей сущности статьи.
3. Создайте HTML-шаблон, который будет использовать данные статьи.
4. Определите собственный модуль визуализации для HTML, чтобы связать все воедино.
Нам также нужен способ определить, какой шаблон должна использовать конечная точка. Мы можем использовать еще одну невероятно мощную функцию Athena - возможность определять/использовать пользовательские аннотации. Эта функция обеспечивает огромную гибкость, поскольку возможности ее использования практически безграничны. Вы могли бы определить постраничную аннотацию для обработки разбивки на страницы, общедоступную аннотацию для обозначения общедоступных конечных точек или, в нашем случае, шаблонную аннотацию для сопоставления конечной точки с ее шаблон Crinja.
Чтобы создать эту пользовательскую аннотацию, мы используем макрос
configuration_annotation как часть компонента Athena::Config. Этот макрос принимает в качестве первого аргумента имя аннотации, а затем переменное количество полей, которые также могут содержать значения по умолчанию, очень похоже на макрос записи. В нашем случае нам нужно сохранить только имя шаблона, поэтому вызов макроса будет выглядеть так:
ACF.configuration_annotation Blog::Annotations::Template, name
: String
Вскоре мы вернемся к использованию этой аннотации, но сначала нам нужно разобраться с другими пунктами нашего списка дел. Прежде всего, настройте согласование содержимого. Добавьте следующий код в файл src/config.cr:
def ATH::Config::ContentNegotiation.conРисунок :
ATH::Config::ContentNegotiation?
new(
Rule.new(path: /^\/article\/\d+$/, priorities: ["json",
"html"],
methods: ["GET"], fallback_format: "json"),
Rule.new(priorities: ["json"], fallback_format: "json")
)
end
Подобно тому, как мы настроили прослушиватель CORS, мы можем сделать то же самое для функции согласования контента. Однако в этом случае он настраивается путем предоставления ряда экземпляров правил, которые позволяют точно настроить согласование.
Аргумент
path принимает регулярное выражение, благодаря которому это правило будет применяться только к конечным точкам, соответствующим шаблону. Учитывая, что нам нужна только одна конечная точка, поддерживающая оба формата, мы настраиваем регулярное выражение для сопоставления с его путем.
Аргументы priorities управляют форматами, которые следует учитывать. В данном случае мы хотим поддерживать JSON и HTML, поэтому у нас установлены эти значения. Порядок значений имеет значение. В случае, когда заголовок принятия допускает оба формата, будет использоваться первый соответствующий формат в массиве, которым в данном случае будет JSON.
Наше второе правило не содержит пути, поэтому оно применяется ко всем маршрутам и поддерживает только JSON. Мы также устанавливаем значение
fallback_format для JSON таким образом, что JSON все равно будет возвращен, даже если заголовок accept этого не разрешает. Резервный формат также может быть установлен на nil, чтобы попробовать следующее правило, или false, чтобы вызвать ATH::Exceptions::NotAcceptable , если нет обслуживаемого формата.
См. https://athenaframework.org/Framework/Config/ContentNegotiation/Rule/ для получения дополнительной информации о том, как можно настроить правила согласования.
Теперь, когда мы это настроили, мы можем перейти к настройке нашей сущности статьи, чтобы предоставить некоторые ее данные Crinja. Это так же просто, как добавить
include Crinja::Object::Auto внутри класса, а затем добавить аннотацию @[Crinja::Attributes] к самому классу сущности.
Далее мы можем создать HTML-шаблон для представления статьи. Учитывая, что это только пример, выглядеть это будет некрасиво, но свою работу он выполнит. Давайте создадим src/views/article.html.j2 со следующим содержимым:
{{ data.title }}
{{ data.body }}
Updated at: {{ data.updated_at }}
Мы получаем доступ к значениям статьи в объекте данных, который будет представлять корневые данные, предоставленные при вызове рендеринга. Это позволит в будущем расширить представленные данные за пределы статьи.
Наконец, нам нужно создать экземпляр
ATH::View::FormatHandlerInterface, который будет обрабатывать процесс подключения всего, чтобы возвращаемое значение действия контроллера отображалось через Crinja и возвращалось клиенту. Создайте src/services/html_format_handler.cr со следующим содержимым:
@[ADI::Register]
class HTMLFormatHandler
include Athena::Framework::View::FormatHandlerInterface
private CRINJA = Crinja.new loader: Crinja::Loader::
FileSystem
Loader.new "#{__DIR__}/../views"
def call(view_handler : ATH::View::ViewHandlerInterface, view
: ATH::ViewBase, request : ATH::Request, format : String) :
ATH::Response
ann_configs = request.action.annotation_configurations
unless template_ann = ann_configs[Blog::Annotations::
Template]?
raise "Unable to determine the template for the
'#{request.attributes.get "_route"}' route."
end
unless (data = view.data).is_a? Crinja::Object
raise ATH::Exceptions::NotAcceptable.new "Cannot convert value of type '#{view.data.class}' to '#{format}'."
end
content = CRINJA.get_template(template_ann.name). render({data: view.data})
ATH::Response.new content, headers: HTTP::Headers{"content- type" => "text/html"}
end
def format : String
"html"
end
end
Помимо выполнения некоторых вещей, с которыми мы уже должны быть знакомы, таких как регистрация службы и включение модуля интерфейса, мы также определяем метод
#format, который возвращает формат, который обрабатывает этот тип. Мы также создали одноэлементный экземпляр Crinja, который будет загружать шаблоны из папки src/views. Crinja считывает шаблоны при каждом вызове #get_template, поэтому нет необходимости перезапускать сервер, если вы только внесли изменения в шаблон. Однако в его нынешнем виде для этого потребуется, чтобы путь существовал и был действительным как в среде разработки, так и в производственной среде. Рассмотрите возможность использования переменной среды для указания пути.
Наконец, мы определили метод
#call, который имеет доступ к различной информации, которую можно частично использовать для обработки ответа. В нашем случае нам нужны только параметры view и request, последний из которых используется для получения всех конфигураций аннотаций, определенных на соответствующем маршруте. Здесь в игру вступает аннотация, которую мы создали ранее, поскольку мы можем проверить, применяется ли ее экземпляр к действию контроллера, связанному с текущим запросом. См. https://athenaframework.org/Framework/View/ для получения дополнительной информации о том, что отображается через эти параметры.
Далее мы обрабатываем некоторые контексты ошибок, например, если конечная точка не имеет аннотации шаблона или возвращаемое значение не может быть отображено через Crinja. Я намеренно создаю общие исключения, чтобы возвращался ответ об ошибке
500, поскольку мы не хотим утечки внутренней информации за пределы API.
Наконец, мы используем Crinja для получения шаблона на основе имени в аннотации и его визуализации, используя значение, возвращаемое из действия контроллера, в качестве значения объекта данных. Затем мы используем визуализированное содержимое в качестве тела ответа для
ATH::Response, устанавливая тип содержимого ответа на text/html.
Чтобы включить такое поведение, нам просто нужно применить аннотацию
@ [Blog::Annotations::Template("article.html.j2")] к нашему методу #article в ArticleController. Мы можем все проверить, сделав еще один запрос:
curl --request GET 'http://localhost:3000/article/1' --header
'accept: text/html'
Ответом в этом контексте должен быть наш HTML-шаблон. Если вы установите заголовок application/json или вообще удалите его, ответом должен быть JSON.
И вот она, реализация блога, в которой используются некоторые интересные функции Athena, которые, в свою очередь, сделали реализацию простой и очень гибкой. Мы использовали преобразователи параметров для обработки как десериализации тела запроса, так и для поиска и предоставления значения из базы данных. Мы создали специальный обработчик аннотаций и форматов для поддержки ответов в нескольких форматах посредством согласования содержимого. И самое главное, мы прикоснулись к компоненту DI, показав, как он упрощает повторное использование объектов, а также как можно использовать концепцию контейнера на запрос для предотвращения утечки состояния между запросами.
Как вы можете себе представить, Athena использует немало концепций метапрограммирования для реализации своих функций. В следующей главе мы собираемся изучить основную функцию метапрограммирования — макросы.
• https://athenaframework.org/EventDispatcher/
• https://athenaframework.org/Console/
• https://athenaframework.org/Routing/