八、后端和前端调试

所有编程语言(如 C#)和大多数脚本语言(如 JavaScript)最相关的功能之一是它们为开发人员提供的调试功能

"If debugging is the process of removing software bugs, then programming must be the process of putting them in."                                                                                                         — E. W. Dijkstra

术语“调试”普遍指的是发现和解决阻止程序或应用按预期工作的问题和/或问题(通常称为 bug)的过程,我们可以说,调试过程使开发人员能够更好地理解源代码是如何在后台执行的,以及为什么它会产生这样的结果。

调试对于任何开发人员来说都是一项非常重要的技能,可以说与编程本身一样重要;这是一项所有开发人员都必须通过理论、实践和经验学习的技能,就像编码一样。

完成这些任务的最佳方法是使用调试器——一种允许在受控条件下运行目标程序的工具。这使开发人员能够实时跟踪其操作,使用断点停止操作、逐步执行操作、查看基础类型的值等。高级调试器功能还允许开发人员访问内存内容、CPU 寄存器、存储设备活动等,查看或更改其值以再现可能导致解决问题的特定条件。

幸运的是,Visual Studio提供了一组调试器,可用于跟踪任何.NET Core 应用。尽管它的大部分功能都是为了调试我们应用的托管代码部分(例如,我们的 C#文件),但其中一些功能——如果配置正确——对于跟踪客户端代码也非常有用。在本章中,我们将学习如何使用它们,以及 Chrome、Firefox和 Edge 等一些 web 浏览器内置的各种调试工具,以不断监控我们WorldCities应用的整个 HTTP 工作流。

出于实际原因,调试过程分为两个单独的部分:

  • 后端,调试任务主要使用 Visual Studio 和.NET Core 工具处理
  • 前端,其中 VisualStudio 和 web 浏览器都起主要作用

在本章结束时,我们将学习如何充分使用 VisualStudio 提供的各种调试工具正确调试 web 应用的 web API 以及 Angle 组件。

技术要求

在本章中,我们将需要前面章节中列出的所有以前的技术要求,而不需要额外的资源、库或包。

本章代码文件可在此处找到:https://github.com/PacktPublishing/ASP.NET-Core-3-and-Angular-9-Third-Edition/tree/master/Chapter_08/

后端调试

在本节中,我们将学习如何利用 VisualStudio 环境提供的调试功能来查看 web 应用的服务器端生命周期,并了解如何正确地排除某些潜在缺陷。

然而,在做这件事之前,让我们花几分钟来看看它在各种可用的操作系统中是如何工作的。

Windows 还是 Linux?

为了简单起见,我们将理所当然地使用 Visual Studio 社区版、专业版或企业版 Windows 操作系统。但是,由于.NET Core 的设计是跨平台的,因此对于希望调试到其他环境(如 Linux 或 macOS)的用户,至少有两种选择:

  • 使用 Visual Studio 代码,这是 Visual Studio 的一种轻量级开源替代方案,可用于 Windows、Linux 和 macOS,并提供完全的调试支持
  • 使用 Visual Studio,得益于自 Visual Studio 2017 起提供的 Docker 容器工具,以及自版本 16.3 起内置到 Visual Studio 2019 中的工具

**Visual Studio Code can be downloaded for free (under MIT license) from the following URL:

https://code.visualstudio.com/download

Visual Studio Docker container tools require Docker for Windows, which can be installed from the following URL:

https://docs.docker.com/docker-for-windows/install/

The container tools usage information is available here:

https://docs.microsoft.com/en-us/aspnet/core/host-and-deploy/docker/visual-studio-tools-for-docker

For additional information about the .NET Core debugging features under Linux and macOS, check out the following URL:

https://github.com/Microsoft/MIEngine/wiki/Offroad-Debugging-of-.NET-Core-on-Linux---OSX-from-Visual-Studio

在本书中,为了简单起见,我们将坚持使用 Windows 环境,从而使用适用于 Windows 的 Visual Studio 调试器集。

基础

我们想当然地认为,购买本书的每个人都已经知道 Visual Studio 提供的所有基本调试功能,例如:

  • 调试与发布构建配置模式
  • 断点以及如何设置和使用断点
  • 中的步出程序* * 观看呼叫栈本地人*、即时窗口**

**For those who don't know (or remember) them well enough, here's a great tutorial that can be useful if you want a quick recap:

https://docs.microsoft.com/en-US/dotnet/core/tutorials/debugging-with-visual-studio?tabs=csharp

在下一节中,我们将简要介绍一些在特定场景中有用的高级调试选项。

条件断点

条件断点是一个有用的调试特性,大多数开发人员通常不知道(或未充分利用);它的行为就像一个普通的断点,但它只有在满足某些条件时才会触发。

要设置条件断点,只需单击创建标准断点时出现的设置上下文图标(带齿轮的图标),如以下屏幕截图所示:

一旦我们这样做,一个模式面板将出现在窗口底部,显示我们可以为该断点配置的一些可能的条件设置:

如我们所见,有许多可能的设置(条件、操作等)。让我们看看如何使用它们。

条件

如果选中条件复选框,我们将能够定义触发断点的代码条件。

为了更好地解释其工作原理,让我们执行一个快速调试测试:

  1. 在解决方案资源管理器中,打开/Controllers/CitiesController.cs文件。
  2. GetCity()方法的最后一行设置一个断点(找到城市后将其返回给客户端的断点–有关详细信息,请参见下面的屏幕截图)。
  3. 单击设置图标以访问断点设置面板。
  4. 激活条件复选框。
  5. 选择条件表达式,并在两个下拉列表中为 true。
  6. 在右侧的文本框中键入以下条件:city.Name == "Moscow"

完成后,断点设置面板应如以下屏幕截图所示:

正如我们所见,我们的条件已经创造出来;该界面允许我们添加其他条件,以及通过激活其下方的另一个复选框来执行某些操作。

行动

Actions 功能可用于在输出窗口中显示自定义消息(例如,嘿,我们正在从 Angular 编辑 Moscow!)和/或选择是否继续执行代码。如果未指定操作,断点将正常运行,不会发出消息并停止代码执行。

在这里,让我们借此机会测试一下 Actions 特性。激活复选框,然后在最右边的文本框中键入上一段中的消息。完成后,断点设置面板应如以下屏幕截图所示:

我们刚刚创建了第一个条件断点;让我们快速测试它,看看它是如何工作的。

测试条件断点

要测试断点被命中时发生的情况,请在调试模式下运行WorldCities应用(通过点击F5),导航到 Cities 视图,过滤表格以定位莫斯科市,然后单击其名称进入编辑模式。

如果一切正常,我们的条件断点应按以下方式触发和运行:

正如我们所看到的,输出窗口中也填充了我们的自定义消息。如果我们现在用不同的名字(例如,罗马、布拉格或纽约)对任何其他城市重复相同的测试,则根本不会触发相同的断点;什么也不会发生。

It's worth mentioning that there are two cities called Moscow in our WorldCities database: the Russian capital city and a city in Idaho, USA. It goes without saying that our conditional breakpoint will trigger on both of them because it only checks for the Name property. If we wanted to limit its scope to the Russian city only, we should refine the Conditional Expression to also match the CityId, the CountryId, or any other suitable property.

到目前为止一切都很好;让我们继续。

输出窗口

在上一节中,我们讨论了 VisualStudio 输出窗口,每当遇到条件断点时,我们都会使用该窗口编写自定义消息

如果您有使用 VisualStudio 调试器的经验,您需要了解该窗口的极端重要性,以了解幕后发生的事情。“输出”窗口显示 IDE 中各种功能的状态消息,这意味着大多数.NET 中间件、库和包都将其相关信息写入其中,就像我们使用条件断点所做的那样。

To open the Output window, either choose View | Output from the main menu bar or press Ctrl + Alt + O.

如果我们在刚刚执行的测试期间查看输出窗口中发生的情况,我们可以看到很多有趣的东西:

正如我们所看到的,有许多不同来源的信息,包括以下内容:

  • Microsoft.AspNetCore.Hosting.Diagnostics:专用于异常处理、异常显示页面和诊断信息的.NET Core 中间件。它处理开发人员异常页面中间件、异常处理程序中间件、运行时信息中间件、状态代码页面中间件和欢迎页面中间件。简而言之,它是调试 web 应用时输出窗口的王者。
  • Microsoft.AspNetCore.Mvc.Infrastructure:处理(和跟踪)控制器操作并响应.NET Core MVC 中间件的名称空间。

  • Microsoft.AspNetCore.Routing:处理静态和动态路由的.NET Core 中间件,例如我们的所有 web 应用的 URI 端点。

  • Microsoft.EntityFrameworkCore:处理数据源连接的.NET Core 中间件;例如,我们的 SQL Server,我们在第 4 章中详细讨论过,数据模型,具有实体框架核心

所有这些信息基本上都是 web 应用执行期间发生的所有事情的顺序日志。通过执行用户驱动的操作并阅读它,我们可以从.NET Core 生命周期中学到很多东西。

配置输出窗口

不用说,VisualStudio 界面允许我们过滤输出和/或选择捕获信息的详细程度。

要配置要显示和隐藏的内容,请从主菜单中选择工具|选项,然后从右侧的树菜单项导航到调试|输出窗口。从该面板中,我们可以选择(或取消选择)许多输出消息:异常消息、模块加载消息/模块卸载消息、进程退出消息、步骤筛选消息,等等:

现在我们已经了解了后端调试输出的要点,让我们将注意力转移到一个可能需要特别注意的中间件上:实体框架EF核心

调试 EF 核心

如果我们在调试模式下运行一个 web 应用后立即查看输出窗口,我们应该能够看到大量以纯文本编写的 SQL 查询。这些是底层的LINQ to SQL 提供者使用的查询,它负责将我们所有的 lambda 表达式、查询表达式、IQueryable 对象和表达式树转换为有效的 T-SQL 查询。

以下是Microsoft.EntityFrameworkCore中间件发出的输出信息行,其中包含用于检索莫斯科市的 SQL 查询(实际 SQL 查询突出显示):

Microsoft.EntityFrameworkCore.Database.Command: Information: Executing DbCommand [Parameters=[@__p_0='?' (DbType = Int32), @__p_1='?' (DbType = Int32)], CommandType='Text', CommandTimeout='30']
SELECT [c].[Id], [c].[Name], [c].[Lat], [c].[Lon], [c0].[Id] AS [CountryId], [c0].[Name] AS [CountryName]
FROM [Cities] AS [c]
INNER JOIN [Countries] AS [c0] ON [c].[CountryId] = [c0].[Id]
WHERE CHARINDEX(N'moscow', [c].[Name]) > 0
ORDER BY [c].[Name]
OFFSET @__p_0 ROWS FETCH NEXT @__p_1 ROWS ONLY

不错吧?在转换 lambda 或 LINQ 查询表达式s的性能时,这些明文形式的 SQL 查询对于确定 LINQ-to-SQL 提供程序是否工作良好非常有用。

GetCountries()SQL 查询

让我们尝试使用相同的技术来检索与 CountriesController 的GetCountries()方法实现相对应的 SQL 查询,我们在第 7 章代码调整和数据服务中对其进行了细化,以包括城市计数。

**以下是源代码片段:

return await ApiResult<CountryDTO>.CreateAsync(
        _context.Countries
            .Select(c => new CountryDTO()
            {
                Id = c.Id,
                Name = c.Name,
                ISO2 = c.ISO2,
                ISO3 = c.ISO3,
                TotCities = c.Cities.Count
            }),
        pageIndex,
        pageSize,
        sortColumn,
        sortOrder,
        filterColumn,
        filterQuery);

要查看它是如何转换为 T-SQL 的,请执行以下操作:

  1. 点击F5以调试模式运行 web 应用。
  2. 导航到“国家/地区”视图。
  3. 查看结果输出窗口(搜索TotCities会有帮助)。

下面是我们应该在那里找到的 SQL 查询:

SELECT [c0].[Id], [c0].[Name], [c0].[ISO2], [c0].[ISO3], (
    SELECT COUNT(*)
    FROM [Cities] AS [c]
    WHERE [c0].[Id] = [c].[CountryId]) AS [TotCities]
FROM [Countries] AS [c0]
ORDER BY [c0].[Name]
OFFSET @__p_0 ROWS FETCH NEXT @__p_1 ROWS ONLY

那还不错;LINQ-to-SQL 提供程序使用子查询对其进行转换,这在性能方面是一个不错的选择。SQL 查询的OFFSET部分与前面代码段中提到的DBCommand Parameters一起处理分页,并确保我们只获得我们一直要求的行。

然而,VisualStudio 输出窗口并不是查看这些 SQL 查询的唯一方法——我们可以通过实现一个简单但有效的扩展方法为自己提供一个更好的选择,我们将在以下部分中看到。

以编程方式获取 SQL 代码

输出窗口对于大多数场景来说已经足够好了,但是如果我们想通过编程方式从IQueryable<T>中检索 SQL 代码呢?这样的选项对于调试(或有条件地调试)应用的某些部分可能非常有用,特别是如果我们希望在输出窗口之外自动保存这些 SQL 查询(例如,日志文件或日志聚合器服务)。

为了实现这样的结果,我们需要创建一个专用的助手函数,该函数将能够使用System.Reflection实现这一点。让我们快速地做这件事,并测试它是如何工作的。

在解决方案资源管理器中,右键单击/Data/文件夹,创建一个新的IQueryableExtensions.cs文件,并用以下源代码填充其内容:

using Microsoft.EntityFrameworkCore.Query;
using Microsoft.EntityFrameworkCore.Query.SqlExpressions;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;

namespace WorldCities.Data
{
    public static class IQueryableExtension
    {
        public static string ToSql<T>(this IQueryable<T> query)
        {
            var enumerator = query.Provider
                .Execute<IEnumerable<T>>
                (query.Expression).GetEnumerator();
            var relationalCommandCache = enumerator
                .Private("_relationalCommandCache");
            var selectExpression = relationalCommandCache
                .Private<SelectExpression>("_selectExpression");
            var factory = relationalCommandCache
                .Private<IQuerySqlGeneratorFactory>
                ("_querySqlGeneratorFactory");

            var sqlGenerator = factory.Create();
            var command = sqlGenerator.GetCommand(selectExpression);

            string sql = command.CommandText;
            return sql;
        }

        private static object Private(this object obj, string 
         privateField) => 
            obj?.GetType()
            .GetField(privateField, BindingFlags.Instance | 
              BindingFlags.NonPublic)?
            .GetValue(obj);
        private static T Private<T>(this object obj, string 
         privateField) => 
            (T)obj?
            .GetType()
            .GetField(privateField, BindingFlags.Instance | 
              BindingFlags.NonPublic)?
            .GetValue(obj);
    }
}

正如我们所看到的,我们抓住机会创建了 helper 类作为一个IQueryable<T>扩展方法。这允许我们扩展IQueryable<T>类型的功能,而无需创建新的派生类型、修改原始类型或创建明确要求它作为引用参数的静态函数。

For those who have never heard of them, C# extension methods are static methods that can be called as if they were instance methods on the extended type. For further information, take a look at the following URL from the Microsoft C# programming guide:

https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/classes-and-structs/extension-methods

现在我们已经创建了IQueryableExtension静态类,我们可以在任何其他类中使用ToSql()扩展方法,只要它包含对WorldCities.Data名称空间的引用。

让我们看看如何在ApiResult.cs类中实现前面的扩展,这是大多数IQueryable<T>对象执行的地方。

实现 ToSql()方法

在解决方案资源管理器中,选择/Data/ApiResult.cs文件,打开该文件进行编辑,并将以下行添加到现有CreateAsync方法实现中(新行高亮显示):

// ...existing code...

public static async Task<ApiResult<T>> CreateAsync(
    IQueryable<T> source,
    int pageIndex,
    int pageSize,
    string sortColumn = null,
    string sortOrder = null,
    string filterColumn = null,
    string filterQuery = null)
{
    if (!String.IsNullOrEmpty(filterColumn)
        && !String.IsNullOrEmpty(filterQuery)
        && IsValidProperty(filterColumn))
    {
        source = source.Where(
            String.Format("{0}.Contains(@0)",
            filterColumn),
            filterQuery);
    }

    var count = await source.CountAsync();

    if (!String.IsNullOrEmpty(sortColumn)
        && IsValidProperty(sortColumn))
    {
        sortOrder = !String.IsNullOrEmpty(sortOrder)
            && sortOrder.ToUpper() == "ASC"
            ? "ASC"
            : "DESC";
        source = source.OrderBy(
            String.Format(
                "{0} {1}",
                sortColumn,
                sortOrder)
            );
    }

    source = source
        .Skip(pageIndex * pageSize)
        .Take(pageSize);

 // retrieve the SQL query (for debug purposes)
 var sql = source.ToSql();

    var data = await source.ToListAsync();

    return new ApiResult<T>(
        data,
        count,
        pageIndex,
        pageSize,
        sortColumn,
        sortOrder,
        filterColumn,
        filterQuery);
}

// ...existing code...

如我们所见,我们添加了一个变量来存储新扩展方法的结果。让我们快速测试一下,看看它是如何工作的。

It's worth noting that, since the ApiResult.cs class is part of the WorldCities.Data namespace, we didn't have to add the using reference at the top. 

ApiResult.cs类的行上,在前面添加的新行的正下方放置一个断点(如下面的屏幕截图所示)。完成后,点击F5以调试模式运行 web 应用,然后导航到国家/地区视图。

应该命中断点,如以下屏幕截图所示:

如果我们将鼠标光标移到sql变量上并单击放大镜图标,我们将能够在文本可视化工具窗口中看到 SQL 查询。现在,我们知道如何从IQueryable<T>对象快速查看 EF Core 生成的 SQL 查询。

使用#if 预处理器

如果我们担心ToSql()方法任务的性能受到影响,我们肯定可以通过以下方式使用#if预处理器调整前面的代码:

// retrieve the SQL query (for debug purposes)
#if DEBUG 
{
    var sql = source.ToSql();
 // do something with the sql string
}
#endif

如我们所见,我们已经将ToSql()方法调用包装在#if预处理器指令块中:当 C#编译器遇到这些指令时,它将仅在定义了指定符号的情况下编译它们之间的代码。更具体地说,我们在前面的代码中使用的DEBUG符号将防止编译包装好的代码,除非 web 应用正在调试模式下运行,从而避免在发布/生产版本中出现任何性能损失。

For additional information regarding the C# preprocessor directives, take a look at the following URLs:C# preprocessor directives:https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/preprocessor-directives/

if preprocessor directives:https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/preprocessor-directives/preprocessor-if

关于 VisualStudio 和.NETCore 提供的后端调试功能,还有很多话要说;然而,出于空间的原因,最好暂时停在这里,继续前进到前端。

前端调试

在本节中,我们将简要回顾现有的各种前端调试选项(VisualStudio 或浏览器的开发人员工具)。之后,我们将研究一些 Angular 特性,可以利用这些特性提高对客户端应用在后台执行的各种任务的认识,并对它们进行调试。

VisualStudioJavaScript 调试

前端调试工作s后端调试一样,得益于 Visual Studio 的JavaScript 调试功能。默认情况下不会启用 JS 调试器,但当我们第一次在 JavaScript(或 TypeScript)文件上设置断点并在调试模式下运行应用时,VisualStudioIDE 将自动询问是否激活它。

*到目前为止,仅为 Chrome 和 Microsoft Edge 提供客户端调试支持。除此之外,由于我们直接使用的是 TypeScript 而不是 JavaScript,如果我们想在 TypeScript 文件(我们的 Angular 组件类文件)而不是 JavaScript 传输文件中设置和命中断点,则需要使用源映射。

幸运的是,我们正在使用的 Angular 模板(参见第 1 章准备第 2 章环视)已经提供了源地图支持,我们可以通过查看/ClientApp/tsconfig.json文件中的sourceMap参数值看到:

[...]

"sourceMap": true

[...]

这意味着我们可以做到以下几点:

  1. 打开/ClientApp/src/app/countries/countries.component.ts文件。
  2. countryService返回的Observable订阅中放置一个调试器(详见下面的屏幕截图)。
  3. 点击F5以调试模式启动 web 应用。

如果一切都正确,Visual Studio IDE 应该询问我们是否要启用 JavaScript 调试,如以下屏幕截图所示:

一旦启用,运行时环境将在我们导航到 Countries 视图时立即停止程序执行。不用说,我们将能够检查 Angular 组件类的各种成员:

那很酷,对吧?我们甚至可以定义条件断点,使用无明显缺陷的监视调用堆栈局部变量、立即窗口。

For additional information about debugging a TypeScript or JavaScript app in Visual Studio, take a look at the following URL:

https://docs.microsoft.com/en-US/visualstudio/javascript/debug-nodejs.

在下一节中,我们将介绍另一个重要的前端调试资源:JavaScript 源映射。

JavaScript 源代码映射

对于那些不知道源地图实际上是什么的人,让我们尝试简要总结一下这个概念。

*从技术上讲,源映射是将压缩、组合、缩小和/或传输文件中的代码映射回其在源文件中的原始位置的文件。由于这些映射,我们甚至可以在资产优化后调试应用。

正如我们刚才看到的,源映射被 Visual Studio JavaScript 调试器广泛使用,使我们能够在 TypeScript 源代码中设置断点,Google Chrome、Mozilla Firefox 和 Microsoft Edge developer 工具也支持它们,这样,即使在处理压缩和缩小的文件时,这些浏览器的内置调试器也可以向开发人员显示未统一和未组合的源代码。

*有关 JavaScript 源映射的其他信息,请查看以下 URL:

JavaScript 源代码映射简介,Ryan Seddon: https://www.html5rocks.com/en/tutorials/developertools/sourcemaps/

源地图介绍,马特·韦斯特: https://blog.teamtreehouse.com/introduction-source-maps

然而,考虑到我们的特定场景,上述浏览器的调试功能可能并不理想;在下一节中,我们将尽力解释原因。

浏览器开发工具

我们很容易猜到,VisualStudioJavaScript 调试功能不是调试客户端脚本的唯一方法。然而,由于我们正在处理一个 TypeScript 应用,它可以说是最好的选择,因为它允许通过自动生成的源映射调试.ts文件。

尽管浏览器的内置调试工具肯定可以使用源映射让我们处理未统一和未组合的文件,但它们无法将这些已传输的文件恢复到以前的 TypeScript 类中,因为它们从未见过这些文件。

正是出于这个原因,如果我们尝试激活 Chrome 开发者工具来调试我们的CountriesComponentAngular 类,我们会遇到如下情况:

我们可以看到,TypeScript 文件不在那里。浏览器正在处理一个巨大的main.js传输文件,该文件基本上包含所有 Angular 组件。在该文件中,CountriesComponent类的上述行(第 69 行左右)对应于第 888 行(参见前面的屏幕截图;实际行号可能有所不同)。

但是,只要我们单击该行在那里设置断点,相应的 TypeScript 文件也将变得可访问,就像在 Visual Studio 中一样:

这怎么可能呢?我们刚才不是说浏览器对 TypeScript 类一无所知吗?

事实上,事实并非如此;但是,由于我们在开发环境中运行应用,我们的.NET Core 应用正在使用AngularCliMiddleware为我们的 Angular 应用提供服务,从而转发那里的所有 HTTP 请求。我们已经在Startup.cs文件中看到了此设置:

// [...]

app.UseSpa(spa =>
{
    // To learn more about options for serving an Angular SPA from     
    // ASP.NET Core,
    // see https://go.microsoft.com/fwlink/?linkid=864501

    spa.Options.SourcePath = "ClientApp";

 if (env.IsDevelopment())
 {
 spa.UseAngularCliServer(npmScript: "start");
 }
});

// [...]

UseAngularCliServer()方法将在内部调用AngularCliMiddleware,它将执行以下操作:

  1. 启动 npm 实例(使用动态端口)
  2. 使用该动态端口执行 ng 发球(发球 Angular 应用)
  3. 创建一个透明代理,将所有 HTTP 请求转发到 Angular dev 服务器

多亏了这一切,浏览器虽然只接收到main.jsJavaScript 传输文件,但仍然能够按照源映射到达底层的 TypeScript 文件

*但是,即使我们在 TypeScript 页面上设置了断点,一旦触发,我们将返回到main.js文件,如下面的屏幕截图所示:

这种行为并不令人惊讶;浏览器的内置调试器可以使用源映射从代理检索 TypeScript 类,但显然无法直接处理/调试它们。

正是出于这个原因,至少在我们的特定场景中,VisualStudio 前端调试功能(使用内置 JavaScript 调试器)可以说是当今调试 Angular 应用最有效的方法。

角形调试

在本节中,我们将花费一些宝贵的时间来理解与表单调试相关的一些关键概念。

正如我们在第 6 章表单和数据验证中提到的,模型驱动方法的优势之一是它允许我们对表单元素进行粒度控制。我们如何利用这些特性并将其转化为编写更健壮的代码?

在下面的部分中,我们将通过展示一些有用的技术来解决这个问题,这些技术可以用来更好地控制表单。

查看表单模型

第 6 章表单和数据验证中,我们已经讨论了很多表单模型,但我们从未仔细看过。在开发表单模板时将其显示在屏幕上会有很大帮助,特别是当我们使用表单输入和控件时,它可以实时更新。

下面是一个方便的 HTML 代码段,其中包含实现此操作所需的模板语法:

<!-- Form debug info panel -->
<div class="card bg-light mb-3">
  <div class="card-header">Form Debug Info</div>
  <div class="card-body">
    <div class="card-text">
      <div><strong>Form value:</strong></div>
      <div class="help-block">
 {{ form.value | json }}
      </div>
      <div class="mt-2"><strong>Form status:</strong></div>
      <div class="help-block">
 {{ form.status | json }}
      </div>
    </div>
  </div>
</div>

我们可以将此代码片段放在任何基于表单的组件上,例如,CityEditComponent,以获得以下结果:

很有用吧?如果我们稍微玩一下表单,我们可以看到表单调试信息面板中包含的值将如何随着输入控件的更改而更改;在处理复杂表单时,这样的东西肯定会派上用场。

管道操作员

通过查看前面源代码中突出显示的行,我们可以看到如何使用管道操作符(|),这是来自 Angular 模板语法的另一个有用工具。

为了快速总结它的作用,我们可以这样说:pipe运算符允许使用一些转换函数,这些函数可用于执行各种任务,例如格式化字符串、将数组元素合并为字符串、将文本大写/小写以及对列表排序。

以下是内置有 Angular 传感器的管道:

  • DatePipe
  • UppercasePipe
  • LowerCasePipe
  • CurrencyPipe
  • PercentPipe
  • JsonPipe

这些都可以在任何模板中使用。不用说,我们在前面的脚本中使用了后者来将form.valueform.status对象转换为可读的 JSON 字符串。

It's worth noting that we can also chain multiple pipes and define custom pipes; however, we don't need to do that for the time being, and talking about such a topic will take us far away from the scope of this chapter. Those who want to know more about pipes should take a look at the official Angular documentation at https://angular.io/guide/pipes.

对变化作出反应

我们选择反应式方法的原因之一是能够对用户发布的更改做出反应。我们可以通过订阅FormGroupFormControl类公开的valueChanges属性来实现这一点,该属性返回一个发出最新值的RxJS Observable

第三章前端和后端交互开始,我们就一直在使用 Observable,当我们第一次订阅HttpClientget()方法来处理 web 服务器接收到的HTTP响应时。我们在第 6 章表单和数据验证中再次使用了它们,当时我们还必须实现对put()post()方法的支持,最后但并非最不重要的是,我们在第 7 章中广泛讨论了它们代码调整和数据服务,当我们解释它们相对于承诺的优缺点时,了解了它们最相关的一些特性,并将它们集成到我们的CityServiceCountryService中。事实上,无论何时何地,只要我们需要获取为数据模型接口和表单模型对象提供数据的 JSON 数据,我们都会继续使用它们*。*

***在下一节中,我们将使用它们演示如何在用户更改表单中的某些内容时执行一些任意操作。更准确地说,我们将通过实现一个定制的活动日志来观察可观察到的情况。

活动日志

再一次,CityEditComponent将成为我们的实验鼠。

打开/ClientApp/src/app/cities/city-edit.component.ts类文件并用以下突出显示的行更新其代码:

// ...existing code...

 // Activity Log (for debugging purposes)
 activityLog: string = '';

  constructor(
    private activatedRoute: ActivatedRoute,
    private router: Router,
    private cityService: CityService,
    @Inject('BASE_URL') private baseUrl: string) {
      super();
    }

  ngOnInit() {
    this.form = new FormGroup({
      name: new FormControl('', Validators.required),
      lat: new FormControl('', [
        Validators.required,
        Validators.pattern('^[-]?[0-9]+(\.[0-9]{1,4})?$')
      ]),
      lon: new FormControl('', [
        Validators.required,
        Validators.pattern('^[-]?[0-9]+(\.[0-9]{1,4})?$')
      ]),
      countryId: new FormControl('', Validators.required)
    }, null, this.isDupeCity());

 // react to form changes
 this.form.valueChanges
 .subscribe(val => {
 if (!this.form.dirty) {
 this.log("Form Model has been loaded.");
 }
 else {
 this.log("Form was updated by the user.");
 }
 });

    this.loadData();
  }

 log(str: string) {
 this.activityLog += "["
 + new Date().toLocaleString()
 + "] " + str + "<br />";
 }

// ...existing code

在前面的代码中,我们为表单模型提供了一个简单但有效的日志功能,该功能将注册框架和/或用户执行的任何更改活动。

正如我们所看到的,所有逻辑都放在了constructor中,因为这是组件类初始化的地方,以及我们需要监视的可观察对象。log()函数只是一个快捷方式,可以将基本时间戳附加到日志活动字符串中,并以集中的方式将其添加到activityLog局部变量中。

为了充分享受我们新的日志功能,我们必须找到一种方法将activityLog显示在屏幕上。

为此,请打开/ClientApp/src/app/cities/city-edit.component.html模板文件,并在文件末尾添加以下 HTML 代码片段,位于上一个表单调试信息面板的正下方:

<!-- Form activity log panel -->
<div class="card bg-light mb-3">
  <div class="card-header">Form Activity Log</div>
  <div class="card-body">
    <div class="card-text">
      <div class="help-block">
        <span *ngIf="activityLog" 
            [innerHTML]="activityLog"></span>
      </div>
    </div>
  </div>
</div>

就这样,;现在,活动日志将实时显示,这意味着以真正的反应方式显示。

It's worth noting that we didn't use the double curly braces of interpolation here—we went straight for the [innerHTML] directive instead. The reason for that is very simple. The interpolation strips the HTML tags from the source string; hence, we would've lost the <br /> tag that we used in the log() function to separate all log lines with a line feed. If not for that, we would have used the {{ activityLog }} syntax instead.

测试活动日志

我们现在需要做的就是测试我们的新活动日志。

为此,在调试模式下运行项目,通过编辑已存在的城市(例如布拉格)直接进入CityEditComponent,使用表单字段,然后查看表单活动日志面板中发生的情况:

HttpClient从后端 Web API 检索到 city JSON 并且表单模型得到更新时,第一个日志行应该会自动触发。然后,表单将记录用户执行的任何更新;我们所能做的就是改变各种输入字段,但这足以让我们谦逊的反应性测试成功完成。

扩展活动日志

对表单模型更改作出反应不是我们唯一能做的事情;我们还可以扩展订阅以观察任何表单控件。让我们对当前的活动日志实现进行进一步升级,以演示这一点

打开/ClientApp/src/app/cities/city-edit.component.ts类文件,用以下突出显示的行更新constructor方法中的代码:

// ...existing code...

// react to form changes
this.form.valueChanges
  .subscribe(val => {
    if (!this.form.dirty) {
      this.log("Form Model has been loaded.");
    }
    else {
      this.log("Form was updated by the user.");
    }
  });

// react to changes in the form.name control
this.form.get("name")!.valueChanges
 .subscribe(val => {
 if (!this.form.dirty) {
 this.log("Name has been loaded with initial values.");
 }
 else {
 this.log("Name was updated by the user.");
 }
 });

// ...existing code...

前面的代码将在表单活动日志中添加更多的日志行,所有这些都与包含城市名称的name表单控件中发生的更改有关,如下所示:

我们在这里所做的足以证明valueChanges可观测属性的奇迹;让我们转到下一个话题。

We can definitely keep the Form Debug Info and Form Activity Log panels in the CityEditComponent template for further reference, yet there's no need to copy/paste it within the other form-based Components' templates or anywhere else.

客户端调试

observables 的另一个巨大优势是,我们可以通过在订阅源代码中放置断点来使用它们调试整个反应式工作流的大部分。要快速演示这一点,只需在最新订阅中添加 Visual Studio 断点,如下所示:

完成后,以调试模式运行项目并导航到CityEditComponent;一旦加载表单模型,断点就会被命中,因为name控件也会被更新,而且每次我们对该控件进行更改时都会被更新。无论何时,我们都可以使用客户端调试中可用的所有 VisualStudioJavaScript 调试工具和功能,例如WatchLocalsAutosImmediate调用堆栈等等。

For additional information about client-side debugging with Google Chrome, we strongly suggest reading the following post on the official MSDN blog:

https://blogs.msdn.microsoft.com/webdev/2016/11/21/client-side-debugging-of-asp-net-projects-in-google-chrome/

总结

在本章中,我们讨论了一些在开发过程中非常有用的调试特性和技术。让我们试着快速总结一下到目前为止所学的内容。

我们从 VisualStudio服务器端调试功能开始了我们的旅程。这些是一组运行时调试功能,可用于防止 Web API 上的大多数编译器错误,并允许我们跟踪整个后端应用生命周期:从中间件初始化到整个 HTTP 请求/响应管道,再到控制器、实体和IQueryable对象。

*紧接着,我们转到了 VisualStudio 客户端调试功能。这是一个整洁的 JavaScript 调试器,由于 TypeScript transpiler 创建的源映射,它允许我们直接调试 TypeScript 类,并以真正高效的方式访问变量、订阅和初始值设定项。

最后,我们设计并实现了一个实时活动日志。这是一种快速有效的方法,可以利用 Angular 模块暴露的各种可见光的反应特性来跟踪我们的组件发生了什么;更不用说 VisualStudioTypeScript transpiler(和 Intellisense)有望保护我们免受大多数语法、语义和逻辑编程错误的影响,使我们免受基于脚本编程的有害影响,至少在大部分情况下是如此。

然而,如果我们想针对一些特定的用例测试我们的表单呢?有没有一种方法可以模拟后端.NET Core 控制器的行为,以及前端 Angular 组件的行为,并执行单元测试?

答案是肯定的。事实上,我们选择的两个框架提供了各种开源测试工具来执行单元测试。在下一章中,我们将学习如何使用它们来提高代码质量,并在重构、回归和新的实现过程中防止 bug。

建议的主题

Visual Studio 代码、调试器、服务器端调试、客户端调试、扩展方法、C#预处理器指令、JavaScript 源代码映射和 Angular 管道。

工具书类