2016 年 12 月
第 31 卷,第 13 期
Roslyn - 使用 Roslyn 和 T4 模板生成 JavaScript
作者:Nick Harrison | 2016 年 12 月
几天以前,我女儿告诉了我一个有关智能手机与功能手机之间对话的笑话。这个笑话是这样的: 智能手机对功能手机说: “我来自未来,你能弄明白我是怎么回事吗?” 有时,我们在学习新事物和最前沿的技术时就会有这种感觉。Roslyn 来自未来,新手可能很难弄明白它是怎么回事。
在本文中,我没有太多重点介绍 Roslyn,而是重点介绍了如何在将 Roslyn 用作元数据源的情况下使用 T4 生成 JavaScript。在此期间,我将使用工作区 API、一些语法 API、符号 API 以及 T4 引擎中的运行时模板。对于了解元数据收集过程,实际生成的 JavaScript 只起到辅助作用。
由于 Roslyn 还提供了一些实用的代码生成选项,因此你可能会认为这两种技术会发生冲突,无法完美契合。沙盒重叠的技术经常会发生冲突。不过,这两种技术可以十分完美地契合。
等等,那什么是 T4 呢?
如果你是刚接触 T4,可参阅 2015 年 Syncfusion 扼要系列电子书之一《T4 扼要》,获取所需的全部背景知识 (bit.ly/2cOtWuN)。
对于本文,要知道的最重要一点是,T4 是 Microsoft 提供的基于模板的文本转换工具包。只需将元数据馈送给模板,即可将文本转换成所需的代码。实际上,除了生成代码之外,还可以生成其他任何类型的文本,只不过最常输出的是源代码。可以生成 HTML、SQL、文本文档、Visual Basic .NET、C# 或任何基于文本的输出。
我们来看看图 1。其中展示了一个简单的控制台应用程序。在 Visual Studio 中,我新添加了一个名为 AngularResourceService.tt 的运行时文本模板。此模板代码会自动生成一些 C # 代码,用于在运行时实现模板,你可以在控制台窗口中看到相应情况。
图 1:使用 T4 生成设计时代码
在本文中,我将介绍如何使用 Roslyn 从 Web API 项目收集元数据,将收集到的元数据馈送给 T4 以生成 JavaScript 类,然后使用 Roslyn 将该 JavaScript 添加回解决方案。
从概念上讲,上述过程流如图 2 所示。
图 2:T4 过程流
Roslyn 将元数据馈送给 T4
生成代码期间要用到大量元数据。你需要使用元数据来描述要生成的代码。反射、代码模型和数据字典是常见的现成元数据源。Roslyn 可以提供你从反射或代码模型收到的所有元数据,但不会像这些源一样引发一些问题。
在本文中,我将使用 Roslyn 查找从 ApiController 派生的类。然后,我将使用 T4 模板为每个 Controller 创建一个 JavaScript 类,并在与 Controller 关联的 ViewModel 中为每个 Action 公开一个方法,以及为每个属性公开一个属性。生成的代码如图 3 所示。
图 3:代码运行结果
var app = angular.module("challenge", [ "ngResource"]);
app.factory(ActivitiesResource , function ($resource) {
return $resource(
'http://localhost:53595//Activities',{Activities : '@Activities'},{
Id : "",
ActivityCode : "",
ProjectId : "",
StartDate : "",
EndDate : "",
, get: {
method: "GET"
}
, put: {
method: "PUT"
}
, post: {
method: "POST"
}
, delete: {
method: "DELETE"
}
});
});
收集元数据
首先,我在 Visual Studio 2015 中新建一个控制台应用程序项目来收集元数据。在此项目中,我有一个专门用于使用 Roslyn 收集元数据的类,以及一个 T4 模板。这将是一个运行时模板,用于根据收集到的元数据生成一些 JavaScript 代码。
在此项目创建后,程序包管理器控制台发出以下命令:
Install-Package Microsoft.CodeAnalysis.CSharp.Workspaces
这样可确保使用的是适用于 CSharp 编译器和相关服务的最新 Roslyn 代码。
我将各种方法的代码放在一个名为 RoslynDataProvider 的新类中。我将通篇引用这个类,只要想使用 Roslyn 收集元数据,就可以立即引用这个类。
我使用 MSBuildWorksspace 获取一个工作区,用于提供编译内容所需的全部上下文。有了解决方案之后,我就可以轻松浏览所有项目,从而查找 WebApi 项目了:
private Project GetWebApiProject()
{
var work = MSBuildWorkspace.Create();
var solution = work.OpenSolutionAsync(PathToSolution).Result;
var project = solution.Projects.FirstOrDefault(p =>
p.Name.ToUpper().EndsWith("WEBAPI"));
if (project == null)
throw new ApplicationException(
"WebApi project not found in solution " + PathToSolution);
return project;
}
如果你采用其他命名约定,可以将它轻松纳入 GetWebApiProject,从而找到相关项目。
我已经知道要使用哪个项目了,现在我需要获得此项目的编译内容,同时引用将用于标识相关 Controller 的类型。我之所以需要获得编译内容,是因为我将使用 SemanticModel 来确定类是否派生自 System.Web.Http.ApiController。我可以获得此项目中的文档。每个文档都是一个单独的文件,可以包含多个类声明。虽然最佳做法是任意文件中只包含一个类,且文件与类同名,但并不是所有人都始终遵循这一标准做法。
查找 Controller
图 4 展示了如何查找每个文档中的所有类声明,并确定该类是否派生自 ApiController。
图 4:在项目中查找 Controller
public IEnumerable<ClassDeclarationSyntax> FindControllers(Project project)
{
compilation = project.GetCompilationAsync().Result;
var targetType = compilation.GetTypeByMetadataName(
"System.Web.Http.ApiController");
foreach (var document in project.Documents)
{
var tree = document.GetSyntaxTreeAsync().Result;
var semanticModel = compilation.GetSemanticModel(tree);
foreach (var type in tree.GetRoot().DescendantNodes().
OfType<ClassDeclarationSyntax>()
.Where(type => GetBaseClasses(semanticModel, type).Contains(targetType)))
{
yield return type;
}
}
}
由于编译内容有权访问编译此项目所需的全部引用,因此可以解析目标类型。虽然在获得编译对象时我已开始编译此项目,但我在获得有助于获取所需元数据的详细信息后中途中断了编译。
图 5 展示了 GetBaseClasses 方法,该方法可确定当前类是否派生自目标类,为你省却一切麻烦。此方法进行的处理比实际所需稍微多一些。在确定类是否派生自 ApiController 期间,我真正关心的不是其间实现的接口,而是通过添加这些详细信息,使此方法成为适用于各种应用场景的实用工具方法。
图 5:查找基类和接口
public static IEnumerable<INamedTypeSymbol> GetBaseClasses
(SemanticModel model, BaseTypeDeclarationSyntax type)
{
var classSymbol = model.GetDeclaredSymbol(type);
var returnValue = new List<INamedTypeSymbol>();
while (classSymbol.BaseType != null)
{
returnValue.Add(classSymbol.BaseType);
if (classSymbol.Interfaces != null)
returnValue.AddRange(classSymbol.Interfaces);
classSymbol = classSymbol.BaseType;
}
return returnValue;
}
对于反射,这种分析变得复杂。因为反射方法依赖于递归,可能需要不断加载任意数量的程序集才能访问所有干预类型。对于代码模型,甚至无法执行这种分析,但使用 Roslyn 中的 SemanticModel 执行时就相对简单。SemanticModel 是元数据的宝库,表示编译器在将语法树绑定到 Symbol 后了解到的一切代码相关信息。除了跟踪基类型,它还可以用于解决很难解决的问题,例如重载/替代处理或查找对方法、属性或任意 Symbol 的所有引用。
查找关联的 Model
此时,我可以访问项目中的所有 Controller。在 JavaScript 类中,还可以公开 Controller 中 Action 返回的 Model 中的属性。若要了解具体的运作方式,请参阅下面的代码,其中展示了为 WebApi 运行基架后的输出:
public class Activity
{
public int Id { get; set; }
public int ActivityCode { get; set; }
public int ProjectId { get; set; }
public DateTime StartDate { get; set; }
public DateTime EndDate { get; set; }
}
在此代码中,基架是对 Model 运行的,如图 6 所示。
图 6:生成的 API Controller
public class ActivitiesController : ApiController
{
private ApplicationDbContext db = new ApplicationDbContext();
// GET: api/Activities
public IQueryable<Activity> GetActivities()
{
return db.Activities;
}
// GET: api/Activities/5
[ResponseType(typeof(Activity))]
public IHttpActionResult GetActivity(int id)
{
Activity activity = db.Activities.Find(id);
if (activity == null)
{
return NotFound();
}
return Ok(activity);
}
// POST: api/Activities
[ResponseType(typeof(Activity))]
public IHttpActionResult PostActivity(Activity activity)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
db.Activities.Add(activity);
db.SaveChanges();
return CreatedAtRoute("DefaultApi", new { id = activity.Id }, activity);
}
// DELETE: api/Activities/5
[ResponseType(typeof(Activity))]
public IHttpActionResult DeleteActivity(int id)
{
Activity activity = db.Activities.Find(id);
if (activity == null)
{
return NotFound();
}
db.Activities.Remove(activity);
db.SaveChanges();
return Ok(activity);
}
向 Action 添加的 ResponseType 属性将 ViewModel 与 Controller 相关联。使用此属性,可以获取与 Action 关联的 Model 的名称。只要 Controller 是使用基架创建的,各个 Action 就会与同一 Model 相关联,但手动创建或在生成后编辑的 Controller 可能不会这么一致。图 7 展示了如何与所有 Action 进行对比,从而获取与 Controller 关联的 Model 的完整列表(如果有多个 Model 的话)。
图 7:查找与 Controller 关联的 Model
public IEnumerable<TypeInfo> FindAssociatedModel
(SemanticModel semanticModel, TypeDeclarationSyntax controller)
{
var returnValue = new List<TypeInfo>();
var attributes = controller.DescendantNodes().OfType<AttributeSyntax>()
.Where(a => a.Name.ToString() == "ResponseType");
var parameters = attributes.Select(a =>
a.ArgumentList.Arguments.FirstOrDefault());
var types = parameters.Select(p=>p.Expression).OfType<TypeOfExpressionSyntax>();
foreach (var t in types)
{
var symbol = semanticModel.GetTypeInfo(t.Type);
if (symbol.Type.SpecialType == SpecialType.System_Void) continue;
returnValue.Add( symbol);
}
return returnValue.Distinct();
}
此方法体现的逻辑十分有趣,其中一些相当微妙。请注意如下的 ResponseType 属性:
[ResponseType(typeof(Activity))]
我想要访问以表达式类型的形式引用的类型(即此属性的第一个参数,在此示例中为 Activity)中的属性。属性变量是 Controller 中各种 ResponseType 属性的列表。参数变量是这些属性的参数列表。其中每个参数都是一个 TypeOfExpressionSyntax,我可以通过 TypeOfExpressionSyntax 对象的类型属性获得关联的类型。同样,SemanticModel 用于提取该类型的 Symbol,提供你可能需要的所有详细信息。
方法结束时生成不同结果可确保返回的每个 Model 都是唯一的。在大多数情况下,由于 Controller 中的多个 Action 都与同一 Model 相关联,因此预计会有重复的 Model。此外,最好检查是否有无效的 ResponseType。其中不含任何相关的属性。
检查关联的 Model
以下代码展示了如何从 Controller 中的所有 Model 查找属性:
public IEnumerable<ISymbol> GetProperties(IEnumerable<TypeInfo> models)
{
return models.Select(typeInfo => typeInfo.Type.GetMembers()
.Where(m => m.Kind == SymbolKind.Property))
.SelectMany(properties => properties).Distinct();
}
查找 Action
除了显示关联的 Model 中的属性外,我还想添加对 Controller 中方法的引用。Controller 中的方法即为 Action。我只对公共方法感兴趣,因为这些是 WebApi Action,应将其全部转换成相应的 HTTP 谓词。
有关处理此映射的约定并不统一。基架遵循的约定是方法名称以谓词名称开头。所以,put 方法的名称为 PutActivity,post 方法的名称为 PostActivity,delete 方法的名称为 DeleteActivity。get 方法的名称通常有两个,分别为: GetActivity 和 GetActivities。可以通过检查这两种 get 方法的返回类型来进行区分。如果返回类型直接或间接实现 IEnumerable 接口,get 方法旨在获取所有项,否则旨在获取单项。
另一种约定是明确添加用于指定谓词的属性,然后便可任意命名方法。图 8 展示了 GetActions 代码,用于标识公共方法,然后按照上述两种方法将它们映射到谓词。
图 8:在 Controller 中查找 Action
public IEnumerable<string> GetActions(ClassDeclarationSyntax controller)
{
var semanticModel = compilation.GetSemanticModel(controller.SyntaxTree);
var actions = controller.Members.OfType<MethodDeclarationSyntax>();
var returnValue = new List<string>();
foreach (var action in actions.Where
(a => a.Modifiers.Any(m => m.Kind() == SyntaxKind.PublicKeyword)))
{
var mapName = MapByMethodName(semanticModel, action);
if (mapName != null)
returnValue.Add(mapName);
else
{
mapName = MapByAttribute(semanticModel, action);
if (mapName != null)
returnValue.Add(mapName);
}
}
return returnValue.Distinct();
}
GetActions 方法先尝试根据方法的名称进行映射。如果不起作用,则尝试按属性进行映射。如果无法映射方法,则不会将其添加到 Action 列表中。如果你希望检查其他约定,可以将它轻松纳入 GetActions 方法。图 9 展示了 MapByMethodName 和 MapByAttribute 方法的实现。
图 9:MapByName 和 MapByAttribute
private static string MapByAttribute(SemanticModel semanticModel,
MethodDeclarationSyntax action)
{
var attributes = action.DescendantNodes().OfType<AttributeSyntax>().ToList();
if ( attributes.Any(a=>a.Name.ToString() == "HttpGet"))
return IdentifyIEnumerable(semanticModel, action) ? "query" : "get";
var targetAttribute = attributes.FirstOrDefault(a =>
a.Name.ToString().StartsWith("Http"));
return targetAttribute?.Name.ToString().Replace("Http", "").ToLower();
}
private static string MapByMethodName(SemanticModel semanticModel,
MethodDeclarationSyntax action)
{
if (action.Identifier.Text.Contains("Get"))
return IdentifyIEnumerable(semanticModel, action) ? "query" : "get";
var regex = new Regex("\b(?'verb'post|put|delete)", RegexOptions.IgnoreCase);
if (regex.IsMatch(action.Identifier.Text))
return regex.Matches(action.Identifier.Text)[0]
.Groups["verb"].Value.ToLower();
return null;
}
这两种方法先明确搜索 Get Action,然后确定方法引用的 get 类型。
如果 Action 不是 get 方法,MapByAttribute 会检查 Action 是否具有以 Http 开头的属性。如果能找到,只需获取属性名称并从中删除 Http 即可确定谓词。无需明确检查每个属性来确定要使用的谓词。
MapByMethodName 的结构与之相似。此方法先搜索 Get Action,然后使用正则表达式来确定其他任何谓词是否匹配。如果找到匹配项,可以从命名的捕获组中获取谓词名称。
这两种映射方法都需要区分是获取一个 Action,还是获取所有 Action,并且都使用下面代码中展示的 IdentifyEnumerable 方法:
private static bool IdentifyIEnumerable(SemanticModel semanticModel,
MethodDeclarationSyntax actiol2n)
{
var symbol = semanticModel.GetSymbolInfo(action.ReturnType);
var typeSymbol = symbol.Symbol as ITypeSymbol;
if (typeSymbol == null) return false;
return typeSymbol.AllInterfaces.Any(i => i.Name == "IEnumerable");
}
同样,SemanticModel 也发挥了关键作用。我可以通过检查方法的返回类型来区分 get 方法。SemanticModel 返回与返回类型绑定的 Symbol。借助此 Symbol,我可以判断返回类型是否实现了 IEnumerable 接口。如果方法返回的是 List<T>、Enumerable<T> 或任意类型的集合,则表明它将实现 IEnumerable 接口。
T4 模板
现在,我已收集到所有元数据。是时候介绍一下将所有元数据组合在一起的 T4 模板了。首先,我会在此项目中添加一个运行时文本模板。
对于运行时文本模板,运行模板后的输出是一个用于实现已定义的模板的类,而不是我想要生成的代码。大部分情况下,可以在运行时文本模板中执行文本模板中支持的一切操作。两者的区别在于如何运行模板生成代码。如果是文本模板,Visual Studio 将负责运行模板,并创建用于运行模板的托管环境。如果是运行时文本模板,则由你负责设置托管环境和运行模板。虽然这会增加你的工作量,但你却可以更好地控制如何运行模板以及如何处理输出。这样一来,也可以完全不依赖 Visual Studio 了。
我会先编辑 AngularResource.tt,并将图 10 中的代码添加到模板中。
图 10:初始模板
<#@ template debug="false" hostspecific="false" language="C#" | #>
var app = angular.module("challenge", [ "ngResource"]);
app.factory(<#=className #>Resource . function ($resource) {
return $resource('<#=Url#>/<#=className#>',{<=className#> : '@<#=className#>'},{
<#=property.Name#> : "",
query : {
method: "GET"
, isArray : true
}
' <#=action#>: {
method: "<#= action.ToUpper()#>
}
});
});
你可能是刚接触此模板,视你对 JavaScript 的熟悉程度而定。如果是这样,也不必担心。
第一行是模板指令,用于指示 T4 我编写 C# 模板代码;如果是运行时模板,忽略其他两个属性,但为了清楚起见,我明确表示自己并不寄希望于托管环境,也不期望保留中间文件以供调试。
T4 模板有点像 ASP 页面。<# and #> 标记用于界定模板驱动代码和模板要转换的文本。<#= #> 标记用于界定要计算并插入生成的代码中的替换变量。
我们来看此模板,你会发现元数据预计会提供 className、URL、属性列表和 Action 列表。由于这是运行时模板,因此我可以执行一些操作来进行简化,但首先来看看在运行此模板后创建的代码。运行此模板的具体方法为:保存 .TT 文件,或在解决方案资源管理器中右键单击文件,然后选择“运行自定义工具”。
运行模板后的输出是一个与模板匹配的新类。更为重要的一点是,如果我向下滚动,则会发现模板还生成了基类。这一点很重要,因为如果我将基类移到新文件中,并在模板指令中明确声明基类,模板便不再生成基类,这样我就可以根据需要随意更改这个基类了。
接下来,我将模板指令更改为如下指令:
<#@ template debug="false" hostspecific="false" language="C#"
inherits="AngularResourceServiceBase" #>
然后,我将 AngularResourceServiveBase 移动到它自己的文件中。再次运行模板时,我会发现生成的类仍派生自同一个基类,但模板不再生成此基类。现在,我可以根据需要随意更改这个基类了。
接下来,我将向此基类添加一些新方法和几个属性,以便其可以更轻松地为模板提供元数据。
为配合新添加的方法和属性,我还需要使用语句新增一些:
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
我将为 URL 以及我在本文开头创建的 RoslynDataProvider 添加属性:
public string Url { get; set; }
public RoslynDataProvider MetadataProvider { get; set; }
添加这些属性后,我还需要添加几个与 MetadataProvider 交互的方法,如图 11 所示。
图 11:添加到 AngularResourceServiceBase 的帮助程序方法
public IList<ClassDeclarationSyntax> GetControllers()
{
var project = MetadataProvider.GetWebApiProject();
return MetadataProvider.FindControllers(project).ToList();
}
protected IEnumerable<string> GetActions(ClassDeclarationSyntax controller)
{
return MetadataProvider.GetActions(controller);
}
protected IEnumerable<TypeInfo> GetModels(ClassDeclarationSyntax controller)
{
return MetadataProvider.GetModels(controller);
}
protected IEnumerable<ISymbol> GetProperties(IEnumerable<TypeInfo> models)
{
return MetadataProvider.GetProperties(models);
}
现在,我已将这些方法添加到基类,接下来可以扩展模板以使用这些属性和方法了。有关模板的具体变化,请参阅图 12。
图 12:模板的最终版本
<#@ template debug="false" hostspecific="false" language="C#" inherits="AngularResourceServiceBase" #>
var app = angular.module("challenge", [ "ngResource"]);
<#
var controllers = GetControllers();
foreach(var controller in controllers)
{
var className = controller.Identifier.Text.Replace("Controller", "");
#> app.facctory(<#=className #>Resource , function ($resource) {
return $resource('<#=Url#>/<#=className#>',{<#=className#> : '@<#=className#>'},{
<#
var models= GetModels(controller);
var properties = GetProperties(models);
foreach (var property in properties)
{
#>
<#=property.Name#> : "",
<#
}
var actions = GetActions(controller);
foreach (var action in actions)
{
#>
<#
if (action == "query")
{
#> query : {
method: "GET"
运行模板
由于这是运行时模板,因此我将负责设置用于运行模板的环境。图 13 展示了运行模板所需的代码。
图 13:运行运行时文本模板
private static void Main()
{
var work = MSBuildWorkspace.Create();
var solution = work.OpenSolutionAsync(Path to the Solution File).Result;
var metadata = new RoslynDataProvider() {Workspace = work};
var template = new AngularResourceService
{
MetadataProvider = metadata,
Url = @"http://localhost:53595/"
};
var results = template.TransformText();
var project = metadata.GetWebApiProject();
var folders = new List<string>() { "Scripts" };
var document = project.AddDocument("factories.js", results, folders)
.WithSourceCodeKind(SourceCodeKind.Script)
;
if (!work.TryApplyChanges(document.Project.Solution))
Console.WriteLine("Failed to add the generated code to the project");
Console.WriteLine(results);
Console.ReadLine();
}
在我保存模板或运行自定义工具时创建的类可以直接实例化。我可以设置或访问任何公共属性,也可以从基类调用任何公共方法。这就是属性值的设置方式。调用 TransformText 方法将运行模板,并以字符串的形式返回生成的代码。结果变量将保留生成的代码。其余代码负责将新文档添加到包含生成的代码的项目中。
不过,这部分代码存在一个问题。调用 AddDocuments 会成功创建一个文档并将其放入脚本文件夹中。调用 TryApplyChanges 会成功返回结果。当我查看解决方案时就会发现以下问题: 脚本文件夹中有一个工厂文件。问题在于这是 factories.cs,而不是 factories.js。配置的 AddDocument 方法不接受扩展名。无论扩展名如何,都将根据添加到的项目的类型添加文档。这是设计使然。
因此,在程序运行并生成 JavaScript 类后,文件将位于脚本文件夹中。我只需将扩展名从 .cs 更改为 .js 即可。
总结
本文用大篇幅重点介绍了如何使用 Roslyn 获取元数据。无论你计划如何使用元数据,都会发现本文介绍的这些做法非常有用。T4 代码适用于各种应用场景。如果你需要为 Roslyn 不支持的任何语言生成代码,T4 就非常合适,可以轻松纳入流程。这不失为一个好办法,因为使用 Roslyn 只能为 C# 和 Visual Basic .NET 生成代码,而使用 T4 可以生成任意类型的文本(SQL、JavaScript、HTML、CSS 或普通旧文本)。
令人高兴的是,能生成诸如 JavaScript 类之类的代码,因为这些代码生成起来很麻烦,而且还容易出错。也可以轻松遵循一种模式。你希望尽可能始终如一地遵循一种模式。最重要的是,你需要生成的代码可能会在一段时间内因最佳做法的形成而改变,尤其当有更新时。如果你要做的就是根据新出现的最佳做法更新 T4 模板,则更有可能遵循这些最佳做法;但如果你不得不修改手动生成的大量单调冗长的代码,则可能需要根据各个时期流行的最佳做法进行多种实现。
Nick Harrison是一位软件顾问,他和爱妻 Tracy、女儿生活在南卡罗来纳州的哥伦比亚。自 2002 年起,他一直专注于使用 .NET 开发完整堆栈,从而创建业务解决方案。请在 Twitter 上与他 (@Neh123us) 联系,其中还收录了他发布的博文、已发表的文章和演讲。
衷心感谢以下 Microsoft 技术专家对本文的审阅: James McCaffrey
ScriptoJames McCaffrey 供职于华盛顿地区雷蒙德市沃什湾的 Microsoft Research。他参与过多个 Microsoft 产品的工作,包括 Internet Explorer 和 Bing。Scripto可通过 jammc@microsoft.com 与 McCaffrey 取得联系。