我们在2020-3-3-使用T4模板进行C#代码生成 - huangtengxiao介绍了C#使用T4模板生成代码。
今天给大家详细介绍下T4模板的组成
T4模板的组成部分如图所示。主要由文本部分,以及非文本部分的指令(Directives)和控制块(Control blocks)组成。
下面是一个T4模板示例以及最终的生成效果。
文本是直接在生成文件中输出的内容。
所有的文本元素包括空格和缩进都会原封不动的输出到生成文件中。
指令块由<#@ #> 包裹。主要用于控制模板的处理方式。
例如<#@ assembly name=”System.Core” #> 这句指令,能够在处理模板时,引用System.Core程序集。
这样我们就可以在模板的控制块中,使用System.Core程序集中所包含的方法。
标准控制块由<# #> 包裹。主要用于表示一整段处理代码。
有了标准控制块,我们就可以利用诸如分支,选择等逻辑,对生成的代码进行控制。
表达式控制块由<#= #> 包裹。
当我们期望将一段表达式,或者某个变量的值,插入生成文本中,就可以使用表达式控制块。
这给了我们利用输入内容生成代码的能力。
类功能块由<#+ #> 包裹。他表示一些辅助方法。
例如我们这里定义了一个Foo方法返回一个数值的平方。
这可以大大减少重复代码的书写。
不过需要注意的是,类功能控制块只能够写在模板的末尾。
有过前端开发经验的同学一定了解模板文件的重要用户。其实C#也有类似的模板功能(T4模板),不仅可以生成html文件,还可以生成代码。今天就给大家介绍一下。
T4模板全称是Text Template Transformation Toolkit
,因为四个单词的开头字母都是T,所以称作T4模板。
T4模板是一种支持C#或者VB代码开发的模板格式,已经在Visual Studio,MonoDevelop,Rider这些主流IDE中得到支持。
T4不仅能支持在运行时动态生成Html网页这种常见需求,而且还可以在设计时生成各种语言的代码(不仅仅是C#),xaml,xml等以便于提升开发效率。
我们在项目上右键选择添加新项,在弹出的界面中搜索T4,可以得到两个结果。分别是文本模板(设计时T4模板)和运行时文本模板(运行时T4模板)。前者可以在开发时期或者编译时期生成,后者只能在运行时调用API生成。这里我们先选择文本模板。
这时我们在项目内就多了一个后缀为tt的模板文件。
我们把下面这段内容粘贴进去。注意如果是第一次使用vs可能会弹出一个提示框,选择确认即可。
<#@ template debug="false" hostspecific="false" language="C#" #>
<#@ assembly name="System.Core" #>
<#@ import namespace="System.Linq" #>
<#@ import namespace="System.Text" #>
<#@ import namespace="System.Collections.Generic" #>
<#@ output extension=".cs" #>
//这个是生成代码
using System;
namespace ConsoleApp2
{
public class GeneratedClass
{
static void Print()
{
Console.WriteLine("黄腾霄好帅!");
}
}
}
此时我们会发现多了一个同名的.cs文件,其中的代码就是我们刚刚粘贴的内容。
更重要的是,生成的代码就在这个项目中,可以直接使用。
光是生成静态文件肯定没啥意思,T4可以使用C#代码来辅助文件的生成。
我们下面使用这段代码填充带模板中。
<#@ template debug="false" hostspecific="false" language="C#" #>
<#@ assembly name="System.Core" #>
<#@ import namespace="System.Linq" #>
<#@ import namespace="System.Text" #>
<#@ import namespace="System.Collections.Generic" #>
<#@ output extension=".cs" #>
//这个是生成代码
using System;
namespace ConsoleApp2
{
class GeneratedClass
{
public static void Print()
{
<#
for (int i = 0; i < 5; i++)
{
#>
Console.WriteLine("黄腾霄好帅+<#=i+1#>!");
<#
}
#>
}
}
}
这里所有被<# #>包围的代码是模板编译时会执行的代码。
这里的代码表示将Console.WriteLine("黄腾霄好帅+<#=i+1#>!");
在生成文件中输出5次。
其中<#=i+1#>表示将表达式i+1的值转为字符串填充至模板的生成文件中。
结果如下
值得注意的是,这里的i+1输出随着循环进行而更新。这说明所在的模板中的代码块都隶属于同一个上下文。
可以实现变量的传递。
至此相信你已经可以使用T4模板完成基本的代码生成功能开发了。当然本文作为入门介绍还有很多细节没有介绍。这里可以在微软的官方文档中找到更加详细的介绍:Writing a T4 Text Template - Visual Studio -Microsoft Docs
当然也可以关注我之后关于T4模板的系列博客。
参考文档:
C# 8.0 引入了可为空引用类型和不可为空引用类型。由于这是语法级别的支持,所以比传统的契约式编程具有更强的约束力。更容易帮助我们消灭 null
异常。
本文将介绍如何在项目中开启 C# 8.0 的可空引用类型的支持。
如果你还在使用旧的项目文件,请先升级成 Sdk 风格的项目文件:将 WPF、UWP 以及其他各种类型的旧 csproj 迁移成 Sdk 风格的 csproj - 吕毅。
本文会示例一个项目文件。
由于现在 C# 8.0 还没有正式发布,所以如果要启用 C# 8.0 的语法支持,需要在项目文件中设置 LangVersion
属性为 8.0
而不能指定为 latest
等正式版本才能使用的值。
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>netcoreapp3.0</TargetFramework>
<LangVersion>8.0</LangVersion>
</PropertyGroup>
</Project>
在项目属性中添加一个属性 NullableContextOptions
:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>netcoreapp3.0</TargetFramework>
<LangVersion>latest</LangVersion>
++ <Nullable>enable</Nullable>
</PropertyGroup>
</Project>
此属性可被指定为以下四个值之一:
enable
warnings
annotations
disable
这五个值其实是两个不同维度的设置排列组合之后的结果:
当仅仅启用警告上下文而不开启可为空注释上下文,那么编译器将仅仅识别局部变量中明显可以判定出对 null 解引用的代码,而不会对包括变量或者参数定义部分进行分析。
以上只是警告,如果你希望更严格地执行可空引用的建议,可以考虑使用编译错误:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>netcoreapp3.0</TargetFramework>
++ <LangVersion>latest</LangVersion>
++ <Nullable>enable</Nullable>
++ <WarningsAsErrors>$(WarningsAsErrors);CS8600;CS8601;CS8602;CS8603;CS8604;CS8609;CS8610;CS8616;CS8618;CS8619;CS8622;CS8625</WarningsAsErrors>
</PropertyGroup>
</Project>
详见:
当启动可为空注释上下文后,C# 编译器会将所有的类型引用变量识别为以下种类:
于是,当你写出 string walterlv
的变量定义,那么 walterlv
就是不可为空的引用类型;当写出 string? walterlv
的变量定义,那么 walterlv
就是可为空的引用类型。
对于类型参数来说,可能不能确定是否是可空引用类型,那么将视为“未知”。
当关闭可为空注释上下文后,C# 编译器会将所有类型引用变量识别为以下种类:
于是,无论你使用什么方式顶一个一个引用类型的变量,C# 编译器都不会判定这到底是不是一个可为空还是不可为空的引用类型。
例如以下代码:
string walterlv = null;
var value = walterlv.ToString();
在将 null
赋值给 walterlv
变量时,是不会引发程序异常的;而在后面调用了 ToString()
方法则会引发程序异常。
安全性区别就在这里。安全性警告仅会将编译期间可识别到可能运行时异常的代码进行警告(即下面的 walterlv.ToString()
),而不会对没有异常的代码进行警告。如果是 enable
,那么将 null
赋值给 walterlv
变量的那一句也会警告。
除了在项目文件中全局开启可空引用类型的支持,也可以在 C# 源代码文件中覆盖全局的设定。
#nullable enable
: 在源代码中启用可空引用类型并给出警告。#nullable disable
: 在源代码中禁用可空引用类型并关闭警告。#nullable restore
: 还原这段代码中可空引用类型和可空警告。#nullable disable warnings
: 在源代码中禁用可空警告。#nullable enable warnings
: 在源代码中启用可空警告。#nullable restore warnings
: 还原这段代码中可空警告。#nullable disable annotations
: 在源代码中禁用可空引用类型。#nullable enable annotations
: 在源代码中启用用可空引用类型。#nullable restore annotations
: 还原这段代码中可空引用类型。在接近正式版的时候,开关才是 Nullable
,而之前是 NullableContextOptions
,但在 Visual Studio 2019 Preview 2 之前,则是 NullableReferenceTypes
。现在,这些旧的属性已经废弃。
ReSharper 从 2019.1.1 版本开始支持 C# 8.0,如果使用早期版本,就会到处报错。
但是,由于 C# 8.0 可空引用类型的特性总在变,所以建议使用 2019.2.3 或以上版本,这是 C# 8.0 正式版本发布之后的 ReSharper。
参考资料
C# 8.0 引入了可为空引用类型和不可为空引用类型。当你需要给你或者团队更严格的要求时,可能需要定义这部分的警告和错误级别。
本文将介绍 C# 可空引用类型部分的警告和错误提示,便于进行个人项目或者团队项目的配置。
本文的内容本身没什么意义,但如果你试图进行一些团队配置,那么本文的示例可能能带来一些帮助。
CS8600
将 null 文本或可能的 null 值转换为非 null 类型。
string walterlv = null;
CS8601
可能的 null 引用赋值。
string Text { get; set; }
void Foo(string? text)
{
// 将可能为 null 的文本向不可为 null 的类型赋值。
Text = text;
}
CS8602
null 引用可能的取消引用。
// 当编译器判定 walterlv 可能为 null 时才会有此警告。
var value = walterlv.ToString();
CS8603
可能的 null 引用返回。
string Foo()
{
return null;
}
CS8604
将可能为 null
的引用作为参数传递到不可为 null
的方法中:
void Foo()
{
string text = GetText();;
Bar(text);
}
string? GetText()
{
return null;
}
CS8609
返回类型中引用类型的为 Null 性与重写成员不匹配。
比如你的基类中返回值不允许为 null,但是实现中返回值却允许为 null。
protected virtual async Task<string> FooAsync()
{
}
protected override async Task<string?> FooAsync()
{
}
CS8610
参数中引用类型的为 Null 性与重写成员不匹配。
比如你的基类中方法参数值不允许为 null,但是实现中方法参数却允许为 null。
protected virtual void FooAsync(string value)
{
}
protected override void FooAsync(string? value)
{
}
CS8616
接口中定义的成员中的 null 性与实现中成员的 null 型不匹配。
比如你的接口中不允许为 null,但是实现中却允许为 null。
CS8618
未初始化不可以为 null 的字段 “_walterlv”。
如果一个类型中存在不可以为 null 的字段,那么需要在构造函数中初始化,如果没有初始化,则会发出警告或者异常。
CS8619
一个类型与构造这个类型的 null 性不匹配。
例如:
Task<object?> foo = new Task<object>(() => new object());
CS8622
委托定义的参数中引用类型的为 null 性与目标委托不匹配。
比如你定义了一个委托:
void Foo(object? sender, EventArgs e);
然而在订阅事件的时候,使用的函数 null 性不匹配,则会出现警告:
void OnFoo(object sender, EventArgs e)
{
// 注意到这里的 object 本应该写作 object?
}
CS8625
无法将 null 文本转换为非 null 引用或无约束类型参数。
void Foo(string walterlv = null)
{
}
CS8653
对于泛型 T,使用 default
设置其值。如果 T 是引用类型,那么 default
就会将这个泛型类型赋值为 null
。然而并没有将泛型 T 的使用写为 T?。
我只是增加库的一个 API,比如增加几个类而已,应该不会造成兼容性问题吧。对于编译好的二进制文件来说,不会造成兼容性问题;但——可能造成源码不兼容。
本文介绍可能的源码不兼容问题。
This post is written in multiple languages. Please select yours:
比如我有一个项目 P 引用 A 和 B 两个库。其中使用到了 A 库中的 Walterlv.A.Diagnostics.Foo
类型。
using Walterlv.A;
using Walterlv.B;
namespace Walterlv.Demo
{
class Hello
{
Run(Diagnostics.Foo foo)
{
}
}
}
现在,我们在 B 库中新增一个类型 Walterlv.B.Diagnostics.Bar
类型。
那么上面的代码将无法完成编译,因为 Diagnosis
命名空间将具有不确定的含义,其中的 Foo
类型也将无法在不确定的命名空间中找到。
因此:
using
),要么写全命名空间(从第一段开始写,不要省略任何部分),否则就容易与其他命名空间冲突;是的,即使是单纯的新增 API 也可能会导致使用库的一方在源码级不兼容。当然二进制还是兼容的。
另外,OpportunityLiu 提醒,如果命名空间是 Walterlv.B.Walterlv.A.Diagnostics.Bar
,一样可以让写全了的命名空间炸掉。呃……还是不要在库里面折腾这样的命名空间好……不然代码当中到处充斥着 global::
可是非常难受的。
启用转为编程设计的连字字体,可以给你的变成带来不一样的体验。
微软随 Windows Terminal 设计了一款新的字体 Cascadia Code,而这是一款连字字体。
你可以看到,在 Windows Terminal 的终端中,=>
==
!=
符号显示成了更容易理解的连字符号:
在 Cascadia Code 发布之前,Fira Code 是一款特别火的连字字体,下面是 Fira Code 连字字体在 Visual Studio Code 中的显示效果:
而显示的,其实是下面这一段代码:
x =>
{
if (x >= 2 || x == 0)
{
Console.WriteLine(" >=> 欢迎访问吕毅的博客 ~~> blog.walterlv.com");
}
}
作为微软的粉丝,当然首推 Cascadia Code!不过我喜欢比较细的字体风格,目前 Cascadia Code 还没有提供细体,因此我可能还需要等一些时间才正式入坑。
在这里可以关注 Cascadia Code 的状态:
灵台,你也可以在这里找到其他一些好看的用于编程的连字字体:
相关的开源项目链接:
以 Fira Code 为例安装的话,去它的 GitHub 的 release 页面:
下载最新的发布文件 FiraCode_1.207.zip。
下载解压后,你会看到五个不同的文件夹,这是四种不同的字体类型:
对于 Open Type 和 True Type 的选择,一般有对应的 Open Type 类型字体的时候就优先选择 Open Type 类型的,因为 True Type 格式是比较早期的,限制比较多,比如字符的数量受到限制,而 Open Type 是基于 Unicode 字符集来设计的新的跨平台的字体格式。
Variable True Type 是可以无极变换的 True Type 字体。
而 Web Open Font Format 主要为网络传输优化,其特点是字体均经过压缩,其大小会比较小。
我们点击进入 otf
文件夹,然后全选所有的字体文件,右键,安装,等待安装完成即可。
在 Visual Studio Code 中启用连字字体需要用到两个选项:
"editor.fontFamily": "Fira Code Light, Consolas, Microsoft YaHei",
"editor.fontLigatures": true,
然后点击新打开的标签右上角的 {}
图标以打开 json 形式编辑的设置:
然后修改把上面两个设置增加或替换进去即可。下面是我的设置的部分截图:
只需要将字体设置成 Fira Code 即可。
参考资料
.NET Core 3 相比于 .NET Core 2 是一个大更新。也正因为如此,即便它长时间处于预览版尚未发布的状态,大家也一直在使用。
Visual Studio 2019 中提供了使用 .NET Core SDK 预览版的开关。但几个更新的版本其开关的位置不同,本文将介绍在各个版本中的位置,方便你找到然后设置。
.NET Core 3.0 已经发布,下载地址:
Visual Studio 16.3 与 .NET Core 3.0 正式版同步发布,因此不再需要 .NET Core 3.0 的预览版设置界面。你只需要安装正式版 .NET Core SDK 即可。
从 Visual Studio 2019 的 16.2 版本,.NET Core 预览版的设置项的位置在:
工具
-> 选项
环境
-> 预览功能
-> Use previews of the .NET Core SDK (需要 restart)
如果你是英文版的 Visual Studio,也可以参考英文版:
Tools
-> Options
Environment
-> Preview Features
-> Use previews of the .NET Core SDK (requires restart)
从 Visual Studio 2019 的 16.1 版本,.NET Core 预览版的设置项的位置在:
工具
-> 选项
环境
-> 预览功能
-> 使用 .NET Core SDK 的预览
如果你是英文版的 Visual Studio,也可以参考英文版:
Tools
-> Options
Environment
-> Preview Features
-> Use previews of the .NET Core SDK
在 Visual Studio 2019 的早期,.NET Core 在设置中是有一个专用的选项的,在这里:
工具
-> 选项
项目和解决方案
-> .NET Core
-> 使用 .NET Core SDK 预览版
如果你是英文版的 Visual Studio,也可以参考英文版:
Tools
-> Options
Projects and solutions
-> .NET Core
-> Use previews of the .NET Core SDK
Visual Studio 2019 中此对于 .NET Core SDK 的预览版的设置是全局生效的。
也就是说,你在 Visual Studio 2019 中进行了此设置,在命令行中使用 MSBuild
或者 dotnet build
命令进行编译也会使用这样的设置项。
那么这个全局的设置项在哪个地方呢?是如何全局生效的呢?可以阅读我的其他博客:
Visual Studio 的功能可谓真是丰富,再配合各种各样神奇强大的插件,Visual Studio 作为太阳系最强大的 IDE 名副其实。
如果你能充分利用起 Visual Studio 启用这些功能的快捷键,那么效率也会很高。
功能 | 快捷键 | 建议修改成 |
---|---|---|
重构 | Ctrl + . |
Alt + Enter |
转到所有 | Ctrl + , |
Ctrl + N |
重命名 | F2 |
|
打开智能感知列表 | Ctrl + J |
Alt + 右 |
注释 | Ctrl + K, Ctrl + C |
|
取消注释 | Ctrl + K, Ctrl + U |
|
保存全部文档 | Ctrl + K, S |
|
折叠成大纲 | Ctrl + M, Ctrl + O |
|
展开所有大纲 | Ctrl + M, Ctrl + P |
|
加入书签 | Ctrl + K, Ctrl + K |
|
上一书签 | Ctrl + K, Ctrl + P |
|
下一书签 | Ctrl + K, Ctrl + N |
|
切换自动换行 | Alt + Z |
你可以不记住本文的其他任何快捷键,但这个你一定要记住,那就是:
当然,因为中文输入法会占用这个快捷键,所以我更喜欢将这个快捷键修改一下,改成:
修改方法可以参见:如何快速自定义 Visual Studio 中部分功能的快捷键。
它的功能是“快速操作和重构”。你几乎可以在任何代码上使用这个快捷键来快速修改你的代码。
比如修改命名空间:
比如提取常量或变量:
比如添加参数判空代码:
还有更多功能都可以使用此快捷键。而且因为 Roslyn 优秀的 API,有更多扩展可以使用此快捷键生效,详见:基于 Roslyn 同时为 Visual Studio 插件和 NuGet 包开发 .NET/C# 源代码分析器 Analyzer 和修改器 CodeFixProvider。
不能每次都去解决方案里面一个个找文件,对吧!所以一个快速搜索文件和符号的快捷键也是非常能够提升效率的。
Ctrl + ,
转到所有(go to all)
不过我建议将其改成:
Ctrl + N
这是 ReSharper 默认的转到所有(Goto Everything)的快捷键
这可以帮助你快速找到整个解决方案中的所有文件或符号,看下图:
修改方法可以参见:如何快速自定义 Visual Studio 中部分功能的快捷键,下图是此功能的命令名称 编辑.转到所有
(Edit.GoToAll
):
有一些小技巧:
mw
就可以找到 MainWindow
PrivateTokenManager
,如果希望干扰少一些,建议输入 PTM
而不是 ptm
;当然想要更少的干扰,可以打更多的字母,例如 priToM
等等注意到上面的界面里面右上角有一些过滤器吗?这些过滤器有单独的快捷键。这样就直接搜索特定类型的符号,而不是所有了,可以提高查找效率。
Ctrl + O
查找当前文件中的所有成员(只搜一个文件,这可以大大提高命中率)
Ctrl + T
转到符号(只搜类型名称、成员名称)
Ctrl + G
查找当前文件的行号(比如你在代码审查中看到一行有问题的代码,得知行号,可以迅速跳转到这一行)
F2
如果你在一个标识符上直接重新输入改了名字,也可以通过 Ctrl + .
或者 Alt + Enter
完成重命名。
这些都可以被最上面的 Ctrl + .
或者 Alt + Enter
替代,因此都可以忘记。
Ctrl + R, Ctrl + E
封装字段
Ctrl + R, Ctrl + I
提取接口
Ctrl + R, Ctrl + V
删除参数
Ctrl + R, Ctrl + O
重新排列参数
IntelliSense 以前有个漂亮的中文名字,叫做“智能感知”,不过现在大多数的翻译已经与以前的另一个平淡无奇的功能结合到了一起,叫做“自动完成列表”。Visual Studio 默认只会让智能感知列表发挥非常少量的功能,如果你不进行一些配置,使用起来会“要什么没什么”,想显示却不显示。
请通过另一篇博客中的内容把 Visual Studio 的智能感知列表功能好好配置一下,然后我们才可以再次感受到它的强大(记得要翻到最后哦):
如果还有一些时机没有打开智能感知列表,可以配置一个快捷键打开它,我这边配置的快捷键是 Alt + 右
。
Ctrl + Shift + 空格
显示方法的参数信息。
默认在输入参数的时候就已经会显示了;如果错过了,可以在输入 ,
的时候继续出现;如果还错过了,可以使用此快捷键出现。
Ctrl + K, Ctrl + E
全文代码清理(包含全文代码格式化以及其他功能)
Shift + Alt + F
全文代码格式化
Ctrl + K, Ctrl + F
格式化选定的代码
关于代码清理,你可以配置做哪些事情:
Ctrl + K, Ctrl + /
将当前行注释或取消注释
Ctrl + K, Ctrl + C
将选中的代码注释掉
Ctrl + K, Ctrl + U
或 Ctrl + Shift + /
将选定的内容取消注释
Ctrl + U
将当前选中的所有文字转换为小写(请记得配合 F2 重命名功能使用避免编译不通过)
Ctrl + ]
增加行缩进
Ctrl + [
减少行缩进
Ctrl + S
保存文档
Ctrl + K, S
保存全部文档(注意按键,是按下 Ctrl + K
之后所有按键松开,然后单按一个 S
)
Ctrl + F
打开搜索面板开始强大的搜索功能
Ctrl + H
打开替换面板,或展开搜索面板为替换面板
Ctrl + I
渐进式搜索(就像 Ctrl + F 一样,不过不会抢焦点,搜索完按回车键即完成搜索,适合键盘党操作)
Ctrl + Shift + F
打开搜索窗口(与 Ctrl + F 虽然功能重合,但两者互不影响,意味着你可以充分这两套搜索来执行两套不同的搜索配置)
Ctrl + Shift + H
打开替换窗口(与 Ctrl + H 虽然功能重合,但两者互不影响,意味着你可以充分这两套替换来执行两套不同的替换配置)
Alt + 下
在当前文件中,将光标定位到下一个方法
Alt + 上
在当前文件中,将光标定位到上一个方法
Ctrl + M, Ctrl + M
将光标当前所在的类/方法切换大纲的展开或折叠
Ctrl + M, Ctrl + L
将全文切换大纲的展开或折叠(如果当前有任何大纲折叠了则全部展开,否则全部折叠)
Ctrl + M, Ctrl + P
将全文的大纲全部展开
Ctrl + M, Ctrl + U
将光标当前所在的类/方法大纲展开
Ctrl + M, Ctrl + O
将全文的大纲都折叠到定义那一层
Ctrl + D
查找下一个相同的标识符,然后放一个新的脱字号(或者称作输入光标)(多次点按可以在相同字符串上出很多光标,可以一起编辑,如下图)
Ctrl + Insert
查找所有相同的标识符,然后全部放置脱字号(如下图)
脱字号 是 Visual Studio 中对于输入光标的称呼,对应英文的 Caret。
Ctrl + K, Ctrl + K
为当前行加入到书签或从书签中删除
Ctrl + K, Ctrl + P
切换到上一个书签
Ctrl + K, Ctrl + N
切换到下一个书签
Ctrl + K, Ctrl + L
删除所有书签(会有对话框提示的,不怕误按)
如果配合书签面板,那么可以在调查问题的时候很方便在找到的各种关键代码处跳转,避免每次都寻找。
另外,还有个任务列表,跟书签列表差不多的功能:
Ctrl + K, Ctrl + H
将当前代码加入到任务列表中或者从列表中删除(效果类似编写 // TODO
)
Ctrl + R, Ctrl + W
显示空白字符
Alt + Z
切换自动换行和单行模式
使用 Visual Studio 开发 C#/.NET 应用程序,以前有 ReSharper 来不足其各项功能短板,后来不断将 ReSharper 的功能一点点搬过来稍微好了一些。不过直到 Visual Studio 2019,才开始渐渐可以和 ReSharper 拼一下了。
如果你使用 Visual Studio 2019,那么像本文这样配置一下,可以大大提升你的开发效率。
打开菜单 “工具” -> “选项”,然后你就打开了 Visual Studio 的选项窗口。接下来本文的所有内容都会在这里进行。
在 “文本编辑器” -> “常规” 分类中,我们关心这些设置:
使鼠标单击可执行转到定义
这样按住 Ctrl 键点击标识符的时候可以转到定义(开启此选项之后,后面有其他选项可以转到反编译后的源码)当然也有其他可以打开玩的:
查看空白
专治强迫症,可以把空白字符都显示出来,这样你可以轻易看到对齐问题以及多于的空格了在 “文本编辑器” -> “C#” -> “IntelliSense” 分类中,我们关心这些设置:
键入字符后显示完成列表
删除字符后显示完成列表
突出显示完成列表项的匹配部分
显示完成项筛选器
打开这些选项可以让智能感知列表更容易显示出来,而我们也知道智能感知列表的强大显示 unimported 命名空间中的项(实验)
这一项默认不会勾选,但强烈建议勾选上;它可以帮助我们直接输入没有 using 的命名空间中的类型,这可以避免记住大量记不住的类名在 “文本编辑器” -> “C#” -> “高级” 分类中,我们关心大量设置:
支持导航到反编译源(实验)
前面我们说可以 Ctrl + 鼠标导航到定义,如果打开了这个就可以看反编译后的源码了启用可为 null 的引用分析 IDE 功能
这个功能可能还没有完成,暂时还是无法开启的当然也有其他可以打开玩的:
启用完成解决方案分析
这是基于 Roslyn 的分析,Visual Studio 的大量重构功能都依赖于它;默认关闭也可以用,只是仅分析当前正在编辑的文件;如果打开则分析整个解决方案,你会在错误列表中看到大量的编译警告在 “文本编辑器” -> “C#” -> “代码样式” 分类,如果你关心代码的书写风格,那么这个分类底下的每一个子类别都可以考虑一个个检查一下。
Visual Studio 2019 默认安装了 IntelliCode 可以充分利用微软使用 GitHub 上开源项目训练出来的模型来帮助编写代码。这些强烈建议开启。
C# 基础模型
微软利用 GitHub 开源项目训练的基础模型XAML 基础模型
微软利用 GitHub 开源项目训练的基础模型C# 参数完成
C# 自定义模型
如果针对单个项目训练出来了模型,那么可以使用专门针对此项目训练的模型EditorConfig 推理
可以根据项目推断生成 EditorConfig 文件 可以参见在 Visual Studio 中使用 EditorConfig 统一代码风格自定义模型训练提示
如果开启,那么每个项目的规模如果达到一定程度就会提示训练一个自定义模型出来训练模型会上传一部分数据到 IntelliCode 服务器,你可以去 %TEMP%\Visual Studio IntelliCode
目录来查看到底上传了哪些数据。
当然,设置好快捷键也是高效编码的重要一步,可以参考:
在你点击 “确定” 关闭了以上窗口之后,我们还需要设置一项。
确保下图中的这个按钮处于 “非选中” 状态:
这样,当出现智能感知列表的时候,我们直接就可以按下回车键输入这个选项了;否则你还需要按上下选中再回车。
我们做的公共库可能通过 nuget.org 发布,也可能是自己搭建 NuGet 服务器。但是,如果某个包正在开发中,需要快速验证其是否解决掉一些诡异的 bug 的话,除了单元测试这种间接的测试方法,还可以在本地安装未发布的 NuGet 包的方法来快速调试。
本文介绍如何本地打包发布 NuGet 包,然后通过 mklink 收集所有的本地包达到快速调试的目的。
我有另一篇博客介绍如何将本地文件夹设置称为 NuGet 包源:
在 Visual Studio 中打开 工具
-> 选项
-> NuGet 包管理器
-> 包源
可以直接将一个本地文件夹设置称为 NuGet 包源。
其他设置方法可以去那篇博客当中阅读。
如下图,是我通过 mklink 将散落在各处的 NuGet 包的调试输出目录收集了起来:
比如,点开其中的 Walterlv.Packages
可以看到 Walterlv.Packages
仓库中输出的 NuGet 包:
由于我的每一个文件夹都是指向的 Visual Studio 编译后的输出目录,所以,只需要使用 Visual Studio 重新编译一下项目,文件夹中的 NuGet 包即会更新。
于是,这相当于我在一个文件夹中,包含了我整个计算机上所有库项目的 NuGet 包,只需要将这个文件夹设置称为 NuGet 包源,即可直接调试本地任何一个公共组件库打出来的 NuGet 包。
如下图,是我将那个收集所有 NuGet 文件夹的目录设置成为了 NuGet 源:
于是,我可以在 Visual Studio 的包管理器中看到所有还没有发布的,依然处于调试状态的各种库:
基于此,我们可以在包还没有编写完的时候调试,验证速度非常快。
本文介绍在使用 Visual Studio 2019 或者命令行执行 MSBuild
dotnet build
命令时,决定是否使用 .NET Core SDK 预览版的全局配置文件。
指定是否使用 .NET Core 预览版 SDK 的全局配置文件在:
%LocalAppData%\Microsoft\VisualStudio\16.0_xxxxxxxx\sdk.txt
其中 %LocalAppData%
是 Windows 系统中自带的环境变量,16.0_xxxxxxxx
在不同的 Visual Studio 版本下不同。
比如,我的路径就是 C:\Users\lvyi\AppData\Local\Microsoft\VisualStudio\16.0_0b1a4ea6\sdk.txt
。
这个文件的内容非常简单,只有一行:
UsePreviews=True
你一定觉得奇怪,我们在 Visual Studio 2019 中设置了使用 .NET Core SDK 预览版之后,这个配置是全局生效的,即便在命令行中运行 MSBuild
或者 dotnet build
也是会因此而使用预览版或者正式版的。但是这个路径明显看起来是 Visual Studio 的私有路径。
虽然这很诡异,但确实如此,不信,可以看我是如何确认这个文件就是 .NET Core SDK 预览版的全局配置的:
另外,如果你想知道如何在 Visual Studio 2019 中指定使用 .NET Core SDK 的预览版,可以参考我的另外一篇博客:
You might just add some simple APIs in your library and you’ll not think that will break down your compatibility. But actually, it might, that is – the source-code compatibility.
This post is written in multiple languages. Please select yours:
Assume that we’ve written a project P which references another two libraries A and B. And we have a Walterlv.A.Diagnostics.Foo
class in library A.
using Walterlv.A;
using Walterlv.B;
namespace Walterlv.Demo
{
class Hello
{
Run(Diagnostics.Foo foo)
{
}
}
}
And now we add a new class Walterlv.B.Diagnostics.Bar
class into the B library. That is adding a new API only.
Unfortunately, the code above would fail to compile because of the ambiguity of Diagnostics
namespace. The Foo
class cannot be found in an ambiguity namespace.
I write this post down to tell you that there may be source-code compatibility issue even if you only upgrade your library by simply adding APIs.
做库的时候,需要一定程度上保持 API 的兼容性
首先打开你的库项目,或者如果你希望从零开始也可以直接新建一个项目。这里为了博客阅读的简单,我创建一个全新的项目来演示。
然后,为主要的库项目安装 NuGet 包:
安装完成之后,你的项目文件(.csproj)可能类似于下面这样:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.PublicApiAnalyzers" Version="2.9.3" />
</ItemGroup>
</Project>
在你的项目内创建两个文件:
这就是两个普通的文本文件。创建纯文本文件的方法是在项目上右键 -> 添加
-> 新建项...
,然后在打开的模板中选择 文本文件
,使用上面指定的名称即可(要创建两个)。
然后,编辑项目文件,我们需要将这两个文件加入到项目中来。
如果你看不到上图中的“编辑项目文件”选项,则需要升级项目文件到 SDK 风格,详见:
然后,将这两个文件添加为 AdditionalFiles
:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.PublicApiAnalyzers" Version="2.9.3" />
</ItemGroup>
+ <ItemGroup>
+ <AdditionalFiles Include="PublicAPI.Shipped.txt" />
+ <AdditionalFiles Include="PublicAPI.Unshipped.txt" />
+ </ItemGroup>
</Project>
如果你把这两个文件放到了其他的路径,那么上面也需要改成对应的路径。
这时,这两个文件内容还是空的。
这个时候,你会看到库中的 public
类、方法、属性等都会发出修改建议,说此符号并不是已声明 API 的一部分。
点击小灯泡,即可将点击所在的 API 加入到 PublicAPI.Unshipped.txt
中。
我将两个 API 都添加之后,PublicAPI.Unshipped.txt
文件中现在是这样的(注意有一个隐式构造函数哦):
Walterlv.PackageDemo.ApiTracking.Class1
Walterlv.PackageDemo.ApiTracking.Class1.Class1() -> void
Walterlv.PackageDemo.ApiTracking.Class1.Foo.get -> string
现在,我们将 Foo 属性改名成 Foo2 属性,于是就会出现编译警告:
RS0016 Symbol ‘Foo2.get’ is not part of the declared API.
RS0017 Symbol ‘Walterlv.PackageDemo.ApiTracking.Class1.Foo.get -> string’ is part of the declared API, but is either not public or could not be found
提示 Foo2
属性不是已声明 API 的一部分,而 Foo
属性虽然是已声明 API 的一部分,但已经找不到了。
这种提示对于保持库的兼容性是非常有帮助的。
在分析器的规则上面右键,可以为某项规则设置严重性。
这时,再编译即会报告编译错误。
项目中也会多一个规则集文件:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
+ <CodeAnalysisRuleSet>Walterlv.PackageDemo.ApiTracking.ruleset</CodeAnalysisRuleSet>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.PublicApiAnalyzers" Version="2.9.3" />
</ItemGroup>
<ItemGroup>
<AdditionalFiles Include="PublicAPI.Shipped.txt" />
<AdditionalFiles Include="PublicAPI.Unshipped.txt" />
</ItemGroup>
</Project>
前面我们都是在 PublicAPI.Unshipped.txt
文件中追踪 API。但是如果我们的库需要发布一个版本的时候,我们就需要跟上一个版本比较 API 的差异。
上一个发布版本的 API 就记录在 PublicAPI.Shipped.txt
文件中,这两个文件的差异即是这两个版本的 API 差异。在一个新的版本发布后,就需要将 API 归档到 PublicAPI.Shipped.txt
文件中。
参考资料
你是否好奇 Visual Studio 2019 中的 .NET Core SDK 预览版开关是全局生效的,那个全局的配置在哪里呢?
本文将和你一起探索找到这个全局的配置文件。
Process Monitor 是微软极品工具箱的一部分,你可以在此页面下载:
当你一开始打开 Process Monitor 的时候,列表中会立刻刷出大量的进程的操作记录。这么多的记录会让我们找到目标进程操作的文件有些吃力,于是我们需要设置规则。
Process Monitor 的工具栏按钮并不多,而且我们这一次的目标只会用到其中的两个:
在工具栏上点击“设置过滤器”,然后,添加我们感兴趣的两个进程名称:
devenv.exe
MSBuild.exe
前者是 Visual Studio 的进程名,后者是 MSBuild.exe 的进程名。我们使用这两个进程名称分别找到 Visual Studio 2019 是如何设置全局 .NET Core 预览配置的,并且在命令行中运行 MSBuild.exe 来验证确实是这个全局配置。
然后排除除了文件意外的所有事件类型,最终是如下过滤器:
现在,我们打开 Visual Studio 2019,然后停留到下面这个界面中。改变一下 .NET Core SDK 预览版选项的勾选状态。
现在,我们点击一下“确定”,将立即可以在 Process Monitor 中看到一些文件的修改:
上面是在点击“确定”按钮一瞬间 Visual Studio 2019 的所有文件操作。你可以注意到左侧的时间,我的截图中从 45 秒到 48 秒是可能有效的文件读写,再后面已经延迟了 10 秒了,多半是其他的操作。
在这些文件中,可以很明显看到文件分为三类:
sdk.txt
一个不知名的文件,但似乎跟我们的 .NET Core SDK 相关SettingsLogs
一看就是给设置功能用的日志VSApplicationInsights
一看就是数据收集相关通过排除法,我们能得知最关键的文件就是那个 sdk.txt
。去看一看那个文件的内容,发现只有一行:
UsePreviews=True
这基本上可以确认 Visual Studio 2019 设置是否使用 .NET Core SDK 预览版就是在这个文件中。
不过,这带来一个疑惑,就是这个路径特别不像是 .NET Core SDK 的配置路径,倒像是 Visual Studio 自己的设置配置。
于是必须通过其他途径来确认这是否就是真实的全局配置。
现在,我们清除一下 Process Monitor 中的已经记录的数据,然后,我们在命令行中对一个项目敲下 msbuild
命令。
> msbuild
然后在 Process Monitor 里面观察事件。这次发现事件相当多,于是换个方式。
因为我们主要是验证 sdk.txt
文件,但同时希望看看是否还有其他文件。于是我们将 sdk.txt
文件相关的事件高亮。
点击 Filter
-> Highlight...
,然后选择 Path
contains
sdk.txt
时则 Include
。
这时,再看捕获到的事件,可以发现编译期间确实读取了这个文件。
此举虽不能成为此文件是全局配置的铁证,但至少说明这个文件与全局配置非常相关。
另外,继续在记录中翻找,还可以发现与此配置可能相关的两个 dll:
要验证此文件确实是全局配置其实也很简单,自行改一改配置,然后使用 MSBuild.exe 编译试试即可。
现在,将 sdk.txt 文件内容改为:
UsePreviews=False
编译一下使用了 .NET Core 3.0 新特性的项目(我使用了 Microsoft.NET.Sdk.WindowsDesktop,这是 3.0 才有的 SDK)。
编译错误,提示 Microsoft.NET.Sdk.WindowsDesktop 这个 SDK 没有找到。
现在,将 sdk.txt 文件内容改为:
UsePreviews=True
编译相同的项目,发现可以正常编译通过了。
这可以证明,此文件正是决定是否使用预览版的决定性证据。
但值得注意的是,打开 Visual Studio 2019 后,发现其设置界面并没有应用此文件最新的修改,这可以说 Visual Studio 2019 的配置是不止这一处。
通过反编译探索的方式感谢小伙伴 KodamaSakuno (神樹桜乃) 彻夜寻找。
相关的代码在 cli/VSSettings.cs at master · dotnet/cli 中,你可以前往查看。
在 VSSettings
的构造函数中,为字段 _settingsFilePath
赋值,拼接了 sdk.txt
文件的路径。
_settingsFilePath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Microsoft",
"VisualStudio",
version.Major + ".0_" + instanceId,
"sdk.txt");
读取时,使用此路径下的 sdk.txt
文件读取了 UsePreviews
变量的值。
private void ReadFromDisk()
{
using (var reader = new StreamReader(_settingsFilePath))
{
string line;
while ((line = reader.ReadLine()) != null)
{
int indexOfEquals = line.IndexOf('=');
if (indexOfEquals < 0 || indexOfEquals == (line.Length - 1))
{
continue;
}
string key = line.Substring(0, indexOfEquals).Trim();
string value = line.Substring(indexOfEquals + 1).Trim();
if (key.Equals("UsePreviews", StringComparison.OrdinalIgnoreCase)
&& bool.TryParse(value, out bool usePreviews))
{
_disallowPrerelease = !usePreviews;
return;
}
}
}
// File does not have UsePreviews entry -> use default
_disallowPrerelease = _disallowPrereleaseByDefault;
}
我这里使用 Visual Studio 2019 能好好编译的一个项目,发现在另一个小伙伴那里却编译不通过,是在 NuGet 还原那里报告了错误:
调用的目标发生了异常。Error parsing the nested project section in solution file.
本文介绍如何解决这样的问题。
此问题的原因可能有多种:
Project
和 EndProject
不成对,导致某个项目没有被识别出来Project
部分发现对应的项目Project
和 EndProject
不成对Project
和 EndProject
不成对通常是合并分支时,自动解冲突解错了导致的,例如像下面这样:
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Walterlv.Demo", "Walterlv.Demo\Walterlv.Demo.csproj", "{DC0B1D44-5DF4-4590-BBFE-072183677A78}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Walterlv.Demo2", "Walterlv.Demo2\Walterlv.Demo2.csproj", "{98FF9756-B95A-4FDB-9858-5106F486FBF3}"
EndProject
而解决方法,就是补全缺失的 EndProject
行:
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Walterlv.Demo", "Walterlv.Demo\Walterlv.Demo.csproj", "{DC0B1D44-5DF4-4590-BBFE-072183677A78}"
++ EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Walterlv.Demo2", "Walterlv.Demo2\Walterlv.Demo2.csproj", "{98FF9756-B95A-4FDB-9858-5106F486FBF3}"
EndProject
Project
部分发现对应的项目这是说,如果在 Global
部分通过项目 Id 引用了一些项目,但是这些项目没有在前面 Project
部分定义。例如下面的 sln 片段:
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Walterlv.Demo2", "Walterlv.Demo2\Walterlv.Demo2.csproj", "{98FF9756-B95A-4FDB-9858-5106F486FBF3}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{98FF9756-B95A-4FDB-9858-5106F486FBF3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{98FF9756-B95A-4FDB-9858-5106F486FBF3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{98FF9756-B95A-4FDB-9858-5106F486FBF3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{98FF9756-B95A-4FDB-9858-5106F486FBF3}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
-- {DC0B1D44-5DF4-4590-BBFE-072183677A78} = {20B61509-640C-492B-8B33-FB472CCF1391}
{98FF9756-B95A-4FDB-9858-5106F486FBF3} = {20B61509-640C-492B-8B33-FB472CCF1391}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {F2F1AD1B-207B-4731-ABEB-92882F89B155}
EndGlobalSection
EndGlobal
上面红框标注的项目 Id {DC0B1D44-5DF4-4590-BBFE-072183677A78}
在前面的 Project
部分是没有定义的,于是出现问题。这通常也是合并冲突所致。
解决方法是删掉这个多于的配置,或者在前面加回误删的 Project
节点,如:
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Walterlv.Demo", "Walterlv.Demo\Walterlv.Demo.csproj", "{DC0B1D44-5DF4-4590-BBFE-072183677A78}"
EndProject
可能是 nuget 识别出来的 MSBuild 版本过旧,也可能是没有安装对应的工作负载。
检查你的项目是否安装了需要的工作负载,比如做 Visual Studio 插件开发需要插件工作负载。可以阅读:
我在另外的博客中写了解决方案中项目类型的内容:
而如果是 nuget 自动识别出来的 MSBuild 版本过旧,则你会同时看到下面的这段提示:
NuGet Version: 5.1.0.6013
MSBuild auto-detection: using msbuild version ‘15.9.21.664’ from ‘C:\Program Files (x86)\Microsoft Visual Studio\2017\Enterprise\MSBuild\15.0\Bin’. Use option -MSBuildVersion to force nuget to use a specific version of MSBuild.
Error parsing solution file at C:\walterlv\Walterlv.Demo\Walterlv.Demo.sln: 调用的目标发生了异常。 Error parsing the nested project section in solution file.
于是解决方法是使 NuGet 能够找到正确的 MSBuild.exe 的版本。
我在另一篇博客中有写一些决定 MSBuild.exe 版本的方法:
可以通过设置环境变量的方式来解决自动查找版本错误的问题。
你可以看到本文后面附带了很多的参考资料,但实际上这里的所有资料都没有帮助我解决掉任何问题。这个问题的本质是 nuget 识别到了旧版本的 MSBuild.exe。
参考资料
使用 nuget restore
命令还原项目的 NuGet 包的时候,NuGet 会尝试自动检测计算机上已经安装的 MSBuild。不过,如果你同时安装了 Visual Studio 2017 和 Visual Studio 2019,那么 NuGet 有可能找到错误版本的 MSBuild。
本文介绍如何解决自动查找版本错误的问题。
当我们敲下 nuget restore
命令的时候,命令行的第 2 行会输出自动检测到的 MSBuild 版本号,就像下面的输出一样:
NuGet Version: 5.0.2.5988
MSBuild auto-detection: using msbuild version ‘15.9.21.664’ from ‘C:\Program Files (x86)\Microsoft Visual Studio\2017\Community\MSBuild\15.0\Bin’. Use option -MSBuildVersion to force nuget to use a specific version of MSBuild.
实际上我计算机上同时安装了 Visual Studio 2017 和 Visual Studio 2019,我有两个不同版本的 MSBuild:
C:\Program Files (x86)\Microsoft Visual Studio\2017\Community\MSBuild\15.0\Bin
C:\Program Files (x86)\Microsoft Visual Studio\2019\Professional\MSBuild\Current\Bin
要让 NuGet 找到正确版本的 MSBuild.exe,我们有三种方法。
实际上前面 nuget restore
命令的输出中就已经可以看出来其中一个解决方法了,即使用 -MSBuildVersion
来指定 MSBuild
的版本号。
虽然命令行输出中推荐使用了 -MSBuildVersion
选项来指定 MSBuild 的版本,但是实际上实现同样功能的有两个不同的选项:
-MSBuildPath
自 NuGet 4.0 开始新增的选项,指定 MSBuild 程序的路径。-MSBuildVersion
当同时指定上面两个选项时,-MSBuildPath
选项优先级高于 -MSBuildVersion
选项。
于是我们的 nuget restore
命令改成这样写:
> nuget restore -MSBuildPath "C:\Program Files (x86)\Microsoft Visual Studio\2019\Professional\MSBuild\Current\Bin"
输出现在会使用期望的 MSBuild 了:
Using Msbuild from 'C:\Program Files (x86)\Microsoft Visual Studio\2019\Professional\MSBuild\Current\Bin'.
NuGet 的命令行自动查找 MSBuild.exe 时,是通过环境变量中的 PATH
变量来找的。会找到 PATH
中第一个包含 msbuild.exe
文件的路径,将其作为自动查找到的 MSBuild 的路径。
所以,我们的解决方法是,如果找错了,我们就把期望正确的 MSBuild 所在的路径设置到不期望的 MSBuild 路径的前面。就像下图这样,我们把 2019 版本的 MSBuild 设置到了 2017 版本的前面。
以下是 NuGet 项目中自动查找 MSBuild.exe 文件的方法,源代码来自 https://github.com/NuGet/NuGet.Client/blob/2b45154b8568d6cbf1469f414938f0e3e88e3704/src/NuGet.Clients/NuGet.CommandLine/MsBuildUtility.cs#L986。
private static string GetMSBuild()
{
var exeNames = new [] { "msbuild.exe" };
if (RuntimeEnvironmentHelper.IsMono)
{
exeNames = new[] { "msbuild", "xbuild" };
}
// Try to find msbuild or xbuild in $Path.
var pathDirs = Environment.GetEnvironmentVariable("PATH")?.Split(new[] { Path.PathSeparator }, StringSplitOptions.RemoveEmptyEntries);
if (pathDirs?.Length > 0)
{
foreach (var exeName in exeNames)
{
var exePath = pathDirs.Select(dir => Path.Combine(dir, exeName)).FirstOrDefault(File.Exists);
if (exePath != null)
{
return exePath;
}
}
}
return null;
}
我故意在桌面上放了一个老旧的 MSBuild.exe,然后将此路径设置到环境变量 PATH
的前面,出现了编译错误。
参考资料
一般情况下我们并不需要关心 Visual Studio 解决方案文件格式(.sln),因为 Visual Studio 对解决方案文件的自动修复能力是非常强的。但是如果遇到自动解冲突错误或者编译不通过了,那么此文件还是需要手工修改的。
本文介绍 Visual Studio 解决方案(.sln)文件的格式。
Visual Studio 的解决方案文件由这三个部分组成:
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 16
VisualStudioVersion = 16.0.28606.126
MinimumVisualStudioVersion = 10.0.40219.1
Project
EndProject
Global
EndGlobal
虽然看起来是三个独立的部分,但其实除了版本号之外,项目信息和全局信息还是有挺多耦合部分的。
比如我们来看一个 sln 文件的例子,是一个最简单的只有一个项目的 sln 文件:
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 16
VisualStudioVersion = 16.0.29102.190
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Walterlv.Demo", "Walterlv.Demo\Walterlv.Demo.csproj", "{DC0B1D44-5DF4-4590-BBFE-072183677A78}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{DC0B1D44-5DF4-4590-BBFE-072183677A78}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{DC0B1D44-5DF4-4590-BBFE-072183677A78}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DC0B1D44-5DF4-4590-BBFE-072183677A78}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DC0B1D44-5DF4-4590-BBFE-072183677A78}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {F2F1AD1B-207B-4731-ABEB-92882F89B155}
EndGlobalSection
EndGlobal
下面我们来一一说明。
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 16
VisualStudioVersion = 16.0.29102.190
MinimumVisualStudioVersion = 10.0.40219.1
记录文件的格式版本是 12.0。使用 Visual Studio 2019 编辑/创建。
这里有一个小技巧,这里的 VisualStudioVersion 版本号设置为 15.0 会使得打开 sln 文件的时候默认使用 Visual Studio 2017,而设置为 16.0 会使得打开 sln 文件的时候默认使用 Visual Studio 2019。
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Walterlv.Demo", "Walterlv.Demo\Walterlv.Demo.csproj", "{DC0B1D44-5DF4-4590-BBFE-072183677A78}"
EndProject
项目信息至少由两行组成,第一行标记项目信息开始,而最后一行表示信息结束。
其格式为:
Project("{项目类型}") = "项目名称", "项目路径", "项目 Id"
EndProject
你可以在我的另一篇博客中找到项目类型:
但是本文列举几个 .NET/C# 项目中的常见类型:
9A19103F-16F7-4668-BE54-9A1E7A4F7556
SDK 风格的 C# 项目文件FAE04EC0-301F-11D3-BF4B-00C04F79EFBC
传统风格的 C# 项目文件2150E333-8FDC-42A3-9474-1A3956D46DE8
解决方案文件夹关于 SDK 风格的项目文件,可以阅读我的另一篇博客:
项目名称和项目路径不必多说,都知道。对于文件夹而言,项目名称就是文件夹的名称,而项目路径也是文件夹的名称。
项目 Id 是在解决方案创建项目的过程中生成的一个新的 GUID,每个项目都不一样。对于 SDK 风格的 C# 项目文件,csproj 中可以指定项目依赖,而如果没有直接的项目依赖,而只是解决方案编译级别的依赖,那么也可以靠 sln 文件中的项目 Id 来指定项目的依赖关系。另外,也通过项目 Id 来对项目做一些编译上的解决方案级别的配置。
Project
和 EndProject
的内部还可以放 ProjectSection
。
比如对于解决方案文件夹,可以包含解决方案文件:
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{B002382D-4C9E-4F08-85E5-F12E2C061F5A}"
ProjectSection(SolutionItems) = preProject
.gitattributes = .gitattributes
.gitignore = .gitignore
README.md = README.md
build\Version.props = build\Version.props
EndProjectSection
EndProject
这个解决方案文件夹中包含了四个文件,其路径分别记录在了 ProjectSection
节点里面。
ProjectSection
还可以记录项目依赖关系(非项目之间的真实依赖,而是解决方案级别的编译依赖):
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Walterlv.Demo", "Walterlv.Demo\Walterlv.Demo.csproj", "{DC0B1D44-5DF4-4590-BBFE-072183677A78}"
ProjectSection(ProjectDependencies) = postProject
{98FF9756-B95A-4FDB-9858-5106F486FBF3} = {98FF9756-B95A-4FDB-9858-5106F486FBF3}
EndProjectSection
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Walterlv.Demo2", "Walterlv.Demo2\Walterlv.Demo2.csproj", "{98FF9756-B95A-4FDB-9858-5106F486FBF3}"
EndProject
在这一段节点里面,我们的 Walterlv.Demo
项目依赖于另外一个 Walterlv.Demo2
项目。依赖是以 项目 Id = 项目 Id
的方式写出来的;如果有多个依赖,那么就写多行。不用吐槽为什么一样还要写两遍,因为这是一个固定的格式,后面我们会介绍一些全局配置里面会有两个不一样的。
关于设置项目依赖关系的方法,除了 sln 文件里面的设置之外,还有通过设置项目依赖属性的方式,详情可以阅读:
一个全局信息的例子如下:
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{DC0B1D44-5DF4-4590-BBFE-072183677A78}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{DC0B1D44-5DF4-4590-BBFE-072183677A78}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DC0B1D44-5DF4-4590-BBFE-072183677A78}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DC0B1D44-5DF4-4590-BBFE-072183677A78}.Release|Any CPU.Build.0 = Release|Any CPU
{98FF9756-B95A-4FDB-9858-5106F486FBF3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{98FF9756-B95A-4FDB-9858-5106F486FBF3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{98FF9756-B95A-4FDB-9858-5106F486FBF3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{98FF9756-B95A-4FDB-9858-5106F486FBF3}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {F2F1AD1B-207B-4731-ABEB-92882F89B155}
EndGlobalSection
EndGlobal
在这个全局信息的例子中,为解决方案指定了两个配置(Configuration),Debug
和 Release
,平台都是 Any CPU
。同时也为每个项目指定了单独的配置种类,可供选择,每一行都是 项目的配置 = 解决方案的配置
表示此项目的此种配置在解决方案的某个全局配置之下。
如果我们将这两个项目放到文件夹中,那么我们可以额外看到一个新的全局配置 NestedProjects
字面意思是说 {DC0B1D44-5DF4-4590-BBFE-072183677A78}
和 {98FF9756-B95A-4FDB-9858-5106F486FBF3}
两个项目在 {20B61509-640C-492B-8B33-FB472CCF1391}
项目中嵌套,实际意义代表 Walterlv.Demo
和 Walterlv.Demo2
两个项目在 Folder
文件夹下。
GlobalSection(NestedProjects) = preSolution
{DC0B1D44-5DF4-4590-BBFE-072183677A78} = {20B61509-640C-492B-8B33-FB472CCF1391}
{98FF9756-B95A-4FDB-9858-5106F486FBF3} = {20B61509-640C-492B-8B33-FB472CCF1391}
EndGlobalSection
上图解决方案下的整个解决方案全部内容如下:
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 16
VisualStudioVersion = 16.0.29102.190
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Walterlv.Demo", "Walterlv.Demo\Walterlv.Demo.csproj", "{DC0B1D44-5DF4-4590-BBFE-072183677A78}"
ProjectSection(ProjectDependencies) = postProject
{98FF9756-B95A-4FDB-9858-5106F486FBF3} = {98FF9756-B95A-4FDB-9858-5106F486FBF3}
EndProjectSection
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Walterlv.Demo2", "Walterlv.Demo2\Walterlv.Demo2.csproj", "{98FF9756-B95A-4FDB-9858-5106F486FBF3}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Folder", "Folder", "{20B61509-640C-492B-8B33-FB472CCF1391}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{DC0B1D44-5DF4-4590-BBFE-072183677A78}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{DC0B1D44-5DF4-4590-BBFE-072183677A78}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DC0B1D44-5DF4-4590-BBFE-072183677A78}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DC0B1D44-5DF4-4590-BBFE-072183677A78}.Release|Any CPU.Build.0 = Release|Any CPU
{98FF9756-B95A-4FDB-9858-5106F486FBF3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{98FF9756-B95A-4FDB-9858-5106F486FBF3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{98FF9756-B95A-4FDB-9858-5106F486FBF3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{98FF9756-B95A-4FDB-9858-5106F486FBF3}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{DC0B1D44-5DF4-4590-BBFE-072183677A78} = {20B61509-640C-492B-8B33-FB472CCF1391}
{98FF9756-B95A-4FDB-9858-5106F486FBF3} = {20B61509-640C-492B-8B33-FB472CCF1391}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {F2F1AD1B-207B-4731-ABEB-92882F89B155}
EndGlobalSection
EndGlobal
当 A 项目引用 B 项目,那么使用 Visual Studio 或者 MSBuild 编译 A 项目之前就会确保 B 项目已经编译完毕。通常我们指定这种引用是因为 A 项目确实在运行期间需要 B 项目生成的程序集。
但是,现在 B 项目可能仅仅只是一个工具项目,或者说 A 项目编译之后的程序集并不需要 B,仅仅只是将 B 打到一个包中,那么我们其实需要的仅仅是 B 项目先编译而已。
本文介绍如何影响项目的编译顺序,而不带来项目实际引用。
依然在项目中使用往常习惯的方法设置项目引用:
但是,在项目引用设置完成之后,需要打开项目的项目文件(.csproj)给 ProjectReference
节点加上 ReferenceOutputAssembly
的属性设置,将其值设置为 false
。这表示仅仅是项目引用,而不将项目的任何输出程序集作为此项目的依赖。
<ItemGroup>
<ProjectReference Include="..\Walterlv.Demo.Analyzer\Walterlv.Demo.Analyzer.csproj" ReferenceOutputAssembly="false" />
<ProjectReference Include="..\Walterlv.Demo.Build\Walterlv.Demo.Build.csproj" ReferenceOutputAssembly="false" />
</ItemGroup>
上面的 ProjectReference
是 Sdk 风格的 csproj 文件中的项目引用。即便不是 Sdk 风格,也是一样的加这个属性。
当然,你写多行也是可以的:
<ItemGroup>
<ProjectReference Include="..\Walterlv.Demo.Analyzer\Walterlv.Demo.Analyzer.csproj">
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
</ProjectReference>
<ProjectReference Include="..\Walterlv.Demo.Build\Walterlv.Demo.Build.csproj">
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
</ProjectReference>
</ItemGroup>
这种做法有两个非常棒的用途:
ReferenceOutputAssembly
来引用项目,最终生成的 NuGet 包中就不会生成对这些项目的依赖。此方法可能会是更加常用的方法,但兼容性不那么好,可能在部分旧版本的 Visual Studio 或者 .NET Core 版本的 dotnet build
命令下不容易工作起来。
在解决方案上右键,然后选择“设置项目依赖”:
然后在弹出的项目依赖对话框中选择一个项目的依赖:
这时,如果看看解决方案文件(.sln)则可以看到多出了 ProjectDependencies
区:
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Walterlv.Demo", "Walterlv.Demo\Walterlv.Demo.csproj", "{DC0B1D44-5DF4-4590-BBFE-072183677A78}"
ProjectSection(ProjectDependencies) = postProject
{98FF9756-B95A-4FDB-9858-5106F486FBF3} = {98FF9756-B95A-4FDB-9858-5106F486FBF3}
EndProjectSection
EndProject
更多关于 sln 文件的理解,可以阅读我的另一篇博客:
使用 ReferenceOutputAssembly
属性设置的方式是将项目的编译顺序指定到项目文件中的,这意味着如果使用命令行单独编译这个项目,也是能获得提前编译目标项目的效果的,而不需要打开对应的解决方案编译才可以获得改变编译顺序的效果。
不过使用 ReferenceOutputAssembly
的一个缺陷是,必须要求目标框架能够匹配。比如 .NET Core 2.1 的项目就不能引用 .NET Core 3.0 或者 .NET Framework 4.8 的项目。
而在解决方案级别设置项目依赖则没有框架上的限制。无论你的项目是什么框架,都可以在编译之前先编译好依赖的项目。只是旧版本的 MSBuild 工具和 dotnet build
不支持 ProjectDependencies
这样的解决方案节点,会导致要么不识别这样的项目依赖(从而实际上并没有影响编译顺序)或者无法完成编译(例如出现 Error parsing the nested project section in solution file. 错误)。
参考资料
Visual Studio 可以通过得知项目类型快速地为项目显示相应的图标、对应的功能等等。
本文整理已收集到的一些项目的 GUID,如果你把你的解决方案文件(sln)改坏了,那么可以修复一下。
8BB2217D-0F2D-49D1-97BC-3654ED321F3B
ASP.NET 5603C0E0B-DB56-11DC-BE95-000D561079B0
ASP.NET MVC 1F85E285D-A4E0-4152-9332-AB1D724D3325
ASP.NET MVC 2E53F8FEA-EAE0-44A6-8774-FFD645390401
ASP.NET MVC 3E3E379DF-F4C6-4180-9B81-6769533ABE47
ASP.NET MVC 4349C5851-65DF-11DA-9384-00065B846F21
ASP.NET MVC 5 / Web ApplicationFAE04EC0-301F-11D3-BF4B-00C04F79EFBC
C#9A19103F-16F7-4668-BE54-9A1E7A4F7556
C# (SDK 风格的项目)8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942
C++A9ACE9BB-CECE-4E62-9AA4-C7E7C5BD2124
Database4F174C21-8C12-11D0-8340-0000F80270F8
Database (other project types)3EA9E505-35AC-4774-B492-AD1749C4943A
Deployment Cab06A35CCD-C46D-44D5-987B-CF40FF872267
Deployment Merge Module978C614F-708E-4E1A-B201-565925725DBA
Deployment SetupAB322303-2255-48EF-A496-5904EB18DA55
Deployment Smart Device CabF135691A-BF7E-435D-8960-F99683D2D49C
Distributed SystemBF6F8E12-879D-49E7-ADF0-5503146B24B8
Dynamics 2012 AX C# in AOT82B43B9B-A64C-4715-B499-D71E9CA2BD60
ExtensibilityF2A71F9B-5D33-465A-A702-920D77279786
F#6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705
F# (SDK 风格的项目)95DFC527-4DC1-495E-97D7-E94EE1F7140D
IL projectE6FDF86B-F3D1-11D4-8576-0002A516ECE8
J#262852C6-CD72-467D-83FE-5EEB1973A190
JScript20D4826A-C6FA-45DB-90F4-C717570B9F32
Legacy (2003) Smart Device (C#)CB4CE8C6-1BDB-4DC7-A4D3-65A1999772F8
Legacy (2003) Smart Device (VB.NET)581633EB-B896-402F-8E60-36F3DA191C85
LightSwitch Project8BB0C5E8-0616-4F60-8E55-A43933E57E9C
LightSwitchb69e3092-b931-443c-abe7-7e7b65f2a37f
Micro FrameworkC1CDDADD-2546-481F-9697-4EA41081F2FC
Office/SharePoint App786C830F-07A1-408B-BD7F-6EE04809D6DB
Portable Class Library66A26720-8FB5-11D2-AA7E-00C04F688DDE
Project FoldersD954291E-2A0B-460D-934E-DC6B0785DB48
Shared Project593B0543-81F6-4436-BA1E-4747859CAAE2
SharePoint (C#)EC05E597-79D4-47f3-ADA0-324C4F7C7484
SharePoint (VB.NET)F8810EC1-6754-47FC-A15F-DFABD2E3FA90
SharePoint WorkflowA1591282-1198-4647-A2B1-27E5FF5F6F3B
Silverlight4D628B5B-2FBC-4AA6-8C16-197242AEB884
Smart Device (C#)68B1623D-7FB9-47D8-8664-7ECEA3297D4F
Smart Device (VB.NET)2150E333-8FDC-42A3-9474-1A3956D46DE8
解决方案文件夹3AC096D0-A1C2-E12C-1390-A8335801FDAB
TestA5A43C5B-DE2A-4C0C-9213-0A381AF9435A
Universal Windows Class LibraryF184B08F-C81C-45F6-A57F-5ABD9991F28F
VB.NET778DAE3C-4631-46EA-AA77-85C1314464D9
VB.NET (forces use of SDK project system)C252FEB5-A946-4202-B1D4-9916A0590387
Visual Database Tools54435603-DBB4-11D2-8724-00A0C9A8B90C
Visual Studio 2015 Installer Project ExtensionA860303F-1F3F-4691-B57E-529FC101A107
Visual Studio Tools for Applications (VSTA)BAA0C2D2-18E2-41B9-852F-F413020CAA33
Visual Studio Tools for Office (VSTO)E24C65DC-7377-472B-9ABA-BC803B73C61A
Web Site3D9AD99F-2412-4246-B90B-4EAA41C64699
Windows Communication Foundation (WCF)76F1466A-8B6D-4E39-A767-685A06062A39
Windows Phone 8/8.1 Blank/Hub/Webview AppC089C8C0-30E0-4E22-80C0-CE093F111A43
Windows Phone 8/8.1 App (C#)DB03555F-0C8B-43BE-9FF9-57896B3C5E56
Windows Phone 8/8.1 App (VB.NET)60DC8134-EBA5-43B8-BCC9-BB4BC16C2548
Windows Presentation Foundation (WPF)BC8A1FFA-BEE3-4634-8014-F334798102B3
Windows Store (Metro) Apps & ComponentsD954291E-2A0B-460D-934E-DC6B0785DB48
Windows Store App Universal14822709-B5A1-4724-98CA-57A101D1B079
Workflow (C#)D59BE175-2ED0-4C54-BE3D-CDAA9F3214C8
Workflow (VB.NET)32F31D43-81CC-4C15-9DE6-3FC5453562B6
Workflow FoundationEFBA0AD7-5A72-4C68-AF49-83D382785DCF
Xamarin.Android / Mono for Android6BC8ED88-2882-458C-8E55-DFD12B67127B
Xamarin.iOS / MonoTouchF5B4F3BC-B597-4E2B-B552-EF5D8A32436F
MonoTouch Binding6D335F3A-9D43-41b4-9D22-F6F17C4BE596
XNA (Windows)2DF5C3F4-5A5F-47a9-8E94-23B4456F55E2
XNA (XBox)D399B71A-8929-442a-A9AC-8BEC78BB2433
XNA (Zune)参考资料
如果某天改了一点代码但是没有完成,我们可能会在注释里面加上 // TODO
。如果某个版本为了控制影响范围临时使用不太合适的方法解了 Bug,我们可能也会在注释里面加上 // TODO
。但是,对于团队项目来说,一个人写的 TODO
可能过了一段时间就淹没在大量的 TODO
堆里面了。如果能够强制要求所有的 TODO
被跟踪,那么代码里面就比较容易能够控制住 TODO
的影响了。
本文将基于 Roslyn 开发代码分析器,要求所有的 TODO
注释具有可被跟踪的负责人等信息。
如果你对基于 Roslyn 编写分析器和代码修改器不了解,建议先阅读我的一篇入门教程:
我们先准备一些公共的信息:
namespace Walterlv.Demo
{
internal static class DiagnosticIds
{
/// <summary>
/// 标记了待办事项的代码必须被追踪。WAL 是我名字(walterlv)的前三个字母。
/// </summary>
public const string TodoMustBeTracked = "WAL302";
}
}
在后面的代码分析器和修改器中,我们将都使用此公共的字符串常量来作为诊断 Id。
我们先添加分析器(TodoMustBeTrackedAnalyzer
)最基础的代码:
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class TodoMustBeTrackedAnalyzer : DiagnosticAnalyzer
{
private static readonly DiagnosticDescriptor Rule = new DiagnosticDescriptor(
DiagnosticIds.TodoMustBeTracked,
"任务必须被追踪",
"未完成的任务缺少负责人和完成截止日期:{0}",
"Maintainability",
DiagnosticSeverity.Error,
isEnabledByDefault: true,
description: "未完成的任务必须有对应的负责人和截止日期(// TODO @lvyi 2019-08-01),最好有任务追踪系统(如 JIRA)跟踪。");
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => ImmutableArray.Create(Rule);
public override void Initialize(AnalysisContext context)
=> context.RegisterSyntaxTreeAction(AnalyzeSingleLineComment);
private void AnalyzeSingleLineComment(SyntaxTreeAnalysisContext context)
{
// 这里将是我们分析器的主要代码。
}
}
接下来我们则是要完善语法分析的部分,我们需要找到单行注释和多行注释。
注释在语法节点中不影响代码含义,这些不影响代码含义的语法部件被称作 Trivia
(闲杂部件)。这跟我前面入门教程部分说的语法节点不同,其 API 会少一些,但也更加简单。
我们从语法树的 DescendantTrivia
方法中可以拿到文档中的所有的 Trivia
然后过滤掉获得其中的注释部分。
比如,我们要分析下面的这个注释:
// TODO 林德熙在这个版本写的逗比代码,下个版本要改掉。
在语法节点中判断注释的袋子性,然后使用正则表达式匹配 TODO
、负责人以及截止日期即可。
private static readonly Regex TodoRegex = new Regex(@"//\s*todo", RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly Regex AssigneeRegex = new Regex(@"@\w+", RegexOptions.Compiled);
private static readonly Regex DateRegex = new Regex(@"[\d]{4}\s?[年\-\.]\s?[01]?[\d]\s?[月\-\.]\s?[0123]?[\d]\s?日?", RegexOptions.Compiled);
private void AnalyzeSingleLineComment(SyntaxTreeAnalysisContext context)
{
var root = context.Tree.GetRoot();
foreach (var comment in root.DescendantTrivia()
.Where(x =>
x.IsKind(SyntaxKind.SingleLineCommentTrivia)
|| x.IsKind(SyntaxKind.MultiLineCommentTrivia)))
{
var value = comment.ToString();
var todoMatch = TodoRegex.Match(value);
if (todoMatch.Success)
{
var assigneeMatch = AssigneeRegex.Match(value);
var dateMatch = DateRegex.Match(value);
if (!assigneeMatch.Success || !dateMatch.Success)
{
var diagnostic = Diagnostic.Create(Rule, comment.GetLocation(), value);
context.ReportDiagnostic(diagnostic);
}
}
}
}
将上面的类组装起来运行 Visual Studio 调试即可看到效果。没有负责人和截止日期的 TODO
注释将报告编译错误。
TodoMustBeTrackedAnalyzer
类型的完整代码如下:
using System;
using System.Collections.Immutable;
using System.Linq;
using System.Text.RegularExpressions;
using Walterlv.Demo;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
namespace Walterlv.Analyzers.Maintainability
{
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class TodoMustBeTrackedAnalyzer : DiagnosticAnalyzer
{
private static readonly LocalizableString Title = "任务必须被追踪";
private static readonly LocalizableString MessageFormat = "未完成的任务缺少负责人和完成截止日期:{0}";
private static readonly LocalizableString Description = "未完成的任务必须有对应的负责人和截止日期(// TODO @lvyi 2019-08-01),最好有任务追踪系统(如 JIRA)跟踪。";
private static readonly Regex TodoRegex = new Regex(@"//\s*todo", RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly Regex AssigneeRegex = new Regex(@"@\w+", RegexOptions.Compiled);
private static readonly Regex DateRegex = new Regex(@"[\d]{4}\s?[年\-\.]\s?[01]?[\d]\s?[月\-\.]\s?[0123]?[\d]\s?日?", RegexOptions.Compiled);
private static readonly DiagnosticDescriptor Rule = new DiagnosticDescriptor(
DiagnosticIds.TodoMustBeTracked,
Title, MessageFormat,
Categories.Maintainability,
DiagnosticSeverity.Error, isEnabledByDefault: true, description: Description);
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => ImmutableArray.Create(Rule);
public override void Initialize(AnalysisContext context)
{
context.EnableConcurrentExecution();
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.Analyze | GeneratedCodeAnalysisFlags.ReportDiagnostics);
context.RegisterSyntaxTreeAction(AnalyzeSingleLineComment);
}
private void AnalyzeSingleLineComment(SyntaxTreeAnalysisContext context)
{
var root = context.Tree.GetRoot();
foreach (var comment in root.DescendantTrivia()
.Where(x =>
x.IsKind(SyntaxKind.SingleLineCommentTrivia)
|| x.IsKind(SyntaxKind.MultiLineCommentTrivia)))
{
var value = comment.ToString();
var todoMatch = TodoRegex.Match(value);
if (todoMatch.Success)
{
var assigneeMatch = AssigneeRegex.Match(value);
var dateMatch = DateRegex.Match(value);
if (!assigneeMatch.Success || !dateMatch.Success)
{
var diagnostic = Diagnostic.Create(Rule, comment.GetLocation(), value);
context.ReportDiagnostic(diagnostic);
}
}
}
}
}
}
只是报错的话,开发者看到错误可能会一脸懵逼,因为从未见过注释还会报告编译错误的,不知道怎么改。
于是我们需要编写一个代码修改器以便自动完成注释的修改,添加负责人和截止日期。我这里代码修改器修改后的结果就像下面这样:
生成一个新的注释字符串然后替换即可:
using System;
using System.Collections.Immutable;
using System.Composition;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Walterlv.Demo;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CodeActions;
using Microsoft.CodeAnalysis.CodeFixes;
using Microsoft.CodeAnalysis.CSharp;
namespace Walterlv.Analyzers.Maintainability
{
[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(TodoMustBeTrackedCodeFixProvider)), Shared]
public class TodoMustBeTrackedCodeFixProvider : CodeFixProvider
{
private const string Title = "添加任务负责人 / 完成日期 / JIRA Id 追踪";
private static readonly Regex AssigneeRegex = new Regex(@"@\w+", RegexOptions.Compiled);
private static readonly Regex DateRegex = new Regex(@"[\d]{4}\s?[年\-\.]\s?[01]?[\d]\s?[月\-\.]\s?[0123]?[\d]\s?日?", RegexOptions.Compiled);
public sealed override ImmutableArray<string> FixableDiagnosticIds =>
ImmutableArray.Create(DiagnosticIds.TodoMustBeTracked);
public sealed override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer;
public sealed override Task RegisterCodeFixesAsync(CodeFixContext context)
{
var diagnostic = context.Diagnostics.First();
context.RegisterCodeFix(CodeAction.Create(
Title,
c => FormatTrackableTodoAsync(context.Document, diagnostic, c),
nameof(TodoMustBeTrackedCodeFixProvider)),
diagnostic);
return Task.CompletedTask;
}
private async Task<Document> FormatTrackableTodoAsync(
Document document, Diagnostic diagnostic, CancellationToken cancellationToken)
{
var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
var oldTrivia = root.FindTrivia(diagnostic.Location.SourceSpan.Start);
var oldComment = oldTrivia.ToString();
if (oldComment.Length > 3)
{
oldComment = oldComment.Substring(2).Trim();
if (oldComment.StartsWith("todo", StringComparison.CurrentCultureIgnoreCase))
{
oldComment = oldComment.Substring(4).Trim();
}
}
var comment = $"// TODO @{Environment.UserName} {DateTime.Now:yyyy年M月d日} {oldComment}";
var newTrivia = SyntaxFactory.ParseTrailingTrivia(comment);
var newRoot = root.ReplaceTrivia(oldTrivia, newTrivia);
return document.WithSyntaxRoot(newRoot);
}
}
}
本文只谈论 ReSharper 的那些常用功能中,Visual Studio 2019 能还原多少,主要提供给那些正在考虑不使用 ReSharper 插件的 Visual Studio 用户作为参考。毕竟 ReSharper 如此强大的功能是建立在每年缴纳不少的费用以及噩梦般占用 Visual Studio 性能的基础之上的。然而使用 Visual Studio 2019 社区版不搭配 ReSharper 则可以免费为开源社区做贡献。
本文的内容分为三个部分:
默认情况下,Visual Studio 只在你刚开始打字或者输入 .
和 (
的时候才出现智能感知提示,但是如果你使用 ReSharper 开发,你会发现智能感知提示无处不在(所以那么卡?)。
实际上你也可以配置 Visual Studio 的智能感知在更多的情况下出现,请打开下面“工具”->“选项”->“文本编辑器”->“C#”->“IntelliSense”:
打开“键入字符后显示完成列表”和“删除字符后显示完成列表”。这样,你只要正在编辑,都会显示智能感知提示。
另外,如果你当前需要打开智能感知提示,默认情况下使用 Ctrl + 空格键
可以打开。当然你也可以将其修改为 ReSharper 中常见的快捷键 Alt + 右箭头
。方法是修改键盘快捷键中的 “” 项。
修改快捷键方法详见:
另外,在 IntelliCode
部分,可以选择打开更多的 IntelliSense
完成项:
ReSharper 的智能感知提示包含所依赖的各种程序集中的类型,然而 Visual Studio 的智能感知则没有包含那些,只有顶部写了 using
的几个命名空间中的类型。
Visual Studio 2019 中可以设置智能感知提示中“显示未导入命名空间中的项”。默认是没有开启的,当开启后,你将直接能在智能感知提示中看到原本 ReSharper 中才能有的编写任何类型的体验。
默认情况下输入未知类型时只能完整输入类名然后使用重构快捷键将命名空间导入:
但开启了此选项后,只需要输入类名的一部分,哪怕此类型还没有写 using
将其导入,也能在智能感知提示中看到并且完成输入。
在 ReSharper 中,选中一段代码,如果这段代码可以返回一个值,那么可以使用重构快捷键(默认 Alt+Enter)生成一个局部变量。如果同样带代码块在此方法体中有多处,那么可以同时将多处代码一并提取出来成为一个布局变量。
在 Visual Studio 中,也可以选中一段代码将其提取称一个局部变量:
ReSharper 可以使用 Ctrl + R, R 快捷键重命名一个标识符。
Visual Studio 中也是默认使用 F2 或者与 ReSharper 相同的 Ctrl + R, R 快捷键来重命名一个标识符。
正在填坑……
ReSharper 中自带了大量方便的代码片段,而且其代码片段的可定制性非常强,有很多可以只能完成的宏;而且还有后置式代码片段。
然而 Visual Studio 自带的代码片段就弱很多,只能支持最基本的宏。
不过可以通过下面一些插件通过数量来补足功能上的一些短板:
因为很多涉及到 Visual Studio 插件开发相关的文章/博客需要以安装 Visual Studio 插件开发环境为基础,所以本文介绍如何安装 Visual Studio 插件开发环境,以简化那些博客中的内容。
请在开始菜单中找到或者搜索 Visual Studio Installer,然后启动它:
在 Visual Studio 的安装界面中选择“修改”:
在工作负载中找到并勾选 Visual Studio 扩展开发(英文版是 Visual Studio extension development),然后按下右下角的“修改”:
等待 Visual Studio 安装完 Visual Studio 扩展开发。如果提示重启计算机,那么就重启一下。
如果你想开发基于 Roslyn 的语法/语义分析插件,那么需要在选择了 Visual Studio 扩展开发工作负载之后,在右侧将可选的 .NET Compiler Platform SDK 也打上勾。
如果你成功安装了 Visual Studio 扩展开发的工作负载,那么你在新建项目的时候就可以看到 Visual Studio 扩展开发相关的项目模板。
Roslyn 是 .NET 平台下十分强大的编译器,其提供的 API 也非常丰富好用。本文将基于 Roslyn 开发一个 C# 代码分析器,你不止可以将分析器作为 Visual Studio 代码分析和重构插件发布,还可以作为 NuGet 包发布。不管哪一种,都可以让我们编写的 C# 代码分析器工作起来并真正起到代码建议和重构的作用。
本文将教大家如何从零开始开发一个基于 Roslyn 的 C# 源代码分析器 Analyzer 和修改器 CodeFixProvider。可以作为 Visual Studio 插件安装和使用,也可以作为 NuGet 包安装到项目中使用(无需安装插件)。无论哪一种,你都可以在支持 Roslyn 分析器扩展的 IDE(如 Visual Studio)中获得如下面动图所展示的效果。
你需要先安装 Visual Studio 的扩展开发工作负载,如果你还没有安装,那么请先阅读以下博客安装:
启动 Visual Studio,新建项目,然后在项目模板中找到 “Analyzer with Code Fix (.NET Standard)”,下一步。
随后,取好项目名字之后,点击“创建”,你将来到 Visual Studio 的主界面。
我为项目取的名称是 Walterlv.Demo.Analyzers
,接下来都将以此名称作为示例。你如果使用了别的名称,建议你自己找到名称的对应关系。
在创建完项目之后,你可选可以更新一下项目的 .NET Standard 版本(默认是 1.3,建议更新为 2.0)以及几个 NuGet 包。
如果你现在按下 F5,那么将会启动一个 Visual Studio 的实验实例用于调试。
由于我们是一个分析器项目,所以我们需要在第一次启动实验实例的时候新建一个专门用来测试的小型项目。
简单起见,我新建一个 .NET Core 控制台项目。新建的项目如下:
我们目前只是基于模板创建了一个分析器,而模板中自带的分析器功能是 “只要类型名称中有任何一个字符是小写的,就给出建议将其改为全部大写”。
于是我们看到 Program
类名底下标了绿色的波浪线,我们将光标定位到 Program
类名上,可以看到出现了一个 “小灯泡” 提示。按下重构快捷键(默认是 Ctrl + .
)后可以发现,我们的分析器项目提供的 “Make uppercase” 建议显示了出来。于是我们可以快速地将类名修改为全部大写。
因为我们在前面安装了 Visual Studio 扩展开发的工作负载,所以可以在 “视图”->“其他窗口” 中找到并打开 Syntax Visualizer 窗格。现在,请将它打开,因为接下来我们的代码分析会用得到这个窗格。
如果体验完毕,可以关闭 Visual Studio;当然也可以在我们的分析器项目中 Shift + F5 强制结束调试。
下次调试的时候,我们不需要再次新建项目了,因为我们刚刚新建的项目还在我们新建的文件夹下。下次调试只要像下面那样再次打开这个项目测试就好了。
在创建完项目之后,你会发现解决方案中有三个项目:
在项目内部:
别看我们分析器文件中的代码很长,但实际上关键的信息并不多。
我们现在还没有自行修改 WalterlvDemoAnalyzersAnalyzer
类中的任何内容,而到目前位置这个类里面包含的最关键代码我提取出来之后是下面这些。为了避免你吐槽这些代码编译不通过,我将一部分的实现替换成了 NotImplementedException
。
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class WalterlvDemoAnalyzersAnalyzer : DiagnosticAnalyzer
{
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics
=> throw new NotImplementedException();
public override void Initialize(AnalysisContext context)
=> throw new NotImplementedException();
}
最关键的点:
[DiagnosticAnalyzer(LanguageNames.CSharp)]
override SupportedDiagnostics
override Initialize
现在我们分别细化这些关键代码。为了简化理解,我将多语言全部替换成了实际的字符串值。
重写 SupportedDiagnostics
的部分,创建并返回了一个 DiagnosticDescriptor
类型的只读集合。目前只有一个 DiagnosticDescriptor
,名字是 Rule
,构造它的时候传入了一大堆字符串,包括分析器 Id、标题、消息提示、类型、级别、默认开启、描述信息。
可以很容易看出,如果我们这个分析器带有多个诊断建议,那么在只读集合中返回多个 DiagnosticDescriptor
的实例。
public const string DiagnosticId = "WalterlvDemoAnalyzers";
private static readonly LocalizableString Title = "Type name contains lowercase letters";
private static readonly LocalizableString MessageFormat = "Type name '{0}' contains lowercase letters";
private static readonly LocalizableString Description = "Type names should be all uppercase.";
private const string Category = "Naming";
private static DiagnosticDescriptor Rule = new DiagnosticDescriptor(DiagnosticId, Title, MessageFormat, Category, DiagnosticSeverity.Warning, isEnabledByDefault: true, description: Description);
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => ImmutableArray.Create(Rule);
重写 Initialize
的部分,模板中注册了一个类名分析器,其实就是下面那个静态方法 AnalyzeSymbol
。
public override void Initialize(AnalysisContext context)
{
context.RegisterSymbolAction(AnalyzeSymbol, SymbolKind.NamedType);
}
private static void AnalyzeSymbol(SymbolAnalysisContext context)
{
// 省略实现。
// 在模板自带的实现中,这里判断类名是否包含小写字母,如果包含则创建一个新的诊断建议以改为大写字母。
}
代码修改器文件中的代码更长,但关键信息也没有增加多少。
我们现在也没有自行修改 WalterlvDemoAnalyzersCodeFixProvider
类中的任何内容,而到目前位置这个类里面包含的最关键代码我提取出来之后是下面这些。为了避免你吐槽这些代码编译不通过,我将一部分的实现替换成了 NotImplementedException
。
[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(WalterlvDemoAnalyzersCodeFixProvider)), Shared]
public class WalterlvDemoAnalyzersCodeFixProvider : CodeFixProvider
{
public sealed override ImmutableArray<string> FixableDiagnosticIds
=> throw new NotImplementedException();
public sealed override FixAllProvider GetFixAllProvider()
=> WellKnownFixAllProviders.BatchFixer;
public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context)
=> throw new NotImplementedException();
}
最关键的点:
[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(WalterlvDemoAnalyzersCodeFixProvider)), Shared]
override FixableDiagnosticIds
WalterlvDemoAnalyzersAnalyzer
类型中有一个公共字段 DiagnosticId
吗?在这里返回,可以为那里分析器找到的代码提供修改建议override GetFixAllProvider
BatchFixer
,其他种类的 FixAllProvider
我将通过其他博客进行说明override RegisterCodeFixesAsync
FixableDiagnosticIds
属性中我们返回的那些诊断建议这个方法中可以拿到,于是为每一个返回的诊断建议注册一个代码修改器(CodeFix)在这个模板提供的例子中,FixableDiagnosticIds
返回了 WalterlvDemoAnalyzersAnalyzer
类中的公共字段 DiagnosticId
:
public sealed override ImmutableArray<string> FixableDiagnosticIds =>
ImmutableArray.Create(WalterlvDemoAnalyzersAnalyzer.DiagnosticId);
RegisterCodeFixesAsync
中找到我们在 WalterlvDemoAnalyzersAnalyzer
类中找到的一个 Diagnostic
,然后对这个 Diagnostic
注册一个代码修改(CodeFix)。
public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context)
{
var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
// TODO: Replace the following code with your own analysis, generating a CodeAction for each fix to suggest
var diagnostic = context.Diagnostics.First();
var diagnosticSpan = diagnostic.Location.SourceSpan;
// Find the type declaration identified by the diagnostic.
var declaration = root.FindToken(diagnosticSpan.Start).Parent.AncestorsAndSelf().OfType<TypeDeclarationSyntax>().First();
// Register a code action that will invoke the fix.
context.RegisterCodeFix(
CodeAction.Create(
title: title,
createChangedSolution: c => MakeUppercaseAsync(context.Document, declaration, c),
equivalenceKey: title),
diagnostic);
}
private async Task<Solution> MakeUppercaseAsync(Document document, TypeDeclarationSyntax typeDecl, CancellationToken cancellationToken)
{
// 省略实现。
// 将类名改为全大写,然后返回解决方案。
}
作为示例,我们写一个属性转换分析器,将自动属性转换为可通知属性。
就是像以下上面的一种属性转换成下面的一种:
public string Foo { get; set; }
private string _foo;
public string Foo
{
get => _foo;
set => SetValue(ref _foo, value);
}
这里我们写了一个 SetValue
方法,有没有这个 SetValue
方法存在对我们后面写的分析器其实没有任何影响。不过你如果强迫症,可以看本文最后的“一些补充”章节,把 SetValue
方法加进来。
于是,我们将 Initialize
方法中的内容改成我们期望的分析自动属性的语法节点分析。
public override void Initialize(AnalysisContext context)
=> context.RegisterSyntaxNodeAction(AnalyzeAutoProperty, SyntaxKind.PropertyDeclaration);
private void AnalyzeAutoProperty(SyntaxNodeAnalysisContext context)
{
// 你可以在这一行打上一个断点,这样你可以观察 `context` 参数。
}
上面的 AnalyzeAutoProperty
只是我们随便取的名字,而 SyntaxKind.PropertyDeclaration
是靠智能感知提示帮我找到的。
现在我们来试着分析一个自动属性。
按下 F5 调试,在新的调试的 Visual Studio 实验实例中,我们将鼠标光标放在 public string Foo { get; set; }
行上。如果我们提前在 AnalyzeAutoProperty
方法中打了断点,那么我们可以在此时观察到 context
参数。
CancellationToken
指示当前是否已取消分析Node
语法节点SemanticModel
ContainingSymbol
语义分析节点Compilation
Options
其中,Node.KindText
属性的值为 PropertyDeclaration
。还记得前面让你先提前打开 Syntax Visualizer 窗格吗?是的,我们可以在这个窗格中找到 PropertyDeclaration
节点。
我们可以借助这个语法可视化窗格,找到 PropertyDeclaration
的子节点。当我们一级一级分析其子节点的语法的时候,便可以取得这个语法节点的全部所需信息(可见性、属性类型、属性名称),也就是具备生成可通知属性的全部信息了。
由于我们在前面 Initialize
方法中注册了仅在属性声明语法节点的时候才会执行 AnalyzeAutoProperty
方法,所以我们在这里可以简单的开始报告一个代码分析 Diagnostic
:
var propertyNode = (PropertyDeclarationSyntax)context.Node;
var diagnostic = Diagnostic.Create(_rule, propertyNode.GetLocation());
context.ReportDiagnostic(diagnostic);
现在,WalterlvDemoAnalyzersAnalyzer
类的完整代码如下:
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class WalterlvDemoAnalyzersAnalyzer : DiagnosticAnalyzer
{
public const string DiagnosticId = "WalterlvDemoAnalyzers";
private static readonly LocalizableString _title = "自动属性";
private static readonly LocalizableString _messageFormat = "这是一个自动属性";
private static readonly LocalizableString _description = "可以转换为可通知属性。";
private const string _category = "Usage";
private static readonly DiagnosticDescriptor _rule = new DiagnosticDescriptor(
DiagnosticId, _title, _messageFormat, _category, DiagnosticSeverity.Info,
isEnabledByDefault: true, description: _description);
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => ImmutableArray.Create(_rule);
public override void Initialize(AnalysisContext context) =>
context.RegisterSyntaxNodeAction(AnalyzeAutoProperty, SyntaxKind.PropertyDeclaration);
private void AnalyzeAutoProperty(SyntaxNodeAnalysisContext context)
{
var propertyNode = (PropertyDeclarationSyntax)context.Node;
var diagnostic = Diagnostic.Create(_rule, propertyNode.GetLocation());
context.ReportDiagnostic(diagnostic);
}
}
可以发现代码并不多,现在运行,可以在光标落在属性声明的行时看到修改建议。如下图所示:
你可能会觉得有些不满,看起来似乎只有我们写的那些标题和描述在工作。但实际上你还应该注意到这些:
DiagnosticId
、_messageFormat
、_description
已经工作起来了;CodeFixProvider
没有写呢,你现在看到的依然还在修改大小写的部分代码是那个类(WalterlvDemoAnalyzersCodeFixProvider
)里的。现在,我们开始进行代码修改,将 WalterlvDemoAnalyzersCodeFixProvider
类改成我们希望的将属性修改为可通知属性的代码。
[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(WalterlvDemoAnalyzersCodeFixProvider)), Shared]
public class WalterlvDemoAnalyzersCodeFixProvider : CodeFixProvider
{
private const string _title = "转换为可通知属性";
public sealed override ImmutableArray<string> FixableDiagnosticIds =>
ImmutableArray.Create(WalterlvDemoAnalyzersAnalyzer.DiagnosticId);
public sealed override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer;
public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context)
{
var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
var diagnostic = context.Diagnostics.First();
var declaration = (PropertyDeclarationSyntax)root.FindNode(diagnostic.Location.SourceSpan);
context.RegisterCodeFix(
CodeAction.Create(
title: _title,
createChangedSolution: ct => ConvertToNotificationProperty(context.Document, declaration, ct),
equivalenceKey: _title),
diagnostic);
}
private async Task<Solution> ConvertToNotificationProperty(Document document,
PropertyDeclarationSyntax propertyDeclarationSyntax, CancellationToken cancellationToken)
{
// 获取文档根语法节点。
var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
// 生成可通知属性的语法节点集合。
var type = propertyDeclarationSyntax.Type;
var propertyName = propertyDeclarationSyntax.Identifier.ValueText;
var fieldName = $"_{char.ToLower(propertyName[0])}{propertyName.Substring(1)}";
var newNodes = CreateNotificationProperty(type, propertyName, fieldName);
// 将可通知属性的语法节点插入到原文档中形成一份中间文档。
var intermediateRoot = root
.InsertNodesAfter(
root.FindNode(propertyDeclarationSyntax.Span),
newNodes);
// 将中间文档中的自动属性移除形成一份最终文档。
var newRoot = intermediateRoot
.RemoveNode(intermediateRoot.FindNode(propertyDeclarationSyntax.Span), SyntaxRemoveOptions.KeepNoTrivia);
// 将原来解决方案中的此份文档换成新文档以形成新的解决方案。
return document.Project.Solution.WithDocumentSyntaxRoot(document.Id, newRoot);
}
private async Task<Solution> ConvertToNotificationProperty(Document document,
PropertyDeclarationSyntax propertyDeclarationSyntax, CancellationToken cancellationToken)
{
// 这个类型暂时留空,因为这是真正的使用 Roslyn 生成语法节点的代码,虽然只会写一句话,但相当长。
}
}
还记得我们在前面解读 WalterlvDemoAnalyzersCodeFixProvider
类型时的那些描述吗?我们现在为一个诊断 Diagnostic
注册了一个代码修改(CodeFix),并且其回调函数是 ConvertToNotificationProperty
。这是我们自己编写的一个方法。
我在这个方法里面写的代码并不复杂,是获取原来的属性里的类型信息和属性名,然后修改文档,将新的文档返回。
其中,我留了一个 CreateNotificationProperty
方法为空,因为这是真正的使用 Roslyn 生成语法节点的代码,虽然只会写一句话,但相当长。
于是我将这个方法单独写在了下面。将这两个部分拼起来(用下面方法替换上面同名的方法),你就能得到一个完整的 WalterlvDemoAnalyzersCodeFixProvider
类的代码了。
private SyntaxNode[] CreateNotificationProperty(TypeSyntax type, string propertyName, string fieldName)
=> new SyntaxNode[]
{
SyntaxFactory.FieldDeclaration(
new SyntaxList<AttributeListSyntax>(),
new SyntaxTokenList(SyntaxFactory.Token(SyntaxKind.PrivateKeyword)),
SyntaxFactory.VariableDeclaration(
type,
SyntaxFactory.SeparatedList(new []
{
SyntaxFactory.VariableDeclarator(
SyntaxFactory.Identifier(fieldName)
)
})
),
SyntaxFactory.Token(SyntaxKind.SemicolonToken)
),
SyntaxFactory.PropertyDeclaration(
type,
SyntaxFactory.Identifier(propertyName)
)
.AddModifiers(SyntaxFactory.Token(SyntaxKind.PublicKeyword))
.AddAccessorListAccessors(
SyntaxFactory.AccessorDeclaration(
SyntaxKind.GetAccessorDeclaration
)
.WithExpressionBody(
SyntaxFactory.ArrowExpressionClause(
SyntaxFactory.Token(SyntaxKind.EqualsGreaterThanToken),
SyntaxFactory.IdentifierName(fieldName)
)
)
.WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken)),
SyntaxFactory.AccessorDeclaration(
SyntaxKind.SetAccessorDeclaration
)
.WithExpressionBody(
SyntaxFactory.ArrowExpressionClause(
SyntaxFactory.Token(SyntaxKind.EqualsGreaterThanToken),
SyntaxFactory.InvocationExpression(
SyntaxFactory.IdentifierName("SetValue"),
SyntaxFactory.ArgumentList(
SyntaxFactory.Token(SyntaxKind.OpenParenToken),
SyntaxFactory.SeparatedList(new []
{
SyntaxFactory.Argument(
SyntaxFactory.IdentifierName(fieldName)
)
.WithRefKindKeyword(
SyntaxFactory.Token(SyntaxKind.RefKeyword)
),
SyntaxFactory.Argument(
SyntaxFactory.IdentifierName("value")
),
}),
SyntaxFactory.Token(SyntaxKind.CloseParenToken)
)
)
)
)
.WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken))
),
};
实际上本文并不会重点介绍如何使用 Roslyn 生成新的语法节点,因此我不会解释上面我是如何写出这样的语法节点来的,但如果你对照着语法可视化窗格(Syntax Visualizer)来看的话,也是不难理解为什么我会这么写的。
在此类型完善之后,我们再 F5 启动调试,可以发现我们已经可以完成一个自动属性的修改了,可以按照预期改成一个可通知属性。
你可以再看看下面的动图:
前往我们分析器主项目 Walterlv.Demo.Analyzers 项目的输出目录,因为本文没有改输出路径,所以在项目的 bin\Debug
文件夹下。我们可以找到每次编译产生的 NuGet 包。
如果你不知道如何将此 NuGet 包发布到 nuget.org,请在文本中回复,也许我需要再写一篇博客讲解如何推送。
前往我们分析器的 Visual Studio 插件项目 Walterlv.Demo.Analyzers.Vsix 项目的输出目录,因为本文没有改输出路径,所以在项目的 bin\Debug
文件夹下。我们可以找到每次编译产生的 Visual Studio 插件安装包。
如果你不知道如何将此 Visual Studio 插件发布到 Visual Studio Marketplace,请在文本中回复,也许我需要再写一篇博客讲解如何推送。
前面我们提到了 SetValue
这个方法,这是为了写一个可通知对象。为了拥有这个方法,请在我们的测试项目中添加下面这两个文件:
一个可通知类文件 NotificationObject.cs:
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace Walterlv.TestForAnalyzer
{
public class NotificationObject : INotifyPropertyChanged
{
protected bool SetValue<T>(ref T field, T value, [CallerMemberName] string propertyName = null)
{
if (Equals(field, value))
{
return false;
}
field = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
return true;
}
public event PropertyChangedEventHandler PropertyChanged;
}
}
一个用于分析器测试的类 Demo.cs:
namespace Walterlv.TestForAnalyzer
{
class Demo : NotificationObject
{
public string Foo { get; set; }
}
}
代码仓库在我的 Demo 项目中,注意协议是 996.ICU 哟!
别忘了我们一开始创建仓库的时候有一个单元测试项目,而我们全文都没有讨论如何充分利用其中的单元测试。我将在其他的博客中说明如何编写和使用分析器项目的单元测试。
参考资料
使用 Visual Studio 的代码片段功能,我们可以快速根据已有模板创建出大量常用的代码出来。ReSharper 已经自带了一份非常好用的代码片段工具,不过使用 ReSharper 创建出来的代码片段只能用在 ReSharper 插件中。如果团队当中有一些小伙伴没有 ReSharper(毕竟很贵),那么也可以使用到 Visual Studio 原生的代码片段。
Visual Studio 的官方文档有演示如何创建 Visual Studio 的代码片段,不过上手成本真的很高。本文介绍如何快速创建 Visual Studio 代码片段,并不需要那么麻烦。
Visual Studio 中代码片段管理器的入口在“工具”中。你可以参照下图找到代码片段管理器的入口。
在打开代码片段管理器之后,你可以选择自己熟悉的语言。里面会列出当前语言中可以插入的各种代码片段的源。
不过,Visual Studio 并没有提供创建代码片段的方法。在这个管理器里面,你只能导入已经存在的代码片段,并不能直接进行编辑。
官方文档提供了创建代码片段的方法,就在这里:
你只需要看一看就知道这其实是非常繁琐的创建方式,你几乎在手工编写本来是给机器阅读的代码。
我们创建代码片段其实只是关注代码片段本身,那么有什么更快速的方法呢?
方法是安装插件。
请去 Visual Studio 的扩展管理器中安装插件,或者去 Visual Studio 的插件市场中下载安装插件:
在安装完插件之后(需要重新启动 Visual Studio 以完成安装),你就可以直接在 Visual Studio 中创建和编辑代码片段了。
你需要去 Visual Studio 的“文件”->“新建”->“新建文件”中打开的模板选择列表中选择“Code Snippet”。
下面,我演示创建一个 Debug.WriteLine
代码片段的创建方法。
我将一段最简单的代码编写到了代码编辑窗格中:
Debug.WriteLine("[section] text");
实际上,这段代码中的 section
和 text
应该是占位符。那么如何插入占位符呢?
选中需要成为占位符的文本,在这里是 section
,然后鼠标右键,选择“Make Replacement”。
这样,在下方的列表中就会出现一个新的占位符。
现在我们设置这个占位符的更多细节。比如在下图中,我设置了工具提示(即我们使用此代码片段的时候 Visual Studio 如何提示我们编写这个代码片段),设置了默认值(即没有写时应该是什么值)。设置了这只是一个文本文字,没有其他特别含义。设置这是可以编辑的。
用通常的方法,设置 text
也是一个占位符。
如果我们只是这样创建一个代码片段,而目标代码可能没有引用 System.Diagnostics
命名空间,那么插入完之后手动引用这个命名空间体验可不好。那么如何让 Debug
类可以带命名空间地插入呢?
我们需要将 Debug
也设置成占位符。
但是这是可以自动生成的占位符,不需要用户输入,于是我们将其设置为不可编辑。同时,在“Function”一栏填写这是一个类型名称:
SimpleTypeName(global::System.Diagnostics.Debug)
$
符号实际上用于调试的话,代码越简单功能越全越好。于是我希望 Debug.WriteLine
上能够有一个字符串内插符号 $
。
那么问题来了,$
符号是表示代码片段中占位符的符号,那么如何输入呢?
方法是——写两遍 $
。于是我们的代码片段现在是这样的:
Debug.WriteLine($$"[$section$] $text$");
你可以随时按下 Ctrl+S 保存这个新建的代码片段。插件一个很棒的设计是,默认所在的文件夹就是 Visual Studio 中用来存放代码片段的文件夹。于是,你刚刚保存完就可以立刻在 Visual Studio 中看到效果了。
如果你将代码片段保存在插件给你的默认的位置,那么你根本不需要导入任何代码片段。但如果你曾经导出过代码片段或者保存在了其他的地方,那么就需要在代码片段管理器中导入这些代码片段文件了。
如果你前面使用了默认的保存路径,那么现在直接就可以开始使用了。
使用我们在 Shortcut 中设置的字母组合可以插入代码片段:
在插入完成之后,我们注意到此类型可以使用导入的命名空间前缀 System.Diagnostics
。如果没有导入此命名空间前缀,代码片段会自动加入。
按下 Tab 键可以在多个占位符之间跳转,而使用回车键可以确认这个代码片段。
在 Visual Studio 视图菜单的其他窗口中,可以找到“Snippet Explorer”,打开它可以管理已有的代码片段,包括 Visual Studio 中内置的那些片段。
推荐另一款插件 Snippetica:
前者适用于 Visual Studio,后者适用于 Visual Studio Code。
它自带了很多的 C# 代码片段,可以很大程度补充 Visual Studio 原生代码片段存在感低的问题。
参考资料
[Walkthrough: Create a code snippet - Visual Studio | Microsoft Docs](https://docs.microsoft.com/en-us/visualstudio/ide/walkthrough-creating-a-code-snippet) |
当使用 Visual Studio 调试的时候,如果我们的代码中出现了异常,那么 Visual Studio 会让我们的程序中断,然后我们就能知道程序中出现了异常。但是,如果这个异常已经被 catch
了,那么默认情况下 Visual Studio 是不会帮我们中断的。
能否在这个异常发生的第一时间让 Visual Studio 中断程序以便于我们调试呢?本文将介绍方法。
看下面这一段代码,读取一个根本不存在的文件。我们都知道这会抛出 FileNotFoundException
,随后 Visual Studio 会中断,然后告诉我们这句话发生了异常。
using System;
using System.IO;
namespace Walterlv.Demo.DoubiBlogs
{
internal class Program
{
private static void Main(string[] args)
{
File.ReadAllText(@"C:\walterlv\逗比博客\不存在的文件.txt");
}
}
}
现在,我们为这段会出异常的代码加上 try
-catch
:
using System;
using System.IO;
namespace Walterlv.Demo.DoubiBlogs
{
internal class Program
{
private static void Main(string[] args)
{
try
{
File.ReadAllText(@"C:\walterlv\逗比博客\不存在的文件.txt");
}
catch (IOException)
{
Console.WriteLine("出现了异常");
}
}
}
}
现在再运行,会发现 Visual Studio 并没有在出现此异常的时候中断,而是完成了程序最终的输出,随后结束程序。
有时我们会发现已经 catch
过的代码在后来也可能被证明有问题,于是希望即便被 catch
也要发生中断,以便在异常发生的第一时刻定位问题。
Visual Studio 提供了一个异常窗格,可以用来设置在发生哪些异常的时候一定会中断并及时给出提示。
异常窗格可以在“调试”->“窗口”->“异常设置”中打开:
在异常设置窗格中,我们可以将 Common Language Runtime Exceptions
选项打勾,这样任何 CLR 异常引发的时候 Visual Studio 都会中断而无论是否有 catch
块处理掉了此异常。
如果需要恢复设置,点击上面的恢复成默认的按钮即可。
当然,你也可以不需要全部打勾,而是只勾选你期望诊断问题的那几个异常。你可以试试,这其实是一个非常繁琐的工作,你会在大量的异常名称中失去眼神而再也无法直视任何异常了。
所以更推荐的做法不是仅设置特定异常时中断,而是反过来设置——设置发生所有异常时中断,除了特定的一些异常之外。
方法是:
Common Language Runtime Exceptions
打勾如果程序并不是在 Visual Studio 中运行,那么有没有方法进行中断呢?
一个做法是调用 Debugger.Launch()
,但这样的话中断的地方就是在 Debugger.Launch()
所在的代码处,可能异常还没发生或者已经发生过了。
有没有方法可以在异常发生的那一刻中断呢?请阅读我的另一篇博客:
在扩展 MSBuild 编译的时候,我们一般的处理的路径都是临时路径或者输出路径,那么发布路径在哪里呢?
我曾经在下面这一篇博客中说到可以通过阅读 Microsoft.NET.Sdk 的源码来探索我们想得知的扩展编译的答案:
于是,我们可以搜索 "Publish"
这样的关键字找到我们希望找到的编译目标,于是找到在 Microsoft.NET.Sdk.Publish.targets 文件中,有很多的 PublishDir
属性存在,这可以很大概率猜测这个就是发布路径。不过我只能在这个文件中找到这个路径的再次赋值,找不到初值。
如果全 Sdk 查找,可以找到更多赋初值和使用它复制和生成文件的地方。
于是可以确认,这个就是最终的发布路径,只不过不同类型的项目,其发布路径都是不同的。
比如默认是:
<PublishDir Condition="'$(PublishDir)'==''">$(OutputPath)app.publish\</PublishDir>
还有:
<_DeploymentApplicationDir>$(PublishDir)$(_DeploymentApplicationFolderName)\</_DeploymentApplicationDir>
和其他。
在为 .NET 项目扩展 MSBuild 编译而编写编译目标(Target)时,我们会遇到用于扩展编译目标用的属性 BeforeTargets
AfterTargets
和 DependsOnTargets
。
这三个应该分别在什么情况下用呢?本文将介绍其用法。
BeforeTargets
/ AfterTargets
BeforeTargets
和 AfterTargets
是用来扩展编译用的。
如果你希望在某个编译任务开始执行一定要执行你的编译目标,那么请使用 BeforeTargets
。例如我想多添加一个文件加入编译,那么写:
<Target Name="_WalterlvIncludeSourceFiles"
BeforeTargets="CoreCompile">
<ItemGroup>
<Compile Include="$(MSBuildThisFileFullPath)..\src\Foo.cs" />
</ItemGroup>
</Target>
这样,一个 Foo.cs 就会在编译时加入到被编译的文件列表中,里面的 Foo
类就可以被使用了。这也是 NuGet 源代码包的核心原理部分。关于 NuGet 源代码包的制作方法,可以扩展阅读:
如果你希望一旦执行完某个编译任务之后执行某个操作,那么请使用 AfterTargets
。例如我想在编译完成生成了输出文件之后,将这些输出文件拷贝到另一个调试目录,那么写:
<Target Name="CopyOutputLibToFastDebug" AfterTargets="AfterBuild">
<ItemGroup>
<OutputFileToCopy Include="$(OutputPath)$(AssemblyName).dll"></OutputFileToCopy>
<OutputFileToCopy Include="$(OutputPath)$(AssemblyName).pdb"></OutputFileToCopy>
</ItemGroup>
<Copy SourceFiles="@(OutputFileToCopy)" DestinationFolder="$(MainProjectPath)"></Copy>
</Target>
这种写法可以进行快速的组件调试。下面这篇博客就是用到了 AfterTargets
带来的此机制来实现的:
如果 BeforeTargets
和 AfterTargets
中写了多个 Target 的名称(用分号分隔),那么只要任何一个准备执行或者执行完毕,就会触发此 Target 的执行。
DependsOnTargets
而 DependsOnTargets
是用来指定依赖的。
DependsOnTargets
并不会直接帮助你扩展一个编译目标,也就是说如果你只为你的 Target 写了一个名字,然后添加了 DependsOnTargets
属性,那么你的 Target 在编译期间根本都不会执行。
但是,使用 DependsOnTargets
,你可以更好地控制执行流程和其依赖关系。
例如上面的 CopyOutputLibToFastDebug
这个将输出文件复制到另一个目录的编译目标(Target),依赖于一个 MainProjectPath
属性,因此计算这个属性值的编译目标(Target)应该设成此 Target 的依赖。
当 A 的 DependsOnTargets
设置为 B;C;D
时,那么一旦准备执行 A 时将会发生:
当我们实际上在扩展编译的时候,我们会用到不止一个编译目标,因此这几个属性都是混合使用的。但是,你应该在合适的地方编写合适的属性设置。
例如我们做一个 NuGet 包,这个 NuGet 包的 .targets 文件中写了下面几个 Target:
那么我们改如何为每一个 Target 设置正确的属性呢?
第一步:找出哪些编译目标是真正完成编译任务的,这些编译目标需要通过 BeforeTargets
和 AfterTarget
设置扩展编译。
于是我们可以找到 _WalterlvIncludeSourceFiles
、_WalterlvPackOutput
。
_WalterlvIncludeSourceFiles
需要添加参与编译的源代码文件,因此我们需要将 BeforeTargets
设置为 CoreCompile
。_WalterlvPackOutput
需要在编译完成后进行自定义打包,因此我们将 AfterTargets
设置为 AfterBuild
。这个时候可以确保文件已经生成完毕可以使用了。第二步:找到依赖关系,这些依赖关系需要通过 DependsOnTargets
来执行。
于是我们可以找到 _WalterlvEvaluateProperties
、_WalterlvGenerateStartupObject
。
_WalterlvEvaluateProperties
被其他所有的编译目标使用了,因此,我们需要将后面所有的 DependsOnTargets
属性设置为 _WalterlvEvaluateProperties
。_WalterlvGenerateStartupObject
生成的入口点函数被 _WalterlvIncludeSourceFiles
加入到编译中,因此 _WalterlvIncludeSourceFiles
的 DependsOnTargets
属性需要添加 _WalterlvGenerateStartupObject
(添加方法是使用分号“;”分隔)。将所有的这些编译任务合在一起写,将是下面这样:
<Target Name="_WalterlvEvaluateProperties">
</Target>
<Target Name="_WalterlvGenerateStartupObject"
DependsOnTargets="_WalterlvEvaluateProperties">
</Target>
<Target Name="_WalterlvIncludeSourceFiles"
BeforeTargets="CoreCompile"
DependsOnTargets="_WalterlvEvaluateProperties;_WalterlvGenerateStartupObject">
</Target>
<Target Name="_WalterlvPackOutput"
AfterTargets="AfterBuild"
DependsOnTargets="_WalterlvEvaluateProperties">
</Target>
我们平时在编写代码时会考虑面向对象的六个原则,其中有一个是依赖倒置原则,即具体依赖于抽象。
你不这么写代码当然不会带来错误,但会带来维护性困难。在编写扩展编译目标的时候,这一条同样适用。
假如我们要写的编译目标不止上面这些,还有更多:
那么这个时候我们前面写的用于引入源代码的 _WalterlvIncludeSourceFiles
编译目标其依赖的 Target 会更多。似乎看起来应该这么写了:
<Target Name="_WalterlvIncludeSourceFiles"
BeforeTargets="CoreCompile"
DependsOnTargets="_WalterlvEvaluateProperties;_WalterlvGenerateStartupObject;_WalterlvConvertTemplateCompileToRealCompile;_WalterlvConditionalImportedSourceCode">
</Target>
但你小心:
_WalterlvConditionalImportedSourceCode
是有条件的,而我们 DependsOnTargets
这样的写法会导致这个 Target 的条件失效这里更抽象的编译目标是 _WalterlvIncludeSourceFiles
,我们的依赖关系倒置了!
为了解决这样的问题,我们引入一个新的属性 _WalterlvIncludeSourceFilesDependsOn
,如果有编译目标在编译过程中生成了新的源代码,那么就需要将自己加入到此属性中。
现在的源代码看起来是这样的:
<!-- 这里是一个文件 -->
<PropertyGroup>
<_WalterlvIncludeSourceFilesDependsOn>
$(_WalterlvIncludeSourceFilesDependsOn);
_WalterlvGenerateStartupObject
</_WalterlvIncludeSourceFilesDependsOn>
</PropertyGroup>
<Target Name="_WalterlvEvaluateProperties">
</Target>
<Target Name="_WalterlvGenerateStartupObject"
DependsOnTargets="_WalterlvEvaluateProperties">
</Target>
<Target Name="_WalterlvIncludeSourceFiles"
BeforeTargets="CoreCompile"
DependsOnTargets="$(_WalterlvIncludeSourceFilesDependsOn)">
</Target>
<Target Name="_WalterlvPackOutput"
AfterTargets="AfterBuild"
DependsOnTargets="_WalterlvEvaluateProperties">
</Target>
<!-- 这里是另一个文件 -->
<PropertyGroup>
<_WalterlvIncludeSourceFilesDependsOn>
$(_WalterlvIncludeSourceFilesDependsOn);
_WalterlvConvertTemplateCompileToRealCompile;
_WalterlvConditionalImportedSourceCode
</_WalterlvIncludeSourceFilesDependsOn>
</PropertyGroup>
<PropertyGroup Condition=" '$(UseWalterlvDemoCode)' == 'True' ">
<_WalterlvIncludeSourceFilesDependsOn>
$(_WalterlvIncludeSourceFilesDependsOn);
_WalterlvConditionalImportedSourceCode
</_WalterlvIncludeSourceFilesDependsOn>
</PropertyGroup>
<Target Name="_WalterlvConvertTemplateCompileToRealCompile"
DependsOnTargets="_WalterlvEvaluateProperties">
</Target>
<Target Name="_WalterlvConditionalImportedSourceCode"
Condition=" '$(UseWalterlvDemoCode)' == 'True' "
DependsOnTargets="_WalterlvEvaluateProperties">
</Target>
实际上,Microsoft.NET.Sdk 内部有很多的编译任务是通过这种方式提供的扩展,例如:
你可以阅读我的另一篇博客了解更多:
.NET 扩展编译用的文件有 .props 文件和 .targets 文件。不给我选择还好,给了我选择之后我应该使用哪个文件来编写扩展编译的代码呢?
如果你不了解 .props 文件或者 .targets 文件,可以阅读下面的博客:
具体的例子有下面这些博客。不过大概阅读一下就好,这只是 .props 和 .targets 文件的一些应用。文章比较长,你可以考虑稍后阅读。
当我们创建的 NuGet 包中包含 .props 和 .targets 文件的时候,我们相当于在项目文件 csproj 的两个地方添加了 Import 这些文件的代码。
<Project Sdk="Microsoft.NET.Sdk">
<!-- 本来是没有下面这一行的,我只是为了说明 NuGet 相当于帮我们添加了这一行才假装写到了这里。 -->
<Import Project="$(NuGetPackageRoot)walterlv.samplepackage\0.8.3-alpha\build\Walterlv.SamplePackage.props" Condition="Exists('$(NuGetPackageRoot)walterlv.samplepackage\0.8.3-alpha\build\Walterlv.SamplePackage.props')" />
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>netcoreapp3.0</TargetFrameworks>
</PropertyGroup>
<!-- 本来是没有下面这一行的,我只是为了说明 NuGet 相当于帮我们添加了这一行才假装写到了这里。 -->
<Import Project="$(NuGetPackageRoot)walterlv.samplepackage\0.8.3-alpha\build\Walterlv.SamplePackage.targets" Condition="Exists('$(NuGetPackageRoot)walterlv.samplepackage\0.8.3-alpha\build\Walterlv.SamplePackage.targets')" />
</Project>
如果你安装的多份 NuGet 包都带有 .props 和 .targets 文件,那么就相当于帮助你 Import 了多个:
<Project Sdk="Microsoft.NET.Sdk">
<!-- 本来是没有下面这一行的,我只是为了说明 NuGet 相当于帮我们添加了这一行才假装写到了这里。 -->
<Import Project="$(NuGetPackageRoot)walterlv.samplepackage1\0.8.3-alpha\build\Walterlv.SamplePackage1.props" Condition="Exists('$(NuGetPackageRoot)walterlv.samplepackage1\0.8.3-alpha\build\Walterlv.SamplePackage1.props')" />
<Import Project="$(NuGetPackageRoot)walterlv.samplepackage2\0.5.1-beta\build\Walterlv.SamplePackage2.props" Condition="Exists('$(NuGetPackageRoot)walterlv.samplepackage2\0.5.1-beta\build\Walterlv.SamplePackage2.props')" />
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>netcoreapp3.0</TargetFrameworks>
</PropertyGroup>
<!-- 本来是没有下面这一行的,我只是为了说明 NuGet 相当于帮我们添加了这一行才假装写到了这里。 -->
<Import Project="$(NuGetPackageRoot)walterlv.samplepackage1\0.8.3-alpha\build\Walterlv.SamplePackage1.targets" Condition="Exists('$(NuGetPackageRoot)walterlv.samplepackage1\0.8.3-alpha\build\Walterlv.SamplePackage1.targets')" />
<Import Project="$(NuGetPackageRoot)walterlv.samplepackage2\0.5.1-beta\build\Walterlv.SamplePackage2.targets" Condition="Exists('$(NuGetPackageRoot)walterlv.samplepackage2\0.5.1-beta\build\Walterlv.SamplePackage2.targets')" />
</Project>
于是,什么代码写到 .props 里而什么代码写到 .targets 里就一目了然了:
DependsOn
等属性来获取例如下面的属性适合写到 .props 里面。这是一个设置属性初始值的地方:
<Project>
<PropertyGroup>
<MSBuildAllProjects>$(MSBuildAllProjects);$(MSBuildThisFileFullPath)</MSBuildAllProjects>
<!-- 当生成 WPF 临时项目时,不会自动 Import NuGet 中的 props 和 targets 文件,这使得在临时项目中你现在看到的整个文件都不会参与编译。
然而,我们可以通过欺骗的方式在主项目中通过 _GeneratedCodeFiles 集合将需要编译的文件传递到临时项目中以间接参与编译。
WPF 临时项目不会 Import NuGet 中的 props 和 targets 可能是 WPF 的 Bug,也可能是刻意如此。
所以我们通过一个属性开关 `ShouldFixNuGetImportingBugForWpfProjects` 来决定是否修复这个错误。-->
<ShouldFixNuGetImportingBugForWpfProjects Condition=" '$(ShouldFixNuGetImportingBugForWpfProjects)' == '' ">True</ShouldFixNuGetImportingBugForWpfProjects>
</PropertyGroup>
</Project>
而下面的属性适合写到 .targets 里面,因为这里使用到了其他的属性:
<Project>
<PropertyGroup>
<MSBuildAllProjects>$(MSBuildAllProjects);$(MSBuildThisFileFullPath)</MSBuildAllProjects>
<!-- 因为这里使用到了 `Configuration` 属性,需要先等到此属性已经初始化完成再使用,否则我们会拿到非预期的值。 -->
<ShouldOptimizeDebugging> Condition=" '$(Configuration)' == 'Debug' ">True</ShouldOptimizeDebugging>
</PropertyGroup>
</Project>
对于稍微大一点的 .NET 解决方案来说,编译时间通常都会长一些。如果项目结构和差量编译优化的好,可能编译完也就 5~30 秒,但如果没有优化好,那么出现 1~3 分钟都是可能的。
如果能够在编译出错的第一时间停止编译,那么我们能够更快地去找编译错误的原因,也能从更少的编译错误列表中找到出错的关键原因。
如果你只是觉得你的项目或解决方案编译很慢而不知道原因,我推荐你安装 Parallel Builds Monitor 插件来调查一下。你可以阅读我的一篇博客来了解它:
一个优化比较差的解决方案可能是下面这个样子的:
明明没有多少个项目,但是项目之间的依赖几乎是一条直线,于是不可能开启项目的并行编译。
图中这个项目的编译时长有 1 分 30 秒。可想而知,如果你的改动导致非常靠前的项目编译错误,而默认情况下编译的时候会继续尝试编译下去,于是你需要花非常长的时间才能等待编译完毕,然后从一大堆项目中出现的编译错误中找到最开始出现错误的那个(通常也是编译失败的本质原因)。
现在,推荐使用插件 VSColorOutput。
它的主要功能是给你的输出窗格加上颜色,可以让你更快速地区分调试信息、输出、警告和错误。
不过,也正是因为它是通过匹配输出来上色的,于是它可以得知你的项目出现了编译错误,可以采取措施。
在你安装了这款插件之后,你可以在 Visual Studio 的“工具”->“设置”中找到 VSColorOutput 的设置。其中有一项是“Stop Build on First Error”,打开之后,再出现了错误的话,将第一时间会停止。你也可以发现你的 Visual Studio 错误列表中的错误数量非常少了,这些错误都是导致编译失败的最早出现的错误,利于你定位问题。
默认情况下,我们打包 NuGet 包时,目标项目安装我们的 NuGet 包会引用我们生成的库文件(dll)。除此之外,我们也可以专门做 NuGet 工具包,还可以做 NuGet 源代码包。然而做源代码包可能是其中最困难的一种了,目标项目安装完后,这些源码将直接随目标项目一起编译。
本文将从零开始,教你制作一个支持 .NET 各种类型项目的源代码包。
在开始制作一个源代码包之间,建议你提前了解项目文件的一些基本概念:
当然就算不了解也没有关系。跟着本教程你也可以制作出来一个源代码包,只不过可能遇到了问题的时候不容易调试和解决。
接下来,我们将从零开始制作一个源代码包。
我们接下来的将创建一个完整的解决方案,这个解决方案包括:
像其他 NuGet 包的引用项目一样,我们需要创建一个空的项目。不过差别是我们需要创建的是控制台程序。
当创建好之后,Main
函数中的所有内容都是不需要的,于是我们删除 Main
函数中的所有内容但保留 Main
函数。
这时 Program.cs 中的内容如下:
namespace Walterlv.PackageDemo.SourceCode
{
class Program
{
static void Main(string[] args)
{
}
}
}
双击创建好的项目的项目,或者右键项目 “编辑项目文件”,我们可以编辑此项目的 csproj 文件。
在这里,我将目标框架改成了 net48
。实际上如果我们不制作动态源代码生成,那么这里无论填写什么目标框架都不重要。在这篇博客中,我们主要篇幅都会是做静态源代码生成,所以你大可不必关心这里填什么。
提示:如果 net48 让你无法编译这个项目,说明你电脑上没有装 .NET Framework 4.8 框架,请改成 net473, net472, net471, net47, net462, net 461, net46, net45, netcoreapp3.0, netcoreapp2.1, netcoreapp2.0 中的任何一个可以让你编译通过的目标框架即可。
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net48</TargetFramework>
</PropertyGroup>
</Project>
接下来,我们会让这个项目像一个 NuGet 包的样子。当然,是 NuGet 源代码包。
请在你的项目当中创建这些文件和文件夹:
- Assets
- build
+ Package.props
+ Package.targets
- buildMultiTargeting
+ Package.props
+ Package.targets
- src
+ Foo.cs
- tools
+ Program.cs
在这里,-
号表示文件夹,+
号表示文件。
Program.cs 是我们一开始就已经有的,可以不用管。src 文件夹里的 Foo.cs 是我随意创建的一个类,你就想往常创建正常的类文件一样创建一些类就好了。
比如我的 Foo.cs 里面的内容很简单:
using System;
namespace Walterlv.PackageDemo.SourceCode
{
internal class Foo
{
public static void Print() => Console.WriteLine("Walterlv is a 逗比.");
}
}
props 和 targets 文件你可能在 Visual Studio 的新建文件的模板中找不到这样的模板文件。这不重要,你随便创建一个文本文件,然后将名称修改成上面列举的那样即可。接下来我们会依次修改这些文件中的所有内容,所以无需担心模板自动为我们生成了哪些内容。
为了更直观,我将我的解决方案截图贴出来,里面包含所有这些文件和文件夹的解释。
我特别说明了哪些文件和文件夹是必须存在的,哪些文件和文件夹的名称一定必须与本文说明的一样。如果你是以教程的方式阅读本文,建议所有的文件和文件夹都跟我保持一样的结构和名称;如果你已经对 NuGet 包的结构有一定了解,那么可自作主张修改一些名称。
现在,我们要双击项目名称或者右键“编辑项目文件”来编辑项目的 csproj 文件
我们编辑项目文件的目的,是让我们前一步创建的项目文件夹结构真正成为 NuGet 包中的文件夹结构。
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net48</TargetFramework>
<!-- 要求此项目编译时要生成一个 NuGet 包。-->
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<!-- 这里为了方便,我将 NuGet 包的输出路径设置在了解决方案根目录的 bin 文件夹下,而不是项目的 bin 文件夹下。-->
<PackageOutputPath>..\bin\$(Configuration)</PackageOutputPath>
<!-- 创建 NuGet 包时,项目的输出文件对应到 NuGet 包的 tools 文件夹,这可以避免目标项目引用我们的 NuGet 包的输出文件。
同时,如果将来我们准备动态生成源代码,而不只是引入静态源代码,还可以有机会运行我们 Program 中的 Main 函数。-->
<BuildOutputTargetFolder>tools</BuildOutputTargetFolder>
<!-- 此包将不会传递依赖。意味着如果目标项目安装了此 NuGet 包,那么安装目标项目包的项目不会间接安装此 NuGet 包。-->
<DevelopmentDependency>true</DevelopmentDependency>
<!-- 包的版本号,我们设成了一个预览版;当然你也可以设置为正式版,即没有后面的 -alpha 后缀。-->
<Version>0.1.0-alpha</Version>
<!-- 设置包的作者。在上传到 nuget.org 之后,如果作者名与 nuget.org 上的账号名相同,其他人浏览包是可以直接点击链接看作者页面。-->
<Authors>walterlv</Authors>
<!-- 设置包的组织名称。我当然写成我所在的组织 dotnet 职业技术学院啦。-->
<Company>dotnet-campus</Company>
</PropertyGroup>
<!-- 在生成 NuGet 包之前,我们需要将我们项目中的文件夹结构一一映射到 NuGet 包中。-->
<Target Name="IncludeAllDependencies" BeforeTargets="_GetPackageFiles">
<ItemGroup>
<!-- 将 Package.props / Package.targets 文件的名称在 NuGet 包中改为需要的真正名称。
因为 NuGet 包要自动导入 props 和 targets 文件,要求文件的名称必须是 包名.props 和 包名.targets;
然而为了避免我们改包名的时候还要同步改四个文件的名称,所以就在项目文件中动态生成。-->
<None Include="Assets\build\Package.props" Pack="True" PackagePath="build\$(PackageId).props" />
<None Include="Assets\build\Package.targets" Pack="True" PackagePath="build\$(PackageId).targets" />
<None Include="Assets\buildMultiTargeting\Package.props" Pack="True" PackagePath="buildMultiTargeting\$(PackageId).props" />
<None Include="Assets\buildMultiTargeting\Package.targets" Pack="True" PackagePath="buildMultiTargeting\$(PackageId).targets" />
<!-- 我们将 src 目录中的所有源代码映射到 NuGet 包中的 src 目录中。-->
<None Include="Assets\src\**" Pack="True" PackagePath="src" />
</ItemGroup>
</Target>
</Project>
接下来,我们将编写编译文件 props 和 targets。注意,我们需要写的是四个文件的内容,不要弄错了。
如果我们做好的 NuGet 源码包被其他项目使用,那么这四个文件中的其中一对会在目标项目被自动导入(Import)。在你理解 理解 C# 项目 csproj 文件格式的本质和编译流程 一文内容之前,你可能不明白“导入”是什么意思。但作为从零开始的入门博客,你也不需要真的理解导入是什么意思,只要知道这四个文件中的代码将在目标项目编译期间运行就好。
你只需要将下面的代码拷贝到 buildMultiTargeting 文件夹中的 Package.props 文件即可。注意将包名换成你自己的包名,也就是项目名。
<Project>
<PropertyGroup>
<MSBuildAllProjects>$(MSBuildAllProjects);$(MSBuildThisFileFullPath)</MSBuildAllProjects>
</PropertyGroup>
<!-- 为了简单起见,如果导入了这个文件,那么我们将直接再导入 ..\build\Walterlv.PackageDemo.SourceCode.props 文件。
注意到了吗?我们并没有写 Package.props,因为我们在第三步编写项目文件时已经将这个文件转换为真实的包名了。-->
<Import Project="..\build\Walterlv.PackageDemo.SourceCode.props" />
</Project>
你只需要将下面的代码拷贝到 buildMultiTargeting 文件夹中的 Package.targets 文件即可。注意将包名换成你自己的包名,也就是项目名。
<Project>
<PropertyGroup>
<MSBuildAllProjects>$(MSBuildAllProjects);$(MSBuildThisFileFullPath)</MSBuildAllProjects>
</PropertyGroup>
<!-- 为了简单起见,如果导入了这个文件,那么我们将直接再导入 ..\build\Walterlv.PackageDemo.SourceCode.targets 文件。
注意到了吗?我们并没有写 Package.targets,因为我们在第三步编写项目文件时已经将这个文件转换为真实的包名了。-->
<Import Project="..\build\Walterlv.PackageDemo.SourceCode.targets" />
</Project>
下面是 build 文件夹中 Package.props 文件的全部内容。可以注意到我们几乎没有任何实质性的代码在里面。即便我们在此文件中还没有写任何代码,依然需要创建这个文件,因为后面第五步我们将添加更复杂的代码时将再次用到这个文件完成里面的内容。
现在,保持你的文件中的内容与下面一模一样就好。
<Project>
<PropertyGroup>
<MSBuildAllProjects>$(MSBuildAllProjects);$(MSBuildThisFileFullPath)</MSBuildAllProjects>
</PropertyGroup>
</Project>
下面是 build 文件夹中的 Package.targets 文件的全部内容。
我们写了两个编译目标,即 Target。_WalterlvDemoEvaluateProperties
没有指定任何执行时机,但帮我们计算了两个属性:
_WalterlvDemoRoot
即 NuGet 包的根目录_WalterlvDemoSourceFolder
即 NuGet 包中的源代码目录另外,我们添加了一个 Message
任务,用于在编译期间显示一条信息,这对于调试来说非常方便。
_WalterlvDemoIncludeSourceFiles
这个编译目标指定在 CoreCompile
之前执行,并且执行需要依赖于 _WalterlvDemoEvaluateProperties
编译目标。这意味着当编译执行到 CoreCompile
步骤时,将在它执行之前插入 _WalterlvDemoIncludeSourceFiles
编译目标来执行,而 _WalterlvDemoIncludeSourceFiles
依赖于 _WalterlvDemoEvaluateProperties
,于是 _WalterlvDemoEvaluateProperties
会插入到更之前执行。那么在微观上来看,这三个编译任务的执行顺序将是:_WalterlvDemoEvaluateProperties
-> _WalterlvDemoIncludeSourceFiles
-> CoreCompile
。
_WalterlvDemoIncludeSourceFiles
中,我们定义了一个集合 _WalterlvDemoCompile
,集合中包含 NuGet 包源代码文件夹中的所有 .cs 文件。另外,我们又定义了 Compile
集合,将 _WalterlvDemoCompile
集合中的所有内容添加到 Compile
集合中。Compile
是 .NET 项目中的一个已知集合,当 CoreCompile
执行时,所有 Compile
集合中的文件将参与编译。注意到我没有直接将 NuGet 包中的源代码文件引入到 Compile
集合中,而是经过了中转。后面第五步中,你将体会到这样做的作用。
我们也添加一个 Message
任务,用于在编译期间显示信息,便于调试。
<Project>
<PropertyGroup>
<MSBuildAllProjects>$(MSBuildAllProjects);$(MSBuildThisFileFullPath)</MSBuildAllProjects>
</PropertyGroup>
<Target Name="_WalterlvDemoEvaluateProperties">
<PropertyGroup>
<_WalterlvDemoRoot>$(MSBuildThisFileDirectory)..\</_WalterlvDemoRoot>
<_WalterlvDemoSourceFolder>$(MSBuildThisFileDirectory)..\src\</_WalterlvDemoSourceFolder>
</PropertyGroup>
<Message Text="1. 初始化源代码包的编译属性" />
</Target>
<!-- 引入 C# 源码。 -->
<Target Name="_WalterlvDemoIncludeSourceFiles"
BeforeTargets="CoreCompile"
DependsOnTargets="_WalterlvDemoEvaluateProperties">
<ItemGroup>
<_WalterlvDemoCompile Include="$(_WalterlvDemoSourceFolder)**\*.cs" />
<Compile Include="@(_WalterlvDemoCompile)" />
</ItemGroup>
<Message Text="2 引入源代码包中的所有源代码:@(_WalterlvDemoCompile)" />
</Target>
</Project>
我们刚刚花了很大的篇幅教大家完成 props 和 targets 文件,那么这四个文件是做什么的呢?
如果安装我们源代码包的项目使用 TargetFramework
属性写目标框架,那么 NuGet 会自动帮我们导入 build 文件夹中的两个编译文件。如果安装我们源代码包的项目使用 TargetFrameworks
(注意复数形式)属性写目标框架,那么 NuGet 会自动帮我们导入 buildMultiTargeting 文件夹中的两个编译文件。
如果你对这个属性不熟悉,请回到第一步看我们一开始创建的代码,你会看到这个属性的设置的。如果还不清楚,请阅读博客:
也许你已经从本文拷贝了很多代码过去了,但直到目前我们还没有看到这些代码的任何效果,那么现在我们就可以来看看了。这可算是一个阶段性成果呢!
先编译生成一下我们一直在完善的项目,我们就可以在解决方案目录的 bin\Debug
目录下找到一个 NuGet 包。
现在,我们要打开这个 NuGet 包看看里面的内容。你需要先去应用商店下载 NuGet Package Explorer,装完之后你就可以开始直接双击 NuGet 包文件,也就是 nupkg 文件。现在我们双击打开看看。
我们的体验到此为止。如果你希望在真实的项目当中测试,可以阅读其他博客了解如何在本地测试 NuGet 包。
截至目前,我们只是在源代码包中引入了 C# 代码。如果我们需要加入到源代码包中的代码包含 WPF 的 XAML 文件,或者安装我们源代码包的目标项目包含 WPF 的 XAML 文件,那么这个 NuGet 源代码包直接会导致无法编译通过。至于原因,你需要阅读我的另一篇博客来了解:
即便你不懂 WPF 程序的编译过程,你也可以继续完成本文的所有内容,但可能就不会明白为什么接下来我们要那样去修改我们之前创建的文件。
接下来我们将修改这些文件:
在这个文件中,我们将新增一个属性 ShouldFixNuGetImportingBugForWpfProjects
。这是我取的名字,意为“是否应该修复 WPF 项目中 NuGet 包自动导入的问题”。
我做一个开关的原因是怀疑我们需要针对 WPF 项目进行特殊处理是 WPF 项目自身的 Bug,如果将来 WPF 修复了这个 Bug,那么我们将可以直接通过此开关来关闭我们在这一节做的特殊处理。另外,后面我们将采用一些特别的手段来调试我们的 NuGet 源代码包,在调试项目中我们也会将这个属性设置为 False
以关闭 WPF 项目的特殊处理。
<Project>
<PropertyGroup>
<MSBuildAllProjects>$(MSBuildAllProjects);$(MSBuildThisFileFullPath)</MSBuildAllProjects>
++ <!-- 当生成 WPF 临时项目时,不会自动 Import NuGet 中的 props 和 targets 文件,这使得在临时项目中你现在看到的整个文件都不会参与编译。
++ 然而,我们可以通过欺骗的方式在主项目中通过 _GeneratedCodeFiles 集合将需要编译的文件传递到临时项目中以间接参与编译。
++ WPF 临时项目不会 Import NuGet 中的 props 和 targets 可能是 WPF 的 Bug,也可能是刻意如此。
++ 所以我们通过一个属性开关 `ShouldFixNuGetImportingBugForWpfProjects` 来决定是否修复这个错误。-->
++ <ShouldFixNuGetImportingBugForWpfProjects Condition=" '$(ShouldFixNuGetImportingBugForWpfProjects)' == '' ">True</ShouldFixNuGetImportingBugForWpfProjects>
++ </PropertyGroup>
</Project>
请按照下面的差异说明来修改你的 Package.targets 文件。实际上我们几乎删除任何代码,所以其实你可以将下面的所有内容作为你的新的 Package.targets 中的内容。
<Project>
<PropertyGroup>
<MSBuildAllProjects>$(MSBuildAllProjects);$(MSBuildThisFileFullPath)</MSBuildAllProjects>
</PropertyGroup>
++ <PropertyGroup>
++ <!-- 我们增加了一个属性,用于处理 WPF 特殊项目的源代码之前,确保我们已经收集到所有需要引入的源代码。 -->
++ <_WalterlvDemoImportInWpfTempProjectDependsOn>_WalterlvDemoIncludeSourceFiles</_WalterlvDemoImportInWpfTempProjectDependsOn>
++ </PropertyGroup>
<Target Name="_WalterlvDemoEvaluateProperties">
<PropertyGroup>
<_WalterlvDemoRoot>$(MSBuildThisFileDirectory)..\</_WalterlvDemoRoot>
<_WalterlvDemoSourceFolder>$(MSBuildThisFileDirectory)..\src\</_WalterlvDemoSourceFolder>
</PropertyGroup>
<Message Text="1. 初始化源代码包的编译属性" />
</Target>
<!-- 引入 C# 源码。 -->
<Target Name="_WalterlvDemoIncludeSourceFiles"
BeforeTargets="CoreCompile"
DependsOnTargets="_WalterlvDemoEvaluateProperties">
<ItemGroup>
<_WalterlvDemoCompile Include="$(_WalterlvDemoSourceFolder)**\*.cs" />
++ <_WalterlvDemoAllCompile Include="@(_WalterlvDemoCompile)" />
<Compile Include="@(_WalterlvDemoCompile)" />
</ItemGroup>
-- <Message Text="2 引入源代码包中的所有源代码:@(_WalterlvDemoCompile)" />
++ <Message Text="2.1 引入源代码包中的所有源代码:@(_WalterlvDemoCompile)" />
</Target>
++ <!-- 引入 WPF 源码。 -->
++ <Target Name="_WalterlvDemoIncludeWpfFiles"
++ BeforeTargets="MarkupCompilePass1"
++ DependsOnTargets="_WalterlvDemoEvaluateProperties">
++ <ItemGroup>
++ <_WalterlvDemoPage Include="$(_WalterlvDemoSourceFolder)**\*.xaml" />
++ <Page Include="@(_WalterlvDemoPage)" Link="%(_WalterlvDemoPage.FileName).xaml" />
++ </ItemGroup>
++ <Message Text="2.2 引用 WPF 相关源码:@(_WalterlvDemoPage)" />
++ </Target>
++ <!-- 当生成 WPF 临时项目时,不会自动 Import NuGet 中的 props 和 targets 文件,这使得在临时项目中你现在看到的整个文件都不会参与编译。
++ 然而,我们可以通过欺骗的方式在主项目中通过 _GeneratedCodeFiles 集合将需要编译的文件传递到临时项目中以间接参与编译。
++ WPF 临时项目不会 Import NuGet 中的 props 和 targets 可能是 WPF 的 Bug,也可能是刻意如此。
++ 所以我们通过一个属性开关 `ShouldFixNuGetImportingBugForWpfProjects` 来决定是否修复这个错误。-->
++ <Target Name="_WalterlvDemoImportInWpfTempProject"
++ AfterTargets="MarkupCompilePass1"
++ BeforeTargets="GenerateTemporaryTargetAssembly"
++ DependsOnTargets="$(_WalterlvDemoImportInWpfTempProjectDependsOn)"
++ Condition=" '$(ShouldFixNuGetImportingBugForWpfProjects)' == 'True' ">
++ <ItemGroup>
++ <_GeneratedCodeFiles Include="@(_WalterlvDemoAllCompile)" />
++ </ItemGroup>
++ <Message Text="3. 正在欺骗临时项目,误以为此 NuGet 包中的文件是 XAML 编译后的中间代码:@(_WalterlvDemoAllCompile)" />
++ </Target>
</Project>
我们增加了 _WalterlvDemoImportInWpfTempProjectDependsOn
属性,这个属性里面将填写一个到多个编译目标(Target)的名称(多个用分号分隔),用于告知 _WalterlvDemoImportInWpfTempProject
这个编译目标在执行之前必须确保执行的依赖编译目标。而我们目前的依赖目标只有一个,就是 _WalterlvDemoIncludeSourceFiles
这个引入 C# 源代码的编译目标。如果你有其他考虑有引入更多 C# 源代码的编译目标,则需要把他们都加上(当然本文是不需要的)。为此,我还新增了一个 _WalterlvDemoAllCompile
集合,如果存在多个依赖的编译目标会引入 C# 源代码,则需要像 _WalterlvDemoIncludeSourceFiles
一样,将他们都加入到 Compile
的同时也加入到 _WalterlvDemoAllCompile
集合中。
为什么可能有多个引入 C# 源代码的编译目标?因为本文我们只考虑了引入我们提前准备好的源代码放入源代码包中,而我们提到过可能涉及到动态生成 C# 源代码的需求。如果你有一两个编译目标会动态生成一些 C# 源代码并将其加入到 Compile
集合中,那么请将这个编译目标的名称加入到 _WalterlvDemoImportInWpfTempProjectDependsOn
属性(注意多个用分号分隔),同时将集合也引入一份到 _WalterlvDemoAllCompile
中。
_WalterlvDemoIncludeWpfFiles
这个编译目标的作用是引入 WPF 的 XAML 文件,这很容易理解,毕竟我们的源代码中包含 WPF 相关的文件。
请特别注意:
Link
属性,并且将其指定为 %(_WalterlvDemoPage.FileName).xaml
。这意味着我们会把所有的 XAML 文件都当作在项目根目录中生成,如果你在其他的项目中用到了相对或绝对的 XAML 文件的路径,这显然会改变路径。但是,我们没有其他的方法来根据 XAML 文件所在的目录层级来自定指定 Link
属性让其在正确的层级上,所以这里才写死在根目录中。
Link
属性(例如改为 Views\%(_WalterlvDemoPage.FileName).xaml
),这意味着需要在此项目编译期间执行一段代码,把 Package.targets 文件中为所有的 XAML 文件生成正确的 Link
属性。本文暂时不考虑这个问题,但你可以参考 dotnet-campus/SourceYard 项目来了解如何动态生成 Link
。_WalterlvDemoPage
集合中转地存了 XAML 文件,这是必要的。因为这样才能正确通过 %
符号获取到 FileName
属性。而 _WalterlvDemoImportInWpfTempProject
这个编译目标就不那么好理解了,而这个也是完美支持 WPF 项目源代码包的关键编译目标!这个编译目标指定在 MarkupCompilePass1
之后,GenerateTemporaryTargetAssembly
之前执行。GenerateTemporaryTargetAssembly
编译目标的作用是生成一个临时的项目,用于让 WPF 的 XAML 文件能够依赖同项目的 .NET 类型而编译。然而此临时项目编译期间是不会导入任何 NuGet 的 props 或 targets 文件的,这意味着我们特别添加的所有 C# 源代码在这个临时项目当中都是不存在的——如果项目使用到了我们源代码包中的源代码,那么必然因为类型不存在而无法编译通过——临时项目没有编译通过,那么整个项目也就无法编译通过。但是,我们通过在 MarkupCompilePass1
和 GenerateTemporaryTargetAssembly
之间将我们源代码包中的所有源代码加入到 _GeneratedCodeFiles
集合中,即可将这些文件加入到临时项目中一起编译。而原本 _GeneratedCodeFiles
集合中是什么呢?就是大家熟悉的 XAML 转换而成的 xxx.g.cs
文件。
现在我们再次编译这个项目,你将得到一个支持 WPF 项目的 NuGet 源代码包。
至此,我们已经完成了编写一个 NuGet 源代码包所需的全部源码。接下来你可以在项目中添加更多的源代码,这样打出来的源代码包也将包含更多源代码。由于我们将将 XAML 文件都通过 Link
属性指定到根目录了,所以如果你需要添加 XAML 文件,你将只能添加到我们项目中的 Assets\src
目录下,除非做 dotnet-campus/SourceYard 中类似的动态 Link
生成的处理,或者在 Package.targets 文件中手工为每一个 XAML 编写一个特别的 Link
属性。
另外,在不改变我们整体项目结构的情况下,你也可以任意添加 WPF 所需的图片资源等。但也需要在 Package.targets 中添加额外的 Resource
引用。如果没有 dotnet-campus/SourceYard 的自动生成代码,你可能也需要手工编写 Resource
。
接下来我会贴出更复杂的代码,用于处理更复杂的源代码包的场景。
更复杂源代码包的项目组织形式会是下面这样图这样:
我们在 Assets 文件夹中新增了一个 assets 文件夹。由于资源在此项目中的路径必须和安装后的目标项目中一样才可以正确用 Uri 的方式使用资源,所以我们在项目文件 csproj 和编译文件 Package.targets 中都对这两个文件设置了 Link
到同一个文件夹中,这样才可以确保两边都能正常使用。
我们在 src 文件夹的不同子文件夹中创建了 XAML 文件。按照我们前面的说法,我们也需要像资源文件一样正确在 Package.targets 中设置 Link 才可以确保 Uri 是一致的。注意,我们接下来的源代码中没有在项目文件中设置 Link,原则上也是需要设置的,就像资源一样,这样才可以确保此项目和安装此 NuGet 包中的目标项目具有相同的 XAML Uri。此例子只是因为没有代码使用到了 XAML 文件的路径,所以才能得以幸免。
我们还利用了 tools 文件夹。我们在项目文件的末尾将输出文件拷贝到了 tools 目录下,这样,我们项目的 Assets 文件夹几乎与最终的 NuGet 包的文件夹结构一模一样,非常利于调试。但为了防止将生成的文件上传到版本管理,我在 tools 中添加了 .gitignore 文件:
/net*/*
-- <Project Sdk="Microsoft.NET.Sdk">
++ <Project Sdk="Microsoft.NET.Sdk.WindowsDesktop">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net48</TargetFramework>
++ <UseWpf>True</UseWpf>
<!-- 要求此项目编译时要生成一个 NuGet 包。-->
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<!-- 这里为了方便,我将 NuGet 包的输出路径设置在了解决方案根目录的 bin 文件夹下,而不是项目的 bin 文件夹下。-->
<PackageOutputPath>..\bin\$(Configuration)</PackageOutputPath>
<!-- 创建 NuGet 包时,项目的输出文件对应到 NuGet 包的 tools 文件夹,这可以避免目标项目引用我们的 NuGet 包的输出文件。
同时,如果将来我们准备动态生成源代码,而不只是引入静态源代码,还可以有机会运行我们 Program 中的 Main 函数。-->
<BuildOutputTargetFolder>tools</BuildOutputTargetFolder>
<!-- 此包将不会传递依赖。意味着如果目标项目安装了此 NuGet 包,那么安装目标项目包的项目不会间接安装此 NuGet 包。-->
<DevelopmentDependency>true</DevelopmentDependency>
<!-- 包的版本号,我们设成了一个预览版;当然你也可以设置为正式版,即没有后面的 -alpha 后缀。-->
<Version>0.1.0-alpha</Version>
<!-- 设置包的作者。在上传到 nuget.org 之后,如果作者名与 nuget.org 上的账号名相同,其他人浏览包是可以直接点击链接看作者页面。-->
<Authors>walterlv</Authors>
<!-- 设置包的组织名称。我当然写成我所在的组织 dotnet 职业技术学院啦。-->
<Company>dotnet-campus</Company>
</PropertyGroup>
++ <!-- 我们添加的其他资源需要在这里 Link 到一个统一的目录下,以便在此项目和安装 NuGet 包的目标项目中可以用同样的 Uri 使用。 -->
++ <ItemGroup>
++ <Resource Include="Assets\assets\Icon.ico" Link="Assets\Icon.ico" Visible="False" />
++ <Resource Include="Assets\assets\Background.png" Link="Assets\Background.png" Visible="False" />
++ </ItemGroup>
<!-- 在生成 NuGet 包之前,我们需要将我们项目中的文件夹结构一一映射到 NuGet 包中。-->
<Target Name="IncludeAllDependencies" BeforeTargets="_GetPackageFiles">
<ItemGroup>
<!-- 将 Package.props / Package.targets 文件的名称在 NuGet 包中改为需要的真正名称。
因为 NuGet 包要自动导入 props 和 targets 文件,要求文件的名称必须是 包名.props 和 包名.targets;
然而为了避免我们改包名的时候还要同步改四个文件的名称,所以就在项目文件中动态生成。-->
<None Include="Assets\build\Package.props" Pack="True" PackagePath="build\$(PackageId).props" />
<None Include="Assets\build\Package.targets" Pack="True" PackagePath="build\$(PackageId).targets" />
<None Include="Assets\buildMultiTargeting\Package.props" Pack="True" PackagePath="buildMultiTargeting\$(PackageId).props" />
<None Include="Assets\buildMultiTargeting\Package.targets" Pack="True" PackagePath="buildMultiTargeting\$(PackageId).targets" />
<!-- 我们将 src 目录中的所有源代码映射到 NuGet 包中的 src 目录中。-->
<None Include="Assets\src\**" Pack="True" PackagePath="src" />
++ <!-- 我们将 assets 目录中的所有源代码映射到 NuGet 包中的 assets 目录中。-->
++ <None Include="Assets\assets\**" Pack="True" PackagePath="assets" />
</ItemGroup>
</Target>
++ <!-- 在编译结束后将生成的可执行程序放到 Tools 文件夹中,使得 Assets 文件夹的目录结构与 NuGet 包非常相似,便于 Sample 项目进行及时的 NuGet 包调试。 -->
++ <Target Name="_WalterlvDemoCopyOutputToDebuggableFolder" AfterTargets="AfterBuild">
++ <ItemGroup>
++ <_WalterlvDemoToCopiedFiles Include="$(OutputPath)**" />
++ </ItemGroup>
++ <Copy SourceFiles="@(_WalterlvDemoToCopiedFiles)" DestinationFolder="Assets\tools\$(TargetFramework)" />
++ </Target>
</Project>
<Project>
<PropertyGroup>
<MSBuildAllProjects>$(MSBuildAllProjects);$(MSBuildThisFileFullPath)</MSBuildAllProjects>
</PropertyGroup>
<PropertyGroup>
<!-- 我们增加了一个属性,用于处理 WPF 特殊项目的源代码之前,确保我们已经收集到所有需要引入的源代码。 -->
<_WalterlvDemoImportInWpfTempProjectDependsOn>_WalterlvDemoIncludeSourceFiles</_WalterlvDemoImportInWpfTempProjectDependsOn>
</PropertyGroup>
<Target Name="_WalterlvDemoEvaluateProperties">
<PropertyGroup>
<_WalterlvDemoRoot>$(MSBuildThisFileDirectory)..\</_WalterlvDemoRoot>
<_WalterlvDemoSourceFolder>$(MSBuildThisFileDirectory)..\src\</_WalterlvDemoSourceFolder>
</PropertyGroup>
<Message Text="1. 初始化源代码包的编译属性" />
</Target>
<!-- 引入主要的 C# 源码。 -->
<Target Name="_WalterlvDemoIncludeSourceFiles"
BeforeTargets="CoreCompile"
DependsOnTargets="_WalterlvDemoEvaluateProperties">
<ItemGroup>
<_WalterlvDemoCompile Include="$(_WalterlvDemoSourceFolder)**\*.cs" />
<_WalterlvDemoAllCompile Include="@(_WalterlvDemoCompile)" />
<Compile Include="@(_WalterlvDemoCompile)" />
</ItemGroup>
<Message Text="2.1 引入源代码包中的所有源代码:@(_WalterlvDemoCompile)" />
</Target>
<!-- 引入 WPF 源码。 -->
<Target Name="_WalterlvDemoIncludeWpfFiles"
BeforeTargets="MarkupCompilePass1"
DependsOnTargets="_WalterlvDemoEvaluateProperties">
<ItemGroup>
-- <_WalterlvDemoPage Include="$(_WalterlvDemoSourceFolder)**\*.xaml" />
-- <Page Include="@(_WalterlvDemoPage)" Link="Views\%(_WalterlvDemoPage.FileName).xaml" />
++ <_WalterlvDemoRootPage Include="$(_WalterlvDemoSourceFolder)FooView.xaml" />
++ <Page Include="@(_WalterlvDemoRootPage)" Link="Views\%(_WalterlvDemoRootPage.FileName).xaml" />
++ <_WalterlvDemoThemesPage Include="$(_WalterlvDemoSourceFolder)Themes\Walterlv.Windows.xaml" />
++ <Page Include="@(_WalterlvDemoThemesPage)" Link="Views\%(_WalterlvDemoThemesPage.FileName).xaml" />
++ <_WalterlvDemoIcoResource Include="$(_WalterlvDemoRoot)assets\*.ico" />
++ <_WalterlvDemoPngResource Include="$(_WalterlvDemoRoot)assets\*.png" />
++ <Resource Include="@(_WalterlvDemoIcoResource)" Link="assets\%(_WalterlvDemoIcoResource.FileName).ico" />
++ <Resource Include="@(_WalterlvDemoPngResource)" Link="assets\%(_WalterlvDemoPngResource.FileName).png" />
</ItemGroup>
-- <Message Text="2.2 引用 WPF 相关源码:@(_WalterlvDemoPage);@(_WalterlvDemoIcoResource);@(_WalterlvDemoPngResource)" />
++ <Message Text="2.2 引用 WPF 相关源码:@(_WalterlvDemoRootPage);@(_WalterlvDemoThemesPage);@(_WalterlvDemoIcoResource);@(_WalterlvDemoPngResource)" />
</Target>
<!-- 当生成 WPF 临时项目时,不会自动 Import NuGet 中的 props 和 targets 文件,这使得在临时项目中你现在看到的整个文件都不会参与编译。
然而,我们可以通过欺骗的方式在主项目中通过 _GeneratedCodeFiles 集合将需要编译的文件传递到临时项目中以间接参与编译。
WPF 临时项目不会 Import NuGet 中的 props 和 targets 可能是 WPF 的 Bug,也可能是刻意如此。
所以我们通过一个属性开关 `ShouldFixNuGetImportingBugForWpfProjects` 来决定是否修复这个错误。-->
<Target Name="_WalterlvDemoImportInWpfTempProject"
AfterTargets="MarkupCompilePass1"
BeforeTargets="GenerateTemporaryTargetAssembly"
DependsOnTargets="$(_WalterlvDemoImportInWpfTempProjectDependsOn)"
Condition=" '$(ShouldFixNuGetImportingBugForWpfProjects)' == 'True' ">
<ItemGroup>
<_GeneratedCodeFiles Include="@(_WalterlvDemoAllCompile)" />
</ItemGroup>
<Message Text="3. 正在欺骗临时项目,误以为此 NuGet 包中的文件是 XAML 编译后的中间代码:@(_WalterlvDemoAllCompile)" />
</Target>
</Project>
本文涉及到的所有代码均已开源到:
本文服务于开源项目 SourceYard,为其提供支持 WPF 项目的解决方案。dotnet-campus/SourceYard: Add a NuGet package only for dll reference? By using dotnetCampus.SourceYard, you can pack a NuGet package with source code. By installing the new source code package, all source codes behaviors just like it is in your project.
更多制作源代码包的博客可以阅读。从简单到复杂的顺序:
基于 Sdk 的项目进行编译的时候,会使用 Sdk 中附带的 props 文件和 targets 文件对项目进行编译。Microsoft.NET.Sdk.WindowsDesktop 的 Sdk 包含 WPF 项目的编译过程。
而本文介绍 WPF 项目的编译过程,包含 WPF 额外为编译过程添加的那些扩展编译目标,以及这些扩展的编译目标如何一步步完成 WPF 项目的过程。
在阅读本文之前,你可能需要提前了解编译过程到底是怎样的。可以阅读:
如果你不明白上面文章中的一些术语(例如 Target / Task),可能不能理解本文后面的内容。
另外,除了本文所涉及的内容之外,你也可以自己探索编译过程:
WPF 的编译代码都在 Microsoft.WinFx.targets 文件中,你可以通过上面这一篇博客找到这个文件。接下来,我们会一一介绍这个文件里面的编译目标(Target),然后统一说明这些 Target 是如何协同工作,将 WPF 程序编译出来的。
Microsoft.WinFx.targets 的源码可以查看:
WPF 在编译期间会执行以下这些 Target,当然 Target 里面实际用于执行任务的是 Task。
知道 Target 名称的话,你可以扩展 WPF 的编译过程;而知道 Task 名称的话,可以帮助理解编译过程实际做的事情。
本文都会列举出来。
FileClassification
FileClassifier
用于将资源嵌入到程序集。如果资源没有本地化,则嵌入到主程序集;如果有本地化,则嵌入到附属程序集。
在 WPF 项目中,这个 Target 是一定会执行的;但里面的 Task 则是有 Resource
类型的编译项的时候才会执行。
Target 名称和 Task 名称相同,都是 GenerateTemporaryTargetAssembly
。
只要项目当中包含任何一个生成类型为 Page 的 XAML 文件,则会执行此 Target。
关于生成临时程序集的原因比较复杂,可以阅读本文后面的 WPF 程序的编译过程部分来了解。
Target 名称和 Task 名称相同,都是 MarkupCompilePass1
。
将非本地化的 XAML 文件编译成二进制格式。
Target 名称和 Task 名称相同,都是 MarkupCompilePass2
。
对 XAML 文件进行第二轮编译,而这一次会引用同一个程序集中的类型。
这是一个仅在有设计器执行时才会执行的 Target,当这个编译目标执行时,将会直接调用 MarkupCompilePass1
。
实际上,如果在 Visual Studio 中编译项目,则会调用到这个 Target。而判断是否在 Visual Studio 中编译的方法可以参见:
<Target Name="DesignTimeMarkupCompilation">
<!-- Only if we are not actually performing a compile i.e. we are in design mode -->
<CallTarget Condition="'$(BuildingProject)' != 'true'"
Targets="MarkupCompilePass1" />
</Target>
Target 名称和 Task 名称相同,都是 MergeLocalizationDirectives
。
将本地化属性和一个或多个 XAML 二进制格式文件的注释合并到整个程序集的单一文件中。
<Target Name="MergeLocalizationDirectives"
Condition="'@(GeneratedLocalizationFiles)' !=''"
Inputs="@(GeneratedLocalizationFiles)"
Outputs="$(IntermediateOutputPath)$(AssemblyName).loc"
>
<MergeLocalizationDirectives GeneratedLocalizationFiles="@(GeneratedLocalizationFiles)"
OutputFile="$(IntermediateOutputPath)$(AssemblyName).loc"/>
<!--
Add the merged loc file into _NoneWithTargetPath so that it will be copied to the
output directory
-->
<CreateItem Condition="Exists('$(IntermediateOutputPath)$(AssemblyName).loc')"
Include="$(IntermediateOutputPath)$(AssemblyName).loc"
AdditionalMetadata="CopyToOutputDirectory=PreserveNewest; TargetPath=$(AssemblyName).loc" >
<Output ItemName="_NoneWithTargetPath" TaskParameter="Include"/>
<Output ItemName="FileWrites" TaskParameter="Include"/>
</CreateItem>
</Target>
MainResourcesGeneration
和 SatelliteResourceGeneration
,分别负责主资源生成和附属资源生成。ResourcesGenerator
将一个或多个资源(二进制格式的 .jpg、.ico、.bmp、XAML 以及其他扩展名类型)嵌入 .resources 文件中。
CheckUid
、UpdateUid
和 RemoveUid
,分别负责主资源生成和附属资源生成。ResourcesGenerator
检查、更新或移除 UID,以便将 XAML 文件中所有的 XAML 元素进行本地化。
<Target Name="CheckUid"
Condition="'@(Page)' != '' or '@(ApplicationDefinition)' != ''">
<UidManager MarkupFiles="@(Page);@(ApplicationDefinition)" Task="Check" />
</Target>
<Target Name="UpdateUid"
Condition="'@(Page)' != '' or '@(ApplicationDefinition)' != ''">
<UidManager MarkupFiles="@(Page);
@(ApplicationDefinition)"
IntermediateDirectory ="$(IntermediateOutputPath)"
Task="Update" />
</Target>
<Target Name="RemoveUid"
Condition="'@(Page)' != '' or '@(ApplicationDefinition)' != ''">
<UidManager MarkupFiles="@(Page);
@(ApplicationDefinition)"
IntermediateDirectory ="$(IntermediateOutputPath)"
Task="Remove" />
</Target>
当编译基于 XAML 的浏览器项目的时候,会给 manifest 文件中添加一个配置 <hostInBrowser />
。
上面列举出来的那些 Target 主要是 WPF 几个关键的 Target,在实际编译时会有更多编译 Target 执行。另外有些也不在常规的编译过程中,而是被专门的编译过程执行。
图的阅读方法是这样的:
CoreCompile
有一个指向 DesignTimeMarkupCompilation
的箭头,表示 CoreCompile
执行前会确保 DesignTimeMarkupCompilation
执行完毕;PrepareResources
指向了多个 Target MarkupCompilePass1
、GenerateTemporaryTargetAssembly
、MarkupCompilePass2
、AfterMarkupCompilePass2
、CleanupTemporaryTargetAssembly
,那么在 PrepareResources
执行之前,如果还有没有执行的依赖,会按顺序依次执行;各种颜色代表的含义:
我们都知道 XAML 是可以引用 CLR 类型的;如果 XAML 所引用的 CLR 类型在其他被引用的程序集,那么编译 XAML 的时候就可以直接引用这些程序集,因为他们已经编译好了。
但是我们也知道,XAML 还能引用同一个程序集中的 CLR 类型,而此时这个程序集还没有编译,XAML 编译过程并不知道可以如何使用这些类型。同时我们也知道 CLR 类型可是使用 XAML 生成的类型,如果 XAML 没有编译,那么 CLR 类型也无法正常完成编译。这是矛盾的,这也是 WPF 扩展的编译过程会比较复杂的原因之一。
WPF 编译过程有两个编译传递,MarkupCompilePass1
和 MarkupCompilePass2
。
MarkupCompilePass1
的作用是将 XAML 编译成二进制格式。如果 XAML 文件包含 x:Class
属性,那么就会根据语言生成一份代码文件;对于 C# 语言,会生成“文件名.g.cs”文件。但是 XAML 文件中也有可能包含对同一个程序集中的 CLR 类型的引用,然而这一编译阶段 CLR 类型还没有开始编译,因此无法提供程序集引用。所以如果这个 XAML 文件包含对同一个程序集中 CLR 类型的引用,则这个编译会被推迟到 MarkupCompilePass2
中继续。而在 MarkupCompilePass1
和 MarkupCompilePass2
之间,则插入了 GenerateTemporaryTargetAssembly
这个编译目标。
GenerateTemporaryTargetAssembly
的作用是生成一个临时的程序集,这个临时的程序集中包含了 MarkupCompilePass1
推迟到 MarkupCompilePass2
中编译时需要的 CLR 类型。这样,在 MarkupCompilePass2
执行的时候,会获得一个包含原本在统一程序集的 CLR 类型的临时程序集引用,这样就可以继续完成 XAML 格式的编译了。在 MarkupCompilePass2
编译完成之后,XAML 文件就完全编译完成了。之后,会执行 CleanupTemporaryTargetAssembly
清除之前临时编译的程序集。
编译临时程序集时,会生成一个新的项目文件,名字如:$(项目名)_$(随机字符)_wpftmp.csproj
,在与原项目相同的目录下。
在需要编译一个临时程序集的时候,CoreCompile
这样的用于编译 C# 代码文件的编译目标会执行两次,第一次是编译这个临时生成的项目,而第二次才是编译原本的项目。
现在,我们看一段 WPF 程序的编译输出,可以看到看到这个生成临时程序集的过程。
随后,就是正常的其他的编译过程。
在 WPF 的编译过程中,我想单独将临时生成程序集的部分进行特别说明。因为如果你不了解这一部分的细节,可能在未来的使用中遇到一些临时生成程序集相关的坑。
下面这几篇博客就是在讨论其中的一些坑:
我需要摘抄生成临时程序集的一部分源码:
<PropertyGroup>
<_CompileTargetNameForLocalType Condition="'$(_CompileTargetNameForLocalType)' == ''">_CompileTemporaryAssembly</_CompileTargetNameForLocalType>
</PropertyGroup>
<Target Name="_CompileTemporaryAssembly" DependsOnTargets="BuildOnlySettings;ResolveKeySource;CoreCompile" />
<Target Name="GenerateTemporaryTargetAssembly"
Condition="'$(_RequireMCPass2ForMainAssembly)' == 'true' " >
<Message Text="MSBuildProjectFile is $(MSBuildProjectFile)" Condition="'$(MSBuildTargetsVerbose)' == 'true'" />
<GenerateTemporaryTargetAssembly
CurrentProject="$(MSBuildProjectFullPath)"
MSBuildBinPath="$(MSBuildBinPath)"
ReferencePathTypeName="ReferencePath"
CompileTypeName="Compile"
GeneratedCodeFiles="@(_GeneratedCodeFiles)"
ReferencePath="@(ReferencePath)"
IntermediateOutputPath="$(IntermediateOutputPath)"
AssemblyName="$(AssemblyName)"
CompileTargetName="$(_CompileTargetNameForLocalType)"
GenerateTemporaryTargetAssemblyDebuggingInformation="$(GenerateTemporaryTargetAssemblyDebuggingInformation)"
>
</GenerateTemporaryTargetAssembly>
<CreateItem Include="$(IntermediateOutputPath)$(TargetFileName)" >
<Output TaskParameter="Include" ItemName="AssemblyForLocalTypeReference" />
</CreateItem>
</Target>
我们需要关注这些点:
_CompileTargetNameForLocalType
这个私有属性来决定;_CompileTargetNameForLocalType
没有指定时,会设置其默认值为 _CompileTemporaryAssembly
这个编译目标;_CompileTemporaryAssembly
这个编译目标执行时,仅会执行三个依赖的编译目标,BuildOnlySettings
、ResolveKeySource
、CoreCompile
,至于这些依赖目标所依赖的其他编译目标,则会根据新生成的项目文件动态计算。_CompileTargetNameForLocalType
来执行,而不能直接调用这个编译目标或者设置编译目标的依赖。新生成的临时项目文件相比于原来的项目文件,包含了这些修改:
MarkupCompilePass1
)时生成的 .g.cs 文件;ReferencePath
,这样就可以避免临时项目编译期间再执行一次 ResolveAssemblyReference
编译目标来收集引用,避免降低太多性能。关于引用换成 ReferencePath
的内容,可以阅读我的另一篇博客了解更多:
在使用 ReferencePath
的情况下,无论是项目引用还是 NuGet 包引用,都会被换成普通的 dll 引用,因为这个时候目标项目都已经编译完成,包含可以被引用的程序集。
以下是我在示例程序中抓取到的临时生成的项目文件的内容,与原始项目文件之间的差异:
<Project Sdk="Microsoft.NET.Sdk.WindowsDesktop">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net48</TargetFramework>
<UseWPF>true</UseWPF>
<GenerateTemporaryTargetAssemblyDebuggingInformation>True</GenerateTemporaryTargetAssemblyDebuggingInformation>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Walterlv.SourceYard.Demo" Version="0.1.0-alpha" />
</ItemGroup>
++ <ItemGroup>
++ <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\mscorlib.dll" />
++ <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\PresentationCore.dll" />
++ <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\PresentationFramework.dll" />
++ <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\System.Core.dll" />
++ <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\System.Data.dll" />
++ <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\System.dll" />
++ <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\System.Drawing.dll" />
++ <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\System.IO.Compression.FileSystem.dll" />
++ <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\System.Numerics.dll" />
++ <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\System.Runtime.Serialization.dll" />
++ <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\System.Windows.Controls.Ribbon.dll" />
++ <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\System.Xaml.dll" />
++ <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\System.Xml.dll" />
++ <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\System.Xml.Linq.dll" />
++ <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\UIAutomationClient.dll" />
++ <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\UIAutomationClientsideProviders.dll" />
++ <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\UIAutomationProvider.dll" />
++ <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\UIAutomationTypes.dll" />
++ <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\WindowsBase.dll" />
++ </ItemGroup>
++ <ItemGroup>
++ <Compile Include="D:\Developments\Open\Walterlv.Demo\Walterlv.GettingStarted.SourceYard\Walterlv.GettingStarted.SourceYard.Sample\obj\Debug\net48\Demo.g.cs" />
++ </ItemGroup>
</Project>
你可能已经注意到了我在项目中设置了 GenerateTemporaryTargetAssemblyDebuggingInformation
属性,这个属性可以让 WPF 临时生成的项目文件保留下来,便于进行研究和调试。在前面 GenerateTemporaryTargetAssembly
的源码部分我们已经贴出了这个属性使用的源码,只是前面我们没有说明其用途。
注意,虽然新生成的项目文件中有 PackageReference
来表示包引用,但由于只有 _CompileTargetNameForLocalType
指定的编译目标和相关依赖可以被执行,而 NuGet 包中自动 Import 的部分没有加入到依赖项中,所以实际上包中的 .props
和 .targets
文件都不会被 Import
进来,这可能造成部分 NuGet 包在 WPF 项目中不能正常工作。比如下面这个:
更典型的,就是 SourceYard 项目,这个 Bug 给 SourceYard 造成了不小的困扰:
参考资料
在使用 NuGet 包来分发源代码时,如果目标项目是 WPF 项目,那么会有一大堆的问题。
本文将这些问题列举出来并进行分析。
源代码包不是 NuGet 官方的概念,而是林德熙和我在 GitHub 上做的一个项目,目的是将你的项目以源代码的形式发布成 NuGet 包。在安装此 NuGet 包后,目标项目将获得这些源代码。
你可以通过以下博客了解如何制作一个源代码包。
这可以避免因为安装 NuGet 包后带来的大量程序集引用,因为程序集数量太多对程序的启动性能有很大的影响:
然而制作一个 NuGet 的坑很多,详见:
为了让 NuGet 源代码包对 WPF 项目问题暴露得更彻底一些,我们需要一个最简单的例子来说明这一问题。我将它放在了我的 Demo 项目中:
但为了让博客理解起来更顺畅,我还是将关键的源代码贴出来。
为了尽可能避免其他因素的影响,我们这个源码包只做这些事情:
具体来说,我们的目录结构是这样的:
- Walterlv.SourceYard.Demo
- Assets
- build
- Package.targets
- src
- Foo.cs
Walterlv.SourceYard.Demo.targets 中的内容如下:
<Project>
<PropertyGroup>
<MSBuildAllProjects>$(MSBuildAllProjects);$(MSBuildThisFileFullPath)</MSBuildAllProjects>
</PropertyGroup>
<Target Name="_WalterlvIncludeSomeCode" BeforeTargets="CoreCompile">
<ItemGroup>
<Compile Include="$(MSBuildThisFileDirectory)..\src\Foo.cs" />
</ItemGroup>
</Target>
</Project>
Foo.cs 中的内容如下:
using System;
namespace Walterlv.SourceYard
{
internal class Foo
{
public static void Run() => Console.WriteLine("walterlv is a 逗比.");
}
}
而项目文件(csproj)如下:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net48</TargetFramework>
<PackageOutputPath>..\bin\$(Configuration)</PackageOutputPath>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<BuildOutputTargetFolder>tools</BuildOutputTargetFolder>
<PackageRequireLicenseAcceptance>false</PackageRequireLicenseAcceptance>
<Version>0.1.0-alpha</Version>
<Authors>walterlv</Authors>
<Company>dotnet-campus</Company>
</PropertyGroup>
<!-- 在编译结束后将需要的源码拷贝到 NuGet 包中 -->
<Target Name="IncludeAllDependencies" BeforeTargets="_GetPackageFiles">
<ItemGroup>
<None Include="Assets\build\Package.targets" Pack="True" PackagePath="build\$(PackageId).targets" />
<None Include="Assets\src\**" Pack="True" PackagePath="src" />
</ItemGroup>
</Target>
</Project>
这样,编译完成之后,我们可以在 ..\bin\Debug
目录下找到我们已经生成好的 NuGet 包,其目录结构如下:
- Walterlv.SourceYard.Demo.nupkg
- build
- Walterlv.SourceYard.Demo.targets
- src
- Foo.cs
- tools
- net48
- Walterlv.SourceYard.Demo.dll
其中,那个 Walterlv.SourceYard.Demo.dll 完全没有作用。我们是通过项目中设置了属性 BuildOutputTargetFolder
让生成的文件跑到这里来的,目的是避免安装此 NuGet 包之后,引用了我们生成的 dll 文件。因为我们要引用的是源代码,而不是 dll。
现在,我们新建另一个简单的控制台项目用于验证这个 NuGet 包是否正常工作。
项目文件就是很简单的项目文件,只是我们安装了刚刚生成的 NuGet 包 Walterlv.SourceYard.Demo.nupkg。
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net48</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Walterlv.SourceYard.Demo" Version="0.1.0-alpha" />
</ItemGroup>
</Project>
而 Program.cs 文件中的内容很简单,只是简单地调用了我们源码包中的 Foo.Run()
方法。
using System;
using Walterlv.SourceYard;
namespace Walterlv.GettingStarted.SourceYard.Sample
{
class Program
{
static void Main(string[] args)
{
Foo.Run();
Console.WriteLine("Hello World!");
}
}
}
现在,编译我们的项目,发现完全可以正常编译,就像我在这篇博客中说到的一样:
但是,事情并不那么简单。接下来全部剩下的都是问题。
当我们不进行任何改变,就是以上的代码,对 Walterlv.GettingStarted.SourceYard.Sample
项目进行编译(记得提前 nuget restore
),我们可以得到正常的控制台输出。
注意,我使用了 msbuild /t:Rebuild
命令,在编译前进行清理。
PS D:\Walterlv.Demo\Walterlv.GettingStarted.SourceYard.Sample> msbuild /t:Rebuild
用于 .NET Framework 的 Microsoft (R) 生成引擎版本 16.1.76+g14b0a930a7
版权所有(C) Microsoft Corporation。保留所有权利。
生成启动时间为 2019/6/10 17:32:50。
项目“D:\Walterlv.Demo\Walterlv.GettingStarted.SourceYard.Sample\Walt
erlv.GettingStarted.SourceYard.Sample.csproj”在节点 1 上(Rebuild 个目标)。
_CheckForNETCoreSdkIsPreview:
C:\Program Files\dotnet\sdk\3.0.100-preview5-011568\Sdks\Microsoft.NET.Sdk\targets\Microsoft.NET.RuntimeIdentifierInfer
ence.targets(157,5): message NETSDK1057: 你正在使用 .NET Core 的预览版。请查看 https://aka.ms/dotnet-core-preview [D:\Walterlv.Demo\Walterlv.GettingStarted.SourceYard.Sample\Walterlv.GettingStarted.
SourceYard.Sample.csproj]
CoreClean:
正在创建目录“obj\Debug\net48\”。
PrepareForBuild:
正在创建目录“bin\Debug\net48\”。
GenerateBindingRedirects:
ResolveAssemblyReferences 中没有建议的绑定重定向。
GenerateTargetFrameworkMonikerAttribute:
正在跳过目标“GenerateTargetFrameworkMonikerAttribute”,因为所有输出文件相对于输入文件而言都是最新的。
CoreCompile:
C:\Program Files (x86)\Microsoft Visual Studio\2019\Professional\MSBuild\Current\Bin\Roslyn\csc.exe /noconfig /unsafe
- /checked- /nowarn:1701,1702,1701,1702 /nostdlib+ /platform:AnyCPU /errorreport:prompt /warn:4 /define:TRACE;DEBUG;N
ETFRAMEWORK;NET48 /highentropyva+ /reference:"C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFra
mework\v4.8\mscorlib.dll" /reference:"C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v
4.8\System.Core.dll" /reference:"C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\S
ystem.Data.dll" /reference:"C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\System
.dll" /reference:"C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\System.Drawing.d
ll" /reference:"C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\System.IO.Compress
ion.FileSystem.dll" /reference:"C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\Sy
stem.Numerics.dll" /reference:"C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\Sys
tem.Runtime.Serialization.dll" /reference:"C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramew
ork\v4.8\System.Xml.dll" /reference:"C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4
.8\System.Xml.Linq.dll" /debug+ /debug:portable /filealign:512 /optimize- /out:obj\Debug\net48\Walterlv.GettingStarte
d.SourceYard.Sample.exe /ruleset:"C:\Program Files (x86)\Microsoft Visual Studio\2019\Professional\Team Tools\Static
Analysis Tools\\Rule Sets\MinimumRecommendedRules.ruleset" /subsystemversion:6.00 /target:exe /warnaserror- /utf8outp
ut /deterministic+ Program.cs "C:\Users\lvyi\AppData\Local\Temp\.NETFramework,Version=v4.8.AssemblyAttributes.cs" C:\
Users\lvyi\.nuget\packages\walterlv.sourceyard.demo\0.1.0-alpha\build\..\src\Foo.cs obj\Debug\net48\Walterlv.GettingS
tarted.SourceYard.Sample.AssemblyInfo.cs /warnaserror+:NU1605
对来自后列目录的编译器使用共享编译: C:\Program Files (x86)\Microsoft Visual Studio\2019\Professional\MSBuild\Current\Bin\Roslyn
_CopyAppConfigFile:
正在将文件从“D:\Developments\Open\Walterlv.Demo\Walterlv.GettingStarted.SourceYard\Walterlv.GettingStarted.SourceYard.Sampl
e\obj\Debug\net48\Walterlv.GettingStarted.SourceYard.Sample.exe.withSupportedRuntime.config”复制到“D:\Developments\Open\
Walterlv.Demo\Walterlv.GettingStarted.SourceYard\Walterlv.GettingStarted.SourceYard.Sample\bin\Debug\net48\Walterlv.G
ettingStarted.SourceYard.Sample.exe.config”。
CopyFilesToOutputDirectory:
正在将文件从“D:\Developments\Open\Walterlv.Demo\Walterlv.GettingStarted.SourceYard\Walterlv.GettingStarted.SourceYard.Sampl
e\obj\Debug\net48\Walterlv.GettingStarted.SourceYard.Sample.exe”复制到“D:\Developments\Open\Walterlv.Demo\Walterlv.Getti
ngStarted.SourceYard\Walterlv.GettingStarted.SourceYard.Sample\bin\Debug\net48\Walterlv.GettingStarted.SourceYard.Sam
ple.exe”。
Walterlv.GettingStarted.SourceYard.Sample -> D:\Developments\Open\Walterlv.Demo\Walterlv.GettingStarted.SourceYard\Wa
lterlv.GettingStarted.SourceYard.Sample\bin\Debug\net48\Walterlv.GettingStarted.SourceYard.Sample.exe
正在将文件从“D:\Developments\Open\Walterlv.Demo\Walterlv.GettingStarted.SourceYard\Walterlv.GettingStarted.SourceYard.Sampl
e\obj\Debug\net48\Walterlv.GettingStarted.SourceYard.Sample.pdb”复制到“D:\Developments\Open\Walterlv.Demo\Walterlv.Getti
ngStarted.SourceYard\Walterlv.GettingStarted.SourceYard.Sample\bin\Debug\net48\Walterlv.GettingStarted.SourceYard.Sam
ple.pdb”。
已完成生成项目“D:\Developments\Open\Walterlv.Demo\Walterlv.GettingStarted.SourceYard\Walterlv.GettingStarted.SourceYard.Sample
\Walterlv.GettingStarted.SourceYard.Sample.csproj”(Rebuild 个目标)的操作。
已成功生成。
0 个警告
0 个错误
已用时间 00:00:00.59
当然,贴一张图片可能更能体现编译通过:
上面的输出非常多,但我们提取一下关键的点:
CoreClean
-> PrepareForRebuild
-> GenerateBindingRedirects
-> GenerateTargetFrameworkMonikerAttribute
-> CoreCompile
-> _CopyAppConfigFile
-> CopyFilesToOutputDirectory
。Program.cs "C:\Users\lvyi\AppData\Local\Temp\.NETFramework,Version=v4.8.AssemblyAttributes.cs" C:\ Users\lvyi\.nuget\packages\walterlv.sourceyard.demo\0.1.0-alpha\build\..\src\Foo.cs obj\Debug\net48\Walterlv.GettingStarted.SourceYard.Sample.AssemblyInfo.cs
。可以注意到,编译期间成功将 Foo.cs
文件加入了编译。
现在,我们将我们的项目升级成 WPF 项目。编辑项目文件。
-- <Project Sdk="Microsoft.NET.Sdk">
++ <Project Sdk="Microsoft.NET.Sdk.WindowsDesktop">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net48</TargetFramework>
++ <UseWPF>true</UseWPF>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Walterlv.SourceYard.Demo" Version="0.1.0-alpha" />
</ItemGroup>
</Project>
现在编译,依然不会出现任何问题,跟控制台程序一模一样。
但一旦在你的项目中放上一个 XAML 文件,问题立刻变得不一样了。
<UserControl x:Class="Walterlv.GettingStarted.SourceYard.Sample.DemoControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:Walterlv.GettingStarted.SourceYard.Sample">
</UserControl>
PS D:\Walterlv.Demo\Walterlv.GettingStarted.SourceYard.Sample> msbuild /t:Rebuild
用于 .NET Framework 的 Microsoft (R) 生成引擎版本 16.1.76+g14b0a930a7
版权所有(C) Microsoft Corporation。保留所有权利。
生成启动时间为 2019/6/10 17:43:18。
项目“D:\Walterlv.Demo\Walterlv.GettingStarted.SourceYard.Sample\Walt
erlv.GettingStarted.SourceYard.Sample.csproj”在节点 1 上(Rebuild 个目标)。
_CheckForNETCoreSdkIsPreview:
C:\Program Files\dotnet\sdk\3.0.100-preview5-011568\Sdks\Microsoft.NET.Sdk\targets\Microsoft.NET.RuntimeIdentifierInfer
ence.targets(157,5): message NETSDK1057: 你正在使用 .NET Core 的预览版。请查看 https://aka.ms/dotnet-core-preview [D:\Walterlv.Demo\Walterlv.GettingStarted.SourceYard.Sample\Walterlv.GettingStarted.
SourceYard.Sample.csproj]
CoreClean:
正在删除文件“D:\Developments\Open\Walterlv.Demo\Walterlv.GettingStarted.SourceYard\Walterlv.GettingStarted.SourceYard.Sampl
e\obj\Debug\net48\Walterlv.GettingStarted.SourceYard.Sample.csprojAssemblyReference.cache”。
正在删除文件“D:\Developments\Open\Walterlv.Demo\Walterlv.GettingStarted.SourceYard\Walterlv.GettingStarted.SourceYard.Sampl
e\obj\Debug\net48\Demo.g.cs”。
正在删除文件“D:\Developments\Open\Walterlv.Demo\Walterlv.GettingStarted.SourceYard\Walterlv.GettingStarted.SourceYard.Sampl
e\obj\Debug\net48\Walterlv.GettingStarted.SourceYard.Sample_MarkupCompile.cache”。
正在删除文件“D:\Developments\Open\Walterlv.Demo\Walterlv.GettingStarted.SourceYard\Walterlv.GettingStarted.SourceYard.Sampl
e\obj\Debug\net48\Walterlv.GettingStarted.SourceYard.Sample_MarkupCompile.lref”。
GenerateBindingRedirects:
ResolveAssemblyReferences 中没有建议的绑定重定向。
项目“D:\Developments\Open\Walterlv.Demo\Walterlv.GettingStarted.SourceYard\Walterlv.GettingStarted.SourceYard.Sample\Walt
erlv.GettingStarted.SourceYard.Sample.csproj”(1)正在节点 1 上生成“D:\Developments\Open\Walterlv.Demo\Walterlv.GettingStarted.S
ourceYard\Walterlv.GettingStarted.SourceYard.Sample\Walterlv.GettingStarted.SourceYard.Sample_vobqk5lg_wpftmp.csproj”(2
) (_CompileTemporaryAssembly 个目标)。
CoreCompile:
C:\Program Files (x86)\Microsoft Visual Studio\2019\Professional\MSBuild\Current\Bin\Roslyn\csc.exe /noconfig /unsafe
- /checked- /nowarn:1701,1702,1701,1702 /nostdlib+ /platform:AnyCPU /errorreport:prompt /warn:4 /define:TRACE;DEBUG;N
ETFRAMEWORK;NET48 /highentropyva+ /reference:"C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFra
mework\v4.8\mscorlib.dll" /reference:"C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v
4.8\PresentationCore.dll" /reference:"C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v
4.8\PresentationFramework.dll" /reference:"C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramew
ork\v4.8\System.Core.dll" /reference:"C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v
4.8\System.Data.dll" /reference:"C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\S
ystem.dll" /reference:"C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\System.Draw
ing.dll" /reference:"C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\System.IO.Com
pression.FileSystem.dll" /reference:"C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4
.8\System.Numerics.dll" /reference:"C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.
8\System.Runtime.Serialization.dll" /reference:"C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETF
ramework\v4.8\System.Windows.Controls.Ribbon.dll" /reference:"C:\Program Files (x86)\Reference Assemblies\Microsoft\F
ramework\.NETFramework\v4.8\System.Xaml.dll" /reference:"C:\Program Files (x86)\Reference Assemblies\Microsoft\Framew
ork\.NETFramework\v4.8\System.Xml.dll" /reference:"C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.N
ETFramework\v4.8\System.Xml.Linq.dll" /reference:"C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NE
TFramework\v4.8\UIAutomationClient.dll" /reference:"C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.
NETFramework\v4.8\UIAutomationClientsideProviders.dll" /reference:"C:\Program Files (x86)\Reference Assemblies\Micros
oft\Framework\.NETFramework\v4.8\UIAutomationProvider.dll" /reference:"C:\Program Files (x86)\Reference Assemblies\Mi
crosoft\Framework\.NETFramework\v4.8\UIAutomationTypes.dll" /reference:"C:\Program Files (x86)\Reference Assemblies\M
icrosoft\Framework\.NETFramework\v4.8\WindowsBase.dll" /debug+ /debug:portable /filealign:512 /optimize- /out:obj\Deb
ug\net48\Walterlv.GettingStarted.SourceYard.Sample.exe /ruleset:"C:\Program Files (x86)\Microsoft Visual Studio\2019\
Professional\Team Tools\Static Analysis Tools\\Rule Sets\MinimumRecommendedRules.ruleset" /subsystemversion:6.00 /tar
get:exe /warnaserror- /utf8output /deterministic+ Program.cs D:\Developments\Open\Walterlv.Demo\Walterlv.GettingStart
ed.SourceYard\Walterlv.GettingStarted.SourceYard.Sample\obj\Debug\net48\Demo.g.cs obj\Debug\net48\Walterlv.GettingSta
rted.SourceYard.Sample_vobqk5lg_wpftmp.AssemblyInfo.cs /warnaserror+:NU1605
对来自后列目录的编译器使用共享编译: C:\Program Files (x86)\Microsoft Visual Studio\2019\Professional\MSBuild\Current\Bin\Roslyn
Program.cs(2,16): error CS0234: 命名空间“Walterlv”中不存在类型或命名空间名“SourceYard”(是否缺少程序集引用?) [D:\Developments\Open\Walterlv.Demo\
Walterlv.GettingStarted.SourceYard\Walterlv.GettingStarted.SourceYard.Sample\Walterlv.GettingStarted.SourceYard.Sample_
vobqk5lg_wpftmp.csproj]
已完成生成项目“D:\Developments\Open\Walterlv.Demo\Walterlv.GettingStarted.SourceYard\Walterlv.GettingStarted.SourceYard.Sample
\Walterlv.GettingStarted.SourceYard.Sample_vobqk5lg_wpftmp.csproj”(_CompileTemporaryAssembly 个目标)的操作 - 失败。
已完成生成项目“D:\Developments\Open\Walterlv.Demo\Walterlv.GettingStarted.SourceYard\Walterlv.GettingStarted.SourceYard.Sample
\Walterlv.GettingStarted.SourceYard.Sample.csproj”(Rebuild 个目标)的操作 - 失败。
生成失败。
“D:\Developments\Open\Walterlv.Demo\Walterlv.GettingStarted.SourceYard\Walterlv.GettingStarted.SourceYard.Sample\Walter
lv.GettingStarted.SourceYard.Sample.csproj”(Rebuild 目标) (1) ->
“D:\Developments\Open\Walterlv.Demo\Walterlv.GettingStarted.SourceYard\Walterlv.GettingStarted.SourceYard.Sample\Walter
lv.GettingStarted.SourceYard.Sample_vobqk5lg_wpftmp.csproj”(_CompileTemporaryAssembly 目标) (2) ->
(CoreCompile 目标) ->
Program.cs(2,16): error CS0234: 命名空间“Walterlv”中不存在类型或命名空间名“SourceYard”(是否缺少程序集引用?) [D:\Developments\Open\Walterlv.Dem
o\Walterlv.GettingStarted.SourceYard\Walterlv.GettingStarted.SourceYard.Sample\Walterlv.GettingStarted.SourceYard.Sampl
e_vobqk5lg_wpftmp.csproj]
0 个警告
1 个错误
已用时间 00:00:00.87
因为上面有编译错误但看不出来,所以我们贴一张图,可以很容易看出来有编译错误。
并且,如果对比两张图,会发现 CoreCompile 中的内容已经不一样了。变化主要是 /reference
参数和要编译的文件列表参数。
/reference
参数增加了 WPF 需要的库。
mscorelib.dll
++ PresentationCore.dll
++ PresentationFramework.dll
System.Core.dll
System.Data.dll
System.dll
System.Drawing.dll
System.IO.Compression.FileSystem.dll
System.Numerics.dll
System.Runtime.Serialization.dll
++ System.Windows.Controls.Ribbon.dll
++ System.Xaml.dll
System.Xml.dll
System.Xml.Linq.dll
++ UIAutomationClient.dll
++ UIAutomationClientsideProviders.dll
++ UIAutomationProvider.dll
++ UIAutomationTypes.dll
++ WindowsBase.dll
但是要编译的文件却既有新增,又有减少:
Program.cs
++ D:\Developments\Open\Walterlv.Demo\Walterlv.GettingStarted.SourceYard\Walterlv.GettingStarted.SourceYard.Sample\obj\Debug\net48\Demo.g.cs
-- "C:\Users\lvyi\AppData\Local\Temp\.NETFramework,Version=v4.8.AssemblyAttributes.cs"
-- C:\Users\lvyi\.nuget\packages\walterlv.sourceyard.demo\0.1.0-alpha\build\..\src\Foo.cs
-- obj\Debug\net48\Walterlv.GettingStarted.SourceYard.Sample.AssemblyInfo.cs
++ obj\Debug\net48\Walterlv.GettingStarted.SourceYard.Sample_vobqk5lg_wpftmp.AssemblyInfo.cs
同时,我们还能注意到还临时生成了一个新的项目文件:
项目“D:\Walterlv.Demo\Walterlv.GettingStarted.SourceYard.Sample\Walterlv.GettingStarted.SourceYard.Sample.csproj”(1)正在节点 1 上生成“D:\Walterlv.Demo\Walterlv.GettingStarted.SourceYard.Sample\Walterlv.GettingStarted.SourceYard.Sample_vobqk5lg_wpftmp.csproj”(2) (_CompileTemporaryAssembly 个目标)。
新的项目文件有一个后缀 _vobqk5lg_wpftmp
,同时我们还能注意到编译的 AssemblyInfo.cs
文件前面也有相同的后缀 _vobqk5lg_wpftmp
:
我们几乎可以认为,当项目是编译成 WPF 时,执行了不同的编译流程。
要了解问题到底出在哪里了,我们需要知道 WPF 究竟在编译过程中做了哪些额外的事情。WPF 额外的编译任务主要在 Microsoft.WinFX.targets 文件中。在了解了 WPF 的编译过程之后,这个临时的程序集将非常容易理解。
我写了一篇讲解 WPF 编译过程的博客,在解决这个问题之前,建议阅读这篇博客了解 WPF 是如何进行编译的:
在了解了 WPF 程序的编译过程之后,我们知道了前面一些疑问的答案:
在那篇博客中,我们解释到新生成的项目文件会使用 ReferencePath
替代其他方式收集到的引用,这就包含项目引用和 NuGet 包的引用。
在使用 ReferencePath
的情况下,无论是项目引用还是 NuGet 包引用,都会被换成普通的 dll 引用,因为这个时候目标项目都已经编译完成,包含可以被引用的程序集。
以下是我在示例程序中抓取到的临时生成的项目文件的内容,与原始项目文件之间的差异:
<Project Sdk="Microsoft.NET.Sdk.WindowsDesktop">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net48</TargetFramework>
<UseWPF>true</UseWPF>
<GenerateTemporaryTargetAssemblyDebuggingInformation>True</GenerateTemporaryTargetAssemblyDebuggingInformation>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Walterlv.SourceYard.Demo" Version="0.1.0-alpha" />
</ItemGroup>
++ <ItemGroup>
++ <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\mscorlib.dll" />
++ <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\PresentationCore.dll" />
++ <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\PresentationFramework.dll" />
++ <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\System.Core.dll" />
++ <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\System.Data.dll" />
++ <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\System.dll" />
++ <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\System.Drawing.dll" />
++ <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\System.IO.Compression.FileSystem.dll" />
++ <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\System.Numerics.dll" />
++ <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\System.Runtime.Serialization.dll" />
++ <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\System.Windows.Controls.Ribbon.dll" />
++ <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\System.Xaml.dll" />
++ <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\System.Xml.dll" />
++ <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\System.Xml.Linq.dll" />
++ <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\UIAutomationClient.dll" />
++ <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\UIAutomationClientsideProviders.dll" />
++ <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\UIAutomationProvider.dll" />
++ <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\UIAutomationTypes.dll" />
++ <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\WindowsBase.dll" />
++ </ItemGroup>
++ <ItemGroup>
++ <Compile Include="D:\Developments\Open\Walterlv.Demo\Walterlv.GettingStarted.SourceYard\Walterlv.GettingStarted.SourceYard.Sample\obj\Debug\net48\Demo.g.cs" />
++ </ItemGroup>
</Project>
你可能已经注意到了我在项目中设置了 GenerateTemporaryTargetAssemblyDebuggingInformation
属性,这个属性可以让 WPF 临时生成的项目文件保留下来,便于进行研究和调试。在前面 GenerateTemporaryTargetAssembly
的源码部分我们已经贴出了这个属性使用的源码,只是前面我们没有说明其用途。
注意,虽然新生成的项目文件中有 PackageReference
来表示包引用,但由于只有 _CompileTargetNameForLocalType
指定的编译目标和相关依赖可以被执行,而 NuGet 包中自动 Import 的部分没有加入到依赖项中,所以实际上包中的 .props
和 .targets
文件都不会被 Import
进来,这可能造成部分 NuGet 包在 WPF 项目中不能正常工作。比如本文正片文章都在探索的这个 Bug。
更典型的,就是 SourceYard 项目,这个 Bug 给 SourceYard 造成了不小的困扰:
这个问题解决起来其实并不如想象当中那么简单,因为:
MarkupCompilePass1
和 MarkupCompilePass2
之间的 GenerateTemporaryTargetAssembly
编译目标时,会插入一段临时项目文件的编译;_CompileTargetNameForLocalType
内部属性指定的编译目标,虽然相当于开放了修改,但由于临时项目文件中不会执行 NuGet 相关的编译目标,所以不会自动 Import NuGet 包中的任何编译目标和属性定义;换句话说,我们几乎没有可以自动 Import 源码的方案。如果我们强行将 _CompileTargetNameForLocalType
替换成我们自己定义的类型会怎么样?
这是通过 NuGet 包中的 .targets 文件中的内容,用来强行替换:
<Project>
<PropertyGroup>
<MSBuildAllProjects>$(MSBuildAllProjects);$(MSBuildThisFileFullPath)</MSBuildAllProjects>
<_CompileTargetNameForLocalType>_WalterlvCompileTemporaryAssembly</_CompileTargetNameForLocalType>
</PropertyGroup>
<Target Name="_WalterlvCompileTemporaryAssembly" />
</Project>
我们在属性中将临时项目的编译目标改成了我们自己的目标,但会直接出现编译错误,找不到我们定义的编译目标。当然这个编译错误出现在临时生成的程序集上。
原因就在于这个 .targets 文件没有自动被 Import 进来,于是我们定义的 _WalterlvCompileTemporaryAssembly
在临时生成的项目编译中根本就不存在。
我们失去了通过 NuGet 自动被 Import 的时机!
既然我们失去了通过 NuGet 被自动 Import 的时机,那么我们只能另寻它法:
GenerateTemporaryTargetAssembly
这个编译任务入手,修改其需要的参数;// TODO:正在组织 issues 和 pull request
无论结果如何,等待微软将这些修改发布也是需要一段时间的,这段时间我们需要使用方案二和方案三来顶替一段时间。
方案二的其中一种实施方案是下面这篇文章在最后一小节说到的方法:
具体来说,就是修改项目文件,在项目文件的首尾各加上 NuGet 自动生成的那些 Import 来自 NuGet 中的所有编译文件:
<Project Sdk="Microsoft.NET.Sdk">
<Import Condition="Exists('obj\$(MSBuildProjectName).csproj.nuget.g.props') " Project="obj\$(MSBuildProjectName).csproj.nuget.g.props" />
<!-- 项目文件中的原有其他代码。 -->
<Import Condition="Exists('obj\$(MSBuildProjectName).csproj.nuget.g.targets') " Project="obj\$(MSBuildProjectName).csproj.nuget.g.targets" />
</Project>
另外,可以直接在这里 Import 我们 NuGet 包中的编译文件,但这些不如以上方案来得靠谱,因为上面的代码可以使得项目文件的修改完全确定,不用随着开发计算机的不同或者 NuGet 包的数量和版本不同而变化。
如果打算选用方案二,那么上面这种实施方式是最推荐的实施方式。
当然需要注意,此方案的副作用是会多出重复导入的编译警告。在清楚了 WPF 的编译过程之后,是不是能理解了这个警告的原因了呢?是的,对临时项目来说,由于没有自动 Import,所以这里的 Import 不会导致临时项目出现问题;但对于原项目来说,由于默认就会 Import NuGet 中的那两个文件,所以如果再次 Import 就会重复导入。
Directory.Build.props 和 Directory.Build.targets 也是可以被自动 Import 的文件,这也是在 Microsoft.NET.Sdk 中将其自动导入的。
关于这两个文件的自动导入,可以阅读博客:
但是,如果我们使用这两个文件帮助自动导入,将造成导入循环,这会形成编译错误!
GenerateTemporaryTargetAssembly
的代码如下:
<GenerateTemporaryTargetAssembly
CurrentProject="$(MSBuildProjectFullPath)"
MSBuildBinPath="$(MSBuildBinPath)"
ReferencePathTypeName="ReferencePath"
CompileTypeName="Compile"
GeneratedCodeFiles="@(_GeneratedCodeFiles)"
ReferencePath="@(ReferencePath)"
IntermediateOutputPath="$(IntermediateOutputPath)"
AssemblyName="$(AssemblyName)"
CompileTargetName="$(_CompileTargetNameForLocalType)"
GenerateTemporaryTargetAssemblyDebuggingInformation="$(GenerateTemporaryTargetAssemblyDebuggingInformation)"
>
</GenerateTemporaryTargetAssembly>
可以看到它的的参数有:
$(MSBuildProjectFullPath)
,表示项目文件的完全路径,修改无效。$(MSBuildBinPath)
,表示 MSBuild 程序的完全路径,修改无效。ReferencePath
,这是为了在生成临时项目文件时使用正确的引用路径项的名称。Compile
,这是为了在生成临时项目文件时使用正确的编译项的名称。@(_GeneratedCodeFiles)
,包含生成的代码文件,也就是那些 .g.cs 文件。@(ReferencePath)
,也就是目前已收集到的所有引用文件的路径。$(IntermediateOutputPath)
,表示临时输出路径,当使用临时项目文件编译时,生成的临时程序集将放在这个目录中。$(AssemblyName)
,表示程序集名称,当生成临时程序集的时候,将参考这个程序集名称。$(_CompileTargetNameForLocalType)
,表示当生成了新的项目文件后,要使用哪个编译目标来编译这个项目。$(GenerateTemporaryTargetAssemblyDebuggingInformation)
,表示是否要为了调试保留临时生成的项目文件和程序集。可能为我们所用的有:
@(_GeneratedCodeFiles)
,我们可以把我们需要 Import 进来的源代码伪装成生成的 .g.cs 文件好吧,就这一个了。其他的并不会对我们 Import 源代码造成影响。
于是回到我们本文一开始的 Walterlv.SourceYard.Demo.targets 文件,我们将内容修改一下,增加了一个 _ENSdkImportInTempProject
编译目标。它在 MarkupCompilePass1
之后执行,因为这是 XAML 的第一轮编译,会创造 _GeneratedCodeFiles
这个集合,将 XAML 生成 .g.cs 文件;在 GenerateTemporaryTargetAssembly
之前执行,因为这里会生成一个新的临时项目,然后立即对其进行编译。我们选用这个之间的时机刚好可以在产生 _GeneratedCodeFiles
集合之后修改其内容。
<Project>
<PropertyGroup>
<MSBuildAllProjects>$(MSBuildAllProjects);$(MSBuildThisFileFullPath)</MSBuildAllProjects>
</PropertyGroup>
<Target Name="_WalterlvIncludeSomeCode" BeforeTargets="CoreCompile">
<ItemGroup>
<Compile Include="$(MSBuildThisFileDirectory)..\src\Foo.cs" />
</ItemGroup>
</Target>
++ <Target Name="_ENSdkImportInTempProject" AfterTargets="MarkupCompilePass1" BeforeTargets="GenerateTemporaryTargetAssembly">
++ <ItemGroup>
++ <_GeneratedCodeFiles Include="$(MSBuildThisFileDirectory)..\src\Foo.cs" />
++ </ItemGroup>
++ </Target>
++
</Project>
现在重新再编译,我们本文一开始疑惑的各种问题,现在终于无警告无错误地解决掉了。
如果你觉得本文略长,希望立刻获得解决办法,可以:
参考资料
我们这里说的编译任务是 MSBuild 的 Target。虽然只有少部分,但确实有一些情况需要判断是否在 Visual Studio 中编译的时候才需要执行的编译任务,典型的如某些仅为设计器准备的代码。
本文需要理解的前置知识是:
而使用 Visual Studio 编译的时候,会自动帮我们设置 BuildingInsideVisualStudio
的值为 True
,所以实际上我们可以使用这个值进行判断。
我们可以在 Microsoft.NET.Sdk 中找到不少使用此属性的编译任务。
比如为了 IO 性能考虑的硬连接,在 Visual Studio 中即便打开也不会使用:
<!--
============================================================
CopyFilesToOutputDirectory
Copy all build outputs, satellites and other necessary files to the final directory.
============================================================
-->
<PropertyGroup>
<!-- By default we're not using Hard or Symbolic Links to copy to the output directory, and never when building in VS -->
<CreateHardLinksForCopyAdditionalFilesIfPossible Condition="'$(BuildingInsideVisualStudio)' == 'true' or '$(CreateHardLinksForCopyAdditionalFilesIfPossible)' == ''">false</CreateHardLinksForCopyAdditionalFilesIfPossible>
<CreateSymbolicLinksForCopyAdditionalFilesIfPossible Condition="'$(BuildingInsideVisualStudio)' == 'true' or '$(CreateSymbolicLinksForCopyAdditionalFilesIfPossible)' == ''">false</CreateSymbolicLinksForCopyAdditionalFilesIfPossible>
</PropertyGroup>
另外 Visual Studio 接管了一部分引用项目的清理工作,所以编译任务里面也将其过滤掉了。
<!--
============================================================
CleanReferencedProjects
Call Clean target on all Referenced Projects.
============================================================
-->
<Target
Name="CleanReferencedProjects"
DependsOnTargets="PrepareProjectReferences">
<!--
When building the project directly from the command-line, clean those referenced projects
that exist on disk. For IDE builds and command-line .SLN builds, the solution build manager
takes care of this.
-->
<MSBuild
Projects="@(_MSBuildProjectReferenceExistent)"
Targets="Clean"
Properties="%(_MSBuildProjectReferenceExistent.SetConfiguration); %(_MSBuildProjectReferenceExistent.SetPlatform); %(_MSBuildProjectReferenceExistent.SetTargetFramework)"
BuildInParallel="$(BuildInParallel)"
Condition="'$(BuildingInsideVisualStudio)' != 'true' and '$(BuildProjectReferences)' == 'true' and '@(_MSBuildProjectReferenceExistent)' != ''"
ContinueOnError="$(ContinueOnError)"
RemoveProperties="%(_MSBuildProjectReferenceExistent.GlobalPropertiesToRemove)"/>
</Target>
关于如何探索 Microsoft.NET.Sdk 可以阅读我的另一篇博客:
Visual Studio 的早期版本中有一个高级保存功能,但是升级到 Visual Studio 2019 之后这个功能就不在菜单项里面了。
本文将带你把它找出来继续使用。
打开 Visual Studio 2019,然后进入“工具 -> 自定义”菜单项。对于英文版本,是“Tools -> Customize”菜单项。
按照下图一个个点击,把“高级保存选项”放出来:
当刚刚添加出来的时候,位置可能不太正确,但是我们可以点击窗口旁边的“上移”和“下移”按钮将其放在合适的位置。
为了照顾英文版,我也放出英文版的界面:
充分利用 MSBuild 自带的方法,可以在编译期间完成大多数常见的属性转换,而不再需要自己专门写库来完成。
本文介绍如何使用 MSBuild 自带的方法,并列举 MSBuild 中各种自带的方法。
当然,在修改编译期间的代码的时候,你可能需要提前了解项目文件相关的知识:
以下是使用 MSBuild 自带方法的最简单的一个例子,执行 5-1
的数学运算。
<Walterlv>$([MSBuild]::Subtract(5, 1))</Walterlv>
更复杂的,可能是 MSBuild 方法调用的嵌套了:
<WalterlvPath Condition="HasTrailingSlash('$(WalterlvPath)')">$(WalterlvPath.Substring(0, $([MSBuild]::Add($(WalterlvPath.Length), -1))))</WalterlvPath>
以上两段示例分别来自我的另外两篇博客,如果不明白,可以参考这两篇博客的内容:
MSBuild 中数学运算的部分可以参考我的另一篇博客:
确保路径结尾有斜杠。
可参考我的另一篇博客:
这两个是非常有用却又非常容易被忽视的 API,非常有必要介绍一下。
可以阅读我的另一篇博客了解其用途和用法:
计算两个路径之间的相对路径表示。
<PropertyGroup>
<Path1>C:\Walterlv\</Path1>
<Path2>C:\Walterlv\Demo\</Path2>
<WalterlvPath1>$([MSBuild]::MakeRelative($(Path1), $(Path2)))</WalterlvPath1>
<WalterlvPath2>$([MSBuild]::MakeRelative($(Path2), $(Path1)))</WalterlvPath2>
</PropertyGroup>
WalterlvPath1
的值会计算为 Demo\
,而 WalterlvPath2
的值会计算为 ..\
。
如果赋值了,就使用所赋的值;否则使用参数指定的值:
<PropertyGroup>
<WalterlvValue1>$([MSBuild]::ValueOrDefault('$(FooBar)', 'walterlv'))</WalterlvValue1>
<WalterlvValue2>$([MSBuild]::ValueOrDefault('$(WalterlvValue1)', 'lindexi'))</WalterlvValue2>
</PropertyGroup>
第一行,因为我们没有定义任何一个名为 FooBar
的属性,所以 WalterlvValue1
属性会计算得到 walterlv
值。第二行,因为 WalterlvValue1
已经得到了一个值,所以 WalterlvValue2
也会得到 WalterlvValue1
的值,也就是 walterlv
,不会得到 lindexi
。
MSBuild 剩下的一些方法使用场景非常有限(不懂就别瞎装懂了),这里做一些简单的介绍。
$([MSBuild]::DoesTaskHostExist(string theRuntime, string theArchitecture))
GetRegistryValue
GetRegistryValueFromView
参考资料
大家在进行各种开发的时候,往往都不是写一个单纯项目就完了的,通常都会有一个解决方案,里面包含了多个项目甚至是大量的项目。我们经常会考虑输出一些文件或者处理一些文件,例如主项目的输出目录一般会选在仓库的根目录,文档文件夹一般会选在仓库的根目录。
然而,我们希望输出到这些目录或者读取这些目录的项目往往在很深的代码文件夹中。如果直接通过 ..\..\..
来返回仓库根目录非常不安全,你会数不过来的。
现在,我们有了一个好用的 API:GetDirectoryNameOfFileAbove
,可以直接找到仓库的根目录,无需再用数不清又容易改出问题的 ..\..\..
了。
你只需要编写这样的代码,即可查找 Walterlv.DemoSolution.sln 文件所在的文件夹的完全路径了。
<PropertyGroup>
<WalterlvSolutionRoot>$([MSBuild]::GetDirectoryNameOfFileAbove($(MSBuildThisFileDirectory), Walterlv.DemoSolution.sln))</BuildRoot>
</PropertyGroup>
而这段代码所在的文件,可能是这样的目录结构(里面的 Walterlv.DemoProject.csproj 文件):
- D:\walterlv\root
- \src
- \Walterlv.DemoProject
+ \Walterlv.DemoProject.csproj
- \Walterlv.DemoProject2
+ README.md
- \docs
- \bin
+ \Walterlv.DemoSolution.sln
+ README.md
这样,我们便可以找到 D:\walterlv\root
文件夹。
另外还有一个 API GetPathOfFileAbove
,只传入一个参数,找到文件后,返回文件的完全路径:
<PropertyGroup>
<WalterlvSolutionRoot>$([MSBuild]::GetPathOfFileAbove(Walterlv.DemoSolution.sln))</BuildRoot>
</PropertyGroup>
最终可以得到 D:\walterlv\root\Walterlv.DemoSolution.sln
路径。
需要注意的是:
*.sln
来找路径.git
等等文件夹去找路径\src\README.md
的方式来查找路径参考资料
本文介绍如何在项目文件 csproj,或者 MSBuild 的其他文件(props、targets)中处理路径中的斜杠与反斜杠。
我们都知道文件路径的层级之间使用斜杠(/
)或者反斜杠(\
)来分隔,具体使用哪一个取决于操作系统。本文不打算对具体使用哪一种特别说明,不过示例都是使用 Windows 操作系统中的反斜杠(\
)。
对于一个文件夹的路径,末尾无论是否有反斜杠都不会影响找到这个路径对应的文件夹,但是有时我们又因为一些特殊的用途需要知道末尾的反斜杠的情况。
在 MSBuild 中,通常有一个在文件夹路径末尾添加反斜杠 \
的惯例,这样可以直接使用属性拼接来形成新的路径而不用担心路径中的不同层级的文件夹会连接在一起。
例如属性 WalterlvPath1
的值为 bin
,属性 WalterlvPath2
的值为 Debug
。为了确保两个可以直接使用 $(WalterlvPath1)$(WalterlvPath2)
来拼接,我们需要在这两个属性的末尾都加上反斜杠 \
。不过由于需要照顾到各式各样的开发者,包括大多数的那些从来不看文档的开发者,我们需要进行本文所述的处理。
如果路径末尾没有反斜杠,那么我们现在就添加一个反斜杠。
<WalterlvPath Condition="!HasTrailingSlash('$(WalterlvPath)')">$(WalterlvPath)\</WalterlvPath>
这样,如果 WalterlvPath
的值为 bin
,则会在这一个属性重新计算值的时候变成 bin\
;如果已经是 bin\
,则不会重新计算值,于是保持不变。
另外,也有方法可以不用做判断,直接给末尾根据情况加上反斜杠。
通过调用 MSBuild.EnsureTrailingSlash
可以确保路径的末尾已经有一个斜杠或者反斜杠。
例如,我们有一个 WalterlvPath
属性,值可能是 bin\Debug
也有可能是 bin\Debug\
,那么可以统一将其处理成 bin\Debug\
。
<WalterlvPath>$([MSBuild]::EnsureTrailingSlash('$(WalterlvPath)'))</WalterlvPath>
正常情况下,我们都是需要 MSBuild 中文件夹路径的末尾有斜杠或者反斜杠。不过,当我们需要将这个路径作为命令行参数的一部分传给一个可执行程序的时候,就没那么容易了。
因为为了确保路径中间的空格不会被命令行参数解析给分离,我们需要在路径的周围加上引号。具体来说,是使用 "
转义字符来添加引号:
<Target Name="WalterlvDemoTarget" BeforeTargets="BeforeBuild">
<Exec Command=""$(WalterlvDemoTool)" --option "$(WalterlvPath)"" />
</Target>
以上的 Target 是我在另一篇博客中的简化版本:如何创建一个基于命令行工具的跨平台的 NuGet 工具包 - walterlv。
但是这样,如果 WalterlvPath
中存在反斜杠,那么这个命令行将变成这样:
> "walterlv.tool.exe" --option "bin\"
后面的 \"
将使得引号成为路径中的一部分,而这样的路径是不合法的路径!
我们可以确保路径的末尾添加一个空格来避免将引号也解析成命令行的一部分:
<Target Name="WalterlvDemoTarget" BeforeTargets="BeforeBuild">
<Exec Command=""$(WalterlvDemoTool)" --option "$([MSBuild]::EnsureTrailingSlash('$(BasePathInInstaller)')) "" />
</Target>
不过也可以通过 SubString
来对末尾的斜杠或反斜杠进行裁剪。
<WalterlvPath Condition="HasTrailingSlash('$(WalterlvPath)')">$(WalterlvPath.Substring(0, $([MSBuild]::Add($(WalterlvPath.Length), -1))))</WalterlvPath>
解释一下这里 $(WalterlvPath.Substring(0, $([MSBuild]::Add($(WalterlvPath.Length), -1))))
所做的事情:
$(WalterlvPath.Length)
计算出 WalterlvPath
属性的长度;$([MSBuild]::Add(length, -1))
调用加法,将前面计算所得的长度 -1,用于提取无斜杠或反斜杠的路径长度。$(WalterlvPath.Substring(0, length-1)
将路径字符串取出子串。这里的解释里面,length
只是表意,并不是为了编译通过。要编译的代码还是上面代码块中的完整代码。
更多关于在 Roslyn/MSBuild 中进行数学运算的内容,可以阅读我的另一篇博客:
在任何一种编程语言中,做基本的数学运算都是非常容易的事情。不过,不知道 .NET 项目的项目文件 csproj 文件中进行数学运算就不像一般的编程语言那样直观了,毕竟这不是一门语言,而只是一种项目文件格式而已。
本文介绍如何在 Roslyn/MSBuild 的项目文件中使用基本的数学运算。
在 MSBuild 中,数学运算需要使用 MSBuild
内建的方法调用来实现。
你只需要给 MSBuild 中那些数学计算方法中传入看起来像是数字的属性,就可以真的计算出数字出来。
Add
两个数相加,实现 a + bSubtract
第一个数减去第二个数,实现 a - bMultiply
两个数相乘,实现 a * bDivide
第一个数除以第二个数,实现 a / bModulo
第一个数与第二个数取模,实现 a % b而具体到 MSBuild 中的使用,则是这样的:
<!-- 计算 5 - 1 的数学运算结果 -->
<Walterlv>$([MSBuild]::Subtract(5, 1))</Walterlv>
<!-- 取出 Walterlv 属性的字符串值,然后计算其长度减去 1,将数学运算结果存入 Walterlv2 属性中 -->
<Walterlv>walterlv is a 逗比</Walterlv>
<Walterlv2>$([MSBuild]::Subtract($(Walterlv.Length), 1))</Walterlv2>
不同于一般编程语言可以写的 +
-
*
/
,如果你直接在项目文件中使用这样的符号来进行数学计算,要么你将得到一个数学运算的字符串,要么你将得到编译错误。
例如,如果你在你的项目文件中写了下面这样的代码,那么无一例外全部不能得到正确的数学运算结果。
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netcoreapp3.0</TargetFramework>
<!-- 这个属性将得到一个 “1 + 1” 字符串 -->
<Walterlv>1 + 1</Walterlv>
<!-- 无法编译此属性 -->
<!-- 无法计算表达式“"1 + 1".Length + 1”。未找到方法“System.String.Length + 1” -->
<Walterlv2>$(Walterlv.Length + 1)</Walterlv2>
<!-- 这个属性将得到一个 “5 + 1” 字符串 -->
<Walterlv3>$(Walterlv.Length) + 1</Walterlv3>
</PropertyGroup>
</Project>
参考资料
嫌项目编译太慢?不一定是 Visual Studio 的问题,有可能是你项目的引用关系决定这个编译时间真的省不下来。
可是,编译瓶颈在哪里呢?本文介绍 Parallel Builds Monitor 插件,帮助你迅速找出编译瓶颈。
前往 Parallel Builds Monitor - Visual Studio Marketplace 下载插件安装。
之后启动 Visual Studio 2019,你就能在 “其他窗口” 中找到 “Parallel Builds Monitor” 窗口了。请点击打开它。
现在,使用 Visual Studio 编译一个项目,点开这个窗口,一个正在进行中的甘特图将呈现出来:
我们可以通过此插件寻找到多种可能的瓶颈:
看上面的那张图,这里存在典型的项目依赖瓶颈。因为在编译的中后期,几个编译时间最长的项目,其编译过程完全是串联起来编译的。
这里串联起来的每一个项目,都是依赖于前一个项目的。所以要解决掉这部分的性能瓶颈,我们需要断开这几个项目之间的依赖关系,这样它们能变成并行的编译。
通常,CPU 成为瓶颈在编译中是个好事情,这意味着无关不必要的编译过程非常少,主要耗时都在编译代码的部分。当然,如果你有一些自定义的编译过程浪费了 CPU 占用那是另外一回事。
比如我之前写过自己可以做一个工具包,在编译期间会执行一些代码:
IO 本不应该成为瓶颈。如果你的项目就是存在非常多的依赖文件需要拷贝,那么应该尽可能利用差量编译来避免重复拷贝文件。
参考资料
Visual Studio 创建新项目的时候,默认位置在 C:\Users\lvyi\source\repos\
下。多数时候,我们都希望将其改为一个更适合自己开发习惯的路径。实际上修改默认路径并不是一个麻烦的事情,但是当紧急需要修改的时候,你可能找不到设置项在哪里。
本文介绍如何修改这个默认路径。
默认位置在 C:\Users\lvyi\source\repos\
下。
在 Visual Studio 中打开菜单 “工具” -> “选项”;然后找到 “项目和解决方案” -> “位置” 标签。“项目位置” 一栏就是设置新建项目默认路径的地方。
如果是英文本,则打开菜单 “Tools” -> “Options”;然后找到 “Projects and Solutions” -> “Locations” 标签。“Projects location” 一栏就是设置新建项目默认路径的地方。
修改完后,再次新建项目,就可以看到修改后的默认路径了。
.NET 托管程序的编译速度比非托管程序要快非常多,即便是 .NET Core,只要不编译成 Native 程序,编译速度也是很快的。然而总是有一些逗比大项目编译速度非常缓慢(我指的是分钟级别的),而且还没做好差量编译;于是每一次编译都需要等待几十秒到数分钟。这显然是非常影响效率的。
在解决完项目的编译速度问题之前,如何能够临时进行快速调试改错呢?本文将介绍在 Visual Studio 中不进行编译就调试的方法。
我找到了两种临时调试而不用编译的方法:
新建一个普通的类库项目,右击项目,属性,打开属性设置页面。进入“调试”标签:
现在,将默认的启动从“项目”改为“可执行文件”,然后将我们本来调试时输出的程序路径贴上去。
现在,如果你不希望编译大项目而直接进行调试,那么将启动项目改为这个小项目即可。
.NET 托管程序的编译速度比非托管程序要快非常多,即便是 .NET Core,只要不编译成 Native 程序,编译速度也是很快的。然而总是有一些逗比大项目编译速度非常缓慢(我指的是分钟级别的),而且还没做好差量编译;于是每一次编译都需要等待几十秒到数分钟。这显然是非常影响效率的。
在解决完项目的编译速度问题之前,如何能够临时进行快速调试改错呢?本文将介绍在 Visual Studio 中不进行编译就调试的方法。
我找到了两种临时调试而不用编译的方法:
有时候只是为了定位 Bug 不断重复运行以调试程序,并没有修改代码。然而如果 Visual Studio 的差量编译因为逗比项目失效的话,就需要手动告诉 Visual Studio 不需要进行编译,直接进行调试。
进入 工具
-> 选项
-> 项目和解决方案
-> 生成并运行
。
“当项目过期时”,选择“从不生成”。
顺便附中文版截图:
这时,你再点击运行你的项目的时候,就不会再编译了,而是直接进入调试状态。
这特别适合用来定位 Bug,因为这时基本不改什么代码,都是在尝试复现问题以及查看各种程序的中间状态。
在 MSBuild 中有一些特殊字符,如 $
@
%
'
等,本文介绍他们的含义,如何使用他们,以及你真的需要这些字符的时候如何编写他们。
MSBuild 中有这些特殊字符:
$
@
%
'
;
?
*
$
引用一个属性或者环境变量。
<Project>
<ItemGroup>
<TargetFramework>netcoreapp3.0</TargetFramework>
<OutputPath>bin\$(Configuration)</OutputPath>
</ItemGroup>
</Project>
比如以下两篇博客列出了一些最典型的使用场景。
@
引用一个集合。
<Target Name="WalterlvDemoTarget" BeforeTargets="CoreCompile">
<Message Text="References:" />
<Message Text="@(Reference)" />
</Target>
比如以下两篇博客列出了一些最典型的使用场景:
%
引用集合中某一个项的某个属性。
<Target Name="Xxx" AfterTargets="AfterBuild">
<ItemGroup>
<Walterlv Include="@(Compile)=%(Compile.CopyToOutputDirectory)" />
</ItemGroup>
<Warning Text="@(Walterlv)" />
</Target>
比如下面两篇博客列出了此字符的一些使用:
'
在形成一个字符串的时候,会使用到此字符。
下面这篇博客列出了此字符的一些使用:
;
如果存在分号,那么在形成一个集合的时候,会被识别为集合中的各个项之间的分隔符。
有时候你真的需要分号而不是作为分隔符的时候,需要进行转义:
?
和 *
作为通配符使用。一个 *
表示文件或者文件夹通配符,而 **
则表示任意层级的文件或文件夹。
下面这篇博客虽然古老,却也说明了其用法:
在 MSBuild 中,由于这些特殊字符其实非常常见,所以与一些已有的值很容易冲突,所以需要转义。
转义可以使用 ASCII 编码:
$
- %24
@
- %40
%
- %25
'
- %27
;
- %3B
?
- %3F
*
- %2A
转义方法一:
<Compile Include="Walterlv1%3BWalterlv2.cs"/>
这样得到的将是一个名字为 Walterlv1;Walterlv2.cs
的文件,而不是两个文件。
转义方法二:
<Compile Include="$([MSBuild]::Escape('Walterlv1;Walterlv2.cs'))" />
详细方法可参见:
参考资料
在编写项目文件或者 MSBuild Target 文件的时候,我们经常会使用 <Foo Include="Identity" />
来定义集合中的一项。在定义的同时,我们也会额外指定一些属性。
然而这些属性如何拿到并且使用呢?本文将介绍使用方法。
将下面的代码放到你项目文件的末尾,最后一个 </Project>
的前面,可以在编译的时候看到两个新的警告。
<Target Name="Xxx" AfterTargets="AfterBuild">
<ItemGroup>
<WalterlvX Include="@(Compile)" />
<WalterlvY Include="%(Compile.FileName)" />
</ItemGroup>
<Warning Text="@(WalterlvX)" />
<Warning Text="@(WalterlvY)" />
</Target>
在定义 WalterlvX
集合的时候,我们使用了 @(Compile)
来获取所有需要编译的文件。
在定义 WalterlvY
集合的时候,我们使用了 %(Compile.FileName)
来获取编译文件的文件名。
于是,你在警告信息中看到的两个警告信息里面,一个输出了 Compile
集合中每一项的标识符(通常是相对于项目文件的路径),另一个输出了每一个 Compile
项中的 FileName
属性。FileName
属性是 Compile
会被 Microsoft.NET.Sdk 自动填充。
需要注意,如果 %
得到的项中某个属性为空,那么这一项在最终形成的新集合中是不存在的。
所以,如果存在可能不存在的属性,那么建议先进行拼接再统一处理拼接后的值:
<Target Name="Xxx" AfterTargets="AfterBuild">
<ItemGroup>
<Walterlv Include="@(Compile)=%(Compile.CopyToOutputDirectory)" />
</ItemGroup>
<Warning Text="@(Walterlv)" />
</Target>
这里的 CopyToOutputDirectory
不是一个总是会设置的属性。
Visual Studio 中有些自带的快捷键与现有软件有冲突,那么如何修改这些快捷键让这些功能正常工作起来呢?
在 Visual Studio 中打开 “工具 -> 选项”,打开选项设置界面。在其中找到 “环境 -> 键盘” 项。我们设置快捷键的地方就在这里。
默认情况下,在 Visual Studio 2019 中快速重构的快捷键是 Ctrl+.
。然而,使用中文输入法的各位应该非常清楚,Ctrl+.
是输入法切换中英文符号的快捷键。
于是,当使用中文输入法的时候,实际上是无法通过按下 Ctrl+.
来完成快速重构的。我们需要修改快捷键来避免这样的冲突。
在“新快捷键”那个框框中,按下 Ctrl+.
,正常会在“快捷键的当前使用对象”框中出现此快捷键的功能。不过,如果快捷键已经与输入法冲突,则不会出现,你需要先切换至英文输入法以避免此冲突。
通过“快捷键的当前使用对象”下拉框,我们可以得知功能的名称,下拉框中的每一项都是此快捷键的功能。
我们需要做的是,搜索这些功能,并为这些功能分配新的快捷键。每一个我们关心的功能都这么设置:
于是新快捷键就设置好了。
现在,可以使用新的快捷键来操作这些功能了。
参考资料
当你的项目中多个不同的项目以及不同的依赖存在不同的依赖程序集时,可能会因为依赖于不同版本的程序集而产生冲突。而绑定重定向可以帮助解决不同程序集的依赖版本不同的问题,使整个程序使用统一个版本的 dll 来运行整个应用程序。
然而,如果我们就是需要使用一个分离的不同版本,那么我们就需要禁用掉自动生成绑定重定向。本文介绍如何禁用自动生成绑定重定向。
本文的结论只有一句,就是在项目中设置属性 <AutoGenerateBindingRedirects>false</AutoGenerateBindingRedirects>
。阅读本文全文是了解更多与绑定重定向此场景相关的知识。
从 .NET Framework 4.5.1 开始到后面的 .NET Core 所有版本,编译器会自动向你的程序集中插入绑定重定向。如果你升级使用了新的 csproj 格式,即便你用了旧的 .NET Framework 也会自动生成绑定重定向。
关于新旧 csproj 格式,你可以参考我的另一篇博客:将 WPF、UWP 以及其他各种类型的旧 csproj 迁移成 Sdk 风格的 csproj - walterlv。
你可以在你的应用程序的 App.config 文件中查看到自动生成的绑定重定向。当然,编译之后这个 App.config 文件会编程 “你的程序集名称.config” 文件,例如对于我的 Walterlv.Demo.exe
程序对应 Walterlv.Demo.exe.config
文件。
一个典型的包含绑定重定向的文件大概是下面这样的:
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<startup useLegacyV2RuntimeActivationPolicy="true">
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.6" />
</startup>
<runtime>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="Newtonsoft.Json" publicKeyToken="30ad4fe6b2a6aeed" culture="neutral" />
<bindingRedirect oldVersion="0.0.0.0-11.0.0.0" newVersion="11.0.0.0" />
</dependentAssembly>
<dependentAssembly>
<assemblyIdentity name="System.ValueTuple" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral" />
<bindingRedirect oldVersion="0.0.0.0-4.0.3.0" newVersion="4.0.3.0" />
</dependentAssembly>
</assemblyBinding>
</runtime>
</configuration>
上面 dependentAssembly
以及 bindingRedirect
就是在描述绑定重定向。
对于上面的代码,指的是:
0.0.0.0-11.0.0.0
区间版本号的 Newtonsoft.Json 程序集的引用,都将使用 11.0.0.0 版本的。0.0.0.0-4.0.3.0
区间版本号的 System.ValueTuple 程序集的引用,都将使用 4.0.3.0 版本的(这个其实使用的 NuGet 包版本是 4.5)。绑定重定向多数时候都是在帮助我们解决依赖问题,然而我们总有一些时候不是按照常规的方式来使用依赖,例如下文这样的方式:
以上文章的场景,是需要在同一个解决方案的不同项目中引用不同版本的同名 dll。解决方法是像下面这样:
<dependentAssembly>
<assemblyIdentity name="LiteDB" publicKeyToken="4ee40123013c9f27" culture="neutral" />
<codeBase version="2.0.2.0" href="LiteDB.2.0.2.0\LiteDB.dll" />
<codeBase version="4.0.0.0" href="LiteDB.4.0.0.0\LiteDB.dll" />
</dependentAssembly>
于是,如果引用了 2.0.2.0 版本的 LiteDB 的时候,会去应用程序所在目录的 LiteDB.2.0.2.0 子目录中查找名为 LiteDB.dll 的引用 dll;而如果引用了 4.0.0.0 版本的 LiteDB 的时候,会去应用程序所在目录的 LiteDB.4.0.0.0 子目录中查找名为 LiteDB.dll 的引用 dll。这种方式使用两个 dll 互不干扰。
如果你的项目从 .NET Framework 4.5 或者更早版本升级到 .NET Framework 4.5.1 或者 .NET Core 的版本,或者 csproj 的格式升级到了新的基于 Microsoft.NET.Sdk 的版本,那么绑定重定向就会从之前的手动编程自动生成。
但是如果你编写了上一节中我们讲到的你需要引用同名程序集的多个版本的时候,如果依然自动生成绑定重定向,那么上面的功能会失效。
解决方法,便是禁用自动生成绑定重定向。在你的主项目中添加一个属性:
<AutoGenerateBindingRedirects>false</AutoGenerateBindingRedirects>
参考资料
我们都知道可以通过在 Visual Studio 中设置输出路径(OutputPath)来更改项目输出文件所在的位置。对于 .NET Core 所使用的 Sdk 风格的 csproj 格式来说,你可能会发现实际生成路径中带了 netcoreapp3.0
或者 net472
这样的子文件夹。
然而有时我们并不允许生成这样的子文件夹。本文将介绍可能影响实际输出路径的各种设置。
对于这样的一个简单的项目文件,这个项目的实际输出路径可能是像下图那样的。
<Project>
<ItemGroup>
<TargetFramework>netcoreapp3.0</TargetFramework>
<OutputPath>bin\$(Configuration)</OutputPath>
</ItemGroup>
</Project>
有没有办法可以不要生成这样的子文件夹呢?答案是可以的。
我在 解读 Microsoft.NET.Sdk 的源码,你能定制各种奇怪而富有创意的编译过程 一文中有说到如何解读 Microsoft.NET.Sdk,而我们的答案就是从解读这个 Sdk 而来。
OutputPath 属性由这些部分组成:
$(BaseOutputPath)\$(PlatformName)\$(Configuration)\$(RuntimeIdentifier)\$(TargetFramework.ToLowerInvariant())\
如果以上所有属性都有值,那么生成的路径可能就像下面这样:
bin\x64\Debug\win7-x64\netcoreapp3.0
具体的,这些属性以及其相关的设置有:
$(BaseOutputPath)
默认值 bin\
,你也可以修改。
$(PlatformName)
默认值是 $(Platform)
,而 $(Platform)
的默认值是 AnyCPU
;当这个值等于 AnyCPU
的时候,这个值就不会出现在路径中。
$(Configuration)
默认值是 Debug
。
$(RuntimeIdentifier)
这个值和 $(PlatformTarget)
互为默认值,任何一个先设置都会影响另一个;此值即 x86
、x64
等标识符。可以通过 $(AppendRuntimeIdentifierToOutputPath)
属性指定是否将此加入到输出路径中。
$(TargetFramework)
这是在 csproj 文件中强制要求指定的,如果不设置的话项目是无法编译的;可以通过 $(AppendTargetFrameworkToOutputPath)
属性指定是否将此加入到输出路径中。
现在,你应该可以更轻松地设置你的输出路径,而不用担心总会出现各种意料之外的子文件夹了吧!
因为我使用 Visual Studio 主要用来编写 .NET 托管程序,所以平时调试的时候是仅限托管代码的。不过有时需要在托管代码中混合调试本机代码,那么就需要额外在项目中开启本机代码调试。
本文介绍如何开启本机代码调试。
本文涉及到新旧 csproj 项目格式,不懂这个也不影响你完成开启本机代码调试。不过如果你希望了解,可以阅读:将 WPF、UWP 以及其他各种类型的旧 csproj 迁移成 Sdk 风格的 csproj - walterlv。
旧格式指的是 Visual Studio 2015 及以前版本的 Visual Studio 使用的项目格式。目前 Visual Studio 2017 和 2019 对这种格式的支持还是很完善的。
在项目上右键 -> 属性 -> Debug,这时你可以在底部的调试引擎中发现 Enable native code debugging
选项,开启它你就开启了本机代码调试,于是也就可以使用混合模式调试程序。
如果你在你项目属性的 Debug 标签下没有找到上面那个选项,那么有可能你的项目格式是新格式的。
这个时候,你需要在 lauchsettings.json 文件中设置。这个文件在你项目的 Properties 文件夹下。
如果你没有找到这个文件,那么随便在上图那个框框中写点什么(比如在启动参数一栏中写 吕毅是逗比),然后保存。我们就能得到一个 lauchsettings.json 文件。
打开它,然后删掉刚刚的逗比行为,添加 "nativeDebugging": true
。这时,你的 lauchsettings.json 文件影响像下面这样:
{
"profiles": {
"Walterlv.Debugging": {
"commandName": "Project",
"nativeDebugging": true
}
}
}
这时你就可以开启本机代码调试了。当然,新的项目格式支持设置多个这样的启动项,于是你可以分别配置本机和非本机的多种配置:
{
"profiles": {
"Walterlv.Debugging": {
"commandName": "Project"
},
"本机调试": {
"commandName": "Project",
"nativeDebugging": true
}
}
}
现在,你可以选择你项目的启动方式了,其中一个是开启了本机代码调试的方式。
关于这些配置的更多博客,你可以阅读:VisualStudio 使用多个环境进行调试 - 林德熙。
参考资料
如果你是开发个人项目,那就直接用 Visual Studio Community 版本吧,对个人免费,对小团体免费,不需要这么折腾。
如果你是 Mac / Linux 用户,不想用 Visual Studio for Mac 版;或者不想用 Visual Studio for Windows 版那么重磅的 IDE 来开发简单的 .NET Core 程序;或者你就是想像我这么折腾,那我们就开始吧!
搜索的时候,推荐使用 OmniSharp
关键字,因为这可以得到唯一的结果,你不会弄混淆。如果你使用 C# 作为关键字,那需要小心,你得找到名字只有 C#,点开之后是 C# for Visual Studio Code 的那款插件。因为可能装错,所以我不推荐这么做。
对于新版的 Visual Studio Code,装完会自动启用,所以你不用担心。我们可以后续步骤了。
本文不会讲解如何使用 VSCode 创建 .NET Core 项目,因为这不是本文的重点。
也许你可以参考我还没有写的另一篇博客。
现在假设你已经有一个现成的能用 Visual Studio 跑起来的 .NET Core 控制台项目了(可能是刚克隆下来的,也可能就是用我另一篇博客中的教程创建的),于是我们就在这个项目上进行开发。
本文以我的自动化测试程序 Walterlv.InfinityStartupTest 为例进行说明。如果你找不到合适的例子,可以使用这篇博客创建一个。
在这个文件夹的根目录下右键,然后 使用 Code 打开
。
正常情况下,当你用 Visual Studio Code 打开一个包含 .NET Core 项目的文件夹时,C# 插件会在右下角弹出通知提示,问你要不要为这个项目创建编译和调试文件,当然选择“Yes”。
这个提示一段时间不点会消失的,但是右下角会有一个小铃铛(上面的图片也可以看得到的),点开可以看到刚刚消失的提示,然后继续操作。
这时,你的项目文件夹中会多出两个文件,都在 .vscode 文件夹中。tasks.json
是编译文件,指导如何进行编译;launch.json
是调试文件,指导如何进行调试。
现在,你只需要按下 F5(就是平时 Visual Studio 调试按烂的那个),你就能使用熟悉的调试方式在 Visual Studio Code 中来调试 .NET Core 程序了。
下图是调试进行中各个界面的功能分区。如果你没看到这个界面,请点击左侧那只被圈在圆圈里面的小虫子。
当你按照本文操作,在按下 F5 后有各种报错,那么原因只有一个——你的这个项目本身就是编译不过的,你自己用命令行也会编译不过。你需要解决编译问题,而本文只是入门教程,不会说如何解决编译问题。
如果自动创建的这两个文件有问题,或者你根本就找不到自动创建的入口,可以考虑手工创建这两个文件。
请参见博客:
还补充一句,本文说编译文件和调试文件是不对的,因为在 Visual Studio Code 中没有编译这个概念,编译只是任务中的一种而已。
如果 C# for Visual Studio Code 没有办法自动为你生成正确的 tasks.json 和 launch.json 文件,那么可以考虑阅读本文手工创建他们。
你需要安装 .NET Core Sdk、Visual Studio Code 和 C# for Visual Studio Code,然后打开一个 .NET Core 的项目。如果你没有准备,请先阅读:
本文主要处理自动生成的配置文件无法满足要求,手工生成。
这依然是个偷懒的好方案,我喜欢。
你不需要再做什么其他的工作了,这时再按下 F5 你已经可以开始调试了。
tasks.json 定义一组任务。其中我们需要的是编译任务,通常编译一个项目使用的动词是 build
。比如 dotnet build
命令就是这样的动词。
于是定义一个名字为 build
的任务,对应 label
标签。command
和 args
对应我们在命令行中编译一个项目时使用的命令行和参数。type
为 process
表示此任务是启动一个进程。
{
"version": "2.0.0",
"tasks": [
{
"label": "build",
"command": "dotnet",
"type": "process",
"args": [
"build",
"${workspaceFolder}/Walterlv.InfinityStartupTest/Walterlv.InfinityStartupTest.csproj"
],
"problemMatcher": "$msCompile"
}
]
}
在 launch.json 中通常配置两个启动配置,一个是启动调试,一个是附加调试。
type
是在安装了 C# for Visual Studio Code (powered by OmniSharp) 插件之后才会有的调试类型。preLaunchTask
表示在此启动开始之前需要执行的任务,这里指定的 build
跟前面的 build
任务就关联起来了。program
是调试的程序路径,console
指定调试控制台使用内部控制台。
{
"version": "0.2.0",
"configurations": [
{
"name": "调试 Walterlv 的自动化测试程序",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
"program": "${workspaceFolder}/Walterlv.InfinityStartupTest/bin/Debug/netcoreapp3.0/Walterlv.InfinityStartupTest.dll",
"args": [],
"cwd": "${workspaceFolder}/Walterlv.InfinityStartupTest",
"console": "internalConsole",
"stopAtEntry": false,
"internalConsoleOptions": "openOnSessionStart"
},
{
"name": "附加进程",
"type": "coreclr",
"request": "attach",
"processId": "${command:pickProcess}"
}
]
}
这样自己手写的方式更灵活但是也更难。
Windows 系统以及很多应用程序会考虑使用系统的环境变量来传递一些公共的参数或者配置。Windows 资源管理器使用 %var%
来使用环境变量,那么我们能否在 Visual Studio 的项目文件中使用环境变量呢?
本文介绍如何在 csproj 文件中使用环境变量。
在 Windows 资源管理器中,我们可以使用 %AppData%
进入到用户的漫游路径。我正在为 希沃白板5 为互动教学而生 - 课件制作神器 编写插件,于是需要将插件放到指定目录:
%AppData%\Seewo\EasiNote5\Walterlv.Presentation
在 Windows 资源管理器中可以直接输入以上文字进入对应的目录(当然需要确保存在)。
更多关于路径的信息可以参考:UWP 中的各种文件路径(用户、缓存、漫游、安装……) - walterlv
然而,为了调试方便,我最好在 Visual Studio 中编写的时候就能直接输出到插件目录。
于是,我需要将 Visual Studio 的调试目录设置为以上目录,但是以上目录中包含环境变量 %AppData%
如果直接在 csproj 中使用 %AppData%
,那么 Visual Studio 会原封不动地创建一个这样的文件夹。
实际上,Visual Studio 是天然支持环境变量的。直接使用 MSBuild 获取属性的语法即可获取环境变量的值。
也就是说,使用 $(AppData)
即可获取到其值。在我的电脑上是 C:\Users\lvyi\AppData\Roaming
。
于是,在 csproj 中设置 OutputPath
即可正确输出我的插件到目标路径。
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net472</TargetFrameworks>
<OutputPath>$(AppData)\Seewo\EasiNote5\Extensions\Walterlv.Presentation</OutputPath>
<AppendTargetFrameworkToOutputPath>False</AppendTargetFrameworkToOutputPath>
</PropertyGroup>
</Project>
这里,我额外设置了 AppendTargetFrameworkToOutputPath
属性,这是避免 net472
出现在了目标输出路径中。你可以阅读我的另一篇博客了解更多关于输出路径的问题:
很多库都会在 nuget.org 上发布预览版本,不过一般来说这个预览版本也是大多可用的。然而想要体验日构建版本,这个就没有了,毕竟要照顾绝大多数开发者嘛……
本文介绍如何使用 MyGet 这个激进的 NuGet 源,介绍如何使用框架级别的库的预览版本如 .NET Standard 的预览版本。
添加 NuGet 源的方法在我和林德熙的博客中都有说明:
简单点,就是在 Visual Studio 中打开 工具
-> 选项
-> NuGet 包管理器
-> 包源
:
然后把 MyGet 的源添加进去:
如果你想添加其他的 NuGet 源,可以参见我的另一篇博客:我收集的各种公有 NuGet 源 - 吕毅。
因为我们在使用 .NET Standard 库的时候,是直接作为目标框架来选择的,就像下面的项目文件内容一样:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
</PropertyGroup>
</Project>
然而,如果你直接把 TargetFramework
中的值改为预览版本,是无法使用的。因为 TargetFramework
的匹配是按照字符串来匹配的,并不会解析成库和版本号。关于这一点可以如何得知的,可以参考我的另一篇博客(中英双语):
然而实际上的使用方法很简单,就是直接用正常的方法安装对应的 NuGet 包:
PM> Install-Package NETStandard.Library -Version 2.1.0-preview1-27119-01
或者直接去 csproj 中添加 PackageReference
。
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="NETStandard.Library" Version="2.1.0-preview1-27119-01" />
</ItemGroup>
</Project>
至于版本号如何确定,请直接前往 MyGet 网站查看:dotnet-core - NETStandard.Library - MyGet。
这个时候,.NET Standard 的预览版标准库会使用以替换 .NET Standard 2.0 的正式版本库。
使用 Visual Studio 调试 .NET 程序的时候,在局部变量窗格或者用鼠标划到变量上就能查看变量的各个字段和属性的值。默认显示的是对象 ToString()
方法调用之后返回的字符串,不过如果 ToString()
已经被占作它用,或者我们只是希望在调试的时候得到我们最希望关心的信息,则需要使用 .NET 中调试器相关的特性。
本文介绍使用 DebuggerDisplayAttribute
和 DebuggerTypeProxyAttribute
来自定义调试信息的显示。(同时隐藏我们在背后做的这些见不得人的事儿。)
比如我们有一个名为 CommandLine
的类型,表示从命令行传入的参数;内有一个字典,包含命令行参数的所有信息。
public class CommandLine
{
private readonly Dictionary<string, IReadOnlyList<string>> _optionArgs;
private CommandLine(Dictionary<string, IReadOnlyList<string>> optionArgs)
=> _optionArgs = optionArgs ?? throw new ArgumentNullException(nameof(optionArgs));
}
现在,我们在 Visual Studio 里面调试得到一个 CommandLine
的实例,然后使用调试器查看这个实例的属性、字段和集合。
然后,这样的一个字典嵌套列表的类型,竟然需要点开 4 层才能知道命令行参数究竟是什么。这样的调试效率显然是太低了!
使用 DebuggerDisplayAttribute
可以帮助我们直接在局部变量窗格或者鼠标划过的时候就看到对象中我们最希望了解的信息。
现在,我们在 CommandLine
上加上 DebuggerDisplayAttribute
:
// 此段代码非最终版本。
[DebuggerDisplay("CommandLine: {DebuggerDisplay}")]
public class CommandLine
{
private readonly Dictionary<string, IReadOnlyList<string>> _optionArgs;
private CommandLine(Dictionary<string, IReadOnlyList<string>> optionArgs)
=> _optionArgs = optionArgs ?? throw new ArgumentNullException(nameof(optionArgs));
private string DebuggerDisplay => string.Join(' ', _optionArgs
.Select(pair => $"{pair.Key}{(pair.Key == null ? "" : " ")}{string.Join(' ', pair.Value)}"));
}
效果有了:
不过,展开对象查看的时候可以看到一个 DebuggerDisplay
的属性,而这个属性我们只是调试使用,这是个垃圾属性,并不应该影响我们的查看。
我们使用 DebuggerBrowsable
特性可以关闭某个属性或者字段在调试器中的显示。于是代码可以改进为:
-- [DebuggerDisplay("CommandLine: {DebuggerDisplay}")]
++ [DebuggerDisplay("CommandLine: {DebuggerDisplay,nq}")]
public class CommandLine
{
private readonly Dictionary<string, IReadOnlyList<string>> _optionArgs;
private CommandLine(Dictionary<string, IReadOnlyList<string>> optionArgs)
=> _optionArgs = optionArgs ?? throw new ArgumentNullException(nameof(optionArgs));
++ [DebuggerBrowsable(DebuggerBrowsableState.Never)]
private string DebuggerDisplay => string.Join(' ', _optionArgs
.Select(pair => $"{pair.Key}{(pair.Key == null ? "" : " ")}{string.Join(' ', pair.Value)}"));
}
添加了从不显示此字段(DebuggerBrowsableState.Never
),在调试的时候,展开后的属性列表里面没有垃圾 DebuggerDisplay
属性了。
另外,我们在 DebuggerDisplay
特性的中括号中加了 nq
标记(No Quote)来去掉最终显示的引号。
虽然我们使用了 DebuggerDisplay
使得命令行参数一眼能看出来,但是看不出来我们把命令行解析成什么样了。于是我们需要更精细的视图。
然而,上面展开 _optionArgs
字段的时候,依然需要展开 4 层才能看到我们的所有信息,所以我们使用 DebuggerTypeProxyAttribute
来优化调试器实例内部的视图。
class CommandLineDebugView
{
[DebuggerBrowsable(DebuggerBrowsableState.Never)]
private readonly CommandLine _owner;
public CommandLineDebugView(CommandLine owner)
{
_owner = owner;
}
[DebuggerBrowsable(DebuggerBrowsableState.RootHidden)]
private string[] Options => _owner._optionArgs
.Select(pair => $"{pair.Key}{(pair.Key == null ? "" : " ")}{string.Join(' ', pair.Value)}")
.ToArray();
}
我面写了一个新的类型 CommandLineDebugView
,并在构造函数中允许传入要优化显示的类型的实例。在这里,我们写一个新的 Options
属性把原来字典里面需要四层才能展开的值合并成一个字符串集合。
但是,我们在 Options
上标记 DebuggerBrowsableState.RootHidden
:
别忘了我们还需要禁止 _owner
在调试器中显示,然后把 [DebuggerTypeProxy(typeof(CommandLineDebugView))]
加到 CommandLine
类型上。
这样,最终的显示效果是这样的:
点击 Raw View
可以看到我们没有使用 DebuggerTypeProxyAttribute
视图时的属性和字段。
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using Walterlv.Framework.StateMachine;
namespace Walterlv.Framework
{
[DebuggerDisplay("CommandLine: {DebuggerDisplay,nq}")]
[DebuggerTypeProxy(typeof(CommandLineDebugView))]
public class CommandLine
{
private readonly Dictionary<string, IReadOnlyList<string>> _optionArgs;
private CommandLine(Dictionary<string, IReadOnlyList<string>> optionArgs)
=> _optionArgs = optionArgs ?? throw new ArgumentNullException(nameof(optionArgs));
[DebuggerBrowsable(DebuggerBrowsableState.Never)]
private string DebuggerDisplay => string.Join(' ', _optionArgs
.Select(pair => $"{pair.Key}{(pair.Key == null ? "" : " ")}{string.Join(' ', pair.Value)}"));
private class CommandLineDebugView
{
[DebuggerBrowsable(DebuggerBrowsableState.Never)]
private readonly CommandLine _owner;
public CommandLineDebugView(CommandLine owner) => _owner = owner;
[DebuggerBrowsable(DebuggerBrowsableState.RootHidden)]
private string[] Options => _owner._optionArgs
.Select(pair => $"{pair.Key}{(pair.Key == null ? "" : " ")}{string.Join(' ', pair.Value)}")
.ToArray();
}
}
}
参考资料
MSBuild 的编译过程提供了一些可以被重写的 Target,通过重写这些 Target 可以扩展 MSBuild 的编译过程。
有这些预定义的 Target 可以重写:
BeforeCompile
, AfterCompile
BeforeBuild
, AfterBuild
BeforeRebuild
, AfterRebuild
BeforeClean
, AfterClean
BeforePublish
, AfterPublish
BeforeResolveReference
, AfterResolveReferences
BeforeResGen
, AfterResGen
你可以在 Microsoft.NET.Sdk 中找到各种富有创意的 Target 用来扩展,以上这些也是 Microsoft.NET.Sdk 的一部分,在那个文件夹的 Microsoft.Common.targets 或者 Microsoft.Common.CurrentVersion.targets 中。
而写法是这样的:
<Project>
...
<Target Name="BeforeResGen">
<!-- 这里可以写在生成资源之前执行的 Task 或者修改属性和集合。 -->
</Target>
<Target Name="AfterCompile">
<!-- 这里可以写在 C# 文件以及各种资源文件编译之后执行的 Task 或者修改属性和集合。 -->
</Target>
</Project>
是的,相比于你全新定义一个 Target 来说,你不需要去写 BeforeTargets 或者 AfterTargets。
那么以上那些 Target 都是什么时机呢?
BeforeCompile
, AfterCompile
在 C# 文件以及各种资源文件被编译成 dll 的之前或之后执行。你可以在之前执行以便修改要编译的 C# 文件或者资源文件,你也可以在编译之后做一些其他的操作。
由于我们可以在 BeforeCompile 这个时机修改源码,所以我们很多关于代码级别的重新定义都可以在这个时机去完成。
BeforeBuild
, AfterBuild
在整个编译之前或者之后执行。对于普通的编译来说,一般来说不会有比 BeforeBuild
更前以及比 AfterBuild
更后的时机了,不过如果有其他 Import 进来的 Target 或者通过 NuGet 自动引入进来的其他 Target 也使用了类似这样的时机,那么你就不一定比他们更靠前或者靠后。
BeforeRebuild
, AfterRebuild
如果编译时采用了 /t:Rebuild
方案,也就是重新编译,那么 BeforeRebuild 和 AfterRebuild 就会被触发。一旦触发,会比前面更加提前和靠后。
执行顺序为:BeforeRebuild -> Clean -> Build -> AfterRebuild
BeforeClean
, AfterClean
在清理开始和结束时执行。如果是重新编译,那么也会有 Clean 的过程。顺序见上面。
BeforePublish
, AfterPublish
在发布之前执行和发布之后执行。对应到 Visual Studio 右键菜单中的发布按钮。
BeforeResolveReference
, AfterResolveReferences
在程序集的引用被解析之前和之后执行。你可以通过重写这两个时机的 Target 来修改程序集的引用关系或者利用引用执行一些其他操作。
BeforeResGen
, AfterResGen
在资源被生成之前和之后执行。
有这些预定义的 DependsOn 可以改写:
BuildDependsOn
CleanDependsOn
CompileDependsOn
这几个属性的时机跟上面是一样的,你可以直接通过阅读上面一节中对应名字的 Target 的解释来获得这几个属性所对应的时机。
而这几个属性影响编译过程的写法是这样的:
<PropertyGroup>
<BuildDependsOn>WalterlvDemoTarget1;$(BuildDependsOn);WalterlvDemoTarget1</BuildDependsOn>
</PropertyGroup>
<Target Name="WalterlvDemoTarget1">
<Message Text="正在运行 WalterlvDemoTarget1……"/>
</Target>
<Target Name="WalterlvDemoTarget1">
<Message Text="正在运行 WalterlvDemoTarget2……"/>
</Target>
更推荐使用 DependsOn
属性的改写而不是像本文第一节那样直接重写 Target,是因为一个 Target 的重写很容易被不同的开发小伙伴覆盖。比如一个小伙伴在一处代码里面写了一个 Target,但另一个小伙伴不知道,在另一个地方也写了相同名字的 Target,那么这两个 Target 也会相互覆盖,导致其中的一个失效。
虽然同名的属性跟 Target 一样的会被覆盖,但是我们可以通过在改写属性的值的时候同时获取这个属性之前设置的值,可以把以前的值保留下来。
正如上面的例子那样,我们通过写了两个新的 Target 的名字,分别叠加到 $(BuildDependsOn)
这个属性原有值的两边,使得我们可以在编译前后执行两个不同的 Target。如果有其他的小伙伴使用了相同的方式去改写这个属性的值,那么它获取原有值的时候就会把这里已经赋过的值放入到它新的值的中间。也就是说,一个也不会丢。
参考资料
在项目编译成 dll 之前,如何分析项目的所有依赖呢?可以在在项目的 Target 中去收集项目的依赖。
本文将说明如何在 Target 中收集项目依赖的所有 dll 的文件路径。
<Target Name="WalterlvDemoTarget" BeforeTargets="CoreCompile">
<Message Text="References:" />
<Message Text="@(Reference)" />
</Target>
这个 Target 的作用是将项目的所有 Reference
节点作为集合输出出来。然而实际上如果真的编译这个项目,会发现我们得到的结果有一些问题:
所以,我们需要一个新的属性来查找引用的 dll。通过 研究 Microsoft.NET.Sdk 的源码,我发现有 ReferencePath
属性可以使用,于是将 Target 改为这样:
<Target Name="WalterlvDemoTarget" BeforeTargets="CoreCompile;ResolveAssemblyReference">
<Message Text="ReferencePaths:" />
<Message Text="@(ReferencePath)" />
</Target>
现在得到的所有依赖字符串则没有以上的问题。
注意,我在 BeforeTargets
上增加了一个 ResolveAssemblyReference
。
引用通常很多,所以我将以上的输出单独放到这里来,避免影响到上面一节知识的阅读。
可以看到,Reference 的输出几乎就是 Reference 中写的字符串本身。
CefSharp, Version=57.0.0.0, Culture=neutral, PublicKeyToken=40c4b6fc221f4138, processorArchitecture=x86
CefSharp.Core, Version=57.0.0.0, Culture=neutral, PublicKeyToken=40c4b6fc221f4138, processorArchitecture=x86
CefSharp.WinForms, Version=57.0.0.0, Culture=neutral, PublicKeyToken=40c4b6fc221f4138, processorArchitecture=x86
Microsoft.Expression.Interactions, Version=4.5.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35, processorArchitecture=MSIL
System.IO.Compression.FileSystem
System.Windows.Interactivity, Version=4.5.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35, processorArchitecture=MSIL
WindowsFormsIntegration
C:\Users\walterlv\.nuget\packages\walterlv.demopackage\1.0.0.0\lib\net47\Walterlv.DemoPackageLibrary.dll
PresentationCore
System.ComponentModel.Composition
System.Configuration
System.Windows.Forms
WindowsBase
PresentationFramework
System.Xaml
System.ServiceModel
System
System.Data
System.Data.DataSetExtensions
System.Management
System.Net.Http
System.Runtime.Serialization
System.ServiceProcess
System.Web
System.Xml
System.Xml.Linq
System.Drawing
Microsoft.CSharp
System.Core
可以看到,ReferencePath 则是将所有的 dll 的路径也输出了,而且即便是项目引用,项目编译好的 dll 的路径也在。
D:\Walterlv\Demo\Walterlv.Demo\Code\_Externals\Refs\Cef\x86\CefSharp.Core.dll
D:\Walterlv\Demo\Walterlv.Demo\Code\_Externals\Refs\Cef\x86\CefSharp.dll
D:\Walterlv\Demo\Walterlv.Demo\Code\_Externals\Refs\Cef\x86\CefSharp.WinForms.dll
C:\Users\walterlv\.nuget\packages\walterlv.demopackage\1.0.0.0\lib\net47\Walterlv.DemoPackageLibrary.dll
D:\Walterlv\Demo\Walterlv.Demo\Walterlv.Library1\bin\Debug\Walterlv.Library1.dll
D:\Walterlv\Demo\Walterlv.Demo\Walterlv.Library2\bin\Debug\Walterlv.Library2.dll
D:\Walterlv\Demo\Walterlv.Demo\Walterlv.Library3\bin\Debug\Walterlv.Library3.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\Microsoft.CSharp.dll
D:\Walterlv\Demo\Walterlv.Demo\Code\_Externals\Refs\Microsoft.Expression.Interactions.dll
C:\Users\walterlv\.nuget\packages\windowsapicodepackshell\1.1.0.8\lib\NET45\Microsoft.WindowsAPICodePack.dll
C:\Users\walterlv\.nuget\packages\windowsapicodepackshell\1.1.0.8\lib\NET45\Microsoft.WindowsAPICodePack.Shell.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\mscorlib.dll
C:\Users\walterlv\.nuget\packages\newtonsoft.json\11.0.2\lib\net45\Newtonsoft.Json.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\PresentationCore.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\PresentationFramework.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\System.ComponentModel.Composition.dll
C:\Users\walterlv\.nuget\packages\system.composition.attributedmodel\1.0.31\lib\portable-net45+win8+wp8+wpa81\System.Composition.AttributedModel.dll
C:\Users\walterlv\.nuget\packages\system.composition.convention\1.0.31\lib\portable-net45+win8+wp8+wpa81\System.Composition.Convention.dll
C:\Users\walterlv\.nuget\packages\system.composition.hosting\1.0.31\lib\portable-net45+win8+wp8+wpa81\System.Composition.Hosting.dll
C:\Users\walterlv\.nuget\packages\system.composition.runtime\1.0.31\lib\portable-net45+win8+wp8+wpa81\System.Composition.Runtime.dll
C:\Users\walterlv\.nuget\packages\system.composition.typedparts\1.0.31\lib\portable-net45+win8+wp8+wpa81\System.Composition.TypedParts.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\System.Configuration.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\System.Core.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\System.Data.DataSetExtensions.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\System.Data.dll
C:\Users\walterlv\.nuget\packages\system.data.sqlite.core\1.0.97\lib\net45\System.Data.SQLite.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\System.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\System.Drawing.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\System.IO.Compression.FileSystem.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\System.Management.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\System.Net.Http.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\System.Runtime.Serialization.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\System.ServiceModel.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\System.ServiceProcess.dll
C:\Users\walterlv\.nuget\packages\system.valuetuple\4.5.0\ref\portable-net40+sl4+win8+wp8\System.ValueTuple.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\System.Web.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\System.Windows.Forms.dll
D:\Walterlv\Demo\Walterlv.Demo\Code\_Externals\Refs\System.Windows.Interactivity.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\System.Xaml.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\System.Xml.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\System.Xml.Linq.dll
C:\Users\walterlv\.nuget\packages\texteditorplus\1.0.0.903\lib\NET45\TextEditorPlus.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\WindowsBase.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\WindowsFormsIntegration.dll
C:\Users\walterlv\.nuget\packages\wpfmediakit\3.0.2.78\lib\NET45\WPFMediaKit.dll
obj\Debug\Interop.IWshRuntimeLibrary.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\Facades\System.Collections.Concurrent.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\Facades\System.Collections.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\Facades\System.ComponentModel.Annotations.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\Facades\System.ComponentModel.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\Facades\System.ComponentModel.EventBasedAsync.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\Facades\System.Diagnostics.Contracts.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\Facades\System.Diagnostics.Debug.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\Facades\System.Diagnostics.Tools.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\Facades\System.Diagnostics.Tracing.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\Facades\System.Dynamic.Runtime.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\Facades\System.Globalization.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\Facades\System.IO.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\Facades\System.Linq.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\Facades\System.Linq.Expressions.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\Facades\System.Linq.Parallel.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\Facades\System.Linq.Queryable.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\Facades\System.Net.NetworkInformation.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\Facades\System.Net.Primitives.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\Facades\System.Net.Requests.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\Facades\System.ObjectModel.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\Facades\System.Reflection.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\Facades\System.Reflection.Emit.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\Facades\System.Reflection.Emit.ILGeneration.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\Facades\System.Reflection.Emit.Lightweight.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\Facades\System.Reflection.Extensions.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\Facades\System.Reflection.Primitives.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\Facades\System.Resources.ResourceManager.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\Facades\System.Runtime.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\Facades\System.Runtime.Extensions.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\Facades\System.Runtime.InteropServices.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\Facades\System.Runtime.InteropServices.WindowsRuntime.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\Facades\System.Runtime.Numerics.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\Facades\System.Runtime.Serialization.Json.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\Facades\System.Runtime.Serialization.Primitives.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\Facades\System.Runtime.Serialization.Xml.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\Facades\System.Security.Principal.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\Facades\System.ServiceModel.Duplex.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\Facades\System.ServiceModel.Http.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\Facades\System.ServiceModel.NetTcp.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\Facades\System.ServiceModel.Primitives.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\Facades\System.ServiceModel.Security.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\Facades\System.Text.Encoding.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\Facades\System.Text.Encoding.Extensions.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\Facades\System.Text.RegularExpressions.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\Facades\System.Threading.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\Facades\System.Threading.Tasks.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\Facades\System.Threading.Tasks.Parallel.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\Facades\System.Xml.ReaderWriter.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\Facades\System.Xml.XDocument.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\Facades\System.Xml.XmlSerializer.dll
解析引用的 dll 的路径的 Task 是 ResolveAssemblyReference
,你可以在 Microsoft.NET.Sdk 文件夹 中找到它。如果想知道 Task 是什么意思,可以阅读:理解 C# 项目 csproj 文件格式的本质和编译流程。
<ResolveAssemblyReference
Assemblies="@(Reference)"
AssemblyFiles="@(_ResolvedProjectReferencePaths);@(_ExplicitReference)"
TargetFrameworkDirectories="@(_ReferenceInstalledAssemblyDirectory)"
InstalledAssemblyTables="@(InstalledAssemblyTables);@(RedistList)"
IgnoreDefaultInstalledAssemblyTables="$(IgnoreDefaultInstalledAssemblyTables)"
IgnoreDefaultInstalledAssemblySubsetTables="$(IgnoreInstalledAssemblySubsetTables)"
CandidateAssemblyFiles="@(Content);@(None)"
SearchPaths="$(AssemblySearchPaths)"
AllowedAssemblyExtensions="$(AllowedReferenceAssemblyFileExtensions)"
AllowedRelatedFileExtensions="$(AllowedReferenceRelatedFileExtensions)"
TargetProcessorArchitecture="$(ProcessorArchitecture)"
AppConfigFile="@(_ResolveAssemblyReferencesApplicationConfigFileForExes)"
AutoUnify="$(AutoUnifyAssemblyReferences)"
SupportsBindingRedirectGeneration="$(GenerateBindingRedirectsOutputType)"
IgnoreVersionForFrameworkReferences="$(IgnoreVersionForFrameworkReferences)"
FindDependencies="$(_FindDependencies)"
FindSatellites="$(BuildingProject)"
FindSerializationAssemblies="$(BuildingProject)"
FindRelatedFiles="$(BuildingProject)"
Silent="$(ResolveAssemblyReferencesSilent)"
TargetFrameworkVersion="$(TargetFrameworkVersion)"
TargetFrameworkMoniker="$(TargetFrameworkMoniker)"
TargetFrameworkMonikerDisplayName="$(TargetFrameworkMonikerDisplayName)"
TargetedRuntimeVersion="$(TargetedRuntimeVersion)"
StateFile="$(ResolveAssemblyReferencesStateFile)"
InstalledAssemblySubsetTables="@(InstalledAssemblySubsetTables)"
TargetFrameworkSubsets="@(_ReferenceInstalledAssemblySubsets)"
FullTargetFrameworkSubsetNames="$(FullReferenceAssemblyNames)"
FullFrameworkFolders="$(_FullFrameworkReferenceAssemblyPaths)"
FullFrameworkAssemblyTables="@(FullFrameworkAssemblyTables)"
ProfileName="$(TargetFrameworkProfile)"
LatestTargetFrameworkDirectories="@(LatestTargetFrameworkDirectories)"
CopyLocalDependenciesWhenParentReferenceInGac="$(CopyLocalDependenciesWhenParentReferenceInGac)"
DoNotCopyLocalIfInGac="$(DoNotCopyLocalIfInGac)"
ResolvedSDKReferences="@(ResolvedSDKReference)"
WarnOrErrorOnTargetArchitectureMismatch="$(ResolveAssemblyWarnOrErrorOnTargetArchitectureMismatch)"
IgnoreTargetFrameworkAttributeVersionMismatch ="$(ResolveAssemblyReferenceIgnoreTargetFrameworkAttributeVersionMismatch)"
FindDependenciesOfExternallyResolvedReferences="$(FindDependenciesOfExternallyResolvedReferences)"
ContinueOnError="$(ContinueOnError)"
Condition="'@(Reference)'!='' or '@(_ResolvedProjectReferencePaths)'!='' or '@(_ExplicitReference)' != ''"
>
<Output TaskParameter="ResolvedFiles" ItemName="ReferencePath"/>
<Output TaskParameter="ResolvedFiles" ItemName="_ResolveAssemblyReferenceResolvedFiles"/>
<Output TaskParameter="ResolvedDependencyFiles" ItemName="ReferenceDependencyPaths"/>
<Output TaskParameter="RelatedFiles" ItemName="_ReferenceRelatedPaths"/>
<Output TaskParameter="SatelliteFiles" ItemName="ReferenceSatellitePaths"/>
<Output TaskParameter="SerializationAssemblyFiles" ItemName="_ReferenceSerializationAssemblyPaths"/>
<Output TaskParameter="ScatterFiles" ItemName="_ReferenceScatterPaths"/>
<Output TaskParameter="CopyLocalFiles" ItemName="ReferenceCopyLocalPaths"/>
<Output TaskParameter="SuggestedRedirects" ItemName="SuggestedBindingRedirects"/>
<Output TaskParameter="FilesWritten" ItemName="FileWrites"/>
<Output TaskParameter="DependsOnSystemRuntime" PropertyName="DependsOnSystemRuntime"/>
<Output TaskParameter="DependsOnNETStandard" PropertyName="_DependsOnNETStandard"/>
</ResolveAssemblyReference>
从这个 Task 中可以看出,它还输出了以下这些属性或集合:
我之前写过一些改变 MSBuild 编译过程的一些博客,包括利用 Microsoft.NET.Sdk 中各种自带的 Task 来执行各种各样的编译任务。更复杂的任务难以直接利用自带的 Task 实现,需要自己写 Task。
本文将编写一个内联的编译任务,获取当前编译环境下的所有编译目标(Target)。获取所有的这些 Target 对我们调试一些与 MSBuild 或编译相关的问题时可能带来一些帮助。
编写纯 C# 版本编译任务获取所有编译目标(Target)的代码是这样的:
using Microsoft.Build.Evaluation;
using Microsoft.Build.Execution;
using Microsoft.Build.Utilities;
using Microsoft.Build.Framework;
public class WalterlvGetAllTargets : Task
{
public string ProjectFile { get; set; }
public ITaskItem[] WalterlvTargets { get; set; }
public override bool Execute()
{
var project = new Project(ProjectFile);
var taskItems = new List<ITaskItem>(project.Targets.Count);
foreach (KeyValuePair<string, ProjectTargetInstance> pair in project.Targets)
{
var target = pair.Value;
var metadata = new Dictionary<string, string>
{
{ "Condition", target.Condition },
{ "Inputs", target.Inputs },
{ "Outputs", target.Outputs },
{ "DependsOnTargets", target.DependsOnTargets }
};
taskItems.Add(new TaskItem(pair.Key, metadata));
}
WalterlvTargets = taskItems.ToArray();
return true;
}
}
那么转换成内联版本下面这样。为了方便验证,我直接把完整的 csproj 文件贴出来了。如果你希望在你的项目中去使用,可以只复制 UsingTask
和 Target
两个部分。
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net472</TargetFramework>
</PropertyGroup>
<UsingTask TaskName="WalterlvGetAllTargets" TaskFactory="CodeTaskFactory"
AssemblyFile="$(MSBuildToolsPath)\Microsoft.Build.Tasks.v4.0.dll" >
<ParameterGroup>
<!-- 内联 C# 代码的输入参数(Task 的输入属性),相当于 public string ProjectFile { get; set; } -->
<ProjectFile ParameterType="System.String" Required="true"/>
<!-- 内联 C# 代码的输出参数(Task 的输入属性),相当于 public ITaskItem[] WalterlvTargets { get; set; } -->
<WalterlvTargets ParameterType="Microsoft.Build.Framework.ITaskItem[]" Output="true"/>
</ParameterGroup>
<Task>
<!-- 引用程序集。 -->
<Reference Include="System.Xml"/>
<Reference Include="Microsoft.Build"/>
<Reference Include="Microsoft.Build.Framework"/>
<!-- 编写 C# 代码所用到的 using。 -->
<Using Namespace="Microsoft.Build.Evaluation"/>
<Using Namespace="Microsoft.Build.Execution"/>
<Using Namespace="Microsoft.Build.Utilities"/>
<Using Namespace="Microsoft.Build.Framework"/>
<!-- 开始插入 C# 代码。 -->
<Code Type="Fragment" Language="cs">
<![CDATA[
var project = new Project(ProjectFile);
var taskItems = new List<ITaskItem>(project.Targets.Count);
foreach (KeyValuePair<string, ProjectTargetInstance> pair in project.Targets)
{
var target = pair.Value;
var metadata = new Dictionary<string, string>
{
{ "Condition", target.Condition },
{ "Inputs", target.Inputs },
{ "Outputs", target.Outputs },
{ "DependsOnTargets", target.DependsOnTargets }
};
taskItems.Add(new TaskItem(pair.Key, metadata));
}
WalterlvTargets = taskItems.ToArray();
]]>
</Code>
</Task>
</UsingTask>
<Target Name="WalterlvOutputAllTargets" AfterTargets="Build">
<!-- 执行刚刚写的内联 Task,然后获取它的输出参数 WalterlvTargets 并填充到 TargetItems 集合中。 -->
<WalterlvGetAllTargets ProjectFile="$(MSBuildProjectFile)">
<Output ItemName="TargetItems" TaskParameter="WalterlvTargets"/>
</WalterlvGetAllTargets>
<!-- 用一个 Message 输出刚刚生成的 TargetItems 集合中每一项的 Identity 属性(集合中每一项都会输出。) -->
<Message Text="输出的 Target:%(TargetItems.Identity)"/>
</Target>
<Project>
现在使用 msbuild
命令进行编译,我们将看到所有 Target 的输出:
WalterlvOutputAllTargets:
输出的 Target:OutputAll
输出的 Target:_CheckForUnsupportedTargetFramework
输出的 Target:_CollectTargetFrameworkForTelemetry
输出的 Target:_CheckForUnsupportedNETCoreVersion
输出的 Target:_CheckForUnsupportedNETStandardVersion
输出的 Target:_CheckForUnsupportedAppHostUsage
输出的 Target:_CheckForMismatchingPlatform
输出的 Target:_CheckForNETCoreSdkIsPreview
输出的 Target:AdjustDefaultPlatformTargetForNetFrameworkExeWithNoNativeCopyLocalItems
输出的 Target:CreateManifestResourceNames
输出的 Target:ResolveCodeAnalysisRuleSet
输出的 Target:XamlPreCompile
输出的 Target:ShimReferencePathsWhenCommonTargetsDoesNotUnderstandReferenceAssemblies
输出的 Target:_BeforeVBCSCoreCompile
输出的 Target:InitializeSourceRootMappedPaths
输出的 Target:_InitializeSourceRootMappedPathsFromSourceControl
输出的 Target:_SetPathMapFromSourceRoots
输出的 Target:CoreCompile
输出的 Target:ResolvePackageDependenciesDesignTime
输出的 Target:CollectSDKReferencesDesignTime
输出的 Target:CollectResolvedSDKReferencesDesignTime
输出的 Target:CollectPackageReferences
输出的 Target:_CheckCompileDesignTimePrerequisite
输出的 Target:CollectAnalyzersDesignTime
输出的 Target:CollectResolvedCompilationReferencesDesignTime
输出的 Target:CollectUpToDateCheckInputDesignTime
输出的 Target:CollectUpToDateCheckOutputDesignTime
输出的 Target:CollectUpToDateCheckBuiltDesignTime
输出的 Target:CompileDesignTime
输出的 Target:_FixVCLibs120References
输出的 Target:_AddVCLibs140UniversalCrtDebugReference
输出的 Target:InitializeSourceControlInformation
输出的 Target:_CheckForInvalidConfigurationAndPlatform
输出的 Target:Build
输出的 Target:BeforeBuild
输出的 Target:AfterBuild
输出的 Target:CoreBuild
输出的 Target:Rebuild
输出的 Target:BeforeRebuild
输出的 Target:AfterRebuild
输出的 Target:BuildGenerateSources
输出的 Target:BuildGenerateSourcesTraverse
输出的 Target:BuildCompile
输出的 Target:BuildCompileTraverse
输出的 Target:BuildLink
输出的 Target:BuildLinkTraverse
输出的 Target:CopyRunEnvironmentFiles
输出的 Target:Run
输出的 Target:BuildOnlySettings
输出的 Target:PrepareForBuild
输出的 Target:GetFrameworkPaths
输出的 Target:GetReferenceAssemblyPaths
输出的 Target:GetTargetFrameworkMoniker
输出的 Target:GetTargetFrameworkMonikerDisplayName
输出的 Target:GetTargetFrameworkDirectories
输出的 Target:AssignLinkMetadata
输出的 Target:PreBuildEvent
输出的 Target:UnmanagedUnregistration
输出的 Target:GetTargetFrameworkVersion
输出的 Target:ResolveReferences
输出的 Target:BeforeResolveReferences
输出的 Target:AfterResolveReferences
输出的 Target:AssignProjectConfiguration
输出的 Target:_SplitProjectReferencesByFileExistence
输出的 Target:_GetProjectReferenceTargetFrameworkProperties
输出的 Target:GetTargetFrameworks
输出的 Target:GetTargetFrameworkProperties
输出的 Target:PrepareProjectReferences
输出的 Target:ResolveProjectReferences
输出的 Target:ResolveProjectReferencesDesignTime
输出的 Target:ExpandSDKReferencesDesignTime
输出的 Target:GetTargetPath
输出的 Target:GetTargetPathWithTargetPlatformMoniker
输出的 Target:GetNativeManifest
输出的 Target:ResolveNativeReferences
输出的 Target:ResolveAssemblyReferences
输出的 Target:FindReferenceAssembliesForReferences
输出的 Target:GenerateBindingRedirects
输出的 Target:GenerateBindingRedirectsUpdateAppConfig
输出的 Target:GetInstalledSDKLocations
输出的 Target:ResolveSDKReferences
输出的 Target:ResolveSDKReferencesDesignTime
输出的 Target:FindInvalidProjectReferences
输出的 Target:GetReferenceTargetPlatformMonikers
输出的 Target:ExpandSDKReferences
输出的 Target:ExportWindowsMDFile
输出的 Target:ResolveAssemblyReferencesDesignTime
输出的 Target:DesignTimeResolveAssemblyReferences
输出的 Target:ResolveComReferences
输出的 Target:ResolveComReferencesDesignTime
输出的 Target:PrepareResources
输出的 Target:PrepareResourceNames
输出的 Target:AssignTargetPaths
输出的 Target:GetItemTargetPaths
输出的 Target:SplitResourcesByCulture
输出的 Target:CreateCustomManifestResourceNames
输出的 Target:ResGen
输出的 Target:BeforeResGen
输出的 Target:AfterResGen
输出的 Target:CoreResGen
输出的 Target:CompileLicxFiles
输出的 Target:ResolveKeySource
输出的 Target:Compile
输出的 Target:_GenerateCompileInputs
输出的 Target:GenerateTargetFrameworkMonikerAttribute
输出的 Target:GenerateAdditionalSources
输出的 Target:BeforeCompile
输出的 Target:AfterCompile
输出的 Target:_TimeStampBeforeCompile
输出的 Target:_GenerateCompileDependencyCache
输出的 Target:_TimeStampAfterCompile
输出的 Target:_ComputeNonExistentFileProperty
输出的 Target:GenerateSerializationAssemblies
输出的 Target:CreateSatelliteAssemblies
输出的 Target:_GenerateSatelliteAssemblyInputs
输出的 Target:GenerateSatelliteAssemblies
输出的 Target:ComputeIntermediateSatelliteAssemblies
输出的 Target:SetWin32ManifestProperties
输出的 Target:_SetExternalWin32ManifestProperties
输出的 Target:_SetEmbeddedWin32ManifestProperties
输出的 Target:_GenerateResolvedDeploymentManifestEntryPoint
输出的 Target:GenerateManifests
输出的 Target:GenerateApplicationManifest
输出的 Target:_DeploymentComputeNativeManifestInfo
输出的 Target:_DeploymentComputeClickOnceManifestInfo
输出的 Target:_DeploymentGenerateTrustInfo
输出的 Target:GenerateDeploymentManifest
输出的 Target:PrepareForRun
输出的 Target:CopyFilesToOutputDirectory
输出的 Target:_CopyFilesMarkedCopyLocal
输出的 Target:_CopySourceItemsToOutputDirectory
输出的 Target:GetCopyToOutputDirectoryItems
输出的 Target:GetCopyToPublishDirectoryItems
输出的 Target:_CopyOutOfDateSourceItemsToOutputDirectory
输出的 Target:_CopyOutOfDateSourceItemsToOutputDirectoryAlways
输出的 Target:_CopyAppConfigFile
输出的 Target:_CopyManifestFiles
输出的 Target:_CheckForCompileOutputs
输出的 Target:_SGenCheckForOutputs
输出的 Target:UnmanagedRegistration
输出的 Target:IncrementalClean
输出的 Target:_CleanGetCurrentAndPriorFileWrites
输出的 Target:Clean
输出的 Target:BeforeClean
输出的 Target:AfterClean
输出的 Target:CleanReferencedProjects
输出的 Target:CoreClean
输出的 Target:_CleanRecordFileWrites
输出的 Target:CleanPublishFolder
输出的 Target:PostBuildEvent
输出的 Target:Publish
输出的 Target:_DeploymentUnpublishable
输出的 Target:SetGenerateManifests
输出的 Target:PublishOnly
输出的 Target:BeforePublish
输出的 Target:AfterPublish
输出的 Target:PublishBuild
输出的 Target:_CopyFilesToPublishFolder
输出的 Target:_DeploymentGenerateBootstrapper
输出的 Target:_DeploymentSignClickOnceDeployment
输出的 Target:AllProjectOutputGroups
输出的 Target:BuiltProjectOutputGroup
输出的 Target:DebugSymbolsProjectOutputGroup
输出的 Target:DocumentationProjectOutputGroup
输出的 Target:SatelliteDllsProjectOutputGroup
输出的 Target:SourceFilesProjectOutputGroup
输出的 Target:GetCompile
输出的 Target:ContentFilesProjectOutputGroup
输出的 Target:SGenFilesOutputGroup
输出的 Target:GetResolvedSDKReferences
输出的 Target:CollectReferencedNuGetPackages
输出的 Target:PriFilesOutputGroup
输出的 Target:SDKRedistOutputGroup
输出的 Target:AllProjectOutputGroupsDependencies
输出的 Target:BuiltProjectOutputGroupDependencies
输出的 Target:DebugSymbolsProjectOutputGroupDependencies
输出的 Target:SatelliteDllsProjectOutputGroupDependencies
输出的 Target:DocumentationProjectOutputGroupDependencies
输出的 Target:SGenFilesOutputGroupDependencies
输出的 Target:ReferenceCopyLocalPathsOutputGroup
输出的 Target:SetCABuildNativeEnvironmentVariables
输出的 Target:RunCodeAnalysis
输出的 Target:RunNativeCodeAnalysis
输出的 Target:RunSelectedFileNativeCodeAnalysis
输出的 Target:RunMergeNativeCodeAnalysis
输出的 Target:ImplicitlyExpandDesignTimeFacades
输出的 Target:GetWinFXPath
输出的 Target:DesignTimeMarkupCompilation
输出的 Target:PrepareResourcesForSatelliteAssemblies
输出的 Target:_AfterCompileWinFXInternal
输出的 Target:AfterCompileWinFX
输出的 Target:AfterMarkupCompilePass1
输出的 Target:AfterMarkupCompilePass2
输出的 Target:MarkupCompilePass1
输出的 Target:MarkupCompilePass2
输出的 Target:_CompileTemporaryAssembly
输出的 Target:MarkupCompilePass2ForMainAssembly
输出的 Target:GenerateTemporaryTargetAssembly
输出的 Target:CleanupTemporaryTargetAssembly
输出的 Target:AddIntermediateAssemblyToReferenceList
输出的 Target:SatelliteOnlyMarkupCompilePass2
输出的 Target:HostInBrowserValidation
输出的 Target:SplashScreenValidation
输出的 Target:ResignApplicationManifest
输出的 Target:SignDeploymentManifest
输出的 Target:FileClassification
输出的 Target:MainResourcesGeneration
输出的 Target:SatelliteResourceGeneration
输出的 Target:GenerateResourceWithCultureItem
输出的 Target:CheckUid
输出的 Target:UpdateUid
输出的 Target:RemoveUid
输出的 Target:MergeLocalizationDirectives
输出的 Target:AssignWinFXEmbeddedResource
输出的 Target:EntityDeploy
输出的 Target:EntityDeploySplit
输出的 Target:EntityDeployNonEmbeddedResources
输出的 Target:EntityDeployEmbeddedResources
输出的 Target:EntityClean
输出的 Target:EntityDeploySetLogicalNames
输出的 Target:DesignTimeXamlMarkupCompilation
输出的 Target:InProcessXamlMarkupCompilePass1
输出的 Target:CleanInProcessXamlGeneratedFiles
输出的 Target:XamlMarkupCompileReadGeneratedFileList
输出的 Target:XamlMarkupCompilePass1
输出的 Target:XamlMarkupCompileAddFilesGenerated
输出的 Target:XamlMarkupCompileReadPass2Flag
输出的 Target:XamlTemporaryAssemblyGeneration
输出的 Target:CompileTemporaryAssembly
输出的 Target:XamlMarkupCompilePass2
输出的 Target:XamlMarkupCompileAddExtensionFilesGenerated
输出的 Target:GetCopyToOutputDirectoryXamlAppDefs
输出的 Target:ExpressionBuildExtension
输出的 Target:ValidationExtension
输出的 Target:GenerateCompiledExpressionsTempFile
输出的 Target:AddDeferredValidationErrorsFileToFileWrites
输出的 Target:ReportValidationBuildExtensionErrors
输出的 Target:DeferredValidation
输出的 Target:ResolveTestReferences
输出的 Target:CleanAppxPackage
输出的 Target:GetPackagingOutputs
输出的 Target:Restore
输出的 Target:GenerateRestoreGraphFile
输出的 Target:_LoadRestoreGraphEntryPoints
输出的 Target:_FilterRestoreGraphProjectInputItems
输出的 Target:_GenerateRestoreGraph
输出的 Target:_GenerateRestoreGraphProjectEntry
输出的 Target:_GenerateRestoreSpecs
输出的 Target:_GenerateDotnetCliToolReferenceSpecs
输出的 Target:_GetProjectJsonPath
输出的 Target:_GetRestoreProjectStyle
输出的 Target:EnableIntermediateOutputPathMismatchWarning
输出的 Target:_GetRestoreTargetFrameworksOutput
输出的 Target:_GetRestoreTargetFrameworksAsItems
输出的 Target:_GetRestoreSettings
输出的 Target:_GetRestoreSettingsCurrentProject
输出的 Target:_GetRestoreSettingsAllFrameworks
输出的 Target:_GetRestoreSettingsPerFramework
输出的 Target:_GenerateRestoreProjectSpec
输出的 Target:_GenerateProjectRestoreGraph
输出的 Target:_GenerateRestoreDependencies
输出的 Target:_GenerateProjectRestoreGraphAllFrameworks
输出的 Target:_GenerateProjectRestoreGraphCurrentProject
输出的 Target:_GenerateProjectRestoreGraphPerFramework
输出的 Target:_GenerateRestoreProjectPathItemsCurrentProject
输出的 Target:_GenerateRestoreProjectPathItemsPerFramework
输出的 Target:_GenerateRestoreProjectPathItems
输出的 Target:_GenerateRestoreProjectPathItemsAllFrameworks
输出的 Target:_GenerateRestoreProjectPathWalk
输出的 Target:_GetAllRestoreProjectPathItems
输出的 Target:_GetRestoreSettingsOverrides
输出的 Target:_GetRestorePackagesPathOverride
输出的 Target:_GetRestoreSourcesOverride
输出的 Target:_GetRestoreFallbackFoldersOverride
输出的 Target:_IsProjectRestoreSupported
输出的 Target:DesktopBridgeCopyLocalOutputGroup
输出的 Target:DesktopBridgeComFilesOutputGroup
输出的 Target:GetDeployableContentReferenceOutputs
输出的 Target:DockerResolveAppType
输出的 Target:DockerUpdateComposeVsGeneratedFiles
输出的 Target:DockerResolveTargetFramework
输出的 Target:DockerComposeBuild
输出的 Target:DockerPackageService
输出的 Target:ImplicitlyExpandNETStandardFacades
输出的 Target:_RemoveZipFileSuggestedRedirect
输出的 Target:SetARM64AppxPackageInputsForInboxNetNative
输出的 Target:_CleanMdbFiles
输出的 Target:PreXsdCodeGen
输出的 Target:XsdCodeGen
输出的 Target:XsdResolveReferencePath
输出的 Target:CleanXsdCodeGen
输出的 Target:_SetTargetFrameworkMonikerAttribute
输出的 Target:ResolvePackageDependenciesForBuild
输出的 Target:RunResolvePackageDependencies
输出的 Target:ResolvePackageAssets
输出的 Target:FilterSatelliteResources
输出的 Target:RunProduceContentAssets
输出的 Target:ReportAssetsLogMessages
输出的 Target:ResolveLockFileReferences
输出的 Target:IncludeTransitiveProjectReferences
输出的 Target:ResolveLockFileAnalyzers
输出的 Target:_ComputeLockFileCopyLocal
输出的 Target:ResolveLockFileCopyLocalProjectDeps
输出的 Target:CheckForImplicitPackageReferenceOverrides
输出的 Target:CheckForDuplicateItems
输出的 Target:GenerateBuildDependencyFile
输出的 Target:GenerateBuildRuntimeConfigurationFiles
输出的 Target:AddRuntimeConfigFileToBuiltProjectOutputGroupOutput
输出的 Target:_SdkBeforeClean
输出的 Target:_SdkBeforeRebuild
输出的 Target:_ComputeNETCoreBuildOutputFiles
输出的 Target:_ComputeReferenceAssemblies
输出的 Target:CoreGenerateSatelliteAssemblies
输出的 Target:_GetAssemblyInfoFromTemplateFile
输出的 Target:_DefaultMicrosoftNETPlatformLibrary
输出的 Target:GetAllRuntimeIdentifiers
输出的 Target:GenerateAssemblyInfo
输出的 Target:AddSourceRevisionToInformationalVersion
输出的 Target:GetAssemblyAttributes
输出的 Target:CreateGeneratedAssemblyInfoInputsCacheFile
输出的 Target:CoreGenerateAssemblyInfo
输出的 Target:GetAssemblyVersion
输出的 Target:ComposeStore
输出的 Target:StoreWorkerMain
输出的 Target:StoreWorkerMapper
输出的 Target:StoreResolver
输出的 Target:StoreWorkerPerformWork
输出的 Target:StoreFinalizer
输出的 Target:_CopyResolvedOptimizedFiles
输出的 Target:PrepareForComposeStore
输出的 Target:PrepforRestoreForComposeStore
输出的 Target:RestoreForComposeStore
输出的 Target:ComputeAndCopyFilesToStoreDirectory
输出的 Target:CopyFilesToStoreDirectory
输出的 Target:_CopyResolvedUnOptimizedFiles
输出的 Target:_ComputeResolvedFilesToStoreTypes
输出的 Target:_SplitResolvedFiles
输出的 Target:_GetResolvedFilesToStore
输出的 Target:ComputeFilesToStore
输出的 Target:PrepRestoreForStoreProjects
输出的 Target:PrepOptimizer
输出的 Target:_RunOptimizer
输出的 Target:RunCrossGen
输出的 Target:_InitializeBasicProps
输出的 Target:_GetCrossgenProps
输出的 Target:_SetupStageForCrossgen
输出的 Target:_RestoreCrossgen
输出的 Target:_CheckForObsoleteDotNetCliToolReferences
输出的 Target:_PublishBuildAlternative
输出的 Target:_PublishNoBuildAlternative
输出的 Target:_PreventProjectReferencesFromBuilding
输出的 Target:PrepareForPublish
输出的 Target:ComputeAndCopyFilesToPublishDirectory
输出的 Target:CopyFilesToPublishDirectory
输出的 Target:_CopyResolvedFilesToPublishPreserveNewest
输出的 Target:_CopyResolvedFilesToPublishAlways
输出的 Target:_ComputeResolvedFilesToPublishTypes
输出的 Target:ComputeFilesToPublish
输出的 Target:_ComputeNetPublishAssets
输出的 Target:RunResolvePublishAssemblies
输出的 Target:FilterPublishSatelliteResources
输出的 Target:_ComputeCopyToPublishDirectoryItems
输出的 Target:DefaultCopyToPublishDirectoryMetadata
输出的 Target:GeneratePublishDependencyFile
输出的 Target:_ComputeExcludeFromPublishPackageReferences
输出的 Target:_ParseTargetManifestFiles
输出的 Target:GeneratePublishRuntimeConfigurationFile
输出的 Target:DeployAppHost
输出的 Target:PackTool
输出的 Target:GenerateToolsSettingsFileFromBuildProperty
输出的 Target:ResolveApphostAsset
输出的 Target:ComputeDependencyFileCompilerOptions
输出的 Target:ComputeRefAssembliesToPublish
输出的 Target:_CopyReferenceOnlyAssembliesForBuild
输出的 Target:_HandlePackageFileConflicts
输出的 Target:_HandlePublishFileConflicts
输出的 Target:_GetOutputItemsFromPack
输出的 Target:_GetTargetFrameworksOutput
输出的 Target:_PackAsBuildAfterTarget
输出的 Target:_CleanPackageFiles
输出的 Target:_CalculateInputsOutputsForPack
输出的 Target:Pack
输出的 Target:_IntermediatePack
输出的 Target:GenerateNuspec
输出的 Target:_InitializeNuspecRepositoryInformationProperties
输出的 Target:_LoadPackInputItems
输出的 Target:_GetProjectReferenceVersions
输出的 Target:_GetProjectVersion
输出的 Target:_WalkEachTargetPerFramework
输出的 Target:_GetFrameworksWithSuppressedDependencies
输出的 Target:_GetFrameworkAssemblyReferences
输出的 Target:_GetBuildOutputFilesWithTfm
输出的 Target:_GetTfmSpecificContentForPackage
输出的 Target:_GetDebugSymbolsWithTfm
输出的 Target:_AddPriFileToPackBuildOutput
输出的 Target:_GetPackageFiles
参考资料
我之前写过一些改变 MSBuild 编译过程的一些博客,包括利用 Microsoft.NET.Sdk 中各种自带的 Task 来执行各种各样的编译任务。更复杂的任务难以直接利用自带的 Task 实现,需要自己写 Task。
本文介绍非常简单的 Task 的编写方式 —— 在 csproj 文件中写内联的 Task。
在阅读本文之前,你至少需要懂得:
所以如果你不懂或者理不清,则请先阅读:
关于 Task 的理解,我有一些介绍自带 Task 的博客以及如何编写 Task 的教程:
如果你阅读了前面的博客,那么大致知道如何写一个在编译期间执行的 Task。不过,默认你需要编写一个额外的项目来写 Task,然后将这个项目生成 dll 供编译过程通过 UsingTask
来使用。然而如果 Task 足够简单,那么依然需要那么复杂的过程显然开发成本过高。
于是现在可以编写内联的 Task:
Microsoft.Build.Tasks.v4.0.dll
;<![CDATA[ ]]>
来内嵌 C# 代码;UsingTask
编写内联的 Task 外,我们需要额外编写一个 Target
来验证我们的内联 Task 能正常工作。下面是一个最简单的内联编译任务:
<Project Sdk="Microsoft.NET.Sdk">
<UsingTask TaskName="WalterlvDemoTask" TaskFactory="CodeTaskFactory"
AssemblyFile="$(MSBuildToolsPath)\Microsoft.Build.Tasks.v4.0.dll">
<Task>
<Code Type="Fragment" Language="cs">
<![CDATA[
Console.WriteLine("Hello Walterlv!");
]]>
</Code>
</Task>
</UsingTask>
<Project>
为了能够测试,我把完整的 csproj 文件贴出来:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net472</TargetFramework>
</PropertyGroup>
<UsingTask TaskName="WalterlvDemoTask" TaskFactory="CodeTaskFactory"
AssemblyFile="$(MSBuildToolsPath)\Microsoft.Build.Tasks.v4.0.dll">
<Task>
<Code Type="Fragment" Language="cs">
<![CDATA[
Console.WriteLine("Hello Walterlv!");
]]>
</Code>
</Task>
</UsingTask>
<Target Name="WalterlvDemoTarget" AfterTargets="Build">
<WalterlvDemoTask />
</Target>
</Project>
目前内联编译仅适用于 MSBuild,而 dotnet build
尚不支持。现在在项目目录输入命令进行编译,可以在输出窗口看到我们内联编译中的输出内容:
msbuild
阅读我的另一篇博客了解如何编写一个更复杂的内联编译任务:
大型旧项目可能存在大量的 Warning,在编译之后 Visual Studio 会给出大量的警告。Visual Studio 中可以直接点掉警告,然而如果是通过命令行 msbuild 编译的,那如何不要让警告输出呢?
在使用 msbuild 命令编译项目的时候,如果存在大量的警告,输出量会非常多。如果我们使用 msbuild 命令编译来定位项目的编译错误,那么这些警告将会导致我们准确查找错误的效率明显降低。
当然,这种问题的首选解决方案是 —— 真的修复掉这些警告!!!
那么可以用什么方式临时关闭 msbuild 命令编译时的警告呢?可以输入如下命令:
msbuild /p:WarningLevel=0
这样在调试编译问题的时候,因警告而造成的大量输出信息就会少很多。
不过需要注意的是,这种方式不会关闭所有的警告,实际上这关闭的是 csc 命令的警告(CS
开头)。关于 csc 命令的警告可以参见:-warn (C# Compiler Options) - Microsoft Docs。于是,如果项目中存在 msbuild 的警告(MSB
开头),此方法依然还会输出,只不过如果是为了调试编译问题,那么依然会方便很多,因为 MSB
开头的警告会少非常多。
关于警告等级:
0
关闭所有的警告。1
仅显示严重警告。2
显示 1 级的警告以及某些不太严重的警告,例如有关隐藏类成员的警告。3
显示级别 2 警告以及某些不太严重的警告,例如关于始终评估为 true
或 false
的表达式的警告。4
默认值 显示所有 3 级警告和普通信息警告。参考资料
本文介绍如何添加自定义的 NuGet 源。包括全局所有项目生效的 NuGet 源和仅在某些特定项目中生效的 NuGet 源。
你可以前往 我收集的各种公有 NuGet 源 以发现更多的 NuGet 源,然后使用本文的方法添加到你自己的配置中。
在使用命令行之前,你需要先在 https://www.nuget.org/downloads 下载最新的 nuget.exe 然后加入到环境变量中。
现在,我们使用命令行来添加一个包含各种日构建版本的 NuGet 源 MyGet:
nuget sources add -Name "MyGet" -Source "https://dotnet.myget.org/F/dotnet-core/api/v3/index.json"
如果你添加的只是一个镜像源(比如华为云 huaweicloud),那么其功能和官方源是重合的,可以禁用掉官方源:
nuget sources Disable -Name "nuget.org"
nuget sources add -Name "huaweicloud" -Source "https://mirrors.huaweicloud.com/repository/nuget/v3/index.json"
在 Visual Studio 中打开 工具
-> 选项
-> NuGet 包管理器
-> 包源
:
然后在界面上添加、删除、启用和禁用 NuGet 源。
值得注意的是:
nuget.org
的,无论你如何取消勾选,实际都不会生效。
NuGet 的全局配置文件在 %AppData\NuGet\NuGet.config
,例如:
C:\Users\lvyi\AppData\Roaming\NuGet\NuGet.Config
直接修改这个文件的效果跟使用命令行和 Visual Studio 的界面配置是等价的。
<configuration>
<packageSources>
<add key="huaweicloud" value="https://repo.huaweicloud.com/repository/nuget/v3/index.json" />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
<add key="Walterlv.Debug" value="C:\Users\lvyi\Debug\Walterlv.NuGet" />
<add key="Microsoft Visual Studio Offline Packages" value="C:\Program Files (x86)\Microsoft SDKs\NuGetPackages\" />
<add key="MyGet" value="https://dotnet.myget.org/F/dotnet-core/api/v3/index.json" />
</packageSources>
<disabledPackageSources>
<add key="Microsoft Visual Studio Offline Packages" value="true" />
<add key="Walterlv.Debug" value="true" />
<add key="nuget.org" value="true" />
</disabledPackageSources>
</configuration>
NuGet.config 文件是有优先级的。nuget.exe 会先把全局配置加载进来;然后从当前目录中寻找 NuGet.config 文件,如果没找到就去上一级目录找,一直找到驱动器的根目录;找到后添加到已经加载好的全局配置中成为一个合并的配置。
所以我们只需要在项目的根目录放一个 NuGet.config 文件并填写相比于全局 NuGet.config 新增的配置即可为单独的项目添加 NuGet 配置。
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<!-- 下一行的 clear 如果取消了注释,那么就会清除掉全局的 NuGet 源,而注释掉可以继承全局 NuGet 源,只是额外添加。 -->
<!-- <clear /> -->
<add key="MyGet" value="https://dotnet.myget.org/F/dotnet-core/api/v3/index.json" />
</packageSources>
</configuration>
当你的编写的是一个多进程的程序的时候,调试起来可能会比较困难,因为 Visual Studio 默认只会把你当前设置的启动项目的启动调试。
本文将介绍几种用 Visual Studio 调试多进程程序的方法,然后给出每种方法的适用条件和优劣。
在 Visual Studio 的解决方案上点击右键,属性。在公共属性节点中选择启动项目。
在这里,你可以给多个项目都设置成启动项目,就像下图这样:
当然,这些项目都必须要是能够启动的才行(不一定是可执行程序)。
此方案的好处是 Visual Studio 原生支持。但此方案的使用必须满足两个前提:
请先安装 Microsoft Child Process Debugging Power Tool 插件。
安装插件后启动 Visual Studio,可以在 Debug -> Other Debugging Targets 中找到 Child Process Debugging Settings。
然后你可以按照下图的设置开启此项目的子进程调试:
但是,子进程要能够调试,你还必须开启混合模式调试,开启方法请参见我的另一篇博客:在 Visual Studio 新旧不同的 csproj 项目格式中启用混合模式调试程序(开启本机代码调试) - walterlv。
现在,你只需要开始调试你的程序,那么你程序中启动的新的子进程都将可以自动加入调试。
现在,我们拿下面这段代码作为例子来尝试子进程的调试。下面的代码中,if
中的代码会运行在子进程中,而 else
中的代码会运行在主进程中。
using System;
using System.Diagnostics;
using System.Linq;
namespace Walterlv.Debugging
{
class Program
{
static void Main(string[] args)
{
if (args.Any())
{
Console.WriteLine("Walterlv child application");
Console.WriteLine(string.Join(Environment.NewLine, args));
Console.ReadLine();
}
else
{
Console.WriteLine("Walterlv main application");
var process = new Process
{
StartInfo = new ProcessStartInfo(Process.GetCurrentProcess().MainModule.FileName, "--child"),
};
process.Start();
process.WaitForExit();
}
}
}
}
我们在 if
和 else
中都打上断点。正常情况下运行,只有 else
中的代码可以进断点;而如果以上子进程调试配置正确,那么两边你都可以进入断点(如下图)。
值得注意的是,只要启动了本机代码调试,就不能在程序暂停之后修改代码了(像平时调试纯托管代码那样)。
调用 Debugger.Launch()
可以启动一个调试器来调试此进程。于是我们可以在我们被调试的程序中写下如下代码:
#if DEBUG
if (!Debugger.IsAttached)
{
Debugger.Launch();
}
#endif
仅在 DEBUG
条件下,如果当前没有附加任何调试器,那么就启动一个新的调试器来调试它。
当存在以上代码时,运行会弹出一个对话框,用于选择调试器。
这里选择的调试器有个不太方便的地方,如果调试器已经在使用,那么就不能选择。对于我们目前的场景,我们的主进程已经在调试了,所以子进程选择调试器的时候不能再选择主进程调试所用的 Visual Studio 了,而只能选择一个新的 Visual Studio;这一点很不方便。
对于此方法,我的建议是平常不要在团队项目中使用(这会让团队中的其他人不方便)。但是由于代码简单不需要配置,所以临时使用的话还是非常建议的。
编写中……
综上,虽然我给出了 4 种不同的方法,但实际上没有任何一种方法能够像我们调试单个原生托管程序那样方便。每一种方法都各有优劣,一般情况下建议你使用我标注了“推荐”的方法;不过也建议针对不同的情况采用不同的方案。
参考资料
我们有时候会使用解决方案的清理(Clean)功能来解决一些项目编译过程中非常诡异的问题。这通常是一些 Target 生成了一些错误的中间文件,但又不知道到底是哪里错了。
我们自己编写 Target 的时候,也可能会遇到这样的问题,所以让我们自己的 Target 也能支持 Clean 可以在遇到诡异问题的时候,用户可以自己通过清理解决方案来消除错误。
以下代码来自于 SourceFusion/Package.targets。这是我主导开发的一个预编译框架,用于在编译期间执行各种代码,以便优化代码的运行期性能。
<PropertyGroup>
<CleanDependsOn>$(CleanDependsOn);_SourceFusionClean</CleanDependsOn>
</PropertyGroup>
<!--清理 SourceFusion 计算所得的文件-->
<Target Name="_SourceFusionClean">
<PropertyGroup>
<_DefaultSourceFusionWorkingFolder Condition="'$(_DefaultSourceFusionWorkingFolder)' == ''">obj\$(Configuration)\</_DefaultSourceFusionWorkingFolder>
<SourceFusionWorkingFolder Condition="'$(SourceFusionWorkingFolder)' == ''">$(_DefaultSourceFusionWorkingFolder)</SourceFusionWorkingFolder>
<SourceFusionToolsFolder>$(SourceFusionWorkingFolder)SourceFusion.Tools\</SourceFusionToolsFolder>
<SourceFusionGeneratedCodeFolder>$(SourceFusionWorkingFolder)SourceFusion.GeneratedCodes\</SourceFusionGeneratedCodeFolder>
</PropertyGroup>
<RemoveDir Directories="$(SourceFusionToolsFolder);$(SourceFusionGeneratedCodeFolder)" />
</Target>
这段代码的作用便是支持 Visual Studio 中的解决方案清理功能。通过指定 CleanDependsOn
属性的值给一个新的 Target,使得在 Clean 的时候,这个 Target 能够执行。我在 Target 中删除了我生成的所有中间文件。
你可以通过阅读 通过重写预定义的 Target 来扩展 MSBuild / Visual Studio 的编译过程 来了解这个 Target 是如何工作起来的。
参考资料
自从微软推出 .NET Core 以来,新的项目文件格式以其优秀的可扩展性正吸引着更多项目采用。然而——微软官方的 WPF/UWP 项目模板依然还在采用旧的 csproj 格式!
这只是因为——官方 SDK 依然对 WPF/UWP 支持不够友好。
关于项目文件格式的迁移,我和 林德熙 都写过文章:
不过,这两篇文章中的迁移方法都是手动或半自动迁移的。而且迁移完毕之后,对新增的 WPF/UWP XAML 文件的支持非常不友好——新增的 XAML 文件是看不见的,除非手工去 csproj 文件中去掉自动生成的 Remove XAML 的代码。
这确实阻碍着我们在 WPF/UWP 项目中体会到新风格 csproj 的好处。
微软在 Build 2018 大会上宣布,WPF/UWP 将能够在 .NET Core 3 中运行。想必,微软会为未来版本的 Microsoft.NET.Sdk 这样的官方 SDK 添加更多的 WPF/UWP 这类格式的支持吧!即便没有这样的原生支持,想必也会提供官方的扩展方案。
但在此之前呢?感谢小伙伴 KodamaSakuno (神樹桜乃) 提醒我第三方 SDK 的存在 —— MSBuild.Sdk.Extras。我想,在 .NET Core 3 推出之前,这是一种不错的中转方案。既能体会到新风格 csproj 格式的好处,也能在将来 .NET Core 3 官方支持后较快地迁移成官方版本。
虽说是第三方 SDK,但实际使用的方便程度却如官方般简洁!只需要将 SDK 替换成 MSBuild.Sdk.Extras/1.5.4
即可。1.5.4 是目前 MSBuild.Sdk.Extras 在 NuGet 上的最新版本,建议访问 NuGet Gallery - MSBuild.Sdk.Extras 使用最新稳定版本。
以下是最简同时支持 WPF 和 UWP 双框架的代码:
<Project Sdk="MSBuild.Sdk.Extras/1.5.4">
<PropertyGroup>
<TargetFrameworks>net47;uap10.0</TargetFrameworks>
</PropertyGroup>
</Project>
没错,真的如此简单!在我们猜测的 .NET Core 3 支持 WPF/UWP 项目格式之前,这应该算是最简单的迁移方案了!
至于项目结构的效果,可以看下图所示:
相比于此前的手工迁移,使用此新格式创建出来的 XAML 文件是可见的,而且 .xaml.cs 也是折叠在 .xaml 之下,且能正常编译!(当然,咱们还得考虑 UWP 和 WPF 在 XAML 书写上的细微差异)
官方提供了更多的使用方法,例如更简单的是安装 NuGet 包,而不修改 SDK。详见:onovotny/MSBuildSdkExtras: Extra properties for MSBuild SDK projects。
我之前写过一篇 理解 C# 项目 csproj 文件格式的本质和编译流程,其中,Target
节点就是负责编译流程的最关键的节点。但因为篇幅限制,那篇文章不便详说。于是,我在本文说说 Target
节点。
<Target>
内部几乎有着跟 <Project>
一样的节点结构,内部也可以放 PropertyGroup
和 ItemGroup
,不过还能放更加厉害的 Task
。按照惯例,我依然用思维导图将节点结构进行了总结:
▲ 上面有绿线和蓝线区分,仅仅是因为出现了交叉,怕出现理解歧义
<Hash>
和 <WriteCodeFragment>
都是 Task
。我们可以看到,Task
是多种多样的,它可以占用一个 xml 节点。而本例中,WriteCodeFragment
Task 就是生成代码文件,并且将生成的文件作为一项 Compile
的 Item 和 FileWrites
的 Item。在 理解 C# 项目 csproj 文件格式的本质和编译流程 中我们提到 ItemGroup
的节点,其作用由 Target
指定。所有 Compile
会在名为 CoreCompile
的 Target
中使用,而 FileWrites
在 Microsoft.NET.Sdk 的多处都生成了这样的节点,不过目前从我查看到的全部 Microsoft.NET.Sdk 中,发现内部并没有使用它。
既然 <Target>
内部节点很大部分跟 <Project>
一样,那区别在哪里呢?<Project>
里的 <PropertyGroup>
和 <ItemGroup>
是静态的状态,如果使用 Visual Studio 打开项目,那么所有的状态将会直接在 Visual Studio 的项目文件列表和项目属性中显示;而 <Target>
内部的 <PropertyGroup>
和 <ItemGroup>
是在编译期间动态生成的,不会在 Visual Studio 中显示;不过,它为我们提供了一种在编译期间动态生成文件或属性的能力。
总结起来就是——Target 是在编译期间执行的。
不过,同样是编译期间,那么多个 Target
,它们之间的执行时机是怎么确定的呢?
一共有五个属性决定了 Target 之间的执行顺序:
InitialTargets
项目初始化的时候应该执行的 TargetDefaultTargets
如果没有指定执行的 Target,那么这个属性将指定执行的 TargetDependsOnTargets
在执行此 Target 之前应该执行的另一个或多个 TargetBeforeTargets
这是 MSBuild 4.0 新增的,指定应该在另一个或多个 Target 之前执行AfterTargets
这也是 MSBuild 4.0 新增的,指定应该在另一个或多个 Target 之后执行通过指定这些属性,我们的 Target
能够被 MSBuild 自动选择合适的顺序进行执行。例如,当我们希望自定义版本号,那么就需要赶在我们此前提到的 GenerateAssemblyInfo
之前执行。
有 Microsoft.NET.Sdk 的帮助,我们可以很容易地编写自己的 Target,因为很多功能它都帮我们实现好了,我们排列组合一下就好。
Copy
复制文件 Copy TaskMove
移动文件 Move TaskDelete
删除文件Message
显示一个输出信息(我在 如何创建一个基于 MSBuild Task 的跨平台的 NuGet 工具包 中利用这个进行调试)Warning
显示一个警告信息Error
报错(这样,编译就会以错误结束)CombinePath
, ConvertToAbsolutePath
拼接路径,转成绝对路径CreateItem
, CreateProperty
创建项或者属性Csc
调用 csc.exe 编译 Csc TaskMSBuild
编译一个项目 MSBuild TaskExec
执行一个外部命令(我在 如何创建一个基于命令行工具的跨平台的 NuGet 工具包 一文中利用到了这个 Task 执行命令)WriteCodeFragment
生成一段代码 WriteCodeFragment TaskWriteLinesToFile
向文件中写文字 WriteLinesToFile Task提供的 Task 还有更多,如果上面不够你写出想要的功能,可以移步至官方文档翻阅:MSBuild Task Reference - Visual Studio - Microsoft Docs。
我有另外的一篇文章来介绍如何创建一个基于 MSBuild Task 的跨平台的 NuGet 工具包 - 吕毅。如果希望自己写 Ta
如果你认为自己写的 Target
执行比较耗时,那么就可以使用差量编译。我另写了一篇文章专门来说 Target 的差量编译:每次都要重新编译?太慢!让跨平台的 MSBuild/dotnet build 的 Target 支持差量编译 - 吕毅。