Точки расширения: реестр, SPI, хуки и плагины
В предыдущей главе правила переехали из кода в данные, чтобы меняться без релиза. Это частный случай большой задачи: дать поведению системы меняться без пересборки — и, в пределе, без вашего участия вообще.
Задача возникает в трёх ситуациях. Первая: продукт используют разные компании с разными требованиями, и форк ради каждой — путь в никуда. Вторая: вы хотите экосистему — чтобы вокруг вашего инструмента писали интеграции те, кого вы никогда не встретите. Третья, самая частая и самая скучная: система внутри одной компании, но интеграций с внешним миром так много, что каждая новая ветка в общем коде — это конфликт релизных циклов между командами.
Ответ во всех трёх случаях один — точка расширения. Но у неё есть свойство, которое отличает её от всех паттернов предыдущих глав: опубликованная точка расширения — это обещание. Стратегию внутри модуля можно переписать за вечер. Контракт, по которому уже написаны сорок плагинов, нельзя переписать никогда — можно только выпустить вторую версию и годами поддерживать обе.
Шкала расширяемости: не всё сразу
значения параметров"] --> L1["1. Колбэк / хук
функция в параметре"] L1 --> L2["2. Реестр реализаций
ключ → фабрика, внутри вашего кода"] L2 --> L3["3. Плагины в процессе
чужой код грузится в рантайме"] L3 --> L4["4. Плагины вне процесса
подпроцесс, gRPC, WASM, webhook"] L0 -.->|"цена: почти ноль
сила: мала"| C0["меняем поведение
в известных пределах"] L2 -.->|"цена: контракт внутри репозитория"| C2["новые варианты без правки клиента"] L3 -.->|"цена: контракт наружу + чужие падения
в вашем процессе"| C3["экосистема"] L4 -.->|"цена: сериализация, задержка,
отдельный релизный цикл"| C4["экосистема + изоляция сбоев
+ граница доверия"]
Правило выбора звучит так: берите минимальный уровень, который закрывает известные вам случаи. Прыжок на уровень 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 для интерфейсов вообще, — и по той же причине: контракт должен описывать потребность хоста, а не возможности первой попавшейся реализации.
Что входит в контракт, кроме методов
Начинающие публикуют интерфейс и считают, что контракт готов. На практике контракт — это ещё пять вещей, и каждая всплывает в первый же месяц жизни экосистемы:
- Версия контракта. Числом, в явном виде, проверяемая при загрузке. Без неё вы не сможете ничего изменить: старый плагин просто рухнет в непонятном месте.
- Модель ошибок. Что делать плагину при сбое: бросить исключение? вернуть результат-ошибку? Что сделает хост: отключит плагин? повторит? продолжит без него?
- Модель времени жизни. Когда вызывается инициализация, когда завершение, может ли плагин держать состояние между вызовами, гарантирована ли однопоточность.
- Что передаётся внутрь. Контекст с логгером, конфигом и точками регистрации — явно, а не через глобальные объекты хоста. Глобальный доступ к внутренностям — это тот самый Service Locator, только теперь в чужих руках.
- Что считается публичным. Всё, до чего плагин может дотянуться, станет частью контракта де-факто — это закон Хайрама: «при достаточном числе пользователей любое наблюдаемое поведение системы становится чьей-то зависимостью».
увеличить счётчик ошибок, продолжить else успех P-->>H: изменённый order end H->>P: teardown() при остановке
Загрузчик с изоляцией ошибок
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.ServiceLoader— docs.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 и мэпперы, «парсить, а не валидировать», накопление ошибок вместо первого исключения и честная работа с отсутствующим значением.