Spec-Zone.ru › Elixir 1.17

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

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

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

Проблема

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

Пример

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

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)

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

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

Проблема

Этот антипаттерн относится к коду, использующему 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.34.1) для языка программирования Elixir

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

Spec-Zone.ru

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