Рекомендации по разработке библиотек
В данном документе изложены общие рекомендации, антипаттерны и правила для разработчиков и публикующих 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.
Управление зависимостями
Когда ваша библиотека публикуется и используется в качестве зависимости, её файл блокировки (обычно с именем 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
Если вам нужно настроить процесс, параметры должны передаваться при запуске этого процесса.
Среда приложения должна использоваться только для конфигурации, которая действительно является глобальной, например, для управления процессом запуска вашего приложения и его деревом управления. И, как правило, лучше избегать глобальной конфигурации. Если вам необходимо использовать конфигурацию, предпочтительнее использовать конфигурацию во время выполнения, а не во время компиляции. Дополнительную информацию см. в модуле Application.
Во всех остальных случаях библиотеки не должны заставлять своих пользователей использовать среду приложения для конфигурации. Если пользователь библиотеки считает, что определённый параметр должен быть настроен глобально, то он может обернуть функциональность библиотеки своей собственной конфигурацией среды приложения.
Избегайте определения модулей, которые не находятся в вашем пространстве имён
Несмотря на то, что в Elixir формально нет понятия пространств имён, библиотека должна использовать своё имя в качестве «префикса» для всех своих модулей (за исключением специальных случаев, таких как задачи mix). Например, если имя приложения OTP библиотеки — :my_lib, то все её модули должны начинаться с префикса MyLib, например, MyLib.User, MyLib.SubModule, и MyLib.Application.
Это важно, потому что виртуальная машина Erlang может загрузить только один экземпляр модуля за раз. Поэтому, если несколько библиотек определяют один и тот же модуль, они несовместимы друг с другом из-за этого ограничения. Использование имени библиотеки в качестве префикса позволяет избежать конфликтов имён модулей благодаря уникальному префиксу.
Кроме того, при написании библиотеки, являющейся расширением другой библиотеки, следует избегать определения модулей внутри пространства имён родительской библиотеки. Например, если вы пишете пакет, добавляющий аутентификацию к 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 SomeModule. Преимущество use SomeModule заключается в том, что оно предоставляет общую точку расширения для экосистемы Elixir. Однако, учитывая, что use SomeModule может выполнить любой код, разработчикам может быть трудно понять влияние use SomeModule.
По этой причине, чтобы обеспечить руководство и ясность, мы рекомендуем авторам библиотек включать блок замечаний в их @moduledoc с объяснением того, как use SomeModule влияет на код разработчика. В качестве примера, документация GenServer описывает:
use GenServerКогда вы
use GenServer, модульGenServerустановит@behaviour GenServerи определит функциюchild_spec/1, поэтому ваш модуль можно использовать как дочерний элемент в дереве надзора.
Представьте себе этот свод как «сведения о питательной ценности» для генерации кода. Имейте в виду, что нужно указывать только изменения, внесённые в публичный API модуля. Например, если use SomeModule устанавливает внутреннее свойство, называемое @_some_module_info, и это свойство никогда не должно быть публичным, его не нужно указывать.
Для удобства, разметка для создания блока замечаний, приведённого выше, имеет вид:
> #### `use GenServer` {: .info}
>
> When you `use GenServer`, the `GenServer` module will
> set `@behaviour GenServer` and define a `child_spec/1`
> function, so your module can be used as a child
> in a supervision tree.
Избегайте макросов
Хотя предыдущий раздел можно было бы сформулировать как «избегайте макросов», обе темы достаточно важны, чтобы заслуживать собственных разделов.
Цитируя официальное руководство по макросам:
Несмотря на то, что 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.await/2 после запуска задачи с помощью Task.async/1. Даже если ваше приложение запускает несколько асинхронных процессов, следует рассмотреть использование Task.Supervisor для лучшей наглядности при инструментировании и мониторинге системы.
© 2012 Plataformatec
Licensed under the Apache License, Version 2.0.
https://hexdocs.pm/elixir/1.15.4/library-guidelines.html