Руководство по процессно-ориентированному программированию в Elixir и OTP

Опубликовано: 2022-03-11

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

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

  • В языках OO это класс или объект как способ инкапсулировать состояние (данные) с манипулированием этим состоянием (методами).

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

Хотя Elixir (и Erlang до него) часто классифицируют как функциональные языки, потому что они демонстрируют неизменяемые данные, общие для функциональных языков, я бы сказал, что они представляют собой отдельную парадигму от многих функциональных языков . Они существуют и приняты из-за существования OTP, поэтому я бы отнес их к категории процессно-ориентированных языков .

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

Что такое процессно-ориентированное программирование?

Давайте начнем с определения: процессно-ориентированное программирование — это парадигма, основанная на обмене последовательными процессами, изначально взятая из статьи Тони Хоара в 1977 году. Ее также часто называют акторной моделью параллелизма. Другие языки, имеющие некоторое отношение к этой оригинальной работе, включают Occam, Limbo и Go. Официальный документ касается только синхронной связи; большинство моделей акторов (включая OTP) также используют асинхронную связь. Всегда можно построить синхронную связь поверх асинхронной связи, и OTP поддерживает обе формы.

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

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

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

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

Процессно-ориентированное программирование: пример диаграммы классов UML

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

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

Функциональное и процессно-ориентированное программирование

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

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

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

Elixir/OTP и процессно-ориентированное программирование

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

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

Что значит быть ориентированным на процесс?

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

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

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

Последствия обучения

Значение этой категоризации для обучения заключается в том, что обучение должно начинаться не с языкового синтаксиса или примеров «Hello World», а с системного инженерного мышления и проектирования с акцентом на распределение процессов .

Проблемы кодирования вторичны по отношению к проектированию и распределению процессов, которые лучше всего решать на более высоком уровне и включают кросс-функциональное мышление о жизненном цикле, обеспечении качества, DevOps и бизнес-требованиях клиентов. Любой учебный курс по Elixir или Erlang должен (и обычно включает) включать OTP и с самого начала должен иметь ориентированность на процесс, а не подход типа «Теперь вы можете кодировать на Elixir, так что давайте сделаем параллелизм».

Последствия принятия

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

Для документации и проектных работ может быть очень полезно использовать графическую нотацию (например, рисунок 1 для объектно-ориентированных языков). Предложением для Elixir и процессно-ориентированного программирования из UML была бы диаграмма последовательности (пример на рисунке 2), чтобы показать временные отношения между процессами и определить, какие процессы участвуют в обслуживании запроса. Типа диаграммы UML для описания жизненного цикла и структуры процесса не существует, но его можно представить в виде простой диаграммы со стрелкой для типов процессов и их взаимосвязей. Например, рисунок 3:

Образец процессно-ориентированного программирования UML-диаграмма последовательности

Структурная схема примера процессно-ориентированного программирования

Пример ориентации процесса

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

Первоначальный дизайн процесса и распределение

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

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

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

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

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

Пример разработки, ориентированной на процесс: первоначальный проект процесса

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

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

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

Дополнительные требования

Теперь давайте добавим некоторые усложняющие требования. Предположим, что в каждой юрисдикции (стране или штате) подведение итогов голосования может привести к пропорциональному результату, результату «победитель получает все» или к отсутствию результата, если подано недостаточное количество голосов относительно населения этой юрисдикции. Каждая юрисдикция контролирует эти аспекты. С этим изменением результаты стран представляют собой не просто агрегирование необработанных результатов голосования, а агрегирование результатов штата/провинции. Это изменяет распределение процессов по сравнению с исходным, чтобы требовать, чтобы результаты процессов штата/области передавались в процессы страны. Если протокол, используемый между сбором голосов и процессами от штата/провинции и от провинции к стране, один и тот же, то логику агрегирования можно использовать повторно, но необходимы разные процессы, содержащие результаты, и пути их связи разные, как показано на рисунке. 5.

Пример разработки, ориентированной на процесс: измененный дизайн процесса

Код

Чтобы завершить пример, мы рассмотрим реализацию примера в Elixir OTP. Для упрощения в этом примере предполагается, что веб-сервер, такой как Phoenix, используется для обработки реальных веб-запросов, и эти веб-службы отправляют запросы к процессу, указанному выше. Преимущество этого заключается в упрощении примера и сохранении фокуса на Elixir/OTP. В производственной системе наличие отдельных процессов имеет некоторые преимущества, а также разделяет задачи, обеспечивает гибкое развертывание, распределяет нагрузку и снижает задержку. Полный исходный код с тестами можно найти по адресу https://github.com/technomage/voting. Источник сокращен в этом посте для удобства чтения. Каждый описанный ниже процесс вписывается в дерево контроля OTP, чтобы гарантировать перезапуск процессов в случае сбоя. Подробнее об этом аспекте примера см. в источнике.

Регистратор голосов

Этот процесс получает голоса, записывает их в постоянное хранилище и отправляет результаты в агрегаторы. Модуль VoteRecoder использует Task.Supervisor для управления краткосрочными задачами по записи каждого голоса.

 defmodule Voting.VoteRecorder do @moduledoc """ This module receives votes and sends them to the proper aggregator. This module uses supervised tasks to ensure that any failure is recovered from and the vote is not lost. """ @doc """ Start a task to track the submittal of a vote to an aggregator. This is a supervised task to ensure completion. """ def cast_vote where, who do Task.Supervisor.async_nolink(Voting.VoteTaskSupervisor, fn -> Voting.Aggregator.submit_vote where, who end) |> Task.await end end

Агрегатор голосов

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

 defmodule Voting.Aggregator do use GenStage ... @doc """ Submit a single vote to an aggregator """ def submit_vote id, candidate do pid = __MODULE__.via_tuple(id) :ok = GenStage.call pid, {:submit_vote, candidate} end @doc """ Respond to requests """ def handle_call {:submit_vote, candidate}, _from, state do n = state.votes[candidate] || 0 state = %{state | votes: Map.put(state.votes, candidate, n+1)} {:reply, :ok, [%{state.id => state.votes}], state} end @doc """ Handle events from subordinate aggregators """ def handle_events events, _from, state do votes = Enum.reduce events, state.votes, fn e, votes -> Enum.reduce e, votes, fn {k,v}, votes -> Map.put(votes, k, v) # replace any entries for subordinates end end # Any jurisdiction specific policy would go here # Sum the votes by candidate for the published event merged = Enum.reduce votes, %{}, fn {j, jv}, votes -> # Each jourisdiction is summed for each candidate Enum.reduce jv, votes, fn {candidate, tot}, votes -> Logger.debug "@@@@ Votes in #{inspect j} for #{inspect candidate}: #{inspect tot}" n = votes[candidate] || 0 Map.put(votes, candidate, n + tot) end end # Return the published event and the state which retains # Votes by jourisdiction {:noreply, [%{state.id => merged}], %{state | votes: votes}} end end

Ведущий результатов

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

 defmodule Voting.ResultPresenter do use GenStage … @doc """ Handle requests for results """ def handle_call :get_votes, _from, state do {:reply, {:ok, state.votes}, [], state} end @doc """ Obtain the results from this presenter """ def get_votes id do pid = Voting.ResultPresenter.via_tuple(id) {:ok, votes} = GenStage.call pid, :get_votes votes end @doc """ Receive votes from aggregator """ def handle_events events, _from, state do Logger.debug "@@@@ Presenter received: #{inspect events}" votes = Enum.reduce events, state.votes, fn v, votes -> Enum.reduce v, votes, fn {k,v}, votes -> Map.put(votes, k, v) end end {:noreply, [], %{state | votes: votes}} end end

Забрать

В этом посте мы рассмотрели потенциал Elixir/OTP как процессно-ориентированного языка, сравнили его с объектно-ориентированной и функциональной парадигмами и рассмотрели последствия этого для обучения и внедрения.

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

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