lenec ru

← все посты

Async patterns в Node.js: callbacks, promises, async/await, error handling

16K

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.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.