Как сообщать пользователю, если «Упс, что-то пошло не так»

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

Как сообщать пользователю, если «Упс, что-то пошло не так»

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

Этот материал, посвящённый обработке ошибок в JavaScript, разбит на три части. Сначала мы сделаем общий обзор системы обработки ошибок в JavaScript и поговорим об объектах ошибок. После этого мы поищем ответ на вопрос о том, что делать с ошибками, возникающими в серверном коде (в частности, при использовании связки Node.js + Express.js). Далее — обсудим обработку ошибок в React.js. Фреймворки, которые будут здесь рассматриваться, выбраны по причине их огромной популярности. Однако рассматриваемые здесь принципы работы с ошибками универсальны, поэтому вы, даже если не пользуетесь Express и React, без труда сможете применить то, что узнали, к тем инструментам, с которыми работаете.

Код демонстрационного проекта, используемого в данном материале, можно найти в этом репозитории.

kventin tarantino

Временами на экране любого телефона может появляться подобное сообщение. Это значит, что какое-то приложение работает неправильно. Что с этим делать?

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

Другое дело – когда отчет об ошибке предлагается отправить постоянно. Это мешает нормально использовать смартфон Xiaomi. И нужно что-то делать.

Сначала следует попробовать варианты исправить ситуацию, которые подходят для всех телефонов, находящиеся под управлением ОС «Андроид». Есть такие варианты:

  1. Обновить Android System WebView. Это своего рода «оболочка», благодаря которой веб-страницы подгружаются в приложения. Возможно, что проблема в этой программе. Обновить её можно в Google Play. Нужно зайти в это приложение, найти в нем Android System WebView и нажать кнопку «Обновить». Если такой кнопки нет, это значит, что установлена последняя версия. А еще это значит, что придется искать проблему дальше.
  2. Можно пойти совершенно противоположным путем. Возможно, что дело не в «недообновлении», а в том, что обновление с ошибкой. Или оно установилось некорректно. Чтобы исправить ситуацию, войдите в «Настройки» — «Все приложения», выберите там Android System WebView и удалите обновления. Есть для этого кнопка.

Возможно, что проблема с каким-то другим приложением. Нужно искать. То есть, необходимо внимательно посмотреть в сообщении об отправке отчета, какая программа упоминается. Её нужно обновить. Или – наоборот – удалить обновления.

Если всё это не помогает, то есть еще два варианта:

  • обновить не какое-то конкретное приложение, а сделать обновление операционной системы;
  • сбросить настройки телефона до заводских.

Внимание: во втором случае лучше сохранить важные данные, чтобы они не пропали.

Способ для Xiaomi

Есть еще один вариант исправить ситуацию. Он актуален конкретно для смартфонов Xiaomi.

  • откройте раздел «О телефоне»;
  • выберите «Все параметры»;
  • Нажмите 7 раз на версию прошивки;

На этой странице нужно деактивировать опцию «Всегда показывать отчет об ошибке».

Можно отключить и ANR в фоновом режиме. После этого еще и пропадут сообщения о том, что приложение не отвечает.

Создание нотификации

Чтобы использовать нотификации мы должны создать объект Notification. Нотификация может быть достаточно простой, временами просто List.

  var notification = new List();
  if (NumberOfSeats < 5) notification.add("Количество мест должно быть не менее 5");
  // ещё проверки

  // затем…
  if (notification.Any()) // обработка ошибок

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

public class Notification
{
        private List errors = new List();

        public void AddError(string message)
        {
                errors.Add(message);
        }

        public bool HasErrors
        {
                get { return errors.Any(); }
        }
}

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

Стартовая точка

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

  JsonConvert.DeserializeObject(json);

Класс BookingRequest содержит всего два элемента, которые мы валидируем здесь: дату выступления и как много мест было запрошено.

  class BookingRequest
  {
    public int? NumberOfSeats { get; set; }
    public string Date { get; set; }
  }

Валидация уже была показана выше.

public void Сheck()
{
   if (Date == null) throw new ArgumentNullException("Дата не указана");
   DateTime parsedDate;
   try {
     parsedDate = DateTime.Parse(Date);
   }
   catch (FormatException e) {
     throw new ArgumentException("Дата указана в неизвестном формате", e);
   }
   if (parsedDate < DateTime.Now) throw new ArgumentException("Дата не может быть раньше сегодняшней");
   if (NumberOfSeats == null) throw new ArgumentException("Количество мест не указано");
   if (NumberOfSeats < 1) throw new ArgumentException("Количество мест должно быть положительным числом");
 }

Валидация даты

Начнем с выноса проверок для даты в отдельный метод.

public Notification Validation()
{
       ValidateDate(notification);
       ValidateNumberOfSeats(notification);
}

Затем, как и в случае с числом, начнём заменять исключения с конца метода.

private void ValidateNumberOfSeats(Notification notification)
{
        //...
        if (parsedDate < DateTime.Now) notification.AddError("Дата не может быть раньше сегодняшней");
}

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

Добавим в метод AddError параметр Exception и укажем ему значение по умолчанию null.

public void AddError(string message, Exception exc = null)
{
        errors.Add(message);
}

Это значит мы принимаем исключение, но игнорируем его. Чтобы поместить его куда-либо мы должны изменить тип ошибки внутри класса Notification с string на более сложный объект. Создадим класс Error внутри Notification.

private class Error
{
        public string Message { get; set; }
        public Exception Exception { get; set; }

        public Error(string message, Exception exception)
        {
                Message = message;
                Exception = exception;
        }
}

Теперь у нас есть класс и нам осталось изменить Notification, чтобы он использовал его.

//...
private List errors = new List();

public void AddError(string message)
{
        errors.Add(new Error(message, null));
}

public void AddError(string message, Exception exception = null)
{
        errors.Add(new Error(message, exception));
}

//...
public string ErrorMessage
{
        get { return string.Join(", ", errors.Select(e => e.Message)); }
}

С новым уведомлением на месте, теперь мы можем внести изменения в запрос бронирования.

private void ValidateDate(Notification notification)
{
        if (Date == null) throw new ArgumentNullException("Дата не указана");
        DateTime parsedDate;
        try
        {
                parsedDate = DateTime.Parse(Date);
        }
        catch (FormatException e)
        {
                notification.AddError("Дата указана в неизвестном формате", e);
                return;
        }
        if (parsedDate < DateTime.Now) notification.AddError("Дата не может быть раньше сегодняшней");
}

И последнее изменение достаточно простое.

private void ValidateDate(Notification notification)
{
        if (Date == null) notification.AddError("Дата не указана");
        DateTime parsedDate;
        try
        {
                parsedDate = DateTime.Parse(Date);
        }
        catch (FormatException e)
        {
                notification.AddError("Дата указана в неизвестном формате", e);
                return;
        }
        if (parsedDate < DateTime.Now) notification.AddError("Дата не может быть раньше сегодняшней");
}

Валидация числа

Очевидная вещь, которую нужно сделать это заменить первую проверку.

public Notification Validation()
{
        var notification = new Notification();
        if (Date == null) notification.AddError("Дата не указана");
        //...
}

Очевидная замена, но плохая, так как ломает код. Если мы передадим null в качестве аргумента для Date, то мы добавим ошибку в объект Notification, код продолжит выполняться и при разборе получим NullReferenceException в методе DateTime.Parse. Это не то, что мы хотим получить.

Неочевидное, но более эффективное, что нужно сделать в этом случае, это идти с конца метода.

public Notification Validation()
{
       //...
       if (NumberOfSeats < 1) notification.AddError("Количество мест должно быть положительным числом");
}

Следующая проверка — это проверка на null, поэтому мы должны добавить условие, чтобы избежать NullReferenceException

public Notification Validation()
{
       //...
       if (NumberOfSeats == null) notification.AddError("Количество мест не указано");
       else if (NumberOfSeats < 1) notification.AddError("Количество мест должно быть положительным числом");
}

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

public Notification Validation()
{
       //...
       ValidateNumberOfSeats(notification);
}

private void  ValidateNumberOfSeats(Notification notification)
{
       if (NumberOfSeats == null) notification.AddError("Количество мест не указано");
       else if (NumberOfSeats < 1) notification.AddError("Количество мест должно быть положительным числом");
}

Когда мы смотрим на выделенную валидацию для числа, она выглядит не очень естественно. Использование if-then-else блоков для валидации может легко привести к чрезмерно вложенному коду. Более предпочтительно использовать линейный код, который обрывается, если не может идти далее, что мы можем реализовать с использованием защитного условия.

private void ValidateNumberOfSeats(Notification notification)
{
        if (NumberOfSeats == null)
        {
                notification.AddError("Количество мест не указано");
                return;
        }

        if (NumberOfSeats < 1) notification.AddError("Количество мест должно быть положительным числом");
}

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

Работа с ошибками на клиенте

Теперь пришла пора описать третью часть нашей системы обработки ошибок, касающуюся фронтенда. Тут нужно будет, во-первых, обрабатывать ошибки, возникающие в клиентской части приложения, а во-вторых, понадобится оповещать пользователя об ошибках, возникающих на сервере. Разберёмся сначала с показом сведений о серверных ошибках. Как уже было сказано, в этом примере будет использована библиотека React.

▍Сохранение сведений об ошибках в состоянии приложения

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

Следующее, с чем надо разобраться, заключается в том, что ошибки одного типа нужно показывать в одном стиле. По аналогии с сервером, здесь можно выделить 3 типа ошибок.

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

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

Здесь будет использоваться встроенная в React система управления состоянием приложения, но, при необходимости, вы можете воспользоваться и специализированными решениями для управления состоянием — такими, как MobX или Redux.

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

Как сообщать пользователю, если «Упс, что-то пошло не так»

Сообщение о глобальной ошибке

Теперь взглянем на код, который хранится в файле Application.js.

import React, { Component } from 'react'

import GlobalError from './GlobalError'

class Application extends Component {
    constructor(props) {
        super(props)

        this.state = {
            error: '',
        }

        this._resetError = this._resetError.bind(this)
        this._setError = this._setError.bind(this)
    }

    render() {
        return (
            
                               

Handling Errors

           
       )    }    _resetError() {        this.setState({ error: '' })    }    _setError(newError) {        this.setState({ error: newError })    } } export default Application

Как видно, в состоянии, в Application.js, имеется место для хранения данных ошибки. Кроме того, тут предусмотрены методы для сброса этих данных и для их изменения.

Ошибка и метод для сброса ошибки передаётся компоненту GlobalError, который отвечает за вывод сообщения об ошибке на экран и за сброс ошибки после нажатия на значок x в поле, где выводится сообщение. Вот код компонента GlobalError (файл GlobalError.js).

import React, { Component } from 'react'

class GlobalError extends Component {
    render() {
        if (!this.props.error) return null

        return (
            
               {this.props.error}                                                     close                            
       )    } } export default GlobalError

Обратите внимание на строку if (!this.props.error) return null. Она указывает на то, что при отсутствии ошибки компонент ничего не выводит. Это предотвращает постоянный показ красного прямоугольника на странице. Конечно, вы, при желании, можете поменять внешний вид и поведение этого компонента. Например, вместо того, чтобы сбрасывать ошибку по нажатию на x, можно задать тайм-аут в пару секунд, по истечении которого состояние ошибки сбрасывается автоматически.

Теперь, когда всё готово для работы с глобальными ошибками, для задания глобальной ошибки достаточно воспользоваться _setError из Application.js. Например, это можно сделать в том случае, если сервер, после обращения к нему, вернул сообщение об общей ошибке (error: 'GENERIC'). Рассмотрим пример (файл GenericErrorReq.js).

import React, { Component } from 'react'
import axios from 'axios'

class GenericErrorReq extends Component {
    constructor(props) {
        super(props)

        this._callBackend = this._callBackend.bind(this)
    }

    render() {
        return (
            
                           
       )    }    _callBackend() {        axios            .post('/api/city')            .then(result => {                // сделать что-нибудь с результатом в том случае, если запрос оказался успешным            })            .catch(err => {                if (err.response.data.error === 'GENERIC') {                    this.props.setError(err.response.data.description)                }            })    } } export default GenericErrorReq

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

▍Обработка специфических ошибок, возникающих при выполнении запросов

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

Как сообщать пользователю, если «Упс, что-то пошло не так»

Сообщение о специфической ошибке

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

import React, { Component } from 'react'
import axios from 'axios'

import InlineError from './InlineError'

class SpecificErrorRequest extends Component {
    constructor(props) {
        super(props)

        this.state = {
            error: '',
        }

        this._callBackend = this._callBackend.bind(this)
    }

    render() {
        return (
            
                                           
       )    }    _callBackend() {        this.setState({            error: '',        })        axios            .delete('/api/city')            .then(result => {                // сделать что-нибудь с результатом в том случае, если запрос оказался успешным            })            .catch(err => {                if (err.response.data.error === 'GENERIC') {                    this.props.setError(err.response.data.description)                } else {                    this.setState({                        error: err.response.data.description,                    })                }            })    } } export default SpecificErrorRequest

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

▍Ошибки, возникающие в клиентской части приложения

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

Как сообщать пользователю, если «Упс, что-то пошло не так»

В поле ничего нет, мы сообщаем об этом пользователю

Вот код файла SpecificErrorFrontend.js, реализующий вышеописанный функционал.

import React, { Component } from 'react'
import axios from 'axios'

import InlineError from './InlineError'

class SpecificErrorRequest extends Component {
    constructor(props) {
        super(props)

        this.state = {
            error: '',
            city: '',
        }

        this._callBackend = this._callBackend.bind(this)
        this._changeCity = this._changeCity.bind(this)
    }

    render() {
        return (
            
                                                           
       )    }    _changeCity(e) {        this.setState({            error: '',            city: e.target.value,        })    }    _validate() {        if (!this.state.city.length) throw new Error('Please provide a city name.')    }    _callBackend() {        this.setState({            error: '',        })        try {            this._validate()        } catch (err) {            return this.setState({ error: err.message })        }        axios            .delete('/api/city')            .then(result => {                // сделать что-нибудь с результатом в том случае, если запрос оказался успешным            })            .catch(err => {                if (err.response.data.error === 'GENERIC') {                    this.props.setError(err.response.data.description)                } else {                    this.setState({                        error: err.response.data.description,                    })                }            })    } } export default SpecificErrorRequest

▍Интернационализация сообщений об ошибках с использованием кодов ошибок

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

Ошибки в JavaScript и универсальные способы работы с ними

Если в вашем коде что-то пошло не так, вы можете воспользоваться следующей конструкцией.

throw new Error('something went wrong')

В ходе выполнения этой команды будет создан экземпляр объекта Error и будет сгенерировано (или, как говорят, «выброшено») исключение с этим объектом. Инструкция throw может генерировать исключения, содержащие произвольные выражения. При этом выполнение скрипта остановится в том случае, если не были предприняты меры по обработке ошибки.

Начинающие JS-программисты обычно не используют инструкцию throw. Они, как правило, сталкиваются с исключениями, выдаваемыми либо средой выполнения языка, либо сторонними библиотеками. Когда это происходит — в консоль попадает нечто вроде ReferenceError: fs is not defined и выполнение программы останавливается.

У экземпляров объекта Error есть несколько свойств, которыми мы можем пользоваться. Первое интересующее нас свойство — message. Именно сюда попадает та строка, которую можно передать конструктору ошибки в качестве аргумента. Например, ниже показано создание экземпляра объекта Error и вывод в консоль переданной конструктором строки через обращение к его свойству message.

const myError = new Error('please improve your code')
console.log(myError.message) // please improve your code

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

Error: please improve your code
 at Object. (/Users/gisderdube/Documents/_projects/hacking.nosync/error-handling/src/general.js:1:79)
 at Module._compile (internal/modules/cjs/loader.js:689:30)
 at Object.Module._extensions..js (internal/modules/cjs/loader.js:700:10)
 at Module.load (internal/modules/cjs/loader.js:599:32)
 at tryModuleLoad (internal/modules/cjs/loader.js:538:12)
 at Function.Module._load (internal/modules/cjs/loader.js:530:3)
 at Function.Module.runMain (internal/modules/cjs/loader.js:742:12)
 at startup (internal/bootstrap/node.js:266:19)
 at bootstrapNodeJSCore (internal/bootstrap/node.js:596:3)

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

▍Генерирование и обработка ошибок

Создание экземпляра объекта Error, то есть, выполнение команды вида new Error(), ни к каким особым последствиям не приводит. Интересные вещи начинают происходить после применения оператора throw, который генерирует ошибку. Как уже было сказано, если такую ошибку не обработать, выполнение скрипта остановится. При этом нет никакой разницы — был ли оператор throw использован самим программистом, произошла ли ошибка в некоей библиотеке или в среде выполнения языка (в браузере или в Node.js). Поговорим о различных сценариях обработки ошибок.

Этот блок можно использовать для обработки любых ошибок, происходящих в синхронном коде. Рассмотрим пример.

const a = 5

try {
    console.log(b) // переменная b не объявлена - возникает ошибка
} catch (err) {
    console.error(err) // в консоль попадает сообщение об ошибке и стек ошибки
}

console.log(a) // выполнение скрипта не останавливается, данная команда выполняется

const a = 5

try {
    console.log(b) // переменная b не объявлена - возникает ошибка
} catch (err) {
    console.error(err) // в консоль попадает сообщение об ошибке и стек ошибки
} finally {
    console.log(a) // этот код будет выполнен в любом случае
}

▍Асинхронные механизмы — коллбэки

Программируя на JavaScript всегда стоит обращать внимание на участки кода, выполняющиеся асинхронно. Если у вас имеется асинхронная функция и в ней возникает ошибка, скрипт продолжит выполняться. Когда асинхронные механизмы в JS реализуются с использованием коллбэков (кстати, делать так не рекомендуется), соответствующий коллбэк (функция обратного вызова) обычно получает два параметра. Это нечто вроде параметра err, который может содержать ошибку, и result — с результатами выполнения асинхронной операции. Выглядит это примерно так:

myAsyncFunc(someInput, (err, result) => {
    if(err) return console.error(err) // порядок работы с объектом ошибки мы рассмотрим позже
    console.log(result)
})

▍Асинхронные механизмы — промисы

Для выполнения асинхронных операций в JavaScript лучше использовать не коллбэки а промисы. Тут, в дополнение к улучшенной читабельности кода, имеются и более совершенные механизмы обработки ошибок. А именно, возиться с объектом ошибки, который может попасть в функцию обратного вызова, при использовании промисов не нужно. Здесь для этой цели предусмотрен специальный блок catch. Он перехватывает все ошибки, произошедшие в промисах, которые находятся до него, или все ошибки, которые произошли в коде после предыдущего блока catch. Обратите внимание на то, что если в промисе произошла ошибка, для обработки которой нет блока catch, это не остановит выполнение скрипта, но сообщение об ошибке будет не особенно удобочитаемым.

(node:7741) UnhandledPromiseRejectionWarning: Unhandled promise rejection (rejection id: 1): Error: something went wrong
(node:7741) DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code. */

В результате можно порекомендовать всегда, при работе с промисами, использовать блок catch. Взглянем на пример.

Promise.resolve(1)
    .then(res => {
        console.log(res) // 1

        throw new Error('something went wrong')

        return Promise.resolve(2)
    })
    .then(res => {
        console.log(res) // этот блок выполнен не будет
    })
    .catch(err => {
        console.error(err) // о том, что делать с этой ошибкой, поговорим позже
        return Promise.resolve(3)
    })
    .then(res => {
        console.log(res) // 3
    })
    .catch(err => {
        // этот блок тут на тот случай, если в предыдущем блоке возникнет какая-нибудь ошибка
        console.error(err)
    })

;(async function() {
    try {
        await someFuncThatThrowsAnError()
    } catch (err) {
        console.error(err) // об этом поговорим позже
    }

    console.log('Easy!') // будет выполнено
})()

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

Разделяем метод Check

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

Используя способ «Выделение метода», выносим тело функции Check в функцию Validation.

public void Сheck()
{
        Validation();
}

public void Validation()
{
        if (Date == null) throw new ArgumentNullException("Дата не указана");
        DateTime parsedDate;
        try
        {
                parsedDate = DateTime.Parse(Date);
        }
        catch (FormatException e)
        {
                throw new ArgumentException("Дата указана в неизвестном формате", e);
        }
        if (parsedDate < DateTime.Now) throw new ArgumentException("Дата не может быть раньше сегодняшней");
        if (NumberOfSeats == null) throw new ArgumentException("Количество мест не указано");
        if (NumberOfSeats < 1) throw new ArgumentException("Количество мест должно быть положительным числом");
}

Затем расширяем метод Validation с созданием Notification и его возвращением из функции.

public Notification Validation()
{
        var notification = new Notification();
        //...
        return notification;
}

Теперь я могу проверить Notification и выкинуть исключение, если он содержит ошибки.

public void Сheck()
{
        var notification = Validation();

        if (notification.HasErrors)
                throw new ArgumentException(notification.ErrorMessage);
}

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

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

Разделение изначального метода позволило нам отделить валидацию от реакции на её результаты

Перед тем как мы продолжим, следует сказать несколько слов о сообщениях об ошибке. Когда мы делаем рефакторинг, важно избежать изменений в наблюдаемом поведении. Данное правило ведёт нас к вопросу о том какое поведение является наблюдаемым. Очевидно, что выброс исключения — это то, что внешняя программа будет наблюдать, но в какой степени они заботятся о сообщении об ошибке? Notification будет собирать множество сообщений об ошибках и объединять их в одно, например таким образом.

public string ErrorMessage
{
        get { return string.Join(", ", errors); }
}

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

public string ErrorMessage
{
        get { return errors[0]; }
}

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

Генерирование и обработка ошибок в серверном коде

Теперь, когда у нас есть инструменты для работы с ошибками, посмотрим на то, что мы можем с ними делать в реальных ситуациях. Генерирование и правильная обработка ошибок — это важнейший аспект серверного программирования. Существуют разные подходы к работе с ошибками. Здесь будет продемонстрирован подход с использованием собственного конструктора для экземпляров объекта Error и кодов ошибок, которые удобно передавать во фронтенд или любым механизмам, использующим серверные API. Как структурирован бэкенд конкретного проекта — особого значения не имеет, так как при любом подходе можно использовать одни и те же идеи, касающиеся работы с ошибками.

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

  1. Универсальная обработка ошибок — некий базовый механизм, подходящий для обработки любых ошибок, в ходе работы которого просто выдаётся сообщение наподобие Something went wrong, please try again or contact us, предлагающее пользователю попробовать выполнить операцию, давшую сбой, ещё раз или связаться с владельцем сервера. Эта система не отличается особой интеллектуальностью, но она, по крайней мере, способна сообщить пользователю о том, что что-то пошло не так. Подобное сообщение гораздо лучше, чем «бесконечная загрузка» или нечто подобное.
  2. Обработка конкретных ошибок — механизм, позволяющий сообщить пользователю подробные сведения о причинах неправильного поведения системы и дать ему конкретные советы по борьбе с неполадкой. Например, это может касаться отсутствия неких важных данных в запросе, который пользователь отправляет на сервер, или в том, что в базе данных уже существует некая запись, которую он пытается добавить ещё раз, и так далее.

▍Разработка собственного конструктора объектов ошибок

Здесь мы воспользуемся стандартным классом Error и расширим его. Пользоваться механизмами наследования в JavaScript — дело рискованное, но в данном случае эти механизмы оказываются весьма полезными. Зачем нам наследование? Дело в том, что нам, для того, чтобы код удобно было бы отлаживать, нужны сведения о трассировке стека ошибки. Расширяя стандартный класс Error, мы, без дополнительных усилий, получаем возможности по трассировке стека. Мы добавляем в наш собственный объект ошибки два свойства. Первое — это свойство code, доступ к которому можно будет получить с помощью конструкции вида err.code. Второе — свойство status. В него будет записываться код состояния HTTP, который планируется передавать клиентской части приложения.

Вот как выглядит класс CustomError, код которого оформлен в виде модуля.

class CustomError extends Error {
    constructor(code = 'GENERIC', status = 500, ...params) {
        super(...params)

        if (Error.captureStackTrace) {
            Error.captureStackTrace(this, CustomError)
        }

        this.code = code
        this.status = status
    }
}

module.exports = CustomError

Теперь, когда наш объект ошибки готов к использованию, нужно настроить структуру маршрутов. Как было сказано выше, нам требуется реализовать унифицированный подход к обработке ошибок, позволяющий одинаково обрабатывать ошибки для всех маршрутов. По умолчанию фреймворк Express.js не вполне поддерживает такую схему работы. Дело в том, что все его маршруты инкапсулированы.

Для того чтобы справиться с этой проблемой, мы можем реализовать собственный обработчик маршрутов и определять логику маршрутов в виде обычных функций. Благодаря такому подходу, если функция маршрута (или любая другая функция) выбрасывает ошибку, она попадёт в обработчик маршрутов, который затем может передать её клиентской части приложения. При возникновении ошибки на сервере мы планируем передавать её во фронтенд в следующем формате, полагая, что для этого будет применяться JSON-API:

{
    error: 'SOME_ERROR_CODE',
    description: 'Something bad happened. Please try again or contact support.'
}

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

Вот как выглядит код обработчика маршрутов.

const express = require('express')
const router = express.Router()
const CustomError = require('../CustomError')

router.use(async (req, res) => {
    try {
        const route = require(`.${req.path}`)[req.method]

        try {
            const result = route(req) // Передаём запрос функции route
            res.send(result) // Передаём клиенту то, что получено от функции route
        } catch (err) {
            /*
            Сюда мы попадаем в том случае, если в функции route произойдёт ошибка
            */
            if (err instanceof CustomError) {
                /* 
                Если ошибка уже обработана - трансформируем её в 
                возвращаемый объект
                */

                return res.status(err.status).send({
                    error: err.code,
                    description: err.message,
                })
            } else {
                console.error(err) // Для отладочных целей

                // Общая ошибка - вернём универсальный объект ошибки
                return res.status(500).send({
                    error: 'GENERIC',
                    description: 'Something went wrong. Please try again or contact support.',
                })
            }
        }
    } catch (err) {
        /* 
         Сюда мы попадём, если запрос окажется неудачным, то есть,
         либо не будет найдено файла, соответствующего пути, переданному
         в запросе, либо не будет экспортированной функции с заданным
         методом запроса
        */
        res.status(404).send({
            error: 'NOT_FOUND',
            description: 'The resource you tried to access does not exist.',
        })
    }
})

module.exports = router

Теперь взглянем на файл маршрутов.

const CustomError = require('../CustomError')

const GET = req => {
    // пример успешного выполнения запроса
    return { name: 'Rio de Janeiro' }
}

const POST = req => {
    // пример ошибки общего характера
    throw new Error('Some unexpected error, may also be thrown by a library or the runtime.')
}

const DELETE = req => {
    // пример ошибки, обрабатываемой особым образом
    throw new CustomError('CITY_NOT_FOUND', 404, 'The city you are trying to delete could not be found.')
}

const PATCH = req => {
    // пример перехвата ошибок и использования CustomError
    try {
        // тут случилось что-то нехорошее
        throw new Error('Some internal error')
    } catch (err) {
        console.error(err) // принимаем решение о том, что нам тут делать

        throw new CustomError(
            'CITY_NOT_EDITABLE',
            400,
            'The city you are trying to edit is not editable.'
        )
    }
}

module.exports = {
    GET,
    POST,
    DELETE,
    PATCH,
}
{
    error: 'GENERIC',
    description: 'Something went wrong. Please try again or contact support.'
}

Конструктор CustomError используется так:

throw new CustomError('MY_CODE', 400, 'Error description')

Это даёт следующий JSON-код, передаваемый во фронтенд:

{
    error: 'MY_CODE',
    description: 'Error description'
}

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

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

Когда использовать этот рефакторинг

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

Хорошее правило использования исключений можно встретить в книге «Pragmatic Programmers»:

Мы верим, что исключения редко должны использоваться, как часть нормального потока программы: исключения должны быть зарезервированы для неожиданный ситуаций. Представьте, что необработанное исключение завершит вашу программу и спросите себя: «Будет ли этот код всё ещё работать, если я уберу все обработчики исключений?» Если ответ «нет», то, возможно, исключения использовались в составе нормального потока программы.

— Дэйв Томас и Энди Хант

Важный вывод, который нужно сделать из этого — решение применять ли исключения для конкретной задачи зависит от контекста. Чтение файла, который не существует может являться исключительной ситуацией, а может и не являться. Если мы пытаемся прочитать файл по хорошо известному пути, например, /etc/hosts в Unix, то мы можем предположить, что файл должен быть здесь, поэтому выбрасывание исключения имеет смысл. С другой стороны, если мы читаем файл по пути, переданному пользователем, через командную строку, то мы должны ожидать, что, вероятно, файла здесь нет и использовать другой механизм взаимодействия с неисключительной по своей природе ошибкой.

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

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

Замена выброса исключений уведомлениями

Предлагаю вашему вниманию перевод статьи «Replace Throw With Notification» Мартина Фаулера. Примеры адаптированы под .NET.

Если мы валидируем данные, обычно мы не должны использовать исключения, чтобы известить о валидационных ошибках. Здесь я опишу как отрефакторить такой код с использованием паттерна «Уведомление» («Notification»).

Как сообщать пользователю, если «Упс, что-то пошло не так»

public void Сheck()
{
   if (Date == null) throw new ArgumentNullException("Дата не указана");
   DateTime parsedDate;
   try {
     parsedDate = DateTime.Parse(Date);
   }
   catch (FormatException e) {
     throw new ArgumentException("Дата указана в неизвестном формате", e);
   }
   if (parsedDate < DateTime.Now) throw new ArgumentException("Дата не может быть раньше сегодняшней");
   if (NumberOfSeats == null) throw new ArgumentException("Количество мест не указано");
   if (NumberOfSeats < 1) throw new ArgumentException("Количество мест должно быть положительным числом");
 }

Это общий подход к реализации валидации. Запускается серия проверок для некоторых данных (в примере выше валидируются два параметра). Если какая-либо проверка не проходит, то кидается исключение с сообщением об ошибке.

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

Если ошибка — это ожидаемое поведение, то мы не должны использовать исключения

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

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

private void ValidateNumberOfSeats(Notification note)
{
  if (numberOfSeats < 1) note.addError("Количество мест должно быть положительным числом");
  // другие проверки, как проверка выше
}

Затем мы можем просто вызвать метод Notification.hasErrors(), чтобы отреагировать на ошибки. Другие методы Notification могут предоставить больше деталей об ошибках.

if (numberOfSeats < 1) throw new ArgumentException(«Количество мест должно быть положительным числом»);

  if (numberOfSeats < 1) note.addError("Количество мест должно быть положительным числом");
  return note;

Заключение

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

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

Итоги

Надеемся, теперь у вас сформировалось понимание того, как можно работать с ошибками в веб-приложениях. Нечто вроде console.error(err) следует использовать только в отладочных целях, в продакшн подобные вещи, забытые программистом, проникать не должны. Упрощает решение задачи логирования использование какой-нибудь подходящей библиотеки наподобие loglevel.

Уважаемые читатели! Как вы обрабатываете ошибки в своих проектах?

Как сообщать пользователю, если «Упс, что-то пошло не так»

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *