Проблема №1: когда components превращается в свалку
Типичная структура React-проекта, переросшего прототип, выглядит знакомо до боли:
text
src/
├── components/ // Свалка всего, что рисуется
├── hooks/ // Свалка всей логики
├── utils/ // Свалка вспомогательных функций
├── types/ // Свалка интерфейсов
└── ...
Это ведёт к закономерным проблемам:
- Низкая переиспользуемость. Нельзя перенести компонент, не перетащив за ним кучу зависимостей.
- Сложность тестирования. Логика размазана по проекту, нельзя протестировать только её часть изолированно.
- Снижение скорости разработки. Страх сломать что-то в другом месте заставляет писать код с оглядкой.
Проблема №2: синдром «слишком длинного сообщения»
Анализатор проекта, который я так гордо добавил во второй части, стал жертвой собственного успеха. Он генерировал промты размером в сотни килобайтов, а иногда и в несколько мегабайтов. Отправлять такой объём через вебсокет одним куском — верный способ убить соединение.
Вебсокеты проектировались для небольших сообщений, и многие прокси-серверы (включая тот же Nginx) имеют жёсткие лимиты на размер одного фрейма — обычно около 1–2 МБ. Наше сообщение просто «роняло» соединение, и пользователь получал ошибку вместо анализа кода.
Нужно было решение, которое позволит передавать промты любого размера надёжно и безболезненно, при этом органично вписываясь в новую архитектуру.
Решение: разделяй и властвуй (и упаковывай в FSD)
Я решил убить двух зайцев одним архитектурным выстрелом: навести порядок в проекте с помощью Feature-Sliced Design (FSD) и в рамках этого порядка реализовать механизм чанкинга (chunking) больших сообщений.
FSD — это методология, предлагающая делить код не по техническому признаку, а по бизнес-смыслу. Представьте себе слоёный пирог, где каждый слой отвечает за свой уровень абстракции:
- shared/ — самый нижний слой. Переиспользуемые модули, не знающие о бизнес-логике, например API-клиент, хелперы, константы.
- entities/ — бизнес-сущности. Всё, что связано с конкретными понятиями проекта: сообщение, модель, проект.
- features/ — пользовательские сценарии, такие как отправить сообщение, выбрать модель, очистить историю.
- widgets/ — композиционные блоки, которые собирают несколько сущностей и фич в самодостаточные компоненты. Это окно чата, список сообщений, анализатор проекта.
- pages/ собирают виджеты в готовые страницы приложения.
- app/ — самый верхний слой: инициализация приложения, провайдеры, глобальные стили.
Вместо плоской кучи папок получается иерархия, где каждый модуль лежит на своём месте.
Давайте разберём несколько примеров того, как живёт код в новой структуре.
Пример 1: модели и API
Раньше запрос за моделями лежал где-то в компоненте Chat.tsx. Теперь у него есть свой дом в entities/model. Здесь хранится тип модели (Model), функция для её загрузки с сервера (modelApi.ts) и в будущем могут появиться сторы, связанные с моделью.
typescript
// entities/model/api/modelApi.ts
import { API_BASE_URL } from '../../../shared/config/constants';
import type { Model } from '../types/modelTypes';
export const loadModels = async (): Promise<Model[]> => {
const response = await fetch(`${API_BASE_URL}/api/models`);
// ... обработка ответа
return data.map((item: any) => ({ name: item.name, ...item }));
};
Пример 2: логика отправки сообщения
Сложная логика отправки сообщения, включая разбивку на чанки, выделена в отдельную фичу sendMessage. Этот хук ничего не знает о том, как отображаются сообщения. Его задача — принять текст, список моделей и отправить это всё через WebSocket.
typescript
// features/sendMessage/lib/useSendMessage.ts
import { useCallback } from 'react';
import { useWebSocket } from '../../../app/providers';
import { CHUNK_SIZE } from '../../../shared/config/constants';
export const useSendMessage = ({ parentSessionId }: { parentSessionId: string }) => {
const { publish, isConnected } = useWebSocket();
const sendMessage = useCallback((messageText: string, models: Model[], temperature: number): boolean => {
if (!isConnected) return false;
if (messageText.length <= CHUNK_SIZE) {
// Отправка цельного сообщения
return publish('/app/chat.send', { parentSessionId, message: messageText, ... });
} else {
// Разбивка на чанки и отправка
// ...
}
}, [parentSessionId, publish, isConnected]);
return { sendMessage };
};
Пример 3: виджет окна чата
Виджет ChatWindow выступает в роли дирижёра. Он собирает вместе UI-компоненты (ChatTabs, MessageList, ChatInput) и использует фичи (useModelSelection, useSendMessage, useClearHistory) и сущности (Message, loadModels), чтобы заставить всё это работать как единое целое.
typescript
// widgets/ChatWindow/ui/ChatWindow.tsx
import { ChatTabs } from './ChatTabs';
import { ChatInput } from './ChatInput';
import { MessageList } from '../../MessageList';
import { useModelSelection } from '../../../features/selectModels';
import { useSendMessage } from '../../../features/sendMessage';
// ... другие импорты
export const ChatWindow: React.FC = () => {
const { selectedModels, handleModelChange } = useModelSelection();
const { sendMessage } = useSendMessage({ parentSessionId });
const { clearHistory } = useClearHistory({ parentSessionId });
// ... состояние виджета (вкладки, активная вкладка, сообщения)
return (
<div className="chat-window">
{/* ... хедер с кнопками и ModelSelector */}
<ChatTabs tabs={tabs} activeIndex={activeTabIndex} onTabChange={setActiveTabIndex} />
<MessageList messages={activeTab?.messages || []} />
<ChatInput onSendMessage={handleSendMessage} /* ... */ />
</div>
);
};
К чему приведёт рефакторинг
Изоляция и переиспользуемость. Хотите добавить функцию «Анализ проекта» на другую страницу? Просто импортируйте виджет ProjectAnalyzer и сущность project. Хотите использовать хук для отправки сообщений с разбивкой на чанки в другом приложении? Копируете папку features/sendMessage и shared/config/constants.
Простота навигации. Нужно поправить парсинг .gitignore? Вы точно знаете, что идти нужно в entities/project/lib/gitignoreParser.ts. Никакого поиска по всей папке utils.
Масштабируемость команды. Два разработчика могут одновременно работать над разными фичами (sendMessage и clearHistory), не создавая конфликтов в одном гигантском файле Chat.tsx.
Упрощение тестирования. Можно писать юнит-тесты для сложной логики в entities/project/lib/, не поднимая при этом весь контекст React-приложения. Но тестами я займусь как-нибудь потом.
Лёгкий рефакторинг. Если мы решим заменить @stomp/stompjs на что-то другое, мы меняем только app/providers/WebSocketProvider.tsx. Остальное приложение (фичи, виджеты) этого даже не заметит.
Чанкинг в действии
Всю логику разбиения и отправки больших сообщений мы инкапсулировали в фиче sendMessage. Это идеальный пример того, как FSD помогает изолировать сложную логику.
1. Конфигурация (shared/config).
В shared/config/constants.ts я вынес размер чанка, чтобы легко его менять под разные условия.
typescript
// shared/config/constants.ts
export const CHUNK_SIZE = parseInt(import.meta.env.VITE_CHUNK_SIZE || '5000', 10);
2. Фича отправки (features/sendMessage).
Здесь находится хук useSendMessage. Он принимает сообщение и, если оно больше CHUNK_SIZE, не передаёт его целиком, а режет на куски и отправляет их с небольшой задержкой.
typescript
// features/sendMessage/lib/useSendMessage.ts
import { useCallback } from 'react';
import { useWebSocket } from '../../../app/providers';
import { CHUNK_SIZE } from '../../../shared/config/constants';
export const useSendMessage = ({ parentSessionId }: { parentSessionId: string }) => {
const { publish, isConnected } = useWebSocket();
const sendMessage = useCallback((
messageText: string,
models: string[],
temperature: number
): boolean => {
if (!isConnected) return false;
// Если сообщение короткое, отправляем как есть (обратная совместимость)
if (messageText.length <= CHUNK_SIZE) {
const payload = { parentSessionId, message: messageText, models, temperature };
return publish('/app/chat.send', payload);
}
// Длинное сообщение — режем на чанки!
const messageId = `msg_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const totalChunks = Math.ceil(messageText.length / CHUNK_SIZE);
console.log(`📦 Разбиваем сообщение на ${totalChunks} чанков`);
for (let i = 0; i < totalChunks; i++) {
const start = i * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, messageText.length);
const chunk = messageText.substring(start, end);
const chunkPayload = {
parentSessionId,
message: chunk,
models,
temperature,
messageId, // Идентификатор для сборки на сервере
chunkIndex: i, // Номер чанка
totalChunks, // Всего чанков
isLastChunk: i === totalChunks - 1
};
// Отправляем чанки с небольшой задержкой, чтобы не перегружать сокет
setTimeout(() => {
publish('/app/chat.send', chunkPayload);
}, i * 50);
}
return true;
}, [parentSessionId, publish, isConnected]);
return { sendMessage };
};
3. Серверная сборка.
Сервер должен уметь собирать рассыпанные куски обратно в целое сообщение, прежде чем отправить его в Ollama. Для этого в ChatService есть специальный механизм.
java
// backend/src/main/java/ru/akademit/ai/ollama/service/ChatService.java
private final Map<String, String[]> messageChunks = new ConcurrentHashMap<>();
private final Map<String, ChatMessagePayload> messageMetadata = new ConcurrentHashMap<>();
public void processAndStreamChat(ChatMessagePayload payload) {
// Если это чанк, а не целое сообщение
if (payload.isChunked()) {
handleChunk(payload);
return;
}
// ... обработка целого сообщения
}
private void handleChunk(ChatMessagePayload chunk) {
String messageId = chunk.getMessageId();
// Сохраняем метаданные из первого чанка
if (chunk.getChunkIndex() == 0) {
messageMetadata.put(messageId, chunk);
}
// Получаем или создаём массив для чанков
String[] chunks = messageChunks.computeIfAbsent(messageId,
k -> new String[chunk.getTotalChunks()]);
// Кладём чанк на своё место
chunks[chunk.getChunkIndex()] = chunk.getMessage();
// Если все чанки на месте, собираем и обрабатываем
if (isComplete(messageId, chunks)) {
assembleAndProcessCompleteMessage(messageId);
}
}
private void assembleAndProcessCompleteMessage(String messageId) {
String[] chunks = messageChunks.remove(messageId);
String fullMessage = String.join("", chunks); // Склеиваем обратно!
ChatMessagePayload metadata = messageMetadata.remove(messageId);
ChatMessagePayload completePayload = new ChatMessagePayload(
metadata.getParentSessionId(),
fullMessage,
metadata.getModels(),
metadata.getTemperature()
);
// Отправляем собранное сообщение дальше, в Ollama
processCompleteMessage(completePayload);
}
4. Модель данных на бэкенде.
Чтобы клиент и сервер понимали друг друга, мы расширили DTO ChatMessagePayload новыми полями.
java
// backend/src/main/java/ru/akademit/ai/ollama/dto/ChatMessagePayload.java
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ChatMessagePayload {
private String parentSessionId;
private String message;
private List<String> models;
private Double temperature;
// Новые поля для чанков
private String messageId; // ID всего сообщения
private Integer chunkIndex; // Номер текущего чанка
private Integer totalChunks; // Всего чанков
private Boolean isLastChunk; // Флаг последнего чанка
public boolean isChunked() {
return messageId != null && chunkIndex != null && totalChunks != null;
}
}
Почему чанкинг — это круто
Надёжность передачи данных. Больше никаких обрывов соединения из-за больших промтов. Механизм чанкинга позволяет передавать сообщения любого размера, оставаясь в рамках лимитов вебсокет-фреймов.
Изоляция сложной логики. Вся «магия» чанкинга спрятана внутри фичи sendMessage. Виджет ChatWindow просто вызывает sendMessage (огромный_текст) и не знает, сколько чанков будет отправлено. Сервис ChatService не засорён логикой сборки, ведь она вынесена в отдельные приватные методы.
Тестируемость. Можно написать юнит-тест для handleChunk и assembleAndProcessCompleteMessage, просто подавая на вход чанки и проверяя, что на выходе получилось правильное сообщение. Никаких моков вебсокетов или баз данных.
Гибкость и настраиваемость. Размер чанка теперь задаётся в константе. Можно менять его под разные окружения: для локальной разработки один размер, для продакшна — другой.
Прозрачность для пользователя. Он вообще не знает, что его сообщение передаётся по частям: в интерфейсе просто крутится спиннер отправки.
Обратная совместимость. Короткие сообщения по-прежнему отправляются целиком, чтобы не создавать лишней нагрузки.
Жизнь после рефакторинга
Теперь, когда пользователь загружает проект через анализатор и генерирует промт на 10 000 строк кода, происходит следующее:
- Фича
sendMessageполучает гигантскую строку. - Хук
useSendMessageнарезает её на чанки по 5000 символов. - Чанки с одинаковым
messageIdуходят на сервер с короткими интервалами. - На сервере
ChatServiceскладывает чанки в массив и ждёт последний. - Когда все чанки собраны, сообщение склеивается и отправляется в Ollama Cloud.
- Пользователь получает полноценный анализ своего проекта, даже не подозревая, сколько технической «магии» произошло за кулисами.
А сам код при этом остаётся чистым, модульным и лёгким для понимания — спасибо Feature-Sliced Design.
Заключение
Рефакторинг по Feature-Sliced Design стал для проекта не просто перекладыванием файлов по папкам. Это был переход на новый уровень архитектуры. Я привёл код в порядок и создал среду для решения сложных технических задач, таких как передача огромных промтов.
А выделив механизм чанкинга в отдельную фичу, я сделал его прозрачным для остальных частей приложения. Теперь его легко тестировать и дорабатывать. Это и есть главная ценность хорошей архитектуры — она не ограничивает, а помогает решать любые задачи элегантно и надёжно.
Проект продолжает жить и развиваться. Код всегда доступен на GitVerse в ветке stream-to-server. Заходите, вдохновляйтесь и создавайте свои шедевры!