Skip to content

Latest commit

 

History

History
753 lines (558 loc) · 71.2 KB

12-error-handling.md

File metadata and controls

753 lines (558 loc) · 71.2 KB

Обработка ошибок

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

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

Уточнение 🎯
У меня нет цели «продать» конкретный способ обрабатывать ошибки. Вместо этого я хочу показать, на что чаще всего обращаю внимание во время рефакторинга и какие обстоятельства могут усложнить обработку ошибок.
Я могу быть не прав. Описанные приёмы помогают лично мне, но это не значит, что они универсально применимы. Перед их использованием изучите, как обработка ошибок устроена в вашем проекте, обсудите идеи с командой и удостоверьтесь, что они подходят всем.
У любых инструментов есть область применения и ограничения, поэтому не используйте их только ради использования. Если ваш проект написан в ООП-стиле, будет странно тащить туда функциональное связывание, как бы заманчиво это ни было.

Грамотная обработка ошибок помогает справляться с нештатными ситуациями в приложении и дебажить проблемный код. Добиться этого можно разными способами, но как правило, удобная обработка ошибок начинается с 3 эвристик:

  • Если ошибка мешает нормальному выполнению бизнес-логики — не пытаться продолжать работу, а переходить к её обработке.
  • Обрабатывать ошибки централизованно, но не смешивать в кучу разные виды ошибок.
  • Никогда не умалчивать никакие ошибки, особенно — необработанные.

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

Виды ошибок

В “Domain Modeling Made Functional” Скотт Влашин делит ошибки на 3 вида:1

  • Доменные. Такие ошибки ожидаемы в бизнес-процессах, мы знаем их причину и как их обрабатывать. Например, деление на 0 в калькуляторе — это доменная ошибка, потому что запрет операции — это часть предметной области.
  • Инфраструктурные. Тоже ожидаемы и понятны в обработке, но связаны с инфраструктурой, а не бизнес-логикой. Например, неудачный сетевой запрос.
  • Паники. Неожиданные ошибки, мы не знаем как их обработать и восстановиться после них. Например, получить null там, где его быть не должно — это паника, потому что непонятно, как продолжать работу программы без нужных данных.
К слову 🙅
Я специально не использую термин «исключение», потому что в разных источниках «исключения» и «ошибки» значат ровно противоположное.23 Вместо него я буду использовать термин «паника».

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

Техники обработки

Техники обработки ошибок зависят от языка, предпочтений команды, специфики и ограничений проекта. Чаще всего встречается код, где обработка ошибок построена одним из 4 способов:

  • С помощью выбрасывания через throw;
  • Использования Result-контейнеров;
  • Сочетания выбрасывания и контейнеров;
  • Сочетания контейнеров и функционального связывания.

Мы не будем «ранжировать» их «от плохих к хорошим». Вместо этого обсудим их преимущества и недостатки, а также исследуем, какие обстоятельства в проекте могут подтолкнуть к использованию той или иной техники.

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

Будьте внимательны 🚧
Повторюсь, что приёмы в книге — это рекомендации, а не правила. Решение, применять ли эти идеи, стоит принимать в каждой конкретной ситуации, посоветовавшись с другими разработчиками.

Выбрасывание паник

В JavaScript-коде самый частый способ работы с ошибками — выбросить панику с помощью throw new Error(). Выбрасывание работает нативно «из коробки», и этот способ — первый, который всплывает в голове, когда надо «сообщить, что что-то пошло не так».

К слову 👀
Кроме Error ещё есть Promise.reject, но он скорее связан с асинхронными операциями, поэтому, например, в бизнес-логике им пользуются гораздо реже.

Главный недостаток паник в том, что они больше подходят для неожиданных ошибок, чем для ожидаемых. Паники описывают ситуации, которые приводят программу в неконсистентное состояние, от которого нельзя восстановиться. От ожидаемых же ошибок восстановиться можно и известно как. Из-за этой разницы есть даже рекомендации избегать паник в бизнес-логике.45

На практике, однако, добиться такого разделения можно не всегда. Бывают проекты, в которых обработка паник и ошибок перемешана и устроена одинаково с помощью выбрасывания. В таких проектах может быть сложно внедрить альтернативные способы обработки ошибок и приходится работать только с паниками. Но даже такой код мы всё ещё можем сделать немного лучше.

Разберём на примере. Допустим, у нас есть функция getUser, которая обращается к API, чтобы получить данные о пользователе. После получения ответа она парсит его и сохраняет результат в хранилище.

async function getUser(id) {
  const dto = await fetchUser(id);
  const user = dto ? parseUser(dto) : null;
  if (user) storage.setUser(user);
  else storage.setError("Something went wrong.");
}

Функция fetchUser занимается запросом к сети и возвращает DTO из ответа сервера или null, если сервер ответил с ошибкой:

async function fetchUser(url) {
  const response = await fetch(url);
  const { value, error } = await response.json();
  if (error) return null;
  return value;
}

Функция parseUser парсит серверный ответ и возвращает объект пользователя или null, если DTO оказался невалидным:

function parseUser(dto: UserDto): User | null {
  if (!dto || !dto.firstName || !dto.lastName || !dto.email) return null;
  return { ...dto, fullName: `${dto.firstName} ${dto.lastName}` };
}

Чтобы понять, как именно рефакторить этот код, сперва определим в нём проблемы:

  • Обработки ошибок как таковой тут нет. Мы возвращаем из функций null, когда что-то идёт не так, но умалчиваем причины ошибок и никак их не обрабатываем.
  • Из-за null в результатах fetchUser и parseUser мы теряем контекст того, что именно пошло не так, и на уровне выше приходится проверять данные на те же ошибки снова. Из-за дублирования проверок код становится шумным.
  • В функции fetchUser учтены только некоторые проблемы, а явного делегирования непредвиденных ошибок другим модулям нет. При исполнении такого кода приложение может упасть на любой строчке.
  • Мы не различаем ошибки инфраструктуры и предметной области. При анализе ошибок эта информация могла бы понадобиться для поиска мест, где приложение сломалось.

Попробуем эти проблемы исправить.

Неожиданные ошибки и потеря контекста

Одна из причин для рефакторинга кода с выбрасыванием — неочевидность того, какие ошибки мы отловим, а какие нет. В таком коде легко пропустить необработанную ошибку, и приложение упадёт, когда мы этого не ожидаем.

Например, сейчас если внутри fetchUser произойдёт ошибка, которую мы не ждали, приложение упадёт:

async function fetchUser(url) {
  const response = await fetch(url);

  // Допустим, после распаковки JSON мы получили не объект, а `null`,
  // тогда следующая строка выбросит `null is not an object`:
  const { value, error } = await response.json();
  // ...
}

Мы можем это решить, добавив обработку через try-catch на уровне выше. Выброшенная ошибка будет перехвачена в функции getUser, и мы сможем её обработать:

async function getUser(id) {
  try {
    const dto = await fetchUser(id);
    const user = dto ? parseUser(dto) : null;
    if (user) storage.setUser(user);
    else storage.setError("Something went wrong.");
  } catch (error) {
    storage.setError("Couldn't fetch the data.");
  }
}

Однако, если мы выбрасываем паники для обработки всех ошибок, то мы можем случайно смешать ожидаемые и неожидаемые ошибки. Например, если валидация тоже выбрасывает паники:

function parseUser(dto: UserDto): User {
  if (!dto) throw new Error("Missing user DRO.");

  const { firstName, lastName, email } = dto;
  if (!firstName || !lastName || !email) throw new Error("Invalid user DRO.");

  return { ...dto, fullName: `${firstName} ${lastName}` };
}

То try-catch на уровне выше сможет их поймать, но мы не сможем отличить разные ошибки друг от друга:

async function getUser(id) {
  try {
    // ...
  } catch (error) {
    // error — это ошибка сети или ошибка валидации?
    // Она ожидаема или нет?
    //
    // Мы можем определять ошибки по сообщению,
    // которое передавали в конструктор Error,
    // но это ненадёжно.
  }
}
Зачем знать разницу 🤔
Мы хотим различать ошибки и паники, потому что их может быть нужно по-разному обрабатывать. В зависимости от требований проекта может быть нужно, например, логировать паники в алёрт-мониторинге, а ожидаемые ошибки отражать в сервисе аналитики. Чем проще их отличить, тем меньше кода уйдёт на раздельную обработку.

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

Разные типы ошибок

В случае с JavaScript «отдельный тип» ошибки — это класс, который расширяет Error. В таких расширениях мы можем указать название или вид ошибки, а также какую-то дополнительную информацию в отдельных полях.

Например, для различения сетевых ошибок и ошибок валидации, мы можем создать такие типы:

// Ошибки валидации:
class InvalidUserDto extends Error {
  constructor(message) {
    super(message ?? "The given User DTO is invalid.");
    this.name = this.constructor.name;
  }
}

// Ошибки API:
class NetworkError extends Error {
  constructor(message, status, traceId) {
    super(message ?? messageFromStatus(status));
    this.name = this.constructor.name;

    // Можно расширить дополнительными полями для логирования:
    this.status = status;
    this.traceId = traceId;
  }
}
К слову 👀
Если при валидации надо выбросить несколько ошибок сразу, а не выбрасывать по одной, то можно расширить класс дополнительными полями или использовать AggregateError.6

Тогда при отлове мы сможем по типу понять, что именно случилось:

async function getUser(id) {
  try {
    // ...
  } catch (error) {
    if (error instanceof InvalidUserDto) {
    } else if (error instanceof NetworkError) {
    } else throw error;
  }
}

Fail Fast

У такой обработки много минусов. Например, она использует выбрасывание в коде бизнес-логики, а также нарушает LSP7 внутри блока catch. Но несмотря на это у неё есть одно преимущество: при появлении ошибки мы не пытаемся продолжать работу, а переходим к её обработке.

Мы не хотим продолжать «нормальную работу», потому что ошибка — это неконсистентное состояние приложения. В таком состоянии нет гарантий, что данные приложения валидны, целостны, и с ними можно продолжать работать.

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

Диаграмма выполнения кода с главной цепочкой действий слева и ответвления с ошибками справа, которые ведут в одно место на диаграмме

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

Rethrow

В последнем примере на последней строке блока catch появилось выражение else throw error — это так называемое перебрасывание (rethrow). Мы используем его как механизм, который помогает не замалчивать ошибки, которые мы не можем обработать.

Если текущий обработчик проверил ошибку на все знакомые ему типы, но не нашёл подходящего, он может перебросить её на уровень вверх, чтобы ошибкой занялся «кто-то поумнее, кто знает, что делать».

Однако ❓
Тут возникает вопрос, кто будет это обрабатывать. Об этом мы поговорим подробнее ближе к концу главы.

В таком коде функции «выпрямляются», а количество проверок на null уменьшается:

async function getUser(id) {
  try {
    const dto = await fetchUser(id);
    const user = parseUser(dto);
    storage.setUser(user);
  } catch (error) {
    if (error instanceof InvalidUserDto || error instanceof NetworkError) {
      storage.setError(error.message);
    } else throw error;
  }
}

Преимущества

Мы добились некоторых улучшений кода. И хоть их немного, они могут (не гарантируют, но могут) упростить работу:

  • Выполнение стало более линейным. Мы перестали перепроверять данные на уже проверенные ошибки, поэтому поток кода стал более «прямым».
  • Код переходит к обработке ошибок сразу, как только нормальное выполнение становится невозможным. Мы останавливаем ошибку в начале, не давая ей привести приложение в невалидное состояние.
  • Контекст ошибок сохраняется, поэтому при начале обработки у нас есть полная информация о том, что произошло.

Проблемы

Но у выбрасывания есть и проблемы:

  • Выброшенная ошибка может выстрелить в рантайме, если её не обработать.
  • При этом в языке нет инструментов, которые бы заставили обрабатывать возможные ошибки, поэтому обработку легко забыть или пропустить.
  • В коде почти нет синтаксической разницы между ошибками и паниками, из-за чего их сложно различать.
  • При этом выбрасывать паники в коде доменной модели — явный запах, потому что ошибки домена — не паники, они ожидаемы.
  • Выполнить проверку на все потенциальные ошибки можно, но это выглядит некрасиво из-за instanceof.
  • Использования instanceof можно избежать, создавая подклассы на каждый «слой» приложения, но это делает модель ошибок сложнее.
  • Неясно, кто будет обрабатывать выброшенную ошибку. Полагаться на «договорённости» опасно, потому что в языке нет инструментов для принуждения к договорённостям.
  • Нет чётких правил, которые бы однозначно указывали, когда и как оборачивать «низкоуровневый» код (fetch, Browser API и т.д.).
  • Может пострадать производительность, потому что каждый объект Error собирает стек и другую информацию.
  • Сигнатура функций не сообщает, что они могут выбросить ошибку — мы можем узнать об ошибках только из исходников.
Уточнение 🎯
Вообще, в TypeScript для предупреждения о возможных ошибках через сигнатуру функции можно использовать тип never8. Он не указывает, какие именно ошибки стоит ожидать, но как минимум намекает на их возможность. Это делает сигнатуру чуть точнее, но для более детального описания ошибок через типы never не подойдёт.

Если в проекте можно использовать только выбрасывание, то это, пожалуй, максимум, что мы можем сделать. По-хорошему, выбрасывать лучше только паники и избегать их в бизнес-логике. Но если в проекте есть ограничения, из-за которых нельзя использовать ничего другого, такой вид обработки может быть неплохим вариантом.

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

Result-контейнеры

Как мы говорили ранее, некоторые ошибки ожидаемы. Например, мы можем ожидать, что API вернёт 404 в ответ на запрос несуществующей страницы. Такая ситуация нестандартная, но мы знаем, как её обрабатывать.

Для обработки ожидаемых ошибок может быть удобно использовать Result-контейнеры. Контейнер — это тип, который находится в одном из двух состояний: он либо содержит данные, если операция прошла без проблем, либо содержит возникшую ошибку. Схематично его можно изобразить так:

// Тип контейнера состоит из двух частей:
// - Success — для ситуации, когда надо вернуть результат;
// - Failure — для ситуации, когда надо вернуть ошибку.

type Result<TOk, TErr> = Success<TOk> | Failure<TErr>;
type Success<T> = { ok: true; value: T };
type Failure<E> = { ok: false; error: E };
Уточнение 🦄
Реализация Result из примера намеренно абстрактная и не полная. Я сделал это, чтобы не претендовать на «каноничность».
Реализовать свой Result-контейнер с нуля — это интересная задача, но в продакшене я бы рекомендовал использовать известные сообществу решения. Например, для подобных задач подойдёт Either из библиотеки fp/ts.9
Если в вашем проекте уже используется некая реализация контейнеров, изучите её возможности. Вероятно, у неё уже есть всё необходимое.

Используя контейнер, мы могли бы переписать функцию parseUser как-то так:

type MissingDtoError = "MissingDTO";
type InvalidDtoError = "InvalidDTO";
type ValidationError = MissingDtoError | InvalidDtoError;

function parseUser(dto: UserDto): Result<User, ValidationError> {
  if (!dto) return Result.failure("MissingDTO");

  const { firstName, lastName, email } = dto;
  if (!firstName || !lastName || !email) {
    return Result.failure("InvalidDtoError");
  }

  return Result.success({ ...dto, fullName: `${firstName} ${lastName}` });
}

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

Сигнатура точнее отражает процесс

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

Использование контейнеров имеет и обратную сторону, конечно. Чтобы использовать данные из результата на уровне выше, нам надо контейнер «распаковать»:

async function getUser(id) {
  try {
    const { value: dto, error: networkError } = await fetchUser(id);
    const { value: user, error: parseError } = parseUser(dto);
    storage.setUser(user);
  } catch (error) {
    // ...
  }
}

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

async function getUser(id) {
  try {
    const { value: dto, error: networkError } = await fetchUser(id);
    // Обработать `networkError`...

    const { value: user, error: parseError } = parseUser(dto);
    // Обработать `parseError`...

    storage.setUser(user);
  } catch (error) {
    // Обработать непредвиденные ситуации...
  }
}

Для описания ожидаемых ошибок контейнеры, вероятно, подойдут лучше, чем паники. Они отличаются синтаксически, что помогает воспринимать их как именно ожидаемые. Кроме этого контейнеры не надо «выбрасывать», а значит они не сломают приложение.

К слову 📦
В Node.js во времена колбеков опасные операции использовали нечто похожее на контейнер — кортеж из ошибки и значения:10
function errorFirstCallback(err, value) {}
Переменная err пуста, если операция прошла успешно, или содержит возникшую ошибку. Это также известно как error-first callback.

Явная обработка

После распаковки контейнеров мы можем настроить обработку так, чтобы ошибки заканчивали «нормальное» выполнение программы — получим Fail Fast:11

async function getUser(id) {
  try {
    const { value: dto, error: networkError } = await fetchUser(id);
    if (networkError) return handleError(networkError);
    // Или throw new Error(networkError) и обработать позже.

    const { value: user, error: parseError } = parseUser(dto);
    if (parseError) return handleError(parseError);

    storage.setUser(user);
  } catch (error) {
    // ...
  }
}

Диаграмма выполнения кода с главной цепочкой действий слева и ответвления с ошибками справа, которые ведут в одно место на диаграмме

Цепочка результатов прервётся там, где появится ошибка, и управление перейдёт в конец

Уточнение 🔗
В императивном коде при большом количестве операций это будет выглядеть громоздко. Но если количество операций небольшое (1–2), то в целом это не так страшно. О том, как распаковывать контейнеры более изящно, мы поговорим чуть позже.

Централизованная обработка

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

type ValidationError = MissingDtoError | InvalidDtoError;
type NetworkError = BadRequest | NotFound | ServerError;
type UseCaseError = ValidationError | NetworkError;

// Функция `handleGetUserErrors` обрабатывает ошибки юзкейса `getUser`:
function handleGetUserErrors(error: UseCaseError): void {
  const messages: Record<UseCaseError, ErrorMessage> = {
    MissingDTO: "The user DTO is required.",
    InvalidDTO: "The given DTO is invalid.",
    // `Record<UseCaseError, ErrorMessage>` удостоверится,
    // что мы покрыли все ожидаемые ошибки.
  };

  // Если ошибка неизвестная, перебросим её наверх:
  const message = messages[error];
  if (!message) throw new Error("Unexpected error when getting user data.");

  // Если ожидаемая, обработаем:
  storage.setError(message);

  // Если необходимо, добавим инфраструктурной функциональности:
  logger.logError(error);
  analytics.captureUserActions();
}

Паники отдельно

Так как «низкоуровневый» код не возвращает контейнер, а выбрасывает паники, нам надо оборачивать такие операции в try-catch, чтобы в случае проблемы вернуть контейнер:

type NetworkError = BadRequest | NotFound | InternalError;

async function fetchUser(url) {
  try {
    const response = await fetch(url);
    const { value, error } = await response.json();
    return error ? Result.failure("BadRequest") : Result.success(value);
  } catch (error) {
    //
    // Если ошибка ожидаема — возвращаем контейнер:
    const reason = errorFromStatus(error);
    if (reason) return Result.failure(reason);
    //
    // Если не ожидаема, то используем rethrow:
    else throw new Error("Unexpected error when fetching user data.");
  }
}

Обернуть таким образом нужно будет каждый вызов «низкоуровневого» API — в нашем случае каждый запрос к сети. Это может увеличить количество кода. Однако, если схема работы с этими API одинаковая, мы можем уменьшить дублирование с помощью декораторов:

// Декоратор будет принимать «опасную» функцию,
// и вызывать её внутри `try-catch`.
// При возникновении ошибки, будет проверять,
// ожидаема ли ошибка и возвращать контейнер
// или перебрасывать ошибку выше.

function robustRequest(request) {
  return async function perform(...args) {
    try {
      return await request(...args);
    } catch (error) {
      const reason = errorFromStatus(error);
      if (reason) return Result.failure(reason);
      else throw new Error("Unexpected error when making a request.");
    }
  };
}

// Использовать декоратор можно будет
// с разными функциями работы с сетью:

const safeFetchUser = robustRequest(fetchUser);
const safeCreatePost = robustRequest(createPost);

Множественные ошибки

Дополнительные данные, например для множественных ошибок, мы можем хранить в поле error контейнера. Мы можем передавать в этом поле объекты, массивы или даже Error-инстансы. Это может пригодиться, например, для ошибок валидации, которые содержат список невалидных полей.

К слову 🤙
Один из способов сообщить о множественных ошибках — это паттерн «Уведомление» (Notification).412 Его тоже можно воспринимать, как один из видов контейнеров.

Распаковка

Главная проблема, конечно, никуда не делась — нам всё ещё нужно распаковывать контейнеры и прерывать выполнение кода вручную. Сделать это пару раз не сложно, но более частая ручная распаковка может сделать код шумным. На помощь в такой ситуации может прийти функциональное связывание.

Связывание результатов

Прежде, чем начать 🚂
О функциональном связывании хорошо написал Скотт Влашин в посте о “Railway Oriented Programming”,13 советую прочесть эту статью перед продолжением. Я не буду подробно вдаваться в детали. В этой статье всё описано гораздо лучше, чем смог бы написать я.
Кроме этого 🙃
Если в вашем проекте используется ручная распаковка, и вас всё устраивает, то не стоит переписывать её на функциональное связывание. Оно может усложнить код, не принеся пользы.
О связывании стоит задуматься, если ваш проект написан в функциональном стиле, а ручная распаковка контейнеров начинает напрягать. В остальных случаях оно, скорее всего, не нужно.

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

function fetchUser(id: UserId): Result<UserDTO, FetchUserError> {}
function parseUser(dto: UserDTO): Result<User, ParseUserError> {}

// Мы хотим вызывать функции в виде:
// fetchUser -> parseUser -> storage.setUser

// Но сейчас этого сделать нельзя, потому что выход одной функции
// не соответствует входному типу следующей за ней.
//
// Функция `fetchUser` возвращает `Result<UserDTO, FetchUserError>`,
// но `parseUser` на вход требует просто `UserDTO`.

Мы можем воспринимать связывание, как «подгонку» входного типа функций, чтобы они могли принимать на вход Result<TOk, TErr>. Если бы мы «подгоняли» типы руками, то получилось бы что-то типа:

function parseUser(
  result: Result<UserDTO, FetchUserError>
): Result<User, ParseUserError> {}

Но это не вариант, потому что мы не хотим на входе parseUser зависеть от результата конкретной предыдущей операции. Мы хотим уметь получать любые результаты и распаковывать автоматически.

Для автоматизации этого «железнодорожное программирование» предлагает использовать специальные функции-адаптеры. В зависимости от реализации контейнера, эти адаптеры могут быть отдельными функциями или методами контейнера. Я не буду претендовать на каноничность, поэтому моя реализация будет схематичной:

const Result = {
  // ...

  // Если текущий контейнер в состоянии Success,
  // то мы «распаковываем» значение и передаём его на вход функции fn.
  // Если контейнер в состоянии Failure,
  // возвращаем новый контейнер Failure<E> с ошибкой,
  // которая была в этом контейнере.
  bind: (fn) => (ok ? fn(value) : failure(error)),

  // При завершении цепочки выполнения
  // нам надо обработать ошибку или результат,
  // поэтому две функции-аргумента в этом методе:
  match: (handleError, handleValue) =>
    ok ? handleValue(value) : handleError(error),
};

// Метод `bind` будет «связывать» функции в цепочку,
// а метод `match` будет «разворачивать» финальный результат.
//
// Метод `match` принимает 2 аргумента:
// - первая функция — это обработчик ошибок, она будет обрабатывать ошибки всей цепочки функций;
// - а вторая — обработчик результата цепочки.

Теперь результаты можно связывать в виде цепочки из нескольких вызовов:

async function getUser(id) {
  (await fetchUser(id))
    .bind(parseUser)
    .match(handleGetUserErrors, storage.setUser);
}

В такой функции ошибка на любом из результатов выполнения сразу же отправится в функцию handleGetUserErrors. Так в сочетании с Exhaustiveness Check14 мы можем организовать обработку всех ошибок для getUser без необходимости руками распаковывать контейнеры.

К слову ⛓
Связывание может выглядеть по-разному. Это может быть цепочка методов, может быть функция типа pipe,15 которая «проталкивает» по себе результаты от одной функции к другой. Финальная обработка результатов тоже может выглядеть по-разному в зависимости от стиля и парадигмы проекта.
Если в вашем проекте есть контейнеры, приглядитесь к их реализации повнимательнее, возможно, там уже есть всё нужное. Если нет, посмотрите на существующие решения типа fp/ts или sweet-monads и выберите себе ту, которая больше нравится.91617

Смысл связывания в автоматизации «проталкивания» и распаковки результатов. Мы соединяем все результаты в последовательность «развилок». Ошибка из любой функции съезжает по развилке на «колею с ошибками» и катится по ней до самого конца. Это похоже на соединение железнодорожных путей — поэтому и программирование “railway oriented” 😃

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

Связывание помогает адаптировать входы и выходы функций

Нативное связывание в JavaScript?..

В JavaScript на самом деле есть «нативная реализация контейнеров и связывания» — это Promise. Он тоже может находиться в одном из нескольких состояний и помогает связывать вычисления:

fetchPosts()
  .then(validatePosts)
  .then((posts) => keepOnlyAuthoredBy(user, posts))
  .then(dashboardPostsProjection)
  .catch(handleGetPostsError);

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

Проблемы

Проблемы у связывания тоже есть:

  • Код становится сложнее, это может увеличить порог входа в проект.
  • Связывание требует договорённостей: нужно следить за использованием контейнеров и обёртками для «опасных» низкоуровневых функций.
  • В нефункциональном коде это может выглядеть неоднозначно.
  • В JavaScript не хватает нативного паттерн-матчинга для удобной работы с похожими идеями.

Когда предпочесть паники

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

Например, если контейнер никто не собирается обрабатывать или если не важно, какая произошла ошибка, то контейнер может быть не нужен. Хороший чеклист с тем, когда предпочесть контейнер, а когда панику вы сможете найти у Скотта Влашина в статье “Against Railway-Oriented Programming”.18

Cross-Cutting Concerns

Когда обработка ошибок описана единообразно во всём проекте, нам проще отыскать место с ошибкой по, например, баг-репорту. Но кроме этого централизованная обработка позволяет удобно компоновать cross-cutting concerns,19 например, логирование.

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

// services/logger.js
// Сервис логирования предоставляет функцию
// для отправки нового события:

const logEvent = (entry: LogEntry) => {};

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

const withLogger =
  (fn) =>
  (...args) => {
    const result = fn(...args);
    const entry = createEntry({ args, result });
    logEvent(entry);
  };

// network.js
// Тогда для логирования ошибок будет достаточно
// «обернуть» обработчик в логирующий декоратор:

const handleNetworkError = (error: NetworkError) => {};
const errorHandler = withLogger(handleNetworkError);

В целом, cross-cutting concerns удобно оформлять в виде декораторов: так «сервисная» функциональность оказывается изолирована, не смешивается с остальным кодом приложения, а сами декораторы удобно «внедрять» в самые разные части кода.

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

Зоопарк обработчиков

Потенциально опасные функции могут отличаться и быть связаны с разными API. Требования к обработке ошибок в таких функциях могут отличаться. Например, какие-то API вместо использования try-catch требуют указывать свойство .onerror или слушать событие ошибки.

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

К слову 🔌
Если сигнатура обработчика ошибки и API сервиса несовместимы, мы можем воспользоваться адаптером для решения этой проблемы.22

Иерархия отлова ошибок

Ранее мы упоминали перебрасывание ошибок (rethrow) — то есть делегирование их «вышестоящему» обработчику, если текущий не знает, как их обработать.

Делегирование создаёт иерархию обработчиков. Как с любыми иерархиями, её лучше держать как можно более плоской. Не всегда этого получится добиться, но всё же чем иерархия площе, тем проще с ней работать. Один из вариантов организации обработчиков можно собрать из 3 уровней:

Обёртки над «низкоуровневым» кодом

На «низком» уровне мы оборачиваем в try-catch браузерные API, функции для работы с сетью, общения с девайсом и т.д.

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

Обработчики пользовательских сценариев

На этом уровне мы обрабатываем ошибки пользовательских сценариев: ошибки доменной логики и ожидаемые инфраструктурные ошибки низкого уровня.

К слову 🍰
Такие обработчики могут ловить ошибки как одного сценария, так и набора связанных фич. Первые могут называться обработчиками ошибок юзкейса, а вторые — обработчиками слайса приложения.
Удобство обоих вариантов зависит от многих факторов и рекомендовать конкретный довольно сложно, поэтому в этой главе мы просто объединим их в «один уровень».

Сообщать пользователю о проблемах обычно проще всего именно отсюда, потому что на этом уровне есть доступ к сервисам, которые могут, например, вывести сообщение на экран или отправить запрос в алёрт-мониторинг. Не во всех проектах это будет самое удобное место, но чаще всего основу обработки удобно выстроить на этом уровне.

Обработчики последней надежды

На уровне всего приложения или веб-страницы мы отлавливаем все не обработанные ранее ошибки и паники.

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

Например 📌
В React для обработки ошибок пользовательских сценариев и обработки последней надежды могут пригодиться Error Boundaries.23 Они отлавливают ошибки во время рендера и выводят UI, где разработчики могут сообщить пользователю о проблеме.
Кроме этого 🗑
В JavaScript мы можем отловить необработанные ошибки на уровне глобального объекта с помощью специальных событий.242526 Такие события обычно содержат информацию о причине и месте возникновения ошибки, поэтому их обработку полезно сочетать с логированием и инструментами алёрт-мониторинга.

Предвалидация данных

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

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

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

При рефакторинге обработки ошибок мы также можем пользоваться этой идеей и отодвигать проверки данных ближе ко входу в приложение:

Прямоугольник с входящей и выходящей стрелками; места, где стрелки соединяются с прямоугольником, подписаны как «Валидация входных данных» и «Проекция выходных данных» соответственно

Снаружи данные опасные, внутри — безопасные; наличие невалидных данных завершит выполнение и передаст управление обработчику ошибок

Подробнее 🛟
О предвалидации, поствалидации, селекторах и безопасности данных мы поговорим более детально в главе об архитектуре.

Footnotes

  1. “Domain Modeling Made Functional” by Scott Wlaschin, https://www.goodreads.com/book/show/34921689-domain-modeling-made-functional

  2. “Errors Are Not Exceptions” by swyx, https://www.swyx.io/errors-not-exceptions

  3. “The Error Model” by Joe Duffy, http://joeduffyblog.com/2016/02/07/the-error-model/

  4. “Replacing Throwing Exceptions with Notification in Validations” by Martin Fowler, https://martinfowler.com/articles/replaceThrowWithNotification.html 2

  5. “The Pragmatic Programmer” by Andy Hunt, https://www.goodreads.com/book/show/4099.The_Pragmatic_Programmer

  6. AggregateError, MDN, https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/AggregateError

  7. “A behavioral notion of subtyping” by Barbara H. Liskov, Jeannette M. Wing, https://dl.acm.org/doi/10.1145/197320.197383

  8. More on Functions, never, TypeScript: Documentation, https://www.typescriptlang.org/docs/handbook/2/functions.html#never

  9. fp/ts, Typed functional programming in TypeScript, https://github.com/gcanti/fp-ts 2

  10. Error-first callbacks, Node.js Documentation, https://nodejs.org/api/errors.html#error-first-callbacks

  11. Fail-fast, Wikipedia, https://en.wikipedia.org/wiki/Fail-fast

  12. “Notification” by Martin Fowler, https://martinfowler.com/eaaDev/Notification.html

  13. “Railway Oriented Programming” by Scott Wlaschin, https://fsharpforfunandprofit.com/rop/

  14. switch-exhaustiveness-check, ES Lint TypeScript, https://typescript-eslint.io/rules/switch-exhaustiveness-check/

  15. pipe, fp/ts, https://gcanti.github.io/fp-ts/modules/function.ts.html#pipe

  16. sweet-monads, Easy-to-use monads implementation with static types definition, https://github.com/JSMonk/sweet-monads

  17. neverthrow, Type-Safe Errors for JS & TypeScript, https://github.com/supermacro/neverthrow

  18. “Against Railway-Oriented Programming” by by Scott Wlaschin, https://fsharpforfunandprofit.com/posts/against-railway-oriented-programming/

  19. Cross-Cutting Concern, Wikipedia, https://en.wikipedia.org/wiki/Cross-cutting_concern

  20. “Code That Fits in Your Head” by Mark Seemann, https://www.goodreads.com/book/show/57345272-code-that-fits-in-your-head

  21. Decorator Pattern, Refactoring Guru https://refactoring.guru/design-patterns/decorator

  22. Adapter Pattern, Refactoring Guru, https://refactoring.guru/design-patterns/adapter

  23. Предохранители в React, https://ru.reactjs.org/docs/error-boundaries.html

  24. Window: error event, MDN Web Docs, https://developer.mozilla.org/en-US/docs/Web/API/Window/error_event

  25. Window: unhandledrejection event, MDN Web Docs, https://developer.mozilla.org/en-US/docs/Web/API/Window/unhandledrejection_event

  26. “Dealing with Unhandled Exceptions”, by Alexander Zlatkov https://blog.sessionstack.com/how-javascript-works-exceptions-best-practices-for-synchronous-and-asynchronous-environments-39f66b59f012#ecc9