Decorator: добавляем поведение без наследования

Decorator: добавляем поведение без наследования

Логирование, кэш, метрики - поведение, которое хочется добавлять снаружи, а не зашивать в исходный объект. Decorator делает это аккуратно и без взрыва классов.

Проблема

У тебя есть репозиторий пользователей - PgUserRepo. Нужно добавить логирование. Потом - метрики. Потом - кэширование. Как, не нарушая SRP и не превращая репозиторий в комбайн?

Вариант 1: всё запихать в PgUserRepo - получится монстр на 500 строк, который и в БД ходит, и логирует, и кэширует.

Вариант 2: наследование - LoggingPgUserRepo extends PgUserRepo, потом CachingLoggingPgUserRepo extends LoggingPgUserRepo... Взрыв классов.

Вариант 3: Decorator - обёртки, которые добавляют поведение по одному слою.

Решение: Decorator

Decorator - обёртка, которая реализует тот же интерфейс, что и оборачиваемый объект. Она добавляет поведение до/после вызова, делегируя основную работу «внутреннему» объекту. Каждый слой остаётся с одной ответственностью, а добавление нового слоя не трогает существующие - это OCP в действии.

Декораторы как концентрические кольца вокруг основной логики

Декоратор логирования для репозитория

type UserRepo interface {
    Get(id int64) (User, error)
}

// Основная реализация
type PgUserRepo struct{ db *sql.DB }
func (r *PgUserRepo) Get(id int64) (User, error) {
    // SELECT * FROM users WHERE id = $1
    return User{}, nil
}

// Декоратор логирования
type LoggingUserRepo struct {
    next   UserRepo
    logger *slog.Logger
}

func (r *LoggingUserRepo) Get(id int64) (User, error) {
    start := time.Now()
    u, err := r.next.Get(id)  // делегируем основную работу
    r.logger.Info("UserRepo.Get",
        "id", id,
        "duration_ms", time.Since(start).Milliseconds(),
        "error", err,
    )
    return u, err
}

Ключевой момент: LoggingUserRepo реализует UserRepo и содержит поле next UserRepo. Он не знает, что внутри - PgUserRepo, кэш или другой декоратор.

Стек декораторов

Декораторы складываются как матрёшка:

// Собираем стек: pg → cache → logging
var repo UserRepo
repo = &PgUserRepo{db: db}
repo = &CachingUserRepo{next: repo, cache: redis}
repo = &LoggingUserRepo{next: repo, logger: logger}

// Внешний код видит просто UserRepo
user, err := repo.Get(42)
// Путь: logging → caching → pg (или из кэша)
interface UserRepo
{
    public function get(int $id): array;
}

final class PgUserRepo implements UserRepo
{
    public function get(int $id): array
    {
        return ['id' => $id];
    }
}

final class LoggingUserRepo implements UserRepo
{
    public function __construct(
        private readonly UserRepo $next,
        private readonly LoggerInterface $logger,
    ) {}

    public function get(int $id): array
    {
        $t = microtime(true);
        try {
            return $this->next->get($id);
        } finally {
            $ms = (int) ((microtime(true) - $t) * 1000);
            $this->logger->info('UserRepo.get', ['id' => $id, 'ms' => $ms]);
        }
    }
}

Ключевой момент: LoggingUserRepo реализует тот же UserRepo и принимает другой UserRepo в конструктор. Он не знает, что внутри.

Стек декораторов

Декораторы складываются как матрёшка:

$repo = new PgUserRepo($pdo);
$repo = new CachingUserRepo($repo, $cache);
$repo = new LoggingUserRepo($repo, $logger);

// Внешний код видит просто UserRepo
$user = $repo->get(42);
// Путь: logging → caching → pg (или из кэша)

В Symfony такой стек собирается автоматически через #[AsDecorator(decorates: UserRepo::class)].

class PgUserRepo {
  async get(id) {
    // SELECT * FROM users WHERE id = $1
    return { id };
  }
}

// Декоратор логирования
class LoggingUserRepo {
  #next;
  #logger;
  constructor(next, logger) {
    this.#next = next;
    this.#logger = logger ?? console;
  }

  async get(id) {
    const start = Date.now();
    try {
      return await this.#next.get(id);
    } finally {
      this.#logger.info?.('UserRepo.get', { id, ms: Date.now() - start });
    }
  }
}

Ключевой момент: LoggingUserRepo имеет тот же метод get(id), что и PgUserRepo, и принимает другой репозиторий в конструктор. Он не знает, что внутри.

Стек декораторов

Декораторы складываются как матрёшка:

let repo = new PgUserRepo(db);
repo = new CachingUserRepo(repo, cache);
repo = new LoggingUserRepo(repo, logger);

// Внешний код видит просто репозиторий
const user = await repo.get(42);
// Путь: logging → caching → pg (или из кэша)

В JS такой стек часто собирают через простую функцию-обёртку (особенно для middleware) - withLogging(withCache(repo)). Это та же идея, только без явного класса. ES-decorators (@logging) - отдельная история на уровне синтаксиса класса, но семантически - то же самое.

Каждый слой добавляет одну обязанность. Порядок можно менять - например, логировать до или после кэша.

Middleware - это Decorator

HTTP middleware в Go - классический Decorator. То же в PHP: PSR-15 MiddlewareInterface оборачивает RequestHandlerInterface, цепочка собирается из обёрток.

func LoggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        next.ServeHTTP(w, r)  // делегируем
        log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start))
    })
}

func AuthMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if !isAuthed(r) {
            http.Error(w, "unauthorized", 401)
            return  // не делегируем - прерываем цепочку
        }
        next.ServeHTTP(w, r)
    })
}

// Стек middleware = стек декораторов
handler := AuthMiddleware(LoggingMiddleware(myHandler))
<?php
declare(strict_types=1);

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Log\LoggerInterface;

final class LoggingMiddleware implements MiddlewareInterface
{
    public function __construct(
        private readonly LoggerInterface $logger,
    ) {}

    public function process(
        ServerRequestInterface $request,
        RequestHandlerInterface $handler,
    ): ResponseInterface {
        $t = microtime(true);
        try {
            return $handler->handle($request); // делегируем
        } finally {
            $ms = (int) ((microtime(true) - $t) * 1000);
            $this->logger->info(sprintf(
                '%s %s %d ms',
                $request->getMethod(),
                (string) $request->getUri(),
                $ms,
            ));
        }
    }
}

final class AuthMiddleware implements MiddlewareInterface
{
    public function __construct(
        private readonly ResponseFactory $responses,
    ) {}

    public function process(
        ServerRequestInterface $request,
        RequestHandlerInterface $handler,
    ): ResponseInterface {
        if (!$this->isAuthed($request)) {
            return $this->responses->error(401, 'unauthorized');
            // не делегируем - прерываем цепочку
        }

        return $handler->handle($request);
    }

    private function isAuthed(ServerRequestInterface $request): bool
    {
        return $request->hasHeader('Authorization');
    }
}

// Стек middleware = стек декораторов
$pipeline = new Pipeline([new AuthMiddleware($responses), new LoggingMiddleware($logger)], $myHandler);
// Express-стиль: middleware = (req, res, next) => { ... next(); }
function logging(req, res, next) {
  const start = Date.now();
  res.on('finish', () => {
    console.log(`${req.method} ${req.url} ${Date.now() - start} ms`);
  });
  next(); // делегируем
}

function auth(req, res, next) {
  if (!req.headers.authorization) {
    res.status(401).send('unauthorized');
    return; // не делегируем - прерываем цепочку
  }
  next();
}

// Стек middleware = стек декораторов
app.use(auth);
app.use(logging);
app.get('/me', myHandler);

Каждый middleware принимает следующее звено и возвращает ответ - контракт сохраняется, поведение добавляется. Это и есть Decorator.

Если ты писал middleware в chi, gin или net/http - ты уже использовал Decorator. Каждый слой добавляет поведение (логи, авторизацию, CORS, метрики) без изменения самого обработчика.

io.Reader chain: Decorator в stdlib

Go stdlib построен на цепочках io.Reader:

file, _ := os.Open("data.txt")          // io.Reader
buffered := bufio.NewReader(file)        // декоратор: добавляет буферизацию
limited := io.LimitReader(buffered, 1024) // декоратор: ограничивает размер

Каждый io.Reader оборачивает предыдущий, добавляя одну функцию. Это Decorator в чистом виде.

Другие примеры из stdlib:

io.LimitReader(r, n) - ограничивает количество байт
io.TeeReader(r, w) - читает из r и копирует в w
cipher.StreamReader{S, R} - декоратор шифрования поверх Reader
gzip.NewReader(r) - декоратор распаковки

Decorator vs наследование

Decorator (композиция)Наследование
Добавляет поведение в runtimeФиксировано при компиляции
Слои комбинируются свободноЖёсткая иерархия
Каждый слой - отдельная структураОдин «жирный» потомок
Работает в Go ✅Не работает в Go ❌

Когда НЕ использовать

Один слой, не растёт - если логирование нужно в одном месте, проще добавить log.Printf рядом с вызовом.

Декоратор меняет контракт - если обёртка изменяет сигнатуру или семантику метода, это уже не Decorator (скорее Adapter).

Слишком глубокий стек - 5+ слоёв декораторов затрудняют отладку. Если отслеживать вызов через 7 обёрток - что-то пошло не так.

Decorator **сохраняет** интерфейс и **добавляет** поведение. Adapter **меняет** интерфейс. Logging middleware - Decorator. Обёртка над SMS SDK - Adapter.

Связь с другими паттернами

Decorator + Strategy - декоратор может использовать стратегию для логики
Decorator + Factory - фабрика собирает стек декораторов
Decorator + Proxy - Proxy контролирует доступ, Decorator добавляет поведение
                          (структурно похожи, разница в намерении)

Мини-задание

  • Добавь логирование к интерфейсу через декоратор, не трогая реализацию
  • Собери стек из 2 декораторов (логирование + метрики) для одного репозитория
  • Напиши HTTP middleware, которое добавляет заголовок X-Request-Id - это Decorator
  • Попробуй io.TeeReader - прочитай файл и одновременно скопируй в os.Stdout

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.