C--秘籍-四-

126 阅读34分钟

C# 秘籍(四)

原文:zh.annas-archive.org/md5/a8f8c1cbab144b65246bf82de72f5bb5

译者:飞龙

协议:CC BY-NC-SA 4.0

第七章:操控数据

每个应用程序都使用数据,并且我们需要将数据从一种形式转换为另一种形式。本章提供了关于数据转换的多个主题,如机密管理、JSON 序列化和 XML 序列化。

机密信息是我们不希望向第三方公开的数据,例如密码或 API 密钥。本章包括三节关于管理这些机密信息的内容,包括哈希处理、加密和隐藏存储。

当今我们处理的许多数据都是以 JSON 格式。在现代框架中,基本的序列化/反序列化操作很简单,如果你同时拥有数据的消费者和提供者,这一切会更加简单。但是当你处理第三方数据时,你无法控制数据的一致性或标准。因此,本章的 JSON 部分深入探讨了定制方法,帮助你处理任何你需要的 JSON 格式数据。

最后,尽管 JSON 在当前互联网数据格式中占据主导地位,但仍然有大量 XML 数据需要处理,这是 XML 章节的主题。你将看到 LINQ 的另一种风格,称为 LINQ to XML,它可以完全控制序列化/反序列化过程。

7.1 生成密码哈希

问题

你需要安全地存储用户密码。

解决方案

这种方法生成一个随机盐来保护秘密:

static byte[] GenerateSalt()
{
    const int SaltLength = 64;

    byte[] salt = new byte[SaltLength];
    var rngRand = new RNGCryptoServiceProvider();

    rngRand.GetBytes(salt);

    return salt;
}

接下来的两种方法使用该盐生成哈希值:

static byte[] GenerateMD5Hash(string password, byte[] salt)
{
    byte[] passwordBytes = Encoding.UTF8.GetBytes(password);

    byte[] saltedPassword =
        new byte[salt.Length + passwordBytes.Length];

    using var hash = new MD5CryptoServiceProvider();

    return hash.ComputeHash(saltedPassword);
}

static byte[] GenerateSha256Hash(string password, byte[] salt)
{
    byte[] passwordBytes = Encoding.UTF8.GetBytes(password);

    byte[] saltedPassword =
        new byte[salt.Length + passwordBytes.Length];

    using var hash = new SHA256CryptoServiceProvider();

    return hash.ComputeHash(saltedPassword);
}

这里是如何使用方法生成哈希值:

static void Main(string[] args)
{
    Console.WriteLine("\nPassword Hash Demo\n");

    Console.Write("What is your password? ");
    string password = Console.ReadLine();

    byte[] salt = GenerateSalt();

    byte[] md5Hash = GenerateMD5Hash(password, salt);
    string md5HashString = Convert.ToBase64String(md5Hash);
    Console.WriteLine($"\nMD5:    {md5HashString}");

    byte[] sha256Hash = GenerateSha256Hash(password, salt);
    string sha256HashString = Convert.ToBase64String(sha256Hash);
    Console.WriteLine($"\nSHA256: {sha256HashString}");
}

讨论

ASP.NET Identity 对密码和组/角色管理提供了很好的支持,这应该是在规划新项目时考虑的要点之一。然而,在某些情况下,例如当你必须使用 ASP.NET Identity 不支持的数据库或必须使用具有自己自制密码管理系统的现有数据库时,ASP.NET Identity 可能不是最佳选择。

在构建自定义密码管理解决方案时,最佳实践是使用盐对密码进行哈希处理。哈希是将密码单向转换为一串无法理解的字符。每次你对特定密码进行哈希处理时,都会得到相同的哈希值。然而,与加密的重要区别在于你无法解密哈希值——没有办法将哈希值翻译回原始密码。这就引出了一个问题:如何知道用户输入了正确的密码。由于本书主要讲解 C#,数据库开发超出了本书的范围。尽管如此,在这里我们列出了验证密码所需的步骤:

  1. 创建用户账户时,对密码进行哈希处理,并将哈希值与用户名一起存储在数据库中。

  2. 当用户登录时,他们提供用户名和密码。

  3. 使用用户名,你的代码进行数据库查询,并检索匹配的哈希值。

  4. 对用户输入的密码进行哈希处理。

  5. 比较哈希密码。

  6. 如果密码哈希匹配,则验证成功——否则验证失败。

安全性是一场持续的猫鼠游戏。我们刚刚学会用哈希保护密码,黑客们就寻找方法突破它。最终,我们能做的最好的事情就是找到一定程度的安全性,使得黑客因我们需要保护信息而愿意获取它的成本变得非常高昂。您能承受多少安全性成本?

幸运的是,有一个简单的方法来加强密码安全性。在处理哈希密码的安全最佳实践中,包含盐(salt)是一种方法,盐是追加到密码后的随机字节数组。我们将盐和用户名、密码一起保存在数据库中。这对于防范彩虹表攻击非常有效,详见注释。解决方案中的GenerateSalt方法生成一个随机的 64 字节值。盐可以防止彩虹表攻击,并迫使黑客转向更为计算密集的字典攻击。

注释

如果黑客侵入您的系统或者找到了存储密码的表格的副本,那么常见的攻击有两种:字典攻击和彩虹表攻击。

在字典攻击中,黑客拥有一个包含单词和短语的字典列表,并逐个对列表中的项进行哈希处理,然后与数据库表进行比较。尽管有所有的复杂规则和遵循这些规则的人数,总会有些人使用单个单词密码。剧透警告:对于那些认为用符号/数字字符替换会变得聪明的人,这种方法行不通;黑客的字典和算法已经考虑到了这一点。

彩虹表攻击是字典攻击的另一种变体,其区别在于彩虹表已经对常见单词进行了哈希处理,因此他们只需进行简单的比较即可快速地遍历密码表。

GenerateMD5HashGenerateSha256哈希方法都接受一个密码和一个盐。这两种方法都将密码转换为byte[],连接密码和盐,然后生成一个哈希。MD5 和 SHA256 实现之间的语法差异在于MD5CryptoServiceProviderSHA256​Cryp⁠to​ServiceProvider

在实践中,有不同的原因使用特定的哈希算法。.NET 框架有几种哈希算法,你可以通过查找HashAlgorithm并检查其派生类来找到这些算法。许多最近的实现使用 SHA256 哈希,因为它比早期的哈希算法提供更好的保护。我包括 MD5 算法是为了说明你并不总是能够选择算法,因为密码表可能已经使用 MD5 创建。在这种情况下,对用户的不便可能会阻止他们重新输入密码以适应另一种哈希算法。

Main 方法演示如何使用这些算法生成哈希值。这里的一个有趣之处是调用 Convert.ToBase64String。每当你在不同地方传输数据时,传输机制都有一套基于特殊字符的协议和格式。如果哈希字节中的字符在传输过程中转换为特殊字符,软件将会出现问题。解决这个问题的标准方法是使用一种名为 Base64 的数据格式,它生成的字符不会与特殊的数据格式或传输协议字符冲突。

7.2 加密和解密机密信息

问题

你有需要在静态状态下加密的 API 密钥。

解决方案

这个类用于加密和解密机密信息:

public class Crypto
{
    public byte[] Encrypt(string plainText, byte[] key)
    {
        using Aes aes = Aes.Create();
        aes.Key = key;

        using var memStream = new MemoryStream();
        memStream.Write(aes.IV, 0, aes.IV.Length);

        using var cryptoStream = new CryptoStream(
            memStream,
            aes.CreateEncryptor(),
            CryptoStreamMode.Write);

        byte[] plainTextBytes = Encoding.UTF8.GetBytes(plainText);

        cryptoStream.Write(plainTextBytes);
        cryptoStream.FlushFinalBlock();

        memStream.Position = 0;

        return memStream.ToArray();
    }

    public string Decrypt(byte[] cypherBytes, byte[] key)
    {
        using var memStream = new MemoryStream();
        memStream.Write(cypherBytes);
        memStream.Position = 0;

        using var aes = Aes.Create();

        byte[] iv = new byte[aes.IV.Length];
        memStream.Read(iv, 0, iv.Length);

        using var cryptoStream = new CryptoStream(
            memStream,
            aes.CreateDecryptor(key, iv),
            CryptoStreamMode.Read);

        int plainTextByteLength = cypherBytes.Length - iv.Length;
        var plainTextBytes = new byte[plainTextByteLength];
        cryptoStream.Read(plainTextBytes, 0, plainTextByteLength);

        return Encoding.UTF8.GetString(plainTextBytes);
    }
}

这是一个生成随机密钥的方法:

static byte[] GenerateKey()
{
    const int KeyLength = 32;

    byte[] key = new byte[KeyLength];
    var rngRand = new RNGCryptoServiceProvider();

    rngRand.GetBytes(key);

    return key;
}

使用 Crypto 类和随机密钥加密和解密机密信息的方法如下:

static void Main()
{
    var crypto = new Crypto();

    Console.Write("Please enter text to encrypt: ");
    string userPlainText = Console.ReadLine();

    byte[] key = GenerateKey();

    byte[] cypherBytes = crypto.Encrypt(userPlainText, key);

    string cypherText = Convert.ToBase64String(cypherBytes);

    Console.WriteLine($"Cypher Text: {cypherText}");

    string decryptedPlainText = crypto.Decrypt(cypherBytes, key);

    Console.WriteLine($"Plain Text: {decryptedPlainText}");
}

讨论

我们经常有需要保护的秘密信息——API 密钥或其他敏感信息。加密是在静止状态下保护信息的方法。在保存之前,我们对数据进行加密,然后在检索加密数据后,我们对其进行解密以供使用。

在解决方案中,Crypto 类具有加密和解密数据的方法。key 参数是加密/解密算法使用的秘密值。我们将使用称为 对称密钥加密 的技术,其中我们使用单个密钥来加密/解密所有数据。显然,你不应将加密密钥存储在与数据相同的位置,因为如果黑客能够读取数据,他们还需要找出加密密钥所在的位置。在此演示中,GenerateKey 方法生成一个随机的 32 位密钥,加密提供程序所需。

加密提供程序是使用特殊算法加密/解密数据的代码。解决方案示例使用了先进加密标准 (AES),这是一种现代和安全的加密算法。

在保存数据时,你将 plainText 字符串与 key 一起传递到 Encrypt 方法中。调用 AES.Create 返回 AES 的一个实例。存储在数据库中的值是连接的初始化向量 (IV) 和加密文本。注意 memStream 首先从 AES 实例加载 IV 的方式。

CryptoStream 的三个参数是 memStream(包含 IV)、ICryptoTransform(通过调用 AES.CreateEncryptor 返回)、以及 Crypto​S⁠treamMode(指示我们正在向流中写入)。cryptoStream 实例将加密后的字节追加到 memStream 中的 IV。我们使用数据的 byte[] 表示,包括 plainText。在 cryptoStream 上调用 Write 执行加密,在调用 FlushFinalBlock 确保所有字节都被处理并推送到 memStream 中。

Decrypt 方法反转了这个过程。除了与加密时相同的 key 外,还有一个 cypherBytes 参数。如果您回忆一下 Encrypt 过程,加密值包括 IV 和附加的加密值,这些是 cypherBytes 的内容。加载了 cypherBytesmemStream 后,代码将 memStream 重新定位到开头,并将 IV 提取到 iv 中。这样 memStream 就位于 IV 的长度处,加密值从这里开始。

这里,cryptoStream 使用了加密文本(memStream 适当位置)。在这里,ICryptoTransform 不同,因为我们使用 ivkey 调用 CreateDecryptor。此外,CryptoStreamMode 需要设置为 Read。在 cryptoStream 上调用 Read 执行解密操作。

Main 方法展示了如何使用 EncryptDecrypt 方法。请注意,它们都使用相同的 keyConvert.ToBase64String 确保我们可以处理数据,避免随机字节被意外解释。例如,如果将二进制文件打印到控制台,可能会听到响声,因为某些字节被解释为响铃字符。此外,在传输数据时,Base64 可以避免字节被解释为传输协议或格式字符,从而破坏代码。

7.3 隐藏开发秘密

问题

您需要避免将密码和 API 密钥等秘密信息意外提交到源代码控制中。

解决方案

这是项目文件:

<Project Sdk="Microsoft.NET.Sdk">
    <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net5.0</TargetFramework>
    <RootNamespace>Section_07_03</RootNamespace>
    <UserSecretsId>d3d91a8b-d440-414a-821e-7f11eec48f32</UserSecretsId>
    </PropertyGroup>

    <ItemGroup>
    <PackageReference
        Include="Microsoft.Extensions.Hosting" Version="5.0.0" />
    </ItemGroup>
</Project>

以下代码展示了如何轻松添加支持隐藏秘密的代码:

class Program
{
    static void Main()
    {
        var config = new ConfigurationBuilder()
            .AddUserSecrets<Program>()
            .Build();

        string key = "CSharpCookbook:ApiKey";
        Console.WriteLine($"{key}: {config[key]}");
    }
}

讨论

这是很常见的问题,开发人员意外将数据库连接字符串从配置文件提交到源代码控制中。另一个常见问题是,开发人员在在线论坛寻求帮助时,在其代码示例中意外留下了秘密。希望能清楚地看到这些错误可能对应用程序甚至业务造成严重损害。

其中一种解决方案是使用 Secret Manager。虽然 Secret Manager 通常与 ASP.NET 关联紧密,因为它具有内置的配置支持,但您可以将其与任何类型的应用程序一起使用。该解决方案展示了如何在控制台应用程序中轻松使用 Secret Manager。

注意

这是在开发环境中非常有用的功能。在生产环境中,您可能希望使用更安全的选项,例如,如果您部署到 Azure,则可能会使用密钥保管库(Key Vault)。将机密保存在环境变量中是避免将其存储在代码或配置中的另一种方式。

有些项目类型,如 ASP.NET,已经支持确保不会意外将开发代码部署到生产环境中,例如:

if (env.IsDevelopment())
{
    config.AddUserSecrets<Program>();
}

您可以使用 dotnet CLI 配置应用程序以使用 Secret Manager。第一步是通过命令行更新项目:

dotnet user-secrets init

这在项目文件中添加了一个UserSecretsID标签,如前所示。该 GUID 标识了存储秘密的文件系统位置。在这个示例中,该位置是:

%APPDATA%\Microsoft\UserSecrets\d3d91a8b-d440-414a-821e-7f11eec48f32
\secrets.json

在 Windows 上或:

~/.microsoft/usersecrets/d3d91a8b-d440-414a-821e-7f11eec48f32
/secrets.json

对于 Linux 或 macOS 机器。

设置完成后,您可以开始添加秘密,就像这样(与项目文件夹相同的位置):

dotnet user-secrets set "CSharpCookbook:ApiKey" "mYaPIsECRET"

您可以通过查看secrets.json或以下命令来验证已保存的秘密:

dotnet user-secrets list

Main方法展示了如何读取秘密管理器的密钥。记得引用Microsoft.Extensions.Hosting NuGet 包。只需在新的ConfigurationBuilder上调用AddUserSecrets。在其上调用Build返回一个IConfigurationRoot实例,提供索引器支持以读取键。

7.4 生成 JSON

问题

你需要自定义 JSON 输出格式。

解决方案

此代码显示了PurchaseOrder的外观:

public enum PurchaseOrderStatus
{
    Received,
    Processing,
    Fulfilled
}

public class PurchaseItem
{
 [JsonPropertyName("serialNo")]
    public string SerialNumber { get; set; }

 [JsonPropertyName("description")]
    public string Description { get; set; }

 [JsonPropertyName("qty")]
    public float Quantity { get; set; }

 [JsonPropertyName("amount")]
    public decimal Price { get; set; }
}

public class PurchaseOrder
{
 [JsonPropertyName("company")]
    public string CompanyName { get; set; }
 [JsonPropertyName("address")]
    public string Address { get; set; }
 [JsonPropertyName("phone")]
    public string Phone { get; set; }

 [JsonPropertyName("status")]
    public PurchaseOrderStatus Status { get; set; }

 [JsonPropertyName("other")]
    public Dictionary<string, string> AdditionalInfo { get; set; }

 [JsonPropertyName("details")]
    public List<PurchaseItem> Items { get; set; }
}

此代码序列化了PurchaseOrder

public class PurchaseOrderService
{
    public void View(PurchaseOrder po)
    {
        var jsonOptions = new JsonSerializerOptions
        {
            WriteIndented = true
        };

        string poJson = JsonSerializer.Serialize(po, jsonOptions);

        // send HTTP request

        Console.WriteLine(poJson);
    }
}

这是如何填充PurchaseOrder的方法:

static PurchaseOrder GetPurchaseOrder()
{
    return new PurchaseOrder
    {
        CompanyName = "Acme, Inc.",
        Address = "123 4th St.",
        Phone = "555-835-7609",
        AdditionalInfo = new Dictionary<string, string>
        {
            { "terms", "Net 30" },
            { "poc", "J. Smith" }
        },
        Items = new List<PurchaseItem>
        {
            new PurchaseItem
            {
                Description = "Widget",
                Price = 13.95m,
                Quantity = 5,
                SerialNumber = "123"
            }
        }
    };
}

Main方法驱动该过程:

static void Main()
{
    PurchaseOrder po = GetPurchaseOrder();
    new PurchaseOrderService().View(po);
}

这是输出结果:

{
  "company": "Acme, Inc.",
  "address": "123 4th St.",
  "phone": "555-835-7609",
  "status": 0,
  "other": {
    "terms": "Net 30",
    "poc": "J. Smith"
  },
  "details": [
    {
      "serialNo": "123",
      "description": "Widget",
      "qty": 5,
      "amount": 13.95
    }
  ]
}

讨论

只需调用JsonSerializer.Serialize,来自System.Text.Json命名空间,这是将对象序列化为 JSON 的简单快速方法。如果您拥有应用程序的生产和消费部分,这可能是简单和快速的选择。但通常情况下,您会消费一个指定其自己 JSON 数据格式的第三方 API。此外,其命名约定与 C# Pascal 大小写属性名称不匹配。本节显示如何执行这些序列化器输出的自定义。

注意

Microsoft 在.NET Core 3 中引入了System.Text.Json命名空间。此前,一个广受欢迎的选择是得到了出色支持的Newtonsoft.Json库。

在解决方案场景中,我们希望将 JSON 文档发送到 API,但属性名称不匹配。这就是为什么PurchaseOrder(及其支持类型)使用JsonPropertyName属性装饰属性。JsonSerializer使用JsonPropertyName指定输出属性名称。

PurchaseOrderService有一个View方法,可以序列化PurchaseOrder。默认情况下,序列化器输出是单行的,我们希望看到格式化输出。代码使用了一个JsonSerializerOption,其中WriteIndented设置为true,产生了解决方案中显示的输出。

Main方法驱动该过程,获取一个新的PurchaseOrder,然后调用View打印出结果。

有时,API 会有机地增长,它们的命名约定缺乏一致性,这使得自定义输出的理想方法。但是,如果您使用的是具有一致命名约定的 API,7.5 章解释了如何构建转换器以避免为每个属性都装饰JsonPropertyName

参见

7.5 章,“消费 JSON”

7.5 消费 JSON

问题

您需要读取不符合默认反序列化选项的 JSON 对象。

解决方案

这是PurchaseOrder的外观:

public enum PurchaseOrderStatus
{
    Received,
    Processing,
    Fulfilled
}

public class PurchaseItem
{
    public string SerialNumber { get; set; }

    public string Description { get; set; }

    public float Quantity { get; set; }

    public decimal Price { get; set; }
}

public class PurchaseOrder
{
    public string CompanyName { get; set; }
    public string Address { get; set; }
    public string Phone { get; set; }

 [JsonConverter(typeof(PurchaseOrderStatusConverter))]
    public PurchaseOrderStatus Status { get; set; }

    public Dictionary<string, string> AdditionalInfo { get; set; }

    public List<PurchaseItem> Items { get; set; }
}

这是一个自定义JsonConverter类:

public class PurchaseOrderStatusConverter
    : JsonConverter<PurchaseOrderStatus>
{
    public override PurchaseOrderStatus Read(
        ref Utf8JsonReader reader,
        Type typeToConvert,
        JsonSerializerOptions options)
    {
        string statusString = reader.GetString();

        if (Enum.TryParse(
            statusString,
            out PurchaseOrderStatus status))
        {
            return status;
        }
        else
        {
            throw new JsonException(
                $"{statusString} is not a valid " +
                $"{nameof(PurchaseOrderStatus)} value.");
        }
    }

    public override void Write(
        Utf8JsonWriter writer,
        PurchaseOrderStatus value,
        JsonSerializerOptions options)
    {
        writer.WriteStringValue(value.ToString());
    }
}

这是自定义的 JSON 命名策略:

public class SnakeCaseNamingPolicy : JsonNamingPolicy
{
    public override string ConvertName(string name)
    {
        var targetChars = new List<char>();
        char[] sourceChars = name.ToCharArray();

        char first = sourceChars[0];
        if (char.IsUpper(first))
            targetChars.Add(char.ToLower(first));
        else
            targetChars.Add(first);

        for (int i = 1; i < sourceChars.Length; i++)
        {
            char ch = sourceChars[i];

            if (char.IsUpper(ch))
            {
                targetChars.Add('_');
                targetChars.Add(char.ToLower(ch));
            }
            else
            {
                targetChars.Add(ch);
            }
        }

        return new string(targetChars.ToArray());
    }
}

此类模拟请求,返回格式化为 JSON 的数据:

public class PurchaseOrderService
{
    public string Get(int poID)
    {
        // get HTTP request

        return @"{
""company_name"": ""Acme, Inc."",
""address"": ""123 4th St."",
""phone"": ""555-835-7609"",
""additional_info"": {
 ""terms"": ""Net 30"",
 ""poc"": ""J. Smith"",
},
""status"": ""Processing"",
""items"": [
 {
 ""serial_number"": ""123"",
 ""description"": ""Widget"",
 ""quantity"": 5,
 ""price"": 13.95
 }
]
}";
    }
}

Main 方法显示了如何使用自定义转换器、选项和策略:

static void Main()
{
    string poJson =
        new PurchaseOrderService()
            .Get(poID: 123);

    var jsonOptions = new JsonSerializerOptions
    {
        AllowTrailingCommas = true,
        Converters =
        {
            new PurchaseOrderStatusConverter()
        },
        PropertyNameCaseInsensitive = true,
        PropertyNamingPolicy = new SnakeCaseNamingPolicy(),
        WriteIndented = true
    };

    PurchaseOrder po =
        JsonSerializer
        .Deserialize<PurchaseOrder>(poJson, jsonOptions);

    Console.WriteLine($"{po.CompanyName}");
    Console.WriteLine($"{po.AdditionalInfo["terms"]}");
    Console.WriteLine($"{po.Items[0].Description}");

    string poJson2 = JsonSerializer.Serialize(po, jsonOptions);

    Console.WriteLine(poJson2);
}

这是输出结果:

Acme, Inc.
Net 30
Widget
{
  "company_name": "Acme, Inc.",
  "address": "123 4th St.",
  "phone": "555-835-7609",
  "status": "Processing",
  "additional_info": {
    "terms": "Net 30",
    "poc": "J. Smith"
  },
  "items": [
    {
      "serial_number": "123",
      "description": "Widget",
      "quantity": 5,
      "price": 13.95
    }
  ]
}

讨论

JsonSerializer 具有用于生成驼峰命名属性名的内置转换器,通过 JsonInitializerOptions,像这样:

var serializeOptions = new JsonSerializerOptions
{
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};

这处理了许多情况,但是如果第三方 API 没有使用帕斯卡命名或驼峰命名属性名称怎么办?此解决方案包括对由下划线分隔单词的蛇形命名属性名称的支持。例如,SnakeCase 变成了 snake_case。除了新的命名策略外,该解决方案还包括其他定制,包括对 enum 的支持。

注意,PurchaseOrder 不会使用 JsonPropertyName 装饰任何属性。相反,我们使用自定义命名策略,在 SnakeCaseNamingPolicy 类中定义,该类派生自 JsonNamingPolicyConvertName 中的算法假定它已收到帕斯卡命名规则的属性名。它迭代字符,查找大写字符。遇到大写字符时,它会附加下划线 _,将字母小写,并将小写字母附加到结果中。否则,它附加字符,该字符已经是小写的。

Main 方法实例化了 JsonSerializerOptions,将 PropertyNamingPolicy 设置为 SnakeCaseNamingPolicy 的实例。这将命名策略应用于所有属性名称,生成蛇形命名的属性名称。

提示

与许多情况一样,您可能会遇到规则的例外情况,其中 JSON 属性不符合蛇形命名规则。在这种情况下,使用 JsonPropertyName 属性,如 Recipe 7.4 中所述,覆盖该属性的命名策略。

您可能已经注意到,在 Main 中,JsonSerializerOptions 还具有其他定制。AllowTrailingCommas 很有趣,因为有时您会收到包含列表的 JSON 数据,列表中的最后一项有一个尾随逗号。这会破坏反序列化,将 AllowTrailingCommas 设置为 true 可以忽略尾随逗号。

PropertyNameCaseInsensitive 是一种选择,不考虑属性名称的格式。在反序列化时,它允许小写属性名称与它们的大写等效项匹配。当传入的 JSON 属性名称可能不一致时,这是有用的。

默认情况下,JsonSerializer 生成单行 JSON 文档。设置 WriteIndented 可以格式化文本以提高可读性,如输出中所示。

其中一个属性 Converters 是一个类型集合,用于在属性上进行自定义转换。PurchaseOrderStatusConverterJsonConverter<T> 派生,允许将 Status 属性反序列化为 PurchaseOrderStatus 枚举。有两种方法可以应用它:在 JsonSerialization 选项中或通过属性。将转换器添加到 JsonSerializationOptionsConverter 集合中会为所有 PurchaseOrderStatus 属性类型应用转换。此外,PurchaseOrder 类使用 JsonConverter 属性装饰 Status 属性。我在解决方案中添加了这两种方法,以便你能看到它们各自的工作方式。将转换器添加到 Converters 集合中就足够了。但是,如果你想要为特定属性应用不同的转换器,或者需要为不同的属性使用不同的转换器,那么请使用 JsonConverter 属性,因为它优先于 Converters 集合。

Main 方法展示了如何在反序列化和序列化中使用相同的 JsonSerializationOptions

参见

7.4 节,“生成 JSON”

7.6 处理 JSON 数据

问题

收到了无法干净反序列化为对象的 JSON 数据。

解决方案

这是一个 PurchaseOrder 的样例:

public enum PurchaseOrderStatus
{
    Received,
    Processing,
    Fulfilled
}

public class PurchaseItem
{
    public string SerialNumber { get; set; }

    public string Description { get; set; }

    public double Quantity { get; set; }

    public decimal Price { get; set; }
}

public class PurchaseOrder
{
    public string CompanyName { get; set; }
    public string Address { get; set; }
    public string Phone { get; set; }
    public string Terms { get; set; }
    public string POC { get; set; }

    public PurchaseOrderStatus Status { get; set; }

    public Dictionary<string, string> AdditionalInfo { get; set; }

    public List<PurchaseItem> Items { get; set; }
}

这个类模拟了返回 JSON 数据的请求:

public class PurchaseOrderService
{
    public string Get(int poID)
    {
        // get HTTP request

        return @"{
""company_name"": ""Acme, Inc."",
""address"": ""123 4th St."",
""phone"": ""555-835-7609"",
""additional_info"": {
 ""terms"": ""Net 30"",
 ""poc"": ""J. Smith""
},
""status"": ""Processing"",
""items"": [
 {
 ""serial_number"": ""123"",
 ""description"": ""Widget"",
 ""quantity"": 5,
 ""price"": 13.95
 }
]
}";
    }
}

这里是支持自定义反序列化的类:

public static class JsonConversionExtensions
{
    public static bool IsNull(this JsonElement json)
    {
        return
            json.ValueKind == JsonValueKind.Undefined ||
            json.ValueKind == JsonValueKind.Null;
    }

    public static string GetString(
        this JsonElement json,
        string propertyName,
        string defaultValue = default)
    {
        if (!json.IsNull() &&
            json.TryGetProperty(propertyName, out JsonElement element))
            return element.GetString() ?? defaultValue;

        return defaultValue;
    }

    public static int GetInt(
        this JsonElement json,
        string propertyName,
        int defaultValue = default)
    {
        if (!json.IsNull() &&
            json.TryGetProperty(propertyName, out JsonElement element) &&
            !element.IsNull() &&
            element.TryGetInt32(out int value))
            return value;

        return defaultValue;
    }

    public static ulong GetULong(
        this string val,
        ulong defaultValue = default)
    {
        return string.IsNullOrWhiteSpace(val) ||
            !ulong.TryParse(val, out ulong result)
                ? defaultValue
                : result;
    }

    public static ulong GetUlong(
        this JsonElement json,
        string propertyName,
        ulong defaultValue = default)
    {
        if (!json.IsNull() &&
            json.TryGetProperty(propertyName, out JsonElement element) &&
            !element.IsNull() &&
            element.TryGetUInt64(out ulong value))
            return value;

        return defaultValue;
    }

    public static long GetLong(
        this JsonElement json,
        string propertyName,
        long defaultValue = default)
    {
        if (!json.IsNull() &&
            json.TryGetProperty(propertyName, out JsonElement element) &&
            !element.IsNull() &&
            element.TryGetInt64(out long value))
            return value;

        return defaultValue;
    }

    public static bool GetBool(
        this JsonElement json,
        string propertyName,
        bool defaultValue = default)
    {
        if (!json.IsNull() &&
            json.TryGetProperty(propertyName, out JsonElement element) &&
            !element.IsNull())
            return element.GetBoolean();

        return defaultValue;
    }

    public static double GetDouble(
        this string val,
        double defaultValue = default)
    {
        return string.IsNullOrWhiteSpace(val) ||
            !double.TryParse(val, out double result)
                ? defaultValue
                : result;
    }

    public static double GetDouble(
        this JsonElement json,
        string propertyName,
        double defaultValue = default)
    {
        if (!json.IsNull() &&
            json.TryGetProperty(propertyName, out JsonElement element) &&
            !element.IsNull() &&
            element.TryGetDouble(out double value))
            return value;

        return defaultValue;
    }

    public static decimal GetDecimal(
        this JsonElement json,
        string propertyName,
        decimal defaultValue = default)
    {
        if (!json.IsNull() &&
            json.TryGetProperty(propertyName, out JsonElement element) &&
            !element.IsNull() &&
            element.TryGetDecimal(out decimal value))
            return value;

        return defaultValue;
    }

    public static TEnum GetEnum<TEnum>
        (this JsonElement json,
        string propertyName,
        TEnum defaultValue = default)
        where TEnum: struct
    {
        if (!json.IsNull() &&
            json.TryGetProperty(propertyName, out JsonElement element) &&
            !element.IsNull())
        {
            string enumString = element.GetString();

            if (enumString != null &&
                Enum.TryParse(enumString, out TEnum num))
                return num;
        }

        return defaultValue;
    }
}

Main 方法展示了如何执行自定义反序列化:

static void Main()
{
    string poJson =
        new PurchaseOrderService()
            .Get(poID: 123);

    JsonElement elm = JsonDocument.Parse(poJson).RootElement;

    JsonElement additional = elm.GetProperty("additional_info");
    JsonElement items = elm.GetProperty("items");

    if (additional.IsNull() || items.IsNull())
        throw new ArgumentException("incomplete PO");

    var po = new PurchaseOrder
    {
        Address = elm.GetString("address", "none"),
        CompanyName = elm.GetString("company_name", string.Empty),
        Phone = elm.GetString("phone", string.Empty),
        Status = elm.GetEnum("status", PurchaseOrderStatus.Received),
        Terms = additional.GetString("terms", string.Empty),
        POC = additional.GetString("poc", string.Empty),
        AdditionalInfo =
            (from jElem in additional.EnumerateObject()
             select jElem)
            .ToDictionary(
                key => key.Name,
                val => val.Value.GetString()),
        Items =
            (from jElem in items.EnumerateArray()
             select new PurchaseItem
             {
                 Description = jElem.GetString("description"),
                 Price = jElem.GetDecimal("price"),
                 Quantity = jElem.GetDouble("quantity"),
                 SerialNumber = jElem.GetString("serial_number")
             })
            .ToList()
    };

    Console.WriteLine($"{po.CompanyName}");
    Console.WriteLine($"{po.Terms}");
    Console.WriteLine($"{po.AdditionalInfo["terms"]}");
    Console.WriteLine($"{po.Items[0].Description}");
}

讨论

虽然在序列化和反序列化中使用 JsonSerializer 是首选,但有时你不会得到 JSON 和 C# 对象之间干净的一对一结构匹配。例如,你可能需要从不同格式的不同源获取数据,并有一个单一的 C# 对象进行填充。其他时候,你可能有一个分层的 JSON 文档,并希望将其扁平化为你自己的对象。另一种常见情况是已经使用一个版本工作的对象,而 API 的新版本改变了结构。在某种程度上,这些都是同一个问题的多个视角,你可以通过自定义反序列化来解决。

System.Text.Json 命名空间中用于自定义反序列化的两种类型是 JsonDocumentJsonElementMain 方法展示了如何使用 JsonDocument 解析 JSON 输入,并通过 RootElement 属性获取 JsonElement。之后,我们只需处理 JsonElement 的成员。

JsonElement 有多个成员,包括 GetStringGetInt64,用于进行转换。仅仅依赖这些成员存在的问题是数据通常不干净。即使你拥有应用程序的生产者和消费者端,获得完全干净的数据可能也是难以实现的。为了解决这个问题,我创建了 JsonConversionExtensions 类。

在概念上,JsonConversionExtensions 包装了许多模板代码,你需要调用它们来确保你正在读取的数据是你所期望的。它还有一个可选的默认值概念。

解决第一个问题的技巧是,JsonElement中的null值不表示为nullIsNull方法检查ValueKind属性,检查UndefinedNull属性是否为 true。这是其他转换方法中使用的重要方法。

浏览其余的方法,你会看到一个熟悉的模式。每个方法都会检查元素的IsNull,然后使用一个或多个TryGetXxxIsNull的组合来获取值。这样做是安全的,在值为null或类型错误时避免异常。没错,一些 API 文档中的值是一个类型,运行时返回另一个类型,将数字设置为null,并省略属性。

每个方法都有一个默认参数。如果代码无法提取真实值,它将使用defaultValuedefaultValue参数是可选的,会回到返回类型的 C# default

Main方法展示了如何使用JsonElementJsonConversionExtensions类构造对象。你可以看到代码如何使用GetXxx方法填充每个属性。

几个有用的JsonElement方法是EnumerateObjectEnumerateArray。在前面的章节中,JsonSerializer将 JSON additional_info对象反序列化为 C#字典。这是处理具有可变信息对象的方法,你不知道对象属性是什么。在 API 返回单个错误 JSON 响应中,每个属性是一个错误的代码或描述。在PurchaseOrder示例中,这表示可以添加不适合预定义属性的杂项信息的地方。要手动读取这些属性,请使用EnumerateObject。它返回对象中的每个属性/值对。你可以看到 LINQ 语句如何通过从EnumerateObject返回的每个JsonProperty提取KeyValue来创建一个新字典。

EnumerateArray返回列表的每个元素。在解决方案中,我们将从EnumerateArray返回的每个JsonElement投影到一个新的PurchaseOrderItem实例中。

JsonConversionExtensions是不完整的,因为它不包括日期。由于DateTime处理是一个特例,我从这个示例中分离了它;你可以在 Recipe 7.10 中找到更多信息。

参见

Recipe 7.10,“灵活的 DateTime 读取”

7.7 消费 XML

问题

你需要将 XML 文档转换为对象。

解决方案

这是一个PurchaseOrder的样子:

public enum PurchaseOrderStatus
{
    Received,
    Processing,
    Fulfilled
}

public class PurchaseItem
{
    public string SerialNumber { get; set; }

    public string Description { get; set; }

    public float Quantity { get; set; }

    public decimal Price { get; set; }
}

public class PurchaseOrder
{
    public string CompanyName { get; set; }
    public string Address { get; set; }
    public string Phone { get; set; }

    public PurchaseOrderStatus Status { get; set; }

    public Dictionary<string, string> AdditionalInfo { get; set; }

    public List<PurchaseItem> Items { get; set; }
}

该方法模拟了返回 XML 数据的请求:

static string GetXml()
{
    return @"
<PurchaseOrder ">
 <Address>123 4th St.</Address>
 <CompanyName>Acme, Inc.</CompanyName>
 <Phone>555-835-7609</Phone>
 <Status>Received</Status>
 <AdditionalInfo>
 <Terms>Net 30</Terms>
 <POC>J. Smith</POC>
 </AdditionalInfo>
 <Items>
 <PurchaseItem SerialNumber=""123"">
 <Description>Widget</Description>
 <Price>13.95</Price>
 <Quantity>5</Quantity>
 </PurchaseItem>
 </Items>
</PurchaseOrder>";
}

Main方法展示了如何将 XML 反序列化为对象:

static void Main(string[] args)
{
    XNamespace or = "https://www.oreilly.com";

    XName address = or + nameof(PurchaseOrder.Address);
    XName company = or + nameof(PurchaseOrder.CompanyName);
    XName phone = or + nameof(PurchaseOrder.Phone);
    XName status = or + nameof(PurchaseOrder.Status);
    XName info = or + nameof(PurchaseOrder.AdditionalInfo);
    XName poItems = or + nameof(PurchaseOrder.Items);
    XName purchaseItem = or + nameof(PurchaseItem);
    XName description = or + nameof(PurchaseItem.Description);
    XName price = or + nameof(PurchaseItem.Price);
    XName quantity = or + nameof(PurchaseItem.Quantity);
    XName serialNum = nameof(PurchaseItem.SerialNumber);

    string poXml = GetXml();

    XElement poElmt = XElement.Parse(poXml);

    PurchaseOrder po =
        new PurchaseOrder
        {
            Address = (string)poElmt.Element(address),
            CompanyName = (string)poElmt.Element(company),
            Phone = (string)poElmt.Element(phone),
            Status =
                Enum.TryParse(
                    (string)poElmt.Element(nameof(po.Status)),
                    out PurchaseOrderStatus poStatus)
                ? poStatus
                : PurchaseOrderStatus.Received,
            AdditionalInfo =
                (from addInfo in poElmt.Element(info).Descendants()
                 select addInfo)
                .ToDictionary(
                    key => key.Name.LocalName,
                    val => val.Value),
            Items =
                (from item in poElmt
                                .Element(poItems)
                                .Descendants(purchaseItem)
                 select new PurchaseItem
                 {
                     Description = (string)item.Element(description),
                     Price =
                        decimal.TryParse(
                            (string)item.Element(price),
                            out decimal itemPrice)
                        ? itemPrice
                        : 0m,
                     Quantity =
                        float.TryParse(
                            (string)item.Element(quantity),
                            out float qty)
                        ? qty
                        : 0f,
                     SerialNumber = (string)item.Attribute(serialNum)
                 })
                .ToList()
        };

    Console.WriteLine($"{po.CompanyName}");
    Console.WriteLine($"{po.AdditionalInfo["Terms"]}");
    Console.WriteLine($"{po.Items[0].Description}");
    Console.WriteLine($"{po.Items[0].SerialNumber}");
}

讨论

在 JSON 成为主导数据格式之前,XML 无处不在。在处理配置文件、项目文件或可扩展应用标记语言(XAML)等方面,XML 仍然非常明显。还有相当数量的遗留代码,包括广泛使用 XML 的 Windows Communication Foundation(WCF)Web 服务。暂时而言,掌握如何处理 XML 是一项宝贵的技能,而 LINQ to XML 是一个优秀的工具。

解决方案展示了如何将 XML 反序列化为 PurchaseOrder 对象。Main 方法首先设置命名空间。XML 中的命名空间很常见,代码创建了一个命名空间标签 orXNamespace 类型有一个转换器,将字符串转换为命名空间。XNamespace 还重载了 + 运算符,允许您给元素打上特定的命名空间标签,创建一个新的 XName。代码为每个元素设置了一个 XName,以使 PurchaseOrder 的构造更易于阅读。

每个元素都有一个命名空间,serialNum 除外,它是一个属性。数据属性不需要用命名空间注释,因为它们位于包含元素的命名空间中。唯一的例外是,如果您想要向元素添加命名空间属性,将其放入新的命名空间中。

在获取 XML 后,Main 调用 XElement.Parse 获取一个新的 XElement 来处理。XElement 具有移动文档和读取所需内容所需的所有轴方法。此示例通过使用 AttributeElementDescendants 轴方法按层次移动文档来保持简单。

Element 方法帮助读取当前元素下的子元素。Descendants 方法深入一级,访问指定元素的子元素。从 GetXml 返回的 XML 中,PurchaseOrder 是根元素,由 poElmt 表示。查看 PurchaseOrderpoElmt.Element(address) 读取 PurchaseOrder 的子元素 Address。如您所知,address 是一个命名空间限定的 XName

填充 AdditionalInfoItems 属性展示了如何使用 Descendants。我们使用 Element 来读取子元素,使用 Descendants 来获取该元素的子元素列表。对于 AdditionalInfoDescendants 是可变元素和值,并且我们不传递 XName 参数。对于 Items,我们需要传递 purchaseItemXNameDescendants,以便对每个对象进行操作。

我们使用 Attribute 方法来填充每个 PurchaseOrderItemSerialNumber 属性。

此对象构造的有趣部分是在 TryParse 操作中声明 out 参数的能力。这使我们可以在对象构造时内联编码分配。在此 C# 特性之前,我们需要在对象构造外部声明变量,这在像解决方案中的 LINQ 投影中填充 Items 属性时并不自然。

参见

Recipe 7.8, “生成 XML”

7.8 XML 生成

问题

你需要将一个对象转换为 XML。

解决方案

这是一个PurchaseOrder的样子:

public enum PurchaseOrderStatus
{
    Received,
    Processing,
    Fulfilled
}

public class PurchaseItem
{
    public string SerialNumber { get; set; }

    public string Description { get; set; }

    public float Quantity { get; set; }

    public decimal Price { get; set; }
}

public class PurchaseOrder
{
    public string CompanyName { get; set; }
    public string Address { get; set; }
    public string Phone { get; set; }

    public PurchaseOrderStatus Status { get; set; }

    public Dictionary<string, string> AdditionalInfo { get; set; }

    public List<PurchaseItem> Items { get; set; }
}

这个方法模拟一个数据请求,返回一个PurchaseOrder

static PurchaseOrder GetPurchaseOrder()
{
    return new PurchaseOrder
    {
        CompanyName = "Acme, Inc.",
        Address = "123 4th St.",
        Phone = "555-835-7609",
        AdditionalInfo = new Dictionary<string, string>
        {
            { "Terms", "Net 30" },
            { "POC", "J. Smith" }
        },
        Items = new List<PurchaseItem>
        {
            new PurchaseItem
            {
                Description = "Widget",
                Price = 13.95m,
                Quantity = 5,
                SerialNumber = "123"
            }
        }
    };
}

Main方法展示了如何将PurchaseOrder实例序列化为 XML:

static void Main(string[] args)
{
    PurchaseOrder po = GetPurchaseOrder();

    XNamespace or = "https://www.oreilly.com";

    XElement poXml =
        new XElement(or + nameof(PurchaseOrder),
            new XElement(
                or + nameof(PurchaseOrder.Address),
                po.Address),
            new XElement(
                or + nameof(PurchaseOrder.CompanyName),
                po.CompanyName),
            new XElement(
                or + nameof(PurchaseOrder.Phone),
                po.Phone),
            new XElement(
                or + nameof(PurchaseOrder.Status),
                po.Status),
            new XElement(
                or + nameof(PurchaseOrder.AdditionalInfo),
                (from info in po.AdditionalInfo
                 select
                     new XElement(
                         or + info.Key,
                         info.Value))
                .ToList()),
            new XElement(
                or + nameof(PurchaseOrder.Items),
                (from item in po.Items
                 select new XElement(
                     or + nameof(PurchaseItem),
                     new XAttribute(
                         nameof(PurchaseItem.SerialNumber),
                         item.SerialNumber),
                     new XElement(
                         or + nameof(PurchaseItem.Description),
                         item.Description),
                     new XElement(
                         or + nameof(PurchaseItem.Price),
                         item.Price),
                     new XElement(
                         or + nameof(PurchaseItem.Quantity),
                         item.Quantity)))
                .ToList()));

    Console.WriteLine(poXml);
}

这里是输出结果:

<PurchaseOrder xmlns="https://www.oreilly.com">
  <Address>123 4th St.</Address>
  <CompanyName>Acme, Inc.</CompanyName>
  <Phone>555-835-7609</Phone>
  <Status>Received</Status>
  <AdditionalInfo>
    <Terms>Net 30</Terms>
    <POC>J. Smith</POC>
  </AdditionalInfo>
  <Items>
    <PurchaseItem SerialNumber="123">
      <Description>Widget</Description>
      <Price>13.95</Price>
      <Quantity>5</Quantity>
    </PurchaseItem>
  </Items>
</PurchaseOrder>

讨论

Recipe 7.7 将一个 XML 文档反序列化为一个PurchaseOrder对象。这一部分则相反—将PurchaseOrder序列化为 XML 文档。

我们从XNamespace开始,用作每个元素的XName参数,以保持所有元素在同一个命名空间中。

解决方案通过调用XElementXAttribute构建 XML 文档。我们唯一使用XAttribute的地方是每个PurchaseOrderItem元素的SerialNumber属性。

从视觉上看,你可以看到 LINQ 到 XML 查询子句的布局与其生成的 XML 输出具有相同的分层结构。解决方案使用了两个XElement构造函数重载。如果一个元素是一个底层节点,没有子元素,第二个参数是元素的值。然而,如果元素是一个父元素,有子元素,第二个参数是一个新的XElement

LINQ 语句用于AdditionalInfoItems,生成一个新的XElement

参见

Recipe 7.7, “消费 XML”

7.9 编码和解码 URL 参数

问题

你正在使用一个需要符合 RFC 3986 的 API 进行操作。

解决方案

这里是一个正确编码 URL 参数的类:

public class Url
{
    /// <summary>
    /// Implements Percent Encoding according to RFC 3986
    /// </summary>
    /// <param name="value">string to be encoded</param>
    /// <returns>Encoded string</returns>
    public static string PercentEncode(
        string? value, bool isParam = true)
    {
        const string IsParamReservedChars = @"`!@#$^&*+=,:;'?/|\[] ";
        const string NoParamReservedChars = @"`!@#$^&*()+=,:;'?/|\[] ";

        var result = new StringBuilder();

        if (string.IsNullOrWhiteSpace(value))
            return string.Empty;

        var escapedValue = EncodeDataString(value);

        var reservedChars =
            isParam ? IsParamReservedChars : NoParamReservedChars;

        foreach (char symbol in escapedValue)
        {
            if (reservedChars.IndexOf(symbol) != -1)
                result.Append(
                    '%' +
                    string.Format("{0:X2}", (int)symbol).ToUpper());
            else
                result.Append(symbol);
        }

        return result.ToString();
    }

    /// <summary>
    /// URL-encode a string of any length.
    /// </summary>
    static string EncodeDataString(string data)
    {
        // the max length in .NET 4.5+ is 65520
        const int maxLength = 65519;

        if (data.Length <= maxLength)
            return Uri.EscapeDataString(data);

        var totalChunks = data.Length / maxLength;

        var builder = new StringBuilder();
        for (var i = 0; i <= totalChunks; i++)
        {
            string? chunk =
                i < totalChunks ?
                    data[(maxLength * i)..maxLength] :
                    data[(maxLength * i)..];

            builder.Append(Uri.EscapeDataString(chunk));
        }
        return builder.ToString();
    }
}

此方法解析 URL,编码参数并重建 URL:

static string EscapeUrlParams(string originalUrl)
{
    const int Base = 0;
    const int Parms = 1;
    const int Key = 0;
    const int Val = 1;
    string[] parts = originalUrl.Split('?');
    string[] pairs = parts[Parms].Split('&');

    string escapedParms =
        string.Join('&',
            (from pair in pairs
             let keyVal = pair.Split('=')
             let encodedVal = Url.PercentEncode(keyVal[Val])
             select $"{keyVal[Key]}={encodedVal}")
            .ToList());

    return $"{parts[Base]}?{escapedParms}";
}

Main方法比较不同的编码方式:

static void Main()
{
    const string OriginalUrl =
        "https://myco.com/po/search?company=computers+";
    Console.WriteLine($"Original:    '{OriginalUrl}'");

    string escapedUri = Uri.EscapeUriString(OriginalUrl);
    Console.WriteLine($"Escape URI:  '{escapedUri}'");

    string escapedData = Uri.EscapeDataString(OriginalUrl);
    Console.WriteLine($"Escape Data: '{escapedData}'");

    string escapedUrl = EscapeUrlParams(OriginalUrl);
    Console.WriteLine($"Escaped URL: '{escapedUrl}'");
}

生成这个输出:

Original:    'https://myco.com/po/search?company=computers+'
Escape URI:  'https://myco.com/po/search?company=computers+'
Escape Data: 'https%3A%2F%2Fmyco.com%2Fpo%2Fsearch%3Fcompany
%3Dcomputers%2B'
Escaped URL: 'https://myco.com/po/search?company=computers%2B'

讨论

如果你同时构建网络通信的消费者和生产者部分,比如内部企业应用,编码的正确性可能并不重要,因为这两部分使用同一个库。然而,某些第三方 API 要求严格遵守 RFC 3986。你可能首先想到的是.NET 中的System.Uri类有EscapeUriStringEscapeDataString方法。不幸的是,这些方法并没有始终正确实现 RFC 3986。虽然.NET 5+跨平台并且看起来实现良好,但是.NET Framework 的早期版本针对不同技术并没有这样做。为了解决这个问题,我在解决方案中创建了Url类。

注意

RFC 3986 是定义互联网 URL 编码的标准。RFC 代表“请求评论”,标准通常以 RFC 后跟一些唯一编号。

PercentEncode 将值参数的每个字符替换为带有百分号(%)前缀的两位十六进制表示。第一个操作是调用 EscapeDataString。该方法调用 Uri.EscapeDataStringUri.EscapeDataString 的一个问题是长度限制,因此该方法会将输入分块以确保所有数据都被编码。方法的思路是让 Uri.EscapeDataString 处理大部分的转换工作,并让 PercentEncode 补充那些未被编码的字符。

PercentEncode 还有一个第二个参数 isParam,用于指示是否应编码括号。它默认为 true,用户可以将其设置为 false 以防止编码括号,这是 IsParamReservedCharsNoParamReservedChars 之间唯一的区别。如果该方法发现未编码的字符,它会手动进行编码。

我们只对查询字符串参数值进行编码,因为基本 URL、段和参数名称是不需要编码的值。EscapeUrlParameters 方法通过将 URL 与参数分离,并迭代每个参数来实现此目的。对于每个参数,它将参数名与其值分开,并对值调用 PercentEncode。在对值进行编码之后,代码重建并返回 URL。

Main 方法展示了不同类型的编码,阐明了为什么选择了自定义编码方法。请注意,Uri.EscapeUriString 没有对 + 符号进行编码。使用 Uri.EscapeDataString 对整个 URL 进行了编码,这并不是您想要的。将 URL 拆分并对每个值进行编码可以完美解决问题。

请记住,在 .NET 5+ 应用程序中可能会获得良好的结果。但是,如果在旧的 .NET Framework 版本中进行跨平台工作,Uri.EscapeUriStringUri.EscapeDataString 的结果可能不一致,很可能会导致错误。无论使用哪个框架/技术版本,仅对参数值进行编码的技术是一个常见的需求。

7.10 灵活的日期时间读取

问题

您需要解析可能以多种不同格式出现的 DateTime 值。

解决方案

这些扩展方法有助于解析日期:

public static class StringExtensions
{
    static readonly string[] dateFormats =
    {
        "ddd MMM dd HH:mm:ss %zzzz yyyy",
        "yyyy-MM-dd\\THH:mm:ss.000Z",
        "yyyy-MM-dd\\THH:mm:ss\\Z",
        "yyyy-MM-dd HH:mm:ss",
        "yyyy-MM-dd HH:mm"
    };

    public static DateTime GetDate(
        this string date,
        DateTime defaultValue)
    {
        return string.IsNullOrWhiteSpace(date) ||
            !DateTime.TryParseExact(
                    date,
                    dateFormats,
                    CultureInfo.InvariantCulture,
                    DateTimeStyles.AssumeUniversal |
                    DateTimeStyles.AdjustToUniversal,
                    out DateTime result)
                ? defaultValue
                : result;
    }

    public static DateTime GetDate(
        this JsonElement json,
        string propertyName,
        DateTime defaultValue = default)
    {
        string? date = json.GetString(propertyName);
        return date?.GetDate(defaultValue) ?? defaultValue;
    }

    public static string? GetString(
        this JsonElement json,
        string propertyName,
        string? defaultValue = default)
    {
        if (!json.IsNull() &&
            json.TryGetProperty(propertyName, out JsonElement element))
            return element.GetString() ?? defaultValue;

        return defaultValue;
    }

    public static bool IsNull(this JsonElement json)
    {
        return
            json.ValueKind == JsonValueKind.Undefined ||
            json.ValueKind == JsonValueKind.Null;
    }
}

Main 方法展示了如何提取和解析 JSON 文档中的日期:

static void Main()
{
    const string TweetID = "1305895383260782593";
    const string CreatedDate = "created_at";

    string tweetJson = GetTweet(TweetID);

    JsonElement tweetElem = JsonDocument.Parse(tweetJson).RootElement;

    DateTime created = tweetElem.GetDate(CreatedDate);

    Console.WriteLine($"Created Date: {created}");
}

static string GetTweet(string tweetID)
{
    return @"{
 ""text"": ""Thanks @github for approving sponsorship for
 LINQ to Twitter: https://t.co/jWeDEN07HN"",
 ""id"": ""1305895383260782593"",
 ""author_id"": ""15411837"",
 ""created_at"": ""2020-09-15T15:44:56.000Z""
 }";
}

讨论

在使用第三方 API 时,您可能会遇到数据表示的偶发不一致。一个问题区域是解析日期。不同的 API 使用不同的日期格式,甚至在同一个 API 中,也可能用不同的格式表示不同的日期属性。解决方案中的 StringExtensions 类帮助解决了这个问题。

注意

我从 7.6 节 中的 JsonConversionExtensions 中提取了 StringExtensions 成员。

解决方案包括一个dateFormats数组,其中包含日期格式字符串的实例。这些都是此代码可以容纳的所有可能日期格式。GetDate方法在调用TryParseExact时使用dateFormats。每当遇到新的日期格式(例如,如果 API 提供了新版本并更新了日期格式),请将其添加到dateFormats中。

最佳实践是将日期表示为 UTC 值,因此DateTimeStyles参数反映了这一假设。

函数GetDate有两个重载,取决于您需要传递string还是JsonElementJsonElement的重载使用GetString扩展方法,并将结果转发给另一个GetDate方法。

这些方法是安全的,因为您必须考虑糟糕的数据。它们检查null,使用TryParse,并在无法读取有效值时返回default值。如果未提供defaultValue,则返回类型的default是可选的。

参见

Recipe 7.6,“使用 JSON 数据”

第八章:模式匹配

历来,开发人员用各种逻辑检查和比较实现业务规则。有时这些规则很复杂,自然而然地导致代码难以编写、阅读和维护。想想你多少次遇到多分支逻辑、多变量比较和多层嵌套的情况。

为了帮助简化这种复杂性,现代编程语言已经开始引入模式匹配——这些语言的特性通过声明性语法帮助将事实与结果匹配。在 C# 中,模式匹配体现为一个不断增长的功能列表,特别是从 C# 7 开始。

本章的主题围绕酒店调度和使用模式进行业务规则。标准通常围绕客户类型如铜牌、银牌或金牌展开,金牌客户由于更频繁的酒店住宿而获得更多积分,是最高级别。

本章讨论了属性、元组和类型的模式匹配。还有一些关于逻辑操作的部分,它们支持和简化多条件模式。令人惊讶的是,C# 从 v1.0 开始就具有某种形式的模式匹配。本章的第一部分讨论了 isas 操作符,并展示了 is 操作符的新增强功能。

8.1 安全地转换实例

问题

您的遗留代码是弱类型的,依赖于过程式模式,并且需要重构。

解决方案

这里是一个接口及其实现类,用于生成我们正在寻找的结果:

public interface IRoomSchedule
{
    void ScheduleRoom();
}

public class GoldSchedule : IRoomSchedule
{
    public void ScheduleRoom() =>
        Console.WriteLine("Scheduling Gold Room");
}

public class SilverSchedule : IRoomSchedule
{
    public void ScheduleRoom() =>
        Console.WriteLine("Scheduling Silver Room");
}

public class BronzeSchedule : IRoomSchedule
{
    public void ScheduleRoom() =>
        Console.WriteLine("Scheduling Bronze Room");
}

这是一个代表返回的遗留非类型化实例数据的方法:

static ArrayList GetWeakTypedSchedules()
{
    var list = new ArrayList();

    list.Add(new BronzeSchedule());
    list.Add(new SilverSchedule());
    list.Add(new GoldSchedule());

    return list;
}

并且这段代码处理遗留集合:

static void ProcessLegacyCode()
{
    ArrayList schedules = GetWeakTypedSchedules();

    foreach (var schedule in schedules)
    {
        if (schedule is IRoomSchedule)
        {
            IRoomSchedule roomSchedule = (IRoomSchedule)schedule;
            roomSchedule.ScheduleRoom();
        }

        //
        // alternatively
        //

        IRoomSchedule asRoomSchedule = schedule as IRoomSchedule;

        if (asRoomSchedule != null)
            asRoomSchedule.ScheduleRoom();

        //
        // even better
        //

        if (schedule is IRoomSchedule isRoomSchedule)
            isRoomSchedule.ScheduleRoom();
    }
}

这里有更现代的代码,返回一个强类型集合:

static List<IRoomSchedule> GetStrongTypedSchedules()
{
    return new List<IRoomSchedule>
    {
        new BronzeSchedule(),
        new SilverSchedule(),
        new GoldSchedule()
    };
}

并且这段代码处理强类型集合:

static void ProcessModernCode()
{
    List<IRoomSchedule> schedules = GetStrongTypedSchedules();

    foreach (var schedule in schedules)
    {
        schedule.ScheduleRoom();

        if (schedule is GoldSchedule gold)
            Console.WriteLine(
                $"Extra processing for {gold.GetType()}");
    }
}

Main 方法调用旧版和现代版:

static void Main()
{
    ProcessLegacyCode();
    ProcessModernCode();
}

讨论

asis 操作符在 C# 1 中就已经出现了;您可能已经知道并/或者已经使用过它们。简单回顾一下,is 操作符告诉您一个对象的类型是否与正在匹配的类型相同。as 操作符将引用类型对象转换为指定类型。如果转换后的实例不是指定的类型,则 as 操作符返回 null。这个例子还展示了最近 C# 添加的功能,允许使用 is 操作符进行类型检查和转换。

我们今天大部分编写的代码使用泛型集合,越来越不需要使用弱类型集合。我敢说,你可能会采纳一个经验法则,以泛型集合作为默认选择,除非无法避免使用弱类型集合。必须使用弱类型集合的一个重要情况是维护已经使用它们的遗留代码。泛型直到 C# 2 才添加,因此你可能会遇到一些使用弱类型集合的旧代码。另一个例子是当你需要或希望使用使用弱类型集合的库时。实际上,你可能不想重写该代码,因为需要的时间和资源——特别是如果它已经经过测试且运行良好。

在解决方案中,GetWeakTypedSchedules 方法返回一个 ArrayList,这是一个弱类型集合,因为它仅对 Object 类型的实例进行操作。ProcessLegacyCode 方法调用 GetWeakTypedSchedules 并展示了如何使用 asis 运算符。

第一个 foreach 循环中的第一个 if 语句使用 is 运算符来确定对象是否为 IRoomSchedule。如果是,它使用转型运算符获取 IRoomSchedule 实例并调用 GetSchedule。如果我们已经知道集合包含 IRoomSchedule 实例,为什么还需要 is 运算符呢?为什么不直接进行转换?问题在于,并不保证集合中的类型是什么。如果开发人员意外加载了一个不是 IRoomSchedule 的对象进入集合怎么办?is 运算符提高了代码的可靠性。

is 运算符相对的是 as 运算符。在解决方案中,schedule as IRoomSchedule 执行转换。如果结果不是 null,则对象是 IRoomSchedule。这种方法可能性能更好,因为 is 操作既检查类型又需要转换,而 as 运算符只需要转换和 null 检查。

最后一个 if 语句演示了更新的 is 运算符语法。它既进行类型检查又进行转换,并将结果赋给 isRoomSchedule 变量。如果 schedule 不是 IRoomSchedule,则 isRoomSchedule 变量为 null,但由于 is 运算符返回了一个 bool 结果,我们不需要额外的 null 检查。

GetStrongTypedSchedulesProcessModernCode 展示了今天你可能想要编写的代码。请注意,由于强类型化,它具有更少的仪式感。每个类都实现了相同的接口,集合就是该接口,允许你编写有效地操作每个对象的代码。

这个例子还展示了新的is运算符在当前代码中可以很有用(不仅限于旧代码)。在ProcessModernCode中,即使所有对象都实现了IRoomScheduleis运算符也让我们能够检查GoldSchedule并进行一些额外处理。

8.2 捕获筛选后的异常

问题

你需要处理相同异常类型的不同条件逻辑。

解决方案

这是一个演示抛出异常的演示类:

public class Scheduler
{
    public void ScheduleRoom(string arg1, string arg2)
    {
        _ = arg1 ?? throw new ArgumentNullException(nameof(arg1));
        _ = arg2 ?? throw new ArgumentNullException(nameof(arg2));
    }
}

这个Main方法使用异常过滤器来进行清晰的处理:

static void Main()
{
    try
    {
        Console.Write("Choose (1) arg1 or (2) arg2? ");
        string arg = Console.ReadLine();

        var scheduler = new Scheduler();

        if (arg == "1")
            scheduler.ScheduleRoom(null, "arg2");
        else
            scheduler.ScheduleRoom("arg1", null);
    }
    catch (ArgumentNullException ex1)
        when (ex1.ParamName == "arg1")
    {
        Console.WriteLine("Invalid arg1");
    }
    catch (ArgumentNullException ex2)
        when (ex2.ParamName == "arg2")
    {
        Console.WriteLine("Invalid arg2");
    }
}

讨论

C#中一个有趣的补充,与模式匹配相关,是异常过滤器。如你所知,catch块根据抛出的异常类型进行操作。然而,当同一类型的异常由于不同原因可能被抛出时,有时能够区分每个原因的处理方式会很有用。虽然你可以在catch块中添加ifswitch语句,但过滤器提供了一种清晰分离和简化不同逻辑的方式。

在解决方案中,我们希望根据参数是null来过滤ArgumentNullExceptionScheduleRoom方法检查每个参数,如果任一参数为null,则抛出ArgumentNullException

Main方法在try/catch块中包装对ScheduleRoom的调用。这个例子有两个catch块,每个均为ArgumentNullException类型。两者之间的区别在于过滤器,由when子句指定。when子句的参数是一个布尔表达式。在解决方案中,表达式比较了ParamName与其处理的参数名。

8.3 简化switch分配

问题

你想根据某些条件返回一个值,但不想从每一个switch分支中返回。

解决方案

这里是一个接口及其实现类,是我们寻找的结果:

public interface IRoomSchedule
{
    void ScheduleRoom();
}

public class GoldSchedule : IRoomSchedule
{
    public void ScheduleRoom() =>
        Console.WriteLine("Scheduling Gold Room");
}

public class SilverSchedule : IRoomSchedule
{
    public void ScheduleRoom() =>
        Console.WriteLine("Scheduling Silver Room");
}

public class BronzeSchedule : IRoomSchedule
{
    public void ScheduleRoom() =>
        Console.WriteLine("Scheduling Bronze Room");
}

这个枚举将在即将进行的逻辑中使用:

public enum ScheduleType
{
    None,
    Bronze,
    Silver,
    Gold
}

这个类展示了switch语句和新的switch表达式:

public class Scheduler
{
    public IRoomSchedule CreateStatement(
        ScheduleType scheduleType)
    {
        switch (scheduleType)
        {
            case ScheduleType.Gold:
                return new GoldSchedule();
            case ScheduleType.Silver:
                return new SilverSchedule();
            case ScheduleType.Bronze:
            default:
                return new BronzeSchedule();
        }
    }

    public IRoomSchedule CreateExpression(
        ScheduleType scheduleType) =>
            scheduleType switch
            {
                ScheduleType.Gold => new GoldSchedule(),
                ScheduleType.Silver => new SilverSchedule(),
                ScheduleType.Bronze => new BronzeSchedule(),
                _ => new BronzeSchedule()
            };
}

Main方法测试代码:

static void Main()
{
    Console.Write(
        "Choose (1) Bronze, (2) Silver, or (3) Gold: ");
    string choice = Console.ReadLine();

    Enum.TryParse(choice, out ScheduleType scheduleType);

    var scheduler = new Scheduler();

    IRoomSchedule scheduleStatement =
        scheduler.CreateStatement(scheduleType);
    scheduleStatement.ScheduleRoom();

    IRoomSchedule scheduleExpression =
        scheduler.CreateExpression(scheduleType);
    scheduleExpression.ScheduleRoom();
}

讨论

switch语句从 C# 1 开始存在,而最近的增加是switch表达式。switch表达式的主要语法特性是一种简写表示法和将结果分配给变量的能力。如果你思考过你使用switch语句的所有情况,你可能会注意到产生值或新实例是一个常见主题。switch表达式简化了这个主题,并通过模式匹配进一步改进它。

解决方案有两个例子:一个是switch语句,一个是switch表达式。两者都依赖于ScheduleType枚举来进行条件判断,并根据此条件生成一个IRoomSchedule类型的结果。

CreateStatement方法使用了switch语句,其中有针对ScheduleType枚举的每个成员的case分支。注意它如何从方法中返回值,以及它需要普通的块体语法(带有花括号)。

CreateExpression方法使用了新的switch表达式。请注意,该方法可以是命令体(带箭头),返回表达式。与switch关键字后面的括号中的参数不同,参数位于switch关键字之前。此外,与case子句不同,案例模式匹配出现在箭头之前,箭头后的结果表达式。默认情况是丢弃模式_

每当参数与案例模式匹配时,switch表达式返回结果。在解决方案中,模式是ScheduleType枚举的值。switch表达式的结果是方法的结果,因为方法的命令语法指定了switch表达式。

如果您有一个用例,其中逻辑需要处理每个情况,但不需要返回值,那么经典的switch语句可能更合适。但是,如果您可以使用模式匹配并需要返回值,那么switch表达式可能是一个很好的选择。

8.4 切换属性值

问题

您需要基于强类型类属性的业务规则。

解决方案

这是一个具有我们需要评估的属性的类:

public class Room
{
    public int Number { get; set; }
    public string RoomType { get; set; }
    public string BedSize { get; set; }
}

这个枚举是评估的结果:

public enum ScheduleType
{
    None,
    Bronze,
    Silver,
    Gold
}

该方法获取我们需要的数据:

static List<Room> GetRooms()
{
    return new List<Room>
    {
        new Room
        {
            Number = 333,
            BedSize = "King",
            RoomType = "Suite"
        },
        new Room
        {
            Number = 222,
            BedSize = "King",
            RoomType = "Regular"
        },
        new Room
        {
            Number = 111,
            BedSize = "Queen",
            RoomType = "Regular"
        },
    };
}

该方法使用该数据并根据匹配模式返回枚举:

const int RoomNotAvailable = -1;

static int AssignRoom(ScheduleType scheduleType)
{
    foreach (var room in GetRooms())
    {
        ScheduleType roomType = room switch
        {
            { BedSize: "King", RoomType: "Suite" }
                => ScheduleType.Gold,
            { BedSize: "King", RoomType: "Regular" }
                => ScheduleType.Silver,
            { BedSize: "Queen", RoomType: "Regular" }
                => ScheduleType.Bronze,
            _ => ScheduleType.Bronze
        };

        if (roomType == scheduleType)
            return room.Number;
    }

    return RoomNotAvailable;
}

Main方法驱动程序:

static void Main()
{
    Console.Write(
        "Choose (1) Bronze, (2) Silver, or (3) Gold: ");
    string choice = Console.ReadLine();

    Enum.TryParse(choice, out ScheduleType scheduleType);

    int roomNumber = AssignRoom(scheduleType);

    if (roomNumber == RoomNotAvailable)
        Console.WriteLine("Room not available.");
    else
        Console.WriteLine($"The room number is {roomNumber}.");
}

讨论

以前,switch语句根据单个参数的值匹配情况。现在,您可以根据对象属性的值进行参数匹配。

解决方案使用Room类的实例作为AssignRoom方法中switch表达式的参数。模式是具有参数属性和匹配值的对象。返回的结果基于参数属性匹配哪种模式而确定。

该程序的目标是为客户找到一个可用的房间。AssignRoom的目的是返回与特定调度类型关联的第一个房间。这就是为什么AssignRoom比较roomTypescheduleType,如果它们匹配,则返回的原因。

属性模式匹配是一种很好的方法,因为它易于阅读。这可能会转化为更可维护的代码。一个权衡是,如果要匹配许多属性,则可能会很冗长。下一个配方提供了更短的语法。

参见

配方 8.5,“切换元组”

8.5 切换元组

问题

您需要基于业务规则,并且更喜欢更短的语法。

解决方案

这个类很有趣,因为它有一个析构函数:

public class Room
{
    public int Number { get; set; }
    public string RoomType { get; set; }
    public string BedSize { get; set; }

    public void Deconstruct(out string size, out string type)
    {
        size = BedSize;
        type = RoomType;
    }
}

这是程序将生成的枚举:

public enum ScheduleType
{
    None,
    Bronze,
    Silver,
    Gold
}

这是程序将使用的数据:

static List<Room> GetRooms()
{
    return new List<Room>
    {
        new Room
        {
            Number = 333,
            BedSize = "King",
            RoomType = "Suite"
        },
        new Room
        {
            Number = 222,
            BedSize = "King",
            RoomType = "Regular"
        },
        new Room
        {
            Number = 111,
            BedSize = "Queen",
            RoomType = "Regular"
        },
    };
 }

并且这个方法使用从类析构函数返回的元组来确定要返回的枚举:

static int AssignRoom(ScheduleType scheduleType)
{
    foreach (var room in GetRooms())
    {
        ScheduleType roomType = room switch
        {
            ("King", "Suite") => ScheduleType.Gold,
            ("King", "Regular") => ScheduleType.Silver,
            ("Queen", "Regular") => ScheduleType.Bronze,
            _ => ScheduleType.Bronze
        };

        if (roomType == scheduleType)
            return room.Number;
    }

    return RoomNotAvailable;
}

Main方法驱动程序:

static void Main()
{
    Console.Write(
        "Choose (1) Bronze, (2) Silver, or (3) Gold: ");
    string choice = Console.ReadLine();

    Enum.TryParse(choice, out ScheduleType scheduleType);

    int roomNumber = AssignRoom(scheduleType);

    if (roomNumber == RoomNotAvailable)
        Console.WriteLine("Room not available.");
    else
        Console.WriteLine($"The room number is {roomNumber}.");
}

讨论

在整本书中,你已经看到了元组在管理一组值时有多么有用,而无需所有自定义类型的繁文缛节。元组的快速语法使它们成为简单模式匹配的理想选择。

在这个例子中,我们有一个自定义类型Room。注意Room有一个自定义的解构器,在这个解决方案中我们将使用它。GetRooms方法返回一个List<Room>AssignRooms使用了那个集合。然而,由于解构器的存在,我们可以将每个房间用作switch表达式参数,它足够聪明以使用解构器生成用于模式匹配的元组。

除了通过解构器使用元组外,这个演示与 Recipe 8.4 相同。在这个例子中,元组提供了更简洁的语法。属性模式更冗长但更易读。一个考虑因素是,如果你匹配标量值,比如boolint,属性模式更好地记录了文档。如果你匹配字符串或枚举,元组可能在可读性和更短语法方面提供了最佳选择。因为两种方法之间的选择是情境性的,最好在每种情况下评估权衡,看看哪种对你更有意义。

参见

Recipe 8.4,“基于属性值切换”

8.6 位置切换

问题

你需要基于值的业务规则,但不想创建一个新的一次性类。

解决方案

这个枚举是我们将要寻找的结果:

public enum ScheduleType
{
    None,
    Bronze,
    Silver,
    Gold
}

这是一个用于指定决策标准的类:

public class Room
{
    public int Number { get; set; }
    public string RoomType { get; set; }
    public string BedSize { get; set; }
}

这些方法模拟了从两个源获取数据的情况:

static List<Room> GetHotel1Rooms()
{
    return new List<Room>
    {
        new Room
        {
            Number = 333,
            BedSize = "King",
            RoomType = "Suite"
        },
        new Room
        {
            Number = 111,
            BedSize = "Queen",
            RoomType = "Regular"
        },
    };
}

static List<Room> GetHotel2Rooms()
{
    return new List<Room>
    {
        new Room
        {
            Number = 222,
            BedSize = "King",
            RoomType = "Regular"
        },
    };
}

这个方法将这些数据源连接起来,生成一个元组列表:

static
    List<(int no, string size, string type)>
    GetRooms()
{
    var rooms = GetHotel1Rooms().Union(GetHotel2Rooms());
    return
        (from room in rooms
         select (
            room.Number,
            room.BedSize,
            room.RoomType
         ))
        .ToList();
}

这个方法展示了基于位置模式匹配的业务逻辑:

static int AssignRoom(ScheduleType scheduleType)
{
    foreach (var room in GetRooms())
    {
        ScheduleType roomType = room switch
        {
            (_, "King", "Suite") => ScheduleType.Gold,
            (_, "King", "Regular") => ScheduleType.Silver,
            (_, "Queen", "Regular") => ScheduleType.Bronze,
            _ => ScheduleType.Bronze
        };

        if (roomType == scheduleType)
            return room.no;
    }

    return RoomNotAvailable;
}

Main方法驱动这个过程:

static void Main()
{
    Console.Write(
        "Choose (1) Bronze, (2) Silver, or (3) Gold: ");
    string choice = Console.ReadLine();

    Enum.TryParse(choice, out ScheduleType scheduleType);

    int roomNumber = AssignRoom(scheduleType);

    if (roomNumber == RoomNotAvailable)
        Console.WriteLine("Room not available.");
    else
        Console.WriteLine($"The room number is {roomNumber}.");
}

讨论

这里的解决方案类似于 Recipe 8.5,因为它也使用元组进行模式匹配。这个解决方案的不同之处在于它探讨了当你有两个不同数据源的情况,分别在GetHotel1RoomsGetHotel2Rooms中展示,模拟了通常会是数据库查询的情况。这种情况可能发生在公司合并或形成合作伙伴关系时,它们的数据相似但并非完全相同。

GetRooms方法展示了如何使用 LINQ 的Union运算符来合并这两个列表。方法构建了一个元组集合,而不是为我们需要的值组合创建一个新类型。

AssignRooms调用GetRooms时,你不需要在对象上使用解构器,因为你已经在使用元组。如果你正在使用第三方类型而无法修改其成员,这是一种有用的技术。

AssignRoom内部,switch表达式使用元组进行匹配。在这里立即引人注目的是第一个参数,表示Room.Number属性 - 每个模式都有一个丢弃符号。显然,这在GetRooms中可以被省略,但我以这种方式编写是为了阐明几个观点:值的位置必须匹配,并且每个值都是必需的。

元组模式要求值位于正确的位置(例如,不能交换NumberSize)。每个模式值的位置必须与相应元组位置匹配。相比之下,属性模式可以任意顺序且在不同情况下不同。

对于元组,您必须包括元组每个位置的值。因此,即使在模式中不使用位置,也必须至少指定丢弃参数。属性模式没有此限制,允许您添加或忽略模式中想要的任何属性。

参见

8.5 "元组切换"的食谱

8.7 值范围切换

问题

您的业务规则是连续的,而不是离散的。

解决方案

这是一个接口,以及实现类,我们正在寻找的结果:

public interface IRoomSchedule
{
    void ScheduleRoom();
}

public class GoldSchedule : IRoomSchedule
{
    public void ScheduleRoom() =>
        Console.WriteLine("Scheduling Gold Room");
}

public class SilverSchedule : IRoomSchedule
{
    public void ScheduleRoom() =>
        Console.WriteLine("Scheduling Silver Room");
}

public class BronzeSchedule : IRoomSchedule
{
    public void ScheduleRoom() =>
        Console.WriteLine("Scheduling Bronze Room");
}

此方法使用关系模式匹配来生成结果:

const int SilverPoints = 5000;
const int GoldPoints = 20000;

static IRoomSchedule GetSchedule(int points) =>
    points switch
    {
        >= GoldPoints => new GoldSchedule(),
        >= SilverPoints => new SilverSchedule(),
        < SilverPoints => new BronzeSchedule()
    };

Main方法驱动整个过程:

static void Main()
{
    Console.Write("How many points? ");
    string response = Console.ReadLine();

    if (!int.TryParse(response, out int points))
    {
        Console.WriteLine($"'{response}' is invalid!");
        return;
    }

    IRoomSchedule schedule = GetSchedule(points);

    schedule.ScheduleRoom();
}

讨论

本章的前几节探讨了基于离散值的模式匹配。模式必须精确匹配才能成功。但是,有很多情况下,值是连续的,而不是离散的。本节中的解决方案就是一个例子,酒店客户的积分范围可能各不相同。

在解决方案中,积分从 0 到 4,999 的客户为青铜。积分从 5,000 到 19,999 的客户为银。积分达到或超过 20,000 的客户为金。解决方案中的SilverPointsGoldPoints常量定义了边界。

Main方法询问客户的积分数,并将该值传递给GetSchedule。这个值可能会变化,这取决于一个人预订房间或使用其他酒店服务的次数。因此,GetSchedule根据这些积分使用switch表达式。而不是使用离散模式进行匹配,GetSchedule使用关系运算符。

第一个模式询问points是否等于或高于GoldPoints。如果不是,points必须更少,并且代码检查是否等于或高于SilverPoints。由于我们已经评估了GoldPoints情况,所以这意味着范围在SilverPointsGoldPoints之间。最后一个情况,低于SilverPoints,说明了青铜的含义,但您可以很容易地用丢弃模式替换它,因为其他两种情况处理了所有其他可能性,而青铜是唯一剩下的。

8.8 复杂条件切换

问题

您的业务规则是多条件的。

解决方案

这是一个接口,以及实现类,我们正在寻找的结果:

public interface IRoomSchedule
{
    void ScheduleRoom();
}

public class GoldSchedule : IRoomSchedule
{
    public void ScheduleRoom() =>
        Console.WriteLine("Scheduling Gold Room");
}

public class SilverSchedule : IRoomSchedule
{
    public void ScheduleRoom() =>
        Console.WriteLine("Scheduling Silver Room");
}

public class BronzeSchedule : IRoomSchedule
{
    public void ScheduleRoom() =>
        Console.WriteLine("Scheduling Bronze Room");
}

本课程描述了使用的标准:

public class Customer
{
    public int Points { get; set; }

    public bool HasFreeUpgrade { get; set; }
}

此方法生成具有各种值的模拟数据,以演示我们的逻辑:

static List<Customer> GetCustomers() =>
    new List<Customer>
    {
        new Customer
        {
            Points = 25000,
            HasFreeUpgrade = false
        },
        new Customer
        {
            Points = 10000,
            HasFreeUpgrade = true
        },
        new Customer
        {
            Points = 1000,
            HasFreeUpgrade = true
        },
    };

这是一个使用switch表达式中复杂逻辑的方法:

static IRoomSchedule GetSchedule(Customer customer) =>
    customer switch
    {
        Customer c
            when
                c.Points >= GoldPoints
                    ||
                (c.Points >= SilverPoints && c.HasFreeUpgrade)
            => new GoldSchedule(),

        Customer c
            when
                c.Points >= SilverPoints
                    ||
                (c.Points < SilverPoints && c.HasFreeUpgrade)
            => new SilverSchedule(),

        Customer c
            when
                c.Points < SilverPoints
            => new BronzeSchedule(),

        _ => new BronzeSchedule()
    };

Main方法遍历结果:

static void Main()
{
    foreach (var customer in GetCustomers())
    {
        IRoomSchedule schedule = GetSchedule(customer);
        schedule.ScheduleRoom();
    }
}

讨论

有时候条件非常复杂,无法通过本章前面展示的技术解决问题。例如,本节中的解决方案需要涉及多个属性的多个条件。在这里,我们使用switch表达式和when子句来指定匹配。

此场景基于Customer类型,指示点数数量以及客户是否有免费升级。免费升级可能来自于比赛或酒店促销活动。在安排房间时,我们希望确保客户获得与其点数水平相称的房间。此外,如果他们有免费升级选项,则会获得升级到下一个更高级别的房间。为简便起见,我们方便地忽略了金牌是否有免费升级。

GetSchedule方法操作Customer的一个实例。金牌和银牌的情况都会导致相应级别的房间。此外,||运算符表示,如果customer处于下一个较低级别,但HasFreeUpgradetrue,则结果是此较高级别的房间。

使用这样的逻辑会很快变得复杂。请注意使用换行和其他间距来增加结果的对称性和一致性以方便阅读。

当逻辑比离散模式匹配复杂时,这种技术可以帮助您。您可能希望考虑使用if语句的阈值作为更好的实现。一个考虑因素是维护,因为将每个逻辑片段分解出来有助于调试,而单个表达式具有多个条件可能不会立即明显。

8.9 使用逻辑条件

问题

您希望多条件逻辑更易读。

解决方案

这是一个作为标准使用的类:

public class Customer
{
    public int Points { get; set; }

    public int Month { get; set; }
}

此方法模拟数据源:

static List<Customer> GetCustomers() =>
    new List<Customer>
    {
        new Customer
        {
            Points = 25000,
            Month = 1
        },
        new Customer
        {
            Points = 10000,
            Month = 12
        },
        new Customer
        {
            Points = 10000,
            Month = 11
        },
        new Customer
        {
            Points = 1000,
            Month = 2
        },
    };

此方法在switch表达式中实现了业务规则和条件逻辑:

const int SilverPoints = 5000;
const int GoldPoints = 20000;

const int May = 5;
const int Sep = 9;
const int Dec = 12;

static decimal GetDiscount(Customer customer) =>
    (customer.Points, customer.Month) switch
    {
        (>= GoldPoints, not Dec and > Sep or < May) => 0.15m,
        (>= GoldPoints, Dec) => 0.10m,
        (>= SilverPoints, not (Dec or <= Sep and >= May)) => 0.05m,
        _ => 0.0m
    };

Main方法驱动此过程:

static void Main()
{
    foreach (var customer in GetCustomers())
    {
        decimal discount = GetDiscount(customer);
        Console.WriteLine(discount);
    }
}

讨论

Recipe 8.8 描述了如何向switch表达式添加复杂逻辑。在这里,我指的是涉及两个或更多属性的多个条件。这与本章先前使用的属性和元组模式进行简单模式匹配形成对比。在这些对比方法之间的某处,是一个需要逻辑隔离在各个属性内的适度方法。

此解决方案中感兴趣的属性是 Customer 类的 PointsMonth。与前几节类似,Points 属性有助于为至少具有一定数量点数的客户预订房间。另一个条件 Month 是客户想要预订房间的月份。由于季节性供需,一些月份会留给酒店更多的空房。因此,此应用程序根据积分提供激励,鼓励客户在空房较多的月份预订房间。

在解决方案中,你可以看到 GoldPointsSilverPoints 常量,用于确定客户的等级。同时,还有 MaySepDec 这些繁忙月份的常量。逻辑是在非繁忙月份给予折扣。

GetDiscount 方法的 switch 表达式的模式匹配基于两个属性:PointsMonth。请注意,这段代码不依赖于对象解构,而原始参数是一个类,而不是元组。GetDiscountswitch 表达式创建了一个内联元组。

模式本身依赖于 Points 的关系运算符,就像 Recipe 8.7 中一样。

Month 模式使用了新的 C# 9 逻辑运算符:andnotor。第一个表达式确保客户在冬季月份(SepMay 之间,除了 Dec)期间享受折扣。第二个模式表示金牌客户在 Dec 仍然享受折扣,但是折扣从 15% 变为 10%。

最后一个模式在逻辑上等同于第一个模式,并使用了德摩根定理。也就是说,它否定了整个结果,并交换了 andor。因为最后一个示例将 not 应用于整个表达式,所以它使用了括号。而在第一个模式中,not 仅应用于 Dec

参见

Recipe 8.7,“在值范围上进行切换”

Recipe 8.8,“使用复杂条件进行切换”

8.10 类型切换

问题

你需要对象的类型来做出决定。

解决方案

这里有一个接口,以及实现了我们需要的结果的类:

public interface IRoomSchedule
{
    void ScheduleRoom();
}

public class GoldSchedule : IRoomSchedule
{
    public void ScheduleRoom() =>
        Console.WriteLine("Scheduling Gold Room");
}

public class SilverSchedule : IRoomSchedule
{
    public void ScheduleRoom() =>
        Console.WriteLine("Scheduling Silver Room");
}

public class BronzeSchedule : IRoomSchedule
{
    public void ScheduleRoom() =>
        Console.WriteLine("Scheduling Bronze Room");
}

以下类型代表条件:

public class Customer {}

public class GoldCustomer : Customer {}

public class SilverCustomer : Customer {}

public class BronzeCustomer : Customer {}

此方法模拟了一个数据源:

static List<Customer> GetCustomers() =>
    new List<Customer>
    {
        new GoldCustomer(),
        new SilverCustomer(),
        new BronzeCustomer()
    };

下面是一个根据类型模式匹配实现逻辑的方法:

static IRoomSchedule GetSchedule(Customer customer) =>
    customer switch
    {
        GoldCustomer => new GoldSchedule(),
        SilverCustomer => new SilverSchedule(),
        BronzeCustomer => new BronzeSchedule(),
        _ => new BronzeSchedule()
    };

Main 方法通过数据迭代来执行模式匹配逻辑:

static void Main()
{
    foreach (var customer in GetCustomers())
    {
        IRoomSchedule schedule = GetSchedule(customer);
        schedule.ScheduleRoom();
    }
}

讨论

过去,唯一能够根据类型做出决策的方式是使用 if 语句或将对象类型转换为 string,并使用带有 string 情况的 switch 语句。多年来对 C# 的一个常见请求是允许使用类型 case 的 switch 语句,现在我们终于有了。

解决方案包含一组类:GoldCustomerSilverCustomerBronzeCustomer,它们都是从 Customer 派生的。我们在这个程序中的目标是根据匹配的类类型安排一个房间。

GetSchedule 方法通过接受一个类型为 Customer 的对象来进行调度,而 switch 表达式针对从 Customer 派生的每个类都有一个模式。你只需要指定每个类的名称,switch 表达式会根据对象类型进行匹配。