tafuji's blog

C#, Xamarin, Azure DevOps を中心に書いています。

PCL プロジェクトを .NET Standard 2.0 プロジェクトに変換する Visual Studio 拡張機能を作った話

Qiita より転載

はじめに

本記事は、Xamarin Advent Calendar 2018 の 23日目の記事です。今年も誕生日の節目に記事を書くことができました。ありがとうございます。

本記事は、PCL プロジェクトを .NET Standard のプロジェクトに変換するための Visual Studio拡張機能PCL TO .NET Standard)を作成したときの技術的なポイントを記載したメモです。

なぜ拡張機能を作ったのか?

Xamarin で、PCL を使って実装されていたプロジェクトを .NET Standard 対応させるときに、手動でプロジェクトファイルを書き換えるのが面倒だったので作成しました。 Visual Studio for Mac には、Mutatio というツールがあるのですが、Visual Studio 2017 に対応したツールがなかっことも理由の一つです。

拡張機能の概要

PCL プロジェクトを右クリックして、[Convert to .NET Standard] を選択すると、PCL プロジェクトが .NET Standard プロジェクトに変換されます。

Demo

具体的には、以下の処理を行っています。

  • PCL プロジェクト関係のファイルをバックアップフォルダにコピーする
    • プロジェクトファイル、AssemblyInfo.cs ファイル、package.config ファイルなど
  • .NET Standard 2.0 のプロジェクトファイルを作成する
  • PCL プロジェクトが参照していた NuGet パッケージ、プロジェクト参照を .NET Standard 2.0 のプロジェクトの参照に追加する
  • 変換元の PCL プロジェクト関係のファイルを削除する
    • プロジェクトファイル、AssemblyInfo.cs ファイル、package.config ファイルなど
  • プロジェクトをリロードする

技術的なメモ

拡張機能を作成したきのポイントを記載しておきます。

VSCT ファイル

Visual Studio Command Table ファイルにメニューコマンドの配置に関する設定を記述します。 まず、コマンドメニューのグループとそのコマンドを定義しています。

<!-- This is the guid used to group the menu commands together -->
<GuidSymbol name="guidConvertCommandPackageCmdSet" value="{6553808a-4ee1-4e84-b99c-02f13a2e3246}">
    <IDSymbol name="MyMenuGroup" value="0x1020" />
    <IDSymbol name="ConvertCommandId" value="0x0100" />
</GuidSymbol>

コマンドを Visual Studio のメニューのどこに配置するかの設定

MyMenuGroup というコマンドグループがどこに配置されるかを以下のように設定しています。

<CommandPlacements>
    <CommandPlacement guid="guidConvertCommandPackageCmdSet" id="MyMenuGroup" priority="0xFFFF">
        <Parent guid="guidSHLMainMenu" id="IDM_VS_CTXT_PROJNODE"/>
    </CommandPlacement>
</CommandPlacements>

ポイントは、Prant 要素で、コマンドグループをどこに配置するかを指定します。guidSHLMainMenu は、Visual Studio のメニューバーの GUID 値で、idIDM_VS_CTXT_PROJNODE という値は、プロジェクトのコンテキストメニューを表します。つまり、拡張機能のメニューコマンドは、Visual Studio のプロジェクトのコンテキストメニューに配置するという意味になります。

メニューの項目の表示・非表示を切り替える

右クリックしたプロジェクトが PCL プロジェクトではないときには、メニューを非表示にしたいので、CommandFlag 要素の設定で、表示を動的にする設定値とデフォルトでは非表示とする設定を追加しておきます。

<Button guid="guidConvertCommandPackageCmdSet" id="ConvertCommandId" priority="0x0100" type="Button">
    <Parent guid="guidConvertCommandPackageCmdSet" id="MyMenuGroup" />
    <Icon guid="commandImage" id="icon1" />
    <CommandFlag>DynamicVisibility</CommandFlag>
    <CommandFlag>DefaultInvisible</CommandFlag>
    <Strings>
        <ButtonText>Convert to .NET Standard</ButtonText>
    </Strings>
</Button>

Command クラス

Visual Studio の項目テンプレートから作成したものに追記をしたものについて記載します。

IVsPackageInstallerServices のサービスの取得

PCL プロジェクトにインストールされている NuGet パッケージを取得するために、NuGet.VisualStudio という NuGet パッケージを利用します。 このパッケージ内の IVsPackageInstallerServices インタフェースを利用する必要があるため、Command クラスの中でサービスを取得しておきます。

プロジェクトテンプレートで作成された InitializeAsync メソッドの中で、AsyncPackage クラスの GetServiceAsync メソッドを利用して取得しておきます。

public static async Task InitializeAsync(AsyncPackage package)
{
    // ......
    var componentModel = await package.GetServiceAsync(typeof(SComponentModel)) as IComponentModel;
    // ......
}

BeforeQueryStatus イベントの登録

メニューを表示する前に呼び出されるイベントハンドラを登録します。このイベントハンドラ内で、選択されたプロジェクトが PCL の時にメニューを表示するという処理を実装しています。

private ConvertCommand(AsyncPackage package, OleMenuCommandService commandService)
{
    // ......
    var menuItem = new OleMenuCommand(this.Execute, menuCommandID);
    menuItem.BeforeQueryStatus += new EventHandler(OnBeforeQueryStatus);
}

イベントハンドラは、以下のように実装しています。

private void OnBeforeQueryStatus(object sender, EventArgs e)
{
    ThreadHelper.ThrowIfNotOnUIThread();
    if (Dte == null)
        return;
    Project project = (Project)((object[])Dte.ActiveSolutionProjects)[0]; // Get the active project.
    if (project == null)
        return;
    OleMenuCommand cmd = (OleMenuCommand)sender;
    cmd.Visible = project.IsPclProject(Solution); // Custom extension method.
    cmd.Enabled = !Dte.OnBuilding() && !Dte.OnDebugging();
}

OleMenuCommand の Visible プロパティを true に設定すると、メニューが表示状態になります。 また、Enabled プロパティは、メニューが有効かどうかを設定することができ、ビルド中、デバッグ中にはメニューを選択できないようにしています。 OnBuilding, OnDebuging メソッドは拡張メソッドで以下のようになっています。

internal static class DteExtension
{
    public static bool OnBuilding(this DTE dte)
    {
        Solution solution = dte.Solution;
        SolutionBuild solutionBuild = solution.SolutionBuild;
        vsBuildState buildState = solutionBuild.BuildState;
        return buildState == vsBuildState.vsBuildStateInProgress;
    }

    public static bool OnDebugging(this DTE dte)
    {
        Debugger debugger = dte.Debugger;
        return debugger.CurrentMode != dbgDebugMode.dbgDesignMode;
    }
}

project.IsPclProject(Solution) は、独自に作成した拡張メソッドです。次の「当該プロジェクトが PCL かどうかを判定する」に説明を記載します。

当該プロジェクトが PCL かどうかを判定する

旧型式の Visual Studio のプロジェクトファイルには、プロジェクトファイルの中に ProjectTypeGuids というプロジェクトの種類を表す値が定義されたタグがあり、Visual Studio は、この GUID 値によってプロジェクトの種類を識別しています。以下に PCL プロジェクトのプロジェクトファイルの例を示します。

<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
  <!-- 中略 -->
  <PropertyGroup>
    <!-- 中略 -->
    <ProjectTypeGuids>{786C830F-07A1-408B-BD7F-6EE04809D6DB};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}</ProjectTypeGuids>
    <!-- 中略 -->
  </PropertyGroup>
  <!-- 以下省略 -->

C# の PCL のプロジェクトタイプを表す GUID 値は、以下のようになります。

{786C830F-07A1-408B-BD7F-6EE04809D6DB};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}

拡張機能では、Visual Studio 上で変換しようとするプロジェクトの ProjectTypeGuids の値を取得して、そのプロジェクトが PCL かどうかを判断しています。 ProjectTypeGuids の値は、IVsSolution インタフェースの GetProjectOfUniqueName を起点に取得しています。

public static bool IsPclProject(this Project project, IVsSolution solution)
{
    solution.GetProjectOfUniqueName(project.UniqueName, out IVsHierarchy hierarchy);
    IVsAggregatableProjectCorrected ap = hierarchy as IVsAggregatableProjectCorrected;
    string projTypeGuids = null;
    ap?.GetAggregateProjectTypeGuids(out projTypeGuids);
    return projTypeGuids == "{786C830F-07A1-408B-BD7F-6EE04809D6DB};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}";
}

プロジェクトにインストールされた NuGet パッケージを取得する

プロジェクトにインストールされた NuGet パッケージを直接取得することはできないので、ソリューションにインストールされた NuGet パッケージのリストを取得して、パッケージがプロジェクトにインストールされているかどうかを判定しています。

  • GetInstalledPackages メソッド
    • ソリューションで参照されている NuGet パッケージを取得します
  • IsPackageInstalledEx メソッド
    • プロジェクトにパッケージがインストールされているかどうかを返します
var nugetpackages = vsPackageInstallerServices.GetInstalledPackages().ToList();
foreach (var item in nugetpackages)
{
    if (vsPackageInstallerServices.IsPackageInstalledEx(project, item.Id, item.VersionString))
    {
        // Do something
    }
}

プロジェクト参照を取得する

プロジェクト参照は、 EnvDTE ライブラリの Project インタフェースから VSLangProj.VSProject を取得し、References プロパティから取得することができます。

VSLangProj.VSProject vSProject = project.Object as VSLangProj.VSProject;
VSLangProj.References references = vSProject.References;
foreach (VSLangProj.Reference reference in references)
{
    if (reference.SourceProject != null)
    {
        // Do something
    }
}

.NET Standard 2.0 プロジェクトファイルの作成

プロジェクトファイルの変換には、ランタイム T4 テキストテンプレートを利用しています。以下のような .NET Starndard 2.0 プロジェクトファイルのテンプレートを作成しておき、プロジェクトファイルを生成するときに、PackageReference 要素に参照する NuGet パッケージの Id と バージョン、ProjectReference 要素に参照するプロジェクトの相対パスを渡すようにしています。

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
<# foreach (var item in Packages)
{ #>
    <PackageReference Include="<#= item.Key #>" Version="<#= item.Value #>"/>
<# }#>
  </ItemGroup>
  
  <ItemGroup>
<# foreach (string include in ProjectReferences)
{ #>
    <ProjectReference Include="<#= include #>" />
<# }#>
  </ItemGroup>
</Project>

ランタイムテキストテンプレートのクラスに NuGet 参照、プロジェクト参照の情報を設定するためのプロパティを定義しておきます。

internal partial class NetStandardTemplate
{
    // ....
    public Dictionary<string, string> Packages { get; set; }
    public List<string> ProjectReferences { get; set; }
    // ....
}

プロジェクトファイルを生成する処理は、TransformText() メソッドを利用します。このメソッドは、文字列を返すので、File.WriteAllText を利用してファイルに書き込みます。

var converter = new NetStandardTemplate();
// .....
// converter.ProjectReferences にプロジェクト参照を設定
// converter.Packages に NuGet パッケージの参照に関する情報を設定

File.Delete(ProjectFullName);
var resultString = converter.TransformText();
File.WriteAllText(ProjectFullName, resultString, Encoding.UTF8);

ソースコードインストーラ

拡張機能ソースコードは、GitHub に公開しています。また、拡張機能インストーラーは、Visual Studio Marketplace で公開しています。

最後に

記事を書きながら、Xamarin というよりは Visual StudioIDE の話になってしまったなと、少し反省しております。 今後も機会があれば、Xamarin の開発に少しでも役に立つようなツールをつくっていければと考えています。

明日は、atsushieno さんの記事です。いつも、濃い内容の記事を書いてくださっているすごい方ですね。とても楽しみです。