やること
NuGetで参照するだけで、ビルドプロセスに介入し自動生成機能を付加するNuGetパッケージを作成します。
対象とする読者
インターフェースから実装を生成するような黒魔術を使いたいけど、ランタイムの生成だとAOTが問題になる環境があったりして、コンパイルタイム自動生成のような代替手段を探している人を想定しています。
概略
NuGetで配布するパッケージにtargetsファイルを含めると、ビルドプロセスに介入することができます。
この機能を使用して、ライブラリ(DLL)と共にtargetsファイルとRoslynを使ったコード生成ツールを配布することで、NuGetでパッケージを参照するだけで自動生成ツールが動作するようにビルドプロセスを構成できます。
開発環境
- Visual Studio 2017
サンプル
インターフェースから実装を自動生成する処理サンプルを以下に用意しています。
サンプル概要
よくあるRESTクライアントライブラリのような、インターフェースだけ定義すればそこから実装を生成してくれるようなものをイメージしてもらえれば良いです。
なお、サンプルで行っているのは呼び出し内容のログ出力のみです。
構成
サンプルは以下の3プロジェクトで構成しています。
プロジェクト | 概要 |
---|---|
Example.Library | 実際に処理を行うライブラリ部 |
Example.Library.CodeGenerator | 自動生成ツール |
Example.Client | ライブラリを使用するサンプル |
使用例
サンプルでは次のようなインターフェースからコンパイルタイムで実装の自動生成を行っています。
実際に生成されるコードについては後述します。
[Api]
public interface IHogeApi
{
int Add(int x, int y);
[Method("Subtract")]
int Sub([Parameter("x")] int a, [Parameter("y")] int b);
}
また、自動生成されたコードは次のように使用します。
var api = new Builder()
.UseLogger(ConsoleLogger.Default)
.For<IHogeApi>();
api.Add(1, 2);
api.Sub(3, 4);
処理は定義したインターフェース経由で呼び出し、自動生成したコードを直接使用しない形となっています。
サンプル動作方法
ソース中のbuild.bat
を実行するとnupkgファイルが作成されます(nuget.exeにパスが通っている必要あり)。
ローカルのパッケージ用フォルダ(例 C:\NuGetPackages
)を作成してそこに配置し、Visual Studio上で[ツール]-[オプション]から[NuGet パッケージ マネージャー]-[パッケージ ソース]を選択、[利用可能なパッケージ ソース]そのフォルダを登録して、Example.Client.slnを開いてビルドしてください。
ビルドできたらExample.Clientを実行すると、自動生成されたコードの呼び出し(ログ出力)を確認できます。
解説
自動生成ツール
自動生成ツールExample.Library.CodeGeneratorは.NET Coreのコンソールアプリとして作成しています。
ソースの主要部分を抜粋すると以下になります。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
public class Generator
{
private readonly HashSet<string> usings = new HashSet<string>(new[] { "System" });
private readonly List<ClassInfo> classes = new List<ClassInfo>();
public void AddSource(string text)
{
var tree = CSharpSyntaxTree.ParseText(text);
var root = tree.GetRoot();
// usingの一覧を収集
foreach (var item in root
.DescendantNodes()
.OfType<UsingDirectiveSyntax>()
.Select(ud => ud.Name.ToString()))
{
usings.Add(item);
}
// 自動生成対象とするインターフェースの一覧及びそのメンバの一覧を収集
classes.AddRange(root
.DescendantNodes()
.OfType<NamespaceDeclarationSyntax>()
.SelectMany(nds => nds.DescendantNodes()
.OfType<InterfaceDeclarationSyntax>()
.Where(ids => ids.AttributeLists.SelectMany(als => als.Attributes).Any(IsApiAttribute))
.Select(interfaceNode => CreateClassInfo(nds, interfaceNode))));
}
private static bool IsApiAttribute(AttributeSyntax syntax)
{
// インターフェースが自動生成の対象かを判定、Syntaxのみでの判定なのでちょっと手抜き
var name = syntax.Name.ToString().Split('.').Last();
return ((name == "Api") || (name == "ApiAttribute")) &&
((syntax.ArgumentList?.Arguments.Count ?? 0) == 0);
}
private static ClassInfo CreateClassInfo(NamespaceDeclarationSyntax nds, InterfaceDeclarationSyntax ids)
{
// 自動生成するクラスの元になる情報
return new ClassInfo
{
Namespace = nds.Name.ToString(),
Interface = ids.Identifier.Text,
Methods = ids.Members
.OfType<MethodDeclarationSyntax>()
.Select(CreateMethodInfo)
.ToArray()
};
}
private static MethodInfo CreateMethodInfo(MethodDeclarationSyntax mds)
{
// 自動生成するクラスのメソッドの元になる情報
return new MethodInfo
{
Name = mds.Identifier.Text,
ReturnType = mds.ReturnType.ToString(),
ParameterTypes = String.Join(", ", mds.ParameterList.Parameters.Select(ps => String.Format("typeof({0})", ps.Type.ToString()))),
ArgumentsWithTypes = String.Join( ",", mds.ParameterList.Parameters.Select(ps => String.Format("{0} {1}", ps.Type.ToString(), ps.Identifier.Text))),
Arguments = String.Join(", ", mds.ParameterList.Parameters.Select(ps => ps.Identifier.Text))
};
}
public string Generate()
{
...
}
}
Roslyn(CSharpSyntaxTree
)を使ってソースの解析を行い、自動生成コードで必要とするusingの一覧と、生成するクラス及びそのメンバ一覧の元になる情報を収集しています。
自動生成のための情報が収集できたら、後はGenerate()
メソッドでその情報を元にソースの生成を行います。
なお、サンプルではStringBuilderを使ってソースを生成していますが、本来はテンプレートエンジン等を使って処理すべきものだと考えます。
自動生成されるソースの例を以下に示します。
// <auto-generated />
using Example.Library;
using System;
namespace ExampleGenerated
{
[AttributeUsage (AttributeTargets.Class)]
internal sealed class PreserveAttribute : Attribute
{
}
}
namespace Example.Client.Network
{
using global::ExampleGenerated;
[Preserve]
public class IHogeApiProxy : IHogeApi
{
private readonly global::Example.Library.Engine engine;
public IHogeApiProxy(global::Example.Library.Engine engine)
{
this.engine = engine;
}
private readonly global::Example.Library.MethodMetadata methodMetadata0 = global::Example.Library.MetadataFactory.CreateMethodMetadata(typeof(IHogeApi), "Add", new Type[] { typeof(int), typeof(int) });
public int Add(int x,int y)
{
return (int)engine.Call(this.methodMetadata0, x, y);
}
private readonly global::Example.Library.MethodMetadata methodMetadata1 = global::Example.Library.MetadataFactory.CreateMethodMetadata(typeof(IHogeApi), "Sub", new Type[] { typeof(int), typeof(int) });
public int Sub(int a,int b)
{
return (int)engine.Call(this.methodMetadata1, a, b);
}
}
}
自動生成するのはメソッドのメタデータを使用したラッパーコードのみで、実際の処理はライブラリ(Engine
)に委譲する形にしています。
これは、自動生成部分は簡略にしてデバッグしやすくするためです。
Engine
クラスの詳細は省略しますが、サンプルではメソッドのメタデータからメソッド呼び出しのログ出力(どのメソッドがどの引数で呼び出されたかについて)を行っているだけです。
例えば、この部分を実際にHTTP呼び出しのようなものにすれば、実用的なソースコードの自動生成になるかと思います。
targetsスクリプト
targetsスクリプトは以下のような形になっています。
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="4.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<CoreCompileDependsOn>$(CoreCompileDependsOn);ExampleGenerate;</CoreCompileDependsOn>
</PropertyGroup>
<PropertyGroup>
<IntermediateOutputPath Condition="$(IntermediateOutputPath) == ''">$(MSBuildProjectDirectory)obj\$(Configuration)\</IntermediateOutputPath>
</PropertyGroup>
<Target Name="ExampleGenerate" BeforeTargets="CoreCompile">
<PropertyGroup>
<ExampleGenerateCommand>dotnet "$(MSBuildThisFileDirectory)..\..\tools\Example.Library.CodeGenerator.dll" "$(IntermediateOutputPath)ExampleLibrary.g.cs" "$(MSBuildProjectDirectory)"</ExampleGenerateCommand>
</PropertyGroup>
<Exec Command="$(ExampleGenerateCommand)" />
<Message Text="Example proxy generated" />
<ItemGroup Condition="Exists('$(IntermediateOutputPath)\ExampleLibrary.g.cs')">
<Compile Include="$(IntermediateOutputPath)\ExampleLibrary.g.cs" />
</ItemGroup>
</Target>
</Project>
ビルドプロセスのCoreCompile
前に自動生成ツールを起動し、自動生成コードを生成してそれもコンパイル対象に含める設定にしています。
IntermediateOutputPath
では自動生成するツールの出力先を設定しています。
デフォルトではobj\Debug\netcoreapp1.1\ExampleLibrary.g.cs
に生成するように設定しています。
自動生成ツールはdotnet
コマンドで実行しています。
ツールの第1引数は生成するソースの指定で、第2引数は解析対象のフォルダの指定です。
デフォルトでは、NuGetパッケージを参照したプロジェクトのフォルダ下のソースを解析して自動生成を行う設定にしています。
また、自動生成ツール実行後にでメッセージ出力を行っています。
生成されたソースはの指定により、コンパイル対象に含まれるような設定にしています。
これらの設定により、コンパイル前のソースの生成と、それも含めた形でのコンパイルを実現しています。
nuspec定義ファイル
ライブラリとtargetsスクリプト、自動生成ツールを配布するNuGetパッケージの定義は以下のようになります。
<?xml version="1.0" encoding="utf-8"?>
<package xmlns="http://schemas.microsoft.com/packaging/2012/06/nuspec.xsd">
<metadata>
<id>Example.Library</id>
<version>0.2.0</version>
<authors>machi_pon</authors>
<owners>machi_pon</owners>
<description>Code generation example</description>
<dependencies>
<dependency id="NETStandard.Library" version="1.6.1" />
</dependencies>
</metadata>
<files>
<file src="Example.Library\bin\Release\netstandard1.1\Example.Library.dll" target="lib\netstandard1.1" />
<file src="Example.Library.targets" target="build\netstandard1.1" />
<file src="Example.Library.CodeGenerator\bin\Release\PublishOutput\**\*" target="tools" />
</files>
</package>
lib\netstandard1.1
に対してbuild\netstandard1.1
にtargetsスクリプトを配置すると、その内容がビルドプロセスに統合されます。
クライアント
クライアントはNuGetパッケージを参照しているだけです。
それだけでcsprojを手で編集するといった事なしに、自動生成処理をビルドプロセスに統合することが可能になります。
補足
その他、サンプルについて何点か補足です。
generator.jsonファイル
解析対象のフォルダにgenerator.json
ファイルが存在する場合、その設定を元に自動生成ツールが動作する形にしています。
プロジェクト毎に自動生成の挙動を変更したいような場合を想定していて、解析対象フォルダの明示的指定、対象フォルダの除外指定、対象とするファイルパターンの指定ができるようにしています。
これらの設定は、いずれも無駄なファイルの解析を抑止するためのもので、Example.ClientプロジェクトではNetworkフォルダ内のソースのみを自動生成の対象としているため、他のフォルダにインターフェースを作成しても処理の対象とはなりません。
ファイル更新の判定
自動生成ツールの動きとして、生成されたコードが既に生成済みのコードと同一内容だった場合にはファイルの更新を行わないようにしています。
これは自動生成されたコードのコンパイルが都度行われるのを抑止するためのものです。
自動生成機能を作成する場合は、このような処理時間短縮の仕掛けを用意した方が良いと考えます。
うさコメ
Xamarinで黒魔術っぽいことをしたい時にどうするかと考え、調査したのが今回の内容となります。
Android開発ではアノテーションプロセッサを使いまくっているわけで、Xamarinでもコンパイルタイムの自動生成を活用しまくっても良いよね(・ω・)?