dotnet 职业技术学院

博客

dotnet 职业技术学院

2020-1-6-什么是尾递归

dotnet 职业技术学院 发布于 2020-01-06

递归算法想必大家都已经很熟悉了。递归算法虽然简单,但是容易导致一些性能问题,于是就有了尾递归这种优化算法。


首先我们先看看递归算法的性能问题是在哪里?

比如我们有一个常见的算法,叫做阶乘算法。

\[f(x)=1\cdot2\cdot3\cdots\!x\]

他的递归实现是这样子的

\[\begin{array}{1}f(x)=x f(x-1)\\\qquad\,=x\cdot(x-1)f(x-2)\\\qquad\,\cdots\\\qquad\,=x\cdot(x-1)\cdot(x-2)\cdots1\end{array}\]

实现代码如下

//C#实现
int Foo(int x)
{
    if(x==1)
    {
        return 1;
    }
	return x*Foo(x-1);
}
#python 实现
def foo(x):
    if(x==1):
        return 1
    return x*foo(x-1)

我们看到每次调用foo方法的时候,又会执行一次foo方法。

此时程序会将当前上下文压栈,计算出下一个foo的值,然后再出栈和x进行相乘

所以对于foo(3)的调用,整个栈的情况是这样的

\[\begin{array}{1}foo(3)\\3\cdot foo(2)\\3\cdot 2\cdot foo(1)\\3\cdot 2\cdot1\\3\cdot 2\\6\end{array}\]

那么尾递归呢?

它是指函数的最后一个位置(或者动作)是调用自身

我们把上面的方法改一下尾递归

//C#尾递归实现
int Foo(int x, int result=1)
{
    if(x==1)
    {
        return result;
    }
	return Foo(x-1,x*result);
}
#python 尾递归实现
def foo2(x,result=1):
    if(x==1):
        return result
    return foo2(x-1,result*x)

这里有两个需要注意的点

  • 参数里面多了一个result,表示返回值。那么原本需要在内存中记录的信息,从方法参数中传入了
  • 最后的递归调用处位于return,递归的方法只需要返回一个值,而不需要同上一层递归调用的方法再做交互

那么这么有什么好处呢?

好处就是“聪明”的编译器在准备入栈时发现,咦,这里的递归放回值不需要做任何计算,直接返回更上一层就好了。那么存储上下文没有啥好处,不存了!!

所以此时的栈使用情况就会变成

\[\begin{array}{1}foo2(3)\\foo2(2,3)\\foo2(1,6)\\6\end{array}\]

内存占用,显著减少

不过尾递归虽好,但是还是要依赖于各种编译器的支持。

目前我知道的是python是支持的,探索c#之尾递归编译器优化 - 蘑菇先生 - 博客园文章中表示64位release下会进行尾递归优化


参考文档:

如何在 MSBuild 中正确使用 % 来引用每一个项(Item)中的元数据

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

MSBuild 中写在 <ItemGroup /> 中的每一项是一个 ItemItem 除了可以使用 Include/Update/Remove 来增删之外,还可以定义其他的元数据(Metadata)。

使用 % 可以引用 Item 的元数据,本文将介绍如何正确使用 % 来引用每一个项中的元数据。


定义 Item 的元数据

就像下面这样,当引用一个 NuGet 包时,可以额外使用 Version 来指定应该使用哪个特定版本的 NuGet 包。这里的 VersionPrivateAssets 就是 PackageReference 的元数据。

<ItemGroup>
    <PackageReference Include="dotnetCampus.Configurations.Source" Version="1.0.0" PrivateAssets="All" />
    <PackageReference Include="dotnetCampus.CommandLine.Source" Version="1.2.1" PrivateAssets="All" />
    <PackageReference Include="Walterlv.Win32.Source" Version="0.12.2-alpha" PrivateAssets="All" />
    <PackageReference Include="Walterlv.IO.PackageManagement.Source" Version="0.13.2-alpha" PrivateAssets="All" />
</ItemGroup>

我们随便创建一个新的 Item,也可以定义自己的元数据。

<ItemGroup>
    <_WalterlvItem Include="欢迎访问" Url="https://" />
    <_WalterlvItem Include="吕毅的博客" Url="blog.walterlv.com" />
</ItemGroup>

引用元数据

引用元数据使用的是 % 符号。

<Target Name="_WalterlvDemo" AfterTargets="AfterBuild">
    <ItemGroup>
        <_WalterlvItem Include="欢迎访问" Url="https://" />
        <_WalterlvItem Include="吕毅的博客" Url="blog.walterlv.com" />
    </ItemGroup>
    <Message Text="@(_WalterlvItem):%(Url)" />
</Target>

虽然这里我们只写了一个 Message Task,但是最终我们会输出两次,每一个 _WalterlvItem 项都会输出一次。下面是这段代码的输出:

_WalterlvDemo:
  欢迎访问:https://
  吕毅的博客:blog.walterlv.com

当你使用 % 的时候,会为每一个项执行一次这行代码。当然,如果某个 Task 支持传入集合,那么则可以直接收到集合。

如果你不是用的 Message,而是定义一个其他的属性,使用 @(_WalterlvItem):%(Url) 作为属性的值,那么这个属性也会为每一个项都计算一次值。当然最终这个属性的值就是最后一项计算所得的值。

也许可以帮你回忆一下,如果我们不写 %(Url) 会输出什么。当只输出 @(WalterlvItem) 的时候,会以普通的分号分隔的文字。

<Target Name="_WalterlvDemo" AfterTargets="AfterBuild">
    <ItemGroup>
        <_WalterlvItem Include="欢迎访问" Url="https://" />
        <_WalterlvItem Include="吕毅的博客" Url="blog.walterlv.com" />
    </ItemGroup>
    <Message Text="@(_WalterlvItem)" />
</Target>

会输出:

_WalterlvDemo:
  欢迎访问;吕毅的博客

使用元数据

如果你希望自己处理编译过程,那么可能会对元数据做更多的处理。

为了简单说明 % 的用法,我将已收集到的所有的元数据和它的本体一起输出到一个文件中。这样,后续的编译过程可以直接使用这个文件来获得所有的项和你希望关心它的所有元数据。

<PropertyGroup>
    <_WalterlvContentArgsFilePath>$(IntermediateOutputPath)Args\Content.txt</_WalterlvContentArgsFilePath>
    <_WalterlvToolFile>$(MSBuildThisFileDirectory)..\bin\compile.exe</_WalterlvContentArgsFilePath>
</PropertyGroup>

<Target Name="_WalterlvDemo" AfterTargets="AfterBuild">
    <ItemGroup>
        <_WalterlvContentFileLine Include="@(Content)" Line="@(Content)|%(Content.PublishState)|%(Content.CopyToOutputDirectory)" />
    </ItemGroup>
    <WriteLinesToFile File="$(_WalterlvContentArgsFilePath)" Lines="%(_WalterlvContentFileLine.Line)" Overwrite="True" />
    <Exec ConsoleToMSBuild="True"
          Command="&quot;$(_WalterlvToolFile)&quot; PackContent --content-file &quot; $(_WalterlvContentArgsFilePath) &quot;" />
</Target>

这段代码的含义是:

  1. 定义一个文件路径,这个路径即将用来存放所有 Content 项和它的元数据;
  2. 定义一个工具路径,我们即将运行这个路径下的命令行程序来执行自定义的编译;
  3. 收集所有的 Content 项,然后把所有项中的 PublishStateCopyToOutputDirectory 一起拼接成这个样子:
    • Content|PublishState|CopyToOutputDirectory
  4. 写文件,将以上拼接出来的每一项写入到文件中的每一行;
  5. 执行工具程序,这个程序将使用这个文件来执行自定义的编译。

关于使用 exe 进行自定义编译的部分可以参考我的另一篇博客:

关于写文件的部分可以参考我的另一篇博客:

关于项元数据的其他信息

一些已知的元数据:


参考资料

在 MSBuild 编译过程中操作文件和文件夹(检查存在/创建文件夹/读写文件/移动文件/复制文件/删除文件夹)

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

本文整理 MSBuild 在编译过程中对文件和文件夹处理的各种自带的编译任务(Task)。


Exists 检查文件存在

使用 Exists 可以判断一个文件或者文件夹是否存在。注意无论是文件还是文件夹,只要给定的路径存在就返回 true。可以作为 MSBuild 属性、项和编译任务的执行条件。

<PropertyGroup Condition=" Exists( '$(MSBuildThisFileDirectory)..\build\build.xml' ) ">
    <_WalterlvPackingDirectory>$(MSBuildThisFileDirectory)..\bin\$(Configuration)\</_WalterlvPackingDirectory>
</PropertyGroup>

MakeDir 创建文件夹

下面的例子演示创建一个文件夹:

<Target Name="_WalterlvCreateDirectoryForPacking">
    <MakeDir Directories="$(MSBuildThisFileDirectory)..\bin\$(Configuration)\" />
</Target>

下面是使用到 MakeDir 全部属性的例子,将已经成功创建的文件夹提取出来。

<Target Name="_WalterlvCreateDirectoryForPacking">
    <MakeDir Directories="$(MSBuildThisFileDirectory)..\bin\$(Configuration)\">
        <Output TaskParameter="DirectoriesCreated" PropertyName="CreatedPackingDirectory" />
    </MakeDir>
</Target>

Move 移动文件

下面的例子是将输出文件移动到一个专门的目录中,移动后,所有的文件将平级地在输出文件夹中(即所有的子文件夹中的文件也都被移动到同一层目录中了)。

<PropertyGroup>
    <_WalterlvPackingDirectory>$(MSBuildThisFileDirectory)..\bin\$(Configuration)\</_WalterlvPackingDirectory>
</PropertyGroup>

<Target Name="_WalterlvMoveFilesForPacking">
    <ItemGroup>
        <_WalterlvToMoveFile Include="$(OutputPath)**" />
    </ItemGroup>
    <Move SourceFiles="@(_WalterlvToMoveFile)"
          DestinationFolder="$(_WalterlvPackingDirectory)"
          SkipUnchangedFiles="True" />
</Target>

你可以通过下面的例子了解到 Move 的其他大多数属性及其用法:

<PropertyGroup>
    <_WalterlvPackingDirectory>$(MSBuildThisFileDirectory)..\bin\$(Configuration)\</_WalterlvPackingDirectory>
</PropertyGroup>

<Target Name="_WalterlvMoveFilesForPacking">
    <ItemGroup>
        <_WalterlvToMoveFile Include="$(OutputPath)**" />
        <_WalterlvTargetFile Include="$(_WalterlvPackingDirectory)\%(_WalterlvToMoveFile.RecursiveDir)" />
    </ItemGroup>
    <Move SourceFiles="@(_WalterlvToMoveFile)"
          DestinationFiles="$(_WalterlvTargetFile)"
          OverwriteReadOnlyFiles="True">
        <Output TaskParameter="MovedFiles" PropertyName="MovedOutputFiles" />
    </Copy>
</Target>

这段代码除了没有使用 DestinationFolder 之外,使用到了所有 Move 能用的属性:

  • 将所有的 _WalterlvToCopyFile 一对一地复制到 _WalterlvTargetFile 指定的路径上。
  • 即便目标文件是只读的,也会覆盖。

Copy 复制文件

下面的例子是将输出文件拷贝到一个专门的目录中,保留原来所有文件之间的目录结构,并且如果文件没有改变则跳过。

<PropertyGroup>
    <_WalterlvPackingDirectory>$(MSBuildThisFileDirectory)..\bin\$(Configuration)\</_WalterlvPackingDirectory>
</PropertyGroup>

<Target Name="_WalterlvCopyFilesForPacking">
    <ItemGroup>
        <_WalterlvToCopyFile Include="$(OutputPath)**" />
    </ItemGroup>
    <Copy SourceFiles="@(_WalterlvToCopyFile)"
          DestinationFolder="$(_WalterlvPackingDirectory)\%(RecursiveDir)"
          SkipUnchangedFiles="True" />
</Target>

如果你希望复制后所有的文件都在同一级文件夹中,不再有子文件夹,那么去掉 \%(RecursiveDir)

你可以通过下面的例子了解到 Copy 的其他大多数属性及其用法:

<PropertyGroup>
    <_WalterlvPackingDirectory>$(MSBuildThisFileDirectory)..\bin\$(Configuration)\</_WalterlvPackingDirectory>
</PropertyGroup>

<Target Name="_WalterlvCopyFilesForPacking">
    <ItemGroup>
        <_WalterlvToCopyFile Include="$(OutputPath)**" />
        <_WalterlvTargetFile Include="$(_WalterlvPackingDirectory)\%(_WalterlvToCopyFile.RecursiveDir)" />
    </ItemGroup>
    <Copy SourceFiles="@(_WalterlvToCopyFile)"
          DestinationFiles="@(_WalterlvTargetFile)"
          OverwriteReadOnlyFiles="True"
          Retries="10"
          RetryDelayMilliseconds="10"
          SkipUnchangedFiles="True"
          UseHardlinksIfPossible="True">
        <Output TaskParameter="CopiedFiles" PropertyName="CopiedOutputFiles" />
    </Copy>
</Target>

这段代码除了没有使用 DestinationFolder 之外,使用到了所有 Copy 能用的属性:

  • 将所有的 _WalterlvToCopyFile 一对一地复制到 _WalterlvTargetFile 指定的路径上。
  • 即便目标文件是只读的,也会覆盖。
  • 如果复制失败,则重试 10 次,每次等待 10 毫秒
  • 如果文件没有改变,则跳过复制
  • 如果目标文件系统支持硬连接,则使用硬连接来提升性能

Delete 删除文件

下面这个例子是删除输出目录下的所有的 pdb 文件(适合 release 下发布软件)。

<Target Name="_WalterlvDeleteFiles">
    <Delete Files="$(OutputPath)*.pdb" />
</Target>

也可以把此操作已经删除的文件列表拿出来。使用全部属性的 Delete 的例子:


<Target Name="_WalterlvDeleteFiles">
    <Delete Files="$(OutputPath)*.pdb" TreatErrorsAsWarnings="True">
        <Output TaskParameter="DeletedFiles" PropertyName="DeletedPdbFiles" />
    </Delete>
</Target>

ReadLinesFromFile 读取文件

在编译期间,可以从文件中读出文件的每一行:

<PropertyGroup>
    <_WalterlvToWriteFile>$(OutputPath)walterlv.md</_WalterlvToWriteFile>
</PropertyGroup>

<Target Name="_WalterlvReadFilesToLines">
    <ReadLinesFromFile File="$(_WalterlvToWriteFile)">
        <Output TaskParameter="Lines" PropertyName="TheLinesThatRead" />
    </ReadLinesFromFile>
</Target>

WriteLinesToFile 写入文件

可以在编译期间,将一些信息写到文件中以便后续编译的时候使用,甚至将代码写到文件中以便动态生成代码。

<PropertyGroup>
    <_WalterlvBlogSite>https://blog.walterlv.com</_WalterlvBlogSite>
    <_WalterlvToWriteFile>$(OutputPath)walterlv.md</_WalterlvToWriteFile>
</PropertyGroup>

<ItemGroup>
    <_WalterlvToWriteLine Include="This is the first line" />
    <_WalterlvToWriteLine Include="This is the second line" />
    <_WalterlvToWriteLine Include="My blog site is: $(_WalterlvBlogSite)" />
</ItemGroup>

<Target Name="_WalterlvWriteFilesForPacking">
    <WriteLinesToFile File="$(_WalterlvToWriteFile)"
                      Lines="@(_WalterlvToWriteLine)" />
</Target>

▲ 注意,默认写入文件是不会覆盖的,会将内容补充到原来文件的后面。

<Target Name="_WalterlvWriteFilesForPacking">
    <WriteLinesToFile File="$(_WalterlvToWriteFile)"
                      Lines="@(_WalterlvToWriteLine)"
                      Overwrite="True"
                      Encoding="Unicode"
                      WriteOnlyWhenDifferent="True" />
</Target>

RemoveDir 删除文件夹

在编写编译命令的时候,可能会涉及到清理资源。或者为了避免无关文件的影响,在编译之前删除我们的工作目录。

<Target Name="_WalterlvRemoveDirectoryForPacking">
    <RemoveDir Directories="$(MSBuildThisFileDirectory)..\bin\$(Configuration)\" />
</Target>

下面是使用到 MakeDir 全部属性的例子,将已经成功创建的文件夹提取出来。

<Target Name="_WalterlvRemoveDirectoryForPacking">
    <RemoveDir Directories="$(MSBuildThisFileDirectory)..\bin\$(Configuration)\">
        <Output TaskParameter="RemovedDirectories" PropertyName="RemovedPackingDirectory" />
    </RemoveDir>
</Target>

如何在 .NET/C# 代码中安全地结束掉一个控制台应用程序?通过发送 Ctrl+C 信号来结束

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

我的电脑上每天会跑一大堆控制台程序,于是管理这些程序的运行就成了一个问题。或者说你可能也在考虑启动一个控制台程序来完成某些特定的任务。

如果我们需要结束掉这个控制台程序怎么做呢?直接杀进程吗?这样很容易出问题。我正在使用的一个控制台程序会写文件,如果直接杀进程可能导致数据没能写入到文件。所以本文介绍如何使用 .NET/C# 代码向控制台程序发送 Ctrl+C 来安全地结束掉程序。


用 Ctrl+C 结束控制台程序

如果直接用 Process.Kill 杀掉进程,进程可能来不及保存数据。所以无论是窗口程序还是控制台程序,最好都让控制台程序自己去关闭。

Process.Kill 结束控制台程序

▲ 使用 Process.Kill 结束程序,程序退出代码是 -1

Ctrl+C 结束控制台程序

▲ 使用 Ctrl+C 结束程序,程序退出代码是 0

Ctrl+C 信号

Windows API 提供了方法可以将当前进程与目标控制台进程关联起来,这样我们便可以向自己发送 Ctrl+C 信号来结束掉关联的另一个控制台进程。

关联和取消关联的方法是下面这两个,AttachConsoleFreeConsole

[DllImport("kernel32.dll")]
private static extern bool AttachConsole(uint dwProcessId);

[DllImport("kernel32.dll")]
private static extern bool FreeConsole();

不过,当发送 Ctrl+C 信号的时候,不止我们希望关闭的控制台程序退出了,我们自己程序也是会退出的(即便我们自己是一个 GUI 程序)。所以我们必须先组织自己响应 Ctrl+C 信号。

需要用到另外一个 API:

[DllImport("kernel32.dll")]
private static extern bool SetConsoleCtrlHandler(ConsoleCtrlDelegate? HandlerRoutine, bool Add);

enum CtrlTypes : uint
{
    CTRL_C_EVENT = 0,
    CTRL_BREAK_EVENT,
    CTRL_CLOSE_EVENT,
    CTRL_LOGOFF_EVENT = 5,
    CTRL_SHUTDOWN_EVENT
}

private delegate bool ConsoleCtrlDelegate(CtrlTypes CtrlType);

不过,因为我们实际上并不需要真的对 Ctrl+C 进行响应,只是单纯临时禁用以下,所以我们归这个委托传入 null 就好了。

最后,也是最关键的,就是发送 Ctrl+C 信号了:

[DllImport("kernel32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GenerateConsoleCtrlEvent(CtrlTypes dwCtrlEvent, uint dwProcessGroupId);

下面,我将完整的代码贴出来。

全部源代码

using System;
using System.Diagnostics;
using System.Runtime.InteropServices;

namespace Walterlv.Fracture.Utils
{
    /// <summary>
    /// 提供与控制台程序的交互。
    /// </summary>
    public class ConsoleInterop
    {
        /// <summary>
        /// 关闭控制台程序。
        /// </summary>
        /// <param name="process">要关闭的控制台程序的进程实例。</param>
        /// <param name="timeoutInMilliseconds">如果不希望一直等待进程自己退出,则可以在此参数中设置超时。你可以在超时未推出候采取强制杀掉进程的策略。</param>
        /// <returns>如果进程成功退出,则返回 true;否则返回 false。</returns>
        public static bool StopConsoleProgram(Process process, int? timeoutInMilliseconds = null)
        {
            if (process is null)
            {
                throw new ArgumentNullException(nameof(process));
            }

            if (process.HasExited)
            {
                return true;
            }

            // 尝试将我们自己的进程附加到指定进程的控制台(如果有的话)。
            if (AttachConsole((uint)process.Id))
            {
                // 我们自己的进程需要忽略掉 Ctrl+C 信号,否则自己也会退出。
                SetConsoleCtrlHandler(null, true);

                // 将 Ctrl+C 信号发送到前面已关联(附加)的控制台进程中。
                GenerateConsoleCtrlEvent(CtrlTypes.CTRL_C_EVENT, 0);

                // 拾前面已经附加的控制台。
                FreeConsole();

                bool hasExited;
                // 由于 Ctrl+C 信号只是通知程序关闭,并不一定真的关闭。所以我们等待一定时间,如果仍未关闭,则超时不处理。
                // 业务可以通过判断返回值来角是否进行后续处理(例如强制杀掉)。
                if (timeoutInMilliseconds == null)
                {
                    // 如果没有超时处理,则一直等待,直到最终进程停止。
                    process.WaitForExit();
                    hasExited = true;
                }
                else
                {
                    // 如果有超时处理,则超时候返回。
                    hasExited = process.WaitForExit(timeoutInMilliseconds.Value);
                }

                // 重新恢复我们自己的进程对 Ctrl+C 信号的响应。
                SetConsoleCtrlHandler(null, false);

                return hasExited;
            }
            else
            {
                return false;
            }
        }

        [DllImport("kernel32.dll")]
        private static extern bool AttachConsole(uint dwProcessId);

        [DllImport("kernel32.dll")]
        private static extern bool FreeConsole();

        [DllImport("kernel32.dll")]
        private static extern bool SetConsoleCtrlHandler(ConsoleCtrlDelegate? HandlerRoutine, bool Add);

        [DllImport("kernel32.dll")]
        [return: MarshalAs(UnmanagedType.Bool)]
        private static extern bool GenerateConsoleCtrlEvent(CtrlTypes dwCtrlEvent, uint dwProcessGroupId);

        enum CtrlTypes : uint
        {
            CTRL_C_EVENT = 0,
            CTRL_BREAK_EVENT,
            CTRL_CLOSE_EVENT,
            CTRL_LOGOFF_EVENT = 5,
            CTRL_SHUTDOWN_EVENT
        }

        private delegate bool ConsoleCtrlDelegate(CtrlTypes CtrlType);
    }
}

如何使用

现在,我们可以通过调用 ConsoleInterop.StopConsoleProgram(process) 来安全地结束掉一个控制台程序。

当然,为了处理一些意外的情况,我把超时也加上了。下面的用法演示超时 2 秒候程序还没有退出,则强杀。

if (!ConsoleInterop.StopConsoleProgram(process, 2000))
{
    try
    {
        process.Kill();
    }
    catch (InvalidOperationException e)
    {
    }
}

Ctrl+C 结束控制台程序


参考资料

如何将一个 .NET 对象序列化为 HTTP GET 的请求字符串

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

HTTP GET 请求时携带的参数直接在 URL 中,形式如 ?key1=value&key2=value&key3=value。如果是 POST 请求时,我们可以使用一些库序列化为 json 格式作为 BODY 发送,那么 GET 请求呢?有可以直接将其序列化为 HTTP GET 请求的 query 字符串的吗?


HTTP GET 请求

一个典型的 HTTP GET 请求带参数的话大概是这样的:

https://s.blog.walterlv.com/api/example?key1=value&key2=value&key3=value

于是我们将一个类型序列化为后面的参数:

[DataContract]
public class Foo
{
    [DataMember(Name = "key1")]
    public string? Key1 { get; set; }

    [DataMember(Name = "key2")]
    public string? Key2 { get; set; }

    [DataMember(Name = "key3")]
    public string? Key3 { get; set; }
}

库?

可能是这个需求太简单了,所以并没有找到单独的库。所以我就写了一个源代码包放到了 nuget.org 上。

在这里下载源代码包:

你不需要担心引入额外的依赖,因为这是一个源代码包。关于源代码包不引入额外依赖 dll 的原理,可以参见:

方法

我们需要做的是,将一个对象序列化为 query 字符串。假设这个对象的局部变量名称是 query,于是我们需要:

  1. 取得此对象所有可获取值的属性
    • query.GetType().GetProperties()
  2. 获取此属性值的方法
    • property.GetValue(query, null)
  3. 将属性和值拼接起来
    • string.Join("&", properties)

然而真实场景可能比这个稍微复杂一点:

  1. 我们需要像 Newtonsoft.Json 一样,对于标记了 DataContract 的类,按照 DataMember 来序列化
  2. URL 中的值需要进行转义

所以,我写出了下面的方法:

var isContractedType = query.GetType().IsDefined(typeof(DataContractAttribute));
var properties = from property in query.GetType().GetProperties()
                    where property.CanRead && (isContractedType ? property.IsDefined(typeof(DataMemberAttribute)) : true)
                    let memberName = isContractedType ? property.GetCustomAttribute<DataMemberAttribute>().Name : property.Name
                    let value = property.GetValue(query, null)
                    where value != null && !string.IsNullOrWhiteSpace(value.ToString())
                    select memberName + "=" + HttpUtility.UrlEncode(value.ToString());
var queryString = string.Join("&", properties);
return string.IsNullOrWhiteSpace(queryString) ? "" : prefix + queryString;

完整的代码如下:

using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Reflection;
using System.Runtime.Serialization;
using System.Web;

namespace Walterlv.Web.Core
{
    internal class QueryString
    {
        [return: NotNullIfNotNull("query")]
        public static string? Serialize(object? query, string? prefix = "?")
        {
            if (query is null)
            {
                return null;
            }

            var isContractedType = query.GetType().IsDefined(typeof(DataContractAttribute));
            var properties = from property in query.GetType().GetProperties()
                             where property.CanRead && (isContractedType ? property.IsDefined(typeof(DataMemberAttribute)) : true)
                             let memberName = isContractedType ? property.GetCustomAttribute<DataMemberAttribute>().Name : property.Name
                             let value = property.GetValue(query, null)
                             where value != null && !string.IsNullOrWhiteSpace(value.ToString())
                             select memberName + "=" + HttpUtility.UrlEncode(value.ToString());
            var queryString = string.Join("&", properties);
            return string.IsNullOrWhiteSpace(queryString) ? "" : prefix + queryString;
        }
    }
}

你可能会遇到 [return: NotNullIfNotNull("query")] 这一行编译不通过的情况,这个是 C# 8.0 带的可空引用类型所需要的契约类。

你可以将它删除,或者安装我的另一个 NuGet 包来获得更多可空引用类型契约的支持,详见:

屏幕边缘上有趣的 1 个像素,看不见、摸不到

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

如果你的屏幕分辨率是 1920×1080,那么一个全屏的窗口程序尺寸是多少呢?想都不用想,是 1920×1080。

那么输入设备输入的坐标是多少呢?是 X∈[0, 1919] ?还是 X∈[1, 1920] ?还是 X∈[0, 1920]


鼠标输入与触摸输入

一个有趣的问题,因为 1920×1080 分辨率的屏幕,其横向只有 1920 个像素,也就是说如果需要区分一个像素,那么只需要 1920 个数值就够了。这意味着 X∈[0, 1919] 或者 X∈[1, 1920] 的取值范围就能表示横向的所有像素了。

那么实际上最左侧的点的输入数值是多少呢?最右侧的点的输入数值是多少呢?

我写了一个最大化全屏的程序专门用来测试鼠标和触摸输入的数值是多少。

鼠标输入

▲ 在鼠标输入的情况下,最右侧其实是 1919(我的屏幕是 2560×1080,所以最右侧是 2559)

测量的时候,鼠标是直接往右移动到底,移到不能动为止。

那么在触摸输入的时候又如何?

触摸输入

▲ 在触摸输入的情况下,最右侧是 1920(我的屏幕是 2560×1080,所以最右侧是 2560)

测量的时候,是让手指近乎在屏幕外触摸,不断触摸到能够在屏幕上看到的最小或最大值为止。

有趣的 1 像素

发现上面实验中有趣的现象了吗?明明只有 1920×1080 的屏幕分辨率,窗口明明只有 1920×1080 那么大,鼠标下收到正常范围内的输入坐标,而触摸下我们能收到超出我们窗口大小 1 像素的触摸事件!

问题并没有完——

如果说,触摸给了你超出窗口大小的坐标,那么你能如何使用这个坐标呢?虽然程序里收到什么坐标都无所谓(至少不崩),但如果你真拿它来渲染,就会在屏幕之外。

更有趣的是,虽然你能收到这个“在屏幕边缘之外”的坐标,但这个消息并不总会发送到你的程序里。更多的时候,你的程序根本就不会收到这个触摸事件,于是我们也就不能在程序里面更新窗口上显示的坐标到 1920 了,就像鼠标一样。

于是,你可能遇到的问题是:

  1. 如果你在屏幕的左侧边缘触摸,你的程序可以一直收到触摸事件,你能够得到正确的响应;
  2. 如果你在屏幕的右侧边缘触摸,你将仅能偶尔收到零星的刚好超出窗口大小的触摸坐标,大多数时候收不到触摸事件,于是你可能无法获知用户在屏幕右侧边缘进行触摸。

防踩坑秘籍

林德熙小伙伴告诉我说可以特意把窗口的尺寸做大一个像素。我试过了,确实能够让触摸在整个屏幕上生效,但对于双屏用户来说,就能在另外一个屏幕上看到“露馅儿”了的窗口,对于我这种强迫症患者来说,显然是不能接受的。

我的建议是,并不需要对这种情况进行什么特殊的处理。

使用 MSBuild Target 复制文件的时候如何保持文件夹结构不变

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

使用 MSBuild 中的 Copy 这个编译目标可以在 .NET 项目编译期间复制一些文件。不过使用默认的参数复制的时候文件夹结构会丢失,所有的文件会保留在同一级文件夹下。

那么如何在复制文件的时候保持文件夹结构与原文件夹结构一样呢?


Copy

下面是一个典型的使用 MSBuild 在编译期间复制文件的一个编译目标。

<Target Name="_WalterlvCopyDemo" AfterTargets="AfterBuild">
  <ItemGroup>
    <_WalterlvToCopyFile Include="$(OutputPath)**" />
  </ItemGroup>
  <Copy SourceFiles="@(_WalterlvToCopyFile)" DestinationFolder="bin\Debug\Test" SkipUnchangedFiles="True" />
</Target>

这样复制的文件是不会保留文件夹结构的。

在同一层级

复制之后,所有的文件夹将不存在,所有文件覆盖地到同一层级。

RecursiveDir

如果希望保留文件夹层级,可以在 DestinationFolder 中使用文件路径来替代文件夹路径。

  <Target Name="_WalterlvCopyDemo" AfterTargets="AfterBuild">
    <ItemGroup>
      <_WalterlvToCopyFile Include="$(OutputPath)**" />
    </ItemGroup>
-   <Copy SourceFiles="@(_WalterlvToCopyFile)" DestinationFolder="bin\Debug\Test" SkipUnchangedFiles="True" />
+   <Copy SourceFiles="@(_WalterlvToCopyFile)" DestinationFolder="bin\Debug\Test\%(RecursiveDir)" SkipUnchangedFiles="True" />
  </Target>

保留了文件夹层次结构

使用正则表达式尽可能准确匹配域名/网址

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

你可能需要准确地知道一段字符串是否是域名/网址/URL。虽然可以使用 ./ 这些来模糊匹配,但会造成误判。

实际上单纯使用正则表达式来精确匹配也是非常复杂的,通过代码来判断会简单很多。不过本文依然从域名的定义出发来尽可能匹配一段字符串是否是域名或者网址,在要求不怎么高的场合,使用本文的正则表达式写的代码会比较简单。


网址

网址实际上是 URL(统一资源定位符),它是由协议、主机名和路径组成。不过我们通常所说的网址中的主机名通常是域名,因此我们在匹配的时候主要考虑域名。

域名

维基百科 中关于域名的描述:

  1. 域名由一或多个部分组成,这些部分通常连接在一起,并由点分隔。最右边的一个标签是顶级域名,例如zh.wikipedia.org的顶级域名是org。一个域名的层次结构,从右侧到左侧隔一个点依次下降一层。每个标签可以包含1到63个八字节。域名的结尾有时候还有一点,这是保留给根节点的,书写时通常省略,在查询时由软件内部补上。
  2. 域名里的英文字母不区分大小写。
  3. 完整域名的所有字符加起来不得超过253个ASCII字符的总长度。因此,当每一级都使用单个字符时,限制为127个级别:127个字符加上126个点的总长度为253。但实际上,某些域名可能具有其他限制;也没有只有一个字符的域名后缀。

后面关于非 ASCII 字符的描述我没有贴出来。这种域名例如“.中国”。

中国电信网站备案自助管理系统 中,我们可以找到关于域名的描述:

域名中的标号都由英文字母和数字组成,每一个标号不超过63个字符,也不区分大小写字母。标号中除连字符(-)外不能使用其他的标点符号。级别最低的域名写在最左边,而级别最高的域名写在最右边。由多个标号组成的完整域名总共不超过255个字符。

路径

路径是使用 / 分隔的一段一段字符串。

正则表达式匹配

在确认了完整的网址 URL 的规范之后,使用正则表达式来匹配就会比较精确了。

域名

现在,我们来尝试匹配一下域名

  1. 每个标签可组成的字符是 - a-z A-Z 0-9,但是 - 不可作为开头,标签总长度 1-63 个字符,于是
    • [a-zA-Z0-9][-a-zA-Z0-9]{0,62}
    • 即首字不含 -,后面的字可以包含 -
  2. 允许多个标签,于是
    • (\.[a-zA-Z0-9][-a-zA-Z0-9]{0,62})+
    • 除了标签内容和前面一样,但我们加了个 .

别忘了,我们还有总长度限制,于是考虑加上零宽断言 ^.{3,255}$,匹配开头和结尾,中间任意字符但长度在 3-255 之间。通过零宽断言,我们可以在不捕获匹配字符串的情况下对后面的字符串增加限制条件。

现在,把整个正则表达式拼出来:

^(?=^.{3,255}$)[a-zA-Z0-9][-a-zA-Z0-9]{0,62}(\.[a-zA-Z0-9][-a-zA-Z0-9]{0,62})+$

URL

对于不同的业务需求,可能有严格匹配或者宽松的匹配方式。

比如你要做一些比较精准的检查时需要进行严格的检查,那么选择严格匹配;这时,稍微出现一些不符合要求的字符都将认定为不是 URL。

如果你只是打算做一些简单的检查(例如只是语法高亮),那么简单匹配即可;因为当你使用 Chrome 浏览器访问这些 URL 的时候,依然可以正常访问,Chrome 会帮你格式化一下这个 URL。

URL(严格)

匹配 URL 跟匹配域名不一样,URL 复杂得多。严格匹配的要求是准确反应出 URL 的标准,但实际上如实反应标准编写的正则表达式会非常复杂,因此相比于 100% 准确匹配,我们还是从简了。

所以如果不是有特别要求,建议还是跳到后面的“宽松”部分来阅读吧!

我们以下面这个网址为例说明。

https://blog.walterlv.com/post/read-32bit-registry-from-x64-process.html

  1. 前面是可选的协议名,于是
    • (http(s)?:\/\/)
    • 然而既然可选,而且是行首,那么加一个 ? 和什么都不加的效果是一样的
  2. 随后是域名,于是
    • [a-zA-Z0-9][-a-zA-Z0-9]{0,62}(\.[a-zA-Z0-9][-a-zA-Z0-9]{0,62})+
    • 这里我们没有把总长度限制算上去
  3. 别忘了有个可选的端口号
    • (:[0-9]{1,5})?
    • 端口号的范围是 0-65535,但 0 是保留端口,49152 到 65535 也是保留端口,因此可以作为网址访问的范围也就是 1-49151,因此我们限制 1-5 位长度。
  4. 接下来是资源路径
    • 资源路径可以使用的字符也是有限制的,我们接下来详细说明。

组合整个正则表达式:

^[a-zA-Z0-9][-a-zA-Z0-9]{0,62}(\.[a-zA-Z0-9][-a-zA-Z0-9]{0,62})+(:[0-9]{1,5})?[-a-zA-Z0-9()@:%_\\\+\.~#?&//=]*$

顺便一提,不同于域名,我们这里去掉了长度限制,因为 URL 真的可以“很长”。另外,这里的

现在,我们补充说明一下资源路径可以使用的字符问题。

; / ? : @ & = + $ , 这些字符应该被转义。转义使用的字符是 &xxx;,因此在转义之后,依然还可能在网址中看到 &;,不过没有其他字符了。

- _ . ! ~ * ' ( ) 这些字符可以不进行转义,但也不建议在 URL 中使用。对于这部分,我们考虑将其匹配。

{ } | \ ^ [ ] ` 这部分字符可能被网关当作分隔符使用,因此不建议出现在 URL 中。对于这部分,我们考虑将其匹配。

< > # % " 控制字符。使用 % 可以组成其他 Unicode 字符,使用 # 用来指代网址中的某个部分。

因此,我们最终总结应该匹配的特殊字符有 @ : % _ \ + . ~ # ? & / =

URL(宽松)

宽松一点的话,正则表达式就好写多了。

这个正则表达式可以不写 https 协议前缀:

^\w+[^\s]+(\.[^\s]+){1,}$

如果上下文中要求必须匹配 https,则可以写:

^(http(s)?:\/\/)\w+[^\s]+(\.[^\s]+){1,}$
  • https://blog.walterlv.com/post/read-32bit-registry-from-x64-process.html#content)
    • 期望不匹配(主要是不能匹配末尾的括号),实际匹配
    • 在 URL 中,如果括号是成对的,则此 URL 允许以 ) 结尾,如果 URL 中括号不成对,则此 URL 不能以 ) 结尾;> 同理
  • https://blog.walterlv.com/post/read-32bit -registry-from-x64-process.html
    • 期望不匹配,实际不匹配
  • https://blog.lindexi.com/post/dotnet-配置-github-自动打包上传-nuget-文件.html
    • 期望匹配,实际匹配
  • https://域名.中国
    • 期望匹配,实际匹配
  • blog.walterlv.com/post/read-32bit-registry-from-x64-process.html
    • 期望匹配,实际匹配
  • x<blog.walterlv.com/post/read-32bit-registry-from-x64-process.html
    • 期望不匹配,实际匹配

这里的宽松正则表达式请小心!此正则表达式会将一段话中 URL 后面非空格的部分都算作 URL 的一部分。

更多大牛匹配 URL 的正则表达式

在 GitHub 上还有很多大牛们在写各种匹配 URL 的正则表达式:

最长的一个写了 1347 个字符,最短的有 38 个字符。

有人将其整理成一张表格,一图说明各种正则表达式能匹配到什么程度:


参考资料

C# 8.0 的可空引用类型,不止是加个问号哦!你还有很多种不同的可空玩法

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

C# 8.0 引入了可空引用类型,你可以通过 ? 为字段、属性、方法参数、返回值等添加是否可为 null 的特性。

但是如果你真的在把你原有的旧项目迁移到可空类型的时候,你就会发现情况远比你想象当中复杂,因为你写的代码可能只在部分情况下可空,部分情况下不可空;或者传入空时才可为空,传入非空时则不可为空。


C# 8.0 可空特性

在开始迁移你的项目之前,你可能需要了解如何开启项目的可空类型支持:

可空引用类型是 C# 8.0 带来的新特性。

你可能会好奇,C# 语言的可空特性为什么在编译成类库之后,依然可以被引用它的程序集识别。也许你可以理解为有什么特性 Attribute 标记了字段、属性、方法参数、返回值的可空特性,于是可空特性就被编译到程序集中了。

确实,可空特性是通过 NullableAttributeNullableContextAttribute 这两个特性标记的。

但你是否好奇,即使在古老的 .NET Framework 4.5 或者 .NET Standard 2.0 中开发的时候,你也可以编译出支持可空信息的程序集出来。这些古老的框架中没有这些新出来的类型,为什么也可以携带类型的可空特性呢?

实际上反编译一下编译出来的程序集就能立刻看到结果了。

看下图,在早期版本的 .NET 框架中,可空特性实际上是被编译到程序集里面,作为 internalAttribute 类型了。

反编译

所以,放心使用可空类型吧!旧版本的框架也是可以用的。

更灵活控制的可空特性

阻碍你将老项目迁移到可空类型的原因,可能还有你原来代码逻辑的问题。因为有些情况下你无法完完全全将类型迁移到可空。

例如:

  1. 有些时候你不得不为非空的类型赋值为 null 或者获取可空类型时你能确保此时一定不为 null(待会儿我会解释到底是什么情况);
  2. 一个方法,可能这种情况下返回的是 null 那种情况下返回的是非 null
  3. 可能调用者传入 null 的时候才返回 null,传入非 null 的时候返回非 null

为了解决这些情况,C# 8.0 还同时引入了下面这些 Attribute

  • AllowNull: 标记一个不可空的输入实际上是可以传入 null 的。
  • DisallowNull: 标记一个可空的输入实际上不应该传入 null。
  • MaybeNull: 标记一个非空的返回值实际上可能会返回 null,返回值包括输出参数。
  • NotNull: 标记一个可空的返回值实际上是不可能为 null 的。
  • MaybeNullWhen: 当返回指定的 true/false 时某个输出参数才可能为 null,而返回相反的值时那个输出参数则不可为 null。
  • NotNullWhen: 当返回指定的 true/false 时,某个输出参数不可为 null,而返回相反的值时那个输出参数则可能为 null。
  • NotNullIfNotNull: 指定的参数传入 null 时才可能返回 null,指定的参数传入非 null 时就不可能返回 null。
  • DoesNotReturn: 指定一个方法是不可能返回的。
  • DoesNotReturnIf: 在方法的输入参数上指定一个条件,当这个参数传入了指定的 true/false 时方法不可能返回。

想必有了这些描述后,你在具体遇到问题的时候应该能知道选用那个特性。但单单看到这些特性的时候你可能不一定知道什么情况下会用得着,于是我可以为你举一些典型的例子。

输入:AllowNull

设想一下你需要写一个属性:

public string Text
{
    get => GetValue() ?? "";
    set => SetValue(value ?? "");
}

当你获取这个属性的值的时候,你一定不会获取到 null,因为我们在 get 里面指定了非 null 的默认值。然而我是允许你设置 null 到这个属性的,因为我处理好了 null 的情况。

于是,请为这个属性加上 AllowNull。这样,获取此属性的时候会得到非 null 的值,而设置的时候却可以设置成 null

++  [AllowNull]
    public string Text
    {
        get => GetValue() ?? "";
        set => SetValue(value ?? "");
    }

输入:DisallowNull

与以上场景相反的一个场景:

private string? _text;

public string? Text
{
    get => _text;
    set => _text = value ?? throw new ArgumentNullException(nameof(value), "不允许将这个值设置为 null");
}

当你获取这个属性的时候,这个属性可能还没有初始化,于是我们获取到 null。然而我却并不允许你将这个属性赋值为 null,因为这是个不合理的值。

于是,请为这个属性加上 DisallowNull。这样,获取此属性的时候会得到可能为 null 的值,而设置的时候却不允许为 null

输出:MaybeNull

如果你有尝试过迁移代码到可空类型,基本上一定会遇到泛型方法的迁移问题:

public T Find<T>(int index)
{
}

比如以上这个方法,找到了就返回找到的值,找不到就返回 T 的默认值。那么问题来了,T 没有指定这是值类型还是引用类型。

如果 T 是引用类型,那么默认值 default(T) 就会引入 null。但是泛型 T 并没有写成 T?,因此它是不可为 null 的。然而值类型和引用类型的 T? 代表的是不同的含义。这种矛盾应该怎么办?

这个时候,请给返回值标记 MaybeNull

++  [return: MaybeNull]
    public T Find<T>(int index)
    {
    }

这表示此方法应该返回一个不可为 null 的类型,但在某些情况下可能会返回 null

实际上这样的写法并没有从本质上解决掉泛型 T 的问题,不过可以用来给旧项目迁移时用来兼容 API 使用。

如果你可以不用考虑 API 的兼容性,那么可以使用新的泛型契约 where T : notnull

public T Find<T>(int index) where T : notnull
{
}

输出:NotNull

设想你有一个方法,方法参数是可以传入 null 的:

public void EnsureInitialized(ref string? text)
{
}

然而这个方法的语义是确保此字段初始化。于是可以传入 null 但不会返回 null 的。这个时候请标记 NotNull

--  public void EnsureInitialized(ref string? text)
++  public void EnsureInitialized([NotNull] ref string? text)
    {
    }

NotNullWhen, MaybeNullWhen

string.IsNullOrEmpty 的实现就使用到了 NotNullWhen

bool IsNullOrEmpty([NotNullWhen(false)] string? value);

它表示当返回 false 的时候,value 参数是不可为 null 的。

这样,你在这个方法返回的 false 判断分支里面,是不需要对变量进行判空的。

当然,更典型的还有 TryDo 模式。比如下面是 Version 类的 TryParse

bool TryParse(string? input, [NotNullWhen(true)] out Version? result)

当返回 true 的时候,result 一定不为 null

NotNullIfNotNull

典型的情况比如指定默认值:

[return: NotNullIfNotNull("defaultValue")]
public string? GetValue(string key, string? defaultValue)
{
}

这段代码里面,如果指定的默认值(defaultValue)是 null 那么返回值也就是 null;而如果指定的默认值是非 null,那么返回值也就不可为 null 了。

在早期 .NET Framework 或者早期版本的 .NET Core 中使用

在本文第一小节里面,我们说 Nullable 是编译到目标程序集中的,所以不需要引用什么特别的程序集就能够使用到可空引用的特性。

那么上面这些特性呢?它们并没有编译到目标程序集中怎么办?

实际上,你只需要有一个命名空间、名字和实现都相同的类型就够了。你可以写一个放到你自己的程序集中,也可以把这些类型写到一个自己公共的库中,然后引用它。当然,你也可以用我已经写好的 NuGet 包 Walterlv.NullableAttributes。

Walterlv.NullableAttributes

微软 .NET 官方的可空特性在这里:

我将其注释翻译成中文之后,也写了一份在这里:

如果你想简单一点,可以直接引用我的 NuGet 包:

源代码包可以在不用引入其他 dll 依赖的情况下完成引用。最终你输出的程序集是不带对此包的依赖的,详见:


参考资料

一个简单的方法:截取子类名称中不包含基类后缀的部分

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

基类是 MenuItem,子类是 WalterlvMenuItemFooMenuItem。基类是 Configuration,子类是 WalterlvConfigurationExtensionConfiguration。在代码中,我们可能会为了能够一眼看清类之间的继承(从属)关系而在子类名称后缀中带上基类的名称。但是由于这种情况下的基类不参与实际的业务,所以对外(文件/网络)的名称通常不需要带上这个后缀。

本文提供一个简单的方法,让子类中基类的后缀删掉,只取得前面的那部分。


在这段代码中,我们至少需要获得两个传入的参数,一个是基类的名称,一个是子类的名称。但是考虑到让开发者就这样传入两者名称的话会比较容易出问题,因为开发者可能根本就不会按照要求去获取类型的名称。所以我们需要自己通过类型对象来获取名称。

另外,我们还需要有一些约束,必须有一个类型是另外一个类型的子类。于是我们可能必须来使用泛型做这样的约束。

于是,我们可以写出下面的方法:

using System;

namespace Walterlv.Utils
{
    /// <summary>
    /// 包含类名相关的处理方法。
    /// </summary>
    internal static class ClassNameUtils
    {
        /// <summary>
        /// 当某个类型的派生类都以基类(<typeparamref name="T"/>)名称作为后缀时,去掉后缀取派生类名称的前面部分。
        /// </summary>
        /// <typeparam name="T">名称统一的基类名称。</typeparam>
        /// <param name="this">派生类的实例。</param>
        /// <returns>去掉后缀的派生类名称。</returns>
        internal static string GetClassNameWithoutSuffix<T>(this T @this)
        {
            if (@this is null)
            {
                throw new ArgumentNullException(nameof(@this));
            }

            var derivedTypeName = @this.GetType().Name;
            var baseTypeName = typeof(T).Name;
            // 截取子类名称中去掉基类后缀的部分。
            var name = derivedTypeName.EndsWith(baseTypeName, StringComparison.Ordinal)
                ? derivedTypeName.Substring(0, derivedTypeName.Length - baseTypeName.Length)
                : derivedTypeName;
            // 如果子类名称和基类完全一样,则直接返回子类名称。
            return string.IsNullOrWhiteSpace(name) ? derivedTypeName : name;
        }
    }
}

我们通过判断子类是否以基类名称作为后缀来决定是否截取子字符串。

在截取完子串之后,我们还需要验证截取的字符串是否已经是空串了,因为父子类的名称可能是完全一样的(虽然这样的做法真的很逗比)。

于是使用起来只需要简单调用一下:

class Program
{
    static void Main(string[] args)
    {
        var name = ClassNameUtils.GetClassNameWithoutSuffix<Foo>(new XFoo());
    }
}

internal class Foo
{

}

internal class XFoo : Foo
{

}

于是我们可以得到 name 局部变量的值为 X。如果这个时候我们对 XFoo 类型改名,例如改成 XFoo1,那么就不会截取,而是直接得到名称 XFoo1

Windows 系统的默认字体是什么?应用的默认字体是什么?

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

作为中文应用的开发者,我们多半会认为系统的默认字体是“微软雅黑”。然而如果真的产生了这种误解,则很容易在开发本地化应用的时候踩坑。

于是本文带你了解 Windows 系统的默认字体。


Windows 10/8.1/8/7/Vista

Windows 操作系统的默认字体是 Segoe UI(发音为 see go 这两个单词),默认的字体大小为 9 点。

Segoe UI

Segoe UI 是 Segoe 字体家族中专为显示器显示而设计的一款字体。当然,Windows 系统中的其他字体也遵循这一命名规则,带 UI 后缀的适用于界面显示,而不带 UI 后缀的适用于打印和其他排版设计。

Segoe UI包含拉丁(Latin),希腊(Greek),西里尔字母(Cyrillic)和阿拉伯(Arabic)字符,覆盖了基本的英文俄文字母、数字和一些常用符号。然而其他语言就没有了。

其他语言的默认字体分别是:

语言 字体
日语(Japanese) Meiryo
韩语(Korean) Malgun Gothic
繁体中文(Chinese (Traditional)) Microsoft JhengHei
简体中文(Chinese (Simplified)) Microsoft YaHei
希伯来语(Hebrew) Gisha
泰语(Thai) Leelawadee

Windows 操作系统在启动应用程序的时候,会根据当前系统用户的地区决定默认字体应该采用哪一个。

Windows XP 及更早系统

早期版本的 Windows,默认字体是 Tahoma。简体中文下则是宋体。


参考资料

可集成到文件管理器,一句 PowerShell 脚本发布某个版本的所有 NuGet 包

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

要发布 NuGet 包,只需要执行命令 nuget push xxx.nupkg 即可,或者去 nuget.org 点鼠标上传。

不过,如果你有很多的 NuGet 包并且经常需要推送的话,也可以集成到 Directory Opus 或者 Total Commander 中。


NuGet 推送命令

NuGet 推送命令可直接在微软官方文档中阅读到:

在你已经设置了 ApiKey 的情况下:

nuget setapikey xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx -source https://api.nuget.org/v3/index.json

之后你只需要执行一句命令即可:

nuget.exe push Walterlv.Themes.FluentDesign.Source.0.8.0-alpha.nupkg -source https://api.nuget.org/v3/index.json

或者推送此文件夹下 0.8.0-alpha 版本的所有 NuGet 包:

nuget.exe push *.0.8.0-alpha.nupkg -source https://api.nuget.org/v3/index.json

用 PowerShell 包装一下

要执行 NuGet 的推送命令,我们需要一个可以执行命令的终端,比如 PowerShell。命令的执行结果我们也可以直接在终端看到。

不过,如果命令是集成到其他工具里面,那么就不一定能够看得到命令的执行结果了。

这个时候,可以考虑用 PowerShell 间接执行这个命令:

# PowerShell 版本
powershell -NoExit -c "nuget push *.0.8.0-alpha.nupkg -Source https://api.nuget.org/v3/index.json"
# PowerShell Core 版本
pwsh -NoExit -c "nuget push *.0.8.0-alpha.nupkg -Source https://api.nuget.org/v3/index.json"

关于使用 PowerShell 间接执行命令的更多细节,可以参考我的另一篇博客:

集成到 Directory Opus

我将这个命令集成到了 Directory Opus 中,这样,一次点击或者一个快捷键就能发布某个特定版本的所有的 NuGet 包了。

集成到 Directory Opus

关于使用 Directory Opus 继承工具栏按钮的细节,可以阅读我的另一篇博客:

具体来说,就是安装上文中所述的方法添加一个按钮,在按钮当中需要执行的脚本如下:

cd "{sourcepath} "
pwsh -NoExit -c "$file=[Regex]::Match('{file}', '\.\d+\.\d+\.\d+.+.nupkg').Value; nuget push *$file -Source https://api.nuget.org/v3/index.json"

含义为:

  1. 转到 Directory Opus 当前目录
  2. 执行一段 PowerShell 脚本,但执行完之后不退出(这样,我可以观察到我实际上推送的是哪一些包,并且可以知道推送是否出现了错误)
  3. 要执行的命令为 nuget push *.xxx.nupkg -Source https://api.nuget.org/v3/index.json
    • 其中,中间的 xxx 是使用正则表达式匹配的 {file} 文件名
    • {file} 是 Directory Opus 当前选中的文件,我用正则表达式匹配出其版本号和后面的 .nupkg 后缀
    • 将正则表达式匹配出来的文本作为 nuget push 的包,最终生成的命令会非常类似于本文一开始提到的命令 nuget push *.0.8.0-alpha.nupkg -Source https://api.nuget.org/v3/index.json

Directory Opus 工具栏按钮

于是,当我选中了一个包,按下这个工具栏按钮之后,就可以推送与这个包相同版本的所有的 NuGet 包了。

毕竟我一次编译产生的 NuGet 包太多了,还是需要使用这样的方式来提高一点效率。至于为什么不用持续集成,是因为目前 SourceYard 还不支持在 GitHub 上集成。

一键推送 NuGet 包


参考资料

C# 可空引用类型 Nullable 更强制的约束:将警告改为错误 WarningsAsErrors

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

程序员不看警告!

于是 C# 8.0 带来的可空引用类型由于默认以警告的形式出现,所以实际上约束力非常弱。

本文将把 C# 8.0 的可空引用类型警告提升为错误,以提高约束力。


启用可空引用类型

你需要先在你的项目中启用可空引用类型的支持,才能修改警告到错误:

项目属性

在项目属性中设置是比较快捷直观的方法。

在项目上右键属性,打开“生成”标签。

项目属性

在这里,可以看到“将警告视为错误”一栏:

  • 所有
  • 特定警告

可以看到默认选中的是“特定警告”且值是 NU1605

NU 是 NuGet 中发生的错误或者警告的前缀,NU1605 是大家可能平时经常见到的一个编译错误“检测到包降级”。关于这个错误的信息可以阅读官网:NuGet Warning NU1605 - Microsoft Docs,本文不需要说明。

于是,我们将我们需要视为错误的错误代码补充到后面就可以,以分号分隔。

NU1605;CS8600;CS8602;CS8603;CS8604;CS8618;CS8625

这些值的含义可以参考我的另一篇博客:

记得在改之前,把前面的配置从“活动”改为“所有配置”,这样你就不用改完之后仅在 Debug 生效,完了还要去 Release 配置再改一遍。

改为所有配置

WarningsAsErrors

前面使用属性面板指定时,有一个奇怪的默认值。实际上我们直接修改将固化这个默认值,这不利于将来项目跟随 Sdk 或者 NuGet 包的升级。

所以,最好我们能直接修改到项目文件,以便更精细地控制这个属性的值。

在上一节界面中设置实际上是生成了一个属性 WarningsAsErrors。那么我们现在修改 WarningsAsErrors 属性的值,使其拼接之前的值:

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

这句话的含义是先获取之前的值,将其放到我们要设置的值的前面。这样可以跟随 Sdk 或者 NuGet 包的升级而更新此默认值。

这些值的含义可以参考我的另一篇博客:


参考资料

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

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

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。


参考资料

WPF 程序如何跨窗口/跨进程设置控件焦点

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

WPF 程序提供了 Focus 方法和 TraversalRequest 来在 WPF 焦点范围内转移焦点。但如果 WPF 窗口中嵌入了其他框架的 UI(比如另一个子窗口),那么就需要使用其他的方法来设置焦点了。


一个粗略的设置方法是,使用 Win32 API:

SetFocus(hwnd);

传入的是要设置焦点的窗口的句柄。


参考资料