Spec-Zone.ru › Elixir 1.18

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

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

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

Проблема

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

Пример

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

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
      case Integer.parse(string) do
        {int, _rest} -> int
        :error -> :error
      end
    else
      Integer.parse(string)
    end
  end
end
iex> AlternativeInteger.parse("13")
{13, ""}
iex> AlternativeInteger.parse("13", discard_rest: false)
{13, ""}
iex> AlternativeInteger.parse("13", discard_rest: true)
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.

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

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

  • некорректные аргументы: ожидается, что функции будут генерировать исключения для некорректных аргументов, так как это структурные ошибки, а не семантические. Например, File.read(123) всегда будет генерировать исключение, потому что 123 никогда не является допустимым именем файла

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

  • некоторые фреймворки, такие как Phoenix, позволяют разработчикам генерировать исключения в своем коде и используют протокол для преобразования этих исключений в семантические HTTP-ответы

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

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

Проблема

Этот антипаттерн возникает, когда базовые типы 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.36.1) для языка программирования Elixir

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

Spec-Zone.ru

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