Часть 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  [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 


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 


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 

#include 


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, также известные как шарды, в качестве зависимостей внутри вашего собственного проекта. Иди, начни!

Загрузка...