> Как реализовать расширяемость процессора для добавления новых типов событий без изменения кода (Go)

Уровень: senior · Роль: backend · Язык: Go · Категория: Технические вопросы

Компании: Wildberries

Стек: Go

> Пример ответа

Короткий ответ

Расширяемость процессора событий в Go достигается через комбинацию интерфейсов, реестра обработчиков и паттерна "фабрика". Основная идея - отделить механизм диспетчеризации от конкретной логики обработки. Новый тип события добавляется регистрацией нового обработчика в реестре, без модификации существующего кода процессора. Используйте пустые интерфейсы для данных события и type switch или map[string]Handler для маршрутизации.

Подробное объяснение

Ключевая проблема - соблюдение принципа открытости/закрытости (Open/Closed Principle). Процессор не должен знать о конкретных типах событий, только о контракте обработчика.

Архитектурно решение состоит из трёх частей:

  1. Контракт обработчика - интерфейс с методом Handle(ctx, event) error. Каждый тип события реализует свой обработчик.
  2. Реестр - map[string]Handler, где ключ - тип события (строка или типобезопасная константа). Регистрация происходит в init() или явной функцией Register.
  3. Диспетчер - получает событие, определяет его тип и вызывает соответствующий обработчик из реестра.

Для определения типа события используйте один из подходов:

  • type switch - если события представлены конкретными типами, реализующими общий интерфейс Event.
  • поле Type - если события - структуры с полем-дискриминатором (например, JSON-сообщения).

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

Реестр можно сделать глобальным (пакет registry) или внедрять через DI. Глобальный реестр проще, но усложняет тестирование. DI-подход чище, но требует передачи реестра в конструктор процессора.

Важный нюанс - обработка неизвестных типов. Процессор должен иметь fallback-обработчик или возвращать ошибку ErrUnknownEventType. Это часть контракта.

Для асинхронной обработки добавьте worker pool, но это уже отдельный уровень абстракции, не влияющий на расширяемость.

На практике

Начните с определения интерфейса Event и Handler. Реестр сделайте отдельным пакетом, чтобы избежать циклических импортов. Используйте sync.RWMutex для потокобезопасности, если регистрация может происходить в рантайме.

Для событий из внешних источников (Kafka, RabbitMQ) используйте поле Type + JSON-десериализацию в map[string]interface{} или json.RawMessage. Это позволяет добавлять новые поля без изменения процессора.

Регистрацию обработчиков делайте в init() каждого пакета, где определён обработчик. Это автоматически подключает обработчик при импорте пакета (side-effect import). Но будьте осторожны - такой подход скрывает зависимости.

Для тестирования создавайте реестр с мок-обработчиками и проверяйте диспетчеризацию.

Пример кода

GO
package event
import (
"context"
"fmt"
"sync"
)
type Event interface {
Type() string
}
type Handler interface {
Handle(ctx context.Context, event Event) error
}
type Registry struct {
mu sync.RWMutex
handlers map[string]Handler
}
func NewRegistry() *Registry {
return &Registry{
handlers: make(map[string]Handler),
}
}
func (r *Registry) Register(eventType string, h Handler) {
r.mu.Lock()
defer r.mu.Unlock()
r.handlers[eventType] = h
}
func (r *Registry) Dispatch(ctx context.Context, event Event) error {
r.mu.RLock()
handler, ok := r.handlers[event.Type()]
r.mu.RUnlock()
if !ok {
return fmt.Errorf("unknown event type: %s", event.Type())
}
return handler.Handle(ctx, event)
}
// Конкретное событие
type UserCreated struct {
UserID string
}
func (e UserCreated) Type() string { return "user.created" }
// Обработчик
type UserCreatedHandler struct{}
func (h UserCreatedHandler) Handle(ctx context.Context, event Event) error {
e, ok := event.(UserCreated)
if !ok {
return fmt.Errorf("unexpected event type: %T", event)
}
// логика обработки
fmt.Printf("user created: %s\n", e.UserID)
return nil
}
// Регистрация в init()
func init() {
registry := GetGlobalRegistry() // глобальный синглтон
registry.Register(UserCreated{}.Type(), UserCreatedHandler{})
}

Как отвечать на собеседовании

Начните с формулировки проблемы: "процессор не должен знать о конкретных типах". Затем опишите три компонента: интерфейс, реестр, диспетчер. Подчеркните, что добавление нового типа - это новый файл с событием и обработчиком + регистрация, без изменения существующего кода.

Упомяните trade-off между type switch и map[string]Handler. Для senior-позиции важно показать понимание, когда какой подход применить. Например, type switch - для внутренних событий с гарантией типов, map - для внешних сообщений.

Расскажите про потокобезопасность реестра, обработку неизвестных типов, тестирование. Если спросят про альтернативы - упомяните паттерн Visitor или цепочку обязанностей, но объясните, почему реестр проще и практичнее.

Что проверяет интервьюер

  • Понимание принципов SOLID, особенно открытости/закрытости.
  • Умение проектировать расширяемые системы без переписывания ядра.
  • Знание идиоматичного Go: интерфейсы, пустые интерфейсы, sync.RWMutex.
  • Способность видеть trade-off между типобезопасностью и гибкостью.
  • Понимание жизненного цикла приложения: инициализация, регистрация, диспетчеризация.
  • Навыки тестирования: как проверить диспетчер без реальных обработчиков.

Типичные ошибки

  • Использование глобального реестра без возможности подмены в тестах.
  • Отсутствие обработки неизвестных типов - молчаливый пропуск события.
  • Регистрация обработчиков в main() вместо init() - усложняет подключение новых пакетов.
  • Хранение обработчиков в map без RWMutex - data race при конкурентной регистрации.
  • Попытка использовать type switch на пустом интерфейсе без проверки типа - паника.
  • Слишком сложная иерархия интерфейсов вместо простого контракта.
  • Игнорирование контекста в сигнатуре Handle - нельзя отменить операцию.

> ГОТОВЫ К СЛЕДУЮЩЕМУ СОБЕСЕДОВАНИЮ?

Запустите тренировочную сессию с ИИ и получите детальную обратную связь, чтобы увереннее проходить реальные интервью