Unity 中使用 Roslyn

背景 & 目标

之前因为时间原因,一直没有功夫研究 Roslyn,最近刚好有时间,开篇博客记录一下过程。在我这的实际使用情况下,需要将现有的 Roslyn 规则封装在 UPM 中,业务只需要引用一行 Package 记录,就可以拥有自定义编译规则的能力

刚好在 ET 的开源代码库中, sj 老哥带来了很多自定义的编译规则,但是 ET 的实际使用方式和 Package 的封装有出入,因此这个部分需要适配

由于引入 Package 机制,这样就导致实际测试 Roslyn 规则时,带来了新的麻烦,非常不方便,因此需要一个 Roslyn 的单元测试机制

Unity Roslyn 规则

官方文档写的非常模糊,跟着官方文档一步一步做,最后你会发现怎么都不行,逛了相关论坛发现了这个仓库 unity-roslyn-analyzers

如果你也有类似需求,建议直接拷贝这个仓库的 RoslynAnalyzers 文件夹 到你实际的项目中

  • 在实际需要 Roslyn 的代码库中引用 asmdef
  • Emptyxx.cs 文件,不可以删,否则 Analyzers.asmdef 不会编译
  • Microsoft.Unity.Analyzers.dll 就是实际的 Roslyn 代码

这里的 dll 文件需要注意,platforms 全部为空,否则会编译错误

Roslyn Package 项目

这里推荐直接去 Microsoft.Unity.Analyzers 下载,作为 Unity Roslyn 的初始项目,然后按照下方的目录层级创建 Roslyn Package 项目

.
├── Assets
│  └── RoslynAnalyzers
└── Tools
   └── Microsoft.Unity.Analyzers

当对 Roslyn 代码修改后,直接运行下方代码,更新到 Unity Assets 文件夹,然后 npm 发布到 self host UPM 即可

#!/bin/bash

dotnet build ./Tools/Microsoft.Unity.Analyzers/Microsoft.Unity.Analyzers.csproj -c Release -o ./Assets/RoslynAnalyzers

Roslyn 单元测试

这里以 Microsoft.Unity.Analyzers.sln 为例,其中微软提供了 Microsoft.Unity.Analyzers.Tests 示例项目,这里微软的实现非常有趣,为了完成 Roslyn 的单元测试,实际在运行时,会创建一个临时的 sln,然后运行 Roslyn 检测,我们以官方的一个单元测试为例SetPixelsTests.cs

	[Fact]
	public async Task Texture3DTest()
	{
		const string test = @"
using UnityEngine;

class Camera : MonoBehaviour
{
    private void Test(Texture3D test)
    {
        test.SetPixels(null);
    }
}
";

		var diagnostic = ExpectDiagnostic()
			.WithLocation(8, 14)
			.WithArguments("SetPixels");

		await VerifyCSharpDiagnosticAsync(test, diagnostic);
	}

通过直接写字符串的方式,创建一个单元测试,比如这里的 SetPixels 检测,我们已经知道在第 8 行的第 14 个字符存在问题,因此需要追加 WithLocation(8, 14),同时此处在 Roslyn 的规则中,动态拼接了 SetPixels 作为参数,因此追加了 WithArguments("SetPixels")

最后 await VerifyCSharpDiagnosticAsync(test, diagnostic) 会创建一个临时项目,并检查 test 中的语法错误,最后和 ExpectDiagnostic 的结果进行对比

dll 引入

既然单元测试的原理是创建了一个临时的 sln,那么为了完成诸如上面的 using UnityEngine; 等内容,势必要完成对 UnityEngine.dll 等相关文件的引入,实际的代码写在 DiagnosticVerifier.cs 文件中

这样如果需要引入一些第三方 Unity 项目所需的 dll,只需要追加对应的 yield return xxx.dll 即可

ET Roslyn 的适配

在 ET 的规则中,大部分规则都是视为 Error,但是在微软的这个单元测试项目中,如果 Roslyn 匹配到的结果是一个 Error 会触发断言,导致单元测试失败,因此解决方案就有两种

  • 调整 ET Roslyn 的规则为 Warning
  • 删除单元测试的 Error 断言

这里我说一下第二个方案的具体内容,首先在 DiagnosticVerifier.cs 中关闭这两行断言,直接注释就行

foreach (var error in errors)
	Assert.Fail($"Line {error.Location.GetLineSpan().StartLinePosition.Line}: {error.GetMessage()}");

接着注释掉 DiagnosticVerifier.cs 文件中 Expect(erros) 即可

var diags = allDiagnostics
	// .Except(errors)
	.Where(d => d.Location.IsInSource); //only keep diagnostics related to a source location

由于 ExpectDiagnostic 函数中会强制校验是否只有一个 DiagnosticResult,因此部分 ET 的 Roslyn 规则需要自行拆分,否则会导致单元测试无法通过

这里的 xxx.Single() 函数

protected DiagnosticResult ExpectDiagnostic()
{
	var analyzer = GetCSharpDiagnosticAnalyzer();
	try
	{
		return ExpectDiagnostic(analyzer.SupportedDiagnostics.Single());
	}
	catch (InvalidOperationException ex)
	{
		throw new InvalidOperationException(
				$"'{nameof(Diagnostic)}()' can only be used when the analyzer has a single supported diagnostic. Use the '{nameof(Diagnostic)}(DiagnosticDescriptor)' overload to specify the descriptor from which to create the expected result.",
				ex);
	}
}

完整示例

这里我以 ChildOf 规则为例

public class FriendOfTest : BaseDiagnosticVerifierTest<EntityFiledAccessAnalyzer>
{
    [Fact]
    public async Task AccessFromClass()
    {
        const string test=@"
using ET;

public class Unit : Entity
{
    public int value;
}

public static class Test
{
    public static void Run(this Unit self)
    {
        self.value = 1;
    }
}
"
        var diagnostic = ExpectDiagnostic()
                        .WithLocation(13, 9)
                        .WithArguments("Unit", "value");
        
        await VerifyCSharpDiagnosticAsync(test, diagnostic);
    }
}

最后

虽然所有流程都跑通了,但是由于 Unity 自身蹩脚的设计,导致原本 C# 支持的功能变的非常繁琐,或许这些在未来 dotnet 版本的 Unity 后会得到非常大的改善

Roslyn 的单元测试相关的代码非常有趣,能看出来微软在 C# 生态下了很多功夫,有了这套工具链,测试修改 Roslyn 会非常非常方便~