Микросервисы на JavaScript: от монолита к распределенной системе
Переход от монолитного приложения к микросервисной архитектуре — это не просто технический рефакторинг, а изменение философии разработки. На продвинутом уровне работы с Node.js мы сталкиваемся с необходимостью не просто разбить код на модули, а создать устойчивую, масштабируемую и отказоустойчивую распределенную систему. В этой статье мы разберем ключевые архитектурные решения и практические паттерны, которые помогут вам построить production-ready микросервисы на JavaScript.
Основные вызовы, с которыми сталкивается разработчик: синхронизация данных между сервисами, поддержание целостности транзакций, обнаружение сервисов и эффективная коммуникация. Ошибка на этапе проектирования может привести к хаосу в продакшене. Давайте рассмотрим, как этого избежать.
1. Коммуникация между сервисами: RabbitMQ и gRPC
В мире микросервисов ключевым является выбор протокола общения. Для Node.js наиболее популярны два подхода: асинхронная очередь сообщений (RabbitMQ) и синхронный высокопроизводительный RPC (gRPC).
Асинхронная очередь (RabbitMQ)
Используется для событийно-ориентированной архитектуры. Сервис-производитель не ждет ответа, а публикует событие. Это снижает связанность. Рассмотрим пример отправки заказа в сервис доставки.
// Сервис "Заказы" — продюсер
const amqp = require('amqplib');
async function publishOrderCreated(order) {
const connection = await amqp.connect('amqp://rabbitmq:5672');
const channel = await connection.createChannel();
const exchange = 'order.events';
await channel.assertExchange(exchange, 'topic', { durable: true });
channel.publish(exchange, 'order.created', Buffer.from(JSON.stringify(order)), {
persistent: true
});
console.log(`[x] Sent order: ${order.id}`);
await channel.close();
await connection.close();
}
// Вызов при создании заказа
publishOrderCreated({ id: '123', userId: '456', amount: 1500 });
Сервис-потребитель (доставка) подписывается на очередь:
// Сервис "Доставка" — потребитель
const amqp = require('amqplib');
async function consumeOrders() {
const connection = await amqp.connect('amqp://rabbitmq:5672');
const channel = await connection.createChannel();
const exchange = 'order.events';
await channel.assertExchange(exchange, 'topic', { durable: true });
const q = await channel.assertQueue('', { exclusive: true });
await channel.bindQueue(q.queue, exchange, 'order.created');
channel.consume(q.queue, (msg) => {
if (msg.content) {
const order = JSON.parse(msg.content.toString());
console.log(`[x] Received order: ${order.id}`);
// Логика создания доставки
channel.ack(msg);
}
});
}
consumeOrders();
Синхронный gRPC
Для сценариев, где требуется мгновенный ответ (например, проверка баланса пользователя), gRPC обеспечивает строгую типизацию и высокую скорость через HTTP/2. Определяем прото-файл:
// user.proto
service UserService {
rpc GetUserBalance (UserRequest) returns (UserResponse);
}
message UserRequest {
string user_id = 1;
}
message UserResponse {
double balance = 1;
}
Реализация сервера на Node.js:
const grpc = require('@grpc/grpc-js');
const protoLoader = require('@grpc/proto-loader');
const PROTO_PATH = './user.proto';
const packageDefinition = protoLoader.loadSync(PROTO_PATH, {
keepCase: true,
longs: String,
enums: String,
defaults: true,
oneofs: true
});
const userProto = grpc.loadPackageDefinition(packageDefinition).userservice;
function getBalance(call, callback) {
const userId = call.request.user_id;
// Имитация запроса к БД
const balance = userId === '456' ? 5000 : 0;
callback(null, { balance });
}
const server = new grpc.Server();
server.addService(userProto.UserService.service, { GetUserBalance: getBalance });
server.bindAsync('0.0.0.0:50051', grpc.ServerCredentials.createInsecure(), () => {
console.log('gRPC server running on port 50051');
server.start();
});
2. Паттерн Saga: управление распределенными транзакциями
В микросервисах ACID-транзакции невозможны. Паттерн Saga решает проблему целостности данных через цепочку локальных транзакций с компенсирующими действиями. Рассмотрим хореографическую сагу для оформления заказа.
Сценарий: Создать заказ → Зарезервировать товар → Списать деньги → Отправить уведомление. Если списание денег не удалось — отменить резервирование.
// Сервис "Заказы" (координатор событий)
const EventEmitter = require('events');
const amqp = require('amqplib');
class OrderSaga extends EventEmitter {
async start(order) {
console.log('Saga started for order: ', order.id);
// Шаг 1: Создать заказ
await this.saveOrder(order);
this.emit('order.created', order);
}
async handlePaymentFailed(order) {
console.log('Payment failed, compensating inventory...');
// Компенсация: освободить товары
await this.releaseInventory(order.items);
await this.cancelOrder(order.id);
this.emit('saga.failed', order);
}
}
// Подписка через RabbitMQ (упрощенно)
const saga = new OrderSaga();
saga.on('order.created', async (order) => {
// Сервис инвентаря резервирует товары
await reserveInventory(order.items);
});
saga.on('inventory.reserved', async (order) => {
// Сервис платежей списывает деньги
const success = await processPayment(order);
if (!success) {
saga.handlePaymentFailed(order);
}
});
3. Паттерн CQRS: разделение чтения и записи
CQRS (Command Query Responsibility Segregation) предполагает использование разных моделей для операций чтения (Query) и записи (Command). Это особенно полезно, когда нагрузка на чтение и запись сильно различается.
Пример: Создаем два сервиса: order-write (принимает команды) и order-read (отвечает на запросы). Синхронизация данных происходит через событие.
// Сервис записи (Command)
app.post('/api/orders', async (req, res) => {
const order = { id: uuid(), ...req.body, status: 'pending' };
await writeDb.insert(order);