Блог инженера

History is written by its contributors

Proxy Pattern в Go: Контроль доступа и управление ресурсами

2025-08-12 время чтения 9 мин Разработка Ilya Brin

Proxy pattern предоставляет суррогат или заместитель другого объекта для контроля доступа к нему. Он действует как посредник, добавляя функциональность без изменения оригинального объекта.

Вот когда и как использовать его в реальных Go-приложениях.

Проблема

У вас есть объект, который дорого создавать, требует контроля доступа или нуждается в дополнительной функциональности. Прямой доступ вызывает проблемы:

Сценарий 1: Подключение к базе данных

Ваше приложение подключается к PostgreSQL. Каждое соединение потребляет память и сетевые ресурсы. Создание соединений для каждого запроса расточительно. Нужен пул соединений, но клиенты не должны знать об этом.

Сценарий 2: Внешний API

Вы вызываете сторонний платёжный API. Каждый вызов стоит денег. Нужно кеширование, ограничение частоты запросов и логирование. Но вы не хотите модифицировать код API-клиента каждый раз при изменении требований.

Сценарий 3: Чувствительные ресурсы

Ваш сервис обращается к пользовательским данным. Не все пользователи имеют право доступа ко всем данным. Нужны проверки авторизации перед каждым доступом. Но бизнес-логика не должна быть загромождена кодом безопасности.

Общая нить: Нужно контролировать доступ к объекту без изменения того, как клиенты его используют.

Что такое Proxy Pattern

Proxy pattern создаёт объект-посредник, который:

  • Реализует тот же интерфейс, что и реальный объект
  • Контролирует доступ к реальному объекту
  • Может добавлять функциональность до/после делегирования реальному объекту
  • Выглядит идентично для клиентов

Ключевое понимание: Клиенты не знают, что общаются с прокси. Они думают, что используют реальный объект.

Типы прокси

Virtual Proxy (Виртуальный прокси)

Цель: Отложить дорогостоящее создание объекта до момента реальной необходимости.

Реальный случай: Загрузка изображений в системе управления контентом.

Вы строите CMS, где статьи содержат изображения. Загрузка всех изображений сразу приведёт к:

  • Чрезмерному потреблению памяти
  • Замедлению рендеринга страницы
  • Трате трафика на изображения, которые пользователь никогда не увидит

Решение с виртуальным прокси:

  • Создать лёгкий заполнитель для каждого изображения
  • Загружать реальное изображение только когда пользователь прокручивает до него
  • Кешировать загруженные изображения для повторного использования
  • Освобождать память для изображений, мимо которых прокрутили

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

  • Быстрая начальная загрузка страницы
  • Меньшее потребление памяти
  • Лучший пользовательский опыт
  • Снижение нагрузки на сервер

Protection Proxy (Защитный прокси)

Цель: Контролировать доступ на основе разрешений.

Реальный случай: Система управления документами.

В вашей компании есть конфиденциальные документы. Разные сотрудники имеют разные уровни доступа:

  • Публичные документы: Все могут читать
  • Внутренние документы: Только сотрудники
  • Конфиденциальные: Только менеджеры
  • Секретные: Только топ-менеджмент

Решение с защитным прокси:

  • Проверять права пользователя перед разрешением доступа
  • Логировать все попытки доступа для аудита
  • Отказывать в доступе с понятными сообщениями об ошибках
  • Поддерживать ролевой контроль доступа (RBAC)

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

  • Централизованная логика безопасности
  • Согласованный контроль доступа
  • Аудиторский след для соответствия требованиям
  • Легко изменять права доступа

Remote Proxy (Удалённый прокси)

Цель: Представлять объект в другом адресном пространстве.

Реальный случай: Коммуникация микросервисов.

Ваша e-commerce платформа имеет отдельные сервисы:

  • Сервис продуктов (управляет инвентарём)
  • Сервис заказов (обрабатывает заказы)
  • Сервис платежей (обрабатывает транзакции)

Сервису заказов нужна информация о продуктах, но он не должен напрямую обращаться к базе данных продуктов. Решение с удалённым прокси:

  • Сервис заказов использует прокси, который выглядит как локальный репозиторий продуктов
  • Прокси делает HTTP/gRPC вызовы к сервису продуктов
  • Обрабатывает сетевые ошибки и повторные попытки
  • Кеширует ответы для уменьшения сетевых вызовов

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

  • Сервисы остаются разделёнными
  • Сетевая сложность скрыта от бизнес-логики
  • Легко добавить кеширование и логику повторов
  • Можно переключаться между локальной и удалённой реализациями

Caching Proxy (Кеширующий прокси)

Цель: Кешировать дорогостоящие операции.

Реальный случай: Аналитический дашборд.

Ваш дашборд показывает бизнес-метрики:

  • Выручка по регионам (сложный SQL-запрос)
  • Рост пользователей (агрегирует миллионы записей)
  • Производительность продуктов (объединяет множество таблиц)

Запросы занимают 5-10 секунд каждый. Пользователи часто обновляют страницу. База данных перегружена.

Решение с кеширующим прокси:

  • Прокси находится между дашбордом и базой данных
  • Первый запрос: Выполнить запрос, закешировать результат
  • Последующие запросы: Вернуть закешированный результат
  • Инвалидировать кеш через 5 минут или при обновлении данных

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

  • Дашборд отвечает мгновенно
  • Нагрузка на базу данных снижена на 90%
  • Лучший пользовательский опыт
  • Меньшие затраты на инфраструктуру

Реальная реализация: Database Proxy

Контекст

Вы строите SaaS-приложение. Множество арендаторов делят одну базу данных. Данные каждого арендатора должны быть изолированы. Вам нужно:

  • Автоматическая фильтрация по арендатору во всех запросах
  • Логирование запросов для отладки
  • Пул соединений для производительности
  • Автоматический повтор при временных сбоях

Без прокси

Каждый вызов базы данных в вашем коде выглядит так:

// Разбросано по всему коду
func GetUser(db *sql.DB, tenantID, userID string) (*User, error) {
    // Проверка арендатора - повторяется везде
    if tenantID == "" {
        return nil, errors.New("требуется арендатор")
    }
    
    // Логирование - повторяется везде
    log.Printf("Запрос: GetUser, Арендатор: %s, Пользователь: %s", tenantID, userID)
    
    // Реальный запрос
    row := db.QueryRow("SELECT * FROM users WHERE tenant_id = $1 AND id = $2", tenantID, userID)
    
    // Обработка ошибок - повторяется везде
    var user User
    err := row.Scan(&user.ID, &user.Name, &user.Email)
    if err != nil {
        log.Printf("Ошибка: %v", err)
        return nil, err
    }
    
    return &user, nil
}

Проблемы:

  • Проверка арендатора дублируется в каждой функции
  • Легко забыть фильтрацию по арендатору (риск безопасности!)
  • Код логирования загромождает бизнес-логику
  • Нельзя добавить функции без модификации всех функций

С прокси

Прокси обрабатывает сквозные задачи:

type DatabaseProxy struct {
    db       *sql.DB
    tenantID string
    logger   *log.Logger
}

func (p *DatabaseProxy) QueryRow(query string, args ...interface{}) *sql.Row {
    // Автоматическая инъекция арендатора
    modifiedQuery := p.injectTenantFilter(query)
    
    // Логирование
    p.logger.Printf("Запрос: %s, Арендатор: %s", query, p.tenantID)
    
    // Делегирование реальной базе данных
    return p.db.QueryRow(modifiedQuery, args...)
}

Теперь ваша бизнес-логика чиста:

func GetUser(db *DatabaseProxy, userID string) (*User, error) {
    // Проверка арендатора не нужна - прокси обрабатывает
    // Логирование не нужно - прокси обрабатывает
    row := db.QueryRow("SELECT * FROM users WHERE id = $1", userID)
    
    var user User
    err := row.Scan(&user.ID, &user.Name, &user.Email)
    return &user, err
}

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

  • Бизнес-логика фокусируется на бизнесе
  • Безопасность обеспечивается автоматически
  • Легко добавлять функции (кеширование, метрики, трейсинг)
  • Единое место для модификации поведения базы данных

Реальная реализация: API Client Proxy

Контекст интеграции

Ваше приложение интегрируется с платёжным API Stripe. Требования:

  • Ограничение частоты запросов (100 запросов в минуту)
  • Автоматический повтор при сетевых ошибках
  • Кеширование для GET-запросов
  • Логирование всех API-вызовов для отладки
  • Circuit breaker для предотвращения каскадных сбоев

Без паттерна прокси

Каждый API-вызов нуждается во всей этой логике:

func ChargeCustomer(client *stripe.Client, amount int) error {
    // Проверка ограничения частоты
    if !rateLimiter.Allow() {
        return errors.New("превышен лимит запросов")
    }
    
    // Логирование
    log.Printf("Списание с клиента: %d", amount)
    
    // Логика повторов
    var err error
    for i := 0; i < 3; i++ {
        err = client.Charge(amount)
        if err == nil {
            break
        }
        time.Sleep(time.Second * time.Duration(i+1))
    }
    
    // Проверка circuit breaker
    if err != nil {
        circuitBreaker.RecordFailure()
    }
    
    return err
}

Эта логика дублируется для каждого метода API. Кошмар поддержки.

С паттерном прокси

Прокси инкапсулирует все сквозные задачи:

type StripeProxy struct {
    client         *stripe.Client
    rateLimiter    *RateLimiter
    cache          *Cache
    circuitBreaker *CircuitBreaker
    logger         *log.Logger
}

func (p *StripeProxy) Charge(amount int) error {
    // Проверка circuit breaker
    if p.circuitBreaker.IsOpen() {
        return errors.New("circuit breaker открыт")
    }
    
    // Проверка ограничения частоты
    if !p.rateLimiter.Allow() {
        return errors.New("ограничение частоты")
    }
    
    // Логирование запроса
    p.logger.Printf("Stripe списание: %d", amount)
    
    // Повтор с экспоненциальной задержкой
    err := p.retryWithBackoff(func() error {
        return p.client.Charge(amount)
    })
    
    // Обновление circuit breaker
    if err != nil {
        p.circuitBreaker.RecordFailure()
    } else {
        p.circuitBreaker.RecordSuccess()
    }
    
    return err
}

Бизнес-код остаётся простым:

func ProcessPayment(proxy *StripeProxy, amount int) error {
    // Вся сложность обработана прокси
    return proxy.Charge(amount)
}

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

  • Функции надёжности в одном месте
  • Легко тестировать (мокировать прокси)
  • Можно менять реализации (тест vs продакшн)
  • Бизнес-логика остаётся чистой

Реальная реализация: Lazy Loading Proxy

Контекст загрузки данных

Ваше приложение загружает профили пользователей. Каждый профиль включает:

  • Базовую информацию (имя, email) - маленькая, быстрая
  • Аватар - большой, медленно загружается
  • История активности - огромная, дорогой запрос
  • Настройки - средний размер

Большинство запросов нуждаются только в базовой информации. Загрузка всего тратит ресурсы.

Решение с ленивой загрузкой

Прокси с ленивой загрузкой загружает данные по требованию:

type UserProxy struct {
    userID   string
    repo     *UserRepository
    
    // Кешированные данные
    basicInfo     *BasicInfo
    avatar        *Image
    history       *ActivityHistory
    preferences   *Preferences
    
    // Флаги загрузки
    basicLoaded   bool
    avatarLoaded  bool
    historyLoaded bool
    prefsLoaded   bool
}

func (p *UserProxy) GetBasicInfo() *BasicInfo {
    if !p.basicLoaded {
        p.basicInfo = p.repo.LoadBasicInfo(p.userID)
        p.basicLoaded = true
    }
    return p.basicInfo
}

func (p *UserProxy) GetAvatar() *Image {
    if !p.avatarLoaded {
        p.avatar = p.repo.LoadAvatar(p.userID)
        p.avatarLoaded = true
    }
    return p.avatar
}

Паттерны использования:

Паттерн 1: Список (только базовая информация)

users := GetUsers() // Возвращает прокси
for _, user := range users {
    // Загружена только базовая информация
    fmt.Printf("%s (%s)\n", user.GetBasicInfo().Name, user.GetBasicInfo().Email)
}
// Аватар и история никогда не загружались - сэкономлено время и память

Паттерн 2: Детальный вид (всё)

user := GetUser(userID) // Возвращает прокси
// Загрузка по требованию по мере необходимости
info := user.GetBasicInfo()
avatar := user.GetAvatar()
history := user.GetHistory()

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

  • Быстрее списки (на 90%)
  • Меньше использование памяти (снижение на 70%)
  • Снижена нагрузка на базу данных
  • Тот же интерфейс для клиентов

Когда использовать Proxy Pattern

Используйте когда:

Нужен контроль доступа:

  • Аутентификация/авторизация
  • Аудит-логирование
  • Ограничение частоты запросов

Требуется управление ресурсами:

  • Ленивая загрузка
  • Пул соединений
  • Кеширование

Дополнительная функциональность без модификации:

  • Логирование
  • Метрики
  • Трейсинг

Удалённый доступ:

  • Коммуникация микросервисов
  • API-клиенты
  • Распределённые системы

Не используйте когда:

Достаточен простой прямой доступ:

  • Не нужен контроль доступа
  • Нет проблем с производительностью
  • Не требуется дополнительная функциональность

Накладные расходы не оправданы:

  • Очень простые объекты
  • Критичный путь производительности
  • Сложность перевешивает преимущества

Proxy vs Decorator

Оба добавляют функциональность, но разные цели:

Proxy:

  • Контролирует доступ к объекту
  • Может не создавать реальный объект немедленно
  • Фокусируется на контроле доступа и управлении ресурсами
  • Клиент может не знать, что использует прокси

Decorator:

  • Добавляет функциональность объекту
  • Реальный объект всегда существует
  • Фокусируется на расширении поведения
  • Клиент явно оборачивает объект

Пример различия:

Proxy: “Проверить, есть ли у пользователя разрешение перед доступом к базе данных” Decorator: “Добавить шифрование к записи файлов”

Распространённые ошибки

Ошибка 1: Прокси становится god object

Не добавляйте несвязанную функциональность в прокси. Держите его сфокусированным.

Плохо: Прокси обрабатывает кеширование, логирование, метрики, валидацию, трансформацию и бизнес-логику.

Хорошо: Отдельные прокси для отдельных задач. Цепочка их при необходимости.

Ошибка 2: Забыть реализовать все методы интерфейса

Если прокси не реализует полный интерфейс, клиенты ломаются.

Решение: Используйте встраивание интерфейсов в Go для обеспечения полноты.

Ошибка 3: Накладные расходы на производительность

Каждый вызов прокси добавляет накладные расходы. Не используйте прокси в горячих путях без необходимости.

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

Тестирование с прокси

Прокси упрощают тестирование:

Тестирование поведения прокси:

func TestCachingProxy(t *testing.T) {
    mock := &MockDatabase{}
    proxy := NewCachingProxy(mock)
    
    // Первый вызов обращается к базе данных
    proxy.GetUser("user-1")
    assert.Equal(t, 1, mock.CallCount)
    
    // Второй вызов использует кеш
    proxy.GetUser("user-1")
    assert.Equal(t, 1, mock.CallCount) // Всё ещё 1!
}

Тестирование с mock прокси:

func TestBusinessLogic(t *testing.T) {
    mockProxy := &MockDatabaseProxy{
        Users: map[string]*User{
            "user-1": {ID: "user-1", Name: "Тест"},
        },
    }
    
    // Тестирование бизнес-логики без реальной базы данных
    result := ProcessUser(mockProxy, "user-1")
    assert.NotNil(t, result)
}

Заключение

Proxy pattern предоставляет контролируемый доступ к объектам.

Ключевые преимущества:

  • Разделение ответственности
  • Централизованная сквозная логика
  • Легко добавлять функциональность
  • Прозрачен для клиентов

Распространённые случаи использования:

  • Контроль доступа и безопасность
  • Управление ресурсами и оптимизация
  • Доступ к удалённым объектам
  • Ленивая загрузка и кеширование

Советы по реализации:

  • Держите прокси сфокусированными
  • Реализуйте полный интерфейс
  • Учитывайте влияние на производительность
  • Используйте для сквозных задач

Proxy pattern необходим для построения поддерживаемых, безопасных и производительных Go-приложений.


Как вы используете прокси в ваших Go-приложениях? Делитесь опытом в комментариях или пишите напрямую.

comments powered by Disqus