Паттерны проектирования Точки расширения: реестр, SPI, хуки и плагины
0%

Точки расширения: реестр, SPI, хуки и плагины

Точки расширения: реестр, SPI, хуки и плагины

В предыдущей главе правила переехали из кода в данные, чтобы меняться без релиза. Это частный случай большой задачи: дать поведению системы меняться без пересборки — и, в пределе, без вашего участия вообще.

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

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


Шкала расширяемости: не всё сразу

Правило выбора звучит так: берите минимальный уровень, который закрывает известные вам случаи. Прыжок на уровень 3 «на будущее» — это спекулятивная обобщённость в самой дорогой форме: вы фиксируете внутренности как публичный контракт до того, как узнали, какие внутренности вам нужны.

Обратный признак — когда пора наверх: если ветка if provider == "..." растёт от людей, которые не имеют доступа к вашему репозиторию, вы уже опоздали на один уровень.


Уровень 2: реестр реализаций

Самая полезная и самая недооценённая ступень. Реестр — это словарь «ключ → фабрика», плюс дисциплина: клиент знает интерфейс и ключ, но не знает конкретных классов.

from typing import Callable, Protocol

class Exporter(Protocol):
    """Контракт расширения. Он узкий намеренно: чем меньше методов, тем дешевле
    написать реализацию и тем меньше вам придётся поддерживать вечно."""
    extension: str
    def export(self, report: "Report", out: "BinaryIO") -> None: ...


_REGISTRY: dict[str, Callable[[], Exporter]] = {}


def register(name: str) -> Callable[[type], type]:
    def wrapper(cls: type) -> type:
        if name in _REGISTRY:
            # Тихая перезапись — источник багов «работает на моей машине»:
            # порядок импортов решает, чей экспортёр победит.
            raise ValueError(f"экспортёр '{name}' уже зарегистрирован: {_REGISTRY[name]}")
        _REGISTRY[name] = cls
        return cls
    return wrapper


def create(name: str) -> Exporter:
    try:
        return _REGISTRY[name]()
    except KeyError:
        # Сообщение об ошибке — часть контракта расширения.
        raise UnknownExporter(f"нет экспортёра '{name}'; доступны: {sorted(_REGISTRY)}") from None


@register("csv")
class CsvExporter:
    extension = "csv"
    def export(self, report, out) -> None: ...

Есть ловушка, из-за которой реестры на декораторах регулярно ломаются: декоратор выполняется только при импорте модуля. Если никто не импортировал exporters/csv.py, экспортёра просто нет, и падает это в проде, а не в тестах. Три способа решить, по возрастанию магии:

Способ Плюс Минус
Явный список импортов в __init__.py пакета Видно глазами, воспроизводимо, работает с любым упаковщиком Надо не забыть добавить строку
Обход пакета с pkgutil.iter_modules Ничего не забудешь Ломается в замороженных сборках (PyInstaller); порядок недетерминирован
Точки входа пакетов (entry_points) Плагин ставится как отдельный пакет — уже уровень 3 Зависит от установки, а не от кода

Явный список выигрывает чаще, чем кажется. «Магическая» автозагрузка экономит одну строку и отнимает предсказуемость — цена невыгодная.

В Go тот же паттерн реализован на init() и «пустых» импортах — так устроены драйверы database/sql и декодеры формата в пакете image:

// registry.go — принадлежит хосту
var (
    mu      sync.RWMutex
    codecs  = map[string]func() Codec{}
)

func Register(name string, factory func() Codec) {
    mu.Lock()
    defer mu.Unlock()
    if _, dup := codecs[name]; dup {
        // Паника на старте лучше молчаливой подмены кодека в рантайме.
        panic("codec: повторная регистрация " + name)
    }
    codecs[name] = factory
}

func New(name string) (Codec, error) {
    mu.RLock()
    defer mu.RUnlock()
    f, ok := codecs[name]
    if !ok {
        return nil, fmt.Errorf("codec: неизвестный формат %q (доступны: %v)", name, names())
    }
    return f(), nil
}

// main.go — выбор реализаций делает composition root, а не библиотека
import (
    _ "example.com/app/codec/avro"     // побочный эффект импорта: регистрация
    _ "example.com/app/codec/protobuf"
)

Обратите внимание: список подключённых реализаций живёт в main — это ровно composition root из главы про DI, только для множества вариантов вместо одного.


Уровень 3: контракт расширения (SPI)

SPI (Service Provider Interface) — интерфейс, который реализует поставщик расширения, а вызывает хост. Он зеркален обычному API: там вы вызываете чужой код, здесь чужой код вызывают вас.

Отсюда главное правило проектирования SPI: интерфейс объявляет тот, кто вызывает. То же правило, что в Go для интерфейсов вообще, — и по той же причине: контракт должен описывать потребность хоста, а не возможности первой попавшейся реализации.

Что входит в контракт, кроме методов

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

  1. Версия контракта. Числом, в явном виде, проверяемая при загрузке. Без неё вы не сможете ничего изменить: старый плагин просто рухнет в непонятном месте.
  2. Модель ошибок. Что делать плагину при сбое: бросить исключение? вернуть результат-ошибку? Что сделает хост: отключит плагин? повторит? продолжит без него?
  3. Модель времени жизни. Когда вызывается инициализация, когда завершение, может ли плагин держать состояние между вызовами, гарантирована ли однопоточность.
  4. Что передаётся внутрь. Контекст с логгером, конфигом и точками регистрации — явно, а не через глобальные объекты хоста. Глобальный доступ к внутренностям — это тот самый Service Locator, только теперь в чужих руках.
  5. Что считается публичным. Всё, до чего плагин может дотянуться, станет частью контракта де-факто — это закон Хайрама: «при достаточном числе пользователей любое наблюдаемое поведение системы становится чьей-то зависимостью».

Загрузчик с изоляцией ошибок

from dataclasses import dataclass
from importlib.metadata import entry_points
import logging

API_VERSION = 3
SUPPORTED = {2, 3}          # окно совместимости объявлено явно


@dataclass
class PluginContext:
    """Всё, что плагин имеет право трогать. Ни одного внутреннего объекта хоста."""
    name: str
    config: dict
    logger: logging.Logger
    _hooks: "HookBus"

    def register_hook(self, event: str, fn, priority: int = 100) -> None:
        # Владелец подставляется хостом, а не плагином: по нему потом
        # считаются метрики и срабатывает автоматическое отключение.
        self._hooks.add(event, fn, priority, owner=self.name)


@dataclass
class LoadedPlugin:
    name: str
    version: str
    healthy: bool = True
    failures: int = 0


def load_plugins(hooks: "HookBus", config: dict) -> list[LoadedPlugin]:
    loaded: list[LoadedPlugin] = []
    for ep in entry_points(group="reporting.plugins"):
        log = logging.getLogger(f"plugin.{ep.name}")
        try:
            module = ep.load()                       # чужой код исполняется здесь
        except Exception:
            # Плагин, который не грузится, не должен ронять приложение,
            # но обязан быть заметен: лог + метрика + статус в /health.
            log.exception("плагин не загрузился, пропускаем")
            continue

        declared = getattr(module, "API_VERSION", None)
        if declared not in SUPPORTED:
            log.error("несовместимая версия контракта: %s, поддерживаются %s", declared, SUPPORTED)
            continue

        ctx = PluginContext(name=ep.name, config=config.get(ep.name, {}), logger=log, _hooks=hooks)
        try:
            module.setup(ctx)
        except Exception:
            log.exception("setup() упал, плагин отключён")
            continue

        loaded.append(LoadedPlugin(name=ep.name, version=getattr(module, "__version__", "?")))
    return loaded

Три решения здесь стоят объяснения. Загрузка каждого плагина в своём try: один сломанный плагин не должен уносить остальные и приложение. Версия проверяется до вызова setup: иначе несовместимость проявится в середине работы. Контекст передаётся явно: плагин получает ровно то, что ему разрешено, и не ищет ничего в глобальном пространстве хоста.


Хуки: два принципиально разных вида

Смешивать их — распространённая ошибка проектирования. Уведомление («заказ оплачен») ничего не возвращает, порядок обработчиков неважен, падение одного можно записать в лог и жить дальше. Фильтр («до списания — измени сумму») влияет на результат, порядок определяет исход, а падение обязано быть решением: отменить операцию или проигнорировать вклад плагина.

Механика обоих разобрана в поведенческих паттернах — это Observer и Chain of Responsibility. Расширяемость добавляет к ним три требования:

  • Детерминированный порядок. Не «порядок регистрации» и не алфавит имён файлов: явный числовой приоритет, а при равенстве — стабильная сортировка по имени. Иначе поведение системы меняется от переустановки пакетов.
  • Бюджет времени. Синхронный хук в горячем пути — это чужой код на вашем критическом пути. Таймаут обязателен, иначе один медленный плагин превращается в отказ обслуживания.
  • Наблюдаемость по плагинам. Метрики и трассировка с меткой имени плагина. Вопрос «почему p99 вырос в два раза» должен решаться графиком, а не биссекцией по списку установленных расширений.
class HookBus:
    def __init__(self, budget_s: float = 0.2) -> None:
        self._hooks: dict[str, list[tuple[int, str, Callable]]] = defaultdict(list)
        self._budget = budget_s

    def add(self, event: str, fn: Callable, priority: int, owner: str) -> None:
        self._hooks[event].append((priority, owner, fn))
        # Сортировка при регистрации: приоритет, затем имя — полностью детерминированно.
        self._hooks[event].sort(key=lambda t: (t[0], t[1]))

    def notify(self, event: str, **payload) -> None:
        """Уведомление: ошибки изолируются, результат не используется."""
        for _, owner, fn in self._hooks[event]:
            with metrics.timer("hook", event=event, plugin=owner):
                try:
                    fn(**payload)
                except Exception:
                    logging.getLogger(f"plugin.{owner}").exception("хук %s упал", event)
                    breaker.record_failure(owner)     # три падения — плагин отключается

    def apply(self, event: str, value):
        """Фильтр: результат каждого шага идёт в следующий. Ошибка — решение, а не лог."""
        for _, owner, fn in self._hooks[event]:
            if breaker.is_open(owner):
                continue                              # деградированный плагин пропускаем
            value = fn(value)                         # исключение поднимается наверх осознанно
        return value

Приём с автоматическим отключением (breaker) — это предохранитель, применённый не к внешнему сервису, а к чужому коду внутри вашего процесса. Логика та же: N ошибок подряд — изолировать, периодически пробовать снова, показывать состояние в диагностике.


Состояния плагина: что показывать в диагностике

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


Граница доверия: чужой код в вашем процессе

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

Модель изоляции Что даёт Чем платите Где встречается
В процессе нулевая задержка, прямые объекты полное доверие, общий отказ pytest, webpack, расширения PostgreSQL
Отдельный процесс + IPC/gRPC падение и утечка изолированы, свои лимиты CPU/памяти сериализация, задержка, сложность отладки Terraform providers, VS Code extension host
WASM-песочница детерминированные лимиты, нет доступа к ОС по умолчанию ограниченный набор возможностей, вес рантайма Envoy, Fastly/Cloudflare, плагины Zellij
Webhook (чужой сервер) полная изоляция, чужой релизный цикл сеть, таймауты, ретраи, безопасность Kubernetes admission webhooks, GitHub Apps

Выбор диктует не мода, а ответ на вопрос: кто пишет расширения. Свои команды в одном репозитории — уровень «в процессе» нормален. Неизвестные третьи лица — только изоляция. Промежуточный вариант, который часто упускают: аудит и подпись — список разрешённых пакетов, проверка подписи при загрузке, фиксация версий (см. безопасность цепочки поставок).

Отдельно про «плагин, который сам ходит в интернет»: если хост загружает расширения по URL из конфига, вы построили механизм удалённого исполнения кода. Такой конфиг должен быть защищён как секрет, а источники — как реестр артефактов.


Эволюция контракта: что делать, когда нужно менять

Приёмы, которые делают эволюцию возможной:

  • Расширяйте, не изменяйте. Новый необязательный метод с умолчанием ломает меньше, чем новый обязательный параметр в существующем. Отсюда любовь к базовым классам с реализациями по умолчанию: они позволяют добавлять точки, не ломая старое.
  • Адаптер внутри хоста. Старый контракт заворачивается в новый один раз — в вашем коде, а не в сорока чужих репозиториях. Это Adapter в его классической роли.
  • Депрекация с датой и телеметрией. Предупреждение в логе при загрузке устаревшего плагина, счётчик «сколько инсталляций ещё на v1», объявленная дата удаления.
  • Набор тестов для авторов. Опубликованный «contract test kit» — тесты, которые автор плагина запускает у себя. Это единственный масштабируемый способ убедиться, что чужие реализации соблюдают контракт (см. интеграционное тестирование).

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

Ошибка Как выглядит Что делать
Расширяемость «на будущее» Механизм плагинов есть, плагин один — ваш Начинать с реестра внутри репозитория
Контракт скопирован с первой реализации В SPI торчат термины конкретного вендора Проектировать от потребности хоста
Плагин видит внутренности хоста Импортирует app.internal.* Явный контекст; всё остальное — приватно
Порядок хуков недетерминирован Поведение зависит от порядка установки Числовой приоритет + стабильная сортировка
Падение плагина роняет запрос Одно расширение — общий отказ Изоляция ошибок, предохранитель, kill switch
Нет таймаута на хук Медленный плагин = деградация сервиса Бюджет времени на каждый вызов
Нет версии контракта Любое изменение ломает всех Версия + окно совместимости + адаптер
Автозагрузка «магией» Плагин не подхватился, причина неизвестна Явный список или обнаружение с логом
Чужой код в процессе с секретами Расширение читает переменные окружения Изоляция процессом/песочницей, подпись, allowlist
Всё стало плагином Ядро пустое, поведение непредсказуемо Ядро отвечает за инварианты и не расширяется

Последняя строка — про антипаттерн «микроядро без ядра». Расширяемость должна кончаться там, где начинаются инварианты системы: деньги не могут исчезнуть, заказ не может быть оплачен дважды, удалённые данные не восстанавливаются плагином. Всё это ядро обязано гарантировать независимо от того, что установлено.


Как это выглядит в проде

  • pytest — эталон системы хуков на Python: pluggy, именованные точки, приоритеты (tryfirst/trylast), обёртки вокруг хуков. Стоит прочитать документацию pluggy целиком, даже если вы не на Python.
  • Terraform providers — плагины в отдельном процессе через gRPC: изоляция падений и независимые релизные циклы у сотен провайдеров.
  • VS Code — расширения в отдельном процессе (extension host), чтобы чужой код не блокировал UI; API редактора версионируется и депрецируется по правилам.
  • Kubernetes — сразу несколько уровней: admission webhooks (чужой HTTP-сервис), CRD + контроллеры (свои процессы), CNI/CSI (бинарники с контрактом), CEL для валидации.
  • PostgreSQL extensions — код в адресном пространстве сервера: максимальная скорость, максимальное доверие.
  • Envoy / прокси на WASM — фильтры в песочнице с лимитами.
  • database/sql в Go и java.util.ServiceLoader — реестры уровня стандартной библиотеки.

Мини-итог

  • Расширяемость — шкала из пяти уровней. Берите минимальный достаточный: опубликованный контракт нельзя отозвать.
  • Реестр реализаций — недооценённая ступень: решает большинство задач без чужого кода в процессе.
  • Контракт расширения — это не только интерфейс: версия, модель ошибок, время жизни, явный контекст и граница «что публично».
  • Различайте уведомления и фильтры: у них разные требования к порядку, ошибкам и таймаутам.
  • Изоляция ошибок, бюджет времени, метрики по плагинам и kill switch — обязательный минимум, а не улучшения второго этапа.
  • Плагин в процессе получает все ваши права. Если авторы расширений вам не подотчётны — выносите их за границу процесса.
  • Эволюция контракта возможна только заранее: версии, адаптеры внутри хоста, депрекация с датой, тестовый набор для авторов.
  • Расширяемость кончается там, где начинаются инварианты. Ядро гарантирует их независимо от установленных расширений.

Источники

  • pluggy — система хуков pytest: pluggy.readthedocs.io.
  • Python Packaging, «Entry points specification» — packaging.python.org.
  • java.util.ServiceLoaderdocs.oracle.com: классический SPI в стандартной библиотеке.
  • Hyrum Wright, «Hyrum’s Law» — hyrumslaw.com, и глава про устаревание API в «Software Engineering at Google».
  • HashiCorp, «Plugin system overview» (go-plugin, gRPC) — github.com/hashicorp/go-plugin.
  • VS Code, «Extension API» — code.visualstudio.com/api, раздел про extension host и изоляцию.
  • Kubernetes, «Dynamic Admission Control» — kubernetes.io.
  • Frank Buschmann et al., «Pattern-Oriented Software Architecture, vol. 1», 1996 — архитектурный стиль Microkernel: хост, внутренние сервисы, внешние расширения.
  • Go blog, «Package names and blank imports» — go.dev/doc/effective_go.

Что дальше

Плагины, правила и внешние системы объединяет одно: данные пересекают границу. Приходит JSON, которому нельзя верить; уходит структура, на которую кто-то немедленно начнёт полагаться. Следующая глава — про паттерны этой границы: DTO и мэпперы, «парсить, а не валидировать», накопление ошибок вместо первого исключения и честная работа с отсутствующим значением.

Паттерны границ: DTO, мэпперы и отсутствующее значение

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов