Перейти к содержанию

События и журнал операций

DeskQueue умеет загрузить заявки, но ещё не показывает отдельные факты их обработки. Введём событие изменения состояния. Событие описывает, что произошло; подписчик решает, как на это отреагировать. Это помогает отделить операцию над заявкой от вывода журнала. При этом событие внутри Node.js остаётся механизмом одного процесса и не превращается автоматически в долговечную историю.

В состоянии stages/04 используются прежние начальные заявки, модуль загрузки и новая точка входа с EventEmitter. Мы изменим состояние первой заявки с open на working и передадим факт журналу. Позднее тот же смысл попадёт в таблицу ticket_events, однако там понадобятся транзакция и проверенный действующий пользователь. Сначала рассмотрим простую локальную реакцию.

Подписка до отправки

Полный файл server.js создаёт объект событий, назначает подписчик и только затем изменяет данные. Встроенная функция randomUUID назначает идентификатор операции. Он помогает связать сообщения, но не заменяет номер заявки.

import { EventEmitter } from 'node:events';
import { randomUUID } from 'node:crypto';
import { loadTickets } from './ticket-source.js';
const events = new EventEmitter();
events.on('ticket.changed', (event) => {
  console.log(JSON.stringify({ event: 'ticket.changed', ...event }));
});
const tickets = await loadTickets();
const ticket = tickets.find((item) => item.id === 1);
const from = ticket.status;
ticket.status = 'working';
events.emit('ticket.changed', {
  operationId: randomUUID(), ticketId: ticket.id, from, to: ticket.status
});

Ожидаемая строка содержит имя события, идентификатор операции, номер заявки и состояния open и working. Случайное значение operationId будет разным при каждом выполнении. Не сравнивайте его с буквальным примером из текста. Нам важна форма и связь полей, а не заранее известная строка UUID.

Подписчик должен существовать до emit. Обычный EventEmitter не хранит прошедшие события для будущих подписчиков. Если регистрация выполнится после изменения, новый подписчик не получит прежний факт. Это отличие от чтения таблицы истории, где запись может существовать дольше процесса. При выборе механизма задайте вопрос: реакция нужна сейчас или событие должно быть доступно спустя время?

Синхронная граница события

Обычные обработчики EventEmitter вызываются синхронно в порядке регистрации. Поэтому тяжёлый цикл внутри подписчика задерживает возвращение из emit. Название «событие» само по себе не означает отдельный поток или фоновую очередь. Обязанности и особенности ошибок следует сверять по API событий Node.js.

Если подписчик является async-функцией, его начало также выполняется при вызове, но после await работа продолжается позднее. emit не ждёт все возвращённые обещания как единое подтверждение. Поэтому выражение «после emit журнал гарантированно записан» неверно для асинхронной файловой записи. Для операции, результат которой обязателен, нужен явный вызов функции с ожиданием либо специально определённый договор доставки.

Это хорошо видно на записи файла. Если сначала изменили заявку, затем отправили событие, а подписчик не смог открыть каталог журнала, состояние уже изменено. Мы не можем обещать откат обычного объекта автоматически. В этом уроке вывод служит диагностикой. В главе о транзакциях история станет обязательной частью изменения и будет записываться вместе с ним в PostgreSQL.

Не используйте диагностическое событие как скрытую команду. Имя ticket.changed сообщает о завершённом факте. Если подписчик на самом деле выполняет основное изменение, вызывающий код не знает, когда оно закончилось и удалось ли. Для обязательного действия уместнее функция transitionTicket, которая возвращает результат или ошибку. После успешной операции можно уведомить необязательные наблюдатели.

Структурированный журнал

Журнал в нашем примере является строкой JSON. Каждая строка имеет известные поля, поэтому её легче сопоставлять и фильтровать. Свободное сообщение «всё получилось» не говорит, какую заявку изменили и откуда пришёл переход. Структура повышает полезность журнала без добавления секретного содержания.

В событии нет пароля, токена и полного пользовательского запроса. Когда появится регистрация, эти данные не должны попадать в журнал через удобное распространение ...req.body. Полезный контекст выбирается явно. Номер заявки и идентификатор запроса достаточны для многих диагностических задач; содержание обращения может требовать иной политики хранения.

Если событие получает внешний текст, JSON.stringify корректно представит перевод строки внутри строкового поля. Это уменьшает риск разорвать одну запись на несколько визуально похожих сообщений. Но сериализация не является полной системой защиты: необходимо также ограничивать размеры, выбирать поля и контролировать доступ к журналу. В дальнейшем HTTP middleware будет выдавать один идентификатор каждому запросу.

Подписки также являются ресурсом. Если добавлять новый обработчик при каждом запросе и не удалять его, один факт начнёт обрабатываться многократно. В длительно работающем приложении появляются лишние ссылки на объекты и предупреждения о числе слушателей. Не исправляйте такое предупреждение лишь увеличением лимита; сначала выясните, почему подписка регистрируется повторно.

Изменение первой заявки существует только в памяти. Повторный запуск загрузит исходный open из JSON и снова произведёт переход. Это ожидаемое ограничение ступени, а не ошибка события. Мы ещё не сохранили данные и не назначили глобальный идентификатор операции, устойчивый к перезапуску. Локальная случайная метка решает задачу сопоставления текущего наблюдения.

В результате вы различаете изменение, факт и реакцию: функция меняет объект, событие сообщает о переходе, подписчик пишет диагностическую строку. Эта модель останется полезной после появления HTTP. Следующий урок создаст сервер, который возвращает заявки клиенту и долго живёт, ожидая новых запросов.

Можно зарегистрировать второй подписчик, который считает число изменений в текущем процессе. Тогда один вызов emit запустит обе реакции. Однако их порядок не должен незаметно определять достоверность основного состояния: если счётчик зависит от побочного изменения другого слушателя, связь стала скрытой. Лучше передать в событии достаточные значения from, to и ticketId, чтобы каждый наблюдатель понимал факт самостоятельно. Объект события также не следует изменять внутри подписчика, поскольку другие получат ту же ссылку.

Событие ошибки имеет специальное поведение в EventEmitter, поэтому его нельзя считать обычным произвольным именем без сопровождения. Для учебного факта выбрано предметное имя, а ошибки обязательной операции передаются через её результат. Когда мы перейдём к HTTP, отсутствие записи в диагностическом журнале не будет означать, что операция не состоялась. Источник истины находится в хранилище. Это различие полезно при разборе инцидента: сообщение является свидетельством работы наблюдателя, а сохранённая строка — состоянием предметных данных. Оба наблюдения нужны, но отвечают на разные вопросы и могут иметь разные сроки жизни.

Для самостоятельного изменения добавьте вторую заявку в действие и сравните диагностические поля. Номер заявки должен измениться, имя события остаться тем же, а идентификатор операции стать новым. Если сохраняется прежний номер, событие сформировано из неправильного объекта. Если изменился только текст сообщения, структура потеряла часть смысла. Такая простая вариация помогает проверить договор события без добавления новых инструментов и не требует принимать свободный журнал за историю базы.

Оглавление курса

Полные файлы учебного проекта. Примеры при подготовке текста не запускались; ответы и вывод в уроке описывают ожидаемый результат читателя.