Рефакторинг кода

Рефакторинг кода, работающего со STOMP.js, обычно связан с несколькими типовыми проблемами:

  • дублирование логики подключения;
  • хаотичная обработка подписок;
  • отсутствие централизованного reconnect-механизма;
  • смешивание бизнес-логики и сетевого уровня;
  • утечки подписок;
  • сложность тестирования;
  • зависимость компонентов от конкретной реализации WebSocket-клиента;
  • накопление callback-логики;
  • отсутствие типизации сообщений.

На ранних этапах разработки код со STOMP.js часто выглядит компактным и понятным, однако по мере роста количества каналов, подписок и обработчиков проект быстро становится трудно поддерживаемым.

Типичный пример «разрастающегося» кода:

import { Client } from '@stomp/stompjs';

const client = new Client({
    brokerURL: 'ws://localhost:15674/ws',
});

client.onConn ect = () => {

    client.subscribe('/topic/orders', (message) => {
        const data = JSON.parse(message.body);

        updateOrders(data);
    });

    client.subscribe('/topic/users', (message) => {
        const data = JSON.parse(message.body);

        updateUsers(data);
    });

    client.subscribe('/topic/statistics', (message) => {
        const data = JSON.parse(message.body);

        updateStatistics(data);
    });
};

client.activate();

Проблемы такого подхода:

  • все подписки сосредоточены в одном месте;
  • отсутствует контроль жизненного цикла;
  • невозможно повторно использовать код;
  • обработчики тесно связаны с UI;
  • reconnect не восстанавливает структуру подписок централизованно;
  • логика сериализации повторяется многократно.

Выделение отдельного STOMP-сервиса

Одно из главных направлений рефакторинга — изоляция транспортного слоя.

Вместо прямой работы со STOMP-клиентом внутри компонентов создаётся отдельный сервис.

Пример плохой архитектуры

function initChat() {

    const client = new Client({
        brokerURL: 'ws://localhost:15674/ws',
    });

    client.onConn ect = () => {

        client.subscribe('/topic/chat', (message) => {
            renderMessage(JSON.parse(message.body));
        });

        document
            .querySelector('#send')
            .addEventListener('click', () => {

                client.publish({
                    destination: '/app/chat',
                    body: JSON.stringify({
                        text: 'Hello'
                    })
                });
            });
    };

    client.activate();
}

Недостатки:

  • UI зависит от STOMP.js;
  • невозможно переиспользовать соединение;
  • тестирование становится сложным;
  • компонент управляет сетью самостоятельно.

Создание транспортного слоя

После рефакторинга появляется отдельный сервис.

import { Client } from '@stomp/stompjs';

class StompService {

    constructor() {

        this.client = new Client({
            brokerURL: 'ws://localhost:15674/ws',
        });
    }

    connect(onConnect) {

        this.client.onConn ect = onConnect;

        this.client.activate();
    }

    subscribe(destination, callback) {

        return this.client.subscribe(destination, (message) => {
            callback(JSON.parse(message.body));
        });
    }

    publish(destination, data) {

        this.client.publish({
            destination,
            body: JSON.stringify(data),
        });
    }
}

export default new StompService();

Теперь UI работает только с абстракцией:

import stompService from './services/StompService';

stompService.connect(() => {

    stompService.subscribe('/topic/chat', (message) => {
        renderMessage(message);
    });
});

Преимущества:

  • единая точка подключения;
  • централизованная конфигурация;
  • упрощённое тестирование;
  • изоляция сетевого уровня;
  • переиспользование клиента.

Инкапсуляция reconnect-логики

Во многих проектах reconnect-логика размазывается по компонентам.

Плохой пример:

client.onWebSocketCl ose = () => {

    setTimeout(() => {
        client.activate();
    }, 5000);
};

При большом количестве модулей появляется:

  • множественный reconnect;
  • конфликт состояний;
  • повторные подписки;
  • гонки подключения.

Централизация переподключения

После рефакторинга reconnect становится частью сервиса.

class StompService {

    constructor() {

        this.client = new Client({
            brokerURL: 'ws://localhost:15674/ws',
            reconnectDelay: 5000,
        });

        this.client.onConn ect = () => {
            console.log('Connected');
        };

        this.client.onStompEr ror = (frame) => {
            console.error(frame);
        };
    }
}

STOMP.js уже содержит встроенный reconnect-механизм, поэтому ручная реализация часто оказывается лишней.


Устранение дублирования подписок

Распространённая проблема — повторение одинаковой логики:

client.subscribe('/topic/orders', (message) => {
    const data = JSON.parse(message.body);

    handleOrders(data);
});

client.subscribe('/topic/users', (message) => {
    const data = JSON.parse(message.body);

    handleUsers(data);
});

Повторяются:

  • JSON.parse;
  • обработка ошибок;
  • логирование;
  • структура callback.

Универсальный механизм подписки

После рефакторинга создаётся общий слой.

subscribe(destination, handler) {

    return this.client.subscribe(destination, (message) => {

        try {

            const data = JSON.parse(message.body);

            handler(data);

        } catch (error) {

            console.error('Parse error', error);
        }
    });
}

Теперь вся сериализация централизована.


Разделение бизнес-логики и транспорта

Антипаттерн:

client.subscribe('/topic/orders', (message) => {

    const data = JSON.parse(message.body);

    if (data.status === 'NEW') {
        showNotification(data);
    }

    if (data.total > 100000) {
        sendAudit(data);
    }

    if (data.user.vip) {
        updateVipDashboard(data);
    }
});

Такой обработчик:

  • сложно тестировать;
  • сложно читать;
  • невозможно переиспользовать;
  • трудно расширять.

Выделение domain-обработчиков

После рефакторинга:

client.subscribe('/topic/orders', (message) => {

    const order = JSON.parse(message.body);

    processOrder(order);
});

Бизнес-логика переносится:

function processOrder(order) {

    handleNotifications(order);
    handleAudit(order);
    handleVip(order);
}

Теперь транспорт отвечает только за доставку сообщения.


Рефакторинг callback-архитектуры

Большие проекты со временем превращаются в «лес callback-функций».

Пример:

client.onConn ect = () => {

    loadSettings(() => {

        loadPermissions(() => {

            client.subscribe('/topic/data', (message) => {

                processData(message, () => {

                    updateUI();
                });
            });
        });
    });
};

Такой код:

  • сложно читать;
  • трудно отлаживать;
  • плохо масштабируется.

Переход на async/await

Хотя STOMP.js остаётся event-driven библиотекой, часть инфраструктурного кода можно упростить.

async function initialize() {

    await loadSettings();

    await loadPermissions();

    connectSocket();
}

Логика становится линейной и предсказуемой.


Централизованное управление подписками

Без рефакторинга подписки создаются хаотично:

const sub1 = client.subscribe(...);
const sub2 = client.subscribe(...);
const sub3 = client.subscribe(...);

Проблемы:

  • сложно отписываться;
  • возникают утечки памяти;
  • старые подписки продолжают работать;
  • reconnect создаёт дубликаты.

Реестр подписок

Хорошая практика — хранение подписок в registry.

class SubscriptionRegistry {

    constructor() {
        this.subscriptions = new Map();
    }

    add(name, subscription) {
        this.subscriptions.set(name, subscription);
    }

    remove(name) {

        const subscription = this.subscriptions.get(name);

        if (subscription) {
            subscription.unsubscribe();
        }

        this.subscriptions.delete(name);
    }

    clear() {

        this.subscriptions.forEach((subscription) => {
            subscription.unsubscribe();
        });

        this.subscriptions.clear();
    }
}

Преимущества:

  • контролируемый lifecycle;
  • отсутствие дубликатов;
  • безопасное уничтожение подписок;
  • упрощённый reconnect.

Рефакторинг структуры каналов

Во многих системах destination-строки разбросаны по проекту:

client.subscribe('/topic/orders', ...);
client.subscribe('/topic/users', ...);
client.subscribe('/topic/chat', ...);

Проблемы:

  • опечатки;
  • невозможность рефакторинга;
  • отсутствие единого контракта.

Выделение констант

export const CHANNELS = {
    ORDERS: '/topic/orders',
    USERS: '/topic/users',
    CHAT: '/topic/chat',
};

Использование:

client.subscribe(CHANNELS.ORDERS, handler);

Теперь изменение маршрутов происходит централизованно.


Типизация сообщений

В больших проектах сообщения начинают иметь сложную структуру.

Без типизации:

const data = JSON.parse(message.body);

console.log(data.user.name);

Ошибка структуры обнаруживается только во время выполнения.


Использование TypeScript

Рефакторинг часто сопровождается миграцией на TypeScript.

interface OrderMessage {
    id: number;
    status: string;
    total: number;
}

Подписка:

subscribe<OrderMessage>(
    CHANNELS.ORDERS,
    (order) => {
        console.log(order.status);
    }
);

Generic-подход:

subscribe<T>(destination: string, handler: (data: T) => void) {

    return this.client.subscribe(destination, (message) => {

        const data: T = JSON.parse(message.body);

        handler(data);
    });
}

Преимущества:

  • безопасный рефакторинг;
  • автодополнение IDE;
  • контроль структуры сообщений;
  • уменьшение runtime-ошибок.

Рефакторинг логирования

Плохой пример:

console.log(message);
console.log(error);
console.log('Connected');

В крупных приложениях такое логирование становится бесполезным.


Централизованный logger

class Logger {

    info(message, payload = null) {
        console.info(message, payload);
    }

    error(message, payload = null) {
        console.error(message, payload);
    }
}

Использование:

logger.info('STOMP connected');

logger.error('Subscription error', error);

Теперь возможно:

  • подключение внешних logger-систем;
  • отправка логов в monitoring;
  • фильтрация событий;
  • централизованный аудит.

Рефакторинг обработки ошибок

Антипаттерн:

client.subscribe('/topic/orders', (message) => {

    const data = JSON.parse(message.body);

    process(data);
});

Любая ошибка ломает callback.


Безопасная обработка

client.subscribe('/topic/orders', (message) => {

    try {

        const data = JSON.parse(message.body);

        process(data);

    } catch (error) {

        logger.error('Order processing error', error);
    }
});

После рефакторинга ошибки становятся локализованными.


Декомпозиция монолитного STOMP-сервиса

Со временем сервис начинает разрастаться:

class StompService {

    connect() {}
    reconnect() {}
    subscribe() {}
    unsubscribe() {}
    log() {}
    retry() {}
    validate() {}
    cache() {}
    metrics() {}
}

Появляется «god object».


Разделение ответственности

После рефакторинга:

TransportLayer
SubscriptionManager
ReconnectManager
MessageSerializer
Logger
MetricsService

Каждый модуль отвечает только за одну задачу.


Рефакторинг publish-логики

Плохой вариант:

client.publish({
    destination: '/app/orders',
    body: JSON.stringify(order),
});

Подобный код повторяется десятки раз.


Выделение message-api

class OrderApi {

    create(order) {

        stomp.publish('/app/orders/create', order);
    }

    cancel(orderId) {

        stomp.publish('/app/orders/cancel', {
            orderId,
        });
    }
}

Преимущества:

  • единая бизнес-модель;
  • инкапсуляция маршрутов;
  • переиспользование;
  • более чистый UI-код.

Рефакторинг состояния соединения

Антипаттерн:

if (client.connected) {
    sendMessage();
}

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


Централизованный state manager

class ConnectionState {

    constructor() {
        this.connected = false;
    }

    setConnected(value) {
        this.connected = value;
    }

    isConnected() {
        return this.connected;
    }
}

Интеграция:

client.onConn ect = () => {
    state.setConnected(true);
};

client.onDisconn ect = () => {
    state.setConnected(false);
};

Рефакторинг конфигурации

Плохой пример:

new Client({
    brokerURL: 'ws://localhost:15674/ws',
    reconnectDelay: 5000,
    heartbeatIncoming: 4000,
    heartbeatOutgoing: 4000,
});

Конфигурация дублируется в нескольких файлах.


Выделение config-слоя

export const STOMP_CONFIG = {
    brokerURL: process.env.STOMP_URL,
    reconnectDelay: 5000,
    heartbeatIncoming: 4000,
    heartbeatOutgoing: 4000,
};

Использование:

const client = new Client(STOMP_CONFIG);

Рефакторинг heartbeat-логики

Некоторые проекты реализуют собственные heartbeat-механизмы поверх встроенных возможностей STOMP.js.

Плохой пример:

setInterval(() => {

    client.publish({
        destination: '/app/ping',
        body: 'ping',
    });

}, 5000);

STOMP.js уже поддерживает heartbeat:

const client = new Client({
    heartbeatIncoming: 4000,
    heartbeatOutgoing: 4000,
});

Удаление лишнего кода уменьшает сложность системы.


Рефакторинг event-архитектуры

Крупные приложения часто начинают зависеть от прямых callback-вызовов.

client.subscribe('/topic/orders', (message) => {

    updateOrders(message);
    updateStatistics(message);
    updateDashboard(message);
});

Такой код создаёт жёсткую связанность.


Event Bus

После рефакторинга:

eventBus.emit('order.created', order);

Компоненты подписываются независимо:

eventBus.on('order.created', updateOrders);
eventBus.on('order.created', updateStatistics);
eventBus.on('order.created', updateDashboard);

STOMP превращается в транспортный слой, а не центральный механизм бизнес-логики.


Рефакторинг производительности

Плохой пример:

client.subscribe('/topic/metrics', (message) => {

    render(JSON.parse(message.body));
});

Если сообщения приходят сотни раз в секунду, UI начинает деградировать.


Batch-обработка сообщений

После рефакторинга:

const queue = [];

client.subscribe('/topic/metrics', (message) => {
    queue.push(JSON.parse(message.body));
});

setInterval(() => {

    if (queue.length > 0) {

        renderBatch(queue.splice(0));
    }

}, 100);

Преимущества:

  • уменьшение нагрузки;
  • снижение количества render-cycle;
  • повышение FPS интерфейса.

Рефакторинг тестируемости

Неправильный подход:

import { Client } from '@stomp/stompjs';

const client = new Client(...);

Код жёстко связан с библиотекой.


Dependency Injection

После рефакторинга:

class StompService {

    constructor(client) {
        this.client = client;
    }
}

Теперь возможно тестирование через mock-объекты:

const fakeClient = {
    subscribe: jest.fn(),
    publish: jest.fn(),
};

const service = new StompService(fakeClient);

Изоляция зависимости делает архитектуру устойчивой к изменениям.