C--秘籍-二-

56 阅读38分钟

C# 秘籍(二)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

第三章:确保质量

所有的最佳实践、复杂算法和模式,在代码工作正常的情况下毫无意义。我们都希望构建尽可能最好的应用程序并最小化错误。本章的主题围绕可维护性、错误预防和编写正确的代码。

在团队合作时,其他开发人员必须与您编写的代码一起工作。他们会添加新功能并修复错误。如果您编写的代码易于阅读,那么它将更易于维护,即其他开发人员将能够阅读和理解它。即使您是唯一的开发人员,回顾过去编写的代码也可能是一种新体验。增强的可维护性导致引入较少的新错误,并且任务周转更快。较少的错误意味着较少的软件生命周期成本,为其他增值功能提供更多时间。正是这种可维护性的精神激励了本章内容。

与可维护性类似,错误预防是一个重要的质量概念。用户可以并且将使用应用程序发现我们从未想过会发生的一个错误。章节 3.1 和 3.4 提供了帮助的关键工具。正确的异常处理是一项重要的技能,您也将学到这一点。

质量的另一个特征是确保代码正确,单元测试是一种重要的实践。虽然单元测试已经存在很长时间,但它并不是一个解决的问题。许多开发人员仍然不写单元测试。然而,这是一个如此重要的主题,本章的第一部分将向您展示如何编写单元测试。

3.1 编写单元测试

问题

质量保证专业人员在集成测试期间持续发现问题,您希望减少检查到的错误数量。

解决方案

这是测试的代码:

public enum CustomerType
{
    Bronze,
    Silver,
    Gold
}

public class Order
{
    public decimal CalculateDiscount(
        CustomerType custType, decimal amount)
    {
        decimal discount;

        switch (custType)
        {
            case CustomerType.Silver:
                discount = amount * 1.05m;
                break;
            case CustomerType.Gold:
                discount = amount * 1.10m;
                break;
            case CustomerType.Bronze:
            default:
                discount = amount;
                break;
        }

        return discount;
    }
}

单独的测试项目包含单元测试:

public class OrderTests
{
 [Fact]
    public void
    CalculateDiscount_WithBronzeCustomer_GivesNoDiscount()
    {
        const decimal ExpectedDiscount = 5.00m;

        decimal actualDiscount =
            new Order().CalculateDiscount(CustomerType.Bronze, 5.00m);

        Assert.Equal(ExpectedDiscount, actualDiscount);
    }

 [Fact]
    public void
    CalculateDiscount_WithSilverCustomer_GivesFivePercentDiscount()
    {
        const decimal ExpectedDiscount = 5.25m;

        decimal actualDiscount =
            new Order().CalculateDiscount(CustomerType.Silver, 5.00m);

        Assert.Equal(ExpectedDiscount, actualDiscount);
    }

 [Fact]
    public void
    CalculateDiscount_WithGoldCustomer_GivesTenPercentDiscount()
    {
        const decimal ExpectedDiscount = 5.50m;

        decimal actualDiscount =
            new Order().CalculateDiscount(CustomerType.Gold, 5.00m);

        Assert.Equal(ExpectedDiscount, actualDiscount);
    }
}

讨论

要测试的代码是系统的被测代码(SUT),测试它的代码称为单元测试。单元测试通常在一个独立的项目中,引用 SUT,通过不将测试代码与生产代码一起发布来避免膨胀可交付组件的大小。要测试的单元通常是类、记录或结构类型。解决方案中有一个Order类(SUT),其中有一个CalculateDiscount方法。单元测试确保CalculateDiscount能够正确操作。

有几个众所周知的单元测试框架,您可以尝试几个,并选择最喜欢的一个使用。这些示例使用了 XUnit。大多数单元测试框架与 Visual Studio 和其他 IDE 集成。

单元测试框架帮助使用属性识别单元测试代码。一些框架为测试类添加了一个属性,但 XUnit 没有。对于 XUnit,您只需要为单元测试添加一个[Fact]属性,它将与您正在使用的 IDE 或其他工具配合使用。XUnit 的作者希望减少过度使用属性,并使 F#(以及其他.NET 语言)更容易使用该框架。

注意

单元测试框架使用属性来识别测试是很有趣的。它们使用了一个名为reflection的.NET 特性。Recipe 5.1 展示了如何使用 reflection 在代码中处理属性,以便您可以构建自己的工具。

单元测试的命名约定指示了它们的目的,使其易于阅读。OrderTests类指示其单元测试操作Order类。单元测试方法的命名模式如下:

    <MethodToTest>_<Condition>_<ExpectedOutcome>

第一个单元测试,CalculateDiscount_WithBronzeCustomer_GivesNoDiscount,遵循以下模式:

  • CalculateDiscount是要测试的方法。

  • WithBronzeCustomer指定了这个特定测试的输入中的独特之处。

  • GivesNoDiscount是要验证的结果。

单元测试的组织使用了一种称为安排、执行和断言(AAA)的格式。以下讨论涵盖了测试格式的每个部分。

安排部分创建了测试发生所需的所有类型。在这些单元测试中,安排创建了一个const ExpectedDiscount。在更复杂的情况下,安排部分将实例化输入参数,以建立适当的测试条件。在这个例子中,条件非常简单,它们被写成了执行部分的常量参数。

执行部分是一个方法调用,如果有的话,会传入参数,创建要测试的条件。在这些示例中,执行部分实例化了一个Order实例,并调用CalculateDiscount,传入适当的参数值,将响应分配给actualDiscount

Assert类属于 XUnit 测试框架。恰如其名,Assert语句用于测试的断言部分。请注意我为actualDiscountExpectedDiscount使用的命名约定。Assert类有几种方法,其中Equal非常受欢迎,因为它允许您比较您在执行部分期望的结果和实际收到的结果。

您从单元测试中可能获得的好处包括更好的代码设计,验证代码是否符合预期,防止回归,部署验证和文档编制。关键词在于可能,因为不同的人和/或团队选择他们想要从单元测试中获得的好处。

更好的代码设计来自于在编写代码之前编写测试。你可能听说过这种技术在敏捷或行为驱动开发(BDD)环境中被讨论过。通过让开发者提前考虑预期行为,可能会产生更清晰的设计。另一方面,你可能希望在编写代码之后编写单元测试。开发者们以两种方式编写代码和单元测试,对于哪种方式更可取存在不同意见。但无论如何,拥有测试,比起没有测试,更有可能提高代码质量。

第二点验证代码是否达到预期目的是最大的好处。对于像服务于代码文档的简单方法来说,这并不是什么大问题。然而,对于复杂的算法或像确保客户获得正确折扣这样关键的任务来说,单元测试确实发挥了重要作用。

另一个重要的好处是防止回归。当代码发生变化时,你或其他开发者可能会意外改变代码的原始意图,引入错误。通过在修改代码后运行单元测试,可以在源头找到并修复错误,而不是由质量保证专业人员或(更糟糕的是)客户在后期发现。

随着现代化的 DevOps,我们有能力通过持续部署来自动化构建。你可以将单元测试运行添加到 DevOps 管道中,这样可以在与其余代码合并之前捕获错误。拥有更多的单元测试可以通过这种技术减少开发者破坏构建的可能性。

最后,你还有另一层文档。这就是为什么单元测试的命名约定如此重要。如果另一个不熟悉应用程序的开发者需要理解代码,单元测试可以解释代码应该具有的正确行为。

如果你还没有使用单元测试,本讨论将帮助你入门。你可以通过搜索 XUnit 和其他单元测试框架来了解它们的工作原理。如果你还没有这样做,请查看食谱 1.2,其中描述了使代码更具可测试性的技术。

参见

食谱 1.2,“移除显式依赖项”

食谱 5.1,“使用反射读取属性”

3.2 版本化接口的安全性

问题

你需要在一个库中安全地更新一个接口,而不会破坏已部署的代码。

解决方案

更新前的接口:

public interface IOrder
{
    string PrintOrder();
}

更新后的接口:

public interface IOrder
{
    string PrintOrder();

    decimal GetRewards() => 0.00m;
}

CompanyOrder 更新前:

public class CompanyOrder : IOrder
{
    public string PrintOrder()
    {
        return "Company Order Details";
    }
}

CompanyOrder 更新后:

public class CompanyOrder : IOrder
{
    decimal total = 25.00m;

    public string PrintOrder()
    {
        return "Company Order Details";
    }

    public decimal GetRewards()
    {
        return total * 0.01m;
    }
}

CustomerOrder 更新前后:

class CustomerOrder : IOrder
{
    public string PrintOrder()
    {
        return "Customer Order Details";
    }
}

这是类型的使用方式:

class Program
{
    static void Main()
    {
        var orders = new List<IOrder>
        {
            new CustomerOrder(),
            new CompanyOrder()
        };

        foreach (var order in orders)
        {
            Console.WriteLine(order.PrintOrder());
            Console.WriteLine($"Rewards: {order.GetRewards()}");
        }
    }
}

讨论

在 C# 8 之前,我们无法向现有接口添加新成员,而不改变实现该接口的所有类型。如果这些实现类型位于同一代码库中,这是可以修复的更改。然而,对于框架库,开发人员依赖于接口与该库进行交互,这将是一个破坏性变更。

解决方案描述了如何更新接口以及其影响。这个场景适用于可能希望将之前赚取的一些奖励点数应用到当前订单的客户。

查看IOrder,您可以看到更新后版本添加了GetRewards方法。从历史上看,接口是不允许有实现的。然而,在新版本的IOrder中,GetRewards方法有一个默认实现,返回$0.00作为奖励。

解决方案还介绍了CompanyOrder类的前后版本,其中后版本包含了GetRewards的实现。现在,任何通过CompanyOrder实例调用GetRewards的代码将执行CompanyOrder的实现,而不是默认的实现。

相比之下,解决方案展示了一个同样实现了IOrderCustomerOrder类。这里的区别在于CustomerOrder没有改变。任何通过CompanyOrder实例调用GetRewards的代码将执行默认的IOrder实现。

Program Main方法展示了这是如何工作的。orders是一个IOrder列表,包含CustomerOrderCompanyOrder的运行时实例。foreach循环遍历orders,调用IOrder的方法。如前所述,对于CompanyOrder实例调用GetRewards会使用该类的实现,而CustomerOrder则使用默认的IOrder实现。

本质上,这个变化意味着如果开发人员在自己的类中实现IOrder,比如CustomerOrder,他们的代码在更新到最新版本时不会中断。

3.3 简化参数验证

问题

你总是在寻找简化代码的方法,包括参数验证。

解决方案

冗长的参数验证语法:

static void ProcessOrderOld(string customer, List<string> lineItems)
{
    if (customer == null)
    {
        throw new ArgumentNullException(
            nameof(customer), $"{nameof(customer)} is required.");
    }

    if (lineItems == null)
    {
        throw new ArgumentNullException(
            nameof(lineItems), $"{nameof(lineItems)} is required.");
    }

    Console.WriteLine($"Processed {customer}");
}

简洁的参数验证语法:

static void ProcessOrderNew(string customer, List<string> lineItems)
{
    _ = customer ?? throw new ArgumentNullException(
        nameof(customer), $"{nameof(customer)} is required.");
    _ = lineItems ?? throw new ArgumentNullException(
        nameof(lineItems), $"{nameof(lineItems)} is required.");

    Console.WriteLine($"Processed {customer}");
}

讨论

公共方法的第一行代码通常涉及参数验证,有时可能会很冗长。此部分展示了如何节省几行代码,以免混淆原方法的目的代码。

解决方案有两种参数验证技术:冗长和简洁。冗长的方法是典型的,代码确保参数不为空,并在其他情况下抛出异常。在这种单行抛出语句中,括号并不是必需的,但是如果编码标准要求括号出现,某些开发人员/团队可能仍然会喜欢它们,以避免未来维护错误,特别是对于应该在if块中的语句。

简短的方法是可以节省几行代码的替代方法。它依赖于 C#的新功能:变量丢弃_和合并运算符??

注意

使用合并运算符和丢弃进行简化的参数验证适合单行。然而,为了书本格式,需要使用两行。

在验证customer的行上,代码以丢弃的赋值开头,因为我们需要一个表达式。合并运算符是一个检测表达式为null时执行下一条语句的保护。

提示

此示例是用于参数评估。但是,在代码遇到设置为null的变量并需要抛出无效条件或本不应发生的情况时,还有其他场景。此技术让您可以快速处理单行代码。

参见

第 3.4 节,“保护代码免受 NullReferenceException”

3.4 保护代码免受 NullReferenceException

问题

您正在构建一个可重用库,并需要传达可空引用语义。

解决方案

这是旧式代码,不处理空引用:

public class OrderLibraryNonNull
{
    // nullable property
    public string DealOfTheDay { get; set; }

    // method with null parameter
    public void AddItem(string item)
    {
        Console.Write(item.ToString());
    }

    // method with null return value
    public List<string> GetItems()
    {
        return null;
    }

    // method with null type parameter
    public void AddItems(List<string> items)
    {
        foreach (var item in items)
            Console.WriteLine(item.ToString());
    }
}

以下项目文件启用了新的可空引用特性:

<Project Sdk="Microsoft.NET.Sdk">
    <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>netcoreapp3.1</TargetFramework>
        <RootNamespace>Section_03_04</RootNamespace>
        <Nullable>enable</Nullable>
    </PropertyGroup>
</Project>

下面是更新后的库代码,涉及可空引用的通信:

public class OrderLibraryWithNull
{
    // nullable property
    public string? DealOfTheDay { get; set; }

    // method with null parameter
    public void AddItem(string? item)
    {
        _ = item ?? throw new ArgumentNullException(
            nameof(item), $"{nameof(item)} must not be null");

        Console.Write(item.ToString());
    }

    // method with null return value
    public List<string>? GetItems()
    {
        return null;
    }

    // method with null type parameter
    public void AddItems(List<string?> items)
    {
        foreach (var item in items)
            Console.WriteLine(item?.ToString() ?? "None");
    }
}

这是一个忽略可空引用的旧式消费代码示例:

static void HandleWithNullNoHandling()
{
    var orders = new OrderLibraryWithNull();

    string deal = orders.DealOfTheDay;
    Console.WriteLine(deal.ToUpper());

    orders.AddItem(null);
    orders.AddItems(new List<string> { "one", null });

    foreach (var item in orders.GetItems().ToArray())
        Console.WriteLine(item.Trim());
}

图 3-1 显示了用户在使用忽略可空引用的代码时看到的警告墙。

Visual Studio 中显示多个可空引用警告的错误窗口

图 3-1. Visual Studio 中的可空引用警告

最后,这是消费代码如何正确对待具有适当检查和验证的可重用库的示例:

static void HandleWithNullAndHandling()
{
    var orders = new OrderLibraryWithNull();

    string? deal = orders.DealOfTheDay;
    Console.WriteLine(deal?.ToUpper() ?? "Deals");

    orders.AddItem(null);
    orders.AddItems(new List<string?> { "one", null });

    List<string>? items = orders.GetItems();

    if (items != null)
        foreach (var item in items.ToArray())
            Console.WriteLine(item.Trim());
}

讨论

如果你已经使用 C#编程一段时间,很可能遇到过NullReferenceExceptions。当引用仍为 null 的变量的成员时会发生NullReferenceException,实质上是试图使用尚不存在的对象。C# 8 首次引入了可空引用,通过减少抛出的NullReferenceException异常数量来帮助编写更高质量的代码。整个概念围绕着在编译时通知开发人员变量为 null 的情况,可能导致抛出NullReferenceException。这种情况基于需要为其他开发人员编写可重用库,可能是一个单独的类库或 NuGet 包。您的目标是让他们知道库中可能发生空引用的位置,以便他们编写代码来防止NullReferenceException

为了演示,解决方案展示了不通知空引用的库代码。本质上,这是旧式代码,展示了 C# 8 之前开发人员会编写的代码。您还将看到如何配置项目以支持 C# 8 的可空引用。然后,您将了解如何更改该库代码,以向可能消费它的开发人员传达空引用。最后,您将看到两个消费代码的示例:一个不处理空引用,另一个显示如何防止空引用。

在第一个解决方案示例中,OrderLibraryNonNull类具有参数或返回类型为引用类型(例如stringList<string>)的成员。在可空和非可空上下文中,这段代码不会生成任何警告。即使在可空上下文中,引用类型也没有标记为可空,并且危险地传达给用户,他们永远不会收到NullReferenceException。然而,由于可能会出现NullReferenceExceptions,我们不希望再这样编写我们的代码了。

在解决方案中的 XML 清单是项目文件,其中包含/Project/PropertyGroup/Nullable元素。将其设置为true将项目置于可空上下文中。将单独的类库放入可空上下文可能会为类库开发人员提供警告,但代码的使用者永远不会看到这些警告。

OrderLibraryWithNull的下一个解决方案代码片段修复了这个问题。与OrderLibraryNonNull进行比较,以区分它们的不同之处。在评估空引用时,逐个成员地遍历类型,思考参数和返回值如何影响库的消费者,特别是在空引用方面。存在许多不同的空场景,但这个例子涵盖了三种常见情况:属性类型、方法参数类型和泛型参数类型,下面的段落中有详细解释。

注意

有时,一个方法确实不会返回空引用。这时候不使用可空操作符来告知使用者不需要检查空引用是有意义的。

DealOfTheDay展示了属性类型空引用的情景。它的getter属性返回一个string,这个值可能为空。使用可空操作符?来修复这些问题,并返回string?

AddItems类似,只是它接受一个string参数,演示了方法参数的情况。由于string可以为null,将其更改为string?也让编译器了解了。请注意,我使用了 Recipe 3.3 中描述的简化参数检查。

有时,您可能会遇到可空的泛型参数类型。GetItems方法返回一个List<string>,而List<T>是引用类型。因此,将其更改为List<string>?可以解决问题。

最后,这里有一个有点棘手的例子。AddItems 中的 items 参数是一个 List<string>。可以轻松进行参数检查以测试 null 参数,但是省略可空操作符也是一种好方法,以告知用户不应传递 null 值。

也就是说,如果 List<string> 中的一个值是 null 怎么办?在这种情况下,它是一个 List<string>,但是对于用户可以传递 Dictionary<string, string> 的场景,其中值可以是 null,那么就像例子中对 List<string?> 所做的那样,注释类型参数,表示允许值为 null。因为你知道参数可以为 null,在引用其成员之前检查是非常重要的,以避免 NullReferenceException

现在你有了一个对消费者有用的库代码。然而,只有消费者也将其项目置于可为空的上下文中,才能发挥其作用,如项目文件中所示。

HandleWithNullNoHandling 方法展示了在 C# 8 之前开发者可能编写的代码。然而,一旦将项目置于可为空的上下文中,将收到多个警告,如在 Visual Studio 错误列表窗口中显示的警告墙所示。与 HandleWithNullAndHandling 方法进行比较,对比非常明显。

整个过程是级联的,所以从方法顶部开始,逐步向下工作:

  1. 因为 DealOfTheDay 的 getter 可能返回 null,将 deal 的类型设置为 string?

  2. 由于 deal 可能为 null,使用空引用操作符和合并操作符确保 Console.WriteLine 有合理的内容可写。

  3. 传递给 AddItems 的类型需要是 List<string?>,以表明你知道一个项可能为 null

  4. orders.GetItems 内联到 foreach 循环中,改为将其重构为一个新变量。这样可以检查 null 以避免使用 null 迭代器。

参见

Recipe 3.3, “简化参数验证”

3.5 避免神奇的字符串

问题

const 字符串在应用程序的多个位置存在,并且你需要一种方法来更改它而不会破坏其他代码。

解决方案

这是一个 Order 对象:

public class Order
{
    public string DeliveryInstructions { get; set; }

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

这里是一些常量:

public class Delivery
{
    public const string NextDay = "Next Day";
    public const string Standard = "Standard";
    public const string LowFare = "Low Fare";

    public const int StandardDays = 7;
}

这是使用 Order 和常量计算交付天数的程序:

static void Main(string[] args)
{
    var orders = new List<Order>
    {
        new Order { DeliveryInstructions = Delivery.LowFare },
        new Order { DeliveryInstructions = Delivery.NextDay },
        new Order { DeliveryInstructions = Delivery.Standard },
    };

    foreach (var order in orders)
    {
        int days;

        switch (order.DeliveryInstructions)
        {
            case Delivery.LowFare:
                days = 15;
                break;
            case Delivery.NextDay:
                days = 1;
                break;
            case Delivery.Standard:
            default:
                days = Delivery.StandardDays;
                break;
        }

        Console.WriteLine(order.DeliveryInstructions);
        Console.WriteLine($"Expected Delivery Day(s): {days}");
    }
}

讨论

开发软件一段时间后,大多数开发者都见过一些神奇的值,这些是直接写入表达式的文本值和数字值。从原始开发者的角度来看,它们可能不是一个大问题。然而,从维护开发者的角度来看,这些文本值并不立即显得合理。就像它们神奇地从无处出现一样,或者感觉代码之所以工作是因为这些文本值的含义并不明显。

目标是编写能够让未来的维护人员理解的代码。否则,由于试图弄清楚某些看似随机的数字而浪费的时间,项目成本会增加。解决方案通常是用一个变量替换文字值,其名称表达了值的语义或存在的原因。一种普遍认为可读性良好的代码比注释更具可维护性的生命周期更长。

更进一步,本地常量有助于提高方法的可读性,但常量通常是可重复使用的。解决方案示例演示了如何将一些可重复使用的常量放置在它们自己的类中,以便代码的其他部分重复使用。

除了itemsOrder类还有一个DeliveryInstructions属性。在这里,我们假设有一组有限的交货说明。

Delivery类具有NextDayStandardLowFareconst string值,描述了订单应该如何交付。此外,请注意该类有一个StandardDays值,设置为7。你更愿意阅读哪种程序——使用7还是使用名为StandardDays的常量?这使得代码更易读,正如在Program类中所示。

注意

你可能首先考虑Delivery类中的const string值更适合用枚举。但请注意它们有空格。而且,它们将与order一起写入。虽然有技术可以将枚举用作string,但这很简单。

在某些场景中,你需要一个特定的string值进行查找。这是一个主观的问题,取决于你认为适合某项任务的工具。如果发现枚举更方便的情况,请使用该路线。

Program类使用OrdersDelivery来计算交货所需的天数,基于订单的DeliveryInstructions。列表中有三个订单,每个订单对DeliveryInstructions有不同的设置。foreach循环遍历这些订单,使用switch语句根据DeliveryInstructions设置交货天数。

注意到有序列表构造和switch语句都使用了Delivery中的常量。如果没有这样做,到处都会有strings。现在,借助 IntelliSense 支持,编码变得更加容易,没有重复,因为string只在一个地方,减少了打字错误的机会。而且,如果需要更改strings,只需在一个地方进行修改。此外,你还能获得 IDE 重构支持,以便在应用程序中改变常量出现的所有地方。

3.6 自定义类字符串表示

问题

在调试器中的类表示、字符串参数和日志文件都是不可读的,你希望自定义它们的外观。

解决方案

下面是一个具有自定义ToString方法的类:

public class Order
{
    public int ID { get; set; }

    public string CustomerName { get; set; }

    public DateTime Created { get; set; }

    public decimal Amount { get; set; }

    public override string ToString()
    {
        var stringBuilder = new StringBuilder();

        stringBuilder.Append(nameof(Order));
        stringBuilder.Append(" {\n");

        if (PrintMembers(stringBuilder))
            stringBuilder.Append(" ");

        stringBuilder.Append("\n}");

        return stringBuilder.ToString();
    }

    protected virtual bool PrintMembers(StringBuilder builder)
    {
        builder.Append("  " + nameof(ID));
        builder.Append(" = ");
        builder.Append(ID);
        builder.Append(", \n");
        builder.Append("  " + nameof(CustomerName));
        builder.Append(" = ");
        builder.Append(CustomerName);
        builder.Append(", \n");
        builder.Append("  " + nameof(Created));
        builder.Append(" = ");
        builder.Append(Created.ToString("d"));
        builder.Append(", \n");
        builder.Append("  " + nameof(Amount));
        builder.Append(" = ");
        builder.Append(Amount);

        return true;
    }
}

下面是使用的示例:

class Program
{
    static void Main(string[] args)
    {
        var order = new Order
        {
            ID = 7,
            CustomerName = "Acme",
            Created = DateTime.Now,
            Amount = 2_718_281.83m
        };

        Console.WriteLine(order);
    }
}

这是输出:

Order {
  ID = 7,
  CustomerName = Acme,
  Created = 1/23/2021,
  Amount = 2718281.83
}

讨论

有些类型很复杂,在调试器中查看实例很麻烦,因为您需要深入多个级别来检查值。现代 IDE 使这一过程更加轻松,但有时更希望有更可读的类表示。

这就是重写ToString方法的用处。ToString是所有类型派生自的Object类型的方法。默认实现是类型的完全限定名称,在解决方案中Order类的名称是Section_03_06.Order。由于它是虚方法,您可以重写它。

实际上,Order类使用自己的表示方式重写了ToString。如第 2.1 节所述,实现使用StringBuilder。格式使用对象名称,大括号内是属性,如输出中所示。

Main中,演示代码通过Console.WriteLine生成此输出。这是因为如果参数不是stringConsole.WriteLine会调用对象的ToString方法。

另请参阅

第 2.1 节,“高效处理字符串”

3.7 重新抛出异常

问题

应用程序抛出异常,但消息缺少信息,您需要确保处理过程中所有相关数据都是可用的。

解决方案

此对象抛出一个异常:

public class Orders
{
    public void Process()
    {
        throw new IndexOutOfRangeException(
            "Expected 10 orders, but found only 9.");
    }
}

这里有处理异常的不同方法:

public class OrderOrchestrator
{
    public static void HandleOrdersWrong()
    {
        try
        {
            new Orders().Process();
        }
        catch (IndexOutOfRangeException ex)
        {
            throw new InvalidOperationException(ex.Message);
        }
    }

    public static void HandleOrdersBetter1()
    {
        try
        {
            new Orders().Process();
        }
        catch (IndexOutOfRangeException ex)
        {
            throw new InvalidOperationException("Error Processing Orders", ex);
        }
    }

    public static void HandleOrdersBetter2()
    {
        try
        {
            new Orders().Process();
        }
        catch (IndexOutOfRangeException)
        {
            throw;
        }
    }

    public static void DontHandleOrders()
    {
        new Orders().Process();
    }
}

此程序测试每种异常处理方法:

class Program
{
    static void Main(string[] args)
    {
        AppDomain.CurrentDomain.UnhandledException +=
            (object sender, UnhandledExceptionEventArgs e) =>
            System.Console.WriteLine("\n\nUnhandled Exception:\n" + e);

        try
        {
            OrderOrchestrator.HandleOrdersWrong();
        }
        catch (InvalidOperationException ex)
        {
            Console.WriteLine("Handle Orders Wrong:\n" + ex);
        }

        try
        {
            OrderOrchestrator.HandleOrdersBetter1();
        }
        catch (InvalidOperationException ex)
        {
            Console.WriteLine("\n\nHandle Orders Better #1:\n" + ex);
        }

        try
        {
            OrderOrchestrator.HandleOrdersBetter2();
        }
        catch (IndexOutOfRangeException ex)
        {
            Console.WriteLine("\n\nHandle Orders Better #2:\n" + ex);
        }

        OrderOrchestrator.DontHandleOrders();
    }
}

下面是输出:

Handle Orders Wrong:
System.InvalidOperationException: Expected 10 orders, but found only 9.
   at Section_03_07.OrderOrchestrator.HandleOrdersWrong() in
   /CSharp9Cookbook/Chapter03/Section-03-07/OrderOrchestrator.cs:line 15
   at Section_03_07.Program.Main(String[] args) in
   /CSharp9Cookbook/Chapter03/Section-03-07/Program.cs:line 11

Handle Orders Better #1:
System.InvalidOperationException: Error Processing Orders
 ---> System.IndexOutOfRangeException: Expected 10 orders, but found only 9.
   at Section_03_07.Orders.Process() in
   /CSharp9Cookbook/Chapter03/Section-03-07/Orders.cs:line 9
   at Section_03_07.OrderOrchestrator.HandleOrdersBetter1() in
   /CSharp9Cookbook/Chapter03/Section-03-07/OrderOrchestrator.cs:line 23
   --- End of inner exception stack trace ---
   at Section_03_07.OrderOrchestrator.HandleOrdersBetter1() in
   /CSharp9Cookbook/Chapter03/Section-03-07/OrderOrchestrator.cs:line 27
   at Section_03_07.Program.Main(String[] args) in
   /CSharp9Cookbook/Chapter03/Section-03-07/Program.cs:line 20

Handle Orders Better #2:
System.IndexOutOfRangeException: Expected 10 orders, but found only 9.
   at Section_03_07.Orders.Process() in
   /CSharp9Cookbook/Chapter03/Section-03-07/Orders.cs:line 9
   at Section_03_07.OrderOrchestrator.HandleOrdersBetter2() in
   /CSharp9Cookbook/Chapter03/Section-03-07/OrderOrchestrator.cs:line 35
   at Section_03_07.Program.Main(String[] args) in
   /CSharp9Cookbook/Chapter03/Section-03-07/Program.cs:line 29

Unhandled Exception:
System.UnhandledExceptionEventArgs
Unhandled exception. System.IndexOutOfRangeException:
   Expected 10 orders, but found only 9.
   at Section_03_07.Orders.Process() in
   /CSharp9Cookbook/Chapter03/Section-03-07/Orders.cs:line 9
   at Section_03_07.OrderOrchestrator.DontHandleOrders() in
   /CSharp9Cookbook/Chapter03/Section-03-07/OrderOrchestrator.cs:line 45
   at Section_03_07.Program.Main(String[] args) in
   /CSharp9Cookbook/Chapter03/Section-03-07/Program.cs:line 40

讨论

有多种处理异常的方法,其中一些比其他方法更好。从故障排除的角度来看,我们通常希望记录具有足够有意义信息的异常日志,以帮助解决问题。这正是本节的目的,确定应采用哪种更好的解决方案。

Orders类的Process方法抛出IndexOutOfRangeException,而OrderOrchestrator类以几种不同的方式处理该异常:其中一种是应该避免的,而另外两种则更好,具体取决于您的情况。

HandleOrdersWrong方法获取原始异常的Message属性,并使用该消息作为输入抛出新的InvalidOperationException。该场景模拟了分析情况并尝试抛出比原始异常更具意义或提供更多信息的异常的情况。然而,这会导致另一个问题,即丢失关键的堆栈跟踪信息,这些信息对于解决问题至关重要。在实践中,异常可能通过多个级别抛出并通过不同路径到达。您可以在堆栈跟踪中看到此问题,在输出中显示异常的堆栈跟踪源自OrderOrchestrator.HandleOrdersWrong方法,而非其真正源自Orders.Process

警告

另一件绝对不应该做的事情是像这样重新抛出原始异常:

try
{
    OrderOrchestrator.HandleOrdersWrong();
}
catch (InvalidOperationException ex)
{
    throw ex;
}

这种方法的问题在于重新抛出原始异常会导致丢失堆栈跟踪。没有原始堆栈跟踪,试图调试程序的开发人员将不知道异常的起源位置。此外,原始异常可能与您收到的异常不同,可能包含更详细的信息,而没有人会看到。

HandleOrdersBetter1方法通过向innerException参数添加额外的参数ex改进了这种情况。这样做的好处在于现在可以抛出带有附加数据的异常,并保留整个堆栈跟踪。您可以在输出中看到异常路径始于Orders.Process(由--- End of inner exception stack trace ---分隔)。

HandleOrdersBetter2仅抛出原始异常。这里的假设是逻辑无法处理异常或记录并重新抛出。如输出所示,堆栈跟踪也源自Orders.Process

处理异常有很多策略,本文涵盖了其中一个方面。在这种情况下,考虑保留用于后续调试的原始堆栈跟踪,您应该重新抛出异常。无论如何,都要考虑您的情景及其对您来说是否有意义。

有时,可能会遇到代码抛出异常但没有处理策略的情况。OrderOrchestrator.DontHandleOrders不执行任何处理,而Main方法未用try/catch保护。在这种情况下,仍可以通过向AppDomain.​Cur⁠rentDomain.UnhandledException添加事件处理程序来拦截异常,正如在Main方法末尾所示。在运行任何代码之前,您需要分配事件处理程序,否则将无法处理异常。

参见

Recipe 1.9, “设计自定义异常”

3.8 管理进程状态

问题

用户启动了一个进程,但发生异常后,用户界面状态未更新。

解决方案

此方法会抛出异常:

static void ProcessOrders()
{
    throw new ArgumentException();
}

这是您不应编写的代码:

static void Main()
{
    Console.WriteLine("Processing Orders Started");

    ProcessOrders();

    Console.WriteLine("Processing Orders Complete");
}

取而代之,这是您应编写的代码:

static void Main()
{
    try
    {
        Console.WriteLine("Processing Orders Started");

        ProcessOrders();
    }
    catch (ArgumentException ae)
    {
        Console.WriteLine('\n' + ae.ToString() + '\n');
    }
    finally
    {
        Console.WriteLine("Processing Orders Complete");
    }
}

讨论

问题陈述提到发生了异常,这是正确的。但是,从用户角度来看,他们将不会收到解释问题发生以及其工作未完成的消息或状态。这是因为在第一个Main方法中,如果在ProcessOrder期间抛出异常,"Processing Orders Complete"消息不会显示给用户。

这是一个try/finally块的良好使用案例,第二个Main方法使用了它。将所有应在try块中运行的代码和最终状态放在finally块中。如果发生异常,可以捕获它,记录下来,并告知用户他们的任务未成功。

虽然这是控制台应用程序的示例,但对于 UI 代码也是一个好的技术。在启动进程时,您可能会有一个类似沙漏或进度指示器的等待通知。关闭通知也是 finally 块可以帮助的任务。

参见

第 3.9 节,“构建弹性网络连接”

第 3.10 节,“性能测量”

3.9 构建弹性网络连接

问题

该应用程序与不稳定的后端服务通信,您希望防止其失败。

解决方案

此方法会抛出异常:

static async Task GetOrdersAsync()
{
    throw await Task.FromResult(
        new HttpRequestException(
            "Timeout", null, HttpStatusCode.RequestTimeout));
}

这是一种处理网络错误的技术:

public static async Task Main()
{
    const int DelayMilliseconds = 500;
    const int RetryCount = 3;

    bool success = false;
    int tryCount = 0;

    try
    {
        do
        {
            try
            {
                Console.WriteLine("Getting Orders");
                await GetOrdersAsync();

                success = true;
            }
            catch (HttpRequestException hre)
                when (hre.StatusCode == HttpStatusCode.RequestTimeout)
            {
                tryCount++;

                int millisecondsToDelay = DelayMilliseconds * tryCount;
                Console.WriteLine(
                    $"Exception during processing—" +
                    $"delaying for {millisecondsToDelay} milliseconds");

                await Task.Delay(millisecondsToDelay);
            }

        } while (tryCount < RetryCount);
    }
    finally
    {
        if (success)
            Console.WriteLine("Operation Succeeded");
        else
            Console.WriteLine("Operation Failed");
    }
}

这是输出的内容:

    Getting Orders
    Exception during processing - delaying for 500 milliseconds
    Getting Orders
    Exception during processing - delaying for 1000 milliseconds
    Getting Orders
    Exception during processing - delaying for 1500 milliseconds
    Operation Failed

讨论

每当您进行进程外工作时,都有可能出现错误或超时。通常您无法控制您正在交互的应用程序,编写防御性代码非常重要。特别是进行网络操作的代码由于延迟、超时或硬件问题而容易出现与连接的两端代码质量无关的错误。

此解决方案通过 GetOrdersAsync 模拟了网络连接问题。它抛出一个带有 RequestTimeout 状态的 HttpRequestExceptionMain 方法展示了如何减轻这些问题的方法。目标是在尝试之间以一定的延迟重试连接。

首先,请注意 success 初始化为 falsetry/finallyfinally 块让用户根据 success 的结果了解操作的结果。在 try/do/try 的嵌套中,try 块的最后一行将 success 设置为 true,因为所有逻辑都完成了——如果之前发生了异常,程序将无法达到那一行。

do/while 循环重试 RetryCount 次。我们将 tryCount 初始化为 0,并在 catch 块中递增它。因为如果发生错误,我们知道我们将重试,并且希望确保不超过指定的重试次数。RetryCount 是一个 const,初始化为 3。您可以根据需要调整 RetryCount 的次数。如果操作时间敏感,您可能希望限制重试并发送关键错误的通知。另一个场景可能是,您知道连接的另一端最终会恢复在线,并可以将 RetryCount 设置为非常高的数字。

每当出现异常时,通常不希望立即重新发起请求。一个超时的原因可能是另一端的扩展能力不强,过多的请求可能会使服务器不堪重负。此外,一些第三方 API 会对客户端进行速率限制,连续的请求会消耗速率限制计数。一些 API 提供商甚至可能因为过多的连接请求而阻止您的应用程序。

DelayMilliseconds有助于您的重试策略,初始化为500毫秒。如果发现重试仍然太快,您可以调整这个值。如果单个延迟时间有效,那么您可以使用它。然而,许多情况需要线性或指数回退策略。您可以看到,解决方案使用了线性回退,将DelayMilliseconds乘以tryCount。由于tryCount初始化为0,我们首先递增它。

提示

您可能希望将重试记录为警告,而不是错误。管理员、质量保证或任何查看日志(或报告)的人可能会感到不必要地惊慌。他们看到看起来像错误的东西,而您的应用程序正在对典型的网络行为做出适当的反应和修复。

或者,您可能需要使用指数退避策略,例如将DelayMilliseconds提高到tryCount的幂次方——Math.Pow(DelayMilliseconds, tryCount)。您可以进行实验,例如记录错误并定期审查,以查看对您的情况最有效的方法。

3.10 测量性能

问题

您知道几种编写算法的方式,并需要测试哪种算法性能最佳。

解决方案

这是我们将操作的对象类型:

public class OrderItem
{
    public decimal Cost { get; set; }
    public string Description { get; set; }
}

这是创建OrderItem列表的代码:

static List<OrderItem> GetOrderItems()
{
    const int ItemCount = 10000;

    var items = new List<OrderItem>();
    var rand = new Random();

    for (int i = 0; i < ItemCount; i++)
        items.Add(
            new OrderItem
            {
                Cost = rand.Next(i),
                Description = "Order Item #" + (i + 1)
            });

    return items;
}

这是一个效率低下的字符串连接方法:

static string DoStringConcatenation(List<OrderItem> lineItems)
{
    var stopwatch = new Stopwatch();

    try
    {
        stopwatch.Start();

        string report = "";

        foreach (var item in lineItems)
            report += $"{item.Cost:C} - {item.Description}\n";

        Console.WriteLine(
            $"Time for String Concatenation: " +
            $"{stopwatch.ElapsedMilliseconds}");

        return report;
    }
    finally
    {
        stopwatch.Stop();
    }
}

这是更快的StringBuilder方法:

static string DoStringBuilderConcatenation(List<OrderItem> lineItems)
{
    var stopwatch = new Stopwatch();
    try
    {
        stopwatch.Start();

        var reportBuilder = new StringBuilder();

        foreach (var item in lineItems)
            reportBuilder.Append($"{item.Cost:C} - {item.Description}\n");

        Console.WriteLine(
            $"Time for String Builder Concatenation: " +
            $"{stopwatch.ElapsedMilliseconds}");

        return reportBuilder.ToString();
    }
    finally
    {
        stopwatch.Stop();
    }
}

此代码驱动演示:

static void Main()
{
    List<OrderItem> lineItems = GetOrderItems();

    DoStringConcatenation(lineItems);

    DoStringBuilderConcatenation(lineItems);
}

这是输出:

    Time for String Concatenation: 1137
    Time for String Builder Concatenation: 2

讨论

第 2.1 节讨论了StringBuilder相对于字符串连接的优势,强调性能是主要驱动因素。然而,它并未解释如何通过代码测量性能。本节建立在此基础上,展示了如何通过代码测量算法性能。

提示

随着我们的计算机每年(或更少)变得越来越快,StringBuilder方法的结果将接近0。要体验两种方法之间时间差异的真实大小,可以在GetOrderItemsItemCount中再加一个0

StringConcatenationStringBuilderConcatenation方法中,您会发现StopWatch的实例,它位于System.Diagnostics命名空间中。

调用Start启动计时器,Stop停止计时器。注意,算法使用try/finally,如第 3.8 节所述,以确保计时器停止。

Console.WriteLine在每个算法末尾使用stopwatch.ElapsedMilliseconds显示算法使用的时间。

如输出所示,StringBuilder和字符串连接之间的运行时间差异是显著的。

参见

第 2.1 节,“高效处理字符串”

第 3.8 节,“管理进程状态”

第四章:使用 LINQ 进行查询

LINQ 自 C# 3 开始就已经存在。它为开发人员提供了一种查询数据源的方式,使用带有 SQL 风格的语法。因为 LINQ 是语言的一部分,您可以在 IDE 中体验到语法高亮和智能感知等功能。

LINQ 通常被认为是一个用于查询数据库的工具,其目标是减少所谓的 阻抗不匹配,即数据库数据表示与 C# 对象之间的差异。事实上,我们可以为任何数据技术构建 LINQ 提供程序。事实上,作者为 Twitter API 编写了一个开源提供程序,名为 LINQ to Twitter

本章的示例采用了一种不同的方法。它们不使用外部数据源,而是使用专门针对内存数据源的提供程序,称为 LINQ to Objects。尽管可以使用 C# 循环和命令式逻辑执行任何内存数据操作,但通常使用 LINQ 可以简化代码,因为它具有声明性的特性——指定要做什么而不是如何做。每个部分都有一个或多个实体(要查询的对象)的独特表示,以及设置了用于查询的 InMemoryContext 的内存数据。

本章中有几个简单的示例,如转换对象形状和简化查询。然而,也有一些重要的观点可以澄清和简化您的代码。

从不同数据源中汇集代码可能导致混乱的代码。关于连接、左连接和分组的部分描述了如何简化这些场景。还有一个相关的部分用于处理集合操作。

开发人员在构建带有连接字符串的查询时,会出现搜索表单和查询的严重安全问题。虽然这听起来可能是一个快速简单的解决方案,但通常代价过高。本章包含几节,展示了 LINQ 延迟执行如何让您动态构建查询。另一节解释了一种重要的搜索查询技术,以及它如何让您能够使用表达树生成动态子句。

4.1 转换对象形状

问题

您希望数据呈现自定义形状,与原始数据源不同。

解决方案

这里是需要重塑的实体:

public class SalesPerson
{
    public int ID { get; set; }

    public string Name { get; set; }

    public string Address { get; set; }

    public string City { get; set; }

    public string PostalCode { get; set; }

    public string Region { get; set; }

    public string ProductType { get; set; }
}

这是数据源:

public class InMemoryContext
{
    List<SalesPerson> salesPeople =
        new List<SalesPerson>
        {
            new SalesPerson
            {
                ID = 1,
                Address = "123 1st Street",
                City = "First City",
                Name = "First Person",
                PostalCode = "45678",
                Region = "Region #1"
            },
            new SalesPerson
            {
                ID = 2,
                Address = "234 2nd Street",
                City = "Second City",
                Name = "Second Person",
                PostalCode = "56789",
                Region = "Region #2"
            },
            new SalesPerson
            {
                ID = 3,
                Address = "345 3rd Street",
                City = "Third City",
                Name = "Third Person",
                PostalCode = "67890",
                Region = "Region #3"
            },
        };

    public List<SalesPerson> SalesPeople => salesPeople;
}

这段代码执行了重新塑形数据的投影:

class Program
{
    static void Main()
    {
        var context = new InMemoryContext();

        var salesPersonLookup =
            (from person in context.SalesPeople
             select (person.ID, person.Name))
            .ToList();

        Console.WriteLine("Sales People\n");

        salesPersonLookup.ForEach(person =>
            Console.WriteLine($"{person.ID}. {person.Name}"));
    }
}

讨论

在 LINQ 中,将对象形状转换称为 投影。您可能希望这样做的几个常见原因包括创建查找列表、创建视图或视图模型对象,或将数据传输对象(DTO)转换为您的应用更好处理的格式。

当使用 LINQ to Entities 进行数据库查询(一种不同于数据库的提供程序)或使用 DTOs 消费数据时,数据通常以表示原始数据源的格式到达。然而,如果你想要处理领域数据或绑定到 UI,纯数据表示形式可能不具有正确的形状。此外,数据表示通常具有对象关系模型(ORM)或数据访问库的属性和语义。一些开发人员试图将这些数据对象绑定到他们的 UI,因为他们不想创建新的对象类型。尽管这可以理解,因为没有人愿意比必要工作更多,但问题是 UI 代码通常需要不同形状的数据,并且需要自己的验证和属性。因此,问题在于你为两种不同目的使用一个对象。理想情况下,一个对象应该具有单一职责,而这样混合使用通常会导致代码混乱,难以维护。

解决方案展示的另一种场景是仅需查找列表的情况,带有 ID 和可显示值。当填充 UI 元素如复选框列表、单选按钮组、组合框或下拉框时,这非常有用。如果需要的只是 ID 和一些显示给用户的内容,查询整个实体将会很浪费且慢(尤其是在跨进程或跨网络的数据库连接中)。

解决方案的 Main 方法展示了这一点。它查询了 InMemoryContextSalesPeople 属性,这是一个 SalesPerson 列表,而 select 子句重新将结果重新塑形为 IDName 的元组。

注意

解决方案中的 select 子句使用了一个元组。然而,你也可以将请求的字段投影(仅投影请求的字段)到一个匿名类型、一个 SalesPerson 类型或一个新的自定义类型中。

虽然这是一个内存操作,但这种技术的好处在于使用 LINQ to Entities 这样的库查询数据库时体现出来。在这种情况下,LINQ to Entities 将 LINQ 查询转换为仅请求 select 子句中指定的字段的数据库查询。

4.2 数据连接

问题

你需要从不同的源中提取数据到一条记录中。

解决方案

下面是要连接的实体:

public class Product
{
    public int ID { get; set; }

    public string Name { get; set; }

    public string Type { get; set; }

    public decimal Price { get; set; }

    public string Region { get; set; }
}

public class SalesPerson
{
    public int ID { get; set; }

    public string Name { get; set; }

    public string Address { get; set; }

    public string City { get; set; }

    public string PostalCode { get; set; }

    public string Region { get; set; }

    public string ProductType { get; set; }
}

这是数据源:

public class InMemoryContext
{
    List<SalesPerson> salesPeople =
        new List<SalesPerson>
        {
            new SalesPerson
            {
                ID = 1,
                Address = "123 1st Street",
                City = "First City",
                Name = "First Person",
                PostalCode = "45678",
                Region = "Region #1",
                ProductType = "Type 2"
            },
            new SalesPerson
            {
                ID = 2,
                Address = "234 2nd Street",
                City = "Second City",
                Name = "Second Person",
                PostalCode = "56789",
                Region = "Region #2",
                ProductType = "Type 3"
            },
            new SalesPerson
            {
                ID = 3,
                Address = "345 3rd Street",
                City = "Third City",
                Name = "Third Person",
                PostalCode = "67890",
                Region = "Region #3",
                ProductType = "Type 1"
            },
            new SalesPerson
            {
                ID = 4,
                Address = "678 9th Street",
                City = "Fourth City",
                Name = "Fourth Person",
                PostalCode = "90123",
                Region = "Region #1",
                ProductType = "Type 2"
            },
        };

    List<Product> products =
        new List<Product>
        {
            new Product
            {
                ID = 1,
                Name = "Product 1",
                Price = 123.45m,
                Type = "Type 2",
                Region = "Region #1",
            },
            new Product
            {
                ID = 2,
                Name = "Product 2",
                Price = 456.78m,
                Type = "Type 2",
                Region = "Region #2",
            },
            new Product
            {
                ID = 3,
                Name = "Product 3",
                Price = 789.10m,
                Type = "Type 3",
                Region = "Region #1",
            },
            new Product
            {
                ID = 4,
                Name = "Product 4",
                Price = 234.56m,
                Type = "Type 2",
                Region = "Region #1",
            },
        };

    public List<SalesPerson> SalesPeople => salesPeople;

    public List<Product> Products => products;
}

下面是连接实体的代码:

class Program
{
    static void Main()
    {
        var context = new InMemoryContext();

        var salesProducts =
            (from person in context.SalesPeople
             join product in context.Products on
             (person.Region, person.ProductType)
             equals
             (product.Region, product.Type)
             select new
             {
                Person = person.Name,
                Product = product.Name,
                product.Region,
                product.Type
             })
            .ToList();

        Console.WriteLine("Sales People\n");

        salesProducts.ForEach(salesProd =>
            Console.WriteLine(
                $"Person: {salesProd.Person}\n" +
                $"Product: {salesProd.Product}\n" +
                $"Region: {salesProd.Region}\n" +
                $"Type: {salesProd.Type}\n"));
    }
}

讨论

当数据来自多个源时,LINQ 连接非常有用。一个公司可能已经合并,你需要从每个数据库中提取数据,你可能正在使用微服务架构,数据来自不同的服务,或者一些数据是在内存中创建的,你需要将其与数据库记录关联起来。

通常情况下,无法使用 ID,因为如果数据来自不同的源,它们永远不会匹配。你唯一能期望的是一些字段能够对应上。话虽如此,如果有单个字段匹配,那就太好了。解决方案的 Main 方法使用了 RegionProductType 的组合键,并依赖于元组中的值相等性。

注意

select子句使用匿名类型进行自定义投影。关于形状化对象数据的另一个示例在 Recipe 4.1 中讨论。

即使此示例使用元组作为复合键,您也可以使用匿名类型获得相同的结果。元组使用稍少的语法。

参见

Recipe 4.1,“形状化对象形状”

4.3 执行左连接

问题

您需要在两个数据源上进行连接,但其中一个数据源没有匹配记录。

解决方案

这里是要执行左连接的实体:

public class Product
{
    public int ID { get; set; }

    public string Name { get; set; }

    public string Type { get; set; }

    public decimal Price { get; set; }

    public string Region { get; set; }
}

public class SalesPerson
{
    public int ID { get; set; }

    public string Name { get; set; }

    public string Address { get; set; }

    public string City { get; set; }

    public string PostalCode { get; set; }

    public string Region { get; set; }

    public string ProductType { get; set; }
}

这是数据源:

public class InMemoryContext
{
    List<SalesPerson> salesPeople =
        new List<SalesPerson>
        {
            new SalesPerson
            {
                ID = 1,
                Address = "123 1st Street",
                City = "First City",
                Name = "First Person",
                PostalCode = "45678",
                Region = "Region #1",
                ProductType = "Type 2"
            },
            new SalesPerson
            {
                ID = 2,
                Address = "234 2nd Street",
                City = "Second City",
                Name = "Second Person",
                PostalCode = "56789",
                Region = "Region #2",
                ProductType = "Type 3"
            },
            new SalesPerson
            {
                ID = 3,
                Address = "345 3rd Street",
                City = "Third City",
                Name = "Third Person",
                PostalCode = "67890",
                Region = "Region #3",
                ProductType = "Type 1"
            },
            new SalesPerson
            {
                ID = 3,
                Address = "678 9th Street",
                City = "Fourth City",
                Name = "Fourth Person",
                PostalCode = "90123",
                Region = "Region #1",
                ProductType = "Type 2"
            },
        };

    List<Product> products =
        new List<Product>
        {
            new Product
            {
                ID = 1,
                Name = "Product 1",
                Price = 123.45m,
                Type = "Type 2",
                Region = "Region #1",
            },
            new Product
            {
                ID = 2,
                Name = "Product 2",
                Price = 456.78m,
                Type = "Type 2",
                Region = "Region #2",
            },
            new Product
            {
                ID = 3,
                Name = "Product 3",
                Price = 789.10m,
                Type = "Type 3",
                Region = "Region #1",
            },
            new Product
            {
                ID = 4,
                Name = "Product 4",
                Price = 234.56m,
                Type = "Type 2",
                Region = "Region #1",
            },
        };

    public List<SalesPerson> SalesPeople => salesPeople;

    public List<Product> Products => products;
}

以下代码执行左连接操作:

class Program
{
    static void Main()
    {
        var context = new InMemoryContext();

        var salesProducts =
            (from product in context.Products
             join person in context.SalesPeople on
             (product.Region, product.Type)
             equals
             (person.Region, person.ProductType)
             into prodPersonTemp
             from prodPerson in prodPersonTemp.DefaultIfEmpty()
             select new
             {
                Person = prodPerson?.Name ?? "(none)",
                Product = product.Name,
                product.Region,
                product.Type
             })
            .ToList();

        Console.WriteLine("Sales People\n");

        salesProducts.ForEach(salesProd =>
            Console.WriteLine(
                $"Person: {salesProd.Person}\n" +
                $"Product: {salesProd.Product}\n" +
                $"Region: {salesProd.Region}\n" +
                $"Type: {salesProd.Type}\n"));
    }
}

这是输出:

Sales People

Person: First Person
Product: Product 1
Region: Region #1
Type: Type 2

Person: Fourth Person
Product: Product 1
Region: Region #1
Type: Type 2

Person: (none)
Product: Product 2
Region: Region #2
Type: Type 2

Person: (none)
Product: Product 3
Region: Region #1
Type: Type 3

Person: First Person
Product: Product 4
Region: Region #1
Type: Type 2

Person: Fourth Person
Product: Product 4
Region: Region #1
Type: Type 2

讨论

此解决方案类似于在 Recipe 4.3 中讨论的join,不同之处在于Main方法的 LINQ 查询。注意into prodPersonTemp子句。这是联接数据的临时持有者。第二个from子句(into下方)查询prodPersonTemp.DefaultIfEmpty()

DefaultIfEmpty()导致左连接,其中prodPerson范围变量接收所有产品对象和仅匹配的人员对象。

第一个from子句指定查询的左侧,Productsjoin子句指定查询的右侧,SalesPeople,这些可能没有匹配值。

注意select子句如何检查prodPerson?.Name是否为null,并将其替换为(none)。这样确保输出指示没有匹配项,而不是依赖后续代码来检查 null。

展示左连接结果在解决方案输出中。注意,产品 1 和产品 4 的输出有一个人员条目。然而,产品 2 和产品 3 没有匹配的人员,显示为(none)

4.4 数据分组

问题

您需要将数据聚合到自定义组中。

解决方案

这里是要分组的实体:

public class SalesPerson
{
    public int ID { get; set; }

    public string Name { get; set; }

    public string Address { get; set; }

    public string City { get; set; }

    public string PostalCode { get; set; }

    public string Region { get; set; }

    public string ProductType { get; set; }
}

这是数据源:

public class InMemoryContext
{
    List<SalesPerson> salesPeople =
        new List<SalesPerson>
        {
            new SalesPerson
            {
                ID = 1,
                Address = "123 1st Street",
                City = "First City",
                Name = "First Person",
                PostalCode = "45678",
                Region = "Region #1"
            },
            new SalesPerson
            {
                ID = 2,
                Address = "234 2nd Street",
                City = "Second City",
                Name = "Second Person",
                PostalCode = "56789",
                Region = "Region #2"
            },
            new SalesPerson
            {
                ID = 3,
                Address = "345 3rd Street",
                City = "Third City",
                Name = "Third Person",
                PostalCode = "67890",
                Region = "Region #3"
            },
            new SalesPerson
            {
                ID = 4,
                Address = "678 9th Street",
                City = "Second City",
                Name = "Fourth Person",
                PostalCode = "56788",
                Region = "Region #2"
            },
        };

    public List<SalesPerson> SalesPeople => salesPeople;
}

以下代码对数据进行分组:

class Program
{
    static void Main()
    {
        var context = new InMemoryContext();

        var salesPeopleByRegion =
            (from person in context.SalesPeople
             group person by person.Region
             into personGroup
             select personGroup)
            .ToList();

        Console.WriteLine("Sales People by Region");

        foreach (var region in salesPeopleByRegion)
        {
            Console.WriteLine($"\nRegion: {region.Key}");

            foreach (var person in region)
                Console.WriteLine($"  {person.Name}");
        }
    }
}

讨论

分组在需要数据层次结构时很有用。它在数据的父/子关系中创建一个父类别和子对象(表示该类别中的数据记录)之间的关系。

在解决方案中,每个SalesPerson都有一个Region属性,其值在InMemoryContext数据源中重复。这有助于显示如何将多个SalesPerson实体分组到单个区域中。

Main方法查询中,有一个group by子句,指定范围变量person进行分组,以及键Region进行分组。personGroup保存结果。在这个例子中,select子句使用整个personGroup,而不是进行自定义投影。

salesPeopleByRegion中是一组顶级对象,代表每个组。每个组都有属于该组的对象集合,如下所示:

Key (Region):
    Items (IEnumerable<SalesPerson>)
注意

针对数据库的 LINQ 提供程序,如针对 SQL Server 的 LINQ to Entities,返回非物化查询的 IQueryable<T>。物化发生在您使用 Count()ToList() 等运算符时,实际执行查询并返回 intList<T>。相比之下,LINQ to Objects 返回的非物化类型是 IEnumerable<T>

foreach 循环演示了此组结构及其如何使用。在顶层,每个对象都有一个 Key 属性。因为原始查询是按 Region 进行的,所以该键将具有 Region 的名称。

嵌套的 foreach 循环在组上迭代,读取该组中的每个 SalesPerson 实例。您可以看到它打印出该组中每个 SalesPerson 实例的 Name

4.5 构建增量查询

问题

您需要根据用户的搜索条件定制查询,但不希望串联字符串。

解决方案

这是要查询的类型:

public class SalesPerson
{
    public int ID { get; set; }

    public string Name { get; set; }

    public string Address { get; set; }

    public string City { get; set; }

    public string PostalCode { get; set; }

    public string Region { get; set; }

    public string ProductType { get; set; }
}

这是数据源:

public class InMemoryContext
{
    List<SalesPerson> salesPeople =
        new List<SalesPerson>
        {
            new SalesPerson
            {
                ID = 1,
                Address = "123 1st Street",
                City = "First City",
                Name = "First Person",
                PostalCode = "45678",
                Region = "Region #1",
                ProductType = "Type 2"
            },
            new SalesPerson
            {
                ID = 2,
                Address = "234 2nd Street",
                City = "Second City",
                Name = "Second Person",
                PostalCode = "56789",
                Region = "Region #2",
                ProductType = "Type 3"
            },
            new SalesPerson
            {
                ID = 3,
                Address = "345 3rd Street",
                City = "Third City",
                Name = "Third Person",
                PostalCode = "67890",
                Region = "Region #3",
                ProductType = "Type 1"
            },
            new SalesPerson
            {
                ID = 4,
                Address = "678 9th Street",
                City = "Fourth City",
                Name = "Fourth Person",
                PostalCode = "90123",
                Region = "Region #1",
                ProductType = "Type 2"
            },
        };

    public List<SalesPerson> SalesPeople => salesPeople;
}

此代码构建动态查询:

class Program
{
    static void Main()
    {
        SalesPerson searchCriteria = GetCriteriaFromUser();

        List<SalesPerson> salesPeople = QuerySalesPeople(searchCriteria);

        PrintResults(salesPeople);
    }

    static SalesPerson GetCriteriaFromUser()
    {
        var person = new SalesPerson();

        Console.WriteLine("Sales Person Search");
        Console.WriteLine("(press Enter to skip an entry)\n");

        Console.Write($"{nameof(SalesPerson.Address)}: ");
        person.Address = Console.ReadLine();

        Console.Write($"{nameof(SalesPerson.City)}: ");
        person.City = Console.ReadLine();

        Console.Write($"{nameof(SalesPerson.Name)}: ");
        person.Name = Console.ReadLine();

        Console.Write($"{nameof(SalesPerson.PostalCode)}: ");
        person.PostalCode = Console.ReadLine();

        Console.Write($"{nameof(SalesPerson.ProductType)}: ");
        person.ProductType = Console.ReadLine();

        Console.Write($"{nameof(SalesPerson.Region)}: ");
        person.Region = Console.ReadLine();

        return person;
    }

    static List<SalesPerson> QuerySalesPeople(SalesPerson criteria)
    {
        var ctx = new InMemoryContext();

        IEnumerable<SalesPerson> salesPeopleQuery =
            from people in ctx.SalesPeople
            select people;

        if (!string.IsNullOrWhiteSpace(criteria.Address))
            salesPeopleQuery = salesPeopleQuery.Where(
                person => person.Address == criteria.Address);

        if (!string.IsNullOrWhiteSpace(criteria.City))
            salesPeopleQuery = salesPeopleQuery.Where(
                person => person.City == criteria.City);

        if (!string.IsNullOrWhiteSpace(criteria.Name))
            salesPeopleQuery = salesPeopleQuery.Where(
                person => person.Name == criteria.Name);

        if (!string.IsNullOrWhiteSpace(criteria.PostalCode))
            salesPeopleQuery = salesPeopleQuery.Where(
                person => person.PostalCode == criteria.PostalCode);

        if (!string.IsNullOrWhiteSpace(criteria.ProductType))
            salesPeopleQuery = salesPeopleQuery.Where(
                person => person.ProductType == criteria.ProductType);

        if (!string.IsNullOrWhiteSpace(criteria.Region))
            salesPeopleQuery = salesPeopleQuery.Where(
                person => person.Region == criteria.Region);

        List<SalesPerson> salesPeople = salesPeopleQuery.ToList();

        return salesPeople;
    }

    static void PrintResults(List<SalesPerson> salesPeople)
    {
        Console.WriteLine("\nSales People\n");

        salesPeople.ForEach(person =>
            Console.WriteLine($"{person.ID}. {person.Name}"));
    }
}

讨论

从安全的角度来看,开发人员可以做的最糟糕的事情之一就是从用户输入构建一个串联的字符串,以此作为 SQL 语句发送到数据库。问题在于字符串串联允许用户的输入被解释为查询的一部分。在大多数情况下,人们只想执行搜索。然而,有恶意用户有意地探测系统的这种漏洞。他们不必是专业黑客,因为有很多初学者(通常被称为脚本小子)想要练习并玩得开心。在最糟糕的情况下,黑客可以访问私人或专有信息,甚至接管一台机器。一旦进入网络中的一台机器,黑客就在内部,并且可以攀爬到其他计算机并接管您的网络。这个特定问题被称为SQL 注入攻击,本节解释了如何避免这种情况。

注意

从安全角度来看,理论上没有一台计算机可以百分之百安全,因为总有一定程度的努力,无论是物理的还是虚拟的,都可以破解计算机。实际上,安全措施可能会增长到一个成本高得无法承受的程度,包括建设、购买和维护。你的目标是对系统进行威胁评估(超出本书范围),足以阻止潜在的黑客。在大多数情况下,如果未能执行典型的攻击,如 SQL 注入,黑客将评估攻击你的系统的成本,然后转向耗时或成本更低的其他系统。本节提供了解决高成本安全灾难的低成本选项。

本节的场景设想了一个用户可以执行搜索的情况。他们填写数据,应用程序根据用户输入的条件动态构建查询。

在解决方案中,Program 类有一个名为 GetCriteriaFromUser 的方法。此方法的目的是为 SalesPerson 内的每个字段询问匹配值。这些值成为构建动态查询的标准。如果任何字段为空,则不会包含在最终查询中。

QuerySalesPeople 方法从 ctx.SalesPeople 开始一个 LINQ 查询。然而,请注意,这不像前几节那样放在括号中或调用 ToList 操作符。调用 ToList 会实现查询,导致其执行。但是,在这里我们没有这样做 - 代码只是在构建查询。这就是为什么 salesPersonQuery 具有 IEnumerable<SalesPerson> 类型,表示它是 LINQ 到对象的结果,而不是通过调用 ToList 获得的 List<SalesPerson>

注意

此配方利用了 LINQ 的一项功能,称为 延迟查询执行,它允许您构建查询,直到您告诉它执行。除了促进动态查询构建外,延迟执行还非常高效,因为仅发送一个查询到数据库,而不是每次算法调用特定的 LINQ 操作符时都发送查询。

使用 salesPersonQuery 引用,代码检查每个 SalesPerson 字段是否有值。如果用户为该字段输入了值,则代码使用 Where 操作符检查与用户输入的值是否相等。

注意

在前几节中,您已经看到了使用语言语法的 LINQ 查询。但是,本节利用了另一种使用 LINQ 的方式,即通过流畅接口称为 方法语法。这与您在 Recipe 1.10, “Constructing Objects with Complex Configuration” 中了解到的建造者模式非常相似。

到目前为止,唯一发生的事情是我们动态构建了一个 LINQ 查询,并且由于延迟执行的原因,该查询尚未运行。最后,代码在 salesPersonQuery 上调用 ToList,实现了查询。由于此方法的返回类型,这将返回一个 List<SalesPerson>

现在,算法已经构建并执行了一个动态查询,从 SQL 注入攻击中受到保护。这种保护来自于 LINQ 提供程序始终将用户输入参数化,因此它将被视为参数数据,而不是查询的一部分。作为一个副作用,您还拥有一个强类型代码的方法,不必担心意外和难以找到的拼写错误。

参见

Recipe 1.10, “构建具有复杂配置的对象”

4.6 查询不同对象

问题

您有一个对象列表,其中包含重复项,并且需要将其转换为唯一对象的不同列表。

解决方案

这是一个不支持不同查询的对象:

public class SalesPerson
{
    public int ID { get; set; }

    public string Name { get; set; }

    public string Address { get; set; }

    public string City { get; set; }

    public string PostalCode { get; set; }

    public string Region { get; set; }

    public string ProductType { get; set; }
}

下面是如何修复该对象以支持不同查询的方法:

public class SalesPersonComparer : IEqualityComparer<SalesPerson>
{
    public bool Equals(SalesPerson x, SalesPerson y)
    {
        return x.ID == y.ID;
    }

    public int GetHashCode(SalesPerson obj)
    {
        return obj.GetHashCode();
    }
}

public class SalesPerson
{
    public int ID { get; set; }

    public string Name { get; set; }

    public string Address { get; set; }

    public string City { get; set; }

    public string PostalCode { get; set; }

    public string Region { get; set; }

    public string ProductType { get; set; }
}

这是数据源:

public class InMemoryContext
{
    List<SalesPerson> salesPeople =
        new List<SalesPerson>
        {
            new SalesPerson
            {
                ID = 1,
                Address = "123 1st Street",
                City = "First City",
                Name = "First Person",
                PostalCode = "45678",
                Region = "Region #1",
                ProductType = "Type 2"
            },
            new SalesPerson
            {
                ID = 2,
                Address = "234 2nd Street",
                City = "Second City",
                Name = "Second Person",
                PostalCode = "56789",
                Region = "Region #2",
                ProductType = "Type 3"
            },
            new SalesPerson
            {
                ID = 3,
                Address = "345 3rd Street",
                City = "Third City",
                Name = "Third Person",
                PostalCode = "67890",
                Region = "Region #3",
                ProductType = "Type 1"
            },
            new SalesPerson
            {
                ID = 4,
                Address = "678 9th Street",
                City = "Fourth City",
                Name = "Fourth Person",
                PostalCode = "90123",
                Region = "Region #1",
                ProductType = "Type 2"
            },
            new SalesPerson
            {
                ID = 4,
                Address = "678 9th Street",
                City = "Fourth City",
                Name = "Fourth Person",
                PostalCode = "90123",
                Region = "Region #1",
                ProductType = "Type 2"
            },
        };

    public List<SalesPerson> SalesPeople => salesPeople;
}

此代码按独特对象进行过滤:

class Program
{
    static void Main(string[] args)
    {
        var salesPeopleWithoutComparer =
            (from person in new InMemoryContext().SalesPeople
             select person)
            .Distinct()
            .ToList();

        PrintResults(salesPeopleWithoutComparer, "Without Comparer");

        var salesPeopleWithComparer =
            (from person in new InMemoryContext().SalesPeople
             select person)
            .Distinct(new SalesPersonComparer())
            .ToList();

        PrintResults(salesPeopleWithComparer, "With Comparer");
    }

    static void PrintResults(List<SalesPerson> salesPeople, string title)
    {
        Console.WriteLine($"\n{title}\n");

        salesPeople.ForEach(person =>
            Console.WriteLine($"{person.ID}. {person.Name}"));
    }
}

讨论

有时你会有一个包含重复实体的列表,可能是因为某些应用程序处理或数据库查询类型导致的。通常,你需要一个唯一对象的列表。例如,你正在使用不允许重复的 Dictionary 集合进行实体化。

LINQ 的 Distinct 运算符帮助获取唯一对象的列表。乍一看,这很容易,就像 Main 方法的第一个查询所示,使用了 Distinct() 运算符。请注意,它没有参数。然而,检查结果会显示,数据中仍然存在与开始时相同的重复项。

问题及随后的解决方案可能不会立即显而易见,因为它依赖于结合了几个不同的 C# 概念。首先,考虑一下 Distinct 应该如何区分对象之间的差异——它必须执行比较。接下来,考虑到 SalesPerson 的类型是 class。这很重要,因为类是引用类型,具有引用相等性。当 Distinct 进行引用比较时,没有两个对象引用是相同的,因为每个对象都有一个唯一的引用。最后,你需要编写代码来比较 SalesPerson 实例,以确定它们是否相等,并告诉 Distinct 这段代码。

SalesPerson 类是一个基本类,具有属性,并且不包含任何指示如何执行相等性的语法。相反,SalesPersonComparer 实现了 IEqualityComparer<SalesPerson>SalesPerson 类不起作用,因为它具有引用相等性。然而,实现了 IEqualityComparer<SalesPerson>SalesPersonComparer 类能够正确比较,因为它具有一个 Equals 方法。在这种情况下,检查 ID 是否足以确定实例是否相等,假设每个实体来自具有唯一 ID 字段的同一数据源。

SalesPersonComparer 知道如何比较 SalesPerson 实例,但这并不是问题的终点,因为它与查询没有任何关联。如果你在 Main 中运行第一个没有参数的查询 Distinct(),结果仍然会有重复。问题在于 Distinct 不知道如何比较对象,因此默认使用实例类型 class,正如前面解释的那样,它是引用类型。

解决方案是在 Main 中使用第二个带有 Distinct(new SalesPersonComparer()) 调用的查询。这使用了带有 IEqualityComparer<T> 参数的 Distinct 运算符的重载。由于 SalesPersonComparer 实现了 IEqualityComparer<SalesPerson>,这个方法可以实现。

参见

第 2.5 节,“检查类型相等性”

4.7 简化查询

问题

查询变得过于复杂,需要使其更易读。

解决方案

这是要查询的实体:

public class SalesPerson
{
    public int ID { get; set; }

    public string Name { get; set; }

    public string Address { get; set; }

    public string City { get; set; }

    public string PostalCode { get; set; }

    public string Region { get; set; }

    public string ProductType { get; set; }

    public string TotalSales { get; set; }
}

这是数据源:

public class InMemoryContext
{
    List<SalesPerson> salesPeople =
        new List<SalesPerson>
        {
            new SalesPerson
            {
                ID = 1,
                Address = "123 1st Street",
                City = "First City",
                Name = "First Person",
                PostalCode = "45678",
                Region = "Region #1",
                ProductType = "Type 2",
                TotalSales = "654.32"
            },
            new SalesPerson
            {
                ID = 2,
                Address = "234 2nd Street",
                City = "Second City",
                Name = "Second Person",
                PostalCode = "56789",
                Region = "Region #2",
                ProductType = "Type 3",
                TotalSales = "765.43"
            },
            new SalesPerson
            {
                ID = 3,
                Address = "345 3rd Street",
                City = "Third City",
                Name = "Third Person",
                PostalCode = "67890",
                Region = "Region #3",
                ProductType = "Type 1",
                TotalSales = "876.54"
            },
            new SalesPerson
            {
                ID = 4,
                Address = "678 9th Street",
                City = "Fourth City",
                Name = "Fourth Person",
                PostalCode = "90123",
                Region = "Region #1",
                ProductType = "Type 2",
                TotalSales = "987.65"
            },
            new SalesPerson
            {
                ID = 4,
                Address = "678 9th Street",
                City = "Fourth City",
                Name = "Fourth Person",
                PostalCode = "90123",
                Region = "Region #1",
                ProductType = "Type 2",
                TotalSales = "109.87"
            },
        };

    public List<SalesPerson> SalesPeople => salesPeople;
}

以下显示了如何简化查询投影:

class Program
{
    static void Main(string[] args)
    {
        decimal TotalSales = 0;

        var salesPeopleWithAddresses =
            (from person in new InMemoryContext().SalesPeople
             let FullAddress =
             $"{person.Address}\n" +
             $"{person.City}, {person.PostalCode}"
             let salesOkay =
                 decimal.TryParse(person.TotalSales, out TotalSales)
             select new
             {
                person.ID,
                person.Name,
                FullAddress,
                TotalSales
             })
            .ToList();

        Console.WriteLine($"\nSales People and Addresses\n");

        salesPeopleWithAddresses.ForEach(person =>
            Console.WriteLine(
                $"{person.ID}. {person.Name}: {person.TotalSales:C}\n" +
                $"{person.FullAddress}\n"));
    }
}

讨论

有时 LINQ 查询会变得复杂。如果代码仍然难以阅读,那么维护也很困难。一种选择是转为命令式语言并将查询重写为循环。另一种选择是使用 let 子句进行简化。

在解决方案中,Main 方法具有一个查询,该查询将投影到匿名类型中。有时查询会因为在投影中有子查询或其他逻辑,例如在 let 子句中构建的 FullAddress,而变得复杂。如果没有这种简化,代码可能会完全进入投影中。

另一个可能遇到的情景是解析来自字符串的对象输入。示例中使用了 TryParselet 子句中,这在投影中是不可能的。这有点棘手,因为 out 参数 TotalSales 是在查询之外的。我们忽略 TryParse 的结果,但现在可以在投影中分配 TotalSales

4.8 操作集合

问题

您想要将两组对象组合在一起,避免重复。

解决方案

这是要查询的实体:

public class SalesPerson : IEqualityComparer<SalesPerson>
{
    public int ID { get; set; }

    public string Name { get; set; }

    public string Address { get; set; }

    public string City { get; set; }

    public string PostalCode { get; set; }

    public string Region { get; set; }

    public string ProductType { get; set; }

    public bool Equals(SalesPerson x, SalesPerson y)
    {
        return x.ID == y.ID;
    }

    public int GetHashCode(SalesPerson obj)
    {
        return ID.GetHashCode();
    }
}

这是数据源:

public class InMemoryContext
{
    List<SalesPerson> salesPeople =
        new List<SalesPerson>
        {
            new SalesPerson
            {
                ID = 1,
                Address = "123 1st Street",
                City = "First City",
                Name = "First Person",
                PostalCode = "45678",
                Region = "Region #1",
                ProductType = "Type 2"
            },
            new SalesPerson
            {
                ID = 2,
                Address = "234 2nd Street",
                City = "Second City",
                Name = "Second Person",
                PostalCode = "56789",
                Region = "Region #2",
                ProductType = "Type 3"
            },
            new SalesPerson
            {
                ID = 3,
                Address = "345 3rd Street",
                City = "Third City",
                Name = "Third Person",
                PostalCode = "67890",
                Region = "Region #3",
                ProductType = "Type 1"
            },
            new SalesPerson
            {
                ID = 4,
                Address = "678 9th Street",
                City = "Fourth City",
                Name = "Fourth Person",
                PostalCode = "90123",
                Region = "Region #1",
                ProductType = "Type 2"
            },
        };

    public List<SalesPerson> SalesPeople => salesPeople;
}

这段代码展示了如何执行集合操作:

class Program
{
    static InMemoryContext ctx = new InMemoryContext();

    static void Main()
    {
        System.Console.WriteLine("\nLINQ Set Operations");

        DoUnion();
        DoExcept();
        DoIntersection();

        System.Console.WriteLine("\nComplete.\n");
    }

    static void DoUnion()
    {
        var dataSource1 =
            (from person in ctx.SalesPeople
             where person.ID < 3
             select person)
            .ToList();

        var dataSource2 =
            (from person in ctx.SalesPeople
             where person.ID > 2
             select person)
            .ToList();

        List<SalesPerson> union =
            dataSource1
                .Union(dataSource2, new SalesPerson())
                .ToList();

        PrintResults(union, "Union Results");
    }

    static void DoExcept()
    {
        var dataSource1 =
            (from person in ctx.SalesPeople
             select person)
            .ToList();

        var dataSource2 =
            (from person in ctx.SalesPeople
             where person.ID == 4
             select person)
            .ToList();

        List<SalesPerson> union =
            dataSource1
                .Except(dataSource2, new SalesPerson())
                .ToList();

        PrintResults(union, "Except Results");
    }

    static void DoIntersection()
    {
        var dataSource1 =
            (from person in ctx.SalesPeople
             where person.ID < 4
             select person)
            .ToList();

        var dataSource2 =
            (from person in ctx.SalesPeople
             where person.ID > 2
             select person)
            .ToList();

        List<SalesPerson> union =
            dataSource1
                .Intersect(dataSource2, new SalesPerson())
                .ToList();

        PrintResults(union, "Intersect Results");
    }

    static void PrintResults(List<SalesPerson> salesPeople, string title)
    {
        Console.WriteLine($"\n{title}\n");

        salesPeople.ForEach(person =>
            Console.WriteLine($"{person.ID}. {person.Name}"));
    }
}

讨论

在配方 4.2 中,我们讨论了从两个不同数据源中连接数据的概念。示例在相同的精神中操作,并展示了基于集合的不同操作。

第一个方法 DoUnion 获取两组数据,并通过 ID 进行有意义的过滤以确保重叠。从第一个数据源的引用中,代码调用 Union 运算符并以第二个数据源作为参数。这将导致从两个数据源获取的数据集,包括重复数据。

DoExcept 方法类似于 DoUnion,但使用 Except 运算符。这将导致第一个数据源中所有对象的集合。然而,任何在第二个数据源中的对象,即使它们曾经在第一个数据源中,也不会出现在结果中。

最后,DoIntersect 在结构上类似于 DoUnionDoExcept。然而,它查询的对象只存在于两个数据源中。如果某个对象只存在于一个数据源中而不在另一个数据源中,则不会出现在结果中。这种操作称为集合理论中的差异

LINQ 具有许多标准运算符,就像集合运算符一样,非常强大。在执行 LINQ 查询中的任何复杂操作之前,最好查看标准运算符,看看是否存在可以简化任务的内容。

参见

配方 4.2,“连接数据”

配方 4.3,“执行左连接”

4.9 用表达式树构建查询过滤器

问题

LINQ 的 where 子句通过 AND 条件组合,但您需要一个动态的 where,它作为 OR 条件工作。

解决方案

这是要查询的实体:

public class SalesPerson
{
    public int ID { get; set; }

    public string Name { get; set; }

    public string Address { get; set; }

    public string City { get; set; }

    public string PostalCode { get; set; }

    public string Region { get; set; }

    public string ProductType { get; set; }
}

这是数据源:

public class InMemoryContext
{
    List<SalesPerson> salesPeople =
        new List<SalesPerson>
        {
            new SalesPerson
            {
                ID = 1,
                Address = "123 1st Street",
                City = "First City",
                Name = "First Person",
                PostalCode = "45678",
                Region = "Region #1",
                ProductType = "Type 2"
            },
            new SalesPerson
            {
                ID = 2,
                Address = "234 2nd Street",
                City = "Second City",
                Name = "Second Person",
                PostalCode = "56789",
                Region = "Region #2",
                ProductType = "Type 3"
            },
            new SalesPerson
            {
                ID = 3,
                Address = "345 3rd Street",
                City = "Third City",
                Name = "Third Person",
                PostalCode = "67890",
                Region = "Region #3",
                ProductType = "Type 1"
            },
            new SalesPerson
            {
                ID = 4,
                Address = "678 9th Street",
                City = "Fourth City",
                Name = "Fourth Person",
                PostalCode = "90123",
                Region = "Region #1",
                ProductType = "Type 2"
            },
        };

    public List<SalesPerson> SalesPeople => salesPeople;
}

这里有一个过滤的 OR 操作的扩展方法:

public static class CookbookExtensions
{
    public static IEnumerable<TParameter> WhereOr<TParameter>(
        this IEnumerable<TParameter> query,
        Dictionary<string, string> criteria)
    {
        const string ParamName = "person";

        ParameterExpression paramExpr =
            Expression.Parameter(typeof(TParameter), ParamName);

        Expression accumulatorExpr = null;

        foreach (var criterion in criteria)
        {
            MemberExpression paramMbr =
                LambdaExpression.PropertyOrField(
                    paramExpr, criterion.Key);

            MemberExpression leftExpr =
                Expression.Property(
                    paramExpr,
                    typeof(TParameter).GetProperty(criterion.Key));
            Expression rightExpr =
                Expression.Constant(criterion.Value, typeof(string));
            Expression equalExpr =
                Expression.Equal(leftExpr, rightExpr);

            accumulatorExpr = accumulatorExpr == null
                ? equalExpr
                : Expression.Or(accumulatorExpr, equalExpr);
        }

        Expression<Func<TParameter, bool>> allClauses =
            Expression.Lambda<Func<TParameter, bool>>(
                accumulatorExpr, paramExpr);

        Func<TParameter, bool> compiledClause = allClauses.Compile();

        return query.Where(compiledClause);
    }
}

这是消耗新扩展方法的代码:

class Program
{
    static void Main()
    {
        SalesPerson searchCriteria = GetCriteriaFromUser();

        List<SalesPerson> salesPeople = QuerySalesPeople(searchCriteria);

        PrintResults(salesPeople);
    }

    static SalesPerson GetCriteriaFromUser()
    {
        var person = new SalesPerson();

        Console.WriteLine("Sales Person Search");
        Console.WriteLine("(press Enter to skip an entry)\n");

        Console.Write($"{nameof(SalesPerson.Address)}: ");
        person.Address = Console.ReadLine();

        Console.Write($"{nameof(SalesPerson.City)}: ");
        person.City = Console.ReadLine();

        Console.Write($"{nameof(SalesPerson.Name)}: ");
        person.Name = Console.ReadLine();

        Console.Write($"{nameof(SalesPerson.PostalCode)}: ");
        person.PostalCode = Console.ReadLine();

        Console.Write($"{nameof(SalesPerson.ProductType)}: ");
        person.ProductType = Console.ReadLine();

        Console.Write($"{nameof(SalesPerson.Region)}: ");
        person.Region = Console.ReadLine();

        return person;
    }

    static List<SalesPerson> QuerySalesPeople(SalesPerson criteria)
    {
        var ctx = new InMemoryContext();

        var filters = new Dictionary<string, string>();

        IEnumerable<SalesPerson> salesPeopleQuery =
            from people in ctx.SalesPeople
            select people;

        if (!string.IsNullOrWhiteSpace(criteria.Address))
            filters[nameof(criteria.Address)] = criteria.Address;

        if (!string.IsNullOrWhiteSpace(criteria.City))
            filters[nameof(criteria.City)] = criteria.City;

        if (!string.IsNullOrWhiteSpace(criteria.Name))
            filters[nameof(criteria.Name)] = criteria.Name;

        if (!string.IsNullOrWhiteSpace(criteria.PostalCode))
            filters[nameof(criteria.PostalCode)] = criteria.PostalCode;

        if (!string.IsNullOrWhiteSpace(criteria.ProductType))
            filters[nameof(criteria.ProductType)] = criteria.ProductType;

        if (!string.IsNullOrWhiteSpace(criteria.Region))
            filters[nameof(criteria.Region)] = criteria.Region;

        salesPeopleQuery =
            salesPeopleQuery.WhereOr<SalesPerson>(filters);

        List<SalesPerson> salesPeople = salesPeopleQuery.ToList();

        return salesPeople;
    }

    static void PrintResults(List<SalesPerson> salesPeople)
    {
        Console.WriteLine("\nSales People\n");

        salesPeople.ForEach(person =>
            Console.WriteLine($"{person.ID}. {person.Name}"));
    }
}

讨论

Recipe 4.5 展示了在 LINQ 中动态查询的强大功能。然而,这并不是你可以做的全部。使用表达式树,您可以利用 LINQ 进行任何类型的查询。如果标准运算符不能提供您需要的内容,您可以使用表达式树。本节正是如此,展示了如何使用表达式树来运行动态的 WhereOr 操作。

WhereOr 的动机源于标准的 Where 运算符组合为 AND 比较的事实。在 Recipe 4.5 中,所有这些 Where 运算符都具有隐式的 AND 关系。这意味着给定实体必须具有与用户在标准中指定的每个字段相等的值才能匹配。在本节中,通过 WhereOr,所有字段都具有 OR 关系,仅需要匹配一个字段即可包含在结果中。

在解决方案中,GetCriteriaFromUser 方法获取每个 SalesPerson 属性的值。QuerySalesPeople 启动了一个延迟执行的查询,正如 Recipe 4.5 中所解释的那样,并构建了一个 Dictionary<string, string> 类型的过滤器。

CookbookExtensions 类有一个接受过滤器的 WhereOr 扩展方法。WhereOr 要完成的高级描述来自于它需要为调用者返回一个 IEnumerable<SalesPerson>,以完成 LINQ 查询。

首先,转到 WhereOr 的底部,注意它返回带有 Where 运算符的查询,并且有一个名为 compiledQuery 的参数。请记住,LINQ 的 Where 运算符接受一个带有参数和谓词的 C# lambda 表达式。我们希望一个过滤器,如果对象的任何一个字段匹配基于输入条件,则 compiledQuery 必须评估为以下形式的 lambda:

person => person.Field1 == "val1" || ... || person.FieldN == "valN"

这是一个带有 OR 运算符的 lambda 表达式,它使用 Dictionary<string, string> criteria 参数的每个值。为了从算法的顶部到底部,我们需要构建一个表达式树,该树评估为此形式的 lambda。Figure 4-1 显示了此代码的作用。

使用 OR 运算符分隔子句构建 Where 表达式

图 4-1. 使用 OR 运算符分隔子句构建 Where 表达式

Figure 4-1 展示了解决方案创建的表达式树。在这里,我们假设用户想要查询四个值:CityNameProductTypeRegion。表达式树按深度优先、从左到右的方式读取,每个框表示一个节点。因此,LINQ 沿着左侧向下遍历树,直到找到叶子节点,即 City 表达式。然后它向上移动到找到 OR,再向右移动找到 Name 表达式,并构建 OR 表达式。到目前为止,LINQ 已构建了以下子句:

City == 'MyCity' || Name == 'Joe'

LINQ 继续读取表达式树,直到最终构建以下子句:

City == 'MyCity' || Name == 'Joe' || ProductType == 'Widgets' || Region == 'West'

回到解决方案代码,WhereOr 首先创建了一个 ParameterExpression。这是 lambda 中的 person 参数。它是每个比较表达式的参数,因为它表示 TParameter,在本例中是 SalesPerson 的实例。

注意

此示例称为 ParameterExpressionperson。但是,如果这是一个通用的可重用扩展方法,您可能会给它一个更一般的名称,例如 parameterTerm,因为 TParameter 可以是任何类型。在此示例中选择 person 是为了澄清 ParameterExpression 在本例中表示一个 SalesPerson 实例。

Expression accumulatorExpr,正如其名称所示,收集 lambda 主体的所有子句。

foreach 语句循环遍历 Dictionary 集合,返回 KeyValuePair 实例,这些实例具有 KeyValue 属性。如 QuerySalesPeople 方法所示,Key 属性是 SalesPerson 属性的名称,而 Value 属性是用户输入的内容。

对于 lambda 的每个子句,左侧是对 SalesPerson 实例上属性的引用(例如 person.Name)。为了创建这个,代码使用 paramExpr 实例化了 paramMbr(即 person)。这成为 leftExpr 的参数。rightExpr 表达式是一个常量,它保存了要比较的值及其类型。然后,我们需要使用左侧和右侧表达式(分别是 leftExprrightExpr)完成表达式与 Equals 表达式。

最后,我们需要将该表达式与其他表达式进行 OR 运算。在 foreach 循环的第一次迭代中,accumulatorExpr 将为 null,因此我们只分配第一个表达式。在后续表达式中,我们使用 OR 表达式将新的 Equals 表达式附加到 accumulatorExpr

遍历每个输入字段后,我们形成了最终的 LambdaExpression,它添加了在每个 Equals 表达式左侧使用的参数。请注意,结果是一个 Expression<Func<TParameter, bool>>,它的参数类型与原始查询的 lambda 委托类型匹配,即 Func<SalesPerson, bool>

现在我们有一个动态构建的表达式树,准备转换为可运行的代码,这是 Expression.Compile 方法的任务。这给了我们一个编译的 lambda,我们可以传递给 Where 子句。

调用代码从 WhereOr 方法接收 IEnumerable<SalesPerson>,并通过调用 ToList 材料化查询。这产生了一个 SalesPerson 对象的列表,这些对象至少匹配用户指定的一个条件。

参见

Recipe 4.5, “Building Incremental Queries”

4.10 并行查询

问题

您希望提高性能,您的查询可能会从多线程中获益。

解决方案

这是要查询的实体:

public class SalesPerson
{
    public int ID { get; set; }

    public string Name { get; set; }

    public string Address { get; set; }

    public string City { get; set; }

    public string PostalCode { get; set; }

    public string Region { get; set; }

    public string ProductType { get; set; }
}

这是数据源:

public class InMemoryContext
{
    List<SalesPerson> salesPeople =
        new List<SalesPerson>
        {
            new SalesPerson
            {
                ID = 1,
                Address = "123 1st Street",
                City = "First City",
                Name = "First Person",
                PostalCode = "45678",
                Region = "Region #1",
                ProductType = "Type 2"
            },
            new SalesPerson
            {
                ID = 2,
                Address = "234 2nd Street",
                City = "Second City",
                Name = "Second Person",
                PostalCode = "56789",
                Region = "Region #2",
                ProductType = "Type 3"
            },
            new SalesPerson
            {
                ID = 3,
                Address = "345 3rd Street",
                City = "Third City",
                Name = "Third Person",
                PostalCode = "67890",
                Region = "Region #3",
                ProductType = "Type 1"
            },
            new SalesPerson
            {
                ID = 4,
                Address = "678 9th Street",
                City = "Fourth City",
                Name = "Fourth Person",
                PostalCode = "90123",
                Region = "Region #1",
                ProductType = "Type 2"
            },
            new SalesPerson
            {
                ID = 5,
                Address = "678 9th Street",
                City = "Fifth City",
                Name = "Fifth Person",
                PostalCode = "90123",
                Region = "Region #1",
                ProductType = "Type 2"
            },
        };

    public List<SalesPerson> SalesPeople => salesPeople;
}

此代码显示如何执行并行查询:

class Program
{
    static void Main()
    {
        List<SalesPerson> salesPeople = new InMemoryContext().SalesPeople;
        var result =
            (from person in salesPeople.AsParallel()
             select ProcessPerson(person))
            .ToList();
    }

    static SalesPerson ProcessPerson(SalesPerson person)
    {
        Console.WriteLine(
            $"Starting sales person " +
            $"#{person.ID}. {person.Name}");

        // complex in-memory processing
        Thread.Sleep(500);

        Console.WriteLine(
            $"Completed sales person " +
            $"#{person.ID}. {person.Name}");

        return person;
    }
}

讨论

本节考虑可以从并发中受益的查询。想象一下,您有一个 LINQ 到对象的查询,其中数据存储在内存中。也许每个实例的工作需要密集处理,代码在多线程/多核 CPU 上运行,并且/或者需要花费相当大的时间。在并行中运行查询可能是一个选择。

Main 方法执行一个查询,与任何其他查询类似,除了在数据源上使用 AsParallel 操作符。这样做的效果是让 LINQ 确定如何分割工作并并行操作每个范围变量。图 4-2 显示了这个查询在做什么。

PLINQ 在集合成员中并行运行

图 4-2. PLINQ 在集合成员中并行运行

图 4-2 显示了左侧的 salesPeople 集合。当查询运行时,它会并行处理多个集合对象,这些对象由从 salesPeople 指向每个 SalesPerson 实例的分割箭头表示。处理完成后,查询将每个对象的处理响应组合成一个名为 result 的新集合。

注意

此示例使用一种名为并行 LINQ (PLINQ) 的 LINQ 技术。在幕后,PLINQ 对查询进行评估,进行各种运行时优化,如并行度。它甚至足够智能,可以判断在给定机器上启动新线程的开销是否比同步运行更快。

此示例还演示了另一种使用方法返回对象的投影类型。这里的假设是密集处理发生在 ProcessPerson 中,该方法使用 Thread.Sleep 模拟非平凡处理。

在实践中,您需要进行一些测试,以确定您是否真正从并行性中受益。配方 3.10 展示了如何使用 System.Diagnostics.StopWatch 类来测量性能。如果成功,这可能是提升应用程序性能的一种简单方式。

参见

配方 3.10,“性能测量”