Comments 19
Глазам больно. Выглядит как попытка решить проблему, которой нет.
Идиома в го:
Проброс ошибки наверх без обработки
v, err := foo()
if err != nil { return err}
Обработка на месте
if err != nil //{return something (err)}
return nil, fmt.Errorf("имя функции(%d): %w", параметры функции, err)
На самом верхнем уровне (main, HTTP-handler, worker-loop) единожды пишем в лог и решаем судьбу процесса.
if err := run(); err != nil {
log.Fatalf("fatal: %v", err)
}
А у вас вызывающий код не узнает об ошибке; придётся парсить лог (как в тесте).
Лайк за анализ от клауди, кмк должно стать новой нормой.
По существу: передача ошибок вверх не спасает от зашумления кода, error hell никуда не делся, даже если делать банальные паники на каждую ошибку (что не всегда правильно но читается глазами лучше чем сложная передача вверх), все равно это hell, так что пока не избавились
Какая-то ерунда. Подходит только для бизнеслогики, где всегда есть одно и только одно действие. А если у меня два действия и второе я не могу сделать, если зафейлилось первое? А еще для лога нужно будет всю метаинформацию пробрасывать в самый низ...
В современных языках, таких как Rust и Zig, обработка ошибок сделана достаточно легковесной и грамотной. Непонятно что мешает добавить её в Go - ИМХО это ни коим образом не нарушит идеологию "максимально простого и прозрачного" языка.
Специальные алгебраические типы Option и Result (которые можно завернуть в компактный синтаксис опционалов, со знаком вопроса и т.п.), и компактный оператор распаковки - который возвращает значение опционала если ошибки не было, и "пробрасывает" код возврата в вызывающую функцию через штатный return - если ошибка была. Никаких наворотов с классическими исключениями C++/C#/Java... Хотя от этих наворотов кажется всё равно никуда не деться - во всех языках есть "паника" с размоткой стека, есть деление на ноль и прочее, что не укладывается в легковесный механизм.
Ну да
Если без обработки
Rust: оператор '?' на каждом уровне
C#: throw и ловить где-то на самом верху
Go:
if
err != nil {
return
err} на каждом уровне
Это нормально©
А для деления на нуль в kotlin есть отметка throwable или что-то похожее
Писать оператор "?" на каждом уровне проще чем явно проверять коды возврата в Go. Но если хочется совсем уж неявно - то можно придумать такой синтаксис: если функция возвращает Option или Result, и мы в ней разыменовываем объект Option/Result в котором невалидные данные - делать неявный return этого объекта. Явность теряется (никаких знаков вопроса и т.п.), но зато и никакой лишней писанины.
Там на гитхабе до сих пор обсуждают обработку ошибок, были неплохие идеи. Может когда-то добавят. Go тоже современный язык. А вообще нет никакой проблемы. Есть проблема что я не знаю какие ошибки обработать
Последний обсуждаемый issue был как раз про ? - https://github.com/golang/go/issues/71203
После долгих и бурных обсуждений issue ожидаемо закрыли с пометкой «not planned»
Итого вы сделали код ещё более сложным, заодно и лишнюю зависимость подцепили.
Если ошибки не влияют на ветвление логики, может они вообще тогда не нужны...
Я не мастер функциональных языков, но в typescript как по мне самый классный паттерн это OperationResult (давно не трогал typescript, накидал пример с помощью нейросети):
import { Request, Response } from 'express';
type OperationResult<T, E> =
| { success: true; value: T }
| { success: false; error: E };
type CheckoutError =
| { type: 'CardNotFound'; message: string }
| { type: 'OrderNotFound'; message: string }
| { type: 'NotEnoughFunds'; message: string; deficit: number }
| { type: 'AlreadyPaid'; message: string; paymentDate: Date }
| { type: 'ValidationError'; message: string; details: string[] };
type CheckoutOperationResult = OperationResult<string, CheckoutError>;
interface ProblemDetails {
type: string;
title: string;
status: number;
detail?: string;
instance?: string;
[key: string]: any;
}
function performCheckout(orderId: string, cardId: string): CheckoutOperationResult {
if (orderId === 'valid-order' && cardId === 'valid-card') {
return {
success: true,
value: 'chk_' + Math.random().toString(36).substring(2, 15)
};
}
if (orderId === 'invalid-order') {
return {
success: false,
error: {
type: 'OrderNotFound',
message: `Order ${orderId} not found`
}
};
}
if (cardId === 'invalid-card') {
return {
success: false,
error: {
type: 'CardNotFound',
message: `Card ${cardId} not found`
}
};
}
if (cardId === 'insufficient-funds') {
return {
success: false,
error: {
type: 'NotEnoughFunds',
message: 'Insufficient funds',
deficit: 50.00
}
};
}
if (orderId === 'paid-order') {
return {
success: false,
error: {
type: 'AlreadyPaid',
message: 'Order already paid',
paymentDate: new Date()
}
};
}
return {
success: false,
error: {
type: 'ValidationError',
message: 'Invalid data',
details: ['Missing required field']
}
};
}
function createProblemDetails(
errorType: string,
status: number,
title: string,
detail: string,
extensions?: Record<string, any>
): ProblemDetails {
return {
type: `https://api.example.com/probs/${errorType.toLowerCase()}`,
title,
status,
detail,
...extensions
};
}
async function handleCheckout(req: Request, res: Response) {
try {
const { orderId, cardId } = req.body;
if (!orderId || !cardId) {
const problem = createProblemDetails(
'missing-fields',
400,
'Missing required fields',
'Both orderId and cardId are required'
);
return res.status(400).json(problem);
}
const result = performCheckout(orderId, cardId);
if (result.success) {
return res.status(200).json({
checkoutId: result.value
});
}
switch (result.error.type) {
case 'CardNotFound':
const cardProblem = createProblemDetails(
'card-not-found',
404,
'Card not found',
result.error.message
);
return res.status(404).json(cardProblem);
case 'OrderNotFound':
const orderProblem = createProblemDetails(
'order-not-found',
404,
'Order not found',
result.error.message
);
return res.status(404).json(orderProblem);
case 'NotEnoughFunds':
const fundsProblem = createProblemDetails(
'insufficient-funds',
402,
'Insufficient funds',
result.error.message,
{ deficit: result.error.deficit }
);
return res.status(402).json(fundsProblem);
case 'AlreadyPaid':
const paidProblem = createProblemDetails(
'already-paid',
409,
'Order already paid',
result.error.message,
{ paymentDate: result.error.paymentDate.toISOString() }
);
return res.status(409).json(paidProblem);
case 'ValidationError':
const validationProblem = createProblemDetails(
'validation-error',
400,
'Invalid request data',
result.error.message,
{ errors: result.error.details }
);
return res.status(400).json(validationProblem);
default:
const _exhaustiveCheck: never = result.error;
throw new Error(`Unhandled error type: ${_exhaustiveCheck}`);
}
} catch (error) {
console.error('Checkout failed:', error);
const problem = createProblemDetails(
'internal-server-error',
500,
'Internal Server Error',
'An unexpected error occurred'
);
return res.status(500).json(problem);
}
}
Суть в следующем - error перечисляет консьюмеру все причины, почему application layer может отказать выполнять сценарий, при этом эти ошибки могут быть устранены вызывающей стороной. Такой подход форсирует все те конечные точки, которые триггерят сценарий, обрабатывать все возможные исходы событий по соответствующему протоколу; в нашем случае это HTTP + RFC 7807 Problem Details.
Так же есть некий глобальный обработчик, который отлавливает системные ошибки (в моем случае это try / catch внутри HTTP обрабработчика, но может быть вынесено в middleware); эта ошибка не устраняется консьюмером модуля, только разработчиком, поэтому она всегда вылетает как exception и в HTTP обрабатывается как 500. Конечно разработчик консьюмера и разработчик сценария может быть одно и то же лицо, но всё-таки надо уметь разделять роли и перспективы.
Опять же паттерн не для каждого случая, но у меня получалось эффективно его внедрять и разработчиком нравилось, что глядя на сигнатуру операции можно увидеть все возможные варианты аутпута.
Тут ещё важно сказать пару слов про "валидацию". Если инпут в принципе навалиден (например, невозможно десерилизовать JSON), возвращаем 400. Дальше нужно отвалидировать инпут перед тем, как отправлять в processCheckout, а может быть вам нужно скомбинировать несколько операций. В этом случае либо 400, либо 422, как вам больше нравится. И вот тут может быть интересный кейс - валидацию на уровне обработчика запрос прошел, а на уровне application (processCheckout) - нет. И в данном случае нужно выкидывать исключение. Дело в том, что вы неправильно консьюмаете модуль и пользователь извне может просто не подобрать такую комбинацию параметров, которая позволить пройти обе валидации. Так что для внешнего консьюмера вашего API это ошибка, которую устранить можете только вы как разработчик API, поэтому 500.
Пишу с телефона, поэтому неудобно редактировать код.
Как-будто это вообще не то. Паттерн для клиент-серверного взаимодействия.
Не вижу каких-либо ограничений для использования в других случаях.
Предположим есть модуль FileSystem, я его разработчик, Вы его консьюмер. Я как разработчик определяю контракт, по которому можно определить как позитивные, так и негативные исходы взаимодейсвтия с модулем FileSystem. В сигнатуре отражены те исходы, на которые Вы можете повлиять как консьюмер (Файл не найден, Неправильный путь и тд). В противных случаях будет выброшено исключение (NullReference) из-за неправильного кода и эту ошибку должен устранить я как продьюсер модуля . Далее Вы разрабатываете свой модуль FileManager - он использует модуль FileSystem разработанный мной, а еще модуль ObjectStorage и тд. В конечном итоге Ваш модуль FileManager будет иметь ряд предустановленных провайдеров (FileSystem, ObjectStorage), но для консьюмера Ваш модуль будет иметь свои контракты и свои типы ошибок, в зависимости от вашей бизнес логики, например, вы можете накрутить авторизацию, теггирование, поиск по тегам, индексацию и тд за счет композиции разных модулей. Более того, однажды вы можете поменять используемые модули на другие (например, из-за смены типа лицензии) и ваши контракты не изменятся, модуль останется обратно совместимым.
И мы все еще можем иметь в виду модуль FileManager как подключаемый пакет / библиотеку классов, который можно сконфигурировать для использования других провайдеров (GoogleDrive, etc) с помощью Dependency Inversion (далее DI). В сигнатуре интерфейсов для DI n у вас будут свои ошибки, свои ожидаемые результаты (в этом смысл DI), которые умеет использовать и интепретировать модуль FileManager. И уже те разработчики, которые будут писать конфигурации и расширения для вашего модуля, будут обязаны реализовывать сигнатуры Вашего модуля. И в случае, если консьюмеры Вашего модуля FileManager, которые реализуют интерфейсы для конфигурации модуля, сталкиваются с кейсом, которую не могут интепретировать в одну из ошибок ожидаемых FileManager-ом, они могут смело выкидывать исключение. Оно впоследствии может быть модулем обработано в каком-то общем виде, либо будет выброшего как есть или в какой-то обертке в рантайме.
В итоге подход простой и как мне кажется сработает везде - если разработчик модуля считает консьюмера источником проблемы - возвращаем ошибку; если ошибка внутри модуля (ошибка кода, ошибка конфигурации, ошибка среды исполнения вроде отсутсвтия сети и тд) - выбрасываем исключение. Чем более "общий" модуль, тем больше исходов в нем будет интепретировано как ошибка и тем меньше как исключения; чем более "узкий" модуль, тем меньше исходов в нем будет интепретировано как ошибка, тем больше в нем будет исключений. Получается достаточно балансно.
Подправил код с корректной реализацией валидации
import { Request, Response } from 'express';
type OperationResult<T, E> =
| { success: true; value: T }
| { success: false; error: E };
type CheckoutError =
| { type: 'CardNotFound'; message: string }
| { type: 'OrderNotFound'; message: string }
| { type: 'NotEnoughFunds'; message: string; deficit: number }
| { type: 'AlreadyPaid'; message: string; paymentDate: Date }
| { type: 'InvalidInput'; message: string; details: string[] };
type CheckoutOperationResult = OperationResult<string, CheckoutError>;
interface ProblemDetails {
type: string;
title: string;
status: number;
detail?: string;
instance?: string;
[key: string]: any;
}
class ValidationError extends Error {
constructor(public details: string[]) {
super('Validation failed');
this.name = 'ValidationError';
}
}
function performCheckout(orderId: string, cardId: string): CheckoutOperationResult {
if (orderId === 'valid-order' && cardId === 'valid-card') {
return {
success: true,
value: 'chk_' + Math.random().toString(36).substring(2, 15)
};
}
if (orderId === 'invalid-order') {
return {
success: false,
error: {
type: 'OrderNotFound',
message: `Order ${orderId} not found`
}
};
}
if (cardId === 'invalid-card') {
return {
success: false,
error: {
type: 'CardNotFound',
message: `Card ${cardId} not found`
}
};
}
if (cardId === 'insufficient-funds') {
return {
success: false,
error: {
type: 'NotEnoughFunds',
message: 'Insufficient funds',
deficit: 50.00
}
};
}
if (orderId === 'paid-order') {
return {
success: false,
error: {
type: 'AlreadyPaid',
message: 'Order already paid',
paymentDate: new Date()
}
};
}
return {
success: false,
error: {
type: 'InvalidInput',
message: 'Invalid data',
details: ['Missing required field']
}
};
}
function createProblemDetails(
errorType: string,
status: number,
title: string,
detail: string,
extensions?: Record<string, any>
): ProblemDetails {
return {
type: `https://api.example.com/probs/${errorType.toLowerCase()}`,
title,
status,
detail,
...extensions
};
}
async function handleCheckout(req: Request, res: Response) {
try {
const { orderId, cardId } = req.body;
if (!orderId || !cardId) {
return res.status(400).json(createProblemDetails(
'missing-fields',
400,
'Missing required fields',
'Both orderId and cardId are required'
));
}
const result = performCheckout(orderId, cardId);
if (result.success) {
return res.status(200).json({
checkoutId: result.value
});
}
switch (result.error.type) {
case 'CardNotFound':
return res.status(404).json(createProblemDetails(
'card-not-found',
404,
'Card not found',
result.error.message
));
case 'OrderNotFound':
return res.status(404).json(createProblemDetails(
'order-not-found',
404,
'Order not found',
result.error.message
));
case 'NotEnoughFunds':
return res.status(402).json(createProblemDetails(
'insufficient-funds',
402,
'Insufficient funds',
result.error.message,
{ deficit: result.error.deficit }
));
case 'AlreadyPaid':
return res.status(409).json(createProblemDetails(
'already-paid',
409,
'Order already paid',
result.error.message,
{ paymentDate: result.error.paymentDate.toISOString() }
));
// Throwing exception here because this should be fixed by
// an API developer asap.
case 'InvalidInput':
throw new ValidationError(result.error.details);
default:
// Ensure all of the cases are processed
// otherwise compilation error
const _exhaustiveCheck: never = result.error;
throw new Error(`Unhandled error type: ${_exhaustiveCheck}`);
}
} catch (error) {
console.error('Checkout failed:', error);
return res.status(500).json(createProblemDetails(
'internal-server-error',
500,
'Internal Server Error',
'An unexpected error occurred'
));
}
}
Прощай error-hell: альтернативная обработка ошибок