Перейти до основного вмісту

Callbacks, Promises, async/await в Node.js

Node.js однопотоковий і використовує неблокуючий I/O. Замість створення нових потоків, Node.js реєструє callback-функції, які викликаються, коли операція завершується. Це схоже на CompletableFuture у Java, але є основним способом роботи.

1. Callbacks: Перший підхід до асинхронності

Callback — це функція, яку ви передаєте як аргумент і яка викликається після завершення операції. У Java це можна порівняти з передачею Consumer або Function як параметра.

// Node.js - callback pattern
import fs from 'fs';

// Error-first callback convention
fs.readFile('user.json', 'utf8', (err, data) => {
    if (err) {
        console.error('Помилка читання файлу:', err);
        return;
    }
    console.log('Дані:', data);
});

console.log('Цей рядок виведеться ПЕРШИМ!');

Конвенція Error-First Callback: Перший параметр завжди помилка (або null), другий — результат. Це стандарт у Node.js.

Приклад: HTTP-запит з callbacks

import http from 'http';

function fetchUser(userId: number, callback: (err: Error | null, user?: any) => void) {
    http.get(`http://api.example.com/users/${userId}`, (res) => {
        let data = '';
        
        res.on('data', (chunk) => {
            data += chunk;
        });
        
        res.on('end', () => {
            try {
                const user = JSON.parse(data);
                callback(null, user);
            } catch (err) {
                callback(err as Error);
            }
        });
    }).on('error', (err) => {
        callback(err);
    });
}

// Використання
fetchUser(123, (err, user) => {
    if (err) {
        console.error(err);
        return;
    }
    console.log('User:', user);
});

Проблема: Callback Hell (Піраміда Doom)

Коли потрібно виконати кілька асинхронних операцій послідовно, код стає нечитабельним:

// Callback Hell - погана практика!
fetchUser(123, (err, user) => {
    if (err) return console.error(err);
    
    fetchOrders(user.id, (err, orders) => {
        if (err) return console.error(err);
        
        fetchOrderDetails(orders[0].id, (err, details) => {
            if (err) return console.error(err);
            
            updateInventory(details.items, (err, result) => {
                if (err) return console.error(err);
                
                sendNotification(user.email, result, (err) => {
                    if (err) return console.error(err);
                    console.log('Все готово!');
                });
            });
        });
    });
});

У Java це виглядало б як вкладені CompletableFuture з вкладеними thenAccept. Неприємно, чи не так?

2. Promises: Розв'язання проблеми Callback Hell

Promise — це об'єкт, що представляє майбутнє значення. Аналог у Java — CompletableFuture<T>.

Три стани Promise:

  • Pending — очікує виконання (як незавершений Future)
  • Fulfilled — успішно завершено з результатом
  • Rejected — завершено з помилкою
// Створення Promise
function fetchUser(userId: number): Promise<any> {
    return new Promise((resolve, reject) => {
        http.get(`http://api.example.com/users/${userId}`, (res) => {
            let data = '';
            
            res.on('data', (chunk) => {
                data += chunk;
            });
            
            res.on('end', () => {
                try {
                    const user = JSON.parse(data);
                    resolve(user); // Еквівалент completableFuture.complete(user)
                } catch (err) {
                    reject(err); // Еквівалент completableFuture.completeExceptionally(err)
                }
            });
        }).on('error', (err) => {
            reject(err);
        });
    });
}

Ланцюжки Promise (Promise Chaining)

Метод .then() повертає новий Promise, що дозволяє будувати ланцюжки:

// Promise chaining - набагато читабельніше!
fetchUser(123)
    .then(user => {
        console.log('Користувач:', user);
        return fetchOrders(user.id); // Повертаємо новий Promise
    })
    .then(orders => {
        console.log('Замовлення:', orders);
        return fetchOrderDetails(orders[0].id);
    })
    .then(details => {
        console.log('Деталі:', details);
        return updateInventory(details.items);
    })
    .then(result => {
        console.log('Результат:', result);
    })
    .catch(err => {
        // Один обробник помилок для всього ланцюжка!
        console.error('Помилка:', err);
    })
    .finally(() => {
        console.log('Завершено (незалежно від результату)');
    });

Це схоже на CompletableFuture.thenCompose() у Java.

Паралельне виконання з Promise.all

// Аналог CompletableFuture.allOf() у Java
const promises = [
    fetchUser(123),
    fetchUser(456),
    fetchUser(789)
];

Promise.all(promises)
    .then(users => {
        console.log('Всі користувачі:', users);
        // users - це масив результатів у тому ж порядку
    })
    .catch(err => {
        // Якщо хоча б один Promise rejected
        console.error('Помилка:', err);
    });

Promise.race та Promise.allSettled

// Promise.race - повертає перший завершений Promise
Promise.race([
    fetchFromPrimaryDB(),
    fetchFromReplicaDB()
])
.then(result => console.log('Найшвидша відповідь:', result));

// Promise.allSettled - чекає на всі Promise (навіть rejected)
Promise.allSettled([
    fetchUser(123),
    fetchUser(999), // Може не існувати
    fetchUser(456)
])
.then(results => {
    results.forEach(result => {
        if (result.status === 'fulfilled') {
            console.log('Успіх:', result.value);
        } else {
            console.log('Помилка:', result.reason);
        }
    });
});

3. Async/Await: Синтаксичний цукор над Promises

У Java 21+ з'явилися Virtual Threads та Structured Concurrency. Async/await у JavaScript з'явився раніше і робить асинхронний код схожим на синхронний.

// Async/await - найсучасніший підхід
async function processOrder(userId: number): Promise<void> {
    try {
        // await "розпаковує" Promise і чекає на результат
        const user = await fetchUser(userId);
        console.log('Користувач:', user);
        
        const orders = await fetchOrders(user.id);
        console.log('Замовлення:', orders);
        
        const details = await fetchOrderDetails(orders[0].id);
        console.log('Деталі:', details);
        
        const result = await updateInventory(details.items);
        console.log('Результат:', result);
        
    } catch (err) {
        // Ловимо помилки з будь-якого await
        console.error('Помилка:', err);
    } finally {
        console.log('Завершено');
    }
}

// Виклик async функції
processOrder(123);

Важливо: await можна використовувати тільки всередині async функції. Async функція завжди повертає Promise.

Паралельне виконання з async/await

// НЕПРАВИЛЬНО - послідовне виконання (повільно!)
async function getMultipleUsers() {
    const user1 = await fetchUser(123); // Чекаємо 1 секунду
    const user2 = await fetchUser(456); // Чекаємо ще 1 секунду
    const user3 = await fetchUser(789); // Чекаємо ще 1 секунду
    return [user1, user2, user3]; // Загалом: 3 секунди
}

// ПРАВИЛЬНО - паралельне виконання (швидко!)
async function getMultipleUsersFast() {
    // Запускаємо всі Promise одночасно
    const [user1, user2, user3] = await Promise.all([
        fetchUser(123),
        fetchUser(456),
        fetchUser(789)
    ]);
    return [user1, user2, user3]; // Загалом: ~1 секунда
}

Порівняння з Java Spring Boot

// Java/Spring Boot - блокуючий підхід
@Service
public class OrderService {
    public OrderResponse processOrder(Long userId) {
        User user = userRepository.findById(userId);
        List<Order> orders = orderRepository.findByUserId(userId);
        OrderDetails details = orderClient.getDetails(orders.get(0).getId());
        return new OrderResponse(user, orders, details);
    }
}

// Node.js - async/await
async function processOrder(userId: number): Promise<OrderResponse> {
    const user = await userRepository.findById(userId);
    const orders = await orderRepository.findByUserId(userId);
    const details = await orderClient.getDetails(orders[0].id);
    return { user, orders, details };
}

// Java - з CompletableFuture (реактивний підхід)
public CompletableFuture<OrderResponse> processOrderAsync(Long userId) {
    return userRepository.findByIdAsync(userId)
        .thenCompose(user -> 
            orderRepository.findByUserIdAsync(userId)
                .thenCompose(orders ->
                    orderClient.getDetailsAsync(orders.get(0).getId())
                        .thenApply(details -> 
                            new OrderResponse(user, orders, details)
                        )
                )
        );
}

Обробка помилок: порівняння підходів

Підхід Обробка помилок Читабельність
Callbacks Error-first параметр, перевірка в кожному callback ❌ Погано (callback hell)
Promises .catch() в кінці ланцюжка ✅ Добре
Async/Await try/catch блоки (як у Java!) ✅✅ Відмінно

Типові помилки та best practices

// ❌ ПОМИЛКА 1: Забути return у Promise chain
fetchUser(123)
    .then(user => {
        fetchOrders(user.id); // Забули return!
        // Наступний .then() отримає undefined
    })
    .then(orders => {
        console.log(orders); // undefined!
    });

// ✅ ПРАВИЛЬНО
fetchUser(123)
    .then(user => {
        return fetchOrders(user.id); // Повертаємо Promise
    })
    .then(orders => {
        console.log(orders); // Масив замовлень
    });

// ❌ ПОМИЛКА 2: Не обробляти rejected promises
async function riskyOperation() {
    await somethingThatMightFail(); // Якщо fail, необроблена помилка!
}

// ✅ ПРАВИЛЬНО
async function safeOperation() {
    try {
        await somethingThatMightFail();
    } catch (err) {
        console.error('Обробили помилку:', err);
        // Або прокинути далі: throw err;
    }
}

// ❌ ПОМИЛКА 3: Змішувати callbacks і promises
async function confused() {
    fs.readFile('file.txt', (err, data) => { // Callback
        return data; // Це НЕ СПРАЦЮЄ!
    });
}

// ✅ ПРАВИЛЬНО: Використовувати promise-based API
import { readFile } from 'fs/promises';

async function correct() {
    const data = await readFile('file.txt', 'utf8');
    return data;
}

Util.promisify: Перетворення callbacks у promises

import { promisify } from 'util';
import fs from 'fs';

// Перетворюємо callback-based функцію на Promise-based
const readFileAsync = promisify(fs.readFile);

async function readConfig() {
    try {
        const data = await readFileAsync('config.json', 'utf8');
        return JSON.parse(data);
    } catch (err) {
        console.error('Помилка читання конфігу:', err);
        throw err;
    }
}

// Або використовувати вбудований fs/promises:
import { readFile } from 'fs/promises';

async function readConfigModern() {
    const data = await readFile('config.json', 'utf8');
    return JSON.parse(data);
}

Практичний приклад: Express endpoint

import express, { Request, Response } from 'express';

const app = express();

// ПОГАНО: callback hell в Express
app.get('/user/:id/orders', (req, res) => {
    getUserById(req.params.id, (err, user) => {
        if (err) return res.status(500).json({ error: err.message });
        
        getOrdersByUserId(user.id, (err, orders) => {
            if (err) return res.status(500).json({ error: err.message });
            
            res.json({ user, orders });
        });
    });
});

// ДОБРЕ: async/await (як контролер у Spring!)
app.get('/user/:id/orders', async (req: Request, res: Response) => {
    try {
        const user = await getUserById(req.params.id);
        const orders = await getOrdersByUserId(user.id);
        
        res.json({ user, orders });
    } catch (err) {
        res.status(500).json({ 
            error: err instanceof Error ? err.message : 'Unknown error' 
        });
    }
});

// ЩЕ КРАЩЕ: з обробником помилок
app.get('/user/:id/orders', asyncHandler(async (req: Request, res: Response) => {
    const user = await getUserById(req.params.id);
    const orders = await getOrdersByUserId(user.id);
    res.json({ user, orders });
}));

// Utility для автоматичної обробки помилок
function asyncHandler(fn: Function) {
    return (req: Request, res: Response, next: Function) => {
        Promise.resolve(fn(req, res, next)).catch(next);
    };
}

Підсумок: Що використовувати?

  • Callbacks — уникайте в новому коді. Використовуйте тільки якщо працюєте зі старими бібліотеками.
  • Promises — добре для складних ланцюжків та комбінування операцій (Promise.all, Promise.race).
  • Async/Await — основний вибір для більшості випадків. Код виглядає як синхронний Java-код.

Якщо ви з Java/Spring Boot світу — використовуйте async/await. Це найближче до того, як ви пишете код зараз, але з перевагами неблокуючого I/O.

Коментарі

Популярні публікації

Колекції в Elixir

Списки Що таке список у Elixir У Elixir список — це впорядкована колекція елементів, реалізована як однозв’язний список . Це означає, що кожен елемент (вузол) зберігає посилання на наступний, але не на попередній. Така структура дозволяє швидко додавати елементи на початок списку, але повільно доступати до довільного елементу. У Java подібну структуру представляє LinkedList — частина Java Collections Framework. Вона реалізована як двозв’язний список, що забезпечує зручне додавання/видалення елементів з початку або кінця списку. Створення списків У Elixir список створюється за допомогою квадратних дужок: list = [1, 2, 3, 4] Додавання елементів У Elixir новий елемент можна додати тільки на початок списку за допомогою оператора | : # Elixir list = [2, 3, 4] new_list = [1 | list] # [1, 2, 3, 4] Доступ до елементів У Elixir немає прямого доступу до елементів за індексом, але це можна зробити через Enum.at : Enum.at([10, 20, 30], 1) # 20 Ітерація по списку У Elixir ...

Інструменти для роботи з Node.js

Що таке npm? npm (Node Package Manager) — це офіційний пакетний менеджер для Node.js . Він дозволяє: Встановлювати сторонні бібліотеки та фреймворки Керувати залежностями проєкту Запускати скрипти (команди) через package.json Приклад ініціалізації проєкту з npm npm init -y Файл package.json (скорочений приклад) { "name": "my-project", "version": "1.0.0", "scripts": { "start": "ts-node src/index.ts", "build": "tsc" }, "dependencies": { "express": "^4.18.0" }, "devDependencies": { "typescript": "^5.0.0", "ts-node": "^10.0.0" } } Що таке tsconfig.json? tsconfig.json — це файл конфігурації для компілятора TypeScript, який визначає, як слід компілювати код. Приклад файлу tsconfig.json { "compilerOptions": { "target": "ES2020...

Атоми в мові програмування Elixir

Атоми в Elixir Атоми є фундаментальною концепцією в Elixir , що відіграє ключову роль у створенні надійних та масштабованих систем. В Elixir це специфічний тип даних, який є константою , незмінною , ідентифікованою за своїм ім'ям . Отже, атом в Elixir — це іменована константа, що представляє себе. Уявіть, що ви даєте унікальне ім'я певній речі, і це ім'я завжди посилається саме на цю річ, і ніколи на щось інше. Наприклад, атом :ok завжди буде означати саме успішне завершення операції, а не якесь інше значення. Технічно, атоми є похідними від чисел . Кожен унікальний атом зберігається у таблиці атомів, і йому присвоюється унікальний цілочисельний ідентифікатор. Це робить їх надзвичайно ефективними для порівняння: замість порівняння рядків (що є повільною операцією), Elixir порівнює цілочисельні ідентифікатори. Переваги та особливості використання атомів Переваги атомів: Ефективність. Завдяки своєму числовому представленню, порівняння атомів є дуже швидким. Це осо...

Основи Node.js

Що таке Node.js? Node.js — це середовище виконання JavaScript поза браузером, побудоване на рушії Google V8 . Воно дозволяє запускати JavaScript на сервері, створюючи серверні застосунки з високою продуктивністю. Основні характеристики: Однопотокова модель з неблокуючим I/O Асинхронне виконання за допомогою event loop Висока продуктивність у роботі з мережевими запитами npm — найбільший реєстр пакетів Що таке event loop? Event loop — це механізм в Node.js, який дозволяє неблокуючим асинхронним операціям виконуватись у середовищі з єдиним потоком. Він постійно перевіряє наявність подій у черзі та викликає відповідні колбеки. Як працює однопоточність у Node.js? Node.js використовує один потік (main thread) для обробки JavaScript-коду. Операції, які займають час (мережеві запити, читання з файлової системи), делегуються до системних API або thread pool, і після завершення результат повертається у основний потік через event loop. Приклад: асинхронна о...

Встановлення PostgreSQL на Ubuntu-сервер

Встановлення Оновлюємо пакети та встановлюємо PostgreSQL: sudo apt update sudo apt install -y postgresql postgresql-contrib Перевіряємо статус сервісу: sudo systemctl status postgresql Якщо PostgreSQL не запущений, запустимо його: sudo systemctl start postgresql sudo systemctl enable postgresql Налаштування безпеки Зміна пароля: sudo -u postgres psql У консолі PostgreSQL: ALTER USER postgres PASSWORD 'міцний_пароль'; \q \q - вихід з консолі. Список основних команд для роботи з PostgreSQL можна переглянути за посиланням. За замовчуванням PostgreSQL слухає localhost (127.0.0.1). Щоб дозволити доступ із зовнішніх машин, редагуємо конфігурацію: sudo nano /etc/postgresql/17/main/postgresql.conf (замість 17 вкажи версію PostgreSQL, яку встановлено) Шукаємо рядок: #listen_addresses = 'localhost' та замінюємо на listen_addresses = '*' Зберігаємо (Ctrl + X, Y, Enter). Тепер редагуємо pg_hba.conf: sudo nano /etc/postgresql/17/main/pg_hba.conf...