JavaScript-数据整理指南-全-

JavaScript 数据整理指南(全)

原文:Data Wrangling with JavaScript

译者:飞龙

协议:CC BY-NC-SA 4.0

1

开始吧:建立你的数据处理管道

本章涵盖

  • 理解数据处理的“是什么”和“为什么”

  • 定义数据处理与数据分析之间的区别

  • 学习何时适合使用 JavaScript 进行数据分析

  • 收集你在 JavaScript 数据处理工具箱中需要的工具

  • 漫步数据处理过程

  • 获取真实数据处理管道的概览

1.1 为什么需要数据处理?

我们的现代世界似乎围绕着数据旋转。你几乎在任何地方都能看到它。如果数据可以被收集,那么它正在被收集,有时你必须努力理解它。

分析学是商业决策过程中的一个重要组成部分。用户对你的应用或服务有何反应?如果你改变你的业务方式,这有助于改善情况还是使情况变得更糟?这些问题是企业在他们的数据中询问的问题。更好地利用你的数据并获得有用的答案可以帮助我们在竞争中脱颖而出。

数据也被政府用来基于证据制定政策,随着越来越多的开放数据变得可用,公民也在分析和理解这些数据中扮演着一定的角色。

数据处理,即准备你的数据以便进行查询的行为,是一项需求日益增长且不断上升的技能。数据相关技能的熟练度越来越普遍,并且需要更多样化的人群。在这本书中,你将练习数据处理技能,以帮助你支持数据相关活动。

这些技能在日常的开发任务中也同样有用。你的应用性能如何?性能瓶颈在哪里?错误数量的发展趋势是什么?这些问题对我们这些开发者来说很有趣,而且它们也可以通过数据得到解答。

1.2 什么是数据处理?

维基百科将数据处理描述为在工具的帮助下将数据从一种形式转换为另一种形式的过程,以便方便地消费数据。这包括转换、聚合、可视化和统计分析。我认为数据处理是将数据带入并通过你的管道的整个过程,无论这管道是什么,从数据采集到目标受众,无论他们是谁。

许多书籍只涉及数据分析,维基百科将其描述为处理和检查数据以支持决策的过程。我认为数据分析是数据处理过程的一个子集。数据分析师可能不会关心数据库、REST API、流数据、实时分析、为生产使用准备代码和数据等。对于数据处理员来说,这些通常是工作的关键。

数据分析师可能会花大部分时间离线分析数据,以生成报告和可视化,帮助决策者。数据处理员也做这些事情,但他们也可能有生产方面的考虑:例如,他们可能需要他们的代码在一个实时系统中执行,自动分析和可视化实时数据。

数据处理难题可能有多个部分。它们以许多不同和复杂的方式组合在一起。首先,你必须获取数据。数据可能包含任何你需要解决的问题。你有许多方式可以格式化和向目标受众交付数据。在某个中间位置,你必须以高效的方式存储数据。你也可能需要接受流式更新并实时处理传入的数据。

最终,数据处理的过程是关于沟通的。你需要将你的数据整理成一种促进清晰度和理解,并能够快速做出决策的形状。你如何格式化和表示数据,以及你需要对其提出的问题,将根据你的情况和需求而有很大差异,但这些问题是实现结果的关键。

通过数据处理,你将你的数据从一种形状调整到另一种形状。有时,这会是一个非常混乱的过程,尤其是当你无法控制源头时。在某些情况下,你会构建一次性的数据处理代码,它只会运行一次。这不会是你的最佳代码。它不必是,因为你可能永远不会再次使用它,你不应该对不会重用的代码投入过多的努力。对于这段代码,你将只投入必要的努力来证明输出是可靠的。

在其他时候,数据处理,就像任何编码一样,可以是一个非常严谨的过程。你会有理解需求很好的时候,并且你已经耐心地构建了一个生产就绪的数据处理管道。你将对这段代码投入极大的关注和技巧,因为它将在生产环境中被调用成千上万次。你可能已经使用了测试驱动开发,这可能是你写过的最健壮的代码之一。

很可能你的数据处理将介于临时和严谨之间。你可能会写一些临时代码来将源数据转换成更可用的形式。然后对于必须在生产中运行的代码,你会更加小心。

数据整理的过程包括多个阶段,正如你在图 1.1 中可以看到的那样。这本书将这个过程划分为这些阶段,好像它们是独立的,但实际上它们很少被干净地分开,也不一定按顺序依次进行。我在这里将它们分开,以保持事情简单,并使解释更容易。在现实世界中,事情永远不会这么干净和明确。数据整理的阶段相互交叉和相互作用,通常纠缠在一起。通过这些阶段,你理解、分析、重塑和转换你的数据,以便交付给受众。

c01_01.eps

图 1.1 将数据整理分为阶段

数据整理的主要阶段包括数据获取、探索、清理、转换、分析,最后是报告和可视化。

数据整理涉及处理许多不同的问题。你如何过滤或优化数据,以便更有效地工作?你如何改进你的代码以更快地处理数据?你如何使用你的语言来提高效率?你如何扩展并处理更大的数据集?

在这本书中,你将了解数据整理的过程及其各个组成部分。在这个过程中,我们将讨论许多问题以及如何应对它们。

1.3 为什么会有关于 JavaScript 数据整理的书?

JavaScript 并不以数据处理能力著称。通常你会被告知去使用其他语言来处理数据。在过去,我使用 Python 和 Pandas 来处理数据。这就是大家都会说的使用方法,对吧?那么为什么写这本书?

Python 和 Pandas 确实适合数据分析。我不会试图否认这一点。它们具有成熟度和建立的生态系统。

Jupyter Notebook(以前称为 IPython Notebook)是一个用于探索性编码的绝佳环境,但现在你也有这种类型的工具在 JavaScript 中。Jupyter 本身有一个插件,允许它运行 JavaScript。现在也提供了各种 JavaScript 特定的工具,例如 RunKit、Observable 以及我自己的 Data-Forge Notebook。

我使用 Python 来处理数据,但我总觉得它不适合我的开发流程。我并不是说 Python 有什么问题;在许多方面,我喜欢这门语言。我对 Python 的问题是我已经在 JavaScript 中做了很多工作。我需要我的数据分析代码在 JavaScript 中运行,以便在需要运行的 JavaScript 生产环境中工作。你如何用 Python 做到这一点?

你可以用 Python 进行探索和分析编码,然后将数据移动到 JavaScript 可视化,正如许多人所做的那样。由于 JavaScript 强大的可视化生态系统,这是一种常见的做法。但如果你想在实时数据上运行你的分析代码呢?当我发现我需要在生产环境中运行我的数据分析代码时,我就不得不将其重写为 JavaScript。我从未能够接受这是事情必须如此的方式。对我来说,这归结为一点:我没有时间重写代码。

但谁有时间为代码重写呢?世界变化太快,以至于没有时间。我们都有要遵守的最后期限。你需要为你的业务增加价值,而在繁忙和快节奏的商业环境中,时间通常是一种你难以承受的奢侈。你希望以探索的方式编写你的数据分析代码,就像 Jupyter Notebook 一样,但使用 JavaScript,然后将其部署到 JavaScript 网络应用程序或微服务中。

这引导我走上了在 JavaScript 中处理数据并构建开源库 Data-Forge 的旅程,以帮助实现这一目标。在这个过程中,我发现 JavaScript 程序员的数据分析需求没有得到很好的满足。鉴于 JavaScript 程序员的激增、JavaScript 语言的易于访问以及看似无尽的 JavaScript 可视化库系列,这一状况有些令人困惑。我们为什么还没有谈论这个问题呢?人们真的认为数据分析不能在 JavaScript 中完成吗?

这些问题引导我写下了这本书。如果你知道 JavaScript,这是我所做的假设,那么你可能不会对我发现 JavaScript 是一种出人意料的有能力且能显著提高生产力的语言感到惊讶。当然,它有一些需要注意的问题,但所有优秀的 JavaScript 开发者都已经在使用语言的优点,并避免使用缺点。

这些天,各种复杂的应用程序都在用 JavaScript 编写。你已经熟悉这门语言,它功能强大,并且你在生产环境中也在使用它。继续使用 JavaScript 将会为你节省时间和精力。为什么不也用 JavaScript 来处理数据呢?

1.4 你将从这本书中获得什么?

你将学习如何在 JavaScript 中进行数据处理。通过众多示例,从简单到复杂,你将发展你的数据处理技能。在这个过程中,你将了解许多你可以使用的工具,这些工具已经 readily 可用。你将学习如何在 JavaScript 中应用其他语言中常用的数据分析技术。

我们将一起纯粹在 JavaScript 中查看整个数据处理过程。你将学会构建一个数据处理管道,它从数据源获取数据,对其进行处理和转换,然后最终以适当的形式将数据交付给受众。

你将学习如何解决将你的数据处理管道部署到生产环境并扩展到大数据集所涉及的问题。我们将探讨你可能会遇到的问题,并学习你必须采用的思维过程来找到解决方案。

我将证明你不需要转向其他语言,如 Python,这些语言传统上被认为更适合数据分析。你将学习如何在 JavaScript 中做到这一点。

最终的收获是对数据处理世界的欣赏以及它与 JavaScript 的交汇点。这是一个巨大的世界,但《使用 JavaScript 进行数据处理》将帮助你导航并理解它。

1.5 为什么使用 JavaScript 进行数据处理?

我主张使用 JavaScript 进行数据处理,原因有几个;这些原因总结在表 1.1 中。

表 1.1 使用 JavaScript 进行数据处理的理由

理由 详情
你已经了解 JavaScript。 为什么还要学习另一种语言来处理数据?(假设你已经了解 JavaScript。)
JavaScript 是一种强大的语言。 它被用来构建各种复杂的应用程序。
探索性编码。 使用带有实时重载的原型制作流程(在第五章中讨论)是使用 JavaScript 编写应用程序的强大方式。
强大的可视化生态系统。 Python 程序员通常会转向 JavaScript 来使用其许多可视化库,包括 D3,可能是最复杂的数据可视化库。我们将在第十章和第十三章中探讨可视化。
通常拥有强大的生态系统。 JavaScript 拥有最强大的用户驱动生态系统之一。在整个书中,我们将使用许多第三方工具,并鼓励你进一步探索以构建自己的工具包。
JavaScript 无处不在。 JavaScript 在浏览器、服务器、桌面、移动设备,甚至嵌入式设备上都有。
JavaScript 易于学习。 JavaScript 因其易于入门而闻名。也许它难以精通,但这同样适用于任何编程语言。
JavaScript 程序员容易找到。 如果你需要雇佣某人,JavaScript 程序员无处不在。
JavaScript 正在发展。 语言继续变得更加安全、更可靠、更方便。它随着 ECMAScript 标准的每一版更新而得到改进。
JavaScript 和 JSON 密不可分。 JSON 数据格式,即网络数据格式,是从 JavaScript 演变而来的。JavaScript 内置了处理 JSON 的工具,许多第三方工具和库也是如此。

1.6 JavaScript 适合数据分析吗?

我们没有理由单独将 JavaScript 视为不适合数据分析的语言。反对 JavaScript 的最好论点是,像 Python 或 R 这样的语言背后有更多的经验。我的意思是,它们已经为这种工作建立起了声誉和生态系统。如果这是你想要使用 JavaScript 的方式,JavaScript 也可以达到那里。这确实是我想要使用 JavaScript 的方式,我认为一旦 JavaScript 数据分析起飞,它将迅速发展。

我预计会对 JavaScript 在数据分析方面的批评。一个论点将是 JavaScript 没有性能。与 Python 类似,JavaScript 是一种解释型语言,由于这个原因,两者都有受限的性能。Python 通过其众所周知的原生 C 库来解决这个问题,这些库补偿了其性能问题。请知道,JavaScript 也有类似的本地库!而且,尽管 JavaScript 从未是镇上最高性能的语言,但得益于 V8 引擎和 Chrome 浏览器的创新和努力,其性能已经显著提升。

反对 JavaScript 的另一个论点可能是它不是一个高质量的语言。JavaScript 语言有设计缺陷(哪种语言没有呢?)和复杂的历史。作为 JavaScript 程序员,你已经学会了如何绕过它向我们抛出的问题,而且你仍然很有效率。随着时间的推移和多次修订,该语言继续发展、改进,并成为一个更好的语言。如今,我花在TypeScript上的时间比 JavaScript 多。这提供了在需要时类型安全智能感知的好处,以及其他所有关于 JavaScript 的喜爱之处。

Python 在它的角落里有一个主要优势,那就是现在被称为 Jupyter Notebook 的出色的探索性编码环境。请记住,Jupyter 现在与 JavaScript 一起工作!没错,你可以在 Jupyter 中使用 JavaScript 进行探索性编码,与专业数据分析师使用 Jupyter 和 Python 的方式几乎一样。这还处于早期阶段……它确实可以工作,你可以使用它,但体验还没有达到你期望的完整和精致。

Python 和 R 在数据分析方面拥有强大且成熟的社区和生态系统。JavaScript 也有一个强大的社区和生态系统,尽管它还没有在数据分析领域达到那种强度。JavaScript 确实有一个强大的数据可视化社区和生态系统。这是一个很好的开始!这意味着数据分析的输出通常最终会在 JavaScript 中可视化。关于连接 Python 和 JavaScript 的书籍证实了这一点,但以那种方式跨语言工作对我来说听起来不方便。

JavaScript 永远不会取代 Python 和 R 在数据分析中的角色。它们在数据分析方面已经非常成熟,我不认为 JavaScript 能够超越它们。实际上,我的意图也不是让人们远离这些语言。然而,我确实想向 JavaScript 程序员展示,他们可以在不离开 JavaScript 的情况下完成所有需要做的事情。

1.7 探索 JavaScript 生态系统

JavaScript 生态系统非常庞大,对于新手来说可能会令人不知所措。经验丰富的 JavaScript 开发者将生态系统视为他们工具箱的一部分。需要完成某事?在 npm(node 包管理器)或 Bower(客户端包管理器)上可能已经存在一个能够完成你想要的工作的包。

你是否找到一个几乎能满足你需求但又不完全符合的包?大多数包都是开源的。考虑复制这个包并做出你需要的修改。

许多 JavaScript 库将帮助你进行数据处理。在写作开始时,npm 列出了 71 个关于数据分析的结果。随着这本书的接近完成,这个数字已经增长到 115。可能已经有了一个满足你需求的库。

你会发现许多用于可视化、构建用户界面、创建仪表板和构建应用的工具和框架。如 Backbone、React 和 AngularJS 这样的流行库,对于构建 Web 应用非常有用。如果你正在创建构建或自动化脚本,你可能想看看 Grunt、Gulp 或 Task-Mule。或者,在 npm 中搜索任务运行器,选择对你有意义的工具。

1.8 组装你的工具箱

当你学习成为数据处理者时,你会组装你的工具箱。每个开发者都需要工具来完成工作,而不断升级你的工具箱是这本书的核心主题之一。我对任何开发者的最重要的建议是确保你有好的工具,并且你知道如何使用它们。你的工具必须是可靠的,它们必须帮助你提高生产力,你必须了解如何很好地使用它们。

尽管这本书会介绍许多新的工具和技术,但我们不会在基本开发工具上花费任何时间。我会假设你已经有一个文本编辑器和版本控制系统,并且知道如何使用它们。

在这本书的大部分内容中,你将使用 Node.js 来开发代码,尽管你写的绝大多数代码也可以在浏览器、移动设备(使用 Ionic)或桌面(使用 Electron)上运行。为了跟随这本书的内容,你应该已经安装了 Node.js。这本书中使用的包和依赖项可以使用 npm 安装,npm 是 Node.js 的一部分,或者使用 npm 安装的 Bower。请阅读第二章以了解如何快速掌握 Node.js

你可能已经有一个偏好的测试框架。本书不涵盖自动化的单元或集成测试,但请记住,我对我最重要的代码进行自动化测试,并将其视为我一般编码实践的重要组成部分。我目前使用 Mocha 与 Chai 进行 JavaScript 单元和集成测试,尽管还有其他好的测试框架可用。最后一章介绍了一种我称之为输出测试*的测试技术;这是一种简单而有效的测试方法,当你与数据一起工作时,可以测试你的代码。

对于任何严肃的编码工作,你已经有了一种构建和部署代码的方法。技术上 JavaScript 不需要构建过程,但根据你的目标环境,它可能是有用的或必要的;例如,我经常使用 TypeScript,并使用构建过程将代码编译成 JavaScript。如果你将代码部署到云服务器,你肯定需要一个配置和部署脚本。构建和部署不是本书的重点,但我们将在第十四章中简要讨论它们。否则,我将假设你已经有一种方法将代码放入目标环境,或者这是一个你以后会解决的问题。

许多有用的库将帮助你日常的编码工作。Underscore 和 Lodash 会首先想到。无处不在的JQuery似乎正在过时,尽管它仍然包含许多有用的功能。对于处理数据集合,linq,从 C#语言移植的Microsoft LINQ,非常有用。我自己的 Data-Forge 库是处理数据的有力工具。Moment.js 是处理 JavaScript 中日期和时间的必备工具。Cheerio 是一个从 HTML 中抓取数据的库。数据可视化方面有众多库,包括但不限于 D3、Google Charts、Highcharts 和 Flot。对于数据分析和统计,有用的库包括 jStat、Mathjs 和 Formulajs。我将在本书中详细介绍各种库。

异步编码值得特别提及。Promise是管理异步编码的一种表达性和一致性的方式,我确实认为你应该了解如何使用它们。请参阅第二章以了解异步编码和 Promise 的概述。

对于你的工作来说,拥有一个良好的探索性编码环境是最重要的。这个过程对于检查、分析和理解你的数据非常重要。它通常被称为原型设计。这是一个快速构建代码的过程,逐步迭代,从简单的开始,逐步构建到更复杂的代码——我们将在整本书中经常使用这个过程。在原型设计代码的同时,我们也会深入挖掘你的数据,以理解其结构和形状。我们将在第五章中更多地讨论这一点。

在下一节中,我们将讨论数据处理过程,并详细阐述一个数据管道,这将帮助你理解如何将拼图的各个部分组合在一起。

1.9 建立你的数据处理流程

第一章的其余部分是对数据整理过程的概述。到那时,你将涵盖一个项目的数据处理流程的示例。这是一次从开始到结束的数据整理快速浏览。请注意,这并不是一个典型数据整理项目的示例——那将是困难的,因为它们都有自己独特的方面。我想要给你一个关于涉及的内容以及你将从这个书中学到的内容的味觉。

你目前还没有代码示例;在本书的其余部分还有很多时间来学习这些,书中充满了你可以亲自尝试的工作代码示例。在这里,我们旨在理解数据整理过程的示例,并为本书的其余部分奠定基础。稍后我会更深入地解释数据整理的各个方面。

1.9.1 奠定基础

我得到了使用一个有趣数据集的许可。在本书的各个示例中,我们将使用“XL Catlin 全球珊瑚礁记录”的数据。我们必须感谢昆士兰大学允许访问这些数据。我与全球珊瑚礁记录项目没有其他联系,除了对在本书的示例中使用这些数据感兴趣之外。

珊瑚礁数据是由世界各地的珊瑚礁调查团队的潜水员收集的。随着潜水员沿着他们的调查路线(在数据中称为横断面)移动,他们的相机会自动拍照,他们的传感器会读取数据(见图 1.2)。通过这些数据,珊瑚礁及其健康状况正在被绘制出来。在未来,数据收集过程将再次开始,并允许科学家比较那时和现在的珊瑚礁健康状况。

c01_02.tif

图 1.2 潜水员在珊瑚礁上测量数据。

© 海洋机构 / XL Catlin Seaview Survey / 克里斯托夫·巴伊哈切和杰恩·詹金斯。

珊瑚礁数据集是一个引人入胜的样本项目。它包含与时间相关的数据、地理定位数据、水下传感器获取的数据、照片,以及由机器学习从图像生成数据。这是一个大型数据集,对于这个项目,我提取并处理了其中我需要用来创建数据可视化仪表板的部分。有关珊瑚礁调查项目的更多信息,请观看www.youtube.com/watch?v=LBmrBOVMm5Q视频.

我需要构建一个包含表格、地图和图表的仪表板来可视化和探索珊瑚礁数据。我们将一起完成这个过程的大致概述,我会从开始到结束解释整个过程,从从原始 MySQL 数据库中捕获数据,处理这些数据,最终到创建一个显示数据的网络仪表板。在本章中,我们采取的是鸟瞰视角,并没有深入细节;然而,在后面的章节中,我们将扩展这里展示的过程的各个方面。

初始时,我得到了一些珊瑚数据的 CSV(逗号分隔值)文件样本。我探索了 CSV 文件,以对数据集有一个初步的了解。后来,我获得了访问完整 MySQL 数据库的权限。目标是把数据带入生产系统。我需要组织和处理数据,以便在具有操作性的 REST API 的实际网络应用程序中使用,该 API 为仪表板提供数据。

1.9.2 数据处理过程

让我们来看看数据处理过程:它由一系列阶段组成,如图 图 1.3 所示。通过这个过程,你获取数据,探索它,理解它,并可视化它。我们最终以一个生产就绪的格式完成数据,例如网络可视化或报告。

图 1.3 给我们一个直观且线性的过程的印象,但如果你有软件开发的经验,你可能会在这里嗅到一些问题。软件开发很少这么直接,阶段通常不会完全分开,所以不要过于担心这里展示的阶段顺序。我必须以一个有意义的顺序来展示它们,线性顺序对于本书来说是一个有用的结构。在第五章中,你将超越软件开发的线性模型,并查看一个迭代的探索性模型。

c01_03.eps

图 1.3 数据处理过程

在你阅读本章内容并逐步完成这个过程时,请记住这不是一个标准的过程;相反,这是一个特定项目数据处理过程的一个示例。这个过程的具体表现将根据你的数据和需求而有所不同。当你开始其他项目时,你自己的过程无疑会与我在本章中描述的不同。

1.9.3 规划

在开始数据处理或任何项目之前,你应该了解你在做什么。你的需求是什么?你将如何构建你的软件?可能遇到哪些问题,你将如何处理它们?你的数据是什么样的?你应该对数据提出哪些问题?当你规划一个新项目时,你应该问自己这些问题。

当你进行任何形式的软件开发时,从规划开始是很重要的。我看到的许多程序员的最大问题是他们在编码之前没有思考和规划他们的工作。根据我的经验,提高编码能力的一个最好方法是提高规划能力。

为什么?因为规划通过更好的实施和更少的错误导致更好的结果。但你必须小心不要过度规划!为不太可能发生的情况规划会导致过度设计。

在你能够规划之前,可能需要先进行探索性编码!这是一个阶段划分不够清晰的例子。如果你没有足够的信息来规划,那么就先进行探索性编码,并在对你要解决的问题有更好的理解后返回规划阶段。

c01_04.png

图 1.4 反馈循环

规划是有效反馈循环的重要组成部分(请参阅图 1.4)。规划涉及处理可能发生的错误,并找出如何避免这些错误。避免错误可以节省你大量的时间和痛苦。每次绕过反馈循环都是一次宝贵的学习经历,它提高了你对项目的理解,以及你规划和执行的能力。

为了规划这个项目,让我们记录几个最终产品的需求:

  • 创建一个网络仪表板,以方便浏览珊瑚礁数据。

  • 通过表格、图表和地图总结珊瑚礁和完成的调查。

随着你对项目理解的加深,需求通常会发生变化。如果发生这种情况,请不要担心。需求的变化是自然的,但请注意:它也可能是规划不当或范围蔓延的迹象。

在这个阶段,我规划网站的架构,如图图 1.5 所示。

c01_05.png

图 1.5 仪表板网站结构

简单的线框草图可以帮助我们巩固计划。图 1.6 是一个例子。在规划过程中,你需要考虑可能出现的各种问题。这将帮助你预先规划解决方案,但请确保你的方法平衡。如果你认为某个问题出现的可能性很小,你应该投入很少的努力来减轻它的影响。例如,以下是我可能在处理珊瑚礁数据集和构建仪表板时遇到的一些问题:

c01_06.eps

图 1.6 仪表板页面草图

  • 由于其规模,其中一些表格包含超过一百万条记录。复制 MySQL 数据库可能需要很长时间,尽管它可以运行我们需要的任何小时数。我优化这个过程的必要性很小,因为它只发生一次,所以不是时间敏感的。

  • 数据中可能存在需要清理的问题,但我在探索数据集之前不会知道(请参阅第六章关于数据清理和准备的内容)。

  • 如果仪表板中的可视化加载缓慢或性能不佳,你可以预先将数据烘焙成优化的格式(有关更多信息,请参阅第六章和第七章)。

在规划阶段,最重要的是对数据想要得到的结果有一个概念。问自己以下问题:你需要从数据中了解什么?你在向数据提出什么问题?

对于你的示例,以下是一些对珊瑚礁数据提出的问题:

  • 澳大利亚被调查的珊瑚礁的平均温度是多少?

  • 每个珊瑚礁的总覆盖面积(穿越的距离)是多少?

  • 每个珊瑚礁的平均潜水深度是多少?

通常,尽管有计划,你可能会发现事情并不按计划进行。当这种情况发生时,请休息一下,花时间重新评估情况。必要时,回到计划并再次工作。当事情出错或你需要确认自己是否走在正确的道路上时,随时回到计划。

1.9.4 数据获取、存储和检索

在这个阶段,你捕获数据并将其存储在适当的格式中。你需要将数据存储在一个你可以方便和有效地查询和检索的格式中。

数据获取始于从昆士兰大学发送的一个样本 CSV 文件。我对样本数据进行了小规模探索,以了解其感觉。样本数据足够小,以至于我可以将其加载到 Excel 中。

在编写任何代码之前,我需要先了解我要处理的内容。当查看完整的数据集时,我使用了一个名为 HeidiSQL 的 SQL 数据库查看器(图 1.7)来连接远程数据库,探索数据,并对其形成理解。

c01_07.tif

图 1.7 在 HeidiSQL 中检查 SQL 表

由于网络速度慢,远程数据访问对于探索性编码来说不会很有效。我需要将数据下载到本地数据库以实现高效访问。我还希望数据在本地,这样我就可以根据需要对其进行更改,并且我无法更改我不拥有的数据库。我计划将数据复制到本地的 MongoDB 数据库中(图 1.8)。

c01_08.png

图 1.8 从 SQL 到 MongoDB 的数据提取

你可能会想知道我为什么选择 MongoDB?好吧,选择是有些随机的。你需要选择一个适合你和你项目的数据库。我喜欢 MongoDB 的几个原因:

  • 安装很简单。

  • 它与 JavaScript 和 JSON 配合得很好。

  • 存储和检索数据都很简单。

  • 查询语言内置在编程语言中。

  • 可以存储临时或非规则数据。

  • 它的性能很好。

如果你担心将数据从 SQL 迁移到 MongoDB 会导致数据结构丢失,请不要担心:MongoDB 可以像 SQL 一样存储结构化和关系型数据。它们是不同的,MongoDB 没有 SQL 连接的便利性,它也不强制结构或关系——但这些是你可以轻松在自己的代码中模拟的功能。

与 MongoDB 相关的重要事情之一是,你不需要预先定义模式。你不必承诺数据的最终形状!这很好,因为我还不知道数据的最终形状。不使用模式可以减轻设计数据的工作负担,并允许你随着对项目理解的加深更容易地演进数据。

你将在第三章中了解更多关于 SQL、MongoDB 和其他数据源的信息。

到目前为止,是时候开始编码了。我必须编写一个脚本,从 SQL 数据库复制到 MongoDB。我开始使用 nodejs-mysql 从远程数据库将 MySQL 表加载到内存中。对于大型数据库来说,这并不现实,但这次它确实有效。在第八章和第九章中,我们将讨论处理无法装入内存的数据集。

在将 SQL 表加载到内存后,你现在使用 MongoDB API 将数据插入到我们本地的 MongoDB 数据库实例中(图 1.9)。

现在我可以组装到目前为止的代码,我有一个 Node.js 脚本可以复制 MySQL 表到 MongoDB。我现在可以轻松地将其扩展,并有一个可以复制整个 MySQL 数据库到我们本地 MongoDB 实例的脚本。

我要下载多少数据?需要多长时间?注意,我目前还没有处理数据或以任何方式转换数据。那是在我有了本地数据库并对数据有了更好的理解之后的事情。

复制这个数据库花费了很多小时,而且是在糟糕的网络连接下。像这样依赖于脆弱的外部资源的长时间运行的过程应该设计成容错和可重启的。我们将在第十四章再次讨论这些点。不过,重要的是,大多数时候脚本都在没有干预的情况下完成其工作,而且并没有占用我太多的时间。我很乐意等待这个过程完成,因为拥有数据的本地副本使得所有未来的交互都更加高效。

c01_09.eps

图 1.9 使用 Node.js 脚本下载 SQL 数据库表

现在我已经有了数据库的本地副本,我们几乎准备好开始更全面地探索数据了。不过,首先我必须检索数据。

我使用 MongoDB API 来查询本地数据库。与 SQL 不同,MongoDB 查询语言集成到 JavaScript(或根据你选择的语言的其他语言)中。

在这种情况下,你可以用一个基本的查询来应付,但你可以用 MongoDB 查询做更多的事情,包括

  • 过滤记录

  • 过滤每个记录返回的数据

  • 排序记录

  • 跳过和限制记录以查看数据的简化 窗口

这是一种获取数据的方式,但还有许多其他方式。可以使用许多不同的数据格式和数据存储解决方案。你将在第八章深入了解 MongoDB。

1.9.5 探索性编码

在这个阶段,你使用代码来深入探索你的数据,并建立对它的理解。有了更好的理解,你可以开始对数据的结构和一致性做出假设。假设必须得到验证,但你可以用代码轻松地做到这一点!

我们编写代码来探索、检查和挑逗数据。我们称之为探索性编码(也常被称为原型设计),这有助于我们在编写可能有用的代码的同时了解我们的数据。

在这个阶段,与数据的一个较小子集一起工作是很重要的。尝试处理整个数据集可能既低效又适得其反,尽管当然这取决于你特定数据集的大小。

探索性编码是通过迭代和交互过程逐步构建你的代码的过程(图 1.10)。编写几行代码,然后运行代码并检查输出,重复此过程。重复此过程同时构建你的代码和理解。

c01_10.eps

图 1.10 探索性编码过程

开始查看数据的最简单方式是使用数据库查看器。我已经使用 HeidiSQL 来查看 SQL 数据库。现在我用 Robomongo(最近更名为 Robo 3T)来查看我的本地 MongoDB 数据库的内容(图 1.11)。

c01_11.eps

图 1.11 在 Robomongo 中查看横断面集合

使用代码,我探索数据,查看第一条和最后一条记录以及它们包含的数据类型。我将前几条记录打印到控制台,看到以下内容:

> [ { _id: 10001,
    reef_name: 'North Opal Reef',
    sub_region: 'Cairns-Cooktown',
    local_region: 'Great Barrier Reef',
    country: 'Australia',
    region: 'Australia',
    latitude: -16.194318893060213,
    longitude: 145.89624754492613 },
  { _id: 10002,
    reef_name: 'North Opal Reef',
    sub_region: 'Cairns-Cooktown',
    local_region: 'Great Barrier Reef',
    country: 'Australia',
    region: 'Australia',
    latitude: -16.18198943421998,
    longitude: 145.89718533957503 },
  { _id: 10003,
    reef_name: 'North Opal Reef',
    sub_region: 'Cairns-Cooktown',
    local_region: 'Great Barrier Reef',
    country: 'Australia',
    region: 'Australia',
    latitude: -16.17732916639253,
    longitude: 145.88907464416826 } ] 

通过查看数据,我正在了解其形状,并可以提出以下问题:我有哪些列?我正在处理多少条记录?再次使用代码,我分析数据并将答案打印到控制台:

Num columns: 59
Columns:     _id,transectid,exp_id,start_datetime,…
Num records: 812 

在我的开源数据处理工具包 Data-Forge 的帮助下,我可以了解数据的类型和值的频率。我将结果打印到控制台,并进一步了解我的数据:

__index__  Type    Frequency            Column
---------  ------  -------------------  --------------------------
0          number  100                  _id
1          number  100                  transectid
2          number  100                  exp_id
3          string  100                  start_datetime
4          string  100                  end_datetime
5          string  100                  campaing
…
__index__  Value                             Frequency            Column
---------  --------------------------------  -------------------  -------
0          Australia                         31.896551724137932   region
1          Atlantic                          28.57142857142857    region
2          Southeast Asia                    16.133004926108374   region
3          Pacific                           15.024630541871922   region
… 

你将在整本书中了解更多关于使用 Data-Forge 以及它能做什么的信息,尤其是在第九章中。

现在我对数据有了基本的了解,我可以开始列出我们对它的假设。每一列是否预期只包含某种类型的数据?数据是否一致?

嗯,我目前还不知道这个。我正在处理一个大数据集,我还没有查看每一条记录。实际上,我无法手动检查每一条记录,因为我有太多记录了!然而,我可以轻松地使用代码来测试我的假设。

我编写了一个假设检查脚本,该脚本将验证我对数据的假设。这是一个检查数据库中每条记录并检查每个字段是否包含我们预期的相同类型值的 Node.js 脚本。你将在第五章中看到假设检查的代码示例。

数据有时可能令人沮丧地不一致。问题可能在大数据集中长时间隐藏。我的假设检查脚本让我安心,并减少了我在数据中遇到意外问题的可能性。

运行假设检查脚本显示,我对数据的假设并不成立。我发现我在 dive_temperature 字段中有意外值,现在可以在 Robomongo 中更仔细地检查(图 1.12)。

数据为什么损坏?这很难说。也许有几个传感器故障或间歇性工作。理解为什么错误数据以这种方式进入您的系统可能很困难。

c01_12.png

图 1.12 在 Robomongo 中检查不良温度值

如果数据不符合预期怎么办?那么我们必须纠正数据或调整我们的工作流程以适应,因此接下来我们转向数据清理和准备。

您已经完成了这一部分,但您还没有完成您的探索性编码。您可以在数据整理的所有阶段继续探索性编码。无论何时您需要尝试对数据进行新的操作,测试一个想法或测试代码,您都可以回到探索性编码以迭代和实验。您将在第五章中花费整整一章来探讨探索性编码。

1.9.6 清洁和准备

您的数据是否以您预期的格式到来?您的数据是否适合生产使用?在清洁和准备阶段,您将解决数据中的问题,使其更容易处理。您还可以对其进行标准化和重构,以便在生产中更有效地使用。

您收到的数据可能以任何格式到来!它可能包含任何数量的问题。这无关紧要;您仍然必须处理它。假设检查脚本已经发现数据不愿意符合我的预期!我现在必须清理数据,使其符合我期望的格式。

我知道我的数据包含无效的温度值。我可以从数据库中删除包含无效温度的记录,但这样我会丢失其他有用的数据。相反,我将在稍后解决这个问题,根据需要过滤掉包含无效温度的记录。

为了举例说明,让我们看看另一个问题:surveys集合中的日期/时间字段。您可以看到该字段存储为字符串,而不是 JavaScript 日期/时间对象(图 1.13)。

c01_13.png

图 1.13 调查集合中的日期/时间字段是字符串值。

日期/时间字段存储为字符串时,这可能导致它们以不一致的格式存储。实际上,我的样本数据在这方面结构良好,但让我们假设在这个例子中,有几个日期是以假设的澳大利亚时区的时间信息存储的。这类问题可能是一个隐蔽且难以发现的问题;处理日期/时间时经常遇到这样的困难。

为了修复这些数据,我编写了另一个 Node.js 脚本。对于每条记录,它检查字段并在必要时修复数据。然后必须将修复后的数据保存回数据库。这类问题并不难修复;最难的是首先发现问题。但你也可能遇到其他不那么容易修复的问题,修复它们可能很耗时。在许多情况下,在运行时处理坏数据比尝试离线修复它更有效率。

在这个阶段,你也可能考虑对数据进行归一化或标准化,以确保它适合分析,简化下游代码,或提高性能。我们将在第六章中看到更多数据问题和解决方案的例子。

1.9.7 分析

在这个阶段,你分析数据。你针对数据提出并回答具体的问题。这是理解数据并从中提取有意义的洞察的进一步步骤。

现在我有了清洗和准备好的数据,是时候进行分析了。我想从数据中获得很多信息。我想了解每次调查的总距离。我想计算每个珊瑚礁的平均水温。我想了解每个珊瑚礁的平均深度。

我首先查看每个珊瑚礁潜水员的总行程距离。我需要聚合和总结数据。聚合的形式是按珊瑚礁分组。总结的形式是计算每个珊瑚礁的行程距离总和。这是这次分析的结果:

__index__      reef_name      distance
-------------  -------------  ------------------
Opal Reef      Opal Reef      15.526000000000002
Holmes Reef    Holmes Reef    13.031
Flinders Reef  Flinders Reef  16.344
Myrmidon Reef  Myrmidon Reef  7.263999999999999
Davies Reef    Davies Reef    3.297
… 

这个代码可以很容易地扩展。例如,我已经根据珊瑚礁对数据进行分组,所以我将添加每个珊瑚礁的平均温度,现在我有总距离和平均温度:

__index__      reef_name      distance            temperature
-------------  -------------  ------------------  ------------------
Opal Reef      Opal Reef      15.526000000000002  22.625
Holmes Reef    Holmes Reef    13.031              16.487499999999997
Flinders Reef  Flinders Reef  16.344              16.60909090909091
Myrmidon Reef  Myrmidon Reef  7.263999999999999   0
… 

通过对代码的轻微修改,我可以提出类似的问题,比如平均温度按国家划分是多少。这次,我不再按珊瑚礁分组,而是按国家分组,这是看待数据的不同方式:

__index__  country    distance
---------  ---------  -----------------
Australia  Australia  350.4500000000004
Curacao    Curacao    38.48100000000001
Bonaire    Bonaire    32.39100000000001
Aruba      Aruba      8.491
Belize     Belize     38.45900000000001 

这让你对数据分析有了初步的了解,但请保持关注;你将在第九章中花费更多时间,并查看代码示例。

1.9.8 可视化

现在你来到了可能最令人兴奋的阶段。在这里,你将数据可视化并使其生动起来。这是理解数据的最终阶段。以可视化的方式呈现数据可以揭示那些原本难以察觉的洞察。

在你探索和分析数据之后,是时候以不同的视角可视化和理解它了。可视化完成了你对数据的理解,并允许你轻松地看到可能否则隐藏的东西。你希望通过可视化暴露数据中任何剩余的问题。

对于本节,我需要一个更复杂的基础设施(见图 1.14)。我需要

  • 服务器

  • 一个 REST API 来暴露你的数据

  • 一个简单的 Web 应用程序来呈现可视化

c01_14.eps

图 1.14 带有图表的 Web 应用程序基础设施

我使用 Express.js 构建了一个简单的 Web 服务器。该 Web 服务器托管一个 REST API,该 API 通过 HTTP GET 公开珊瑚礁数据。REST API 是服务器和您的 Web 应用程序之间的接口(图 1.14)。

接下来,我创建了一个简单的 Web 应用程序,该程序使用 REST API 以 JSON 格式检索数据。我的简单 Web 应用程序使用 REST API 从数据库中检索数据,我可以将数据投入使用。我在这里使用 C3 来渲染图表。我将图表添加到网页上,并使用 JavaScript 注入数据。本书稍后我们将了解更多关于 C3 的信息。

但是,我对图表的第一版有很大的问题。它显示了每个调查的温度,但数据太多,无法用条形图表示。而且这也不是我想要的。相反,我想展示每个珊瑚礁的平均温度,所以我需要将分析阶段开发出的代码移动到浏览器中。此外,我还筛选出澳大利亚的珊瑚礁数据,这有助于减少数据量。

在分析阶段代码的基础上,我筛选出非澳大利亚珊瑚礁,按珊瑚礁名称分组,然后计算每个珊瑚礁的平均温度。我们将这些数据输入到图表中。您可以在图中看到结果。(要查看颜色,请参考书籍的电子版。)

c01_15.eps

图 1.15 显示澳大利亚珊瑚礁温度的图表

1.9.9 进入生产阶段

在数据整理的最后阶段,您将您的数据管道交付给您的受众。我们将部署 Web 应用程序到生产环境。这可能是这个过程中最具挑战性的部分:将生产系统上线。通过生产,我指的是一个正在运行并被某人使用(通常是客户或公众)的系统。这就是它必须存在以触及您的受众的地方。

有时会进行一次性的数据分析然后丢弃代码。当这足以完成工作时,您不需要将代码移动到生产,因此您不会有这样的担忧和困难(幸运的是),尽管大多数时候您需要将代码移动到需要运行的地方。

您可能会将代码移动到 Web 服务、前端、移动应用或桌面应用。在将代码移动到生产后,它将自动运行或在需要时运行。通常,它将实时处理数据,并可能生成报告和可视化或执行它需要完成的任何操作。

在这种情况下,我构建了一个仪表板来显示和探索珊瑚礁数据。最终的仪表板看起来像图 1.16。

c01_16.eps

图 1.16 珊瑚礁数据仪表板

本章中已经涵盖的代码已经是 JavaScript,所以将它放入我的 JavaScript 生产环境中并不困难。这是我们在 JavaScript 中完成所有数据相关工作的主要好处之一。随着你进入探索阶段并向生产阶段过渡,你自然会更加注意你的编码。有了计划和方向,你可能会参与测试驱动开发或其他形式的自动化测试(关于这一点,请参阅第十四章)。

仪表板还有一个珊瑚礁表,你可以深入查看(图 1.17)。为了在仪表板中有效地显示数据,我已经在数据库中预先处理了各种数据分析。

c01_17.png

图 1.17 仪表板中的珊瑚礁表

为了将你的代码投入生产,你很可能会需要一个构建或部署脚本,可能两者都需要。构建脚本将执行诸如静态错误检查、连接、压缩和打包代码以供部署等任务。你的部署脚本将你的代码复制到它将运行的环境。当你部署服务器或微服务时,通常需要部署脚本。为了在云中托管你的服务器,你可能还需要一个配置脚本。这是一个创建代码将运行的环境的脚本。它可能从镜像创建一个虚拟机,然后安装依赖项——例如,Node.js 和 MongoDB。

当你的代码转移到生产环境时,你将面临一系列全新的问题:

  • 当你得到不符合你最初假设的数据更新时会发生什么?

  • 当你的代码崩溃时会发生什么?

  • 你如何知道你的代码有问题?

  • 当你的系统过载时会发生什么?

你将在第十四章中探讨这些问题以及如何处理它们。

欢迎来到数据整理的世界。你现在对数据整理项目可能的样子有了了解,你将在本书的剩余部分探索该过程的各个阶段,但在那之前,你可能需要帮助开始使用 Node.js,这就是我们在第二章要涵盖的内容。

摘要

  • 数据整理是从获取到处理和分析,最后到报告和可视化的整个数据处理过程。

  • 数据分析是数据整理的一部分,并且可以用 JavaScript 完成。

  • JavaScript 已经是一种功能强大的语言,并且随着标准的每一次更新而不断改进。

  • 就像任何编码一样,数据整理可以以多种方式处理。它从临时的丢弃编码到有纪律的高质量编码有一个范围。你在这一范围内的位置取决于你拥有的时间和代码预期的持久性。

  • 探索性编码对于原型设计和理解数据非常重要。

  • 数据整理有几个阶段:获取、清理、转换,然后是分析、报告和可视化。

  • 阶段通常不会完全分开;它们往往交织在一起,相互纠缠。

  • 你应该始终从规划开始。

  • 检查关于数据的基本假设非常重要。

  • 将代码部署到生产环境涉及许多新问题*。

2

开始使用 Node.js

本章涵盖

  • 安装 Node.js 并创建项目

  • 创建一个命令行应用程序

  • 创建一个可重用的代码库

  • 创建一个简单的带有 REST API 的 Web 服务器

  • 检查异步编程和承诺的入门指南

在这本书中,我们将经常使用 Node.js,本章将帮助您提高使用它的效率。您将学习使用 Node.js 创建项目和应用程序的基础知识。我们只涵盖基础知识,但我们将涵盖足够的内容,让您能够使用 Node.js 完成本书的其余部分。

在本章末尾,我们将介绍异步编程和承诺的基础知识。这更高级,但你需要它,因为 Node.js 和 JavaScript 在一般情况下都严重依赖于异步编程的使用。

如果您已经具备 Node.js 和异步编程的经验,那么您可能希望跳过本章的大部分内容,直接进入第三章。不过,在继续之前,请至少阅读“开始您的工具集”和“获取代码和数据”这两节。

2.1 开始您的工具集

本书的核心主题是在学习过程中构建我们的数据整理工具集。我们将在本章开始开发我们的工具集,并在继续的过程中不断扩展它。表 2.1 列出了本章中引入的工具。

表 2.1 第二章中使用的工具

平台 工具 用途
Node.js 命令行应用 运行在命令行上的各种数据整理任务的应用程序
可重用代码模块 在我们的 Node.js 项目中组织和重用代码
Node.js 与 Express 静态 Web 服务器 向浏览器提供网页和静态数据
REST API 向网络应用和可视化提供动态数据
浏览器 网页/网络应用 用于显示数据、可视化、报告等的网络应用
Node.js 和浏览器 异步编程 Node.js 和浏览器之间的连接是异步的;因此,您在 JavaScript 中的大部分编码都是异步的。
承诺 承诺是一种设计模式,有助于管理异步操作。

对于 JavaScript 开发者来说,Node.js 和浏览器是我们最基本工具。如今 JavaScript 可以在许多环境中运行,但本书主要关注数据整理发生的主要场所:

  • 在您的开发工作站上,用于常规或临时数据处理、操作和可视化

  • 在您的生产 Web 服务器上,用于自动化数据处理、访问和报告

  • 浏览器中的数据展示、编辑和可视化

在本章中,我们将学习如何在 Node.js 和浏览器下运行代码。我们将在每个部分结束时提供模板代码,您可以在本书及其它地方使用这些代码作为您自己的数据整理项目的起点。

在本书的整个过程中,我们将继续完善你的工具箱,并填充我们编写的代码、第三方库和各种软件包。随着你经验的积累,你也会采用各种方法、技术和设计模式。这些是心理工具,它们也构成了我们工具箱的重要组成部分。完成本书后,我希望你能通过日常的数据整理工作继续构建你的工具箱。

2.2 构建一个简单的报告系统

即使在学习基础知识时,有一个问题去解决总是很有用的。我们将为数据生成一个简单的报告。我们不会深入任何数据处理细节;我们将保持早期讨论的简洁和专注于 Node.js 开发。

对于这里的示例,我们将重用第一章中的礁石数据。我们还没有准备好处理导入数据(我们将在第三章中回到这个问题),所以我们从这里开始,将数据直接嵌入到我们的代码中。

让我们考虑一下本章我们将要构建的内容。首先,我们将创建一个命令行应用程序,该程序基于数据生成报告(图 2.1)。我们保持事情简单,所以数据将被硬编码到脚本中,而“报告”将是简单的命令行输出。在命令行应用程序之后,我们将创建一个托管 REST API 的 Web 服务器。服务器将托管一个简单的网页,从 Web 服务器检索报告并在浏览器中显示它(图 2.2)。

c02_01.eps

图 2.1 你首先创建的内容:一个 Node.js 命令行应用程序,用于从数据生成报告

c02_02.eps

图 2.2 我们接下来要创建的内容:在通过 REST API 公开的网页中显示数据

2.3 获取代码和数据

本书附带大量示例代码和数据。你可以运行书中的许多代码示例来亲自尝试。当我提到“你可以运行这个”或“你现在应该运行这个”时,这是一个指令,你应该找到适当的代码并运行它。当你运行代码示例时,它将从一个学术练习(阅读本书)转变为一个实际经验(运行代码以查看它做什么),这对你提高学习和知识回忆能力有巨大的影响。

你也应该对代码进行自己的修改,尝试你感兴趣的变化和实验。不要害怕动手实践并破坏代码!实验和破坏代码是学习过程的重要组成部分,这也是乐趣的一部分。

每个章节(除了第一和最后一章)在 GitHub 上都有自己的代码仓库,包含示例代码和数据。本节是关于如何设置代码以便您可以运行的简要介绍。在您需要复习时请参考本节。您可以在 GitHub 上找到代码:github.com/data-wrangling-with-javascript。浏览到该网页,您会看到代码仓库的列表。有 Chapter-2、Chapter-3 等,一直到 Chapter-13,还有一些额外的代码仓库。

2.3.1 查看代码

如果您不想运行代码(但我鼓励您运行代码以充分利用本书)或者您想更简单地开始,您还可以在线浏览和阅读代码。将您的浏览器导航到章节的仓库,您会看到代码和数据文件的列表。您可以点击任何文件来查看它并阅读内容。

现在试试看。将您的浏览器指向本章的仓库:github.com/data-wrangling-with-javascript/chapter-2

您会看到如 listing-2.2、listing-2.4 等子目录。第二章中的许多代码列表都可以在这些子目录中找到。进入每个子目录查看其中的代码文件。例如,导航到 listing-2.2 并打开 index.js。现在您可以阅读本章的代码列表 2.2。

大多数代码仓库每个列表只有一个文件,例如 listing-2.1.js、listing-2.2.js 等,尽管在几个仓库中,例如第二章,您会发现包含每个代码列表多个文件的子目录。

2.3.2 下载代码

在开始每个章节时,您应该从 GitHub 上相应的仓库下载代码和数据。您可以通过以下两种方式之一来完成:下载代码的 zip 文件或克隆代码仓库。

第一种也是最简单的方法是下载 GitHub 提供的 zip 文件。例如,对于本章,将您的浏览器导航到以下代码仓库:github.com/data-wrangling-with-javascript/chapter-2

现在找到网页右上角通常靠近顶部的“克隆”或“下载”按钮。点击此按钮,会出现一个下拉菜单;现在点击“下载 ZIP”,一个 zip 文件将下载到您的下载目录。解压此 zip 文件,现在您就有了第二章代码的副本。

*获取代码的另一种方式是克隆 Git 仓库。为此,您需要在您的 PC 上安装 Git。然后打开命令行,切换到您想要克隆仓库的目录。例如,让我们使用 Git 克隆第二章的仓库:

git clone https://github.com/data-wrangling-with-javascript/chapter-2.git 

克隆完成后,您将在子目录 Chapter-2 中拥有代码的本地副本。

2.3.3 安装 Node.js

书中的大多数代码示例都是在 Node.js 下运行的应用程序,所以不言而喻,在运行许多代码列表之前,你需要安装 Node.js。

第 2.4 节简要概述了如何选择版本并安装 Node.js。安装过程通常是直接的,尽管我不深入细节,因为它取决于你的操作系统。

2.3.4 安装依赖项

对于书中许多示例,你需要使用 npm(Node.js 包管理器)或 Bower(客户端包管理器)安装第三方依赖项。

在大多数情况下,每个代码列表(尽管有时几个代码列表会合并)都是一个可运行的 Node.js 应用程序或网络应用程序。每个应用程序都有自己的依赖项集,在运行代码之前必须安装。

关键是要寻找 package.json 和/或 bower.json 文件。这些文件会告诉你,在运行代码之前必须安装外部包。如果你尝试在没有先安装依赖项的情况下运行代码,它将不会工作。

对于 Node.js 项目,npm 包通过在 package.json 相同的目录下运行以下命令安装:

npm install 

对于网络应用程序项目,使用以下命令(在 bower.json 相同的目录下)安装包:

bower install 

安装完成后,你将拥有运行代码所需的所有依赖项。

2.3.5 运行 Node.js 代码

运行代码的方式取决于它是一个 Node.js 项目还是一个网络应用程序项目。

你可以通过以下特征识别一个 Node.js 项目或应用程序:它将有一个 index.js(作为应用程序入口点的 JavaScript 代码)和一个 package.json(跟踪应用程序的依赖项)。在 Node.js 世界中,使用 index.js 作为入口点文件的名称是一种常见的约定。

要运行本书中的 Node.js 示例脚本,你需要打开命令行,切换到 Node.js 项目目录(与 index.js 或 package.json 相同的目录),并运行 node index.js。例如,很快你将像这样运行第二章的 listing-2.2:

cd Chapter-2
cd listing-2.2
node index.js 

大多数其他章节每个列表都有一个文件——例如,第三章的 listing-3.1,你可以这样运行:

cd Chapter-3
node listing-3.1.js 

如果你确保已经安装了依赖项(通过运行 npm install)并且知道要运行哪个脚本,运行 Node.js 脚本很简单。

2.3.6 运行网络应用程序

书中的许多示例都是需要网络服务器来托管它们的网络应用程序。

你会知道这些项目,因为它们通常会有一个 index.html(网络应用程序的主要 HTML 文件)或以列表命名的 HTML 文件(例如,listing-1.3.html),并且通常还会有一个 bower.json(跟踪依赖项)和经常还有一个 app.js(网络应用程序的 JavaScript 代码)。

一些更复杂的 Web 应用需要自定义 Node.js Web 服务器,而这些 Web 应用通常包含在 Node.js 项目的public子目录中。要运行这些 Web 应用,你需要运行 Node.js 应用:

node index.js 

现在,请将你的浏览器导航到localhost:3000/,Web 应用将在浏览器中渲染。一些较简单的 Web 应用不需要自定义 Node.js Web 服务器。在这些情况下,我们将使用名为 live-server 的工具托管 Web 应用。这是一个简单的命令行 Web 服务器,你可以按照以下方式在你的系统上全局安装 live-server:

npm install -g live-server 

我们可以在包含 index.html 的目录中不带任何参数运行 live-server:

live-server 

这将启动 Web 应用的 Web 服务器并自动打开指向它的浏览器。这是原型化不需要(或至少目前不需要)自定义 Web 服务器的 Web 应用和可视化的一种方便方式。我们将在第五章中了解更多关于 live-server 以及如何使用它的信息。

2.3.7 获取数据

许多代码仓库也包含数据文件。通常为 CSV(逗号分隔值)或 JSON(JavaScript 对象表示法)数据文件。要找到这些数据文件,搜索以.csv 或.json 扩展名结尾的文件。

代码列表被设置为自动读取这些数据文件,但查看数据并对其有所了解是个好主意。CSV 文件可以在 Excel 或你喜欢的电子表格查看器中打开。CSV 和 JSON 文件也可以直接在文本编辑器中打开以查看原始数据。

GitHub 仓库中的数据文件被用于书中的许多代码示例,但它们也为你提供了使用方式,无论是用于你自己的原型、数据处理管道还是可视化。

2.3.8 获取第二章的代码

我们已经简要概述了如何获取代码、安装依赖项以及运行书中的各种列表。在未来的章节中,获取代码和数据的说明将更加简短,所以当你需要这方面的帮助时,请随时回到这一章。

现在请获取第二章的代码。下载 zip 文件或克隆github.com/data-wrangling-with-javascript/chapter-2的 Chapter-2 仓库。接下来,打开命令行,切换到 Chapter-2 目录,你就可以开始运行代码示例了:

cd Chapter-2 

在运行列表中的代码之前,例如列表 2.2(将在下一节中展示),请记住切换到目录并安装依赖项:

cd listing-2.2
npm install 

现在你可以按照以下步骤运行代码:

node index.js 

让我们开始吧!

2.4 安装 Node.js

Node.js 是我们的核心工具,所以请确保在您的开发电脑上安装它。它支持 Windows、Mac 或 Linux 系统。请从 nodejs.org/en/download 下载适用于您平台的安装程序。安装过程很简单:运行安装程序,然后按照提示操作。Node.js 也可以通过各种包管理器安装,例如 Linux 上的 apt-get。您可以在 nodejs.org/en/download/package-manager/ 上了解更多信息。

2.4.1 检查您的 Node.js 版本

在我们使用 Node.js 进行开发之前,让我们检查它是否已正确安装并达到预期的版本。打开命令行并运行

node --version 

您应该看到如图 2.3 所示的输出。

c02_03.png

图 2.3 启动命令行并验证您已安装正确的 Node.js 版本。

Node.js 应该已经添加到您的 path 中,因此您可以从任何地方运行它。如果您无法从命令行运行 Node.js,请尝试重新启动命令行或尝试注销并重新登录。最后尝试重新启动您的电脑。根据您的系统,您可能需要重新启动以使更新的路径可用,或者您可能需要自己配置路径。

c02_04.png

图 2.4 Node.js REPL 是尝试小段代码和测试第三方库的绝佳方式。

2.5 使用 Node.js

让我们创建一个 Node.js 应用程序!

首先,我们将创建一个项目。然后我们将进入编码阶段:我们将构建一个命令行应用程序,然后是一个简单的 Web 服务器。

2.5.1 创建 Node.js 项目

一个 Node.js 项目是一个包含构成您的 Node.js 应用程序的 JavaScript 代码和依赖项的目录。它由各种文件组成:JavaScript 代码文件、package.json 和一个 node_modules 子目录 (图 2.5)。

c02_05.png

图 2.5 Node.js 项目

一个 Node.js 项目可以包含任意数量的 JavaScript 文件,这些文件可以是入口点(可以从命令行运行)、可重用的代码模块,或者两者兼而有之(这对于测试您的代码可能很有用)。按照惯例,主入口点通常被称为 index.js。

node_modules 子目录包含使用 npm(Node 包管理器)安装的第三方包。文件 package.json 包含有关项目的信息并记录了已安装的依赖项。

npm 初始化

您通常通过使用 npm 创建初始的 package.json 来开始 Node.js 项目:

cd my-project
npm init -y 

–y 参数指示 npm 填写包文件中的详细信息(参见 列表 2.1 中的结果包文件)。如果我们计划将来将包公开(例如,通过 npm 分发),那么我们稍后必须回去修改它。否则,我们可以省略 –y 参数,npm init 将会交互式地提示这些详细信息。

列表 2.1 生成的 npm 包文件

{
 "name": "Code",    ①  
 "version": "1.0.0",    ①  
  "description": "",
 "main": "index.js",    ②  
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
 "license": "ISC"    ①  
} 

添加第一个 JavaScript 文件

为了开始,让我们创建一个 Hello world 程序。创建一个空的 index.js 文件,并添加一个 console.log 语句,将 Hello world 打印到控制台(如下所示)。

列表 2.2 您的第一个脚本:Hello world

"use strict";

console.log(“Hello world!”); 

你可以在 GitHub 仓库 Chapter-2 的列表-2.2 子目录中找到这段代码,所以你不必亲自输入。如果你遵循了“获取代码和数据”中的说明,你已经切换到了列表-2.2 目录并安装了依赖项,但为了回顾,让我们再看一遍:

cd listing-2.2
npm install 

现在按照以下方式运行代码:

node index.js 

如果你只创建了项目并手动输入了代码,你可以这样运行它:

cd my-project
node your-script.js 

你可能会想知道为什么你需要为这样一个简单的代码示例安装依赖项。好吧,说实话——你不需要!我想让你养成这样做的习惯,因为大多数示例确实有依赖项,你确实需要在运行代码之前运行 npm install 来下载依赖项。尽管如此,你只需要在每个项目中做一次。一旦安装了依赖项,你可以多次运行代码列表。

运行脚本后,我们看到 Hello world! 被打印到控制台。

注意我们执行了 node 应用程序并指定了我们的脚本文件名(index.js)。运行 Node.js 脚本的一般模式是这样的:

node <script-file.js> 

<script-file.js> 替换为你想要运行的脚本。

安装 npm 依赖项

现在让我们将第三方依赖项安装到您新创建的 Node.js 项目中。我选择在这里安装 moment,因为它是最适合处理日期的 JavaScript 库,我知道当你需要处理日期和时间时,它会让你更容易。

如果你正在使用一个新的 Node.js 项目,你可以像这样将 moment 包安装到你的项目中:

npm install --save moment 

注意 --save 参数将依赖项保存到 package.json 并跟踪版本号(更新后的文件显示在 列表 2.3 中)。随着我们安装每个依赖项,它们都会被记录下来,这意味着我们可以很容易地稍后使用此命令再次恢复这些包:

npm install 

列表-2.3 在 GitHub 仓库中没有代码,但如果你想尝试这样做,你可以通过将 moment 依赖项安装到列表-2.2 代码中来练习。

列表 2.3 包含 moment 依赖项的 package.json

{
  "name": "Code",
  "version": "1.0.0",
 "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
 "dependencies": {    ①  
 "moment": "2.18.1"    ②  
 }    ①  
} 

安装依赖项并跟踪已安装的版本是很好的。这意味着我们不需要将依赖项提交到版本控制。因为我们可以在任何时候恢复包(使用npm install),我们可以简化我们的项目,这使得对于新开发者或在我们将代码安装到新 PC 上时,克隆或复制代码变得超级快。

寻找有用的包

我们可以使用 npm 安装我们需要的任何数量的包,并且手头上有许多有用的包。将你的浏览器指向www.npmjs.com并查看。输入一个搜索字符串,你会找到现有的代码库和命令行工具,帮助你完成各种任务。

2.5.2 创建命令行应用程序

命令行应用程序对于各种数据处理、转换和分析任务都很有用。我们的目标是创建一个简单的应用程序,从数据生成报告。

我们已经向我们的 Node.js 项目添加了一个脚本,该脚本将“Hello world”打印到控制台。这已经是一个基本的命令行应用程序,但我们需要它做更多的事情。你的应用程序的输出是一个简单的报告,你可以通过图 2.6 中的示例看到。

c02_06.png

图 2.6 你简单命令行应用程序的输出:打印关于我们数据的基本报告

为了使入门章节的内容简单,我们将直接在脚本中包含数据。这并不具有可扩展性或便利性,理想情况下,我们会从文件或数据库中加载数据,尽管我们还没有介绍如何导入数据,所以这是我们在第三章中会再次讨论的内容。

图 2.7 显示了硬编码在 JavaScript 文件中的数据。我们正在重用第一章中关于礁石数据的一小部分。我们的命令行应用程序将打印硬编码数据的简单摘要:行数、列数和列名。你可以在列表 2.4 中看到代码;确保你跳入代码仓库并运行此脚本以查看输出。

c02_07.png

图 2.7 在你的 JavaScript 文件 index.js 中嵌入的简单硬编码数据

列表 2.4 从你的数据生成简单报告的基本命令行应用程序

"use strict";

const data = ... array of data, see GitHub code for details ...    ①  

function generateReport (data) {    ②  
 const columns = Object.keys(data[0]); {    ②  
 return {    ②  
 numRows: data.length,    ②  
 numColumns: columns.length,    ②  
 columnNames: columns,    ②  
 };    ②  
};    ②  

const report = generateReport(data);    ③  

console.log("Number of rows: " + report.numRows); );    ③  
console.log("Number of columns: " + report.numColumns); );    ③  
console.log("Columns: " + report.columnNames.join(", ")););    ③   

生成这个报告远非火箭科学,但在这里我们想要专注于创建一个简单的命令行应用程序。

命令行应用程序的一般模式

以下列表为你提供了未来命令行应用程序的一般模式和模板。添加你需要的逻辑。

列表 2.5 命令行应用程序的一般模式

"use strict";

const yargs = require('yargs');
const argv = yargs.argv;    ①  
const assert = require('chai').assert;    ②  

//
// App specific module imports here.
//

//
// Argument checking and preprocessing here.
//

//
// Implement the code for the app here.
// 

你可以使用命令行应用程序做更多的事情,但现在这些已经足够了。请注意,我已经在模板中添加了额外的 npm 模块。Yargs 用于读取命令行输入参数。Chai 断言库用于验证、错误处理和报告。

2.5.3 创建代码库

有时我们可能会在一个单独的文件中编写整个命令行应用程序,但我们只能在任务足够小的时候这样做。随着脚本的扩展,我们可以通过抽象代码并将其提取到可重用模块中来降低复杂性。

让我们将 generateReport 函数移动到一个单独的代码模块中。为此,创建一个新的 JavaScript 文件,例如 generate-report.js。将 generateReport 移动到这个新文件中,如下列表所示。通过将函数分配给特别命名的 Node.js 变量 module.exports,函数从代码模块中导出。

列表 2.6 将 generateReport 函数移动到可重用代码模块

"use strict";

function generateReport (data) {
    const columns = Object.keys(data[0]);
    return {
        numRows: data.length,
        numColumns: columns.length,
        columnNames: columns,
    };
};

module.exports = generateReport;    ①   

现在可以使用 Node 的 require 函数将代码模块导入到您的命令行应用程序(或者实际上任何其他代码模块)中,如下列表 2.7 所示。这与您已经看到的导入第三方 npm 库非常相似,尽管为了导入我们自己的库,我们必须指定一个绝对或相对路径。在列表 2.7 中,我们使用路径 ./generate-report.js 加载我们的模块,因为这表明模块位于同一目录中。列表 2.6 和 2.7 一起工作;您将在代码仓库中找到它们,要尝试它们,您只需要运行 index.js 脚本。

列表 2.7 将 generateReport 函数导入到您的命令行应用程序

"use strict";

const data = ... array of data, see GitHub code for details ...

const generateReport = require(‘./generate-report.js’);    ①  

const report = generateReport(data);    ②  

console.log("Number of rows: " + report.numRows);
console.log("Number of columns: " + report.numColumns);
console.log("Columns: " + report.columnNames.join(", ")); 

代码库的一般模式

以下列表是一个模板,您可以使用它来创建可重用工具包函数。

列表 2.8 导出可重用工具包函数的一般模式

"use strict";

// Imports here.

module.exports = function (... parameters ...) {

    //
    // Code
    //

    // Return result.
}; 

注意在列表 2.8 中,只导出了一个单个函数。我们也可以导出一个对象,这允许我们导出一个函数库。以下列表展示了这一点的例子。

列表 2.9 导出可重用函数库的一般模式

"use strict";

// Imports here.

module.exports = {
    someFunction1: function (param1, param2, etc) {
        //
        // Code
        //

        // Return result
    },

 someFunction2: function (param1, param2, etc) {
        //
        // Code
        //

        // Return result
    },
}; 

2.5.4 创建简单的 Web 服务器

我们在 Node.js 中创建了一个命令行应用程序,现在我们将学习如何创建一个简单的 Web 服务器。我们需要 Web 服务器的理由是我们可以构建 Web 应用和可视化。首先,我们将创建最简单的 Web 服务器(输出显示在图 2.8 中)。然后我们将添加对静态文件的支持,这为我们构建 Web 可视化提供了一个基本的基础。最后,我们将添加一个 REST API,允许我们根据动态数据创建 Web 可视化,例如从数据库加载的数据或服务器动态处理的数据。

c02_08.png

图 2.8 最简单 Web 服务器的输出

您的 Web 服务器第一次迭代相当基础,远未达到生产就绪状态,但这就是您开始原型设计 Web 可视化所需的一切。然而,在某个时刻,我们希望扩展并服务于成千上万的用户,但我们将生产问题留到第十四章讨论,这里我们专注于基础知识。

您应该注意,Node.js Web 服务器仍然是一个命令行应用程序。我们将继续构建我们已学到的内容,尽管我们现在正在提高复杂性并创建一个客户端/服务器类型的应用程序。

安装 Express

要构建我们的 Web 服务器,我们将使用 Express:一个流行的 Node.js 框架,用于构建 Web 服务器。我们可以使用 npm 在新的 Node.js 项目中安装 Express,如下所示:

npm install -–save express 

尽管如果您正在 GitHub 仓库中运行列表 2.10 中的示例代码,您需要在列表-10 子目录中运行npm install以恢复已注册的 Express 依赖项。

最简单的 Web 服务器

最简单的 Web 服务器是通过实例化一个 Express 应用并指示它监听传入的 HTTP 请求来创建的。您的第一个 Web 服务器处理单个路由并返回文本“这是一个网页!”您可以在列表 2.10 中看到这一点,该列表显示了您第一个和最简单的 Web 服务器的 index.js 文件.

列表 2.10 最简单的 Web 服务器

"use strict";

const express = require('express');    ①  
const app = express();    ①  

app.get("/", (req, res) => {    ②  
 res.send("This is a web page!");    ②  
});    ②  

app.listen(3000, () => {    ③  
 console.log("Web server listening on port 3000!");    ③  
});    ③   

您应该尝试运行此代码。切换到列表-2.10 子目录,使用npm install安装依赖项,然后运行node index.js。现在我们有一个 Node.js Web 服务器!将您的浏览器指向localhost:3000以查看网页。您将在浏览器中看到“这是一个网页!”(如图所示)。

提供静态文件

打印“这是一个网页!”的网页并不特别有用,但我们可以轻松地将其扩展以提供静态文件,这些文件是任何网页的基础,并且是简单的 Web 资产,如 HTML、JavaScript 和 CSS 文件。我们将在 Node.js 项目下有一个公共子目录,我们将在这里保存 Web 应用的静态资产(参见图 2.9)。

要向我们的 Web 服务器添加静态文件,我们将使用 Express 静态文件中间件。您可以在以下列表中看到扩展的 Web 服务器的代码。

列表 2.11 向您的 Web 服务器添加静态文件

"use strict";

const express = require('express');
const path = require('path');

const app = express();

const staticFilesPath = path.join(__dirname, "public");    ①  
const staticFilesMiddleWare = express.static(staticFilesPath);    ②  
app.use("/", staticFilesMiddleWare);    ③  

app.listen(3000, () => {
    console.log("Web server listening on port 3000!");
}); 

我们现在的 Web 服务器可以提供静态文件,我们可以创建一个基本的 HTML 页面来测试它。您可以在以下列表中看到您扩展的最简单的网页的 HTML 文件;此文件位于public子目录中,名为 index.html。

c02_09.eps

图 2.9 静态文件从公共子目录中提供服务。

列表 2.12 最简单的网页

<!doctype html>
<html lang="en">
    <head>
        <title>Simplest web page</title>
    </head>
    <body>
    This is a static web page!
    </body>
</html> 

现在再次运行您的 Web 服务器,并将您的 Web 浏览器指向localhost:3000。您应该看到“这是一个静态网页!”有关 Express 的更多信息,请参阅 Express 网页www.expressjs.com

提供静态数据文件

我们现在有了构建可以托管基本 Web 可视化的 Web 服务器的工具。我们甚至有一种简单的方法将数据传输到 Web 浏览器以供我们的可视化使用!

除了常规的 Web 资产外,我们还可以将 静态数据(例如,CSV 和 JSON 文件)放入我们的 public 子目录中,然后我们可以通过 AJAX HTTP 请求将它们加载到我们的网页中。你可能已经注意到,在 图 2.9 中,我也偷偷地将一个 CSV 数据文件放入了公共子目录中。

添加 REST API

使用静态数据对于入门或原型设计来说很棒,甚至可能就是你所需要的全部!然而,如果你需要从数据库访问数据或在数据被发送到浏览器之前动态处理数据,那么你需要一个 REST API。在接下来的示例中,我们将使用我们之前创建的 generateReport 函数在服务器上生成我们的报告。我们并没有做任何特别复杂的事情,只是在一个网页中显示格式化的数据,这在 图 2.10 中可以看到。

要构建 REST API,我们必须定义 路由,这些路由通过 URL 来访问,通过 HTTP 请求检索动态数据。你可以在 图 2.11 中看到一个 REST API 的示例,我们在浏览器中导航到 localhost:3000/rest/data 来查看从 REST API 获取的数据。

我们可以通过调用 Express 的 get 函数向现有的 Web 服务器添加一个路由。我们必须指定路由并提供一个处理程序。例如,在下面的列表中,我们指定路由为 /rest/report,作为响应,你以 JSON 格式返回你的数据。现在你可以说你已经配置了你的 Web 服务器来处理 /rest/data 路由的 HTTP GET 请求。

列表 2.13 向您的 Web 服务器添加 REST API 以动态生成报告

"use strict";

const express = require('express');
const path = require('path');
const generateReport = require(‘./generate-report.js’);

const app = express();

const staticFilesPath = path.join(__dirname, "public");
const staticFilesMiddleWare = express.static(staticFilesPath);
app.use("/", staticFilesMiddleWare);

const data = ... hard-coded data ...

app.get("/rest/data", (req, res) => {    ①  
 const report = generateReport(data);    ②  
 res.json(report);    ③  
});    ①  

app.listen(3000, () => {
    console.log("Web server listening on port 3000!");
}); 

在 列表 2.13 中,我们返回的是从硬编码的数据生成的报告。数据永远不会改变,所以在这种情况下技术上并不需要使用 REST API。我们可以使用静态数据,尽管我希望你能理解我们现在已经准备好将这个 Web 应用程序扩展到使用真正的数据库,而不是硬编码的数据,这一点我们将在第三章中进一步探讨。

c02_10.png

图 2.10 在服务器上生成基本报告并在浏览器中显示

c02_11.png

图 2.11 在浏览器中查看 REST API 的 JSON 数据

我们可以使用更多的 get 函数调用,向我们的 Web 服务器添加我们需要的任意多的路由。请注意,HTTP GET 通常用于从 Web 服务器检索数据。我们也可以通过使用 Express 的 post 函数处理 HTTP POST 请求来向 Web 服务器推送数据。

如果我们有像传统的 jQuery、更现代的 Axios 或 AngularJS 的 $http 服务这样的库,使用 AJAX 就很简单了。查询 REST API 并在浏览器中显示数据的代码如下所示。为了方便,JavaScript 代码已被直接嵌入到 HTML 文件中。

列表 2.14 简单的网页,显示从 REST API 获取的报告

<!doctype html>
<html lang="en">
    <head>
        <title>Simple report</title>
    </head>
    <body>
 <script src="bower_components/jquery/dist/jquery.js"></script>    ①  
        <script>
 $.getJSON("/rest/data", function (report) {    ②  
 document.write(    ③  
 "Num rows: " + report.numRows + "\r\n" +    ③  
 "Num columns: " + report.numColumns + "\r\n" +    ③  
 "Columns: " + report.columns.join(', ')    ③  
 );    ③  
 });    ②  
        </script>
    </body>
</html> 

运行这段代码比之前复杂一些。像往常一样,我们需要为 Node.js 项目安装依赖项:

cd listing-2.13-and-2.14
npm install 

但现在我们还有一个位于public子目录下的 web 应用程序项目。我们将使用 Bower 来安装其依赖项:

cd public
bower install 

现在你可以切换回 Node.js 项目并启动 web 服务器:

cd ..
node index.js 

将你的浏览器指向localhost:3000你现在正在查看一个使用 AJAX 从 web 服务器检索数据的 web 应用。

我们现在在哪里?我们有了创建用于处理数据或其他任务的命令行工具的能力。我们可以构建一个简单的 web 服务器来托管 web 应用或可视化。我们已经扩展了我们的 web 应用以使用 REST API,这将允许我们在本书的后面部分对数据进行服务器端处理,或者将 web 应用连接到数据库,这两者我们都会在本书的后面部分探讨。这些是我们在这本书中将依赖的基本工具;然而,我们仍然需要讨论异步编程。

2.6 异步编程

为什么异步编程很重要,为什么我们需要尽早解决它?这是因为 JavaScript 和 Node.js 高度依赖于异步编程范式,而且在这本书中我们将多次使用它。本章的其余部分是对异步编程的简要介绍。这是一个困难的话题,但我们现在解决它非常重要。

当用 JavaScript 编程时,我们经常会发现自己在进行异步编程。浏览器和 web 服务器之间的连接本质上是异步的,Node.js 的大部分设计都是围绕这个概念。我们在本章中已经进行了异步编程。你注意到了吗?在上一个代码示例中,当我们通过调用listen函数启动 web 服务器时,这是我们第一次接触异步编程。

同步编程和异步编程有什么区别?在同步编程中,每一行代码按顺序完成:在下一行代码执行之前,上一行代码的效果已经完成。这是大多数编程语言默认的编程方式。以这种方式进行编程时,很容易理解正在发生的事情,也容易预测将要发生的事情。这是因为同步编程中事情是按顺序一个接一个发生的,这种方式是可预测的。但是,在异步编程中,我们发现代码可以与主代码流并行执行。这种执行顺序不固定的可能性使得理解代码的流程变得更加困难,也更难预测最终的代码序列。

在 Node.js 中,异步编码特别常见。在某些情况下——例如,文件操作——Node.js 提供了同步和异步两种选项。你应该使用哪一种?嗯,这取决于你的情况。当你能够避免使用同步编码时,同步编码当然更简单、更容易。在其他情况下,例如,处理 REST API 和数据库时,你必须进行异步编码,因为 API 没有提供其他选项。

在这本书中,我尽量只使用异步编码,即使可能使用函数的同步版本。我这样做有两个原因。一是我想展示一致性,并希望从长远来看这能减少混淆。二是当在生产系统上工作时,我倾向于更喜欢异步编码。除了大多数 API 强制要求这样做之外,这也是 Node.js 文化的一部分。Node.js 被设计为首先考虑异步:这就是我们如何用它来构建响应式和性能良好的服务器,而且你如果不运行异步编码,在 Node.js 中很难走得很远。

在接下来的章节中,我将解释同步和异步编码之间的区别,以及为什么和何时需要异步编码。我会概述你在进行异步编码时将面临的三种主要困难,然后解释承诺如何帮助缓解这些问题。最后,我将简要介绍 Node.js 最新版本中的新 JavaScript 关键字asyncawait,这些关键字使异步编码变得更加容易。

2.6.1 加载单个文件

让我们考虑一个最简单的异步编码的实际例子:加载文件。比如说你想加载一个名为 bicycle_routes.txt 的数据文件。你可能想转换文件中的数据,将数据交付给 Web 应用,或者从数据中生成报告。无论你想做什么,首先你必须加载这个文件。

c02_12.eps

图 2.12 加载文件时的同步代码流程

图 2.12 展示了如何同步地完成这项操作。我们调用 Node 的readFileSync函数来开始文件加载。然后文件被加载到内存中。之后,控制权返回到调用readFileSync之后的代码行。从那里,你的代码继续执行,我们可以处理从文件中加载的数据。

同步编码简单且易于解释。但它有一个大问题:在同步操作期间,它会阻塞主线程执行任何其他工作(图 2.13)。

c02_13.eps

图 2.13 同步操作期间主线程被阻塞。

当基于 UI 的应用程序发生阻塞操作时,UI 变得无响应。当这种情况在 Node.js 中发生时,你的服务器变得无响应:在同步操作期间,服务器无法再响应 HTTP 请求。如果操作很快完成,就像在这个简单的例子中一样,这几乎没有影响:进入的 HTTP 请求被排队,并在主线程解除阻塞后立即执行。

然而,如果同步操作很长,或者你有多个连续的同步操作,那么进入的 HTTP 请求最终会超时,导致你的用户在浏览器中看到错误消息,而不是看到你的网页。

这是一个随着你使用的同步操作越来越多而变得更大的问题。随着你使用越来越多的同步操作,你逐渐降低了服务器处理并发用户的能力。

在其他语言和环境中使用同步编码是正常的情况下,我们可以通过将这种资源密集型操作委托给 工作线程 来避免这个问题。然而,通常我们无法在被认为是 单线程 的 Node.js 中使用这样的线程。

为了避免阻塞主线程,我们必须使用异步编码。在下一个例子中,我们将使用 Node 的异步文件加载函数:readFile。调用此函数开始文件加载操作,并立即返回调用代码。在这个过程中,文件内容被 异步 加载到内存中。当文件加载操作完成时,你的回调函数被调用,文件数据被发送给你 (图 2.14)。

回调是一个 JavaScript 函数,当单个异步操作完成时,它会自动为你调用。对于正常的(例如,非 Promise)回调,无论操作是否失败,回调最终都会被调用——通过将错误对象传递给回调来指示失败发生的情况。我们稍后将回到错误处理来进一步探讨。

c02_14.eps

图 2.14 加载文件时的异步代码流

现在我们正在使用异步编码,文件加载操作不会锁定主线程,使其可以空闲下来处理其他工作,例如响应用户请求 (图 2.15)。

c02_15.eps

图 2.15 在异步操作期间主线程不会被阻塞。

你还在吗?理解异步编码可能很困难,但对于使用 Node.js 来说是必不可少的。我已经使用单个文件的加载作为 Node.js 中使用回调的异步编码的简单示例,但 Node.js 应用程序通常由许多这样的异步操作构建。为了继续这个例子,让我们扩展到加载多个文件。

2.6.2 加载多个文件

我们无法仅使用单个异步操作创建 Node.js 应用程序。任何体量较大的 Node.js 应用程序都将由多个异步操作组成,这些操作一个接一个地按顺序排列或交织在一起,以构建对 HTTP 请求的响应。

让我们扩展这个例子,加载多个文件。比如说,我们需要加载一系列文件。这些文件按国家分开,例如,bicycle_routes_usa.txt、bicycle_routes_australia.txt、bicycle_routes_england.txt 等等。我们需要加载这些文件并将它们合并以访问完整的数据集。以同步方式执行此操作会导致一个大问题;它将锁定主线程一段时间(图 2.16)。

c02_16.eps

图 2.16 主线程被多个连续的同步操作阻塞。

使用异步编码,我们可以以两种不同的方式处理这个问题。我们可以依次顺序执行异步操作,或者并行执行它们。以这种方式依次顺序执行异步操作(图 2.17)使得它们看起来像一系列同步操作,只不过在它们进行的过程中主线程不会被阻塞。

c02_17.eps

图 2.17 顺序异步操作发生在主线程之外。

在这里,我们遇到了基于回调的 JavaScript 异步编码的第一个大问题。图 2.17 中的每个回调都必须调用后续的异步操作并设置其回调。这导致了回调函数的嵌套:每个回调的代码都在一个新的缩进级别上定义。随着我们的异步操作链变长,缩进也变得更深。嵌套函数和大量的缩进使得代码难以阅读和维护,这就是问题所在;这是一个非常普遍的问题,它有一个名字:回调地狱

为了更好的性能和吞吐量,我们可能需要并行执行多个异步操作(图 2.18)。这可能会压缩完成所有工作所需的时间。这意味着 CPU 和 IO 系统可以尽可能快地将所有文件加载到内存中,但它仍然这样做而不阻塞主线程。

c02_18.eps

图 2.18 并行运行的多项异步操作

在介绍了并行异步操作之后,我们遇到了基于回调的异步编码的下一个大问题。注意当我们并行运行异步操作时引入的额外复杂性:回调可以以任何顺序被调用!

我们如何知道所有回调都已完成?它们可以以任何顺序完成,因此任何依赖于所有三个回调完成的后续操作都必须编写成可以由任何一个回调触发的代码。然后,最后执行的回调将触发后续操作。这个新问题完全是关于管理多个独立的回调。

使用传统的回调来解决这些问题通常会导致代码丑陋且脆弱。不过,很快我们就会了解到承诺(promises),它能够以优雅的方式处理这些问题,但首先我们需要理解异步错误处理的工作原理。

2.6.3 错误处理

在传统的异步编码中,无法使用 try/catch 语句来检测和处理错误。我们不能使用它,因为它无法检测异步代码中的错误。相反,我们必须通过检查作为回调的第一个可选参数传递的错误对象来处理错误。当这个参数为null时,表示没有发生错误;否则,我们可以调查错误对象以确定错误的性质。

这种简单的机制在处理单个异步操作时是可行的。当我们执行多个顺序的异步操作时,情况变得更加复杂,因为任何操作都可能失败,而且它们可能以任何可能的顺序失败。

当我们执行并行异步操作或并行和顺序操作的组合时,情况变得更加复杂,并且越来越难以管理。考虑一下当你的第二个文件加载失败时会发生什么(图 2.19)。当这种情况发生时,任何依赖于所有三个文件的后续操作也必须失败。我们如何实现这一点?同样,回调可以以任何顺序被调用,因此每个回调都需要检测组合操作的成功或失败,但只有最后一个回调应该调用错误处理逻辑。管理 Node.js 回调可能会很困难,但请不要气馁。一会儿我们将介绍承诺(promises),这是一种处理这些情况更好的方式。

c02_19.eps

图 2.19 其中一个异步操作失败了。

异步错误处理将我们带到了基于回调的异步编码的第三个也是最后一个大问题:每个回调都必须处理自己的错误。例如,在图 2.19 中,每个回调都必须定义自己的错误处理程序。如果能在一个回调之间共享一个单一的错误处理程序会更好。管理多个回调的逻辑变得越来越复杂,因为它现在必须理解是否任何操作失败了。

由于你在进行异步编码时面临的困难,异步编码被认为是困难的也就不足为奇了。现在,是时候介绍承诺(promises)了,它将帮助你管理和简化你的异步编码。

2.6.4 使用承诺进行异步编码

随着异步编程复杂性的迅速增加,承诺(Promises)设计模式可以极大地帮助。承诺(Promises)允许我们将异步操作连接和交织在一起。它们帮助我们同时管理多个操作,并自动为我们收集所有回调。

通过承诺(Promises),我们希望解决基于回调的异步编程中的以下问题:

  1. 回调地狱 —承诺(Promises)有助于最小化回调的嵌套。

  2. 回调顺序 —承诺(Promises)自动将多个回调编织在一起,这意味着你不再关心它们的完成顺序。

  3. 错误处理 —承诺(Promises)允许在任何异步操作链中插入错误处理器。我们可以根据需要共享错误处理器,在任意数量的异步操作之间共享。

也许我们应该首先考虑承诺(promise)的确切含义。一个 承诺(promise) 是一个封装异步操作并承诺在未来某个时间点提供结果(或错误)的对象。承诺(Promises)为我们提供了一种词汇,可以以几乎看起来像同步操作序列的方式表达异步操作的链。你承诺词汇中的主要词汇是 thenall* 和 catch

Then

Then 用于连接一系列异步操作(图 2.20)。

c02_20.eps

图 2.20 使用 then 执行顺序异步操作

我喜欢将承诺链想象为一系列由 then 箭头连接的盒子,如图图 2.21 所示。每个盒子代表一系列异步操作中的一个阶段。

c02_21.eps

图 2.21 可视化承诺链

All

Promise.all 用于管理并行运行的异步操作。它自动编织回调并调用一个单一的最终回调(图 2.22)。使用 all,你不再需要担心可能以任何顺序调用的多个回调的协调。

c02_22.eps

图 2.22 使用 Promise.all 并行执行异步操作

thenall 之间,我们已经有一个强大的工具集来管理异步操作。我们可以以各种方式将它们结合起来,以最小的努力拼接任意复杂的序列。请参阅图 2.23 以获取更复杂的示例。

Catch

最后,我们剩下 catch,用于错误处理。使用承诺(promises),我们可以在链的末尾附加一个错误处理器(图 2.24)。这允许我们在所有异步操作之间共享错误处理器,并且如果任何操作失败(例如,图 2.24 中的文件 2 加载失败),它将被调用。我喜欢将承诺错误处理想象为从承诺链中跳出的短路*,如图图 2.25 所示。

c02_23.eps

图 2.23 Promises 的一个更复杂的示例,展示了如何使用 thenall 来编织复杂的异步逻辑链。

c02_24.eps

图 2.24 使用 catch 向承诺链添加错误处理程序

c02_25.eps

图 2.25 一个错误中断承诺链并调用错误处理程序。

Catch 允许我们对异步错误处理进行优雅的控制。它在我们异步世界中为我们带来了 try/catch 语句。

在这个例子中,我将错误处理程序放置在承诺链的末尾,尽管在现实中,你可以根据你想要检测和报告错误的时间,将错误处理程序放置在链中的任何位置。

2.6.5 在 Promises 中包装异步操作

现在你已经知道了如何使用 Promises 以及它们如何帮助你简化异步操作的管理,你可以寻找使用它们的机会。

通常,你会发现第三方 API 已经提供了使用 Promises 的异步函数。在这些情况下,你调用异步函数,它返回一个承诺给你,然后你可以从那里链式调用额外的操作并按需处理错误。

在经过多年的孕育之后,Promises 在 2015 年的 JavaScript 第 6 版(也称为 ES6)中被引入,在此之前它们已经在各种第三方库中存在。现在 Promises 已在 Node.js 中可用,尽管 Node.js API 还未升级以正确支持它们。据我所知,所有 Node.js API 的异步函数仍然是基于回调的。许多第三方库尚未支持 Promises

别担心;即使它们不被我们使用的 API 直接支持,我们仍然可以使用 Promises。我们必须自己进行转换。

让我们回顾一下异步加载单个文件的例子,并使用 Promise 来转换它。我们将创建一个新的函数 readFilePromise,它包装了 Node 的 readFile 函数。我们希望如下使用我们的新函数:

readFilePromise("bicycle_routes.txt")    ①  
 .then(content => {    ②  
 console.log(content);    ②  
 })    ②  
 .catch(err => {    ③  
 console.error("An error occurred.");    ③  
 console.error(err);    ③  
 });    ③   

readFilePromise 函数创建并返回一个 Promise 对象。然后我们可以与这个承诺进行交互来管理异步操作。

我们使用一个匿名函数实例化一个 Promise 对象,该匿名函数启动异步文件加载操作。匿名函数接收两个参数。第一个参数是一个 resolve 函数,我们在异步操作完成后并准备好 resolve 承诺时调用它。这将触发承诺链中连接的下一个 then 处理器。第二个参数是一个 reject 函数,如果发生错误,我们可以调用它。我们可以使用这个来 fail 承诺并触发承诺链中最接近的 catch 处理器:

function readFilePromise (filePath) {    ①  
 return new Promise(    ②  
 (resolve, reject) => {    ③  
 fs.readFile(filePath, "utf8",    ④  
 (err, content) => {    ⑤  
 if (err) {    ⑥  
 reject(err);    ⑥  
 return;    ⑥  
 }    ⑥  

 resolve(content);    ⑦  
                }
 )    ⑤  
        }
 );    ②  
};    ①   

这种将基于回调的异步函数包装在 Promise 中的技术可以轻松应用于任何需要此类转换的情况。以下是一个你可以使用的通用模式:

function myPromiseBasedFunction (param1, param2, etc) {
 return new Promise(    ①  
 (resolve, reject) => {    ②  
 ... Start your async operation ...    ③  

 if async operation fails    ④  
 reject(error);    ④  

 when async operation completes    ⑤  
 resolve(optionalResult);    ⑤  
        }
 );    ②  
};    ①   

2.6.6 使用“async”和“await”进行异步编码

如果你使用的是 Node.js 7 或更高版本,你可能想使用新的asyncawait关键字。这些新关键字为 Promise 提供了语法糖,这意味着它们不再是 API 构造,JavaScript 语言本身已经更新以支持 Promise!

这些新关键字让 Promise 链看起来像一系列同步操作。例如,读取、转换然后写入数据文件,如下面的列表所示。

列表 2.15 使用await重写的 Promise 链

try {
 let textFileContent = await readFilePromise("input-file.csv");    ①  
    let deserialiedData = parseCsv(textFileContent);
    let transformedData = transform(deserialiedData);
    let serializedCsvData = serializeCsv(transformedData);
 await writeFilePromise("output-file.csv", serializedCsvData);    ①  

    console.log("File transformation completed!");
}
catch (err) {
    console.error(err);
} 

列表 2.15 中的代码是异步的,但并没有充斥着回调Promise。我们回到了一个看起来非常像同步代码的东西。

这依赖于一个解释器技巧来将await代码转换为 Promise,所以最终这仍然是一个then回调的序列,并在最后添加一个catch。你不会看到那么复杂的层次,因为解释器在为你做这项工作。

我们已经介绍了在 Node.js 中创建命令行应用和 Web 服务器的基础知识。我们已经对异步编程和 Promise 进行了概述。你现在可以开始真正处理数据了!

摘要

  • 你学习了如何开始一个项目并安装第三方库。

  • 你练习了创建一个简单的命令行应用。

  • 你将应用程序的部分代码重构为可重用的代码模块。

  • 你创建了一个简单的带有 REST API 的 Web 服务器。

  • 你学习了在 Node.js 中异步编码的重要性以及如何通过 Promise 更好地管理它*。

3

获取、存储和检索

本章涵盖

  • 围绕称为核心数据表示的设计模式来构建数据管道

  • 从文本文件和 REST API 导入和导出 JSON 和 CSV 数据

  • 使用 MySQL 和 MongoDB 数据库导入和导出数据

  • 创建灵活的管道以在不同格式之间转换数据

第三章涵盖了数据整理过程中至关重要的一个主题:从某处获取数据并将其本地存储,以便我们能够高效有效地处理它。

初始时,我们必须从某处导入我们的数据:这是获取。我们可能然后将数据导出到数据库,以便于处理:这是存储。我们可能然后将数据导出到各种其他格式,用于报告、共享或备份。最终,我们必须能够访问我们的数据以进行处理:这是检索

在第一章中,我们查看了一个数据整理过程的示例,其中数据从 MySQL 数据库导入并导出到 MongoDB 数据库。这是一个可能的场景。你在任何特定情况下的工作方式取决于数据是如何交付给你的,你项目的需求,以及你选择与之一起工作的数据格式和存储机制。

在本章中,我们讨论构建一个灵活的数据管道,它可以处理各种不同的格式和存储机制。这是为了展示各种可能性。在任何实际项目中,你可能不会处理大量的格式。例如,你可能只处理这些数据格式中的两到三种,但我认为了解所有选项是好的:毕竟,你永远不知道下一步会发生什么,我们需要一个能够处理可能出现的任何类型数据的流程。

本章是基础——关于数据管道的基本知识。当你阅读并通过这些技术尝试时,你可能会想知道这些技术如何扩展到大量数据。本章中提出的技术可以处理合理大小的数据集,但确实存在一个点,我们的数据变得如此之大,这些技术将开始崩溃。我们将在第七章和第八章中回到这些问题,届时我们将处理大量数据集。

3.1 构建您的工具包

通过本章,我们将探讨您需要将数据从一个地方移动到另一个地方的工具。我们将使用 Node.js 和各种第三方库。表 3.1 列出了我们将使用的工具。

请注意,这仅仅是冰山一角!这些模块是通过 Node.js 包管理器(npm)安装的,并且只是任何 Node.js 开发者可触及的众多工具中的一小部分。

表 3.1 第三章工具

类型 数据源 数据格式 工具 方法
导入 文本文件 JSON Node.js API fs.readFile, JSON.parse
CSV Node.js API, PapaParse fs.readFile Papa.parse
REST API JSON request-promise request.get
CSV request-promise, PapaParse request.get, Papa.parse
数据库 MongoDB promised-mongo <database>.find
MySQL nodejs-mysql <database>.exec
导出 文本文件 JSON Node.js API fs.writeFile, JSON.stringify
CSV Node.js API, PapaParse fs.writeFile, Papa.unparse
数据库 MongoDB promised-mongo <database>.insert
MySQL nodejs-mysql <database>.exec

在本章中,实际上在整个书中,我们将继续构建我们的工具集。这很重要,因为我们将反复在未来的项目中使用它。随着我们通过各种示例进行工作,我们将创建一个 Node.js 函数库,用于在 JavaScript 中处理数据。

3.2 获取代码和数据

本章的数据主题是地震,数据是从美国地质调查网站下载的。还从 Seismi 地震数据可视化项目下载了附加数据。请注意,Seismi 网站似乎不再运行。

本章的代码和数据可在 Data Wrangling with JavaScript GitHub 组织中的第三章仓库github.com/data-wrangling-with-javascript/chapter-3中找到。请下载代码并安装依赖项。如果您需要帮助,请参考“获取代码和数据”在第二章中

第三章代码仓库以及本书中的大多数其他代码仓库与第二章中您所看到的不同。它们将每个代码列表的代码分别放在同一目录下的单独 JavaScript 文件中,并且根据列表编号命名,例如,listing_3.1.js,listing_3.3.js,等等。您可以通过在仓库的根目录中运行一次npm install来一次性安装所有代码列表的所有第三方依赖项。工具集**子目录包含我们在本章中创建的工具函数。

在本章的后面部分,我们将处理数据库。数据库设置可能很复杂,因此为了方便起见,第三章的 GitHub 仓库包含了 Vagrant 脚本,这些脚本可以启动带有数据库和示例数据的虚拟机。我们将在本章的后面部分更多地讨论 Vagrant。

3.3 核心数据表示

我想向您介绍核心数据表示(CDR)。这是一种用于结构化数据管道的设计模式。CDR 允许我们使用可重用的代码模块灵活地构建数据管道。使用这种设计模式,我们可以产生几乎无限多样的数据处理和转换管道。

我们数据处理管道中的阶段使用 CDR 进行通信;你可以说 CDR 是我们数据处理管道的粘合剂(见图 3.1)。CDR 是我们数据的共享表示,其目的是允许我们的管道阶段进行通信,并且能够干净地分离,彼此之间没有硬依赖。这种分离使我们能够构建可重用代码模块,然后我们可以重新排列它们来创建其他数据处理管道。

c03_01.eps

图 3.1 通过核心数据表示进行通信的阶段数据处理管道

阶段的分离也为我们提供了灵活性——我们可以通过重新排列阶段或添加和删除阶段来重构我们的数据处理管道。这些修改很容易进行,因为阶段只依赖于 CDR,并且不需要任何特定的先前阶段顺序。

在本章中,我们将使用 CDR 来弥合导入和导出代码之间的差距。这使我们能够从可重用代码模块中拼接数据转换管道。我们可以混合和匹配导入和导出代码,构建一个可以将数据从任何一种格式转换为另一种格式的管道。

3.3.1 地震网站

让我们从一个例子开始,以帮助理解 CDR。假设我们正在维护一个报告全球地震活动的网站。该网站从各种来源收集世界各地的地震数据,并将其汇总到一个中心位置。对于研究人员和关心公民来说,有一个地方可以获取新闻和数据是非常有用的。

数据从哪里来?假设我们的网站必须从各种不同的来源和多种不同的格式读取数据。灵活性是关键。我们必须接受来自其他网站和组织的数据,无论它们以何种格式提供。我们还希望成为一个好的数据共享公民,因此我们不仅通过网页和可视化使数据可用,还希望以各种机器可读的格式提供数据。简而言之,我们必须将各种格式导入和导出到我们的数据处理管道中。

让我们看看一种特定数据格式的导入和导出。假设我们已经将数据文件 earthquakes.csv 导入到 CDR 中。它将看起来如图 3.2 和图 3.3 所示。

CDR 应该很容易理解:毕竟它只是一个数据 JavaScript 数组。每个数组元素对应于 earthquakes.csv 中的一行(如图 3.2 所示)。每个数组元素包含一个 JavaScript 对象,或者你可以称之为记录,每个字段对应于 earthquakes.csv 中的一列(如图 3.3 所示)。

c03_02.eps

图 3.2 JavaScript 数组中的元素对应于 earthquakes.csv 中的行。

要创建数据转换管道,我们必须从一个数据格式导入,然后导出到另一个格式。作为一个例子,让我们以 earthquakes.csv 为例,将其导入 MongoDB 的地震数据库。为此,我们需要导入 CSV 文件中的数据的代码,然后是导出数据到 MongoDB 数据库的代码。我们很快就会看到代码;现在,注意在 图 3.4 中数据是如何通过位于中间的核心数据表示从导入到导出的。

我们不仅对 CSV 文件和 MongoDB 数据库感兴趣。我提到这些作为特定例子,以说明 CDR 如何连接我们的导入和导出代码。我们正在维护地震网站,我们需要接受和分享任何格式的数据!

c03_03.eps

图 3.3 JavaScript 对象中的字段对应于 earthquakes.csv 中的列。

c03_04.eps

图 3.4 导入和导出代码流通过核心数据表示。

3.3.2 涵盖的数据格式

表 3.2 展示了本章我们将涵盖的数据格式范围。到结束时,你将学会导入和导出这些常见数据格式的基本方法。

表 3.2 第三章涵盖的数据格式

数据格式 数据来源 说明
JSON 文本文件,REST API JSON 格式内置在 JavaScript 中。方便且大多数 REST API 都使用它。
CSV 格式比 JSON 更紧凑,且与 Excel 兼容。
MongoDB 数据库 灵活方便,无模式数据库。在你还不了解数据格式时非常理想。
MySQL 数据库 标准关系型数据库。成熟、健壮、可靠。

我想要传达给你的主要思想是,我们可以很容易地将各种数据格式插入到我们的工作流程中,只要我们需要它们。

在这本书中,你将学习一组常见但必要的有限数据格式,但这可能不会涵盖你最喜欢的数据格式。例如,有人问过我关于 XML、Microsoft SQL、PostgreSQL 和 Oracle。本书的目标不是涵盖所有可能的数据源;那样会很快变得无聊,所以我们将专注于一组代表性且常用的数据格式。

CSV 存在的原因是它在数据分析项目中非常常见。JSON 存在的原因是它在 JavaScript 中非常常见(而且非常方便)。我使用 MongoDB 来表示 NoSQL 类型的数据库。最后,我使用 MySQL 来表示 SQL 类型的数据库。

3.3.3 力量和灵活性

你已经理解了 CDR 设计模式的强大之处了吗?看看图 3.5 中数据格式是如何相互配合的。注意可以导入到 CDR 中的数据格式范围以及可以从 CDR 导出的数据格式范围。通过连接模块化的导入和导出代码(使用 CDR 进行通信),我们现在可以构建各种数据转换管道。

c03_05.eps

图 3.5 从多种数据格式中选择以构建自定义数据转换过程。

需要导入 JSON 并导出 MongoDB?没问题,我们可以做到!从 REST API 导入并导出到 CSV 呢?我们同样可以做到!使用 CDR 设计模式,我们可以轻松地将所需的数据转换连接起来,无论是从左侧的任何数据格式(图 3.5)导入,还是导出到右侧的任何格式。

3.4 导入数据

让我们从将数据导入 CDR 开始。我们首先将了解如何从文本文件和 REST API 加载数据。这两种方式在商业和数据科学场景中都很常见。在加载文本数据——无论是从文本文件还是 REST API——之后,我们需要根据特定的数据格式对其进行解析或解释。这通常是 JSON 或 CSV,两种常见的文本格式。最后,我们将从两种不同类型的数据库加载数据:MongoDB 和 MySQL。

3.4.1 从文本文件加载数据

我们从文本文件开始——这可能是最简单的数据存储机制——它们易于理解且普遍使用。在本节中,我们将学习如何将文本文件加载到内存中。最终,我们需要根据数据格式解析或解释文件中的数据,但首先让我们专注于从文件中加载,之后我们再回到解析的话题,届时我们也会看到如何从 REST API 加载文本数据。

将文本文件导入核心数据表示的一般过程如图 3.6 所示。在图表的右侧,注意路径分支;这就是我们将传入的数据解释为特定格式并将其解码到 CDR 的地方。不过,目前我们先加载文本文件到内存中。

在 Node.js 中,我们使用fs.readFile函数将文件内容读入内存。解析文件的方式根据数据格式而有所不同,但将文本文件读入内存的方式在每种情况下都是相同的,一个例子如列表 3.1 所示。你可以运行此代码,它将打印出文件 earthquakes.csv 的内容到控制台。

c03_06.eps

图 3.6 将文本文件导入 CDR

列表 3.1 将文本文件读入内存(listing-3.1.js)

const fs = require('fs');    ①  

fs.readFile("./data/earthquakes.csv", "utf8",    ②  
 (err, textFileData) => {    ③  
 if (err) {    ④  
 console.error(“An error occurred!”);    ④  
 return;    ④  
 }    ④  

 console.log(textFileData);    ⑤  
 }    ③  
).  ②   

列表 3.1 是在 Node.js 中加载文本文件的基本示例,但为了方便管理异步操作,我们现在将这个操作封装在 Promise 中。我们需要一些样板代码,我们将在每次加载文本文件时使用这些代码。我们将在整本书中多次重用这些代码,所以让我们将其转换成一个可重用的工具函数。

以下列表是您工具箱中的第一个函数。它位于一个我称为 file.js 的文件中,并定义了一个名为 file 的 Node.js 代码模块。目前,它包含一个名为 read 的单个函数。

列表 3.2 一个基于 Promise 的函数来读取文本文件(toolkit/file.js)

const fs = require('fs');

function read (fileName) {    ①  
 return new Promise((resolve, reject) => {    ②  
 fs.readFile(fileName, "utf8",    ③  
            function (err, textFileData) {
                if (err) {
 reject(err);    ④  
                    return;
                }

 resolve(textFileData);    ⑤  
            }
 );    ③  
 });    ②  
};    ①  

module.exports = {    ⑥  
 read: read,    ⑥  
};    ⑥   

列表 3.3 是我们如何使用新的 read 函数的一个示例。需要引入 file 模块,现在我们可以调用 file.read 来将 earthquakes.csv 加载到内存中。你可以运行代码,它将文件内容打印到控制台。你应该比较列表 3.1 和 3.3 的代码。这将帮助你理解基于回调和基于 Promise 的异步编程之间的区别。

列表 3.3 使用基于 Promise 的读取函数加载文本文件(listing-3.3.js)

const file = require('./toolkit/file.js');    ①  

file.read("./data/earthquakes.csv")    ②  
 .then(textFileData => {    ③  
 console.log(textFileData);    ④  
 }) //  ③  
 .catch(err => {    ⑤  
 console.error("An error occurred!");    ⑤  
 });    ⑤   

从文本文件加载数据说明了将文本数据放入内存的一种方法;现在让我们看看另一种方法。

3.4.2 从 REST API 加载数据

我们可以从文本文件中加载数据,所以现在让我们看看如何使用 HTTP(超文本传输协议)从 REST(表示状态传输)API 加载数据。这是从网站或网络服务通过互联网获取数据的一种常见方式。在这里,我们同样会将数据加载到内存中;然后我们再回来看看如何根据其格式解释数据。

从 REST API 导入数据的一般过程在 图 3.7 中展示。要通过 HTTP 获取数据,我们使用第三方库 request-promise。Node.js API 内置了对 HTTP 通信的支持,但我更喜欢使用更高层次的 request-promise 库,因为它更简单、更方便,并且它为我们封装了操作在 Promise 中。

c03_07.eps

图 3.7 从 REST API 导入数据到 CDR

从 REST API 获取数据,我们需要安装 request-promise。如果你正在跟随 GitHub 上的代码进行操作,并在代码仓库中执行了 npm install,那么你已经安装了这个依赖项。如果你需要在新的 Node.js 项目中安装它,你可以这样做:

npm install –-save request-promise request 

注意,我们安装了 request-promiserequest,因为前者作为依赖项依赖于后者。

作为例子,我们将从 earthquake.usgs.gov/earthquakes/feed/v1.0/summary/significant_month.geojson 拉取数据。你现在应该打开这个链接,你会在你的网络浏览器中看到 JSON 数据的样子。

以下列表展示了使用 request-promiserequest.get 函数通过 HTTP GET 获取数据的简单代码。你可以运行此代码,检索到的数据将打印到控制台,以便你可以进行检查。

列表 3.4 从 REST API 获取数据(listing-3.4.js)

const request = require('request-promise');    ①  

const url = "https://earthquake.usgs.gov" +    ②  
 "/earthquakes/feed/v1.0/summary/significant_month.geojson";    ②  

request.get(url)    ③  
 .then(response => {    ④  
 console.log(response);    ④  
 })    ④  
 .catch(err => {    ⑤  
 console.error(err);    ⑤  
 });    ⑤   

3.4.3 解析 JSON 文本数据

现在我们能够将文本数据加载到内存中,无论是从文本文件还是从 REST API,我们必须决定如何解码内容。处理原始文本数据可能会很痛苦、耗时且容易出错;然而,当我们处理一个通用或标准化的数据格式,如 JSON 或 CSV 时,我们可以利用现有的库来导入或导出数据。

JSON 是我们将从文本数据中解析的第一个数据格式。它是你在使用 JavaScript 时会遇到的最常见的数据格式之一。它易于理解,与 JavaScript 密不可分。用于处理 JSON 的工具内置在 JavaScript API 中,这使得 JSON 对我们来说是一个特别吸引人的格式。

解析 JSON 文本文件

在我们尝试导入数据文件之前,打开文件在文本编辑器中并直观地验证数据是否是我们所期望的是个好主意。尝试处理一个损坏或有其他问题的数据文件是没有意义的,我们可以在开始编码之前轻松快速地检查这一点。这不会捕捉到所有可能的问题,但你可能会惊讶于通过首先进行简单的视觉检查就能发现多少数据问题。图 3.8 展示了在 Notepad++(我在 Windows PC 上使用的文本编辑器)中加载的 earthquakes.json。

c03_08.png

图 3.8 在 Notepad++ 中查看 Earthquakes.json

现在我们将 earthquakes.json 导入到核心数据表示中。使用 Node.js 和 JavaScript API 提供的工具来做这件事尤其简单。JSON 格式是序列化的 JavaScript 数据结构,因此它与核心数据表示直接对应。为了读取文件,我们使用我们的工具函数 file.read。然后我们使用内置的 JavaScript 函数 JSON.parse 将文本数据解码到 CDR。这个过程在 图 3.9 中展示。

下面的列表是一个新的函数,用于将 JSON 文件导入到核心数据表示中。我们使用我们的函数 file.read 读取文件内容,然后使用 JSON.parse 解析 JSON 数据。

列表 3.5 导入 JSON 文本文件的函数(toolkit/importJsonFile.js)

const file = require('./file.js');    ①  

//
// Toolkit function to import a JSON file.
//
function importJsonFile (filePath) {    ②  
 return file.read(filePath)    ③  
 .then(textFileData => {    ④  
 return JSON.parse(textFileData);    ⑤  
 });    ④  
};    ⑥  

module.exports = importJsonFile;    ⑥   

c03_09.eps

图 3.9 将 JSON 文本文件导入到 CDR

下面的列表展示了如何使用我们的新函数导入 earthquakes.json。你可以运行此代码,解码后的数据将打印到控制台,以便我们可以直观地验证数据是否正确解析。

列表 3.6 从 earthquakes.json 导入数据(listing-3.6.js)

const importJsonFile = require('./toolkit/importJsonFile.js');    ①  

importJsonFile("./data/earthquakes.json")    ②  
 .then(data => {    ③  
 console.log(data);    ④  
 })    ③  
 .catch(err => {    ⑤  
 console.error("An error occurred.");    ⑤  
        console.error(err.stack);
 });    ⑤   

从 REST API 解析 JSON 数据

从 REST API 导入 JSON 数据与从文本文件导入类似。我们需要更改数据加载的位置。而不是使用 file.read 函数,我们可以使用我们的 request-promise 从 REST API 加载数据。以下列表显示了一个用于工具包的新函数,用于从 REST API 导入 JSON 数据。

列表 3.7 从 REST API 导入 JSON 数据 (toolkit/importJsonFromRestApi.js)

const request = require('request-promise');    ①  

function importJsonFromRestApi (url) {    ②  
 return request.get(url)    ③  
        .then(response => {
            return JSON.parse(response);
        });
};    ②  

module.exports = importJsonFromRestApi;    ④   

列表 3.8 展示了如何调用 importJsonFromRestApi 从之前在 列表 3.4 中也使用过的示例 REST API 导入数据。此代码与 列表 3.6 类似,但不是从文件加载数据,而是从 REST API 加载数据。运行此代码,您将看到它是如何操作的,它会抓取数据,然后将解码后的 JSON 数据打印到控制台,以便您可以检查它是否按预期工作。

列表 3.8 从 REST API 导入地震数据 (listing-3.8.js)

const importJsonFromRestApi = require('./toolkit/importJsonFromRestApi.js');    ①  

const url = "https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/significant_mont[CA]h.geojson";

importJsonFromRestApi(url)    ②  
 .then(data => {    ③  
 const earthquakes = data.features.map(feature => {    ④  
 const earthquake = Object.assign({},    ④  
 feature.properties,    ④  
 { id: feature.id }  ④  
 );    ④  
            return earthquake;
        });
 console.log(earthquakes);    ⑤  
 })    ③  
 .catch(err => {    ⑥  
 console.error("An error occurred.");    ⑥  
 console.error(err.stack);    ⑥  
 });    ⑥   

注意在 列表 3.8 中,如何将传入的数据重新组织以符合我们对 CDR 的想法。传入的 JSON 数据的结构与我们希望它符合的结构并不完全一致,因此我们即时将其重写为表格格式。

3.4.4 解析 CSV 文本数据

我们接下来要查看的格式是 CSV(逗号分隔值)格式。这种简单的格式在数据科学社区中很常见。它直接表示表格数据,并且比 JSON 格式更紧凑。

不幸的是,我们需要解析 CSV 文件的工具并未包含在 Node.js 或 JavaScript 中,但我们可以很容易地从 npm 获取所需的内容。在这种情况下,我们将安装一个名为 Papa Parse 的优秀第三方库来解析 CSV 文件。

解析 CSV 文本文件

与 JSON 类似,我们首先应该检查 CSV 文件的内容是否格式良好且未损坏。我们可以像查看 JSON 文件时一样查看 CSV 文件,但值得注意的是,CSV 文件也可以作为工作表加载!图 3.10 显示了 earthquakes.csv 在 Excel 中的加载情况。

c03_10.png

图 3.10 在 Excel 中加载的 Earthquakes.csv

应该注意,CSV 文件也可以从常规 Excel 工作表中导出,这意味着我们可以在处理 CSV 时使用 Excel 的所有功能。我发现 CSV 格式在需要与使用 Excel 的人交换数据时非常有用。

让我们将我们的 CSV 文件导入到核心数据表示中。这比 JSON 更困难一些,但只是因为我们必须安装第三方库 Papa Parse 来完成解析 CSV 数据的工作。与 JSON 不同,CSV 格式并不直接与 CDR 对齐,因此在导入过程中需要重新结构化。幸运的是,Papa Parse 会处理这一点。

与 JSON 类似,我们首先将 CSV 文本文件读取到内存中;之后,我们使用 Papa Parse 将文本数据解码为 CDR。这个过程在图 3.11 中有所说明。你可能已经知道 CSV 文件的结构,但如果你不知道,图 3.12 展示了在 Notepad++ 中查看的 CSV 文件结构。

c03_11.eps

图 3.11 将 CSV 文本文件导入到 CDR

c03_12.eps

图 3.12 CSV 文件的结构

CSV 文件是一个普通的文本文件:文件的每一行都是一行数据。然后每一行被分割成字段,这些字段由逗号分隔,因此得名。这个格式除了我刚才描述的之外,没有更多内容。

如果你正在使用本章的 GitHub 仓库并且已经执行了 npm install,那么你已经在 Node.js 项目中安装了 Papa Parse。如果没有,你可以在新的 Node.js 项目中按照以下方式安装 Papa Parse:

`npm install –-save papaparse` 

下面的代码示例是我们下一个工具包函数;这个函数将 CSV 文件导入到核心数据表示中。同样,我们使用我们的工具包函数 file.read 将文件加载到内存中;然后使用 papa.parse 解析 CSV 数据。

列表 3.9 导入 CSV 文本文件的函数(toolkit/importCsvFile.js)

const papa = require('papaparse');    ①  
const file = require('./file.js');    ②  

function importCsvFile (filePath) {    ③  
 return file.read(filePath)    ④  
 .then(textFileData => {    ⑤  
 const result = papa.parse(textFileData, {    ⑥  
 header: true,    ⑦  
 dynamicTyping: true,    ⑧  
 });    ⑤  
 return result.data;    ⑨  
 });    ⑤  
};    ③  

module.exports = importCsvFile;    ⑩   

注意与 Papa Parse 一起使用的选项。header 选项使 Papa Parse 识别 CSV 文件的第一个行作为标题行,该行指定了表格数据的列名。

dynamicTyping 选项启用了 Papa Parse 的自动类型转换。这为每个字段值选择一个类型,取决于值的类型。这是必需的,因为 CSV 格式与 JSON 不同,没有对数据类型的特殊支持。CSV 中的每个字段只是一个字符串值,但 Papa Parse 会为我们确定实际的数据类型。这个功能很方便,并且大多数时候都能正常工作。有时,尽管如此,它可能会选择错误的类型,或者出于某种原因,你可能想要更多的控制权,以便能够应用你自己的约定。

下面的代码示例使用我们新的函数导入 earthquakes.csv。你可以运行这个代码示例,你将看到解码后的数据打印到控制台,以便你可以检查导入是否成功。

列表 3.10 从 earthquakes.csv 导入数据(listing_3.10.js)

const importCsvFile = require('./toolkit/importCsvFile.js');    ①  

importCsvFile("./data/earthquakes.csv")    ②  
 .then(data => {    ③  
 console.log(data);    ④  
 })    ③  
 .catch(err => {    ⑤  
 console.error("An error occurred.");    ⑤  
 console.error(err.stack);    ⑤  
 });    ⑤   

从 REST API 解析 CSV 数据

与 JSON 类似,使用 CSV,我们也有从文本文件或 REST API 加载 CSV 的选项。为此,我们将 file.read 替换为 request-promise 以从 REST API 加载数据而不是从文本文件。下面的代码示例是一个新的函数 importCsvFromRestApi,它执行此操作,我们可以使用它从 REST API 导入 CSV 数据。

列表 3.11 从 REST API 导入 CSV 数据的函数(toolkit/importCsvFromRestApi.js)

const request = require('request-promise');    ①  
const papa = require('papaparse');    ②  

function importCsvFromRestApi (url) {    ③  
 return request.get({    ④  
 uri: url,    ④  
 json: false    ④  
 })    ④  
        .then(response => {
 const result = papa.parse(response, {    ⑤  
 header: true,    ⑤  
 dynamicTyping: true    ⑤  
 });    ⑤  

            return result.data;
        });
};    ⑥  

module.exports = importCsvFromRestApi; 

列表 3.12 使用importCsvFromRestApi函数从earthquake.usgs.gov/fdsnws/event/1/query.csv的 REST API 导入 CSV 数据。你可以运行以下代码列表,它将通过你的网络拉取 CSV 数据,对其进行解码,然后将其打印到控制台以便你可以检查。

列表 3.12 从 REST API 导入 CSV 数据(listing-3.12.js)

const importCsvFromRestApi = require('./toolkit/importCsvFromRestApi.js');    ①  

const url = "https://earthquake.usgs.gov/fdsnws/event/1/query. ➥csv?starttime=2017-01-01&endtime=2017-03-02";    ②  
importCsvFromRestApi(url)    ③  
 .then(data => { //  ④  
 console.log(data); //  ④  
 }) //  ④   
 .catch(err => {    ⑤  
 console.error(err);    ⑤  
 });    ⑤   

这使我们到达了从文本文件加载和解析数据的结束。请注意,存在其他可能需要加载的数据格式,但在这里我们只使用了两种最常见的格式:CSV 和 JSON。在实践中,你也可能需要处理 XML 文件、YAML 文件等等——但任何你可以想到的新格式都可以通过 CDR 插入到你的数据管道中。

我们将在第四章回到文本文件,学习如何使用正则表达式处理不寻常的文本文件格式,对于那些我们必须导入自定义或专有数据格式的情况。

3.4.5 从数据库导入数据

在我们完成查看数据导入之前,我们需要学习如何从数据库导入到核心数据表示。正如你可以想象的,数据库在数据整理的世界中非常重要。它们通常是我们的数据管道的一个组成部分,并且对于有效地处理大量数据是必要的。数据库通常使用第三方访问库通过网络协议访问,如图图 3.13 所示。许多数据库产品都是可用的,但在这里我们将关注两个最常见的:MongoDB 和 MySQL。

c03_13.eps

图 3.13 从数据库导入到 CDR

3.4.6 从 MongoDB 导入数据

MongoDB 是一个流行的 NoSQL 数据库,也是我首选的数据库,因为它提供了便利性、灵活性和性能的良好组合。作为 NoSQL 数据库,MongoDB 是无模式的。MongoDB 不会对你的数据施加固定的模式,因此我们不需要预先定义数据库的结构。

我发现当处理我还不太理解的数据时,这很有用。MongoDB 意味着我可以将数据扔进数据库,并将关于结构的问题留到以后解决。使用 MongoDB 并不意味着我们拥有非结构化数据——远非如此;我们可以在 MongoDB 中轻松表达结构,但这意味着我们不需要担心在前面定义这种结构。就像任何数据导入工作一样,我们应该在编写导入代码之前先查看数据。图 3.14 展示了通过 Robomongo 查看的示例地震数据库。

c03_14.eps

图 3.14 使用 Robomongo 数据库查看器查看地震 MongoDB 数据库

您有多种方式从 MongoDB 数据库中检索数据。在这里,我们将使用promised-mongo,这是一个第三方库,它模拟 Mongo shell 并提供了一个优雅的基于承诺的 API。我们在这里使用promised-mongo是因为这是一种稍微容易一些的开始使用 MongoDB 的方法,并且它与我们在 Mongo shell 和 Robomongo 中也可以使用的命令类似。在第八章,当我们回到 MongoDB 时,我们将使用官方的 MongoDB 访问库。

我们使用promised-mongo将数据从 MongoDB 导入到核心数据表示中,如图 3.15 所示。请注意,与处理文本文件不同,不需要额外的解析步骤;数据库访问库会处理这一点。

如果您正在使用 GitHub 存储库并执行了npm install,您已经安装了promised-mongo。否则,您可以在新的 Node.js 项目中按照以下方式安装它:

npm install –-save promised-mongo 

c03_15.eps

图 3.15 从 MongoDB 地震数据库导入到 CDR

MongoDB 数据库易于安装:您可以在www.mongodb.com找到下载和更多信息。为了您的方便,第三章的 GitHub 存储库包含一个 Vagrant 脚本,您可以使用它启动一个已经安装了 MongoDB 数据库的虚拟机,其中包括示例地震数据。要使用此脚本,您需要安装 Vagrant 和 Virtual Box,我在附录 C,“开始使用 Vagrant”中进行了说明。

Vagrant 允许您创建模拟生产环境的虚拟机。我使用 Vagrant 是为了让您能够快速启动一个带有数据库的机器,这为您提供了一个方便的数据源来尝试列表 3.13 和 3.14 中的示例代码。如果您不想使用 Vagrant,但想尝试这段代码,那么您需要在您的开发 PC 上安装 MongoDB,并手动将数据加载到数据库中。

一旦您安装了 Vagrant 和 Virtual Box,您可以按照以下方式启动虚拟机:

cd Chapter-3/MongoDB
vagrant up 

虚拟机需要一些时间来准备。当它完成时,您将拥有一个包含地震数据的 MongoDB 数据库,可以立即使用。Vagrant 已将默认的 MongoDB 端口 27017 映射到我们本地 PC 的 6000 端口(假设该端口尚未被占用)。这意味着我们可以像它实际在虚拟机上运行一样,在本地 PC 的 6000 端口访问 MongoDB 数据库(而不是在它实际运行的虚拟机上)。

当您完成 MongoDB 虚拟机的使用后,不要忘记销毁它,以免它继续消耗您的系统资源:

cd Chapter-3/MongoDB
vagrant destroy 

下面的列表是我们的下一个工具函数。它使用 MongoDB 的find函数将数据从 MongoDB 集合导入到核心数据表示中。

列表 3.13 从 MongoDB 集合导入数据的函数(toolkit/importFromMongoDB.js)

function importFromMongoDB (db, collectionName) {    ①  
 return db[collectionName].find().toArray();    ②  
};    ①  

module.exports = importFromMongoDB;    ③   

下面的列表显示了如何使用函数从 largest_earthquakes*集合导入数据。运行此代码将检索数据库中的数据并将其打印到控制台供您检查。

*列表 3.14 从 MongoDB 导入 largest earthquakes 集合(listing-3.14.js)

const mongo = require('promised-mongo');    ①  
const importFromMongoDB = require('./toolkit/importFromMongoDB.js');    ②  

const db = mongo(  ③  
 "localhost:6000/earthquakes",    ③  
 ["largest_earthquakes"]    ③  
);    ③  

importFromMongoDB(db, "largest_earthquakes")    ④  
 .then(data => {    ⑤  
 console.log(data);    ⑤  
 })    ⑤  
 .then(() => db.close())    ⑥  
 .catch(err => {    ⑦  
 console.error(err);    ⑦  
 });    ⑦   

注意在列表 3.14 中,我们如何使用连接字符串localhost:6000/earthquakes连接到 MongoDB 数据库。这假设我们正在连接到运行在 Vagrant 虚拟机上的名为earthquakes的 MongoDB 数据库,并且 MongoDB 数据库实例映射到主机 PC 上的 6000 端口。

您必须更改此连接字符串以连接到不同的数据库。例如,如果您在本地 PC 上安装了 MongoDB(而不是使用 Vagrant 虚拟机),您可能会发现 MongoDB 正在使用其默认端口 27017。如果是这种情况,您需要使用连接字符串localhost:27017/earthquakes。考虑到localhost27017是默认值,您甚至可以省略这些部分,直接使用earthquakes作为您的连接字符串。

您也可以通过在连接字符串中提供有效的主机名来通过互联网连接到 MongoDB 数据库。例如,如果您有一个名为my_host.com的机器上的可访问互联网数据库,那么您的连接字符串可能看起来像这样:my_host.com:27017/my_database.

3.4.7 从 MySQL 导入数据

在查看数据导入之前,我们无法完成对数据的查看。SQL 是商业世界的基石,大量数据包含在 SQL 数据库中。在这里,我们查看从 MySQL 导入数据,MySQL 是一个流行的 SQL 数据库。

正如我们在其他情况下所做的那样,在我们进入代码之前,我们应该首先查看数据库中的数据。在图 3.16 中,您可以通过 HeidiSQL 数据库查看器看到地震数据库和 largest_earthquakes 集合。

c03_16.tif

图 3.16 使用 HeidiSQL 数据库查看器查看 largest_earthquakes 表

要从 MySQL 读取数据,我们将使用一个名为nodejs-mysql的第三方库。图 3.17 说明了从地震数据库检索数据并将其导入核心数据表示的过程。

如果您正在使用 GitHub 仓库并执行了npm install,您已经安装了nodejs-mysql。否则,您可以在新的 Node.js 项目中按以下方式安装它:

npm install –-save nodejs-mysql 

MySQL 的设置比 MongoDB 要复杂一些。在安装 MySQL 并在导入数据之前,您必须定义模式,这在 MongoDB 中是不必要的。MySQL 的下载和安装说明可以在www.mysql.com找到。

c03_17.eps

图 3.17 从 SQL 数据库导入数据到 CDR

为了您的方便,第三章的 GitHub 仓库包含另一个 Vagrant 脚本,该脚本将启动一个带有已安装 MySQL 数据库的虚拟机,其中包含地震数据库,您可以使用它来尝试列表 3.15 和 3.16 中的代码。您需要安装 Vagrant 和 Virtual Box,您可能已经从之前的 MongoDB 示例中安装了它们。

使用以下命令启动虚拟机:

cd Chapter-3/MySql
vagrant up 

虚拟机需要一些时间来准备。一旦完成,您将拥有一个包含地震数据库的 MySQL 数据库,可以开始使用。Vagrant 将默认的 MySQL 端口 3306 映射到我们本地电脑的端口 5000(假设端口 5000 没有被占用)。您可以在端口 5000 上访问您的 PC 上的 MySQL 数据库,就像它在那里运行一样(而不是在实际上运行的虚拟机上)。

一旦您完成虚拟机的使用,不要忘记销毁它,以免它继续消耗您的系统资源:

cd Chapter-3/MySql
vagrant destroy 

有关设置和使用 Vagrant 的更多信息,请参阅附录 C。

列表 3.15 定义了 importFromMySql 函数,其中包含执行 SQL 命令并导入数据到核心数据表示所需的简单代码。

列表 3.15 从 MySQL 数据库导入数据的函数(toolkit/importFromMySql.js)

function importFromMySql (db, tableName) {    ①  
 return db.exec("select * from " + tableName);    ②  
};    ①  

module.exports = importFromMySql;    ③   

列表 3.16 展示了如何使用 importFromMySql 函数。它连接到地震数据库,并从 largest_earthquakes 表中导入数据。运行此代码,它将从 MySQL 数据库检索数据并将其打印到控制台,以便我们可以进行检查。

列表 3.16 从 MySQL 导入最大地震表(listing-3.16.js)

const importFromMySql = require('./toolkit/importFromMySql.js');    ①  
const mysql = require('nodejs-mysql').default;

const config = {    ②  
 host: "localhost",    ②  
 port: 5000,    ③  
 user: "root",    ②  
 password: "root",    ②  
 database: "earthquakes",    ④  
 dateStrings: true,    ②  
 debug: true    ②  
};    ②  

const db = mysql.getInstance(config);    ⑤  

return importFromMySql(db, "largest_earthquakes")    ⑥  
 .then(data => {    ⑦  
 console.log(data);    ⑦  
 })    ⑦  
 .catch(err => {    ⑧  
 console.error(err);    ⑧  
 });    ⑧   

3.5 导出数据

我们已经完成了关于将数据导入内存的学习。在本章的后半部分,我们将探讨等式的另一面:导出数据。我们将学习如何将数据从我们的数据管道导出到各种数据格式和存储机制。就像我们学习导入时一样,我们将从文本文件开始,并以 MongoDB 和 MySQL 数据库结束。

3.5.1 您需要导出数据!

通过导入数据的代码示例,我们将导入的数据打印到控制台以检查一切是否按预期工作。导出略有不同。在我们能够导出数据之前,我们需要导出的示例数据!

在本章的剩余部分,我们将使用 earthquakes.csv 作为示例数据。导出代码示例的一般模式在 图 3.18 中展示。首先,我们使用之前创建的工具包函数 importCsvFile 将 earthquakes.csv 加载到 CDR 中。随后是导出过程的其余部分,这取决于我们导出的数据格式。以下列表展示了代码中的通用导出过程。您可以看到,在导入 earthquakes.csv 之后,我们有一个空白槽,可以插入我们的导出代码。

列表 3.17 数据导出示例代码的一般模式(toolkit/file.js)

const importCsvFile = require('./importCsvFile');    ①  

importCsvFile("./data/earthquakes.csv")    ②  
 .then(earthquakesData => {    ③  
        //
        // ... Export code here ...
        //
 })    ③  
 .catch(err => {    ④  
 console.error("An error occurred.");    ④  
 console.error(err.stack);    ④  
 });    ④   

3.5.2 将数据导出到文本文件

将数据导出到文本文件的过程始于将我们持有的核心数据表示形式中的数据进行序列化。我们必须首先选择我们的数据格式:在这里,我们将数据导出为 JSON 或 CSV 格式。我们的数据在内存中以文本形式进行序列化。然后我们使用 Node.js 函数fs.writeFile将文本数据写入文件系统。这个过程在图 3.19 中进行了说明。

c03_18.eps

图 3.18 数据导出示例的一般格式

c03_19.eps

图 3.19 从 CDR 导出到文本文件

正如我们在 Node 的fs.readFile函数中所做的那样,我们创建了一个函数,该函数将fs.writeFile包装在一个 Promise 中。我们希望将我们的文件相关函数放在一起,所以让我们将新的write函数添加到以下列表中现有的文件模块中。

列表 3.18 基于 Promise 的函数用于写入文本文件(toolkit/file.js)

const fs = require('fs');

//
// ... read toolkit function ...

function write (fileName, textFileData) {    ①  
 return new Promise((resolve, reject) => {    ②  
 fs.writeFile(fileName, textFileData,    ③  
            (err) => {
                if (err) {
 reject(err);    ④  
                    return;
                }

 resolve();    ⑤  
            }
        );
 });    ②  
};    ①  

module.exports = {    ⑥  
 read: read,    ⑥  
 write: write,    ⑥  
};    ⑥   

在接下来的几节中,我们将使用我们新的工具包函数将数据写入 JSON 和 CSV 文件。

3.5.3 将数据导出到 JSON 文本文件

要从 CDR 导出到 JSON,请使用内置的 JavaScript 函数JSON.stringify。将我们的数据序列化为文本后,然后按照图 3.20 所示将其写入 earthquakes.json 文件。以下列表显示了新的函数exportJsonFile,该函数将我们的数据导出到 JSON 文件。

列表 3.19 导出数据到 JSON 文本文件的函数(toolkit/exportJsonFile.js)

const file = require('./file.js');    ①  

function (fileName, data) {  ②  
 const json = JSON.stringify(data, null, 4);    ③  
 return file.write(fileName, json);    ④  
};    ②  

module.exports = exportJsonFile;    ⑤   

c03_20.eps

图 3.20 从 CDR 导出到 JSON 文本文件

以下列表使用exportJsonFile函数将我们的数据导出到 JSON 文件。您可以运行此代码,您会发现它在output文件夹中生成了一个名为 earthquakes.json 的文件。

列表 3.20 将数据导出到 earthquakes.json(listing-3.20.js)

const importCsvFile = require('./toolkit/importCsvFile.js');    ①  
const exportJsonFile = require('./toolkit/exportJsonFile.js');    ②  

importCsvFile("./data/earthquakes.csv")    ③  
 .then(data => exportJsonFile("./output/earthquakes.json", data))    ④  
 .catch(err => {    ⑤  
 console.error("An error occurred.");    ⑤  
 console.error(err.stack);    ⑤  
 });    ⑤   

3.5.4 将数据导出到 CSV 文本文件

CSV 导出不是 JavaScript 内置的,所以我们再次转向 Papa Parse 来实现这一功能。这次我们使用函数papa.unparse将我们的数据序列化为 CSV 文本。然后我们使用file.write函数将数据写入 earthquakes.csv。这个过程在图 3.21 中进行了说明。以下列表显示了我们的函数exportCsvFile,该函数使用papa.unparse将数据导出到 CSV 文件。

c03_21.eps

图 3.21 从 CDR 导出到 CSV 文本文件

列表 3.21 导出数据到 CSV 文本文件的函数(toolkit/exportCsvFile.js)

const papa = require('papaparse');    ①  
const file = require('./file.js');    ②  

function exportCsvFile (fileName, data) {    ③  
 const csv = papa.unparse(data);    ④  
 return file.write(fileName, csv);    ⑤  
};    ③  

module.exports = exportCsvFile;    ⑥   

列表 3.22 使用exportCsvFile函数将我们的数据导出到 CSV 文件。运行此代码,它将在输出文件夹中生成名为 earthquakes-export.csv 的文件。

列表 3.22 将数据导出到 earthquakes.csv(listing-3.22.js)

const importCsvFile = require('./toolkit/importCsvFile.js');    ①  
const exportCsvFile = require('./toolkit/exportCsvFile.js');    ②  

importCsvFile("./data/earthquakes.csv")    ③  
 .then(data =>    ④  
 exportCsvFile("./output/earthquakes-export.csv", data)    ④  
 )    ④  
    .catch(err => {
        console.error("An error occurred.");
        console.error(err.stack);
    }); 

3.5.5 将数据导出到数据库

将我们的数据导出到数据库对于我们有效地处理数据是必要的。有了数据库,我们可以在需要时轻松高效地检索过滤和排序后的数据。

图 3.22 展示了整个过程。核心数据表示被输入到数据库访问库中。通常,库通过网络与数据库接口以存储数据。

c03_22.eps

图 3.22 从 CDR 导出到数据库

3.5.6 将数据导出到 MongoDB

我们可以使用之前安装的第三方库 promised-mongo 将数据导出到 MongoDB。这在上面的 图 3.23 中展示。

c03_23.eps

图 3.23 从 CDR 导出到 MongoDB 数据库

您的工具箱函数,用于将数据导出到 MongoDB,如以下列表所示,是最简单的一种。几乎不值得为这个单独创建一个函数,但我为了完整性而包含了它。对于特定的数据库和集合,它调用 insert 函数来插入记录数组。

列表 3.23 导出数据到 MongoDB 的函数(toolkit/exportToMongoDB.js)

function exportToMongoDB (db, collectionName, data) {    ①  
 return db[collectionName].insert(data);    ②  
 };    ①   

module.exports = exportToMongoDB;    ③   

在 列表 3.24 中展示了一个具体示例。这段代码连接到一个在 Vagrant 虚拟机上运行的 MongoDB 实例。数据库访问端口映射到我们的开发 PC 上的 6000 端口。示例数据从 earthquakes.csv 导入;然后我们调用 exportToMongoDB 函数并将数据存储在 MongoDB 数据库中。你可以运行这段代码,它将在数据库中创建并填充一个名为 largest_earthquakes_export. 的新集合。

列表 3.24 将数据导出到 MongoDB 的 largest_earthquakes 集合(listing-3.24)

const importCsvFile = require('./toolkit/importCsvFile.js');    ①  
const exportToMongoDB = require('./toolkit/exportToMongoDB.js');    ②  
const mongo = require('promised-mongo');    ③  

const db = mongo("localhost:6000/earthquakes",    ④  
 ["largest_earthquakes_export"]    ④  
);    ④  

importCsvFile("./data/earthquakes.csv")    ⑤  
    .then(data =>
 exportToMongoDB(db, "largest_earthquakes_export", data)    ⑥  
)
 .then(() => db.close())    ⑦  
 .catch(err => {    ⑧  
 console.error("An error occurred.");    ⑧  
 console.error(err.stack);    ⑧  
 });    ⑧   

3.5.7 将数据导出到 MySQL

我们可以使用之前安装的第三方库 nodejs-mysql 将数据导出到 MySQL。这个过程在 图 3.24 中展示。

c03_24.eps

图 3.24 从 CDR 导出到 MySQL 数据库

我们将数据导出到 MySQL 的函数在 列表 3.25 中展示。这与导出到 MongoDB 有所不同。对于 MongoDB,我们可以通过一次调用 insert 来插入大量记录。我们无法使用这个库做到这一点;相反,我们必须执行多个 SQL insert 命令。注意以下列表中如何使用 JavaScript 的 reduce 函数来按顺序执行这些 SQL 命令。

列表 3.25 导出数据到 MySQL 的函数(toolkit/exportToMySql.js)

function exportToMySql (db, tableName, data) {    ①  
 return data.reduce(    ②  
 (prevPromise, record) =>    ②  
 prevPromise.then(() =>    ③  
 db.exec(    ④  
 "insert into " + tableName + " set ?",    ④  
 record    ④  
 )    ③  
 ),    ③  
 Promise.resolve()    ②  
 );    ②  
};    ①  

module.exports = exportToMySql;    ⑤   

在将数据插入我们的 MySQL 数据库之前,我们需要创建数据库表。对我来说,这是使用 SQL 的一大缺点:在插入数据之前,我们必须创建表并定义我们的模式。这种准备并不需要 MongoDB。

下面的列表显示了在 MySQL 数据库中创建一个与我们的示例数据格式匹配的 largest_earthquakes_export 表的过程。你必须运行此代码以创建我们数据的数据库模式。

列表 3.26 在 MySQL 数据库中创建 largest_earthquakes_export 表(listing-3.26.js)

const mysql = require('nodejs-mysql').default;    ①  

const config = {    ②  
 host: "localhost",    ②  
 port: 5000,    ②  
 user: "root",    ②  
 password: "root",    ②  
 database: "earthquakes",    ③  
 dateStrings: true,    ②  
 debug: true    ②  
};

const db = mysql.getInstance(config);    ④  

const createDbCmd =
 "create table largest_earthquakes_export ( Magnitude double, Time ➥ datetime, Latitude double, Longitude double, `Depth/Km` double )";    ⑤  

db.exec(createDbCmd)    ⑥  
 .then(() => {    ⑦  
 console.log("Database table created!");    ⑦  
 })    ⑦  
 .catch(err => {    ⑧  
 console.error("Failed to create the database table.");    ⑧  
 console.error(err.stack);    ⑧  
 });    ⑧   

在创建数据库表之后,我们现在可以将其导出。在下面的列表中,我们导入 earthquakes.csv 的示例数据,然后使用我们的 exportToMySql 函数将其导出到 MySQL 数据库。你可以运行此代码,它将用你的数据填充 SQL 表 largest_earthquakes_export*。

*列表 3.27 导出到 MySQL largest_earthquakes 表(listing-3.27.js)

const importCsvFile = require('./ toolkit/importCsvFile.js');    ①  
const exportToMySql = require('./ toolkit/exportToMySql.js');    ②  
const mysql = require('nodejs-mysql').default;    ③  

const config = {    ④  
 host: "localhost",    ④  
 port: 5000,    ④  
 user: "root",    ④  
 password: "root",    ④  
 database: "earthquakes",    ⑤  
 dateStrings: true,    ④  
 debug: true    ④  
};    ④  

const db = mysql.getInstance(config);    ⑥  

importCsvFile("./data/earthquakes.csv")    ⑦  
 .then(data =>    ⑧  
 exportToMySql(db, "largest_earthquakes_export", data)    ⑧  
 )    ⑧  
 .catch(err => {    ⑨  
 console.error("An error occurred.");    ⑨  
 console.error(err.stack);    ⑨  
 });    ⑧   

我们现在已经完成了对各种数据格式导入和导出的旅程。我们如何利用这个经验呢?嗯,现在我们可以混合和匹配数据格式,我们可以构建大量不同类型的数据管道。

3.6 构建完整的数据转换

图表展示了从 CSV 文件到 MongoDB 数据库的数据转换的完整视图。我们已经在“导出到 MongoDB”这一节中看到了这种转换。注意导入代码如何在中部与导出代码重叠,核心数据表示就在这里。

让我们再次审视这个转换的代码。下面的列表清晰地标识了转换的导入和导出组件。这些组件被很好地定义为我们在本章早期创建的工具函数。

列表 3.28 从 CSV 文件到 MongoDB 集合的数据转换示例

const importCsvFile = require('./toolkit/importCsvFile.js');
const exportToMongoDB = require('./toolkit/exportToMongoDB.js');

// ... Initialisation code ...

**importCsvFile**("./data/earthquakes.csv")    ①  
 .then(data => **exportToMongoDB**(db, "largest_earthquakes", data))    ②  
    .then(() => {
        // ... Cleanup code ...
))
    .catch(err => {
        console.error("An error occurred.");
        console.error(err.stack);
    }); 

希望你现在开始对如何混合和匹配数据格式并将它们拼接起来以构建数据管道有了感觉。

3.7 扩展过程

让我们回到核心数据表示模式。你可以在列表 3.28 中看到,你可以轻松地用处理任何其他数据格式的函数替换那里的导入和导出函数。这形成了一个模式,允许你构建几乎你能想象到的任何数据转换。

c03_25.eps

图 3.25 CSV 到 MongoDB 的数据转换示例

你现在可以为之前我们覆盖的任何格式构建数据转换。看看图 3.26。从左侧选择一个导入格式。从右侧选择一个导出格式。然后在 JavaScript 代码中将这些格式连接起来,通过 CDR 传输数据。

c03_26.eps

图 3.26 核心数据表示设计模式是构建数据转换管道的配方。

核心数据表示模式是可扩展的。你不仅限于本章中展示的数据格式。你可以引入自己的数据格式,无论是标准格式(如 XML 或 YAML)还是自定义格式,并将它们集成到你的工作流程中。

我们之前查看的数据管道类型在图 3.27 中进行了概括。我们以某种格式接收输入数据,并通过可以解码该格式的代码传递它。此时,数据位于内存中的核心数据表示中。现在我们通过导出代码将 CDR 数据传递出去,使其到达需要的位置。我相信你可以想象如何将新格式添加到混合中。例如,假设你创建了导入和导出 XML 的工具函数。现在你已经扩展了创建数据转换管道的能力——例如,XML 到 CSV,XML 到 MongoDB,MySQL 到 XML 等等。

c03_27.eps

图 3.27 一个通用数据转换流程

在接下来的章节中,我们将基于核心数据表示模式进行构建。正如你在图 3.28 中看到的,我们将扩展转换流程的中间部分。这就是我们将添加数据清理、转换和分析阶段到我们的数据管道的地方。每个阶段都在核心数据表示上操作。每个阶段将 CDR 数据作为输入,对其进行处理,然后输出转换后的 CDR 数据,传递给下一个阶段。

c03_28.eps

图 3.28 扩展的基本数据转换流程,包括数据清理和转换阶段

使用核心数据表示模式,我们可以从本章学到的技术中创建总共 36 种不同的数据转换。36 是导入器数量(6)乘以导出器数量(6)。我们添加到混合中的任何新格式只会增加这个数字。比如说,你将 XML 格式添加到混合中;现在你有 49 种不同的数据转换可供使用!

获取、存储和检索是构建数据管道的基本要素。现在你已经处理了数据整理的这些方面,你可以转向更多样化和高级的主题。然而,你还没有完成数据导入,在第四章中,我们将探讨其更高级的方面,例如处理自定义数据、网络爬虫和与二进制数据一起工作。

摘要

  • 你了解到,你可以通过代码将灵活的数据管道连接起来,这些代码将数据通过核心数据表示传递。

  • 你发现了如何导入和导出 JSON 和 CSV 文本文件。

  • 我们讨论了通过 HTTP GET 从 REST API 导入 JSON 和 CSV 数据。

  • 你通过示例学习了如何使用 MongoDB 和 MySQL 数据库导入和导出数据。

4

处理不寻常的数据

本章涵盖

  • 处理各种不寻常的数据格式

  • 使用正则表达式解析自定义文本文件格式

  • 使用网页抓取从网页中提取数据

  • 处理二进制数据格式

在上一章中,你学习了如何将各种标准和常见的数据格式导入到核心数据表示中。在本章中,我们将探讨一些你可能需要不时使用的更不寻常的数据导入方法。

从第三章继续,假设你正在维护一个关于地震的网站,并且需要从各种来源接受新数据。在本章中,我们将探讨你可能需要或希望支持的几个不太常见的数据格式。表 4.1 显示了我们将涵盖的新数据格式。

表 4.1 第四章涵盖的数据格式

数据格式 数据源 备注
自定义文本 文本文件 数据有时以自定义或专有文本格式出现。
HTML Web 服务器/REST API 当没有其他方便的访问机制时,可以从 HTML 网页中抓取数据。
自定义二进制 二进制文件 数据有时以自定义或专有二进制格式出现。或者我们可能选择使用二进制数据作为更紧凑的表示形式。

在本章中,我们将为处理正则表达式、进行网页抓取和解码二进制文件添加新的工具到我们的工具箱中。这些工具列在表 4.2 中。

表 4.2 第四章工具

数据源 数据格式 工具 函数
自定义文本 自定义文本 request-promise 库 request.get, 正则表达式
网页抓取 HTML request-promise 和 cheerio 库 request.get, cheerio.load

| 二进制文件 | 自定义 | Node.js 文件系统 API 和 Buffer 类 | fs.readFileSync fs.writeFileSync

各种缓冲函数 |

二进制文件 BSON bson 库 serialize 和 deserialize

4.1 获取代码和数据

在本章中,我们继续使用第三章中的地震数据。本章的代码和数据可在 Data Wrangling with JavaScript GitHub 组织中的 Chapter-4 仓库中找到,网址为github.com/data-wrangling-with-javascript/chapter-4。请下载代码并安装依赖项。如需帮助,请参考“获取代码和数据”(第二章)。

*与第三章的情况一样,第四章的仓库包含每个代码列表的代码,分别存储在同一目录下的单独 JavaScript 文件中,并且它们根据列表编号命名。您可以通过在仓库的根目录中运行一次npm install来安装所有代码列表的所有第三方依赖项。

4.2 从文本文件导入自定义数据

有时你可能会遇到一个自定义的、专有的或临时的文本格式,对于这种格式没有现成的 JavaScript 库。在这种情况下,你必须编写自定义解析代码来将你的数据导入核心数据表示。

尽管存在各种解析方法,包括手动实现自己的解析器,在本节中,我将演示使用正则表达式进行解析。在将我们的示例文件 earthquakes.txt 加载到内存后,我们将使用正则表达式来解释数据,并将有趣的部分提取到核心数据表示中,如图 4.1 所示。

c04_01.eps

图 4.1 将自定义文本文件格式导入核心数据表示

对于正则表达式的第一个例子,我们将解析从美国地质调查局(USGS)下载的 earthquakes.txt 文本文件。如图 4.2 所示,earthquakes.txt 看起来类似于 CSV 文件,但它使用管道符号而不是逗号作为字段分隔符。

c04_02.eps

图 4.2 从 USGS 下载的自定义文本格式数据文件

正则表达式是一个强大的工具,并且它们在 JavaScript 中是原生支持的。它们可以帮助你处理临时或自定义的文件格式,因此你不需要为遇到的每个自定义格式手动编写解析器。

当我们使用正则表达式时,首先应该使用在线测试工具,例如 https://regex101.com。这个工具,如图 4.3 所示,允许我们在接近代码之前创建和测试我们的正则表达式。

c04_03.eps

图 4.3 使用 regex101.com 测试正则表达式

在这个例子中,我们将使用一个简单的正则表达式,但它们可以比这更复杂,我们可以使用它们来解析更复杂的数据格式。使用 regex101.com 的一个重大优势是,在我们原型化和测试我们的正则表达式之后,我们可以导出可以包含在我们的应用程序中的工作 JavaScript 代码。

在从 regex101.com 导出代码后,我们必须修改它,使其从 earthquakes.txt 读取。以下列表显示了结果代码和修改。您可以从第四章的 GitHub 仓库运行此代码,并且它将打印出由正则表达式解码的数据。

列表 4.1 从自定义文本文件 earthquakes.txt 导入数据(listing-4.1.js)

const file = require('./tookit/file.js');

function parseCustomData (textFileData) {    ①  
 const regex = /(.*)\|(.*)\|(.*)\|(.*)\|(.*)\|(.*)\|(.*)\|(.*)\|(.*)\|(.*) ➥\|(.*)\|(.*)\| (.*)$/gm;  ②  

    var rows = [];
    var m;

 while ((m = regex.exec(textFileData)) !== null) {    ③  
        // This is necessary to avoid infinite loops with zero-width ➥ matches
        if (m.index === regex.lastIndex) {
            regex.lastIndex++;
        }

 m.shift();    ④  

 rows.push(m);    ⑤  
 }    ③  

 var header = rows.shift();    ⑥  
 var data = rows.map(row => {    ⑦  
 var hash = {};    ⑦  
 for (var i = 0; i < header.length; ++i) {    ⑦  
 hash[header[i]] = row[i];    ⑦  
 }    ⑦  
 return hash;    ⑦  
 });    ⑦  

 return data;    ⑧  
};    ①  

file.read("./data/earthquakes.txt")    ⑨  
 .then(textFileData => parseCustomData(textFileData))    ⑩  
 .then(data => {    ⑪  
 console.log(data);    ⑪  
 })    ⑪  
 .catch(err => {    ⑫  
 console.error("An error occurred.");    ⑫  
 console.error(err.stack);    ⑫  
 });    ⑫   

注意,与我们在第三章中看到的读取文件示例不同,我们没有从 列表 4.1 保存一个单独的工具包函数。这是一个自定义格式,我们可能永远不会再次看到它,所以可能不值得创建可重用的工具包函数。一般来说,我们只有在确定我们将来还会看到该数据格式时,才需要向我们的工具包中添加一个函数。

在这个例子中,我们没有向我们的工具箱中添加任何代码,尽管我们确实添加了一种技术。你应该将正则表达式视为一种强大的技术,用于解析不寻常的数据格式。我们的第一个正则表达式示例只是触及了可能性的表面,所以让我们看看其他示例,看看正则表达式还能在其他哪些方面发挥作用。

使用正则表达式,我们可以为解析数据文件中的每一行创建一个更复杂的模式。你想要确保时间列是一个日期/时间值吗?那么创建一个更高级的模式,它只会识别该数据列的日期/时间值。其他列也是如此。你可以调整模式,只接受该列的有效数据;这是一种验证你的传入数据是否符合你预期的假设的好方法。

正则表达式也非常适合提取嵌套数据。比如说,你得到了一份客户评论的数据(可能是通过表单或电子邮件添加的),你需要提取相关的细节,比如客户的电子邮件和他们对某个产品的评分。

你一定会想用正则表达式来解析你的应用程序或服务器生成的日志文件。这是一个相当常见的正则表达式用例——比如说,当你想从日志文件中提取运行时指标和其他细节时。

当你开始使用正则表达式时,你会发现你的模式很快就会变得复杂。这是正则表达式的一个缺点:你可以快速创建难以阅读的模式,这些模式在以后修改起来也很困难。如果你觉得正则表达式有用,我会将进一步的探索留给你自己决定。

4.3 通过抓取网页导入数据

有时候,我们可能会在网页上看到一些有用的数据。我们希望拥有这些数据,但没有任何方便的方法可以访问它们。我们经常发现,重要的数据被嵌入在网页中,而公司或组织没有以我们方便下载的任何其他方式共享它们,比如 CSV 文件下载或 REST API。

理想情况下,所有组织都会以易于导入我们数据管道的格式共享他们的数据。然而,不幸的是,偶尔会有这样的情况,即抓取一个网页,从中提取数据,是我们获取所需数据的唯一途径。

网页抓取是一项繁琐、易出错且令人疲惫的工作。你的网页抓取脚本依赖于被抓取页面的结构:如果这个结构发生变化,那么你的脚本就会失效。这使得网页抓取脚本本质上很脆弱。因此,将网页抓取作为数据源应被视为最后的手段;在可能的情况下,你应该使用更可靠的替代方案。

如果抓取网页是访问数据集的唯一方式,那么尽管存在上述警告,我们也可以轻松地在 JavaScript 中完成它。第一部分与第三章中从 REST API 导入数据相同:我们可以使用 request-promise 来检索网页。在这个例子中,我们将从以下 URL 抓取地震数据:earthquake.usgs.gov/earthquakes/browse/largest-world.php.

当网页下载到内存中时,我们将使用第三方库 Cheerio 从网页中提取数据并将其转换为核心数据表示。这个过程在 图 4.4 中展示。

c04_04.eps

图 4.4 通过抓取网页导入数据

4.3.1 确定要抓取的数据

我们应该首先使用我们的网页浏览器来检查网页。图 4.5 展示了在 Chrome 中查看的最大地震网页。

c04_05.eps

图 4.5 在抓取之前在网页浏览器中查看最大的地震网页

在我们开始编码之前,我们必须确定识别页面中嵌入数据的 HTML 元素和 CSS 类。图 4.6 展示了使用 Chrome 的调试工具检查页面元素层次结构。有趣的元素是 tbodytrtd;这些元素构成了包含数据的 HTML 表格。

4.3.2 使用 Cheerio 抓取

我们现在可以识别网页中的数据,并且我们已经准备好进入代码。如果你已经安装了第四章代码仓库的所有依赖项,那么你已经有 Cheerio 安装了。如果没有,你可以在新的 Node.js 项目中安装 Cheerio,如下所示:

npm install --save cheerio 

Cheerio 是一个基于 jQuery 构建的出色库,因此如果你已经熟悉 jQuery,那么你会在 Cheerio 中感到宾至如归。列表 4.2 是一个工作示例,它抓取了最大的地震网页并提取了嵌入的数据到核心数据表示。你可以运行此代码,它将抓取的数据打印到控制台。

列表 4.2 通过抓取网页导入数据(列表-4.2.js)

const request = require('request-promise');    ①  
const cheerio = require('cheerio');    ②  

function scrapeWebPage (url) {    ③  
 return request.get(url)    ④  
 .then(response => {    ⑤  
 const $ = cheerio.load(response);    ⑥  
 const headers = $("thead tr")    ⑦  
 .map((i, el) => {    ⑦  
 return $(el)  [  ⑦  
 .find("th")    ⑦  
 .map((i, el) => {    ⑦  
 return $(el).text();    ⑦  
 })    ⑦  
 .toArray()];    ⑦  
 })    ⑦  
 .toArray();    ⑦  

 const rows = $("tbody tr")    ⑧  
 .map((i, el) => {    ⑧  
 return $(el)  [  ⑧  
 .find("td")    ⑧  
 .map((i, el) => {    ⑧  
 return $(el).text();    ⑧  
 })    ⑧  
 .toArray()];    ⑧  
 })    ⑧  
 .toArray();    ⑧  

 return rows.map(row => {    ⑨  
 const record = {};    ⑨  
 headers.forEach((fieldName, columnIndex) => {    ⑨  
 if (fieldName.trim().length > 0) {    ⑨  
 record[fieldName] = row[columnIndex];    ⑨  
 }    ⑨  
 });    ⑨  
 return record;    ⑨  
 });    ⑨  
 });    ⑤  
};    ③  

const url = "https://earthquake.usgs.gov/earthquakes/browse/largest-world. ➥ php";    ⑩  
scrapeWebPage(url)    ⑪  
 .then(data => {    ⑫  
 console.log(data);    ⑫  
 })    ⑫  
 .catch(err => {    ⑬  
 console.error(err);    ⑬  
 });    ⑬   

注意,这又是一个实例,类似于解析自定义文本文件,我们不一定需要将可重用函数添加到我们的工具箱中。抓取网站是一项如此定制的任务,以至于很少有机会再次使用相同的代码。我们发现,我们添加到工具箱中的是 技术,即抓取网站的能力,而不是可重用代码。

4.4 处理二进制数据

虽然可能看起来很少见,但有时作为一个 JavaScript 开发者,你可能会需要或想要处理二进制数据格式。

你应该始终问的第一个问题是,“为什么?”鉴于我们已经有很好的数据格式可以工作,例如 JSON 和 CSV,那么为什么还要处理二进制数据?

好吧,首先考虑的是,也许这就是我们用来工作的数据。在地震网站的情况下,让我们假设我们得到了地震数据的二进制数据转储。在这种情况下,我们需要解包二进制数据,以便我们可以处理它。

这是我们可能处理二进制数据的一个原因,但还有另一个原因。二进制数据比 JSON 或 CSV 数据更紧凑。例如,我们即将查看的二进制文件 earthquakes.bin 的大小是等效 JSON 文件大小的 24%。这对磁盘空间和网络带宽来说是一个显著的节省!

选择二进制数据格式的另一个原因可能是由于性能。如果你手动编写二进制序列化器并将其优化到极致,你可以实现比 JSON 序列化更好的性能。但我不会对这个原因寄予太多希望。内置的 JSON 序列化器已经非常优化且非常快。你必须聪明并非常努力才能打败它!

如果你必须这样做或者你需要使用更紧凑的格式,也许可以转向二进制数据文件。但在转向二进制格式以提高性能之前,要仔细思考。可能比你预期的更难实现性能提升,而且你很容易使性能变得更差!

这里有一个很好的理由说明为什么我们不应该使用二进制文件。基于文本的数据格式是可读的,我们可以打开并阅读它们,而无需特殊查看器应用程序。不要低估这一点的重要性!当我们试图理解或调试数据文件时,在文本编辑器中打开和查看该文件非常有帮助。

c04_06.eps

图 4.6 使用 Chrome 开发者工具识别包含要抓取数据 HTML 元素

4.4.1 解包自定义二进制文件

假设你被给了二进制文件 earthquakes.bin,并且你需要将其导入到你的数据库中。你如何解码这个文件?

首先,你需要了解二进制文件的结构。这不是基于文本的格式,所以你不能在文本编辑器中浏览它来理解它。假设二进制文件的提供者已经向我们解释了文件布局。他们说过,这是一个由一系列依次打包的二进制记录组成的序列(图 4.7)。文件首先指定它包含的记录数,你可以在图 4.7 中看到文件开头的Num records字段。

c04_07.png

图 4.7 Earthquakes.bin 是一个包含一系列依次打包记录的二进制文件。

我们数据提供者还解释说,每个记录通过一系列值来描述一次地震(图 4.8)。这些是双精度数字(JavaScript 的标准数字格式),表示每次地震的时间、位置、深度和震级。

c04_08.png

图 4.8 每个数据记录是一系列打包的值,描述了一个地震。

要处理二进制文件,我们将使用 Node.js 文件系统函数。我们将使用同步函数——例如,readFileSync—因为它们使代码更简单,尽管在生产环境中你可能会想使用异步版本以提高服务器的性能。在第三章中,我们将文本文件作为字符串读入内存;然而,在这里,我们将我们的二进制文件 earthquakes.bin 读入一个 Node.js Buffer 对象。

你可以在图 4.9 中看到这个过程的步骤。首先,你调用 readFileSync 将 earthquakes.bin 载入缓冲区(1)。然后,你将从缓冲区中读取记录数(2)。接下来,你开始一个循环,按顺序从缓冲区中读取每个记录(3)。记录的字段被提取并用于构建一个 JavaScript 对象(4),该对象被添加到你的记录数组中。

c04_09.eps

图 4.9 使用 Node.js 缓冲区对象从 earthquakes.bin 读取记录

图 4.10 描述了表示地震记录的 JavaScript 对象的构建。时间(1)、纬度(2)和其他字段(3)从缓冲区中读取并分配给 JavaScript 对象。

解码 earthquakes.bin 的代码非常简单,如下所示。你可以运行此代码,它将解码示例二进制文件并将数据打印到控制台。

列表 4.3 使用 Node.js 缓冲区对象解包 earthquakes.bin 二进制文件(listing-4.3.js)

const fs = require('fs');
const buffer = fs.readFileSync("./data/earthquakes.bin");    ①  

const numRecords = buffer.readInt32LE(0);    ②  

let bufferOffset = 4;
const records = [];

for (let recordIndex = 0; recordIndex < numRecords; ++recordIndex) {    ③  

 const time = buffer.readDoubleLE(bufferOffset);    ④  

 const record = {    ④  
 Time: new Date(time),    ④  
 Latitude: buffer.readDoubleLE(bufferOffset + 8),    ④  
 Longitude: buffer.readDoubleLE(bufferOffset + 16),    ④  
 Depth_Km: buffer.readDoubleLE(bufferOffset + 24),    ④  
 Magnitude: buffer.readDoubleLE(bufferOffset + 32),    ④  
 };    ④  

 bufferOffset += 8 * 5;    ⑤  

 records.push(record);    ⑥  
}    ③  

console.log(records);    ⑦   

c04_10.eps

图 4.10 从二进制地震记录中读取字段到 JavaScript 对象

4.4.2 打包自定义二进制文件

在上一个例子中,我们得到了 earthquakes.bin,这是一个二进制文件,我们必须解码它才能使用其中包含的数据。你可能很好奇这样一个文件最初是如何创建的。

打包 earthquakes.bin 实质上是与我们解包它的过程相反。我们从一个表示地震的 JavaScript 对象数组开始。如图 4.11 所示,地震对象的字段是顺序打包以形成一个二进制记录。首先,时间字段被打包(1),然后是纬度字段(2),依此类推,直到所有字段都被打包(3)到缓冲区中。

c04_11.eps

图 4.11 将 JavaScript 地震对象中的字段打包到 Node.js 缓冲区

你可以在图 4.12 中看到,每个记录都是紧密地一个接一个地打包到缓冲区中。我们首先创建一个 Node.js Buffer 对象(1)。在将记录写入缓冲区之前,我们必须首先记录记录数(2),因为这允许我们了解在稍后解码二进制文件时预期的记录数。然后我们按顺序将每个地震记录打包到缓冲区中(3)。最后,缓冲区被写入我们的二进制文件 earthquakes.bin(4)。这就是我们产生之前示例中给出的文件的方法。

将 earthquakes.json 转换为我们自定义的二进制格式的代码显示在列表 4.4 中;这比解包所需的代码复杂一些,但差别不大。你可以运行此代码,它将从 earthquakes.json 中读取示例数据,将数据打包到二进制缓冲区中,然后生成输出文件 earthquakes.bin。如果你想测试生成的 earthquakes.bin 是否是一个有效的文件,你可以将其再次通过列表 4.3 中的代码运行,以测试它是否可以被随后解包。

c04_12.png

图 4.12 将地震记录写入我们的二进制文件 earthquakes.bin

列表 4.4 使用 Node.js 缓冲区打包二进制文件 earthquakes.bin(listing-4.4.js)

const fs = require('fs');
const moment = require('moment');

const records = JSON.parse(  ①  
 fs.readFileSync("./data/earthquakes.json", 'utf8')    ①  
);    ①  

const bufferSize = 4 + 8 * 5 * records.length;    ②  
const buffer = new Buffer(bufferSize);    ③  

buffer.writeInt32LE(records.length);  ④  

let bufferOffset = 4;

for (let i = 0; i < records.length; ++i) {    ⑤  

    const record = records[i];
 const time = moment(record.Time).toDate().getTime();    ⑥  
 buffer.writeDoubleLE(time, bufferOffset);    ⑥  
 bufferOffset += 8;    ⑥  

 buffer.writeDoubleLE(record.Latitude, bufferOffset);    ⑥  
 bufferOffset += 8;    ⑥  

 buffer.writeDoubleLE(record.Longitude, bufferOffset);    ⑥  
 bufferOffset += 8;    ⑥  

 buffer.writeDoubleLE(record.Depth_Km, bufferOffset);    ⑥  
 bufferOffset += 8;    ⑥  

 buffer.writeDoubleLE(record.Magnitude, bufferOffset);    ⑥  
 bufferOffset += 8;    ⑥  
}    ⑤  

fs.writeFileSync("./output/earthquakes.bin", buffer);    ⑦   

注意,这里引入了对 moment 的依赖。这是我们首次在第二章中安装的用于处理日期和时间的出色库。

创建我们自己的自定义二进制数据格式是有问题的。代码杂乱无章,如果我们想要处理更大的文件,代码会变得更加复杂。输出格式不是人类可读的,所以除非我们记录格式的结构,否则我们可能会忘记它是如何工作的。这可能会使得未来解码我们的数据变得困难。

然而,如果你想要两者兼得,你还有一个选择。你想要的是既有 JSON 的便利性和可靠性,又有二进制数据的紧凑性:那么让我向你介绍 BSON(发音为 bison)。

4.4.3 用 BSON 替换 JSON

BSON,或二进制 JSON,是 JSON 的二进制编码序列化。虽然你不能在文本编辑器中打开 BSON 文件,但它(像 JSON 一样)是一个自描述的格式。你不需要文档来理解或记住如何解码数据文件。

BSON 是一个标准且成熟的数据格式。它是 MongoDB 的基础格式。它几乎可以无缝替换 JSON,并且很容易在 JSON 和 BSON 之间进行转换。

BSON 将允许你以更紧凑的方式存储你的 JSON。如果你试图节省磁盘空间或网络带宽,这可能很有用。但是,BSON 在性能上不会给你带来任何好处,因为它比 JSON 序列化稍微慢一些。因此,要使用 BSON,你必须在大小的性能之间做出权衡。

4.4.4 将 JSON 转换为 BSON

假设我们有一个名为 earthquakes.json 的 JSON 文件,它占用了我们磁盘上太多的空间。让我们将此文件转换为 BSON 格式,以便它占用更少的空间。

在这两个示例中,我们将使用bson库。如果你已经为第四章代码仓库安装了依赖项,那么你将已经拥有它;或者你可以在新的 Node.js 项目中按照以下方式安装它:

npm install --save bson 

列表 4.5 展示了如何将 earthquakes.json 转换为 BSON 文件。我们实例化BSON对象,并使用其serialize函数将我们的 JavaScript 数据转换为二进制 BSON 格式。结果是写入我们新数据文件 earthquakes.bson 的 Node.js Buffer对象。你可以运行以下列表中的代码,它将示例文件 earthquakes.json 转换为 BSON 文件 earthquakes.bson。

列表 4.5 将 JSON 数据转换为 BSON(listing-4.5.js)

const fs = require('fs');
const moment = require('moment');
const BSON = require('bson');

const records = JSON.parse(  ①  
    fs.readFileSync("./data/earthquakes.json", "utf8") 
); 

for (let i = 0; i < records.length; ++i) {   ②  
 const record = records[i];   ②  
 record.Time = moment(record.Time).toDate();   ②  
}   ②  

const bson = new BSON();   ③  
const serializedData = bson.serialize(records);   ④  

fs.writeFileSync("./output/earthquakes.bson", serializedData);   ⑤   

4.4.5 反序列化 BSON 文件

在之后,当我们需要解码 earthquakes.bson 时,我们可以使用bson库将其反序列化回 JavaScript 数据。我们首先将文件加载到 Node.js Buffer对象中。然后实例化一个BSON对象,并使用其deserialize函数解码缓冲区中的数据。最后,我们将重构的 JavaScript 数据结构打印到控制台以验证数据是否正确。代码在列表 4.6 中展示,你可以在示例 BSON 文件上运行它以将其转换为等效的 JSON 表示。你可能甚至想尝试在之前使用列表 4.5 代码生成的 BSON 文件上运行以下列表。你应该能够通过列表 4.5,然后列表 4.6,再回到列表 4.5 等等的方式循环处理你的文件。

列表 4.6 反序列化 BSON 数据(listing-4.6.js)

const fs = require('fs');
const BSON = require('bson');

const loadedData = fs.readFileSync("./data/earthquakes.bson");  ①   

const bson = new BSON();  ②   
const deserializedData = bson.deserialize(loadedData);  ③   

console.log(deserializedData);  ④   

在上一章中,你学习了导入和导出各种数据格式。在这一章中,你扩展了这方面的知识,涵盖了获取和存储数据的几种更神秘的方法。我们现在已经解决了几个重要的数据处理基础问题。在第五章中,我们将继续前进,学习探索性编码在原型设计和理解数据方面的价值。

摘要

  • 你学习了如何处理不寻常的数据格式。

  • 我们讨论了使用正则表达式解析自定义文本文件格式。

  • 我们使用request-promise和 Cheerio 进行了网页抓取,以从网页中提取数据。

  • 我们通过打包和解包自定义二进制格式的示例进行了操作。

  • 你学习了如何使用 BSON 处理二进制数据格式。*

5

探索性编码

本章涵盖

  • 理解快速反馈循环如何使您更高效

  • 原型设计以探索我们的数据并加深我们的理解

  • 使用 Excel 开始原型设计

  • 继续使用 Node.js 和浏览器进行原型设计

  • 设置一个 实时重载 编码管道,其中代码更改会自动流向数据和可视化输出

在本章中,我们将使用探索性编码深入挖掘您的数据,构建您的知识和理解。我们将使用一个易于理解的小型示例数据集,但在现实世界中,探索和理解我们数据的需求随着数据集的不断扩大而增长。

本章是数据处理过程的缩影。我们将经历获取、探索和理解、分析,最终到达可视化。不过,我们的重点在于快速原型设计,强调拥有一个简化和有效的反馈循环,以便我们可以快速编码并立即看到结果。

数据处理过程探索阶段的输出

  • 对您数据的更深入理解

  • 可能在生产中使用的 JavaScript 代码

5.1 扩展您的工具集

在本章中,我们将以多种方式扩展我们的数据处理工具集。我们将使用 Excel 进行初始原型设计和可视化。一旦达到 Excel 的极限,我们将转向 Node.js 进行探索和分析,然后最终转向浏览器进行可视化。

本章的主要心理工具是 快速反馈循环。快速迭代和减少反馈循环的往返是提高您生产力的关键。在本章中,我将把这个想法推向极致来说明问题,所以这比我的通常现实世界流程更为极端,但它并不遥远,并且与我通常的工作方式相似。

为了简化我们的反馈循环,我们将使用 Nodemonlive-server,这两个工具都会自动监视并执行我们的代码。这给了我们编写代码并观察结果进展的自由。我们将在本章中使用的所有工具列表见 表 5.1。

表 5.1 第五章中使用的工具

平台 工具 用途
Excel 查看器/编辑器 查看和编辑数据
Excel 图表 可视化数据
Excel 公式 探索性编码
JavaScript console.log 不要低估,控制台日志是您最重要的调试工具。
Data-Forge JavaScript 数据处理工具包
Node.js Formula.js Node.js 实现的 Excel 公式
Nodemon 实时代码重载
浏览器 live-server 简单的 Web 服务器和实时代码重载
Flot 可视化

5.2 分析交通事故

本章的数据主题是昆士兰州车祸。假设我们被问到以下问题:昆士兰州的致命车祸是上升还是下降?我们希望将此数据引入我们的管道,探索它,理解它,绘制趋势,并预测未来。

通过 Excel 和后来的编码,我们将加深对数据的理解。我们将创建一个快速迭代编码的过程,几乎可以立即得到结果,并且当我们在编写代码或修改数据时,可视化会自动更新。

我们的目的是理解这些车祸中的死亡趋势,并预测未来是上升还是下降。剧透一下:图 5.1 展示了本章的最终结果——我们将在浏览器中产生的简单可视化。

c05_01.png

图 5.1 展示了 2001 年和 2002 年死亡趋势的原型网络可视化

5.3 获取代码和数据

本章的数据是从昆士兰州政府数据网站下载的。原始数据集很大,包括所有单个车祸。为了使数据易于您使用并使本章保持简单,我已经将数据总结为月度桶。代码和总结后的数据可在 GitHub 上的 Data Wrangling with JavaScript Chapter-5 存储库中找到,网址为github.com/data-wrangling-with-javascript/chapter-5

因为在本章中我们也在浏览器中工作,所以你必须按照以下步骤在存储库的根目录中安装 Bower 依赖项:

`bower install` 

由于 npm 是 Node.js 开发的包管理器,Bower 是浏览器开发的包管理器。

如果你想玩完整的原始数据,你可以在data.qld.gov.au/dataset/crash-data-from-queensland-roads找到它。有关获取代码和数据的通用帮助,请参阅第二章中的“获取代码和数据”。

*## 5.4 迭代与你的反馈循环

本章的重点是拥有一个快速的反馈循环。这究竟是什么,为什么它很重要?

你是否曾经编写了大量代码,然后在测试之前感到恐惧?大量的代码隐藏了更多的错误,并且更难测试。在编码时,错误会悄悄进入并隐藏。我们编码的时间越长,没有反馈,积累的错误就越多。我们调试代码的过程通常很耗时。我们可以在错误被创建后的第一时间抓住错误,从而挽回大量的生产力。

我们通常应该编写一个快速循环,通过多次迭代来扩展(图 5.2):编写代码,获取反馈,解决问题等等。循环的每一次迭代都必须很小,并且我们必须能够轻松测试我们编写的新代码。

c05_02.eps

图 5.2 探索性编码是一系列迭代,它推动你朝着目标前进,并帮助你保持目标方向。

重要的是许多小的迭代。每个迭代的输出都是可工作的代码,所以我们从可工作的代码到可工作的代码,再到可工作的代码,如此循环。我们不允许有错误的代码在这个过程中前进。问题迅速暴露,错误不会累积。这些小变化和反馈的序列最终汇总成大量但可靠的代码。它让我们有信心代码在生产中能正确运行。在整个过程中看到我们的代码持续工作也是令人满意和有动力的。

我们可以采取任何措施来减少迭代的时长,这将提高生产力。自动化和流程简化将有所帮助,在本章中,我们将探讨如何使用 Nodemon(用于 Node.js)和 live-server(用于浏览器)来实现这一点。

反馈循环全在于尽快看到我们的代码运行并得到实际结果。它还帮助我们保持对目标的关注:在每次迭代中,我们都有机会自然地评估我们的位置和方向。这使我们能够专注于我们的目标,并采取更直接的途径实现我们的目标。它促使我们解决问题并快速克服障碍。它帮助我们排除干扰并保持进度。

5.5 对数据理解的第一步

让我介绍一个简单的思维工具,我称之为“数据理解表”。让我们在构建对数据的理解过程中填写这个表格。作为初步尝试,我们查看数据查看器中的数据以了解其结构。

在开始时,我们对数据一无所知,除了我们可以预期有行和列。最初,我们的数据理解表是空的。在处理数据后,我填写了如表 5.2 所示的表格。

表 5.2 数据理解表:在 Excel 中查看数据后我们所了解的数据

数据类型 预期值 描述
Year Integer 2001、2002 等 崩溃发生的年份
Month String 一月、二月等 崩溃发生的月份
Crashes Integer 零或正数,没有负数 本年/月发生的崩溃次数
Fatalities Integer 零或正数,没有负数 本年/月发生的致命事故次数
etc. etc. etc. etc.

图 5.3 展示了在 Excel 中查看的 monthly_crashes_full.csv。当我们第一次查看这个文件时,我们扫描标题行并了解表格数据的列名。接下来,我们扫描数据中的初始几行,并对我们可以预期的数据类型和值范围做出合理的猜测。我们在了解数据的过程中填写我们的数据理解表。

c05_03.eps

图 5.3 使用 Excel 开发对数据的初步理解

在这个相当简单的例子中,我们通过查看查看器中的数据几乎学到了我们需要知道的一切。但文件的其他部分没有义务遵循这些规则!对于这个例子,输入数据已经相当干净。在其他项目中,数据可能不会表现得那么好,可能会有很多问题!我们将在第六章中解决这个问题。

5.6 处理缩减后的数据样本

当我们开始处理数据集时,通常最好从一个缩减的样本开始。当我们处理大量数据时,这一点尤其如此,我们将在第七章和第八章中更详细地讨论这一点。大型数据集可能难以处理,会拖慢我们的迭代速度,使我们效率降低。因此,我们应该旨在仅使用数据的一小部分来原型设计。我们可以在同时开发我们的理解和代码,最终,当我们确信我们的代码是健壮和可靠的,我们可以扩展到完整的数据集。

从昆士兰州政府下载的原始数据文件超过 138 MB。处理这样大的文件并不容易。我已经将原始数据整理并汇总到 monthly_crashes_full.csv 文件中。使用我为你们准备的数据,我们已经在本章中处理了一个较小的数据样本。monthly_crashes_full.csv 文件*的重量为 13 KB。我们的数据已经很小,但进一步缩减它也无妨。我们可以通过在 Excel(或文本编辑器)中加载数据并删除前 200 行之后的所有内容来实现这一点。

将缩减后的数据保存为新的文件 monthly_crashes-cut-down.csv。始终要小心不要覆盖你的原始数据!你不想丢失你的源数据!我们也可以使用 Excel 快速删除我们不需要的任何列。多余的数据是我们不需要的额外负担。

我们已经显著减少了数据量。文件大小为 monthly_crashes-cut-down.csv 现在约为 1 KB。使用轻量级数据集意味着我们可以快速工作,而且我们不会因为等待可能被数据量压垮的任何过程或工具而减慢速度。

*## 5.7 使用 Excel 进行原型设计

我们从使用 Excel 进行原型设计和数据探索开始。我们只在使用 Node.js 之前使用 Excel 进行快速原型设计,这可以节省初始时间。我们已经用它来查看和缩减我们的数据。现在让我们使用 Excel 来原型设计一个公式和可视化。

我们将在数据集中创建一个新的趋势列。使用 Excel 的预测函数,我们将根据六个月的数据预测死亡人数。预测函数需要输入 xy 值。我们已经有我们的 y 值:那就是现有的死亡人数列。但我们没有明显的列可以用作 x 值,因此我们必须生成一个新的列,它是一个数字序列。我称这个列为 Month#,因为它标识了序列中月份的编号。

我们可以在 Excel 中通过输入一个短序列(1, 2, 3, 4),选择该序列,然后将其拖动到列的长度来创建这个列。Excel 将会外推我们的数字序列以填充整个列。

现在,我们可以继续添加我们的新趋势列。创建一个新列,在六行空行之后输入预测公式,如图 5.4 所示。趋势列中的每一行都偏移了六行,因为它是由前六个月的数据计算得出的。

c05_04.eps

图 5.4 使用预测公式预测下个月的死亡人数

现在,我们选择包含预测公式的单元格,并将其拖动到趋势的末尾。图 5.5 展示了完成的趋势列。列中的每个值都是基于前六个月预测的该月死亡人数。

c05_05.eps

图 5.5 添加趋势列后的月度事故

现在,我们可以使用 Excel 的图表功能来可视化 2001 年至 2002 年期间汽车事故死亡趋势,如图 5.6 所示。从这张图中我们可以看出,在半数期间内死亡人数在下降,然后看起来趋势在图表的末尾又开始逆转。

c05_06.png

图 5.6 在 Excel 图表中可视化的致命汽车事故趋势列

我们已经对我们的数据有了更多的了解,而且我们甚至还没有接触任何代码!这是一种极快的方式开始你的数据,并且从数据到可视化比直接跳入深水区并尝试制作基于网络的可视化要快得多。我们可以用 Excel 做很多事情,所以我们不应该低估它。有时,它就是你所需要的全部。

为什么要转向代码呢?首先,你可能已经注意到在使用 Excel 时需要手动准备数据。我们必须拖动月份编号和趋势列,而在大量数据中这类事情变得相当繁琐,但我们可以用代码轻松完成。此外,我还必须手动调整数据以生成图 5.6 中的漂亮图表。

然而,转向代码的主要原因是你可以扩展并自动化繁琐且费力的数据准备。我们可能还希望使用网络提供交互式可视化。最终,我们需要让我们的代码在生产环境中运行。我们希望在 Node.js 服务器上运行我们的数据分析代码或在网络浏览器中显示交互式图表。现在是时候从 Excel 转移注意力,转向使用 JavaScript 进行探索性编码了。

5.8 使用 Node.js 进行探索性编码

在我们努力扩展并处理大量数据的过程中,我们现在转向 Node.js 进行探索性编码。在这一节中,我们将把我们的 Excel 原型转换为在 Node.js 中工作。在这样做的同时,我们将用代码探索我们的数据。我们可以构建我们对数据的理解,同时编写有用的代码。

在我们通过这一节工作时,我们将逐步发展一个 Node.js 脚本。由于本章的重点是迭代编码,我们将通过逐步升级脚本的过程来逐步完成每个小步骤,直到我们达到目标,即输出与图 5.5 中所示类似的计算趋势列的 CSV 文件。你可以通过查看和运行 listing-5.1.js,然后是 listing-5.2.js,依此类推,直到 listing-5.12.js,随着我们通过这一章的进展来跟随脚本的发展。代码文件可在 GitHub 仓库中找到。

我们将重现我们在 Excel 中原型化的趋势列。我们将从命令行运行我们的 Node.js 脚本。它将以 monthly_crashes-cut-down.csv 作为输入,并产生一个名为 trend_output.csv 的新 CSV 文件,其中包含计算出的趋势列。

我们在这里将使用的重要工具称为 Nodemon。这是一个工具(基于 Node.js 构建),它会监视我们的代码,并在我们工作时自动执行它。这自动化了反馈循环中的运行代码部分。这种自动化简化了我们的迭代过程,并使我们能够快速行动。

图 5.7 展示了我的基本编码设置。左侧是我的代码窗口(使用 Visual Studio Code)。右侧是运行 Nodemon 的命令行(在 Windows 上使用 ConEmu)。当我编辑并保存左侧的代码时,我会看到右侧的代码自动执行。通常我会在我台式机的多个显示器上运行这个设置。我也经常在我的笔记本电脑上工作,尽管由于屏幕空间较小,实现并排布局更困难。

c05_07.eps

图 5.7 左侧编码,右侧查看输出

Nodemon 持续监视脚本文件的变化。当检测到变化时,它会自动执行代码并产生新的输出(这个过程在图 5.8 中展示)。这使我们能够在编码的同时查看结果。

5.8.1 使用 Nodemon

到目前为止,在本书中,我们一直在使用安装到我们的 Node.js 项目中的 npm 模块。Nodemon 和很快的 live-server 是我们将在系统上全局安装而不是在项目中本地安装的第一个工具。为此,我们在使用 npm 安装时添加–g(全局)参数。让我们运行 npm 并全局安装 Nodemon:

npm install -g nodemon 

c05_08.png

图 5.8 Nodemon 监视你的代码,并在你进行更改时自动执行它。

现在,我们可以使用命令行中的 Nodemon 代替 Node.js。例如,通常你会这样运行一个 Node.js 脚本:

node listing-5.1.js 

我们然后将 Node.js 替换为 Nodemon,如下所示:

nodemon listing-5.1.js 

通常情况下,当我们运行 Node.js 时,一旦脚本运行完成,它就会退出。然而,Nodemon 不会退出;相反,一旦脚本完成,它会暂停,然后等待脚本被修改。当 Nodemon 检测到文件已更改时,它会再次执行代码。这个循环会一直持续到你使用 Ctrl-C 退出 Nodemon。

现在,让我们看看我们的第一个脚本文件 listing-5.1.js,我们将在本节的过程中逐步改进它。我们这里的重点是脚本的演变。我们将从一个简单的东西(输出文本)开始,然后逐步改进代码,直到我们到达目的地并输出 CSV 文件 trend_output.csv。

列表 5.1 输出到控制台(listing-5.1.js)

'use strict;'

console.log("Hello world"); 

列表 5.1 的代码非常简单。我相信始终从一个简单的地方开始,然后逐步构建到更复杂的东西是一个好主意。你可以运行这段代码,并轻松验证它是否工作。

我通常不会从这么简单的代码开始,但我想要从 console.log 开始,因为它是一个重要的工具。console.log 函数是你的好朋友。我们已经在第三章和第四章中广泛使用了它来验证我们的数据,并且我们将在整本书中继续使用它。

现在,使用 Nodemon 从命令行运行脚本:

nodemon listing-5.1.js 

确保你已经设置好环境以便进行代码更改并查看 Nodemon 的输出。你可能需要将编辑器和输出窗口并排排列,就像在图 5.7 中展示的那样。

现在,将文本 Hello world 改成其他内容,比如 Hello data analysis。Nodemon 会检测到这个更改,执行代码,你应该会看到类似于图 5.9 的输出。这个简单的测试让你可以检查你的实时重新加载编码管道是否工作。

c05_09.eps

图 5.9 Nodemon 会自动在你工作时执行你的代码。

5.8.2 探索你的数据

让我们做一些实际的数据分析。首先,我们将使用我们在第三章中创建的 importCsvFile 工具函数来加载输入 CSV 文件(monthly_crashes-cut-down.csv)。我们将使用 console.log 打印内容,如下面的列表所示。运行此代码并检查控制台上的输出。

列表 5.2 加载你的输入 CSV 文件并将其内容打印到控制台(listing-5.2.js)

const importCsvFile = require('./toolkit/importCsvFile.js');

importCsvFile("./data/monthly_crashes-cut-down.csv")
    .then(data => {
 console.log(data);    ①  
    })
    .catch(err => {
        console.error(err && err.stack || err);
    }); 

将数据打印到控制台让我们从代码的角度首次看到数据。不幸的是,这里的数据太多了,我们的输出超出了屏幕。我们已经在处理一个数据样本的裁剪版本,但仍然太多了,我们只想一次查看几条记录,就像你在图 5.10 中看到的那样。

现在,让我们使用 JavaScript 数组的 slice 函数来截取一小部分数据进行检查。你应该运行以下列表中的代码来查看裁剪的数据样本。这是产生图 5.10 中所示输出的代码。

c05_10.png

图 5.10 我们不想被输出淹没,我们只想一次查看几个记录。

列表 5.3 切割并打印数据的一部分以进行检查(listing-5.3.js)

const importCsvFile = require('./toolkit/importCsvFile.js');

importCsvFile("./data/monthly_crashes-cut-down.csv")
    .then(data => {
 const sample = data.slice(0, 3);    ①  
        console.log(sample);
    })
    .catch(err => {
        console.error(err && err.stack || err);
    }); 

我们还可以使用 slice 函数通过指定起始索引来从数据中间提取数据,如下所示:

var sample = data.slice(15, 5); 

slice 函数还接受一个负索引来从数组的末尾提取数据。这允许我们查看数据集末尾的记录。例如,让我们使用负 3 索引来查看数据集中的最后三个记录:

var sample = data.slice(-3); 

现在,让我们深入探讨并更详细地检查数据。我们可以查看输出(例如,参见图 5.11 中的输出),并对照我们的数据理解表来查看数据集的开始、中间和结束部分是否与我们对数据的当前理解一致。如果不一致,你可能需要更新你的数据理解表。

现在,让我们检查我们数据中存在的数据类型。我们可以使用 JavaScript 的 typeof 操作符来显示每个字段的类型。图 5.11 显示了第一条记录的类型。

c05_11.png

图 5.11 使用 JavaScript 的 typeof 操作符检查第一条记录中的类型

生成图 5.11 中输出的代码显示在列表 5.4 中。查看第一条记录,并使用 typeof 操作符检查第一条记录中每个字段的 JavaScript 类型。我们开始验证我们对数据的假设。你可以运行以下列表,并会看到数据集中存在的数据类型。

列表 5.4 使用代码检查你的数据类型(listing-5.4js)

const importCsvFile = require('./toolkit/importCsvFile.js');

importCsvFile("./data/monthly_crashes-cut-down.csv")
    .then(data => {
        const sample = data[0];
 console.log("Year: " + typeof(sample.Year));    ①  
 console.log("Month: " + typeof(sample.Month));    ①  
 console.log("Crashes: " + typeof(sample.Crashes));    ①  
 console.log("Fatalities: " + typeof(sample.Fatalities));    ①  
    })
    .catch(err => {
        console.error(err && err.stack || err);
    }); 

我们已经检查了数据的第一行符合我们的初始假设,并且数据类型与我们预期的完全一致。这仅仅是数据的第一行;然而,文件中的其余部分可能不符合你的假设!进行快速检查以确保我们不会在后续遇到任何问题是有价值的。在下面的列表中,我们已经修改了我们的脚本,以便使用 Node.js 的 assert 函数迭代 所有 数据并检查每一行。

列表 5.5 使用 assert 检查数据集是否符合你的假设(listing-5.5.js)

const assert = require('assert');
const importCsvFile = require('./toolkit/importCsvFile.js');

importCsvFile("./data/monthly_crashes-cut-down.csv")
    .then(data => {
 data.forEach(row => {    ①  
 assert(typeof(row.Year) === "number");    ①  
 assert(typeof(row.Month) === "string");    ①  
 assert(typeof(row.Crashes) === "number");    ①  
 assert(typeof(row.Fatalities) === "number");    ①  
 });    ①  
    })
    .catch(err => {
        console.error(err && err.stack || err);
    }); 

你可以运行列表 5.5 中的代码来验证假设,这是一个重要的步骤,但在这种情况下,它并没有做什么。这是因为我们的数据已经干净且表现良好。我们将在第六章中重新访问假设检查脚本。

我们的数据已经符合我们的假设,但我们事先无法知道这一点。运行这样的数据检查脚本可以防止我们在后续过程中遇到问题。这个脚本在将来当我们扩展到完整数据集时也会很有用。如果需要接受更新的数据,这个脚本也会很有用,因为我们无法保证我们将来收到的数据将遵循相同的规则!

5.8.3 使用 Data-Forge

c05_12.tif

图 5.12 使用 Data-Forge 从 CSV 文件输出列名

在这一点上,我想介绍 Data-Forge,这是我为 JavaScript 编写的开源数据处理工具包。它就像瑞士军刀一样,用于处理数据,并具有许多有用的函数和功能,尤其是在探索我们的数据时。在本章中,我们将特别使用 Data-Forge 的rollingWindow函数来计算我们的趋势列。我们将在本书的后面部分了解更多关于 Data-Forge 的信息。

如果您为第五章代码仓库安装了依赖项,您已经安装了 Data-Forge;否则,您可以在一个新的 Node.js 项目中按照以下步骤安装它:

npm install –-save data-forge 

我们将使用 Data-Forge 做的第一件事是读取 CSV 文件并打印列名。此输出的显示如图 5.12 所示。

Data-Forge 有一个readFile函数,我们用它来加载数据集。Data-Forge 可以读取 JSON 和 CSV 文件,因此我们需要调用parseCSV来明确告诉 Data-Forge 将文件作为 CSV 数据处理。然后我们调用getColumnNames来检索列名。您可以运行以下列表的代码,它将打印出如图 5.12 所示的列名。

列表 5.6 使用 Data-Forge 加载 CSV 文件并列出列名(listing-5.6.js)

const dataForge = require('data-forge');    ①  

dataForge.readFile("./data/monthly_crashes-cut-down.csv")    ②  
 .parseCSV()    ③  
    .then(dataFrame => {
 console.log(dataFrame.getColumnNames());    ④  
    })
    .catch(err => {
        console.error(err && err.stack || err);
    }); 

当我们使用 Data-Forge 读取 CSV 文件时,它给我们一个包含数据集的 DataFrame 对象。DataFrame 包含许多函数,可以对我们的数据进行切片、切块和转换。让我们使用 Data-Forge 的headtail函数从数据集的开始和结束提取并显示数据行。Data-Forge 提供了格式良好的输出,如图 5.13 所示。

c05_13.png

图 5.13 使用 Data-Forge 查看数据集头部和尾部的行

列表 5.7 使用headtail函数查看我们的数据。这些函数的使用会产生一个新的 DataFrame 对象,其中只包含数据集的前 X 行或最后 X 行数据。然后使用toString函数生成图 5.13 中显示的格式良好的表格。您可以运行此代码并亲自查看输出。

列表 5.7 使用 Data-Forge 查看数据集头部和尾部的行(listing-5.7.js)

const dataForge = require('data-forge');

dataForge.readFile("./data/monthly_crashes-cut-down.csv")
    .parseCSV()
    .then(dataFrame => {
        console.log("=== Head ===");
 console.log(dataFrame.head(2).toString());    ①  

        console.log("=== Tail ===");
 console.log(dataFrame.tail(2).toString());    ②  
    })
    .catch(err => {
        console.error(err && err.stack || err);
    }); 

Data-Forge 做的一件有用的事情是总结我们数据集中存在的类型。图 5.14 显示了格式良好的 Data-Forge 数据类型总结。

c05_14.png

图 5.14 使用 Data-Forge 总结数据集中的数据类型——它们都是字符串!

图 5.14 中的输出是由 Data-Forge 函数detectTypes产生的,它扫描数据集并生成一个新的表格,显示我们数据中不同类型的频率。

您可能已经注意到图 5.14 中,我们所有的数据类型都是字符串!这肯定是不正确的!之前,当我们使用我们的importCsvFile工具函数时,我们的数据被加载为我们预期的类型:Crashes、Fatalities 和 Hospitalized 列都是数字。这是因为我们使用了 Papa Parse 来解析 CSV,并使用了它的自动类型检测。

CSV 数据格式,与 JSON 不同,没有对数据类型提供任何特殊支持;每个字段只是一个字符串。Papa Parse 内置了额外的智能,它会查看值以尝试确定它们看起来像什么的类型,但 CSV 数据格式本身并没有内置对数据类型的理解,因此 Data-Forge 不会自动检测它们。(注意:现在您可以在 Data-Forge 的最新版本中启用dynamicTyping;它底层使用 Papa Parse。)我们必须明确决定我们希望如何解释我们的数据,并使用parseFloats函数相应地指导 Data-Forge,如列表 5.8 所示。

列表 5.8 使用 Data-Forge 解析数据类型(listing-5.8.js)

const dataForge = require('data-forge');

dataForge.readFile("./data/monthly_crashes-cut-down.csv")
    .parseCSV()
    .then(dataFrame => {
 dataFrame = dataFrame.parseFloats(  [  ①  
 "Month#",    ①  
 "Year",    ①  
 "Crashes",    ①  
 "Fatalities",    ①  
 "Hospitalized"    ①  
 ]);    ①  
 console.log(dataFrame.detectTypes().toString());    ②  
    })
    .catch(err => {
        console.error(err && err.stack || err);
    }); 

图 5.15 显示了解析数字列后的输出结果。所有列都是 100%的数字,除了月份列。

c05_15.png

图 5.15 解析数据类型后,我们看到数据集中我们预期的类型。

5.8.4 计算趋势列

我们已经探索并理解了我们的数据。我们已经检查了关于数据的假设。现在是时候进行有趣的部分了。我们将计算趋势列。我在本章介绍 Data-Forge 不仅是因为它适合探索我们的数据,还因为它使我们的下一个任务更容易。

趋势列是从 Fatalities 列计算出来的,因此我们需要提取 Fatalities 列并在其上运行我们的 Excel FORECAST 公式。这生成了趋势列,但然后我们必须将列放回数据集中,并将其保存为新的 CSV 文件 trend_output.csv。

我们可以先提取 Fatalities 列并将其打印到控制台。我们不需要打印整个列,所以我们再次使用 Data-Forge 的head函数来仅显示数据的前几行。输出结果如图 5.16 所示。

c05_16.png

图 5.16 使用 Data-Forge 提取并显示的 Fatalities 列的前几行

我们使用 getSeries 函数从 DataFrame 中提取趋势列。这返回一个包含该列数据的 Data-Forge Series 对象。然后 head 函数提取前几行或数据,我们使用 toString 格式化输出,以便于显示。你可以运行 代码列表 5.9,你将看到与图 5.16 相同的输出。

列表 5.9 使用 Data-Forge 提取并显示死亡人数列的前几行(listing-5.9.js)

const dataForge = require('data-forge');

dataForge.readFile("./data/monthly_crashes-cut-down.csv")
    .parseCSV()
    .then(dataFrame => {
        dataFrame = dataFrame.parseFloats([
            "Month#",
            "Year",
            "Crashes",
            "Fatalities",
            "Hospitalized"
        ]);
 console.log(dataFrame    ①  
 .getSeries("Fatalities")    ①  
 .head(3)    ①  
 .toString()    ①  
 );    ①  
    })
    .catch(err => {
        console.error(err && err.stack || err);
    }); 

现在,我们已经提取了死亡人数序列,我们可以计算趋势。我们可以轻松地将 Excel 公式移植到 Node.js,使用优秀的 npm 模块 Formula.js。如果你为第五章的 GitHub 仓库安装了依赖项,你已经有 Formula.js。如果没有,你可以在新的 Node.js 项目中安装它,如下所示:

npm install –-save formulajs 

Formula.js 是 Excel 公式函数的 JavaScript 实现。它方便在 Excel 中原型化数据分析,然后在 Node.js 中精确地重现。

使用 Formula.js,我们可以重新创建我们在 Excel 中之前原型化的预测公式。我们的第一步是在前六个月的数据上测试这个公式,并得到一个预测值,如图 5.17 所示。

c05_17.png

图 5.17 使用 Formula.js 从前六个月的数据预测死亡人数

我们从 DataFrame 中提取 Month# 和死亡人数序列,取每个序列的前六行(对于前六个月的数据)并将这些作为 FORECAST 函数的输入。这段代码显示在 代码列表 5.10 中。运行此代码,它将预测未来六个月的死亡人数,并显示图 5.17 中的结果。

列表 5.10 使用 Formula.js 重新生成 Excel 的 FORECAST 公式并基于前六个月的死亡人数预测下个月的死亡人数(listing-5.10.js)

const dataForge = require('data-forge');
const formulajs = require('formulajs');

dataForge.readFile("./data/monthly_crashes-cut-down.csv")
    .parseCSV()
    .then(dataFrame => {
        dataFrame = dataFrame.parseFloats([
            "Month#", "Year", "Crashes", "Fatalities",
            "Hospitalized"
        ]);
 const monthNoSeries = dataFrame.getSeries("Month#");    ①  
 const xValues = monthNoSeries.head(6).toArray();    ①  
 const fatalitiesSeries = dataFrame.getSeries("Fatalities");    ②  
 const yValues = fatalitiesSeries.head(6).toArray();    ②  
 const nextMonthNo = monthNoSeries.skip(6).first();    ③  
 const nextMonthFatalitiesForecast =    ④  
 formulajs.FORECAST(nextMonthNo, yValues, xValues);    ④  
 console.log('Forecasted fatalities: ' +    ⑤  
 nextMonthFatalitiesForecast);    ⑤  
    })
    .catch(err => {
        console.error(err && err.stack || err);
    }); 

尽管如此,我们还没有完成。我们只计算了一个预测值,你还需要计算整个趋势列。

在接下来的时间里,我们将覆盖更多内容,Data-Forge 将承担大部分繁重的工作。如果你在这里遇到困难,请不要过于担心;我们将在后面的章节中更详细地介绍 Data-Forge。

目前,只需了解我们正在使用 Data-Forge 的 rollingWindow 函数以六个月为一个数据块(称为数据窗口)迭代我们的数据,对于每个六个月的数据块,我们将预测一个新的值,构建未来值的滚动预测。这个过程的结果将是我们的计算出的趋势列。

这是我们之前在 Excel 中手动完成的事情,现在我们将使用代码来完成这项工作。计算出的趋势列将集成回 DataFrame 并输出到控制台,如图 5.18 所示。

c05_18.png

图 5.18 计算出的趋势列的 DataFrame

注意在 列表 5.11 中我们如何使用 setIndex 将月份数列设置为 DataFrame 的索引。在 DataFrame 上有索引可以允许使用 withSeries 函数(您可以在代码列表的末尾看到)将其新趋势列集成到 DataFrame 中。再次提醒,不要过于努力地理解 rollingWindow 在这里的使用;我们将在后面的章节中回到它。您可以运行此代码,您将看到图 5.18 所示的输出。

列表 5.11 使用 Data-Forge 的 rollingWindow 计算趋势列(listing-5.11.js)

const dataForge = require('data-forge');
const formulajs = require('formulajs');

dataForge.readFile("./data/monthly_crashes-cut-down.csv")
    .parseCSV()
    .then(dataFrame => {
        dataFrame = dataFrame
            .parseFloats([
            "Month#", "Year", "Crashes",
                "Fatalities", "Hospitalized"
        ])
 .setIndex("Month#");    ①  
        const fatalitiesSeries = dataFrame.getSeries("Fatalities");
        const fatalitiesSeriesWithForecast =
 fatalitiesSeries.rollingWindow(6)    ②  
 .select(window => {    ③  
 const fatalitiesValues = window.toArray();    ③  
                    const monthNoValues =
 window.getIndex().toArray();  ③  
                    const nextMonthNo =
 monthNoValues[monthNoValues.length-1] + 1;    ③  
 return [  ③  
 nextMonthNo,    ③  
 formulajs.FORECAST(    ③  
 nextMonthNo,    ③  
                            fatalitiesValues, 
 monthNoValues    ③  
 )    ③  
 ];    ③  
 })    ③  
 .withIndex(pair => pair[0])    ④  
 .select(pair => pair[1]);    ④  
 const dataFrameWithForecast = dataFrame.withSeries({    ⑤  
 Trend: fatalitiesSeriesWithForecast    ⑤  
 });    ⑤  
 console.log(dataFrameWithForecast.toString());    ⑥  
    })
    .catch(err => {
        console.error(err && err.stack || err);
    }); 

5.8.5 输出新的 CSV 文件

我们几乎得到了我们的结果!我们必须做的最后一件事是将数据输出为一个新的 CSV 文件。这可以通过 Data-Forge 的 asCSVwriteFile 函数简化,如下所示。如果您运行此代码,它将输出一个名为 trend_output.csv 的 CSV 文件。

列表 5.12 在 Data-Forge 的帮助下计算趋势列并输出新的 CSV 文件(listing-5.12.js)

const dataForge = require('data-forge');
const formulajs = require('formulajs');

dataForge.readFile("./data/monthly_crashes-cut-down.csv")
    .parseCSV()
    .then(dataFrame => {
        dataFrame = dataFrame
            .parseFloats(["Month#", "Year", "Crashes",
                "Fatalities", "Hospitalized"]
            )
            .setIndex("Month#");
        const fatalitiesSeries = dataFrame.getSeries("Fatalities");
        const fatalitiesSeriesWithForecast =
            fatalitiesSeries.rollingWindow(6)
                .select(window => {
                    const fatalitiesValues = window.toArray();
                    const monthNoValues =
                        window.getIndex().toArray();
                    const nextMonthNo =
                        monthNoValues[monthNoValues.length-1] + 1;
                    return [
                        nextMonthNo,
                        formulajs.FORECAST(
                            nextMonthNo,
                            fatalitiesValues,
                            monthNoValues
                        )
                    ];
                })
                .withIndex(pair => pair[0])
                .select(pair => pair[1]);
        const dataFrameWithForecast = dataFrame.withSeries({
            Trend: fatalitiesSeriesWithForecast
        });
        return dataFrameWithForecast
 .asCSV()  ①  
 .writeFile("./output/trend_output.csv");    ②  
    })
    .catch(err => {
        console.error(err && err.stack || err);
    }); 

现在我们已经生成了包含计算出的趋势列的新 CSV 文件 trend_output.csv,我们可以将其带回到 Excel 中查看其外观!如图 5.19 所示,在 Excel 中打开 CSV 文件,并检查其格式良好,以及新列看起来是否符合预期。

c05_19.png

图 5.19 我们使用 Data-Forge 从 Node.js 生成的最终 CSV 文件。注意计算出的趋势列。

您甚至可以从这些生成数据创建图表,以快速查看其可视化效果。我们现在不会这样做;我们将使用这个 CSV 文件并在网页上显示它。让我们将注意力转向浏览器!

5.9 浏览器中的探索性编码

在使用 Node.js 生成包含计算出的趋势列的新 CSV 文件 trend_output.csv 之后,我们现在将为这些数据创建一个交互式网页可视化。为了生成可视化,我们将使用简单而有效的 Flot JavaScript 图表库。

在本节中,我们将通过 HTML 文件逐步完善我们的网页可视化。正如我们在上一节中所做的那样,我们将从简单开始,逐步将代码向我们的目标发展。我们的目标是生成图 5.20 所示的可视化。您可以通过查看 listing-5.13.html、listing-5.14.html 和 listing-5.15.html 来跟随代码的演变,我们在本章剩余部分进行工作。这些文件可在 GitHub 仓库中找到。

本节的主要工具称为 live-server。Live-server 是一个简单的命令行网页服务器;尽管它不是为了生产使用而设计的,但它对于快速原型设计非常出色。

Live-server 提供了一个即时网页服务器,其工作方式如图 5.21 所示。我们不需要手动编写网页服务器代码来开始原型设计我们的基于网页的可视化——这真是太好了,因为我们正在原型设计,我们希望快速进行。

Live-server,就像 Nodemon 一样,有助于自动化我们的工作流程。它监视我们的代码,并在检测到代码更改时自动刷新我们的网页。

我在这个部分使用的编码设置如图 5.22 所示。左边是我们正在开发的可视化代码。右边是显示我们网页的浏览器。当我们专注于左边时,live-server 会自动在右边刷新我们的可视化以显示更新后的结果。

c05_20.png

图 5.20 你的网页可视化的最终输出。伤亡趋势随时间变化。

c05_21.eps

图 5.21 运行 live-server 以快速原型化网页可视化。

要使用 live-server,你应该按照以下方式全局安装它:

npm install –g live-server 

现在你可以从命令行运行 live-server,尽管在我们启动即时网页服务器之前,我们需要创建一个简单的网页。在进化编码的持续精神中,我们从简单开始,确保它工作,然后在我们迭代代码时保持其工作状态,我们将从最简单的网页开始,如图 清单 5.13 所示。我们将使用 JavaScript 创建我们的网页可视化,因此网页包含一个脚本部分,将“Hello world!”写入网页。

c05_22.eps

图 5.22 使用 live-server,你可以编辑代码,并在你进行更改时立即看到网页刷新。

列表 5.13 最简单的网页,用于启动你的网页可视化迭代编码(listing-5.13.html)

<!doctype html>
<html lang="en">
    <head>
        <title>My prototype web page</title>
    </head>
    <body>
        <script>
            //
            // Your JavaScript code goes here.
            //
            document.write("Hello world!");
        </script>
    </body>
</html> 

现在让我们启动网页服务器。在代码仓库目录中从命令行运行 live-server:

cd Chapter-5
live-server 

创建用于原型化的网页就这么简单!Live-server 自动打开我们的默认浏览器,我们可以浏览到 listing-5.13.html 来查看网页。

现在让我们更新我们的代码。我们需要 jQuery 和 Flot。如果你在第五章代码仓库中安装了 Bower 依赖项,那么你已经有它们了。否则,你可以按照以下方式将它们安装到一个新的网页项目中:

bower install –-save jquery flot 

现在我们已经安装了 jQuery,我们可以在我们的网页中包含它,以便我们可以使用它的 get 函数来检索之前使用 HTTP GET 生成的 CSV 文件 trend_output.csv(如下所示)。当我们修改代码时,live-server 会检测到变化并刷新网页,因此我们可以坐下来编码,并观察浏览器自动刷新以运行我们的最新代码。

列表 5.14 使用 HTTP GET 从 CSV 文件中检索数据(listing-5.14.html)

<!doctype html>
<html lang="en">
    <head>
        <title>My prototype web page</title>
    </head>
    <body>
 <script src="/bower_components/jquery/dist/jquery.min.js"></script>    ①  

        <script>
 $.get("./output/trend_output.csv")    ②  
                .then(response => {
 console.log(response);    ③  
                })
                .catch(err => {
                    console.error(err && err.stack || err);
                })
        </script>
    </body>
</html> 

我们仍然在这里进行进化编码。我们一次做一件小事情,并在进行中测试。记住,我们的目标是从小块可管理的增量中移动到工作的代码。列表 5.14 中的代码将我们的数据输出到浏览器的控制台。我们这样做是为了检查浏览器中的代码是否正确接收了数据。

当 live-server 仍在运行时,在浏览器中导航到列表 5.14 的网页,并打开开发者工具以检查控制台输出。例如,在 Chrome 中,你可以通过按 F12 并查看控制台标签(如图 5.23 所示)来打开开发者工具。

c05_23.eps

图 5.23 在 Chrome 的开发者工具控制台中查看 console.log 输出

我们应该在浏览器编码时始终打开开发者工具。这允许我们看到可能来自我们代码的任何 JavaScript 错误,并且我们可以使用日志验证我们的代码是否按预期工作。

我们检查数据的另一个选项是将它添加到网页中使用document.write,尽管如图 5.24 所示,这种输出的外观相当杂乱。

c05_24.png

图 5.24 直接将 CSV 数据输出到网页中——这不是最吸引人的可视化!

好吧,现在是时候将数据放入图表中了!为了使事情变得简单,我们将为浏览器安装 Data-Forge 并使用它来转换我们的数据,以便用于 Flot 图表库。如果你为存储库安装了 Bower 依赖项,那么 Data-Forge 已经安装;否则,在新的 Web 项目中按照以下方式安装它:

bower install –-save data-forge 

在我们的网页中包含 Data-Forge 脚本之后,我们现在可以从我们的数据中创建一个 DataFrame,按月份编号索引它,然后从我们在列表 5.12 中产生的 CSV 中提取趋势列。接下来,我们使用 Flot 绘制趋势列。我们使用toPairs函数获取一个索引/值对的数组。每个对包括索引(我们使用了月份编号作为索引)和数据(来自趋势列)。然后我们使用 Flot 的plot函数将图表绘制到我们网页的占位符元素中,如下面的列表所示。

列表 5.15 使用 Data-Forge 从数据集中提取趋势列并在 Flot 图表中可视化它(listing-5.15.html)

<!doctype html>
<html lang="en">
    <head>
 <title>My prototype web page</title>
    </head>
    <body>
        <table style="text-align:center">
            <tr>
                <td></td>
                <td><h2>Car Accidents<h2></td>
                <td></td>
            </tr>

            <tr>
                <td>Fatalities</td>

                <td>
                    <div
                        id="placeholder"
                        style="width: 700px; height: 400px"
                        >
                    </div>
                </td>

                <td></td>
            </tr>

            <tr>
                <td></td>
                <td>Month#</td>
                <td></td>
            </tr>

        </table>
        <script src="/bower_components/jquery/dist/jquery.min.js"></script>
 <script src="/bower_components/Flot/jquery.flot.js"></script>    ①  
 <script src="bower_components/data-forge/data-forge.dist.js">➥</script>    ①  

        <script>
            $.get("./output/trend_output.csv")
                .then(response => {
 var dataFrame = new dataForge    ②  
 .fromCSV(response)    ②  
 .parseFloats(["Month#", "Trend"])    ②  
 .setIndex("Month#");    ②  
 var data = dataFrame    ③  
 .getSeries("Trend")    ③  
 .toPairs();  ③  
 $.plot("#placeholder", [ data ]);    ④  
                })
                .catch(err => {
                    console.error(err && err.stack || err);
                })
        </script>
    </body>
</html> 

当 live-server 运行时,导航到列表 5.15 的网页,你现在应该能看到图 5.25 中显示的最终结果。我们使用 Flot 图表库绘制了趋势列。就这些内容而言,这是一个基本的可视化,但它是从短暂且快速的原型设计会议中得出的一个很好的结果。

如果你想知道为什么列表 5.15 中图表的占位符div嵌入在一个table中,这纯粹是出于美观原因。table用于排列图表的标题以及 X 轴和 Y 轴的标签。

c05_25.png

图 5.25 你原型设计的最终产品——Flot 图表库中关于伤亡数据的初步可视化

整合所有内容

c05_26.eps

图 5.26 从 Node.js 到浏览器完整的管道——通过 Nodemon 和 live-server 的自动化代码执行

我们将本章的编码工作分为 Node.js 和浏览器编码两部分。然而,在实践中,并没有理由将这两项活动分开。我们可以同时运行 Nodemon 并在 Node.js 中编码,同时运行 live-server 并编码网页可视化。这形成了一个完整的编码流程,如图 5.26 所示。Nodemon 会捕捉到 Node.js 代码的更改,这些更改会自动流向输出 CSV 文件。Live-server 会检测 CSV 文件和网页代码的更改,这些更改会自动流向浏览器可视化。

你在这里做得很好,尽管这并不是全部的故事。回想一下,在本章中你只处理了数据的一个缩减样本。目标是更好地理解数据和你试图解决的问题。

通过本章的学习,你在编码的过程中积累了知识。在这个过程中,你编写了将来在扩展到完整数据集并将此网页可视化投入生产时将非常有用的代码。但就目前而言,你已经实现了目标:通过探索性编码和产生的有用代码更好地理解了问题。在第六章中,你将更深入地探讨数据中可能存在的问题,并学习如何纠正这些问题或绕过它们。

摘要

  • 你学习了如何构建快速且高效的反馈循环,以实现快速迭代和提升生产力。

  • 在开始编码之前,你发现了如何在 Excel 中原型化数据分析与可视化。

  • 你使用 Formulajs 在 Node.js 中重现了 Excel 数据分析。

  • 你练习了如何使用 Flot 快速构建基于网页的可视化。

  • 你了解到可以使用 Nodemon 和 live-server 构建一个编码管道,该管道在你工作时自动刷新。

6

清理和准备

本章涵盖

  • 理解你可能在数据中找到的错误类型

  • 识别数据中的问题

  • 实施修复或绕过不良数据的策略

  • 为在生产中有效使用数据做准备

当我们处理数据时,我们能够信任数据并有效地与之工作至关重要。几乎每个数据整理项目都预先加载了修复问题和准备数据以供使用的工作。

你可能听说过清理和准备等于 80%的工作!我不确定这一点,但确实准备通常是总工作量的大部分。

在这个阶段投入的时间可以帮助我们避免在后期发现我们一直在处理不可靠或有问题数据的情况。如果你遇到这种情况,那么你的大部分工作、理解和决策可能都是基于错误输入的。这不是一个好的情况:你现在必须回溯并修复这些错误。这是一个昂贵的过程,但我们可以通过在清理阶段早期注意来减轻这种风险。

在本章中,我们将学习如何识别和修复不良数据。你会看到数据出错的不同方式,所以我们无法期望查看所有这些方式。相反,我们将查看处理不良数据的一般策略,并将这些策略应用于具体示例。

6.1 扩展我们的工具包

在本章中,我们将更深入地了解 JavaScript 和 Data-Forge 函数,用于切片、切块和转换数据。我们还将依赖第三章的工具包,使用importCsvFileexportCsvFile来加载和保存 CSV 文件。

表 6.1 列出了本章中涵盖的各种工具。

表 6.1 第六章使用的工具

API 库 函数/操作符 说明
JavaScript Map 在转换输入数组的每个元素后构建一个新的数组
Filter 通过过滤掉不需要的元素构建一个新的数组
Concat 将两个或多个数组连接成一个数组
Delete JavaScript 操作符,用于从 JavaScript 对象中删除字段
Reduce 将数组折叠成一个单一值;可用于聚合或总结数据集
Data-Forge select 与 JavaScript map函数类似,在转换输入 DataFrame 的每一行后构建一个新的 DataFrame
where 与 JavaScript filter函数类似,构建一个新的 DataFrame,过滤掉不需要的数据行
concat 与 JavaScript concat函数类似,将两个或多个 DataFrame 连接成一个 DataFrame
dropSeries 从 DataFrame 中删除整个命名序列。使用此功能可以从数据集中删除整个数据列
groupBy 根据你指定的标准将数据行组织成组
aggregate 与 JavaScript reduce函数类似,将 DataFrame 折叠成一个单一值;可用于聚合或总结数据集
Globby globby 用于读取文件系统并确定哪些文件与特定通配符匹配的函数。我们将使用它将多个文件合并成一个文件。

我们在这里的主要心智工具是数据管道。当我们考虑不同的数据转换方式时,请记住我们正在努力构建一个灵活的数据管道。如何构建它,那完全取决于你,但到本章结束时,我会向你展示一种优雅且灵活的方法,使用 Data-Forge 来串联你的数据转换。

6.2 准备珊瑚数据

当我们获取数据时,它并不总是以我们希望的方式出现。让我们回到我们在第一章和第二章中看到的珊瑚数据集。我们在使用它之前可能需要修复这个数据集的几个问题。

首先,让我们先解决几个与数据清理和准备相关的一般性问题。我们将探讨不良数据的来源以及我们如何识别它。然后,我们将介绍处理问题数据的一般技术。之后,我们将基于珊瑚数据集查看具体示例。

我应该说的是,我们并不一定需要我们的数据完美无瑕!除了实现这一点可能很困难(谁有资格定义完美?),我们的数据只需要适合目的。我们希望有效地处理那些尽可能没有问题、符合我们业务需求的数据。让我们开始吧。

6.3 获取代码和数据

代码和数据可在 GitHub 的 Chapter-6 存储库中找到,网址为github.com/data-wrangling-with-javascript/chapter-6

示例数据位于存储库中的 data 目录下。由代码生成的输出位于 output 目录下(但不在存储库中)。

请参考第二章中的“获取代码和数据”以获取帮助获取代码和数据。

*## 6.4 数据清理和准备的需求

为什么我们需要清理和准备我们的数据?最终,这关乎解决数据中的问题。我们需要出于以下原因来做这件事:

  • 为了确保我们不会基于错误或不准确的数据得出错误的结论并做出糟糕的决定。

  • 为了避免产生负面影响——例如,失去那些注意到数据错误的客户/客户的信任。

  • 与干净、准确和可靠的数据一起工作可以使我们的工作更简单、更直接。

  • 我们应该在问题容易解决的时候修复数据问题。你留得越久,解决它们就越昂贵。

  • 我们可能需要在生产中离线准备我们的数据以实现高效使用。为了及时获得结果,以便我们可以迅速采取行动,我们需要数据已经以最佳格式存在,以便能够提供足够的性能。

我们有许多原因需要投入精力来修复我们的数据,但这引发了一个问题:数据最初为什么会出错?

6.5 破坏数据从何而来?

数据可能由于任何原因出现错误。我们通常无法控制数据源,尽管如果我们能控制,我们可以在收集点确保良好的验证。我们可以在收集数据时确保数据清洁,从而节省时间和精力。

然而,即使我们控制了数据源,我们也不能总是达到良好的数据质量。例如,如果我们从电子传感器读取数据,它们可能会偶尔返回虚假或错误的数据。它们可能会有间歇性问题,并在一段时间内失效,导致数据中断。

我们可能有负责收集或合成数据的软件。该软件中的潜在错误可能会生成不良数据,而我们甚至还没有意识到这一点!这样的错误可能长时间未被察觉。

也许我们正在使用有缺陷的软件生成数据,我们知道这些缺陷正在导致不良数据。我们是否处于修复它们的位置?我们可能无法做到!可能存在各种原因,使我们可能无法修复程序中的错误。首先,我们可能无法访问源代码,因此无法更新程序。或者我们可能在与复杂的遗留代码一起工作,并犹豫是否要做出更改——这些更改可能会产生更多的错误(如果你曾经与遗留代码一起工作,你应该知道我的意思)。当你无法更改代码,或者更改代码太难时,唯一的选择是绕过不良数据。

我们通常无法控制数据来源,因此我们获取的数据可能存在任何数量的问题,在我们可以使用之前必须解决这些问题。

无论我们以何种方式获取数据,似乎都无法避免不良数据,因此需要数据清理和准备。我们必须投入时间检查数据中的错误,并在必要时修复问题,为生产中的高效使用准备数据。

6.6 数据清理如何融入管道?

在第三章中,我介绍了核心数据表示(CDR)设计模式。这是通过连接具有共享数据表示的阶段来构建灵活数据管道的想法。

在第三章结束时,我们的数据转换管道的概念模型看起来像图 6.1。导入代码产生核心数据表示的数据,然后输入到导出代码中。

c06_01.eps

图 6.1 基本数据管道:数据通过核心数据表示从一种格式转换为另一种格式。

c06_02.eps

图 6.2 添加了清理和准备阶段的更完整的数据管道。

c06_03.eps

图 6.3 数据管道中的各个阶段通过核心数据表示连接在一起。

在本章中,我们扩展了我们数据管道的概念模型,包括多个转换阶段来清理、准备和转换我们的数据。图 6.2 展示了任意清理和准备阶段如何融入管道。它演示了如何在导入和导出之间包含任意数量的数据转换阶段。我们可以使用这个模型来构建一个可以从任何一种格式导入,通过多个阶段转换数据,然后导出到任何其他格式的数据管道。

转换阶段之间的空间是我们使用核心数据表示的地方。图 6.3 阐述了核心数据表示如何连接我们的模块化数据转换阶段。任何转换阶段的输入和输出都是共享格式中的数据块。我们可以链接多个阶段,并从可重用的代码模块中构建灵活的数据管道。

6.7 识别不良数据

你可能会问:我们如何检测不良数据?你可以用各种方式来处理这个问题。

早期,我们可以在文本编辑器或查看器中查看数据,并通过肉眼发现问题。我们无论如何都需要这样做,以便对数据的形状有一个感觉,但这也可以帮助我们快速检测任何明显的问题。这种方法可以让我们开始,并且对于小数据集来说可能有效,但显然它不能扩展到大数据集。人眼擅长发现问题,但它也有局限性,所以我们很容易错过问题。

我的做法是先通过肉眼分析一小部分数据,然后对其结构和格式做出假设。然后我编写一个脚本来在整个数据集中检查这些假设。这是我们第五章中提到的假设检查脚本。运行这个脚本可能需要相当长的时间,但这是值得的,因为这样你就可以知道你的假设是否成立。这个脚本的工作是告诉你你的数据中是否存在问题。

可能值得优化你的假设检查脚本以加快处理速度,尤其是因为你可能想在生产环境中运行你的假设检查脚本,以便接受实时数据更新到你的数据管道中。我们将在第十二章中更多地讨论实时数据管道。

你可能还想考虑的一种检测不良数据的方法是众包问题,并允许你的用户找到并报告损坏的数据。你可能还想考虑在生产版本中引入金丝雀测试,即将新版本提供给一小部分用户,以便在它广泛发布之前帮助你找到问题。这种方法是否合理取决于你的产品:你需要一个巨大的数据集(否则你为什么要这样做)和一个庞大且活跃的用户群体。

6.8 种类的问题

我们可能在数据中看到的问题种类繁多。以下是一些示例,以供说明:

  • 额外 空白空间—字段值周围的空白行或空白。

  • 缺失数据—空、null 或 NaN 字段。

  • 意外数据 —你的代码能处理新的和意外的值吗?

  • 不准确的数据—传感器读数偏离一定量。

  • 不一致性—街道和 St,先生和 Mr,不同货币中的数据,不一致的大小写。

  • 格式错误的字段—电子邮件、电话号码、拼写错误的类别等。

  • 损坏的数据—缺少时区或传感器读数错误的日期/时间。

  • 无关数据—对我们无用的数据。

  • 冗余数据—重复的数据。

  • 低效的数据—没有为有效使用而组织的数据。

  • 数据过多—我们处理不过来的数据。

我们很快将深入研究修复这些问题的具体代码示例。

6.9 对坏数据的响应

我们已经识别出坏数据,但我们如何应对它?

这取决于你的情况和数据规模,但我们有各种应对坏数据的策略可供部署。考虑以下选项:

  • 我们可以修复数据—如果可能的话。

  • 我们可以优化数据—如果它在无效或不高效的格式中。

  • 我们可以忽略这个问题—我们需要问:最坏的情况会怎样?

  • 我们可以绕过问题—也许我们可以在生产中处理这个问题,而不是离线?

  • 我们可以过滤掉损坏的数据—也许修复它比对我们来说更有价值。

  • 我们可以重新生成数据—如果可能的话,也许我们可以修复问题的源头,然后从头开始捕获或生成数据。如果最初生成数据很便宜,重新生成可能比试图修复数据更便宜。

当我们谈论对坏数据的响应时,我们也必须考虑在哪里我们将对其进行响应。本章的大部分内容假设我们将数据离线修复,尽管值得注意的是,这些技术中的大多数也适用于实时数据管道,例如我们将在第十二章中涵盖的示例。

我们不是应该总是离线修复我们的数据吗?如果我们这样做,确实会提高我们生产系统的性能,但存在一些情况,这样做可能不可行。例如,想象一下你有一个巨大的数据集,它有错误,但这些错误仅与少数用户相关,并且访问频率不高。在这种情况下,让实时系统及时修复这些错误可能更有效,这就是所谓的懒惰模式,然后将修复后的记录重新烘焙回数据库。这允许我们的生产系统随着时间的推移缓慢地自行纠正,而不需要大量的离线时间和资源,并且不会过度影响我们的用户群。

修复坏数据的技巧

我们还没有解决修复损坏数据需要做什么。数据中可能发生大量问题;幸运的是,我们有一套简单的策略可以部署来修复损坏的数据。

表 6.2 列出了我们将现在添加到工具包中修复坏数据的技巧。

表 6.2 修复不良数据的技巧

技巧 如何? 为什么?
修改数据 迭代并更新行和列。 用于数据归一化和标准化
用于修复损坏的数据
删除数据 过滤行和列。 用于删除不相关和冗余的数据
当我们数据过多时减少数据
数据聚合 合并、组合和汇总数据 优化数据以实现高效访问
当我们数据过多时减少数据
分割数据 将数据分离成单独的数据集 用于高效访问

我们将在本章的剩余部分探索这些技术的代码示例。

清理我们的数据集

是时候进入代码示例了!我们首先将查看最常见的技术之一:重写数据行以修复我们发现的问题。然后我们将查看一个常见的替代方案:过滤行或列以删除损坏或不相关的数据。

我们将在这些示例中使用重要的 JavaScript 函数,所以请务必注意。我还会展示如何在 Data-Forge 中完成这类工作。为了加载数据,我们将回退到我们在第三章中创建的用于导入和导出 CSV 文件的工具包函数。

6.11.1 重写不良行

在礁石数据中,我们首先要解决的问题是一个日期/时间问题。处理日期/时间值可能会引起许多问题,尽管在理解了问题之后,解决方案通常很容易找到。在这种情况下,问题在于日期/时间被存储为不包含时区信息的字符串表示(见图 6.4)。礁石数据库包含来自许多不同时区的记录,因此,在我们的日期中正确编码时区非常重要。由于我们的产品用户中存在日期位于错误时区的情况,这已经导致了许多生产问题。

c06_04.eps

图 6.4 日期和时区存储在不同的列中。

我们的目标是将所有日期/时间值转换为带有正确时区编码的标准 UTC 格式(如图 6.5 所示)。我们将使用 JavaScript 日期/时间库 moment 来实现这一点。这是你将找到的最实用的 JavaScript 库之一。你可能记得我们第一次在第二章中安装它,并在第四章中再次使用它。它是处理日期和时间值的无价之宝。

c06_05.png

图 6.5 将日期和时区列合并为一个包含时区的 UTC 格式日期。

在这种情况下,我们已经有所有需要的信息,因为每个记录都将时区编码为单独的字段。我们需要将这些两个字段合并成一个单一的国际化日期/时间值,以反映正确时区的正确日期/时间。我们可以很容易地使用 moment(如图 6.5 所示)来完成这项工作。

要重写我们数据集中的每一行,我们将使用 JavaScript 的 map 函数。这个函数接受一个数组作为输入——我们的输入数据集。我们还向 map 函数传递一个转换函数。这个函数对我们的数据集中的每条记录应用修改。map 函数的输出是一个修改后的数据集——转换每条记录并构建新数组的结果。

我们可以说,map 函数通过将指定的修改应用于每条记录来 重写 我们的数据集。您可以在 图 6.6 中看到 transformRow 函数是如何应用于输入数组的每个元素以构建输出数组的。

c06_06.eps

图 6.6 使用 JavaScript map 函数将数据从一种结构转换为另一种结构

列表 6.1 展示了使用 map 函数修复我们珊瑚数据集中日期/时间值的代码。需要关注的重要函数是 transformDatatransformRowtransformData 转换整个数据集。transformRow 修复数据集中的每条记录。我们使用 moment 库将日期/时间的字符串表示与每条记录中的时区值结合起来。

map 函数本质上将输入数组拆分开来,然后通过 transformRow 修改每条记录。最后,它将修改后的记录粘合在一起形成一个新的数组,输出一个修复了损坏数据的新数据集。运行以下列表后,它将生成输出文件(surveys-with-fixed-dates.csv),然后在 Excel 或文本编辑器中加载该文件以验证其正确性。

列表 6.1 重写行以修复不良数据(listing-6.1.js)

const moment = require('moment');
const importCsvFile = require('./toolkit/importCsvFile.js');    ①  
const exportCsvFile = require('./toolkit/exportCsvFile.js');    ①  

const importDateFormat = "YYYY-MM-DD HH:mm";
const inputFileName = "./data/surveys.csv";    ②  
const outputFileName = "./output/surveys-with-fixed-dates.csv";    ②  

function parseDate (inputDate, timezoneOffset) {
 return moment(inputDate, importDateFormat)    ③  
 .utcOffset(timezoneOffset)    ③  
 .toDate();    ③  
}

function transformRow (inputRow) {    ④  
 const outputRow = Object.assign({}, inputRow);    ⑤  
 outputRow.start_datetime =    ④  
 parseDate(inputRow.start_datetime, inputRow.timezone);    ④  
 outputRow.end_datetime =    ④  
 parseDate(inputRow.end_datetime, inputRow.timezone);    ④  
 return outputRow;    ④  
}    ④  

function transformData (inputData) {    ⑥  
 return inputData.map(transformRow);    ⑥  
}    ⑥  

importCsvFile(inputFileName)    ⑦  
    .then(inputData => {
 const outputData = transformData(inputData);    ⑧  
 return exportCsvFile(outputFileName, outputData);    ⑨  
    })
    .then(() => {
        console.log('Done!');
    })
    .catch(err => {
        console.error('Error!');
        console.error(err && err.stack || err);
    }); 

注意在 列表 6.1 中,我们如何重用了我们在第三章中创建的 CSV 导入和导出函数。我们现在使用这些函数从 CSV 文件 surveys.csv 中加载数据,然后在损坏的数据被修复后,将数据保存到新的 CSV 文件 surveys-with-fixed-dates.csv 中.

这种技术可以用来重写整个行,或者,就像我们在 列表 6.1 中做的那样,重写特定的单个字段。我们使用这项技术来修复我们的数据,但您也可以说我们这样做是为了使我们的生产代码更简单,因为现在它只需要处理组合的日期/时间值。

行转换的通用模式

我们可以从这项技术中提炼出一个可重用的模式,以便我们可以用它来重写任何表格数据集。以下列表显示了通用模式。将您自己的代码放入 transformRow 函数中。

列表 6.2 重写不良行的通用模式(摘自 listing-6.2.js)

function transformRow (inputRow) {    ①  
 const outputRow = Object.assign({}, inputRow);    ①  
    //
    // TODO: Your code here to transform the row of data.
    //
 return outputRow;    ①  
}    ①  

function transformData (inputData) {    ②  
 return inputData.map(transformRow);    ②  
}    ②  

importCsvFile(inputFileName)    ③  
    .then(inputData => {
 const outputData = transformData(inputData);    ②  
 return exportCsvFile(outputFileName, outputData);    ④  
    })
    .then(() => {
        console.log("Done! ");
    })
    .catch(err => {
        console.error("Error!");
        console.error(err && err.stack || err);
    }); 

使用 Data-Forge 重写损坏的数据

我们还可以使用 Data-Forge 以类似于传统 JavaScript 的方式重写我们的数据集。

我们为什么应该使用 Data-Forge 呢?因为像这样的数据转换非常适合灵活、方便且优雅的 Data-Forge 数据管道。在章节的结尾,你将看到一个更完整的 Data-Forge 示例,展示这一切是如何在大数据管道的背景下结合在一起的,但就目前而言,让我们使用 Data-Forge 重新编写列表 6.1。

你会注意到 列表 6.3 与 列表 6.1 类似。我们有熟悉的 transformDatatransformRow 函数。实际上,transformRow 与 列表 6.1 中的完全相同。然而,transformData 是不同的。在这种情况下,它接受一个 Data-Forge DataFrame 作为输入,并返回一个新的、修改后的 DataFrame 作为输出。我们不是使用 JavaScript 的 map 函数,而是使用 Data-Forge 的 select 函数来转换数据集。mapselect 在概念上是等效的:它们都拆分一个数据序列,修改每个记录,然后将输出合并以创建一个新的序列。你可以运行以下列表,它将输出文件 surveys-with-fixed-dates-using-data-forge.csv。

列表 6.3 使用 Data-Forge 重新编写不良记录(listing-6.3.js)

const moment = require('moment');
const extend = require('extend');
const dataForge = require('data-forge');    ①  

const importDateFormat = "YYYY-MM-DD HH:mm";
const inputFileName = "./data/surveys.csv" ;
const outputFileName =
    "./output/surveys-with-fixed-dates-using-data-forge.csv";

function parseDate (inputDate, timezoneOffset) {
    return moment(inputDate, importDateFormat)
        .utcOffset(timezoneOffset)
        .toDate();
}

function transformRow (inputRow) {
    const outputRow = Object.assign({}, inputRow);
    outputRow.start_datetime = parseDate(
        inputRow.start_datetime, inputRow.timezone
    );
    outputRow.end_datetime = parseDate(
        inputRow.end_datetime, inputRow.timezone
    );
    return outputRow;
}

function transformData (inputDataFrame) {
 return inputDataFrame.select(transformRow);    ②  
}

dataForge.readFile(inputFileName)    ③  
 .parseCSV()    ③  
    .then(inputDataFrame => {
 const outputDataFrame = transformData(inputDataFrame);    ④  
        return outputDataFrame
 .asCSV()    ⑤  
 .writeFile(outputFileName);    ⑤  
    })
    .then(() => {
        console.log("Done! ");
    })
    .catch(err => {
        console.error("Error!");
        console.error(err && err.stack || err);
    }); 

列表 6.3 与 列表 6.1 并没有太大的不同,它还没有展示出 Data-Forge 的强大功能。Data-Forge 的好处之一,以及其他好处,是它很容易链式调用数据转换并构建管道。在我们看到它们如何使用 Data-Forge 链接成一个更复杂的管道之前,让我们先处理完剩余的示例。

6.11.2 过滤数据行

我们在珊瑚礁数据中需要解决的第二个问题是,我们只对澳大利亚的珊瑚礁感兴趣。这就是我们的关注点,其余的数据与我们的数据分析无关,所以让我们删除我们不感兴趣的行。当我们发现数据无用或检测到重复或冗余时,我们可以过滤掉数据。我们可能还希望过滤掉那些我们没有成本效益的修复方法的数据。

正如我们在第五章中已经讨论过的,使用精简的数据集将使我们的过程更快、更流畅。此外,你感兴趣的数据将更加清晰,因为它没有被无关的额外数据所杂乱。你绝对应该删除你不需要的数据部分。一如既往,请注意不要覆盖你的源数据。你即将删除的数据可能在将来某一天需要,所以请小心保留原始未修改数据的副本。

我们的目标是移除不在澳大利亚的珊瑚礁数据。我们将使用 JavaScript filter 函数来实现这一点。我们将在数据数组上调用 filter 函数,并传入一个用户定义的 谓词 函数,该函数指定要过滤掉的记录。谓词函数必须返回布尔 true 以保留记录或 false 以移除它。与之前检查的 map 函数类似,filter 函数将输入数组拆分,然后根据谓词函数的结果,它将拼接一个新的数组,但减去任何被过滤掉的记录。

我们可以说,filter 函数通过删除我们不再想要的记录来 重写 我们的数据集。你可以在 图 6.7 中看到 filterRow 谓词函数是如何应用于输入数组的每个元素,以确定记录是否应包含在输出数组中。

列表 6.4 展示了使用 JavaScript filter 函数从我们的珊瑚礁数据集中删除行。我们在这里再次看到了之前列表中的 transformData 函数,尽管这次我们使用 filter 函数而不是 map 函数来转换数据集。

注意 filterRow 函数:这是我们为每个记录调用的谓词函数,它确定记录是否应该保留或删除。filterRow 对每个位于澳大利亚的记录返回 true,因此它保留了这些记录。另一方面,它对每个其他记录返回 false,并删除了不在澳大利亚的记录。

filter 函数将输入数组拆分,并为每个记录调用 filterRow。它生成一个只包含通过过滤的记录的新数组——输出数组只包含 filterRow 返回 true 的记录。它输出一个新数据集,不包括我们想要删除的记录。你应该运行以下列表并检查它输出的文件 surveys-but-only-Australia.csv。

c06_07.eps

图 6.7 使用 JavaScript 过滤函数生成一个新数组,其中包含过滤掉某些元素

列表 6.4 过滤掉不需要或不良数据(摘自列表-6.4.js)

function filterRow (inputRow) {    ①  
 return inputRow.country === 'Australia';    ①  
}    ①  

function transformData (inputData) {
 return inputData.filter(filterRow);    ②  
}; 

过滤行的一般模式

我们可以为从我们的数据集中过滤掉数据行创建一个通用模式。列表 6.5 是这个模式的模板,你可以插入你自己的过滤代码。记住,你的谓词函数必须为你要保留的记录返回 true,为你要删除的记录返回 false

列表 6.5 过滤掉不良数据的一般模式(列表-6.5.js)

function filterRow (inputRow) {    ①  
    // TODO: Your predicate here.
    // Return true to preserve the row or false to remove it.
    const preserveRow = true;
    return preserveRow;
}    ①  

function transformData (inputData) {
    return inputData.filter(filterRow);
};

importCsvFile(inputFileName)
    .then(inputData => {
        const outputData = transformData(inputData);
        return exportCsvFile(outputFileName, outputData)
    })
    .then(() => {
        console.log("Done!");
    })
    .catch(err => {
        console.error("Error!");
        console.error(err && err.stack || err);
    }); 

使用 Data-Forge 过滤行

让我们再次看看 Data-Forge,这次我们将学习如何使用它来过滤数据行。我们看到的情况与在纯 JavaScript 中实现的方式相似。因为它如此相似,你可能会想知道为什么我们要费心使用 Data-Forge?这个原因应该在章节结束时变得清晰,届时我将向您展示如何将多个 Data-Forge 函数链接起来以构建更复杂的数据管道。

列表 6.6 与列表 6.4 有相同的filterRow函数。然而,它的transformData函数使用 Data-Forge 的where函数来过滤记录,而不是我们在列表 6.4 中使用的 JavaScript filter函数。wherefilter函数执行相同的概念性任务:它们为每个记录执行一个谓词函数,以确定哪些记录应该保留,哪些应该被删除。我们的列表 6.6 中的transformData函数接受一个 DataFrame 作为输入,并返回一个新的、修改后的 DataFrame 作为输出。输出 DataFrame 仅保留我们想要保留的记录;所有其他记录都已过滤掉。当你运行此代码时,它会产生输出文件 surveys-but-only-Australia-using-data-forge.csv。检查输出文件,你会发现它与列表 6.4 产生的相同。

列表 6.6 使用 Data-Forge 过滤掉不需要或不良数据(摘自列表-6.6.js)

function filterRow (inputRow) {    ①  
 return inputRow.country === 'Australia';    ①  
}    ①  

function transformData (inputDataFrame) {
 return inputDataFrame.where(filterRow);    ②  
} 

我们还没有看到 Data-Forge 的真正力量。请耐心等待;它很快就会到来!

6.11.3 过滤数据列

我们在礁石数据中需要解决的第三个问题是删除列。这与之前的问题类似,那时我们想要删除数据行。然而,这次,我们不是要删除整个记录,而是要从每个记录中删除单个字段,但保留每个记录的其余部分。

我们这样做的原因与删除行相同:为了删除损坏的、无关的或冗余的数据,并使数据集更加紧凑,更容易处理。再次提醒,请务必不要覆盖您的源数据,并将它的副本保存在安全的地方。

我们的目标是从每个记录中删除reef_type字段,这将从我们的整个数据集中删除reef_type列。我们不需要这个列,它使我们的数据变得杂乱。

从数组的每个项目中删除字段并不像我们使用 JavaScript filter函数那样过滤整个项目那样方便;然而,JavaScript 确实提供了一个delete运算符,它可以完成我们需要的功能:从一个 JavaScript 对象中删除一个字段(参见图 6.8)。

c06_08.eps

图 6.8 从数组的每个元素中删除字段的效果是从我们的表格数据中删除一个“列”。

要使用delete运算符,我们必须遍历我们的数据集并应用于每个记录,如列表 6.7 所示。注意在transformData中,我们再次使用map函数来转换整个数据数组。transformRow函数访问每个记录并使用delete运算符删除reef_type字段。运行此代码,将生成输出文件 surveys-with-no-reef-type.csv。输出数据与输入数据相同,但已删除所需的列。

列表 6.7 删除整个列(列表-6.7.js 的摘录)

function transformRow (inputRow) {
 const outputRow = Object.assign({}, inputRow);    ①  
 delete outputRow.reef_type;    ②  
    return outputRow;
}

function transformData (inputData) {
 return inputData.map(filterColumn);    ③  
} 

使用 Data-Forge 过滤列

继续我们的主题,先在纯 JavaScript 中实现,然后在 Data-Forge 中,我们也可以使用 Data-Forge 从我们的数据集中删除整个列。在先前的例子中,使用 Data-Forge 与使用纯 JavaScript 并没有太大的区别,但在这个例子中,我们的任务变得稍微简单一些。

列表 6.8 展示了使用 Data-Forge 的dropSeries函数从我们的 DataFrame 中删除一个命名序列(例如,数据列)。这比从每个单独的记录中逐个删除字段要简单。当你运行此代码时,将生成输出文件 surveys-with-no-reef-type-using-data-forge.csv。这是与列表 6.7 生成的相同输出,但使用 Data-Forge 生成更为方便。

列表 6.8 使用 Data-Forge 删除整个列(列表-6.8.js 的摘录)

function transformData (inputDataFrame) {
 return inputDataFrame.dropSeries("reef_type");    ①  
} 

这是 Data-Forge 如何简化并简化数据处理过程的第一个好例子,但我们才刚刚开始,Data-Forge 还有许多更多功能可以帮助我们轻松分割、转换和重新整合我们的数据。

准备我们的数据以有效使用

我们清理并修复了我们在数据中识别出的各种问题。然而,为了有效使用数据,可能还需要做更多的工作。我们可能仍然有太多的数据需要减少,或者我们的数据可能不适合分析。现在让我们看看几个如何聚合或划分我们的数据以使其更容易处理的例子。

6.12.1 数据行聚合

让我们看看如何按珊瑚礁名称聚合我们的数据。如果我们想查看每个珊瑚礁的统计数据,那么将特定珊瑚礁的所有记录合并成一个珊瑚礁的总结记录是有意义的。

我们在这里将保持简单,并查看每个珊瑚礁所行驶的累积距离。我们需要对每个珊瑚礁的所有记录中的transects_length字段进行求和。从数据分析的角度来看,这很简单,但这是我们本章示例中所需的所有内容。在第九章的后面,我们将探讨更高级的数据分析技术。

图 6.9 展示了源数据的一部分以及它与聚合数据的比较。注意左侧每行数据在每个珊瑚礁中都有多个记录,但在右侧,它们已经被压缩成每个珊瑚礁一行。

c06_09.eps

图 6.9 聚合数据:按礁石名称分组然后对每个组的 transects_length 求和

为了聚合我们的数据,我们执行以下步骤:

  1. 源数据根据reef_name字段组织到桶中。

  2. 对于每个记录组,我们计算transects_length字段的和。

  3. 最后,创建一个新的数据集,每个礁石一条记录,包含聚合数据。

列表 6.9 展示了聚合礁石数据的代码。注意对 Data-Forge 的groupBy函数的调用:这将我们的 DataFrame 转换成一系列组。传递给groupBy的函数指定了如何组织我们的数据到组中。这表示我们希望按reef_name分组。groupBy的输出是一个表示一系列组的 Data-Forge Series 对象。每个组本身是一个包含原始数据子集的 DataFrame。然后我们调用select将组转换成一组新的汇总记录。在这里,我们调用sum函数对组的transects_length字段求和。

这里有很多事情在进行中,所以请花时间阅读代码,并让这些内容深入人心。您可以运行此代码,它将生成类似于右侧图 6.9 所示的 surveys-aggregated.csv 文件。

列表 6.9 使用 Data-Forge 聚合数据(列表-6.9.js 的摘录)

function transformData (inputDataFrame) {
    return inputDataFrame
 .parseFloats("transects_length")    ①  
 .groupBy(inputRow => inputRow.reef_name)    ②  
 .select(group => {    ③  
            return {
 reef_name: group.first().reef_name,    ④  
                transects_length: group
 .deflate(row => row.transects_length)    ⑤  
 .sum(),    ⑥  
            };
 })    ③  
 .inflate();    ⑦  
} 

这是一个仅使用 Data-Forge 的另一个示例。您可以用普通的 JavaScript 编写此代码,但代码会更长,更难阅读。

使用 Data-Forge 允许我们更简洁地表达这种类型的转换。代码越少,错误越少,所以这是一个好事。注意函数parseFloatsgroupByselect是如何一个接一个地链在一起的?我们已经瞥见了 Data-Forge 函数是如何一个接一个地链在一起以快速构建数据管道的。

6.12.2 使用 globby 从不同文件中组合数据

让我们假设我们已经以一组文件的形式收到了我们的礁石数据。比如说,礁石数据按国家分开,有文件 Australia.csv、United States.csv 等等。在我们能够处理这些数据之前,我们需要从本地文件系统中加载这些文件并将它们合并。

存在多种方法来组合此类数据:

  • 连接文件的行。

  • 行行合并。

  • 通过匹配字段(类似于 SQL 连接操作)来合并数据。

在本节中,我们将保持简单,并专注于连接方法。我们的目标是读取多个文件到内存中,在内存中连接它们,然后将它们写入一个单一的大型数据文件。我们将使用一个名为 globby 的 JavaScript 库来查找文件。我们已经有文件导入和导出功能,使用我们的工具函数。为了进行连接,我们将使用 JavaScript 的数组concat函数。这个过程在图 6.10 中展示。

c06_10.eps

图 6.10 将多个输入文件聚合到一个输出文件中

要连接多个文件,我们执行以下过程:

  1. 定位并读取多个 CSV 文件到内存中。

  2. 使用 JavaScript 数组的 concat 函数将所有记录连接成一个单独的数组。

  3. 将连接后的数组写入一个单一的合并输出文件。

如果你已经为第六章代码仓库安装了依赖项,那么你的项目中已经安装了 globby;否则,你可以在一个新的 Node.js 项目中按照以下方式安装它:

npm install –-save globby 

列表 6.10 展示了使用 globby 和我们的工具函数 importCsvFile 将多个文件加载到内存中的代码。我们使用 JavaScript 的 reduce 函数将导入文件的选择 reduce 到一个单一的连接 JavaScript 数组。对于每个导入的文件,我们调用 concat 函数将导入的记录追加到合并数组中。你应该运行此代码并查看它创建的合并输出文件 surveys-aggregated-from-separate-files.csv。

列表 6.10 使用 globby 聚合多个文件(listing-6.10.js)

const globby = require('globby');    ①  
const importCsvFile = require('./toolkit/importCsvFile.js');
const exportCsvFile = require('./toolkit/exportCsvFile.js');

const inputFileSpec = "./data/by-country/*.csv";    ②  
const outputFileName =
    "./output/surveys-aggregated-from-separate-files.csv";

globby(inputFileSpec)    ③  
 .then(paths => {    ④  
 return paths.reduce((prevPromise, path) => {    ⑤  
 return prevPromise.then(workingData => {    ⑤  
 return importCsvFile(path)    ⑥  
                        .then(inputData => {
 return workingData.concat(inputData);    ⑦  
                        });
 });    ⑤  
 }, Promise.resolve([]));    ⑤  
    })
    .then(aggregatedData => {
 return exportCsvFile(outputFileName, aggregatedData);    ⑧  
    })
    .then(() => {
        console.log("Done!");
    })
    .catch(err => {
        console.error("An error occurred.");
        console.error(err);
    }); 

注意在 列表 6.10 中,所有导入的文件都是异步加载的。在这里使用 reduce 的主要目的是将一系列异步操作合并成一个单一的 promise;这允许我们使用这个单一的 promise 来管理整个异步操作链。我们也可以在这里使用 Promise.all 并并行处理文件,而不是按顺序处理,但我想要展示如何以这种方式使用 reduce 函数。如果你在这方面遇到困难,请参考第二章中关于异步编码和 promises 的入门指南。

请注意,Data-Forge 有一个 concat 函数,你可以使用它来连接多个 DataFrame 的内容。

6.12.3 将数据拆分到单独的文件中

我们学习了如何将多个输入文件合并成一个数据集。现在让我们看看这个过程的反面:将大数据集拆分成多个文件。我们可能想要这样做,以便我们可以处理数据的一个较小的分区部分,或者如果我们能够根据某些标准拆分数据来处理数据,这可能使我们的工作变得更简单。

对于这个例子,我们将做与上一个例子完全相反的事情,根据国家拆分我们的数据,如图 6.11 所示。这使我们能够更灵活地处理数据。在这个例子中,让我们假设我们想要单独处理每个国家的数据。或者,如果我们有大量的数据,一次处理一个批次可能更有效率,这是我们将在第八章再次探讨的技术。

列表 6.11 中的代码定义了一个名为 splitDataByCountry 的函数。它首先调用 getCountries 函数,查询数据以确定表示的唯一国家列表。然后,对于每个国家,它过滤该国家的数据集,并导出一个只包含过滤数据的新的 CSV 文件。

这里的过滤和导出逻辑与我们在 列表 6.6 中看到的类似,即 Data-Forge 过滤行的示例,尽管我们在这里添加了一个额外的层,它遍历所有国家并为每个国家导出单独的 CSV 文件。如果你运行此代码,它将为每个国家生成输出:Australia.csv、United States.csv 等。

列表 6.11 将数据拆分到多个文件中(listing-6.11.js)

const dataForge = require('data-forge');

const inputFileName = "./data/surveys.csv";

function filterRow (inputRow, country) {
 return inputRow.country === country;    ①  
}

function transformData (inputDataFrame, country) {    ②  
 return inputDataFrame.where(inputRow => {    ②  
 return filterRow(inputRow, country);    ②  
 });    ②  
}    ②  

function getCountries (inputDataFrame) {    ③  
 return inputDataFrame    ③  
 .getSeries("country")    ③  
 .distinct();    ④  
}    ③  

function splitDataByCountry (inputDataFrame) {
 return getCountries(inputDataFrame)    ⑤  
 .aggregate(Promise.resolve(), (prevPromise, country) => {    ⑥  
 return prevPromise.then(() => {    ⑥  
 const outputDataFrame = transformData(    ⑦  
 inputDataFrame,    ⑦  
 country    ⑦  
 );    ⑦  
                const outputFileName = "./data/by-country/" +
                    country + ".csv";
 return outputDataFrame    ⑧  
 .asCSV()    ⑧  
 .writeFile(outputFileName);    ⑧  
 });    ⑥  
 });    ⑥  
}

dataForge.readFile(inputFileName)
    .parseCSV()
    .then(splitDataByCountry)
    .then(() => {
        console.log("Done! ");
    })
    .catch(err => {
        console.error("Error! ");
        console.error(err && err.stack || err);
    }); 

在 列表 6.11 中,请注意 Data-Forge aggregate 函数的使用。这与我们在本章早些时候看到的 JavaScript reduce 函数的工作方式类似,我们在这里使用它的原因也是相同的:将一系列异步操作序列化成一个单一的合并承诺。请参考第二章以复习异步编码和承诺。

使用 Data-Forge 构建数据处理管道

我使用 Data-Forge 的一个主要原因是它能够将操作链式连接起来,快速构建灵活的数据管道。我说灵活,是因为 Data-Forge 函数链的语法很容易重新排列和扩展。我们可以轻松地插入新的数据转换,移除不再需要的,或者修改现有的。

c06_11.eps

图 6.11 按国家拆分单个文件到多个文件

在本章中,你一直在构建对 Data-Forge 链式操作的理解,我希望你也能欣赏到它能为你的数据处理工具箱带来的力量,但现在我想使这一点更加明确。让我们看看一个新的 Data-Forge 示例,它是从多个之前的代码列表中组合而成的。它展示了这些转换如何被链式组合成一个单一的数据管道。

更复杂的数据管道的代码显示在 列表 6.12 中。你可以看到本章中我们探讨过的许多函数:wheregroupByselect 以及其他几个。你可以运行以下列表并检查它生成的输出文件 data-pipeline-output.csv。

列表 6.12 使用 Data-Forge 构建更复杂的数据管道(从 listing-6.12.js 中提取)

dataForge.readFile(inputFileName)    ①  
 .parseCSV()    ②  
    .then(dataFrame => {
 return dataFrame.dropSeries(  [  ③  
 "exp_id",    ③  
 "dive_observations",    ③  
 "obs_topography"    ③  
 ])    ③  
 .parseDates(  [  ④  
 "start_datetime",    ④  
 "end_datetime"    ④  
 ],    ④  
 importDateFormat    ④  
 )    ④  
 .where(row =>    ⑤  
 moment(row.start_datetime).year() === 2014    ⑤  
 )    ⑤  
 .parseFloats("dive_temperature")    ⑥  
 .where(row => row.dive_temperature !== 0)    ⑦  
 .groupBy(row => row.country)    ⑧  
 .select(group => ({    ⑨  
 country: group.first().country,    ⑨  
 dive_temperature: group    ⑨  
 .select(row => row.dive_temperature)    ⑨  
 .average()    ⑨  
 }))    ⑨  
 .inflate()    ⑩  
 .asCSV()    ⑪  
 .writeFile(outputFileName);    ⑫  
    }); 

在本章中,我们覆盖了相当多的内容,我们学习了在尝试用于分析或将其移至生产之前清理和准备数据的各种技术。在第九章中,我们将进入实际的数据分析,但首先我们需要处理我们至今为止一直避免的事情:我们如何应对大量数据?这是第七章和第八章的主题,接下来即将到来。

概述

  • 你已经学会了使用 JavaScript 的 map 函数和 Data-Forge 的 select 函数来重写你的数据集以修复坏数据。

  • 你已经学会了使用各种其他函数来过滤掉有问题的或不相关的数据。我们探讨了 JavaScript 的 filter 函数、delete 操作符以及 Data-Forge 的 wheredropSeries 函数。

  • 我们研究了聚合的示例,以总结和减少你的数据集。我们使用了 JavaScript 的reduce函数以及 Data-Forge 的groupByaggregate函数。

  • 我们使用globby将来自多个文件的数据合并在一起。

** 我们根据标准将数据拆分到多个文件中。我们使用了 JavaScript 的filter函数和 Data-Forge 的where函数。**

7

处理大型数据文件

本章涵盖

  • 使用 Node.js 流

  • 增量处理文件以处理大型数据文件

  • 处理大量 CSV 和 JSON 文件

在本章中,我们将学习如何处理大型数据文件。有多大?对于本章,我从国家海洋和大气管理局(NOAA)下载了一个巨大的数据集。这个数据集包含来自世界各地气象站的测量数据。该数据集的压缩下载约为 2.7 GB。解压后,文件大小达到惊人的 28 GB。原始数据集包含超过 10 亿条记录。然而,在本章中,我们只处理其中的一部分数据,但即使是本章的简化示例数据也无法适应 Node.js 可用的内存,因此为了处理如此大量的数据,我们需要新的技术。

未来,我们希望分析这些数据,我们将在第九章中回到这一点。但就目前而言,我们无法使用传统技术处理这些数据!为了扩大我们的数据处理过程并处理大型文件,我们需要更高级的技术。在本章中,我们将扩展我们的工具集,包括使用 Node.js 流进行 CSV 和 JSON 文件的增量处理。

7.1 扩展我们的工具集

在本章中,我们将使用各种新工具,以便我们可以使用 Node.js 流来增量处理我们的大型数据文件。我们将重新访问熟悉的 Papa Parse 库来处理我们的 CSV 数据,但这次我们将以流模式使用它。为了处理流式 JSON 数据,我将向您介绍一个名为 bfj(Big-Friendly JSON)的新库。

表 7.1 列出了本章中我们涵盖的各种工具。

表 7.1 第七章中使用的工具

API / Library Function / Class Notes
Node.js fs createReadStream 以增量方式打开文件进行读取
createWriteStream 以增量方式打开文件进行写入
stream.Readable 我们实例化这个类以创建自定义的可读数据流。
stream.Writable 我们实例化这个类以创建自定义的可写数据流。
stream.Transform 我们实例化这个类以创建双向转换流,可以在数据通过流时修改我们的数据。
Papa Parse parse / unparse 我们再次使用 Papa Parse,这次是在流模式下用于 CSV 数据的序列化和反序列化。
Bfj (Big-friendly JSON) walk 我们使用第三方库 bfj 进行流式 JSON 反序列化。
Data-Forge readFileStream 以流模式读取文件,允许增量转换
writeFileStream 以流模式写入文件

7.2 修复温度数据

对于本章,我们使用的是我从 NOAA 下载的大型数据集。

你可以从这里下载原始数据集,尽管我不建议这样做;下载文件大小为 2.7 GB,解压后为 28 GB。这些文件可在ftp://ftp.ncdc.noaa.gov/pub/data/ghcn/daily/找到.

我已经做了前期工作,将这个自定义数据集转换为 28 GB 的 weather-stations.csv 文件和 80 GB 的 weather-stations.json 文件,这些文件可以用来测试本章的代码列表。

显然,我无法提供这么大的文件,因为它们太大,不适合这样做;然而,我在 GitHub 存储库的第七章中提供了这些文件的缩减版本(下一节将详细介绍)。

我想分析这个数据集,但我遇到了一个问题。在初步检查数据样本后,我发现温度字段不是以摄氏度表示的。起初,我以为这些值必须是华氏度。但经过实验和查阅数据集的文档后,我发现温度值是以十分之一摄氏度表示的。这是一个不寻常的计量单位,但显然在记录开始时很流行,并且为了保持数据集的一致性而被保留下来。

无论如何,我觉得使用摄氏度更自然,这是我们在澳大利亚的标准温度计量单位。我需要将这些巨大的数据文件中的所有温度字段转换为摄氏度!这几乎是第六章的延续,但现在我们需要新的技术来处理这些大型文件。

7.3 获取代码和数据

本章的代码和数据可在 GitHub 上的 Data Wrangling with JavaScript Chapter-7 存储库中找到。别担心!GitHub 中的示例数据已被大幅缩减,比原始原始数据集小得多。您可以在github.com/data-wrangling-with-javascript/chapter-7找到数据.

示例数据位于存储库中的数据子目录下。由代码生成的输出位于输出目录下,但未包含在 repo 中,因此请运行代码列表以生成输出。

如果需要帮助获取代码和数据,请参考第二章中的“获取代码和数据”。

*## 7.4 当传统数据处理失败时

本书迄今为止介绍的方法在很大程度上是有效的:它们相对简单直接,因此你可以使用它们提高生产力。这些技术能让你走得很远。然而,可能会有这样的时候,你面对一个巨大的数据文件,并被期望处理它。在这种情况下,简单的传统技术将失效——这是因为简单的技术无法扩展到超级大型数据文件。

让我们了解为什么是这样的情况。图 7.1 展示了传统数据处理是如何工作的。

  1. 我们将整个数据文件 input.json 加载到内存中。

  2. 我们在内存中处理整个文件。

  3. 我们输出整个数据文件 output.json。

c07_01.eps

图 7.1 传统数据处理:将整个文件加载到内存中

将整个数据文件加载到内存中很简单,这使得我们的数据处理过程变得直接。不幸的是,它不适用于巨大的文件。在图 7.2 中,你可以看到 large-file.json 不适合我们可用的内存。在第一步时,进程失败,我们无法一次性将整个文件读入内存。之后,我们无法处理或输出文件。我们的进程已经崩溃。

在内存中处理整个文件很方便,我们应该尽可能这样做。然而,如果你知道你需要处理大量数据集,那么你应该尽早开始准备。不久我们将探讨如何处理大文件,但首先让我们来探索 Node.js 的限制。

c07_02.eps

图 7.2 对于太大而无法装入内存的大文件,传统技术会失效。

7.5 Node.js 的限制

我们的进程在什么确切点会崩溃?我们可以在 Node.js 中加载多大文件?

我不确定这些限制是什么。在网上搜索,你会得到各种各样的答案;这是因为答案可能取决于你的 Node.js 版本和操作系统。我亲自测试了 Node.js 的限制。我使用了在我的 Windows 10 笔记本电脑上运行的 64 位 Node.js v8.9.4,该电脑有 8 GB 的内存。

我发现,我可以完全加载的最大 CSV 或 JSON 数据文件的大小受 Node.js 中可以分配的最大字符串大小的限制。在我的测试中,我发现最大的字符串大小约为 512 MB(上下浮动几 MB)或约 2.68 亿个字符。这似乎是 Node.js 所使用的 v8 JavaScript 引擎的限制,这限制了可以通过我们传统数据处理管道的数据文件的大小。

如果你想了解更多关于我如何进行这项测试或自己运行测试的信息,请查看以下 GitHub 仓库中的我的代码:github.com/javascript-data-wrangling/nodejs-json-testgithub.com/javascript-data-wrangling/nodejs-memory-test.

第二个仓库更广泛地探讨了 Node.js 的限制,并将帮助你了解你可以分配的总堆内存量。

7.5.1 增量数据处理

我们有一个大的数据文件:weather_stations.csv。我们需要对这个文件进行转换,将 MinTemp 和 MaxTemp 温度列转换为摄氏度。转换后,我们将输出文件 weather_stations.json。我们正在转换的字段目前以十分之一度摄氏度表示,显然是为了与较旧的记录保持向后兼容。转换的公式很简单:我们必须将每个字段除以 10。我们的困难在于处理这个大文件。传统的流程已经失败了,我们无法将文件加载到内存中,那么我们如何处理这样一个大文件呢?

Node.js 流是解决方案。我们可以使用流来按增量处理数据文件,一次加载和处理一块数据,而不是一次性处理所有数据。

图 7.3 展示了这是如何工作的。文件被分成块。每个数据块都很容易适应可用的内存,这样我们就可以处理它。我们永远不会接近耗尽我们的可用内存。

c07_03.eps

图 7.3 按增量处理数据:一次只将一块数据加载到内存中

传统的数据处理管道非常方便,并且在一定范围内有效。当它开始崩溃时,我们可以引入增量处理,这使得我们的数据处理管道能够扩展到处理大型文件。

有多大?我们受限于文件系统中可用的空间,因为这限制了我们的输入和输出文件的大小。我们还受限于处理整个文件所需的时间。例如,您可能能够在文件系统中容纳一个 100GB 的 CSV 文件,但如果处理需要一周时间,您还关心吗?

只要文件可以放在我们的硬盘上,并且我们有耐心等待处理完成,我们基本上可以处理任何大小的文件。

7.5.2 增量核心数据表示

如您所忆,我们一直在使用一种名为核心数据表示(CDR)的设计模式。CDR 定义了一个共享的数据格式,它连接了我们数据处理管道的各个阶段。当我首次在第三章介绍 CDR 时,我们是在内存中处理整个文件,而 CDR 本身就是我们整个数据集的表示。

我们现在必须调整 CDR 设计模式以适应增量数据处理。我们不需要做任何事情,也许只是深化我们对 CDR 的理解。

CDR 是一个 JavaScript 对象的数组,其中每个对象都是我们数据集中的一个记录。目前,转换管道中的每个阶段都在处理整个数据集。您可以在图 7.4 中看到一个例子,我们取 weather-stations.csv 并通过几个转换,最后输出另一个名为 weather-stations-transformed.csv 的文件。

c07_04.eps

图 7.4 传统核心数据表示在内存中对整个文件应用转换。

让我们改变我们的思维方式,重新定义 CDR,使其不再表示整个数据集,而是现在将表示我们整个数据集的一部分。图 7.5 展示了经过改造的 CDR 如何以增量方式逐块处理我们的数据集。

这意味着,任何已经编写为与 CDR 一起工作的工具箱中的代码模块,无论是使用传统方法还是增量数据处理,都能同样良好地工作。我们与 CDR 一起工作的可重用代码模块处理记录数组,现在我们切换到 CDR 的增量版本,我们仍然将记录数组传递到我们的转换阶段。但现在这些数组每个都代表记录的一部分,而不是整个数据集。

7.5.3 Node.js 文件流基础知识入门

我们将使用 Node.js 流来增量处理我们的大 CSV 和 JSON 文件,但在我们能够这样做之前,我们首先需要了解 Node.js 流的基本知识。如果您已经了解它们是如何工作的,请跳过本节。

我们需要了解可读流、可写流以及管道的概念。我们将从最简单的例子开始。图 7.6展示了将可读输入流管道连接到可写输出流。这基本上是一个文件复制,但由于使用了 Node.js 流,数据是分块复制的,一次不会将整个文件加载到内存中。Node.js 会自动为我们分块化文件,我们不需要担心分块创建或管理。

c07_05.eps

图 7.5 增量核心数据表示:设计模式被调整为增量工作。

c07_06.eps

图 7.6 将输入文件流管道连接到输出文件流

列表 7.1 展示了实现图 7.6 中所示过程的代码。我们从一个可读文件流打开 weather-stations.csv,并为 weather-stations-transformed.csv 创建一个可写文件流。调用 pipe 函数来连接流并使数据从输入文件流向输出文件。尝试运行代码,并查看生成的转换文件,该文件位于输出子目录中,

列表 7.1 简单 Node.js 文件流(listing-7.1.js)

const fs = require('fs');

const inputFilePath = "./data/weather-stations.csv";
const outputFilePath = "./output/weather-stations-transformed.csv";

const fileInputStream = fs.createReadStream(inputFilePath);    ①  
const fileOutputStream = fs.createWriteStream(outputFilePath);    ②  

fileInputStream.pipe(fileOutputStream);    ③   

很简单,对吧?诚然,列表 7.1 不是一个特别有用的例子。我们使用 Node.js 流,这些流不理解我们数据的结构,但这个例子是为了从基本示例开始学习 Node.js 流。管道的有趣之处在于,我们可以通过将流通过一个或多个转换流来添加任意数量的中间转换阶段。例如,具有三个转换(X、Y 和 Z)的数据流可能看起来像这样:

fileInputStream
    .pipe(transformationX)
    .pipe(transformationY)
    .pipe(transformationZ)
    .pipe(fileOutputStream); 

每个中间转换阶段都可以是一个独立的可重用代码模块,你可能之前已经创建过,现在从你的工具包中提取出来。或者它们可能是针对你当前项目的特定转换。

学习 Node.js 流很重要,因为它们允许我们从可重用代码模块中构建可伸缩的数据转换管道。不仅我们的数据管道可以有任意数量的中间处理阶段,而且它们现在也可以处理任意大小的文件(这正是我们所需要的!)。

你应该像可视化本书中的任何数据管道一样,以相同的方式可视化流数据管道——一系列由箭头连接的框。参见 图 7.7 中的示例。箭头显示了数据流的流向。

c07_07.eps

图 7.7 通过多个转换阶段管道传输 Node.js 流

要为 Node.js 流创建这样的转换,我们需要实例化 Transform 类。这创建了一个双向流,它可以同时读取和写入。它需要可写,以便我们可以将输入数据管道传输到它。它需要可读,以便它可以管道传输转换后的数据到管道的下一阶段。

例如,让我们看看一个简单转换的工作示例。列表 7.2 是 列表 7.1 的扩展,它通过一个转换流将数据传递时将文本数据转换为小写。Node.js 流 API 自动将我们的文本文件分割成块,我们的转换流一次只处理一小块文本。

我告诉过你这将会很简单。我们正在处理文本文件,列表 7.2 将输入文件复制到输出文件。但在过程中,它也将所有文本转换为小写。运行此代码,然后比较输入文件 weather-stations.csv 和输出文件 weather-stations-transformed.csv,以查看所做的更改。

列表 7.2 转换 Node.js 流(listing-7.2.js)

//
// … setup is the same as listing 7.1 …
//

function transformStream () {    ①  
 const transformStream = new stream.Transform();    ②  
 transformStream._transform = (inputChunk, encoding, callback) => {    ③  
 const transformedChunk = inputChunk.toString().toLowerCase();    ④  
 transformStream.push(transformedChunk);    ⑤  
 callback();    ⑥  
 };    ③  
 return transformStream;    ⑦  
};    ①  

fileInputStream
 .pipe(transformStream())    ⑧  
    .pipe(fileOutputStream)
 .on("error", err => {    ⑨  
 console.error(err);    ⑨  
 }); //  ⑨   

注意 列表 7.2 末尾的错误处理。流错误处理与承诺类似:当管道中的某个阶段发生错误或异常时,它将终止整个管道。

这只是一个关于 Node.js 流的简要入门。我们只是触及了表面,但我们已经可以做一些实际的事情:我们可以通过转换流式传输我们的数据,而且我们已经以一种可以扩展到极大型文件的方式做到了这一点。

7.5.4 转换巨大的 CSV 文件

我们不仅对纯文本文件感兴趣;我们需要转换结构化数据。具体来说,我们有数据文件 weather-stations.csv,我们必须枚举其记录并将温度字段转换为摄氏度。

我们如何使用 Node.js 流来转换一个大型 CSV 文件?这可能很困难,但幸运的是,我们在第三章开始使用的 Papa Parse 库已经支持读取 Node.js 流。

不幸的是,Papa Parse 没有提供我们可以轻松管道连接到另一个流的可读流。相反,它有一个自定义 API,每当从 CSV 格式解析出数据块时,它会触发自己的事件。不过,我们将创建自己的 Papa Parse 适配器,以便我们可以将它的输出管道连接到 Node.js 流。这本身就是一个有用的技术——将非流式 API 适配,使其适合 Node.js 流式框架。

在图 7.8 中,你可以看到我们将如何将解析后的 CSV 数据通过转换温度流,然后再将其输出到另一个 CSV 文件。

c07_08.eps

图 7.8 大型 CSV 文件的流式转换

为了让你了解我们在这里试图实现的目标,考虑以下代码片段:

openCsvInputStream(inputFilePath) // 1
    .pipe(convertTemperatureStream()) // 2
    .pipe(openCsvOutputStream(outputFilePath)); // 3 

那么,这里发生了什么?

  1. 我们正在打开一个可读的 CSV 数据流。这里流式传输的数据块以核心数据表示形式表达。

  2. 然后,我们将 CSV 数据通过一个转换流。这是我们将温度字段转换为摄氏度的位置。

  3. 最后,我们将转换后的数据管道连接到一个可写的 CSV 数据流。

函数convertTemperatureStream可能是一个可重用的代码模块,尽管它似乎非常特定于这个项目,但如果它具有通用性,我们可以在我们的工具包中为其提供一个位置。

安装 Papa Parse

如果你已经为代码仓库安装了依赖项,那么你已经有 Papa Parse 了;否则,你可以在一个新的 Node.js 项目中按照以下方式安装它:

node install --save papaparse 

打开一个可读的 CSV 流

我们 CSV 流式传输难题的第一部分是创建一个可读流,它可以流式传输 CSV 文件并增量解析它到 JavaScript 对象。我们最终想要的是反序列化的 JavaScript 对象。图 7.9 展示了我们将如何将 Papa Parse 封装在可读 CSV 数据流中。这给了我们一个输入流,我们可以将其管道连接到我们的数据转换流。

c07_09.eps

图 7.9 将 Papa Parse CSV 反序列化封装在可读 CSV 数据流中

让我们创建一个新的工具函数openCsvInputStream来创建并返回我们的可读 CSV 数据流。该代码在下面的列表中展示。它使用了 Papa Parse 的定制流式 API。当 Papa Parse 从文件流中反序列化每个 JavaScript 对象时,反序列化的对象会被传递到我们的 CSV 数据流中。

列表 7.3 打开 CSV 文件输入流的工具函数(toolkit/open-csv-input-stream.js)

const stream = require('stream');
const fs = require('fs');
const papaparse = require('papaparse');

function openCsvInputStream (inputFilePath) {    ①  

 const csvInputStream = new stream.Readable({ objectMode: true });    ②  
 csvInputStream._read = () => {};    ③  

 const fileInputStream = fs.createReadStream(inputFilePath);    ④  
 papaparse.parse(fileInputStream, {    ⑤  
        header: true,
        dynamicTyping: true,
 skipEmptyLines: true,  ⑥  
 step: (results) => {    ⑦  
            for (let row of results.data) {
 csvInputStream.push(row);    ⑧  
            }        

 complete: () => {    ⑨  
 csvInputStream.push(null);    ⑩  
        },

 error: (err) => {    ⑪  
 csvInputStream.emit('error', err);    ⑫  
 }    ⑪  
    });

 return csvInputStream;    ①  
};    ①  

module.exports = openCsvInputStream;    ⑬   

注意 列表 7.3 中的几个关键点。首先,我们创建了一个启用 对象模式 的可读流。通常,Node.js 流是低级的,它使用 Node.js Buffer 对象枚举文件的原始内容。我们希望在一个更高的抽象级别上工作。我们希望检索 JavaScript 对象而不是原始文件数据,这就是为什么我们以对象模式创建了可读流。这允许我们处理以核心数据表示形式表达的数据流。

下一个需要注意的是我们如何将 CSV 数据传递到可读流。每当 Papa Parse 准备好一批 CSV 行供我们使用时,step 回调函数就会被调用。我们通过其 push 函数将此数据传递到可读流。可以说我们是在 推送 数据到流中。

当整个 CSV 文件被解析时,complete 回调函数会被调用。此时,不会再有 CSV 行通过,我们通过传递一个 null 参数给 push 函数来向流指示我们已经完成。

最后,别忘了 error 回调:这是我们向可读流转发 Papa Parse 错误的方式。

打开可写 CSV 流

在我们 CSV 流式传输谜题的另一边,我们必须创建一个可写流,我们可以将 JavaScript 对象传递给它,并将它们以 CSV 格式写入文件。图 7.10 展示了我们将如何将 Papa Parse 封装在可写 CSV 数据流中。这为我们提供了一个可以用来输出转换后数据的流。

c07_10.eps

图 7.10 在可写 CSV 数据流中封装 Papa Parse CSV 序列化

以下列表是一个新的工具函数 openCsvOutputStream,它打开我们的可写 CSV 数据流。对于传递到 CSV 输出流的每个 JavaScript 对象,在传递到文件输出流之前,它都会被 Papa Parse 序列化为 CSV 数据。

列表 7.4 打开 CSV 文件输出流的工具函数(toolkit/open-csv-output-stream.js)

const stream = require('stream');
const fs = require('fs');
const papaparse = require('papaparse');

function openCsvOutputStream (outputFilePath) {    ①  

 let firstOutput = true;    ②  
 const fileOutputStream = fs.createWriteStream(outputFilePath);    ③  

 const csvOutputStream = new stream.Writable({ objectMode: true });    ④  
 csvOutputStream._write = (chunk, encoding, callback) => {    ⑤  
 const outputCSV = papaparse.unparse([chunk], {    ⑥  
 header: firstOutput    ⑦  
 });    ⑥  
 fileOutputStream.write(outputCSV + "\n");    ⑧  
 firstOutput = false;    ⑨  
 callback();    ⑩  
 };    ⑤  

 csvOutputStream.on("finish", () => {    ⑪  
 fileOutputStream.end();    ⑪  
 });    ⑪  

 return csvOutputStream;    ①  
};    ①  

module.exports = openCsvOutputStream;    ⑫   

在这里,我们再次以启用 对象模式 的方式打开我们的流,这样我们就可以处理 JavaScript 对象流,而不是 Node.js Buffer 对象。

列表 7.4 比列表 7.3 简单一些。我们实现了 write 函数来处理写入到可写 CSV 数据流的块数据。在这里,我们使用 Papa Parse 序列化记录,然后将它们转发到可写文件流以进行输出。

注意使用 firstOutput 变量来关闭除了第一条记录之外的所有 CSV 头部。我们允许 Papa Parse 仅在 CSV 文件的开始处输出 CSV 列表名。

在列表的末尾,我们处理可写流的 finish 事件,这是关闭可写文件流的地方。

转换巨大的 CSV 文件

现在我们已经设置了两个工具函数,我们可以拼凑整个数据管道。我们可以打开一个流来读取和解析 weather-stations.csv。我们还可以打开一个流来序列化我们的转换数据并输出 weather-stations-transformed.csv。完成的数据转换在以下列表中展示。运行此代码后,你应该在输入和输出文件中视觉比较温度字段,以确保它们已被正确转换。

列表 7.5 转换大型 CSV 文件(listing-7.5.js)

const stream = require('stream');
const openCsvInputStream = require('./toolkit/open-csv-input-stream');
const openCsvOutputStream = require('./toolkit/open-csv-output-stream');

const inputFilePath = "./data/weather-stations.csv";
const outputFilePath = "./output/weather-stations-transformed.csv";

function transformRow (inputRow) { //  ①  

 const outputRow = Object.assign({}, inputRow);    ②  

 if (typeof(outputRow.MinTemp) === "number") {    ③  
 outputRow.MinTemp /= 10;    ③  
 }    ③  
 else {    ③  
 outputRow.MinTemp = undefined;    ③  
 }    ③  

 if (typeof(outputRow.MaxTemp) === "number") {    ③  
 outputRow.MaxTemp /= 10;    ③  
 }    ③  
 else {    ③  
 outputRow.MaxTemp = undefined;    ③  
 }    ③  

 return outputRow;    ④  
};    ①  

function convertTemperatureStream () {    ⑤  
 const transformStream = new stream.Transform({ objectMode: true });    ⑥  
 transformStream._transform = (inputChunk, encoding, callback) => {    ⑦  
 const outputChunk = transformRow(inputChunk);    ⑧  
 transformStream.push(outputChunk);    ⑨  
 callback();    ⑩  
 };    ⑧  
 return transformStream;    ⑥  
};    ⑥  

openCsvInputStream(inputFilePath)    ⑪  
 .pipe(convertTemperatureStream())    ⑫  
 .pipe(openCsvOutputStream(outputFilePath))    ⑬  
 .on("error", err => {    ⑭  
        console.error("An error occurred while transforming the CSV file.");  
        console.error(err);  
    }); 

注意,transformRow 是转换单个数据记录的函数。它会在整个文件以分块方式处理时,逐条记录多次调用。

7.5.5 转换巨大的 JSON 文件

现在让我们来看看转换巨大的 JSON 文件。这可能是比处理大型 CSV 文件更困难的事情,这也是为什么我把它留到最后。

我们将对 weather-stations.json 执行类似的转换:将温度字段转换为摄氏度,然后输出 weather-stations-transformed.json。我们将使用与转换大型 CSV 文件时类似的原则。

但为什么增量处理 JSON 更困难呢?通常,JSON 文件比 CSV 文件更容易解析,因为我们需要进行此操作的功能已经内置在 JavaScript 中,而且 JSON 与 JavaScript 的匹配度非常高。在这种情况下,由于 JSON 数据格式的特性,这变得更加困难。

JSON 自然是一种分层的数据格式。我们可以在 JSON 中表达简单和扁平的表格数据——就像你在本书中已经看到的那样——但 JSON 文件可以深度嵌套,比简单的表格数据复杂得多。我编写的代码,你在这里会看到,它假设 JSON 文件只包含一个扁平的对象数组,没有嵌套数据。请务必注意,这里展示的代码不一定适用于通用 JSON 数据文件,你可能需要根据你的需求对其进行调整。

在本节中,我们将使用一个名为 bfj 或 Big-Friendly JSON 的库。这是一个用于解析流式 JSON 文件的巧妙库。它就像我们使用 Papa Parse 所做的那样;我们将 bfj 封装在一个可读的 JSON 流中,通过转换温度流将其传递,然后使用可写 JSON 流将其输出到 weather-stations-transformed.json,如图 7.11 所述。我们将重用之前创建的相同转换流,但这次我们将它嵌入到输入和输出 JSON 文件之间的管道中。

c07_11.eps

图 7.11 大型 JSON 文件的流式转换

安装 bfj

如果你为第七章代码仓库安装了依赖项,那么你已安装了 bfj;否则,你可以在新的 Node.js 项目中按以下方式安装它:

node install --save bfj 

打开可读的 JSON 流

我们必须首先创建一个可读流,它可以增量地读取 JSON 文件并将其解析为 JavaScript 对象。图 7.12 展示了我们将如何封装 bfj 在我们的可读 JSON 数据流中。这给了我们一个可以用来读取 JSON 文件并将反序列化数据通过管道传输到另一个流的输入流。

让我们创建一个新的工具函数openJsonInputStream来创建我们的可读 JSON 数据流。Bfj 是一个自定义 API,它通过识别 JSON 文件中的结构来触发事件。当它识别到 JSON 数组、JSON 对象、属性等时,它会触发事件。在列表 7.6 中,我们处理这些事件,以增量地构建我们的 JavaScript 对象并将它们喂送到可读 JSON 流中。一旦我们识别到每个完整的 JSON 对象,我们就立即将等效的反序列化 JavaScript 对象传递到 JSON 数据流中。

c07_12.eps

图 7.12 将 bfj JSON 反序列化封装在可读 JSON 数据流中

列表 7.6 工具函数用于打开 JSON 文件输入流(toolkit/open-json-file-input-stream.js)

const bfj = require('bfj');
const fs = require('fs');
const stream = require('stream');

function openJsonInputStream (inputFilePath ) {    ①  

 const jsonInputStream = new stream.Readable({ objectMode: true });    ②  
 jsonInputStream._read = () => {};    ③  

 const fileInputStream = fs.createReadStream(inputFilePath);    ④  

 let curObject = null;    ⑤  
 let curProperty = null;    ⑥  

 const emitter = bfj.walk(fileInputStream);    ⑦  

 emitter.on(bfj.events.object, () => {    ⑧  
 curObject = {};    ⑧  
 });    ⑧  

 emitter.on(bfj.events.property, name => {    ⑨  
 curProperty = name;    ⑨  
 });    ⑨  

 let onValue = value => {    ⑩  
 curObject[curProperty] = value;    ⑩  
 curProperty = null;    ⑩  
 };    ⑩  

 emitter.on(bfj.events.string, onValue);    ⑩  
 emitter.on(bfj.events.number, onValue);    ⑩  
 emitter.on(bfj.events.literal, onValue);    ⑩  

 emitter.on(bfj.events.endObject, () => {    ⑪  
 jsonInputStream.push(curObject);    ⑫  
 curObject = null;    ⑬  
    });

 emitter.on(bfj.events.endArray, () => {    ⑭  
 jsonInputStream.push(null);    ⑭  
 });    ⑭  

 emitter.on(bfj.events.error, err => {    ⑮  
 jsonInputStream.emit("error", err);    ⑮  
 });    ⑮  

 return jsonInputStream;    ①  
};    ①  

module.exports = openJsonInputStream;    ⑯   

在列表 7.6 中需要注意的一点是我们如何使用 bfj 的walk函数来遍历JSON 文件的结构。在这里使用遍历这个术语是因为 JSON 文件可能是一个分层文档。它可能被组织成树状结构,我们必须遍历(或遍历)这个树来处理它,尽管在这种情况下我们并没有处理分层文档。相反,我们假设 weather-stations.json 包含一个扁平的数据记录数组。当 bfj 为数组、每个对象和属性触发事件时,我们收集这些事件并构建数据记录,通过其push函数将它们喂送到 JSON 数据流中。

由于我们期望输入的 JSON 文件是一个扁平的记录数组,当 bfj 的endArray事件被触发时,在那个点上,我们通过将null传递给push函数来表示流的结束。

打开可写 JSON 流

为了完成我们的 JSON 文件转换流,我们还必须有一个可写的 JSON 流,我们可以将 JavaScript 对象传递给它,并将它们以 JSON 格式写入输出文件。图 7.13 展示了我们将如何封装 JSON.stringify 在可写 JSON 数据流中。这给了我们一个可写流,我们可以增量地将对象写入它,并将它们按顺序序列化到输出文件 weather-stations-transformed.json 中。

列表 7.7 展示了工具函数openJsonOutputStream,它打开我们的可写 JSON 数据流,因此我们可以开始输出 JavaScript 对象。对于传递给 JSON 数据流的每个 JavaScript 对象,我们将其序列化为 JSON,并将序列化的 JSON 数据传递到文件输出流中。

c07_13.eps

图 7.13 将 bfj JSON 序列化封装在可写 JSON 数据中

列表 7.7 打开 JSON 文件输出流的工具函数(toolkit/open-json-file-output-stream.js)

const fs = require('fs');
const stream = require('stream');

function openJsonOutputStream (outputFilePath) {    ①  

 const fileOutputStream = fs.createWriteStream(outputFilePath);    ②  
 fileOutputStream.write("");  [  ③  

 let numRecords = 0;    ④  

 const jsonOutputStream = new stream.Writable({ objectMode: true });    ⑤  
 jsonOutputStream._write = (chunk, encoding, callback) => {    ⑥  
        if (numRecords > 0) {
 fileOutputStream.write(",");    ③  
        }

        // Output a single row of a JSON array.
 const jsonData = JSON.stringify(chunk);    ⑦  
 fileOutputStream.write(jsonData);    ⑧  
        numRecords += chunk.length;
 callback();    ⑨  
 };    ⑥  

 jsonOutputStream.on("finish", () => {    ⑩  
 fileOutputStream.write("]");    ③  
 fileOutputStream.end();    ⑩  
 });    ⑩  

 return jsonOutputStream;    ①  
};    ①  

module.exports = openJsonOutputStream;    ⑪   

正如我们在 CSV 输出流中发现的那样,打开可写 JSON 流的代码比打开可读 JSON 流的代码简单得多。再次,我们实现 _write 函数来序列化记录并将它们写入文件。在这里,我们使用 JSON.stringify 来序列化每个数据记录。

最后,我们处理 finish 事件并使用它来最终化流。

转换巨大的 JSON 文件

使用我们为打开输入和输出 JSON 数据流而新增的两个工具函数,我们现在可以转换我们的巨大 JSON 文件,如列表 7.8 所示。为了使列表保持简洁,我已省略了自列表 7.5 以来未发生变化的几个函数。这是一个可以独立运行的完整代码列表;请确保检查输出数据文件,以确保数据转换成功。

列表 7.8 转换巨大的 JSON 文件(listing-7.8.js)

const stream = require('stream');
const openJsonInputStream = require('./toolkit/open-json-input-stream.js');
const openJsonOutputStream =
    require('./toolkit/open-json-output-stream.js');

const inputFilePath = "./data/weather-stations.json";
const outputFilePath = "./output/weather-stations-transformed.json";

// ... transformRow, transformData and convertTemperatureStream are omitted
// they are the same as listing 7.5 …

openJsonInputStream(inputFilePath)    ①  
 .pipe(convertTemperatureStream())    ②  
 .pipe(openJsonOutputStream(outputFilePath))    ③  
 .on("error", err => {    ④  
        console.error(
            "An error occurred while transforming the JSON file."
 );    ④  
 console.error(err);    ④  
 });    ④   

我们现在可以使用 Node.js 流来处理大量的 CSV 和 JSON 文件。你还需要什么?作为副作用,我们现在可以混合匹配我们的流,这使我们能够快速构建各种 流式 数据管道。

7.5.6 混合匹配

在我们的数据管道阶段之间,核心数据表示作为抽象,我们可以轻松构建不同格式之间的大型数据文件的转换管道。

例如,考虑我们如何将 CSV 文件转换为 JSON 文件:

openCsvInputStream(inputFilePath)    ①  
 .pipe(transformationX)    ②  
 .pipe(transformationY)    ②  
 .pipe(transformationZ)    ②  
 .pipe(openJsonOutputStream(inputFilePath));    ③   

以同样的方式,我们可以将 JSON 转换为 CSV,或者实际上从任何格式转换为任何其他格式,前提是我们为该数据格式创建一个合适的流。例如,你可能想处理 XML,那么你会创建一个函数来打开一个流式 XML 文件,然后使用它来转换 XML 文件或将它们转换为 CSV 或 JSON。

在本章中,我们探讨了传统数据处理技术在面对大型数据文件时可能会崩溃的情况。有时,希望这种情况不常发生,我们必须采取更极端的措施,并使用 Node.js 流来增量处理这些巨大的数据文件。

当你发现自己陷入处理大型数据文件的困境时,你可能想知道是否有更好的方法来处理大型数据集。好吧,我相信你已经猜到了,但我们应该与数据库一起工作。在下一章中,我们将构建一个 Node.js 流,该流将我们的记录输出到数据库。这将使我们能够将大型数据文件移动到数据库中,以便更高效、更方便地访问数据。

摘要

  • 我们讨论了 Node.js 的内存限制。

  • 你了解到增量处理可以用来处理大型数据文件。

  • 我们找到了如何将核心数据表示设计模式适应增量处理的方法。

  • 我们使用 Node.js 流来构建由可重用代码模块组成的数据管道,这些模块可扩展到大型数据文件。

  • 你了解到你可以混合匹配 Node.js 流来构建各种数据管道。

8

处理大量数据

本章涵盖

  • 使用数据库进行更高效的数据处理过程

  • 将大量数据文件导入 MongoDB

  • 高效地处理大量数据

  • 优化你的代码以提高数据吞吐量

本章解决的问题是:当我们处理大量数据集时,我们如何更高效和有效地工作?

在上一章中,我们处理了从国家海洋和大气管理局下载的几个非常大的文件。第七章表明,可以处理这样大的 CSV 和 JSON 文件!然而,这样大小的文件对于数据分析来说太大,无法有效使用。为了现在变得高效,我们必须将我们的大数据集移动到数据库中。

在本章中,我们将数据移动到 MongoDB 数据库中,考虑到数据的大小,这是一个大操作。数据在数据库中,我们可以借助查询和其他数据库 API 功能更有效地工作。

我选择 MongoDB 作为本章以及整本书的数据库,因为它是我偏好的数据库。这是一个个人选择(我相信也是一个实用的选择),但实际上任何数据库都可以,我鼓励你尝试在本章中使用你选择的数据库来实践这些技术。这里介绍的大多数技术都可以与其他数据库一起工作,但你必须找出如何将代码转换为与你的技术选择兼容。

8.1 扩展我们的工具集

在本章中,我们将使用几个 MongoDB 数据库工具来处理我们的大数据集。我们还将使用 Node.js 函数来创建新的操作系统进程,以便在多个 CPU 核心上并行执行数据处理操作。

表 8.1 列出了我们在第八章中介绍的各种工具。

表 8.1 第八章中使用的工具

API / 库 函数 说明
MongoDB find 获取数据库游标,以便我们可以增量地访问数据库中的每条记录。
skip and limit 获取数据窗口或记录集合,以便我们可以分批访问数据库中的每条记录。
createIndex 为高效查询和排序创建数据库索引。
find(query) 使用数据库查询查找记录。
find({}, projection) 获取记录,但丢弃某些字段。
sort 对从数据库中检索的记录进行排序。
Node.js spawn, fork 创建新的操作系统进程以并行处理数据。
async-await-parallel parallel(sequence, X) 执行一系列操作,其中 X 个操作并行执行。

8.2 处理大量数据

我们想分析上一章中的气象站数据集。我们目前还不能这样做,因为我们有比我们能有效处理更多的数据。

我们有 weather-stations.csv 文件,但 28GB 的大小使得直接处理这个文件并不实际。大多数数据科学教程和课程都要求你使用 CSV 文件来分析数据,当可能的时候,这是一种很好的工作方式,但它只适用于小规模。使用 CSV 文件(以及 JSON 文件)无法扩展到我们现在拥有的这种大规模数据集。我们该如何处理这个问题呢?

我们即将将数据迁移到数据库,届时我们将有更多新工具可用于处理我们的数据。但在查看数据库之前,我们将探索一些更简单的技术,这些技术将帮助你管理你的大数据集。然后我们将探讨 Node.js 的内存限制以及我们如何超越它们。最后,我们将探讨代码优化和其他提高数据吞吐量的方法。

8.3 获取代码和数据

本章的代码和数据可在 GitHub 上的 Data Wrangling with JavaScript Chapter-8 存储库中找到,网址为github.com/data-wrangling-with-javascript/chapter-8

示例数据位于存储库下的data子目录中。

GitHub 存储库包含两个 Vagrant 脚本,这些脚本可以方便地引导虚拟机安装 MongoDB 数据库。第一个脚本启动一个带有空数据库的虚拟机,当你运行列表 8.2 以练习将数据导入数据库时可以使用。第二个脚本启动一个带有预先填充示例数据的虚拟机,你可以用它来尝试列表 8.3 及以后的练习。

如果你在获取代码和数据时需要帮助,请参考第二章的“获取代码和数据”。

8.4 处理大数据的技术

我们需要将我们的大数据集存入数据库。然而,在我们做到这一点之前,让我们快速浏览几种技术,这些技术将帮助你在任何情况下都更加高效。

8.4.1 从小开始

从第五章,我们已经了解到我们应该从处理小数据集开始。你应该首先将你的大数据集削减到可以更容易和更有效地处理的大小。

处理大数据会减慢你的速度;你无法绕过这一点,所以不要急于投身于大数据。先解决你的问题,为小数据集编写你的代码;小问题比大问题更容易解决!专注于在小规模上构建可靠且经过充分测试的代码。然后,只有在你自信且准备好处理它时,才逐步扩展到大数据。

8.4.2 回到小规模

当你处理大数据并遇到问题时,减少你的数据量,以便再次处理一个小数据集,尽可能紧密地关注问题。试图在大数据集中解决问题可能就像在干草堆里找针一样(图 8.1)。这适用于解决任何类型的编码问题。你应该尝试通过最小化问题可能隐藏的空间来隔离问题。

你可以通过逐步删除代码和数据(如果可能的话)直到问题无处可藏来做这件事。问题应该变得明显或至少更容易找到。为了在大数据集中找到问题,使用二分搜索或二分法逐步减少数据并聚焦于问题。

c08_01.png

图 8.1 大数据集中的错误就像干草堆里的针。

8.4.3 使用更高效的表现形式

确保你使用的是高效的数据表示。CSV 文件比 JSON 文件更高效(至少更紧凑),而使用数据库比 JSON 和 CSV 更高效(图 8.2)。在小规模工作时,使用 JSON 或 CSV 是有效的,但在大规模工作时,我们需要拿出“杀手锏”。

c08_02.eps

图 8.2 数据格式的效率谱

8.4.4 离线准备你的数据

在尝试扩展之前,确保你已经充分准备了你的数据。我们应该通过使用第六章中介绍的各种技术进行准备和清理阶段,以减少数据量并主动处理问题。为生产使用准备数据的过程总结在 f 中。

c08_03.eps

图 8.3 离线准备用于生产的数据

这样的准备需要多长时间?这取决于你的数据量的大小,可能需要非常多的时间。对于本章,我准备了 NOAA 气象站数据,并运行了一个执行了超过 40 小时的脚本!数据是在一个 8 核心 CPU 上并行处理的,使用的技术将在本章末尾介绍。不过,不用担心;你不需要经历 40 小时的处理时间来学习如何进行大数据处理。

多长时间算太长?我主张让你的数据处理脚本运行尽可能长的时间,但显然有一个上限,这取决于你业务的性质。我们希望及时得到结果。例如,如果你需要在周五提交报告,你不能运行超过那个时间的过程。

在达到这个阶段之前,你需要可靠且健壮的代码(参见“从小处着手”部分)。你还应该使用功能强大的电脑。记住,如果我能在 40 小时(一个周末)内处理超过 10 亿条记录,你也可以。这并不是火箭科学,但它确实需要仔细的准备和足够的耐心。在本章的后面部分,我们将探讨优化我们的数据管道和实现更高吞吐量的方法。

如果你计划运行长时间的数据处理操作,请考虑以下建议:

  • 包含日志和进度报告,这样你可以看到发生了什么。

  • 报告所有错误。你可能以后需要纠正它们。

  • 不要因为个别错误而使整个过程失败。完成一个大型数据处理操作的 85% 比在遇到问题时从头开始要好。

  • 确保你的过程在出错时可以恢复。如果你在过程中遇到错误导致进程终止,修复错误;然后重新启动进程,并从上次停止的地方恢复。

8.5 更多 Node.js 限制

在第七章中,我们处理了几个非常大的 CSV 和 JSON 文件,并遇到了无法将这些文件完全加载到内存中的限制。我们达到这个限制是因为我们触发了 Node.js 中可以分配的最大字符串大小。在这个时候,我们转向使用 Node.js 流和增量文件处理,这使得我们能够处理这些大文件。在本章中,我们有一个新的限制。

使用数据库意味着我们可以一次性将更大的数据集加载到内存中。然而,现在我们受限于在所有可用内存耗尽之前 Node.js 可以分配的最大内存量。

究竟需要多少内存?这取决于你使用的 Node.js 版本和操作系统。我亲自测试了使用 64 位 Node.js v7.7.4 在我的 Windows 10 笔记本电脑上运行的极限,该电脑有 8 GB 的内存。我是通过分配 Node.js 数组直到内存耗尽来测试的;然后我估计了已分配的内存量。这并不完全准确,但这是一个很好的方法来大致判断我们可以访问多少内存。

通过测试,我可以说我大约有 1.4 GB 的可用内存。这是一个很好的数量,应该可以处理相当大的数据集,但我们已经可以看到 Node.js 无法从 NOAA 加载我们的 28 GB 气象站数据集!

如果你想了解更多关于我如何进行这项测试或你想自己运行测试,请参阅以下 GitHub 仓库中的我的代码 github.com/data-wrangling-with-javascript/nodejs-memory-test

8.6 分而治之

我们无法将整个气象站数据集加载到内存中,但我们可以将其分成批次进行处理,如图 8.4 所示。figure 8.4。分而治之是计算机科学中的一个经典技术。简单来说,我们有一个大问题,最好通过将其分解成多个小问题来解决。小问题比大问题更容易解决(参见“从小处着手”和“回到小处”部分)。一旦我们解决了每个小问题,我们将结果合并,这样我们就解决了大问题。

c08_04.eps

Figure 8.4 将数据分割成单独的批次进行处理

当我们分割我们的数据时,我们必须组织它,使得每个批次足够小,可以完全放入内存中。这种技术不仅允许我们将数据放入内存(逐批处理),而且还可以使处理速度大大加快。在本章的结尾,我们将看到如何并行处理我们的数据,并使用多个 CPU 核心来大幅提高我们的数据吞吐量。

8.7 与大型数据库协同工作

使用数据库是专业数据管理的首选标准。所有数据库都有处理大型数据集的功能,这正是我们感兴趣的。

我们将查看以下功能:

  • 使用数据库游标逐条增量处理记录

  • 使用数据窗口逐批增量处理记录

  • 使用查询过滤和丢弃数据

  • 对大型数据集进行排序

虽然大多数(如果不是所有)数据库都有我们需要的功能,但我们将专注于 MongoDB。我必须选择一个,我的首选是 MongoDB。

为什么选择 MongoDB?它方便、易用且灵活。最重要的是,它不需要预定义的模式。我们可以使用 MongoDB 表达许多种模式化的结构化数据,但不必预先定义这种结构;我们可以将任何类型的数据扔给 MongoDB,它都会处理。MongoDB 及其 BSON(二进制 JSON)数据格式与 JavaScript 自然地很好地配合。

8.7.1 数据库设置

在我们开始使用数据库之前,我们需要先设置它!你可以从www.mongodb.org/下载并安装 MongoDB。否则,你可以使用我在 GitHub 仓库中为第八章提供的 Vagrant 脚本之一(参见“获取代码和数据”)。

要使用这些脚本,你首先需要安装 Virtual Box 和 Vagrant。然后打开命令行,将目录更改为第八章的 git 仓库:

cd chapter-8 

然后,你可以使用第一个 Vagrant 脚本启动一个带有空 MongoDB 数据库的虚拟机:

cd vm-with-empty-db
vagrant up 

当虚拟机完成启动后,你将拥有一个可通过mongodb://localhost:6000访问的空 MongoDB 数据库。

或者,如果你想实验一个已经包含气象站数据样本的数据库(由于数据集太大,我无法发布完整的数据集),请使用第二个 Vagrant 脚本:

cd vm-with-sample-db
vagrant up 

当这个虚拟机完成启动后,你将拥有一个包含示例数据且可通过mongodb://localhost:7000访问的 MongoDB。

在你完成虚拟机后,请销毁它们,以便停止消耗你的系统资源。为此,为两个虚拟机执行以下命令:

vagrant destroy 

你可以通过再次使用 Vagrant 来随时重新创建虚拟机。有关 Vagrant 的更多信息,请参阅附录 C。

要从 JavaScript 访问数据库,我们将使用官方的 MongoDB Node.js 库。如果你为第八章的存储库安装了依赖项,你已经安装了 MongoDB API;否则,你可以在新的 Node.js 项目中安装它,如下所示:

npm install --save mongodb 

8.7.2 打开数据库连接

在所有接下来的代码清单中,我们必须做的第一件事是连接到我们的数据库。为了使清单简单,它们都使用以下代码来打开数据库连接:

const MongoClient = require('mongodb').MongoClient;    ①  

const hostName = "mongodb://127.0.0.1:6000";    ②  
const databaseName = "weather_stations";    ③  
const collectionName = "daily_readings";    ④  

function openDatabase () {    ⑤  
 return MongoClient.connect(hostName)    ⑥  
        .then(client => {
 const db = client.db(databaseName);    ⑦  
 const collection = db.collection(collectionName);    ⑧  
 return {    ⑨  
 collection: collection,    ⑨  
 close: () => {    ⑨  
 return client.close();  ⑨  
 },    ⑨  
 };    ⑨  
        });
};    ⑤   

你传递给openDatabase的连接字符串决定了要连接到哪个数据库。例如,代码清单 8.2 连接到mongodb://127.0.0.1:6000。这是我们在上一节中启动的 Vagrant VM 中的空数据库。其他代码清单依赖于已经存在的数据,因此它们连接到mongodb://localhost:7000。这是来自另一个虚拟机的数据库,其中已经预先填充了示例数据。

如果你没有使用虚拟机,而是直接在你的 PC 上安装了 MongoDB,你应该将连接字符串设置为mongodb://127.0.0.1:27017,因为 27017 是访问本地 MongoDB 安装的默认端口号。

8.7.3 将大文件移动到数据库中

要使用数据库,我们必须首先将我们的数据传输到那里。在这种情况下,我们必须将我们的 CSV 文件 weather-stations.csv 移动到数据库中。为此,我们可以构建我们在第七章中学到的技术。我们将结合可读的 CSV 数据输入流和可写的 MongoDB 输出流,将数据管道输入到我们的数据库中,如图 8.5 所示。

在第七章中,我们编写了一个名为openCsvInputStream的工具包函数。我们在这里将再次使用它,但我们仍然需要一个新工具包函数来创建可写的 MongoDB 输出流。此代码在以下列表中展示。

列表 8.1 用于打开 MongoDB 输出流的工具包函数(toolkit/open-mongodb-output-stream.js)

const stream = require('stream');    ①  

function openMongodbOutputStream (dbCollection) {    ②  

 const csvOutputStream = new stream.Writable({ objectMode: true });    ③  
 csvOutputStream._write = (chunk, encoding, callback) => {    ④  
 dbCollection.insertMany(chunk)    ⑤  
 .then(() => {    ⑥  
 callback();    ⑥  
 })    ⑥  
 .catch(err => {    ⑦  
 callback(err);    ⑦  
 });    ⑦  
 };    ④  

    return csvOutputStream;
};    ②  

module.exports = openMongodbOutputStream;    ⑧   

这段代码与我们在第七章中创建的其他可写流类似。请注意,我们是以对象模式打开流,并使用 MongoDB 的insertMany函数将每个对象数组插入到数据库中。

列表 8.2 将两个流连接到数据管道中,从输入文件 weather-stations.csv 填充我们的数据库。你应该运行此代码,给它足够的时间完成,然后使用 Robomongo 检查你的数据库,以确认数据确实已复制到数据库中。

c08_05.eps

图 8.5 将流式输入 CSV 数据传输到我们的 MongoDB 数据库

列表 8.2 将大型 CSV 文件移动到 MongoDB(listing-8.2.js)

const openCsvInputStream = require('./toolkit/open-csv-input-stream');    ①  
const openMongodbOutputStream = require('./toolkit/open-mongodb-output-➥stream');    ②  
const MongoClient = require('mongodb').MongoClient;

const hostName = "mongodb://127.0.0.1:6000";
const databaseName = "weather_stations";
const collectionName = "daily_readings";

const inputFilePath = "./data/weather-stations.csv";

// ... openDatabase function is omitted ...

function streamData (inputFilePath, dbCollection) {    ③  
 return new Promise((resolve, reject) => {    ④  
 openCsvInputStream(inputFilePath)    ⑤  
 .pipe(openMongodbOutputStream(dbCollection))    ⑥  
 .on("finish", () => {    ⑦  
 resolve();    ⑦  
 })    ⑦  
 .on("error", err => {    ⑧  
 reject(err);    ⑧  
 });    ⑧  
 });    ④  
};    ③  

openDatabase()
    .then(client => {
        return streamData(inputFilePath, client.collection)
            .then(() => client.close());
    })
    .then(() => {
        console.log("Done");
    })
    .catch(err => {
        console.error("An error occurred.");
        console.error(err);
    }); 

好的,现在我们的数据已经在数据库中了!我们准备好开始研究我们现在如何高效地检索和使用我们的数据了。

8.7.4 使用数据库游标进行增量处理

在数据库中有了我们的数据后,我们有多种方法可以使用它来处理我们的大量数据!这些方法中的第一个是使用数据库游标访问数据库中的每一条记录,如图 8.6 所示。

这是一种增量数据处理的另一种形式,尽管我们不像在第七章中那样以文件的形式进行增量工作,我们现在是以数据库的形式进行增量工作。以这种方式工作时,我们不太担心耗尽 Node.js 中可用的内存——一次处理一条记录不应该这样做——尽管这也取决于你的应用程序在同一时间正在做什么其他工作。

列表 8.3 展示了如何创建数据库游标并遍历整个数据集,顺序访问每条记录。你可以运行这个脚本,但请确保你在包含数据的数据库上运行它!默认情况下,这个脚本连接到 7000 端口的数据库,这是由第二个 Vagrant 脚本创建的预填充数据库。如果你是从第一个 Vagrant 脚本中自己填充数据库,请将端口号更改为 6000;如果你在使用你自己安装的本地数据库,请将其更改为 27017。

列表 8.3 使用数据库游标逐条记录地增量遍历数据库(listing-8.3.js)

// ... openDatabase function is omitted ...

let numRecords = 0;

function readDatabase (cursor) {    ①  
 return cursor.next()    ②  
        .then(record => {
            if (record) {
 console.log(record);    ③  
                ++numRecords;

 return readDatabase(cursor);    ④  
            }
 else {    ⑤  
                // No more records.
 }    ⑤  
        });
};    ①  

openDatabase()    ⑥  
    .then(db => {
 const databaseCursor = db.collection.find();    ⑦  
 return readDatabase(databaseCursor)    ⑧  
 .then(() => db.close());    ⑨  
    })
    .then(() => {
        console.log("Displayed " + numRecords + " records.");
    })
 .catch(err => {
        console.error("An error occurred reading the database.");
        console.error(err);
    }); 

数据库游标是通过find函数创建的。然后通过反复调用游标的next函数,我们可以遍历数据库中的每条记录。

这可能看起来有点像流式数据库访问——实际上,创建一个从 MongoDB 读取的 Node.js 可读流是一个相当简单的任务——然而,我将把这个任务留给读者去练习。请随意基于第七章中的一个可读流(CSV 或 JSON)来编写你的代码,并将其与列表 8.3 结合,以创建你自己的可读 MongoDB 流。

c08_06.eps

图 8.6 数据库游标允许我们依次访问数据库中的每条记录。

8.7.5 使用数据窗口进行增量处理

逐个访问数据库中的每条记录并不是数据访问中最有效的方法,尽管至少我们可以用它来处理大量数据。然而,我们可以通过一次处理多条记录而不是单条记录来提高我们的数据吞吐量。这仍然是增量处理,但现在我们将使用数据窗口,其中每个窗口是一批记录,而不是单条记录。处理完每个数据窗口后,我们将窗口向前移动。这使我们能够按顺序查看每批记录,如图 8.7 所示。

c08_07.eps

图 8.7 将数据集划分为窗口以进行高效的增量处理

我们可以通过在调用 MongoDB 的 find 函数后链式调用 skiplimit 来读取数据窗口。skip 允许我们跳过一定数量的记录;我们使用它来选择窗口中的起始记录。limit 允许我们只检索一定数量的记录;我们可以使用它来限制窗口中的记录数量。此代码的示例在 列表 8.4 中。你可以运行此代码,它将逐窗口读取数据库记录。尽管它没有做任何有用的事情,但它有一个占位符,你可以在这里添加自己的数据处理代码。

列表 8.4 使用数据窗口处理数据库记录批量(listing-8.4.js)

// ... openDatabase function is omitted

let numRecords = 0;
let numWindows = 0;

function readWindow (collection, windowIndex, windowSize) {    ①  
 const skipAmount = windowIndex * windowSize;    ②  
 const limitAmount = windowSize;    ③  
 return collection.find()    ④  
 .skip(skipAmount)    ④  
 .limit(limitAmount)    ④  
 .toArray();    ④  
};    ①  

function readDatabase (collection, startWindowIndex, windowSize) {    ⑤  
 return readWindow(collection, startWindowIndex, windowSize)    ⑥  
        .then(data => {
            if (data.length > 0) {
 console.log("Have " + data.length + " records.");    ⑦  

                // Add your data processing code here.

                numRecords += data.length;
                ++numWindows;

 return readDatabase(  ⑧  
 collection,    ⑧  
 startWindowIndex+1,    ⑧  
 windowSize    ⑧  
 );    ⑧  
            }
 else {    ⑨  
                // No more data.
 }    ⑨  
        })

};    ⑤  
 openDatabase()    ⑩  
    .then(db => {
 const windowSize = 100;    ⑪  
 return readDatabase(db.collection, 0, windowSize)    ⑫  
            .then(() => {
 return db.close();    ⑬  
            });
    })
    .then(() => {
        console.log("Processed " + numRecords +
            " records in " + numWindows + " windows."
        );
    })
    .catch(err => {
        console.error("An error occurred reading the database.");
        console.error(err);
    }); 

列表 8.4 中的 readWindow 函数使用 MongoDB API 来检索一窗口的数据。我们应该在每个窗口中包含多少条记录呢?这完全取决于你,但你确实需要确保每个窗口都能舒适地适应可用内存,而这取决于每条数据记录的大小以及你的应用程序其他部分已经使用的内存量。

readDatabase 函数负责遍历整个数据库;它调用 readWindow 直到所有数据窗口都被访问。readDatabase 会重复调用自身,直到整个数据库都被划分为窗口并处理。这看起来像是一个正常的递归函数,但它并不以相同的方式操作。这是因为它在 readWindow promise 解决后进行递归。由于 Node.js 中 promises 的工作方式,then 回调直到事件循环的下一个 tick 才被触发。当再次调用 readDatabase 时,readDatabase 调用栈已经退出,并且调用栈不会随着每个新调用而增长。因此,我们在这里永远不会像在正常递归函数调用中那样面临耗尽栈的危险。

使用数据窗口处理数据库也可以称为 分页:将数据分割成多个页面以供显示的过程,通常用于在网站的多页面上显示。但我避免将其称为分页,因为尽管它也会使用 MongoDB 的 findskiplimit 函数,但分页是一个不同的用例。

在这里,我们同样可以创建一个可读流来批量处理所有记录,这将是一个一次访问多条记录而不是逐条记录的流。如果这对你来说听起来很有用,我将把它留作读者的练习来创建这样的流。

在窗口中处理数据使我们能够更有效地利用数据。我们可以一次处理多条记录,但这并不是主要的好处。我们现在已经具备了进行数据并行处理的基础,这个想法我们将在本章结束前再次提及。

8.7.6 创建索引

我们尚未查看数据库查询和排序。在我们能够这样做之前,我们必须为我们的数据库创建一个索引。以下部分中的示例查询和排序使用了数据库中的 Year 字段。为了使我们的查询和排序快速,我们应该为这个字段创建一个索引。

如果您正在使用第二个 Vagrant 脚本提供的预填充示例数据库,那么您已经拥有了所需的索引。如果您是从第一个 Vagrant 脚本创建的空数据库开始,或者如果您是从头开始构建自己的数据库,您可以通过打开 MongoDB shell(或 Robomongo)并输入以下命令来自己添加索引:

use weather_stations    ①  
db.daily_readings.createIndex({ Year: 1 })    ②   

当您与大型数据库一起工作时,创建索引可能需要相当长的时间,所以请耐心等待,并允许其完成。

要检查索引是否已存在或您的新索引是否已成功创建,您可以在 MongoDB shell 中执行以下命令:

use weather_stations
db.daily_readings.getIndexes() 

getIndexes 函数将为您提供已为集合创建的索引的转储。

8.7.7 使用查询进行过滤

当我们在寻找方法来减少我们的数据以便它能够适应内存时,我们有一个选项是使用过滤器。我们可以通过数据库查询过滤我们的数据,从而显著减少我们正在处理的数据量。例如,我们可能只对分析更近期的数据感兴趣,因此在这个例子中,我们要求数据库只返回 2016 年或之后的记录。结果是,所有在 2016 年之前的记录都被省略,只留下最近的记录。这一概念在图 8.8 中得到了说明。

这里的想法是我们正在积极删除我们不需要的数据,这样我们就可以与一个显著减少的数据集一起工作。在列表 8.5 中,我们正在使用 MongoDB 的 $gte(大于或等于)查询运算符在 Year 字段上过滤掉 2016 年之前的记录。您可以运行列表 8.5,查询应该会快速执行(因为 Year 字段的索引),并将 2016 年及以后的记录打印到控制台。

列表 8.5 使用数据库查询过滤数据(listing-8.5.js)

// ... openDatabase function omitted ...

openDatabase()
    .then(db => {
 const query = {    ①  
 Year: {    ②  
 $gte: 2016,    ③  
 },    ②  
 };    ①  
 return db.collection.find(query)    ④  
            .toArray()
 .then(data => {    ⑤  
 console.log(data);    ⑤  
 })    ⑤  
 .then(() => db.close());    ⑥  
    })
    .then(() => {
        console.log("Done.");
    })
    .catch(err => {
        console.error("An error occurred reading the database.");
        console.error(err);
    }); 

注意在列表 8.5 中我们如何定义查询对象并将其传递给 find 函数。这是我们在 MongoDB 中构建查询以从数据库中检索过滤记录的一个示例。MongoDB 支持灵活和复杂的查询,但您还有更多需要学习的内容,这些内容超出了本书的范围。请参阅 MongoDB 文档以了解您可以在查询中使用哪些其他类型的表达式。

您还应该注意,我们之前在处理记录增量处理和数据窗口的早期部分中使用的任何其他 find 函数——例如——我们也可以使用查询来过滤我们正在查看的数据。查询也可以与投影和排序一起使用,正如我们将在下一两个部分中看到的。

c08_08.eps

图 8.8 使用数据库查询过滤数据。

8.7.8 使用投影删除数据

另一种减少我们处理的数据量的方法是使用投影。投影允许我们从返回给查询的记录中删除字段。图 8.9 展示了一个示例,其中删除了某些字段,并且只返回我们想要检索的字段。在这个例子中,我们选择只检索年份月份降水量字段。当我们只需要某些数据字段时,这非常有用——比如说我们正在进行降雨研究,我们不需要检索整个数据集的所有字段。

正如你在下面的列表中可以看到的,我们通过find函数指定了一个投影,因此我们可以将投影附加到任何其他查询上。如果你运行这段代码,它将打印检索到的数据记录到控制台,但只包含我们在投影中选择的字段。

列表 8.6 使用投影减少检索数据(listing-8.6.js)

// ... openDatabase function is omitted ...

openDatabase()
    .then(db => {
 const query = {};    ①  
 const projection = {    ②  
 fields: {    ③  
 _id: 0,    ④  
 Year: 1,    ⑤  
 Month: 1,    ⑤  
 Precipitation: 1    ⑤  
 }    ③  
 };    ②  
 return db.collection.find(query, projection)    ⑥  
            .toArray()
 .then(data => {    ⑦  
 console.log(data);    ⑦  
            })
            .then(() => db.close());
    })
    .then(() => {
        console.log("Done.");
    })
    .catch(err => {
        console.error("An error occurred reading the database.");
        console.error(err);
    }); 

投影允许我们减小每条记录的大小,从而减小我们从查询中检索的数据集的总大小。这不仅增加了我们可以在内存中容纳的记录数量(因为每条记录都更小),而且在我们通过互联网访问数据库时,也减少了检索一组记录所需的带宽。

c08_09.eps

图 8.9 使用投影从每个数据库记录中删除数据

8.7.9 对大数据集进行排序

排序是一个有用且通常是必要的操作。例如,大多数排序算法,比如内置的 JavaScript sort函数,都需要我们将整个数据集放入内存。当我们处理不适合内存的数据集时,我们可以使用数据库来为我们进行排序(图 8.10)。

c08_10.eps

图 8.10 通常,在排序时,所有数据都必须适合内存。

在下面的列表中,我们通过年份字段来查找和排序记录。这将非常高效,因为我们已经为年份字段建立了索引。你可以运行这段代码,它将打印排序后的数据记录到控制台。

列表 8.7 使用 MongoDB 对大数据集进行排序(listing-8.7.js)

// ... openDatabase function is omitted ...

openDatabase()
    .then(db => {
 return db.collection.find()    ①  
 .sort({    ②  
 Year: 1    ②  
 })    ②  
            .toArray()
 .then(data => {    ③  
 console.log(data);    ③  
 })    ③  
            .then(() => db.close());
    })
    .then(() => {
        console.log("Done.");
    })
    .catch(err => {
        console.error("An error occurred reading the database.");
        console.error(err);
    }); 

看看sort函数是如何在find函数之后链式调用的。在这个例子中,我们没有向find函数传递任何参数,但我们同样可以指定一个查询和一个投影,在排序之前减小数据量。

还要注意在sort函数之后链式使用toArray。这会返回整个排序后的数据集,但对于大数据集来说,这可能不是我们想要的。我们可以轻松地删除toArray函数,而是使用数据库游标按记录逐个处理,就像我们之前做的那样。或者,我们可以保留toArray函数,并将其与skiplimit结合使用,从而从之前的方法中执行窗口处理。所有这些技术都围绕find函数展开,它们结合在一起帮助我们处理大量数据集。

关于排序的最后一点思考。我认为始终处理排序后的数据是一个好主意。为什么?因为当处理大量数据时,最好有一个可靠的顺序。否则,你的记录将以数据库想要的任何顺序返回,这对你来说可能不是最好的。有排序的数据使得调试更容易。它使得对数据问题的推理更容易。它还提供了一个有用的进度指示器!例如,当你看到 A、B、C 和 D 都已完成时,你就有了一个很好的想法,知道还剩下多少要处理,以及可能需要多长时间。

8.8 提高数据吞吐量

我们已经学习了如何使用数据库更有效地管理我们的大数据集。现在让我们看看我们可以使用的技巧来提高我们的数据吞吐量。

8.8.1 优化你的代码

提高性能的明显建议是:优化你的代码。大多数情况下,这超出了本书的范围,关于如何优化 JavaScript 代码的信息有很多。例如,在性能敏感的代码中不要使用forEach函数;相反,使用常规的for循环。

然而,当涉及到代码优化时,我会给你两条重要的建议,这有助于你提高生产力:

  1. 专注于瓶颈。使用像statman-stopwatch这样的库来计时你的代码,并测量运行所需的时间长度。专注于耗时最长的代码。如果你花时间优化不是瓶颈的代码,你就是在浪费时间,因为这不会对你的数据吞吐量产生任何影响。

  2. 不要专注于代码,而要专注于算法。

8.8.2 优化你的算法

仔细考虑你使用的算法。选择一个更适合任务的算法将比专注于你的代码给你带来更大的性能提升。例如,当你需要快速查找时,确保你使用的是 JavaScript 散列而不是数组。这只是一个简单且明显的例子。

然而,总的来说,算法是一个很大的领域,也是一个独立的研究主题(如果你想跟进这个话题,可以搜索大 O 表示法)。但在结束这一章之前,让我们看看一种在处理大量数据集时可以带来巨大性能收益的特定方法。

8.8.3 并行处理数据

Node.js 本质上是单线程的。这可以是一件好事,因为通常我们可以编写代码而无需担心线程安全或锁定。在性能方面,Node.js 通常通过将异步编程置于首位和中心来弥补其线程不足。但是,当你有多个核心可以用来解决问题时,只运行一个线程可能会浪费你的 CPU。

在本节中,我们将探讨如何通过使用多个操作系统能够利用多个核心并能够同时处理数据批次的单独操作系统进程来并行划分我们的数据并处理。这是“分而治之”的扩展,并建立在“使用数据窗口的增量处理”之上。

你可以在图 8.11 中看到这是如何工作的。我们有一个进程,它控制两个或更多的奴隶进程。我们将数据集划分为两个或更多的单独数据窗口。每个奴隶负责处理单个数据窗口,并且多个奴隶可以使用单独的 CPU 核心同时操作多个数据窗口。

c08_11.eps

图 8.11 使用多个操作系统进程并行处理数据

不幸的是,这种应用程序结构使我们的应用程序变得更加复杂,并且随着我们添加的奴隶进程数量的增加,复杂性也会上升。当我们向应用程序添加复杂性时,我们应该确保这是出于一个很好的原因。在这种情况下,我们这样做有两个原因:

  1. 我们可以并行处理数据,这增加了我们的整体数据吞吐量。我们可以通过运行的奴隶数量来增加我们的吞吐量。如果我们有 8 倍的奴隶(在 8 核 CPU 上),我们有望将吞吐量增加 8 倍。

  2. 一个稍微不那么明显的事实是,每个奴隶进程都在自己的内存空间中运行。这通过奴隶进程的数量增加了我们可用的内存量。如果有 8 个奴隶进程,我们就有 8 倍的内存。

为了获得更高的吞吐量和更多的内存,我们可以添加更多的奴隶。然而,这有其局限性,因为当奴隶的数量超过物理 CPU 核心的数量时,收益递减。

在实践中,我们可以通过经验调整奴隶的数量,以消耗我们 CPU 时间的适当百分比。我们可能不想将 100%的 CPU 时间都用于此,因为这可能会影响计算机上其他应用程序的性能,甚至使其过热并变得不稳定。

此外,你应该有足够的物理内存来支持奴隶的数量以及它们将消耗的内存量。耗尽你的物理内存可能会适得其反,因为当数据在工作内存和文件系统之间交换时,你的应用程序将开始颠簸

我们如何实现这个?这不像你想象的那么困难。首先,我将向你展示我是如何通过并行运行单独的 Node.js 命令来解决这个问题。然后我会解释其他人如何使用 Node.js 的fork函数来做这件事。

并行执行单独的命令

让我们通过一个简化的例子来看看如何实现并行处理。这可能会变得复杂,所以为了保持例子简单,我们不会进行任何实际的数据处理,而是并行访问我们的数据窗口。但你会看到一个占位符,你可以在这里添加你自己的数据处理代码。这可以作为一个练习,稍后添加数据处理到这个框架中。

对于这个例子,我们需要安装yargs来读取命令行参数,还需要一个名为async-await-parallel的模块,我们很快会讨论它。如果你为第八章的存储库安装了依赖项,那么这些依赖项已经安装好了;否则,你可以在一个新的 Node.js 项目中安装它们,如下所示:

npm install --save yargs async-await-parallel 

我的方法在列表 8.8 和 8.9 中展示。第一个脚本列表 8.8 是从脚本。这个脚本在一个单独的数据窗口上操作,类似于我们在“使用数据窗口进行增量处理”中看到的。数据窗口的位置和大小通过skiplimit命令行参数传递给脚本。在我们转到列表 8.9 之前,查看这个脚本,并在processData函数中注意可以插入你自己的数据处理代码的行(或者插入一个调用你之前章节中可重用数据处理代码模块的调用)。

列表 8.8 并行工作的从进程(listing-8.8.js)

// ... openDatabase function is omitted ...

function processData (collection, skipAmount, limitAmount) {    ①  
 return collection.find()    ②  
 .skip(skipAmount)    ②  
 .limit(limitAmount)    ②  
        .toArray()
 .then(data => {    ③  
            console.log(">> Your code to process " + data.length + " ➥ records here!");
 });    ③  
};    ①  

console.log("Processing records " + argv.skip + " to " + (argv.skip + ➥ argv.limit));

openDatabase()
    .then(db => {
 return processData(db.collection, argv.skip, argv.limit)    ④  
            .then(() => db.close());
    })
    .then(() => {
        console.log(
            "Done processing records " + argv.skip +
 " to " + (argv.skip + argv.limit)
        );
    })
    .catch(err => {
        console.error(
            "An error occurred processing records " + argv.skip +
            " to " + (argv.skip + argv.limit)
        );
        console.error(err);
    }); 

现在,让我们看看主脚本列表 8.9。这个脚本调用列表 8.8 中的从脚本来完成实际工作。它一次会启动两个从脚本,等待它们完成,然后启动下一组两个从脚本。它会继续以两组两个的方式运行从脚本,直到整个数据库都被处理。我设置从脚本的数量为两个以保持事情简单。当你自己运行这段代码时,你应该尝试调整maxProcesses变量以适应你可用于数据处理的核心数量。

列表 8.9 协调从进程的主进程(listing-8.9.js)

const argv = require('yargs').argv;
const spawn = require('child_process').spawn;    ①  
const parallel = require('async-await-parallel');

// ... openDatabase function is omitted ...

function runSlave (skip, limit, slaveIndex) {    ②  
 return new Promise((resolve, reject) => {    ③  
 const args =   [  ④  
 "listing-8.8.js",    ④  
 "--skip",    ④  
 skip,    ④  
 "--limit",    ④  
 limit    ④  
 ];    ④  

 const childProcess = spawn("node", args);    ⑤  

    // … input redirection omitted …

 childProcess.on("close", code => {    ⑥  
            if (code === 0) {
 resolve();    ⑦  
            }
            else {
 reject(code);    ⑧  
            }
 });    ⑤  

 childProcess.on("error", err => {    ⑨  
 reject(err);    ⑩  
 });    ⑨  
 });    ③  
};    ②  

function processBatch (batchIndex, batchSize) {    ⑪  
 const startIndex = batchIndex * batchSize;    ⑫  
 return () => {    ⑬  
 return runSlave(startIndex, batchSize, batchIndex);    ⑭  
 };    ⑬  
};    ⑪  

function processDatabase (numRecords) {    ⑮  

 const batchSize = 100;    ⑯  
 const maxProcesses = 2;    ⑰  
 const numBatches = numRecords / batchSize;    ⑱  
 const slaveProcesses = [];    ⑲  
 for (let batchIndex = 0; batchIndex < numBatches; ++batchIndex) {    ⑲  
 slaveProcesses.push(processBatch(batchIndex, batchSize));    ⑲  
 }    ⑲  

    return parallel(slaveProcesses, maxProcesses);
};    ⑮  

openDatabase()
    .then(db => {
 return db.collection.find().count()    ⑳  
 .then(numRecords => processDatabase (numRecords))    ㉑  
            .then(() => db.close());
    })
    .then(() => {
        console.log("Done processing all records.");
    })
    .catch(err => {
        console.error("An error occurred reading the database.");
        console.error(err);
    }); 

列表 8.9 首先在数据库集合上调用find().count()来确定它包含多少条记录。然后它将数据库划分为数据窗口。对于每个窗口,它调用processBatch。这个函数有一个不寻常的行为,就是创建并返回一个匿名函数,该函数封装了对runSlave的调用。我稍后会解释这一点。

runSlave是启动奴隶进程的函数。在这里,我们使用 Node.js 的spawn函数创建一个新的进程。我们正在调用 Node.js 来运行我们在列表 8.8 中看到的奴隶脚本。注意传递给奴隶的skiplimit命令行参数。这些告诉奴隶必须处理哪个数据窗口。

在为每个窗口调用processBatch之后,我们现在有一个函数列表,当执行这些函数时,将为每批数据调用runSlave。我们需要这种延迟操作来与我们从 async-await-parallel 库中使用的parallel函数一起使用。

我们将我们的函数列表和要并行执行的操作数量传递给parallelparallel为我们做艰苦的工作,并行批处理调用我们的延迟函数,直到所有函数都执行完毕。parallel返回一个当整个序列完成时解决的承诺,或者在任何单个操作失败时拒绝。

创建新的进程

我们学习了在 Node.js 中执行并行数据处理的一种方法,但我们还有在 Node.js 中构建主/从类型应用的一个更简单的方法,那就是使用fork函数。这是你在搜索互联网上这个主题时最常找到的技术。

我们以单个进程开始我们的应用程序,然后我们为所需的奴隶调用forkfork函数使我们的进程分支成两个进程,然后我们的代码在主进程或奴隶进程中运行。

如果运行单独的命令比运行单独的命令更简单,为什么不使用fork函数呢?

这里是我更喜欢自己的方法的一些原因:

  • 运行单独的命令更加明确,并且更容易确保奴隶进程并行运行。

  • 主从进程之间有清晰的分离。你要么在主脚本中,要么在奴隶脚本中。

  • 这使得奴隶进程易于测试。因为你可以从命令行运行奴隶进程,所以你可以轻松地以这种方式进行测试和调试。

  • 我认为这使代码更具可重用性。将你的应用程序分解成多个脚本意味着你有一个主进程,它可以潜在地(经过一些重构)与不同的奴隶一起使用。此外,你还有可以潜在地在其他方式和情况下使用的单独的奴隶脚本。

  • 它可以与 Node.js 脚本以外的脚本一起工作。你可能还有其他想要运行的工具,主进程可以像运行 Node.js 和你的奴隶脚本一样轻松地运行这些工具。

两种方法最终的结果几乎相同;我们能够并行处理我们的数据。使用fork是一个更简单的替代方案。运行单独的命令更困难,但并不太多,并且具有我概述的好处。选择最适合你的方法。

通过本章和上一章,我们处理了一个庞大的数据集。我们从一个大型的 CSV 文件中提取数据并将其导入我们的数据库。现在我们武装了各种构建数据管道、清理和转换数据的技术,并且现在我们的技术可以扩展到庞大的数据集。我们终于准备好进行一些数据分析了!第九章,我们来了!

摘要

  • 我们讨论了 Node.js 的内存限制如何限制在任何时候可以放入内存中的数据量。

  • 你探索了与大型数据库一起工作的各种技术,包括

  • 如何将大型 CSV 数据文件移动到 MongoDB 数据库

  • 如何将数据分成批次,其中每个批次都适合内存并且可以单独处理

  • 使用游标或数据窗口来逐步处理整个数据库

  • 使用查询和投影来减少数据

  • 使用数据库来排序数据——当数据不适合内存时,这是一个通常难以进行的操作

  • 你通过一个例子学习了如何通过生成多个操作系统进程来并行处理我们的数据,从而提高数据管道的吞吐量。

9

实践数据分析

本章涵盖

  • 使用统计工具:求和、平均值、标准差和频率分布

  • 对数据集进行分组和汇总,以便理解其含义

  • 使用工具处理时间序列数据:滚动平均、线性回归等

  • 使用数据分析技术来比较数据和进行预测

  • 使用相关性来理解数据变量之间的关系

恭喜你来到了数据分析章节。到达这里需要很多努力。我们不得不从某处获取我们的数据,并且我们需要对其进行清理和准备。然后我们发现我们拥有的数据比我们能处理的多,所以我们必须将其移动到我们的数据库中处理。这是一条漫长的道路。

数据分析是研究我们的数据以获得更好的理解、获取洞察力并回答我们问题的学科。例如,当我正在寻找一个居住地或度假地时,我可能对天气有具体的要求。在本章中,我们将研究纽约市中央公园气象站 100 年的天气数据。稍后,我们将将其与洛杉矶的天气进行比较,看看它们如何相互比较。我还对整体趋势感兴趣:天气是否变热了?哪个城市升温更快?

在本章中,我们将学习数据分析,并将在第七章和第八章中使用过的 NOAA 气象站数据上进行实践。我们将从基础知识开始,逐步过渡到更高级的技术。到结束时,我们将拥有理解、比较和预测的工具。

本章深入探讨了数学,但不要因此而气馁。我们涉及的数学是基础的,对于更高级的数学,我们将依赖第三方库来抽象处理困难的部分。我坚信,你不需要成为一个数学大师就能使用数据分析;你只需要知道每种技术适合做什么以及如何使用它。

当你了解到这些技术有多么强大时,你会想到它们的各种用途;它们甚至可以帮助你完成像理解你的服务器或应用性能指标这样的常规任务。

9.1 扩展你的工具箱

在本章中,我们将添加几种数据分析技术到我们的工具箱中,如表 9.1 所示。我们将探讨如何自己编写这些公式的代码。对于更高级的数学,我们将使用第三方库。我们还将在本章中更多地使用 Data-Forge。

表 9.1 第九章中使用的工具

技术 函数 说明
基本统计学 sum 从一组值中求和。
average 从一组值中计算平均值或中心值。
std 从一组值中计算标准差;这是衡量数据波动性、波动或分散程度的指标。
分组和汇总 groupBy, select 通过分组记录并使用总和、平均值或标准差进行汇总,使数据集更加紧凑,更容易理解。
频率分布 bucket, detectValues 确定数据集中的值分布,如果它符合正态分布,这将给我们一定的预测能力。
时间序列 rollingAverage 平滑时间序列数据,去除噪声,以便我们更好地检测趋势和模式。
rollingStandardDeviation 查看数据系列随时间的变化或波动。
linearRegression 用于预测和检测趋势。
difference 理解时间序列之间的差异,并确定它们是否在发散。
数据标准化 average, std 标准化两个数据集以进行直接比较。
相关系数 sampleCorrelation 理解数据变量之间的关系以及它们的相关性强弱。

在本章中,我们将查看各种生成图表的代码示例。由于我们尚未学习可视化,我准备了一系列工具函数,您将使用这些函数来渲染图表。您只需将数据传递给工具函数,它就会为您渲染一个图表到图像中。

随着我们学习本章,您将看到这些函数是如何使用的。在接下来的可视化章节(第十章和第十一章)中,您将学习如何从头开始创建这样的图表。

9.2 分析天气数据

在本章中,我们分析了前两章中使用的天气数据。我们对这些数据可能提出的问题有很多。已经提到的是,我们可能想要搬到一个气候宜人的地方,或者我们可能想去一个温暖的地方度假。

来自 NOAA 的完整气象站数据集非常大,未压缩时达到 27 GB。如果我们进行全球分析,我们希望处理和汇总整个数据集;然而,这是一个庞大的操作。对于本章,我们将有一个更本地化的焦点,因此从大数据集中,我提取了两个特定气象站的数据。一个是纽约市(NYC),另一个是洛杉矶(LA)。

在将庞大的数据集加载到我的 MongoDB 数据库后,我通过StationId对其进行索引。加载数据库和创建索引的过程花费了相当多的时间,但在此之后,提取特定气象站的所有数据变得非常快。我将 NYC 和 LA 的数据提取到两个单独的 CSV 文件中,这些文件可在本章的 GitHub 存储库中找到。

9.3 获取代码和数据

本章的代码和数据可在 GitHub 上的 Data Wrangling with JavaScript Chapter-9 仓库中找到,网址为 github.com/data-wrangling-with-javascript/chapter-9. 示例数据位于仓库中的 data 子目录下。

本章的大部分示例代码将图表渲染为图像文件,这些文件将在你运行每个代码列表后可在 output 子目录中找到。渲染此类图表的代码位于 toolkit 子目录中(我们将在第十章和第十一章中深入研究此代码)。有关获取代码和数据的信息,请参阅第二章中的“获取代码和数据”。

9.4 基本数据汇总

统计学和数据分析中常用三个基本函数。它们是求和、平均和标准差。这些统计工具使我们能够总结数据集,并对其进行比较。

9.4.1 求和

你几乎找不到比求和更基本的操作了:将数据集中的值加起来。求和本身很有用——比如说,当我们需要从单个值中累计总量时,但我们很快还需要用它来计算平均值。我认为这将是一个很好的方式来为更高级的函数做准备。

我们将计算 2016 年在纽约市气象站收集到的所有降雨量。我们使用 JavaScript 的 reduce 函数创建 sum 函数,在这个过程中,我们创建了一个新的可重用 statistics 代码模块,并将其添加到我们的工具箱中。这将在以下列表中展示。

列表 9.1 我们工具箱中的求和函数(toolkit/statistics.js)

function sum (values) {    ①  
 return values.reduce((prev, cur) => prev + cur, 0);    ②  
}

module.exports = {
    sum: sum,
}; 

列表 9.2 展示了我们是怎样使用新的 sum 函数来计算总降雨量的。为了保持简单,我们首先使用硬编码的数据集,但很快我们将升级到一些真实数据。尝试运行以下列表,你应该会看到它计算的总降雨量为 1072.2 毫米。

列表 9.2 计算 2016 年的总降雨量(listing-9.2.js)

const sum = require('./toolkit/statistics').sum;    ①  

const monthlyRainfall =   [  ②  
    112.1,
    112,
    // ... data omitted ...
    137.5,
    73.4
];

const totalRainfall = sum(monthlyRainfall);    ③  
console.log("Total rainfall for the year: " + totalRainfall + "mm"); 

9.4.2 平均

现在我们有了 sum 函数,我们可以用它来构建我们的 average 函数。average 函数计算一组值的 平均值算术平均值,这是计算数据集 中心值 的一种方法。当你想知道最常见值时,平均值很有用,因为我们可以在新值高于或低于正常值时检测到。让我们计算平均月降雨量。

以下列表展示了我们基于 sum 函数构建的 average 函数。这是另一个添加到我们可重用统计代码模块中的函数。

列表 9.3 我们工具箱中的平均函数(toolkit/statistics.js)

// ... sum function omitted ...

function average (values) {    ①  
 return sum(values) / values.length;    ②  
}

module.exports = {
    sum: sum,
    average: average,
}; 

以下列表展示了我们如何使用 average 函数从硬编码的数据集中计算平均值。运行此代码,你应该会看到它计算的平均值约为 89.35 毫米。

列表 9.4 计算 2016 年的平均月降雨量(listing-9.4.js)

const average = require('./toolkit/statistics.js').average;

const monthlyRainfall = [
    // ... hard-coded data omitted ...
];

const averageMonthlyRainfall = average(monthlyRainfall);    ①  
console.log("Average monthly rainfall: " + averageMonthlyRainfall + "mm"); 

9.4.3 标准差

标准差是一个更复杂的公式。这告诉我们我们的值与平均值平均偏离的程度。它量化了我们数据集中的变化或分散程度。

我们可以使用它来衡量我们数据的可变性或波动性,这使我们能够了解我们的数据值是平静有序还是波动不定、四处散布。让我们计算一下月降雨量的标准差。

在下面的列表中,我们向我们的统计代码模块添加了一个 std 函数,用于计算标准差。它基于我们之前创建的 average 函数。

列表 9.5 为我们的工具包(toolkit/statistics.js)提供一个标准差函数

// ... sum and average functions omitted ...

function std (values) {    ①  
 const avg = average(values);    ②  
 const squaredDiffsFromAvg = values    ③  
 .map(v => Math.pow(v – avg, 2))    ③  
 const avgDiff = average(squaredDiffsFromAvg);    ④  
 return Math.sqrt(avgDiff);    ⑤  
}

module.exports = {
    sum: sum,
    average: average,
    std: std,
}; 

以下列表显示了如何使用 std 函数计算 2016 年月降雨量的标准差。你可以运行此代码,它应该将标准差放在大约 40.92 毫米的位置。

列表 9.6 计算 2016 年月降雨量的标准差(listing-9.6.js)

const std = require('./toolkit/statistics.js').std;

const monthlyRainfall = [
    // ... hard-coded data omitted ...
];

const monthlyRainfallStdDeviation = std(monthlyRainfall);    ①  
console.log("Monthly rainfall standard deviation: " + ➥monthlyRainfallStdDeviation + "mm"); 

虽然标准差可以作为波动性的度量单独使用,但它也常与 分布 结合使用,以便我们可以预测未来值的概率。它还可以用于 标准化 数据,以便我们可以像比较不同的数据集一样进行比较。我们将在本章后面讨论这两种技术。

9.5 分组并总结

现在我们已经建立了基本的统计数据,我们可以继续进行更高级的数据分析技术。我们之前处理的数据一直是一个硬编码的月降雨值 JavaScript 数组。这些数据是如何准备的?

那些数据是通过按月份分组每日值,然后对每个组中的每日降雨量求和来计算月降雨量准备的。这种 分组和总结 操作经常被使用,我认为它是一种基本的数据分析技术。

当我们被数据淹没时,很难提取信息,但当我们分组和总结时,我们将其简化为更容易理解的东西。我们甚至可能多次压缩数据,当我们 深入挖掘 寻找数据集中的有趣数据点或异常时。

让我们开始使用实际的数据集而不是硬编码的数据。我们将分析来自纽约市气象站的数据集。本章附带的 CSV 文件包含回溯 100 年的记录,但我们将从 2016 年的数据开始查看。

我们可以查看 2016 年全年每日温度数据的条形图,但正如你所想象的那样,这样的图表会相当嘈杂,并且不会提供很好的数据总结。相反,让我们使用我们的分组和总结技术将数据压缩成月度总结,结果就是 图 9.1 中的图表,它显示了 Y 轴上的平均月度温度(摄氏度)。

c09_01.tif

图 9.1 2016 年纽约市月平均温度

图 9.1 使我们能够轻松地看到纽约市一年中最热和最冷的月份。如果我计划去那里旅行,而且我不喜欢冷天气,那么最好避免十二月份、一月份和二月份(实际上,我来自一个相对炎热的国度,所以我喜欢冷天气)。

图 9.2 阐述了分组和总结的过程。我们取左侧的每日天气数据。我们根据 月份 列将所有数据记录组织成组。然后,对于每个组,我们计算平均温度。这产生了我们在 图 9.2 右侧看到的压缩后的表格。

列表 9.7 包含了一个分组和总结技术的代码示例。在这里,我们将进入更高级的数据分析技术,因此我们将使用 Data-Forge 来简化操作。如果你已经为第九章代码仓库安装了依赖项,那么它已经安装好了;否则,你可以在一个新的 Node.js 项目中按照以下步骤安装它:

npm install --save data-forge 

在 列表 9.7 中,我们首先读取了 100 年纽约市天气的整个数据集。在这个例子中,我们只对 2016 年感兴趣,因此我们使用 where 函数筛选出 2016 年的记录。

c09_02.eps

图 9.2 通过按月份分组并总结数据来压缩每日数据

然后,我们使用 groupBy 函数将 2016 年的记录排序到每月的组中。之后,select 函数将每个组(计算最小值、最大值和平均值)进行转换,我们已重新编写了数据集。我们从嘈杂的每日数据中提取出来,并将其压缩成每月的总结。运行此代码,它将打印出类似于 图 9.2 右侧的控制台输出,并渲染一个类似于 图 9.1 的条形图到输出/nyc-monthly-weather.png。

列表 9.7 按月份分组和总结每日天气数据(listing-9.7.js)

const dataForge = require('data-forge');
const renderMonthlyBarChart = require('./toolkit/charts.js').renderMonthlyBarChart;    ①  
const average = require('./toolkit/statistics.js').average;    ②  

const dataFrame = dataForge
 .readFileSync("./data/nyc-weather.csv")    ③  
    .parseCSV()
 .parseInts("Year")    ④  
 .where(row => row.Year === 2016)    ⑤  
 .parseFloats(["MinTemp", "MaxTemp"])    ⑥  
 .generateSeries({    ⑦  
 AvgTemp: row => (row.MinTemp + row.MaxTemp) / 2,    ⑦  
 })    ⑦  
 .parseInts("Month")    ⑧  
 .groupBy(row => row.Month)    ⑨  
 .select(group => {    ⑩  
        return {
 Month: group.first().Month,
 MinTemp: group.deflate(row => row.MinTemp).min(),    ⑪  
 MaxTemp: group.deflate(row => row.MaxTemp).max(),    ⑫  
 AvgTemp: average(group    ⑬  
 .deflate(row => row.AvgTemp)    ⑬  
 .toArray()    ⑬  
 )    ⑬  
        };
    })
 .inflate();    ⑭  

console.log(dataFrame.toString());    ⑮  

renderMonthlyBarChart(    ⑯  
 dataFrame,    ⑯  
 "AvgTemp",    ⑯  
 "./output/nyc-monthly-weather.png"    ⑯  
 )    ⑯  
 .catch(err => {    ⑰  
        console.error(err);
    }); 

注意 列表 9.7 结尾处的 renderMonthlyBarChart 调用。这是一个我为你准备的工具函数,这样我们就可以专注于数据分析,而不必担心可视化的细节。我们将在第十章和第十一章中回到可视化,并了解如何创建这样的图表。

在 列表 9.7 中,我们只总结了温度。我们通过取平均值来完成这项工作。我们可以在我们的总结中添加其他指标。例如,我们可以轻松修改 列表 9.7 中的代码,以包括每月的总降雨量和总降雪量。更新后的代码在下面的列表中展示。

列表 9.8 向总结每月降雨量和降雪量的代码中添加代码(从 列表 9.7 升级)

// ... Remainder of code omitted, it is as per listing 9.7 ...

    .select(group => {
        return {
            Month: group.first().Month,
            MinTemp: group.deflate(row => row.MinTemp).min(),
            MaxTemp: group.deflate(row => row.MaxTemp).max(),
            AvgTemp: average(group.deflate(row => row.AvgTemp).toArray()),
 TotalRain: sum(group.deflate(row => row.Precipitation).toArray()),    ①  
 TotalSnow: sum(group    ①  
 .deflate(row => row.Snowfall)    ①  
 .toArray()    ①  
 )    ①  
        };
    }) 

在我们总结新值的同时,我们也可以将它们添加到我们的条形图中。图 9.3 展示了一个更新后的图表,其中添加了降雨量和降雪量,温度位于左侧坐标轴(摄氏度)上,而降雪量/降雨量位于右侧坐标轴(毫米)上。

c09_03.tif

图 9.3 2016 年纽约市包括降雨和降雪的天气图表

在图 9.3 中的图表上稍加研究,就能注意到 1 月份降雪量出现了巨大的峰值。这里发生了什么?是整个月都在下雪吗?还是只有少数几天下雪。这是我们数据中找到有趣的数据点或异常的例子。我们不禁对这里发生的事情感到好奇。这甚至可能是我们数据中的一个错误!

现在,你可以深入查看一月份的每日图表。为此,你需要筛选出 2016 年 1 月的记录,并绘制每日降雪柱状图——你可以通过简单修改列表 9.7 或 9.8 来实现这一点。如果你这样做,你会发现降雪峰值出现在 1 月 23 日。在网络上搜索这个日期在纽约市的情况,你会发现那天发生了一场巨大的暴风雪。谜团解开。如果你在 1 月份去纽约度假,你可能会发现自己被困在暴风雪中!(这在我大约 20 年前在纽约发生过的。)

了解这样一个事件发生的频率可能很有趣。纽约市暴风雪发生的频率是多少?为此,我们需要对数据集进行更广泛的分析,但有一百年的数据可用,所以你尝试找到其他暴风雪怎么样?你将如何进行这项工作?

首先,你可能想按年份总结降雪量并生成图表。寻找降雪量峰值出现的年份。其次,深入到那些年份,找到峰值出现的月份。最后,深入到那些年份和月份的每日图表,找到峰值出现的日期。

这是我们所做的一般总结:

  1. 筛选出你感兴趣的数据记录。

  2. 按指标分组。

  3. 总结该组的数据。

  4. 寻找异常,然后深入到一个有趣的小组。然后在更细粒度的层面上重复这个过程。

这种查看数据摘要然后深入到更细粒度的方法是一种快速定位感兴趣数据和事件的有效技术。在这种方法中,我们从一个数据的全局视角开始,逐步聚焦于突出的数据点。

我们现在有了帮助我们理解气象数据的工具,但我们还没有任何技术可以帮助我们预测新气象数据中未来值的可能性,所以现在我们将探讨如何做到这一点。

9.6 温度的频率分布

现在我们来看看纽约市的温度分布。很快你就会看到,这可能让我们能够预测新温度值的概率。

图 9.4 显示了过去 100 年纽约市温度频率分布的直方图。这样的图表将数值排列成一系列,每个桶中的数值量用垂直条表示。每个条形图总结了温度值的一个集合,每个桶的中点位于 X 轴上,以摄氏度表示。每个条形图的高度,即 Y 轴,表示落在桶范围内的值(来自总数据集)的百分比。

c09_04.eps

图 9.4 纽约市过去 100 年的温度分布

观察到图 9.4,我们可以迅速了解纽约市所经历的温度范围。例如,我们可以看到大多数记录值所在的温度范围,以及记录值中最大的群体占所有值的 11%。

这里的 11%值并不重要——这是最高的条形,它是一种让我们看到值最密集聚集的温度范围的方法。这样的直方图只能在首先生成如图 9.5 所示的频率分布表之后绘制。这个特定表格中的每一行都对应于图 9.4 中的直方图中的一个条形。

c09_05.png

图 9.5 纽约市温度频率分布表(用于绘制图 9.4 中的直方图))

在我有限的工作经验中,处理气象数据时,我预计温度分布可能呈正态分布(我稍后会解释),但实际数据并未符合我的假设(在处理数据时这种情况很常见)。

虽然我确实注意到图 9.4 看起来像是两个正态分布紧挨在一起。经过一些调查后,我决定将冬季和夏季数据分开。在指定了冬季和夏季月份后,我根据这一标准分割了数据。

接下来,我为每个季节创建了单独的直方图。当我查看新的可视化时,很明显,每个季节的温度分布都与正态分布紧密吻合。例如,您可以在图 9.6 中看到冬季温度的直方图。

c09_06.eps

图 9.6 纽约市的冬季温度与正态分布紧密吻合。

到目前为止,尤其是如果你已经忘记了高中统计学,你可能想知道什么是正态分布。它是统计学中的一个重要概念,非正式地被称为钟形曲线。这让你想起什么了吗?落在正态或接近正态分布的数据集具有允许我们估计新数据值概率的特性。

这与温度值有什么关系?这意味着我们可以快速确定特定温度发生的概率。一旦我们知道数据集属于正态分布,我们现在可以对数据集做出某些陈述,例如

  • 68%的值落在平均值加减 1 个标准差(SDs)的范围内。

  • 95%的值落在平均值加减 2 个标准差(SDs)的范围内。

  • 99.7%的值落在平均值加减 3 个标准差(SDs)的范围内。

  • 反过来,只有 0.3%的值将落在平均值加减 3 个标准差(SDs)之外。我们可以将这些值视为极端值。

我们如何知道这些概率?这是因为这些是正态分布的已知特性(如图 9.7 所示)。现在我们并不拥有一个完美的正态分布,但它足够接近,我们可以利用这些特性来理解我们最常见的值,并对未来可能看到的值进行预测。

例如,如果我们纽约市在冬天有一个极端的高温天气,比如说 18 摄氏度,那么我们可以从统计学上知道这是一个极端的温度。我们知道这是极端的,因为它比平均温度高出三个标准差(SDs),所以这样的温度一天发生的可能性很小。这并不意味着这样的日子永远不会发生,但根据我们从过去 100 年中分析的数据,它发生的概率很低。

正态分布及其特性有趣之处在于,大量统计学和科学都依赖于它。

假设我们进行一项实验,进行观察并记录数据。我们还需要一个对照组来与之比较,并了解实验结果是否具有显著性。我们设置了一个不受实验影响的单独对照组,然后再次进行观察和记录数据。

我们可以查看对照组的数据分布,看看它与实验结果有何关联。实验结果与对照组平均值的差距越大,我们对实验结果具有统计学意义的信心就越强。当实验结果比对照组结果超出两个标准差(SDs)时,我们越来越有信心认为实验是导致结果的原因,而不是偶然或巧合。这种统计检验依赖于我们的数据呈正态分布。如果您在家中尝试此方法,请首先验证您的数据是否近似正态分布。

c09_07.eps

图 9.7 举例说明值与正态分布的关系

列表 9.9 展示了创建用于绘制图 9.4 中显示的直方图的频率分布的代码。首先,我们使用 Data-Forge 的bucket函数将我们的温度值组织成直方图所需的桶。然后detectValues函数总结了桶化值的频率。输出是我们的频率表。我们需要调用orderBy来按值对频率表进行排序,以便它以直方图正确的顺序排列。

列表 9.9 计算纽约市温度的频率分布和直方图(listing-9.9.js)

const dataForge = require('data-forge');
const renderBarChart = require('./toolkit/charts.js').renderBarChart;

function createDistribution (series, chartFileName) {    ①  
 const bucketed = series.bucket(20);    ②  
    const frequencyTable = bucketed
 .deflate(r => r.Mid)    ③  
 .detectValues()    ④  
 .orderBy(row => row.Value);    ⑤  
 console.log(frequencyTable.toString());    ⑥  

    const categories = frequencyTable
 .deflate(r => r.Value.toFixed(2))    ⑦  
        .toArray();

 return renderBarChart(    ⑧  
 "Frequency",    ⑧  
 frequencyTable,    ⑧  
 categories,    ⑧  
 chartFileName    ⑧  
 );    ⑧  
};

function isWinter (monthNo) {    ⑨  
 return monthNo === 1 ||    ⑨  
 monthNo === 2 ||    ⑨  
 monthNo === 12;    ⑨  
};    ⑨  

const dataFrame = dataForge.readFileSync("./data/nyc-weather.csv")    ⑩  
 .parseCSV()    ⑩  
 .parseInts("Month")    ⑩  
 .parseFloats(["MinTemp", "MaxTemp"])    ⑩  
 .generateSeries({    ⑩  
 AvgTemp: row => (row.MinTemp + row.MaxTemp) / 2    ⑩  
 });    ⑩  

console.log("Winter temperature distribution:");
const winterTemperatures = dataFrame    ⑪  
 .where(row => isWinter(row.Month))    ⑪  
 .getSeries("AvgTemp");    ⑪  

const outputChartFile = "./output/nyc-winter-temperature-distribution.png";
createDistribution(winterTemperatures, outputChartFile)    ⑫  
    .catch(err => {
        console.error(err);
    }); 

注意在列表 9.9 中,我们如何读取纽约市 100 年的整个数据集,但我们随后过滤数据,只留下在冬季月份发生的温度。

现在我们有了描述我们的数据集、比较数据集和理解哪些值是正常值以及哪些是极端值所需的工具。让我们将注意力转向分析时间序列数据的技术。

9.7 时间序列

时间序列是一系列按日期和/或时间顺序排列或索引的数据点。我们关于纽约市天气的数据集是一个时间序列,因为它由按日期排序的每日天气读数组成。

我们可以使用本节中的技术来检测随时间发生的变化的趋势和模式,以及比较时间序列数据集。

9.7.1 年度平均温度

图 9.8 是过去 100 年纽约市年度平均温度的图表。为了生成此图表,我使用了分组和总结技术来创建一个按年度平均温度的时间序列。然后我创建了线形图作为时间序列数据的视觉表示。

c09_08.tif

图 9.8 过去 100 年纽约市的平均年度温度

列表 9.10 展示了按年份分组数据并生成年度平均温度的代码。它调用了我为你们准备的renderLineChart工具函数。在第十章和第十一章中,我们将更详细地探讨此类图表的创建过程。你可以运行此代码,它将生成图 9.8 中所示的图表。

列表 9.10 按年份分组并总结纽约市的温度数据(listing-9.10.js)

const dataForge = require('data-forge');
const renderLineChart = require('./toolkit/charts.js').renderLineChart;
const average = require('./toolkit/statistics.js').average;

function summarizeByYear (dataFrame) {    ①  
 return dataFrame    ①  
 .parseInts(["Year"])    ①  
 .parseFloats(["MinTemp", "MaxTemp"])    ①  
 .generateSeries({    ①  
 AvgTemp: row => (row.MinTemp + row.MaxTemp) / 2,    ①  
 })    ①  
 .groupBy(row => row.Year) // Group by year and summarize.    ①  
 .select(group => {    ①  
 return {    ①  
 Year: group.first().Year,    ①  
 AvgTemp: average(group.select(row => row.AvgTemp).toArray())    ①  
 };    ①  
 })    ①  
 .inflate();    ①  
};    ①  

let dataFrame = dataForge.readFileSync("./data/nyc-weather.csv")
    .parseCSV();

dataFrame = summarizeByYear(dataFrame);    ①  

const outputChartFile = "./output/nyc-yearly-trend.png";
renderLineChart(dataFrame, ["Year"], ["AvgTemp"], outputChartFile)    ②  
    .catch(err => {
        console.error(err);
    }); 

我们可能已经从每日数据中创建了一个图表,但那将会有很多噪声,因为每天的波动很大。噪声数据使得观察趋势和模式更加困难——这就是为什么我们在制作图表之前按年份分组的原因。

按年度总结我们的数据使得观察温度上升的趋势变得容易得多。然而,数据仍然存在噪声。你注意到图表中大幅度的上下波动了吗?这种变异性可能会使得我们难以确定我们认为是的趋势或模式。我们认为我们可以在图 9.8 中看到上升趋势,但我们如何才能确定呢?

9.7.2 滚动平均

如果我们想要更清楚地看到趋势,我们需要一种消除噪声的方法。一种方法是从年温度时间序列中生成一个滚动平均(也称为移动平均)。我们可以像图 9.9 中那样绘制这个新的时间序列。

c09_09.tif

图 9.9 过去 100 年纽约市 20 年滚动平均温度

注意图 9.9 是如何像图 9.8 的图表的平滑版本。这种平滑消除了大部分噪声,并允许我们更清楚地看到上升趋势。

为了计算滚动平均,我们使用 Data-Forge 的rollingWindow函数。我们第一次遇到这个函数是在第五章,当时我说我会稍后解释。好吧,现在是时候更好地解释它了,让我们了解它是如何工作的。

rollingWindow函数将一个数据窗口逐个值地移动到时间序列上。每个窗口是一组值,我们可以在其上执行统计操作。在这种情况下,我们使用平均,但我们同样可以使用我们的求和或标准差函数。对每个窗口执行的操作的输出被捕获,在这个过程中,我们计算出一个新的时间序列。

图 9.10 阐述了在一系列值上计算滚动平均的过程。为了便于说明,这是一个小值集,窗口大小设置为四。数据窗口从时间序列的开始处开始,第一组四个值被平均,产生出数字 9.025(A)。

然后将数据窗口向前移动一个值,并在下一个四个值上重复操作,产生出数字 8.875(B)。

此过程一直持续到数据窗口达到时间序列的末尾,此时它从最后四个值(C)中产生出数字 3.225。现在我们有一个新的时间序列,它是随时间平均出来的,并生成了一条类似于图 9.9 的平滑图表。

c09_10.eps

图 9.10 从时间序列生成滚动平均的过程

在以下列表中,我们为我们的工具包创建了一个名为time-series.js的新代码模块。我们以计算时间序列的滚动平均的rollingAverage函数开始。平均的周期或数据窗口的长度作为参数传入。

列表 9.11 带有rollingAverage函数的新工具包模块(toolkit/time-series.js)

const average = require('./statistics.js').average;

function rollingAverage (series, period) {    ①  
    return series.rollingWindow(period)
        .select(window => {
            return 
 window.getIndex().last(),  [  ②  
 average(window.toArray())    ③  
            ];
        })
        .withIndex(pair => pair[0])
        .select(pair => pair[1]);
};

module.exports = {
    computeRollingAverage: computeRollingAverage,
}; 

注意在列表 9.11 中我们如何重用我们之前创建的average函数。

以下列表展示了我们如何使用新的rollingAverage函数,通过 20 年的周期来计算纽约市的滚动平均温度。在列表 9.12 的末尾,我们绘制了一条线形图。你可以运行这段代码,它将生成图 9.9 中所示的图表。

列表 9.12 计算纽约市温度的 20 年滚动平均值(listing-9.12.js)

const dataForge = require('data-forge');
const renderLineChart = require('./toolkit/charts.js').renderLineChart;
const average = require('./toolkit/statistics.js').average;
const rollingAverage = require('./toolkit/time-series.js').rollingAverage;

// ... summarizeByYear function omitted ...

let dataFrame = dataForge.readFileSync("./data/nyc-weather.csv")
    .parseCSV();

dataFrame = summarizeByYear(dataFrame)
 .setIndex("Year")    ①  
 .withSeries("TempMovingAvg", dataFrame => {    ②  
 const temperatureSeries = dataFrame.getSeries("AvgTemp");    ③  
 return rollingAverage(temperatureSeries, 20)    ④  
    });

const outputChartFile = "./output/nyc-yearly-rolling-average.png";
renderLineChart(    ⑤  
 dataFrame,    ⑤  
 ["Year"],    ⑤  
 ["TempMovingAvg"],    ⑤  
 outputChartFile    ⑤  
 ) //    ⑤      .catch(err => {
        console.error(err);
    }); 

9.7.3 滚动标准差

我们还可以使用 Data-Forge 的rollingWindow函数来创建滚动标准差。

假设我们计算了纽约市温度的滚动平均值上的滚动标准差,并将其作为折线图绘制,我们最终会得到类似于图 9.11 所示的图表。

这使我们能够看到温度随时间的变化情况。我们使用标准差作为一种可视化随时间变化波动性或波动性的方法。从图表中我们可以看出,在 20 世纪 60 年代,温度波动下降并趋于稳定。自 20 世纪 70 年代初以来,温度波动性一直在上升,这可能会表明在未来我们可以预期温度将出现更极端的波动。

c09_11.tif

图 9.11 纽约市温度的 20 年滚动标准差

如果您在时间序列代码模块中添加了一个rollingStandardDeviation函数,它将类似于我们在上一节中创建的rollingAverage函数,但使用std函数而不是average函数来计算。如果您想绘制类似于图 9.11 的图表,我将把它留作读者的练习来创建这个函数。

9.7.4 线性回归

使用滚动平均并不是我们突出时间序列趋势的唯一选项。我们还可以使用线性回归。此外,使用线性回归,我们可以预测和预测未来的数据点。

我们在第五章中首次看到了线性回归的例子,当时我们使用 Excel 的FORECAST函数预测一个数据点进入未来。在底层,这是使用线性回归,一种将一条线拟合到我们的数据集的建模技术。然后我们可以使用那条线的方程来预测未来的趋势。

图 9.12 显示了过去 100 年纽约市的年温度。我们计算并叠加了此图表的线性回归(橙色线)。这使得上升趋势变得明确。我们预测了到 2100 年的温度,以便我们可以预测未来可能会上升多少。

创建线性回归涉及复杂的数学,它确定如何将一条线最好地拟合到我们的数据点。这是本书中最难的数学。让我们避免它,并使用第三方库来为我们做繁重的工作。如果您为第九章代码库安装了依赖项,您已经安装了 simple-statistics。如果没有,您可以在新的 Node.js 项目中安装它,如下所示:

`npm install --save simple-statistics` 

在列表 9.13 中,我们将一个linearRegression函数添加到我们的time-series.js代码模块中。这是基于我们之前在列表 9.12 中创建的rollingAverage函数,但不是计算数据窗口的平均值,而是使用 simple-statistics 库计算线性回归。

列表 9.13 将线性回归函数添加到我们的时间序列工具包中(toolkit/time-series.js)

const statistics = require('./statistics.js');
const average = statistics.average;
const std = statistics.std;
const simpleStatistics = require('simple-statistics');
const dataForge = require('data-forge');

// ... rollingAverage function omitted ...

function linearRegression (series, period,
 forecastIndexA, forecastIndexB) {    ①  
 const regressionInput = series.toPairs();    ②  
    const regression =
 simpleStatistics.linearRegression(regressionInput);    ③  
    const forecaster =
 simpleStatistics.linearRegressionLine(regression);    ④  

    return new dataForge.Series({
        values: [forecaster(forecastIndexA), forecaster(forecastIndexB)],
        index: [forecastIndexA, forecastIndexB],
    });
};

module.exports = {
    rollingAverage: rollingAverage,
    linearRegression: linearRegression,
}; 

c09_12.eps

图 9.12 使用线性回归预测 2100 年纽约市平均温度

以下列表展示了我们如何使用新的 linearRegression 函数从纽约市温度时间序列中计算线性回归。你应该运行此列表以查看它是否生成了 图 9.12 中的图表。

列表 9.14 计算线性回归以预测 2100 年的温度(listing-9.14.js)

const dataForge = require('data-forge');
const renderLineChart = require('./toolkit/charts.js').renderLineChart;
const linearRegression = require('./toolkit/time-series.js').linearRegression;

// ... summarizeByYear ommitted ...

let dataFrame = dataForge.readFileSync("./data/nyc-weather.csv")
    .parseCSV();

dataFrame = summarizeByYear(dataFrame)
 .concat(new dataForge.DataFrame(  [  ①  
        {
            Year: 2100
        }
    ]))
 .setIndex("Year");    ②  

const forecastSeries = linearRegression(    ③  
 dataFrame.getSeries("AvgTemp"),    ③  
 1917,    ③  
 2100    ③  
);    ③  
dataFrame = dataFrame    ④  
    .withSeries({
 ForecastYear: new dataForge.Series({    ⑤  
 values: [1917, 2100],
 index: [1917, 2100],    ⑥  
        }),
        Forecast: forecastSeries,
    });

const outputChartFile = "./output/nyc-yearly-trend-with-forecast.png";
renderLineChart(dataFrame, ["Year", "ForecastYear"], ["AvgTemp", "Forecast"], outputChartFile)
    .catch(err => {
        console.error(err);
    }); 

9.7.5 比较时间序列

我们如何比较一个时间序列与另一个时间序列?比如说,我们想比较纽约市和洛杉矶的温度。我们可以使用平均值和标准差来描述每个数据集,但在处理时间序列数据时,更具有信息量的做法是可视化和在图表中进行比较。

我们可以在图表中绘制两个时间序列,如图 图 9.13 所示,但由于时间序列之间有较大的垂直差距,这使得比较变得困难。如果能将它们并排比较会更好,尽管要做到这一点,我们必须找到一种方法来叠加时间序列,以便它们可以直接比较。

c09_13.eps

图 9.13 在同一图表中比较纽约市和洛杉矶的温度

测量差值

比较两个时间序列的一种方法是通过它们之间的差值。然后我们可以绘制差值图,如图 图 9.14 所示。这个数据序列波动很大,因此我们可能需要拟合一个线性回归(橙色线)以便更容易地看到趋势。这看起来是洛杉矶和纽约市温度差的一个轻微上升趋势。这意味着什么?这意味着洛杉矶的气温上升速度略快于纽约市。

c09_14.tif

图 9.14 测量纽约市和洛杉矶温度之间的差值

列表 9.15 展示了如何将 difference 函数添加到我们的时间序列代码模块中,以计算两个时间序列之间的差值。这使用了 Data-Forge 的 zip 函数将我们的两个时间序列组合在一起。zip 函数使用我们提供的函数生成一个新的序列。该函数计算序列中每个值的差值。

列表 9.15 将差分函数添加到我们的时间序列工具包中(toolkit/time-series.js)

const statistics = require('./statistics.js');
const average = statistics.average;
const std = statistics.std;
const simpleStatistics = require('simple-statistics');
const dataForge = require('data-forge');

// ... rollingAverage and linearRegression functions omitted ...

function difference (seriesA, seriesB) {    ①  
 return seriesA.zip(seriesB, (valueA, valueB) => valueA - valueB);    ①  
};    ①  

module.exports = {
    rollingAverage: rollingAverage,
    linearRegression: linearRegression,
    difference: difference,
}; 

为了使用我们新的 difference 函数,我们必须加载两个数据集。计算图 9.14 中显示的图表的代码与 9.12 和 9.14 中的列表类似,但我们不仅加载了纽约市的天气数据;我们还加载了洛杉矶的数据。当我们加载了这两个时间序列后,我们可以使用我们的 difference 函数来计算它们之间的差异。正如你在图 9.14 中可以看到的,我还使用了我们的 linearRegression 函数来生成差异的线性回归。我将把这个留给你作为一个练习,让你编写生成图 9.14 的代码。

标准化数据点以进行比较

假设我们确实想在图表中绘制纽约市和洛杉矶的温度,并且直接进行比较,我们必须标准化我们的数据。

当我说标准化数据时,我的意思是我们将两个时间序列都转换到同一个尺度,以便它们可以直接比较。我们这样做的原因是,对于温度数据(技术上已经处于相同的尺度),我们并不关心实际的温度。相反,我们想要比较年与年之间的波动。用统计学的术语来说,我们可以说我们正在将我们的数据转换为标准分数,也称为z 值z 分数

在图 9.15 中,你可以看到纽约市和洛杉矶温度经过标准化后的比较。我应该补充一点,这种标准化不仅适用于时间序列数据,实际上它适用于我们可能希望比较的任何类型的数据。

我们如何标准化我们的数据?很简单。我们必须将每个数据点转换为平均值的标准差数。我们首先计算平均值和标准差(我们经常回到这些基本的统计工具上!)。然后我们的代码遍历每个数据点,并从其值中减去平均值。以下列表展示了这一过程。如果你运行此代码,它将生成图 9.15 中的图表。

列表 9.16 标准化纽约市和洛杉矶温度数据以便于比较(listing-9.16.js)

const dataForge = require('data-forge');
const renderLineChart = require('./toolkit/charts.js').renderLineChart;
const statistics = require('./toolkit/statistics.js');
const average = statistics.average;
const std = statistics.std;

// ... summarizeByYear function omitted ...

function standardize (dataFrame, seriesName) {    ①  
    const series = dataFrame.getSeries(seriesName);
    const values = series.toArray();
    const avg = average(values);
    const standardDeviation = std(values);
    const standardizedSeries = series
 .select(value => (value - avg) / standardDeviation);    ②  
    return dataFrame.withSeries(seriesName, standardizedSeries);
};

let nycWeather = dataForge.readFileSync("./data/nyc-weather.csv").parseCSV();
let laWeather = dataForge.readFileSync("./data/la-weather.csv").parseCSV();

nycWeather = summarizeByYear(nycWeather)
            .setIndex("Year");
laWeather = summarizeByYear(laWeather)
            .setIndex("Year");

nycWeather = standardize(nycWeather, "AvgTemp");    ③  
laWeather = standardize(laWeather, "AvgTemp");    ④  

const combinedWeather = laWeather
    .renameSeries({
        AvgTemp: "TempLA",
    })
    .withSeries({
        TempNYC: nycWeather
            .setIndex("Year")
            .getSeries("AvgTemp")
    });

const outputChartFile = "output/standardised-yearly-comparision.png";
renderLineChart(
           combinedWeather,
        ["Year", "Year"],
        ["TempLA", "TempNYC"],
        outputChartFile
    )
    .catch(err => {
        console.error(err);
    }); 

c09_15.tif

图 9.15 比较标准化的纽约市和洛杉矶温度

9.7.6 时间序列操作的堆叠

你可能已经注意到了这一点,但我还想明确指出。我们迄今为止创建的时间序列操作(滚动平均值、滚动标准差、线性回归和差异)都可以像正常的数学运算一样堆叠起来。

你已经在 9.7.5 节中看到了我们如何计算纽约市和洛杉矶温度的差异,并在其上堆叠线性回归。我们可以以几乎任何我们想要的顺序或至少任何有意义的顺序应用这些操作。

例如,我们可能从纽约市的温度中生成滚动平均值,然后在上面叠加线性回归,或者我们可能创建滚动标准差,然后在上面叠加移动平均。我们可以根据我们试图从数据中提取的理解来混合和匹配这些操作。

9.8 理解关系

假设我们有两个数据变量,并且我们怀疑它们之间存在某种关系。我们可以使用散点图来帮助我们识别这种关系。观察散点图,你可能注意到当一个变量上升时,另一个变量也上升,反之亦然。在统计学中,这被称为相关性

坚持天气主题,假设我们想看看降雨量和雨伞销量之间是否存在关系。现在,正如你可能想象的那样,找到雨伞销量的数据很困难,所以我使用自定义 JavaScript 代码合成了数据,以便我可以向你展示相关数据看起来是什么样子。如果你的业务是在纽约中央公园卖雨伞,那么你可能想使用这种技术来确定降雨量如何影响你的销售!

9.8.1 使用散点图检测相关性

图 9.16 是雨伞销量与降雨量的散点图。Y 轴显示售出的雨伞数量。X 轴显示降雨量(以毫米为单位)。你可以看到数据点从左下角到右上角呈现出一个明显的带状分布。这些点分布并不特别均匀,但你很容易看出它们大致排列成一条对角向上和向右的线。从这个角度来看,我们可以推断出降雨量和我们将在任何给定的一天售出的雨伞数量之间存在一种正相关或相关性

c09_16.png

图 9.16 降雨量与雨伞销量散点图

9.8.2 相关性的类型

图 9.16 显示了降雨量和雨伞销量之间存在正相关性。正相关意味着当一个变量增加时,另一个变量也会增加。我们也可能看到负相关或无相关,如图 9.17 所示。

c09_17.eps

图 9.17 比较正相关、负相关和无相关

当我们以这种方式看到两个变量之间的关系时,我们可以用它来预测未来的值。我们会通过计算两个数据序列作为输入的线性回归来做这件事。这使我们能够根据另一个值来预测一个值。

这样的预测受相关性强度的限制。如果你的数据点靠近线性回归线,那么相关性就很高,你的预测能力就会很好。当数据点分布得更远时,这会降低线性回归的预测能力。

9.8.3 确定相关性的强度

我们不必依赖我们的视觉判断来确定两个变量之间相关性的强度。我们可以使用相关系数来量化相关性的数量和类型,这是一个相关性的数值度量。相关系数的值范围从-1 到+1,其中-1 表示完全负相关,+1 表示完全正相关。这形成了图 9.18 中显示的光谱。负相关位于左侧,正相关位于右侧,无相关位于中间。

雨量与雨伞销售的相关系数结果是大约 0.64。图 9.18 显示这个值符合强烈正相关类别下的光谱。

c09_18.eps

图 9.18 雨量与雨伞销售的相关系数在可能值的光谱上

在这种情况下,显然更多的降雨会导致更多的人购买雨伞。我们想说这是一种因果关系,但我们不能确定这一点!这应该让我们想到流行的说法“相关性不等于因果关系”。

这意味着什么?当我们看到两个数据变量之间有强烈的相关性时,我们会倾向于认为一个数据变量导致另一个,但相关性并不这样工作。在这个例子中,变量之间似乎有明显的因果联系(嗯,至少我是合成了数据,让它看起来是这样)。尽管在其他情况下可能不会这么明显,你不应该假设一个变量导致另一个变量,但完全有可能另一个尚未发现的变量因果变量,并负责两个被检验变量之间的关系。例如,可能是预报的降雨新闻推动了雨伞销售,然后雨才来!我敢打赌你没想到这一点。

9.8.4 计算相关系数

你有多种方法可以计算相关系数,并且每种情况下数学都相当复杂。幸运的是,我们已经有了一个简单的统计代码模块,它有一个方便的sampleCorrelation函数供我们使用。以下代码清单显示了如何使用此函数来计算自 2013 年以来雨量和雨伞销售的相关系数。

代码清单 9.17 自 2013 年以来计算雨量和雨伞销售的相关系数(listing-9.17.js)

const dataForge = require('data-forge');
const simpleStatistics = require('simple-statistics');

let dataFrame = dataForge.readFileSync("./data/nyc-weather.csv")    ①  
 .parseCSV()    ①  
 .parseInts(["Year", "Month", "Day"])    ①  
 .where(row => row.Year >= 2013)    ②  
 .parseFloats("Precipitation")    ①  
 .generateSeries({    ①  
 Date: row => new Date(row.Year, row.Month-1, row.Day),    ③  
 })    ①  
 .setIndex("Date");    ④  

const umbrellaSalesData = dataForge
 .readFileSync("./data/nyc-umbrella-sales.csv")    ⑤  
 .parseCSV()    ⑤  
 .parseDates("Date", "DD/MM/YYYY") //    ⑤  
 .parseFloats("Sales")    ⑤  
 .setIndex("Date");    ⑥  

dataFrame = dataFrame
 .withSeries(    ⑦  
 "UmbrellaSales",    ⑦  
 umbrellaSalesData.getSeries("Sales")    ⑦  
 )    ⑦  
 .where(row => row.Precipitation !== undefined    ⑧  
 && row.UmbrellaSales !== undefined);    ⑧  

const x = dataFrame.getSeries("Precipitation").toArray();    ⑨  
const y = dataFrame.getSeries("UmbrellaSales").toArray();    ⑩  
const correlationCoefficient = simpleStatistics
 .sampleCorrelation(x, y);    ⑪  
console.log(correlationCoefficient);    ⑫   

您可以运行代码清单 9.17,它将打印出大约 0.64 的相关系数,这在视觉上研究了图 9.16 中的散点图图表后应该符合我们的预期。我们预期有一个强烈的正相关,但不是完美的相关。我们已经量化了雨量和雨伞销售之间的关系。

现在,您拥有各种工具来分析您的数据。您可以找到趋势和模式,比较您的数据集,并对未来的数据点进行预测。

在本章中,我们使用了特别准备好的函数来创建我们的图表。在接下来的第十章和第十一章中,我们将退后一步,学习如何在浏览器中(第十章)和服务器端(第十一章)创建这样的图表。

摘要

  • 您学习了基本的统计操作:总和、平均值和标准差。

  • 您发现了如何对数据集进行分组和总结,以便将其简化并使其更容易理解。

  • 我们讨论了如何使用标准化、差异和值的分布来比较数据集。

  • 您学习了如何使用分布来对新值进行预测。

  • 我们探讨了使用滚动平均值、滚动标准差和线性回归来分析时间序列数据。

  • 您了解到可以使用相关系数来量化两个数据变量之间的关系。

10

基于浏览器的可视化

本章涵盖

  • 使用 C3 进行基于浏览器的可视化

  • 理解各种图表:折线图、柱状图、饼图和散点图

  • 建立图表模板,以便您可以快速开始新项目

  • 快速原型化图表

  • 创建一个简单的 Web 服务器和 REST API 来为您的可视化提供服务

  • 为您的图表添加各种视觉和交互式改进

现在我们来到了 JavaScript 最擅长的数据处理方面!

在网页浏览器中运行 JavaScript 是唯一托管交互式可视化之处。通过可视化,我们将我们的数据公之于众。这是我们更好地理解数据的方式。以这种方式查看数据可以比我们通过查看原始数字所能希望达到的更有效地将信息传递给我们的大脑。

可视化是我们将有关我们数据的信息传达给我们的观众的方式,无论他们是谁;它允许知识和理解的转移。我们可以轻松地识别和指出有趣的趋势、模式或数据点。

在第九章中,我们分析了我们的数据,在分析过程中,我们查看了许多图表。在这一章中,让我们退后一步,学习如何为自己创建这样的图表。

我们将使用 C3 可视化库并创建一系列包含图表的简单工作 Web 应用。我们将从纽约市的年温度折线图开始。在我们尝试其他图表类型之前,我们将对第一个图表进行各种改进。

10.1 扩展您的工具箱

本章我们将使用的主要工具是 C3 可视化库。JavaScript 有许多可视化库,那么为什么我选择 C3 作为本章的工具呢?

好吧,我们必须从某个地方开始,而 C3 是一个方便且易于开始的起点。大多数简单的图表都是声明性的(它们通常可以用在 JSON 文件中指定的图表定义来声明),尽管我们也有在需要时使用代码的能力。C3 直接提供了交互性,我们甚至可以制作简单的动画图表。

C3 被广泛使用,社区支持强大,并且处于持续的开发和改进中。但没有任何库是完美的,C3 也有其局限性。当我们超越简单的图表时,我们会发现它的限制;然而,我相信 C3 的易用性和快速原型化简单图表的能力使其成为我们工具箱中的绝佳补充。

选择 C3 的另一个好理由是它基于 D3,正如您可能非常了解的,D3 是 JavaScript 中首屈一指的可视化工具包。但既然 D3 如此出色,为什么还要选择 C3 而不是它呢?

D3 是一个用于开发动态和交互式网络可视化的高级工具包;我们将在第十三章中了解更多关于它的内容。D3 很棒,但缺点是它也很复杂,学习曲线陡峭。C3 是 D3 的简化包装器,在创建常见类型的图表时使用起来要容易得多。C3 提供了一系列模板图表,我们可以对其进行配置,但背后有 D3 的强大功能。

我提到 D3 不仅因为其在 JavaScript 可视化社区中的重要性。你还需要知道,当你达到 C3 的极限时,你可以开始使用 D3 API 来定制你的图表。这导致了一个全新的复杂度级别,但它确实在我们达到 C3 的极限时为我们提供了一种前进的方式,通过这种方式,我们可以将 C3 视为迈向完整 D3 的垫脚石,前提是你所追求的是这个目标。

10.2 获取代码和数据

本章的代码和数据可在 GitHub 上的 Data Wrangling with JavaScript Chapter-10 仓库中找到。

`[`github.com/data-wrangling-with-javascript/chapter-10.`](https://github.com/data-wrangling-with-javascript/chapter-10.)` ````` `Each subdirectory in the repository corresponds to a code listing in this chapter and contains a complete and working browser-based visualization.` ````These examples can be run using live-server as your web server. Install live-server globally as follows: ``` npm install -g live-server ``` You may now use live-server to run listings 10.1 to 10.3, for example (also installing dependencies): ``` cd Chapter-10/listing-10.1 bower install live-server ``` Live-server conveniently opens a browser to the correct URL, so you should immediately see your visualization onscreen. The later code examples in this chapter include a Node.js–based web server, so you must use both npm and Bower to install dependencies, for example: ``` cd Chapter-10/listing-10.4 npm install cd public bower install ``` You must run the Node.js app to start the web server for listing 10.4 and on as follows: ``` cd Chapter-10/listing-10.4 node index.js ``` You can now open your browser to the URL [`localhost:3000`](http://localhost:3000) to view your visualization. Refer to “Getting the code and data” in chapter 2 for help on getting the code and data. ## 10.3 Choosing a chart type When starting a visualization, we must first choose a chart type. In this chapter, we’ll cover the chart types shown in figure 10.1. Table 10.1 lists the chart types along with a brief explanation of the best use of each type of chart. Table 10.1 Chart types and their uses | **Chart Type** | **Uses** | **Example** | | --- | --- | --- | | Line chart | Time series data or continuous data set | NYC yearly temperature | | Bar chart | Comparing groups of data to each other | NYC monthly temperature | | | Analyzing distributions of data (also known as a histogram) | Understanding the distribution of temperatures in NYC | | Pie chart | Comparing groups to the whole, but only a snapshot in time | Comparing the monthly temperatures in 2016 | | Scatter plot | Understanding the relationship and correlation between data variables | Understanding the relationship between rainfall and umbrella sales | ![c10_01.tif](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/dt-wgl-js/img/c10_01.png) Figure 10.1 The types of charts we'll create in chapter 10 To get started, we have to pick one type of chart. We’ll start with a line chart because that’s one of the most common charts, and it also happens to be the default for C3 when you don’t choose any particular type of chart. ## 10.4 Line chart for New York City temperature We’re going to start with C3 by learning how to construct a line chart. We’ll first create a simple chart template with hard-coded data and use live-server so that we can prototype our chart without having to first build a web server. We’ll then add in a CSV data file so that we’re rendering our chart from real data. Ultimately, we’ll work up to building a simple web server that delivers the data to our web app for rendering in the chart. Figure 10.2 shows what you can expect from the end product: a line chart of the yearly average temperature in New York City. You might remember this chart from chapter 9. ![c10_02.tif](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/dt-wgl-js/img/c10_02.png) Figure 10.2 A line chart showing yearly average temperature for New York City Even though we’re starting our visualization journey with a line chart, it’s easy to convert to almost any other chart type. Indeed, C3 has a facility that allows us to create an animated transition from one chart type to another. For example, we could do an animated transition from the line chart to a bar chart. But let’s not get ahead of ourselves; we need to start with the basics, and then later in the chapter we’ll learn more about the advanced features. ### 10.4.1 The most basic C3 line chart When I start work on a project, I like to start as simply as possible. As you’ve learned from other chapters, my philosophy on coding is to start small, get it to work, then evolve and refine it through incremental changes, all the while keeping it working. I like to take my code through an evolution toward my end goal. On the way, I take it through a series of transitions from working state to working state so that I keep the code working and problems aren’t allowed to accumulate. We’re going to start our web-based visualization with a simple web app. We’ll use static web assets and hard-coded data so that we don’t have to build a custom web server. Instead, we’ll use live-server as our off-the-shelf web server (live-server was first introduced in chapter 5). We can install live-server globally on our system as follows: ``` npm install -g live-server ``` Now we can run live-server from the command line in the same directory as our web project, and we’ll have an instant web server. To see this in action, open a command prompt, change directory to the listing-10.1 subdirectory in the GitHub repo for this chapter, install dependencies, and then run live-server as follows: ``` > cd Chapter-10/listing-10.1 > bower install > live-server ``` Live-server automatically opens a web browser, so we should now see our first C3 chart rendered as shown in figure 10.3. You can follow this same pattern for each of the code listings up to listing 10.4 (where we abandon live-server and create our own web server). Change the directory to the appropriate subdirectory for the code listing and run the `live-server` command (making sure you install dependencies the first time you run each listing). Our web app project is composed of an HTML file (index.html), a JavaScript file (app.js), and a collection of third-party components installed through Bower. You can see what the file system for this project looks like on the left-hand side of figure 10.4. When we run live-server in the same directory as our project, it connects the web browser to our web project, and what we’re looking at is index.html rendered in the browser after the JavaScript has executed and rendered our chart (as represented on the right-hand side of figure 10.4). Listings 10.1a and 10.1b show the HTML and JavaScript files for our first C3 chart. If you haven’t already, please run live-server for listing 10.1 so you can see the results of the code later. Don’t forget to first install the Bower dependencies. ![c10_03.tif](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/dt-wgl-js/img/c10_03.png) Figure 10.3 The most basic possible C3 chart; we’ll use this as our template chart. Listing 10.1a The HTML file for our C3 chart template (listing-10.1/index.html) ``` <!doctype html> <html lang="en"> <head> <title>C3 chart template</title> <link href="bower_components/c3/c3.css" rel="stylesheet">    ①   </head> <body> <div id='chart'></div>    ②   <script src="bower_components/jquery/dist/jquery.js"></script>    ③   <script src="bower_components/d3/d3.js"></script>    ④   <script src="bower_components/c3/c3.js"></script>    ⑤   <script src="app.js"></script>    ⑥   </body> </html> ``` ![c10_04.eps](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/dt-wgl-js/img/c10_04.png) Figure 10.4 Live-server is our web server while prototyping. Listing 10.1a is a minimal HTML file for our C3 chart. It includes CSS and JavaScript files for C3\. It includes jQuery so that we can have a callback when the document is loaded and for its AJAX capabilities. It also includes the JavaScript file for D3 because C3 depends on that. Finally, it includes our own custom JavaScript file that is presented in the following listing. Listing 10.1b The JavaScript file for our C3 chart template (listing-10.1/app.js) ``` $(function () {    ①   var chart = c3.generate({    ②   bindto: "#chart",    ③   data: {    ④   json: { "my-data": [30, 200, 100, 400, 150, 250],    ⑤   } } }); }); ``` Listing 10.1b is the JavaScript file that creates our first C3 chart. It initializes the chart after jQuery invokes our *document ready* callback. The chart is created by the call to `c3.generate`. We pass our *chart definition* as a parameter. Note that we supplied the chart with simple hard-coded data using the `json` field of the chart definition. We use such simple data here as a starting point to check that our basic chart works, but for our next step let’s get real data in there. To sum up, this is what we’ve done: * We created a simple web app containing the most basic C3 chart. * We used hard-coded data to get started. * We used live-server as our web server*and viewed our basic chart in the browser.* *### 10.4.2 Adding real data Now we’re going to introduce real data into our chart. We’ll read the data from a CSV data file that we’ll put in our web project. Figure 10.5 is a screenshot of our data file loaded in Excel; it includes the yearly average temperatures for New York City. You can find this data in the file data.csv in the listing-10.2 subdirectory of the Chapter-10 GitHub repo. ![c10_05.png](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/dt-wgl-js/img/c10_05.png) Figure 10.5 NYC yearly temperature CSV file After we plug our new CSV data file into our C3 chart and refresh the browser, we’ll see a line chart that looks like figure 10.6. ![c10_06.tif](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/dt-wgl-js/img/c10_06.png) Figure 10.6 NYC average yearly temperature rendered from a static CSV file We’re going to load our data from data.csv. We’ve placed this file in our web project next to our web assets, as you can see on the left-hand side of figure 10.7. I’ve given this file the generic data.csv filename to make it easier for you to use this code as a template for your own visualizations. In a bigger project that might have multiple data files, we’d probably want to give them more specific names—for example, NYC_yearly_temperatures.csv. ![c10_07.eps](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/dt-wgl-js/img/c10_07.png) Figure 10.7 We add data file data.csv to our web project, and we can render a chart from it in the browser. Even though we’re using real data now, we still don’t need a web server. This is because live-server gives us access to the file system of our web project. We use jQuery’s AJAX API to retrieve our CSV data file using an asynchronous HTTP transfer. ### 10.4.3 Parsing the static CSV file Getting the data is only part of the problem. The data we get back through live-server and jQuery is text data, and our simple visualization doesn’t yet have the capability to understand our CSV data. However, we’ve already learned the tools we need! We’ll use Papa Parse again here, which we first used way back in chapter 3\. Papa Parse also works in the browser. If you followed the instructions in “Getting the code and data” and you’ve installed dependencies for the listing-10.2 subdirectory of the GitHub repo, you already have Papa Parse installed; otherwise, you can install it in a fresh web project as follows: ``` bower install --save papaparse ``` Listing 10.2a shows an updated HTML file. We’ve included Papa Parse’s JavaScript file so that we can use it to deserialize our CSV data. Note that we’ve also updated the title of the web page; that’s a small visual improvement to our web page. Listing 10.2a HTML file for our chart of NYC average yearly temperature (listing-10.2/index.html) ``` <!doctype html> <html lang="en"> <head> <title>NYC average yearly temperature</title>    ①   <link href="bower_components/c3/c3.css" rel="stylesheet"> </head> <body> <div id='chart'></div> <script src="bower_components/jquery/dist/jquery.js"></script> <script src="bower_components/d3/d3.js"></script> <script src="bower_components/c3/c3.js"></script> <script src="bower_components/papaparse/papaparse.js"></script>    ②   <script src="app.js"></script> </body> </html> ``` The changes to the JavaScript file in listing 10.2b are more substantial. We’re now using jQuery’s `$.get` function to *get* our data from the web server (in this example that’s still live-server). This creates an HTTP GET request to live-server that is resolved asynchronously and eventually triggers our *then* callback when the data has been fetched (or otherwise calls the error handler if something went wrong). Once the data is retrieved, we deserialize it from CSV data to a JavaScript array using Papa Parse. We now have the data in our core data representation*(read chapter 3 for a refresher on that), and we can plug the data into our chart using the `json` field in the chart definition. Because we have the data in the core data representation, any of our reusable JavaScript modules for transforming such data could potentially be reused here.* *Listing 10.2b Retrieving CSV data to render to the C3 chart (listing-10.2/app.js) ``` $(function () { $.get("data.csv")    ①   .then(function (response) {    ②   var parseOptions = {    ③   header: true,    ④   dynamicTyping: true    ⑤   }; var parsed = Papa.parse(response, parseOptions);    ⑥   var chart = c3.generate({    ⑦   bindto: "#chart", data: { json: parsed.data,    ⑧   keys: { value: "AvgTemp"  [  ⑨   ] } } }); }) .catch(function (err) {    ⑩   console.error(err); }); }); ``` Did you notice that the chart definition between listings 10.1b and 10.2b barely changed? In 10.2b we plugged in the real data that was retrieved from our CSV data file. The other change we made was to use the `keys` and `value` fields of the chart definition to specify the column from the CSV file to render in the line chart. A CSV file may contain many columns, but we don’t necessarily want them all to appear in our chart, so we restrict the chart to the column or columns we care about. We’ve now added some real data to our chart. We added our CSV data file to our web project and relied on live-server to deliver the data to the browser where it was rendered to the chart. I chose to use CSV here because it’s a common format for data like this. We might also have used a JSON file and that would have saved us effort because then we wouldn’t have needed Papa Parse to deserialize the data. ### 10.4.4 Adding years as the X axis If you take another look at figure 10.6, you will notice that the labels on the X axis indicate sequential numbers starting at 0\. This is supposed to be a chart of yearly average temperature, so how does the X axis in figure 10.6 relate to the year of each record? The problem is that we didn’t explicitly tell C3 which column in the CSV file to use as the values for the X axis, so C3 defaulted to using a zero-based index for each data point. Look again at figure 10.5, and you can see a Year column that’s clearly an obvious candidate for the X axis; however, C3 has no way of knowing these are the correct values for the X axis! We need to tell C3 to use the Year column for the X axis in our chart. When C3 knows this, it will now render the chart shown in figure 10.8. Notice now that the labels along the X axis show the correct years for the data points on the Y axis. ![c10_08.png](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/dt-wgl-js/img/c10_08.png) Figure 10.8 NYC average yearly temperature now using the year as the X axis We use the `keys` and `x` fields of the chart definition to set the data for our X axis. Note that listing 10.3 is similar to what’s shown in listing 10.2b, but we’ve set the `x` field to `Year`. Now C3 extracts the Year field from the data for use as the X axis. Listing 10.3 Adding an X axis to the NYC temperature chart (extract from listing-10.3/app.js) ``` var chart = c3.generate({ bindto: "#chart", data: { json: parsed.data, keys: { x: "Year",    ①   value: [ "AvgTemp" ] } } }); ``` Now we’ve prototyped a line chart within a simple web app. We’ve used live-server so that we didn’t have to create a web server. We started with hard-coded data, and then we upgraded it to read data from a CSV file. We haven’t yet seen any need to create a custom web server. As you can see, we can go a long way in our prototyping and development before we need to invest the time to build a custom Node.js web server. You might even find that you don’t need to build a Node.js web server at all. I’m not saying you should use live-server to host a public website or visualization—you’d have production issues with that—but you could take any off-the-shelf web server (for example, Apache or nginx) and use it to host a public visualization such as we’ve produced so far in this chapter. Maybe you’re creating a visualization that’s for yourself and not for public consumption? For example, you want to improve your own understanding of a data set or to take a screenshot to save for later. When you create a visualization that isn’t public-facing, you won’t require a production web server. We’ll have many times, however, when we’ll want to build our own custom web server, and it’s not particularly difficult, so let’s now learn how to do that. ### 10.4.5 Creating a custom Node.js web server Although creating our own web server in Node.js isn’t strictly necessary for any of the visualizations in this chapter, it’s handy for a variety of reasons. In this section, we’ll expand on what we learned back in chapter 2 and build a simple web server and REST API that can host both our web app and the data it needs. Each code listing that we’ve seen so far in this chapter (listings 10.1 to 10.3) has been a simple web project with static assets delivered to the browser through live-server. Now we’re going to move our web project into the context of a Node.js project that’s the web server that hosts the visualization. We move our web project to the *public* subdirectory in the new Node.js project that’s shown on the left-hand side of figure 10.9. Also notice the *data* subdirectory. We’re still going to use a CSV data file, but we’ve moved it from the web project*to the *data* subdirectory. This is a convenient location where we can organize our data.* *When we run the Node.js project, it will create a web server to host our web app and a REST API that delivers the data to it. Our web server now becomes the middleman between the server-side file system and the web app running in the browser (as shown in figure 10.9). What you should understand from this is that our data is no longer directly accessible to the public; we’re now forcing access to our data to go through our REST API. Because of this, we have the potential to control access to the data in whatever way we need. We’ll revisit this idea again soon. ![c10_09.eps](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/dt-wgl-js/img/c10_09.png) Figure 10.9 After adding our Node.js web server and REST API, we take full control of how data is accessed from the browser. Listing 10.4a shows the Node.js code for a simple web server, which does two things: 1. Exposes the *public* subdirectory as static web assets. This allows the web app to be served to the web browser (similar to what live-server did). 2. Creates a REST API that delivers our data to the web app. You can run this script now like any other Node.js app. Open a command line, install npm and Bower dependencies, and then run the Node.js script: ``` > cd Chapter-10/listing-10.4 > npm install > cd public > bower install > cd .. > node index.js ``` Note that you might also want to use `nodemon` for live reload of the Node.js project; please refer to chapter 5 for details on that. Here we’re using the `express` library for our web server. You may have installed that in the example project already with `npm install`, or you can install it in a fresh Node.js project using the command: ``` npm install --save express. ``` Now open your web browser and enter [`localhost:3000`](http://localhost:3000) into the address bar. You should see a line chart of NYC yearly temperature. Please take note of the steps you followed; this is how you’ll run all subsequent code listings in the chapter. The following listing starts our web server. Listing 10.4a Node.js web server to host our web app (listing-10.4/index.js) ``` const express = require('express'); const path = require('path'); const importCsvFile = require('./toolkit/importCsvFile.js'); const app = express(); const staticFilesPath = path.join(__dirname, "public");    ①   const staticFilesMiddleWare = express.static(staticFilesPath); //  ①   app.use("/", staticFilesMiddleWare);    ①   app.get("/rest/data", (request, response) => {    ②   importCsvFile("./data/data.csv")    ③   .then(data => { response.json(data);    ④   }) .catch(err => { console.error(err); response.sendStatus(500);    ⑤   }); }); app.listen(3000, () => {    ⑥   console.log("Web server listening on port 3000!"); }); ``` Note in listing 10.4a that we’re using the `importCsvFile` toolkit function that we created in chapter 3\. You’ll find that your most useful toolkit functions will be used time and again. This is the definition of a good reusable function! We also now have a REST API. In listing 10.4a we attached an HTTP GET request handler to the URL `/rest/data`. We could have made this URL whatever we wanted, and we could have called it something more specific such as `/rest/nyc-temperature`, but in the interest of reusing this code listing as a template for your own visualizations, I’ve chosen to have a more generic name for the URL. We can test that our REST API works with our browser. Enter [`localhost:300/rest/data`](http://localhost:300/rest/data) into your browser’s address bar, and you should see something similar to figure 10.10. This is what the data looks like when I view it in the browser (using Chrome with nice formatting provided by the JSON Viewer plugin). ![c10_10.png](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/dt-wgl-js/img/c10_10.png) Figure 10.10 Browsing our temperature data REST API in the browser To connect our web app to the REST API, we must change how it loads the data. Instead of loading the data from a static CSV data file (as we did in listing 10.2b), we now load it from the REST API as shown in listing 10.4b. Note that in both cases we’re still doing an HTTP GET request to the web server through jQuery’s `$.get` function, but now we’re using the URL of our new REST API rather than the URL of the CSV file. In addition to the change in how the data is loaded, you’ll see another difference between listings 10.2b and 10.4b. We no longer need Papa Parse! We’re sending our data from server to web app in the JSON data format. jQuery `$.get` automatically deserializes the JSON data to a JavaScript data structure (the core data representation; see chapter 3). This simplifies the code for our web app, and it’s always nice when that happens. Listing 10.4b The web app gets data from the REST API (extract from listing-10.4/public/app.js) ``` $.get("/rest/data")    ①   .then(function (data) { var chart = c3.generate({    ②   bindto: "#chart", data: { json: data, keys: { x: "Year", value: [ "AvgTemp" ] }, type: "line"    ③   } }); }) ``` Why is it important to create our own web server and REST API? Well, I’ve already mentioned that it gives us the ability to control access to our data. To take this web app to production, we probably need a form of authentication. If our data is sensitive, we don’t want anyone to access it—we should make them log in before they can see data like that. We’ll talk more about authentication again in chapter 14. Other important benefits exist for creating our own web server. One primary reason is so that we can create visualizations from data in a database. Figure 10.11 shows how we can put a database behind our web server (instead of CSV files in the file system). We can also use our REST API to dynamically process our data (retrieved from either database or files) before it’s sent to the web browser. Having a REST API is also useful in situations when we’re working with live data; that’s data that is fed into our pipeline in real time, an idea we’ll revisit in much detail in chapter 12. ![c10_11.eps](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/dt-wgl-js/img/c10_11.png) Figure 10.11 Our data in a database with the web server as a secure gateway As a parting note on REST APIs, please remember that it’s not always necessary to create a web server. In fact, I recommend that you go as far as you can prototyping your visualization *before* adding the extra complexity. Extra complexity slows you down. For the rest of this chapter, we don’t need the REST API, but I wanted to make sure that you’re ready to go with it because it’s commonplace to develop visualizations based on a database. And for that, you *do* need the REST API. We’ve now created a web server and a REST API to serve our web app and feed it with data. You could say this is now a completed browser-based visualization. Although we still need to explore other types of charts, first let’s make improvements to our line chart. ### 10.4.6 Adding another series to the chart Let’s make upgrades and improvements to our chart. To start, we’ll add another data series to the chart to compare temperature between New York City and Los Angeles, similar to what we saw in chapter 9\. The resulting chart is shown in figure 10.12. ![c10_12.tif](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/dt-wgl-js/img/c10_12.png) Figure 10.12 Combined chart with NYC and LA yearly temperatures This example uses almost the exact same code as listing 10.4. We’re changing only two things: 1. We replace data.csv in the Node.js project with a new data file that contains temperature columns for both NYC and LA. 2. We modify the chart definition to add the new series to the chart. The updated code is shown in listing 10.5. You can use this same process to create new visualizations for yourself. Take the code from listing 10.4 (or another listing that’s closer to your needs), replace the data with whatever new data you want, and then change the chart definition to suit your data. Continue to tweak the chart definition until you have a visualization that you’re happy with. Listing 10.5 Rendering two data series into our chart to compare NYC temperature against LA (extract from listing-10.5/public/app.js) ``` var chart = c3.generate({ bindto: "#chart", data: { json: data, keys: { x: "Year", value: "TempNYC",  [  ①   "TempLA"    ①   ] } } }); ``` Again, in listing 10.5 we use the `json` and `keys` fields in the chart definition to specify the data to render in the chart. Note that we’ve specified both the TempNYC and TempLA columns using the `value` field. This is what causes both data series to be rendered in the chart. ### 10.4.7 Adding a second Y axis to the chart Another thing we might want to do is add a second Y axis to our chart. Let’s say we want to compare temperature and snowfall in NYC. We take our chart from figure 10.8, and we add a snowfall data series to it. The result is shown in figure 10.13. Can you tell me what’s wrong with this chart? ![c10_13.tif](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/dt-wgl-js/img/c10_13.png) Figure 10.13 Adding the snowfall series to the NYC yearly temperature chart. What’s wrong with this picture? The problem is that temperature and snowfall have values that are on different scales, and this makes comparison impossible. Note that the line for temperature in figure 10.13 is basically a straight line even though we know that if we zoom in on it what we’ll see is not going to be a straight line (see figure 10.8 for a reminder). Now we could deal with this by *standardizing* both temperature and snowfall data sets the way we did in chapter 9\. This would have the effect of bringing both data sets into a comparable scale, but it would also change the values, and if the actual values are what we want to see in the chart, this isn’t going to work for us. The simple fix for this is to add a second Y axis to our chart. You can see in figure 10.14 that we now have the temperature Y axis on the left-hand side of the chart and the snowfall Y axis on the right-hand side. This simple change allows us to compare data series side by side without having to make any modifications to our data. ![c10_14.tif](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/dt-wgl-js/img/c10_14.png) Figure 10.14 Adding the snowfall series as the secondary Y axis makes it easier to compare the two series. Listing 10.6 shows the simple changes we must make to our chart definition to move one of our data sets to the second Y axis. With the addition of the `axes` field, we specify which data series belongs to which Y axis. It’s important to note that the second Y axis is enabled under the `axis` field. The second Y axis is disabled by default, and you must enable it. Otherwise, it won’t appear in the chart! Listing 10.6 Adding a second Y axis to the chart (extract from listing-10.6/public/app.js) ``` var chart = c3.generate({ bindto: "#chart", data: { json: data, keys: { x: "Year", value: [ "AvgTemp", "Snowfall" ] }, axes: { AvgTemp: "y",    ①   Snowfall: "y2"    ②   } }, axis: { y2: { show: true    ③   } } }); ``` ### 10.4.8 Rendering a time series chart We haven’t rendered a proper time series chart, although we did use the year as our X axis. This might seem like a time series to us, but on a technical level, C3 will only consider it as a time series if we use actual dates as our X axis. Let’s have a quick look at how to do that. ![c10_15.png](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/dt-wgl-js/img/c10_15.png) Figure 10.15 CSV file containing NYC daily temperatures (viewed in Excel) In this example we’ll change our data to be a temperature time series for each day in 2016\. You can see an example of what this data looks like in figure 10.15. Note that the *Date column contains dates (in the Australian format, sorry U.S. readers).* *Our new time series data is rendered by C3, as shown in figure 10.16. ![c10_16.tif](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/dt-wgl-js/img/c10_16.png) Figure 10.16 Rendering average daily temperature for NYC as a time series chart To render our time series data correctly, we must make small changes to our chart definition. The updated chart definition is shown in listing 10.7. First, we set the X axis to the *Date* column, but this isn’t anything groundbreaking yet. The most important thing is that we set the X axis `type` to `timeseries`. C3 now interprets the *Date* series as date/time values. We haven’t used time in this example, but you could easily also add time to your date format. Listing 10.7 Rendering a time series chart with formatted dates as labels for the X axis (extract from listing-10.7/public/app.js) ``` var chart = c3.generate({ bindto: "#chart", data: { json: data, keys: { x: "Date",    ①   value: [ "AvgTemp" ] } }, axis: { x: { type: 'timeseries',    ②   tick: { rotate : 50,    ③   format: '%Y-%m-%d',    ④   count: 12    ⑤   } } }, point: { show: false    ⑥   } }); ``` The other changes to note in listing 10.7 are cosmetic. We’ve improved the look of the chart by setting the format and rotation of the tick labels. ## 10.5 Other chart types with C3 We know how to create line charts with C3, but how do we create the other chart types? It all comes down to the chart definition. We can change the chart definition and turn our line chart into any of the other chart types. This is trivial for bar charts, but for pie charts and scatter plots, we’ll have more work to prepare our data. ### 10.5.1 Bar chart Figure 10.17 is a bar chart that shows monthly temperature data for 2016 in NYC. The code that produces this bar chart is almost identical to the code for the line chart in listing 10.4. We’ve replaced the data, of course. The data shown in this chart was produced from raw data using the group and summarize technique that we covered in chapter 9. ![c10_17.tif](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/dt-wgl-js/img/c10_17.png) Figure 10.17 NYC average monthly temperature for 2016 as a bar chart We can start with the code from listing 10.4 and change the data; then we have one other thing we must do to turn it into a bar chart. As shown in listing 10.8, we change the `type` field in the `data` section to be `bar`. That’s it! That’s all we need to do to convert a line chart to a bar chart. Listing 10.8 isn’t included in the Chapter-10 code repository, but you can try this yourself by taking listing 10.4 and setting the type to `bar`. Your result won’t look like figure 10.17 (you’ll need to update the data for that), but it will be a bar chart. Listing 10.8 Changing the line chart to a bar chart ``` var chart = c3.generate({ bindto: "#chart", data: { json: data, keys: { x: "Month", value: [ "AvgTemp" ] }, type: "bar"    ①   } }); ``` ### 10.5.2 Horizontal bar chart It’s also trivial to convert our vertical bar chart to a horizontal bar chart, as shown in figure 10.18. Listing 10.9 shows the small code change we make to listing 10.8 to make our bar chart horizontal. We set the `rotated` field from the `axis` section to `true`. We now have a horizontal bar chart! ![c10_18.tif](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/dt-wgl-js/img/c10_18.png) Figure 10.18 NYC average monthly temperature for 2016 as a horizontal bar chart Listing 10.9 Converting the vertical bar chart to a horizontal bar chart ``` var chart = c3.generate({ bindto: "#chart", data: { json: data, keys: { x: "Month", value: [ "AvgTemp" ] }, type: "bar" }, axis: { rotated: true    ①   } }); ``` ### 10.5.3 Pie chart Pie charts are great for showing how various parts compare to the whole. It may seem like an odd choice to plug temperature data into a pie chart, as shown in figure 10.19, but this does serve a purpose. Here we can easily pick out the hottest and coldest months in NYC by looking for the largest and smallest slices of the pie. In addition, we can use color coding to help identify the hottest and coldest months. Preparation of the data for a pie chart is a bit different to the other charts in this chapter, so listing 10.10 is a larger code listing. In this code listing, we organize our data as a JavaScript object that maps the name of each month to the average temperature of that month. The chart definition for a pie chart is simple; it’s the data preparation that makes this a little more difficult. ![c10_19.png](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/dt-wgl-js/img/c10_19.png) Figure 10.19 NYC average monthly temperature as a pie chart. The size of each slice and the color make it easy to pick out the hottest and coldest months in New York. To see this figure in color, refer to the electronic versions of this book. Listing 10.10 Restructuring the data and rendering a pie chart (listing-10.10/public/app.js) ``` var monthNames = [ // ... Array that specifies the name of each month ... ]; var monthColor = [ // ... Array that specifies the color for each month in the chart ... ]; function getMonthName (monthNo) {    ①   return monthNames[monthNo-1]; } function getMonthColor (monthNo) {    ②   return monthColor[monthNo-1]; } $(function () { $.get("/rest/data") .then(function (data) { var chartData = {}; var chartColors = {}; for (var i = 0; i < data.length; ++i) {    ③   var row = data[i]; var monthName = getMonthName(row.Month); chartData[monthName] = row.AvgTemp;    ④   chartColors[monthName] = getMonthColor(row.Month); } var chart = c3.generate({ bindto: "#chart", data: { json: [ chartData ], keys: { value: monthNames }, type: "pie",    ⑤   order: null, colors: chartColors } }); }) .catch(function (err) { console.error(err); }); }); ``` Pie charts are best used to show a snapshot of data composition at a particular point in time and can’t easily be used to represent time series data. If you’re looking for a type of chart that can be used to compare parts to the whole (like a pie chart) but over time, then consider using a stacked bar chart. ### 10.5.4 Stacked bar chart Figure 10.20 shows a bar chart with two data series. This sort of chart can be useful for comparing data side by side. It’s a bar chart with two data series, like the line chart shown in figure 10.12 but with the type set to `bar`. ![c10_20.tif](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/dt-wgl-js/img/c10_20.png) Figure 10.20 A normal bar chart used for comparing average monthly temperature in NYC and LA We can easily convert our two-series bar chart shown in figure 10.20 to a stacked bar chart. The result is shown in figure 10.21. ![c10_21.tif](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/dt-wgl-js/img/c10_21.png) Figure 10.21 Converting the normal bar chart to a stacked bar chart might help us compare the proportions. We stack our data series like this by organizing them into *groups*. In listing 10.11 we use the `groups` field to make groups from our data series and create the stacked bar chart from figure 10.21. There’s no code in the repository for listing 10.11, but you can easily create this yourself with a small modification to listing 10.5. Why don’t you try doing that? Listing 10.11 Creating a stacked bar chart from two data series ``` var chart = c3.generate({ bindto: "#chart", data: { json: data, keys: { x: "Month", value: [ "TempNYC", "TempLA" ] }, type: "bar",    ①   groups: [ [ "TempNYC", "TempLA" ]    ②   ] } }); ``` ### 10.5.5 Scatter plot chart The scatter plot is probably my favorite kind of chart, and it’s easy to create with C3\. As we learned in chapter 9, scatter plot charts are used to identify relationships between data variables. Figure 10.22 shows the scatter plot of rainfall versus umbrella sales that you might remember from chapter 9\. Let’s learn how to create this chart, and then we’ll improve the look of it. ![c10_22.png](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/dt-wgl-js/img/c10_22.png) Figure 10.22 Scatter plot chart of NYC rainfall vs. umbrella sales Listing 10.12 shows the simple chart definition required to create a scatter plot. We’re using the *Precipitation* (rainfall) column as our X axis and the *UmbrellaSales* column as our Y axis. The difference to the other charts is that we set the `type` field to `scatter`. That’s it, job done, we’ve created a scatter plot. Not difficult at all. Listing 10.12 Creating a scatter plot chart comparing rainfall to umbrella sales in NYC (extract from listing-10.12/public/app.js) ``` var chart = c3.generate({ bindto: "#chart", data: { json: data, keys: { x: "Precipitation",    ①   value: [ "UmbrellaSales" ]    ②   }, type: "scatter"    ③   } }); ``` ## 10.6 Improving the look of our charts We have many ways we can improve our charts, starting with simple built-in options all the way up to advanced customizations using D3\. In this section, we’ll learn the simple options. Look again at the scatter plot from figure 10.22. The X axis ticks are all bunched up. Let’s fix that and make other improvements. We can easily control the number of ticks that are rendered on an axis and the formatting of the labels for the ticks. In figure 10.23 we’ve cleaned up the scatter plot, added nicely positioned labels for the X and Y axes, and hidden the legend (which wasn’t adding anything useful to this particular chart). ![c10_23.tif](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/dt-wgl-js/img/c10_23.png) Figure 10.23 The scatter plot finished with nicely formatted axis labels and the legend hidden Listing 10.13 shows the changes and additions made to listing 10.12 to get the desired formatting for our chart. Note that the labels for the axes and ticks have been set, formatted, and positioned. The legend is disabled to reduce clutter in the chart. Listing 10.13 Various visual improvements have been applied to the scatter plot (extract from listing-10.13/public/app.js) ``` var chart = c3.generate({ bindto: "#chart", data: { json: data, keys: { x: "Precipitation", value: [ "UmbrellaSales" ] }, type: "scatter" }, axis: { x: { label: { text: 'Rainfall',    ①   position: 'outer-center',    ②   }, tick: { count: 8,    ③   format: function (value) { return value.toFixed(2);    ④   } } }, y: { label: { text: 'Umbrella Sales',    ⑤   position: 'outer-middle'    ⑥   } } }, legend: { show: false    ⑦   } }); ``` We could do more to this chart, including interactive features such as adding animation and dynamically adding new data points. C3 charts, by default, are interactive, so we already get nice tooltips and a legend that allows us to focus and highlight each data series. ## 10.7 Moving forward with your own projects As I’ve indicated already, you can use any of the code listings in this chapter as templates or starting points for your own C3 visualization projects. To make a line or bar chart, you can start with listing 10.2 (if you don’t need a web server) or listing 10.4 (if you do need a web server). If you’re making a pie chart, you can start with listing 10.11. If you’re making a scatter plot, you can start with listing 10.13. Next, add your own data file; you can find other example CSV and JSON files in the GitHub repos for other chapters of this book. Then set the chart type to line, bar, pie, or scatter, depending on what you’re trying to achieve. Finish by tweaking the chart to make it look nice. The process in summary: 1. Copy listing 10.2 or listing 10.4 (or create your own template web app from scratch) from the Chapter-10 GitHub repo. 2. Replace the data file in the project with new data of your choosing. 3. Set the chart type. 4. Tweak the chart definition to make it look nice. We covered the standard charts that are available through the C3 library. C3 has much more to offer: other chart types, the ability to combine chart types, more configuration options, customization using D3, and support for interactivity. I encourage you to browse their example gallery and documentation to learn more. In this chapter, we covered web-based interactive charts, but that’s not exactly what we were using for data analysis in chapter 9\. If you recall, we rendered charts in Node.js (on the server side), and we didn’t even once open a browser. We can easily render charts in Node.js, and this is incredibly useful when doing exploratory coding in Node.js when we don’t need or want an interactive visualization. We’ll continue our visualization journey in chapter 11 and learn how to render charts on the server-side in Node.js. ## Summary * You learned about most common types of charts—line, bar, pie, and scatter plots—and how to create them using C3. * We used live-server to quickly start prototyping visualizations without having to create a web server. * We also created a custom web server and REST API to control how data is delivered to your browser-based visualization. * We finished by learning how to format axis and tick labels for better-looking charts.****```` `````

11

服务器端可视化

本章涵盖

  • 使用 Node.js 渲染图表和可视化

  • 构建可重用函数以渲染图表,我们可以在进行数据探索分析时使用

  • 使用无头浏览器捕获网页到 PNG 图像文件和 PDF 文档

  • 使用无头浏览器将网络抓取提升到下一个水平

当我们进行探索性编码(第五章)或数据分析(第九章)时,我们希望渲染图表和可视化来探索和理解我们的数据。在第十章中,我们学习了如何为浏览器创建基于 Web 的交互式可视化。这是在 JavaScript 中创建可视化的正常和常见方式。基于浏览器的可视化技术众所周知,并且很容易在网上找到帮助。

我们把浏览器从等式中去掉怎么样?如果我们想直接从 Node.js 在服务器上渲染我们的图表和可视化呢?嗯,我们可以这样做,但与基于浏览器的可视化不同,这并不是一个常见的用例,而且在网上找到你需要帮助可能会很困难。

首先,你可能想知道为什么在服务器端渲染图表是有用的?在进行数据探索分析(这就是我们在第九章中所做的)时,直接从 Node.js 渲染图表对我们来说很方便。这种数据分析方法在 Python 中很常见,如果我们能在 Node.js 中复制它,那将很棒。

此外,在服务器端预先渲染可视化也是一项有用的功能。我们可能这样做是为了生成报告或为了在网页中显示而预先缓存图像。服务器端渲染可视化如此有用,以至于我认为值得克服设置时的复杂性和困难,以便我们可以将这项技术添加到我们的工具箱中。

记得我们在第九章中处理了数据分析技术,并渲染了各种图表来展示这些技术。我们通过调用工具函数,如renderLineChartrenderBarChart,从 Node.js 创建了这些图表。在第九章中,我为你提供了这些函数。但本章,你将学习如何创建这样的函数并在 Node.js 中渲染静态可视化。

11.1 扩展你的工具箱

我们如何在 Node.js 中渲染图表呢?当在浏览器中工作时,我们有这么多可视化库可供选择,尽管通常我们无法直接从 Node.js 使用这些选项。如果我们能从任何基于浏览器的可视化库中选择并从 Node.js 使用它们,那岂不是很好?

嗯,我要告诉你的是,你可以使用任何基于浏览器的可视化库来从 Node.js 创建可视化,尽管我们仍然需要在内部某个地方运行一个网络浏览器。在本章中,我们将使用一种称为无头浏览器的东西,以便在 Node.js 下使基于浏览器的可视化库为我们工作。

无头浏览器是一个 Web 浏览器,但它没有可见的用户界面。你可以将其想象为一个不可见的浏览器。在本章中,我们将把 Nightmare 添加到我们的工具箱中。这个可以通过 npm 安装的 Node.js 库允许你以无头方式控制 Electron 网络浏览器。你不会直接使用 Electron;它将通过 Nightmare API 从代码中控制。重要的是要注意,Electron 是一个类似于 Chrome 或 Edge 的网络浏览器;事实上,它与 Chrome 相似,因为它是由相同的开源代码库构建的。

无头浏览器对于许多任务都很有用,但我们将关注如何在 Node.js 下渲染可视化报表。我们将学习如何从 Node.js 远程控制无头浏览器并捕获基于 Web 的图表和可视化到静态图像文件。在这个过程中,我们将重新创建在第九章中使用的renderLineChart函数;这是一个示例,说明我们可以使用该函数从 Node.js 渲染图表,而无需显式创建或与基于 Web 的可视化交互,尽管底层将运行基于 Web 的可视化!我们还将学习如何使用这些技术渲染包含图形和图表的多页 PDF 报表。

11.2 获取代码和数据

本章的代码和数据可在 GitHub 上的 Data Wrangling with JavaScript Chapter-11 仓库中找到,网址为github.com/data-wrangling-with-javascript/chapter-11. 仓库中的每个子目录都是一个完整的示例,对应本章中的某个列表。在尝试运行每个子目录中的代码之前,请确保已安装 npm 和浏览器依赖项。

列表 11.11 包含一个 Vagrant 脚本,演示如何在无头 Linux 服务器上使用这项技术。有关获取代码和数据的帮助,请参阅第二章中的“获取代码和数据”。

11.3 无头浏览器

当我们想到网络浏览器时,我们通常想到的是我们在浏览万维网时每天与之交互的图形软件。通常情况下,我们直接与这样的浏览器交互,用眼睛观看它,用鼠标和键盘控制它,如图 11.1 所示。

c11_01.eps

图 11.1 通常情况:我们的可视化在浏览器中渲染,用户直接与浏览器交互。

无头浏览器是一个没有图形用户界面且没有直接控制手段的 Web 浏览器。你可能会问,我们无法直接看到或与之交互的浏览器有什么用?

作为开发者,我们通常使用无头浏览器来自动化和测试网站。假设你已经创建了一个网页,并且想要运行一系列自动化测试来证明它按预期工作。测试套件是自动化的,这意味着它由代码控制,因此我们需要从代码中“驾驶”浏览器。

我们使用无头浏览器进行自动化测试,因为我们不需要直接看到或与正在测试的网页交互。查看这种自动化测试的进行过程是不必要的;我们只需要知道测试是否通过——如果失败了,我们还想知道原因。实际上,为浏览器提供一个 GUI 在持续集成或持续部署服务器上可能会成为障碍,我们希望许多这样的测试可以并行运行。

无头浏览器通常用于我们网页的自动化测试,但我还发现它们在捕获基于浏览器的可视化并将其输出为 PNG 图像或 PDF 文件方面非常有用。为了使这成为可能,我们需要一个 Web 服务器和一个可视化工具,这些我们在第十章中已经学过。然后我们必须编写代码来实例化一个无头浏览器,并将其指向我们的 Web 服务器。然后我们的代码指示无头浏览器捕获网页截图,并将其保存到我们的文件系统中作为 PNG 或 PDF 文件。

为了一个更具体的例子,请参阅图 11.2。在这里,我们使用第十章中的纽约市温度图表,并使用我们的无头浏览器捕获一个截图到文件 nyc-temperature.png。我们很快就会了解到这样做有多简单,至少在开发过程中是这样。到本章结束时,我们将面对在生产环境中实现这一功能的困难。

c11_02.eps

图 11.2 我们可以使用 Node.js 下的无头浏览器将我们的可视化捕获到静态图像文件中。

11.4 使用 Nightmare 进行服务器端可视化

Nightmare 是我们将使用的无头浏览器。它是一个基于 Electron 构建的 Node.js 库(使用 npm 安装)。Electron 是一个通常用于构建基于 Web 技术的跨平台桌面应用程序的 Web 浏览器。我们不需要直接交互或理解如何使用 Electron;我们只通过 Nightmare 与之交互,我们可以将 Electron 视为一个标准 Web 浏览器。

11.4.1 为什么选择 Nightmare?

浏览器名为 Nightmare,但使用起来绝对不是噩梦。事实上,这是我使用过的最简单、最方便的无头浏览器。它自动包含 Electron,因此要开始使用,我们需要按照以下步骤将 Nightmare 安装到我们的 Node.js 项目中:

npm install --save nightmare 

这就是安装 Nightmare 所需的所有步骤,我们就可以立即从 JavaScript 开始使用它了!

恶梦伴随着我们需要的几乎所有东西:一个带有嵌入式无头浏览器的脚本库。它还包括从 Node.js 控制无头浏览器的通信机制。大部分情况下,它无缝且很好地集成到 Node.js 中,但 API 可能需要一些时间来习惯。

在接下来的几节中,我们将构建一个新的函数,在 Node.js 下渲染图表。我们将把这个函数添加到我们的工具箱中,你可以在你的开发工作站上重复使用它进行探索性编码和数据分析。

当涉及到生产使用——比如,构建一个自动报告系统时——Nightmare 要复杂一些才能正常工作。我们需要做一些额外的工作,但我们在本章的后面会处理这些困难。

11.4.2 Nightmare 和 Electron

当你使用 npm 安装 Nightmare 时,它会自动附带一个嵌入式的 Electron 版本。我们可以这样说,Nightmare 不仅仅是一个控制无头浏览器的库;它实际上就是一个无头浏览器。这也是我喜欢 Nightmare 的另一个原因。与其他几个无头浏览器相比,控制库是分开的,或者更糟的是,它们根本就没有 Node.js 控制库。在最坏的情况下,你必须自己开发通信机制来控制无头浏览器。

Nightmare 使用 Node.js 的child_process模块创建 Electron 进程的实例。然后它使用进程间通信和自定义协议来控制 Electron 实例。这种关系在图 11.3 中显示。

c11_03.eps

图 11.3 Nightmare 允许我们控制作为无头浏览器运行的 Electron。

Electron 建立在 Node.js 和 Chromium 之上,由 GitHub 维护。它是其他流行桌面应用程序的基础,尽管从我们的角度来看,我们可以将其视为一个普通的 Web 浏览器。

这些是我选择使用 Nightmare 而不是其他任何无头浏览器的原因:

  • Electron 是稳定的。

  • 电子具有优良的性能。

  • API 简单且易于学习。

  • 它没有复杂的配置(你可以快速开始使用它)。

  • 它与 Node.js 集成良好。

Electron 在你的生产环境中可能有点棘手,但我们很快就会解决这个问题。

11.4.3 我们的过程:使用 Nightmare 捕获可视化

让我们看看将可视化渲染到图像文件的过程。首先,我们的数据将硬编码在我们的可视化中。随着我们迭代和改进我们的代码,我们将构建一个新的工具箱函数来渲染图表。最终,我们希望将数据从 Node.js 泵入这个图表,这意味着数据必须位于可视化之外。

这是我们追求的完整过程:

  1. 获取我们的数据。

  2. 启动本地 Web 服务器以托管我们的可视化。

  3. 将我们的数据注入到 Web 服务器中。

  4. 实例化一个无头浏览器并将其指向我们的本地 Web 服务器。

  5. 等待可视化显示。

  6. 将可视化的截图保存到图像文件中。

  7. 关闭无头浏览器。

  8. 关闭本地网络服务器。

这个过程可能听起来相当复杂,但别担心;我们一如既往地从简单开始,并在多次迭代中逐步增加复杂性。最终,我们将把这个过程封装在一个方便且易于重用的工具函数中。

11.4.4 准备要渲染的可视化

我们首先需要有一个可视化。我们将从第十章中您所熟悉的一个开始。图 11.4 显示了纽约市平均年温度图表。

c11_04.png

图 11.4 我们将从第十章中使用的图表:纽约市平均年温度

此图表的代码显示在列表 11.1a 和 11.1b 中。它与第十章中的 列表 10.3 类似。您现在可以使用 live-server 测试此图表(与第十章中我们所做的一样):

cd listing-11.1
bower install
live-server 

在 listing-11.1 子目录中运行 live-server 会自动打开浏览器,您应该会看到一个类似于 图 11.4 的可视化。

在尝试在无头浏览器中捕获之前,检查您的可视化是否在浏览器中直接工作是一个好主意,因为很容易出现问题。在实际浏览器中解决问题比在无头浏览器中容易得多。

列表 11.1a 用于浏览器可视化的 HTML 文件(listing-11.1/index.html)

<!doctype html>
<html lang="en">
    <head>
        <title>NYC average yearly temperature</title>

        <link href="bower_components/c3/c3.css" rel="stylesheet">
    </head>
    <body>
 <div id='chart'></div>    ①  

        <script src="bower_components/jquery/dist/jquery.js"></script>
        <script src="bower_components/d3/d3.js"></script>
        <script src="bower_components/c3/c3.js"></script>
        <script src="bower_components/papaparse/papaparse.js"></script>
 <script src="app.js"></script>    ②  
    </body>
</html> 

列表 11.1b 用于浏览器可视化的 JavaScript 文件(listing-11.1/app.js)

function renderChart (bindto, data, size) {    ①  
    var chart = c3.generate({
        bindto: bindto,
        size: size,
        data: {
            json: data,
            keys: {
                x: "Year",
                value: [
                    "AvgTemp"
                ]
            }
        },
        transition: {
 duration: 0    ②  
        }
    });
};

$(function () {

 $.get("nyc-temperature.csv")    ③  
        .then(function (response) {
            var parseOptions = {
 header: true,
                dynamicTyping: true
            };
 var parsed = Papa.parse(response, parseOptions);    ④  
 renderChart("#chart", parsed.data);    ⑤  
        })
        .catch(function (err) {
            console.error(err);
        });

}); 

我对 列表 11.1b 有一个重要的补充要解释。看看图表定义,注意我设置动画过渡时间为零的地方。这实际上禁用了 C3 图表的动画。在这里动画没有用,因为我们正在将图表渲染为静态图像,这可能会在我们的捕获图像文件中引起问题,所以最好禁用动画。

默认情况下,C3 将我们的数据动画化到图表中,这意味着它会淡入。如果在捕获图像时发生这种情况(这是一个时间问题),那么我们最终会捕获到一个部分透明的图表,这可能不是我们想要的,而且效果不会一致。当我第一次开始使用这种方法渲染可视化时,我的图表部分透明让我几乎要疯了,试图找出原因。

11.4.5 启动网络服务器

要托管我们的可视化,我们需要一个网络服务器。同样,我们可以从第十章中重用代码。请注意,我们可以通过将无头浏览器指向 index.html 并在前面加上 file:// 协议来从文件系统提供服务。这种方法在简单情况下可以很好地工作,但我们需要一种自定义方式来将数据喂入可视化,所以让我们直接使用自定义 Node.js 网络服务器来托管我们的可视化。

请注意,根据你的需求,你可能不需要自定义网络服务器。你可以通过使用文件系统或可能使用现成的工具如 live-server 来简化你的流程。

列表 11.2 展示了我们的网络服务器代码。这与第十章中的 列表 10.4 类似。在尝试在无头浏览器中捕获可视化之前,让我们测试它是否以正常方式工作:

cd listing-11.2
cd public
bower install
cd ..
npm install
node index 

现在打开一个常规网络浏览器,将其指向 localhost:3000。你应该看到我们之前在 图 11.4 中看到的纽约市年度平均温度图表。

列表 11.2 基于浏览器的可视化基本 Node.js 网络服务器(listing-11.2/index.js)

const express = require('express');
const path = require('path');

const app = express();

const staticFilesPath = path.join(__dirname, "public");    ①  
const staticFilesMiddleWare = express.static(staticFilesPath);
app.use("/", staticFilesMiddleWare);

app.listen(3000, () => {    ②  
    console.log("Web server listening on port 3000!");
}); 

这是一个简单的网络服务器,但它不足以满足我们的需求。我们还需要能够动态地启动和停止它。

11.4.6 程序化启动和停止网络服务器

让我们对我们的网络服务器进行修改,以便我们可以程序化地启动和停止它。我们在捕获可视化之前启动它,然后之后停止它。

让我们将 列表 11.2 中的代码升级以实现这一点。我们将首先将网络服务器重构为一个可重用的代码模块,如下所示。

列表 11.3a 我们的网络服务器被重构为一个可重用的代码模块(listing-11.3/web-server.js)

const express = require('express');
const path = require('path');

module.exports = {
 start: () => {    ①  
 return new Promise((resolve, reject) => {    ②  
            const app = express();

 const staticFilesPath = path.join(__dirname, "public");    ③  
            const staticFilesMiddleWare = express.static(staticFilesPath);
            app.use('/', staticFilesMiddleWare);

 const server = app.listen(3000, err => {    ④  
                if (err) {
 reject(err);    ⑤  
                }
                else {
 resolve(server);    ⑥  
                }
            });
        });
    }
} 

列表 11.3a 中的代码模块导出了一个 start 函数,我们可以调用它来启动我们的网络服务器。我们将如何使用这个函数的例子在 列表 11.3b 中展示,其中我们启动了网络服务器,然后随后停止它。在这之间,你可以看到一个占位符,我们将很快在这里添加代码来渲染网页并截图。

列表 11.3b 使用可重用代码模块启动和停止网络服务器(listing-11.3/index.js)

const webServer = require('./web-server.js');    ①  

webServer.start()    ②  
    .then(server => {
        console.log("Web server has started!");

        // ... Do something with the web server here,
        //     eg capture a screen shot of the web page or
        //     run automated integration tests against it  ...

 server.close();    ③  
    })
    .then(() => {
        console.log("Web server has stopped.");
    })
    .catch(err => {
        console.error("Web server failed to start :(");
        console.error(err);
    }); 

这种技术,即启动和停止我们的网络服务器,对于在网站上执行自动集成测试也是很有用的。想象一下,列表 11.3b 中的占位符被一系列测试所取代,这些测试会探测和刺激网页以查看其响应。我们将在第十四章中再次探讨自动测试。

现在我们有了基于浏览器的可视化,我们还拥有一个可以根据需要启动和停止的网络服务器。这些是我们捕获服务器端可视化的基本原料。让我们用 Nightmare 来混合它们!

11.4.7 将网页渲染为图像

现在,让我们用捕获我们可视化的截图的代码替换 列表 11.3b 中的占位符。列表 11.4 有新的代码,它实例化 Nightmare,将其指向我们的网络服务器,然后进行截图。你可以运行此代码,它将渲染图表并在 listing-11.4 目录下的输出子目录中生成 nyc-temperatures.png 文件。

列表 11.4 使用 Nightmare 将图表捕获到图像文件(listing-11.4/index.js)

const webServer = require('./web-server.js');
const Nightmare = require('nightmare');

webServer.start()    ①  
    .then(server => {
 const outputImagePath = "./output/nyc-temperatures.png";

 const nightmare = new Nightmare();    ②  
 return nightmare.goto("http://localhost:3000")    ③  
 .wait("svg")    ④  
 .screenshot(outputImagePath)    ⑤  
 .end()    ⑥  
 .then(() => server.close());    ⑦  
    })
    .then(() => {
        console.log("All done :)");
    })
    .catch(err => {
        console.error("Something went wrong :(");
        console.error(err);
    }) 

注意 goto 函数的使用;这是指导浏览器加载我们的可视化内容的方式。网页通常需要一段时间才能加载。这可能不会太长,尤其是因为我们正在运行本地 web 服务器,但我们仍然面临在无头浏览器初始绘制之前或期间截图的风险。

此外,由于我们正在异步地将数据加载到图表中,我们需要确保在截图之前数据已经加载到图表中。这就是为什么我们必须使用列表 11.4 中显示的 wait 函数,等待图表的 svg 元素出现在浏览器 DOM 中,然后再调用 screenshot 函数。

最终,会调用 end 函数。到目前为止,我们实际上构建了一个要发送给无头浏览器的命令列表。end 函数刷新命令列表;然后,命令被发送到浏览器,浏览器访问页面,渲染图表,截图,并输出文件 nyc-temperatures.png。在图像文件被捕获后,我们通过关闭 web 服务器来完成后续工作。

注意,我们本可以使用 goto 将浏览器发送到任何网站,而不仅仅是我们的自己的 web 服务器。我们也可以使用 file:// 协议将浏览器指向本地文件系统中任何 HTML 文件。这使得你只需这么少的代码,就能以程序化的方式捕获任何网站或 HTML 文件的截图。

11.4.8 在我们继续之前...

希望到目前为止的工作并没有让你感到过于吃力,但现在事情将开始变得更加复杂。不过,在之前,让我们整理一下到目前为止我们所做的工作。

不幸的是,运行列表 11.4 给我们留下了一个具有透明背景的捕获图像。为了解决这个问题,我们必须将我们的可视化背景颜色设置为纯色。在列表 11.5a 和 11.5b 中,你可以看到我是如何使用 CSS 将 body 元素的背景设置为白色的。这使得我们的背景不透明。

列表 11.5a 设置网页背景(listing-11.5/public/app.css)

body {
 background: white;    ①  
} 

列表 11.5b 将 app.css 添加到基于浏览器的可视化中(listing-11.5/public/index.html)

<!doctype html>
<html lang="en">
    <head>
        <title>NYC average yearly temperature</title>

        <link href="bower_components/c3/c3.css" rel="stylesheet">
 <link href="app.css" rel="stylesheet">    ①  
    </head>
    <body>
        <div id='chart'></div>

        <script src="bower_components/jquery/dist/jquery.js"></script>
        <script src="bower_components/d3/d3.js"></script>
        <script src="bower_components/c3/c3.js"></script>
        <script src="bower_components/papaparse/papaparse.js"></script>
        <script src="app.js"></script>
    </body>
</html> 

当我们更新你的可视化 CSS 时,我希望你意识到我们在这里处理的是一个正常的网页,我们可以添加任何可能添加到任何其他网页上的内容:JavaScript、CSS、其他 Bower 模块等。你可以使用这种技术来捕获任何可以放在网页上的内容。

在我们继续之前,我还想重构我们当前的代码,以便我们有一个可重用的工具函数来捕获网页。我现在这么做是因为这是一个方便的方式,可以在本章的其余部分重用和扩展这段代码。以下列表显示了重构后的函数 captureWebPage,我们可以使用它来捕获给定 URL 的任何网页。

列表 11.5c 用于服务器端图表渲染的可重用工具包函数(listing-11.5/toolkit/capture-web-page.js)

const Nightmare = require('nightmare');

function captureWebPage (urlToCapture,
 captureElementSelector, outputImagePath) {    ①  

 const nightmare = new Nightmare();    ②  
 return nightmare.goto(urlToCapture)    ③  
 .wait(captureElementSelector)    ④  
 .screenshot(outputImagePath)    ⑤  
 .end();    ⑥  
};

module.exports = captureWebPage;    ⑦   

以下列表展示了我们如何使用我们新的工具包函数 captureWebPage 来捕获我们的可视化。

列表 11.5d 使用我们的可重用工具包函数来渲染服务器端图表(listing-11.5/index.js)

const webServer = require('./web-server.js');
const captureWebPage = require('./toolkit/capture-web-page.js');    ①  

webServer.start()
    .then(server => {
        const urlToCapture = "http://localhost:3000";
        const outputImagePath = "./output/nyc-temperatures.png";
 return captureWebPage(urlToCapture, "svg", outputImagePath)    ②  
            .then(() => server.close());
    })
    .then(() => {
        console.log("All done :)");
    })
    .catch(err => {
        console.error("Something went wrong :(");
        console.error(err);
    }); 

现在我们有了可重用代码模块的骨架,让我们进行改进,并解决其几个缺陷。

11.4.9 捕获完整可视化

如果你仔细检查我们迄今为止捕获的可视化,你可能注意到我们在图表周围捕获了额外的非必要空间!这是因为我们在捕获整个浏览器的可见区域。我们想要的是将截图限制在图表的确切区域。

或者,如果我们的图表更大,它就不会适合在浏览器的可见区域内。此外,在我们的捕获图像中,我们会看到浏览器的滚动条,并且只有图表的一部分是可见的。

为了解决这些问题,我们需要做两件事:

  1. 将浏览器可见区域扩展,使其完全包含图表(这样我们就不捕获任何滚动条)。

  2. 将截图限制在图表区域(这样我们就不捕获任何额外空间)。

我们对这个问题的解决方案很复杂,因为我们现在必须在无头浏览器中执行代码来确定图表的大小和网页的大小。

列表 11.6 是一个扩展的代码示例,可以捕获无论大小如何的整个图表。注意我们如何使用 evaluate 函数在无头浏览器中执行 JavaScript 代码。这段代码确定图表的大小和网页的可滚动区域。然后,Nightmare 将这些数据从无头浏览器进程复制回 Node.js,以便我们可以使用它。

我们现在调用 viewport 函数来扩展浏览器的视口,并使网页的整个可滚动区域可见。这从我们的捕获图像中移除了滚动条。

我们还修改了对 screenshot 的调用,传递了一个矩形,定义了我们想要捕获的网页部分。这限制了截图,使其只捕获图表,而不捕获网页上的其他任何内容。

列表 11.6 捕获整个图表(listing-11.6/toolkit/capture-web-page.js)

function captureWebPage (urlToCapture, captureElementSelector, outputImagePath) {

    const nightmare = new Nightmare();
    return nightmare.goto(urlToCapture)
        .wait(captureElementSelector)
 .evaluate(captureElementSelector => {    ①  
 const body = document.querySelector("body");    ②  
 const captureElement =    ③  
 document.querySelector(captureElementSelector);    ③  
 const captureRect =    ④  
 captureElement.getBoundingClientRect();    ④  
 return {    ⑤  
 documentArea: {    ⑥  
                    width: body.scrollWidth,
                    height: body.scrollHeight
                },
 captureArea: {    ⑦  
                    x: captureRect.left,
                    y: captureRect.top,
                    width: captureRect.right - captureRect.left,
                    height: captureRect.bottom - captureRect.top
                }
 };
        }, captureElementSelector)
 .then(pageDetails => {    ⑧  
 return nightmare.viewport(    ⑨  
 pageDetails.documentArea.width,    ⑨  
 pageDetails.documentArea.height    ⑨  
 )    ⑨  
 .screenshot(outputImagePath, pageDetails.captureArea)    ⑩  
                .end();
        });
}; 

注意我们如何将 captureElementSelector 传递给 evaluate 函数。这允许我们在浏览器代码中使用这个变量,而通常这个变量与 Node.js 代码是隔离的。无头浏览器在单独的进程中运行,所以我们不能直接从浏览器代码中访问 Node.js 变量。任何在浏览器代码中需要的数据都必须作为参数传递给 evaluate 函数。

向图表中添加数据

现在,我们终于可以重新创建第九章中使用的 renderLineChart 函数了。我们已经有了一切所需来在 Node.js 下渲染和捕获图表;现在我们需要将其打包成一个函数,我们可以用要可视化的数据来调用它。

我们在第九章中使用的函数是基于 c3-chart-maker,这是一个可在 npm 上找到的代码模块,您可以将它集成到自己的 Node.js 应用程序中,用于服务器端渲染 C3 图表。然而,为了学习,我们在这里不会使用 c3-chart-maker。我们将从头开始实现,基于我们已经学到的所有内容。

我们已经有了网络服务器和折线图的可视化。我们有来自 列表 11.6 的 captureWebPage 函数,我们可以用它将我们的可视化渲染到图像文件中。让我们调整这些,以便我们可以混合我们想要的任何数据。为了实现这一点,我们必须充分利用我们的自定义网络服务器。我们将数据输入到网络服务器,然后它将数据传递给可视化。

这些更改将贯穿我们的代码。首先,我们需要更改我们的网络应用(如下列表所示),使其能够从网络服务器接收数据(以及图表大小)。

列表 11.7a 修改我们的网络应用以从我们的 Node.js 应用中检索数据(列表-11.7/toolkit/template-chart/public/app.js)

function renderChart (bindto, data, size) {
    var chart = c3.generate({
        bindto: bindto,
        size: size,
 data: data,    ①  
        transition: {
            duration: 0
 }
    });
};

$(function () {

 $.get("chart-data")    ②  
        .then(function (response) {
            renderChart("#chart", response.data, response.chartSize);
        })
        .catch(function (err) {
            console.error(err);
        });
}); 

接下来,我们必须修改我们的网络服务器,使其能够传递数据(以及图表大小),然后通过 REST API 将其暴露给网络应用(见以下列表)。

列表 11.7b 修改网络服务器以将 C3 数据对象传递给网络应用(列表-11.7/toolkit/template-chart/web-server.js)

const express = require('express');
const path = require('path');

module.exports = {
 start: (data, chartSize) => {    ①  
        return new Promise((resolve, reject) => {
            const app = express();

            const staticFilesPath = path.join(__dirname, "public");
            const staticFilesMiddleWare = express.static(staticFilesPath);
            app.use("/", staticFilesMiddleWare);

 app.get("/chart-data", (request, response) => {    ②  
                response.json({
                    data: data,
                    chartSize: chartSize,
                });
            });

 const server = app.listen(3000, err => {    ③  
                if (err) {
                    reject(err);
                }
                else {
                    resolve(server);
                }
            });
        });
    }
} 

现在,我们可以通过网络服务器将数据传递给我们的折线图,我们可以创建我们的 renderLineChart 函数。正如您在 列表 11.7c 中可以看到的,这个函数接受数据、图表大小以及渲染图像文件输出路径。它与本章中看到的内容类似:启动网络服务器(但这次向其中输入数据)然后使用 Nightmare 捕获网页。

列表 11.7c 新的 toolkit 函数 renderLineChart,可以将数据集渲染成图表(列表-11.7/toolkit/charts.js)

const webServer = require('./template-chart/web-server.js');
const captureWebPage = require('./capture-web-page.js');

function renderLineChart (data, chartSize, outputImagePath) {    ①  
    return webServer.start(data, chartSize)
        .then(server => {
            const urlToCapture = "http://localhost:3000";
            return captureWebPage(urlToCapture, "svg", outputImagePath)
                .then(() => server.close());
        });
};

module.exports = {
    renderLineChart: renderLineChart,

    // ... You can add functions for other chart types here ...
}; 

最后要做的事情是向您展示如何使用这个新函数。以下列表通过将硬编码的数据输入到我们新的 renderLineChart 函数中来演示该函数。您可以运行此代码并检查写入到 output 子目录中的图像文件。

列表 11.7d 调用新的 renderLineChart 工具包函数(列表-11.7/index.js)

const charts = require('./toolkit/charts.js');

const chartSize = {    ①  
    width: 600,
    height: 300
};

const myData = {    ②  
 json:   [  ③  
        {
          "Year": 1917,
          "AvgTemp": 10.54724518
        },
        {
          "Year": 1918,
          "AvgTemp": 11.82520548
        },

 // ... Much data omitted ...
    ],
    keys: {
        x: "Year",
        value: [
            "AvgTemp"
        ]
    }
};

const outputImagePath = "./output/my-output-file.png";

charts.renderLineChart(myData, chartSize, outputImagePath)    ④  
    .then(() => {
        console.log("Line chart renderered!");
    })
    .catch(err => {
        console.error("Failed to render line chart.");
        console.error(err);
    }); 

现在,我们有一个在 Node.js 下渲染折线图的可重用函数!我们为此付出了很多努力,但我们的新函数简单易用。我们可以用不同的数据集反复使用它,并从中获得我们为使此函数成为可能所做出的投资的回报。

尽管我们还可以进行改进,但重要的是我们有一个可以工作的事物!我相信在追求完美之前,拥有一个可以工作的事物总是更好的。

你可以轻松地修改 renderLineChart 并创建自己的工具函数来渲染不同类型的图表,或者添加不同的配置选项,或者控制图表的外观和功能。请随意实验,看看你可以将其带到哪里!

多页报告

到目前为止,我们只从网页中捕获了一个图表。如果我们能够捕获多页信息到 PDF 文件中,那也会很有用——比如说,用于生成数据分析报告。Nightmare 直接支持这一功能,我们可以使用 pdf 函数来捕获多页文档。

让我们复制我们的工具函数 captureWebPage,将其重命名为 captureReport,并做出以下更改,以便我们可以捕获报告:

  1. 我们需要重新构建我们的模板网页以包含多个页面。

  2. 我们调用 pdf 函数而不是 screenshot 函数。

  3. 我们捕获整个网页,而不仅仅是单个图表。

重新构建页面

首先,我们必须将我们的 HTML 文档划分为多个页面。. 每个页面将在输出 PDF 文件中作为单独的页面。在以下列表中,你可以看到我们已经将页面类添加到 CSS 文件中,我们将使用它来定义每个单独的页面。

列表 11.8a 定义页面的额外 CSS(摘自 listing-11.8/public/app.css)

.page {    ①  
 page-break-before: always;    ②  
 width: 29.7cm;    ③  
 height: 21cm;    ③  
} 

我们使用页面类来划分三个独立的页面,如下所示列表,并且我们在每个页面中放置了一个单独的图表。

列表 11.8b 将单独的页面添加到 HTML 文档中(摘自 listing-11.8/public/index.html)

 <body>
 <div class="page">    ①  
            <h1>Page 1</h1>
            <div id='chart1'></div>
        </div>
 <div class="page">    ②  
            <h1>Page 2</h1>
            <div id='chart2'></div>
        </div>
 <div class="page">    ③  
            <h1>Page 3</h1>
            <div id='chart3'></div>
        </div>
    </body> 

调用 pdf 函数并捕获整个页面

列表 11.8c 展示了新的 captureReport 函数,该函数可以将网页渲染为 PDF 文件。我们已经从早期的 captureWebPage 函数复制并改进了这段代码。主要的变化是我们现在正在捕获整个网页,并且我们调用 pdf 函数将其渲染为 PDF 文件。

列表 11.8c 将多页报告渲染为 PDF 文件(摘自 listing-11.8/index.js)

function captureReport (urlToCapture,
 captureElementSelector, outputPdfFilePath) {    ①  

    const nightmare = new Nightmare();
    return nightmare.goto(urlToCapture)
        .wait(captureElementSelector)
        .evaluate(() => {
 const body = document.querySelector("body");    ②  
 return {    ③  
 documentArea: {    ④  
                    width: body.scrollWidth,
                    height: body.scrollHeight
                },
            };
        })
 .then(pageDetails => {    ⑤  
            const printOptions = {
 marginsType: 0,    ⑥  
 pageSize: {    ⑦  
 width: 297000,    ⑧  
 height: 210000,    ⑨  
                },
                landscape: true,
            };
 return nightmare.viewport(    ⑩  
 pageDetails.documentArea.width,    ⑩  
 pageDetails.documentArea.height    ⑩  
 )    ⑩  
 .pdf(outputPdfFilePath, printOptions)    ⑪  
                .end();
        });
}; 

注意我们传递给 pdf 函数的 printOptions。这允许我们控制生成的 PDF 文件的一些方面。我们清除边距(我们现在可以在 CSS 中控制边距),我们设置页面大小(奇怪的是,以微米为单位),并且我们可以设置横幅或纵向方向。

在无头浏览器中调试代码

当我们在可视化代码中遇到问题时会发生什么?我们看不到无头浏览器,而且我们还没有讨论错误处理。我们如何调试可能出现的任何问题?

首先,如果你认为可视化中存在问题,请在真实浏览器中运行它(而不是无头浏览器)。现在你可以使用浏览器的控制台和开发者工具来调试问题,就像处理任何正常的 Web 应用一样。

防止问题的最有效方法是,在你将可视化内容放入无头浏览器之前,对其进行彻底的测试和调试。然而,如果它在普通浏览器中运行正常,但在无头浏览器中出现问题,你将需要使用 Nightmare 的调试功能以及适当的错误处理。

列表 11.9 展示了我们可以如何创建 Nightmare 实例并显示浏览器的窗口(查看正在渲染的内容很有用),以及启用浏览器的开发者工具(Electron 基于 Chromium,因此我们得到与 Chrome 中相同的所有可爱的开发者工具)。这使得我们更容易调试在无头浏览器中发生的问题(因为它不再是那么无头了)。

列表 11.9 创建用于调试的 Nightmare 实例

const nightmare = Nightmare({
 show: true,    ①  
 openDevTools: { mode: "detach" }    ②  
}); 

确保我们能够看到任何可能来自无头浏览器的错误也很重要。我们应该从一开始就包含错误处理,但我不想过早地使事情复杂化。

以下列表将错误处理程序附加到Nightmare实例。现在,任何在无头浏览器中发生的控制台日志记录或错误都会传递回 Node.js,以便我们可以处理它们。

列表 11.10 向 Nightmare 实例添加错误处理

nightmare.on("console", function (type, message) {

 if (type === "log") {    ①  
 console.log("LOG: " + message);    ①  
 return;    ①  
    }

 if (type === "warn") {    ②  
 console.warn("LOG: " + message);    ②  
 return;    ②  
    }

 if (type === "error") {    ③  
 throw new Error("Browser JavaScript error: " + message);    ③  
 }    ③  
}); 

在 Linux 服务器上使其工作

在无头 Linux 服务器上使用 Nightmare 会变得更加复杂。Electron 并不是真正的无头(至少目前还不是),因此它仍然需要一个 framebuffer 来渲染其(不可见的)内容。

如果你在一个基于正常 UI 操作系统的开发工作站上渲染可视化,那么一切都很顺利,你可以将可视化作为数据分析或报告和演示等部分。问题出现在你想要在无头 Linux 服务器上作为自动化过程的一部分捕获可视化时。

假设你有一个用于报告生成的自动化管道(你将在第十二章中看到它是如何工作的)。作为对事件或可能是计划任务的响应,你的 Linux 服务器会聚合数据库中的最近数据,然后你的captureWebPagecaptureReport函数生成一个图像或 PDF 文件。

不幸的是,仅使用 Nightmare 本身是无法实现这一点的,因为你的无头 Linux 服务器(即没有图形用户界面的 Linux)没有 Electron 可以渲染的 framebuffer。正如我之前所说的,Electron 并不是真正的无头,它仍然需要一个地方进行渲染。

幸运的是,我们可以在 Linux 上安装创建虚拟帧缓冲区的软件。我不会介绍如何安装此类软件,因为这可能取决于你的 Linux 版本。但安装好此软件后,我们可以使用 xvfb npm 模块启动虚拟帧缓冲区,这使我们能够从我们的无头 Linux 服务器捕获可视化。

你可以在列表 11.11 中看到这是如何工作的。大部分代码与之前版本的captureWebPage相同,但现在我们在捕获我们的可视化之前启动虚拟帧缓冲区,然后在之后停止它。

如果你想亲自尝试,请使用仓库中列表-11.11 子目录下的 Vagrant 脚本。这个 Vagrant 脚本启动一个 Ubuntu 虚拟机,并安装好 Xvfb 软件,以便你使用。如果你登录到虚拟机,你可以运行以下列表中展示的xvfb-version代码。

列表 11.11 在无头 Linux 服务器上使用虚拟帧缓冲区进行服务器端图表渲染(listing-11.11/xvfb-version/toolkit/capture-web-page.js)

const Nightmare = require('nightmare');
const Xvfb = require('xvfb');    ①  

function captureWebPage (urlToCapture,
    captureElementSelector, outputImagePath) {

    const xvfb = new Xvfb();
 xvfb.startSync();    ②  

    const nightmare = Nightmare();
    return nightmare.goto(urlToCapture)
 .wait(captureElementSelector)
        .evaluate(captureElementSelector => {
            const body = document.querySelector("body");
            const captureElement =
                document.querySelector(captureElementSelector);
            const captureRect = captureElement.getBoundingClientRect();
            return {
                documentArea: {
                    width: body.scrollWidth,
                    height: body.scrollHeight
                },
                captureArea: {
                    x: captureRect.left,
                    y: captureRect.top,
                    width: captureRect.right - captureRect.left,
                    height: captureRect.bottom - captureRect.top
                }
            };
        }, captureElementSelector)
        .then(pageDetails => {
            return nightmare.viewport(
                    pageDetails.documentArea.width,
                    pageDetails.documentArea.height
                )
                .screenshot(outputImagePath, pageDetails.captureArea)
                .end();
        })
 .then(() => xvfb.stopSync());    ③  
}; 

在仓库中,你可以找到 Xvfb 和非 Xvfb 版本的此代码。你可以在无头 Ubuntu 虚拟机上自由尝试非 Xvfb 版本;你会发现,在没有虚拟帧缓冲区的情况下尝试使用 Nightmare 会导致你的脚本挂起。

Xvfb 版本在无头 Ubuntu 虚拟机上确实可以工作。实际上,它只会在安装了 Xvfb 的机器上工作。如果你尝试在例如 Windows PC 上运行它,它会给你错误。

11.5 你可以用无头浏览器做更多的事情

在这个阶段,你可能想知道我们还能用无头浏览器做些什么。在章节的开头,我提到开发者使用无头浏览器的主要原因是进行 Web 应用的自动化测试。在本章中,我们也看到了无头浏览器在 Node.js 下渲染基于浏览器的可视化时的有用性。以下是一些你可能想要使用无头浏览器的原因。

11.5.1 网络爬取

在第四章中,我们简要地提到了网络爬取,我避免了你深入网络爬取时可能会遇到的问题——比如身份验证或在爬取网页之前在网页中执行 JavaScript。无头浏览器是我们将网络爬取提升到下一个层次所需的工具。

我们可以使用 Nightmare 完全模拟我们想要爬取的网页——这意味着在尝试爬取之前,页面中的 JavaScript 已经正常执行。我们还可以与页面进行程序性交互——这意味着我们可以与服务器进行身份验证或准备网页进行爬取所需的其他任何操作。

有一样东西可以使这更加容易。我们可以安装 Daydream Chrome 扩展。这允许我们使用网页并同时记录我们的 Nightmare 脚本操作。我们实际上可以排练并回放任何可能需要执行以使网络爬取成为可能的操作序列。

11.5.2 其他用途

我们可以使用无头浏览器执行许多其他任务,例如捕获文档和营销的屏幕截图或为我们的网站预渲染可视化(可能是作为构建过程的一部分)。我们还可能用它来封装遗留网页作为 API。我相信你可以想象出无头浏览器在其他方面的用途,因为它是你工具箱中一个非常有用的工具。

我们已经完成了整个循环!在第九章,我们学习了在 Node.js 的帮助下使用几个工具函数进行数据分析,直接渲染图表。在第十章,我们学习了如何使用 C3 图表库创建在浏览器中运行的图表。在本章中,我们学习了如何从 Node.js 渲染可视化,甚至如何在无头 Linux 服务器上这样做。我们现在能够将任何网页捕获为图像或 PDF 文件。

学习了这项技术后,我们现在理解了第九章中图表渲染函数是如何工作的,并且我们可以创建自己的函数来渲染任何基于浏览器的可视化。我们可以轻松地生成可能对我们业务所需的报告。在第十二章中,我们将探讨如何在实时数据管道中使用自动化报告。

摘要

  • 你学习了如何使用 Nightmare 在 Node.js 下将图表捕获为图像。

  • 你看到了如何将多页报告捕获为 PDF 文档。

  • 你知道你必须使用 Xvfb 来创建一个虚拟帧缓冲区,这样你就可以在无头 Linux 服务器上运行 Nightmare。

  • 你了解到无头浏览器可以将你的网络爬取提升到下一个层次。

12

实时数据

本章涵盖

  • 与实时数据流一起工作

  • 通过 HTTP POST 和套接字接收数据

  • 使用基于事件的架构解耦服务器模块

  • 触发短信警报并生成自动报告

  • 通过 socket.io 将新数据发送到实时图表

在本章中,我们将汇集我们已经学习到的数据整理的多个方面,并将它们组合成一个实时数据管道。我们将构建几乎是一个真实生产系统的东西。这是一个将执行所有常规事情的数据管道:获取和存储数据(第三章),清理和转换数据(第六章),此外,还会进行即时数据分析(第九章)。

系统的输出将采取几种形式。最令人兴奋的是基于浏览器的可视化,基于第十章的工作,但会有实时数据在观看时流入和更新。它将自动生成一份日报(使用第十一章的技术),并将其发送给感兴趣的各方。它还将就系统收到的异常数据点发出短信警报。

当然,我们现在要构建的系统将是一个玩具项目,但除此之外,它将展示许多你希望在真实系统中看到的特性,并且在小规模上,它可以在真实的生产环境中工作。

这将是迄今为止最复杂的章节之一,但请坚持下去!我可以向你保证,达到实时可视化将是值得的。

12.1 我们需要一个预警系统

对于许多城市来说,监测空气质量很重要,在有些国家,它甚至受到政府的监管。无论是由什么原因造成的,空气污染都可能成为一个真正的问题。在 2016 年的澳大利亚墨尔本,发生了一起媒体称之为雷暴哮喘的事件。

一场大风暴袭击了这座城市,风和湿度的结合导致花粉破裂并分散成鼻子无法过滤的微小颗粒。患有哮喘和过敏症的人处于高风险。在接下来的几个小时里,紧急服务部门被大量电话淹没。成千上万的人生病了。在随后的那一周里,有九人死亡。某种预警系统可能有助于帮助公众和紧急服务部门为即将到来的危机做好准备,所以让我们尝试构建类似的东西。

在本章中,我们将构建一个空气质量监控系统。它将相对简化,但至少是一个完整生产系统的良好起点。我们正在构建一个预警系统,它必须在检测到空气质量不佳时立即发出警报。

我们在这里的目标是什么?我们的实时数据管道将从假设的空气质量传感器接受连续的数据流。我们的系统将具有三个主要功能:

  • 允许通过实时图表持续监测空气质量

  • 为了自动生成每日报告并将其发送给感兴趣的相关方

  • 为了持续检查空气质量水平,并在检测到空气质量不佳时发送短信警报

本章全部关于处理实时和动态数据,我们将尝试在一个真实的环境中完成这项工作。在本章中,我们将看到比书中之前看到的更多软件架构,因为我们所做的工作变得更加复杂,我们需要更强大的方式来组织我们的代码。我们将致力于在基于事件架构上构建我们的应用程序。为了模拟我真正如何进行开发,我们将从简单开始,然后在代码的部分重构中引入一个事件中心,这将解耦我们的应用程序组件,并帮助我们管理不断上升的复杂性水平。

12.2 获取代码和数据

本章的代码和数据可在 GitHub 上的 Data Wrangling with JavaScript 第十二章仓库中找到:github.com/data-wrangling-with-javascript/chapter-12. 本章的数据是从昆士兰州政府开放数据网站data.qld.gov.au/获取的.

代码仓库中的每个子目录都是一个完整的示例,并且每个子目录都对应本章中的代码列表。

在尝试运行每个子目录中的代码之前,请确保根据需要安装 npm 和 Bower 依赖项。

请参考第二章中的“获取代码和数据”部分以获取获取代码和数据的帮助。

12.3 处理实时数据

创建实时数据管道与我们之前在书中看到的任何其他事情并没有太大区别,只是现在我们将通过通信渠道接收连续的数据流。图 12.1 给出了简化的整体图。我们将有一个空气污染传感器(我们的数据收集设备),它将每小时将当前的空气质量指标提交到我们的 Node.js 服务器,尽管为了开发和测试,我们会大幅加快这一过程。

c12_01.eps

图 12.1 空气污染传感器将数据推送到我们的 Node.js 服务器。

要深入了解数据流如何融入我们的管道,请参阅图 12.2。数据在图中的左侧数据收集点进入我们的系统。然后数据通过处理管道。你应该能识别出这里的各个管道阶段,并且已经对它们的作用有了概念。输出随后通过警报、可视化和每日报告交付给用户。

c12_02.eps

图 12.2 现在,我们将有一个连续的数据流进入我们的数据处理管道。

12.4 构建空气质量监控系统

在我们深入构建空气质量监测系统之前,让我们看看我们拥有的数据。CSV 数据文件brisbanecbd-aq-2014.csv可在第十二章 GitHub 仓库的data子目录下找到。像往常一样,在我们开始编码之前,我们应该仔细查看我们的数据。你可以在图 12.3 中看到数据文件的摘录。

这份数据是从昆士兰州政府开放数据网站下载的.^(1) 感谢昆士兰州政府支持开放数据。

数据文件包含大气条件的每小时读数。我们感兴趣的指标是 PM10 列。这是直径小于 10 微米的空气中颗粒物的计数。花粉和灰尘是这类颗粒物的例子。为了理解这有多小,你需要知道人类头发的宽度大约是 100 微米,所以 10 个这样的颗粒物可以放在一根人类头发的宽度上。这非常小。

这样小的颗粒物可以被吸入肺部,而较大的颗粒物通常被鼻子、嘴巴和喉咙捕获。PM10 值指定的是质量与体积的比率,在这种情况下是每立方米微克(µg/m³)。

c12_03.eps

图 12.3 本章的数据。我们关注的是 PM10 列,用于监测空气质量。

注意图 12.3 中突出显示的 PM10 的较大值。在这些时候,我们的大气颗粒物水平可能存在问题。在图 12.4 的图表中,我们可以很容易地看到中午 12 点到下午 3 点之间的峰值——这是空气质量比正常更差的时候。图 12.4 还显示了本章我们将制作的图表。

为了我们空气质量监测系统的目的,我们将任何超过 80 的 PM10 值视为空气质量差,并值得发出警报。我从维多利亚环境保护局(EPA Victoria)的空气质量类别表中选取了这个数字。

我们的系统将是什么样子?你可以在图 12.5 中看到整个系统的示意图。我现在向你展示这个系统图,是为了让你提前了解我们的方向。我不期望你立刻就能理解这个系统的所有部分,但你可以把它看作是我们正在创建的地图,请在本章中不时地参考它来定位自己。

c12_04.eps

图 12.4 绘制 PM10 值,我们可以看到中午 12 点和下午 3 点之间的大峰值。

我已经告诉你,这将是书中最复杂的项目!然而,与大多数实际生产系统相比,这个系统将会很简单。尽管我们只将检查这个整体的部分,但它将包含图中显示的所有部件。在章节结束时,我将向您展示完成系统的代码,以便您在空闲时间自行研究。

我们的系统从由空气污染传感器产生的数据开始(如图 12.5 左边的所示)。该传感器检测空气质量,并以每小时一次的间隔将数据馈送到数据收集点。我们必须做的第一件事是将数据存储到我们的数据库中。最糟糕的事情就是丢失数据,因此首先确保数据安全是非常重要的。然后数据收集点会触发传入数据事件。这就是我们的基于事件架构发挥作用的地方。它允许我们创建关注点的分离,并将我们的数据收集与下游数据操作解耦。在图 12.5 的右边,我们看到系统的输出,包括短信警报、日报和实时可视化。

c12_05.eps

图 12.5 我们空气质量监测系统的示意图

12.5 开发设置

要构建这个系统,我们必须创建一个运行它的某种人工支架。你可能没有实际的颗粒物传感器在手——尽管如果你对这个示例项目特别感兴趣,你实际上可以以合理的价格购买这些传感器。

相反,我们将使用 JavaScript 创建一种模拟传感器来模拟真实传感器。我们将编写的代码可能非常接近真实产品的样子。例如,如果我们能将 Raspberry PI 连接到真实传感器并安装 Node.js,我们就可以运行可能与我们要构建的模拟传感器相似的代码。

我们没有真实的传感器,因此我们需要为模拟传感器提供预录制的数据来“生成”并馈送到我们的监控系统。我们已经有了真实的数据,如图 12.3 所示,尽管这些数据是按小时记录的。如果我们想以现实的方式使用它,那么我们的工作流程将会很慢,因为我们必须等待一个小时才能得到每个新的数据点。

为了提高效率,我们需要加快这个流程。我们不会让数据以每小时一次的间隔传入,而是每秒传入一次。这就像加快时间并观看系统以快进的方式运行。除了这种时间操作外,我们的系统将以现实的方式运行。

本章每个代码列表都在 Chapter-12 GitHub 仓库下的一个子目录中。在每个列表的目录下,您将找到一个客户端和一个服务器目录。您可以在图 12.6 中了解其结构。

c12_06.eps

图 12.6 第十二章代码列表的项目结构

对于每个代码列表,模拟传感器,我们的数据收集设备,位于客户端子目录中,我们不断发展的空气质量监控系统位于服务器子目录中。要跟随代码列表,您需要打开两个命令行窗口。在第一个命令行中,您应该按照以下方式运行服务器:

cd listing-12.1
cd server
node index.js 

在第二个命令行中,您应该按照以下方式运行客户端(模拟传感器):

cd listing-12.1
cd client
node index.js 

客户端和服务器现在都在运行,客户端正在向服务器提供数据。在查看下一个代码列表时,根据你的位置更改列表编号。在尝试运行每个代码列表之前,请确保安装了 npm 和 Bower 依赖项。

12.6 实时流数据

我们必须解决的首要问题是如何将我们的传感器连接到我们的监控系统。在接下来的章节中,我们将介绍两种基于网络的机制:HTTP POST 和套接字。这两种协议都建立在 TCP 网络协议之上,并且直接由 Node.js 支持。你选择哪种协议取决于你期望数据提交的频率。

12.6.1 频繁数据提交的 HTTP POST

让我们先看看通过 HTTP POST 提交数据。当数据提交不频繁或临时时,我们可以使用这个方法。它也是最简单的,因此是一个很好的起点。图 12.7 展示了我们的空气污染传感器将如何向我们的 Node.js 服务器发送单个数据包。在这种情况下,我们的数据收集点,即数据到达我们服务器的人口,将是一个 HTTP POST 请求处理器。从那里,数据被输入到我们的实时数据管道中。

c12_07.eps

图 12.7 使用 HTTP POST 向我们的服务器发送单个数据包。

到目前为止,我们的代码将非常简单。首先,我们希望从模拟传感器开始将数据流移动到我们的 Node.js 服务器。你可以运行这段代码,但你必须按正确的顺序启动它——首先是服务器,然后是客户端(模拟传感器)。我们的 Node.js 服务器接收数据并将其打印到控制台(如图 12.8 所示)。我们开始得很简单,目前就做这么多。我们这样做是为了检查我们的数据是否正确地到达了我们的服务器。

c12_08.png

图 12.8 使用 HTTP POST 接收数据时显示的输出。

Node.js 直接支持 HTTP POST,但在这个例子中,我们将使用 request-promise,一个高级库,使这个过程变得更容易,并且将我们的 HTTP 请求包装在承诺中。

如果你已经安装了依赖项,那么你的项目中已经安装了 request-promise;否则,你可以在新的 Node.js 项目中这样安装它:

npm install --save request-promise 

以下列表显示了我们的第一个模拟空气污染传感器的代码。它读取我们的示例 CSV 数据文件。每秒它读取下一行数据,并使用 HTTP POST 将其提交到服务器。

列表 12.1a 通过 HTTP POST 向服务器提交数据的空气污染传感器(列表-12.1/client/index.js)

const fs = require('fs');
const request = require('request-promise');
const importCsvFile = require('./toolkit/importCsvFile.js');

const dataFilePath = "../../data/brisbanecbd-aq-2014.csv";    ①  
const dataSubmitUrl = "http://localhost:3000/data-collection-point";    ②  

importCsvFile(dataFilePath)    ③  
    .then(data => {
        let curIndex = 0;

 setInterval(() => {    ④  

 const outgoingData = Object.assign({}, data[curIndex]);    ⑤  
 curIndex += 1;    ⑥  

 request.post({    ⑦  
 uri: dataSubmitUrl,    ⑧  
 body: outgoingData,    ⑨  
 json: true    ⑩  
               });

            }, 1000);
    })
    .catch(err => {
        console.error("An error occurred.");
        console.error(err);
    }); 

在服务器端,我们使用 express 库通过 HTTP POST 接收传入的数据。就像我们使用 request-promise 一样,我们使用 express 库让我们的生活变得简单一些。Node.js 已经拥有我们构建 HTTP 服务器所需的一切,但使用像 express 这样的高级库来简化并精简我们的代码是一种常见的做法。

再次强调,如果你已经安装了依赖项,那么你已经有 express 库了;否则,你可以按照以下方式安装它和 body-parser 中间件:

npm install --save express body-parser 

我们使用 body-parser 中间件在接收到 HTTP 请求体时从 JSON 中解析它。这样我们就不必自己进行解析。它将自动完成。

列表 12.1b 展示了一个简单的 Node.js 服务器代码,该服务器使用 URL 数据收集点接收数据。我们将传入数据打印到控制台以检查其是否正确通过。

列表 12.1b 可以通过 HTTP POST 接收数据的 Node.js 服务器(列表-12.1/server/index.js)

const express = require('express');
const app = express();
const bodyParser = require('body-parser');

app.use(bodyParser.json());    ①  

app.post("/data-collection-point", (req, res) => {    ②  
 console.log(req.body);    ③  
 res.sendStatus(200);    ④  
});

app.listen(3000, () => { // Start the server.
    console.log("Data collection point listening on port 3000!");
}); 

我们现在有一个机制,允许我们接受不频繁或临时的数据馈送。如果我们只按小时接收传入数据——就像在现实生活中的系统中那样,这已经足够好了。但鉴于我们每秒都在发送数据,而且这也是进行更多网络编码的借口,让我们看看如何使用套接字来接受高频实时数据馈送到我们的服务器。

12.6.2 用于高频数据提交的套接字

现在,我们将把我们的代码转换为使用套接字连接,这在数据提交频率较高时是一个更好的选择。我们将创建传感器和服务器之间的长连接通信通道。通信通道也是双向的,但在这个例子中我们不会使用它,尽管你可以稍后使用它来向你的传感器发送命令和状态,如果这是你的系统设计所需的话。

c12_09.eps

图 12.9 使用长连接套接字接收连续且高频的流数据到我们的服务器。

图 12.9 展示了我们将如何将套接字连接集成到我们的系统中。这看起来与我们在 HTTP POST 中所做的工作类似,尽管它显示我们将有一个数据流通过并到达套接字处理器,这取代了 HTTP POST 处理器,并成为我们的新数据收集点。

在以下列表中,我们将从列表 12.1a 中的模拟传感器进行适配,使其将输出数据写入套接字连接。除了连接设置和对socket.write的调用外,此列表与列表 12.1a 类似。

列表 12.2a 通过套接字连接提交数据到服务器的空气污染传感器(列表-12.2/client/index.js)

// ... initial setup as per listing 12.1a ...

const serverHostName = "localhost";    ①  
const serverPortNo = 3030;    ①  

const client = new net.Socket();
client.connect(serverPortNo, serverHostName, () => {    ②  
    console.log("Connected to server!");
});

client.on("close", () => {    ③  
    console.log("Server closed the connection.");
});

importCsvFile(dataFilePath)    ④  
    .then(data => {
        let curIndex = 0;

 setInterval(() => {    ⑤  

                const outgoingData = Object.assign({}, data[curIndex]);
                curIndex += 1;

 const outgoingJsonData = JSON.stringify(outgoingData);    ⑥  

 client.write(outgoingJsonData);    ⑦  

            }, 1000);
    })
    .catch(err => {
        console.error("An error occurred.");
        console.error(err);
    }); 

在列表 12.2b 中,我们有一个新的 Node.js 服务器,它监听网络端口并接受传入的套接字连接。当我们的模拟传感器(客户端)连接时,我们为套接字的data事件设置处理器。这就是我们拦截传入数据的方式;我们也开始看到我之前提到的基于事件的架构。在这个例子中,就像之前一样,我们将数据打印到控制台以检查其是否正确通过。

列表 12.2b 通过套接字连接获取实时数据(listing-12.2/server/index.js)

const net = require('net');

const serverHostName = "localhost";    ①  
const serverPortNo = 3030;    ①  

const server = net.createServer(socket => {    ②  
    console.log("Client connected!");

 socket.on("data", incomingJsonData => {    ③  

 const incomingData = JSON.parse(incomingJsonData);    ④  

        console.log("Received: ");
 console.log(incomingData);    ⑤  
    });

 socket.on("close", () => {    ⑥  
        console.log("Client closed the connection");
    });

 socket.on("error", err => {    ⑦  
        console.error("Caught socket error from client.");
        console.error(err);
    });
});

server.listen(serverPortNo, serverHostName, () => {    ⑧  
    console.log("Waiting for clients to connect.");
}); 

注意我们是如何以 JSON 数据格式通过网络发送数据的。我们在 HTTP 示例中也这样做过,但在那种情况下,request-promise(在客户端)和 express(在服务器端)为我们做了大量的工作。在这种情况下,我们在将数据推送到网络之前(在客户端)手动将数据序列化为 JSON,然后在另一端出来时(在服务器端)手动反序列化。

12.7 配置重构

到目前为止,我们的服务器代码很简单,但很快复杂性将开始急剧上升。让我们花点时间进行一次重构,这将干净地分离我们的配置和代码。我们不会走得太远;这只是一个简单的重构,将帮助我们保持应用程序的整洁,随着其增长。

目前我们唯一的配置是从 列表 12.2b 中的套接字服务器设置细节。我们将把这些移动到一个单独的配置文件中,如图 12.10 所示。这将是一个集中配置应用程序的地方,我们稍后需要去更改其配置。

c12_10.png

图 12.10 我们 Node.js 项目的新的配置文件

列表 12.3a 展示了我们为项目设置的一个简单起始配置。你可能会问,“为什么要费这个劲?”嗯,这是因为我们还有一大堆配置细节尚未到来。数据库、短信警报和报告生成都需要它们自己的配置,把所有这些集中在一个地方是很方便的。

列表 12.3a 向 Node.js 项目添加简单的配置文件(listing-12.3/server/config.js)

module.exports = {
    server: {
 hostName: "localhost",    ①  
 portNo: 3030    ①  
    }
}; 

列表 12.3b 展示了我们如何加载和使用配置文件。这里没有复杂的地方;我们的配置是一个普通的 Node.js 代码模块,具有导出的变量。这是一种简单方便的方法来开始向你的应用程序添加配置。我们花费很少的时间来设置这个,而且从长远来看很有用。

列表 12.3b 修改 Node.js 服务器以加载和使用配置文件(listing-12.3/server/index.js)

const net = require('net');
const config = require('./config.js');    ①  

const server = net.createServer(socket => {
    // ... code omitted, same as listing 12.1b ...
});

server.listen(config.server.portNo, config.server.hostName, () => {    ②  
    console.log("Waiting for clients to connect.");
}); 

你可能会想知道我为什么选择使用 Node.js 代码模块作为配置文件。嗯,我的第一个想法是为了简单。通常,在生产环境中,我使用 JSON 文件来做这类事情,这在这个例子中也同样简单。信不信由你,你可以在 Node.js 中以与要求 JavaScript 文件相同的方式要求 JSON 文件。例如,你也可以这样做:

const config = require('./config.json'); 

你可以这样做真是太酷了:这是一个简单而有效的方法将数据和配置加载到你的 Node.js 应用程序中。但这也让我想到,使用 JavaScript 作为配置文件意味着你可以包含注释!这是一种很好的方式来记录和解释配置文件,而且这是你通常无法用 JSON 文件做到的。(你有多少次希望能在 JSON 文件中添加注释?!)

你有更多可扩展和更安全的存储配置的方法,但简单性满足了我们的需求,我们将在第十四章再次涉及这一点。

12.8 数据捕获

现在,我们已准备好对我们的数据进行一些操作,我们首先应该确保它是安全和安全的。我们应该立即将其捕获到我们的数据库中,这样我们就不会丢失它。

图 12.11 展示了此时我们的系统看起来是什么样子。我们有来自传感器的数据进入,数据到达数据收集点,然后存储在我们的数据库中以备安全使用。

这次,在我们运行代码之后,我们使用数据库查看器,如 Robomongo,来检查我们的数据是否安全地到达了我们的数据库(见图 12.12)。

c12_11.eps

图 12.11 在采取任何进一步行动之前立即将接收到的数据存储到我们的数据库中。

c12_12.eps

图 12.12 使用 Robomongo 检查我们的数据是否已捕获到数据库中

要连接到数据库,我们需要从某处获取数据库连接详细信息。在以下列表中,我们已经将这些信息添加到我们的配置文件中。

列表 12.4a 将数据库连接详细信息添加到配置文件(列表-12.4/server/config.js)

module.exports = {
    server: {
        hostName: "localhost",
        portNo: 3030
    },

    database: {
 host: "mongodb://localhost:27017",    ①  
 name: "air_quality"    ①  
    }
}; 

注意,当我们连接到 MongoDB 时,使用的是默认端口 27017,如列表 12.4a 所示。这假设你在你的开发电脑上安装了 MongoDB 的默认版本。如果你想尝试运行此代码,你需要安装 MongoDB;否则,你可以启动位于 Chapter-8 Github 仓库 vm-with-empty-db 子目录中的 Vagrant VM。启动该 VM 将为你提供一个端口为 6000 的空 MongoDB 数据库,用于本章中的代码示例。确保你修改代码以引用正确的端口号。例如,在列表 12.4a 中,你需要将连接字符串从mongodb://localhost:27017更改为mongodb://localhost:6000。有关 Vagrant 的帮助,请参阅附录 C。

以下列表展示了连接到 MongoDB 并将接收到的数据存储在数据收集点的代码。

列表 12.4b 将传入数据存储到 MongoDB 数据库(列表-12.4/server/index.js)

const mongodb = require('mongodb');
const net = require('net');
const config = require('./config.js');

mongodb.MongoClient.connect(config.database.host)    ①  
    .then(client => {
 const db = client.db(config.database.name);    ②  
 const collection = db.collection("incoming");    ③  

        console.log("Connected to db");

        const server = net.createServer(socket => {
            console.log("Client connected!");

 socket.on("data", incomingJsonData => {
                console.log("Storing data to database.");

                const incomingData = JSON.parse(incomingJsonData);

 collection.insertOne(incomingData)    ④  
 .then(doc => {    ⑤  
                        console.log("Data was inserted.");
                    })
 .catch(err => {    ⑥  
                        console.error("Error inserting data.");
                        console.error(err);
                    });
            });

            socket.on("close", () => {
                console.log('Client closed the connection');
            });

            socket.on("error", err => {
                console.error("Caught socket error from client.");
                console.error(err);
            });
        });

        server.listen(config.server.portNo, config.server.hostName, () => {
            console.log("Waiting for clients to connect.");
        });
    }); 

我们在接收到数据后立即将其存储在数据库中是一个设计决策。我相信这些数据很重要,我们不应该在将其安全存储之前对它进行任何初始处理。我们很快将再次讨论这个想法。

12.9 基于事件的架构

让我们现在看看我们如何更好地随着时间的推移改进我们的应用程序。我想有机会展示我们如何部署设计模式来结构化我们的应用程序并帮助管理其复杂性。

你可能会认为我过度设计了这个简单的玩具应用,但我想向你展示的是,关注点的分离和组件解耦如何为我们构建一个坚实、可靠和可扩展的应用程序奠定基础。随着我们逐步增加复杂性,并在章节末尾达到完整的系统,这一点应该会变得明显。

图 12.13 展示了我们将如何使用事件中心来解耦我们的数据收集与任何下游数据处理操作;例如,更新 可视化,它负责将传入数据转发到网页浏览器中的实时图表。

c12_13.eps

图 12.13 事件处理架构允许我们解耦我们的代码模块。

事件中心就像是我们事件的导管:数据收集点引发传入数据事件,更新可视化事件处理器对其做出响应。有了这种基础设施,我们现在可以轻松地添加新的下游数据操作来扩展系统。

图 12.14,例如,展示了我们将如何接入一个短信警报模块,以便我们的系统在检测到空气质量不佳时发出警报。

c12_14.eps

图 12.14 我们现在可以扩展我们的系统,添加新的下游操作,而无需重构或重新结构化数据收集点。

使用这种基于事件的架构,我们可以在其上挂起新的代码模块。我们增加了一个自然的扩展点,可以在此处接入新的事件源和事件处理器。这意味着我们已经设计了一个可升级的应用程序。我们现在能够更好地修改和扩展我们的应用程序,而不会将其变成一团糟的意大利面代码——至少这是我们的目标。我不会声称保持一个不断发展的应用程序处于控制之下很容易,但像这样的设计模式可以有所帮助。

在这个项目中对我们来说重要的是,我们可以添加新的代码模块,如更新可视化SMS 警报,而无需修改我们的数据收集点。为什么现在这里很重要?嗯,我想指出,我们数据的安全性至关重要,我们必须在发生任何其他事情之前确保它是安全可靠的。每次我们对数据收集点进行代码更改时,我们都有破坏这段代码的风险。我们迫切需要最小化我们对这段代码未来所做的更改,基于事件架构意味着我们可以在不更改数据收集点代码的情况下添加新的代码模块。

除了帮助结构化我们的应用程序并使其更具可扩展性外,基于事件的架构还使得将我们的系统分区变得容易,这样,如果需要扩展,我们可以将应用程序分布到多个服务器或虚拟机上,事件通过电线传输。这种架构可以帮助实现我们在第十四章中将进一步讨论的水平扩展。

事件处理代码重构

让我们重构我们的代码,使其基于事件中心的概念,该中心协调事件的提升和处理。我们将使用 Node.js 的EventEmitter类,因为它是为这类事情设计的。

在列表 12.5a 中,你可以看到我们新的事件中心的代码。这非常简单:整个模块实例化一个EventEmitter并将其导出以供其他模块使用。没有人说这需要复杂,尽管你可以构建一个比这更复杂的更高级的事件中心!

列表 12.5a 创建服务器的事件中心(列表-12.5/server/event-hub.js)

const events = require('events');
const eventHub = new events.EventEmitter();    ①  

module.exports = eventHub;    ②   

现在我们有了事件中心,我们可以将其连接到现有代码。我们首先要做的是在服务器接收到数据时触发传入数据事件。我们通过在事件中心上调用emit函数来完成此操作。

如您从以下列表中的代码摘录中可以看到,事件在数据成功存储在数据库后立即被触发。为了安全起见,我们首先存储数据,然后发生其他所有事情。

列表 12.5b 触发传入数据事件(列表-12.5/server/data-collection-point.js)

incomingDataCollection.insertOne(incomingData)    ①  
    .then(doc => {
 eventHub.emit('incoming-data', incomingData);    ②  
    })
    .catch(err => {
        console.error("Error inserting data.");
        console.error(err);
    }); 

在传入数据事件就绪并在我们有数据到达服务器时被触发后,我们可以开始构建下游数据处理模块。

12.10.1 触发 SMS 警报

我们接下来关心的是实时了解空气质量何时恶化。现在我们可以添加一个事件处理程序来监控传入的 PM10 值,并在检测到空气质量差时发出警报。

为了处理事件,我们首先将事件中心导入到我们的代码中。然后我们调用 on 函数来为名为事件(例如我们刚才添加的 incoming-data 事件)的事件注册一个事件处理函数。这如下所示:检查 PM10 值是否大于或等于配置文件中设置为 80 的最大安全水平。当检测到这样的值时,我们会发出警报并向我们的用户发送短信文本消息。

列表 12.5c 处理事件并在 PM10 超过安全值时触发警报(listing-12.5/server/trigger-sms-alert.js)

const eventHub = require('./event-hub.js');    ①  
const raiseSmsAlert = require('./sms-alert-system.js');    ②  
const config = require('./config.js');

eventHub.on("incoming-data", incomingData => {    ③  
 const pm10Value = incomingData["PM10 (ug/m³)"];    ④  
 const pm10SafeLimit = config.alertLimits.maxSafePM10;    ⑤  
 if (pm10Value > pm10SafeLimit) {    ⑥  
 raiseSmsAlert("PM10 concentration has exceeded safe levels.");    ⑦  
    }
}); 

列表 12.5c 中的代码是添加下游数据操作的一个示例,该操作执行数据分析并安排适当的响应。此代码很简单,但我们可以想象在这里做更复杂的事情,比如检查滚动平均值(见第九章)是否呈上升趋势,或者传入的值是否比正常平均值高出两个标准差(再次见第九章)。如果您已经使用探索性编码(例如我们在第五章或第九章中做的那样)原型化了数据分析代码,您可能可以想象在这个时候将那段代码集成到系统中。

现在如果您运行此代码 (列表 12.5) 并稍等片刻,您将看到触发了“短信警报”。您只需等待片刻即可发生此事件(当中午 12 点到下午 3 点之间的大 PM10 值通过时)。不过,此时发送短信消息的代码已被注释掉,所以您只会看到显示将要发生什么的控制台日志。

要使短信代码生效,您需要在文件 listing-12.5/server/sms-alert-system.js 中取消注释代码。您需要注册 Twilio(或类似服务)并添加您的配置详细信息到配置文件中。同时确保您添加了自己的手机号码,这样短信消息就会发送到您的手机上。完成所有这些操作后,再次运行代码,您将在手机上收到警报。

12.10.2 自动生成日报

让我们看看另一个触发和处理事件的例子。对于下一个特性,我们将添加自动生成的日报。报告不会很复杂;我们将 PM10 的图表渲染到 PDF 文件中,然后将其通过电子邮件发送给我们的用户。但你可以想象这可以做得更深入,比如渲染其他统计数据或附加一个包含最近数据摘要的电子表格。

因为我们希望每天生成报告,我们现在需要一种生成基于时间的事件的方法。为此,我们将在系统中添加一个调度器,并编程它每天触发一次 generate-daily-report 事件。一个独立的日报生成模块将处理事件并完成工作。您可以在 图 12.15 中看到它是如何结合在一起的。

c12_15.eps

图 12.15 我们的调度器每天向系统中输入一个事件以生成日报。

要实现调度器,我们需要一个计时器来知道何时触发事件。我们可以从头开始使用 JavaScript 函数 setTimeoutsetInterval 来构建它。虽然这些函数很有用,但它们也是低级的,我希望我们使用更易于表达和更方便的方法。

触发生成每日报告事件

为了安排我们的基于时间的事件,我们将依赖 npm 中的 cron 库作为我们的计时器。使用这个库,我们可以使用众所周知的 UNIX cron 格式表达计划作业。与任何此类库一样,在 npm 上有许多可用的替代方案;这是我使用的库,但总是好的,要四处看看,以确保你正在使用最适合你自身需求的库。

在 列表 12.6a 中,我们创建了一个 CronJob 实例,其计划是从我们的配置文件中检索的,然后启动作业。这每天调用一次 generateReport,这就是我们触发生成每日报告事件的地方。

列表 12.6a 使用 cron 库触发基于时间的生成每日报告事件(listing-12.6/server/scheduler.js)

const eventHub = require('./event-hub.js');    ①  
const cron = require('cron');    ②  
const config = require('./config.js');

function generateReport () {    ③  
 eventHub.emit("generate-daily-report");    ④  
};

const cronJob = new cron.CronJob({    ⑤  
 cronTime: config.dailyReport.schedule,    ⑥  
 onTick: generateReport    ⑦  
});

cronJob.start();    ⑧   

我们将用于每日定时任务的 cron 格式在配置文件中指定,看起来如下所示:

00 00 06 * * 1-5 

这看起来很神秘,但我们可以从右到左阅读,即星期一到星期五(1-5 天),每月(星号),每月的每一天(下一个星号),零分钟的 6 点,以及零秒。这指定了调用作业的时间。更简洁地说:我们每周工作日早上 6 点生成报告。

这个计划的问题在于测试它需要花费太长时间。我们无法等待整整一天来测试报告生成代码的下一个迭代!正如我们处理传入数据流一样,我们需要加快速度,所以我们将注释掉每日计划(我们将在将此应用程序投入生产时再次需要它)并替换为运行更频繁的计划:

00 * * * * * 

这指定了一个每分钟运行一次的计划(你可以从右到左阅读为每天,每月,每月的每一天,每小时,每分钟,以及该分钟的零秒)。

我们将每分钟生成一个新的报告。这确实是一个快速的节奏,但这也意味着我们有频繁的机会来测试和调试我们的代码。

处理生成报告事件

现在我们准备处理 generate-daily-report 事件并生成并发送报告。以下列表显示了如何处理事件,然后调用辅助函数来完成工作。

列表 12.6b 处理生成每日报告事件并生成报告(listing-12.6/server/trigger-daily-report.js)

const eventHub = require('./event-hub.js');    ①  
const generateDailyReport = require('./generate-daily-report.js');

function initGenerateDailyReport (db) {    ②  
 eventHub.on("generate-daily-report", () => {    ③  
 generateDailyReport(db)    ④  
            .then(() => {
                console.log("Report was generated.");
            })
            .catch(err => {
                console.error("Failed to generate report.");
                console.error(err);
            });
    });
};

module.exports = initGenerateDailyReport; 

生成报告

生成报告与我们在第十一章中学到的类似;实际上,列表 12.6c 是从第十一章中的 列表 11.7 衍生出来的。

在生成报告之前,我们查询数据库并检索要包含在其中的数据。然后我们使用generateReport工具包函数,就像我们在第十一章中做的那样,启动一个带有模板报告的嵌入式 Web 服务器,并使用 Nightmare 捕获报告到 PDF 文件。最终,我们调用我们的辅助函数sendEmail将报告通过电子邮件发送给用户。

列表 12.6c 生成日报并将其发送给感兴趣方(列表-12.6/server/generate-daily-report.js)

const generateReport = require('./toolkit/generate-report.js');    ①  
const sendEmail = require('./send-email.js');    ②  
const config = require('./config.js');

function generateDailyReport (db) {    ③  

    const incomingDataCollection = db.collection("incoming");

 const reportFilePath = "./output/daily-report.pdf";    ④  

 return incomingDataCollection.find()    ⑤  
 .sort({ _id: -1 })    ⑥  
 .limit(24)    ⑦  
        .toArray()
        .then(data => {
 const chartData = {    ⑧  
 xFormat: "%d/%m/%Y %H:%M",    ⑨  
 json: data.reverse(),    ⑩  
                keys: {
                    x: "Date",
                    value: [
                        "PM10 (ug/m³)"
                    ]
                }
            };
 return generateReport(chartData, reportFilePath);    ⑪  
        })
 .then(() => {
 const subject = "Daily report";    ⑫  
 const text = "Your daily report is attached.";    ⑫  
 const html = text;    ⑬  
 const attachments =   [  ⑭  
 {    ⑮  
 path: reportFilePath,    ⑯  
                }
            ];
 return sendEmail(    ⑰  
 config.dailyReport.recipients,    ⑰  
 subject, text, html, attachments    ⑰  
 );    ⑰  
        });
};

module.exports = generateDailyReport; 

要运行列表 12.6 的代码,你需要有一个 SMTP 邮件服务器,你可以用它来发送邮件。通常,我会使用 Mailgun(它有一个免费/试用版本)来做这件事,但你有很多其他的选择,比如 Gmail。你需要访问一个标准的 SMTP 账户,然后可以在配置文件中输入你的 SMTP 用户名和密码以及与报告相关的详细信息。现在你可以运行列表 12.6,并且每分钟它会通过电子邮件发送一份日报给你(请不要让它运行得太久——你会收到很多邮件!)

你现在可能对查看列表-12.6/server/send-email.js 中的代码感兴趣,以了解如何使用Nodemailer库(最优秀的 Node.js 邮件发送库)发送邮件。

实时数据处理

我们将在稍后讨论实时可视化,并完成本章,但在那之前,我想快速谈谈如何将更多的数据处理步骤添加到你的实时管道中。

c12_16.eps

图 12.16 数据采集过程中的数据转换(如果出错,你会丢失数据)

假设你需要添加更多代码来进行数据清理、转换,或者可能是数据分析。最好的放置位置在哪里?

我们可以在存储数据之前,像图 12.16 所示的那样,直接在我们的数据收集点放置这样的代码。显然,我不推荐这样做,因为它会使我们在数据转换出错时面临数据丢失的风险(而且我经验丰富,知道总会有事情出错)。

为了使用我认为最安全的代码结构方式来适当缓解这种风险,我们可以让我们的下游数据操作始终在事件中心的其他一侧发生。我们在触发任何下游工作之前快速且安全地存储数据。如图 12.17 所示,后续操作独立决定如何检索它们所需的数据,并且它们有责任安全地存储任何已修改的数据。

c12_17.eps

图 12.17 数据转换位于存储之后(管理数据采集的一种更安全的方式)。

下游数据操作所需的数据可能通过事件本身传递(就像我们处理 incoming-data 事件那样),或者操作可以完全独立,必须查询数据库本身以找到自己的数据。

如果你现在有需要存储的修改后的数据,你可以覆盖原始数据。然而,我不建议这种方法,因为如果任何潜在的错误出现,你可能会发现你的源数据已被损坏的数据覆盖。更好的解决方案是将转换后的数据存储到不同的数据库集合中;至少这为你提供了对数据破坏性错误的缓冲。

实时可视化

我们终于来到了本章最激动人心的部分,你一直期待的部分:让我们让实时数据流入动态更新的图表。

图 12.18 展示了我们的实时数据图表的外观。当它运行时,你可以坐下来观看每秒(基于我们加速的时间观念)被输入到图表中的新数据点。

c12_18.png

图 12.18 我们将从实时数据流生成的图表

要制作我们的实时更新可视化,我们必须做两件事:

  1. 将初始数据放入图表中。

  2. 当新数据点到达时,将它们输入到图表中。

第一个现在应该对我们来说很熟悉,因为我们已经在第十章和第十一章中看到了如何创建图表。现在我们将添加第二个步骤,并创建一个动态图表,当有新数据可用时,它会自动更新。

我们已经有了实现这一目标所需的部分基础设施。让我们添加一个新的代码模块,更新可视化,来处理传入的数据事件并将新数据点转发到浏览器。看看它在图 12.19 中的组合方式。

c12_19.eps

图 12.19 数据流向实时可视化

如果我写这一章而不提一下 socket.io,那我就疏忽了。这是一个在 JavaScript 中用于实时事件、消息和数据流的热门库。

Socket.io 允许我们在服务器和我们的 Web 应用程序之间打开一个双向通信通道。我们不能使用常规套接字与沙盒 Web 应用程序通信,但 socket.io 使用 Web 套接字,这是一种建立在常规 HTTP 之上的技术,为我们提供了所需的数据流通道,以便将数据流发送到浏览器。Socket.io 还有一个回退模式,如果 Web 套接字不可用,它将优雅地降级为使用常规 HTTP POST 发送我们的数据。这意味着我们的代码将在旧浏览器上工作。

列表 12.7a 展示了托管我们新实时可视化的 Web 服务器的代码。这主要有三个任务:

  • 为 Web 应用程序本身提供资产

  • 为图表提供初始数据

  • 使用我们新的代码模块更新可视化功能注册 Socket.io 连接

你可以在代码列表的中间部分看到,Web 服务器开始接受传入的 Socket.io 连接,并将每个连接注册到我们新的 update-visualization 模块。

列表 12.7a 带有实时 PM10 图表的 Web 服务器(listing-12.7/server/web-server.js)

const path = require('path');
const http = require('http');
const socket.io = require('socket.io');
const updateVisualization = require('./update-visualization.js');

function startWebServer (db) {    ①  

    const incomingDataCollection = db.collection("incoming");

    const app = express();

    const httpServer = http.Server(app);
 const socket.ioServer = socket.io(httpServer);    ②  

    const staticFilesPath = path.join(__dirname, "public");
    const staticFilesMiddleWare = express.static(staticFilesPath);
    app.use("/", staticFilesMiddleWare);

 app.get("rest/data", (req, res) => {    ③  
 return incomingDataCollection.find()    ④  
            .sort({ _id: -1 })
            .limit(24)
            .toArray()
            .then(data => {
                data = data.reverse(),
 res.json(data);    ⑤  
            })
            .catch(err => {
                console.error("An error occurred.");
                console.error(err);

                res.sendStatus(500);
            });
    });

 socket.ioServer.on("connection", socket => {    ⑥  
        updateVisualization.onConnectionOpened(socket);

        socket.on("disconnect", () => {
            updateVisualization.onConnectionClosed(socket);
        });
 });

    httpServer.listen(3000, () => { // Start the server.
        console.log("Web server listening on port 3000!");
    });
};

module.exports = startWebServer; 

列表 12.7b 展示了我们新的更新可视化模块的代码,该模块跟踪所有打开的连接,因为任何时刻可能有多个我们的 Web 应用程序实例连接。注意它如何处理 incoming-data 事件;在这里,我们调用 socket.emit 将每个数据包转发到 Web 应用程序。这就是如何将新的数据点发送到 Web 应用程序以添加到图表中的方式。

列表 12.7b 将传入数据转发到 Web 应用程序(listing-12.7/server/update-visualization.js)

const eventHub = require('./event-hub.js');

const openSockets = [];    ①  

function onConnectionOpened (openedSocket) {    ②  
    openSockets.push(openedSocket);
};

function onConnectionClosed (closedSocket) {    ③  
    const socketIndex = openSockets.indexOf(closedSocket);
    if (socketIndex >= 0) {
        openSockets.splice(socketIndex, 1);
    }
};

eventHub.on("incoming-data", (id, incomingData) => {
 for (let i = 0; i < openSockets.length; ++i) {    ④  
        const socket = openSockets[i];
 socket.emit("incoming-data", incomingData);    ⑤  
   }
});

module.exports = {
    onConnectionOpened: onConnectionOpened,
    onConnectionClosed: onConnectionClosed
} 

我们还需要查看 Web 应用程序代码中发生的情况。您可以在列表 12.7c 中看到,它基本上与您在 C3 图表中预期看到的内容相同(为了复习,请参阅第十章)。这次,除了这个之外,我们还在创建一个 socket.io 实例,并从我们的 Web 服务器接收 incoming-data 事件。然后,将传入的数据点添加到我们现有的数据数组中,并使用 C3 的 load 函数加载修订后的数据。C3 便利地提供了一个新数据的动画,这使得图表具有流畅的视觉效果。

列表 12.7c 在数据到达时向图表中添加新数据(listing-12.7/server/public/app.js)

function renderChart (bindto, chartData) {
    var chart = c3.generate({
        bindto: bindto,
        data: chartData,
        axis: {
            x: {
                type: 'timeseries',
            }
        }
    });
    return chart;
};

$(function () {

 var socket = io();    ①  

 $.get("/rest/data")    ②  
        .then(function (data) {
 var chartData = {    ③  
                xFormat: "%d/%m/%Y %H:%M",
                json: data,
                keys: {
                    x: "Date",
                    value: [
                        "PM10 (ug/m³)"
                    ]
                }
            };

 var chart = renderChart("#chart", chartData);    ④  

 socket.on("incoming-data", function (incomingDataRecord) {    ⑤  
 chartData.json.push(incomingDataRecord);    ⑥  
 while (chartData.json.length > 24) {    ⑦  
 chartData.json.shift();    ⑧  
                }

 chart.load(chartData);    ⑨  
            });
        })
        .catch(function (err) {
            console.error(err);
        });
}); 

最后要注意的一点是我们如何使 Socket.io 可用于我们的 Web 应用程序。您可以在列表 12.7d 中看到,我们将 socket.io 客户端的 JavaScript 文件包含到我们的 Web 应用程序的 HTML 文件中。这个文件是从哪里来的?

嗯,这个文件是由我们包含在服务器应用程序中的 Socket.io 库自动提供并通过 HTTP 服务的。它以一种类似魔法的方式提供,我们不需要使用 Bower 或其他方式手动安装此文件。

列表 12.7d 服务器自动使 Socket.io 可用于客户端(listing-12.7/server/public/index.html)

<!doctype html>
<html lang="en">
    <head>
        <title>Live data visualization</title>

        <link href="bower_components/c3/c3.css" rel="stylesheet">
        <link href="app.css" rel="stylesheet">

        <script src="bower_components/jquery/dist/jquery.js"></script>
        <script src="bower_components/d3/d3.js"></script>
        <script src="bower_components/c3/c3.js"></script>
 <script src="/socket.io/socket.io.js"></script>    ①  
        <script src="app.js"></script>
    </head>
    <body>
        <div>
            No need to refresh this web page,
            the chart automatically updates as the data
            flows through.
        </div>
        <div id='chart'></div>
    </body>
</html> 

当您运行列表 12.7 的代码时,请记住一个注意事项:每次您全新运行它(模拟传感器和服务器)时,请每次重置您的传入 MongoDB 集合(您可以使用 Robomongo 从集合中删除所有文档)。否则,由于数据的时序性和我们正在回放我们的假数据,您的实时图表可能会出现异常。这是我们使用模拟传感器和假数据设置我们的开发框架的方式的一个副作用。在生产环境中这不会是问题。这在开发过程中是个麻烦,因此,为了继续开发,您可能希望有一种自动重置数据库到起始条件的方法。

好了,这就是我们完成的。我们已经构建了一个用于处理连续实时数据流的完整系统。使用这个系统,我们可以监控空气质量,并且希望我们能够更好地为紧急情况做好准备,并能够实时响应。你可以在 GitHub 仓库第十二章的complete子目录下找到完整的代码。它汇集了我们本章讨论的所有部分,并将它们组合成一个连贯的、功能齐全的系统。

本章我们所做的工作是朝着完整生产系统迈出的重要一步,但我们还没有完全达到那里。我们仍有许多问题需要解决,以便我们可以依赖这个系统,但我们将回到第十四章来讨论这些问题。现在让我们暂时放下严肃的内容,在第十三章中,我们将使用 D3 提升我们的可视化技能。

摘要

  • 你学习了如何管理实时数据管道。

  • 你通过示例学习了如何通过 HTTP POST 和套接字发送和接收数据。

  • 我们重构了代码,提取了一个简单的配置文件。

  • 我们使用 Node.js 的 EventEmitter 引入了基于事件的架构,为我们的服务器添加了一个简单的事件中心。

  • 我们使用了cron库来创建基于时间的计划任务。

  • 我们探讨了使用 Socket.io 向实时更新的 C3 图表发送数据的方法。

13

使用 D3 进行高级可视化

本章涵盖

  • 使用 SVG 创建矢量图形

  • 使用 D3 创建不同寻常的视觉呈现

没有介绍 D3:JavaScript 中构建交互式和动画浏览器可视化最杰出的框架,我就无法结束这本书。

D3(数据驱动文档)是一个复杂的库;这就是为什么这一章被称为高级可视化。D3 拥有一个庞大的 API 和复杂的概念。这是你工具箱中的一个强大补充,但遗憾的是,它有一个陡峭的学习曲线!对于大多数常规图表,你最好使用一个更简单的 API,比如我们在第十章中提到的 C3。然而,当你达到 C3 的限制,或者你想创建一些完全不同寻常的东西时,你才会转向 D3。

我无法完全教你 D3;那需要一本书。但我确实希望教你基本概念以及它们之间的关系。例如,我们将学习 D3 的一个核心概念,即数据连接模式,以及如何使用它将我们的数据转换为可视化。D3 是一个庞大且复杂的 API,所以我们只会触及表面。

你准备好创建一个更高级的可视化了么?在本章中,我们将做一些不同寻常的事情,一些我们无法用 C3 做的事情。这个例子将让你对 D3 的强大功能有所了解,在这个过程中,我们将学习核心的 D3 技能。

13.1 高级可视化

在本章中,我们将使用 D3 创建一个可伸缩矢量图形(SVG)可视化。我们也可以使用 D3 与 HTML 或 Canvas 一起使用,但通常与 SVG 一起使用,我们肯定可以通过这种方式构建一些视觉上令人印象深刻的东西。不过,如果你不知道 SVG,也不要太担心,因为我们将从简短的 SVG 快速入门课程开始。

我们将创建的可视化显示在 图 13.1 中。这是环绕地球的美国太空垃圾的按比例呈现。我所说的太空垃圾是指火箭、卫星和其他此类现在被遗弃但仍然在太空中的东西。

c13_01.tif

图 13.1 本章的最终成果:一个逐年动画的、二维可视化,展示环绕地球的美国太空垃圾

在 图 13.1 中,地球被涂成蓝色,周围环绕着黄色、橙色和红色的物体:太空垃圾的视觉呈现根据其大小进行了着色。这种着色是通过 CSS 样式实现的,你将在后面看到。(要查看本章中的彩色图示,请参阅本书的电子版。)

这个可视化是交互式的:当你将鼠标指针悬停在太空垃圾对象上时,会显示解释性文本,如图 13.2 所示。

c13_02.tif

图 13.2 当鼠标悬停在太空垃圾对象上时,会显示解释性文本。

这个可视化也是动态的:注意在 图 13.1 中,年份显示在顶部。我们的动画将使这个可视化随着时间的推移每年向前推进。在动画的每次迭代中,它将显示那一年发射的物体以及数十年来积累的太空垃圾量。

13.2 获取代码和数据

本章的代码和数据可在 GitHub 上的 Data Wrangling with JavaScript Chapter-13 仓库中找到,网址为 github.com/data-wrangling-with-javascript/chapter-13.

仓库中的每个子目录都包含一个完整的工作示例,并且每个示例都与本章中的各种列表相对应。在尝试运行每个子目录中的代码之前,请确保已安装 Bower 依赖项。

您可以使用 live-server 运行每个列表:

 cd Chapter-13/listing-13.5
    live-server 

这将打开您的浏览器并导航到网页。有关获取代码和数据的帮助,请参阅第二章的“获取代码和数据”部分。

13.3 可视化太空垃圾

为什么是太空垃圾?我正在寻找一个可以展示 D3 力量的可视化效果,在参加世界科学节关于太空垃圾扩散的讲座后,我受到了启发。讲座结束后,我进行了自己的研究,并在 stuffin.space/ 找到了一个令人惊叹且准确的太空垃圾 3D 可视化。.

我决定为这本书重现类似的内容,但使用 2D 和 D3 技术。我已经预先过滤了数据,只保留了美国太空垃圾的数据;否则,我们会拥有太多数据,这会使可视化变得杂乱无章。

我们将使用的数据存储在名为 us-space-junk.json 的 JSON 文件中,您可以在 Chapter-13 GitHub 仓库中找到它。如图 13.3 所示,每条数据记录代表一个太空垃圾物体,并描述了其名称、大小、发射日期和近地点。

近地点是物体距离地球最近的位置。在我们的可视化中,我们将使用这个值来近似物体的地球距离。

c13_03.eps

图 13.3 描述太空垃圾每个物体的 JSON 数据(us-space-junk.json 的摘录)

13.4 什么是 D3?

D3,数据驱动文档,是一个用于生成可视化的 JavaScript API。人们经常称它为通用可视化工具包,这意味着我们使用它时不受限制或约束,可以创建各种类型的可视化。如果您能想象出一个可视化——好吧,一个可以用 HTML、SVG 或 Canvas 创建的可视化——那么您肯定可以使用 D3 创建它。

D3 并非专门用于构建图表或任何特定类型的可视化;它足够强大,可以表达任何内容,您只需进行一次 D3 示例的图片搜索,就能看到我的意思。我继续对使用 D3 制作的各种可视化效果的广泛性感到惊讶。

D3 是由迈克尔·博斯特克、瓦迪姆·奥吉维茨基和杰弗里·希尔在《纽约时报》可视化部门创建的。它是可视化 API 家族中的最新和最强大的成员,拥有成熟的经验。

我们将使用 D3 为我们的太空垃圾可视化创建配方。将我们的数据添加到这个配方中会产生可视化。使用 D3 将数据转换为可视化的过程没有固定的流程。我们必须明确编写将我们的数据转换为可视化的代码。我们正在构建从数据到可视化的自定义转换。正如你可以想象的那样,这是一种构建独特或定制可视化的强大方式,但当你想要一个标准的线图或柱状图时,这会很快变得繁琐。

D3 与 jQuery 有重叠:它允许我们选择和创建 DOM 节点,然后以可能让你感到熟悉的方式设置它们的属性值。我们还可以向 DOM 节点添加事件处理器以创建交互性;例如,对鼠标悬停和鼠标点击做出响应。

尽管如此,D3 可能会让你感到挑战。它的强大之处在于抽象概念,这也是 D3 难以学习的原因。D3 还要求具备高级的 JavaScript 知识,但我将努力使内容易于理解,并且我们会从简单的开始逐步构建到更复杂的数据可视化。

D3 对于从头开始创建可视化可能对你很有用,我们还可以使用我们的 D3 技能来扩展我们的 C3 图表。很容易达到 C3 的限制——比如说,当你想在 C3 图表中添加额外的图形、交互性或动画时。但由于 C3 建立在 D3 之上,我们可以使用 D3 API 来扩展 C3,并为我们的 C3 图表添加额外的功能。

我一直赞扬 D3 的优点,但同时也希望我已经说服了你们不要轻视它。使用 D3 的唯一原因是为了创建一个高级可视化,这是用更简单的 API(如 C3)无法实现的。例如,你不会用 D3 来创建公司每周销售额的柱状图。这样做不值得——有点像用一把大锤子来钉一个图钉,那将是过度杀鸡用牛刀。

13.5 D3 数据管道

我说过,我们将使用 D3 来创建我们的数据可视化配方。将其视为配方是一种理解 D3 工作方式的方法。

我还喜欢的一个类比是将我们的 D3 配方视为一个数据管道。如图 13.4 所示,我们的数据集通过管道传输,并在另一端以可视化的形式出现。

c13_04.eps

图 13.4 D3 创建了一个将我们的数据集转换为可视化的管道。

然而,D3 不仅仅是为了在空 DOM 中创建新的可视化。我们的 D3 配方还可以描述如何将数据添加到现有的可视化中。这就是 D3 如此强大的原因;我们可以使用它来更新实时可视化,当新数据可用时(见图 13.5)。

我们将要构建的 D3 管道将在一组数据记录上操作,如图 13.6 所示。管道依次访问每个数据记录,并为每个太空垃圾对象生成一个 DOM 节点。这个过程产生了一组新的 DOM 节点,这些节点共同构成了我们的可视化。

你准备好开始编写太空垃圾可视化代码了吗?

c13_05.eps

图 13.5 D3 数据管道可以用新数据更新现有的可视化。

c13_06.eps

图 13.6 D3 为每个数据记录生成“边界 DOM 节点”。

13.6 基本设置

首先,让我们确定基本设置。列表 13.1 展示了我们 D3 可视化的 HTML 模板。最终,我们将使用 D3 生成所有我们的视觉元素,所以这个空白的 HTML 模板在本章中主要满足我们的需求。

列表 13.1 D3 可视化的 HTML 文件(listing-13.1/index.html)

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="utf-8">
        <title>Space junk visualization</title>
        <link rel="stylesheet" href="app.css">
 <script src="bower_components/d3/d3.js"></script>    ①  
        <script src="app.js"></script>
    </head>
    <body>
        <svg class="chart"></svg>
    </body>
</html> 

注意,HTML 文件包括 CSS 和 JavaScript 文件:app.css 和 app.js。在我们开始本章时,这两个文件实际上都是空的。不过,很快我们就会开始向 app.js 中添加 D3 代码,并通过 app.css 来设计我们的可视化。

这是一个空白画布,我们将在这里创建我们的可视化。我们将使用 D3 逐步生成视觉元素,但在我们这样做之前,让我们直接将一些 SVG 基本元素手动添加到 HTML 文件中,这样我们就可以了解 SVG 的工作原理。

13.7 SVG 快速入门

SVG 是一种 XML 格式,类似于 HTML,用于渲染 2D 向量图形。像 HTML 一样,SVG 可以是交互式的和动画的——这是我们将在我们的可视化中使用的东西。

SVG 是一个存在了相当长一段时间的开放标准。它于 1999 年开发,但在最近的历史中,它已经得到了所有现代浏览器的支持,因此可以直接嵌入到 HTML 文档中。

本节作为我们将在太空垃圾可视化中使用到的 SVG 基本元素的快速入门。如果你已经理解了 SVG 基本元素、属性、元素嵌套和转换,请跳过本节,直接跳到“使用 D3 构建可视化”。

13.7.1 SVG 圆形

让我们从设置 svg 元素的宽度和高度开始。这创建了一个我们可以用向量图形绘制的空间。

我们将使用的主要基本元素是 circle 元素。图 13.7 显示了一个 SVG 圆形(在左侧),Chrome 的 DevTools 中的 DOM 看起来如何(在中间),以及一个与 circle 元素和 svg 元素相关联的示意图(在右侧):circle 元素是 svg 元素的子元素。

列表 13.2 是这个最简单可视化的代码。注意属性是如何用来设置位置(cx 和 cy)、半径(r)和颜色(fill)的。你还可以在 Chrome 的 DevTools 中检查这些属性的值(如图 13.7 中间所示)。

c13_07.eps

图 13.7 手动添加到我们的 SVG 中的 SVG 圆形元素(在 Chrome 开发者工具中查看 DOM)

列表 13.2 将 circle 元素添加到 SVG 中(在 listing-13.2/index.html 中)

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="utf-8">
        <title>Space junk visualization</title>
        <link rel="stylesheet" href="app.css">
        <script src="bower_components/d3/d3.js"></script>
        <script src="app.js"></script>
    </head>
    <body>
 <svg    ①  
            class="chart"
 width="500"    ②  
 height="500"    ②  
            >
 <circle    ③  
 cx="250"    ④  
 cy="250"    ④  
 r="50"    ④  
 fill="#01499D"    ④  
                />
        </svg>
    </body>
</html> 

在这个简单的例子中,我们可能不需要使用开发者工具来检查 DOM 和属性值。因为我们已经知道它们将是什么样子,因为这是我们输入到 HTML 文件中的内容。但很快我们将使用 D3 生成这些圆形,所以我们不会简单地“知道”它们看起来是什么样子。我们需要检查实际的结果,这样当事情出错时,我们可以进行故障排除和解决问题。

请现在花点时间在浏览器中运行这个简单的可视化,并使用浏览器开发者工具检查 DOM 结构和圆的属性。在早期嵌入这种行为是很好的实践,这样当事情变得复杂和混乱时,你会处于更好的位置来解决问题。

13.7.2 样式

我们可以像样式 HTML 元素一样样式化 SVG 元素:使用 CSS 样式。例如,在我们的简单圆形可视化中,我们可以将圆的填充颜色移动到 CSS 中,并将样式与结构分离。以下列表显示了修改后的 circle 元素,其中移除了 fill 属性;然后 列表 13.3b 展示了我们将填充颜色移动到 CSS 样式中的方法。

列表 13.3a 圆的填充属性已移动到 CSS 中(从 listing-13.3/index.html 中提取)

<circle    ①  
    cx="250"
    cy="250"
    r="50"
    /> 

列表 13.3b 圆的填充颜色现在由 CSS 指定(在 listing-13.3/app.css 中)

.chart circle {
    fill: #01499D;
} 

我们将在可视化中稍后使用更多的 CSS 样式。

13.7.3 SVG 文本

现在我们将添加一个 text 元素作为标题,与我们的圆一起,将圆命名为地球。图 13.8 展示了我们的更新后的可视化(左侧)和示意图(右侧),它说明了圆形和文本如何与 DOM 中的 svg 相关。

c13_08.eps

图 13.8 使用 text 元素为我们的地球可视化添加标题

列表 13.4a 展示了添加 text 元素,现在我们有了地球的简单可视化。请随意在浏览器中打开它,打开开发者工具,检查 DOM 看看现在的样子。

列表 13.4a 为我们的地球可视化添加标题(从 listing-13.4/index.html 中提取)

<svg
    class="chart"
    width="500"
    height="500"
    >
    <circle
        cx="250"
        cy="250"
        r="50"
        />
 <text    ①  
        x="250"
        y="320"
        >
        The Earth
    </text>
</svg> 

为了配合我们的文本,我们将添加一个新的 CSS 样式。如下所示,我们将 text-anchor 设置为 middle,这样我们的文本就围绕其位置居中——这是一个用作标题的不错的小技巧。

列表 13.4b 新 text 元素的 CSS 样式(从 listing-13.4/app.css 中提取)

.chart text {
 text-anchor: middle;    ①  
} 

13.7.4 SVG 组

到目前为止,这很简单,但让我们更加严肃。我们需要一种方法来定位我们围绕地球的空间垃圾。我们将使用 SVG g元素来组装一个 SVG 原语,并将它们作为一个单一实体定位。图 13.9 中的示意图显示了我们的圆圈和文本如何与现在嵌套在svg下的组相关。

列表 13.5 展示了如何将circletext元素包含在g元素中。注意组上的transform属性以及一个translate命令,它们共同设置组的位置。这允许我们在 SVG 元素内的任何位置定位圆圈和文本。尝试将以下列表加载到您的浏览器中,然后修改translate坐标以将组移动到其他位置。

列表 13.5 在g元素中分组视觉元素以将它们视为单一实体(来自列表-13.5/index.html)

<svg
    class="chart"
    width="500"
    height="500"
 >
 <g    ①  
 transform="translate(250, 250)"    ②  
        >
        <circle
            r="50"
            />
        <text
 y="70"    ③  
            >
            The Earth
        </text>
    </g>
</svg> 

现在我们已经掌握了足够的 SVG 基础知识来构建我们的 D3 可视化。我们知道如何使用circletextg元素来创建 SVG 可视化。现在让我们学习 D3。

c13_09.eps

图 13.9 展示了我们的 DOM 结构,其中圆圈和文本被分组在 g 元素下

13.8 使用 D3 构建可视化

要构建 D3 可视化,我们必须选择、创建、配置和修改我们的 SVG 元素。让我们从配置元素状态开始。

13.8.1 元素状态

每个元素都有一个与之关联的状态,该状态通过其属性指定。我们已经看到了如何手动设置各种 SVG 属性——例如,圆圈的cxcyrfill属性。表 13.1 是我们已分配的属性和值的总结。

表 13.1 来自列表 13.2 的圆元素的元素状态

属性 目的
cx 250 圆圈的 X 位置
cy 250 圆圈的 Y 位置
r 50 圆的半径
fill #01499D 圆圈的填充颜色(地球蓝)

图 13.10 突出了这些属性在 Chrome 的开发者工具中的外观。无论这些属性是在 SVG 中手动设置还是在使用 D3 API 设置其值时,外观都是相同的。

要使用 D3 设置属性值,我们将使用attr函数。假设我们已经有了一个对circle元素的引用,我们可以在代码中设置属性如下:

var circle = ...    ①  
circle.attr("cx", "250")    ②  
    .attr("cx", "250")
 .attr("r", "50")    ③  
 .attr("fill", "#01499D");    ④   

除非我们有元素的引用,否则我们无法设置其属性,因此现在让我们看看如何选择一个元素,以便我们可以对其进行操作。

13.8.2 选择元素

使用 D3,我们有三种基本的方式来引用一个元素。我们可以选择一个现有的单个元素。我们可以一次性选择多个元素。或者我们可以按程序创建元素并将它们添加到 DOM 中。最终,我们需要这三种方法,但我们将使用最后一种方法来生成我们的初始可视化。

单个元素

我们可以使用 D3 的 select 函数选择单个元素。您可以在 图 13.11 中看到,我用一个虚线框表示当前的 D3 选择,该虚线框选择了我们的 circle 元素。

c13_11.eps

图 13.11 使用 D3 的 select 函数操作单个现有 DOM 节点。

假设我们已经有了一个现有的元素,比如说我们的 circle 元素,我们可以使用 D3 的 select 函数通过 CSS 风格的选择器来选择我们的圆圈:

c13_10.eps

图 13.10 在 Chrome DevTools 中查看圆形元素的属性

var circle = d3.select("circle"); 

列表 13.6a 展示了一个真实示例。在这里,我们选择了 svg 元素,并根据文档的尺寸设置其宽度和高度。这样,我们的可视化就可以充分利用浏览器窗口中可用的空间。

列表 13.6a 选择 svg 元素并设置其尺寸(来自列表-13.6/app.js)

var width = window.innerWidth;    ①  
var height = window.innerHeight;

var svgElement = d3.select("svg.chart")    ②  
 .attr("width", width)    ③  
 .attr("height", height);    ③   

注意,我们还在通过其 CSS 类名引用 svg 元素。这使得我们的选择更加具体,以防我们在同一页面上有多个可视化。

我们看到的 svg.chart 类似于 CSS 选择器。如果您熟悉 jQuery,您会对这段代码感到舒适,因为 jQuery 通过 CSS 选择器和设置属性值与这类似。

多个元素

我们也可以使用 D3 同时选择和操作多个元素。您可以在 图 13.12 中看到,代表 D3 选择的虚线框现在包围了多个现有的 circle 元素。

c13_12.eps

图 13.12 使用 D3 同时选择和操作多个 DOM 节点

我们可以使用 D3 的 selectAll 函数如下选择多个现有元素:

var circle = d3.selectAll("circle")    ①  
circle
 .attr("r", 50)    ②  
 .attr("fill", "#01499D");    ③   

注意,当我们对一个包含多个元素的选取调用 attr 时,该属性的值将更新所有这些元素。这允许我们一次性配置一组元素,这在我们要配置我们全部空间垃圾对象的可视化时将非常有用。

添加新元素

选择元素的最后一种方式是添加新元素。当我们添加新元素时,它会自动被选中,这样我们就可以设置其初始状态。请注意,因为这是我们创建空间垃圾可视化的程序化方法。在我们的可视化中,我们需要表示许多空间垃圾,并且需要以编程方式添加 DOM 元素。如果我们手动添加它们,那将是一项繁琐的工作,更不用说我们无法轻松地动画化手动准备的可视化了。

列表 13.6b 展示了我们将如何使用 D3 的 append 函数将程序生成的地球添加到我们的可视化中。添加一个元素会产生一个选择,尽管在这种情况下,它是一个包含单个 DOM 节点的选择。我们仍然可以设置元素的属性,我们在这里使用它来设置地球的类、位置和半径。

列表 13.6b 将“地球”添加到我们的可视化中(摘自 listing-13.6/app.js)

var earthRadius = 50;    ①  
var earthTranslation =
 "translate(" + (width/2) + ", " + (height/2) + ")";    ②  

var theEarth = svgElement.append("circle")    ③  
theEarth.attr("class", "earth")    ④  
 .attr("transform", earthTranslation)    ⑤  
 .attr("r", earthRadius);    ⑥   

13.8.3 手动添加元素到我们的可视化中

正确使用 D3 API 的方法是遍历我们的数据,并为我们的每个太空垃圾数据记录程序化地创建视觉效果。这现在可能是一个很大的跳跃,可能难以理解。让我们首先以更直接的方式来看这个问题。

我们可能以什么最简单的方式为我们的每个数据记录添加视觉效果呢?嗯,我们可以遍历我们的数据数组,并调用 append 函数为每个太空垃圾对象添加一个新的视觉效果。

任何了解 D3 的人都会告诉你这不是正确使用 D3 的方法,他们是对的,但我希望我们首先采取一种更简单的方法,这样我们以后才能更好地欣赏 D3 为我们带来的魔法。

如果我们实例化 DOM 元素以对应我们的太空垃圾数据,我们最终会得到类似于 图 13.13 的东西。不过,它看起来不会完全一样,因为我们正在使用随机坐标来表示太空垃圾。每次运行它时,它都会选择不同的位置。DOM 显示在右侧,这样你可以看到每个太空垃圾对象的 DOM 节点。

c13_13.eps

图 13.13 在 Chrome DevTools 中查看我们手动添加的 DOM 节点的 DOM 层级:我们的可视化开始成形。

在 图 13.13 的右侧,你可以看到我在 Chrome 的 DevTools 中将鼠标指针悬停在 DOM 节点上。这是一个有用的调试技术,因为它会在可视化中突出显示相应的元素。

下面的列表显示了循环遍历数据并为每个数据记录添加视觉效果的代码。你可以看到,对于每个数据记录,我们调用 D3 的 append 函数来完善我们的可视化。

列表 13.7 手动添加太空垃圾元素到我们的 D3 可视化中(摘自 listing-13.7/app.js)

for (var rowIndex = 0; rowIndex < spaceJunkData.length; ++rowIndex) {    ①  
 var spaceJunk = svgElement.append("g");    ②  
 spaceJunk.attr("class", "junk")    ③  
 .attr("transform", function(row, index) {    ④  
 var orbitRadius = earthRadius + orbitDistance;    ⑤  
 var randomAngle = Math.random() * 360;    ⑥  
 var point = pointOnCircle(    ⑥  
 orbitRadius,    ⑥  
 randomAngle    ⑥  
 );    ⑥  
 var x = (width/2) + point.x;    ⑦  
 var y = (height/2) + point.y;    ⑦  
 return "translate(" + x + ", " + y + ")" ;    ⑧  

        })
 .append("circle")    ⑨  
 .attr("r", 5);    ⑩  
} 

以这种方式手动遍历我们的数据并多次调用 append 并不是正确使用 D3 的方法,但我希望这个垫脚石已经使你更容易理解 D3 的工作原理。稍后,我们将看看如何正确地做这件事。不过,首先,我们应该把我们的缩放顺序整理好。

13.8.4 缩放以适应

到目前为止,我们使用可视化时已经使用了一系列硬编码的坐标和测量值。例如,我们在代码列表 13.6b 中将地球的半径设置为 50 像素,尽管你在代码列表 13.7 中看不到,但太空垃圾的轨道距离也是一个硬编码的值。我们希望用按比例缩放的版本来替换这些值,这些值确实代表了地球的实际大小和每个太空垃圾对象的实际轨道距离。

此外,我们还没有有效地利用可视化中的空间,正如你在图 13.14 的左侧所看到的。我们希望我们的可视化占据所有可用空间,更像图 13.14 的右侧。

D3 支持缩放,我们将使用代码列表 13.8a 中展示的scaleLinear函数。这创建了一个 D3 比例,它将地球的实际半径(6,371 千米)线性映射以适应可用的空间,并且整齐地嵌入其中。

[代码列表 13.8a] 缩放地球大小以适应可用空间(摘自listing-13.8/app.js

var earthRadius = 6371;    ①  
var earthTranslation = "translate(" + (width/2) + ", " + (height/2) + ")";
var maxOrbitRadius = d3.max(
        spaceJunkData.map(
spaceJunkRecord => earthRadius + spaceJunkRecord.PERIGEE    ②  
       )
);

var radiusScale = d3.scaleLinear()    ③  
    .domain([0, maxOrbitRadius])
 .range([0, Math.min(height/2, width/2)]);    ④  

var theEarth = svgElement.append("circle")
theEarth.attr("class", "earth")
    .attr("transform", earthTranslation)
 .attr("r", radiusScale(earthRadius));    ⑤   

还请注意,在代码列表 13.8a 中,我们如何也考虑了每个太空垃圾对象的轨道距离。这些值中的最大值是使用 D3 的max函数确定的。

我们生成的比例radiusScale是一个 JavaScript 函数。没有更多,也没有更少。我们可以向这个函数传递真实值(如地球的半径),它将生成一个适合我们浏览器窗口的缩放值。

c13_14.eps

图 13.14 并排示例以展示缩放效果。右侧的可视化使用了现实世界的尺寸,但已缩小以适应浏览器窗口中可用的空间。

我们还使用相同的radiusScale函数来缩放太空垃圾的轨道距离,正如你在代码列表 13.8b 中所看到的。轨道半径是从地球半径加上太空垃圾的近地点(最近的轨道接近点)得出的。然后,这个值通过radiusScale函数转换成像素值。然后,这个值被用来在可视化中定位太空垃圾。

[代码列表 13.8b] 太空垃圾每个对象的轨道距离也被缩放以适应(摘自listing-13.8/app.js

var spaceJunk = svgElement.append("g");
spaceJunk.attr("class", "junk")
    .attr("transform", function () {
 var orbitRadius = radiusScale(    ①  
 earthRadius + spaceJunkRecord.PERIGEE    ①  
 );    ①  
        var randomAngle = Math.random() * 360;
        var point = pointOnCircle(orbitRadius, randomAngle);
        var x = (width/2) + point.x;
        var y = (height/2) + point.y;
        return "translate(" + x + ", " + y + ")" ;
    })
    .append("circle")
        .attr("r", 5); 

好的,我们已经解决了缩放问题。我们几乎准备好了第一个版本的可视化。我们现在需要确保我们的太空垃圾视觉效果是以 D3 方式生成的,而不是我们之前使用的手动循环和追加。

13.8.5 D3 方式的过程生成

这就是事情开始变得相当棘手的地方。你现在必须接受 D3 的数据连接和条目选择的概念。这些概念难以理解,因为它们有两个目的,尽管一开始我们只需要其中一个。我们现在将使用这项技术从无到有地创建我们的可视化。稍后,当我们开始构建动画时,我们将使用同样的技术向现有的可视化中添加新数据。

我现在告诉你这一点,因为如果不理解数据连接不仅用于产生新的可视化,那么很难理解数据连接的工作原理。这个技术之所以复杂,是因为它也用于更新现有的可视化。

D3 数据连接的目的是什么?

  • 为了配对 DOM 节点和数据记录

  • 为了整理新和现有的数据记录

  • 为了动画化数据记录的增加和移除

我们将使用 D3 的selectAll函数来生成一个g元素的选择,但由于我们可视化中甚至还没有这样的元素,这将给我们一个所谓的空选择

在调用selectAll之后,我们将调用 D3 的data函数,传入我们的太空垃圾数据集。这创建了所谓的数据连接,并产生了一个与我们的数据记录绑定的 DOM 节点选择。但是等等,我们还没有任何 DOM 节点!这个操作我们得到了什么?如图 13.15 所示,这些不是普通的 DOM 节点。在我们的可视化中还没有任何 DOM 节点,所以 D3 创建了一组占位符 DOM 节点,每个数据记录一个占位符。很快,我们将用实际的 DOM 节点来填充这些空白,以表示我们的太空垃圾。

c13_15.eps

图 13.15 我们最初的数据连接产生了与我们的数据记录绑定的占位符 DOM 节点。

图 13.16 展示了如何将一个空的g元素选择与我们的数据结合,从而产生与我们的数据绑定的占位符 DOM 节点集。

如果你现在感到困惑,嗯,我能感受到你的痛苦。在我看来,数据连接和占位符 DOM 节点的概念可能是你在学习 D3 时遇到的最令人困惑的事情。如果你能理解这一点,那么 D3 的其他部分就不会那么困难了。

c13_16.eps

图 13.16 在选择上调用 D3 数据函数(即使是空选择)将选择与我们的数据集连接起来。这被称为数据连接。

我们现在从数据连接中获得的选择代表尚未存在的 DOM 节点。如果在我们可视化中已经存在 SVG 组元素,它们将与新数据的占位符元素一起出现在选择中,但我们目前还没有任何组元素。

列表 13.9 显示了生成数据连接的代码。调用 selectAll 产生一个空的选择。然后使用 data 函数将数据集连接起来。接下来,注意 enter 函数的使用,它过滤选择以获取新的数据记录。enter 函数允许我们指定当新数据添加到我们的可视化中时会发生什么。这就是我们调用 D3 的 append 函数的地方。当我们处于 enter 选择上下文中的 append 调用时,D3 将用我们想要添加到可视化中的元素替换占位符 DOM 节点。

列表 13.9 执行数据连接和程序生成太空垃圾视觉效果(摘自 app.js)

// ... other code here ...

var spaceJunk = svgElement.selectAll("g")    ①  
 .data(spaceJunkData);    ②  
var enterSelection = spaceJunk.enter();    ③  
enterSelection.append("g")    ④  
 .attr("class", "junk")    ④  
 .attr("transform", spaceJunkTranslation)    ④  
 .append("circle")    ④  
 .attr("r", 5);    ④   

列表 13.9 定义了我所称之为可视化配方核心的内容。这是对 D3 的指令,即“请用我的所有太空垃圾数据创建一个视觉表示。”如果您像我一样理解这一点,您就会明白为什么我喜欢将其视为数据管道。列表 13.9 是一段消耗数据并生成可视化的代码片段。

现在我们已经连接了数据并创建了太空垃圾视觉效果,您可以运行此代码,并在浏览器 DevTools 中检查 DOM 层次结构,以更好地理解 DOM 节点和数据记录是如何绑定在一起的。选择一个如图 13.17 所示的空间垃圾 DOM 节点。现在打开浏览器的控制台。

Google Chrome 有一个名为 $0 的特殊变量。如果您在控制台中输入这个变量并按 Enter 键,所选的 DOM 元素将被显示供您检查。现在输入 $0.__data__ 并按 Enter 键。这将显示绑定到 DOM 节点的数据记录。__data__ 属性是由 D3 添加的,看到 D3 如何跟踪其数据绑定是有教育意义的。

c13_17.eps

图 13.17 使用 Chrome 的 DevTools 检查绑定到 DOM 的数据

我们的第一轮可视化工作即将完成,目前我们只需考虑最后一件事。不过,很快我们就会回到选择和数据连接,学习如何将新数据添加到我们的可视化中,并随时间对其进行动画处理。

13.8.6 加载数据文件

到目前为止,我们只使用了一小部分硬编码到 app.js 中的数据。现在是时候让这个家伙在真实数据上大显身手了。在下面的列表中,我们使用 D3 的 json 函数异步从网络服务器以 JSON 格式检索我们的数据(我们也可以使用 CSV 数据)。

列表 13.10a 使用 D3 加载真实数据文件(摘自 listing-13.10/app.js)

d3.json("data/us-space-junk.json")    ①  
    .then(function (spaceJunkData) {
        // ... Build your visualization here ...
    })
 .catch(function (err) {    ②  
 console.error("Failed to load data file.");    ②  
 console.error(err);    ②  
 });    ②   

我们的空间垃圾可视化第一轮已经完成。我们很快会添加更多内容,但请利用这个机会运行代码,并使用您浏览器的 DevTools 探索和理解我们使用 D3 构建的这个 DOM。您的可视化应该与 图 13.18 类似。

c13_18.tif

图 13.18 我们部分完成的太空垃圾可视化

大部分代码在 列表 13.10b 中展示。我们迄今为止学到的最困难的事情是数据连接的概念和进入选择,以及它是如何导致一组占位符 DOM 元素,然后我们用太空垃圾视觉元素替换它们。当你阅读 列表 13.10b 时,请注意对 selectAlldataenter,append 的调用。

列表 13.10b 到目前为止的太空垃圾可视化代码(摘自列表-13.10/app.js)

var width = window.innerWidth;    ①  
var height = window.innerHeight;

var earthRadius = 6371;
var earthTranslation = "translate(" + (width/2) + ", " + (height/2) + ")";
var maxDistanceFromEarth = 6000;    ②  

d3.json("data/us-space-junk.json")    ③  
    .then(function (spaceJunkData) {

 var filteredData = spaceJunkData.filter(    ④  
 spaceJunkRecord =>    ④  
 spaceJunkRecord.PERIGEE <=    ④  
 maxDistanceFromEarth    ④  
 );    ④  

 var maxOrbitRadius = d3.max(filteredData.map(    ⑤  
 spaceJunkRecord =>    ⑤  
 earthRadius +    ⑤  
 spaceJunkRecord.PERIGEE    ⑤  
 ));    ⑤  

 var radiusScale = d3.scaleLinear()    ⑥  
            .domain([0, maxOrbitRadius])
            .range([0, Math.min(height/2, width/2)]);

        var svgElement = d3.select("svg.chart")
 .attr("width", width)    ⑦  
            .attr("height", height);

 var theEarth = svgElement.append("circle")    ⑧  
        theEarth.attr("class", "earth")
            .attr("transform", earthTranslation)
 .attr("r", scaleRadius(earthRadius));    ⑨  

        svgElement.selectAll("g")
 .data(filteredData)    ⑩  
 .enter()    ⑪  
 .append("g")    ⑫  
 .attr("class", "junk")    ⑫  
 .attr("transform", spaceJunkTranslation)    ⑫  
 .append("circle")    ⑫  
 .attr("r", 2);    ⑫  
    })
 .catch(function (err) {
        console.error("Failed to load data file.");
        console.error(err);
    });
}; 

一旦你理解了 列表 13.10b 中的代码,你可能会意识到这并没有多少内容。你甚至可能会 wonder 为什么你最初认为 D3 是如此困难。

嗯,我们还没有完成,代码将变得更加复杂。在我们完成本章之前,我们将根据大小对太空垃圾进行颜色编码,向可视化添加简单的交互性,然后是最后的荣耀:我们将按年动画化可视化,以便我们可以清楚地看到太空垃圾是如何随时间积累的。

13.8.7 对太空垃圾进行颜色编码

在本章的开头,你可能已经注意到我们的数据指定了每个太空垃圾对象的大小为小、中或大。现在我们将使用这些信息来根据大小对可视化中的太空垃圾进行颜色编码。

我们将通过 CSS 类和样式应用颜色。在下面的列表中,我们为我们的太空垃圾视觉元素分配 CSS 类名 SMALLMEDIUMLARGE。这个值直接从数据中提取,并成为 CSS 类名。

列表 13.11a 基于大小设置太空垃圾类(摘自列表-13.11/app.js)

spaceJunk.enter()
.append("g")    ①  
 .attr("class", function  (spaceJunkRecord) {    ②  
 return "junk " + spaceJunkRecord.RCS_SIZE;    ③  
        }) 

下面的列表显示了我们现在如何添加 CSS 样式来为不同大小的太空垃圾元素设置不同的填充颜色。

列表 13.11b 基于大小设置太空垃圾颜色的 CSS(摘自列表-13.11/app.css)

.junk.SMALL {
    fill: yellow;
}

.junk.MEDIUM {
    fill: orange;
}

.junk.LARGE {
    fill: red;
} 

这并没有给我们的代码增加多少复杂性,但它使可视化看起来更好,并且是条件 CSS 样式的一个好例子,它依赖于我们数据的内容。

13.8.8 添加交互性

我们使用 D3 建立了一个不寻常的可视化,这展示了它的力量。但我们也需要看到交互性和动画的例子,才能真正理解我们可以用 D3 做到什么程度。

首先,让我们解决交互性。在 图 13.19 中,我们可以看到我们新着色的太空垃圾,以及描述性的鼠标悬停文本。我们将以创建任何基于浏览器的交互性(使用 JavaScript)的相同方式创建此悬停文本。我们可以通过事件响应用户输入;在这种情况下,我们将使用鼠标 hoverunhover,如下面的列表所示。

c13_19.tif

图 13.19 带有鼠标悬停文本的颜色编码太空垃圾

列表 13.11c 处理太空垃圾视觉的鼠标悬停事件(摘自 listing-13.11/app.js)

var spaceJunk = svgElement.selectAll("g")
    .data(filteredData);
spaceJunk.enter()
        .append("g")
 .attr("class", function  (spaceJunkRecord) {    ①  
                return "junk " + spaceJunkRecord.RCS_SIZE;
            })
            .attr("transform", spaceJunkTranslation)
 .on("mouseover", hover)    ②  
 .on("mouseout", unhover)  ②  
        .append("circle")
            .attr("r", 2); 

针对鼠标悬停事件,我们将对可视化进行修改。在 列表 13.11d 中,我们添加新的 text 元素用于描述文本。我们还修改了太空垃圾的大小(通过增加 circle 元素的半径)。这从视觉上吸引了我们对鼠标指针悬停的特定对象的注意。

列表 13.11d 在鼠标悬停在太空垃圾上时添加悬停文本到可视化(摘自 listing-13.11/app.js)

function addText (className, text, size, pos, offset) {    ①  
 return svgElement.append("text")    ②  
 .attr("class", className)    ③  
        .attr("x", pos.x)
 .attr("y", pos.y + offset)    ④  
        .text(text);
};

function hover (spaceJunkRecord, index) {    ⑤  

    d3.select(this)
        .select("circle")
 .attr("r", 6);    ⑥  

    var pos = computeSpaceJunkPosition(spaceJunkRecord);

 addText("hover-text hover-title", row.OBJECT_NAME, 20, pos, 50)    ⑦  
        .attr("font-weight", "bold");

    addText("hover-text",
        "Size: " + spaceJunkRecord.RCS_SIZE, 16, pos, 70
    );
    addText("hover-text",
        "Launched: " + spaceJunkRecord.LAUNCH, 16, pos, 85
    );
};

function unhover (spaceJunkRecord, index) {    ⑧  

    d3.select(this)
        .select("circle")
 .attr("r", 2);    ⑨  

    d3.selectAll(".hovertext")
 .remove();    ⑩  
}; 

还请注意,在 列表 13.11d 中,我们如何通过移除描述文本和将太空垃圾圆圈恢复到原始大小来清理鼠标悬停事件。

13.8.9 添加按年发射动画

我们可视化的最后一笔是逐年动画,以显示太空垃圾被发射并积累在轨道上。这将完成我们对 D3 数据连接的理解,我们将学习它是如何用于向现有可视化添加新数据的。

图 13.20 展示了我们的动画将采取的一般形式:我们将按年逐年向前动画。动画的每一迭代在真实时间中持续一秒,但它代表了一整年的发射。

c13_20.eps

图 13.20 按年逐年动画可视化,每年依次添加太空垃圾

在动画开始之前,我们将有一个空的可视化。然后随着每一迭代的完成,我们将越来越多地将太空垃圾添加到可视化中,你将看到它围绕地球积累。

在动画的第一迭代中,我们的 D3 数据连接操作就像我们在 图 13.16 中看到的那样。我们的可视化是空的,所以我们正在将第一年的数据与空选择连接起来,以产生占位符 DOM 节点的选择。

第二次及以后的迭代都使用现有的可视化并添加新数据到其中。现在我们确实有一个现有的 g 元素的选择,这些元素已经绑定到数据记录上。图 13.21 显示了我们的数据连接的结果是一组绑定的 DOM 节点:现在它包含之前添加到可视化中的数据记录的现有 DOM 节点,并且它还包含新数据记录的占位符 DOM 节点。

再次强调,我们使用 enter 函数和 append 函数用太空垃圾视觉效果替换占位符 DOM 节点,这更新了可视化并添加了新数据。如果你之前在想“enter函数有什么用”,现在可能变得更明显了。enter 函数允许我们在忽略现有元素的同时添加新元素。**

**列表 13.12 展示了我们动画可视化的代码。注意setInterval如何创建动画的迭代,以及数据是如何在每个迭代中过滤的,以便我们可以逐步将新数据输入到我们的 D3 管道中。

注意列表 13.12 中用于动画化添加太空垃圾到可视化中的 D3 transition 函数。这使得太空垃圾看起来是从地球表面发射出去,然后到达最终位置。太空垃圾圆的半径也被动画化,以引起对发射的注意。

c13_21.eps

图 13.21 将非空选择与数据连接会产生绑定 DOM 节点的选择。这包含现有的 DOM 节点,也为任何新的数据记录提供了占位符 DOM 节点。

列表 13.12 按发射日期逐年动画化太空垃圾(摘自列表-13.12/app.js)

var currentYear = 1957;    ①  
addText("title-text",    ②  
 currentYear.toString(), { x: width/2, y: 30 }, 0    ②  
);    ②  

var dateFmt = "DD/MM/YYYY";

setInterval(function () {    ③  
    ++currentYear;

 svgElement.select(".title-text")    ④  
        .text(currentYear.toString());

 var currentData = filteredData.filter(    ⑤  
 spaceJunkRecord =>    ⑤  
 moment(spaceJunkRecord.LAUNCH, dateFmt).year() <=    ⑤  
 currentYear    ⑤  
 );    ⑤  

    const spaceJunk = svgElement.selectAll("g")
 .data(currentData, function (row) { return row.id; });    ⑥  
 spaceJunk.enter()    ⑦  
            .append("g")
            .on("mouseover", hover)
            .on("mouseout", unhover)
            .attr("class", function  (spaceJunkRecord) {
                return "junk " + spaceJunkRecord.RCS_SIZE;
            })
            .attr("transform", spaceJunkTranslationStart);

 spaceJunk.transition()    ⑧  
 .duration(1000)    ⑧  
 .attr("transform", spaceJunkTranslationEnd)    ⑧  
 .ease(d3.easeBackOut);    ⑧  

    spaceJunk.append("circle")
 .attr("r", 5)    ⑨  
 .transition()    ⑨  
 .attr("r", 2);    ⑨  

}, 1000);    ⑩   

本章概述了 D3 背后的基本概念和思想。我希望它已经激发了你学习更多关于 D3 以及深入了解高级可视化世界的兴趣。

你最大的收获应该是对 D3 中的数据连接和选择概念的理解。在我看来,这两个概念是 D3 中最难理解的部分。如果你已经理解了这些,那么你已经走上了掌握 D3 的道路,但不要停下来。D3 是一个庞大的 API,你还有更多要探索,所以请继续前进。有关 D3 的更多背景信息,请参阅 Bostock、Ogievetsky 和 Heer 在斯坦福大学发表的论文vis.stanford.edu/files/2011-D3-InfoVis.pdf[.]。

书的结尾现在越来越近了!我们已经涵盖了数据整理的主要方面:获取、存储、检索、清理、准备、转换、可视化和实时数据。还有什么剩下?假设你编写的代码不是用于个人或一次性使用(这没什么不好),我们现在必须将我们的代码部署到生产环境中。

原型设计、开发和测试只是战斗的一部分。将你的代码展示给你的用户——或者许多经常有要求的用户——将把你的代码推向极限,并可能暴露出许多问题。最后一章,“进入生产”,对这些问题和可以帮助你应对它们的策略进行了概述。

*## 摘要

  • 你进行了 SVG 的快速课程,学习了原语:circletextg元素。

  • 你学习了如何使用 D3 进行元素选择和创建。

  • 我们使用 D3 配置了一个元素的状态。

  • 我们讨论了如何通过数据连接从数据生成动画化的 D3 可视化。

  • 我们通过鼠标事件为我们的可视化添加了交互性。

  • 我们通过加载 JSON 文件升级到真实数据以生成可视化。

14

达到生产

本章涵盖

  • 在将数据处理管道迁移到生产时解决关注点、风险和问题

  • 部署一个生产就绪应用程序的策略

我们共同的数据处理之旅即将结束,尽管此时你的真正工作即将开始。虽然探索性编码、开发和测试可能看起来是一大堆工作,但你还没有看到最糟糕的。构建和测试你的数据处理管道通常只是项目生命周期中的小部分。

软件开发的丑陋真相是,大多数开发者进入生产阶段后,将花费大部分时间维护现有应用程序。达到生产阶段是一个大事件:我们需要部署我们的应用程序,监控它,并理解其行为。

然后,我们需要更新我们的应用程序,以便我们可以部署错误修复或升级其功能集。同时,我们需要一个坚实的测试制度来确保它不会变成一团糟。这些是我们应用程序进入生产阶段后必须处理的一些事情。

这本《使用 JavaScript 进行数据处理》的最后一章将带你快速浏览生产关注点和问题。我们将学习预期的问题,如何处理意外问题,以及处理这些问题的各种策略。本章不是实战性的,也不是详尽的;它是对你达到生产阶段将面临的问题的一个预览。这是一个如此庞大的主题,而我们剩下的时间不多,所以请系好安全带!

14.1 生产关注点

你准备好将你的应用程序迁移到生产环境了吗?这是我们希望应用程序能够送达目标受众的地方。我们可能会将代码推送到托管服务器或云中的虚拟机。无论我们在哪里托管我们的应用程序,我们都需要将其放置在那里,并使其对尽可能多的用户可用。这是生产部署的一个目标。其他目标在表 14.1 中列出。

表 14.1 生产目标

目标 描述
交付 将我们的软件交付给目标受众。
容量 为所需数量的用户提供服务。
部署 无故障或问题地更新我们的软件。
恢复 快速从发生的任何故障中恢复。
系统寿命 在其预期的寿命期内运行。

通过这些目标,我们面临着许多风险。其中最主要的风险是我们可能会部署有缺陷的代码,我们的应用程序会崩溃。其他潜在的风险在表 14.2 中列出。不同的项目也将有其独特的风险。

我们在这里究竟冒着什么风险?好吧,我们冒着应用程序无法按预期工作的风险。我们的应用程序可能因为任何原因而崩溃。然后它将无法处理其工作负载,变得无响应,或者导致我们基于错误的数据做出商业决策。

这为什么很重要呢?好吧,当系统出现故障时,组织会停止工作,因此损坏的系统会花费金钱。此外,当我们根据坏数据或损坏的数据采取行动时,我们会为我们的业务做出错误的决定。损坏的系统也可能导致用户沮丧和信誉损失,尽管信誉损失很难量化。在最坏的情况下,例如,根据第十二章中的早期预警系统,人们可能会因为系统故障而受到伤害。我们需要考虑我们的应用程序故障可能造成的损害。这将帮助我们确定在为生产使用设置应用程序时需要采取多少预防措施。

表 14.2 生产风险

风险 描述
部署了损坏的代码。 系统在初始发布或更新时崩溃。这很可能表明测试制度不充分。
需求或负载超过了应用程序的处理能力。 系统的需求超过了系统有效响应的能力。系统要么响应缓慢,要么因为过载而损坏。
进入的数据是损坏或无效的。 进入的坏数据是应该预料到的事情,我们的系统应该足够健壮,能够处理它。
损坏的代码通过新的输入、用例或变化条件表现出来。 一个错误可以在代码中隐藏很长时间,直到某些事情发生变化(输入、系统使用方式、另一个代码模块)导致错误表现出来。

在本章中,我们将讨论一系列生产问题。这些问题列在表 14.3 中。我们将在本章中简要讨论这些问题。

表 14.3 生产问题

问题 描述
部署 我们必须以安全、方便且易于回滚的方式将我们的应用程序部署到生产环境中,以防万一出现问题。我们需要一个部署管道。
监控 我们如何知道系统正在运行并且功能正常?我们需要一个监控系统。
可靠性 我们的系统必须有效且可靠地运行。当用户需要时,它必须在那里。我们需要确保可靠运行的技术。系统应该优雅地处理故障并重新进入运行状态。
安全性 我们的系统应该足够安全,以防止未授权的入侵或窥探。我们需要安全原则和机制来保护我们的系统。
可扩展性 我们的系统将如何处理大量用户活动的突发情况?我们将如何扩展我们的系统以满足用户需求,而不会导致系统失败?

14.2 将我们的早期预警系统推向生产

在第十二章中,我们为空气污染监测开发了一个早期预警系统。现在让我们谈谈将这个项目推向生产。我们需要将应用程序部署到生产环境中;这是一个应用程序通过它交付给用户的环境。

我们究竟在交付什么,我们面临什么问题?我们可能有一个可以由数千人查看的仪表板。我们的系统能够处理这么多并发用户吗?它能否及时响应他们?

我们可能有一个自动生成的报告,发送给数百名需要每天获取这些信息的用户。它能否持续不断地执行而不会失败?当我们的紧急预警系统触发,并将短信警报发送给应急响应人员时,我们如何确保系统可以无问题地完成这项任务?这些是我们转移到生产时必须深思的问题。

将应用程序投入生产需要更新我们的工作流程。图 14.1 中所示的工作流程与您可能从本书第一章中记住的不同。它现在显示,开发的最终结果是应用程序的生产部署。也就是说,应用程序的代码最终将从开发环境转移到其生产环境。

c14_01.eps

图 14.1 开发工作流程和何时开始考虑生产

图 14.1 表明,我们在规划阶段就需要开始考虑生产。在开发早期更多地考虑架构和设计,更不用说测试,可以让我们在遇到与可靠性、安全性和性能相关的问题时减少痛苦。

我们首先要解决的问题是如何将我们的应用程序部署到其生产环境。图 14.2 展示了一个常见的软件开发过程,称为持续交付——一个持续的迭代序列——其中每个迭代之后都跟随生产部署。

要实现持续交付,我们需要一个部署管道。

14.3 部署

我们如何将应用程序部署到生产环境取决于我们部署的位置,因为不同的环境将需要不同的机制。不过,通常来说,有一个脚本/自动化部署管道是很常见的,图 14.3 中展示了这样一个例子。管道中的每个阶段(带有虚线的框)都由一个构建或部署脚本实现,以及在每个阶段之间的网关(菱形),这些网关控制着进入(或未进入)下一阶段。这些网关可能是自动的,也可能需要手动激活,具体取决于您项目的适当性。

c14_03.eps

图 14.3 持续交付部署管道

c14_02.eps

图 14.2 持续交付:我们的应用程序经常且定期部署到生产环境。

我们从左侧开始,将代码更改提交到我们的版本控制系统。这触发了您的持续集成系统的调用,该系统会自动构建和测试您的代码。如果代码构建并通过测试,那么我们就进入生产部署阶段。

我们可以为此阶段编写脚本,将我们的代码部署到生产环境。如果部署阶段成功,我们就进入自动监控阶段。在这个阶段,我们可能会运行烟雾测试或健康检查,然后进行常规的自动监控。恭喜!你的应用已经通过了生产部署。

重要的是要注意,代码部署,即你应用的更新,往往是应用失败的最大原因。图 14.4 提供了一个例子。在我们的第一个和第二个版本中一切顺利,但然后,例如,在第三个版本中,我们可能会发现一个严重的错误,这个错误 somehow 通过了我们的测试流程,然后——系统失败了。

c14_04.eps

图 14.4 失败通常发生在新软件发布时。处理它们最快的方法是回滚到之前的正常版本。

当我们遇到重大系统故障时,我们应该怎么做?最简单、对用户影响最小的解决方案是立即将整个系统回滚到之前的正常版本。这突显了我们部署管道的一个重要要求。我们应该努力实现一个部署系统,使其能够轻松回滚或重新部署应用的早期版本。

然而,不幸的是,错误可能在被发现之前长时间不被注意。我们必须为将来可能出现的错误做好准备,并且它们通常会在最不方便的时候出现。对于大型系统故障,应用无法工作可能是显而易见的,但对于不那么严重的问题呢?我们如何知道系统是正常工作还是异常工作?我们需要一种方法来监控我们的应用。

14.4 监控

将我们的代码部署到生产环境是第一步。现在我们需要知道应用是否在正常工作。我们必须对应用正在做什么有透明度;如果我们不知道问题,我们就无法修复它们。我们需要检查应用是否表现正常,以及它是否已经经历过失败并已恢复。

在开发过程中彻底调试代码很重要。阅读每一行代码并不等同于观察每一行代码的执行。调试是我们用来理解代码正在做什么而不是我们以为它在做什么的工具。

然而,不幸的是,当我们的代码在生产环境中运行时,我们并不能轻松地调试它。在你尝试在生产环境中使你的代码工作之前,你必须在你的开发工作站上做足够的测试和调试。

而不是调试生产代码,为了了解正在发生的事情,我们可以使用事件和指标的日志记录和报告来了解我们的应用是如何表现的。一种简单的方法(假设你已经有了一个数据库)是将你的日志和指标记录到你的数据库中,如图 14.5 所示。

c14_05.eps

图 14.5 从您的系统中收集日志、错误和指标,以便您可以了解其活动情况。

我们可能会倾向于将日志记录到标准输出或文件中,这是一个很好的开始方式,并且在开发期间很有用,但当应用程序进入生产阶段时,它的实用性就降低了。

如果我们将日志和指标放入我们的数据库中,我们就可以开始做一些有趣的事情。首先,我们可以使用数据库查看器远程查看数据,这在我们在物理上与运行我们应用程序的服务器分离时非常有用。其次,我们可以使用我们的数据处理和分析技能来转换、汇总并理解应用程序的行为。我们甚至可以构建一个定制的日志或指标查看器,或者使用现成的系统来搜索和查询应用程序的历史记录。

我们可以将我们的日志和监控系统进一步扩展——比如说,如果我们需要支持分布式系统(一组应用程序)。为此,我们可以创建(或购买)一个专门的监控服务器,如图 14.6 所示,为多个应用程序提供服务,并将它们的日志和指标整合到一个可搜索的系统中。

将我们的服务器监控系统集中化,使我们能够更好地理解我们的分布式系统。我们现在有一个地方来管理我们如何监控和报告我们的生产应用程序。这与第十二章中我们的早期预警系统的报告和警报系统类似,我们可以在这里重用那些相同的思想。例如,我们可能希望每天报告应用程序的性能,或者在检测到故障时触发短信警报。

服务器监控系统的一个改进是赋予它主动监控应用程序的能力。如图 14.7 所示,服务器监控系统可以与应用程序建立双向通信通道,并主动 ping 它以检查它是否仍然活跃、响应迅速且未过载。

c14_06.eps

图 14.6 多个系统可以输入到单个服务器监控系统。

c14_07.eps

图 14.7 我们可以使我们的系统与监控 API 之间的关系双向化;监控 API 现在正在主动检查我们系统的健康状况。

通过理解我们的应用程序正在做什么,我们现在可以持续了解其状态:它是在工作还是出了问题。但这仍然提出了一个问题,即我们如何最好地组织我们的代码以确保它继续工作并具有高度的容错能力。

14.5 可靠性

当我们将应用程序推向生产时,我们期望它能够以一定的可靠性运行。我们有一些方法可以在早期准备,以创建健壮和稳定的代码;然而,不可避免的是,问题会发生,我们应该注意编写能够快速从失败中恢复的代码。

可以使用许多策略来提高我们代码的可靠性和稳定性,其中最重要的莫过于充分的测试,这一点我们很快就会讨论。我们还将讨论各种技术,这些技术将帮助你创建容错代码。

14.5.1 系统寿命

我们理解我们的应用程序预期将保持运行多长时间是很重要的。通过这一点,我指的是在它重启或其宿主重启之前,它必须可靠运行的时间。如果你使用持续交付流程,那么你的交付周期将决定重启之间的时间,如图 14.8 所示。

c14_08.eps

图 14.8 你的部署计划决定了你系统的寿命(系统重启之间的时间)。

如果你的交付计划是每月一次,系统必须至少持续运行一个月。我们需要围绕这个时间段进行测试。

14.5.2 实践防御性编程

我通常喜欢以防御性编程的心态进行编码。这是一种工作模式,我们始终预期会发生错误,即使我们还不清楚它们会是什么。我们应该预期我们会得到不良的输入。我们应该预期我们调用的函数或我们依赖的服务会表现不佳或无响应。

你可以将其视为墨菲定律:如果某件事可能出错,它就会出错。如果你在编码,发现自己正在避免一个问题,并告诉自己这个问题永远不会发生——那么,那就是你假设它将会出错的时候!当我们实践防御性编程时,我们假设任何和所有这样的问题都可能发生,并采取措施让我们的代码能够生存并报告失败。

培养这种态度将帮助你构建健壮的软件。

14.5.3 数据保护

如果我们有一个数据整理的第一规则,那么它应该是这样的:不要丢失你的数据!

无论发生什么,保护你的数据是最重要的。内化并遵守以下规则:

  • 一旦数据被捕获,就安全地记录它。

  • 永远不要覆盖你的源数据。

  • 永远不要删除你的源数据。

如果你遵循这些规则,你的数据将会得到保护。在某些情况下,例如,当你的数据库扩展开始影响你的系统寿命时,你可能需要打破这些规则,但当你这样做时要小心——这里可能有龙。

在第十二章中,当我们讨论早期预警系统时,我们讨论了在将数据捕获到数据库之前或之后转换数据的影响。我再次强调这一点。你应该首先捕获你的重要数据——确保它是安全的——然后再对它进行任何额外的工作。图 14.9 指出了正确处理这一点的正确方法。捕获你的数据的代码是保护你的数据的代码;它应该是你测试最充分的代码。你还应该尽量减少执行这项工作的代码量。少量的代码更容易测试,也更容易证明其正确性。

c14_09.eps

图 14.9 在进行任何工作之前,将你的数据捕获到数据库中。不要冒险丢失你的数据!

在转换数据并将其写回数据库时,永远不要覆盖你的源数据。如果你这样做,你的转换代码中的任何问题都可能导致源数据的损坏。错误是会发生的;丢失数据不应该。这是一个你不应该承担的风险。请将你的转换数据与源数据分开存储。图 14.10 显示了应采取的方法。

这可能看起来很明显,但你还需要备份你的源数据。在业界,我们喜欢说,除非我们有至少三份副本,否则它就不存在!此外,如果你的源数据是定期更新或收集的,你也应该定期备份它。如果这变得繁琐,那么你需要自动化它!

c14_10.eps

图 14.10 在转换数据时,将输出写入单独的数据库表/集合。不要冒险损坏你的源数据!

14.5.4 测试与自动化

测试是生产健壮代码的一个关键因素,尽管在这本书中我们几乎没有涉及它——但这并不意味着它不重要!在我们逐章处理代码的过程中,我们边做边手动测试,并没有进行任何自动化测试。但是,当你致力于实现准确且高度可靠的软件时,自动化测试是非常重要的。

为了使你的测试有价值,你还需要在尽可能接近生产环境的测试环境中进行。许多生产部署的失败都伴随着熟悉的借口:“但是它在我的电脑上运行过!”如果你的开发工作站与你的生产环境不同,这很可能是这样,那么你应该使用 Vagrant 或 Docker 来模拟你的生产机器。你也可以考虑使用 Docker 来配置你的生产环境。

让我们讨论几种我认为适用于数据管道的流行测试类型。这里提到的所有测试类型都可以自动化,所以一旦你创建了一个测试,它就可以作为你持续交付管道的一部分自动运行(如第 14.3 节所述)。

测试驱动开发

c14_11.eps

图 14.11 测试驱动开发周期

测试驱动开发(TDD)从构建一个失败的测试开始。然后我们编写代码来满足这个测试并使其通过。最后,我们重构代码以改进它(如图 14.11 所示)。TDD 周期产生可靠的代码,可以快速演变。它通常被称为构建细粒度单元测试来锻炼你的代码并验证其正确性的过程。单个单元测试将测试你代码的一个方面。这种测试的集合被称为测试套件。

使用 TDD 会导致您拥有一个涵盖应用程序功能的重大测试套件。这些测试在您对代码进行更改时自动运行。在实践中,至少如果您有良好的测试覆盖率,这将使破坏应用程序变得困难,并允许您积极重构和重构以改进其设计——最终使添加新功能变得更加容易。这允许快速向前发展,同时您有一个安全网来捕捉出错时的问题。

您可能还记得在第一章我说过,许多程序员最大的失败是没有规划他们的工作,以及这后来引起的所有问题。好吧,在我看来,TDD 在很大程度上解决了这个问题。您不能进行 TDD 而不进行规划。它们是手牵手的关系——您必须在编码系统之前规划您的测试。TDD 迫使您进行规划,并帮助您预见和减轻可能在未来困扰您的风险。这从不完美,但它可以在很大程度上纠正我们工作流程中缺乏规划的缺陷。

不幸的是,TDD 与探索性编码不太兼容。这是因为探索性编码是我们试图理解我们拥有的数据和发现应用程序需求的过程的一部分。从这个意义上说,探索性编码为我们的规划阶段提供信息。为了使其有效,我们必须将其从 TDD 阶段中提取出来。您可以在图 14.12 中看到更新的工作流程。我们使用探索性编码在进入 TDD 之前理解我们的数据和需求。在每一轮开发之后,我们将部署到生产环境,就像任何敏捷流程一样,周期会迭代重复,直到应用程序完成。

c14_12.eps

图 14.12 成功的 TDD 依赖于良好的规划;探索性编码建立理解并融入规划,因此它通常会在 TDD 之前。

我热爱测试优先的哲学,我认为它不仅适用于单元测试。正确实践 TDD 会使您养成在编码之前思考如何测试系统的习惯。在我看来,这是它的最大好处。一旦您切换到这种心态,它将在更可靠和更好测试的系统上产生积极回报。

我们可以使用任何流行的测试框架在 JavaScript 中执行 TDD。我个人的选择是使用 Mocha。

输出测试

这种测试形式,我倾向于称之为输出测试,简单且适用于以数据为导向的应用程序。它相当简单:比较代码的前后迭代输出。然后提出以下问题:输出是否发生了变化?这种变化是预期的吗?这将帮助您了解代码的更改是否破坏了您的数据管道。

输出可以是您应用程序中任何有意义的部分。在数据管道中,输出可能是从管道输出的数据的文本版本。在另一种类型的应用程序中,输出可能是描述应用程序行为的文本日志。图 14.13 展示了该过程。

c14_13.eps

图 14.13 比较测试运行的输出,我称之为“输出测试”,是测试数据管道代码更改的绝佳方式。

这个测试过程允许您检测意外的代码故障,并让您有自由重构和重新构建您的数据管道,而不用担心将其破坏。

我经常使用版本控制软件(例如,Git 或 Mercurial)来管理我的输出测试。我将输出数据存储在单独的仓库中。然后,在测试运行之后,我使用版本控制软件来检测输出是否发生变化,如果发生了变化,我会查看比较以了解差异。

这种测试方法可能看起来像是暴力方法。但它简单、有效,并且易于操作。

集成测试

集成测试是比单元测试更高层次的测试形式。通常,一个单独的集成测试会测试多个组件或代码的多个方面。集成测试通常比单元测试覆盖的范围更广,而且不那么繁琐——你花同样的钱可以得到更多的回报。因此,我认为集成测试可能比单元测试更具有成本效益。

虽然请别误会我的意思;我确实相信单元测试是有效的,并且是生产出坚不可摧代码的最佳方式。但它也很耗时,投入的时间需要物有所值。考虑使用集成测试以实现全面的测试覆盖,并将单元测试留给您最宝贵的代码或需要最可靠代码的部分。

当您在系统中有一个可以应用测试的自然边界时,集成测试效果最佳。我之所以提到这一点,是因为在我们的早期预警系统中,我们有一个合适的系统边界。我们的 REST API 通过 HTTP 接口交付,而集成测试恰好与 HTTP 工作得非常好。

我们可以使用任何 JavaScript 测试框架来进行自动集成测试。图 14.14 显示了 Mocha 如何应用于测试 REST API。在这种情况下,我们可以像第十一章中那样启动我们的 Web 服务器进行测试。一旦测试完成,我们评估结果,Mocha 会通知测试通过/失败,然后我们停止 Web 服务器。

c14_14.eps

图 14.14 HTTP REST API 可以很容易地使用标准的 JavaScript 测试框架,如 Mocha 进行测试。

记录和回放

另一种有用的测试技术是我喜欢称之为“记录和回放”的技术。这种方法与数据处理管道配合得很好,尤其是在可以将管道阶段解耦到每个阶段的结果可以被记录并回放以创建下一阶段的自动化测试的程度。这使我们能够为数据处理管道的每个阶段创建一种单元测试。但如果逐阶段测试对您来说不可行,您仍然可以使用记录和回放来测试整个数据处理管道。

我们已经以这种方式做过这件事了。回想一下第十二章,我们使用了预先录制的大气污染数据(我们的测试数据)并将其输入到我们的系统中。我们使用预先准备好的数据,以便我们有一个方便的方式来开发和演进我们的系统,但我们也可以使用这些记录的数据来为系统创建一个自动化的测试。

我在游戏行业中见过回放技术的应用,拥有回放功能对于游戏体验通常很重要。我也见过这种技术在客户端/服务器类型的应用中有效地使用,其中方程的一侧可以被记录,然后通过回放记录来模拟给另一侧。

压力测试

负载测试是我们可以应用于我们的 Web 服务器或 REST API 的另一种测试形式。这是在系统上应用或模拟负载的过程,以确定它可以处理多少。图 14.15 表明我们可以向服务器发送请求流来测试其容量。

存在着我们可以用于负载测试的在线服务,或者我们可能编写一个定制的脚本来适应我们的应用程序。无论哪种方式,我们现在都可以优化我们的系统,使其能够处理更多的负载。如果没有这种性能测试,我们就无法知道我们的优化是否有助于改善情况,或者是否使情况变得更糟。

c14_15.eps

图 14.15 对您的服务器进行负载测试,以查看它可以处理多少流量和工作负载。

负载测试类似于压力测试,但区别是微妙的。在负载测试中,我们试图测试系统是否可以处理我们打算处理的负载,但在压力测试中,我们积极尝试将系统推向其断裂点,以了解那个点在哪里。

浸泡测试

最后要提到的测试形式是浸泡测试。这是一种长时间运行的测试,用于确定您的系统是否能够运行其预期的系统寿命。例如,我们之前决定,我们的系统寿命将是一个月,以符合我们的持续交付计划。我们的系统必须在野外和负载下至少运行一个月。

要相信我们的应用程序可以存活这么长时间,我们可以模拟它在负载下的运行时间。这就是我们所说的压力测试。在测试期间,你需要从应用程序中收集指标。例如,指标包括在测试期间测量其内存使用情况和响应时间。现在使用你的数据分析技能和可视化技能来理解这些数据在告诉你什么。系统能否坚持下去?其性能是否随时间稳定?如果不稳定,那么你可能需要采取纠正措施。

14.5.5 处理意外错误

错误会发生。软件会失败。如果我们计划得当,我们已经有了一个很好的理解,即我们的数据管道可能会以预期的方式失败。例如,当我们从传感器读取数据时,最终它们会给我们错误的数据。或者当我们有人员进行数据录入时,数据中会包含偶尔的错误。这些是我们可以在软件设计中轻松预测、计划和缓解的风险。

当发生我们没有预料到的错误时会发生什么?我们的应用程序将如何处理?好吧,我们无法预测生产中可能发生的每一个问题。这尤其适用于我们在新领域或独特领域构建软件时。然而,我们可以计划让我们的应用程序优雅地处理意外情况,并尽可能恢复。

不同的人会告诉你以不同的方式处理这个问题。我首选的方法是意外的错误不应该使你的应用程序瘫痪。相反,应该报告问题,并允许应用程序继续运行,如图 14.16 所示。

c14_16.eps

图 14.16 意外的错误不应该使你的应用程序瘫痪。确保它能够尽可能好地处理它们并继续运行。

实现这一点的最简单方法是处理 Node.js 中的未捕获异常事件,如下所示列表。在这里,我们可以报告错误(例如,向第 14.4 节中的监控服务器报告)然后允许程序尝试继续。

列表 14.1 在 Node.js 中处理未捕获的异常

process.on("uncaughtException", (err) => {
    // ... Report the error ...
}); 

某些人主张不处理未捕获的异常。他们说我们应该让程序崩溃并重新启动;然后我们应该监控崩溃,并在发现时纠正这些崩溃。我认为在某些情况下,这可以是一个有效的方法,但根据具体情况,在数据管道的背景下,我觉得这相当令人不安。

如果你让程序终止,正在进行的异步操作会发生什么?它们将被终止,这可能会导致数据丢失(参考第 14.5.3 节的数据整理的第一条规则——“不要丢失你的数据”)。我更喜欢显式地处理未处理的异常,将错误报告给错误跟踪系统,然后让我们的系统尽可能恢复。我们仍然可以了解发生的问题,我相信我们现在面临的数据丢失风险更小。

同样,我们也应该处理未处理的拒绝承诺,如列表 14.2 所示。这是一个稍微不同的场景。无论你如何处理未捕获的异常,你都应该始终为未处理的承诺拒绝设置处理程序。如果你不这样做,你可能会让未处理的拒绝进入生产环境,在那里你将不知道你在某个地方遗漏了一个 catch(你可以在开发中通过阅读 Node.js 控制台来知道这种情况发生了)。

列表 14.2 在 Node.js 中处理未处理的承诺拒绝

process.on("unhandledRejection", (reason, promise) => {
    // ... Report the error ... 

});

即使你能告诉我你总是在你的承诺链的末尾加上 catch(你永远不会忘记这一点,对吧?),但你也能告诉我你的 catch 回调中从未有过错误吗?只需在最终的 catch 处理程序中有一个异常,你现在就有一个未处理的承诺拒绝,它可能不会被注意到进入生产环境。这就是为什么这是一个如此隐蔽的问题。

错误确实会发生,你的数据管道不应该因为它们而停止运行。此外,别忘了测试你的未捕获异常处理程序。像所有其他代码一样,这段代码也需要测试;否则,你无法确信你的系统可以应对这些最坏的情况。

14.5.6 设计进程重启

对于任何运行时间较长且昂贵的进程——例如,我在第一章中提到的那个数据库副本——你应该设计这个进程,使其能够处理中断和恢复。

你永远不知道什么时候会中断你的代码。它可能是一个显现的 bug,或者是一个网络中断。有人可能绊倒在电缆上,关闭了你的工作站。为了避免浪费时间,确保进程可以从它被中断的点(或附近)重新启动。参见图 14.17 了解这可能如何工作。

长运行进程应该定期提交其结果并记录其进度,例如,到你的数据库中。如果进程需要重启,它必须然后检查数据库并确定从哪里继续其工作。

c14_17.eps

图 14.17 设计长进程以在意外中断的情况下重启和恢复。

14.5.7 处理不断增长的数据库

任何运行时间较长且数据库不断增长的应用程序最终都会耗尽内存或磁盘空间。我们必须在它成为问题之前决定如何处理这个问题。我们可以通过以下策略的组合来处理它:

  • 清理旧数据。我们可以定期清理数据,但只有当旧数据不再相关时;否则,这违反了我们的规则“不要丢失你的数据”。

  • 归档旧数据。如果我们确实需要保留旧数据,那么我们必须定期将其存档到低成本存储解决方案中。在归档你的数据的代码上要小心。这里的问题意味着你会丢失数据。

  • 清理或存档并总结。定期清理或存档旧数据,但汇总它并保留旧数据的有关细节的摘要。

如果在定期清理或存档启动之前我们就用完了空间,会发生什么?如果这是一个危险,那么我们需要通过指标(第 14.4 节)来监控情况,或者在情况变得严重之前自动发出警报,或者根据需要自动激活清理或存档过程。

14.6 安全性

安全性对于您来说或多或少是一个问题,这取决于您的数据和系统有多有价值以及/或者有多敏感。我们早期预警系统中的数据本身并不敏感,但我们仍然不希望任何人篡改它,隐藏紧急情况或触发误报。可能更重要的是,我们需要确保系统能够安全访问,以防止任何形式的干扰。

我们无法期望应对所有可能的安全问题,但我们可以通过采取分层方法尽可能做好准备。就像城堡既有城墙又有护城河一样,多层可以使得我们的系统更加安全。

14.6.1 身份验证和授权

我们的第一层安全措施是确保访问我们数据和系统的人是我们允许的。通过身份验证,我们确认人们是他们所说的那个人。通过授权,我们检查一个人是否有权访问某个系统或数据库。

身份验证通常是在用户可以使用系统之前验证用户的密码。由于 HTTP 服务是无状态的,我们必须以某种方式记住(至少在一段时间内)用户的安全凭证。存储在服务器或数据库中的会话会记住这些细节。在客户端,用户通过浏览器中的 cookie 来识别;然后服务器将 cookie 与会话关联起来,并可以记住经过身份验证的用户。这种场景在图 14.18 中展示。

c14_18.eps

图 14.18 一个 cookie 识别用户给服务器;服务器在会话中记住用户的身份验证。

当在 Node.js 下工作时,我们可以使用事实上的标准库 Passport 来管理我们的身份验证。

我们可能通过在数据库中为每个用户记录一些额外数据来实现授权;这些额外数据将记录用户在我们系统中的权限级别。例如,他们可能被标记为普通用户或管理员用户,或者介于两者之间的权限级别。然后我们可以根据需要从数据库中读取用户的权限级别,以确定是否允许或拒绝访问敏感的服务器端数据或操作。

14.6.2 隐私和机密性

为了减轻第三方拦截和窃听我们数据的风险,我们可以在所有点对其进行加密。这并不是我们预警系统的担忧,因为数据本身并不那么机密,但在另一个更安全的系统中,你可能想要考虑使用内置的 Node.js Crypto 模块来加密你的敏感数据。你可能还想使用 HTTPs 来加密客户端和服务器之间的通信。加密甚至可能得到数据库的支持,这是需要考虑的,以实现数据保护的最终权威级别。

如果你正在管理有关个人用户的数据,那么你应该考虑对存储的数据进行匿名化。清除每个记录中的任何字段,以防止它与特定个人相关联,这样如果数据库以某种方式丢失,敏感数据就不能与任何特定个人联系起来。这有助于减少你的隐私担忧。

在我们当前的示例项目中,更有兴趣的是保护管理和存储我们数据系统的安全。特别是,我们的数据库应该位于私有网络和防火墙后面,这样它就不能直接被外部世界访问,如图 14.19 所示。

c14_19.eps

图 14.19 我们的数据库隐藏在私有网络上的防火墙后面。它不能从外部世界访问,因此更加安全。

如果我们有敏感的商业逻辑和/或重要的知识产权需要保护,我们也可以更进一步,将服务器分为公共和私有组件,然后将私有服务器与数据库一起移到防火墙后面,如图 14.20 所示。

例如,想象一下,你有一个分析数据以生成预警系统每日报告的秘密算法。报告的输出并不那么机密,只是用来解释数据的公式,而你认为这是有价值的知识产权。你可以将生成报告的算法移到防火墙后面,这样它就会对外部入侵的脆弱性降低。

这种对系统进行分区和隔离其最敏感部分的例子,是创建多层安全性的另一个例子。要突破系统的最敏感部分,潜在的攻击者必须突破多个级别的安全。

毫无疑问,你已经听说过这一点,但同样重要的是,你需要保持你的服务器操作系统和软件更新,以跟上最新的已知安全漏洞。你需要明白,你的整个系统只有在其最薄弱环节上才是安全的。

c14_20.eps

图 14.20 将我们的服务器分为公共和私有组件;然后将私有服务器放在防火墙后面。我们现在为敏感操作和知识产权提供了一个更安全的家。

14.6.3 秘密配置

在安全方面需要注意的最后一点是,你需要一种安全的方式来存储你的应用程序的秘密。回想一下第十二章中我们创建的用于存储应用程序配置详细信息的文件——例如,存储到你的电子邮件服务器的登录详情。

这种方法鼓励你将配置详情存储在版本控制中,但这是管理敏感配置最不安全的方式。我们这样做纯粹是为了简单,但在生产系统中,我们需要考虑这种做法的安全影响。例如,在这种情况下,我们可能不希望授予对源代码存储库的访问权限也授予对电子邮件服务器的访问权限。

我们在生产中安全处理这个问题的方法取决于我们选择的云提供商,但我们应该使用提供的(或由受信任的第三方提供的)安全存储或保险库,因为自己构建用于存储秘密的安全存储充满了危险。

14.7 扩展

当我们进入生产阶段并发现我们的应用程序无法处理所需的工作负载时,我们该怎么办?我们如何增加应用程序的容量?我们需要能够扩展我们的应用程序。

就像本章讨论的任何主题一样,我们在规划时投入的任何思考都将使我们以后少受很多痛苦。

14.7.1 优化前的测量

在我们能够理解和改进系统的性能之前,我们必须对其进行测量。我们有多种不同的指标可供选择:数据吞吐量(例如,每秒字节数)、服务器响应时间(以毫秒为单位),或应用程序可以服务的并发用户数。

主要的观点是,就像任何优化过程一样,我们无法希望提高性能,除非我们能够测量它。一旦我们能够测量性能,我们现在就可以进行实验,并明确地展示我们的优化努力是否产生了结果。

我们可以通过捕获、记录和分析适当的系统指标来评估系统的性能。在第九章中,你的数据分析技能在这里将很有用,用于确定趋势和找到指标中的模式。有了测量性能的系统,你现在可以思考扩展以提高应用程序的性能。

14.7.2 垂直扩展

我们应该考虑的第一个扩展方法是垂直扩展。这是扩展中最简单的方法,通常不需要对您的应用程序进行更改。如图 14.21 所示,我们增加应用程序运行的 PC 的大小。如果我们运行在物理硬件上,这很困难,但如果我们运行在任何主要云提供商的虚拟机(VM)上,这将是微不足道的。

c14_21.eps

图 14.21 您可以通过增加托管服务器的虚拟机的大小来扩展服务器。

在这里我们所做的一切只是扩大了我们用来运行应用的 PC 的规模。我们已经增加了 CPU、内存和硬盘,并希望同时增加了我们应用的能力。然而,这有一个限制,最终我们将耗尽服务器的容量。在这个时候,我们必须现在转向水平扩展。

14.7.3 水平扩展

我们用于扩展的第二个选项被称为水平扩展。这种扩展的最简单版本是我们将应用复制到多个在云中运行的虚拟机(VM)上,并使用负载均衡器在应用的不同实例之间分配负载,如图 14.22 所示。

这种扩展形式比垂直扩展更复杂,但可能更经济高效,尤其是在扩展可以自动进行的情况下,新的实例可以根据需求创建以扩展容量并满足工作量。

当应用实例必须共享资源时,这种方法会更困难。例如,所有实例可能共享同一个数据库,这有可能成为性能瓶颈。类似于我们讨论的安全问题,你的应用性能只能与其最薄弱的环节一样好——我们称之为瓶颈。幸运的是,大多数现代数据库,如 MongoDB,都可以以相同的方式进行扩展,并且可以分布到多台机器上。

c14_22.eps

图 14.22 水平扩展将负载分配到应用的多实例之间。

水平扩展还提供了另一个好处。它为我们提供了冗余和另一种处理应用故障的方法。如果应用失败,负载均衡器可以自动重新分配流量,远离故障实例,由其他实例处理,同时修复或重启失败的服务器。

如果你读完这一章感到有些不知所措,我不会感到惊讶。将应用推向生产是一个困难的过程,你需要考虑很多因素。但如果你从这个章节中带走了一件事,那不是生产很复杂(尽管确实如此),也不是到达生产需要解决很多问题(尽管确实如此)。

如果你记得一件事,请记住这一点:与生产相关的问题都是好问题。这是因为这些都是与成功相关的问题。如果你的应用不成功,你就不会遇到这些问题。

现在你已经对将你的数据管道带入生产的一些方面有了某种程度的了解。这确实是一个庞大而复杂领域,当你进入生产时,你肯定会遇到你自己的独特问题。我可以就这个主题写一本书,但希望你现在有足够的了解,开始你自己的生产之路和产品的完成,无论那可能是什么。祝你好运,我的朋友。请享受这段旅程,并始终学习新事物。

摘要

  • 你了解到将你的数据管道带入产品意味着要处理一个全新的问题世界。

  • 我们讨论了使用持续交付技术,你的应用部署可能的样子。

  • 我们描述了能够回滚失败的部署是任何部署脚本的基本功能。

  • 我们探索了一种监控系统的结构,该系统能够让我们检查我们的应用是否运行良好且性能优秀。

  • 我们学习了各种提高我们代码可靠性、增加系统寿命、更好地保护我们的数据以及优雅处理意外错误的方法。

  • 你了解到良好的安全性是一个多层次的方法,潜在的攻击者必须突破多个层次才能危害你应用的安全性。

  • 最后,你了解了如何通过垂直和水平扩展来增加应用程序的容量。

附录 A:JavaScript 速查表

更新

您可以在jscheatsheet.the-data-wrangler.com找到此速查表的更新和演变版本。

记录日志

记录日志是你的最佳朋友。这是检查和验证数据最简单的方式:

console.log("Your logging here"); // General text logging for debugging.

const arr = [1, 2, 3];            // Your data.
console.log(arr);

console.trace();                  // Show callstack for current function. 

对象

let o = { A: 1, B: 2 };                 // Your data

let v1 = o["A"];                        // Extract field value
let v2 = o.A;

o["A"] = 3;                             // Set field value
o.A = 3;

delete o["A"];                          // Delete a field value
delete o.A;

let c = Object.assign({}, o);           // Clone an object
let ovr = { /* ... */ };
let c = Object.assign({}, o, ovr);      // Clone and override fields 

数组

let a = [1, 2, 3, 4, 5, 6];             // Your data
a.forEach(element => {
    // Visit each element in the array.
});

let v = a[5];                           // Get value at index
a[12] = v;                              // Set value at index

a.push("new item");                     // Add to end of array

let last = a.pop();                     // Remove last element

a.unshift("new item");                  // Add to start of array

let first = a.shift();                  // Remove first element

let a1 = [1, 2, 3];
let a2 = [4, 5, 6];
let a = a1.concat(a2);                  // Concatenate arrays

let e = a.slice(0, 3);                  // Extract first 3 elements

let e = a.slice(5, 11);                 // Extract elements 5 to 10

let e = a.slice(-4, -1);                // Negative indices relative to end                                   // of the array

let e = a.slice(-3);                    // Extract last three elements

let c = a.slice();                      // Clone array

let i = a.indexOf(3);                   // Find index of item in array
if (i >= 0) {
    let v = a[i];                       // The value exists, extract it
}

a.sort();                               // Ascending alphabetical sort

a.sort((a, b) => a - b);                // Customize sort with a user-defined                                    // function

let f = a.filter(v => predicate(v));    // Filter array

let t = a.map(v => transform(v));       // Transform array

let t = a.reduce((a, b) => a + b, 0)    // Aggregate an array 

正则表达式

let re = /search pattern/;              // Define regular expression
let re = new RegExp("search pattern");

let re = /case insensitive/ig           // Case insensitive + global

let source = "your source data";
let match = re.exec(source);            // Find first match.

while ((match = re.exec(source)) !== null) {
    // Find all matches.
} 

读取和写入文本文件(Node.js,同步)

const fs = require(‘fs’);

const text = “My text data”;                // Data to write.

fs.writeFileSync(“my-file.txt”, text);      // Write the to file.

const loaded = 

    fs.readFileSync(“my-file.txt”, “utf8”); // Read from file.

console.log(loaded); 

读取和写入 JSON 文件(Node.js,同步)

const fs = require(‘fs’);

const data = [

    { item: “1” },

    { item: “2” },

    { item: “3” }

];

const json = JSON.stringify(data);        // Serialize to JSON

fs.writeFileSync(“my-file.json”, json);   // Write to file.

const loaded = fs.readFileSync(“my-file.json”, “utf8”); // Read file.

const deserialized = JSON.parse(loaded); // Deserialize JSON.

console.log(deserialized); 

读取和写入 CSV 文件(Node.js,同步)

const fs = require(‘fs’);

const Papa = require(‘papaparse’);

const data = [

    { item: “1”, val: 100 },

    { item: “2”, val: 200 },

    { item: “3”, val: 300 }

];

const csv = Papa.unparse(data);     // Serialize to CSV.

fs.writeFileSync(“my-file.csv”, csv);     // Write to file.

const loaded = fs.readFileSync(“my-file.csv”, “utf8”); // Read file.

const options = { dynamicTyping: true, header: true };

const deserialized = Papa.parse(loaded, options); // Deserialize CSV.

console.log(deserialized.data); 
 let source = "your source data";

let match = re.exec(source);            // Find first match. 

附录 B:Data-Forge 快速参考表

更新

你可以在 dfcheatsheet.the-data-wrangler.com 找到这个快速参考表的更新和演变版本***。

将数据加载到 DataFrame 中

你可以将内存中的数据加载到 Data-Forge DataFrame 中:

let data = [ /* ... your data ... */ ];
let df = new dataForge.DataFrame(data);
console.log(df.toString()); 

加载 CSV 文件

将 CSV 文件中的数据加载到 DataFrame 中:

let df = dataForge
    .readFileSync("./example.csv", { dynamicTyping: true })
    .parseCSV();
console.log(df.head(5).toString()); // Preview first 5 rows 

加载 JSON 文件

同时将 JSON 文件加载到 DataFrame 中:

let df = dataForge
    .readFileSync("./example.json")
    .parseJSON(); 
console.log(df.tail(5).toString()); // Preview last 5 rows. 

数据转换

使用 select 函数转换或重写你的数据集:

df = df.select(row => transformRow(row)); 

数据过滤

使用 where 函数过滤数据:

df = df.where(row => predicate(row)); 

删除列

使用 dropSeries 函数删除数据列:

df = df.dropSeries("ColumnToRemove"); 

保存 CSV 文件

将修改后的数据保存到 CSV 文件中:

df.asCSV().writeFileSync("./transformed.csv"); 

保存 JSON 文件

将修改后的数据保存到 JSON 文件中:

df.asJSON().writeFileSync("./transformed.json"); 

附录 C:Vagrant 入门

Vagrant 是一个用于构建和运行虚拟机的开源软件产品。您可以使用它来模拟生产环境或在各种操作系统上测试您的代码。它也是在一个与您的开发工作站隔离的环境中尝试新软件的好方法。

更新

您可以在网上找到这个入门指南的更新和演变版本,网址为vagrant-getting-started.the-data-wrangler.com

安装 VirtualBox

首先,您必须安装 VirtualBox。这是在您的普通计算机(主机)内运行虚拟机的软件。您可以从 VirtualBox 下载页面下载它,网址为www.virtualbox.org/wiki/Downloads

下载并安装适合您主机操作系统的软件包。请遵循 VirtualBox 网页上的说明。

安装 Vagrant

现在您应该安装 Vagrant。这是 VirtualBox 之上的一个脚本层,允许您通过代码(Ruby 代码)管理虚拟机的设置。您可以从 Vagrant 下载页面下载它,网址为www.vagrantup.com/downloads.html

下载并安装适合您主机操作系统的软件包。请遵循 Vagrant 网页上的说明。

创建虚拟机

在安装了 VirtualBox 和 Vagrant 之后,您现在可以创建虚拟机了。首先,您必须决定要使用哪个操作系统。如果您已经有一个生产系统,请选择相同的操作系统。如果没有,请选择一个长期支持(LTS)版本,该版本将长期得到支持。您可以在网页上搜索操作系统,网址为app.vagrantup.com/boxes/search

我喜欢 Ubuntu Linux,所以在这个例子中,我们将使用 Ubuntu 18.04 LTS(Bionic Beaver)。我们将安装的box的名称是ubuntu/bionic64。**

在创建 Vagrant 虚拟机之前,打开命令行并创建一个用于存储它的目录。切换到该目录;然后按照以下方式运行vagrant init命令:

vagrant init ubuntu/bionic64 

这将在当前目录中创建一个基本的Vagrantfile。编辑此文件以更改虚拟机的配置和设置。

现在启动您的虚拟机:

vagrant up 

确保您在包含 Vagrantfile 的同一目录中运行此命令。

这可能需要一些时间,尤其是如果您还没有在本地缓存操作系统的镜像。请给它足够的时间完成。一旦完成,您将有一个全新的 Ubuntu 虚拟机可供使用。

在虚拟机上安装软件

在您的虚拟机运行时,您需要在上面安装软件。您可以使用以下命令shell into机器:

vagrant ssh 

要更新虚拟机上的操作系统并安装软件,你将使用特定于该操作系统的命令。在这个例子中,我们使用 Ubuntu,所以接下来的三个命令是针对 Ubuntu 的。如果你选择了不同的操作系统,你需要使用适合它的命令。

使用你的新虚拟机时,首先要做的是更新操作系统。你可以在 Ubuntu 上使用以下命令来完成此操作:

sudo apt-get update 

现在,你可以安装你需要的任何软件。例如,让我们安装 Git,这样我们就可以克隆我们的代码:

sudo apt-get install git 

在 Ubuntu 上安装大多数软件都遵循相同的模式。不幸的是,获取尚未由包管理器支持的最新版本的 Node.js 会稍微复杂一些。为此,最好遵循 Node.js 文档中的说明,网址为 nodejs.org/en/download/package-manager/

对于 Ubuntu,它说我们可以使用以下命令安装 Node.js 版本 8:

curl -sL https://deb.nodesource.com/setup_8.x | sudo -E bash -
sudo apt-get install -y nodejs 

这比之前的例子复杂一些,但现在我们已经安装了 Node.js 的最新版本。你还可以通过遵循 Node.js 下载页面 nodejs.org/en/download/ 上针对你的操作系统的说明来手动安装 Node.js(例如,不使用包管理器)。

在虚拟机上运行代码

在你的虚拟机上安装了 Node.js 后,你现在可以运行代码。这可以通过在你的开发工作站上,将你的代码复制到与你的 Vagrantfile 相同的目录中轻松实现。放置在这个目录中的文件将自动在虚拟机中的此目录下可用:

/vagrant 

如果你有一个 index.js 文件位于你的 Vagrantfile 旁边,当你进入虚拟机时,你可以这样运行它:

cd /vagrant
node index.js 

开发者将 Vagrantfile 提交到版本控制是一种常见的做法。这样,新开发者(或你在不同的工作站上)只需要克隆仓库,然后运行 vagrant up 来构建开发环境。

你甚至可以将自定义设置和代码放入 Vagrantfile 中,以便在虚拟机中安装依赖项并启动应用程序或服务器。你可能记得,在本书的几个章节中,我提供了启动虚拟机、安装数据库并填充数据的 Vagrantfile,创建了一种即时数据库。当虚拟机完成启动时,你就有了一个可以工作的系统。

关闭虚拟机

完全完成你的虚拟机后,你可以使用以下命令将其销毁:

vagrant destroy 

如果你只是暂时完成机器并希望以后再次使用它,可以使用以下命令将其挂起:

vagrant suspend 

可以通过运行 vagrant resume 命令在任何时间恢复挂起的机器。

请记住,当您不使用虚拟机时,请销毁或暂停它们;否则,它们将无端消耗您宝贵的系统资源。**

posted @ 2025-11-15 13:05  绝不原创的飞龙  阅读(0)  评论(0)    收藏  举报