Рекомендации по созданию библиотек
В этом документе изложены общие рекомендации, антипаттерны и правила для авторов и издателей библиотек Elixir, предназначенных для использования другими разработчиками.
Начало работы
Вы можете создать новую библиотеку Elixir, выполнив команду mix new:
$ mix new my_library
Имя проекта задаётся в соответствии с соглашением snake_case, где все буквы строчные, а слова разделяются нижним подчёркиванием. Это то же соглашение, что используется для переменных, имён функций и атомов в Elixir. Дополнительную информацию см. в документе Соглашения об именовании.
В каждом проекте есть файл mix.exs, содержащий инструкции по сборке, компиляции, запуску тестов и т. д. Библиотеки обычно содержат директорию lib, в которую входит исходный код Elixir, и директорию test. Также может существовать директория src для исходного кода Erlang.
Дополнительную информацию о запуске проекта см. в официальном руководстве Mix & OTP или документации Mix Mix.
Приложения с деревом управления
Команда mix new также позволяет использовать опцию --sup для создания приложения с деревом управления «из коробки». Мы подробнее поговорим о деревьях управления позже, когда будем обсуждать один из распространённых антипаттернов при написании библиотек.
Публикация
Написание кода — лишь первый из многих шагов для публикации пакета. Мы настоятельно рекомендуем разработчикам:
-
Выбрать схему версионирования. Elixir требует, чтобы версии имели формат
MAJOR.MINOR.PATCH, но значение этих чисел зависит от вас. Большинство проектов выбирают Семантическое версионирование. -
Выбрать лицензию. Наиболее распространённые лицензии в сообществе Elixir — это MIT License и Apache 2.0 License. Последняя также используется самим Elixir.
-
Запустить форматтер кода. Форматтер кода форматирует ваш код в соответствии со согласованным стилем, используемым вашей библиотекой и всем сообществом, что облегчает понимание вашего кода и участие в нём других разработчиков.
-
Написать тесты. Elixir поставляется с тестовой системой, называемой ExUnit. Проект, сгенерированный командой
mix new, включает примеры тестов и doctests. -
Написать документацию. Сообщество Elixir гордится тем, что рассматривает документацию как важнейшую составляющую и делает её легко доступной. Библиотеки поддерживают эту традицию, предоставляя полную API-документацию с примерами для своих модулей, типов и функций. Дополнительную информацию см. в руководстве Написание документации. Такие проекты, как ExDoc, могут использоваться для генерации HTML и EPUB-документов из документации. ExDoc также поддерживает «дополнительные страницы», такие как эта, которую вы читаете. Такие страницы дополняют документацию руководствами, справочниками и ссылками.
Проекты часто предоставляются другим разработчикам путём публикации пакета Hex. Hex также поддерживает частные пакеты для организаций. Если ExDoc настроен для проекта Mix, то при публикации пакета на Hex автоматически будет опубликована сгенерированная документация на HexDocs.
Антипаттерны
В этом разделе описаны распространённые антипаттерны, которых следует избегать при написании библиотек.
Избегайте использования исключений для управления потоком
Следует избегать использования исключений для управления потоком. Например, вместо:
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
В случае необходимости конфигурации процесса параметры должны передаваться при запуске этого процесса.
Среда приложения должна использоваться только для конфигурации, которая действительно является глобальной, например, для управления процессом запуска приложения и его деревом управления.
Во всех остальных случаях библиотеки не должны заставлять пользователей использовать среду приложения для конфигурации. Если пользователь библиотеки считает, что определённый параметр должен быть настроен глобально, то он может обернуть функциональность библиотеки собственной конфигурацией среды приложения.
Избегайте импорта, когда достаточно импорта
Библиотека не должна предоставлять функциональность импорта, если всё, что она делает, — это импортировать/экспортировать сам модуль. Например, это антипаттерн:
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 следует отказаться, если всё, что она делает, — это импортировать или экспортировать модуль. Короче говоря, alias проще и понятнее, чем import, а import проще и понятнее, чем use.
Избегайте использования макросов
Хотя предыдущий раздел можно было бы сформулировать как «избегайте макросов», обе темы достаточно важны, чтобы заслужить свои собственные разделы.
Цитата из официального руководства по макросам:
Несмотря на то, что Elixir стремится предоставить безопасную среду для макросов, основная ответственность за написание чистого кода с макросами лежит на разработчиках. Макросы сложнее писать, чем обычные функции Elixir, и считается плохим стилем использовать их, когда они не нужны. Поэтому используйте макросы ответственно.
Elixir уже предоставляет механизмы для написания повседневного кода простым и читаемым способом с использованием его структур данных и функций. Макросы следует использовать только в крайнем случае. Помните, что явное лучше неявного. Ясный код лучше лаконичного.
Когда вам абсолютно необходимо использовать макрос, убедитесь, что макрос не является единственным способом взаимодействия пользователя с вашей библиотекой, и минимизируйте количество кода, генерируемого макросом. Например, модуль Logger предоставляет debug/2, 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
# List all child processes to be supervised
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
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.8.2/library-guidelines.html