Spec-Zone.ru › Elixir 1.16

Источник Антипаттерны, связанные с проектированием

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

Альтернативные типы возвращаемых значений

Проблема

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

Пример

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

defmodule AlternativeInteger do
  @spec parse(String.t(), keyword()) :: integer() | {integer(), String.t()} | :error
  def parse(string, options \\ []) when is_list(options) do
    if Keyword.get(options, :discard_rest, false) do
      Integer.parse(string)
    else
      case Integer.parse(string) do
        {int, _rest} -> int
        :error -> :error
      end
    end
  end
end
iex> AlternativeInteger.parse("13")
13
iex> AlternativeInteger.parse("13", discard_rest: true)
13
iex> AlternativeInteger.parse("13", discard_rest: false)
{13, ""}

Переработка

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

defmodule AlternativeInteger do
  @spec parse(String.t()) :: {integer(), String.t()} | :error
  def parse(string) do
    Integer.parse(string)
  end

  @spec parse_discard_rest(String.t()) :: integer() | :error
  def parse_discard_rest(string) do
    case Integer.parse(string) do
      {int, _rest} -> int
      :error -> :error
    end
  end
end
iex> AlternativeInteger.parse("13")
{13, ""}
iex> AlternativeInteger.parse_discard_rest("13")
13

Навязчивое использование булевых значений

Проблема

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

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

Пример

Пример этого антипаттерна — функция, которая получает две или более опций, такие как editor: true и admin: true, для конфигурирования своего поведения перекрывающимися способами. В приведенном ниже коде опция :editor не имеет эффекта, если установлена опция :admin, что означает, что опция :admin имеет более высокий приоритет, чем :editor, и они в конечном счёте взаимосвязаны.

defmodule MyApp do
  def process(invoice, options \\ []) do
    cond do
      options[:admin] ->  # Is an admin
      options[:editor] -> # Is an editor
      true ->          # Is none
    end
  end
end

Переработка

Вместо использования нескольких опций, код выше можно переработать, чтобы получить одну опцию, называемую :role, которая может быть либо :admin, либо :editor, либо :default.

defmodule MyApp do
  def process(invoice, options \\ []) do
    case Keyword.get(options, :role, :default) do
      :admin ->   # Is an admin
      :editor ->  # Is an editor
      :default -> # Is none
    end
  end
end

Этот антипаттерн также может встречаться в собственных структурах данных. Например, мы можем определить структуру User со двумя булевыми полями, :editor и :admin, в то время как одно поле с именем :role может быть предпочтительнее.

Наконец, стоит отметить, что использование атомов может быть предпочтительнее даже при наличии одного булевого аргумента/опции. Например, рассмотрим счёт, который может быть утверждён или не утверждён. Один вариант — предоставить функцию, которая ожидает булевое значение:

MyApp.update(invoice, approved: true)

Однако использование атомов может быть более читабельным и упростить добавление дополнительных состояний (например, ожидающий) в будущем:

MyApp.update(invoice, status: :approved)

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

Исключения для управления потоком

Проблема

Этот антипаттерн относится к коду, который использует исключения Exception для управления потоком. Обработка исключений сама по себе не является антипаттерном, но разработчики должны предпочесть использовать case и сопоставление с образцом для изменения потока своего кода вместо try/rescue. В свою очередь, авторы библиотек должны предоставлять разработчикам API для обработки ошибок без использования обработки исключений. Когда разработчики не имеют свободы в решении, является ли ошибка исключительной или нет, это рассматривается как антипаттерн.

Пример

Пример этого антипаттерна, как показано ниже, — использование try/rescue для обработки операций с файлами:

defmodule MyModule do
  def print_file(file) do
    try do
      IO.puts(File.read!(file))
    rescue
      e -> IO.puts(:stderr, Exception.message(e))
    end
  end
end
iex> MyModule.print_file("valid_file")
This is a valid file!
:ok
iex> MyModule.print_file("invalid_file")
could not read file "invalid_file": no such file or directory
:ok

Переработка

Чтобы переработать этот антипаттерн, как показано ниже, используйте File.read/1, которая возвращает кортежи вместо возбуждения исключения, когда файл не может быть прочитан:

defmodule MyModule do
  def print_file(file) do
    case File.read(file) do
      {:ok, binary} -> IO.puts(binary)
      {:error, reason} -> IO.puts(:stderr, "could not read file #{file}: #{reason}")
    end
  end
end

Это возможно только потому, что модуль File предоставляет API для чтения файлов с кортежами в качестве результатов (File.read/1), а также версию, которая возбуждает исключение (File.read!/1). Знак восклицания (восклицательный знак) фактически является частью конвенций именования Elixir.

Авторам библиотек рекомендуется следовать тем же правилам. На практике вариант с восклицательным знаком реализован поверх невозбуждающей версии кода. Например, File.read!/1 реализован как:

def read!(path) do
  case read(path) do
    {:ok, binary} ->
      binary

    {:error, reason} ->
      raise File.Error, reason: reason, action: "read file", path: IO.chardata_to_string(path)
  end
end

Распространённая практика, принятая сообществом, заключается в том, чтобы сделать невозбуждающую версию возвращающей {:ok, result} или {:error, Exception.t}. Например, HTTP-клиент может вернуть {:ok, %HTTP.Response{}} в случаях успеха и {:error, %HTTP.Error{}} в случае ошибок, где HTTP.Error реализован как исключение. Это позволяет кому-либо возбудить исключение, просто вызвав Kernel.raise/1.

Дополнительные замечания

Этот антипаттерн ранее назывался Использование исключений для управления потоком.

Навязчивое использование примитивных типов

Проблема

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

Пример

Пример этого антипаттерна — использование одной строки для представления Address. Address — это более сложная структура, чем простое базовое (т.е., примитивное) значение.

defmodule MyApp do
  def extract_postal_code(address) when is_binary(address) do
    # Extract postal code with address...
  end

  def fill_in_country(address) when is_binary(address) do
    # Fill in missing country...
  end
end

Хотя вы можете получить address в виде строки из базы данных, веб-запроса или стороннего приложения, если вы часто манипулируете или извлекаете информацию из строки, это хороший признак того, что вы должны преобразовать адрес в структурированные данные:

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

Переработка

Возможными решениями этого антипаттерна является использование отображений или структур для моделирования адреса. В приведённом ниже примере создаётся структура Address , лучше представляющая эту предметную область с помощью составного типа. Кроме того, мы вводим функцию parse/1 , преобразующую строку в Address, что упростит логику оставшихся функций. С этой модификацией мы можем извлекать каждое поле этого составного типа по отдельности при необходимости.

defmodule Address do
  defstruct [:street, :city, :state, :postal_code, :country]
end
defmodule MyApp do
  def parse(address) when is_binary(address) do
    # Returns %Address{}
  end

  def extract_postal_code(%Address{} = address) do
    # Extract postal code with address...
  end

  def fill_in_country(%Address{} = address) do
    # Fill in missing country...
  end
end

Функция с несколькими не связанными разделами

Проблема

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

Пример

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

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

@doc """
Updates a struct.

If given a product, it will...

If given an animal, it will...
"""
def update(%Product{count: count, material: material})  do
  # ...
end

def update(%Animal{count: count, skin: skin})  do
  # ...
end

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

Переработка

Как показано ниже, возможным решением этого антипаттерна является разделение бизнес-правил, смешанных в одной не связанной многораздельной функции, на простые функции. Каждая функция может иметь определённое имя и @doc, описывающее её поведение и принятые параметры. Хотя эта переработка кажется простой, она может повлиять на вызывающие функции, поэтому будьте осторожны!

@doc """
Updates a product.

It will...
"""
def update_product(%Product{count: count, material: material}) do
  # ...
end

@doc """
Updates an animal.

It will...
"""
def update_animal(%Animal{count: count, skin: skin}) do
  # ...
end

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

def update_product(%Product{count: 0}) do
  # ...
end

def update_product(%Product{material: material})
    when material in ["metal", "glass"] do
  # ...
end

def update_product(%Product{material: material})
    when material not in ["metal", "glass"] do
  # ...
end

Вы можете увидеть этот паттерн на практике в самом Elixir. Оператор +/2 может складывать Integer и Float, но не String, которые вместо этого используют оператор <>/2. В этом смысле разумно обрабатывать целые числа и вещественные числа в одной операции, но строки достаточно не связаны, чтобы заслуживать собственной функции.

Вы также найдёте в Elixir примеры функций, которые работают с любой структурой, что, казалось бы, является случаем этого антипаттерна, например, struct/2:

iex> struct(URI.parse("/foo/bar"), path: "/bar/baz")
%URI{
  scheme: nil,
  userinfo: nil,
  host: nil,
  port: nil,
  path: "/bar/baz",
  query: nil,
  fragment: nil
}

Разница здесь в том, что функция struct/2 ведет себя точно так же для любой заданной структуры, поэтому нет вопроса о том, как функция обрабатывает различные входные данные. Если поведение чётко и последовательно для всех входных данных, то антипаттерн не имеет места.

Использование конфигурации приложения для библиотек

Проблема

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

Пример

Модуль DashSplitter представляет библиотеку, которая конфигурирует поведение своих функций с помощью глобальной среды приложения. Эти настройки сконцентрированы в файле config/config.exs, показанном ниже:

import Config

config :app_config,
  parts: 3

import_config "#{config_env()}.exs"

Одна из функций, реализованных в библиотеке DashSplitter, это split/1. Эта функция предназначена для разделения строки, полученной в качестве параметра, на определенное количество частей. Разделителем в split/1 всегда является "-", а количество частей, на которые разбивается строка, определяется глобально средой приложения. Это значение извлекается функцией split/1 вызовом Application.fetch_env!/2, как показано ниже:

defmodule DashSplitter do
  def split(string) when is_binary(string) do
    parts = Application.fetch_env!(:app_config, :parts) # <= retrieve parameterized value
    String.split(string, "-", parts: parts)             # <= parts: 3
  end
end

Из-за этого параметризованного значения, используемого библиотекой DashSplitter, все приложения, зависящие от неё, могут использовать функцию split/1 только с идентичным поведением относительно количества частей, полученных при разделении строки. В настоящее время это значение равно 3, как можно увидеть в примерах использования ниже:

iex> DashSplitter.split("Lucas-Francisco-Vegi")
["Lucas", "Francisco", "Vegi"]
iex> DashSplitter.split("Lucas-Francisco-da-Matta-Vegi")
["Lucas", "Francisco", "da-Matta-Vegi"]

Рефакторинг

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

defmodule DashSplitter do
  def split(string, opts \\ []) when is_binary(string) and is_list(opts) do
    parts = Keyword.get(opts, :parts, 2) # <= default config of parts == 2
    String.split(string, "-", parts: parts)
  end
end
iex> DashSplitter.split("Lucas-Francisco-da-Matta-Vegi", [parts: 5])
["Lucas", "Francisco", "da", "Matta", "Vegi"]
iex> DashSplitter.split("Lucas-Francisco-da-Matta-Vegi") #<= default config is used!
["Lucas", "Francisco-da-Matta-Vegi"]

Конечно, не все использования среды приложения библиотеками являются неправильными. Один пример — использование конфигурации для замены компонента (или зависимости) библиотеки другим, который должен вести себя точно так же. Представьте библиотеку, которая нуждается в парсинге CSV-файлов. Автор библиотеки может выбрать один пакет в качестве парсера по умолчанию, но разрешить своим пользователям переключаться на другие реализации через среду приложения. В конечном счёте, выбор другого парсера CSV не должен менять результат, и авторы библиотек могут даже закрепить это, определяя поведения с точным ожидаемым семантическим значением.

Дополнительные замечания: Деревья надзора

На практике библиотеки могут потребовать дополнительной конфигурации помимо списков ключевых слов. Например, если библиотека должна запустить дерево надзора, как пользователь этой библиотеки может настроить своё дерево надзора? Учитывая, что само дерево надзора является глобальным (поскольку оно принадлежит библиотеке), авторы библиотек могут быть искушены использовать конфигурацию приложения ещё раз.

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

Вы можете увидеть этот шаблон на практике в таких проектах, как Nx и DNS Cluster. Эти библиотеки требуют, чтобы вы перечислили процессы в своём собственном дереве надзора:

children = [
  {DNSCluster, query: "my.subdomain"}
]

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

children = [
  {DNSCluster, query: Application.get_env(:my_app, :dns_cluster_query) || :ignore}
]

Некоторые библиотеки, такие как Ecto, позволяют передавать имя вашего приложения в качестве параметра (называемого :otp_app или подобного) и затем автоматически считывать среду из вашего приложения. Хотя это решает проблему с тем, что среда приложения является глобальной, так как они считывают данные из каждого отдельного приложения, это приводит к некоторой косвенности по сравнению с примером выше, где пользователи явно считывают свою среду приложения из своего собственного кода, когда это необходимо.

Дополнительные замечания: Конфигурация на этапе компиляции

Аналогичная дискуссия касается конфигурации на этапе компиляции. Что если автору библиотеки требуется какая-то конфигурация, предоставляемая во время компиляции?

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

defmodule MyApp.Repo do
  use Ecto.Repo, adapter: Ecto.Adapters.Postgres
end

Вместо принуждения разработчиков к совместному использованию одного репозитория, Ecto позволяет своим пользователям определять столько репозиториев, сколько им нужно. Учитывая, что конфигурация :adapter требуется на этапе компиляции, это обязательное значение в use Ecto.Repo. Если разработчики хотят настроить адаптер в зависимости от среды, то это их выбор:

defmodule MyApp.Repo do
  use Ecto.Repo, adapter: Application.compile_env(:my_app, :repo_adapter)
end

С другой стороны, генерация кода связана со своими собственными антипаттернами и должна рассматриваться тщательно. То есть: хотя использование среды приложения для библиотек, особенно конфигурации на этапе компиляции, не рекомендуется, в некоторых случаях это может быть лучшим вариантом. Например, рассмотрите библиотеку, которая нуждается в парсинге CSV или JSON-файлов для генерации кода на основе данных из файлов. В таких случаях лучше предоставить разумные значения по умолчанию и сделать их настраиваемыми через среду приложения, а не просить каждого пользователя вашей библиотеки генерировать точно такой же код.

Дополнительные замечания: Задачи Mix

Для задач Mix и связанных инструментов может быть необходимо предоставить конфигурацию на уровне проекта. Например, представьте проект :linter, который поддерживает установку выходного файла и уровень подробности. Вы можете настроить его с помощью среды приложения:

config :linter,
  output_file: "/path/to/output.json",
  verbosity: 3

Однако, Mix позволяет задачам читать конфигурацию на уровне проекта через Mix.Project.config/0. В этом случае вы можете настроить :linter непосредственно в файле mix.exs.

def project do
  [
    app: :my_app,
    version: "1.0.0",
    linter: [
      output_file: "/path/to/output.json",
      verbosity: 3
    ],
    ...
  ]
end

Кроме того, если задача Mix доступна, вы также можете принять эти параметры в качестве аргументов командной строки (см. OptionParser):

mix linter --output-file /path/to/output.json --verbosity 3
← Предыдущая страница Антипаттерны, связанные с кодом
Следующая страница → Антипаттерны, связанные с процессами

Скачать версию ePub

Создано с помощью ExDoc (v0.32.2) для языка программирования Elixir

© 2012-2024 The Elixir Team
Licensed under the Apache License, Version 2.0.
https://hexdocs.pm/elixir/1.16.3/design-anti-patterns.html

Spec-Zone.ru

Настройки Оффлайн Что нового Помощь О нас
Spec-Zone .ru
спецификации, руководства, описания, API