首页 > 编程语言 >NodeJS-高性能编程-全-

NodeJS-高性能编程-全-

时间:2024-05-23 16:12:07浏览次数:24  
标签:NodeJS 可以 编程 应用程序 js 高性能 内存 测试 使用

NodeJS 高性能编程(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

在像 Node.js 这样的平台上实现高性能意味着要了解如何充分利用硬件的各个方面,并帮助内存管理发挥最佳作用,并正确决定如何设计复杂的应用程序。如果您的应用程序开始消耗大量内存,请不要惊慌。相反,及时发现并解决内存泄漏。更好的做法是在问题出现之前进行监控和停止。

本书涵盖的内容

第一章,“介绍和构成”,介绍了主题,强调了性能分析和基准测试的重要性。它涉及将应用程序分成几个较小的组件,降低每个组件的复杂性,使其对参与应用程序开发的开发人员来说更易管理。在这里,您将了解到开发方法的重要性,将复杂性分解为更小且可重用的模块,这些模块可以更容易地进行分析,并在应用程序生命周期中与其他新的和更好的模块进行交换。

第二章,“开发模式”,讲述了有助于避免性能损失或帮助发现性能问题的良好编程模式。您将了解到精心选择简单技术和模式的重要性,并避免未来的问题。有了这个理念,您将更好地了解语言的工作原理,了解事件循环的重要性,异步编程的最佳实践以及语言的一些一流公民——流和缓冲区。

第三章,“垃圾收集”,涵盖了垃圾收集,其重要性以及行为。在这里,您将了解到 V8 内存管理,死内存和内存泄漏。您还将学习如何对应用程序进行分析,并发现由于开发人员未正确取消引用对象而导致的内存泄漏。

第四章,“CPU 性能分析”,讲述了对处理器进行性能分析,以及了解应用程序何时以及为何占用主机资源。在本章中,您将了解语言的限制,以及如何开发可以分成多个组件在不同主机上运行的应用程序,从而实现更好的性能和可伸缩性。

第五章,“数据和缓存”,解释了外部存储的应用程序数据以及它如何影响应用程序的性能。本章涉及应用程序中本地存储的数据,磁盘上的数据,本地服务,本地网络服务甚至客户端主机。您将了解到不同类型的数据存储方法会有不同的惩罚,选择最佳方法时必须考虑这些因素。您将了解到数据可以存储在本地或远程,并且有时可以对数据进行缓存,具体取决于数据的重要性。

第六章,“测试,基准测试和分析”,讲述了测试和基准测试应用程序的内容。它还涉及强制执行代码覆盖,以避免未知的应用程序测试区域。然后我们涵盖了基准测试和基准测试分析。您将了解到良好的测试如何能够准确定位基准测试和分析应用程序特定部分,以实现性能改进。

第七章,“瓶颈”,涵盖了应用程序外部的限制。本章讲述了当您意识到性能限制不是由于应用程序编程而是由于外部因素,例如主机硬件,网络或客户端时的情况。您将意识到外部组件对应用程序的限制,无论是本地还是远程。此外,本章解释了有时限制在客户端,并且无法做任何事情来改善当前的性能。

您需要为本书准备什么

唯一需要的软件是 Node.js。一些模块可能需要编译,因此 Linux 或 OS X 操作系统更容易测试示例。不需要特定的硬件。

这本书适合谁

这本书适合那些具有基本 Node.js 背景并且需要更深入了解这个平台的人。也许,你对这种语言很熟悉,也许你知道它有一个垃圾收集器,但你从来没有真正理解它是如何工作的,以及它如何根据你使用语言的方式而无法工作。需要基本的语言理解和扎实的经验。

约定

在这本书中,您会发现一些文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。

文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄显示如下:“我们可以通过使用include指令包含其他上下文。”

代码块设置如下:

    async.each(users, function (user, next) {
        // do something on each user object
        return next();
    }, function (err) {
        // done!
    });

任何命令行输入或输出都以以下形式书写:

$ node --debug leaky.js
Debugger listening on port 5858
mem. nodes: 37293
mem. nodes: 37645
mem. nodes: 37951
mem. nodes: 37991
mem. nodes: 38004

新术语重要单词以粗体显示。您在屏幕上看到的单词,例如菜单或对话框中的单词,会以这种形式出现在文本中:“现在,不要选择拍摄快照,只需点击加载按钮,然后从磁盘中选择快照。”

注意

警告或重要说明会以这样的方式出现在框中。

提示

提示和技巧会以这种形式出现。

第一章:介绍和构成

高性能很难,它取决于许多因素。开发人员应该不断追求最佳性能。为了实现这一点,开发人员必须了解他们使用的编程语言,更重要的是,了解语言在重负载下的性能表现,包括磁盘、内存、网络和处理器的使用情况。

开发人员只有了解语言的弱点,才能充分利用它。在一个完美的世界中,由于每个工作都不同,开发人员应该寻找最适合工作的工具。但这是不可行的,开发人员不可能知道每个最佳工具,因此他们必须为每个工作寻找第二最佳的工具。如果开发人员了解少量工具但能够精通它们,他们将会表现出色。

作为隐喻,锤子用来钉钉子,你也可以用它来打碎物体或锻造金属,但不应该用它来拧螺丝。语言和平台也是如此。有些平台非常适合做很多工作,但在其他工作上表现非常糟糕。有时可以缓解这种性能问题,但有时无法避免,这时就需要寻找更好的工具。

Node.js 不是一种语言;它实际上是建立在 V8 之上的平台,V8 是谷歌开源的 JavaScript 引擎。这个引擎实现了 ECMAScript,它本身是一种简单而非常灵活的语言。我说“简单”,是因为它没有访问网络、访问磁盘或与其他进程通信的方式。它甚至无法停止执行,因为它没有任何退出指令。这种语言需要在其上面构建一种接口模型才能发挥作用。Node.js 通过使用 libuv 暴露了一个(最好是)非阻塞的 I/O 模型来实现这一点。这种非阻塞 API 允许您访问文件系统,连接到网络服务并执行子进程。

API 还有另外两个重要元素:缓冲区和流。由于 JavaScript 字符串对 Unicode 友好,引入了缓冲区来处理二进制数据。流用作简单的事件接口来传递数据。在读取文件内容或接收网络数据包时,API 中到处都使用缓冲区和流。

流是一个模块,类似于网络模块。加载后,它提供对一些基本类的访问,这些类有助于创建可读、可写、双工和转换流。这些可以用来以简化和统一的格式执行各种数据操作。

当将二进制数据格式转换为其他格式(例如 JSON)时,缓冲区模块很容易成为您的好朋友。多个读取和写入方法帮助您转换整数和浮点数,有符号或无符号,大端或小端,从 8 位到 8 字节长。

大部分平台都设计成简单、小巧和稳定。它们被设计和准备用来创建一些高性能的应用程序。

性能分析

性能是在一定时间内使用一组定义的资源完成的工作量。可以使用一个或多个取决于性能目标的度量标准来分析性能。目标可以是低延迟、低内存占用、减少处理器使用,甚至减少功耗。

性能分析的行为也被称为分析。分析对于制作优化的应用程序非常重要,可以通过对应用程序的源代码或实例进行仪器化来实现。通过对源代码进行仪器化,开发人员可以发现常见的性能弱点。通过对应用程序实例进行仪器化,他们可以在不同的环境中测试应用程序。这种类型的仪器化也可以被称为基准测试

Node.js 以快速著称。实际上,它并不是那么快;它只能达到你的资源允许的速度。Node.js 最擅长的是不会因为 I/O 任务而阻塞你的应用程序。在 Node.js 应用程序中,性能的感知可能会误导。在其他一些语言中,当应用程序任务被阻塞时——例如,由于磁盘操作——所有其他任务都会受到影响。但在 Node.js 中,这种情况通常不会发生。

有些人认为这个平台是单线程的,这是不正确的。你的代码在一个线程上运行,但还有一些线程负责 I/O 操作。由于这些操作与处理器性能相比极其缓慢,它们在一个单独的线程上运行,并在它们为你的应用程序提供信息时通知平台。阻塞 I/O 操作的应用程序性能不佳。由于 Node.js 不会阻塞 I/O,除非你希望它这样做,因此在等待 I/O 时可以执行其他操作。这极大地提高了性能。

V8 是一个开源的谷歌项目,是 Node.js 背后的 JavaScript 引擎。它负责编译和执行 JavaScript,以及管理应用程序的内存需求。它是以性能为目标设计的。V8 遵循几个设计原则来提高语言性能。该引擎具有分析器和最好和最快的垃圾收集器之一,这是其性能的关键之一。它也不会将语言编译成字节码;它会在第一次执行时直接将其编译成机器码。

在开发环境中具有良好的背景将极大地增加开发高性能应用程序成功的机会。了解解引用的工作原理或者为什么你的变量应该避免切换类型非常重要。以下是其他一些有用的提示,你可能想要遵循。你可以使用像 JSCS 这样的样式指南和像 JSHint 这样的代码检查工具来强制执行它们,对自己和团队都是如此。以下是其中一些:

  • 编写小函数,因为它们更容易优化

  • 使用单态参数和变量

  • 更喜欢使用数组来操作数据,因为整数索引元素更快

  • 尽量使用小对象,避免长的原型链。

  • 避免克隆对象,因为大对象会减慢操作

监控

应用程序投入生产模式后,性能分析变得更加重要,因为用户的要求会比你更苛刻。用户不会接受任何超过一秒的东西,随着时间和特定负载的增加,监控应用程序的行为将变得极为重要,因为它将指出你的平台在哪里出现了问题或将在下一步出现问题。

是的,你的应用程序可能会失败,你能做的最好的就是做好准备。制定备份计划,备用硬件,并创建服务探针。基本上,预测你能想到的所有情景,并记住你的应用程序仍然会失败。以下是你应该监控的一些情景和方面:

  • 在生产环境中,应用程序的使用情况对于了解应用程序在数据大小或内存使用方面的发展方向至关重要。重要的是,你要仔细定义源代码探针来监控指标——不仅是性能指标,比如每秒请求或并发请求,还有错误率和每个请求服务的异常百分比。你的应用程序会发出错误,有时会抛出异常;这是正常的,你不应该忽视它们。

  • 不要忘记其他基础设施。如果你的应用程序必须达到高标准,你的基础设施也应该如此。你的服务器电源供应应该是不间断的和稳定的,因为不稳定会比应有的更快地降低你的硬件性能。

  • 明智地选择你的磁盘,因为更快的磁盘更昂贵,通常存储容量更小。然而,当你的应用程序不需要那么多存储空间而速度更重要时,这实际上并不是一个坏决定。但不要只看每美元的千兆字节。有时,更重要的是看每美元的千兆位每秒。

  • 此外,你的服务器温度和机房应该受到监控。高温会降低性能,你的硬件有操作温度限制。安全性,无论是物理的还是虚拟的,也非常重要。一切都符合高性能的标准,因为停止为用户提供服务的应用程序根本就不是在表现。

获得高性能

规划对于实现最佳结果至关重要。高性能是从基础开始构建的,从你的规划和开发方式开始。这显然取决于物理资源,因为当你没有足够的内存来完成任务时,你无法表现良好,但它也极大地取决于你如何规划和开发应用程序。精通工具将比仅仅使用它们带来更好的性能机会。

从开发的一开始就设定高标准将迫使规划更加谨慎。数据库层的糟糕规划会严重降低性能。谨慎的规划也会促使开发人员更多地考虑使用情况并更加谨慎地编程。

高性能意味着当你的所有资源都耗尽时,你必须考虑新的资源(处理器、内存、存储),而不仅仅是因为某一资源耗尽。高性能应用程序不应该在使用少量处理器和磁盘已满时需要第二台服务器。在这种情况下,你只需要更大的磁盘。

当今的应用程序不能设计为单片式。不断增长的用户群强制采用分布式架构,或者至少可以通过多个实例分配负载。这在规划初期就很重要,因为改变已经投入生产的应用程序会更加困难。

大多数常见的应用程序随着时间的推移会表现得更差,不是因为处理能力不足,而是因为数据库和磁盘上的数据量增加。你会注意到内存的重要性增加,备用磁盘对于避免停机时间至关重要。应用程序能够水平扩展非常重要,无论是将数据分片到服务器上还是跨区域分片。

分布式架构也会提高性能。地理分布的服务器可以更接近客户端,并给人以性能感知。此外,由更多服务器分布的数据库将作为一个整体处理更多流量,并允许 DevOps 实现零停机目标。这对于维护也非常有用,因为节点可以在不影响应用程序的情况下进行支持。

测试和基准测试

要知道应用程序在特定环境下是否表现良好,我们必须进行测试。这种测试称为基准测试。基准测试对每个应用程序都很重要。即使是相同的语言和平台,不同的应用程序可能表现不同,这可能是因为应用程序的某些部分的结构方式或数据库设计方式不同。

分析性能将指示应用程序的瓶颈,或者说,表现不佳的部分。这些部分需要改进。不断尝试改进表现最差的部分将提升应用程序的整体性能。

有很多工具可以使用,有些更专注于 JavaScript 应用程序,比如 benchmarkjs (benchmarkjs.com/) 和 ben (github.com/substack/node-ben),还有一些更通用的,比如 ab (httpd.apache.org/docs/2.2/programs/ab.html) 和 httpload (github.com/perusio/httpload)。根据目标,有几种不同类型的基准测试,它们如下:

  • 负载测试是基准测试的最简单形式。它用于查看应用程序在特定负载下的性能。你可以测试并了解应用程序每秒接受多少连接,或者应用程序可以处理多少流量字节。应用程序的负载可以通过查看外部性能(如流量)和内部性能(如使用的处理器或消耗的内存)来检查。

  • 浸泡测试用于查看应用程序在更长时间内的性能。当应用程序随着时间的推移而变得不稳定,需要进行分析以查看其反应时,就会进行这种测试。这种测试类型对于检测内存泄漏非常重要,因为一些应用程序在一些基本测试中可能表现良好,但随着时间的推移,内存泄漏和性能可能会下降。

  • 峰值测试是在负载迅速增加时使用的,以查看应用程序的反应和性能。这种测试在可能出现峰值使用的应用程序中非常有用和重要,运营商需要知道应用程序将如何反应。Twitter 是一个很好的例子,它的应用环境可能会受到使用峰值的影响(比如体育赛事或宗教日期),需要知道基础设施将如何处理它们。

随着应用程序的增长,所有这些测试都会变得更加困难。随着用户群的增长,应用程序的规模扩大,你失去了使用现有资源进行负载测试的能力。最好为这一时刻做好准备,特别是为了监控性能并跟踪浸泡和峰值,因为你的应用程序用户开始负责持续测试负载。

应用程序中的组合

由于对高性能应用程序的持续需求,组合变得非常重要。组合是一种实践,你将应用程序分解为几个更小、更简单的部分,使它们更容易理解、开发和维护。它还使它们更容易测试和改进。

避免创建庞大的、单一的代码库。当你需要进行更改时,它们效果不佳,如果你需要测试和分析代码的任何部分以改进性能,也不会起作用。

Node.js 平台帮助你——在某些方面,迫使你——组合你的代码。Node.js 包管理器NPM)是一个很棒的模块发布服务。你可以下载其他人的模块,也可以发布自己的模块。已经发布了数以万计的模块,这意味着在大多数情况下你不必重复造轮子。这很好,因为你可以避免浪费时间去创建一个模块,而是使用已经在生产中并被许多人使用的模块,这通常意味着错误将更快地被跟踪,改进也将更快地交付。

Node.js 平台允许开发人员轻松地分离代码。虽然平台并不强制你这样做,但你应该尝试并遵循一些良好的实践,比如下面描述的实践。

使用 NPM

除非需要,否则不要重写代码。花时间尝试一些可用的模块,并选择适合您的模块。这降低了编写错误代码的概率,并有助于发布的模块具有更大的用户群。错误将更早地被发现,并且不同环境中的更多人将测试修复。此外,您将使用更具弹性的模块。

在开始使用一些模块后,一个重要而被忽视的任务是跟踪变化,并在可能的情况下继续使用最新的稳定版本。如果一个依赖模块一年没有更新,您可能会在以后发现问题,但在一年之间的两个版本之间找出变化是很困难的。Node.js 模块往往会随着时间的推移而得到改进,API 的变化并不罕见。始终谨慎升级,并不要忘记测试。

将您的代码分离

同样,您应该始终将代码拆分为较小的部分。Node.js 可以帮助您以非常简单的方式做到这一点。您的文件不应该超过 5kB。如果超过了,最好考虑拆分。此外,作为一个良好的规则,每个用户定义的对象应该有自己单独的文件。根据文件命名:

    // MyObject.js
    module.exports = MyObject;

    function MyObject() {
      // …
    }
    MyObject.prototype.myMethod = function () { … };

另一个好的规则是检查您是否有一个比应该更大的文件;也就是说,它应该在不到 5 分钟的时间内由一个新的应用程序用户轻松阅读和理解。如果不是,这意味着它太复杂了,以后跟踪和修复错误将更加困难。

提示

记住,当您的应用程序变得庞大时,当打开一个文件进行修复时,您将像一个新的开发人员一样。您无法记住应用程序的所有代码,需要快速吸收文件的行为。

拥抱异步任务

该平台设计为异步,因此您不应该违背它。有时,对一些递归任务或者简单地循环执行一系列需要串行运行的任务可能会非常困难。您应该避免创建一个处理异步任务的模块,因为有一些模块被成千上万的人使用和测试。例如,async是帮助开发人员更好地执行的一种简单而实用的方式,学习曲线非常平滑:

    async.each(users, function (user, next) {
        // do something on each user object
        return next();
    }, function (err) {
        // done!
    });

这个模块有很多类似于数组对象中的方法,比如 map、reduce、filter 和 each,但是用于异步迭代。当您的应用程序变得更加复杂,一些用户操作需要一些串行任务时,这是非常有用的。错误处理也被正确地执行,执行停止也如预期般完成。该模块有助于运行串行或并行任务。

此外,通常需要串行任务的情况下,开发人员通常需要嵌套调用并进入回调地狱,但可以简单地避免这种情况。特别是在需要执行涉及多个查询的数据库事务时,这是非常有用的。

编写异步代码时的另一个常见错误是抛出错误。回调在定义它们的范围之外被调用,因此您不能简单地将回调放在try/catch块中。因此,除非这是一个非常关键的错误,应该使您的应用程序停止并退出,否则避免这样做。在 Node.js 中,抛出异常而不捕获它将触发uncaughtException事件。

该平台有一个对大多数开发人员来说是共识的规则——所谓的错误优先回调风格。这个规则非常重要,因为它允许更容易地重用您的代码。即使您有一个函数,其中没有抛出错误的机会,或者当您不希望它抛出并在函数内部使用某种错误处理时,您的回调应该始终将第一个参数保留给错误事件,即使它总是 null。这将允许您的函数与async模块一起使用。此外,其他开发人员在调试时将依赖这种风格,因此始终将第一个参数作为错误对象保留。

此外,您应该始终将函数的最后一个参数保留为回调。永远不要在回调之后定义参数:

    function mySuperFunction(arg1, ..., argN, next) {
        // do some voodoo
        return next(null, my_result); // 1st argument reserved for error
    }

使用库函数

库函数是您应该使用的另一种模块类型。它们有助于处理重复的任务,每个开发人员都必须执行这些任务。一些重复的任务可以毫不费力地完成,只需使用 lodash 或 underscore 中的库函数。它们是您代码的重要部分,并且具有良好的优化,您甚至无需考虑。许多循环任务,例如基于对象键在数组中查找对象,或将对象数组映射到每个对象的键数组,都可以在这些库中用一行代码完成。首先阅读文档,以避免使用库而未充分利用其潜力。

尽管这些类型的模块可能很有用,但如果选择不当,它们也可能降低性能。一些模块旨在帮助开发人员完成某些任务,但并不针对性能,只是为了方便。换句话说,这些模块可以帮助您更快地开发,但您不应忘记每个函数的复杂性。否则,您将因为忘记其复杂性而多次调用同一个函数,而不是调用一次并保存结果。

提示

记住,当您开发应用程序并与一两个用户进行测试时,并不能看到高性能。那时,应用程序的速度表现良好,因为数据大小和用户数量仍然很小。但以后,您可能会后悔一些设计决定。

使用函数规则

函数在这个平台上非常重要。这并不奇怪,因为这种语言是功能性的,并且具有一流的函数。在编写函数时,有一些规则您应该遵循,这将使您在以后调试或优化时更加轻松。它们还可以避免一些错误,因为它们试图强制执行一些常见的结构。再次强调,您可以使用 JSCS 等工具来强制执行这些规则。

  1. 始终为您的函数命名,特别是当它们作为闭包用作回调时。这样可以在代码中断时识别它们在堆栈跟踪中的位置。此外,它们允许新开发人员迅速了解函数应该做什么。但是,避免使用过长的名称:
socket.on("data", function onSocketData(data) {
    // …
});
  1. 不要嵌套您的条件,并尽早返回。如果您在函数中有一个必须返回的条件,并且如果您返回了,您就不必使用else语句。这样还可以避免新的缩进级别,减少代码并简化其修订。如果您不这样做,如果有两个或更多条件需要满足,您最终会陷入条件地狱,有几个级别:
// do this
if (someCondition) {
    return false;
}
return someThing;

// instead of this:
if (someCondition) {
    return false;
} else {
    return someThing;
}
  1. 创建小而简单的函数。不要让您的函数跨越屏幕可处理的行数。即使您的任务无法重用,也将函数拆分为更小的函数。最好将其放入新模块并发布。这样,如果需要,您可以在前端重用它们。这也可以使引擎在无法优化之前的大函数时优化一些较小的函数。再次强调,如果您不希望开发人员在能够触及任何内容之前阅读您的应用程序代码一两周,这一点非常重要。

测试您的模块

测试您的模块是一项艰巨的工作,通常被忽视,但对于为您的模块编写测试非常重要。最初的测试是最困难的。寻找一个您喜欢的测试工具,比如 vows、chai 或 mocha。如果您不知道如何开始,请阅读模块的文档,或者其他模块的测试代码。但不要放弃测试。

注意

如果您需要帮助,请阅读前面提到的测试工具网站,因为它们通常会帮助您入门。或者,您可以查看 Igor 的文章(https://semaphoreci.com/community/tutorials/getting-started-with-node-js-and-mocha)在 semaphore。

一旦您开始添加一个或两个测试,就会有更多的测试跟随。从一开始就测试您的模块的一个重要优势是,当您发现一个错误时,您可以为其制作一个测试用例,以便能够重现它并在将来避免它。

代码覆盖率并不是至关重要的,但可以帮助您了解您的测试覆盖了模块代码的程度,以及您是否只是测试了一小部分。有一些覆盖率模块,比如istanbuljscoverage;选择最适合您的那个。代码覆盖率是与测试一起完成的,所以如果您不进行测试,就无法看到覆盖率。

正如您可能想要改进应用程序的性能一样,每个依赖模块都应该被检查以进行改进。只有在测试它们时才能做到这一点。依赖版本管理非常重要,但很难跟踪新版本和更改,但它们可能会给您一些好消息。有时,模块会被重构,性能会得到提升。一个很好的例子是数据库访问模块。

总结

Node.js 和 NPM 一起构成了一个非常好的平台,用于开发高性能的应用程序。由于它们背后的语言是 JavaScript,而且大多数应用程序都是 Web 应用程序,这些组合使它成为一个更具吸引力的选择,因为它是一个不需要学习的服务器端语言(如 PHP 或 Ruby),最终可以允许开发人员在客户端和服务器端共享代码。此外,前端和后端开发人员可以共享、阅读和改进彼此的代码。许多开发人员选择这种模式,并带来了许多他们从客户端带来的习惯。其中一些习惯不适用,因为在服务器端,异步任务必须起作用,因为有许多连接的客户端(而不是一个),性能变得至关重要。

在下一章中,我们将介绍一些开发模式,帮助应用程序保持简单、快速和可扩展,随着更多客户端的加入,开始对您的基础设施施加压力。

为 Bentham Chang 准备,Safari ID 为 [email protected] 用户编号:2843974 © 2015 Safari Books Online,LLC。此下载文件仅供个人使用,并受到服务条款的约束。任何其他使用都需要版权所有者的事先书面同意。未经授权的使用、复制和/或分发严格禁止,并违反适用法律。保留所有权利。

第二章:开发模式

开发真的很棒。它给了你创造新事物的自由感。这对几乎每种语言都是真实的——以自己的方式创建东西的自由。这意味着有好的方法和不那么好的方法来完成相同的任务。在他们的生涯中,开发人员将面临不同的问题,这些问题有相似的解决方案,并且会采用模式。对于一些问题,他们会知道他们正在使用的模式;对于其他问题,他们可能会使用甚至不知道的模式。

有些模式直接提高性能,而其他模式则间接提高性能,因为它们是能够扩展的架构模式。创建高性能应用程序涉及了解每一行运行的代码,这意味着了解应用程序中使用的模式。有时,它们是无意的。在其他时候,它们是强制性的,因为特定模式的好处。模式无处不在,从对象的创建到对象之间的交互以及应用程序的一流服务。

同样,不同的语言和平台有特定的模式。这是因为编译器或解释器处理某些代码片段比其他更好。有时,这是因为它是设计和针对最常见情况的最佳性能。在其他时候,这仅仅是因为语言如何处理某些实体,比如函数、变量类型或某些循环。因此,了解解释器如何处理某些代码模式是很重要的。

什么是模式?

模式不是库或类。它们是概念——可重用的解决常见编程问题的解决方案,经过测试和优化以适用于特定用例。由于它们只是旨在解决特定问题的概念,因此它们必须在您的语言中实现。每种模式都有其优点和缺点,选择错误的模式可能会给您带来很大的麻烦。

模式可以加快开发过程,因为它们提供了经过充分测试和验证的开发范例。重用模式有助于防止问题,并提高熟悉它们的开发人员之间的代码可读性。

模式在高性能应用程序中非常重要。有时,为了实现一些灵活性,模式会在代码中引入新的间接层,这可能会降低性能。您应该选择何时引入模式,并知道引入模式会对您的性能指标造成何种影响。

了解好的模式是非常重要的,以避免相反的—反模式。反模式是一种对重复问题的解决方案,既无效又适得其反。反模式不是特定的模式,而更像是常见的错误。它们被大多数成熟的开发人员/社区视为不应该使用的策略。以下是一些最常见和频繁出现的反模式:

  • 重复自己:不要重复过多的代码部分。放松一下,看看整体情况,然后重构它。一些开发人员倾向于将这种重构视为应用程序的复杂性,但实际上它可以使您的应用程序更简单。如果您认为自己无法理解重构的简单性,请不要忘记在代码中添加一些介绍性注释。

  • 金榔头还是银子弹:特别是在 Node.js 生态系统中,多亏了 NPM,那里有成千上万的模块可供使用。不要重复造轮子。花时间使用最常见的模块来满足您的需求,避免重新创建它们。

  • 异常编码:您的代码应该处理所有类型的常见错误。如果应用程序经过良好规划,这种意外复杂性应该可以避免,因为它不会为应用程序带来任何新的东西。避免为每种类型的错误编写代码,处理最常见的错误,并默认为最一般的错误。这并不意味着您不应该在后端记录错误。这样做是为了以后分析,但要避免处理所有类型的错误。这会减少您的代码维护。

  • 偶然编程:不要通过试错来编程。这种方法的成功纯粹是运气和概率的问题。这是您真正应该避免的事情。偶然编程可能会使您的代码在某些情况下工作,但在未经计划的情况下会出现错误行为。

Node.js 模式

由于 Node.js 平台的结构和 API 模型,某些模式更倾向或更自然。最明显的是事件驱动和事件流模式。它们并不是强制性的,但在核心 API 中非常根深蒂固,您在应用程序的某些部分被迫使用它,因此最好了解它们如何单独工作,如何共同工作以及如何从中受益。

使用核心 API,您可以访问文件系统,例如,使用单个方法和回调来读取文件;或者您可以请求读取流,然后检查数据和结束事件,或将流传输到其他位置。当您不想查看文件,只想将其提供给客户端时,这非常有用。这种架构旨在为httpnet等核心模块工作。同样,当监听客户端连接时,您将不得不监听连接事件(除非在创建套接字时定义了连接监听器),然后为每个连接监听数据和结束事件。记住不要忽略错误事件,因为如果不监听,它们会触发异常,并强制您的应用程序停止。事件是 Node.js 平台的核心特性:

  • 流也存在,并且有人可能认为它们是两种不同的东西,但实际上它们并不是。每个流都是事件发射器的扩展。在最基本的形式中,流是从某种缓冲区发出数据事件的过程。事件、流和缓冲区共同构成了一个非常好的事件驱动架构的例子——这种模式非常适合 JavaScript 语言。

  • 不同类型的流可能会相互连接,特别是在共享公共数据和结束事件时。非常常见的是使用fs流并将其传输到http流。这种可用性使开发人员能够避免在应用程序中进行不必要的内存分配,只需将任务传递给平台。

  • 事件使应用程序组件之间实现了松散耦合,使其能够在组件发出事件和监听事件之间没有严格的连接的情况下进行更改和演变。不足之处是,有一些边缘情况需要注意,例如因为我们没有监听而丢失了发出的事件,或者因为忘记停止监听不再存在的事件而导致内存泄漏。

  • 缓冲区是您在处理可能因为字符串编码而破坏的数据时应该使用的对象。它们被平台用于读取文件和将数据写入套接字。对于缓冲区,有许多字符串操作函数可供使用。

模式类型

你的应用程序不仅仅使用核心 API。在一个复杂的应用程序中,你将会使用许多其他模块,有些是你自己制作的,有些是你简单地下载的。模式存在于你的应用程序的各个地方。当你使用一个模块并且需要创建一个不同的接口时,你将会使用适配器模式,这是一种结构模式。如果你需要扩展刚刚下载的模块以添加一些功能方法,你可以使用装饰器模式,另一种结构模式。当下载的模块可能需要一些复杂的信息来初始化时,你可能会想要使用工厂模式,这是一种创建模式。如果你的应用程序发展并且这种初始化需要更多的灵活性,你将会使用建造者模式,另一种创建模式。如果你的应用程序访问关系数据,你可能需要使用活动记录模式。如果你使用某种软件框架,你可能会使用 MVC 模式。

许多开发人员并没有注意到他们正在使用一些这些模式。了解它们,特别是了解某些模式在某些情境中存在的问题是很重要的。为了能够分析和测试这些模式,它们被分类为几种类型。让我们看看一些这些类型和每种类型中一些最常见的模式。

架构模式

架构模式是通常在软件框架内实现的模式。这些模式解决了大多数应用程序中发现的常见问题。它们通过创建某种层来解决常见的更广泛的问题,避免了代码重复。这张图片是对前端控制器的描述:

架构模式

  • 前端控制器模式,在 Web 应用程序中最常见,是一个唯一的控制器处理所有传入的请求的情况。这是通过有一个单一的入口点来实现的,该入口点加载常见的库,如数据访问和会话管理,然后为每个请求加载特定的控制器。这是一个非常常见的做法,因为另一种选择——为不同的操作有多个入口点——会大大增加和重复代码,使应用程序更加复杂和难以管理和维护。

在大多数框架中,这种模式允许你的应用程序通过不重复不必要的代码来增长不同的模块。它有一个中心点,可以处理许多常见的任务,如数据库访问、会话管理、访问日志和错误日志、通用访问、授权和会计等。

这种模式在任何良好结构的应用程序中都是必不可少的,因为它通过强制应用程序的一个公共部分首先运行并执行你需要的每个检查,大大减少了重复的代码。它还可以增加安全性;如果发现任何漏洞,封闭一个单一的入口点比多个入口点更容易。使用一个中心点,你的应用程序可以使用各种性能方法来提供更好的响应感,也可以增加整体性能。下面的图片是对 MVC 的描述

架构模式

  • 模型-视图-控制器MVC)模式是一种将应用程序组件分为三个部分的模式:模型、视图和控制器(因此得名)。模型是你的数据结构,或者你的信息逻辑。例如,这可以是关系数据库中的一个或多个表。视图是一种视觉表示,通常是用户界面。它可以是图形的或基于文本的。它是你的模型的一种表示方式,用户可以看到并操作。控制器是实际上负责操作你的模型的部分,有时直接更新视图,根据用户在视图中的操作。

这种模式有许多变体,你应该选择最适合你的任务和语言的那种。其中一些变体是Model-View-ViewModelMVVM)和Model-View-AdapterMVA),它们试图将视图与模型解耦,使模型不一定要意识到视图的存在。这样就可以拥有同一模型的多个视图。

这种模式的主要目的是清晰地将用户所见(视图或设计)与编程逻辑(模型)分开。这对于设计师能够更改视图而不影响逻辑非常重要。同时,开发人员可以修复逻辑而不破坏设计。如果你认为自己至少是一名中级开发人员,那么这种模式是必不可少的。因为它不仅仅是一种模式,更被认为是一种必要的实践。

架构模式

  • Active Record模式是一个用于访问关系数据库的抽象层,它提供了一个简单的数据对象。操作这个对象可以触发数据库的更改,而开发人员不需要知道应用背后的数据库类型。通常,数据库中的表或视图被映射到一个类,实例被映射到行。通常,外键由引用实例处理。逻辑可以赋予数据对象常见的应用任务,例如,根据两个不同的表列(如名字和姓氏)计算全名。所有这些都为业务逻辑提供了更好的方法,使得你可以拥有你的数据以及在顶部扩展它以匹配应用的预期行为的额外层。这种模式通常用于扩展功能到新水平的对象关系映射ORM)库中。其中一个例子是可能在应用的两个或更多不同位置引用数据库中的同一行,并且(不知情地)拥有相同的引用数据对象。

这种模式主要因为两个方面受到批评。首先是应用和数据之间存在一个抽象层,这可能会大幅降低性能,并在数据密集型应用中增加内存泄漏。另一个方面是可测试性;数据对象和数据库之间的紧密耦合使得很难为适当的测试使用真实数据库。

架构模式

  • 服务定位器模式是通过使用一个称为服务定位器的中央注册表来抽象访问服务的概念,这个注册表允许服务注册并了解彼此的访问方法。尽管这种模式涉及在应用程序的组件之间添加一个额外的层,但它可以为应用程序提供适应性和可扩展性。

这种方法有一些优势,最重要的是适应工作负载的可能性。服务定位器可以控制对注册服务的访问,如果你在多台服务器上有同一服务的多个实例,这个定位器可以轮流访问每一个实例,从而可以添加更多实例并处理更多负载。另一个重要的优势是可以注销服务并注册新的具有更好性能或错误修复的服务,从而使你可以保持零停机时间。

然而,并非一切都是好消息;有一些不利之处需要权衡。服务定位器可能成为单点故障的潜在来源,这是没有人想要的。安全性也很重要,服务注册必须谨慎处理,以防止外部人员劫持注册表。此外,由于服务与服务定位器和应用程序解耦,它们就像黑匣子一样,可能更难处理错误并从中恢复。

架构模式

  • 事件驱动模式是一种促进事件的生产和消费的模式。这种架构强制编程逻辑对事件做出反应。事件是状态变化,例如,当建立网络连接时,数据到达,或文件句柄关闭时。需要通知事件的对象(称为消费者)在适当的事件发射器对象(生产者)中注册(监听)事件。当该对象检测到与其相关的状态变化时,它会通知(发出)事件给消费者。

事件可以包含数据信息。例如,如果文件阅读器对象是一个事件发射器,它可能会在相应文件被打开时通知消费者,当它从文件中获取数据时(无论是否完整),当文件关闭时(没有更多数据),以及最终发生任何错误时(无访问权限或文件系统是两个例子)。数据事件最终可能会获取文件本身,错误事件应该获取相关的错误。

围绕这种模式构建应用程序通常使它们更具响应性,因为这些系统是针对不可预测和异步环境设计的,这种环境存在于使用网络或文件系统的任何系统中。这种架构非常松散耦合,因为事件几乎可以是任何东西,任何地方,使得这种模式具有可扩展性和可分发性。

具有这种模式的框架通常允许开发人员创建自己的产品,即事件发射器,具有自定义事件和数据,扩展核心功能,并使整个应用程序成为事件驱动。

创建模式

创建模式是开发人员在创建新数据或对象时使用的模式。这些模式使您的应用程序具有灵活性,可以选择何时实例化新对象或重用当前对象。在这种类型的模式中,您可以找到一些如下所述的模式:

创建模式

  • 工厂方法模式用于将应用程序与特定类抽象化。它用于创建新对象。在这种模式中,调用一个方法,返回一个新的(或重用的)对象,并且创建的逻辑(如果需要)由另一个子类处理。当需要创建新对象的组件可能没有所有必要的信息(例如数据库信息)时,这种模式特别有用。另一个用例是当这个对象在组件之间被重复使用时,创建对象所需的代码可能太复杂,可能需要重复许多代码片段。再次,数据库连接或另一个数据信息服务访问是这种模式的一个很好的案例。

  • 延迟初始化模式是指延迟创建对象或计算复杂表达式。这也被称为延迟加载。当您在调用某个工厂函数后保存一个实例,以便在再次调用该函数时可以返回该实例时,通常会出现这种模式。这是获取单例的另一种方式。创建模式

  • 单例模式用于应用程序需要单个对象实例以实现高效运行的情况。这种模式通常在类本身中创建,其中类的开发者创建一个方法来创建一个新实例,如果先前已创建实例,则返回该实例。它也可以出现在工厂模式中,应用程序可能有一个用于创建数据库连接池的库,并且希望所有模块使用相同的池而不是创建新的。这对于 Web 应用程序尤为重要,因为您希望避免每次请求时连接到数据库。例如,在 Active Record 模式中也会使用,当多个组件需要相同的行时,而不是返回不同的对象,返回相同的对象。

  • 建造者模式是负责创建其他类的新实例的类。这类似于工厂方法模式,更灵活但也更复杂。开发人员通常从工厂模式开始,然后演变为这种模式。这在抽象一个具有多种构造组合的类时特别有用,例如构造数据库查询时。

建造者背后的类通常很复杂,建造者有时通过暴露更简单的方法来解决这种复杂性,并随着需求的到来而不断发展。这是一个很好的模式,可以级联或链接方法以创建更流畅的接口。

创建型模式

  • 对象池模式中,一组对象(称为池)被其他组件准备好供使用。这种模式通常与连接池和可能涉及重要初始化时间的其他操作相关联。通常,这些池以较低的值(减少的池大小)初始化,并根据需求增长到更高或限制值。

这种模式经常用于数据库连接中,因为创建连接可能很昂贵,考虑到连接和认证。始终保持一些连接活动会大大减少初始化时间并提高性能。

结构型模式

另一种类型的模式是结构型模式。在这种类型中,有助于组件之间的关系和通信的模式。这些通常用于将第三方模块连接在一起作为一个公共接口。此类型的示例描述如下:

结构型模式

  • 适配器模式是最常见的模式,用于连接两个不兼容的组件,通过一个公共接口连接。一个区分这种模式与类似模式的规则是,连接两个组件的适配器不应具有任何逻辑,只允许两个接口连接到一个新的公共接口。

这种模式出现在你有两个接口并且需要重构其中一个接口,接口将改变方法。虽然你不必重构另一个接口,但你需要一个适配器来保持应用程序运行。

结构型模式

  • 组合模式用于当一组对象或单个对象应该以相同的方式对待和访问时。当组件不知道何时访问一组对象或单个对象时,应该使用这种模式。当意图处理两种变化的代码复杂性不大时,这种模式特别有用。这种模式在 jQuery 和其他将一组元素与单个元素视为相同的库中经常出现。

行为模式

当你用一个更容易使用和理解的接口包装一个复杂的库时,就会使用外观模式。有时候库变得非常灵活,有许多不同的选项,当你创建一个不那么灵活但更简单的接口到一个复杂的库时就会使用这种模式。

  • 装饰器模式

  • 中介者模式创建了一个抽象层,称为中介者,它处理与多个类的通信。随着应用程序变得复杂,需要一个中介者来降低类之间通信的复杂性。这个中介者封装了与所有类的通信,通过保持对象不直接相互交互来减少依赖性和降低耦合。如果你的应用程序是模块化的,不同的模块可以在运行时加载,这可以被称为你的内部 API。

这种模式出现在一对重复或复杂任务很常见的情况下,你决定最好有一个接口来完成它。这不是一个适配器模式,因为它不是一个接口改变;它是一个简化的接口。例如,如果你有一个理解和使用 SMTP 的类。你需要发送一封电子邮件,而且更喜欢有一个单一的方法来发送消息,而不是原始类的一系列复杂方法。

结构模式

  • 外观模式

代理模式通常用于简化更复杂的任务,它是一个对象充当代理来访问某些东西的模式。它可以是另一个对象、一个文件、一个文件夹或一些数据库信息。例如,这种模式被用来为其他东西添加安全层,因为它可以限制应用程序访问特定资源的方式和时间。这种模式的一个例子是对服务的 REST 接口。

代理模式

中介者模式

  • 当向对象添加功能而不影响同一类的其他对象的行为时使用装饰器模式。这实际上是原型继承的基础,这是 JavaScript 的一个基本原则。通过将对象包装在另一个类中,保存对它的引用,并将新功能添加到新类中来实现。当你想要使用的模块没有你想要的所有功能时,你会使用这个模式,并决定包装它并提供额外的方法。这是适配器模式的扩展或下一步。通常你会发现一个几乎符合你需求的模块,但后来你意识到有一两个缺失的功能,所以你不是去寻找另一个模块(也许是因为你已经习惯了它),而是装饰第一个模块。

  • 模板方法模式被几个框架使用。通常是一个方法,它接受一组选项并编译您的信息的一部分,留下一些可修改部分的占位符。例如,这被用作预编译图形用户界面视图的一种方式,留下一些占位符,比如国际化文本,以及最终一些代码逻辑以便以后运行。当模板的某个部分不变时,这种模式非常有效,减少了每次需要时从模板编译的时间。这也是控制反转的典型例子,模板可以调用应用程序的部分,而不是应用程序调用模板的方法。

  • 观察者模式维护了一个被称为观察者的依赖列表,并通过调用每个依赖提供的方法来通知它们的变化。这通常被称为事件系统,并且在事件驱动架构中被使用,比如 Node.js。这种模式在异步编程中非常有效和有用。另一方面,如果使用不当,当事件监听器没有正确注销并且观察者保持对其的强引用时,可能会导致内存泄漏,从而阻止垃圾回收对其进行处理(过期监听器问题)。这种模式被 Node.js 平台大量使用,如果您想创建一个高性能的应用程序,那么您必须接受并理解它。

事件驱动架构

在 Node.js 中开发与其他语言并无不同。您有一些更多或更少本地的模式,被广泛采用并得到充分支持。一个非常常见的模式是事件驱动架构。这种模式促进了事件的产生和消费。这意味着您的代码应该对事件做出反应,而不是不断尝试检测变化。通常,许多监听器可以消费一个事件。有一些变化,比如有一种停止事件传播的方法,或者只允许第一个监听器消费事件,但通常所有监听器都能消费他们正在监听的所有事件。

当您需要在代码的一对多模块内部进行通信时,这种模式非常有效,因为它给您提供了非常松散的耦合。这在面向服务的架构SOA)中特别有趣,因为它确保您的应用程序组件(您的服务)保持松散耦合,并且可以随着时间的推移进行升级而不影响其他服务。想象一下,您有一个附加了许多服务的应用程序,还有一个名为Sessions的服务,负责管理用户会话,创建它们和销毁它们。当会话发生变化时,该服务可能会产生事件。这样,其他服务可以监听事件并相应地采取行动。这意味着只想知道会话何时创建的服务,例如,只需监听创建事件,而另一个只需要知道何时销毁的服务只需监听特定事件。其他服务可以添加而不必改变太多您的应用程序。这对于在某种程度上创建服务之间的边界也是有益的,例如,当您不想信任第三方服务时。

有一些相关的模式——这种模式的变体。一个广泛使用的模式是发布-订阅模式。一个广泛使用且非常相似的模式是发布-订阅模式。你有消息而不是事件;你有订阅者而不是监听器;你有发布者而不是事件发射器。这种模式的主要优势通常是实现为使用网络层工作,因此可以被服务用于通过互联网相互通信。然而,与 Node.js 核心事件相比,这种模式实际上并不那么简单,可能会变得相当复杂,因为它允许消息过滤,订阅者可以根据消息属性决定他们想要接收什么样的消息。

通常,这种模式涉及第三个元素,负责接受发布者的消息并将其传递给订阅者。这个元素可能会扩展并允许更分布式的架构。另一方面,由于解耦了发布者和订阅者,发布者可能会失去知道谁订阅了哪些频道的能力。此外,要注意消息传递,因为网络层可能会引入许多复杂性并减慢你的工作流程。这不是你想依赖的东西。

事件驱动的架构允许你创建一个应用程序,其中信息的流动由事件决定。这很棒,但有两件事情你不应该忘记:

  1. 在你的流程期望事件发生时,要小心不要创建一种死锁,而事件却从未触发,或者你注册监听的时间太晚了。通常情况下,这对你的应用程序并不致命,因为你并没有被阻塞在等待事件,但你的应用程序将处于一个中间状态,无法摆脱,并且可能会泄漏内存。从用户的角度来看,你的应用程序是失败的。

  2. 始终要优雅地处理错误;不要忽视它们。核心模块如httpnet在你没有正确处理错误事件时会抛出异常。这意味着将触发未捕获的异常,你的应用程序将会严重停止。你不会忽略未捕获的异常,对吧?

总的来说,这是一个非常适合 Node.js 平台的通用模式,当你需要在应用程序的几个部分之间进行通信时非常方便。此外,JavaScript 本身通过支持匿名函数(称为闭包)很好地处理了这种模式。

你可能已经注意到,在 Node.js 中,事件和流有一定的关联。这并非偶然;它有助于构建一个简单易懂且易于适应的工作流程。流使用事件通知消费者有可供消费的数据以及数据何时到达末尾。

从流的角度来看,可以将其视为 Unix 管道([en.wikipedia.org/wiki/Pipeline_(Unix)](https://en.wikipedia.org/wiki/Pipeline_(Unix)))。目标是像在命令之间传输数据一样有用,以读取数据、处理数据、转换数据,然后输出数据。流是一个快速且易于使用的接口,用于创建可读、可写、双工和转换流。让我们来看看不同类型的流,如下所示:

  • readable:例如,这是一个文件解析器,读取某种格式(如 CSV),并为每一行发出数据事件。这个流可以被传输到其他类型的流中。可读流可以处于流动模式,这意味着数据在源头可用时被传输,以及暂停模式,其中数据必须在需要时手动获取(如果可用的话)。

  • 可写:向文件写入或响应客户端是此类型流的示例。其他示例包括数据压缩流(zlib)和加密流(crypto)。此流将数据写入目的地并通知其进度。它还可以处理所谓的回压,即当数据被写入流中但在另一侧未被处理时,强制流将数据保留在内存中。

  • 双工:这既是可读流又是可写流,因为它处理源和目的地。此类型的示例包括套接字以及压缩和加密流,具体取决于目标。

  • 转换:此流是双工流的扩展,在源和目的地之间执行某种数据转换。压缩数据是此类型的一个很好的示例,但在不同格式之间转换数据也是如此。

缓冲区

在 Node.js 平台中,另一个重要的组成部分是缓冲区。由于 JavaScript 字符串是以 Unicode 编码的,二进制数据可能在处理过程中被破坏。缓冲区是处理二进制数据的替代方法。作为奖励,您可以获得多种不同大小的读写数字的方法,无论是大端还是小端。

由于二进制兼容性,核心模块在流数据事件中使用缓冲区。将文件流式传输到客户端或从客户端接收文件并将其写入磁盘就像管道流一样简单。它们之间可以直接传递缓冲区,因此它们可以直接工作。

优化

使用模式可以改进您的应用程序,因为您使用了经过充分验证和经过充分测试的概念,这有助于开发人员更好地理解并最终改进您的代码。但是改进您的代码并不止于此。还有一种模式,它因语言而异,我们称之为优化。

优化是一种模式,不是特定于任何问题,而是特定于代码结构。其思想是改变代码以使其更有效或使用更少的内存或其他类型的资源来完成相同的事情。优化的目标不是使代码更简单或更易读。它可能更大,但仍然可读。不要为了优化而优化,降低代码的可读性。

由于 Node.js 使用 V8 引擎作为语言处理器,我们必须在代码中使用 V8 特定的优化。一些优化跨版本有效,而其他一些则不太有效,优化的努力可能是徒劳的。这是因为 V8 不断改进,Node.js 平台在每次发布时都会发布新版本,因此昨天因为 V8 在某些方面性能不佳而好的优化,可能在 V8 解决了该性能问题后,明天就不值得了。现在让我们来看看一些值得注意的优化。

隐藏类型

JavaScript 具有动态类型。这意味着变量具有动态类型,因此它可以从数字变为字符串,反之亦然。这个特性在编译时很难优化,V8 有一个称为隐藏类型的功能,它在相同类型的对象之间共享优化。例如,当您使用new关键字创建对象时,如果对象的每个实例在其原型中没有发生更改,它们都共享相同的隐藏类型,并且将使用相同的优化代码:

function Person(first_name, last_name) {
  this.first_name = first_name;
  this.last_name  = last_name;
}

var john = new Person("John", "Doe");
var jane = new Person("Jane", "Doe");

// john and jane share the same type

jane.age = 18; // jane no longer has the same type as john!

对于复杂对象可能无法实现这一点,但对于简单对象,您可以通过在构造函数中设置属性,然后封闭对象以避免更多的更改来强制执行它。

数字

再次,由于 JavaScript 具有动态类型,数字可以改变类型。编译器将尝试推断类型,一旦知道类型,它将标记变量为该类型,以便能够与其他变量执行操作。在此之后改变类型是可能的,但代价高昂,因此最好避免改变数字类型。更具体地说,避免进出 31 位有符号整数:

var number = 32; // 31-bit signed integer
number /= 10; // double precision floating point

数组

数组的长度可以是可变的。为了处理这个问题,编译器为每种特定类型的数组都有一些内部类型,并且在这些类型之间切换是不可取的。数组应该具有连续的键,从零开始。避免删除中间的元素并访问您之前未初始化的元素。与数字类似,您应该保持数组元素的相同类型。此外,如果您知道数组的大小,应该在构造函数中指出:

var a = new Array();
a[0] = 32;
a[1] = 3.2; // internal conversion
a[2] = false; // another conversion

在这个特定的例子中,最好在创建之前初始化所有元素,以便编译器在创建之前知道隐藏的类型,而不是推断两次。

var a = [ 32, 3.2, false ]; // much faster

函数

函数继承自对象,因此隐藏类型在这里也适用。多态函数会大大降低性能。如果您希望获得最佳性能,请为您需要的每个构造函数创建一个单独的函数:

function add(a, b) {
    return a + b;
}

add(2, 3); // looks like monomorphic
add("john", "doe"); // it's now polymorphic

同样,一些对arguments的使用会降低性能。避免重新分配它们(例如,当未定义时)。而是使用另一个变量。您应该只使用arguments来检查参数长度并查看有效的索引:

function add(a, b) {
    if (arguments.length == 1) b = 0; // penalty
}

for-in 循环

在运行时评估代码时会有一些性能损失,另一个特性循环,为了获得最佳性能,应该避免使用普通的for循环。性能损失来自编译器无法优化的边缘情况。始终使用Object.keys来获取对象中的键列表,然后迭代该列表:

var keys = Object.keys(obj);
for (var i = 0; i < keys.length; i++) {
    // obj[keys[i]]
}

无限循环

您永远不应该创建无限循环(while (true) {}for (;;) {})。这对性能代码来说是一个更重要的规则。无限循环很难优化,最好重构您的代码并审查您的逻辑。

try-catch 块

try-catch 块在捕获异常方面很重要,但在异步架构中,它们可能不那么重要。编译器很难优化 try-catch 内部的范围,因此您应该尽量将尽可能多的代码移出语句。

Eval

Eval 是另一个可以避免的特性,因为带有eval调用的任何函数作用域都将使函数无法优化。除非真的需要,否则永远不要使用这个特性,如果需要,就把它放在尽可能小的函数中。

提示

下载示例代码

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

总结

开发应该是一种很棒的体验。高性能的应用程序需要对它们的设计和开发进行一些限制。了解大多数常见模式有助于为您的应用程序选择明智的路径,并避免未来的一些性能损失。然而,模式并不是全部,对 Node.js 平台背后的东西有很好的理解真的有助于您在性能方面达到更高的水平。

即使选择了良好的模式并在开发中尽力使用本章中描述的一些优化技巧,应用程序在某些情况下可能表现不佳。除非需要,否则不要进行优化。遵循模式和技巧,但在测试应用程序性能并意识到需要优化之前不要过分考虑。

为 Bentham Chang 准备,Safari ID [email protected] 用户编号:2843974 ©2015 Safari Books Online,LLC。此下载文件仅供个人使用,并受到服务条款的约束。任何其他使用都需要版权所有者的事先书面同意。未经授权的使用、复制和/或分发严格禁止并违反适用法律。保留所有权利。

第三章:垃圾收集

在编写应用程序时,管理可用内存是乏味且困难的。当应用程序变得复杂时,很容易开始泄漏内存。许多编程语言都具有自动内存管理,通过垃圾收集器GC)帮助开发人员忘记这种管理。GC 只是这种内存管理的一部分,但它是最重要的部分,负责定期查看已处理的引用对象并释放与其关联的内存。

GC 最常用的技术是监视引用计数。这意味着对于每个对象,GC 都保存引用它的其他对象的数量(计数)。当一个对象没有引用时,它可以被收集,这意味着它可以被处理并且其内存被释放。

在 V8 中,Node.js 引擎中,这种引用计数并不是不断检查的。相反,它会定期扫描,这个任务被称为一个循环。通常,这个循环不是原子的,这意味着程序在运行此循环时会暂停执行。此外,为了保持这种引用计数,GC 需要内存。这意味着除了程序使用的内存之外,程序还需要内存开销。此外,由于语言是动态的,对象可以改变类型,因此内存有时并不以最有效的方式使用。回顾前一章关于更有效地使用内存的开发模式。

自动内存管理

GC 极大地简化了语言的使用,使开发人员有更多的时间专注于应用程序的其他方面。此外,它可以减少一种称为内存泄漏的错误,尽管不能完全消除,这种错误会困扰长时间运行的应用程序和服务。然而,与其定期任务相关的性能惩罚。这可能会被注意到,也可能不会,这取决于在短时间内使用和处理多少内存。

通过将内存管理移出开发人员,Node.js 消除或大大减少了一些类型的错误:

  • 悬空指针错误:当内存被释放但仍然有一个或多个指针引用该内存块时发生。如果重新分配内存,这些指针可能会导致不可预测的行为,如果用于更改程序其他部分的块。在这种情况下,应用程序中有两个以上的位置更改相同的内存块。这是一个特别难以找到的错误。

  • 双重释放错误:当内存被释放一次,然后再次被释放时发生。在此期间,它可能已被重新分配并被应用程序的另一部分使用,破坏了对重复使用的块的访问。这类似于前一个内存,其中两个位置管理相同的块,但在这种情况下,一个位置试图使用它,另一个位置只会擦除数据。

  • 内存泄漏:当对象在被释放之前被取消引用时发生。当程序分配内存,使用它,然后在显式释放之前取消对该内存的引用时,就会发生这种情况。如果这种行为反复发生,特别是在长时间运行的服务上,这种类型的错误可能导致内存耗尽。

  • 缓冲区溢出:当尝试写入的信息超过任务分配的空间时发生。例如,当程序在需要比分配的内存块更多的空间时分配内存块的某个地方,但未能检测并重新分配所需的空间时,这种情况很常见。这种错误可能会使应用程序或服务停止。

另一方面,将内存管理移出开发人员的控制会大大减少对内存使用和管理方式的控制。当 GC 查看正在使用的内存并决定何时释放未引用的对象时,会消耗资源,在应用程序执行期间创建不可预测的暂停。此外,GC 开始执行其工作的时间可能是不可预测的,并且超出您的控制范围,这可能会在您的程序需要资源时引入不可预测的性能惩罚。

这是 Node.js 的情况,但由于它使用 V8,在--expose_gc标志下暴露了一个gc()方法,您可以手动强制使用它。您无法决定它何时运行,但如果您认为这样做是最好的,可以强制它更频繁地运行。您还可以调整一些 GC 的行为。要了解更多信息,请运行--v8-options节点。

没有办法阻止其使用,因此您只能使其更频繁地运行,可能减少其占用空间。GC 的成本与引用对象的数量成正比,因此如果在大大减少引用对象后使用此方法,您可以使应用程序保持精简,并在以后减少 GC 的惩罚。

自动内存管理

图 1:GC 内存图

内存组织

将内存视为通常是基本元素(数字和字符串)和对象(哈希表)的网格。它可以被表示为相互连接的点的图。内存可以用来保存对象信息或引用其他对象。您可以将这种相互连接视为叶子节点是保存信息的元素,其他节点是对其他节点的引用(在图 1 中,节点 1、3、6 和 9 是叶子节点)。

在使用 V8 时,有一些术语可能对您更好地理解 V8 检查器或 Chrome 开发者工具有用。对象本身使用的内存称为浅大小。它用于存储其直接值,通常只有字符串和数组可以具有显着的大小。

还有一个距离列,它是从根节点到节点本身的最小图距离。根节点是从那里引用开始指向其他节点的节点。在图 2 中,它将是节点 2,因为没有箭头指向 2,图上的一切都从节点 2 开始。在检查器中,您会看到Profiles中的另一个术语称为保留大小。这是一旦对象被删除将被释放的大小。它至少是对象本身的大小加上引用对象的大小,这些引用对象也将立即被释放,因为它们也将被取消引用。令人困惑?让我们看一个例子:

内存组织

图 2:清扫前的 GC 标记节点

在前面的图中,您可以看到节点 2 是图中的根节点,因为没有节点引用(指向)它。该节点引用节点 5 和节点 11。如果对节点 11 的引用被移除,那么从节点 2(以及图的左侧的其余部分)到节点 8 和 1 就没有路径了。这些节点是节点 11 的保留大小的一部分,因为它们在没有节点 11 的情况下是无用的。当节点 11 被移除时,它们也将被移除。

内存泄漏

内存泄漏是可用内存持续丢失,当程序反复未能释放不再使用的内存时发生。Node.js 应用程序可能间接受到这个问题的影响,因为 GC。通常不是 GC 的错,而是由于一些对象销毁没有在应该发生时发生,当您使用事件驱动的架构时,这并不难。

泄漏问题困扰着每个开发者,一旦他们的应用程序达到中等规模。一旦你的程序开始与其他程序或客户端等外部元素有更多的交互,或者当你的程序复杂度增加时,就会开始出现内存泄漏。当你的应用程序中有一个不再有用的对象没有被取消引用时,就会发生这种情况。如果垃圾回收器发现该对象仍然被其他对象引用,即使它对你的应用程序不再有用,它仍将保留在堆中,并将被移动到一个称为“旧空间”的地方。

通常,对象的生存周期很长(自应用程序开始以来)或者很短(为特定客户端提供服务)。V8 GC 被设计用来利用这两种最常见的对象类型。GC 周期通常会清理这些短寿命对象,如果它认为这些对象仍然有用(也就是说,当它经历了一两个 GC 周期后),它会将它们移动到一个更大的区域,开始积累垃圾。当这个区域变大时,GC 周期的持续时间也会变长,你会开始注意到应用程序会出现一秒钟甚至几秒钟的停顿。如果发生这种情况,这意味着你已经迟了分析你的应用程序。

对于像 V8 的默认 1GB 限制这样的大内存限制,如果你不监控你的应用程序,当你的应用程序开始停顿一秒钟时,你可能会注意到泄漏,之后,它还会再过几秒钟才会因为内存限制而停止。对于大对象集合,GC 周期变得非常消耗 CPU,所以你应该真正监控 GC 内存管理,并尽可能避免更大的内存使用。

事件发射器

由于 Node.js 使用事件发射器,现在你脑海中应该有一个问题。由于 GC 只能清除未被引用的对象,这意味着在你将事件监听器附加到事件发射器后,事件发射器将不会被收集:

var net    = require("net");
var server = net.createServer();

server.on("connection", function (socket) {
  socket.pipe(socket);
});
server.listen(7, "0.0.0.0");

前面的代码只是一个回声服务器的示例。在这个例子中,GC 永远不会收集server,在这种情况下这是好事,因为这是程序的主要对象。在其他情况下,你可能会遇到这样的情况,你的发射器不会被清除,因为有对监听器的引用。最重要的是,事件回调是函数——JavaScript 中的扩展对象——也不会被清除。

仔细看一下前面的例子。想象一下,对于每个客户端(套接字),你有更复杂的代码和一些私有协议。为了简化,你使用适配器模式创建一个抽象来访问每个客户端。这个抽象可以是一个事件发射器,作为一种手段来将其与应用程序的其他部分解耦。当你的客户端保持连接时,任何没有明确取消事件监听的事件监听器都不会被垃圾回收,即使它们不应该再存在(即使你将它们设置为 null 也是如此)。如果你的连接卡住了,而且没有超时(例如移动连接),你将会收集一大堆僵尸连接一段时间。

引用对象

GC 的主要目标是识别被丢弃的内存。这指的是你的应用程序不再使用的内存块,通常是因为你的代码不再引用它们。一旦识别出来,这些内存可以被重用或释放给操作系统:

function foo() {
  var bar = { x: 1 }, baz = bar.x;

  return bar; // baz is unreferenced but bar isn't
}

在前面的例子中,尽管barbaz都是函数的局部变量(因为 JavaScript 函数作用域),bazreturn后会被取消引用,但bar不会,直到你完全取消引用它。这可能看起来很明显,但如果你的应用程序增长,并且你开始使用你不知道内部工作方式的外部模块,你可能会得到比预期更多的悬空引用:

function foo() {
  var bar = { x: 1 };

  doSomething(bar);

  return bar;
}

现在想象一下,您调用foo函数并忽略返回的对象。您可能会认为它会变得未引用,但由于doSomething可能已经做了一些事情,这并没有保证。它可能已经保持了对bar的引用。

function foo() {
  var bar = { x: 1 };

  doSomething(bar);

  bar = null;
}

现在想象一下,您不需要返回bar变量,因此在不再需要它之后将其置空,销毁引用。这样就更好了,对吗?不!如果doSomething函数保持对bar的引用,那么在doSomething之外,您无法完全取消引用它。

更糟糕的是,函数可以通过在bar中创建一个引用自身的属性来创建循环引用。但 GC 足够聪明,可以判断应用程序的其他部分何时不再使用对象。这取决于您的代码有多复杂。请记住,如果存在疑问(即仍然在某处被引用并且仍然可以使用),GC 将不会扫描该对象。

在其工作的每个周期中,GC 会在所谓的停止-世界中暂停 V8 执行,确切地知道内存中所有对象的位置和存在的引用。如果引用太多,GC 将只处理对象堆的一部分,最小化暂停的影响。下图显示了 V8 如何扫描内存对象,标记未引用的对象(第一行,红色),从列表中清除它们(第二行),然后通过删除对象之间的空白来压缩列表(第三行)。

引用对象

以前的 V8 GC 代有两种用于清理旧空间的算法:标记-扫描和标记-压缩。在这两种算法中,GC 遍历堆栈并标记可达(引用)的对象。之后,它可以使用标记-扫描来释放未被引用的对象的内存,或者使用标记-压缩来重新分配和压缩所使用的内存。这两种算法都在页面级别上工作。这两种算法的问题在于它们会在中等大小的应用程序中引入显著的暂停。

引用对象

2012 年,谷歌推出了一项改进,显著减少了垃圾收集周期中的暂停。它引入了增量标记以避免遍历可能巨大的区域。相反,GC 只需通过区域的一部分进行标记,使暂停时间更短。GC 不再产生大的暂停,而是产生更多但更小的暂停。但改进并不止于此。标记后,GC 进行所谓的懒惰扫描。由于 GC 确切地知道哪些对象被引用,哪些没有(因为之前的标记步骤),它现在可以释放未引用对象的内存(扫描)。但它不需要立即这样做。相反,它只在需要时进行扫描。在扫描完所有对象后,GC 再次开始新的标记周期。

只要程序保持精简和简单,GC 就会很快。不要创建一个庞大的怪物,然后寻找提高 V8 内存限制的方法。在 64 位机器上,您可以将 1GB 限制几乎翻倍,但这并不是解决方案。您真的应该拆分应用程序。即使如此,如果您考虑更改限制,您要寻找的是--max-stack-size(以字节为单位)这个选项。

对象表示

在 V8 中,有三种原始类型:数字、布尔值和字符串。数字有两种形式:SMall Integers(SMI),它们是 31 位有符号整数,或者在诸如双精度(大数字)或具有扩展属性的数字的情况下是普通对象。字符串也有两种形式:一种在堆内,另一种在堆外,堆内有一个包装对象作为指针指向它。

还有其他对象,比如数组,它们是带有魔术长度属性的对象,以及本机对象,它们不在堆本身中(它们像某些字符串一样被包装),因此不受 GC 管理或扫描。

对象堆

GC 将对象存储在对象堆中。堆分为两个主要区域:新空间和旧空间,分别用于新对象和旧对象。新空间是对象创建的地方,旧空间是对象在一个或多个 GC 周期后移动到的地方。由于 GC 不是持续工作的,因此在周期之间,对象可以被创建,也可以在几分钟后被销毁(和取消引用)。这是最常见的对象行为,因此 GC 通常会有效地扫描它们。其他对象的生存周期更长,因此它们将在周期中存活,因为它们一直被引用和使用。这就是内存泄漏可能出现的地方。

这两个空间的设计目标不同。新空间比旧空间小,旨在快速、有意义,并且可以被 GC 快速分析。旧空间更大,包含在周期后移动到那里的对象。这个旧空间可以增长到非常大的大小,从几兆字节到一千兆字节。这种设计利用了大多数对象寿命短的常见行为,因此只存在于较小且更快的新空间中。

每个空间由页面组成,页面是连续的内存块,用于存储对象。每个页面顶部有一些头部和一个位图,告诉 GC 页面的哪些部分被对象使用。

对象之间的分离和从一个空间到另一个空间的移动引入了一些问题。一个问题显而易见,即重新分配。另一个问题是需要知道新空间中对对象的引用是否只存在于旧空间中。这是一种可能的情况,应该阻止对象被清理,但这会迫使 GC 扫描旧空间以弄清楚,从而破坏了这种架构的速度。为了避免这种情况,GC 维护了从旧空间到新空间的引用列表。这是另一个内存开销,但扫描这个列表更快。通常很小,因为这种引用相对罕见。

新空间很小,创建新对象很便宜,因为只是在已经保留的内存中增加指针。当这个新空间满了时,将触发一个次要周期来收集任何死对象并回收空间,避免使用更多空间。如果一个对象经历了两个次要周期,它将被移动到旧空间。

在旧空间中,对象在主要周期中进行扫描,这个周期比新空间中的次要周期频率低。当达到该空间的一定内存量或经过更长时间后,可以触发这个主要周期。这个周期的频率较低,可能会导致应用程序停顿更长时间。

堆快照

V8 允许您获取堆快照,以分析对象之间的内存分配。它允许您查看代码使用的对象,每个对象的使用数量以及应用程序如何在一段时间内使用它们,如果您请求堆快照转储。有几种收集堆快照的方法,我们将看一些。

让我们创建一个小的泄漏程序,并使用node-inspector模块进行分析。打开一个终端并全局安装 node inspector(-g),这样您就可以在机器的任何地方使用它。在下面的例子中,我们使用sudo,因为全局模块通常驻留在受限制的区域中:

$ sudo npm install -g node-inspector

检查器需要编译一些模块,因此您需要一个编译器。如果安装正确,您将看到已安装的依赖项列表,现在可以启动它。一旦它运行起来,您就无需在更改和重新启动程序时重新启动它。只需现在不带参数启动它,并将其留在终端选项卡中:

$ node-inspector

您应该看到类似以下控制台输出。您可以看到我正在使用版本0.10.0,但您可能会得到不同的版本。在这个例子中,实际上使用相同的版本并不是很重要。根据您使用的版本,输出可能会有所不同。在这种情况下,它与以下内容类似:

$ node-inspector
Node Inspector v0.10.0
Visit http://127.0.0.1:8080/debug?ws=127.0.0.1:8080&port=5858 to start debugging.

打开您的网络浏览器,转到输出中指示的页面。现在让我们创建一个名为leaky的程序。这个程序的目的是故意泄漏内存。创建一个文件夹,并在其中安装 V8 分析器:

$ mkdir leaky
$ cd leaky
$ npm install v8-profiler

请注意,这个模块也可能需要一个编译器。现在,在同一个文件夹中,创建一个名为leaky.js的文件,内容如下:

require("v8-profiler");
var leakObject   = null;
function MemoryLeak() {
  var originalObject = leakObject;

  leakObject = {
    longString : new Array(1000000).join("*"),
    someMethod : function () {
      console.log(originalObject);
    }
  };
};

setInterval(MemoryLeak, 1000);

这个程序可能会让人困惑,但是想法是让 GC 无法看到我们强制它不回收对象,从而泄漏内存。如果您仔细观察,您会发现leakObject被重新定义为一个在调用时输出它的函数,但它引用的方式使 GC 不知道我们可怕的目标。请注意,运行此程序时,您将很快耗尽内存,可能每秒耗费 100 兆字节。以调试模式运行此程序:

$ node --debug leaky.js

现在转到您刚刚打开的网页,单击刷新,转到页面上的Profiles选项卡,选择Take Heap Snapshot,然后单击Take Snapshot按钮,如下所示:

堆快照

等一分钟,然后再次点击该按钮。您会看到快照出现在左侧边栏,并且您会注意到它们的大小不一样。它们在增长,而且 GC 正在泄漏我们的无意义程序。如果您选择最后一个快照并选择与第一个快照进行比较,您很容易注意到这一点。

您会看到大小和新对象的变化。正增量意味着创建的对象比销毁的对象多。

堆快照

您可以在上面的截图中看到检查器显示快照时的样子。有一个构造函数或基本对象的列表。在这种情况下,由于我们正在比较快照 3快照 1,所以有显示创建和删除的对象数量以及分配和释放的内存量的列。

检测内存泄漏的另一个有用方法是记录随时间的对象分配。使用这个检查器,重新启动程序,转到Profiles,选择Record Heap Allocations,然后点击Start,如此截图所示:

堆快照

检查器将开始记录。当您单击左上角的红色圆圈时,它将停止。您将看到一个不断增长的时间线和每个次要周期的分配条形图。如果等一会儿,您会看到主要周期和对象重新分配(从新区到旧区)。

堆快照

停止后,您可以通过单击起点并将其拖动到终点来选择一段时间。您将只看到该时期的分配情况,而不是所有对象。您可以保存快照以供以后分析或比较。在这个特定的例子中,您可以看到内存如何每秒快速消耗。

堆快照

您可以单击并展开对象列表以查看每个对象。如果您正在寻找特定对象,可以使用顶部的过滤器。在这个例子中,您可以打开(字符串)组,您会看到我们在程序中创建的像********…这样的几个实例。

堆快照

使用v8-profiler不仅可以与node-inspector一起进行调试。例如,您可以对代码进行快照并进行分析,也许与以前的快照进行比较,或者将其序列化并保存以供以后分析。

例如,考虑前面的程序示例,我们可以定期检查我们的堆栈中有多少个节点:

var profiler   = require("v8-profiler");
var leakObject = null;

function MemoryLeak() {
  var originalObject = leakObject;
  leakObject = {
    longString : new Array(1000000).join("*"),
    someMethod : function () {
      console.log(originalObject);
    }
  };
};

setInterval(MemoryLeak, 1000);
setInterval(function () {
  console.log("mem. nodes: %d", profiler.takeSnapshot().nodesCount);
}, 1000);

如果您运行这个新版本,您可能会得到类似以下的输出。这证明了对象在 GC 周期中存活并泄漏内存:

$ node --debug leaky.js
Debugger listening on port 5858
mem. nodes: 37293
mem. nodes: 37645
mem. nodes: 37951
mem. nodes: 37991
mem. nodes: 38004
mem. nodes: 38012

这只是一个例子。如果你监视你的应用程序,而它在空闲时内存不断增长,这是需要进一步分析的原因。第一类公民(对于来自其他面向对象语言的人来说,所谓的类)将出现在应用程序快照的构造函数列表中。

有其他模块可以用来分析和监控你的 Node.js 程序的内存和垃圾回收器。heapdump模块是另一个简单的模块,可以帮助你定期将堆快照转储到磁盘上。请记住,这些快照是同步的,所以如果堆很大,你的程序会暂停一会儿。

要使用它,只需像之前安装其他模块一样安装它:

$ npm install heapdump

然后改变你的程序来使用它。这是一个每分钟将快照保存到磁盘的程序示例。这不是一个真实或好的用例,但也许每小时拍摄一次快照,并使用某种一次性脚本来避免填满你的磁盘可能不是一个坏主意:

var heapdump = require("heapdump");

setInterval(function () {
  heapdump.writeSnapshot("" + Date.now() + ".heapsnapshot");
}, 60000);

文件的名称是 Unix 毫秒日期,所以你会始终知道它是何时被拍摄的。运行它,等待至少一个快照被写入磁盘。在这种情况下,你不需要在节点中启用debug--debug)。

你保持node-inspector在终端上运行了吗?如果没有,请运行它。然后像之前一样,去它的网页,刷新页面。

堆快照

现在,不要选择Take Snapshot,只需点击Load按钮,然后选择来自磁盘的快照。这是另一种方法——离线方法——通常更有用,因为你通常不会以调试模式运行你的代码,并且在 v8-inspector 中实时查看它。此外,当你的程序停止时,node-inspector会重新启动界面,所以你需要在重新启动node-inspector之前保存你的快照。

如果你知道有内存泄漏,并且能够通过压力来重现它,你可以使用这种方法,也许在执行程序时添加一点变化,通过激活每个动作的 GC 跟踪行。然后你可以看到 GC 何时在清扫或标记。以下是一个例子,如果你监视 GC 动作,你会看到的:

$ node --trace_gc leaky.js
[26503]        8 ms: Scavenge 1.9 (37.5) -> 1.8 (37.5) MB, 0.8 ms 
[26503]        9 ms: Scavenge 1.9 (37.5) -> 1.9 (38.5) MB, 0.9 ms 
[26503]       53 ms: Scavenge 3.6 (39.5) -> 3.2 (39.5) MB, 0.7 ms 
[26503]      116 ms: Scavenge 5.1 (40.5) -> 4.1 (41.5) MB, 1.9 ms 
[26503]      155 ms: Scavenge 5.9 (41.5) -> 4.4 (41.5) MB, 1.1 ms 
[26503]     1227 ms: Scavenge 14.3 (50.1) -> 14.5 (50.1) MB, 0.8 ms (+ 1.6 ms in 1 steps since last GC) [allocation failure].
[26503]     1235 ms: Mark-sweep 14.6 (50.1) -> 5.4 (43.5) MB, 6.7 ms (+ 1.6 ms in 1 steps since start of marking, biggest step 1.6 ms) [HeapSnapshotGenerator::GenerateSnapshot] [GC in old space requested].

为了清晰起见,前面的输出部分被截断了。数字26503是本例程序的进程 ID。你可以看到动作发生的时间以及每条跟踪线的末尾花费的时间。你还可以看到每个周期的动作(ScavengeMark-sweep)以及内存的演变。

对于运行中的应用程序,启用--trace-gc(如前面的命令)是不可行的,你应该考虑一个适合你的架构的方法。其中一个选项是使用heapdump,每小时安排一个快照,并保存最后的 10 或 20 个快照。使用这种方法时,你至少应该查看最后一个快照,并将其与上一个快照进行比较,以了解你的应用程序随时间的演变。你可能会发现慢速内存泄漏或非常快速的内存泄漏。对于快速的内存泄漏,你应该能够记录堆分配并迅速停止泄漏。对于慢速的内存泄漏,很难发现它,只有在非常长的时间内才能比较变化并找到问题。

还有另一个有用的模块可以帮助你发现泄漏,它叫做memwatch。这个模块会寻找堆大小的变化,当它发现堆大小不断增长时,它会发出一个泄漏事件(讽刺)。它还有一个带有 GC 周期信息的漂亮的统计事件。

让我们把我们最初的程序改成使用这个模块,而不是任何分析器或检查器。是的,它不需要它们,甚至不需要你启用节点调试。首先,让我们安装它:

$ npm install memwatch-next

现在让我们把我们的程序改成类似于这样的东西:

var memwatch   = require("memwatch-next");
var leakObject = null;

function MemoryLeak() {
  var originalObject = leakObject;

  leakObject = {
    longString : new Array(1000000).join("*"),
    someMethod : function () {
      console.log(originalObject);
    }
  };
};

setInterval(MemoryLeak, 1000);

memwatch.on("leak", function (info) {
console.log("GC leak detected: %d bytes growth", info.growth);
});

memwatch.on("stats", function (stats) {
  console.log("GC stats: %d cycles, %s bytes", stats.num_full_gc, stats.current_base);
});

现在只需运行程序。让它运行几秒钟,你会看到类似于这个例子输出:

$ node leaky.js
GC stats: 1 cycles, 13228416 bytes
GC stats: 2 cycles, 7509080 bytes
GC stats: 3 cycles, 7508408 bytes
GC stats: 4 cycles, 17317456 bytes
GC stats: 5 cycles, 23199080 bytes
GC stats: 6 cycles, 32201264 bytes
GC stats: 7 cycles, 45582232 bytes
GC leak detected: 40142200 bytes growth

你会注意到 GC 周期经常发生。这是因为我们程序的行为。GC 会适应快速的堆变化,并更频繁地触发周期。如果你将内存泄漏调用周期更改为 5 秒或更长时间,你将不得不等待更长时间才能看到周期和泄漏。

memwatch模块通过在 GC 扫描和压缩后检查堆变化来工作,因此它不会仅仅因为你的应用程序使用内存就触发泄漏,而是因为你使用了内存而没有处理它。

该模块的另一个非常有用的功能是帮助你比较堆快照。你可以通过显式告诉模块你想要一个heapdiff来实现这一点。此时,模块会对堆进行快照,等待你再次对堆进行快照,并进行比较。之后,它会给你一个对象,显示之前和之后的总数以及每个快照的变化:

var memwatch   = require("memwatch-next");
var heapdiff   = new memwatch.HeapDiff();
var leakObject = null;

function MemoryLeak() {
  var originalObject = leakObject;

  leakObject = {
    longString : new Array(1000000).join("*"),
    someMethod : function () {
      console.log(originalObject);
    }
  };
};

setInterval(MemoryLeak, 1000);

setTimeout(function () {
  console.log(heapdiff.end());
}, 10000);

运行程序。之后,你会得到类似以下的输出:

$ node leaky.js
{ before: { nodes: 19524, size_bytes: 3131984, size: '2.99 mb' },
  after: { nodes: 21311, size_bytes: 12246992, size: '11.68 mb' },
  change:
   { size_bytes: 9115008,
     size: '8.69 mb',
     freed_nodes: 2201,
     allocated_nodes: 3988,
     details:
      [ [Object],
        [Object],
        [Object],
        [Object],
        …
        [Object],
        [Object],
        [Object] ] } }

如果你看change.details数组,你会注意到你有一个在堆之间发生变化的构造函数列表。如果在快照之间发生泄漏,它将在其中的某个项目中。在我们的情况下,它是字符串构造函数,因为我们正在泄漏字符串变量。

无论是否使用这些模块,你都应该监控内存使用和增长。快速的内存泄漏会耗尽你的资源,让你的客户感到不满。对于高负载的应用程序,你应该创建压力测试,以便在应用程序投入生产之前能够检测到泄漏。

第三方管理

在将应用程序划分为较小组件的精神下,有时将一些对象和操作移动到外部服务可能是一个更好的主意,这些服务有时针对特定的工作负载和对象格式进行了优化。在开始操作大型对象结构之前,先探索一些这样的服务器:

  • Memcached 用于键/值,Redis 用于列表、集合和哈希表

  • 如果你想在数据上运行 JavaScript,可以使用 MongoDB,如果你需要一些有趣的功能,比如数据超时或分层元素(文档内的文档),可以使用 ElasticSearch

  • 如果你需要一些复杂的 map/reduce 代码,可以使用 HBase,如果你需要该代码的轻量级版本,可以使用 Hypertable

  • 如果你需要图形数据库,可以使用 OrientDB,如果需要存储大型二进制数据,可以使用 Riak

你的应用程序通常在内存中运行,所以如果它失败并停止,使用的内存就会丢失,你宝贵的数据也可能会丢失。使用外部服务来处理数据(有时还可以操作数据)可以大大减少你的内存占用。此外,这些服务通常允许你并发访问,使你能够将数据操作工作分配给应用程序或工具的多个实例。

总结

现在您已经看到垃圾收集器的任务并不那么容易,但它确实非常擅长自动管理内存。您可以在很大程度上帮助它,特别是如果您正在编写性能优化的应用程序。防止 GC 老年代空间增长是必要的,以避免长时间的 GC 周期。否则,它可能会暂停您的应用程序,有时甚至会强制您的服务重新启动。每次创建新变量时,都会分配内存并接近新的 GC 周期。即使了解了内存是如何管理的,有时您仍需要检查内存使用行为。最干净的方法是通过收集内存堆的快照,并使用 V8 检查器或其他类似的软件进行分析。界面是不言自明的,如果您按浅层大小、保留大小或引用计数对对象列表进行排序,泄漏将会简单地显示出来。但在创建具有巨大内存占用的应用程序之前,先看看数据库,无论是关系型还是非关系型,这将帮助您存储和操作数据,避免使用语言自己来做。请记住,JavaScript 并不是为了创建计算密集型任务而设计的。如果您仍然需要执行更多的密集任务,您可能需要对代码进行仪器化分析和改进,以便实现最佳性能。

在下一章中,我们将看到什么是性能分析,做性能分析的好处是什么,一些可用的分析工具,以及如何理解结果并升级您的代码。

为 Bentham Chang 准备,Safari ID 为 [email protected] 用户编号:2843974 © 2015 Safari Books Online,LLC。此下载文件仅供个人使用,并受到服务条款的约束。任何其他用途均需获得版权所有者的事先书面同意。未经授权的使用、复制和/或分发严格禁止,并违反适用法律。保留所有权利。

第四章:CPU 性能分析

性能分析可能很无聊,但它是一种很好的软件分析形式,你可以在其中测量资源使用情况。这种使用是随时间测量的,有时是在特定的工作负载下。资源可以指应用程序正在使用的任何东西,无论是内存、磁盘、网络还是处理器。更具体地说,CPU 性能分析允许你分析你的函数如何以及多少使用处理器。你还可以分析相反的情况——处理器的非使用,或者空闲时间。

Node.js 并不主要用于连续的 CPU 密集型任务,有时,为了进行性能分析,重要的是要识别占用处理器的密集任务的方法,并阻止其他任务表现更好。你可能会发现持续占用处理器的大型调用堆栈,或者重复和递归任务没有如你所期望的那样结束。有几种技术,比如分割和调度任务,而不是连续运行它们,因为它们会阻塞事件循环。

为什么这些任务如此可怕,你可能会问。答案很简单;Node.js 围绕着一个事件循环运行,这意味着当你的代码结束特定任务时,循环重新启动并且待处理的事件被分发。如果你的代码没有结束,应用程序的其余部分将被保持在待机状态,直到任务完成。你需要能够将一个大任务分解成较小的任务,以使你的应用程序表现良好。

应用程序的主要目标应该是尽可能地使用最少的资源,因此尽可能地使用最少的处理器时间将是理想的。这相当于在主线程中大部分时间处于空闲状态。这时调用堆栈是可能的最小。从基本的 Node.js 角度来看,应该是零级。

在对处理器进行性能分析时,我们通常以一定频率对调用堆栈进行采样,并分析堆栈在采样期间的变化(增加或减少)。如果你使用操作系统的分析器,你会发现堆栈中有比你预期的更多的项目,因为你会得到 Node.js 和 V8 的内部调用。

在本章中,将涵盖以下主题:

  • I/O 库

  • 斐波那契

  • 火焰图

  • 性能分析替代方案

I/O 库

Node.js 用于在多个平台环境中执行异步 I/O 操作的库是libuv。这是一个开源库。实际上,它被平台用来提供类似于 Luvit 和 Lua 等其他语言的功能。Libuv是一个跨平台库,它使用每个平台的最佳方法来实现最佳的 I/O 性能,并且仍然暴露一个通用的 API。

这个库负责网络任务(TCP 和 UDP 套接字)、DNS 请求、文件系统操作等等。它是访问文件、列出目录内容、监听套接字连接和执行子进程的库。下面的图片显示了 Node.js 如何在相同级别上使用 V8 和 libuv:

I/O 库

你可以看到 libuv 不依赖于 V8 来进行 I/O 交互。它是一个带有自己线程池的 C 库。这个线程池被设计得非常快速,并且尽量避免频繁创建和销毁任务线程,因为它们非常昂贵。该库处理从网络到文件系统的许多 I/O 任务。它负责 Node.js 暴露fsnetdns等许多 API。在事件循环期间,你的代码可以请求 I/O 数据。这些数据被处理,当准备就绪(也就是说,你的请求全部或部分准备好了),它会触发一个事件,希望在下一个事件循环中处理。下面的图片描述了线程池的工作原理。你的代码在事件循环中运行(绿色),libuv 在单独的线程中运行(蓝色),并触发事件到你的代码(橙色),这些事件在每个周期之前被触发:

I/O 库

这意味着如果你请求一个文件的内容并开始执行大量的密集操作,它不会影响文件操作,因为它是在你的范围之外完成的。因此,尽管 Node.js 是单线程的,但有一些操作是在单独的线程(来自一个池)中完成的。这对我们在对代码进行性能分析时进行区分 Node.js 瓶颈、libuv(I/O)瓶颈和系统瓶颈非常重要。

斐波那契

让我们深入一个例子。带着一点怀疑的态度来看待它。这实际上是一个非常常见且备受批评的例子,涉及到斐波那契数列。让我们创建一个简单的 HTTP 服务器文件,名为fib.js,它将根据特定长度的斐波那契数列的数字之和来回答每个请求。这里没有依赖,只是纯粹的 Node.js。此外,我们将使用ab命令(Apache Benchmark)向我们的服务器发出一些请求。如果你有一个基于 Debian 的机器,你只需要安装apache2-utils就可以使用这个命令了:

var http   = require("http");
var server = http.createServer();

server.on("request", function (req, res) {
  var f = fibonacci(40);

  return res.end("" + f);
});

server.listen(3000);

function fibonacci(n) {
  return (n < 2 ? n : fibonacci(n - 1) + fibonacci(n - 2));
}

你可以看到,fibonacci函数是递归的(应该是的),每次有新的请求进来时都会被调用。看到它表现不太好应该不会让人感到意外。让我们开始并告诉 V8 我们想要一个配置文件日志:

$ node --prof fib.js

现在让我们用只有 10 个请求和两个并发连接的方式进行基准测试。以下输出已经被截断以便更清楚地理解:

$ ab –n 10 –c 2 http://localhost:3000/
This is ApacheBench, Version 2.3 <$Revision: 1604373 $>
(...)
Concurrency Level:      2
Time taken for tests:   18.851 seconds
Complete requests:      10
Failed requests:        0
(...)
Requests per second:    0.52 [#/sec] (mean)
Time per request:       3822.383 [ms] (mean)
(...)

你可以看到每个请求花费了 2 秒的时间(每秒半个请求)。这看起来不太好,对吧?让我们停止服务器。你应该会在同一个文件夹中看到一个isolate*.log文件。你可以用 V8 Tick Processor 打开它。有一个在线版本(v8.googlecode.com/svn/trunk/tools/tick-processor.html),如果你想要的话;或者如果你像我一样有 node 源码,你可以在deps/v8/tools/tick-processor.html中找到它。

Fibonacci

点击选择文件,选择你的日志。该工具将进行处理,返回类似以下的输出。再次强调,一些输出已被删除:

Statistical profiling result from null, (...).

(...)

 [JavaScript]:
   ticks  total  nonlib   name 
  14267   89.1%  100.0%  LazyCompile: *fibonacci fib.js:15:19
      1    0.0%    0.0%  Stub: reinitialize

(...)

 [Bottom up (heavy) profile]:
(...)
   ticks parent  name
  14267   89.1%  LazyCompile: *fibonacci fib.js:15:19
  14267  100.0%    LazyCompile: *fibonacci fib.js:15:19
  14267  100.0%      LazyCompile: *fibonacci fib.js:15:19
  14267  100.0%        LazyCompile: *fibonacci fib.js:15:19
  14267  100.0%          LazyCompile: *fibonacci fib.js:15:19

我们的fibonacci函数真的一直在使用我们的处理器。你可以在Bottom up (heavy) profile部分看到递归模式。你可以看到不同的级别(缩进)是因为函数的递归性。

提示

请注意,在运行自己的测试时,应该限制服务器运行的时间只在基准测试的时间内(就像这个例子中一样)。如果让服务器运行的时间超过这个时间,你的函数的使用将会与空闲时间混合在一起。

在我们的例子中,将代码拆分并不容易,甚至不是更好的选择,因为操作非常简单(只是两个数字相加)。在其他用例中,你可能可以通过修改代码来优化一些操作,例如使用第二章中展示的一些技术,开发模式

在这种情况下提高性能的另一种方法是使用一种称为memoizing的技术。它的作用是包装一个函数并根据参数缓存其返回值。这意味着对于特定的参数集,函数只会被调用一次,然后返回值将被缓存并重复使用。当然,并非所有情况都适用。让我们在服务器上尝试一下:

var http   = require("http");
var server = http.createServer();

fibonacci = memoize(fibonacci);

server.on("request", function (req, res) {
  var f = fibonacci(40);

  return res.end("" + f);
});

server.listen(3000);

function fibonacci(n) {
  return (n < 2 ? n : fibonacci(n - 1) + fibonacci(n - 2));
}

function memoize(f) {
  var cache = {};

  return function memoized(n) {
    return cache[n] || (cache[n] = f[n]);
  };
}

有一些模块可以帮助你实现这个结果。在我们的例子中,我们添加了一个memoizing函数,并实际上用自身重写了这个函数—memoized。这很重要,因为函数会递归调用自身,所以真的需要被重写。

这将缓存对它的每次调用,所以只有第一次的fibonacci(40)调用不会使用缓存。此外,由于函数调用自身使用n-1n-2,一半的调用将被缓存,所以第一次调用甚至会更快。运行ab测试将得到非常不同的结果:

$ ab -n 10 -c 2 http://localhost:3000/
This is ApacheBench, Version 2.3 <$Revision: 1604373 $>
(...)
Concurrency Level:      2
Time taken for tests:   0.038 seconds
Complete requests:      10
Failed requests:        0
(...)
Requests per second:    263.86 [#/sec] (mean)
Time per request:       7.580 [ms] (mean)
(...)

这在每秒超过 250 个请求时要好得多。这显然是一个糟糕的基准,因为如果您将请求的数量增加到几千个,数字会更好(几千个)。如果您使用 V8 Tick Processor,您会注意到函数调用不再存在:

(...)
[JavaScript]:
ticks  total  nonlib   name
 1    0.1%   12.5%  Stub: ToBooleanStub(Null,SpecObject)
 1    0.1%   12.5%  LoadMegamorphic: args_count: 0
 1    0.1%   12.5%  LazyCompile: ~httpSocketSetup _http_common...
 1    0.1%   12.5%  LazyCompile: ~exec native regexp.js:98:20
 1    0.1%   12.5%  LazyCompile: ~UseSparseVariant native array...
 1    0.1%   12.5%  LazyCompile: ADD native runtime.js:99:13
(...)

这显然是一个糟糕而非常简单的例子。每个应用程序都是不同的,分析它将涉及更多了解它。使用开发平台有助于集中您对主题的知识,并有助于您随着时间的推移更容易地改进。

Fibonacci

火焰图

火焰图是一种用于对应用程序进行概要分析并快速、更精确地找出最常用函数的可视化技术。这些图形取代或补充了以前的日志文本输出,因为它们提供了一种更愉快和简单的概要分析方式。

火焰图由几个堆叠的块组成,每个块代表一个函数调用。它通常水平显示使用时间(不一定按顺序)。当一个函数被另一个函数调用时,第一个函数显示在前一个函数的顶部。使用这个规则,您可以想象出顶部的块肯定会比底部的块小(水平)。这创建了一个在视觉上类似火焰的图形。此外,这些块通常使用暖色(如红色和橙色),因此图形看起来真的像火焰。

这些可以用于不同的目标,比如查看内存使用和泄漏。例如,您可以创建一个火焰图来查看 CPU 的使用情况(繁忙的 CPU 是一直在工作的,空闲的 CPU 是什么都不做的)。另一个很好的用途是查看应用程序何时处于空闲状态,I/O 与 CPU 和内存相比非常慢,这在应用程序阻塞(停止)等待来自磁盘或网络的文件时是正常的。这称为 CPU 外。这在冷色(蓝色和绿色)中更容易看到。两个 CPU 火焰图的混合也可以让您更好地了解应用程序的行为。

在 Node.js 上创建火焰图并不容易,这取决于您的系统。由于 V8 支持perf_eventscodereview.chromium.org/70013002),我发现在 Linux 上使用perf命令和perf_events要容易得多,但您也可以选择其他方法,比如 DTrace(www.brendangregg.com/flamegraphs.html)。让我们现在尝试一下。获取一个 Ubuntu 机器(或虚拟机)并安装一些依赖项。请注意,其中一些依赖于您当前的内核版本:

$ sudo apt-get update
$ sudo apt-get install linux-tools-common linux-tools-`uname –r`

现在让我们运行我们的 node 服务器,告诉 V8 我们想要perf_events输出。这次,让我们在后台运行它,以便更容易看到其 PID,并在之后运行perf

$ node –-perf-basic-prof fib.js &
[1] 30462

我们需要的 PID 是30462。然后让我们运行perf来收集大约一分钟的事件。该命令在完成之前不会返回(监听一分钟的事件),因此您需要打开另一个控制台来运行基准测试命令:

$ perf record -F 99 -p 30462 -g -- sleep 60
# on another console..
$ ab –n 1000 http://localhost:3000/

我们告诉 perf 以99 Hz 的频率记录30462进程的事件,启用调用图(-g),并持续60秒。之后,此命令应该结束。第一个版本的代码非常慢,需要超过 60 秒才能完成,因此用户可以在一分钟后停止它。第二个版本要快得多,不需要这样做。

您可以查看目录并注意到有一个perf.data文件。现在我们需要告诉perf读取此文件并显示跟踪输出。我们将使用它并将其转换为火焰图。为此,我们将使用 Brendan Gregg 创建的堆栈跟踪可视化器。此输出将转换为 SVG 文件。然后您可以在您喜欢的浏览器中打开它。首先让我们获取此堆栈输出:

$ perf script > stack01.trace

现在让我们下载堆栈跟踪可视化工具,并使用它来转换这个文件。你需要使用git来获取这个命令:

$ git clone --depth 1 http://github.com/brendangregg/FlameGraph
$ ./FlameGraph/stackcollapse-perf.pl < stack01.trace | ./FlameGraph/flamegraph.pl > stack01.svg

现在你应该有一个stack01.svg文件,你可以与之交互。你可以点击水平块来放大到该级别,或者点击最低的块来重置放大。对于你的服务器的第一个版本,你应该得到类似于这个图表:

火焰图

你可以清楚地看到推动火焰变高的递归模式。有一个初始的大火焰,旁边还有其他火焰。如果你点击第二个火焰的底部,你会看到类似于以下的内容:

火焰图

现在你可以清楚地看到你的处理器被这个低效和递归的函数耗尽了。在检查火焰图时,看看底部的线条。它会显示我们在日志处理器的初始输出中看到的信息,比如使用百分比。

如果我们使用第二个服务器版本,如果我们想要看到有用的东西,我们需要增加基准负载。尝试使用以下步骤为第二个服务器版本创建火焰图:

$ node –-perf-basic-prof fib.js &
[1] 30611
$ perf record -F 99 -p 30611 -g -- sleep 60
# on another console..
$ ab –n 10000 http://localhost:3000/
$ perf script > stack02.trace
$ ./FlameGraph/stackcollapse-perf.pl < stack02.trace | ./FlameGraph/flamegraph.pl > stack02.svg

现在在浏览器中打开这个新的 SVG 文件,看看火焰是不是更细了。这意味着虽然堆栈大小可能很大,但堆栈大小的持续时间很短。类似于这样的情况更正常:

火焰图

在图表的底部,你总是会看到nodemain,因为 Node.js 大部分时间都在主线程上。在 node 或 main 的上面,你会看到其他线条。每一条堆叠的线条代表下面一行的调用。当你到达火焰的顶部时,你会开始看到实际的 JavaScript 代码。你会发现很多调用与 Node.js 的内部函数相关,涉及事件和libuv任务。

提示

作为一个经验法则,一个有着巨大而宽阔火焰的火焰图意味着 CPU 使用过多。一个有着高但较细火焰的火焰图意味着 CPU 使用较低。

性能分析的替代方案

根据操作系统,还有其他用于分析应用程序处理器使用情况的替代方案。如果你使用的是支持的系统,可以尝试使用 DTrace。我不建议在 Linux 系统上立即使用它。此外,如果你没有使用基于 Illumos 的系统,至少对于 Node.js 来说,你可能会忘记它。Linux 有更多的调用堆栈调试工具,你可以使用它们来记录堆栈,然后生成火焰图。

Node.js 有性能分析模块,甚至有调用堆栈跟踪模块,但我建议你避免在语言级别上调试它们,而是选择操作系统级别。这样做速度更快,对你的代码干扰更小,通常可以更全面地了解你尝试分析的行为或不良行为。记住,系统不仅仅是你的应用程序,可能还有其他因素在你的性能范围之外影响着你的性能。

你可以使用火焰图来分析其他类型的数据。例如,你可以跟踪设备 I/O 调用或系统调用。你可以将跟踪结果过滤到特定的函数调用,以查看函数何时以及多长时间被使用。你可以跟踪内存分配,而不是收集分配调用,你可以收集分配大小(以字节为单位)。这种类型的图表有很多用途,因为它对于直观分析你的应用程序行为非常方便。

总结

在当今的环境中,能够对应用程序进行性能分析以识别瓶颈是非常重要的,特别是在处理器和内存级别。系统是复杂的,分为几个层次,因此使用调用堆栈来分析处理器使用情况可能非常困难,如果没有一些工具和可视化技术,比如火焰图。总的来说,你应该在进行性能分析之前专注于你的代码质量。

正如您在我们的示例中看到的,对于我们的服务器来说,一个简单而有效的解决方案是缓存结果。缓存是一种非常重要的技术,通常在平衡资源使用方面至关重要。通常情况下,您有可用的内存,最好将结果缓存一小段时间,而不是每次都处理它,特别是当结果是不可变的时候。

接下来,我们将看看您应该如何使用和存储数据,以及何时以及多长时间应该对其进行缓存。我们将研究一些缓存方法论的利弊,这样您就可以更好地选择自己的路径,使您的应用程序成为可能的最高性能应用程序。

为 Bentham Chang 准备,Safari ID 为 [email protected] 用户编号:2843974 © 2015 Safari Books Online,LLC。此下载文件仅供个人使用,并受到服务条款的约束。任何其他用途均需版权所有者的事先书面同意。未经授权的使用、复制和/或分发严格禁止,并违反适用法律。保留所有权利。

第五章:数据和缓存

数据是您应用程序中最重要的资产之一。实际上,它应该是基本资产。您可以在任何地方运行应用程序,但没有数据,它是没有意义的。通过数据,我指的是您的应用程序操作的信息,无论是由最终用户生成还是不生成。如果您的应用程序没有数据库无法运行,那么该数据库包含了您必须保留的重要数据。

应用程序数据非常重要。在 Web 应用程序中,用户使用互联网访问数据,并且他们的数据存储在服务器端,这增加了重要性。随着用户群体的增长和数据总量的增加,计划数据存储方式以及其使用方式变得更加重要。

不要忘记制定备份计划。您不希望丢失数据并且无法回滚,即使回滚意味着时间倒退一周。您的用户可能会接受丢失一些数据(1 周),但绝对不会接受全部丢失。

让我们通过查看一些重要主题来了解数据存储:

  • 过多的 I/O

  • 数据库管理系统

  • 缓存数据和异步缓存

  • 数据集群

  • 访问数据

数据存储

存储数据的方式有很多种。这取决于您拥有的数据类型以及它可能变得有多大。如果您只需要存储简单的键/值对,您可以使用您选择的格式的文件(例如 INI 或 JSON 文件)。如果这些键/值对增长到成千上万,您可能不希望将其保留在那里。您需要考虑您的数据,并选择从您的角度来看最佳的存储方式。

如果您有其他应用程序,您可以尝试为所有或其中一些应用程序选择相同的数据存储。这实际上不是一个坏决定。选择第二好的工具,并尝试仅使用一两个工具来处理一些应用程序,大大提高了您对该子集的了解的机会,而不是为每个应用程序使用最好的工具,最终拥有许多工具,对每个工具的了解很少。

过多的 I/O

在使用自定义解决方案时,我们需要仔细规划如何存储和访问我们的数据,特别是何时以及多少次。您的主机有磁盘吞吐量限制,您不希望达到该限制。此外,您肯定不希望每次需要数据时都从磁盘读取。这在本地测试期间可能有效,但如果您的应用程序面向数千用户,它将崩溃,您可能会开始收到EBUSYEMFILE错误。

其中一种策略是避免过多的 I/O,只需在开始时读取数据,将其在内存中操作,然后不时地将数据刷新到磁盘上。数据可以以各种格式存储,JSON目前是最著名和最常用的格式。这有一个缺点,即强制您的应用程序实现一个单一通道来读取和写入文件,否则您迟早会得到损坏的数据。

与其创建自定义数据存储,不如使用数据库或其他数据模型服务器。将数据存储留给专业人员,专注于您的应用程序。这样做的一些优势如下:

  • 数据存储不需要维护。

  • 数据库服务器针对高性能场景进行了优化

  • 数据库服务器通常支持多台机器持有数据,允许您的应用程序按需扩展大小

这一切取决于您选择的系统。最好在开始之前花时间选择一个好的系统。我会专注于可扩展性和一致性。速度是无法衡量的,它因应用程序和用例而异。

数据库管理系统

如果你选择了数据库管理系统DBMS),非常重要的是你要对它感到舒适。不要将你不熟悉的服务器投入生产,因为你肯定会后悔的。在生产中使用 DBMS 时,你需要对以下内容感到舒适:

  • 管理:非常重要的是,你能够将你的应用场景复制到一个新的主机上,而不用太多考虑。你应该知道如何初始化你的存储并管理访问。寻找可视化界面(如桌面和 Web),避免仅通过控制台进行管理;在控制台上会更容易出错,因为对于复杂的任务来说更难。可视化界面通常具有自动化工具,可以帮助你避免语法错误。

  • 安全性:要小心默认权限,特别是本地主机权限,因为它们通常被设置为宽松,可以完全控制数据。你不想丢失数据,对吧?

  • 备份:非常重要的是你要有一个定期和自动化的计划,并且知道如何回滚到备份。你应该在另一个主机上进行试验。你不想回滚后发现你的备份已损坏。安装一个 cron 作业(可以是本地或远程),定期导出并进行测试。我个人更喜欢有一个或两个可用的备份,而不是有 10 个不可用的备份。

  • 结构:知道如何组织和关联你的数据以获得更好的存储和更快的访问是必不可少的。你绝对不想以后再做改变。

你选择的数据结构与你的 DBMS 和应用程序的性能直接相关。绘制你的数据草图,看看你的数据实体是如何相互关联的。在你的数据库中拥有几个表是非常普遍的。毕竟,这是你首先使用数据库的原因之一。

你通常没有考虑到的是,你可能有一个单独的表,也许是一个历史表或类似的表,随着时间的推移,它将占据超过 90%的数据库空间。优化该表并决定是否有不需要的列或者可以移动到另一个表是至关重要的。以后你会感谢我的!

即使优化了该表,你也无法阻止它的增长。你真的需要有一个终身历史记录吗,还是可以将数据每月或每年导出到另一种格式,并从数据库中清除?拥有一个可以增长甚至扩展到多个服务器的数据库是好的,但这并不意味着性能。

就这个问题而言,分析一下你可能最看重的是什么。是完整性吗?你需要额外的安全性吗?你是否计划将数据库分布到不同的服务器上,就像 MongoDB 一样?你更喜欢一个经过验证稳定的成熟服务器,还是选择一个新技术?就像我之前说的,尝试第二个最佳选择。你可能会更频繁地使用它,避免使用很多不同的技术,这样会更难维护。

你的数据现在应该已经被结构化了。例如,如果你正在创建一个日历应用程序,你可能有用户、日历和事件等实体。在创建基本结构之后,你可能会意识到你需要更多的结构来关联日历和用户(也许是访问权限),以及用户和事件(也许是参与者)。经过几次开发迭代后,你可能会有更多的结构。

你的结构会增长,你的表会开始增加更多的列。你会意识到,在这种情况下,你的瓶颈表是保存事件的表。希望你能及时优化它,删除一些很少使用的列,并将其移动到另一个表中。当没有空间可以减少时,你必须考虑其他选项。

缓存数据

当某个信息被频繁请求且其值不会改变时,例如历史值时,缓存变得相关。如果这些值在数据库中需要一些复杂性和操作,那么缓存是提高性能的好方法。即使它们不是历史值并且可能会改变,有时缓存也不是那么糟糕,至少可以持续几分钟。

在复杂系统中,你可能会发现缓存是应用程序和数据库之间的第二层抽象。在这种情况下,双向更新会发生;也就是说,数据被获取到缓存中,当被某个用户操作更改时,缓存数据会被更新,然后数据库也会被更新。这比清除缓存并强制向数据库发出新请求来获取我们已经知道的数据要快。你可能会在基本应用程序中发现这一点,例如在会话数据中。

一些数据库可以执行这种缓存,但其他数据库不行,你不能依赖它们来执行。此外,在其他情况下,它们无法缓存,因为你需要操作数据。在某些情况下,你需要将缓存地址到另一个应用程序或另一个键/值服务,你可以用它来保存值并在一段时间内使用。Redis 可以用作缓存服务。它支持一些不错的功能,比如复杂结构、事务和生存时间键。

你的缓存逻辑应该类似于这样:

Caching data

这种逻辑可以以多种方式使用。你可以在内存中使用缓存,获取最快的缓存以适用于小数据集。如果你知道你的缓存数据可能超出可用内存,你可以使用文件。例如,如果你生成图像或文档缩略图,你可以对它们进行缓存,而且,存储它们的最佳位置可能是磁盘。

你可以使用处理数据存储并允许你专注于应用程序逻辑的服务。一些最受欢迎和简单的服务包括 memcached 和 Redis。它们各自都有优缺点。在这两种情况下,它们都需要零设置即可开始使用。

异步缓存

编写 Node.js 应用程序会迫使你以异步方式思考。这意味着你将面临一些挑战,其中一些你可能甚至还不知道。其中一个特别痛苦的挑战就是异步缓存。无论你是使用外部服务还是简单的内部函数,异步部分都在你这一边,它是负责给你带来不愉快的部分。

问题不会轻易显现出来;你可能只有在负载变高并且看到很多缓存函数命中时才能发现它。这不容易描述,所以让我们看一个假的缓存示例,我们可能在每个应用程序中的某个地方都会做到:

var users = {};

function getUser(id, next) {
  if (users.hasOwnProperty(id)) {
    return next(null, users[id]);
  }

  userdb.findOne({ id: id }, function (err, user) {
    if (err) return next(err);

    users[id] = user;
    return next(null, user);
  });
}

这是非常不完整的,但你可以理解。每当你想要一个用户时,你调用getUser。这个函数会从某个地方获取它(users.findOne可能是来自 ORM),然后返回它。然后它会将它存储在哈希表中,如果你再次请求它,它将直接返回该用户。没有生存时间或适当的错误处理,但这不会解决下一个问题。

我们假设获取用户非常快,对吧?想象一下它需要一些时间,也许几秒钟。接下来,想象一下这个函数被经常使用。如果由于网络中的某些故障,获取用户需要 10 秒钟,而在这段时间内,你调用了这个函数 100 次,会发生什么?

没有缓存值,100 次调用中的每一次都会尝试访问数据库,因为它们忽略了第一次调用实际上会缓存该值,其余的 99 次调用可以使用它。如果问题出在用户获取上,调用会累积并使应用程序崩溃。这是因为获取用户不是瞬时的,所以对同一用户的后续调用应该排队,直到用户被获取为止。

它可能是以下代码的样子。再次强调,这是一个简化版本:

var users = {};

function getUser(id, next) {
  if (users.hasOwnProperty(id)) {
    if (users[id].hasOwnProperty("data")) {
      // already have a value
      return next(null, users[id].data);
    }
    // not yet, queue the callback
    return users[id].queue.push(next);
  }

  // first time
  users[id] = {
    queue: [ next ]
  };

  userdb.findOne({ id: id }, function (err, user) {
    if (err) return next(err);

    users[id].data = user;

    users[id].queue.map(function (cb) {
      cb(null, user);
    });

    delete users[id].queue;
  });
}

花时间去理解它。正如你所看到的,它并不是范式有缺陷;而是方式。通常,开发人员接受培训,但并没有为 Node.js(以及其他相关事项)强加给你的异步平台做好准备。

多年来,将数据库抽象成对象关系映射ORM)是一个好的做法(现在仍然是)。抽象创建了一个新的层,允许你更改数据库类型(多多少少)并仍然让你的应用程序正常工作。对于更成熟的应用程序来说,这实际上并不那么简单,因为要避免服务器的某些特定性以提高性能可能会非常困难。除了这个小优势之外,它可能会降低访问速度,从而使你的应用程序变慢一点。然而,它还有其他优势,特别是在专业市场上,因为你可以直接将你的业务模型和实体应用到你的代码中。

对于历史数据或一般的大型数据集,ORM 并不是最佳选择。许多 ORM 为你提供了对每个项目的额外控制权,但这是有代价的(速度和内存)。对于大型数据集,你会得到额外的控制权(以及大量的速度和内存成本)。你会发现,不仅是层使得你的应用变慢;通常数据库也不准备好在表中存储大型数据集(大型意味着千兆字节)。

你可能会寻找其他可以给你中间级别缓存的服务,如果使用正确,可以通过帮助你访问最常用的特定数据来提高性能。像ØMQRabbitMQ(都是消息队列服务)这样的服务可能会引起你的兴趣。它们可以充当数据存储服务器的代理,创造一个你拥有一个大型和统一的存储服务器的想法。这些服务旨在提供高性能,这是它们设计的用例之一。

添加像代理一样的服务会给你的应用环境增加另一层。在小型场景和小型数据集中,它们可能会过度。但在更大的数据集中,即使在单个存储服务器上,它们可以帮助维持恒定的吞吐量,同时数据集增长。

数据集群

需要将服务分布在不同的主机上。在你的应用数据集不断增长的过程中,你会看到你的主机在呼喊资源,平均负载逐渐消耗你的每一个处理器。从那一刻开始,你需要添加一个主机来保持速度稳定,并允许数据集继续增长一点。

从使用一个主机转变为使用两个主机可能会很复杂,迫使你控制数据库服务器或其他类型的数据集群。许多数据库服务支持集群或某种形式的复制。下面的图片是一个数据库在服务器上的复制示例,允许应用程序访问任何数据库实例。

数据集群

在多主复制模式下,数据集通常存储(并复制)在两个或更多个主机上,允许数据从任何一个主机更新。这将数据复制到所有主机,称为成员。由于没有分区,每个成员都负责处理客户端请求。

这些是一些优势:

  • 无单点故障。每个成员都是主节点,所以每个人都可能失败。

  • 主机可以在地理上分布,使你的应用程序也可以分布在你的客户附近。

以下是一些缺点:

  • 如果是在异步模式下,通常不是一致的,因为在数据复制到另一个主机之前,网络可能会让你失望。

  • 如果是在某种同步模式下,它会引入延迟,因为你的服务器在数据复制之前不会回复给你,而且再一次,你的网络可能会让你失望。

并没有银弹,对于一个真正高性能的应用程序,您绝对需要深入研究您的数据。您可能需要将其分割到不同类型的服务器上,利用它们独特的特性。如前所述,消息队列服务器可能是您数据的最佳选择之一。

复制不能让您正确扩展。您的数据在每台服务器上都是完整的。对于大型数据集来说,这是一种浪费空间的做法,因为所有服务器除了一台之外的概率非常小。而且你有备份,对吧?

提示

还有更好的选择,比如集群,其中您的数据被分区,每个块都在至少两台主机上复制。通常由您决定。这类似于磁盘上的 RAID5,但没有写洞现象(www.raid-recovery-guide.com/raid5-write-hole.aspx)。

访问数据

您的应用程序需要为这些情景做好准备。在前面的图表中显示了其中一种可能性。您的应用程序了解复制成员并尝试随机使用它们或按照特定规则使用它们。由您的应用程序或数据库模块来识别故障并正确处理。下图描述了您还可以复制应用程序实例并引入代理来中介访问应用程序。

访问数据

另一个可能的情景是将您的应用程序实例绑定到每个复制主机,甚至是本地主机。这样,您的应用程序可以在本地工作。然而,这带来了两个需要解决的问题:

  • 拥有反向代理可以根据用户的地理位置或应用程序实例负载为每个用户分配一个应用程序实例。

  • 您的应用程序需要能够在这种情况下工作(无状态),除非您的代理确保每个客户端始终访问相同的实例

如果您的应用程序只需要存储在数据库中的数据,这些是可能的情景。如果它依赖于文件系统,除非您在主机之间进行了某种同步,否则有些情况不适用。我想到了 GlusterFS。如果您不需要文件系统,并且对某种对象/大块存储感到满意,Ceph 甚至 MongoDB 都是不错的选择。如果您想要一个高度可扩展的数据存储服务器,您可能只需开始查看 Cassandra 并忘记其他选择。从头开始准备您的应用程序以便与之一起工作,您将不会后悔。

摘要

数据是您的应用程序的关键部分,规划如何构建数据结构非常重要。更重要的是,您要计划应用程序的增长和数据升级。不要忘记为数据的最常用部分进行缓存,更重要的是,不要忘记备份。复制和集群不是备份的一种形式。您需要一个正确的备份计划,以避免将来的停机时间。不要忘记重视您的数据。

在下一章中,我们将继续讨论应用程序性能的主题,看看测试为什么重要,以及您应该如何进行基准测试和仔细阅读结果(带有一些保留)。您的应用程序几乎已经准备好高性能了。但在投入生产之前,请确保对其进行彻底测试。

为 Bentham Chang 准备,Safari ID [email protected] 用户编号:2843974 © 2015 Safari Books Online,LLC。此下载文件仅供个人使用,并受到服务条款的约束。任何其他用途均需版权所有者的事先书面同意。未经授权的使用、复制和/或分发严格禁止并违反适用法律。保留所有权利。

第六章:测试、基准测试和分析

测试您的应用程序与其开发一样重要。测试是分析应用程序模块和整个应用程序的过程,以查看它是否表现如您所期望的。它允许您的业务定义用例并检查它们是否都得到满足。

有许多测试技术。其中最著名的之一是测试驱动开发TDD)。这种技术包括使用尽可能小的开发周期。在每个周期之间,进行测试并在开发之前添加新的测试和用例。这样,您的应用程序版本可以持续测试,并且可以快速发现任何有问题的版本。如果您使用版本控制系统,比如 Git,那么很容易找到失败测试的罪魁祸首并加以修复。

从头开始执行测试的一个重要方面是,您可以在发现它们时不断添加用例和测试用例。例如,如果有人报告了一个错误,并且您为此创建了一个特定的用例,您可以确保该错误不会再次出现,或者它不会在测试中可见。在社区驱动的项目中,很常见看到这种用例(成员发现错误并为其添加测试用例)。如果您能复制它,您可以创建一个测试用例。

根据您的测试平台,您可以对应用程序进行基准测试。通常,测试平台每个测试有一个默认的超时时间,长达 1 或 2 秒。您可以减少这个值以确保功能的性能。您也可以通过为更长的用例提供更多时间来做相反的操作。

具有此超时功能的平台可以让您进行一致的测试。记住在一个常见的平台上进行测试,比如一个通用的工作环境。不要为一个超快的服务器定义测试基准,然后期望它们在一个 20 年前的计算机上通过。

测试基础

测试可以以多种方式定义。最常见的方法是单元测试。这是一种方法,通过该方法可以逐个检查应用程序的部分,以确认它们是否符合规范。这种方法鼓励您的应用程序部分作为独立和可替换的黑匣子。

您需要真实的数据来正确测试您的应用程序。您还需要不切实际的数据。这两者对于确认它在正确数据和混乱数据下的行为是否符合预期都是至关重要的。这确保了误导或恶意用户不会破坏您的应用程序。

您可能想知道我所说的不切实际的数据是什么意思。您的应用程序是否处理日期字段中的文本或复选框中的数字?缺少的数据呢?您可能认为它可以,但是如果有更多的开发人员在上面工作,您可能希望确保在将来的某个地方它不会停止正确地行为。最常见的错误类型是在一个地方进行更改后在完全不同的地方出现。

单元测试的目标应该是完全隔离您应用程序的每个模块,并能够独立测试它们。如果一个模块需要应用程序的其他部分正常工作,您可以使用 Sinon(例如sinonjs.org/)来伪造数据或模拟依赖关系。

一些测试的好处如下:

  • 在开发周期的早期发现错误。由于您可以在每次更改代码时测试代码,因此应该更早地发现错误。更早地修复错误的成本,有时甚至在投入生产之前,大大降低了总体成本。

  • 它迫使开发人员考虑 I/O 数据和错误,因为应用程序架构师必须考虑并正确描述每个用例。功能和用例是根据一个或多个测试用例开发的。

  • 它使您能够更改或重构模块,同时确保预期行为保持不变,因为有了测试用例。

  • 它有助于模块集成测试,因为每个模块都经过测试并具有预期行为。

只有在测试被正确定义并且您的测试覆盖了整个应用程序(所有功能和对象)时,才能实现所有这些好处。通过正确的测试覆盖,您还可以为新功能或边缘情况添加特定的用例。

为每个模块分离测试是相当困难的。例如,如果您的模块之一需要数据库才能工作,那么您的测试用例将需要给予它数据库访问权限。这不好,因为您的单元测试实际上将是集成测试,如果失败,您将无法确定问题是模块还是数据库的问题。

测试环境

拥有一致的测试环境也很重要。更重要的是,环境应该与生产环境相同或几乎相同。这意味着相同的应用程序(当然),但也是相同的操作系统版本、相同的数据库服务器版本等等。

例如,对于 Node.js 测试,请确保您的测试环境具有相同的 Node.js 版本。您可以尝试不同的版本,但最重要的是在生产中使用的版本。相同的原则也适用于操作系统版本、数据库服务版本、依赖项版本等等。

Docker 工具

拥有相同的环境可能不容易,但有解决方案——Linux 容器。如果您还没有尝试过 Docker,那您就错过了。这个解决方案是免费的,是一个涉及容器的工具,使它们可用。

与 Vagrant 等工具相比的主要区别在于,它不需要虚拟机来创建环境。Docker 类似于 OpenVZ(openvz.org/Main_Page),但有一个区别;您可以创建一个环境(容器)并共享给其他人使用。如果您喜欢 NPM,您会发现这很相似。您有版本和依赖关系,最常用的环境已经在线上供您下载和使用。

您可以在容器中创建一个测试环境,然后将容器分发给其他开发人员。这也适用于生产。您的开发人员可以在他们的笔记本电脑上获得生产数据库的快照和完整的生产环境。通过这种方式,可以进行更改和测试,就好像它们被应用于生产环境一样。这比在生产环境中尝试并不得不回滚要好。这样,您将更少地回滚。这是持续集成的原则。

让我们为我们的 Node.js 应用程序创建一个非常简单的环境。安装 Docker,打开终端,运行以下代码:

$ docker pull node:0.12.4
Pulling repository node
4797dc6f7a9c: Download complete
...
6abd33745acb: Download complete
Status: Downloaded newer image for node:0.12.4

请记住,我们想要一个特定的版本,这就是为什么在这种情况下我们强制使用0.12.4。我认为操作系统不重要,因为我们的应用程序不会有外部依赖项或节点模块。这个命令只会下载图像模板,还没有创建任何环境;我们马上就会做。您会注意到它需要几百兆字节。不用担心;这可能是您唯一需要的空间,因为您的环境几乎总是依赖于这个图像。如果您想查看已下载的图像,请运行以下命令:

$ docker images
REPOSITORY     TAG       IMAGE ID        CREATED      VIRTUAL SIZE
node           0         4797dc6f7a9c    3 days ago   711.8 MB
node           0.12      4797dc6f7a9c    3 days ago   711.8 MB
node           latest    4797dc6f7a9c    3 days ago   711.8 MB
node           0.12.4    4797dc6f7a9c    3 days ago   711.8 MB

嗯,那里有很多空间,不是吗?如果你仔细看,你会注意到只有一个图像(IMAGE ID是相同的)。发生的事情是,0.12.4实际上是写这本书时的最新版本,并且最新标签也已分配给我们的图像。此外,该版本是0.12的最后一个版本,也是 0 的最后一个版本。

这意味着我们可以使用这些标签中的任何一个来引用我们的图像,但我们不希望这样,因为可能会出现新版本,我们的图像将开始使用这些新版本构建。

我们可以看到哪些容器正在运行,或者之前创建过但不再运行。我们可以简单地看到正在运行的内容,但我发现看到死掉的容器更有用,因为它们可能使用了不必要的空间。现在没有容器了。我们可以简单地测试镜像以查看它是否工作:

$ docker run -it node:0.12.4 bash
root@daa77af1b150:/# node -v
v0.12.4
root@daa77af1b150:/# npm -v
2.11.1
root@daa77af1b150:/# exit

我们刚刚使用我们的镜像在交互模式下运行了一个基本环境,使用了tty(-t)中的bash,而不是在后台运行(-d)。您可以看到我们的环境中有 node 和npm。如果我们查看存在的容器,我们会看到类似于这样的东西:

$ docker ps -a
CONTAINER ID     IMAGE      COMMAND     CREATED ...
1a56bbeb3d36     node:0     "bash"      47 seconds ago ...

我们的容器有唯一标识符1a56bbeb3d36,正在使用0节点镜像,并且正在运行bash命令。好吧,它实际上已经不再运行了。您可以通过运行这行来删除它:

$ docker rm 1a56bbeb3d36

注意ps命令中的Exited (0) ..行吗?是的,命令的退出代码是可以访问的。如果您使用exit 123退出bash,您将在容器外看到它。这对于启动测试命令而不是bash,然后仅根据退出代码检查所有测试是否通过非常有用。您还可以记录输出,并在失败时保存以供分析。

测试工具

现在我们有了一种复制环境进行测试的方法,我们需要一个合适的测试工具——您可以用它来定义您的用例和测试用例。有许多很棒的工具,Node.js 有专门用于测试的工具。其中一些真的很棒。

如果您没有头绪,我建议尝试一下 mocha (mochajs.org/)。它可以在 NPM 上安装,并且您应该全局安装它:

sudo npm install -g mocha

通过这种方式,您可以在计算机上的所有应用程序中使用 mocha,而无需一遍又一遍地安装它,因为它实际上是一个开发/测试依赖项,而不是一个真正的应用程序依赖项。全局安装它还将在您的路径中安装mocha命令。

让我们创建一个非常简单的名为module.js 的模块,其中包含一个简单地添加两个数字的函数:

// add a with b
exports.add = function (a, b) {
  return a + b;
};

现在,让我们创建一个测试用例。为此,我们将创建另一个名为test.js的文件:

var assert = require("assert");
var m      = require("./module");

describe("module.add()", function () {
  it("should add two numbers", function () {
    assert.equal(m.add(2, 3), 5);
  });
});

正如您所看到的,这个文件加载了我们的模块(m),并断言m.add应该添加两个数字。为了检查它,我们通过检查模块在我们传递23时是否返回5来添加一个测试用例。现在,在您拥有这两个文件的文件夹中打开一个终端,并且只需运行mocha而不带任何参数,就像这样:

测试工具

不错,是吧?还有其他形式的输出称为报告者,例如进度、列表或点矩阵。如果您只想要简单的输出,尝试列表或进度。如果您想要每个测试的详细信息,请使用规范报告者。它显示在前面的截图中。

让我们向我们的函数添加另一个测试。将测试文件更改为如下所示:

var assert = require("assert");
var m      = require("./module");

describe("module.add()", function () {
  it("should add two numbers", function () {
    assert.equal(m.add(2, 3), 5);
  });

  it("should return null when one is not a number", function () {
    assert.equal(m.add(2, "a"), null);
  });
});

如果您再次运行mocha,您的测试用例将导致test套件失败,就像这个截图中显示的那样:

测试工具

让我们更改我们的模块以正确地行事,就像我们在新测试中所述的那样。您可以随意更改它;我只是举个例子:

// add a with b
exports.add = function (a, b) {
  if (isNaN(a) || isNaN(b)) {
    return null;
  }
  return a + b;
};

再次运行,我们的测试应该通过,就像下面的截图中显示的那样:

测试工具

现在,我们可以在我们的环境中测试这个,而不是直接测试它。这确保了我们的应用程序在一个干净的环境中工作,并且不是因为您的本地环境的某些原因而通过。为此,我们可以使用我们之前的 node 镜像。让我们创建一个简单的测试环境。为此,我们需要在我们的测试文件夹中创建一个名为Dockerfile的文件:

FROM node:0.12.4

RUN npm install -g mocha

VOLUME /opt/app/

这描述了我们的环境。文件描述如下:

  • 使用 node 镜像版本0.12.4

  • 安装mocha依赖

  • /opt/app上创建可链接的卷

现在,让我们构建我们的环境并称之为env/test。我们实际上是在基于另一个镜像创建一个新的镜像。我们可以在运行环境时指定一个可链接的卷作为文件夹。这样,你可以为所有你的应用程序使用这个镜像。为了构建我们的环境,我们运行这个命令:

$ Sending build context to Docker daemon 11.26 kB
Sending build context to Docker daemon
Step 0 : FROM node:0.12.4
 ---> 4797dc6f7a9c
Step 1 : RUN npm install -g mocha
 ---> Running in 286c8bb64a2b
...
Removing intermediate container 26fd9bb79ed5
Successfully built e36af32c961c

现在我们有一个可以使用的镜像。让我们通过使用mocha运行我们的测试来尝试这个镜像。

测试工具

查看 Docker 的在线文档,了解命令行的详细信息。我们正在运行我们的镜像,其中/opt/app(-v)卷是我们当前的文件夹(包含我们的 Node.js 文件)。我们的测试环境以交互模式运行(-it),并且在最后丢弃结果镜像(--rm)。

如果有一个中央代码存储库,最好在提交之前进行测试,以避免常见错误。这也可以避免破坏性的变更。通常会出现修复或改进某些内容的变更,同时破坏其他内容的情况。有了始终干净的测试环境,开发人员可以确保测试正确运行。这个环境可以类似于下图中的环境:

测试工具

持续集成

持续集成(CI)是一种实践,应用程序的所有开发人员不断将他们的更改集成到一个中央存储库中。这是极限编程(XP)中使用的一种实践。它可以更快地引入新功能,并通过减少代码合并时间来避免代码冲突。

如果应用程序有一个良好的测试套件,开发人员可以在本地测试变更,模拟生产和测试环境,并且只有通过了测试才提交。这些测试不应该取代服务器上的测试。如果测试套件执行速度快,甚至可以作为提交合并的保证,但通常不建议这样做,因为有些提交实际上无法通过。通常,所有提交都会被接受,然后才会进行测试。测试结果应该至少在开发人员圈子内公开,作为一种强制他们注意他们的提交、代码结构和提交描述的方式。

CI 有四个最佳实践:

  • 拥有一个代码存储库并使用版本控制系统

  • 每次提交都应该经过检查,以确保它通过了所有的测试。

  • 将测试环境与生产环境分开

  • 自动化部署

实现这种工作流程的一种方法是使用 git。因为它允许你为提交和合并定义钩子,你可以在中央存储库中添加一个钩子来测试每个新的提交。如果提交通过,它可能有资格通过到生产环境。

一种策略是将最新通过所有测试的提交与生产环境合并。这可以是每次提交通过或在特定时间。对于简单的应用程序,这种方法是可以接受的。但如果你有一个庞大的用户群体,这可能会带来真正的风险。确保你的测试基础真的很好,并且至少查看和阅读提交的变更日志。有一些你应该知道的风险,如下:

  • 你的测试基础可能无法覆盖所有代码。这意味着你的代码中有一些部分没有经过测试,这会对其行为产生不确定性。在这种情况下,你应该尽可能覆盖你的代码。

  • 你的测试基础可能无法覆盖所有用例。如果你的所有用例都没有在测试中描述,它们将不会在你的代码中进行测试。它们可能会被正确处理,但仍然存在不确定性。因此,你应该描述所有的用例。

  • 有些测试用例很难描述甚至重现。你应该努力避免这些类型的测试,并确保你可以完全依赖测试。否则,你需要有人在应用程序变更上线之前进行测试。

另外,能够针对生产数据库进行应用测试也很重要,也许是最新的备份或者具有复制的数据库,可以在不影响生产环境的情况下使用。

数据大小总是影响你的应用程序的性能。如果你只是测试你的模块来检查简单的用例,你并没有测试负载,但你应该。有时,你的生产数据可能有你一开始没有预料到的关系。你可能认为你的代码不允许这些关系出现,但你可能是错的。

例如,考虑一个层次结构,你为某个元素定义了一个父元素。假设这个后代也可以是另一个元素的父元素。如果一个第三代后代是一个祖先的父元素呢?这会创建一个循环,你可能不希望出现,但你必须处理。即使你的应用程序一开始不允许出现这种循环,也要考虑获取所需的代码来保护自己免受它的影响。

代码覆盖率

通过测试覆盖所有代码对于确保你真正测试了所有东西,或者至少测试了所有编码的东西是很重要的。这并不是一件容易的事。你的代码中的条件和循环会创建许多不同的情况和运行路径,你的一些代码可能只在非常特定的情况下触发。这种情况需要以某种方式进行测试。

代码覆盖率是一个指标,用来表示你的代码有多少被你的测试套件覆盖。更高的指标表示你的应用程序更“测试覆盖”,通常可以表示低错误概率。这个指标通常以百分比值给出,50%的覆盖率意味着你的代码有一半被测试套件覆盖。

有一些工具可以帮助你找到这个值,否则将无法计算它。在 Node.js 环境中,这些工具通常做的是创建你的代码的副本,然后改变每一行有意义的代码,以便计算执行通过该行的次数。有意义的行是真正的代码行,而不是注释或空行。

也有在线服务可以做到这一点。根据你的应用程序许可证或预算,你可能更喜欢在本地准备你的测试环境。这通常并不像看起来那么简单。你必须创建一种仪器化你的代码的方法(最好在副本上完成),并在收集覆盖度指标的同时运行你的测试,然后生成一个报告。

有几种 Node.js 工具可以尝试。没有魔法工具,你应该看看哪个最适合你和你的应用程序。一个可能的工具是istanbul。让我们在我们的小测试示例上试一试。你会发现这有点棘手,对于一个真正的应用程序,你必须自动化这个过程。让我们从安装依赖开始:

sudo npm install –g istanbul mocha-istanbul

mocha-istanbul依赖项可以在本地安装。istanbul Node.js 模块应该是全局的,因为它有一个我们可以使用的命令。现在我们可以仪器化我们的代码。让我们创建一个仪器化的副本:

istanbul instrument module.js > instrumented.js

我们现在必须更改我们的测试套件以使用我们的仪器版本:

var assert = require("assert");
var m      = require("./instrumented");

describe("module.add()", function () {
  it("should add two numbers", function () {
    assert.equal(m.add(2, 3), 5);
  });

  it("should return null when one is not a number", function () {
    assert.equal(m.add(2, "a"), null);
  });
});

最后,我们只需要使用istanbul报告者运行我们的测试套件。要做到这一点,使用reporter参数运行mocha

mocha –reporter mocha-istanbul test.js

不再显示测试的描述,而是显示一个报告,显示代码中有多少行和函数被测试套件覆盖。以下是一个输出的例子:

代码覆盖率

之后,你应该有一个名为html-report的文件夹,里面有一个index.html页面。在浏览器中打开它以分析你的测试覆盖率。你应该看到一个类似以下截图的页面:

代码覆盖率

你会看到test文件夹,里面有我们的原始模块。点击它,你会看到一个覆盖率报告。对于每一行代码(注意,带有闭合括号的行将被忽略),你会看到一个相关联的数字。这个数字对应着在测试过程中执行通过该行的次数。在我们的案例中,它是12列,带有绿色背景。很容易理解,因为我们只有两个测试。

代码覆盖率

基准测试

基准测试是运行一组工具或测试来测量特定性能指标的过程,以便将它们与其他工具或过去的测试进行比较。应用程序最常见的基准测试与两个类似的指标有关:时间(操作的时间)和操作(一段时间内的操作次数)。

为了保持应用程序的性能,你需要不断地进行基准测试。一个明显的方法是使用测试套件,你可以添加专门用于基准测试的特定测试。在检查常见用例之后,你可以有特定的测试,确保某些操作继续运行一段特定的时间。

认真对待基准测试,但不要为此失眠!大多数情况下,当你开始应用程序开发时,你没有统计数据可以进行比较,也不知道要定义哪些基准测试。

首先从基准测试简单的列表开始,比如历史列表,并确保它们不超过 100 毫秒。当创建一个更复杂的界面时,确保它的渲染也表现良好。如果人们不得不等待超过半秒钟来完成一个简单的任务,或者超过一两秒来完成一个更复杂的任务,他们往往会感到压力。

这些基准测试通常使用生产数据的副本,或者如果数据太大,则使用其子集,以确保你正在针对大量数据进行基准测试,而不是在测试环境中(比如你的个人笔记本电脑)上的一小组数据。你也可以对生产数据进行测试,但我不建议这样做。

例如,使用我们之前的测试框架,mocha确保每个测试运行时间不超过两秒。你可以为特定测试更改这个默认超时时间。让我们尝试一下,使用一个名为timeout.js的新测试文件:

describe("timeout", function () {
  this.timeout(100); // milliseconds

  it("will fail", function (done) {
    // we should call done() but we don't to cause timeout
  });
});

我们正在创建一个异步测试。这是因为我们在测试函数中引用了done,以便在测试结束时调用它。在这种情况下,我们没有明确调用它,以强制它失败。让我们试一下,如下所示:

基准测试

在性能重要的特定测试中使用超时是一个好的做法。通常的超时对于大多数常见的测试可能是可以的,但确保你分析一些特定的测试,并确保它们在一定的时间范围内执行。

超时可能是性能限制,或者只是一个标记,告诉你当你的应用程序变得太复杂或者测试数据变得太大以至于无法保持性能时。这时,基于前一章的内容,你需要审视你的环境并分析下一步的行动。

像 mocha 这样的测试套件还可以为你提供其他有趣的信息,补充你的测试,并帮助你更好地了解应用程序的行为,比如:

  • 报告测试持续时间,即使对于不是基准测试的测试,这将使你首先进行测试并查看指标,然后定义一个良好的超时标记。

  • 呈现测试报告。它们可以用于质量保证报告,并且可以保存以供以后分析或比较。

特别是对于 Node.js 应用程序,mocha可以为你提供:

  • 内存泄漏检测,通过查看测试前后的全局变量

  • 检测未捕获的异常,指示引起异常的测试

  • 无缝的异步支持

  • Node.js 调试器支持

  • 浏览器支持

分析测试

拥有一个测试套件非常重要。最重要的好处是能够对应用程序进行全面测试,或者至少尽可能多地进行测试。创建初始测试环境可能是一个挑战,但随着应用程序的不断开发,它会得到回报。

进行适当的测试可以确保您:

  • 不要在引入新功能时重新引入旧的错误。即使不触及源代码,只是进行数据库更改,也可能发生这种情况。

  • 可以通过首先定义测试用例来定义用例(参见en.wikipedia.org/wiki/Test-driven_development)。

  • 可以进行更改,并轻松检查应用程序是否保持预期的行为。

  • 可以检查测试覆盖率,并查看其随时间的变化。

  • 可以为新发现的错误创建特定的测试,并确保它们不会再次出现。

  • 确保基准测试在特定指标下运行。

拥有一个适当的测试套件就像每次进行更改时都有一个质量保证人员测试您的应用程序一样。此外,您的质量保证人员不会像您的测试套件那样精确或快速。

如果您的应用程序不仅仅是您一个人在开发,确保您强制执行测试通过成功和高达 90%的测试覆盖率。如果您自动化了覆盖率测试,您可以将覆盖率指标作为合并新功能与生产的条件。

确保您的测试在开发组圈子中是公开的,让每个人都能看到其他人的工作。这激励人们更好地工作,因为他们的声誉是公开的,至少在团队内部是这样。

当有更多的人关注测试时,开发人员可以分享经验,并在遇到测试失败时寻求帮助。这减少了解决问题所需的时间,并激励开发人员始终保持测试套件的运行。保持测试历史清除失败应该是一个不断的目标。

总结

一个良好的、高性能的应用程序取决于其性能。完整的测试套件确保您在开发中也能表现良好,并且可以快速引入改变——这些改变可以提高性能。测试套件应该有专门的测试用于基准分析,具有严格的时间限制。开发人员应该了解这些测试,并努力保持测试通过,而不必解除这些限制。

将测试套件用作生产的度量标准。确保如果您的测试套件覆盖了应用程序源代码至少 90%并通过了所有基准测试,那么您就可以合并新的更改。为这些测试使用单独的服务器,并且不要将测试与生产混合在一起。保持生产服务器精简和快速,并且只有在确定它将保持这种状态时才进行更改。

在下一章中,我们将看看瓶颈——降低性能的限制,以及您无法对其做任何事情的情况。您必须努力为它们做好准备,并在可能的情况下尽量减轻其后果。网络、服务器和客户端是引入瓶颈的一些因素。有些你可以控制和最小化,但其他的……你只能为它们做好准备。

为 Bentham Chang 准备,Safari ID [email protected] 用户编号:2843974 © 2015 Safari Books Online,LLC。此下载文件仅供个人使用,并受到服务条款的约束。任何其他使用都需要版权所有者的事先书面同意。未经授权的使用、复制和/或分发严格禁止,并违反适用法律。保留所有权利。

第七章:瓶颈

正如我们在前面的章节中看到的,许多因素影响性能。甚至开发过程也会影响你如何监控性能下降。你使用的模式可能在小规模上没有什么区别,但部署后,你会后悔你所做的每一个错误决定。

主机也是一个重要的性能因素。你的处理器对于你特定的任务的表现有多好是很重要的。你有多少可用内存会影响你的数据有多少会驻留在一个快速的位置或者移动到一个较慢的位置,比如本地磁盘。

缓存你的数据也非常重要。使用某种中间存储加速数据访问的技术,给人一种更快速的感觉,创造了一个快速应用程序的重要幻觉。虽然这可能看起来不对,因为它看起来像是一个幻觉,但如果你想要将性能提升到极限,这实际上是非常重要的。

所有这些都很重要,但有一些限制是你无法突破的,或者至少有一些你应该了解以便绕过并选择更好的设计模式。其中一些限制超出了你的范围,你无法调整或控制它们。其他一些可以在有预算和/或时间的情况下最小化,并且想要选择这条道路。我建议你这样做,因为了解你的应用程序的周围环境将使你更全面地了解它的工作方式以及如何改进它。

主机限制

你托管应用程序的地方——服务器——有限制。主机上有两种类型的限制:硬件和软件。硬件限制可能很容易发现。你的应用程序可能会消耗所有内存并需要使用磁盘继续工作。通过升级主机的内存,无论是物理的还是虚拟的,似乎是正确的选择。

对于 Node.js 应用程序,你还有一个由 V8 强加的软件内存限制,所以在升级内存时不要忘记这一点。在 32 位环境中,限制大约为 3.5GB,我假设你在 64 位环境中升级内存。在这种情况下,你的应用程序默认会以 1GB 的 V8 限制运行。然后你需要以类似以下命令的方式启动你的应用程序来以更高的限制运行:

$ node --max_old_space_size 4000 application

这将以 4GB 内存限制运行application.js。这实际上是不推荐的。你可能选择了一个不适合任务的设计模式,你应该尝试将你的应用拆分成更小的服务。

当你无法控制你的生产环境时,可能会有其他限制,比如无法安装软件依赖项或升级库以修复安全或性能问题。如果你无法从头到尾控制环境,你就无法挑战其极限。

操作系统和数据库服务器通常都预设了适度使用的数值。这通常对于普通用户来说是可以的,但对于高级用户来说绝对不够。

一个简单的例子是每个进程的最大打开文件描述符数。套接字是一个文件描述符,如果你使用默认的 1024 限制,这意味着最多你可能会有 1000 个打开的客户端连接。我很慷慨;我说的是一个 Linux 机器。如果你看看 OS X,情况会更糟。

类似于这个限制,特别是看看 Linux,你可以查看其他明显影响你的应用程序的限制。查看手册,看看你可能想要调整的选项。以下是你可能在 Linux 系统中找到的限制和默认值的示例:

$ ulimit -a
core file size          (blocks, -c) 0
data seg size           (kbytes, -d) unlimited
scheduling priority             (-e) 0
file size               (blocks, -f) unlimited
pending signals                 (-i) 31692
max locked memory       (kbytes, -l) 64
max memory size         (kbytes, -m) unlimited
open files                      (-n) 1024
pipe size            (512 bytes, -p) 8
POSIX message queues     (bytes, -q) 819200
real-time priority              (-r) 0
stack size              (kbytes, -s) 8192
cpu time               (seconds, -t) unlimited
max user processes              (-u) 31692
virtual memory          (kbytes, -v) unlimited
file locks                      (-x) unlimited

还有其他方法和选项可以为你的应用程序进行更改和优化。我说的是内核参数。你可以查看它们并使用sysctl命令进行更改。

你可以调整诸如文件系统、网络时间和路由、虚拟内存行为以及内核本身,如处理器调度和对挂起任务的反应等领域。

这里有一个小列表,只显示了一小部分选项:

$ sysctl -a | tail
vm.overcommit_ratio = 50
vm.page-cluster = 3
vm.panic_on_oom = 0
vm.percpu_pagelist_fraction = 0
vm.scan_unevictable_pages = 0
vm.stat_interval = 1
vm.swappiness = 60
vm.user_reserve_kbytes = 131072
vm.vfs_cache_pressure = 100
vm.zone_reclaim_mode = 0

如前所述,不仅操作系统可能对你的用例进行糟糕的优化。服务通常带有一个简单的默认配置,不针对性能而设计。

MySQL 数据库服务器可能有一些奇怪的配置参数,比如innodb_flush_log_at_trx_commit,默认为1。这意味着每个事务都会触发一次刷新到磁盘(保存事务)。如果你每秒有 100 个事务,那么你的磁盘将会发热,并通过发出 100 次刷新来降低性能。

相反,你可能希望确保此配置为2,这意味着磁盘刷新最多每秒执行一次。这个配置不能确保 ACID(en.wikipedia.org/wiki/ACID)合规性,但我想你以后会感谢我的。性能是有代价的,在这种情况下,需要不间断的电源供应。

另一个你必须注意的配置是操作系统和应用程序中涉及的所有服务使用的内存。例如,以 MySQL 服务器为例,你必须确保它不会消耗所有内存,留一些给其他服务。这可以避免交换并确保其正常运行。

网络限制

网络现在是访问应用程序的事实上的传输方法。随着物联网变得更加现实,甚至常见的桌面应用程序,如办公生产工具,也在转移到云端。你可能从未开发过传统的桌面应用程序。

云应用程序相对于传统应用程序给你很多优势,比如以下:

  • 更容易的部署。由于应用程序位于一个或多个中心点,修复错误或向所有用户添加功能更加简单。

  • 许可证执行。由于应用程序没有安装在用户的计算机上,你可以阻止其使用或控制服务质量。

  • 适当的环境。因为你控制主机,你可以确保它有适当的处理器、足够的内存和磁盘空间来正常运行。

所有这些都是非常好的优点,但缺点呢?对于每个优势,通常都有一个劣势。它不是好或坏,只取决于你的偏好。以前的列表,我们可以列举出相应的对应项:

  • 部署必须小心进行,因为服务器包含敏感数据,也是使用你的应用程序的唯一途径。你接受 Gmail 离线 15 分钟吗?为了保证正确的部署,你需要适当的基础设施,数据重复以确保你可以从网络池中移除服务器,更新它们,然后重新部署它们。

  • 强制许可证意味着你保持服务在线,不接受停机。同样,你可能需要在用户使用应用程序时确保一个计费系统。这与常见的桌面应用程序相反,你只需支付一次,然后忘记它。

  • 适应多个环境的应用程序。支持所有主要浏览器供应商并不容易。随之而来的是用户的假设,即你的应用程序必须有一个移动友好的替代方案,通常在桌面版本中不存在。

有很多市场提供(免费和付费)可以将你的 Web 应用程序“转换”为桌面应用程序,如果你更喜欢不将你的应用程序转移到云端的优势。

现在应用程序更喜欢驻留在云端。它们的优势通常超过了桌面应用程序的优势,并且在优势中提到了一个重要的事情——许可证。云端给了你“作为服务”的机会,这通常在传统应用程序中是没有的。

随着云计算的发展,会带来大量的辛苦和麻烦。您需要注册自己的域名,支付专用或共享主机的费用,并部署您的应用程序。如果您正在开发一个大型应用程序并希望信守承诺,您需要更多:硬件、具有良好服务质量的网络连接、支持团队、备份计划等等。

无论您选择什么,都应该意识到存在一些限制。您可能知道它们,但这从未反映出来。您有诸如以下的限制:

  • 响应速度。当用户使用应用程序界面进行交互时,可能会感觉很慢,因为用户在使用界面时,界面正在从云端下载。如果您在用户的计算机上缓存界面,可以改善这种响应速度。缓存意味着有时用户可能会看到旧界面,但这可能并不像获得快速用户体验那样关键。有关此方面的标准,请参阅 HTML 标准的离线 Web 应用程序部分。

  • 数据访问,当用户与更加数据密集的界面进行交互时。有时,界面的缓慢与服务器从数据库中收集数据并通过网络发送有关。您也可以使用缓存,但您可能需要更加小心,因为界面缓存和数据缓存是两回事。人们可以容忍旧界面一两个小时,但不能容忍旧数据。

注意

安全性至关重要。为您的用户提供 HTTPS 访问,以便他们可以放心其隐私。

除了这些限制之外,还有安全问题会影响性能。例如,就隐私而言,您必须选择 HTTPS,这意味着需要一个良好的证书和良好的服务器配置,以避免使用较差的密码。这反过来意味着一些用户可能无法访问该应用程序,服务器和客户端之间的数据交换会变得稍慢一些。

如果您想确保从服务器传输到最终用户的数据不会被篡改,这是一个要求。然而,这其实还不够,因为用户还必须拥有最新的浏览器和良好的配置。最近发现了许多 SSL 漏洞,可以通过更新浏览器来避免。

网络并不是为了安全而设计的;它是基于每个人都怀有良好意图的假设而设计的,这显然是错误的。当用户使用公共热点(来自咖啡店、商场或机场)访问您的应用程序时,他们容易受到隐私问题的影响。攻击者可以嗅探网络流量,并尝试找到密码或附加到一个开放的会话中,并能够冒充用户。

获得安全连接很重要,但可能会降低性能,也可能降低每台服务器可以处理的用户数量。这可能是安全的代价。认为 HTTPS 总是更慢的吗?试试www.httpvshttps.com/

另外,不要忘记您的数据库。确保您没有使用默认密码,并且只允许应用程序访问(不要向互联网上的所有人开放访问)。

安全性并不仅止于此。由于您的应用程序是一个已知的网络位置,您可能会成为攻击的受害者。也许您认为将服务器放在防火墙后,并将流量重定向到用户所需的端口(如 HTTP 和 HTTPS)就足够了,但不要忘记拒绝服务DoS)攻击。一个拥有攻击网络的攻击者可以通过迫使应用程序忙于处理攻击而使其无法访问和使用,从而使真实用户无法访问和使用应用程序。这会给他们一种性能不佳的感觉,这是您无法避免的。

例如,GitHub 在 2015 年 3 月遭受了来自中国的攻击。持续了几天。他们无法避免,只能试图转移流量来减轻影响。一些人受到了严重影响。随着你的应用程序变得越来越大,可能会有更多的攻击者对你的信息感兴趣,或者只是想拒绝访问。

客户端限制

客户端也有限制。他们可能使用你不了解或不能确定的操作系统。这也适用于浏览器、安装的应用程序,甚至位置。

注意

永远不要相信浏览器发送的用户代理。也不要从中推断任何信息。它可以伪造成任何东西。一台笔记本电脑可以非常容易地模拟上个世纪的诺基亚手机——不需要任何黑客技术!

这是每个开发者必须遵守的规则:永远不要相信客户端。我并不是以一种贬义的方式说这个,但你必须确信你所拥有的信息。例如,你的界面在表单中有验证,并且在提交之前你确信它们验证正确,对吗?错!永远不要相信客户端。

此外,永远不要相信客户端和你之间的链接。在服务器端再次验证信息。如果可能的话,使用 Node.js,在双方使用相同的代码进行验证,并避免重复的代码。例如,你可以使用一些代码在 Web 视图中验证一个表单,这些代码也可以在服务器上使用。不要忘记!Node.js 是 JavaScript。如果它是一个复杂的代码或模块,你可能需要查看 browserify(browserify.org/)。

表单验证应该在双方进行,以给人一种性能感知,并实际上避免常见错误。你不应该在客户端验证所有东西,但至少要检查货币字段是否实际上是数字而不是文本,并确认所有必填字段是否有适当的值。这样可以减少向服务器提交的往返次数,以及服务器回复错误的次数。

除了应用程序的限制之外,还有一些你无法控制的外部限制。用户总是会责怪你,也许大部分时间并不是你的应用程序的错。你准备好应对来自蜂窝网络的客户端的间歇性连接了吗?我不是指 3G,因为这可能足够稳定。我指的是 GPRS 连接。

你是否有一个适用于没有超过 300 像素宽屏幕并且行为类似于我高中时期的 TI-83 的手机应用程序?你是否期望每个人都会使用具有巨大屏幕和比你的上网本更多处理器性能的最新手机?这里就是性能感知的地方。

一个庞大的应用程序可能会使一部性能较弱的手机崩溃,只是因为渲染界面。一个廉价的处理器将很难渲染所有元素并运行你的应用程序中的所有 JavaScript。它将很难在小屏幕上进行渲染。因此,最好为这种类型的屏幕拥有完全不同的界面,并且只是对较小的差异使用自适应界面。

用户接受不同的界面,因为他们实际上是以不同的方式与应用程序进行交互。他们可能在手机上使用手指,在平板电脑或笔记本电脑上使用鼠标或几根手指。此外,眼睛和屏幕之间的距离也不同,因此分辨率也不同。

因此,为了达到最佳性能,你应该提供一个更简单的界面。删除用户可能不需要的混乱信息,例如在手机上。只保留重要的操作。如果可能的话,缓存界面以获得更好的性能感知。看到一个旋转的圆圈要比看到一个没有进度信息的空白屏幕要好。

如今,网络给了你选择。你可以使用不同类型的设备和不同的系统和网络浏览器。这对用户来说是好事,但对开发者来说是可怕的。这是一种碎片化,迫使应用程序只针对少数目标进行开发,而不是全部市场。

你需要专注于应用程序的主要目标,并为其开发最佳界面。然后你可以将注意力转移到其他环境,比如手机和手表上的小屏幕。不要开发一个可以在所有屏幕上运行但在任何屏幕上都不是最佳的应用程序。

几年前,应用程序被复制到所有屏幕上,这实际上是愚蠢的。人们使用不同的设备来实现不同的目标。例如,人们不会想在手机上创建任务列表,但可能会想要查看并标记为完成。这意味着你可以建立一个更小的应用程序来满足用户的需求,避免过多的信息和减慢交互和降低体验的风险。

浏览器限制

浏览器供应商正在合并努力,使开发者的生活更轻松。几年前,为多个浏览器开发 Web 应用程序是一件苦差事。你通常会专注于其中一个或两个。如果你专注于更多,你的代码会变得更加复杂,性能会受到影响。通常,随着时间的推移和新的浏览器版本,应用程序会变得更慢。

现在,只在一个浏览器中开发应用程序更安全。大多数应用程序,如果不是全部——取决于你用于 DOM 的抽象(jQuery 是最好的例子)——都会在其他浏览器上正常运行。然后你可以进行一些改进,你的应用程序将在每个浏览器上顺畅运行。

保持这些抽象层的最新状态是重要的,以避免过时和更慢的代码。浏览器往往会更频繁地发布版本,并带来新的开发者接口,这些抽象可以利用。

浏览器限制

上面的截图是一个jsperf测试一些版本的 jQuery。这些版本实际上并不是最新的,但这并不重要。你可以看到,新版本的性能更好——并不总是如此,但通常是如此。你可以看到,在这个例子中,最旧版本的性能比最新版本差了 77%。

性能变量

性能应该被视为一系列选择和变量,你应该根据自己的需求进行调整。以下是一些你应该考虑的变量:

  • 选择最好或次好的平台。记住最好的可能不一定适合你。

  • 明确定义你的数据结构并明智选择你的数据库服务器。放眼未来,计划如何应对快速数据增长。

  • 规划你的应用程序模块,不要忘记对每个模块进行测试。创建一个可以复制的开发环境,以便新开发者可以更快地开始编程。

  • 选择一个目标环境并开始开发。不要为每个设备和浏览器开始开发。

总结

你的应用程序的性能不受你的代码和数据库选择的限制。有一些限制你必须意识到,以便为你的应用程序选择最佳路径。这些只是影响性能的应用程序的外部元素,但也有其他因素。

最重要的规则——你不应该忘记的是规划你的步骤。不要在没有好好考虑的情况下进行开发。一个糟糕的选择会在以后修复时让你的生活变得更加困难。花一个小时思考要比花一周修复要好。这实际上是你自己发展表现的一部分。

为 Bentham Chang 准备,Safari ID 为 [email protected] 用户编号:2843974 © 2015 Safari Books Online,LLC。此下载文件仅供个人使用,并受到服务条款的约束。任何其他用途均需版权所有者的事先书面同意。未经授权的使用、复制和/或分发严格禁止并违反适用法律。保留所有权利。

标签:NodeJS,可以,编程,应用程序,js,高性能,内存,测试,使用
From: https://www.cnblogs.com/apachecn/p/18208665

相关文章

  • Node-Red-实战编程-全-
    Node-Red实战编程(全)原文:zh.annas-archive.org/md5/C5AA5862C03AC3F75583D0632C740313译者:飞龙协议:CCBY-NC-SA4.0前言Node-RED是由Node.js制作的基于流的编程工具。这个工具主要用于连接物联网设备和软件应用程序。然而,它不仅可以涵盖物联网,还可以涵盖标准的Web应用......
  • nodejs + express + mysql + redis 基础功能实现
    nodejs+express+mysql+redis基础功能实现yeyue  9人赞同了该文章本文大体介绍了nodejs项目的创建、express框架的使用、mysql数据库的连接、以及redis的数据交互等方法,并举例了些简单的例子进行说明,代码都是亲自重头跑了一遍的,拿来可用。 一、......
  • 满足 5G 通信的带宽需求,1ST040EH2F35I2LG,1ST085ES3F50I3VG,1ST085ES3F50E3VG,1ST110E
    说明Stratix®10FPGA和SoCFPGA大幅提高了性能、功效、密度和系统集成度。Stratix10采用创新HyperflexFPGA架构,将嵌入式多芯片互连桥接器(EMIB)、高级接口总线(AIB)和芯片组等技术结合在一起。™因此,与上一代高性能FPGA相比,Stratix10器件的性能提高了2倍。Stratix®10......
  • 精通-JavaScript-高性能-全-
    精通JavaScript高性能(全)原文:zh.annas-archive.org/md5/582AFDEF15013377BB79AB8CEA3B2B47译者:飞龙协议:CCBY-NC-SA4.0序言欢迎来到精通JavaScript高性能。在这本书中,我们已经以帮助任何JavaScript开发者,无论他们是新手上路还是经验丰富的老手的方式,覆盖了JavaScrip......
  • PureBasic是一种基于BASIC语言的编程语言,它提供了一个简单易用的开发环境,旨在帮助开发
    PureBasic是一种基于BASIC语言的编程语言,它提供了一个简单易用的开发环境,旨在帮助开发人员快速创建跨平台的应用程序。PureBasic的特点如下:简单易学:PureBasic的语法类似于传统的BASIC语言,非常容易学习和理解,适合初学者入门。跨平台支持:PureBasic可以在多个操作系统上运行,包括W......
  • Nodejs Playwright 2Captcha 验证码识别实现自动登陆
    NodejsPlaywright2Captcha验证码识别实现自动登陆需求日常工作当中,为了提高工作效率,我们可能会写脚本来自动执行任务。有些网站因为需要用户登陆,所以脚本的自动登陆功能必不可少。不过我们在登陆网站的时候经常会出现验证码,验证码的目的就是为了防止机器登陆、自动化脚本操......
  • 1.说说你了解过、使用过什么编程语言?比较他们的优势 2.说说你了解过,使用过什么编程工
    1.我了解过、使用过的编程语言有C语言,Java,C++。比较:①C语言是一种通用的高级编程语言,具有高效性和直接接近计算机硬件的能力,适合编写操作系统和嵌入式系统等低级应用。但C语言缺乏面向对象的支持,对于大型项目的复杂性处理有些力不从心。②Java是一种跨平台的面向对象编程语言,......
  • C++高性能服务器框架—协程模块
    协程模块概述一、概念可以简单的认为:协程就是用户态的线程,但是上下文切换的时机是靠调用方(写代码的开发人员)自身去控制的;对比首先介绍一下为什么要使用协程。从了解进程,线程,协程之间的区别开始。从定义来看进程是资源分配和拥有的基本单位。进程通过内存映射拥有独立的代......
  • 【学习整理】编程知识点总结
    编程知识点总结查询接口实现查询接口示例查询排序@SortDefault查询参数配置@ApiIgnore@Param消息发送常量定义......
  • 如何全程使用docker部署jeecg平台,无需安装开发环境(主要是如何使用Docker来进行Maven打
    在部署jeecg平台时,文档中即使通过docker部署,也需要安装开发环境编译一部分内容,本文记录使用docker替代安装环境的过程。使用docker的目的是在平台选型的过程中,不希望麻烦的安装环境,同时如果选型不满意,无需卸载环境就能恢复一个干净的系统。部署环境:UbuntuServer20.04docker,......