Руководящие принципы библиотек
В этом документе описаны общие рекомендации, антипаттерны и правила для тех, кто пишет и публикует библиотеки Elixir, предназначенные для использования другими разработчиками.
Начало работы
Вы можете создать новую библиотеку Elixir, выполнив команду mix new:
$ mix new my_library
Имя проекта указано в формате snake_case, где все буквы строчные, а слова разделены нижним подчеркиванием. Это тот же формат, который используется для переменных, имён функций и атомов в Elixir. Более подробную информацию см. в документе Правила именования.
В каждом проекте есть файл mix.exs, содержащий инструкции по сборке, компиляции, запуску тестов и т. д. Библиотеки обычно содержат директорию lib, которая включает исходный код Elixir, и директорию test. Также может существовать директория src для исходных кодов Erlang.
Дополнительную информацию о запуске проекта см. в официальном руководстве Mix & OTP или в документации Mix.
Приложения с деревом наблюдения
Команда mix new также позволяет использовать параметр --sup для создания приложения с деревом наблюдения по умолчанию. Мы поговорим о деревьях наблюдения позже, когда будем обсуждать один из распространённых антипаттернов при написании библиотек.
Публикация
Написание кода — это только первый из многих шагов к публикации пакета. Мы настоятельно рекомендуем разработчикам:
Выбрать схему версионирования. Elixir требует версий в формате
MAJOR.MINOR.PATCH, но значение этих чисел зависит от вас. Большинство проектов выбирают Семантическое версионирование.Выбрать лицензию. Наиболее распространёнными лицензиями в сообществе Elixir являются MIT License и Apache License 2.0. Последняя также используется самим Elixir.
Выполнить форматирование кода. Форматировщик кода форматирует код в соответствии с единым стилем, используемым в вашей библиотеке и всем сообществе, что упрощает другим разработчикам понимание и внесение изменений в ваш код.
Написать тесты. Elixir поставляется с тестовой средой под названием ExUnit. Проект, сгенерированный командой
mix new, содержит примеры тестов и doctests.Написать документацию. Сообщество Elixir гордится тем, что рассматривает документацию как элемент первой важности и делает её легко доступной. Библиотеки способствуют сохранению статуса, предоставляя полную документацию API с примерами для своих модулей, типов и функций. Подробности см. в руководстве Написание документации. Проекты, такие как ExDoc, могут использоваться для создания HTML и EPUB-документов из документации. ExDoc также поддерживает «дополнительные страницы», такие как эта, которую вы сейчас читаете. Эти страницы дополняют документацию руководствами, справочниками и ссылками.
Проекты часто предоставляются другим разработчикам путём публикации пакета Hex. Hex также поддерживает частные пакеты для организаций. Если ExDoc настроен для проекта Mix, публикация пакета в Hex также автоматически опубликует сгенерированную документацию в HexDocs.
Обработка зависимостей
Когда ваша библиотека опубликована и используется в качестве зависимости, её файл lockfile (обычно имеющий имя mix.lock) игнорируется проектом-хостом. Выполнение команды mix deps.get в проекте-хосте пытается получить самые последние версии зависимостей вашей библиотеки, как указано в разделе deps вашего файла mix.exs. Эти версии могут быть больше, чем те, которые хранятся в вашем файле mix.lock (и, следовательно, используются в ваших тестах/CI).
С другой стороны, авторы вашей библиотеки нуждаются в детерминированной сборке, что подразумевает наличие mix.lock в вашей системе управления версиями (VCS).
Лучшей практикой обработки файла mix.lock является его хранение в VCS и запуск двух различных рабочих процессов непрерывной интеграции (CI): обычного детерминированного и другого, который начинается с mix deps.unlock --all и всегда компилирует вашу библиотеку и запускает тесты с использованием последних версий зависимостей. Последний может запускаться даже каждую ночь или по другому расписанию, чтобы своевременно получать уведомления о любых возможных проблемах с обновлениями зависимостей.
Антипаттерны
В этом разделе описаны распространённые антипаттерны, которых следует избегать при написании библиотек.
Избегайте использования исключений для управления потоком
Следует избегать использования исключений для управления потоком. Например, вместо:
try do
contents = File.read!("some_path_that_may_or_may_not_exist")
{:it_worked, contents}
rescue
File.Error ->
:it_failed
end
предпочтительнее использовать:
case File.read("some_path_that_may_or_may_not_exist") do
{:ok, contents} -> {:it_worked, contents}
{:error, _} -> :it_failed
end
Как автору библиотеки, вы несете ответственность за то, чтобы пользователи не были вынуждены использовать исключения для управления потоком в своих приложениях. Вы можете следовать той же соглашению, что и в Elixir, используя имя без ! для возврата :ok/:error кортежей и добавляя ! для версии функции, которая вызывает исключение.
Важно отметить, что имя без ! не означает, что функция никогда не вызовет исключение. Например, даже File.read/1 может завершиться сбоем в случае неправильных аргументов:
iex> File.read(1) ** (FunctionClauseError) no function clause matching in IO.chardata_to_string/1
Использование :ok/:error кортежей связано с областью, в которой работает функция, в данном случае с доступом к файловой системе. Неправильные аргументы, логические ошибки, недопустимые параметры должны вызывать исключения независимо от имени функции. В случае сомнений, предпочитайте возвращать кортежи вместо вызова исключений, так как пользователи вашей библиотеки всегда могут обрабатывать результаты и вызывать исключение при необходимости.
Избегайте работы с недопустимыми данными
Программы Elixir должны предпочитать проверять данные как можно ближе к конечному пользователю, чтобы ошибки было легко обнаружить и исправить. Это также экономит время на написании защищённого кода в внутренней части библиотеки.
Например, представьте, что у вас есть API, который получает имя файла в виде бинарного значения. В какой-то момент вам захочется записать в этот файл. У вас может быть функция такого вида:
def my_fun(some_arg, file_to_write_to, options \\ []) do ...some code... AnotherModuleInLib.invoke_something_that_will_eventually_write_to_file(file_to_write_to) ...more code... end
Проблема с приведенным выше кодом заключается в том, что, если пользователь предоставляет недопустимый ввод, ошибка будет вызвана глубоко внутри библиотеки, что затрудняет понимание для пользователей. Кроме того, когда вы не проверяете значения на границе, внутренняя часть вашей библиотеки никогда не уверена в том, с какими типами значений она работает.
Более подходящим определением функции будет:
def my_fun(some_arg, file_to_write_to, options \\ []) when is_binary(file_to_write_to) do
Elixir также использует сопоставление с образцом и условные выражения в функциях для предоставления чётких сообщений об ошибках в случае ввода неправильных аргументов.
Этот совет относится не только к библиотекам, но и ко всему коду Elixir. Всякий раз, когда вы получаете несколько опций или работаете с внешними данными, вы должны проверять данные на границе и преобразовывать их в структурированные данные. Например, если вы предоставляете GenServer, который может быть запущен с несколькими параметрами, вы хотите проверить эти параметры при запуске сервера и полагаться только на структурированные данные на протяжении всего жизненного цикла процесса. Точно так же, если база данных или сокет возвращают вам карту строк, после получения данных вы должны их проверить и, возможно, преобразовать в структуру или карту атомов.
Избегайте конфигурации приложения
Следует избегать использования среды приложения (см. Application.get_env/2) в качестве механизма конфигурации библиотек. Среда приложения является глобальной, что делает невозможным для двух зависимостей использовать вашу библиотеку по-разному.
Рассмотрим простой пример. Представьте, что вы реализуете библиотеку, которая разделяет строку на две части по первому вхождению символа тире -:
defmodule DashSplitter do
def split(string) when is_binary(string) do
String.split(string, "-", parts: 2)
end
end
Теперь предположим, что кто-то хочет разделить строку на три части. Вы решаете сделать количество частей настраиваемым через среду приложения:
def split(string) when is_binary(string) do parts = Application.get_env(:dash_splitter, :parts, 2) String.split(string, "-", parts: parts) end
Теперь пользователи могут настроить вашу библиотеку в своём файле config/config.exs следующим образом:
config :dash_splitter, :parts, 3
После настройки библиотеки поведение всех пользователей вашей библиотеки изменится. Если библиотека ожидала разделения строки на 2 части, то из-за глобальной конфигурации она теперь будет разделять её на 3 части.
Решение состоит в том, чтобы предоставлять конфигурацию как можно ближе к месту её использования, а не через среду приложения. В случае функции вы могли бы ожидать списки ключевых слов в качестве нового аргумента:
def split(string, opts \\ []) when is_binary(string) and is_list(opts) do parts = Keyword.get(opts, :parts, 2) String.split(string, "-", parts: parts) end
В случае необходимости настройки процесса параметры должны передаваться при запуске этого процесса.
Среда приложения должна использоваться только для глобальных настроек, например, для управления процессом запуска вашего приложения и его деревом наблюдения.
Во всех остальных случаях библиотеки не должны заставлять своих пользователей использовать среду приложения для конфигурации. Если пользователь библиотеки считает, что определённый параметр должен быть настроен глобально, то он может обернуть функциональность библиотеки своей собственной конфигурацией среды приложения.
Избегайте конфигурации приложения во время компиляции
Предположим, вам нужно использовать конфигурацию приложения, и вы не можете этого избежать, как описано в предыдущем разделе. Вы также должны избегать конфигурации приложения во время компиляции. Например, вместо этого:
@http_client Application.fetch_env!(:my_app, :http_client) def request(path) do @http_client.request(path) end
вы должны сделать так:
def request(path) do http_client().request(path) end defp http_client() do Application.fetch_env!(:my_app, :http_client) end
Это потому, что при чтении конфигурации приложения в теле модуля и её хранении в атрибуте модуля мы фактически читаем конфигурацию во время компиляции, что может стать проблемой при последующей настройке системы.
Если по какой-либо причине вам необходимо прочитать среду приложения во время компиляции, используйте Application.compile_env/2. Подробности см. в разделе «Среда компиляции» документации модуля Application.
Избегайте определения модулей, которые не находятся в вашем "пространстве имён"
Несмотря на то, что в Elixir формально нет понятия пространств имён, библиотека должна использовать своё имя как "префикс" для всех своих модулей (за исключением специальных случаев, таких как задачи mix). Например, если имя приложения OTP библиотеки — :my_lib, то все её модули должны начинаться с префикса MyLib, например, MyLib.User, MyLib.SubModule, и MyLib.Application.
Это важно, потому что Erlang VM может загрузить только один экземпляр модуля за раз. Если несколько библиотек определяют один и тот же модуль, они будут несовместимы из-за этого ограничения. Использование имени библиотеки в качестве префикса позволяет избежать конфликтов имён модулей благодаря уникальному префиксу.
Кроме того, при написании библиотеки, являющейся расширением другой библиотеки, следует избегать определения модулей внутри пространства имён родительской библиотеки. Например, если вы пишете пакет, добавляющий аутентификацию к Plug, называемый plug_auth, его модули должны быть расположены в пространстве имён PlugAuth вместо Plug.Auth, чтобы избежать конфликтов с Plug, если она когда-нибудь определит свою собственную функциональность аутентификации.
Избегайте use, если достаточно import
Библиотека не должна предоставлять use MyLib функциональность, если всё, что она делает, это import/alias сам модуль. Например, это антипаттерн:
defmodule MyLib do
defmacro __using__(_) do
quote do
import MyLib
end
end
def some_fun(arg1, arg2) do
...
end
end
Причина, по которой следует избегать определения макроса __using__ выше, заключается в том, что когда разработчик пишет:
defmodule MyApp do use MyLib end
это позволяет use MyLib запускать любой код в модуле MyApp. Для человека, читающего код, невозможно оценить влияние use MyLib на модуль без просмотра реализации __using__.
Следующий код более понятен:
defmodule MyApp do import MyLib end
В данном коде указано, что мы импортируем только функции из MyLib, чтобы вызывать some_fun(arg1, arg2) напрямую без префикса MyLib.. Ещё важнее то, что import MyLib указывает, что у нас есть возможность вообще не импортировать, так как мы можем просто вызвать функцию как MyLib.some_fun(arg1, arg2).
Если у модуля, на котором нужно вызвать функцию, длинное имя, например, SomeLibrary.Namespace.MyLib, и вы считаете его громоздким, вы можете использовать специальную форму alias/2 и по-прежнему ссылаться на модуль как на MyLib.
Хотя в некоторых ситуациях use SomeModule необходима, use следует пропускать, если она только импортирует или import или alias другие модули. Короче говоря, alias предпочтительнее, так как она проще и понятнее, чем import, в то время как import проще и понятнее, чем use.
Избегайте макросов
Хотя предыдущий раздел можно было бы обобщить как "избегайте макросов", обе темы достаточно важны, чтобы заслуживать отдельных разделов.
Цитируя официальное руководство по макросам:
Хотя Elixir пытается обеспечить безопасную среду для макросов, основная ответственность за написание чистого кода с макросами лежит на разработчиках. Макросы сложнее писать, чем обычные функции Elixir, и их использование считается плохой практикой, когда это не обязательно. Поэтому используйте макросы ответственно.
Elixir уже предоставляет механизмы для написания повседневного кода простым и удобочитаемым способом с использованием его структур данных и функций. Макросы следует использовать только в крайнем случае. Помните, что явное лучше неявного. Чистый код лучше краткого кода.
Если вам абсолютно необходимо использовать макрос, убедитесь, что макрос не является единственным способом взаимодействия пользователя с вашей библиотекой, и минимизируйте количество кода, генерируемого макросом. Например, модуль Logger предоставляет Logger.debug/2, Logger.info/2 и другие как макросы, способные извлекать информацию из окружения, но низкоуровневый механизм для ведения журнала всё ещё доступен с помощью Logger.bare_log/3.
Избегайте использования процессов для организации кода
Разработчик никогда не должен использовать процесс в целях организации кода. Процесс необходимо использовать для моделирования свойств времени выполнения, таких как:
- Изменяемое состояние и доступ к общим ресурсам (таким как ETS, файлы и другие)
- Конкурентность и распределённость
- Логика инициализации, завершения и перезапуска (как в супервизорах)
- Системные сообщения, такие как таймерные сообщения и события мониторинга
В Elixir организация кода выполняется с помощью модулей и функций, процессы не нужны. Например, представьте, что вы реализуете калькулятор и решили поместить все операции калькулятора за GenServer:
def add(a, b) do
GenServer.call(__MODULE__, {:add, a, b})
end
def handle_call({:add, a, b}, _from, state) do
{:reply, a + b, state}
end
def handle_call({:subtract, a, b}, _from, state) do
{:reply, a - b, state}
end
Это антипаттерн не только потому, что он усложняет логику калькулятора, но и потому, что вы помещаете логику калькулятора в один процесс, который потенциально может стать узким местом в вашей системе, особенно по мере роста количества вызовов. Вместо этого просто определите функции напрямую:
def add(a, b) do a + b end def subtract(a, b) do a - b end
Используйте процессы только для моделирования свойств времени выполнения, а не для организации кода. И даже когда вы считаете, что что-то можно сделать параллельно с помощью процессов, часто лучше позволить вызывающим сторонам вашей библиотеки решить, как распараллелить, а не навязывать определённый поток выполнения пользователям вашего кода.
Избегайте создания ненадзорных процессов
Следует избегать создания процессов за пределами дерева супервизора, особенно длительно работающих. Вместо этого процессы должны запускаться внутри деревьев супервизоров. Это гарантирует, что разработчики имеют полный контроль над инициализацией, перезапуском и завершением работы системы.
Если в вашем приложении нет дерева супервизора, его можно добавить, изменив def application в mix.exs так, чтобы добавить ключ :mod с именем обратного вызова приложения:
def application do
[
extra_applications: [:logger],
mod: {MyApp.Application, []}
]
end
а затем определите файл my_app/application.ex со следующей структурой:
defmodule MyApp.Application do
# See https://hexdocs.pm/elixir/Application.html
# for more information on OTP Applications
@moduledoc false
use Application
def start(_type, _args) do
children = [
# Starts a worker by calling: MyApp.Worker.start_link(arg)
# {MyApp.Worker, arg}
]
# See https://hexdocs.pm/elixir/Supervisor.html
# for other strategies and supported options
opts = [strategy: :one_for_one, name: MyApp.Supervisor]
Supervisor.start_link(children, opts)
end
end
Это та же структура, сгенерированная mix new --sup.
Каждый процесс, запущенный с приложением, должен быть указан как дочерний элемент под Supervisor выше. Мы называем их "статическими процессами", потому что они известны заранее. Для обработки динамических процессов, таких как те, которые запускаются во время запросов и других пользовательских вводов, обратитесь к модулю DynamicSupervisor.
Один из немногих случаев, когда допустимо запускать процесс за пределами дерева супервизора, — это с использованием Task.async/1 и Task.await/2. В отличие от Task.start_link/1, механизм async/await даёт вам полный контроль над жизненным циклом запущенного процесса — именно поэтому после запуска задачи с помощью Task.async/1 вы всегда должны вызвать Task.await/2. Даже если ваше приложение запускает несколько асинхронных процессов, следует рассмотреть использование Task.Supervisor для лучшей видимости при инструментировании и мониторинге системы.
© 2012 Plataformatec
Licensed under the Apache License, Version 2.0.
https://hexdocs.pm/elixir/1.13.4/library-guidelines.html