Crystal Programming. Введение на основе проекта в создание эффективных, безопасных и читаемых веб-приложений и приложений CLI [Джордж Дитрих] (fb2) читать онлайн


 [Настройки текста]  [Cбросить фильтры]
  [Оглавление]

Джордж Дитрих Гильерме Берналь Crystal Programming Введение на основе проекта в создание эффективных, безопасных и читаемых веб-приложений и приложений CLI

В будущее Crystal; пусть он будет таким же ярким, как бриллиант.

- George Dietrich
Моей любимой жене, которая поддерживает меня во всем.

- Guilherme Bernal

О издании

Crystal Programming

Copyright © 2022 Packt Publishing

Все права защищены. Никакая часть этой книги не может быть воспроизведена, сохранена в поисковой системе или передана в любой форме и любыми средствами без предварительного письменного разрешения издателя, за исключением случаев включения кратких цитат в критические статьи или обзоры.

При подготовке этой книги были приложены все усилия, чтобы обеспечить точность представленной информации. Однако информация, содержащаяся в этой книге, продается без каких-либо гарантий, явных или подразумеваемых. Ни авторы, ни Packt Publishing, ни ее дилеры и дистрибьюторы не несут ответственности за любой ущерб, причиненный или предположительно причиненный этой книгой, прямо или косвенно.

Packt Publishing постаралась предоставить информацию о товарных знаках обо всех компаниях и продуктах, упомянутых в этой книге, используя соответствующие прописные буквы. Однако Packt Publishing не может гарантировать точность этой информации.

Group Product Manager: Alok Dhuri

Publishing Product Manager: Shweta Bairoliya

Senior Editor: Nisha Cleetus

Content Development Editor: Nithya Sadanandan

Technical Editor: Maran Fernandes

Copy Editor: Safis Editing

Project Coordinator: Deeksha Thakkar

Proofreader: Safis Editing

Indexer: Subalakshmi Govindhan

Production Designer: Vijay Kamble

Marketing Coordinator: Sonakshi Bubbar

First published: July 2022

Production reference: 1130522

Published by Packt Publishing Ltd.

Livery Place

35 Livery Street

Birmingham

B3 2PB, UK.

ISBN 978-1-80181-867-4

www.packt.com

Составители

Об авторах

Джордж Дитрих — инженер-программист, поклонник открытого исходного кода и модератор сообщества Crystal. Он имеет степень магистра наук в области информационных систем Интернета и степень бакалавра наук в области информационных наук.

Гильерме Берналь — технический директор Cubos Tecnologia. Он имеет степень бакалавра в области управления ИТ. Гильермс стал соучредителем компании по разработке программного обеспечения и нескольких технологических стартапов, в том числе одного, ориентированного на обучение навыкам программирования нового поколения разработчиков. Он также является двукратным финалистом мирового конкурса по программированию ACM ICPC.

О рецензенте

Брайан Кардифф создает программное обеспечение для других уже более 20 лет. Он мог играть множество ролей в процессе разработки: сбор требований, проверка прототипа, кодирование, развертывание и обслуживание. За 15 лет работы в Manas.Tech он присоединился к Ари Боренцвейгу и Хуану Вайнерману, чтобы придать форму Crystal. Ему нравится создавать инструменты для технических и нетехнических людей. В основном благодаря Crystal он стал сотрудником сообщества открытого исходного кода. Он также ознакомился с книгой Иво Бальберта и Саймона Сен-Лорана «Программирование кристалла: создание высокопроизводительных, безопасных и параллельных приложений». Работая полный рабочий день в отрасли, он старается поддерживать связь с академическими и исследовательскими языками программирования и формальными методами.

Я хотел бы поблагодарить мою жену и дочь за их постоянную поддержку во всем. различные проекты, которыми я продолжаю заниматься.

- Brian Cardiff

Предисловие

Язык программирования Crystal разработан с учетом потребностей как людей, так и компьютеров. Он обеспечивает легко читаемый синтаксис, который компилируется в эффективный код.

В этой книге мы собираемся изучить все, что может предложить Кристалл. Мы начнем с представления языка, включая его основные синтаксические и семантические особенности. Далее мы углубимся в создание нового проекта Crystal, рассказав, как создать приложение на основе CLI, которое потребует использования более продвинутых функций, таких как операции ввода-вывода, параллелизм и привязки C.

В третьей части этой книги мы узнаем, как использовать внешние библиотеки в виде Crystal Shards. Затем мы воспользуемся этими знаниями, пройдя процесс создания веб-приложения с использованием Athena Framework.

Четвертая часть книги посвящена одной из самых мощных возможностей Crystal: метапрограммированию. Здесь мы научимся использовать макросы, аннотации и самоанализ типов во время компиляции. Затем мы узнаем, как их можно объединить для реализации некоторых довольно мощных функций.

В завершение мы представим некоторые вспомогательные функции Crystal, например способы документирования, тестирования и развертывания программ Crystal, а также способы автоматизации этих процессов путем внедрения CI в ваш рабочий процесс.

Важная заметка:
Эта книга предназначена для Crystal версии 1.4.x. Будущие версии также должны работать, но не будут охватывать новые добавленные функции.

Для кого эта книга

Эта книга будет полезна разработчикам, желающим изучить программирование Crystal, а также всем, кто хочет улучшить свои способности решать реальные проблемы с помощью языка. Ожидается опыт разработки приложений на любом другом языке программирования. Однако предварительные знания Crystal не требуются.

О чем эта книга

Глава 1 «Введение в Crystal» содержит краткое введение в Crystal, включая его историю, ключевые концепции и цели. Эта глава также будет включать информацию о настройке Crystal, а также информацию об соглашениях, которые будут использоваться на протяжении всей книги.

Глава 2 «Основы семантики и особенности Crystal» знакомит вас с написанием кода Crystal, начиная с самых основ и заканчивая наиболее распространенными методами. Он также исследует распространенные типы и операции из стандартной библиотеки.

Глава 3 «Объектно-ориентированное программирование» углубляет использование объектно-ориентированных функций языка, обучая вас созданию новых типов с настраиваемыми функциями — основного инструмента каждой нетривиальной программы.

Глава 4 «Изучение Crystal посредством написания интерфейса командной строки» описывает настройку проекта CLI и пошаговую реализацию первоначальной реализации.

Глава 5 «Операции ввода-вывода» развивает предыдущую главу и представляет операции ввода-вывода как средство обработки ввода и вывода вместо жестко закодированных строк.

Глава 6 «Параллелизм» начинается с рассмотрения функций параллелизма Crystal, а затем используется то, что было изучено ранее, для обеспечения параллельности программы CLI.

Глава 7, «Взаимодействие C», демонстрирует, как можно использовать библиотеки C в программе Crystal путем привязки libnotify для уведомления программы CLI.

Глава 8 «Использование внешних библиотек» знакомит с командой shards и способами ее поиска.

Глава 9 «Создание веб-приложения с помощью Athena» описывает создание простого веб-приложения для блога с использованием Athena Framework, используя многие из его функций.

Глава 10 «Работа с макросами» представляет собой введение в мир метапрограммирования путем изучения макросов Crystal.

Глава 11 «Введение в аннотации» рассказывает о том, как определять, включать в себя данные и читать аннотации.

Глава 12 «Использование интроспекции типов во время компиляции» демонстрирует, как перебирать переменные, типы и методы экземпляра во время компиляции.

Глава 13 «Продвинутое использование макросов» демонстрирует некоторые мощные возможности, которые можно создать с помощью макросов и аннотаций, а также немного творчества.

Глава 14 «Тестирование» знакомит с модулем Spec и знакомит вас с модульным и интеграционным тестированием в контексте CLI и веб-приложений.

Глава 15 «Документирование кода» показывает, как лучше всего документировать, генерировать, размещать и версионировать документацию по коду Crystal.

Глава 16 «Развертывание кода» рассказывает о том, как выпускать новые версии сегмента, а также о том, как лучше всего создавать и распространять рабочую версию приложения.

Глава 17 «Автоматизация» содержит примеры рабочих процессов и комментарии по включению непрерывной интеграции для проектов Crystal.

Приложение A «Настройка инструментов» содержит практическое объяснение того, как настроить Visual Studio Code для программирования Crystal с помощью официального плагина.

Приложение B, «Будущее Crystal», дает краткий обзор работы, которая в настоящее время ведется за кулисами ради будущего языка, и показывает, как принять в ней участие и внести свой вклад.

Чтобы получить максимальную пользу от этой книги

Для этой книги требуется какой-либо текстовый редактор, а также доступ к терминалу. Рекомендуется использовать macOS или Linux, но Windows с WSL также должна работать нормально. Наконец, вам может потребоваться установить некоторые дополнительные системные библиотеки, чтобы некоторые примеры кода работали правильно.

Программное/аппаратное обеспечение, описанное в книге Требования к операционной системе
Crystal Windows (с WSL), macOS, или Linux
libnotify gcc (или другой C компилятор)
jq libpcre2

Примечание:
Если вы используете цифровую версию этой книги, мы советуем вам ввести код самостоятельно или получить доступ к коду из репозитория книги на GitHub (ссылка доступна в следующем разделе). Это поможет вам избежать любых потенциальных ошибок, связанных с копированием и вставкой кода.

Загрузите файлы примеров кода

Вы можете загрузить файлы примеров кода для этой книги с GitHub по адресу https://github.com/PacktPublishing/Crystal-Programming/. Если есть обновление кода, оно будет обновлено в репозитории GitHub.

У нас также есть другие пакеты кода из нашего богатого каталога книг и видео, доступных на https://github.com/PacktPublishing/. Проверь их!

Загрузка цветных изображений

Мы также предоставляем PDF-файл с цветными изображениями снимков экрана и диаграмм, использованных в этой книге. Вы можете скачать его здесь: https://static.packt-cdn.com/downloads/9781801818674_ColorImages.pdf.

Используемые соглашения

В этой книге используется ряд текстовых соглашений.

Код в тексте: указывает кодовые слова в тексте, имена таблиц базы данных, имена папок, имена файлов, расширения файлов, пути, фиктивные URL-адреса, пользовательский ввод и дескрипторы Twitter. Вот пример: «В нашем контексте типы STDIN, STDOUT и STDERR фактически являются экземплярами IO::FileDescriptor».

Блок кода задается следующим образом:

require "./transform"


STDOUT.puts Transform::Processor.new.process STDIN.gets_to_end

Когда мы хотим привлечь ваше внимание к определенной части блока кода, соответствующие строки или элементы выделяются жирным шрифтом:

require "./transform"


STDOUT.puts Transform::Processor.new.process STDIN.gets_to_end

Любой ввод или вывод командной строки записывается следующим образом:

---

- id: 2

  name: Jim

- id: 3

   name: Bob

Жирный шрифт: обозначает новый термин, важное слово или слова, которые вы видите на экране. Например, слова в меню или диалоговых окнах выделяются жирным шрифтом. Вот пример: «Откройте Windows PowerShell и выберите «Запуск от имени Администратора».


Советы или важные примечания
выглядят следующим образом.

Как связаться

Обратная связь от наших читателей всегда приветствуется.

Общая обратная связь: если у вас есть вопросы по какому-либо аспекту этой книги, напишите нам по адресу customercare@packtpub.com и укажите название книги в теме сообщения.

Опечатка: Хотя мы приложили все усилия, чтобы обеспечить точность нашего контента, ошибки все же случаются. Если вы нашли ошибку в этой книге, мы будем признательны, если вы сообщите нам об этом. Посетите www.packtpub.com/support/errata и заполните форму.

Пиратство. Если вы встретите в Интернете незаконные копии наших работ в любой форме, мы будем признательны, если вы предоставите нам адрес или название веб-сайта. Пожалуйста, свяжитесь с нами по адресу copyright@packt.com и укажите ссылку на материал.

Если вы заинтересованы в том, чтобы стать автором: Если есть тема, в которой вы разбираетесь, и вы заинтересованы в написании или внесении вклада в книгу, посетите авторов. Packtpub.com.

Поделитесь своими мыслями

После того, как вы прочитали Crystal Programming, мы будем рады услышать ваши мысли! Нажмите здесь, чтобы перейти прямо на страницу обзора этой книги на Amazon и поделиться своим мнением.

Ваш отзыв важен для нас и технического сообщества и поможет нам убедиться, что мы предоставляем контент отличного качества..

Часть 1: Приступая к работе

Как и в любой книге по программированию, нам нужно начать с знакомства с языком, включая способы его использования, его основные функции и семантику, а также с рассмотрения некоторых часто используемых шаблонов, которые он использует. Эта часть посвящена именно этому: началу работы с Crystal, но с уклоном в сторону читателей, знающих какой-либо другой язык программирования, но не имевших предыдущего контакта с самим Crystal.

Эта часть содержит следующие главы:

• Глава 1. Введение в Crystal.

• Глава 2. Основы семантики и особенности Crystal.

• Глава 3. Объектно-ориентированное программирование.

1. Введение в Crystal

Crystal — безопасный, производительный, объектно-ориентированный язык общего назначения. Он был во многом вдохновлен синтаксисом Ruby, а также средами выполнения Go и Erlang, что позволяет программисту быть очень продуктивным и выразительным при создании программ, которые эффективно работают на современных компьютерах.

Crystal имеет надежную систему типов и может компилироваться в собственные программы. Следовательно, большинство ошибок и ошибок в программировании можно обнаружить во время компиляции, что, помимо прочего, обеспечивает нулевую безопасность. Однако наличие типов не означает, что вы должны писать их везде. Crystal использует свою уникальную систему интерференции типов для определения типов практически каждой переменной в программе. Редко встречаются ситуации, когда программисту приходится где-то писать явный тип. Но когда вы это сделаете, вам очень помогут типы-объединения, обобщения и метапрограммирование.

Метапрограммирование — это метод, при котором структурированное представление написанной программы доступно и модифицируется самой программой, создавая новый код. Это место, где Ruby сияет всем своим динамизмом и встроенной моделью отражения, как и Crystal, по-своему. Crystal способен изменять и генерировать код во время компиляции с помощью макросов и модели статического отражения с нулевой стоимостью. Во всех отношениях он напоминает динамический язык, но он компилирует программу в чистый и быстрый машинный код.

Код, написанный на Crystal, выразителен и безопасен, но он также быстр — очень быстр. После создания он конкурирует с другими языками низкого уровня, такими как C, C++ или Rust. Он превосходит практически любой динамический язык, а также некоторые компилируемые языки. Хотя Crystal является языком высокого уровня, он может без дополнительных затрат использовать библиотеки C, лингва-франка системного программирования.

Вы можете использовать Crystal сегодня. После 10 лет интенсивной разработки и тестирования в начале 2021 года была выпущена стабильная и готовая к эксплуатации версия. Наряду с ней доступен полный набор библиотек (называемых «осколками»), включая веб-фреймворки, драйверы баз данных, форматы данных, сетевые протоколы и машинное обучение.

В этой главе будет представлена краткая история языка Crystal и представлены некоторые его характеристики, касающиеся производительности и выразительности. После этого он введет вас в курс дела, объяснив, как создать и запустить вашу первую программу Crystal. Наконец, вы узнаете о некоторых проблемах будущего языка.

В частности, мы затронем следующие темы:

• Немного истории

• Исследование выразительности Crystal

• Программы Crystal также БЫСТРЫ.

• Создание нашей первой программы

• Настройка среды


Это должно помочь вам понять, что такое Crystal, понять, почему его следует использовать, и научиться выполнять свою первую программу. Этот контекст важен для обучения программированию в Crystal, начиная от небольших фрагментов кода и заканчивая полнофункциональными и готовыми к использованию приложениями.

Технические требования

В рамках этой главы вы установите на свой компьютер компилятор Crystal и напишете с его помощью код. Для этого вам понадобится следующее:

• Компьютер Linux, Mac или Windows. В случае компьютера с Windows необходимо включить подсистему Windows для Linux (WSL).

• Текстовый редактор, например Visual Studio Code или Sublime Text. Подойдет любой, но у этих двух есть хорошие готовые к использованию плагины Crystal.


Вы можете получить весь исходный код, используемый в этой главе, из репозитория книги на GitHub по адресу https://github.com/PacktPublishing/Crystal-Programming/tree/main/Chapter01.

Немного истории

Crystal был создан в середине 2011 года в Manas Technology Solutions (https://manas.tech/), аргентинской консалтинговой компании, которая в то время много работала над созданием приложений Ruby on the Rails. Ruby — язык, с которым приятно работать, но его всегда подвергали сомнению из-за недостаточной производительности. Crystal ожил, когда Ари Боренцвейг, Брайан Кардифф и Хуан Вайнерман начали экспериментировать с концепцией нового языка, похожего на Ruby. Это будет статически типизированный, безопасный и компилируемый язык с почти таким же элегантным синтаксисом, как Ruby, но использующий преимущества вывода глобального типа для устранения динамизма во время выполнения. С тех пор многое изменилось, но основные концепции остались прежними.

Результат? Сегодня Crystal — это стабильный и готовый к использованию язык, созданный 10 лет назад, с более чем 500 участниками и растущим сообществом. Команда, стоящая за ним, успешно реализовала язык с быстрой параллельной средой выполнения и уникальной системой вывода типов, которая рассматривает всю программу за один раз, сохраняя при этом лучшие функции Ruby.

Первоначальным мотивирующим фактором для создателей была производительность. Им нравилось программировать на Ruby и использовать обширную экосистему Ruby, но производительности не было. С тех пор Ruby значительно улучшился, но даже сегодня существует ощутимый разрыв по сравнению с другими динамическими языками, такими как Python или JavaScript.

Все началось с простой идеи: что, если бы мы могли иметь ту же выразительность, что и Ruby, определять типы всех переменных и аргументов на основе сайтов вызовов, а затем генерировать собственный машинный код, аналогичный языку C? Они начали прототипировать его как побочный проект в 2011 году, и это сработало. Вначале он был принят как проект «Манас», что позволило троице работать над ним в оплачиваемые часы.

Crystal разрабатывался открыто с самого начала в общедоступном репозитории на GitHub по адресу https://github.com/crystal-lang/crystal. Это привлекло сообщество пользователей, участников, а также спонсоров, которые рассчитывали на успех Crystal. Первоначальный интерес исходил от сообщества Ruby, но вскоре он расширился. На следующем рисунке вы можете увидеть рост числа людей, интересующихся Crystal, измеренный по количеству «звезд» GitHub в основном репозитории.


Рисунок 1.1 – Устойчивый рост звезд GitHub


На момент написания последней версией является 1.2.2, и ее можно установить с официального сайта Crystal по адресу https://crystal-lang.org/.

Много вдохновения пришло от Ruby, но Crystal превратился в другой язык. Он сохранил лучшие части Ruby, но изменил, улучшил и удалил некоторые из его наследий. Ни один из языков не стремится быть совместимым с другим.

Понимание этой истории дает вам возможность проследить, что побудило Crystal создать и развиться до того, чем он является сегодня. Crystal стал очень производительным, но в то же время и очень выразительным. Теперь давайте посмотрим, что придает такую выразительность.

Исследование выразительности Crystal

Часто говорят, что Crystal — это язык людей и компьютеров. Это связано с тем, что Crystal стремится к балансу того, чтобы быть удивительно приятным языком для программистов и при этом быть очень производительным для машин. Одно не может существовать без другого, и в Crystal большинство абстракций не приводят к снижению производительности. Он имеет такие особенности и идиомы, как следующие:

• Объектно-ориентированное программирование: все является объектом. Даже сами классы - это объекты, то есть случаи класса. Примитивными типами являются объекты и также имеют методы, и каждый класс может быть вновь открыт и расширен по мере необходимости. Кроме того, Crystal имеет наследование, перегрузку метода/оператора, модули и дженерики.

• Статический тип: все переменные имеют известный тип во время компиляции. Большинство из них выведены компилятором и не написаны программистом. Это означает, что компилятор может улавливать ошибки, такие как вызывные методы, которые не определены или пытаются использовать значение, которое может быть нулевым (или nil в Crystal) в то время. Переменные могут быть комбинацией нескольких типов, что позволяет программисту писать динамический код.

• Блоки: Всякий раз, когда вы вызываете метод для объекта, вы можете передать блок кода. Затем этот блок может быть вызван из реализации метода с помощью ключевого слова yield. Эта идиома допускает всевозможные итерации и манипуляции с потоком управления и широко распространена среди разработчиков Ruby. Crystal также имеет замыкания, которые можно использовать, когда блоки не подходят друг другу.

• Сбор мусора: Объекты хранятся в куче, и их память автоматически освобождается, когда они больше не используются. Существуют также объекты, созданные из структуры, размещенной во фрейме стека текущего выполняемого метода, и они перестают существовать, как только метод завершается. Таким образом, программисту не приходится иметь дело с ручным управлением памятью.

• Метапрограммирование: Хотя Crystal не является динамическим языком, он часто может вести себя так, как если бы он им был, благодаря мощному метапрограммированию во время компиляции. Программист может использовать макросы и аннотации вместе с информацией обо всех существующих типах (статическое отражение) для генерации или мутирования кода. Это обеспечивает множество динамических идиом и паттернов.

• Одновременное (Concurrent) программирование: Программа Crystal может создавать новые волокна (легкие потоки) для выполнения блокирующего кода, координируясь с каналами. Асинхронное программирование становится простым в рассуждении и следовании. Эта модель была в значительной степени вдохновлена Go и другими параллельными языками, такими как Erlang.

• Кроссплатформенные: программы, созданные с помощью Crystal, могут работать на Linux, MacOS и FreeBSD, нацеливание x86 или ARM (как 32-битный, так и 64-битный). Это включает в себя новые кремниевые чипы от Apple. Поддержка Windows экспериментально, она еще не готова. Компилятор также может производить небольшие статические двоичные файлы на каждой платформе без зависимостей для простоты распространения.

• Безопасность времени выполнения: Crystal является безопасным языком – это означает, что нет неопределенного поведения и скрытых сбоев, таких как доступ к массиву за его пределами, доступ к свойствам по null или доступ к объектам после того, как они уже были освобождены. Вместо этого они становятся либо исключениями во время выполнения, ошибками во время компиляции, либо не могут произойти из-за защиты во время выполнения. У программиста есть возможность повысить безопасность, используя явно небезопасные функции языка, когда это необходимо.

• Программирование низкого уровня: хотя Crystal безопасен, всегда есть вариант использовать небезопасные функции. Такие вещи, как работа с необработанными указателями, обращение к нативным библиотекам C или даже использование сборки непосредственно, доступны для смелых. Во многих общих библиотеках C есть безопасные обертки, готовые к использованию, что позволяет им использовать свои функции из кристаллической программы.

На первый взгляд Crystal очень похож на Ruby, и многие синтаксические примитивы одинаковы. Но Crystal пошел своим путем, черпая вдохновение из многих других современных языков, таких как Go, Rust, Julia, Elixir, Erlang, C#, Swift и Python. В результате он сохраняет большую часть хороших частей красивого синтаксиса Ruby, в то же время внося изменения в основные аспекты, такие как метапрограммирование и параллелизм.

Программы Crystal также БЫСТРЫЕ

С самого начала Crystal создавался как быстрый. Он следует тем же принципам, что и другие быстрые языки, такие как C. Компилятор может анализировать исходный код, чтобы узнать точный тип каждой переменной и расположение памяти перед выполнением. Затем он может создать быстрый и оптимизированный собственный исполняемый файл без необходимости что-либо угадывать во время выполнения. Этот процесс широко известен как предварительная компиляция.

Компилятор Crystal построен на основе LLVM, той же инфраструктуры компилятора, которая используется в Rust, Clang и Apple Swift. В результате Crystal извлекает выгоду из того же уровня оптимизации, что и эти языки, что делает его хорошо подходящим для приложений с интенсивными вычислениями, таких как машинное обучение, обработка изображений или сжатие данных.

Но не все приложения привязаны к процессору. В большинстве случаев на карту поставлены другие ресурсы, такие как сетевые коммуникации или локальный диск. Все вместе они известны как ввод-вывод. Crystal имеет модель параллелизма, аналогичную горутинам Go или процессам Erlang, где несколько операций могут выполняться за циклом событий, не блокируя процесс и не делегируя слишком много работы операционной системе. Эта модель идеально подходит для таких приложений, как веб-службы или инструменты управления файлами.

Использование эффективного языка, такого как Crystal, поможет вам снизить затраты на оборудование и улучшить восприятие реакции пользователей. Кроме того, это означает, что вы можете запускать все меньше и меньше экземпляров вашего приложения для обработки того же объема обработки.

Давайте взглянем на простую реализацию алгоритма сортировки выбором, написанную на Crystal:


def selection_sort(arr)

  # Для каждого индекса элемента...

  arr.each_index do |i|

    # Найдите наименьший элемент после него

    min = (i...arr.size).min_by { |j| arr[j] }

    # Поменяйте местами позиции с наименьшим элементом

    arr[i], arr[min] = arr[min], arr[i]

  end

end


# Создайте перевернутый список из 30 тысяч элементов.

list = (1..30000).to_a.reverse


# Отсортируйте его, а затем распечатайте его голову и хвост select_sort(list)

p list[0...10]

p list[-10..-1]


В этом примере уже показаны некоторые интересные особенности Crystal:

• Прежде всего, он относительно небольшой. Основной алгоритм состоит всего из четырех строк.

• Это выразительно. Вы можете перебирать списки со специализированными блоками или использовать диапазоны.

• Не существует единого обозначения типа. Вместо этого компилятор выводит каждый тип, включая аргумент метода.


Удивительно, но этот же код действителен и в Ruby. Воспользовавшись этим, если мы возьмем этот файл и запустим его как Ruby Selection_sort.cr (обратите внимание, что Ruby не заботится о расширениях файлов), это займет около 30 секунд. С другой стороны, выполнение этой программы после ее компиляции с помощью Crystal в оптимизированном режиме занимает около 0,45 секунды, то есть в 60 раз меньше. Конечно, эта разница не одинакова для любой программы. Это зависит от того, с какой рабочей нагрузкой вы имеете дело. Также важно отметить, что Crystal требуется время для анализа, компиляции, при необходимости оптимизации и создания собственного исполняемого файла.

На следующем графике показано сравнение этого алгоритма сортировки выбором, написанного для разных языков. Здесь вы можете видеть, что Crystal соревнуется на вершине, проигрывая C и очень близко приближаясь к Go. Важно отметить, что Crystal — безопасный язык: он имеет полную поддержку обработки исключений, отслеживает границы массивов, чтобы избежать небезопасного доступа, а также проверяет переполнение при целочисленных математических операциях. С другой стороны, C — небезопасный язык, и он ничего из этого не проверяет. Безопасность достигается за счет незначительного снижения производительности, но, несмотря на это, Crystal остается очень конкурентоспособным:


Сортировка выбором в перевернутом списке из 30 тыс. элементов


Рисунок 1.2. Сравнение реализации простой сортировки выбором на разных языках.


Примечание
Сравнение различных языков и сред выполнения в таких синтетических тестах, как этот, не отражает реальную производительность. Правильное сравнение производительности требует более реалистичной задачи, чем сортировка выбором, и широкого обзора кода экспертами по каждому языку. Тем не менее, разные проблемы могут иметь очень разные характеристики производительности. Итак, рассмотрите возможность сравнительного анализа для вашего варианта использования. В качестве справочного материала для комплексного теста можно изучить тесты TechEmpower Web Framework (https://www.techempower.com/benchmarks).

Сравнение веб-серверов

Crystal не только отлично подходит для выполнения вычислений в небольших случаях, но также хорошо работает в более крупных приложениях, таких как веб-сервисы. Язык включает в себя богатую стандартную библиотеку со всем понемногу, и вы узнаете о некоторых ее компонентах в Главе 4 «Изучение Crystal посредством написания интерфейса командной строки». Например, вы можете создать простой HTTP-сервер, например этот:


require "http/server"


server = HTTP::Server.new do |context|

  context.response.content_type = "text/plain"

  context.response.print "Hello world, got #{context

    .request.path}!"

end


puts "Listening on http://127.0.0.1:8080"

server.listen(8080)


Первая строка require "http/server” импортирует зависимость из стандартной библиотеки, которая становится доступной как HTTP::Server. Затем он создает сервер с некоторым кодом для обработки каждого запроса и запускает его на порту 8080. Это простой пример, поэтому у него нет маршрутизации.

Давайте сравним это с некоторыми другими языками, чтобы увидеть, насколько хорошо он работает. Но, опять же, это не сложный реальный сценарий, а просто быстрый сравнительный тест:


Запросов в секунду на одном ядре


Рисунок 1.3 – Сравнение скорости запросов в секунду простых HTTP-серверов на разных языках


Здесь мы видим, что Crystal значительно опережает многие другие популярные языки (очень близкие к Rust и Go), а также является очень высокоуровневым и удобным для разработчиков. Многие языки достигают производительности за счет использования низкоуровневого кода, но при этом не требуется жертвовать выразительностью или раскрывать абстракции. Код Crystal легко читать и развивать. Та же тенденция наблюдается и в других приложениях, а не только в веб-серверах или микробенчмарках.

Теперь давайте попрактикуемся в использовании Crystal.

Настройка среды

Давайте подготовимся к созданию и запуску приложений Crystal, которые мы начнем в разделе «Создание нашей первой программы». Для этого вам понадобятся две самые важные вещи — текстовый редактор и компилятор Crystal:

• Текстовый редактор. Любой редактор кода справится с этой задачей, но использование редактора с хорошими плагинами для Crystal значительно облегчит жизнь. Рекомендуется использовать Visual Studio Code или Sublime Text. Более подробную информацию о настройке редактора вы можете найти в Приложении А.

• Компилятор Crystal: следуйте инструкциям по установке на веб-сайте Crystal по адресу https://crystal-lang.org/install/.

• После установки текстового редактора и компилятора у вас должна быть работающая установка Crystal! Давайте проверим это: откройте терминал и введите следующее: crystal eval "puts 1 + 1":



Рисунок 1.4 – Вычисление 1 + 1 с помощью Crystal


Эта команда скомпилирует и выполнит код Кристалла puts 1 + 1, который запишет результат этого вычисления обратно на консоль. Если вы видите 2, значит, все готово, и мы можем перейти к написанию настоящего кода Crystal.

Создаем нашу первую программу

Теперь давайте поэкспериментируем с созданием нашей первой программы с использованием Crystal. Это основа того, как вы будете писать и выполнять код в оставшейся части этой книги. Вот наш первый пример:


who = "World"

puts "Hello, " + who + "!"


После этого выполните следующие действия:

1. Сохраните это в файле hello.cr.

2. Запустите его с помощью crystal run hello.cr на своем терминале. Обратите внимание на результат.

3. Попробуйте изменить переменную who на что-нибудь другое и запустить снова.

Не существует шаблонного кода, такого как создание статического класса или «основной» функции. Для этого базового примера также не нужно ничего импортировать из стандартной библиотеки. Вместо этого вы можете просто начать программировать прямо сейчас! Это хорошо для быстрого написания сценариев, но также упрощает приложения.

Обратите внимание: переменную who не обязательно объявлять, определять или иметь явный тип. Это все рассчитано для вас.

Вызов метода в Crystal не требует круглых скобок. Вы можете увидеть там puts; это просто вызов метода, и его можно было бы записать как puts("Hello, " + who + "!").

Конкатенацию строк можно выполнить с помощью оператора +. Это всего лишь метод, определенный для строк, и в последующих главах вы узнаете, как определить свой собственный.

Давайте попробуем что-нибудь еще, прочитав имя, введенное пользователем:


def get_name

  print "What's your name? "

  read_line

end


puts "Hello, " + get_name + "!"


После этого сделаем следующее:

1. Сохраните приведенный выше код в файле с именем “hello_name.cr”.

2. Запустите его с помощью команды crystal run hello_name.cr на своем терминале.

3. Он спросит ваше имя; введите его и нажмите Enter.

4. Теперь запустите его еще раз и введите другое имя. Обратите внимание на изменение вывода.

В этом примере вы создали метод get_name, который взаимодействует с пользователем для получения имени. Этот метод вызывает два других метода: print и read_line. Обратите внимание: поскольку для вызова метода не требуются круглые скобки, вызов метода без аргументов выглядит точно так же, как переменная. Это нормально. Кроме того, метод всегда возвращает свое последнее выражение. В этом случае результат get_name является результатом read_line.

Это все еще просто, но позже вы сможете начать писать более сложный код. Здесь вы уже можете увидеть некоторое взаимодействие с консолью и использование методов для повторного использования кода. Далее давайте посмотрим, как из этого кода можно сделать собственный исполняемый файл.

Создание исполняемого файла

Когда вам нужно отправить приложение на компьютер конечного пользователя или на рабочий сервер, отправлять исходный код напрямую не идеально. Вместо этого лучший подход — скомпилировать код в собственный двоичный исполняемый файл. Они более производительны, их сложнее перепроектировать и они проще в использовании.

До сих пор вы использовали crystal run hello.cr для выполнения своих программ. Но у Crystal есть компилятор, и он также должен создавать собственные исполняемые файлы. Это возможно с помощью другой команды; попробуйте crystal build hello.cr.

Как вы увидите, это не запустит ваш код. Вместо этого он создаст файл «привет» (без расширения), который является собственным исполняемым файлом для вашего компьютера. Вы можете запустить этот исполняемый файл с помощью ./hello.

Фактически, crystal run hello.cr работает в основном как сокращение для crystal build hello.cr && ./hello.

Вы также можете использовать crystal build --release hello.cr для создания оптимизированного исполняемого файла. Это займет больше времени, но потребует нескольких преобразований кода, чтобы ваша программа работала быстрее. Более подробную информацию о том, как развернуть окончательную версию вашего приложения, можно найти в Приложении B «Будущее Crystal».

Краткое содержание

Crystal обеспечивает очень хорошую производительность, стабильность и удобство использования. Это полноценный язык с растущим сообществом и экосистемой, который сегодня можно использовать в производстве. Crystal является очень инновационным и содержит все компоненты успешного языка программирования.

Знание того, как создавать и запускать программы Crystal, будет иметь основополагающее значение в следующих главах, поскольку вам предстоит опробовать множество примеров кода.

Теперь, когда вы знаете о происхождении Crystal и важных характеристиках языка (а именно, о его выразительности и производительности), давайте продолжим изучать основы программирования в Crystal и поможем вам начать и продуктивно работать с этим языком.

2. Основные семантики и особенности Crystal

В этой главе вы изучите основы программирования Crystal, которые помогут вам быстро освоиться, даже если вы еще не умеете писать ни одной строки кода Crystal. Здесь вы узнаете о вещах, общих для многих других языков программирования, таких как переменные, функции и структуры управления, а также о функциях, характерных для Crystal, таких как система типов и передача блоков. Ожидается, что у вас уже есть базовый опыт работы с каким-либо другим языком программирования.

В этой главе будут рассмотрены следующие основные темы:

• Значения и выражения

• Управление потоком выполнения с помощью условных операторов.

• Изучение системы типов

• Организация кода в методах.

• Контейнеры данных

• Организация кода в файлах.

Технические требования

Для выполнения задач данной главы вам понадобится следующее:

• Рабочая установка Crystal.

• Текстовый редактор, настроенный для использования Crystal.


Вы можете обратиться к Главе 1 «Введение в Crystal» для получения инструкций по настройке Crystal и к Приложению A «Настройка инструментов» для получения инструкций по настройке текстового редактора для Crystal.

Каждый пример в этой главе (а также в остальной части книги) можно запустить, создав текстовый файл с расширением .cr для кода и затем используя команду crystal file.cr в терминальном приложении. Вывод или любые ошибки будут показаны на экране.

Вы можете получить весь исходный код, использованный в этой главе, на GitHub книги по адресу https://github.com/PacktPublishing/Crystal-Programming/tree/main/Chapter02.

Значения и выражения

Программирование — это искусство преобразования и перемещения данных. Мы хотим получать информацию, возможно, от пользователя, печатающего на клавиатуре, от датчика Интернета вещей на крыше вашего дома или даже от входящего сетевого запроса, отправленного на ваш сервер. Затем мы хотим интерпретировать и понять эту информацию, представляя ее в нашей программе структурированным образом. Наконец, мы хотим обработать и преобразовать его, применяя алгоритмы и взаимодействуя с внешними источниками (например, запрос к базе данных или создание локального файла). Практически все компьютерные программы следуют этой структуре, и важно понимать, что все дело в данных.

Crystal имеет множество примитивных типов данных, используемых для выражения значений. Например, вы можете записывать целые числа, используя цифры, например 34. Вы также можете хранить данные в переменных. Они действуют как именованные контейнеры для хранения значений и могут изменяться в любое время. Для этого просто напишите имя переменной, затем символ равенства и значение, которое вы хотите сохранить. Вот пример программы Crystal:


score = 38

distance = 104

score = 41


p score


Вы можете выполнить эту программу Crystal, записав ее в файл и используя crystal file.cr на вашем терминале. Если вы это сделаете, вы увидите 41 на экране. Видите эту последнюю строчку? Он использует метод p для отображения значения переменной на экране.

Если вы работаете с другими языками, такими как Java, C#, Go или C, обратите внимание, что это полноценная программа. В Crystal вам не нужно создавать основную функцию, объявлять переменные или указывать типы. Вместо этого при создании новой переменной и изменении ее значения используется тот же синтаксис.

В одной строке можно присвоить несколько значений нескольким переменным, указав их через запятую. Множественное присвоение обычно используется для замены значений двух переменных. Посмотрите это, например:


# Назначаем две переменные одновременно

emma, josh = 19, 16


# Это то же самое, в две строки

emma = 19

josh = 16


# Теперь поменяем их значения emma, josh = josh, emma

p emma # => 16

p josh # => 19


Этот пример начинается со строки комментария. Комментарии предназначены для добавления пояснений или дополнительных деталей в исходный код и всегда начинаются с символа #. Затем у нас есть множественное присваивание, создающее переменные с именами emma и josh со значениями 19 и 16 соответственно. Это точно так же, как если бы переменные создавались по одной в две строки. Затем используется другое множественное присвоениедля обмена значениями двух переменных, одновременно присваивая emma значение переменной josh и josh значения переменной emma.

Имена переменных всегда пишутся строчными буквами в соответствии с соглашением о разделении слов символом подчеркивания (известным как snake_case). Хотя это и редкость, в именах переменных также могут использоваться заглавные буквы и неанглийские буквы.

Если используемые вами значения не изменятся, вы можете использовать константы вместо переменных. Они должны начинаться с заглавной буквы и обычно пишутся заглавными буквами, слова разделяются подчеркиванием и не могут быть изменены позже. Посмотрите это, например:


FEET = 0.3048 # Метры

INCHES = 0.0254 # Метры


my_height = 6 * FEET + 2 * INCHES # 1.87960 метров


FEET = 20 # Ошибка: константа FEET уже инициализирована.


Этот код показывает определение двух констант: ФУТОВ и ДЮЙМОВ. В отличие от переменных, им нельзя впоследствии присвоить другое значение. К константам можно получить доступ и использовать их в выражениях вместо их значений. Они полезны при присвоении имен специальным или повторяющимся значениям. Они могут хранить любые данные, а не только числа.

Теперь давайте рассмотрим некоторые из наиболее распространенных примитивных типов данных.

Числа (Numbers)

Как и в других языках, числа бывают разных видов; вот таблица с их описанием:

Таблица 2.1 – Виды чисел и их пределы


При записи числа в соответствии со значением будет использоваться наиболее подходящий тип: если это целое число, то это будет Int32, Int64 или UInt64, в зависимости от того, что наиболее подходит. Если это значение с плавающей запятой, оно всегда будет Float64. Вы также можете добавить суффикс, чтобы указать один конкретный тип. Наконец, для улучшения читаемости можно свободно использовать символы подчеркивания. Вот несколько примеров того, как можно выразить числа:


small_number = 47 # Это тип Int32

larger_number = 8795656243 # Теперь это тип Int64

very_compact_number = 47u8 # Тип UInt8 из-за суффикса

other_number = 1_234_000 # Это то же самое, что 1234000

negative_number = -17 # Есть и отрицательные значения

invalid_number = 547_u8 # 547 не соответствует диапазону UInt8

pi = 3.141592653589 # Дробные числа имеют формат Float64

imprecise_pi = 3.14159_f32 # Это Float32


Как и ожидалось, вы также можете выполнять математические операции с числами. В большинстве языков это работает аналогично математике. Операторов много, и их можно переставлять в зависимости от их приоритета. В любом случае круглые скобки могут помочь организовать более крупное выражение. Давайте посмотрим пример:


hero_health_points = 100

hero_defense = 7

enemy_attack = 16


damage = enemy_attack - hero_defense # Враг наносит 9 единиц урона

hero_health_points -= damage # Теперь здоровье героя составляет 91


healing_factor = 0.05 # Герой исцеляется со скоростью 5% за ход

recovered_health = hero_health_points * healing_factor

hero_health_points += recovered_health # Теперь здоровье 95,55


# Этот же расчет можно выполнить и в одну строку: result = (100 - (16 - 7)) * (1 + 0.05) # => 95.55


Вот некоторые из наиболее распространенных операций с числами:

Таблица 2.2 – Операции, применимые к числам

Существуют и другие типы чисел для выражения больших или более точных величин:


BigInt: произвольно большое целое число.

BigFloat: произвольно большие числа с плавающей запятой.

BigDecimal: точные и произвольные числа по основанию 10, особенно полезно для валют.

BigRational: выражает числа в виде числителя и знаменателя.

Complex: содержит число с действительной и мнимой частью.


Все они действуют как числа и имеют функциональность, аналогичную целым числам и числам с плавающей запятой, которые мы уже представили.

Примитивные константы — true, false и nil

В Crystal есть три примитивные константы, каждая из которых имеет свое значение. Ниже указаны типы и использование:

Таблица 2.3 – Примитивные константы и описания

Значения true и false являются результатом выражений сравнения и могут использоваться с условными выражениями. Несколько условных операторов можно комбинировать с помощью && (и) или || (или) символы. Например, 3 > 5 || 1 < 2 оценивается как true.

Не все данные состоят только из чисел; нам часто приходится иметь дело с текстовыми данными. Давайте посмотрим, как мы можем с ними справиться.

Строки и символы (String и Char)

Текстовые данные могут быть представлены типом String: они могут хранить произвольные объемы текста UTF-8, предоставляя множество служебных методов для его обработки и преобразования. Существует также тип Char, способный хранить одну кодовую точку Юникода: character. Строки выражаются с помощью текста в двойных кавычках, а символы — с одинарными кавычками:


text = "Crystal is cool!"

name = "John"

single_letter = 'X'

kana = 'あ' # Международные символы всегда действительны


Внутри строки вы можете использовать интерполяцию для внедрения в текст других значений. Это полезно для создания строки из данных в других переменных. Хотя вы можете интерполировать любое выражение, постарайтесь сделать его простым. Вот несколько примеров того, как это делается:


name = "John"

age = 37

msg = "#{name} is #{age} years old" # То же, что и "Джону 37 лет"


Вы также можете использовать escape-последовательности внутри строки для обозначения некоторых специальных символов. Например, команда puts "a\nb\nc” покажет три строки вывода. Они заключаются в следующем:

Таблица 2.4 – Специальные escape-последовательности внутри строк или символов

Важно помнить, что строки Crystal являются неизменяемыми после их создания, поэтому любая операция над ними приведет к созданию новой строки. Многие операции можно выполнять со строками; они будут использоваться в примерах на протяжении всей книги. Вот некоторые распространенные операции, которые можно выполнять со строками:

Таблица 2.5 – Общие операции над строковыми значениями


Строки и числа являются обычным представлением большинства данных, но есть еще несколько структур, которые мы можем изучить, чтобы облегчить понимание данных.

Диапазоны (Ranges)

Еще один полезный тип данных — Range; это позволяет представлять интервал значений. Используйте две или три точки, разделяющие значения:

a..b обозначает интервал, начинающийся с a и заканчивающийся буквой b включительно.

a...b обозначает интервал, начинающийся с a и заканчивающийся непосредственно перед b, исключая его.


Ниже приведены примеры диапазонов:


1..5 # => 1, 2, 3, 4, и 5.

1...5 # => 1, 2, 3, и 4.

1.0...4.0 # => Включает 3,9 и 3,999999, но не 4.

'a'..'z' # => Все буквы алфавита

"aa".."zz" # => Все комбинации двух букв


Вы также можете опустить начало или конец, чтобы создать открытый диапазон. Вот некоторые примеры:


1.. # => Все числа больше 1

...0 # => Отрицательные числа, кроме нуля

.. # => Ассортимент, который включает в себя все, даже самого себя


Диапазоны также можно применять к разным типам; подумайте, например, о временных интервалах.

С диапазонами можно выполнять множество операций. В частности, Range реализует как Enumerable, так и Iterable, что позволяет ему действовать как сбор данных. Вот несколько служебных методов:

Таблица 2.6 – Общие операции со значениями диапазона


Вы уже можете выражать некоторые данные, используя литеральные значения и переменные в своем коде. Этого достаточно для некоторых базовых вычислений; попробуйте использовать его для некоторых преобразований строк или математических формул. Некоторые виды значений можно сначала объявить, чтобы использовать позже; перечисления — самые простые из них.

Перечисления и символы (Enums and symbols)

Строки используются для представления произвольного текста, обычно касающегося некоторого взаимодействия с пользователем, когда набор всех возможных текстов заранее неизвестен. String предлагает операции по разрезанию, интерполяции и преобразованию текста. Бывают случаи, когда значение вообще не предназначено для манипуляций, а просто должно представлять одно состояние из некоторых известных возможностей.

Например, предположим, что вы взаимодействуете с каким-либо пользователем в многопользовательской системе. Этот конкретный пользователь может быть гостем, обычным пользователем, прошедшим проверку подлинности, или администратором. Каждый из них имеет разные возможности, и их следует различать. Это можно сделать с помощью числового кода для представления каждого типа пользователей, например 0, 1 и 2. Или это можно сделать с использованием типа String, имеющего типы пользователей «гость», «обычный» и «администратор».

Лучшая альтернатива — объявить правильное перечисление возможных типов пользователей, используя ключевое слово enum для создания совершенно нового типа данных. Давайте посмотрим синтаксис:


enum UserKind

   Guest

   Regular

   Admin

end


Переменная, содержащая тип пользователя, может быть назначена путем ссылки на имя типа, а затем на один из объявленных типов:


user_kind = UserKind::Regular

puts "This user is of kind #{user_kind}"


Тип переменной user_kindUserKind, точно так же, как тип 20 — Int32. В следующей главе вы узнаете, как создавать более сложные пользовательские типы. Для каждой потребности могут быть созданы разные перечисления; они не будут смешиваться друг с другом.

Значение перечисления можно проверить с помощью метода, сгенерированного из каждой альтернативы. Вы можете использовать user_kind.guest? чтобы проверить, содержит ли этот user_kind тип Guest или нет. Аналогично, regular? и admin? методы можно использовать для проверки других типов.

Объявление и использование перечислений — предпочтительный способ обработки набора известных альтернатив. Например, они позаботятся о том, чтобы вы никогда не ошиблись в написании типа пользователя. В любом случае перечисления — не единственный вариант. Crystal также имеет тип Symbol.

Символ подобен программному анонимному перечислению, которое не нужно объявлять. Вы можете просто ссылаться на символы, добавляя двоеточие к имени символа. Они могут выглядеть и ощущаться очень похожими на строки, но их имена не предназначены для проверки и манипулирования ими, как со строками; вместо этого они оптимизированы для сравнения и не могут создаваться динамически:


user_kind = :regular

puts "This user is of kind #{user_kind}"


Символы подобны тегам и однозначно идентифицируются по имени. Сравнение символов более эффективно, чем сравнение строк: они будут совпадать, если их имена совпадают. Чтобы это работало, компилятор просканирует все символы, используемые во всем исходном коде, и объединит символы с одинаковым именем. Их писать быстрее, чем правильное перечисление, но их следует использовать с осторожностью, поскольку компилятор не обнаружит орфографическую ошибку и будет просто рассматриваться как другой символ.

Теперь мы увидели, как выражать множество типов данных, но этого недостаточно. Создание нелинейного кода с помощью условий и циклов имеет фундаментальное значение для более сложных программ, которые должны принимать решения на основе вычислений. Теперь пришло время добавить логику в ваш код.

Управление потоком выполнения с помощью условных выражений

Crystal, как и большинство императивных языков, имеет построчный поток выполнения сверху вниз. После выполнения текущей строки следующая строка будет следующей. Но вы имеете право контролировать и перенаправлять этот поток выполнения на основе любого условного выражения, которое только можете придумать. Первый вид управления потоком, который мы рассмотрим, — это реакция на условные выражения.

if и unless

Оператор if можно использовать для проверки условия; если оно истинно (то есть не равно nil и не false), то оператор внутри него выполняется. Вы можете использовать else, чтобы добавить действие, если условие неверно. Посмотрите это, например:


secret_number = rand(1..5) # Случайное целое число от 1 до 5


print "Пожалуйста, введите свое предположение:"

guess = read_line.to_i


if guess == secret_number

   puts "Вы правильно догадались!"

else

   puts "Извините, номер был #{secret_number}."

end


Условное выражение не обязательно должно быть выражением, результатом которого является логическое значение (true или false). Любое значение, кроме ложных, нулевых и нулевых указателей (подробнее об указателях см. в Главе 7, «C Функциональная совместимость»), будет считаться правдивым. Обратите внимание, что нулевые и пустые строки также являются правдивыми.

Противоположностью if является unless. Его можно использовать, когда вы хотите отреагировать, когда условие является false или nil. Посмотрите это, например:


unless guess.in? 1..5

   puts "Пожалуйста, введите число от 1 до 5."

end


Оператор if также может содержать блок else, но в этом случае всегда лучше изменить порядок на обратный и использовать последовательность if-else.

И if, и unless можно записать в одну строку, вставив ее после действия. В некоторых случаях это более читабельно. Предыдущий пример аналогичен этому:


puts "Пожалуйста, введите число от 1 до 5." unless guess.in? 1..5


Вы можете объединить несколько операторов if, используя один или несколько блоков elsif. Это уникально для if и не может использоваться с unless. Посмотрите это, например:


if !guess.in? 1..5

   puts "Пожалуйста, введите число от 1 до 5."

elsif guess == secret_number

   puts "Вы правильно угадали!"

else

   puts "Извините, номер был #{secret_number}."

end


Как вы часто увидите в Crystal, эти операторы также можно использовать как выражения; они выдадут последний оператор выбранной ветки. Вы даже можете использовать блок if в середине присваивания переменной:


msg = if !guess.in? 1..5

        "Пожалуйста, введите число от 1 до 5."

      elsif guess == secret_number

        "Вы правильно угадали!"

      else

        "Извините, номер был #{secret_number}."

      end

puts msg


Это может быть полезно, чтобы избежать повторения или выполнить сложную логику внутри другого выражения. Существует также сокращенная версия, если использовать условие ? истинное утверждение : структура ложного утверждения. Это часто называют тройным:


puts "Вы догадались #{guess == secret_number ? "правильно" : "неправильно"}!"


Часто вы не смотрите на проверку условных операторов, а вместо этого выбираете один из нескольких вариантов. Здесь на помощь приходит оператор case, объединяющий длинную последовательность операторов if.

case

case похож на оператор if, но позволяет определить несколько возможных результатов в зависимости от заданного значения. Вы указываете оператор case с некоторым значением и одним или несколькими вариантами, проверяющими различные возможности. Вот структура:


case Time.local.month

when 1, 2, 3

  puts "Мы в первом квартале"

when 4, 5, 6

  puts "Мы во втором квартале"

when 7, 8, 9

  puts "Мы в третьем квартале"

when 10, 11, 12

  puts "Мы в четвертом квартале"

end


Это прямой эквивалент гораздо более длинной и менее читаемой последовательности операторов if:


month = Time.local.month

if month == 1 || month == 2 || month == 3

  puts "Мы в первом квартале"

elsif month == 4 || month == 5 || month == 6

  puts "Мы во втором квартале"

elsif month == 7 || month == 8 || month == 9

  puts "Мы в третьем квартале"

elsif month == 10 || month == 11 || month == 12

  puts "Мы в четвертом квартале"

end


Оператор case также можно использовать с диапазонами:


case Time.local.month

when 1..3

  puts "Мы в первом квартале"

when 4..6

  puts "Мы во втором квартале"

when 7..9

  puts "Мы в третьем квартале"

when 10..12

  puts "Мы в четвертом квартале"

end


Его также можно использовать с типами данных вместо значений или диапазонов:


int_or_string = rand(1..2) == 1 ? 10 : "привет"

case int_or_string

when Int32

  puts "Это целое число"

when String

  puts "Это строка"

end


Таким образом, интересно использовать оператор case для проверки других вещей, кроме прямого равенства. Это работает, потому что за кулисами case использует оператор === для сравнения целевого значения с каждым предложением if. Вместо строгого равенства оператор === проверяет равенство или совместимость с заданным набором и является более расслабленным.

Как и оператор if, оператор case также может иметь ветвь else, если ни один из параметров не соответствует:


case rand(1..10)

when 1..3

  puts "Я кот"

when 4..6

  puts "Я собака"

else

  puts "Я случайное животное"

end


На данный момент вы научились использовать переменные, вызывать методы и выполнять различные операции с условными выражениями. Но также очень полезно повторять выполнение до тех пор, пока какое-либо условие не станет истинным, например, при поиске данных или преобразовании элементов. Теперь вы узнаете о примитивах, позволяющих сделать именно это.

while и until loops

Оператор while аналогичен оператору if, но он повторяется до тех пор, пока условие не станет ложным. Посмотрите это, например:


secret_number = rand(1..5)


print "Пожалуйста, введите ваше предположение: "

guess = read_line.to_i


while guess != secret_number

  puts "Извините, это не то. Пожалуйста, попробуйте еще раз: "

  guess = read_line.to_i

end


puts "Вы правильно угадали!"


Аналогично, оператор until является противоположностью оператора while, так же, как оператор unless является противоположностью оператора if:


secret_number = rand(1..5)


print "Пожалуйста, введите ваше предположение: "

guess = read_line.to_i


until guess == secret_number

  puts "Извините, это не то. Пожалуйста, попробуйте еще раз: "

  guess = read_line.to_i

end


puts "Вы правильно угадали!"


Внутри циклической структуры вы можете использовать следующие дополнительные ключевые слова:

break — немедленно прерывает цикл и выходит из него без повторной проверки условия.

next — прерывает текущее выполнение цикла и начинает заново с начала, проверяя условие

Вот пример использования break и next для дальнейшего управления потоком:


secret_number = rand(1..5)


while true

  print "Пожалуйста, введите свое предположение (ноль, чтобы отказаться): "

  guess = read_line.to_i


  if guess < 0 || guess > 5

    puts "Неверное предположение. Пожалуйста, попробуйте еще раз."

    next

  end


  if guess == 0

    puts "Извините, вы сдались. Ответ был #{secret_number}."

    break

  elsif guess == secret_number

    puts "Поздравляем! Вы угадали секретный номер!"

    break

  end


  puts "Извините, это не то. Пожалуйста, попробуйте еще раз."

end


Они составляют основу управления потоком выполнения с использованием условий и структуры цикла. Далее в этой главе вы также узнаете о блоках — наиболее распространенном способе создания циклов в Crystal, особенно с контейнерами данных. Но перед этим давайте углубимся в систему типов.

Изучение системы типов

Crystal — статически типизированный язык; компилятор знает типы каждой переменной и выражения перед выполнением. Это позволяет выполнить несколько проверок правильности вашего кода, например проверить существование вызванных методов и соответствие переданных аргументов сигнатуре или убедиться, что вы не пытаетесь получить доступ к нулевым свойствам.

Одного типа недостаточно в каждой ситуации: одну переменную можно переназначить значениям разных типов, и, таким образом, тип переменной может быть любым из типов каждого значения. Это можно выразить с помощью типа объединения, типа, созданного путем объединения всех возможных типов. Благодаря этому компилятор знает, что переменная может содержать значение любого из этих типов во время выполнения.

Вы можете использовать оператор typeof(x), чтобы определить тип любого выражения или переменной, видимый компилятором. Это может быть объединение нескольких типов. Вы также можете использовать x.class для определения типа значения во время выполнения; это никогда не будет союзом. Наконец, существует оператор x.is_a?(Type), позволяющий проверить, принадлежит ли что-либо заданному типу, что полезно для разветвления и выполнения действий по-разному. Ниже приведены некоторые примеры:


a = 10

p typeof(a) # => Int32


# Измените 'a', чтобы оно стало строкой String

a = "привет"

p typeof(a) # => String


# Возможно, 'a' изменится на Float64

if rand(1..2) == 1

  a = 1.5

  p typeof(a) # => Float64

end


# Теперь переменная 'a' может быть либо String либо Float64

p typeof(a) # => String | Float64


# Но мы можем узнать во время выполнения, какой это тип.

if a.is_a? String

  puts "Это String"

  p typeof(a) # => String

else

  puts "Это Float64"

  p typeof(a) # => Float64

end


# Тип 'a' был отфильтрован внутри условного выражения, но не изменился.

p typeof(a) # => String | Float64


# Вы также можете использовать .class для получения типа среды выполнения

puts "It's a #{a.class}"


В Crystal каждое значение является объектом, даже примитивные типы, такие как целые числа. Объекты имеют тип, и этот тип может реагировать на вызовы методов. Все операции, которые вы выполняете над объектом, проходят через вызов какого-либо метода. Даже nil является объектом типа Nil и может реагировать на методы. Например, nil.inspect возвращает "nil".

Все переменные имеют тип или, возможно, объединение нескольких типов. Когда это объединение, оно сохраняет объект одного из типов во время выполнения. Фактический тип можно определить с помощью оператора is_a? .

Методы, доступные данному типу, всегда известны компилятору. Таким образом, попытка вызвать несуществующий метод приведет к ошибке времени компиляции, а не к исключению времени выполнения.

К счастью, у Crystal есть инструмент, который помогает нам визуализировать типы по мере их вывода. Следующий раздел проведет вас через это.

Экспериментируем с командой Crystal Play

Команда crystal play запускает Crystal Playground для воспроизведения языка с помощью вашего браузера. Он покажет результат каждой строки вместе с выведенным типом:

1. Откройте терминал и введите "crystal play”; он покажет следующее сообщение:

Listening on http://127.0.0.1:8080

2. Оставьте терминал открытым, а затем запустите этот URL-адрес в своем любимом веб-браузере. Это даст вам удобный интерфейс для начала программирования в Crystal:


Рисунок 2.1 - The Crystal playground


1. С левой стороны у вас есть текстовый редактор с некоторым кодом Crystal. Вы можете попробовать изменить код на какой-нибудь код из этой книги, чтобы получить интерактивный способ обучения.

2. С правой стороны есть поле с некоторыми аннотациями к вашему коду. Например, он покажет вам результат каждой строки рядом с типом значения, видимым компилятором.

Если вы сомневаетесь в каких-то примерах или нестандартных решениях, попробуйте их с помощью Crystal playground.

Переходя к более практичному представлению о том, как используются типы, нам нужно узнать о хранении данных в коллекциях и манипулировании ими. Они всегда печатаются на машинке для обеспечения безопасности.

Организация вашего кода по методам

При написании приложений код необходимо структурировать таким образом, чтобы его можно было повторно использовать, документировать и тестировать. Основой этой структуры является создание методов. В следующей главе мы перейдем к объектно-ориентированному программированию с классами и модулями. Метод имеет имя, может получать параметры и всегда возвращает значение (nil также является значением). Посмотрите это, например:


def leap_year?(year) divides_by_4 = (year % 4 == 0)

    divides_by_100 = (year % 100 == 0)

    divides_by_400 = (year % 400 == 0)

    

    divides_by_4 && !(divides_by_100 && !divides_by_400)

end

puts leap_year? 1900 # => false

puts leap_year? 2000 # => true

puts leap_year? 2020 # => true


Определения методов начинаются с ключевого слова def, за которым следует имя метода. В данном случае имя метода — jump_year?, включая символ вопроса. Затем, если у метода есть параметры, они будут заключены в круглые скобки. Метод всегда возвращает результат своей последней строки, в данном примере — условный результат. Типы не нужно указывать явно, они будут определяться в зависимости от использования.

При вызове метода круглые скобки вокруг аргументов не являются обязательными и часто опускаются для удобства чтения. В этом примере puts — это метод, аналогичный jump_year? и его аргумент является результатом последнего. ставит leap_year? 1900 — это то же самое, что и puts(leap_year?(1900)).

Имена методов подобны переменным и соответствуют соглашению об использовании только строчных букв, цифр и подчеркиваний. Кроме того, имена методов могут заканчиваться символами вопроса или восклицательного знака. Они не имеют специального значения в языке, но обычно применяются в соответствии со следующим соглашением:

• Метод, заканчивающийся на ? может указывать на то, что метод проверяет какое-то условие и возвращает значение Bool. Он также часто используется для методов, которые возвращают объединение некоторого типа и Nil для обозначения состояния сбоя.

• Метод, заканчивающийся на ! указывает на то, что выполняемая им операция в некотором роде «опасна», и программисту следует быть осторожным при ее использовании. Иногда может существовать «более безопасный» вариант метода с тем же именем, но без ! символ.

Методы могут основываться на других методах. Посмотрите это, например:


def day_count(year)

    leap_year?(year) ? 366 : 365

end


Методы могут быть перегружены по количеству аргументов. Посмотрите это, например:


def day_count(year, month)

    case month

    when 1, 3, 5, 7, 8, 10, 12

      31

    when 2

      leap_year?(year) ? 29 : 28

    else

      30

    end

end


В этом случае метод будет выбран в зависимости от того, как вы расставите аргументы для его вызова:


puts day_count(2020) # => 366

puts day_count(2021) # => 365

puts day_count(2020, 2) # => 29


Внутри методов ключевое слово return можно использовать для досрочного завершения выполнения метода, при необходимости доставляя значение вызывающему методу. Последнее выражение в теле метода ведет себя как неявный возврат. Чаще всего он используется внутри условий для исключительных путей. Посмотрите это, например:


def day_count(year, month)

    if month == 2

      return leap_year?(year) ? 29 : 28

    end


    month.in?(1, 3, 5, 7, 8, 10, 12) ? 31 : 30

end


Поскольку типы могут быть опущены при объявлении метода, типы параметров определяются при вызове метода. Посмотрите это, например:


def add(a, b) # 'a' and 'b' could be anything.

    a + b

end


p add(1, 2) # Here they are Int32, prints 3.

p add("Crys", "tal") # Here they are String, prints "Crystal".


# Let's try to cause issues: 'a' is Int32 and 'b' is String.

p add(3, "hi")

# => Error: no overload matches 'Int32#+' with type String


Каждый раз, когда метод вызывается с другим типом, генерируется его специализированная версия. В этом примере один и тот же метод можно использовать для сложения чисел и объединения строк. Его нельзя путать с динамической типизацией: в каждом варианте метода параметр a имеет известный тип.

В третьем вызове он пытается вызвать add с Int32 и String. Опять же, для этих типов создается новая специализированная версия add, но теперь она не будет работать, поскольку a + b не имеет смысла при смешивании чисел и текста.

Отсутствие указания типов допускает использование шаблона ввода «утка». Говорят, что если оно ходит как утка и крякает как утка, то это, должно быть, утка. В этом контексте, если типы, переданные в качестве аргументов, поддерживают выражение a + b, то они будут разрешены, потому что это все, о чем заботится реализация, даже если они относятся к типу, никогда ранее не встречавшемуся. Этот шаблон может быть полезен для предоставления более общих алгоритмов и поддержки неожиданных вариантов использования.

Добавление ограничений типа

Отсутствие типов — не всегда лучший вариант. Вот несколько преимуществ указания типов:

• Сигнатуру метода с типами легче понять, особенно в документации.

• Для разных типов можно добавлять перегрузки с разными реализациями.

• Если вы допустили ошибку и вызвали какой-либо метод с неправильным типом, сообщение об ошибке будет более четким при вводе параметров.


Crystal имеет специальную семантику для указания типов: можно ограничить типы, которые может принимать параметр. При вызове метода компилятор проверяет, соответствует ли тип аргумента ограничению типа параметра. Если да, то для этого типа будет создана специализированная версия метода. Вот некоторые примеры:


def show(value : String)

    puts "The string is '#{value}'"

end


def show(value : Int)

    puts "The integer is #{value}"

end


show(12) # => The integer is 12

show("hey") # => The string is 'hey'

show(3.14159) # Error: no overload matches 'show' with type Float64


x = rand(1..2) == 1 ? "hey" : 12

show(x) # => Either "The integer is 12" or "The string is 'hey'"


Параметр можно ограничить типом, написав его после двоеточия. Обратите внимание, что пробел до и после двоеточия обязателен. Типы будут проверяться всякий раз, когда метод вызывается для обеспечения корректности. Если предпринята попытка вызвать метод с недопустимый тип, он будет обнаружен во время компиляции и выдаст правильное сообщение об ошибке.

В этом примере вы также видите тип Int. Это объединение всех целочисленных типов и особенно полезен при ограничениях. Вы также можете использовать другие союзы.

Последняя строка показывает концепцию множественной диспетчеризации в Crystal: если аргумент вызова тип объединения (в данном случае Int32 | String), и метод имеет несколько перегрузок, компилятор сгенерирует код для проверки фактического типа во время выполнения и выбора правильного реализация метода.

Мультидиспетчеризация также произойдет в иерархии типов, если выражение аргумента имеет абстрактный родительский тип, и для каждого возможного конкретного типа определен метод. В следующей главе вы узнаете больше об определении иерархии типов.

Ограничение типа аналогично аннотациям типов в большинстве других языков, где вы укажите фактический тип параметра. Но в Crystal нет аннотаций типов. Здесь важно слово «ограничение»: ограничение типа служит для ограничения возможных типы приемлемы. Фактический тип по-прежнему исходит из места вызова. Посмотрите это, например:


def show_type(value : Int | String)

    puts "Compile-time type is #{typeof(value)}."

    puts "Runtime type is #{value.class}."

    puts "Value is #{value}."

end


show_type(10)

# => Compile-time type is Int32.

# => Runtime type is Int32.

# => Value is 10.


x = rand(1..2) == 1 ? "hello" : 5_u8

show_type(x)

# => Compile-time type is (String | UInt8).

# => Runtime type is String.

# => Value is hello.


Интересно видеть, что тело метода всегда специализировано для типов, используемых в вызывайте сайт, не требуя проверок во время выполнения или какого-либо динамизма. Это часть того, что делает Кристалл очень быстрый язык.

Вы также можете применить ограничения типа к возвращаемому типу метода; это будет гарантировать, что метод ведет себя так, как ожидалось, и выдает правильные данные. Посмотрите это, например:


def add(a, b) : Int

    a + b

end


add 1, 3 # => 4

add "a", "b" # Error: method top-level add must return Int but it is returning String

Здесь вариант строки не удастся скомпилировать, поскольку a + b создаст строку, но метод ограничен возвратом Int. Помимо типа, параметры также могут иметь значения по умолчанию.

Значения по умолчанию

Методы могут иметь значения по умолчанию для своих аргументов; это способ пометить их как необязательные. Для этого укажите значение после имени параметра, используя символ равенства. Посмотрите это, например:


def random_score(base, max = 10)

    base + rand(0..max)

end

p random_score(5) # => Some random number between 5 and 15.

p random_score(5, 5) # => Some random number between 5 and 10.


Вы можете использовать значение по умолчанию, если метод имеет наиболее распространенное значение, но вы все равно хотите, чтобы при необходимости можно было передавать разные значения. Если параметров много со значениями по умолчанию рекомендуется давать им имена.

Именованные параметры

Когда метод вызывается с множеством аргументов, иногда может быть непонятно, что означает каждый из них. Чтобы улучшить это, параметры могут быть названы в месте вызова. Вот пример:

# These are all the same:

p random_score(5, 5)

p random_score(5, max: 5)

p random_score(base: 5, max: 5)

p random_score(max: 5, base: 5)


Все четыре вызова делают одно и то же, но чем более подробным является вызов, тем легче становится понять, что означает каждый из пяти. Кроме того, вы можете изменить порядок аргументов при использовании именованных параметров.

В некоторых случаях имеет смысл заставить некоторые параметры всегда именоваться. Например, предположим, что у нас есть метод, который возвращает время открытия магазина. Ему необходимо знать, является ли этот день выходным и является ли он частью выходных:


def store_opening_time(is_weekend, is_holiday)

   if is_holiday

       is weekend ? nil : "8:00"

   else

       is_weekend ? "12:00" : "9:00"

   end

end


В этой реализации нет ничего необычного. Но если вы начнете его использовать, все быстро станет очень запутанным:


p store_opening_time(true, false) # What is 'true' and 'false' here?


You can call the same method while specifying the name of each parameter for clarity:


p store_opening_time(is_weekend: true, is_holiday: false)


Чтобы принудительно дать имена некоторым параметрам, добавьте перед ними символ *. Все, что слева от *, будет позиционными параметрами, а все, что справа, всегда будет именованными параметрами. Они также могут иметь значения по умолчанию:


def store_opening_time(*, is_weekend, is_holiday)

    # ...

end


p store_opening_time(is_weekend: true, is_holiday: false)

p store_opening_time(is_weekend: true, is_holiday: false)


p store_opening_time(true, false) # Invalid!


Имейте в виду, что именованные параметры можно использовать всегда, даже если они не являются обязательными.

Внешние и внутренние имена параметров

Иногда параметр может иметь имя, которое имеет большой смысл в качестве описания аргумента для вызывающего объекта, но может звучать странно при использовании в качестве переменной в теле реализации метода. Crystal позволяет вам определить внешнее имя (видимое для вызывающего объекта) и внутреннее имя (видимое для реализации метода). По умолчанию они одинаковы, но это не обязательно. Посмотрите это, например:


def multiply(value, *, by factor, adding term = 0)

   value * factor + term

end


p multiply(3, by: 5) # => 15

p multiply(2, by: 3, adding: 10) # => 16


Этот метод принимает два или три параметра. Первый называется значением и является позиционным параметром, то есть его можно вызывать без указания имени. Следующие два параметра названы из-за символа *. Второй параметр имеет внешнее имя by и внутреннее имя фактора. Третий и последний параметр имеет добавление внешнего имени и термин внутреннего имени. Он также имеет значение по умолчанию 0, поэтому это необязательно. Эту функцию можно использовать для того, чтобы сделать вызов методов с именованными параметрами более естественным.

Передача блоков в методы

Методы являются основой для организации и повторного использования кода. Но для дальнейшего улучшения этого метода методы повторного использования также могут получать блоки кода при вызове. Внутри метода вы можете использовать ключевое слово yield для вызова полученного блока столько раз, сколько необходимо.

Определить метод, который получает блок, просто; просто используйте выход внутри него. Посмотрите это, например:


def perform_operation

    puts "before yield"

    yield

    puts "between yields"

    yield

    puts "after both yields"

end


Затем этот метод можно вызвать, передав блок кода либо вокруг do ... end, либо в фигурных скобках { ... }:


perform_operation {

    puts "inside block"

}


perform_operation do

    puts "inside block"

end


Выполнение этого кода приведет к следующему выводу:


before yield

inside block

between yields

inside block

after both yields


Вы можете видеть, что сообщение внутреннего блока происходит между операторами тела метода. Он появляется дважды, поскольку код внутри блока выполнялся при каждом выходе основного метода.

Но для хорошей работы кода внутри блока может потребоваться некоторый контекст. По этой причине блоки также могут получать аргументы и возвращаемые значения. Вот пример метода, который преобразует элементы массива во что-то другое:


def transform(list)

   i = 0

   # new_list is an Array made of whatever type the block returns

   new_list = [] of typeof(yield list[0])

   while i < list.size

       new_list << yield list[i]

       i += 1

   end

   new_list

end


numbers = [1, 2, 3, 4, 5]


p transform(numbers) { |n| n ** 2 } # => [1, 4, 9, 16, 25] p transform(numbers) { |n| n.to_s } # => ["1", "2", "3", "4", "5"]


Ключевое слово доходность ведет себя как вызов метода: вы можете передать ему аргументы, и оно вернет результат вызова блока. Параметры блока указываются между парой символов вертикальной черты (|), разделенных запятыми, если их несколько.

Вышеупомянутый метод преобразования эквивалентен методу карты, доступному для массивов:


numbers = [1, 2, 3, 4, 5]


p numbers.map { |n| n ** 2 } # => [1, 4, 9, 16, 25]

p numbers.map { |n| n.to_s } # => ["1", "2", "3", "4", "5"]


В Crystal уже определено множество других методов, использующих блоки; наиболее распространенными являются те, которые используются для перебора элементов коллекции данных.

Как и while и until, ключевые слова next и break также можно использовать внутри блоков.

Использование next внутри блока

Используйте next, чтобы остановить текущее выполнение блока и вернуться к оператору yield, который его вызвал. Если значение передается в next, yield получит выход. Посмотрите это, например:


def generate

  first = yield 1   # This will be 2

  second = yield 2  # This will be 10

  third = yield 3   # This will be 4


  first + second + third

end


result = generate do |x|

  if x == 2

     next 10

  end


  x + 1

end

p result


Метод generate вызывает полученный блок три раза, а затем вычисляет сумму результатов. Наконец, этот метод вызывается, передавая блок, который может завершиться раньше при следующем вызове. Хорошей аналогией является то, что если бы блоки были методами, ключевое слово yield действовало бы как вызов метода, а next было бы эквивалентно return.

Другой способ выйти из выполнения блока — использовать ключевое слово break.

Использование break внутри блока

Используйте break, чтобы остановить метод, вызывающий блок, действуя так, как если бы он вернулся. Расширяя тот же пример, что и раньше, посмотрите на следующее:


result = generate do |x|

   if x == 2

     break 10 # break instead of next

   end


x + 1

end

p result


В этом случае yield 1 будет равна 2, но yield 2 никогда не вернется; вместо этого метод generate будет сразу завершен, а result получит значение 10. Ключевое слово break приводит к завершению метода, вызывающего блок.

Возвращение изнутри блока

Наконец, давайте посмотрим, как ведет себя return при использовании внутри блока. Гипотеза Коллатца — это интересная математическая задача, которая предсказывает, что последовательность, в которой следующее значение вдвое превышает предыдущее, если оно четное, или в три раза больше плюс один, если оно нечетное, в конечном итоге всегда достигнет 1, независимо от того, какое начальное число выбрано.

Следующий метод collatz_sequence реализует эту последовательность, бесконечно вызывая блок для каждого элемента. Эта реализация не имеет условия остановки и может либо работать вечно, либо быть завершена раньше вызывающей стороной.

Затем следует реализация метода, который запускает collatz_sequence с некоторым начальным значением и подсчитывает, сколько шагов необходимо, чтобы достичь 1:


def collatz_sequence(n)

   while true

      n = if n.even?

      n // 2

   else

      3 * n + 1

   end

   yield n

   end

end


def sequence_length(initial)

   length = 0

   collatz_sequence(initial) do |x|

      puts "Element: #{x}"

      length += 1

      if x == 1

         return length # <= Note this 'return'

      end

  end

end


puts "Length starting from 14 is: #{sequence_length(14)}"


Метод sequence_length отслеживает количество шагов и, как только оно достигает 1, выполняет возврат. В этом случае обратите внимание, что возврат происходит внутри блока метода collatz_sequence. Ключевое слово return останавливаетвызов блока (например, next), останавливает метод, который вызвал блок с yield (например, break), но затем также останавливает метод, в котором записывается блок. Напоминаем, что return всегда завершает выполнение определения, которое находится внутри.

В этом примере кода выводится Length starting from 14 is: 17. Фактически, гипотеза Коллатца утверждает, что этот код всегда найдет решение для любого положительного целого числа. Однако это нерешенная математическая проблема.

Контейнеры данных

Crystal имеет множество встроенных контейнеров данных, которые помогут вам манипулировать и организовывать нетривиальную информацию. Наиболее распространенным на сегодняшний день является массив. Вот краткий обзор наиболее часто используемых контейнеров данных в Crystal:

Array (Массив) — линейный и изменяемый список элементов. Все значения будут иметь один тип, возможно, объединение.

Tuple (Кортеж) — линейный и неизменяемый список элементов, в котором точный тип каждого элемента сохраняется и известен во время компиляции.

Set (Набор) — уникальная и неупорядоченная группа элементов. Значения никогда не повторяются, и при перечислении значения отображаются в том порядке, в котором они были вставлены (без дубликатов).

Hash (Хэш) — уникальная коллекция пар ключ-значение. Значения можно получить по их ключам и перезаписать, обеспечивая уникальность ключей. Как и Set, он нумеруется в порядке вставки.

NamedTuple — неизменяемая коллекция пар ключ-значение, где каждый ключ известен во время компиляции, а также тип каждого значения.

Deque — изменяемый и упорядоченный список элементов, предназначенный для использования либо в виде структуры стека (FIFO, или First In First Out), либо в качестве структуры очереди (FILO, или First In Last Out). Он оптимизирован для быстрой вставки и удаления на обоих концах.

Далее давайте подробнее рассмотрим некоторые из этих типов контейнеров.

Массивы и кортежи

Вы можете выразить некоторые простые данные с помощью чисел и текста, но вам быстро понадобится собрать больше информации в списки. Для этого вы можете использовать массивы и кортежи. Массив — это динамический контейнер, который может увеличиваться, сжиматься и изменяться во время выполнения программы. С другой стороны, кортеж статичен и неизменяем; его размер и типы элементов известны и фиксируются во время компиляции:


numbers = [1, 2, 3, 4] # This is of type Array(Int32)

numbers << 10

puts "The #{numbers.size} numbers are #{numbers}"

   # => The 5 numbers are [1, 2, 3, 4, 10]


С массивами нельзя смешивать разные типы, если они не были указаны при создании массива. Эти ошибки обнаруживаются во время сборки; они не являются исключениями во время выполнения. Посмотрите это, например:


numbers << "oops"

   # Error: no overload matches 'Array(Int32)#<<' with type String


Используя типы объединения, вы можете иметь массивы, в которых сочетаются более одного типа, либо инициализируя их несколькими типами, либо явно указывая их. Вот пример:


first_list = [1, 2, 3, "abc", 40]

p typeof(first_list) # => Array(Int32 | String)

first_list << "hey!" # Ok


# Now all elements are unions:

element = first_list[0]

p element         # => 1

p element.class   # => Int32

p typeof(element) # => Int32 | String

# Types can also be explicit:

second_list = [1, 2, 3, 4] of Int32 | String

p typeof(second_list) # => Array(Int32 | String)

second_list << "hey!" # Ok


# When declaring an empty array, an explicit type is mandatory:

empty_list = [] of Int32


Внутри массива все значения имеют один и тот же тип; значения разных типов при необходимости расширяются до объединения типов или общего предка. Это важно, поскольку массивы изменяемы, и значение по заданному индексу можно свободно заменить чем-то другим.

Тип Array реализует стандартные модули Indexable, Enumerable и Iterable, предоставляя несколько полезных методов для исследования коллекции и управления ею.

Кортеж похож на массив в том смысле, что он хранит ряд элементов в упорядоченном виде. Два основных различия заключаются в том, что кортежи являются неизменяемыми после их создания и что исходный тип каждого элемента сохраняется без необходимости объединения:


list = {1, 2, "abc", 40}

p typeof(list) # => Tuple(Int32, Int32, String, Int32)


element = list[0]

p typeof(element) # => Int32


list << 10 # Invalid, tuples are immutable.


Поскольку кортежи неизменяемы, они используются не так часто, как массивы.

И массивы, и кортежи имеют несколько полезных методов. Вот некоторые из наиболее распространенных:

Таблица 2.7 – Общие операции с контейнерами Array и Tuple
Операция Описание
list [index] Считывает элемент по заданному индексу. Вызывает ошибку времени выполнения, если этот индекс выходит за пределы. Если список представляет собой кортеж, а индекс — целое число, ошибка выхода за пределы будет обнаружена во время компиляции.
list[index]? Аналогично list [index], но возвращает ni1, если индекс выходит за пределы.
list.size Возвращает количество элементов внутри кортежа или массива.
array[index] = value Заменяет значение по заданному индексу или повышает, если индекс выходит за пределы. Поскольку кортежи неизменяемы, это доступно только для массивов.
array << value array.push(value) Добавляет новое значение в конец массива, увеличивая его размер на единицу.
array.pop array.pop? Удаляет и возвращает последний элемент массива. В зависимости от варианта он может поднимать или возвращать ноль в пустых массивах.
array.shift array.shift? Аналогично pop, но удаляет и возвращает первый элемент массива, уменьшая его размер на единицу.
array.unshift(value) Добавляет новое значение в начало массива, увеличивая его размер на единицу. Это противоположность сдвигу.

Операция Описание
array.sort Реорганизует элементы массива, чтобы обеспечить их упорядоченность. Другой полезный вариант — сортировка по методу, при которой для получения критериев сортировки требуется блок. Первый вариант возвращает отсортированную копию массива, а второй сортирует на месте.
array.sort!
array.shuffle array.shuffle! Реорганизует элементы массива случайным образом. Все перестановки имеют одинаковую вероятность. Первый вариант возвращает перетасованную копию массива; второй шаркает на месте.
list.each do el puts el Перебирает элементы коллекции. Порядок сохранен.
end
list.find do el Возвращает первый элемент массива или кортежа, соответствующий заданному условию. Если ни одно не соответствует, возвращается nil.
   el > 3
end
list.map do el Преобразует каждый элемент списка, применяя к нему блок, возвращая новую коллекцию (массив или кортеж) с новыми элементами в том же порядке. У массива также есть карта! метод, который изменяет элементы на месте.
   el + 1
end
list.select do el Возвращает новый массив, отфильтрованный по условию в блоке. Если ни один элемент не соответствует, массив будет пустым. Существует также функция reject, которая выполняет противоположную операцию, фильтруя несовпадающие элементы. Для массивов доступны варианты на месте путем добавления ! к имени метода.
   el > 3
end
Не все данные упорядочены или последовательны. Для них существуют другие контейнеры данных, например хэш.

Хэш

Тип Hash представляет собой сопоставление ключей словаря со значениями. Ключи могут иметь любой тип, то же самое касается и значений. Единственное ограничение состоит в том, что каждый ключ может иметь только одно значение, хотя само значение может быть другим контейнером данных, например массивом.

Буквальный хэш создается как список пар ключ-значение внутри фигурных скобок ({...}). Ключ отделяется от значения символом =>. Например, вот самая большая численность населения в мире по странам, по данным Worldometer:


population = {

    "China" => 1_439_323_776,

    "India" => 1_380_004_385,

    "United States" => 331_002_651,

    "Indonesia" => 273_523_615,

    "Pakistan" => 220_892_340,

    "Brazil" => 212_559_417,

    "Nigeria" => 206_139_589,

    "Bangladesh" => 164_689_383,

    "Russia" => 145_934_462,

    "Mexico" => 128_932_753,

}


Переменная населения имеет тип Hash(String, Int32) и состоит из 10 элементов.

Типы ключей и значений выводятся из использования, но если вам нужно объявить пустой хэш, типы необходимо будет указать явно, как и массивы:


population = {} of String => Int32


Хэши — это изменяемые коллекции, в которых есть несколько операторов для запроса и управления ими. Вот некоторые распространенные примеры:

Таблица 2.8 – Общие операции с хеш-контейнерами
Операция Описание
hash[key] Считывает значение по заданному ключу. Если ключ не существует, это вызовет ошибку времени выполнения. Например, население ["India"] составляет 1380004385 человек.
hash[key]? Считывает значение по заданному ключу, но если ключ не существует, вместо выдачи ошибки возвращается ni 1. Например, население ["India"]? 13 8 00 043 8 5 и население ["Mars"] ? равен nil.
Hash [key] = value Заменяет значение данного ключа, если оно существует. В противном случае к хешу добавляется новая пара ключ-значение.

Операция Описание
hash.delete(key) Находит и удаляет пару, определенную данным ключом. Если он был найден, возвращается удаленное значение; в противном случае возвращается nil.
hash.each { k, v p k, v } Перебирает элементы, хранящиеся в хеше. Перечисление следует порядку, в котором были вставлены ключи. Вот пример:
hash.each key { к population.each do country, pop puts "#{country} has {pop}
P к } people."
hash.each value { End
|v| p v }
hash.has key?(key) Проверяет, существует ли данный ключ или значение в хеш-структуре.
hash.has value?(val)
hash.key for(value) Находит пару с заданным значением и возвращает ее ключ. Эта операция является дорогостоящей, поскольку ей приходится искать все пары одну за другой.
hash.key for?(value)
hash.keys Создает массив всех ключей или массив всех значений хеша.
hash.values

В качестве интересной задачи давайте посмотрим, как получить общую численность населения всех стран вместе взятых. Мы можем использовать метод значений, чтобы получить массив счетчиков населения, а затем вызвать метод суммы для этого массива, чтобы агрегировать его:


puts "Total population: #{population.values.sum}"


Если вы попробуете этот код, вы увидите, что он не работает со следующим сообщением об ошибке:


Unhandled exception: Arithmetic overflow (OverflowError)


Проблема в том, что популяции — это экземпляр Hash(String, Int32), и поэтому вызов значений в нем приведет к созданию экземпляра Array(Int32). Если сложить эти значения, получится 4 503 002 371, но давайте напомним себе, что экземпляр Int32 может представлять только целые числа от -2 147 483 648 до 2 147 483 647.

Результат выходит за пределы этого диапазона и не помещается в экземпляр Int32. В этих случаях Crystal не выполнит операцию вместо автоматического повышения целочисленного типа или предоставления неверных результатов.

Одним из решений было бы с самого начала хранить счетчики населения как Int64, указав тип, как если бы мы делали это с пустым хешем:


population = {

    "China" => 1_439_323_776,

    "India" => 1_380_004_385,

    # ...

    "Mexico" => 128_932_753,

} of String => Int64


Другое решение — передать начальное значение методу суммы, используя правильный тип:


puts "Total population: #{population.values.sum(0_i64)}"


Теперь давайте посмотрим, как мы можем перебирать эти коллекции.

Итерация коллекций с блоками

При вызове метода можно передать блок кода, разделенный do...end. Несколько методов получают блок и работают с ним, многие из них позволяют каким-либо образом выполнять циклы. Первый пример — метод цикла. Это просто — он просто зацикливается навсегда, вызывая переданный блок:


loop do

   puts "I execute forever"

end


Это прямой эквивалент использования while true:


while true

   puts "I execute forever"

end


Два других очень полезных метода, которые берут блоки, — это times и each. Вызов times для целого числа приведет к повторению блока указанное количество раз, а вызов каждого из коллекции вызовет блок для каждого элемента:


5.times do

    puts "Hello!"

end


(10..15).each do |x|

    puts "My number is #{x}"

end


["apple", "orange", "banana"].each do |fruit|

    puts "Don't forget to buy some #{fruit}s!"

end


В предыдущем примере показано, как можно использовать блоки для обхода некоторой коллекции. При написании кода Crystal это предпочтительнее, чем итерация с помощью цикла while. Несколько методов из стандартной библиотеки принимают блок: мы видели каждый, но есть также карта для преобразования каждого элемента во что-то другое, выбора или отклонения для фильтрации элементов на основе некоторого условия и сокращения для вычисления значения на основе каждого элемента.

Синтаксис короткого блока

Очень частым случаем является вызов метода, передающего блок, имеющий только один аргумент, а затем вызов метода для этого аргумента. Например, предположим, что у нас есть массив строк, и мы хотим преобразовать их все в заглавные буквы. Вот три способа написать это:


fruits = ["apple", "orange", "banana"]


# (1) Prints ["APPLE", "ORANGE", "BANANA"]

    p(fruits.map do |fruit| fruit.upcase

end)


# (2) Same result, braces syntax

p fruits.map { |fruit| fruit.upcase }


# (3) Same result, short block syntax

p fruits.map &.upcase


В первом фрагменте (1) использовался метод карты вместе с блоком do... end. Метод map выполняет итерацию по массиву, передавая блок для каждого элемента и создавая новый массив с результатом блока. В этом первом примере необходимы круглые скобки, поскольку do...end блоки подключаются к самому внешнему методу, в данном случае p.

Второй фрагмент (2) использует синтаксис { ... } и может опускать круглые скобки, поскольку этот блок подключается к ближайшему вызову метода. Обычно синтаксис { ... } записывается в одну строку, но это не обязательно.

Наконец, мы видим синтаксис коротких блоков в третьем фрагменте (3). Написание &.foo аналогично использованию { |x| x.foo }. Его также можно записать как p fruits.map(&.upcase), как если бы блок был общим аргументом вызова метода.

Отличается только синтаксис; поведение и семантика всех трех фрагментов одинаковы. Обычно везде, где это возможно, используется синтаксис коротких блоков.

Контейнер Tuple также отображается в определениях методов при использовании параметров splat.

Параметры сплата (Splat)

Метод можно определить так, чтобы он принимал произвольное количество аргументов, используя параметры splat. Это делается путем добавления символа * перед именем параметра: теперь при вызове метода он будет ссылаться на кортеж с нулевым или более значениями аргументов. Посмотрите это, например:


def get_pop(population, *countries)

    puts "Requested countries: #{countries}"

    countries.map { |country| population[country] }

end


puts get_pop(population, "Indonesia", "China", "United States")


Этот код даст следующий результат:


Requested countries: {"Indonesia", "China", "United States"}

{273523615, 1439323776, 331002651}


Использование splat всегда будет создавать кортежи правильных типов, как если бы метод имел такое количество обычных позиционных параметров. В этом примере typeof(countries) будет Tuple(String, String, String); тип будет меняться при каждом использовании. Параметры Splat — наиболее распространенный вариант использования кортежей.

Организация вашего кода в файлах

Написание кода в одном файле подходит для некоторых быстрых тестов или очень небольших приложений, но все остальное в конечном итоге придется организовывать в нескольких файлах. Всегда существует основной файл, который вы передаете команде crystal run или crystal build, но этот файл может ссылаться на код в других файлах с ключевым словом require. Компиляция всегда начинается с анализа этого основного файла, а затем рекурсивного анализа любого файла, на который он ссылается, и так далее.

Разберем пример:

1. Сначала создайте файл с именем Factorial.cr:


def factorial(n)

    (1..n).product

end


2. Затем создайте файл с именем program.cr:


require "./factorial"


(1..10).each do |i|

    puts "#{i}! = #{factorial(i)}"

end


В этом примере require «./factorial» будет искать файл с именем factorial.cr в той же папке, что и program.cr, и импортируйте все, что он определяет. Невозможно выбрать только часть того, что определяют необходимые файлы; требуют импорта всего последовательно. Запустите этот пример с помощью crystal run program.cr.

Один и тот же файл не может быть импортирован дважды; компилятор Crystal проверит и проигнорирует такие попытки.

Вам могут потребоваться файлы двух типов: это либо файл из вашего проекта — в этом случае для ссылки на него используется относительный путь, начинающийся с расширения . - или это файл библиотеки, взятый из стандартной библиотеки или из установленной вами зависимости. В этом случае имя используется напрямую, без относительного пути.

require "./filename"

Начальный параметр ./ сообщает Crystal искать этот файл в текущем каталоге относительно текущего файла. Он будет искать файл с именем filename.cr или каталог с именем filename, в котором находится файл с именем filename.cr. Вы также можете использовать ../ для ссылки на родительский каталог.

Также поддерживаются шаблоны Glob для импорта всех файлов из заданного каталога, как здесь:


require "./commands/*"


Это импортирует все файлы Crystal в каталог команд. Импорт всего из текущего каталога также допустим:


require


Эта нотация используется в первую очередь для ссылки на файлы из вашего собственного проекта. При ссылке на файлы из установленной библиотеки или стандартной библиотеки Crystal путь не начинается с расширения ..

require "filename"

Если путь не начинается ни с ./, ни с ../, это должна быть библиотека. В этом случае компилятор будет искать файл в стандартной библиотеке и в папке lib, куда установлены зависимости проекта. Посмотрите это, например:


require "http/server" # Imports the HTTP server from stdlib.


Server = HTTP::Server.new do |context|

    context.response.content_type = "text/plain"

    context.response.print "Hello world, got

    #{context.request.path}!"

end


puts "Listening on http://127.0.0.1:8080" server.listen(8080)


Для чего-либо большего, чем пара сотен строк, предпочтительнее разделить код и организовать его в файлах, каждый из которых имеет определенную цель или область применения. Таким образом, легче найти ту или иную часть приложения.

Резюме

В этой главе представлено несколько новых концепций, которые помогут вам приступить к написанию реальных приложений Crystal. Вы узнали об основных типах значений (числа, текст, диапазоны и логические значения), о том, как определять переменные для хранения данных и управления ими, а также о том, как управлять потоком выполнения с помощью условных операторов и циклов. Вы рассмотрели создание методов повторного использования кода различными способами. Наконец, вы узнали о сборе данных с помощью Array и Hash, а также об использовании блоков и параметров splat. Это набор инструментов, который вы будете использовать до конца книги.

В последующих главах мы начнем применять эти знания в практических проектах. Далее давайте воспользуемся возможностями объектной ориентации Crystal для создания масштабируемого программного обеспечения.

Дальнейшее чтение

Некоторые языковые детали были опущены, чтобы сделать текст кратким и целенаправленным. Однако вы можете найти документацию и справочные материалы по всему, что здесь объясняется более подробно, на веб-сайте Crystal по адресу https://crystal-lang.org/docs/.

3. Объектно-ориентированное программирование

Как и многие другие, Crystal — объектно-ориентированный язык. Таким образом, в нем есть объекты, классы, наследование, полиморфизм и так далее. Эта глава познакомит вас с возможностями Crystal по созданию классов и работе с объектами, а также познакомит вас с этими концепциями. Crystal во многом вдохновлен Ruby, который сам по себе многое заимствует из языка Small Talk, известного своей мощной объектной моделью.

В этой главе мы рассмотрим следующие основные темы:

• Понятие объектов и классов

• Создание собственных классов.

• Работа с модулями

• Значения и ссылки — использование структур.

• Общие классы

• Исключения

Технические требования

Для выполнения задач этой главы вам понадобится следующее:

• Рабочая установка Кристалла.

• Текстовый редактор, настроенный для использования Crystal.

Инструкции по настройке Crystal см. в Главе 1 «Введение в Crystal» и в Приложении A «Настройка инструментов» для инструкций по настройке текстового редактора для Crystal.

Вы можете найти весь исходный код этой главы в репозитории этой книги на GitHub по адресу https://github.com/PacktPublishing/Crystal-Programming/tree/main/Chapter03.

Понятие объектов и классов

Объекты содержат внутри себя некоторый объем данных и управляют доступом и поведением вокруг этих данных. Они подобны актерам, взаимодействующим с другими объектами посредством вызова методов и обмена данными в строго определенном интерфейсе. Ни одному объекту не разрешается напрямую вмешиваться во внутреннее состояние другого объекта — все взаимодействие определяют методы.

Классы — это чертежи, на основе которых создаются объекты. Каждый объект является экземпляром некоторого класса. Класс определяет структуру данных, доступные методы, поведение и внутреннюю реализацию. Класс объекта часто называют его типом: каждый объект имеет тип.

В Crystal все является объектом: каждое значение, с которым вы взаимодействуете, имеет тип (то есть класс) и методы, которые вы можете вызывать. Числа — это объекты, строки — это объекты — даже nil является объектом класса Nil и имеет методы. Вы можете запросить класс объекта, вызвав для него метод .class:


p 12.class # => Int32

p "hello".class # => String

p nil.class # => Nil

p true.class # => Bool

p [1, 2, "hey"].class # => Array(Int32 | String)


В предыдущем примере вы можете видеть, что существуют более сложные классы, такие как массив, состоящий из целочисленных и строковых элементов. Не волнуйтесь, мы рассмотрим их в последнем разделе этой главы.

Каждый класс предоставляет некоторые методы объектам, которые являются его экземплярами. Например, все экземпляры класса String имеют метод size, который возвращает количество символов строки в виде объекта типа Int32. Аналогично, объекты типа Int32 имеют метод с именем +, который принимает другое число в качестве единственного аргумента и возвращает его сумму, как показано в следующем примере:


p "Crystal".size + 4 # => 11


Это то же самое, что и более явная форма:


p("Crystal".size().+(4)) # => 11


Это показывает, что все распространенные операторы и свойства — это просто вызовы методов.

Некоторые классы не имеют буквального представления, и объекты необходимо создавать непосредственно с использованием имени класса. Ниже приведен пример:


file = File.new("some_file.txt")

puts file.gets_to_end

file.close


Здесь file — это объект типа File, показывающий, как можно открыть файл, прочитать все его содержимое, а затем закрыть его. Новый метод вызывается в File для создания нового экземпляра класса. Этот метод получает строку в качестве аргумента и возвращает новый объект File, открывая указанный файл. Отсюда внутренняя реализация этого файла в памяти скрыта и взаимодействовать с ним можно только вызовом других методов. get_to_end затем используется для получения содержимого файла в виде строки, а метод close используется для закрытия файла и освобождения некоторых ресурсов.

Предыдущий пример можно упростить, используя вариант блока, который автоматически закрывает файл после его использования:


File.openCsome_file.txt") do |file|

    puts file.gets_to_end

end


В предыдущем фрагменте методу open передается блок, который получает в качестве аргумента файл (тот же, который возвращает new). Блок выполняется, а затем файл закрывается.

Возможно, вы заметили, что так же, как этот код вызывает метод gets_to_end объекта file, он также вызывает метод open класса File. Ранее вы узнали, что методы — это то, как мы общаемся с объектами, так почему же они используются и для взаимодействия с классом? Это очень важная деталь, о которой следует помнить: в Crystal все является объектами, даже классы. Все классы являются объектами типа Class, и их можно присваивать переменным точно так же, как простые значения:


p 23.class    # => Int32

p Int32.class # => Class


num = 10

type = Int32

p num.class == type # => true


p File.new("some_file.txt") # => #<File:some_file.txt>

file_class = File

p file_class.newCsome_file.txt") # => #<File:some_file.txt>


Теперь вы знаете, что примитивные значения — это объекты, экземпляры более сложных типов из классов стандартной библиотеки — это объекты, и что сами классы тоже являются объектами. Каждый объект имеет внутреннее состояние и раскрывает методы мышления поведения. Переменные используются для хранения этих объектов.

Хотя Crystal поставляется со многими полезными классами, и вы можете установить больше из внешних зависимостей, вы можете создавать свои собственные классы для всего, что вам нужно. Мы рассмотрим это в следующем разделе.

Создание собственных классов

Классы описывают поведение объектов. Приятно узнать, что стандартные типы, поставляемые с Crystal, по большей части представляют собой обычные классы, которые вы могли бы реализовать самостоятельно. Кроме того, вашему приложению понадобятся еще несколько специализированных классов, поэтому давайте их создадим.

Новые классы создаются с помощью ключевого слова class, за которым следует имя, а затем определение класса. Следующий минимальный пример:


class Person

end


person1 = Person.new

person2 = Person.new


В этом примере создается новый класс с именем Person, а затем два экземпляра этого класса — два объекта. Этот класс пуст — он не определяет никаких методов или данных, но классы Crystal по умолчанию имеют некоторую функциональность:


p person1   # You can display any object and inspect it

p person1.to_s # Any object can be transformed into a String

p person1 == person2  # false. By default, compares by reference.

p person1.same?(person2) # Also false, same as above.

p person1.nil?  # false, person1 isn't nil.

p person1.is_a?(Person) # true, person1 is an instance of Person.


Внутри класса вы можете определять методы так же, как и методы верхнего уровня. Один из таких методов особенный: метод initialize. Он вызывается всякий раз, когда создается новый объект, чтобы инициализировать его в исходное состояние. Данные, хранящиеся внутри объекта, хранятся в переменных экземпляра; они подобны локальным переменным, но они используются всеми методами класса и начинаются с символа @. Вот более полный класс Person:


class Person

   def initialize(name : String)

      @name = name

      @age = 0

   end


   def age_up

      @age += 1

   end


   def name

      @name

   end


   def name=(new_name)

      @name = new_name

   end

end


Здесь мы создали более реалистичный класс Person с внутренним состоянием, состоящим из @name, String, @age и Int32. В классе есть несколько методов, которые взаимодействуют с этими данными, включая метод initialize, который создаст нового человека — ребенка.

Теперь давайте воспользуемся этим классом:


jane = Person.new("Jane Doe")

p jane # => #<Person:0x7f97ae6f3ea0 @name="Jane Doe", # @age=0>

jane.name = "Mary"

5.times { jane.age_up }

p jane # => #<Person:0x7f97ae6f3ea0 @name="Mary", @age=5>


В этом примере создается экземпляр Person путем передачи строки новому методу. Эта строка используется для инициализации объекта и в конечном итоге присваивается переменной экземпляра @name. По умолчанию объекты можно проверять с помощью метода верхнего уровня p, который показывает имя класса, адрес в памяти и значение переменных экземпляра. Следующая строка вызывает метод name=(new_name) — он может делать что угодно, но для удобства он обновляет переменную @name новым значением. Затем мы вызываем age_up пять раз и снова проверяем объект. Здесь вы должны увидеть новое имя и возраст человека.

Обратите внимание, что в методе initialize мы явно указываем тип аргумента имени вместо того, чтобы позволить компилятору определить его на основе использования. Здесь это необходимо, поскольку типы переменных экземпляра должны быть известны только из класса и не могут быть выведены из использования. Вот почему нельзя сказать, что Crystal имеет механизм вывода глобального типа.

Теперь давайте углубимся в то, как можно определять методы и переменные экземпляра.

Манипулирование данными с использованием переменных и методов экземпляра

Все данные внутри объекта хранятся в переменных экземпляра; их имена всегда начинаются с символа @. Существует несколько способов определить переменную экземпляра для класса, но одно правило является фундаментальным: их тип должен быть известен. Тип может быть либо указан явно, либо синтаксически выведен компилятором.

Начальное значение переменной экземпляра может быть задано либо внутри метода initialize, либо непосредственно в теле класса. В последнем случае он ведет себя так, как если бы переменная была инициализирована в начале метода initialize. Если переменная экземпляра не назначена ни в одном методе initialize, ей неявно присваивается значение nil.

Тип переменной будет определяться из каждого присвоения ей в классе, из всех методов. Но имейте в виду, что их тип может зависеть только от литеральных значений или типизированных аргументов и больше ни от чего. Давайте посмотрим несколько примеров:


class Point

    def initialize(@x : Int32, @y : Int32)

    end

end

origin = Point.new(0, 0)


В этом первом случае класс Point указывает, что его объекты имеют две целочисленные переменные экземпляра. Метод initialize будет использовать свои аргументы, чтобы предоставить им начальное значение:


class Cat

    @birthday = Time.local

    

    def adopt(name : String)

        @name = name

    end

end


my_cat = Cat.new

my_cat.adopt("Tom")


Теперь у нас есть класс, описывающий кошку. У него нет метода initialize, поэтому он ведет себя так, как если бы он был пустым. Переменная @birthday назначается Time.local. Это происходит внутри этого пустого метода initialize при создании нового экземпляра объекта. Предполагается, что тип является экземпляром Time, поскольку Time.local вводится так, чтобы всегда возвращать его. Переменная @name получает строковое значение из типизированного аргумента, но нигде не имеет начального значения, поэтому ее тип — String? (это также можно представить как String | Nil).

Обратите внимание, что выведение переменной экземпляра из аргумента работает только в том случае, если параметр указан явно, а переменной экземпляра присваивается непосредственно значение. Следующий пример недействителен:


class Person

  def initialize(first_name, last_name)

    @name = first_name + " " + last_name

  end

end


person = Person.new("John", "Doe")


В этом примере переменная @name создается путем объединения двух аргументов с пробелами между ними. Здесь тип этой переменной невозможно определить без более глубокого анализа типов двух параметров и результата вызова метода +. Даже если бы аргументы были явно типизированы как String, информации все равно было бы недостаточно, поскольку метод + для строк может быть переопределен где-то в коде, чтобы возвращать какой-либо другой произвольный тип. В подобных случаях необходимо объявить тип переменной экземпляра:


class Person

  @name : String

  def initialize(first_name, last_name)

    @name = first_name + " " + last_name

  end

end


В качестве альтернативы можно использовать буквальную интерполяцию строки, поскольку она гарантированно всегда создает строку:


class Person

  def initialize(first_name, last_name)

    @name = "#{first_name} #{last_name}"

  end

end


В любой ситуации допускается явное объявление типа переменной экземпляра, возможно, для ясности.


Примечание
Вы можете задаться вопросом, почему компилятор не анализирует всю программу и каждый вызов метода, чтобы самостоятельно определить типы каждой переменной экземпляра, как он уже делает это для локальных переменных? Компилятор делал именно это в первые дни, но эта функция была удалена, поскольку этот анализ был слишком дорогим с точки зрения производительности и делал инкрементальную компиляцию невозможной в будущем. Существующие правила вывода переменных экземпляра в большинстве случаев успешны, и их редко приходится вводить.


Переменные экземпляра представляют частное состояние объекта, и ими следует манипулировать только с помощью методов внутри класса. Их можно раскрыть через геттеры и сеттеры. Доступ к переменным экземпляра можно получить извне с помощью синтаксиса obj.@ivar, но это не рекомендуется.

Создание геттеров и сеттеров

В Crystal нет специальной концепции метода получения или установки свойств объекта; вместо этого они построены на основе функций, о которых мы уже узнали. Допустим, у нас есть человек, у которого есть переменная экземпляра имени:


class Person

    def initialize(@name : String)

    end

end


Мы уже можем создать нового человека и проверить его:


person = Person.new("Tony")

p person


Но было бы неплохо иметь возможность написать что-то вроде следующего, как если бы @name был доступен:


puts "My name is #{person.name}"


person.name — это просто вызов метода name объекта person. Помните, что круглые скобки необязательны для вызовов методов. Мы можем пойти дальше и создать именно этот метод:


class Person

    def name

        @name

    end

end


Теперь вызов person.name действителен, как если бы переменная экземпляра была доступна извне. В качестве дополнительного преимущества будущий рефакторинг может изменить внутреннюю структуру объекта и переопределить этот метод, не затрагивая пользователей. Это настолько распространено, что специально для этого существует служебный макрос:


class Person

    getter name

end


Предыдущие два фрагмента ведут себя одинаково. Макрос-получатель создает метод, предоставляющий переменную экземпляра. Его также можно комбинировать с объявлением типа или начальным значением:


class Person

    getter name : String

    getter age = 0

    getter height : Float64 = 1.65

end


Несколько геттеров могут быть созданы в одной строке:


class Person

    getter name : String, age = 0, height : Float64 = 1.65

end


Для сеттеров логика очень похожа. Имена методов Crystal могут заканчиваться символом = для обозначения установщика. Если у него один параметр, его можно вызвать с помощью удобного синтаксиса:


class Person

    def name=(new_name)

        puts "The new name is #{new_name}"

    end

end


Этот метод name= можно вызвать следующим образом:


person = Person.new("Tony")

person.name = "Alfred"


Последняя строка представляет собой просто вызов метода и не меняет значение переменной экземпляра @name. Это то же самое, что написать person.name=("Alfred"), как если бы = была любая другая буква. Мы можем воспользоваться этим, чтобы написать метод установки:


class Person

    def name=(new_name)

        @name = new_name

    end

end


Теперь он будет вести себя так, как если бы имя было общедоступным свойством объекта. Макрос установки может создавать для вас эти методы, подобно макросу геттера, который мы только что видели:


class Person

    setter name

end


Его также можно использовать с объявлением типа или начальным значением.

Нам часто необходимо предоставить переменную экземпляра как с помощью геттера, так и сеттера. Для этого у Crystal есть макрос свойств:


class Person

    property name

end


Это то же самое, что написать следующее:


class Person

    def name

        @name

    end

    

    def name=(new_name)

        @name = new_name

    end

end


Как обычно, объявления типов или начальные значения могут использоваться для очень удобного синтаксиса. Существуют и другие полезные макросы, например макрос записи, как вы увидите далее в этой главе. В следующих главах вы также узнаете, как создавать собственные макросы для автоматизации генерации кода. Далее вы узнаете об основной концепции объектно-ориентированного программирования: классах, которые наследуются от других классов.

Наследование

Классы могут основываться на других классах, обеспечивая более специализированное поведение. Когда класс наследует от другого, он получает все существующие методы и переменные экземпляра и может добавлять новые или перезаписывать существующие. Например, давайте расширим ранее определенный класс Person:


class Person

    property name : String

    def initialize(@name)

    end

end


class Employee < Person

    property salary = 0

end


Экземпляр Employee может находиться в любом месте, где требуется экземпляр Person, поскольку, по сути, employee – это человек:


person = Person.new("Alan")

employee = Employee.new("Helen")

employee.salary = 10000

p person.is_a? Person # => true

p employee.is_a? Person # => true

p person.is_a? Employee # => false


В этом примере родительским классом является Person, а дочерним - Employee. Для создания иерархии классов можно создать несколько классов. При наследовании от существующего класса дочерний класс может не только расширять, но и переопределять части своего родительского класса. Давайте посмотрим на это на практике:


class Employee

    def yearly_salary

        12 * @salary

    end

end


class SalesEmployee < Employee

property bonus = 0


    def yearly_salary

        12 * @salary + @bonus

    end

end


В этом примере мы видим, что ранее определенный класс Employee повторно открывается для добавления нового метода. При повторном открытии класса не следует указывать его родительский класс (в данном случае Person). Метод yearly_salary добавляется к Employee, а затем создается новый специализированный тип Employee, наследуемый от него (и, в свою очередь, также наследуемый от Person). Добавляется новое свойство и переопределяется yearly_ salary, чтобы учесть его. Переопределение затрагивает только объекты типа SalesEmployee, но не объекты типа Employee.

При наследовании от класса и переопределении метода ключевое слово super может использоваться для вызова переопределенного определения из родительского класса. yearly_salary можно было бы записать следующим образом:


def yearly_salary

    super + @bonus

end


Поскольку метод initialize используется для подготовки начального состояния объекта, ожидается, что он всегда будет выполнен раньше всего остального. Таким образом, общепринятой практикой является использование ключевого слова super для вызова конструктора родительского класса при наследовании от существующего класса.

Теперь, когда мы определили несколько классов и подклассов, мы можем воспользоваться еще одной мощной концепцией: объекты типа подкласса могут храниться в переменной, типизированной для хранения одного из его базовых классов.

Полиморфизм

SalesEmployee наследуется от Employee, чтобы определить более специализированный тип сотрудника, но это не меняет того факта, что сотрудник отдела продаж является сотрудником и может рассматриваться как таковой. Это называется полиморфизмом. Давайте посмотрим пример этого в действии:


employee1 = Employee.new("Helen")

employee1.salary = 5000

employee2 = SalesEmployee.new("Susan")

employee2.salary = 4000

employee2.bonus = 20000

employee3 = Employee.new("Eric")

employee3.salary = 4000

employee_list = [employee1, employee2, employee3]


Здесь мы создали трех разных сотрудников, а затем создали массив, содержащий их всех. Этот массив имеет тип Array(Employee), хотя в нем также содержится SalesEmployee. Этот массив можно использовать для вызова методов:


employee_list.each do |employee|

    puts "#{employee.name}'s yearly salary is $#{employee. yearly_salary.format(decimal_places: 2)}."

end


Это приведет к следующему результату:


Elen's yearly salary is $60,000.00.

Susan's yearly salary is $68,000.00.

Eric's yearly salary is $48,000.00.


Как показано в этом примере, Crystal вызовет правильный метод, основанный на реальном типе объекта во время выполнения, даже если он статически типизирован как родительский класс.

Создание иерархии классов полезно не только для повторного использования кода, но и для обеспечения полиморфизма. Вы дажеможете ввести в свою программу неполные классы, просто чтобы объединить похожие концепции. Некоторые из них должны быть абстрактными классами, как мы увидим далее.

Абстрактные классы

Иногда мы пишем иерархию классов, и не имеет смысла разрешать создавать объекты на основе некоторых из них, потому что они не представляют конкретных понятий. Сейчас самое время пометить класс как абстрактный. Давайте рассмотрим пример:


abstract class Shape

end


class Circle < Shape

    def initialize(@radius : Float64)

    end

end


class Rectangle < Shape

    def initialize(@width : Float64, @height : Float64)

    end

end


И круги, и прямоугольники - это разновидности фигур, и они могут быть поняты сами по себе. Но форма сама по себе является чем-то абстрактным и была создана для наследования. Когда класс является абстрактным, его создание в виде объекта запрещено:


a = Circle.new(4)

b = Rectangle.new(2, 3)

c = Shape.new # This will fail to compile; it doesn't make sense.


Абстрактный класс не только накладывает ограничения, но и позволяет нам описать характеристики, которые должен реализовывать каждый подкласс. Абстрактные классы могут иметь абстрактные методы, а методы без определений должны быть переопределены:


abstract class Shape

    abstract def area : Number

end


class Circle

    def area : Number

        Math::PI * @radius ** 2

    end

end


class Rectangle

    def area : Number

        @width * @height

    end

end


Определяя метод абстрактной области в родительском классе, мы гарантируем, что все подклассы должны будут определять его, используя одну и ту же сигнатуру (без аргументов, возвращая какое-либо число). Например, если у нас есть список фигур, мы можем быть уверены, что сможем вычислить площадь каждой из них.

Абстрактный класс не ограничивается абстрактными методами - он также может определять обычные методы и переменные экземпляра.

Переменные класса и методы класса

Объекты являются экземплярами определенного класса и хранят значения переменных его экземпляра. Хотя имена и типы переменных одинаковы, каждый экземпляр (каждый объект) может иметь разные значения для них. Если тип переменной экземпляра является объединением нескольких типов, то разные объекты могут хранить в себе значения разных типов. Класс описывает каркас, в то время как объекты являются живыми объектами.

Но классы тоже являются объектами! Разве у них не должны быть переменные экземпляра и методы? Да, конечно.

Когда вы создаете класс, вы можете определить переменные класса и методы класса. Они находятся в самом классе, а не в каком-либо конкретном объекте. Переменные класса обозначаются префиксом @@, точно так же, как переменные экземпляра имеют префикс @. Давайте посмотрим на это на практике:


class Person

    @@next_id = 1

    @id : Int32

    def initialize(@name : String)

        @id = @@next_id

        @@next_id += 1

    end

end


Здесь мы определили переменную класса с именем @@next_id. Она существует сразу для всей программы. У нас также есть переменные экземпляра @name и @id, которые существуют для каждого объекта Person:


first = Person.new("Adam") # This will have @id = 1

second = Person.new("Jess") # And this will have @id = 2

# @@next_id inside Person is now 3.


Имейте в виду, что эти переменные класса действуют как глобальные переменные, и их значения являются общими для всей программы. Хотя это полезно для некоторых глобальных состояний, это также не обеспечивает потокобезопасность в программах с включенным параллелизмом, поскольку могут возникнуть условия гонки. Предыдущий пример не является потокобезопасным, если экземпляры Person создаются из разных потоков. Crystal по умолчанию не является многопоточным.

Подобно переменным класса, методы класса можно определить в самом классе, добавив к его имени префикс self. Посмотри:


class Person

    def self.reset_next_id

        @@next_id = 1

    end

end


Теперь вы можете вызвать Person.reset_next_id для выполнения этого действия, работая напрямую с классом. Отсюда становится ясно, что классы действительно являются объектами, поскольку у них есть данные и методы. Все это работает, как и ожидалось, и с наследованием подклассов.

Поскольку метод класса вызывается для класса, а не для экземпляра класса, в игре нет никакого объекта, а ключевое слово self относится к самому классу. Вы не можете получить доступ к переменным экземпляра или вызвать методы экземпляра, не обращаясь к какому-либо объекту.

Подобно переменным экземпляра, существуют вспомогательные макросы, помогающие предоставлять переменные класса с помощью методов класса, то есть class_getter, class_setter и class_property:


class Person

    class_property next_id

end


Теперь можно сделать Person.next_id = 3 или x = Person.next_id.

Работа с модулями

Модули, как и абстрактные классы, не представляют собой конкретные классы, из которых можно создавать объекты. Вместо этого модули — это фрагменты класса реализации, которые можно включить в класс при его определении. Модули могут определять переменные экземпляра, методы, переменные класса, методы класса и абстрактные методы, все из которых внедряются в класс, который их включает.

Давайте рассмотрим пример модуля, который определяет метод Say_name на основе некоторого существующего метода имени:


module WithSayName

    abstract def name : String


    def say_name

        puts "My name is #{name}"

    end

end


Это можно использовать с вашим классом Person:


class Person

    include WithSayName

    property name : String


    def initialize(@name : String)

    end

end


Здесь метод имени, ожидаемый WithSayName, создается макросом свойства. Теперь мы можем создать новый экземпляр Person и вызвать для него Say_name.

Модули можно использовать для ограничений типа и типа переменных. Когда это будет сделано, он укажет любой класс, включающий этот модуль. Учитывая ранее определенный код, мы можем сделать следующее:


def show(thing : WithSayName)

    thing.say_name

end

show Person.new("Jim")


Как обычно, ограничения типов не являются обязательными, но они могут помочь улучшить читаемость и документацию.

Модули часто используются для той же цели, что и интерфейсы других языков, где определен общий набор характеристик и один и тот же модуль реализуется множеством разных классов. Кроме того, один класс может включать в себя столько модулей, сколько необходимо.

Стандартная библиотека включает в себя несколько полезных модулей для указания характеристик некоторых классов:

Comparable: реализует все операторы сравнения при условии, что вы правильно реализовали оператор <=>. Классы, представляющие значения в естественном порядке, которые можно сортировать внутри контейнера, обычно включают этот модуль.

Enumerable: используется для коллекций, элементы которых можно перечислять один за другим. Класс должен реализовать each метод. передавая каждый элемент в блок. Этот модуль, в свою очередь, реализует несколько вспомогательных методов для управления коллекцией.

Iterable: это означает, что можно лениво перебирать включающую коллекцию. Класс должен реализовать each метод без получения блока и вернуть экземпляр Iterator. Модуль добавит множество полезных методов для преобразования этого итератора.

Indexable: предназначен для коллекций, элементы которых имеют числовую позицию в строгом порядке и могут рассчитываться от 0 до размера коллекции. Ожидается, что класс предоставит метод size и unsafe_fetch. Indexable включает Enumerable и Iterable и предоставляет все их методы, а также некоторые дополнения для работы с индексами.


Подробнее о каждом из этих модулей можно прочитать в официальной документации по адресу https://crystal-lang.org/docs.

Мы уже обсуждали использование модулей в качестве миксинов (mixins), когда их основной целью является включение в другой существующий класс. Вместо этого модуль может использоваться просто как пространство

имен или выступать в качестве держателя переменных и методов. Примером этого является модуль Base64 из стандартной библиотеки – он просто предоставляет некоторые служебные методы и не предназначен для включения в класс:


# Prints "Crystal Rocks!":

p Base64.decode_string("Q3J5c3RhbCBSb2NrcyE=")


В данном случае Base64 – это просто группа связанных методов, доступ к которым осуществляется непосредственно из модуля. Это общий шаблон, который помогает организовать методы и классы.

Подробнее о различных вариантах использования модулей будет рассказано позже в этой книге. Мы многое узнали о классах и объектах, но не все объекты ведут себя одинаково. Далее давайте разберемся, в чем разница между значениями и ссылками.

Значения и ссылки – использование структур

По умолчанию объекты Crystal размещаются в памяти и управляются сборщиком мусора. Это означает, что вам не нужно беспокоиться о том, где находится каждый объект в памяти и как долго он должен жить - среда выполнения позаботится о том, чтобы учесть, на какие объекты все еще ссылаются некоторые переменные, и освободит все остальные, автоматически освобождая ресурсы. Переменные не будут хранить объект как таковой - они будут хранить ссылку, указывающую на объект. Все это работает прозрачно, и беспокоиться об этом не нужно.

Вышесказанное справедливо для всех объектов, созданных из классов; типы этих объектов являются ссылочными типами. Но есть и другой тип объектов: типы значений.

На следующей диаграмме вы можете увидеть цепочку наследования некоторых типов. Те, которые являются ссылками, наследуются от ссылочного класса, в то время как те, которые являются значениями, наследуются от структуры Value. Все они наследуются от специального базового типа Object:


Рисунок 3.1 - Иерархия типов, показывающая, как ссылки связаны со значениями.


Ссылки управляются сборщиком мусора и хранятся в куче — специальной области памяти. Переменные указывают на них, и несколько переменных могут ссылаться на один и тот же объект. С другой стороны, объекты-значения живут в самих переменных и обычно имеют небольшой размер. Они создаются из структур.

Вы можете создавать свои собственные структуры. Они очень похожи на классы тем, что у них также есть переменные экземпляра и методы:


struct Address

    property state : String, city : String

    property line1 : String, line2 : String

    property zip : String


    def initialize(@state, @city, @line1, @line2, @zip)

    end

end


Структуры и классы – это все типы объектов, и их можно использовать для ввода любой переменной, включая объединения типов. Например, давайте сохраним адрес внутри класса Person:


class Person

    property address : Address?

end


В данном случае переменная экземпляра @address имеет тип Address? это сокращение от Address | Nil. Поскольку начального значения нет и эта переменная не назначается в методе initialize, она начинается с nil. Использование структуры является простым:


address = Address.new("CA", "Los Angeles", "Some fictitious line", "First house", "1234")

person1 = Person.new

person2 = Person.new

person1.address = address

address.zip = "ABCD"

person2.address = address

puts person1.address.try &.zip

puts person2.address.try &.zip


Мы начали этот пример с создания адреса и двух persons – в общей сложности трех объектов: одного объекта-значения и двух объектов-ссылок. Затем мы присвоили адрес из локальной переменной address переменной экземпляра @address для person1. Поскольку адрес является значением, эта операция копирует данные. Мы изменяем его и присваиваем @address person2. Обратите внимание, что изменение не влияет на person1 – значения всегда копируются. Наконец, мы показываем почтовый индекс в каждом адресе. Нам нужно использовать метод try для доступа к свойству zip только в том случае, если на данный момент значение union не равно nil, поскольку компилятор не может определить это самостоятельно.

Поэкспериментируйте с изменением адреса класса и повторным запуском предыдущего кода. На этот раз у обоих пользователей будет одинаковый почтовый индекс. Это происходит потому, что ссылки не копируются при присвоении, поэтому все переменные будут ссылаться на один и тот же объект address.

Значения структуры всегда копируются, когда вы присваиваете их из одной переменной в другую, когда вы передаете их в качестве аргументов при вызове метода или когда вы получаете их из возвращаемого значения при вызове метода. Это известно как семантика "по значению"; таким образом, рекомендуется , чтобы структуры были небольшими с точки зрения объема их памяти. Из этого правила есть интересное и полезное исключение: когда тело метода просто возвращает переменную экземпляра напрямую, копия удаляется, и к значению осуществляется прямой доступ. Давайте рассмотрим пример:


struct Location

    property latitude = 0.0, longitude = 0.0

end


class Building

    property gps = Location.new

end


building = Building.new

building.gps.latitude = 1.5

p store


В предыдущем примере мы создали структурный тип Location, который имеет два свойства, и класс Building, который имеет одно свойство. Макрос property gps сгенерирует метод с именем def gps; @gps; end для получателя - обратите внимание, что этот метод просто возвращает переменную экземпляра напрямую, что соответствует правилу исключения копирования. Если бы этот метод был каким-то другим, этот пример не сработал бы.

Строка building.gps.latitude = 1.5 вызывает метод gps и получает результат, затем вызывает параметр latitude=setter с значением 1.5 в качестве аргумента. Если бы возвращаемое значение gps было скопировано, то средство настройки работало бы с копией структуры и не влияло бы на значение, хранящееся в переменной building. Попробуйте поэкспериментировать с добавлением пользовательского определения для метода gps.

Теперь, когда вы знаете, как создавать как классы, так и структуры, мы сделаем шаг вперед и узнаем о дженериках и о том, как эта новая концепция может помочь вам создавать более гибкие типы.

Общие (Generic) классы

Общий класс (или структура) создается на основе одного или нескольких неизвестных типов, которые определяются только позже, когда вы создаете экземпляр указанного класса. Это звучит сложно, но вы уже использовали некоторые общие классы раньше. Array является наиболее распространенным: заметили ли вы, что нам всегда нужно указывать тип данных, которые содержит массив? Недостаточно сказать, что данная переменная является массивом — мы должны сказать, что это массив строк или Array(String). Универсальный класс Hash аналогичен, но у него есть два параметра типа — типы ключей и типы значений.

Давайте посмотрим на простой пример. Предположим, вы хотите создать класс, который содержит значение в одной из переменных экземпляра, но это значение может быть любого типа. Давайте посмотрим, как мы можем это сделать:


class Holder(T)

    def initialize(@value : T)

    end


    def get

        @value

    end


    def set(new_value : T)

        @value = new_value

    end

end


Общие параметры, по соглашению, представляют собой одиночные заглавные буквы — в данном случае T. В этом примере Holder является универсальным классом, а Holder(Int32) будет универсальным экземпляром этого класса: обычным классом, который может создавать объекты. Переменная экземпляра @value имеет тип T, независимо от того, какое T будет позже. Вот как можно использовать этот класс:


num = Holder(Int32).new(10)

num.set 40

p num.get # Prints 40.


В этом примере мы создаем новый экземпляр класса Holder(Int32). Это как если бы у вас был абстрактный класс Holder и наследуемый от него класс Holder_Int32, созданный по требованию для T=Int32. Объект можно использовать как любой другой. Методы вызываются и взаимодействуют с переменной экземпляра @value.

Обратите внимание, что в этих случаях тип T не обязательно указывать явно. Поскольку метод инициализации принимает аргумент типа T, общий параметр можно вывести из использования. Давайте создадим Holder(String):


str = Holder.new("Hello")

p str.get # Prints "Hello".


Здесь T считается строкой, поскольку Holder.new вызывается с аргументом строкового типа.

Классы-контейнеры из стандартной библиотеки являются универсальными классами, как и определенный нами класс Holder. Некоторые примеры: Array(T), Set(T) и Hash(K, V). Вы можете поиграть с созданием собственных классов контейнеров, используя дженерики.

Далее давайте узнаем, как вызывать и обрабатывать исключения.

Исключения

Существует множество способов, по которым код может сбоить. Некоторые сбои обнаруживаются во время анализа, например, невыполненный метод или нулевое значение в переменной, которое не должно содержать nil. Некоторые другие сбои происходят во время выполнения программы и описываются специальными объектами: исключениями. Исключение представляет собой сбой на "счастливом пути" и содержит точное местоположение, в котором была обнаружена ошибка, а также подробные сведения для ее понимания.

Исключение может быть вызвано в любой момент с помощью метода верхнего уровня raise. Этот метод ничего не вернет; вместо этого он начнет выполнять обратные вызовы всех методов, как если бы все они имели неявный возврат. Если ничто не фиксирует исключение выше в цепочке методов, программа завершит работу, и пользователю будут представлены подробные сведения об исключении. Приятным аспектом возникновения исключения является то, что оно не должно останавливать выполнение программы; вместо этого его можно перехватить и обработать, возобновив нормальное выполнение.

Давайте рассмотрим пример:


def half(num : Int)

    if num.odd?

      raise "The number #{num} isn't even"

    end

    num // 2

end


p half(4) # => 2

p half(5) # Unhandled exception: The number 5 isn't even (Exception)

p half(6) # This won't execute as we have aborted the program.


В предыдущем фрагменте мы определили метод half, который возвращает половину заданного целого числа, но только для четных чисел. Если задано нечетное число, это вызовет исключение. В этой программе нет ничего, что могло бы перехватить и обработать это исключение, поэтому программа завершит работу с сообщением о необработанном исключении.

Обратите внимание, что raise "описание ошибки" – это то же самое, что raise Exception. new("описание ошибки"), поэтому будет создан объект exception. Exception - это класс, единственная особенность которого заключается в том, что метод raise принимает только его объекты.

Чтобы показать разницу между ошибками во время компиляции и во время выполнения, попробуйте добавить p half("привет") к предыдущему примеру. Теперь это недопустимая программа (из-за несоответствия типов), и она даже не собирается, поэтому не может быть запущена. Ошибки во время выполнения обнаруживаются и сообщаются только во время выполнения программы.

Исключения могут быть зафиксированы и обработаны с помощью ключевого слова rescue. Оно чаще используется в выражениях begin и end, но может использоваться непосредственно в телах методов или блоков. Вот пример:


begin

    p half(3)

rescue

    puts "can't compute half of 3!"

end


Если внутри выражения begin возникнет какое-либо исключение, независимо от того, насколько глубоко оно находится в цепочке вызовов метода, это исключение будет восстановлено в коде rescue. Удобно иметь возможность обрабатывать все виды исключений за один раз, но вы также можете получить доступ к тому, что это за исключение, указав переменную:


begin

    p half(3)

rescue error

    puts "can't compute half of 3 because of #{error}"

end


Здесь мы зафиксировали объект exception и можем его проверить. Мы могли бы даже вызвать его снова, используя raise error. Та же концепция может быть применена к телам методов:


def half?(num)

    half(num)

rescue

    nil

end

p half? 2 # => 1

p half? 3 # => nil

p half? 4 # => 2


В этом примере у нас есть версия метода half, которая называется half?. Этот метод возвращает объединение Int32 | Nil, в зависимости от введенного номера.

Наконец, ключевое слово rescue также можно использовать встроенно, чтобы защитить одну строку кода от любого исключения и заменить ее значение. Метод half? можно реализовать следующим образом:


def half?(num)

    half(num) rescue nil

end


В реальном мире обычной практикой является пойти наоборот и сначала реализовать метод, который возвращает nil в неудачном пути, а затем создать вариант, который вызывает исключение поверх первой реализации.

Стандартная библиотека содержит множество типов предопределенных исключений, таких как DivisionByZeroError, IndexError и JSON::Error. Каждый из них представляет различные типы ошибок. Это простые классы, которые наследуются от класса Exception.

Пользовательские исключения

Поскольку исключения - это обычные объекты, а Exception - это класс, вы можете определять новые типы исключений, наследуя от них. Давайте посмотрим на это на практике:


class OddNumberError < Exception

    def initialize(num : Int)

        super("The number #{num} isn't even")

    end

end


def half(num : Int32)

    if num.odd?

        raise OddNumberError.new(num)

    end


    num // 2

end


В этом примере мы создали класс с именем OddNumberError, который наследуется от Exception. Таким образом, его объекты могут быть вызваны и сохранены. Затем мы переписываем метод half, чтобы использовать этот более специфичный класс ошибок. Эти объекты могут иметь переменные экземпляра и методы, как обычно.

Теперь, когда мы определили класс ошибок, мы можем фиксировать ошибки только из одного конкретного класса, а не из всех возможных исключений. Рекомендуется обрабатывать только известный набор ошибок, с которыми вы можете справиться. Это можно сделать, указав ограничение типа для ключевого слова rescue:


def half?(num)

    half(num)

rescue error : OddNumberError

    nil

end


Вы можете повторить несколько блоков rescue, чтобы зафиксировать и обработать несколько различных типов исключений. Единственная ситуация, в которой вы не можете быть разборчивы, – это встроенное восстановление, поскольку оно всегда будет обрабатывать и заменять все исключения.

Резюме

В этой главе вы узнали, как создавать классы и структуры, разобравшись в их различиях. Стало ясно, что каждое отдельное значение является объектом - даже сами классы являются объектами: объекты содержат данные, и ими можно манипулировать с помощью методов. Вы узнали, как наследовать и расширять классы, а также как создавать повторно используемые модули для организации вашего кода. Наконец, вы узнали об исключениях и о том, как использовать классы для создания ошибок пользовательского типа. Поскольку язык в значительной степени объектно-ориентирован, вы будете взаимодействовать с объектами практически в каждой строке кода. Знание того, как определять свои собственные классы, является важным навыком для написания программ на Crystal.

В следующей главе мы перейдем к решению более практических задач с использованием языка Crystal, написав несколько инструментов для интерфейса командной строки (CLI).

Часть 2: Обучение на практике – CLI

В этой части будет представлен первый проект Learn by Doing, в котором будет рассказано обо всем, что необходимо для создания CLI-приложения. Это включает в себя различные функции Crystal, такие как операции ввода-вывода, волокна и привязки C. В этой части также будут рассмотрены основы создания нового проекта Crystal.

Эта часть содержит следующие главы:

• Глава 4, изучение Crystal с помощью написания интерфейса командной строки

• Глава 5, Операции ввода/вывода

• Глава 6, Параллелизм

• Глава 7, Взаимодействие с C

4. Изучение Crystal с помощью написания интерфейса командной строки

Теперь, когда вы знакомы с основами Crystal, мы готовы применить эти навыки на практике. В этой части мы расскажем вам о создании интерфейса командной строки (CLI), в котором будут использованы концепции из Главы 1 "Введение в Crystal", а также некоторые новые.

Эта глава станет введением в суть данной части книги, в которой основное внимание будет уделено настройке проекта и первому этапу реализации интерфейса командной строки. Идея заключается в том, что в этой главе описывается первоначальная реализация, а затем в последующих главах она расширяется и улучшается.

Цель CLI – создать программу, позволяющую использовать данные YAML с jq, популярным CLI-приложением, которое позволяет разделять, фильтровать, отображать и преобразовывать структурированные данные JSON с помощью фильтра для описания этого процесса. Эта глава послужит отправной точкой нашего проекта, в которой будут рассмотрены следующие темы:

• Введение в проект

• Построение структуры проекта

• Написание базовой реализации


К концу этой главы вы сможете создавать свои собственные проекты Crystal, в том числе понимать, для чего используется каждый файл и папка в проекте. Вы также познакомитесь с тем, как работать с проектами, состоящими из нескольких файлов и папок. Оба эти элемента являются важными составляющими любого приложения Crystal.

Технические требования

Для выполнения кода, описанного в этой главе, вам понадобится следующее программное обеспечение:

• Работающая установка Crystal

• Рабочая установка jq

Инструкции по получению Crystal можно найти в Главе 1 «Введение в Crystal». настраивать. jq, скорее всего, можно установить с помощью менеджера пакетов в вашей системе, но можно также можно установить вручную, загрузив его с https://stedolan.github.io/jq/download.

Все примеры кода, использованные в этой главе, можно найти в папке Chapter 4 на GitHub: https://github.com/PacktPublishing/Crystal-Programming/ tree/main/Chapter04.

Введение в проект

Прежде чем мы перейдем к нашему CLI-приложению, было бы полезно немного понять, как работает jq, поскольку он является основной частью желаемой функциональности нашего приложения. Как упоминалось ранее, jq позволяет создавать фильтры, которые используются для описания того, как следует преобразовать входные данные JSON.

Фильтр состоит из строки различных символов и символов, некоторые из которых имеют особое значение. Самый простой фильтр — ., также известный как фильтр идентификации. Этот фильтр оставляет входные данные неизмененными, что может быть полезно в тех случаях, когда вы просто хотите отформатировать входные данные, учитывая, что jq по умолчанию печатает весь вывод. Фильтр идентификации также представляет входные данные, проходящие через несколько фильтров. Подробнее об этом в ближайшее время.

jq включает в себя различные другие фильтры, целью которых является доступ к определенным частям входных данных или управление выполнением фильтра. Наиболее распространенными из них являются следующие:

• Индекс идентификатора объекта

• Индекс массива

• Запятая

• Pipe


Фильтр индекса идентификатора объекта позволяет получить доступ к значению по определенному ключу, предполагая, что входные данные являются объектом, и выдает ошибку, если это не так. Этот фильтр вернет значение null, если нужный ключ отсутствует в объекте. Например, использование фильтра .name для входных данных {"id":1,"name":"George"} приведет к получению выходного значения "George". Фильтр индекса массива работает во многом аналогично фильтру индекса идентификатора объекта, но для входных данных массива. Учитывая входные данные [1, 2, 3], использование фильтра .[1] даст выход 2.

Хотя первые два примера посвящены доступу к данным, фильтры «Запятая» и «Канал» предназначены для управления потоком данных через фильтр. Если несколько фильтров разделены запятой, входные данные передаются каждому фильтру независимо. Например, используя ранее полученный входной объект, фильтр .id, .name выдает выходные данные 1 и "George", каждое в отдельной строке. С другой стороны, канал передает выходные данные фильтра слева в качестве входных данных для фильтра справа. Опять же, используя тот же ввод, что и раньше, фильтр .id | . + 1 выдаст результат 2. Обратите внимание, что в этом примере мы используем идентификационный фильтр для ссылки на выходное значение предыдущего фильтра, которое в этом примере было равно 1, которое изначально пришло из входного объекта.

Доступ к определенным значениям из входных данных — это только половина дела, когда дело доходит до преобразования данных. jq предоставляет способ создания новых объектов/массивов с использованием синтаксиса JSON. Используя проверенный входной объект, который мы использовали, фильтр {"new_id":(.id+2)} создает новый объект, который выглядит как {"new_id":3}. Аналогично, массив можно создать с помощью синтаксиса [] и [(.id), (.id*2), (.id)] создает массив [1, 2, 1]. В обоих последних примерах мы используем круглые скобки, чтобы контролировать порядок операций оценки фильтра.

Давайте объединим все эти функции в более сложный пример, учитывая следующие входные данные:


[

  {

    "id": 1,

    "author": {

        "name": "Jim"

    }

  },

  {

    "id": 2,

    "author": {

       "name": "Bob"

    }

  }

]


Мы можем использовать фильтр [.[] | {"id": (.id + 1), "name": .author.name}] для получения следующего вывода, полная команда — jq '[.[] | {"id": (.id + 1), "name": .author.name}]' input.json:


[

    {

        "id": 2,

        "name": "Jim"

    },

    {

        "id": 3,

        "name": "Bob"

    }

]


Если вы хотите узнать больше о возможностях jq, ознакомьтесь с его документацией по адресу https://stedolan.github.io/jq/manual, поскольку существует множество вариантов, методов и функций, выходящих за рамки этой книги.

Теперь, когда вы познакомились с синтаксисом jq, давайте перейдем к его применению для нашего собственного приложения, начиная с его терминологической структуры.

Строительные леса проекта

Первое, что нам нужно сделать, это инициализировать новый проект, который будет содержать код приложения. Crystal предлагает простой способ сделать это с помощью команды crystal init. Эта команда создаст новую папку, создаст базовый набор файлов и инициализирует пустой репозиторий Git. Команда поддерживает создание проектов типа app и lib, с той лишь разницей, что в проектах библиотеки файл shard.lock также игнорируется через .gitignore, по той причине, что зависимости будут заблокированы через приложение, использующее проект. Учитывая, что у нас не будет никаких внешних общих зависимостей и в конечном итоге мы захотим разрешить включение проекта в другие проекты Crystal, мы собираемся создать проект lib.

Начните с запуска crystal init lib transform в вашем терминале. Это инициализирует проект библиотеки под названием Transform со следующей структурой каталогов (файлы, связанные с Git, опущены для краткости):



Давайте подробнее рассмотрим, что представляют собой эти файлы/каталоги:

.editorconfig — файл https://editorconfig.org, который позволяет некоторым IDE (если они настроены правильно) автоматически применять стиль кода Crystal к файлам *.cr.

LICENSE — лицензия, которую использует проект. По умолчанию используется MIT, и нас это устраивает.

См. https://docs.github.com/en/github/creating-cloning-and-archiving-repositories/creating-a-repository-on-github/licensing-a-repository для получения дополнительной информации.

README.md — следует использовать для общей документации по приложению, такой как установка, использование и предоставление информации.

shard.yml — содержит метаданные об этом осколке Crystal. Подробнее об этом в Главе 8 «Использование внешних библиотек».

spec/ — папка, в которой хранятся все спецификации (тесты), относящиеся к приложению. Подробнее об этом в Главе 14 «Тестирование».

src/ — папка, в которой находится исходный код приложения.

src/transform.cr — основная точка входа в приложение.


Хотя эта структура проекта является хорошей отправной точкой, мы собираемся внести несколько изменений, создав еще один файл: src/transform_cli.cr. Также добавьте в файл shard.yml следующее:


targets:

   transform:

      main: src/transform_cli.cr


Это позволит нам запустить run shards build, а также собрать двоичный файл CLI и вывести его в каталог ./bin.

Разбивать код на несколько файлов — хорошая практика как по организационным причинам, так и для предоставления более специализированных точек входа в ваше приложение. Например, проект преобразования можно использовать как через командную строку, так и в другом приложении Crystal. По этой причине мы можем использовать src/transform.cr в качестве основной точки входа, тогда как src/transform_cli.cr требует src/transform.cr, но также включает некоторую логику, специфичную для CLI. Мы вернемся к этому файлу позже в этой главе.

На данный момент у нас есть все необходимые файлы для нашего приложения, и мы можем перейти к первоначальной реализации.

Написание базовой реализации

Прежде чем мы перейдем непосредственно к написанию кода, давайте потратим минуту на то, чтобы спланировать, что именно должен делать наш код. Целью нашего CLI является создание программы, позволяющей использовать YAML с jq. В конечном итоге это сводится к трем требованиям:

1. Преобразуйте входные данные YAML в JSON.

2. Передайте преобразованные данные в jq.

3. Преобразуйте выходные данные JSON в YAML.

Важно помнить, что конечной целью этого упражнения является демонстрация того, как различные концепции Crystal могут применяться для создания функционального и удобного приложения CLI. Таким образом, мы не собираемся уделять слишком много внимания попыткам сделать его на 100% надежным для каждого варианта использования, а вместо этого сосредоточимся больше на различных инструментах/концепциях, используемых в рамках реализации.

Имея это в виду, давайте перейдем к написанию первоначальной реализации, начав с чего-то простого и повторяя его, пока не получим полностью работающую реализацию. Начнем с самого простого случая: вызовите jq с жестко закодированными данными JSON, чтобы показать, как эта часть будет работать. К счастью для нас, стандартная библиотека Crystal включает тип https://crystal-lang.org/api/Process.html, который позволяет напрямую вызывать процесс jq, установленный в данный момент. Таким образом, мы можем использовать все его функции без необходимости переносить их в Crystal.

Откройте src/transform.cr в выбранной вами IDE и обновите его, чтобы он выглядел следующим образом:


module Transform

   VERSION = "0.1.0"

   # The same input data used in the example at the

  # beginning of the chapter.

   INPUT_DATA = %([{"id":1,"author":{"name":"Jim"}},{"id":2,

  "author":{"name":"Bob"}}])

   Process.run(

      "jq",

      [%([.[] | {"id": (.id + 1), "name": .author.name}])],

      input: IO::Memory.new(INPUT_DATA),

      output: :inherit

   )

end


Сначала мы определяем константу с входными данными, которые использовались в предыдущем примере. Process.run запускает процесс и ожидает его завершения. Затем мы вызываем его, используя jq в качестве команды вместе с массивом аргументов (в данном случае только фильтр). Мы передаем ввод-вывод из памяти в качестве входных данных для команды. Не обращайте на это особого внимания; более подробно это будет рассмотрено в следующей главе. Наконец, мы устанавливаем для выходных данных команды значение :inherit, что заставляет программу наследовать выходные данные своего родительского модуля, которым является наш терминал.

Выполнение этого файла через crystal src/transform.cr приводит к тому же результату, что и в предыдущем примере jq, который удовлетворяет второму требованию нашего CLI. Однако нам все еще нужно выполнить требования 1 и 3. Давайте начнем с этого.

Преобразование данных

Следуя предыдущей рекомендации, я собираюсь создать новый файл, который будет содержать логику преобразования. Для начала создайте файл src/yaml.cr со следующим содержимым:


require "yaml"

require "json"


module Transform::YAML

   def self.deserialize(input : String) : String

      ::YAML.parse(input).to_json

   end


   def self.serialize(input : String) : String

      JSON.parse(input).to_yaml

   end

end


Кроме того, не забудьте запросить этот файл в src/transform.cr, добавив require "./ yaml" в начало файла.

Crystal поставляется с довольно надежной стандартной библиотекой общих / полезных функций. Хорошим примером этого являются модули https://crystal-lang.org/api/YAML.html и https://crystal-lang.org/api/JSON.html, которые упрощают написание логики преобразования. Я определил два метода: один для обработки YAML => JSON, а другой для обработки JSON => YAML. Обратите внимание, что я использую ::YAML для ссылки на модуль стандартной библиотеки. Это связано с тем, что метод уже определен в пространстве имен YAML. Без :: Crystal будет искать метод .parse в своем текущем пространстве имен вместо того, чтобы обращаться к стандартной библиотеке. Этот синтаксис также работает с методами, что может пригодиться, если вы случайно определите свой собственный метод #raise, а затем захотите, например, также вызвать реализацию стандартной библиотеки.

Затем я обновил файл src/transform.cr, чтобы он выглядел следующим образом:


require "./yaml"


   module Transform

      VERSION = "0.1.0"

      INPUT_DATA = <←YAML

      ---

      - id: 1

         author:

            name: Jim

      - id: 2

      author:

         name: Bob

      YAML


   output_data = String.build do |str|

      Process.run(

         "jq",

         [%([.[] | {"id": (.id + 1), "name": .author.name}])],

         input: IO::Memory.new(

            Transform::YAML.deserialize(INPUT_DATA)

         ),

         output: str

      )

      end


   puts Transform::YAML.serialize(output_data)

end


Код в основном тот же, но теперь он предоставляет входные данные на языке YAML и включает нашу логику преобразования. Стоит отметить, что теперь мы используем String.build для создания строки в коде, как вы могли видеть на своем терминале ранее. Основная причина этого заключается в том, что строка нужна нам для того, чтобы преобразовать ее обратно в YAML перед выводом на экран нашего терминала.

На данный момент у нас есть рабочая базовая реализация, которая соответствует нашим целям, но код на самом деле не пригоден для повторного использования, поскольку все это определено на верхнем уровне нашего пространства имен transform. Нам следует исправить это, прежде чем мы сможем назвать это завершенным.

Улучшение возможности повторного использования

С этого момента мы начнем использовать файл src/transform_cli.cr. Чтобы решить эту проблему повторного использования, мы планируем определить тип процессора, который будет содержать логику, связанную с вызовом jq и преобразованием данных.

Давайте начнем с создания файла src/processor.cr, обязательно указав его в src/transform.cr, со следующим содержимым:


class Transform::Processor

  def process(input : String) : String

    output_data = String.build do |str|

      Process.run(

        "jq",

        [%([.[] | {"id": (.id + 1), "name": .author.name}])],

        input: IO::Memory.new(

          Transform::YAML.deserialize input

        ),

        output: str

      )

    end


    Transform::YAML.serialize output_data

  end

end


Наличие этого класса делает наш код намного более гибким и пригодным для повторного использования. Мы можем создать объект Transform::Processor и вызывать его метод #process несколько раз с различными входными строками. Далее, давайте используем этот новый тип в src/transform_cli.cr:


require "./transform"


  INPUT_DATA = <←YAML

  ---

    - id: 1

      author:

        name: Jim

    - id: 2

      author:

        name: Bob

  YAML


puts Transform::Processor.new.process INPUT_DATA


Наконец, src/transform.cr теперь должен выглядеть следующим образом:


require "./processor"

require "./yaml"


module Transform

   VERSION = "0.1.0"

end


Запуск src/transform_cli.cr по-прежнему приводит к тому же результату, что и раньше, но теперь можно повторно использовать нашу логику преобразования для разных входных данных. Однако цель CLI – разрешить использование аргументов из терминала и использовать значения внутри CLI. Учитывая, что в настоящее время входной фильтр жестко привязан к типу процессора, я думаю, что это то, к чему нам следует обратиться, прежде чем завершать начальную реализацию.

Аргументы, передаваемые программе CLI, отображаются через константу ARGV в виде Array(String). Сам код, позволяющий использовать это, довольно прост, учитывая, что аргументы jq уже принимают массив строк, который у нас на данный момент жестко запрограммирован. Мы можем просто заменить этот массив константой ARGV, и все будет в порядке. src/processor.cr теперь выглядит следующим образом:


class Transform::Processor

  def process(input : String) : String

    output_data = String.build do |str|

      Process.run("jq",

        ARGV,

        input: IO::Memory.new(Transform::YAML.deserialize

          input

        ),

        output:str

      )

    end


    Transform::YAML.serialize output_data

  end

end


Кроме того, поскольку фильтр больше не является жестко запрограммированным, нам нужно будет ввести его вручную. Запуск crystal src/transform_cli.cr '[.[] | {"id": (.id + 1), "name": .author.name}]' снова выдает тот же результат, но гораздо более гибким способом.

Если вы предпочитаете использовать crystal run, команду нужно будет немного изменить, чтобы учесть различную семантику каждого варианта. В этом случае команда была бы crystal run src/transform_cli.cr -- '[.[] | {"id": (.id + 1), "name": .author.name }]', где параметр -- сообщает команде запуска, что должны быть переданы будущие аргументы к исполняемому файлу, а не в качестве аргументов для самой команды запуска.

Стандартная библиотека Crystal также включает тип OptionParser, который предоставляет DSL, позволяющий описывать аргументы, которые принимает CLI, обрабатывать их синтаксический анализ из ARGV и генерировать справочную информацию на основе этих параметров. Мы будем использовать этот тип в одной из следующих глав, так что следите за обновлениями!

Резюме

На данный момент наш интерфейс командной строки отвечает всем нашим требованиям. Мы можем преобразовать несколько жестко запрограммированных входных данных YAML в JSON и обработать их с помощью фильтра jq, а выходные данные преобразовать обратно в YAML и вывести для нашего просмотра, все время принимая фильтр jq в качестве аргумента CLI. Однако нашей реализации по-прежнему не хватает гибкости и производительности. В следующей главе будет рассказано, как использовать типы ввода-вывода (IO) для улучшения приложения в соответствии с обоими этими критериями.

Хотя то, что мы сделали в этой главе, может показаться довольно простым, важно помнить, что эти концепции являются общими для каждого будущего проекта Crystal, который вы будете создавать. Правильный дизайн приложения, как с точки зрения организационной структуры, так и с точки зрения самого кода, является важной частью разработки удобочитаемых, тестируемых и сопровождаемых приложений.

5. Операции ввода/вывода

В этой главе будет подробно рассмотрено CLI-приложение, о котором говорилось в предыдущей главе, с акцентом на операции ввода/вывода (IO). В ней будут рассмотрены следующие темы:

• Поддержка терминального ввода-вывода, такого как STDIN/STDOUT/STDERR

• Поддержка дополнительного ввода-вывода

• Тестирование производительности

• Объяснение поведения ввода-вывода


К концу этой главы у вас должно быть общее представление об операциях ввода-вывода, в том числе о том, как их использовать и как они себя ведут. С помощью этих концепций вы сможете создавать интерактивные, эффективные потоковые алгоритмы, которые могут быть использованы в различных приложениях. Знание того, как работает IO, также поможет вам понять более сложные концепции, которые будут рассмотрены в следующих главах, таких как Глава 6 "Параллелизм".

Технические требования

Для выполнения кода, описанного в этой главе, вам потребуется следующее программное обеспечение:

• Рабочая установка Crystal

• Рабочая установка jq

• Средство измерения использования памяти, например https://man7.org/linux/man-pages/man1/time.1.html с параметром -v

Инструкции по настройке Crystal приведены в Главе 1 "Введение в Crystal". Скорее всего, jq можно установить с помощью менеджера пакетов в вашей системе, но его также можно установить вручную, загрузив с сайта https://stedolan.github.io/jq/download.

Все примеры кода, использованные в этой главе, можно найти в папке Chapter 5 на GitHub: https://github.com/PacktPublishing/Crystal-Programming/tree/main/Chapter05.

Поддерживающий терминальный ввод/вывод

В предыдущей главе мы остановились на нашем типе процессора, имеющем метод def process(input : String) : String, который преобразует входную строку, обрабатывает ее с помощью jq, а затем преобразует и возвращает выходные данные. Затем мы вызываем этот метод со статическим вводом. Однако CLI-приложение не очень полезно, если его нужно перекомпилировать каждый раз, когда вы хотите изменить входные данные.

Более правильный способ справиться с этим - использовать терминальный ввод-вывод, а именно Standard In(STDIN), Standard Out (STDOUT) и Standard Error (STDERR). Это позволит нам использовать данные, выводить данные и выводить ошибки соответственно. Фактически, вы уже используете стандартный вывод, даже не подозревая об этом! Метод Crystal puts записывает переданное ему содержимое в стандартный вывод, за которым следует перевод строки. Тип STDOUT наследуется от абстрактного типа ввода-вывода, который также определяет метод puts для экземпляра ввода-вывода. В принципе, это позволяет вам делать то же самое, что и puts верхнего уровня, но для любого ввода-вывода. Например, обратите внимание, что эти два варианта puts дают один и тот же результат:

puts "Hello!"         # => Hello!

STDOUT.puts "Hello!"  # => Hello!

Но подождите, что такое IO? Технически в Crystal IO — это все, что наследуется от абстрактного типа IO.

Однако на практике ввод/вывод обычно представляет собой что-то, что может записывать и/или считывать данные, например файлы или тела HTTP-запроса/ответа. IO также обычно реализуется таким образом, что не все читаемые/записываемые данные должны находиться в памяти одновременно, чтобы поддерживать «потоковую передачу» данных. Пользовательский IO также может быть определен для более специализированных случаев использования.

В нашем контексте типы STDIN, STDOUT и STDERR фактически являются экземплярами IO::FileDescriptor.

Crystal предоставляет некоторые полезные типы IO, которые мы уже использовали. Помните, как мы также использовали IO::Memory как средство передачи преобразованных входных данных в jq? Или как мы использовали String.build для создания строки данных после того, как jq преобразовал ее? IO::Memory — это реализация IO, которая хранит записанные данные в памяти приложения, а не во внешнем хранилище, таком как файл. Метод String.build выдает IO, в который можно записать данные, а затем возвращает записанное содержимое в виде строки. Полученный IO можно рассматривать как оптимизированную версию IO::Memory. Пример этого в действии будет выглядеть так:


io = IO::Memory.new


io << "Hello"

io << " " << "World!"


puts io # => Hello World!

string = String.build do |io|

    io << "Goodbye"

    io << " " << "World"

end


puts string # => Goodbye World!


Стандартная библиотека Crystal также включает в себя несколько примесей, которые можно использовать для улучшения поведения IO. Например, модуль IO::Buffered можно включить в тип IO, чтобы повысить производительность за счет добавления буферизации ввода/вывода к типу IO Другими словами, вы можете сделать так, чтобы данные не записывались немедленно в базовый IO, если это тяжелый процесс. Файл является примером буферизованного IO.

Crystal также предоставляет некоторые дополнительные специализированные типы ввода-вывода, которые можно использовать в качестве строительных блоков для создания других типов IO. Некоторые из них, на которые стоит обратить внимание, включают следующее:

Delimited — IO, который оборачивает другой IO, считывая только до начала указанный разделитель. Может быть полезно для экспорта только части потока клиенту.

Hexdump — IO, который печатает шестнадцатеричный дамп всех переданных данных. Может быть полезно для отладки двоичных протоколов, чтобы лучше понять, когда и как данные отправляются/получаются.

Sized — IO, который оборачивает другой ввод-вывод, устанавливая ограничение на количество байтов, которые можно прочитать.


Полный список см. в документации API: https://crystal-lang.org/api/IO.html.

Теперь, когда мы познакомились с IO, давайте вернемся к обновлению нашего CLI, чтобы лучше использовать ввод-вывод на основе терминала. Планируется обновить src/transform_cli.cr для чтения непосредственно из STDIN и вывода непосредственно в STDOUT. Это также позволит нам устранить необходимость в константе INPUT_DATA. Теперь файл выглядит так:


require "./transform"


STDOUT.puts Transform::Processor.new.process STDIN.gets_to_end


Главное, что изменилось, это то, что мы заменили константу INPUT_DATA на STDIN. get_to_end. При этом все данные из STDIN будут прочитаны в виде строки, передав их в качестве аргумента методу #process. Мы также заменили puts на STDOUT.puts, которые семантически эквивалентны, но это просто проясняет, куда направляются выходные данные.

Остальная логика внутри нашего типа процессора остается прежней, включая String.build, чтобы вернуть вывод jq в виде строки, чтобы мы могли преобразовать его обратно в YAML перед выводом на терминал. Однако в следующем разделе будут представлены некоторые рефакторинги, которые сделают это ненужным.

Мы можем убедиться, что наше изменение работает, запустив echo $'---\n- id: 1\n author:\n name: Jim\n- id: 2\n author:\n name: Bob\n' | crystal src/transform_cli.cr '[.[] | {"id": (.id + 1), "name": .author.name}]', который должен выводиться так же, как и раньше:


---

- id: 2

   name: Jim

- id: 3

   name: Bob


Хотя сейчас мы читаем входные данные из STDIN, было бы также хорошим улучшением, если бы мы разрешили передачу входного файла для чтения входных данных. Crystal определяет константу ARGF, которая позволяет считывать данные из файла и возвращаться к STDIN, если файлы не предоставлены. ARGF также является вводом-выводом, поэтому мы можем просто заменить STDIN на ARGF в src/transform_cli.cr. Мы можем проверить это изменение, записав выходные данные последнего вызова в файл, скажем, input.yaml. Затем запустите приложение, передав файл в качестве второго аргумента после фильтра. Полная команда будет выглядеть так: crystal src/transform_cli.cr. input.yaml. Однако при запуске вы заметите ошибки: Необработанное исключение: Ошибка чтения файла: Является каталогом (IO::Error). Вы можете задаться вопросом, почему это так, но ответ заключается в том, как работает ARGF.

ARGF сначала проверит, пуст ли ARGV. Если да, то будет выполнено чтение из STDIN. Если ARGV не пуст, предполагается, что каждое значение в ARGV представляет файл для чтения. В нашем случае ARGV не пуст, поскольку содержит [.", "input.yaml"], поэтому он пытается прочитать первый файл, который в данном случае представляет собой точку, обозначающую текущую папку. Поскольку папку нельзя прочитать как файл, возникает исключение, которое мы видели. Чтобы обойти эту проблему, нам нужно убедиться, что ARGV содержит только тот файл, который мы хотим прочитать, прежде чем вызывать ARGF#gets_to_end. Самый простой способ справиться с этой проблемой — вызвать метод #shift для ARGV, который работает, поскольку это массив. Этот метод удаляет первый элемент массива и возвращает его, в результате чего в ARGV остается только файл.

Однако есть еще одна проблема, которую нам также необходимо решить. Поскольку мы используем ARGV напрямую для предоставления входных аргументов jq, нам нужно будет провести некоторый рефакторинг, чтобы иметь возможность получить доступ к фильтру перед вызовом #gets_to_end. Мы можем добиться этого, переместив часть логики из src/transform_cli.cr в src/processor.cr! Обновите src/processor.cr, чтобы он выглядел так:


class Transform::Processor

  def process : Nil

    filter = ARGV.shift

    input = ARGF.gets_to_end


    output_data = String.build do |str|

      Process.run(

        "jq",

        [filter],

        input: IO::Memory.new(

        Transform::YAML.deserialize input

        ),

        output: str

      )

    end


      STDOUT.puts Transform::YAML.serialize output_data

  end

end


Ключевым дополнением здесь является введение filter = ARGV.shift, который гарантирует, что остальная часть ARGV будет содержать только тот файл, который мы хотим использовать в качестве входных данных. Затем мы используем нашу переменную как единственный элемент в массиве, представляющий аргументы, которые мы передаем в jq, заменяя жестко закодированную ссылку ARGV.

Также обратите внимание, что мы удалили входной аргумент из метода #process. Причина этого в том, что все входные данные теперь получаются изнутри самого метода, и поэтому нет смысла принимать внешние входные данные. Еще одним примечательным изменением было изменение типа возвращаемого значения метода на Nil, поскольку мы выводим его непосредственно в STDOUT. Это немного снижает гибкость метода, но об этом также будет сказано в следующем разделе.

Есть еще одна вещь, которую нам нужно обработать, прежде чем мы сможем объявить рефакторинг завершенным: что произойдет, если в jq будет передан недопустимый фильтр (или данные)? В настоящее время это вызовет не очень дружелюбное исключение. Что нам действительно нужно сделать, так это проверить, успешно ли выполнен jq, и если нет, записать сообщение об ошибке в STDERR и выйти из приложения, внеся следующие изменения в src/processor.cr:


class Transform::Processor

  def process : Nil

    filter = ARGV.shift

    input = ARGF.gets_to_end


    output_data = String.build do |str|

      run = Process.run(

        "jq",

        [filter],

        input: IO::Memory.new(

          Transform::YAML.deserialize input

        ),

        output: str,

        error: STDERR

      )


    exit 1 unless run.success?

    end


    STDOUT.puts Transform::YAML.serialize output_data

  end

end


Два основных улучшения заключаются в том, что любой вывод ошибок, возникающий во время работы jq, должен выводиться в STDERR и что программа должна завершиться раньше, если jq не выполнился успешно.

Эти два улучшения позволяют пользователю понять, что пошло не так, и предотвращают дальнейшее выполнение приложения, которое в противном случае привело бы к попытке преобразовать сообщение об ошибке в YAML.

Поддержка других IO

В последнем разделе мы уже внесли немало улучшений: нам больше не нужно жестко кодировать входные данные, и мы лучше справляемся с ошибками, исходящими от jq. Но помните, как мы также хотели поддержать использование нашего приложения в контексте библиотеки? Как кто-то будет обрабатывать тело ответа HTTP и выводить его в файл, если наш процессор тесно связан с концепцией терминала?

В этом разделе мы собираемся устранить этот недостаток, снова проведя рефакторинг, чтобы разрешить любой тип IO, а не только типы IO на основе терминала.

Первым шагом в этом является повторное введение аргументов в Processor#process: один для входных аргументов, входной IO, выходной IO и IO ошибок. В конечном итоге это будет выглядеть так:


class Transform::Processor

  def process(input_args : Array(String), input : IO,

    output : IO, error : IO) : Nil

    filter = input_args.shift

    input = input.gets_to_end


    output_data = String.build do |str|

      run = Process.run(

        "jq",

        [filter],

        input: IO::Memory.new(

          Transform::YAML.deserialize input

        ),

        output: str,

        error: error

      )

      exit 1 unless run.success?

    End


    output.puts Transform::YAML.serialize output_data

  end

end


Затем мы, конечно, должны обновить связанные константы, добавив в них новые переменные-аргументы. Как упоминалось ранее, вывод этого метода непосредственно в STDOUT делал его не таким гибким, как тогда, когда он просто возвращал окончательные преобразованные данные. Однако теперь, когда он поддерживает любой тип IO в качестве вывода, кто-то может легко использовать String.build для получения строки преобразованных данных. Далее нам нужно будет обновить нашу логику преобразования, чтобы она также основывалась на IO.

Откройте src/yaml.cr и обновите первый аргумент, чтобы он принимал IO, а также добавьте еще один аргумент IO, который будет представлять выходные данные. Оба метода .parse поддерживают String | IO входы, поэтому нам там ничего особенного делать не нужно. Методы #to_* также имеют перегрузку на основе IO, которой мы передадим новый выходной аргумент. Наконец, поскольку этот метод больше не будет возвращать преобразованные данные в виде строки, мы можем обновить тип возвращаемого значения на Nil. В конечном итоге это должно выглядеть следующим образом:


require "yaml"

require "json"


module Transform::YAML

  def self.deserialize(input : IO, output : IO) : Nil

    ::YAML.parse(input).to_json output

  end


  def self.serialize(input : IO, output : IO) : Nil

    JSON.parse(input).to_yaml output

  end

end


Поскольку мы добавили второй аргумент, нам, конечно, также потребуется обновить процессор для передачи второго аргумента. Аналогичным образом, поскольку сейчас мы работаем исключительно с операциями IO, нам нужно будет реализовать новый способ хранения/перемещения данных. Мы можем решить обе эти задачи, используя объекты IO::Memory для хранения преобразованных данных. Кроме того, поскольку они сами относятся к типу IO, мы можем передавать их непосредственно в качестве входных данных в jq.

Конечный результат этого рефакторинга следующий:


class Transform::Processor

  def process(input_args : Array(String), input : IO,

    output : IO, error : IO) : Nil

    filter = input_args.shift


    input_buffer = IO::Memory.new

    output_buffer = IO::Memory.new


    Transform::YAML.deserialize input, input_buffer

    input_buffer.rewind


    run = Process.run(

      "jq",

      [filter],

      input: input_buffer,

      output: output_buffer,

      error: error

    )


    exit 1 unless run.success?


    output_buffer.rewind

    Transform::YAML.serialize output_buffer, output

  end

end


Мы все еще смещаем фильтр с входных аргументов. Однако вместо использования #gets_to_end для получения всех данных из IO мы теперь создаем два экземпляра IO::Memory — первый для хранения данных JSON из преобразования десериализации, а второй для хранения выходных данных JSON через jq.

По сути, это работает так: процесс десериализации будет использовать все данные входного типа IO, выводя преобразованные данные в первый IO::Memory. Затем мы передаем его в качестве входных данных в jq, который записывает обработанные данные во второй IO::Memory. Затем второй экземпляр передается в качестве входного типа IO в метод serialize, который выводит данные непосредственно в выходной тип IO.

Еще один ключевой момент, на который стоит обратить внимание, — это то, как нам нужно вызывать .rewind для буферов до/после запуска логики преобразования. Причина этого связана с тем, как работает IO::Memory. По мере записи в него данных он продолжает добавлять данные в конец.

Другой способ подумать об этом — представить, что вы пишете эссе. Чем длиннее и длиннее эссе, тем дальше и дальше вы отходите от начала. Вызов .rewind имеет тот же эффект, как если бы вы переместили курсор обратно в начало эссе. Или, в случае с нашим буфером, он сбрасывает буфер, чтобы будущие чтения начинались с самого начала. Если бы мы этого не сделали, jq — и наша логика преобразования — начали бы читать с конца буфера, что привело бы к некорректному выводу, поскольку он по существу пуст.

Следуя нашей идее разрешить использование нашего приложения в чужом проекте, нам нужно улучшить еще одну вещь. В настоящее время мы выходим из процесса, если вызов jq завершается неудачей. Было бы нехорошо, если бы кто-то использовал это, например, в веб-фреймворке, а мы случайно отключили его сервер! К счастью, исправить это просто. Вместо вызова exit 1 нам следует просто вызвать исключение, которое мы можем проверить в точке входа, специфичной для CLI. Или, другими словами, замените эту строку на raise RuntimeError.new, если только run.success?. Затем обновите src/transform_cli.cr следующим образом:


require "./transform"


begin

  Transform::Processor.new.process ARGV, STDIN, STDOUT, STDERR rescue ex : RuntimeError

  exit 1

end


Сделав это таким образом, мы по-прежнему будем иметь правильный код завершения при использовании в качестве CLI, но также сможем лучше использовать наше приложение в контексте библиотеки, поскольку исключение можно будет спасти и корректно обработать. Но подождите — мы много говорили об использовании нашего приложения в качестве библиотеки в другом проекте, но как это выглядит?

Во-первых, пользователям нашей библиотеки необходимо будет установить наш проект как сегмент — подробнее об этом в Главе 8 «Использование внешних библиотек». Тогда они могли бы потребовать, чтобы наш src/transform.cr имел доступ к нашему процессору и логике преобразования. Это было бы намного сложнее, если бы мы не использовали отдельную точку входа для контекста CLI. Отсюда они могли создать тип Processor и использовать его в соответствии со своими потребностями. Например, предположим, что они хотят обработать тело ответа HTTP-запроса, выведя преобразованные данные в файл. Это будет выглядеть примерно так:


require "http/client"

require "transform"


private FILTER = %({"name": .info.title, "swagger_version": .swagger, "endpoints": .paths | keys})


HTTP::Client.get "https://petstore.swagger.io/v2/swagger.yaml" do

    |response|

  File.open("./out.yml", "wb") do |file|

    Transform::Processor.new.process [FILTER], response.body_io, file

  end

end


В результате файл будет следующим:


---

name: Swagger Petstore swagger_version: "2.0" endpoints:

- /pet

- /pet/findByStatus

- /pet/findByTags

- /pet/{petId}

- /pet/{petId}/uploadImage

- /store/inventory

- /store/order

- /store/order/{orderId}

- /user

- /user/createWithArray

- /user/createWithList

- /user/login

- /user/logout

- /user/{username}


Эта способность может быть очень ценной для кого-то другого, поскольку может означать, что им не придется реализовывать эту логику самостоятельно.

Теперь, когда и наш процессор, и типы преобразования используют IO, мы можем сделать еще одну оптимизацию. Текущая логика преобразования использует метод класса .parse в соответствующем модуле формата. Этот метод очень удобен, но имеет один главный недостаток: он загружает все входные данные в память. Возможно, это не проблема для небольших тестов, которые мы проводили, но представьте себе, что вы пытаетесь преобразовать гораздо более крупные файлы/входные данные? Вполне вероятно, что это приведет к тому, что наше приложение будет использовать много (и, возможно, исчерпать) памяти.

К счастью для нас, JSON и, как следствие, YAML являются форматами потоковой сериализации. Другими словами, вы можете переводить один формат в другой по одному символу за раз, не загружая все данные заранее. Как упоминалось ранее, это одно из основных преимуществ создания нашего приложения на основе IO. Мы можем использовать это, обновив нашу логику преобразования для вывода преобразованных выходных данных, одновременно анализируя входные данные. Начнем с метода .deserialize в src/yaml.cr. Код этого метода довольно длинный, его можно найти на Github по адресу https://github.com/PacktPublishing/Crystal-Programming/blob/main/Chapter05/yaml_v2.cr.

Здесь много всего происходит, поэтому давайте немного разберем алгоритм:

1. Мы начинаем использовать некоторые новые типы в модуле каждого формата вместо того, чтобы оба они полагались на метод .parse:

YAML::PullParser позволяет использовать входной токен YAML токеном по требованию, поскольку данные доступны из типа входного IO. Он также предоставляет метод, который возвращает тип токена, который он анализирует в данный момент.

JSON::Builder, с другой стороны, используется для создания JSON с помощью объектно-ориентированного API, записывая JSON в выходной тип IO.

2. Мы используем эти два объекта совместно для одновременного анализа YAML и вывода JSON. По сути, алгоритм начинает чтение потока данных YAML, запуская цикл, который будет продолжаться до конца документа YAML, переводя соответствующий токен YAML в его аналог JSON.


Метод .serialize следует той же общей идее: код также доступен на Github в том же файле.

Однако в этом случае алгоритм существенно обратный. Мы используем анализатор JSON и построитель YAML. Давайте проведем тест и посмотрим, насколько это помогло.

Тестирование производительности

Для тестирования я буду использовать реализацию GNU утилиты time с опцией -v для подробного вывода. В качестве входных данных я буду использовать файл invItems.yaml, который можно найти в папке этой главы на GitHub. Входные данные не имеют особого значения, если они представлены в формате YAML, но я выбрал эти данные, потому что они довольно большие — 53,2 МБ. Чтобы выполнить тест, мы выполним следующие шаги:

1. Начните со старой версии кода, поэтому обязательно вернитесь к старому коду, прежде чем продолжить.

2. Соберите двоичный файл в режиме выпуска с помощью shards build --release. Поскольку мы хотим протестировать производительность нашего приложения, а не jq, мы просто будем использовать идентификационный фильтр, чтобы не загружать jq дополнительной работой.

3. Запустите тест через /usr/bin/time -v ./bin/transform . invItems.yaml > /dev/null. Поскольку нас не волнует фактический вывод, мы просто перенаправляем вывод в /dev/null. Эта команда выведет довольно много информации, но нас действительно волнует одна строка — Максимальный размер резидентного набора (кбайт), который представляет общий объем памяти, используемой процессом в килобайтах. В моем случае это значение было 1 432 592, а это значит, что наше приложение потратило почти 1,5 ГБ на преобразование этих данных!

Затем восстановите новый код и снова выполните предыдущие шаги, чтобы увидеть, приведут ли наши изменения к улучшению использования памяти. На этот раз у меня получилось 325 352, что более чем в 4 раза меньше, чем раньше!

До сих пор во входном IO находились данные для обработки либо из входного файла, либо из STDIN. Однако что произойдет, если наше приложение ожидает входные данные, но данных для обработки нет? В следующем разделе мы собираемся изучить, как ведет себя ввод-вывод в этом сценарии.

Объяснение поведения IO

Если вы создадите и запустите приложение как ./bin/transform ., оно просто будет зависать на неопределенный срок. Причина этого связана с тем, как большая часть операций ввода-вывода работает в Crystal. Большая часть операций IO является блокирующей по своей природе, то есть будет ожидать поступления данных через тип входного IO, в данном случае STDIN. Лучше всего это можно продемонстрировать с помощью этой простой программы:


print "What is your name? "

if (name = gets).presence

    puts "Your name is: '#{name}'"

else

    puts "No name supplied"

end


Метод get используется для чтения строки из STDIN и будет ждать, пока она не получит данные или пользователь не прервет команду. Такое поведение также справедливо для IO, не связанного с терминалом, например для тел ответов HTTP. Причины и преимущества такого поведения будут объяснены в следующей главе.

Резюме

В этой главе мы добились фантастического прогресса в работе над приложением. Мы не только сделали его действительно удобным для использования, поддержав терминальный IO, но и сделали его еще более гибким, чем раньше, разрешив использовать любой IO. Мы также значительно повысили эффективность нашей логики преобразования, выполнив потоковое преобразование. Наконец, мы немного узнали о блокирующей природе IO, что подготовило почву для следующей главы.

IO является основной частью любого приложения, которое выполняет чтение/запись данных. Наличие знаний о том, когда их использовать и, что более важно, как ими воспользоваться, в конечном счете приведет к созданию более эффективных программ. В этой главе также был затронут вопрос о правильном дизайне приложения, рассмотренный в предыдущей главе, и приведено несколько примеров того, как небольшие изменения могут значительно повысить общую полезность приложения.

В следующей главе мы рассмотрим концепцию параллелизма и то, как она может позволить нашему приложению обрабатывать множество файлов.

6. Параллелизм (Concurrency)

В некоторых сценариях программе может потребоваться обработка нескольких фрагментов работы, например суммирование количества строк в серии файлов. Это прекрасный пример проблемы, которую может помочь решить параллелизм, позволяя программе выполнять фрагменты работы, ожидая выполнения других. В этой главе мы узнаем, как работает параллелизм в Crystal, и рассмотрим следующие темы:

• Использование волокон для одновременного выполнения работы

• Использование каналов для безопасной передачи данных

• Одновременное преобразование нескольких файлов


К концу этой главы вы сможете понять разницу между параллелизмом и параллелизмом, как использовать волокна для выполнения нескольких одновременных задач и как использовать каналы для правильного обмена данными между волокнами. Вместе эти концепции позволяют создавать многозадачные программы, что приводит к повышению производительности кода.

Технические требования

Прежде чем мы углубимся в эту главу, в вашей системе должно быть установлено следующее:

• Рабочая установка Crystal

• Рабочая установка jq

Инструкции по настройке Crystal можно найти в Главе 1 «Введение в Crystal». Обратите внимание, что jq, скорее всего, можно установить с помощью менеджера пакетов в вашей системе. Однако вы также можете установить его вручную, загрузив с https://stedolan.github.io/jq/download.

Все примеры кода, использованные в этой главе, можно найти в папке Chapter 6 на GitHub по адресу https://github.com/PacktPublishing/Crystal-Programming/tree/main/Chapter06.

Использование волокон (fibers ) для одновременного выполнения работы

Волокно представляет собой часть работы, которая должна выполняться либо одновременно с другими волокнами, либо в какой-то момент в будущем, когда появятся свободные циклы. Они похожи на потоки операционной системы, но более легкие и управляются изнутри Crystal. Прежде чем мы углубимся, важно отметить, что параллелизм — это не то же самое, что параллелизм, но они связаны между собой.

В параллельном коде на различные фрагменты работы тратится немного времени, при этом в определенный момент времени выполняется только часть работы. С другой стороны, параллельный код позволяет одновременно выполнять несколько фрагментов работы. На практике это означает, что по умолчанию одновременно выполняется только одно волокно. В Crystal есть поддержка параллелизма, который позволяет одновременно выполнять более одного волокна, но он все еще считается экспериментальным. По этой причине мы собираемся сосредоточиться на параллелизме.

Мы уже использовали волокна под капотом во всем коде, с которым работали до сих пор. Весь код Crystal выполняется внутри собственного основного волокна. Кроме того, мы можем создавать собственные волокна с помощью метода spawn, который принимает блок, представляющий работу, которую необходимо выполнить в этом волокне. В качестве примера возьмем следующую программу:


puts "Hello program!"


spawn do

   puts "Hello from fiber!"

end


puts "Goodbye program!"


Если бы вы запустили это приложение, оно выдало бы следующее:


Hello program!

Goodbye program!


Но подождите! Что случилось с сообщением в fiber, которое мы создали? Ответ можно найти в начале главы, в разделе "Определение fiber". Ключевые слова появятся в какой-то момент в будущем. Создание fiber не приводит к немедленному выполнению fiber. Вместо этого он запланирован для выполнения планировщиком Crytal. Планировщик выполнит следующий поставленный в очередь fiber при первой возможности. В этом примере такой возможности никогда не возникает, поэтому fiber никогда не выполняется.

Это важная деталь для понимания того, как работает параллелизм в Crystal, а также того, почему природа IO, рассмотренная в Главе 5 "Операции ввода/вывода", может быть настолько полезной. К числу факторов, которые могут привести к выполнению другого fiber, относятся следующие:

• Метод sleep

Fiber.yield метод

• Операции, связанные с IO, такие как чтение/запись в файл или сокет

• Ожидание получения значения из канала

• Ожидание отправки значения в канал

• Когда текущее волокно завершит выполнение


Все эти параметры блокируют волокно, в результате чего другие волокна получают возможность выполниться. Например, добавьте sleep 1 после блока появления и перезапустите программу. Обратите внимание: на этот раз Hello from fiber! действительно печатается. Метод sleep сообщает планировщику, что он должен продолжить выполнение основного волокна через одну секунду. Тем временем он может свободно выполнить следующее волокно в очереди, которое в данном случае печатает наше сообщение.

Метод Fiber.yield, или sleep 0, даст тот же результат, но означает немного другое. При использовании метода sleep с целочисленным аргументом планировщик знает, что он должен вернуться к этому волокну в какой-то момент в будущем после того, как он достаточно отоспался. Однако использование Fiber.yield или sleep 0 позволит проверить, есть ли волокна, ожидающие выполнения, и если да, выполнить их. В противном случае это будет продолжаться без переключения. Такое поведение наиболее распространено, когда вы выполняете некоторую логику в узком цикле, но все же хотите дать возможность другим волокнам выполниться. Однако Fiber.yield просто сообщает планировщику, что вы можете запустить другое волокно, но не гарантирует, когда и если выполнение переключится обратно на это исходное волокно.

В обоих случаях единственная причина, по которой выполнение вообще переключается обратно на основное волокно, заключается в том, что что-то внутри волокна выполняет одно из действий, которые могут вызвать выполнение другого волокна. Если бы вы удалили путы и волокно состояло бы только из бесконечного цикла, это заблокировало бы волокно навсегда, и программа никогда бы не завершила работу. Если вы хотите разрешить выполнение других файберов и навсегда заблокировать основной файбер, вы можете использовать sleep без каких-либо аргументов. Это будет держать основное волокно в режиме ожидания и выполнять другие волокна по мере их появления.

Продолжая предыдущий пример, вы можете захотеть использовать переменные внутри волокна, которые были определены за его пределами. Однако это плохая идея, поскольку она приводит к неожиданным результатам:


idx = 0


while idx < 4

    spawn do

        puts idx

    end


    idx += 1

end


Fiber.yield


Вы могли бы ожидать, что предыдущий код напечатает числа от одного до четырех, но на самом деле он печатает число четыре четыре раза. Причина этого двоякая:

• Волокна не выполняются немедленно.

• Каждое волокно ссылается на одну и ту же переменную.


Поскольку волокна не выполняются немедленно, они создаются при каждой итерации цикла while loop. После четырех раз значение idx достигает четырех и выходит из цикла while loop. Затем, поскольку каждое волокно ссылается на одну и ту же переменную, все они печатают текущее значение этой переменной, равное 4. Эту проблему можно решить, переместив порождение каждого волокна в отдельный процесс, который создаст замыкание, фиксирующее значение переменная на каждой итерации. Однако это далеко не идеально, поскольку в этом нет необходимости и ухудшается читаемость кода. Лучший способ справиться с этим — использовать альтернативную форму spawn, которая принимает вызов в качестве аргумента:


idx = 0


while idx < 4

    spawn puts idx

    idx += 1

end


Fiber.yield


Это внутренне обрабатывает создание и выполнение Proc, что позволяет сделать код гораздо более читаемым. Использование методов с блоками, например 4.times { |idx| spawn { puts idx } }, работает как положено. Этот сценарий представляет собой проблему только при ссылке на одну и ту же локальную переменную, переменную класса или экземпляра во время итерации. Это также яркий пример того, почему совместное использование состояния непосредственно внутри волокон считается плохой практикой. Правильный способ сделать это — использовать каналы, которые мы рассмотрим в следующем разделе.

Использование каналов для безопасной передачи данных

Если совместное использование переменных между волокнами не является правильным способом взаимодействия между волокнами, то что? Ответ – каналы. Канал — это способ связи между волокнами без необходимости беспокоиться об условиях гонки, блокировках, семафорах или других специальных структурах. Давайте посмотрим на следующий пример:


input_channel = Channel(Int32).new

output_channel = Channel(Int32).new


spawn do

    output_channel.send input_channel.receive * 2

end


input_channel.send 2


puts output_channel.receive


В предыдущем примере создаются два канала, содержащие входные и выходные значения Int32. Затем он порождает волокно, которое сначала получает значение из входного канала, удваивает его и отправляет в выходной канал. Затем мы отправляем входному каналу начальное значение 2 и, наконец, печатаем результат, который получаем обратно из выходного канала. Как упоминалось в предыдущем разделе, само волокно не выполняется ни при его создании, ни при отправке ему значения. Ключевой частью этого примера является последний вызов приема на выходном канале. Этот вызов блокирует основной файбер до тех пор, пока он не получит обратно значение, в результате чего наш файбер будет выполнен и напечатан окончательный результат 4.

Давайте посмотрим на другой пример, который сделает поведение более понятным:


channel = Channel(Int32).new


spawn do

  loop do

    puts "Waiting"

    sleep 0.5

  end

end


spawn do

  sleep 2


  channel.send channel.receive * 2

  sleep 1

  channel.send channel.receive * 3

  end


channel.send 2


puts channel.receive


channel.send 3


puts channel.receive


Запуск программы приводит к следующему выводу:


Waiting

Waiting

Waiting

Waiting

4

Waiting

Waiting

9


Первые результаты отправки и получения во втором волокне выполняются первыми. Однако первая строка — это sleep 2, поэтому она делает именно это. Поскольку спящий режим является блокирующей операцией, планировщик Crystal выполнит следующее ожидающее волокно, то есть то, которое печатает Waiting, а затем в цикле ожидает полсекунды. Это сообщение выводится четыре раза, что соответствует двухсекундному спящему режиму, за которым следует ожидаемый результат 4. Затем выполнение возвращается ко второму волокну, но сразу же переходит к первому волокну из-за sleep 1, что печатает Ожидание еще дважды, прежде чем отправить ожидаемый вывод 9 обратно в канал.

В обоих примерах мы работали с небуферизованными каналами. Небуферизованный канал продолжит выполнение на волокне, ожидающем получения отправленного значения из канала. Другими словами, именно поэтому выполнение программы возвращается к основному волокну для печати значения вместо продолжения выполнения второго волокна.

С другой стороны, буферизованный канал не будет переключаться на другое волокно при вызове отправки, если буфер не заполнен. Буферизованный канал можно создать, передав размер буфера конструктору канала. Например, взгляните на следующее:


channel = Channel(Int32).new 2


spawn do

  puts "Before send 1"

  channel.send 1

  puts "Before send 2"

  channel.send 2

  puts "Before send 3"

  channel.send 3

  puts "After send"

end


3.times do

  puts channel.receive

end


Это выведет следующее:


Before send 1

Before send 2

Before send 3

After send

1

2

3


Теперь, если мы запустим тот же код с небуферизованным каналом, результат будет следующий:


Before send 1

Before send 2

1

2

Before send 3

After send

3


В обоих случаях первое значение было отправлено, как и следовало ожидать. Однако два типа каналов начинают различаться, когда отправляется второе значение. В случае без буферизации ожидающий получатель отсутствует, поэтому канал запускает перепланирование, в результате чего выполнение переключается обратно на основное волокно. После печати первых двух значений выполнение переключается обратно на волокно и отправляет третье значение. Это приводит к перепланированию, при котором выполнение будет переключено обратно на основное волокно, когда в следующий раз появится такая возможность. В данном конкретном случае такой шанс появляется после печати конечного сообщения и когда в волокне больше нечего выполнять.

В случае с буферизацией первое отправленное значение выполняет команду channel.receive, которая первоначально вызвала выполнение оптоволокна. В буфер добавляется второе значение, за ним следует третье значение и, наконец, конечное сообщение. На этом этапе волокно завершает выполнение, поэтому выполнение переключается обратно на основное волокно, печатая все три значения: они включают одно из начального приема плюс два из буфера канала. Давайте добавим еще одно значение к волокну, добавив puts “Before send 4” и channel.send 4 перед конечным сообщением. Затем обновите цикл, чтобы сказать 4.times do. Повторный запуск программы дает следующий результат:


Before send 1

Before send 2

Before send 3

Before send 4

1

2

3

4


Обратите внимание, что на этот раз конечное сообщение не было напечатано. Это связано с тем, что второе и третье значения укладываются в размер буфера, равный 2. Однако, когда отправляется четвертое значение, буфер больше не может обрабатывать дополнительные значения, поэтому канал запускает перепланирование, в результате чего выполнение переключается на основное волокно снова. Поскольку первое значение было отправлено как часть исходного канала channel.recieve, а второе, третье и четвертое значения уже находятся в буфере канала, они печатаются так, как и следовалоожидать. Однако к этому моменту основное волокно уже получило четыре желаемых значения. Поэтому у него никогда не будет возможности возобновить выполнение волокна, чтобы распечатать конечное сообщение.

Во всех этих примерах мы получали значение из одного канала. Но что, если вы хотите использовать первые значения, полученные из набора из нескольких каналов? Здесь в игру вступает ключевое слово select (не путать с методом #select). Ключевое слово select позволяет вам ожидать на нескольких каналах и выполнять некоторую логику в зависимости от того, какой из них получит значение первым. Кроме того, он поддерживает работу логики, если все каналы заблокированы и по истечении заданного периода времени значение не получено. Начнем с простого примера:


channel1 = Channel(Int32).new

channel2 = Channel(Int32).new


spawn do

    puts "Starting fiber 1"

    sleep 3

    channel1.send 1

end


spawn do

    puts "Starting fiber 2"

    sleep 1

    channel2.send 2

end


select

when v = channel1.receive

    puts "Received #{v} from channel1"

when v = channel2.receive

    puts "Received #{v} from channel2"

end


Этот пример выводит следующее:


Starting fiber 1

Starting fiber 2

Received 2 from channel2


Здесь оба волокна начинают выполняться более или менее одновременно, но поскольку у второго волокна более короткий период сна и он завершается первым, это приводит к тому, что ключевое слово select печатает значение из этого канала и затем завершает работу. Обратите внимание, что ключевое слово select действует аналогично одиночному каналу channel.receive в том смысле, что оно блокирует основное волокно, а затем продолжает работу после получения значения из любого канала. Кроме того, мы могли бы обрабатывать несколько итераций, поместив ключевое слово select в цикл вместе с методом timeout, чтобы избежать вечной блокировки. Давайте расширим предыдущий пример, чтобы продемонстрировать, как это работает. Во-первых, давайте добавим переменную channel3, аналогичную двум другим, которые у нас уже есть. Далее давайте создадим еще одно волокно, которое отправит значение в наш третий канал. Например, взгляните на следующее:


spawn do

    puts "Starting fiber 3"

    channel3.send 3

end


Наконец, мы можем переместить наше ключевое слово select в цикл:


loop do

  select

  when v = channel1.receive

    puts "Received #{v} from channel1"

  when v = channel2.receive

    puts "Received #{v} from channel2"

  when v = channel3.receive

    puts "Received #{v} from channel3"

  when timeout 3.seconds

    puts "Nothing left to process, breaking out"

    break

  end

end


Эта версия ключевого слова select аналогична первой, но мы добавили к ней два новых предложения. Один считывает значение из третьего канала, а другой выйдет из цикла, если ни по одному каналу в течение трех секунд не будут получены данные. Вывод этой программы следующий:


Starting fiber 1

Starting fiber 2

Starting fiber 3

Received 3 from channel3

Received 2 from channel2

Received 1 from channel1

Nothing left to process, breaking out


Волокна начинают работать по порядку, но заканчивают в другом порядке из-за разной продолжительности сна. Через три секунды выполняется последнее предложение if, поскольку ничего не получено, а затем программа завершает работу.

Ключевое слово select не ограничивается только получением значений. Его также можно использовать при их отправке. Возьмем эту программу в качестве примера:


spawn_receiver = true


channel = Channel(Int32).new


if spawn_receiver

  spawn do

    puts "Received: #{channel.receive}"

  end

end


  spawn do

  select

  when channel.send 10

    puts "sent value"

  else

    puts "skipped sending value"

  end

end


Fiber.yield


Запуск этого как есть дает следующий результат:


sent value

Received: 10


Установка флага spawn_receiver в значение false и его повторный запуск приводит к пропущенному значению отправки. Причина разницы в выводе связана с поведением send в сочетании с предложением else ключевого слова select. select проверит каждое предложение if на наличие того, которое не будет блокироваться при выполнении. Однако в этом случае отправляйте блоки, поскольку нет волокна, ожидающего значения, поэтому предложение else будет выполнено, поскольку ни одно другое предложение не может быть выполнено без блокировки. Поскольку принимающее волокно не было создано, выполняется последний путь, что приводит к пропуску сообщения. В другом сценарии ожидающий получатель не позволяет блокировать отправку.

Хотя использование каналов и волокон для сигнализации о завершении единицы работы является одним из вариантов их использования, это не единственный вариант использования. Эти две концепции, а также select, можно объединить для создания довольно мощных шаблонов, таких как разрешение одновременного выполнения только определенного количества волокон, координация состояния между несколькими волокнами и каналами или обработка нескольких независимых фрагментов данных. работать одновременно. Последний имеет дополнительное преимущество: скорее всего, он уже настроен для обработки многопоточных рабочих процессов, поскольку каждое волокно может обрабатываться в отдельном потоке.

На этом этапе мы рассмотрели практически все основные концепции параллелизма в Crystal. Следующим шагом будет применение этих концепций, а также того, что было изучено в предыдущих главах, к нашему приложению CLI для поддержки одновременной обработки нескольких файлов.

Преобразование нескольких файлов одновременно

На данный момент приложение поддерживает файловый ввод, но только из одного файла. Допустимым вариантом использования может быть предоставление нескольких файлов и создание нового файла с преобразованными данными для каждого из них. Учитывая, что логика преобразования привязана к IO, одновременное выполнение этого имеет смысл и должно привести к повышению производительности.

Причина, по которой логика, связанная с IO, и параллелизм так хорошо сочетаются друг с другом, заключается в планировщике Crystal. Когда волокно достигает точки своего выполнения, когда оно зависит от некоторой части данных из IO, планировщик может легко отложить это волокно в сторону до тех пор, пока не поступят эти данные.

Более конкретным примером этого в действии было бы рассмотрение того, как функционирует стандартная библиотека HTTP::Server. Каждый запрос обрабатывается в отдельном волокне. Из-за этого, если во время обработки запроса необходимо выполнить еще один HTTP-запрос, например, для получения данных из внешнего API, Crystal сможет продолжать обрабатывать другие запросы, ожидая возвращения данных через IO сокет.

Параллелизм не сильно поможет, если часть работы связана с процессором. Однако в нашем случае чтение/запись данных в/из файлов является проблемой, связанной с вводом-выводом, что делает его идеальным кандидатом для демонстрации некоторых функций параллелизма.

Возвращаясь к нашей логике обработки нескольких файлов, давайте сначала создадим непараллельную реализацию, а затем реорганизуем ее, чтобы использовать функции параллелизма, описанные в последних двух разделах.

Прежде чем мы перейдем непосредственно к делу, давайте потратим немного времени на то, чтобы спланировать, что нам нужно сделать, чтобы поддержать это:

• Найдите способ сообщить CLI, что он должен обрабатывать файлы в режиме нескольких файлов.

• Определить новый метод, который будет обрабатывать каждый файл из ARGV.


Первое требование можно удовлетворить, поддерживая опцию CLI --multi, которая переведет его в правильный режим. Второе требование также простое, поскольку мы можем добавить еще один метод к типу Processor, чтобы также предоставить его для использования библиотекой. Во-первых, давайте начнем с метода Processor. Откройте src/processor.cr и добавьте в него следующий метод:


def process_multiple(filter : String, input_files :

  Array(String), error : IO) : Nil

    input_files.each do |file|

      File.open(file, "r") do |input_file|

        File.open("#{input_file.path}.transformed", "w") do

          |output_file|

          self.process [filter], input_file, output_file, error

        end

      end

    end

  end


Этот метод сводится к следующим шагам:

1. Определите новый метод, предназначенный для обработки нескольких входных файлов, который принимает фильтр и массив файлов для обработки.

2. Переберите каждый входной файл, используя метод File.open, чтобы открыть файл для чтения.

3. Снова используйте File.open, чтобы открыть выходной файл для записи, используя путь к входному файлу с добавлением .transformed в качестве имени выходного файла,

4. Вызовите метод одиночного ввода, передав наш фильтр в качестве единственного аргумента и используя открытые файлы в качестве входных и выходных операций IO.


Прежде чем мы сможем это протестировать, нам нужно сделать так, чтобы передача опции --multi заставляла CLI вызывать этот метод. Давайте сделаем это сейчас. Откройте src/transform_cli.cr и обновите его, чтобы он выглядел следующим образом:


require "./transform"

require "option_parser"


processor = Transform::Processor.new


multi_file_mode = false


OptionParser.parse do |parser|

  parser.banner = "Usage: transform <filter> [options]

    [arguments] [filename …]"

  parser.on("-m", "--multi", "Enables multiple file input mode") { multi_file_mode = true }

  parser.on("-h", "--help", "Show this help") do

    puts parser

    exit

  end

end


begin


  if multi_file_mode

    processor.process_multiple ARGV.shift, ARGV, STDERR

  else

    processor.process ARGV, STDIN, STDOUT, STDERR

  end

rescue ex : RuntimeError

  exit 1

end


И снова на помощь приходит стандартная библиотека Crystal в виде типа OptionParser. Этот тип позволяет вам настроить логику, которая должна выполняться, когда эти параметры передаются через ARGV. В нашем случае мы можем использовать это для определения более удобного интерфейса, который также будет поддерживать параметры -h или --help. Кроме того, он позволяет вам реагировать на флаг --multi без необходимости вручную анализировать ARGV. Код довольно прост. Если флаг передан, мы устанавливаем для переменной multi_file_mode значение true, которое используется для определения того, какой метод процессора вызывать.

Чтобы проверить это, я создал несколько простых файлов YAML в корневом каталоге проекта. Не имеет большого значения, что они собой представляют, важно лишь то, что они действительны в формате YAML. Затем я собрал наш двоичный файл и запустил его с помощью ./bin/transform --multi. file1.yml file2.yml file3.yml, утверждая, что три выходных файла были созданы должным образом. У меня это заняло ~0,1 секунды. Давайте посмотрим, сможем ли мы улучшить это, реализовав параллельную версию метода process_multiple.

Вспоминая то, что мы узнали в последних двух разделах, чтобы сделать этот метод параллельным, нам понадобится инициировать открытие файла и обрабатывать логику внутри волокна. Затем нам понадобится канал, чтобы мы могли отслеживать завершенные файлы. В конечном итоге метод должен выглядеть так:


def process_multiple(filter : String, input_files :

  Array(String), error : IO) : Nil

  channel = Channel(Bool).new


  input_files.each do |file|

    spawn do

      File.open(file, "r") do |input_file|

        File.open("#{input_file.path}.transformed", "w")

          do |output_file|

          self.process [filter], input_file, output_file, error

        end

      end

    ensure

      channel.send true

    end

  end


  input_files.size.times do

    channel.receive

  end

end


По сути, это то же самое, только с введением волокон для параллельности. Назначение канала — гарантировать, что основное волокно не выйдет из строя до завершения обработки всех файлов. Это достигается путем отправки значения true в канал после обработки файла и получения этого значения ожидаемое количество раз. Команда send находится внутри блока ensure для обработки сценария в случае сбоя процесса. Эта реализация требует немного большей доработки и будет рассмотрена в следующей главе. Я провел тот же тест, что и раньше, с параллельным кодом и получил значение от 0,03 до 0,06 секунды.

Я бы в любой день взял прирост производительности в 2-3 раза.

Резюме

И вот оно: одновременная обработка нескольких входных файлов! Параллельное программирование может быть ценным инструментом для создания высокопроизводительных приложений, позволяя разбивать рабочие нагрузки, связанные с IO, так, чтобы некоторая часть работы выполнялась постоянно. Кроме того, его можно использовать для уменьшения объема памяти приложения за счет одновременной обработки входных данных по мере их поступления, без необходимости ждать и загружать все данные в память.

На данный момент наш CLI почти готов! Теперь он может эффективно обрабатывать как одиночные, так и множественные входные файлы. Он может передавать данные в потоковом режиме, чтобы уменьшить использование памяти, и настроен для простой поддержки использования библиотек. Далее мы собираемся сделать что-то немного другое: мы собираемся поддерживать отправку уведомлений на рабочем столе о различных событиях в нашем CLI. Для этого в следующей главе мы узнаем о способности Crystal связываться с библиотеками C.

7. Совместимость c C

В этой главе основное внимание будет уделено одной из наиболее продвинутых функций Crystal: возможности взаимодействия с существующими библиотеками C путем написания привязок C. Эта функция Crystal позволяет повторно использовать высокооптимизированный и/или надежный код внутри Crystal, не написав ни строчки C и не беря на себя нетривиальную задачу по переносу всего этого в Crystal. Мы рассмотрим следующие темы:

• Знакомство с привязками C.

• Привязка libnotify

• Интеграция привязок


libnotify позволяет отправлять уведомления на рабочий стол в качестве средства предоставления пользователю ненавязчивой информации при возникновении событий. Мы собираемся использовать эту библиотеку для отправки собственных уведомлений.

К концу этой главы вы сможете писать привязки C для существующих библиотек и понимать, как лучше всего скрыть детали реализации привязок от конечного пользователя. Привязки C позволяют коду Crystal использовать высокооптимизированный код C или просто разрешать повторное использование кода без необходимости предварительного переноса всей библиотеки в Crystal.

Технические требования

Требования к этой главе следующие:

• Рабочая установка Кристалла.

• Рабочая установка jq.

• Рабочая установка libnotify.

• Рабочий компилятор C, например GCC.


Инструкции по настройке Crystal можно найти в Главе 1 «Введение в Crystal». Последние версии jq, libnotify и GCC, скорее всего, можно установить с помощью менеджера пакетов в вашей системе, но их также можно установить вручную, загрузив их с https://stedolan.github.io/jq/download, https://gitlab. gnome.org/GNOME/libnotify и https://gcc.gnu.org/releases.html соответственно. Если вы работаете с этой главой в ОС, отличной от Linux, например, macOS или Windows/WSL, то все может работать не так, как ожидалось, если вообще работать.

Все примеры кода, использованные в этой главе, можно найти в папке главы 7 на GitHub: https://github.com/PacktPublishing/Crystal-Programming/tree/main/Chapter07.

Вводим привязки на языке C

Написание привязок C предполагает использование некоторых конкретных ключевых слов и концепций Crystal для определения API библиотеки C, например, какие функции она имеет, каковы аргументы и какой тип возвращаемого значения. Затем Crystal может использовать эти определения, чтобы определить, как их использовать. Конечным результатом является возможность вызывать функции библиотеки C из Crystal без необходимости писать код C самостоятельно. Прежде чем мы углубимся непосредственно в привязку libnotify, давайте начнем с нескольких более простых примеров, чтобы представить концепции и тому подобное. Возьмем, к примеру, этот простой файл C:


#include <stdio.h>


void sayHello(const char *name)

{

    printf("Hello %s!\n", name);

}


Мы определяем одну функцию, которая принимает указатель char, представляющий имя человека, с которым можно поздороваться. Затем мы можем определить наши привязки:


@[Link(ldflags: "#{ DIR }/hello.o")]

lib LibHello

    fun say_hello = sayHello(name : LibC::Char*) : Void

end


LibHello.say_hello "Bob"


Аннотация @[Link] используется для информирования компоновщика, где найти дополнительные внешние библиотеки, которые он должен связать при создании двоичного файла Crystal. В данном случае мы указываем на объектный файл, созданный из нашего кода C — подробнее об этом позже. Далее мы используем ключевое слово lib для создания пространства имен, которое будет содержать все типы и функции привязки. В этом примере у нас есть только одна функция. Функции связываются с помощью ключевого слова fun, за которым следует обычное объявление функции Crystal с одним отличием. В обычном методе Crystal вы можете использовать возвращаемый тип Nil, однако здесь мы используем Void. Семантически они эквивалентны, но при написании привязок C предпочтительнее использовать Void. Наконец, мы можем вызывать методы, определенные в пространстве имен нашей библиотеки, как если бы они были методами класса.

Также обратите внимание, что имя, которое мы используем для вызова этой функции, отличается от имени, определенного в реализации C. Привязки Crystal C позволяют использовать псевдонимы для имен функций C, чтобы лучше соответствовать рекомендациям по стилю кода Crystal. В некоторых случаях псевдонимы могут потребоваться, если имя функции C не является допустимым именем метода Crystal, например, если оно содержит точки. В этом случае имя функции можно заключить в двойные кавычки, например, fun ceil_f32 = "llvm.ceil.f32"(value: Float32) : Float32.

Глядя на код Crystal, вы можете заметить некоторые вещи, которые могут показаться странными. Например, почему тип LibC::Char или строка “Bob” не является указателем? Поскольку Crystal также привязывается к некоторым библиотекам C для реализаций стандартной библиотеки, он предоставляет псевдонимы типам C, которые обрабатывают различия платформ. Например, если бы вы запускали программу на 32-битной машине, длина типа C составляла бы 4 байта, а на 64-битной машине — 8 байт, что соответствовало бы типам Crystal Int32 и Int64 соответственно. Чтобы лучше справиться с этой разницей, вы можете использовать псевдоним LibC::Long, который обрабатывает установку правильного типа Int в зависимости от системы, компилирующей программу.

Crystal также предоставляет некоторые абстракции, которые упрощают работу со связанными функциями. Причина, по которой мы можем передать строку функции, ожидающей указатель, заключается в том, что тип String определяет метод #to_unsafe, который возвращает указатель на содержимое строки. Этот метод определен для различных типов в стандартной библиотеке, но его также можно определить для пользовательских типов. Если этот метод определен, Crystal вызовет его, ожидая, что он вернет правильное значение, которое должно быть передано соответствующей функции C.

Как упоминалось ранее, прежде чем мы сможем запустить нашу программу Crystal, нам необходимо создать объектный файл для кода C. Это можно сделать с помощью различных компиляторов C, но я буду создавать это через GCC, выполнив команду gcc -Wall -O3 -march=native -c hello.c -o hello.o. У нас уже есть аннотация ссылки, ссылающаяся на только что созданный файл hello.o, поэтому все, что осталось сделать, это запустить программу через кристалл hello.cr, который выдает вывод Hello Bob!.

Функций привязки будет недостаточно для использования libnotify; нам также нужен способ представления самого объекта уведомления в форме структуры C. Они также определены в пространстве имен lib, например:


#include <stdio.h>


struct TimeZone {

  int minutes_west;

  int dst_time;

};


void print_tz(struct TimeZone *tz)

{

  printf("DST time is: %d\n", tz->dst_time);

}


Здесь мы определяем структуру C под названием TimeZone, которая имеет два свойства int. Затем мы определяем функцию, которая будет печатать свойство времени летнего времени указателя на эту структуру. Соответствующая привязка Crystal будет выглядеть следующим образом:


@[Link(ldflags: "#{__DIR__}/struct.o")]

lib LibStruct

  struct TimeZone

    minutes_west : Int32

    dst time : Int32

  end


  fun print_tz(tz : TimeZone*) : Void

end


tz = LibStruct::TimeZone.new

tz.minutes_west = 1

tz.dst_time = 14


LibStruct.print_tz pointerof(tz)


Определение этой структуры позволяет создать ее экземпляр, как и любой другой объект, через .new. Однако, в отличие от предыдущего примера, мы не можем передать объект непосредственно в функцию C. Это связано с тем, что структура определена в пространстве имен lib, ожидает указатель на нее и не имеет метода #to_unsafe. В следующем разделе будет рассказано, как лучше всего с этим справиться.

Компиляция объектного файла и запуск программы Crystal, как и раньше, выведет: Время летнего времени: 14.

Еще одна распространенная функция привязки C — поддержка обратных вызовов. Crystal, эквивалентный указателю на функцию C, — это Proc. Лучше всего это показать на примере. Давайте напишем функцию C, которая принимает обратный вызов, принимающий целочисленное значение. Функция C сгенерирует случайное число, а затем вызовет обратный вызов с этим значением. В конечном итоге это может выглядеть примерно так:


#include <stdlib.h>

#include <time.h>


void number_callback(void (*callback)(int))

{

  srand(time(0));

  return (*callback)(rand());

}


Привязки Crystal будут выглядеть так:


@[Link(ldflags: "#{__DIR__}/callback.o")]

lib LibCallback

  fun number_callback(callback : LibC::Int -> Void) : Void

  end


LibCallback.number_callback ->(value) { puts "Generated: #{value}" }


В этом примере мы передаем Proc(LibC::Int, Nil) в качестве значения аргумента обратного вызова C. Обычно вам нужно будет ввести значение аргумента Proc. Однако, поскольку мы передаем Proc напрямую, компилятор может определить его на основе типа привязанного развлечения и ввести его за нас. Тип обязателен, если мы сначала присвоили его переменной, например callback = ->(value : LibC::Int) { ... }.

Обратный вызов напечатает, какое случайное значение сгенерировал код C. Помните: прежде чем мы сможем запустить код Crystal, нам нужно скомпилировать код C в объектный файл с помощью этой команды: gcc -Wall -O3 -march=native -c callback.c -o callback.o. После этого вы можете свободно запускать код Crystal несколько раз и утверждать, что он каждый раз генерирует новое число.

Хотя мы можем передавать Procs как функцию обратного вызова, вы не можете передать замыкание, например, если вы попытались сослаться на переменную, определенную вне Proc внутри него. Например, если мы хотим умножить сгенерированное значение C на некоторый множитель:


multiplier = 5

LibCallback.number_callback ->(value : LibC::Int) { puts

value * multiplier }


Выполнение этого приведет к ошибке времени компиляции: Ошибка: невозможно отправить замыкание в функцию C (замыкающие переменные: множитель).

Передача замыкания возможна, но это немного сложнее. Я бы предложил проверить этот пример в документации Crystal API: https://crystal-lang.org/api/Proc.html#passing-a-proc-to-a-c-function. Как упоминалось ранее, привязки C могут быть отличным способом использования уже существующего кода C. Теперь, когда вы знаете, как подключаться к библиотеке, писать привязки и использовать их в Crystal, вы можете фактически использовать код библиотеки C. Далее перейдем к написанию привязок для libnotify.

Привязка libnotify

Одним из преимуществ написания привязок C в Crystal является то, что вам нужно привязывать только то, что вам нужно. Другими словами, нам не нужно полностью привязывать libnotify, если мы собираемся использовать лишь небольшую его часть. На самом деле нам нужны всего четыре функции:

• notify_init – используется для инициализации libnotify.

• notify_uninit — используется для деинициализации libnotify.

• notify_notification_new — используется для создания нового уведомления.

• notify_notification_show – используется для отображения объекта уведомления.


В дополнение к этим методам нам также необходимо определить одну структуру NotifyNotification, которая представляет собой отображаемое уведомление.

Я определил это, просмотрев файлы *.h libnotify на GitHub: https://github.com/GNOME/libnotify/blob/master/libnotify. HTML-документация Libnotify также включена в папку этой главы на GitHub, и ее можно использовать в качестве дополнительной справки.

Основываясь на информации из их документации, исходном коде и том, что мы узнали в последнем разделе, привязки, которые нам нужны для libnotify, будут выглядеть следующим образом:


@[Link("libnotify")]

lib LibNotify

  alias GInt = LibC::Int

  alias GBool = GInt

  alias GChar = LibC::Char


  type NotifyNotification = Void*


  fun notify_init(app_name : LibC::Char*) : GBool

  fun notify_uninit : Void


  fun notify_notification_new(summary : GChar*, body :

    GChar*, icon : GChar*) : NotifyNotification*

  fun notify_notification_show(notification :

    NotifyNotification*, error : Void**) : GBool

  fun notify_notification_update(notification :

    NotifyNotification*, summary : GChar*, body : Gchar*, icon : GChar*) : GBool

end


Обратите внимание: в отличие от других случаев, мы можем просто передать “libnotify” в качестве аргумента аннотации Link. Мы можем это сделать, поскольку соответствующая библиотека уже установлена в масштабе всей системы, а не является созданным нами специальным файлом.

Под капотом Crystal использует https://www.freedesktop.org/wiki/Software/pkg-config, если таковой имеется, чтобы определить, что следует передать компоновщику для правильного связывания библиотеки. Например, если бы мы проверили команду полной ссылки, которую Crystal выполняет при сборке нашего двоичного файла, мы бы смогли увидеть, какие флаги используются. Чтобы увидеть эту команду, добавьте флаг --verbose к команде сборки, которая будет выглядеть как Crystal build --verbose src/transform_cli.cr. Это выведет достаточное количество информации, но мы хотим посмотреть в самом конце, после опции -o, указывающей, каким будет имя выходного двоичного файла. Если бы мы запустили pkg-config --libs libnotify, мы бы получили -lnotify -lgdk_pixbuf-2.0 -lgio-2.0 -lgobject-2.0 -lglib-2.0, что мы также можем увидеть в команде необработанной ссылки.

Если pkg-config не установлен или недоступен, Crystal попытается передать флаг -llibnotify, который может работать или не работать в зависимости от связываемой библиотеки. В нашем случае это не так. Также можно явно указать, какие флаги следует передавать компоновщику, используя поле аннотации ldflags, которое будет иметь вид @[Link(ldflags: "...")].

Еще следует отметить, что мы используем некоторые псевдонимы в библиотеке lib. Псевдонимы в этом контексте действуют так же, как стандартные псевдонимы Crystal. Причина, по которой мы их определили, состоит в том, чтобы сделать код немного проще в сопровождении, оставаясь как можно ближе к фактическому определению методов. Если в будущем создатели библиотеки захотят изменить значение GInt, мы также легко сможем это поддержать.

Для представления типа уведомления мы используем ключевое слово type для создания непрозрачного типа, поддерживаемого указателем void, что нам может сойти с рук, поскольку нам не нужно фактически ссылаться или взаимодействовать с фактическим внутренним представлением уведомления в libnotify. Это также служит хорошим примером того, что не все нужно связывать, особенно если оно не будет использоваться.

Причина создания NotifyNotification непрозрачного типа заключается в том, что libnotify обрабатывает создание/обновление структуры внутри себя. Ключевое слово type позволяет нам создавать что-то, на что мы можем ссылаться в нашем коде Crystal, не заботясь о том, как это было создано.

В случае notify_notification_show мы сделали второй аргумент типа Void, поскольку предполагаем, что все работает так, как ожидалось. Мы также связали функцию notify_notification_update. Этот метод на самом деле не обязателен, но он поможет кое-что продемонстрировать позже в этом разделе, так что следите за обновлениями!

Тестирование привязок

Следующий вопрос, на который нам нужно ответить: куда нам следует поместить файл привязки? Идеальным решением было бы создать выделенный сегмент и потребовать его в качестве зависимости. Основное преимущество, которое это дает, заключается в том, что другие могут использовать их независимо от источника нашего приложения CLI. Однако для целей этой демонстрации мы просто добавим их в исходные файлы нашего приложения CLI.

Мы собираемся создать подкаталог lib_notify, чтобы хотя бы обеспечить некоторое разделение организации между типами, связанными с привязками, и нашей реальной логикой. Это также облегчит переключение на выделенный сегмент, если мы решим сделать это позже. Давайте создадим новый файл src/lib_notify/lib_notify.cr, который будет содержать код, связанный с привязкой. Обязательно добавьте require “./lib_notify” в файл src/transform.cr.

Поскольку сами привязки не зависят от нашего приложения CLI, мы можем протестировать их независимо. Мы можем сделать это, добавив следующие строки в наш файл привязки, запускающий его, и обязательно удалив этот тестовый код после его запуска:


LibNotify.notify_init "Transform"

notification = LibNotify.notify_notification_new "Hello",

"From Crystal!", nil

LibNotify.notify_notification_show notification, nil LibNotify.notify_uninit


Если все работает правильно, вы должны увидеть уведомление на рабочем столе с заголовком “Привет” и текстом “От Crystal!”. Мы передаем nil аргументам, для которых не имеем значения. Это работает нормально, поскольку эти аргументы являются необязательными, и Crystal автоматически преобразует их в нулевой указатель. Однако это не сработало бы, если бы переменная представляла собой объединение Pointer и Nil. Работа с необработанными привязками функциональна, но не удобна для пользователя. Обычной практикой является определение стандартных типов Crystal, которые обертывают типы привязки C. Это позволяет скрыть внутренние компоненты библиотеки C за API, который более удобен для пользователя и его легче документировать. Давайте начнем с этого сейчас.

Абстрагирование привязок

Основываясь на логике C, которую мы использовали ранее, нам нужны следующие две основные абстракции:

• Лучший способ отправить уведомление, чтобы избежать необходимости вызывать методы init и uninit.

• Улучшен способ создания/редактирования уведомления, ожидающего отправки.

Чтобы обработать первую абстракцию, давайте создадим новый файл src/lib_notify/notify.cr со следующим кодом:


require "./lib_notify"


class Transform::Notification

  @notification : LibNotify::NotifyNotification*


  getter summary : String

  getter body : String

  getter icon : String


  def initialize(@summary : String, @body : String, @icon : String = "")

    @notification = LibNotify.notify_notification_new @summary, @body, @icon

  end


  def summary=(@summary : String) : Nil

    self.update

  end


  def body=(@body : String) : Nil

    self.update

  end


  def icon=(@icon : String?) : Nil

    self.update

  end


  def to_unsafe : LibNotify::NotifyNotification* @notification

  end


  private def update : Nil

    LibNotify.notify_notification_update @notification, @summary, @body, @icon

  end

end


По сути, этот класс представляет собой просто обертку вокруг указателя уведомления C. Мы определяем метод #to_unsafe, который возвращает завернутый указатель, чтобы позволить предоставить экземпляр этого класса функциям C. В этом типе мы также будем использовать notify_notification_update. Этот тип реализует установщики для каждого свойства уведомления, которые обновляют значение внутри типа-оболочки, а также обновляют значения структур C.

libnotify также имеет различные дополнительные функции, с которыми мы могли бы поиграть, такие как приоритет уведомления или установка задержки перед отображением уведомления. На самом деле нам не нужны эти функции для нашего CLI, но вы можете свободно исследовать libnotify и настраивать все по своему усмотрению! Далее давайте создадим тип, который поможет отправлять эти экземпляры уведомлений.

Создайте новый файл src/lib_notify/notification_emitter.cr со следующим кодом:


require "./lib_notify"

require "./notification"

class Transform: :NotificationEmitter

  @@initialized : Bool = false


  at_exit { LibNotify.notify_uninit if @@initialized }


  def emit(summary : String, body : String) : Nil

    self.emit Transform::Notification.new summary, body

  end


  def emit(notification : Transform::Notification) : Nil

    self.init

    LibNotify.notify_notification_show notification, nil

  end


  private def init : Nil

    return if @@initialized

    LibNotify.notify_init "Transform"

    @@initialized = true

  end

end


Основным методом этого типа является #emit, который отображает предоставленное уведомление, гарантируя предварительную инициализацию libnotify. Первая перегрузка принимает сводку и тело, создает уведомление, а затем передает его второй перегрузке. Мы сохраняем статус инициализации libnotify как переменную класса, поскольку он не привязан к конкретному экземпляру NotificationEmitter. Мы также зарегистрировали обработчик at_exit, который деинициализирует libnotify перед завершением работы программы, если она была инициализирована ранее.

Также стоит отметить, что обработка инициализации libnotify в многопоточном приложении будет немного более затруднительной, поскольку libnotify необходимо инициализировать только один раз, а не для каждого потока или волокна. Однако, поскольку поддержка многопоточности в Crystal все еще считается экспериментальной, и эта тема немного выходит за рамки рассмотрения, мы просто пропустим этот сценарий. На данный момент мы будем использовать наше приложение. Это не будет проблемой.

Теперь, когда у нас есть абстракции, мы можем перейти к их реализации в нашем CLI.

Интеграция привязок

Учитывая то, что мы сделали в последнем разделе, это будет самая простая часть главы, и останется только один вопрос: какое уведомление мы хотим отправить? Хорошим вариантом использования было бы выдавать его при возникновении ошибки в процессе преобразования. Уведомление привлечет внимание пользователя к тому, что ему необходимо принять меры по поводу чего-то, что в противном случае могло бы остаться незамеченным, если бы ожидалось, что это займет некоторое время.

Теперь вы, возможно, думаете, что мы просто создаем новые экземпляры NotificationEmitter по мере необходимости и используем их для каждого контекста. Однако мы собираемся применить несколько иной подход. План состоит в том, чтобы добавить инициализатор к нашему типу процессора, который будет хранить ссылку на эмиттер в качестве переменной экземпляра. Это будет выглядеть так: def initialize(@emitter : Transform::NotificationEmitter = Transform::NotificationEmitter.new); end. Я не буду объяснять причину этого, поскольку она будет рассмотрена в Главе 14 «Тестирование».

Давайте сначала сосредоточимся на обработке контекста ошибки. К сожалению, поскольку jq будет выводить сообщения об ошибках непосредственно на IO, ошибок, мы не сможем их обработать. Однако мы можем обрабатывать реальные исключения из нашего кода Crystal. Поскольку мы хотим обрабатывать любые исключения, возникающие в нашем методе #process, мы можем использовать короткую форму для определения блока rescue:


rescue ex : Exception

  if message = ex.message

    @emitter.emit "Oh no!", message

  end


  raise ex


Этот код должен располагаться непосредственно под последней строкой каждого метода, но перед закрывающим тегом метода. Этот блок спасет любое исключение, возникшее в методе. Затем он отправит уведомление с сообщением об исключении в качестве тела уведомления. Не все исключения имеют сообщение, поэтому мы обрабатываем этот случай, проверяя его перед отправкой уведомления. Наконец, мы повторно вызываем исключение.

В случае с методом #process_multiple нам нужно будет немного улучшить наш код параллелизма, чтобы лучше поддерживать обработку исключений. Хорошей практикой считается обработка любых исключений, возникающих в волокне, внутри самого волокна.

К сожалению, на данный момент работа с каналами и волокнами находится на несколько более низком уровне, чем хотелось бы в идеале. Есть несколько выдающихся предложений, например https://github. com/crystal-lang/crystal/issues/6468, но в стандартной библиотеке еще не реализовано ничего, что позволяло бы использовать некоторые встроенные абстракции или API более высокого уровня. С другой стороны, проблема, которую мы хотим решить, довольно тривиальна.

В последней главе мы добавили отправку с использованием блока ensure для корректной обработки контекстов сбоя, но упомянули, что эта реализация не идеальна, главным образом потому, что мы хотим иметь возможность различать контексты успеха и неудачи. Чтобы решить эту проблему, мы можем изменить канал, чтобы он принимал объединение Bool | Exception вместо просто Bool. Затем, снова используя короткую форму rescue, мы можем отправить каналу возникшее исключение, заменив блок ensure. В конечном итоге это будет выглядеть так:


channel.send true

rescue ex : Exception

channel.send ex


Подобно другим блокам восстановления, этот также будет идти сразу после channel.send true, но перед конечным тегом блока spawn. Затем нам нужно обновить логику получения для обработки значения исключения, поскольку в данный момент мы всегда игнорируем полученное значение. Для этого мы обновим цикл, чтобы проверить тип полученного значения, и поднимем его, если это тип Exception:


input_args.size.times do


case v = channel.receive

  in Exception then raise v

  in Bool

    # Skip

  end

end


Теперь, когда мы вызываем исключение из волокна внутри самого метода, наш блок восстановления в методе теперь будет вызываться правильно. Полный метод #process_ multiple находится в папке главы на GitHub: https://github.com/PacktPublishing/Crystal-Programming/blob/main/ Chapter07/process_multiple.cr.

Я обнаружил, что самый простой способ протестировать нашу логику отправки уведомлений — это передать файл, который не существует в режиме нескольких файлов. Например, запустив ./bin/transform -m .random-file.txt должен привести к отображению уведомления, информирующего вас о том, что при попытке открыть этот файл произошла ошибка.

Резюме

Увы, мы подошли к завершению нашего проекта CLI. За последние четыре главы мы значительно улучшили приложение. В процессе работы мы также расширили наши знания о различных концепциях Crystal. Хотя это и конец этой части книги, это не обязательно должен быть конец CLI. Не стесняйтесь продолжать самостоятельно, добавляя функции по своему желанию. В конечном счете, это поможет закрепить концепции, представленные на этом пути.

В следующей части книги будут представлены некоторые новые проекты, ориентированные на веб-разработку, и будет использовано все, что вы узнали до сих пор. Он также потратит некоторое время на демонстрацию различных шаблонов проектирования, которые могут пригодиться в ваших будущих проектах. И так, чего же ты ждешь? Прежде всего нужно научиться использовать внешние проекты Crystal, также известные как шарды, в качестве зависимостей внутри вашего собственного проекта. Иди, начни!

Часть 3. Обучение на практике — веб-приложение

Эта часть продолжит парадигму «Обучение на практике» с другим распространенным типом приложений: веб-фреймворком. Эта часть будет опираться на информацию из первых двух частей. Чаще всего веб-приложение создается с помощью фреймворка. К счастью, в экосистеме Crystal есть из чего выбирать. Хотя лучшая платформа для использования варьируется от варианта использования к варианту использования, мы собираемся сосредоточиться на Athena Framework.

Эта часть содержит следующие главы:

Глава 8. Использование внешних библиотек

Глава 9. Создание веб-приложения с помощью Athena

8. Использование внешних библиотек

Уменьшение дублирования за счет совместного использования кода является практическим правилом во многих языках программирования. Сделать это в рамках одного проекта достаточно легко. Однако, когда вы хотите поделиться чем-то между несколькими проектами, это становится немного сложнее. К счастью для нас, большинство языков также предоставляют свои собственные менеджеры пакетов, которые позволяют нам устанавливать в наши проекты другие библиотеки в качестве зависимостей, чтобы использовать определенный в них код.

Чаще всего эти внешние проекты называются просто библиотеками или пакетами, но в некоторых языках для них есть уникальные имена, например драгоценные камни Ruby gems.. Crystal следует шаблону Ruby и называет свои проекты Crystal Shards. В этой главе мы собираемся изучить мир внешних библиотек, в том числе способы их поиска, установки, обновления и управления ими. Мы рассмотрим следующие темы:

• Использование Crystal Shards

• Поиск Shards

Технические требования

Требования к этой главе следующие:

• Рабочая установка Кристалла.

Инструкции по настройке Crystal можно найти в Главе 1 «Введение в Crystal».

Все примеры кода, использованные в этой главе, можно найти в папке Chapter 08 на GitHub: https://github.com/PacktPublishing/Crystal-Programming/tree/main/Chapter08.

Использование Crystal Shards

Если вы помните Главу 4 «Изучение Crystal посредством написания интерфейса командной строки», когда мы впервые создавали проект, в рамках этого процесса был создан файл shard.yml, но мы не особо вникали в то, что он собой представляет. был за. Пришло время более подробно изучить назначение этого файла. Суть в том, что этот файл содержит различные метаданные об осколке, такие как его имя, версия икакие внешние зависимости у него есть (если таковые имеются). Напомню, что файл shard.yml из этого проекта выглядел так:


name: transform

version: 0.1.0


authors:

  - George Dietrich <george@dietrich.app>


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.

Shard зависимости от кода 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.

9. Создание веб-приложения с помощью 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 со следующим содержимым:


<h1>{{ data.title }}</h1>


<p>{{ data.body }}</p>


<i>Updated at: {{ data.updated_at }}</i>


Мы получаем доступ к значениям статьи в объекте данных, который будет представлять корневые данные, предоставленные при вызове рендеринга. Это позволит в будущем расширить представленные данные за пределы статьи.

Наконец, нам нужно создать экземпляр 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/

Часть 4: Метапрограммирование

В этой части мы рассмотрим более продвинутые функции и методы метапрограммирования, уделяя особое внимание аннотациям. Эта информация, как правило, недостаточно хорошо документирована. Без дальнейших церемоний давайте рассмотрим, как использовать эти более продвинутые функции.

Эта часть содержит следующие главы:

• Глава 10, Работа с макросами

• Глава 11, Введение в аннотации

• Глава 12, Использование анализа типов во время компиляции

• Глава 13, Расширенное использование макросов

10. Работа с макросами

В этой главе мы собираемся исследовать мир метапрограммирования. Метапрограммирование может быть отличным способом «СУШИТЬ» (DRY) ваш код путем объединения шаблонного кода в фрагменты многократного использования или путем обработки данных во время компиляции для создания дополнительного кода. Сначала мы рассмотрим основную часть этой функции: макросы.

В этой главе мы рассмотрим следующие темы:

• Определение макросов

• Понимание API макросов.

• Изучение макросов.


К концу этой главы вы сможете понять, когда и как можно применять макросы, чтобы уменьшить количество шаблонного кода в приложении.

Технические требования

Для этой главы вам понадобится работающая установка Crystal.

Инструкции по настройке Crystal можно найти в Главе 1 «Введение в Crystal».

Все примеры кода в этой главе можно найти в папке Chapter 10 репозитория GitHub этой книги: https://github.com/PacktPublishing/Crystal-Programming/tree/main/Chapter10.

Определение макросов

В Crystal макрос имеет два значения. Как правило, это относится к любому коду, который запускается или расширяется во время компиляции. Однако, более конкретно, это может относиться к типу метода, который принимает узлы AST во время компиляции, тело которых вставляется в программу в момент использования макроса. Примером последнего является макрос property, который вы видели в предыдущих главах, который представляет собой простой способ определения как метода получения, так и метода установки для данной переменной экземпляра:


class Example

  property age : Int32


  def initialize(@age : Int32); end

end


Предыдущий код эквивалентен следующему:


class Example

  @age : Int32


  def initialize(@age : Int32); end


  def age : Int32

    @age

  end


  def age=(@age : Int32)

  end

end


Как мы упоминали ранее, макросы принимают узлы AST во время компиляции и выводят код Crystal, который добавляется в программу, как если бы он был введен вручную. По этой причине property age: Int32 не является частью конечной программы, а только тем, во что оно расширяется — объявлением переменной экземпляра, методом получения и методом установки. Аналогичным образом, поскольку макросы работают на узлах AST во время компиляции, аргументы/значения, используемые внутри макроса, также должны быть доступны во время компиляции. Сюда входит следующее:

• Переменные среды.

• Константы

• Жестко запрограммированные значения.

• Жестко закодированные значения, созданные с помощью другого макроса.


Поскольку аргументы должны быть известны во время компиляции, макросы не заменяют обычные методы, даже если результат в обоих случаях кажется одинаковым. Возьмем, к примеру, эту небольшую программу:


macro print_value(value)

  {{pp value}}

  pp {{value}}

end


name = "George"


print_value name


Запуск этой программы приведет к следующему выводу:


name

"George"


Главное, на что следует обратить внимание, — это вывод значения, когда оно находится в контексте макроса. Поскольку макросы принимают узлы AST, макрос не имеет доступа к текущему значению переменной времени выполнения, такой как имя. Вместо этого типом значения в контексте макроса является Var, который представляет локальную переменную или аргумент блока. Это можно подтвердить, добавив в макрос строку, состоящую из {{pp value.class_name}}, которая в конечном итоге напечатает "Var”. Мы узнаем больше об узлах AST позже в этой главе.

Макросами легко злоупотреблять из-за предоставляемых ими возможностей. Однако, как говорится: с большой силой приходит и большая ответственность. Эмпирическое правило заключается в том, что если вы можете добиться того, чего хотите, с помощью обычного метода, используйте обычный метод и используйте макросы как можно реже. Это не значит, что макросов следует избегать любой ценой, а скорее, что их следует использовать стратегически, а не как решение каждой проблемы, с которой вы сталкиваетесь.

Макрос можно определить с помощью ключевого слова макроса:


macro def_method(name)

  def {{name.id}}

    puts "Hi"

  end

end


  def_method foo


foo


В этом примере мы определили макрос с именем def_method, который принимает один аргумент. В целом макросы очень похожи на обычные методы с точки зрения их определения, при этом основные различия заключаются в следующем:

• Аргументы макроса не могут иметь ограничений типа.

• Макросы не могут иметь ограничений по типу возвращаемого значения.

• Аргументы макроса не существуют во время выполнения, поэтому на них можно ссылаться только в синтаксисе макроса.


Макросы ведут себя аналогично методам класса в отношении их области действия. Макросы могут быть определены внутри типа и вызываться вне его, используя синтаксис метода класса. Аналогично, вызовы макросов будут искать определение в цепочке предков типа, например, в родительских типах или включенных модулях. Также можно определить частные макросы, которые сделают их видимыми в том же файле только в том случае, если они объявлены на верхнем уровне или только в пределах определенного типа, в котором они были объявлены.

Синтаксис макроса состоит из двух форм: {{ ... }} и {% ... %}. Первый используется, когда вы хотите вывести какое-то значение в программу. Последний используется как часть потока управления макросом, например, циклы, условная логика, присвоениепеременных и т. д. В предыдущем примере мы использовали синтаксис двойной фигурной скобки, чтобы вставить значение аргумента name в программу в качестве имени метода, которое в данном случае — foo. Затем мы вызвали метод, в результате чего программа напечатала Hi.

Макросы также могут расширяться до нескольких элементов и иметь более сложную логику для определения того, что будет сгенерировано. Например, давайте определим метод, который принимает переменное количество аргументов, и создадим метод для доступа к каждому значению, возможно, только для нечетных чисел:


macro def_methods(*numbers, only_odd = false)

  {% for num, idx in numbers %}

    {% if !only_odd || (num % 2) != 0 %}

      # Returns the number at index {{idx}}.

      def {{"number_#{idx}".id}}

        {{num}}

      end

      {% end %}

  {% end %}

  {{debug}}

end


def_methods 1, 3, 6, only_odd: true


pp number_0

pp number_1


В этом примере происходит нечто большее, чем мы видим! Давайте разберемся. Сначала мы определили макрос под названием def_methods, который принимает переменное количество аргументов с необязательным логическим флагом, которому по умолчанию присвоено значение false. Макрос ожидает, что вы предоставите ему серию чисел, с помощью которых он создаст методы для доступа к числу, используя индекс каждого значения для создания уникального имени метода. Необязательный флаг заставит макрос создавать методы только для нечетных чисел, даже если в макрос также были переданы четные числа.

Цель использования аргументов splat и именованных аргументов — показать, что макросы похожи на методы, которые могут быть написаны таким же образом. Однако разница становится более очевидной, когда вы попадаете в тело макроса. Обычно метод #each используется для итерации коллекции. В случае макроса вы должны использовать синтаксис for item, index in collection, который также можно использовать для итерации фиксированного количества раз или для перебора ключей/значений Hash/NamedTuple через for i in (0.. 10), а для ключа — значение в hash_or_named_tuple соответственно.

Основная причина, по которой #each нельзя использовать, заключается в том, что циклу необходим доступ к реальной программе, чтобы иметь возможность вставить сгенерированный код. #each можно использовать внутри макроса, но он должен использоваться в синтаксисе макроса и не может использоваться для генерации кода. Лучше всего это продемонстрировать на примере:


{% begin %}

  {% hash = {"foo" => "bar", "biz" => "baz"} %}


  {% for key, value in hash %}

    puts "#{{{key}}}=#{{{value}}}"

  {% end %}

{% end %}


{% begin %}

  {% arr = [1, 2, 3] %}

  {% hash = {} of Nil => Nil %}

  {% arr.each { |v| hash[v] = v * 2 } %}


  puts({{hash}})

{% end %}


В этом примере мы перебирали ключи и значения хеша, генерируя вызов метода puts, который печатает каждую пару. Мы также использовали ArrayLiteral#each для перебора каждого значения и установки вычисленного значения в хеш-литерал, который затем печатаем. В большинстве случаев синтаксис for in можно использовать вместо #each, но #each нельзя использовать вместо for in. Проще говоря, поскольку метод #each использует блок, у него нет возможности вывод сгенерированного кода. Таким образом, его можно использовать только для итерации, а не генерации кода.

Следующее, что делает наш макрос def_methods, — это использует оператор if, чтобы определить, должен ли он генерировать метод или нет для текущего числа. Операторы if/unless в макросах работают идентично своим аналогам во время выполнения, хотя и в рамках синтаксиса макросов.

Далее обратите внимание, что у этого метода есть комментарий, включающий {{idx}}. Макровыражения оцениваются как в комментариях, так и в обычном коде. Это позволяет генерировать комментарии на основе расширенного значения макровыражений. Однако эта функция также делает невозможным комментирование кода макроса, поскольку он все равно будет оцениваться как обычно.

Наконец, у нас есть логика, создающая метод. В данном случае мы интерполировали индекс из цикла в строку, представляющую имя метода. Обратите внимание, что мы использовали для строки метод #id. Метод #id возвращает значение как MacroId, что по существу нормализует значение как один и тот же идентификатор, независимо от типа входных данных. Например, вызов #id для “foo”, :foo и foo приводит к возврату того же значения foo. Это полезно, поскольку позволяет вызывать макрос с любым идентификатором, который предпочитает пользователь, при этом создавая тот же базовый код.

В самом конце определения макроса вы могли заметить строку {{debug}}. Это специальный метод макроса, который может оказаться неоценимым при отладке кода макроса. При использовании он выводит код макроса, который будет сгенерирован в строке, в которой он был вызван. В нашем примере мы увидим следующий вывод на консоли перед выводом ожидаемых значений:


# Returns the number at index 0.

def number_0

1

end

# Returns the number at index 1.

def number_1

3

end


Поскольку макрос становится все более и более сложным, это может быть невероятно полезно для обеспечения того, чтобы он генерировал то, что должно быть.

Макрос также может генерировать другие макросы. Однако при этом необходимо соблюдать особую осторожность, чтобы гарантировать правильное экранирование выражений внутреннего макроса. Например, следующий макрос аналогичен предыдущему примеру, но вместо непосредственного определения методов он создает другой макрос и немедленно вызывает его, в результате чего создаются связанные методы:


macro def_macros(*numbers)

  {% for num, idx in numbers %}

    macro def_num_{{idx}}_methods(n)

      def num_\{{n}}

        \{{n}}

      end


      def num_\{{n}}_index

        {{idx}}

      end

    end


    def_num_{{idx}}_methods({{num}})

  {% end %}

end


def_macros 2, 1


pp num_1_index # => 1

pp num_2_index # => 0


В конце макросы расширяются и определяют четыре метода. Ключевым моментом, на который следует обратить внимание в этом примере, является использование \{{. Обратная косая черта экранирует выражение синтаксиса макроса, поэтому оно не оценивается внешним макросом, что означает, что оно расширяется только внутренним макросом. На переменные макроса из внешнего макроса по-прежнему можно ссылаться во внутреннем макросе, используя переменную во внутреннем макросе, не экранируя выражение.

Необходимость экранирования каждого выражения синтаксиса макроса внутри внутреннего макроса может быть довольно утомительной и подверженной ошибкам. К счастью, для упрощения этого можно использовать дословный вызов. Внутренний макрос, показанный в предыдущем примере, также можно записать следующим образом:


macro def_num_{{idx}}_methods(n)

  {% verbatim do %}

    def num_{{n}}

      {{n}}

    end


    def num_{{n}}_index

      {{idx}}

    end

  {% end %}

end


Однако если вы запустите это, вы увидите, что оно не компилируется. Единственным недостатком дословного перевода является то, что он не поддерживает интерполяцию переменных. Другими словами, это означает, что код внутри блока verbatim не может использовать переменные, определенные вне него, например idx.

Чтобы иметь возможность доступа к этой переменной, нам нужно определить другую экранированную макропеременную за пределами блока verbatim внутри внутреннего макроса, для которого установлено расширенное значение переменной idx внешнего макроса. Проще говоря, нам нужно добавить \{% idx = {{idx}} %} над строкой {% verbatim do %}. В конечном итоге это приводит к расширению {% idx = 1 %} внутри внутреннего макроса в случае второго значения.

Поскольку макросы расширяются до кода Crystal, код, сгенерированный макросом, может создать конфликт с кодом, определенным в расширении макроса. Наиболее распространенной проблемой является переопределение локальных переменных. Решением этой проблемы является использование новых переменных как средства создания уникальных переменных.

Свежие переменные

Если макрос использует локальную переменную, предполагается, что эта локальная переменная уже определена. Эта функция позволяет макросу использовать предопределенные переменные в контексте раскрытия макроса, что может помочь уменьшить дублирование. Однако это также позволяет легко случайно переопределить локальную переменную, определенную в макросе, как показано в этом примере:


macro update_x

  x = 1

end


x = 0

update_x

puts x


Макрос update_x расширяется до выражения x = 1, которое переопределяет исходную переменную x, в результате чего эта программа печатает значение 1. Чтобы позволить макросу определять переменные, которые не будут конфликтовать, необходимо использовать новые переменные, например:


macro dont_update_x

  %x = 1

  puts %x

end


x = 0

dont_update_x

puts x


В отличие от предыдущего примера, здесь будет выведено значение 1, за которым следует значение 0, тем самым показывая, что расширенный макрос не изменил локальную переменную x. Новые переменные определяются путем добавления символа % к имени переменной. Новые переменные также могут быть созданы относительно другого значения макроса времени компиляции. Это может быть особенно полезно в циклах, где для каждой итерации цикла должна определяться новая переменная с тем же именем, например:


macro fresh_vars_sample(*names)

  {% for name, index in names %}

    %name{index} = {{index}}

  {% end %}

  {{debug}}

end


fresh_vars_sample a, b, c


Предыдущая программа будет перебирать каждый из аргументов, переданных макросу, и определит новую переменную для каждого элемента, используя индекс элемента в качестве значения переменной. На основе результатов отладки этот макрос расширяется до следующего:


__temp_24 = 0

__temp_25 = 1

__temp_26 = 2


Для каждой итерации цикла определяется одна переменная. Компилятор Crystal отслеживает все новые переменные и присваивает каждой из них номер, чтобы гарантировать, что они не конфликтуют друг с другом.

Макросы определения, не являющиеся макросами

Весь код макроса, который мы написали/рассмотрели до сих пор, был представлен в контексте определения макроса . Хотя это одно из наиболее распространенных мест для просмотра кода макроса, макросы также могут использоваться вне определения макроса. Это может быть полезно для условного определения кода на основе некоторого внешнего значения, такого как переменная среды, флаг времени компиляции или значение константы. Это можно увидеть в следующем примере:


{% if flag? :release %}

  puts "Release mode!"

{% else %}

  puts "Non-release mode!"

{% end %}


Метод flag? — это специальный метод макроса, который позволяет нам проверять наличие либо предоставленных пользователем, либо встроенных флагов времени компиляции. Одним из основных вариантов использования этого метода является определение кода, специфичного для конкретной ОС и/или архитектуры. Компилятор Crystal включает в себя несколько встроенных флагов, которые можно использовать для этого, например {% if flag?(:linux) && flag?(:x86_64) %}, которые будут выполняться только в том случае, если система, компилирующая программу, использует 64-битная ОС Linux.

Пользовательские флаги можно определить с помощью опций --define или -D. Например, если вы хотите проверить наличие flag? :foo, флаг можно определить, выполнив crystal run -Dfoo main.cr. Флаги времени компиляции либо присутствуют, либо нет; они не могут включать значение. Однако переменные окружающей среды могут стать хорошей заменой, если требуется большая гибкость.

Переменные среды можно прочитать во время компиляции с помощью метода макроса env. Хорошим вариантом использования этого является возможность встраивания в двоичный файл информации о времени сборки, такой как эпоха сборки, время сборки и т. д. В этом примере во время компиляции значение константы будет установлено либо в значение переменной среды BUILD_SHA_HASH, либо в пустую строку, если она не была установлена (все это происходит во время компиляции):


COMMIT_SHA = {{ env("BUILD_SHA_HASH") || "" }}


pp COMMIT_SHA


При запуске этого кода обычно печатается пустая строка, а при установке связанной переменной env выводится это значение. Установка этого значения через переменную env, а не генерация внутри самого макроса с помощью системного вызова, гораздо более переносима, поскольку не зависит от Git, а также гораздо проще интегрируется с внешними системами сборки, такими как Make.

Одним из ограничений макросов является то, что сгенерированный из макроса код также должен быть действительным кодом Crystal, как показано здесь:


def {{"foo".id}}

  "foo"

end


Этот предыдущий код не является допустимой программой, поскольку метод неполный и не полностью определен в макросе. Этот метод можно включить в макрос, обернув все тегами {% begin %}/{% end %}, которые будут выглядеть следующим образом:


{% begin %}

  def {{"foo".id}}

    "foo"

  end

{% end %}


На этом этапе вы должны иметь четкое начальное представление о том, что такое макросы, как их определять и для каких случаев использования они предназначены, что позволит вам сохранить ваш код СУХИМ (DRY). Далее мы рассмотрим API макросов, чтобы можно было создавать более сложные макросы.

Понимание API макросов

В примерах из предыдущего раздела в контексте макроса использовались различные переменные разных типов, такие как числа, которые мы перебираем, строки, которые мы используем для создания идентификаторов, и логические значения, которые мы сравниваем для условной генерации кода. Было бы легко предположить, что это напрямую соответствует стандартным типам Number, String и Bool. Однако это не так. Как мы упоминали в разделе «Определение макросов» этой главы, макросы работают на узлах AST и, как таковые, имеют свой собственный набор типов, похожий на связанные с ними обычные типы Crystal, но с подмножеством API. Например, типы, с которыми мы до сих пор работали, включают NumberLiteral, StringLiteral и BoolLiteral.

Все типы макросов находятся в пространстве имен Crystal::Macros в документации API, которая находится по адресу https://crystal-lang.org/api/Crystal/Macros.html. К наиболее распространенным/полезным типам относятся следующие:

 Def: описывает определение метода.

 TypeNode: описывает тип (класс, структура, модуль, библиотека).

 MetaVar: описывает переменную экземпляра.

 Arg: описывает аргумент метода.

Annotation: представляет аннотацию, применяемую к типу, методу или переменной экземпляра (подробнее об этом в следующей главе).


Crystal предоставляет удобный способ получить экземпляр первых двух типов в виде макропеременных @def и @type. Как следует из их названий, использование @def внутри метода вернет экземпляр Def, представляющий этот метод. Аналогично, использование @type вернет экземпляр TypeNode для связанного типа. Доступ к другим типам можно получить через методы, основанные на одном из этих двух типов. Например, запуск следующей программы выведет "Метод hello внутри Foo":


class Foo

  def hello

    {{"The #{@def.name} method within #{@type.name}"}}

  end

end


pp Foo.new.hello


Другой, более продвинутый способ получения TypeNode — использование макрометода parse_type. Этот метод принимает StringLiteral, который может быть создан динамически, и возвращает один из нескольких типов макросов в зависимости от того, что представляет собой строка. Дополнительную информацию см. в документации по методу https://crystal-lang.org/api/Crystal/Macros.html.

Как мы упоминали ранее, API макросов позволяет нам вызывать фиксированное подмножество обычных методов API для литеральных типов. Другими словами, это позволяет нам вызывать ArrayLiteral#select, но не ArrayLiteral#each_repeated_permutation, или StringLiteral#gsub, но не StringLiteral#scan.

В дополнение к этим примитивным типам ранее упомянутые типы макросов предоставляют свой собственный набор методов, чтобы мы могли получать информацию о связанном типе, например:

• Тип возвращаемого значения, его видимость или аргументы метода.

• Тип/значение по умолчанию аргумента метода.

• Какие аргументы объединения/обобщения имеет тип, если таковые имеются.


Конечно, их слишком много, чтобы их здесь упоминать, поэтому я предлагаю просмотреть документацию по API для получения полного списка. А пока давайте применим некоторые из этих методов:


class Foo

  def hello(one : Int32, two, there, four : Bool, five :

    String?)

    {% begin %}

      {{"#{@def.name} has #{@def.args.size} arguments"}}

      {% typed_arguments = @def.args.select(&.restriction) %}

      {{"with #{typed_arguments.size} typed

        arguments"}}

      {{"and is a #{@def.visibility.id} method"}}

    {% end %}

  end

end


Foo.new.hello 1, 2, 3, false, nil


Эта программа выведет следующее:


"hello has 5 arguments"

"with 3 typed arguments"

"and is a public method"


Первая строка выводит имя метода и количество его аргументов через ArrayLiteral#size, поскольку Def#args возвращает ArrayLiteral(Arg). Затем мы используем метод ArrayLiteral#select, чтобы получить массив, содержащий только аргументы, имеющие ограничение типа. Arg#restriction возвращает TypeNode на основе типа ограничения или Nop, которое является ложным значением и используется для представления пустого узла. Наконец, мы используем Def#visibility, чтобы узнать уровень видимости метода. Он возвращает символический литерал, поэтому мы вызываем для него #id, чтобы получить его общее представление.

Существует еще одна специальная макропеременная @top_level, которая возвращает TypeNode, представляющий пространство имен верхнего уровня. Если мы не воспользуемся этим, единственный другой способ получить к нему доступ — это вызвать @type в пространстве имен верхнего уровня, что сделает невозможным ссылку на него внутри другого типа. Давайте посмотрим, как можно использовать эту переменную:


A_CONSTANT = 0


module Foo; end


{% if @top_level.has_constant?("A_CONSTANT") && @top_level

  .has_constant?("Foo") %}

  puts "this is printed"


{% else %}

  puts "this is not printed"

{% end %}


В этом примере мы использовали TypeNode#has_constant?, который возвращает BoolLiteral, если связанный TypeNode имеет предоставленную константу, предоставленную в виде StringLiteral, SymbolLiteral или MacroId (тип, который вы получаете при вызове #id для другого типа). Этот метод работает как для реальных констант, так и для типов.

Понимание API макросов имеет решающее значение для написания макросов, использующих информацию, полученную из типа и/или метода. Я настоятельно рекомендую прочитать документацию по API для некоторых типов макросов, о которых мы говорили в этом разделе, чтобы полностью понять, какие методы доступны.

Прежде чем мы перейдем к следующему разделу, давайте применим все, что мы узнали, для воссоздания макроса property стандартной библиотеки.

Воссоздание макроса property

Обычно макрос property принимает экземпляр TypeDeclaration, который представляет имя, тип и значение по умолчанию, если таковое имеется, переменной экземпляра. Макрос использует это определение для создания переменной экземпляра, а также методов получения и установки для нее.

Макрос property также обрабатывает несколько дополнительных случаев использования, но сейчас давайте сосредоточимся на наиболее распространенном. Наша реализация этого макроса будет выглядеть так:


macro def_getter_setter(decl)

  @{{decl}}


  def {{decl.var}} : {{decl.type}}

    @{{decl.var}}

  end


  def {{decl.var}}=(@{{decl.var}} : {{decl.type}})

  end

end


Мы можем определить переменную экземпляра, используя @{{decl}}, потому что она автоматически расширится до нужного формата. Мы могли бы также использовать @{{decl.var}} : {{decl. type}}, но другой путь был короче и лучше обрабатывал значения по умолчанию. Более длинная форма должна будет явно проверить и установить значение по умолчанию, если таковое имеется, тогда как более короткая форма сделает это за нас. Однако тот факт, что вы можете реконструировать узел вручную, используя предоставляемые им методы, не является совпадением. Узлы AST — это абстрактные представления чего-либо внутри программы, например, объявление типа, метода или выражение оператора if, поэтому имеет смысл только то, что вы можете построить то, что представляет узел, используя сам узел.

Остальная часть нашего макроса def_getter_setter строит методы получения и установки для определенной переменной экземпляра. Отсюда мы можем пойти дальше и использовать его:


class Foo

  def_getter_setter name : String?

  def getter setter number : Int32 = 123

  property float : Float64 = 3.14

end


obj = Foo.new


pp obj.name

obj.name = "Bob"

pp obj.name


pp obj.number

pp obj.float


Запуск этой программы приведет к следующему выводу:


nil

"Bob"

123

3.14


И вот оно! Успешная повторная реализация наиболее распространенной формы макроса property! Здесь легко увидеть, как можно использовать макросы, чтобы уменьшить количество шаблонов и повторений в вашем приложении.

Последняя концепция макросов, которую мы собираемся обсудить в этой главе, — это макро-хуки, которые позволяют нам подключаться к различным событиям Crystal.

Изучение макро-хуков

Перехватчики макросов — это специальные определения макросов, которые в некоторых ситуациях вызываются компилятором Crystal во время компиляции. К ним относятся следующие:

 inherited вызывается, когда определен подкласс, где @type — это наследующий тип.

 included вызывается при включении модуля, где @type — включаемый тип.

 extended вызывается при расширении модуля, где @type — расширяемый тип.

• method_missing вызывается, когда метод не найден, и ему передается один аргумент Call.

• method_added вызывается, когда новый метод определен в текущей области и ему передается один аргумент Def.

 finished вызывается после этапа семантического анализа, поэтому известны все типы и их методы.


Первые три и законченные определения являются наиболее распространенными/полезными, поэтому мы сосредоточимся на них. Первые три хука работают по сути одинаково — они просто выполняются в разных контекстах. Например, следующая программа демонстрирует, как они работают, определяя различные перехватчики и печатая уникальное сообщение при выполнении этого перехватчика:


abstract class Parent

  macro inherited

    puts "#{{{@type.name}}} inherited Parent"

  end

end


module MyModule

  macro included

    puts "#{{{@type.name}}} included MyModule"

  end


  macro extended

    puts "#{{{@type.name}}} extended MyModule"

  end

end


class Child < Parent

  include MyModule

  extend MyModule

end


Предыдущий код выведет следующий результат:


Child inherited Parent

Child included MyModule

Child extended MyModule


Эти перехватчики могут быть весьма полезны, если вы хотите добавить методы/переменные/константы к другому типу в случаях, когда обычная семантика наследования/модуля не работает. Примером этого может служить случай, когда вы хотите добавить к типу методы экземпляра и класса при включении модуля. Из-за того, как работает включение/расширение модулей, в настоящее время невозможно добавить оба типа методов к типу из одного модуля.

Обходной путь — вложить еще один модуль ClassMethods в основной. Однако для этого пользователю потребуется вручную включить основной модуль и расширить вложенный модуль, что не очень удобно для пользователя. Лучшим вариантом было бы определить в основном модуле макрос, включающий ловушку, которая расширяет модуль ClassMethods. Таким образом, макрос будет расширяться внутри включенного класса, автоматически расширяя модуль методов класса. Это будет выглядеть примерно так:


module MyModule

  module ClassMethods

    def foo

      "foo"

    end

  end


  macro included

    extend MyModule::ClassMethods

  end


  def bar

    "bar"

  end

end


class Foo

  include MyModule

end


pp Foo.foo

pp Foo.new.bar


Таким образом, пользователю нужно только включить модуль, чтобы получить оба типа методов, что в целом улучшит взаимодействие с пользователем.

macro finished в основном используется, когда вы хотите выполнить какой-либо макрокод только после того, как Crystal узнает обо всех типах. В некоторых случаях отсутствие вашего макрокода в обработчике finished может привести к неверным результатам. Следите за обновлениями! Мы рассмотрим это более подробно в Главе 15 "Документирование кода".

Резюме

Метапрограммирование — одна из областей, в которой Кристалл преуспевает. Он предоставляет нам довольно мощную систему, которую можно использовать для генерации кода и уменьшения количества шаблонов/повторений, при этом оставаясь при этом достаточно простой по сравнению с другими языками. Однако, когда это необходимо, эту силу следует использовать экономно.

В этой главе мы узнали, как и когда использовать макросы для сокращения шаблонного кода, как подключаться к различным событиям Crystal с помощью перехватчиков макросов, а также познакомились с API макросов для поддержки создания более сложных макросов.

В следующей главе мы рассмотрим аннотации и то, как их можно использовать в сочетании с макросами для хранения данных, которые можно прочитать во время компиляции.

11. Знакомство с аннотациями

Как упоминалось в предыдущей главе, макросы могут быть мощным инструментом для генерации кода, позволяющим уменьшить дублирование и сохранить ваше приложение DRY. Однако одно из ограничений макросов, особенно тех, которые находятся за пределами определения макроса, заключается в том, что сложно получить доступ к данным для использования внутри макроса, поскольку они должны быть доступны во время компиляции, как переменная среды или константа.

Ни один из этих вариантов в большинстве случаев не является отличным вариантом. Чтобы лучше решить эту проблему, нам нужно изучить следующую концепцию метапрограммирования Crystal: аннотации.

В этой главе мы рассмотрим следующие темы:

• Что такое аннотации?

• Хранение данных в аннотациях.

• Чтение аннотаций


К концу этой главы вы должны иметь четкое представление о том, что такое аннотации и как их использовать.

Технические требования

Требования к этой главе следующие:

• Рабочая установка Crystal.

Инструкции по настройке Crystal можно найти в Главе 1 «Введение в Crystal».

Все примеры кода, использованные в этой главе, можно найти в папке Chapter 11 на GitHub: https://github.com/PacktPublishing/Crystal-Programming/tree/main/Chapter11.

Что такое аннотации?

Проще говоря, аннотация — это способ прикрепить метаданные к определенным функциям кода, к которым впоследствии можно получить доступ во время компиляции внутри макроса. Crystal поставляется в комплекте с некоторыми встроенными аннотациями, с которыми вы, возможно, уже работали, например @[JSON::Field] или аннотацией @[Link], которая была рассмотрена в Главе 7, «Взаимодействие C». Хотя обе эти аннотации включены по умолчанию, они различаются по своему поведению. Например, аннотация JSON::Field существует в стандартной библиотеке Crystal и реализована/используется таким образом, что вы можете воспроизвести ее в своем собственном коде с помощью собственной аннотации. С другой стороны, аннотация Link имеет особые отношения с компилятором Crystal, и часть ее поведения не может быть воспроизведена в пользовательском коде.

Пользовательские аннотации можно определить с помощью ключевого слова annotation:

annotation MyAnnotation; end

Вот и все. Затем аннотацию можно было применить к различным элементам, включая следующие:

• Методы экземпляра и класса.

• Переменные экземпляра

• Классы, структуры, перечисления и модули.


Аннотацию можно применять к различным объектам, помещая имя аннотации в квадратные скобки синтаксиса @[], как в следующем примере:


@[MyAnnotation]

def foo

  "foo"

end


@[MyAnnotation]

class Klass

end


@[MyAnnotation]

module MyModule

end


К одному и тому же элементу также можно применить несколько аннотаций:


annotation Ann1; end

annotation Ann2; end


@[Ann1]

@[Ann2]

@[Ann2]

def foo

end


В этом конкретном контексте на самом деле нет смысла использовать более одной аннотации, поскольку нет способа отличить их друг от друга; однако это будет иметь больше смысла, если вы добавите данные в аннотацию, что является темой следующего раздела.

Итак, аннотации — это то, что можно применять к различным вещам в коде для хранения метаданных о них. Но чем они на самом деле хороши? Основное преимущество, которое они предоставляют, заключается в том, что они не зависят от реализации. Другими словами, это означает, что вы можете просто аннотировать что-то, и соответствующая библиотека сможет читать из него данные без необходимости специального определения макроса для создания переменной экземпляра, метода или типа.

Примером этого может быть, скажем, у вас есть модель ORM, которую вы хотите проверить. Например, если одна из установленных вами библиотек использует собственный макрос, такой как column id : Int64, это может сделать другие библиотеки нефункциональными, поскольку аннотация может быть неправильно применена к переменной экземпляра или методу. Однако если все библиотеки используют аннотации, то все они работают со стандартными переменными экземпляра Crystal, поэтому у библиотек нет возможности конфликтовать, и это делает все более естественным.

Кроме того, аннотации более ориентированы на будущее и более гибки по сравнению с определениями макросов для этого конкретного варианта использования. Далее давайте поговорим о том, как хранить данные в аннотации.

Хранение данных в аннотациях

Подобно методу, аннотация поддерживает как позиционные, так и именованные аргументы:


annotation MyAnnotation

end


@[MyAnnotation(name: "value", id: 123)]

def foo; end


@[MyAnnotation("foo", 123, false)]

def bar; end


В этом примере мы определили два пустых метода, к каждому из которых применена аннотация. Первый использует исключительно именованные аргументы, а второй использует исключительно позиционные аргументы. Лучший пример применения нескольких аннотаций одного и того же типа можно продемонстрировать, когда в каждую аннотацию включены данные. Вот пример:


annotation MyAnnotation; end


@[MyAnnotation(1, enabled: false)]

@[MyAnnotation(2)]

def foo

end


Поскольку значения в каждой аннотации могут быть разными, связанная библиотека может создать несколько методов или переменных, например, на основе каждой аннотации и данных в ней. Однако эти данные бесполезны, если вы не можете получить к ним доступ! Давайте посмотрим, как это сделать дальше.

Чтение аннотаций

В Crystal вы обычно вызываете метод объекта, чтобы получить доступ к некоторым данным, хранящимся внутри. Аннотации ничем не отличаются. Тип Annotation предоставляет три метода, которые можно использовать для доступа к данным, определенным в аннотации, различными способами. Однако прежде чем вы сможете получить доступ к данным в аннотации, вам необходимо получить ссылку на экземпляр Annotation. Это можно сделать, передав тип Annotation методу #annotation, определенному для типов, поддерживающих аннотации, включая TypeNode, Def и MetaVar. Например, мы можем использовать этот метод для печати аннотации, примененной к определенному классу или методу, если таковой имеется:


annotation MyAnnotation; end

@[MyAnnotation]

class MyClass

  def foo

    {{pp @type.annotation MyAnnotation}}

    {{pp @def.annotation MyAnnotation}}

  end

end


MyClass.new.foo


Метод #annotation вернет NilLiteral, если аннотация указанного типа не применена. Теперь, когда у нас есть доступ к примененной аннотации, мы готовы начать чтение из нее данных!

Первый, наиболее простой способ — использование метода #[], который может показаться знакомым, поскольку он также используется, среди прочего, как часть типов Array и Hash. Этот метод имеет две формы: первая принимает NumberLiteral и возвращает позиционное значение по предоставленному индексу. Другая форма принимает StringLiteral, SymbolLiteral или MacroId и возвращает значение с предоставленным ключом. Оба этих метода вернут NilLiteral, если по указанному индексу или указанному ключу не существует значения.

Два других метода, #args и #named_args, не возвращают конкретное значение, а вместо этого возвращают коллекцию всех позиционных или именованных аргументов в аннотации в виде TupleLiteral и NamedTupleLiteral соответственно.

Прежде всего, давайте посмотрим, как мы можем работать с данными, хранящимися в классе, используя данные из аннотации для создания вывода:


annotation MyClass; end

Annotation MyAnnotation; end

@[MyClass(true, id: "foo_class")]

class Foo

  {% begin %}

    {% ann = @type.annotation MyClass %}

    {% pp "#{@type} has positional arguments of:

      #{ann.args}" %}

    {% pp "and named arguments of #{ann.named_args}" %}

    {% pp %(and is #{ann[0] ? "active".id :

      "not active".id}) %}

    {% status = if my_ann = @type.annotation MyAnnotation

                  "DOES"

                else

                  "DOES NOT"

                end %}

    {% pp "#{@type} #{status.id} have MyAnnotation applied." %}

  {% end %}

end


Запуск этой программы выведет следующее:


"Foo has positional arguments of: {true}"

"and named arguments of {id: \"foo_class\"}"

"and is active."

"Foo DOES NOT have MyAnnotation applied."


Мы также можем сделать то же самое с аннотацией, примененной к методу:


annotation MyMethod; end


@[MyMethod(4, 1, 2, id: "foo")]

def my_method

  {% begin %}

    {% ann = @def.annotation MyMethod %}

    {% puts "\n" %}

    {% pp "Method #{@def.name} has an id of #{ann[:id]}" %}

    {% pp "and has #{ann.args.size} positional arguments" %}

    {% total = ann.args.reduce(0) { |acc, v| acc + v } %}

    {% pp "that sum to #{total}" %}

  {% end %}

end


my_method


Запуск этой программы выведет следующее:


"Method my_method has an id of \"foo\""

"and has 3 positional arguments"

"that sum to 7"


В обоих этих примерах мы использовали все три метода, а также некоторые сами типы коллекций. Мы также увидели, как обрабатывать необязательную аннотацию, следуя той же логике обработки nil, что и в коде Crystal, не являющемся макросом. Если бы к нашему классу была применена аннотация, мы могли бы получить доступ к любым дополнительным данным из него через переменную my_ann, так же, как мы это делали с переменной ann в предыдущих строках. Этот шаблон может быть невероятно полезен, позволяя влиять на логику макроса наличием или отсутствием аннотации. Это может привести к более читабельному коду, для которого в противном случае потребовалась бы одна аннотация со множеством различных полей.

Как и в предыдущем примере с несколькими аннотациями для одного элемента, метод #annotation возвращает последнюю аннотацию, примененную к данному элементу. Если вы хотите получить доступ ко всем примененным аннотациям, вместо этого вам следует использовать метод #annotations. Этот метод работает почти идентично другому методу, но возвращает ArrayLiteral(Annotation) вместо Annotation?. Например, мы могли бы использовать этот метод для перебора нескольких аннотаций, чтобы напечатать индекс аннотации вместе со значением, которое она хранит:


annotation MyAnnotation; end


@[MyAnnotation("foo")]

@[MyAnnotation(123)]

@[MyAnnotation(123)]

def annotation_read

  {% for ann, idx in @def.annotations(MyAnnotation) %}

    {% pp "Annotation #{idx} = #{ann[0].id}" %}

  {% end %}

end


annotation_read


Запуск этого приведет к печати следующего:


"Annotation 0 = foo"

"Annotation 1 = 123"

"Annotation 2 = 123"


Вот и все. Аннотации сами по себе являются довольно простой функцией, но могут быть весьма мощными в сочетании с некоторыми другими функциями метапрограммирования Crystal.

Резюме

В этой главе мы рассмотрели, как определять и использовать аннотации для расширения различных Функции Crystal с дополнительными метаданными, включая способ хранения как именованных, так и позиционные аргументы, как читать одиночные и множественные аннотации и какие преимущества/Аннотации вариантов использования выполняются поверх макросов.

Аннотации — это жизненно важная функция метапрограммирования, которую мы обязательно будем использовать в следующих главах. До сих пор весь макрокод, который мы писали для доступа к данным типа или метода, находился в контексте этого типа или метода.

В следующей главе мы собираемся изучить функцию самоанализа типов во время компиляции Crystal, которая представит новые способы доступа к той же информации.

12. Использование интроспекции типов во время компиляции

В предыдущих главах мы в основном использовали макросы внутри самих типов и методов для доступа к информации времени компиляции или чтения аннотаций. Однако это значительно снижает эффективность макросов, поскольку они могут динамически реагировать на добавление или аннотирование новых типов. Следующая концепция метапрограммирования Crystal, которую мы собираемся рассмотреть, — это интроспекция типов во время компиляции, которая будет охватывать следующие темы:

• Итерация переменных типа

• Итерационные типы

• Итерационные методы


К концу этой главы вы сможете создавать макросы, которые генерируют код, используя переменные экземпляра, методы и/или информацию о типе, а также данные, считываемые из аннотаций.

Технические требования

Требования к этой главе следующие:

• Рабочая установка Кристалла.

Инструкции по настройке Crystal можно найти в Главе 1 «Введение в Crystal».

Все примеры кода, использованные в этой главе, можно найти в папке Главы 12 на GitHub: https://github.com/PacktPublishing/Crystal-Programming/tree/main/Chapter12.

Итерация переменных типа

Одним из наиболее распространенных случаев использования интроспекции типов является перебор переменных экземпляра типа. Простейшим примером этого может быть добавление метода #to_h к объекту, который возвращает хэш, используя переменные экземпляра типа для ключа/значений. Это будет выглядеть так:


class Foo

  getter id : Int32 = 1

  getter name : String = "Jim"

  getter? active : Bool = true


  def to_h

    {

      "id" => @id,

      "name" => @name,

      "active" => @active,

    }

  end

end


pp Foo.new.to_h


Который, когда будет выполнен, выведет следующее:


{"id" => 1, "name" => "Jim", "active" => true}


Однако это далеко не идеально, поскольку вам нужно не забывать обновлять этот метод каждый раз, когда добавляется или удаляется переменная экземпляра. Он также не обрабатывает случай, когда этот класс расширяется и добавляются дополнительные переменные экземпляра.

Мы могли бы улучшить его, используя макрос для перебора переменных экземпляра этого типа с целью построения хеша. Новый метод #to_h будет выглядеть так:


def to_h

  {% begin %}

    {

      {% for ivar in @type.instance_vars %}

        {{ivar.stringify}} => @{{ivar}},

      {% end %}

    }

  {% end %}

end


Если вы помните из Главы 10 «Работа с макросами», нам нужно обернуть эту логику в начало/конец, чтобы сделать все допустимым синтаксисом Crystal. Затем мы используем метод #instance_vars для экземпляра TypeNode, полученного с помощью специальной макропеременной @type. Этот метод возвращает Array(MetaVar), который включает информацию о каждой переменной экземпляра, такую как ее имя, тип и значение по умолчанию.

Наконец, мы перебираем каждую переменную экземпляра с помощью цикла for, используя строковое представление имени переменной экземпляра в качестве ключа и, конечно же, ее значение в качестве значения хеша. Запуск этой версии программы дает тот же результат, что и раньше, но с двумя основными преимуществами:

• Он автоматически обрабатывает вновь добавленные/удаленные переменные экземпляра.

• Он будет включать переменные экземпляра, определенные для дочерних типов, поскольку макрос расширяется для каждого конкретного подкласса, поскольку он использует макропеременную @type.


Подобно итерации переменных экземпляра, доступ к переменным класса также можно получить с помощью метода TypeNode#class_vars. Однако есть одна серьезная ошибка при переборе переменных экземпляра/класса типа.


ПРЕДУПРЕЖДЕНИЕ
Доступ к переменным экземпляра возможен только в контексте метода. Попытка сделать это вне метода всегда приведет к получению пустого массива, даже если используется в ловушке завершения макроса.


По сути, это ограничение компилятора Crystal на данный момент, которое может быть реализовано в той или иной форме в будущем. Но до тех пор лучше иметь это в виду, чтобы не тратить время на отладку чего-то, что просто не будет работать. Посетите https://github.com/crystal-lang/crystal/issues/7504 для получения дополнительной информации об этом ограничении.

Другой вариант использования итерации переменных экземпляра — это добавление переменных экземпляра к некоторой внешней логике, которая может бытьвключена в модуль. Например, предположим, что у нас есть модуль Incrementable, который определяет один метод #increment, который, как следует из названия, будет увеличивать определенные выбранные переменные. Реализация этого метода может использовать @type.instance_vars вместе с ArrayLiteral#select, чтобы определить, какие переменные следует увеличить.

Прежде всего, давайте посмотрим на код модуля Incrementable:


module Incrementable

  annotation Increment; end


    def increment

      {% for ivar in @type.instance_vars.select &.annotation Increment %}

      @{{ivar}} += 1

    {% end %}

  end

end


Сначала мы определяем наш модуль вместе с аннотацией внутри него. Затем мы определяем метод, который фильтрует переменные экземпляра типа только для тех, к которым применена аннотация. Для каждой из этих переменных мы увеличиваем ее на единицу. Далее давайте посмотрим на тип, который будет включать в себя этот модуль:


class MyClass

  include Incrementable


  getter zero : Int32 = 0


  @[Incrementable::Increment]

  getter one : Int32 = 1

  getter two : Int32 = 2 @[Incrementable::Increment]


  getter three : Int32 = 3

end


Это довольно простой класс, который просто включает в себя наш модуль, определяет некоторые переменные экземпляра с помощью макроса getter и применяет аннотацию, определенную в модуле, к паре переменных. Мы можем протестировать наш код, создав и запустив следующую небольшую программу:


obj = MyClass.new


pp obj


obj.increment


pp obj


В этой программе мы создаем новый экземпляр нашего класса, который мы определили в последнем примере, печатаем состояние этого объекта, вызываем метод increment, а затем снова печатаем состояние объекта. Первая строка вывода показывает, что значение каждой переменной экземпляра соответствует имени переменной. Однако вторая строка вывода показывает, что переменные номер один и три действительно были увеличены на единицу.

Конечно, этот пример довольно тривиален, но приложения могут быть гораздо более сложными и мощными, о чем мы подробнее поговорим в следующей главе. А пока давайте перейдем от итерации переменных экземпляра/класса к итерации типов.

Итерационные типы

Многое из того, о чем мы говорили и продемонстрировали в последнем разделе, также можно применить и к самим типам. Одним из основных преимуществ перебора типов является то, что они не ограничены теми же ограничениями, что и переменные экземпляра. Другими словами, вам не обязательно находиться в контексте метода, чтобы перебирать типы. Благодаря этому возможности практически безграничны!

Вы можете перебирать типы в контексте другого класса для генерации кода, перебирать на верхнем уровне для создания дополнительных типов или даже внутри метода, чтобы построить своего рода конвейер, используя аннотации для определения порядка.

В каждом из этих контекстов любые данные, доступные во время компиляции, могут использоваться для изменения способа генерации кода, например переменные среды, константы, аннотации или данные, извлеченные из самого типа. В общем, это очень мощная функция, имеющая множество полезных применений. Но прежде чем мы сможем начать исследовать некоторые из этих вариантов использования, нам сначала нужно узнать, как можно выполнять итерации типов. Существует четыре основных способа итерации типов:

1. По всем или прямым подклассам родительского типа.

2. Типы, включающие определенный модуль.

3. Типы, к которым применяются определенные аннотации*

4. Некоторая комбинация предыдущих трех способов.


Первые два довольно очевидны. Третий метод отмечен звездочкой, так как здесь есть одна проблема, которую мы обсудим чуть позже в этой главе. Четвертое заслуживает дальнейшего объяснения. По сути, это означает, что вы можете использовать комбинацию первых трех, чтобы отфильтровать нужные вам типы. Примером этого может быть перебор всех типов, которые наследуются от определенного базового класса и к которым применена определенная аннотация, имеющая поле с определенным значением.

Самый распространенный способ перебора типов — через подклассы родительского типа. Это могут быть либо все подклассы этого типа, либо только прямые подклассы. Давайте посмотрим, как бы вы это сделали.

Итерация подклассов типа

Прежде чем мы перейдем к более сложным примерам, давайте сосредоточимся на более простом варианте использования перебора подклассов типа с использованием следующего дерева наследования:


abstract class Vehicle; end

abstract class Car < Vehicle; end


class SUV < Vehicle; end


class Sedan < Car; end

class Van < Car; end


Первое, что нам нужно, это TypeNode родительского типа, подклассы которого мы хотим перебрать. В нашем случае это будет Vehicle, но это не обязательно должен быть самый верхний тип. Мы могли бы с тем же успехом выбрать Car, если бы она лучше соответствовала нашим потребностям.

Если вы помните первую главу этой части, мы смогли получить TypeNode с помощью специальной макропеременной @type. Однако это будет работать только в том случае, если мы хотим перебирать типы в контексте типа Vehicle. Если вы хотите выполнить итерацию за пределами этого типа, вам нужно будет использовать полное имя родительского типа.

Когда у нас есть TypeNode, мы можем использовать два метода в зависимости от того, что именно мы хотим сделать. TypeNode#subclasses можно использовать для получения прямых подклассов этого типа. TypeNode#all_subclasses можно использовать для получения всех подклассов этого типа, включая подклассы подклассов и так далее. Например, добавьте в файл следующие две строки вместе с показанным ранее деревом наследования:


{{pp Vehicle.subclasses}}

{{pp Vehicle.all_subclasses}}


В результате компиляции программы на консоль будут выведены две строки: первая — [Car, SUV], а вторая — [Car, Sedan, Van, SUV]. Вторая строка длиннее, поскольку она также включает подклассы типа Car, который не включен в первую строку, поскольку Van и Sedan не являются прямыми дочерними элементами типа Vehicle.

Также обратите внимание, что массив содержит как конкретные, так и абстрактные типы. На это стоит обратить внимание, поскольку если бы вы захотели перебрать типы и создать их экземпляры, это не удалось бы, поскольку был бы включен абстрактный тип Car. Чтобы этот пример работал, нам нужно отфильтровать список типов до тех, которые не являются абстрактными. Оба метода в предыдущем примере возвращают ArrayLiteral(TypeNode). По этой причине мы можем использовать метод ArrayLiteral#reject для удаления абстрактных типов. Код для этого будет выглядеть так:


{% for type in Vehicle.all_subclasses.reject &.abstract? %}

    pp {{type}}.new

{% end %}


Запуск этого в конечном итоге приведет к печати нового экземпляра типов Sedan, Van, и SUV. Мы можем пойти дальше в этой идее фильтрации и включить более сложную логику, например, использование данных аннотаций для определения того, следует ли включать тип.

Например, предположим, что мы хотим получить подмножество типов, имеющих аннотацию, исключая те, у которых есть определенное поле аннотации. В этом примере мы будем использовать следующие типы:


annotation MyAnnotation; end


abstract class Parent; end

@[MyAnnotation(id: 456)]

class Child < Parent; end


@[MyAnnotation]

class Foo; end


@[MyAnnotation(id: 123)]

class Bar; end


class Baz; end


У нас пять занятий, включая одно реферативное. Мы также определили аннотацию и применили ее к некоторым типам. Кроме того, некоторые из этих аннотаций также включают поле id, в котором установлено некоторое число. Используя эти классы, давайте переберем только те, у которых есть аннотация и либо нет поля id, либо ID является четным числом.

Однако обратите внимание, что в отличие от предыдущих примеров здесь нет прямого родительского типа, от которого наследуются все типы, а также не существует конкретного модуля, включенного в каждый из них. Итак, как мы собираемся отфильтровать нужный нам тип? Здесь в игру вступает звездочка в начале главы. Пока не существует прямого способа просто получить все типы с определенной аннотацией. Однако мы можем использовать один и тот же шаблон перебора всех подклассов типа, чтобы воспроизвести это поведение.

Итерация типов с определенной аннотацией

В Crystal Object является самым верхним типом из всех типов. Поскольку все типы неявно наследуются от этого типа, мы можем использовать его в качестве базового родительского типа для фильтрации до нужных нам типов.

Однако, поскольку этот подход требует перебора всех типов, он гораздо менее эффективен, чем более целенаправленный подход. В будущем, возможно, появится лучший способ сделать это, но на данный момент, в зависимости от конкретного варианта использования/API, который вы хотите поддерживать, это достойный обходной путь.

Например, этот подход необходим, если типы, которые вы хотите перебрать, еще не имеют какого-либо общего определяемого пользователем типа и/или включенного модуля. Однако, поскольку этот тип также является родительским типом для типов в стандартной библиотеке, вам потребуется какой-то способ его фильтровать, например, с помощью аннотации.

Код, фактически выполняющий фильтрацию, похож на предыдущие примеры, только с немного более сложной логикой фильтрации. В конечном итоге это будет выглядеть следующим образом:


{% for type in Object.all_subclasses.select {|t| (ann =

  t.annotation(MyAnnotation)) && (ann[:id] == nil || ann[:id]

    % 2 == 0) } %}

  {{pp type}}

{% end %}


В этом случае мы используем ArrayLiteral#select, потому что нам нужны только те типы, для которых этот блок возвращает true. Логика отражает требования, которые мы упоминали ранее. Он выбирает типы, которые имеют нашу аннотацию и либо не имеют поля id, либо поля id с четным номером. При создании этого примера будут правильно напечатаны ожидаемые типы: Child и Foo.

Итерационные типы, включающие определенный модуль

Третий способ, которым мы можем перебирать типы, - это запросить те типы, которые включают определенный модуль. Это может быть достигнуто с помощью метода TypeNode#includers, где TypeNode представляет модуль, например:


module SomeInterface; end


class Bar

   include SomeInterface

end


class Foo; end


class Baz

   include SomeInterface

end


class Biz < Baz; end


{{pp SomeInterface.includers}}


Построение этой программы выведет следующее:


[Bar, Baz]


При использовании метода #includers следует отметить, что он включает только типы, которые напрямую включают этот модуль, а не типы, которые затем наследуются от него. Однако затем можно было бы вызвать #all_subclasses для каждого типа, возвращаемого через #includers, если это соответствует вашему варианту использования. Конечно, здесь также применима любая из ранее упомянутых логик фильтрации, поскольку #includers возвращает ArrayLiteral(TypeNode).

Во всех этих примерах мы начали с базового родительского типа и прошли через все подклассы этого типа. Также возможно сделать обратное; начните с дочернего типа и перебирайте его предков. Например, давайте посмотрим на предков класса Biz, добавив в нашу программу следующий код и запустив его:

{{pp Biz.ancestors}}

Это должно вывести следующее:


[Baz, SomeInterface, Reference, Object]


Обратите внимание, что мы получаем прямой родительский тип, модуль, который включает в себя его суперкласс, и некоторые неявные суперклассы этого типа, включая вышеупомянутый тип Object. И снова метод #ancestors возвращает ArrayLiteral(TypeNode), поэтому его можно фильтровать, как мы это делали в предыдущих примерах.

Следующая особенность метапрограммирования, которую мы собираемся рассмотреть, — это перебор методов типа.

Итерационные методы

Итерирующие методы имеют много общего с итерирующими типами, только с другим типом макроса. Первое, что нам нужно для перебора методов, — это TypeNode, представляющий тип, методы которого нас интересуют. Отсюда мы можем вызвать метод #methods, который возвращает ArrayLiteral(Def) всех методов, определенных для этого типа. Например, давайте напечатаем массив всех имен методов внутри класса:


abstract class Foo

   def foo; end

end


module Bar

   def bar; end

end

class Baz < Foo

   include Bar


   def baz; end


   def foo(value : Int32); end


   def foo(value : String); end


   def bar(x); end

end


baz = Baz.new

baz.bar 1

baz.bar false


{{pp Baz.methods.map &.name}}


Запуск этого приведет к следующему:


[baz, foo, foo, bar]


Обратите внимание, что, как и в случае с методом #includers, выводятся только методы, явно определенные внутри типа. Также обратите внимание, что метод #foo включается один раз для каждой из его перегрузок. Однако, несмотря на то, что #bar вызывается с двумя уникальными типами, он включается только один раз.

Логика фильтрации, о которой мы говорили в последнем разделе, также применима к итеративным методам. Проверка аннотаций может быть простым способом отметить методы, на которые должна воздействовать другая конструкция. Если вы вспомните модуль Incrementable из первого раздела, вы легко можете сделать что-то подобное, но заменив переменные экземпляра методами. Методы также обладают дополнительной гибкостью, поскольку их не нужно повторять в контексте метода.

Если вы помните раздел об итерации переменных экземпляра ранее в этой главе, для доступа к переменным класса существовал специальный метод TypeNode#class_vars. В случае методов класса эквивалентного метода не существует. Однако их можно перебирать. В большинстве случаев TypeNode будет представлять тип экземпляра типа, поэтому он используется для перебора переменных экземпляра или методов экземпляра этого типа. Однако существует метод, который можно использовать для получения другого TypeNode, представляющего метакласс этого типа, из которого мы можем получить доступ к методам его класса. Существует также метод, который возвращает тип экземпляра, если TypeNode представляет тип класса.

Этими методами являются TypeNode#class и TypeNode#instance. Например, если у вас есть TypeNode, представляющий тип MyClass, первый метод вернет новый TypeNode, представляющий MyClass.class, тогда как последний метод превратит MyClass.class в MyClass. Когда у нас есть тип класса TypeNode, это так же просто, как вызвать для него #methods; например:


class Foo

   def self.foo; end

   def self.bar; end

end


{{pp Foo.class.methods.map &.name}}


Запуск этого приведет к следующему:


[allocate, foo, bar]


Вам может быть интересно, откуда взялся метод allocate. Этот метод автоматически добавляется Crystal для использования в конструкторе, чтобы выделить память, необходимую для его создания. Учитывая, что вы, скорее всего, не захотите включать этот метод в свою логику, обязательно предусмотрите способ его отфильтровать.

Поскольку сами типы можно повторять, вы можете объединить эту концепцию с методами итерации. Другими словами, можно перебирать типы, а затем перебирать каждый из методов этого типа. Это может быть невероятно мощным средством автоматической генерации кода, так что конечному пользователю нужно только применить некоторые аннотации или наследовать/включить какой-либо другой тип.

Резюме

И вот оно у вас есть; как анализировать переменные, типы и методы экземпляра/класса во время компиляции! Этот метод метапрограммирования можно использовать для создания мощной логики генерации кода, которая может упростить расширение и использование приложений, одновременно делая приложение более надежным за счет снижения вероятности опечаток или ошибок пользователя.

Далее, в последней главе этой части, мы рассмотрим несколько примеров того, как все изученные до сих пор концепции метапрограммирования можно объединить в более сложные шаблоны/функции.

Дальнейшее чтение

Как упоминалось ранее, в TypeNode есть гораздо больше методов, которые находятся за пределами области видимости. Однако я настоятельно рекомендую ознакомиться с документацией по адресу https://crystal-lang.org/api/Crystal/Macros/TypeNode.html, чтобы узнать больше о том, какие дополнительные данные могут быть извлечены.

13. Расширенное использование макросов

В последних нескольких главах мы рассмотрели различные концепции метапрограммирования, такие как макросы, аннотации, и то, как их можно использовать вместе, чтобы обеспечить самоанализ типов, методов и переменных экземпляра во время компиляции. Однако по большей части мы использовали их самостоятельно. Эти концепции также можно комбинировать, чтобы создавать еще более мощные шаборны! В этой главе мы собираемся изучить некоторые из них, в том числе:

• Использование аннотаций для влияния на логику времени выполнения.

• Представление данных аннотаций/типов во время выполнения.

• Определение значения константы во время компиляции.

• Создание собственных ошибок времени компиляции.


К концу этой главы вы должны иметь более глубокое понимание метапрограммирования в Crystal. У вас также должны быть некоторые идеи о неочевидных вариантах использования метапрограммирования, которые позволят вам создавать уникальные решения проблем в вашем приложении.

Технические требования

Прежде чем мы углубимся в эту главу, в вашей системе должно быть установлено следующее:

• Рабочая установка Crystal.

Инструкции по настройке Crystal можно найти в Главе 1 «Введение в Crystal».

Все примеры кода, использованные в этой главе, можно найти в папке Chapter13 на GitHub: https://github.com/PacktPublishing/Crystal-Programming/tree/main/Chapter13.

Использование аннотаций для влияния на логику времени выполнения

Как мы узнали в Главе 11 «Введение в аннотации», аннотации — это отличный способ добавить дополнительные метаданные к различным функциям Crystal, таким как типы, переменные экземпляра и методы. Однако одним из их основных ограничений является то, что хранящиеся в них данные доступны только во время компиляции.

В некоторых случаях вам может потребоваться реализовать функцию с использованием аннотаций для настройки чего-либо, но логика, требующая этих данных, не может быть сгенерирована только с помощью макросов и должна выполняться во время выполнения. Например, предположим, что мы хотим иметь возможность печатать экземпляры объектов в различных форматах. Эта логика может использовать аннотации, чтобы отметить, какие переменные экземпляра следует предоставлять, а также настроить способ их форматирования. Высокоуровневый пример этого будет выглядеть так:


annotation Print; end


class MyClass

   include Printable


   @[Print]

   property name : String = "Jim"


   @[Print(format: "%F")]

   property created_at : Time = Time.utc


   @[Print(scale: 1)]

   property weight : Float32 = 56.789

end


MyClass.new.print


Результатом этого может быть следующее:


---

name: Jim

created_at: 2021-11-16

weight: 56.8

---


Чтобы реализовать это, логика печати должна иметь доступ как к данным аннотации, так и к значению переменной экземпляра, которая должна быть напечатана. В нашем случае модуль Printable позаботится об этом, определяя метод, который обрабатывает итерацию и печатает каждую применимую переменную экземпляра. В конечном итоге это будет выглядеть так:


module Printable

  def print(printer)

    printer.start

      {% for ivar in @type.instance_vars.select(&.annotation Print) %}

        printer.ivar({{ivar.name.stringify}},

        @{{ivar.name.id}},

        {{ivar.annotation(Print).named_args.double_splat}})

      {% end %}

    printer.finish

  end


  def print(io : IO = STDOUT)

    print IOPrinter.new(io)

  end

end


Большая часть логики выполняется в методе #print(printer). Этот метод напечатает начальный шаблон, которым в данном случае являются три тире. Затем он использует макрос цикла for для перебора переменных экземпляра включающего типа. Переменные экземпляра фильтруются таким образом, что включаются только те, у которых есть аннотация Print. Затем для каждой из этих переменных вызывается метод #ivar на принтере с именем и значением переменной экземпляра, а также любых именованных аргументов, определенных в аннотации. Наконец, он печатает конечный образец, который также состоит из трех тире.

Для поддержки предоставления значений из аннотации мы также используем метод NamedTupleLiteral#double_splat вместе с Annotation#named_ args. Эта комбинация предоставит любые пары ключ/значение, определенные в аннотации, в качестве именованных аргументов для вызова метода.

Метод #print(io) служит основной точкой входа для печати экземпляра. Он позволяет предоставить пользовательский I/O, на который должны выводиться данные, но по умолчанию это STDOUT. I/O используется для создания другого типа, который фактически выполняет печать:


struct IOPrinter

  def initialize(@io : IO); end


  def start

    @io.puts "---"

  end


  def finish

    @io.puts "---"

    @io.puts

  end


  def ivar(name : String, value : String)

    @io << name << ": " << value

    @io.puts

  end


  def ivar(name : String, value : Float32, *, scale :

    Int32 = 3)

    @io << name << ": "

    value.format(@io, decimal_places: scale)

    @io.puts

  end


  def ivar(name : String, value : Time, *, format : String

    = "%Y-%m-%d %H:%M:%S %:z")

    @io << name << ": "

    value.to_s(@io, format)

    @io.puts

  end

end


Этот тип определяет начальный и конечный методы, а также перегрузку для каждого из поддерживаемые типы переменных экземпляра, каждый из которых имеет определенные значения и значения по умолчанию, связанные с этим тип. Используя отдельный тип с перегрузками, мы можем раньше отловить по ним ошибки. являются ошибками времени компиляции, например, если вы использовали аннотацию для неподдерживаемого введите или не указал значение в аннотации для обязательного аргумента. Этот пример показывает, насколько гибкими и мощными могут быть аннотации Crystal в сочетании с другими понятиями, такими как композиция и перегрузки. Однако бывают случаи, когда вы можете захотеть отделить логику от самого типа, например, чтобы сохранить вещи слабо связанный.

В следующем разделе мы рассмотрим, как мы можем сделать шаг вперед в том, что мы уже узнали, разрешив использование данных аннотаций/типов во время выполнения, чтобы их можно было использовать по мере необходимости.

Предоставление данных времени компиляции во время выполнения

Как мы закончили в предыдущем разделе, предоставление данных аннотации за пределами самого типа может быть хорошим способом сделать вещи менее связанными. Эта концепция фокусируется на определении структуры, которая представляет параметры связанной аннотации, а также другие метаданные, относящиеся к элементу, к которому была применена аннотация.

Если структура, представляющая данные аннотации, имеет обязательные параметры, которые, как ожидается, будут предоставлены через аннотацию, программа не будет компилироваться, если эти значения не будут предоставлены. Он также обрабатывает случай, когда параметры имеют значение по умолчанию. Кроме того, если в аннотации есть неожиданное поле или аргумент неправильного типа, она также не будет скомпилирована. Это значительно упрощает добавление / удаление свойств из структуры, поскольку все они не должны быть явно заданы в StringLiteral.

В настоящее время существует Crystal RFC, который предлагает сделать этот шаблон более встроенной функцией, сделав аннотацию и структуру одним и тем же. См. https://github.com/crystal-lang/crystal/issues/9802 для получения дополнительной информации.

Есть несколько способов фактически раскрыть структуры:

• Определите метод, который возвращает их массив.

• Определите метод, который возвращает хэш, который предоставляет их по имени переменной экземпляра.

• Определите метод, который принимает имя переменной экземпляра и возвращает его.


У каждого из этих подходов есть свои плюсы и минусы, но все они имеют что-то общее. В самом экземпляре/типе должна быть какая-то точка входа, которая предоставляет данные. Основная причина этого заключается в том, что переменные экземпляра можно повторять только в контексте метода.

Кроме того, существует два основных способа обработки самих структур. Один из вариантов — сделать метод методом экземпляра и включить значение каждой переменной экземпляра в структуру. У этого подхода есть несколько недостатков, например, его сложнее запомнить и он не очень хорошо обрабатывает обновления. Например, вы вызываете метод и получаете структуру для данной переменной экземпляра, но затем значение этой переменной экземпляра изменяется до того, как будет выполнена фактическая логика. Значение в структуре может представлять только значение на момент вызова метода.

Другой подход — сделать метод лениво инициализируемым запоминаемым методом класса. Этот подход идеален, потому что:

1. Он создает хэш/массив только для типов, которые используются вместо каждого типа/экземпляра.

2. Он кэширует структуры, поэтому их нужно создать только один раз.

3. Это имеет больше смысла, поскольку большая часть данных будет относиться к данному типу, а не к экземпляру этого типа.


Для целей этого примера мы собираемся создать модуль, который определяет лениво инициализированный метод класса, который будет возвращать хеш свойств этого типа. Но прежде чем мы это сделаем, давайте подумаем, какие данные мы хотим хранить в нашей структуре. Чаще всего структура представляет переменную экземпляра вместе с данными из примененной к ней аннотации. В этом случае наша структура будет иметь следующие поля:

1. name – название объекта недвижимости.

2. type– тип объекта недвижимости.

3. class – класс, частью которого является свойство.

4. priority – необязательное числовое значение из аннотации.

5. id – необходимое числовое значение из аннотации.


Конечно, то, какие данные вам нужны, во многом зависит от конкретного варианта использования, но, как правило, имя, тип и класс полезно иметь во всех случаях. Тип может быть, например, типом переменной экземпляра или типом возвращаемого значения метода.

Мы можем использовать макрос record, чтобы упростить создание нашей структуры. В конечном итоге это будет выглядеть так:


abstract struct MetadataBase; end

record PropertyMetadata(ClassType, PropertyType, Propertyldx)

  < MetadataBase,

  name : String,

  id : Int32,

  priority : Int32 = 0 do

  def class_name : ClassType.class

    ClassType

  end


  def type : PropertyType.class

    PropertyType

  end

end


Мы используем дженерики, чтобы указать тип класса и переменную экземпляра. У нас также есть еще одна универсальная переменная, с которой мы вскоре разберемся. Мы представили эти дженерики как методы, поскольку универсальные типы уже будут ограничены каждым экземпляром, и поэтому нет необходимости также хранить их как переменные экземпляра.

У каждой записи будет имя, и мы также добавили к ней два дополнительных свойства. Поскольку значение priority является необязательным, мы установили для него значение по умолчанию, равное 0, тогда как идентификатор является обязательным, поэтому у него нет значения по умолчанию.

Далее нам нужно создать модуль, который будет создавать и предоставлять хеш метаданных свойств. Мы можем использовать некоторые концепции макросов, которые мы изучили несколько глав назад, такие как макроперехваты и дословное выполнение. В конечном итоге этот модуль будет выглядеть так:


annotation Metadata; end


module Metadatable

  macro included

    class_property metadata : Hash(String, MetadataBase) do

      {% verbatim do %}

        {% begin %}

          {

            {% for ivar, idx in @type.instance_vars.select &.

              annotation Metadata %}

              {{ivar.name.stringify}} => (PropertyMetadata(

                {{@type}}, {{ivar.type.resolve}},{{idx}}

                ).new({{ivar.name.stringify}},

                  {{ivar.annotation(Metadata).named_args

                  .double_splat}}

              )),

            {% end %}

          } of String => MetadataBase

        {% end %}

      {% end %}

    end

  end

end


Мы также используем блочную версию макроса class_getter для определения ленивого метода получения. Включенный хук используется для того, чтобы гарантировать, что метод получения определен внутри класса, в который включен модуль. Функции дословного макроса и начала также используются для обеспечения выполнения кода дочернего макроса в контексте включающего типа, а не самого модуля.

Фактическая логика макроса довольно проста и делает многое из того, что мы делали в предыдущем разделе. Однако в этом примере мы также передаем некоторые общие значения при создании экземпляра нашего экземпляра PropertyMetadata.

На этом этапе наша логика готова к испытанию. Создайте класс, включающий модуль и некоторые свойства, использующие аннотацию, например:


class MyClass

include Metadatable


  @[Metadata(id: 1)]

  property name : String = "Jim"


  @[Metadata(id: 2, priority: 7)]

  property created_at : Time = Time.utc

  property weight : Float32 = 56.789

end


pp MyClass.metadata["created_at"]


Если бы вы запустили эту программу, вы бы увидели, что она выводит экземпляр PropertyMetadata со значениями из аннотации и самой переменной экземпляра, установленными правильно. Однако есть еще одна вещь, с которой нам нужно разобраться; как мы можем получить доступ к значению связанного экземпляра метаданных? Именно это мы и собираемся исследовать дальше.

Доступ к значению

Малоизвестный факт об обобщениях заключается в том, что в качестве значения универсального аргумента можно также передать число. В первую очередь это сделано для поддержки типа StaticArray, который использует синтаксис StaticArray(Int32, 3) для обозначения статического массива из трех значений Int32.

Как упоминалось ранее, наш тип PropertyMetadata имеет третью универсальную переменную, которой мы присваиваем индекс связанной переменной экземпляра. Основной вариант использования этого заключается в том, что мы можем затем использовать это для извлечения значения, которое представляет экземпляр метаданных, в сочетании с другим трюком.

Если вам интересно, нет, нет способа волшебным образом получить значение из воздуха только потому, что у нас есть индекс переменной экземпляра и TypeNode типа, которому оно принадлежит. Для извлечения нам понадобится реальный экземпляр MyClass. Чтобы учесть это, нам нужно добавить в PropertyMetadata несколько дополнительных методов:


def value(obj : ClassType)

  {% begin %}

     obj.@{{ClassType.instance_vars[PropertyIdx].name.id}}

  {% end %}

end


def value(obj) i : NoReturn

    raise "BUG: Invoked default value method."

end


Другая хитрость, которая делает эту реализацию возможной, — это возможность прямого доступа к переменным экземпляра типа, даже если у них нет метода получения через синтаксис obj.@ivar_name. В предисловии к этому я скажу, что вам не следует использовать это часто, если вообще когда-либо, за исключением очень специфических случаев использования, таких как этот. Это антишаблон, и его следует избегать, когда это возможно. В 99% случаев вам следует вместо этого определить метод получения, чтобы вместо этого предоставить значение переменной экземпляра.

С учетом вышесказанного реализация использует индекс переменной экземпляра для доступа к ее имени и использования его для создания предыдущего синтаксиса. Поскольку все это происходит во время компиляции, фактический метод, который добавляется, например, для переменной экземпляра name, будет выглядеть следующим образом:


def value(obj : ClassType)

    obj.@name

end


Мы также определили еще одну перегрузку, которая вызывает исключение, если вы передаете экземпляр объекта, тип которого отличается от типа, представленного экземпляром метаданных. В основном это делается для того, чтобы компилятор был доволен, когда существует более одного типа Metadatable. На практике этого никогда не должно происходить, поскольку конечный пользователь не будет напрямую взаимодействовать с этими экземплярами метаданных, поскольку это будет внутренней деталью реализации.

Мы можем пойти дальше и опробовать это, добавив в нашу программу следующее и запустив ее:


my_class = MyClass.new


pp MyClass.metadata["name"].value my_class


Вы должны увидеть значение свойства name, напечатанное на вашем терминале, которое в данном случае будет "Jim". У этой реализации есть один недостаток. Тип значения, возвращаемого методом #value, будет состоять из объединения всех свойств, имеющих аннотацию данного типа. Например, typeof(name_value) вернет (String | Time), что в целом приводит к менее эффективному представлению памяти.

Этот шаблон отлично подходит для реализации мощных внутренних API, но его следует использовать с осторожностью, не использовать в «горячем» пути приложения и даже не публиковать публично.

Если вы помните Главу 9 «Создание веб-приложения с помощью Athena», где вы применяли аннотации ограничений проверки, компонент Validator Athena реализован с использованием этого шаблона, хотя и с несколько большей сложностью.

Конечно, это, скорее всего, не тот шаблон, который вам понадобится очень часто, если вообще когда-либо понадобится, но полезно знать, если такая необходимость когда-нибудь возникнет. Это также хороший пример того, насколько мощными могут быть макросы, если вы мыслите немного нестандартно. В качестве дополнительного бонуса мы можем еще раз продвинуть эту модель на шаг дальше.

Моделирование всего класса

В предыдущем разделе мы рассмотрели, как можно использовать структуру для представления определенного элемента, например переменной экземпляра или метода, вместе с данными из примененной к нему аннотации. Другой шаблон предполагает создание специального типа для хранения этих данных вместо непосредственного использования массива или хеша. Этот шаблон может быть полезен для отделения метаданных о типе от самого типа, а также для добавления дополнительных методов/свойств без необходимости засорять фактический тип.

Чтобы это работало, вам нужно иметь возможность перебирать свойства и создавать хэш или массив внутри конструктора другого типа. Несмотря на то, что существует ограничение на чтение переменных экземпляра типа, оно не означает, что это должен быть метод внутри самого типа. Учитывая, что конструктор — это всего лишь метод, который возвращает self, это не будет проблемой. Несмотря на это, нам все равно нужна ссылка на TypeNode интересующего нас типа.

Поскольку макросы имеют доступ к общей информации, даже в контексте метода мы можем заставить этот тип ClassMetadata принимать аргумент универсального типа, чтобы передать ссылку на TypeNode. Кроме того, мы могли бы продолжать передавать общий тип другим типам/методам, которым он нужен.

Например, используя тот же тип PropertyMetadata, что и в последнем разделе:


annotation Metadata; end

annotation ClassConfig; end


class ClassMetadata(T)

  def initialize

    {{@type}}


    {% begin %}

      @property_metadata = {

        {% for ivar, idx in T.instance_vars.select &.

          annotation Metadata %}

          {{ivar.name.stringify}} => (

            PropertyMetadata({{@type}}, {{ivar.type.resolve}},

              {{idx}}).new({{ivar.name.stringify}},

                {{ivar.annotation(Metadata).named_args

                  .double_splat}})

          ),

        {% end %}

      } of String => MetadataBase


      @name = {{(ann = T.annotation(ClassConfig)) ?

        ann[:name] : T.name.stringify}}

    {% end %}

  end


  getter property_metadata : Hash(String, MetadataBase)

  getter name : String

end


Модуль Metadatatable теперь выглядит так:


module Metadatable

  macro included


  class_getter metadata : ClassMetadata(self)

     { ClassMetadata(self).new }

  end

end


Большая часть логики такая же, как и в предыдущем примере, за исключением того, что вместо прямого возврата хеша метод .metadata теперь возвращает экземпляр ClassMetadata, который предоставляет хеш. В этом примере мы также представили еще одну аннотацию, чтобы продемонстрировать, как предоставлять данные, когда аннотацию можно применить к самому классу, например настройку имени с помощью @[ClassConfig(name: "MySpecialName")].

В следующем разделе мы рассмотрим, как можно использовать макросы и константы вместе для регистрации вещей, которые можно будет использовать/перебирать в более поздний момент времени.

Определение значения константы во время компиляции

Константы в Crystal постоянны, но не заморожены. Другими словами, это означает, что если вы определите константу как массив, вы не сможете изменить ее значение на String, но вы можете вставлять/извлекать значения в/из массива. Это, в сочетании с возможностью макроса получать доступ к значению константы, приводит к довольно распространенной практике использования макросов для изменения констант во время компиляции, чтобы впоследствии значения можно было использовать/перебирать в готовом перехватчике.

С появлением аннотаций этот шаблон уже не так полезен, как раньше. Тем не менее, это все равно может быть полезно, если вы хотите предоставить пользователю возможность влиять на некоторые аспекты вашей макрологики, и нет места для применения аннотации. Одним из основных преимуществ этого подхода является то, что его можно вызвать в любом месте исходного кода и при этом применить, в отличие от аннотаций, которые необходимо применять к связанному элементу.

Например, скажем, нам нужен способ регистрации типов во время компиляции, чтобы можно было разрешать их по имени строки во время выполнения. Чтобы реализовать эту функцию, мы определим константу как пустой массив и макрос, который будет помещать типы в константу массива во время компиляции. Затем мы обновим логику макроса, чтобы проверить этот массив и пропустить переменные экземпляра с типами, включенными в массив. Первая часть реализации будет выглядеть так:


MODELS = [] of ModelBase.class


macro register_model(type)

{% MODELS << type.resolve %}

end


abstract class ModelBase

end


class Cat < ModelBase

end


class Dog < ModelBase

end


Здесь мы определяем изменяемую константу, которая будет содержать зарегистрированные типы, сами типы и макрос, который будет их регистрировать. Мы также вызываем #resolve для типа, переданного макросу, поскольку типом аргумента макроса будет Path. Метод #resolve преобразует путь в TypeNode, который представляет собой типы переменных экземпляра. Метод #resolve необходимо использовать только в том случае, если тип передается по имени, например, в качестве аргумента макроса, тогда как макропеременная @type всегда будет TypeNode.

Теперь, когда у нас определена сторона регистрации, мы можем перейти к стороне времени выполнения. Эта часть представляет собой просто метод, который генерирует оператор case, используя значения, определенные в константах MODELS, например:


def model_by_name(name)

  {% begin %}

    case name

    {% for model in MODELS %}

      when {{model.name.stringify}} then {{model}}

    {% end %}

    else

      raise "model unknown"

    end

  {% end %}

end


Отсюда мы можем пойти дальше и добавить следующий код:


pp {{ MODELS }}

pp model_by_name "Cat"


register_model Cat

register_model Dog


pp {{ MODELS }}

pp model_by_name "Cat"


После его запуска вы увидите следующее, напечатанное на вашем терминале:


[]

Cat

[Cat, Dog]

Cat


Мы видим, что первый массив пуст, поскольку ни один тип не был зарегистрирован, хотя строка “Cat" может быть успешно разрешена, даже если после нее зарегистрирован связанный тип. Причина этого в том, что регистрация происходит во время компиляции, а разрешение — во время выполнения. Другими словами, регистрация модели происходит до того, как программа начнет выполняться, независимо от того, в каком месте исходного кода зарегистрированы типы.

После регистрации двух типов мы видим, что массив MODELS содержит их. Наконец, это еще раз показывает, что его можно было разрешить при вызове до или после регистрации связанного типа. Как упоминалось ранее в этой главе, макросы не имеют такой же типизации, как обычный код Crystal. Из-за этого к макросам невозможно добавлять ограничения типов. Это означает, что пользователь может передать в макрос .register_model все, что пожелает, что может привести к не столь очевидным ошибкам. Например, если они случайно передали "Time" вместо Time, это приведет к следующей ошибке: неопределенный метод макроса 'StringLiteral#resolve'. В следующем разделе мы собираемся изучить способ сделать источник ошибки более очевидным.

Создание пользовательских ошибок времени компиляции

Ошибки времени компиляции — одно из преимуществ компилируемого языка. Вы сразу же узнаете о проблемах, вместо того, чтобы ждать, пока этот код будет выполнен, чтобы обнаружить ошибку. Однако, поскольку Crystal не знает контекста конкретной ошибки, он всегда будет выводить одно и то же сообщение об ошибке одного и того же типа. Последняя функция, которую мы собираемся обсудить в этой главе, связана с выдачей ваших собственных ошибок во время компиляции.

Пользовательские ошибки времени компиляции могут быть отличным способом добавить дополнительную информацию к сообщению об ошибке, что значительно облегчает жизнь конечному пользователю, поскольку ему становится понятнее, что необходимо сделать для устранения проблемы. Возвращаясь к примеру в конце последнего раздела, давайте обновим наш макрос .exclude_type, чтобыобеспечить лучшее сообщение об ошибке в случае передачи неожиданного типа.

В последних нескольких главах мы использовали различные макрометоды верхнего уровня, такие как #env, #flag и #debug. Другой метод верхнего уровня — #raise, который вызывает ошибку во время компиляции и позволяет предоставить собственное сообщение. Мы можем использовать это с некоторой условной логикой, чтобы определить, не является ли значение, переданное нашему макросу, Path. Наш обновленный макрос будет выглядеть так:


macro exclude_type(type)

  {% raise %(Expected argument to 'exclude_type' to be

    'Path', got '#{type.class_name.id}'.) unless type.is_a?

      Path %}

  {% EXCLUDED_TYPES << type.resolve %}

end


Теперь, если бы мы вызвали макрос с "Time", мы бы получили ошибку:


In mutable_constants.cr:43:1


43 | exclude_type "Time"

     ^-----------

Error: Expected argument to 'exclude_type' to be 'Path', got 'StringLiteral'.


Помимо отображения нашего специального сообщения, он также выделяет вызов макроса, вызвавший ошибку, и показывает номер строки. Однако есть кое-что, что мы можем сделать, чтобы еще больше улучшить эту ошибку.

Все типы макросов, с которыми мы работали, произошли от базового типа макроса ASTNode, который предоставляет базовые методы, общие для всех узлов, откуда и берет свое начало метод #id, который мы использовали несколько раз. Этот тип также определяет свой собственный метод #raise, который работает так же, как и метод верхнего уровня, но выделяет конкретный узел, на котором он был вызван.

Мы можем реорганизовать нашу логику, чтобы использовать это, используя type.raise вместо простого повышения. К сожалению, в этом случае результирующая подсветка ошибок такая же. В Crystal есть несколько серьезных ошибок, связанных с этим, так что, надеюсь, со временем ситуация улучшится. Тем не менее, следовать этой практике по-прежнему рекомендуется, поскольку она не только дает читателю более ясное представление о том, что такое недопустимое значение, но также делает код пригодным для будущего.

Ограничение универсальных типов

Обобщенные шаблоны в Crystal обеспечивают хороший способ уменьшения дублирования, позволяя параметризовать тип для поддержки его использования с несколькими конкретными типами. Хорошим примером этого могут быть типы Array(T) или Hash(K, V). Однако обобщенные типы Crystal в настоящее время не предоставляют встроенного способа ограничения типов, с помощью которых может быть создан универсальный тип. Возьмем, к примеру, следующий код:


abstract class Animal

end


class Cat < Animal

end


class Dog < Animal

end


class Food(T)

end


Food(Cat).new

Food(Dog).new

Food(Int32).new


В этом примере имеется общий тип еды, который должен принимать только подкласс Animal. Однако по умолчанию вполне нормально иметь возможность создавать экземпляр Food, используя тип, отличный от Animal, например Int32. Мы можем использовать специальную ошибку времени компиляции в конструкторе Food, чтобы гарантировать, что T является дочерним элементом Animal. В конечном итоге это будет выглядеть так:


class Food(T)

  def self.new

    {% raise "Non animal '#{t}' cannot be fed." unless T <=

      Animal %}

  end

end


В этом новом коде попытка выполнить Food(Int32).new вызовет ошибку во время компиляции.

Возможность определять собственные ошибки времени компиляции может существенно сократить время, необходимое для отладки проблемы. В противном случае неопределенные ошибки могут быть дополнены дополнительным контекстом/ссылками и в целом станут более удобными для пользователя.

Резюме

Ура! Мы подошли к концу части книги, посвященной метапрограммированию, рассмотрели много нового и продемонстрировали, насколько мощными могут быть макросы Crystal. Я надеюсь, что вы сможете применить свое более глубокое понимание макросов и этих шаблонов для решения сложных задач, с которыми вы можете столкнуться в рамках ваших будущих проектов.

В следующей части мы рассмотрим различные инструменты поддержки Crystal, например, как тестировать, документировать и развертывать ваш код, а также как автоматизировать этот процесс!

Часть 5: Вспомогательные инструменты

Crystal поставляется в комплекте с различными вспомогательными функциями и инструментами, которые помогут создать все необходимое для создания надежных и удобных в использовании приложений после того, как само приложение будет написано. Это включает в себя платформу тестирования, которая гарантирует, что приложение продолжает функционировать должным образом, и систему документации, которая облегчает другим пользователям изучение того, как пользоваться приложением, и поддерживается природой самого языка, что упрощает его развертывание. Давайте начнем!

Эта часть содержит следующие главы:

• Глава 14, тестирование

• Глава 15, Документирование кода

• Глава 16, Развертывание кода

• Глава 17, Автоматизация

• Приложение А, Настройка инструментария

• Приложение В, Будущее Crystal

14. Тестирование

Если вы помните, в Главе 4 «Изучение Crystal посредством написания интерфейса командной строки» при создании проекта создавалась папка spec/. В этой папке находились все тесты, относящиеся к приложению, но что такое тесты и зачем их писать? Короче говоря, тесты — это автоматизированный способ убедиться, что ваш код по-прежнему работает должным образом. Они могут быть чрезвычайно полезны по мере роста вашего приложения, поскольку время и усилия, необходимые для ручного тестирования всего на предмет каждого изменения, становятся просто невозможными. В этой главе мы рассмотрим следующие темы:

• Зачем тестировать?

• Модульное тестирование.

• Интеграционное тестирование.


К концу этой главы вы должны понять преимущества тестирования и то, как писать общие модульные тесты и интеграционные тесты в контексте Athena Framework.

Технические требования

Для этой главы вам потребуется следующее:

• Рабочая установка Crystal.

Инструкции по настройке Crystal можно найти в Главе 1 «Введение в Crystal».

Все примеры кода, использованные в этой главе, можно найти в папке Chapter 14 на GitHub по следующей ссылке: https://github.com/PacktPublishing/Crystal-Programming/tree/main/Chapter14

Зачем тестировать?

В обоих двух более крупных проектах, над которыми мы работали до сих пор, и во всех других примерах, мы запускали их вручную после внесения изменений, чтобы гарантировать, что они дают ожидаемый результат, например, возвращают правильный ответ, производят желаемое преобразование и т. д. или просто вывести правильное значение на терминал.

Этот процесс подходит, когда имеется всего несколько методов/потоков. Однако по мере роста приложения становится невозможным тестировать каждый метод или поток самостоятельно после каждого изменения. Конечно, вы можете вернуться к тестированию только тех вещей, которые напрямую связаны с тем, что вы изменили, но это может привести к пропущенным ошибкам в другой логике, которая это использует. Тестирование — это процесс написания дополнительного кода, который автоматически выполняет утверждения, чтобы гарантировать, что код выполняется должным образом.

Тестирование также может быть хорошим способом убедиться, что изменение не приведет к непреднамеренному нарушению общедоступного интерфейса прикладного программирования (API) вашего приложения, поскольку тесты будут тестировать общедоступный API и, как следствие, частный API.

Некоторые люди (или компании) могут колебаться, стоит ли тратить дополнительное время и деньги на что-то, что по существу не приносит никакой пользы клиенту/пользователю приложения. Однако то небольшое количество времени, которое потребуется для написания некоторых тестов, может в конечном итоге сэкономить бесчисленное количество часов, предотвращая попадание ошибок в рабочую среду.

Существуют различные виды тестирования, каждый из которых преследует свою цель. Некоторые из них включают следующее:

• Модульное тестирование (Unit testing): изолированное тестирование конкретной функции/метода.

• Интеграционное тестирование (Integration testing): тестирование интеграции различных типов вместе, имитация внешних коммуникаций (база данных, внешние API и т. д.).

• Функциональное тестирование (Functional testing): аналогично интеграционному тестированию, но с меньшим количеством насмешек и более конкретными утверждениями, например, конкретное значение, возвращаемое из базы данных, а не просто подтверждение того, что запрос был выполнен.

• Сквозное тестирование (E2E) (End-to-end (E2E) testing): аналогично функциональному тестированию, но обычно включает пользовательский интерфейс (UI) и минимальное количество макетов.

• Тестирование безопасности (Security testing): проверка отсутствия известных недостатков безопасности в коде. Каждый из этих типов тестирования имеет свои плюсы, минусы и цели. Однако мы собираемся в первую очередь сосредоточиться на модульной и интеграционной/функциональной стороне вещей, начиная с модульного тестирования.

Модульное тестирование

Модульное тестирование означает, что вы хотите протестировать определенный метод, будь то на верхнем уровне или как часть объекта, изолированно. Изолированное тестирование является важной частью этого типа тестирования. Это гарантирует, что вы тестируете только ту логику, которую хотите, а не логику ее зависимостей.

Crystal поставляется в комплекте с модулем Spec, который предоставляет инструменты, необходимые для тестирования вашего кода. Например, предположим, что у вас есть следующий метод, который возвращает сумму двух значений как часть add.cr:


def add(value1, value2)

    valuel + value2

end


Соответствующие тесты для этого могут выглядеть так:


require "spec"

require "./add"


describe "#add" do

  it "adds with positive values" do

    add(1, 2).should eq 3

  end


  it "adds with negative values" do

    add(-1, -2).should eq -3

  end


  it "adds with mixed signed values" do

    add(-1, 2).should eq 1

  end

end


Сначала нам нужен модуль Spec, а затем мы используем метод #describe для создания группы связанных тестов — в данном случае всех тех, которые связаны с методом #add. Затем мы используем метод #it для определения конкретных тестовых случаев, в которых мы утверждаем, что он возвращает правильное значение. У нас есть некоторые из них, определенные для примера. В идеале у вас должен быть тестовый пример для каждого потока, через который может пройти код, и обязательно добавлять новые по мере исправления ошибок.

Если вы тестировали этот метод как часть сегмента, вам нужно было бы создать файл в папке spec/ с именем, оканчивающимся на _spec, например spec/add_spec.cr. Обычно тесты следуют тому же организационному стилю, что и исходный код, например, используют те же подпапки и т.п. После этого вы сможете запустить спецификацию Crystal, которая запустит все спецификации, определенные в папке. В противном случае вы также можете запустить этот файл, как и любую другую программу Crystal, если это разовый тест. Также предлагается использовать опцию --order=random для crystal spec. Это запустит все тестовые примеры в случайном порядке, что может помочь выявить случаи, когда одна спецификация требует запуска предыдущей, а это не то, что вам нужно.

Файл spec/spec_helper.cr, созданный командой crystal init, используется в качестве точки входа в тесты проекта. Этот файл обычно требует спецификации, исходного кода проекта, а также любых других файлов, специфичных для спецификации, таких как фикстуры или макеты. Здесь также могут быть определены глобальные помощники тестирования. Каждый тест должен требовать, чтобы этот файл имел доступ к модулю Spec и другим помощникам.

В предыдущем примере мы использовали только утверждение eq, то есть два значения равны. Однако модуль Spec предоставляет множество других утверждений, как показано в следующем примере:


require "spec"


it do

  true.should be_true

  nil.should be_nil

  10.should be >= 5

  "foo bar baz".should contain "bar"

  10.should_not eq 5


  expect_raises Exception, "Err" do

    raise Exception.new "Err"

  end

end


Полный список см. на https://crystal-lang.org/api/Spec/Expectations.html. Этот пример также демонстрирует, что внешний блок #describe не требуется. Однако обычно рекомендуется включить один из них, поскольку он помогает в организации тестов. Однако блок #it необходим, поскольку без него сообщения об ошибках не будут корректно сообщаться.

По мере роста количества кода в приложении будет расти и количество тестов. Это может затруднить отладку конкретных тестовых случаев. В этом случае аргумент focus: true можно добавить в блок #describe или #it. При этом будет выполнена только одна спецификация, как в следующем примере:

it "does something", focus: true do

    1.should eq 1

end

Только не забудьте удалить его перед совершением!

Модуль Spec также предоставляет некоторые дополнительные методы, которые можно использовать для более точного контроля выполнения ваших тестовых случаев. Некоторые из них перечислены здесь:

• #pending: этот метод используется для определения тестового примера для чего-то, что еще не полностью реализовано, но будет реализовано в будущем, например, ожидающий "check cat" { cat.alive? }. Блок метода никогда не выполняется, но может использоваться для описания того, что должен делать тест.

• #pending!: Метод #pending! аналогичен предыдущему методу, но может использоваться для динамического пропуска тестового примера. Это может быть полезно для обеспечения выполнения зависимостей/требований системного уровня перед запуском тестового примера.

• #fail: Наконец, этот метод можно использовать для ручного провала тестового примера. Это можно использовать в сочетании с пользовательской условной логикой для создания более сложных утверждений, с которыми не могут справиться встроенные утверждения.

Маркировка (Tagging) тестов

Теги — это способ организовать спецификации в группы, чтобы можно было выполнить их подмножество. Подобно фокусировке спецификации, теги применяются к блокам #describe или #it через аргумент tags следующим образом:


require "spec"

describe "tags" do

  it "tag a", tags: "a" do

  end


    it "tag b", tags: "b" do

  end

end


Отсюда вы можете использовать опцию --tag через crystal spec, чтобы контролировать, какие из них будут выполняться, как описано здесь:

• --tag 'a' --tag 'b' будет включать спецификации, отмеченные ИЛИ b.

• --tag '~a' --tag '~b' будет включать спецификации, не помеченные знаком И, не помеченные знаком b.

• --tag 'a' --tag '~b' будет включать спецификации, отмеченные тегом a, но не отмеченные тегом b.

Последняя команда может выглядеть так: crystal spec --tag 'a'. Далее мы рассмотрим, как обрабатывать зависимости внутренних объектов путем создания макетов.

Осмеяние (Mocking)

Предыдущий пример с методом #add не имел никаких внешних зависимостей, но помните в Главе 4 «Изучение Crystal посредством написания интерфейса командной строки», как мы сделали NotificationEmitter типом аргумента конструктора, а не использовали его непосредственно в методе #process? Тип NotificationEmitter является зависимостью типа Processor.

Причина, по которой мы сделали его аргументом конструктора, заключается в том, что он следует нашим принципам проектирования SOLID (где SOLID означает принцип единой ответственности, принцип открытости-закрытости, принцип замены Лискова, принцип сегрегации интерфейса и принцип инверсии зависимостей), что, в свою очередь, делает тип легче для тестирования, позволяя использовать фиктивную реализацию вместо этого аргумента. Макет позволяет вам подтвердить, что он вызывается правильно, и настроить его на возврат значений так, чтобы тестовые примеры каждый раз были одинаковыми.

Давайте посмотрим на упрощенный пример здесь:


module TransformerInterface

  abstract def transform(value : String) : String

end


struct ShoutTransformer

  include Transformerinterface


  def transform(value : String) : String

    value.upcase

  end

end


class Processor

  def initialize(@transformer : Transformerinterface =

    ShoutTransformer.new); end

  def process(value : String) : String

    @transformer.transform value

  end

end


puts Processor.new.process "foo"


Здесь у нас есть тип интерфейса Transformer, который определяет требуемый метод, который должен реализовать каждый преобразователь. У нас есть единственная его реализация, ShoutTransformer, которая преобразует значение в верхний регистр. Затем у нас есть тип Processor, который использует тип интерфейса Transformer как часть своего метода #process, по умолчанию использующий преобразователь крика. Запуск этой программы приведет к выводу FOO на ваш терминал.

Поскольку мы хотим протестировать наш тип Processor изолированно, мы собираемся создать имитацию реализации преобразователя для использования в нашем тесте. Это гарантирует, что мы не тестируем больше, чем требуется. Взгляните на следующий пример:


class MockTransformer

  include Transformerinterface


  getter transform_arg_value : String? = nil


  def transform(value : String) : String

    @transform_arg_value = value

  end

end


Он реализует тот же API, что и другие, но фактически не преобразует значение, а просто предоставляет его через переменную экземпляра. Затем мы могли бы использовать это в тесте следующим образом, обязательно потребовав также Processor и MockTransformer, если они не определены в одном файле:


require "spec"


describe Processor do

  describe "#process" do

    it "processes" do

      transformer = MockTransformer.new


      Processor.new(transformer).process "bar"

      transformer.transform_arg_value.should eq "bar"

    end

  end

end


Поскольку фиктивный преобразователь хранит значение, мы можем использовать его, чтобы гарантировать, что он был вызван с ожидаемым значением. Это позволит выявить случаи, когда он не вызывается или вызывается с неожиданным значением, что является ошибкой. Макетная реализация также не обязательно должна быть частной. Его можно было бы представить как часть самого проекта, чтобы конечный пользователь мог использовать его и в своих тестах.

Хуки

Основной принцип тестирования заключается в том, что каждый тестовый пример независим от других, например, не полагаясь на состояние предыдущего теста. Однако для нескольких тестов может потребоваться одно и то же состояние для проверки того, на чем они сосредоточены. Crystal предоставляет несколько методов как часть модуля Spec, которые можно использовать для определения обратных вызовов в определенных точках жизненного цикла теста.

Эти методы могут быть полезны для централизации настройки/удаления необходимого состояния для тестов. Например, предположим, что вы хотите убедиться, что глобальная переменная среды установлена перед запуском любого теста, и в нескольких тестовых случаях есть другая переменная, но нет других тестов. Для этого вы можете использовать методы .before_suite, #before_each и #after_each. Пример этого вы можете увидеть в следующем фрагменте кода:


require "spec"


Spec.before_suite do

  ENV["GLOBAL_VAR"] = "foo"

end


describe "My tests" do

  it "parentl" do

    puts "parent test 1: #{ENV["GLOBAL_VAR"]?}

      - #{ENV["SUB_VAR"]?}"

  end


describe "sub tests" do

  before_each do

    ENV["SUB_VAR"] = "bar"

  end


  after_each do

    ENV.delete "SUB_VAR"

  end

  it "child1" do

    puts "child test: #{ENV["GLOBAL_VAR"]?}

      - #{ENV["SUB_VAR"]?}"

  end

end

  it "parent2" do

    puts "parent test 2: #{ENV["GLOBAL_VAR"]?}

      - #{ENV["SUB_VAR"]?}"

  end

end


Этот пример делает именно то, что мы хотим. Метод .before_suite запускается один раз перед запуском любого теста, а методы #before_each и #after_each выполняются до/после каждого тестового примера в текущем контексте, например, определенного блока #describe. Запуск приведет к печати следующего:


parent test 1: foo -

child test: foo - bar

parent test 2: foo -


Важно отметить, что некоторые из этих методов существуют как методы экземпляра, так и методы класса. Версии метода класса будут влиять на все тестовые примеры независимо от того, где они определены, а версии метода экземпляра будут ограничены текущим контекстом.

Другой тип перехвата — методы around_*. Вы можете думать о них как о комбинации методов «до» и «после», но позволяющей точно контролировать, когда и если выполняется тест или группа тестов. Например, мы могли бы упростить внутренний блок #describe из предыдущего примера, заменив хук «до/после» следующим:


around_each do |example|

  ENV["SUB_VAR"] = "bar"

  example.run

  ENV.delete "SUB_VAR"

end


В отличие от других блоков, этот метод возвращает тип Spec::Example, который предоставляет информацию о связанном тестовом примере, например его описание, теги и информацию о том, находится ли он в фокусе. Кроме того, в отличие от других блоков, тестовый пример необходимо выполнять вручную с помощью метода #run. Альтернативно, его можно вообще не выполнить, используя информацию из примера или другие внешние данные для определения этого.

Модульные тесты могут быть хорошим способом проверки конкретных частей приложения, но они не подходят для тестирования взаимодействия между этими частями. Для этого нам нужно будет начать использовать интеграционные/функциональные тесты.

Интеграционное тестирование

Общий процесс написания интеграционных тестов очень похож на модульное тестирование. Используются те же ожидания, может использоваться тот же синтаксис, а общие рекомендации/организационная структура также остаются прежними. Основное различие сводится к тому, что тестируется. Например, в предыдущем разделе мы создали макет, чтобы ограничить объем нашего теста. Однако в интеграционном тесте следует экономно использовать макеты, чтобы полностью протестировать реальную интеграцию ваших типов в приложении.

Моки по-прежнему могут быть полезны в случаях, когда требуется внешняя связь, например, со сторонними клиентами API, когда вы не отправляете реальные запросы к их серверам каждый раз при запуске тестов. Уровень базы данных также можно имитировать, но использование реальной тестовой базы данных может оказаться очень полезным, поскольку она является основной частью приложения.

Распространенной формой интеграционного тестирования является контекст веб-фреймворка. Вы делаете запрос к одной из ваших конечных точек и утверждаете, что получили ожидаемый ответ, либо проверяя тело ответа, либо просто утверждая, что вы получили ожидаемый код состояния. Давайте воспользуемся нашим блог-приложением из Главы 9 «Создание веб-приложения с помощью Athena» и напишем для него несколько интеграционных тестов.

Но прежде чем мы приступим к написанию наших интеграционных тестов, нам следует потратить некоторое время на изучение компонента Spec Athena, поскольку он будет использоваться для создания интеграционных тестов, но при желании его также можно использовать для модульного тестирования.

Компонент Athena Spec предоставляет часто полезные методы тестирования, а также альтернативный предметно-ориентированный язык (DSL) для написания тестов. В отличие от других сегментов тестирования, компонент Spec сводится к стандартным функциям модуля Spec, а не к переписыванию того, как пишутся и выполняются тесты.

Основная цель компонента Spec — обеспечить возможность повторного использования и расширения за счет использования более объектно-ориентированного подхода к программированию (ООП). Например, предположим, что у нас есть тип Calculator с методами #add и #subtract, которые выглядят следующим образом:


struct Calculator

  def add(value1 : Number, value2 : Number) : Number

    value1 + value2

  end


  def substract(value1 : Number, value2 : Number) : Number

    value1 - value2

  end

end


Пример тестового файла с использованием компонента Spec для нашего типа Calculator будет выглядеть следующим образом:


struct CalculatorSpec < ASPEC::TestCase

  @target : Calculator


  def initialize : Nil

    @target = Calculator.new

  end


  def test_add

    @target.add(1, 2).should eq 3

  end


  test "subtract" do

    @target.subtract(10, 5).should eq 5

  end

end


Каждый метод, начинающийся с test_, сводится к методу #it из модуля Spec. Макрос test также можно использовать для упрощения создания этих методов. Поскольку тесты определяются внутри структуры, вы можете использовать наследование и/или композицию, чтобы разрешить повторное использование логики для групп связанных тестов. Это также позволяет проектам предоставлять абстрактные типы, что упрощает создание тестов для определенных типов. Именно такой подход Athena Framework использовала в отношении своего типа ATH::Spec::APITestCase. См. https://athenaframework.org/Framework/Spec/APITestCase/ и https:// athenaframework.org/Spec/TestCase/#Athena::Spec::TestCase для получения дополнительной информации.

Возвращаясь к интеграционным тестам нашего блога, давайте начнем с тестирования контроллера статей, создав для их хранения новый файл: spec/controllers/article_controller_spec.cr. Затем добавьте в него следующий контент:


require "../spec_helper"


struct ArticleControllerTest < ATH::Spec::APITestCase

end


Мы также можем удалить файл spec/blog_spec.cr по умолчанию.

APITestCase предоставляет метод #request, который можно использовать для отправки запросов к нашему API, а также предоставляет вспомогательные методы для распространенных команд протокола передачи гипертекста (HTTP), таких как #get и #post. Он также реализован таким образом, что фактический тип HTTP::Server не требуется. Это позволяет тестировать логику приложения быстрее и надежнее. Однако, как упоминалось в начале этой главы, тестирование E2E также важно для проверки полного взаимодействия системы.

Начнем с тестирования конечной точки для получения конкретной статьи по идентификатору (ID), добавив следующий метод в ArticleControllerTest:


def test_get_article : Nil

  response = self.get "/article/10"

  pp response.status, response.body

end


Прежде чем мы сможем опробовать этот тестовый пример, нам сначала нужно сообщить spec/spec_helper.cr об абстрактном типе тестового примера, а также настроить его для запуска наших тестов на основе компонентов Athena::Spec. Обновите spec/spec_helper.cr, чтобы он выглядел так:


require "spec"

require "../src/blog"


require "athena/spec"


ASPEC.run_all


Помимо модуля Spec и исходного кода нашего блога, нам также требуются помощники по спецификациям, предоставляемые компонентом Framework. Наконец, нам нужно вызвать ASPEC.run_all, чтобы убедиться, что эти типы тестов действительно выполняются. Однако, поскольку компонент Athena Spec не является обязательным, нам необходимо добавить его в качестве зависимости разработки, добавив следующий код в ваш файл shard.yml с последующей установкой шардов:


development_dependencies:

  athena-spec:

    github: athena-framework/spec

      version: ~> 0.2.3


Запуск crystal spec выявил проблему с нашей тестовой установкой. Ответ на запрос полностью зависит от состояния вашей базы данных разработки. Например, если у вас нет созданной/работающей базы данных, вы получите HTTP-ответ 500. Если у вас есть статья с идентификатором 10, вы получите ответ 200, поскольку все работает как положено.

Смешивание данных базы данных разработки с данными тестирования — не лучшая идея, поскольку это усложняет управление и приводит к менее надежным тестам. Чтобы облегчить эту проблему, мы воспользуемся тестовой схемой, созданной еще в Главе 9 «Создание веб-приложения с помощью Athena». Файл настройки языка структурированных запросов (SQL) устанавливает владельцем того же пользователя, что и наша база данных разработки, чтобы мы могли повторно использовать одного и того же пользователя. Поскольку мы также настроили использование переменной среды, нам не нужно менять какой-либо код для поддержки этого. Просто export DATABASE_URL=postgres://blog_user:mYAw3s0meB\!log@ localhost:5432/postgres?currentSchema=test, и все должно работать. Еще одна вещь, которую нам нужно будет сделать, — это создать таблицы, а также создать/удалить данные о приборах. Мы собираемся немного схитрить и использовать для этого необработанный API Crystal DB, поскольку он немного выходит за рамки нашего типа EntityManager.

Как упоминалось ранее в этой главе, для решения этой проблемы мы можем использовать некоторые обратные вызовы модуля Crystal Spec. Давайте начнем с добавления следующего кода в файл spec/spec_helper.cr:


DATABASE = DB.open ENV["DATABASE_URL"]


Spec.before_suite do

  DATABASE.exec File.read "#{__DIR__}/../db/000_setup.sql"

  DATABASE.exec "ALTER DATABASE \"postgres\" SET

    SEARCH_PATH TO \"test\";"

  DATABASE.exec File.read "#{__DIR__}/../db/001_articles.sql"

end


Spec.after_suite do

  DATABASE.exec "ALTER DATABASE \"postgres\"

    SET SEARCH_PATH TO \"public\";"

  DATABASE.close

end


Spec._each do


end


Здесь мы создаем константу для представления пула соединений с нашей базой данных. Затем мы определяем обратный вызов, который запускается один раз перед выполнением любого теста. В рамках этого обратного вызова мы запускаем файлы миграции базы данных, чтобы убедиться в наличии схемы и таблиц перед запуском тестов. Мы также выполняем запрос, чтобы гарантировать, что наши таблицы/запросы будут выполняться в соответствии с нашей тестовой схемой. Наконец, у нас есть еще один обратный вызов, который запускается после выполнения всех тестов, чтобы немного подчистить путем сброса пути поиска обратно к общедоступной схеме и закрытия пула соединений.

Теперь, когда у нас есть таблицы для хранения наших данных, нам нужно выполнить очистку, и мы уже определили, где мы будем это делать. Обновите блок Spec.before_each, чтобы он выглядел следующим образом:


Spec.before_each do

  DATABASE.exec "TRUNCATE TABLE \"articles\" RESTART IDENTITY;"

end


Здесь мы удаляем все статьи, которые могли быть созданы в рамках каждого интеграционного теста. Сделав это здесь, мы можем гарантировать, что наши тесты не будут мешать друг другу.

На этом этапе, если бы мы снова запустили спецификации, мы бы получили ответ об ошибке 404, поскольку мы не делали ничего, связанного с сохранением каких-либо настроек статьи. Давайте сделаем это дальше.

Чтобы сохранить целенаправленность и простоту, мы просто собираемся выполнять вставки необработанного SQL для целей этой главы. Не стесняйтесь определять некоторые абстракции и вспомогательные методы, а также использовать стороннюю библиотеку приборов — или что-то еще — если хотите.

Поскольку мы автоматически очищаем нашу таблицу после каждого тестового примера, мы можем свободно вставлять любые данные, которые требуются для нашего конкретного тестового примера. В нашем случае нам нужно вставить статью с идентификатором 10. Нам также следует сделать некоторые утверждения против ответа, чтобы убедиться, что это то, что мы ожидаем. Обновите наш тест статьи GET, чтобы он выглядел так:


def test_get_article : Nil

  DATABASE.exec <<-SQL

    INSERT INTO "articles" (id, title, body, created_at,

      updated_at) OVERRIDING SYSTEM VALUE

    VALUES (10, 'TITLE', 'BODY', timezone('utc', now()),

      timezone('utc', now()));

  SQL


  response = self.get "/article/10"


  response.status.should eq HTTP::Status::OK


  article = JSON.parse response.body


  article["title"].as_s.should eq "TITLE"

  article["body"].as_s.should eq "BODY"

end


Поскольку в наших таблицах для первичного ключа (PK) используется GENERATED ALWAYS AS IDENTITY, нам необходимо включить OVERRIDING SYSTEM VALUE в наши инструкции INSERT, чтобы мы могли указать нужный идентификатор.

В нашем тесте статьи GET мы утверждаем, что запрос прошел успешно и возвращает ожидаемые данные. Мы также можем протестировать поток языка гипертекстовой разметки (HTML), установив заголовок принятия как часть запроса. Давайте определим для этого еще один тестовый пример:


def test_get_article_html : Nil

  DATABASE.exec <<-SQL

    INSERT INTO "articles" (id, title, body, created_at,

      updated_at) OVERRIDING SYSTEM VALUE

    VALUES (10, 'TITLE', 'BODY', timezone('utc', now()),

      timezone('utc', now()));

  SQL


  response = self.get "/article/10", headers: HTTP::Headers

    {"accept" => "text/html"}


  response.status.should eq HTTP::Status::OK

  response.body.should contain "<p>BODY</p>"

end


Мы также могли бы легко протестировать создание статьи, например:


def test_post_article : Nil

  response = self.post "/article", body: %({"title":"TITLE",

    "body":"BODY"})


  article = JSON.parse response.body

  article["title"].as_s.should eq "TITLE"

  article["body"].as_s.should eq "BODY"

  article["created_at"].as_s?.should_not be_nil

  article["id"].raw.should be_a Int64

end


Независимо от того, как вы это сделаете, в конечном итоге наши интеграционные тесты контроллера статьи оказались довольно простыми и мощными. Они предоставляют средства для тестирования всего потока запроса, включая прослушиватели, преобразователи параметров и обработчики форматов. Он также позволяет тестировать любую пользовательскую логику сериализации или проверки как часть полезной нагрузки запроса/ответа.

Резюме

Тесты — это одна из тех вещей, написание которых может показаться пустой тратой времени, но в конечном итоге окупается в виде выигранного времени за счет предотвращения попадания ошибок в производство. Чем раньше вы получите тестовое покрытие типа, тем лучше.

В этой главе мы узнали, как использовать модуль Spec для написания модульных тестов и компонент Athena::Spec для написания интеграционных тестов. Поскольку это два наиболее распространенных типа тестов, понимание того, как писать хорошие тесты, а также изучение преимуществ того, почему написание тестов является такой хорошей идеей, может оказаться невероятно полезным для обеспечения общей надежности приложения.

В следующей главе мы рассмотрим еще одну вещь, которая не менее важна, чем тесты, — как документировать ваш код/проект.

15. Документирование кода

Независимо от того, насколько хорошо реализован shard, если пользователь не знает, как его использовать, он не сможет использовать его в полной мере или полностью откажется. Хорошо документированный код может быть так же важен, как и хорошо написанный или хорошо протестированный код. Как предлагает https://documentation.divio.com, правильная документация для программного продукта должна охватывать четыре отдельные области:

• Учебники

• Практические руководства

• Пояснения

• Использованная литература


Каждая из этих областей позволяет вам использовать документацию в зависимости от того, что вы хотите сделать — например, хотите решить конкретную проблему или выяснить параметры конкретного метода. Хотя первые три лучше всего реализуются с помощью кода, Crystal поставляется с некоторыми простыми в использовании функциями документирования кода, которые могут сделать создание справочной документации довольно безболезненным.

В этой главе мы рассмотрим следующие темы:

• Документирование кода Crystal.

• Директивы документации

• Создание документации.


После прочтения этой главы вы должны иметь представление об инструментах и функциях, которые можно использовать для документирования вашего кода. В конечном итоге это позволит пользователям шарда быстро приступить к работе и легко научиться его использовать.

Технические требования

Для этой главы вам понадобится работающая установка Crystal.

Инструкции по настройке Crystal см. в Главе 1 «Введение в Crystal».

Все примеры кода для этой главы можно найти в папке Chapter 15 репозитория GitHub этой книги: https://github.com/PacktPublishing/Crystal-Programming/tree/main/Chapter15.

Документирование кода Crystal

Комментарии к коду, добавляемые к типам, методам, макросам и константам, считаются комментариями к документации. Компилятор позволяет нам извлечь документацию для создания веб-сайта HTML и ее представления. Мы вернемся к этому позже в этой главе.

Чтобы комментарий действовал как документация, его необходимо разместить непосредственно над элементом, без пустых строк. Пустые строки допускаются, но перед ними также должен стоять символ #, чтобы цепочка комментариев не прерывалась. Давайте посмотрим на простой пример:


# This comment is not associated with MyClass.


# A summary of what MyClass does.

class MyClass; end


В этом примере есть два комментария: один связан с MyClass, а другой — нет. Первый абзац следует использовать в качестве резюме, определяющего назначение и функциональность элемента. Первый абзац включает в себя весь текст, вплоть до точки или пустой строки комментария, как показано здесь:


# This is the summary

# this is still the summary

#

# This is not the summary.

def foo; end

# This is the summary.

# This is no longer the summary.

def bar; end


Здесь метод #foo имеет многострочное резюме, которое заканчивается пустой новой строкой. С другой стороны, метод #bar использует точку для обозначения конца сводки и начала тела. Crystal генерирует документацию HTML и JSON на основе комментариев к документу. Подробнее о том, как на самом деле генерировать документацию, читайте далее в этой главе, а пока давайте просто посмотрим, как она будет выглядеть:


Краткое описание метода
bar

   Это краткое изложение.

foo

   Это резюме, это все еще резюме

Подробности метода
# def bar

   Это краткое изложение. Это уже не резюме.

# def foo

   Это краткое изложение, это еще не краткое изложение

   Это не резюме.

Рисунок 15.1 - Созданная документация метода


Хотя хорошо написанные резюме и описания могут иметь неоценимое значение, они не изолированы. Обычно метод может принимать/возвращать экземпляры другого типа, или тип может быть тесно связан с другим. В таких случаях возможность связать их вместе может значительно облегчить навигацию по документации.

Привязка функции API

Функцию API можно связать с другой, заключив ее в одинарные обратные кавычки. Давайте посмотрим на пример:


# Creates and returns a default instance of 'MyClass'.

def create : MyClass; end


Эти элементы затем автоматически разрешаются и преобразуются в ссылки при создании документации. Объекты в одном пространстве имен могут быть связаны с относительными именами:

• Мы можем использовать #foo для ссылки на метод экземпляра.

• Мы можем использовать .new для ссылки на метод класса.

• Мы можем использовать MyClass для ссылки на другой тип или константу.

Функции, определенные в других пространствах имен, должны использовать свои полные пути; то есть MyOtherClass#foo, MyOtherClass.new и MyOtherClass::CONST соответственно. Определенные перегрузки также можно связать с помощью полной подписи, например #increment или #increment(by).

Если метод имеет тип возвращаемого значения или параметр имеет ограничение типа, Crystal автоматически свяжет их со связанным типом, если эти типы определены в одном проекте. Типы, определенные в стандартной библиотеке Crystal или во внешних сегментах, по умолчанию не связаны.

Если вы хотите добавить дополнительную документацию к параметру метода, рекомендуется выделить имя параметра курсивом, например:


# Returns of sum of *value1* and *value2*.

def add(value1 : Int32, value : Int32); end


Комментарии к документации поддерживают большинство функций уценки, таких как ограничения кода, упорядоченные/неупорядоченные списки, заголовки, кавычки и многое другое. Давайте посмотрим на них дальше!

Форматирование

Одна из наиболее распространенных функций уценки, которую вы будете использовать при документировании кода, — это ограничения кода. Их можно использовать для подсветки синтаксиса фрагментов кода, которые показывают, как использовать метод или тип, следующим образом:


# ## Example

#

# '''

# value = 2 + 2 => 4

# value # : Int32

# '''

module MyModule; end


Приведенный выше код создает подзаголовок с границей кода. По умолчанию языком ограничения является Crystal, но его можно переопределить, явно пометив язык, который вы хотите использовать, например '''yaml. Также распространенной практикой является использование # => value для обозначения значения чего-либо в блоке кода. # : Type также можно использовать для отображения типа определенного значения.

Другая причина использования синтаксиса значения # => — возможность использования будущих инструментов, которые могли бы запускать пример кода и гарантировать, что выходные данные соответствуют ожидаемым выходным данным, что в конечном итоге приведет к более надежной и надежной документации.

В некоторых случаях вы можете подчеркнуть конкретное предложение, чтобы обозначить, что что-то необходимо исправить, или предупредить читателя о чем-то. Для этой цели можно использовать несколько ключевых слов-предупреждений, например:


# Runs the application.

#

# DEPRECATED: Use '#execute' instead.

def run; end


В предыдущем примере будет создана документация, которая выглядит следующим образом:



Рисунок 15.2 - Пример использования относительно


Ключевое слово предупреждения должно быть первым словом в строке и должно быть написано заглавными буквами. Двоеточие не является обязательным, но рекомендуется для удобства чтения.


Совет
См. https://crystal-lang.org/reference/syntax_and_ semantics/documenting_code.html#admonitionsдля получения полного списка ключевых слов предупреждения.


В предыдущем примере мы использовали предупреждение DEPRECATED для обозначения устаревшего метода. Однако это влияет только на сгенерированную документацию и не поможет пользователям идентифицировать устаревшие методы/типы, если они не просматривают документацию.

В случаях, когда вы хотите полностью объявить устаревшим тип или метод, предлагается использовать устаревшую аннотацию (https://crystal-lang.org/api/Deprecated.html). Эта аннотация добавит для вас предупреждение DEPRECATED, а также предоставит предупреждения компилятора, чтобы конечному пользователю было более очевидно, что является устаревшим.

В дополнение к различным предупреждениям Crystal также включает несколько директив, которые можно использовать в комментариях к документации и влиять на ее создание. Давайте посмотрим на них дальше.

Директивы документации

Crystal также предоставляет несколько директив, которые сообщают генератору документации, как ему следует обращаться с документацией для конкретной функции. К ним относятся следующие:

• :ditto:

• :nodoc:

• :inherit:


Давайте подробнее посмотрим, что они делают.

Ditto

Директиву :ditto: можно использовать для копирования документации из предыдущего определения, например:


# Returns the number of items within this collection.

def size; end


# :ditto:

def length; end


# :ditto:

#

# Some information specific to this method.

def count; end


При создании документации #length будет иметь то же предложение, что и #size. #count также будет содержать это предложение в дополнение к другому предложению, специфичному для этого метода. Это может помочь уменьшить дублирование ряда связанных методов.

Nodoc

Документация создается только для общедоступного API. Это означает, что частные и защищенные функции по умолчанию скрыты. Однако в некоторых случаях тип или метод не могут быть частными, но их все равно не следует рассматривать как часть общедоступного API. Директиву :nodoc: можно использовать, чтобы скрыть общедоступные функции из документации, например:


# :nodoc:

#

# This is an internal method.

def internal_method; end


Эта директива должна находиться в первой строке. Следующие строки по-прежнему могут использоваться для внутренней документации.

Inherit

Наследование меняет способ обработки документации в некоторых контекстах. Например, если метод родительского типа имеет комментарий к документации, он автоматически копируется в дочерний метод, при условии, что дочерний метод имеет ту же сигнатуру и не имеет комментариев к документации. Ниже приведен пример этого:


abstract class Vehicle

   # Returns the name of 'self'.

   abstract def name

end


class Car < Vehicle

   def name

      "car"

   end

end


Здесь документация Car#name будет следующей:


# def name
Описание скопировано из класса Vehicle.

Возвращает имя seif.

Рисунок 16.3 - Поведение наследования документации по умолчанию


Эта функция дает понять, откуда взята документация, но в некоторых случаях вы можете захотеть опустить текст «Описание, скопированное из...». Этого можно добиться, применив директиву :inherit: к дочернему методу, например:


class Truck < Vehicle

   # Some documentation specific to *name*'s usage within 'Truck'.

   #

   # :inherit:

   def name : String

      "truck"

   end

end


В этом случае, поскольку использовалась директива :inherit:, документация по Truck#name будет выглядеть следующим образом:


# def name : String
Некоторая документация, касающаяся использования имени в Truck.

Возвращает имя seif.

Рисунок 15.4 - Поведение наследования документации с помощью :inherit:


Важное замечание
Наследование документации работает только с экземплярами и методами, не являющимися конструкторами.


Эта функция может быть невероятно полезна для уменьшения дублирования при наличии большого количества дочерних типов или реализаций интерфейса.

Хотя вся документация, которую мы написали, важна, она не принесет много пользы, если пользователю нужно будет взглянуть на сам код, чтобы увидеть его. Чтобы сделать его полезным и доступным для пользователей, его необходимо сгенерировать. Давайте научимся, как это сделать.

Создание документации

Подобно команде crystal spec, о которой мы узнали в Главе 14 «Тестирование», существует также команда crystal docs. Наиболее распространенный сценарий генерации кода — в контексте сегмента. В этом случае все, что вам нужно сделать для создания документации, — это запустить crystal docs. Это обработает весь код в src/ и выведет сгенерированный веб-сайт в каталоге docs/ в корне проекта. Отсюда вы можете открыть docs/index.html в своем браузере, чтобы просмотреть созданный файл. Будущие вызовы crystal docs перезапишут предыдущие файлы.

Мы также можем передать этой команде явный список файлов; например, crystal docs one.cr two.cr three.cr. Это создаст документацию для кода внутри всех этих файлов или требуемую для них. Вы можете использовать это для включения внешнего кода в сгенерированную документацию. Например, предположим, что у вас есть проект, который зависит от двух других сегментов в том же пространстве имен. Вы можете передать основной файл точки входа для каждого проекта в crystal docs, в результате чего будет создан веб-сайт, содержащий документацию для всех трех проектов. Это будет выглядеть примерно так: crystal docs lib/project1/src/main.cr lib/project2/src/main.cr src/main.cr. Возможно, потребуется изменить порядок, чтобы он соответствовал требованиям project1 и project2 в src/main.cr.

Предоставление файлов для использования вручную требуется, если вы не используете команду в контексте сегмента, поскольку ни папка src/, ни файл shard.yml не существуют. Файл shard.yml используется для создания документации для определения названия проекта и его версии. Оба из них можно настроить с помощью опций --project-name и --project-version. Первое требуется, если оно не находится в контексте сегмента, а второе по умолчанию будет использовать имя текущей ветки с суффиксом -dev. Если вы не находитесь в контексте репозитория GitHub, его также необходимо указать явно.

Помимо создания HTML, эта команда также создает файл index.json, который представляет документацию в машиночитаемом формате. Это можно использовать для расширения/настройки способа отображения документации; например, https://mkdocstrings.github.io/crystal/index.html. Теперь, когда мы создали документацию, давайте потратим некоторое время на обсуждение того, что с ней делать, чтобы другие могли ее просмотреть. Мы также коснемся того, как управлять версиями документации по мере разработки вашего приложения.

Хостинг документации

Требовать, чтобы каждый пользователь создавал документацию для вашего проекта, далеко не идеально и мешает им просматривать его, что в конечном итоге приводит к меньшему принятию. Лучшим решением было бы разместить заранее созданную версию документации, чтобы пользователи могли легко ее найти и просмотреть.

Сгенерированная документация представляет собой полностью статический HTML, CSS и JavaScript, что позволяет размещать ее так же, как и любой веб-сайт, например, через Apache, Nginx и т. д. Однако для этих вариантов требуется сервер, к которому большинство людей, вероятно, не имеет доступа, исключительно для размещения HTML-документации. Распространенным альтернативным решением является использование https://pages. github.com/. Руководство о том, как это сделать, можно найти в справочном материале Crystal: https://crystal-lang.org/reference/guides/hosting/github.html#hosting-your-docs-on-github-pages.

Управление версиями документации

Документацию, созданную для конкретной версии, больше никогда не нужно трогать. По этой причине в некоторых случаях может быть полезно опубликовать документацию для нескольких версий вашего приложения. Это особенно полезно, когда вы поддерживаете несколько версий вашего приложения, а не только последнюю.

Генератор документов имеет относительно простой встроенный переключатель версий, однако способы его использования не документированы. Суть в том, что при создании документации может быть предоставлен URL-адрес, указывающий на файл JSON, представляющий доступные версии, для включения раскрывающегося списка выбора версии.

Например, файл версий JSON для стандартной библиотеки можно найти по адресу https://Crystal-lang.org/api/versions.json. Содержимое файла представляет собой простой объект JSON с одним массивом версий, где каждый объект в массиве содержит имя версии и путь, по которому можно найти созданную для этой версии документацию.

Используя тот же URL-адрес, что и у файла версий Crystal, команда для создания документации будет выглядеть следующим образом: crystal docs –json-config-url=/api/versions.json.

Несмотря на то, что это касается пользовательского интерфейса, создание файла конфигурации и размещение сгенерированной документации по каждому пути — это не то, что он обрабатывает за вас. В зависимости от ваших требований, этого встроенного способа может быть достаточно. Но использование стороннего решения или чего-то, что вы создадите самостоятельно, также является вариантом, если вам требуются дополнительные функции.

Резюме

Вот и все, что вам нужно знать! Все, что вам нужно знать о том, как наилучшим образом документировать свой код. Типизированный характер Crystal помогает частично облегчить написание документации, поскольку он справляется с основами. Использование markdown для комментариев к коду также помогает приблизить документацию к коду, снижая вероятность его устаревания.

Теперь, когда мы знаем, как написать хорошо спроектированное, протестированное и документированное приложение, пришло время перейти к последнему шагу: его развертыванию! В следующей главе мы узнаем, как следует управлять версиями сегментов, как создать рабочий двоичный файл и как распространять его с помощью Docker.

16. Развертывание кода

Одним из основных преимуществ Crystal является то, что его двоичные файлы могут быть статически связаны. Это означает, что все зависимости программы от времени выполнения включены в сам двоичный файл. Если бы вместо этого двоичный файл был динамически связан, пользователю потребовалось бы установить эти зависимости для использования программы. Аналогично, поскольку он компилируется в один двоичный файл, его распространение значительно упрощается, поскольку не требуется включать исходный код.

В этой главе мы рассмотрим следующие темы:

• Управление версиями вашего сегмента

• Создание рабочих двоичных файлов

• Распространение вашего двоичного файла


К концу этой главы у вас будет портативный и производительный двоичный файл, который можно будет распространять среди конечных пользователей вашего приложения.

Технические требования

Требования к этой главе следующие:

• Рабочая установка Crystal

Пожалуйста, обратитесь к Главе 1 "Введение в Crystal" для получения инструкций по настройке Crystal.

Все примеры кода для этой главы можно найти в папке Chapter16 в репозитории этой книги на GitHub: https://github.com/PacktPublishing/Crystal-Programming/tree/main/Chapter16.

Управление версиями вашего shard

Первое, что вам нужно сделать, прежде чем вы сможете развернуть проект, — это создать новый выпуск. Как вы узнали из Главы 8 «Использование внешних библиотек», настоятельно рекомендуется, чтобы все сегменты Crystal, особенно библиотеки, следовали семантическому управлению версиями (https://semver.org), чтобы сделать зависимости более удобными в обслуживании, обеспечивая воспроизводимые установки и ожидая стабильности.

По этой причине любое несовместимое с обратной совместимостью изменение в общедоступном API должно привести к созданию новой основной версии сегмента. Примером этого может быть переименование метода, удаление метода, изменение имени параметра метода и т. д. Однако код может быть признан устаревшим как часть второстепенной версии с указанием, что он будет изменен/удален в следующей основной версии.

Crystal предоставляет аннотацию https://crystal-lang.org/api/Deprecated.html, которую можно использовать для создания предупреждений об устаревании при применении к методам или типам. В некоторых случаях программе может потребоваться поддержка нескольких основных версий сегмента одновременно. Эту проблему можно решить, проверив версию сегмента во время компиляции, а также некоторую условную логику для генерации правильного кода на основе текущей версии.

Константа VERSION доступна во время компиляции и является хорошим источником информации о текущей версии сегмента. Ниже приведен пример:


module MyShard

   VERSION = "1.5.17"

end


{% if compare_versions(MyShard::VERSION, "2.0.0") >= 0 %}

   puts "greater than or equal to 2.0.0"

{% else %}

   puts "less than 2.0.0"

{% end %}


Если требуется несколько диапазонов версий, можно добавить дополнительные ветки.

Релиз — это не что иное, как тег Git для конкретного коммита. Как создать релиз, зависит от того, какой хост вы используете. Инструкции о том, как это сделать для вашего конкретного хоста, см. по следующим ссылкам:

• https://docs.github.com/en/repositories/releasing-projects-on-github/managing-releases-in-a-repository#creating-a-release

• https://docs.gitlab.com/ee/user/project/releases/#create-a-release


Важная заметка
Тег выпуска должен начинаться с буквы v — например, v1.4.7, а не 1.4.7.


Прежде чем создавать выпуск, вам следует убедиться, что вы обновили все ссылки на версию в исходных файлах, например, в shard.yml или любых константах VERSION.

Если проект представляет собой библиотеку, то это все, что нужно. Другие приложения смогут использовать новую версию, shards install или shards update, в зависимости от того, является ли это новой или существующей зависимостью. Если проект представляет собой приложение, необходимо выполнить еще несколько шагов, чтобы пользователи могли загружать готовые двоичные файлы для его использования.

Создание производственных двоичных файлов

Хотя это было предсказано в Главе 6 «Параллелизм», в основном мы собирали двоичные файлы с помощью команды crystal build file.cr и ее эквивалента. Эти команды подходят для разработки, но они не создают полностью оптимизированный двоичный файл для производственной рабочей нагрузки/среды, подходящий для распространения.

Чтобы создать двоичный файл выпуска, нам нужно передать флаг --release. Это сообщит бэкэнду LLVM, что он должен применить к коду все возможные оптимизации. Другой вариант, который мы можем передать, — это --no-debug. Это заставит компилятор Crystal не включать символы отладки, в результате чего двоичный файл будет меньшего размера. Остальные символы можно удалить с помощью команды strip. Дополнительную информацию см. на https://man7.org/linux/man-pages/man1/strip.1.html.

После сборки с использованием этих двух вариантов вы получите меньший по размеру и более производительный двоичный файл, который будет пригоден для тестирования или использования в производственной среде. Однако он не будет переносимым, а это означает, что для него по-прежнему потребуется, чтобы у пользователя были установлены все среды выполнения Crystal и системные зависимости для конкретных приложений. Чтобы создать более портативный двоичный файл, нам нужно будет статически связать его.

Статическое связывание так же просто, как добавление параметра --static, но с одной особенностью. Загвоздка в том, что не все зависимости хорошо работают со статическим связыванием, причем главным нарушителем является libc, учитывая, что от него зависит Crystal. Вместо этого можно использовать musl-libc, который имеет лучшую поддержку статического связывания. Хотя это и не единственный способ, рекомендуемый способ создания статического двоичного файла — использовать Alpine Linux. Предоставляются официальные образы Crystal Docker на основе Alpine, которые можно использовать для упрощения этого процесса.

Для этого требуется, чтобы собственные зависимости приложения имели статические версии, доступные в базовом образе. Флаг --static также не гарантирует на 100%, что полученный двоичный файл будет полностью статически связан. В некоторых случаях статическое связывание может быть менее идеальным, чем динамическое связывание.

Например, если в зависимости обнаружена и исправлена критическая ошибка, двоичный файл необходимо будет перекомпилировать/выпустить с использованием новой версии этого пакета. Если бы он был динамически связан, пользователь мог бы просто обновить пакет, и он начал бы использовать новую версию.

Статическая компоновка также увеличивает размер двоичного файла, поскольку он должен включать код всех его зависимостей. В конце концов, стоило бы подумать, какой подход вам следует использовать в зависимости от требований распространяемой вами программы.

Пример команды для этого будет выглядеть так:


docker run --rm -it -v $PWD:/workspace -w /workspace crystallang/crystal:latest-alpine crystal build app.cr --static --release --no-debug


При этом контейнер запускается с использованием последнего образа Crystal Alpine, монтируется в него текущий каталог, создается статический рабочий двоичный файл, а затем происходит выход и удаление контейнера.

Мы можем обеспечить статическую компоновку полученного двоичного файла с помощью команды ldd, доступной в Linux. Пользователи macOS могут использовать otool -L. Передача этой команды с именем нашего двоичного файла вернет все общие объекты, которые он использует, или статически связанные, если их нет. Эту команду можно использовать для проверки новых двоичных файлов, чтобы предотвратить любые неожиданности в дальнейшем, когда вы запустите их в другой среде.

Теперь, когда у нас есть портативный, готовый к использованию двоичный файл, нам нужен способ его распространения, чтобы пользователи могли легко его установить и использовать. Однако если ваше приложение предназначено для внутреннего использования и его не нужно распространять среди конечных пользователей, все, что вам нужно сделать на этом этапе, — это развернуть двоичный файл и запустить его. Существует множество способов сделать это, в зависимости от вашего варианта использования, но на высоком уровне все сводится к копированию/перемещению двоичного файла туда, где он должен находиться, и его запуску.

Распространение вашего бинарного файла

Простейшей формой распространения было бы добавление двоичного файла, который мы создали в предыдущем разделе, к ресурсам выпуска. Это позволит любому загрузить и запустить его, при условии, что для его комбинации ОС/архитектуры существует двоичный файл. Бинарный файл, который мы создали в предыдущем разделе, будет работать на любом компьютере, использующем ту же базовую ОС и архитектуру, на которой он был скомпилирован — в данном случае x86_64 Linux. Для других архитектур ЦП/ОС, таких как macOS и Windows, потребуются специальные двоичные файлы.

Через Docker

Другой распространенный способ распространения двоичного файла — включение его в образ Docker, который затем можно использовать напрямую. Портативность Crystal упрощает создание таких изображений. Мы также можем использовать многоэтапные сборки для создания двоичного файла в образе, содержащем все необходимые зависимости, а затем извлечь его в более минимальный образ для распространения. Результирующий Dockerfile для этого процесса может выглядеть так:


FROM crystallang/crystal:latest-alpine as builder


WORKDIR /app


COPY ./shard.yml ./shard.lock ./

RUN shards install –production


COPY . ./

RUN shards build --static --no-debug --release –production


FROM alpine:latest

WORKDIR /

COPY --from=builder /app/bin/greeter .


ENTRYPOINT ["/greeter"]


Во-первых, мы должны использовать базовый образ Crystal Alpine в качестве основы с псевдонимом builder (подробнее об этом позже). Затем мы должны установить наш WORKDIR, который представляет, на чем будут основываться будущие команды каталога. Далее нам необходимо скопировать shard.yml и shard.lock-файлы для установки любых осколков, не зависящих от разработки. Мы делаем это как отдельные шаги, чтобы они рассматривались как разные слои изображения. Это повышает производительность, поскольку эти шаги будут повторяться только в том случае, если что-то изменится в одном из этих файлов, например, при добавлении или редактировании зависимости.

Наконец, в качестве последней команды на этом этапе сборки мы создаем статический двоичный файл выпуска, который в конечном итоге будет создан в /app/bin, поскольку это расположение вывода по умолчанию. Теперь, когда этот шаг завершен, мы можем перейти ко второму этапу сборки.

Начало второго этапа сборки начинается с использования в качестве базовой последней версии Alpine. Поскольку двоичный файл является статическим, мы могли бы использовать царапину в качестве основы. Тем не менее, мне нравится использовать Alpine, поскольку он уже имеет довольно минимальный размер, но также предоставляет вам менеджер пакетов на случай, если вам все еще понадобится какое-то подмножество зависимостей, что в большинстве случаев вам понадобится.

Здесь мы должны снова установить наш WORKDIR и скопировать в него двоичный файл. Команда COPY имеет параметр --from, который позволяет указать, какой этап сборки следует использовать в качестве источника. В этом случае мы можем ссылаться на псевдоним builder, который мы определили на первом этапе. Наконец, мы должны установить точку входа изображения в наш двоичный файл, чтобы любые аргументы, передаваемые в изображение, пересылались в сам двоичный файл внутри контейнера.

Теперь, когда мы определили наш Dockerfile, нам нужно создать с его помощью образ. Мы можем сделать это, запустив docker build -t greeter .. Это создаст изображение с тегом Greeter, которое мы затем сможем запустить с помощью docker run --rm greeter --shout George. Поскольку мы определили точку входа изображения в двоичный файл, это будет идентично запуску ./greeter --shout George с локальной копией двоичного файла.

Опция --rm удалит контейнер после его выхода, что полезно при однократных вызовах, чтобы они не накапливались.

Также возможно извлечь двоичный файл из контейнера. Но прежде чем мы сможем это сделать, нам нужно получить идентификатор контейнера. Вы можете просмотреть существующие контейнеры с помощью команды docker ps -a. Если вы запустите наш образ без флага --rm, вы увидите вышедший из этого вызова контейнер. Если у вас в настоящее время нет существующего контейнера, его можно создать с помощью команды docker create greetinger, которая возвращает идентификатор контейнера, который мы можем использовать на следующем шаге.

Docker также предоставляет команду cp, которую можно использовать для извлечения файла из контейнера. Например, чтобы извлечь двоичный файл greeter в текущую папку, используйте команду docker cp abc123:/greeter ./, где вам следует заменить abc123 идентификатором контейнера, из которого следует извлечь файл.

Даже если ваш проект предназначен для внутреннего использования, Docker все равно может быть хорошим инструментом для организации развертываний, поскольку каждая версия проекта существует в своем собственном образе. Это позволяет различным инструментам, таким как Kubernetes, легко масштабировать и развертывать после их настройки.

Через менеджер(ы) пакетов

Другой способ распространения двоичного файла — добавить его в выбранные вами менеджеры пакетов. Хотя описание того, как это сделать, немного выходит за рамки этой книги, стоит упомянуть, что это может значительно улучшить пользовательский опыт (UX), поскольку пользователь может устанавливать/обновлять ваш проект точно так же, как они делают остальные пакеты. Некоторые распространенные менеджеры пакетов, которые можно использовать, включают следующее:

• Snap

• macOS's Homebrew

• Arch Linux's AUR


В конечном счете, это необязательный шаг. Предоставления предварительно собранного двоичного файла и инструкций по сборке из исходного кода, скорее всего, будет достаточно для начала.

Резюме

Благодаря единственному двоичному файлу и переносимости двоичных файлов Crystal развертывание приложения, по сути, так же просто, как копирование двоичного файла куда-либо и его запуск. Нет необходимости включать исходный код или исключать непроизводственные файлы в процесс сборки, поскольку все это делается за вас, когда используются правильные параметры.

Однако, несмотря на то, что этот процесс относительно прост, в сочетании с выполнением тестов и созданием документации, требуется выполнить довольно много шагов, которые через некоторое время могут стать утомительными для выполнения вручную каждый раз, когда новая версия готова к выпуску. В следующей и заключительной главе мы рассмотрим, как автоматизировать некоторые из этих процессов.

Дальнейшее чтение

Существует гораздо больше материалов, связанных с развертыванием проектов, чем мы можем охватить в этой главе. Ознакомьтесь со следующими ссылками для получения дополнительной информации по темам, которые мы рассмотрели:

• https://crystal-lang.org/reference/guides/static_linking. html

• https://docs.docker.com/develop/develop-images/baseimages

• montreal.html

17. Автоматизация

Поздравляем с тем, что вы зашли так далеко! Мы рассмотрели многое, но, увы, дошли до последней главы. В нескольких предыдущих главах мы рассмотрели, как превратить работающий проект в полностью пригодный для использования и простой в обслуживании путем написания тестов, документирования того, как он работает, и распространения его среди конечных пользователей. Однако можно легко забыть выполнить один или несколько из этих шагов, что сведет на нет всю цель. В этой главе мы собираемся изучить, как автоматизировать эти процессы, а также несколько новых, чтобы вам вообще о них не приходилось думать! При этом мы собираемся охватить следующие темы:

• Код форматирования.

• Линтинг-код

• Непрерывная интеграция с GitHub Actions.

Технические требования

Требования к этой главе следующие:

• Рабочая установка Crystal.

• Специальный репозиторий GitHub.

Вы можете обратиться к Главе 1 «Введение в Crystal» для получения инструкций по настройке Crystal, а также к https://docs.github.com/en/get-started/quickstart/create-a-repo для настройки вашего репозитория.

Все примеры кода, использованные в этой главе, можно найти в папке Chapter17 на GitHub: https://github.com/PacktPublishing/Crystal-Programming/tree/main/Chapter17.

Форматирование кода

Некоторые из самых горячих споров в программировании могут возникать из-за самых незначительных вещей, например, следует ли использовать табуляции или пробелы для отступов или сколько каждого из них. Crystal пытается предотвратить возникновение подобных сценариев, предоставляя стандартизированный, осуществимый стиль кода, который следует использовать в каждом проекте.

Вот некоторые примеры того, что делает форматтер:

• Удаляет лишние пробелы в конце строк.

• Отменить экранирование символов, которые не нужно экранировать, например F\oo и Foo.

• При необходимости добавляет/удаляет отступы, включая замену ; с символами новой строки в некоторых случаях.


Хотя не все могут согласиться со всем, что делает форматтер, в этом-то и суть. Он предназначен для обеспечения стандарта, а не для настройки с целью исключения выбора из уравнения. Однако это не означает, что нет областей, которые можно улучшить, или случаев неправильного форматирования.

Этот стиль кода обеспечивается командой Crystal, очень похожей на команды spec, run или build, которые мы использовали в предыдущих главах. Самый простой способ использовать форматтер — run crystal tool format в вашей кодовой базе. При этом будет просмотрен каждый исходный файл и отформатирован его в соответствии со стандартом Crystal. Некоторые IDE даже поддерживают форматтер и запускают его автоматически при сохранении. См. Приложение A «Настройка инструментов» для получения более подробной информации о том, как это настроить.

Однако бывают случаи, когда вы можете не захотеть автоматически переформатировать код, а просто определить, является ли он допустимым. В этом случае вы можете передать опцию --check, которая заставит команду возвращать ненулевой код выхода, если в код были внесены какие-либо изменения. Это может быть полезно в сценариях/рабочих процессах автоматизации, которые используют коды завершения, чтобы определить, была ли команда успешной.

Помимо проверки правильности форматирования кода, неплохо было бы также линстовать его. Линтинг позволит выявить любые запахи кода или идиоматические проблемы, которые необходимо решить. Давайте посмотрим на это дальше!

Линтинг-код

Статический анализ — это анализ исходного кода программы с целью выявления проблем в коде без необходимости фактического выполнения программы. Этот процесс в основном используется для обнаружения проблем безопасности, стилистических или неидиоматических проблем с кодом.

Эти инструменты статического анализа не являются чем-то новым для языков программирования. Однако типизированная природа Crystal позволяет справиться с большей частью того, с чем может справиться внешний инструмент статического анализа, не требуя ничего, кроме самого компилятора. Хотя компилятор будет обнаруживать ошибки, связанные с типом, он не будет обнаруживать более идиоматические проблемы, такие как запах кода или использование неоптимальных методов.

В Crystal доступен инструмент статического анализа https://github.com/crystal-ameba/ameba. Этот инструмент обычно устанавливается как зависимость разработки, добавляя его в файл shard.yml и затем запуская shards install:


development_dependencies:

   ameba:

      github: crystal-ameba/ameba

version: ~> 1.0


После установки Ameba создаст и выведет себя в папку bin/ вашего проекта, которую затем можно будет запустить через ./bin/ameba. При выполнении Ameba просмотрит все ваши файлы Crystal, проверяя на наличие проблем. Давайте создадим тестовый файл, чтобы продемонстрировать, как это работает:

1. Создайте новый каталог и новый файл shard.yml в нем. Самый простой способ сделать это — run shards init, который создаст файл за вас.

2. Затем добавьте Ameba в качестве зависимости разработки и run shards install.

3. Наконец, создайте в этой папке еще один файл со следующим содержимым:


[1, 2, 3].each_with_index do |idx, v|

   PP v

end


def foo

   return "foo"

end


4. Затем мы можем запустить Ameba и увидеть примерно следующий результат:


Inspecting 2 files


F.


test.cr:1:31


[W] Lint/UnusedArgument: Unused argument 'idx'. If it's necessary, use '_' as an argument name to indicate that it won't be used.

> [1, 2, 3].each_with_index do |idx, v|

                                                                                              ^


test.cr:6:3

[C] Style/RedundantReturn: Redundant 'return' detected

> return "foo"

  ^----------^

Finished in 2.88 milliseconds

2 inspected, 2 failure


Ameba проверила наш тестовый файл и, хотя сам код валидный, обнаружила некоторые ошибки. Эти ошибки не относятся к тому типу вещей, которые могут помешать выполнению кода, а в большей степени связаны с его общей ремонтопригодностью и читабельностью. Вывод Ameba отображает каждую ошибку, включая ее тип, в каком файле/строке/столбце находится ошибка и к какой категории она относится.

Подобно проверке формата, Ameba также вернет ненулевой код выхода, если обнаружена хотя бы одна ошибка. С другой стороны, Ameba должна быть более настраиваемой, чем форматтер. Например, вы можете настроить ограничения по умолчанию, отключить/включить определенные правила или подавить ошибки в самом коде.

Теперь, когда мы знаем, как обеспечить правильное форматирование нашего кода и отсутствие проблем с его качеством, мы можем перейти к автоматизации всех этих процессов.

Непрерывная интеграция с GitHub Actions

Непрерывная интеграция предполагает автоматизацию рабочих процессов, которые происходят централизованно и обеспечивают различные аспекты написания кода. Что именно он делает, зависит от вас, но наиболее распространенным вариантом использования является сборка, тестирование и анализ кода по мере внесения изменений. Этот процесс обеспечивает автоматизированный способ гарантировать, что в репозиторий вашего проекта будет добавлен только действительный код.

Для этого можно использовать множество провайдеров; однако, учитывая, что GitHub является наиболее вероятным местом размещения вашего проекта, и поскольку у него уже есть хорошие инструменты для Crystal, мы собираемся использовать GitHub Actions для наших нужд непрерывной интеграции.

Прежде чем мы приступим к настройке наших рабочих процессов, мы должны сначала подумать обо всем, что мы от них хотим. Основываясь на том, что мы сделали в последних нескольких главах, я составил следующий список:

1. Убедитесь, что код отформатирован правильно.

2. Убедитесь, что стандарты кодирования соответствуют коду через Ameba.

3. Убедитесь, что наши тесты пройдены.

4. Развертывайте документацию при выпуске новой версии.


Что касается шага 3, есть несколько дополнительных улучшений, которые мы могли бы сделать, чтобы улучшить его, например, запуск на разных платформах или тестирование ночной сборки Crystal, последняя из которых может быть отличным способом получения предупреждений о предстоящих критических изменениях или регрессии, которые, возможно, потребуется исправить или сообщить о них, что в конечном итоге приводит к гораздо более стабильному коду, поскольку вы не пытаетесь исправить проблему в день выхода нового выпуска Crystal.

Работа на нескольких платформах также может быть хорошим способом обнаружить проблемы до того, как они попадут в производство. Однако в зависимости от того, что делает ваше приложение, это может не понадобиться. Например, если вы пишете веб-приложение, которое будет работать только на сервере Linux, нет смысла также тестировать его на macOS. С другой стороны, если вы создаете проект на основе CLI, который будет распространяться на различные платформы, то тестирование на каждой поддерживаемой платформе является хорошей идеей.

В связи с тем, что существует множество различных поставщиков, которые мы могли бы использовать, существует также множество способов настроить каждый рабочий процесс, который в конечном итоге выполняет одно и то же. Рабочие процессы, описанные в этой главе, — это то, что, по моему мнению, лучше всего соответствует моим потребностям/желаниям. Не стесняйтесь настраивать их по мере необходимости, чтобы они наилучшим образом соответствовали вашим потребностям.

Форматирование, стандарты кодирования и тесты

Для начала давайте сначала создадим наш файл рабочего процесса. GitHub ожидает определенную структуру каталогов, поэтому обязательно следуйте инструкциям. Вы можете либо создать новый сегмент для тестирования, либо добавить его в существующий проект:

1. Создайте папку .github в корне вашего проекта, например, на том же уровне, что и shard.yml.

2. В этой папке создайте еще одну папку под названием workflows.

3. Наконец, создайте файл с именем ci.yml. Файл можно было бы назвать как угодно, но, учитывая, что он будет содержать все наши задания непрерывной интеграции, ci было похоже на хороший выбор.

Затем вы можете добавить в файл ci.yml следующее содержимое:


name: CI


on:

  pull_request:

    branches:

      - 'master'

  schedule:

    - cron: '37 0 * * *' # Nightly at 00:37


jobs:


Каждый файл рабочего процесса должен определять свое имя и причину его запуска. В этом примере я назвал рабочий процесс CI и настроил его на запуск каждый раз, когда в главную ветку поступает запрос на включение. Он также будет работать ежедневно в 37 минут после полуночи. В GitHub Actions рабочий процесс (workflow) представляет собой набор связанных заданий, где задание (job) — это набор шагов, которые будут выполняться для достижения определенной цели. Как видите, мы удалили карту вакансий, в которой будут определены все наши вакансии.


В демонстрационных целях мы собираемся провести наши тесты как для последних, так и для ночных выпусков Crystal, а также запустить их как на Linux, так и на macOS. Как упоминалось ранее, не стесняйтесь настраивать платформы по своему усмотрению. GitHub Actions поддерживает концепцию, называемую матрицами, которая позволяет нам определить одно задание, которое будет создавать дополнительные задания для каждой комбинации. Мы доберемся до этого в ближайшее время. Во-первых, давайте сосредоточимся на двух более простых задачах — стандартах форматирования и кодирования.

Обновите карту вакансий нашего файла ci.yml, чтобы она выглядела следующим образом:


jobs:

  check_format:

    runs-on: ubuntu-latest

    steps:

      - uses: actions/checkout@v2

      - name: Install Crystal

      uses: crystal-lang/install-crystal@v1

      - name: Check Format

      run: crystal tool format --check

  coding_standards:

    runs-on: ubuntu-latest

    steps:

      - uses: actions/checkout@v2

      - name: Install Crystal

      uses: crystal-lang/install-crystal@v1

      - name: Install Dependencies

      run: shards install

      - name: Ameba

      run: ./bin/ameba


На высоком уровне эти профессии очень похожи. Мы настроили их для работы в последней версии Ubuntu, используя последний Crystal Alpine Docker image. Конечно, шаги для каждого из них немного отличаются, но оба они начинаются с проверки кода вашего проекта.

Для проверки форматирования можно просто запустить run crystal tool format --check. Если он отформатирован неправильно, он вернет ненулевой код выхода, как мы узнали недавно, что приведет к сбою задания. Задание по стандартам кодирования начинается так же, но также будет выполняться run shards install для установки Ameba. Наконец, он запускает Ameba, которая также вернет ненулевой код выхода в случае сбоя. Далее давайте перейдем к заданию, которое будет запускать наши тесты.

Добавьте следующий код в карту заданий:


test:

  strategy:

    fail-fast: false

    matrix:

      os:

        - ubuntu-latest

        - macos-latest

      crystal:

        - latest

        - nightly

    runs-on: ${{ matrix.os }}

    steps:

      - uses: actions/checkout@v2

      - name: Install Crystal

      uses: crystal-lang/install-crystal@v1

      with:

        crystal: ${{ matrix.crystal }}

      - name: Install Dependencies

      run: shards install

      - name: Specs

      run: crystal spec --order=random --error-on- warnings


Эта работа немного сложнее двух последних. Давайте сломаем это!

В этом задании представлено отображение стратегию (strategy), которое включает данные, описывающие, как должно выполняться задание. Две основные функции, которые мы используем, включают отказоустойчивость (fail-fast) и матрицу (matrix). Первый вариант делает так, что если одно из заданий, созданных с помощью матрицы, потерпит неудачу, оно не потерпит крах все из них. Мы хотим, чтобы это значение было false, чтобы, например, ночной сбой Crystal на определенной платформе не приводил к сбою всех остальных заданий.

Как упоминалось ранее, отображение матрицы, как следует из названия, позволяет определить матрицу, которая будет создавать задание для каждой комбинации значений матрицы. В итоге наша матрица определит четыре вакансии:

• Последняя версия Crystal для Ubuntu

• Crystal Nightly в Ubuntu

• Последняя версия Crystal для macOS.

• Crystal Nightly на macOS.


Дополнительные части конфигурации задания шаблонизированы для использования значений из матрицы, например для указания того, на чем выполняется задание и какую версию Crystal установить. Мы также используем https://github.com/crystal-lang/install-crystal для установки Crystal, который работает кросс-платформенно.

Затем мы запускаем shards install, чтобы установить любые зависимости. Если ваш проект не имеет каких-либо зависимостей, смело удаляйте этот шаг. Наконец, мы запускаем спецификации в случайном порядке, а также выдаем ошибку при обнаружении каких-либо предупреждений от любых зависимостей, включая сам Crystal. Основная причина этого — выявить будущие недостатки ночной работы Crystal, чтобы их можно было устранить.

Отсюда вы можете рассмотреть возможность добавления некоторых правил защиты ветвей, например, http://docs.github.com/en/rePositories/configuring-branches-and-merges-in-your-repository/defining-the-mergeability-of-phref="http://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/defining-the-mergeability-of-pull-requests/about-protected-branches#require-status-checks-before-merging" rel="nofollow noopener noreferrer">ull-requests/about-protected-branches#require-status-checks-before-merging, чтобы потребовать прохождения определенных проверок перед объединением запроса на включение.

Теперь, когда мы применяем стандарты форматирования, кодирования и тесты, мы можем перейти к развертыванию нашей документации.

Развертывание документации

Существует множество различных способов, которыми мы могли бы использовать наши развертывания документации, как с точки зрения того, какие функции мы хотим поддерживать, где они будут размещены, так и с точки зрения того, как должна быть создана документация. Например, вы можете захотеть поддерживать отображение документации для каждой версии вашего приложения, или вы можете захотеть самостоятельно разместить ее, или вам может потребоваться включить документацию из других сегментов.

В примере, который мы собираемся рассмотреть, я размещу документацию на веб-сайте https://pages.github.com, только с последней версией, без каких-либо внешних зависимостей. Таким образом, вам нужно будет обязательно настроить GitHub Pages для вашего репозитория.


Совет
см. https://docs.github.com/en/pages/quickstart для получения дополнительной информации о том, как это настроить.


Теперь, когда с этим покончено, мы можем перейти к настройке рабочего процесса! Поскольку развертывание документации — это то, что должно произойти только после публикации новой версии, мы собираемся создать для этого специальный рабочий процесс. Начните с создания файла deployment.yml в папке workflows. В этот файл можно добавить следующее содержимое:


name: Deployment


on:

  release:

    types:

      - created


jobs:

  deploy_docs:

    runs-on: ubuntu-latest

    steps:

      - uses: actions/checkout@v2

      - name: Install Crystal

      uses: crystal-lang/install-crystal@v1

      - name: Build

      run: crystal docs

      - name: Deploy

      uses: JamesIves/github-pages-deploy-action@4.1.5

      with:

        branch: gh-pages

        folder: docs

        single-commit: true


Начав так же, как и раньше, мы даем имя этому рабочему процессу и определяем, когда он должен запускаться. Поскольку ваша документация является общедоступной, вы не захотите, чтобы она обновлялась с возможными критическими изменениями каждый раз, когда что-то объединяется. Вместо этого мы настраиваем этот рабочий процесс для запуска при создании новой версии, чтобы документация всегда соответствовала последней версии. стабильный релиз проекта.

Поэтапно мы проверяем код, устанавливаем Crystal, создаем документацию, запуская crystal docs, и, наконец, загружаем документацию на GitHub Pages.

Мы используем внешнее действие для развертывания документации. Есть немало других действий, которые поддерживают это, или вы также можете сделать это вручную, но я обнаружил, что это работает довольно хорошо и его легко настроить. Вы можете проверить https://github.com/JamesIves/github-pages-deploy-action для получения дополнительной информации об этом действии.

Мы предоставляем несколько вариантов конфигурации для действия. Первые два являются обязательными и указывают, в какую ветку нашего репозитория следует загрузить документацию, а второй представляет источник документации для загрузки. В качестве названия ветки вы можете выбрать все, что захотите. Я просто назвал его gh-pages, чтобы было понятно, для чего он используется.

Кроме того, поскольку Crystal Docs выводит результаты в папку docs/, я указал ее в качестве исходной папки. Я также устанавливаю для опции single-commit значение true. По сути, это сбрасывает историю нашей ветки, так что в этой ветке всегда остается только один коммит. В нашем случае это нормально, поскольку при необходимости документацию можно легко восстановить, поэтому нет необходимости хранить эту историю.

На этом этапе определены все наши рабочие процессы. Рабочий процесс CI гарантирует, что код, поступающий в проект, действителен и работает должным образом, а рабочий процесс развертывания развернет нашу документацию на страницах GitHub при создании нового выпуска. Как только это произойдет, вы сможете перейти к URL-адресу страниц вашего репозитория, чтобы увидеть результаты.

Вы также можете добавить дополнительные элементы в рабочий процесс развертывания, например автоматическое создание/публикацию двоичных файлов выпуска.

Резюме

И вот оно! Непрерывная интеграция может быть отличным способом более легкого управления вкладами, поскольку у вас есть автоматизированный способ, который может обеспечить соблюдение ваших стандартов и упростить отладку/уведомление о любых возникающих проблемах. Это также может помочь автоматизировать процесс развертывания. Он также настраиваемый и достаточно гибкий, чтобы справиться практически с любым вариантом использования.

Еще раз поздравляю с завершением книги! В различных областях Crystal было много контента, который, мы надеемся, предоставил некоторую полезную информацию, которую можно будет использовать в ваших будущих проектах или, еще лучше, послужить справочной информацией по некоторым более сложным темам.

Приложение A. Настройка инструмента

Компилятор Crystal отвечает за анализ кода Crystal и создание исполняемых файлов для отладки и выпуска. Обычный процесс написания кода, а затем использование компилятора для сборки и запуска вашего приложения может быть полностью выполнен с использованием интерфейса командной строки, но он быстро становится утомительным.

Это приложение научит вас настраивать и использовать Crystal из Visual Studio Code со стандартными функциями IDE, такими как подсветка синтаксиса, завершение кода, наведение курсора на символы для получения дополнительной информации, изучение классов и методов, определенных в файле, сборка проекта и запустить его. Если вы используете другие редакторы кода, инструкции должны быть аналогичными.

Установка компилятора Crystal

Первый шаг — убедиться, что компилятор Crystal установлен правильно. Попробуйте запустить команду crystal --version со своего терминала. Вы можете перейти к следующему разделу, если он успешно показывает версию компилятора и целевую архитектуру.

Перейдите на https://crystal-lang.org/install и проверьте точные инструкции для вашей операционной системы. В macOS Crystal доступен на сайте Homebrew. В большинстве дистрибутивов Linux Crystal доступен из репозитория. Crystal также доступен для систем BSD.

Установка компилятора в Windows

В Windows компилятор Crystal все еще находится в экспериментальной стадии (начиная с Crystal 1.4.0). Итак, вы должны включить подсистему Windows для Linux (WSL) и использовать дистрибутив Linux внутри Windows.

Если вы еще не использовали WSL, включить его очень просто. Вам потребуется либо Windows 10, либо Windows 11. Откройте Windows PowerShell, выберите «Запуск от имени администратора» и выполните команду wsl --install.


Рисунок 18.1 - Запуск PowerShell от имени администратора


По умолчанию он будет использовать WSL2 с Ubuntu, как показано на следующем снимке экрана. Это хороший вариант по умолчанию, если вы раньше не использовали Linux:


Рисунок 18.2 - Включение WSL


После выполнения этих шагов приступайте к установке Crystal внутри WSL, используя инструкции Ubuntu с официального сайта, как упоминалось ранее.

Установка кода Visual Studio

Если у вас нет Visual Studio Code, вы можете установить его с официального сайта https://code.visualstudio.com/. Это популярный, бесплатный и мощный редактор кода.

Если вы используете Windows и WSL, то установите расширение Remote — WSL. Это позволит Visual Studio Code подключаться к WSL.


Рисунок 18.3 - Установка расширения Remote – WSL


После установки этого расширения вы увидите небольшой зеленый значок в левом нижнем углу экрана. Используйте его, чтобы открыть окно WSL.


Рисунок 18.4 - Использование расширения редактора


Найдите и установите расширение Crystal Language с помощью языковых инструментов Crystal.


Рисунок 18.5 - Установка расширения Crystal Language


Он предоставит вам подсветку синтаксиса, форматирование кода и структуру проекта.

Рисунок 18.6 – Включение языкового сервера Crystalline


Чтобы раскрыть весь потенциал расширения, ему также необходим языковой сервер. Мы рекомендуем использовать для этого Crystalline. Это позволит завершить код, сообщить об ошибках, перейти к определению и получить информацию о символе при наведении курсора мыши.

Инструкции по установке можно найти по адресу https://github.com/elbywan/crystal#pre-built-binaries. По ссылке показана команда для загрузки и установки на macOS и Linux. Если вы используете Windows, следуйте инструкциям Linux внутри WSL.

Чтобы включить некоторые дополнительные функции, перейдите в настройки кода Visual Studio (Файл | Настройки | Settings) и найдите Crystal. Вы можете включить больше или меньше функций, но имейте в виду, что анализ кода Crystal не является легким процессом и может быть медленным для более крупных проектов в зависимости от вашего компьютера:

1. Первые параметры включают завершение кода, наведение курсора мыши и функцию перехода к определению; включите их.


Рисунок 18.7 - Дополнительные функции расширения


2. Далее вы можете выбрать, о каких проблемах будет сообщаться. Это полезно, поскольку позволяет вам обнаружить ошибки перед попыткой запуска кода. Параметр синтаксиса используется по умолчанию и проверяет наиболее распространенные ошибки. Вы также можете использовать сборку для проверки всех ошибок времени компиляции (более дорого) или ни одной, чтобы полностью отключить эту функцию.


Рисунок 18.8 - Уровень обнаружения проблем


3. Наконец, вы можете дополнительно настроить языковой сервер. Это позволит более полный анализ завершения кода и информации о символах на основе предполагаемого типа переменных. Здесь добавьте путь к исполняемому файлу Crystalline, который вы установили ранее. Имейте в виду, что языковой сервер является экспериментальным и может не предоставлять точную информацию во всех случаях.


Рисунок 18.9 - Настройка языкового сервера

Приложение B. Будущее Crystal

Crystal недавно стал стабильным и готовым к выпуску версией 1.0.0 в марте 2021 года. По состоянию на апрель 2022 года последней версией является 1.4.1, которая содержит множество усовершенствований. Тем не менее, впереди еще много работы, и многие области языка будут улучшены в следующих выпусках. Все обсуждения разработки и дизайна происходят открыто в официальном репозитории GitHub, и существует множество возможностей для внесения вклада со стороны посторонних.

Сегодня Кристалл уже используется в производстве несколькими компаниями. Вы можете найти общедоступный список некоторых из них на Wiki Crystal здесь: https://github.com/crystal-lang/crystal/wiki/Used-in-production. Ожидается, что теперь, когда введена надлежащая политика, запрещающая вносить критические изменения, уровень внедрения будет еще выше. Исходный код, созданный сейчас, будет нормально компилироваться без изменений во всех будущих версиях 1.x.

Windows

Crystal поддерживает Linux, macOS и FreeBSD, но сегодня он не может работать в Windows. Все остальные платформы Unix-подобны и достаточно похожи. С другой стороны, Windows — это совсем другое дело, и для ее правильной поддержки требуются значительные усилия. Это одна из наиболее востребованных функций, и ведется работа по обеспечению надлежащей поддержки Windows. Запуск Crystal внутри подсистемы Windows для Linux (WSL) поддерживается, но в основном предназначен для разработчиков.

Crystal 1.0.0 был выпущен с очень ранней поддержкой для создания простых программ, скомпилированных в Windows, но это не значит, что вы уже можете использовать его для всего: для функций одновременного ввода-вывода (файлы, сокеты, консоль и т. д.), для например, до сих пор отсутствуют. К счастью, реализации каждого из этих примитивов вносятся сообществом и должны быть доступны в одной из следующих версий 1.x.

Вы можете проверить текущий прогресс в выпуске GitHub #5430. Если эта проблема уже закрыта, когда вы читаете эту книгу, значит, в текущей версии поддерживается Windows. Ура!

WebAssembly

WebAssembly — это новый стандарт для целей компиляции, популярность которого быстро растет, и не только в Интернете. Он предлагает портативность для работы где угодно со скоростью, близкой к исходной: веб-браузеры, облачные серверы, встроенные устройства, плагины, блокчейны и многое другое. Кроме того, он позволяет различным языкам взаимодействовать в удобном формате, а также является безопасным и проверяемым перед выполнением.

Продолжается работа по добавлению поддержки таргетинга в компилятор и стандартную библиотеку, что упростит написание программы Crystal, которая может работать где угодно и принимает WebAssembly. Версия Crystal 1.4.0 поставляется с первоначальной экспериментальной реализацией, в которой большая часть стандартной библиотеки уже работает.

Пожалуйста, обратитесь к выпуску #12002 для получения актуальной информации о ходе выполнения.

Многопоточность

Параллельное программирование — важная тема при изучении Crystal. Вы можете создавать легкие потоки (известные как волокна) с помощью метода spawn. По умолчанию Crystal распределяет работу между одним ядром CPU, используя асинхронный цикл событий. Это простой и очень эффективный подход, который избавляет программиста от необходимости иметь дело с синхронизацией потоков и гонками за данными. При выполнении операции ввода-вывода блокируется только текущее волокно; все остальные могут тем временем работать. В большинстве случаев масштабируемость может быть достигнута за счет запуска нескольких экземпляров Crystal для использования преимуществ нескольких ядер. Параллелизм будет обсуждаться более подробно в Главе 8 «Использование внешних библиотек».

Тем не менее, бывают случаи, когда настоящая многопоточность становится необходимостью. Например, при работе с интенсивной обработкой CPU одного наличия одновременных волокон недостаточно. Возможность одновременной параллельной работы нескольких волокон является обязательной. Для этого в Crystal есть экспериментальный флаг -Dpreview_mt, который позволяет вашей программе использовать все ядра. Каждое ядро будет иметь собственный цикл событий для запуска волокон и операций I/O.

Этот режим является экспериментальным, и пока не все функции в нем хорошо работают. Особое внимание следует уделить синхронизации данных. Рекомендуемый и безопасный подход — использовать каналы для всей связи между волокнами и избегать совместного использования глобального состояния. Тем не менее, он работает и его можно использовать для тестирования. Несколько возможных изменений, которые он может иметь, прежде чем он будет признан готовым к производству, заключаются в следующем:

Work stealing: когда одно ядро процессора простаивает из-за того, что у него нет волокна для запуска (возможно, все они ожидают какой-либо операции I/O), оно должно иметь возможность украсть возобновляемое волокно у другого ядра и продолжить работу с ним. Это предотвращает простаивание ядра процессора во время выполнения работы.

Предварительное планирование: это гарантирует, что одно волокно не сможет использовать слишком много процессорного времени, прежде чем другое волокно сможет запуститься. Это достигается путем приостановки длительно работающих волокон и принудительного переключения контекста.

Структурированный параллелизм

Параллелизм — это процесс одновременного выполнения множества вычислений. В разных языках это понятие рассматривается по-разному. Например, в Erlang есть актеры, в JavaScript — промисы, в .NET — задачи, а в Go — горутины. Каждый из них предоставляет различную абстракцию того, как понимать и обрабатывать текущие задания, а также передавать данные между ними.

Crystal предоставляет некоторые примитивы низкоуровневого параллелизма с волокнами, каналами и оператором выбора. Они довольно мощные и позволяют программе обрабатывать параллелизм по своему усмотрению. Но в стандартной библиотеке по-прежнему отсутствует инструмент более высокого уровня для структурированного параллелизма, где время жизни и поток данных каждого задания четко определены и предсказуемы. Это сделает параллельное программирование менее подверженным ошибкам и облегчит его анализ. Подробнее об этом можно узнать, прочитав выпуск #6468.

Инкрементальная компиляция и улучшенный инструментарий

Crystal использует систему вывода типов, которая применяется ко всей программе одновременно для анализа и идентификации каждого типа выражения в программе. Это отличается от обычного вывода типа в других языках, поскольку оно работает за пределами границ метода, и типы аргументов не требуют явного типизации. Однако это имеет свою цену. Анализ всей программы на предмет типов требует, ну, всей программы сразу. Любое изменение любой строки в любом файле приводит к повторению всего анализа с самого начала.

Компиляция и анализ программ Crystal происходит немного медленнее, чем на других языках, но это плата за отличную производительность и потрясающий синтаксис, семантику и выразительность.

Существуют расширения для многих редакторов кода и IDE, поддерживающих Crystal, но они в основном основаны на самом компиляторе и, следовательно, не предлагают поэтапный анализ программы, и разработчику часто приходится ждать несколько секунд, прежде чем получить отзыв, например тип информация о наведении или смысловых ошибках. Скорее всего, он будет разработан как собственный языковой сервер.

Использование встроенных отладчиков работает, но они пока не обеспечивают полной поддержки Crystal для проверки любых переменных во время выполнения или оценки выражений.

В прошлом проводилась работа по улучшению времени компиляции, например кэширование промежуточных результатов или некоторые семантические изменения в самом языке. Но переосмысление средства проверки типов для постепенной работы потребует много усилий и времени. Понятно, что главным достоинством Crystal является его выразительность и тот факт, что им приятно пользоваться; любое внесенное изменение должно будет сохранить это. Тем не менее, петля обратной связи в настоящее время является болевой точкой, и со временем появятся улучшения, чтобы решить эту проблему. Если вы хотите узнать больше об этой задаче, посмотрите выпуск #10568. Есть также много других вопросов, касающихся различных аспектов поддержки инструментов.

Как связаться с сообществом

Перечисленные ранее темы и многие другие являются предметом ежедневного обсуждения сообществом, местом для понимания вариантов использования, споров о различных подходах к реализации и организации усилий по сотрудничеству. Присоединиться может любой желающий.

Основным каналом является форум https://forum.crystal-lang.org/. Там может происходить любое обсуждение: от гипотетических функций до обращения за помощью и проверки кода, от поиска вакансий Crystal до обмена созданными вами проектами.

Если вы ищете другие способы взаимодействия, загляните на https://crystal-lang.org/community; он объединяет ссылки с разных платформ.

Наконец, есть репозиторий GitHub, где происходит сотрудничество в разработке языка, по адресу https://github.com/crystal-lang/crystal. Это то место, куда вам следует обратиться, если вы хотите внести свой вклад в стандартную библиотеку или сам компилятор с помощью кода, улучшений документации или проблем.

Куда бы вы ни пошли, вы найдете там страстное сообщество, которое поможет, поделится опытом и поработает вместе.


Оглавление

  • О издании
  • Составители
  •   Об авторах
  •   О рецензенте
  • Предисловие
  •   Для кого эта книга
  •   О чем эта книга
  •   Чтобы получить максимальную пользу от этой книги
  •   Загрузите файлы примеров кода
  •   Загрузка цветных изображений
  •   Используемые соглашения
  •   Как связаться
  •   Поделитесь своими мыслями
  • Часть 1: Приступая к работе
  •   1. Введение в Crystal
  •     Технические требования
  •     Немного истории
  •     Исследование выразительности Crystal
  •     Программы Crystal также БЫСТРЫЕ
  •       Сравнение веб-серверов
  •     Настройка среды
  •     Создаем нашу первую программу
  •       Создание исполняемого файла
  •     Краткое содержание
  •   2. Основные семантики и особенности Crystal
  •     Технические требования
  •     Значения и выражения
  •       Числа (Numbers)
  •       Примитивные константы — true, false и nil
  •       Строки и символы (String и Char)
  •       Диапазоны (Ranges)
  •       Перечисления и символы (Enums and symbols)
  •       Управление потоком выполнения с помощью условных выражений
  •         if и unless
  •         case
  •         while и until loops
  •       Изучение системы типов
  •         Экспериментируем с командой Crystal Play
  •     Организация вашего кода по методам
  •       Добавление ограничений типа
  •       Значения по умолчанию
  •       Именованные параметры
  •       Внешние и внутренние имена параметров
  •       Передача блоков в методы
  •       Использование next внутри блока
  •       Использование break внутри блока
  •       Возвращение изнутри блока
  •     Контейнеры данных
  •       Массивы и кортежи
  •       Хэш
  •         Итерация коллекций с блоками
  •         Синтаксис короткого блока
  •         Параметры сплата (Splat)
  •       Организация вашего кода в файлах
  •         require "./filename"
  •         require "filename"
  •       Резюме
  •       Дальнейшее чтение
  •   3. Объектно-ориентированное программирование
  •     Технические требования
  •     Понятие объектов и классов
  •     Создание собственных классов
  •       Манипулирование данными с использованием переменных и методов экземпляра
  •       Создание геттеров и сеттеров
  •       Наследование
  •       Полиморфизм
  •       Абстрактные классы
  •       Переменные класса и методы класса
  •     Работа с модулями
  •     Значения и ссылки – использование структур
  •     Общие (Generic) классы
  •     Исключения
  •       Пользовательские исключения
  •     Резюме
  • Часть 2: Обучение на практике – CLI
  •   4. Изучение Crystal с помощью написания интерфейса командной строки
  •     Технические требования
  •     Введение в проект
  •     Строительные леса проекта
  •     Написание базовой реализации
  •       Преобразование данных
  •       Улучшение возможности повторного использования
  •   Резюме
  •   5. Операции ввода/вывода
  •     Технические требования
  •     Поддерживающий терминальный ввод/вывод
  •     Поддержка других IO
  •     Тестирование производительности
  •     Объяснение поведения IO
  •   Резюме
  •   6. Параллелизм (Concurrency)
  •     Технические требования
  •     Использование волокон (fibers ) для одновременного выполнения работы
  •     Использование каналов для безопасной передачи данных
  •     Преобразование нескольких файлов одновременно
  •     Резюме
  •   7. Совместимость c C
  •     Технические требования
  •     Вводим привязки на языке C
  •       Привязка libnotify
  •       Тестирование привязок
  •       Абстрагирование привязок
  •     Интеграция привязок
  •     Резюме
  • Часть 3. Обучение на практике — веб-приложение
  •   8. Использование внешних библиотек
  •     Технические требования
  •   Использование Crystal Shards
  •       Shard зависимости от кода C
  •       Обновление осколков
  •       Проверка зависимостей
  •     Поиск осколков
  •       Пример сценария
  •     Резюме
  •   9. Создание веб-приложения с помощью Athena
  •     Технические требования
  •     Понимание архитектуры Афины
  •     Начало работы с Афиной
  •       Сущность статьи
  •       Возврат статьи
  •       Обработка тела запроса
  •       Проверка
  •     Реализация взаимодействия с базой данных
  •       Настройка базы данных
  •       Сохраняющиеся статьи
  •       Получение статей
  •       Обновление статьи
  •       Удаление статьи
  •     Использование переговоров по содержанию
  •     Резюме
  •     Дальнейшее чтение
  • Часть 4: Метапрограммирование
  •   10. Работа с макросами
  •     Технические требования
  •     Определение макросов
  •       Свежие переменные
  •       Макросы определения, не являющиеся макросами
  •     Понимание API макросов
  •       Воссоздание макроса property
  •     Изучение макро-хуков
  •     Резюме
  •   11. Знакомство с аннотациями
  •     Технические требования
  •     Что такое аннотации?
  •     Хранение данных в аннотациях
  •     Чтение аннотаций
  •   Резюме
  •   12. Использование интроспекции типов во время компиляции
  •   Технические требования
  •     Итерация переменных типа
  •     Итерационные типы
  •       Итерация подклассов типа
  •       Итерация типов с определенной аннотацией
  •       Итерационные типы, включающие определенный модуль
  •     Итерационные методы
  •     Резюме
  •     Дальнейшее чтение
  •   13. Расширенное использование макросов
  •     Технические требования
  •     Использование аннотаций для влияния на логику времени выполнения
  •     Предоставление данных времени компиляции во время выполнения
  •       Доступ к значению
  •       Моделирование всего класса
  •     Определение значения константы во время компиляции
  •     Создание пользовательских ошибок времени компиляции
  •       Ограничение универсальных типов
  •     Резюме
  • Часть 5: Вспомогательные инструменты
  •   14. Тестирование
  •   Технические требования
  •     Зачем тестировать?
  •     Модульное тестирование
  •       Маркировка (Tagging) тестов
  •       Осмеяние (Mocking)
  •       Хуки
  •     Интеграционное тестирование
  •     Резюме
  •   15. Документирование кода
  •     Технические требования
  •     Документирование кода Crystal
  •     Привязка функции API
  •     Форматирование
  •     Директивы документации
  •       Ditto
  •       Nodoc
  •       Inherit
  •     Создание документации
  •     Хостинг документации
  •     Управление версиями документации
  •     Резюме
  •   16. Развертывание кода
  •     Технические требования
  •     Управление версиями вашего shard
  •     Создание производственных двоичных файлов
  •     Распространение вашего бинарного файла
  •       Через Docker
  •       Через менеджер(ы) пакетов
  •     Резюме
  •     Дальнейшее чтение
  •   17. Автоматизация
  •     Технические требования
  •     Форматирование кода
  •     Линтинг-код
  •     Непрерывная интеграция с GitHub Actions
  •       Форматирование, стандарты кодирования и тесты
  •       Развертывание документации
  •     Резюме
  • Приложение A. Настройка инструмента
  •   Установка компилятора Crystal
  •     Установка компилятора в Windows
  •   Установка кода Visual Studio
  • Приложение B. Будущее Crystal
  •   Windows
  •   WebAssembly
  •   Многопоточность
  •   Структурированный параллелизм
  •   Инкрементальная компиляция и улучшенный инструментарий
  •   Как связаться с сообществом