PHP、MySQL-和-JavaScript-快速-Web-开发(全)
PHP、MySQL 和 JavaScript 快速 Web 开发(全)
原文:
zh.annas-archive.org/md5/cfad008c082876a608d45b61650bee20
译者:飞龙
前言
更快 Web 可以定义为在所有 Web 技术领域中发展的一系列特质,以加快客户端和服务器之间的任何交易。它还包括可以影响用户对速度感知的 UI 设计原则。因此,理解更快 Web 涉及理解性能、效率和感知性能的概念,并发现构成今天互联网的大部分新基础 Web 技术。
本书适合对象
任何希望更好地理解更快 Web 的 Web 开发人员、系统管理员或 Web 爱好者。基本的Docker容器技术知识是一个加分项。
本书涵盖内容
第一章,更快 Web-入门,通过试图更好地理解其正式方面来定义更快 Web,并着手了解如何衡量性能,确定网站或 Web 应用是否属于更快 Web。
第二章,持续性能分析和监控,旨在帮助读者学习如何安装和配置性能分析和监控工具,以帮助他们在持续集成(CI)和持续部署(CD)环境中轻松优化 PHP 代码。
第三章,利用 PHP 7 数据结构和函数的性能,帮助读者学习如何通过大部分关键优化来利用 PHP 7 的性能提升。它还帮助他们探索更好地理解数据结构和数据类型,以及使用简化的函数如何帮助 PHP 应用程序在其关键执行路径上的全局性能。此外,它介绍了在我们的 PHP 代码中最好避免使用低效结构(如大多数动态结构),以及在优化 PHP 代码时一些功能技术如何立即帮助。
第四章,异步 PHP 展望未来,概述了如何通过学习生成器和异步非阻塞代码、使用POSIX Threads(pthreads
)库进行多线程以及使用ReactPHP
库进行多任务处理来应对输入和输出(I/O)的低延迟。
第五章,测量和优化数据库性能,展示了如何测量数据库性能,从简单的测量技术到高级的基准测试工具。
第六章,高效查询现代 SQL 数据库,解释了如何使用现代 SQL 技术来优化复杂的 SQL 查询。
第七章,JavaScript 和危险驱动开发,涵盖了 JavaScript 的一些优点和缺点,特别是与代码效率和整体性能有关的部分,以及开发人员应该如何编写安全、可靠和高效的 JavaScript 代码,主要是通过避免“危险驱动开发”。
第八章,函数式 JavaScript,介绍了 JavaScript 如何越来越成为一种函数式语言,以及这种编程范式将成为未来性能的一个向量,通过快速查看将帮助改进 JavaScript 应用程序性能的即将推出的语言特性。
第九章,提升 Web 服务器性能,介绍了 HTTP/2 协议的相关内容,以及 SPDY 项目是如何实现的,PHP-FPM 和 OPcache 如何帮助提升 PHP 脚本的性能,如何通过设置 Varnish Cache 服务器来使用 ESI 技术,如何使用客户端缓存以及其他更快 Web 工具如何帮助提升 Web 服务器的整体性能。
第十章,超越性能,展示了当一切似乎已经完全优化时,通过更好地理解 UI 设计背后的原则,我们仍然可以超越性能。
为了充分利用本书
为了运行本书中包含的源代码,我们建议您首先在计算机上安装 Docker(docs.docker.com/engine/installation/
)。Docker是一个软件容器平台,允许您在隔离和复杂的 chroot-like 环境中轻松连接到计算机的设备。与虚拟机不同,容器不会捆绑完整的操作系统,而只会捆绑运行某些软件所需的二进制文件。您可以在 Windows、Mac 或 Linux 上安装Docker。但是需要注意的是,在 macOS 上运行Docker时,一些功能,如全功能网络,仍然不可用(docs.docker.com/docker-for-mac/networking/#known-limitations-use-cases-and-workarounds
)。
本书中我们将使用的主要Docker镜像是Linux for PHP 8.1(linuxforphp.net/
),其中包含 PHP 7.1.16 的非线程安全版本和MariaDB(MySQL)10.2.8(asclinux/linuxforphp-8.1:7.1.16-nts)。要启动主容器,请输入以下命令:
# docker run --rm -it \
> -v ${PWD}/:/srv/fasterweb \
> -p 8181:80 \
> asclinux/linuxforphp-8.1:7.1.16-nts \
> /bin/bash
如果您喜欢在优化代码的同时使用多线程技术,可以运行Linux for PHP的线程安全版本(asclinux/linuxforphp-8.1:7.0.29-zts)。
此外,您应该docker commit
任何对容器所做的更改,并创建容器的新镜像,以便以后可以docker run
。如果您不熟悉 Docker 命令行及其run
命令,请查看文档docs.docker.com/engine/reference/run/
。
最后,每当您启动原始的 Linux for PHP 镜像并希望开始使用本书中包含的大多数代码示例时,必须在 Linux for PHP 容器内运行以下三个命令:
# /etc/init.d/mysql start
# /etc/init.d/php-fpm start
# /etc/init.d/httpd start
下载示例代码文件
您可以从www.packtpub.com的账户中下载本书的示例代码文件。如果您在其他地方购买了本书,可以访问www.packtpub.com/support注册并直接将文件发送到您的邮箱。
您可以按照以下步骤下载代码文件:
-
请在www.packtpub.com登录或注册
-
选择“支持”选项卡
-
点击“代码下载和勘误”
-
在搜索框中输入书名并按照屏幕上的说明进行操作
文件下载后,请确保使用最新版本的解压缩或提取文件夹:
-
Windows 的 WinRAR/7-Zip
-
Mac 的 Zipeg/iZip/UnRarX
-
Linux 的 7-Zip/PeaZip
该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Mastering-the-Faster-Web-with-PHP-MySQL-and-JavaScript
。如果代码有更新,将在现有的 GitHub 存储库中更新。
本书中提供的所有代码示例都可以在代码存储库中的以章节编号命名的文件夹中找到。因此,预计您在每章开始时更改工作目录,以便运行其中给出的代码示例。因此,对于第一章,您预计在容器的 CLI 上输入以下命令:
# mv /srv/www /srv/www.OLD
# ln -s /srv/fasterweb/chapter_1 /srv/www
接下来的章节,您预计输入以下命令:
# rm /srv/www
# ln -s /srv/fasterweb/chapter_2 /srv/www
接下来的章节也是如此。
我们还有其他代码包来自我们丰富的图书和视频目录,可在github.com/PacktPublishing/
上找到。去看看吧!
使用的约定
本书中使用了许多文本约定。
CodeInText
:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。这是一个例子:“在可能的情况下,开发人员应始终优先使用 const
而不是 let
或 var
。”
代码块设置如下:
function myJS()
{
function add(n1, n2)
{
let number1 = Number(n1);
let number2 = Number(n2);
return number1 + number2;
}
}
任何命令行输入或输出都以以下方式编写:
# php parallel-download.php
粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词会以这种方式出现在文本中。这是一个例子:“如果您向页面的末尾滚动,现在应该看到一个 xdebug 部分。”
警告或重要提示会以这种方式出现。技巧和窍门会以这种方式出现。
第一章:更快速的网络-入门
更快速的网络是一个已经存在几年的表达,用来指代网络性能的许多不同方面。在本书中,我们将更仔细地看看它是什么。为什么它重要?它和性能是一样的吗?我们如何测量它?在开发新项目时何时应该开始考虑它?底层技术是什么,我们如何利用这些技术的力量,使我们的网络项目成为更快速的网络的一部分?
在本章中,我们将首先定义更快速的网络是什么,并尝试更好地了解其正式方面。
此外,在整本书中,我们将提供许多代码示例,以帮助我们更好地理解更快速的网络背后的概念。我们将花时间回顾其起源,评估其当前发展,并展望未来,以了解其下一个重要的里程碑。
目前,我们将从在Docker容器中安装基准测试和分析工具开始,以便学会如何使用它们。此外,我们将花时间了解如何测量性能,并确定网站或 Web 应用程序是否属于更快速的网络。
因此,本章将涵盖以下几点:
-
了解更快速的网络是什么,以及为什么它很重要
-
学会区分更快速的网络和性能
-
知道如何测量更快速的网络
-
安装、配置和使用基准测试和分析工具
什么是更快速的网络?
2009 年,谷歌宣布其意图使网络更快[1],并启动了相应的倡议,邀请网络社区想出使互联网更快的方法。宣布称“人们更喜欢更快速、更具响应性的应用程序”,这是谷歌倡议的主要原因。该公告还包括谷歌确定的许多挑战的清单,这些挑战被视为该倡议的首要任务。主要挑战包括:
-
更新老化的协议
-
解决 JavaScript 性能不足的问题
-
寻找新的测量、诊断和优化工具
-
为全球范围内提供更多宽带安装的机会
更快速的网络和性能
更快速的网络可以被定义为在所有网络技术领域中发展的一系列特质,以加快客户端和服务器之间的任何交易速度。
但速度有多重要?谷歌在 2010 年发现,任何减速都会直接影响公司的网站流量和广告收入。事实上,谷歌成功地建立了流量和广告收入与结果数量和获取结果所需时间之间的统计相关性。他们的研究结果表明,当在 0.9 秒内获得更多结果与在页面上仅在 0.4 秒内获得更少结果时,流量和广告收入可能会减少 20%。雅虎也证实,约 5%至 9%的用户会放弃加载时间超过 400 毫秒的网页。微软必应在搜索结果交付时额外延迟 2 秒时,收入减少了 4%。显然,速度不仅确保用户参与度,而且对公司的收入和整体表现都有重大影响。
乍一看,更快速的网络似乎与网络性能完全相同。但真的是这样吗?
性能被定义为机制的执行方式。根据André B. Bondi[2]的说法,"计算机系统的性能通常以其以快速速率执行定义的一组活动的能力和快速响应时间来表征。" 正如J. D. Meier 等人在他们关于性能测试的书中所述,"性能测试是一种旨在确定系统在给定工作负载下的响应性、吞吐量、可靠性和/或可扩展性的测试类型*。"
因此,很明显,网站性能是更快网络的核心概念。但是,我们总是期望这些特征是唯一的吗?如果一个应用程序承诺对硬盘进行彻底分析并在不到五秒的时间内完成任务,我们肯定会认为出了问题。根据Denys Mishunov[4]的说法,性能也与感知有关。正如Stéphanie Walter[5]在她关于感知性能的演讲中所述,"时间的测量取决于测量的时刻,可以根据要执行的任务的复杂性、用户的心理状态(压力)以及用户根据他认为是执行某项任务时的参考软件所定义的期望而变化。" 因此,应用程序执行任务的良好方式也意味着软件必须满足用户对计算机程序应该如何执行任务的期望。
尽管更快的网络倡议最初集中精力使不同的网络技术变得更快,但不同的研究使研究人员重新回到了主观时间或感知时间与客观时间或计时时间的概念,以便充分衡量网站性能如何影响用户在浏览网页时的习惯和一般行为。
因此,在本书中,我们将涵盖更快的网络,因为它适用于所有主要的网络技术,也就是说,在全球 70%至 80%的网络服务器上运行的技术以及所有主要的浏览器,即 Apache、PHP、MySQL 和 JavaScript。此外,我们不仅将从开发人员的角度讨论这些主要的网络技术,还将在最后几章中从系统管理员的角度讨论更快的网络,包括 HTTP/2 和反向代理缓存。尽管本书的大部分内容将只涉及网站性能的问题,但最后一章将涵盖更快网络的另一个方面,即通过良好的用户界面(UI)设计来满足用户的期望。
测量更快的网络
现在我们更好地理解了网站性能如何成为更快网络作为整体的一个非常重要部分,更快网络不仅关注效率和速度,还关注完全满足用户的期望,我们现在可以问自己如何客观地衡量更快的网络以及哪些工具最适合这样做。
在测量之前
在讨论速度测量时,始终重要的是要记住速度最终取决于硬件,如果在性能不佳的硬件基础设施上运行性能不佳的软件并不一定是问题。
当然,输入和输出(I/O)始终占据硬件基础设施总延迟的大部分。网络和文件系统是可能出现最糟糕性能的两个主要瓶颈,例如,访问磁盘上的数据可能比随机存取内存(RAM)慢上百倍,而繁忙的网络可能使网络服务几乎无法访问。
RAM 限制也迫使我们在速度、可伸缩性和准确性方面做出某些权衡。通过缓存应用程序数据的大部分并将所有内容加载到内存中,总是可以获得最高速度的性能。但在所有情况下,这是否是最佳解决方案?在重负载情况下,它是否仍然保持速度?在高度不稳定的数据情况下,数据是否得到了充分的刷新?对这些问题的明显答案可能是否定的。因此,最佳速度是纯速度、合理的内存消耗和可接受的数据陈旧之间的平衡。
为了确定计算机程序的最佳速度而进行性能测量,是通过实施适当的权衡并在之后进行微调来在特定业务规则和可用资源的情况下找到完美平衡的艺术。
因此,评估速度性能的第一步将是分析可用资源,并确定硬件速度性能的上限和下限。由于我们正在处理 Web 性能,这一步将通过对 Web 服务器本身进行基准测试来完成。
第二步将包括对 Web 应用程序进行分析,以分析其内部工作的每个部分的性能,并确定应用程序代码的哪些部分缺乏完美的平衡并应进行优化。
基准测试和分析
Web 服务器基准测试是评估 Web 服务器在特定工作负载下的性能的过程。软件分析是分析计算机程序在内存使用和执行时间方面的过程,以优化程序的内部结构。
在本章的这一部分,我们将设置和测试一些工具,这些工具将允许我们对我们的 Web 服务器进行基准测试和对我们将在本书的后续章节中分析的源代码进行分析。
实际先决条件
为了运行本书中包含的源代码,我们建议您首先在计算机上安装 Docker(docs.docker.com/engine/installation/
)。 Docker 是一个软件容器平台,允许您在隔离和复杂的 chroot-like 环境中轻松连接到计算机的设备。与虚拟机不同,容器不附带完整的操作系统,而是附带所需的二进制文件以运行某些软件。您可以在 Windows、Mac 或 Linux 上安装 Docker。然而,需要注意的是,在 macOS 上运行 Docker 时,一些功能,如全功能网络,仍然不可用(docs.docker.com/docker-for-mac/networking/#known-limitations-use-cases-and-workarounds
)。
我们将在本书中使用的主要 Docker 镜像是Linux for PHP 8.1(linuxforphp.net/
),其中包含 PHP 7.1.16 的非线程安全版本和MariaDB(MySQL)10.2.8(asclinux/linuxforphp-8.1:7.1.16-nts)。一旦在您的计算机上安装了 Docker,请在类似 bash 的终端中运行以下命令,以获取本书代码示例的副本并启动适当的 Docker 容器:
# git clone https://github.com/andrewscaya/fasterweb
# cd fasterweb
# docker run --rm -it \
-v ${PWD}/:/srv/fasterweb \
-p 8181:80 \
asclinux/linuxforphp-8.1:7.1.16-nts \
/bin/bash
运行这些命令后,您应该会得到以下命令提示符:
Linux for PHP 容器的命令行界面(CLI)Windows 用户请注意:请确保在以前的 Docker 命令中的共享卷选项中用您的工作目录的完整路径(例如'/c/Users/fasterweb')替换'${PWD}'部分,否则您将无法启动容器。此外,您应该确保在 Docker 设置中启用了卷共享。此外,如果您在 Windows 7 或 8 上运行 Docker,您只能在地址 http://192.168.99.100:8181 访问容器,而不能在'localhost:8181'上访问。
本书中提供的所有代码示例都可以在代码存储库中的一个名为根据章节编号命名的文件夹中找到。因此,预计您在每章开始时更改工作目录,以便运行其中给出的代码示例。因此,对于本章,您应该在容器的 CLI 上输入以下命令:
# mv /srv/www /srv/www.OLD
# ln -s /srv/fasterweb/chapter_1 /srv/www
对于下一章,您应该输入以下命令:
# rm /srv/www
# ln -s /srv/fasterweb/chapter_2 /srv/www
接下来的章节也是如此。
此外,如果您在优化代码时更喜欢使用多线程技术,可以通过运行Linux for PHP的线程安全版本(asclinux/linuxforphp-8.1:7.0.29-zts)来实现。
如果您希望以分离模式(-d
开关)运行容器,请这样做。这将允许您在同一个容器上保持运行并且独立于您是否有运行的终端而运行docker exec
多个命令 shell。
此外,您应该docker commit
您对容器所做的任何更改,并创建其新图像,以便以后可以docker run
它。如果您不熟悉 Docker 命令行及其run
命令,请在以下地址找到文档:docs.docker.com/engine/reference/run/
。
最后,Packt Publishing 出版了许多关于 Docker 的优秀书籍和视频,我强烈建议您阅读它们以掌握这个优秀的工具。
现在,输入以下命令以启动本书中将需要的所有服务,并创建一个测试脚本,以确保一切都按预期工作:
# cd /srv/www
# /etc/init.d/mysql start
# /etc/init.d/php-fpm start
# /etc/init.d/httpd start
# touch /srv/www/index.php
# echo -e "<?php phpinfo();" > /srv/www/index.php
当您完成这些命令后,您应该将您喜欢的浏览器指向http://localhost:8181/
,并查看以下结果:
phpinfo 页面
如果您没有看到此页面,请尝试排除您的 Docker 安装问题。
此外,请注意,如果您不docker commit
您的更改,并且希望在开始使用本书中包含的代码示例时使用原始的 Linux for PHP 基础镜像,那么以前的命令将需要每次都重复。
我们现在准备对我们的服务器进行基准测试。
了解 Apache Bench(AB)
有许多工具可用于对 Web 服务器进行基准测试。其中较为知名的是 Apache Bench(AB)、Siege、JMeter 和 Tsung。尽管 JMeter(jmeter.apache.org/
)和 Tsung(tsung.erlang-projects.org/
)是非常有趣的负载测试工具,并且在进行更高级别的系统管理上的测试时应该进行探索,但我们将专注于 AB 和 Siege 以满足我们的开发需求。
AB 包含在 Apache Web 服务器的开发工具中,并且默认安装在包含 PHP 二进制文件的 Linux for PHP 镜像中。否则,AB 可以在大多数 Linux 发行版的单独 Apache 开发工具安装包中找到。重要的是要注意,Apache Bench 不支持多线程,这可能会在运行高并发测试时造成问题。
此外,在进行基准测试时有一些常见的陷阱需要避免。主要的是:
-
避免同时在正在进行基准测试的计算机上运行其他资源密集型应用程序
-
避免对远程服务器进行基准测试,因为网络,特别是在并发测试中,可能成为测得的延迟的主要原因
-
避免在通过 HTTP 加速器或代理缓存的网页上进行测试,因为结果将会被扭曲,并且不会显示实际的服务器速度性能
-
不要认为基准测试和负载测试会完美地代表用户与服务器的交互,因为结果只是指示性的
-
请注意,基准测试结果是针对正在测试的硬件架构的,并且会因计算机而异
对于我们的测试,我们将使用 Apache Bench 的 -k
、-l
、-c
和 -n
开关。以下是这些开关的定义:
-
-k 启用 KeepAlive 功能,以便在一个单一的 HTTP 会话中执行多个请求
-
-l 当内容长度从一个响应到另一个响应的大小不同时,禁用错误报告
-
-c 启用并发,以便同时执行多个请求
-
-n 确定当前基准测试会话中要执行的请求数
有关 AB 选项的更多信息,请参阅 Apache 文档中的相应条目 (httpd.apache.org/docs/2.4/programs/ab.html
)。
在启动基准测试之前,打开一个新的终端窗口,并通过 docker exec
运行一个新的 bash 终端到容器中。这样,您将能够通过 top 实用程序查看资源消耗。首先,获取容器的名称。它将出现在此命令返回的列表中:
# docker ps
然后,您将能够进入容器并开始使用以下命令观察资源消耗:
# docker exec -it [name_of_your_container_here] /bin/bash
并且在容器的新获得的命令行上,请运行 top
命令:
# top
现在,从第一个终端窗口启动一个基准测试:
# ab -k -l -c 2 -n 2000 localhost/index.html
然后,您将获得一个基准测试报告,其中包含服务器能够响应的平均请求数 (每秒请求数
)、每个请求的平均响应时间 (每个请求的时间
) 和响应时间的标准偏差 (在特定时间内服务的请求的百分比 (ms)
) 的信息。
报告应该类似于以下内容:
基准测试报告显示,Apache 平均每秒提供约 817 个请求
现在,通过请求 index.php
文件来尝试新的基准测试:
# ab -k -l -c 2 -n 2000 localhost/index.php
您会注意到每秒平均请求数已经下降,平均响应时间和标准偏差更高。在我的情况下,平均值从大约 800 下降到我的计算机上的约 300,平均响应时间从 2 毫秒增加到 6 毫秒,响应时间的标准偏差现在从 100% 的请求在 8 毫秒内被服务,增加到 24 毫秒:
基准测试报告显示,Apache 平均每秒大约提供 313 个请求
这些结果使我们能够对硬件性能限制有一个大致的了解,并确定在扩展生成一些动态内容的 PHP 脚本性能时,我们将不得不处理的不同阈值。
现在,让我们通过 Siege 进一步深入了解我们的 Web 服务器性能,这是基准测试和负载测试时的首选工具。
了解 Siege
Siege 是一个负载测试和基准测试工具,它允许我们进一步分析我们的 Web 服务器性能。让我们开始在 Docker 容器中安装 Siege。
请从容器的命令行下载并解压 Siege 的 4.0.2 版本:
# wget -O siege-4.0.2.tar.gz http://download.joedog.org/siege/siege-4.0.2.tar.gz
# tar -xzvf siege-4.0.2.tar.gz
然后,请进入 Siege 的源代码目录以编译和安装软件:
# cd siege-4.0.2
# ./configure
# make
# make install
对于这些 Siege 测试,我们将使用-b
,-c
和-r
开关。以下是这些开关的定义:
-
-b,启用基准测试模式,这意味着迭代之间没有延迟
-
-c
,启用并发以同时执行多个请求 -
-r
,确定每个并发用户执行的请求数
当然,您可以通过在容器的命令行中调用手册来获取有关 Siege 命令行选项的更多信息:
# man siege
现在启动 Siege 基准测试:
# siege -b -c 3000 -r 100 localhost/index.html
然后您将获得类似这样的基准测试报告:
Siege 基准测试报告确认了从 AB 获得的结果
如您所见,结果与我们之前从 AB 获得的结果相匹配。我们的测试显示每秒近 800 次的事务率。
Siege 还配备了一个方便的工具,名为 Bombard,可以自动化测试并帮助验证可伸缩性。Bombard 允许您使用 Siege 和不断增加的并发用户数量。它可以带有一些可选参数。这些参数是:包含在执行测试时使用的 URL 的文件的名称,初始并发客户端的数量,每次调用 Siege 时要添加的并发客户端的数量,Bombard 应该调用 Siege 的次数以及每个请求之间的时间延迟(以秒为单位)。
因此,我们可以尝试通过在容器内部发出以下命令来确认我们之前测试的结果:
# cd /srv/www
# touch urlfile.txt
# for i in {1..4}; do echo "http://localhost/index.html" >> urlfile.txt ; done
# bombardment urlfile.txt 10 100 4 0
完成后,您应该获得类似以下的报告:
结果显示,当有 210 个或更多并发用户时,最长的事务要高得多
再试一次,但请求 PHP 文件:
# echo "http://localhost/index.php" > urlfile.txt
# for i in {1..3}; do echo "http://localhost/index.php" >> urlfile.txt ; done
# bombardment urlfile.txt 10 100 4 0
这个测试应该提供类似这样的结果:
提供动态内容的效率类似于提供静态内容的效率,但事务率要低得多
现在运行top
的第二个终端窗口显示了两个可用处理器的 50%使用率和我电脑上几乎 50%的 RAM 使用率:
容器在提交基准测试时使用的 CPU 和内存资源
我们现在知道,当并发请求不多时,这台硬件可以在小规模上表现良好,静态文件可以达到每秒 800 次的事务率,动态生成内容的页面大约为每秒 200 次的事务率。
现在,我们对基于硬件资源的基本速度性能有了更好的了解,现在我们可以开始真正测量通过性能分析来衡量 Web 服务器动态生成内容的速度和效率。我们现在将继续安装和配置工具,以便我们对 PHP 代码进行性能分析和优化。
安装和配置有用的工具
现在我们将安装和配置 MySQL 基准测试和 JavaScript 性能分析工具。但首先,让我们从安装和配置 xdebug 开始,这是一个 PHP 调试器和性能分析工具。
性能分析 PHP – xdebug 安装和配置
我们将安装和配置的第一个工具是 xdebug,这是一个用于 PHP 的调试和性能分析工具。这个扩展可以通过使用 PHP 附带的 PECL 实用程序(pecl.php.net/
)以非常简单的方式下载、解压缩、配置、编译和安装。要做到这一点,请在容器的终端窗口中输入以下命令:
# pecl install xdebug
# echo -e "zend_extension=$( php -i | grep extensions | awk '{print $3}' )/xdebug.so\n" >> /etc/php.ini
# echo -e "xdebug.remote_enable = 1\n" >> /etc/php.ini
# echo -e "xdebug.remote_enable_trigger = 1\n" >> /etc/php.ini
# echo -e "xdebug.remote_connect_back = 1\n" >> /etc/php.ini
# echo -e "xdebug.idekey = PHPSTORM\n" >> /etc/php.ini
# echo -e "xdebug.profiler_enable = 1\n" >> /etc/php.ini
# echo -e "xdebug.profiler_enable_trigger = 1\n" >> /etc/php.ini
# /etc/init.d/php-fpm restart
# tail -50 /etc/php.ini
您容器的/etc/php.ini
文件的最后几行现在应该是这样的:
在 php.ini 文件中新增的行
完成后,请在您喜爱的浏览器中重新加载http://localhost:8181
页面。它现在应该显示如下:
确认 xdebug 扩展已加载
如果您向页面朝向滚动,现在应该会看到 xdebug 部分:
phpinfo 页面的 xdebug 部分
您还应该注意,在 xdebug 条目下现在启用了性能分析器选项:
确认 xdebug 代码分析已启用
我们现在将配置 PHPStorm 作为调试服务器。这将允许我们将 IDE 用作调试会话的控制中心。
在开始之前,我们将通过在容器内输入以下命令将整个fasterweb
文件夹作为服务器的网站根目录可用:
# rm /srv/www
# ln -s /srv/fasterweb /srv/www
# cd /srv/www
现在,启动PHPStorm,并将我们的fasterweb
目录设置为此项目的主目录。为此,请选择从现有文件创建新项目,源文件位于本地目录,并在单击完成之前将我们的fasterweb
目录指定为项目根目录。
创建后,从“文件”菜单中选择“设置”。在“语言和框架”部分下,展开 PHP 菜单条目,然后单击“服务器”条目。请根据您的设置的具体情况输入所有适当的信息。主机选项必须包含 Linux 的 PHP 容器的 IP 地址值。如果您不确定 Docker 容器的 IP 地址是什么,请在容器的命令行上输入以下命令以获取它:
# ifconfig
完成后,您可以通过单击“应用”和“确定”按钮进行确认:
配置 PHPStorm 以连接到 Web 服务器和 xdebug
然后,在“运行”菜单下,您将找到“编辑配置...”条目。它也可以在 IDE 屏幕的右侧找到:
“编辑配置...”设置
然后,通过单击窗口左上角的绿色加号添加 PHP 远程调试条目。请选择我们在上一步中创建的服务器,并确保将 Ide 密钥(会话 ID)设置为 PHPSTORM:
配置调试会话
现在,通过单击主 PHPStorm 屏幕右上角菜单中的“监听调试器连接”按钮来激活 PHPStorm 调试服务器,通过单击index.php
文件的任何行号右侧的空白处设置断点,并启动我们在上一步中创建的index.php
配置对应的调试工具。
如果您的屏幕上没有显示右上方的工具栏菜单,请单击“查看”菜单的“工具栏”条目,以使它们显示在您的屏幕上。这些按钮也可以作为“运行”菜单中的条目进行访问。
激活 PHPStorm 调试服务器,设置断点并启动调试工具
现在,打开您喜欢的浏览器,并通过输入 Docker 容器的 IP 地址请求相同的网页:http://[IP_ADDRESS]/?XDEBUG_SESSION_START=PHPSTORM
。
然后您会注意到浏览器陷入了无限循环:
浏览器正在等待调试会话恢复或结束
您还会注意到调试信息现在显示在 IDE 中。我们还可以在 IDE 内控制会话,并确定何时会话将从中恢复。请在允许执行恢复之前检查变量的内容,方法是单击屏幕左侧的绿色播放按钮。您还可以通过单击同一图标菜单中的粉红色停止按钮来结束调试会话:
调试会话允许在运行时详细检查变量
调试会话结束后,我们现在可以检查容器的/tmp
目录,并应该在名为cachegrind.out
的文件中找到分析器输出。然后,您可以通过您喜欢的文本编辑器直接检查此文件,或者通过安装专门的软件,如您的 Linux 发行版的软件包管理器中的 Kcachegrind 来检查此文件。以下是使用 Kcachegrind 时的示例输出:
使用 Kcachegrind 查看 xdebug 分析报告
因此,如果您希望在我们将在接下来的章节中使用的工具之上使用 xdebug 的分析工具,它将对您可用。话虽如此,在下一章中,我们将研究更高级的分析工具,如Blackfire.io
。
在测试 xdebug 完成后,您可以将chapter_1
文件夹恢复为服务器的网站根目录:
# rm /srv/www
# ln -s /srv/fasterweb/chapter_1 /srv/www
# cd /srv/www
现在,让我们继续看一下 SQL 速度测试工具。
SQL – 速度测试
尽管 PostgreSQL 服务器通常被认为是继Oracle Database之后世界上最快的 RDBMS,但MariaDB(MySQL的分支)服务器仍然是最快和最受欢迎的 RDBMS 之一,特别是在处理简单的 SQL 查询时。因此,在本书中讨论 SQL 优化时,我们将主要使用MariaDB。
为了对我们的MariaDB服务器进行基准测试,我们将使用自MySQL服务器 5.1.4 版本以来包含的mysqlslap
实用程序。为了运行测试,我们将首先加载Sakila
测试数据库。在容器的命令行上,输入以下命令:
# wget -O sakila-db.tar.gz \
> https://downloads.mysql.com/docs/sakila-db.tar.gz
# tar -xzvf sakila-db.tar.gz
# mysql -uroot < sakila-db/sakila-schema.sql
# mysql -uroot < sakila-db/sakila-data.sql
数据库加载完成后,您可以启动第一个基准测试:
# mysqlslap --user=root --host=localhost --concurrency=20 --number-of-queries=1000 --create-schema=sakila --query="SELECT * FROM film;" --delimiter=";" --verbose --iterations=2 --debug-info
然后,您应该获得类似于这样的结果:
使用 mysqlslap 工具对 MariaDB 服务器进行基准测试
然后,您可以运行第二个基准测试,但使用不同的并发级别来比较结果:
# mysqlslap --user=root --host=localhost --concurrency=50 --number-of-queries=1000 --create-schema=sakila --query="SELECT * FROM film;" --delimiter=";" --verbose --iterations=2 --debug-info
以下是第二次测试的结果:
使用更高的并发性对 MariaDB 服务器进行基准测试
我的测试结果表明,对于具有大约 1,000 条记录的表的全表扫描查询,在向服务器发送 50 个或更多并发查询时,性能会急剧下降。
我们将看到这些类型的测试以及许多其他更高级的测试在专门讨论此主题的章节中将特别有用。
JavaScript – 开发者工具
为了衡量性能并分析本书中包含的 JavaScript 代码,我们将使用 Google Chrome 内置的开发者工具。具体来说,Chrome 包括时间线记录器和 JavaScript CPU 分析器,这将允许您识别 JavaScript 代码中的瓶颈。要激活这些工具,请单击浏览器右上角的三个点,然后单击“更多工具”子菜单中的“开发者工具”,如下所示:
在 Chrome 的主菜单的“更多工具”部分中找到“开发者工具”条目
使用分析工具就像点击记录按钮并刷新要分析的页面一样简单。然后,您可以分析结果以识别代码中的潜在问题:
Chrome 的时间线记录器和 JavaScript CPU 分析器
在第七章中,JavaScript 和“危险驱动开发”,以及第八章中,函数式 JavaScript,我们将更广泛地使用此工具,以便全面衡量和优化 JavaScript 代码的性能。
摘要
在本章中,我们定义了更快的 Web 是什么,为什么它很重要,它如何与纯速度性能区分开来,以及如何安装、配置和使用基准测试和分析工具来衡量它。
在下一章中,我们将了解使用Blackfire.io
进行自动分析。此外,我们将通过在一个虚构的生产服务器上安装和配置 TICK 堆栈与 Grafana 来学习监控,该服务器将部署为另一个 Docker 容器。
参考文献
[1] googleblog.blogspot.ca/2009/06/lets-make-web-faster.html
[2] BONDI, André B. Foundations of Software and System Performance Engineering: Process, Performance Modeling, Requirements, Testing, Scalability, and Practice. Upper Saddle River, NJ: Addison-Wesley, 2015.
[3] MEIER, J. D. et al. Performance Testing Guidance for Web Applications. Redmond, WA: Microsoft Corporation, 2007.
[4] www.smashingmagazine.com/2015/11/why-performance-matters-part-2-perception-management/
第二章:持续分析和监控
在本章中,我们将学习如何安装和配置分析和监控工具,这将帮助您在持续集成(CI)和持续部署(CD)环境中轻松优化 PHP 代码。
我们将从安装和配置基本的Blackfire.io
设置开始,以便在提交到存储库时轻松自动地对代码进行分析。我们还将学习如何安装 TICK Stack,以便在将代码部署到实时生产服务器后持续监视我们代码的性能。
因此,在本章中,我们将涵盖以下几点:
-
安装和配置
Blackfire.io
代理,客户端和 PHP 扩展 -
将
Blackfire.io
客户端与 Google Chrome 集成 -
将
Blackfire.io
客户端集成到像 Travis 这样的已知 CI 工具 -
安装和配置完整的 TICK Stack 与 Grafana
什么是 Blackfire.io?
正如官方 Blackfire 网站所述(blackfire.io
),Blackfire 赋予所有开发人员和 IT/Ops 持续验证和改进其应用程序性能的能力,通过在适当的时刻获取正确的信息。因此,它是一种性能管理解决方案,允许您在整个应用程序生命周期中自动对代码进行分析,并通过断言设置性能标准,特别是在开发阶段。Blackfire.io
是一种工具,使 Fabien Potencier 所说的性能作为特性成为可能,通过使性能测试成为项目从一开始就开发周期的一部分。
安装和配置 Blackfire.io
安装和配置Blackfire.io
意味着设置三个组件:代理,客户端和 PHP 探针。在本书的背景下,我们将在 Linux 的 PHP 容器中安装Blackfire.io
。要获取有关在其他操作系统上安装Blackfire.io
的更多信息,请参阅以下说明:blackfire.io/docs/up-and-running/installation
。
我们将从安装 Blackfire 代理开始。在容器的命令行界面上,输入以下命令:
# rm /srv/www
# ln -s /srv/fasterweb/chapter_2 /srv/www
# cd /srv/www
# wget -O blackfire-agent https://packages.blackfire.io/binaries/blackfire-agent/1.17.0/blackfire-agent-linux_static_amd64
下载完成后,您应该看到以下结果:
Blackfire 代理下载完成
如果是这样,请继续输入以下命令:
# mv blackfire-agent /usr/local/bin/
# chmod +x /usr/local/bin/blackfire-agent
现在,我们将把一个基本的代理配置文件复制到我们的etc
目录:
# mkdir -p /etc/blackfire
# cp agent /etc/blackfire/
这是我们刚刚复制的文件的内容。这是一个基本的配置文件,正如 Blackfire 团队建议的那样:
[blackfire]
;
; setting: ca-cert
; desc : Sets the PEM encoded certificates
; default:
ca-cert=
;
; setting: collector
; desc : Sets the URL of Blackfire's data collector
; default: https://blackfire.io
collector=https://blackfire.io/
;
; setting: log-file
; desc : Sets the path of the log file. Use stderr to log to stderr
; default: stderr
log-file=stderr
;
; setting: log-level
; desc : log verbosity level (4: debug, 3: info, 2: warning, 1: error)
; default: 1
log-level=1
;
; setting: server-id
; desc : Sets the server id used to authenticate with Blackfire API
; default:
server-id=
;
; setting: server-token
; desc : Sets the server token used to authenticate with Blackfire
API. It is unsafe to set this from the command line
; default:
server-token=
;
; setting: socket
; desc : Sets the socket the agent should read traces from. Possible
value can be a unix socket or a TCP address
; default: unix:///var/run/blackfire/agent.sock on Linux,
unix:///usr/local/var/run/blackfire-agent.sock on MacOSX, and
tcp://127.0.0.1:8307 on Windows.
socket=unix:///var/run/blackfire/agent.sock
;
; setting: spec
; desc : Sets the path to the json specifications file
; default:
spec=
然后,创建一个空文件,将用作代理的套接字:
# mkdir -p /var/run/blackfire
# touch /var/run/blackfire/agent.sock
最后,我们将注册我们的代理到 Blackfire 服务:
# blackfire-agent -register
一旦您输入了最后一个命令,您将需要提供您的 Blackfire 服务器凭据。这些可以在您的 Blackfire 帐户中找到:blackfire.io/account#server
。输入凭据后,您可以通过输入以下命令启动代理:
# blackfire-agent start &
启动代理后,您应该看到代理的 PID 号。这告诉您代理正在监听我们之前创建的默认 UNIX 套接字。在本例中,代理的 PID 号为 8:
Blackfire 代理进程 ID 号显示
安装和配置代理后,您可以安装 Blackfire 客户端。我们将通过以下命令安装和配置客户端。让我们首先下载二进制文件:
# wget -O blackfire https://packages.blackfire.io/binaries/blackfire-agent/1.17.0/blackfire-cli-linux_static_amd64
下载完成后,您应该看到以下消息:
Blackfire 客户端下载完成
现在您可以继续配置客户端。输入以下命令:
# mv blackfire /usr/local/bin/
# chmod +x /usr/local/bin/blackfire
# blackfire config
在输入最终命令后,您将需要提供 Blackfire 客户端凭据。这些也可以在以下 URL 的 Blackfire 帐户中找到:blackfire.io/account#client
。
为了在我们的服务器上运行Blackfire.io
,最后一步是将 Blackfire 探针安装为 PHP 扩展。为了做到这一点,请首先下载库:
# wget -O blackfire.so https://packages.blackfire.io/binaries/blackfire-php/1.20.0/blackfire-php-linux_amd64-php-71.so
下载完成后,您应该会收到以下确认消息:
Blackfire 探针下载完成
然后,您可以将共享库文件复制到 PHP 扩展目录中。如果您不确定该目录的位置,可以在将库文件移动到该目录之前发出以下命令:
# php -i | grep 'extension_dir'
# mv blackfire.so $( php -i | grep extensions | awk '{print $3}' )
在本例中,扩展的目录是/usr/lib/php/extensions/no-debug-non-zts-20160303
。
现在可以在PHP.INI
文件中配置扩展。激活 Blackfire 探针时,建议停用其他调试和分析扩展,如 xdebug。请运行以下命令(或者,您可以复制并粘贴我们存储库中已包含这些修改的PHP.INI
文件):
# sed -i 's/zend_extension=\/usr\/lib\/php\/extensions\/no-debug-non-zts-20160303\/xdebug.so/;zend_extension=\/usr\/lib\/php\/extensions\/no-debug-non-zts-20160303\/xdebug.so/' /etc/php.ini
# sed -i 's/^xdebug/;xdebug/' /etc/php.ini
# cat >>/etc/php.ini << 'EOF'
[blackfire]
extension=blackfire.so
; On Windows use the following configuration:
; extension=php_blackfire.dll
; Sets the socket where the agent is listening.
; Possible value can be a unix socket or a TCP address.
; Defaults to unix:///var/run/blackfire/agent.sock on Linux,
; unix:///usr/local/var/run/blackfire-agent.sock on MacOSX,
; and to tcp://127.0.0.1:8307 on Windows.
;blackfire.agent_socket = unix:///var/run/blackfire/agent.sock
blackfire.agent_timeout = 0.25
; Log verbosity level (4: debug, 3: info, 2: warning, 1: error)
;blackfire.log_level = 1
; Log file (STDERR by default)
;blackfire.log_file = /tmp/blackfire.log
;blackfire.server_id =
;blackfire.server_token =
EOF
请通过重新启动 PHP-FPM 来完成扩展的安装和配置:
# /etc/init.d/php-fpm restart
让我们从命令行对我们的第一个脚本进行分析。您现在可以通过在容器的 CLI 上输入以下命令来运行客户端:
# blackfire curl http://localhost/index.php
分析完成后,您将获得一个 URL 和一些分析统计信息。如果浏览到该 URL,您将看到分析的调用图,并获得有关分析脚本的更详细信息:
Blackfire 客户端返回一个初步的分析报告和一个 URL,以查看脚本的调用图
您还可以选择将客户端安装为浏览器插件。在本例中,我们将使用 Blackfire Companion,一个 Google Chrome 扩展程序。要安装该扩展,请使用 Chrome 访问以下 URL 并单击安装按钮:blackfire.io/docs/integrations/chrome
。安装完成后,可以通过浏览到页面并单击工具栏中的 Blackfire Companion 图标,然后单击 Profile 按钮来对服务器上的资源进行分析:
Chrome 的 Blackfire Companion 允许您直接从浏览器对 PHP 脚本进行分析
使用 Blackfire.io 手动进行分析
我们将首先手动对两个 PHP 脚本进行分析,以更好地了解 Blackfire 工具的用途和功能。我们将使用以下脚本,可以在我们的存储库(chap2pre.php
)中找到:
<?php
function getDiskUsage(string $directory)
{
$handle = popen("cd $directory && du -ch --exclude='./.*'", 'r');
$du = stream_get_contents($handle);
pclose($handle);
return $du;
}
function getDirList(string $directory, string &$du)
{
$result = getDiskUsage($directory);
$du = empty($du)
? '<br />' . preg_replace('/\n+/', '<br />', $result)
: $du;
$fileList = [];
$iterator = new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS);
foreach($iterator as $entry) {
if (!$entry->isDir() && $entry->getFilename()[0] != '.') {
$fileList[$entry->getFilename()] = 'size is ' . $entry->getSize();
} else {
if ($entry->isDir() && $entry->getFilename()[0] != '.') {
$fileList[$entry->getFilename()] = getDirList(
$directory . DIRECTORY_SEPARATOR . $entry->getFilename(),
$du
);
}
}
}
return $fileList;
}
$du = '';
$baseDirectory = dirname(__FILE__);
$fileList = getDirList($baseDirectory, $du);
echo '<html><head></head><body><p>';
echo 'Disk Usage : ' . $du . '<br /><br /><br />';
echo 'Directory Name : ' . $baseDirectory . '<br /><br />';
echo 'File listing :';
echo '</p><pre>';
print_r($fileList);
echo '</pre></body></html>';
该脚本基本上列出了存储库中包含的所有文件(目录及其子目录),并计算了每个文件的大小。此外,它还给出了每个目录大小的汇总结果。请使用 Chrome 浏览到以下 URL 以查看脚本的输出并使用 Blackfire Companion 启动分析:http://localhost:8181/chap2pre.php
:
单击右上方工具栏中的 Blackfire 图标将允许您启动分析会话
单击 Profile 按钮并等待几秒钟后,您应该可以单击 View Call Graph 按钮:
您可以单击“查看调用图”按钮查看脚本的调用图
结果应该如下:
该脚本执行完成所需的时间为 14.3 毫秒,并且使用'popen'函数创建了五个进程
结果显示,这个脚本的实际时间(墙时间[1])为 14.3 毫秒,而唯一具有重要独占时间的函数是stream_get_contents
和popen
。这是合理的,因为脚本必须处理磁盘访问和可能大量的 I/O 延迟。不太合理的是,脚本似乎要创建五个子进程来获取一个简单的文件列表。
此外,如果我们向下滚动,我们会注意到SplInfo::getFilename
被调用了六十七次,几乎是目录中文件数量的两倍:
SplFileInfo::getFilename 函数被调用了 67 次
从分析器获得的信息使我们能够快速确定我们代码库的哪些部分应该成为代码审查的候选项,以及在审查它们时要寻找什么。快速查看我们的代码表明,我们在每个目录迭代中都调用了popen
,而不是只在开始时调用一次。一个简单的修复方法是用以下两行代码替换:
function getDirList(string $directory, string &$du)
{
$result = getDiskUsage($directory);
$du = empty($du)
? '<br />' . preg_replace('/\n+/', '<br />', $result)
: $du;
[...]
然后,以下代码行可以插入到它们的位置:
function getDirList(string $directory, string &$du)
{
$du = empty($du)
? '<br />' . preg_replace('/\n+/', '<br />', getDiskUsage($directory))
: $du;
[...]
最后的调整是用包含函数调用结果的变量替换所有对SplInfo::getFilename()
的调用。修改后的脚本如下所示:
<?php
function getDiskUsage(string $directory)
{
$handle = popen("cd $directory && du -ch --exclude='./.*'", 'r');
$du = stream_get_contents($handle);
pclose($handle);
return $du;
}
function getDirList(string $directory, string &$du)
{
$du = empty($du)
? '<br />' . preg_replace('/\n+/', '<br />', getDiskUsage($directory))
: $du;
$fileList = [];
$iterator = new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS);
foreach($iterator as $entry) {
$fileName = $entry->getFilename();
$dirFlag = $entry->isDir();
if (!$dirFlag && $fileName[0] != '.') {
$fileList[$fileName] = 'size is ' . $entry->getSize();
} else {
if ($dirFlag && $fileName[0] != '.') {
$fileList[$fileName] = getDirList(
$directory . DIRECTORY_SEPARATOR . $fileName,
$du
);
}
}
}
return $fileList;
}
$du = '';
$baseDirectory = dirname(__FILE__);
$fileList = getDirList($baseDirectory, $du);
echo '<html><head></head><body><p>';
echo 'Disk Usage : ' . $du . '<br /><br /><br />';
echo 'Directory Name : ' . $baseDirectory . '<br /><br />';
echo 'File listing :';
echo '</p><pre>';
print_r($fileList);
echo '</pre></body></html>';
让我们尝试对新脚本(chap2post.php
)进行分析,以衡量我们的改进。同样,请使用 Chrome 浏览到以下网址查看脚本的输出,并使用 Blackfire Companion 启动分析:http://localhost:8181/chap2post.php
。
结果应该如下:
现在,脚本只需要 4.26 毫秒来完成执行,并且只使用'popen'函数创建了一个进程
结果显示,这个脚本现在的墙时间为 4.26 毫秒,而popen
函数只创建了一个子进程。此外,如果我们向下滚动,我们现在注意到SplInfo::getFilename
只被调用了三十三次,比之前少了两倍:
现在,SplFileInfo::getFilename 函数只被调用了 33 次
这些都是重大的改进,特别是如果这个脚本要在不同的目录结构上每分钟被调用数千次。确保这些改进不会在应用程序开发周期的未来迭代中丢失的一个好方法是通过性能测试自动化分析器。现在我们将快速介绍如何使用Blackfire.io
自动化性能测试。
使用 Blackfire.io 进行性能测试
在开始之前,请注意,此功能仅适用于高级和企业用户,因此需要付费订阅。
为了自动化性能测试,我们将首先在我们的存储库中创建一个非常简单的blackfire.yml
文件。这个文件将包含我们的测试。一个测试应该由一个名称、一个正则表达式和一组断言组成。最好避免创建易变的时间测试,因为这些测试很容易变得非常脆弱,可能会导致从一个分析会话到下一个分析会话产生非常不同的结果。强大的性能测试示例包括检查 CPU 或内存消耗、SQL 查询数量或通过配置比较测试结果。在我们的情况下,我们将创建一个非常基本和易变的时间测试,只是为了举一个简短和简单的例子。以下是我们.blackfire.yml
文件的内容:
tests:
"Pages should be fast enough":
path: "/.*" # run the assertions for all HTTP requests
assertions:
- "main.wall_time < 10ms" # wall clock time is less than 10ms
最后一步是将这个性能测试与持续集成工具集成。要选择您喜欢的工具,请参阅以下网址的文档:blackfire.io/docs/integrations/index
。
在我们的情况下,我们将与Travis CI集成。为此,我们必须创建两个文件。一个将包括我们的凭据,并且必须加密(.blackfire.travis.ini.enc
)。另一个将包括我们的 Travis 指令(.travis.yml
)。
这是我们的.blackfire.travis.ini
文件在加密之前的内容(用您自己的凭据替换):
[blackfire]
server-id=BLACKFIRE_SERVER_ID
server-token=BLACKFIRE_SERVER_TOKEN
client-id=BLACKFIRE_CLIENT_ID
client-token=BLACKFIRE_CLIENT_TOKEN
endpoint=https://blackfire.io/
collector=https://blackfire.io/
然后,必须在提交到存储库之前对该文件进行加密。为此,请在 Linux for PHP 容器内部发出以下命令:
# gem install travis
# travis encrypt-file /srv/www/.blackfire.travis.ini -r [your_Github_repository_name_here]
这是我们的.travis.yml
文件的内容:
language: php
matrix:
include:
- php: 5.6
- php: 7.0
env: BLACKFIRE=on
sudo: false
cache:
- $HOME/.composer/cache/files
before_install:
- if [[ "$BLACKFIRE" = "on" ]]; then
openssl aes-256-cbc -K [ENCRYPT_KEY_HERE] -iv [ENCRYPT_IV_HERE] -in .blackfire.travis.ini.enc -out ~/.blackfire.ini -d
curl -L https://blackfire.io/api/v1/releases/agent/linux/amd64 | tar zxpf -
chmod 755 agent && ./agent --config=~/.blackfire.ini --socket=unix:///tmp/blackfire.sock &
fi
install:
- travis_retry composer install
before_script:
- phpenv config-rm xdebug.ini || true
- if [[ "$BLACKFIRE" = "on" ]]; then
curl -L https://blackfire.io/api/v1/releases/probe/php/linux/amd64/$(php -r "echo PHP_MAJOR_VERSION . PHP_MINOR_VERSION;")-zts | tar zxpf -
echo "extension=$(pwd)/$(ls blackfire-*.so | tr -d '[[:space:]]')" > ~/.phpenv/versions/$(phpenv version-name)/etc/conf.d/blackfire.ini
echo "blackfire.agent_socket=unix:///tmp/blackfire.sock" >> ~/.phpenv/versions/$(phpenv version-name)/etc/conf.d/blackfire.ini
fi
script:
- phpunit
一旦提交,此配置将确保性能测试将在每次 git 推送到您的 Github 存储库时运行。因此,性能成为一个特性,并且像应用程序的其他任何特性一样持续测试。下一步是在生产服务器上部署代码后监视代码的性能。让我们了解一些可用的工具,以便这样做。
使用 TICK 堆栈监控性能
TICK 堆栈是由 InfluxData(InfluxDB)开发的,由一系列集成组件组成,允许您轻松处理通过时间生成的不同服务的时间序列数据。TICK 是一个首字母缩写词,由监控套件的每个主要产品的首字母组成。T 代表 Telegraf,它收集我们希望在生产服务器上获取的信息。I 代表 InfluxDB,这是一个包含 Telegraf 或任何其他配置为这样做的应用程序收集的信息的时间序列数据库。C 代表 Chronograf,这是一个图形工具,可以让我们轻松地理解收集的数据。最后,K 代表 Kapacitor,这是一个警报自动化工具。
监控基础设施性能不仅对于确定应用程序和脚本是否按预期运行很重要,而且还可以开发更高级的算法,如故障预测和意外行为模式识别,从而使得可以自动化性能监控的许多方面。
当然,还有许多其他出色的性能监控工具,比如 Prometheus 和 Graphite,但我们决定使用 TICK 堆栈,因为我们更感兴趣的是事件日志记录,而不是纯粹的指标。有关 TICK 堆栈是什么,内部工作原理以及用途的更多信息,请阅读 Gianluca Arbezzano 在 Codeship 网站上发表的这篇非常信息丰富的文章:blog.codeship.com/infrastructure-monitoring-with-tick-stack/
。
现在,为了查看我们的Blackfire.io
支持的分析有多有用,以及我们的代码变得更加高效,我们将再次运行这两个脚本,但是这次使用官方 TICK Docker 镜像的副本,以便我们可以监视优化后的 PHP 脚本部署到 Web 服务器上后,Web 服务器的整体性能是否有所改善。我们还将用 Grafana 替换 Chronograf,这是一个高度可定制的图形工具,我们不会设置 Kapacitor,因为配置警报略微超出了我们当前目标的范围。
让我们开始激活 Apache 服务器上的mod_status
。从我们的 Linux for PHP 的 CLI 中,输入以下命令:
# sed -i 's/#Include \/etc\/httpd\/extra\/httpd-info.conf/Include \/etc\/httpd\/extra\/httpd-info.conf/' /etc/httpd/httpd.conf
# sed -i 's/Require ip 127/Require ip 172/' /etc/httpd/extra/httpd-info.conf
# /etc/init.d/httpd restart
完成后,您应该能够通过 Chrome 浏览器浏览以下 URL 来查看服务器的状态报告:http://localhost:8181/server-status?auto
。
下一步是启动 TICK 套件。请打开两个新的终端窗口以执行此操作。
在第一个终端窗口中,输入此命令:
# docker run -d --name influxdb -p 8086:8086 andrewscaya/influxdb
然后,在第二个新打开的终端窗口中,通过发出此命令获取我们两个容器的 IP 地址:
# docker network inspect bridge
这是我在我的计算机上运行此命令的结果:
两个容器的 IP 地址
请保留这两个地址,因为配置 Telegraf 和 Grafana 时将需要它们。
现在,我们将使用一个简单的命令生成 Telegraf 的示例配置文件(此步骤是可选的,因为示例文件已经包含在本书的存储库中)。
首先,将目录更改为我们项目的工作目录(Git 存储库),然后输入以下命令:
# docker run --rm andrewscaya/telegraf -sample-config > telegraf.conf
其次,用您喜欢的编辑器打开新文件,并取消注释inputs.apache
部分中的以下行。不要忘记在urls
行上输入我们 Linux for PHP容器的 IP 地址:
配置 Telegraf 以监视在另一个容器中运行的 Apache 服务器
在终端窗口中,现在可以使用以下命令启动 Telegraf(请确保您在我们项目的工作目录中):
# docker run --net=container:influxdb -v ${PWD}/telegraf.conf:/etc/telegraf/telegraf.conf:ro andrewscaya/telegraf
在第二个新生成的终端窗口中,使用以下命令启动 Grafana:
# docker run -d --name grafana -p 3000:3000 andrewscaya/grafana
使用 Chrome 浏览到http://localhost:3000/login
。您将看到 Grafana 的登录页面。请使用用户名 admin 和密码 admin 进行身份验证:
显示 Grafana 登录页面
然后,添加新数据源:
将 Grafana 连接到数据源
请选择 InfluxDB 数据源的名称。选择 InfluxDB 作为类型。输入 InfluxDB 容器实例的 URL,其中包括您在之前步骤中获得的 IP 地址,后跟 InfluxDB 的默认端口号 8086。您可以选择直接访问。数据库名称是 telegraf,数据库用户和密码是 root:
配置 Grafana 的数据源
最后,单击添加按钮:
添加数据源
现在数据源已添加,让我们添加一些从 Grafana 网站导入的仪表板。首先点击仪表板菜单项下的导入:
单击导入菜单项开始导入仪表板
我们将添加的两个仪表板如下:
- Telegraf 主机指标(
grafana.com/dashboards/1443
):
Telegraf 主机指标仪表板的主页
- Apache 概览(
grafana.com/dashboards/331
):
Apache 概览仪表板的主页
在导入屏幕上,只需输入仪表板的编号,然后单击加载:
加载 Telegraf 主机指标仪表板
然后,确认新仪表板的名称并选择我们的本地 InfluxDB 连接:
将 Telegraf 主机指标仪表板连接到 InfluxDB 数据源
现在您应该看到新的仪表板:
显示 Telegraf 主机指标仪表板
现在,我们将重复最后两个步骤,以导入 Apache 概览仪表板。单击仪表板菜单项下的导入按钮后,输入仪表板的标识符(331
),然后单击加载按钮:
加载 Apache 概览仪表板
然后,确认名称并选择我们的本地 InfluxDB 数据源:
将 Apache 概览仪表板连接到 InfluxDB 数据源
现在您应该在浏览器中看到第二个仪表板:
显示 Apache 概览仪表板
所有 TICK 套件仪表板都允许更高级的图形配置和自定义。因此,通过执行自定义 cron 脚本,可以收集一组自定义的时间序列数据点,然后配置仪表板以按您的要求显示这些数据。
在我们当前的例子中,TICK 套件现在已安装和配置。因此,我们可以开始测试和监视使用Blackfire.io
在本章第一部分中进行优化的 PHP 脚本,以测量其性能的变化。我们将首先部署、进行基准测试和监视旧版本。在 Linux 上的 PHP CLI 中,输入以下命令以对旧版本的脚本进行基准测试:
# siege -b -c 3000 -r 100 localhost/chap2pre.php
基准测试应该产生类似以下结果:
显示了原始脚本的性能基准测试结果
然后,等待大约十分钟后,通过输入以下命令开始对新版本的脚本进行基准测试:
# siege -b -c 3000 -r 100 localhost/chap2post.php
这是我电脑上最新基准测试的结果:
显示了优化脚本的性能基准测试结果
结果已经显示出性能上的显著改善。事实上,新脚本每秒允许的交易数量是原来的三倍多,失败交易的数量也减少了三分之一以上。
现在,让我们看看我们的 TICK Stack 收集了关于这两个版本的 PHP 脚本性能的哪些数据:
监控图表中清楚地显示了性能的提升
我们 Grafana 仪表板中的图表清楚地显示了与基准测试结果本身相同数量级的性能提升。在 08:00 之后对新版本脚本进行的基准测试明显使服务器负载减少了一半,输入(I/O)减少了一半以上,并且总体上比之前在 7:40 左右进行基准测试的旧版本快了三倍以上。因此,毫无疑问,我们的Blackfire.io
优化使得新版本的 PHP 脚本更加高效。
总结
在本章中,我们学习了如何安装和配置基本的Blackfire.io
设置,以便在提交到存储库时轻松自动地对代码进行分析。我们还探讨了如何安装 TICK Stack,以便在将代码部署到实时生产服务器后持续监视其性能。因此,我们已经了解了如何安装和配置分析和监视工具,这些工具可以帮助我们在持续集成(CI)和持续部署(CD)环境中轻松优化 PHP 代码。
在下一章中,我们将探讨如何更好地理解 PHP 数据结构并使用简化的函数可以帮助应用程序在其关键执行路径上的全局性能。我们将首先分析项目的关键路径,然后微调其某些数据结构和函数。
参考资料
[1] 关于这些性能测试术语的进一步解释,请访问以下网址:blackfire.io/docs/reference-guide/time
。
第三章:利用 PHP 7 数据结构和函数的强大功能
在本章中,我们将学习如何利用 PHP 7 的性能优化。
此外,我们将探讨更好地理解数据结构和数据类型,以及如何使用简化的函数可以帮助 PHP 应用程序在其关键执行路径上提高全局性能。
此外,我们将学习如何避免在 PHP 代码中使用效率低下的结构,比如大多数动态结构。
最后,尽管 PHP 不是一种函数式语言,但我们将看到一些函数式技术在优化 PHP 代码时可以立即提供帮助。
因此,在本章中,我们将涵盖以下几点:
-
PHP 7 的优化
-
识别可能的优化并避免动态结构
-
函数式编程和记忆化
PHP 7 的优化
PHP 7 本身就是一个重大的优化。PHP 的大部分代码库都是为了这个版本而重写的,大多数官方基准测试显示,一般来说,几乎任何 PHP 代码在 PHP 7 上运行的速度都比以前的版本快两倍或更多。
PHP 是用 C 编程的,优化 Zend 的Ahead-Of-Time(AOT)编译器的性能最终取决于以优化的方式使用 C 编译器的内部逻辑。PHP 7 的最新版本是 Zend 多年研究和实验的结果。这些优化的大部分是通过消除由某些 PHP 内部结构构造和数据结构产生的性能开销来实现的。根据Dmitry Stogov[1]的说法,典型的现实生活中的 PHP 应用程序大约有 20%的 CPU 时间用于内存管理器,10%用于哈希表操作,30%用于内部函数,只有 30%用于虚拟机。为了优化 PHP 代码的执行,PHP 7 的 Zend 引擎的新版本必须首先将源代码表示为抽象语法树(AST),从而使引擎能够生成更高质量的中间表示(IR)源代码,并且自 PHP 7.1 以来,能够删除死代码并尽可能将许多表达式转换为它们的静态表示形式,通过静态单赋值(SSA)形式和类型推断。反过来,这使得引擎只需在运行时将必要的数据结构分配到堆栈而不是内存中的堆中。
这对于理解本章的其余部分非常重要,因为它让我们看到为什么数据类型转换和动态结构通常会通过在运行时膨胀内存分配来创建大部分开销,为什么必须重新实现某些数据结构以实现 C 级性能,以及为什么不可变性是开发人员在努力实现更好代码性能时的盟友。让我们更仔细地看看这些元素。
严格类型
当一种语言是动态类型的,也就是说,它具有松散类型的变量,它提供了更高级的抽象,提高了开发人员的生产力,但在尝试确定变量的数据类型时,编译器需要更多的工作,因此性能并不是最佳的。毫不奇怪,强类型语言在运行时的性能总是比松散类型的语言更好。这个结论得到了 Facebook 的 HipHop 项目的证实,该项目对不同语言进行了基准测试,并得出结论:静态编译的语言总是比动态语言执行更快,消耗的内存也更少。
尽管 PHP 7 仍然是一种松散类型的语言,但现在它提供了严格类型化变量和函数签名的可能性。可以通过执行以下代码示例来轻松测试。让我们运行以下代码来查看其当前性能:
// chap3_strict_typing.php
declare(strict_types = 0);
$start = microtime(true);
function test ($variable)
{
$variable++;
return "$variable is a test.";
}
ob_start();
for ($x = 0; $x < 1000000; $x++) {
$array[$x] = (string) $x;
echo test($array[$x]) . PHP_EOL;
}
$time = microtime(true) - $start;
ob_clean();
ob_end_flush();
echo 'Time elapsed: ' . $time . PHP_EOL;
以下是使用Blackfire.io
运行此脚本的结果:
省略变量和函数签名的严格类型化时的分析报告
现在,让我们用以下代码替换原来的代码:
// chap3_strict_typing_modified.php
declare(strict_types = 1);
$start = microtime(true);
function test (int $variable) : string
{
$variable++;
return $variable . ' is a test.';
}
ob_start();
for ($x = 0; $x < 1000000; $x++) {
$array[$x] = (int) $x;
echo test($array[$x]) . PHP_EOL;
}
$time = microtime(true) - $start;
ob_clean();
ob_end_flush();
echo 'Time elapsed: ' . $time . PHP_EOL;
如果我们执行它,我们会立即看到性能上的差异:
在严格类型变量和函数签名的性能分析报告
使用microtime()
函数也可以看到性能提升。让我们运行我们脚本的两个版本,看看结果:
使用 microtime()函数比较脚本性能
为了充分利用 PHP 的新 AST 和 SSA 功能,开发人员应尽可能严格地对变量和函数签名进行类型限定。当 Zend 引擎在未来版本中获得即时(JIT)编译器时,这将变得尤为重要,因为这将允许基于类型推断进行进一步的优化。
严格类型的另一个附加优势是,它让编译器管理代码质量的一个方面,消除了需要进行单元测试来确保函数在接收到意外输入时表现如预期的必要性。
不可变和紧凑数组
正如我们将在本章后面看到的,不可变性不仅有助于开发人员在编程时减轻认知负担,提高代码质量和一般单元测试的质量,而且还将允许编译器进行更好的代码优化。从 PHP 7 开始,任何静态数组都会被 OPcache 缓存,并且指向数组的指针将与尝试访问它的代码的任何部分共享。此外,PHP 7 为紧凑数组提供了一个非常重要的优化,这些数组只使用升序整数进行索引。让我们拿以下代码来对比在启用 OPcache 的 PHP 5.6 和 PHP 7 上执行的结果:
// chap3_immutable_arrays.php
$start = microtime(true);
for ($x = 0; $x < 10000; $x++) {
$array[] = [
'key1' => 'This is the first key',
'key2' => 'This is the second key',
'key3' => 'This is the third key',
];
}
echo $array[8181]['key2'] . PHP_EOL;
$time = microtime(true) - $start;
echo 'Time elapsed: ' . $time . PHP_EOL;
echo memory_get_usage() . ' bytes' . PHP_EOL;
如果我们用 PHP 5.6 运行之前的代码,我们会消耗近 7.4MB 的内存,耗时为 0.005 秒:
在 PHP 5.6 上运行脚本时的结果
如果我们用 PHP 7 运行相同的代码,我们会得到以下结果:
在 PHP 7.1 上运行相同脚本时的结果
结果令人印象深刻。相同的脚本快了 40 倍,内存消耗几乎减少了 10 倍。因此,不可变数组提供了更快的速度,开发人员应该避免修改大数组,并在处理大数组时尽可能使用紧凑数组,以优化内存分配并最大化运行时速度。
整数和浮点数的内存分配
PHP 7 引入的另一个优化是重用先前分配的变量容器。如果你需要创建大量的变量,你应该尝试重用它们,因为 PHP 7 的编译器将避免重新分配内存,并重用已经分配的内存槽。让我们看下面的例子:
// chap3_variables.php
$start = microtime(true);
for ($x = 0; $x < 10000; $x++) {
$$x = 'test';
}
for ($x = 0; $x < 10000; $x++) {
$$x = $x;
}
$time = microtime(true) - $start;
echo 'Time elapsed: ' . $time . PHP_EOL;
echo memory_get_usage() . ' bytes' . PHP_EOL;
让我们运行这段代码,以便看到内存消耗的差异。让我们从 PHP 5.6 开始:
在 PHP 5.6 上运行脚本时的结果
现在,让我们用 PHP 7 运行相同的脚本:
在 PHP 7.1 上运行相同脚本时的结果
正如你所看到的,结果显示内存消耗减少了近三分之一。尽管这违背了变量不可变的原则,但当你必须在内存中分配大量变量时,这仍然是一个非常重要的优化。
字符串插值和连接
在 PHP 7 中,使用新的字符串分析算法对字符串插值进行了优化。这意味着字符串插值现在比连接快得多,过去关于连接和性能的说法不再成立。让我们拿以下代码示例来衡量新算法的性能:
// chap3_string_interpolation.php
$a = str_repeat(chr(rand(48, 122)), rand(1024, 3000));
$b = str_repeat(chr(rand(48, 122)), rand(1024, 3000));
$start = microtime(true);
for ($x = 0; $x < 10000; $x++) {
$$x = "$a is not $b";
}
$time = microtime(true) - $start;
echo 'Time elapsed: ' . $time . PHP_EOL;
echo memory_get_usage() . ' bytes' . PHP_EOL;
在运行这段代码时,以下是对 PHP 5.6 的性能测量:
针对 PHP 5.6 运行相同脚本的结果
以下是使用 PHP 7 的相同脚本:
针对 PHP 7.1 运行相同脚本的结果
PHP 7 大约快三到四倍,并且消耗的内存比少了三分之一。这里要学到的教训是,在处理字符串时,尽量使用 PHP 7 的字符串插值算法。
参数引用
尽管最好避免将变量通过引用传递给函数,以避免在函数外部改变应用程序的状态,但 PHP 7 使得以高度优化的方式传递变量给函数成为可能,即使引用不匹配。让我们看下面的代码示例,以更好地理解 PHP 7 在这方面比 PHP 5 更有效率:
// chap3_references.php
$start = microtime(true);
function test (&$byRefVar)
{
$test = $byRefVar;
}
$variable = array_fill(0, 10000, 'banana');
for ($x = 0; $x < 10000; $x++) {
test($variable);
}
$time = microtime(true) - $start;
echo 'Time elapsed: ' . $time . PHP_EOL;
echo memory_get_usage() . ' bytes' . PHP_EOL;
让我们用 PHP 5 二进制运行这段代码:
针对 PHP 5.6 运行脚本的结果
在执行相同的代码时,PHP 7 的结果如下:
针对 PHP 7.1 运行相同脚本的结果
PHP 7 的结果再次非常令人印象深刻,它几乎以三分之一的内存分配和 1000 倍的速度完成了相同的工作!在幕后发生的是,当引用不匹配时,PHP 7 不再在内存中复制变量。因此,新的编译器避免了为无用的内存分配膨胀,并加快了任何 PHP 脚本的执行,其中引用不匹配是一个问题。
识别更多可能的优化。
在优化应用程序时,您将首先确定最耗时的函数,特别是沿着应用程序的关键路径。正如前一章所述,大多数这些函数将是 I/O 函数,因为这些函数对计算机来说总是最昂贵的操作。大多数情况下,您会看到优化循环和减少系统调用的可能性,但很快您会意识到,无论您希望对其进行何种优化,I/O 操作始终是昂贵的。不过,有时您可能会遇到非常慢的 PHP 结构,可以简单地用更快的结构替换,或者您可能会意识到,设计不良的代码可以很容易地重构为更节约资源,比如用更简单的静态结构替换动态结构。
的确,除非绝对必要,应避免使用动态结构。现在我们来看一个非常简单的例子。我们将使用三种不同的方法编写相同的功能四次:函数和动态、函数和静态,最后是结构和静态。让我们从函数和动态方法开始:
// chap3_dynamic_1.php
$start = microtime(true);
$x = 1;
$data = [];
$populateArray = function ($populateArray, $data, $x) {
$data[$x] = $x;
$x++;
return $x <= 1000 ? $populateArray($populateArray, $data, $x) : $data;
};
$data = $populateArray($populateArray, $data, $x);
$time = microtime(true) - $start;
echo 'Time elapsed: ' . $time . PHP_EOL;
echo memory_get_usage() . ' bytes' . PHP_EOL;
这段代码通过递归调用相同的闭包来创建一个包含 1,000 个元素的数组。如果我们运行这段代码,我们会得到以下结果:
使用函数和动态方法编写的脚本运行时所消耗的时间和内存
让我们看看使用Blackfire.io
运行此脚本的结果:
使用函数和动态方法编写的脚本运行时的性能报告
让我们以更静态的方式编写相同的功能,使用经典的命名函数:
// chap3_dynamic_2.php
$start = microtime(true);
$x = 1;
$data = [];
function populateArray(Array $data, $x)
{
$data[$x] = $x;
$x++;
return $x <= 1000 ? populateArray($data, $x) : $data;
}
$data = populateArray($data, $x);
$time = microtime(true) - $start;
echo 'Time elapsed: ' . $time . PHP_EOL;
echo memory_get_usage() . ' bytes' . PHP_EOL;
如果我们执行这个版本的代码,我们会得到以下结果:
使用函数和静态方法编写的脚本运行时所消耗的时间和内存
使用Blackfire.io
分析器运行脚本产生了以下结果:
使用函数和静态方法编写的脚本运行时的性能报告
最后,让我们再次以非常结构化和静态的方式编写这个功能,而不是通过尾递归调用函数:
// chap3_dynamic_3.php
$start = microtime(true);
$data = [];
function populateArray(Array $data)
{
static $x = 1;
$data[$x] = $x;
$x++;
return $data;
}
for ($x = 1; $x <= 1000; $x++) {
$data = populateArray($data);
}
$time = microtime(true) - $start;
echo 'Time elapsed: ' . $time . PHP_EOL;
echo memory_get_usage() . ' bytes' . PHP_EOL;
在执行代码的最新版本后,以下是结果:
运行使用结构化和静态方法编程的脚本时所消耗的时间和内存
使用Blackfire.io
对这个脚本版本进行分析的结果如下:
运行使用结构化和静态方法编程的脚本时的分析报告
结果清楚地显示了结构化方法是最快的。如果我们现在沿着结构化的路线再走一小步,只是稍微使用一点功能性编程,并尝试使用生成器来迭代创建数组,我们对将获得的高性能结果不应感到惊讶。以下是我们代码的最新版本:
// chap3_dynamic_4.php
$start = microtime(true);
$data = [];
function populateArray()
{
for ($i = 1; $i <= 1000; $i++) {
yield $i => $i;
}
return;
}
foreach (populateArray() as $key => $value) {
$data[$key] = $value;
}
$time = microtime(true) - $start;
echo 'Time elapsed: ' . $time . PHP_EOL;
echo memory_get_usage() . ' bytes' . PHP_EOL;
这是运行我们代码的最新版本的结果:
运行使用非常结构化和静态方法编程的脚本时所消耗的时间和内存
使用Blackfire.io
的结果如下:
运行使用非常结构化和静态方法编程的脚本时的分析报告
结果清楚地显示了我们代码的这个最新版本确实优于其他版本。事实上,PHP 仍然是一种非常结构化的语言,因为它的编译器仍然没有完全优化尾递归调用,并且如果以结构化方式编写程序,则完成程序执行所需的时间更短。这是否意味着 PHP 永远不会成为一种功能性语言,最好避免在 PHP 中以功能性方式编程?简短的答案是否定的。这是否意味着使用 PHP 进行功能性编程只是未来的事情?同样,答案也是否定的。有一些功能性编程技术可以立即使用,并且将帮助我们的脚本更具性能。让我们特别看一下其中一种技术,即记忆化。
函数式编程和记忆化
PHP 是一种命令式而不是声明式语言,这意味着编程是通过改变程序状态的语句来完成的,就像 C 语言系列中的其他语言一样,它不是由无状态表达式或声明组成的,比如 SQL。尽管 PHP 主要是一种结构化(过程式)和面向对象的编程语言,但自 PHP 5.3 以来,我们已经看到越来越多的请求要求更多的功能性结构,比如生成器和 lambda 函数(匿名函数)。然而,就性能而言,PHP 目前仍然是一种结构化语言。
话虽如此,大多数功能性编程技术将在未来几年内产生成果,但仍然有一些功能性编程技术可以立即在 PHP 中使用,一旦在项目的代码库中实施,就会提高性能。其中一种技术就是记忆化。
记忆化是一种函数式编程技术,它将昂贵的函数计算的结果存储并在同一程序中每次调用时重复使用。其思想是在接收特定输入时返回函数的静态值。显然,为了避免值的失效,函数应该是引用透明的,这意味着当给定特定输入时,它应该始终返回相同的输出。当你意识到引用透明函数在应用程序的关键路径上被多次调用并且每次都被计算时,这就派上了用场。记忆化是一种简单的优化实现,因为它只是创建一个缓存来存储计算的结果。
让我们来看一个简单的例子,这将帮助我们轻松地理解其背后的思想。假设我们有以下代码沿着应用程序的关键路径:
// chap3_memoization_before.php
$start = microtime(true);
$x = 1;
$data = [];
function populateArray(Array $data, $x)
{
$data[$x] = $x;
$x++;
return $x <= 1000 ? populateArray($data, $x) : $data;
}
$data = populateArray($data, $x);
$data = populateArray($data, $x);
$data = populateArray($data, $x);
$data = populateArray($data, $x);
$data = populateArray($data, $x);
$time = microtime(true) - $start;
echo 'Time elapsed: ' . $time . PHP_EOL;
echo memory_get_usage() . ' bytes' . PHP_EOL;
在这里,我们看到同一个函数被递归调用了很多次。而且,它是一个引用透明的函数。因此,它是记忆化的一个完美候选者。
让我们从检查其性能开始。如果我们执行代码,我们将得到以下结果:
在实施记忆化之前的结果
现在,让我们实施一个缓存来记忆化结果:
// chap3_memoization_after.php
$start = microtime(true);
$x = 1;
$data = [];
function populateArray(Array $data, $x)
{
static $cache = [];
static $key;
if (!isset($key)) {
$key = md5(serialize($x));
}
if (!isset($cache[$key])) {
$data[$x] = $x;
$x++;
$cache[$key] = $x <= 1000 ? populateArray($data, $x) : $data;
}
return $cache[$key];
}
$data = populateArray($data, $x);
$data = populateArray($data, $x);
$data = populateArray($data, $x);
$data = populateArray($data, $x);
$data = populateArray($data, $x);
$time = microtime(true) - $start;
echo 'Time elapsed: ' . $time . PHP_EOL;
echo memory_get_usage() . ' bytes' . PHP_EOL;
以下是执行相同代码的新版本时的结果:
在实施记忆化后的结果
正如我们所看到的,PHP 脚本现在运行得更快了。当在应用程序的关键路径上调用引用透明函数的次数越多时,使用记忆化时速度就会增加得越多。让我们使用Blackfire.io
来查看我们脚本的性能。
以下是在没有使用记忆化时执行脚本的结果:
在不使用记忆化时的性能分析报告
以下是使用记忆化后的结果:
使用记忆化时的性能分析报告
比较显示,脚本的记忆化版本运行大约快了八倍,并且消耗的内存少了三分之一。对于这样一个简单的实现来说,性能上的重要提升。
关于记忆化的最后一个问题可能是:我们可以在同一个脚本的多次运行之间缓存结果吗?当然可以。由你来确定最佳的缓存方式。你可以使用任何标准的缓存结果的方式。此外,至少有一个库可以用来在 PHP 中缓存记忆化的结果。你可以在以下地址找到它:github.com/koktut/php-memoize
。请注意,这个库对于我们上一个脚本来说不是一个好的选择,因为它与递归尾调用不兼容。
摘要
在本章中,我们学习了 PHP 7 本身是一个优化,如何避免一般动态结构总是会提升 PHP 脚本的性能,以及某些函数式编程技术,比如记忆化,在优化代码性能时可以是强大的盟友。
在下一章中,我们将学习如何通过学习生成器和异步非阻塞代码、使用 POSIX 线程(pthreads
)库进行多线程处理,以及使用ReactPHP
库进行多任务处理来应对输入和输出(I/O)延迟较大的情况。
参考
news.php.net/php.internals/73888
第四章:使用异步 PHP 构想未来
在本章中,我们将学习如何确定在处理 I/O 调用时什么是最佳策略,以及如何实施这些策略。我们将看到多线程与多任务处理的区别,何时实施其中一个,以及如何实施。
此外,我们将学习如何使用ReactPHP
库,并在处理异步 I/O 调用时如何从事件驱动编程中受益。
因此,在本章中,我们将涵盖以下几点:
-
使用异步非阻塞代码优化 I/O 调用
-
使用
POSIX Threads
库进行多线程 -
实施
ReactPHP
解决方案
异步非阻塞 I/O 调用
正如我们在本书的前几章中所看到的,由于建立、使用和关闭流和套接字的基础延迟,I/O 调用始终会提供最差的性能。由于 PHP 基本上是一种同步语言,它在恢复代码执行之前等待被调用的函数返回,因此如果被调用的函数必须等待流关闭才能返回到调用代码,I/O 调用尤其成问题。当一个 PHP 应用程序例如每隔几分钟需要进行数千次 I/O 调用时,情况会变得更糟。
自 PHP 5.3 以来,通过使用生成器中断 PHP 的正常执行流程成为可能,从而异步执行代码。正如我们之前所看到的,即使动态结构在一般情况下可能性能较差,它们仍然可以用于加速阻塞代码。这对于通常具有非常高延迟的 I/O 调用尤其如此。为了更好地掌握 I/O 延迟的数量级,我们可以查看谷歌发布的以下著名图表:
延迟比较数字--------------------------L1 缓存引用 0.5 ns 分支错误预测 5 nsL2 缓存引用 7 ns 14 倍 L1 缓存互斥锁定/解锁 25 ns 主存储器引用 100 ns 20 倍 L2 缓存,200 倍 L1 缓存使用 Zippy 压缩 1K 字节 3,000 ns 3 us 通过 1 Gbps 网络发送 1K 字节 10,000 ns 10 us 从 SSD随机读取 4K150,000 ns 150 us 〜1GB/秒 SSD 从内存顺序读取 1 MB250,000 ns 250 us 在同一数据中心的往返 500,000 ns 500 us 从 SSD顺序读取 1 MB1,000,000 ns 1,000 us 1 ms 〜1GB/秒 SSD,4 倍内存磁盘查找 10,000,000 ns 10,000 us 10 ms 20 倍数据中心往返从磁盘顺序读取 1 MB20,000,000 ns 20,000 us 20 ms 80 倍内存,20 倍 SSD 发送数据包 CA->荷兰->CA150,000,000 ns 150,000 us 150 ms 注释-----1 ns = 10^-9 秒 1 us = 10^-6 秒 = 1,000 ns1 ms = 10^-3 秒 = 1,000 us = 1,000,000 ns 来源------Jeff Dean:research.google.com/people/jeff/ Peter Norvig 原作:norvig.com/21-days.html#answers 贡献-------------来自:gist.github.com/2843375 "人性化"比较:gist.github.com/2843375 可视化比较图表:i.imgur.com/k0t1e.png 动画演示:prezi.com/pdkvgys-r0y6/latency-numbers-for-programmers-web-development/latency.txt gist.github.com/jboner/2841832 gist.github.com/andrewscaya/2f9e68d4b41f9d747b92fb26b1b60d9f |
---|
毫无疑问,从磁盘读取始终比从内存读取慢,网络 I/O 调用仍然是最慢的。
让我们深入一点,看一下一些进行一系列 I/O 调用的代码。我们的第一个例子将使用cURL
。让我们看一下以下代码:
// chap4_IO_blocking.php
$start = microtime(true);
$i = 0;
$responses = [];
while ($i < 10) {
$curl = curl_init();
curl_setopt_array($curl, array(
CURLOPT_RETURNTRANSFER => 1,
CURLOPT_URL => 'http://www.google.ca',
CURLOPT_USERAGENT => 'Faster Web cURL Request'
));
$responses[] = curl_exec($curl);
curl_close($curl);
$i++;
}
$time = microtime(true) - $start;
echo 'Time elapsed: ' . $time . PHP_EOL;
echo memory_get_usage() . ' bytes' . PHP_EOL;
现在,让我们执行 PHP 脚本。我们现在应该看到以下结果:
运行阻塞代码脚本时经过的时间和消耗的内存
由于访问网络的高延迟,这段代码需要很长时间才能完成。
如果我们使用Blackfire.io
对先前的代码进行性能分析,我们会看到 10 次cURL
调用需要超过一秒才能完成:
对代码进行性能分析显示,10 次 cURL 调用占据了脚本总执行时间的大部分
让我们修改我们的 PHP 脚本,以使用异步代码同时运行我们的cURL
请求。以下是先前 PHP 代码的新版本:
// chap4_IO_non_blocking.php
$start = microtime(true);
$i = 0;
$curlHandles = [];
$responses = [];
$multiHandle = curl_multi_init();
for ($i = 0; $i < 10; $i++) {
$curlHandles[$i] = curl_init();
curl_setopt_array($curlHandles[$i], array(
CURLOPT_RETURNTRANSFER => 1,
CURLOPT_URL => 'http://www.google.ca',
CURLOPT_USERAGENT => 'Faster Web cURL Request'
));
curl_multi_add_handle($multiHandle, $curlHandles[$i]);
}
$running = null;
do {
curl_multi_exec($multiHandle, $running);
} while ($running);
for ($i = 0; $i < 10; $i++) {
curl_multi_remove_handle($multiHandle, $curlHandles[$i]);
$responses[] = curl_multi_getcontent($curlHandles[$i]);
}
curl_multi_close($multiHandle);
$time = microtime(true) - $start;
echo 'Time elapsed: ' . $time . PHP_EOL;
echo memory_get_usage() . ' bytes' . PHP_EOL;
执行代码后,我们现在得到了以下结果:
运行非阻塞代码脚本时经过的时间和消耗的内存
正如预期的那样,PHP 脚本更快,因为它不再需要等待 I/O 调用完成后才能继续执行其余的代码。实际上,在幕后发生的是在同一线程内的多任务处理。事实上,代码的执行流程实际上被中断,以允许许多 I/O 调用的并发执行。这是由于非阻塞代码会在等待某个任务完成时将控制权交还给调用者代码,并在完成时可能调用回调函数。如果我们使用Blackfire.io
对先前的代码进行性能分析,我们将看到这种循环的执行——为了完成所有 10 个请求,yielding 函数实际上被调用了 45000 多次:
为了完成所有 10 个 cURL 请求,yielding 函数被调用了 45000 多次
在 PHP 5.5 中引入的生成器允许代码的不同部分似乎同时执行,从而更容易进行异步编程。生成器实际上是一个实现了迭代器接口的可调用对象。其基本原则是有一个循环,将重复调用一个生成器函数,然后将控制权交还给循环,直到没有东西可处理为止,此时生成器函数将返回。
现在,让我们通过一个简单的代码示例深入了解异步编程。为此,让我们使用以下代码编写一个基本的汽车比赛:
// chap4_async_race.php
$laps[] = 0;
$laps[] = 0;
$laps[] = 0;
function car1(int &$lap) {
while ($lap <= 10) {
for ($x = 0; $x <= 200; $x++) {
yield 0;
}
yield 1;
}
// If the car has finished its race, return null in order to remove the car from the race
return;
}
function car2(int &$lap) {
while ($lap <= 10) {
for ($x = 0; $x <= 220; $x++) {
yield 0;
}
yield 1;
}
// If the car has finished its race, return null in order to remove the car from the race
return;
}
function car3(int &$lap) {
while ($lap <= 10) {
for ($x = 0; $x <= 230; $x++) {
yield 0;
}
yield 1;
}
// If the car has finished its race, return null in order to remove the car from the race
return;
}
function runner(array $cars, array &$laps) {
$flag = FALSE;
while (TRUE) {
foreach ($cars as $key => $car) {
$penalty = rand(0, 8);
if($key == $penalty) {
// We must advance the car pointer in order to truly apply the penalty
to the "current" car
$car->next();
} else {
// Check if the "current" car pointer points to an active race car
if($car->current() !== NULL) {
// Check if the "current" car pointer points to a car that has
completed a lap
if($car->current() == 1) {
$lapNumber = $laps[$key]++;
$carNumber = $key + 1;
if ($lapNumber == 10 && $flag === FALSE) {
echo "*** Car $carNumber IS THE WINNER! ***\n";
$flag = TRUE;
} else {
echo "Car $carNumber has completed lap $lapNumber\n";
}
}
// Advance the car pointer
$car->next();
// If the next car is no longer active, remove the car from the
race
if (!$car->valid()) {
unset($cars[$key]);
}
}
}
}
// No active cars left! The race is over!
if (empty($cars)) return;
}
}
runner(array(car1($laps[0]), car2($laps[1]), car3($laps[2])), $laps);
正如你所看到的,主循环中的 runner 函数以随机顺序处理三个生成器函数,直到它们没有任何东西可处理为止。最终结果是,我们永远不知道哪辆车会赢得比赛,尽管其中一些车似乎比其他车快!让我们运行这段代码三次。以下是第一次运行的结果:
汽车 2 赢得了比赛!
以下是第二次运行的结果:
汽车 3 赢得了比赛!
以下是第三次也是最后一次运行的结果:
汽车 1 赢得了比赛!
最终结果是似乎在同一线程内同时执行三个不同函数。这正是异步编程的基本原则。事实上,很容易理解多任务处理是如何被用来帮助减轻单个 PHP 脚本的重负,通过中断脚本的执行来使用第三方软件(如 RabbitMQ 和 Redis)排队一些任务,从而延迟处理这些任务,直到适当的时候。
现在我们已经看过了多任务处理,让我们来看看多线程处理。
使用 pthreads 进行多线程
POSIX Threads
,更为人所知的是pthreads
,是一个允许计算机程序通过从其父进程分叉子进程来同时执行多个进程或线程的库。pthreads
库可以在 PHP 中使用,因此可以在执行其他操作的同时在后台分叉进程。因此,多线程是另一种处理 I/O 调用延迟的方法。为了实现这一点,我们需要一个带有pthreads
扩展启用的线程安全版本的 PHP。在我们的情况下,我们将使用运行Zend 线程安全(ZTS)版本的 PHP 7.0.29 的 Linux for PHP 容器。打开一个新的终端窗口,cd
到项目的目录,并输入以下命令:
# docker run -it --rm \
> -p 8282:80 \
> -v ${PWD}/:/srv/fasterweb \
> asclinux/linuxforphp-8.1:7.0.29-zts \
> /bin/bash
输入此命令后,如果在 CLI 中输入php -v
命令,您应该会看到以下信息:
ZTS 容器的命令行界面(CLI)
这条消息确认我们正在使用线程安全(ZTS)版本的 PHP。然后,在容器的 CLI 中,输入这些命令:
# mv /srv/www /srv/www.OLD
# ln -s /srv/fasterweb/chapter_4 /srv/www
# cd /srv/www
# pecl install pthreads
# echo "extension=pthreads.so" >> /etc/php.ini
您现在可以通过输入命令php -i
来检查pthreads
扩展是否已正确安装。最后一个命令应该让您看到扩展的版本号。如果是这样,那么扩展已正确安装:
pthread 扩展的 3.1.6 版本现已安装
现在pthreads
库已安装并启用,让我们继续使用它,尝试在计算机的 CPU 上创建多个线程,这些线程将真正同时执行。为此,我们将使用以下源代码:
// chap4_pthreads.php
$start = microtime(true);
class TestThreads extends Thread {
protected $arg;
public function __construct($arg) {
$this->arg = $arg;
}
public function run() {
if ($this->arg) {
$sleep = mt_rand(1, 10);
printf('%s: %s -start -sleeps %d' . "\n", date("g:i:sa"), $this->arg,
$sleep);
sleep($sleep);
printf('%s: %s -finish' . "\n", date("g:i:sa"), $this->arg);
}
}
}
$stack = array();
// Create Multiple Thread
foreach ( range('1', '9') as $id ) {
$stack[] = new TestThreads($id);
}
// Execute threads
foreach ( $stack as $thread ) {
$thread->start();
}
sleep(1);
$time = microtime(true) - $start;
echo 'Time elapsed: ' . $time . PHP_EOL;
echo memory_get_usage() . ' bytes' . PHP_EOL;
执行后,我们获得以下输出:
线程同时执行
结果清楚地表明,线程是同时执行的,因为脚本的总经过时间为 10 秒,即使每个线程至少睡了几秒。如果没有使用多线程执行此同步阻塞代码,完成执行可能需要大约 40 秒。在这种情况下,多任务处理不是一个合适的解决方案,因为对sleep()
函数的阻塞调用将阻止每个生成器将控制权让给主循环。
现在我们已经看到了通过异步编程进行多任务处理和通过POSIX Threads
库进行多线程处理,我们将把注意力转向一个在编程异步时非常有用的 PHP 库,即ReactPHP
库。
使用 ReactPHP 库
ReactPHP
是一个事件驱动的、非阻塞 I/O 库。这个库基本上依赖于一个事件循环,它轮询文件描述符,使用定时器,并通过在每次循环迭代中注册和执行未完成的 tick 来推迟回调。
ReactPHP
基于 Reactor 模式,根据 Douglas C. Schmidt 的说法,Reactor 模式是“一种处理一个或多个客户端并发传递给应用程序的服务请求的设计模式。应用程序中的每个服务可能由多个方法组成,并由一个单独的事件处理程序表示,负责分派特定于服务的请求。事件处理程序的分派由一个初始化分派器执行,该分派器管理注册的事件处理程序。服务请求的多路复用由同步事件多路复用器执行。”在 Schmidt 的原始论文Reactor: An Object Behavioral Pattern for Demultiplexing and Dispatching Handles for Synchronous Events中,我们可以找到这种模式的 UML 表示:
根据 Douglas C. Schmidt 的说法,Reactor 模式
让我们通过在我们的代码库中安装它来开始探索这个异步编程库。在容器的 CLI 中,输入以下命令:
# cd /srv/www/react
# php composer.phar self-update
# php composer.phar install
# cd examples
一旦库通过 Composer 安装,你可以尝试 examples 目录中找到的任何示例脚本。这些代码示例来自ReactPHP的主代码库。在我们的例子中,我们将首先看一下parallel-download.php
脚本。以下是它的源代码:
// parallel-download.php
$start = microtime(true);
// downloading the two best technologies ever in parallel
require __DIR__
. DIRECTORY_SEPARATOR
.'..'
. DIRECTORY_SEPARATOR
. 'vendor'
. DIRECTORY_SEPARATOR
.'autoload.php';
$loop = React\EventLoop\Factory::create();
$files = array(
'node-v0.6.18.tar.gz' => 'http://nodejs.org/dist/v0.6.18/node-v0.6.18.tar.gz',
'php-5.5.15.tar.gz' => 'http://it.php.net/get/php-5.5.15.tar.gz/from/this/mirror',
);
foreach ($files as $file => $url) {
$readStream = fopen($url, 'r');
$writeStream = fopen($file, 'w');
stream_set_blocking($readStream, 0);
stream_set_blocking($writeStream, 0);
$read = new React\Stream\Stream($readStream, $loop);
$write = new React\Stream\Stream($writeStream, $loop);
$read->on('end', function () use ($file, &$files) {
unset($files[$file]);
echo "Finished downloading $file\n";
});
$read->pipe($write);
}
$loop->addPeriodicTimer(5, function ($timer) use (&$files) {
if (0 === count($files)) {
$timer->cancel();
}
foreach ($files as $file => $url) {
$mbytes = filesize($file) / (1024 * 1024);
$formatted = number_format($mbytes, 3);
echo "$file: $formatted MiB\n";
}
});
echo "This script will show the download status every 5 seconds.\n";
$loop->run();
$time = microtime(true) - $start;
echo 'Time elapsed: ' . $time . PHP_EOL;
echo memory_get_usage() . ' bytes' . PHP_EOL;
基本上,这个脚本创建了两个流,将它们设置为非阻塞模式,并将这些流注册到循环中。定时器被添加到循环中,以便每 5 秒回显一条消息。最后,它运行了循环。
让我们通过以下命令来看一下这个脚本的运行情况:
# php parallel-download.php
以下是结果:
这两个包是异步下载的
正如你所看到的,下载是以并行、异步和反应式的方式执行的。
让我们继续通过在代码示例中包含的tcp-chat.php
脚本来继续我们对 ReactPHP 世界的短暂旅程。以下是这个代码示例的源代码:
// tcp-chat.php
// socket based chat
require __DIR__
. DIRECTORY_SEPARATOR
.'..'
. DIRECTORY_SEPARATOR
. 'vendor'
. DIRECTORY_SEPARATOR
.'autoload.php';
$loop = React\EventLoop\Factory::create();
$socket = new React\Socket\Server($loop);
$conns = new \SplObjectStorage();
$socket->on('connection', function ($conn) use ($conns) {
$conns->attach($conn);
$conn->on('data', function ($data) use ($conns, $conn) {
foreach ($conns as $current) {
if ($conn === $current) {
continue;
}
$current->write($conn->getRemoteAddress().': ');
$current->write($data);
}
});
$conn->on('end', function () use ($conns, $conn) {
$conns->detach($conn);
});
});
echo "Socket server listening on port 4000.\n";
echo "You can connect to it by running: telnet localhost 4000\n";
$socket->listen(4000);
$loop->run();
该脚本创建了一个在 4000 端口监听的套接字服务器,并通过监听连接事件被循环通知有新连接。在收到事件通知后,套接字服务器将连接对象注入处理程序。连接对象然后开始监听数据事件,这将触发它对从套接字服务器客户端接收的数据进行处理。在这个聊天脚本的情况下,连接对象将触发SplObjectStorage
对象中所有注册连接对象的写入方法,从而有效地将消息发送给当前连接的所有聊天客户端。
首先,通过运行脚本启动聊天服务器:
# php tcp-chat.php
然后,打开三个新的终端窗口,并通过在每个窗口中输入以下命令来连接到我们的Linux for PHP Docker容器:
# **docker exec -it $( docker ps -q | awk '{ print $1 }' ) /bin/bash**
在每个容器的 CLI 中,输入以下命令:
# telnet localhost 4000
通过telnet
连接后,只需在一个终端窗口和另一个终端窗口之间来回发送消息,玩得开心:
从一个终端窗口向其他终端窗口发送消息
显然,通过同一个容器内的终端窗口所做的工作也可以通过网络连接到不同计算机上的终端窗口来完成。这个例子向我们展示了异步编程有多么强大。
让我们通过查看scalability.php
脚本来完成我们对ReactPHP代码示例的调查。以下是它的源代码:
// scalability.php
// a simple, single-process, horizontal scalable http server listening on 10 ports
require __DIR__
. DIRECTORY_SEPARATOR
.'..'
. DIRECTORY_SEPARATOR
. 'vendor'
. DIRECTORY_SEPARATOR
.'autoload.php';
$loop = React\EventLoop\Factory::create();
for ($i = 0; $i < 10; ++$i) {
$s = stream_socket_server('tcp://127.0.0.1:' . (8000 + $i));
$loop->addReadStream($s, function ($s) use ($i) {
$c = stream_socket_accept($s);
$len = strlen($i) + 4;
fwrite($c,"HTTP/1.1 200 OK\r\nContent-Length: $len\r\n\r\nHi:$i\n");
echo "Served on port 800$i\n";
});
}
echo "Access your brand new HTTP server on 127.0.0.1:800x. Replace x with any number from 0-9\n";
$loop->run();
该脚本创建了一个套接字服务器,然后将其附加到主事件循环中,以便在向服务器发送请求时调用一个 lambda 函数。然后,lambda 函数执行将答复发送回客户端的代码,通过将其写入接受的流套接字。
让我们通过以下命令运行这段代码:
# php scalability.php
然后,打开另一个终端窗口,并将其连接到我们的Linux for PHP Docker容器:
# **docker exec -it $( docker ps -q | awk '{ print $1 }' ) /bin/bash**
然后,使用wget
查询服务器:
# wget -nv -O - http://localhost:8000
# wget -nv -O - http://localhost:8001
# wget -nv -O - http://localhost:8002
# wget -nv -O - http://localhost:8003
# wget -nv -O - http://localhost:8004
# wget -nv -O - http://localhost:8005
# wget -nv -O - http://localhost:8006
# wget -nv -O - http://localhost:8007
# wget -nv -O - http://localhost:8008
# wget -nv -O - http://localhost:8009
完成后,你应该得到每个请求的以下响应:
连接到 Web 服务器的每个可用端口
这是你在服务器端应该看到的:
服务器确认已在所有这些端口上为所有这些请求提供服务
再次,你可以看到ReactPHP有多么强大,只需几行代码就足以创建一个可扩展的 Web 服务器。
此外,我们强烈建议探索并尝试我们存储库中包含的ReactPHP项目的所有文件,这样你就可以充分体会到这个库在异步编程方面为开发者能做些什么。
此外,还有其他出色的异步 PHP 库可以帮助您掌握这种新的开发方式,并加速高延迟 I/O 应用程序。其中一个这样的库是Amp(amphp.org/
)。在掌握异步编程艺术的过程中,探索这些非常有用的库是非常值得的。
最后,要了解有关 PHP 异步编程的更多信息,您可以听Christopher Pitt在Nomad PHP上关于这个主题的精彩演讲(nomadphp.com/asynchronous-php/
)。
总结
在本章中,我们学习了如何确定应对 I/O 调用的最佳策略以及如何实施这些策略。此外,我们还了解了如何使用ReactPHP
库以及在处理异步 I/O 调用时如何从事件驱动编程中获益。
在下一章中,我们将学习如何测量数据库性能,从应用简单的测量技术到使用高级基准测试工具。
第五章:测量和优化数据库性能
在本书的第一章中,我们使用 mysqlslap 工具学习了如何进行基本的 MySQL 基准测试。在本章中,我们将使用这个工具和其他工具来对我们的 MariaDB(MySQL)服务器进行更高级的基准测试。但首先,我们将学习查询优化技术,这些技术将使用 MySQL 的一些内置功能,以便更好地分析我们的 SQL 查询。
因此,我们将学习如何通过使用简单的测量技术来测量和优化数据库性能,例如查询优化。此外,我们将看到如何使用高级数据库基准测试工具,如 DBT2 和 SysBench。
因此,我们将涵盖以下几点:
-
测量和优化 SQL 查询性能
-
安装、配置和使用高级数据库基准测试工具
SQL 查询性能
为了更好地理解 SQL 查询性能,我们必须首先了解索引是什么以及它们是如何构建的。
索引的结构
索引是表元素的有序列表。这些元素首先存储在物理上无序的双向链表中。该列表通过指向表条目和存储索引值的第二个结构的指针双向链接到表,以逻辑顺序、平衡树或 b 树存储索引值。因此,索引具有对数算法复杂度,平均读操作为 O(log n),这意味着即使表中有大量条目,数据库引擎也应该保持速度。实际上,索引查找涉及三个步骤:
-
树遍历
-
搜索叶节点链
-
从表中获取数据
因此,当仅从 b 树中读取时,索引查找是很好的,因为你避免了线性的 O(n)完整表扫描。尽管如此,你永远无法避免由于在写入表时保持索引最新而引起的开销复杂性。
这带我们来到了关于查询优化的第一个考虑因素:表的数据的最终目的是什么?我们只是记录信息还是存储用户的购物车商品?我们查询的表大多是读取还是写入?这很重要,因为优化一个 SELECT 查询可能会减慢对同一表的整个系列其他 INSERT 或 UPDATE 查询的速度。
第二个考虑因素是表的数据的性质。例如,我们是否试图索引生成等价性的值,从而迫使数据库引擎在 b 树的叶节点中进行进一步查找,以确定真正满足特定查询期望的所有值?当等价性是一个问题时,我们可能会得到一个“慢索引”或者通常被称为“退化索引”。
第三个考虑因素是围绕查询表的效率的经济性。底层计算机有多强大?平均有多少用户在给定时间查询表?可伸缩性重要吗?
最后一个考虑因素是数据的存储大小。重要的是要知道,一般规则是,索引的大小平均增长到原始表大小的约 10%。因此,当表的大小很大时,预计表的索引也会更大。当然,索引越大,由于 I/O 延迟,等待的时间就越长。
这些考虑因素将决定要优化哪个查询以及如何优化它。有时,优化就是什么都不做。
现在我们更好地理解了索引,让我们开始分析简单的 SQL 查询,以了解数据库引擎执行计划。
执行计划
我们将通过分析简单的WHERE
子句来开始理解执行计划。为此,我们将使用我们的第一个 Linux for PHP Docker 容器。在第一章中,我们将 Sakila 数据库加载到 MariaDB(MySQL)服务器中。现在我们将使用它来学习执行计划的工作原理以及何时使用查询优化技术。在容器的 CLI 上,输入以下命令:
# mysql -uroot
# MariaDB > USE sakila;
# MariaDB > SELECT * FROM actor WHERE first_name = 'AL';
这些命令应该产生以下结果:
SELECT 语句的结果
乍一看,这个查询似乎很好,执行时间为 0.00 秒。但是,这真的是这样吗?要进一步分析这个查询,我们需要查看数据库引擎的执行计划。为此,在查询开头输入关键字EXPLAIN
:
# MariaDB > EXPLAIN SELECT * FROM actor WHERE first_name = 'AL';
以下结果为我们提供了一些执行计划的信息:
相同 SELECT 语句的执行计划
让我们花时间来定义结果集的每一列:
-
id
列告诉我们表的连接顺序。在这种情况下,只有一个表。 -
select_type
是SIMPLE
,这意味着执行此查询时没有子查询、联合或依赖查询类型。 -
table
列给出了查询对象的表的名称。如果它是一个临时物化表,我们会在这一列看到表达式<subquery#>
。 -
type
列对于查询优化非常重要。它提供了关于表访问以及如何从表中找到和检索行的信息。在这种情况下,我们看到这一列的值是ALL
,这是一个警告信号。要进一步了解这个非常重要的列的不同可能值,请参阅 MariaDB 手册mariadb.com/kb/en/library/explain/
。 -
possible_keys
列通知我们表中可以用来回答查询的键。在这个例子中,值为NULL
。 -
key
列指示实际使用的键。在这里,值再次为NULL
。 -
key_len
列中的值表示完成查询查找所使用的多列键的特定字节数。 -
ref
列告诉我们用于与使用的索引进行比较的列或常量。当然,由于没有使用索引来执行此查询,因此这一列的值也是NULL
。 -
rows
列表示数据库引擎需要检查的行数以完成其执行计划。在这个例子中,引擎需要检查 200 行。如果表很大并且必须与前一个表连接,性能会迅速下降。 -
最后一列是
Extra
列。这一列将为我们提供有关执行计划的更多信息。在这个例子中,数据库引擎使用WHERE
子句,因为它必须进行全表扫描。
基本查询优化
为了开始优化这个查询,我们必须经历我之前所说的查询优化的初始考虑。举例来说,让我们假设这个表将成为READ
查询的对象,而不是WRITE
查询,因为一旦写入表中,数据将保持相当静态。此外,重要的是要注意,在actor
表的first_name
列上创建索引将使索引容易产生由于该列中的非唯一值而产生的模糊性。此外,让我们假设可伸缩性很重要,因为我们打算每小时让许多用户查询这个表,并且表的大小应该在长期内保持可管理。
鉴于这一点,我们将在actor
表的first_name
列上创建一个索引:
# MariaDB > CREATE INDEX idx_first_name ON actor(first_name);
完成后,MariaDB 确认了索引的创建:
确认索引已创建
现在索引已创建,当我们要求数据库引擎“解释”其执行计划时,我们得到了这个结果:
执行计划现在已经优化
type
列的值现在是ref
,possible_keys
是idx_first_name
,key
是idx_first_name
,ref
是const
,rows
是1
,Extra
是Using index condition
。正如我们所看到的,引擎现在已经将我们新创建的索引识别为可能使用的关键,并继续使用它。它使用查询中给定的常量值在索引中执行查找,并在访问表时只考虑一行。所有这些都很好,但正如我们在最初的考虑中所期望的那样,索引由非唯一值组成。表列的值之间可能的等价性可能会导致随着时间的推移出现退化的索引,因此访问类型为ref
,额外信息指示引擎正在“使用索引条件”,这意味着WHERE
子句被推送到表引擎以在索引级别进行优化。在这个例子中,根据我们最初的考虑,这是在绝对意义上我们可以做的最佳查询优化,因为在“actor”表的first_name
列中不可能获得唯一值。但实际上,根据领域使用情况,可能存在一种优化。如果我们只希望使用演员的名字,那么我们可以通过只选择适当的列来进一步优化Extra
列中的Using index condition
,从而允许数据库引擎只访问索引:
# MariaDB > EXPLAIN SELECT first_name FROM actor WHERE first_name = 'AL';
数据库引擎随后确认它只在Extra
列中使用索引:
“Extra”列现在包含信息“使用 where; 使用索引”
这一切又如何转化为整体性能呢?让我们运行一些基准测试,以衡量我们的更改的影响。
首先,我们将在没有索引的情况下运行基准测试。在容器的 CLI 上运行以下命令:
# mysqlslap --user=root --host=localhost --concurrency=1000 --number-of-queries=10000 --create-schema=sakila --query="SELECT * FROM actor WHERE first_name = 'AL';" --delimiter=";" --verbose --iterations=2 --debug-info;
以下是不使用索引的结果:
不使用索引的基准测试结果
以及,使用索引的结果:
使用索引的基准测试结果
最后,让我们只选择适当的列运行相同的命令,从而将查找限制为仅使用索引:
# mysqlslap --user=root --host=localhost --concurrency=1000 --number-of-queries=10000 --create-schema=sakila --query="SELECT first_name FROM actor WHERE first_name = 'AL';" --delimiter=";" --verbose --iterations=2 --debug-info;
这是最后一个基准测试的结果:
使用索引的基准测试结果
基准测试结果清楚地显示,我们的查询优化确实满足了我们最初的可扩展性假设,特别是如果我们看到表的大小增长,随着时间的推移,我们的数据库变得更受欢迎,用户数量也在增长。
性能模式和高级查询优化
查询优化的艺术可以通过使用 MariaDB(MySQL)的性能模式来进一步推进。查询分析允许我们看到发生在幕后的情况,并进一步优化复杂的查询。
首先,让我们在数据库服务器上启用性能模式。为此,请在 Linux 的 PHP 容器的 CLI 上输入以下命令:
# sed -i '/myisam_sort_buffer_size =/a performance_schema = ON' /etc/mysql/my.cnf
# sed -i '/performance_schema =/a performance-schema-instrument = "stage/%=ON"' /etc/mysql/my.cnf
# sed -i '/performance-schema-instrument =/a performance-schema-consumer-events-stages-current = ON' /etc/mysql/my.cnf
# sed -i '/performance-schema-consumer-events-stages-current =/a performance-schema-consumer-events-stages-history = ON' /etc/mysql/my.cnf
# sed -i '/performance-schema-consumer-events-stages-history =/a performance-schema-consumer-events-stages-history-long = ON' /etc/mysql/my.cnf
# /etc/init.d/mysql restart
# mysql -uroot
# MariaDB > USE performance_schema;
# MariaDB > UPDATE setup_instruments SET ENABLED = 'YES', TIMED = 'YES';
# MariaDB > UPDATE setup_consumers SET ENABLED = 'YES';
数据库引擎将确认在performance_schema
数据库中已修改了一些行:
“performance_schema”数据库已被修改
我们现在可以检查性能模式是否已启用:
# MariaDB > SHOW VARIABLES LIKE 'performance_schema';
数据库引擎应返回以下结果:
确认性能模式现已启用
现在,性能分析已经启用并准备就绪,让我们在 Sakila 数据库上运行一个复杂的查询。在使用NOT IN
子句的子查询时,引擎通常会迭代地对主查询进行额外的检查。这些查询可以使用JOIN
语句进行优化。我们将使用以下查询在我们的数据库服务器上运行:
# MariaDB > SELECT film.film_id
> FROM film
> WHERE film.rating = 'G'
> AND film.film_id NOT IN (
> SELECT film.film_id
> FROM rental
> LEFT JOIN inventory ON rental.inventory_id = inventory.inventory_id
> LEFT JOIN film ON inventory.film_id = film.film_id
> );
运行查询会产生以下结果:
SELECT 语句的结果
并且,在上一个查询上使用EXPLAIN
语句时的结果如下:
同一 SELECT 语句的执行计划
正如我们所看到的,引擎正在进行全表扫描,并使用一个物化子查询来完成其查找。要了解底层发生了什么,我们将不得不查看分析器记录的有关此查询的事件。为此,请输入以下查询:
# MariaDB > SELECT EVENT_ID, TRUNCATE(TIMER_WAIT/1000000000000,6) as Duration, SQL_TEXT
> FROM performance_schema.events_statements_history_long WHERE SQL_TEXT like
'%NOT IN%';
运行此查询后,您将获得原始查询的唯一标识符:
原始查询的标识符
这些信息允许我们运行以下查询,以获取运行原始查询时发生的底层事件列表:
# MariaDB > SELECT event_name AS Stage, TRUNCATE(TIMER_WAIT/1000000000000,6) AS Duration
> FROM performance_schema.events_stages_history_long WHERE NESTING_EVENT_ID=43;
这是 MariaDB 性能模式中关于我们原始查询的内容:
查询的概要显示了一个特别长的操作
这个结果显示NOT IN
子句导致数据库引擎创建了一个物化子查询,因为内部查询被优化为半连接子查询。因此,在运行查询和物化子查询之前,引擎必须进行一些优化操作。此外,结果显示物化子查询是最昂贵的操作。
优化这些子查询的最简单方法是将它们替换为主查询中的适当JOIN
语句,如下所示:
# MariaDB > SELECT film.film_id
# > FROM rental
# > INNER JOIN inventory ON rental.inventory_id = inventory.inventory_id
# > RIGHT JOIN film ON inventory.film_id = film.film_id
# > WHERE film.rating = 'G'
# > AND rental.rental_id IS NULL
# > GROUP BY film.film_id;
通过运行此查询,我们从数据库中获得相同的结果,但是EXPLAIN
语句揭示了一个全新的执行计划,以获得完全相同的结果:
新的执行计划只显示了“SIMPLE”选择类型
子查询已经消失,变成了简单的查询。让我们看看性能模式这次记录了什么:
# MariaDB > SELECT EVENT_ID, TRUNCATE(TIMER_WAIT/1000000000000,6) as Duration, SQL_TEXT
> FROM performance_schema.events_statements_history_long WHERE SQL_TEXT like '%GROUP BY%';
# MariaDB > SELECT event_name AS Stage, TRUNCATE(TIMER_WAIT/1000000000000,6) AS Duration
> FROM performance_schema.events_stages_history_long WHERE NESTING_EVENT_ID=22717;
分析器记录了以下结果:
新查询的概要显示了相当大的性能改进
结果清楚地显示,在执行计划的初始化阶段发生的优化操作较少,并且查询执行本身大约快了七倍。并非所有物化子查询都可以以这种方式进行优化,但是,在优化查询时,物化子查询、依赖子查询或不可缓存的子查询应该总是激励我们问自己是否可以做得更好。
有关查询优化的更多信息,您可以收听 Michael Moussa 在Nomad PHP上关于此主题的出色演示(nomadphp.com/product/mysql-analysis-understanding-optimization-queries/
)。
高级基准测试工具
到目前为止,我们使用了mysqlslap
基准测试工具。但是,如果您需要更彻底地测试数据库服务器,还存在其他更高级的基准测试工具。我们将简要介绍其中两个工具:DBT2 和 SysBench。
DBT2
这个基准测试工具用于针对 MySQL 服务器运行自动化基准测试。它允许您模拟大量的数据仓库。
要下载、编译和安装 DBT2,请在容器的 CLI 上输入以下命令:
# cd /srv/www
# wget -O dbt2-0.37.tar.gz https://master.dl.sourceforge.net/project/osdldbt/dbt2/0.37/dbt2-0.37.tar.gz
# tar -xvf dbt2-0.37.tar.gz
# cd dbt2-0.37.tar.gz
# ./configure --with-mysql
# make
# make install
# cpan install Statistics::Descriptive
# mkdir -p /srv/mysql/dbt2-tmp-data/dbt2-w3
# ./src/datagen -w 3 -d /srv/mysql/dbt2-tmp-data/dbt2-w3 --mysql
一旦数据仓库被创建,您应该看到以下消息:
确认数据库仓库已经创建。
现在,您需要使用 vi 编辑器修改文件scripts/mysql/mysql_load_db.sh
:
# vi scripts/mysql/mysql_load_db.sh
进入编辑器后,输入/LOAD DATA
并按Enter。将光标定位在此行的末尾,按*I*
并输入大写的IGNORE
。编辑完成后,您的文件应该是这样的:
在'mysql_load_db.sh'脚本的'LOAD DATA'行上插入字符串"Ignore"
完成后,按Esc键,然后输入:wq
。这将保存更改并关闭 vi 编辑器。
现在,输入以下命令将测试数据加载到数据库中:
# ./scripts/mysql/mysql_load_db.sh -d dbt2 -f /srv/mysql/dbt2-tmp-data/dbt2-w3 -s /run/mysqld/mysqld.sock -u root
一旦数据加载到数据库中,您应该会看到以下消息:
确认数据正在加载到数据库中
要启动测试,输入以下命令:
# ./scripts/run_mysql.sh -n dbt2 -o /run/mysqld/mysqld.sock -u root -w 3 -t 300 -c 20
输入命令后,您将首先看到这条消息:
确认测试已开始
您还会收到以下消息:
确认测试正在运行
大约五分钟后,您将得到基准测试的结果:
确认测试已完成
从给定的结果中,我们可以看到在大型数据仓库的背景下,我们可以对数据库服务器的性能有一个很好的了解。通过边缘案例测试,额外的测试可以轻松确认服务器的极限。让我们使用 SysBench 运行这样的测试。
SysBench
SysBench 是另一个非常流行的开源基准测试工具。这个工具不仅允许您测试开源 RDBMS,还可以测试您的硬件(CPU、I/O 等)。
要下载、编译和安装 SysBench,请在 Linux for PHP Docker 容器中输入以下命令:
# cd /srv/www
# wget -O sysbench-0.4.12.14.tar.gz https://downloads.mysql.com/source/sysbench-0.4.12.14.tar.gz
# tar -xvf sysbench-0.4.12.14.tar.gz
# cd sysbench-0.4.12.14
# ./configure
# make
# make install
现在,输入以下命令将创建一个包含 100 万行的表作为测试数据加载到数据库中:
# sysbench --test=oltp --oltp-table-size=1000000 --mysql-db=test --mysql-user=root prepare
一旦数据加载到数据库中,您应该会看到以下消息:
确认测试数据已加载到数据库中
现在,运行测试,输入以下命令:
# sysbench --test=oltp --oltp-table-size=1000000 --mysql-db=test --mysql-user=root --max-time=60 --oltp-read-only=on --max-requests=0 --num-threads=8 run
输入上一个命令后,您将首先收到以下消息:
确认测试正在运行
几分钟后,您应该会得到类似于以下结果:
SysBench 测试结果
结果显示,我的计算机上的 MariaDB 服务器大约可以处理每秒约 2300 次事务和每秒约 33000 次读/写请求。这些边缘案例测试可以让我们对硬件和数据库服务器的一般性能水平有一个很好的了解。
摘要
在本章中,我们已经学会了如何通过简单的测量技术(如查询优化)来衡量和优化数据库性能。此外,我们还看到了如何使用高级数据库基准测试工具,如 DBT2 和 SysBench。
在下一章中,我们将看到如何使用现代 SQL 技术来优化非常复杂的 SQL 查询。
第六章:高效查询现代 SQL 数据库
现在,我们将学习如何使用现代 SQL 高效地查询 SQL 数据库。在本章中,我们将定义现代 SQL 是什么以及如何使用它。我们将从定义现代 SQL 的概念开始,了解它与传统 SQL 的区别,并描述许多其特点。因此,我们将了解如何将某些传统 SQL 查询转换为现代查询以及何时最好这样做。此外,通过这样做,我们将更好地了解现代 SQL 如何帮助我们以多种方式优化服务器性能。
因此,我们将涵盖以下内容:
-
了解现代 SQL 及其特点
-
学习如何使用
WITH
和WITH RECURSIVE
、CASE
、OVER AND PARTITION BY
、OVER AND ORDER BY
、GROUPING SETS、JSON 子句和函数、FILTER
和LATERAL
查询。
现代 SQL
现代 SQL 是什么,它与传统 SQL 有何不同?它的主要特点是什么?让我们从定义概念开始。
定义
正如 Markus Winand 在他的网站modern-sql.com
上所述,现代 SQL 可以被定义为“一种国际标准化、广泛可用且图灵完备的数据处理语言,支持关系和非关系数据模型。”这个定义指的是 ISO 和 ANSI 组织多年来推广的一系列标准,并为 SQL 编程语言添加了新功能。自 SQL-92 以来,SQL 标准的许多新版本被采纳,并且这些标准引入了许多基于关系和非关系模型的新功能。以下是这些功能的简要列表,以及确认它们被采纳到 SQL 语言中的相应标准:
-
WITH
和WITH RECURSIVE
(SQL:1999) -
CASE
(SQL:1999 和 SQL:2003) -
OVER AND PARTITION BY
(SQL:2003 和 SQL:2011) -
OVER AND ORDER BY
(SQL:2003 和 SQL:2011) -
GROUPING SETS(SQL:2011)
-
JSON 子句和函数(SQL:2016)
-
FILTER
(SQL:2003) -
LATERAL
查询(SQL:1999)
值得注意的是,大多数这些功能直到最近才被大多数关系数据库管理系统(RDBMS)实现。大多数 RDBMS 仅为其用户提供了基于老化的 SQL-92 标准所推广的关系模型的传统 SQL 语言。直到最近几年,许多 RDBMS 才开始实现现代 SQL 功能。
此外,让我们提出警告:使用这些功能不会立即为您的数据库服务器带来巨大的性能提升。那么,在您的代码库中使用这些功能的意义是什么?目的是使您的代码库与未来的数据库引擎优化兼容,并避免大多数与慢查询执行相关的问题。
但在进一步了解新的 SQL 功能之前,我们将在我们的 Linux 中为 PHP 容器安装phpMyAdmin
,以便以用户友好的方式查看我们查询的结果。为此,请在容器的 CLI 上输入以下命令:
# rm /srv/www
# ln -s /srv/fasterweb/chapter_6 /srv/www
# cd /srv
# wget -O phpMyAdmin-4.7.7-all-languages.zip https://files.phpmyadmin.net/phpMyAdmin/4.7.7/phpMyAdmin-4.7.7-all-languages.zip
# unzip phpMyAdmin-4.7.7-all-languages.zip
# cp phpMyAdmin-4.7.7-all-languages/config.sample.inc.php phpMyAdmin-4.7.7-all-languages/config.inc.php
# sed -i "s/AllowNoPassword'] = false/AllowNoPassword'] = true/" phpMyAdmin-4.7.7-all-languages/config.inc.php
# cd fasterweb/chapter_6
# ln -s ../../phpMyAdmin-4.7.7-all-languages ./phpmyadmin
这些命令应该可以让您通过 Web 界面访问数据库服务器,网址为http://localhost:8181/phpmyadmin
。在您喜欢的浏览器中访问此地址时,您应该看到以下屏幕:
在 phpMyAdmin 的登录页面上输入用户名和密码
安装phpMyAdmin
后,您可以使用用户名root
和空密码登录到数据库服务器。
现在,让我们更详细地了解每一个新的 SQL 功能。
WITH 和 WITH RECURSIVE
第一个功能是所谓的公共表达式(CTE)。CTE 是一个临时结果集,允许您多次将相同的数据连接到自身。有两种类型的 CTE:非递归(WITH
)和递归(WITH RECURSIVE
)。
非递归类型的 CTE 就像派生表一样,允许您从临时结果集中SELECT
。一个简单的例子,使用一个虚构的员工表,将是:
WITH accountants AS (
SELECT id, first_name, last_name
FROM staff
WHERE dept = 'accounting'
)
SELECT id, first_name, last_name
FROM accountants;
递归类型的 CTE 由两部分组成。查询的第一部分称为 CTE 的锚成员。锚的结果集被认为是基本结果集(T[0])。第二部分是递归成员,它将以 T[i]作为输入和 T[i+1]作为输出运行,直到返回一个空的结果集。查询的最终结果集将是递归结果集(T[n)和锚(T[0])之间的UNION ALL
。
为了更好地理解递归 CTE 以及它们可以有多么有用,让我们举个例子。但在开始之前,让我们先将以下表加载到测试数据库中。在容器的 CLI 中,输入此命令:
# mysql -uroot test < /srv/www/employees.sql
完成后,您可以通过使用phpMyAdmin
打开数据库来确保一切都加载正确,如下所示:
测试数据库中员工表中找到的所有行
为了更好地理解 CTE,我们将从使用具有多个连接的基本查询开始,以获得分层结果集。为了仅基于数据库中员工记录中经理 ID 的存在来获取员工的整个层次结构,我们必须考虑使用多个连接到同一表的查询。在 SQL 选项卡中,输入此查询:
SELECT CONCAT_WS('->', t1.last_name, t2.last_name, t3.last_name, t4.last_name, t5.last_name, t6.last_name) AS path
FROM employees AS t1
RIGHT JOIN employees AS t2 ON t2.superior = t1.id
RIGHT JOIN employees AS t3 ON t3.superior = t2.id
RIGHT JOIN employees AS t4 ON t4.superior = t3.id
RIGHT JOIN employees AS t5 ON t5.superior = t4.id
RIGHT JOIN employees AS t6 ON t6.superior = t5.id
WHERE t1.superior IS NULL
ORDER BY path;
您将获得这个结果集:
使用连接语句生成的所有员工的分层树
首先要注意的是,这个查询假设我们事先知道这个层次结构中的级别数量,这意味着我们之前做了一个查询来确认关于我们数据集的这个事实。第二件事是,为了检索整个结果集,必须重复JOIN
子句的笨拙。递归 CTE 是优化这种查询的完美方式。要使用递归 CTE 获得完全相同的结果集,我们必须运行以下查询:
WITH RECURSIVE hierarchy_list AS (
SELECT id, superior, CONVERT(last_name, CHAR(100)) AS path
FROM employees
WHERE superior IS NULL
UNION ALL
SELECT child.id, child.superior, CONVERT(CONCAT(parent.path, '->', child.last_name), CHAR(100)) AS path
FROM employees AS child
INNER JOIN hierarchy_list AS parent ON (child.superior = parent.id)
)
SELECT path
FROM hierarchy_list
ORDER BY path;
如果我们通过运行它们针对 MariaDB 的性能模式来比较前两个查询,即使它们不提供有关我们层次结构中级别动态发现的相同功能,我们也会更好地了解底层发生了什么。
首先,让我们使用EXPLAIN
语句运行多个连接查询:
MariaDB 的连接语句查询执行计划
现在来看一下 RDBMS 的性能模式:
多个连接导致数据库引擎中的 65 个操作
其次,让我们按照相同的步骤进行,但使用递归 CTE:
MariaDB 的递归 CTE 查询执行计划
而性能模式应该产生以下结果:
CTE 在数据库引擎中引起了 47 个操作
尽管这个递归 CTE 在我的电脑上比基本的多重连接查询慢了一点,但当所有选择的列都被索引时,它确实生成了更少的引擎操作。那么,为什么这更有效率呢?递归 CTE 将允许你避免创建存储过程或类似的东西,以便递归地发现你的层次树中的级别数量,例如。如果我们将这些操作添加到主查询中,多重连接查询肯定会慢得多。此外,递归 CTE 可能是一种派生表,不比视图快多少,比基本的多重连接查询稍慢一点,但在查询数据库时非常可扩展和有用,以便在休息时基于小的结果子集修改表内容,同时确保你更复杂的查询将免费受益于未来的引擎优化。此外,它将使你的开发周期更加高效,因为它将使你的代码对其他开发人员更易读,保持DRY(“不要重复自己”)。
让我们继续下一个特性,CASE
表达式。
案例
尽管CASE
表达式似乎让我们想起了诸如IF
、SWITCH
之类的命令式结构,但它仍不允许像这些命令式结构那样进行程序流控制,而是允许根据某些条件对值进行声明性评估。让我们看下面的例子,以更好地理解这个特性。
请在phpMyAdmin
界面的测试数据库的 SQL 选项卡中输入以下查询:
SELECT id, COUNT(*) as Total, COUNT(CASE WHEN superior IS NOT NULL THEN id END) as 'Number of superiors'
FROM employees
WHERE id = 2;
这个查询应该产生以下结果集:
包含 CASE 语句的查询结果集
正如结果所示,具有id
值为2
的行被从第二个COUNT
函数的输入中过滤掉,因为CASE
表达式应用了条件,即上级列必须不具有NULL
值才能计算 id 列。使用这个现代 SQL 的特性,在很大程度上不是为了提高性能,而是为了尽可能地避免存储过程和控制执行流程,同时保持代码清晰、易读和可维护。
OVER 和 PARTITION BY
OVER
和PARTITION BY
是窗口函数,允许对一组行进行计算。与聚合函数不同,窗口函数不会对结果进行分组。为了更好地理解这两个窗口函数,让我们花点时间在phpMyAdmin
的 Web 界面上运行以下查询:
SELECT DISTINCT superior AS manager_id, (SELECT last_name FROM employees WHERE id = manager_id) AS last_name, SUM(salary) OVER(PARTITION BY superior) AS 'payroll per manager'
FROM employees
WHERE superior IS NOT NULL
ORDER BY superior;
运行这个查询后,你应该看到以下结果:
每个经理的工资单列表
正如我们所看到的,结果集显示了每个经理的工资单列,而不是对结果进行分组。这就是为什么我们必须使用DISTINCT
语句,以避免对同一个经理出现多行。显然,窗口函数允许在当前行与某种关系的行子集上进行高效的查询和优化性能的聚合计算。
OVER 和 ORDER BY
OVER AND ORDER BY
窗口函数在对行子集进行排名、计算累积总数或简单地避免自连接时非常有用。
为了说明何时使用这个最有用的特性,我们将采用前面的例子,并通过执行这个查询来确定每个经理的每个工资单上薪水最高的员工:
SELECT id, last_name, salary, superior AS manager_id, (SELECT last_name FROM employees WHERE id = manager_id) AS manager_last_name, SUM(salary) OVER(PARTITION BY superior ORDER BY manager_last_name, salary DESC, id) AS payroll_per_manager
FROM employees
WHERE superior IS NOT NULL
ORDER BY manager_last_name, salary DESC, id;
执行这个查询将得到以下结果集:
每个经理的每个工资单上薪水最高的员工列表
返回的结果集使我们能够查看每个工资单的细分,并对每个子集中的每个员工进行排名。那么,允许我们获取关于这些数据子集的所有这些细节的底层执行计划是什么?答案是一个SIMPLE
查询!在我们的查询中,存在一个依赖子查询,但这是因为我们正在获取每个经理的姓氏,以使结果集更有趣。
在删除依赖子查询后,这将是生成的查询:
SELECT id, last_name, salary, superior AS manager_id, SUM(salary) OVER(PARTITION BY superior ORDER BY manager_id, salary DESC, id) AS payroll_per_manager
FROM employees
WHERE superior IS NOT NULL
ORDER BY manager_id, salary DESC, id;
这是相同查询版本的底层执行计划:
避免获取经理姓氏时,查询执行计划是简单的
通过在没有返回每个经理姓氏的依赖子查询的情况下运行查询,我们的查询执行计划的select_type
是SIMPLE
。这将产生一个高效的查询,未来易于维护。
GROUPING SETS
GROUPING SETS 使得可以在一个查询中应用多个GROUP BY
子句。此外,这一新功能引入了ROLLUP
的概念,它是结果集中添加的额外行,以先前返回的值的超级聚合形式给出结果的摘要。让我们在测试数据库的 employees 表中给出一个非常简单的例子。在phpMyAdmin
的 Web 界面中执行以下查询:
SELECT superior AS manager_id, SUM(salary)
FROM employees
WHERE superior IS NOT NULL
GROUP BY manager_id, salary;
执行后,您应该会看到这个结果:
GROUPING SETS 使得可以在一个查询中应用多个 GROUP BY 子句
多个GROUP BY
子句使我们能够快速查看每个经理监督下每个员工的工资。如果现在将ROLLUP
操作符添加到GROUP BY
子句中,我们将获得这个结果:
在 GROUP BY 子句中添加 ROLLUP 操作符后的结果集
ROLLUP
操作符添加了额外的行,其中包含每个子集和整个结果集的超级聚合结果。执行计划显示,底层的select_type
再次是SIMPLE
,而不是在此功能存在之前我们将使用UNION
操作符将多个查询合并。再次,现代 SQL 为我们提供了一个高度优化的查询,将在未来多年内保持高度可维护性。
JSON 子句和函数
SQL 语言的最新增强之一是 JSON 功能。这一系列新功能使得更容易从 SQL 本机函数中受益,将某些类型的非结构化和无模式数据(如 JSON 格式)以非常结构化和关系方式存储。这允许许多事情,例如对 JSON 文档中包含的某些 JSON 字段应用完整性约束,对某些 JSON 字段进行索引,轻松地将非结构化数据转换并返回为关系数据,反之亦然,并通过 SQL 事务的可靠性插入或更新非结构化数据。
为了充分欣赏这一系列新功能,让我们通过执行查询将一些数据插入测试数据库,将 JSON 数据转换为关系数据。
首先,请在容器的 CLI 上执行以下命令:
# mysql -uroot test < /srv/www/json_example.sql
新表加载到数据库后,您可以执行以下查询:
SELECT id,
JSON_VALUE(json, "$.name") AS name,
JSON_VALUE(json, "$.roles[0]") AS main_role,
JSON_VALUE(json, "$.active") AS active
FROM json_example
WHERE id = 1;
执行后,您应该会看到这个结果:
JSON 函数会自动将 JSON 数据转换为关系数据
正如我们所看到的,使用新的 JSON 函数将 JSON 非结构化数据转换为关系和结构化数据非常容易。将非结构化数据插入结构化数据库同样容易。此外,添加的约束将验证要插入的 JSON 字符串是否有效。为了验证这一功能,让我们尝试将无效的 JSON 数据插入我们的测试表中:
INSERT INTO `json_example` (`id`, `json`) VALUES (NULL, 'test');
尝试执行查询时,我们将收到以下错误消息:
JSON 约束确保要插入的 JSON 字符串是有效的
因此,现代 SQL 使得在 SQL 环境中轻松处理 JSON 格式的数据。这将极大地优化应用程序级别的性能,因为现在可以消除每次应用程序需要检索或存储 JSON 格式数据到关系数据库时都需要json_encode()
和json_decode()
的开销。
还有许多现代 SQL 功能,我们可以尝试更好地理解,但并非所有 RDBMS 都已实现,并且其中许多功能需要我们分析实现细节。我们将简单地看一下两个在 MariaDB 服务器中未实现但在 PostgreSQL 服务器中实现的功能。要启动和使用包含在 Linux for PHP 容器中的 PostgreSQL 服务器,请在容器的 CLI 上输入以下命令:
# /etc/init.d/postgresql start
# cd /srv
# wget --no-check-certificate -O phpPgAdmin-5.1.tar.gz https://superb-sea2.dl.sourceforge.net/project/phppgadmin/phpPgAdmin%20%5Bstable%5D/phpPgAdmin-5.1/phpPgAdmin-5.1.tar.gz
# tar -xvf phpPgAdmin-5.1.tar.gz
# sed -i "s/extra_login_security'] = true/extra_login_security'] = false/" phpPgAdmin-5.1/conf/config.inc.php
# cd fasterweb/chapter_6
# ln -s ../../phpPgAdmin-5.1 ./phppgadmin # cd /srv/www
输入这些命令后,您应该能够通过phpPgAdmin
Web 界面访问 PostgreSQL 服务器,网址为http://localhost:8181/phppgadmin
。将浏览器指向此地址,并点击屏幕右上角的服务器图标,以查看以下界面:
列出唯一可用的 PostgreSQL 服务器,并通过端口 5432 访问
在这里,点击页面中央的 PostgreSQL 链接,在登录页面上,将用户名输入为postgres
,密码留空:
在登录页面上,输入用户名'postgres',并将密码框留空
然后,点击“登录”按钮,您应该能够访问服务器:
服务器显示 postgres 作为唯一可用的数据库
最后,我们将创建一个数据库,以便学习如何使用本书中将要介绍的最后两个现代 SQL 功能。在phpPgAdmin
界面中,点击“创建数据库”链接,并填写表单以创建 test 数据库:
使用 template1 模板和 LATIN1 编码创建名为 test 的数据库
点击“创建”按钮,您将创建 test 数据库并将其与 postgres 数据库一起创建:
现在,服务器显示 test 数据库和 postgres 数据库
完成后,在容器的 CLI 上输入以下命令:
# su postgres
# psql test < sales.sql
# exit
现在我们准备尝试FILTER
子句。
FILTER
现代 SQL 的另一个非常有趣的功能是FILTER
子句。它可以将WHERE
子句添加到聚合函数中。让我们通过在phpPgAdmin
界面的 test 数据库的 SQL 选项卡中执行以下查询来尝试FILTER
子句:
SELECT
SUM(total) as total,
SUM(total) FILTER(WHERE closed IS TRUE) as transaction_complete,
year
FROM sales
GROUP BY year;
您应该会得到以下结果:
包含 FILTER 语句的查询结果集
FILTER
子句非常适合在查询的WHERE
子句中生成报表而不会增加太多开销。
此外,FILTER
子句非常适合数据透视表,其中按年和月进行分组更加复杂,因为必须生成一个跨越月份和年份的报表,这在两个不同的轴上(例如月份=x和年份=y)变得更加复杂。
让我们继续讨论最后一个现代 SQL 功能,LATERAL
查询。
LATERAL 查询
LATERAL
查询允许您在相关子查询中选择多个列和一行以上。在创建 Top-N 子查询并尝试将表函数连接在一起时,这非常有用,从而使得解除嵌套成为可能。然后,可以将LATERAL
查询视为一种 SQL foreach
循环。
让我们举一个简单的例子来说明LATERAL
查询是如何工作的。假设我们有两个假想的包含有关电影和演员数据的表:
SELECT
film.id,
film.title,
actor_bio.name,
actor_bio.biography
FROM film,
LATERAL (SELECT
actor.name,
actor.biography
FROM actor
WHERE actor.film_id = film.id) AS actor_bio;
正如我们所看到的,LATERAL
子查询从演员表中选择了多列(actor.name 和 actor.biography),同时仍然能够与电影表(film.id)相关联。许多优化,无论是性能优化还是代码可读性和可维护性,都成为了使用LATERAL
查询的真正可能性。
有关现代 SQL 的更多信息,我邀请您查阅 Markus Winand 的优秀网站(https://modern-sql.com),并收听 Elizabeth Smith 在 Nomad PHP 上关于这个主题的精彩演讲(https://nomadphp.com/product/modern-sql/)。
总结
在本章中,我们学习了如何使用现代 SQL 高效地查询 SQL 数据库。我们定义了现代 SQL 是什么,以及我们如何使用它。我们学会了如何将某些传统的 SQL 查询转换为现代查询,以及何时最好这样做。此外,通过这样做,我们现在更好地理解了现代 SQL 如何帮助我们以多种方式优化服务器的性能。
在下一章中,我们将介绍一些 JavaScript 的优点和缺点,特别是与代码效率和整体性能有关的部分,以及开发人员应该如何始终编写安全、可靠和高效的 JavaScript 代码,主要是通过避免“危险驱动开发”。
第七章:JavaScript 和危险驱动开发
“在 JavaScript 中,有一种美丽、优雅、高度表达的语言,被一堆良好意图和失误所掩盖。”
- Douglas Crockford,《JavaScript:精粹》
这段引语基本上表达了优化 JavaScript 代码的全部内容。
开发人员常常被最新的闪亮功能所吸引,或者出于需要故意或假装展示自己的能力,他们的思维有时会陷入一种神秘的清醒睡眠状态,因此他们会被展示过于复杂的代码或者使用最新功能的欲望所克服,尽管他们心里清楚,这意味着他们将不得不牺牲长期稳定性和计算机程序的效率。这种构建应用程序的方式我们可以称之为“危险驱动开发”。JavaScript 有很多非常糟糕的部分,但也有足够多的好部分来抵消坏部分。话虽如此,危险驱动开发的问题在于开发人员听从 JavaScript 糟糕部分的诱惑,而忽视了最终用户的满意度。
在本章中,我们将涵盖一些 JavaScript 的最好和最坏的部分,特别是与代码效率和整体性能有关的部分,以及开发人员应该始终编写安全、可靠和高效的 JavaScript 代码,即使这样做并不像编写最新的闪亮代码那样迷人。
因此,我们将涵盖以下几点:
-
全局对象和局部变量
-
避免不良习惯,并密切关注非常糟糕的部分
-
高效使用 DOM
-
构建和加载 JavaScript 应用程序
全局对象和局部变量
JavaScript 的全局对象是所有全局变量的容器。任何编译单元的顶级变量都将存储在全局对象中。当全局对象未被正确使用时,全局对象是 JavaScript 中最糟糕的部分之一,因为它很容易因不需要的变量而膨胀,并且在 JavaScript 默认行为被大量依赖时,开发人员可能会无意中滥用它。以下是两个这种滥用的例子:
-
当运行一个简单的代码,比如
total = add(3, 4);
,实际上是在全局对象中创建了一个名为total
的属性。这对性能来说并不是一件好事,因为您可能会在堆上保留大量变量,而其中大部分只在应用程序执行的某个时刻需要。 -
当忽略使用
new
关键字来创建对象时,JavaScript 将执行普通的函数调用,并将this
变量绑定到全局对象。这是一件非常糟糕的事情,不仅出于安全原因,因为可能会破坏其他变量,而且出于性能原因,因为开发人员可能会认为他正在将值存储在对象的属性中,而实际上,他正在直接将这些值存储在全局对象中,从而使全局对象膨胀,并在代码的其他地方已经实例化了所需的对象的情况下,在两个不同的内存空间中存储这些值。
为了有效地使用全局对象,您应该将所有变量封装在一个单一的应用对象中,根据需要对其应用函数,在应用到应用对象的函数中强制执行类型验证,以确保它被正确实例化,并将全局对象视为一种不可变对象,并将其视为一种具有一些副作用函数的应用对象。
避免全局变量
全局变量可以在应用程序的任何作用域中进行读取或写入。它们是必要的恶。实际上,任何应用程序都需要组织其代码结构,以处理输入值并返回适当的响应或输出。当代码组织不良时,以及代码的任何部分因此可以修改应用程序的全局状态并修改程序的整体预期行为时,问题和错误开始出现。
首先,组织不良的代码意味着脚本引擎或解释器在尝试查找变量名时需要做更多的工作,因为它将不得不通过许多作用域直到在全局作用域中找到它。
其次,组织不良的代码意味着内存中的堆总是比运行相同功能所需的堆要大,因为许多多余的变量将一直保留在内存中,直到脚本执行结束。
解决这个问题的方法是尽量避免使用全局变量,并几乎始终使用命名空间变量。此外,使用局部作用域变量的额外优势是确保在丢失局部作用域时变量会自动取消设置。
以下示例(chap7_js_variables_1.html
)向我们展示了全局变量的使用可能非常问题,并且最终非常低效,特别是在复杂应用程序中:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>JS Variables</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body onload="myJS()" style="margin:0;">
<div id="main"></div>
<script type="text/javascript">
function Sum(n1, n2)
{
// These will be global when called from the myJSAgain() function.
this.number1 = Number(n1);
this.number2 = Number(n2);
return this.number1 + this.number2;
}
function myJS()
{
// Side-effect: creates a global variable named 'total'.
total = new Sum(3, 4);
alert( window.total ); // Object
// Side-effect: modifies the global variable named 'total'.
myJSAgain();
// Global 'total' variable got clobbered.
alert( window.total ); // 3
}
function myJSAgain()
{
// Missing 'new' keyword. Will clobber the global 'total' variable.
total = Sum(1, 2);
// There are now two sets of 'number1' and 'number2' variables!
alert( window.number2 ); // 2
}
</script>
</body>
</html>
简单的解决方案是通过使用模块和命名空间来组织代码。这可以通过将所有变量和函数包装在单个应用程序对象中来轻松实现,以强制在设置或修改变量时产生某种关联行为,并将应用程序的机密信息保留在全局对象中。闭包也可以用于隐藏全局作用域中的重要值。让我们在考虑命名空间的情况下修改我们之前的脚本:
function myJS()
{
function MyJSObject(n1, n2)
{
let number1 = Number(n1);
let number2 = Number(n2);
return {
set_number1: function (n1) {
number1 = Number(n1);
},
set_number2: function (n2) {
number2 = Number(n2);
},
sum: function ( ) {
return number1 + number2;
}
};
}
let oApp1 = new MyJSObject(3, 4);
alert( oApp1.sum() ); // 7
let app2 = MyJSObject(1, 2);
alert( app2.sum() ); // 3
alert( oApp1.sum() ); // 7
alert( window.number1 ); // undefined
}
通过这种方式使用let
关键字,开发人员仍然可以获得正确的值,同时避免破坏全局变量并无意中修改整个应用程序的全局状态,即使他忘记使用new
关键字。此外,通过避免不必要的膨胀和减少在命名空间查找中花费的时间,全局对象保持精简和高效。
评估局部变量
正如我们在前面的示例中所看到的,省略局部变量声明前的let
或var
关键字会使其变成全局变量。在所有情况下,函数和对象都不应该能够通过修改其局部作用域外的变量的值来创建功能性副作用。因此,在函数或结构的作用域内声明变量时,应始终使用let
关键字。例如,将全局变量简单地移动到在本地循环中使用它们的函数的局部作用域中,可以使大多数浏览器的性能提高近 30%。
此外,使用let
关键字声明变量时,可以使用块作用域,应尽可能使用。因此,在for
循环中使用的变量在循环结束后不会保持在作用域内。这允许更好的变量封装和隔离,更有效的垃圾回收和更好的性能。
轻松跟踪变量声明的一种方法是使用 JavaScript 的严格模式。我们将在本章的下一节中更详细地解释这个 ES5 特性。
避免坏习惯并注意非常糟糕的部分
与大多数基于 C 的编程语言一样,最好避免某些常见的坏习惯,这些习惯经常导致代码效率低下和错误。
坏习惯
以下是一些应该被视为有问题的坏习惯:
-
在 JavaScript 中,首次使用时声明变量是一个坏主意,因为开发人员很可能会给变量全局范围,以便以后访问它。最好从项目开始组织代码,并使用直观和有意义的命名空间,以便在整个应用程序中组织变量的使用。
-
在任何情况下都应该避免以不明确或原本不打算的方式使用结构。例如,让
switch
语句穿透或在条件语句的条件中给变量赋值都是非常糟糕的习惯,不应该使用。 -
依赖自动分号插入是一个坏主意,可能导致代码被错误解释。应该始终避免。
-
数组和对象中的尾随逗号是一个坏主意,因为一些浏览器可能无法正确解释它们。
-
当使用一个带有一个单一命令行的
block
语句时,应该始终避免省略花括号。
当然,适当构造代码的艺术首先取决于对结构本身的良好了解。在 JavaScript 中有一些不好的结构应该在任何时候都避免。让我们花点时间来看看其中的一些。
不好的结构 - with 语句
这些不好的结构之一是with
语句。with
语句的最初意图是帮助开发人员在不必每次键入整个命名空间的情况下访问对象属性。它旨在成为一种use
语句,就像我们在其他语言(如 PHP)中可能遇到的那样。例如,你可以以以下方式使用with
语句:
foo.bar.baz.myVar = false;
foo.bar.baz.otherVar = false;
with (foo.bar.baz) {
myVar = true;
otherVar = true;
}
问题在于,当我们查看这段代码时,我们并不确定引擎是否会覆盖名为myVar
和otherVar
的全局变量。处理长命名空间的最佳方法是将它们分配给本地变量,然后在之后使用它们:
let fBrBz = foo.bar.baz;
fBrBz.myVar = true;
fBrBz.otherVar = true;
不好的结构 - eval 语句
另一个不好的例子是eval()
语句。这个语句不仅非常低效,而且大多数时候是没有用的。事实上,人们经常认为使用eval()
语句是处理提供的字符串的正确方式。但事实并非如此。你可以简单地使用数组语法来做同样的事情。例如,我们可以以以下方式使用eval()
语句:
function getObjectProperty(oString)
{
let oRef;
eval("oRef = foo.bar.baz." + oString);
return oRef;
}
要获得大幅度的速度提升(从 80%到 95%更快),你可以用以下代码替换前面的代码:
function getObjectProperty(oString)
{
return foo.bar.baz[oString];
}
不好的结构 - try-catch-finally 结构
重要的是要注意,应该避免在性能关键的函数内部使用 try-catch-finally 结构。原因与这个结构必须创建一个运行时变量来捕获异常对象有关。这种运行时创建是 JavaScript 中的一个特殊情况,并非所有浏览器都以相同的效率处理它,这意味着这个操作可能会在应用程序的关键路径上造成麻烦,特别是在性能至关重要的情况下。你可以用简单的测试条件替换这个结构,并在一个对象中插入错误消息,这个对象将作为应用程序的错误注册表。
避免低效的循环
嵌套循环是在 JavaScript 中编写这些类型结构时要避免的第一件事。
此外,大多数情况下,使用for-in
循环也不是一个好主意,因为引擎必须创建一个可枚举属性的完整列表,这并不是非常高效的。大多数情况下,for
循环会完美地完成任务。这在应用程序的关键路径上找到的性能关键函数中尤其如此。
此外,在处理循环时要注意隐式对象转换。通常,乍一看,很难看出在重复访问对象的length
属性时发生了什么。但有些情况下,当对象没有事先被明确创建时,JavaScript 会在循环的每次迭代中创建一个对象。请参阅以下代码示例(chap7_js_loops_1.html
):
function myJS()
{
let myString = "abcdefg";
let result = "";
for(let i = 0; i < myString.length; i++) {
result += i + " = " + myString.charAt(i) + ", ";
console.log(myString);
}
alert(result);
}
在查看谷歌 Chrome 开发者工具中的控制台结果时,我们得到了以下结果:
总共创建了七个字符串对象,每次迭代都创建了一个
在 JavaScript 引擎内部,实际上是在循环的每次迭代中创建了一个字符串对象。为了避免这个问题,我们将在进入循环之前显式实例化一个字符串对象(chap7_js_loops_2.html
):
function myJS()
{
let oMyString = new String("abcdefg");
let result = "";
for(let i = 0; i < oMyString.length; i++) {
result += i + " = " + oMyString.charAt(i) + ", ";
console.log(oMyString);
}
alert(result);
}
新脚本的结果如下所示:
只创建了一个对象,并显示了七次
控制台现在显示了同一个对象七次。很容易理解这如何可以优化循环的性能,特别是当循环可能导致引擎创建数十、数百甚至数千个对象以完成其工作时。
代码检查工具和严格模式
JavaScript 中还有一些其他不好的部分,可能会在某些情况下导致性能问题。为了密切关注所有这些不好的部分,并用 JavaScript 的好部分替换它们,强烈建议您使用一个工具,即使在第一次运行代码之前,也可以找到代码的问题。这些工具就是代码检查工具。
JSLint、ESLint和Prettier是可以帮助您找到松散代码并修复它的工具,甚至在某些情况下可以自动修复。一些代码检查工具,如ESLint,甚至可以通过减少语句数量、通过函数和 Promises 替换结构的嵌套、识别圈复杂度(衡量单个结构代码的分支数量)来帮助您改进代码,也许还可以允许您用更功能性的代码替换这些结构性的代码,正如我们将在下一章中看到的那样。您可以在以下地址找到这些工具:
使用代码检查工具的一个额外好处是它们使 JavaScript 代码与 ES5 的严格模式兼容。在可能的情况下,应该使用严格模式。只需在脚本或函数的开头添加一个use strict;
语句即可使用它。使用严格模式的许多好处之一是简化变量名称与变量定义的映射(优化的命名空间查找)、禁止with
语句、通过eval
语句防止意外引入变量到当前作用域、保护this
变量免受"装箱"(强制实例化)的影响,当它不包含对象并且传递给函数时,可以大大减少性能成本,并消除大多数性能限制,例如访问函数调用者的变量和在运行时"遍历"JavaScript 堆栈。
Packt Publishing 出版了许多关于 JavaScript 性能的优秀书籍和视频,我强烈建议您阅读它们,以掌握所有这些优秀的工具。
高效使用 DOM
文档对象模型(DOM)操作仍然是 JavaScript 中成本最高的操作之一。事实上,应该尽量减少重绘或回流,以避免一般性能问题。
尽管如此,还有其他必须避免的陷阱,以保持脚本在需要 DOM 操作并导致重绘或回流时的速度。这些陷阱涉及如何修改文档树,如何更新不可见元素,如何进行样式更改,如何搜索节点,如何管理从一个文档到另一个文档的引用,以及在检查大量节点时该怎么做。
修改文档树
重要的是要知道,在遍历树时进行修改是非常昂贵的。最好创建一个临时集合来处理,而不是在循环遍历所有节点时直接修改树。
事实上,最好的方法是使用非显示的 DOM 树片段,一次性进行所有更改,然后一起显示它们。以下是一个理论示例,说明如何实现这一点:
function myJS()
{
let docFragment = document.createDocumentFragment();
let element, content;
for(let i = 0; i < list.length; i++) {
element = document.createElement("p");
content = document.createTextNode(list[i]);
element.appendChild(content);
docFragment.appendChild(element);
}
document.body.appendChild(docFragment);
}
还可以克隆一个元素,以便在触发页面回流之前完全修改它。以下代码显示了如何实现这一点:
function myJS()
{
let container = document.getElementById("container1");
let cloned = container.cloneNode(true);
cloned.setAttribute("width", "50%");
let element, content;
for(let i = 0; i < list.length; i++) {
element = document.createElement("p");
content = document.createTextNode(list[i]);
element.appendChild(content);
cloned.appendChild(element);
}
container.parentNode.replaceChild(cloned, container);
}
通过使用这些技术,开发人员可以避免 JavaScript 中一些性能方面最昂贵的操作。
更新不可见元素
另一种技术是将元素的显示样式设置为none
。因此,在更改其内容时,它不需要重绘。以下是一个显示如何实现这一点的代码示例:
function myJS()
{
let container = document.getElementById("container1");
container.style.display = "none";
container.style.color = "red";
container.appendChild(moreNodes);
container.style.display = "block";
}
这是一种简单快速的方法,可以修改节点而避免多次重绘或回流。
进行样式更改
与我们提到如何在遍历 DOM 树时一次修改多个节点类似,也可以在文档片段上同时进行多个样式更改,以最小化重绘或回流的次数。以下代码片段是一个例子:
function myJS()
{
let container = document.getElementById("container1");
let modifStyle = "background: " + newBackgound + ";" +
"color: " + newColor + ";" +
"border: " + newBorder + ";";
if(typeof(container.style.cssText) != "undefined") {
container.style.cssText = modifStyle;
} else {
container.setAttribute("style", modifStyle);
}
}
正如我们所看到的,通过这种方式可以修改任意数量的样式属性,以便触发只有一个重绘或回流。
搜索节点
在整个 DOM 中搜索节点时,最好使用 XPath 来进行。通常会使用for
循环,如下面的示例,其中正在搜索h2
、h3
和h4
元素:
function myJS()
{
let elements = document.getElementsByTagName("*");
for(let i = 0; i < elements.length; i++) {
if(elements[i].tagName.match("/^h[2-4]$/i")) {
// Do something with the node that was found
}
}
}
可以使用 XPath 迭代器对象来获取相同的结果,只是效率更高:
function myJS()
{
let allHeadings = document.evaluate("//h2|//h3|//h4", document, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE, null);
let singleheading;
while(singleheading = allHeadings.iterateNext()) {
// Do something with the node that was found
}
}
在包含超过一千个节点的 DOM 中使用 XPath 肯定会在性能上有所不同。
检查大量节点
另一个要避免的陷阱是一次检查大量节点。更好的方法是将搜索范围缩小到特定的节点子集,然后使用内置方法找到所需的节点。例如,如果我们知道要查找的节点可以在特定的div
元素内找到,那么我们可以使用以下代码示例:
function myJS()
{
let subsetElements = document.getElementById("specific-div").getElementsByTagName("*");
for(let i = 0; i < subsetElements.length; i++) {
if(subsetElements[i].hasAttribute("someattribute")) {
// Do something with the node that was found...
break;
}
}
}
因此,这种搜索将比我们在大量节点中搜索它要高效得多,并且返回结果更快。
管理从一个文档到另一个文档的引用
在 JavaScript 中管理对许多文档的引用时,当不再需要文档时,销毁这些引用是很重要的。例如,如果一个文档在弹出窗口中,在框架中,在内联框架中或在对象中,并且用户关闭了文档,则文档的节点将保留在内存中,并将继续膨胀 DOM。销毁这些未使用的引用可以显著提高性能。
缓存 DOM 值
当重复访问对象时,将其存储在本地变量中以便反复使用会更有效。例如,以下代码将对分组的 DOM 值进行本地复制,而不是分别访问每个值:
function myJS()
{
let group = document.getElementById("grouped");
group.property1 = "value1";
group.property2 = "value2";
group.property3 = "value3";
group.property4 = "value4";
// Instead of:
//
// document.getElementById("grouped").property1 = "value1";
// document.getElementById("grouped").property2 = "value2";
// document.getElementById("grouped").property3 = "value3";
// document.getElementById("grouped").property4 = "value4";
}
这样做将使您避免与动态查找相关的性能开销。
构建和加载 JavaScript 应用程序
在考虑如何构建和加载 JavaScript 应用程序时,重要的是要记住某些重要原则。
最小化昂贵的操作
在 JavaScript 中成本最高的操作是:
-
通过网络 I/O 请求资源
-
重绘,也称为重新绘制,由于动态内容更改,例如使元素可见。
-
重排,可能是由于窗口调整大小
-
DOM 操作或页面样式的动态更改
显然,最重要的是要尽量减少所有这些操作,以保持良好的性能。在处理执行速度过慢的脚本时,这些都是要在 Google Chrome 的时间轴工具中查找的最重要元素,可以通过 Chrome 的开发者工具访问,如本书的第一章 更快的 Web-入门中所述。
清理,缩小和压缩资源
当然,从捆绑包中排除未使用的导出,也称为摇树,通过清理死代码来缩小脚本,然后压缩脚本文件,在处理 JavaScript 性能时总是一个好事,特别是在处理网络延迟时。在这方面,Webpack(webpack.js.org/
)是一个非常好的工具,结合UglifyJS插件(github.com/webpack-contrib/uglifyjs-webpack-plugin
)和其压缩插件(github.com/webpack-contrib/compression-webpack-plugin
),它将摇树您的代码,通过删除任何未使用或死代码来缩小您的脚本,并压缩生成的文件。
摇树的优势主要体现在使用摇树的第三方依赖时。为了更好地理解如何使用这些工具,强烈建议您查看以下教程:
另一个优化 JavaScript 代码(摇树,缩小和压缩)的好工具是 Google 的Closure,尽管它是用 Java 构建的。您可以在以下地址找到这个工具:developers.google.com/closure/
。
加载页面资源
在 HTML 文档的头部加载脚本文件时,重要的是要避免阻塞页面的渲染。脚本应始终在 body 部分的末尾加载,以确保渲染不会依赖于在获取所需的 JavaScript 文件时可能发生的网络延迟。
此外,重要的是要知道最好将内联脚本放在 CSS 样式表之前,因为 CSS 通常会阻止脚本运行,直到它们完成下载。
此外,在为性能构建 JavaScript 应用程序时,拆分脚本文件负载和异步下载脚本都是必须考虑的技术。
此外,Steve Souders已经写了很多关于提升网页性能的优秀书籍和文章,您应该阅读这些书籍,以获取有关这些非常重要的技术和原则的更多信息(stevesouders.com/
)。
缓存页面资源
另一件重要的事情要记住,正如我们将在第九章 提高 Web 服务器性能中更详细地看到的,服务器端和客户端的缓存技术将帮助您显著提高网页的性能。利用这些技术将使您能够减少简单地一遍又一遍地获取相同的 JavaScript 文件所需的请求数量。
总结
在本章中,我们已经涵盖了一些 JavaScript 的优点和缺点,特别是可能导致性能问题的陷阱。我们已经看到,编写安全、可靠和高效的 JavaScript 代码可能并不像使用最新的闪亮特性或懒惰编码那样令人兴奋,但肯定会帮助任何 JavaScript 应用程序成为更快速的 Web 的一部分。
在下一章中,我们将看到 JavaScript 如何越来越成为一种函数式语言,以及这种编程范式将成为未来性能的一个向量。我们将快速了解即将推出的语言特性,这些特性将有助于改善 JavaScript 应用程序的性能。
第八章:函数式 JavaScript
JavaScript 的未来将是函数式的。事实上,过去几年对语言所做的许多更改都使得在使用函数式编程技术时更容易、更高效的实现成为可能。
在本章中,我们将看到 JavaScript 如何越来越成为一种函数式语言,以及这种编程范式如何成为性能的一个向量。我们将学习如何用简化的函数版本替换过于复杂的代码,以及如何使用不可变性和尾调用优化将有助于使 JavaScript 在长期内更加高效。因此,我们将涵盖以下几点:
-
简化函数
-
函数式编程技术
-
更多即将推出的 JavaScript 功能
简化函数
传统上,计算机科学学生被告知保持他们的函数简单。经常说一个函数应该对应一个单一的动作。事实上,函数的圈复杂度越高,它就越难以重用、维护和测试。函数变得越来越纯粹的逻辑实体,没有真实世界中清晰可识别的动作根源,它就越难以理解和与其他函数结合使用。
函数式编程原则
函数式编程(FP)范式通过将计算设计视为基于数学函数和状态和数据的不可变性而进一步推动这种推理。FP 的指导原则是整个计算机程序应该是一个单一的、引用透明的表达式。在其核心,FP 的概念要求函数是纯的、引用透明的,并且没有副作用。当给定相同的输入时,函数是纯的,它总是返回相同的输出。当其函数表达式可以在计算机程序的任何地方与其相应的值互换时,它是引用透明的。当它不修改其范围之外的应用程序状态时,它是没有副作用的。因此,例如,修改在其范围之外声明的变量或向屏幕回显消息被认为是必须尽可能避免的函数副作用。
纯函数的一个例子如下:
function myJS()
{
function add(n1, n2)
{
let number1 = Number(n1);
let number2 = Number(n2);
return number1 + number2;
}
}
下一个函数不是纯的,因为有两个副作用:
function myJS()
{
function add(n1, n2)
{
// 1\. Modifies the global scope
number1 = Number(n1);
number2 = Number(n2);
// 2\. The alert function
alert( number1 + number2 );
}
}
引用透明函数可以在代码的任何地方替换为等于函数表达式计算值的常量:
4 === addTwo(2);
例如,这个函数不是引用透明的:
function myJS()
{
function addRandom(n1)
{
let number1 = Number(n1);
return number1 + Math.random();
}
}
在最显著的 JavaScript 函数中,不是引用透明的并且产生副作用的有:Date
、Math.random
、delete
、Object.assign
、Array.splice
、Array.sort
和RegExp.exec
。
保持函数简单和纯净有许多优点。最重要的是:
-
简化关键路径,开发人员在尝试维护或更新应用程序时减少认知负担
-
更容易测试函数
-
免费编译器优化,编译器可能决定在编译时用相应的常量值替换函数表达式,而不是每次计算函数
-
未来由于运行时优化而提高的性能
-
通过避免应用程序状态的可变性而实现安全的多线程(JavaScript 目前是单线程的,但谁知道未来会发生什么)
函数作为一等公民
函数作为一等公民是一个原则,它规定函数应该被视为与任何其他数据类型一样。当语言允许这种情况时,函数可以成为高阶函数,其中任何函数都可以作为参数接收,并且可以从任何其他函数返回计算值,就像任何其他数据类型一样。
当函数是纯的并且引用透明时,它们可以更容易地被用作一等公民函数。因此,更容易将函数组合在一起以动态产生其他函数。这就是所谓的函数组合。柯里化是一种动态生成新函数的方法,将其单个参数的评估转换为具有多个参数的另一个函数,并且部分应用是一种新动态生成的函数,其参数数量较少将修复另一个函数的参数数量。正如我们将在本章后面看到的,ES2020 正准备将这些概念引入 JavaScript 编程语言中。
处理副作用
如果需要避免所有形式的副作用,我们应该如何处理输入和输出、网络、用户输入和用户界面?根据 FP 原则,与现实世界的这些交互应该封装在特殊的数据结构中。即使包含的值在运行时仍然是未知的,这些特殊的数据结构使得可以将函数映射到一个或多个包装值(函子),将包装函数映射到一个或多个包装值(应用程序)或将返回其自身数据结构类型实例的包装函数映射到一个或多个包装值(单子)。这样,副作用就与纯函数分离开来。
不可变性
FP 的另一个重要原则是不可变性。修改状态和数据会产生圈复杂度,并使任何计算机程序容易出现错误和低效。事实上,所有变量实际上都应该是不可变的。变量从分配到内存的时刻直到释放的时刻都不应该改变其值,以避免改变应用程序的状态。
自 ES6 以来,现在可以使用const
关键字来定义常量或不可变变量。以下是一个示例:
function myJS()
{
const number = 7;
try {
number = 9;
} catch(err) {
// TypeError: invalid assignment to const 'number'
console.log(err);
}
}
这个新增的功能现在可以防止通过赋值修改变量。这样,就可以在整个运行时期保护 JavaScript 应用程序的状态免受突变的影响。
在可能的情况下,开发人员应始终优先使用const
而不是let
或var
。尝试修改使用const
关键字声明的变量将导致以下错误(chap8_js_const_1.html
):
给常量变量赋值会导致'TypeError'
函数式编程技术
自 ES6 以来,JavaScript 已经更容易使用 FP 实现软件解决方案。许多引擎优化已经添加,使得根据 FP 原则编程 JavaScript 时可以获得更好的性能。映射、过滤、减少和尾调用优化是其中的一些技术。
映射
映射是一种高阶函数,它允许我们将回调映射到集合的每个元素。当将数组的所有元素从一组值转换为另一组值时,它特别有用。以下是一个简单的代码示例:
function myJS()
{
let array = [1, 2, 3];
let arrayPlusTwo = array.map(current => current + 2);
// arrayPlusTwo == [3, 4, 5]
}
这种技术使得在简单修改数组的值时尽可能避免使用结构循环成为可能。
过滤
过滤是一种高阶函数,它允许我们根据布尔谓词区分和保留集合中的某些元素。当根据特定条件从集合中移除某些元素时,过滤当然是非常有用的。以以下代码为例:
function myJS()
{
let array = [1, 2, 3];
let arrayEvenNumbers = array.filter(current => current % 2 == 0);
// arrayEvenNumbers == [2]
}
过滤是避免循环和嵌套条件以提取所需数据集的一种很好的方法。
减少
Reduce 是一个高阶函数,它允许我们根据组合函数将集合的元素合并为一个返回值。当处理累积或连接值时,这种技术非常有用。在下面的例子中,我们正在计算数组元素的总和:
function myJS()
{
let array = [1, 2, 3];
let sum = array.reduce((cumul, current) => cumul + current, 0);
// sum == 6;
}
我们将再看一种 FP 技术,即尾调用优化。
尾调用优化
为了更好地理解尾调用优化(TCO)是什么,我们需要定义它是什么,了解它是如何工作的,并学习如何确定一个函数是否被尾调用。
什么是尾调用优化?
尾调用或尾递归是一种函数式编程技术,其中一个函数在返回控制权给自己的调用者之前调用一个子例程函数作为其最终过程。如果一个函数递归地调用自身,则发生直接递归。如果一个函数调用另一个函数,而另一个函数又调用原始函数,则递归是相互的或间接的。
因此,例如,当一个函数尾调用自身时,它会一遍又一遍地将自己堆叠,直到满足某个条件为止,此时它一定会返回,从而有效地弹出整个调用堆栈。
优化尾调用包括在执行尾调用之前从调用堆栈中弹出当前函数,并将当前函数的调用者地址保留为尾调用的返回地址。因此,堆栈的内存占用保持较小,实际上完全避免了堆栈溢出。
尾调用优化的工作原理
让我们比较两个堆栈帧,一个没有尾调用优化,另一个有尾调用优化。首先让我们看一下以下代码:
function a(x)
{
y = x + 2;
return b(y);
}
function b(y)
{
z = y + 3;
return z;
}
console.log(a(1)); // 6
在没有使用尾调用优化的情况下,分配给内存后,前面代码的三个堆栈帧将如下图所示:
典型的后进先出(LIFO)调用堆栈
一旦将值 6 分配给变量z
,堆栈帧就准备好被弹出。在这种情况下,堆栈帧2仅保留在内存中,只是为了保留console.log()
的地址。这就是尾调用优化可以产生差异的地方。如果在调用b()
之前,堆栈帧2被从堆栈中弹出,同时保持原始调用者的返回地址不变,那么在运行时只会有一个函数被堆叠,堆栈空间将会减少。
无论函数被尾调用多少次,整个堆栈只会计算两个堆栈帧。因此,经过尾调用优化的堆栈看起来会像这样:
尾调用优化的调用堆栈
一些人声称,在某些 JavaScript 实现中实现尾调用优化会是一个坏主意,因为这样做会破坏应用程序的实际执行流程,使调试变得更加困难,并且一般会破坏遥测软件。在某些 JavaScript 实现中,这可能是真的,但绝对不是绝对的。从技术上讲,由于某些 JavaScript 实现中存在技术债务,实现尾调用优化可能会很困难,但绝对不需要为某些应该在任何语言中都是隐含的东西而要求一个语法标志,特别是在使用严格模式标志时。
话虽如此,并非所有浏览器和 JavaScript 项目都已经实现了这个 ES6 功能,但它只是时间问题,他们迟早都得这么做,开发人员应该为这一重大变化做好准备。事实上,从结构范式到函数范式的这一变化将使得使用函数而不是众所周知的循环结构来制作非常高效的循环成为可能。根据这些新原则进行编程的主要优势将是:
-
通过消耗更少的内存和花费更少的时间来完成大型循环,从而提高效率
-
减少圈复杂度和简化关键路径
-
代码行数减少,开发人员的认知负担减轻
-
封装和组织良好的代码
-
一般来说,更好地测试代码
截至撰写本文时,只有 Safari 11、iOS 11、Kinoma XS6 和 Duktape 2.2 完全支持尾调用优化。
让我们来看看两个代码示例(chap8_js_performance_1.html
和chap8_js_performance_2.html
),以比较传统的for
循环与尾调用优化函数的性能。以下是第一个示例:
function myJS()
{
"use strict";
function incrementArrayBy2(myArray, len = 1, index = 0)
{
myArray[index] = index;
myArray[index] += 2;
return (index === len - 1) ? myArray : incrementArrayBy2(myArray, len, index +
1); // tail call
}
let myArray = [];
for(let i = 0; i < 100000000; i++) {
myArray[i] = i;
myArray[i] += 2;
}
console.log(myArray);
}
以下是第二个:
function myJS()
{
"use strict";
function incrementArrayBy2(myArray, len = 1, index = 0)
{
myArray[index] = index;
myArray[index] += 2;
return (index === len - 1) ? myArray :
incrementArrayBy2(myArray, len, index +
1); // tail call
}
let myArray = [];
myArray = incrementArrayBy2(myArray, 100000000);
console.log(myArray);
}
如果我们对这两个脚本进行基准测试,我们会注意到它们之间没有太大的区别,除了使用尾调用的那个更容易进行单元测试,具有非常简单的关键路径,并且即使出于明显的原因而不是纯的,它仍然可以很容易地进行记忆化。
以下是第一个脚本的结果:
使用结构化'for'循环时的结果
第二个脚本的结果是:
使用经过尾调用优化的堆叠函数时的结果
现在,让我们通过一些代码示例更好地了解这个 ES6 功能,以便更好地识别尾调用的不同用法。
识别尾调用
如前所述,尾调用发生在子例程被调用为当前函数的最后一个过程时。这种情况有很多种。
如果您以以下方式使用三元运算符,则one()
和two()
函数都是尾调用:
function myFunction()
{
// Both one() and two() are in tail positions
return (x === 1) ? one() : two();
}
以下代码示例不是尾调用,因为被调用者是从函数体内部调用的,可以用于进一步计算,而不仅仅是返回给调用者:
function myFunction()
{
// Not in a tail position
one();
}
以下是另一个示例,其中一个被调用者不处于尾调用位置:
function myFunction()
{
// Only two() is in a tail position
const a = () => (one() , two());
}
原因是在这种情况下,one()
函数可以与其他计算结合在一起,而two()
函数不能,其返回值将简单地分配给a
常量。如果我们使用逻辑运算符而不是逗号,那么同样的情况也会发生。
让我们继续了解其他即将推出的 JavaScript 功能。
更多即将推出的 JavaScript 功能
许多其他功能将很快添加到 JavaScript 中,这将进一步推动语言朝着功能性和异步编程的方向发展。让我们来看看其中的一些。
异步函数
由于异步编程,当生成器用于此目的时,对 FP 的需求将会更加迫切,避免竞争条件将变得比现在更加重要。
确实,ES2017 引入了async
/ await
函数。这些函数将允许我们轻松创建一个event
循环,并在循环内部进行异步 I/O 调用,以获得非阻塞代码。这将有许多实际应用,包括在渲染完成后异步下载补充的 JavaScript 文件以加快网页加载时间的可能性。以下是使用这些类型函数的代码示例:
async function createEntity(req, res) {
try {
const urlResponse = await fetch(req.body.url)
const html = await urlResponse.text()
const entity = await Entity.post({ // POST request })
// More stuff here
} catch (error) {
req.flash('error', `An error occurred : ${error.message}`)
res.redirect('/entity/new')
}
}
异步生成器和 for-await-of 循环
ES2018 定义了异步生成器和for-await-of
循环的规范。这些功能已经在大多数浏览器中可用,并且在 JavaScript 中进行异步编程时将非常有帮助。它们将大大简化在异步请求上进行迭代时创建队列和循环。此外,使用异步迭代器、可迭代对象和生成器与异步调用将通过使用 promises 变得非常容易。以下是使用这些新功能的简单代码示例:
async function* readLines(path) {
let file = await fileOpen(path);
try {
while (!file.EOF) {
yield await file.readLine();
}
} finally {
await file.close();
}
}
管道操作符
ES2020 提案正在制定中,其中包括更多 FP 概念,例如使用管道操作符进行简单的函数链接。因此,链接函数将变得更加简单。而不是做类似于这样的事情:
const text = capitalize(myClean(myTrim(' hAhaHAhA ')));
我们只需要这样做:
const text = ' hAhaHAhA '
|> myTrim
|> myClean
|> capitalize
部分应用
ES2020 提案中还有一个非常重要的 FP 技术:部分应用。如前所述,这种 FP 技术可以通过生成一个参数更少的新动态生成的函数,来固定函数的一些参数。以下是一个简单的代码示例:
function add(num1, num2) {
return num1 + num2;
}
function add1(num2) {
return add(1, num2);
}
ES2020 提案建议,可以通过以下方式执行部分应用:
const add = (x, y) => x + y
const add1 = add(?, 1)
当然,我们还可以提到许多其他 FP 技术,这些技术可能会出现在 ES2020 规范中,比如函数绑定、柯里化和模式匹配,但必须知道的是,JavaScript 越来越成为一种函数式语言,并且许多未来的引擎优化将自动增强任何执行的代码的整体性能,如果它是根据 FP 原则编写的。
有关函数式编程和函数式 JavaScript 的更多信息,请阅读 Packt Publishing 近年来出版的许多优秀书籍和视频。
总结
我们现在更好地理解了为什么 JavaScript 越来越成为一种函数式语言,以及这种编程范式如何成为性能的一个向量。我们了解了如何用简化的函数式版本替换过于复杂的代码,以及如何使用不可变性和尾调用优化来提高 JavaScript 的效率。我们还简要了解了 JavaScript 语言即将推出的功能。
在下一章中,我们将看一些项目,这些项目多年来一直与谷歌的更快网络计划一起进行,并且我们将看到如何结合这些技术以提高整体网络服务器性能。
第九章:提升 Web 服务器性能
谷歌确定其更快 Web 计划的首要任务之一是更新老化的 Web 协议。全球范围内已经有许多项目正在进行,因为 Web 开发的新重点正在从为用户提供更多功能(即使这些功能很慢)转向提供与 Web 性能不相冲突的功能。谷歌的倡议有助于改变 Web 开发的优先事项,从而使现有项目得以光明,新项目得以创建。
在本章中,我们将介绍一些与谷歌新的 Web 倡议一起进行的项目。因此,我们将涵盖以下几点:
-
MOD_SPDY 和 HTTP/2
-
PHP-FPM 和 OPCache
-
ESI 和 Varnish Cache
-
客户端缓存
-
其他更快 Web 工具
MOD_SPDY 和 HTTP/2
2009 年,谷歌宣布将寻找更新 HTTP 协议的方法,通过使用名为 SPDY(SPeeDY
)的新会话协议。这个新的会话协议在底层 TLS 表示层上工作,并允许在应用层进行许多 HTTP 速度优化。使用 SPDY 就像激活 SSL 一样简单,在 Web 服务器上安装mod_spdy
模块并激活它。为了从其功能中受益,不需要对网站进行任何修改。
此外,所有主要浏览器都支持它。SPDY 迅速成为更快 Web 的核心元素,并在 2012 年 11 月成为下一次重大 HTTP 协议修订的基础。然后,在 2015 年,它被弃用,改为使用新的 HTTP/2 协议。SPDY 引入的最重要的优化措施,并将其纳入新的 HTTP 协议规范的是多路复用和优先级流、服务器推送和头部压缩。在我们深入了解 HTTP/2 协议的一些具体内容之前,让我们更详细地看看这些优化措施中的每一个。
多路复用和优先级流
SPDY 的多路复用流功能允许将多个请求映射到单个连接上的多个流。这些流是双向的,可以由客户端或服务器(服务器推送功能)发起。在单个连接上打开多个流可以避免在每个客户端/服务器交换时建立新连接的开销,特别是在并行下载多个资源以完成单个页面的渲染时。因此,这个第一个功能使得在使用 HTTP/1 协议时摆脱了可能的连接数量限制:
多路复用和优先级流的工作原理
此外,SPDY 的流是有优先级的。这个额外的功能允许客户端确定哪些资源应该首先发送到网络上。因此,SPDY 避免了在 HTTP/1 协议中进行服务器管线化(即KeepAlive
指令)时出现的先进先出(FIFO)问题。
服务器推送
正如已经提到的,SPDY 的新流特性使得服务器能够在不响应客户端请求的情况下向客户端推送数据。这使得通信变得双向,并允许 Web 服务器预测客户端的需求。事实上,甚至在客户端解析 HTML 并确定渲染页面所需的所有文件之前,Web 服务器就可以将文件推送到客户端,从而减少客户端发送请求以获取所有必要资源的次数:
“服务器推送”功能的工作原理
通过了解许多研究显示,平均而言,大多数页面需要 70 到 100 个请求,涉及 20 到 30 个域名,才能完成其渲染,我们可以很容易地看出这个功能如何使 Web 变得更简洁,并显著减少网络延迟。
头部压缩
SPDY 的第三个重要特性是使用gzip
进行标头压缩。通过压缩通常较多的 HTTP 标头,并将其平均减少 85%的原始大小,SPDY 可以在网络上将大多数 HTTP 事务的加载时间缩短整整一秒。尽管使用gzip
动态压缩标头被发现是不安全的,但标头压缩的概念仍然存在,并且由于对整体网络性能的巨大益处,它在 HTTP/2 协议中得到了重新实现。
HTTP/2
作为 RFC 7540 [1]于 2015 年 5 月发布的 HTTP/2 是 HTTP 协议的最新主要修订版。它主要基于 Google 的 SPDY 协议,并提供了一个新的二进制帧层,与 HTTP/1 不兼容。正如之前提到的,它的大部分功能是通过 SPDY 项目开发的。SPDY 和 HTTP/2 之间最显著的区别是新协议压缩其标头的方式。而 SPDY 依赖于使用gzip
动态压缩标头,HTTP/2 协议使用了一种名为HPACK
的新方法,该方法利用了固定的 Huffman 编码算法。为了避免 SPDY 发现的数据压缩导致可能泄露私人数据的问题,需要这种新方法。
尽管新协议将大多数网页的加载时间缩短了一倍,许多批评者对此表示失望,指出谷歌对更新 HTTP 协议项目施加的不切实际的最后期限使得新协议版本不可能基于其他任何东西而不是其 SPDY 项目,并因此错失了进一步改进新 HTTP 协议的许多机会。Poul-Henning Kamp,Varnish Cache的开发者,甚至表示 HTTP/2 是不一致的,过于复杂且不必要。此外,他指出它违反了协议分层的原则,通过复制应该在传输层正常进行的流量控制 [2]。最后,在这个新协议中发现了许多安全漏洞,其中最显著的是由网络安全公司 Imperva 在 2016 年 Black Hat USA 会议上披露的那些 [3]。这些漏洞包括慢速读取攻击、依赖循环攻击、流复用滥用和 HPACK 炸弹。基本上,所有这些攻击向量都可以用来通过提交拒绝服务(DoS)攻击或通过饱和其内存来使服务器下线。
尽管存在许多与加密相关的问题,但所有主要的网络服务器和浏览器都已经采用并提供了对其的支持。大多数情况下,如果您的网络服务器已经配置并编译了 HTTP/2 标志,您只需要在服务器的/etc/httpd/httpd.conf
文件中激活模块即可开始使用它。在 Apache Web 服务器的情况下,您还必须在服务器的配置文件中添加Protocols
指令。请注意,在服务器上激活 HTTP/2 协议将对资源消耗产生重大影响。例如,在 Apache Web 服务器上启用此功能将导致创建许多线程,因为服务器将通过创建专用工作程序来处理和流式传输结果以响应客户端的 HTTP/2 请求。以下是如何在 Apache 的httpd.conf
和httpd-ssl.conf
配置文件中启用 HTTP/2 模块的示例(假设mod_ssl
模块也已启用):
# File: /etc/httpd/httpd.conf
[...]
LoadModule ssl_module /usr/lib/httpd/modules/mod_ssl.so
LoadModule http2_module /usr/lib/httpd/modules/mod_http2.so
[...]
# File: /etc/httpd/extra/httpd-ssl.conf
[...]
<VirtualHost _default_:443>
Protocols h2 http/1.1
# General setup for the virtual host
DocumentRoot "/srv/www"
[...]
有关 HTTP/2 协议的更多信息,请访问以下网址:
要了解 Apache 对相同协议的实现更多信息,请访问以下链接:
最后,要了解 NGINX 提供的实现更多信息,请参阅他们的文档:
PHP-FPM 和 OPCache
谈到更快的 Web 时,考虑如何确保 PHP 二进制本身在 Web 服务器上以优化的方式运行是非常重要的,考虑到 PHP 安装在全球 70%至 80%的服务器上。
PHP-FPM
自 PHP 5.3 以来,PHP 现在包括一个 FastCGI 进程管理器,允许您在 Web 服务器上运行更安全、更快速和更可靠的 PHP 代码。在 PHP-FPM 之前,在 Web 服务器上运行 PHP 代码的默认方式通常是通过mod_php
模块。PHP-FPM 如此有趣的原因在于它可以根据传入请求的数量自适应,并在工作池中生成新进程,以满足不断增长的需求。此外,以这种方式运行 PHP 允许更好的脚本终止、更优雅的服务器重启、更高级的错误报告和服务器日志记录,以及通过守护进程化 PHP 二进制对每个 PHP 工作池进行 PHP 环境的精细调整。
许多高流量网站报告称,他们在将生产服务器上的 mod_php
更改为 PHP-FPM
后,看到了高达 300%的速度提升。当然,正如 Ilia Alshanetsky 在他的一个演示中提到的那样[4],在提供静态内容时,像 lighttpd、thttpd、Tux 或 Boa 这样的许多其他服务器,可能比 Apache 快 400%。但是,当涉及到动态内容时,没有任何服务器可以比 Apache 或 NGINX 更快,特别是当它们与 PHP-FPM 结合使用时。
在服务器上启用 PHP-FPM 就像在编译时使用 --enable-fpm
开关配置 PHP 一样简单。从那里开始,问题就是确定如何运行 PHP-FPM,这取决于性能和安全问题。例如,如果您在生产环境中,您可能决定在许多服务器上运行许多工作池的 PHP-FPM,以分发工作负载。此外,出于性能和安全原因,您可能更喜欢在服务器上通过 UNIX 套接字而不是网络环回(127.0.0.1
)运行 PHP-FPM。事实上,在任何情况下,UNIX 套接字都更快,并且将提供更好的安全性,以防止本地网络攻击者,可能始终尝试使用域授权通过强制适当的访问控制来破坏环回的套接字监听器以确保连接机密性。
Zend OPcache
自 PHP 5.5 以来,当在编译时向配置脚本添加 --enable-opcache
开关时,opcode 缓存现在可以在 PHP 的核心功能中使用。
一般来说,Zend OPcache 将使任何脚本的运行速度提高 8%至 80%。脚本的墙时间由 PHP 二进制引起的时间越长,OPcache 的差异就越大。但是,如果脚本的 PHP 代码非常基本,或者如果 PHP 由于 I/O 引起的延迟而减慢,例如对文件的流或对数据库的连接,OPcache 只会轻微提高脚本性能。
在所有情况下,Zend OPcache 将优化 PHP 脚本性能,并应默认在所有生产服务器上启用。
让我们看看如何配置运行 PHP 7.1.16 (NTS) 的 Linux 中包含的 PHP-FPM 服务器,以使用 UNIX 套接字而不是网络环回来建立 Apache 和 PHP 之间的连接。此外,让我们配置 PHP-FPM 以使用 Zend OPcache。
请确保您的容器仍在运行,并在其 CLI 上输入以下命令:
# rm /srv/www
# ln -s /srv/fasterweb/chapter_9 /srv/www
# cd /srv/www
# cat >>/etc/php.ini << EOF
> [OpCache]
> zend_extension = $( php -i | grep extensions | awk '{print $3}' )/opcache.so
> EOF
# sed -i 's/;opcache.enable=1/opcache.enable=1/' /etc/php.ini
# sed -i 's/Proxy "fcgi://127.0.0.1:9000"/Proxy "unix:/run/php-fpm.sock|fcgi://localhost/"/' /etc/httpd/httpd.conf
# sed -i 's/# SetHandler "proxy:unix:/SetHandler "proxy:unix:/' /etc/httpd/httpd.conf
# sed -i 's/SetHandler "proxy:fcgi:/# SetHandler "proxy:fcgi:/' /etc/httpd/httpd.conf
# sed -i 's/listen = 127.0.0.1:9000/; listen = 127.0.0.1:9000nlisten = /run/php-fpm.sock/' /etc/php-fpm.d/www.conf
# /etc/init.d/php-fpm restart
# chown apache:apache /run/php-fpm.sock
# /etc/init.d/httpd restart
现在,您可以使用vi编辑器查看修改后的php.ini
文件,以确保以前的设置不再被注释掉,并且新的[OPcache]
部分已添加到文件中。然后,在您喜欢的浏览器中,当访问http://localhost:8181/phpinfo.php
时,您应该会看到以下屏幕:
确认 Zend Opcache 已启用并正在运行
如果您看到上一个屏幕,那么您已成功将Apache服务器通过 UNIX 套接字连接到 PHP-FPM,并启用了Zend OPcache。
如果您希望在Linux for PHP基础镜像(asclinux/linuxforphp-8.1:src
)中使用 FPM 和OPCache配置开关从头开始编译 PHP,请在新的终端窗口中输入以下命令:
# docker run --rm -it -p 8383:80 asclinux/linuxforphp-8.1:src /bin/bash -c "cd ; wget -O tmp http://bit.ly/2jheBrr ; /bin/bash ./tmp 7.2.5 nts ; echo '<?php phpinfo();' > /srv/www/index.php ; /bin/bash"
如果您希望手动完成相同的操作,请访问Linux for PHP网站以获取进一步的说明(linuxforphp.net/cookbook/production
)。
ESI 和 Varnish 缓存
另一种更快的 Web 技术是边缘包含(ESI)标记语言和 HTTP 缓存服务器。
边缘包含(ESI)
最初作为万维网联盟(W3C)于 2001 年批准的规范,ESI 被认为是通过将边缘计算应用于 Web 基础设施扩展的一种方式。边缘计算是一种通过在数据源附近进行数据处理来优化云计算的方法,而不是将所有数据处理集中在数据中心。在 ESI 的情况下,想法是将 Web 页面内容分散到网络的逻辑极端,以避免每次都将所有内容请求发送到 Web 服务器。
规范要求新的 HTML 标记,这些标记将允许 HTTP 缓存服务器确定页面的某些部分是否需要从原始 Web 服务器获取,或者这些部分的缓存版本是否可以发送回客户端,而无需查询服务器。可以将 ESI 视为一种 HTML 包含功能,用于从不同的外部来源组装网页的动态内容。
许多 HTTP 缓存服务器开始使用新的标记语言。一些内容交付网络(CDN),如 Akamai,以及许多 HTTP 代理服务器,如 Varnish、Squid 和 Mongrel ESI,多年来开始实施该规范,尽管大多数并未实施整个规范。此外,一些服务器,如 Akamai,添加了原始规范中没有的其他功能。
此外,重要的 PHP 框架,如Symfony,开始在其核心配置中添加 ESI 功能,从而使 PHP 开发人员在开发应用程序时立即开始考虑 ESI。
此外,浏览器开始鼓励 ESI 的使用,通过在 Web 上保留所有获取的文件的本地缓存,并在其他网站请求相同文件时重复使用它们。因此,在您的网站上使用 CDN 托管的 JavaScript 文件可以减少客户端请求您的 Web 服务器的次数,只需一次获取相同的文件。
使用esi:include
标记在 HTML 中开始缓存网页的部分非常容易。例如,您可以这样使用:
<!DOCTYPE html>
<html>
<body>
... content ...
<!-- Cache part of the page here -->
<esi:include src="http://..." />
... content continued ...
</body>
</html>
另一个例子是使用 PHP 和Symfony框架自动生成 ESI 包含标记。这可以通过让Symfony信任Varnish Cache服务器,在 YAML 配置文件中启用 ESI,在其控制器方法中设置网页的共享最大年龄限制,并在相应的模板中添加所需的渲染辅助方法来轻松实现。让我们一步一步地进行这些步骤。
首先让Symfony信任Varnish Cache服务器。在Symfony的最新版本中,您必须调用Request
类的静态setTrustedProxies()
方法。在Symfony安装的public/index.php
文件中,添加以下行:
# public/index.php
[...]
$request = Request::createFromGlobals();
// Have Symfony trust your reverse proxy
Request::setTrustedProxies(
// the IP address (or range) of your proxy
['192.0.0.1', '10.0.0.0/8'],
// Trust the "Forwarded" header
Request::HEADER_FORWARDED
// or, trust *all* "X-Forwarded-*" headers
// Request::HEADER_X_FORWARDED_ALL
// or, trust headers when using AWS ELB
// Request::HEADER_X_FORWARDED_AWS_ELB
); }
[...]
根据您使用的Symfony版本和Varnish版本,您可能需要遵循不同的步骤才能完成此操作。请参阅Symfony文档的以下页面以完成此第一步:symfony.com/doc/current/http_cache/varnish.html
。
然后,将以下行添加到您的Symfony配置文件中:
# config/packages/framework.yaml
framework:
# ...
esi: { enabled: true }
fragments: { path: /_fragment }
完成后,修改一些控制器如下:
# src/Controller/SomeController.php
namespace App\Controller;
...
class SomeController extends Controller
{
public function indexAction()
{
$response = $this->render('static/index.html.twig');
$response->setSharedMaxAge(600);
return $response;
}
}
第二个应该修改如下:
# src/Controller/OtherController.php
namespace App\Controller;
...
class OtherController extends Controller
{
public function recentAction($maxPerPage)
{
...
$response->setSharedMaxAge(30);
return $response;
}
}
最后,在您的 Twig 模板中执行以下修改:
{# templates/static/index.html.twig #}
{{ render_esi(controller('App\Controller\OtherController::recent', { 'maxPerPage': 5 })) }}
现在,您应该能够在加载Symfony应用程序的页面时看到 ESI 的效果。
为了更好地理解 ESI 的内部工作原理,让我们尝试安装和运行部分实现 ESI 规范的 HTTP 反向代理服务器。
Varnish Cache
部分实现 ESI 的 HTTP 反向代理服务器之一是Varnish Cache。这个 HTTP 缓存服务器最初是由其创始人Poul-Henning Kamp、Anders Berg和Dag-Erling Smørgrav构思的,作为* Squid 的一个非常需要的[5]替代品, Squid 是一个著名的 HTTP 转发代理服务器(客户端代理)。Squid*可以作为反向代理(服务器代理)工作,但很难设置它以这种方式工作。
导致创建Varnish Cache的原始会议于 2006 年 2 月在奥斯陆举行。该项目背后的基本概念是找到一种快速操纵从通过网络流量获取的字节的方法,以及确定何时何地以及何时缓存这些字节。多年后,Varnish Cache已成为 Web 上最重要的 HTTP 缓存服务器之一,几乎有三百万个网站在生产中使用它[6]。
为了更好地理解Varnish Cache的工作原理,让我们花点时间在 Linux for the PHP 基础容器中安装它。
在新的终端窗口中,请输入以下 Docker 命令:
# docker run -it -p 6082:6082 -p 8484:80 asclinux/linuxforphp-8.1:src /bin/bash
然后,输入以下命令:
# pip install --upgrade pip
# pip install docutils sphinx
您现在应该在 CLI 上看到以下消息:
然后,输入以下命令:
# cd /tmp
# wget https://github.com/varnishcache/varnish-cache/archive/varnish-6.0.0.tar.gz
安装完成后,您应该看到类似于这样的屏幕:
最后,请通过以下命令完成安装解压缩、配置和安装Varnish Cache:
# tar -xvf varnish-6.0.0.tar.gz
# cd varnish-cache-varnish-6.0.0/
# sh autogen.sh
# sh configure
# make
# make install
# varnishd -a 0.0.0.0:80 -T 0.0.0.0:6082 -b [IP_ADDRESS_OR_DOMAIN_NAME_OF_WEB_SERVER]:80
完成后,您应该收到以下消息:
!Varnish Cache 守护程序现在正在运行并等待连接
正如我们在本书的第二章“持续分析和监控”中提到的,当我们通过Docker容器安装TICK堆栈时,您可以通过发出此命令来获取两个容器(运行Apache服务器和运行Varnish服务器的新容器)的 IP 地址:
# docker network inspect bridge
获得结果后,您可以将前一个命令中的[IP_ADDRESS_OR_DOMAIN_NAME_OF_WEB_SERVER]占位符替换为运行Apache(Linux for PHP容器)的容器的 IP 地址。在我的情况下,Apache Web 服务器的 IP 地址是172.17.0.2
,Varnish Cache服务器的 IP 地址是172.17.0.3
。因此,命令将是:
# varnishd -a 0.0.0.0:80 -T 0.0.0.0:6082 -b 172.17.0.2:80
一旦启动,您可以将浏览器指向Varnish Cache服务器的 IP 地址,您应该会得到Apache Web 服务器的内容。在我的情况下,当我将浏览器指向172.17.0.3
时,我得到了预期的结果:
Varnish 正在缓存并返回从 Apache 服务器获取的响应
我们可以通过在新的终端窗口中发出以下curl
命令并将结果传输到grep
来确认Varnish Cache服务器是否正在使用我们的Apache Web 服务器作为其后端,以查看请求和响应头:
# curl -v 172.17.0.3 | grep Forwarded
结果应该类似于以下截图:
Varnish Cache 头部被添加到 Apache 头部中
正如我们所看到的,头部显示Apache服务器正在通过Varnish Cache服务器响应。
因此,通过正确的 DNS 配置,将所有的网络流量重定向到Varnish Cache服务器,并将 Web 服务器仅用作其后端成为可能。
这个例子向我们展示了配置Varnish Cache服务器是多么容易,以及开始使用它并立即从中受益以快速提升 Web 服务器性能是多么简单。
客户端缓存
让我们继续介绍另一种更快的 Web 技术,即客户端缓存。这种形式的 HTTP 缓存专注于减少呈现页面所需的请求次数,以尽量避免网络延迟。事实上,大型响应通常需要在网络上进行多次往返。HTTP 客户端缓存试图最小化这些请求的数量,以完成页面的呈现。如今,所有主要浏览器都支持这些技术,并且在您的网站上启用这些技术就像发送一些额外的头部或使用已经在内容交付网络(CDN)上可用的库文件一样简单。让我们看看这两种技术:浏览器缓存头部和 CDN。
浏览器缓存
浏览器缓存的基本思想是,如果在一定时间内某些文件完全相同,就不必获取响应中包含的所有文件。它的工作方式是通过服务器发送给浏览器的头部,以指示浏览器在一定时间内避免获取某些页面或文件。因此,浏览器将显示保存在其缓存中的内容,而不是在一定时间内通过网络获取资源,或者直到资源发生变化。
因此,浏览器缓存依赖于缓存控制评估(过期模型)和响应验证(验证模型)。缓存控制评估被定义为一组指令,它们告知浏览器谁可以缓存响应,在什么情况下以及多长时间。响应验证依赖于哈希令牌,以确定响应的内容是否已更改。它还使浏览器能够避免再次获取结果,即使缓存控制指示缓存的内容已过期。实际上,收到来自服务器的响应,指示内容未被修改,基于发送的令牌在服务器上未更改的事实,浏览器只需更新缓存控制并重置到期前的时间延迟。
这是通过使用某些响应头部来实现的。这些是Cache-Control和ETag头部。以下是在响应中接收到的这些头部的示例:
浏览器缓存的工作原理
在这个例子中,Cache-Control 指示最大年龄为120秒,并设置了值为"e4563ff"的ETag。有了这两个头部,浏览器就能够充分管理其缓存。因此,启用浏览器缓存就像将这些响应头部添加到 Web 服务器返回的响应中一样简单。对于Apache来说,只需确保 FileETag 指令已添加到服务器的配置文件中即可。
在 PHP 中,也可以直接使用Symfony框架设置 Cache-Control 和 Expires 头。具体来说,Symfony的响应对象允许您使用其setCache()
方法设置所有 Cache-Control 头。以下是使用此方法的示例:
# src/Controller/SomeController.php
...
class SomeController extends Controller
{
public function indexAction()
{
$response = $this->render('index.html.twig');
$response->setCache(array(
'etag' => $etag,
'last_modified' => $date,
'max_age' => 10,
's_maxage' => 10,
'public' => true,
// 'private' => true,
));
return $response;
}
}
看到了开始使用浏览器 HTTP 缓存是多么容易和简单,让我们花点时间来看看当与 HTTP 反向代理服务器技术结合时,HTTP 缓存还有其他好处。
内容传送网络(CDN)
内容传送网络是分布式代理服务器网络,允许常见或流行的网页资源高可用和高性能分发。这些资源可以是文本、图像和脚本等网页对象,包括 CSS 和 JavaScript 库,可下载的对象,如文件和软件,以及实时流或点播流媒体。CDN 因此可以被用作一种互联网公共缓存。通过使用 CDN 托管所有库文件,您将浏览器 HTTP 缓存与 HTTP 反向代理缓存结合在一起。这意味着如果另一个网站或网页应用程序使用与您相同的库文件,您的用户浏览器将使用其缓存版本的库文件或提交一个请求到 CDN 而不是您的网页服务器来刷新文件。这不仅通过减少全球渲染相同内容所需的请求数量来减少网络延迟,还通过将刷新过期浏览器缓存的责任委托给 CDN 的反向代理缓存,从您的网页服务器中减轻了一部分工作负载。
这个更快的网络解决方案非常容易实现。通常只需要通过修改 DNS 配置将网页流量重定向到 CDN。例如,Cloudflare (www.cloudflare.com/
) 不需要对您的网页服务器配置进行任何更改就可以开始使用其 HTTP 反向代理缓存。一旦您在Cloudflare界面中注册了原始域名和您的网页服务器的 IP 地址,您只需要通过将域名指向Cloudflare服务器来修改您的 DNS 设置,就可以立即开始使用它。让我们使用 cURL 来查询使用Cloudflare的linuxforphp.net/
网站:
# curl -v https://linuxforphp.net
查询网站应该产生以下结果,确认它现在只能通过Cloudflare访问:
确认 linuxforphp.net 网站可以通过 Cloudflare 访问
正如我们所看到的,Cloudflare确实已启用,并已将 Cache-Control 和 Expires 添加到响应头中。
其他更快的网络工具
还有许多其他更快的网络工具可以帮助您优化您的网页应用程序和网站的性能。在这些众多工具中,有一些是谷歌在其开发者更快的网络网站上建议的(developers.google.com/speed/
)。其中一个工具将帮助您进一步分析网页应用程序的性能问题,那就是PageSpeed Insights。
这个工具可以快速识别您的网页应用可能的性能优化,基于您提交的 URL。为了进一步分析在Linux for PHP网站上使用Cloudflare的效果,让我们把 URL 提交到PageSpeed Insights工具。
以下是在使用Cloudflare之前的初始结果:
在不使用 Cloudflare 时对 linuxforphp.net 网站性能分析的结果
接下来是添加Cloudflare反向代理服务器后的结果:
使用 Cloudflare 时对 linuxforphp.net 网站性能分析的结果
我们不仅可以看到网站的总体性能要好得多,而且PageSpeed Insights还提出了关于如何进一步优化 Web 应用程序的建议。
在切换到Cloudflare之前,该工具的初始建议如下:
建议在不使用 Cloudflare 时优化 linuxforphp.net 网站的性能
然后,在切换到Cloudflare之后:
建议在使用 Cloudflare 时优化 linuxforphp.net 网站的性能
正如我们所看到的,优化建议的列表要短得多,但如果我们利用浏览器缓存特定的图像文件,消除一些阻塞渲染的 JavaScript 和 CSS,减小图像大小,并尝试减少服务器响应时间,我们肯定会得到一个完美的分数!
总结
在这一章中,我们涵盖了一些与Google的新倡议“更快的网络”相关的项目。我们已经了解了 HTTP/2 协议的内容以及 SPDY 项目是如何实现的,PHP-FPM 和 Zend OPCache 如何帮助您提高 PHP 脚本的性能,如何通过设置 Varnish Cache 服务器来使用 ESI 技术,如何使用客户端缓存,以及其他更快的网络工具在优化 Web 服务器性能时如何帮助您。
在下一章中,我们将看到即使一切似乎已经完全优化,我们仍然可以超越性能。
参考资料
[1] tools.ietf.org/html/rfc7540
[2] queue.acm.org/detail.cfm?id=2716278
[3] www.imperva.com/docs/Imperva_HII_HTTP2.pdf
[4] ilia.ws/files/zend_performance.pdf
[5] https://varnish-cache.org/docs/trunk/phk/firstdesign.html
[6] https://trends.builtwith.com/web-server,2018 年 3 月。
第十章:超越性能
在本书的第一章《更快的网络-入门》中,我们提到性能也与感知有关。事实上,正如前面所述,时间的测量取决于测量时刻,并且可以根据要执行的任务的复杂性、用户的心理状态以及用户的期望而变化,因为用户可能已经根据他认为是参考软件的标准来定义他们的期望。因此,应用程序完成其任务的良好方式也意味着软件必须满足用户对计算机程序应该如何执行任务的期望。因此,更快并不总是更好。
在本章中,我们将尝试更好地理解 UI 设计背后的原则,特别是在感知性能方面。我们将看到这些设计原则如何对用户对时间的主观感知产生真正的影响,并在没有真正的优化余地时改善感知性能。
因此,我们将涵盖以下要点:
-
计时和感知时间
-
速度感知
-
合理的延迟和响应时间
-
UI 设计原则和模式
-
超越性能工具
计时和感知时间
在之前的章节中,我们已经讨论了性能如何通过客观时间来衡量。客观时间是通过工具来测量的,它将一个即将到来的未来和一个即将到来的过去之间的持续时间分割成相等的测量单位,这些部分处于持续不断的存在流中。
客观时间的这一定义告诉我们,时间是存在的运动的效果,它将我们从一个不确定的未来状态带到一个冻结的过去状态,通过一个恒定的现在。它是客观的,因为第三方被用作见证这种存在从一个状态到另一个状态的流逝,通过将其分割成相等的测量单位。这就是为什么客观时间经常被称为计时时间,因为它指的是将时间分割成相等的测量单位的概念(例如秒、分钟、小时等)。显然,研究客观时间的科学领域是物理学。
话虽如此,毫无疑问,人类感知两个时刻之间的持续时间是一个可变的事物。事实上,我们都知道玩得开心时时间过得很快。在心理学领域,主观或感知时间因此被定义为心智对两个或多个连续事件之间实际时间流逝的意识水平所留下的印象。意识水平越高,时间被感知为持续时间越长。意识水平越低,时间似乎过得更快:
心智感知的时间流逝
许多因素可以影响心智感知持续时间。其中最显著的因素包括个人的情绪状态、对过去和预期事件的感知、压力水平、体温、药物的存在以及年龄对个人心智状态的一般影响。
将这些概念应用于计算机科学,特别是用户界面设计时,感知时间研究背后的概念成为引导我们发现如何影响用户对持续时间的感知以及这种感知如何影响用户整体满意度的原则。
当然,其中许多因素超出了计算机程序员的控制范围,但在设计用户界面时有一些因素需要考虑,以积极影响用户满意度。
速度感知
首先,根据 Paul Bakaus 的说法,一个人的意识滞后于当前发生的事情大约 80 毫秒。事实上,主观的现在总是客观的过去。此外,如果当前事件在智力上更加复杂,一个人需要更多的时间来理解和完全感知。这些因素适用于任何人。因此,所有用户都会在无意识中为计算机处理提供这种免费的启动时间。
其次,用户的情绪状态对时间的感知有很大影响。根据 Awwwards 和 Google 最近的一项研究,焦虑或匆忙的用户会认为超过 50%的网站加载速度慢,而冷静放松的用户则不到 25%。对于在移动中的用户和舒适坐着的用户同样适用:
一个人的情绪状态或活动水平对速度感知的影响
第三,年龄是考虑时间感知的重要因素。用户年龄越小,他对持续时间的感知就越强烈。根据 Awwwards 的同一项研究,18-24 岁的用户只认为一半的访问网站速度快,而 25 至 44 岁的用户则认为几乎四分之三的相同网站加载速度快:
一个人的年龄对速度感知的影响
最后,即使应用程序尚未加载完成,所有用户在开始有效使用应用程序时都会对持续时间的感知减少。缓慢的零售网站通常因为用户可以开始购物所需的商品,即使浏览器尚未完成整个页面的渲染,而获得了高度赞扬。
因此,有关速度感知的某些元素适用于所有用户,而其他元素则取决于特定用户的个人资料。开发人员需要发现这些特定元素,以更好地了解如何对整体用户满意度产生最佳影响。
合理的延迟和响应时间
另一个因素是用户认为合理的延迟。正如第一章中所述,“更快的 Web—入门”,这直接与用户认为某种类型应用程序的最佳性能有关。这种最佳性能通常根据用户可能认为是参考应用程序来确定。在基于 Web 的应用程序和网站的情况下,有一些阈值需要考虑,因为它们在平均所有 Web 用户之间是共享的。
首先,大多数用户认为 300 毫秒或更短的响应时间是即时的。这在很大程度上可以解释前面提到的“意识滞后”。至于 300 毫秒到 1 秒之间的响应时间,这被认为是合理的延迟,并给用户以平稳过渡的印象。除非有某种用户流程,许多用户在响应时间超过三秒的延迟后会开始失去注意力并感到不耐烦。此外,Google 最近的一项研究显示,超过 50%的用户会在移动网站的页面加载时间超过三秒时离开。超过八秒后,用户的注意力被认为已经丧失:
大多数用户认为在使用 Web 应用程序或浏览网站时,合理的延迟是多少
其次,所有已完成设定目标或在以前访问网站时有良好的感知速度体验的用户,在未来访问网站时更愿意宽容,并更有可能对持续时间有积极的感知。
最后,良好的速度体验不仅会确认用户对网站本身的满意度,还会影响用户对在线访问的最终结果以及对企业品牌的整体欣赏。
UI 设计原则和模式
考虑到所有先前的因素和概念,现在可以提炼和理解某些 UI 设计原则。
首先,速度对用户很重要。因此,如果您的应用程序没有其他优化,确保用户在初始页面完成渲染之前就可以开始使用应用程序。这意味着尽快实现页面的首次有意义绘制(FMP),以减少到达“可交互时间”的时间,这是用户可以开始与应用程序交互的第一时刻。一个基本的技术可以帮助您在任何其他内容之前加载页面的“以上折叠”内容,即将所有阻塞 JavaScript 放在页面主体的末尾。此外,页面的某些部分可以进行缓存以加快渲染,或者可以通过定期定时器触发的 AJAX 请求在浏览器中加载。最后,HTTP/2 的服务器推送功能和 HTTP 反向代理服务器在处理依赖许多 CSS 和 JavaScript 库和框架的网页渲染时可能非常有用。
其次,即使网站加载任何页面的时间少于一秒,也可以通过去除移动浏览器的点击延迟来加快速度。这可以通过在页面的头部添加 HTML meta 标签来实现:
<meta name="viewport" content="width=device-width">
此外,您可以使用Chrome CSS 规则来实现相同的效果:
touch-action: manipulation
对于旧版浏览器,请查看 FT Labs 的FastClick(github.com/ftlabs/fastclick
)。
出于页面加载速度如此之快的原因,可以选择添加简单的动画,以使页面转换更加流畅。最好在提示用户时缓慢开始,在需要立即反应的按钮和菜单时缓慢结束。这些基本的过渡动画应该持续 200 到 500 毫秒,当使用弹跳或弹性效果时,过渡应该持续 800 到 1,200 毫秒。尽管这些类型的视觉效果会给用户留下应用程序是高质量产品的印象,但不要过度使用网页过渡动画,并尽量避免在页面加载未知图像大小时出现内容跳动,以使整个用户体验尽可能流畅和简化。
第三,如果您的页面加载需要两到五秒,建议通过进度条、加载指示器或任何其他智能分心来向用户发送一些反馈。同时,确保通过使用简单的表达方式来解释正在发生的事情,例如“32MB 中的%已上传”,“正在发送电子邮件”或“剩余时间:1 分钟”。
最后,如果页面加载时间超过五秒,应该让用户进入活跃模式,让他们玩一个简单的游戏,例如。当然,您可以继续使用加载指示器、进度条和显示简短消息来解释正在发生的事情。但是让用户进入活跃模式将使他们对时间的流逝不那么敏感。这对于需要非常长的加载时间的页面特别有用。事实上,非常活跃的用户完全可能失去时间感,并对手头的愉快游戏感到高兴。如果您知道用户在查看您的应用程序时会感到焦虑、匆忙或在移动中,也可以使用这种技术。此外,当页面需要非常长的加载时间时,用户应该始终有可能中止操作并稍后重试。
这也将对用户的整体满意度产生积极影响,因为它允许用户完全控制这个漫长的操作:
根据预期的时间延迟适用的 UI 设计原则
现在,让我们看看如何使用先前的原则和模式来实现简单的 UI 设计。
“超越性能”工具
为了更好地了解如何实现这些类型的解决方案,我们将创建一个动画过渡,将其包装在一个非常慢的 PHP 脚本周围。因此,我们将尝试影响原始脚本的感知速度。
我们的原始 PHP 脚本将通过运行一个睡眠命令来模拟缓慢执行。以下是原始脚本的内容:
<?php
// chap10_slow_before.php
sleep(5);
echo 'The original page loads very slowly...';
如果我们立即运行这个脚本,我们肯定会感到脚本很慢,经过的时间可能会让我们相信出了什么问题:
脚本的缓慢执行可能会让我们相信出了什么问题
这个脚本确实给我们一种瞬间挂起的印象。
我们现在将创建一个 HTML 脚本,将查询原始 PHP 脚本,并通过 AJAX 请求获取脚本的输出。这个新的 HTML 脚本还将添加一些过渡动画,以影响原始脚本速度的用户感知。
为了实现这一点,我们将添加一个完全由 CSS 生成的脉动图标,并使用jQuery
和Modernizr
库来进行 AJAX 调用。这些库文件托管在 CDN 上,以便从 HTTP 反向代理缓存和浏览器缓存中受益。以下是第二个脚本的内容(chap10_slow_after.html
):
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Slow page</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
/* Center the loader */
#se-pre-con {
position: absolute;
left: 50%;
top: 50%;
z-index: 1;
width: 150px;
height: 150px;
margin: -75px 0 0 -75px;
border: 16px solid #f3f3f3;
border-radius: 50%;
border-top: 16px solid #3498db;
width: 120px;
height: 120px;
-webkit-animation: spin 2s linear infinite;
animation: spin 2s linear infinite;
}
@-webkit-keyframes spin {
0% { -webkit-transform: rotate(0deg); }
100% { -webkit-transform: rotate(360deg); }
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Add animation to "page content" */
.animate-bottom {
position: relative;
-webkit-animation-name: animatebottom;
-webkit-animation-duration: 1s;
animation-name: animatebottom;
animation-duration: 1s
}
@-webkit-keyframes animatebottom {
from { bottom:-100px; opacity:0 }
to { bottom:0px; opacity:1 }
}
@keyframes animatebottom {
from{ bottom:-100px; opacity:0 }
to{ bottom:0; opacity:1 }
}
#contents {
display: none;
text-align: left;
}
</style>
</head>
<body onload="myAnimatedAjax()" style="margin:0;">
<div id="se-pre-con"></div>
<div id="contents"></div>
<script type="text/javascript" src="http://ajax.googleapis.com/ajax/libs/jquery/1.5.2/jquery.min.js"></script>
<script type="text/javascript" src="http://cdnjs.cloudflare.com/ajax/libs/modernizr/2.8.2/modernizr.js"></script>
<script type="text/javascript">
jQuery.ajaxSetup({
beforeSend: function() {
$("#contents").html("Loading page... One moment please...").toggle();
},
complete: function(){
$("#se-pre-con").fadeOut("slow"); //toggle visibility: off
},
success: function(result) {
$("#contents").html(result);
}
});
function myAnimatedAjax() {
var myVar = setTimeout(animatedAjax, 500);
}
function animatedAjax() {
$.ajax({
type: "GET",
url: "/chap10_slow_before.php"
});
}
</script>
</body>
</html>
运行这个新脚本时,您应该会看到一个脉动图标出现:
脉动图标通知用户正在发生某事。
然后,几秒钟后,您会看到一条消息,说明正在加载所需的页面:
新消息会分散用户的注意力,并部分重置用户对时间流逝的感知
这条新消息旨在分散用户的注意力,并导致用户对时间的感知部分重置。最后,当 AJAX 请求完成时,脉动图标和消息都会消失,以显示其他页面的内容:
脉动图标和消息都消失了,原始脚本的输出显示出来了
当让新脚本运行时,我们确实会得到这样的印象,即原始脚本的墙时间已经减少,而实际上,由于在进行 AJAX 请求的 JavaScript 函数中添加了 0.5 秒的超时,它已经增加。如果您在这个新脚本上运行我们在前几章中提到的 JavaScript 性能分析器,您将看到幕后发生了什么:
大部分执行时间(六秒)都是在等待原始脚本完成执行
性能分析器证实,这个脚本大部分的墙时间都是由于网络 I/O 到原始脚本所解释的,它花费的时间与以前一样多。但是,通过新的包装脚本,我们给最终用户的印象是我们已经成功地实现了“超越性能”。
总结
在本章中,我们更好地理解了 UI 设计在性能感知方面的原则。我们已经看到这些设计原则如何对用户对时间的主观感知产生实际影响,以及在没有真正的优化余地时,它们如何改善了感知性能。
希望您发现本书对于更好地理解性能和效率概念、发现构成今天互联网的大部分新的基础网络技术以及帮助您掌握更快速的网络方面是有用的。