Управление зависимостями расширений

Расширения могут зависеть друг от друга: кеш-расширение требует, чтобы валидатор работал первым, а логгер — последним. Управление этими зависимостями предотвращает конфликты и гарантирует правильный порядок инициализации.


Проблема неупорядоченных расширений

// Неправильный порядок — логгер установлен до валидатора
registry
  .use(LogExtension)          // Логирует до валидации — включая невалидные вызовы
  .use(ValidationExtension)   // Валидирует слишком поздно
  .use(CacheExtension);       // Кеширует невалидные результаты

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


Объявление зависимостей расширения

interface Extension {
  name:    string;
  version: string;
  deps?:   string[];           // Имена расширений, которые должны быть установлены раньше
  after?:  string[];           // Должен быть установлен после этих
  install: (reg: Registry) => void;
}

const CacheExtension: Extension = {
  name:    'cache',
  version: '1.0.0',
  deps:    ['validation'],     // Требует validation
  after:   ['validation'],     // Устанавливается после
  install: (reg) => { /* ... */ },
};

const LogExtension: Extension = {
  name:    'log',
  version: '1.0.0',
  after:   ['cache', 'validation'],
  install: (reg) => { /* ... */ },
};

Топологическая сортировка зависимостей

function topoSort(extensions: Extension[]): Extension[] {
  const nameMap = new Map(extensions.map(e => [e.name, e]));
  const visited = new Set<string>();
  const result:  Extension[] = [];

  function visit(name: string): void {
    if (visited.has(name)) return;
    visited.add(name);

    const ext = nameMap.get(name);
    if (!ext) throw new Error(`Extension "${name}" not found`);

    (ext.after ?? []).forEach(dep => {
      if (!nameMap.has(dep)) throw new Error(`Extension "${name}" requires "${dep}"`);
      visit(dep);
    });

    result.push(ext);
  }

  extensions.forEach(e => visit(e.name));
  return result;
}

// Использование
const sorted = topoSort([LogExtension, ValidationExtension, CacheExtension]);
// Порядок: ValidationExtension → CacheExtension → LogExtension

Реестр с разрешением зависимостей

class Registry {
  private pending:   Extension[] = [];
  private installed = new Map<string, Extension>();

  add(extension: Extension): this {
    this.pending.push(extension);
    return this;
  }

  resolve(): this {
    const sorted = topoSort(this.pending);

    sorted.forEach(ext => {
      // Проверить зависимости
      (ext.deps ?? []).forEach(dep => {
        if (!this.installed.has(dep)) {
          throw new Error(
            `Extension "${ext.name}" requires "${dep}" to be installed`
          );
        }
      });

      ext.install(this);
      this.installed.set(ext.name, ext);
    });

    this.pending = [];
    return this;
  }

  isInstalled(name: string): boolean {
    return this.installed.has(name);
  }
}

const registry = new Registry();

registry
  .add(LogExtension)         // Добавить в любом порядке
  .add(ValidationExtension)
  .add(CacheExtension)
  .resolve();                 // Установить в правильном порядке

Версионирование зависимостей

interface VersionedExtension extends Extension {
  version: string;
  peerDeps?: Record<string, string>; // имя → минимальная версия
}

function checkPeerDeps(
  ext: VersionedExtension,
  installed: Map<string, VersionedExtension>
): void {
  const peers = ext.peerDeps ?? {};

  Object.entries(peers).forEach(([name, minVersion]) => {
    const dep = installed.get(name);

    if (!dep) {
      throw new Error(`"${ext.name}" requires peer "${name}" >= ${minVersion}`);
    }

    if (!satisfiesVersion(dep.version, minVersion)) {
      throw new Error(
        `"${ext.name}" requires "${name}" >= ${minVersion}, got ${dep.version}`
      );
    }
  });
}

function satisfiesVersion(actual: string, required: string): boolean {
  const [aMaj, aMin] = actual.split('.').map(Number);
  const [rMaj, rMin] = required.split('.').map(Number);
  return aMaj > rMaj || (aMaj === rMaj && aMin >= rMin);
}

Опциональные зависимости

const AnalyticsExtension: Extension = {
  name:    'analytics',
  version: '1.0.0',
  install: (reg) => {
    reg.wrapFormat((original) => (date, locale, opts) => {
      const result = original(date, locale, opts);

      // Опциональная зависимость от log-расширения
      if (reg.isInstalled('log')) {
        // Дополнительное поведение при наличии log
      }

      return result;
    });
  },
};

Конфликты расширений

const ConflictingExtension: Extension = {
  name:    'format-override',
  version: '1.0.0',
  install: (reg) => {
    if (reg.isInstalled('cache')) {
      throw new Error(
        '"format-override" conflicts with "cache". Install one or the other.'
      );
    }
    reg.wrapFormat((original) => (date, locale, opts) => {
      return 'overridden!';
    });
  },
};

Граф зависимостей

validation ───────┐
                  ▼
                cache ──┐
                        ▼
                       log
                        ▼
                    analytics

Каждое расширение знает своё место в цепочке. Ниже — правила для примера выше:

const EXTENSIONS = [
  { name: 'validation', after: []                        },
  { name: 'cache',      after: ['validation']            },
  { name: 'log',        after: ['cache']                 },
  { name: 'analytics',  after: ['log'], deps: ['log']    },
];

Итоговая проверка

// Диагностика: вывести установленные расширения и порядок
registry.getInstalled().forEach((ext, name) => {
  console.log(`[${name}] v${ext.version}`);
});