Рекомендации по разработке библиотек
В данном документе изложены общие рекомендации, антипаттерны и правила для тех, кто пишет и публикует библиотеки 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.
Запустить форматировщик кода code formatter. Форматировщик кода форматирует код в соответствии со стилем, применяемым в вашей библиотеке и во всем сообществе, что делает код более понятным для других разработчиков и упрощает сотрудничество.
Написать тесты. Elixir поставляется с фреймворком для тестирования под названием ExUnit. Сгенерированный проектом с помощью
mix newвключает примеры тестов и doctest.Написать документацию. Сообщество Elixir гордится тем, что рассматривает документацию как важный аспект и делает её легко доступной. Библиотеки поддерживают этот подход, предоставляя полную API-документацию с примерами для своих модулей, типов и функций. Более подробную информацию можно найти в руководстве Написание документации. Такие проекты, как ExDoc, могут использоваться для генерации HTML- и EPUB-документов из документации. ExDoc также поддерживает «дополнительные страницы», такие как эта, которую вы сейчас читаете. Эти страницы дополняют документацию руководствами, учебниками и справочниками.
Проекты часто доступны другим разработчикам путём публикации пакета Hex. Hex также поддерживает частные пакеты для организаций. Если для проекта Mix настроен ExDoc, публикация пакета в Hex также автоматически опубликует сгенерированную документацию в HexDocs.
Управление зависимостями
Когда ваша библиотека опубликована и используется как зависимость, её файл блокировки (обычно с именем mix.lock) игнорируется проектом-хостом. Запуск команды mix deps.get в проекте-хосте пытается получить последние возможные версии зависимостей вашей библиотеки, как указано в разделе deps вашего файла mix.exs. Эти версии могут быть больше, чем те, что хранятся в вашем mix.lock (и, следовательно, используемые в ваших тестах/CI).
С другой стороны, разработчики вашей библиотеки нуждаются в детерминированной сборке, что предполагает наличие mix.lock в вашей системе контроля версий (VCS).
Лучшей практикой для обработки файла mix.lock является хранение его в системе контроля версий и выполнение двух разных потоков непрерывной интеграции (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 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 указывает, что у нас есть возможность вообще не 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.await/2 после запуска задачи с помощью Task.async/1. Однако, если ваше приложение запускает несколько асинхронных процессов, следует рассмотреть использование Task.Supervisor для лучшей видимости при инструментировании и мониторинге системы.
© 2012 Plataformatec
Licensed under the Apache License, Version 2.0.
https://hexdocs.pm/elixir/1.14.1/library-guidelines.html