10
0
0
Скопировать ссылку
Telegram
WhatsApp
Vkontakte
Одноклассники
Назад

Эволюция клиента для Ollama: рефакторинг по Feature-Sliced Design

Время чтения 53 минуты
Нет времени читать?
Скопировать ссылку
Telegram
WhatsApp
Vkontakte
Одноклассники
10
0
0
Нет времени читать?
Скопировать ссылку
Telegram
WhatsApp
Vkontakte
Одноклассники

«Хаос — это порядок, который требует дешифровки».

Хосе Сарамаго

Привет! Меня зовут Николай Пискунов, я руководитель направления Big Data и эксперт курса Cloud DevSecOps по безопасной разработке от Академии вАЙТИ и создатель клиента для облачного сервиса Ollama. 

В предыдущих частях мы прошли большой путь: от первого прототипа с багами в стриминге до полноценного мультимодельного чата с инструментом выбора проектов. Переезжали с PostgreSQL на MongoDB, побеждали тормоза на фронте, внедряли вебсокеты и учили Flyway мигрировать NoSQL-базы.

Код работал, функциональность росла, но структура проекта начала напоминать коммунальную квартиру. Компоненты, хуки и утилиты переплетались, теряя чёткие границы. Но была и другая, более коварная проблема. Когда я запустил функцию «Анализ проекта» из второй части, фронтенд попытался отправить на сервер промт огромного размера (код проекта — дело серьёзное). И вебсокет-соединение просто… упало.

Нужно было решать обе задачи: и архитектуру привести в порядок, и научить систему переваривать большие сообщения.

Эволюция клиента для Ollama: рефакторинг по Feature-Sliced Design

Проблема №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) и использует фичи (useModelSelectionuseSendMessageuseClearHistory) и сущности (MessageloadModels), чтобы заставить всё это работать как единое целое.


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 строк кода, происходит следующее:

  1. Фича sendMessage получает гигантскую строку.
  2. Хук useSendMessage нарезает её на чанки по 5000 символов.
  3. Чанки с одинаковым messageId уходят на сервер с короткими интервалами.
  4. На сервере ChatService складывает чанки в массив и ждёт последний.
  5. Когда все чанки собраны, сообщение склеивается и отправляется в Ollama Cloud.
  6. Пользователь получает полноценный анализ своего проекта, даже не подозревая, сколько технической «магии» произошло за кулисами.

А сам код при этом остаётся чистым, модульным и лёгким для понимания — спасибо Feature-Sliced Design.

Заключение

Рефакторинг по Feature-Sliced Design стал для проекта не просто перекладыванием файлов по папкам. Это был переход на новый уровень архитектуры. Я привёл код в порядок и создал среду для решения сложных технических задач, таких как передача огромных промтов.

А выделив механизм чанкинга в отдельную фичу, я сделал его прозрачным для остальных частей приложения. Теперь его легко тестировать и дорабатывать. Это и есть главная ценность хорошей архитектуры — она не ограничивает, а помогает решать любые задачи элегантно и надёжно.

Проект продолжает жить и развиваться. Код всегда доступен на GitVerse в ветке stream-to-server. Заходите, вдохновляйтесь и создавайте свои шедевры!

Комментарии0
Тоже интересно
Комментировать
Поделиться
Скопировать ссылку
Telegram
WhatsApp
Vkontakte
Одноклассники