Async patterns в Node.js: callbacks, promises, async/await, error handling
Node.js построен на асинхронной модели: операции ввода-вывода не блокируют event loop, а выполняются в фоне. За 15 лет эволюции JavaScript прошёл путь от callback hell к элегантному async/await. Понимание этих паттернов и правильная обработка ошибок — основа надёжных приложений на Node.js.
Эволюция асинхронных паттернов
В ранних версиях Node.js единственным способом работы с асинхронностью были callbacks. Promises появились в ES6 (2015), async/await — в ES2017. Каждый новый паттерн решал проблемы предыдущего, но не отменял его полностью: callbacks всё ещё используются в низкоуровневых API, promises — в библиотеках, async/await — в прикладном коде.
// Callbacks (Node.js 0.x)
fs.readFile('file.txt', (err, data) => {
if (err) throw err;
console.log(data);
});
// Promises (ES6)
fs.promises.readFile('file.txt')
.then(data => console.log(data))
.catch(err => console.error(err));
// Async/await (ES2017)
try {
const data = await fs.promises.readFile('file.txt');
console.log(data);
} catch (err) {
console.error(err);
}
Callbacks и callback hell
Callback — функция, которая вызывается после завершения асинхронной операции. Node.js использует соглашение error-first callbacks: первый аргумент — ошибка (или null), остальные — результат.
function readConfig(callback) {
fs.readFile('config.json', 'utf8', (err, data) => {
if (err) return callback(err);
try {
const config = JSON.parse(data);
callback(null, config);
} catch (parseErr) {
callback(parseErr);
}
});
}
readConfig((err, config) => {
if (err) {
console.error('Failed to read config:', err);
return;
}
console.log('Config loaded:', config);
});
Проблема callbacks — callback hell (пирамида doom): вложенные вызовы становятся нечитаемыми.
fs.readFile('user.json', (err, userData) => {
if (err) return handleError(err);
const user = JSON.parse(userData);
db.query('SELECT * FROM orders WHERE user_id = ?', [user.id], (err, orders) => {
if (err) return handleError(err);
orders.forEach(order => {
api.fetchDetails(order.id, (err, details) => {
if (err) return handleError(err);
console.log(details);
});
});
});
});
Каждый уровень вложенности усложняет обработку ошибок и тестирование. Решение — promises.
Promises: цепочки и комбинаторы
Promise — объект, представляющий результат асинхронной операции. Три состояния: pending, fulfilled, rejected. Методы .then() и .catch() позволяют строить цепочки.
function readConfig() {
return fs.promises.readFile('config.json', 'utf8')
.then(data => JSON.parse(data));
}
readConfig()
.then(config => {
console.log('Config loaded:', config);
return db.connect(config.dbUrl);
})
.then(connection => {
console.log('Database connected');
return connection.query('SELECT * FROM users');
})
.then(users => {
console.log('Users:', users);
})
.catch(err => {
console.error('Error:', err);
});
Цепочка читается линейно, ошибки обрабатываются в одном .catch(). Важно: всегда возвращайте promise из .then(), иначе цепочка сломается.
Promise комбинаторы
Promise.all — ждёт завершения всех promises, возвращает массив результатов. Если хотя бы один reject — весь Promise.all reject.
const [users, orders, products] = await Promise.all([
db.query('SELECT * FROM users'),
db.query('SELECT * FROM orders'),
db.query('SELECT * FROM products'),
]);
Promise.race — возвращает результат первого завершившегося promise (fulfilled или rejected).
const result = await Promise.race([
fetch('https://api1.example.com/data'),
fetch('https://api2.example.com/data'),
new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), 5000)),
]);
Promise.allSettled — ждёт завершения всех promises, возвращает массив с результатами и ошибками. Не reject, даже если часть promises failed.
const results = await Promise.allSettled([
fetch('https://api1.example.com/data'),
fetch('https://api2.example.com/data'),
fetch('https://broken-api.example.com/data'),
]);
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
console.log(`API ${index + 1} success:`, result.value);
} else {
console.error(`API ${index + 1} failed:`, result.reason);
}
});
Promise.any — возвращает первый fulfilled promise, игнорирует rejected. Если все rejected — выбрасывает AggregateError.
Async/await: синхронный стиль для асинхронного кода
Async/await — синтаксический сахар над promises. Функция с async всегда возвращает promise. await приостанавливает выполнение до завершения promise.
async function loadUserData(userId) {
const user = await db.query('SELECT * FROM users WHERE id = ?', [userId]);
const orders = await db.query('SELECT * FROM orders WHERE user_id = ?', [userId]);
const details = await api.fetchDetails(user.email);
return { user, orders, details };
}
// Использование
try {
const data = await loadUserData(123);
console.log(data);
} catch (err) {
console.error('Failed to load user data:', err);
}
Код читается как синхронный, но не блокирует event loop. Ошибки обрабатываются через try/catch.
Параллельное выполнение
Последовательные await выполняются друг за другом. Для параллельного выполнения используйте Promise.all:
// Медленно: 3 секунды (1 + 1 + 1)
const user = await fetchUser();
const orders = await fetchOrders();
const products = await fetchProducts();
// Быстро: 1 секунда (параллельно)
const [user, orders, products] = await Promise.all([
fetchUser(),
fetchOrders(),
fetchProducts(),
]);
Top-level await
В ES2022 можно использовать await на верхнем уровне модуля (без async функции):
// config.js
const response = await fetch('https://api.example.com/config');
export const config = await response.json();
// app.js
import { config } from './config.js';
console.log(config);
Модуль блокируется до завершения await. Используйте осторожно: долгие операции замедлят старт приложения.
Error handling: unhandledRejection и async boundaries
Необработанные ошибки в promises приводят к unhandledRejection. В Node.js 15+ процесс завершается с кодом 1.
// Плохо: ошибка не обработана
async function fetchData() {
const data = await fetch('https://api.example.com/data');
return data.json();
}
fetchData(); // Если fetch упадёт, процесс завершится
Всегда обрабатывайте ошибки:
fetchData().catch(err => {
console.error('Failed to fetch data:', err);
});
Глобальный обработчик для unhandledRejection:
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
// Логирование в Sentry, Datadog и т.д.
process.exit(1);
});
Async error boundaries
В Express middleware ошибки в async функциях не попадают в error handler автоматически. Используйте обёртку:
function asyncHandler(fn) {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
}
app.get('/users/:id', asyncHandler(async (req, res) => {
const user = await db.query('SELECT * FROM users WHERE id = ?', [req.params.id]);
res.json(user);
}));
app.use((err, req, res, next) => {
console.error(err);
res.status(500).json({ error: 'Internal server error' });
});
Или используйте библиотеку express-async-errors, которая патчит Express автоматически.
Best practices
- Всегда обрабатывайте ошибки:
.catch()для promises,try/catchдля async/await. - Не смешивайте паттерны: если используете async/await, не добавляйте
.then()в той же функции. - Параллелизм: используйте
Promise.allдля независимых операций. - Избегайте async в циклах:
for (const item of items) await process(item)выполняется последовательно. Для параллельного выполнения:await Promise.all(items.map(process)). - Таймауты: оборачивайте долгие операции в
Promise.raceс таймаутом.
Вывод
Async/await — стандарт для асинхронного кода в современном Node.js. Он устраняет callback hell, делает код читаемым и упрощает обработку ошибок через try/catch. Promises остаются основой: Promise.all, Promise.race, Promise.allSettled решают задачи параллелизма и комбинирования операций. Правильная обработка ошибок — через catch, глобальные обработчики unhandledRejection и async error boundaries — гарантирует стабильность приложения в production.