dotnet 职业技术学院

博客

dotnet 职业技术学院

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

dotnet 职业技术学院 发布于 2019-07-27

做库的时候,需要一定程度上保持 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 文件中。


参考资料

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

dotnet 职业技术学院 发布于 2019-07-27

你是否好奇 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;
}

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

dotnet 职业技术学院 发布于 2019-07-25

我这里使用 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。


参考资料

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

dotnet 职业技术学院 发布于 2019-07-25

使用 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 的前面,出现了编译错误。

编译错误


参考资料

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

dotnet 职业技术学院 发布于 2019-07-24

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

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

dotnet 职业技术学院 发布于 2019-07-24

当 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. 错误)。


参考资料

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

dotnet 职业技术学院 发布于 2019-07-24

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)

参考资料

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

dotnet 职业技术学院 发布于 2019-07-22

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

.NET/C# 使用 #if 和 Conditional 特性来按条件编译代码的不同原理和适用场景

dotnet 职业技术学院 发布于 2019-07-20

有小伙伴看到我有时写了 #if 有时写了 [Conditional] 问我两个不是一样的吗,何必多此一举。然而实际上两者的编译处理是不同的,因此也有不同的应用场景。

于是我写到这篇文章当中。


条件编译符号和预处理符号

我们有时会使用 #if DEBUG 或者 [Conditional("DEBUG")] 来让我们的代码仅在特定的条件下编译。

而这里的 DEBUG 是什么呢?

  • 在我们编写的 C# 代码中,这个叫做 “条件编译符号”(Conditional compilation symbols)
  • 在项目的构建过程中,这个叫做 “定义常量”(Define constants)
  • 而在将 C# 代码编译到 dll 的编译环节,这个叫做 “预处理符号”(Preprocessor symbols)

本文要讨论的是 #ifConditional 的使用,这是在 C# 代码中的使用场景,因此,本文后面都将其称之为 “条件编译符号”。

区别

#if

#if DEBUG

Console.WriteLine("欢迎来 blog.walterlv.com 来做客呀!");

#endif

在这段代码中,#if DEBUG#endif 之间的代码仅在 DEBUG 下会编译,在其他配置下是不会编译的。

Conditional

[Conditional("DEBUG")]
public void Foo()
{
    Console.WriteLine("欢迎来 blog.walterlv.com 来做客呀!");
}

而这段代码,是会被编译到目标程序集中的。它影响的,是调用这个方法的代码。调用这个方法的代码,仅在 DEBUG 下会编译,在其他配置下是不会编译的。

场景

因为 #if DEBUG#endif 仅仅影响包含在其内的代码块,因此其仅仅影响写的这点代码所在的项目(或者说程序集)。于是使用 #if 只会影响实现代码。

[Conditional("DEBUG")] 影响的是调用它的代码,因此可以设计作为 API 使用——让目标项目(或者程序集)仅在目标项目特定的配置下才会编译。

如何为你的 Windows 应用程序关联 URL 协议,以便在浏览器中也能打开你的应用

dotnet 职业技术学院 发布于 2019-07-20

移动程序关联 URL 是常态,桌面应用程序其实也早就支持关联 URL 以便在浏览器中打开。当我们的程序关联了一个 URL 协议之后,开发的网站上就可以通过这个 URL 与程序进行互操作,这很互联网。

对于 Windows 桌面应用来说,关联一个 URL 协议是通过修改注册表来实现的。本文介绍如何为你的应用关联一个 URL 协议。


URL 协议

一个常用的 URL 协议是这样子的:https://walterlv.com。前面的 https 就是协议名称,而 https:// 放在一起就是在使用 https 协议。

本文我们将定义一个 walterlv 协议,然后关联到我们本地安装的一个桌面应用程序上,然后使用 walterlv://open?id=1 来打开一个 id 为 1 的逗比。

注册一个 URL 协议

要在 Windows 系统上注册一个 URL 协议,你只需要两个步骤:

  • 好好想一个协议名称
  • 在注册表中添加协议关联

好好想一个协议名称

就知道你想不出来名字,于是可以使用命名生成工具:Whitman,其原理可阅读 冷算法:自动生成代码标识符(类名、方法名、变量名) - 吕毅

然后本文使用协议名称 walterlv

在注册表中添加协议关联

你需要在注册表的 HKEY_LOCAL_MACHINE\Software\Classes 或者 HKEY_CURRENT_USER\Software\Classes 添加一些子键:

HKEY_CURRENT_USER\Software\Classes
    walterlv
        (Default) = 吕毅的特殊链接
        URL Protocol = WalterlvProtocol
        Shell
            Open
                Command
                    (Default) = "C:\Users\lvyi\AppData\Local\Walterlv.Foo\Walterlv.Windows.Association.exe" "%1"

Classes 中的那个根键 walterlv 就是我们的协议名称,也就是 walterlv:// 的那个前缀。

walterlv 根键 中的 (Default) 属性给出的是链接的名称;如果后面没有设置打开方式(也就是那个 Shell\Open\Command)的话,那么在 Chrome 里打开就会显示为那个名称(如下图)。

默认的协议名称

URL Protocol 这个注册表项是必须存在的,但里面的值是什么其实无所谓。这只是表示 walterlv 是一个协议。

接下来 Shell\Open\Command 中的 (Default) 值设置为一个打开此协议用的命令行。其中路径后面的 "%1" 是文件资源管理器传入的参数,其实就是文件的完整路径。我们加上了引号是避免解析命令行的时候把包含空格的路径拆成了多个参数。

在正确填写了注册表的以上内容之后,在 Chrome 里打开此链接将看到以下 URL 打开提示:

带有打开命令的协议

关于注册表路径的说明

HKEY_LOCAL_MACHINE 主键是此计算机上的所有用户共享的注册表键值,而 HKEY_CURRENT_USER 是当前用户使用的注册表键值。而我们在注册表的 HKEY_CLASSES_ROOT 中也可以看到跟 HKEY_LOCAL_MACHINE\Software\ClassesHKEY_CURRENT_USER\Software\Classes 中一样的文件关联项,是因为 HKEY_CLASSES_ROOTHKEY_LOCAL_MACHINE\Software\ClassesHKEY_CURRENT_USER\Software\Classes 合并之后的一个视图,其中用户键值会覆盖此计算机上的相同键值。

也就是说,如果你试图修改文件关联,那么需要去 HKEY_LOCAL_MACHINE\Software\ClassesHKEY_CURRENT_USER\Software\Classes 中,但如果只是去查看文件关联的情况,则只需要去 HKEY_CLASSES_ROOT 中。

写入计算机范围内的注册表项需要管理员权限,而写入用户范围内的注册表项不需要管理员权限;你可以酌情选用。

额外说明

感谢 人猿 提供的补充信息:

假如初次点击不打开,并且勾选了始终,那么以后这个弹框就没有了,而程序也不会打开,需要做下配置的修改 谷歌浏览器:C:\Users(你的用户名)\AppData\Local\Google\Chrome\User Data\Default\Preferences 火狐浏览器:先关闭浏览器C:\Users(你的用户名)\AppData\Roaming\Mozilla\Firefox\Profiles\4uasyvvi.default 找到handlers.json

软件界面中一些易混淆/易用错的界面文案,以及一些约定俗成的文案约定

dotnet 职业技术学院 发布于 2019-07-13

经常有小伙伴跟我撕到底一些常用同音的词语应该使用哪个的问题。于是我将一些常用的软件界面中用错的文案整理出来,为自己和其他开发者提供我 已经整理的结论可以溯源的资料


词语

下面列举出来的一些词语,有的我写的是 “推荐”,指两者都是正确的,但更应该使用 “推荐” 中的词语;而有的我写的是 “正确”,指只有这一个才是正确的,而其他写法是错误的。

无论哪一种,都说明了理由和可溯源的资料。

撤销 / 撤消

  • 推荐:在软件界面中推荐使用 “撤销”。应该逐渐淘汰“撤消”的使用。
  • 实际:国产软件主要使用“撤销”,而国外软件的中文版本两者都有使用。

看《现代汉语词典》:

第五、六、七版:

【撤销】chèxiāo [动] 取消:~处分|~职务。也作撤消。 【撤消】chèxiāo 同“撤销”。

第三版:

【撤销】chèxiāo [动] 撤除;取消 |> ~原判决 | ~多余的机构。☞ 不宜写作“撤消”。 【撤消】chèxiāo 现在一般写作“撤销”。

可见,“撤消”已经被淘汰,现全部应该使用“撤销”。

那么实际中大家是如何使用的呢?

Windows 系统和 Office 套件使用的是“撤消”:

Windows 文件资源管理器

Office 套件

撤销恢复 / 撤销重做,撤消恢复 / 撤消重做

  • 正确:恢复。

撤销:Undo。恢复:Redo。重做:Repeat。

有些软件会出现此错误,估计跟 Office 的使用有关。

在正常情况下,Office 的左上角有一对按钮:“撤消” 和 “重做”。但是,“重做” 的意思真的是 “重复上一步操作”。当你点了 “撤消” 之后,这个 “重做” 按钮会消失,变成 “恢复” 按钮,意思是将刚刚 “撤消” 的操作 “恢复” 回来。

因此,如果只是在 Office 软件里看了一眼就把文案抄过来了,那就会出现 “撤消重做” 这样的误用;实际上应该是 “撤销恢复”。

Office 套件

账号 / 帐号,账户 / 帐户

  • 推荐:在软件 zhànghào / zhànghù 界面中推荐使用 “账号” 和 “账户”。
  • 实际:各大软件平分秋色,都有使用。

第一批异形词整理表 中对于 “账” 和 “帐” 的用法有一项相关的说明,明确 “账本”(zhàngběn)一词是普通话书面语中推荐的使用词形,而 “帐本” 是 “账本” 异形词。

其对于 “账” 和 “帐” 的解释如下:

“账”是“帐”的分化字。古人常把账目记于布帛上悬挂起来以利保存,故称日用的账目为“帐”。后来为了与帷帐分开,另造形声字“账”,表示与钱财有关。“账”“帐”并存并用后,形成了几十组异形词。《简化字总表》、《现代汉语通用字表》中“账”“帐”均收,可见主张分化。二字分工如下:“账”用于货币和货物出入的记载、债务等,如“账本、报账、借账、还账”等;“帐”专表用布、纱、绸子等制成的遮蔽物,如“蚊帐、帐篷、青纱帐(比喻用法)”等。

从主张分化的目的来看,其更推荐在表示“货币和货物出入的记载、债务”时使用“账”,而在表示“布、纱、绸子等制成的遮蔽物”时使用“帐”。那么软件界面中应该使用哪一个呢?

对于“支付宝”/“京东”/“淘宝”/“微信钱包”/各类银行这些一看就跟钱相关的应用里面,很明显推荐使用“账户”。另外一些如论坛 zhànghào,QQ zhànghào 等没有明前与钱相关的应用,其通常也包含一些虚拟的服务行为记录、以及与其他用户相关的虚拟交易方式(例如论坛币、Q 币),因此也推荐使用“账户”。

然而还有一些与这些虚拟交易也没有关系的,非营利组织的或者完全个人的 zhànghào,应该使用什么呢?这些 zhànghù 通常只做一些密码记录、行为记录、用户个人设置个人偏好存储等。从含义上讲,这些信息与“账”描述中的“货物出入的记载”这一句是相关的,而与“帐”中的“布、纱、绸子等制成的遮蔽物”不相关。因此,即便是这些与钱不直接相关的用户 zhànghù 或者 zhànghào 也更加推荐使用 “账号” 和 “账户”。

那么实际中大家是如何使用的呢?

在我们刚刚参考的维基文库中,其使用的就是 “账号”:

维基文库

京东/1号店/支付宝的登录页面使用了 “账号”(淘宝使用了“会员名”来规避了这种争议词的使用):

京东

淘宝使用了“会员名”来规避了这种争议词的使用。

QQ/微信/网易中使用的是 “帐号”:

QQ 登录页面

Windows 系统采用了 “帐户” 一词。不过其中文版对此异形词做了很友好的适配,无论你输入哪一个词,最终都可以搜到你想要的 zhànghù:

Windows 系统设置中的帐户

你以为微软统一使用 “帐户” 吗?实际上可以看看下面这个页面,两个词都有使用。微软一定很纠结。

纠结的微软

登录 / 登陆

  • 正确:“登录” 才是正确用法。“登陆”根本就不是计算机术语。
  • 实际:主流软、大公司基本都正确使用了 “登录”,但其他网站就不好说了各种乱用。

标点符号

句号

  • 推荐:句子的结尾必须有句号或者可以承担句号职责的标点;而短语后面则不应该加句号或同类标点。
  • 实际:很多不成熟的软件会在句子结尾不带任何句号或同类标点。

为什么连句号也要拿出来说呢?

省略号

从早期的界面设计中一直延续下来一个约定:

如果某个按钮有后续操作,那么这个按钮的名称后面需要带上省略号 “…”。

注意,这是半个省略号 “…”,而不是三个点 “…”。无论中文还是英文都如此。 正在搜寻资料确认到底是什么。

后续操作指的是“需要提供额外的信息”。例如“保存”直接存成文件,而“另存为”需要提供一个新的文件名。因此“保存”没有省略号而“另存为”则有省略号。

这个约定在微软的 Windows 系统中和苹果的 macOS 系统中原本一直都有执行下去,就像下面这样:

Windows 系统

Mac 系统

直到后来发现,如果继续执行这项约定,那么整个界面中将充斥着省略号,非常影响美观。

于是后来就只在菜单中保留这项约定,其他常显界面中就去掉了省略号:

Windows 文件资源管理器

Windows 设置

Visual Studio 中的菜单项

额外说明

可能需要解释一下异形词,来自维基文库:

异形词(variant forms of the same word)

普通话书面语中并存并用的同音(本规范中指声、韵、调完全相同)、同义(本规范中指理性意义、色彩意义和语法意义完全相同)而书写形式不同的词语。

而异体字:

异体字(variant forms of a Chinese character)

与规定的正体字同音、同义而写法不同的字。本规范中专指被《第一批异体字整理表》淘汰的异体字。

对于异形词,其不同的写法需要用在不同的场景中;对于异体字,则需要逐渐淘汰使用。


参考资料

The VisualBrush of WPF only refresh the visual but not the layout

dotnet 职业技术学院 发布于 2019-07-12

Now we’ll talk about a behavior of WPF VisualBrush. Maybe it is a bug, but let’s view some details and determine whether it is or not.


The reproduction code

Let’s make a XAML layout to reproduce such an issue.

We have a large Grid which contains an inner Grid and an inner Border. The Grid contains a Rectangle which is as large as the Grid itself and a TextBlock which presents some contents. The Border only shows its background which a VisualBrush of the Grid.

The layout that can reproduce this issue

This is the whole XAML file:

<Window x:Class="Walterlv.Demo.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="Walterlv 的 WindowChrome 示例窗口" Height="450" Width="800"
        WindowStartupLocation="CenterScreen">
    <Grid>
        <Grid x:Name="VisualSource">
            <Rectangle x:Name="VisibleOr" Fill="LightCoral" Visibility="Visible" />
            <TextBlock FontSize="24" TextAlignment="Center" VerticalAlignment="Center">
                <Run Text="I'm walterlv, " />
                <LineBreak />
                <Run Text="I'm reproducing this Visual bug." />
            </TextBlock>
        </Grid>
        <Border>
            <Border.Background>
                <VisualBrush Visual="{Binding Source={x:Reference VisualSource}}" />
            </Border.Background>
        </Border>
    </Grid>
</Window>

This is the code-behind. Notice that it changes the visibility of the Rectangle every 1 second.

using System.Threading.Tasks;
using System.Windows;

namespace Walterlv.Demo
{
    public partial class MainWindow : Window
    {
        public MainWindow()
        {
            InitializeComponent();
            Loaded += OnLoaded;
        }

        private async void OnLoaded(object sender, RoutedEventArgs e)
        {
            while (true)
            {
                await Task.Delay(1000);
                VisibleOr.Visibility = Visibility.Collapsed;
                await Task.Delay(1000);
                VisibleOr.Visibility = Visibility.Visible;
            }
        }
    }
}

To verify the issue

We know that the VisualBrush shows and stretch the whole Visual so we can predicate only two results:

  • If the Rectangle is visible with Visibility property Visible, the Border background which contains the VisualBrush will be exactly the same with the Grid.
  • If the Rectangle is invisible with Visibility property Collapsed, the Border background which contains the VisualBrush will stretch the whole content to the Border without any area of the Rectangle.

But it is the real result?

The animation picture below shows the result when the Rectangle is visible as the startup:

Visible at the beginning

The animation picture below shows the result when the Rectangle is invisible as the startup:

Invisible at the beginning

Did you notice that?

Only at the very beginning when the program runs it behaves the same as we predicted. But when we change the visibility of the Rectangle the layout never changes.

The issue?

I’ve fired this issue into GitHub and this is the link:

WPF 的 VisualBrush 只刷新显示的视觉效果,不刷新布局范围

dotnet 职业技术学院 发布于 2019-07-12

WPF 的 VisualBrush 可以帮助我们在一个控件中显示另一个控件的外观。这是非常妙的功能。

但是本文需要说其中的一个 Bug —— 如果使用 VisualBrush 显示另一个控件的外观,那么只会在其显示效果有改变的时候刷新,而不会在目标布局改变的时候刷新布局。


用于复现问题的代码

我们现在做一个可以用于验证此问题的布局。

在一个大的 Grid 容器中有一个 Grid 和一个 Border,这个 Grid 将放一个大面积的 Rectangle 和一个表示内容的 TextBlock;而那个 Border 将完全以 VisualBrush 的形式呈现,呈现的内容是此 Grid 中的全部内容。

可以用于验证此问题的布局

它的完整 XAML 代码如下:

<Window x:Class="Walterlv.Demo.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="Walterlv 的 WindowChrome 示例窗口" Height="450" Width="800"
        WindowStartupLocation="CenterScreen">
    <Grid>
        <Grid x:Name="VisualSource">
            <Rectangle x:Name="VisibleOr" Fill="LightCoral" Visibility="Visible" />
            <TextBlock FontSize="24" TextAlignment="Center" VerticalAlignment="Center">
                <Run Text="I'm walterlv, " />
                <LineBreak />
                <Run Text="I'm reproducing this Visual bug." />
            </TextBlock>
        </Grid>
        <Border>
            <Border.Background>
                <VisualBrush Visual="{Binding Source={x:Reference VisualSource}}" />
            </Border.Background>
        </Border>
    </Grid>
</Window>

其后台 C# 代码如下,包含每隔 1 秒钟切换 Rectangle 可见性的代码。

using System.Threading.Tasks;
using System.Windows;

namespace Walterlv.Demo
{
    public partial class MainWindow : Window
    {
        public MainWindow()
        {
            InitializeComponent();
            Loaded += OnLoaded;
        }

        private async void OnLoaded(object sender, RoutedEventArgs e)
        {
            while (true)
            {
                await Task.Delay(1000);
                VisibleOr.Visibility = Visibility.Collapsed;
                await Task.Delay(1000);
                VisibleOr.Visibility = Visibility.Visible;
            }
        }
    }
}

验证问题

我们知道,VisualBrush 在默认情况下会将 Visual 中的全部内容拉伸到控件中显示,于是可以预估出两个可能的结果:

  • 如果 Rectangle 可见(VisibilityVisible),那么 Border 中以 VisualBrush 显示的内容将完全和下面重叠(因为大小相同,拉伸后正好重叠)。
  • 如果 Rectangle 不可见(VisibilityCollapsed),那么 Border 中以 VisualBrush 显示的内容将仅有文字且拉伸到整个 Border 范围。

然而实际运行真的是这样子吗?

下面的动图是 Rectangle 初始状态可见时,窗口运行后的结果:

初始状态可见

下面的动图是 Rectangle 初始状态不可见时,窗口运行后的结果:

初始状态不可见

注意到了吗?

只有初始状态才能正确反应我们之前预估出的结果,而无论后面怎么再改变可见性,布局都不会再刷新了。只是——后面 VisualBrush 的内容始终重叠。这意味着 VisualBrush 中目标 Visual 的范围增大之后不会再缩小了。

问题?

这是问题吗?

于是在以下 issue 中跟进此问题:

VisualBrush 的其他 Bug

参见:

一文看懂 .NET 的异常处理机制、原则以及最佳实践

dotnet 职业技术学院 发布于 2019-07-12

什么时候该抛出异常,抛出什么异常?什么时候该捕获异常,捕获之后怎么处理异常?你可能已经使用异常一段时间了,但对 .NET/C# 的异常机制依然有一些疑惑。那么,可以阅读本文。

本文适用于已经入门 .NET/C# 开发,已经开始在实践中抛出和捕获异常,但是对 .NET 异常机制的用法以及原则比较模糊的小伙伴。通过阅读本文,小伙伴们可以迅速在项目中使用比较推荐的异常处理原则来处理异常。


快速了解 .NET 的异常机制

Exception 类

我们大多数小伙伴可能更多的使用 Exception 的类型、Message 属性、StackTrace 以及内部异常来定位问题,但其实 Exception 类型还有更多的信息可以用于辅助定位问题。

  • Message 用来描述异常原因的详细信息
    • 如果你捕捉到了异常,一般使用这段描述能知道发生的大致原因。
    • 如果你准备抛出异常,在这个信息里面记录能帮助调试问题的详细文字信息。
  • StackTrace 包含用来确定错误位置的堆栈跟踪(当有调试信息如 PDB 时,这里就会包含源代码文件名和源代码行号)
  • InnerException 包含内部异常信息
  • Source 这个属性包含导致错误的应用程序或对象的名称
  • Data 这是一个字典,可以存放基于键值的任意数据,帮助在异常信息中获得更多可以用于调试的数据
  • HelpLink 这是一个 url,这个 url 里可以提供大量用于说明此异常原因的信息

如果你自己写一个自定义异常类,那么你可以在自定义的异常类中记录更多的信息。然而大多数情况下我们都考虑使用 .NET 中自带的异常类,因此可以充分利用 Exception 类中的已有属性在特殊情况下报告更详细的利于调试的异常信息。

捕捉异常

捕捉异常的基本语法是:

try
{
    // 可能引发异常的代码。
}
catch (FileNotFoundException ex)
{
    // 处理一种类型的异常。
}
catch (IOException ex)
{
    // 处理另一种类的异常。
}

除此之外,还有 when 关键字用于筛选异常:

try
{
    // 可能引发异常的代码。
}
catch (FileNotFoundException ex) when (Path.GetExtension(ex.FileName) is ".png")
{
    // 处理一种类型的异常,并且此文件扩展名为 .png。
}
catch (FileNotFoundException ex)
{
    // 处理一种类型的异常。
}

无论是否有带 when 关键字,都是前面的 catch 块匹配的时候执行匹配的 catch 块而无视后面可能也匹配的 catch 块。

如果 when 块中抛出异常,那么此异常将被忽略,when 中的表达式值视为 false。有个但是,请看:.NET Framework 的 bug?try-catch-when 中如果 when 语句抛出异常,程序将彻底崩溃 - walterlv

引发异常

引发异常使用 throw 关键字。只是注意如果要重新抛出异常,请使用 throw; 语句或者将原有异常作为内部异常。

创建自定义异常

如果你只是随便在业务上创建一个异常,那么写一个类继承自 Exception 即可:

public class MyCustomException : Exception
{
    public string MyCustomProperty { get; }

    public MyCustomException(string customProperty) => MyCustomProperty = customProperty;
}

不过,如果你需要写一些比较通用抽象的异常(用于被继承),或者在底层组件代码中写自定义异常,那么就建议考虑写全异常的所有构造函数,并且加上可序列化:

[Serializable]
public class InvalidDepartmentException : Exception
{
    public InvalidDepartmentException() : base() { }
    public InvalidDepartmentException(string message) : base(message) { }
    public InvalidDepartmentException(string message, Exception innerException) : base(message, innerException) { }

    // 如果异常需要跨应用程序域、跨进程或者跨计算机抛出,就需要能被序列化。
    protected InvalidDepartmentException(SerializationInfo info, StreamingContext context) : base(info, context) { }
}

在创建自定义异常的时候,建议:

  • 名称以 Exception 结尾
  • Message 属性的值是一个句子,用于描述异常发生的原因。
  • 提供帮助诊断错误的属性。
  • 尽量写全四个构造函数,前三个方便使用,最后一个用于序列化异常(新的异常类应可序列化)。

finally

异常堆栈跟踪

堆栈跟踪从引发异常的语句开始,到捕获异常的 catch 语句结束。

利用这一点,你可以迅速找到引发异常的那个方法,也能找到是哪个方法中的 catch 捕捉到的这个异常。

异常处理原则

try-catch-finally

我们第一个要了解的异常处理原则是——明确 try catch finally 的用途!

try 块中,编写可能会发生异常的代码。

最好的情况是,你只将可能会发生异常的代码放到 try 块中,当然实际应用的时候可能会需要额外放入一些相关代码。但是如果你将多个可能发生异常的代码放到一个 try 块中,那么将来定位问题的时候你就会很抓狂(尤其是多个异常还是一个类别的时候)。

catch 块的作用是用来 “恢复错误” 的,是用来 “恢复错误” 的,是用来 “恢复错误” 的。

如果你在 try 块中先更改了类的状态,随后出了异常,那么最好能将状态改回来——这可以避免这个类型或者应用程序的其他状态出现不一致——这很容易造成应用程序“雪崩”。举一个例子:我们写一个程序有简洁模式和专业模式,在从简洁模式切换到专业模式的时候,我们设置 IsProfessionalModetrue,但随后出现了异常导致没有成功切换为专业模式;然而接下来所有的代码在执行时都判断 IsProfessionalModetrue 状态不正确,于是执行了一些非预期的操作,甚至可能用到了很多专业模式中才会初始化的类型实例(然而没有完成初始化),产生大量的额外异常;我们说程序雪崩了,多数功能再也无法正常使用了。

当然如果任务已全部完成,仅仅在对外通知的时候出现了异常,那么这个时候不需要恢复状态,因为实际上已经完成了任务。

你可能会有些担心如果我没有任何手段可以恢复错误怎么办?那这个时候就不要处理异常!——如果不知道如何恢复错误,请不要处理异常!让异常交给更上一层的模块处理,或者交给整个应用程序全局异常处理模块进行统一处理(这个后面会讲到)。

另外,异常不能用于在正常执行过程中更改程序的流程。异常只能用于报告和处理错误条件。

finally 块的作用是清理资源。

虽然 .NET 的垃圾回收机制可以在回收类型实例的时候帮助我们回收托管资源(例如 FileStream 类打开的文件),但那个时机不可控。因此我们需要在 finally 块中确保资源可被回收,这样当重新使用这个文件的时候能够立刻使用而不会被占用。

一段异常处理代码中可能没有 catch 块而有 finally 块,这个时候的重点是清理资源,通常也不知道如何正确处理这个错误。

一段异常处理代码中也可能 try 块留空,而只在 finally 里面写代码,这是为了“线程终止”安全考虑。在 .NET Core 中由于不支持线程终止因此可以不用这么写。详情可以参考:.NET/C# 异常处理:写一个空的 try 块代码,而把重要代码写到 finally 中(Constrained Execution Regions) - walterlv

该不该引发异常?

什么情况下该引发异常?答案是——这真的是一个异常情况!

于是,我们可能需要知道什么是“异常情况”。

一个可以参考的判断方法是——判断这件事发生的频率:

  • 如果这件事并不常见,当它发生时确实代表发生了一个错误,那么这件事情就可以认为是异常。
  • 如果这件事经常发生,代码中正常情况就应该处理这件事情,那么这件事情就不应该被认为是异常(而是正常流程的一部分)。

例如这些情况都应该认为是异常:

  • 方法中某个参数不应该传入 null 时但传入了 null
    • 这是开发者使用这个方法时没有遵循此方法的契约导致的,让开发者改变调用此方法的代码就可以完全避免这件事情发生

而下面这些情况则不应该认为是异常:

  • 用户输入了一串字符,你需要将这串字符转换为数字
    • 用户输入的内容本身就千奇百怪,出现非数字的输入再正常不过了,对非数字的处理本就应该成为正常流程的一部分

对于这些不应该认为是异常的情况,编写的代码就应该尽可能避免异常。

有两种方法来避免异常:

  1. 先判断再使用。
    • 例如读取文件之前,先判断文件是否存在;例如读取文件流时先判断是否已到达文件末尾。
    • 如果提前判断的成本过高,可采用 TryDo 模式来完成,例如字符串转数字中的 TryParse 方法,字典中的 TryGetValue 方法。
  2. 对极为常见的错误案例返回 null(或默认值),而不是引发异常。极其常见的错误案例可被视为常规控制流。通过在这些情况下返回 NULL(或默认值),可最大程度地减小对应用的性能产生的影响。(后面会专门说 null)

而当存在下列一种或多种情况时,应引发异常:

  1. 方法无法完成其定义的功能。
  2. 根据对象的状态,对某个对象进行不适当的调用。

请勿有意从自己的源代码中引发 System.ExceptionSystem.SystemExceptionSystem.NullReferenceExceptionSystem.IndexOutOfRangeException

该不该捕获异常?

在前面 try-catch-finally 小节中,我们提到了 catch 块中应该写哪些代码,那里其实已经说明了哪些情况下应该处理异常,哪些情况下不应该处理异常。一句总结性的话是——如果知道如何从错误中恢复,那么就捕获并处理异常,否则交给更上层的业务去捕获异常;如果所有层都不知道如何处理异常,就交给全局异常处理模块进行处理。

应用程序全局处理异常

对于 .NET 程序,无论是 .NET Framework 还是 .NET Core,都有下面这三个可以全局处理的异常。这三个都是事件,可以自行监听。

  • AppDomain.UnhandledException
    • 应用程序域未处理的异常,任何线程中未处理掉的异常都会进入此事件中
    • 当这里能够收到事件,意味着应用程序现在频临崩溃的边缘(从设计上讲,都到这里了,也再没有任何代码能够使得程序从错误中恢复了)
    • 不过也可以配置 legacyUnhandledExceptionPolicy 防止后台线程抛出的异常让程序崩溃退出
    • 建议在这个事件中记录崩溃日志,然后对应用程序进行最后的拯救恢复操作(例如保存用户的文档数据)
  • AppDomain.FirstChanceException
    • 应用程序域中的第一次机会异常
    • 我们前面说过,一个异常被捕获时,其堆栈信息将包含从 throw 块到 catch 块之间的所有帧,而在第一次机会异常事件中,只是刚刚 throw 出来,还没有被任何 catch 块捕捉,因此在这个事件中堆栈信息永远只会包含一帧(不过可以稍微变通一下在第一次机会异常 FirstChanceException 中获取比较完整的异常堆栈
    • 注意第一次机会异常事件即便异常会被 catch 也会引发,因为它引发在 catch 之前
    • 不要认为异常已经被 catch 就万事大吉可以无视这个事件了。前面我们说过异常仅在真的是异常的情况才应该引发,因此如果这个事件中引发了异常,通常也真的意味着发生了错误(差别只是我们能否从错误中恢复而已)。如果你经常在正常的操作中发现可以通过此事件监听到第一次机会异常,那么一定是应用程序或框架中的异常设计出了问题(可能把正常应该处理的流程当作了异常,可能内部实现代码错误,可能出现了使用错误),这种情况一定是要改代码修 Bug 的。而一些被认为是异常的情况下收到此事件则是正常的。
  • TaskScheduler.UnobservedTaskException
    • 在使用 async / await 关键字编写异步代码的时候,如果一直有 await 传递,那么异常始终可以被处理到;但中间有异步任务没有 await 导致异常没有被传递的时候,就会引发此事件。
    • 如果在此事件中监听到异常,通常意味着代码中出现了不正确的 async / await 的使用(要么应该修改实现避免异常,要么应该正确处理异常并从中恢复错误)

对于 GUI 应用程序,还可以监听 UI 线程上专属的全局异常:

  • WPF:Application.DispatcherUnhandledException 或者 Dispatcher.UnhandledException
  • Windows Forms:Application.ThreadException

关于这些全局异常的处理方式和示例代码,可以参阅博客:

抛出哪些异常?

任何情况下都不应该抛出这些异常:

  • 过于抽象,以至于无法表明其含义
    • Exception 这可是顶级基类,这都抛出来了,使用者再也无法正确地处理此异常了
    • SystemException 这是各种异常的基类,本身并没有明确的意义
    • ApplicationException 这是各种异常的基类,本身并没有明确的意义
  • 由 CLR 引发的异常
    • NullReferenceException 试图在空引用上执行某些方法,除了告诉实现者出现了意料之外的 null 之外,没有什么其它价值了
    • IndexOutOfRangeException 使用索引的时候超出了边界
    • InvalidCastException 表示试图对某个类型进行强转但类型不匹配
    • StackOverflow 表示栈溢出,这通常说明实现代码的时候写了不正确的显式或隐式的递归
    • OutOfMemoryException 表示托管堆中已无法分出期望的内存空间,或程序已经没有更多内存可用了
    • AccessViolationException 这说明使用非托管内存时发生了错误
    • BadImageFormatException 这说明了加载的 dll 并不是期望中的托管 dll
    • TypeLoadException 表示类型初始化的时候发生了错误
  • .NET 设计失误
    • FormatException 因为当它抛出来时无法准确描述到底什么错了

首先是你自己不应该抛出这样的异常。其次,你如果在运行中捕获到了上面这些异常,那么代码一定是写得有问题。

如果是捕获到了上面 CLR 的异常,那么有两种可能:

  1. 你的代码编写错误(例如本该判空的代码没有判空,又如索引数组超出界限)
  2. 你使用到的别人写的代码编写错误(那你就需要找到它改正,或者如果开源就去开源社区中修复吧)

而一旦捕获到了上面其他种类的异常,那就找到抛这个异常的人,然后对它一帧狂扁即可。

其他的异常则是可以抛出的,只要你可以准确地表明错误原因。

另外,尽量不要考虑抛出聚合异常 AggregateException,而是优先使用 ExceptionDispatchInfo 抛出其内部异常。详见:使用 ExceptionDispatchInfo 捕捉并重新抛出异常 - walterlv

异常的分类

该不该引发异常 小节中我们说到一个异常会被引发,是因为某个方法声称的任务没有成功完成(失败),而失败的原因有四种:

  1. 方法的使用者用错了(没有按照方法的契约使用)
  2. 方法的执行代码写错了
  3. 方法执行时所在的环境不符合预期

简单说来,就是:使用错误,实现错误、环境错误。

使用错误:

  • ArgumentException 表示参数使用错了
  • ArgumentNullException 表示参数不应该传入 null
  • ArgumentOutOfRangeException 表示参数中的序号超出了范围
  • InvalidEnumArgumentException 表示参数中的枚举值不正确
  • InvalidOperationException 表示当前状态下不允许进行此操作(也就是说存在着允许进行此操作的另一种状态)
  • ObjectDisposedException 表示对象已经 Dispose 过了,不能再使用了
  • NotSupportedException 表示不支持进行此操作(这是在说不要再试图对这种类型的对象调用此方法了,不支持)
  • PlatformNotSupportedException 表示在此平台下不支持(如果程序跨平台的话)
  • NotImplementedException 表示此功能尚在开发中,暂时请勿使用

实现错误:

前面由 CLR 抛出的异常代码主要都是实现错误

  • NullReferenceException 试图在空引用上执行某些方法,除了告诉实现者出现了意料之外的 null 之外,没有什么其它价值了
  • IndexOutOfRangeException 使用索引的时候超出了边界
  • InvalidCastException 表示试图对某个类型进行强转但类型不匹配
  • StackOverflowException 表示栈溢出,这通常说明实现代码的时候写了不正确的显式或隐式的递归
  • OutOfMemoryException 表示托管堆中已无法分出期望的内存空间,或程序已经没有更多内存可用了
  • AccessViolationException 这说明使用非托管内存时发生了错误
  • BadImageFormatException 这说明了加载的 dll 并不是期望中的托管 dll
  • TypeLoadException 表示类型初始化的时候发生了错误

环境错误:

  • IOException 下的各种子类
  • Win32Exception 下的各种子类
  • ……

另外,还剩下一些不应该抛出的异常,例如过于抽象的异常和已经过时的异常,这在前面一小结中有说明。

其他

一些常见异常的原因和解决方法

在平时的开发当中,你可能会遇到这样一些异常,它不像是自己代码中抛出的那些常见的异常,但也不包含我们自己的异常堆栈。

这里介绍一些常见这些异常的原因和解决办法。

AccessViolationException

当出现此异常时,说明非托管内存中发生了错误。如果要解决问题,需要从非托管代码中着手调查。

这个异常是访问了不允许的内存时引发的。在原因上会类似于托管中的 NullReferenceException

FileNotFoundException

捕捉非 CLS 异常


参考资料

WPF 很少人知道的科技

dotnet 职业技术学院 发布于 2019-07-11

本文介绍不那么常见的 WPF 相关的知识。


在 C# 代码中创建 DataTemplate

大多数时候我们只需要在 XAML 中就可以实现我们想要的各种界面效果。这使得你可能已经不知道如何在 C# 代码中创建同样的内容。

比如在代码中创建 DataTemplate,主要会使用到 FrameworkElementFactory 类型。

可以参考:

多个数据源合并为一个列表显示

WPF 提供 CompositionCollection 用于将多个列表合并为一个,以便在 WPF 界面的同一个列表中显示多个数据源的数据。

<ListBox Name="WalterlvDemoListBox">
    <ListBox.Resources>
        <CollectionViewSource x:Key="Items1Source" Source="{Binding Items1}"/>
        <CollectionViewSource x:Key="Items2Source" Source="{Binding Items2}"/>
    </ListBox.Resources>
    <ListBox.ItemsSource>
        <CompositeCollection>
            <CollectionContainer Collection="{Binding Source={StaticResource Items1Source}}" />
            <CollectionContainer Collection="{Binding Source={StaticResource Items2Source}}" />
            <ListBoxItem>Walterlv End Item 1</ListBoxItem>
            <ListBoxItem>Walterlv End Item 2</ListBoxItem>
        </CompositeCollection>
    </ListBox.ItemsSource>
</ListBox>

关于 CompositeCollection 的使用示例可以参考:

神樹桜乃写了一份非 WPF 框架的版本,如果希望在非 WPF 程序中使用,可以参考:

使用附加属性做缓存,避免内存泄漏

在没有使用 WPF 的时候,如果我们要为一个对象添加属性或者行为,我们可能会使用字典来实现。但字典带来了内存泄漏的问题,要自己处理内存泄漏问题可能会写比较复杂的代码。

然而,WPF 的附加属性可以非常容易地为对象添加属性或者行为,而且也不用担心内存泄漏问题。

例如,我曾经用 WPF 来模拟 UWP 流畅设计(Fluent Design)中的光照效果,使用附加属性来管理此行为则完全不用担心内存泄漏问题:

使用 ConditionalWeakTable 做非 WPF 版本的缓存

如果你有一些非 WPF 的对象需要做类似 WPF 那种附加属性,那么可以考虑使用 ConditionalWeakTable 来实现,Key 是那个对象,而 Value 是你需要附加的属性或者行为。

这里的引用关系是 Key 引用着 Value,如果 Key 被回收,那么 Value 也可以被回收。

使用代码模拟触摸

WPF 默认情况下的触摸是通过 COM 组件 PimcManager 获取到的,在禁用实时触摸后会启用系统的 TOUCH 消息获取到,如果开启了 Pointer 消息那么会使用 POINTER 消息。

我们可以继承自 TouchDevice 来模拟触摸,详见:

模拟 UWP 界面

在现有的 Windowing API 下,系统中看起来非常接近系统级的窗口样式可能都是用不同技术模拟实现的,只是模拟得很像而已。

如果要将 WPF 模拟得很像 UWP,可以参考我的这两篇博客:

模拟 Fluent Design 特效

目前 WPF 还不能直接使用 Windows 10 Fluent Design 特效。当然如果你的程序非常小,那么模拟一下也不会伤害太多性能:

然而充分利用 Fluent Design 的高性能,需要上 XAML Islands,详见:

  • [Using the UWP XAML hosting API in a desktop application - Windows apps Microsoft Docs](https://docs.microsoft.com/en-us/windows/apps/desktop/modernize/using-the-xaml-hosting-api)