微信号:cnmsdn

介绍:微软中国MSDN开发社区官方微信.

使用 Roslyn 和 T4 模板生成 JavaScript

2017-01-03 11:52 Nick Harrison

几天以前,我女儿告诉了我一个有关智能手机与功能手机之间对话的笑话。这个笑话是这样的: 智能手机对功能手机说: “我来自未来,你能弄明白我是怎么回事吗?” 有时,我们在学习新事物和最前沿的技术时就会有这种感觉。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 所示。


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);
    }


图 6:生成的 API Controller


向 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,并且都使用下面代码中展示的 Identify­Enumerable 方法:


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 取得联系。


有兴趣阅读原文的可以点击下面哦~

 
微软中国MSDN 更多文章 使用 Roslyn 和 T4 模板生成 JavaScript 2017年从派礼物开始吧 Windows 10部署的新发现 基于Azure的容器化DevOps数据中心 M姐准备了跨年礼物,有本事就来拿
猜您喜欢 QQ登录界面看腻了?修改1个文件让它瞬间高大上! 招聘一个程序员 开展大数据管理 打造“数据化企业” 马云,汉诺威演讲“数据驱动世界” 为什么现在会有这么多种编程语言?