Заместитель (Proxy)
Подставляет вместо объекта другой с тем же интерфейсом, который решает, когда и пускать ли вызов дальше.
Проблема
Архив документов DocumentArchive умеет читать и удалять. Читать можно всем, удалять — только администраторам. Если проверку прав вписать в сам архив, ему придётся знать о пользователях, сессиях и ролях, хотя его дело — хранить. Если проверять в каждом месте вызова, одно место обязательно забудут, и удаление пройдёт без проверки.
Клиентский код при этом не хочет знать, с чем он работает: ему нужен объект с методами read и delete.
Решение
Ввести общий интерфейс Documents и поставить перед настоящим архивом заместителя GuardedDocuments. Заместитель реализует тот же интерфейс, держит ссылку на архив и сам решает, передать ли вызов: read уходит дальше сразу, delete — только если пользователь администратор, иначе AccessDenied. Клиент получает заместителя вместо архива и не замечает подмены.
Защита доступа — один из видов заместителя. Тот же каркас даёт виртуального (создаёт дорогой объект при первом обращении), кэширующего (отвечает из памяти, см. практику), удалённого (прячет сеть за локальным вызовом) и протоколирующего. Различается только то, что заместитель делает до и после передачи вызова.
Структура
Интерфейс — единственное, что знает клиент. Рядом с ним лежат User и ошибка AccessDenied: они часть контракта, потому что клиент должен уметь отличить «нельзя» от «нет такого документа». Настоящий объект про пользователей не знает вовсе: его можно тестировать и использовать без всякой защиты, например в миграциях.
Как это собирается
Архив в приложении один, а заместитель создаётся на каждый запрос. В примере обработчик сначала находит пользователя по сессии: нет пользователя - 401, до документов дело не доходит. Потом строит GuardedDocuments вокруг общего DocumentArchive с этим пользователем и отдаёт его функции handle, которая знает только интерфейс Documents. В фреймворке это место занимает middleware или контейнер с областью жизни "запрос".
handle переводит исход в статус ответа: AccessDenied от заместителя - 403, "нет документа" от самого архива - 404, ошибки архива проходят сквозь заместителя без изменений. Видно и то, что проверка не мешает чтению: ann не смогла удалить договор, но прочитала его, а после удаления админом получила 404.
Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.
Реализации
Заместитель хранит ссылку на интерфейс, а не на DocumentArchive: так перед архивом можно поставить цепочку заместителей (проверка прав, затем кэш, затем журнал), и каждый не знает, кто стоит за ним. Отказ — исключение или ошибка до обращения к архиву, а ошибки самого архива (документа нет) проходят насквозь без изменений.
| Язык | Особенность |
|---|---|
| PHP | interface + final readonly заместитель; отказ — собственное исключение AccessDenied |
| Go | Заместитель встраивает интерфейс: Read пробрасывается сам, переопределён только Delete; отказ — ErrAccessDenied, обёрнутый через %w |
| Python | Protocol вместо базового класса: заместитель совместим структурно; AccessDenied наследует PermissionError |
| TypeScript | implements Documents и параметры-свойства конструктора; User — просто интерфейс с readonly полями |
Запуск: make test T=patterns/structural/proxy
Плюсы и минусы
Тесты проверяют четыре свойства. Клиентская функция, знающая только интерфейс, получает от заместителя тот же результат, что от архива. Обычный пользователь получает отказ, и документ остаётся в архиве: вызов не дошёл до настоящего объекта. Администратор удаляет через заместителя, и документ пропадает из архива. Ошибка архива (документа нет) проходит через заместителя без подмены.
- ✅ Правило доступа живёт в одном месте, настоящий объект о нём не знает.
- ✅ Клиент не меняется: заместитель подставляется там, где собирают объекты.
- ✅ Заместители складываются в цепочку и тестируются по одному.
- ❌ Лишний уровень вызова и лишний класс на каждый интерфейс.
- ❌ Защита работает, только пока никто не держит ссылку на настоящий объект: вызов мимо заместителя её обходит.
- ❌ Каждый новый метод интерфейса надо не забыть пробросить или защитить.
Когда не применять
- Проверка нужна в одном месте вызова: обычный
ifтам понятнее отдельного класса. - Правила доступа сложные и зависят от данных (владелец документа, отдел, время): это политика доступа, которую вызывают из сервиса или заместителя, а не логика внутри заместителя.
- Нужно добавить объекту новое поведение, а не контролировать доступ к старому: это Декоратор. Устроены они одинаково, различаются намерением.
- Во фреймворке уже есть механизм: middleware, атрибуты авторизации, ленивые сервисы контейнера.
Отличия между языками
- PHP — заместитель пишется руками: каждый метод интерфейса проброшен явно. Ленивые заместители умеют генерировать контейнеры (Symfony, Doctrine), а с PHP 8.4 есть встроенные ленивые объекты (
ReflectionClass::newLazyProxy). - Go — встраивание интерфейса в структуру пробрасывает все методы автоматически, и переопределить достаточно защищаемые. Цена удобства: новый метод в
Documentsпройдёт мимо проверки молча, компилятор не напомнит. - Python — утиная типизация делает заместителем любой объект с нужными методами. Соблазн написать универсальный
__getattr__, который пробросит всё, есть, но тогда проверка типов не видит методов, а защищаемый метод легко пропустить. - TypeScript — встроенный
Proxyиз JavaScript перехватывает любой вызов, но теряет типизацию перехватчиков и усложняет отладку. Для заместителя с известным интерфейсом класс сimplementsпроще и строже.
Практика
Учебный заместитель только пропускает или не пропускает вызов. В проекте заместитель чаще хранит состояние: кэш перед медленным репозиторием, и с состоянием приходят вопросы, которых тут нет — когда данные устаревают, кто их сбрасывает и что будет, если писать мимо заместителя. Практика: кэш репозитория товаров показывает кэширующего заместителя с TTL на подменяемых часах, сброс при записи и честную границу приёма.
Связанные темы
- Практика: кэш репозитория товаров — кэширующий заместитель с TTL и инвалидацией
- Адаптер (Adapter) — тоже обёртка, но меняет интерфейс; заместитель сохраняет его
- Практика Одиночки: тарифы, загруженные один раз — ленивая загрузка дорогого ресурса, близкая к виртуальному заместителю