Обзор шаблонов
Обработка ошибок — это тема, о которой часто забывают и пропускают в пользу сценариев «солнечного дня» как в коде, так и в тестах. Отчасти это происходит из-за нашей природной лени, а отчасти из-за того, что обработка ошибок чертовски сложна. Это сложно, утомительно и в то же время бесполезно.
Например, как вы решаете, следует ли обработать ошибку локально или пропустить ее для обработки централизованным обработчиком ошибок? Как вы справляетесь с распространением ошибок через несколько асинхронных вызовов службы? Как определить сеанс (или пользователя), которому он принадлежит, и предоставить контекст для дальнейшего изучения?
В этой статье мы пытаемся сделать обработку ошибок немного проще или немного проще.
Структура статьи
Давайте установим некоторые ожидания, чтобы облегчить наше путешествие по статье, не так ли?
Что это?
Статья посвящена общему подходу к обработке ошибок в Node. Хотя Node, несомненно, представляет собой особую снежинку платформы, большинство концепций, которые мы собираемся обсудить, существуют уже довольно давно. С учетом сказанного, склонность Node к асинхронности изменила некоторые подходы, в основном усложнив обработку ошибок.
Мы попытаемся обсудить эти концепции через призму Node, где это уместно, и установим ряд правил (рекомендаций, на самом деле, но «правило» — это гораздо более короткое слово для ввода) по пути.
Наконец, будет несколько примеров кода, в основном схематичных, иллюстрирующих эти концепции и парадигмы.
Что это не так?
Статья не предназначена для использования в качестве окончательного источника, а скорее является питательной средой для дальнейших мыслей и идей, которые можно применить в ваших приложениях.
Кроме того, хотя Node накладывает определенные ограничения на способ распространения и сообщения об ошибках, основные принципы не отличаются от других платформ и, по сути, в значительной степени зависят от установленных шаблонов.
Кроме того, поскольку JavaScript является таким гибким языком, некоторые стили программирования, такие как функциональные, могут не полностью (вообще?) извлечь пользу из статьи.
Короче, читайте, спорьте, не соглашайтесь, кидайте тухлыми помидорами. Не пропускайте.
Глоссарий двух слов
Мы, наряду со многими другими «текстами» JavaScript, будем использовать термины исключение и ошибканесколько взаимозаменяемо. В большинстве случаев ошибка — это то, что описывает ошибочное состояние или состояние системы и может стать исключением, когда вы выбрасываете (поднимаете) ее. Кроме того, исключения выбрасываются ядром или средой выполнения, возможно, в результате ошибки более низкого уровня.
Некоторые языки, например Java, различают эти два явления: исключение предназначено для перехвата программой, тогда как ошибка может оставаться неотмеченной.
JavaScript, с другой стороны, не делает такого различия, даже если некоторые ошибки называются исключениями, например DOMException, а другие сохраняют свое название.
Ошибка Эргономика
У программных систем много пользователей. Реальные пользователи и их действия (реальные или воображаемые), конечно же, оказывают наибольшее влияние на различные архитектурные и технологические решения в процессе реализации.
Однако есть еще одна группа людей, которые ежедневно используют программное обеспечение и воздействуют на него, но редко считаются его пользователями — разработчики и сопровождающие.
Подобно тому, как «код пишется один раз, но перечитывается много раз», это люди, которые используют систему больше всего, особенно в самые ответственные периоды ее создания и стабилизации. Они полагаются на различные механизмы, чтобы иметь возможность поддерживать, исправлять и улучшать систему, и ошибки являются одними из наиболее важных.
Иногда при обсуждении обработки ошибок или мониторинга эргономика разработки отодвигается на второй план, если вообще стоит. Мы постараемся изменить это, хотя бы в рамках этой статьи, и рассмотрим влияние конкретного решения на техническое обслуживание/разработку, где это возможно и/или применимо.
Типы ошибок
Не все ошибки одинаковы. На самом деле существует две очень разные группы ошибок: технические (которые содержат программистские и операционные ошибки) и ошибки бизнес-логики, которые отличаются в природе и с ними нужно обращаться по-разному.
Мы пропустим слово «логика» в ошибках бизнес-логики и для краткости будем называть их бизнес-ошибками.
Ошибки программиста
Как вы увидите, концептуальные различия между этими ошибками достаточно важны, чтобы создавать, доставлять и обрабатывать их по-разному.
Большинство программистов, исключая нынешнюю компанию, допускают ошибки. Они забывают объявлять переменные, вызывают функции с параметрами неверных типов и вообще пропахают косяк запахами кода.
Эти ошибки позже, во время сборки, тестирования или выполнения, «становятся» ошибками.
Вот пример последней ошибки программиста:
if (enemyMissiles.inFlight() = true) { ourMissiles.launch(); }
Операционные ошибки
Не разрушай мир! Обратите внимание, ворсите и тестируйте!
В отличие от ошибок программиста, операционные ошибки — это то, что является частью правильного функционирования системы — то, что ожидается. В большинстве случаев они являются результатом неисправности, недоступности или отсутствия внешней системы или объекта.
Обратите внимание на условие «правильного функционирования» выше. Это не абсолютный термин, а то, что должно быть определено для каждой системы и каждого интерфейса в ней.
Например, если вы предоставляете своему пользователю форму, а она заполняет ее неправильно — это не должно считаться операционной или программной ошибкой, а нормальным поведением. Примером обработки может быть разрешение пользователю повторно отправить, указывая при этом на неправильно заполненные поля.
Напротив, если ваш пользователь не имеет возможности вводить произвольный текст и выбирает только группу элементов — получение их поврежденными может указывать на операционную ошибку (какой-то сбой на сетевом уровне), ошибку программиста (неправильный синтаксический анализ/сериализацию пользовательского выделение) или даже атака (какая-то инъекция).
Кроме того, ошибки могут быть связаны. Ожидается, что тайм-аут во время HTTP-вызова между двумя службами в системе произойдет и должен быть обработан. То же самое для системы логирования, которая не работает, когда вы отправляете отчет об ошибке — она выдает ошибку (например, 503), с которой нужно разобраться.
Можно представить это как цепочку ошибок:
- ошибка программиста, вызвавшая утечку памяти в системе ведения журналов
- в результате систему регистрации пришлось перезапустить, чтобы сбросить память
- другая ошибка программирования/архитектуры/развертывания не привела к избыточности, и поэтому система ведения журнала стала недоступной.
- другая система получила ошибку операции (503 сверху)
- при обработке такого случая была пропущена еще одна ошибка программиста, и исходная система вышла из строя
Ошибки программиста не существуют в вакууме, они часто распространяются, становясь операционными ошибками или, что еще хуже, ошибками бизнес-логики.
Ошибки бизнес-логики
Вторая группа ошибок — ошибки бизнес-логики — относится к ошибочным условиям, обычновызванным комбинацией ошибок программиста и операционных ошибок, которые нарушают некоторый набор бизнес-правил.
Например, разрешение снятия средств со счета при недостатке средств является ошибкой бизнес-логики, скорее всего, результатом какой-то основной ошибки более низкого уровня:
- операционная ошибка — сбой сети при попытке проверить средства, и
- ошибка программиста — проглатывание ошибки и возврат некоторого значения по умолчанию, отличного от нуля, или
- ошибка программиста — вообще не проверяется наличие средств
Следует отметить, что во многих случаях то, что считается ошибкой, является правильным логическим путем в системе. Пользователь в нашем приложении для онлайн-покупок, пытающийся приобрести продукт, доступный только в определенных странах (я смотрю на вас, Amazon), является ошибочным с точки зрения бизнес-правил действием, но не обязательно может привести к возникновению ошибки или выброшено исключение.
Создание и доставка ошибок
Любой проект обработки ошибок механизмов должен сначала установить метод, с помощью которого ошибки создаются и доставляются по всей системе.
Обратите внимание, что в некоторых текстах вместо слова "доставка" используется слово "отчетность". Однако эти два понятия имеют разное семантическое значение в современной Node/веб-разработке: отчеты предполагают взаимодействие с централизованной системой, которая собирает ошибки, а доставка говорит о методах распространения ошибок в коде/сети/интерфейсах системы.
Существует несколько парадигм, например, размер и тип отступа в вашем редакторе (должно быть 4 пробела!) или вставка новой строки перед открывающими скобками (вы не должны !), их сторонники относятся к ним ревностно религиозно.
Несмотря на то, что мы знаем (и позже сообщим вам), какой из них лучший (доверие всегда играет ключевую роль в такого рода дискуссиях), он абсолютно стоит обсудить варианты.
Особый случай библиотек
Прежде чем углубляться в «обычные» случаи, давайте обсудим особый — доставку и создание ошибок из кода библиотеки.
Библиотека в самом широком смысле — это автономный и хорошо инкапсулированный модуль, который можно использовать в различных местах системы, иногда с совершенно разными парадигмами обработки ошибок.
Как библиотечная функция должна обрабатывать или доставлять ошибки?
Короткий ответ: не должно. Он должен отложить решение потребляющей системы о том, как и должны ли ошибки создаваться и доставляться. Любое предположение о том, как потребляющая система хочет обрабатывать ошибки, скорее всего, не сработает, чем более успешной и универсальной является абстракция библиотеки.
Предпочтительным способом, особенно в Node, должно быть использование инверсии управления и требование, чтобы потребитель предоставлял через настройки или непосредственно функции обратные вызовы для принятия решений и отчетов.
Примером для библиотеки, выполняющей какие-то вычисления, может быть:
const lib = new Library({ shouldReportError() { // logic that decides when and where to report return shouldReport; }, onError() { // create the error ... return error; } }); lib.someMethod(value);
Как автор библиотеки, предоставьте клиентской системе возможность внедрить обратный вызов сообщения об ошибке и, в некоторых случаях, обратный вызов, чтобы решить, возможно, в каждом конкретном случае, следует ли создавать ошибку. и вообще доставлено.
Объекты значения ошибки
Иногда их ошибочно называют кодами ошибок. Мы объясним, почему это ошибка, позже в главе Идентификация ошибок и примем Значение ошибки в качестве названия парадигмы.
Идея состоит в том, чтобы вернуть нормализованный объект из функции:
function foo() { ... if (someCondition) { return { error: true, errorCode: ‘AAA’, context: ..., result: ... // since the same object is used for success }; } ... } // Consume: const result = foo(); if (result.error) { // handle the error and exit? return? throw an exception? } else { // continue the flow }
Плюсы
Трудно найти какое-либо искупительное свойство этого шаблона, кроме, может быть, единообразия в результатах функций? Даже тогда — он вводит много ненужного шаблонного кода для проверки того, является ли результат ошибкой, разворачивает объект и затем, возможно, снова его перепаковывает для дальнейшего распространения.
Минусы
Шаблон:
- запрещает функциям возвращать примитивные значения
- требует сложной и повторяющейся обработки ошибки/успеха
- вводит код создания ошибок, который не является атомарным по ошибкам и может привести к дополнительным ошибкам.
- самое главное, это вовсе не заставляет потребителя обрабатывать ошибку. Следующий потребительский код потерпит неудачу в другом месте, что усугубит проблему:
foo();
Вариант
Потребитель полагался на побочные эффекты функции (и это нормально) и не принуждается ни языком, ни каким-либо другим образом что-то обрабатывать.
Уточнение шаблона заключается в том, что вы должны возвращать значимое значение из вызова функции, где это возможно, так же, как array.indexOf(item) возвращает -1, если совпадений не найдено.
Хотя такое предложение имеет смысл, мы утверждаем, что такое поведение подпадает под бизнес-ошибку, с которой не следует бороться. Невозможность найти элемент в массиве является ожидаемой функцией интерфейса, и это вовсе не исключение и даже не ошибка!
Почетное упоминание
Шаблон использования возвращаемого значения для обозначения успеха, неудачи и неожиданного результата является распространенным шаблоном в функциональных языках. Хотя у JavaScript есть некоторые функциональные возможности, он редко является единственным стилем, используемым в типичной программе JavaScript.
Итак, если вы не используете JavaScript как чисто функциональный язык во всей вашей системе (престижность!), вердикт см. далее:
Вердикт
Избегайте любой ценой!
Обработчики ошибок
Другой способ доставить ошибку — предоставить обратный вызов ошибки:
function foo(params, onError) { ... if (someCondition) { onError({ errorCode: ‘AAA’, context: ... }); } ... } // Consume: foo(value, (err) => { ... });
Плюсы
- Этот шаблон не страдает тем же ограничением, что и объекты Error Value, а именно тем, что он не заставляет функцию возвращать предопределенный нормализованный объект результата.
Минусы
Самые яркие из них:
- что произойдет, если в обработчике onError есть ошибка, должен ли он также как-то закрыться при обратном вызове ошибки?
- нужно добавить дополнительный параметр (или прибегнуть к передаче объекта Бога) во все функции, которые могут выдать ошибку; тогда вопрос становится откуда вы знаете, какая функция будет выполняться? Это быстро приведет к общему шаблону, согласно которому всезначимые функции
- Нет контракта или принуждения, чтобы убедиться, что обработчик вызывается только один раз
Вердикт
Избегать.
Обратные вызовы с первой ошибкой
Приведенный выше шаблон и тот факт, что Node является в основном асинхронной платформой, создали новый шаблон, часто называемый обратный вызов с первой ошибкой:
function foo(callback) { ... if (someCondition) { callback({ errorCode: ‘AAA’, context: ... }); } else { callback(null, ...); } ... } // Consume: foo((err, result) => { if (!err) { // handle failure } else { // handle success } });
Плюсы
Это явно работает, так как многие Node-библиотеки и фреймворки успешно используют его, но этот успех может быть хотя бы частично вопреки, а не из-за него:
- Это последовательный, четко определенный шаблон
- он хорошо работает как для синхронного, так и для асинхронного кода (что на самом деле можно считать недостатком)
Минусы
- Если используется как для синхронной, так и для асинхронной доставки ошибок, становится труднее различать эти два
- Создает Пирамиду Судьбы (и мы все видели одну, так что не ужасная иллюстрация)
- Принуждает вручную распространять стек ошибок и контекст через Pyramid of Doom, независимо от того, собираетесь ли вы обрабатывать ошибку в конкретном обратном вызове или нет.
- Нет контракта или принуждения, чтобы убедиться, что обработчик вызывается только один раз
Вердикт
Избегать. Переход к следующему шаблону — Обещания, где это возможно!
События
Вообще говоря, события — это форма обратного вызова, но с той разницей, что ожидается, что они произойдут произвольное количество раз, таким образом вызывая обратный вызов несколько раз и, возможно, доставляя несколько ошибок или других событий.
Хотя существуют вариации (и тонкие различия между генераторами событий и наблюдаемыми), общая форма такова:
foo(param) { ... return eventEmitter; } foo(value).on('error', (err) => { // handle failure }).on('end', (result) => { // handle success });
Плюсы
- Гибкость в различных событиях, включая различные события ошибок (хотя и редко)
- Последовательность потребления успеха и неудачи
- Возможность повторного использования (в отличие от одноразового характера других шаблонов)
Минусы
- В основном зарезервировано для асинхронного выполнения
- Создает Пирамиду Судьбы (конечно, меньшую)
- Точно так же, как обратный вызов с ошибкой, заставляет вручную распространять стек ошибок и контекст через Pyramid of Doom, собираетесь ли вы обрабатывать ошибку в конкретном обратном вызове или нет.
- Недетерминированность, поскольку точное количество раз или даже взаимодействие с другими возможными событиями не определено точным образом (представьте, что ошибка «останови мир» срабатывает несколько раз или «обгоняется» другим не связанным с ошибкой событием).
Исключения
Наконец, один из самых основных способов создать и доставить ошибку — это сгенерировать или выдать исключение. Многие языки предоставляют для этого механизм, который в JavaScript выглядит следующим образом:
function foo() { ... if (someCondition) { throw new Error('some condition is violated'); } ... }
Одно явное предостережение заключается в том, что в JavaScript вы можете выбросить что угодно:
function foo() { ... if (someCondition) { throw 'some condition is violated'; } ... }
как и в случае строки выше. Вы не должны, и это станет ясно позже в статье.
Другое возможное, но абсолютно неправильное использование для исключений похоже на это:
function bar() { try { while (someCondition) { ... throw new Error('stop'); } } catch () { // continue normal flow } }
где исключения используются как часть механизмов управления потоком.
Плюсы
- Часть спецификации языка, которая подлежит оптимизации и улучшениям со стороны движка, TC39 и т. д.
- Четкий и четкий способ показать, что в коде существует исключительный логический путь.
- Не накладывает никаких ограничений на функцию метания
- Гарантированно выполняется один раз
Минусы
- Не работает для асинхронного кода:
try { setTimeout(() => { ... throw new Error('failure'); ... }, 100); } catch (err) { // handle failure }
catch никогда не будет выполнен, так как он существует до создания исключения.
2. Скрытый/альтернативный (как и большинство других паттернов) поток внутри кода
Вердикт
По возможности используйте в синхронном коде рекомендуемый ниже подход.
Кроме того, обратите внимание на Правило № 1 ниже.
Правило №1. Не используйте исключения для управления потоком
Не используйте исключения там, где подойдет стандартная языковая конструкция, такая как break или return:
- это не сразу очевидно для программиста
- это приводит к ненужному снижению производительности (break сработает так же хорошо)
- это предотвращает оптимизацию компилятора
Обещания
В ES6 промисы, уже популярные, стали де-факто способом написания асинхронного кода, и на то есть веская причина.
Они позволяют писать код гораздо более лаконичным образом, ссылкой на будущее значение, которую представляют промисы, можно манипулировать так же, как и любой другой ссылкой (передавать в функции, помещать в массивы и т. д.):
function foo(param) { ... return promise; } // Consume: foo(value).then((result) => { // handle success }).catch((err) => { // handle failure });
Плюсы
- Гарантировано выполнение обратных вызовов (затем или catch) один раз
- Ошибки в затем передаются в catch.
- Гарантированно асинхронный
- Последовательность потребления успеха и неудачи
Минусы
- Различные парадигмы кодирования для синхронного и асинхронного кода
- Как и во всех других случаях, исключение в коде обработки ошибок (catch) никак не перехватывается.
Вердикт
По возможности используйте в асинхронном коде рекомендуемый ниже подход.
Гибрид (рекомендуется)
С официальным введением async/await
в Node версии 7.6 рекомендуется использовать гибрид шаблонов Exception и Promise.
Вот пример такой комбинации:
try { const matches = await getMatches(text); const products = getProducts(matches, filters); ... } catch (err) { // handle errors }
Вы можете видеть, что один и тот же блок кода содержит как асинхронный, так и синхронный код и заключен в один try/catch.
Плюсы
- Все плюсы паттернов Exception и Promise
- Согласованность всего кода, асинхронного или синхронного
Минусы
- Все еще скрытый/альтернативный (как и большинство других шаблонов) поток в коде
Правило № 2. Используйте комбинацию исключений и обещаний
- использовать промисы для асинхронного кода
- используйте async/await для обработки асинхронного кода
- использовать throw/try/catch во всем (асинхронном или синхронном) коде
Немного о производительности
В заключение я хотел бы внести некоторый смысл в то, что, кажется, сохраняется в сообществе JavaScript. Существует мнение, что генерация и перехват исключений — это очень затратная операция.
Хотя мы не утверждаем, что усовершенствовали метод измерения, но с помощью очень хорошо поддерживаемой и известной библиотеки benchmark.js мы настроили тест выполнения для двух примерно эквивалентных фрагментов кода.
Один для производства и потребления исключений:
function exceptionProducer(param) { if (param > 100) { throw new Error('param is larger than 100'); } } function exceptionConsumer() { try { exceptionProducer(101); } catch (err) { // do something with error, just for // completness sake const {message} = err; } } exceptionConsumer();
и аналогичный для генерации и потребления ошибок через return
:
function errorProducer(param) { if (param > 100) { return new Error('param is larger than 100'); } } function errorConsumer() { const result = errorProducer(101); if (result instanceof Error) { // do something with error, just for // completness sake const {message} = result; } } errorConsumer();
после запуска их как benchmark.jssuite на узле вот результаты:
- Производство и потребление ошибок x 450 317 операций/сек ±1,38 %
- Производство и потребление исключений x 318 017 операций/сек ±1,56 %
На этом этапе должно быть ясно, что хотя сторона ошибки «победила», но абсолютные значения ошеломляют: среднее время выполнения для одного выполнения (benchmark.js предоставляет статистические результаты) для выборок исключений и ошибок составляют 3,144 и 2,22 микросекунды соответственно.
Хотя этот тест очень и очень далек от научности, он также является отличной иллюстрацией преждевременной оптимизации.
Есть цитаты Гарольда Абельсона:
«Программы должны быть написаны для того, чтобы люди их читали, и лишь случайно для того, чтобы машины выполняли их».
и Дональд Кнут:
«Мы должны забыть о малой эффективности, скажем, примерно в 97% случаев: преждевременная оптимизация — корень всех зол. Тем не менее, мы не должны упускать наши возможности в отношении этих критических 3%».
которые резюмируют обсуждение выше.
Правило №3. Не оптимизируйте преждевременно
Не оптимизируйте за счет удобочитаемости, структурной надежности и эргономики разработчика. Используйте исключения и ошибки там, где это уместно, и оптимизируйте критические пути после измерений.
Отчетность и отслеживание
Хотя большая часть статьи посвящена обработке ошибок, она не существует (и полезна только наполовину) в вакууме. Отчетность и отслеживание ошибок имеют первостепенное значение для обеспечения работоспособности системы в долгосрочной перспективе. Хотя подход «если дерево упадет в лесу…» может поначалу работать, в конечном итоге он всплывет на поверхность и приведет к непредсказуемому краху системы. Что еще хуже — вообще не происходит сбоев, при этом неизвестные данные повреждаются или взаимодействие с внешними системами происходит неправильным образом (и возникают ужасные ошибки бизнес-логики, выходящие из-под контроля).
Обычно об ошибках сообщается централизованно или локально в удаленную систему сбора, где они классифицируются, уведомляются и, как правило, становятся доступными для обнаружения и анализа.
Информация о месте вызова ошибок, контексте и первопричине имеет решающее значение для своевременного обнаружения и исправления этих ошибок. Эта информация должна быть частью того, что основная система передает при обработке ошибок.
Идентификация ошибки
Итак, мы определились с шаблоном обработки ошибок и интегрировались с системой сбора ошибок.
Как вы гарантируете, что, когда возникнет необходимость, мы сможем извлечь из этого пользу?
Соединение точек
Одной из наиболее фундаментальных потребностей при анализе журналов отслеживания и ошибок распределенной системы является возможность отслеживать след запроса в изолированном контексте.
Конечно, в наши дни намного проще использовать внешнюю систему сбора/отчета об ошибках. Несколько отличных вариантов, таких как Logz.io или Rollbar.
несколько запросов, поступающих одновременно без гарантированного порядка. Часто это далеко не простая задача. Препятствия могут включать:
- идентичные (с точки зрения бизнеса) запросы, исходящие из одного и того же пользовательского сеанса
- журналы ошибок и другие сообщения мониторинга могут не обязательно располагаться вместе, а разрешения на доступ к этим журналам могут быть недоступны одной и той же группе сопровождающих.
- балансировка нагрузки может разделить части одного и того же «сеанса» между несколькими узлами
В плохо спроектированной системе криминалистика ошибок может быть чрезвычайно утомительной и очень требовательной задачей, требующей навыков и понимания системы.
Учтите, что для разных частей цепочки запросов (пользователь → FE → BE → сервис → сервис):
- имена машин могут отличаться
- временные метки могут не совпадать из-за разницы в системном времени или даже часовых поясах и переходе на летнее время
- бизнес-идентификаторы могут переплетаться из-за нескольких идентичных запросов в рамках одного и того же «сеанса».
Решение состоит в том, чтобы создать уникальный идентификатор ошибки, независимый от каких-либо логических идентификаторов, и вернуть его обратно вызывающей стороне через все системы, обеспечив при этом его включение в любой отчет об ошибке.
Это подводит нас не к одному, а к двум новым правилам:
Правило № 4. К черту временную метку
Используйте уникальный, желательно глобальный, идентификатор для любой ошибки, о которой вы сообщаете, и возвращайте этот идентификатор вызывающей стороне через цепочку обработки.
Имейте в виду, что временные метки по-прежнему предоставляют очень полезную информацию для криминалистического анализа, просто они не должны быть единственным способом выявления «распределенной» ошибки.
И по поводу генерации ID:
Правило № 5. Не используйте идентификаторы повторно
Не используйте какой-либо логический бизнес-идентификатор в качестве замены сгенерированного — идентичные или похожие запросы, исходящие из одного и того же «сеанса», сделают его непригодным для использования.
Контекст ошибки и метаданные
Теперь, когда мы доставляем связанные ошибки под одним и тем же зонтичным идентификатором, возникает вопрос, о чем сообщать? Какая информация и в каком объеме должна передаваться через границы службы во внешнюю систему отслеживания и сбора ошибок?
Рассмотрим пользователей ошибки — они включают в себя систему сбора и отслеживания ошибок, слои системного кода, вызывающие «нарушающую» функцию, и в некоторых случаях «настоящих» пользователей системы.
Это подводит нас к рекомендации включать три типа информации:
- своего рода нормализованный код/статус ошибки для автоматического использования пользователями, не являющимися людьми; это упростит принятие решения о том, что делать с ошибкой на более высоких уровнях кода, какой удобочитаемый контент (если таковой имеется) предоставить пользователю ив конечном итоге правильную маркировку и сортировку в системе отслеживания.
- контекст, в котором произошло ошибочное состояние, которое представляет ошибка; это включает неструктурированную информацию, такую как различные элементы данных, и более структурированную информацию, такую как стек вызовов, в основном подходящую для человеческого анализа.
Таким образом, правило № 6:
Правило № 6. Слуга двух господ
Включайте, помимо уникального идентификатора, дватипа информации о любой созданной и доставленной ошибке:
- код ошибки из набора предопределенных кодов для автоматического использования
- криминалистическая информация для использования человеком, которая включает идентификаторы, трассировки стека и т. д.
Если нет какого-либо конкретного ограничения на размер информации, связанной с ошибкой (например, есть некоторая сериализация), err должен быть более подробным. Это может включать такие вещи, как параметры запроса, вычисляемые данные или другие контексты. Дополнительная информация, вероятно, поможет более своевременно определить причину и контекст ошибки.
Иерархия ошибок
Некоторые языки, в частности Java, допускают обработку исключений с проверкой типов с помощью предложений catch , что позволяет писать такой код:
try { int x = doSomething(); } catch (SomeError e) { // handle some error } catch (SomeOtherError e){ // handle some other error }
а остальные ошибки распространяются на код более высокого уровня.
Такая возможность позволяет реализовать Иерархию ошибок, аналогичную тому, как вода отскакивает от водопадной лестницы — сначала меньшие ступени (более конкретные исключения), а затем более крупные (более общие исключения):
Или, в нашем прагматичном Java-коде, как исключение проходит через предложения catch, от наиболее конкретного к наиболее общему:
class SpecificError extends GenericError {} class MoreSpecificError extends SpecificError {} class SuperDuperSpecificError extends MoreSpecificError {} try { this.doSomething(); } catch (SuperDuperSpecificError e) { // super duper specific handling } catch (MoreSpecificError e) { // more specific handling } catch (SpecificError e) { // specific handling } catch (GenericError e) { // run-of-the-mill handling }
Однако JavaScript и, в более широком смысле, Node представляют собой язык типа «все или ничего», поэтому синхронны:
try { const x = doSomething(); } catch (err) { // handle failure }
или, в Promise-land, асинхронные предложения catch :
doSomething().then((result) => { return doSomethinElse(); }).catch((err) => { // handle failure });
поймает все.
Что же тогда делать бедному разработчику Node?
На данный момент единственный способ фактически установить иерархию ошибок — это реализовать ее с помощью instanceof:
class SpecificError extends Error {} class MoreSpecificError extends SpecificError {} class SuperDuperSpecificError extends MoreSpecificError {} try { const x = doSomething(); } catch (err) { if (err instanceof SuperDuperSpecificError) { // super duper specific handling } else if (err instanceof SuperDuperSpecificError) { // more specific handling } else if (err instanceof MoreSpecificError) { // specific handling } else if (err instanceof Error) { // run-of-the-mill handling } }
с аналогичным подходом в случае промисов.
Любопытно, что Firefox — единственный браузер, который поддерживает условное предложение catch, позволяющее сделать это:
try { const x = doSomething(); } catch (err if err instanceof SuperDuperSpecificError) { // super duper specific handling } catch (err if err instanceof MoreSpecificError) { // more specific handling } catch (err if err instanceof SpecificError) { // specific handling } catch (err if err instanceof Error) { // run-of-the-mill handling }
Чтобы подчеркнуть это, Идентификация ошибок/мета и Иерархия ошибок помогают сделать различие между ошибками более простым и понятным.
Обработка ошибок
Теперь, когда мы решили:
- как мы собираемся создавать и выявлять ошибки
- какую информацию мы собираемся прикрепить к ним, и
- установим ли мы какую-то иерархию ошибок
мы можем начать обсуждение того, как следует обрабатывать доставленные ошибки.
Разделение ошибок
В сильно распределенной системе, такой как наше приложение выше, вполне вероятно, что вы будете переключаться между бизнес-интерфейсом и техническим интерфейсом.
Например, когда приведенная выше система каталогов получает поисковый запрос от обслуживающей системы (FE-сервера), она, скорее всего, сделает следующее:
- извлечь из запроса поисковый запрос, пользовательский контекст и возможные фильтры
- позвонить в службу поисковой системы, чтобы получить набор результатов, максимально близких к поисковому запросу
- вызов базы данных с идентификаторами из результатов поиска и фильтрами, извлеченными из запроса
- позвоните в службу соответствия требованиям/доступности с пользовательским контекстом и результатами базы данных, чтобы отфильтровать недоступные продукты.
- вернуть оставшиеся продукты в систему обслуживания для представления пользователю
Этот процесс может быть несколько схематично и с большим количеством упрощений для краткости реализован следующим образом:
app.get('/search', async (req, res) => { const {text, filters} = req.query; const {context} = req.body; try { const matches = await getMatches(text); const products = await getProducts(matches, filters); const availableProducts = await getAvailable(products, context); res.status(200).send(availableProducts); } catch (err) { // handle errors } });
Рассмотрим возможно ошибочные/исключительные условия, с которыми может столкнуться приведенный выше фрагмент кода:
- поисковая система недоступна
- поиск не соответствует
- поисковый индекс не инициализирован
- нет соответствующих (идентификаторам) продуктов
- неизвестный пользователь
назвать несколько.
Некоторые из них, возможно, связаны с операционными или программными ошибками, например «поисковая система недоступна» или «индекс поиска не инициализирован», где есть:
- проблема с сетью,
- или программист/devop забыл инициализировать поисковую систему с ранее сгенерированным индексом.
Некоторые из них являются явными бизнес-ошибками, например «нет совпадений при поиске» для определенного поискового текста.
Наконец, некоторые из них могут быть любыми, например «неизвестный пользователь». Это ошибочное условие может указывать на то, что:
- система управления пользователями не работает
- пользовательский контекст распространяется неправильно или
- есть попытка олицетворения, когда сеанс (с токеном аутентификации внутри) копируется от пользователя, отличного от пользователя, вошедшего в систему в данный момент.
Подумайте, как будет выглядеть наивнаяобработка этих ошибок в приведенном выше коде (учитывая, что мы пошли по пути иерархии ошибок ):
app.get('/search', async (req, res) => { ... try { ... } catch (err) { if (err instanceof ConnectionRefused) { } else if (err instanceof NoIndexFound) { } else if (err instanceof NoMatch) { } else if (err instanceof IncorrectUserInfo) { } else if (err instanceof AuthenticationError) { } else if (err instanceof Error) { } } });
Вот вопросы, среди многих других, которые сразу же возникают в отношении этих исключений:
ConnectionRefused или NoIndexFound
- откуда вы знаете, что это исключение поисковой системы?
- как повторить попытку?
- какой ответ возвращается обслуживающей системе
Не соответствует
- это приемлемый результат от системы каталогов?
Неверная информация о пользователе или ошибка аутентификации
- следует ли сообщать об этом обслуживающей системе или молчать и сообщать об этом?
- следует ли передавать запрос на повторный вход (в случае, если время ожидания токена истекло)?
Несмотря на то, что исходный код вполне действителен:
app.get('/search', async (req, res) => { ... try { const matches = await getMatches(text); const products = await getProducts(matches, filters); const availableProducts = await getAvailable(products, context); res.status(200).send(availableProducts); } catch (err) { // handle errors } });
обработка ошибок нарушает как минимум два принципа SOLID — принцип единой ответственности и принцип инверсии зависимостей.
Обработка отказа в соединении — это совершенно другая область ответственности и техническая область, чем обработка возможной хакерской атаки или обработка отсутствия совпадений для поиска. Они могут даже требовать работы с разными людьми — как в коде, так и в криминалистическом анализе.
NoIndexFound — это деталь реализации поисковой системы, а NoMatch — соответствующий интерфейс, зависящий от входных данных (подробнее об этом позже).
Мы разработали и закодировали с учетом реализации, а не абстракции, и поэтому обработчик маршрута также сделал слишком много и на разных уровнях абстракции.
Как вы относитесь к такой проблеме?
Разделение (которое является популярным решением) на самом деле не вариант, и это не совсем правильно, поскольку ожидается, что обработчик маршрута будет своего рода концентратором, который организует эти различные действия. Кроме того, если вы посмотрите на интерфейсы вызовов (getMatches, getProducts, getAvailable), вы увидите приемлемый баланс абстракции.
Решение состоит в том, чтобы разделить разные уровни ошибок, как и в случае с вызовами функций. Система поисковой системы не должна выявлять все эти технические ошибки, вместо этого выбирая, возможно, одну, глобальную в рамках нашей распределенной системы: SearchSystemError.
Чем более технической является система, тем меньше ошибок она должна подвергать системе более высокого уровня.
Правило № 7. Детали реализации абстрактной ошибки
Абстрагируйтесь от деталей реализации в пользу минимальной поверхности API на уровне ошибок. Потребляющая система должна получать и обрабатывать только те ошибки, с которыми она знает, что делать.
Технические ошибки должны были быть объединены, в случае неизбежного сбоя, в одну (с кодом, контекстом и идентификатором, как описано ранее). детали), насколько это возможно, проникнуть в потребляющую систему, вам следует подумать, как об этом будет сообщаться
Точно так же сообщение об ошибках не должно делегироваться коду более высокого уровня, а должно сообщаться на границе, особенно в случае технических ошибок.
Отчетность также может содержать довольно много информации, поэтому передавать ее по сети только для того, чтобы сообщить о ней, не имеет смысла.
Наконец, разные сотрудники могут обрабатывать разные уровни ошибок в разных подсистемах, поэтому администратор, имеющий доступ к серверам и журналам поисковой системы, может не иметь одинаковых прав доступа к системе более высокого уровня, такой как система каталогов, описанная выше.
С учетом сказанного вам, безусловно, следует по-прежнему включать сгенерированный идентификатор ошибки в единственную абстрактную ошибку, о которой сообщает система.
Правило № 8. Отчет на границе системы
Сообщайте об ошибках на границе системы (например, до того, как она вернет ответ HTTP), чтобы убедиться, что:
- к ним может получить доступ соответствующий персонал
- не допускать утечки деталей реализации контекста ошибки, кодов и т. д. в систему более высокого уровня
Несколько слов об ошибках в бизнесе
В приведенном выше примере, где мы ищем продукт, используя текст, который мог ввести пользователь, считается, что исключение NoMatch на самом деле вообще не является ошибкой.
Действительно, текстовый поиск, который возвращает пустой набор результатов, является допустимым бизнес-результатом и не должен рассматриваться как ошибка. В этом нет ничего действительно исключительного, и не должно быть необходимости сообщать об этом, особенно если учесть, что это может запутать фактические важные ошибки.
С другой стороны, если запрос на поиск товара был сделан через систему заказов, он, скорее всего, будет сделан с определенным идентификатором, зарегистрированным в корзине пользователя. Получение NoMatch для такого рода взаимодействия, вероятно, указывало бы на ошибку, возможно, операционную или программную, поскольку недопустимые идентификаторы в корзине или отсутствующие идентификаторы в базе данных каталога редко являются бизнес-ошибкой.
Правило № 9. Создавайте исключения для исключительных условий
Создавайте, доставляйте и сообщайте об ошибках/исключениях только те события, которые считаются исключительными и не являются частью обычной бизнес-операции.
Тогда возникает вопрос, что делать с этими не исключительными «ошибками»? Вероятно, они несут некоторую ценность и должны быть каким-то образом зарегистрированы/собраны?
Ответ – твердое да. Они должны быть сообщены и собраны в ваших данных вместе со всей другой информацией, которую вы используете для бизнес-аналитики.
Крахнуть или не крахнуть?
Теперь, когда мы определили четкое разделение между бизнес- и техническими ошибками, давайте снова рассмотрим эти технические ошибки, особенно ошибки программиста.
Представьте себе неожиданное состояние, которое является результатом ошибки программиста. Жук.
Это никак нельзя было предвидеть, иначе код был бы исправлен. Невозможно исправить ошибку из-за того, что состояние, вероятно, повреждено или поток управления нарушен.
Какое обращение мы можем предложить в таком случае?
Наиболее распространенное предложение — как можно быстрее сломать систему:
- как можно скорее уведомить необходимый персонал, чтобы ошибку можно было исправить
- чтобы предотвратить распространение ошибки по системе, что приведет к дальнейшему и более сложному диагностированию проблем в других, иногда несвязанных частях
Последняя часть особенно важна, поскольку в некоторых случаях необработанная ошибка на самом деле может не привести к очевидной ошибке, а скорее привести к молчаливому нарушению неправильного бизнес-правила, что приведет к ошибочному состоянию, которое чрезвычайно трудно отследить и исправить.
Правило № 10. Сбой на ошибках, затем сбой на ошибках
При обнаружении бага, ошибки программиста:
- сообщить об ошибке
- как можно скорее сломать соответствующую часть системы
- перезапустить систему автоматически
Не позволяйте ошибке распространяться, загрязняя остальную часть системы.
При этом, если мы не можем обрабатывать ошибки программиста и не знаем, когда и почему они происходят заранее, что мы можем сделать, кроме сбоя (что в программировании эквивалентно взятию мяча и домой)?
Первый ожидаемый ответ прост — тестировать, тестировать и еще раз тестировать.
Второй способ немного сложнее — рассмотрите возможность использования утверждений в дополнение к тестам.
Асинхронный контекст
Node — это неблокирующая платформа ввода-вывода, что означает в контексте распределенных приложений, что каждая служба Node должна:
- максимально быстро обработать входящий запрос
- распределять необходимые вычисления по различным подсистемам
- предоставить этим подсистемам обратные вызовы/получить промисы/инициализировать генераторы
- продолжить обработку следующего запроса
- …
- где-то в будущем обработайте некоторые обратные вызовы/промисы, выполнение или генераторы урожаев
Именно эта парадигма делает Node такой популярной и мощной платформой. Это также представляет одну из основных трудностей при работе с распределенной системой Node — изоляцию сеанса.
Это не формальный термин, но предпосылка относительно проста — поскольку Node обслуживает много переплетающихся запросов, возможно (и часто), что данные, подготовленные одним, потребляются другим, если мы не будем осторожны.
Пример выше показывает, как легко и относительно неочевидным образом из-за асинхронности может произойти такое наложение:
- первый клиент отправляет запрос A
- Узел обрабатывает его, обновляет некоторую локальную переменную X и отправляет запрос A-1 к некоторой подсистеме.
- второй клиент отправляет запрос B
- Узел обрабатывает его, обновляет ту же переменную X и отправляет запрос B-1 другой подсистеме.
На этом этапе контексты запроса (опять же, очень неформальное название) изолированы. Тем не мение:
- ответ на запрос A-1 возвращается, считывает переменную X
- Узел возвращает ответ первому клиенту на основе переменной X.
Значение переменной X, используемой для расчета ответа для первого клиента, неверно, что, в свою очередь, может привести к тому, что неверные данные или конфиденциальная информация второго клиента будут возвращены первому!
Проблема усугубляется, когда конкурирующие запросы приводят к одинаковым путям через распределенную систему с различными асинхронными вызовами в каждой подсистеме.
Гонка к ошибкам
Что это значит для нас в контексте обработки ошибок? Проще говоря — мы должны проявлять особую осторожность, чтобы не сообщать об ошибках для неправильного пользователя (подумайте об идентификаторе пользователя, помещенном в эту переменную X).
Необходимо связать каждый асинхронный обратный вызов с его источником — создать что-то вроде асинхронного распространяющегося контекста.
В следующем схематическом коде показан возможный желаемый API:
function foo() { const {id} = ... ... process.context.set('id', id); ... bar(value, (err, result) => { ... }); } // somewhere else, in a different file function bar(param, callback) { const id = process.context.get('id'); ... callback(result); }
Явное распространение контекста
Можно утверждать, что есть способы достичь вышеуказанного шаблона, чаще всего:
- через Node global или какое-либо закрытие на уровне модуля или,
- с помощью системы модулей Node для создания одноэлементного контекста (кэшированного с помощью require) для доступа из любого модуля
Это обеспечивает распространение контекста, но оставляет нерешенной проблему изоляции. Теперь каждый запрос должен быть разделен в этом контексте каким-либо идентификатором. Это, вероятно, означает, что его нужно распространить по всей сети распределенной системы и сгенерировать где-то в верхней части асинхронной цепочки (скорее всего, в вашем обработчике маршрута Express):
Это некрасиво и подвержено ошибкам, если не сказать больше.
function foo() { const {id} = ... ... global[contextID] = {id} // context; ... bar(value, contextID, (err, result) => { ... }); } // somewhere else, in a different file function bar(param, contextID, callback) { const {id} = global[contextID]; ... callback(result); }
Обратите внимание, что такой идентификатор контекста должен быть передан явно через границы распределенной системы (по HTTP или другому сетевому протоколу) в случае любого решения асинхронного контекста. Нет неявного способа распространения такого идентификатора между разными конечными точками сети.
Итак, асинхронный контекст — не более чем мечта?
Домены в помощь?
У узла оказалось частичное решение, на уровне ошибки — встроенная конструкция платформы под названием Домены.
На некоторых платформах и платформах эта концепция называется зонами — идея заключается в том, что вы создаете изолированный домен (зону), который доступен только для асинхронного (и синхронного) кода, происходящего из одного и того же исходного стека вызовов.
Примером может быть:
const domain = require('domain'); const d = domain.create(); app.get('/search/:term', (req, res) => { const {term} = req.params; const iid = generateImpressionID(); ... d.on('error', (err) => { report(iid, err); }); d.run(() => { search(term, () => { ... }); }); });
Если какой-либо код, заключенный в d.run
, выдает исключение, оно перехватывается обратным вызовом определенной ошибки и изолируется для этого конкретного сеанса/изолированного контекста, что устраняет необходимость распространения идентификаторов.
Увы, домены устарели уже довольно давно, из-за того, что они подвержены ошибкам и уязвимы. Существует множество реализаций поверх доменов или вместо них, большинство, если не все обезьяньи исправления всех асинхронных методов, чего мы рекомендуем не делать, если это возможно, для производственных систем.
В качестве примера нарушителя см. одну из самых популярных библиотек, на которую опираются многие подобные решения — async-listener.
Ужасный, ужасный код.
Однако не все потеряно. Был кандидат на замену доменов, API более низкого уровня, в ядре Node, который медленно разрабатывается уже более 2 лет, но, как ожидается, будет гораздо более надежным — асинхронные перехватчики.
Асинхронные хуки
Используя магию асинхронных хуков, мы могли бы написать такой код:
app.get('/search/:term', (req, res) => { const {term} = req.params; const iid = generateImpressionID(); createContext({iid}); searchItemByText(term).then(({catalogID}) => { getProduct(catalogID).then((product) => { track(iid, 'product --', product); res.status(200).send(product); }).catch((err) => { report(iid, err); res.status(404).send(err); }); }).catch((err) => { report(iid, err); res.status(404).send(err); }); });
а затем использовать его в других местах для получения идентификатора показа:
function searchItemByText(term) { const {iid} = getContext(); return searchText(term).then((results) => { const {ref: catalogID} = results[0]; track(iid, `item by text: ${term} --`, {catalogID}); return {catalogID}; }).catch((err) => { report(iid, err); throw new Error(err); }); } function getProduct(catalogID) { const {iid} = getContext(); return db.findOne({catalogID}).then((product) => { track(iid, `product by catalogID: ${catalogID} --`, product); return product; }).catch((err) => { report(iid, err); throw new Error(err); }); }
именно этого мы и хотели добиться.
Однако на данный момент это не часть рекомендуемого базового API Node, а скорее экспериментальная функция.
Для любопытства посмотрите библиотеку async-hook, которая создает тонкую оболочку вокруг async-wrap и является той, которую приведенный выше код использует для этого метода createContext.
Пример схемы реализации может выглядеть следующим образом:
‘use strict’; const asyncHook = require('async-hook'); const contexts = {}; function init(uid, handle, provider, parentUid, parentHandle) { // if current handle is a descendant of a handler that created // a context or already has one if (isDescendant(uid)) { // save its context contexts[uid] = contexts[parentId || currentUid] } } function pre(uid, handle) { // do some state initialization // save currently "processed" handle currentUid = uid; } function post(uid, handle, didThrow) { // restore state currentUid = null; } function destroy(uid) { // remove context for the specific handle removeContext(uid); } function createContext(contextData) { // add context for the current handle uid contexts[currentUid] = contextData; } function getContext() { return contexts[currentUid]; } // register the hooks asyncHook.addHooks({init, pre, post, destroy}); asyncHook.enable();
Ясно, что потенциально это отличный способ обеспечить такие вещи, как асинхронное распространение памяти или длинная трассировка стека. Для более глубокого изучения загляните в одну из существующих библиотек, из которых вдохновлен приведенный выше код.
Резюме
По мере создания, доставки и обработки шаблонов ошибок в распределенной системе был установлен набор правил высокого уровня. Эти правила должны, как мы упоминали ранее, считаться рекомендациями, а не жесткими правилами, хотя бы потому, что для краткости и из-за ограничений формата/размера статьи мы пропустили ряд интересных исключений (тьфу, почти удалось избежать каламбуров).
Эти исключения могут иметь различный вес в вашей системе и, как таковые, могут подчеркивать или отрицать определенные правила.
«Знай правила, чтобы нарушать правила»
Первоначально опубликовано на сайте blog.naturalint.com 5 апреля 2017 г.