建築スタイルと原則#
VS Code は、コアパッケージのソフトウェアアーキテクチャにレイヤー化されたモジュラーアプローチを採用しています。コアパッケージは、VS Code で利用可能なほぼすべての機能を提供する拡張 API を使用して拡張できるように設計されています。このアーキテクチャスタイルは、実装とテストのためにコードエディタの異なる責任を分離するのに役立ち、開発者が特定の機能の特定の部分を実装すべき場所を明確にします。
各レイヤーは、アーキテクチャ内の直接下のレイヤーと通信します。ベースレイヤーは、ベースレイヤーで定義されたプラットフォーム固有の依存関係を介してプラットフォームレイヤーと通信し、プラットフォームレイヤーは拡張 API を介して拡張レイヤーと通信します。以下のセクションでは、VS Code のベース、プラットフォーム、および拡張レイヤーの分析に主に焦点を当てます。
ベースレイヤー#
ベースレイヤーは、ElectronJS フレームワークの上に構築されており、他のレイヤーで使用できる一般的なユーティリティとユーザーインターフェイスのビルディングブロックを提供します。これらのコンポーネントは、HTML、CSS、および JavaScript などの Web 技術を使用して構築されています。ベースレイヤーは、VS Code がファイルシステムと対話できるようにするファイルシステムユーティリティも提供します。これには、ファイルの読み書き、ディレクトリのナビゲーション、新しいファイルやディレクトリの作成が含まれます。さらに重要なのは、このレイヤーには、ユーザーが拡張機能をインストール、管理、および更新できる拡張管理システムが含まれていることです。これは Web 技術と ElectronJS API を使用して構築されており、拡張機能を管理するための直感的なインターフェイスを提供します。
ベースパッケージには、common、electron-browser、node などのいくつかのサブパッケージが含まれています。これらのサブパッケージのそれぞれには、そのパッケージに特有のモジュールが含まれています。common パッケージには、異なるプラットフォームや環境で使用されるモジュールが含まれています。これには、テキストバッファの処理、ファイルやディレクトリの操作、ボタンやメニューなどの基本的な UI コンポーネントの提供に関するモジュールが含まれます。electron-browser パッケージには、デスクトップで VS Code を実行するために使用される ElectronJS プラットフォームに特有のモジュールが含まれています。これには、UI 要素の処理、Web ビューの管理、ElectronJS API との対話に関するモジュールが含まれます。node パッケージには、サーバーや他の環境で VS Code を実行するために使用される NodeJS ランタイムに特有のモジュールが含まれています。これには、プロセス、ファイル、およびディレクトリの操作に関するモジュールや、低レベルのネットワーキング機能の提供が含まれます。
全体として、ベースレイヤーは、拡張 API を介して他のレイヤーと相互作用し、他のレイヤーが完全な機能を持つコードエディタを作成するためのコア機能を提供します。
プラットフォームレイヤー#
VS Code のプラットフォームレイヤーは、依存性注入を介してすべてのサービスを提供する責任を持つレイヤーです。プラットフォームレイヤーは、ソフトウェアのさまざまなモジュールやコンポーネントのための基本的なインフラストラクチャと共通サービスを提供します。このアプローチにより、モジュールはより緩やかに結合され、モジュール性と拡張性が促進されます。また、プラットフォームレイヤーは、拡張機能がプラットフォームに登録し、他のサービスやモジュールと対話できるようにする拡張サービスを提供します。
プラットフォームパッケージには、services、configuration、workspace などのいくつかのサブパッケージが含まれています。services パッケージには、テキスト編集機能を処理するテキストモデルサービスや、ファイルシステムへのアクセスを提供するファイルサービスなど、アプリケーションの残りの部分にサービスを提供するモジュールが含まれています。configuration パッケージには、ファイルの構文に関する情報を提供する言語設定サービスや、ユーザーの設定を処理する設定サービスなど、エディタの設定を処理するモジュールが含まれています。workspace パッケージには、ワークスペース内のファイルに関する情報を提供するワークスペースサービスや、ファイルを検索する方法を提供する検索サービスなど、ワークスペース関連の機能を処理するモジュールが含まれています。コードはまた、アプリケーションの異なる部分が相互に通信できるようにするイベントシステムを使用しています。これにより、アプリケーションに新しい機能を追加し、ユーザー入力に応答することが容易になります。
拡張レイヤー#
VS Code の拡張レイヤーは、サードパーティの拡張機能を介してソフトウェアの機能を拡張する能力を提供します。このレイヤーはプラットフォームレイヤーの上に構築されており、開発者が他のソフトウェアと対話できる独自のモジュールやサービスを作成できるようにします。拡張 API は、拡張機能を作成および管理するための豊富な機能とメソッドを提供し、言語サポート、エディタのカスタマイズ、デバッグ機能は、よりパーソナライズされた開発環境を作成するための追加機能を提供します。拡張レイヤーを使用すると、開発者はソフトウェアの他の部分とシームレスに統合できる新しい機能やサービスを作成できます。
拡張パッケージには、commands、languages、views などのいくつかのサブパッケージが含まれています。commands パッケージには、エディタで実行できるコマンドを提供するモジュールが含まれています。languages パッケージには、さまざまなプログラミング言語のサポートを提供するモジュールが含まれています。views パッケージには、さまざまなファイルタイプや機能のためのビューやエディタを提供するモジュールが含まれています。また、コードは API をコネクタとして使用して、拡張機能がベースおよびプラットフォームレイヤーと対話し、提供される機能にアクセスできるようにしています。拡張機能がインストールされると、それはメインエディタプロセスとは別のプロセスにロードされます。これにより、拡張機能は別のコンテキストで実行され、コアエディタの機能に干渉しないようになります。拡張プロセスは、拡張 API によって提供される通信チャネルを介してメインエディタプロセスと通信します。
基本原則#
VS Code のアーキテクチャ原則は、モジュール性、拡張性、柔軟性の概念を中心に構築されています。
-
モジュール性: VS Code のアーキテクチャはモジュラーであるように設計されており、各レイヤーは複数の独立したモジュールで構成されています。これにより、モジュールを追加、削除、または置き換えることができ、全体のシステムに影響を与えずにメンテナンスが容易になります。
-
拡張性: VS Code は非常に拡張性が高く、Web 技術と VS Code 拡張 API を使用しています。これにより、開発者は拡張機能を使用してエディタに新しい機能や機能を簡単に追加できます。
-
柔軟性: VS Code の設計は柔軟性もあり、複数のプログラミング言語、開発環境、およびプラットフォームをサポートする能力があります。これは Web 技術とネイティブコードの組み合わせで構築されており、複数のオペレーティングシステムで実行でき、さまざまな開発者のニーズに合わせて簡単にカスタマイズできます。
VS Code の設計に採用されている他のアーキテクチャ原則には、関心の分離、モジュール性、およびレイヤーアーキテクチャが含まれます。これらの原則は、システムが理解しやすく、メンテナンスしやすく、拡張しやすいことを保証するのに役立ちます。
Electron#
- Web 技術を使用して UI を記述し、Chrome ブラウザエンジンを使用して実行
- NodeJS を使用してファイルシステムを操作し、ネットワークリクエストを開始
- NodeJS C++ アドオンを使用してオペレーティングシステムのネイティブ API を呼び出す
Electron には、メインプロセスとレンダラープロセスの 2 種類のプロセスがあります。
- メインプロセス:メインプロセスは、ブラウザウィンドウを作成し、システムレベルのイベント、メニュー、ダイアログボックスなど、アプリケーションのさまざまな側面を管理する責任があります。これは Node.js 環境で実行され、ネイティブオペレーティングシステム API にアクセスできます。メインプロセスは、アプリケーションの異なる部分間の通信のための IPC メカニズムも提供します。
- レンダラープロセス: Electron.js アプリケーションの各ウィンドウは、アプリケーションのユーザーインターフェイスを Chromium を使用してレンダリングする責任を持つ独自のレンダラープロセスを実行します。レンダラープロセスはサンドボックス化された環境で実行され、ネイティブオペレーティングシステム API へのアクセスは制限されています。これらは、Electron.js によって提供される IPC メカニズムを使用してメインプロセスと通信します。
したがって、同じプロセスにない場合、プロセス間通信が関与します。Electron では、メインプロセスとレンダラープロセス間の通信を実現するために、以下の方法を使用できます。
- IPC メソッドを使用して、アプリケーションのバックエンド(ipcMain)とフロントエンドアプリケーションウィンドウ(ipcRenderer)間で通信を実現できます。これらはプロセス間通信のためのイベントトリガーです。
- リモートプロシージャコール(RPC)通信は、リモートモジュールを使用して実現できます。
リモートモジュールによって返される各オブジェクト(関数を含む)は、メインプロセス内のオブジェクト(リモートオブジェクトまたはリモート関数と呼ばれる)を表します。リモートオブジェクトのメソッドを呼び出すと、リモート関数を呼び出すか、リモートコンストラクタ関数を使用して新しいオブジェクトを作成すると、実際には同期プロセス間メッセージが送信されます。
Electron アプリケーションのバックエンドとフロントエンド間の状態共有は、ipcMain および ipcRenderer モジュールを介して行われます。この方法により、メインプロセスとレンダラープロセスの JavaScript コンテキストは独立して残りますが、データは明示的に転送できます。
VSCode のプロセス構造#
VSCode はマルチプロセスアーキテクチャを持ち、起動時に主に以下のプロセスで構成されます。
バックグラウンドプロセス#
バックグラウンドプロセスは VSCode のエントリポイントとして機能し、エディタのライフサイクル、プロセス間通信、自動更新、メニュー管理を管理する責任があります。
VSCode を起動すると、バックグラウンドプロセスが最初に起動します。さまざまな設定情報や履歴を読み取り、これらの情報をメインウィンドウ UI の HTML メインファイルパスと統合して URL にし、エディタの UI を表示するためにブラウザウィンドウを起動します。バックグラウンドプロセスは常に UI プロセスの状態を監視し、すべての UI プロセスが閉じられると、エディタ全体が終了します。
さらに、バックグラウンドプロセスはローカルソケットも開きます。新しい VSCode プロセスが開始されると、このソケットに接続しようとし、起動パラメータ情報を渡して、既存の VSCode が関連するアクションを実行できるようにします。これにより、VSCode のユニーク性が確保され、複数のフォルダを開くことによる問題を回避します。
エディタウィンドウ#
エディタウィンドウプロセスは、画面上に表示される全体の UI を表示する責任があります。UI は完全に HTML で記述されており、この点についてはあまり紹介することはありません。
Node.js 非同期 IO#
プロジェクトファイルの読み取りと保存は、メインプロセス内の NodeJS API によって完了します。すべての操作が非同期であるため、比較的大きなファイルでも UI をブロックすることはありません。IO と UI は同じプロセス内にあり、非同期操作を使用しているため、IO のパフォーマンスと UI の応答性が確保されます。
拡張プロセス#
各 UI ウィンドウは、拡張機能のホストプロセスとして NodeJS サブプロセスを起動します。すべての拡張機能はこのプロセス内で一緒に実行されます。この設計の主な目的は、複雑な拡張システムが UI の応答をブロックしないようにすることです。ほとんどのオペレーティングシステムでは、ディスプレイのリフレッシュレートは 1 秒あたり 60 フレームであり、これはアプリケーションがすべての計算と UI のリフレッシュを 16.7 ミリ秒以内に完了する必要があることを意味します。HTML DOM の速度は常に批判されており、JavaScript に与えられる時間は限られています。したがって、UI の応答性を確保するためには、すべての命令をこのような短い時間内に完了する必要があります。しかし、実際には、1 万行のコードに色を付けるなどのタスクをこの短い時間内に完了することは困難です。したがって、これらの時間のかかるタスクは他のスレッドやプロセスに移動する必要があります。タスクが完了した後、結果を UI プロセスに返すことができます。ただし、拡張機能を別のプロセスに置くことには明らかな欠点もあります。別のプロセスであるため、UI プロセスではなく、DOM ツリーに直接アクセスすることはできません。リアルタイムで UI を効率的に変更することが難しくなり、VSCode の拡張システムには UI を拡張するための API はほとんどありません。
デバッグプロセス#
デバッガー拡張は、通常の拡張とは少し異なります。デバッグ時に UI によって毎回新しいプロセスで開かれるため、拡張プロセスでは実行されません。
検索プロセス#
検索は非常に時間のかかるタスクであり、VSCode はこの機能を実装するために別のプロセスを使用して、メインウィンドウの効率を確保します。時間のかかるタスクを複数のプロセスに分散させることで、メインプロセスの応答性を効果的に保証します。
VSCode の複数のプロセスは、以下の 3 つのタイプに分類できます。
- メインプロセス:メインプロセスは、アプリケーションの全体的なライフサイクルを管理する責任があり、UI 管理、拡張管理、レンダラープロセスとの通信を含みます。これは、アプリケーションの残りの部分からプラットフォーム固有の詳細を隠す抽象化レイヤーを提供します。
- レンダラープロセス: VSCode には 2 種類のレンダラープロセスがあります。ウィンドウレンダラーと Web ビューレンダラーです。ウィンドウレンダラーは、エディタエリア、サイドバー、ツールバーなど、アプリケーションのメインユーザーインターフェイスをレンダリングする責任があります。一方、Web ビューレンダラーは、カスタムビューやパネルなどの埋め込まれた Web コンテンツをレンダリングするために使用されます。
- 拡張プロセス: VSCode の拡張は、UI スレッドをブロックしたり、他の拡張に干渉したりしないように、メインアプリケーションとは別のプロセスで実行されます。各拡張は独自の隔離されたプロセスで実行され、IPC メカニズムを介してメインプロセスと通信します。
全体として、VSCode のマルチプロセスアーキテクチャは、パフォーマンスの向上、セキュリティの向上、信頼性の向上を可能にします。異なるタスクを異なるプロセスに分離することで、VSCode は全体の応答性に影響を与えることなく複雑な操作を処理できます。さらに、拡張プロセスを使用することで、拡張機能がユーザーの体験に悪影響を与えることなく安全に実行できることが保証されます。
プロセス間通信(IPC)#
プロトコル#
IPC 通信において、プロトコルは最も基本的なものです。人々の間のコミュニケーションが合意された方法(言語、手話)を必要とするのと同様に、IPC においてプロトコルは合意として見ることができます。
通信能力として、最も基本的なプロトコルの範囲には、メッセージの送信と受信が含まれます。
export interface IMessagePassingProtocol {
send(buffer: VSBuffer): void; // より低レベルの通信チャネルを介してUint8Array形式でメッセージを送信
onMessage: Event<VSBuffer>; // より低レベルの通信チャネルでメッセージを受信したときに上位レベルのコールバック関数をトリガー
}
具体的なプロトコルの内容には、接続、切断、イベントなどが含まれる場合があります。
export class Protocol implements IMessagePassingProtocol {
constructor(private sender: Sender, readonly onMessage: Event<VSBuffer>) { }
// メッセージを送信
send(message: VSBuffer): void {
try {
this.sender.send('vscode:message', message.buffer);
} catch (e) {
// システムがダウンしています
}
}
// 接続を閉じる
dispose(): void {
this.sender.send('vscode:disconnect', null);
}
}
IPC は本質的に情報を送受信する能力であり、正確に通信するためには、クライアントとサーバーが同じチャネルにいる必要があります。
チャネル#
チャネルには 2 つの機能があります。1 つは呼び出し、もう 1 つはリスニングです。
/**
* IChannelは一連のコマンドの抽象です
* 呼び出しは常にPromiseを返し、最大で1つの戻り値を持ちます
*/
export interface IChannel {
call<T>(command: string, arg?: any, cancellationToken?: CancellationToken): Promise<T>;
listen<T>(event: string, arg?: any): Event<T>;
}
/**
* `IServerChannel`はサーバー側の`IChannel`に対応します。
* リモートプロミスやイベントを処理したい場合は、このインターフェースを実装する必要があります。
*/
export interface IServerChannel<TContext = string> {
call<T>(ctx: TContext, command: string, arg?: any, cancellationToken?: CancellationToken): Promise<T>;
listen<T>(ctx: TContext, event: string, arg?: any): Event<T>;
}
クライアントとサーバー#
一般的に、クライアントとサーバーの区別は、接続の開始側がクライアントであり、接続されるエンドポイントがサーバーであるという点に主にあります。VSCode では、メインプロセスがサーバーであり、さまざまなチャネルやサービスを提供してサブスクリプションを行います。レンダラープロセスはクライアントであり、サーバーが提供するさまざまなチャネル / サービスをリッスンし、サーバーにメッセージを送信することもできます(接続、サブスクリプション / リスニング、離脱など)。
クライアントとサーバーの両方は、適切に通信するためにメッセージを送受信する能力を必要とします。
VSCode では、クライアントにはChannelClient
とIPCClient
が含まれています。ChannelClient
は、最も基本的なチャネル関連機能のみを処理します。これには以下が含まれます。
getChannel
を使用してチャネルを取得。sendRequest
を使用してチャネルリクエストを送信。- リクエスト結果を受信し、
onResponse/onBuffer
で処理。
// client
export class ChannelClient implements IChannelClient, IDisposable {
getChannel<T extends IChannel>(channelName: string): T {
const that = this;
return {
call(command: string, arg?: any, cancellationToken?: CancellationToken) {
return that.requestPromise(channelName, command, arg, cancellationToken);
},
listen(event: string, arg: any) {
return that.requestEvent(channelName, event, arg);
}
} as T;
}
private requestPromise(channelName: string, name: string, arg?: any, cancellationToken = CancellationToken.None): Promise<any> {}
private requestEvent(channelName: string, name: string, arg?: any): Event<any> {}
private sendRequest(request: IRawRequest): void {}
private send(header: any, body: any = undefined): void {}
private sendBuffer(message: VSBuffer): void {}
private onBuffer(message: VSBuffer): void {}
private onResponse(response: IRawResponse): void {}
private whenInitialized(): Promise<void> {}
dispose(): void {}
}
同様に、サーバーにはChannelServer
とIPCServer
が含まれ、ChannelServer
もチャネルに直接関連する機能のみを処理します。これには以下が含まれます。
registerChannel
を使用してチャネルを登録。onRawMessage/onPromise/onEventListen
を使用してクライアントメッセージをリッスン。- クライアントメッセージを処理し、リクエスト結果を返すために
sendResponse
を使用。
// server
export class ChannelServer<TContext = string> implements IChannelServer<TContext>, IDisposable {
registerChannel(channelName: string, channel: IServerChannel<TContext>): void {
this.channels.set(channelName, channel);
}
private sendResponse(response: IRawResponse): void {}
private send(header: any, body: any = undefined): void {}
private sendBuffer(message: VSBuffer): void {}
private onRawMessage(message: VSBuffer): void {}
private onPromise(request: IRawPromiseRequest): void {}
private onEventListen(request: IRawEventListenRequest): void {}
private disposeActiveRequest(request: IRawRequest): void {}
private collectPendingRequest(request: IRawPromiseRequest | IRawEventListenRequest): void {}
public dispose(): void {}
}
IChannelServer
の主な責任には以下が含まれます。
protocol
からメッセージを受信- メッセージをタイプに基づいて処理
- リクエストを処理するために適切な
IServerChannel
を呼び出す - クライアントに応答を返す
IServerChannel
を登録
IChannelServer
は、protocol
からのメッセージを直接リッスンし、リクエストを処理するために自身のonRawMessage
メソッドを呼び出します。onRawMessage
は、リクエストのタイプに基づいて他のメソッドを呼び出します。Promise ベースの呼び出しを例に取ると、そのコアロジックはIServerChannel
のcall
メソッドを呼び出すことです。
private onRawMessage(message: VSBuffer): void {
const reader = new BufferReader(message);
const header = deserialize(reader);
const body = deserialize(reader);
const type = header[0] as RequestType;
switch (type) {
case RequestType.Promise:
if (this.logger) {
this.logger.logIncoming(message.byteLength, header[1], RequestInitiator.OtherSide, `${requestTypeToStr(type)}: ${header[2]}.${header[3]}`, body);
}
return this.onPromise({ type, id: header[1], channelName: header[2], name: header[3], arg: body });
// ...
}
}
private onPromise(request: IRawPromiseRequest): void {
const channel = this.channels.get(request.channelName);
let promise: Promise<any>;
try {
promise = channel.call(this.ctx, request.name, request.arg, cancellationTokenSource.token);
} catch (err) {
// ...
}
const id = request.id;
promise.then(data => {
this.sendResponse(<IRawResponse>{ id, data, type: ResponseType.PromiseSuccess });
this.activeRequests.delete(request.id);
}, err => {
// ...
});
}
接続#
現在、チャネル関連のクライアント部分ChannelClient
とサーバー部分ChannelServer
がありますが、通信するためには接続が必要です。接続(Connection
)はChannelClient
とChannelServer
で構成されます。
interface Connection<TContext> extends Client<TContext> {
readonly channelServer: ChannelServer<TContext>;
readonly channelClient: ChannelClient;
}
接続の確立はIPCServer
とIPCClient
によって処理されます。具体的には:
IPCClient
はChannelClient
に基づいており、クライアントからサーバーへの単純な 1 対 1 接続を担当します。IPCServer
はChannelServer
に基づいており、サーバーからクライアントへの接続を担当します。サーバーは複数のサービスを提供できるため、複数の接続が存在する可能性があります。
export class IPCClient<TContext = string> implements IChannelClient, IChannelServer<TContext>, IDisposable {
private channelClient: ChannelClient;
private channelServer: ChannelServer<TContext>;
getChannel<T extends IChannel>(channelName: string): T {
return this.channelClient.getChannel(channelName) as T;
}
registerChannel(channelName: string, channel: IServerChannel<TContext>): void {
this.channelServer.registerChannel(channelName, channel);
}
}
export class IPCServer<TContext = string> implements IChannelServer<TContext>, IRoutingChannelClient<TContext>, IConnectionHub<TContext>, IDisposable {
private channels = new Map<string, IServerChannel<TContext>>();
private _connections = new Set<Connection<TContext>>();
get connections(): Connection<TContext>[] {}
/**
* リモートクライアントからチャネルを取得します。
* ルーターを経由することで、どのクライアントを呼び出し、リッスンするかを指定できます。
* そうでなければ、ルーターなしで呼び出すとランダムなクライアントが選ばれ、ルーターなしでリッスンするとすべてのクライアントがリッスンされます。
*/
getChannel<T extends IChannel>(channelName: string, router: IClientRouter<TContext>): T;
getChannel<T extends IChannel>(channelName: string, clientFilter: (client: Client<TContext>) => boolean): T;
getChannel<T extends IChannel>(channelName: string, routerOrClientFilter: IClientRouter<TContext> | ((client: Client<TContext>) => boolean)): T {}
registerChannel(channelName: string, channel: IServerChannel<TContext>): void {
this.channels.set(channelName, channel);
this._connections.forEach(connection => {
connection.channelServer.registerChannel(channelName, channel);
});
}
}
サーバー#
- サービスは、さまざまなビジネスロジックが実行される実際の場所です。
IServerChannel
はサービスに対応し、ChannelServer
が呼び出すためのcall
およびlisten
メソッドを提供し、対応するサービスを呼び出してさまざまなビジネスロジックを実行します。これは実際にはサービスのラッパーです。IChannelServer
は、IMessagePassingProtocol
を介して渡されたリクエストをリッスンし、リクエストで指定されたchannelName
に基づいて対応するIServerChannel
を見つけて呼び出します。また、実行結果をクライアントに返すこともできます。- IPCServer は、IServerChannel を登録および取得するための一連のメソッドを提供し、ルーティングメカニズムを介して通信するクライアントを選択できます。
IMessagePassingProtocol
は、Uint8Array 形式でバイナリ情報を送信し、メッセージが受信されたときにイベントを介して上位レイヤーに通知する役割を担います。
クライアント#
- ビジネスコードは、ビジネスロジックを実装するコードを指し、
IChannel
が提供するメソッドを呼び出して IPC を開始する場合があります。 IChannel
は、ビジネスコードが IPC を開始するためのcall
およびlisten
メソッドを提供します。IPCClient
は、IChannel
を登録および取得するための一連のメソッドを提供します。IMessagePassingProtocol
は、サーバー側の対応物と同じ機能を持っています。
RPC プロトコル#
VSCode IPC の 2 番目のメカニズムは、RpcProtocol
に基づいており、レンダラープロセスと拡張ホストプロセス間の通信に使用されます。VSCode がブラウザ環境で実行されている場合、メインスレッドと拡張ホスト Web ワーカー間の通信に使用されます。
たとえば、ホストプロセスの初期化中にエラーが発生した場合、レンダラープロセスに通知されます。コードは次のとおりです。
const mainThreadExtensions = rpcProtocol.getProxy(MainContext.MainThreadExtensionService);
const mainThreadErrors = rpcProtocol.getProxy(MainContext.MainThreadErrors);
errors.setUnexpectedErrorHandler(err => {
const data = errors.transformErrorForSerialization(err);
const extension = extensionErrors.get(err);
if (extension) {
mainThreadExtensions.$onExtensionRuntimeError(extension.identifier, data);
} else {
mainThreadErrors.$onUnexpectedError(data);
}
});
IPC は、mainThreadExtensions
またはmainThreadError
のメソッドを呼び出すときに発生します。
メカニズムは次の図に示されています。
イベントシステム#
Visual Studio Code では、イベントが広範囲にわたって使用され、アプリケーションの異なる部分間の通信とデータ交換を促進します。イベントメカニズムはオブザーバーパターンに基づいており、アプリケーション内のオブジェクトが特定のアスペクトの変更や更新について通知を受け取るためにサブスクライブできるようにします。
VSCode のイベントメカニズムの主な特徴は次のとおりです。
- カスタムイベント: VSCode は、アプリケーション内のオブジェクトによって発生させることができるカスタムイベントを定義するためのシンプルで柔軟な方法を提供します。イベントは通常、
Event
基本クラスを拡張するクラスとして定義され、サブスクライバーに渡される必要なデータや引数を含みます。 - イベントエミッター:イベントを生成するオブジェクトは「イベントエミッター」として知られています。これらのオブジェクトは登録されたサブスクライバーのリストを含み、特定の条件が満たされたときにイベントを発生させます。たとえば、テキストエディタコンポーネントは、ユーザーがファイルを保存したときに「ドキュメントが保存されました」というイベントを発生させるかもしれません。
- イベントサブスクライバー:イベントの通知を受け取りたいオブジェクトは「イベントサブスクライバー」として知られています。これらのオブジェクトは、
on
メソッドを使用してイベントエミッターに登録し、イベントの名前とイベントを処理するためのコールバック関数を受け取ります。イベントが発生すると、すべての登録されたサブスクライバーがイベントとその関連データを受け取ります。 - 組み込みイベント: VSCode には、ワークスペースの変更、ウィンドウのフォーカス、エディタのコンテンツの変更など、アプリケーションのさまざまな側面について通知を提供する多くの組み込みイベントも含まれています。これらのイベントは、関連するサービスやオブジェクトの
on
メソッドを使用してサブスクライブできます。
// イベントエミッターのライフサイクルと設定に関するいくつかの側面
export interface EmitterOptions {
onFirstListenerAdd?: Function;
onFirstListenerDidAdd?: Function;
onListenerDidAdd?: Function;
onLastListenerRemove?: Function;
leakWarningThreshold?: number;
}
export class Emitter<T> {
// 渡された設定に基づいて、関連するライフサイクルメソッドが呼び出されます。
constructor(options?: EmitterOptions) {}
// このエミッターによって発生したイベントに他の人がサブスクライブできるようにします。
get event(): Event<T> {
// この場合、渡された設定に基づいて、関連するライフサイクルメソッドが呼び出されます。
}
// サブスクライバーにイベントを発生させます。
fire(event: T): void {}
// 関連するリスナーとキューをクリーンアップします。
dispose() {}
}
VS Code におけるイベントの使用は主に以下を含みます。
- イベントエミッターの登録。
- 外部に提供される定義されたイベント。
- 特定のタイミングでサブスクライバーにイベントを発生させる。
class WindowManager {
public static readonly INSTANCE = new WindowManager();
// イベントエミッターを登録します。
private readonly _onDidChangeZoomLevel = new Emitter<number>();
// このエミッターが他の人にサブスクライブできるイベントを取得します。
public readonly onDidChangeZoomLevel: Event<number> = this._onDidChangeZoomLevel.event;
public setZoomLevel(zoomLevel: number, isTrusted: boolean): void {
if (this._zoomLevel === zoomLevel) {
return;
}
this._zoomLevel = zoomLevel;
// zoomLevelが変更されたときにこのイベントを発生させます。
this._onDidChangeZoomLevel.fire(this._zoomLevel);
}
}
// 外部からグローバルインスタンスにアクセスするためのメソッドを提供します。
export function onDidChangeZoomLevel(callback: (zoomLevel: number) => void): IDisposable {
return WindowManager.INSTANCE.onDidChangeZoomLevel(callback);
}
import { onDidChangeZoomLevel } from 'vs/base/browser/browser';
let zoomListener = onDidChangeZoomLevel(() => {});
const instance = new WindowManager(opts);
instance.onDidChangeZoomLevel(() => {});
export abstract class Disposable implements IDisposable {
// 登録されたイベントエミッターを格納するためにSetを使用します。
private readonly _store = new DisposableStore();
constructor() {
trackDisposable(this);
}
// イベントエミッターを処理します。
public dispose(): void {
markTracked(this);
this._store.dispose();
}
// Disposableを登録します。
protected _register<T extends IDisposable>(t: T): T {
if ((t as unknown as Disposable) === this) {
throw new Error('自分自身にDisposableを登録することはできません!');
}
return this._store.add(t);
}
}
Dispose パターンは主にリソース管理に使用されます。これには、オブジェクトによって保持されているメモリなどのリソースを解放することが含まれます。
export interface IDisposable {
dispose(): void;
}
export class DisposableStore implements IDisposable {
private _toDispose = new Set<IDisposable>();
private _isDisposed = false;
// 登録されたすべてのDisposableを解放し、解放済みとしてマークします。
// このオブジェクトに今後追加されるすべてのDisposableは、'add'メソッドで解放されます。
public dispose(): void {
if (this._isDisposed) {
return;
}
markTracked(this);
this._isDisposed = true;
this.clear();
}
// 登録されたすべてのDisposableを解放しますが、解放済みとしてマークしません。
public clear(): void {
this._toDispose.forEach(item => item.dispose());
this._toDispose.clear();
}
// Disposableを追加します。
public add<T extends IDisposable>(t: T): T {
markTracked(t);
if (this._isDisposed) {
// エラーを発生させる
} else {
this._toDispose.add(t);
}
return t;
}
}
export class Scrollable extends Disposable {
private _onScroll = this._register(new Emitter<ScrollEvent>());
public readonly onScroll: Event<ScrollEvent> = this._onScroll.event;
private _setState(newState: ScrollState): void {
const oldState = this._state;
if (oldState.equals(newState)) {
return;
}
this._state = newState;
// 状態が変更されたときにイベントを発生させます。
this._onScroll.fire(this._state.createScrollEvent(oldState));
}
}
依存性注入#
VS Code では、さまざまなモジュールが呼び出すための API を提供する多くのサービスがあります。依存関係は、クラスのコンストラクタ内でデコレータで注釈されたパラメータとして宣言されます。呼び出し元は、このサービスを明示的にインスタンス化する必要はなく、これらの依存サービスは、作成時に自動的に作成され、呼び出し元に渡されます。異なるサービスは互いに依存することもできます。これにより、プログラムの結合が大幅に減少し、メンテナンス性が向上します。
このデカップリングアプローチは依存性注入と呼ばれ、制御の反転を実装する 1 つの方法です。つまり、オブジェクト(またはエンティティ)の依存関係は、他のオブジェクトである可能性があり、外部から注入され、オブジェクト内で自己実装された依存関係のインスタンス化プロセスを回避します。
まず、クラスを定義し、その依存関係をコンストラクタ内で宣言する必要があります。
class MyClass {
constructor(
@IAuthService private readonly authService: IAuthService,
@IStorageService private readonly storageService: IStorageService,
) {
}
}
コンストラクタ内の@IAuthService
と@IStorageService
は 2 つのデコレータであり、JavaScript の提案に属し、TypeScript では実験的な機能です。これらはクラス宣言、メソッド、アクセサ、パラメータに添付できます。このコードでは、これらはコンストラクタパラメータauthService
とstorageService
に添付されており、パラメータデコレータです。パラメータデコレータは、実行時に呼び出され、3 つの引数が渡されます。
- 静的メンバーの場合、クラスコンストラクタが最初の引数として渡され、インスタンスメンバーの場合、クラスのプロトタイプが最初の引数として渡されます。
- メンバーの名前。
- 関数のパラメータリスト内のパラメータのインデックス。
サービスのデコレータとインターフェース定義は通常次のようになります。
export const IAuthService = createDecorator<IAuthService>('AuthService');
export interface IAuthService {
readonly id: string;
readonly nickName: string;
readonly firstName: string;
readonly lastName: string;
requestService: IRequestService;
}
サービスインターフェースには具体的な実装が必要であり、他のサービスに依存することもできます。
class AuthServiceImpl implements IAuthService {
constructor(
@IRequestService public readonly requestService IRequestService,
){
}
public async getUserInfo() {
const { id, nickName, firstName } = await getUserInfo();
this.id = id;
this.nickName = nickName;
this.firstName = firstName;
//...
}
}
サービスコレクションも必要で、これはサービスのグループを保持し、それらからコンテナを作成するために使用されます。
export class ServiceCollection {
private _entries = new Map<ServiceIdentifier<any>, any>();
constructor(...entries: [ServiceIdentifier<any>, any][]) {
for (let [id, service] of entries) {
this.set(id, service);
}
}
set<T>(id: ServiceIdentifier<T>, instanceOrDescriptor: T | SyncDescriptor<T>): T | SyncDescriptor<T> {
const result = this._entries.get(id);
this._entries.set(id, instanceOrDescriptor);
return result;
}
forEach(callback: (id: ServiceIdentifier<any>, instanceOrDescriptor: any) => any): void {
this._entries.forEach((value, key) => callback(key, value));
}
has(id: ServiceIdentifier<any>): boolean {
return this._entries.has(id);
}
get<T>(id: ServiceIdentifier<T>): T | SyncDescriptor<T> {
return this._entries.get(id);
}
}
前述のように、オブジェクトはコンテナによって自動的にインスタンス化されます。ただし、VSCode では、いくつかのサービスは他のサービスに依存せず(ログサービスなど)、他のサービスにのみ依存しているため、手動でインスタンス化してコンテナに登録できます。この例では、AuthServiceImpl
はIRequestService
に依存しており、SyncDescriptor
でラップしてサービスコレクションに保存する必要があります。
const services = new ServiceCollection();
const logService = new LogService();
services.set(ILogService, logService);
services.set(IAuthService, new SyncDescriptor(AuthServiceImpl));
SyncDescriptor
は、コンテナによってインスタンス化される必要があるデスクリプタをラップするために使用されるオブジェクトです。これは、オブジェクトのコンストラクタ関数とその静的パラメータ(コンストラクタに直接渡す必要がある)を保持します。
export class SyncDescriptor<T> {
readonly ctor: any;
readonly staticArguments: any[];
readonly supportsDelayedInstantiation: boolean;
constructor(ctor: new (...args: any[]) => T, staticArguments: any[] = [], supportsDelayedInstantiation: boolean = false) {
this.ctor = ctor; // サービスのコンストラクタ
this.staticArguments = staticArguments;
this.supportsDelayedInstantiation = supportsDelayedInstantiation;
}
}
これで、コンテナを作成し、サービスを登録できます。VSCode では、コンテナはInstantiationService
です。
const instantiationService = new InstantiationService(services, true);
InstantiationService
は依存性注入のコアです。サービスをコンテナに登録した後、プログラムのエントリポイントを手動でインスタンス化する必要があります。VSCode では、これがCodeApplication
です。コンテナ(instantiationService
)は、これらのオブジェクト間の依存関係を保持しているため、CodeApplication
もコンテナを使用してインスタンス化する必要があります。
// ここで、2番目と3番目のパラメータはCodeApplicationコンストラクタの静的パラメータであり、手動で渡す必要があります。
instantiationService.createInstance(CodeApplication, mainIpcServer, instanceEnvironment).startup();
サービスインスタンスを手動で取得するために、instantiationService.invokeFunction
メソッドを呼び出し、コールバック関数を渡すこともできます。コールバック関数はアクセサをパラメータとして受け取り、指定されたサービスがアクセサを介して取得されると、コンテナはその依存サービスを自動的に分析し、インスタンス化してサービスインスタンスを返します。
instantiationService.invokeFunction(accessor => {
const logService = accessor.get(ILogService);
const authService = accessor.get(IAuthService);
});
instantiationService
には、子コンテナを作成できるメンバーメソッドcreateChild
があります。依存関係をより明確に定義するために、子コンテナは親コンテナのサービスインスタンスにアクセスできますが、親コンテナは子コンテナ内のインスタンスにアクセスできません。必要なサービスインスタンスが子コンテナに存在しない場合、instantiationService._parent
を呼び出して親コンテナへの参照を取得し、依存関係を上に向かって再帰的に検索します。
参考文献#
Electron (stephanosterburg.com)
VSCode 技術解析 | VSCode 技術解析と実践 (codeteenager.github.io)
Visual Studio Code / Egret Wing 技術アーキテクチャ:基礎・Chen's Blog (imzc.me)
VSCode ソースコード解読:イベントシステム設計 | 被削除のフロントエンド遊び場 (godbasin.github.io)
https://zhuanlan.zhihu.com/p/60228431
https://zhuanlan.zhihu.com/p/96041706
vscode ソースコード解析 - 依存性注入 - 知乎 (zhihu.com)