Совершенный код: обработка ошибок в библиотеках

Совершенный код: обработка ошибок в библиотеках главное изображение

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

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

Прежде чем начать, давайте разберемся с терминологией. В отличие от программы, библиотека не может использоваться напрямую, например, в терминале. Любая библиотека — это код на конкретном языке, который вызывается другим кодом на этом же языке. Говорят, что у библиотеки есть клиент. Клиент — тот, кто использует библиотеку:

// http-клиент в js, эту библиотеку мы будем использовать в качестве примера
import axios from 'axios';

// С точки зрения axios, этот файл содержит клиентский код
// То есть код, который использует axios.

const runProgram = async () => {
  const url = 'https://ru.hexlet.io';
  // Вызов библиотеки идет в клиентском коде
  const response = await axios.get(url);
  console.log(response.body);
}

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

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

Про ошибки

Что вообще считать ошибкой, а что нет? Представьте функцию, которая ищет символ внутри строки и не находит его. Является ли это ошибкой?

// Эта функция ищет символ в строке и возвращает его индекс
// Данный вызов ничего не найдет
'lala'.indexOf('j'); // -1

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

А что насчет http запроса как в примере выше? Как вести себя функции axios.get, которая не смогла загрузить указанную страницу? С точки зрения функции такая ситуация не является нормальной. Если функция не может выполнить свое основное предназначение, это ошибка. Именно об этих ошибках и пойдет речь. Ниже конкретные примеры того, как делать стоит и как не стоит при использовании библиотек и их проектировании.

Завершение процесса

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

if (/* что-то не получилось */) {
  process.exit();
}

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

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

import axios from 'axios';

const runProgram = async () => {
  const url = 'https://ru.hexlet.io';
  try {
    const response = await axios.get(url);
    // Делаем что-нибудь полезное с response
    console.log(response.body);
  } catch (e) {
    // Для отладки хорошо бы вывести полную информацию
    console.error(e.message);
    // exit нужно делать на уровне программы,
    // так как важно установить правильный код возврата (отличный от 0)
    // только так снаружи можно будет понять что была ошибка
    process.exit(1);
  }
}

Вопрос на самоконтроль. Можно ли написать тесты на библиотеку, которая выполняет остановку процесса?

Имитация успеха

Иногда разработчик пытается скрыть от клиентского кода любые или почти любые ошибки. Код в таком случае перехватывает все возможные ошибки (исключения) и всегда возвращает какой-то результат. Ниже гипотетический пример с функцией axios.get. Как бы она выглядела в этом случае:

// Очень упрощенный код внутри axios.get
const get = (url) => {
  // Тут на самом деле сложный асинхронный код, выполняющий http запрос
  // Опустим его и посмотрим на то место где возникает ошибка
  // Ниже упрощенный пример обработки ошибки
  if (error) {
    // Генеральная идея — этот код возвращает какой-то результат,
    // который сложно или невозможно отличить от успешно выполненного запроса
    const response = { body: null };
    return response;
  }
}

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

import axios from 'axios';

const runProgram = async () => {
  const url = 'https://ru.hexlet.io';
  // Как узнать что тут произошла ошибка и предупредить пользователя?
  const response = await axios.get(url);
  console.log(response.body);
}

Правильное решение — использовать исключения.

Подавления ошибок

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

// Очень упрощенный код внутри axios.get
const get = (url) => {
  if (error) {
    console.error(`Something was wrong during http request: ${error}`);
    return null;
  }
}

Что здесь происходит? Разработчик выводит сообщение об ошибке в консоль и возвращает наружу, например, null. Такой код появляется тогда, когда программист еще не до конца осознал, что такое библиотека. Главный вопрос, который должен вызывать этот код: а как клиентский код узнает об ошибке? Ответ здесь — никак. Подобную библиотеку невозможно использовать правильно. Представьте, если бы так себя вел axios.get:

import axios from 'axios';

const runProgram = async () => {
  const url = 'https://ru.hexlet.io';
  // Во время ошибки идет вывод в консоль
  // А если консоли вообще нет?
  // Даже если есть, как обработать ошибку?
  const response = await axios.get(url);
  console.log(response.body);
}

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

import { promises as fs } from 'fs';

// Клиент этой функции (библиотеки) никогда не узнает о том, что произошла ошибка
const getData = async (filepath) => {
  try {
    const json = await fs.readFile(filepath);
    return JSON.parse(json);
  } catch (e) {
    console.log(e.message);
     // Тут масса вариантов. Возврат {}, null, '' и т.п.
    return {};
  }
}

Правильное решение — порождать исключения и не подавлять исключения.

Коды возврата

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

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

Исключения

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

if (error) {
  throw new Error(/* чем больше тут полезной информации, тем лучше */);
}

Специально для сайта ITWORLD.UZ. Новость взята с сайта Блог Хекслета