Введение
В крупных приложениях с множеством модулей прямая связь между ними через сигналы/слоты (где каждый модуль явно знает о существовании и интерфейсе другого) постепенно создаёт плотный граф зависимостей, усложняющий независимое развитие и тестирование модулей. Шина событий (Event Bus) — паттерн, где модули общаются не напрямую друг с другом, а через центральный посредник, на события которого можно подписаться, не зная заранее, кто их публикует. В статье реализуем простую шину событий на основе сигналов Qt.
Концепция
Центральный объект EventBus (обычно — singleton, доступный из любой части приложения) предоставляет универсальный механизм публикации (publish) и подписки (subscribe) на события, идентифицируемые по типу или строковому имени, без необходимости явно объявлять отдельный сигнал на каждый тип события в самом EventBus. Это даёт более гибкую, но менее типобезопасную (по сравнению с прямыми сигналами/слотами конкретных классов) форму коммуникации, удобную именно для слабой связанности крупных, развивающихся независимо модулей.
Пример кода
#pragma once
#include <QObject>
#include <QVariant>
#include <QHash>
#include <QString>
#include <functional>
class EventBus : public QObject
{
Q_OBJECT
public:
static EventBus &instance()
{
static EventBus instance;
return instance;
}
using Handler = std::function<void(const QVariant &)>;
int subscribe(const QString &eventName, Handler handler)
{
const int id = m_nextId++;
m_handlers[eventName].insert(id, std::move(handler));
return id;
}
void unsubscribe(const QString &eventName, int id)
{
if (m_handlers.contains(eventName)) {
m_handlers[eventName].remove(id);
}
}
void publish(const QString &eventName, const QVariant &payload = QVariant())
{
if (!m_handlers.contains(eventName)) return;
// Копия на случай, если обработчик изменит список подписчиков во время вызова
const auto handlersCopy = m_handlers[eventName];
for (const auto &handler : handlersCopy) {
handler(payload);
}
emit eventPublished(eventName, payload);
}
signals:
void eventPublished(const QString &eventName, const QVariant &payload);
private:
EventBus() = default;
QHash<QString, QHash<int, Handler>> m_handlers;
int m_nextId = 0;
};
// Модуль А: публикует событие при завершении авторизации
#include "eventbus.h"
void onLoginSuccess(const QString &username)
{
QVariantMap payload;
payload["username"] = username;
EventBus::instance().publish("user.loggedIn", payload);
}
// Модуль Б: подписан на событие, не зная ничего о модуле А
class NotificationModule
{
public:
NotificationModule()
{
m_subscriptionId = EventBus::instance().subscribe("user.loggedIn",
[](const QVariant &payload) {
const auto map = payload.toMap();
qDebug() << "Показываем приветствие для" << map["username"].toString();
});
}
~NotificationModule()
{
EventBus::instance().unsubscribe("user.loggedIn", m_subscriptionId);
}
private:
int m_subscriptionId;
};
Пояснения к коду
EventBus хранит обработчики в двухуровневой структуре QHash<QString, QHash<int, Handler>> — внешний ключ — имя события, внутренний — уникальный идентификатор подписки, позволяющий впоследствии корректно отписаться через unsubscribe(). Метод publish() копирует список обработчиков перед вызовом — это защищает от ситуации, когда один из обработчиков во время своего выполнения подписывается или отписывается от того же события, что могло бы инвалидировать итератор по оригинальному контейнеру во время итерации.
QVariant в качестве типа payload — компромисс: он даёт гибкость передачи произвольных данных без объявления отдельного сигнала на каждый тип события, но требует ручного приведения типа на стороне получателя (payload.toMap()), что менее безопасно с точки зрения компилятора, чем обычный типизированный сигнал/слот.
Подводные камни
- Потеря типобезопасности. Поскольку события идентифицируются строками, а данные передаются через
QVariant, опечатка в имени события ("user.loginSuccess"вместо"user.loggedIn") не обнаруживается компилятором — подписчик просто никогда не получит событие, и это сложно диагностировать без специального тестирования или статического анализа использования строковых констант. - EventBus как глобальная точка связанности, замаскированная под «слабую связанность». Хотя модули не знают друг о друге напрямую, они все неявно зависят от общего набора строковых имён событий и формата payload — фактическая связанность не исчезает, а перемещается в «контракт» имён и структур данных событий, который нужно явно документировать и поддерживать в синхронизации между модулями.
- Утечка обработчиков при забытой отписке. Если объект-подписчик удаляется без вызова
unsubscribe()(в отличие от обычных сигналов/слотов Qt междуQObject, где отключение происходит автоматически), захваченные в лямбде указатели на уже удалённый объект могут привести к обращению по освобождённой памяти при следующей публикации события — пример с деструкторомNotificationModuleпоказывает необходимый, но легко забываемый шаг ручной отписки. - Сложность отладки потока событий. В крупном приложении с десятками событий, публикуемых и потребляемых через единую шину, трассировка «что вызвало это действие» сложнее, чем при прямых вызовах методов или явных сигналах/слотах — стоит логировать публикации событий (как в примере, через дополнительный сигнал
eventPublished) для упрощения диагностики в development-сборках.