dotnet 职业技术学院

Visual Studio

dotnet 职业技术学院

| visualstudio

按类别查找文章:Visual Studio


uwp dotnet dotnet-core dotnet-standard csharp 技术 gif解析 wpf windows C# WPF D2D DirectX UWP DirectComposition win2d SharpDX VisualStudio dotnetremoting rpc Win10 编程 slack web team msbuild linux xamarin ios resharper git nuget xaml algorithm powershell ime vscode directx roslyn markdown ui ux miscellaneous cpp sharpdx algorithms JavaScript Unicode c# 工具 系统 架构 编译 数学 几何 C#入门 原理 Powershell 性能测试 命令行 asp aspdotnetcore dotnetcore 控制台 WMI .net framework .net源代码 源代码分析 Latex C 算法 Emit Roslyn MSBuild 编译器 调试 渲染 Resharper 打包 Pandoc PowerShell VisualStudio插件 TotalCommander 软件 Jenkins gif await 安装包 InnoSetup 触摸 WPF调试 图片处理 黑科技 UI sublimetext usb 笔迹 输入法 数据库 sqlite Framework dotnetframework remoting 布局 mvvm frp Avalonia 设计规范 规范 反射 jekyll DevOps AzureDevOps 源代码 多线程 VisualStudio调试 doevents 性能优化 水印 uwp文件 pip python 软件设计 文档 docfx 资源分享 p2p 爬虫 SublimeText AE dotpeek 反编译 btsync pandoc Telegram 聊天软件 微信 P2P PPT v8 .NET JVM Direct2D MobaXterm 代理 ssh vps 代理服务器 mock 单元测试 NuGet dnc 进程通信 IPC pipe ScrollViewer WPF源代码 ink dotnettool tool Github GithubAction Diagnostics DUMP Xamarin GTK
2020
03-05 2020

2020-3-5-C#代码生成之T4模板组成

我们在2020-3-3-使用T4模板进行C#代码生成 - huangtengxiao介绍了C#使用T4模板生成代码。

今天给大家详细介绍下T4模板的组成


T4模板的组成部分

T4模板的组成部分如图所示。主要由文本部分,以及非文本部分的指令(Directives)和控制块(Control blocks)组成。

image-20200305112256219

下面是一个T4模板示例以及最终的生成效果。

image-20200305114021532

image-20200305114119830

文本

文本是直接在生成文件中输出的内容。

所有的文本元素包括空格和缩进都会原封不动的输出到生成文件中。

指令块

指令块由<#@ #> 包裹。主要用于控制模板的处理方式。

例如<#@ assembly name=”System.Core” #> 这句指令,能够在处理模板时,引用System.Core程序集。

这样我们就可以在模板的控制块中,使用System.Core程序集中所包含的方法。

标准控制块

标准控制块由<# #> 包裹。主要用于表示一整段处理代码。

有了标准控制块,我们就可以利用诸如分支,选择等逻辑,对生成的代码进行控制。

表达式控制块

表达式控制块由<#= #> 包裹。

当我们期望将一段表达式,或者某个变量的值,插入生成文本中,就可以使用表达式控制块。

这给了我们利用输入内容生成代码的能力。

类功能控制块

类功能块由<#+ #> 包裹。他表示一些辅助方法。

例如我们这里定义了一个Foo方法返回一个数值的平方。

这可以大大减少重复代码的书写。

不过需要注意的是,类功能控制块只能够写在模板的末尾。


03-03 2020

2020-3-3-使用T4模板进行C#代码生成

有过前端开发经验的同学一定了解模板文件的重要用户。其实C#也有类似的模板功能(T4模板),不仅可以生成html文件,还可以生成代码。今天就给大家介绍一下。


什么是T4模板

T4模板全称是Text Template Transformation Toolkit,因为四个单词的开头字母都是T,所以称作T4模板。

T4模板是一种支持C#或者VB代码开发的模板格式,已经在Visual Studio,MonoDevelop,Rider这些主流IDE中得到支持。

T4不仅能支持在运行时动态生成Html网页这种常见需求,而且还可以在设计时生成各种语言的代码(不仅仅是C#),xaml,xml等以便于提升开发效率。

使用visualstudio创建一个T4模板

我们在项目上右键选择添加新项,在弹出的界面中搜索T4,可以得到两个结果。分别是文本模板(设计时T4模板)和运行时文本模板(运行时T4模板)。前者可以在开发时期或者编译时期生成,后者只能在运行时调用API生成。这里我们先选择文本模板。

image-20200303192301497

这时我们在项目内就多了一个后缀为tt的模板文件。

image-20200303193021735

我们把下面这段内容粘贴进去。注意如果是第一次使用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文件,其中的代码就是我们刚刚粘贴的内容。

image-20200303193447359

更重要的是,生成的代码就在这个项目中,可以直接使用。

image-20200303193634749

使用代码辅助生成

光是生成静态文件肯定没啥意思,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的值转为字符串填充至模板的生成文件中。

结果如下

image-20200303195114635

值得注意的是,这里的i+1输出随着循环进行而更新。这说明所在的模板中的代码块都隶属于同一个上下文。

可以实现变量的传递。

More

至此相信你已经可以使用T4模板完成基本的代码生成功能开发了。当然本文作为入门介绍还有很多细节没有介绍。这里可以在微软的官方文档中找到更加详细的介绍:Writing a T4 Text Template - Visual Studio -Microsoft Docs

当然也可以关注我之后关于T4模板的系列博客。


参考文档:

2019
11-29 2019

C# 8.0 如何在项目中开启可空引用类型的支持

C# 8.0 引入了可为空引用类型和不可为空引用类型。由于这是语法级别的支持,所以比传统的契约式编程具有更强的约束力。更容易帮助我们消灭 null 异常。

本文将介绍如何在项目中开启 C# 8.0 的可空引用类型的支持。


使用 Sdk 风格的项目文件

如果你还在使用旧的项目文件,请先升级成 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
    • 所有引用类型均被视为不可为空,启用所有 null 相关的警告。
  • warnings
    • 不会判定类型是否可空或不可为空,但启用局部范围内的 null 相关的警告。
  • annotations
    • 所有引用类型均被视为不可为空,但关闭 null 相关的警告。
  • disable
    • 与 8.0 之前的 C# 行为相同,即既不认为类型不可为空,也不启用 null 相关的警告。

这五个值其实是两个不同维度的设置排列组合之后的结果:

  • 可为空注释上下文
    • 用于告知编译器是否要识别一个类型的引用可为空或者不可为空。
  • 可为空警告上下文
    • 用于告知编译器是否要启用 null 相关的警告,以及警告的级别。

当仅仅启用警告上下文而不开启可为空注释上下文,那么编译器将仅仅识别局部变量中明显可以判定出对 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>

详见:

可为空注释(Annotation)上下文

当启动可为空注释上下文后,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 支持

ReSharper 从 2019.1.1 版本开始支持 C# 8.0,如果使用早期版本,就会到处报错。

但是,由于 C# 8.0 可空引用类型的特性总在变,所以建议使用 2019.2.3 或以上版本,这是 C# 8.0 正式版本发布之后的 ReSharper。


参考资料

11-24 2019

C# 8.0 可空引用类型中的各项警告/错误的含义和示例代码

C# 8.0 引入了可为空引用类型和不可为空引用类型。当你需要给你或者团队更严格的要求时,可能需要定义这部分的警告和错误级别。

本文将介绍 C# 可空引用类型部分的警告和错误提示,便于进行个人项目或者团队项目的配置。


开启可空引用类型以及配置警告和错误

本文的内容本身没什么意义,但如果你试图进行一些团队配置,那么本文的示例可能能带来一些帮助。

警告和错误

CS8600

将 null 文本或可能的 null 值转换为非 null 类型。

string walterlv = null;

CS8600

CS8601

可能的 null 引用赋值。

string Text { get; set; }

void Foo(string? text)
{
    // 将可能为 null 的文本向不可为 null 的类型赋值。
    Text = text;
}

CS8602

null 引用可能的取消引用。

// 当编译器判定 walterlv 可能为 null 时才会有此警告。
var value = walterlv.ToString();

CS8602

CS8603

可能的 null 引用返回。

string Foo()
{
    return null;
}

CS8603

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

CS8625

CS8653

对于泛型 T,使用 default 设置其值。如果 T 是引用类型,那么 default 就会将这个泛型类型赋值为 null。然而并没有将泛型 T 的使用写为 T?。

10-29 2019

不要在 C# 代码中写部分命名空间(要么不写,要么写全),否则会有源码兼容性问题

我只是增加库的一个 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 类型也将无法在不确定的命名空间中找到。

因此:

  1. 强烈建议遵守 使用类型的时候,要么不写命名空间(完全留给 using),要么写全命名空间(从第一段开始写,不要省略任何部分),否则就容易与其他命名空间冲突;
  2. 可选遵守 在库中新增 API 的时候,可能需要考虑避免将部分命名空间写成过于通用的名称。

是的,即使是单纯的新增 API 也可能会导致使用库的一方在源码级不兼容。当然二进制还是兼容的。

另外,OpportunityLiu 提醒,如果命名空间是 Walterlv.B.Walterlv.A.Diagnostics.Bar,一样可以让写全了的命名空间炸掉。呃……还是不要在库里面折腾这样的命名空间好……不然代码当中到处充斥着 global:: 可是非常难受的。

09-27 2019

推荐几款连字字体,在代码编辑器中启用连字字体(Visual Studio Code)

启用转为编程设计的连字字体,可以给你的变成带来不一样的体验。


连字字体

微软随 Windows Terminal 设计了一款新的字体 Cascadia Code,而这是一款连字字体。

你可以看到,在 Windows Terminal 的终端中,=> == != 符号显示成了更容易理解的连字符号:

Cascadia Code

在 Cascadia Code 发布之前,Fira Code 是一款特别火的连字字体,下面是 Fira Code 连字字体在 Visual Studio Code 中的显示效果:

Fira Code in 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

下载解压后,你会看到五个不同的文件夹,这是四种不同的字体类型:

  • otf (Open Type)
  • ttf (True Type)
  • variable_ttf (Variable True Type)
  • woff (Web Open Font Format)
  • woff2 (Web Open Font Format)

对于 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 中启用

在 Visual Studio Code 中启用连字字体需要用到两个选项:

"editor.fontFamily": "Fira Code Light, Consolas, Microsoft YaHei",
"editor.fontLigatures": true,

打开 Visual Studio Code 设置

然后点击新打开的标签右上角的 {} 图标以打开 json 形式编辑的设置:

使用 json 编辑设置

然后修改把上面两个设置增加或替换进去即可。下面是我的设置的部分截图:

设置启用连字字体

在 Visual Studio 或其他 Windows 系统自带软件中启用

只需要将字体设置成 Fira Code 即可。


参考资料

09-24 2019

如何在 Visual Studio 2019 中设置使用 .NET Core SDK 的预览版(全局生效)

.NET Core 3 相比于 .NET Core 2 是一个大更新。也正因为如此,即便它长时间处于预览版尚未发布的状态,大家也一直在使用。

Visual Studio 2019 中提供了使用 .NET Core SDK 预览版的开关。但几个更新的版本其开关的位置不同,本文将介绍在各个版本中的位置,方便你找到然后设置。


Visual Studio 2019 (16.3 及以上)

.NET Core 3.0 已经发布,下载地址:

Visual Studio 16.3 与 .NET Core 3.0 正式版同步发布,因此不再需要 .NET Core 3.0 的预览版设置界面。你只需要安装正式版 .NET Core SDK 即可。

Visual Studio 2019 (16.2)

从 Visual Studio 2019 的 16.2 版本,.NET Core 预览版的设置项的位置在:

  • 工具 -> 选项
  • 环境 -> 预览功能 -> Use previews of the .NET Core SDK (需要 restart)

Visual Studio 2019 16.2 的设置位置

如果你是英文版的 Visual Studio,也可以参考英文版:

  • Tools -> Options
  • Environment -> Preview Features -> Use previews of the .NET Core SDK (requires restart)

Option location of Visual Studio 2019 16.2

Visual Studio 2019 (16.1)

从 Visual Studio 2019 的 16.1 版本,.NET Core 预览版的设置项的位置在:

  • 工具 -> 选项
  • 环境 -> 预览功能 -> 使用 .NET Core SDK 的预览

Visual Studio 2019 16.1 的设置位置

如果你是英文版的 Visual Studio,也可以参考英文版:

  • Tools -> Options
  • Environment -> Preview Features -> Use previews of the .NET Core SDK

Option location of Visual Studio 2019 16.1

Visual Studio 2019 (16.0 和早期预览版)

在 Visual Studio 2019 的早期,.NET Core 在设置中是有一个专用的选项的,在这里:

  • 工具 -> 选项
  • 项目和解决方案 -> .NET Core -> 使用 .NET Core SDK 预览版

Visual Studio 2019 16.0 的设置位置

如果你是英文版的 Visual Studio,也可以参考英文版:

  • Tools -> Options
  • Projects and solutions -> .NET Core -> Use previews of the .NET Core SDK

Option location of Visual Studio 2019 16.0

关于全局配置

Visual Studio 2019 中此对于 .NET Core SDK 的预览版的设置是全局生效的。

也就是说,你在 Visual Studio 2019 中进行了此设置,在命令行中使用 MSBuild 或者 dotnet build 命令进行编译也会使用这样的设置项。

那么这个全局的设置项在哪个地方呢?是如何全局生效的呢?可以阅读我的其他博客:

09-07 2019

提高使用 Visual Studio 开发效率的键盘快捷键

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  

万能重构

你可以不记住本文的其他任何快捷键,但这个你一定要记住,那就是:

Ctrl + .

当然,因为中文输入法会占用这个快捷键,所以我更喜欢将这个快捷键修改一下,改成:

Alt + Enter

修改方法可以参见:如何快速自定义 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 自动完成列表

智能感知

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 + UCtrl + 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 切换自动换行和单行模式

显示空白字符

08-29 2019

通过设置启用 Visual Studio 默认关闭的大量强大的功能提升开发效率

使用 Visual Studio 开发 C#/.NET 应用程序,以前有 ReSharper 来不足其各项功能短板,后来不断将 ReSharper 的功能一点点搬过来稍微好了一些。不过直到 Visual Studio 2019,才开始渐渐可以和 ReSharper 拼一下了。

如果你使用 Visual Studio 2019,那么像本文这样配置一下,可以大大提升你的开发效率。


工具选项

打开菜单 “工具” -> “选项”,然后你就打开了 Visual Studio 的选项窗口。接下来本文的所有内容都会在这里进行。

打开选项窗口

文本编辑器

在 “文本编辑器” -> “常规” 分类中,我们关心这些设置:

  • 使鼠标单击可执行转到定义 这样按住 Ctrl 键点击标识符的时候可以转到定义(开启此选项之后,后面有其他选项可以转到反编译后的源码)

文本编辑器 -> 常规

当然也有其他可以打开玩的:

  • 查看空白 专治强迫症,可以把空白字符都显示出来,这样你可以轻易看到对齐问题以及多于的空格了

C#

在 “文本编辑器” -> “C#” -> “IntelliSense” 分类中,我们关心这些设置:

  • 键入字符后显示完成列表 删除字符后显示完成列表 突出显示完成列表项的匹配部分 显示完成项筛选器 打开这些选项可以让智能感知列表更容易显示出来,而我们也知道智能感知列表的强大
  • 推荐 显示 unimported 命名空间中的项(实验) 这一项默认不会勾选,但强烈建议勾选上;它可以帮助我们直接输入没有 using 的命名空间中的类型,这可以避免记住大量记不住的类名

IntelliSense

C# 高级

在 “文本编辑器” -> “C#” -> “高级” 分类中,我们关心大量设置:

  • 支持导航到反编译源(实验) 前面我们说可以 Ctrl + 鼠标导航到定义,如果打开了这个就可以看反编译后的源码了
  • 启用可为 null 的引用分析 IDE 功能 这个功能可能还没有完成,暂时还是无法开启的

高级

当然也有其他可以打开玩的:

  • 启用完成解决方案分析 这是基于 Roslyn 的分析,Visual Studio 的大量重构功能都依赖于它;默认关闭也可以用,只是仅分析当前正在编辑的文件;如果打开则分析整个解决方案,你会在错误列表中看到大量的编译警告

代码样式

在 “文本编辑器” -> “C#” -> “代码样式” 分类,如果你关心代码的书写风格,那么这个分类底下的每一个子类别都可以考虑一个个检查一下。

代码样式

人工智能 IntelliCode

Visual Studio 2019 默认安装了 IntelliCode 可以充分利用微软使用 GitHub 上开源项目训练出来的模型来帮助编写代码。这些强烈建议开启。

  • C# 基础模型 微软利用 GitHub 开源项目训练的基础模型
  • XAML 基础模型 微软利用 GitHub 开源项目训练的基础模型
  • C# 参数完成
  • C# 自定义模型 如果针对单个项目训练出来了模型,那么可以使用专门针对此项目训练的模型
  • EditorConfig 推理 可以根据项目推断生成 EditorConfig 文件 可以参见在 Visual Studio 中使用 EditorConfig 统一代码风格
  • 自定义模型训练提示 如果开启,那么每个项目的规模如果达到一定程度就会提示训练一个自定义模型出来

IntelliCode

IntelliCode English

训练模型会上传一部分数据到 IntelliCode 服务器,你可以去 %TEMP%\Visual Studio IntelliCode 目录来查看到底上传了哪些数据。

快捷键

当然,设置好快捷键也是高效编码的重要一步,可以参考:

自动完成

在你点击 “确定” 关闭了以上窗口之后,我们还需要设置一项。

确保下图中的这个按钮处于 “非选中” 状态:

建议完成模式

这样,当出现智能感知列表的时候,我们直接就可以按下回车键输入这个选项了;否则你还需要按上下选中再回车。

建议完成和标准完成

08-04 2019

通过 mklink 收集本地文件系统的所有 NuGet 包输出目录来快速调试公共组件代码

我们做的公共库可能通过 nuget.org 发布,也可能是自己搭建 NuGet 服务器。但是,如果某个包正在开发中,需要快速验证其是否解决掉一些诡异的 bug 的话,除了单元测试这种间接的测试方法,还可以在本地安装未发布的 NuGet 包的方法来快速调试。

本文介绍如何本地打包发布 NuGet 包,然后通过 mklink 收集所有的本地包达到快速调试的目的。


将本地文件夹作为 NuGet 源

我有另一篇博客介绍如何将本地文件夹设置称为 NuGet 包源:

在 Visual Studio 中打开 工具 -> 选项 -> NuGet 包管理器 -> 包源 可以直接将一个本地文件夹设置称为 NuGet 包源。

管理包源

其他设置方法可以去那篇博客当中阅读。

如下图,是我通过 mklink 将散落在各处的 NuGet 包的调试输出目录收集了起来:

通过 mklink 收集的 NuGet 包源

比如,点开其中的 Walterlv.Packages 可以看到 Walterlv.Packages 仓库中输出的 NuGet 包:

其中的一个 NuGet 输出文件夹

由于我的每一个文件夹都是指向的 Visual Studio 编译后的输出目录,所以,只需要使用 Visual Studio 重新编译一下项目,文件夹中的 NuGet 包即会更新。

于是,这相当于我在一个文件夹中,包含了我整个计算机上所有库项目的 NuGet 包,只需要将这个文件夹设置称为 NuGet 包源,即可直接调试本地任何一个公共组件库打出来的 NuGet 包。

设置源并体验快速调试

如下图,是我将那个收集所有 NuGet 文件夹的目录设置成为了 NuGet 源:

设置的本地 NuGet 源

于是,我可以在 Visual Studio 的包管理器中看到所有还没有发布的,依然处于调试状态的各种库:

各种处于调试状态的各种库

基于此,我们可以在包还没有编写完的时候调试,验证速度非常快。

07-29 2019

Visual Studio 2019 中使用 .NET Core 预览版 SDK 的全局配置文件在哪里?

本文介绍在使用 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

sdk.txt 的所在路径

你一定觉得奇怪,我们在 Visual Studio 2019 中设置了使用 .NET Core SDK 预览版之后,这个配置是全局生效的,即便在命令行中运行 MSBuild 或者 dotnet build 也是会因此而使用预览版或者正式版的。但是这个路径明显看起来是 Visual Studio 的私有路径。

虽然这很诡异,但确实如此,不信,可以看我是如何确认这个文件就是 .NET Core SDK 预览版的全局配置的:

另外,如果你想知道如何在 Visual Studio 2019 中指定使用 .NET Core SDK 的预览版,可以参考我的另外一篇博客:

Visual Studio 2019 的

07-27 2019

The partial same C# namespace may cause source code compatibility issue

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.

07-27 2019

使用基于 Roslyn 的 Microsoft.CodeAnalysis.PublicApiAnalyzers 来追踪项目的 API 改动,帮助保持库的 API 兼容性

做库的时候,需要一定程度上保持 API 的兼容性


第一步:安装 NuGet 包

首先打开你的库项目,或者如果你希望从零开始也可以直接新建一个项目。这里为了博客阅读的简单,我创建一个全新的项目来演示。

打开一个项目

然后,为主要的库项目安装 NuGet 包:

安装 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>

第二步:创建 API 记录文件

在你的项目内创建两个文件:

  • PublicAPI.Shipped.txt
  • PublicAPI.Unshipped.txt

创建 API 记录文件

这就是两个普通的文本文件。创建纯文本文件的方法是在项目上右键 -> 添加 -> 新建项...,然后在打开的模板中选择 文本文件,使用上面指定的名称即可(要创建两个)。

然后,编辑项目文件,我们需要将这两个文件加入到项目中来。

编辑项目文件

如果你看不到上图中的“编辑项目文件”选项,则需要升级项目文件到 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>

如果你把这两个文件放到了其他的路径,那么上面也需要改成对应的路径。

这时,这两个文件内容还是空的。

第三步:添加 API 记录

这个时候,你会看到库中的 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

体验 API 的追踪

现在,我们将 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>

第四步:将 API 打包

前面我们都是在 PublicAPI.Unshipped.txt 文件中追踪 API。但是如果我们的库需要发布一个版本的时候,我们就需要跟上一个版本比较 API 的差异。

上一个发布版本的 API 就记录在 PublicAPI.Shipped.txt 文件中,这两个文件的差异即是这两个版本的 API 差异。在一个新的版本发布后,就需要将 API 归档到 PublicAPI.Shipped.txt 文件中。


参考资料

07-27 2019

找出 .NET Core SDK 是否使用预览版的全局配置文件在哪里(探索篇)

你是否好奇 Visual Studio 2019 中的 .NET Core SDK 预览版开关是全局生效的,那个全局的配置在哪里呢?

本文将和你一起探索找到这个全局的配置文件。


使用 Process Monitor 探索

下载 Process Monitor

Process Monitor 是微软极品工具箱的一部分,你可以在此页面下载:

打开 Process Monitor

当你一开始打开 Process Monitor 的时候,列表中会立刻刷出大量的进程的操作记录。这么多的记录会让我们找到目标进程操作的文件有些吃力,于是我们需要设置规则。

Process Monitor 的工具栏按钮并不多,而且我们这一次的目标只会用到其中的两个:

  • 清除列表(将已经记录的所有数据清空,便于聚焦到我们最关心的数据中)
  • 设置过滤器(防止大量无关的进程操作进入列表中干扰我们的查找)

Process Monitor 的工具栏按钮

设置过滤规则

在工具栏上点击“设置过滤器”,然后,添加我们感兴趣的两个进程名称:

  • devenv.exe
  • MSBuild.exe

前者是 Visual Studio 的进程名,后者是 MSBuild.exe 的进程名。我们使用这两个进程名称分别找到 Visual Studio 2019 是如何设置全局 .NET Core 预览配置的,并且在命令行中运行 MSBuild.exe 来验证确实是这个全局配置。

然后排除除了文件意外的所有事件类型,最终是如下过滤器:

设置过滤器

捕获 devenv.exe

现在,我们打开 Visual Studio 2019,然后停留到下面这个界面中。改变一下 .NET Core SDK 预览版选项的勾选状态。

设置 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 自己的设置配置。

于是必须通过其他途径来确认这是否就是真实的全局配置。

捕获 MSBuild.exe

现在,我们清除一下 Process Monitor 中的已经记录的数据,然后,我们在命令行中对一个项目敲下 msbuild 命令。

> msbuild

然后在 Process Monitor 里面观察事件。这次发现事件相当多,于是换个方式。

因为我们主要是验证 sdk.txt 文件,但同时希望看看是否还有其他文件。于是我们将 sdk.txt 文件相关的事件高亮。

点击 Filter -> Highlight...,然后选择 Path contains sdk.txt 时则 Include

打开 Highlight

高亮 sdk.txt 文件

这时,再看捕获到的事件,可以发现编译期间确实读取了这个文件。

MSBuild.exe 读取了 sdk.txt

此举虽不能成为此文件是全局配置的铁证,但至少说明这个文件与全局配置非常相关。

另外,继续在记录中翻找,还可以发现与此配置可能相关的两个 dll:

  • Microsoft.Build.NuGetSdkResolver.dll
  • Microsoft.DotNet.MSBuildSdkResolver.dll

可能与此相关的 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;
}

07-25 2019

nuget.exe 还原解决方案 NuGet 包的时候出现错误:调用的目标发生了异常。Error parsing the nested project section in solution file.

我这里使用 Visual Studio 2019 能好好编译的一个项目,发现在另一个小伙伴那里却编译不通过,是在 NuGet 还原那里报告了错误:

调用的目标发生了异常。Error parsing the nested project section in solution file.

本文介绍如何解决这样的问题。


原因

此问题的原因可能有多种:

  1. 解决方案里面 ProjectEndProject 不成对,导致某个项目没有被识别出来
  2. 解决方案中 Global 部分的项目 Id 没有在 Project 部分发现对应的项目
  3. 解决方案里面出现了当前 MSBuild 版本不认识的项目类型

解决方法

ProjectEndProject 不成对

ProjectEndProject 不成对通常是合并分支时,自动解冲突解错了导致的,例如像下面这样:

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

Global 部分的项目 Id 没有在 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

出现了当前 MSBuild 版本不认识的项目类型

可能是 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。


参考资料

07-25 2019

为 NuGet 指定检测的 MSBuild 路径或版本,解决 MSBuild auto-detection: using msbuild version 自动查找路径不合适的问题

使用 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:

  • 15.9.21.664
    • C:\Program Files (x86)\Microsoft Visual Studio\2017\Community\MSBuild\15.0\Bin
  • 16.1.76.45076
    • 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 的前面,出现了编译错误。

编译错误


参考资料

07-24 2019

理解 Visual Studio 解决方案文件格式(.sln)

一般情况下我们并不需要关心 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 文件:

只有一个项目的 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

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 来对项目做一些编译上的解决方案级别的配置。

ProjectSection

ProjectEndProject 的内部还可以放 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),DebugRelease,平台都是 Any CPU。同时也为每个项目指定了单独的配置种类,可供选择,每一行都是 项目的配置 = 解决方案的配置 表示此项目的此种配置在解决方案的某个全局配置之下。

如果我们将这两个项目放到文件夹中,那么我们可以额外看到一个新的全局配置 NestedProjects 字面意思是说 {DC0B1D44-5DF4-4590-BBFE-072183677A78}{98FF9756-B95A-4FDB-9858-5106F486FBF3} 两个项目在 {20B61509-640C-492B-8B33-FB472CCF1391} 项目中嵌套,实际意义代表 Walterlv.DemoWalterlv.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

07-24 2019

两种方法设置 .NET/C# 项目的编译顺序,而不影响项目之间的引用

当 A 项目引用 B 项目,那么使用 Visual Studio 或者 MSBuild 编译 A 项目之前就会确保 B 项目已经编译完毕。通常我们指定这种引用是因为 A 项目确实在运行期间需要 B 项目生成的程序集。

但是,现在 B 项目可能仅仅只是一个工具项目,或者说 A 项目编译之后的程序集并不需要 B,仅仅只是将 B 打到一个包中,那么我们其实需要的仅仅是 B 项目先编译而已。

本文介绍如何影响项目的编译顺序,而不带来项目实际引用。


方法一:设置 ReferenceOutputAssembly

依然在项目中使用往常习惯的方法设置项目引用:

设置项目引用

但是,在项目引用设置完成之后,需要打开项目的项目文件(.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>

这种做法有两个非常棒的用途:

  1. 生成代码
    • 依赖的项目(如上面的 Walterlv.Demo.Build)编译完成之后会生成一个可执行程序,它的作用是为我们当前的项目生成新的代码的。
    • 于是我们仅仅需要在编译当前项目之前先把这个依赖项目编译好就行,并不需要生成运行时的依赖。
  2. NuGet 包中附带其他文件
    • 如果要生成 NuGet 包,我们有时需要多个项目生成的文件来共同组成一个 NuGet 包,这个时候我们需要的仅仅是把其他项目生成的文件放到 NuGet 包中,而不是真的需要在 NuGet 包级别建立对此项目的依赖。
    • 当使用 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. 错误)。


参考资料

07-24 2019

解决方案文件 sln 中的项目类型 GUID

Visual Studio 可以通过得知项目类型快速地为项目显示相应的图标、对应的功能等等。

本文整理已收集到的一些项目的 GUID,如果你把你的解决方案文件(sln)改坏了,那么可以修复一下。


  • 8BB2217D-0F2D-49D1-97BC-3654ED321F3B ASP.NET 5
  • 603C0E0B-DB56-11DC-BE95-000D561079B0 ASP.NET MVC 1
  • F85E285D-A4E0-4152-9332-AB1D724D3325 ASP.NET MVC 2
  • E53F8FEA-EAE0-44A6-8774-FFD645390401 ASP.NET MVC 3
  • E3E379DF-F4C6-4180-9B81-6769533ABE47 ASP.NET MVC 4
  • 349C5851-65DF-11DA-9384-00065B846F21 ASP.NET MVC 5 / Web Application
  • FAE04EC0-301F-11D3-BF4B-00C04F79EFBC C#
  • 9A19103F-16F7-4668-BE54-9A1E7A4F7556 C# (SDK 风格的项目)
  • 8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942 C++
  • A9ACE9BB-CECE-4E62-9AA4-C7E7C5BD2124 Database
  • 4F174C21-8C12-11D0-8340-0000F80270F8 Database (other project types)
  • 3EA9E505-35AC-4774-B492-AD1749C4943A Deployment Cab
  • 06A35CCD-C46D-44D5-987B-CF40FF872267 Deployment Merge Module
  • 978C614F-708E-4E1A-B201-565925725DBA Deployment Setup
  • AB322303-2255-48EF-A496-5904EB18DA55 Deployment Smart Device Cab
  • F135691A-BF7E-435D-8960-F99683D2D49C Distributed System
  • BF6F8E12-879D-49E7-ADF0-5503146B24B8 Dynamics 2012 AX C# in AOT
  • 82B43B9B-A64C-4715-B499-D71E9CA2BD60 Extensibility
  • F2A71F9B-5D33-465A-A702-920D77279786 F#
  • 6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705 F# (SDK 风格的项目)
  • 95DFC527-4DC1-495E-97D7-E94EE1F7140D IL project
  • E6FDF86B-F3D1-11D4-8576-0002A516ECE8 J#
  • 262852C6-CD72-467D-83FE-5EEB1973A190 JScript
  • 20D4826A-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 Project
  • 8BB0C5E8-0616-4F60-8E55-A43933E57E9C LightSwitch
  • b69e3092-b931-443c-abe7-7e7b65f2a37f Micro Framework
  • C1CDDADD-2546-481F-9697-4EA41081F2FC Office/SharePoint App
  • 786C830F-07A1-408B-BD7F-6EE04809D6DB Portable Class Library
  • 66A26720-8FB5-11D2-AA7E-00C04F688DDE Project Folders
  • D954291E-2A0B-460D-934E-DC6B0785DB48 Shared Project
  • 593B0543-81F6-4436-BA1E-4747859CAAE2 SharePoint (C#)
  • EC05E597-79D4-47f3-ADA0-324C4F7C7484 SharePoint (VB.NET)
  • F8810EC1-6754-47FC-A15F-DFABD2E3FA90 SharePoint Workflow
  • A1591282-1198-4647-A2B1-27E5FF5F6F3B Silverlight
  • 4D628B5B-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 Test
  • A5A43C5B-DE2A-4C0C-9213-0A381AF9435A Universal Windows Class Library
  • F184B08F-C81C-45F6-A57F-5ABD9991F28F VB.NET
  • 778DAE3C-4631-46EA-AA77-85C1314464D9 VB.NET (forces use of SDK project system)
  • C252FEB5-A946-4202-B1D4-9916A0590387 Visual Database Tools
  • 54435603-DBB4-11D2-8724-00A0C9A8B90C Visual Studio 2015 Installer Project Extension
  • A860303F-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 Site
  • 3D9AD99F-2412-4246-B90B-4EAA41C64699 Windows Communication Foundation (WCF)
  • 76F1466A-8B6D-4E39-A767-685A06062A39 Windows Phone 8/8.1 Blank/Hub/Webview App
  • C089C8C0-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 & Components
  • D954291E-2A0B-460D-934E-DC6B0785DB48 Windows Store App Universal
  • 14822709-B5A1-4724-98CA-57A101D1B079 Workflow (C#)
  • D59BE175-2ED0-4C54-BE3D-CDAA9F3214C8 Workflow (VB.NET)
  • 32F31D43-81CC-4C15-9DE6-3FC5453562B6 Workflow Foundation
  • EFBA0AD7-5A72-4C68-AF49-83D382785DCF Xamarin.Android / Mono for Android
  • 6BC8ED88-2882-458C-8E55-DFD12B67127B Xamarin.iOS / MonoTouch
  • F5B4F3BC-B597-4E2B-B552-EF5D8A32436F MonoTouch Binding
  • 6D335F3A-9D43-41b4-9D22-F6F17C4BE596 XNA (Windows)
  • 2DF5C3F4-5A5F-47a9-8E94-23B4456F55E2 XNA (XBox)
  • D399B71A-8929-442a-A9AC-8BEC78BB2433 XNA (Zune)

参考资料

07-22 2019

使用 Roslyn 分析代码注释,给 TODO 类型的注释添加负责人、截止日期和 issue 链接跟踪

如果某天改了一点代码但是没有完成,我们可能会在注释里面加上 // 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);
                    }
                }
            }
        }
    }
}

代码修改器

只是报错的话,开发者看到错误可能会一脸懵逼,因为从未见过注释还会报告编译错误的,不知道怎么改。

于是我们需要编写一个代码修改器以便自动完成注释的修改,添加负责人和截止日期。我这里代码修改器修改后的结果就像下面这样:

TODO 注释的代码修改器

生成一个新的注释字符串然后替换即可:

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:yyyyMd} {oldComment}";
            var newTrivia = SyntaxFactory.ParseTrailingTrivia(comment);

            var newRoot = root.ReplaceTrivia(oldTrivia, newTrivia);
            return document.WithSyntaxRoot(newRoot);
        }
    }
}

07-10 2019

如果不用 ReSharper,那么 Visual Studio 2019 能还原 ReSharper 多少功能呢?

本文只谈论 ReSharper 的那些常用功能中,Visual Studio 2019 能还原多少,主要提供给那些正在考虑不使用 ReSharper 插件的 Visual Studio 用户作为参考。毕竟 ReSharper 如此强大的功能是建立在每年缴纳不少的费用以及噩梦般占用 Visual Studio 性能的基础之上的。然而使用 Visual Studio 2019 社区版不搭配 ReSharper 则可以免费为开源社区做贡献。


本文的内容分为三个部分:

  1. Visual Studio 能完全还原的 ReSharper 的功能
    • 可能 Visual Studio 在此功能上已经追赶上了 ReSharper
    • 可能 Visual Studio 在此功能上虽然依然不如 ReSharper 完善,但缺少的部分几乎不影响体验
    • 可能 Visual Studio 此功能比 ReSharper 更胜一筹
  2. Visual Studio 能部分还原 ReSharper 的功能
    • 可能在多数场景中 Visual Studio 能获得 ReSharper 的此功能效果,在少数场景下不如 ReSharper
    • 可能对多数人来说 Visual Studio 能获得 ReSharper 的此功能效果,对另一部分人来说无法替代 ReSharper
    • 有可能 Visual Studio 在此功能上另辟蹊径比 ReSharper 更厉害,但综合效果不如 ReSharper
    • Visual Studio 此功能依然很弱,但可以通过安装免费的插件的方式补足
  3. Visual Studio 此功能依然比不上 ReSharper
    • 可能是 Visual Studio 没有此功能
    • 可能是 Visual Studio 此功能的实现方式上不如 ReSharper 快速、高效、简单

完美还原

无处不在的智能感知提示

默认情况下,Visual Studio 只在你刚开始打字或者输入 .( 的时候才出现智能感知提示,但是如果你使用 ReSharper 开发,你会发现智能感知提示无处不在(所以那么卡?)。

实际上你也可以配置 Visual Studio 的智能感知在更多的情况下出现,请打开下面“工具”->“选项”->“文本编辑器”->“C#”->“IntelliSense”:

打开更多的智能感知提示时机

打开“键入字符后显示完成列表”和“删除字符后显示完成列表”。这样,你只要正在编辑,都会显示智能感知提示。

另外,如果你当前需要打开智能感知提示,默认情况下使用 Ctrl + 空格键 可以打开。当然你也可以将其修改为 ReSharper 中常见的快捷键 Alt + 右箭头。方法是修改键盘快捷键中的 “” 项。

完成列表

修改快捷键方法详见:

另外,在 IntelliCode 部分,可以选择打开更多的 IntelliSense 完成项:

IntelliCode

在输入时即自动导入需要的命名空间

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 自带的代码片段就弱很多,只能支持最基本的宏。

不过可以通过下面一些插件通过数量来补足功能上的一些短板:

07-07 2019

如何安装和准备 Visual Studio 扩展/插件开发环境

因为很多涉及到 Visual Studio 插件开发相关的文章/博客需要以安装 Visual Studio 插件开发环境为基础,所以本文介绍如何安装 Visual Studio 插件开发环境,以简化那些博客中的内容。


启动 Visual Studio 安装程序

请在开始菜单中找到或者搜索 Visual Studio Installer,然后启动它:

找到并且启动 Visual Studio Installer

安装 Visual Studio 插件开发工作负载

在 Visual Studio 的安装界面中选择“修改”:

修改

在工作负载中找到并勾选 Visual Studio 扩展开发(英文版是 Visual Studio extension development),然后按下右下角的“修改”:

勾选 Visual Studio 扩展开发负载

等待 Visual Studio 安装完 Visual Studio 扩展开发。如果提示重启计算机,那么就重启一下。

如果你想开发基于 Roslyn 的语法/语义分析插件

如果你想开发基于 Roslyn 的语法/语义分析插件,那么需要在选择了 Visual Studio 扩展开发工作负载之后,在右侧将可选的 .NET Compiler Platform SDK 也打上勾。

.NET Compiler Platform SDK

体验 Visual Studio 插件模板

如果你成功安装了 Visual Studio 扩展开发的工作负载,那么你在新建项目的时候就可以看到 Visual Studio 扩展开发相关的项目模板。

Visual Studio 扩展开发相关模板

07-07 2019

基于 Roslyn 同时为 Visual Studio 插件和 NuGet 包开发 .NET/C# 源代码分析器 Analyzer 和修改器 CodeFixProvider

Roslyn 是 .NET 平台下十分强大的编译器,其提供的 API 也非常丰富好用。本文将基于 Roslyn 开发一个 C# 代码分析器,你不止可以将分析器作为 Visual Studio 代码分析和重构插件发布,还可以作为 NuGet 包发布。不管哪一种,都可以让我们编写的 C# 代码分析器工作起来并真正起到代码建议和重构的作用。


本文将教大家如何从零开始开发一个基于 Roslyn 的 C# 源代码分析器 Analyzer 和修改器 CodeFixProvider。可以作为 Visual Studio 插件安装和使用,也可以作为 NuGet 包安装到项目中使用(无需安装插件)。无论哪一种,你都可以在支持 Roslyn 分析器扩展的 IDE(如 Visual Studio)中获得如下面动图所展示的效果。

本文教大家可以做到的效果

开发准备

安装 Visual Studio 扩展开发工作负载

你需要先安装 Visual Studio 的扩展开发工作负载,如果你还没有安装,那么请先阅读以下博客安装:

Visual Studio 扩展开发

创建一个分析器项目

启动 Visual Studio,新建项目,然后在项目模板中找到 “Analyzer with Code Fix (.NET Standard)”,下一步。

Analyzer with Code Fix 模板

随后,取好项目名字之后,点击“创建”,你将来到 Visual Studio 的主界面。

我为项目取的名称是 Walterlv.Demo.Analyzers,接下来都将以此名称作为示例。你如果使用了别的名称,建议你自己找到名称的对应关系。

在创建完项目之后,你可选可以更新一下项目的 .NET Standard 版本(默认是 1.3,建议更新为 2.0)以及几个 NuGet 包。

首次调试

如果你现在按下 F5,那么将会启动一个 Visual Studio 的实验实例用于调试。

Visual Studio 实验实例

由于我们是一个分析器项目,所以我们需要在第一次启动实验实例的时候新建一个专门用来测试的小型项目。

简单起见,我新建一个 .NET Core 控制台项目。新建的项目如下:

测试用的控制台项目

我们目前只是基于模板创建了一个分析器,而模板中自带的分析器功能是 “只要类型名称中有任何一个字符是小写的,就给出建议将其改为全部大写”。

于是我们看到 Program 类名底下标了绿色的波浪线,我们将光标定位到 Program 类名上,可以看到出现了一个 “小灯泡” 提示。按下重构快捷键(默认是 Ctrl + .)后可以发现,我们的分析器项目提供的 “Make uppercase” 建议显示了出来。于是我们可以快速地将类名修改为全部大写。

模板中自带的分析器建议

因为我们在前面安装了 Visual Studio 扩展开发的工作负载,所以可以在 “视图”->“其他窗口” 中找到并打开 Syntax Visualizer 窗格。现在,请将它打开,因为接下来我们的代码分析会用得到这个窗格。

打开语法可视化窗格

如果体验完毕,可以关闭 Visual Studio;当然也可以在我们的分析器项目中 Shift + F5 强制结束调试。

下次调试的时候,我们不需要再次新建项目了,因为我们刚刚新建的项目还在我们新建的文件夹下。下次调试只要像下面那样再次打开这个项目测试就好了。

打开历史记录中的项目

解读模板自带的分析器项目

项目和解决方案

在创建完项目之后,你会发现解决方案中有三个项目:

Visual Studio 分析器解决方案

  • Walterlv.Demo.Analyzers
    • 分析器主项目,我们接下来分析器的主要逻辑代码都在这个项目中
    • 这个项目在编译成功之后会生成一个 NuGet 包,安装了此包的项目将会运行我们的分析器
  • Walterlv.Demo.Analyzers.Vsix
    • Visual Studio 扩展项目,我们会在这里 Visual Studio 插件相关的信息
    • 这个项目在便已成功之后会生成一个 Visual Studio 插件安装包,Visual Studio 安装了此插件后将会对所有正在编辑的项目运行我们的分析器
    • 这个项目在默认情况下是启动项目(按下 F5 会启动这个项目调试),调试时会启动一个 Visual Studio 的实验实例
  • Walterlv.Demo.Analyzers.Test
    • 单元测试项目
    • 模板为我们生成了比较多的辅助代码帮助我们快速编写用于测试我们分析器可用性的单元测试,我们接下来的代码质量也靠这个来保证

在项目内部:

  • WalterlvDemoAnalyzersAnalyzer.cs
    • 模板中自带的分析器(Analyzer)的主要代码
    • 我们什么都还没有写的时候,里面已经包含一份示例用的分析器,其功能是找到包含小写的类名。
  • WalterlvDemoAnalyzersCodeFixProvider.cs
    • 模板中自带的代码修改器(CodeFixProvider)的主要代码
    • 我们什么都还没有写的时候,里面已经包含一份示例用的代码修改器,根据前面分析器中找到的诊断信息,给出修改建议,即只要类型名称中有任何一个字符是小写的,就给出建议将其改为全部大写
  • Resources.resx
    • 这里包含分析器建议使用的多语言信息

多语言资源文件

分析器代码(Analyzer)

别看我们分析器文件中的代码很长,但实际上关键的信息并不多。

我们现在还没有自行修改 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();
}

最关键的点:

  1. [DiagnosticAnalyzer(LanguageNames.CSharp)]
    • 为 C# 语言提供诊断分析器
  2. override SupportedDiagnostics
    • 返回此分析器支持的诊断规则
  3. 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)
{
    // 省略实现。
    // 在模板自带的实现中,这里判断类名是否包含小写字母,如果包含则创建一个新的诊断建议以改为大写字母。
}

代码修改器(CodeFixProvider)

代码修改器文件中的代码更长,但关键信息也没有增加多少。

我们现在也没有自行修改 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();
}

最关键的点:

  1. [ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(WalterlvDemoAnalyzersCodeFixProvider)), Shared]
    • 为 C# 语言提供代码修改器
  2. override FixableDiagnosticIds
    • 注意到前面 WalterlvDemoAnalyzersAnalyzer 类型中有一个公共字段 DiagnosticId 吗?在这里返回,可以为那里分析器找到的代码提供修改建议
  3. override GetFixAllProvider
    • 在最简单的示例中,我们将仅仅返回 BatchFixer,其他种类的 FixAllProvider 我将通过其他博客进行说明
  4. 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)
{
    // 省略实现。
    // 将类名改为全大写,然后返回解决方案。
}

开发自己的分析器(Analyzer)

一个简单的目标

作为示例,我们写一个属性转换分析器,将自动属性转换为可通知属性。

就是像以下上面的一种属性转换成下面的一种:

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 参数。

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

可以发现代码并不多,现在运行,可以在光标落在属性声明的行时看到修改建议。如下图所示:

在属性上有修改建议

你可能会觉得有些不满,看起来似乎只有我们写的那些标题和描述在工作。但实际上你还应该注意到这些:

  1. DiagnosticId_messageFormat_description 已经工作起来了;
  2. 只有光标在属性声明的语句块时,这个提示才会出现,因此说明我们的已经找到了正确的代码块了;
  3. 不要忘了我们还有个 CodeFixProvider 没有写呢,你现在看到的依然还在修改大小写的部分代码是那个类(WalterlvDemoAnalyzersCodeFixProvider)里的。

开发自己的代码修改器(CodeFixProvider)

现在,我们开始进行代码修改,将 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 启动调试,可以发现我们已经可以完成一个自动属性的修改了,可以按照预期改成一个可通知属性。

你可以再看看下面的动图:

可以修改属性

发布

发布成 NuGet 包

前往我们分析器主项目 Walterlv.Demo.Analyzers 项目的输出目录,因为本文没有改输出路径,所以在项目的 bin\Debug 文件夹下。我们可以找到每次编译产生的 NuGet 包。

已经打出来的 NuGet 包

如果你不知道如何将此 NuGet 包发布到 nuget.org,请在文本中回复,也许我需要再写一篇博客讲解如何推送。

发布到 Visual Studio 插件商店

前往我们分析器的 Visual Studio 插件项目 Walterlv.Demo.Analyzers.Vsix 项目的输出目录,因为本文没有改输出路径,所以在项目的 bin\Debug 文件夹下。我们可以找到每次编译产生的 Visual Studio 插件安装包。

已经打出来的 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 哟!

别忘了单元测试

别忘了我们一开始创建仓库的时候有一个单元测试项目,而我们全文都没有讨论如何充分利用其中的单元测试。我将在其他的博客中说明如何编写和使用分析器项目的单元测试。


参考资料

07-05 2019

如何快速创建 Visual Studio 代码片段?

使用 Visual Studio 的代码片段功能,我们可以快速根据已有模板创建出大量常用的代码出来。ReSharper 已经自带了一份非常好用的代码片段工具,不过使用 ReSharper 创建出来的代码片段只能用在 ReSharper 插件中。如果团队当中有一些小伙伴没有 ReSharper(毕竟很贵),那么也可以使用到 Visual Studio 原生的代码片段。

Visual Studio 的官方文档有演示如何创建 Visual Studio 的代码片段,不过上手成本真的很高。本文介绍如何快速创建 Visual Studio 代码片段,并不需要那么麻烦。


Visual Studio 的代码片段管理器

Visual Studio 中代码片段管理器的入口在“工具”中。你可以参照下图找到代码片段管理器的入口。

代码片段管理器入口

在打开代码片段管理器之后,你可以选择自己熟悉的语言。里面会列出当前语言中可以插入的各种代码片段的源。

不过,Visual Studio 并没有提供创建代码片段的方法。在这个管理器里面,你只能导入已经存在的代码片段,并不能直接进行编辑。

官方文档提供了创建代码片段的方法,就在这里:

你只需要看一看就知道这其实是非常繁琐的创建方式,你几乎在手工编写本来是给机器阅读的代码。

我们创建代码片段其实只是关注代码片段本身,那么有什么更快速的方法呢?

方法是安装插件。

Snippet Designer 插件

请去 Visual Studio 的扩展管理器中安装插件,或者去 Visual Studio 的插件市场中下载安装插件:

在扩展管理器中安装插件

在安装完插件之后(需要重新启动 Visual Studio 以完成安装),你就可以直接在 Visual Studio 中创建和编辑代码片段了。

创建代码片段

你需要去 Visual Studio 的“文件”->“新建”->“新建文件”中打开的模板选择列表中选择“Code Snippet”。

新建代码片段文件

下面,我演示创建一个 Debug.WriteLine 代码片段的创建方法。

编写一段代码

我将一段最简单的代码编写到了代码编辑窗格中:

Debug.WriteLine("[section] text");

插入占位符

实际上,这段代码中的 sectiontext 应该是占位符。那么如何插入占位符呢?

选中需要成为占位符的文本,在这里是 section ,然后鼠标右键,选择“Make Replacement”。

插入占位符

这样,在下方的列表中就会出现一个新的占位符。

列表中出现占位符

设置文本占位符

现在我们设置这个占位符的更多细节。比如在下图中,我设置了工具提示(即我们使用此代码片段的时候 Visual Studio 如何提示我们编写这个代码片段),设置了默认值(即没有写时应该是什么值)。设置了这只是一个文本文字,没有其他特别含义。设置这是可以编辑的。

设置更多信息

用通常的方法,设置 text 也是一个占位符。

设置类型占位符

如果我们只是这样创建一个代码片段,而目标代码可能没有引用 System.Diagnostics 命名空间,那么插入完之后手动引用这个命名空间体验可不好。那么如何让 Debug 类可以带命名空间地插入呢?

我们需要将 Debug 也设置成占位符。

将 Debug 也设置成占位符

但是这是可以自动生成的占位符,不需要用户输入,于是我们将其设置为不可编辑。同时,在“Function”一栏填写这是一个类型名称:

SimpleTypeName(global::System.Diagnostics.Debug)

设置 Debug 占位符

转义 $ 符号

实际上用于调试的话,代码越简单功能越全越好。于是我希望 Debug.WriteLine 上能够有一个字符串内插符号 $

那么问题来了,$ 符号是表示代码片段中占位符的符号,那么如何输入呢?

方法是——写两遍 $。于是我们的代码片段现在是这样的:

Debug.WriteLine($$"[$section$] $text$");

保存代码片段

你可以随时按下 Ctrl+S 保存这个新建的代码片段。插件一个很棒的设计是,默认所在的文件夹就是 Visual Studio 中用来存放代码片段的文件夹。于是,你刚刚保存完就可以立刻在 Visual Studio 中看到效果了。

保存代码片段

导入代码片段

如果你将代码片段保存在插件给你的默认的位置,那么你根本不需要导入任何代码片段。但如果你曾经导出过代码片段或者保存在了其他的地方,那么就需要在代码片段管理器中导入这些代码片段文件了。

使用代码片段

如果你前面使用了默认的保存路径,那么现在直接就可以开始使用了。

使用我们在 Shortcut 中设置的字母组合可以插入代码片段:

插入代码片段

在插入完成之后,我们注意到此类型可以使用导入的命名空间前缀 System.Diagnostics。如果没有导入此命名空间前缀,代码片段会自动加入。

按下 Tab 键可以在多个占位符之间跳转,而使用回车键可以确认这个代码片段。

插入后编辑的代码片段

管理代码片段

在 Visual Studio 视图菜单的其他窗口中,可以找到“Snippet Explorer”,打开它可以管理已有的代码片段,包括 Visual Studio 中内置的那些片段。

代码片段管理器

推荐 C# 代码片段

推荐另一款插件 Snippetica:

前者适用于 Visual Studio,后者适用于 Visual Studio Code。

它自带了很多的 C# 代码片段,可以很大程度补充 Visual Studio 原生代码片段存在感低的问题。


参考资料

07-04 2019

在 Visual Studio 中设置当发生某个特定异常或所有异常时中断

当使用 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");
        }
    }
}

Visual Studio 异常中断

不会中断的异常

现在,我们为这段会出异常的代码加上 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 块处理掉了此异常。

将 CLR 异常打勾

如果需要恢复设置,点击上面的恢复成默认的按钮即可。

设置发生特定异常时中断或不中断

当然,你也可以不需要全部打勾,而是只勾选你期望诊断问题的那几个异常。你可以试试,这其实是一个非常繁琐的工作,你会在大量的异常名称中失去眼神而再也无法直视任何异常了。

只勾选期望诊断问题的几个异常

所以更推荐的做法不是仅设置特定异常时中断,而是反过来设置——设置发生所有异常时中断,除了特定的一些异常之外。

方法是:

  1. 将整个 Common Language Runtime Exceptions 打勾
  2. 在实际运行程序之后,如果发生了一些不感兴趣的异常,那么就在下面的框中将此异常取消勾选即可

设置发生此异常时中断

脱离 Visual Studio 设置

如果程序并不是在 Visual Studio 中运行,那么有没有方法进行中断呢?

一个做法是调用 Debugger.Launch(),但这样的话中断的地方就是在 Debugger.Launch() 所在的代码处,可能异常还没发生或者已经发生过了。

有没有方法可以在异常发生的那一刻中断呢?请阅读我的另一篇博客:

07-04 2019

.NET/MSBuild 中的发布路径在哪里呢?如何在扩展编译的时候修改发布路径中的文件呢?

在扩展 MSBuild 编译的时候,我们一般的处理的路径都是临时路径或者输出路径,那么发布路径在哪里呢?


我曾经在下面这一篇博客中说到可以通过阅读 Microsoft.NET.Sdk 的源码来探索我们想得知的扩展编译的答案:

于是,我们可以搜索 "Publish" 这样的关键字找到我们希望找到的编译目标,于是找到在 Microsoft.NET.Sdk.Publish.targets 文件中,有很多的 PublishDir 属性存在,这可以很大概率猜测这个就是发布路径。不过我只能在这个文件中找到这个路径的再次赋值,找不到初值。

如果全 Sdk 查找,可以找到更多赋初值和使用它复制和生成文件的地方。

PublishDir 全文查找

于是可以确认,这个就是最终的发布路径,只不过不同类型的项目,其发布路径都是不同的。

比如默认是:

<PublishDir Condition="'$(PublishDir)'==''">$(OutputPath)app.publish\</PublishDir>

还有:

<_DeploymentApplicationDir>$(PublishDir)$(_DeploymentApplicationFolderName)\</_DeploymentApplicationDir>

和其他。

07-01 2019

.NET / MSBuild 扩展编译时什么时候用 BeforeTargets / AfterTargets 什么时候用 DependsOnTargets?

在为 .NET 项目扩展 MSBuild 编译而编写编译目标(Target)时,我们会遇到用于扩展编译目标用的属性 BeforeTargets AfterTargetsDependsOnTargets

这三个应该分别在什么情况下用呢?本文将介绍其用法。


BeforeTargets / AfterTargets

BeforeTargetsAfterTargets 是用来扩展编译用的。

如果你希望在某个编译任务开始执行一定要执行你的编译目标,那么请使用 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 带来的此机制来实现的:

如果 BeforeTargetsAfterTargets 中写了多个 Target 的名称(用分号分隔),那么只要任何一个准备执行或者执行完毕,就会触发此 Target 的执行。

DependsOnTargets

DependsOnTargets 是用来指定依赖的。

DependsOnTargets 并不会直接帮助你扩展一个编译目标,也就是说如果你只为你的 Target 写了一个名字,然后添加了 DependsOnTargets 属性,那么你的 Target 在编译期间根本都不会执行。

但是,使用 DependsOnTargets,你可以更好地控制执行流程和其依赖关系。

例如上面的 CopyOutputLibToFastDebug 这个将输出文件复制到另一个目录的编译目标(Target),依赖于一个 MainProjectPath 属性,因此计算这个属性值的编译目标(Target)应该设成此 Target 的依赖。

当 A 的 DependsOnTargets 设置为 B;C;D 时,那么一旦准备执行 A 时将会发生:

  • 如果 B C D 中任何一个曾经已经执行过,那么就忽略(因为已经执行过了)
  • 如果 B C D 中还有没有执行的,就立刻执行

实践

当我们实际上在扩展编译的时候,我们会用到不止一个编译目标,因此这几个属性都是混合使用的。但是,你应该在合适的地方编写合适的属性设置。

例如我们做一个 NuGet 包,这个 NuGet 包的 .targets 文件中写了下面几个 Target:

  1. _WalterlvEvaluateProperties
    • 用于初始化一些属性和参数,其他所有的 Target 都依赖于这些参数
  2. _WalterlvGenerateStartupObject
    • 生成一个类,包含 Main 入口点函数,然后将入口点设置成这个类
  3. _WalterlvIncludeSourceFiles
    • 为目标项目添加一些源代码,这就包含刚刚新生成的入口点类
  4. _WalterlvPackOutput
    • 将目标项目中生成的文件进行自定义打包

那么我们改如何为每一个 Target 设置正确的属性呢?

第一步:找出哪些编译目标是真正完成编译任务的,这些编译目标需要通过 BeforeTargetsAfterTarget 设置扩展编译。

于是我们可以找到 _WalterlvIncludeSourceFiles_WalterlvPackOutput

  • _WalterlvIncludeSourceFiles 需要添加参与编译的源代码文件,因此我们需要将 BeforeTargets 设置为 CoreCompile
  • _WalterlvPackOutput 需要在编译完成后进行自定义打包,因此我们将 AfterTargets 设置为 AfterBuild。这个时候可以确保文件已经生成完毕可以使用了。

第二步:找到依赖关系,这些依赖关系需要通过 DependsOnTargets 来执行。

于是我们可以找到 _WalterlvEvaluateProperties_WalterlvGenerateStartupObject

  • _WalterlvEvaluateProperties 被其他所有的编译目标使用了,因此,我们需要将后面所有的 DependsOnTargets 属性设置为 _WalterlvEvaluateProperties
  • _WalterlvGenerateStartupObject 生成的入口点函数被 _WalterlvIncludeSourceFiles 加入到编译中,因此 _WalterlvIncludeSourceFilesDependsOnTargets 属性需要添加 _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>

具体依赖于抽象

我们平时在编写代码时会考虑面向对象的六个原则,其中有一个是依赖倒置原则,即具体依赖于抽象。

你不这么写代码当然不会带来错误,但会带来维护性困难。在编写扩展编译目标的时候,这一条同样适用。

假如我们要写的编译目标不止上面这些,还有更多:

  • _WalterlvConvertTemplateCompileToRealCompile
    • 包里有一些模板代码,会在编译期间转换为真实代码并加入编译
  • _WalterlvConditionalImportedSourceCode
    • 会根据 NuGet 包用户的设置有条件地引入一些额外的源代码

那么这个时候我们前面写的用于引入源代码的 _WalterlvIncludeSourceFiles 编译目标其依赖的 Target 会更多。似乎看起来应该这么写了:

<Target Name="_WalterlvIncludeSourceFiles"
        BeforeTargets="CoreCompile"
        DependsOnTargets="_WalterlvEvaluateProperties;_WalterlvGenerateStartupObject;_WalterlvConvertTemplateCompileToRealCompile;_WalterlvConditionalImportedSourceCode">
</Target>

但你小心:

  1. 这个列表会越来越长,而且指不定还会增加一些边边角角的引入的新的源代码呢
  2. _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 内部有很多的编译任务是通过这种方式提供的扩展,例如:

  • BuildDependsOn
  • CleanDependsOn
  • CompileDependsOn

你可以阅读我的另一篇博客了解更多:

07-01 2019

在项目文件 / MSBuild / NuGet 包中编写扩展编译的时候,正确使用 props 文件和 targets 文件

.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 里就一目了然了:

  1. 如果你是定义属性或者为属性设置初值,那么请写到 .props 里面
    • 这样,所有的 NuGet 包或者扩展的编译流程都将可以访问到你设置的属性的值
  2. 如果你是使用属性,或者按条件设置属性,那么请写到 .targets 里面
    • 因为这个时候多数的属性已经初始化完毕,你可以使用到属性的值了
  3. 如果你写的是编译目标(Target),那么请写到 .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>

06-30 2019

使用 Visual Studio 编译时,让错误一开始发生时就停止编译(以便及早排查编译错误节省时间)

对于稍微大一点的 .NET 解决方案来说,编译时间通常都会长一些。如果项目结构和差量编译优化的好,可能编译完也就 5~30 秒,但如果没有优化好,那么出现 1~3 分钟都是可能的。

如果能够在编译出错的第一时间停止编译,那么我们能够更快地去找编译错误的原因,也能从更少的编译错误列表中找到出错的关键原因。


如果你只是觉得你的项目或解决方案编译很慢而不知道原因,我推荐你安装 Parallel Builds Monitor 插件来调查一下。你可以阅读我的一篇博客来了解它:

一个优化比较差的解决方案可能是下面这个样子的:

优化比较差的解决方案的编译甘特图

明明没有多少个项目,但是项目之间的依赖几乎是一条直线,于是不可能开启项目的并行编译。

图中这个项目的编译时长有 1 分 30 秒。可想而知,如果你的改动导致非常靠前的项目编译错误,而默认情况下编译的时候会继续尝试编译下去,于是你需要花非常长的时间才能等待编译完毕,然后从一大堆项目中出现的编译错误中找到最开始出现错误的那个(通常也是编译失败的本质原因)。

现在,推荐使用插件 VSColorOutput

它的主要功能是给你的输出窗格加上颜色,可以让你更快速地区分调试信息、输出、警告和错误。

不过,也正是因为它是通过匹配输出来上色的,于是它可以得知你的项目出现了编译错误,可以采取措施。

在你安装了这款插件之后,你可以在 Visual Studio 的“工具”->“设置”中找到 VSColorOutput 的设置。其中有一项是“Stop Build on First Error”,打开之后,再出现了错误的话,将第一时间会停止。你也可以发现你的 Visual Studio 错误列表中的错误数量非常少了,这些错误都是导致编译失败的最早出现的错误,利于你定位问题。

VSColorOutput 的设置

06-16 2019

从零开始制作 NuGet 源代码包(全面支持 .NET Core / .NET Framework / WPF 项目)

默认情况下,我们打包 NuGet 包时,目标项目安装我们的 NuGet 包会引用我们生成的库文件(dll)。除此之外,我们也可以专门做 NuGet 工具包,还可以做 NuGet 源代码包。然而做源代码包可能是其中最困难的一种了,目标项目安装完后,这些源码将直接随目标项目一起编译。

本文将从零开始,教你制作一个支持 .NET 各种类型项目的源代码包。


前置知识

在开始制作一个源代码包之间,建议你提前了解项目文件的一些基本概念:

当然就算不了解也没有关系。跟着本教程你也可以制作出来一个源代码包,只不过可能遇到了问题的时候不容易调试和解决。

制作一个源代码包

接下来,我们将从零开始制作一个源代码包。

我们接下来的将创建一个完整的解决方案,这个解决方案包括:

  1. 一个将打包成源代码包的项目
  2. 一个调试专用的项目(可选)
  3. 一个测试源代码包的项目(可选)

第一步:创建一个 .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

现在,我们要双击项目名称或者右键“编辑项目文件”来编辑项目的 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>

第四步:编写编译文件 targets

接下来,我们将编写编译文件 props 和 targets。注意,我们需要写的是四个文件的内容,不要弄错了。

如果我们做好的 NuGet 源码包被其他项目使用,那么这四个文件中的其中一对会在目标项目被自动导入(Import)。在你理解 理解 C# 项目 csproj 文件格式的本质和编译流程 一文内容之前,你可能不明白“导入”是什么意思。但作为从零开始的入门博客,你也不需要真的理解导入是什么意思,只要知道这四个文件中的代码将在目标项目编译期间运行就好。

buildMultiTargeting 文件夹中的 Package.props 文件

你只需要将下面的代码拷贝到 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 文件

你只需要将下面的代码拷贝到 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 文件

下面是 build 文件夹中 Package.props 文件的全部内容。可以注意到我们几乎没有任何实质性的代码在里面。即便我们在此文件中还没有写任何代码,依然需要创建这个文件,因为后面第五步我们将添加更复杂的代码时将再次用到这个文件完成里面的内容。

现在,保持你的文件中的内容与下面一模一样就好。

<Project>

  <PropertyGroup>
    <MSBuildAllProjects>$(MSBuildAllProjects);$(MSBuildThisFileFullPath)</MSBuildAllProjects>
  </PropertyGroup>

</Project>

build 文件夹中的 Package.targets 文件

下面是 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 文件夹中的两个编译文件。

如果你对这个属性不熟悉,请回到第一步看我们一开始创建的代码,你会看到这个属性的设置的。如果还不清楚,请阅读博客:

体验和查看 NuGet 源代码包

也许你已经从本文拷贝了很多代码过去了,但直到目前我们还没有看到这些代码的任何效果,那么现在我们就可以来看看了。这可算是一个阶段性成果呢!

先编译生成一下我们一直在完善的项目,我们就可以在解决方案目录的 bin\Debug 目录下找到一个 NuGet 包。

生成项目

生成的 NuGet 包

现在,我们要打开这个 NuGet 包看看里面的内容。你需要先去应用商店下载 NuGet Package Explorer,装完之后你就可以开始直接双击 NuGet 包文件,也就是 nupkg 文件。现在我们双击打开看看。

NuGet 包中的内容

我们的体验到此为止。如果你希望在真实的项目当中测试,可以阅读其他博客了解如何在本地测试 NuGet 包。

第五步:加入 WPF 项目支持

截至目前,我们只是在源代码包中引入了 C# 代码。如果我们需要加入到源代码包中的代码包含 WPF 的 XAML 文件,或者安装我们源代码包的目标项目包含 WPF 的 XAML 文件,那么这个 NuGet 源代码包直接会导致无法编译通过。至于原因,你需要阅读我的另一篇博客来了解:

即便你不懂 WPF 程序的编译过程,你也可以继续完成本文的所有内容,但可能就不会明白为什么接下来我们要那样去修改我们之前创建的文件。

接下来我们将修改这些文件:

  • build 文件夹中的 Package.props 文件
  • build 文件夹中的 Package.targets 文件

build 文件夹中的 Package.props 文件

在这个文件中,我们将新增一个属性 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>

build 文件夹中的 Package.targets 文件

请按照下面的差异说明来修改你的 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 相关的文件。

请特别注意

  1. 我们加了一个 Link 属性,并且将其指定为 %(_WalterlvDemoPage.FileName).xaml。这意味着我们会把所有的 XAML 文件都当作在项目根目录中生成,如果你在其他的项目中用到了相对或绝对的 XAML 文件的路径,这显然会改变路径。但是,我们没有其他的方法来根据 XAML 文件所在的目录层级来自定指定 Link 属性让其在正确的层级上,所以这里才写死在根目录中。
    • 如果要解决这个问题,我们就需要在生成 NuGet 包之前生成此项目中所有 XAML 文件的正确的 Link 属性(例如改为 Views\%(_WalterlvDemoPage.FileName).xaml),这意味着需要在此项目编译期间执行一段代码,把 Package.targets 文件中为所有的 XAML 文件生成正确的 Link 属性。本文暂时不考虑这个问题,但你可以参考 dotnet-campus/SourceYard 项目来了解如何动态生成 Link
  2. 我们使用了 _WalterlvDemoPage 集合中转地存了 XAML 文件,这是必要的。因为这样才能正确通过 % 符号获取到 FileName 属性。

_WalterlvDemoImportInWpfTempProject 这个编译目标就不那么好理解了,而这个也是完美支持 WPF 项目源代码包的关键编译目标!这个编译目标指定在 MarkupCompilePass1 之后,GenerateTemporaryTargetAssembly 之前执行。GenerateTemporaryTargetAssembly 编译目标的作用是生成一个临时的项目,用于让 WPF 的 XAML 文件能够依赖同项目的 .NET 类型而编译。然而此临时项目编译期间是不会导入任何 NuGet 的 props 或 targets 文件的,这意味着我们特别添加的所有 C# 源代码在这个临时项目当中都是不存在的——如果项目使用到了我们源代码包中的源代码,那么必然因为类型不存在而无法编译通过——临时项目没有编译通过,那么整个项目也就无法编译通过。但是,我们通过在 MarkupCompilePass1GenerateTemporaryTargetAssembly 之间将我们源代码包中的所有源代码加入到 _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 开源项目

本文服务于开源项目 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.

相关博客

更多制作源代码包的博客可以阅读。从简单到复杂的顺序:

06-11 2019

WPF 程序的编译过程

基于 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 的源码可以查看:

Target

WPF 在编译期间会执行以下这些 Target,当然 Target 里面实际用于执行任务的是 Task。

知道 Target 名称的话,你可以扩展 WPF 的编译过程;而知道 Task 名称的话,可以帮助理解编译过程实际做的事情。

本文都会列举出来。

FileClassification

  • Target 名称:FileClassification
  • Task 名称:FileClassifier

用于将资源嵌入到程序集。如果资源没有本地化,则嵌入到主程序集;如果有本地化,则嵌入到附属程序集。

在 WPF 项目中,这个 Target 是一定会执行的;但里面的 Task 则是有 Resource 类型的编译项的时候才会执行。

GenerateTemporaryTargetAssembly

Target 名称和 Task 名称相同,都是 GenerateTemporaryTargetAssembly

只要项目当中包含任何一个生成类型为 Page 的 XAML 文件,则会执行此 Target。

关于生成临时程序集的原因比较复杂,可以阅读本文后面的 WPF 程序的编译过程部分来了解。

MarkupCompilePass1

Target 名称和 Task 名称相同,都是 MarkupCompilePass1

将非本地化的 XAML 文件编译成二进制格式。

MarkupCompilePass2

Target 名称和 Task 名称相同,都是 MarkupCompilePass2

对 XAML 文件进行第二轮编译,而这一次会引用同一个程序集中的类型。

DesignTimeMarkupCompilation

这是一个仅在有设计器执行时才会执行的 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>

MergeLocalizationDirectives

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

  • Target 有两个,MainResourcesGenerationSatelliteResourceGeneration,分别负责主资源生成和附属资源生成。
  • Task 名称:ResourcesGenerator

将一个或多个资源(二进制格式的 .jpg、.ico、.bmp、XAML 以及其他扩展名类型)嵌入 .resources 文件中。

CheckUid、UpdateUid、RemoveUid

  • Target 有三个,CheckUidUpdateUidRemoveUid,分别负责主资源生成和附属资源生成。
  • Task 名称: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>

UpdateManifestForBrowserApplication

当编译基于 XAML 的浏览器项目的时候,会给 manifest 文件中添加一个配置 <hostInBrowser />

WPF 程序的编译过程

编译过程图示

上面列举出来的那些 Target 主要是 WPF 几个关键的 Target,在实际编译时会有更多编译 Target 执行。另外有些也不在常规的编译过程中,而是被专门的编译过程执行。

WPF 程序的编译过程

图的阅读方法是这样的:

  1. 箭头代表依赖关系,如 CoreCompile 有一个指向 DesignTimeMarkupCompilation 的箭头,表示 CoreCompile 执行前会确保 DesignTimeMarkupCompilation 执行完毕;
  2. 如果一个 Target 有多个依赖,则这些依赖会按顺序执行还没执行的依赖,如 PrepareResources 指向了多个 Target MarkupCompilePass1GenerateTemporaryTargetAssemblyMarkupCompilePass2AfterMarkupCompilePass2CleanupTemporaryTargetAssembly,那么在 PrepareResources 执行之前,如果还有没有执行的依赖,会按顺序依次执行;
  3. WPF 所有的 Target 扩展都是通过依赖来指定的,也就是说必须基于现有的核心编译过程,图中从绿色或黄色的节点向前倒退的所有依赖都会被执行。

各种颜色代表的含义:

  • 蓝色,表示 WPF 扩展的 Target
  • 浅蓝色,表示 WPF 扩展的 Target,但是没有执行任何实际的任务,只是提供一个扩展点
  • 绿色,表示核心的编译过程,但是被 WPF 编译过程重写了
  • 黄色,表示核心的编译过程(即便不是 WPF 程序也会执行的 Target)
  • 浅黄色,表示在这张图里面不关心的 Target(不然整个画下来就太多了)
  • 紫色,仅在 Visual Studio 编译期间会执行的 WPF 扩展的 Target

编译过程描述

我们都知道 XAML 是可以引用 CLR 类型的;如果 XAML 所引用的 CLR 类型在其他被引用的程序集,那么编译 XAML 的时候就可以直接引用这些程序集,因为他们已经编译好了。

但是我们也知道,XAML 还能引用同一个程序集中的 CLR 类型,而此时这个程序集还没有编译,XAML 编译过程并不知道可以如何使用这些类型。同时我们也知道 CLR 类型可是使用 XAML 生成的类型,如果 XAML 没有编译,那么 CLR 类型也无法正常完成编译。这是矛盾的,这也是 WPF 扩展的编译过程会比较复杂的原因之一。

WPF 编译过程有两个编译传递,MarkupCompilePass1MarkupCompilePass2

MarkupCompilePass1 的作用是将 XAML 编译成二进制格式。如果 XAML 文件包含 x:Class 属性,那么就会根据语言生成一份代码文件;对于 C# 语言,会生成“文件名.g.cs”文件。但是 XAML 文件中也有可能包含对同一个程序集中的 CLR 类型的引用,然而这一编译阶段 CLR 类型还没有开始编译,因此无法提供程序集引用。所以如果这个 XAML 文件包含对同一个程序集中 CLR 类型的引用,则这个编译会被推迟到 MarkupCompilePass2 中继续。而在 MarkupCompilePass1MarkupCompilePass2 之间,则插入了 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>

我们需要关注这些点:

  1. 生成临时程序集时,会调用一个编译目标(Target),这个编译目标的名称由 _CompileTargetNameForLocalType 这个私有属性来决定;
  2. _CompileTargetNameForLocalType 没有指定时,会设置其默认值为 _CompileTemporaryAssembly 这个编译目标;
  3. _CompileTemporaryAssembly 这个编译目标执行时,仅会执行三个依赖的编译目标,BuildOnlySettingsResolveKeySourceCoreCompile,至于这些依赖目标所依赖的其他编译目标,则会根据新生成的项目文件动态计算。
  4. 生成临时程序集和临时程序集的编译过程并不在同一个编译上下文中,这也是为什么只能通过传递名称 _CompileTargetNameForLocalType 来执行,而不能直接调用这个编译目标或者设置编译目标的依赖。

新生成的临时项目文件相比于原来的项目文件,包含了这些修改:

  1. 添加了第一轮 XAML 编译传递(MarkupCompilePass1)时生成的 .g.cs 文件;
  2. 将所有引用方式收集到的引用全部换成 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 造成了不小的困扰:


参考资料

06-11 2019

制作通过 NuGet 分发的源代码包时,如果目标项目是 WPF 则会出现一些问题(探索篇,含解决方案)

在使用 NuGet 包来分发源代码时,如果目标项目是 WPF 项目,那么会有一大堆的问题。

本文将这些问题列举出来并进行分析。


源代码包

源代码包不是 NuGet 官方的概念,而是林德熙和我在 GitHub 上做的一个项目,目的是将你的项目以源代码的形式发布成 NuGet 包。在安装此 NuGet 包后,目标项目将获得这些源代码。

你可以通过以下博客了解如何制作一个源代码包。

这可以避免因为安装 NuGet 包后带来的大量程序集引用,因为程序集数量太多对程序的启动性能有很大的影响:

然而制作一个 NuGet 的坑很多,详见:

基础代码:最小的例子

为了让 NuGet 源代码包对 WPF 项目问题暴露得更彻底一些,我们需要一个最简单的例子来说明这一问题。我将它放在了我的 Demo 项目中:

但为了让博客理解起来更顺畅,我还是将关键的源代码贴出来。

用于打源代码包的项目 Walterlv.SourceYard.Demo

为了尽可能避免其他因素的影响,我们这个源码包只做这些事情:

  1. 包含一个 targets 文件,用于给目标项目引入源代码;
  2. 包含一个几乎没有什么代码的 C# 代码文件,用于测试是否正常引入了源代码包;
  3. 项目的 csproj 文件,用于控制源代码包的编译过程。

具体来说,我们的目录结构是这样的:

- 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。

用于验证源代码包的项目 Walterlv.GettingStarted.SourceYard.Sample

现在,我们新建另一个简单的控制台项目用于验证这个 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

当然,贴一张图片可能更能体现编译通过:

可以编译通过

上面的输出非常多,但我们提取一下关键的点:

  1. 有输出的 Target 有这些:CoreClean -> PrepareForRebuild -> GenerateBindingRedirects -> GenerateTargetFrameworkMonikerAttribute -> CoreCompile -> _CopyAppConfigFile -> CopyFilesToOutputDirectory
  2. 在 CoreCompile 这个编译任务里面,所有需要编译的 C# 代码有这些: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 项目

现在,我们将我们的项目升级成 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

  • $(项目名)_$(随机字符)_wpftmp.csproj
  • $(项目名)_$(随机字符)_wpftmp.AssemblyInfo.cs

我们几乎可以认为,当项目是编译成 WPF 时,执行了不同的编译流程。

修复错误

找出原因

要了解问题到底出在哪里了,我们需要知道 WPF 究竟在编译过程中做了哪些额外的事情。WPF 额外的编译任务主要在 Microsoft.WinFX.targets 文件中。在了解了 WPF 的编译过程之后,这个临时的程序集将非常容易理解。

我写了一篇讲解 WPF 编译过程的博客,在解决这个问题之前,建议阅读这篇博客了解 WPF 是如何进行编译的:

在了解了 WPF 程序的编译过程之后,我们知道了前面一些疑问的答案:

  1. 那个临时的项目文件是如何生成的;
  2. 那个临时项目文件和原始的项目文件有哪些不同;
  3. 编译临时项目文件时,哪些编译目标会执行,哪些编译目标不会执行。

在那篇博客中,我们解释到新生成的项目文件会使用 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 造成了不小的困扰:

解决问题

这个问题解决起来其实并不如想象当中那么简单,因为:

  1. WPF 项目的编译包含两个编译上下文,一个是正常的编译上下文,另一个是临时生成的项目文件编译的上下文;正常的编译上下文编译到 MarkupCompilePass1MarkupCompilePass2 之间的 GenerateTemporaryTargetAssembly 编译目标时,会插入一段临时项目文件的编译;
  2. 临时项目文件的编译中,会执行 _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 的时机,那么我们只能另寻它法:

  1. 帮助微软修复 NuGet 在 WPF 临时生成的项目中依然可以自动 Import 编译文件 .props 和 .targets;
  2. 直接修改项目文件,使其直接或间接 Import 我们希望 Import 进来的编译文件 .props 和 .targets。
  3. 寻找其他可以被自动 Import 的时机进行自动 Import;
  4. 不管时机了,从 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 就会重复导入。

重复导入的编译警告

方案三:寻找其他自动 Import 的时机(不可行)

Directory.Build.props 和 Directory.Build.targets 也是可以被自动 Import 的文件,这也是在 Microsoft.NET.Sdk 中将其自动导入的。

关于这两个文件的自动导入,可以阅读博客:

但是,如果我们使用这两个文件帮助自动导入,将造成导入循环,这会形成编译错误!

因导入循环造成的编译错误

方案四:设置 GenerateTemporaryTargetAssembly 编译任务

GenerateTemporaryTargetAssembly 的代码如下:

<GenerateTemporaryTargetAssembly
        CurrentProject="$(MSBuildProjectFullPath)"
        MSBuildBinPath="$(MSBuildBinPath)"
        ReferencePathTypeName="ReferencePath"
        CompileTypeName="Compile"
        GeneratedCodeFiles="@(_GeneratedCodeFiles)"
        ReferencePath="@(ReferencePath)"
        IntermediateOutputPath="$(IntermediateOutputPath)"
        AssemblyName="$(AssemblyName)"
        CompileTargetName="$(_CompileTargetNameForLocalType)"
        GenerateTemporaryTargetAssemblyDebuggingInformation="$(GenerateTemporaryTargetAssemblyDebuggingInformation)"
        >

</GenerateTemporaryTargetAssembly>

可以看到它的的参数有:

  • CurrentProject,传入了 $(MSBuildProjectFullPath),表示项目文件的完全路径,修改无效。
  • MSBuildBinPath,传入了 $(MSBuildBinPath),表示 MSBuild 程序的完全路径,修改无效。
  • ReferencePathTypeName,传入了字符串常量 ReferencePath,这是为了在生成临时项目文件时使用正确的引用路径项的名称。
  • CompileTypeName,传入了字符串常量 Compile,这是为了在生成临时项目文件时使用正确的编译项的名称。
  • GeneratedCodeFiles,传入了 @(_GeneratedCodeFiles),包含生成的代码文件,也就是那些 .g.cs 文件。
  • ReferencePath,传入了 @(ReferencePath),也就是目前已收集到的所有引用文件的路径。
  • IntermediateOutputPath,传入了 $(IntermediateOutputPath),表示临时输出路径,当使用临时项目文件编译时,生成的临时程序集将放在这个目录中。
  • AssemblyName,传入了 $(AssemblyName),表示程序集名称,当生成临时程序集的时候,将参考这个程序集名称。
  • CompileTargetName,传入了 $(_CompileTargetNameForLocalType),表示当生成了新的项目文件后,要使用哪个编译目标来编译这个项目。
  • GenerateTemporaryTargetAssemblyDebuggingInformation,传入了 $(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>

现在重新再编译,我们本文一开始疑惑的各种问题,现在终于无警告无错误地解决掉了。

解决掉的源代码包问题

解决关键

如果你觉得本文略长,希望立刻获得解决办法,可以:

  1. 直接使用 “方案四” 中新增的那一段代码;
  2. 阅读我的另一篇专门的只说解决方案的博客:如何为 WPF 项目制作源代码包(SourceYard 基础原理篇,解决 WPF 项目编译问题和 NuGet 包中的各种问题)

参考资料

06-04 2019

MSBuild 在编写编译任务的时候判断当前是否在 Visual Studio 中编译

我们这里说的编译任务是 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 可以阅读我的另一篇博客:

05-23 2019

在 Visual Studio 中重新将高级保存功能放出来,便于强制指定文件编码格式

Visual Studio 的早期版本中有一个高级保存功能,但是升级到 Visual Studio 2019 之后这个功能就不在菜单项里面了。

本文将带你把它找出来继续使用。


第一步:工具 -> 自定义

打开 Visual Studio 2019,然后进入“工具 -> 自定义”菜单项。对于英文版本,是“Tools -> Customize”菜单项。

工具 -> 自定义

第二步:自定义命令

按照下图一个个点击,把“高级保存选项”放出来:

放出高级保存选项

当刚刚添加出来的时候,位置可能不太正确,但是我们可以点击窗口旁边的“上移”和“下移”按钮将其放在合适的位置。

为了照顾英文版,我也放出英文版的界面:

English Save Options

05-15 2019

在编译期间使用 Roslyn/MSBuild 自带的方法/函数判断、计算和修改属性

充分利用 MSBuild 自带的方法,可以在编译期间完成大多数常见的属性转换,而不再需要自己专门写库来完成。

本文介绍如何使用 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 自带的方法

数学运算

MSBuild 中数学运算的部分可以参考我的另一篇博客:

EnsureTrailingSlash

确保路径结尾有斜杠。

可参考我的另一篇博客:

GetDirectoryNameOfFileAbove & GetPathOfFileAbove

这两个是非常有用却又非常容易被忽视的 API,非常有必要介绍一下。

可以阅读我的另一篇博客了解其用途和用法:

MakeRelative

计算两个路径之间的相对路径表示。

<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 的值会计算为 ..\

ValueOrDefault

如果赋值了,就使用所赋的值;否则使用参数指定的值:

<PropertyGroup>
    <WalterlvValue1>$([MSBuild]::ValueOrDefault('$(FooBar)', 'walterlv'))</WalterlvValue1>
    <WalterlvValue2>$([MSBuild]::ValueOrDefault('$(WalterlvValue1)', 'lindexi'))</WalterlvValue2>
</PropertyGroup>

第一行,因为我们没有定义任何一个名为 FooBar 的属性,所以 WalterlvValue1 属性会计算得到 walterlv 值。第二行,因为 WalterlvValue1 已经得到了一个值,所以 WalterlvValue2 也会得到 WalterlvValue1 的值,也就是 walterlv,不会得到 lindexi

其他

MSBuild 剩下的一些方法使用场景非常有限(不懂就别瞎装懂了),这里做一些简单的介绍。


参考资料

05-15 2019

Roslyn/MSBuild 在编译期间从当前文件开始查找父级文件夹,直到找到包含特定文件的文件夹

大家在进行各种开发的时候,往往都不是写一个单纯项目就完了的,通常都会有一个解决方案,里面包含了多个项目甚至是大量的项目。我们经常会考虑输出一些文件或者处理一些文件,例如主项目的输出目录一般会选在仓库的根目录,文档文件夹一般会选在仓库的根目录。

然而,我们希望输出到这些目录或者读取这些目录的项目往往在很深的代码文件夹中。如果直接通过 ..\..\.. 来返回仓库根目录非常不安全,你会数不过来的。


现在,我们有了一个好用的 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 路径。

需要注意的是:

  1. 此方法不支持通配符,也就是说不能使用 *.sln 来找路径
  2. 此方法不支持通过文件夹去找,也就是说不能使用我们熟知的 .git 等等文件夹去找路径
  3. 此方法传入的文件支持使用路径,也就是说可以使用类似于 \src\README.md 的方式来查找路径

参考资料

05-12 2019

Roslyn/MSBuild 在编译期间处理路径中的斜杠与反斜杠

本文介绍如何在项目文件 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 中文件夹路径的末尾有斜杠或者反斜杠。不过,当我们需要将这个路径作为命令行参数的一部分传给一个可执行程序的时候,就没那么容易了。

因为为了确保路径中间的空格不会被命令行参数解析给分离,我们需要在路径的周围加上引号。具体来说,是使用 &quot; 转义字符来添加引号:

<Target Name="WalterlvDemoTarget" BeforeTargets="BeforeBuild">
    <Exec Command="&quot;$(WalterlvDemoTool)&quot; --option &quot;$(WalterlvPath)&quot;" />
</Target>

以上的 Target 是我在另一篇博客中的简化版本:如何创建一个基于命令行工具的跨平台的 NuGet 工具包 - walterlv

但是这样,如果 WalterlvPath 中存在反斜杠,那么这个命令行将变成这样:

> "walterlv.tool.exe" --option "bin\"

后面的 \" 将使得引号成为路径中的一部分,而这样的路径是不合法的路径!

我们可以确保路径的末尾添加一个空格来避免将引号也解析成命令行的一部分:

<Target Name="WalterlvDemoTarget" BeforeTargets="BeforeBuild">
    <Exec Command="&quot;$(WalterlvDemoTool)&quot; --option &quot;$([MSBuild]::EnsureTrailingSlash('$(BasePathInInstaller)')) &quot;" />
</Target>

不过也可以通过 SubString 来对末尾的斜杠或反斜杠进行裁剪。

<WalterlvPath Condition="HasTrailingSlash('$(WalterlvPath)')">$(WalterlvPath.Substring(0, $([MSBuild]::Add($(WalterlvPath.Length), -1))))</WalterlvPath>

解释一下这里 $(WalterlvPath.Substring(0, $([MSBuild]::Add($(WalterlvPath.Length), -1)))) 所做的事情:

  1. $(WalterlvPath.Length) 计算出 WalterlvPath 属性的长度;
  2. $([MSBuild]::Add(length, -1)) 调用加法,将前面计算所得的长度 -1,用于提取无斜杠或反斜杠的路径长度。
  3. $(WalterlvPath.Substring(0, length-1) 将路径字符串取出子串。

这里的解释里面,length 只是表意,并不是为了编译通过。要编译的代码还是上面代码块中的完整代码。

更多关于在 Roslyn/MSBuild 中进行数学运算的内容,可以阅读我的另一篇博客:

05-11 2019

在 Roslyn/MSBuild 中进行基本的数学运算

在任何一种编程语言中,做基本的数学运算都是非常容易的事情。不过,不知道 .NET 项目的项目文件 csproj 文件中进行数学运算就不像一般的编程语言那样直观了,毕竟这不是一门语言,而只是一种项目文件格式而已。

本文介绍如何在 Roslyn/MSBuild 的项目文件中使用基本的数学运算。


Roslyn/MSBuild 中的数学运算

在 MSBuild 中,数学运算需要使用 MSBuild 内建的方法调用来实现。

你只需要给 MSBuild 中那些数学计算方法中传入看起来像是数字的属性,就可以真的计算出数字出来。

加减乘除模

  • Add 两个数相加,实现 a + b
  • Subtract 第一个数减去第二个数,实现 a - b
  • Multiply 两个数相乘,实现 a * b
  • Divide 第一个数除以第二个数,实现 a / b
  • Modulo 第一个数与第二个数取模,实现 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>

不要试图在 MSBuild 中使用传统的数学运算符号

不同于一般编程语言可以写的 + - * /,如果你直接在项目文件中使用这样的符号来进行数学计算,要么你将得到一个数学运算的字符串,要么你将得到编译错误。

例如,如果你在你的项目文件中写了下面这样的代码,那么无一例外全部不能得到正确的数学运算结果。

<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>

参考资料

05-05 2019

Visual Studio 使用 Parallel Builds Monitor 插件迅速找出编译速度慢的瓶颈,优化编译速度

嫌项目编译太慢?不一定是 Visual Studio 的问题,有可能是你项目的引用关系决定这个编译时间真的省不下来。

可是,编译瓶颈在哪里呢?本文介绍 Parallel Builds Monitor 插件,帮助你迅速找出编译瓶颈。


下载安装 Parallel Builds Monitor

前往 Parallel Builds Monitor - Visual Studio Marketplace 下载插件安装。

之后启动 Visual Studio 2019,你就能在 “其他窗口” 中找到 “Parallel Builds Monitor” 窗口了。请点击打开它。

编译项目

现在,使用 Visual Studio 编译一个项目,点开这个窗口,一个正在进行中的甘特图将呈现出来:

并行编译窗口

寻找瓶颈

我们可以通过此插件寻找到多种可能的瓶颈:

  1. 项目依赖瓶颈
  2. CPU 瓶颈
  3. IO 瓶颈

项目依赖瓶颈

看上面的那张图,这里存在典型的项目依赖瓶颈。因为在编译的中后期,几个编译时间最长的项目,其编译过程完全是串联起来编译的。

这里串联起来的每一个项目,都是依赖于前一个项目的。所以要解决掉这部分的性能瓶颈,我们需要断开这几个项目之间的依赖关系,这样它们能变成并行的编译。

CPU 瓶颈

通常,CPU 成为瓶颈在编译中是个好事情,这意味着无关不必要的编译过程非常少,主要耗时都在编译代码的部分。当然,如果你有一些自定义的编译过程浪费了 CPU 占用那是另外一回事。

比如我之前写过自己可以做一个工具包,在编译期间会执行一些代码:

IO 瓶颈

IO 本不应该成为瓶颈。如果你的项目就是存在非常多的依赖文件需要拷贝,那么应该尽可能利用差量编译来避免重复拷贝文件。


参考资料

04-30 2019

如何修改 Visual Studio 新建项目时的默认路径

Visual Studio 创建新项目的时候,默认位置在 C:\Users\lvyi\source\repos\ 下。多数时候,我们都希望将其改为一个更适合自己开发习惯的路径。实际上修改默认路径并不是一个麻烦的事情,但是当紧急需要修改的时候,你可能找不到设置项在哪里。

本文介绍如何修改这个默认路径。


默认位置

默认位置在 C:\Users\lvyi\source\repos\ 下。

默认位置

Visual Studio 的设置项

在 Visual Studio 中打开菜单 “工具” -> “选项”;然后找到 “项目和解决方案” -> “位置” 标签。“项目位置” 一栏就是设置新建项目默认路径的地方。

中文版的设置界面

如果是英文本,则打开菜单 “Tools” -> “Options”;然后找到 “Projects and Solutions” -> “Locations” 标签。“Projects location” 一栏就是设置新建项目默认路径的地方。

英文版的设置界面

修改后的默认位置

修改完后,再次新建项目,就可以看到修改后的默认路径了。

修改后的默认位置

04-26 2019

Visual Studio 通过修改项目的调试配置文件做到临时调试的时候不要编译(解决大项目编译缓慢问题)

.NET 托管程序的编译速度比非托管程序要快非常多,即便是 .NET Core,只要不编译成 Native 程序,编译速度也是很快的。然而总是有一些逗比大项目编译速度非常缓慢(我指的是分钟级别的),而且还没做好差量编译;于是每一次编译都需要等待几十秒到数分钟。这显然是非常影响效率的。

在解决完项目的编译速度问题之前,如何能够临时进行快速调试改错呢?本文将介绍在 Visual Studio 中不进行编译就调试的方法。


我找到了两种临时调试而不用编译的方法:

新建一个普通的类库项目,右击项目,属性,打开属性设置页面。进入“调试”标签:

调试标签

现在,将默认的启动从“项目”改为“可执行文件”,然后将我们本来调试时输出的程序路径贴上去。

现在,如果你不希望编译大项目而直接进行调试,那么将启动项目改为这个小项目即可。

04-26 2019

Visual Studio 如何能够不进行编译就调试 .NET/C# 项目(用于解决大项目编译缓慢的问题)

.NET 托管程序的编译速度比非托管程序要快非常多,即便是 .NET Core,只要不编译成 Native 程序,编译速度也是很快的。然而总是有一些逗比大项目编译速度非常缓慢(我指的是分钟级别的),而且还没做好差量编译;于是每一次编译都需要等待几十秒到数分钟。这显然是非常影响效率的。

在解决完项目的编译速度问题之前,如何能够临时进行快速调试改错呢?本文将介绍在 Visual Studio 中不进行编译就调试的方法。


我找到了两种临时调试而不用编译的方法:

不编译直接调试

有时候只是为了定位 Bug 不断重复运行以调试程序,并没有修改代码。然而如果 Visual Studio 的差量编译因为逗比项目失效的话,就需要手动告诉 Visual Studio 不需要进行编译,直接进行调试。

在 Visual Studio 中设置编译选项

进入 工具 -> 选项 -> 项目和解决方案 -> 生成并运行

打开选项

生成并运行

“当项目过期时”,选择“从不生成”。

顺便附中文版截图:

中文版生成并运行

这时,你再点击运行你的项目的时候,就不会再编译了,而是直接进入调试状态。

这特别适合用来定位 Bug,因为这时基本不改什么代码,都是在尝试复现问题以及查看各种程序的中间状态。

04-24 2019

MSBuild 中的特殊字符($ @ % 等):含义、用法以及转义

在 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'))" />

详细方法可参见:


参考资料

04-24 2019

在项目文件 csproj 中或者 MSBuild 的 Target 中使用 % 引用集合中每一项的属性

在编写项目文件或者 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 不是一个总是会设置的属性。

04-21 2019

如何快速自定义 Visual Studio 中部分功能的快捷键

Visual Studio 中有些自带的快捷键与现有软件有冲突,那么如何修改这些快捷键让这些功能正常工作起来呢?


打开快捷键设置界面

在 Visual Studio 中打开 “工具 -> 选项”,打开选项设置界面。在其中找到 “环境 -> 键盘” 项。我们设置快捷键的地方就在这里。

工具 -> 选项 -> 环境 -> 键盘

修改一个现有功能的快捷键

默认情况下,在 Visual Studio 2019 中快速重构的快捷键是 Ctrl+.。然而,使用中文输入法的各位应该非常清楚,Ctrl+. 是输入法切换中英文符号的快捷键。

于是,当使用中文输入法的时候,实际上是无法通过按下 Ctrl+. 来完成快速重构的。我们需要修改快捷键来避免这样的冲突。

使用 Ctrl+. 来进行快速重构

在“新快捷键”那个框框中,按下 Ctrl+.,正常会在“快捷键的当前使用对象”框中出现此快捷键的功能。不过,如果快捷键已经与输入法冲突,则不会出现,你需要先切换至英文输入法以避免此冲突。

显示此快捷键的当前功能

通过“快捷键的当前使用对象”下拉框,我们可以得知功能的名称,下拉框中的每一项都是此快捷键的功能。

快捷键的当前使用对象

我们需要做的是,搜索这些功能,并为这些功能分配新的快捷键。每一个我们关心的功能都这么设置:

设置快捷键

于是新快捷键就设置好了。

新分配的快捷键

现在,可以使用新的快捷键来操作这些功能了。

可以使用新的快捷键


参考资料

04-12 2019

.NET 应用启用与禁用自动生成绑定重定向 (bindingRedirect),解决不同版本 dll 的依赖问题

当你的项目中多个不同的项目以及不同的依赖存在不同的依赖程序集时,可能会因为依赖于不同版本的程序集而产生冲突。而绑定重定向可以帮助解决不同程序集的依赖版本不同的问题,使整个程序使用统一个版本的 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 就是在描述绑定重定向。

对于上面的代码,指的是:

  1. 如果依赖中发现了任何 0.0.0.0-11.0.0.0 区间版本号的 Newtonsoft.Json 程序集的引用,都将使用 11.0.0.0 版本的。
  2. 如果以来中发现了任何 0.0.0.0-4.0.3.0 区间版本号的 System.ValueTuple 程序集的引用,都将使用 4.0.3.0 版本的(这个其实使用的 NuGet 包版本是 4.5)。

引用同名但不同版本的 dll

绑定重定向多数时候都是在帮助我们解决依赖问题,然而我们总有一些时候不是按照常规的方式来使用依赖,例如下文这样的方式:

以上文章的场景,是需要在同一个解决方案的不同项目中引用不同版本的同名 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>

参考资料

04-12 2019

如何更精准地设置 C# / .NET Core 项目的输出路径?(包括添加和删除各种前后缀)

我们都知道可以通过在 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) 互为默认值,任何一个先设置都会影响另一个;此值即 x86x64 等标识符。可以通过 $(AppendRuntimeIdentifierToOutputPath) 属性指定是否将此加入到输出路径中。

  • $(TargetFramework) 这是在 csproj 文件中强制要求指定的,如果不设置的话项目是无法编译的;可以通过 $(AppendTargetFrameworkToOutputPath) 属性指定是否将此加入到输出路径中。

现在,你应该可以更轻松地设置你的输出路径,而不用担心总会出现各种意料之外的子文件夹了吧!

04-12 2019

在 Visual Studio 新旧不同的 csproj 项目格式中启用混合模式调试程序(开启本机代码调试)

因为我使用 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 文件。

launchsettings.json 文件

打开它,然后删掉刚刚的逗比行为,添加 "nativeDebugging": true。这时,你的 lauchsettings.json 文件影响像下面这样:

{
  "profiles": {
    "Walterlv.Debugging": {
      "commandName": "Project",
      "nativeDebugging": true
    }
  }
}

这时你就可以开启本机代码调试了。当然,新的项目格式支持设置多个这样的启动项,于是你可以分别配置本机和非本机的多种配置:

{
  "profiles": {
    "Walterlv.Debugging": {
      "commandName": "Project"
    },
    "本机调试": {
      "commandName": "Project",
      "nativeDebugging": true
    }
  }
}

现在,你可以选择你项目的启动方式了,其中一个是开启了本机代码调试的方式。

选择项目的启动方式

关于这些配置的更多博客,你可以阅读:VisualStudio 使用多个环境进行调试 - 林德熙


参考资料

03-14 2019

让你的 VSCode 具备调试 C# 语言 .NET Core 程序的能力

如果你是开发个人项目,那就直接用 Visual Studio Community 版本吧,对个人免费,对小团体免费,不需要这么折腾。

如果你是 Mac / Linux 用户,不想用 Visual Studio for Mac 版;或者不想用 Visual Studio for Windows 版那么重磅的 IDE 来开发简单的 .NET Core 程序;或者你就是想像我这么折腾,那我们就开始吧!


安装 .NET Core Sdk、Visual Studio Code 和 C# for Visual Studio Code

  1. 点击这里下载正式或者预览版的 .NET Core 然后安装
  2. 点击这里下载 Visual Studio Code 然后安装
  3. 在 Visual Studio Code 里安装 C# for Visual Studio Code 插件(步骤如下图所示)

安装 C# for Visual Studio Code 插件

搜索的时候,推荐使用 OmniSharp 关键字,因为这可以得到唯一的结果,你不会弄混淆。如果你使用 C# 作为关键字,那需要小心,你得找到名字只有 C#,点开之后是 C# for Visual Studio Code 的那款插件。因为可能装错,所以我不推荐这么做。

对于新版的 Visual Studio Code,装完会自动启用,所以你不用担心。我们可以后续步骤了。

使用 VSCode 创建 .NET Core 项目

本文不会讲解如何使用 VSCode 创建 .NET Core 项目,因为这不是本文的重点。

也许你可以参考我还没有写的另一篇博客。

打开一个现有的 .NET Core 项目

现在假设你已经有一个现成的能用 Visual Studio 跑起来的 .NET Core 控制台项目了(可能是刚克隆下来的,也可能就是用我另一篇博客中的教程创建的),于是我们就在这个项目上进行开发。

本文以我的自动化测试程序 Walterlv.InfinityStartupTest 为例进行说明。如果你找不到合适的例子,可以使用这篇博客创建一个。

在这个文件夹的根目录下右键,然后 使用 Code 打开

使用 Visual Studio Code 打开文件夹

配置编译和调试环境

正常情况下,当你用 Visual Studio Code 打开一个包含 .NET Core 项目的文件夹时,C# 插件会在右下角弹出通知提示,问你要不要为这个项目创建编译和调试文件,当然选择“Yes”。

创建编译和调试文件的提示

这个提示一段时间不点会消失的,但是右下角会有一个小铃铛(上面的图片也可以看得到的),点开可以看到刚刚消失的提示,然后继续操作。

这时,你的项目文件夹中会多出两个文件,都在 .vscode 文件夹中。tasks.json 是编译文件,指导如何进行编译;launch.json 是调试文件,指导如何进行调试。

多出的编译文件和调试文件

开始调试

现在,你只需要按下 F5(就是平时 Visual Studio 调试按烂的那个),你就能使用熟悉的调试方式在 Visual Studio Code 中来调试 .NET Core 程序了。

下图是调试进行中各个界面的功能分区。如果你没看到这个界面,请点击左侧那只被圈在圆圈里面的小虫子。

Visual Studio Code 中的 .NET Core 调试界面

当你按照本文操作,在按下 F5 后有各种报错,那么原因只有一个——你的这个项目本身就是编译不过的,你自己用命令行也会编译不过。你需要解决编译问题,而本文只是入门教程,不会说如何解决编译问题。

手工设置 tasks.json 和 launch.json 文件

如果自动创建的这两个文件有问题,或者你根本就找不到自动创建的入口,可以考虑手工创建这两个文件。

请参见博客:

还补充一句,本文说编译文件和调试文件是不对的,因为在 Visual Studio Code 中没有编译这个概念,编译只是任务中的一种而已。

03-14 2019

手工编辑 tasks.json 和 launch.json,让你的 VSCode 具备调试 .NET Core 程序的能力

如果 C# for Visual Studio Code 没有办法自动为你生成正确的 tasks.json 和 launch.json 文件,那么可以考虑阅读本文手工创建他们。


前期准备

你需要安装 .NET Core Sdk、Visual Studio Code 和 C# for Visual Studio Code,然后打开一个 .NET Core 的项目。如果你没有准备,请先阅读:

本文主要处理自动生成的配置文件无法满足要求,手工生成。

半自动创建 tasks.json 和 launch.json

这依然是个偷懒的好方案,我喜欢。

  1. 按下 F5;
  2. 在弹出的列表中,选择 .NET Core;

选择 .NET Core

自动生成的 tasks.json 和 launch.json

你不需要再做什么其他的工作了,这时再按下 F5 你已经可以开始调试了。

全手工创建 tasks.json 和 launch.json

tasks.json 定义一组任务。其中我们需要的是编译任务,通常编译一个项目使用的动词是 build。比如 dotnet build 命令就是这样的动词。

于是定义一个名字为 build 的任务,对应 label 标签。commandargs 对应我们在命令行中编译一个项目时使用的命令行和参数。typeprocess 表示此任务是启动一个进程。

{
    "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}"
        }
    ]
}

这样自己手写的方式更灵活但是也更难。

03-12 2019

在 csproj 文件中使用系统环境变量的值(示例将 dll 生成到 AppData 目录下)

Windows 系统以及很多应用程序会考虑使用系统的环境变量来传递一些公共的参数或者配置。Windows 资源管理器使用 %var% 来使用环境变量,那么我们能否在 Visual Studio 的项目文件中使用环境变量呢?

本文介绍如何在 csproj 文件中使用环境变量。


遇到的问题

在 Windows 资源管理器中,我们可以使用 %AppData% 进入到用户的漫游路径。我正在为 希沃白板5 为互动教学而生 - 课件制作神器 编写插件,于是需要将插件放到指定目录:

%AppData%\Seewo\EasiNote5\Walterlv.Presentation

在 Windows 资源管理器中可以直接输入以上文字进入对应的目录(当然需要确保存在)。

插件目录

更多关于路径的信息可以参考:UWP 中的各种文件路径(用户、缓存、漫游、安装……) - walterlv

然而,为了调试方便,我最好在 Visual Studio 中编写的时候就能直接输出到插件目录。

于是,我需要将 Visual Studio 的调试目录设置为以上目录,但是以上目录中包含环境变量 %AppData%

在 Visual Studio 中修改输出路径

如果直接在 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 出现在了目标输出路径中。你可以阅读我的另一篇博客了解更多关于输出路径的问题:

03-10 2019

如何使用 MyGet 这个激进的 NuGet 源体验日构建版本的 .NET Standard / .NET Core

很多库都会在 nuget.org 上发布预览版本,不过一般来说这个预览版本也是大多可用的。然而想要体验日构建版本,这个就没有了,毕竟要照顾绝大多数开发者嘛……

本文介绍如何使用 MyGet 这个激进的 NuGet 源,介绍如何使用框架级别的库的预览版本如 .NET Standard 的预览版本。


加入 MyGet 这个 NuGet 源

添加 NuGet 源的方法在我和林德熙的博客中都有说明:

简单点,就是在 Visual Studio 中打开 工具 -> 选项 -> NuGet 包管理器 -> 包源

管理包源

然后把 MyGet 的源添加进去:

如果你想添加其他的 NuGet 源,可以参见我的另一篇博客:我收集的各种公有 NuGet 源 - 吕毅

使用 .NET Standard 的预览版本

因为我们在使用 .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 的正式版本库。

03-05 2019

C#/.NET 调试的时候显示自定义的调试信息(DebuggerDisplay 和 DebuggerTypeProxy)

使用 Visual Studio 调试 .NET 程序的时候,在局部变量窗格或者用鼠标划到变量上就能查看变量的各个字段和属性的值。默认显示的是对象 ToString() 方法调用之后返回的字符串,不过如果 ToString() 已经被占作它用,或者我们只是希望在调试的时候得到我们最希望关心的信息,则需要使用 .NET 中调试器相关的特性。

本文介绍使用 DebuggerDisplayAttributeDebuggerTypeProxyAttribute 来自定义调试信息的显示。(同时隐藏我们在背后做的这些见不得人的事儿。)


示例代码

比如我们有一个名为 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 层才能知道命令行参数究竟是什么。这样的调试效率显然是太低了!

原生的调试显示

DebuggerDisplay

使用 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

不过,展开对象查看的时候可以看到一个 DebuggerDisplay 的属性,而这个属性我们只是调试使用,这是个垃圾属性,并不应该影响我们的查看。

里面有一个 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)来去掉最终显示的引号。

DebuggerTypeProxy

虽然我们使用了 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

  1. 如果这是一个集合,那么这个集合将直接显示到调试视图的上一级视图中;
  2. 如果这是一个普通对象,那么这个对象的各个属性字段将合并到上一级视图中显示。

别忘了我们还需要禁止 _owner 在调试器中显示,然后把 [DebuggerTypeProxy(typeof(CommandLineDebugView))] 加到 CommandLine 类型上。

这样,最终的显示效果是这样的:

使用 DebuggerTypeProxy

点击 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();
        }
    }
}

参考资料

03-04 2019

通过重写预定义的 Target 来扩展 MSBuild / Visual Studio 的编译过程

MSBuild 的编译过程提供了一些可以被重写的 Target,通过重写这些 Target 可以扩展 MSBuild 的编译过程。


重写预定义的 Target

有这些预定义的 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 的值扩展编译

有这些预定义的 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。如果有其他的小伙伴使用了相同的方式去改写这个属性的值,那么它获取原有值的时候就会把这里已经赋过的值放入到它新的值的中间。也就是说,一个也不会丢。


参考资料

03-04 2019

在 Target 中获取项目引用的所有依赖(dll/NuGet/Project)的路径

在项目编译成 dll 之前,如何分析项目的所有依赖呢?可以在在项目的 Target 中去收集项目的依赖。

本文将说明如何在 Target 中收集项目依赖的所有 dll 的文件路径。


编写 Target

<Target Name="WalterlvDemoTarget" BeforeTargets="CoreCompile">
    <Message Text="References:" />
    <Message Text="@(Reference)" />
  </Target>

这个 Target 的作用是将项目的所有 Reference 节点作为集合输出出来。然而实际上如果真的编译这个项目,会发现我们得到的结果有一些问题:

  1. 实际上其值就是写到每一个 Reference 里面的字符串的集合
    • 比如引用了 System.Xaml,那么这里就会是 System.Xaml
  2. 如果引用是通过 ProjectReference 进行的项目引用,那么这里就没有目标项目的 dll

所以,我们需要一个新的属性来查找引用的 dll。通过 研究 Microsoft.NET.Sdk 的源码,我发现有 ReferencePath 属性可以使用,于是将 Target 改为这样:

<Target Name="WalterlvDemoTarget" BeforeTargets="CoreCompile;ResolveAssemblyReference">
    <Message Text="ReferencePaths:" />
    <Message Text="@(ReferencePath)" />
  </Target>

现在得到的所有依赖字符串则没有以上的问题。

注意,我在 BeforeTargets 上增加了一个 ResolveAssemblyReference

以上 Target 的输出

引用通常很多,所以我将以上的输出单独放到这里来,避免影响到上面一节知识的阅读。

Reference 的输出

可以看到,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 的输出

可以看到,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 中可以看出,它还输出了以下这些属性或集合:

  • ReferenceDependencyPaths
  • ReferenceSatellitePaths
  • ReferenceCopyLocalPaths
    • 这是需要拷贝到本地的那些 dll 的路径(不含框架自带的 dll)
  • SuggestedBindingRedirects
  • FileWrites
    • 要写入的一些缓存文件
  • DependsOnSystemRuntime
    • 以上都是集合,唯独这是一个布尔值,表示是否依赖系统运行时

03-01 2019

编写 MSBuild 内联编译任务(Task)用于获取当前编译环境下的所有编译目标(Target)

我之前写过一些改变 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 文件贴出来了。如果你希望在你的项目中去使用,可以只复制 UsingTaskTarget 两个部分。

<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 的输出:

输出的所有 Target

WalterlvOutputAllTargets:
  输出的 TargetOutputAll
  输出的 Target_CheckForUnsupportedTargetFramework
  输出的 Target_CollectTargetFrameworkForTelemetry
  输出的 Target_CheckForUnsupportedNETCoreVersion
  输出的 Target_CheckForUnsupportedNETStandardVersion
  输出的 Target_CheckForUnsupportedAppHostUsage
  输出的 Target_CheckForMismatchingPlatform
  输出的 Target_CheckForNETCoreSdkIsPreview
  输出的 TargetAdjustDefaultPlatformTargetForNetFrameworkExeWithNoNativeCopyLocalItems
  输出的 TargetCreateManifestResourceNames
  输出的 TargetResolveCodeAnalysisRuleSet
  输出的 TargetXamlPreCompile
  输出的 TargetShimReferencePathsWhenCommonTargetsDoesNotUnderstandReferenceAssemblies
  输出的 Target_BeforeVBCSCoreCompile
  输出的 TargetInitializeSourceRootMappedPaths
  输出的 Target_InitializeSourceRootMappedPathsFromSourceControl
  输出的 Target_SetPathMapFromSourceRoots
  输出的 TargetCoreCompile
  输出的 TargetResolvePackageDependenciesDesignTime
  输出的 TargetCollectSDKReferencesDesignTime
  输出的 TargetCollectResolvedSDKReferencesDesignTime
  输出的 TargetCollectPackageReferences
  输出的 Target_CheckCompileDesignTimePrerequisite
  输出的 TargetCollectAnalyzersDesignTime
  输出的 TargetCollectResolvedCompilationReferencesDesignTime
  输出的 TargetCollectUpToDateCheckInputDesignTime
  输出的 TargetCollectUpToDateCheckOutputDesignTime
  输出的 TargetCollectUpToDateCheckBuiltDesignTime
  输出的 TargetCompileDesignTime
  输出的 Target_FixVCLibs120References
  输出的 Target_AddVCLibs140UniversalCrtDebugReference
  输出的 TargetInitializeSourceControlInformation
  输出的 Target_CheckForInvalidConfigurationAndPlatform
  输出的 TargetBuild
  输出的 TargetBeforeBuild
  输出的 TargetAfterBuild
  输出的 TargetCoreBuild
  输出的 TargetRebuild
  输出的 TargetBeforeRebuild
  输出的 TargetAfterRebuild
  输出的 TargetBuildGenerateSources
  输出的 TargetBuildGenerateSourcesTraverse
  输出的 TargetBuildCompile
  输出的 TargetBuildCompileTraverse
  输出的 TargetBuildLink
  输出的 TargetBuildLinkTraverse
  输出的 TargetCopyRunEnvironmentFiles
  输出的 TargetRun
  输出的 TargetBuildOnlySettings
  输出的 TargetPrepareForBuild
  输出的 TargetGetFrameworkPaths
  输出的 TargetGetReferenceAssemblyPaths
  输出的 TargetGetTargetFrameworkMoniker
  输出的 TargetGetTargetFrameworkMonikerDisplayName
  输出的 TargetGetTargetFrameworkDirectories
  输出的 TargetAssignLinkMetadata
  输出的 TargetPreBuildEvent
  输出的 TargetUnmanagedUnregistration
  输出的 TargetGetTargetFrameworkVersion
  输出的 TargetResolveReferences
  输出的 TargetBeforeResolveReferences
  输出的 TargetAfterResolveReferences
  输出的 TargetAssignProjectConfiguration
  输出的 Target_SplitProjectReferencesByFileExistence
  输出的 Target_GetProjectReferenceTargetFrameworkProperties
  输出的 TargetGetTargetFrameworks
  输出的 TargetGetTargetFrameworkProperties
  输出的 TargetPrepareProjectReferences
  输出的 TargetResolveProjectReferences
  输出的 TargetResolveProjectReferencesDesignTime
  输出的 TargetExpandSDKReferencesDesignTime
  输出的 TargetGetTargetPath
  输出的 TargetGetTargetPathWithTargetPlatformMoniker
  输出的 TargetGetNativeManifest
  输出的 TargetResolveNativeReferences
  输出的 TargetResolveAssemblyReferences
  输出的 TargetFindReferenceAssembliesForReferences
  输出的 TargetGenerateBindingRedirects
  输出的 TargetGenerateBindingRedirectsUpdateAppConfig
  输出的 TargetGetInstalledSDKLocations
  输出的 TargetResolveSDKReferences
  输出的 TargetResolveSDKReferencesDesignTime
  输出的 TargetFindInvalidProjectReferences
  输出的 TargetGetReferenceTargetPlatformMonikers
  输出的 TargetExpandSDKReferences
  输出的 TargetExportWindowsMDFile
  输出的 TargetResolveAssemblyReferencesDesignTime
  输出的 TargetDesignTimeResolveAssemblyReferences
  输出的 TargetResolveComReferences
  输出的 TargetResolveComReferencesDesignTime
  输出的 TargetPrepareResources
  输出的 TargetPrepareResourceNames
  输出的 TargetAssignTargetPaths
  输出的 TargetGetItemTargetPaths
  输出的 TargetSplitResourcesByCulture
  输出的 TargetCreateCustomManifestResourceNames
  输出的 TargetResGen
  输出的 TargetBeforeResGen
  输出的 TargetAfterResGen
  输出的 TargetCoreResGen
  输出的 TargetCompileLicxFiles
  输出的 TargetResolveKeySource
  输出的 TargetCompile
  输出的 Target_GenerateCompileInputs
  输出的 TargetGenerateTargetFrameworkMonikerAttribute
  输出的 TargetGenerateAdditionalSources
  输出的 TargetBeforeCompile
  输出的 TargetAfterCompile
  输出的 Target_TimeStampBeforeCompile
  输出的 Target_GenerateCompileDependencyCache
  输出的 Target_TimeStampAfterCompile
  输出的 Target_ComputeNonExistentFileProperty
  输出的 TargetGenerateSerializationAssemblies
  输出的 TargetCreateSatelliteAssemblies
  输出的 Target_GenerateSatelliteAssemblyInputs
  输出的 TargetGenerateSatelliteAssemblies
  输出的 TargetComputeIntermediateSatelliteAssemblies
  输出的 TargetSetWin32ManifestProperties
  输出的 Target_SetExternalWin32ManifestProperties
  输出的 Target_SetEmbeddedWin32ManifestProperties
  输出的 Target_GenerateResolvedDeploymentManifestEntryPoint
  输出的 TargetGenerateManifests
  输出的 TargetGenerateApplicationManifest
  输出的 Target_DeploymentComputeNativeManifestInfo
  输出的 Target_DeploymentComputeClickOnceManifestInfo
  输出的 Target_DeploymentGenerateTrustInfo
  输出的 TargetGenerateDeploymentManifest
  输出的 TargetPrepareForRun
  输出的 TargetCopyFilesToOutputDirectory
  输出的 Target_CopyFilesMarkedCopyLocal
  输出的 Target_CopySourceItemsToOutputDirectory
  输出的 TargetGetCopyToOutputDirectoryItems
  输出的 TargetGetCopyToPublishDirectoryItems
  输出的 Target_CopyOutOfDateSourceItemsToOutputDirectory
  输出的 Target_CopyOutOfDateSourceItemsToOutputDirectoryAlways
  输出的 Target_CopyAppConfigFile
  输出的 Target_CopyManifestFiles
  输出的 Target_CheckForCompileOutputs
  输出的 Target_SGenCheckForOutputs
  输出的 TargetUnmanagedRegistration
  输出的 TargetIncrementalClean
  输出的 Target_CleanGetCurrentAndPriorFileWrites
  输出的 TargetClean
  输出的 TargetBeforeClean
  输出的 TargetAfterClean
  输出的 TargetCleanReferencedProjects
  输出的 TargetCoreClean
  输出的 Target_CleanRecordFileWrites
  输出的 TargetCleanPublishFolder
  输出的 TargetPostBuildEvent
  输出的 TargetPublish
  输出的 Target_DeploymentUnpublishable
  输出的 TargetSetGenerateManifests
  输出的 TargetPublishOnly
  输出的 TargetBeforePublish
  输出的 TargetAfterPublish
  输出的 TargetPublishBuild
  输出的 Target_CopyFilesToPublishFolder
  输出的 Target_DeploymentGenerateBootstrapper
  输出的 Target_DeploymentSignClickOnceDeployment
  输出的 TargetAllProjectOutputGroups
  输出的 TargetBuiltProjectOutputGroup
  输出的 TargetDebugSymbolsProjectOutputGroup
  输出的 TargetDocumentationProjectOutputGroup
  输出的 TargetSatelliteDllsProjectOutputGroup
  输出的 TargetSourceFilesProjectOutputGroup
  输出的 TargetGetCompile
  输出的 TargetContentFilesProjectOutputGroup
  输出的 TargetSGenFilesOutputGroup
  输出的 TargetGetResolvedSDKReferences
  输出的 TargetCollectReferencedNuGetPackages
  输出的 TargetPriFilesOutputGroup
  输出的 TargetSDKRedistOutputGroup
  输出的 TargetAllProjectOutputGroupsDependencies
  输出的 TargetBuiltProjectOutputGroupDependencies
  输出的 TargetDebugSymbolsProjectOutputGroupDependencies
  输出的 TargetSatelliteDllsProjectOutputGroupDependencies
  输出的 TargetDocumentationProjectOutputGroupDependencies
  输出的 TargetSGenFilesOutputGroupDependencies
  输出的 TargetReferenceCopyLocalPathsOutputGroup
  输出的 TargetSetCABuildNativeEnvironmentVariables
  输出的 TargetRunCodeAnalysis
  输出的 TargetRunNativeCodeAnalysis
  输出的 TargetRunSelectedFileNativeCodeAnalysis
  输出的 TargetRunMergeNativeCodeAnalysis
  输出的 TargetImplicitlyExpandDesignTimeFacades
  输出的 TargetGetWinFXPath
  输出的 TargetDesignTimeMarkupCompilation
  输出的 TargetPrepareResourcesForSatelliteAssemblies
  输出的 Target_AfterCompileWinFXInternal
  输出的 TargetAfterCompileWinFX
  输出的 TargetAfterMarkupCompilePass1
  输出的 TargetAfterMarkupCompilePass2
  输出的 TargetMarkupCompilePass1
  输出的 TargetMarkupCompilePass2
  输出的 Target_CompileTemporaryAssembly
  输出的 TargetMarkupCompilePass2ForMainAssembly
  输出的 TargetGenerateTemporaryTargetAssembly
  输出的 TargetCleanupTemporaryTargetAssembly
  输出的 TargetAddIntermediateAssemblyToReferenceList
  输出的 TargetSatelliteOnlyMarkupCompilePass2
  输出的 TargetHostInBrowserValidation
  输出的 TargetSplashScreenValidation
  输出的 TargetResignApplicationManifest
  输出的 TargetSignDeploymentManifest
  输出的 TargetFileClassification
  输出的 TargetMainResourcesGeneration
  输出的 TargetSatelliteResourceGeneration
  输出的 TargetGenerateResourceWithCultureItem
  输出的 TargetCheckUid
  输出的 TargetUpdateUid
  输出的 TargetRemoveUid
  输出的 TargetMergeLocalizationDirectives
  输出的 TargetAssignWinFXEmbeddedResource
  输出的 TargetEntityDeploy
  输出的 TargetEntityDeploySplit
  输出的 TargetEntityDeployNonEmbeddedResources
  输出的 TargetEntityDeployEmbeddedResources
  输出的 TargetEntityClean
  输出的 TargetEntityDeploySetLogicalNames
  输出的 TargetDesignTimeXamlMarkupCompilation
  输出的 TargetInProcessXamlMarkupCompilePass1
  输出的 TargetCleanInProcessXamlGeneratedFiles
  输出的 TargetXamlMarkupCompileReadGeneratedFileList
  输出的 TargetXamlMarkupCompilePass1
  输出的 TargetXamlMarkupCompileAddFilesGenerated
  输出的 TargetXamlMarkupCompileReadPass2Flag
  输出的 TargetXamlTemporaryAssemblyGeneration
  输出的 TargetCompileTemporaryAssembly
  输出的 TargetXamlMarkupCompilePass2
  输出的 TargetXamlMarkupCompileAddExtensionFilesGenerated
  输出的 TargetGetCopyToOutputDirectoryXamlAppDefs
  输出的 TargetExpressionBuildExtension
  输出的 TargetValidationExtension
  输出的 TargetGenerateCompiledExpressionsTempFile
  输出的 TargetAddDeferredValidationErrorsFileToFileWrites
  输出的 TargetReportValidationBuildExtensionErrors
  输出的 TargetDeferredValidation
  输出的 TargetResolveTestReferences
  输出的 TargetCleanAppxPackage
  输出的 TargetGetPackagingOutputs
  输出的 TargetRestore
  输出的 TargetGenerateRestoreGraphFile
  输出的 Target_LoadRestoreGraphEntryPoints
  输出的 Target_FilterRestoreGraphProjectInputItems
  输出的 Target_GenerateRestoreGraph
  输出的 Target_GenerateRestoreGraphProjectEntry
  输出的 Target_GenerateRestoreSpecs
  输出的 Target_GenerateDotnetCliToolReferenceSpecs
  输出的 Target_GetProjectJsonPath
  输出的 Target_GetRestoreProjectStyle
  输出的 TargetEnableIntermediateOutputPathMismatchWarning
  输出的 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
  输出的 TargetDesktopBridgeCopyLocalOutputGroup
  输出的 TargetDesktopBridgeComFilesOutputGroup
  输出的 TargetGetDeployableContentReferenceOutputs
  输出的 TargetDockerResolveAppType
  输出的 TargetDockerUpdateComposeVsGeneratedFiles
  输出的 TargetDockerResolveTargetFramework
  输出的 TargetDockerComposeBuild
  输出的 TargetDockerPackageService
  输出的 TargetImplicitlyExpandNETStandardFacades
  输出的 Target_RemoveZipFileSuggestedRedirect
  输出的 TargetSetARM64AppxPackageInputsForInboxNetNative
  输出的 Target_CleanMdbFiles
  输出的 TargetPreXsdCodeGen
  输出的 TargetXsdCodeGen
  输出的 TargetXsdResolveReferencePath
  输出的 TargetCleanXsdCodeGen
  输出的 Target_SetTargetFrameworkMonikerAttribute
  输出的 TargetResolvePackageDependenciesForBuild
  输出的 TargetRunResolvePackageDependencies
  输出的 TargetResolvePackageAssets
  输出的 TargetFilterSatelliteResources
  输出的 TargetRunProduceContentAssets
  输出的 TargetReportAssetsLogMessages
  输出的 TargetResolveLockFileReferences
  输出的 TargetIncludeTransitiveProjectReferences
  输出的 TargetResolveLockFileAnalyzers
  输出的 Target_ComputeLockFileCopyLocal
  输出的 TargetResolveLockFileCopyLocalProjectDeps
  输出的 TargetCheckForImplicitPackageReferenceOverrides
  输出的 TargetCheckForDuplicateItems
  输出的 TargetGenerateBuildDependencyFile
  输出的 TargetGenerateBuildRuntimeConfigurationFiles
  输出的 TargetAddRuntimeConfigFileToBuiltProjectOutputGroupOutput
  输出的 Target_SdkBeforeClean
  输出的 Target_SdkBeforeRebuild
  输出的 Target_ComputeNETCoreBuildOutputFiles
  输出的 Target_ComputeReferenceAssemblies
  输出的 TargetCoreGenerateSatelliteAssemblies
  输出的 Target_GetAssemblyInfoFromTemplateFile
  输出的 Target_DefaultMicrosoftNETPlatformLibrary
  输出的 TargetGetAllRuntimeIdentifiers
  输出的 TargetGenerateAssemblyInfo
  输出的 TargetAddSourceRevisionToInformationalVersion
  输出的 TargetGetAssemblyAttributes
  输出的 TargetCreateGeneratedAssemblyInfoInputsCacheFile
  输出的 TargetCoreGenerateAssemblyInfo
  输出的 TargetGetAssemblyVersion
  输出的 TargetComposeStore
  输出的 TargetStoreWorkerMain
  输出的 TargetStoreWorkerMapper
  输出的 TargetStoreResolver
  输出的 TargetStoreWorkerPerformWork
  输出的 TargetStoreFinalizer
  输出的 Target_CopyResolvedOptimizedFiles
  输出的 TargetPrepareForComposeStore
  输出的 TargetPrepforRestoreForComposeStore
  输出的 TargetRestoreForComposeStore
  输出的 TargetComputeAndCopyFilesToStoreDirectory
  输出的 TargetCopyFilesToStoreDirectory
  输出的 Target_CopyResolvedUnOptimizedFiles
  输出的 Target_ComputeResolvedFilesToStoreTypes
  输出的 Target_SplitResolvedFiles
  输出的 Target_GetResolvedFilesToStore
  输出的 TargetComputeFilesToStore
  输出的 TargetPrepRestoreForStoreProjects
  输出的 TargetPrepOptimizer
  输出的 Target_RunOptimizer
  输出的 TargetRunCrossGen
  输出的 Target_InitializeBasicProps
  输出的 Target_GetCrossgenProps
  输出的 Target_SetupStageForCrossgen
  输出的 Target_RestoreCrossgen
  输出的 Target_CheckForObsoleteDotNetCliToolReferences
  输出的 Target_PublishBuildAlternative
  输出的 Target_PublishNoBuildAlternative
  输出的 Target_PreventProjectReferencesFromBuilding
  输出的 TargetPrepareForPublish
  输出的 TargetComputeAndCopyFilesToPublishDirectory
  输出的 TargetCopyFilesToPublishDirectory
  输出的 Target_CopyResolvedFilesToPublishPreserveNewest
  输出的 Target_CopyResolvedFilesToPublishAlways
  输出的 Target_ComputeResolvedFilesToPublishTypes
  输出的 TargetComputeFilesToPublish
  输出的 Target_ComputeNetPublishAssets
  输出的 TargetRunResolvePublishAssemblies
  输出的 TargetFilterPublishSatelliteResources
  输出的 Target_ComputeCopyToPublishDirectoryItems
  输出的 TargetDefaultCopyToPublishDirectoryMetadata
  输出的 TargetGeneratePublishDependencyFile
  输出的 Target_ComputeExcludeFromPublishPackageReferences
  输出的 Target_ParseTargetManifestFiles
  输出的 TargetGeneratePublishRuntimeConfigurationFile
  输出的 TargetDeployAppHost
  输出的 TargetPackTool
  输出的 TargetGenerateToolsSettingsFileFromBuildProperty
  输出的 TargetResolveApphostAsset
  输出的 TargetComputeDependencyFileCompilerOptions
  输出的 TargetComputeRefAssembliesToPublish
  输出的 Target_CopyReferenceOnlyAssembliesForBuild
  输出的 Target_HandlePackageFileConflicts
  输出的 Target_HandlePublishFileConflicts
  输出的 Target_GetOutputItemsFromPack
  输出的 Target_GetTargetFrameworksOutput
  输出的 Target_PackAsBuildAfterTarget
  输出的 Target_CleanPackageFiles
  输出的 Target_CalculateInputsOutputsForPack
  输出的 TargetPack
  输出的 Target_IntermediatePack
  输出的 TargetGenerateNuspec
  输出的 Target_InitializeNuspecRepositoryInformationProperties
  输出的 Target_LoadPackInputItems
  输出的 Target_GetProjectReferenceVersions
  输出的 Target_GetProjectVersion
  输出的 Target_WalkEachTargetPerFramework
  输出的 Target_GetFrameworksWithSuppressedDependencies
  输出的 Target_GetFrameworkAssemblyReferences
  输出的 Target_GetBuildOutputFilesWithTfm
  输出的 Target_GetTfmSpecificContentForPackage
  输出的 Target_GetDebugSymbolsWithTfm
  输出的 Target_AddPriFileToPackBuildOutput
  输出的 Target_GetPackageFiles

参考资料

03-01 2019

如何在 csproj 中用 C# 代码写一个内联的编译任务 Task

我之前写过一些改变 MSBuild 编译过程的一些博客,包括利用 Microsoft.NET.Sdk 中各种自带的 Task 来执行各种各样的编译任务。更复杂的任务难以直接利用自带的 Task 实现,需要自己写 Task。

本文介绍非常简单的 Task 的编写方式 —— 在 csproj 文件中写内联的 Task。


前置知识

在阅读本文之前,你至少需要懂得:

  • csproj 文件的结构以及编译过程
  • Target 是什么,Task 是什么

所以如果你不懂或者理不清,则请先阅读:

关于 Task 的理解,我有一些介绍自带 Task 的博客以及如何编写 Task 的教程:

编写内联的编译任务(Task)

如果你阅读了前面的博客,那么大致知道如何写一个在编译期间执行的 Task。不过,默认你需要编写一个额外的项目来写 Task,然后将这个项目生成 dll 供编译过程通过 UsingTask 来使用。然而如果 Task 足够简单,那么依然需要那么复杂的过程显然开发成本过高。

于是现在可以编写内联的 Task:

  1. 内联任务的支持需要用到 Microsoft.Build.Tasks.v4.0.dll
  2. 我们用 <![CDATA[ ]]> 来内嵌 C# 代码;
  3. 除了用 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

输出内容

编写更复杂的内联编译任务

阅读我的另一篇博客了解如何编写一个更复杂的内联编译任务:

02-27 2019

在 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 警告以及某些不太严重的警告,例如关于始终评估为 truefalse 的表达式的警告。
  • 4 默认值 显示所有 3 级警告和普通信息警告。

参考资料

02-27 2019

全局或为单独的项目添加自定义的 NuGet 源

本文介绍如何添加自定义的 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 中添加

在 Visual Studio 中打开 工具 -> 选项 -> NuGet 包管理器 -> 包源

管理包源

然后在界面上添加、删除、启用和禁用 NuGet 源。

值得注意的是:

  1. 在 Visual Studio 中是不能禁用掉官方源 nuget.org 的,无论你如何取消勾选,实际都不会生效。
    • 如果要取消,你需要用命令行或者手工编辑配置文件。
  2. 你可以添加一个本地路径作为本地 NuGet 源,而那个路径只要存在 *.nupkg 文件就够了。
    • 对于 .NET Core 项目,勾选编译后生成 NuGet 包则会在输出路径生成这样的文件,于是你可以本地调试。

直接修改配置文件

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 源

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>

02-21 2019

使用 Visual Studio 调试多进程的程序

当你的编写的是一个多进程的程序的时候,调试起来可能会比较困难,因为 Visual Studio 默认只会把你当前设置的启动项目的启动调试。

本文将介绍几种用 Visual Studio 调试多进程程序的方法,然后给出每种方法的适用条件和优劣。


Visual Studio 多启动项目(推荐)

在 Visual Studio 的解决方案上点击右键,属性。在公共属性节点中选择启动项目。

在这里,你可以给多个项目都设置成启动项目,就像下图这样:

设置多启动项目

当然,这些项目都必须要是能够启动的才行(不一定是可执行程序)。

此方案的好处是 Visual Studio 原生支持。但此方案的使用必须满足两个前提:

  1. 要调试的多个进程必须是不同的项目编译出来的;
  2. 这些项目之间的启动顺序不能有明显的依赖关系(所以你可能需要修改你的代码使得这两个进程之间可以互相唤起)。

Microsoft Child Process Debugging Power Tool 插件(推荐)

安装和配置插件

请先安装 Microsoft Child Process Debugging Power Tool 插件。

安装插件后启动 Visual Studio,可以在 Debug -> Other Debugging Targets 中找到 Child Process Debugging Settings。

打开 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();
            }
        }
    }
}

我们在 ifelse 中都打上断点。正常情况下运行,只有 else 中的代码可以进断点;而如果以上子进程调试配置正确,那么两边你都可以进入断点(如下图)。

子进程进入了调试断点

值得注意的是,只要启动了本机代码调试,就不能在程序暂停之后修改代码了(像平时调试纯托管代码那样)。

在代码中编写“附加调试器”

调用 Debugger.Launch() 可以启动一个调试器来调试此进程。于是我们可以在我们被调试的程序中写下如下代码:

#if DEBUG
    if (!Debugger.IsAttached)
    {
        Debugger.Launch();
    }
#endif

仅在 DEBUG 条件下,如果当前没有附加任何调试器,那么就启动一个新的调试器来调试它。

当存在以上代码时,运行会弹出一个对话框,用于选择调试器。

选择调试器

这里选择的调试器有个不太方便的地方,如果调试器已经在使用,那么就不能选择。对于我们目前的场景,我们的主进程已经在调试了,所以子进程选择调试器的时候不能再选择主进程调试所用的 Visual Studio 了,而只能选择一个新的 Visual Studio;这一点很不方便。

对于此方法,我的建议是平常不要在团队项目中使用(这会让团队中的其他人不方便)。但是由于代码简单不需要配置,所以临时使用的话还是非常建议的。

在代码中调用 Visual Studio 的 COM 组件 API

编写中……

总结

综上,虽然我给出了 4 种不同的方法,但实际上没有任何一种方法能够像我们调试单个原生托管程序那样方便。每一种方法都各有优劣,一般情况下建议你使用我标注了“推荐”的方法;不过也建议针对不同的情况采用不同的方案。

  1. 简单的个人项目,希望快速开始多进程/子进程调试
    • 使用附加调试器
  2. 你有多个项目组成的多进程,并且这些进程恰好可以互相唤起,它们之间的启动顺序不影响父子进程的组成
    • 使用 Visual Studio 的多启动项目
  3. 你只有单个项目组成的多进程,或者多个进程之间依赖于启动顺序来组成父子进程

参考资料

01-16 2019

让 MSBuild Target 支持 Clean

我们有时候会使用解决方案的清理(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 是如何工作起来的。


参考资料

2018
05-22 2018

新 csproj 对 WPF/UWP 支持不太好?有第三方 SDK 可以用!MSBuild.Sdk.Extras

自从微软推出 .NET Core 以来,新的项目文件格式以其优秀的可扩展性正吸引着更多项目采用。然而——微软官方的 WPF/UWP 项目模板依然还在采用旧的 csproj 格式!

这只是因为——官方 SDK 依然对 WPF/UWP 支持不够友好。


为什么要使用第三方的 SDK?

关于项目文件格式的迁移,我和 林德熙 都写过文章:

不过,这两篇文章中的迁移方法都是手动或半自动迁移的。而且迁移完毕之后,对新增的 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 官方支持后较快地迁移成官方版本。

如何使用 MSBuild.Sdk.Extras

虽说是第三方 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 项目格式之前,这应该算是最简单的迁移方案了!

至于项目结构的效果,可以看下图所示:

net47 和 uap10.0

相比于此前的手工迁移,使用此新格式创建出来的 XAML 文件是可见的,而且 .xaml.cs 也是折叠在 .xaml 之下,且能正常编译!(当然,咱们还得考虑 UWP 和 WPF 在 XAML 书写上的细微差异)

官方提供了更多的使用方法,例如更简单的是安装 NuGet 包,而不修改 SDK。详见:onovotny/MSBuildSdkExtras: Extra properties for MSBuild SDK projects

参考资料

05-20 2018

如何编写基于 Microsoft.NET.Sdk 的跨平台的 MSBuild Target

我之前写过一篇 理解 C# 项目 csproj 文件格式的本质和编译流程,其中,Target 节点就是负责编译流程的最关键的节点。但因为篇幅限制,那篇文章不便详说。于是,我在本文说说 Target 节点。


Target 的节点结构

<Target> 内部几乎有着跟 <Project> 一样的节点结构,内部也可以放 PropertyGroupItemGroup,不过还能放更加厉害的 Task。按照惯例,我依然用思维导图将节点结构进行了总结:

Target 的节点结构
▲ 上面有绿线和蓝线区分,仅仅是因为出现了交叉,怕出现理解歧义

<Hash><WriteCodeFragment> 都是 Task。我们可以看到,Task 是多种多样的,它可以占用一个 xml 节点。而本例中,WriteCodeFragment Task 就是生成代码文件,并且将生成的文件作为一项 Compile 的 Item 和 FileWrites 的 Item。在 理解 C# 项目 csproj 文件格式的本质和编译流程 中我们提到 ItemGroup 的节点,其作用由 Target 指定。所有 Compile 会在名为 CoreCompileTarget 中使用,而 FileWrites 在 Microsoft.NET.Sdk 的多处都生成了这样的节点,不过目前从我查看到的全部 Microsoft.NET.Sdk 中,发现内部并没有使用它。

Target 执行的时机和先后顺序

既然 <Target> 内部节点很大部分跟 <Project> 一样,那区别在哪里呢?<Project> 里的 <PropertyGroup><ItemGroup> 是静态的状态,如果使用 Visual Studio 打开项目,那么所有的状态将会直接在 Visual Studio 的项目文件列表和项目属性中显示;而 <Target> 内部的 <PropertyGroup><ItemGroup> 是在编译期间动态生成的,不会在 Visual Studio 中显示;不过,它为我们提供了一种在编译期间动态生成文件或属性的能力。

总结起来就是——Target 是在编译期间执行的

不过,同样是编译期间,那么多个 Target,它们之间的执行时机是怎么确定的呢?

一共有五个属性决定了 Target 之间的执行顺序:

  • Project 的属性
    • InitialTargets 项目初始化的时候应该执行的 Target
    • DefaultTargets 如果没有指定执行的 Target,那么这个属性将指定执行的 Target
  • Target 的属性
    • DependsOnTargets 在执行此 Target 之前应该执行的另一个或多个 Target
    • BeforeTargets 这是 MSBuild 4.0 新增的,指定应该在另一个或多个 Target 之前执行
    • AfterTargets 这也是 MSBuild 4.0 新增的,指定应该在另一个或多个 Target 之后执行

通过指定这些属性,我们的 Target 能够被 MSBuild 自动选择合适的顺序进行执行。例如,当我们希望自定义版本号,那么就需要赶在我们此前提到的 GenerateAssemblyInfo 之前执行。

Microsoft.NET.Sdk 为我们提供的现成可用的 Task

有 Microsoft.NET.Sdk 的帮助,我们可以很容易地编写自己的 Target,因为很多功能它都帮我们实现好了,我们排列组合一下就好。

提供的 Task 还有更多,如果上面不够你写出想要的功能,可以移步至官方文档翻阅:MSBuild Task Reference - Visual Studio - Microsoft Docs

使用自己写的 Task

我有另外的一篇文章来介绍如何创建一个基于 MSBuild Task 的跨平台的 NuGet 工具包 - 吕毅。如果希望自己写 Ta

差量编译

如果你认为自己写的 Target 执行比较耗时,那么就可以使用差量编译。我另写了一篇文章专门来说 Target 的差量编译:每次都要重新编译?太慢!让跨平台的 MSBuild/dotnet build 的 Target 支持差量编译 - 吕毅


参考资料