Программисты каждый день пользуются сторонними библиотеками в своих программах, например, http-клиентами или парсерами. Помимо выполнения основных функций, все эти библиотеки как-то обрабатывают возникающие ошибки. Причем чем больше в библиотеке побочных эффектов — сетевое взаимодействие, работа с файлами — тем больше внутри кода, отвечающего за ошибки, и тем он сложнее.
В этой статье разберем принципы, по которым строится обработка ошибок внутри библиотек. Это поможет отличать хорошие библиотеки от плохих. Вы сможете лучше строить взаимодействие с ними и даже проектировать свои собственные библиотеки.
Подписывайтесь на канал Кирилла Мокевнина в Telegram — чтобы узнать больше о программировании и профессиональном пути разработчика
Прежде чем начать, давайте разберемся с терминологией. В отличие от программы, библиотека не может использоваться напрямую, например, в терминале. Любая библиотека — это код на конкретном языке, который вызывается другим кодом на этом же языке. Говорят, что у библиотеки есть клиент. Клиент — тот, кто использует библиотеку:
// 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(/* чем больше тут полезной информации, тем лучше */);
}