Read OSS

DIエンジン:VS Codeが190以上のサービスを繋ぎ合わせる仕組み

上級

前提知識

  • 第1回:アーキテクチャとレイヤリング
  • 第2回:起動とプロセスアーキテクチャ
  • TypeScriptのデコレーターとジェネリクス
  • 依存性注入の基本概念

DIエンジン:VS Codeが190以上のサービスを繋ぎ合わせる仕組み

第2回では、CodeMainが約15のサービスを生成し、CodeApplicationがそれをさらに数十に拡張し、最終的にレンダラー側のWorkbenchが数百の登録済みシングルトンを取り込む様子を見てきました。これらはすべて、サードパーティのDIフレームワークを一切使わずに実現されています。VS Codeは独自の依存性注入システムを持っており、コンパクトでありながら強力で、TypeScriptの型システムと深く統合されています。コードベースのどの部分を読むうえでも、この仕組みを理解することは欠かせません。

createDecoratorとServiceIdentifier

VS CodeのすべてのサービスはServiceIdentifier<T>によって識別されます。これはTypeScriptのパラメーターデコレーターとしても機能するオブジェクトです。ファクトリ関数createDecoratorがこれらを生成します:

export function createDecorator<T>(serviceId: string): ServiceIdentifier<T> {
    if (_util.serviceIds.has(serviceId)) {
        return _util.serviceIds.get(serviceId)!;
    }
    const id = function (target: Function, key: string, index: number) {
        if (arguments.length !== 3) {
            throw new Error('@IServiceName-decorator can only be used to decorate a parameter');
        }
        storeServiceDependency(id, target, index);
    } as ServiceIdentifier<T>;
    id.toString = () => serviceId;
    _util.serviceIds.set(serviceId, id);
    return id;
}

ポイントは、id関数であるという点です。TypeScriptはこれをパラメーターデコレーターとして呼び出しますが、同時にServiceIdentifier<T>インターフェース経由でtypeブランドも持ちます。この二重の性質により、export const IFileService = createDecorator<IFileService>('fileService')という一つの宣言で次の2つを得ることができます:

  1. DIコンテナ向けの型安全なルックアップキー
  2. コンストラクターインジェクション用のパラメーターデコレーター
classDiagram
    class ServiceIdentifier~T~ {
        +type: T
        +(target, key, index): void
        +toString(): string
    }
    
    class createDecorator {
        +createDecorator~T~(serviceId: string): ServiceIdentifier~T~
    }
    
    class _util {
        +serviceIds: Map~string, ServiceIdentifier~
        +DI_TARGET: string
        +DI_DEPENDENCIES: string
        +getServiceDependencies(ctor): Dependency[]
    }
    
    createDecorator ..> ServiceIdentifier : creates
    ServiceIdentifier ..> _util : stores dependency metadata

constructor(@ILogService private logService: ILogService) のようなコンストラクターを書くと、@ILogService デコレーターがクラス定義時に発動します。指定した index のパラメーターが ILogService 識別子に依存していることが記録されます。このメタデータは storeServiceDependency() を通じてコンストラクター関数自体の $di$dependencies プロパティに保存されます。

InstantiationService:グラフベースの依存解決

InstantiationServiceはDIコンテナです。ServiceCollectionMap<ServiceIdentifier, instance | SyncDescriptor>のシンプルなラッパー)を保持し、グラフを構築して依存関係を解決します:

flowchart TD
    A["createInstance(MyClass)"] --> B["Read @decorator metadata<br/>from MyClass constructor"]
    B --> C["For each dependency,<br/>check ServiceCollection"]
    C --> D{Instance exists?}
    D -->|Yes| E["Use existing instance"]
    D -->|No, has SyncDescriptor| F["Add to dependency graph"]
    F --> G["Recursively resolve<br/>descriptor's dependencies"]
    G --> H["Topological sort the graph"]
    H --> I{Cycle detected?}
    I -->|Yes| J["Throw CyclicDependencyError"]
    I -->|No| K["Instantiate in dependency order"]
    K --> L["Return MyClass instance"]

グラフの構築とサイクル検出は、内部メソッド_createAndCacheServiceInstanceで行われます。あるサービスがSyncDescriptorとして登録された別のサービス(「まだインスタンス化されていない」ことを意味します)に依存している場合、リゾルバーはグラフにエッジを追加します。すべての依存関係が収集されると、トポロジカルソートを行い、末端のサービスから順にインスタンス化していきます。

instantiationService.ts#L21-L26CyclicDependencyErrorには、有用な診断情報が含まれています。findCycleSlow()を実行してサイクルパスを正確に特定するため、開発中に非常に役立ちます。

コンテナはcreateChild()による子スコープもサポートしています。子コンテナは親のすべてのサービスを継承しつつ、特定のサービスをオーバーライドできます。ワークベンチでは、特定の機能向けのスコープ付きコンテナを作成する際に利用されます。

SyncDescriptorとProxyによる遅延インスタンス化

SyncDescriptorはシンプルなラッパーです。コンストラクターへの参照、静的引数、そして重要なsupportsDelayedInstantiationフラグを保持しています。

export class SyncDescriptor<T> {
    readonly ctor: any;
    readonly staticArguments: unknown[];
    readonly supportsDelayedInstantiation: boolean;

    constructor(ctor: new (...args: any[]) => T, 
                staticArguments: unknown[] = [], 
                supportsDelayedInstantiation: boolean = false) { ... }
}

supportsDelayedInstantiationtrueの場合、InstantiationServiceは実際のサービスインスタンスをすぐに作成しません。代わりに、サービスの代役となるJavaScript Proxyを生成します。実際のインスタンスが構築されるのは、Proxy上でメソッドが初めて呼び出されたときです。

sequenceDiagram
    participant Consumer
    participant Proxy as JS Proxy
    participant DI as InstantiationService
    participant Service as Real Service

    Consumer->>Proxy: accessor.get(IFooService)
    Note over Proxy: Proxy returned, not real instance
    Consumer->>Proxy: fooService.doSomething()
    Proxy->>DI: First access! Instantiate now.
    DI->>Service: new FooService(deps...)
    Proxy->>Service: Forward doSomething()
    Service-->>Consumer: Result
    Note over Proxy: Subsequent calls go<br/>directly to Service

これはスタートアップのパフォーマンスに大きく貢献する最適化です。多くのサービスは起動中に登録されますが、ユーザーが特定のアクションを実行するまで必要とされません。遅延Proxyがなければ、ワークベンチは起動時に数百のサービスを同期的にインスタンス化しなければならず、最初の描画がブロックされてしまいます。

ヒント: 新しいサービスを登録する際は、起動時に必ず実行すべき副作用がない限り、InstantiationType.DelayedsupportsDelayedInstantiation: trueに対応)を使用しましょう。これが推奨されるデフォルトです。

registerSingleton:グローバルサービスレジストリ

registerSingletonは、バレルファイル(第1回で紹介)とDIコンテナをつなぐ橋渡し役です:

const _registry: [ServiceIdentifier<any>, SyncDescriptor<any>][] = [];

export function registerSingleton<T>(id: ServiceIdentifier<T>, 
    ctorOrDescriptor: ..., 
    supportsDelayedInstantiation?: InstantiationType): void {
    if (!(ctorOrDescriptor instanceof SyncDescriptor)) {
        ctorOrDescriptor = new SyncDescriptor(ctorOrDescriptor, [], Boolean(supportsDelayedInstantiation));
    }
    _registry.push([id, ctorOrDescriptor]);
}

非常にシンプルな仕組みです。[ServiceIdentifier, SyncDescriptor]のタプルをモジュールレベルの配列にpushするだけです。ワークベンチの起動時に、Workbench.initServices()getSingletonServiceDescriptors()を呼び出してこの配列をServiceCollectionに流し込みます:

const contributedServices = getSingletonServiceDescriptors();
for (const [id, descriptor] of contributedServices) {
    serviceCollection.set(id, descriptor);
}

workbench.desktop.main.tsのようなバレルファイルをインポートすると、インポートされた各モジュールがモジュール評価時にregisterSingleton()を呼び出す機会を得ます。initServices()が実行される頃には、サービスグラフ全体が宣言済みになっています。InstantiationType列挙型は、サービスをEager(このループ中にインスタンス化)にするかDelayed(Proxyによる遅延インスタンス化)にするかを制御します。

Disposableパターンとライフサイクル管理

IDisposableインターフェース — 単一のdispose()メソッドを持つ — は、コードベース全体のリソース管理の基盤です。

src/vs/base/common/lifecycle.tsが主要な構成要素を定義しています:

classDiagram
    class IDisposable {
        <<interface>>
        +dispose(): void
    }
    
    class Disposable {
        #_register(disposable): T
        +dispose(): void
    }
    
    class DisposableStore {
        +add(disposable): T
        +clear(): void
        +dispose(): void
    }
    
    class DisposableMap {
        +set(key, disposable): void
        +deleteAndDispose(key): void
        +dispose(): void
    }
    
    class GCBasedDisposableTracker {
        -_registry: FinalizationRegistry
        +trackDisposable(d): void
        +markAsDisposed(d): void
    }
    
    IDisposable <|.. Disposable
    IDisposable <|.. DisposableStore
    Disposable *-- DisposableStore : uses internally
    IDisposable <|.. DisposableMap

Disposable基底クラスは_register()メソッドを提供し、子Disposableを内部のDisposableStoreに追加できます。親がdisposeされると、登録されたすべての子も自動的にdisposeされます。これにより、自然な所有権ツリーが形成されます。

GCBasedDisposableTrackerは特に巧妙な仕組みです。FinalizationRegistry APIを使い、disposeされずにガベージコレクションされたDisposableを検出します。これはリソースリークの強力なシグナルとなります。開発中は、[LEAKED DISPOSABLE] CREATED via: ...のような警告が作成時のスタックトレース付きで表示されます。

Event/Emitterシステム

VS Code独自のEvent<T>型は、リスナーを受け取りIDisposable(サブスクリプション自体)を返す関数です。Emitter<T>クラスがその実体であり、値を発火するためのfire()を提供します。

このシステムを特別なものにしているのは、合成可能なオペレーターを提供するEvent名前空間です:

  • Event.map(event, fn) — イベントのペイロードを変換する
  • Event.filter(event, predicate) — 条件に合致するイベントのみ発火する
  • Event.debounce(event, merge, delay) — 連続した発火をマージする
  • Event.buffer(event) — リスナーがアタッチされるまでイベントをバッファリングする
  • Event.once(event) — 最初の発火後に自動的にdisposeする

すべてのオペレーターは新しいEventインスタンスを返し、すべてDisposableパターンと統合されています。これはVS Codeのリアクティブな根幹です。サービスはonDidChange*イベントを公開し、コンシューマーはそれらを合成して派生シグナルを作ることができます。生のリスナーのライフサイクルを直接管理する必要はありません。

RegistryとContributionパターン

Registryは、コードベース内の拡張ポイントに使用されるシンプルな文字列キーのMapです。Registry.add(id, data)でコントリビューションレジストリを登録し、Registry.as<T>(id)で取得します。

このパターンの最も高レベルな使用例がregisterWorkbenchContribution2です。これは特定のライフサイクルフェーズを指定してワークベンチのコントリビューションを登録します:

export const enum WorkbenchPhase {
    BlockStartup = LifecyclePhase.Starting,    // Blocks editor from showing
    BlockRestore = LifecyclePhase.Ready,       // Blocks UI state restore
    AfterRestored = LifecyclePhase.Restored,   // Views and editors restored
    Eventually = LifecyclePhase.Eventually     // 2-5 seconds after restore
}

src/vs/workbench/common/contributions.ts#L31-L62

flowchart LR
    A["BlockStartup"] --> B["BlockRestore"]
    B --> C["AfterRestored"]
    C --> D["Eventually"]
    
    A -.->|"Essential init<br/>(blocks first paint)"| A1["Keybindings,<br/>Theming"]
    C -.->|"Non-critical<br/>(after UI visible)"| C1["Terminal restore,<br/>Extension recommendations"]
    D -.->|"Background<br/>(2-5s delay)"| D1["Telemetry,<br/>Update checker"]

ほとんどのコントリビューションはWorkbenchPhase.AfterRestoredまたはEventuallyを使用すべきです。BlockStartupを使用すると最初の描画が遅延するため、ユーザーに何かが表示される前に実行しなければならないコントリビューションに限定してください。コントリビューションシステムは作成時間を追跡し、2msを超えるコントリビューションに対して警告を記録します。

ヒント: { lazy: true }インスタンス化オプションを使用すると、getWorkbenchContribution()を通じて明示的にリクエストされるまでコントリビューションの生成が延期されます。セッション中に一度も有効化されないかもしれない機能に最適な選択肢です。

次回予告

DIシステム、Disposable、イベント、コントリビューションは、VS Codeのすべての基盤となるパターンです。次回はExtension Hostを掘り下げます。Extension Hostはサードパーティのコードが実行される独立したプロセスであり、140以上のProxyインターフェースを持つRPCプロトコルを通じてワークベンチと接続されています。