ROMANCE DAWN for the new world

Microsoft Azure を中心とした技術情報を書いています。

Roslyn で ASP.NET Web API 向けの Code Analyzer を作ってみた

Visual Studio 2015 から追加された .NET Compiler Platform(Roslyn)を使って、ASP.NET Web API 向けの Code Analyzer を作ってみました。

環境構築

Roslyn で Code Analyzer を開発するには、Visual Studio のオプション機能と .NET Compiler Platform SDK をインストールする必要があります。Build Insider の記事が分かりやすいので、こちらを参照してください。
www.buildinsider.net
SDK と一緒に、Syntax Visualizer というツールもインストールされます。SyntaxTree をリアルタイムに表示してくれるので、実装する際に役立ちます。

プロジェクトの作成

開発環境が整うと、Visual Studio のプロジェクト テンプレートの Extensibility に Roslyn 用のテンプレートが表示されます。
f:id:TonyTonyKun:20160316060615p:plain
「Analyzer with Code Fix(NuGet + VSIX)」を選択して作成します。Analyzer プロジェクトには、クラス名に小文字が含まれていたら警告を表示するサンプルが入っています。Analyzer.Vsix プロジェクトをスタートアップにしてデバッグ実行すると、別の Visual Studio が起動するので、そこで開いたプロジェクトでコード解析とリファクタリングのコードをデバッグできます。

シナリオ

ASP.NET Web API 向けの実用的なシナリオとして、 POST のアクションメソッドの戻り値を void にすると、HttpStatusCode.NoContent(204)が返されるけどいいの?と警告を表示してみます。一般的には HttpStatusCode.Created(201)を返すことが多いので、リファクタリングの機能も提供します。
いろいろと調べていると、すでに同じ内容でブログを書いている方がいることに気が付きました。
Building Web API Visual Studio support tools with Roslyn | StrathWeb. A free flowing web tech monologue.
3年前の記事で Roslyn のコードが古いので、気にせず続けることにします。

コード解析

コードを解析するには、DiagnosticAnalyzer クラスを継承したクラスを作成します。ID やタイトルなどのルールを設定した後、Initialize() メソッドの実装が肝です。

  • RegisterXXX() メソッドがたくさんあるけど、どれを使えばいいのか?
  • Analyze () メソッドの中で、どうやってコードを解析するのか?

Roslyn の敷居が高いと思うのは、これらに関するドキュメントが少なく、何をどう書けば分からないことです。
まずは、neue さんのブログを読みましょう。
neue cc - VS2015のRoslynでCode Analyzerを自作する(ついでにUnityコードも解析する)
次に、自分がやりたいことに近いサンプルコードを見つける。そして、Syntax Visualizer でひたすら頑張る。これしかなさそうです。

[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class ApiAnalyzer : DiagnosticAnalyzer
{
	public const string DiagnosticId = "ApiAnalyzerId";
	internal const string Title = "POST メソッドで HttpStatusCode.NoContent(204)が返されています。";
	internal const string MessageFormat = "POST メソッドで HttpStatusCode.NoContent(204)が返されています。";
	internal const string Category = "Usage";

	internal static DiagnosticDescriptor Rule = new DiagnosticDescriptor(
							DiagnosticId,
							Title, 
							MessageFormat, 
							Category, 
							DiagnosticSeverity.Warning, isEnabledByDefault: true);

	public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics
	{
		get
		{
			return ImmutableArray.Create(Rule);
		}
	}

	public override void Initialize(AnalysisContext context)
	{
		context.RegisterSyntaxNodeAction(Analyze, SyntaxKind.ClassDeclaration);
	}

	static void Analyze(SyntaxNodeAnalysisContext context)
	{
		// ApiController の継承クラスのみ
		var syntax = (ClassDeclarationSyntax)context.Node;
		if (syntax.BaseList == null)
		{
			return;	// 親クラスがない
		}
		if (!syntax.BaseList.Types.Any(x => x.GetFirstToken().ValueText == "ApiController"))
		{
			return; // 親クラスが ApiController ではない
		}

		// メソッドの絞り込み(1)
		// ・メソッド名が "Post" から始まるメソッド、もしくは
		// ・HttpPost 属性が付与されたメソッド
		var methods = syntax.Members.OfType<MethodDeclarationSyntax>().
				Where(x => (x.Identifier.ValueText.StartsWith("Post") ||
					x.AttributeLists.Any(a => a.Attributes.Any(s => s.Name.ToString() == "HttpPost"))));

		// メソッドの絞り込み(2)
		// ・メソッドの戻り値が "void" 型
		methods = methods.Where(x => (x.ReturnType as PredefinedTypeSyntax) != null &&
					(x.ReturnType as PredefinedTypeSyntax).IsKind(SyntaxKind.VoidKeyword));

		// 警告を表示する
		foreach (var method in methods)
		{
			var diagnostic = Diagnostic.Create(Rule, method.GetLocation());
			context.ReportDiagnostic(diagnostic);
		}
	}
}

解析ロジックにコメントを入れてあります。こんな感じで警告を出したいコードを見つけることができます。この Analyzer を動かすと警告が表示されます。
f:id:TonyTonyKun:20160316055447p:plain

リファクタリング

コードをリファクタリングするには、CodeFixProvider クラスを継承したクラスを作成します。CodeAction.Create() メソッドの引数に渡す ReplaceToHttpStatusCodeCreated() メソッドの実装が肝です。コード解析と同じく、試行錯誤しながら頑張るしかなさそうです。

[ExportCodeFixProvider("ApiCodeFixProvider", LanguageNames.CSharp), Shared]
public class ApiCodeFixProvider : CodeFixProvider
{
	internal const string Title = "HttpStatusCode.Created(201)を返すように変更する。";

	public override ImmutableArray<string> FixableDiagnosticIds
	{
		get
		{
			// DiagnosticAnalyzer と CodeFixProvider の関連づけ
			return ImmutableArray.Create(ApiAnalyzer.DiagnosticId);
		}
	}

	public sealed override FixAllProvider GetFixAllProvider()
	{
		return WellKnownFixAllProviders.BatchFixer;
	}

	public override async Task RegisterCodeFixesAsync(CodeFixContext context)
	{
		// 警告が表示されている場所を取得する
		var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
		var diagnostic = context.Diagnostics.First();
		var diagnosticSpan = diagnostic.Location.SourceSpan;

		// 警告が表示されているメソッドを取得する
		var method = (MethodDeclarationSyntax)root.FindNode(diagnosticSpan);

		// コードを置換する
		var codeAction = CodeAction.Create(Title, x => ReplaceToHttpStatusCodeCreated(context.Document, root, method, x), nameof(ApiCodeFixProvider));
		context.RegisterCodeFix(codeAction, diagnostic);
	}

	static Task<Document> ReplaceToHttpStatusCodeCreated(Document document, SyntaxNode root, MethodDeclarationSyntax method, CancellationToken cancellationToken)
	{
		// 戻り値の型は、"HttpResponseMessage" に書き換える
		var newReturnType = SyntaxFactory.ParseTypeName("HttpResponseMessage").WithTrailingTrivia(SyntaxFactory.Space);

		// "return new HttpResponseMessage(HttpStatusCode.Created);" というコードを生成する
		var expression = SyntaxFactory.ParseExpression("new HttpResponseMessage(HttpStatusCode.Created)").WithLeadingTrivia(SyntaxFactory.Space);
		var returnStatement = SyntaxFactory.ReturnStatement(SyntaxFactory.Token(SyntaxKind.ReturnKeyword), expression, SyntaxFactory.Token(SyntaxKind.SemicolonToken));

		// メソッド本文の最後にコードを追加する
		var oldBody = method.Body;
		var statements = oldBody.Statements.Where(x => x.GetType() != typeof(ReturnStatementSyntax)).ToList();
		var syntaxListStatements = new SyntaxList<StatementSyntax>();
		syntaxListStatements = statements.Aggregate(syntaxListStatements, (current, syntaxListStatement) => current.Add(syntaxListStatement));
		syntaxListStatements = syntaxListStatements.Add(returnStatement);
		var newBody = SyntaxFactory.Block(SyntaxFactory.Token(SyntaxKind.OpenBraceToken), syntaxListStatements, SyntaxFactory.Token(SyntaxKind.CloseBraceToken));

		// メソッドのコードを書き換え
		var newMethod = SyntaxFactory.MethodDeclaration(
						method.AttributeLists,
						method.Modifiers,
						newReturnType,
						method.ExplicitInterfaceSpecifier,
						method.Identifier,
						method.TypeParameterList,
						method.ParameterList,
						method.ConstraintClauses,
						newBody,
						method.SemicolonToken);

		// 書き換えたコードにリプレイス
		var newRoot = root.ReplaceNode(method, newMethod);
		var newDocument = document.WithSyntaxRoot(newRoot);
		return Task.FromResult(newDocument);
	}
}

置換ロジックにはコメントを入れてあります。この CodeFixProvider を動かすとリファクタリングできます。
f:id:TonyTonyKun:20160316055840p:plain
プレビューでリファクタリング内容を確認したら、コードを置換することができます。
f:id:TonyTonyKun:20160316055852p:plain

まとめ

昨年の Build で、Roslyn のセッションがありました。
.NET Compiler Platform ("Roslyn"): Analyzers and the Rise of Code-Aware Libraries | Build 2015 | Channel 9
このなかで語られている Code - Aware Libraries というメッセージが、Roslyn の面白さを表していると思います。ライブラリ開発者は、Analyzer を一緒に提供することで、正しい使い方を教えてあげることができます。ドキュメントにあれこれ書くよりも確実に伝える(強制する)ことができるので、うまく活用していきたいです。