C-8-和--NET-Core3-Azure-项目-全-

C#8 和 .NET Core3 Azure 项目(全)

原文:C# 8 and .NET Core 3 Projects Using Azure

协议:CC BY-NC-SA 4.0

零、前言

.NET Core 是. NET 的通用、模块化、跨平台和开源实现.NET Core 3 具有改进的性能以及对桌面应用的支持。.NET Core 3 不仅应该吸引新开发人员开始学习框架,还应该说服旧开发人员开始迁移他们的应用。

这本书是第二版 C# 7 和.NET Core 2.0 蓝图,更新了 C# 8 和的最新特性和增强功能.NET Core 3.0。这本书是一个全面的指南,提供了 10 个真实世界的企业应用。它将帮助您同时学习和实现这些概念,并通过在 ASP.NET Core 和 Azure 上构建满足现代软件要求的有效应用来推进。

我们将使用实体框架核心 3 处理关系数据,并使用 ASP.NET Core 创建一个真实世界的网络应用。我们将看看读者如何将他们的旧 WinForms 应用升级到的最新版本.NET Core。我们还将使用 SignalR 创建实时聊天应用。最后,我们将学习使用 Azure Storage 的无服务器计算,以及如何使用 Docker 和 Kubernetes 构建负载平衡的订单处理微服务。

总而言之,这本书将教你 web 应用、无服务器计算和使用各种项目和应用的微服务的核心概念。按照这一步一步的指南,您将能够创建一个 ASP.NET Core MVC 应用,并使用微软 Azure 的尖端服务构建现代应用。

这本书是给谁的

这本书是为业余开发人员/程序员以及希望构建真实世界的项目并学习的新功能的专业人士准备的.NET Core 3。对于开发传统桌面软件并希望迁移到的开发人员来说,这也很有用.NET Core 3。的基本知识.NET Core 和 C#是假设的。

这本书涵盖了什么

第 1 章电子书管理器和目录应用–。Windows 桌面上的 NET Core 3,介绍的关键特性.NET Core 3–主要功能是支持中的桌面应用.NET Core。您将基于本书的早期版本创建一个 WinForms 应用,并将其升级为使用.NET Core 3。然后我们将介绍 XAML 群岛,并使用 UWP 创建一个新的桌面控件,并将其添加到现有的 WinForms 应用中。

第二章任务 Bug 记录 ASP.NET Core MVC 应用使用 Cosmos DB ,重点创建一个 ASP.NET Core MVC 应用,允许用户捕捉任务和记录问题。该应用将允许您查看捕获的任务并采取行动。

第三章ASP.NET 天蓝色 SignalR 聊天应用,使用 ASP.NET SignalR 创建实时聊天应用。实时 web 功能是服务器端代码将内容实时推送到连接的客户端的能力。一旦创建,我们将创建一个 Azure 应用服务实例,并在那里托管应用。

第 4 章带实体框架核心的 Web 研究工具,向大家介绍实体框架核心,并向大家展示如何创建一个 ASP.NET Core MVC 应用,可用于保存链接和社交媒体帖子以供研究之用。很多这样的应用都存在,比如 Instapaper 和 Evernote。但是,这个应用将向您展示如何滚动自己的应用并添加特定的功能。

第 5 章使用 Azure 逻辑应用和功能构建 Twitter 自动活动管理器,调查来自 Azure 的逻辑应用。本章将指导您创建一个逻辑应用,将该应用集成到推特上,并允许用户将数据输入电子表格,并自动将其发布在推特上。

第 6 章使用身份服务器和 OAuth 2 的股票检查器,说明了使用身份服务器 OSS 作为模板进行身份验证的概念。本章指导您创建自己的身份服务器,然后从 UWP 应用登录到该服务器。

第 7 章使用 Windows 服务和 Azure Storage 构建照片存储应用,阐述了无服务器计算的概念。您将创建一个应用,将用户电脑上的照片备份到 Azure Storage。如今有许多备份服务可供用户使用。Azure Blob 存储只是这样一种服务,它允许开发人员创建利用微软服务器存储文件的应用。

第 8 章使用 Docker 和 Azure Kubernetes 服务的负载平衡订单处理微服务首先介绍了微服务的概念,并解释了它们是什么以及为什么要使用它们。在本章中,我们将介绍分布式系统的概念。我们将构建一个微服务,在 Azure Kubernetes 上配置一个 Kubernetes 集群,并使用存储队列与我们的微服务接口。

第 9 章使用 Xamarin Forms 和 Azure 认知服务的情绪检测器移动应用,使用 Xamarin.Forms 创建了一个移动应用,在这一章中,我们将与 Azure 认知服务和设备上的摄像头集成,允许用户拍摄一张人脸照片,并让 Azure 回来时对该人的情绪进行评级。然后我们会在屏幕上显示我们认为那个人在感受什么。我们将把它交叉编译到安卓系统中。

第 10 章21 世纪的伊莱扎-UWP 和微软机器人框架,使用.NET Core 3。这将是一个简单的聊天应用,但将与 LUIS 和一个旨在通过图灵测试的微软聊天机器人接口。

附录 AWebAssembly 涵盖了 WebAssembly,它最近被集成到所有主要浏览器中,并允许将代码编译到 WASM(浏览器的一种 IL)。微软最近发布了一个名为 Blazor 的东西的预览版,允许 Razor 语法代替 JavaScript 运行。

充分利用这本书

为了遵循本书中给出的说明,您需要具备以下先决条件:

  • Azure 订阅
  • 可视化工作室
  • Excel/Office 联机副本
  • OneDrive 帐户
  • 推特
  • 邮递员

下载示例代码文件

你可以从你在www.packt.com的账户下载这本书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packt.com/support并注册将文件直接通过电子邮件发送给您。

您可以按照以下步骤下载代码文件:

  1. 登录或注册www.packt.com
  2. 选择“支持”选项卡。
  3. 点击代码下载和勘误表。
  4. 在搜索框中输入图书的名称,并按照屏幕指示进行操作。

下载文件后,请确保使用最新版本的解压缩文件夹:

  • 视窗系统的 WinRAR/7-Zip
  • zipeg/izp/un ARX for MAC
  • 适用于 Linux 的 7-Zip/PeaZip

这本书的代码包也托管在 https://github.com/PacktPublishing/C-8-and-.的 GitHub 上 NET-Core-3-项目-使用-Azure-第二版。如果代码有更新,它将在现有的 GitHub 存储库中更新。

我们还有来自丰富的图书和视频目录的其他代码包,可在【https://github.com/PacktPublishing/】获得。看看他们!

下载彩色图像

我们还提供了一个 PDF 文件,其中包含本书中使用的截图/图表的彩色图像。可以在这里下载:https://static . packt-cdn . com/downloads/9781789612080 _ color images . pdf

使用的约定

本书通篇使用了许多文本约定。

CodeInText:表示文本中的码字、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟网址、用户输入和推特句柄。下面是一个例子:“打开Document.cs文件,将下面的代码添加到类中。”

代码块设置如下:

public class Document
{
  public string Title { get; set; }
  public string FileName { get; set; }
  public string Extension { get; set; }
  public DateTime LastAccessed { get; set; }
  public DateTime Created { get; set; }
  public string FilePath { get; set; }
  public string FileSize { get; set; }
}

当我们希望将您的注意力吸引到代码块的特定部分时,相关的行或项目以粗体显示:

public class Document
{
  public string Title { get; set; }
  public string FileName { get; set; }
 public string Extension { get; set; }
  public DateTime LastAccessed { get; set; }
  public DateTime Created { get; set; }
  public string FilePath { get; set; }
  public string FileSize { get; set; }
}

粗体:表示一个新的术语、一个重要的单词或者你在屏幕上看到的单词。例如,菜单或对话框中的单词像这样出现在文本中。这里有一个例子:“还有其他选项可以配置可空选项。”

Warnings or important notes appear like this. Tips and tricks appear like this.

取得联系

我们随时欢迎读者的反馈。

一般反馈:如果你对这本书的任何方面有疑问,在你的信息主题中提到书名,发邮件给我们customercare@packtpub.com

勘误表:虽然我们已经尽了最大的努力来保证内容的准确性,但是错误还是会发生。如果你在这本书里发现了一个错误,如果你能向我们报告,我们将不胜感激。请访问www.packt.com/submit-errata,选择您的图书,点击勘误表提交链接,并输入详细信息。

盗版:如果您在互联网上遇到任何形式的我们作品的非法拷贝,如果您能提供我们的位置地址或网站名称,我们将不胜感激。请通过copyright@packt.com联系我们,并提供材料链接。

如果你有兴趣成为一名作者:如果有一个你有专长的话题,你有兴趣写或者投稿一本书,请访问authors.packtpub.com

复习

请留下评论。一旦你阅读并使用了这本书,为什么不在你购买它的网站上留下评论呢?然后,潜在的读者可以看到并使用您不带偏见的意见来做出购买决定,我们 Packt 可以了解您对我们产品的看法,我们的作者可以看到您对他们的书的反馈。谢谢大家!

更多关于 Packt 的信息,请访问packt.com

一、电子书管理器和目录应用

.NET Core 3 标志着. NET 重新启动的一个重要版本。现在基本框架已经到位,微软已经能够查看那些虽然不再流行,但正在全球数百万台机器上运行的技术。

WinForms 和 WPF 已经成为他们自身成功的受害者:微软根本不敢改变他们周围的框架,冒着破坏可能已经成功运行了几年的应用的风险。

C# 8 有一个类似的主题,它引入了可空引用类型等特性,以及旨在改进遗留代码库的接口实现。

A legacy code base is any code that has already been written, whether that was 10 years or 10 minutes ago!

在第一章中,我们将创建电子书管理器应用。接下来,我们将继续学习我们的电子书管理器.NET Core 2 并将其迁移到.NET Core 3。

英寸 NET Core 2,进行了许多显著的性能增强,因此有一个真正的动力来升级现有的 WinForms 应用.NET Core 3。微软吹嘘说.NET Core 2.1 对 Bing 的性能提升超过 30%。

我们将讨论的主题如下:

  • 在中创建新的 WinForms 应用。网络核心 3.0
  • 将现有 WinForms 应用迁移到。网络核心 3.0
  • 可空引用类型
  • XAML 群岛,以及如何使用它们为现有的 WinForms 应用添加功能
  • 树摇动和编译

技术要求

要继续本章的第一部分,您将需要 WinForms 设计器。撰写本文时,这是预发布版本,可从https://aka.ms/winforms-designer下载。

对于 XAML 群岛部分,您将需要运行 Windows 10 1903 或更高版本。到这本书出版的时候,预计 1903 版本已经自动交付给所有 Windows 10 机器;但是,如果您运行的是早期版本,则可以通过访问以下链接来强制更新:https://www.microsoft.com/en-us/software-download/windows10

At the time of writing, this WinForms designer was nowhere near ready for production. Try it out while following the chapter; however, if you find that it is too glitchy, feel free to copy the designer code from the GitHub project.

创建新的 WinForms 应用

让我们从创建一个新的.NET Core 3.0 WinForms 应用,稍后我们还将看到如何升级旧的.NET Core WinForms 应用到 3.0,这样我们就可以展示实现这一点的两种方式。

To follow this section, you'll need to install the WinForms designer described in the Technical requirements section. It's worth pointing out that this tool is in preview at the time of writing and therefore has a number of limitations, so the instructions have changed in order to cater to those limitations.

使用 Visual Studio 2019,我们将创建一个简单的 Windows 窗体应用模板项目。你可以随意调用应用,但我调用了我的eBookManager:

在 Visual Studio 2019 中,创建新项目的过程略有变化,您需要选择应用的类型,然后选择创建它的位置:

该项目将被创建,如下所示:

Since this is .NET Core, you can do all of this from the command line. In PowerShell, running the following command will create an identical project: dotnet new winforms -n eBookManager.

我们的解决方案需要一个类库项目来包含驱动eBookManager应用的类。英寸 NET Core 3.0,我们可以选择创建. NET Core 类库或. NET 标准类库。

.NET 标准是一个有点奇怪的概念。就其本身而言,它不是一项技术,而是一份合同;创建. NET 标准类库只是防止您使用任何.NET Core—(或框架—)特定的,不符合.NET 标准。下面的文档说明了.NET 标准版,以及哪些版本的 Core 和 Framework 支持它们:https://github . com/dotnet/Standard/blob/master/docs/versions . MD

添加新的类库(.NET 标准)投影到您的解决方案中,并将其称为eBookManager.Engine:

使用默认类名将类库项目添加到解决方案中。将此类更改为Document:

Document类将代表一个电子书。当考虑一本书时,我们可以有多个属性来代表一本书,但这将代表所有的书。作者就是一个例子。所有的书都必须有作者;否则,它们就不会存在。

我添加到类中的属性仅仅是我对一本书的解释。请随意添加额外的代码,使其成为您自己的代码。打开Document.cs文件,将以下代码添加到类中:

public class Document
{
    public string Title { get; set; }
    public string FileName { get; set; }
    public string Extension { get; set; }
    public DateTime LastAccessed { get; set; }
    public DateTime Created { get; set; }
    public string FilePath { get; set; }
    public string FileSize { get; set; }
    public string ISBN { get; set; }
    public string Price { get; set; }
    public string Publisher { get; set; }
    public string Author { get; set; }
    public DateTime PublishDate { get; set; }
    public DeweyDecimal Classification { get; set; }
    public string Category { get; set; }
}

你会注意到我包含了一个名为Classification的属性,类型为DeweyDecimal。我们还没有添加这个类,接下来会添加。在eBookManager.Engine项目中,添加一个名为DeweyDecimal的类。如果你不想为你的电子书进行这种级别的分类,你可以不去上这门课。为了完整起见,我把它包括在内了。我们将介绍一个已经在 Visual Studio 中使用了一段时间的小功能:如果您将鼠标悬停在DeweyDecimal文本上,您将看到一个灯泡出现(您可以通过按住 Ctrl 键和圆点键( Ctrl +)手动调出该菜单。)。在本书的其余部分,我将大量使用这个快捷方式!):

这允许我们通过几次击键创建一个新的类。这也意味着类的名称将与调用代码中的类名相匹配。

You can use the lightbulb menu to create methods, add using statements, and even import NuGet libraries!

DeweyDecimal系统挺大的。由于这个原因,我没有迎合每一个可用的图书分类。我还假设你只想要用电子书编程。然而,实际上,您可能想要添加其他分类,例如文学、科学、艺术等等。这取决于你:

  1. 打开DeweyDecimal类,并在该类中添加以下代码:
public class DeweyDecimal
{
    public string ComputerScience { get; set; } = "000";
    public string DataProcessing { get; set; } = "004";
    public string ComputerProgramming { get; set; } = "005";
}

文字书呆子可能不同意我的观点,但我想提醒他们,我是一个代码书呆子。这里表示的分类只是为了让我能够将编程和计算机科学相关的电子书分类。如前所述,您可以根据自己的需要进行更改。

  1. 我们现在需要在eBookManager.Engine解的中心添加。这是一个名为DocumentEngine的类,将包含处理文档所需的方法:

您的eBookManager.Engine解决方案现在将包含以下类:

  • DeweyDecimal
  • Document
  • DocumentEngine
  1. 我们现在需要从eBookManager项目中添加对eBookManager.Engine的引用:

eBookManager.Engine项目将出现在参考管理器屏幕的项目部分:

  1. 一旦我们添加了引用,我们就需要一个负责导入新书的窗口表单。在eBookManager解决方案中添加一个名为ImportBooks的新表单:

  1. 我们将为扩展方法创建一个单独的项目。添加eBookManager.Helper类库项目(再次作为. NET 标准类库项目):

  1. 我们将从我们的主要项目中引用它(如前):

我们现在已经设置了我们的eBookManager应用所需的基础。接下来,我们将通过编写一些代码来进一步深入应用的内部。

虚拟存储空间和扩展方法

让我们从讨论虚拟存储空间背后的逻辑开始。这是硬盘上几个物理空间的单一虚拟表示。存储空间将被视为存储一组特定电子书的单一区域。我松散地使用“存储”这个术语,因为存储空间不存在。它比硬盘上的物理空间更能代表一个分组:

  1. 要开始创建虚拟存储空间,请在eBookManager.Engine项目中添加一个名为StorageSpace的新类。打开StorageSpace.cs文件并添加以下代码:
using System;
using System.Collections.Generic;
namespace eBookManager.Engine
{
    [Serializable]
    public class StorageSpace
    {
        public int ID { get; set; }
        public string Name { get; set; }
        public string Description { get; set; }
        public List<Document> BookList { get; set; }
    }
}

Note that you need to include the System.Collections.Generic namespace here, because the StorageSpace class contains a property called BookList of type List<Document> that will contain all the books in that particular storage space.

现在我们需要将注意力集中在eBookManager.Helper项目上,并添加一个名为ExtensionMethods的新类。这将是一个静态类,因为扩展方法需要本质上是静态的,以便作用于由扩展方法定义的各种对象。

  1. 新的ExtensionMethods类最初将如下所示:
public static class ExtensionMethods
{
}

让我们将第一个扩展方法添加到名为ToInt()的类中。这个扩展方法所做的是获取一个字符串值,并尝试将其解析为一个整数值。每当需要将字符串转换为整数时,我都懒得打Convert.ToInt32(stringVariable)。正是因为这个原因,我使用了一个扩展方法。

  1. 将以下静态方法添加到ExtensionMethods类中:
public static int ToInt(this string value, int defaultInteger = 0)
{
    try
    {
        if (int.TryParse(value, out int validInteger))
        {
            // Out variables
            return validInteger;
        }
        else
        {
            return defaultInteger;
        }
    }
    catch
    {
        return defaultInteger;
    }
}

ToInt()扩展方法只作用于字符串。这是由方法签名中的this string value定义的,其中value是包含您试图转换为整数的字符串的变量名。它还有一个默认参数defaultInteger,设置为0。除非调用扩展方法的开发人员想要返回一个默认的整数值0,否则他们可以向这个扩展方法传递一个不同的整数(例如-1)。

ExtensionMethods类的其他方法用于提供以下逻辑:

  • 读写数据源
  • 检查存储空间是否存在
  • 将字节转换为兆字节
  • 将字符串转换为整数(如前所述)

ToMegabytes方法相当简单。为了避免必须到处写这个计算,在扩展方法中定义它是有意义的:

public static double ToMegabytes(this long bytes) => 
    (bytes > 0) ? (bytes / 1024f) / 1024f : bytes;

我们还需要一种方法来检查特定的存储空间是否已经存在。

Be sure to add a project reference to eBookManager.Engine from the eBookManager.Helper project.

这个扩展方法还会将下一个存储空间标识返回给调用代码。如果存储空间不存在,返回的标识将是创建新存储空间时可以使用的下一个标识:

public static bool StorageSpaceExists(this List<StorageSpace> space, string nameValueToCheck, out int storageSpaceId)
{
    bool exists = false;
    storageSpaceId = 0;
    if (space.Count() != 0)
    {
        int count = (from r in space
                     where r.Name.Equals(nameValueToCheck)
                     select r)
            .Count();
        if (count > 0) exists = true;
        storageSpaceId = (from r in space
                          select r.ID).Max() + 1;
    }
    return exists;
}

If you're pasting this code in, remember the Ctrl + . tip from earlier. Wherever you see code that is not recognized, simply place the cursor there and press Ctrl + ., or click the lightbulb, and it should bring in the necessary references.

我们还需要创建一个方法,在将数据转换为 JSON 后,将数据写入文件:

public async static Task WriteToDataStore(this List<StorageSpace> value,
string storagePath, bool appendToExistingFile = false)
{
    using (FileStream fs = File.Create(storagePath)) 
    await JsonSerializer.SerializeAsync(fs, value); 
}

本质上,我们在这里所做的就是创建一个流并将StorageSpace列表序列化到该流中。

Note that we're using the new syntactical sugar here from C# 8, allowing us to add a using statement with an implicit scope (that is, until the end of the method).

您需要从包管理器控制台安装System.Text.Json:

Install-Package System.Text.Json -ProjectName eBookManager.Helper

这允许您使用新的.NET Core 3 JSON 序列化程序。除了比它的前身,甚至像 Json.NET 这样的第三方工具更简洁之外,微软声称你会看到速度的提高,因为它利用了中引入的性能改进.NET Core 2.x。

最后,我们需要能够再次将数据读回List<StorageSpace>对象,并将其返回给调用代码:

public async static Task<List<StorageSpace>> ReadFromDataStore(this List<StorageSpace> value, string storagePath)
{
    if (!File.Exists(storagePath))
    {
        var newFile = File.Create(storagePath);
        newFile.Close();
    }

    using FileStream fs = File.OpenRead(storagePath);
    if (fs.Length == 0) return new List<StorageSpace>();

    var storageList = await JsonSerializer.DeserializeAsync<List<StorageSpace>>(fs);

    return storageList;
}

该方法将返回一个空列表,即一个<StorageSpace>对象,文件中不包含任何内容。ExtensionMethods类可以包含更多您可能经常使用的扩展方法。这是分离常用代码的好方法。

As with any other class, you should consider whether your extension method class is getting too large, or becoming a dumping ground for unrelated functionality, or functionality that may be better extracted into a self-contained class.

文档引擎类

这个类的目的仅仅是为文档提供支持代码。在eBookManager应用中,我将使用一个名为GetFileProperties()的方法,它将(你猜对了)返回所选文件的属性。这个类也只包含这个方法。当应用根据您的特定目的进行修改时,您可以修改这个类并添加特定于文档的其他方法。

DocumentEngine类中,添加以下代码:

public (DateTime dateCreated, DateTime dateLastAccessed, string fileName, string fileExtension, long fileLength, bool error) GetFileProperties(string filePath)
{
    var returnTuple = (created: DateTime.MinValue,
    lastDateAccessed: DateTime.MinValue, name: "", ext: "",
    fileSize: 0L, error: false);
    try
    {
        FileInfo fi = new FileInfo(filePath);
        fi.Refresh();
        returnTuple = (fi.CreationTime, fi.LastAccessTime, fi.Name,
                       fi.Extension, fi.Length, false);
    }
    catch
    {
        returnTuple.error = true;
    }
    return returnTuple;
}

GetFileProperties()方法返回一个元组作为(DateTime dateCreated, DateTime dateLastAccessed, string fileName, string fileExtension, long fileLength, bool error),并允许我们轻松检查从调用代码返回的值。

在获取特定文件的属性之前,通过执行以下操作初始化元组:

var returnTuple = (created: DateTime.MinValue, lastDateAccessed: DateTime.MinValue, name: "", ext: "", fileSize: 0L, error: false);

如果有异常,我可以返回默认值。使用FileInfo类读取文件属性非常简单。然后,我可以通过执行以下操作将文件属性分配给元组:

returnTuple = (fi.CreationTime, fi.LastAccessTime, fi.Name, fi.Extension, fi.Length, false);

然后,元组被返回给调用代码,在那里它将根据需要被使用。接下来我们将看一下调用代码。

进口书籍表格

ImportBooks形式确实如其名。它允许我们创建虚拟存储空间,并将书籍导入这些空间。表单设计如下:

TreeView controls are prefixed with tv, buttons with btn, combo boxes with dl, textboxes with txt, and date time pickers with dt.

Although this kind of prefixing isn't widely used today, this used to be a common practice for WinForms developers. The reason behind it is that WinForms never really lent itself very well to a separation of business and presentation layers (there have been attempts to rectify this, notably with the MVP pattern), meaning that referencing controls directly from code-behind was a common practice and, as such, it made sense to indicate the type of control you were dealing with.

当此表单加载时,如果已经定义了任何存储空间,那么它们将在dlVirtualStorageSpaces组合框中列出。点击选择源文件夹按钮将允许我们选择一个源文件夹,在其中寻找电子书。

如果某个存储空间不存在,我们可以通过点击btnAddNewStorageSpace按钮添加一个新的虚拟存储空间。这将允许我们为新的存储空间添加名称和描述,并点击btnSaveNewStorageSpace按钮。从tvFoundBooks树形视图中选择一个电子书将填充表单右侧的文件详细信息控件组。然后您可以添加额外的图书详情,并点击btnAddeBookToStorageSpace按钮将图书添加到我们的空间。

You can access the code-behind of a Windows Form by simply pressing F7, or right-clicking in Solution Explorer and selecting View Code.

以下步骤描述了要对ImportBooks代码隐藏进行的更改:

  1. 您需要确保将以下命名空间添加到您的类中(这些命名空间应该替换那里的任何现有命名空间):
using eBookManager.Engine;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Windows.Forms;
using static eBookManager.Helper.ExtensionMethods;
using static System.Math;
  1. 接下来,让我们从最符合逻辑的地方开始:构造函数ImportBooks()和类级变量。在构造函数上方添加以下声明:
private string _jsonPath;
private List<StorageSpace> _spaces;
private enum _storageSpaceSelection { New = -9999, NoSelection = -1 }

枚举器的用处将在代码的后面变得明显。_jsonPath变量将包含用于存储我们电子书信息的文件的路径。

Some people, including myself, like to prefix private class-level variables with an underscore (as in this example). This is a personal preference; however, there are settings in Visual Studio that will aid in the auto-generation of such variables if you tell it what your preference is.

  1. 按如下方式修改构造函数:
public ImportBooks()
{
    InitializeComponent();
    _jsonPath = Path.Combine(Application.StartupPath, "bookData.txt"); 
}

_jsonPath在应用的执行文件夹中初始化,文件被硬编码到bookData.txt。如果您选择改进此项目,可以提供设置屏幕来配置这些设置。

  1. 因为我们想在表单加载时加载一些数据,所以我们将附加Form_Load事件。在 WinForms 中创建事件处理程序的一种简单方法是,在表单设计器中选择事件,然后双击要处理的事件旁边的:

新事件应该异步从数据存储中加载以下代码:

private async void ImportBooks_Load(object sender, EventArgs e)
{
    _spaces = await _spaces.ReadFromDataStore(_jsonPath);
}
  1. 接下来,我们需要添加另外两个枚举器,它们定义了我们将能够保存在应用中的文件扩展名:
private HashSet<string> AllowedExtensions => new HashSet<string>(StringComparer.InvariantCultureIgnoreCase) 
{ ".doc", ".docx", ".pdf", ".epub", ".lit" };

private enum Extension { doc = 0, docx = 1, pdf = 2, epub = 3, lit = 4 }

当我们查看PopulateBookList()方法时,我们可以看到AllowedExtensions属性的实现。

填充 TreeView 控件

PopulateBookList()方法所做的只是用在所选源位置找到的文件和文件夹填充TreeView控件。考虑ImportBooks代码中的以下代码:

public void PopulateBookList(string paramDir, TreeNode paramNode)
{
    DirectoryInfo dir = new DirectoryInfo(paramDir);
    foreach (DirectoryInfo dirInfo in dir.GetDirectories())
    {
        TreeNode node = new TreeNode(dirInfo.Name);
        node.ImageIndex = 4;
        node.SelectedImageIndex = 5;
        if (paramNode != null)
            paramNode.Nodes.Add(node);
        else
            tvFoundBooks.Nodes.Add(node);
        PopulateBookList(dirInfo.FullName, node);
    }
    foreach (FileInfo fleInfo in dir.GetFiles()
        .Where(x => AllowedExtensions.Contains(x.Extension)).ToList())
    {
        TreeNode node = new TreeNode(fleInfo.Name);
        node.Tag = fleInfo.FullName;
        int iconIndex = Enum.Parse(typeof(Extension),
        fleInfo.Extension.TrimStart('.'), true).GetHashCode();
        node.ImageIndex = iconIndex;
        node.SelectedImageIndex = iconIndex;
        if (paramNode != null)
            paramNode.Nodes.Add(node);
        else
            tvFoundBooks.Nodes.Add(node);
    }
}

我们需要调用这个方法的第一个地方显然是从它自身内部,因为这是一个递归方法。我们需要调用的第二个地方是来自btnSelectSourceFolder按钮的点击事件(还是和以前一样,选择点击属性并双击):

private void btnSelectSourceFolder_Click(object sender, EventArgs e)
{
    try
    {
        FolderBrowserDialog fbd = new FolderBrowserDialog();
        fbd.Description = "Select the location of your eBooks and documents";
        DialogResult dlgResult = fbd.ShowDialog();
        if (dlgResult == DialogResult.OK)
        {
            tvFoundBooks.Nodes.Clear(); 
            string path = fbd.SelectedPath;
            DirectoryInfo di = new DirectoryInfo(path);
            TreeNode root = new TreeNode(di.Name);
            root.ImageIndex = 4;
            root.SelectedImageIndex = 5;
            tvFoundBooks.Nodes.Add(root);
            PopulateBookList(di.FullName, root);
            tvFoundBooks.Sort();
            root.Expand();
        }
    }
    catch (Exception ex)
    {
        MessageBox.Show(ex.Message);
    }
}

这些都是非常简单的代码。选择要递归的文件夹,并使用找到的与包含在我们的AllowedExtensions属性中的文件扩展名匹配的所有文件填充树形视图控件。当有人在tvFoundBooks TreeView控件中选择一本书时,我们还需要看代码。当选择一本书时,我们需要读取所选文件的属性,并将这些属性返回到文件详细信息部分:

private void tvFoundBooks_AfterSelect(object sender, TreeViewEventArgs e)
{
    DocumentEngine engine = new DocumentEngine();
    string path = e.Node.Tag?.ToString() ?? "";
    if (File.Exists(path))
    {
        var (dateCreated, dateLastAccessed, fileName, fileExtention, fileLength, hasError) = engine.GetFileProperties(e.Node.Tag.ToString());
        if (!hasError)
        {
            txtFileName.Text = fileName; 
            txtExtension.Text = fileExtention;
            dtCreated.Value = dateCreated;
            dtLastAccessed.Value = dateLastAccessed;
            txtFilePath.Text = e.Node.Tag.ToString();
            txtFileSize.Text = $"{Round(fileLength.ToMegabytes(), 2).ToString()} MB"; 
        }
    }
}

您会注意到,正是在这里,我们在返回元组的DocumentEngine类上调用GetFileProperties()方法。

填充存储空间列表

下一步是填充我们的存储空间列表:

private void PopulateStorageSpacesList()
{
    List<KeyValuePair<int, string>> lstSpaces =
        new List<KeyValuePair<int, string>>();
    BindStorageSpaceList((int)_storageSpaceSelection.NoSelection, "Select Storage Space");

    void BindStorageSpaceList(int key, string value) =>     
        lstSpaces.Add(new KeyValuePair<int, string>(key, value));    

    if (_spaces is null || _spaces.Count() == 0) // Pattern matching
    {
        BindStorageSpaceList((int)_storageSpaceSelection.New, " <create new> ");
    }
    else
    {
        foreach (var space in _spaces)
        {
            BindStorageSpaceList(space.ID, space.Name);
        }
    }
    dlVirtualStorageSpaces.DataSource = new
        BindingSource(lstSpaces, null);
    dlVirtualStorageSpaces.DisplayMember = "Value";
    dlVirtualStorageSpaces.ValueMember = "Key";
}

The PopulateStorageSpacesList() method is using a local function, essentially allowing us to declare a piece of functionality that is accessible only from within its parent.

让我们将对这个新方法的调用添加到ImportBooks_Load方法中:

private async void ImportBooks_Load(object sender, EventArgs e)
{
    _spaces = await _spaces.ReadFromDataStore(_jsonPath);
    PopulateStorageSpacesList();
    if (dlVirtualStorageSpaces.Items.Count == 0)
    {
        dlVirtualStorageSpaces.Items.Add("<create new storage space > ");
    }
    lblEbookCount.Text = "";
}

我们现在需要添加用于更改所选存储空间的逻辑。dlVirtualStorageSpaces控制的SelectedIndexChanged()事件修改如下:

private void dlVirtualStorageSpaces_SelectedIndexChanged(object sender, EventArgs e)
{
    int selectedValue = dlVirtualStorageSpaces.SelectedValue.ToString().ToInt();
    if (selectedValue == (int)_storageSpaceSelection.New) // -9999
    {
        txtNewStorageSpaceName.Visible = true;
        lblStorageSpaceDescription.Visible = true;
        txtStorageSpaceDescription.ReadOnly = false;
        btnSaveNewStorageSpace.Visible = true;
        btnCancelNewStorageSpaceSave.Visible = true;
        dlVirtualStorageSpaces.Enabled = false;
        btnAddNewStorageSpace.Enabled = false;
        lblEbookCount.Text = "";
    }
    else if (selectedValue != (int)_storageSpaceSelection.NoSelection)
    {
        // Find the contents of the selected storage space
        int contentCount = (from c in _spaces
            where c.ID == selectedValue
            select c).Count();
        if (contentCount > 0)
        {
            StorageSpace selectedSpace = (from c in _spaces
                where c.ID == selectedValue
                select c).First();
            txtStorageSpaceDescription.Text = selectedSpace.Description;
            List<Document> eBooks = (selectedSpace.BookList == null)
                ? new List<Document> { } 
                : selectedSpace.BookList;
            lblEbookCount.Text = $"Storage Space contains { eBooks.Count()} {(eBooks.Count() == 1 ? "eBook" : "eBooks")}";
        }
    }
    else
    {
        lblEbookCount.Text = "";
    }
}

我不会在这里详细解释代码,因为它正在做什么是相对显而易见的。

我们还需要添加代码来节省新的存储空间。在btnSaveNewStorageSpace按钮的点击事件中添加以下代码:

private void btnSaveNewStorageSpace_Click(object sender, EventArgs e)
{
    try
    {
        if (txtNewStorageSpaceName.Text.Length != 0)
        {
            string newName = txtNewStorageSpaceName.Text;
            bool spaceExists = 
                (!_spaces.StorageSpaceExists(newName, out int nextID)) 
                ? false 
                : throw new Exception("The storage space you are trying to add already exists.");
            if (!spaceExists)
            {
                StorageSpace newSpace = new StorageSpace();
                newSpace.Name = newName;
                newSpace.ID = nextID;
                newSpace.Description =
                txtStorageSpaceDescription.Text;
                _spaces.Add(newSpace);

                PopulateStorageSpacesList();
                // Save new Storage Space Name
                txtNewStorageSpaceName.Clear();
                txtNewStorageSpaceName.Visible = false;
                lblStorageSpaceDescription.Visible = false;
                txtStorageSpaceDescription.ReadOnly = true;
                txtStorageSpaceDescription.Clear();
                btnSaveNewStorageSpace.Visible = false;
                btnCancelNewStorageSpaceSave.Visible = false;
                dlVirtualStorageSpaces.Enabled = true;
                btnAddNewStorageSpace.Enabled = true;
            }
        }
    }
    catch (Exception ex)
    {
        txtNewStorageSpaceName.SelectAll();
        MessageBox.Show(ex.Message);
    }
}

最后几个方法涉及将电子书保存在选定的虚拟存储空间中。修改点击btnAddBookToStorageSpace按钮的事件。这段代码还包含一个抛出表达式。如果您尚未从组合框中选择存储空间,则会引发新的异常:

private async void btnAddeBookToStorageSpace_Click(object sender, EventArgs e)
{
    try
    {
        int selectedStorageSpaceID =
            dlVirtualStorageSpaces.SelectedValue.ToString().ToInt();
        if ((selectedStorageSpaceID != (int)_storageSpaceSelection.NoSelection)
            && (selectedStorageSpaceID != (int)_storageSpaceSelection.New))
        {
            await UpdateStorageSpaceBooks(selectedStorageSpaceID);
        }
        else throw new Exception("Please select a Storage Space to add your eBook to"); // throw expressions
    }
    catch (Exception ex)
    {
        MessageBox.Show(ex.Message);
    }
}

如果输入这个代码,你会注意到UpdateStorageSpaceBooks方法还不存在;让我们纠正一下。

将选定的书保存到存储空间

下面的代码基本上更新了所选存储空间中的图书列表,如果它已经包含了特定的图书(在与用户确认之后)。否则,它会将该书作为新书添加到图书列表中:

private async Task UpdateStorageSpaceBooks(int storageSpaceId)
{
    try
    {
        int iCount = (from s in _spaces
                      where s.ID == storageSpaceId
                      select s).Count();
        if (iCount > 0) // The space will always exist 
        {
            // Update
            StorageSpace existingSpace = (from s in _spaces
                                          where s.ID == storageSpaceId
                                          select s).First();
            List<Document> ebooks = existingSpace.BookList;
            int iBooksExist = (ebooks != null) 
                ? (from b in ebooks
                   where $"{b.FileName}".Equals($"{txtFileName.Text.Trim()}")
                   select b).Count() 
                : 0;
            if (iBooksExist > 0)
            { 
                DialogResult dlgResult = MessageBox.Show($"A book with the same name has been found in Storage Space {existingSpace.Name}. Do you want to replace the existing book entry with this one ?", "Duplicate Title", 
                    MessageBoxButtons.YesNo, 
                    MessageBoxIcon.Warning, 
                    MessageBoxDefaultButton.Button2);
                if (dlgResult == DialogResult.Yes)
                {
                    Document existingBook = (from b in ebooks
                                             where $"{ b.FileName}".Equals($"{txtFileName.Text.Trim()}")
                                             select b).First();
                    SetBookFields(existingBook);
                }
            } 
            else
            {
                // Insert new book
                Document newBook = new Document();
                SetBookFields(newBook);

                if (ebooks == null)
                    ebooks = new List<Document>();
                ebooks.Add(newBook);
                existingSpace.BookList = ebooks;
            }
        }
        await _spaces.WriteToDataStore(_jsonPath);
        PopulateStorageSpacesList();
        MessageBox.Show("Book added");
    }
    catch (Exception ex)
    {
        MessageBox.Show(ex.Message);
    }
}

我们在前面的方法中调用了一个辅助方法,叫做SetBookFields:

private void SetBookFields(Document book)
{
    book.FileName = txtFileName.Text;
    book.Extension = txtExtension.Text;
    book.LastAccessed = dtLastAccessed.Value;
    book.Created = dtCreated.Value;
    book.FilePath = txtFilePath.Text;
    book.FileSize = txtFileSize.Text;
    book.Title = txtTitle.Text;
    book.Author = txtAuthor.Text;
    book.Publisher = txtPublisher.Text;
    book.Price = txtPrice.Text;
    book.ISBN = txtISBN.Text;
    book.PublishDate = dtDatePublished.Value;
    book.Category = txtCategory.Text;
}

最后,作为内务处理,ImportBooks 表单包含以下代码,用于根据btnCancelNewStorageSpacebtnAddNewStorageSpace按钮的按钮点击事件显示和启用控件:

private void btnCancelNewStorageSpaceSave_Click(object sender, EventArgs e)
{
    txtNewStorageSpaceName.Clear();
    txtNewStorageSpaceName.Visible = false;
    lblStorageSpaceDescription.Visible = false;
    txtStorageSpaceDescription.ReadOnly = true;
    txtStorageSpaceDescription.Clear();
    btnSaveNewStorageSpace.Visible = false;
    btnCancelNewStorageSpaceSave.Visible = false;
    dlVirtualStorageSpaces.Enabled = true;
    btnAddNewStorageSpace.Enabled = true;
}

private void btnAddNewStorageSpace_Click(object sender, EventArgs e)
{
    txtNewStorageSpaceName.Visible = true;
    lblStorageSpaceDescription.Visible = true;
    txtStorageSpaceDescription.ReadOnly = false;
    btnSaveNewStorageSpace.Visible = true;
    btnCancelNewStorageSpaceSave.Visible = true;
    dlVirtualStorageSpaces.Enabled = false;
    btnAddNewStorageSpace.Enabled = false;
}

现在剩下的就是我们在Form1.cs表单中完成代码,这是启动表单。

创建主电子书管理器表单

首先将Form1.cs重命名为eBookManager.cs。这是应用的启动表单,它将列出以前保存的所有现有存储空间:

如下设计您的eBookManager表单:

  • 现有存储空间的列表视图控件
  • 所选存储空间中包含的电子书的列表视图
  • 打开电子书文件位置的按钮
  • 导航到 ImportBooks.cs 表单的菜单控件
  • 显示所选电子书信息的各种只读字段:

Again, due to the nature of the WinForms designer, you may choose to simply copy and paste the designer code from the repository.

本节需要以下using语句:

using eBookManager.Engine;
using eBookManager.Helper;
using System;
using System.Collections.Generic;
using System.IO;
using System.Windows.Forms;
using System.Linq;
using System.Diagnostics;

As demonstrated earlier, you may choose to omit this and then press Ctrl + . each time a particular method or namespace isn't recognized.

Bear in mind that you won't be able to use this to include libraries with extension methods, so you'll need to include eBookManager.Helper manually.

现在让我们借助以下步骤开始设计我们的eBookManager表单:

  1. 构造函数和加载方法与ImportBooks.cs表单中的非常相似。他们读取任何可用的存储空间,并用以前保存的存储空间填充存储空间列表视图控件:
private string _jsonPath;
private List<StorageSpace> _spaces;

public eBookManager()
{
    InitializeComponent();
    _jsonPath = Path.Combine(Application.StartupPath,
        "bookData.txt"); 
}

private async void eBookManager_Load(object sender, EventArgs e)
{
    _spaces = await _spaces.ReadFromDataStore(_jsonPath);

    // imageList1 
    this.imageList1.Images.Add("storage_space_cloud.png", Image.FromFile("img/storage_space_cloud.png"));
    this.imageList1.Images.Add("eBook.png", Image.FromFile("img/eBook.png"));
    this.imageList1.Images.Add("no_eBook.png", Image.FromFile("img/no_eBook.png"));
    this.imageList1.TransparentColor = System.Drawing.Color.Transparent;

    // btnReadEbook 
    this.btnReadEbook.Image = Image.FromFile("img/ReadEbook.png");
    this.btnReadEbook.Location = new System.Drawing.Point(103, 227);
    this.btnReadEbook.Name = "btnReadEbook";
    this.btnReadEbook.Size = new System.Drawing.Size(36, 40);
    this.btnReadEbook.TabIndex = 32;
    this.toolTip1.SetToolTip(this.btnReadEbook, "Click here to open the eBook file location");
    this.btnReadEbook.UseVisualStyleBackColor = true;
    this.btnReadEbook.Click += new System.EventHandler(this.btnReadEbook_Click);

    // eBookManager Icon 
    this.Icon = new System.Drawing.Icon("ico/mainForm.ico");

    PopulateStorageSpaceList();
}

private void PopulateStorageSpaceList()
{
    lstStorageSpaces.Clear();
    if (!(_spaces == null))
    {
        foreach (StorageSpace space in _spaces)
        {
            ListViewItem lvItem = new ListViewItem(space.Name, 0);
            lvItem.Tag = space.BookList;
            lvItem.Name = space.ID.ToString();
            lstStorageSpaces.Items.Add(lvItem);
        }
    }
}
  1. 如果用户点击某个存储空间,我们需要能够阅读该选定空间中包含的书籍:
private void lstStorageSpaces_MouseClick(object sender, MouseEventArgs e)
{
    ListViewItem selectedStorageSpace =
    lstStorageSpaces.SelectedItems[0];
    int spaceID = selectedStorageSpace.Name.ToInt();
    txtStorageSpaceDescription.Text = (from d in _spaces
        where d.ID == spaceID
        select d.Description).First();
    List<Document> ebookList =
        (List<Document>)selectedStorageSpace.Tag;
    PopulateContainedEbooks(ebookList);
}
  1. 我们现在需要创建一个方法,用所选存储空间中包含的书籍填充lstBooks列表视图:
private void PopulateContainedEbooks(List<Document> ebookList)
{
    lstBooks.Clear();
    ClearSelectedBook();
    if (ebookList != null)
    {
        foreach (Document eBook in ebookList)
        {
            ListViewItem book = new ListViewItem(eBook.Title, 1);
            book.Tag = eBook;
            lstBooks.Items.Add(book);
        }
    }
    else
    {
        ListViewItem book = new ListViewItem("This storage space contains no eBooks", 2);
        book.Tag = "";
        lstBooks.Items.Add(book);
    }
}
  1. 当选定的存储空间改变时,我们还需要清除选定书籍的详细信息。我已经围绕文件和图书细节创建了两个组控件。这段代码只是遍历所有子控件;如果子控件是文本框,它会清除它:
private void ClearSelectedBook()
{
    foreach (Control ctrl in gbBookDetails.Controls)
    {
        if (ctrl is TextBox)
            ctrl.Text = "";
    }
    foreach (Control ctrl in gbFileDetails.Controls)
    {
        if (ctrl is TextBox)
            ctrl.Text = "";
    }
    dtLastAccessed.Value = DateTime.Now;
    dtCreated.Value = DateTime.Now;
    dtDatePublished.Value = DateTime.Now;
}
  1. 添加到表单中的MenuStripImportEbooks菜单项上有一个点击事件。它只是打开了ImportBooks的形式:
private async void mnuImportEbooks_Click(object sender, EventArgs e)
{
    ImportBooks import = new ImportBooks();
    import.ShowDialog();
    _spaces = await _spaces.ReadFromDataStore(_jsonPath);
    PopulateStorageSpaceList();
}
  1. 以下方法包装了选择特定电子书的逻辑,并在eBookManager表单上填充文件和电子书详细信息:
private void lstBooks_MouseClick(object sender, MouseEventArgs e)
{
    ListViewItem selectedBook = lstBooks.SelectedItems[0];
    if (!String.IsNullOrEmpty(selectedBook.Tag.ToString()))
    {
        Document ebook = (Document)selectedBook.Tag;
        txtFileName.Text = ebook.FileName;
        txtExtension.Text = ebook.Extension;
        dtLastAccessed.Value = ebook.LastAccessed;
        dtCreated.Value = ebook.Created;
        txtFilePath.Text = ebook.FilePath;
        txtFileSize.Text = ebook.FileSize;
        txtTitle.Text = ebook.Title;
        txtAuthor.Text = ebook.Author;
        txtPublisher.Text = ebook.Publisher;
        txtPrice.Text = ebook.Price;
        txtISBN.Text = ebook.ISBN;
        dtDatePublished.Value = ebook.PublishDate;
        txtCategory.Text = ebook.Category;
    }
}
  1. 最后,当所选的书是您想要阅读的书时,单击阅读电子书按钮打开所选电子书的文件位置:
private void btnReadEbook_Click(object sender, EventArgs e)
{
    string filePath = txtFilePath.Text;
    FileInfo fi = new FileInfo(filePath);
    if (fi.Exists)
    {
        Process.Start("explorer.exe", Path.GetDirectoryName(filePath));
    }
}

这就完成了eBookManager应用中包含的代码逻辑。

You can further modify the code to open the required application for the selected eBook instead of just the file location. In other words, if you click on a PDF document, the application can launch a PDF reader with the document loaded. Lastly, note that classification has not been implemented in this version of the application.

是时候启动应用并进行测试了。

运行电子书管理器应用

要运行应用,请执行以下步骤:

  1. 当应用首次启动时,将没有可用的虚拟存储空间。要创建一个,我们需要导入一些书籍。单击“导入电子书”菜单项:

  1. 将打开“导入电子书”屏幕。您可以添加新的存储空间,并选择电子书的源文件夹:

  1. 选择电子书后,添加书籍的相关信息并将其保存到存储空间。添加所有存储空间和电子书后,您将看到虚拟存储空间列表。当您单击存储空间时,其中包含的书籍将会列出:

  1. 选择电子书并点击阅读电子书按钮将打开包含所选电子书的文件位置。
  2. 最后,让我们看看为电子书管理器应用生成的 JSON 文件。最初,它将存储在项目的输出位置:

在下面,我使用 VS 代码很好地格式化了 JSON:

The keyboard shortcut to format JSON in VS Code is Shift + Alt + F.

正如您所看到的,JSON 文件布局非常好,并且很容易阅读。

现在让我们看看如何将现有的 WinForms 应用升级到.NET Core 3。

升级到.NET Core 3

为了遵循这一部分,您将不再需要第一版中的 WinForms 应用——任何 WinForms 应用都可以;但是,建议您使用该应用,尤其是在后面我们将讨论 C# 8 特性的部分。

*您可以从以下位置下载原始项目:

https://github.com/PacktPublishing/CSharp7-and-.网络核心 2.0 蓝图

如果您下载并运行该应用,您应该会发现它仍然可以正常工作:

现在让我们研究如何在下运行这个完全相同的代码库.NET Core 3。我们将从项目文件开始。基本上,我们需要告诉 Visual Studio,我们现在有一个. NET Core 3 项目,而不是框架项目。

如果您安装了电动工具(https://marketplace.visualstudio.com/items?item name = visualstudioproductTeam。生产力 PowerPack2017 ,你可以在 Visual Studio 里面做这个;如果没有,那么只需使用您最喜欢的文本编辑器打开.csproj文件:

.csproj文件的内容更改为以下内容:

<Project Sdk="Microsoft.NET.Sdk.WindowsDesktop">

  <PropertyGroup>
    <OutputType>WinExe</OutputType>
    <TargetFramework>netcoreapp3.0</TargetFramework>
    <LangVersion>8.0</LangVersion>

    <AssetTargetFallback>uap10.0.18362</AssetTargetFallback>
    <UseWindowsForms>true</UseWindowsForms>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Newtonsoft.Json" Version="12.0.2" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\eBookManager.Controls\eBookManager.Controls.csproj" />
    <ProjectReference Include="..\eBookManager.Engine\eBookManager.Engine.csproj" />
    <ProjectReference Include="..\eBookManager.Helper\eBookManager.Helper.csproj" />
  </ItemGroup>

  <ItemGroup>
    <Reference Include="System">
      <HintPath>System</HintPath>
    </Reference>
  </ItemGroup>

</Project>    

本质上,这是所有需要的。但是,您需要决定如何处理项目的资源。您可以手动检查它们是否都被设置为复制到输出目录;或者,我们可以向项目文件中添加一个项目组,如下所示:

  <ItemGroup>
    <None Update="ico\importBooks.ico">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </None>
    <None Update="ico\mainForm.ico">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </None>
    <None Update="img\add_ebook_to_storage_space.png">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </None>
    <None Update="img\add_new_storage_space.png">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </None>
    <None Update="img\docx16.png">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </None>
    <None Update="img\docxx16.png">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </None>
    <None Update="img\eBook.png">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </None>
    <None Update="img\epubx16.png">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </None>
    <None Update="img\folder-close-x16.png">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </None>
    <None Update="img\folder_exp_x16.png">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </None>
    <None Update="img\image sources.txt">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </None>
    <None Update="img\no_eBook.png">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </None>
    <None Update="img\pdfx16.png">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </None>
    <None Update="img\ReadEbook.png">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </None>
    <None Update="img\storage_space_cloud.png">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </None>
  </ItemGroup>

正如您所看到的,整个事情比文件的前一个版本简单得多。

At the time of writing, the first preview of a WinForms editor was released. The following article details what it is currently capable of: https://devblogs.microsoft.com/dotnet/introducing-net-core-windows-forms-designer-preview-1/.

Unfortunately, Preview 1 was not stable enough to make the changes necessary for this chapter, and so we are bypassing the designer.

下一步是删除以下文件,在Properties下找到:

  • AssemblyInfo.cs
  • Settings.Designer.cs
  • Settings.settings

事实上,到本章结束时,整个Properties文件夹都将不复存在。

实际上,就是这样。只需重新加载项目并点击 F5 。该应用现在正在运行.NET Core。然而,在这一点上很可能会出现错误。原因是我们还有另外两个项目仍在进行中.NET 框架:

  • eBookManager.Engine
  • eBookManager.Helper

我们需要以类似的方式迁移每个项目;先说eBookManager.Engine。如前所述,编辑项目文件并用以下内容替换您在那里找到的内容:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>netcoreapp3.0</TargetFramework>
  </PropertyGroup> 
</Project>

如你所见,这比以前更简单了。其实没有必要有这个目标 3.0;如果我们愿意,我们可以瞄准 2.2,甚至 2.1。再次,我们将删除AssemblyInfo.cs

最后,我们来到eBookManager.Helper。再次编辑项目文件以匹配以下内容:

<Project Sdk="Microsoft.NET.Sdk"> 
  <PropertyGroup>
    <TargetFramework>netcoreapp3.0</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Newtonsoft.Json" Version="11.0.2" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\eBookManager.Engine\eBookManager.Engine.csproj" />
  </ItemGroup>
</Project>

再次,我们将删除AssemblyInfo.cs。我们还需要删除在ExtensionMethods.cs中对System.Windows.Forms的不恰当引用。

修复编译错误

最后,我们需要重构一些依赖于嵌入式图像资源的代码。如果您按原样运行代码,您可能会收到类似以下内容的错误:

在写这篇文章的时候,WinForms 还在运行.NET Core 3.0 不支持二进制序列化。因此,我们需要做一些小的改变。

资源文件

我们需要做的第一件事是从输出目录中读取文件,因此我们将更改图像和图标文件上的“复制到输出目录”设置;突出显示所有文件,然后将复制到输出目录操作更改为复制(如果更新):

下一步是进入eBookManager画面。

电子书管理器屏幕

eBookManager.Designer.cs文件中,删除imageList1部分:

同时删除btnReadEbook部分:

最后,删除eBookManager部分的this.Icon赋值:

我们将把已经删除的代码移到eBookManager.csForm_Load事件中:

private void Form1_Load(object sender, EventArgs e)
{
    System.ComponentModel.ComponentResourceManager resources = new System.ComponentModel.ComponentResourceManager(typeof(eBookManager));

    this.components = new System.ComponentModel.Container();

    // imageList1
    //this.imageList1.ImageStream = ((System.Windows.Forms.ImageListStreamer)(resources.GetObject("imageList1.ImageStream")));            
    this.imageList1.Images.Add("storage_space_cloud.png", Image.FromFile("img/storage_space_cloud.png"));
    this.imageList1.Images.Add("eBook.png", Image.FromFile("img/eBook.png"));
    this.imageList1.Images.Add("no_eBook.png", Image.FromFile("img/no_eBook.png"));
    this.imageList1.TransparentColor = System.Drawing.Color.Transparent;

    // btnReadEbook            
    this.btnReadEbook.Image = Image.FromFile("img/ReadEbook.png");
    this.btnReadEbook.Location = new System.Drawing.Point(103, 227);
    this.btnReadEbook.Name = "btnReadEbook";
    this.btnReadEbook.Size = new System.Drawing.Size(36, 40);
    this.btnReadEbook.TabIndex = 32;
    this.toolTip1.SetToolTip(this.btnReadEbook, "Click here to open the eBook file location");
    this.btnReadEbook.UseVisualStyleBackColor = true;
    this.btnReadEbook.Click += new System.EventHandler(this.btnReadEbook_Click);

    // eBookManager Icon            
    this.Icon = new System.Drawing.Icon("ico/mainForm.ico");

    PopulateStorageSpaceList();
}

导入书籍屏幕

importBooks.Designer.cs也需要类似的改变。应删除以下部分:

删除同一文件中btnAddeBookToStorageSpace图像的设置器:

删除btnAddNewStorageSpace的图像(同样,在同一文件中):

最后,移除表单的图标设置器:

我们将把它移到ImportBooks.csForm_Load事件中,现在应该如下所示:

private void ImportBooks_Load(object sender, EventArgs e)
{
    // tvImages                        
    this.tvImages.Images.Add("docx16.png", Image.FromFile("img/docx16.png"));
    this.tvImages.Images.Add("docxx16.png", Image.FromFile("img/docxx16.png"));
    this.tvImages.Images.Add("pdfx16.png", Image.FromFile("img/pdfx16.png"));
    this.tvImages.Images.Add("epubx16.png", Image.FromFile("img/epubx16.png"));
    this.tvImages.Images.Add("folder-close-x16.png", Image.FromFile("img/folder-close-x16.png"));
    this.tvImages.Images.Add("folder_exp_x16.png", Image.FromFile("img/folder_exp_x16.png"));
    this.tvImages.TransparentColor = System.Drawing.Color.Transparent;

    // btnAddeBookToStorageSpace
    this.btnAddeBookToStorageSpace.Image = Image.FromFile("img/add_ebook_to_storage_space.png");

    // btnAddNewStorageSpace
    this.btnAddNewStorageSpace.Image = Image.FromFile("img/add_new_storage_space.png");

    // ImportBooks            
    this.Icon = new System.Drawing.Icon("ico/importBooks.ico");

    PopulateStorageSpacesList();

    if (dlVirtualStorageSpaces.Items.Count == 0)
    {
        dlVirtualStorageSpaces.Items.Add("<create new storage space>");
    }

    lblEbookCount.Text = "";
}

ProcessStartInfo

最后,需要在eBookManager.cs中更改以下内容:

private void btnReadEbook_Click(object sender, EventArgs e)
        {
            string filePath = txtFilePath.Text;
            FileInfo fi = new FileInfo(filePath);
            if (fi.Exists)
            {
                var processStartInfo = new ProcessStartInfo(filePath, Path.GetDirectoryName(filePath))
                {
                    // Change in .NET Core - this defaulted to true in WinForms
                    UseShellExecute = true
                };
                Process.Start(processStartInfo);
            }
        }

原因是ProcessStartInfo在.NET Framework 用来默认为UseShellExecute = true。然而,在.NET Core,它现在默认为false,因此如果没有这个改变,它将会失败。

就这样!如果您运行该应用,您现在正在运行它.NET Core 3。这是同一个应用(尽管有一些小的代码更改),但是现在它正在运行.NET Core 运行时!

升级到的好处.NET Core

让我们从房间里的大象开始。你现在不能拿着电子书管理器在 Linux 上运行——现在跨平台的是而不是。WinForms 一直是,现在仍然是,而且可能永远是一种特定于 Windows 的技术。

升级本质上给你三个好处:

  • 速度:.NET Core 2.1 看到了一些显著的速度改进。您的里程可能会因此而有所不同,这很可能取决于您的应用正在做什么。例如,电子书管理器应用扫描硬盘来检索书籍:内存分配的改进不太可能在.NET Core 2.1 将对这一速度产生巨大的影响。

  • 支持:一旦升级到.NET Core,你的应用现在将运行在一个更加活跃的技术上;未来,微软不太可能改变.NET 框架,除了安全漏洞补丁,但是.NET Core 有一个活跃的路线图。

  • 热情:很难让人们对十五年前编写的 WinForms 应用感到兴奋(或者根本无法让人们兴奋)。

From the announcement with build 2019, it looks like .NET Framework will shortly be swallowed by .NET Core (to be known as .NET 5 at the time of writing). This means that, if you haven't converted by then, you may be on a train that ends (albeit in a few years) with Microsoft withdrawing support for the framework.

C# 8 带来了一系列新特性,包括:

  • 可空引用类型
  • 接口的默认实现
  • 记录
  • 递归模式
  • 异步流
  • 范围
  • 静态局部函数
  • 使用声明

从该列表中选取前两个主要特性,很明显能够在中运行遗留代码之间存在协同作用.NET Core 3,并且能够应用这些特性来帮助更新和维护遗留代码。

理解可空引用类型

英寸 NET Core 2.1(或. NET 的任何早期版本.NET),我们可以合法地键入以下代码并运行它:

string test = null;
Console.WriteLine(test.Length);

当然,它会崩溃。显然,没有一个熟悉这种语言的人会写这个;但是,他们可能会写下如下内容:

string test = FunctionCanReturnNull();
Console.WriteLine(test.Length);

可空引用类型是一个选入特性(也就是说,你必须显式地打开它),它只是给出一个警告,告诉你一个引用类型有可能成为null。让我们试着为我们的电子书管理器打开这个。通过在文件顶部添加以下指令,可以逐个类地打开它:

#nullable enable

但是,您也可以通过在.csproj文件中添加以下行来打开整个项目:

<PropertyGroup>
    <TargetFramework>netcoreapp3.0</TargetFramework>
    <LangVersion>8.0</LangVersion>
    <Nullable>enable</Nullable>
</PropertyGroup>

At the time of writing, this property will be automatically added to the .csproj file. There are other options that the Nullable option can be configured for; for further information, see the following URL:

https://docs.microsoft.com/en-us/dotnet/csharp/nullable-references

这是一个粒度特性,因此可以针对特定的代码段关闭或打开它。有几个警告弹出,让我们关注StorageSpace.cs:

那么,这到底在告诉我们什么?

要回答这个问题,我们先来看看IDID是值类型,因此不能是null。如果没有给ID赋值,则默认值为:0Name不过是一个字符串(是引用类型),因此可以null,实际上也会null,除非我们另外设置。如果我们希望这些字段中的一个可以为空,那么我们当然可以这样做(在Description的情况下,我们可能应该这样做):

但是Name呢?我们可能不希望那是null。这里有几个选择:一种是添加一个空白字符串作为默认初始值,如下所示:

public string Name { get; set; } = string.Empty;

这并不理想。事实上,得到一个null引用异常实际上可能比它为空并绕过它更好。

This is just my opinion, but it is much better to have software crash at runtime and alert you to an error in the logic than to soldier on and potentially corrupt data or, worse, request or update data in a third-party system!

另一个选择是添加一个构造函数。以下是一个例子:

[Serializable]
public class StorageSpace
{
    public StorageSpace(string name)
    {
        Name = name;
    }
    public int ID { get; set; }
    public string Name { get; set; }
    public string? Description { get; set; }
    public List<Document>? BookList { get; set; }
}

这清除了警告,并确保创建该类的任何人都提供了一个名称,我们说这个名称永远不可能是null。这是在ImportBooks.cs中实例化的,所以现在我们必须提供该参数:

private void btnSaveNewStorageSpace_Click(object sender, EventArgs e)
{
    try
    {
        if (txtNewStorageSpaceName.Text.Length != 0)
        {
            string newName = txtNewStorageSpaceName.Text;

            // null conditional operator: "spaces?.StorageSpaceExists(newName) ?? false"
            // throw expressions: bool spaceExists = (space exists = false) ? return false : throw exception                    
            // Out variables
            bool spaceExists = (!spaces.StorageSpaceExists(newName, out int nextID)) ? false : throw new Exception("The storage space you are trying to add already exists.");

            if (!spaceExists)
            {
                StorageSpace newSpace = new StorageSpace(newName);                        
                newSpace.ID = nextID;
                newSpace.Description = txtStorageSpaceDescription.Text;
                spaces.Add(newSpace);

现在我们知道Name属性永远不可能是null,值得记住的是,你在这里得到的警告只是那个,警告;和所有的警告一样,忽略它们是你的特权。然而,C# 8 确实有一个特性(我听说被称为该死的操作符),允许你坚持认为,不管编译器相信什么,你知道变量不会是null;它看起来如下:

string test = null;

Console.WriteLine(test!.Length);
Console.ReadLine();

显然,如果你这样做,前面的代码将崩溃,所以如果你决定你比编译器更了解,请确保!

探索 XAML 群岛

在本节中,您将需要运行 Windows 10 1903 或更高版本。到本书出版时,预计 1903 版本将自动交付给所有 Windows 10 机器;但是,如果您运行的是早期版本,则可以通过访问以下链接来强制更新:https://www.microsoft.com/en-us/software-download/windows10

2019 年写这一章的时候,我们注意到进口书籍部分的TreeView看起来有点过时。事实上,你会认为这是 2005 年 WinForms 风靡一时的一个TreeView!此外,我们希望将我们的数据绑定到TreeView,而不是单独建立。虽然 WinForms 中有一些数据绑定功能,但我们还是停留在TreeView的一般外观上。

除非,也就是说,我们在 WinForms 中使用了一个不错的新 UWP 控件。这正是 XAML 群岛给我们的!我们可以获取一个现有的 UWP 控件,甚至创建我们自己的控件,并直接从现有的 WinForms 应用中使用它。

让我们尝试在 WinForms 应用中使用 UWP 社区工具包中的TreeView

UWP 树景

对此有许多设置要求,我将在后面详细介绍。

By the time this is published, the process for setting this up may have been simplified considerably; please refer to the linked articles for the most recent advice.

第一步是确保(详见技术要求部分)您运行的是 Windows 10,版本 1903 或更高版本。如果您不是,请遵循该部分中的信息。第二步是安装 Windows 10 SDK 为此,您可以使用以下链接:https://developer . Microsoft . com/en-us/windows/downloads/windows-10-SDK

我们将为下一步执行以下文章:https://docs . Microsoft . com/en-us/windows/apps/desktop/现代化/desktop-to-uwp-enhanced # setup-your-project

将以下 NuGet 包添加到您的 WinForms 项目中:

Microsoft.Windows.SDK.Contracts

XamlHost NuGet 包安装到 WinForms 应用中:

Install-Package Microsoft.Toolkit.Forms.UI.XamlHost

现在我们可以用 UWP 的替换我们现有的。

You'll notice that I've fully qualified all the XAML controls. Since we're dealing with two disparate frameworks, this kind of change makes it very easy to get confused and mix up which control you're dealing with. In the following code samples, I've included class-level variables with the code samples for clarity. I, personally, would suggest that these actually go at the top of your class file. Of course, it makes no functional difference.

我们首先需要考虑的是XamlHost

WIndowsXamlHost

让我们创造我们的TreeView;我们将在ImportBooks.cs的代码隐藏中这样做。我们将向构造函数添加一些代码,如下所示:

private readonly Microsoft.Toolkit.Forms.UI.XamlHost.WindowsXamlHost _windowsXamlHostTreeView;        

public ImportBooks()
{
    InitializeComponent();
    _jsonPath = Path.Combine(Application.StartupPath, "bookData.txt");
    spaces = spaces.ReadFromDataStore(_jsonPath);

    var windowsXamlHostTreeView = new WindowsXamlHost();
    windowsXamlHostTreeView.InitialTypeName = "Windows.UI.Xaml.Controls.TreeView";
    windowsXamlHostTreeView.AutoSizeMode = System.Windows.Forms.AutoSizeMode.GrowOnly;
    windowsXamlHostTreeView.Location = new System.Drawing.Point(12, 60);
    windowsXamlHostTreeView.Name = "tvFoundBooks";
    windowsXamlHostTreeView.Size = new System.Drawing.Size(513, 350);
    windowsXamlHostTreeView.TabIndex = 8;
    windowsXamlHostTreeView.Dock = System.Windows.Forms.DockStyle.None;
    windowsXamlHostTreeView.ChildChanged += windowsXamlHostTreeView_ChildChanged;            

    this.Controls.Add(windowsXamlHostTreeView); 
}

让我们快速回顾一下我们在这里做了什么(实际上没那么多)。首先,我们创建了一个新的WIndowsXamlHost对象。这是 XAML 群岛的基础;它充当 UWP 控件的包装器,因此它将在 WinForms 上下文中工作。

Although this chapter discusses WinForms, the same is true for WPF and, while the exact syntax may differ slightly, the basic principle is the same.

此代码示例中需要注意的事项如下:

  • 我们正在将名称设置为tvFoundBooks,这与我们的 WinForms 应用的名称相同。
  • 我们正在收听ChildChanged事件:这是为了我们可以设置控件本身的一些细节(我们将很快回到这一点)。
  • XAML 群岛就是这样知道该调用哪个 UWP 控制的。
  • 我们将宿主控件添加到当前表单中(我们还设置了位置)。

模板列

现在我们已经设置了主机控件,我们可以看一下我们提到的ChildChanged事件;这是我们设置 UWP 控件(而不是主机控件)的地方:

private Windows.UI.Xaml.Controls.TreeView? _tvFoundBooks = null;

private void windowsXamlHostTreeView_ChildChanged(object? sender, EventArgs e)
{
    if (sender == null) return;

    var host = (WindowsXamlHost)sender;
    _tvFoundBooks = (Windows.UI.Xaml.Controls.TreeView)host.Child;
    _tvFoundBooks.ItemInvoked += _tvFoundBooks_ItemInvoked;
    _tvFoundBooks.ItemsSource = DataSource;

    const string Xaml = "<DataTemplate xmlns=\"http://schemas.microsoft.com/winfx/2006/xaml/presentation\"><TreeViewItem ItemsSource=\"{Binding Children}\" Content=\"{Binding Name}\"/></DataTemplate>";
    var xaml = XamlReader.Load(Xaml);
    _tvFoundBooks.ItemTemplate = xaml as Windows.UI.Xaml.DataTemplate;
}

不要太担心为什么_tvFoundBooks是类级变量,我们很快会回到这个问题。在前面的代码示例中,我们进行了门控检查以确保sender不是null,然后我们将其强制为WindowsXamlHost类型。一旦我们有了这个类型,我们就可以通过调用.Child属性来获取宿主内部的任何东西。

和以前一样,我们正在收听ItemInvoked事件(同样,我们将很快回到这一点)。这里第一个真正的新事物是我们正在设置ItemsSourceItemTemplate。我们会回到ItemsSource,但是模板值得探索。与 WinForms 不同,UWP 使用 XAML 来定义其控件的外观。这意味着你可以控制进入TreeView的东西;例如,每个节点可以有一个图像或文本,或者两者都有。但是如果不指定ItemTemplate,那么渲染引擎不知道显示什么,或者怎么显示。

*前面的 XAML 可能是能展示任何东西的最简单的一个。您会注意到有一些绑定语句;它们绑定到与ItemsSource相关的属性。让我们看看我们绑定的到底是什么。

树视图项目模型和项目源

为了将某个东西绑定到 UWP 的某个控件,您需要某个东西。本质上,这意味着我们需要一个模型

A model, in .NET terms, is simply a class that holds data.

我们要创建一个新的类,我们称之为Item:

public class Item
{
    public string Name { get; set; }
    public ObservableCollection<Item> Children { get; set; } = new ObservableCollection<Item>();
    public ItemType ItemType { get; set; }
    public string FullName { get; set; }

    public override string ToString()
    {
        return Name;
    }
}

I would always recommend that models are held in their own file and sit in a folder called Models, but there's no technical reason why you couldn't add this class to the end of ImportBooks.cs.

这个类的大部分应该是不言自明的;我们持有文件的NameFullName(即名称和路径)。ObservableCollectionCollection的一种特殊类型,它允许用户界面框架在变化时得到通知。

For the code that we're writing here, we could get away with this simply being a List; however, ObservableCollection is good practice when dealing with desktop XAML frameworks such as UWP, and this will make extensibility easier.

最后,我们保留该项的类型,这是一个新的枚举类型:

public enum ItemType
{
    Docx,
    Docxx,
    Pdfx,
    Epubx,
    Folder
}

回到ImportBooks.cs,我们将设置我们的ItemsSource。第一步是添加一个名为DataSource的类级变量:

public ObservableCollection<Models.Item> DataSource { get; set; }

我们的下一个变化是在btnSelectSourceFolder_Click事件处理程序中:

private void btnSelectSourceFolder_Click(object sender, EventArgs e)
{
    try
    {
        FolderBrowserDialog fbd = new FolderBrowserDialog();
        fbd.Description = "Select the location of your eBooks and documents";

        DialogResult dlgResult = fbd.ShowDialog();
        if (dlgResult == DialogResult.OK)
        {
            UpdateBookList(fbd.SelectedPath);
        }
    }
    catch (Exception ex)
    {
        MessageBox.Show(ex.Message);
    }
}

正如你所看到的,与以前的版本相比,新方法大大简化了;我们已经将所有真正的逻辑提取到一个新方法中,接下来让我们看看:

private void UpdateBookList(string path)
{            
    DirectoryInfo di = new DirectoryInfo(path);
    var bookList = new List<Models.Item>();
    var rootItem = new Models.Item()
    {
        Name = di.Name
    };

    rootItem.ItemType = Models.ItemType.Folder;

    PopulateBookList(di.FullName, rootItem);
    bookList.Add(rootItem);

    DataSource = new ObservableCollection<Models.Item>(bookList);
    _tvFoundBooks.ItemsSource = DataSource.OrderBy(a => a.Name);
}

这里,我们正在设置TreeView的根项目;然而,你会注意到我们对TreeView的唯一引用是在最后,我们刷新ItemsSourcePopulateBookList是我们的下一个停靠港。和以前一样,这种方法本质上分为两部分;让我们看看第一部分:

public void PopulateBookList(string paramDir, Models.Item rootItem)
{
    if (rootItem == null) throw new ArgumentNullException();

    rootItem.FullName = paramDir;
    rootItem.ItemType = Models.ItemType.Folder;

    DirectoryInfo dir = new DirectoryInfo(paramDir);
    foreach (DirectoryInfo dirInfo in dir.GetDirectories())
    {
        var item = new Models.Item();
        item.Name = dirInfo.Name;

        rootItem.Children.Add(item);

        PopulateBookList(dirInfo.FullName, item);
    }

在这里,我们递归地遍历目录结构并填充我们的新模型。请注意,我们在开始设置项目类型和FullName(目录路径),然后我们遍历所有子目录,重新调用我们的方法。

Recursion is the practice of calling a method from itself. Is can be very useful in scenarios such as this, where you wish to perform exactly the same operation on nested objects. It is faster than using a loop; however, it does have the potential to fill up the stack very quickly if used incorrectly.

对于函数的第二部分,我们将处理当前目录中的任何文件(也就是说,无论哪个目录当时位于递归堆栈的顶部):

    foreach (FileInfo fleInfo in dir.GetFiles().Where(x => AllowedExtensions.Contains(x.Extension)).ToList())
    {
        var item = new Models.Item();
        item.Name = fleInfo.Name;

        item.FullName = fleInfo.FullName;
        item.ItemType = (Models.ItemType)Enum.Parse(typeof(Extention), fleInfo.Extension.TrimStart('.'), true);

        rootItem.Children.Add(item);
    }
}

我们的下一个变化是ItemInvoked方法;新方法应该如下所示:

private void _tvFoundBooks_ItemInvoked(Windows.UI.Xaml.Controls.TreeView sender, Windows.UI.Xaml.Controls.TreeViewItemInvokedEventArgs args)
{
    var selectedItem = (Models.Item)args.InvokedItem;

    DocumentEngine engine = new DocumentEngine();
    string path = selectedItem.FullName.ToString();

    if (File.Exists(path))
    {
        var (dateCreated, dateLastAccessed, fileName, fileExtention, fileLength, hasError) = engine.GetFileProperties(selectedItem.FullName.ToString());

        if (!hasError)
        {
            txtFileName.Text = fileName;
            txtExtension.Text = fileExtention;
            dtCreated.Value = dateCreated;
            dtLastAccessed.Value = dateLastAccessed;
            txtFilePath.Text = selectedItem.FullName.ToString();
            txtFileSize.Text = $"{Round(fileLength.ToMegabytes(), 2).ToString()} MB";
        }
    }
}

同样,这是非常微小的变化;我们现在只是引用底层模型,而不是将完整的文件名(带有路径)存储在节点标签属性中,这样就更清楚了。我们的下一步是移除现有的 WinForms TreeView控件。

删除现有的树形视图

以下代码应从ImportBooks.Designer.cs中删除:

// 
// tvFoundBooks
// 
this.tvFoundBooks.Location = new System.Drawing.Point(12, 41);
this.tvFoundBooks.Name = "tvFoundBooks";
this.tvFoundBooks.Size = new System.Drawing.Size(513, 246);
this.tvFoundBooks.TabIndex = 8;
this.tvFoundBooks.AfterSelect += new System.Windows.Forms.TreeViewEventHandler(this.tvFoundBooks_AfterSelect);

这将移除控件本身。稍后,我们需要删除将TreeView添加到控件集合中的以下代码:

this.Controls.Add(this.tvFoundBooks);

就这样。如果您现在运行该项目,您将在 WinForms 应用的正中间看到一个 UWP TreeView控件。

树摇动和编译成单个可执行文件

web 应用越来越受欢迎,超过桌面应用的主要原因是部署问题。从表面上看,这听起来是一个微不足道的问题,但绝对不是。已经有很多尝试来解决这个问题,从 ClickOnce 等技术到应用商店模型(UWP、苹果和谷歌的模型)。这在桌面世界如此困难,在网络世界如此简单的原因之一是,虽然两者可能都有复杂的依赖关系树,但网络允许这些依赖关系主要存在于服务器上,因此它们不需要直接部署到客户端机器上。

中的一个有用特性.NET Core 3 能够将所有依赖项捆绑到一个可执行文件中。

This has previously been possible using the concept of IL weavers. This topic is beyond the scope of this book; however, because IL is not compiled, it opens the door to changing it after the project has been deployed.

英寸 NET Core 3,我们可以通过在.csproj文件中添加以下行,将我们的项目编译成单个可执行文件:

<PublishSingleFile>true</PublishSingleFile>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<PublishReadyToRun>true</PublishReadyToRun>

当您发布应用时,您将获得一个可执行文件。

你甚至可以通过使用内置的树摇动来减少这个可执行文件的大小。这是删除应用不使用的依赖项的过程;这需要在.csproj文件中增加以下行:

<PublishTrimmed>true</PublishTrimmed>

At the time of writing, this method did not copy across assets (images), so you will need to do that manually until that issue is fixed. Please see the following link for the current details on this feature: https://docs.microsoft.com/en-us/dotnet/core/whats-new/dotnet-core-3-0.

摘要

在本章中,我们将一个现有的 WinForms 应用迁移到.NET Core 3。这意味着,即使我们可能有一个使用了 10 年或 15 年的应用,您也可以更新它,以使用最新的框架,并利用所提供的性能改进和新功能.NET Core 3。

利用这次升级,我们研究了 C# 8 的一个关键特性:可空引用类型。这意味着,在不使用任何第三方软件的情况下,我们能够暴露遗留代码库中的几十个潜在错误。

我们没有止步于此;然后,我们使用 XAML 群岛来扩展我们的应用,将 UWP 功能合并到 WinForms 中。这可能是最令人兴奋的特性,因为它本质上意味着您可以从外部重写遗留的 WinForms 应用。

在最后一节中,我们利用了中提供的新打包方法.NET Core 3。我们使用树摇动的过程来减小输出文件的大小,并将代码编译成单个可执行文件。

和我一样,你可能会看到一个具有这些特征的模式。。在这个版本中,NET Core 加入了只支持 Windows 的功能,这意味着你现在可以使用一个旧的 WinForms 应用,并将其转换为使用最新的.NET Core,从而受益于所有的性能改进。您可以使用在 WPF 或 UWP 创建的组件来扩展它,而无需重写应用。此外,部署现在变得更加容易,因为我们现在可以编译成一个可执行文件。

在下一章中,我们将研究 ASP.NET Core 3,并创建一个使用 Cosmos DB 的 MVC 应用。**

二、使用 Cosmos DB 的任务 Bug 记录 ASP.NET Core MVC 应用

在这一章中,我们将通过创建一个任务/错误日志应用来看看如何将 Cosmos DB 与 ASP.NET Core MVC 结合使用。个人任务管理器很有用,当你不能立即处理 bug 时,记录 bug 尤其方便。

我们将在本章中讨论以下主题:

  • 在 Azure 上设置 Cosmos DB 实例
  • Cosmos DB 的扩展和复制特性
  • 创建 ASP.NET Core MVC 应用并集成 Cosmos DB

Cosmos DB 是微软对名为 DocumentDB 的文档数据库的重塑。除了提供 NoSQL 数据库的功能外,它还提供了一个几乎不费吹灰之力的全球可扩展解决方案。

技术要求

本章的代码可以在 https://github.com/PacktPublishing/C-8-and-.的 GitHub 上找到 NET-Core-3-项目-使用-Azure-第二版。

使用 Cosmos DB 的好处

也许值得花些时间来探索为什么您会选择使用 Cosmos 而不是无数其他数据库引擎中的一个。这里有两个问题:为什么是云,为什么是宇宙?

为什么是云?扩大规模是微软的工作,而不是你的

因为 Cosmos DB 是微软管理的,过渡到它就意味着突然有一些事情你不用担心了。例如,您是否尝试过将 SQL Server 配置为故障转移,或者如果您位于美国,而您的客户在澳大利亚,该怎么办?Cosmos 通过现收现付的方式为您处理所有这些场景。显然,如果你想让你的数据在四大洲复制,并拥有巨大的吞吐量,那么你将付出更多。套用温斯顿·丘吉尔的话:

"Using a cloud solution is the most expensive thing you can do - except for all the others!"

如果你曾经参与过关于购买一台像样的服务器和管理故障转移的讨论,以及雇佣人员来安装和支持这些服务器的成本,你会意识到它毕竟没有那么贵。

为什么是宇宙?与行业领先的原料药和全球分销的兼容性

宇宙支持所谓的多模型应用编程接口。这意味着,例如,如果您有一个使用 DocumentDB 或 MongoDB 的现有应用,您可以简单地将其指向 Cosmos 实例,并告诉它您想要使用该特定的应用编程接口。您可以要求将数据复制到接近数据访问位置的区域,从而减少延迟。

设置蔚蓝 Cosmos DB

在本书中,我们将使用 Azure 作为首选的云提供商。如果你想继续并且目前没有账户,那么你可以在https://azure.microsoft.com/创建一个账户。在写这篇文章的时候,你可以免费注册,并在第一个月获得 150/$200 的学分。

注册后,请访问 https://portal.azure.com。在这里,您可以管理您的 Azure 资源并检查您的余额。

All cloud providers (at least at the time of writing) have a business model whereby you will get billed for your usage. The fact that you can walk away from your machine does not necessarily mean that any processes that you may have been running in the cloud will stop. This is a very different paradigm from the days when your machine being off meant that nothing was running. At the end of each chapter that uses cloud resources will be a Clean up section, which will talk you through the process of tearing down all the resources that you have created.

登录门户后,搜索 Cosmos DB:

选择 Azure Cosmos DB 后,您将获得 Cosmos DB 刀片:

In this context, the term blade refers to the discrete sections of the portal.

If you click the little pin in the top right-hand corner, the blade will be added to your dashboard, making it easier to locate in the future.

正如您所看到的,我们还没有任何资源,所以让我们通过选择添加来创建一个新的数据库:

单击添加数据库的选项后,将出现一个新屏幕。这让您有机会配置数据库的名称和位置,以及您想要使用的应用编程接口类型。让我们从创建一个新的资源组开始。然后,我们可以将该组用于与本书相关的所有未来资源:

我完成的表单如下所示:

现在让我们更详细地讨论每个选项。

订阅/资源组

您的订阅很可能是由您的组织提供的(例如,MyCompanyLtd Dev),或者您可能正在运行一个私有的 MSDN 订阅。根据您所在组织的规模和您正在开发的产品的规模,您可能会订阅您正在开发的产品,或者订阅整个公司,或者介于两者之间。如果您使用的是 MSDN 帐户,那么它也会出现在这里。

At the time of writing, having an MSDN subscription entitles you to a number of Azure credits each month; the exact number depends on the subscription type.

资源组就是:将资源组合在一起的一种方式。没有规则,但是将具有相似生命周期和目的的资源分组是有意义的。

此图说明了 Azure 中各种层次实体之间的关系:

实际上,资源是任何你可以消费和付费的东西。在我们的例子中,我们的 Cosmos DB 实例是我们的资源;如您所见,这存在于资源组中,而这又存在于订阅中。

虽然对资源组及其管理的详细解释超出了本章(和本书)的范围,但只需将资源视为工具,并将资源组视为工具箱:您可以将管道作业所需的工具(扳手、钳子等)放入工具箱中...)整合到一个工具箱中,以及您可能需要的绘画工具(画笔、遮蔽胶带等)...)放入另一个盒子。资源组是相似的:数据库、使用该数据库的一个或多个应用、监控工具(Application Insights)等都将归入一个资源组。在一个真实的场景中,您可能还会按目的划分这些资源,因此您可能有开发、测试和实时资源组。

将工具箱类比扩展到资源的处理,如果你完成了一项特定类型的工作——例如,如果你不再画画了——你可以简单地将整个工具箱扔进垃圾箱。同样,简单地删除资源组将删除其中的任何资源。

您可能希望做的一件事是将您为这本书创建的所有资源保存在一个资源组中,而不是在最后遵循清理过程,只需删除资源组(要么在每章的末尾,要么在书的末尾)。

帐户名

帐户名是数据库实例将如何呈现给全世界;你会注意到,当你选择一个名字时,它会被加上.documents.azure.com。该名称必须是全局唯一的,也就是说,无论订阅是什么,它在整个 Azure 中都必须是唯一的。你会注意到,当你被要求在 Azure 中命名某个东西时,过一会儿,这个名字会被打上一个勾号(也就是说,你可以使用你选择的名字)或一个叉号(表示你不能这样做,通常是因为它被取了或者格式错误)。

应用接口

该应用编程接口允许您以不同的方式与实例交互——例如,我们在这里选择了 MongoDB,这意味着我们将能够像对待 MongoDB 实例一样对待 Cosmos DB 实例。

Setting the API does change the way that you interact with the database, but it does not change the underlying data. At the time of writing, it is not possible to change this selection once you have made it however; it would appear that the functionality to switch this API selection after creation is something that Microsoft would like to introduce in the future.

这是这本书的第二版,在第一版中,本章讨论了与 MongoDB 的接口。通过选择一个 MongoDB API,我们应该能够使用完全相同的代码并简单地切换数据库。

位置

位置是一个比我们这里所能涵盖的更大的话题;然而,本质上你是在选择资源的物理位置。因为信息以有限的速度传播,所以你应该仔细考虑这个选择;但是,Cosmos 确实允许有机会在其他物理位置复制数据。因此,如果像我一样,你位于英国,你可以在那里设置位置。但是,比方说,如果你的用户群有 30%位于澳大利亚,那么有了 Cosmos,你就可以在该地区复制你的数据,从而减少延迟。

地理冗余/多区域写入

地理冗余和多区域写入设置与 Cosmos 同时在多个物理位置存储数据的能力相关。这两个都是非常大的主题,但基本思想很简单:如果数据安全非常重要,就让数据地理冗余,如果您希望降低延迟时间并从全球多个物理位置进行访问,就启用多区域写入。

As is the case with all Azure services, these services have a cost associated with them. The billing model is relatively complex because the services are complex. If you are concerned about cost, Microsoft provides a calculator (https://azure.microsoft.com/en-gb/pricing/calculator) that will give you an idea of how much any given service might cost.

完成所有这些设置后,选择查看+创建,然后单击创建。

Creating the resource can take a few minutes, so this may be a good time to get a coffee.

配置 Cosmos DB 实例

现在创建了资源,我们可以像对待 MongoDB 一样对待这个数据库实例。首先,我们需要启动数据资源管理器:

从这里,我们可以选择以下选项来创建新集合:

如您所见,我在这里保留了默认值。值得注意的是,固定(10 GB)不是推荐值,在生产级应用中,您很可能希望选择无限制。如果您这样做,那么您将需要提供一个分区密钥。我将吞吐量保留为默认的每秒 1000 请求单位 ( RU/s ):这有效地允许您为所需的性能付费;越慢越便宜(反之亦然)!创建后,您应该能够看到您的新收藏:

最后要做的是导航到连接字符串选项卡并复制主连接字符串:

现在我们的 Cosmos DB 实例已经配置好了,我们可以继续创建我们的网络应用并连接到它。

将您的 ASP.NET Core MVC 应用连接到 Cosmos DB

当谈到在您的应用中使用 Cosmos DB 时,人们想知道将这一功能添加到一个新的 ASP.NET Core MVC 应用中会有多容易。这个过程真的很简单。首先,创建一个新项目:

  1. 项目名称BugTracker:

  1. 选择创建 ASP.NET Core 网络应用的选项:

  1. 在下一个屏幕上,选择以下选项(在下面的屏幕截图中引用):

  1. 从下拉列表中选择 ASP.NET Core 3.0。
  2. 选择网络应用(模型-视图-控制器)。
  3. 取消选中启用 Docker 支持选项。最后,点击确定按钮。
  4. 单击创建,您的新 ASP.NET Core MVC 应用将被创建。

Enabling Docker support for your application can easily be done at creation time. You can also enable Docker support for existing applications.

我们将在后面的章节中了解 Docker 以及如何让您的应用与 Docker 一起工作。目前,我们的应用不需要 Docker 支持。不要选中它,像平常一样创建应用。

添加 NuGet 包

我们需要向我们的项目添加 MongoDB 客户端应用编程接口。最好的方法是添加 NuGet 包。我们可以这样做:

  1. 右键单击您的项目,然后选择管理/获取包...从上下文菜单中,如下图所示:

  1. 在“获取”屏幕上,选择“浏览”选项卡,并输入Mongodb.Driver作为搜索词。

  2. 选择 MongoDB。由 MongoDB 选项驱动。

  3. 单击“安装”按钮将最新的稳定包添加到您的项目中。这显示在下面的截图中:

  1. 您可以在 Visual Studio 的“输出”窗口中查看进度。

  2. 将 MongoDB 添加到项目中后,您将看到 MongoDB。驱动程序(2.5.0)已添加到项目的 NuGet 依赖项下,如下图所示:

  1. 展开Controllers文件夹。你会看到,默认情况下,Visual Studio 已经创建了一个HomeController.cs文件。该文件中的代码应该如下所示:
public class HomeController : Controller
{
    private readonly ILogger<HomeController> _logger;

    public HomeController(ILogger<HomeController> logger)
    {
        _logger = logger;
    }

    public IActionResult Index()
    {
        return View();
    }

    public IActionResult Privacy()
    {
        return View();
    }

    [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
    public IActionResult Error()
    {
        return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
    }
}

我们希望能够从这里连接到 Cosmos DB,所以让我们创建一些代码来连接到 Mongo 客户端。

You will need to add a using statement to your class as follows:

using MongoDB.Driver;

连接到 MongoDB 的步骤如下:

  1. 通过键入代码片段短代码ctor并跳转两次,或者通过显式键入代码来创建构造函数。您的构造函数需要创建一个新的MongoClient实例。完成此操作后,您的代码应该如下所示:
public HomeController() 
{ 
    var mclient = new MongoClient(); 
} 

While this illustrates the usage of the MongoClient, instantiating a class inside a constructor like this is generally considered bad practice, as it makes it virtually impossible to unit test.

  1. 为了让MongoClient工作,我们需要给它一个连接字符串到我们创建的 MongoDB 实例。在解决方案“Bug 跟踪器”窗格中打开appsettings.json文件,如下图所示:

  1. 当您打开您的appsettings.json文件时,它应该如下所示:
{ 
  "Logging": { 
    "IncludeScopes": false, 
    "LogLevel": { 
      "Default": "Warning" 
    } 
  } 
} 
  1. 这时您将需要之前复制的连接字符串。修改文件并添加 MongoDB 连接细节,如下所示(用您之前复制的值替换[connectionstring]):
{ 
  "MongoConnection": { 
    "ConnectionString": "[connectionstring]", 
    "Database": "TaskLogger" 
  }, 
  "Logging": { 
    "IncludeScopes": false, 
    "LogLevel": { 
      "Default": "Warning" 
    } 
  } 
}
  1. 我们现在要在Models文件夹中创建一个Settings.cs文件,如下图截图所示:

  1. 打开Settings.cs文件,并添加以下代码:
public class Settings 
{ 
    public string ConnectionString { get; set; } 
    public string Database { get; set; } 
} 
  1. 我们现在需要打开Startup.cs文件,修改ConfigureServices方法如下,注册服务:
public void ConfigureServices(IServiceCollection services) 
{     
    services.AddControllersWithViews();
    services.AddRazorPages();

    services.Configure<Settings>(Options => { Options.ConnectionString = Configuration.GetSection
 ("MongoConnection:ConnectionString").Value; Options.Database = Configuration.GetSection
 ("MongoConnection:Database").Value; }); }
  1. 返回HomeController.cs文件,修改构造函数,将连接字符串传递给MongoClient:
public HomeController(IOptions<Settings> settings) 
{             
    var mclient = new 
     MongoClient(settings.Value.ConnectionString);     
} 
  1. 此时,我们希望测试我们的代码,看看它是否真的在访问我的 MongoDB 实例。为此,请修改代码以返回集群描述:
IMongoDatabase _database; 

public HomeController(IOptions<Settings> settings) 
{             
    var mclient = new 
     MongoClient(settings.Value.ConnectionString);             
      _database = mclient.GetDatabase(settings.Value.Database); 
} 

public IActionResult Index() 
{ 
    return Json(_database.Client.Cluster.Description); 
}
  1. 运行您的 ASP.NET Core MVC 应用,并在浏览器中读取信息输出,如下图所示:

这一切都很好,但是让我们看看如何将添加数据库连接的逻辑分离到自己的类中。

创建 MongoDbRepository 类

要创建MongoDbRepository类,我们需要经过以下步骤:

  1. 在解决方案中创建新文件夹Data。在该文件夹中,创建一个名为MongoDBRepository的新类:

  1. 在这个类中,添加以下代码:
public class MongoDBRepository 
{ 
    public readonly IMongoDatabase Database; 

    public MongoDBRepository(IOptions<Settings> settings) 
    { 
        try 
        { 
            var mclient = new 
             MongoClient(settings.Value.ConnectionString); 
            Database = 
             mclient.GetDatabase(settings.Value.Database); 
        } 
        catch (Exception ex) 
        { 
            throw new Exception("There was a problem connecting 
             to the MongoDB database", ex); 
        } 
    } 
} 

如果代码看起来很熟悉,那是因为它与我们在HomeController.cs类中编写的代码相同;然而,这一次,它有一点错误处理,并且在它自己的类中。这意味着我们还需要修改HomeController类。

  1. HomeController的构造函数和Index动作中更改代码。您的代码需要如下所示:
public MongoDBRepository mongoDb; 

public HomeController(IOptions<Settings> settings) 
{             
    mongoDb =  new MongoDBRepository(settings); 
} 
public IActionResult Index() 
{ 
    return Json(mongoDb.Database.Client.Cluster.Description); 
} 
  1. 再次运行您的应用,您将在浏览器中看到前面显示的相同信息,因此再次输出到浏览器窗口。

唯一的区别是代码现在被适当地分离了,并且易于重用。因此,如果进一步发生任何变化,它只会在这里更新。

现在我们已经创建了 web 应用,我们可以继续访问和更新数据库实例中的数据了。

向 MongoDB 读写数据

在本节中,我们将了解如何从 MongoDB 数据库中读取工作项列表,以及如何向数据库中插入新的工作项(我使用术语“工作项”来指代任务或 bug)。这可以通过执行以下步骤来完成:

  1. Models文件夹中,新建一个名为WorkItem的类,如下图截图所示:

  1. 将以下代码添加到WorkItem类。你会注意到Id属于ObjectId 类型。这表示创建的 MongoDB 文档中的唯一标识符。

You need to ensure that you add the following using statement to your WorkItem class using MongoDB.Bson;.

看看下面的代码:

public class WorkItem 
{ 
    public ObjectId Id { get; set; } 
    public string Title { get; set; } 
    public string Description { get; set; } 
    public int Severity { get; set; } 
    public string WorkItemType { get; set; } 
    public string AssignedTo { get; set; } 
}
  1. 接下来,打开MongoDBRepository类,并将以下属性添加到该类中:
public IMongoCollection<WorkItem> WorkItems 
{ 
    get 
    { 
        return Database.GetCollection<WorkItem>("workitem"); 
    } 
} 
  1. 因为我们至少在使用 C# 6,所以我们可以通过将WorkItem属性更改为表达式体属性来进一步简化它。为此,请将代码更改如下:
public IMongoCollection<WorkItem> WorkItems => Database.GetCollection<WorkItem>("workitem"); 
  1. 如果这看起来有点混乱,请看下面的截图:

大括号getreturn语句被=>λ运算符替换。被返回的对象(在这种情况下,WorkItem对象的集合)在 lambda 操作符之后。这导致了表达式体属性。

创建接口和工作项服务

接下来,我们需要创建一个接口。为此,我们需要完成以下步骤:

  1. 在您的解决方案中创建新的名为Interfaces的文件夹,并将名为IWorkItemService的界面添加到Interfaces文件夹中,如下图所示:

  1. IWorkItemService界面添加以下代码:
public interface IWorkItemService 
{ 
    IEnumerable<WorkItem> GetAllWorkItems(); 
}
  1. 在您的Data文件夹中,添加另一个名为WorkItemService的类,并使其实现IWorkItemService界面。

Be sure to add the using statement to reference your interface. In my example, this is the using BugTracker.Interfaces; statement.

  1. 您会注意到 Visual Studio 会提示您实现该接口。为此,单击灯泡提示,然后从上下文菜单中单击实施界面,如下图所示:

  1. 完成此操作后,您的WorkItemService类将显示如下:
public class WorkItemService : IWorkItemService 
{ 
    public IEnumerable<WorkItem> GetAllWorkItems() 
    { 
        throw new System.NotImplementedException(); 
    } 
}
  1. 接下来,添加一个构造函数并完成GetAllWorkItems方法,这样您的类就如下所示:
public class WorkItemService : IWorkItemService 
{ 
    private readonly MongoDBRepository repository; 

    public WorkItemService(IOptions<Settings> settings) 
    { 
        repository = new MongoDBRepository(settings); 
    } 

    public IEnumerable<WorkItem> GetAllWorkItems() 
    { 
        return repository.WorkItems.Find(x => true).ToList(); 
    } 
} 
  1. 您现在需要打开您的Startup.cs文件并编辑ConfigureServices方法来添加以下代码行:
services.AddScoped<IWorkItemService, WorkItemService>(); 
  1. 您的ConfigureServices方法现在将如下所示:
public void ConfigureServices(IServiceCollection services) 
{ 
    services.AddMvc(); 

    services.Configure<Settings>(Options => 
    { 
        Options.ConnectionString = Configuration.GetSection("MongoConnection:ConnectionString").Value; 
        Options.Database = Configuration.GetSection("MongoConnection:Database").Value; 
    }); 

    services.AddScoped<IWorkItemService, WorkItemService>(); 
} 

你所做的是注册IWorkItemService界面;这将类WorkItemService添加到依赖注入框架中。有关依赖注入的更多信息,请阅读以下文章

创建视图

当我们启动应用时,我们希望看到一个工作项列表。因此,我们需要通过以下步骤为HomeController创建一个视图来显示工作项列表:

  1. Views文件夹中,展开Home子文件夹并删除Index.cshtml文件(如果有)。
  2. 然后,右键单击Home文件夹,从上下文菜单导航至添加|查看。将显示添加 MVC 视图窗口。
  3. 命名视图Index,选择列表作为模板。从模型类的下拉列表中,选择工作项(错误跟踪程序。车型)。
  4. 保持其余设置不变,然后单击添加按钮:

添加视图后,您的解决方案资源管理器将如下所示:

  1. 仔细查看视图,您会注意到它使用IEnumerable<BugTracker.Models.WorkItem>作为模型:
@model IEnumerable<BugTracker.Models.WorkItem> 

@{ 
    ViewData["Title"] = "Work Item Listings"; 
} 

这允许我们迭代返回的WorkItem对象的集合,并在列表中输出它们。还要注意的是ViewData["Title"]已经从Index更新为Work Item Listings

修改家庭控制器

在运行我们的应用之前,我们需要做的最后一件事是修改HomeController类以使用IWorkItemService。让我们通过以下步骤进行设置:

  1. 如下修改构造函数和Index动作:
private readonly IWorkItemService _workItemService; 

public HomeController(IWorkItemService workItemService) 
{ 
    _workItemService = workItemService; 

} 

public IActionResult Index() 
{ 
    var workItems = _workItemService.GetAllWorkItems(); 
    return View(workItems); 
} 

通过这段代码,我们获得了 MongoDB 数据库中的所有工作项,并将它们传递给视图,以便模型使用。

  1. 完成后,运行应用,如下图所示:

此时,数据库中没有工作项,因此我们在浏览器中看到了这个空列表。接下来,我们将添加代码,将工作项插入到我们的 MongoDB 数据库中。

添加工作项

让我们通过以下步骤来添加工作项:

  1. 要添加工作项,让我们从添加一个名为AddWorkItem的类到我们的Models文件夹开始,如下图所示:

  1. 修改类中的代码,使其本质上类似于WorkItem类:
public class AddWorkItem 
{ 
    public string Title { get; set; } 
    public string Description { get; set; } 
    public int Severity { get; set; } 
    public string WorkItemType { get; set; } 
    public string AssignedTo { get; set; } 
}
  1. 接下来,在Views文件夹下创建一个名为AddWorkItem的新文件夹。右键单击AddWorkItem文件夹,选择添加,然后在上下文菜单中单击查看。
  2. 将显示添加 MVC 视图窗口。调用视图AddItem并选择为模板创建。
  3. 从模型类的下拉列表中,选择添加工作项(错误跟踪程序。车型)。
  4. 保持其余设置不变,点击添加按钮,如下图所示:

  1. 打开AddItem.cshtml文件,看一下表单动作。确保设置为CreateWorkItem。下面的代码片段显示了代码应该是什么样子:
<div class="row"> 
  <div class="col-md-4"> 
     <form asp-action="CreateWorkItem"> 
         <div asp-validation-summary="ModelOnly" class="text-danger"></div> @*Rest of code omitted for brevity*@ 

您的Views文件夹现在应该如下所示:

  1. 现在,我们需要对我们的IWorkItemService界面做一个小小的改变。修改界面中的代码,如下所示:
public interface IWorkItemService 
{ 
    IEnumerable<WorkItem> GetAllWorkItems(); 
    void InsertWorkItem(WorkItem workItem); 
} 

我们刚刚指定了实现IWorkItemService接口的类必须有一个名为InsertWorkItem的方法,该方法采用WorkItem类型的参数。这意味着我们需要绕过WorkItemService并添加一个名为InsertWorkItem的方法。我们在WorkItemService界面的代码如下:

private readonly MongoDBRepository repository; 

public WorkItemService(IOptions<Settings> settings) 
{ 
    repository = new MongoDBRepository(settings); 
} 

public IEnumerable<WorkItem> GetAllWorkItems() 
{ 
    return repository.WorkItems.Find(x => true).ToList(); 
} 

public void InsertWorkItem(WorkItem workItem) 
{ 
    throw new System.NotImplementedException(); 
} 
  1. 更改InsertWorkItem方法,将WorkItem类型的单个对象添加到我们的 MongoDB 数据库中。将代码更改如下:
public void InsertWorkItem(WorkItem workItem) 
{ 
    repository.WorkItems.InsertOne(workItem);
}
  1. 现在,我们需要稍微修改一下我们的WorkItem类。向类中添加两个构造函数,一个以AddWorkItem对象为参数,另一个完全不取参数:
public class WorkItem 
{ 
    public ObjectId Id { get; set; } 
    public string Title { get; set; } 
    public string Description { get; set; } 
    public int Severity { get; set; } 
    public string WorkItemType { get; set; } 
    public string AssignedTo { get; set; } 

    public WorkItem() 
    { 

    } 

    public WorkItem(AddWorkItem addWorkItem) 
    { 
        Title = addWorkItem.Title; 
        Description = addWorkItem.Description; 
        Severity = addWorkItem.Severity; 
        WorkItemType = addWorkItem.WorkItemType; 
        AssignedTo = addWorkItem.AssignedTo; 
    } 
} 

我们添加第二个不带参数的构造函数的原因是为了让 MongoDB 能够反序列化WorkItem

During deserialization, an instance of the object is created. As a result, a parameterless constructor is necessary; otherwise, the deserialization would be unable to create the object (as it has no way of determining the relevant parameters.)

  1. 我们现在需要向我们的项目添加另一个控制器。右键单击Controllers文件夹,添加一个名为AddWorkItemController的新控制器。请随意将此添加为空控制器。我们将在下面自己添加代码:

  1. AddWorkItemController控制器中,添加以下代码:
private readonly IWorkItemService _workItemService; 

public AddWorkItemController(IWorkItemService workItemService) 
{ 
    _workItemService = workItemService; 
} 

public ActionResult AddItem() 
{ 
    return View(); 
} 

[HttpPost] 
public ActionResult CreateWorkItem(AddWorkItem addWorkItem) 
{ 
    var workItem = new WorkItem(addWorkItem); 
    _workItemService.InsertWorkItem(workItem); 
    return RedirectToAction("Index", "Home"); 
} 

你会注意到HttpPost动作被称为CreateWorkItem。这就是AddItem.cshtml文件有一个名为CreateWorkItem的表单动作的原因。它告诉视图在单击“创建”按钮时在控制器上调用什么操作。

重定向到工作项列表

另一个值得注意的有趣的事情是,在我们调用WorkItemService上的InsertWorkItem方法之后,我们将视图重定向到HomeController上的Index动作。正如我们已经知道的,这将把我们带到工作项目列表。让我们给AddWorkItem打个电话:

  1. 修改HomeController代码,增加另一个叫做AddWorkItem的动作;这将调用AddWorkItemController类上的AddItem动作:
public ActionResult AddWorkItem() 
{ 
    return RedirectToAction("AddItem", "AddWorkItem"); 
} 
Your HomeController code will now look as follows: 
private readonly IWorkItemService _workItemService; 

public HomeController(IWorkItemService workItemService) 
{ 
    _workItemService = workItemService;             
} 

public IActionResult Index() 
{ 
    var workItems = _workItemService.GetAllWorkItems(); 
    return View(workItems); 
} 

public ActionResult AddWorkItem() 
{ 
    return RedirectToAction("AddItem", "AddWorkItem"); 
} 
  1. 现在,让我们稍微修改一下Index.cshtml视图。为了使索引视图上的列表更加直观,修改Index.cshtml文件。

  2. 添加一个if语句,如果列表为空,允许从列表中添加新的工作项。

  3. 添加一个ActionLink在点击AddWorkItemController时调用AddWorkItem动作(注意我们提供了一个空白区域来强制该动作相对于根而不是HomeController):

@if (Model.Count() == 0)
{
    <tr>
        <td colspan="6">There are no Work Items in BugTracker. @Html.ActionLink(linkText: "Add your first Work Item", actionName: "AddItem", controllerName: "AddWorkItem") now.
        </td>
    </tr>
}
else
{
    @foreach (var item in Model)
    {
    <tr>
        <td>
            @Html.DisplayFor(modelItem => item.Title)
        </td>
        <td>
            @Html.DisplayFor(modelItem => item.Description)
        </td>
        <td>
            @Html.DisplayFor(modelItem => item.Severity)
        </td>
        <td>
            @Html.DisplayFor(modelItem => item.WorkItemType)
        </td>
        <td>
            @Html.DisplayFor(modelItem => item.AssignedTo)
        </td>
        <td>
            @Html.ActionLink("Edit", "Edit", new { /* 
             id=item.PrimaryKey */ }) |
            @Html.ActionLink("Details", "Details", new { /* 
             id=item.PrimaryKey */ }) |
            @Html.ActionLink("Delete", "Delete", new { /* 
             id=item.PrimaryKey */ })
        </td>
    </tr>
    }
}
  1. 现在,将Create New asp-action包装在下面的if语句中:
@if (Model.Count() > 0) 
{ 
<p> 
    <a asp-action="Create">Create New</a> 
</p> 
} 

我们将在稍后讨论这个问题。现在来看一下应用的逻辑,我们可以看到HomeControllerIndex动作列出了工作项。当我们点击添加您的第一个工作项目链接时,我们在HomeController上调用AddWorkItem操作。

AddWorkItemController上的AddWorkItem动作反过来调用AddWorkItemController上的AddItem动作。这只是返回到AddItem视图,在这里我们输入工作项的详细信息并点击创建按钮。

“创建”按钮依次使用HttpPost,由于AddItem视图上的表单操作指向AddWorkItemController类上的CreateWorkItem操作,因此我们将工作项插入到 MongoDB 数据库中,并通过对HomeController上的Index操作执行RedirectToAction调用来重定向回工作项列表。

Now, at this point, if you are thinking that this is a long-winded way to redirect back to the HomeController just to redirect to the AddItem action on the AddWorkItemController, then you're 100% correct. I will show you a quick way to redirect straight to the AddItem action on the AddWorkItemController when the user clicks on the link to create a new work item. For now, just bear with me; I'm trying to show you how we can interact with controllers and actions.

现在,再次运行您的应用:

您将看到列表中的一个链接允许您添加第一个工作项。

这是重定向回AddWorkItemController上的AddWorkItem动作的链接。要运行它,请执行以下操作:

  1. 点击链接,您将看到输出,如下图所示:

  1. 这将带您进入添加新工作项的视图。在字段中输入一些信息,然后单击创建按钮:

  1. 创建按钮调用AddWorkItemController上的CreateWorkItem操作,并重定向回HomeControllerIndex操作上的工作项目列表:

  1. 您可以看到“新建”链接现在显示在列表的顶部。让我们修改Index.cshtml视图,使该链接直接重定向到AddWorkItemController类上的AddItem动作。按照以下步骤更换剃须刀:
@if (Model.Count() > 0) 
{ 
<p> 
    @Html.ActionLink("Create New", "AddWorkItem/AddItem") 
</p> 
} 

您可以看到,我们可以指定应用必须采取的路由,以获得正确的操作。在这种情况下,我们说当点击创建新链接时,我们必须调用AddWorkItemController类上的AddItem动作。

再次运行您的应用,然后单击“新建”链接。您将看到您被重定向到我们之前添加了工作项的输入表单。

The default styling of the views doesn't look too shabby, but they are definitely not the most beautiful designs out there. This, at least, gives you, as a developer, the ability to go back and style the screens with CSS, to prettify them according to your needs. For now, the dull screens are 100% functional and good enough for our purposes.

打开 MongoDB Compass,您会看到其中有一个工作项文档。查看该文档,您将看到我们刚刚从 ASP.NET Core MVC 应用中添加的信息:

我们现在有了一个网站的工作实例。如果你计划扩展这个应用,那么这将是本章的结尾;否则,下一节将讨论如何分解您创建的资源。

清理资源

如前所述,微软根据您的使用情况从 Azure 赚钱。为了避免产生成本,您应该总是清理(即删除)不再需要的资源。如果像我一样,您命名了您的 Cosmos DB 实例错误跟踪器,那么您可以返回到 Cosmos DB 刀片(您以前可能已经将它固定在您的仪表板上;否则,您应该搜索这个)并选择实例。

然后只需选择删除帐户:

与许多 Azure 资源一样,您必须完成第二步来确认您确实想要删除资源:

可能需要几秒钟才能完成,然后就完成了。

摘要

说到 Cosmos DB 和 ASP.NET Core MVC,还是有很多可以学习的。一章当然不足以涵盖全部。数据库位于强大、昂贵的服务器上的日子屈指可数了,这些服务器被放置在办公室的一个机架上,只有一个人有钥匙。这可以看做是好是坏:没有人会意外拔掉或重启服务器,本地停电也不会对其产生任何影响;然而,你(或你的雇主)现在用英镑和便士(或美元和美分)支付低效查询的费用。

在下一章中,我们将了解一下 Azure 上的 SignalR 以及如何创建实时聊天应用。

三、ASP.NET SignalR 聊天应用

聊天应用,以这样或那样的形式,可能和互联网本身一样古老。这并不难相信,因为互联网的最初目的是允许研究人员之间的交流。在早期,这种交流比寄信要快得多,但仍然远远不是即时的。

在本章中,我们将创建一个应用,允许网站的访问者实时聊天。具体来说,我们将涵盖以下主题:

  • 配置 SignalR 服务
  • 设计和设置项目
  • 添加信号库
  • 构建服务器
  • 创建客户端
  • 运行应用

技术要求

在本章中,您将需要一个 Azure 订阅(我们在上一章中简要介绍了如何创建订阅)。我们还将使用一些仅在 Visual Studio 的更高版本中可用的功能;在撰写本文时,15.8.7 是最新版本,但任何晚于 15.8.0 的版本都应该足够了。

本章的代码可以在 https://github.com/PacktPublishing/C-8-and-.的 GitHub 上找到 NET-Core-3-项目-使用-Azure-第二版。

介绍 SignalR

通常,在客户机-服务器关系中,例如访问网站,通信是由客户机发起的。你可以访问一个显示股价的网站,点击一个特定的股票代码,网站就会为你检索该股票的价格。一旦你得到了价格,你可能会让页面打开,并在一个小时内返回。股票的价格完全一样;你刷新网页,股票价格被重新提取,现在显示正确。

解决这个问题的一个可能的方法是让服务器在准备好的时候发送信息给客户端。SignalR 提供这种能力。然而,SignalR 不是一种单一的技术——它实际上是一堆技术,被抽象化了。这是完全透明的,因此作为消费者,您只需调用 signal er 方法来发送或接收消息,并且在内部,signal er 将使用一系列可用的技术来实现这种消息传输。在撰写本文时,该堆栈如下:

  • 求转发到
  • 服务器发送的事件
  • 永久框架
  • 长轮询

详细讨论这些超出了本书的范围,所以我们只说 SignalR 将使用它可用的最佳技术。

您可以在服务器内部自己托管 SignalR 但是,微软 Azure 现在允许您使用微软托管的 SignalR Service。这里的好处是 Azure 将处理扩展,而且该服务可以用作 Azure 无服务器生态系统的一部分;例如,您可以将 signor 与 Azure Functions 一起使用,这样服务器端的脱机进程就可以启动一个函数,该函数又会调用 signor 来更新客户端,并且更新可以推送到成千上万个客户端。

为了展示 SignalR 的能力,我们将构建一个简单的 ASP.NET CoreSignalR 聊天应用。我们的应用将是自托管的,但将使用 Azure 信号服务。

SignalR 项目

在本节中,我们将配置和创建我们的 web 应用。第一步是配置信号服务。

配置天 SignalR

第一步是在 Azure 中创建我们的 SignalR 实例:

  1. 搜索 SignalR,并从结果中选择 SignalR 服务:

  1. 选择此选项后,您将看到信号创建页面:

我们在上一章中介绍了这些设置中的大部分;但是,我要指出的是,资源名称与 Azure 中的许多资源一样,是全球唯一的。这意味着,如果在您阅读本章之前我没有删除此资源,您将无法使用相同的资源名称。

One strategy to avoid this is to establish a prefix or suffix to your naming. For example, if your company was called My Company Name, for example, you might prefix your resources with mcn-. The pricing tier here is free, and it's sufficient for an example project or a POC, but you may wish to explore the paid tier if you intend to use this under significant load.

  1. 如果您单击“创建”,资源将在几分钟后创建。部署完成后,请访问密钥部分并复制主连接字符串:

稍后您将需要连接字符串,所以现在将其粘贴到记事本或类似的工具中。让我们继续创建我们的项目。

创建项目

对于这个项目,我们需要以下元素:

  • 天 SignalR 器实例:这将管理信号器消息。
  • 聊天服务器:这将是我们的服务器端 C#代码,将处理和指导从客户端发送的消息。
  • 聊天客户端:客户端将由用于向服务器发送消息和从服务器接收消息的 JavaScript 函数以及用于显示的 HTML 元素组成。

我们将从设置 Azure 开始,然后是服务器代码,然后转移到客户端,构建一个简单的引导布局,并从那里调用一些 JavaScript 函数。

另外,我们将包括一种方法,将我们的对话历史存档为文本文件。

设置项目

让我们建立这个项目:

  1. 打开 Visual Studio 2019,选择新建项目,如下图截图所示:

  1. 选择 ASP.NET Core 网络应用:

  1. 配置项目(即决定名称和位置):

  1. 我们将使用一个空的项目模板。请确保从下拉列表中选择 ASP.NET Core 3.0:

该项目将被创建,如下所示:

为了使用 SignalR,我们需要添加一些额外的库;我们下一步会这么做。

添加信号库

需要安装两组库,客户端和服务器:

  1. 让我们从服务器库开始。为此,我们将使用 NuGet。从获取软件包管理器中,选择管理获取软件包:

如果您愿意,也可以使用包管理器控制台使用Install-Package Microsoft.Azure.SignalR命令进行安装。

  1. 对于客户端库,我们将使用npm来安装 SignalR 库。

npm is a package manager, like NuGet, but for JavaScript. Feel free to check it out at https://www.npmjs.com.

  1. 让我们确保我们在最新版本的npm上;启动 PowerShell,并在控制台窗口中键入以下内容:
npm install npm@latest -g

这可能需要一段时间,但应该会更新包管理器。

  1. 现在,导航到您的项目目录;例如,如果您有一个与我相似的目录结构,您可以键入以下内容:
cd C:\Dev\packt\RealTimeChat\RealTimeChat
  1. 现在,输入以下命令,点击进入:
npm init
npm install @aspnet/signalr

npm init会在你的项目中创建一个名为package.json的文件;该文件确定需要哪些文件,并将该文件中指定的任何包下载到项目根目录下的node_modules文件夹中。当你初始化节点模块时,你会被问到一系列的问题,对此你可以按进入作为回应。

  1. 如果node_modules目录存在,可以确认下载成功;这不会包含在您的项目中,因此您可能需要选择以显示所有文件:

虽然npm是一个广泛使用的包管理器,但它确实有一个小问题.NET 应用。问题是它将文件下载到node_modules目录中(如前一张截图所示)。这并不理想,因为你将无法访问wwwroot以外的任何东西。此外,默认情况下,它实际上不包含在您的项目中。

  1. 幸运的是,自从 Visual Studio 15.8.0 以来,我们有了一个工具,可以安装和维护这些库。虽然您可以独立于npm使用它,但在这里,我们将使用它来将节点模块放入正确的位置。右键单击项目并选择客户端库...:

随后显示的对话框如下所示:

  1. 在编写本文时,可以将提供程序设置为三个选项之一:现在,将其设置为文件系统。库应该设置为您的库的来源:在我们的例子中,这是node_modules文件夹的目录,也是信号文件所在的位置。

  2. 如您所见,这里的额外好处是您可以挑选您需要的文件。单击“安装”后,您将能够看到软件包几乎立即出现在正确的位置。

有了我们的包,我们可以(最终)开始编写一些代码。

构建服务器

我们需要为我们的聊天程序建立一个服务器,它将包含我们想要从连接的客户端调用的方法。我们将使用信号中枢应用编程接口,它为连接的客户端提供了与聊天服务器通信所需的方法。

信号集线器子类

现在,我们需要创建信号中枢。让我们一步一步地学习如何做到这一点:

  1. 向项目中添加一个类来处理聊天的服务器端。我们称之为Chat:

这需要是信号Hub类的子类。确保添加Micosoft.AspNetCore.SignalR的使用说明。Visual Studio 的快速操作在这方面很有效:

Ctrl-. is the keyboard shortcut for this (currently, it's possibly the second most useful one available in Visual Studio).

  1. 现在,向类添加一个方法来处理发送消息:
public Task Send(string sender, string message) => 
    Clients.All.SendAsync("UpdateChat", sender, message);

该方法将导致任何连接的客户端在传递发送方和消息参数时调用UpdateChat方法。

  1. 现在,添加一个方法来处理归档功能:
public Task ArchiveChat(string archivedBy, string path, string messages)
{
    string fileName = $"ChatArchive_{DateTime.Now.ToString("yyyy_MM_dd_HH_mm")}.txt";
    System.IO.File.WriteAllText($@"{path}\{fileName}", messages);
    return Clients.All.SendAsync("Archived", $"Chat archived by {archivedBy}"); 
}

如您所见,该方法只需获取 messages string 参数的值,将其写入名为ChatArchive_[date].txt的新文本文件,该文件保存到给定的路径,并调用客户端Archived函数。

这一切似乎有点像魔法;事实上,为了让这两个任务真正发挥作用,我们需要做一些更多的脚手架。

配置更改

Startup.cs文件中,我们需要向容器中添加信号服务,以及配置 HTTP 请求管道。让我们开始吧:

  1. ConfigureServices方法中,添加以下代码:
services.AddSignalR().AddAzureSignalR();
  1. Configure方法中,添加以下代码:
app.UseDefaultFiles();
app.UseStaticFiles();
app.UseAzureSignalR(r =>
{
    r.MapHub<Chat>("/chat");
});

Note that we have added app.UseStaticFiles() to the Configure method. Static files are assets that an ASP.NET Core app serves directly to clients. Examples of static files include HTML, CSS, JavaScript, and images.  The call to app.UseDefaultFiles() (which must be called prior to app.UseStaticFiles()) tells the middleware to search for a predefined list of HTML files, including index.html.

我们的服务器完成了。

3.最后,我们需要告诉信号服务如何连接到 Azure 在名为appsettings.json的项目中创建新文件:

  1. 记住我们之前复制的连接字符串;它需要进入这个文件:
{
  "Azure": {
    "SignalR": {
      "ConnectionString": "Endpoint=https://signalr-realtimechat.service.signalr.net;AccessKey=accesskeyhere;Version=1.0;"
    }
  }
}

Make sure that you update the connection string to reflect your connection string.

我们可以(也将)稍后扩展我们服务器的功能,但是现在,让我们转向我们的客户端。

创建客户端

正如我们前面提到的,客户端将由用于向服务器发送消息和从服务器接收消息的 JavaScript 函数以及用于显示的 HTML 元素组成。

wwwroot文件夹中添加一个 HTML 页面,称之为index.html:

我们将保持客户端页面非常简单。我使用div标签作为面板来显示和隐藏页面的不同部分。我也在使用 bootstrap 使它看起来很漂亮,但是你可以用你喜欢的任何方式设计它。我也不会用基础知识来烦你,比如在哪里指定你的页面标题。我们将坚持相关的要素。

我们将创建一个框架页面,并填写函数:

<!DOCTYPE html>
<html>
<head>
    <title>Realtime Chat</title>
    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css">
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
    <script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js"></script>
    <script src="/lib/browser/signalr.min.js"></script>

    <script type="text/javascript">

    </script>

</head>

包含的库

在我们开始填写功能之前,让我们先了解一下linkscript标签在做什么。他们带来了所需的库:

<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css">
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js"></script>
<script src="/lib/browser/signalr.min.js"></script>

如果您不想使用 bootstrap 的外观和感觉,您不需要 bootstrap JavaScript 库或 CSS,但是请注意,我们将在脚本中使用 jQuery,所以把它留在。我们还参考了之前添加到的lib/browser目录中的信号库。

JavaScript 函数

以下代码都将进入<script>标签。

我们的客户端将需要一些代码来发送和消费来自服务器的消息。我试图让 JavaScript 尽可能简单,为了可读性,我选择了 jQuery 代码:

  1. 为我们的信号中枢服务器创建一个变量(我把我的命名为connection),并调用它的start函数:
var connection = new signalR.HubConnectionBuilder()
    .withUrl('/chat')
    .build();

connection.start();

.withUrl'/chat'参数是指我们的Chat.cs类,它继承了 SignalR 的 Hub 接口。

  1. 添加UpdateChatArchived方法,由服务器调用:
connection.on('UpdateChat', (user, message) => {
    updateChat(user, message);
});
connection.on('Archived', (message) => {
    updateChat('system', message);
});

我们只需将从服务器获得的参数传递给我们的updateChat方法。我们将进一步定义该方法。

  1. 定义enterChat功能:
function enterChat() {
    $('#user').text($('#username').val());
    sendWelcomeMessage($('#username').val());
    $('#namePanel').hide();
    $('#chatPanel').show();
};

我们从用户名输入元素的值中设置我们的user标签的文本,将其传递给我们的sendWelcomeMessage方法(我们稍后将定义),并切换相关面板的显示。

  1. 定义一个sendMessage函数:
function sendMessage() {
    let message = $('#message').val();
    $('#message').val('');
    let user = $('#user').text();
    connection.invoke('Send', user, message);
};

我们从message输入元素中设置message变量,然后为下一条消息清除它,并从用户标签中设置user变量。然后,我们使用connection.invoke方法在我们的服务器上调用Send方法,并将我们的变量作为参数传递。

  1. 定义一个sendWelcomeMessage函数:
function sendWelcomeMessage(user) {
    connection.invoke('Send','system',user+' joined the chat');
};

就像我们上一步描述的sendMessage函数一样,我们将使用connection.invoke函数在我们的服务器上调用Send方法。不过这一次,我们传递了'system'字符串作为用户参数,并传递了一些关于刚刚加入的用户的信息。

  1. 定义一个updateChatPanel方法:
function updateChatPanel(user, message) {
    let chat = '<b>' + user + ':</b> ' + message + '<br/>'
    $('#chat').append(chat);
    if ($('#chat')["0"].innerText.length > 0) {
        $('#historyPanel').show();
        $('#archivePanel').show();
    }
};

updateChat只是我们用来更新聊天历史面板的自定义功能。我们本可以在两个connection.on函数中内联完成,但这意味着我们会重复自己。作为任何编码的一般规则,您应该尽量不重复代码。

在这个函数中,我们将chat变量设置为,但是我们希望每个聊天记录行在样式方面看起来都是一样的。在这种情况下,我们简单地用粗体显示我们的用户(用一个冒号),随后显示未格式化的消息,并在最后显示一个换行符。几行聊天看起来像这样:

John: Hello people
Sarah: Hi John
server: Peter joined the chat
John: Hi Sarah, Hello Peter
Peter: Hello Everyone

我们还需要检查聊天div innerText属性,以确定聊天历史和存档面板是否应该可见。

定义archiveChat功能:

function archiveChat() {
    let message = $('#chat')["0"].innerText;
    let archivePath = $('#archivePath').val();
    connection.invoke('ArchiveChat', archivePath, message);
};

像其他所有事情一样,我尽量保持简单。我们采用聊天面板(div)的innerTextarchivePath输入中指定的路径,并将其传递给服务器的ArchiveChat方法。

当然,我们这里有一个小的错误窗口:如果用户没有为要保存的文件键入有效的路径,代码将抛出一个异常。我会让你自己的创造力来解决这个问题。我来这里只是为了 SignalR 的功能。

让我们看看body部分的轮廓:

    <body>
        <div class="container col-md-10">
            <h1>Welcome to Signal R <label id="user"></label></h1>
        </div>
        <hr />

    </body> 
</html>

下面的代码都将按顺序排在水平线之后。

命名部分

让我们先看一下命名部分,然后深入了解一些细节:

    <div id="namePanel" class="container">
        <div class="row">
            <div class="col-md-2">
                <label for="username" class="form-label">Username:</label>
            </div>
            <div class="col-md-4">
                <input id="username" type="text" class="form-control" />
            </div>
            <div class="col-md-6">
                <button class="btn btn-default"
                        onclick="enterChat()">
                    Enter
                </button>
            </div>
        </div>
    </div>

我们需要知道谁是聊天室的参与者。这里,我们添加了一个输入元素来获取用户名,并添加了一个按钮来调用enterChat函数:

  • <input id="username" type="text" class="form-control" />
  • <button class="btn btn-default" onclick="enterChat()">Enter</button>

聊天输入

聊天部分允许用户键入消息并查看已键入的内容:

    <div id="chatPanel" class="container" style="display: none">
        <div class="row">
            <div class="col-md-2">
                <label for="message" class="form-label">
                    Message:
                </label>
            </div>
            <div class="col-md-4">
                <input id="message" type="text" class="form-control" />
            </div>
            <div class="col-md-6">
                <button class="btn btn-info"
                        onclick="sendMessage()">
                    Send
                </button>
            </div>
        </div>
        <div id="historyPanel" style="display:none;">
            <h3>Chat History</h3>
            <div class="row">
                <div class="col-md-12">
                    <div id="chat" class="well well-lg"></div>
                </div>
            </div>
        </div>
    </div>

以下元素允许我们的用户键入消息(输入)并将其发布到服务器(事件按钮为sendMessage):

  • <input id="message" type="text" class="form-control" />
  • <button class="btn btn-info" onclick="sendMessage()">Send</button>

我们还有一个名为<div id="chat" class="well well-lg"></div>的标签,其标识为"chat"。我们用它作为我们对话的容器(聊天记录)。

存档功能

最后,我们有归档部分:

    <div id="archivePanel" class="container" style="display:none;">
        <div class="row">
            <div class="col-md-2">
                <label for="archivePath" class="form-
                 label">Archive Path:</label>
            </div>
            <div class="col-md-4">
                <input id="archivePath" type="text" class="form-control" />
            </div>
            <div class="col-md-6">
                <button class="btn btn-success"
                        onclick="archiveChat()">
                    Archive Chat
                </button>
            </div>
        </div>
    </div>

在这里,我们允许我们的用户指定需要保存(输入)归档文件的路径,并将消息发布到服务器(事件按钮为archiveChat):

  • <input id="archivePath" type="text" class="form-control" />
  • <button class="btn btn-info" onclick="archiveChat()">Archive Chat</button>

If you are going to use this application as a base and extend it, I recommend moving the JavaScript code to a separate .js file. It is easier to manage and is another good coding standard to follow.

现在我们已经创建了应用,让我们构建并测试我们所做的工作。

构建和运行项目

我们已经创建了我们的项目,所以让我们构建它。在 Visual Studio 的顶部菜单上,单击“生成”菜单按钮:

您可以选择构建整个解决方案或单个项目。因为我们的解决方案中只有一个项目,所以我们可以选择任何一个。也可以使用快捷键Ctrl+Shift+B、但是要记住,如果你有多个项目,这只会构建启动项目及其依赖的库。

您应该会在“输出”窗口中看到一些(希望成功的)构建消息:

如果你有任何错误,再看一遍这一章,看看你是否遗漏了什么。

让我们运行该应用,并检查它是否符合我们的预期。

运行应用

运行应用非常简单;然而,测试需要一些独创性:

  1. 运行该应用,点击 F5 (或 Ctrl + F5 开始,无需调试):

Running the application without debugging can have a significant improvement in terms of performance.

  1. 现在,我们可以开始聊天了。输入用户名并点击输入:

如您所见,我们的姓名面板现已隐藏,我们的聊天和存档面板正在显示。由于我们的sendWelcomeMessage(user)功能,我们的服务器也很友好地通知我们加入了聊天。

  1. 每次我们发送消息时,我们的聊天记录都会更新:

让我们看看如何让更多的参与者参与进来。

让派对开始

对话只有在涉及多方的情况下才是对话。所以,让我们开始一个聚会。

如果你在网络上发布应用,你可以用实际的网络客户端来聊天,但我不在网络上(不是那个意义上的),所以我们需要使用另一个技巧。我们可以使用各种浏览器来代表我们不同的聚会客人(网络客户)。

复制您的应用网址(包括端口号),并将其粘贴到其他几个浏览器窗口中。

对于每个新访客(浏览器),您需要指定一个用户名。当他们每个人都加入聊天并开始发送消息时,您将看到我们的聊天记录不断增长。

您可以平铺浏览器(或者如果有额外的浏览器,可以将它们移动到其他显示器上),以查看一个人发送的消息有多少是即时传递给所有人的,这就是 SignalR 的全部意义。

我们从微软边缘的伊恩·基尔米斯开始,所以我们将继续他的工作:

您还会注意到,每位客人的聊天记录只有在他们加入聊天时才会开始。这是设计好的。当客户加入时,我们不会向他们发送历史聊天记录。

存档聊天

要将聊天记录保存到文本文件中,请在archivePath输入元素中输入有效的本地文件夹路径,然后点击存档聊天按钮:

正如我们前面提到的,我们还没有为我们的路径建立适当的验证,所以请确保您使用有效的路径来测试它。如果成功,您应该会在聊天窗口中看到如下消息:

system: Chat archived by Ian Kilmister

您还会在指定路径中找到新创建的具有ChatArchive_[date].txt命名约定的文本文件。

Azure 服务

在这个阶段,让我们暂停一分钟来检查 Azure 门户。使用门户,我们可以很容易地识别服务实际上正在被使用。当我们一直在输入和测试这个应用时,Azure 服务一直在注册活动,我们可以通过快速浏览 SignalR Overview 刀片看到这一点:

如您所见,我们的活动是通过 Azure 上的 SignalR 服务进行的。现在我们已经构建了应用,让我们清理我们使用的资源。

清除

既然我们有了可行的解决方案,我们就需要整理一下。就 Azure SignalR 而言,如果你使用的是免费层,那么保持原样应该不会有任何财务影响;然而,随着您开始更多地使用 Azure,您将看到您的仪表板逐渐填满,变得难以管理。

在信号刀片内部,只需选择删除:

您将被要求确认删除。然后,几秒钟后,您应该会收到通知,告诉您它成功了:

现在,我们已经删除了所有使用的资源,让我们坐下来思考一下我们在本章中讨论的内容。

摘要

正如我们在本章中讨论的,SignalR 非常容易实现。我们创建了一个聊天应用,但是有许多应用可以从实时体验中受益。其中包括股票交易、社交媒体、多人游戏、拍卖、电子商务、财务报告和天气预报。名单可以继续。即使对实时数据的需求不是必需的,SignalR 仍然可以有益于任何应用,使节点之间的通信无缝。

浏览 ASP.NET 通信兵(https://github.com/aspnet/SignalR)的 GitHub 页面,很明显这个库在不断的被加工和改进,这是一个好消息。

随着对快速、相关和准确信息的需求变得越来越重要,SignalR 是一个需要注意的有用工具,特别是因为有了 Azure,您可以简单地将扩展的问题交给微软。

在下一章中,我们将了解使用实体框架核心的数据访问。

四、实体框架核心额 Web 研究工具

"The biggest lie I tell myself is that I don't need to write it down, I'll remember it."     – Unknown

所以,你有几分钟时间补上你的资料。当你滚动浏览时,你会看到一篇文章的链接,文章中有人分享了记忆吉他和弦的新方法。你很想看,但是你现在时间不够。我以后再看,你告诉自己,以后就成了永远。主要是因为你没有写下来。

有各种各样的应用可以满足您保存链接以备后用的需求。但我们是开发者。让我们尽情享受自己写作的乐趣吧!

在本章中,我们将研究以下主题:

  • 实体框架 ( EF )核心历史

  • 代码优先对模型优先对数据库优先的方法

  • 开发数据库设计

  • 设置项目

  • 安装英孚核心

    • 创建模型
    • 配置服务
    • 创建数据库
    • 用测试数据播种数据库
    • 创建控制器
    • 运行应用
  • 测试应用

  • 部署应用

说得很有道理,但别担心,我们会一步一步来。我们去散步吧。

实体框架核心历史

开发一个需要从某种数据库中读取数据和向某种数据库中写入数据的应用,最令人沮丧的部分之一是试图建立代码和数据库之间的通信层。

至少以前是这样,直到实体框架进入画面!

实体框架是一个对象关系映射器 ( ORM )。它映射了你的.NET 代码对象到关系数据库实体。就这么简单。现在,您不必仅仅为了处理简单的 CRUD 操作而关注构建所需的数据访问代码。

当实体框架的第一个版本发布时.NET 3.5 SP1 在 2008 年 8 月,最初的反应并没有那么好——以至于一群开发人员签署了对框架的不信任投票。令人欣慰的是,提出的大多数问题都得到了解决,实体框架 4.0 的发布以及.NET 4.0,把很多关于框架稳定性的批评都抛到了脑后。

实体框架核心是一个完整的重写。微软表示,他们并没有试图在 EF6 和 EF Core 之间实现对等;虽然他们支持许多相同的功能,但英孚核心给了他们机会来解决一些可能不再有意义的原始决定。

代码优先对模型优先对数据库优先的方法

使用实体框架,您可以在三种实现方法之间进行选择。然而,在实体框架核心中,实际上只有一个:代码优先。虽然数据库优先的概念确实存在,但它是一张单程票:也就是说,您可以从数据库中逆向工程代码模式,但从那时起,您可以从代码中生成数据库模型。

开发数据库设计

只有知道自己在做什么,我们才能知道自己在做什么。在我们开始使用我们的数据库、模型和控制器创建解决方案之前,我们需要弄清楚我们想要如何设计数据库。

根据微软的 TechNet,我们可以遵循五个基本步骤来规划数据库:

  1. 收集信息
  2. 识别对象
  3. 建模对象
  4. 确定每个对象的信息类型
  5. 识别对象之间的关系

我们的要求很简单。我们只需要保存一个 web 链接,以便以后导航,这样我们就不会有多个对象之间存在关系。

然而,我们确实需要澄清我们希望为我们的对象(网络链接)保存的信息类型。显然,我们需要网址,但我们还需要什么?确保您了解您的解决方案需要哪些信息以及如何使用这些信息。

用日常用语来思考这个问题——如果你写了一个朋友家的地址,你可能想要的不仅仅是一条街道;可能是你朋友的名字或者某种纸条。

在我们的解决方案中,我们想知道网址是什么,但我们也想知道我们何时保存了它,并有一个位置来捕获注释,以便我们可以为条目添加更多的个人详细信息。因此,我们的模型将包含以下内容:

  • URL
  • DateSaved
  • Notes

当我们开始创建我们的模型时,我们会更详细地讨论,但是我们不要操之过急。我们仍然需要创建我们的项目。

设置项目

使用 Visual Studio 2019,创建 ASP.NET Core 网络应用:

  1. 创建新的 ASP.NET Core 网络应用:

  1. 我们把这个应用叫做WebResearch。这显示在下面的截图中:

  1. 在下一个屏幕上,选择网络应用(模型-视图-控制器)作为项目模板。为了简单起见,将身份验证保持为“无身份验证”。参考以下截图:

  1. 创建的项目将如下所示:

现在我们已经设置了项目,让我们安装所需的包!

安装所需的软件包

我们需要为我们的解决方案安装三个 NuGet 包,这将有助于我们的探索。这是通过包管理器控制台完成的。

转到工具|否获取包管理器|包管理器控制台:

We saw the Package Manager Console previously; however, it's worth pointing out that this is more than it seems (or at least more than its name would suggest). The Package Manager Console is, effectively, a PowerShell console running in the context of your solution.

英孚核心 SQL 服务器

EF Core 提供各种数据库提供商,包括微软的 SQL Server、PostgreSQL、SQLite 和 MySQL。这里我们将使用 SQL Server 作为数据库提供程序。

For a full list of database providers, have a look at the official Microsoft documentation at https://docs.microsoft.com/en-us/ef/core/providers/index.

在控制台窗口中,键入以下命令并点击 Ente r :

Install-Package Microsoft.EntityFrameworkCore.SqlServer  

您应该会看到一些响应行,显示成功安装的项目。

英孚核心工具

接下来,我们将安装一些英孚核心工具,帮助我们从模型中创建数据库。

在控制台窗口中,输入以下命令并点击进入:

Install-Package Microsoft.EntityFrameworkCore.Tools

同样,您应该会看到一些响应行,显示成功安装的项目。

代码生成设计

我们可以使用一些 ASP.NET Core 代码生成工具来帮助我们搭建脚手架,而不是自己编写所有的代码。

在控制台窗口中,输入以下命令并点击进入:

Install-Package Microsoft.VisualStudio.Web.CodeGeneration.Design

像往常一样,检查你是否拿到了Successfully Installed物品。

If you have problems installing any NuGet packages, this may be pointing to an access control issue. As a general rule, I set up my Visual Studio to run as administrator, which sorts out most of those sorts of problems.

安装后,我们的解决方案将在依赖项部分下反映添加的 NuGet 包,如下所示:

创建模型

右键单击项目中的模型文件夹,并添加一个名为ResearchModel.cs的类:

我们实际上需要两个类——一个Research类,它是我们的entity对象的表示,另一个,ResearchContext,它是DbContext的子类。为了简单起见,我们可以将这两个类都放在我们的ResearchModel文件中。下面是代码:

using Microsoft.EntityFrameworkCore; 
using System; 

namespace WebResearch.Models 
{ 
    public class Research 
    { 
        public int Id { get; set; } 
        public string Url { get; set; } 
        public DateTime DateSaved { get; set; } 
        public string Note { get; set; } 
    } 

    public class ResearchContext : DbContext 
    { 
        public ResearchContext(DbContextOptions<ResearchContext> 
        options) : base(options) 
        { 
        } 

        public DbSet<Research> ResearchLinks { get; set; } 
    } 
} 

让我们把它分解如下:

  • 首先,我们有我们的Research类,这是我们的entity对象表示。正如我们在开发数据库设计部分提到的,对于每个链接,我们将保存网址、日期和注释。标识字段是保存信息的数据库表的标准做法。
  • 我们的第二类ResearchContext,是DbContext的子类。这个类将有一个以DbContextOptions为参数的空构造函数和一个用于数据收集的DbSet<TEntity>属性。

我可以在这里给你一个关于DbSet<Entity>的简单概述,但是我更愿意让 Visual Studio 帮助我们。让我们看看下面的屏幕:

如果你悬停在DbSet上,你会得到一个信息弹出窗口,里面有你需要知道的一切!

配置服务

Startup.cs类中,在ConfigureServices方法中,添加带有以下代码的DbContext服务:

string connection = Configuration.GetConnectionString("LocalDBConnection"); 
services.AddDbContext<ResearchContext>(options => options.UseSqlServer(connection)); 

如您所见,我们从配置中设置了一个连接字符串变量,然后将其作为DbContextoptions参数传递给SqlServer

但是坚持住。LocalDBConnection从何而来?我们的配置中没有设置任何内容。反正还没有。我们现在就去做吧。

在项目根目录下打开appsettings.json文件:

默认情况下,您应该会看到一个日志条目。在Logging部分后添加您的ConnectionStrings部分,并添加一个LocalDBConnection属性。

完整的文件应该如下所示:

{ 
  "Logging": { 
    "IncludeScopes": false, 
    "LogLevel": { 
      "Default": "Warning" 
    } 
  }, 

  "ConnectionStrings": { 
    "LocalDBConnection": "Server=(localdb)\mssqllocaldb; 
     Database=WebResearch;  
     Trusted_Connection=True" 
  } 
} 

稍后,我们将了解如何连接到现有数据库,但目前,我们只是连接到本地db文件。

创建数据库

在任何应用的开发阶段,您的数据模型很有可能会改变。当这种情况发生时,您的 EF 核心模型与数据库模式不同,您必须删除过时的数据库,并基于更新的模型创建一个新的数据库。

在您完成第一次实时实现并且您的应用在生产环境中运行之前,这都是有趣的游戏。您不能为了更改几列就删除数据库。当您进行任何更改时,您必须确保实时数据持续存在。

EF Core 迁移是一个漂亮的特性,它允许我们对数据库模式进行更改,而不是重新创建数据库和丢失生产数据。迁移有很多可能的功能和灵活性,这是一个非常值得花时间研究的话题,但我们现在只讨论一些基础知识。

我们可以使用包管理器控制台中的 EF 核心迁移命令来设置、创建和更新数据库(如果需要)。

在包管理器控制台中,我们将执行以下两个命令:

  • Add-Migration InitialCreate
  • Update-Database

第一个命令将在项目的Migrations文件夹中生成代码,用于创建数据库。这些文件的命名约定是<timestamp>_InitialCreate.cs

第二个命令将创建数据库并运行Migrations:

InitialCreate类中Note有两种方法:UpDown。简单来说,升级应用时执行Up方法代码,降级应用时运行Down方法代码。

假设我们想给我们的Research模型添加一个布尔属性,称为Read。为了保持这个值,我们显然还需要将该列添加到我们的表中,但是我们不想为了添加一个字段而删除该表。通过迁移,我们可以更新表,而不是重新创建表。

我们将从改变我们的模型开始。在Research类中,添加Read属性。我们的课将如下所示:

public class Research 
{ 
    public int Id { get; set; } 
    public string Url { get; set; } 
    public DateTime DateSaved { get; set; } 
    public string Note { get; set; } 
    public bool Read { get; set; } 
} 

接下来,我们将添加一个迁移。我们将使用迁移名称作为我们正在做什么的指示。在包管理器控制台中执行以下命令:

Add-Migration AddReseachRead

你会注意到我们的Migrations文件夹中有一个新的类:

让我们看看引擎盖下。你会看到我们的UpDown方法并不像它们在InitialCreate类中那样空洞:

如前所述,Up方法在升级期间执行,Down方法在降级期间执行。现在我们可以看到代码了,这个概念清楚多了。在Up方法中,我们添加了Read列,在Down方法中,我们删除了该列。

如果需要,我们可以对这段代码进行修改。例如,我们可以通过更新代码来更改Read列的nullable属性,使其如下所示:

protected override void Up(MigrationBuilder migrationBuilder) 
{ 
    migrationBuilder.AddColumn<bool>( 
        name: "Read", 
        table: "ResearchLinks", 
        nullable: true, 
        defaultValue: false); 
} 

我们还可以添加一个自定义的 SQL 查询,将所有现有条目更新到Read:

migrationBuilder.Sql( 
    @" 
        UPDATE Research 
        SET Read = 'true'; 
    "); 

我知道这不是一个很好的例子,因为你不会希望每次更新数据库时所有的Research条目都被标记为Read,但希望你理解这个概念。

不过这段代码还没有被执行。因此,目前,我们的模型和数据库模式仍然不同步。

再次执行以下命令,我们将了解最新情况:

Update-Database

用测试数据播种数据库

现在我们有了一个空的数据库,让我们用一些测试数据来填充它。为此,我们需要创建一个在数据库创建后调用的方法:

  1. 在项目中创建一个名为Data的文件夹。在这个文件夹中,添加一个名为DbInitializer.cs的类:

这个类有一个Initialize方法,以我们的ResearchContext为参数:

public static void Initialize(ResearchContext context) 
  1. Initialize方法中,我们调用Database.EnsureCreated方法来确保数据库存在,如果不存在则创建数据库:
context.Database.EnsureCreated(); 
  1. 接下来,我们做一个快速的Linq查询来检查ResearchLinks表是否有记录。论点是,如果表是空的,我们想添加一些测试数据:
if (!context.ResearchLinks.Any()) 
  1. 然后,我们创建一个Research模型的数组,并添加一些测试条目。网址可以是你喜欢的任何东西。我只是去了几个最常见的网站:
var researchLinks = new Research[] 
{ 
 new Research{Url="www.google.com", DateSaved=DateTime.Now, 
  Note="Generated Data", Read=false}, 
       new Research{Url="www.twitter.com", DateSaved=DateTime.Now,  
  Note="Generated Data", Read=false}, 
       new Research{Url="www.facebook.com", DateSaved=DateTime.Now, 
  Note="Generated Data", Read=false}, 
       new Research{Url="www.packtpub.com", DateSaved=DateTime.Now, 
  Note="Generated Data", Read=false}, 
       new Research{Url="www.linkedin.com", DateSaved=DateTime.Now,  
  Note="Generated Data", Read=false}, 
}; 
  1. 填充数组后,我们遍历数组,将条目添加到上下文中,并调用SaveChanges方法将数据保存到数据库中:
foreach (Research research in researchLinks) 
{ 
    context.ResearchLinks.Add(research); 
} 
context.SaveChanges();
  1. 将它们放在一起看起来如下:
using System; 
using System.Linq; 
using WebResearch.Models; 

namespace WebResearch.Data 
{ 
    public static class DbInitializer 
    { 
        public static void Initialize(ResearchContext context) 
        { 
            context.Database.EnsureCreated(); 

            if (!context.ResearchLinks.Any()) 
            { 
                var researchLinks = new Research[] 
                { 
                    new Research{Url="www.google.com", 
                     DateSaved=DateTime.Now, Note="Generated Data", 
                      Read=false}, 
                    new Research{Url="www.twitter.com", 
                      DateSaved=DateTime.Now, Note="Generated
                      Data", 
                       Read=false}, 
                    new Research{Url="www.facebook.com", 
                     DateSaved=DateTime.Now, Note="Generated Data", 
                      Read=false}, 
                    new Research{Url="www.packtpub.com", 
                     DateSaved=DateTime.Now, Note="Generated Data", 
                      Read=false}, 
                    new Research{Url="www.linkedin.com", 
                     DateSaved=DateTime.Now, Note="Generated Data", 
                      Read=false}, 
                }; 
                foreach (Research research in researchLinks) 
                { 
                    context.ResearchLinks.Add(research); 
                } 
                context.SaveChanges(); 
            } 
        } 
    } 
} 

创建控制器

控制器是构建 ASP.NET Core MVC 应用的基本构件。控制器内部的方法被称为动作。因此,我们可以说控制器定义了一组动作。操作处理请求,这些请求通过路由映射到特定的操作。

To read more on the topic of controllers and actions, see the Microsoft documentation at https://docs.microsoft.com/en-us/aspnet/core/mvc/controllers/actions. To read more on routing, see the Microsoft documentation at https://docs.microsoft.com/en-us/aspnet/core/mvc/controllers/routing.

请遵循以下步骤:

  1. 右键单击控制器文件夹,然后选择添加|控制器。
  2. 在支架屏幕上,使用实体框架选择带视图的 MVC 控制器,然后单击添加:

  1. 在下一个屏幕上,为模型类选择我们的研究模型,为数据上下文类选择研究上下文。您可以保持其余部分不变,除非您想要更改控制器名称:

简单看一下创建的控制器,我们会发现我们现在已经有了基本的创建、读取、更新和 删除 ( CRUD )任务。现在,是主要活动的时间了。

运行应用

在我们进入并运行应用之前,让我们确保我们的新页面易于访问。最简单的方法是将其设置为默认主页:

  1. 看看Startup.cs中的Configure法。您会注意到默认路线被指定为Home控制器。
  2. 只需将控制器更改为您的Research控制器,如下所示:
app.UseEndpoints(endpoints =>
{
    endpoints.MapControllerRoute(
        name: "default",
        pattern: "{controller=Research}/{action=Index}/{id?}");
    endpoints.MapRazorPages();
});

如果你熟悉 ASP.NET 2 . x,你会期望在这里看到UseMvc。事实上,UseMvc已被弃用,支持手动构建您需要的 MVC 中间件。

  1. 最后,确保你的Main方法如下:
public static void Main(string[] args)
{
  var host = BuildWebHost(args);
  using (var scope = host.Services.CreateScope())
  {
    var services = scope.ServiceProvider;
    try
    {
      var context = services.GetRequiredService<ResearchContext>();
      DbInitializer.Initialize(context);
    }
    catch (Exception ex)
    {
        var logger = services.GetRequiredService<ILogger<Program>>();
        logger.LogError(ex, "An error occurred while seeding the database.");
    }
  }
  host.Run();
}
  1. 现在,点击 Ctrl + F5 运行应用,看看你的劳动成果:

  1. 如您所见,我们的测试条目可供我们使用。让我们快速了解一下可用的功能:
  2. 单击新建查看我们链接的条目表单:

  1. 输入一些有趣的数据,然后点击创建按钮。您将被重定向到列表视图,并看到我们的新条目已添加到列表底部:

在每个项目旁边,您可以选择编辑、查看详细信息或删除。继续玩这个功能。我们可以做很多事情来改善用户体验,比如自动填写日期字段。我会留给你自己的创造力来改善你认为合适的用户体验。

测试应用

如果您选择扩展这个项目的功能,您可能想添加的一件事是一些自动化测试。让我们避开一些术语:这一部分涉及自动化测试,但不是单元测试。关于测试到底是什么很容易引起争论和讨论,但是为了这一节的目的,我们增加了自动化测试。

由于这个应用是一个非常基本的数据访问应用,自动化测试经常被忽略。可以肯定地说,因为几乎没有逻辑,你在有效地测试英孚核心。不测试基本 CRUD 应用的另一个主要原因一直是它有多难。测试以数据库为中心的应用一直是一项困难、耗时且费力不讨好的任务。

首先,许多单元测试纯粹主义者会认为你不能在测试中包含数据访问,所以你应该模拟数据访问。这通常是通过创建一个存储库模式来抽象数据访问,甚至是通过多层抽象来实现的,但是一旦我们这样做了,我们必须模拟对数据库的调用将返回什么,虽然遵循了单元测试的原则,但这并不能为我们的应用工作提供太多保证。

其次,数据库操作通常不是幂等的;也就是说,一旦您在真实的数据库上运行了一个测试,再次运行它通常会有不同的结果,因为数据已经改变了。过去,人们通过每次重新创建数据库、编写脚本来恢复操作,或者在事务中运行测试然后回滚来避免这种情况。所有这些的一个共同点是它们都非常慢。

实体框架本身就是一种存储库模式,你当然可以抽象出DbContext;然而,有了 EF Core,你就有了一个叫做内存数据库的概念。已经有许多开源项目试图通过在字典或类似程序中维护数据变化来为 EF6 提供这一点。

让我们创建几个自动化测试(我不会称它们为单元测试,因为它们显然不测试单个功能单元):

  1. 第一步是创建一个Test项目:

我们将把这个项目创建为一个类库。对于这个特定的测试,我们将使用XUnit,但是您应该能够毫无困难地用它来替代任何主要的测试库。

  1. 创建项目后,我们可以使用包管理器控制台安装测试库(确保您选择了正确的Test项目):
Install-Package XUnit -ProjectName WebResearch.Test

我们需要更多的库:

Install-Package Microsoft.EntityFrameworkCore -ProjectName WebResearch.Test
Install-Package XUnit.Runner.VisualStudio -ProjectName WebResearch.Test
  1. 接下来,我们需要添加对我们正在测试的项目的引用:

因为控制器是数据访问前面的逻辑,所以我们来测试一下ResearchController。在测试库中创建(或重命名)类为ResearchControllerTests:

  1. 最后,我们需要向测试项目添加一个 NuGet 包,允许我们使用内存中的数据库:
Install-Package Microsoft.EntityFrameworkCore.InMemory

We'll come back to exactly what this is later in this chapter.

让我们看看测试的代码。然后,我们可以了解它在做什么:

[Fact]
public async Task RetrieveDetails_DetailsCorrect()
{
     // Arrange
     var testUrl = "www.pmichaels.net";
     var options = new DbContextOptionsBuilder<ResearchContext>()
         .UseInMemoryDatabase(Guid.NewGuid().ToString())
         .EnableSensitiveDataLogging()
         .Options;
     var researchContext = new ResearchContext(options);
     var researchController = new ResearchController(researchContext);
     var research = new Research()
     {
         Id = 1,
         DateSaved = new DateTime(2018, 10, 24),
         Note = "Useful site for programming and tech information",
         Read = false,
         Url = testUrl
     };

     var createResult = await researchController.Create(research);

     // Act
     var detailsResult = await researchController.Details(1);

     // Assert
     var viewResult = (ViewResult)detailsResult;
     var resultsModel = (Research)viewResult.Model;
     Assert.Equal(testUrl, resultsModel.Url);
}
  1. 创建测试后,启动测试资源管理器:

  1. 一旦出现这种情况,运行所有(或者,此时,我们的单个)测试:

单元测试和XUnit的完整解释超出了本章和本书的范围;然而,为了完整起见,我将涵盖测试中一些更突出的点。

安排/行动/断言

单元测试显然不需要这些注释,但是我发现,当我使用它们时,我可以清楚地识别我在测试什么,并且我在测试单个动作。因为严格来说这不是一个单元测试,所以执行代码的语句不止一个;然而,只有一个正在测试中。这有助于您为测试命名,并识别测试中包含的内容。

内存数据库

下面的代码在DbContext选项中设置内存数据库,并使用这些选项创建一个新的上下文。这与程序使用真实数据运行时创建的DbContext完全相同,但存储在内存中:

var options = new DbContextOptionsBuilder<ResearchContext>()
         .UseInMemoryDatabase(Guid.NewGuid().ToString())
         .EnableSensitiveDataLogging()
         .Options;
var researchContext = new ResearchContext(options);
var researchController = new ResearchController(researchContext);

我们在测试什么?

这个问题总是值得问自己的。测试是代码,这意味着它们需要时间来编写和维护,所以要确保它们能赚到钱。这个测试是否能做到这一点,其实还有待商榷。我们正在做的是创建一个记录,然后检索该记录。这两个步骤之间有一定的逻辑,所以测试肯定是在测试什么。让我们添加第二个测试,以确保我们不会检索不存在的记录:

[Fact]
public async Task RetrieveInvalidRecord_DetailsCorrect()
{
    // Arrange
    var testUrl = "www.pmichaels.net";
    var options = new DbContextOptionsBuilder<ResearchContext>()
                .UseInMemoryDatabase(Guid.NewGuid().ToString())
                .EnableSensitiveDataLogging()
                .Options;
    var researchContext = new ResearchContext(options);
    var researchController = new ResearchController(researchContext);
    var research = new Research()
    {
        Id = 1,
        DateSaved = new DateTime(2018, 10, 24),
        Note = "Useful site for programming and tech information",
        Read = false,
        Url = testUrl
    };

    var createResult = await researchController.Create(research);

    // Act
    var detailsResult = await researchController.Details(2);

    // Assert
    Assert.IsType<NotFoundResult>(detailsResult);
}

如你所见,这和之前的测试完全一样,只是我们测试的是2Id而不是1。然后,我们断言我们期望这不会被发现。

速度

我们在这里讨论的测试确实遵循了大多数 FIRST 测试原则(简称快速隔离可重复自验证彻底);但是,它们不会像典型的单元测试那样快,所以它们应该位于测试金字塔的顶端:

既然我们已经讨论了如何测试我们的应用,那么让我们继续讨论如何部署它。

部署应用

一旦您的应用准备好进行部署,您可以使用一些选项,包括:

  • 微软 Azure 应用服务
  • 自定义目标(IIS、FTP 等)
  • 文件夹
  • 导入配置文件

在 Visual Studio 的“生成”菜单项下,单击“发布网络研究”(或您决定命名的任何项目):

您应该会看到一个屏幕,显示可用的发布选项。让我们仔细看看。

微软 Azure 应用服务

Microsoft Azure 负责创建和维护 web 应用所需的所有基础架构需求。这意味着美国开发人员不需要担心服务器管理、负载平衡或安全性等问题。随着平台几乎每天都在改进和扩展,我们也可以相当有信心,我们将拥有最新和最棒的功能。

关于 Azure 应用服务,我们不会讲太多细节,因为整本书都可以写这方面的内容,但我们肯定可以看看将我们的 web 应用发布到这个云平台所需的步骤:

  1. 选择 Microsoft Azure 应用服务作为您的发布目标。如果您有要发布到的现有网站,可以选择“选择现有”。现在,我假设您需要新建:

  1. 点击“确定”按钮后,Visual Studio 将使用您登录的 Microsoft 帐户联系 Azure,后者将依次检查您是否有 Azure 帐户,并将返回可用的服务详细信息。我为此蓝图创建了一个试用帐户,事先没有设置具体的细节,从下面的截图中可以看到,Azure 将为您推荐一个可用的应用名称和应用服务计划。

  2. 资源组是可选的,如果您没有指定任何内容,它将具有唯一的组名:

  1. 您可以在“更改类型”选项下更改要发布的应用类型。在我们的例子中,我们显然会选择网络应用:

  1. 单击左侧的服务,查看将随出版物设置的服务。第一个框显示了您的应用可能受益的任何推荐资源类型。在我们的例子中,推荐使用一个 SQL 数据库,我们确实需要它,所以我们只需点击添加按钮(+)来添加它:

Azure 将负责 SQL 安装,但是我们需要给它所需的信息,例如如果您的配置文件中已经有一个服务器,应该使用哪个服务器,或者如果您还没有,应该创建一个新的服务器。

  1. 在这种情况下,我们将配置一个新的 SQL Server。单击“SQL Server”下拉列表旁边的“新建”按钮,打开“配置 SQL Server”表单。Azure 将为服务器提供一个推荐的名称。虽然您可以提供自己的名称,但服务器名称很可能不可用,因此我建议您只使用他们推荐的名称。
  2. 为服务器提供管理员用户名和管理员密码,然后点击确定:

  1. 这样做将使您返回到“配置 SQL 数据库”表单,在该表单中,您需要指定数据库名称以及连接字符串名称:

  1. 再次查看“创建应用服务”表单。您会注意到 SQL 数据库已经添加到您选择和配置的资源部分:

  1. 现在,我们可以返回到主机选项卡,它将向您显示当您点击创建按钮时会发生什么的概述。

  2. 如下图所示,将创建应用服务、应用服务计划和 SQL Server 资源:

  1. 创建之后,我们可以通过点击发布按钮来发布我们新的 Azure 配置文件。
  2. 您将在输出窗口中看到一些构建消息,最后应该会出现以下内容:
   Publish Succeeded.
   Web App was published successfully 
   http://webresearch20180215095720.azurewebsites.net/
   ========== Build: 1 succeeded, 0 failed, 0 up-to-date, 0 skipped 
   ==========
   ========== Publish: 1 succeeded, 0 failed, 0 skipped ==========
  1. 您可以在 Azure 门户网站(portal.azure.com)上查看您的仪表板,它将向您显示由于我们的服务创建而在您的帐户上启用的资源:

  1. 发布的应用将在您的浏览器中打开,您很可能会看到一条错误消息。默认情况下,您不会看到关于错误的太多细节,但至少 Azure 为您提供了一些指针,以便您可以获得错误细节。您可以通过将ASPNETCORE_ENVIRONMENT环境变量设置为Development并重新启动应用来实现:

  1. 当您登录到您的 Azure 门户网站时,您可以导航到您的应用服务,然后在应用设置中,用Development的值添加ASPNETCORE_ENVIRONMENT设置并重新启动您的应用:

  1. 现在,我们可以刷新网站。我们应该看到更多关于潜在错误的细节:

  1. 啊,是的!我们仍然指向我们的本地数据库,我们无法从发布环境中访问它。让我们更新我们的appsettings.json,使其指向我们的 Azure DB。
  2. 从 Azure 仪表板导航到 SQL Server,然后导航到属性。在右侧窗格中,您应该会看到一个显示数据库连接字符串的选项:

  1. 复制 ADO.NET 连接字符串,返回到您的代码,并更新appsettings.json文件中的连接字符串条目。
  2. 重新发布应用,你应该可以走了!

自定义目标

下一个发布选项通常称为自定义目标。

这个选项基本上包括任何不是 Azure 或本地文件系统的东西。点击确定按钮后,您可以选择发布方法:

有四种可用的发布方法(或自定义目标),每种方法都有自己的要求:

  • 文件传送协议
  • 网络部署
  • 网络部署包
  • 文件系统

我们还有一个设置选项卡,适用于所有四种方法。让我们快速了解一下我们有哪些选择:

配置选项可以设置为调试或发布。

使用调试,您生成的文件是可调试的,这意味着可以命中指定的断点。但这也意味着性能下降。

使用 Release,您将无法动态调试,但是随着应用的完全优化,性能会有所提高。

在我们的例子中,唯一可用的目标框架是 netcoreapp2.0,但在标准版中.NET 应用,这是您可以设置目标的地方.NET 3.5 或更高版本.NET 4.5 或任何可用的版本。

您还可以指定目标运行时,选择让 Visual Studio 清理目标文件夹,并指定专门用于运行时的连接字符串。

正如我们之前提到的,这些设置适用于所有四种发布方法,我们现在来看一下。

文件传送协议

FTP 发布方法允许您发布到托管的 FTP 位置。对于此选项,您需要提供以下信息:

  • URL 服务器
  • 站点路径
  • 用户名
  • 密码
  • 目标网址

它还允许您根据输入的详细信息验证连接:

网络部署

看看网络部署和文件传输协议的形式,如果你相信它们是一样的,你会被原谅。两者的结果基本相同,都是直接发布到托管站点,但是使用 Web Deploy,您可以获得很多额外的好处,包括:

  • Web Deploy 将源与目标进行比较,并且只同步所需的更改,与 FTP 相比,这大大减少了发布时间。
  • 尽管 FTP 也有它的安全表兄弟,SFTP 和 FTPS,Web Deploy 总是支持安全传输。
  • 适当的数据库支持,允许您在同步过程中应用 SQL 脚本。

发布屏幕如下所示:

网络部署包

“网络部署包”选项用于创建一个部署包,您可以使用它在以后选择的任何位置安装应用。参考以下截图:

文件夹

世界各地的老派开发人员都在使用这个选项,主要是因为我们仍然不太信任一些可用的工具,这个选项允许您发布到您选择的文件夹位置,然后手动将其复制到发布环境中。

只需指定文件夹位置并点击确定:

导入配置文件

Import profile 方法不是一种实际的发布方法,而是一个简单的选项,可以从备份中导入以前保存的配置文件,也可以用于在开发团队之间共享发布配置文件:

选择导入配置文件选项将提示您查找已保存的配置文件(*.publishsettings)。显然,当您创建一个以前的配置文件时,您可以保存它。有时,存储多个发布概要文件是有意义的:这通常是您在本地做的事情,因为在现代软件开发中,很难找到手动部署应用的人;相反,他们将设置一个 CI/CD 管道,这是直接从 GitHub 存储库签入触发的。

既然我们已经成功地发布了我们的应用,我们就到了这一章的结尾。让我们回顾一下我们所学的内容。

摘要

在这一章中,我们在英孚核心街区进行了一次小小的导游之旅。我们从博物馆开始,在参观学区之前查看了实体框架的历史,讨论了代码优先、模型优先和数据库优先实现方法之间的一些差异。TechNet 甚至进行了快速访问,他提供了一些关于设计数据库的想法。

之后,我们花了一些时间构建自己的 EF Core 解决方案,并研究了部署应用的各种方法。我们还查看了用一些测试数据填充我们的新建筑,看看一旦向公众开放,它将如何保持。

旅行结束时,我们参观了配送区,这样我们就可以大致了解可用的部署选项。

这次访问时间太短,无法涵盖实体框架核心世界中所有可用和可能的内容,因为它是一个拥有大型社区的框架,不断致力于改进和扩展其已经广泛的功能。

很高兴知道开发社区不满足于任何平庸,并不断努力改进和扩展功能,比如实体框架,它可能看起来已经相当成熟和广泛了。

在下一章中,我们将研究来自 Azure 的逻辑应用。本章将指导您创建一个逻辑应用,将该应用集成到推特上,并允许您将数据输入电子表格并自动发布到推特上。

五、使用 Azure 逻辑应用和功能构建推特自动活动管理器

自从 200 多年前查尔斯·巴贝奇构想出第一台计算机以来,计算的目的就是自动化和精确。也就是说,我们希望计算机能做我们能做的事情,但速度更快,错误更少。自从模拟计算机时代以来,事情已经发生了很大的变化,现在我们有了可以保存千兆字节数据的系统,而且我们通常把计算机放在口袋里,这让 50 年前把人送上月球的计算机蒙羞!然而,所有这些的前提都是一样的:我们只是希望计算机做我们能做的同样的事情,但是速度更快,错误更少。

我们在这一章中要解决的特殊问题在很大程度上是自动化问题:想象一下,你刚刚在一家新公司开始工作,作为你的第一份工作,你登录了推特,并被告知该公司希望发起一场营销活动来销售他们的新产品。你的任务是定期发送推文,让人们了解他们的新产品。你可以坐在那里,每隔半小时手动输入这些条目,但是如果你能在不到半小时的时间内自动完成这个过程呢?

在这一章中,我们将讨论微软的工作流引擎,逻辑应用,我们将看到如何用很少的代码,我们可以生成一个完全自动化的系统,可以执行相对复杂的任务。

本章将涵盖以下主题:

  • 从头开始创建微软逻辑应用
  • 将微软逻辑应用与 Azure 功能和微软 Excel 集成,以获得更多功能

在本章中,我们将构建一个逻辑应用,它将读取微软 Excel 电子表格,并根据内容发布推文。虽然我们需要的大部分功能将在逻辑应用中提供,但我们需要调用 Azure 函数来进行一些日期计算。

技术要求

与前几章一样,您将需要 Azure 订阅。您还需要一份电子表格,或者访问 Office Online。最后,您需要一个 OneDrive 帐户——这些帐户可以免费设置,并且可以在这里找到:https://onedrive.live.com/

因为我们在这个项目中使用了推特,所以你需要一个有效的推特账户。如果没有,那么可以在这里创建一个:https://twitter.com/signup

与本书其他章节一样,本章的代码可以在这里找到:https://github.com/PacktPublishing/C-8-and-.NET-Core-3-项目-使用-Azure-第二版

工作流引擎、逻辑应用和功能概述

工作流引擎已经存在了一段时间。基本上,这个想法是你有一个非常高级的系统,你可以把复杂的、独立的组件插在一起。微软对此的实现有一个非常直观的图形界面,可以在网络浏览器或 Visual Studio 中编写。当您找不到满足您需求的预构建组件时,您可以调用一个 HTTP 端点,从而允许工作流与 Azure Functions 或另一个云提供商无缝集成。

Azure 函数就是所谓的无服务器;这并不是因为它们很神奇,并且在没有任何硬件的情况下运行,而只是因为您作为开发人员,不需要关心它们在运行什么。它们是功能的小单元——想想 C#中的单个方法——它通常接受一个参数并返回一个值;这正是使用 Azure 函数所得到的结果。

我们已经确定(或者至少在本章结束时我们将确定)使用 Azure Functions 和逻辑应用比从头开始设置所有这些功能更快,但是您还能得到什么?

你得到的主要是规模;你的软件运行在它所需要的硬件上,所以如果你不使用软件,你就不需要付费。如果您遇到使用高峰,微软将提供更多硬件来满足您的软件需求。

创建 Excel 表格

我们需要做的第一件事是创建一个可读的 Excel 电子表格。对 Excel 的任何一种细节的解释都不在本章(和本书)的范围内;但是,在 Excel 中,创建一个新的电子表格并插入一个表格,如下所示:

重命名表(这样以后更容易识别):

At the time of writing, some versions of Excel (the free online ones) don't support the renaming of a table.

将 Excel 文件保存在您的 OneDrive 帐户中。我们将很快回到这个文件,因为它将被用作这个应用的数据源。

在 Azure 门户中构建逻辑应用

在本章中,我们将使用 Visual Studio 和逻辑应用功能(作为扩展提供)来创建和测试我们的应用。但是,在此之前,我们将快速了解一下,如果您选择直接在 Azure 门户中进行构建,您会在哪里进行构建。

在 Azure 门户中,搜索logic apps,然后选择逻辑应用服务,如下所示:

Remember that once the blade has launched, you can pin it to your dashboard using the pin icon on the top right-hand corner.

当刀片启动时,您将看到您当前订阅的任何逻辑应用;如果您没有,那么您将看到一个按钮,邀请您创建一个,如下图所示:

从这一点来看,在 Azure 门户中创建逻辑 app 的过程与使用 Visual Studio 基本相同。如下表所示,在 Azure 门户中创建应用和使用 Visual Studio 插件各有优势:

| Visual Studio | 蔚蓝门户 |
| 源代码控制很容易,对逻辑应用、相关功能和 web 作业的更改可以放在一起 | 更改会被跟踪,但源代码控制并不那么容易 |
| 功能稍微落后于门户 | 最新功能 |
| 需要安装 Azure 工作负载和逻辑应用扩展 | 不需要在本地机器上安装任何软件(甚至不需要 Visual Studio) |

从这里开始,我们将切换到 Visual Studio。

在 Visual Studio 中构建逻辑应用

在开始之前,我们需要经历几个安装步骤;首先是在 Visual Studio 中安装 Azure 工作负载。

Azure 开发工作负载

为此,请运行 Visual Studio 安装程序,并在运行的版本上选择修改,如下所示:

The Visual Studio Installer is a part of the initial installation and is where you change or update your installation of Visual Studio.

选择(或勾选)Azure 开发工作负载,然后选择修改:

如果您当前正在运行 Visual Studio,则需要在更新进行期间关闭它。下载和安装可能需要几分钟,所以这可能是喝咖啡的好时机!

Workloads are collections of features that allow Visual Studio to perform certain tasks, or be aware of certain languages.

逻辑应用扩展

要安装此扩展,首先启动扩展和更新...从 Visual Studio 的“工具”菜单中,如下所示:

这是您可以管理 Visual Studio 扩展的地方。我们将在此搜索我们的逻辑应用扩展,如下所示:

一旦您安装了这个,您将需要重新启动 Visual Studio,一旦您这样做了,您应该准备好开始开发我们的逻辑应用。

创建资源组

现在我们已经安装了 Azure 工作负载和逻辑应用扩展,还有一些功能可用;其中之一是能够创建一个新的项目类型:Visual Studio 中的资源组。我们现在就开始吧:

一旦创建完成,您将看到第二个对话框,询问您想要创建什么。选择逻辑应用,如下图所示:

创建的项目有三个文件,如下所示:

Deploy-AzureResourceGroup.ps1是一个 PowerShell 脚本,它将把你的逻辑应用部署到 Azure 中。另外两个文件是逻辑应用模板本身,描述为 JSON。

如果愿意,可以直接编辑 JSON。如果你有一个非常小的变化要做,或者如果你希望比较或合并变化,这很有效,但是尝试创建这样的逻辑应用至少会有挑战性!相反,右键单击LogicApp.json文件并选择使用逻辑应用设计器打开:

您将被要求选择一个 Azure 订阅和资源组来关联您的应用。然后,您将看到以下内容:

如您所见,有一个有用的小视频让您开始,一些预构建的触发器模板,以及一些几乎准备就绪的模板。

创建工作流

这里的基本流程是在每次循环中询问 Excel 文件,在那里我们找到一个符合条件的行,发送一条带有该行文本的推文,然后删除该行。我们将创建一个空白的逻辑应用,这样我们就可以研究每一步。

步骤 1–选择触发器

一旦您选择了一个空白的逻辑应用,您将看到模板设计器,您需要选择触发器:

A trigger is simply something that will cause the workflow to begin. This could be an event from another system, a file appearing in storage, a message being put on a queue, or just a schedule.

对于我们的情况,我们将只选择时间表,之后,您将获得一个关于时间表类型的选项。在撰写本文时,这里只有一个选项可用,即 Recurrence,所以让我们选择它。

最后,您可以选择希望工作流运行的频率。当我们创建工作流时,保持默认的一个小时;然而,当我们完成它时,我们会将它设置为更具响应性的东西。

The value that you select here may have a very direct effect to the cost of running the workflow. If you have heavy processing running every minute, you might find your Azure bill getting higher. As we're doing very modest processing with this workflow, it shouldn't affect the cost too much to have it run every minute.

一旦您确认了此对话框,循环将被折叠到一个较小的框中,您将能够选择工作流的功能。选择+新建步骤,如下图所示:

Following the initial trigger, you can only add an action, but from subsequent actions, you can add conditions and loops.

步骤 2–读取 Excel 文件

选择“新步骤”后,您将看到数百种可能操作的列表。搜索Excel,选择 Excel Online (OneDrive):

选择 Excel 类别后,列表会缩小。在结果列表中,选择“列出表格中存在的行”(预览):

As you can see, at the time of writing, this feature was still in preview.

选择此选项后,您将被要求登录 OneDrive。这是一次性事件,但对于需要凭据的每个操作来说都是必要的。然后,您的凭据将被安全存储。

现在您需要选择存储在 OneDrive 中的 Excel 文件和您创建的表格:

If you're using the online free version of Excel, then you won't have been able to rename your table. It will, however, still be visible here, but will be named something like Table1. Where you to have more than one table, it would be difficult to identify the correct one.

在下面的截图中,您可以看到我的文件和表格:

随着逻辑应用的开发,有一点需要注意的是,动作的数量增长很快。与使用默认名称如Button1Button2的 UI 设计者一样,如果您离开默认命名,当您只添加了几个动作时,您会发现自己陷入了混乱。我们将在此处重命名我们的操作:

给它起个能识别它的名字很重要:

You may wish to map out the flow first on paper and give each step a code, for example, 001 GetData and 002 Write File; that way, you can map it back.

由于我的工作流程相对简单,我只是给它起了一个描述性的名字。

下一步是遍历电子表格中的日期,确定哪些是过去的日期;然而,在撰写本文时,逻辑应用并不能很好地处理日期,尤其是 Excel 日期。对于这种事情,能够调用 Azure 函数是非常有用的。典型地,对于像这样的高级系统,一旦你到达它没有处理的东西,你就被卡住了。然而,逻辑应用的美妙之处在于,只要有需要,你就可以简单地降入普通代码。让我们现在创建一个 Azure 函数。

新的 Azure 函数

我们将在同一个解决方案中创建我们的新功能。这使我们能够将逻辑应用的所有功能集中在一起:

我们正在创建的项目类型是 Azure 工作负载添加的新项目类型之一,它是 Azure Functions:

一旦你选择了这个,你会看到一个对话框,询问你想创建什么样的函数。Azure Functions v2 现已推出,它利用了.NET Core,所以我们将选择它,我们将选择 Http 触发器:

对 Azure 函数以及它们能做什么和不能做什么的完整而详细的解释超出了本书的范围。然而,简单地说,函数是可以调用的无状态功能,并且能够自动扩展。

但是,如果您曾经使用过 web APIs,那么默认生成的代码应该不会太陌生。我们应该做的第一件事是从函数 1 更改类名:

我们的新类将代表一个单一的 Azure 函数。下面的代码是您需要的函数代码,所以用这个替换默认函数:

[FunctionName("DatesCompare")]
public static IActionResult Run([HttpTrigger(AuthorizationLevel.Function, "get", "post", Route = null)]HttpRequest req, ILogger log)
{
    log.Log(LogLevel.Trace, "C# HTTP trigger function processed a request.");
    string requestBody = new StreamReader(req.Body).ReadToEnd();
    return ParseDates(requestBody);
}

如您所见,除了记录它被调用的事实和用请求体调用ParseDates之外,函数在这里没有做太多的事情。这里需要注意的重要事项都在方法签名中。

虽然函数本身被称为Run,但是装饰器表示它将从其他地方被称为DatesCompare;当我们将其插入逻辑应用时,您会看到更多这样的内容。

HttpTrigger表示函数被调用的时间。在HttpTrigger的情况下,该功能作为端点有效地暴露给整个网络;但是,也可以使用其他触发器;这个列表是不断变化的,可以在这里找到:https://docs . Microsoft . com/en-us/azure/azure-functions/functions-triggers-binding

我们来看看ParseDates的方法如下:

public static IActionResult ParseDates(string requestBody)
{
    dynamic data = JsonConvert.DeserializeObject(requestBody);
    DateTime date1 = (DateTime)data.date1;
    DateTime date2 = DateTime.FromOADate((double)data.date2);
    int returnFlagIndicator = 0;
    if (date1 > date2)
    {
        returnFlagIndicator = 1;
    }
    else if (date1 < date2)
    {
        returnFlagIndicator = -1;
    }
    return (ActionResult)new OkObjectResult(new
    {
        returnFlag = returnFlagIndicator
    });
}

这个方法实际上在功能方面做得很少,因为它实际上只是一个条件检查。这里需要注意的重要一点是DateTime.FromOADate。这将从 Excel 中提取一个日期,并将其转化为标准.NET DateTime格式。

我们也在利用dynamic数据类型;实际上,这告诉 C#运行时不要静态地键入变量。这是一把双刃剑;与 JavaScript 一样,这意味着您可以利用编译时不需要编译器知道的结构。此外,像 JavaScript 一样,这意味着如果你拼错了变量名,你可能会得到一个难以诊断的运行时异常或错误。

The dynamic data type should not be confused with the var keyword. The var keyword does represent a static data type; it's merely a way to have the compiler infer that type itself. A dynamic type is not known by the compiler until the code is executed.

部署和测试功能

现在我们已经编写了函数,我们需要部署它,以便逻辑应用设计人员可以看到它。我们将手动发布该函数;右键单击函数项目,然后选择发布...:

您将看到一个对话框,让您有机会在本地将函数发布到目录或 Azure:

暂时忽略其余部分,并选择发布。因为应用正在发布到 Azure,所以下一个屏幕与它将发布到 Azure 的确切位置相关:

一旦成功部署,您将看到以下屏幕。如您所见,有一个指向该函数部署到的 URL 的链接:

如果您点击此链接,您应该可以访问一个网站,该网站向您保证您的功能确实正在运行并且可以访问:

You can use this endpoint just like any other HTTP endpoint; for example, you could construct a call to it using Postman or another similar tool.

好的,所以它在运行;让我们快速测试一下。在天蓝色门户(www.portal.azure.com)中,搜索你的功能。找到后,选择你的函数名(在我这里是DatesCompare):

如果您滚动到右侧,您将看到一个小选项卡面板,如下所示:

选择测试,将出现一个全新的面板。这将允许你在原位测试你的功能。

As with many things on the Azure portal, unless you have an enormous screen, you'll spend a lot of time scrolling around.

要测试该函数,请输入一个示例 JSON 主体,然后选择运行:

现在,我们的功能已经启动并运行,让我们返回到逻辑应用并探索它。

遍历电子表格

逻辑应用提供了标准编程语言中的大部分功能;除了动作,你还有循环和条件。接下来我们需要的是一个循环。在逻辑应用设计器中,选择新建步骤,然后选择控制类别:

选择此选项后,您可以为每个循环选择一个:

The loop will iterate over each element of the table every time it is invoked. It's worth bearing this in mind, especially if you don't have any conditions that will exclude already processed records.

一旦我们创建了循环,我们就可以在循环中添加一个动作,也就是说,一个将在每次迭代中执行的动作:

这就把我们带到了我们正在做的事情的关键,那就是发一条推文。

发送推文

上一节应该会留下一个对话框,提示您选择一个操作:

  1. 搜索 Twitter,如下图截图所示:

  1. 选择后,您将看到推特上有许多可用的操作,但我们只想发布一条推文,因此我们将选择那一条:

  1. 选择该选项后,系统会提示您登录推特:

  1. 登录后,系统会询问您想要发布什么文本。我们在电子表格中已经有了这个,而且很容易获得:

信不信由你,就是这样。目前,工作流每小时运行一次,遍历列表,并发送它找到的任何推文。然而,我们现在有点太热心了。我们仍然需要只拾取具有正确日期的推文的功能,并且一旦推文被发送,我们需要移除该行(否则一旦日期过去,我们将继续发送它)。

添加条件并删除当前行

让我们从条件开始,这给我们带来了逻辑应用的一个非常有用的特性;虽然可以在流程的底部添加步骤,但也可以在现有流程中插入步骤和条件。每一步之间的区域隐藏一个圆圈中的加号;如果您将鼠标悬停在该区域上,它将会出现,并且可以选择:

在这种情况下,我们将选择推文发布上方的区域。选择后,将出现一个上下文菜单,允许您添加操作。如果您选择该选项,您将看到(现在)熟悉的对话框,允许您选择操作。在该对话框中,选择控制,如下图:

这一次,我们将选择条件。当你选择“条件”时,你会看到表面上看起来像是一组复杂的选项;然而,它实际上非常简单。

每个条件必须是一个测试,得出的答案;不能有任何中间立场。例如,如果测试是Is it 5pm GMT?,那么答案要么是Yes, it is exactly 5pm GMT,要么是No, it is not exactly 5pm GMT。逻辑应用需要知道在这些情况下应该做什么:

在我们的例子中,我们的条件只是使用当前日期和时间(即该函数运行时的日期和时间)以及电子表格上的日期和时间(即我们希望推文发送的日期和时间)调用我们的函数。因此,在添加测试之前,我们需要调用我们的函数。再次,在条件上方插入一个操作,如下所示:

现在,只需选择 Azure 函数作为操作类型:

现在应该会出现可用功能的列表,如下所示:

If you don't see your function here, then the likelihood is that there was a problem while publishing the function.

一旦选择了这个选项,你将会在你的函数应用中看到一个函数列表。

A single function app can contain many functions; however, each function is scaled independently.

将参数传递给函数

使用设计器的下一点并不是特别好的体验。本质上,我们必须将请求体格式化为 JSON 这意味着零件手动键入和零件选择内置功能。一旦你习惯了这一点,你可能会发现自己下降到原始的 JSON 添加这个;然而,让我们一步一步来。

请求主体的完成的 JSON 需要如下所示:

{ date1: '[Current Date]', date2: '[Date from Spreadsheet]' }

在请求正文中,输入 JSON 的第一部分,然后选择添加动态内容:

选择添加动态内容将弹出一个对话框。如您所见,我们的日期在这里,但现在,我们将切换到表达式选项卡:

在表达式选项卡中,您会发现许多内置函数,您会看到 utcNow():

UTC is a time zone that is unaffected by daylight savings or regional variations, which means that if you're dealing with date and times, you should be doing so in UTC (you may convert to the local time zone for display of input purposes).

一旦选择了函数,我们将需要完成 JSON 并从 Excel 传递日期(正如您之前看到的)。完成的 JSON 应该如下所示:

正如我们之前测试函数时看到的,这将返回一个 JSON 文档。如果我们希望稍后在工作流中使用这个函数的输出,我们需要解析JSON 输出。如果您在DatesCompare函数调用后直接插入一个动作,您将能够为此选择一个特殊的动作:

If you're wondering why you would ever not want to use the output of a function, it's worth remembering that functions can simply perform tasks; for example, you could have a function that updates a database or writes to a text file.

我们有一个名为Body的函数的输出。照目前的情况来看,这是标准的 JSON,所以您可以自己简单地剖析它;然而,我们将把它传递给我们的 Parse JSON 函数。

我们还需要指定模式,因为不太可能有人能够获得 JSON 模式。有一个漂亮的小函数,允许您只给它 JSON 输出(您可以直接从我们之前看到的测试屏幕中获得):

Try going back to the earlier part of the chapter and copying the output to paste into here.

生成的模式应该如下所示:

我们现在有一个日期比较,所以让我们回到我们的条件。

回到我们的状态

我们可以简单地插入值。记住Date1是我们当前的 UTC 日期,Date2是我们的 Excel 日期。所以逻辑应该测试当前日期是否等于或晚于 Excel 日期;在这一天,我们可以发布推文:

最后,让我们把我们的推文动作拖进真正的分支:

这基本上是完成的功能。我们现在应该已经成功发布了这条推文。

删除行

下一步是删除该行;我们将在推文下方添加以下内容:

再次搜索Excel,选择 Excel Online (OneDrive)。最后,我们将选择删除一行(预览):

在出现的对话框屏幕中,需要选择相同的电子表格和表格;然后,您将被要求选择一个键列和值:

这些列用于唯一标识要删除的行。在我们的例子中,我们只有两个选择:我们可以选择日期(这意味着我们只能安排在给定的日期发送一条推文),或者推文文本(这意味着我们不能多次发送同一条推文文本)。就我们而言,我们会选择日期。

If you were to extend this application, you may wish to define a third column with a unique identifier (you can even have Excel automatically generate this for you).

在键值框中,我们将选择较早的日期:

我们现在可以继续发布逻辑应用了。

发布逻辑应用

我们现在正处于最后阶段。剩下的就是将应用发布到 Azure。右键单击逻辑应用,选择部署,然后选择新建...:

As you can see from this screenshot, once you've deployed the project, a shortcut will be created to deploy to the correct resource group.

在出现的对话框中,要求您选择一个资源组:

最后,将要求您填写任何缺失的参数;在我们的例子中,这只是应用的名称:

发布后,您可以通过检查您的推特帐户和电子表格来检查工作流是否成功:

不过,你也可以看看日志。这些详细说明了工作流执行的时间,更重要的是,它们可能失败的地方。

失败

事实上,如果您完全复制了前面的步骤,您会发现,尽管做了它应该做的事情,应用还是报告失败了:

让我们看看是否能找到原因;点击其中一个失败,你会看到逻辑应用在抛出错误之前到底走了多远:

所以,我们可以看到我们已经走到ForEach了,有东西失败了,但是乍一看,好像成功了。选择下一个失败选项跳转到问题:

好吧,我们的功能有问题。逻辑应用非常擅长帮助诊断问题;继续点击要深入的步骤(点击日期准备):

那么,我们传递一个空字符串。原因当然是因为我们的 Excel 表包含空白条目。您可以通过简单地将表的大小缩小到可用的数据来解决这个问题,或者更改函数来过滤掉空行;不管怎样,我们现在都成功了:

我们现在有一个工作逻辑应用。让我们整理一下本章使用的资源。

清理 Azure 资源

在本章中,我们只使用了两个资源;让我们从清理逻辑应用开始:

  1. 在 Azure 门户中,选择逻辑应用刀片:

  1. 在此屏幕中,您现在应该能够看到您的应用:

  1. 接下来,我们可以以类似的方式找到我们的功能——选择功能刀片,或者从菜单选项中选择(如您所见,我的选项没有功能),或者只搜索Functions。同样的过程适用于:

If you don't tidy the function app, then it is unlikely you will be charged. Functions only execute when they are invoked; however, the logic app is designed to run at a set interval. If you chose not to tidy it, then you will probably incur charges.

既然我们已经处理了我们的资源,让我们回顾一下这一章。

摘要

我们现在已经创建了一个逻辑应用,从 Excel 电子表格中检索数据,分析数据,并根据结果与推特接口。虽然有很多步骤,但我希望你能看到,这比你必须自己编写所有这些接口要快得多。此外,您可以记录对您的逻辑应用的每个调用,因此您可以轻松诊断任何问题。

您可以很容易地添加一个错误处理功能,以便在失败时发送一封电子邮件;也许你想提出一个关于吉拉的问题;也许你有一个网站,你想每天对它进行一次测试,检查它是否还在运行,如果没有,就发邮件给你;也许你有一个服务总线队列,你想在每次收到死信时通知某人。所有这些都是可能的,不需要或只需要很少的代码。

在下一章中,我们将讨论如何使用身份服务器 4 在您的应用中实现授权。

六、使用身份服务器和 OAuth 2 的股票检查器

在现代发展中,建立一个可靠和安全的接口来验证您的用户是绝对必要的。OAuth 2 已经成为这里事实上的标准;然而,由于其历史,OAuth 2 的确切含义取决于你问谁(也就是说,如果你问谷歌,他们可能会告诉你一件与 Twitter 略有不同的事情)。

事实上,如果你想让某人简单地使用安全界面登录,而你对登录过程的细节不感兴趣,你可能会做得比使用这些公司之一来提供你的身份服务差得多。例如,用户可以用他们的推特凭证登录你的网站。

在本章中,我们将开发一个股票检查应用。我们的应用将非常基本:我们将允许人们输入股票代码并获得股票数字,我们还将允许其他人更新股票数字。我们不能依赖互联网接入,因此我们将无法使用在线身份服务。

为了实现这一点,我们将使用一个名为 identity server(https://identityserver.io/)的开源框架。

It's worth bearing in mind that what we are about to build, using IdentityServer, may be overkill for your specific usage scenario. If all you want is to authenticate a user, then you may find that Twitter, Google, or Microsoft's pre-built implementations of OAuth 2 are better suited to your needs. What we will do here is build a custom identity server, albeit using a framework, but it is still more work than using a pre-built offering.

在本章中,我们将涵盖以下主题:

  • 使用身份服务器保护应用编程接口
  • 实现简单的基于角色的权限模型
  • 通用 Windows 平台 ( UWP )应用
  • 创建 ASP.NET Core 3.0 应用编程接口
  • 实体框架核心
  • 为开发目的创建证书

技术要求

在本章中,我们将使用英孚核心和 SQL Server。在本章中,我将假设您运行的是本地安装的 SQL Server 版本。如果您选择连接到不同的 SQL Server 实例,应该没有区别(除了连接字符串)。SQL Server 的下载页面可以在这里找到:https://www . Microsoft . com/en-GB/SQL-Server/SQL-Server-downloads

SQL Server 开发人员版可免费用于开发和测试软件(就像您将在这里做的那样)。也可以使用 SQL Server Express,甚至是商业版的 SQL Server;然而,就目前而言,其中一个免费版本对于这个项目来说已经足够了。

您还需要一种测试应用编程接口的方法。邮差就是这样一个可以用于此的工具,可以在这里找到:https://www.getpostman.com/

你可以在这里找到邮递员的文档(包括一些入门教程):https://learning.getpostman.com/docs。

Visual Studio 安装程序–新工作负载

通过选择“修改”选项,可以通过 Visual Studio 安装程序安装其他工作负载。

为了创建 UWP 应用,您需要安装 UWP 工作负载:

您可能希望为 Visual Studio 下载数据工具:

或者也可以下载 SQL Server Management Studio,可以在这里找到:https://docs . Microsoft . com/en-us/SQL/ssms/download-SQL-Server-Management-Studio-ssms

身份和许可

在我们开始实现我们的解决方案之前,理解这两个概念(以及它们的同义词)是很重要的。在我们的应用中(除了实际检查库存),我们有两个不同的要求:

  • 只有通过认证的人才可以使用。也就是说,作为用户,您必须已成功登录系统。
  • 认证使用该软件的人中,只有其中的一部分可以被授权更新库存数字。

为了更好地说明这一点,让我们想象一个虚构的公司和与该公司有关联的四个人;假设我们公司销售建筑用品:我们就叫 PCM 建筑用品有限公司

格雷厄姆是公司的现场经理;他负责现场发生的一切,包括检查库存水平是否正确,以及当库存水平下降到一定水平以下时向供应商订购。

露西从事销售工作:她接受顾客的订单,并负责装运货物。

莫里斯是看守人,他负责维护大楼,清理场地,每天晚上都锁门。

山姆是一名建筑商,他从 PCM 建筑用品公司购买产品..

在我们的例子中,格雷厄姆需要访问系统并获得检查和更新库存的许可,因为他是站点经理。

露西需要进入系统,但只需要查看库存的权限,不需要更新数字。

莫里斯确实在公司工作,需要进入系统;他只需要更新库存的许可,因为他没有理由检查库存,但可能会在工作中使用一些。

最后,萨姆既不需要进入也不需要许可,因为她不为公司工作。

This company is a fictitious one. We'll use it throughout this chapter for the purpose of testing data to illustrate our product working in these scenarios.

Having never worked at a building supply company, these examples may not reflect reality. If you're thinking that what I've said makes no sense in a real building supply company, then I would ask that you suspend belief as this is purely for the purpose of illustration. Having said that, the principles here are applicable, regardless of the industry you apply them to: this system could easily be applied to a newsagent, a greengrocer, or a clothes shop.

既然我们已经讨论了本章中关键主题背后的概念,让我们继续讨论项目本身。

项目概述

由于我们的要求之一是,该解决方案应尽可能在没有连接的情况下工作,如果没有连接,只需连接到本地网络,我们的项目将由本地托管的 ASP.NET Core 应用编程接口和 UWP 应用组成。每个需要访问该应用的人都将获得一个平板电脑,他们可以在其中检查或更新库存。我们系统的架构看起来像这样(不完全是原始架构):

UWP 客户端应用将提供给所有工作人员,这意味着,除了验证用户之外,该应用还需要防止某些用户访问某些功能。

股票检查应用接口

首先,我们将创建我们的股票检查器应用,它的所有功能对每个人都是启用的,我们将使用 IdentityServer 将应用锁定到只有经过身份验证的用户,最后,我们将只为用户启用正确的功能。

设置

第一阶段将是创建我们的应用编程接口。英寸 NET Core,一个ApiController的概念被简单的Controller代替;也就是说,服务于数据的控制器方法和服务于除返回类型之外的网页的控制器方法之间没有区别。

让我们创建新项目:

我们将创建一个空的应用,并手动添加应用编程接口(目标.NET Core 3.0):

这应该给你一个基本的网络应用;现在,我们可以通过添加一个新的控制器在这些骨头上放一些肉。

添加控制器和路由

在我们的新应用中,我们需要创建自己的控制器。让我们看看如何:

  1. 首先将这些控制器放在它们自己的名为Controllers的文件夹中:

  1. 然后,右键单击文件夹,并选择添加|控制器...:

  1. 您可以让 Visual Studio 为您创建您的控制器,但是,让我们再次滚动我们自己的并选择空:

The new controller inherits from ControllerBase. This is because the Controller class (which itself inherits from ControllerBase) adds some functionality for binding views that relates only to an MVC controller. You could change this to inherit from Controller and it would work fine (although you would be including some functionality that you won't need). You'll notice that the new controller is decorated as an ApiController. Again, this is optional, but it adds some basic validation for you.

因此,您最初的controller方法应该是这样的:

[Route("api/[controller]")]
[ApiController]
public class StockController : ControllerBase
{
}
  1. 为了让它工作,让我们添加一些非常基本的代码,让它返回一些东西:
[HttpGet]
public string Get()
{
   return "test";
}
  1. 现在,我们需要插入一些中间件来找到控制器。在startup.cs中,更改ConfigureServices方法,如下所示:
public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers();
}

这是一个扩展方法,告诉框架您将为 web API 使用与控制器和控制器映射相关的服务,因此它将添加授权和 CORS。

In previous versions of .NET Core, you would add .UseMvc or .UserMvcCore here; however, in .NET Core 3, the framework expects you to add only the parts that you need. This means that your code will never include middleware that isn't necessary. Clearly, the downside is that there's slightly more work to do initially to set up the API.

  1. Configure方法中,告诉应用使用我们之前添加的服务:
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
    if (env.IsDevelopment())
    {
         app.UseDeveloperExceptionPage();
    }

    app.UseRouting();
    app.UseAuthorization();
    app.UseEndpoints(endpoints =>
    {
        endpoints.MapControllers();
    });
}
  1. 现在,您应该能够运行应用并导航到控制器;确切的地址取决于您的端口,但可能如下所示:
https://localhost:44371/api/stock

现在我们有了一个控制器,我们可以创建我们的股票功能。我们有两个要求:检查库存水平和更新库存水平。我们还需要保存这些信息,所以我们将使用 EF Core。

阅读库存水平

让我们看看如何使用实体框架核心将这一点保留到 SQL Server 数据库中:

  1. 因为我们将所有这些都保存到一个数据库中,所以我们将在我们的应用编程接口中安装实体框架核心:
Install-Package Microsoft.EntityFrameworkCore.SqlServer
Install-Package Microsoft.EntityFrameworkCore.Tools

这将安装所需的实体框架库和工具。

  1. 下一步是创建我们的模型;也就是说,在 C#类中创建数据库的映射。您的模型可能看起来像这样:
public class Product
{
    public int Id { get; set; }
    public string Description { get; set; }
    public int StockCount { get; set; }
}

它应该位于应用可见的地方。我已经把我的添加到一个名为Models的子文件夹中。

For larger applications, it can make sense to take all of the data access logic and move it to its own library, but since this is a very small project, the extra overhead is probably not warranted at this time. If you maintain an abstraction between the data access and the business logic, moving the code later should be a trivial task. By default, Entity Framework Core uses any fields suffixed with Id to create a primary key. In our case, Id will be treated as a primary key.

Database performance, indexes, and keys are beyond the scope of this chapter and book; however, if you decide to extend this project, it is very likely that, for any volume of data, you would need to consider such things.

  1. 下一步是创建数据上下文。这实际上是一个映射类,用来告诉 EF Core 使用哪些类并映射到您的数据库。目前我们的需要是这样的:
public class StockContext : DbContext
{
    public StockContext(DbContextOptions<StockContext> options) 
        : base(options) { }
    public DbSet<Product> Products { get; set; }
}

这个类有两个重要的部分:声明DbSet,它告诉 Entity Framework Core 我们想要持久化什么,以及它继承了什么,也就是DbContext(它的构造函数)。

  1. 最后,我们需要告诉 ASP.NET Core,我们要用实体框架核心,去哪里找DbContext,去哪里找数据库。这些都位于ConfigureServices内部:
public void ConfigureServices(IServiceCollection services)
{
    services.AddMvcCore();
    var connection = @"Server=(localdb)\MSSQLLocalDb;Database=StockCheckerDB;Trusted_Connection=True;ConnectRetryCount=0";
    services.AddDbContext<StockContext>(options => options.UseSqlServer(connection));
}

You may wish to move the connection string into the config file and use SQL Server security rather than a trusted connection.

  1. 现在我们已经配置了数据,我们将创建一个迁移来更新数据库以反映我们的模型。在包管理器控制台中,键入以下内容:
Add-Migration InitialMigration

这应该会在您的项目中创建新文件夹,称为Migrations,以及两个新文件。如果您快速查看迁移文件,您会看到它由两个功能组成:Up,它告诉 EF 当您向前迁移时该做什么,以及Down,它应该恢复这些更改。

Although this is generated code, it is not continually generated, which means that you can change it if you wish. Be aware that if you change Up, but not Down, you may find that you can't revert a migration, or worse, that reverting the migration leaves you in a new state: neither new nor old.

  1. 下一步是更新数据库(只运行迁移):
Update-Database

现在我们的数据库已经存在,可以访问,并且是最新的,我们将需要我们的控制器函数来访问这些数据。

您可以在应用的任何地方简单地访问DataContext;然而,这会给单元测试带来问题;也就是说,如果你的控制器函数直接访问数据库,那么很难测试一个单元的功能。此外,如果您决定在以后用另一种数据访问方法替换实体框架,这将使它变得更加困难。

为了解决这些问题,我们将把依赖注入到控制器中。让我们看看如何:

  1. 为了将依赖注入到我们的控制器中,第一步是将我们的DbContext类抽象到一个接口中:
public interface IDbContext
{
    DbSet<Product> Products { get; set; }
}

You might be wondering why we would create an interface and not pass the class in directly. In fact, there is nothing preventing this; however, what this would mean is that we would always need to pass in a class of the DbContext type. Creating an interface means that we can replace our DbContext class with a dummy class, or even a completely different class that implements the same interface.

This may seem like abstraction for the sake of it, but consider how you would write a unit test for any method that referenced this DbContext.

For our project, we will simply pass the DbContext around; however, the best practice is to completely abstract the data access, so rather than passing in DbContext, you may pass in an IDataAccess class, which in turn accepts the IDbContext. This means that, should you decide to replace EF Core with another ORM, you would simply change the implementation of this class.

  1. 现在我们有了一个接口,我们可以将它注入控制器:
public StockController(IDbContext dbContext)

If you're using Visual Studio, pressing Ctrl-. on DbContext will give you the opportunity to create and populate a field in the class, saving you from adding the class-level variable:

  1. 最后,当调用Get方法时,我们将从数据库返回数据:
[HttpGet("{id}")]
public ActionResult<int> Get(int id)
{
    Product product = dbContext.Products.FirstOrDefault(a => a.Id == id);
    if (product == null) return NotFound();

    return Ok(product.StockCount);
}

你的StockController现在应该是这样的:

[Route("api/[controller]")]
[ApiController]
public class StockController : ControllerBase
{
    private readonly IDbContext dbContext;

    public StockController(IDbContext dbContext)
    {
        this.dbContext = dbContext;
    }

    [HttpGet("{id}")]
    public int Get(int id)
    {
        Product product = dbContext.Products.FirstOrDefault(a => a.Id == id);
        if (product == null) return NotFound();

        return Ok(product.StockCount);
    }
}
  1. 我们现在有一个依赖注入到我们的控制器;然而,我们需要一些东西来为我们注射。这就是 IoC 容器的作用。在 ASP.NET Core 之前,你可能已经使用了类似 Unity 的东西。如果你愿意,你仍然可以,但是 ASP.NET Core 3 有一个内置的 IoC 容器:
public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers();

    var connection = @"Server=(localdb)\MSSQLLocalDb;Database=StockCheckerDB;Trusted_Connection=True;ConnectRetryCount=0";
    services.AddDbContext<StockContext>
 (options => options.UseSqlServer(connection));
    services.AddTransient<IDbContext, StockContext>();
}

There are many IoC containers available for free. If you choose to use a third-party one, then I would advise that you have a reason to do so; while some of the options out there do offer features that the built-in version does not, you should consider whether that functionality is something that you actually need. Furthermore, outside of experimentation, I would advise against writing your own IoC container as this leaves you with the responsibility of maintaining it in the future.

所以,我们现在应该可以运行这个程序,得到一个股票数字;让我们试试。执行 API 它应该会启动一个会显示 404 错误的浏览器(没关系——只是基址什么都没有)。导航到以下地址:

https://localhost:44371/api/stock/1

Your port may be different—if it is, then simply substitute your port.

现在,您应该能够导航到以下地址:

https://localhost:44371/api/stock/1

浏览器应正确返回股票数字(即0)。

更新库存水平

现在我们可以读取库存水平,让我们添加更改它们的功能。现在检索完成了,更新相对琐碎;你的Update方法应该是这样的:

[HttpPut("{id}")]
public IActionResult Update(int id, [FromBody]int stockCount)
{
    Product product = dbContext.Products.FirstOrDefault(a => a.Id == id);
    if (product == null) return NotFound();
    product.StockCount = stockCount;
    dbContext.SaveChanges();
    return NoContent();
}

如您所见,代码与检索非常相似;我们只需更新dbContext上的库存数量并调用SaveChanges()

为了测试这个,我们需要使用 Postman(如果你还没有安装这个,那么参考技术要求部分):

您应该会发现这将项目1的库存水平更新为数量6。您可以通过查看数据库来证明这一点,或者您可以简单地导航到端点来检查库存水平(使用邮递员或浏览器,就像您以前做的那样)。

You may have noticed that we have not built in any functionality to add new products. This is intentional; if you wish to do so as an extension, I would urge you to consider whether it fits in the Stock controller or whether that should be handled separately.

许可

目前为止,一切顺利。我们现在有了股票检查应用的基本功能;也就是说,它检查库存水平,并允许我们更新库存水平。然而,这有一个问题。在我们的示例中,任何用户都可以轻松更新库存水平。这可以(也应该)锁定在客户端上;然而,我们也应该在 API 上实现这种安全性;毕竟,访问 Postman,甚至网络浏览器,并不是排他性的,我们最不希望的是有人未经允许就更新我们的股票水平。

让我们插入标识服务器 4 ,并确保访问应用编程接口的人至少被授权这样做。

客户应用

在我们可以引入 IdentityServer 来验证正确的人和应用可以访问 API 之前,我们需要有一个我们可以说可以合法访问 API 的应用;否则,我们能做的最好的事情就是完全阻止对 API 的访问。我们的客户端应用将使用通用视窗平台 ( UWP )构建。

UWP is Microsoft's preferred method for building Desktop applications. WPF and WinForms are still supported (and if you've read the previous chapters, you'll see that they're getting a new lease of life). However, for new applications, it is recommended that you use UWP. In fact, XAML Islands are a way to bridge the gap between the old and the new.

我们的应用将非常简单:我们只需要一个单一的屏幕与股票水平的查找和更新股票水平的选项。

I've never claimed to be a UX designer, so if you feel you could design the screen better, that's probably because you could (and there is nothing in the functionality that will be altered if the layout of the screen is changed)!

让我们在解决方案中创建一个新项目:

我们的客户端应用将是一个 C# UWP 应用:

In this project, we will leverage the binding capabilities of UWP; however, it would be wrong to say that this represents an MVVM architecture. Data binding, while an important part of an MVVM architecture, is not synonymous with it. For this project, I am purposely not introducing any MVVM frameworks in order to demonstrate how the project is built; however, other than the learning opportunity that this affords, it is very much reinventing the wheel. There are several excellent MVVM frameworks out there: MVVM Cross or MVVM Light, for example. All of them will provide built-in helpers for commands, messaging, and dependency injection.

UWP 允许您简单地编写事件处理程序;因此,理论上,我们可以处理按钮的点击事件,询问屏幕,然后调用 API。事实上,我们的 UI 层非常小,这可能代表了最好的解决方案;但是,我们将使用 UWP 开箱即用的内置数据绑定。这种方法也使解决方案更具可扩展性。

让我们首先创建一个ViewModels文件夹,并为我们的主视图添加一个视图模型:

public class MainPageViewModel : INotifyPropertyChanged

我们将很快解释为什么我们要实现这个特殊的接口。视图模型的目的是在代码中提供视图的表示:也就是说,所有的功能,但没有一个视觉效果。

You can call the ViewModel anything you choose; however, should you elect to use a particular MVVM framework, some of them use a convention that the ViewModel should have the same stem as the View; for example, MainPageView/MainPageViewModel.

我们将在视图模型中声明的第一件事是我们将显示和更新的字段;在我们的例子中,我们实际上只显示了两个:

private int _productId;
private int _quantity;
private int _originalQuantity;

public int ProductId
{
    get => _productId;
    set
    {
        if (UpdateField(ref _productId, value))
        {
            RefreshQuantity();
        }
    }
}

public int Quantity
{
    get => _quantity;
    set
    {
        if (UpdateField(ref _quantity, value))
        {
            UpdateQuantity.RaiseCanExecuteChanged();
        }
    }
}

显然,我们引用了一些这里不存在的方法;UpdateField只是一个帮助器方法,它可以省去我们重写字段已经改变的检查,以及调用OnPropertyChanged的地方(一秒钟后会有更多信息):

private bool UpdateField<T>(ref T field, T value,
 [CallerMemberName] string propertyName = null)
{
    if (EqualityComparer<T>.Default.Equals(field, value)) 
    {
        return false;
    }

    field = value;

    OnPropertyChanged(propertyName);
    return true;
}

OnPropertyChanged

XAML 的工作方式是在必要时重新渲染屏幕。在 WPF 和 UWP 的例子中,这意味着我们需要告诉它一些事情已经改变了,我们通过在INotifyPropertyChanged接口上实现一个名为OnPropertyChanged的方法来做到这一点:

public void OnPropertyChanged([CallerMemberName] string propertyName = null)
{
    this.PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
}

当调用这个函数时,它会重新呈现屏幕上绑定到传入的任何属性的方面。

CallerMemberName was introduced back in .NET 4.5 and it allows you to reference the name of the caller without explicitly defining it at design time. That is, if we change the name of one of the properties, CallerMemberName will simply pick up the new name.

命令和应用编程接口调用

现在我们有了自己的属性,我们需要引入两个功能:更新库存数量的能力和检索库存数量的能力。我们已经提到了后者,所以让我们先补充一下:

private async Task RefreshQuantity()
{
    Quantity = await _httpClientHelper.GetQuantityAsync(ProductId);
    _originalQuantity = Quantity;
    UpdateQuantity.RaiseCanExecuteChanged();
}

同样,我们显然引用了一些尚不存在的代码,但是让我们检查一下我们看到的内容:我们只是调用我们的 API,将数量值赋给我们的属性,然后设置_originalQuantity字段。_originalQuantity场和RaiseCanExecuteChanged联系紧密,我们很快就会看到原因。不过在此之前,我们先来看看_httpClientHelper是从哪里来的。我们将在这里添加一个构造函数和字段定义:

private readonly IHttpStockClientHelper _httpClientHelper;
public RelayCommand UpdateQuantity { get; set; }

public MainPageViewModel(IHttpStockClientHelper httpClientHelper)
{
    _httpClientHelper = httpClientHelper;
    UpdateQuantity = new RelayCommand(async () =>
    {
        await _httpClientHelper.UpdateQuantityAsync( 
            ProductId, Quantity);
        await RefreshQuantity(); 
    }, () => Quantity != _originalQuantity); 
}

这里发生了很多事情。再一次,我会让你暂时保留关于这些接口和变量类型是什么的问题,看看我们能看到什么:我们正在注入我们之前看到的助手类,我们正在实例化一个RelayCommand,它显然只是采取一个动作(做一些事情)和一个函数(评估一些事情)。

这代表了视图模型的所有代码,所以让我们来看看助手类。

助手类

我们在这里使用了两个助手类;第一次是RelayCommand。任何绑定到 XAML 前端的命令都必须实现ICommand。执行ICommand是一件微不足道的工作;你只需告诉它你想在它执行时做什么,以及允许它执行的条件。然而,这确实意味着每个命令都有一个单独的类,这使得从视图模型传递功能变得更加困难。因此,解决方案是一般地实现一个简单地接受动作和评估函数并为您实现ICommand的助手类。看起来是这样的:

public class RelayCommand : ICommand
{
    private readonly Action _execute;
    private readonly Func<bool> _canExecute;
    public event EventHandler CanExecuteChanged;

    public RelayCommand(Action execute) : this(execute, null)
    {
    }

    public RelayCommand(Action execute, Func<bool> canExecute)
    {
        _execute = execute ?? throw new ArgumentNullException("execute");
        _canExecute = canExecute;
    }

    public bool CanExecute(object parameter)
    {
        return _canExecute == null ? true : _canExecute();
    }

    public void Execute(object parameter)
    {
        _execute();
    }

    public void RaiseCanExecuteChanged()
    {
        CanExecuteChanged?.Invoke(this, EventArgs.Empty);
    }
}

There are many versions of this that are available as open source, not least from Microsoft themselves. All of the implementations are basically the same; although if you decide to use it yourself, you might find it needs a little customization; for example, some logging will go a long way! As far as I'm aware, all of the MVVM frameworks provide a version of this that is likely to be much richer in functionality than anything you'll write yourself.

我们的第二个助手类是HttpClientHelper。让我们看看代码。然后,我们可以讨论它的功能,更重要的是,它为什么需要放在一个单独的类中:

public class HttpClientHelper : IHttpStockClientHelper
{
    static HttpClient _httpClient;

    public HttpClientHelper(Uri baseAddress)
    {
        _httpClient = new HttpClient();
        _httpClient.BaseAddress = baseAddress;
    }

    public async Task<int> GetQuantityAsync(int productId)
    { 
        string path = $"api/stock/{productId}";
        string quantityString = await _httpClient.GetStringAsync(path);
        return int.Parse(quantityString);
    }

    public async Task UpdateQuantityAsync(int productId, int newQuantity)
    {
        string path = $"api/stock/{productId}";
        var httpContent = new StringContent(newQuantity.ToString());
        httpContent.Headers.ContentType = new MediaTypeHeaderValue("application/json");

        await _httpClient.PutAsync(path, httpContent);
    }
}

如您所见,我们有两个公共方法和一个构造函数。因为我们调用同一个服务,所以我们可以在构造函数中配置所有这些。如您所见,我们只是使用GetQuantityAsyncHttpGetUpdateQuantityAsyncHttpPut来调用服务。

值得注意的是,这两种方法都在进行某种类型转换:帮助器方法公开您期望的功能是有意义的;例如将产品数量更新为 3 。在这里,你应该只需要两个参数:产品和数量。如果你传入或传出任何其他东西,那么你就制造了噪音,如果出现错误,你(或其他人)可能不得不进行筛选。

所以,降低噪音是有这个帮手的第一个原因。第二,如果我们需要对调用代码进行单元测试,我们可以很容易地模拟出对服务的调用。

我们现在有了一个可以工作的桌面应用,所以让我们继续保护功能。我们可以从身份服务器开始。

标识服务器 4

正如我们前面提到的,IdentityServer 不是一个预构建的服务,而是一个框架。这样的服务确实存在——谷歌、推特、脸书、微软等等都提供了预先构建的服务,你可以简单地调用这些服务并找回身份。IdentityServer 更像是一个自己的roll解决方案。

It's worth considering why you might choose to roll your own in this manner. In our example here, one of the requirements is offline access, so that does weight the argument – you can't authenticate using Facebook if you're not online. It's also worth considering whether you would want to outsource the authentication of your users to a third party. I'm not saying for a minute that these aren't reliable, secure services, but they are run by companies. If you build your entire application around Facebook authentication and they suddenly withdraw the service for some reason, where would that leave you?

让我们从创建我们的身份服务器开始,它可以是一个标准的 ASP.NET Core 网络应用:

同样,我们将创建一个空的应用,这样我们就可以确切地看到它是如何构建的。

All of the instructions in this section relate to the new (IdentityServer) project that you have just created, unless otherwise stated.

在包管理器控制台中,我们将安装IdentityServer4包:

Install-Package IdentityServer4 -ProjectName StockChecker.IdentityServer

If you have decided to call your project something different than mine, then you'll need to change the preceding project name.

Startup.cs中,我们需要添加IdentityServer:

public void ConfigureServices(IServiceCollection services)
{
    services
        .AddIdentityServer()
        .AddDeveloperSigningCredential();
}

我们在这里做两件事:我们将身份服务器添加到依赖注入 ( DI )系统,并且我们将添加一些临时凭证。

We'll revisit this later and add some (more) valid credentials, but this will get us up and running.

接下来,我们需要向 ASP.NET Core 中间件管道注册身份服务器:

public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }
    app.UseIdentityServer();
}

为了运行 IdentityServer,我们需要做的最后一件事是告诉它什么将请求信息;这可以在ConfigureServices(在Startup.cs)中完成:

public void ConfigureServices(IServiceCollection services)
{
    services
        .AddIdentityServer()
        .AddDeveloperSigningCredential()
        .AddInMemoryClients(new List<Client>())
        .AddInMemoryIdentityResources(new List<IdentityResource>());
}

显然,这不会让我们在这个阶段实际验证任何东西。本质上,为了正确运行,IdentityServer 需要知道三件事:

  • 谁需要访问权限(用户)
  • 他们需要获得什么(资源)
  • 他们将如何获得访问权限(客户端)

IdentityServer provides helper methods, such as AddInMemoryClients, as a way to get started. At some stage in the future, additional clients or resources may need to be added, and this could be easily refactored so that the list of each is persisted into a data store.

标识服务器

目前,我们有一个运行的身份服务器;但是,我们可以在没有任何凭据的情况下运行客户端并使用我们的应用。在我们向 identity server 添加资源、客户端或用户之前,下一步是让它拒绝我们进入(因为我们还没有设置这些东西)。

保护应用编程接口

为了保护应用编程接口,我们只需要做两件事(这两件事都不需要身份服务器——其中一件已经为我们完成了!).首先,我们需要告诉 ASP.NET Core,我们想要使用授权。在我们的启动文件中,我们已经在调用AddControllers。因为 ASP.NET Core 现在是开源的,我们可以简单地看看这对我们有什么好处:

private static IMvcCoreBuilder AddControllersCore(IServiceCollection services)
{
    return services
        .AddMvcCore()
        .AddApiExplorer()
        .AddAuthorization()
        .AddCors()
        .AddDataAnnotations()
        .AddFormatterMappings();
}

A common practice in many Microsoft products (especially .NET Core products) is to use the Builder pattern to allow configuration of the middleware. The premise of this pattern is simply that the method performs an action and then returns a reference to the object that it was called from. This allows for a more human-readable code flow (as shown in the preceding code).

这里的相关线是AddAuthorization。接下来我们需要做的是告诉 ASP.NET Core 我们想要获得什么。在控制器中,添加以下装饰器:

[Authorize]
[Route("api/[controller]")]
[ApiController]
public class StockController : ControllerBase
{

现在,如果您尝试访问该 API,您将会得到一个错误。好吧。所以现在我们根本无法访问 API 让我们插入 IdentityServer 代码。这里的原则很简单:我们将从我们的身份服务器请求一个令牌,允许我们访问我们的应用编程接口。在我们的应用编程接口中,我们将安装一个身份服务器包:

Install-Package IdentityServer4.AccessTokenValidation -ProjectName StockChecker.Api

Strictly speaking, you can do this part without IdentityServer at all by adding a JWTBearer authentication. However, using IdentityServer does give you certain advantages here and, since we're already using IdentityServer, it doesn't really make sense to start rolling our own for part of the solution.

在我们的应用编程接口中,我们需要ConfigureServices中的以下代码:

public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers();

    services.AddAuthentication(IdentityServerAuthenticationDefaults.AuthenticationScheme)
        .AddIdentityServerAuthentication(options =>
    {
        // Base-address of our IdentityServer 
        // (if you haven't purposely changed it then this is likely correct)
        options.Authority = "https://localhost:5001";

        // Name of the API resource
        options.ApiName = "StockCheckerApi";
});

我们将在管道中添加身份验证:

public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }
    app.UseAuthentication();

    app.UseRouting();
    app.UseAuthorization();
    app.UseEndpoints(endpoints =>
    {
        endpoints.MapControllers();
    });
}

客户端配置

我理解这一章的流程可能看起来有点随意,但事实上,这里面有一个思考过程。我们首先保护了 API,这意味着我们保护了我们的资源。我们要做的下一件事是更改客户端,这样它将获得令牌并正确调用 API(这是这一部分);显然,这还不行,因为我们还没有改变我们的身份服务器。在下一节中,我们将改变 IdentityServer,我们应该看到一切都突然跃入生活。之所以按照这个顺序来做,是因为我经常发现,如果你先看到某件事没有起作用,就更容易看到它是如何起作用的(否则,你就真的不知道自己做对了什么)。

在我们的 UWP 应用中,我们需要另一个 NuGet 包:

Install-Package IdentityModel -ProjectName StockChecker.UWP

UWP 应用(或一般的桌面应用)在两个重要方面不同于 web 应用:第一个是,在 web 应用中,您必须处理用户可以简单地在应用中的任何地方导航的事实。例如,用户可以简单地将登录屏幕上的地址栏更改为以下内容:

https://www.mysecuresite.com/products/stock/1

因此,在保护 web 应用时,不能依赖屏幕的预期流量;但是,在桌面应用中,您可以。

第二个考虑是用户的桌面上有桌面应用的代码。我们正在努力.NET,这意味着对代码进行逆向工程是一项非常琐碎的任务;然而,如果有足够的意愿,我知道没有哪种语言不能在某种程度上进行逆向工程。

这里的要点是,我们可以期望用户进入登录屏幕,并以最小的努力将他们保持在那里,但是我们不应该在客户端设备上存储任何可能允许用户访问服务器的内容。

登录屏幕

让我们创建一个新的登录屏幕;我已经调用了我的页面LoginView。以下代码在<Page>元素中:

<Grid HorizontalAlignment="Center" VerticalAlignment="Center">
    <Grid.RowDefinitions>
        <RowDefinition Height="Auto" />
        <RowDefinition Height="Auto" />
        <RowDefinition Height="Auto" />
    </Grid.RowDefinitions>
    <Grid.ColumnDefinitions>
        <ColumnDefinition Width="Auto" MinWidth="200" />
        <ColumnDefinition Width="Auto" MinWidth="200" />
    </Grid.ColumnDefinitions>
    <TextBlock Text="Username" Margin="5"
               Grid.Row="0" Grid.Column="0" />
    <TextBlock Text="Password" Margin="5"
               Grid.Row="1" Grid.Column="0" />
    <TextBox Text="{Binding Username, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" Margin="5"
             Grid.Row="0" Grid.Column="1" />
    <PasswordBox Password="{Binding Password, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" Margin="5"
                 Grid.Row="1" Grid.Column="1" />
    <Button Grid.Row="2" Grid.Column="0" Grid.ColumnSpan="2"
            HorizontalAlignment="Center" Margin="5"
            Command="{Binding LoginCommand}">Login</Button>
</Grid>

这里有相当多的代码,但大部分只是语法(XAML,就像它的父级 XML 一样,相当冗长)。正如您所看到的,我们在这里使用数据绑定,就像我们之前所做的那样。表单上只有一个按钮(当用户登录或关闭应用时)。

在这个阶段,我们还没有设置绑定或任何功能,所以视图不会做任何事情。我们还需要告诉 UWP 应用进入这个视图,而不是我们之前在App.xaml.cs中创建的主视图(在OnLaunched方法中):

if (e.PrelaunchActivated == false)
{
    if (rootFrame.Content == null)
    {
        // When the navigation stack isn't restored 
        // navigate to the first page,
        // configuring the new page by passing required 
        // information as a navigation parameter
        rootFrame.Navigate(typeof(LoginView), e.Arguments);
    }
    // Ensure the current window is active
    Window.Current.Activate();
}

我们在这里更改代码,而不是添加任何内容。事实上,唯一真正的变化是LoginView的文本(假设你给你的视图命名和我一样)。

让我们创建视图模型。我们将从用于主视图的相同样板代码开始:

public class LoginViewModel : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler PropertyChanged;
    private bool UpdateField<T>(ref T field, T value,
           [CallerMemberName] string propertyName = null)
    {
        if (EqualityComparer<T>.Default.Equals(field, value)) return false;
        field = value;
        OnPropertyChanged(propertyName);
        return true;
    }

    public void OnPropertyChanged([CallerMemberName] string propertyName = null)
    {
        this.PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
    }
}

If this were a production application, these two classes should inherit from a common base ViewModel. If you choose to extend this application, I would strongly advise that you start there.

接下来我们需要一些新的属性来反映用户名和密码:

private string _username;

public string Username
{
    get => _username;
    set
    {
         UpdateField(ref _username, value);
    }
}

private string _password;

public string Password
{
    get => _password;
    set
    {
         UpdateField(ref _password, value);
    }
}

我们需要的最后两件事是LoginCommand连线和数据上下文设置;先说LoginCommand:

public LoginViewModel()
{
    LoginCommand = new RelayCommand(() =>
    {
        DoLogin();
    });
}

private void DoLogin()
{
    throw new NotImplementedException();
}

public RelayCommand LoginCommand { get; set; }

让我们设置数据上下文。然后,我们应该可以看到登录屏幕上的万事万物栏实际登录(在LoginView.xaml.cs里面):

public LoginView()
{
    this.InitializeComponent();
    ViewModel = new LoginViewModel();
    DataContext = ViewModel;
}

public LoginViewModel ViewModel { get; set; }

运行这个应该会在你启动时显示登录屏幕,允许你输入用户名和密码,然后当你按下Login按钮时抛出Not Implemented异常。现在,我们可以调用 IdentityServer,获取令牌,并访问 API。

正在调用 IdentityServer

调用 IdentityServer 实际上只是填写我们在登录按钮后面创建的命令。让我们在LoginViewModel中更改我们的命令,使它看起来更像这样:

public LoginViewModel(IHttpStockClientHelper httpStockClientHelper)
{
    _httpStockClientHelper = httpStockClientHelper;
    LoginCommand = new RelayCommand(() =>
    {
        DoLogin();
    }); 
}

private async Task DoLogin()
{
    bool loggedIn = await _httpStockClientHelper.Login(Username, Password);
    if (loggedIn)
    {
        var frame = Window.Current.Content as Frame;
        frame.Navigate(typeof(MainPage), null);
    }
}

We are calling an async method from a synchronous one. The effect of this is that the code will not await the result of the operation. Should you decide to extend this application, adding a RelayCommandAsync would be a good addition; however, this will serve for our specific purpose.

如您所见,我们现在正在调用一个尚不存在的新助手方法,并且我们正在传递用户名和密码;一旦我们确定登录成功,我们就可以导航到该屏幕。

You may notice that, as I write code, a lot of times, I'll refer to methods that are yet to exist and then create them. If you practice Test-Driven Development (TDD), you start to get used to this method of working. It does have the advantage that you don't end up scaffolding a lot of infrastructure code that you'll never use.

我们现在只需要编写我们的助手方法来登录。如果使用 Ctrl-。要创建存根,它应该在IHttpClientHelper.cs中为您创建一个接口定义:

Task<bool> Login(string username, string password);

新方法(在HttpClientHelper中)将如下所示:

private static string _accessToken;

public async Task<bool> Login(string username, string password)
{ 
    var disco = await _httpClient.GetDiscoveryDocumentAsync(new DiscoveryDocumentRequest
    {
        Address = "https://localhost:5001"
    });

    var response = await _httpClient.RequestPasswordTokenAsync(new PasswordTokenRequest
    {
        Address = disco.TokenEndpoint,
        ClientId = "StockChecker",
        ClientSecret = "secret",
        Scope = "StockCheckerApi",
        UserName = username,
        Password = password
    });

    if (response.IsError)
    {
        // ToDo: Log error
        return false;
    }

    _accessToken = response.AccessToken;
    return true;
}

这里有很多,让我们一行一行地看一遍。然而,在我们这样做之前,您可能已经输入了这段代码,并意识到其中一些方法(例如,RequestPasswordTokenAsync)在HttpClient对象上不存在。这些是由IdentityModel.Client库添加的扩展方法,所以请确保您已经在文件顶部为此添加了using语句。

首先要注意的是,我们有一个令牌,它保存在类中。虽然我们现在没有使用它,但是为了调用该 API,我们稍后将需要它(这意味着我们将很快重新访问该文件)。

我们在这里做的实际上是一个三步走的过程;第一步是从 IdentityServer 获取令牌端点。发现文档是 OpenID 规范的一部分,它只是:一个告诉您在哪里可以找到身份验证服务器的所有资源的文档;它也总是在同一个(相对)位置,所以你可以去看看大型身份提供商的发现文档,比如微软、谷歌和推特;它总是在这里:

https://baseaddress/.well-known/openid-configuration

GetDiscoveryDocumentAsync给了我们一个很好的包装器,这样我们就可以在不解析 JSON 的情况下将这个文档拆开。我们已经给了它基址,现在就这样。你可能会发现,如果你选择扩展程序,你需要重新访问这个并设置Policy变量。

下一步是从刚刚给我们的端点请求一个令牌。为此,我们必须提供有效的凭据。我们现在不讨论这些设置,因为我们需要在 IdentityServer 本身中返回这些设置。现在,需要注意的重要事情是,我们正在传递用户名和密码。

最后,如果一切正常,我们将缓存令牌并返回一个标志来指示成功。

能力

UWP 应用作为可信应用分发。这意味着他们做的任何事情都必须清楚;也就是说,您需要告诉应用您需要访问什么以及您需要什么权限。这与身份权限无关,但是我们需要告诉应用它可以访问 localhost(显然,只有在我们开发的时候),我们可以使用证书,等等。这都是在Package.appxmanifest文件中实现的。如果您只需在 Visual Studio 中双击它,您将获得一个用户界面,允许您选择资产、功能、声明等;讨论这里的所有内容超出了本章的范围,但是您需要添加三项功能:

现在,一切都应该就绪,我们可以正确配置 IdentityServer,并让整个登录系统焕发生机。

设置身份服务器

设置身份服务器通常是这个难题的第一部分;然而,我觉得它更好地说明了当您首先插入其他部分时,一切是如何工作的。

要设置 IdentityServer,我们需要了解三个概念,我们已经简要地了解了它们:

  • 用户(可以访问资源的用户——在我们的例子中,露西是用户
  • 资源(他们希望访问的资源–我们的资源是我们的 API )
  • 客户端(用户试图访问系统的方法–在我们的例子中,这是我们的 UWP 应用)

为了运行,需要给 IdentityServer 一个有效的列表。通常,特别是对于用户,您会将它链接到数据库;然而,为了简单起见,我们将简单地告诉系统这些是什么。

在我们的身份服务器services.cs文件中,我们已经有了这个:

public void ConfigureServices(IServiceCollection services)
{
    services
        .AddIdentityServer()
        .AddDeveloperSigningCredential()
        .AddInMemoryClients(new List<Client>())
        .AddInMemoryIdentityResources(new List<IdentityResource>());
}

我们已经向 IdentityServer 讲述了我们的客户列表,所以让我们介绍另外两个概念:

public void ConfigureServices(IServiceCollection services)
{
    services
        .AddIdentityServer()
        .AddDeveloperSigningCredential()
        .AddInMemoryClients(IdentityServerHelper.GetClients())
        .AddInMemoryApiResources(IdentityServerHelper.GetApiResources())
        .AddTestUsers(IdentityServerHelper.GetUsers())
        .AddInMemoryIdentityResources(new List<IdentityResource>());
}

现在,我们已经介绍了我们的三个概念。随着成为标准,我们使用了一些尚不存在的方法(和一个类)。

The method to add the users is not called AddTestUsers by accident. Although what we are doing here will work, it is not very extensible, and defining a data store for the users in the system is something that should be high up on the list of things to do to extend this project.

我们的第一个方法是添加客户端:

public static class IdentityServerHelper
{
    internal static IEnumerable<Client> GetClients()
    {
        var clients = new List<Client>
        {
            new Client
            {
                ClientId = "StockChecker", 
                AllowedGrantTypes = GrantTypes.ResourceOwnerPassword, 
                ClientSecrets =
                {
                    new Secret("secret".Sha256())
                },

                AllowedScopes = { "StockCheckerApi" }
            }
        };
        return clients;
    }
}

请记住,我们说过客户端是您访问资源的方法。在我们的案例中,我们的客户是我们的 UWP 应用;然而,它可能是一个网络应用、一个控制台应用,甚至是一个用 Python 或 Go 编写的应用——它不一定是. NET

您将从客户端识别客户端标识和客户端密码(通常,您将首先设置服务器,然后将这些信息用于客户端,而不是相反,就像我们在这里所做的那样)。

我们已经设置了授权类型——我们将在稍后回到这个话题并更详细地讨论它——并且我们已经设置了范围。该作用域告诉 IdentityServer 该客户端被允许做什么。这意味着您可以让多个客户端登录到一个系统,并纯粹基于客户端来限制访问。

接下来,我们将向同一类添加资源:

internal static IEnumerable<ApiResource> GetApiResources()
{
    var resources = new List<ApiResource>
    {
        new ApiResource("StockCheckerApi", "Stock Checker API")
    };

    return resources;
}

这里的资源和客户端内部的范围是相同的概念,但是角度不同。这是服务器提供访问的所有资源的综合列表,而客户端指定它需要这些资源中的哪一个。

最后,在同一个类中,我们将添加用户:

internal static List<TestUser> GetUsers()
{
    var users = new List<TestUser>
    {
        new TestUser
        {
            SubjectId = "1",
            Username = "Lucy",
            Password = "password123"
        },
        new TestUser
        {
            SubjectId = "2",
            Username = "Morris",
            Password = "password123"
        },
        new TestUser
        { 
            SubjectId = "3",
            Username = "Graham",
            Password = "password123"
        }
    };

    return users;
}

在这一章的开始,我们给出了一个场景,在这个场景中,公司需要考虑四个不同的人,正如你所看到的,有三个用户。如果你回去,你会发现其实是山姆不见了。Sam 不在公司工作,是客户,因此不需要访问系统。然而,她是一个重要的概念;也就是说,间接与系统交互但不需要访问的用户。

If you are thinking of extending this project, then you might consider this: as a customer, Sam may like to access a web portal, but not the UWP application. As a result, in addition to a proper user store, as we mentioned earlier, you would need to create a website and add that as a client.

好了,我们的设置完成了。如果你现在运行这三个项目,你应该可以输入露西的凭据(她的密码是password123)并让他们登录。

你也应该能够看到他们被拒绝登录,例如,如果你尝试了password1的密码。

调用应用编程接口

您现在可以登录了;但是,如果您试图访问任何资源,您会发现系统抛出了一个异常。原因是我们保护了 API,这意味着当我们调用 API 时,我们需要传递一个令牌来证明我们是我们所说的那个人。在HttpClientHelper.cs内部,我们可以简单地添加一个对将传递承载令牌的方法的调用:

public async Task<int> GetQuantityAsync(int productId)
{
    string path = $"api/stock/{productId}";
    _httpClient.SetBearerToken(_accessToken);
    string quantityString = await _httpClient.GetStringAsync(path);
    return int.Parse(quantityString);
}

最后,我们可以为update方法添加一个类似的行,我们的 API 应该安全地工作:

public async Task UpdateQuantityAsync(int productId, int newQuantity)
{
    string path = $"api/stock/{productId}";
    _httpClient.SetBearerToken(_accessToken);
    var httpContent = new StringContent(newQuantity.ToString());
    httpContent.Headers.ContentType = new MediaTypeHeaderValue("application/json");

    await _httpClient.PutAsync(path, httpContent);
}

因此,我们的身份系统现在验证用户在我们的系统中是有效的。然而,我们仍然有一个问题:我们的用户可以访问系统的所有功能。我们需要锁定这一点,我们会的,但让我们先收拾一些残局。

授权类型

我们的第一个问题是赠款类型。我们不会改变这一点,但我们会调查为什么我们应该(或者至少为什么我们应该考虑改变它)。我们使用的授权类型是资源所有者密码;这使我们能够获取用户的用户名和密码,然后将其连同密钥一起发送给 IdentityServer。然后,我们获得一个令牌,我们可以使用该令牌与我们的资源进行通信。在我们的例子中,资源是一个应用编程接口。这是一个安全的系统在某种程度上

让我们戴上我们的黑帽子,想一想我们可能如何妥协这样一个系统。请记住,我们处理的是桌面软件.NET,正如我们之前所说的,对. NET 程序集或可执行文件进行逆向工程是极其容易的。我们将秘密存储在编译后的代码中,因此攻击者可以访问该秘密。当然,没有用户名和密码,这并不能访问任何东西。

这种授权类型的另一个问题是,如果我们引入第二个访问点(假设我们决定为构建者 Sam 开发一个门户网站,如果您忘记了她是谁,请参见前面的部分),我们将需要创建另一个屏幕来接受用户名和密码。

那么,解决办法是什么?

解决这个问题的一种方法是在桌面应用中托管一个网页。这样,即使我们在桌面上,处理安全性的代码也托管在服务器上。Windows 10 为此提供了一个网络身份验证代理

When dealing with security, it should always be remembered that there is no such thing as totally secure. There are always ways to get into a system – no matter how locked down you make it, it is always possible to get in: you should think of it a little like securing your house. Different houses have different levels of security: your house probably has a door – just closing your door offers more security than leaving it wide open; locking the door gives more security still; having multiple locks more security still; and a reinforced door even more. However, banks have vaults, with dozens of locks and keys and security guards, alarms, and so forth, and yet if I told you someone had robbed a bank, you wouldn't think of it as a unique thing to happen.

因此,安全性是一种权衡:您试图保护的东西有多有价值(如果您的系统遭到破坏,可能发生的最糟糕的事情是什么?),如果实施这种保护,系统的可用性如何,增加这种保护的成本如何?

在我们的例子中,因为我们有一个非常具体和有限的要求,我们将保持我们的赠款类型不变。然而,如果你想改进这个系统,这是一个很好的扩展点。

创建和使用有效密钥

最后一个问题是我们的关键。我们目前正在使用开发密钥。这在我们编写软件时非常有效,但是我们显然需要在系统发货之前生成一个真实的密钥。虽然生成生产证书不属于本章的范围,但我们将快速介绍如何生成和使用自签名证书。

This solution is not meant for production. Before deploying to production, you should get a certificate from a certificate provider. There are several such providers and some (at least one that I'm aware of) provide a free certificate.

为了生成我们的证书,让我们生成它。首先启动 Windows PowerShell(确保以管理员身份执行此操作),然后输入以下命令:

> New-SelfSignedCertificate -Subject "CN=testcert" -KeySpec "Signature" -CertStoreLocation "Cert:\CurrentUser\My"

这将在您的证书存储中生成个人证书。一旦生成,它会给你一个Thumbprint-记下这个值,因为几分钟后你就需要它了:

如果你想看这个,那么你可以轻松地看。如果您正在运行 Windows 10,请按 Windows 键并键入以下内容:

mmc.exe

这将打开管理控制台。在这里,您可以选择个人存储并查看您的所有证书:

我们需要稍微修改一下代码。在 IdentityServer startup.cs文件中,更改ConfigureServices方法,使其如下所示:

public void ConfigureServices(IServiceCollection services)
{
    X509Certificate2 x509Certificate2 = null;
    using (var certStore = new X509Store(StoreName.My, StoreLocation.CurrentUser))
    {
        certStore.Open(OpenFlags.ReadOnly);
        var certCollection = certStore.Certificates.Find(
        X509FindType.FindByThumbprint,
        "CED666617B2C3E4C244B38EC3BB322191148EA92", // Thumbprint
        false);

        if (certCollection.Count == 0)
            throw new Exception("No certificate found");

        x509Certificate2 = certCollection[0];
    }

    services
        .AddIdentityServer()
        //.AddDeveloperSigningCredential()
        .AddSigningCredential(cert)
        .AddInMemoryClients(IdentityServerHelper.GetClients())
        .AddInMemoryApiResources(IdentityServerHelper.GetApiResources())
        .AddTestUsers(IdentityServerHelper.GetUsers())
        .AddInMemoryIdentityResources(new List<IdentityResource>());
}

如您所见,我们正在证书存储中查找我们之前提到的指纹。如果我们没有找到任何东西,那么我们就崩溃了(这比一个错误更可取)。最后,我们将AddDeveloperSigningCredential更改为普通的旧AddSigningCredential,我们将证书传递给它。虽然证书不适合生产,但这更接近生产代码。

现在,我们将了解如何检查用户的凭据,以允许他们访问应用的不同部分。

批准

授权是您在用户通过身份验证后应用于用户的策略。也就是说,我们现在知道用户是谁:至少,我们知道他们有有效的用户名和密码,并且他们使用的是经过批准的客户端。下一步是确保每个用户只能访问系统的正确部分。

Some of these permissions may not be completely realistic, but they do have the advantage of covering the various possibilities. A quick note on PolicyServer: PolicyServer (found here: https://policyserver.io/) is a framework, written by the same people that created IdentityServer. It offers very similar functionality. If you are intending to extend this application, then I would strongly encourage you to consider using it. It is an open source and commercial offering.

这种更改有三个部分:更改用户以获得相关权限,更改基础结构以传递这些权限,以及更改客户端以显示正确的控件。

用户和角色

本质上,为了让我们的应用为我们不同的用户服务,我们将引入角色的概念。这是处理权限时非常常见的概念;这意味着,我们可以为每个用户分配一个角色,并基于此授予权限,而不是创建一个标识用户名“Graham”的代码路径,然后启用所有功能。

我把这看作是授权的棍棒屋(这是指一个关于三只小猪的儿童故事,他们试图通过建造不同类型的房子来保护自己免受狼的伤害)。稻草屋(也就是最不可扩展的)是每次都显式地检查特定的用户(例如,检查用户名是否是 Graham)。砖房(也就是最可扩展的)是进一步的抽象,在这里您引入了策略的概念;每个角色可能有一个或多个策略,并且是策略控制可以访问的内容。

让我们从这里开始,给每个用户一个角色。在IdentityServerHelper.cs中,我们目前有一个名为GetUsers的方法;我们将在此添加角色:

internal static List<TestUser> GetUsers()
{
    var users = new List<TestUser>
    {
        new TestUser
        {
            SubjectId = "1",
            Username = "Lucy",
            Password = "password123",
            Claims = new List<Claim>()
            {
                new Claim(JwtClaimTypes.Role, "Sales")
            }
        },
        new TestUser
        {
            SubjectId = "2",
            Username = "Morris",
            Password = "password123",
            Claims = new List<Claim>()
            {
                new Claim(JwtClaimTypes.Role, "Maintenance")
            }
        },
        new TestUser
        { 
            SubjectId = "3",
            Username = "Graham",
            Password = "password123",
            Claims = new List<Claim>()
            {
                new Claim(JwtClaimTypes.Role, "Administrator")
            }
        }
    };
    return users;
}

这里有很多代码,但是如您所见,只有五六行新代码:我们只是向用户分配一组新的声明,每个声明都有一个特定的角色。

As we mentioned earlier, this is not an ideal way to store the users, but it does mean that as we make changes such as this, it's obvious what we've changed. If these users were held in a database, then the change would be obscured.

不幸的是,我们不能只是将新信息分配给用户,并让它立即传播:我们需要许多支持性的更改。

标识服务器

下一站是我们的客户。我们需要告诉 IdentityServer 客户端被允许访问额外的资源;在IdentityServerHelper.cs文件内部,我们将更改GetClients方法,使其如下所示:

internal static IEnumerable<Client> GetClients()
{
    var clients = new List<Client>
    {
        new Client
        {
            ClientId = "StockChecker", 
            AllowedGrantTypes = GrantTypes.ResourceOwnerPassword, 
            ClientSecrets =
            {
                new Secret("secret".Sha256())
            },
            AllowedScopes =
            {
                "StockCheckerApi",
                "roles",
                IdentityServerConstants.StandardScopes.OpenId
            }
        }
    };
    return clients;
}

同样,我们在这里只添加了两行新代码;我们已经指定我们可以返回OpenId和一个名为roles的新资源。

OpenId is a standardized method of dealing with identification. In order to return anything at all about the user, we need to specify this.

既然我们已经说了可以返回一个叫做roles的东西,我们来定义一下这是什么;这在这个文件中采用了新的 helper 方法的形式(我们将在下一节中调用它):

internal static IEnumerable<IdentityResource> GetIdentityResources()
{
    return new List<IdentityResource> 
    {
        new IdentityResource 
        {
            Name = "roles",
            UserClaims = new List<string> { JwtClaimTypes.Role } 
        },
        new IdentityResources.OpenId()
    };
}

到目前为止,我们只看到了一个ApiResource;然而,在这里,我们声明我们将返回一个与身份相关的资源。事实上,我们将返回两个:OpenId和我们新的roles资源。

让我们快速重温一下IdentityServer中的startup.cs文件;ConfigureServices方法需要更改,如下所示:

services
    .AddIdentityServer()
    //.AddDeveloperSigningCredential()
    .AddSigningCredential(x509Certificate2)
    .AddInMemoryClients(IdentityServerHelper.GetClients())
    .AddInMemoryApiResources(IdentityServerHelper.GetApiResources())
    .AddTestUsers(IdentityServerHelper.GetUsers())
    .AddInMemoryIdentityResources(IdentityServerHelper.GetIdentityResources());
}

这里不包括整个方法,但是我们只改变了一行:AddInMemoryIdentityResources()现在有了我们新的助手方法传入其中。

仅此而已。现在,让我们来看看需要什么样的客户端变化(到目前为止这种变化最大的部分)。

客户

在这里,我们将更新客户端,以便用户只能看到与他们相关的功能。

It's worth noting that the changes we're making here do not prevent a user from manually calling the API and performing functions that are not available on the screen. Again, this project is a starting point, and when extending it, you should think carefully about what you are protecting and who you are protecting it from.

在客户端更改中,有三个阶段:逻辑更改以实际允许用户查看和更改他们有权访问的控件,对服务器调用的更改以带回额外的数据,最后,需要从服务器检索一些额外的信息并将其传递给相关的视图模型。让我们从逻辑变化开始。

逻辑更改和用户界面更改

让我们从 UWP 申请的MainPage.xaml文件开始。目前,这里只有一个变化(我们将有一秒钟,稍后再回来)。我们需要做的是当用户没有权限查看时,使数量不可见。定位 XAML 内的TextBox数量:

<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding Quantity, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" Visibility="{Binding CanViewQuantity, Converter={StaticResource BooleanToVisibilityConverter}}"/>

谁能想到这么少量的代码会引发这么多问题?然而,答案很简单:我们还没有这里提到的任何新东西——它们正在出现。然而,我们确实需要迅速讨论这里到底会发生什么。

使用数据绑定的一个主要优势是,您可以将业务逻辑(用 MVVM 的话来说,就是视图模型)与视图分开。这里,我们绑定到一个叫做CanViewQuantity的布尔属性;然而,我们希望将其绑定到我们控件的Visible属性。我们可以让CanViewQuantity返回一个枚举的Visibility对象(视图可以直接理解),这是可行的;然而,我们将永远无法在 Windows 环境之外使用该模型。因此,我们需要创建一个转换器。

In fact, if you use the new x:Bind syntax, BooleanToVisibility conversion is now baked into the system; this is, however, only available in Windows 10 since release 1607. Check out the following link for further details: https://docs.microsoft.com/en-us/windows/uwp/xaml-platform/x-bind-markup-extension.

现在让我们创建一个新文件并命名为BooleanToVisibilityConverter:

class BooleanToVisibilityConverter : IValueConverter
{
    public object Convert(object value, Type targetType, object parameter, string language)
    {
        return (bool)value ? Visibility.Visible : Visibility.Collapsed;
    }

    public object ConvertBack(object value, Type targetType, object parameter, string language)
    {
        throw new NotImplementedException();
    }
}

This, along with other useful extensions, tools, and controls can also be found in the Windows Community Toolkit: https://docs.microsoft.com/en-gb/windows/communitytoolkit/.

这个代码文件真的不值得解释;我们只是返回Visible,其中布尔值为真。值得记住的是,转换可以像您需要的那样复杂,尽管您应该避免将业务逻辑放在这里:它应该始终是将原始类型绑定到复杂视图概念的一种方式(例如truevisible)。

让我们简单回到MainPage.Xaml。在我们继续之前,我们需要声明我们希望使用转换器;声明部分应该类似于这样:

<Page
    x:Class="StockChecker.UWP.MainPage"

    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="using:StockChecker.UWP"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    mc:Ignorable="d"
    Background="{ThemeResource ApplicationPageBackgroundThemeBrush}"
    xmlns:converters="using:StockChecker.UWP.Converters">
    <Page.Resources>
        <converters:BooleanToVisibilityConverter x:Key="BooleanToVisibilityConverter" />
    </Page.Resources>

我们在这里添加了两件事:一个包含converters文件的 XML 名称空间引用(xmlns)和我们希望用作资源的特定转换器。

我们现在已经完成了 XAML,所以让我们看看我们需要在视图模型中改变什么。MainPageViewModel需要三个新属性:用户角色、用户是否应该可以查看数量、用户是否应该可以更新数量。让我们先添加局部变量:

private bool _canViewQuantity;
private bool _canUpdateQuantity;
private string _userRole;

CanViewQuantityCanUpdateQuantity是非常简单的属性:

public bool CanUpdateQuantity
{
    get => _canUpdateQuantity;
    set
    {
        UpdateField(ref _canUpdateQuantity, value);
    } 
}

public bool CanViewQuantity
{
    get => _canViewQuantity;
    set
    {
        UpdateField(ref _canViewQuantity, value);
    } 
}

然而,在UserRole中,我们将决定用户可以做什么:

public string UserRole
{
    get => _userRole;
    set
    {
        if (UpdateField(ref _userRole, value))
        {
            CanViewQuantity = UserRole == "Administrator" || UserRole == "Sales";
            CanUpdateQuantity = UserRole == "Administrator" || UserRole == "Maintenance";
        }
    }
}

This is a very simple example; should you wish to make it more complex, I would strongly recommend that you extract this logic into a separate class or even a separate library that is responsible for permissions. One possible way to address this is to use decorators.

视图模型中只剩下一个变化,那就是确保“更新数量”按钮只对那些有权限的人启用。构造函数应该这样更改:

public MainPageViewModel(IHttpStockClientHelper httpClientHelper)
{
    _httpClientHelper = httpClientHelper;
    UpdateQuantity = new RelayCommand(async () =>
    {
        await _httpClientHelper.UpdateQuantityAsync(ProductId, Quantity);
        await RefreshQuantity(); 
    }, 
    () => Quantity != _originalQuantity && CanUpdateQuantity); 
}

我们在这里所做的只是添加一个额外的检查,这样命令(以及按钮)只有在CanUpdateQuantity为真时才被启用。

接下来,我们将更改登录过程以获取附加信息,并将其传递给视图模型。

登录和导航更改

登录视图本身不需要更改;但是,我们需要更改登录视图模型,以便我们可以检索与用户相关的数据。在LoginViewModel.cs中,我们将更改DoLogin()方法,如下所示:

private async Task DoLogin()
{
    bool loggedIn = await _httpStockClientHelper.Login(Username, Password);
    if (loggedIn)
    {
        string userRole = await _httpStockClientHelper.GetUserRole();
        var frame = Window.Current.Content as Frame;
        frame.Navigate(typeof(MainPage), userRole);
    }
}

我们在这里做了两件事:首先,我们调用了 helper 类上的一个新方法(方法本身很快就会被调用),其次,我们将这个信息传递到MainPage中。这个变化的第二部分在后面的MainPage代码(MainPage.xaml.cs)中,我们将在这里添加一个新的方法:

protected override void OnNavigatedTo(NavigationEventArgs e)
{
    base.OnNavigatedTo(e);
    var viewModel = DataContext as MainPageViewModel;
    viewModel.UserRole = e.Parameter.ToString();
}

在构造函数中,我们将视图的数据上下文设置为视图模型。因此,我们可以简单地将数据上下文转换回视图模型。

Having too much code in the code is usually an indication that the project doesn't have a good base architecture and that the business logic and UI are too tightly coupled. As you can see here, we are coupling the two. If you must breach their separation, then referencing the ViewModel from the view is the better approach.

我们需要对逻辑和导航进行的更改到此结束。我们最后一个变化是服务器调用。

服务器调用

这里最明显的变化是我们需要一种新的方法。我们之前引用过它,所以代码不会像现在这样编译。如果选择无法识别的方法调用并使用 Ctrl-。,你应该看到界面中已经创建了如下的方法定义(或者你可以简单的从这里复制到IHttpClientHelper.cs):

Task<string> GetUserRole();

这个新方法的实现在HttpClientHelper.cs中,需要如下所示:

public async Task<string> GetUserRole()
{
    var userInfo = await _httpClient.GetUserInfoAsync(new UserInfoRequest()
    {
        Address = _discoveryResponse.UserInfoEndpoint,
        Token = _accessToken
    });

    string role = userInfo.Claims.First(a => a.Type == JwtClaimTypes.Role).Value;
    return role;
}

GetUserInfoAsync()是 IdentityServer 4 库的扩展方法。本质上,它允许您获得一些关于认证用户的信息。这叫为什么我们需要允许OpenId

这将返回除了关于用户的信息之外,他们拥有的任何声明,这是我们的角色定义。

It's worth bearing in mind that information about a user should be about that user. For example, a user's age, gender, hair color, and role are all examples of information about the user, whereas whether or not the user has access to update the quantity field is most emphatically not information about the user; it is a logical decision based on that information.

在我们结束之前,我们只需要对同一个文件做一些小的修改。首先,我们需要改变Login方法:

public async Task<bool> Login(string username, string password)
{ 
    _discoveryResponse = await _httpClient.GetDiscoveryDocumentAsync(new DiscoveryDocumentRequest
    {
        Address = "https://localhost:5001", 
        Policy =
        { 
            ValidateIssuerName = false,
        } 
    });

    var response = await _httpClient.RequestPasswordTokenAsync(new PasswordTokenRequest
    {
        Address = _discoveryResponse.TokenEndpoint,
        ClientId = "StockChecker",
        ClientSecret = "secret",
        Scope = "openid roles StockCheckerApi",
        UserName = username,
        Password = password
    });

    if (response.IsError)
    {
        // ToDo: Log error
        return false;
    }

    _accessToken = response.AccessToken;
    return true;
}

其实这里真的只有两个变化。第一个在范围内——我们声明我们想要返回关于用户和角色的信息。第二,我们已经重命名了发现响应变量。事实上,我们将给出这个变量的类级范围:

static DiscoveryResponse _discoveryResponse;

这只是为了在我们发出最初的发现呼叫后,能够保留信息。

就这样。现在,如果您运行该应用,您应该会发现一切都如预期的那样工作...只有一个小小的例外。

看不到数量如何更新

事实证明,我们这里的逻辑有一个小故障:我们的看管人莫里斯需要能够更新股票,但不能查看当前水平。然而,我们的程序为他隐藏了股票数字,所以他不能更新它。为了解决这个问题,我们需要添加一些额外的命令和按钮。本质上,我们希望莫里斯能够使用一个库存项目,所以减少库存按钮将是理想的;现在让我们将按钮添加到MainPage.xaml中(我们将它放在Update Quantity按钮的正下方):

    <Button Command="{Binding UpdateQuantity}"
         Grid.Row="2" Grid.Column="0">
        <TextBlock Text="Update Quantity" />
    </Button>
    <Button Command="{Binding DecreaseQuantity}"
         Grid.Row="2" Grid.Column="1">
        <TextBlock Text="Decrease Quantity" />
    </Button>
</Grid>

我们可以将这个新命令添加到我们的视图模型(MainViewModel.cs)中:

public RelayCommand DecreaseQuantity { get; set; }

最后,让我们为构造函数中的新命令创建逻辑,现在应该如下所示:

public MainPageViewModel(IHttpStockClientHelper httpClientHelper)
{
    _httpClientHelper = httpClientHelper;
    UpdateQuantity = new RelayCommand(async () =>
    {
        await _httpClientHelper.UpdateQuantityAsync( 
            ProductId, Quantity);
        await RefreshQuantity(); 
    }, 
    () => Quantity != _originalQuantity && CanUpdateQuantity); 

    DecreaseQuantity = new RelayCommand(async () =>
    {
        await _httpClientHelper.UpdateQuantityAsync(
            ProductId, Quantity - 1);
        await RefreshQuantity();
    }, 
    () => Quantity > 0 && CanUpdateQuantity);
}

好吧,那我们在这里做什么?其实和更新量差不多;我们只是告诉它我们希望减少一个数量,在CanExecute中,我们检查我们至少还有一个项目。我们需要做的最后一件事是强制应用更新RefreshQuantity上的CanExecute:

private async Task RefreshQuantity()
{
    Quantity = await _httpClientHelper.GetQuantityAsync(ProductId);
    _originalQuantity = Quantity;
    UpdateQuantity.RaiseCanExecuteChanged();
    DecreaseQuantity.RaiseCanExecuteChanged();
}

同样,这只是一个额外的行,以确保我们只能在有数量要减少的地方减少数量:

既然我们已经完成了这个项目,让我们回顾一下我们在这里讨论的内容。

摘要

那是一次巨大的旅行。我们为我们的小公司设置了一个功能性应用,我们使用 IdentityServer 4 保护了访问,并使用角色实现了权限。

在这一章中,我已经说过几次了,但只是为了总结一下:当涉及到身份,甚至是一般的安全时,没有一个正确的答案。IdentityServer 在这种情况下是有意义的,因为我们使用了公司拥有和维护的应用和 API,我们需要离线访问,并且我们正在支持桌面应用。如果您只更改其中一个参数,那么使用谷歌 OAuth 或 Azure B2C 可能是有意义的。

为了重申我也多次说过的另一点:任何形式的安全都不是绝对的。您的系统可能非常安全,因为它可能使用加密流量和防火墙。但是,您可能已经对您的应用进行了渗透测试,结果出来时上面没有划痕,然后您的一名员工可能会与他人共享他们的密码。突然间,你可能就不会为这些烦恼了。

在下一章中,我们将研究如何使用创建一个 Windows 服务.NET Core 3。我们将创建一个应用,将您电脑上的照片备份到 Azure Storage 帐户。

建议的改进

如果您希望获取并改进此应用,我有几个建议(如果您一直密切关注,您可能已经注意到了其中的一些):

  1. 将凭据流更改为隐式。为此,您需要在桌面应用中托管一个 web 视图,并创建登录网站。

  2. 添加用户存储。我们的用户显然不适合生产;也就是说,除非我们能够明确保证他们永远不会雇用或失去任何工作人员。此外,在代码中以纯文本形式存储密码显然不是一个好主意(即使它在服务器上)。

  3. 执行以下任一操作:

    • 从 UWP 应用中取出 XAML 并将其转换为Xamarin.Forms,然后编译并发布一个安卓版本。
    • 获取常见的代码区域,并将它们组合起来,为您的应用创建一个小型框架。
    • 实施 MVVM 框架。大多数 MVVM 框架现在在某种程度上也支持交叉编译。
  4. 将库存系统链接到前端网站,允许用户登录并购买库存商品。

  5. 创建库存项目图像;您可以将这些图像存储在 Azure Storage 中。事实上,我们的下一章就是关于这个的。

进一步阅读

在撰写本书时,身份服务器文档处于不断变化的状态。但是,它仍然非常有用,并且包含许多示例。可以在这里找到: docs.identityserver.io

OpenID 规范可以在这里找到:https://openid.net/developers/specs/

OAuth 2 规格可以在这里找到:https://oauth.net/2/

七、使用 Windows 服务和 Azure 存储构建照片存储应用

自 1997 年我开始第一份工作以来,计算已经经历了一些有趣的变化,自 20 世纪 80 年代初我得到第一台计算机(Spectrum ZX81)以来,变化更大。在光谱上,如果你想保存一些信息,你可以把它录在磁带上;一个典型的游戏大约需要 5 到 10 分钟才能从磁带中载入。当然,在当时,更复杂的计算机正在使用,但大部分繁重的存储实际上仍然是在磁带上完成的。甚至在我买了第一台电脑后,我还记得为它买了一个磁带机,这样我就可以存储高达 10 亿字节的信息(10 亿字节的信息在当时是一个很大的数据量)。

如今,你的手机存储空间远不止这些,仍然不够:你去度假,也许拍 100 张照片;带着两个 24 小时曝光胶片去度假的日子已经一去不复返了。当然,过了一段时间,你会意识到这成百上千张你吃早餐的高分辨率照片正在填满你的手机。

一些手机制造商每月提供不到 1 的额外存储容量。考虑到这为您的所有图像提供了备份,这似乎并不多;然而,如果您自己将图像放入云中,这可能比您支付的费用还要多。

这正是我们在本章中打算做的,我们将编写一个 Windows Service 来监视您计算机上的一个目录,将其中的任何内容上传到 Azure Cloud,然后在超过一定年限时将其从源目录中删除。这样,您可以将最新的照片随身携带,并将其余的存档。

本章将涵盖以下主题:

  • 使用创建窗口服务.NET Core 3
  • 安装和使用 Windows 兼容包
  • 将文件上传到 Azure Blob 存储

技术要求

Azure 存储资源管理器可以在这里找到:https://Azure . Microsoft . com/en-GB/features/Storage-Explorer/。这是微软维护的一个开源软件;它的 GitHub 存储库可以在这里找到:https://github.com/Microsoft/AzureStorageExplorer

This isn't a necessity, but it would greatly help with the development and testing stages.

Windows 服务和 Windows 兼容性包

在这个项目中,我们将使用 Windows 兼容性包来创建一个 Windows 服务。我们稍后会讨论为什么,但是在这一部分,我想快速讨论一下这些东西是什么。

Windows 服务

大多数操作系统都有相当于 Windows 服务的功能。本质上,这是一个一直运行的应用,并在后台做一些事情。如果您快速浏览一下当前在您的机器上运行的服务,您会发现有很多,而且它们是多种多样的;为此,在 Windows 10 中,只需按下开始按钮并键入Services。然后,选择最匹配的。应向您提交以下申请:

很有可能你会有一套不同的服务,但原则是一样的。这些应用都是在后台运行并做一些事情。这些被标记为正在运行的服务中的每一个都在消耗您的计算机资源,因此,当您安装服务时,您应该考虑是否需要它一直运行(否则,只需禁用它)。

Disabling some Windows Services can cause your machine to behave differently, or even stop some functionality, so be careful if you intend to disable services that were installed when you arrived.

如果我们正在编写一个服务,这种情况会重复两次:如果是你的应用降低了机器的速度,人们会很快注意到,所以性能是一个重要的考虑因素。

Windows 兼容包

背后的历史.NET Core 是一个有趣的东西,在你合上这本书,拿起斯蒂芬·金的东西之前,我不会在这里详细介绍它(必须快速了解它!).然而,我想简单谈谈微软选择创建的原因(至少是原因之一).NET Core。

那个.NET Framework 从 2002 年就出现了,现在您仍然可以运行当时编写的应用。在撰写本文时,这意味着.NET Framework 需要支持近 20 年的假设和代码,基于当时的技术版图

*正因为如此,微软发现自己陷入了一个无法改变任何基本情况的境地,因为他们不能冒险破坏成千上万甚至数百万的应用,这些应用没有被触及,但仍在运行。因此,唯一的解决方案是创建一个不依赖于原始框架的全新框架:进入.NET Core。但是,您为此付出的代价是新框架不支持原始框架的所有功能。

讽刺的是,一个这样的例子是特定于窗口的功能(包括创建一个窗口服务)。然而,在 2017 年,微软发布了视窗兼容性包:一个 NuGet 包,提供了对你需要的大多数视窗功能的访问。

项目概述

本质上,我们的项目将围绕一个监视机器硬盘上给定文件夹的窗口服务展开。一旦检测到文件,我们将读取一些属性,如大小、日期和名称,并在 Azure 云中进行快速搜索;如果不存在,我们就上传。一旦我们确定它安全地在云中,我们将检查日期,超过一定年龄的任何东西都将从硬盘中删除。

配置 Azure 存储

设置 Azure 存储帐户是一个相当简单的过程;您需要首先登录 Azure。如果你没有账号,那么你可以在这里创建一个:https://azure.microsoft.com/。注册后,请访问https://portal.azure.com

大多数云提供商都有存储模型,使用替代提供商并不难;但是,您需要更改一些与 Azure 接口的代码。

从 Azure 门户中选择存储刀片开始:

或者,你可以搜索Storage。刀片出现后,选择添加并填写新帐户的详细信息:

如果你已经看完了这本书的其余部分,你会熟悉这个屏幕的大部分;但是,前面标记为 3 的部分。请参见注释,表示存储帐户中直接影响数据成本、性能和安全性的部分。本质上,如果你想快速访问数据,你会付出更多;如果您希望数据安全(在其他地区备份),那么您将支付更多费用。

关于这些选项的具体细节我就不赘述了(主要是因为当你读到这里的时候,它们可能已经不一样了)。但是,这里有两个概念在所有云平台上都使用:

  • 冷热储存
  • 存储冗余

先说前者。

冷热储存

通常,云提供商提供存储的概念,这是针对读取进行优化或针对写入进行优化的。虽然我们在这里将我们的储物模式设置为,但可能这个特定的模式更适合储物;也就是说,读写数据的成本更高,但实际存储的成本更低。 Hot 存储则相反:存储成本更高,但获取信息的成本更低。

一些云提供商(包括微软)提供了一个类似于 cold 的层,但对存储进行了更优化:这是一种您可能用于备份的存储类型,或者您认为根本不需要检索的文件。

对于像我们这样的应用,您可能会发现在开发和测试阶段使用 hot 并在实时系统中使用它时替换一个冷存储帐户是很有用的。

最后,关于溢价的一个注意事项:微软为此提供固态硬盘,这意味着你的访问速度提高了;然而,这比其他两种选择都要贵。

存储冗余

值得时刻记住的是,云只是另一种说法别人的电脑;在我们的例子中,这可以理解为微软的电脑。显然,在 Azure 中存储您的数据比将其存储在隔壁邻居的计算机上更安全,但许多概念是相同的;这包括你的邻居可能发生入室盗窃、洪水或火灾的事实。

微软面临着同样的风险,他们试图通过比你的邻居可能拥有的更多的安全性、消防安全性和防水性来减轻这些风险。然而,仍然有可能拥有你的数据的机器化为乌有。

不同的供应商对这个概念有稍微不同的版本,但它们本质上都提供了相同的东西:将数据复制到其他地方的能力。开箱即用,您通常会将数据写入多个物理机,因此单台机器出现故障不会丢失您的数据。

下一级通常是您的数据被复制到物理建筑物之外,甚至是建筑物的集合。最后一层是数据在区域外复制(通常在不同的国家)。

正如我之前提到的,不同的云提供商给这些概念起了不同的名字,他们改变了术语;然而,这大致可以归结为您丢失数据必须发生的情况,即多台机器故障、全国性灾难或世界性灾难。值得注意的是,虽然云提供商会给你一定比例的耐用性,但没有一家会达到 100%;也就是说,他们不会保证你的数据安全。他们只会说它不太可能丢失。

存储帐户

回到我们的存储帐户,我们有四个存储概念:Blobs、文件、表和队列。为了这个项目的目的,我们将使用 Blobs。

In brief: files provide replication of local storage for legacy applications, tables provide a simple key-value pair data storage mechanism, and queues provide basic message queue functionality. Full details on these other types fall outside the scope of this chapter.

在我们的存储区域内,我们需要创建一个容器。在博客中,选择创建一个容器:

The access level relates to how you can access the container. Setting anonymous access means that you're pretty much granting access to anyone that knows where your container is.

下一件我们需要的东西(嗯,不完全需要,但它确实有助于测试)是 Azure 存储资源管理器。如果您已经安装了它,请立即启动它:

如您所见,在这个实用程序中,您可以将文件上传到容器(1),查看其内容(2),然后再次删除它(3)。

该工具还允许您管理文件,但目前,我们将只使用这三个功能。

If you have chosen to not install this tool, you can still follow along with this chapter; however, determining whether your project is working correctly or not will not be easy. Having said that, by the end of this project, you should have sufficient knowledge to write a small utility that will list the contents of the container.

一旦您使用了该工具,我们就可以继续前进并创建我们的窗口服务。

创建我们的窗口服务

要创建 Windows 服务,让我们从一个新的开始.NET Core 3 控制台应用:

You may wish to separate parts of this application into their own class libraries if you choose to extend this application. Clearly, the functionality that we're using is not restricted to a console application. If the functionality was in its own class, you could simply call it from a desktop application.

下一步是安装 Windows 兼容性号码包:

Install-Package Microsoft.Windows.Compatibility

这将允许我们创建一个窗口服务。

It's worth noting at this stage that, despite being .NET Core, this application will not be cross-platform; in fact, any application using the Windows Compatibility Pack will not be.

既然我们已经安装了这个,我们就可以创建服务了。我们需要一个类似这样的类:

public class PhotoService : ServiceBase
{
}

你应该可以使用 Ctrl +。 ServiceBase因为它是兼容性包的一部分。我们需要覆盖这里的一些功能(即OnStartOnStop):

public class PhotoService : ServiceBase
{
    protected override void OnStart(string[] args)
    {
        base.OnStart(args);
    }

    protected override void OnStop()
    {
        base.OnStop();
    }
}

我们还需要告诉控制台应用运行该服务。在这里,我们可以使用 C# 8 的另一个特性——隐式范围的 using 语句。本质上,现在可以简单地声明一次性对象,而不是在 use 语句中显式包装它,并且当对象超出范围时将被处置:

class Program
{
    static void Main(string[] args)
    { 
        using var service = new PhotoService();
        ServiceBase.Run(service);         
    }
}

这就是我们的基本服务。你们中精明的人可能已经注意到,它实际上还没有做任何事情,但就目前的情况来看,这是一项合法的服务。

该服务本质上需要做三件事:扫描硬盘上的文件夹以查找新文件,将文件上传到 Azure 上的容器,以及扫描 Azure 容器。让我们从一个有用的小类开始,它为我们监视文件系统:FileSystemWatcher。此功能现在可用于.NET Core 通过神奇的兼容性包。将所有这些代码放入一个单独的类中是有意义的,所以让我们在服务中创建和销毁这个类。然后,我们可以看到稍后该类可能会是什么样子:

public class PhotoService : ServiceBase
{
    private FileMonitor _fileMonitor;

    protected override void OnStart(string[] args)
    {
        _fileMonitor = new FileMonitor("c:\tmp");
    }

    protected override void OnStop()
    {
        _fileMonitor.Dispose();
    }
}

I'll be using the c:\tmp path a lot in this chapter. It's just a directory that I typically use for testing such things. If you don't want to use this, then you can use any path that you choose (although I recommend that you make it short). We will make this configurable later on.

只需使用快捷键 Ctrl +。在未定义的新类上,FileMonitor,它将为你创建类存根。让我们看看它需要什么样的外观:

public class FileMonitor : IDisposable
{ 
    private FileSystemWatcher _fileSystemWatcher;

    public FileMonitor(string path)
    {
        _fileSystemWatcher = new FileSystemWatcher(path);
        _fileSystemWatcher.Filter = "*.*";
        _fileSystemWatcher.EnableRaisingEvents = true;

        _fileSystemWatcher.Changed += new FileSystemEventHandler(OnChanged);
        _fileSystemWatcher.Created += new FileSystemEventHandler(OnCreated); 
        _fileSystemWatcher.Renamed += new RenamedEventHandler(OnRenamed);
    }

    private void OnRenamed(object sender, RenamedEventArgs e)
    {
        throw new NotImplementedException();
    }

    private void OnCreated(object sender, FileSystemEventArgs e)
    {
        throw new NotImplementedException();
    }

    private void OnChanged(object sender, FileSystemEventArgs e)
    {
        throw new NotImplementedException();
    }

    public void Dispose()
    {
        _fileSystemWatcher.Dispose();
    }
}

如你所见,这里没有太多内容:FileSystemWatcher类本身可能在任何严肃的细节上都超出了本章的范围,但本质上,你把它指向一条路径,告诉它在那里发生任何事情时让你知道;然后,你得到一个事件。FilterEnableRaisingEvents需要设置,这样类就知道要注意什么,发现什么要做什么。我们已经实现了IDisposable模式,这样当服务停止时,我们就可以处理掉FileSystemWatcher

The IDisposable pattern tends not to be implemented as much these days. Essentially, it gives you a chance to tidy up your resources. If you're doing anything that requires file access or database access, then you really should consider using it. The Using statement gives you a very nice way to have the system automatically dispose of your object for you, even when an error occurs.

测试类

在这里使用控制台应用的优势之一是我们可以简单地运行项目。

有无数种方法可以做到这一点,但是前面的代码将只是无限循环地实例化FileMonitor类。如果你在c:\tmp中创建一个文件,你应该会看到程序与NotImplementedException一起崩溃。这证明了我们FileMonitor班到目前为止还算管用。

下一步是告诉全班做什么。在这种情况下,我们将使用一个新的依赖类AzureStorageClientService。这将提供 Azure 存储界面功能。然而,在我们开始写它之前,让我们使用它。首先创建一个银行界面(你可以选择把它放在一个名为AzureClient的目录中,就像我一样):

The astute among you may notice that I haven't called the interface IAzureStorageClientService, but ICloudStorageClientService. The reason for this is that, in addition to enabling testing, having a dependency injection such as this allows you to easily switch out one piece of functionality for another. For example, say you decided you wanted to use Google Cloud Platform instead of Azure—you could simply create GoogleCloudStorageClient that implemented the same interface and inject that instead.

让我们通过在类的顶部声明一个变量来使用我们的接口:

public class FileMonitor : IDisposable
{ 
    private FileSystemWatcher _fileSystemWatcher;
    private ICloudStorageClientService _cloudStorageClientService;

    public FileMonitor(string path, ICloudStorageClientService cloudStorageClientService)
    {
        _cloudStorageClientService = cloudStorageClientService;

        _fileSystemWatcher = new FileSystemWatcher(path);
        _fileSystemWatcher.Filter = "*.*";
        _fileSystemWatcher.EnableRaisingEvents = true;

        _fileSystemWatcher.Changed += new FileSystemEventHandler(OnChanged);
        _fileSystemWatcher.Created += new FileSystemEventHandler(OnCreated); 
        _fileSystemWatcher.Renamed += new RenamedEventHandler(OnRenamed);
    }

如您所见,我们使用构造函数将它注入到我们的类中。我们将在课堂上更深入地使用它:

private async void OnRenamed(object sender, RenamedEventArgs e)
{
    if (_cloudStorageClientService.FileExists(e.Name))
    {
        await _cloudStorageClientService.RenameFile(e.Name, e.OldName);
    }
    else
    {
        await _cloudStorageClientService.UploadFile(e.FullPath);
    }
}

private async void OnCreated(object sender, FileSystemEventArgs e)
{
    await _cloudStorageClientService.UploadFile(e.FullPath);
}

private async void OnChanged(object sender, FileSystemEventArgs e)
{
    await _cloudStorageClientService.UploadFile(e.FullPath);
}

如果你愿意,你可以使用 Ctrl +。创建接口定义;但是,这些方法确实需要异步。接口的最终定义如下:

public interface ICloudStorageClientService
{
    Task RenameFile(string name, string oldName);
    Task UploadFile(string fullPath);
    Task<bool> FileExists(string name);
}

最后,我们需要实现服务。

使用 Azure 存储客户端

我们将从上传一个文件到 Azure Storage 开始。第一步是下载客户端访问 Azure 存储的 NuGet 包:

Install-Package WindowsAzure.Storage

然后,我们可以创建上传文件的方法:

public async Task UploadFile(string fullPath)
{
    string fileName = Path.GetFileName(fullPath);
    var blob = GetBlockBlobReference(fileName);

    using (var fileStream = System.IO.File.OpenRead(fullPath))
    {
        await blob.UploadFromStreamAsync(fileStream);
    }
}

前面的代码基本上是获取对 Azure 中文件的引用(即使它不存在)并上传流。我们大概应该看一下辅助方法,也就是GetBlockBlobReference:

private CloudBlockBlob GetBlockBlobReference(string fileName)
{
    if (CloudStorageAccount.TryParse(_connectionString, out CloudStorageAccount storageAccount))
    {
        var client = storageAccount.CreateCloudBlobClient();
        var container = client.GetContainerReference("photos");
        var blob = container.GetBlockBlobReference(fileName);
        return blob;
    }
    else
    {
        throw new Exception("Unable to parse the storage account");
    }
}

这个助手方法确实有相当多的代码,所以让我们打开它,看看我们实际在做什么。暂时不要质疑_connectionString来自哪里——我们很快会回到这个问题。我们正在做的第一件事是在CloudStorageAccount类上使用静态方法,这有助于我们基于连接字符串创建CloudStorageAccount的新实例。

Should you wish to extend this, you might consider an alternative to throwing an exception where the connection string is not valid.

然后,我们依次根据容器的名称获取存储帐户中的容器;然后,我们根据 Blob 的名称(也就是我们的文件名)获得对它的引用。值得注意的是,为了返回一个 Blob 对象,Blob 不需要实际存在。

Blob is an abbreviation of Binary Large OBject. Essentially, in this context, this represents anything that can be stored on a computer in a single file.

在我们可以测试这个之前,我们需要回到_connectionString,我让你暂时暂停提问直到现在。

配置我们的服务以访问 Azure 存储

本质上,这需要表示到存储帐户的连接字符串。您可以在 Azure 门户的访问键下轻松找到正确的值:

我们可以简单地复制这个并通过给它分配_connectionString变量直接粘贴到代码中。然而,相反,我们将使用ConfigurationBuilder。在我们开始之前,我们需要一些 NuGet 包:

Install-Package Microsoft.Extensions.Configuration -ProjectName PhotoStorage.WindowsService

Install-Package Microsoft.Extensions.Configuration.Json -ProjectName PhotoStorage.WindowsService

这应该允许我们使用 JSON 文件构建配置,所以下一步是创建一个。我们将在项目中添加一个名为appsettings.json的文件:

如您所见,我们不仅创建了新文件,还设置了构建操作和复制到输出目录,以便文件在运行时复制到应用的主目录。

让我们看看文件本身是什么样子的:

{
    "ConnectionStrings": {
        "netcodephotostorage": "DefaultEndpointsProtocol=https;AccountName=netcodephotostorage;AccountKey=A1SYv0myUdUnjXW8oojxJ60PPGY1zCwaAyvvcHz0C7WSDEximNfl6UbtGXli+i0plAxb1S3w5pR9deBQ50kFGQ==;EndpointSuffix=core.windows.net"
    }
}

I've left my connection string here to illustrate what the string would look like; obviously, by the time this book is published, the storage account will not exist anymore, so please replace this with your own connection string; otherwise, it will not connect. In truth, you can structure this file differently if you so wish; by looking at ConfigurationBuilder, you'll see that, providing it's in a key-value pair structure, it makes little difference.

现在,让我们看看如何提取该配置。在AzureStorageClientService中,我们将创建一个构造函数:

private string _connectionString;

public AzureStorageClientService()
{
    var builder = new ConfigurationBuilder()
        .SetBasePath(Directory.GetCurrentDirectory())
        .AddJsonFile("appsettings.json");

    var configuration = builder.Build();
    _connectionString = configuration["ConnectionStrings:netcodephotostorage"];
}

我们在这里所做的就是使用ConfigurationBuilder来加载和解析我们刚刚创建的 JSON 文件。一旦完成,我们可以使用结果configuration属性来提取键值对。

现在,如果我们运行控制台应用,我们应该能够看到文件已经上传到 Azure 云存储:

我们可以看到存储客户端代码现在可以工作了。还有几个方法需要填写,所以接下来让我们这样做。

完成云存储客户端

我们先从一个简单的方法开始,即FileExists:

public async Task<bool> FileExists(string name)
{
    var blob = GetBlockBlobReference(name);

    return await blob.ExistsAsync(); 
}

我们正在重用Upload函数中的同一个助手方法,但是,这一次,我们只是调用一个内置方法ExistsAsync,来确定文件是否在那里。

最后,我们有RenameFile方法:

public async Task<bool> RenameFile(string name, string oldName)
{
    var blobNew = GetBlockBlobReference(name);
    var blobOld = GetBlockBlobReference(oldName);

    if (await blobNew.ExistsAsync()) return false;

    await blobNew.StartCopyAsync(blobOld);
    await blobOld.DeleteAsync();

    return true;
}

由于没有内置的方法来重命名文件,我们只是复制内容并删除原始文件。显然,如果新名称已经存在,任务就会失败。

进一步配置

我们的下一个任务是将我们唯一剩下的硬编码变量移入appsettings.json文件。让我们快速查看一下配置文件中的内容:

{
    "ConnectionStrings": {
        "netcodephotostorage": "DefaultEndpointsProtocol=https;AccountName=netcodephotostorage;AccountKey=A1SYv0myUdUnjXW8oojxJ60PPGY1zCwaAyvvcHz0C7WSDEximNfl6UbtGXli+i0plAxb1S3w5pR9deBQ50kFGQ==;EndpointSuffix=core.windows.net"
    },
    "MonitorPath": "c:\\tmp"
}

好吧,我们的下一步就是利用它。有许多方法可以实现这一点;我们可以根据需要读取每个配置值,或者,就像我们在这里将要做的那样,我们可以在开始时读取所有的配置并传递这些值。让我们创建一个新的类来保存这些值。我已经将我的添加到一个名为Models的新文件夹中:

这里的想法是,我们有一个单独的类来保存应用的所有配置;因此,我们可以简单地将它传递给需要该配置的任何其他类。类本身的代码如下所示:

public class AppSettings
{
    public string ConnectionString { get; set; }
    public string MonitorPath { get; set; }
}

我们的下一步是添加一些代码来读取这个配置。为了实现这一目标,我创建了一项新服务:

public class ConfigurationService
{
    public AppSettings Load()
    {
        var builder = new ConfigurationBuilder()
            .SetBasePath(Directory.GetCurrentDirectory())
            .AddJsonFile("appsettings.json");

        var appSettings = new AppSettings();
        var configuration = builder.Build();

        appSettings.ConnectionString = configuration["ConnectionStrings:netcodephotostorage"];
        appSettings.MonitorPath = configuration["MonitorPath"];

        return appSettings;
    }
}

因为我们已经创建了一个服务来加载配置设置,所以我们可以将其传递到AzureStorageClientService。下面是该文件的修改后的构造函数和类变量:

private readonly AppSettings _appSettings;
public AzureStorageClientService(AppSettings appSettings)
{
    _appSettings = appSettings;
}

我想你会同意,现在我们没有在服务中加载配置,这看起来干净多了;GetBlockBlobReference也需要这样更新:

private CloudBlockBlob GetBlockBlobReference(string fileName)
{
    if (CloudStorageAccount.TryParse(_appSettings.ConnectionString, out CloudStorageAccount storageAccount))
    {
        var client = storageAccount.CreateCloudBlobClient();
        var container = client.GetContainerReference("photos");
        var blob = container.GetBlockBlobReference(fileName);

        return blob;
    }
    else
    {
        throw new Exception("Unable to parse the storage account");
    }
}

我们的PhotoService.cs类需要在启动时实例化这个新的配置服务:

private FileMonitor _fileMonitor;
private AppSettings _appSettings;

protected override void OnStart(string[] args)
{
    var configurationService = new ConfigurationService();
    _appSettings = configurationService.Load();

    var cloudStorageClientService = new AzureStorageClientService(_appSettings);
    _fileMonitor = new FileMonitor(_appSettings.MonitorPath, cloudStorageClientService);
}

最后,如果您创建了一个控制台应用进行测试,您也需要更新它(在Program.cs中):

static void TestFileMonitorNewFile()
{
    var configurationService = new ConfigurationService();
    var appSettings = configurationService.Load();

    var cloudStorageClientService = new AzureStorageClientService(appSettings);

    using var fileMonitor = new FileMonitor(appSettings.MonitorPath, cloudStorageClientService);

    for (; ; ) { }
}

说完了,我们就完了。现在,应用应该从appsettings.json文件中读取配置,如果我们创建一个文件或将它放入c:\tmp文件夹,我们应该看到它被上传到云中。变更时,上传变更后的版本;然而,我们缺少三个关键的功能。

接下来我们需要添加的是日志记录。不幸的是,Windows 服务或者任何类型的服务都很难调试。

其次,如果我们运行我们的应用,并且目标目标是空的,那么所有放入该目录的文件都将被转移到云上;然而,如果我们把它指向一个已经充满照片的目录,会怎么样呢?最后,就目前的情况来看,我们正在备份所有文件,而我们只对图像格式感兴趣。

让我们解决这三个剩余的功能。

记录

最终,日志记录只是将信息写入持久存储,这意味着您可以登录到数据库或文件。您甚至可以登录到屏幕,尽管您会遇到持久性可能不够的问题。在我们的例子中,我们将把文本输出到一个文件中。让我们看看日志代码:

public class FileLogger : ILogger
{
    private readonly string _loggingPath;

    public FileLogger(string loggingPath)
    {
        _loggingPath = loggingPath;
    }

    public void Log(string message)
    {
        File.AppendAllText($@"{_loggingPath}\PhotoStorage.Log.txt", $"{DateTime.Now} : {message}{Environment.NewLine}");
    }
}

在这里,我们实现了一个ILogger接口,我们很快就会用到它。这个类没有太大的复杂性——我们只是接受一个路径,然后输出到路径中的一个文件。让我们看看界面是什么样子的:

public interface ILogger
{
    void Log(string message);
}

同样,除了它的存在之外,可能没有太多可以解释的。日志记录是您肯定希望能够切换到单元测试的特性之一,因此接口至关重要。我不会详细描述我正在记录的每个地方,但是让我们看看PhotoService构造函数:

private readonly string _path;
private readonly ILogger _logger;
private FileMonitor _fileMonitor;
private AppSettings _appSettings;

public PhotoService(string path, ILogger logger)
{
    ServiceName = "PhotoService";
    AutoLog = true;
    _path = path;
    _logger = logger;
}

如你所见,我们正在传入记录器;因此,我们在Main方法中实例化它:

static void Main(string[] args)
{
    var path = args[0];
    var logger = new FileLogger(path);

    using var service = new PhotoService(path, logger); 
    ServiceBase.Run(service); 
}

这有一点重复:现在,我们接受参数并将其存储在path变量中。

现在,我们有能力记录;如果你想看几条日志消息,试着在OnStartOnStop各放一条。

Although I'm not going to list all the logging places, you're free to download the GitHub repository for this book and have a look. Should you encounter any issues when following along, this logging capability will be invaluable; however, there is a section later on Testing and debugging if you want to find out more.

仅上传图像

为了确定我们是否有图像,我们将在处理任何文件之前简单地添加一个检查。下面是我们修改后的OnCreatedOnChanged的方法:

private async void OnCreated(object sender, FileSystemEventArgs e)
{
    if (!FileHelper.IsImage(e.Name)) return;
    await _cloudStorageClientService.UploadFile(e.FullPath);
}

private async void OnChanged(object sender, FileSystemEventArgs e)
{
    if (!FileHelper.IsImage(e.Name)) return;
    await _cloudStorageClientService.UploadFile(e.FullPath);
}

如您所见,我们只有一个非常基本的门控检查,返回我们是否在处理图像。当然,我们需要编写这个方法。在这里,我们来到了一个新的 C# 8 特性,我们还没有提到。在 C# 8 之前,该方法可能是这样的:

public static bool IsImage(string fileName)
{
    string ext = Path.GetExtension(fileName);
    switch (ext)
    {
        case "png":
        case "jpg":
        case "jpeg":
        case "bmp":
        case "gif":
            return true;
        default: return false;
     };
 }

但是,有了新的switch语句,我们可以这样编写该方法:

public static bool IsImage(string fileName)
{
    string ext = Path.GetExtension(fileName);
    return ext switch
    {
        "png" => true,
        "jpg" => true,
        "jpeg" => true,
        "bmp" => true,
        "gif" => true, 
        _ => false
    }; 
}

如果我们在switch语句本身中包含一个GetExtension调用,我们可以使它更加简洁,但是,一般来说,它读起来更容易。

The new switch statement is actually more powerful than this and, in fact, supports pattern matching.

让我们看看如何在启动时上传我们在目录中找到的任何图像。

上传现有图像

一旦启动,我们的应用将监控目录中创建或更改的任何新文件,但是如果我们启动应用并将其指向一个已经满了的目录呢?我们可以更改我们的OnStart代码,以便它调用一个将为我们扫描目录的方法。据推测,我们需要传递信息,告诉它可以扫描哪里,一旦发现什么,该怎么办:

protected override void OnStart(string[] args)
{
    var configurationService = new ConfigurationService();
    _appSettings = configurationService.Load(_path);

    var cloudStorageClientService = new AzureStorageClientService(_appSettings);

    _fileDiscoverer.DiscoverFiles(_appSettings.MonitorPath, (file) => cloudStorageClientService.UploadFile(file));
    _fileMonitor = new FileMonitor(_appSettings.MonitorPath, cloudStorageClientService, _logger);
}

这里的新行是对名为_fileDiscoverer的新对象的调用。这里,我们正在传递我们正在监视的目录和对UploadFile方法的回调。首先,让我们将这个依赖项添加到构造函数中:

private readonly string _path;
private readonly ILogger _logger;
private readonly IFileDiscoverer _fileDiscoverer;
private FileMonitor _fileMonitor;
private AppSettings _appSettings;

public PhotoService(string path, ILogger logger, IFileDiscoverer fileDiscoverer)
{
    ServiceName = "PhotoService";
    AutoLog = true;
    _path = path;
    _logger = logger;
    _fileDiscoverer = fileDiscoverer;
}

和所有好的依赖注入一样,我们使用一个接口来传递对象;让我们看看我们的界面需要是什么样子:

public interface IFileDiscoverer
{
    void DiscoverFiles(string directory, Action<string> action);
}

You can have Visual Studio automatically generate this for you by simply using the Ctrl + . shortcut on all the preceding unknown methods and interfaces.

在我们创建方法本身之前,让我们看看传递这个的Program.cs代码:

static void Main(string[] args)
{
    var path = args[0];
    var logger = new FileLogger(path);
    var fileDiscoverer = new FileDiscoverer(logger);

    using var service = new PhotoService(path, logger, fileDiscoverer);
    ServiceBase.Run(service); 
}

我们只是实例化该对象并将其传入。

You may notice that this is starting to look like we would benefit from an IoC container. I'm purposely not using one because it detracts from the code that we're demonstrating; however, should you decide to extend this project, that would be an excellent place to start.

我们需要一种方法来扫描目录中已经存在的文件。为此,我们将能够使用中介绍的功能.NET Core 2.1 称为FileSystemEnumerable。这是建立在.NET Core 2.1 引入了Span,因此,与之前的System.IO替代品相比,提供了一些性能优势。我们来看看IFileDiscoverer的实现:

public class FileDiscoverer : IFileDiscoverer
{
    private readonly ILogger _logger;

    public FileDiscoverer(ILogger logger)
    {
        _logger = logger;
    }

    public void DiscoverFiles(string directory, Action<string> action)
    {
        var enumerationOptions = new EnumerationOptions()
        {
            RecurseSubdirectories = false,
            AttributesToSkip = FileAttributes.Directory 
             | FileAttributes.Device | FileAttributes.Hidden 
        };

        var files = new FileSystemEnumerable<FileInfo>(directory,
            (ref FileSystemEntry entry) => (FileInfo)entry.ToFileSystemInfo(), enumerationOptions)
        {
            ShouldIncludePredicate = (ref FileSystemEntry entry) => 
                FileHelper.IsImage(entry.FileName.ToString())
        };

        foreach (var file in files)
        {          
            action.Invoke(file.FullName);
        }
    }
}

We're passing in the ILogger so that we can see what files have been found and uploaded; however, it's not relevant to the functionality of the class, so I've left its usage out of the preceding code.

FileSystemEnumerable允许我们以各种方式遍历一个目录。如您所见,我们可以通过使用EnumerationOptionsShouldIncludePredicate的组合来过滤某些文件和文件类型。一旦我们有了我们的列表,我们就可以像任何集合一样简单地迭代它;在我们的例子中,我们正在调用我们传入的方法,并传回文件名。

A full and detailed explanation of FileSystemEnumerable is beyond the scope of this chapter (and book); however, a good source of information for the .NET Core API's functions is the Microsoft online documentation, which can be found here: https://docs.microsoft.com/en-us/dotnet/api/.

安装视窗服务

安装视窗服务,特别是为.NET Core(至少在编写时),是一个非常手工的过程。此外,因为我们使用的是配置文件,所以我们需要做一些细微的调整,以便服务知道在哪里可以找到配置文件。

代码更改

当我们安装服务时,我们需要告诉它在哪里可以找到配置文件。我们可以通过简单地将参数传递给服务来实现这一点。实际上有两种方法可以做到这一点;第一个(也是我们感兴趣的一个)是在服务安装期间传入参数。这被传递到Main方法中。第二个是通过服务管理实用程序传入的;参数被传递到服务的OnStart方法中。

在我们的例子中,我们有兴趣改变Main方法:

static void Main(string[] args)
{ 
    using (var service = new PhotoService(args[0]);
    ServiceBase.Run(service);

}

显然,我们需要在PhotoService的构造函数中接受该参数:

private readonly string _path;
private FileMonitor _fileMonitor;
private AppSettings _appSettings;

public PhotoService(string path)
{
    ServiceName = "PhotoService";
    AutoLog = true;
    _path = path;
}

如您所见,我们将参数存储在类级变量中。最后,我们需要更改OnStart方法,使其使用该路径:

protected override void OnStart(string[] args)
{ 
    var configurationService = new ConfigurationService();
    _appSettings = configurationService.Load(_path);

    var cloudStorageClientService = new AzureStorageClientService(_appSettings);
    _fileMonitor = new FileMonitor(_appSettings.MonitorPath, cloudStorageClientService);
}

我们现在需要做的就是发出安装命令,我们就完成了。

安装命令

完成所有这些后,我们就可以安装我们的服务了。

If you choose to extend this project so that it needs distributing, then this method of deployment would be insufficient. There are a number of options for installation and, I imagine, a number more by the time this book is released. However, creating a simple console application that runs the command in this section might be the easiest and quickest option.

为了安装我们的服务,我们将使用一个名为服务控制的工具:它是视窗软件开发工具包的一部分。以提升的权限启动命令提示符。您需要使用以下命令:

c:\windows\system32\sc.exe create PhotoService binpath= [Full Path and Filename for PhotoStorage.WindowsService.exe] [Full Path to the appsettings.json] start= auto

如果您希望从 Visual Studio 获得可执行文件的路径,您可以在项目编译时从输出窗口中简单地复制它(注意,您需要提供可执行文件,而不是 DLL):

appsettings.json的路径可以是任何东西;但是,最初,它将与可执行文件的路径相同。这也是日志将被写入的地方。

It's worth noting that, depending on how your service runs, and your own permissions, you may not have access to anywhere on the hard drive.

在我的例子中,完整的命令如下所示:

c:\windows\system32\sc.exe create PhotoService binpath= "\"C:\Dev\Packt\C-8-and-.NET-Core-3.0-Projects-Second-Edition\Chapter 7 - Photo Storage\PhotoStorage.WindowsService\bin\Debug\netcoreapp3.0\PhotoStorage.WindowsService.exe\" \"C:\Dev\Packt\C-8-and-.NET-Core-3.0-Projects-Second-Edition\Chapter 7 - Photo Storage\PhotoStorage.WindowsService\bin\Debug\netcoreapp3.0\" " start= auto

显示前面命令的屏幕截图如下:

前面截图中的命令可以解释如下:

  1. 参数后面的空格不是修饰性的,而是非常必要的!
  2. binpath参数中的任何引号都必须转义。
  3. 这些表示binpath参数的开始和结束引号。

创建服务后,您应该能够看到并启动它:

就这样。现在,您应该能够将文件放入指定的目录,并看到它们神奇地上传到云中(您可以使用 Azure 存储资源管理器进行检查,正如我们在本章前面提到的)。

太好了,但是如果不行呢?

测试和调试

如果您提取存储库,您会看到我添加了许多单元测试来覆盖一些基本功能。如果你发现某件事没有用,那么编写一个暴露这个问题的单元测试通常是解决它的最快方法。然而,有时应用会运行,单元测试会通过,但还是有问题。我已经编写了一些步骤,如果服务不工作,您可以按照这些步骤来调试服务。

步骤 1–检查服务是否正在运行

这听起来很简单,但是对某件事情没有做你期望它做的事情最明显的解释是,它根本没有做任何事情。您可以通过转到服务应用轻松检查服务是否正在运行:

如果服务没有说运行,如前面的截图所示,那么它就不是。这可能有很多原因,与大多数其他类似的情况不同,简单地启动它是不太可能奏效的。通常,找出原因的最快方法是检查日志文件或事件查看器。

步骤 2–检查日志文件

最初,日志文件将被写入与可执行文件相同的目录(除非您为配置文件指定了不同的目录)。日志文件可能如下所示:

The code to create the PhotoService starting and PhotoService stopping log messages in the service were left out of the preceding code snippets; however, if you pull the repository, you should be able to see all the logging that I've added and even add your own.

这通常是一个调试逻辑错误的好工具(例如,我期望我的应用做 X,但是它做了 Y);但是,如果您的服务只是崩溃,那么事件查看器应该告诉您它是在哪里崩溃的。

步骤 3–检查事件查看器

当窗口服务执行任何操作时,它会向事件查看器写入一个条目。您可以在这里看到任何可能发生的错误:

可以看到,在前面的例子中,服务找不到配置文件。

这里不可能涵盖所有可能的错误和解决方案;然而,从广义上来说,一个 Windows 服务遵循与任何其他程序相同的规则。如果 Windows 服务在测试环境中工作,并且单元测试通过,那么可能的问题是环境,即部署的目录、权限或所需的运行时资源。

如果程序找不到配置文件(或任何依赖项),那么打开它正在查找的目录,看看你是否能自己找到日志文件(或依赖项)。在这个例子中,appsettings.json文件只需要复制到输出目录。您可以通过更改构建动作复制到输出目录参数来让 Visual Studio 执行此操作。

事件查看器是一个非常有用的工具,它为您提供了整个调用堆栈,这意味着您可以隔离出现故障的特定线路。

摘要

在本章中,我们使用创建了一个窗口服务.NET Core 3 和 Windows 兼容包。我们还使用了 C#8 中的新 switch 语句。然后,我们使用 Azure Blob 存储作为上传和存储文件的机制。

我们现在有了一个小服务,当我们将文件放到一个特定的目录中时,它将位于后台并将我们的文件上传到云中。

在下一章中,我们将研究 Docker,以及如何在 AKS 上构建一个可以在 Azure 中托管的服务。*

八、使用 Docker 和 Azure Kubernetes 服务的负载平衡订单处理微服务

不久前,当浏览招聘广告时,术语 n 层架构会突然出现,这是潜在求职者需要熟悉的东西。这种架构范例背后的原则是,有一个数据存储,通常保存在公司拥有的服务器上;然后,有一个服务(或几个服务)会在客户端应用或另一个服务的要求下询问该数据,您会有一个客户端应用(桌面或 web 客户端)与该服务通信。该体系结构通常如下所示:

术语 n 层取代了三层,因为该图中的服务实际上可以调用另一个服务,因此在客户端和服务器之间可能存在几层。

这种方法有很多优点:您可以将用户界面和业务逻辑分开,并且它可以(如果做得正确的话)与数据存储无关(或者至少不可撤销地绑定到数据存储)。您的所有代码都可以存在于一个解决方案中(如果您正在使用。你知道数据库中的任何东西都是任何给定时间的情况。

然而,这种架构方法也有不利的一面,这与某种规模以及可靠性有关。

第一个是使用:如果你增加流量,那么在某一点上,你的架构不能处理那个级别的流量。

我们前面提到的数据存储有一个定义的限制,因为最终它是读写磁盘。这是可以改进的:数据可以分布在多个磁盘上,这大大提高了性能。还有一个问题是,当数据库中的数据被更新时,记录必须被锁定,最后,插入新记录、更新索引等需要花费物理时间。

该服务有一个类似的问题:它一次可以接收的流量是有限制的,尤其是当它与数据库通信并执行事务时。当然,您可以增加服务的数量,但是它们都在争夺同一个数据存储。

下一个规模问题是开发问题:想象一下,您希望同时处理应用的几个部分。当然,有了现代源代码控制,这在机械上是非常容易的;但是,您的销售订单系统可能依赖于您的库存检查系统。您可能会陷入销售订单团队无法取得进展的情况,因为库存检查团队仍在处理给定的功能。

此外,如果你想更新这些系统中的一个,那么你需要测试它们并同时改变它们。这意味着对于每个部署,系统的每个部分都会改变(可能有些部分只是因为重新编译而改变,但它们确实会改变)。

那么,解决办法是什么?很多人最近一直在推广分布式系统的想法(至少在撰写本文时是这样)。这个想法是将整个系统分解成松散耦合的部分或服务。系统的各个部分通常通过某种形式的消息传递进行通信。

For the purpose of this chapter, we're going to work on the premise that our system is being designed for a large DIY store chain.

在本章中,我们将探索使用 Azure Kubernetes 服务来扩展和协调服务的微服务的使用。我们将涵盖以下主题:

  • 在 Azure 中创建存储队列
  • 创建新服务并将其部署到 Azure 容器注册中心 ( ACR )然后部署到 Azure Kubernetes 服务 ( AKS )
  • 通过杀死一个吊舱来证明系统是负载平衡的
  • 使用 JMeter 在负载下测试我们的服务

微服务和容器是当下的流行语:

技术要求

我们将使用 Azure Kubernetes 服务 ( AKS ),所以您首先需要的是 Azure CLI。可以去https://aka.ms/installazurecliwindows安装。

您还需要一个 Azure 帐户;如果你没有,那么你可以在这里创建一个:https://azure.microsoft.com/

在写这篇文章的时候,你可以免费注册,第一个月可以获得 150/$200 的积分。

注册后,请访问 https://portal.azure.com。在这里,您可以管理您的 Azure 资源并检查您的余额。

测试工具

这是一个可选步骤,稍后我将解释如何跳过这一步。如果您选择跳过这一部分,那么您也可以跳到这一部分的结尾。

为了在负载下测试我们的微服务,我们将使用一个名为 JMeter 的工具;可以从http://jmeter.apache.org/download_jmeter.cgi下载。

此外,您还需要安装 Java SE 开发工具包;你可以在这里这样做:http://www . Oracle . com/tech network/Java/javase/downloads/index . html

安装 JRE 超出了本章的范围,尽管有许多关于这方面的在线教程;如果安装正确,您应该能够在命令提示符下键入以下内容,并查看您安装的版本:

java -version

微服务

很多年前,我曾在一个电子数据交换 ( EDI )系统上工作。该系统由销售订单系统工作,将当天的销售汇总写入文本文件;然后,另一个进程将获取该文本文件并处理这些订单。本质上,这是一个分布式系统:系统的每个部分都独立于其他部分运行。销售订单系统可以关闭,它已经写的任何文件仍然会被处理。

如今,这种数据交换形式已经转变为使用消息代理:要么在内部维护,要么在云中维护。稍后我会解释为什么会这样。

At the time of writing, RabbitMQ and ActiveMQ are two well-known message brokers that are typically run on-premises. All of the big cloud providers offer a message broker of one form or another, including Azure, Google, and AWS (which use ActiveMQ).

微服务只是一种分布式系统,它们非常适合使用容器。想法是将最小级别的功能封装在一个独立的流程中。这里的关键是流程是独立的——虽然似乎没有一个正式的文档来声明什么是微服务,但是如果您只是有一个直接依赖于另一个流程的流程,那么称某个东西为微服务是错误的。

It's worth considering the term dependency here. In my EDI example, clearly, a dependency exists in a logical process sense. The test here is whether you can turn one of these processes off and the other(s) continue (obviously with limited effect).

我们已经介绍了创建分布式系统对我们有什么好处,但是让我们考虑一下这种方法的缺点,因为有很多缺点:除非您需要一个,否则您绝对不应该创建分布式系统。我在这里整理了一个(非详尽的)负面清单(每个负面清单都可以在一定程度上减轻,但这个清单的目的是说明这不是一个万灵药):

  • 数据完整性:在一个只有单一数据存储的系统中,你可以简单地依靠你选择的 DBMS 的事务模型来确保,例如,当你创建一个销售订单时,你的库存会减少。在真正分布式的系统中,这些可能是独立的服务,您不能将它们作为单个事务来执行;这样做的最终结果是,你不能保证在任何特定的时间,你的数据是正确的。

  • 系统复杂性:一旦你开始创建许多服务,你会发现你有一个逻辑依赖的网络(类似于你可能在一个单片或 n 层系统中的物理依赖,正如我们之前提到的)。

  • 速度:将一组数据写入数据库的最快方法是在单个事务中完成。一旦在这个过程中引入服务总线,它的速度就会大大降低。

  • 维护:虽然使用这种架构风格的优点之一是可以独立更新单个组件,但也是缺点之一。如果你有 50 个不同的组件,那么你需要跟踪 50 个不同的版本。

在本章中,我们将执行以下操作:

  • 创建一个非常简单的销售订单处理系统。不会有前端,但我们会给它一个销售订单列表。
  • 将我们的系统包装在 Docker 容器中,并将其添加到Azure Kubernetes Service(AKS上托管的 Kubernetes 集群中。
  • 证明即使我们可能会杀死该服务的一个实例,或者它可能会崩溃,Kubernetes 也可以自我修复,我们的订单将继续被处理。

我提到了 Docker 和 Kubernetes,但是这些技术到底是什么?从探索 Docker 开始最容易。

探索码头工人

Docker 是一个容器引擎:这意味着您可以将流程(可执行文件)的所有依赖项封装到一个实体中。我经常认为它有助于分析以容器结束(或目前已经到达)的旅程。

曾经,在微软开发界(之前.NET),我们将编写一个仍然依赖于 dll 的应用。Windows 提供了一个注册表,告诉程序 DLL 将在哪里。当产品发货的时候,您必须创建一个安装程序,将您的依赖项添加到注册表中;然而,因为所有的动态链接库都集中保存,所以您就遇到了一个问题,即另一个程序可能依赖于同一个动态链接库,但是该动态链接库的版本不同。不用说,这导致了数不清的问题,我们根本不知道现场运行的是什么代码,也不相信会安装任何东西。注册表清理器和安装工具如雨后春笋般出现在左侧、右侧和中央,最终微软脱离了注册表模式。

什么时候.NET 发布后,有两种方法可以发布您的依赖项:您可以将它们安装到全局程序集缓存 ( 广汽)中,或者将它们放在与可执行文件相同的目录中。广汽也有自己的问题(它非常接近注册管理机构模型),但是打包了我需要的依赖项,并且使用非常有效;但是,当您想要使用系统依赖项时会发生什么.NET 框架为例)?微软还是很好的,他们允许你把这个添加到一个安装程序中,但是我们又回到了原则上,我们拿了一个软件,在一个不同于测试环境的环境中运行它。例如,您针对哪个版本的 SQL Server 编写数据访问,目标计算机上的版本是否是这个版本?

开始出现的是创建一个虚拟机,然后将整个虚拟机复制到目的地的原理;这样,您可以绝对确定正在运行的是什么,因为它本质上是在同一台机器上进行的测试。这是一个很好的解决方案,但它(可以说)走得太远了。现在有许多虚拟机的操作系统和许可证需要更新和维护。需要的是一个应用的容器,您可以在其中存储应用及其依赖关系,但是整个事情需要从操作系统中抽象出来。

这就是 Docker 的本质——一个这样的容器。

忽必烈与管弦乐团

实际上,Kubernetes 是在 2016 年左右问世的。这是受内部谷歌产品的启发,他们已经在内部使用了几年。为了说明 Kubernetes 允许我们做的事情,我邀请你走过一个小场景。

你在超市排队。不幸的是,收银机很有问题,而且经常坏。有一个在超市工作的人,他的工作就是让队伍保持畅通:他的名字叫温斯顿,所以每次钱柜坏了,他都会把队伍中的下一个人重定向到另一个钱柜,修好钱柜,然后再打开它。

突然,队列变得很大:20 万人一起排队。温斯顿得到指示,如果发生这种情况,他应该“横向扩展”,因此他很快又建立了 500 台收银机来应对压力;这需要几分钟的时间,但是收银机很快上线,队列再次流动:

Kubernetes 充当我们的温斯顿:它可以创建新的容器,升级现有的容器而不会停机,并路由流量。

创建我们的微服务

我们应该考虑我们的微服务是什么,它不是什么:这里最重要的是服务需要是自治的。如果我们创建了一个需要其他服务才能运行的服务,那么它就不是微服务。

We shouldn't confuse this with creating a service that requires other services to function. For example, our sales order processing system must have sales orders, and these come from an external source. This is fine because we're not dependent on that external source: if we don't get a list of sales orders, then our service will still run; it just won't do anything.

让我们从创建新服务开始!

Remember that what we mean here by service is a process that performs a task: it should not be confused with specific usages of the word service, such as a REST service. What we want is simply an independent process.

请记住,像我们忙碌的超市员工温斯顿一样,Kubernetes 可以通过复制容器来扩展您的应用。您只能复制一个容器或进程,如果该进程的编写方式允许的话。这对我们来说意味着,我们需要使用一种机制与我们的服务进行通信,该机制允许多个进程从单个队列中读取,这需要维护。

如果我们回到涉及超级超市雇员温斯顿的例子,队列中的每个人只有在被叫时才接近收银台;如果他们都试图一起接近钱柜,那么钱柜操作员将无法为他们服务。或者,设想这样一种情况,收银员来到队列,扫描前面那个人的物品,然后把那个人留在那里:下一个可用的收银员也可以这样做。温斯顿创造新的收银机只有在有一个机制让顾客以有序的方式接近收银机时才起作用。

我们的容器也是如此:如果你用错误的方式设计了一个微服务,那么它就不能被扩展。

行列

为了避免这种情况,我们将使用 Azure 服务总线为我们进入系统的提要提供一个队列;因为我们不需要保证交付顺序,所以我们可以使用存储队列,而不是服务总线队列。

It's worth bearing in mind that, in the case of sales order processing, the order that processes the messages is probably not too important; however, this is not always the case. You should carefully consider whether the order of delivery is critical in your own case.

让我们从配置队列开始:

  1. 我们需要做的第一件事是创建一个新的存储帐户。在 Azure 门户中,从菜单中选择存储帐户,或简单地搜索。创建存储帐户刀片出现后,选择添加新存储帐户的选项:

  1. 选择“队列”后,您将看到一个当前已设置的队列列表。最初这里很孤独,所以让我们创建一个新队列:

  1. 您将被要求在此处命名您的队列:

  1. 创建新队列后,记下网址,因为我们稍后会用到它:

  1. 接下来,导航到访问键并复制连接字符串(我们稍后还会用到):

我们已经配置了队列,现在是时候研究如何模拟销售订单的创建了。

销售订单生成器

因为我们没有一个有成千上万订单的网站,我们将创建一个新的流程来生成新的销售订单消息。然后,我们将使用一个名为 JMeter 的工具来调用这个过程足够多次,以模拟一个重负载。让我们首先构建应用来创建新消息;第一步是创建一个新的.NET Core 3 控制台应用。

If you have decided to not use JMeter, then follow through anyway, and I'll explain which sections you should skip.

因为我们要对存储队列进行读写,所以我们将把代码放在一个单独的帮助程序库中。现在,我们将简单地编写代码,就像助手存在一样。让我们从控制台的主要方法开始:

static void Main(string[] args)
{
    // How many to create?
    int salesOrderCount = int.Parse(args[0]);

    // Set-up Helpers and Dependencies
    var serviceBusHelper = new ServiceBusHelper();
    var productRepository = new ProductRepository();
    var productService = new ProductService(productRepository);
    var salesOrderRepository = new SalesOrderRepository();

    // Process sales orders - will run forever
    var generateSalesOrders = new GenerateSalesOrders(serviceBusHelper, productService);
    generateSalesOrders.Run(salesOrderCount);
}

如您所见,我们遵循手动构建依赖关系的标准,而不是使用第三方库。

public class GenerateSalesOrders
{
    private readonly IServiceBusHelper _serviceBusHelper; 
    private readonly IProductService _productService;
    private Random _rnd = new Random();
    public GenerateSalesOrders(IServiceBusHelper serviceBusHelper, IProductService productService)
    {
        _serviceBusHelper = serviceBusHelper; 
        _productService = productService;
    }
}

我们只是在这里接受一些基本的依赖关系:serviceBusHelperproductService。我们将很快讨论这些课程的细节。

For now, I'd like to draw your attention to the _rnd variable. Outside of writing games, it's rare that you'll find random numbers in code. I felt that it made sense here because we're generating example data. Clearly, any test that uses a random number doesn't adhere to FIRST testing principles.

课程的下一部分是Run方法:

public void Run(int salesOrderCount)
{ 
    for (int i = 0; i < salesOrderCount; i++)
    {
        var newOrder = CreateSalesOrder();
        _serviceBusHelper.SendToSalesOrderMessageQueue(newOrder);
    }
}

这只是调用一个方法来创建销售订单,然后使用服务总线助手将消息放入队列。我们将在下一节回到服务总线,但是现在,让我们看一下CreateSalesOrder()方法,看看它是做什么的:

private SalesOrder.Models.SalesOrder CreateSalesOrder()
{
    var products = _productService.GetProductData();
    var product = products.ElementAt(_rnd.Next(products.Count() - 1));
    var salesOrder = new SalesOrder.Models.SalesOrder()
    {
        ProductCode = product.ProductCode,
        UnitPrice = product.UnitPrice,
        Quantity = _rnd.Next(1, 5)
    };

    return salesOrder;
}

这个方法还有更多,但本质上我们所做的就是调用产品服务来检索产品列表,然后随机挑选一个。最后,我们创建一个随机数量的销售订单。

产品服务从产品存储库中读取产品列表;为了完整起见,我将在这里列出产品服务及其接口。但是,因为它只调用产品存储库,所以我不会对代码做任何进一步的解释:

public interface IProductService
{
    IEnumerable<Models.Product> GetProductData();
}

具体实现如下:

public class ProductService : IProductService
{
    private readonly IProductRepository _productRepository;
    public ProductService(IProductRepository productRepository)
    {
        _productRepository = productRepository;
    }

    public IEnumerable<Models.Product> GetProductData()
    {
        return _productRepository.GetProductData();
    }
}

同样,我将列出存储库接口,但它不保证任何解释:

public interface IProductRepository
{
    IList<SalesOrder.Models.Product> GetProductData();
}

这里有趣的部分是在实现中。但是,在深入讨论之前,我将向您展示这些组件在我的解决方案中的位置,以及我将用于产品数据的数据:

ProductList.csv文件作为内容文件包含在内。我不会在这里列出全部内容,但是如果你想看,我会鼓励你从 GitHub 存储库中下载它。不过,我会列出前几行,让你感受一下文件的格式(毕竟,我相信你和我一样有能力编造产品代码和价格!).该文件应采用以下格式:

SCREW025MMX50,1.72
HARDWOODSQMT,0.45
SLEDGEHAMMER,34.56

产品存储库方法如下所示:

public IList<SalesOrder.Models.Product> GetProductData()
{
    string data = File.ReadAllText($"img/ProductList.csv");

    var products = new List<Models.Product>();
    string[] productLines = data.Split(Environment.NewLine); 
    foreach(var productLine in productLines)
    {
        string[] productData = productLine.Split(",");
        var product = new Models.Product()
        {
            ProductCode = productData[0],
            UnitPrice = decimal.Parse(productData[1])
        };

        products.Add(product);
    }

    return products;
}

这个文件确实有很多内容,从调用从数据文件中读取所有文本开始。首先,我们把它分成几行,然后依次用逗号隔开。最后,我们将创建一个新的产品类对象,并读取其中的数据。

这段代码不是很有弹性:你应该能够很容易地用一些格式不良的数据打破它。如果您选择扩展这个项目,您可能希望从这里开始,或者通过添加一些防御检查来改进代码,或者甚至将数据移动到数据库中;对于任何严重的延期,建议后者。

Should you wish to improve the reliability of the code, you may choose to use the following NuGet package, which I created: https://www.nuget.org/packages/Castr/.

现在我们已经完成了服务和存储库,让我们来看看服务总线助手。

服务总线帮助程序库

正如您在前面部分看到的,我们指的是服务总线助手。对于生成例程,我们在服务总线帮助器中只有一个方法。我强烈建议您将这段代码放在自己的项目中:这样,您就可以将对 Azure 的依赖限制在一个项目中。以下是我目前的解决方案概述:

如您所见,我们已经分离了服务总线交互。让我们看看目前为止使用的界面:

public interface IServiceBusHelper
{
    Task SendToSalesOrderMessageQueue(SalesOrder.Models.SalesOrder salesOrderData);
}

在我们的新项目中,我们需要引用一个微软 NuGet 库:

Install-Package WindowsAzure.Storage

一旦我们做到了这一点,我们就可以实现我们的助手方法:

public async Task SendToSalesOrderMessageQueue(Models.SalesOrder salesOrderData)
{
    CloudStorageAccount storageAccount = CloudStorageAccount.Parse("DefaultEndpointsProtocol=https;AccountName=salesorderqueue;AccountKey=...;EndpointSuffix=core.windows.net");

    CloudQueueClient queueClient = storageAccount.CreateCloudQueueClient();
    CloudQueue queue = queueClient.GetQueueReference("salesorder");
    CloudQueueMessage message = new CloudQueueMessage(JsonConvert.SerializeObject(salesOrderData));

    await queue.AddMessageAsync(message);
}

Before we delve into an explanation of what this is doing, note that the string that's passed to CloudStorageAccount.Parse() is the connection string that we made a note of earlier. As you can see, I've put the connection string directly into the code. Needless to say, this is bad practice; however, as in previous chapters, I feel that it illustrates, the software that we are actually writing better, and more directly.

那么,这段代码在做什么?首先,我们创建一个CloudStorageAccount实例;这有效地连接到 Azure 存储帐户,并作为我们未来通话的基础。接下来,我们需要一个CloudQueueClient,然后使用GetQueueReference获取对队列本身的引用。最后,我们通过简单地序列化类数据并将其添加到队列中来构建一条新消息。

A message should contain all the information that's necessary to deal with that message. You should think carefully about the size of your message; however, on storage queues, you have a limit of 200 GB!

测试我们的销售订单生成器和 JMeter

在我们继续之前,让我们给我们的发电机做一个快速测试。有两种方法可以做到这一点;第一种方法是在命令行中编译和运行软件,但是您也可以简单地在项目属性中提供一个参数:

如您所见,我选择创建一个订单。最好先尝试创建一个单独的项目,只是为了检查它是否正确运行。一旦你知道这个有效,你可以把它提高到,比如说,10 或 20。

你怎么知道它有效?有益的是,微软有存储资源管理器应用,你可以免费下载;你可以在这里找到它:https://azure . Microsoft . com/en-us/features/storage-explorer/。

此外,在撰写本文时,微软已经在门户中发布了相同工具的预览版:

无论您决定以哪种方式查看队列,一旦您运行了销售订单生成器,您应该会在队列中看到一条消息(以下截图来自桌面版本):

尝试运行该软件 10 条甚至 100 条消息,只是为了检查它是否如预期那样生成消息。

我们的下一步将是从 JMeter 调用我们的程序,这样我们就可以模拟同时下许多订单。

测试工具

要模拟高负载,一种选择是简单地编译应用并一起运行它的几个实例。这就是 JMeter 这样的工具为你做的:我在技术需求 s 部分提到 JMeter 是可选的;如果您不想使用 JMeter,那么只需运行应用几次,将数字设置为 100 左右。以下 DOS 命令将为您完成此操作:

for /l %x in (1, 1, 10) do start SalesOrder.Generate.exe 10

在这种情况下,我们调用Generate例程 10 次。这将几乎立即向队列中添加 100 条消息。您将在 GitHub 存储库中找到一个执行相同操作的批处理脚本。如果您不希望使用 JMeter,请直接跳到下一部分,标题为登录

回到 JMeter,假设您已经成功安装了 JDK(参见技术要求部分),您应该能够简单地将 ZIP 文件提取到本地驱动器的某个地方并运行jmeter.bat。您可以在提取的以下子目录中找到这个:apache-jmeter-5.1\bin

如果您已成功运行该软件,您将看到类似如下的屏幕:

It's worth bearing in mind that, depending on when you are reading this, the user interface for JMeter may have changed.

第一步是添加线程组:

线程组是一种表示执行操作的一组用户的方式。让我们设置 10 个用户,每个用户将执行我们的世代应用 10 次。

10 users running the app 10 times and the app generating 10 orders for each invocation means that 1,000 orders will be generated.

下面是我们如何在线程屏幕上配置前面的场景:

最后,我们将添加一个新的采样器来调用我们的可执行文件:

一旦我们添加了它,我们可以要求它从新的采样器调用我们的可执行文件,我们就完成了:

我们准备好出发了。只需按下播放按钮。10 个用户应该立即开始以 10 节的速度下单!

记录

在我们取得任何进展之前,我们应该考虑记录。使用单个应用,日志记录是一个非常简单的过程——您将所有内容记录到一个日志文件中(通常位于服务器上),并且由于所有调用都是针对该单个服务器上的服务,所以所有内容都记录在一个地方。在我们迁移到微服务时产生的其他问题中,我们会产生一个日志问题。到底是哪个服务在记录日志,它记录到哪里?在容器系统中,您不能简单地登录到本地文件系统,因为它不一定是持久的。

创建聚合日志系统远远超出了本章的范围;但是,如果您希望这样做,我强烈建议您查看主要云提供商的一些产品。微软的应用洞察提供了日志功能。

另一种选择是简单地创建一个日志队列,并将所有日志消息写入一个队列(然后,您可以选择编写一个使用者,而不是将其转换为一个文件)。请记住,您可能会同时写入多个日志消息,因此您应该有一种方法来分离这些进程。

创建新的微服务

现在我们有了一个队列和一些数据,我们可以创建新的微服务。

Our microservice will be technology-agnostic. It will be written in .NET Core 3.0 and it will be running inside a Linux container; however, it will not have any hard dependencies on any external hosting, so we should be able to take it out of AKS and drop it into Google-hosted Kubernetes. If you are developing microservices (or anything), you should ask yourself why you are using the technology (and architecture) that you've chosen. An answer such as to gain exposure to that technology is an acceptable reason if it's a personal project, but if you are being paid to produce a piece of software, saying this may not be acceptable to the person paying you. Kubernetes is an excellent container orchestrator, but bear in mind that if you simply write an Azure WebJob or function, you may get all of the benefits that you need with less of the overhead.

我们的服务本身非常简单。我们只需监控队列,在发现消息的地方,我们会对其进行处理。显然,一个完整的、功能齐全的销售订单处理系统对于单个章节来说代码太多了;但是,我们将产生一个完整的流程,该流程将执行以下操作:

  • 向数据库添加新的销售订单
  • 将消息添加到另一个队列,以便调度系统接收

您可能已经从架构中意识到这个过程是一个脱节的过程;也就是说,当用户下订单时,检查是否有足够的库存来完成订单的过程会离线进行。这是有意为之的:这是像这样的系统如何处理突然激增的流量;然而,这意味着你必须重新思考你的用户体验。假设用户已经下了订单,然后在未来的某个时候,订单被拒绝。有办法处理这个;例如,如果库存是您唯一的潜在问题,您可以简单地将重新进货所需的时间计入提前期,这样每个订单都有 10 天的交货窗口,但大多数订单都在 2 天内交货。简而言之,这是一个业务逻辑问题,但也许这是一个更传统的架构所没有的问题。

现在,我们必须创建我们的微服务。这个的逻辑其实比较简单;该服务应执行以下操作:

  • 检查销售订单队列中的新请求;如果没有请求,那么服务应该在一段时间内再次检查(我们将进行 1 分钟)
  • 在发现消息的地方,服务应该将销售订单添加到数据库中
  • 服务应该在一个新的队列中放置一条消息,表明订单成功

Clearly, this flow is abridged, but it is fully functional in and of itself.

创建我们新的 Docker 容器

让我们从创建一个新的控制台应用开始;我们将在一个 Linux 容器中编译并运行它。控制台应用的创建与任何其他控制台应用完全一样,尽管因为我们处理的是 Linux,所以命名必须是小写的,不能包含点。这使得我们的解决方案看起来有些混乱,但是,让我们创建salesorder-process:

我们的下一步是添加 Docker 支持。在这里,您可以简单地手动创建一个 Docker 文件,但是 Visual Studio 很乐意帮助我们。通过右键单击项目,现在有一个添加| Docker 支持选项:

您可以选择创建一个窗口或 Linux 容器;我们将选择 Linux。这将为您创建您的 Dockerfile。

Docker for Windows either runs Linux or Windows containers, but at the time of writing, it cannot run both (you can switch). It looks like Microsoft is going all-in on Linux containers, so my advice would be going for Linux and staying with them. Of course, this is just my personal opinion.

一旦 Docker 文件出现,新的右键单击上下文菜单就可用了。选择构建 Docker 映像:

经过相当长的构建过程后,您应该会得到一个报告,表明映像已经成功创建。为了检查这一点,让我们运行一个 PowerShell 实例,看看当前可用的图像列表;列出图像的命令如下:

docker images

您的新图像应列在顶部:

现在,我们可以运行这个图像,只是为了检查它是否构建正确。为此,请使用以下命令:

docker run salesorderprocess

应该向您呈现控制台应用的输出;既然我们还没有改变什么,这就是默认Hello World

现在我们已经成功地创建了容器,让我们从添加将组成微服务的逻辑开始。

创建微服务逻辑

逻辑本身相对简单;但是,您会注意到这里有一些空白,我们使用的方法和类要么不存在,要么不可访问。我们将从把我们的逻辑放入一个单独的类开始;为了使这个类易于理解,我们将创建一个名为Run的公共函数:

public async Task Run()
{
    while (true)
    {
        if (!await ProcessEachMessage())
        { 
            await Task.Delay(60000);
            continue;
        }
    }
}

这里的逻辑是,我们试图从服务总线获取下一条消息;当我们不成功时,我们等待一分钟,然后再试一次。让我们看看ProcessEachMessage是什么样子的:

public async Task<bool> ProcessEachMessage()
{
    SalesOrder.Models.SalesOrder? salesOrder = await _serviceBusHelper.GetNextOrderFromMessageQueue();

    if (salesOrder == null) return false;

    _salesOrderService.Create(salesOrder);
    await _serviceBusHelper.ConfirmSalesOrderToMessageQueue(salesOrder);

    return true;
}

如果我们确实从队列中获得了销售订单,我们调用一个服务方法来创建销售订单;然后,我们向队列中添加一条确认消息。

This is the basis of this kind of design; where you need to communicate with other parts of the system, you need to do so via a mechanism that doesn't require that part of the system to respond immediately (or, indeed, at all). In this case, if we stopped this service and then ran the generator, the generator would not fail because this service was not running.

我们使用一个单独的类,这样我们就可以注入我们的依赖项;正如您所看到的,我们在这里使用了一些,它们目前大多是不存在的。我们将很快介绍这些,但我们先介绍一下新类的构造函数:

private readonly IServiceBusHelper _serviceBusHelper;
private readonly IProductService _productService;
private readonly ISalesOrderService _salesOrderService;

public SalesOrderProcessor(IServiceBusHelper serviceBusHelper,
                           IProductService productService,
                           ISalesOrderService salesOrderService)
{
    _serviceBusHelper = serviceBusHelper;
    _productService = productService;
    _salesOrderService = salesOrderService;
}

在这个阶段,您需要添加对SalesOrder.ServiceBusSalesOrder.Models项目的引用。我们现在已经到了这样一个阶段,即SalesOrder.Generate内部的大部分代码都应该被引入到自己的项目中,以便可以从其他地方访问。因为这大部分是将代码从一个项目移动到另一个项目,所以我不会详细说明每个类,而只是简单地显示新项目和旧项目的结构;以下是搬迁后的SalesOrder.Generate项目:

正如你所看到的,我们已经将几乎所有东西从SalesOrder.Generate中移出,并将其转移到一个新项目中:SalesOrder.Data。让我们看看我们在SalesOrder.Data有什么:

如您所见,我们只是从一个项目中取出文件,并将其粘贴到另一个项目中。唯一需要的其他步骤是重命名名称空间;例如,ProductService的命名空间现在将是SalesOrder.Data.Products

In cases like this, Ctrl + Shift + H is your best friend and will allow you to replace all the instances of the old namespace with the new one.

您还需要添加一些项目引用;SalesOrder.Generate项目现在应该参考SalesOrder.Data项目。新的SalesOrder.Data项目反过来又依赖于SalesOrder.Models

既然我们已经解决了这个问题,我们就可以开始填充存根函数了;让我们从SalesOrder服务开始。

For the sake of illustration, we grouped the service and data repository classes inside a single project. Generally, this should be avoided; should you decide to extend this project, separating the repository and the services may be a good idea.

创建新的 Azure SQL 数据库

当您为容器编写时,尤其是当您想要一个流畅的开发体验时,能够记住 Docker 命令变得越来越不重要;事实上,在本章的过程中,直到我们开始将容器移动到 AKS 中,才有可能不接触命令行。因此,很容易忘记您是在一个容器中运行的,而 Linux 容器也是如此。EF Core 允许您连接到一个 SQL 实例,因此很容易连接到(LocalDB)。但是,您将无法从您的容器中访问它,因为它是一个仅限于 Windows 的概念,并且当然存在于主机上,而不是容器中。

那么,我们可以从一个容器中访问 SQL Server 吗?如果是,怎么做?答案是你可以访问它,但是你需要记住你在哪里:当你在容器里面的时候,你的本地主机不是你的机器——它是你的容器。如果您想托管自己的 SQL Server 实例,当然可以这样做,但是您需要通过外部 IP 地址进行连接。

在我们的例子中,我们打算将它转移到 AKS 上,所以让我们的数据库托管在 Azure 中是有意义的。让我们快速创建一个新的 SQL Azure 数据库和服务器。从 Azure 中,选择 SQL 数据库刀片:

和往常一样,你也可以简单地搜索这个。选中后,单击添加创建新数据库:

这个屏幕现在看起来可能很熟悉。所有部分大致相同:订阅和资源组决定了资源的位置。每个服务器的数据库名称应该是唯一的。

除非已经设置了服务器,否则请选择标记为“创建新的 SQL 服务器”的链接。这将在屏幕右侧打开一个窗口,允许您创建一个新的服务器。一旦您这样做了,它将为您填充该值。

You'll be given a default Standard S0 compute and storage model. The compute and storage model that you choose is something that only you will know; however, if you are simply following along to learn how this works, I would recommend selecting the hyperlink to configure the database and selecting the Basic tier. At the time of writing, this comes in at under £5/month.

创建资源后,从概述中选择连接字符串,并保存一份连接字符串的副本——我们很快就会需要它。

实体框架核心

在这个阶段,我们需要引入一个数据持久层。为此,我们将使用实体框架核心 ( EF 核心)。第一步是向数据项目添加 EF Core:

Install-Package Microsoft.EntityFrameworkCore -ProjectName SalesOrder.Data
Install-Package Microsoft.EntityFrameworkCore.Tools -ProjectName SalesOrder.Data
Install-Package Microsoft.EntityFrameworkCore.SqlServer -ProjectName SalesOrder.Data
Install-Package Microsoft.EntityFrameworkCore.Design -ProjectName salesorder-process

一旦我们安装了必要的包,我们需要几个类,这样 EF Core 就可以创建我们的数据库;让我们将它们都放在SalesOrder.Data项目内部一个名为Entities的新文件夹中。事实上,我们首先需要的是一个SalesOrder实体:

public class SalesOrderEntity : SalesOrder.Models.SalesOrder
{
    [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    public int Id { get; set; }
}

正如您将看到的,我们只是从我们的模型中继承。拥有模型类的过程并不少见,模型类可以在数据库不可知的情况下传递,然后继承它们来创建数据库实体。您可以简单地告诉 EF 使用模型类,但是这个方法提供了一个抽象,这样您就可以屏蔽对模型类的更新,而不会让它们扩散到数据库中。我们还在这里声明了一个自动生成的主键,这显然在我们的模型中没有位置。

接下来,我们需要创建一个数据库上下文:

public class SalesOrderContext : DbContext
{
    public SalesOrderContext(DbContextOptions<SalesOrderContext> options) : base(options)
    { 
    }

    public DbSet<SalesOrderEntity> SalesOrders { get; set; }
}

在这里,我们只是简单地说,我们想要一个单一的表来保存销售订单。其余的功能由英孚核心为我们提供(注意我们继承自DbContext)。最后,我们需要一个设计环境,以便英孚核心知道在哪里创建我们的数据库:

public class SalesOrderContextFactory : IDesignTimeDbContextFactory<SalesOrderContext>
{
    public SalesOrderContext CreateDbContext(string[] args)
    {
        var optionsBuilder = new DbContextOptionsBuilder<SalesOrderContext>();
        optionsBuilder.UseSqlServer(@"Data Source=[ConnectionString]");

        return new SalesOrderContext(optionsBuilder.Options);
    }
}

这只是为了让英孚核心可以连接到我们的数据库,并创建或更新它。您需要用之前记录的连接字符串替换[ConnectionString]占位符。

The connection string has absolutely no place in this file; once you're familiar with how this works, I would encourage you to put this into a configuration file.

这就是我们需要的所有基础设施。下一步是添加迁移:

Add-Migration InitialCreation -Project SalesOrder.Data -StartupProject salesorder-process

You can call this command from PowerShell, or wherever you choose, but using the Package Manager Console inside Visual Studio gives you a nice little PowerShell interface directly inside the IDE.

这将在SalesOrder.Data项目中创建一个Migrations文件夹,并提供一个将创建数据库的迁移。要让它真正创建数据库,我们需要另一个命令:

Update-Database -Project SalesOrder.Data -StartupProject salesorder-process

在 Visual Studio 中快速查看 SQL Server 对象资源管理器,应该会验证数据库实际上已经创建。我们现在可以继续我们的代码,并从消息队列中填充它。

There are ways that you can have your program automatically call the migration when it runs. There are advantages to doing this either way; this method provides more control (you won't be able to run the software if the database isn't up to date), however, having to manually run an update script might not make sense in your deployment scenario.

创建销售订单

在创建销售订单时,我们实际上有两个抽象层:服务和存储库类。这里的想法是,存储库负责数据访问和业务概念的服务。在我们的例子中,这些是相同的,因为它是一个如此简单的应用,但是如果您开始扩展这个项目,记住这些差异将有助于代码保持干净和简洁。

Abstraction, for the sake of it, is the enemy of simplicity. If you know that your application will always be just a UI over a database and that you will always use Entity Framework, then access the data context directly. Although this does reduce your ability to create unit tests that don't include data access, it could be argued that, in such a scenario, those tests were unnecessary anyway.

从我们之前在Main方法中创建的代码来看,我们在销售订单服务中有两个新的创建方法;我们将从创建销售订单开始。SalesOrderService需要一种叫做Create 的单一方法。修改后的界面如下:

public interface ISalesOrderService
{
    void Create(SalesOrder.Models.SalesOrder salesOrder);
}

没什么可看的,我承认。这一点的实现并不复杂:

public void Create(SalesOrder.Models.SalesOrder salesOrder)
{
    var salesOrderEntity = new SalesOrderEntity()
    {
        ProductCode = salesOrder.ProductCode,
        Quantity = salesOrder.Quantity,
        Reference = salesOrder.Reference,
        UnitPrice = salesOrder.UnitPrice
    };
    _salesOrderRepository.Create(salesOrderEntity);
}

我们的存储库方法接受一个SalesOrderEntity类型的实体(我们在前面已经讨论过了),这意味着我们必须在这里转换对象。我们将存储库注入到这个类中;为了完整起见,下面是构造函数和类变量:

public class SalesOrderService : ISalesOrderService
{
    private readonly ISalesOrderRepository _salesOrderRepository;        

    public SalesOrderService(ISalesOrderRepository salesOrderRepository)
    {
        _salesOrderRepository = salesOrderRepository;
    }

    . . . 

我们下一步是填写StorageQueueHelper中的方法。

存储队列

我们有两种方法需要从存储队列中完成。我们的第一个方法是ConfirmSalesOrderToMessageQueue;这负责将销售订单的副本发布到新队列中,以表明我们已经处理了它。您可能开始注意到这与我们之前编写的首先向队列发送消息的方法之间的相似之处:

public async Task ConfirmSalesOrderToMessageQueue(Models.SalesOrder salesOrderData)
{
    CloudStorageAccount storageAccount = CloudStorageAccount.Parse("DefaultEndpointsProtocol=https;AccountName=salesorderqueue;AccountKey=aa8eED0s25wezSDCyj0BmukVq2zE9puEFRVq4jIR++n8L1NNSUyZZaJXZHVN91BgsQQ9sPE2gnlsb5MWC1TsVw==;EndpointSuffix=core.windows.net");

    CloudQueueClient queueClient = storageAccount.CreateCloudQueueClient();
    CloudQueue queue = queueClient.GetQueueReference("salesorderconfirm");
    await queue.CreateIfNotExistsAsync();
    CloudQueueMessage message = new CloudQueueMessage(JsonConvert.SerializeObject(salesOrderData));

    await queue.AddMessageAsync(message);
}

Remember to replace the connection details with your own (these are not real account details).

事实上,这两种方法几乎完全相同。队列名称是不同的,但是很明显,如果您愿意,这两个方法可以重构为一个。现在,我将把它们分开,因为我认为这更好地说明了它们的不同功能;然而,我不会重新解释代码。

最后,我们有从队列中获取下一个销售订单的代码,即GetNextOrderFromMessageQueue:

public async Task<Models.SalesOrder?> GetNextOrderFromMessageQueue()
{
    CloudStorageAccount storageAccount = CloudStorageAccount.Parse("DefaultEndpointsProtocol=https;AccountName=salesorderqueue;AccountKey=aa8eED0s25wezSDCyj0BmukVq2zE9puEFRVq4jIR++n8L1NNSUyZZaJXZHVN91BgsQQ9sPE2gnlsb5MWC1TsVw==;EndpointSuffix=core.windows.net"); 
    CloudQueueClient queueClient = storageAccount.CreateCloudQueueClient(); 
    CloudQueue queue = queueClient.GetQueueReference("salesorder"); 
    var message = await queue.GetMessageAsync();
    if (message == null) return null; 
    string data = message.AsString; 
    var salesOrder = JsonConvert.DeserializeObject<Models.SalesOrder>(data);

    await queue.DeleteMessageAsync(message.Id, message.PopReceipt); 
    return salesOrder;
}

同样,这是一组非常熟悉的方法;重点如下:

var message = await queue.GetMessageAsync();
...
string data = message.AsString;

这只是返回一条消息。现在,我们可以使用AsString属性提取其内容。然后,我们将其反序列化回一个SalesOrder对象:

await queue.DeleteMessageAsync(message.Id, message.PopReceipt);

此命令从队列中删除消息。值得记住的是,如果事情在这一点之后崩溃,信息将会丢失。您可能希望考虑维护这个标识,甚至可能只在订单创建后删除消息。但是,我们仍然可以识别订单,因为我们可以确定销售订单没有确认。

如果您现在运行这个项目,您应该会发现它工作正常,并开始处理队列中的任何消息。

最后一步是把这个上传到 Azure 的 AKS 系统中,这样项目就完成了。

现在我们已经创建了我们的项目,让我们看看如何在微软的 Azure Kubernetes 服务中托管这个项目。

蓝色忽必烈服务

Azure Kubernetes 服务(或 AKS ,通常缩写为)只是几种云 Kubernetes 产品中的一种。它充当了创建和托管自己的 Kubernetes 集群的中间点,并使用特定的云服务,如 AWS Lambda 和 Azure Functions。这里的主要优势是控制(您可以确定部署扩展的原因和方式)和可移植性(您应该能够从微软升级,将完全相同的设置放入谷歌,并从您停止的地方继续)。

构建 Docker 映像

让我们从代码中创建一个 Docker 映像。在项目中找到 Docker 文件,并从上下文菜单中选择“构建 Docker 映像”:

Azure 容器注册表

现在,您应该能够打开 PowerShell 并键入以下内容:

docker images

这将为您提供一个列表,列出您机器上当前拥有的所有 Docker 映像。容器注册表本质上是它的远程版本;也就是说,您创建一个映像并将其上传到注册表。当我们上传到 AKS 时,我们需要我们的图像在一个容器注册表中。虽然 Azure 确实有自己的容器注册表,但是您可以使用任何一个;Docker 有一个容器注册中心,大多数(如果不是全部的话)大型云提供商也有。在这一部分,我们将上传到蔚蓝集装箱登记处 ( ACR )。

到目前为止,我们几乎一直依赖于 Visual Studio 来管理我们的 Docker 映像;在撰写本文时,这是一个非常新的特性,以前需要在命令行中完成所有工作。现在,我们需要离开 IDE 的舒适环境,深入探究 PowerShell 和 Azure CLI 的黑暗世界。要确保安装了 CLI,请打开 PowerShell 窗口并输入以下命令:

az --version

Mine is version 2.0.57, although anything later than 2.0.55 should be fine. If you have a lower version than that, then please return to the Technical requirements section and reinstall the CLI. If you don't have this installed, then jump back to the Technical requirements section, where you'll find some instructions on how to install this.

我们的首要任务是将我们的形象推向 ACR。

蔚蓝集装箱登记处

既然我们已经建立了 CLI 的有效版本,我们就可以创建新的注册表;以下命令将为您完成此操作:

az acr create --resource-group netcode-projects --name salesorderregistry --sku Basic

netcore-projects是我的资源组,所以你需要用你自己的替换它。我已经调用了我的注册表salesorderregistry,但是只要你遵守命名规则,你可以随意调用你的注册表。sku决定可用的性能和规模。

You would need to look into the specifics at the time of using this, but broadly, the more you pay, the better the performance and the easier it will scale. This is a common pattern with cloud utilization.

对前面命令的响应应该类似于这样:

记下loginServer的回答,因为这决定了从我们的登录开始,您将如何从这里开始查阅您的注册表:

az acr login --name salesorderregistry

在前面的命令中,salesorderregistry实际上是我们刚才创建 ACR 时返回的值(为了这个命令的目的,我们只需要这个值的第一部分)。您应该会收到对该命令的响应,告诉您登录成功。

之前,我们查看了 Docker 图像,以识别本地系统中存在的图像;我们将再次需要该命令,但这一次,我们需要记下图像名称和标签。提醒一下,此命令如下:

docker images

响应应该是这样的:

首先,我们需要标记我们的图像;下面的命令应该可以帮我们做到这一点:

docker tag salesorderprocess salesorderregistry.azurecr.io/salesorderprocess:v1

如果我们再次列出图像,我们应该会看到新的标签:

现在我们有了一个标签,我们可以将它推送到我们的存储库中:

docker push salesorderregistry.azurecr.io/salesorderprocess:v1

这一次,我们应该看到一些行动;也就是说,当图像被推入存储库时,您应该会看到一些状态栏在移动:

要确认这一点,请使用以下命令,它应该会告诉您,您的图像现在确实在 ACR 中:

az acr repository list --name salesorderregistry --output table

现在我们在容器注册表中有了一个图像,我们可以创建我们的 Kubernetes 集群并设置我们的编排。

蓝色忽必烈服务

我们将在 PowerShell 中度过这一整节。为了使用 Kubernetes,您需要做的第一件事就是安装 CLI。为此,请登录 Azure:

az login

这将打开一个窗口,允许您输入您的 Azure 凭据。一旦通过身份验证,您应该能够安装 Kubernetes 命令行界面:

az aks install-cli

这可能需要一点时间来下载;一旦有了,我们就可以创建一个新的 Kubernetes 集群。本质上,集群就是当你说 Kubernetes 的时候你可能想到的。集群中的节点由主节点管理。这意味着您与主节点交互,它将工作负载委托给节点。想到这一点的好方法就像建筑工地上的工作人员:你可以接近工头,让他建一堵墙,但他自己不太可能这么做;相反,他会找到他的一些工人,他们有空并且有能力建造一堵墙,并要求他们开始建造。

以下命令应该会创建我们的集群:

az aks create --resource-group netcode-projects --name salesorder-cluster --generate-ssh-keys

It's worth pointing out that creating a Kubernetes cluster creates a number of virtual machines, all of which you pay for whether or not you're actually doing anything with them. In the grand scheme of things, you're looking at maybe a pound or two by the time you've completed this chapter, but remember to clean up afterward; otherwise, you may get an unexpectedly large bill!

同样,您应该将资源组替换为此项目使用的资源组。您也可以将名称替换为...好吧,不管你想要哪个名字。

The SSH keys that you've asked for will allow you to access the cluster.

完成后,您应该会得到一个包含大量集群细节的 JSON 响应。

下一步是将kubectl工具指向您的新集群:

az aks get-credentials --resource-group netcode-projects --name salesorder-cluster

kubectl is the main tool for interfacing with Kubernetes via the command line.

我们可以通过要求它列出我们的节点来验证这是否对我们的集群有效:

kubectl get nodes

你应该得到一个包含三个节点的列表,所有节点都准备好了。接下来我们需要做的是指定要在集群中使用的 Docker 映像;我们可以通过创建一个 Kubernetes 部署文件(在 YAML!).

库比涅斯部署

现代部署技术的巨大优势之一是,您不再需要公司中有人进行部署。我记得在我早期的职业生涯中,你要么自己手动将软件部署到一个真实的环境中,要么认识办公室里的某个人可以做到这一点。不用说,这项技术充满了危险!输入错误的命令,您可能会部署错误的软件,或者部署正确的软件不正确或不完整;这使得现场诊断问题变得极其困难,因为你永远也不能确定到底发生了什么。

*如今,有许多工具可以帮助解决这个问题;但是,Kubernetes(以及一般的容器生态系统)是用自动化部署构建的,作为您如何使用它的一部分。因此,为了将任何东西部署到 Kubernetes,您必须创建一个脚本(在 YAML 或 JSON 中)来告诉 Kubernetes 部署什么以及如何部署。

YAML is white-space sensitive, that is, the indentation levels matter.

让我们从创建 YAML 文件开始:

apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: salesorderservice
spec:
  replicas: 2
  template:
    metadata:
      labels:
        app: salesorderservice
    spec:
      containers:
      - name: salesorder-process
        image: salesorderregistry.azurecr.io/salesorderprocess:v1
        ports:
        - containerPort: 80

在这里,我们宣布打算将我们的形象部署到 Kubernetes。一旦部署了这个,Kubernetes 将不断尝试保持这个声明的正确性;例如,如果一个节点死亡,它将尝试创建另一个节点;如果我们推出一个新的图像,它会试图得到那个图像,以此类推。

It is possible to simply ask Kubernetes to return the latest image (:latest), but this is generally considered to be bad practice, especially in production, as this could result in changes to a production environment that weren't planned.

现在,我们可以使用kubectl工具来应用我们的部署(您将需要在部署文件发生变化时执行此操作–我们稍后将回到这一点):

kubectl apply -f "C:\Dev\Packt\C-8-and-.NET-Core-3.0-Projects-Second-Edition\Chapter 8 - Sales Order Processor\deployment.yaml"

Remember to replace the path with the path to your own YAML file.

当您运行这个时,它应该看起来工作正常。但是,这一切其实告诉你的是,文件本身是有效的;你要求它做的事情可能是不可能的。例如,让我们键入以下内容:

kubectl describe pod

由此可见,部署远未成功:

问题是 Kubernetes 没有权限从 ACR 中提取图像。通过查看 Kubernetes 仪表板,我们可以对正在发生的事情有一个稍微好一点的了解。首先,我们需要让仪表板访问我们的集群:

kubectl create clusterrolebinding kubernetes-dashboard --clusterrole=cluster-admin --serviceaccount=kube-system:kubernetes-dashboard

然后,以下命令将启动仪表板:

az aks browse --resource-group netcode-projects --name salesorder-cluster

The dashboard is an invaluable tool when you're trying to diagnose issues with Kubernetes!

事实上,为了从 ACR 中拉出,AKS 需要明确的许可。我们可以使用以下命令来实现这一点:

az ad sp create-for-rbac --skip-assignment

由此,您应该会得到类似以下内容的响应:

突出显示的值是受让人的值(记下来);我们一会儿就称之为value_a。我们还需要从我们的容器注册表中获得一些详细信息:

az acr show --resource-group netcode-projects --name salesorderregistry --query "id" --output tsv

这应该会给你一个类似如下的回答:

我们在这里将高亮显示的值称为范围(我们称之为value_b)。

要实际分配角色,我们需要使用以下命令:

az role assignment create --assignee value_a --scope value_b --role acrpull

在我的例子中,这看起来如下:

az role assignment create --assignee 8a970d1b-7b69-4d2e-a084-0087c9cbf5d0 --scope /subscription
s/16a7fc79-9bea-4d04-83a1-09f3b260adb0/resourceGroups/netcode-projects/providers/Microsoft.ContainerRegistry/registries/salesorderregistry --role acrpull

现在,这个应该可以正常工作了;但是,如果您遇到任何问题,开始诊断这些问题的好地方是仪表板内。选择以下屏幕截图中突出显示的按钮将显示容器的日志文件,包括任何崩溃报告:

如果您发现您有任何错误,您可以修复它们并重新创建图像(跳转到构建 Docker 图像部分了解更多信息)。然后,您可以通过标记图像来跟随;但是,这一次,将图像标记为v2。最后,更新并重新应用deployment.yaml文件。现在,您的新 YAML 文件可能如下所示:

apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: salesorderservice
spec:
  replicas: 2
  template:
    metadata:
      labels:
        app: salesorderservice
    spec:
      containers:
      - name: salesorder-process
        image: salesorderregistry.azurecr.io/salesorderprocess:v2
        ports:
        - containerPort: 80

当您的吊舱正常运行时,您应该能够在吊舱状态中看到以下内容:

kubectl describe pod

这会给你一个关于豆荚的健康报告:

最后,仪表板应该显示绿色:

现在我们已经成功配置了 Kubernetes 集群,让我们回到本章的标题,研究 Kubernetes 的负载平衡方面。

负载平衡

本章的标题是使用 Docker 和 Azure Kubernetes 服务的负载平衡订单处理微服务。事实上,您已经看到了这一点,因为工人舱正在根据可用的物品从队列中挑选物品。然而,让我们看看我们能否设计出一个更具戏剧性的演示,来说明这实际上意味着什么。让我们进入仪表板,删除其中一个吊舱;选择窗格右侧的省略号并将其删除:

删除 pod 后,您应该(几乎立即)注意到屏幕更改如下:

如你所见,一旦库本内特斯意识到你杀死了其中一个豆荚,它会立即启动新的豆荚进行补偿!它怎么知道要这么做?嗯,在我们的deployment.yaml文件中,我们已经将副本设置为2。为了证明这一点,让我们把副本增加到3:

apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: salesorderservice
spec:
  replicas: 3
  template:
    metadata:
      labels:
        app: salesorderservice
    spec:
      containers:
      - name: salesorder-process
        image: salesorderregistry.azurecr.io/salesorderprocess:v2
        ports:
        - containerPort: 80

如你所见,除了那一个领域,我们什么也没改变。现在,让我们重新应用部署:

kubectl apply -f "C:\Dev\Packt\C-8-and-.NET-Core-3.0-Projects-Second-Edition\Chapter 8 - Sales Order
Processor\deployment.yaml"

Kubernetes 开始行动,并启动一个新节点:

现在,我们已经成功创建了我们的 AKS 集群,并看到它处理我们的订单。AKS 在不必要的情况下运行会很昂贵,所以我们将在下一节中拆除我们的资源。

清除

Kubernetes 集群可能是一项昂贵的业务。要明确的是,这通常比自己购买和运行服务要便宜得多;然而,如果你在玩这些东西,你很快就会欠下一大笔钱。要删除我们在本章中创建的资源,让我们从集群开始:

  1. 首先,打开 Kubernetes 服务刀片并选择您的集群:

这肯定不是一个快速的过程,但是一旦完成,它也应该删除创建的虚拟机。

  1. 接下来,打开存储帐户刀片,找到您为存储队列创建的帐户:

  1. 我们的下一站是容器注册刀片。我们需要找到我们的注册表并删除它:

  1. 最后但同样重要的是,我们的数据库。在 SQL 数据库刀片中,找到并删除我们创建的数据库:

现在,我们已经清理了我们创建的资源,让我们来谈谈本章中介绍的内容。

摘要

在本章中,我们已经了解了什么是微服务,以及如何创建微服务。我们已经讨论了如何使用队列等技术来分离服务,这意味着一个微服务不依赖于另一个微服务。通过使用 Kubernetes 来编排我们的服务,我们已经看到了这是一个自我修复的系统。

一路走来,我们看到了 Docker,并建立了 Docker 的形象;我们已经探索了容器注册中心,以及如何在其中存储 Docker 映像;我们还创建并配置了一个 AKS 集群。我们已经探索了服务总线和存储队列在 Azure 中的使用,以及它们如何在分布式系统中使用。

正如我在介绍中提到的,这是一个非常诱人的建筑模式;但是,我们也讨论了它如何不是没有成本的,而这个成本就是复杂性。在用几个软件替换一个软件的过程中,它们之间的交互、日志记录机制以及轻松维护整个系统的能力突然变得更加困难。

在下一章中,我们将朝着一个非常不同的方向前进,从设计一个大规模的云托管系统到运行在手机上的应用。然而,我们不会完全离开云世界,因为我们将利用一些预先构建的机器学习模型。*

九、情感检测器移动应用——使用 Xamarin.Forms 和 Azure 认知服务

在这一章中,我们将结合过去几年中取得巨大进步的两项技术:移动开发和机器学习。

仅在几年前,机器学习还是极少数人的专属领域。它被用来预测天气和设计象棋计算机。对于那些特定专业领域之外的程序员来说,这是他们力所不及的。然而,最近,随着云的出现,以及前所未闻的大量数据,云提供商正在向普通程序员提供这种能力作为服务。我们将看到上传某人的面部照片,并让微软 Azure 发回对该面部的分析,以预测该人是否快乐、悲伤、愤怒等是多么容易。

我们将讨论的第二个主题是移动开发。Xamarin 使编写 C#应用、交叉编译它以及在安卓、iOS 和其他平台上运行它成为可能。微软收购 Xamarin,让这种跨平台的移动开发方式成为了大多数桌面开发者的默认选择。

本章将涵盖以下主题:

  • 机器学习和 Azure 认知服务
  • 跨平台开发:
    • 创建一个新的运行在安卓设备上的 Xamarin 应用
    • 设置 Azure 认知服务
    • 使用我们新应用的认知服务

技术要求

为了完成本章,您需要为 Visual Studio 安装 Xamarin 工作负载。您应该能够在 Visual Studio 安装程序中找到这一点:

与前几章一样,您还需要一个 Azure 帐户。由于设置这个的过程已经在第 2 章任务 Bug 日志 ASP.NET Core MVC 应用使用 Cosmos DB 中介绍过了,这里就不再赘述了。

在测试阶段,我们将在物理设备上运行我们的应用;这意味着你将需要访问一个带有摄像头的安卓设备。如果没有这个,虽然您仍然可以继续,但是您将无法看到应用工作。

概念概述

在我们开始构建任何东西之前,让我们在高层次上简要讨论一下本章中的两个主要概念。

机器学习

这是一个巨大的话题,而且太大了,无法在一个章节中涵盖任何合理的深度。但是,简单来说,机器学习是什么,它不是什么,这是值得探讨的。

在本章中,我们将拍摄一张人脸图像,并将其上传到机器学习服务中。这项服务将提供关于它认为那张脸是快乐的、悲伤的、愤怒的还是其他什么的细节。重要的是尽量不要将这个过程拟人化。该算法将分析确定性的面部和回报百分比;然而,这是基于它看到很多很多类似的图像,并被告知它所识别的图像是否是,比如说,快乐的。这意味着如果我开始把悲伤的人的图像喂进去,但告诉机器学习算法这些是快乐的人,它会逐渐开始改变它的估计。这是现代版的古训垃圾进,垃圾出 ( GIGO )。

可以创建自己的机器学习模型;事实上,所有大型云提供商都有围绕这一点的工具套件。不过,这里有一个权衡:创建自己的模型需要时间和数据科学方面的专业知识,但可以让你完全控制模型和你想要答案的问题。另一方面,使用预先构建的服务意味着您依赖于服务提供商来正确地训练模型,因此它不太可能适合特定的需求。

跨平台和移动开发

什么时候?NET 是 20 多年前首次发布的,它是一个 Windows 开发工具。事实上,在此之前,微软的开发工具一直是特定于 Windows 的,并以此为荣。微软过去的商业模式依赖于操作系统的销售;而且,很明显,如果你让程序员开发只能在你的操作系统上运行的软件,你就比竞争对手更有优势。

在苹果发布第一部 iPhone 之前,这种模式似乎运行良好。虽然 Windows 仍主导着商业市场,但人们逐渐开始转向手机,然后转向平板电脑休闲。微软在每个家庭和企业中拥有一台计算机的梦想来了又去,突然,程序员们看到了放弃的理由.NET 开发,转向 iOS 和安卓开发。

玩追赶,微软尝试发布自己的手机(也就是他们买了诺基亚);他们还开始密切支持 Xamarin,以努力编写代码.NET 在安卓和 iOS 上运行。在撰写本文时,微软已经放弃了对命运多舛的 Windows 手机的支持,转而购买了 Xamarin。还有其他方法可以编写运行在 iOS 和安卓系统上的软件,但是据我所知,Xamarin 是唯一一个允许你用 C#编写并编译到原生平台的软件。

说了所有这些,就像本书展示的所有技术一样,你应该考虑是否可以使用更简单的选项;例如,一个简单的网站能满足你的需求吗?并非每个用例都需要智能手机的定制应用。

项目概述

我们在本章中的项目将是一个非常简单的应用。这将有一个按钮,可以拍照。然后,我们将该图像发送到 Azure 并显示一个结果,指示我们在该图像中检测到的情绪。

我们将使用 Xamarin Forms,这是一个简单的 UI 层,也是跨平台的:对于我们的需求,这是理想的,因为软件有一个非常小的 UI 组件。

我们也将只为安卓创建这个应用。这样做的主要原因是,为了创建一个 iOS 应用,你需要一个 Mac,我觉得这将排除很多读者(和作者)跟随!

配置 Azure 认知服务

在我们开始编写任何代码之前,我们需要配置我们的认知服务。为此,您需要一个 Azure 帐户;如果你还没有的话,请参考第二章(任务 Bug 记录 ASP.NET Core MVC 应用使用 Cosmos DB)。

让我们设置认知服务:

  1. 我们将从导航到 Azure 门户中的认知服务刀片开始;您可以搜索它或从左侧的菜单中选择它。从此刀片添加新服务。一旦您这样做了,您将看到一个可能创建的认知服务列表:

  1. 同样,您可以通过向下滚动面部应用编程接口或简单地在搜索框中键入Face来搜索该列表。这应该会打开面部应用编程接口,并允许您创建它:

  1. 选择“创建”后,您将看到一个熟悉的选项列表:

除非您打算将其用于商业目的(例如,高吞吐量),否则我建议您选择 F0 的定价计划,本质上是免费的。

  1. 创建资源后,您需要两条信息。第一个是访问密钥:

  1. 您需要的第二条信息是端点,可以在概述屏幕上找到:

就是这样!您现在已经配置了认知服务。快速入门页面有丰富的资源可供您使用;它也很可能有比你在这里找到的更多的最新信息。现在我们已经配置了 Azure 服务,我们可以创建我们的 Xamarin 应用了。

创建 Xamarin 应用

让我们创建我们的 Xamarin 应用。选择文件|新建|项目,您将看到以下屏幕:

一旦您选择了一个 Xamarin 应用,您将看到一个进一步的对话框:

  1. 选择是为 iOS、安卓还是两者创建应用。我们将只创建一个安卓应用。
  2. 选择一个空白模板,这样我们就可以确切地看到我们正在创建的内容。

现在我们已经配置了模板,我们可以创建我们的项目(通过选择创建)。

It's worth bearing in mind that there are some quite strict rules on what you can have in your path and how long that path can be. Avoid spaces, dashes, and full paths over 127 characters.

一旦创建,您的项目应该大致如下所示:

如果我们有一个 iOS 项目,就会有第三个项目,叫做EmotionDetector.iOS。如果你看一下EmotionDetector.Android中的参考文献,你会发现它指的是EmotionDetector。这里的想法是EmotionDetector代表共享的代码库;但是,每个平台都可以覆盖该功能。

在我们对项目做任何事情之前,让我们运行它,看看我们在模拟器中得到什么:

If the emulator is showing a blank screen, ensure that it's turned on. The emulator operates like a real phone, even down to being able to power up and down.

让我们添加一个按钮来拍摄我们的图片,并添加一个图像控件来显示该图片。在MainPage.Xaml中,用以下代码替换 XAML 代码:

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage 
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:local="clr-namespace:EmotionDetector"
             x:Class="EmotionDetector.MainPage">

    <AbsoluteLayout x:Name="Layout">

        <Image x:Name="DisplayedImage"
               VerticalOptions="Center"
               HorizontalOptions="Center" />

        <Button Text="Take Picture" 
           HorizontalOptions="Center"
           VerticalOptions="EndAndExpand"
           Clicked="Button_Clicked"
            AbsoluteLayout.LayoutBounds=".5, .1, .5, .1" AbsoluteLayout.LayoutFlags="All" />

    </AbsoluteLayout>
</ContentPage>

In the previous chapters, I spoke about using the MVVM pattern to abstract the UI layer from the logic of the application. This is possible in Xamarin Forms, too. However, I felt that creating an MVVM abstraction here would create an unnecessary additional distraction in the development.

If you do decide to extend this project, then creating (or using a third party) an MVVM infrastructure might be a good place to start.

前面的代码在Layout组件中有两个控件。AbsoluteLayout是一个容器,允许我们精确地指定控件的位置。在布局中,我们有一个单一的按钮,集中在屏幕顶部和一个图像附近。该代码要求在后面的代码中有一个事件处理程序。MainPage.Xaml.cs后面代码中的Button_Clicked事件现在可以为空:

[DesignTimeVisible(true)]
public partial class MainPage : ContentPage
{
    public MainPage()
    {
        InitializeComponent();
    }

    private void Button_Clicked(object sender, EventArgs e)
    {

    }
}

虽然这个事件处理程序目前什么都不做,但我们最终需要它做三件事:

  • 照相
  • 将图片上传到 Azure
  • 显示结果

让我们在一些对存根方法的调用中概述一下。然后,我们可以依次关注每一个:

private void Button_Clicked(object sender, EventArgs e)
{
    var image = TakePicture();
    var emotionData = GetEmotionAnalysis(image);
    DisplayData(emotionData);
}

private void DisplayData(object emotionData)
{
    throw new NotImplementedException();
}

private object GetEmotionAnalysis()
{
    throw new NotImplementedException();
}

private object TakePicture()
{
    throw new NotImplementedException();
}

这些方法的签名会改变,但这描述了我们逻辑的基本流程;也就是说,在显示结果之前,我们拍摄一张照片,并将结果传递给调用 Azure 认知服务的方法。让我们逐一完成这些步骤,并一次填写一个。

拍照

在 Xamarin 中使用任何(或大多数)特定于平台的代码时,您往往会发现自己在使用插件。在我们继续之前,我们需要讨论一下 Xamarin p lugins。

洗发精外挂程式

Xamarin 背后的原理是你写的代码是交叉编译的。也就是说,当你为安卓编译时,你最终得到的是一个针对安卓平台的原生应用,当你为 iOS 编译时,你最终得到的是一个针对 iOS 的原生应用。然而,当一段代码是特定于平台的时,Xamarin 平台无法做到这一点。比方说,我想使用摄像头——这因平台而异。你不能简单地把为 iPhone 做这些的代码编译成安卓系统。那么,我们如何创建平台特定的代码呢?

答案是,每一段特定于平台的代码都需要为每个平台单独创建;Xamarin Forms 背后的原理是,您以多态的方式调用方法。让我们以相机为例(因为这是我们将要使用的);在安卓系统上拍照的代码显然不同于 iOS 系统。当我们安装插件时,每个环境以特定于平台的方式实现该插件的代码。然而,从我们的角度来看,我们只需安装插件并调用几行代码,它就可以在任何地方工作。

媒体插件

安装插件本身实际上就像添加一个 NuGet 包一样简单:

Install-Package Xam.Plugin.Media -ProjectName EmotionDetector

It's worth bearing in mind that these plugins are constantly changing; while they are correct at the time of writing, you should follow the readme.txt file as a primary source.

完成后,插件会显示一个readme.txt文件,其中包含如何安装它的说明。请遵循这些说明,因为它们可能会在您阅读本文时发生变化。为了完整起见,我将在这里详细介绍安卓指令,因为有些指令可能不像它们可能的那样冗长。

先从EmotionDetector.Android中的MainActivity.cs说起。OnRequestPermissionsResult的方法应该是这样的:

public override void OnRequestPermissionsResult(int requestCode, string[] permissions, Android.Content.PM.Permission[] grantResults)
{
    Plugin.Permissions.PermissionsImplementation.Current.OnRequestPermissionsResult(
        requestCode, permissions, grantResults);
}

如今,你想在手机应用中做的任何事情都必须得到用户的批准;如果您需要访问用户的联系人、电话或相机,那么用户将看到一个对话框,指示您的应用需要这些权限。此方法中的代码在用户对此做出响应后被调用。

你必须声明你的应用需要哪些权限,这应该由插件自动添加。如果你看一下EmotionDetector.Android.Properties.AssemblyInfo.cs内部,你应该会看到以下内容:

// Add some common permissions, these can be removed if not needed
[assembly: UsesPermission(Android.Manifest.Permission.Internet)]
[assembly: UsesPermission(Android.Manifest.Permission.WriteExternalStorage)]

[assembly: UsesFeature("android.hardware.camera", Required = true)]
[assembly: UsesFeature("android.hardware.camera.autofocus", Required = false)]

如果还没有自动添加,现在就添加。

Note that we have slightly diverted from the instructions here, in that we have declared that the camera is required. In most applications, the functionality would still work fine without the camera, but in this case, we really do need to use it.

You should also consider whether your app can offer some degraded functionality, even if the user is unwilling to give access to requested resources. As we've mentioned previously, in this particular case, there's not too much we can do without the camera, but if you extended this application, maybe you would add the ability to upload existing pictures to the application – in which case, the app would still be usable without the camera.

文件应该是这样的:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" android:versionCode="1" android:versionName="1.0" package="com.companyname" android:installLocation="auto">
    <uses-sdk android:minSdkVersion="21" android:targetSdkVersion="27" />
    <application android:label="EmotionDetector.Android">
        <provider android:name="android.support.v4.content.FileProvider"
                          android:authorities="${applicationId}.fileprovider"
                          android:exported="false"
                          android:grantUriPermissions="true">
          <meta-data android:name="android.support.FILE_PROVIDER_PATHS"
                          android:resource="@xml/file_paths"></meta-data>
        </provider>
  </application>
</manifest>

这用于支持媒体插件的子依赖性。如您所见,它引用了一个特定的资源目录,因此下一步是创建该目录。在安卓Resources文件夹中,新建一个xml文件夹:

在这个标有file_paths.xml的文件夹中创建一个文件,并添加以下代码:

<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
    <external-files-path name="my_images" path="Pictures" />
    <external-files-path name="my_movies" path="Movies" />
</paths>

最后,MainActivity.cs模块需要在其OnCreate方法中增加一行:

protected override void OnCreate(Bundle savedInstanceState)
{
    TabLayoutResource = Resource.Layout.Tabbar;
    ToolbarResource = Resource.Layout.Toolbar;

    base.OnCreate(savedInstanceState);
    CrossCurrentActivity.Current.Init(this, savedInstanceState);

    Xamarin.Essentials.Platform.Init(this, savedInstanceState);
    global::Xamarin.Forms.Forms.Init(this, savedInstanceState);
    LoadApplication(new App());
}

CrossCurrentActivity插件是同一个生态系统中的另一个插件。它允许媒体插件访问MainActivity

TakePicture()

现在我们已经添加了插件,我们可以创建代码,这样我们就可以使用相机了。让我们快速了解一下这个方法:

private async Task<MediaFile> TakePicture()
{
    string fileName = $"FaceImg_{DateTime.Now.Ticks}";

    MediaFile photo = await Plugin.Media.CrossMedia.Current.TakePhotoAsync(new Plugin.Media.Abstractions.StoreCameraMediaOptions()
    {
        SaveToAlbum = true,
        Name = fileName
    });

    if (photo != null)
    {
        return photo;
    }

    return null;
}

这个方法没有什么特别之处,因为插件为我们做了大部分工作。我们传递的参数允许保存图像,并且我们为它声明了一个唯一的名称。

During testing, you may decide to switch off the save feature; otherwise, your phone will fill up with pictures of you pulling strange faces to try to look angry, sad, or happy!

如果拍摄成功,我们返回一张MediaFile;否则,我们退回null

GetEmotionAnalysis()

我们的下一个方法负责调用 Azure API 并获得响应。但是,这有一个客户端应用编程接口。写的时候还在预习,好像有各种问题。如果您希望使用这个来进行研究,那么一个好的起点是 NuGet 存储库:https://www.nuget.org/packages/Microsoft.Azure.CognitiveServices.Vision.Face/

但是,我们将只使用该软件包的一部分,所以现在让我们安装它:

Install-Package Microsoft.Azure.CognitiveServices.Vision.Face -Version 2.4.0-preview

在本节中,我们将手动建立对 API 的调用;下面是我们方法的代码:

private async Task<IList<DetectedFace>> GetEmotionAnalysis(Stream imageStream)
{
    var byteData = GetImageAsByteArray(imageStream);

    return await MakeAnalysisRequest(byteData,
        "https://uksouth.api.cognitive.microsoft.com/face/v1.0/detect",
        "4a9c2b7404fd45ed9aff787f158e24c7");
}

MakeAnalysisRequest接受三条信息(你马上就会看到)。第一个,byteData,是图像流的二进制表示(我们将很快回到GetImageAsByteArray方法)。第二个参数是您之前记录的端点;然后,我们将detect附加到网址上,表明我们希望采取的行动。结果如下:

$"{endpoint}/detect"

第三个参数是你之前记下的关键。

The preceding values were my values at the time of writing; by the time you read this, the resource they were generated for will have been removed. You will need to change these to match your own values.

在我们继续之前,让我们快速回顾一下GetImageAsByteArray方法:

private byte[] GetImageAsByteArray(Stream stream)
{
    using BinaryReader binaryReader = new BinaryReader(stream);
    return binaryReader.ReadBytes((int)stream.Length);

}

然而,大部分工作是通过MakeAnalysisRequest方法完成的。让我们看看那是什么样子:

public async Task<List<DetectedFace>> MakeAnalysisRequest(Byte[] byteData, string uriBase, string subscriptionKey)
{
    using HttpClient client = new HttpClient();
    client.DefaultRequestHeaders.Add("Ocp-Apim-Subscription-Key", subscriptionKey);

    string requestParameters = "returnFaceId=true&returnFaceLandmarks=false" +
"&returnFaceAttributes=emotion&recognitionModel=recognition_01&returnRecognitionModel=false";

    string uri = $"{uriBase}?{requestParameters}";
    HttpResponseMessage response;

    using (ByteArrayContent content = new ByteArrayContent(byteData))
    {
        content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");

        response = await client.PostAsync(uri, content);
        string contentString = await response.Content.ReadAsStringAsync();

        List<DetectedFace> faceDetails = JsonConvert.DeserializeObject<List<DetectedFace>>(contentString);
        if (faceDetails.Count != 0)
        {
            return faceDetails;
        }
    }
    return null;
}

这里发生了很多事情;然而,基础是我们正在进行一个 HTTP 调用,并将返回的结果序列化为一个DetectedFace列表。DetectedFace是一个可以在我们之前安装的 NuGet 包中找到的类。我们在请求的头部传递访问键,并在查询字符串中建立参数。

A link to the Face API documentation can be found in the Quick Start guide in the Azure portal. This will give you a comprehensive (and up to date) list of methods and parameters that are available to you.

下一步是打包并将图像发送到 Azure 进行分析。返回的是分析的 JSON 表示。

显示数据()

这是最后一种方法:我们现在已经使用相机拍摄了照片,并将该图像发送到 Azure 进行分析。我们的最终任务是向用户显示结果。在我们看代码之前,我们应该考虑我们在这里试图实现什么。

我们的特定应用旨在指示拍摄照片时某人的情绪状态。当我们把图像传递给 Azure 时,它不会返回一个声明说,这个人是快乐的;相反,它将返回一个可能检测到的面部列表和每个面部的可能情绪列表,以及一个值来指示它认为那个人表现出了多少情绪。比如,快速照照镜子:你看起来怎么样?你是高兴、生气、惊讶还是厌恶?也许你是其中两三个中的一个。因此,返回的 JSON 是每张脸和每种情绪的值列表。

显示数据时,我们需要做两件事:

  1. 如果多于或少于一个面,则显示一个错误以表明这一点。
  2. 如果只有一张脸,只显示最高级别的情感;例如,如果你感到愤怒和惊讶,我们需要采取分析认为更大的,并显示出来。

考虑到这一点,让我们来看看代码:

private void DisplayData(IList<DetectedFace> faces)
{
    // Remove and existing labels
    var labels = Layout.Children
        .Where(a => (a.AutomationId?.Contains("emotion-label") ?? false)
        || (a.AutomationId?.Contains("error") ?? false))
        .ToList();
    foreach (var label in labels)
    {
        Layout.Children.Remove(label);
    }

    if (faces == null)
    {
        CreateLabel("Unable to get data", "error");
        return;
    }

    if (faces.Count() > 1)
    {
        CreateLabel("Multiple faces not supported in this version", "error");
        return;
    }

    var face = faces.SingleOrDefault();
    if (face == null)
    {
        CreateLabel("No face found", "error");
        return;
    }

    CreateLabel(face.GetStrongestEmotion(), face.FaceId.ToString());
}

要开始分析这个,我们将从CreateLabel开始。

CreateLabel()

我们将很快看到这个的代码,但是,现在,让我们接受它动态地创建一个标签,使用AutomationId作为唯一的引用。

AutomationId is really intended to provide an anchored reference to automated UI test frameworks. What we are doing here is potentially not in-keeping with that intent; in fact, there is no real reason that AutomationId couldn't be fixed at simply: "emotion-label", which would allow automated UI testing to work more easily with this application. The preceding code does provide slightly more information when debugging, so I would suggest this as a future enhancement, once the software is working.

如你所见,我们的第一个任务是寻找任何带有相关AutomationId的标签并移除它们。接下来是一系列门控检查,以确定是否返回了一张面孔;如果没有,我们将创建一个错误标签。

假设没有错误,将基于我们尚未看到的名为face.GetStrongestEmotion()的方法创建一个标签。返回一个字符串,表示找到的最强烈的情感(我们稍后会讲到)。让我们回到CreateLabel()方法:

private void CreateLabel(string displayText, string id)
{
    var newLabel = new Label()
    {
        Text = displayText,
        AutomationId = $"emotion-label-{id}",
        FontAttributes = FontAttributes.Bold,
        FontSize = 20,
        HorizontalTextAlignment = TextAlignment.Center
    };

    AbsoluteLayout.SetLayoutBounds(newLabel, new Rectangle(.5, 1, .5, .1));
    AbsoluteLayout.SetLayoutFlags(newLabel, AbsoluteLayoutFlags.All);

    Layout.Children.Add(newLabel);
}

我们在这里做的是用代码构建一个 UI 控件。前面的大部分代码都相对简单:我们正在设置字体和文本(我们已经讨论过AutomationId,然后我们在AbsoluteLayout父控件上调用一个方法,以便将控件定向到特定的坐标:

AbsoluteLayout.SetLayoutBounds(newLabel, new Rectangle(.5, 1, .5, .1));

该方法在AbsoluteLayout控件内为有问题的控件设置边界矩形。我们传递给它的值在意义上有所不同,这取决于我们如何调用方法:

AbsoluteLayout.SetLayoutFlags(newLabel, AbsoluteLayoutFlags.All);

在我们的例子中,我们将其设置为All,这表明所有的值都是成比例的;例如,X 坐标(即Rectangle结构中的第一个值)指定控件应该正好位于AbsoluteLayout控件的中间。

最后,我们将新创建的控件添加到Layout控件中。接下来我们来分析一下GetStrongestEmotion()法。

GetStrongestEmotion()

这种方法只是一种扩展方法,使用反射来计算得分最高的情感:

public static class FaceExtensions
{
    public static string GetStrongestEmotion(this DetectedFace face)
    {
        var emotions = face.FaceAttributes.Emotion;

        var strongest = emotions.GetType()
            .GetProperties()
            .Select(a => new { name = a.Name, value = (double)a.GetValue(emotions) })
            .OrderByDescending(a => a.value)
            .First();

        return strongest.name;
    }
}

If you are not familiar with extension methods, an extension method class must be static, and the item that you're extending is indicated by the this keyword, before the parameter.

按钮 _ 点击

最后,让我们更改事件处理程序,以正确处理更新的方法:

private async void Button_Clicked(object sender, EventArgs e)
{
    var image = await TakePicture();
    DisplayedImage.Source = ImageSource.FromStream(() => image.GetStream());
    var emotionData = await GetEmotionAnalysis(image.GetStream());
    DisplayData(emotionData);
}

如您所见,我们需要使我们的处理程序异步,因为我们正在调用异步方法。我们通过从TakePicture的结果中提取流来设置图像源。最后,我们将把从GetEmotionAnalysis返回的面孔列表传递给DisplayData方法。

我们现在有了一个功能正常的应用:

如果你按下按钮,你会看到模拟器让你知道你在使用相机,但实际上并没有提供对我们的应用有意义的图像。我们现在需要讨论如何在物理设备上进行测试。

在物理设备上测试和运行

因为这个程序的基础是使用相机,所以在物理设备上测试应用是必不可少的。

此外,非常奇怪的是,在物理设备上测试实际上比在仿真器上测试要快。

在这一节中,我们将介绍在我们的安卓设备上设置它所需的基础知识;不过,我强烈建议大家在这方面参考 Xamarin 自己的指南:https://docs . Microsoft . com/en-GB/Xamarin/Android/入门/安装/设置-设备换开发

本指南更加全面,并且,考虑到它经常更新(而本书没有),是一个最新的指南。

这里的说明与 Android 8.x (Oreo)有关,但也应该适用于更高的版本(在撰写本文时,最新版本是 9.x,即 Pie)。

The following screenshots have been taken from the emulator; however, they have been checked on a physical device to ensure they are accurate for this version of Android.

要配置您的手机:

  1. 第一步是配置你的手机;从加载设置屏幕开始:

  1. 在系统内部,您应该会看到一个标签为“关于电话”或类似的菜单选项:

  1. 找到内部版本号:

  1. 你需要敲这个七次。你会收到以下信息(不要眨眼,否则你会错过的!):

  1. 系统菜单上将出现一个新菜单,即开发人员选项:

  1. 这个新菜单允许您确定测试软件时有用的各种设置;但是,有一个设置特别需要更改,那就是 USB 调试:

其他设置是可选的。你可能会发现保持清醒可以让你保持清醒,但是如果你在使用电话,不要忘记在之后关掉它。

下一步是检查 Android SDK 管理器。您应该(至少)为奥利奥(8.1)安装以下组件:

如果和我一样,你有几个更新,你一定要更新。

这是基本的内容。根据您的手机,您可能会发现您需要安装一些 USB 驱动程序。如果你是这种情况,请参阅前面的链接。

当您插入手机时,选择传输文件。

Visual Studio 应该会自动识别您的手机已连接,并且运行图标应该会从模拟器变为实际的手机:

现在,当您运行项目时,它应该在您的物理设备上启动,而不是在模拟器上。

摘要

在这一章中,我们已经看到了如何解决两个相对复杂的问题——跨平台开发和机器学习——并利用一些易于访问的服务来创建一个应用,直到几年前,该应用还需要专家团队花费数年时间来编写。

当然,应用已经花费了专家团队数年的时间来编写——只是你现在可以访问他们的劳动成果。

跨平台开发是很多公司都试图做好的事情:微软首先在公司内部采用 Xamarin(通过购买),现在又出现在代码库中(2019 年的构建宣布,2020 年.NET 将结合 Mono,.NET Core,以及.NET 框架整合成一个单一的.NET–在撰写本文时,这被简单地称为.NET 5)。

机器学习正在吸引数十亿美元的研究:想象一下,在不久的将来,你的手机将能够监控你的情绪。想象一下,如果商店里的摄像头可以检测到顾客什么时候因为不开心而离开;想象一下,在街上有一台摄像机,它可以通过人们的面部表情来检测路过的人是否正准备犯罪。

在下一章中,我们将更详细地探索机器学习,并创建一个使用微软语言理解智能服务的聊天机器人,以模拟与人类的对话。

十、21 世纪的伊莱扎——UWP 和微软机器人框架

图灵测试是由艾伦·图灵开发的,目的是回答机器是否可以被认为具有思考能力的问题。20 世纪 60 年代,一个名为伊莱扎的项目诞生了,这是第一批试图通过这项测试的项目之一。

图灵测试是在机器学习的概念还处于萌芽阶段时发展起来的(事实上,图灵本人在这个领域做出了贡献)。现在,我们有了使用机器学习来检测情绪的系统,我们有了能够理解语言的复杂系统,我们接到了机器人打来的电话,我们与之进行了完整的口头对话,却从未意识到他们不是人类。

伊莱扎是为了模仿一个心理治疗师而建立的,这个想法是,它挑选出关键词和短语,并把它们转换成问题,以便愚弄人类,让他们相信自己在和另一个人说话。

在本章中,我们将创建一个版本的伊莱扎。我们将学习如何使用在 UWP 创建客户端应用.NET Core 3。然后,我们将学习如何设置和配置 LUIS,最后,我们将学习如何将其集成到 MS Bot 框架中。

本章将涵盖以下主题:

  • 查特斯
  • 微软聊天机器人框架
  • 微软语言理解服务
  • 在 UWP 创建应用

聊天机器人可以用于一切,从信息技术支持到预订航班(下图显示了一个例子):

技术要求

在本章中,我们将使用默认模板来使用机器人框架创建微软机器人。在撰写本文时,尽管发布了.NET Core 3,Bot 框架项目仍在创建中.NET 2.2。在后面的部分中,我们将看到如何将其升级到.NET Core 3;然而,当这本书出版时,这种情况可能已经改变了。

在本章中,您将需要一个 Azure 订阅(我们在第 2 章使用 Cosmos DB 任务 Bug 日志记录 ASP.NET Core MVC 应用中简要介绍了如何创建一个)。

本章的代码可以在 https://github.com/PacktPublishing/C-8-and-.的 GitHub 上找到 NET-Core-3-项目-使用-Azure-第二版。

创建聊天机器人

让我们从聊天机器人开始。在写这篇文章的时候,聊天机器人无处不在,从你在网站上寻求帮助到你给呼叫中心打电话。本质上,聊天机器人只是一个复杂的状态机,框架,如微软的 Azure 机器人框架,只是向你隐藏了这种复杂性。想象一下人类和机器之间的对话( HM ):

M: Hello, how can I help?
H: Hello, I'd like to check my balance, please.
M: I can help you with that. Which account is this for?
H: Current.
M: I'm sorry, I didn't get that. Which account is this for?
H: Current account.
M: What is the number of your current account?

好的,如果我们看一下这个交换,我们可以看到它是由机器人发起的。客户回答说,他们想检查他们的余额——所以在这里,机器人正在监听关键词,很明显balance就是一个。现在机器需要知道客户想知道哪个账户的余额。在幕后,它可能已经检查了向客户注册的账户是否不止一个。此时,客户说current,机器人需要澄清,所以它循环回来,再次询问。当它最终明白了答案,它就继续前进。

我们将使用一个模板创建我们的机器人,你需要从 https://marketplace.visualstudio.com/items?安装这个模板 item name = BotBuilder . BotBuilder 4 .

当你选择创建一个新的机器人时,你有三个选项:空机器人、回声机器人和核心机器人,如下图所示。对于这个项目,我们将选择回声机器人:

These are prebuilt templates with varying degrees of existing functionality. Obviously, the empty template has no functionality and only boilerplate code. The Core Bot gives you a fully functioning bot that is linked to LUIS.

我们现在将选择项目的文件位置:

By default, the bot name will be set to the project name.

一旦你创建了你的机器人,运行它,你会看到下面的屏幕:

这可能不完全是你所期望的,因为我们现在还有几个步骤要走,才能看到我们的机器人在行动(剧透:在这个阶段,实际上没有太多可看的!).记下截图中的网址,因为我们很快就会需要它。

Bot 仿真器

下一步是运行模拟器。事实上,我们刚刚创建的机器人只是一项服务。模拟器为我们提供了一个与之交互的用户界面。

在撰写本文时,模拟器的最新版本位于https://github . com/Microsoft/botformal-Emulator/releases/tag/v 4 . 2 . 1

要安装它,有一个自解压可执行文件:

下载并安装模拟器,然后运行它。

You'll need to run the emulator as an administrator.

当你运行模拟器时,你需要给它一个名字和一个网址(你刚才记下了网址,名字可以是任何东西):

一旦你连接了,你就能和你的机器人交谈了。让我们快速看一下模拟器屏幕:

让我们看看它到底在告诉我们什么:

  1. 这个部分列出了你(人类)或机器人写的所有消息。

  2. 您可以选择聊天记录中的任何消息(见 4)。

  3. 这是你给机器人输入信息的地方。

  4. 这显示了原始 JSON 形式的消息。请记住,您的机器人只是一个应用编程接口,因此进出通信是 JSON 格式的。

  5. 这给出了对话的网络历史。如果您遇到身份验证或网络问题,这尤其有用。

  6. 整个聊天记录可以保存到.transcript文件中。本质上,这是会话历史的 JSON 表示。左侧的资源图标允许您打开这些文件并检查旧的成绩单。这在支持场景中非常有用,在这种场景中,出现了一些问题,您希望看到聊天过程中发生了什么。

  7. 任何时候,你都可以简单地从头开始聊天。

  8. 这将显示或隐藏资源部分。这允许您查看成绩单或聊天文件。

  9. 这显示了您当前正在与之通信的端点。

  10. 存储连接端点的历史;您可以通过简单地点击历史记录在这些之间切换。

你可能会注意到这个机器人的对话框很大程度上是受你自己的启发。让我们看看我们是否能确定为什么会这样,并稍微纠正一下。

回声机器人——除了你好

目前,回声机器人名副其实——它只是简单地回应你所说的一切。让我们开始把它做得更鲍里斯一点,在这个基础上增加一点变化。我们先把EchoBot改名为BorisBot:

我们将通过以下步骤稍微修改机器人的代码:

  1. 首先,让我们重命名该类:
public class BorisBot : ActivityHandler
{
   . . .
}
  1. 接下来,我们将对OnMessageActivityAsync进行一个小的更改,以捕获用户键入Hello,并以一个更合理的消息进行响应:
protected override async Task OnMessageActivityAsync(ITurnContext<IMessageActivity> turnContext, CancellationToken cancellationToken)
{
    if (turnContext.Activity.Text == "Hello")
    {
        await turnContext.SendActivityAsync(MessageFactory.Text($"Hello, my name is Boris"), cancellationToken);
    }
    else
    {
        await turnContext.SendActivityAsync(MessageFactory.Text($"Echo: {turnContext.Activity.Text}"), cancellationToken);
    }
}
  1. Startup.cs中,您还需要更改依赖注入来注册新的机器人:
services.AddTransient<IBot, BorisBot>();

现在,如果我们运行机器人,我们会得到不同的响应:

好了,现在当我们输入Hello时,我们得到了一个合理的响应,但是任何其他的,我们回到了回声。为了通过图灵测试,我们需要在这方面进行改进。请记住,这都是关于感知的,所以我们首先需要涵盖说类似事情的各种方式,其次,我们需要改变我们的反应。我们可以在OnMessageActivityAsync里面写一个大量的 switch 语句,并尝试尽可能多地想出表达Hello的方式。然而,我们也可以使用微软为我们创建的一个名为 LUIS 的工具,它为我们做了一些事情。

介绍 LUIS

LUIS 代表语言理解智能服务。它允许你利用机器学习算法来训练预先构建的语言模型。事实上,这个服务(和 Bot 框架一样)比我们在这里看到的要强大得多。出于我们的目的,我们只需要训练一个简单的模型,它只有很少的关键词和短语。如果您还没有这样做,您需要首先在 https://www.luis.ai 注册。

注册后,您可以开始构建新模型:

在这里,如果我们选择意图,我们可以确定对话的关键领域;如您所见,我在这里确定了一些领域。您选择“创建新意图”选项,然后给该意图命名(事实上,给该意图命名并不重要)。一旦你这样做了,你将被要求给出一些可能被输入的例子——例如,对于感觉,你可能有这样的东西:

You should ensure that you add at least five phrases for each intent, but the more phrases you have, the better the model.

一旦你做到了这一点,你就可以训练和测试你的模型。

You can add these examples all day, but unless you train the model, it will not change.

让我们试着训练和测试我们的模型。一旦您添加了多个意图(不一定是全部,也不一定与这里列出的相同),请选择 Train。正如您在前面的截图中看到的,您将知道您的模型何时需要训练,因为 Train 按钮旁边会出现一个红色的小红绿灯。训练好模型后,选择测试进行测试:

在这里,我已经尝试了三个短语。你可以看到,在每个短语下面,它返回一个意图。好像输入其中一个没错,但是另外两个有点偏。这里最棒的是,在我测试的时候,如果它出错了,我可以简单地告诉 LUIS 它是错的,方法是点击 Edit,然后告诉它正确的意图:

显然,你的意图越细,你的机器人听起来就越真实。一旦你对你训练的机器人感到满意,我们就可以进入下一部分,我们将把 LUIS 集成到我们的机器人中。

We can always come back and retrain the model, but make sure that you're happy with the intents, as we'll be coding against them.

将 LUIS 集成到 Bot 框架中

值得注意的是,这个过程被微软很好地记录了下来;请随时关注本部分或查看在https://docs . Microsoft . com/en-us/azure/bot-service/bot-builder-how to-v4-Luis 找到的文档。视图=azure-bot-service-4.0 &选项卡=csharp

让我们完成以下步骤:

  1. 为了集成 LUIS,我们首先需要添加连接细节。在 LUIS 门户中,选择管理,并从应用信息中复制应用标识:

  1. 将此添加到您的appsettings.json文件中:
{
  "MicrosoftAppId": "",
  "MicrosoftAppPassword": "",
  "LuisAppId": "ed7252fa-43c1-4d4a-a084-2b8a58c69028",
  "LuisAPIKey": "",
  "LuisAPIHostName": ""
}
  1. 现在,选择密钥和端点并复制创作密钥:

  1. 在同一页面的底部,如果向下滚动,您将找到端点:

这些值也需要复制到appsettings.json中:

{
  "MicrosoftAppId": "",
  "MicrosoftAppPassword": "",
  "LuisAppId": "ed7252fa-43c1-4d4a-a084-2b8a58c69028",
  "LuisAPIKey": "51d06b40b7df4f41be26b7d80cd80e73",
  "LuisAPIHostName": "https://westus.api.cognitive.microsoft.com"
}

Note that the hostname is trimmed after .microsoft.com.

  1. 完成后,您需要发布您的模型:

  1. 下一步是安装 LUIS NuGet 包:
Install-Package Microsoft.Bot.Builder.AI.Luis
  1. 最后,我们需要探究代码;让我们打电话给 LUIS 来代替我们的支票Hello:
protected override async Task OnMessageActivityAsync(ITurnContext<IMessageActivity> turnContext, CancellationToken cancellationToken)
{
    var luisApplication = new LuisApplication(
                    _configuration["LuisAppId"],
                    _configuration["LuisAPIKey"],
                    _configuration["LuisAPIHostName"]
                );

    var recognizer = new LuisRecognizer(luisApplication);

    var recognizerResult = await recognizer.RecognizeAsync(turnContext, cancellationToken);

    var (intent, score) = recognizerResult.GetTopScoringIntent();
    if (intent == "Hello")
    {
        await turnContext.SendActivityAsync(MessageFactory.Text($"Hello, my name is Boris"), cancellationToken);
    }
    else
    {
        await turnContext.SendActivityAsync(MessageFactory.Text($"Echo: {turnContext.Activity.Text}"), cancellationToken);
    }
}

让我们快速回顾一下我们在这里改变了什么。首先,我们实例化一个新的LuisApplication,并提供我们在appsettings.json中收集的凭证。为了做到这一点,我们还将IConfiguration注入到类中,稍后我们将回到这个类。

然后我们调用RecognizeAsync,它执行短语的实际分析,最后,我们要求它告诉我们它认为意图是什么。让我们看看这对机器人意味着什么:

如你所见,鲍里斯现在明白了一点;然而,我们的反应仍然相当木讷,它只对Hello意图起作用。接下来让我们解决其他意图,但是首先,我们将IConfiguration注入到类中。让我们来看看这个变化:

private readonly IConfiguration _configuration;

public BorisBot(IConfiguration configuration)
{
    _configuration = configuration;
}

下一阶段是升级我们的项目使用.NET Core 3。

在编写时,模板使用.NET Core 2.1;但是,如果在您阅读本文时情况并非如此,请随意跳过下一部分。

从升级模板.NET Core 2.1 到 3.0

在这个阶段,我们需要升级.NET Core 2.1 到.NET Core 3.0。这可能会导致我们需要做一些小调整。

第一个调整是将IHostingEnvironment替换为IHostEnvironment(关于这个方法的完整抄本,见下文):

public void Configure(IApplicationBuilder app, IHostEnvironment env)

这是为了避免 2.1 中引入的名称空间冲突。

下一个变化是兼容性版本:当您得知应用现在需要设置为与版本 3 兼容时,应该不会感到惊讶;

services.AddMvc()
    .SetCompatibilityVersion(CompatibilityVersion.Version_3_0);                

现在我们已经向依赖注入系统添加了 MVC,我们需要在应用中将它注册为中间件。

UseMvc

最后,端点路由发生了一些变化。这样做的净效果是UseMvc现在似乎一次性配置了太多设置;因此,Configure方法应该更像这样:

public void Configure(IApplicationBuilder app, IHostEnvironment env)
{
    if (env.IsDevelopment())
    {   
        app.UseDeveloperExceptionPage();
    }
    else
    {
        app.UseHsts();
    }

    app.UseDefaultFiles();
    app.UseStaticFiles();

    //app.UseHttpsRedirection();
    //app.UseMvc();
    app.UseRouting();
    app.UseCors();
    app.UseEndpoints(e =>
        e.MapControllerRoute("default", "{controller=Home}/{action=Index}/{id?}")
    );
}

我们不再调用UseMvc,而是调用我们需要的中间件的具体方面。

allowsynchroniousio

英寸 NET Core 3.0 引入了一个突破性的变化,允许同步调用的缺省值被更改为不允许。在撰写本文时,BotFrameworkAdaptor打破了这一规则(可能是因为它正在使用 Newtonsoft JSON 序列化——在这种情况下,到本书出版时,这可能还不是问题)。运行项目时,可能会出现以下错误:

System.InvalidOperationException: Synchronous operations are disallowed. Call ReadAsync or set AllowSynchronousIO to true instead.'

只有在运行项目时出现上述错误,才应实施以下更改:

  1. BotController中,更改AllowSynchronousIO标志:
[HttpPost]
public async Task PostAsync()
{    
    var syncIOFeature = _httpContextAccessor.HttpContext.Features.Get<IHttpBodyControlFeature>();
    if (syncIOFeature != null)
    {
        syncIOFeature.AllowSynchronousIO = true;
    }

    // Delegate the processing of the HTTP POST to the adapter.
    // The adapter will invoke the bot.
    await _adapter.ProcessAsync(Request, Response, _bot);
}
  1. 我们需要将IHttpContextAccessor注入BotController;这就是构造函数现在的样子:
private readonly IHttpContextAccessor _httpContextAccessor;

public BotController(IBotFrameworkHttpAdapter adapter, 
    IBot bot, IHttpContextAccessor httpContextAccessor)
{
    _adapter = adapter;
    _bot = bot;
    _httpContextAccessor = httpContextAccessor;
}
  1. 最后,在Startup.ConfigureServices中,添加上下文访问器:
services.AddHttpContextAccessor();

您的项目现在应该使用编译.NET Core 3.0。现在让我们研究一下如何扩展我们的代码来处理其他意图。

意图/响应矩阵

本节基本上涵盖了该应用编程接口的最后两个要求。我们将创建一个 JSON 文件,将一个意图映射到一个可能的回复(或多个回复)。事实上,我们将每个意图链接到多个回复,然后随机使用一个;这样,机器人会给人一种对每条信息做出独特响应的印象。

让我们从 JSON 文档开始。可能是这样的:

{
  "Hello": [
    "Hi there!",
    "Hello, My name is Boris",
    "Nice to meet you",
    "Hello"
  ],
  "Family": [
    "Tell me more about your family",
    "Do you have any siblings?",
    "Let's talk about your family some more"
  ],
  "Feelings": [
    "It's important to talk about your feelings",
    "It's healthy to talk about how you feel",
    "Please tell me: how are you feeling"
  ],
  "Weather": [
    "How's the weather where you are",
    "In 1987, the BBC weatherman Michael Fish failed to predict a Hurricane",
    "The weather is very unpredictable",
    "What's the forecast for tomorrow?"
  ],
  "None": [
    "I'm afraid I'm not sure what you mean",
    "That sounds interesting, please tell me more",
    "Wow - really?"
  ]
}

将其保存在名为Data的文件夹中,并更改属性使其成为嵌入资源:

让我们深入了解一下。我们需要的改变在机器人文件BorisBot.cs中。我们要改变OnMessageActivityAsync方法。在此过程中,我们将在中遇到另外几个新功能.NET Core 3 和 C# 8:

  1. 让我们从这里删除不再需要的代码开始:
if (intent == "Hello")
{
    await turnContext.SendActivityAsync(MessageFactory.Text($"Hello, my name is Boris"), cancellationToken);
}
else
{
    await turnContext.SendActivityAsync(MessageFactory.Text($"Echo: {turnContext.Activity.Text}"), cancellationToken);
}

我们将用一些生成更真实响应的代码来替换这个占位符代码。

  1. 我们下一步是将 JSON 文件读入文本,得到result:
var assembly = Assembly.GetExecutingAssembly();
var resourceName = "Boris.Data.Intent-Response.json";

using Stream stream = assembly.GetManifestResourceStream(resourceName);
using StreamReader reader = new StreamReader(stream);

string result = reader.ReadToEnd();

Note that we're using the new using syntax in C# 8. This is especially nice in methods such as this because without the new syntax, the method would be longer, and in the following code the using statements would need to be nested. The new using statement has an implicit scope from the declaration point to the end of the function; in all other respects, it behaves exactly like a standard using statement.

  1. 然后,我们将 JSON 文件的文本内容和意图传递给一个新方法,然后该方法返回一个回复数组。然后,我们从该数组中选择一个随机回复,并发送:
var response = ReadResponse(result, intent);

string selectedResponse = response[_rnd.Next(response.Length)]; await turnContext.SendActivityAsync(MessageFactory.Text(selectedResponse), cancellationToken);

在我们谈论新方法ReadResponse()之前,让我们看看_rnd是如何声明的,在哪里声明的。它实际上是在类的顶部声明的:

private Random _rnd = new Random();

A discussion about how to reliably generate random numbers in C# is probably outside the scope of this chapter (and book); however, it's worth bearing in mind that the random number algorithm uses a seed based on the time of day, meaning that if you instantiate the class twice in rapid succession and call _rnd.Next(number), you will very likely get the same number back twice.

  1. 最后,让我们看看我们的新方法:
public static string[] ReadResponse(string jsonString, string key)
{
    using var document = JsonDocument.Parse(jsonString);

    var root = document.RootElement;            
    var possibleResponses = root.GetProperty(key);

    return possibleResponses.EnumerateArray().Select(a => a.GetString()).ToArray();
}

我们再次使用新的using语法。我们还利用了新的 JSON 库,它们是.NET Core 3。期间所做的一些更改.NET Core 2.1 和 2.2 为新的、更快的.NET Core JSON 解析库。

The decision to bring this in-house wasn't entirely because Microsoft thought they could make a better JSON parsing library than JSON.NET (in fact, at the time of writing, the author of that library was working at Microsoft.) The decision may have been driven more by the fact that some of the lowest-level libraries within the framework had a dependency on JSON.NET.

让我们简单谈谈这个新的 JSON 解析代码在做什么:一旦我们实例化了一个新的JsonDocument,我们就找到了root元素,然后通过在root上调用GetProperty返回我们正在寻找的特定文本。最后,我们调用EnumerateArray并将它放入Linq Select语句中以提取字符串。

这里的最后一步是发布机器人。

发布机器人

要开始发布过程,只需右键单击项目文件并选择“发布”。您将看到以下屏幕:

保持默认设置不变,并选择“发布”。现在,您将被问及一系列关于如何以及在哪里部署您的应用的问题:

同样,您可以将这些保留为默认值,然后单击创建。

At the time of writing, this prompted an error from Visual Studio, informing you that .NET Core 3.0 is not supported. While that is technically true, following the steps outlined in this chapter should result in a working bot. The team are hoping to get full .NET Core 3 support very soon.

您应该会看到一个网页,表明您的机器人已成功部署。记下这个页面的网址,因为我们很快就会需要它。就我而言,是这样的:

https://boris20190704074938.azurewebsites.net/

下一步是创建频道注册。

Note that an alternative to creating the App Service and Channel Registration separately is to create a bot in the portal. This will create both for you, and you can then publish your bot into that service.

创建频道注册

通道注册允许其他应用与我们的机器人通信。让我们创建一个。从 Azure 门户中选择创建资源:

在下一个屏幕中,选择机器人频道注册:

一旦您选择了创建机器人的选项,您将看到一个类似于下面的屏幕。屏幕截图后面的列表中描述了页面的标记部分:

  1. Bot 句柄是一个全球唯一的引用。
  2. 消息传递端点是您之前提到的网址。您应该将/api/messages附加到网址的末尾;就我而言,这就是https://boris20190704074938.azurewebsites.net/api/messages
  3. 完成设置后,选择“创建资源”选项。

我们现在有了一个与 LUIS 集成的工作的、公开的机器人。下一步是创建我们的 UWP 应用。

微软应用标识和微软应用密码

在这一章的前面,你可能还记得我们在appsettings.json内部创造了一些价值;上次我们见到它们时,它们大致如下:

{
  "MicrosoftAppId": "",
  "MicrosoftAppPassword": "",
  "LuisAppId": "ed7252fa-43c1-4d4a-a084-2b8a58c69028",
  "LuisAPIKey": "51d06b40b7df4f41be26b7d80cd80e73",
  "LuisAPIHostName": "https://westus.api.cognitive.microsoft.com"
}

现在我们已经发布了我们的机器人,我们需要填充这些值。如果您在门户中导航到您的新频道注册并选择设置屏幕,您应该会看到类似于以下内容的内容:

记下MicrosoftAppId。选择管理以显示证书&秘密刀片:

在这里,我们可以创造一个新的秘密:

只需提供描述,然后单击添加。一旦你创造了秘密,你就只有一次机会记录下来;你再也看不到它了,所以如果你丢失了它,你需要移除并重新创建它。

一旦您记下了应用标识和密码,有两个选项可以激活它们。第一种方法(也可能是最简单的调试方法)是将它们粘贴到appsettings.json中,然后再次发布应用。但是,您不需要来完成此操作—您可以简单地导航到应用服务的配置刀片,并在那里添加一个AppSetting:

This is a very useful trick: any app settings that are added here will override anything in the appsettings.json. This means that you don't need to keep sensitive data in your source code.

现在我们已经正确设置了注册,我们可以继续创建客户端应用了。

创建 UWP 应用

我们的 UWP 应用将非常简单。我们将创建一个单一的屏幕,允许我们输入一条消息,然后一个日志,用户可以看到机器人的反应,以及他们自己的聊天记录。首先,让我们创建一个新的 UWP 应用:

初始应用由两个 XAML 文件创建:App.xamlMainPage.xaml:

程序的主要结构其实是在App.xaml : App.xaml.cs的代码隐藏中。如果你看一下那个文件,你会看到当应用被启动和挂起时,以及当应用中的导航失败时处理的代码。

主页

我们的第一个任务是改变MainPage.xaml来构建我们的屏幕。让我们看看新 XAML 的样子,然后看看我们的变化:

<Page
    x:Class="Boris_Client.MainView"

    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="using:Boris_Client"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    mc:Ignorable="d"
    Background="{ThemeResource ApplicationPageBackgroundThemeBrush}"
    xmlns:models="using:Boris_Client.Models"
    Loaded="Page_Loaded">

    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="*" />
            <RowDefinition Height="Auto" />
        </Grid.RowDefinitions>

        <ListView Grid.Row="0" ItemsSource="{Binding ChatHistory}">
            <ListView.ItemTemplate>
                <DataTemplate x:DataType="models:ChatMessage">
                    <StackPanel Orientation="Horizontal">
                        <TextBlock Text="{Binding Sender}"/>
                        <TextBlock Text=":"/>
                        <TextBlock Text="{Binding Message}"/>
                    </StackPanel>
                </DataTemplate>
            </ListView.ItemTemplate>
        </ListView>

        <Grid Grid.Row="1">
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="*"/>
                <ColumnDefinition Width="Auto"/>
            </Grid.ColumnDefinitions>
            <TextBox Grid.Column="0" Grid.Row="0" x:Name="SendMsg" Text="{Binding MessageText}"/>
            <Button Grid.Row="0" Grid.Column="1" 
                    Command="{Binding SendMessageCommand}" 
                    CommandParameter="{Binding ElementName=SendMsg, Path=Text, Mode=OneWay}"
                    Content="Send" />
        </Grid>

    </Grid>
</Page>

我们将在接下来的章节中详细讨论这个模块。让我们从查看网格布局开始。

行定义

在 XAML,我们有一个网格的概念:本质上,每个 XAML 页面都被细分为一个网格,你可以告诉系统你想要在你的网格中有多少行和列。在我们的示例中,网格有两行。第二行需要多大就有多大(Auto)才能容纳其内容,第一行占用剩余空间(*)。

Should you wish, you can have grids nested inside grids. There is no limit (except the available memory) as to how far you can nest controls. Obviously, the deeper you nest your controls, the more work there is for the UI renderer, which can have a performance impact.

网格中的每个后续控件都指定它希望占据的行,甚至是子网格。指定行和列始终适用于直接父网格。

列表视图

接下来,我们来到ListView。理解 XAML 的关键是构图:你几乎可以达到任何效果,因为你可以把一个控件放在另一个控件里面。在ListView控件的情况下,控件本质上有两个元素:它显示的数据和它如何显示数据。

<ListView Grid.Row="0" ItemsSource="{Binding ChatHistory}">

我们稍后会回到ChatHistory到底是什么,但现在,就当它是一个集合。我们将ItemSource(即控件的数据源)绑定到事物的集合,称为ChatHistory。那就是什么;下一部分是怎么:

<ListView.ItemTemplate>
    <DataTemplate x:DataType="models:ChatMessage">
        <StackPanel Orientation="Horizontal">
            <TextBlock Text="{Binding Sender}"/>
            <TextBlock Text=":"/>
            <TextBlock Text="{Binding Message}"/>
        </StackPanel>
    </DataTemplate>
</ListView.ItemTemplate>

这里,我们用DataTemplate告诉ListView每个元素都是ChatMessage类型;再次,我们将回到那是什么——重要的是它至少有两个属性:SenderMessage。在DataTemplate内部,我们声明了一个StackPanel,在这个内部,我们声明了一些TextBlock元素。这就是我所说的组合——如果你想在一个列表中显示一个元素列表,那么你需要定义数据在哪里以及如何显示它。

While this process is more verbose than other technologies, it affords a much greater degree of flexibility—for example, you could have an image control, another StackPanel, or even another ListView inside the DataTemplate. Additionally, the DataTemplate can be reused across files or even applications.

消息和命令绑定

最后,我们有第二行,它只是第一行中的另一个网格。有趣的部分显示在下面的代码中:

<TextBox Grid.Column="0" Grid.Row="0" x:Name="SendMsg" Text="{Binding MessageText}"/>
<Button Grid.Row="0" Grid.Column="1"  Command="{Binding SendMessageCommand}"  CommandParameter="{Binding ElementName=SendMsg, Path=Text, Mode=OneWay}"
        Content="Send" />

TextBox中,我们将内容绑定到一个叫做MessageText 的东西上。还是那句话,不要担心MessageText是什么,尽管,考虑到它与Text属性绑定,我们可以有把握地假设它是(或者必须是)一个字符串。

最后,我们有一个按钮和一个叫做命令绑定的东西。与其他绑定不同,命令绑定本质上只是获取数据并显示它,它绑定到一个方法。还是那句话,我们不要太担心SendMessageCommand到底是什么;现在,让我们接受它做了一些事情

数据绑定和视图模型

现在我们已经创建了前端 XAML 文件,让我们来看看代码隐藏(MainPage.xaml.cs):

public sealed partial class MainPage : Page
{
    public MainPage()
    {
        this.InitializeComponent();

        DataContext = new MainViewModel();    

    } 
}

如你所见,这里没什么可讨论的。事实上,我们实际上只有一行额外的代码,它将DataContext设置为MainViewModel的一个实例。

本质上,我们在这里做的是获取一个类,并将该类分配给 XAML 代码文件的DataContext。这意味着该文件中的任何内容现在都可以绑定到前端 XAML。

It is entirely your choice, but at this stage, I renamed the MainPage file to MainView. The reason that I did this is that it binds to MainViewModel, and I find it much easier to read it if the names are aligned by convention. But don't worry: the code will still work if you don't do this.

现在我们已经绑定了视图模型,我们可以在ViewModel上调用方法。加载页面后,我们将调用一个方法来初始化所有内容:

private async void Page_Loaded(object sender, RoutedEventArgs e)
{
    await ((MainViewModel)DataContext).Initialise();
}

我们可以先看一下ViewModel文件;然而,首先,让我们花一分钟时间来谈谈视图模型作为一个概念。想象一下,你不是在编写应用,而是在安装水槽。如果你去当地的 DIY 商店,你可能会看到几十种类型的水槽,但是你可以挑选任何一个水龙头配件数量正确的水槽,并把它带回家,相信你可以把它放在旧水槽的地方,连接几根管道,并有自来水。

这与视图和视图模型有什么关系?想象一下,进入你家的管道是景观模型。它们以原始形式携带必要的信息——也就是水。如果你没有把水槽垂直放进去,只是打开了总水管,你会看到信息从管道中涌出。水槽就像视图一样,以你想看到的任何形式排列信息或水。

Please don't take any aspect of this analogy as reliable information on plumbing—it's for illustrative purposes only. I am not a qualified plumber!

When designing your view and view model, you should bear this in mind. If you start to see local relating to the arrangement of the data creeping into your view model, that should be a red flag that you're crossing a line and tightly coupling the two. One question to ask yourself is, if I wanted to display this differently, could I do so without changing the view model? If the answer is no, then consider a different approach.

让我们首先声明我们的ViewModel:

public class MainViewModel : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler PropertyChanged;

    protected void RaisePropertyChanged([CallerMemberName]string name = null)
    {
        if (PropertyChanged != null)
        {
            PropertyChanged(this, new PropertyChangedEventArgs(name));
        }
    } 
}

这是一个正常运行的 UWP 所需要的绝对最低代码。INotifyPropertyChanged界面是一个只有一个事件的界面:PropertyChanged。本质上,这里的目的是,当您更改属性时,您需要告诉绑定系统您已经这样做了,然后可以更新用户界面。我们在我们的 ViewModel中首先需要的是一种叫做Initialise的方法——如果你还记得的话,我们实际上更进一步地称之为:

internal async Task Initialise()
{
    await _wrapper.StartConversation(); 
}

让我们添加我们在应用中显示的数据:

public ObservableCollection<ChatMessage> ChatHistory { get; set; } = new ObservableCollection<ChatMessage>();

private string _messageText;
public string MessageText
{
    get { return _messageText; }
    set
    {
        _messageText = value;
        RaisePropertyChanged();
    }
}

MessageText短语只是一个字符串(正如我们之前猜测的那样)。如您所见,我们称之为RaisePropertyChanged事件,它是我们在设置属性时创建的。

You can choose to enhance this by only raising the event when the value changes.

第二条数据保存在一个特殊的集合中,称为ObservableCollection

ObservableCollection

当用户界面的内容改变时,一个ObservableCollection通知用户界面。也就是说,每次添加或删除一个项目,UI 都会自动更新。一个ObservableCollection在其他方面的表现就像任何其他集合一样(比如一个List)。

It's worth bearing in mind that, while an ObservableCollection will update the UI when you add or remove an item, it will not update the UI if you change the collection itself. For example, look at the following:

MyCollection = new ObservableCollection<MyObject>()

This would not update the UI. Furthermore, changing the contents of MyObject (in this case) would not update the UI.

目前,我们不会担心ChatHistory,尽管到目前为止你可能已经知道它会是什么样子。接下来,我们来看看命令。

命令绑定

在用户界面中,我们有一个设置了Command属性的按钮。在本节中,我们将不会看到Command实际上是什么样子的。在这段代码中,我们将使用名为RelayCommandwrapper类。我们将很快回到RelayCommand,但是命令只是实现ICommand接口的任何类。我们在这里使用的helper类仅仅意味着我们可以在ViewModel中声明命令功能。

最初,我们只是宣布RelayCommand:

public RelayCommandAsync<string> SendMessageCommand { get; set; }

然后,我们在构造函数中实例化它:

public MainViewModel()
{
    SendMessageCommand = new RelayCommandAsync<string>(SendMessage);
}

如您所见,我们将一个方法作为参数传递给命令。在我们看方法本身之前,我们需要走一点弯路;否则,这个方法就没有多大意义了。

DirectLineWrapper

最终,整个应用的目的是向我们的机器人发送一条消息,并将回复打印在屏幕上。我们接下来要介绍的是一个包装类来实现这一点。我们将在本章后面填充实际的类代码,但是在这里,我们将使用这个类,就好像它做了我们需要的一切一样。让我们从将它声明为类级变量开始:

BotClientSdk.DirectLineWrapper _wrapper = null;

现在,我们将在构造函数中实例化它,现在应该如下所示:

public MainViewModel()
{
    SendMessageCommand = new RelayCommandAsync<string>(SendMessage); 
    _wrapper = new BotClientSdk.DirectLineWrapper(PopulateHistory);
}

你会注意到我们正在传递一个变量到DirectLineWrapper类;我们将很快回到这个方法。

Broadly speaking, instantiating a dependency inside a constructor is bad practice. It makes testing the class very difficult; should you wish to extend this project, moving this into an injected dependency would be a good start.

SendMessage法体应做如下更改:

private async Task SendMessage(string message)
{            
    await _wrapper.SendMessage(message);
    MessageText = string.Empty;
}

_wrapper有一个叫SendMessage的方法。这将在我们稍后创建的类中调用一个方法。最后,我们清除文本框,以便用户可以输入另一条消息。

我们还需要一种方法来填充历史:

private void PopulateHistory(List<KeyValuePair<string, string>> response)
{ 
    var ignored = Dispatcher.RunAsync(CoreDispatcherPriority.Normal, () =>
    {
        foreach (var historyItem in response)
        {
            ChatHistory.Add(new ChatMessage(historyItem.Key, historyItem.Value));
        }
    });
}

这被传递到我们新的wrapper类的构造函数中——也就是说,整个函数都被传入。这里的想法是我们可以从我们的wrapper类内部更新用户界面,但是wrapper类仍然不知道它的调用上下文。我们需要在这里补充最后一点:

private CoreDispatcher Dispatcher =>
     (Window.Current == null) ?
     CoreApplication.MainView.CoreWindow.Dispatcher :
     CoreApplication.GetCurrentView().CoreWindow.Dispatcher;

这才允许我们使用Dispatcher;因为我们本质上是在一个后台线程上工作,没有这个,我们将无法更新 UI,这必然是一个前台线程。

当我们迭代这个的时候,我们把它添加到ObservableCollection;我们之前说过,以这种方式向集合中添加数据将为我们更新 UI。

在我们介绍包装器内部外观的细节之前,我们有一些已经使用过的helper类和模型。让我们从模型开始。

模型

我们使用了一个模型来存储聊天消息(单数),因此我们需要看看这个类是什么样子的。正如我之前所说的,如果你已经读完了这一章,它的形状应该不会令人惊讶:

public class ChatMessage
{
    public ChatMessage(string sender, string message)
    {
        Sender = sender;
        Message = message;
    }

    public string Message { get; set; }
    public string Sender { get; set; }
}

在我们继续之前,我们需要看RelayCommandAsync课。

RelayCommandAsync

RelayCommandAsync的代码不太繁琐。我们实现了一个名为ICommand的接口,它只有两种方法。ICommand的基本实现如下:

public class RelayCommandAsync<T> : ICommand
{ 
    public bool CanExecute(object parameter)
    {
    }

    public void Execute(object parameter)
    {
    } 
}

显然,这没有任何作用,所以让我们填写那些方法。我们先从Execute开始;我们需要将一个动作传递给Execute方法,我们可以通过构造函数来实现:

public class RelayCommandAsync<T> : ICommand
{
    readonly Func<T, Task> _execute = null;

    public RelayCommandAsync(Func<T, Task> execute)
    {
        if (execute == null)
            throw new ArgumentNullException("execute");

        _execute = execute;
    }

    public void Execute(object parameter)
    {
        _execute.Invoke((T)parameter);
    } 

    . . .
}

所以在这里,我们只是将一个函数委托作为构造函数传递到类中,然后在调用Execute时调用它。

The reason that we're using Func and not Action here is that we are, in fact, returning something—namely, a Task. If the command were synchronous, we could return an Action.

最后,我们将添加CanExecute代码:

public class RelayCommandAsync<T> : ICommand
{
    readonly Func<T, Task> _execute = null;
    readonly Func<T, bool> _canExecute = null;

    public RelayCommandAsync(Func<T, Task> execute)
        : this(execute, null)
    {
    }

    public RelayCommandAsync(Func<T, Task> execute, Func<T, bool> canExecute)
    {
        if (execute == null)
            throw new ArgumentNullException("execute");

        _execute = execute;
        _canExecute = canExecute;
    }

    public bool CanExecute(object parameter)
    {
        return _canExecute == null ? true : _canExecute.Invoke((T)parameter);
    }

    public event EventHandler CanExecuteChanged;

    public void RaiseCanExecuteChanged()
    {
        CanExecuteChanged?.Invoke(this, EventArgs.Empty);
    }

    public void Execute(object parameter)
    {
        _execute.Invoke((T)parameter);
    } 
}

CanExecute短语本质上返回一个布尔值,表示所讨论的命令是否可以有效执行。通过将用户界面绑定到此,如果返回False,您可以禁用用户界面上的按钮。我们实际上并没有在这里使用这个功能;然而,如果我们这样做了,这段代码将会起作用。

One of the advantages of CommandBinding is that you can unit test the command itself without touching the UI—that is, your unit test covers the code immediately after pressing the button.

现在,我们已经填补了剩余的空白,我们需要谈谈直接线,以及如何与我们的机器人沟通。

通道

在我们谈论直线和它是什么之前,我们应该讨论什么是关于机器人的通道。让我们看看下面的图片:

这并不是一个详尽的通道列表,而是试图说明一个通道只是一个应用和机器人之间的接口——例如,如果你愿意,你可以将你的机器人链接到 Slack。

这里有两个突出的渠道:直线和网络聊天。让我们简单地看一下它们。

直拨电话和网络聊天

我们已经看到了网络聊天:本质上,这是一种测试你的机器人的方法,当你注册一个频道时,它是免费的,这就是为什么你可以在本章的前面使用它来测试你的机器人。

直接线允许你直接与你的机器人互动。现在让我们配置一个直通通道。在 Azure 门户中,导航到您的机器人并选择频道:

抛开奇怪扭曲的图形不谈,直达线频道就是地球仪;如果你点击它,你应该被要求命名网站:

在下一个屏幕中,您将获得两个密钥。单击第一页上的“显示”,并保留一份副本,因为我们很快就会需要它。完成后,点按“完成”,您应该会看到现在有两个频道。

Bot 客户端

最后一步是创建我们在 UWP 应用中引用的客户端。让我们从创建一个新项目开始,并立即从 UWP 应用中引用它。. NET 标准 2.0 类库足以满足我们的需求:

我们需要做的第一件事是导入两个 NuGet 包:直接行包本身和 Rest 客户端运行时:

Install-Package Microsoft.Bot.Connector.DirectLine
Install-Package Microsoft.Rest.ClientRuntime

在这个项目中,我们只需要一个类——让我们称之为DirectLineWrapper

As a general principle, I always like to try to wrap external dependencies in a Wrapper Project to protect the main project from having too many direct external dependencies: this allows you to swap out the wrapper much easier if, for example, you wished to use a different bot service.

我们需要一些类级别的变量,包括DirectLineClient本身;我们还需要实例化它,如下所示:

public class DirectLineWrapper
{
    private string? _conversationId = null;
    private readonly DirectLineClient _client;
    Action<List<KeyValuePair<string, string>>> _updateMessages;

    public DirectLineWrapper(Action<List<KeyValuePair<string, string>>> updateMessages)
    {
        _client = new DirectLineClient("A33UCbCQoP1.QNPYS_u2Z7LhobFce9mA2ZWt47n7VzEuTjTGWHO-aL0");
        _updateMessages = updateMessages;
    }
}

传递到DirectLineClient构造函数中的字符串参数是您之前记录的秘密。我们将很快回到对话标识。如你所见,我们正在给班级注入一个动作;这将允许我们在消息进来时更新屏幕。

在这个类中,我们只需要三个方法,其中只有两个是公共的。让我们看看第一个:

public async Task SendMessage(string message)
{ 
    if (string.IsNullOrWhiteSpace(_conversationId))
    {
        throw new Exception("No active conversation");
    }

    Activity userMessage = new Activity
    {
        From = new ChannelAccount("User"),
        Text = message,
        Type = ActivityTypes.Message
    };

    var resourceResponse = await _client.Conversations.PostActivityAsync(_conversationId, userMessage); 
}

在这里,我们创建一个新的Activity实例。本质上,这是我们的信息;然而,它不一定是一条消息——它可能只是一个通知,表明您仍在键入或 ping。有很多选择,但我们对这里的Message感兴趣。

每次你和机器人开始对话,它都会给你一个对话标识。如果你每次都要开始一个新的对话,你就要清除聊天的上下文,所以你需要维护一个对 ID 的引用,如果 ID 不存在,就调用StartConversationAsync(),就能做到这一点。这就带来了我们的第二个公开方法usd:

public async Task StartConversation()
{
    if (string.IsNullOrWhiteSpace(_conversationId))
    {
        var conversation = await _client.Conversations.StartConversationAsync();
        _conversationId = conversation.ConversationId;

        new System.Threading.Thread(async () => await ReadBotMessagesAsync(_client, conversation.ConversationId)).Start();
    }
}

一旦我们开始对话,我们就开始一个新的线程来读取消息。我们的第三种方法是循环读取消息:

private async Task ReadBotMessagesAsync(DirectLineClient client, string conversationId)
{
    string watermark = string.Empty;
    var messages = new List<KeyValuePair<string, string>>();

    while (true)
    {
        var activitySet = await client.Conversations.GetActivitiesAsync(conversationId, watermark);

        lock (_lock)
        {
            watermark = activitySet.Watermark;

            var activities = from x in activitySet.Activities
                             select x;

            messages.Clear();
            foreach (Activity activity in activities)
            {
                messages.Add(new KeyValuePair<string, string>(activity.From.Id, activity.Text));
            }

            _updateMessages(messages);
       }

       await Task.Delay(TimeSpan.FromSeconds(1)).ConfigureAwait(false); 
    }
}

这本质上只是读回活动;然而,这里需要注意的重要一点是水印。尽管这个名字不好,但水印是一个指针,指向我们当前在活动集中的位置。如果您没有存储水印,那么每次您都可以简单地恢复整个对话。一旦我们得到一个活动列表,我们只需调用委托_updateMessages,它会更新用户界面。

我们的项目差不多到此结束。我们现在有了一个聊天客户端,它可以与用户通信,并有希望欺骗他们相信他们正在与另一个人交谈:

正如你所看到的,机器人正在对用户做出反应,并且,不可否认地,模糊地,正在进行对话。为了提高这种交流的质量,我们只需要回到 LUIS 部署,并使用具体的响应对其进行进一步的培训。

摘要

在这一章中,我们创建了一个聊天机器人,并使用 LUIS 对语言进行了基本的理解。我们采用了默认模板,并从.NET Core 2.2 到.NET Core 3.0,并创建了一个 UWP 客户端应用,使用直接线应用编程接口与机器人通信。

我们版本的伊莱扎(鲍里斯)比原版更有可能通过图灵测试吗?也许吧。如果你想改进 LUIS 中的模型,那么你可能会给它更多的战斗机会,但是聊天机器人和语言识别的现实应用远远超出了愚弄某人以为他们在和人类说话的能力。您可以很容易地将它与语音合成器和电话集成软件(如 Twilio)联系起来,您将拥有一个自动呼叫中心。

通用视窗平台,至少目前是微软吹捧的首选桌面平台。这种情况是否会持续,我们是否会走向单一.NET 平台,我不能说。

在本书中,我们介绍了 C#语言的许多新特性。我们利用它们来使我们的代码更加易读和简洁,并且,在可空引用类型的情况下,我们实际上减少了代码中潜在错误的数量。

我们也看了一些好处.NET Core 3 提供了,允许我们将现有的桌面应用迁移到新的.NET Core 3 框架,并且使用 XAML 群岛,甚至在遗留软件中使用来自较新应用的控件。

我们广泛研究了可供使用的 Azure 服务,发现将这些服务集成到我们的代码库中是多么容易。

十一、WebAssembly

在撰写本文时,每个人都在谈论 WebAssembly。2015 年首次推出。本质上,原则是您的浏览器(所有主要浏览器都支持 WebAssembly)可以运行一种类型的编译代码(以前,您仅限于 JavaScript)。

以下链接显示了浏览器对网络组装的支持:https://caniuse.com/#feat=wasm

火狐、Chrome、Edge、Safari 都支持。值得注意的是,一些较旧的浏览器(例如 IE)不支持它,所以如果您正在编写一些需要与这些较旧浏览器兼容的东西,您可能不得不求助于更传统的 JavaScript。

你最初可能会问自己的问题是:我为什么要在乎?希望本附录将涵盖为什么 WebAssembly 准备在未来几年接管 web 的原因。

然后,我们将运行两个示例,一个使用原生的 WebAssembly 编译,第二个使用 Blazor,这是微软的一项技术,允许您编写 C#并让它在浏览器中运行。

本章将涵盖以下主题:

  • 为什么是网络组装?
  • 编写网络程序集
  • 理解布拉佐

为什么是网络组装

就目前的情况而言,您可以编写 JavaScript,并且它在任何浏览器中都可以非常愉快地运行。假设你正在读一本关于 C#的书,你会(至少是模糊地)熟悉 JavaScript 语法。在本节中,我将尝试概述为什么您可能选择使用网络组装而不是可能的替代方法。

Due to the nature of this comparison, it inevitably contains some opinion. For example, I'm about to extol the virtues of statically typed languages over dynamic ones. If you know and are happy with using JavaScript, you may wish to skip the sections that don't interest you.

原因一——静态类型

我个人对 JavaScript 的不满之一是它是动态键入的。这意味着您可以使用尚未显式声明的变量。这里的连锁反应是,您可能会得到以下代码:

var myInt = 2;
myNum++;

假设您希望这两个数字是相同的,那么您会希望在编译时对此进行陷印;然而,有了 JavaScript,这将运行——尽管它显然不会像您期望的那样。

There are many tools available to help to alleviate this problem, from ESLint to TypeScript. However, this involves you including a step in your build chain to capture what, in a statically-typed language, would simply be a compilation error.

原因二-已编译

这是一个微妙的问题:虽然 JavaScript 肯定不是编译的,而是解释的,但是 WebAssemby 是 JIT 编译的。话虽如此,C 或 C#的编译过程将捕捉到前一节中提到的大多数问题。此外,与 JavaScript 相比,代码的执行速度显著提高(因为 JavaScript 是解释的)。有人声称速度提高了 20 倍;然而,正如您将在下一节中看到的,这并不像最初看起来那样清晰。

原因三——速度

和原因二一样,原因三也是脆弱的。笼统地说网络组装比 JavaScript 快是错误的。如前所述,执行相同的代码比 JavaScript 更快——事实上,处理器密集型代码的速度是 WebAssembly 存在的原因之一。

但是,请记住,您仍然在浏览器的上下文中运行,因此您可能希望使用的任何外部库都需要下载。虽然这对于运行编译成 WASM 的 C 程序来说不是什么问题,但对于像 Blazor 这样的东西来说可能是个问题。必须下载. NET 运行时。

In a later section, we will look into this in more detail. As it currently stands, the version of client-side Blazor uses the Mono runtime.

原因四–您知道的语言/前端和后端的相同语言

如果你知道 JavaScript,那么你可以选择使用 Node.js 作为你的后端。选择这样做可能有很多原因,其中一个原因可能是您可以为团队雇佣 JavaScript 开发人员。我们先深究一分钟:在写这篇文章的时候,IT 行业(至少在英国)存在技能短缺;如果你能分离出你需要招聘的一项技能,那肯定会让你的工作变得更容易,因为除了编写 C#和 JavaScript 程序的开发人员之外,你还可以雇佣只使用 JavaScript 的开发人员,或者使用 Python、Ruby、VB.NET 或任何后端语言并在前端使用 JavaScript 的开发人员——事实上,任何在 2019 年为 web 编写程序的人都需要至少了解 JavaScript 的基本水平。

直到现在,如果你是一名 C#开发人员(或任何其他前述语言),你将不得不学习 JavaScript 来编写应用的前端:现在你可以用 C#编写前端和后端。

At the time of writing, client-side Blazor was not officially part of the .NET Core ecosystem. We'll cover this in more detail later in this chapter.

还值得记住的是,随着 Xamarin 的出现,这实质上意味着你可以在任何平台上用 C#编程。

There is, in fact, an open source project that translates Xamarin.Forms code into WASM; it can be found here: https://github.com/praeclarum/Ooui.

Uno is another project that allows you to write XAML and translates it into platform-specific code (including WASM): https://platform.uno/.

原因五-现有代码

由于许多语言已经被各种工具支持编译到 WASM,您可能已经有一些代码可以简单地编译到网络汇编中。有几个例子可以说明这种成功是如何发生的。

想象你有一个完全用 C 写的游戏;您可以将它移植到浏览器上运行。就我个人而言,我正在焦急地等待一个工具的创建,它将把 Spectrum ZX80 汇编语言翻译成 WASM 语,然后我就可以在网上玩所有的老 Spectrum 游戏了!

原因六–部署

web 开发比桌面开发更受欢迎的一个主要原因是,web 浏览器解决了一个困扰我们程序员多年的问题:部署。编写一个桌面应用,并部署它——当您不能完全控制目标机器时,您会惊讶于部署它变得多么困难。

在我看来,这个原因可能是所有原因中最令人信服的:网络组装提供了桌面和网络开发环境之间的混合解决方案。

原因七——安全性

需要明确的是,WebAssembly 与 JavaScript 在相同的上下文中运行,所以它并不比用 JavaScript 编写 web 应用安全多少。然而,当你考虑你在做什么时,它实际上是相当安全的。这比给人们发送可执行文件并要求他们在自己的机器上运行要安全得多。

我已经列出了您可能希望使用网络组装的一些原因。对于某些应用,它显然比传统的 web 模型有一些优势。然而,和技术一样,这是一个权衡问题。

在下一节中,我们将看看您实际上是如何编写一些网络程序集的。

编写网络程序集

为了尝试编写一些网络组装,我们将使用网络组装工作室工具:https://webassembly.studio/

这是一个在线工具,允许你用各种语言编写代码,并把它们编译成 WASM。在 WebAssembly Studio web 应用中,如果您创建了一个新的C Hello World应用,您可以通过构建项目非常快速地看到一些 WASM 代码:

如你所见,WASM 并不完全是直觉。但是,您可以自己编写(或制作)并让网络浏览器运行它。

这个工具肯定会让你对这项技术的可能性有所了解;但是,如果您不太熟悉 C 或 Rust,那么除了在浏览器中显示 42 之外,您可能很难使用它。

幸运的是,微软正在研究一项名为 Blazor 的实验技术,该技术在一个看起来很熟悉的项目结构背后抽象出了大量这项工作。

理解布拉佐

在撰写本文时,Blazor 大约两岁(2017 年首次演示)。这个想法是采用 WebAssembly 的概念,并将其与 web 开发人员熟悉的现有概念(如 Razor)混合在一起。

Part of the reason that this section is an appendix is that only server-side Blazor was released with .NET Core 3. While the client side is in a workable state, it is not in the official release at the time of writing.

Blazor 有两种风格:客户端和服务器。服务器端 Blazor 与一起发布.NET Core 3。它的工作原理是在服务器上运行 C#代码,然后使用 SignalR 向客户端发送屏幕更新。虽然这是一个有趣的方法,因为它不使用网络组装,我们不会在本章中进一步讨论它。

要创建一个布拉佐项目,你需要 Visual Studio 插件:https://marketplace.visualstudio.com/items?itemName=aspnet.blazor

您还需要为 Blazor 安装 Visual Studio 模板;在命令行中,键入以下内容:

dotnet new -i Microsoft.AspNetCore.Blazor.Templates

By the time this book is published, this step may no longer be necessary.

我们现在可以创建一个新的 Blazor 项目:

  1. 从 Visual Studio 2019 创建新的 Blazor 应用项目:

  1. 在下一个屏幕中,选择您的项目名称和位置,就像您通常会做的那样:

  1. 在下一个屏幕中,您应该会看到几个选项。在本例中,我们将创建一个客户端 Blazor 应用:

  1. 如果您现在选择创建,您将看到一个完整的 Blazor 工作示例。尝试运行这个:您应该会看到一个功能齐全的单页应用。如果你看过 React 模板,你应该对这个结构非常熟悉。

While this is true at the time of writing, the template may have changed by the time you read this.

如果您在应用周围单击,它应该感觉很像现代的单页应用。让我们看得更深一点。我们将从项目结构开始:

这个结构你应该很熟悉。值得注意的是.razor的扩展:事实上,名称 Blazor 来自单词 B rowser 和 R azor 的融合。

对于这个快速的概述,我们不会过多地讨论细节,但是让我们看看计数器页面;运行应用时,您应该会看到一个允许您单击按钮并增加值的页面:

这一页给人一种极好的感觉,确切地说,这种语言是如何工作的;让我们看看驱动这个的代码。Counter.razor文件是这样的:

@page "/counter"

<h1>Counter</h1>

<p>Current count: @currentCount</p>

<button class="btn btn-primary" @onclick="@IncrementCount">Click me</button>

@code {
    int currentCount = 0;

    void IncrementCount()
    {
        currentCount++;
    }
}

我们有一些 HTML:页面标题、当前计数等等。然后,我们有了 Razor 语法——几乎在任何地方你都能在符号(@)上看到。注意,该按钮将点击事件处理程序映射到@code块内的方法。Blazor 跟踪 DOM,当某些东西发生变化时,例如currentCount,页面就会被刷新。

让我们再次运行该应用,但这一次,在浏览器中打开开发人员工具:

For most browsers, this can be achieved by pressing F12.

这告诉我们,在与 Blazor 打交道时,两件事非常重要。首先,看看运行应用所需的所有 dll。本质上,这是下载单声道运行时。如您所见,文件并不是特别大,但是在选择 Blazor 技术时需要记住这一点。

要注意的第二件事是,单击按钮不会进行服务器调用。所有代码都在客户端上执行。这就是 Blazor 的真正吸引人之处:你可以编写 C#代码,它在你的浏览器中运行。

我们已经快速浏览了原始形式的 WebAssembly。我们已经讨论了为什么网络组装是如此吸引人的新兴网络技术。然后我们看了一下 Blazor 项目,看它如何简化创建 web 应用的过程。

posted @ 2025-10-22 10:25  绝不原创的飞龙  阅读(3)  评论(0)    收藏  举报