Русский
Русский
English
Статистика
Реклама

Erlang/otp

Отправляем SMS из ErlangElixir. Короткая инструкция

30.12.2020 10:17:29 | Автор: admin


Photo by Science in HD


Если вам когда-либо приходилось решать задачу отправки SMS из кода вашего приложения, скорее всего, вы использовали готовое REST API поставщика дополнительных услуг. Но что происходит после того, как поставщик получит ваш запрос? Какие протоколы используются и какой путь проходит текст сообщения, прежде чем оказаться на экране мобильного терминала пользователя?


В этой статье вы найдёте:


  1. Немного теории и терминологии SMPP-протокола: SMSC, ESME, PDU, MO/MT SM.
  2. Краткий обзор существующих библиотек для работы с SMPP в Erlang/Elixir.
  3. Пример реализации асинхронного клиента при помощи библиотеки SMPPEX. Возможно, он будет полезен тем, кто ещё не использовал Elixir-библиотеки в Erlang-проектах.
  4. Информацию по обработке deliver_sm, MO SM.

Чего тут точно нет, так это информации по отправке коротких сообщений через SIGTRAN.


Определимся с терминами и понятиями


Прежде чем погружаться в протоколы и код, предлагаю разобраться в терминологии. Если быть придирчивым к определениям, то отправить SMS невозможно. Вспоминается момент из Джентльменов удачи: Кто ж его отправит он же сервис! SMS акроним от short message service, что на русский переводится как сервис/служба коротких сообщений. Если возвращаться к шутке, то отправляем мы SM, т. е. короткие сообщения, используя SMS сервис коротких сообщений.


У каждого оператора мобильной связи есть компонент, отвечающий за работу службы коротких сообщений. Это так называемый SMS-центр, он же SMSC, он же SMS-SC. Его задачами являются хранение, передача, конвертация и доставка SM-сообщений. Наиболее распространенным внешним протоколом взаимодействия с SMSC является SMPP. SMPP клиент-серверный протокол-комбайн, отвечающий за обмен короткими сообщениями в одноранговой сети. Источником SM могут быть устройства и приложения. В терминологии SMPP их называют ESME.


Давайте ответим на вопросы в начале статьи. Итак, ваше сообщение по REST API или SMPP попало к поставщику услуг, у которого заключён договор с одним или несколькими операторами связи или другими посредниками. Сервер поставщика подключается к SMSC и отправляет по SMPP ваше SM, затем получает отчёт о доставке или ответное SM. В процессе обработки SM могут проходить через маршрутизаторы RE. SMSC, сходив в HLR, узнает местоположение абонента и доставит SM абоненту. Общая картина и понимание проблемы, надеюсь, у вас появились. Давайте погрузимся в протокольные тонкости.


SMPP


Выше я сказал, что SMPP протокол-комбайн. Подобный эвфемизм я позволил себе из-за того, что SMPP применим не только для организации обмена SMS, с его помощью можно организовать различные сервисы: ESM, голосовой почты, уведомлений, сотового радиовещания, WAP, USSD и прочие. Весь обмен происходит с помощью пар запрос-ответ. Их называют PDU блоками данных или пакетами.


Инициализация подключения


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


  • bind_transmitter клиент может только отправлять запросы на сервер;
  • bind_receiver клиент только получает ответы от сервера;
  • bind_transceiver клиент работает в обоих направлениях, этот режим появился в SMPPv3.4.

Безопасность


При выполнении команды привязки мы должны передать параметры безопасности для идентификации нашего ESME: system_id, system_type и password.


SMPP в экосистеме OTP


Недавно у хорошего друга возник вопрос по работе с SMPP в Erlang. Собственно, благодаря этому и родился этот текст.


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



Но есть нюанс Найти адекватную реализацию оказалось непросто.


Наверняка все, кто пытался серьёзно работать с Erlang, знают про его недостаток, связанный с ограниченным выбором библиотек. С SMPP такая же история в OTP нет штатной поддержки этого протокола, а на первой странице выдачи Гугла творится что-то странное:


  • esmpp библиотека со странным API и отсутствующим сообществом;
  • древний OSERL проект стартовал 11 лет назад, последний коммит сделали более 5 лет назад;
  • неподдерживаемый smpp34 последний коммит был более 10 лет назад;
  • куча вопросов вида Какую библиотеку/клиента использовать для SMPP? на тематических форумах.

Лично я бы загрустил от такого разнообразия существующих решений. Особенно, когда хочется асинхронного режима, адекватной поддержки SMPP 3.4 и возможности написать как клиент, так и сервер. Но на помощь приходит Elixir и библиотека SMPPEX.


SMPPEX


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


От слов к делу


Сначала можно ознакомиться с примерами синхронного и асинхронного клиента в документации. Затем можно перейти к более сложным вещам в контексте использования SMPPEX в Erlang-проекте.


Для иллюстрации возможностей библиотеки возьмём простой сценарий:


  1. Поднять линк.
  2. Отправить сообщение.
  3. Дождаться отчёта о доставке либо обработать входящие сообщения.

Придумаем дополнительные требования. Допустим, мы хотим отправлять MT SM, получать отчёты о доставке и MO SM. При этом по каким-то причинам нам нужны кастомные PDU и полный контроль над линком, поэтому за формирование submit_sm PDU и обработку всех входящих PDU мы будем отвечать сами. При этом мы не должны забывать про требование асинхронности.


Работа с линком


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


'Elixir.SMPPEX.ESME':start_link(SmscHost, SmscPort, {?MODULE, [Opts]})

Для синхронного режима существует SMPPEX.ESME.Sync.


Наш клиент готов, и мы можем сделать привязку к SMSC. Предположим, что SMSC поддерживает SMPPv3.4 и мы можем использовать transceiver режим:


'Elixir.SMPPEX.Pdu.Factory':bind_transceiver(SystemId, Pass)

Если всё прошло хорошо, нам должен прийти PDU с командой bind_transceiver_resp:


bind_transceiver_resp = 'Elixir.SMPPEX.Pdu':command_name(Pdu)

Формирование PDU для MT SM


Линк поднят, и мы можем отправить наше сообщение. Соберём PDU для него:


submit_sm_pdu(SourceMsisdn, DestMsisdn, Message, Ttl) ->  {ok, CommandId} = 'Elixir.SMPPEX.Protocol.CommandNames':id_by_name(submit_sm),  {D, {H, M, S}} = calendar:seconds_to_daystime(Ttl),  VP = lists:flatten(io_lib:format("0000~2..0w~2..0w~2..0w~2..0w000R", [D, H, M, S])),  'Elixir.SMPPEX.Pdu':new(    CommandId,    #{      source_addr => SourceMsisdn,      source_addr_ton => 1,      source_addr_npi => 1,      destination_addr => DestMsisdn,      dest_addr_ton => 1,      dest_addr_npi => 1,      short_message => Message,      data_coding => 246,      protocol_id => 127,      %% For concatenated messages      esm_class => 64,      registered_delivery => 1,      validity_period => list_to_binary(VP)    }  ).

Обработка отчетов о доставке и MO SM


После отправки сообщения в линк SMSC ответит нам submit_sm_resp, в котором указан уникальный ID нашего сообщения:


MsgId = 'Elixir.SMPPEX.Pdu':mandatory_field(Pdu, message_id)

Теперь нам необходимо дождаться deliver_sm с этим message_id.


Чтобы отличить отчёты о доставке от MO SM, проанализируем esm_class:


EsmClass = 'Elixir.SMPPEX.Pdu':mandatory_field(Pdu, esm_class),case <<EsmClass>> of  <<_Head : 2, 0 : 1, 0 : 1, 0 : 1, 1 : 1, _Tail : 2>> -> handle_delivery_receipt(Pdu);  <<_Head : 2, 0 : 1, 0 : 1, 0 : 1, 0 : 1, _Tail : 2>> -> handle_standart_message(Pdu);  Some -> ?LOG_ERROR("unknown deliver_sm: ~p", [Some])end

При этом для обработки отчётов о доставке нам достаточно узнать ID доставленного сообщения:


SmsId = 'Elixir.SMPPEX.Pdu':field(Pdu, receipted_message_id)

А для входящих сообщений узнать номер отправителя:


Msisdn = 'Elixir.SMPPEX.Pdu':field(Pdu, source_addr)

и полезное содержимое сообщения:


Payload = 'Elixir.SMPPEX.Pdu':field(Pdu, short_message)

Как известно, спецификация SMPP требует deliver_sm_resp в ответ на deliver_sm. Поэтому после обработки отчёта о доставке и входящего сообщения мы должны ответить deliver_sm_resp. Создадим PDU для него:


deliver_sm_resp_pdu(MessageId) ->  {ok, CommandId} = 'Elixir.SMPPEX.Protocol.CommandNames':id_by_name(deliver_sm_resp),  CommandStatus = 0,  SeqNumber = 0,  'Elixir.SMPPEX.Pdu':new({CommandId, CommandStatus, SeqNumber}, #{message_id => MessageId}, #{}).

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


ReplyPdu = 'Elixir.SMPPEX.Pdu':as_reply_to(deliver_sm_resp(SmsId), Pdu)

Весь код демопроекта можно найти в репозитории: https://github.com/Elzor/smpp_in_erlang.


OTP-тренды


В 2020 году на тренды развития OTP и BEAM всё большее влияние оказывает сообщество Elixir. Чаще и чаще хорошие инструменты и полезные библиотеки можно найти на Elixir, а не на Erlang. Это не повод для тревоги за Erlang, просто Elixir смог заинтересовать и привлечь больше людей в своё сообщество, и это прекрасно. А благодаря OTP для использования той или иной библиотеки нам не важно, на чём она написана. Надеюсь, пример из статьи смог показать гибкость SMPPEX как инструмента и удобство применения библиотек, написанных на Elixir в Erlang-проектах.

Подробнее..

To spawn, or not to spawn?

11.04.2021 18:19:06 | Автор: admin

Вот в чём вопрос! Что лучше - держать всё в одном процессе, или создавать отдельный процесс на каждый кусок состояния, которым нам нужно управлять? В этой статье я немного расскажу об использовании или неиспользовании процессов. Я также расскажу, как отделить сложную логику с отслеживанием состояния от таких проблем, как временное (темпоральное) поведение и межпроцессное взаимодействие.

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

  • Используйте функции и модули для разделения мыслительных сущностей.

  • Используйте процессы для разделения сущностей времени выполнения.

  • Не используйте процессы (даже агентов) для разделения сущностей мышления.

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

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

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

Пример

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

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

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

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

Для простоты я не рассматривал такие понятия, как дилер, ставки, страхование, разделение(), несколько раундов, люди, присоединяющиеся к столу или покидающие его.

Границы процесса

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

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

Итак, в этом случае я вижу много потенциальных недостатков и не очень много преимуществ от использования нескольких процессов для управления состоянием одного раунда. Однако разные раунды взаимно независимы. У них есть свои отдельные потоки, они держат свои отдельные состояния, у них нет ничего общего. Таким образом, управление несколькими раундами в одном процессе контрпродуктивно. Это увеличит нашу поверхность ошибок (сбой в одном раунде приведет к отключению всего) и, возможно, приведет к снижению производительности (мы не используем несколько ядер) или узким местам (длительная обработка в одном раунде парализует все остальные). Если мы будем запускать разные раунды в разных процессах, есть очевидные выигрыши, так что это решение не составит труда :-) (прим. переводчика: Как же нет ничего общего, а состояние кошельков игроков?)

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

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

Функциональное моделирование

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

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

Колода карт

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

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

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

@cards (  for suit <- [:spades, :hearts, :diamonds, :clubs],      rank <- [2, 3, 4, 5, 6, 7, 8, 9, 10, :jack, :queen, :king, :ace],    do: %{suit: suit, rank: rank})

Теперь я могу добавить функцию shuffle/0 для создания перемешанной колоды:

def shuffled(), do:  Enum.shuffle(@cards)

И наконец, take/1, которая берёт верхнюю карту из колоды:

def take([card | rest]), do:  {:ok, card, rest}def take([]), do:  {:error, :empty}

Функция take/1 возвращает либо {:ok, card_taken, rest_of_the_deck}, либо {:error, :empty}. Такой интерфейс заставляет клиента (пользователя абстракции колоды) явно решать, как поступать в каждом случае.

Как мы можем это использовать:

deck = Blackjack.Deck.shuffled()case Blackjack.Deck.take(deck) do  {:ok, card, transformed_deck} ->    # do something with the card and the transform deck  {:error, :empty} ->    # deck is empty -> do something elseend

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

  • кучи связанных функций,

  • с описательными именами,

  • которые не проявляют побочных эффектов,

  • и могут быть извлечены в отдельный модуль

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

Не так важно, находятся ли эти функции в выделенном модуле. Код этой абстракции довольно прост и используется только в одном месте. Поэтому я мог бы также определить приватные функции shuffled_deck/0 и take_card/1 в клиентском модуле. Фактически, это то, что я часто делаю, если код достаточно мал. Я всегда могу выделить это позже, если что-то усложнится. (прим. переводчика: не совсем уловил здесь мысль, которую хотел донести автор)

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

Полный код модуля доступен здесь.

Рука

Эту же технику можно использовать для управления рукой. Эта абстракция отслеживает карты в руке. Она также умеет подсчитывать очки и определять статус руки (:ok или :busted). Реализация находится в модуле Blackjack.Hand.

Модуль выполняет две функции. Мы используем new/0 для создания экземпляра руки, а затем deal/2, чтобы раздать карту руке. Вот пример комбинации руки и колоды:

# create a deckdeck = Blackjack.Deck.shuffled()# create a handhand = Blackjack.Hand.new()# draw one card from the deck{:ok, card, deck} = Blackjack.Deck.take(deck)# give the card to the handresult = Blackjack.Hand.deal(hand, card)

Результат deal/2 вернётся в форме {hand_status, transformed_hand}, где hand_status это или :ok или :busted.

Раунд

Эта абстракция, реализованная в модуле Blackjack.Round, связывает всё воедино. Она имеет следующие обязанности:

  • сохранять состояния колоды

  • держать состояние всех рук в раунде

  • решать, кому переходит следующий ход

  • получать и интерпретировать ход игрока (хит / стоп)

  • брать карты из колоды и передавать их текущей руке

  • вычислять победителя после того, как все руки разыграны

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

У меня сложилось впечатление, что многие люди, включая опытных эрлангистов/эликсирщиков, реализовали бы концепцию раунда непосредственно в GenServer или в :gen_statem. Это позволит им управлять состоянием раунда и темпоральной логикой (например, общением с игроками) в одном месте.

Однако я считаю, что эти два аспекта необходимо разделить, поскольку оба они потенциально сложны. Логика одного раунда уже в некоторой степени запутана, и она может только ухудшиться, если мы захотим поддержать дополнительные аспекты игры, такие как ставки, сплиты или раздающего игрока. Общение с игроками имеет свои проблемы, если мы хотим иметь дело с расщеплениями сети(netsplits), сбоями, медленными или недоступными клиентами. В этих случаях нам может потребоваться поддержка повторных попыток, возможно, добавить некоторую персистентность, event sourcing или что-то еще.

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

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

Позвольте показать вам код. Чтобы создать новый раунд, мне нужно вызвать start/1:

{instructions, round} = Blackjack.Round.start([:player_1, :player_2])

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

  • создание руки для каждого игрока

  • отслеживание текущего игрока

  • отправка уведомлений игрокам

    Функция возвращает кортеж. Первый элемент кортежа - это список инструкций. Пример:

    [{:notify_player, :player_1, {:deal_card, %{rank: 4, suit: :hearts}}},{:notify_player, :player_1, {:deal_card, %{rank: 8, suit: :diamonds}}},{:notify_player, :player_1, :move}]
    

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

  • уведомить игрока 1, что он получил четвёрку червей

  • уведомить игрока 1, что он получил восьмёрку бубён

  • уведомить игрока 1, что ему нужно сделать ход

    Фактическая доставка этих уведомлений заинтересованным игрокам является ответственностью клиентского кода. Клиентским кодом может быть, скажем, GenServer, который будет отправлять сообщения процессам игроков. Он также будет ждать, пока игроки не сообщат, когда они захотят взаимодействовать с игрой. Это временная(темпоральная) логика, и она полностью хранится за пределами модуля Round.

Второй элемент возвращённого кортежа, называется round, это состояние самого раунда. Стоит отметить, что эти данные типизированы как непрозрачные. Это значит, что клиент не может читать эти данные внутри переменной round. Всё, что нужно клиенту, будет доставлено в списке instruction.

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

{instructions, round} = Blackjack.Round.move(round, :player_1, :hit)

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

Вот инструкции, которые я получил:

[  {:notify_player, :player_1, {:deal_card, %{rank: 10, suit: :spades}}},  {:notify_player, :player_1, :busted},  {:notify_player, :player_2, {:deal_card, %{rank: :ace, suit: :spades}}},  {:notify_player, :player_2, {:deal_card, %{rank: :jack, suit: :spades}}},  {:notify_player, :player_2, :move}]

Этот список говорит мне, что игрок 1 получил десятку пик. Поскольку раньше у него было 4 червы и 8 бубён, игрок вылетает из игры, и раунд немедленно переходит к следующей руке. Клиенту предлагается уведомить игрока 2 о том, что у него есть две карты, и что он должен сделать ход.

Сделаем ход от имени игрока 2:

{instructions, round} = Blackjack.Round.move(round, :player_2, :stand)# instructions:[  {:notify_player, :player_1, {:winners, [:player_2]}}  {:notify_player, :player_2, {:winners, [:player_2]}}]

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

Давайте посмотрим, как Round прекрасно сочетается с абстракциями Deck и Hand. Следующая функция из модуля Round берет карту из колоды и передает ее текущей руке:

defp deal(round) do  {:ok, card, deck} =    with {:error, :empty} <- Blackjack.Deck.take(round.deck), do:      Blackjack.Deck.take(Blackjack.Deck.shuffled())  {hand_status, hand} = Hand.deal(round.current_hand, card)  round =    %Round{round | deck: deck, current_hand: hand}    |> notify_player(round.current_player_id, {:deal_card, card})  {hand_status, round}end

Берём карту из колоды, используя новую колоду, если текущая закончилась. Затем мы передаем карту в текущую руку, обновляем раунд новой рукой и статусом колоды, добавляем инструкцию по уведомлению о данной карте и возвращаем статус руки (:ok или :busted) и обновленный раунд. Никаких дополнительных процессов в этом процессе не задействовано :-)

Вызов notify_player - это простой однострочник, который избавляет этот модуль от многих сложностей. Без него нам нужно было бы отправить сообщение другому процессу (например, другому GenServer или каналу Phoenix). Пришлось бы как-то найти этот процесс и рассмотреть случаи, когда этот процесс не запущен. Вместе с кодом, который моделирует ход раунда, пришлось бы связать много дополнительных сложностей.

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

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

Организация процесса

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

Сервер раунда

Каждый раунд управляется модулем Blackjack.RoundServer, который есть GenServer. Agent также мог бы подойти для этих целей, но я не фанат агентов, так что я остановлюсь на GenServer. Ваши предпочтения могут отличаться, конечно, и я полностью уважаю ваше мнение :-)

Чтобы запустить процесс, нам нужно вызвать функцию start_playing/2. Это имя выбрано вместо более распространенного start_link, поскольку start_link по соглашению ссылается на вызывающий процесс. Напротив, start_playing начнет раунд где-то еще в дереве надзора, и процесс не будет связан с вызывающим.

Функция принимает два аргумента: идентификатор раунда и список игроков. Идентификатор раунда - это произвольный уникальный терм, который должен быть выбран клиентом. Серверный процесс будет зарегистрирован во внутреннем реестре с использованием этого идентификатора.

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

@type player :: %{id: Round.player_id, callback_mod: module, callback_arg: any}

Игрок описывается его идентификатором, модулем обратного вызова и аргументом обратного вызова. Идентификатор будет передан абстракции раунда. Всякий раз, когда абстракция инструктирует сервер уведомить некоторого игрока, сервер вызывает callback_mod.some_function (some_arguments), где some_arguments будет включать идентификатор раунда, идентификатор игрока, callback_arg и дополнительные аргументы, специфичные для уведомления.

Подход callback_mod позволяет нам поддерживать различные типы игроков, такие как:

  • игроков, подключенных через HTTP

  • игроков, подключенных через настраиваемый протокол TCP

  • игрок в сеансе оболочки iex

  • автоматических игроков (ботов)

    Мы легко справимся со всеми этими игроками в одном раунде. Сервер не заботится ни о чём из этого, он просто вызывает функции обратного вызова модуля обратного вызова и позволяет реализации выполнять работу.

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

@callback deal_card(RoundServer.callback_arg, Round.player_id,  Blackjack.Deck.card) :: any@callback move(RoundServer.callback_arg, Round.player_id) :: any@callback busted(RoundServer.callback_arg, Round.player_id) :: any@callback winners(RoundServer.callback_arg, Round.player_id, [Round.player_id])  :: any@callback unauthorized_move(RoundServer.callback_arg, Round.player_id) :: any

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

Другое приятное следствие такого дизайна - это то, что тестирование этого сервера довольно просто. Тест реализует уведомления путём отправки сообщений самому себе из каждого колбека. Затем тестирование сводится к asserting/refuting определённых сообщений, и вызову RoundServer.move/3, чтобы сделать ход от имени игрока.

Отправка сообщений

Когда функция модуля Round возвращает список инструкций серверному процессу, тот пройдёт по этому списку, и интерпретирует инструкции.

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

Это реализовано в модуле Blackjack.PlayerNotifier, процессе на основе GenServer, чья роль - отправлять уведомление отдельному игроку. Когда мы стартуем сервер раунда функцией start_playing/2, запускается небольшое поддерево надзора в котором размещается сервер раунда вместе с одним сервером уведомлений на каждого игрока в раунде.

Когда сервер раунда делает ход, он получает список инструкций от абстракции раунда. Затем он перенаправляет каждую инструкцию соответствующему серверу уведомлений, который интерпретирует эту инструкцию и вызывает соответствующий модуль/функцию/аргументы(M/F/A) для уведомления игрока.

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

[  {:notify_player, :player_1, {:deal_card, %{rank: 10, suit: :spades}}},  {:notify_player, :player_1, :busted},  {:notify_player, :player_2, {:deal_card, %{rank: :ace, suit: :spades}}},  {:notify_player, :player_2, {:deal_card, %{rank: :jack, suit: :spades}}},  {:notify_player, :player_2, :move}]

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

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

Сервис блэкджека

Картинка завершается в виде приложения OTP :blackjack (модуль Blackjack). Когда вы запускаете приложение, запускается пара локально зарегистрированных процессов: экземпляр внутреннего реестра Registry (используется для регистрации серверов раунда и уведомлений) и супервизор :simple_one_for_one, который будет размещать поддерево процесса для каждого раунда.

Это приложение теперь в основном представляет собой сервис блэкджека, который может управлять несколькими раундами. Сервис является универсальным и не зависит от конкретного интерфейса. Вы можете использовать его с Phoenix, Cowboy, Ranch (для простого TCP), elli или любым другим, подходящим для ваших целей. Вы реализуете модуль обратного вызова, запускаете клиентские процессы и запускаете сервер раунда.

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

$ iex -S mixiex(1)> Demo.runplayer_1: 4 of spadesplayer_1: 3 of heartsplayer_1: thinking ...player_1: hitplayer_1: 8 of spadesplayer_1: thinking ...player_1: standplayer_2: 10 of diamondsplayer_2: 3 of spadesplayer_2: thinking ...player_2: hitplayer_2: 3 of diamondsplayer_2: thinking ...player_2: hitplayer_2: king of spadesplayer_2: busted...

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

Заключение

Итак, можем ли мы управлять сложным состоянием в одном процессе? Конечно, можем! Простые функциональные абстракции, такие как Deck and Hand, позволили мне разделить проблемы более сложного состояния раунда без необходимости прибегать к помощи агентов.

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

Если временная логика и/или логика предметной области сложны, рассмотрите возможность их разделения. Подход, который я использовал, позволил мне реализовать более сложное поведение во время выполнения (одновременные уведомления), не усложняя бизнес-процесс раунда. Это разделение также ставит меня в удобное положение, поскольку теперь я могу развивать оба аспекта по отдельности. Добавление поддержки бизнес-концепций дилера, сплита, страхования и других не должно существенно влиять на аспект выполнения. Точно так же поддержка расщеплений сети(netsplits), повторных подключений, сбоев игрока или тайм-аутов не должна требовать изменений в логике домена.

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

Подробнее..

Recovery mode Vela умный кеш для time series и не только

11.07.2020 10:23:53 | Автор: admin

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


Фламинго


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

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


Допустим, мы собираем курсы для трех пар валют. Простейшее определение Vela для хранения актуального состояния будет выглядеть как-то так:


defmodule Pairs do  use Vela,    eurusd: [sorter: &Kernel.<=/2],    eurgbp: [limit: 3, errors: 1],    eurcad: [validator: Pairs]  @behaviour Vela.Validator  @impl Vela.Validator  def valid?(:eurcad, rate), do: rate > 0end

Обновление значений


Vela.put/3 функция последовательно сделает следующее:


  • вызовет validator на значении, если таковой определен (см. главку Валидация ниже);
  • добавит значение либо в ряд хороших значений, если валидация закончилась успешно, или в служебный ряд :__errors__ в обратном случае;
  • вызовет сортировку если sorter определен для данного ключа, или просто положит значение в голову списка (FILO, см. главку Сортировка ниже);
  • обрежет ряд в соответствии с параметром :limit переданном при создании;
  • вернет обновленную структуру Vela.

iex|1 > pairs = %Pairs{}iex|2 > Vela.put(pairs, :eurcad, 1.0)# %Pairs{..., eurcad: [1.0], ...}iex|3 > Vela.put(pairs, :eurcad, -1.0)#%Pairs{__errors__: [eurcad: -1.0], ...}iex|4 > pairs |> Vela.put(:eurusd, 2.0) |> Vela.put(:eurusd, 1.0)#%Pairs{... eurusd: [1.0, 2.0]}

Также Vela имплементирует Access, так что можно для обновления значений воспользоваться любой из стандартных функций для глубокого обновления структур из арсенала Kernel: Kernel.get_in/2, Kernel.put_in/3, Kernel.update_in/3, Kernel.pop_in/2, and Kernel.get_and_update_in/3.


Валидация


Валидатор можен быть определен как:


  • внешняя функция с одним аргументом (&MyMod.my_fun/1), она получит только значение для валидации;
  • внешняя функция с двумя аргументами, &MyMod.my_fun/2, она получит пару serie, value для валидации;
  • модуль, имплементирующий Vela.Validator;
  • конфигурационный параметр threshold, и опционально compare_by, см. главку Comparison ниже.

Если валидация прошла успешно, значение добавляется в список под соответствующим ключом, в обратном случае кортеж {serie, value} отправляется в :__errors_.


Сравнение


Значения, сохраняемые в этих рядах, могут быть любыми. Чтобы научить Vela их сравнивать, необходимо передать compare_by параметр в определение ряда (если только значения не могут быть сравнены стандартным Kernel.</2); этот параметр должен иметь тип (Vela.value() -> number()). По умолчанию это просто & &1.


Также, в определение ряда можно передать параметр comparator для вычисления значений дельт (min/max); например, передавая Date.diff/2 в качестве компаратора, можно получить правильные дельты для дат.


Другим удобным способом работы является передача параметра threshold, который определяет максимально допустимое отношение нового значения к {min, max} интервалу. Поскольку он задан в процентах, проверка не использует comparator, но все еще использует compare_by. Например, чтобы указать пороговое значение для времени дат, необходимо указать compare_by: &DateTime.to_unix/1 (для получения целочисленного значения) и threshold: 1, в результате чего новые значения будут разрешены, только если они находятся в band интервале от текущих значений.


Наконец, можно использовать Vela.equal?/2 для сравнения двух кешей. Если значения определяют функцию equal?/2 или compare/2, то эти функции будут использованы для сравнения, в противном случае мы тупо используем ==/2.


Получение значений


Обработка текущего состояния обычно начинается с вызова Vela.purge/1, который убирает устаревшие значения (если validator завязан на timestamps). Затем можно вызвать Vela.slice/1, которая вернет keyword с именами рядов в качестве ключей и первым, актуальными значениями.


Также можно воспользоваться get_in/2/pop_in/2 для низкоуровнего доступа к значениям в каждом ряду.


Приложение


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


@impl Vela.Validatordef valid?(_key, %Rate{} = rate),  do: Rate.age(rate) < @death_age

и Vela.purge/1 спокойно удаляет все устаревшие значения каждый раз, когда нам требуются данные. Для доступа к актуальным значениям, мы просто вызываем Vela.slice/1, а когда требуется небольшая история по курсу (весь ряд целиком), мы просто возвращаем его, уже отсортированным, с провалидированными значениями.




Удачного кеширования временных рядов!

Подробнее..

Recovery mode O tempora, o mores!

05.09.2020 10:09:13 | Автор: admin

Для протокола: заголовок я позаимствовал у Цицерона, в Oratio in Catilinam Prima in Senatu Habita.


Cicero Denounces Catiline, fresco by Cesare Maccari, 18821888




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


Допустим, нам пора навестить стоматолога. Я знаю, что мне нужен 1 час для ежегодной плановой проверки. Я могу посетить врача во время обеда или после работы. У доктора есть и другие пациенты. На диаграмме ниже мои рабочие часы показаны фиолетовым цветом, докторскиекрасным, нерабочиесерым, а счастливо найденный слот, когда мы оба свободны, зеленым.


Timelines of my busy hours and the dentists busy hours


Беглого взгляда на эту диаграмму достаточно, чтобы определить время назначенной встречи. Завтра, в обеденное время. Легко, да?


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


Итак, встречайте Tempus!


Детали реализации


Сущность, вокруг которой построена вся библиотека Slot. Он представляет временной интервал самым естественным и простым способом: это структура, с полями from и to, оба типа DateTime. Набор слотов хранится в структуре Slots, под капотом реализованной как AVLTree. Этот выбор был сделан для того, чтобы сохранить базовый список слотов согласованным (упорядоченным и не перекрывающимся) с наименьшими затратами при оптимизации доступа по чтению и записи. Обычно список слотов заполняется при инициализации и потом используется для проверки, поиска свободных интервалов, и тому подобного.


Функция Slots.add/2 добавит в список слотовновый, объединяя слоты по мере необходимости. Это позволяет просто вставлять новые слоты в конструкцию, не беспокоясь о порядке и перекрытии. Также предусмотрена вспомогательная функция Slots.merge/2 для объединения двух наборов слотов. Последнее особенно удобно, когда нужно, например, найти пустой слот в обеих сериях, как в примере с выбором времени посещения дантиста выше.


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


Модуль Tempus


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


Вот незамысловатый пример из тестов:


slots =  [    Tempus.Slot.wrap(~D|2020-08-07|), # whole day    %Tempus.Slot{      from: ~U|2020-08-08 01:01:00Z|, # one minute      to: ~U|2020-08-08 01:02:00Z|    },    %Tempus.Slot{      from: ~U|2020-08-08 01:03:00Z|, # one minute      to: ~U|2020-08-08 01:04:00Z|    }  ]  |> Enum.into(%Tempus.Slots{})

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


Tempus.add(slots, ~U|2020-08-08 01:01:30Z|, 0, :second)# ~U[2020-08-08 01:02:00Z]

Добавив 70 секунд ко времени, на пять секунд предшествующему первому занятому слоту~U[2020-08-08 01:00:55Z]вернется экземпляр DateTime через пять секунд после второго занятого слота (5sec + занято + 60sec + занято + 5sec):


Tempus.add(slots, ~U|2020-08-08 01:00:55Z|, 70, :second)# ~U[2020-08-08 01:04:05Z]

И так далее. Разумеется, можно добавлять и отрицательные значения.


Слияние наборов слотов


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


Все функции, возвращающие Slots и / или Slotгарантированно отдают допустимые объекты (нормализованные, упорядоченные и объединенные по мере необходимости).


Что еще?


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




Удачных временных интервалов!

Подробнее..

Recovery mode Типы, где их не ждали

12.11.2020 08:22:24 | Автор: admin

Давайте представим себе реализацию модуля Scaffold, который генерирует структуру с предопределенными пользовательскими полями и инжектит ее в вызываемый модуль при помощи use Scaffold. При вызове use Scaffold, fields: foo: [custom_type()], ... мы хотим реализовать правильный тип в Consumer модуле (common_field в примере ниже определен в Scaffold или еще где-нибудь извне).


@type t :: %Consumer{  common_field: [atom()],  foo: [custom_type()],  ...}

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


Lighthouse in French Catalonia


Пример посложнее будет выглядеть так:


defmodule Scaffold do  defmacro __using__(opts) do    quote do      @fields unquote(opts[:fields])      @type t :: %__MODULE__{        version: atom()        # magic      }      defstruct @fields    end  endenddefmodule Consumer do  use Scaffold, fields: [foo: integer(), bar: binary()]end

и, после компиляции:


defmodule Consumer do  @type t :: %Consumer{    version: atom(),    foo: integer(),    bar: binary()  }  defstruct ~w|version foo bar|aend

Выглядит несложно, да?


Наивный подход


Давайте начнем с анализа того, что за AST мы получим в Scaffold.__using__/1.


  defmacro __using__(opts) do    IO.inspect(opts)  end# [fields: [foo: {:integer, [line: 2], []},#            bar: {:binary, [line: 2], []}]]

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


  quote do    custom_types = unquote(opts[:fields])    ...  end# == Compilation error in file lib/consumer.ex ==#  ** (CompileError) lib/consumer.ex:2: undefined function integer/0

Бамс! Типыэто чего-то особенного, как говорят в районе Привоза; мы не можем просто взять и достать их из AST где попало. Может быть, unquote по месту сработает?


      @type t :: %__MODULE__{              unquote_splicing([{:version, atom()} | opts[:fields]])            }# == Compilation error in file lib/scaffold.ex ==#  ** (CompileError) lib/scaffold.ex:11: undefined function atom/0

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


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


Построение типа в AST


Я опущу тут пересказ нескольких часов моих метаний, мучений, и тычков пальцем в небо. Все знают, что я пишу код в основном наугад, ожидая, что вдруг какая-нибудь комбинация этих строк скомпилируется и заработает. В общем, сложности тут с контекстом. Мы должны пропихнуть полученные определения полей в неизменном виде напрямую в макрос, объявляющий тип, ни разу не попытавшись это AST анквотнуть (потому что в момент unquote типы наподобие binary() будут немедленно приняты за обыкновенную функцию и убиты из базуки вызваны компилятором напрямую, приводя к CompileError.


Кроме того, мы не можем использовать обычные функции внутри quote do, потому что все содержимое блока, переданного в quote, уже само по себеAST.


quote do  Enum.map([:foo, :bar], & &1)end# {#   {:., [], [{:__aliases__, [alias: false], [:Enum]}, :map]}, [],#     [[:foo, :bar], {:&, [], [{:&, [], [1]}]}]}

Видите? Вместо вызова функции, мы получили ее препарированное AST, все эти Enum, :map, и прочий маловнятный мусор. Иными словами, нам придется создать AST определения типа вне блока quote и потом просто анквотнуть внутри него. Давайте попробуем.


Чуть менее наивная попытка


Итак, нам надо инжектнуть AST как AST, не пытаясь его анквотнуть. Звучит устрашающе? Вовсе нет, отнюдь.


defmacro __using__(opts) do  fields = opts[:fields]  keys = Keyword.keys(fields)  type = ???  quote location: :keep do    @type t :: unquote(type)    defstruct unquote(keys)  endend

Все, что нам нужно сделать сейчас, это произвести надлежащий AST, все остальное в порядке. Ну, пусть ruby сделает это за нас!


iex|1  quote do...|1    %Foo{version: atom(), foo: binary()}...|1  end#{:%, [],#   [#     {:__aliases__, [alias: false], [:Foo]},#     {:%{}, [], [version: {:atom, [], []}, foo: {:binary, [], []}]}#   ]}

А нельзя ли попроще?


iex|2  quote do...|2    %{__struct__: Foo, version: atom(), foo: binary()}...|2  end# {:%{}, [],#   [#     __struct__: {:__aliases__, [alias: false], [:Foo]},#     version: {:atom, [], []},#     foo: {:binary, [], []}#   ]}

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


Почти работающее решение


defmacro __using__(opts) do  fields = opts[:fields]  keys = Keyword.keys(fields)  type =    {:%{}, [],      [        {:__struct__, {:__MODULE__, [], ruby}},        {:version, {:atom, [], []}}        | fields      ]}  quote location: :keep do    @type t :: unquote(type)    defstruct unquote(keys)  endend

или, если нет цели пробросить типы из собственно Scaffold, даже проще (как мне вот тут подсказали: Qqwy here). Осторожно, оно не будет работать с проброшенными типами, version: atom() за пределами блока quote выбросит исключение.


defmacro __using__(opts) do  fields = opts[:fields]  keys = Keyword.keys(fields)  fields_with_struct_name = [__struct__: __CALLER__.module] ++ fields  quote location: :keep do    @type t :: %{unquote_splicing(fields_with_struct)}    defstruct unquote(keys)  endend

Вот что получится в результате генерации документации для целевого модуля (mix docs):


Screenshot of type definition


Примечание: трюк с фрагментом AST


Но что, если у нас уже есть сложный блок AST внутри нашего __using__/1 макроса, который использует значения в кавычках? Переписать тонну кода, чтобы в результате запутаться в бесконечной череде вызовов unquote изнутри quote? Это просто даже не всегда возможно, если мы хотим иметь доступ ко всему, что объявлено внутри целевого модуля. На наше счастье, существует способ попроще.


NB для краткости я покажу простое решение для объявления всех пользовательских полей, имеющих тип atom(), которое тривиально расширяеься до принятия любых типов из входных параметров, включая внешние, такие как GenServer.on_start() и ему подобные. Эту часть я оставлю для энтузиастов в виде домашнего задания.

Итак, нам надо сгенерировать тип внутри блока quote do, потому что мы не можем передавать туда-сюда atom() (оно взовется с CompileError, как я показал выше). Хначит, что-нибудь типа такого:


keys = Keyword.keys(fields)type =  {:%{}, [],    [      {:__struct__, {:__MODULE__, [], ruby}},      {:version, {:atom, [], []}}      | Enum.zip(keys, Stream.cycle([{:atom, [], []}]))    ]}

Это все хорошо, но как теперь добавить этот АСТ в декларацию @type? На помощь приходит очень удобная функция эликсира под названием Quoted Fragment, специально добавленный в язык ради генерации кода во время компиляциию Например:


defmodule Squares do  Enum.each(1..42, fn i ->    def unquote(:"squared_#{i}")(),      do: unquote(i) * unquote(i)  end)endSquares.squared_5# 25

Quoted Fragments автоматически распознаются компилятором внутри блоков quote, с напрямую переданным контекстом (bind_quoted:). Проще простого.


defmacro __using__(opts) do  keys = Keyword.keys(opts[:fields])  quote location: :keep, bind_quoted: [keys: keys] do    type =      {:%{}, [],        [          {:__struct__, {:__MODULE__, [], ruby}},          {:version, {:atom, [], []}}          | Enum.zip(keys, Stream.cycle([{:atom, [], []}]))        ]}    #              @type t :: unquote(type)    defstruct keys  endend

Одинокий вызов unquote/1 тут разрешен, потому что bind_quoted: был напрямую указан как первый аргумент в вызове quote/2.




Удачного внедрения!

Подробнее..
Категории: Open source , Elixir/phoenix , Erlang/otp , Injection , Macros , Macro

Recovery mode Типы в рантайме глубже в крольчью нору

19.11.2020 10:11:44 | Автор: admin

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


Все, что предложено по ссылке, будет работать для явных определений типа по месту использования, наподобие use Foo, var: type(). К сожалению, такой подход обречен, если мы хотим определить типы где-нибудь в другом месте: рядом в коде при помощи атрибутов модуля, или, там, в конфиге. Например, для определения структуры мы можем захотеть написать что-то типа такого:


# @fields [foo: 42]# defstruct @fields@definition var: atom()use Foo, @definition

Lighthouse in French Catalonia


Код выше не то, что не обработает тип так, как нам хочетсяон не соберется вовсе, потому что @definition var: atom() выбросит исключение ** (CompileError) undefined function atom/0.


Наивный подход


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


Итак, я начал с того, что сделал две разных реализации __using__/1: одну, которая принимает список (и ожидает увидеть в нем пары field type()), и другую принимающую все, что угодно, ожидая встретить в аргументах либо квотированные типы, либо триплы {Module, :type, [params]}. Я использовал сигил ~q||, который был услужливо имплементирован мной же, в одном из стародавних игрушечных проектов, во времена, когда я учился работать с макросами и AST. Он позволяет вместо quote/1 писать лаконичнее: foo: ~q|atom()|. Там внутри я руками строил список, который потом передавался в первую функцию, принимающую списки. Весь этот код был настоящим кошмаром. Я сомневаюсь, что видел что-то более невнятное за всю свою карьеру, несмотря на то, что я чувствую себя абсолютно комфортно с регулярными выражениями, они мне нравятся, и я их часто использую. Однажды я выиграл спор на воспроизведение регулярного выражения для электронной почты максимально близко к оригиналу, но этот код, всего-то передававший туда-сюда старый добрый простой эрланговский тип оказался в пять раз запутаннее и как-то неаккуратнее, что ли.


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


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


Tyyppi


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


В ядре эликсира присутствует незадокументированный модуль Code.Typespec, который существенно облегчил мне жизнь. Я начал с очень простого подхода: с проверки всех возможных термов по всем возможным типам. Я просто загрузил все типы, доступные в моей текущей сессии, и дописывал новые обработчики по мере того, как рекурсивный анализ типов падал глубже по рекурсии. Честно говоря, это было скорее скучно, чем весело. Зато оно привело меня к первой полезной части этой библиотекифункции Tyyppi.of?/2, которая принимает тип и терм, а возвращает логическое значение да/нет в зависимости от того, принадлежит ли терм указанному типу.


iex|tyyppi|1  Tyyppi.of? GenServer.on_start(), {:ok, self()}# trueiex|tyyppi|2  Tyyppi.of? GenServer.on_start(), :ok# false

Мне нужно было какое-то внутреннее представление для типов, поэтому я решил хранить все в виде структуры с именем Tyyppi.T. Так у Tyyppi.of?/2 появился брат-близнец Tyyppi.of_type?/2.


iex|tyyppi|3  type = Tyyppi.parse(GenServer.on_start)iex|tyyppi|4  Tyyppi.of_type? type, {:ok, self()}# true

Единственный нюанс, связанный с этим подходом, заключается в том, что мне нужно загрузить и сохранить все типы, доступные в системе, и эта информация не будет доступна в релизах. На данный момент я прекрасно справляюсь с хранением всего этого в обычном файле при помощи :erlang.term_to_binary/1, который связывается с релизом и загружается через обычный специализированный Config.Provider.


Структуры


Теперь я был полностью вооружен, чтобы вернуться к своей первоначальной задаче: создать удобный способ объявления типизированной структуры. Со всем этим багажом на борту, это было легко. Я решил ограничить само объявление структуры явным встроенным литералом, содержащим пары key: type(). Также я реализовал для него Access, с проверкой типов при upserts. Имея все это под рукой, я решил позаимствовать еще пару идей у Ecto.Changeset и добавил перегружаемые функции cast_field/1 и validate/1.


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


defmodule MyStruct do  import Kernel, except: [defstruct: 1]  import Tyyppi.Struct, only: [defstruct: 1]  @typedoc "The user type defined before `defstruct/1` declaration"  @type my_type :: :ok | {:error, term()}  @defaults foo: :default,            bar: :erlang.list_to_pid('<0.0.0>'),            baz: {:error, :reason}  defstruct foo: atom(), bar: GenServer.on_start(), baz: my_type()  def cast_foo(atom) when is_atom(atom), do: atom  def cast_foo(binary) when is_binary(binary),    do: String.to_atom(binary)  def validate(%{foo: :default} = my_struct), do: {:ok, my_struct}  def validate(%{foo: foo} = my_struct), do: {:error, {:foo, foo}end

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


Весь код библиотеки доступен, как всегда, на гитхабе.




Удачного рантаймтайпинга!

Подробнее..
Категории: Open source , Elixir/phoenix , Erlang/otp , Injection , Macros , Macro

Категории

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

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru