最適な Visual Studio 拡張モデルを選択する

Visual Studio は VSSDK、Community Toolkit、VisualStudio.Extensibility の 3 つの主要な拡張モデルを使用して拡張できます。 この記事では、それぞれの長所と短所について説明します。 簡単な例を使用して、モデル間のアーキテクチャとコードの違いを明らかにします。

VSSDK

VSSDK (Visual Studio SDK) は、Visual Studio Marketplace にあるほとんどの拡張機能の基になっているモデルです。 Visual Studio 自体も、このモデルに基づいて構築されています。 最も完成度が高く強力ですが、正しく学習して使用するのが最も難しいモデルでもあります。 VSSDK を使用する拡張機能は、Visual Studio 自体と同じプロセスで実行されます。 Visual Studio と同じプロセスで実行されるということは、アクセス違反、無限ループなどの問題が発生した拡張機能が Visual Studio をクラッシュさせたり、ハングアップさせたり、カスタマー エクスペリエンスを低下させたり可能性があるということです。 また、拡張機能は Visual Studio と同じプロセスで実行されるため、.NET Framework を使用してのみ構築できます。 拡張機能開発者は、VSSDK を使用しても、.NET 5 以降を使用するライブラリを利用したり組み込んだりはできません。

VSSDK の API は、Visual Studio 自体が改良され進化するにつれて、長年にわたって集約されてきました。 1 つの拡張機能でも、レガシーの COM ベースの API を扱ったり、DTE のシンプルさに惑わされたり、MEF のインポートやエクスポートを操作したりする場面に遭遇することがあります。 例として、ファイル システムからテキストを読み取り、エディター内の現在のアクティブなドキュメントの先頭に挿入する、拡張機能を作成してみましょう。 次のスニペットは、VSSDK ベースの拡張機能でコマンドが呼び出されたときに処理するコードを示しています。

private void Execute(object sender, EventArgs e)
{
    var textManager = package.GetService<SVsTextManager, IVsTextManager>();
    textManager.GetActiveView(1, null, out IVsTextView activeTextView);

    if (activeTextView != null && activeTextView is IVsTextViewEx nativeView)
    {
        ErrorHandler.ThrowOnFailure(nativeView.GetWindowFrame(out object frameValue));

        IComponentModel2 compService = package.GetService<SComponentModel, IComponentModel2>();
        IVsEditorAdaptersFactoryService editorAdapter = compService.GetService<IVsEditorAdaptersFactoryService>();
        var wpfTextView = editorAdapter?.GetWpfTextView(activeTextView);

        if (frameValue is IVsWindowFrame frame && wpfTextView != null)
        {
            var fileText = File.ReadAllText(Path.Combine(Path.GetTempPath(), "test.txt"));
            wpfTextView.TextBuffer?.Insert(0, fileText);
        }
    }
}

さらに、UI での配置場所や関連するテキストなど、コマンドの構成を定義する .vsct ファイルも提供する必要があります。

<Commands package="guidVSSDKPackage">
    <Groups>
        <Group guid="guidVSSDKPackageCmdSet" id="MyMenuGroup" priority="0x0600">
        <Parent guid="guidSHLMainMenu" id="IDM_VS_MENU_TOOLS" />
        </Group>
    </Groups>

    <Buttons>
        <Button guid="guidVSSDKPackageCmdSet" id="InsertTextCommandId" priority="0x0100" type="Button">
        <Parent guid="guidVSSDKPackageCmdSet" id="MyMenuGroup" />
        <Icon guid="guidImages" id="bmpPic1" />
        <Strings>
            <ButtonText>Invoke InsertTextCommand (Unwrapped Community Toolkit)</ButtonText>
        </Strings>
        </Button>
        <Button guid="guidVSSDKPackageCmdSet" id="cmdidVssdkInsertTextCommand" priority="0x0100" type="Button">
        <Parent guid="guidVSSDKPackageCmdSet" id="MyMenuGroup" />
        <Icon guid="guidImages1" id="bmpPic1" />
        <Strings>
            <ButtonText>Invoke InsertTextCommand (VSSDK)</ButtonText>
        </Strings>
        </Button>
    </Buttons>

    <Bitmaps>
        <Bitmap guid="guidImages" href="Resources\InsertTextCommand.png" usedList="bmpPic1, bmpPic2, bmpPicSearch, bmpPicX, bmpPicArrows, bmpPicStrikethrough" />
        <Bitmap guid="guidImages1" href="Resources\VssdkInsertTextCommand.png" usedList="bmpPic1, bmpPic2, bmpPicSearch, bmpPicX, bmpPicArrows, bmpPicStrikethrough" />
    </Bitmaps>
</Commands>

サンプルからわかるように、このコードは直感的ではなく、.NET に精通している人でも簡単には理解できない可能性があります。 習得すべき概念は数多くあり、アクティブなエディターのテキストにアクセスするための API パターンは時代遅れです。 ほとんどの拡張機能開発者にとって、VSSDK の拡張機能はオンラインのソース コードをコピーして貼り付けることから作成されることが多く、これが原因でデバッグが難しくなり、試行錯誤を重ねることになり、フラストレーションが溜まる可能性があります。 多くの場合、VSSDK の拡張機能は、拡張機能の目標を達成するための最も簡単な方法ではない可能性があります (ただし、それが唯一の選択肢である場合もあります)。

コミュニティ ツールキット

Community Toolkit は、Visual Studio のためのオープンソースのコミュニティベースの拡張モデルであり、より簡単な開発エクスペリエンスを実現するために VSSDK をラップしています。 VSSDK に基づいているため、VSSDK と同じ制限 (.NET Framework しか使用できない、Visual Studio の他の部分から分離できないなど) を受けます。 ファイル システムから読み取ったテキストを挿入する拡張機能の例を引き続き使用すると、Community Toolkit を使用して記述される拡張機能は次のようになります。

protected override async Task ExecuteAsync(OleMenuCmdEventArgs e)
{
    DocumentView docView = await VS.Documents.GetActiveDocumentViewAsync();
    if (docView?.TextView == null) return;
    var fileText = File.ReadAllText(Path.Combine(Path.GetTempPath(), "test.txt"));
    docView.TextBuffer?.Insert(0, fileText);
}

完成したコードは、VSSDK よりシンプルで直感的なものになりました。 行数が大幅に減っただけでなく、完成したコードは理にかなったものになっています。 SVsTextManagerIVsTextManager の違いを理解する必要はありません。 API は、.NET に近い外観と操作性になり、一般的な名前付け規則と非同期パターンを採用しており、一般的な操作を優先しています。 ただし、Community Toolkit はまだ既存の VSSDK モデルに基づいて構築されているため、基盤となる構造の制約が見て取れます。 たとえば、.vsct ファイルは依然として必要です。 Community Toolkit は API の簡素化に大きく貢献していますが、VSSDK の制限に縛られており、拡張機能の構成をシンプルにする方法がありません。

VisualStudio.Extensibility

VisualStudio.Extensibility は、拡張機能が Visual Studio のメイン プロセス外で実行される新しい拡張モデルです。 この基本的なアーキテクチャの変更により、VSSDK や Community Toolkit では不可能だった新しいパターンと機能が拡張機能で利用できるようになりました。 VisualStudio.Extensibility では、一貫性があり使いやすいまったく新しい API セットが提供され、.NET をターゲットにする拡張機能を作成でき、拡張機能から発生するバグが Visual Studio の他の部分から分離されます。ユーザーは Visual Studio を再起動することなく拡張機能をインストールできます。 ただし、新しいモデルは新しい基盤となるアーキテクチャ上に構築されているため、VSSDK や Community Toolkit ほどの幅広い機能はまだ備えていません。 そのギャップを埋めるために、VisualStudio.Extensibility 拡張機能をプロセス内で実行でき、これにより、引き続き VSSDK API を使用できます。 ただし、そうすると、拡張機能は Visual Studio と同じプロセスを共有することになり、Visual Studio が .NET Framework に基づいているため、.NET Framework しかターゲットにできなくなります。

ファイルからテキストを挿入する拡張機能の例を引き続き使用すると、VisualStudio.Extensibility を使用した場合、コマンドの処理は次のように記述されます。

public override async Task ExecuteCommandAsync(IClientContext context, CancellationToken cancellationToken)
{
    var activeTextView = await context.GetActiveTextViewAsync(cancellationToken);
    if (activeTextView is not null)
    {
        var editResult = await Extensibility.Editor().EditAsync(batch =>
        {
            var fileText = File.ReadAllText(Path.Combine(Path.GetTempPath(), "test.txt"));

            ITextDocumentEditor editor = activeTextView.Document.AsEditable(batch);
            editor.Insert(0, fileText);
        }, cancellationToken);
                
    }
}

コマンドの配置やテキストなどを構成するために .vsct ファイルを提供する必要はなくなりました。 代わりに、この構成はコードで行います。

public override CommandConfiguration CommandConfiguration => new("%VisualStudio.Extensibility.Command1.DisplayName%")
{
    Icon = new(ImageMoniker.KnownValues.Extension, IconSettings.IconAndText),
    Placements = [CommandPlacement.KnownPlacements.ExtensionsMenu],
};

このコードは理解しやすく、追跡するのも簡単です。 ほとんどの場合、この拡張機能は、エディターだけで IntelliSense の助けを借りて、コマンドの構成であっても記述できます。

Visual Studio のさまざまな拡張モデルの比較

このサンプルからわかるように、VisualStudio.Extensibility を使用すると、コマンド ハンドラー内のコードの行数が Community Toolkit よりも多くなっています。 Community Toolkit は、VSSDK を使用して拡張機能を構築する上での使いやすさを追求した優れたラッパーです。ただし、すぐにわからない落とし穴がいくつかあり、それが VisualStudio.Extensibility の開発につながりました。 Community Toolkit でも記述しやすく理解しやすいコードになるように見える場合に、移行と必要性を理解するために、サンプルを確認し、コードのより深い層で何が起きているかを比較してみましょう。

このサンプルのコードをすばやくアンラップすれば、VSSDK 側で実際に何が呼び出されているのかがわかります。 ここでは、VSSDK が必要とする多くの詳細な実装は Community Toolkit によってうまく隠されているため、コマンド実行のスニペットにのみ注目することにします。 しかし、基盤となるコードを見れば、このシンプルさには代償が伴うことがわかるでしょう。 シンプルさのために、基盤となる詳細の一部が隠されており、それが予期しない動作やバグ、さらにはパフォーマンスの問題やクラッシュにつながる可能性があります。 次のコード スニペットでは、Community Toolkit のコードをアンラップして VSSDK の呼び出しを示しています。

private void Execute(object sender, EventArgs e)
{
    package.JoinableTaskFactory.RunAsync(async delegate
    {
        var textManager = await package.GetServiceAsync<SVsTextManager, IVsTextManager>();
        textManager.GetActiveView(1, null, out IVsTextView activeTextView);

        if (activeTextView != null && activeTextView is IVsTextViewEx nativeView)
        {
            await package.JoinableTaskFactory.SwitchToMainThreadAsync();
            ErrorHandler.ThrowOnFailure(nativeView.GetWindowFrame(out object frameValue));

            IComponentModel2 compService = package.GetService<SComponentModel, IComponentModel2>();
            IVsEditorAdaptersFactoryService editorAdapter = compService.GetService<IVsEditorAdaptersFactoryService>();
            var wpfTextView = editorAdapter?.GetWpfTextView(activeTextView);

            if (frameValue is IVsWindowFrame frame && wpfTextView != null)
            {
                var fileText = File.ReadAllText(Path.Combine(Path.GetTempPath(), "test.txt"));
                wpfTextView.TextBuffer?.Insert(0, fileText);    
            }
        }
    });
}

ここで考慮すべきいくつかの問題があり、それらはすべてスレッド処理と非同期コードに関連しています。 各問題について詳しく見ていきましょう。

非同期 API と非同期コード実行の比較

最初に注目すべき点は、Community Toolkit の ExecuteAsync メソッドが、VSSDK ではラップされた非同期のファイア アンド フォーゲット呼び出しであることです。

package.JoinableTaskFactory.RunAsync(async delegate
{
  …
});

VSSDK 自体は、コア API の観点からは、非同期コマンド実行をサポートしていません。 つまり、コマンドが実行されたとき、VSSDK にはコマンド ハンドラー コードをバックグラウンド スレッドで実行し、その完了を待って、ユーザーを実行結果と共に元の呼び出しコンテキストに戻す方法がありません。 そのため、Community Toolkit の ExecuteAsync API は構文的には非同期ですが、実際には非同期実行ではありません。 また、これはファイア アンド フォーゲット方式の非同期実行であるため、前回の呼び出しの完了を待つことなく、ExecuteAsync を何度も呼び出すことができます。 Community Toolkit は、拡張機能開発者が一般的なシナリオを実装する方法を発見するのに役立つという点では、優れたエクスペリエンスをもたらしますが、最終的には VSSDK の根本的な問題を解決することはできません。 この場合、基盤となる VSSDK API は非同期ではなく、Community Toolkit が提供するファイア アンド フォーゲットのヘルパー メソッドは、非同期の処理とクライアント状態の操作を適切に扱うことができません。これにより、デバッグが難しい問題が隠される可能性があります。

UI スレッドとバックグラウンド スレッドの比較

Community Toolkit のこのラップされた非同期呼び出しのもう 1 つの問題は、コード自体が依然として UI スレッドから実行されることです。UI のフリーズを避けたい場合は、拡張機能開発者がバックグラウンド スレッドへの正しい切り替え方法を自分で見つける必要があります。 Community Toolkit が VSSDK のノイズや余分なコードを隠してくれるものの、それでも Visual Studio の複雑なスレッド処理を理解する必要があります。 VS のスレッド処理でまず知っておくべきことの 1 つは、すべてをバックグラウンド スレッドから実行できるわけではないということです。 つまり、すべてがスレッド セーフであるとは限らず、特に COM コンポーネントへの呼び出しはそうではありません。 そのため、上記の例では、メイン (UI) スレッドに切り替えるための呼び出しがあることがわかります。

await package.JoinableTaskFactory.SwitchToMainThreadAsync();
ErrorHandler.ThrowOnFailure(nativeView.GetWindowFrame(out object frameValue));

もちろん、この呼び出しの後にバックグラウンド スレッドに切り替えることができます。 ただし、Community Toolkit を使用する拡張機能開発者は、自分のコードがどのスレッドで実行されているかに注意を払い、UI をフリーズさせるリスクがあるかどうかを判断する必要があります。 Visual Studio でのスレッド処理は正しく行うのが難しく、デッドロックを回避するには JoinableTaskFactory を適切に使用する必要があります。 スレッド処理を正しく扱うコードを記述することは難しく、社内の Visual Studio エンジニアにとっても、常にバグの原因となってきました。 一方、VisualStudio.Extensibility では、拡張機能をプロセス外で実行し、エンド ツー エンドで非同期 API に依存することで、この問題を完全に回避しています。

シンプルな API とシンプルな概念の比較

Community Toolkit は VSSDK の複雑な部分の多くを隠しているため、拡張機能開発者にシンプルであるという誤った感覚を与える可能性があります。 同じサンプル コードを引き続き見ていきましょう。 Visual Studio 開発におけるスレッド処理の要件を知らない開発者であれば、自分のコードが常にバックグラウンド スレッドから実行されていると思い込むこともあり得ます。 ファイルからテキストを読み取るための呼び出しが同期的であることを問題視しないでしょう。 バックグラウンド スレッドにあれば、対象のファイルが大きくても UI はフリーズしないはずだと考えます。 しかし、コードを VSSDK にアンラップすると、実際はそうではないことがわかります。 そのため、Community Toolkit の API は確かに理解しやすく、記述しやすく見えますが、VSSDK に紐付いているため、VSSDK の制限を受けることになります。 このシンプルさのため、重要な概念が見落とされ、その点を拡張機能開発者が理解していないと、かえって問題を引き起こすことになります。 VisualStudio.Extensibility では、アウトプロセス モデルと非同期 API を基盤とすることで、メインスレッドの依存関係に起因する多くの問題を回避しています。 プロセス外で実行するとスレッドが最も簡素化されますが、これらの利点の多くはプロセス内で実行される拡張機能にも当てはまります。 たとえば、VisualStudio.Extensibility のコマンドは常にバックグラウンド スレッドで実行されます。 VSSDK API との連携には依然としてスレッド処理の仕組みに関する深い知識が必要ですが、少なくともこの例のように、誤って UI がフリーズすることは避けられます。

比較チャート

前のセクションで詳しく説明した内容をまとめると、次の表のようになります。

VSSDK コミュニティ ツールキット VisualStudio.Extensibility
ランタイム サポート .NET Framework .NET Framework .NET
Visual Studio からの分離
シンプルな API
非同期実行と API
VS シナリオの幅広さ
再起動なしでインストール可能
VS 2019 以下をサポート

Visual Studio の拡張機能のニーズにこの比較を適用できるように、いくつかのサンプル シナリオと、どのモデルを使用するかについての推奨事項を示します。

  • 私は Visual Studio での拡張機能開発は初めてで、高品質な拡張機能を作成するための最も簡単な開始エクスペリエンスを求めています。 サポート対象は Visual Studio 2022 以上のみで構いません。
    • この場合は、VisualStudio.Extensibility を使用することをお勧めします。
  • 私は Visual Studio 2022 以上をターゲットにした拡張機能を書きたいと思っています。しかし、VisualStudio.Extensibility は私が必要とするすべての機能をすべて fサポートしているわけではありません。
    • この場合は、VisualStudio.Extensibility と VSSDK を組み合わせたハイブリッド方式を採用することをお勧めします。 プロセス内で実行される VisualStudio.Extensibility 拡張機能を作成することで、VSSDK または Community Toolkit API にアクセスできます。
  • 私は既存の拡張機能があり、Visual Studio の新しいバージョンをサポートするように更新する必要があります。できるだけ多くのバージョンをサポートしたいと考えています。
    • VisualStudio.Extensibility は Visual Studio 2022 以上のみをサポートしているため、この場合は VSSDK または Community Toolkit が最適です。
  • 私は既存の拡張機能があり、.NET の利点を活かし、再起動なしでインストールできるように、VisualStudio.Extensibility に移行したいと考えています。
    • このシナリオは少し複雑です。なぜなら、VisualStudio.Extensibility は古いバージョンの Visual Studio をサポートしていないからです。
      • 既存の拡張機能が Visual Studio 2022 のみをサポートしていて、必要な API がすべて揃っている場合は、VisualStudio.Extensibility を使用するように拡張機能を書き直すことをお勧めします。 しかし、拡張機能が VisualStudio.Extensibility でまだサポートされていない API を必要とする場合は、プロセス内で実行される VisualStudio.Extensibility 拡張機能を作成して、VSSDK API にアクセスできるようにします。 VisualStudio.Extensibility のサポートが追加され、拡張機能がプロセス外で実行されるようになれば、VSSDK API を使用する必要はなくなります。
      • VisualStudio.Extensibility をサポートしていない Visual Studio の下位バージョンを拡張機能でサポートする必要がある場合は、コードベースのリファクタリングを行うことをお勧めします。 Visual Studio の各バージョンで共有可能な共通コードをすべて独立したライブラリに分離し、異なる拡張モデルをターゲットにする個別の VSIX プロジェクトを作成します。 たとえば、拡張機能が Visual Studio 2019 と Visual Studio 2022 の両方をサポートする必要がある場合は、次のようなプロジェクト構造を採用できます。
        • MyExtension-VS2019 (Visual Studio 2019 をターゲットにする VSSDK ベースの VSIX コンテナー プロジェクト)
        • MyExtension-VS2022 (Visual Studio 2022 をターゲットにする VSSDK+VisualStudio.Extensibility ベースの VSIX コンテナー プロジェクト)
        • VSSDK-CommonCode (VSSDK を介して Visual Studio API を呼び出すために使用される共通ライブラリ。どちらの VSIX プロジェクトもこのライブラリを参照してコードを共有できます)
        • MyExtension-BusinessLogic (拡張機能のビジネス ロジックに関連するすべてのコードを含む共通ライブラリ。どちらの VSIX プロジェクトもこのライブラリを参照してコードを共有できます)

次のステップ

開発者が新しい拡張機能を作成したり既存の拡張機能を強化したりする際は、VisualStudio.Extensibility から始め、サポートされていないシナリオに直面した場合に VSSDK または Community Toolkit を使用することをお勧めします。 VisualStudio.Extensibility を使い始めるには、このセクションのドキュメントを参照してください。 また、VSExtensibility の GitHub リポジトリサンプルを参照したり、問題を報告したりすることもできます。