九、上传文件
PHP 处理表单的能力不仅限于文本。它也可以用来上传文件到服务器。例如,你可以为客户建立一个房地产网站来上传他们的房产图片,或者为你所有的朋友和亲戚建立一个网站来上传他们的假期图片。然而,你能做到并不一定意味着你应该去做。允许其他人上传资料到你的网站会让你面临各种各样的问题。您需要确保图像大小合适,质量合适,并且不包含任何非法内容。您还需要确保上传的内容不包含恶意脚本。换句话说,你需要像保护你自己的电脑一样小心地保护你的网站。
PHP 使得限制接受的文件的类型和大小变得相对简单。它不能做的是检查内容的适用性。仔细考虑安全措施,例如通过将上传表单放在受密码保护的区域来限制注册用户和可信用户的上传。
在你学会如何在第 11 和 19 章中限制对 PHP 页面的访问之前,如果部署在公共网站上,只能在受密码保护的目录中使用本章中的 PHP 解决方案。大多数托管公司通过网站的控制面板提供简单的密码保护。
本章的第一部分致力于理解文件上传的机制,这将使理解后面的代码变得更容易。这是一个相当激烈的章节,而不是快速解决方案的集合。但是在本章结束时,你将已经构建了一个能够处理单个和多个文件上传的 PHP 类。然后,只需编写几行代码,就可以以任何形式使用该类。
您将了解以下内容:
-
了解
$_FILES
数组 -
限制上传的大小和类型
-
防止文件被覆盖
-
处理多次上传
PHP 如何处理文件上传
术语上传意味着将文件从一台计算机移动到另一台计算机,但就 PHP 而言,所发生的只是文件从一个位置移动到另一个位置。这意味着您可以在本地计算机上测试本章中的所有脚本,而无需将文件上传到远程服务器。
PHP 默认支持文件上传,但是托管公司可以限制上传的大小或者完全禁止上传。在继续之前,最好检查一下远程服务器上的设置。
检查您的服务器是否支持上传
你需要的所有信息都显示在主 PHP 配置页面上,你可以通过在你的远程服务器上运行phpinfo()
来显示,如第二章所述。向下滚动,直到在核心部分找到file_uploads
。
如果本地值为 On,您就可以开始了,但是您还应该检查表 9-1 中列出的其他配置设置。
表 9-1
影响文件上传的 PHP 配置设置
|管理的
|
缺省值
|
描述
|
| — | — | — |
| max_execution_time
| Thirty | PHP 脚本可以运行的最大秒数。如果脚本运行时间更长,PHP 会生成一个致命错误。 |
| max_file_uploads
| Twenty | 可以同时上传的最大文件数。多余的文件会被忽略。 |
| max_input_time
| –1 | PHP 脚本被允许解析$_POST
和$_GET
数组和文件上传的最大秒数。默认设置为–1
,使用与max_execution_time
相同的值。非常大的上传很可能会超时。将该值设置为0
允许无限时间。 |
| post_max_size
| 8M | 所有$_POST
数据、包括文件上传的最大允许大小。虽然默认是 8M (8 兆),托管公司可能会施加一个较小的限制。 |
| upload_tmp_dir
| 空 | 这是 PHP 存储上传文件的地方,直到您的脚本将它们移动到一个永久的位置。如果在php.ini
中没有定义值,PHP 将使用系统默认的临时目录(Mac/Linux 上的C:\Windows\Temp
或/tmp
)。 |
| upload_max_filesize
| 2M | 单个上载文件的最大允许大小。默认值为 2M(兆字节),但托管公司可能会设置一个较小的限制。整数表示字节数。k 代表千字节,M 代表兆字节,G 代表千兆字节。 |
理论上,PHP 可以处理非常大的文件的上传,但是限制取决于表 9-1 中的设置。post_max_size
的值包含了$_POST
数组中的所有内容,所以在一个典型的服务器上可以同时上传的文件的总大小小于 8 MB,没有一个文件大于 2 MB。服务器管理员可以更改这些默认值,因此检查托管公司设置的限制很重要。如果超出这些限制,原本完美的脚本将会失败。
如果file_uploads
的本地值关闭,则上传被禁用。你对此无能为力,除了询问你的托管公司是否提供支持文件上传的软件包。您唯一的选择是转移到不同的主机或使用不同的解决方案,如通过 FTP 上传文件。
Tip
在使用phpinfo()
检查您的远程服务器的设置后,删除脚本或将其放在受密码保护的目录中。
向表单添加文件上载字段
向 HTML 表单添加文件上传字段很容易。只需将enctype="multipart/form-data"
添加到开始的<form>
标签中,并将<input>
元素的type
属性设置为file
。以下代码是一个简单的上传表单示例(在ch09
文件夹的file_upload_01.php
中):
<form action="file_upload.php" method="post" enctype="multipart/form-data">
<p>
<label for="image">Upload image:</label>
<input type="file" name="image" id="image">
</p>
<p>
<input type="submit" name="upload" value="Upload">
</p>
</form>
虽然这是标准的 HTML,但它在网页中的呈现方式取决于浏览器。大多数现代浏览器显示一个选择文件或浏览按钮,并在右侧显示一条状态消息或所选文件的名称(见图 9-1 )。一些较旧的浏览器会显示一个文本输入字段,当您在该字段内单击时会启动一个文件选择面板。这些差异不会影响上传表单的操作,但是您需要在设计布局时将它们考虑在内。
图 9-1
大多数浏览器都会显示一个按钮来打开文件选择面板
了解$_FILES 数组
令许多人困惑的是,他们的文件上传后似乎就消失了。这是因为,尽管上传表单使用了post
方法,PHP 还是在一个名为$_FILES
的单独的超全局数组中传输上传文件的细节。此外,文件会上传到临时文件夹,除非您明确地将它们移动到所需的位置,否则它们会被删除。这允许您在接受上传之前对文件进行安全检查。
检查$_FILES 数组
理解$_FILES
数组如何工作的最好方法是观察它的运行。您可以在您的计算机上的本地测试环境中测试一切。它的工作方式与上传文件到远程服务器相同。
-
在
php8sols
站点根目录下创建一个名为uploads
的文件夹。在uploads
文件夹中创建一个名为file_upload.php
的文件,并插入上一节中的代码。或者,从ch09
文件夹中复制file_upload_01.php
,并将文件重命名为file_upload.php
。 -
在结束的
</form>
标签后插入以下代码(它也在file_upload_02.php
中):</form> <pre> <?php if (isset($_POST['upload'])) { print_r($_FILES); } ?> </pre> </body>
它使用isset()
来检查$_POST
数组是否包含upload
,即提交按钮的name
属性。如果是,那么您知道表单已经提交,所以您可以使用print_r()
来检查$_FILES
数组。<pre>
标签使输出更容易阅读。
图 9-2
$_ FILES 数组包含上传文件的详细信息
-
保存
file_upload.php
并将其加载到浏览器中。 -
单击浏览(或选择文件)按钮并选择一个本地文件。单击打开(或在 Mac 上选择)关闭选择对话框,然后单击上传。您应该会看到类似图 9-2 的内容。
$_FILES
是多维数组——数组的数组。顶层包含一个元素,它从文件输入字段的name
属性中获取键(或索引),在本例中是image
。
顶层image
数组包含一个由五个元素组成的子数组,即:
-
name
:上传文件的原始名称 -
type
:上传文件的 MIME 类型 -
tmp_name
:上传文件的位置 -
error
:表示上传状态的整数 -
size
:上传文件的大小,以字节为单位
不要浪费时间去寻找tmp_name
指示的临时文件:它不会在那里。如果不立即保存,PHP 会丢弃它。
Note
MIME 类型是浏览器用来确定文件格式以及如何处理文件的标准。更多信息见 https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types
。
图 9-3
没有上传文件时$_ FILES 数组仍然存在
- 单击上传,不选择文件。这个
$_FILES
数组看起来应该如图 9-3 所示。
错误级别为 4 表示没有上传文件;0 表示上传成功。本章后面的表 9-2 列出了所有错误代码。
表 9-2
$_FILES 数组中不同错误级别的含义
|误差水平
|
意义
|
| — | — |
| Zero | 上传成功。 |
| one | 文件超过了在php.ini
中指定的最大上传大小(默认为 2 MB)。 |
| Two | 文件超过了由MAX_FILE_SIZE
指定的大小(见 PHP 解决方案 9-1)。 |
| three | 文件仅部分上传。 |
| four | 提交的表单没有指定文件。 |
| six | 没有临时文件夹。 |
| seven | 无法将文件写入磁盘。 |
| eight | 上传被未指定的 PHP 扩展停止。 |
- 选择一个程序文件,然后单击上传按钮。在许多情况下,表单会很乐意尝试上传程序,并显示其类型为 application/zip、application/octet-stream 或类似的内容。这应该作为一个警告,说明检查上传的文件类型是很重要的。
建立上传目录
出于安全考虑,通过在线表格上传的文件不应通过浏览器公开访问。换句话说,它们不应该在站点根目录中(通常是htdocs
、public_html
或www
)。在您的远程服务器上,创建一个用于在站点根目录之外上传的目录,并将权限设置为 644(所有者可以读写;其他人只能看)。
为 Windows 上的本地测试创建上传文件夹
对于下面的练习,我建议你在 c 盘的顶层创建一个名为upload_test
的文件夹。在 Windows 上没有权限问题,所以这就是你需要做的。
为 macOS 上的本地测试创建上传文件夹
Mac 用户可能需要做更多的准备,因为文件权限类似于 Linux。在你的主文件夹中创建一个名为upload_test
的文件夹,并按照 PHP 解决方案 9-1 中的说明进行操作。
如果一切顺利,你不需要做任何额外的事情。但是,如果您收到 PHP“未能打开流”的警告,请像这样更改upload_test
文件夹的权限:
-
在 Mac Finder 中选择
upload_test
,选择文件➤获取信息(Cmd+I)打开其信息面板。 -
在“共享与权限”中,单击右下方的挂锁图标解锁设置,然后将所有人的设置从只读更改为读写,如以下截图所示:
- 再次单击挂锁图标以保存新设置并关闭信息面板。现在你应该能够使用
upload_test
文件夹继续本章的剩余部分。
上传文件
在构建文件上传类之前,最好创建一个简单的文件上传脚本,以确保您的系统能够正确处理上传。
将临时文件移动到上传文件夹
上传文件的临时版本只有短暂的存在。如果您对该文件不做任何操作,它会立即被丢弃。你需要告诉 PHP 把它移到哪里,用什么来称呼它。使用move_uploaded_file()
函数可以做到这一点,该函数有以下两个参数:
-
临时文件的名称
-
文件新位置的完整路径名,包括文件名本身
获取临时文件本身的名称很容易:它作为tmp_name
存储在$_FILES
数组中。因为第二个参数需要完整的路径名,所以它给了您重命名文件的机会。目前,让我们保持简单,使用原始文件名,它作为name
存储在$_FILES
数组中。
PHP 解决方案 9-1:创建一个基本的文件上传脚本
继续使用与上一练习中相同的文件。或者,使用ch09
文件夹中的file_upload_03.php
。这个 PHP 解决方案的最终脚本在file_upload_04.php
中。
-
如果您正在使用上一个练习中的文件,请删除结束标签
</form>
和</body>
之间以粗体突出显示的代码:</form> <pre> <?php if (isset($_POST['upload'])) { print_r($_FILES); } ?> </pre> </body>
-
除了在 PHP 配置中设置的自动限制(见表 9-1 ),您还可以在 HTML 表单中指定上传文件的最大大小。在文件输入字段前添加以粗体突出显示的以下行:
<label for="image">Upload image:</label> <input type="hidden" name="MAX_FILE_SIZE" value="<?= $max ?>"> <input type="file" name="image" id="image">
这是一个隐藏的表单域,因此不会显示在屏幕上。然而,将它放在文件输入域之前是至关重要的;不然就不行了。name
属性MAX_FILE_SIZE
是固定的,区分大小写。value
属性以字节为单位设置上传文件的最大大小。
我没有指定一个数值,而是使用了一个名为$max
的变量。该值还将用于文件上传的服务器端验证,因此定义一次是有意义的,避免了在一个地方更改它而忘记在其他地方更改的可能性。
使用MAX_FILE_SIZE
的好处是,如果文件大于规定值,PHP 会放弃上传,避免文件太大时不必要的延迟。不幸的是,用户可以通过伪造隐藏字段提交的值来绕过这一限制,所以你将在本章剩余部分开发的脚本也将在服务器端检查大小。
-
在 DOCTYPE 声明上方的 PHP 块中定义
$max
的值,如下所示:<?php // set the maximum upload size in bytes $max = 51200; ?> <!DOCTYPE HTML>
这将最大上传大小设置为 50 KB (51,200 字节)。
-
将上传文件从临时位置移动到永久位置的代码需要在表单提交后运行。将以下代码插入您刚刚在页面顶部创建的 PHP 块中:
$max = 51200; if (isset($_POST['upload'])) { // define the path to the upload folder $path = '/path/to/upload_test/'; // move the file to the upload folder and rename it move_uploaded_file($_FILES['image']['tmp_name'], $path . $_FILES['image']['name']); } ?>
虽然代码很短,但内容很多。只有当点击了上传按钮时,条件语句才执行代码,方法是检查它的键是否在
$_POST
数组中。$path
的值取决于您的操作系统和upload_test
文件夹的位置。-
如果您使用的是 Windows,并且在 c 盘的顶层创建了
upload_test
文件夹,它应该是这样的:$path = 'C:/upload_test/';
请注意,我使用了正斜杠,而不是 Windows 约定的反斜杠。你可以使用任何一个,但是如果你使用反斜杠,最后一个需要用另一个反斜杠转义,像这样(否则,反斜杠转义引号):
$path = 'C:\upload_test\\';
-
在 Mac 上,如果你在你的个人文件夹中创建了
upload_test
文件夹,它应该是这样的(用你的 Mac 用户名替换用户名):$path = '/Users/``username
-
在远程服务器上,您需要完全限定的文件路径作为第二个参数。在 Linux 上,它可能是这样的:
$path = '/home/user/private/upload_test/';
-
if
语句中的最后一行用move_uploaded_file()
函数移动文件。该函数有两个参数:临时文件的名称和保存文件的完整路径。
$_FILES
是一个多维数组,其名称取自文件输入字段。所以$_FILES['image']['tmp_name']
是临时文件,$_FILES['image']['name']
包含原始文件的名称。第二个参数$path . $_FILES['image']['name']
将上传的文件以其原始名称存储在上传文件夹中。
Caution
您可能会遇到使用copy()
而不是move_uploaded_file()
的脚本。如果没有其他适当的检查,copy()
会使您的网站面临严重的安全风险。例如,恶意用户可能试图欺骗您的脚本复制它不应该访问的文件,如密码文件。始终使用move_uploaded_file()
;安全多了。
-
保存
file_upload.php
,并将其加载到浏览器中。点击浏览或选择文件按钮,从php8sols
站点的images
文件夹中选择一个文件。如果您从其他地方选择一个,请确保它小于 50 KB。点按“打开”(在 Mac 上选取),以在表单中显示文件名。点击上传按钮。如果您正在本地测试,表单输入域应该几乎立即被清空。 -
导航到
upload_test
文件夹,确认您选择的图像副本在那里。如果不是,对照file_upload_04.php
检查您的代码。如有必要,还要检查是否在上传文件夹上设置了正确的权限。注意下载文件使用
C:/upload_test/
。根据您自己的设置进行调整。
如果您没有收到错误信息并且找不到文件,请确保图像没有超过upload_max_filesize
(参见表 9-1 )。还要检查是否没有在$path
的末尾留下尾随斜线。你可能会在你的磁盘结构中找到更高一级的upload_testmyfile.jpg
,而不是在upload_test
文件夹中的myfile.jpg
。
-
将
$max
的值改为3000
,保存file_upload.php
,选择一个大于 2.9 KB 的文件上传(images 文件夹中的任何文件都可以)再次测试。点击上传按钮并检查upload_test
文件夹。文件不应该在那里。 -
如果您有心情进行实验,请将
MAX_FILE_SIZE
隐藏字段移动到文件输入字段下方,然后再试一次。确保您选择的文件不同于您在步骤 6 中使用的文件,因为move_uploaded_file()
会覆盖同名的现有文件。稍后您将了解如何为文件指定唯一的名称。
这一次,文件应该被复制到您的上传文件夹。隐藏字段必须出现在文件输入元素之前,MAX_FILE_SIZE
才能生效。继续之前,请将隐藏字段移回其原始位置。
创建 PHP 文件上传类
正如您刚才看到的,上传一个文件只需要几行代码,但这本身还不足以完成任务。您需要通过实施以下步骤来使该过程更加安全:
-
检查错误级别。
-
在服务器上验证文件没有超过最大允许大小。
-
检查文件是否属于可接受的类型。
-
删除文件名中的空格。
-
重命名与现有文件同名的文件,以防止覆盖。
-
自动处理多个文件上传。
-
通知用户结果。
每次想要上传文件时,您都需要实现这些步骤,因此构建一个易于重用的脚本是有意义的。这就是我选择使用自定义类的原因。构建 PHP 类通常被认为是一门高级学科,但是不要因此而放弃。如果您需要学习使用类和名称空间的基础知识,请参见第四章中的“构建定制类”。
如果你赶时间,完成的类在 ch09/Php8Solutions 文件夹中。即使您没有自己构建脚本,也要通读描述,这样您就可以清楚地了解它是如何工作的。
PHP 解决方案 9-2:创建基本的文件上传类
在这个 PHP 解决方案中,您将创建一个名为Upload
的类的基本定义来处理文件上传。您还将创建该类的一个实例(一个Upload
对象),并使用它来上传图像。给自己充足的时间来完成以下步骤。它们并不难,但是如果您从未使用过 PHP 类,它们会引入一些不熟悉的概念。
-
在
php8sols
站点根文件夹中创建一个名为Php8Solutions
的子文件夹。在文件夹名称中使用相同的大小写字母组合。 -
在
Php8Solutions
文件夹中创建一个名为File
(大写 F)的子文件夹。 -
在新的
Php8Solutions/File
文件夹中,创建一个名为Upload.php
的文件。同样,在文件名中使用相同的大小写字母组合。然后插入以下代码:<?php namespace Php8Solutions\File; class Upload { }
所有剩下的代码都在花括号之间。这个文件将只包含 PHP 代码,所以你不需要一个结束的 PHP 标签。
Note
尽管该类改编自本书第三版和第四版的版本,但它通过 PHP 中的新特性改变了构造函数的签名。因此,我使用了与以前版本不同的名称空间。面向对象编程的一个重要原则是,即使类的内部结构发生变化,用户界面也应该保持不变。使用不同的名称空间表明 Upload 类不仅仅是可以插入现有脚本的更新版本。
- PHP 类通过将一些变量和函数声明为 protected 来隐藏它们的内部工作。如果你用关键字
protected
作为变量或函数的前缀,它只能在类或子类中被访问。这可以防止值被意外更改。
Upload
类需要以下项目的受保护变量:
-
上传文件夹的路径
-
最大文件大小
-
允许的 MIME 类型
-
报告上传状态的消息
通过在花括号内添加变量,为允许的 MIME 类型和消息创建变量,如下所示:
class Upload {
protected $permitted = [
'image/gif',
'image/jpeg',
'image/pjpeg',
'image/png',
'image/webp'
];
protected $messages = [];
}
使用引用当前对象的$this->
,可以在类的其他地方访问这些属性。例如,在类定义中,您以$this->permitted
的身份访问$permitted
。
Note
当你第一次在一个类中声明一个属性时,它像其他变量一样以美元符号开始。但是,您在- >
操作符后面省略了属性名中的美元符号。
两个受保护的属性都被赋予了默认值:
- 创建对象时,类定义文件自动调用该类的构造函数方法,该方法初始化对象。所有类的构造函数方法被称为
__construct()
(带有两个下划线)。与您在上一步中定义的属性不同,构造函数需要在类之外可访问,因此您在它的定义之前添加了public
关键字。
-
包含一个图像 MIME 类型的数组。
-
$messages
是一个空数组。
Upload
类的构造函数有三个参数:
-
上传表单中文件字段的名称
-
您要上传文件的文件夹的路径
-
允许的最大文件大小(这将有一个默认值,使其可选)
第二个和第三个参数将使用 PHP 8 新增的构造函数属性提升特性来创建受保护的属性(参见第四章中的“使用构造函数属性提升”)。将以下代码添加到受保护的属性列表之后,确保它在类定义的右大括号之前:
public function __construct(
string $field,
protected string $path,
protected int $max = 51200
) {
if (!is_dir($this->path) && !is_writable($this->path)) {
throw new \Exception("$this->path must be a valid, writable directory.");
} else {
$this->path = rtrim($this->path, '/\\') . DIRECTORY_SEPARATOR;
if ($this->checkFile($_FILES[$field])) {
$this->moveFile($_FILES[$field]);
}
}
}
每个参数前面都有一个类型声明,指定前两个必须是字符串,第三个必须是整数。第二个和第三个参数前面有一个可见性声明,使得它们的值可以通过使用$this
的类定义的其余部分来访问。
构造函数中的条件语句将$path
受保护的属性传递给is_dir()
和is_writable()
函数,这两个函数检查提交的值是可写的有效目录(文件夹)。如果不是,构造函数抛出一个异常。
Note
类可以定义自己的异常,因为 Upload 类是在名称空间中定义的,所以不清楚构造函数应该使用自定义异常还是 PHP 的核心部分Exception
类。要访问名称空间中的核心命令,需要在它们前面加一个反斜杠。这就是为什么 Exception 前面有一个反斜杠。我们使用的是核心异常类,而不是自定义的。
如果该值是一个有效的可写目录,rtrim()
函数从$path
的末尾删除任何空格和斜杠,然后连接操作系统的正确目录分隔符。这确保了路径以斜杠结束,不管它是否是用户在创建Upload
对象时添加的。当只有一个参数传递给rtrim()
时,它只移除空白。第二个可选参数是一个字符串,它包含所有要去除的字符。转义右引号需要两个反斜杠。
嵌套的条件语句将$_FILES[$field]
传递给我们接下来要定义的两个内部方法。$field
的值来自传递给构造函数的第一个参数,因此它将包含文件输入字段的名称。
Tip
$_FILES
是 PHP 的超全局数组之一,因此它在脚本的所有部分都可用。这就是为什么不需要将它作为参数传递给类构造函数方法。
-
嵌套的条件语句使用
$this
关键字调用checkFile()
。$this
关键字也用于调用类中定义的函数(方法)。目前,我们假设该文件没有问题,因此checkFile()
将简单地返回true
。将以下代码添加到类定义中:protected function checkFile($file) { return true; }
在定义前面加上protected
关键字意味着这个方法只能在类内部访问。我们将返回 PHP 解决方案 9-3 中的checkFile()
,在上传文件之前添加一系列测试。
Tip
类中函数(方法)定义的顺序并不重要,只要它们在类的花括号中。然而,我倾向于将所有公共方法放在顶部,受保护的方法放在底部。
-
如果文件通过了一系列测试,
upload()
方法中的条件语句将文件传递给另一个名为moveFile()
的内部方法,它基本上是我们在 PHP 解决方案 9-1 中使用的move_uploaded_file()
函数的包装器。代码如下所示:protected function moveFile($file) { $success = move_uploaded_file($file['tmp_name'], $this->destination . $file['name']); if ($success) { $result = $file['name'] . ' was uploaded successfully'; $this->messages[] = $result; } else { $this->messages[] = 'Could not upload ' . $file['name']; } }
如果上传成功,move_uploaded_file()
返回true
。否则,它返回false
。通过将返回值存储在$success
中,适当的消息被存储在$messages
数组中。如果$success
为真,则消息最初被分配给$result
,而如果失败,则直接分配给$messages
数组。这是因为如果文件需要重命名,更多的信息将被添加到成功消息中。
-
由于
$messages
是一个受保护的属性,您需要创建一个公共方法来检索数组的内容:public function getMessages() { return $this->messages; }
这只是返回$messages
数组的内容。既然这就是它的全部功能,为什么不首先将数组公开呢?可以在类定义之外访问和更改公共属性。保护$messages
确保数组的内容不会被修改,所以您知道消息是由类生成的。对于这样的消息,这可能看起来没什么大不了的,但是当您开始处理更复杂的脚本或在团队中工作时,这就变得非常重要了。
-
保存
Upload.php
并切换到file_upload.php
。 -
在
file_upload.php
的顶部,通过在开始的 PHP 标签后添加下面一行来导入Upload
类:
use Php8Solutions\File\Upload;
Caution
您必须在脚本的顶层导入命名空间类,即使类定义是在以后加载的。将use
放在条件语句中会产生一个解析错误。
-
在条件语句中,删除调用
move_uploaded_file()
函数的代码,然后使用require_once
来包含Upload
类定义:if (isset($_POST['upload'])) { // define the path to the upload folder $path = 'C:/upload_test/'; require_once '../Php8Solutions/File/Upload.php'; }
-
我们现在可以创建一个
Upload
类的实例,但是因为它可能抛出一个异常,所以最好创建一个try/catch
块(参见第四章中的“处理错误和异常”)。在上一步插入的代码后立即添加以下代码:try { $loader = new Upload('image', $path); $result = $loader->getMessages(); } catch (Throwable $t) { echo $t->getMessage(); }
这创建了一个名为$loader
的Upload
类的实例,通过向它传递文件输入字段的名称和upload_test
文件夹的路径。然后,它调用getMessages()
方法,将结果存储在$result
中。
catch
块将捕获内部错误和异常,因此类型声明是Throwable
而不是Exception
。没有必要在Throwable
前面加上反斜杠,因为file_upload.php
中的脚本不在名称空间中。只有类定义在命名空间中。
Caution
Upload
类有一个getMessages()
方法,而异常使用getMessage()
。多一个“s”会有所不同。
-
在表单上方添加以下 PHP 代码块,以显示由
$loader
对象返回的任何消息:<body> <?php if (isset($result)) { echo '<ul>'; foreach ($result as $message) { echo "<li>$message</li>"; } echo '</ul>'; } ?> <form action="file_upload.php" method="post" enctype="multipart/form-data">
这是一个简单的foreach
循环,将$result
的内容显示为一个无序列表。当页面第一次加载时,$result
没有设置,所以这段代码只在表单提交后运行。
图 9-4
Upload
类报告成功上传
- 保存
file_upload.php
并在浏览器中测试。只要你选择了一个小于 50 KB 的图像,你就会看到文件上传成功的确认,如图 9-4 所示。
您可以将您的代码与ch09
文件夹中的file_upload_05.php
和Php8Solutions/File/Upload_01.php
进行比较。
这个类做的和 PHP 解决方案 9-1 完全一样:它上传一个文件,但是它需要更多的代码来完成。但是,您已经为将要对上传的文件执行一系列安全检查的类打下了基础。这是你只需编写一次的代码。当您使用该类时,您不需要再次编写这些代码。
如果您以前没有使用过对象和类,一些概念可能看起来很奇怪。将$loader
对象简单地看作是访问您在Upload
类中定义的函数(方法)的一种方式。你经常创建单独的对象来存储不同的值,例如,当处理DateTime
对象时。在这种情况下,单个对象足以处理文件上传。
检查上传错误
目前,Upload
类不加选择地上传任何类型的文件。甚至可以绕过 50 KB 的限制,因为唯一的检查是在浏览器中进行的。在将文件交给moveFile()
方法之前,checkFile()
方法需要运行一系列测试。其中最重要的是检查由$_FILES
数组报告的错误级别。表 9-2 显示了错误等级的完整列表。
错误等级 5 目前尚未定义。
PHP 解决方案 9-3:测试错误级别、文件大小和 MIME 类型
这个 PHP 解决方案更新了checkFile()
方法,以调用一系列内部(受保护的)方法来验证该文件是否可以接受。如果文件由于任何原因失败,将会有一条错误消息报告原因。继续与Upload.php
合作。或者,使用ch09/Php8Solutions/File
文件夹中的Upload_01.php
,将其移动到php8sols
站点顶层的Php8Solutions/File
,并将其重命名为Upload.php
。(总是从部分完成的文件中删除下划线和数字。)
-
checkFile()
方法需要运行三个测试:错误级别、文件大小和文件的 MIME 类型。像这样更新方法定义:protected function checkFile($file) { $errorCheck = $this->getErrorLevel($file); $sizeCheck = $this->checkSize($file); $typeCheck = $this->checkType($file); return $errorCheck && $sizeCheck && $typeCheck; }
传递给
checkFile()
方法的参数是$_FILES
数组中的顶级元素。我们正在使用的表单中的上传字段被称为image
,所以$file
相当于$_FILES['image']
。原来,
checkFile()
只是简单的返回了true
。现在,它运行一系列的内部方法,稍后你会定义这些方法。如果文件通过测试,每个方法都将返回true
。否则,如果发现上传的文件有问题,它将返回false
,并向$messages
数组追加一条适当的错误消息。当每组检查完成后,checkFile()
返回检查的组合结果。如果任何测试失败,它返回false
并阻止文件上传。否则,它返回true
,允许上传文件。 -
getErrorLevel()
方法使用一个match
语句来检查表 9-2 中列出的错误等级。如果错误等级为 0,则表示文件上传成功,因此返回true
。否则,它创建一个合适的消息添加到$messages
数组并返回$result
。代码如下所示:protected function getErrorLevel($file) { $result = match($file['error']) { 0 => true, 1, 2 => $file['name'] . ' is too big: (max: ' . $this->getMaxSize() . ').', 3 => $file['name'] . ' was only partially uploaded.', 4 => 'No file submitted.', default => 'Sorry, there was a problem uploading ' . $file['name'] }; return $result; }
错误级别 1 和 2 的部分消息由一个名为
getMaxSize()
的方法创建,该方法将$max
的值从字节转换为千字节。你将很快定义getMaxSize()
。只有前四个错误级别有描述性消息。关键字
default
捕捉其他错误级别,包括将来可能添加的任何错误级别,并添加一个通用原因。 -
因为如果有问题,
getErrorLevel()
中的match
语句会返回一条错误消息,所以我们需要将它添加到$messages
属性中。修改checkFile()
方法来处理返回值,如下所示:$errorCheck = $this->getErrorLevel($file); if ($errorCheck !== true) { $this->messages[] = $errorCheck; $errorCheck = false; } $sizeCheck = $this->checkSize($file);
这使用不相同的比较运算符来检查返回值。如果不是布尔值
true
,则getErrorLevel()
中的match
语句返回的错误信息被添加到$messages
属性中,$errorCheck
被重置为false
。 -
checkSize()
方法如下所示:protected function checkSize($file) { if ($file['error'] == 1 || $file['error'] == 2 ) { return false; } elseif ($file['size'] == 0) { $this->messages[] = $file['name'] . ' is an empty file.'; return false; } elseif ($file['size'] > $this->max) { $this->messages[] = $file['name'] . ' exceeds the maximum size for a file (' . $this->getMaxSize() . ').'; return false; } return true; }
条件语句从检查错误级别开始。如果是 1 或 2,说明文件太大,所以方法简单地返回
false
。已经通过getErrorLevel()
方法设置了适当的错误消息。下一个条件检查报告的大小是否为零。虽然如果文件太大或者没有选择文件会发生这种情况,但是这些情况已经被
getErrorLevel()
方法所涵盖。所以假设文件是空的。生成适当的消息,该方法返回false
。接下来,将报告的大小与存储在
$max
属性中的值进行比较。尽管太大的文件应该触发错误级别 2,但是您仍然需要进行这种比较,以防用户设法避开MAX_FILE_SIZE
。错误信息也使用getMaxSize()
显示最大尺寸,然后返回false
。如果大小合适,该方法返回
true
。 -
第三个测试检查 MIME 类型。将以下代码添加到类定义中:
protected function checkType($file) { if (!in_array($file['type'], $this->permitted)) { $this->messages[] = $file['name'] . ' is not a permitted type of file.'; return false; } return true; }
条件语句使用带有逻辑 Not 运算符的
in_array()
函数,根据存储在$permitted
属性中的数组检查由$_FILES
数组报告的类型。如果不在数组中,拒绝的原因被添加到$messages
数组中,该方法返回false
。否则返回true
。 -
getErrorLevel()
和checkSize()
使用的getMaxSize()
方法将存储在$max
中的原始字节数转换成更友好的格式。将以下定义添加到类文件中:public function getMaxSize() { return number_format($this->max/1024, 1) . ' KB'; }
这使用了
number_format()
函数,它通常有两个参数:想要格式化的值和想要该数字具有的小数位数。第一个参数是$this->max/1024
,它将$max
除以 1024(一千字节中的字节数)。第二个参数是 1,所以数字被格式化为一个小数位。最后的. ' KB'
将 KB 连接到格式化的数字。如果您想在使用
Upload
类的脚本的另一部分显示值,那么getMaxSize()
方法已经被声明为公共的。 -
保存
Upload.php
并用file_upload.php
再次测试。对于小于 50 KB 的图像,它的工作方式和以前一样。但是如果你尝试上传一个太大并且 MIME 类型错误的文件,你会得到类似图 9-5 的结果。
您可以对照ch09/Php8Solutions/File
文件夹中的Upload_02.php
来检查您的代码。
图 9-5
该类现在报告大小和 MIME 类型无效的错误
更改受保护的属性
$permitted
属性只允许上传图像,$max
属性限制文件不能超过 50 KB,但是这些限制可能太严格了。您可以通过使用上传构造函数的可选第三个参数来更改$max
。让我们为$permitted
属性添加另一个可选参数。
PHP 解决方案 9-4:允许上传不同类型和大小的文件
这个 PHP 解决方案向你展示了如何允许上传其他类型的文件,以及改变最大允许大小。您还将看到当您不想更改所有可选参数时,如何使用命名参数来避免提供所有参数的需要。
继续使用以前的 PHP 解决方案中的Upload.php
。或者,使用ch09/Php8Solutions/File
文件夹中的Upload_02.php
。
-
为了使
Upload
类更加灵活,向构造函数签名添加另一个可选参数,如下所示:public function __construct( string $field, protected string $path, protected int $max = 51200, string|array|null $mime = null ) {
$mime
参数前面有一个union
类型声明(参见第四章中的“指定多种数据类型”),允许字符串、数组或 null。默认值为 null。 -
编辑构造函数方法中的
else
块,向$permitted
属性添加新的 MIME 类型,如下所示:} else { $this->path = rtrim($this->path, '/\\') . DIRECTORY_SEPARATOR; if (!is_null($mime)) { $this->permitted = array_merge($this->permitted, (array) $mime); } if ($this->checkFile($_FILES[$field])) { $this->moveFile($_FILES[$field]); } }
嵌套的条件语句使用带有逻辑 Not 运算符的
is_null()
函数来检查$mime
是否为null
。如果不是,那么array_merge()
函数会将$mime
附加到$permitted
属性中的数组。array_merge()
的第二个参数前面是数组转换运算符(见第四章中的表 4-1)。如果单个 MIME 类型作为字符串传递给构造函数,那么这个函数会将$mime
转换成一个数组。 -
保存
Upload.php
并再次测试file_upload.php
。它应该像以前一样继续上传小于 50 KB 的图像。 -
修改
file_upload.php
,将可选参数$mime
的命名参数添加到Upload
构造函数中,如下所示:
$loader = new Upload('image', $path, mime: 'application/pdf');
命名参数使用去掉前导$
符号后的参数名,后跟一个冒号。如果您不想改变它们的值,它们允许您跳过其他可选参数。
- 再次测试
file_upload.php
上传 PDF 文件。如果小于 50 KB,应该可以正常工作。但是,如果文件超过 50 KB,您应该会看到类似于图 9–6 的内容。
图 9-6
检查 MIME 类型时似乎有错误
发生的情况是文件没有被上传,因为它的大小超过了MAX_FILE_SIZE
。因此,$_FILES
数组的type
元素没有值。当这种情况发生时,尝试检查 MIME 类型是没有意义的。
-
修改
checkFile()
方法,将对checkType()
的调用包装在条件语句中,如下所示:protected function checkFile($file) { $errorCheck = $this->getErrorLevel($file); if ($errorCheck !== true) { $this->messages[] = $errorCheck; $errorCheck = false; } $sizeCheck = $this->checkSize($file); $typeCheck = false; if (!empty($file['type'])) { $typeCheck = $this->checkType($file); } return $errorCheck && $sizeCheck && $typeCheck; }
这使用带有逻辑非运算符的
empty()
函数来验证$_FILES
数组的type
元素不为空。但是,如果不执行对 MIME 类型的检查,$typeCheck
将是一个未定义的变量,因此需要在条件语句之前将其初始化为false
。如果类型正常,$typeCheck
将被checkType()
方法重置为true
。 -
如果您再次用一个大的 PDF 文件测试上传表单,错误消息应该不再显示。
-
充分更改
upload_file.php
顶部的$max
的值,以上传大 PDF(代码就在处理上传的条件语句之前)。您还需要将$max
传递给try
块中的构造函数。通常,它应该是第三个参数,但是使用命名参数的一个优点是它们可以按任何顺序排列。像这样修改对构造函数的调用: -
通过改变
$max
的值并将其传递给构造函数,可以影响表单隐藏字段中的MAX_FILE_SIZE
和存储在类中的最大值。在再次测试之前,保存file_upload.php
并在浏览器中重新加载。这对于刷新隐藏表单字段中的值MAX_FILE_SIZE
是必要的。现在一切都应该正常工作了。
$loader = new Upload('image', $path, mime: 'application/pdf', max: $max);
您可以对照ch09/Php8Solutions/File
文件夹中的Upload_03.php
来检查您的类定义。在ch09
文件夹的file_upload_06.php
里有一个上传表格的更新版本。
到目前为止,我希望您已经明白了如何从专门做某项工作的函数(方法)中构建一个 PHP 类。修正关于 PDF 不是允许类型的错误消息变得更加容易,因为该消息只能来自于checkType()
方法。方法定义中使用的大部分代码依赖于内置的 PHP 函数。一旦您了解了哪些函数最适合手头的任务,构建一个类——或任何其他 PHP 脚本——就变得容易多了。
PHP 解决方案 9-5:重命名文件
默认情况下,如果上传的文件与上传文件夹中的文件同名,PHP 会覆盖现有文件。这个 PHP 解决方案改进了Upload
类,增加了在名称冲突时在文件扩展名前插入数字的选项。它还用下划线替换文件名中的空格,因为空格有时会引起问题。
继续使用以前的 PHP 解决方案中的Upload.php
。或者,使用ch09/Php8Solutions/File
文件夹中的Upload_03.php
。
- 将一个新的受保护属性添加到位于
Upload.php
中的类定义顶部的现有属性中:
protected $newName;
这将用于存储文件的新名称,如果它被更改。
-
向构造函数签名添加第五个可选参数,以控制重复项的重命名,如下所示:
public function __construct( string $field, protected string $path, protected int $max = 51200, string|array|null $mime = null, bool $rename = true ) {
这使得重命名文件成为默认设置。
图 9-7
空格已被下划线取代
-
我们需要在文件名通过了由
checkFile()
方法运行的其他测试之后检查它。将下面以粗体突出显示的行添加到构造函数方法的最后一个条件语句中:if ($this->checkFile($_FILES[$field]) { $this->checkName($_FILES[$field], $rename); $this->moveFile($_FILES[$field]); }
如果文件没有通过之前的任何测试,你不需要检查文件名,所以只有当
checkFile()
返回true
时,加粗的代码才会调用新方法checkName()
。 -
将
checkName()
定义为受保护的方法。代码的第一部分如下所示:protected function checkName($file, $rename) { $this->newName = null; $nospaces = str_replace(' ', '_', $file['name']); if ($nospaces != $file['name']) { $this->newName = $nospaces; } }
该方法首先将
$newName
属性设置为null
(换句话说,没有值)。该类最终将能够处理多个文件上传。因此,每次都需要重置该属性。然后,
str_replace()
函数用下划线替换文件名中的空格,并将结果赋给$nospaces
。PHP 解决方案 5-4 中描述了str_replace()
函数。将
$nospaces
的值与$file['name']
进行比较。如果它们不相同,$nospaces
被赋值为$newName
属性的值。它处理文件名中的空格。在处理重复文件名之前,让我们修复将上传的文件移动到目的地的代码。
-
如果名称已经更改,
moveFile()
方法在保存文件时需要使用修改后的名称。像这样更新moveFile()
方法的开头:protected function moveFile($file) { $filename = $this->newName ?? $file['name']; $success = move_uploaded_file($file['tmp_name'], $this->path . $filename); if ($success) {
新的第一行使用零合并操作符(参见第四章中的“使用零合并操作符设置默认值”)为
$filename
赋值。如果已经通过checkName()
方法设置了$newName
属性,则使用新名称。否则,包含来自$_FILES
数组的原始值的$file['name'],
被分配给$filename
。在第二行,
$filename
替换连接到$path
属性的值。因此,如果名称已经更改,新名称将用于存储文件。但是如果没有进行更改,则使用原始名称。 -
让用户知道文件名是否被更改是一个好主意。对
moveFile()
中的条件语句进行以下更改,如果文件已成功上传,该语句将创建消息:if ($success) { $result = $file['name'] . ' was uploaded successfully'; if (!is_null($this->newName)) { $result .= ', and was renamed ' . $this->newName; } $this->messages[] = $result; }
如果
$newName
属性不是null
,那么您知道文件已经被重命名,并且使用组合连接操作符(.=
)将该信息添加到存储在$result
中的消息中。 -
保存
Upload.php
并测试名称中含有空格的上传文件。空格应该用下划线代替,如图 9-7 所示。 -
接下来,将重命名重复文件的代码添加到
checkName()
方法中。在方法的右大括号前插入以下代码:if ($rename) { $name = $this->newName ?? $file['name']; if (file_exists($this->path . $name)) { // rename file $basename = pathinfo($name, PATHINFO_FILENAME); $extension = pathinfo($name, PATHINFO_EXTENSION); $this->newName = $basename . '_' . time() . ".$extension"; } }
条件语句检查
$rename
是true
还是false
。只有当它是true
时,大括号内的代码才会被执行。条件块中的第一行代码使用 null 合并操作符来设置
$name
的值。这与moveFile()
方法中使用的技术相同。如果$newName
属性有一个值,那么这个值被分配给$name
。否则,将使用原始名称。然后,我们可以通过将
$name
连接到$path
属性来获取完整路径并将其传递给file_exists()
函数,从而检查是否已经存在同名文件。如果上传目录中已经有一个同名文件,那么返回true
。如果一个同名文件已经存在,接下来的两行使用
pathinfo()
分别使用常量PATHINFO_FILENAME
和PATHINFO_EXTENSION
将文件名分成基本名和扩展名。既然我们已经将基本名称和扩展名存储在不同的变量中,那么通过在基本名称和扩展名之间插入一个数字来构建新名称就很容易了。理想情况下,这些数字应该从 1 开始递增。然而,在一个繁忙的网站上,这将消耗大量资源,并且不能保证防止两个人同时上传同名文件的竞争情况。我选择了一个更简单的解决方案,在基本名称和扩展名之间插入一个下划线,后跟当前的 Unix 时间戳。time()
函数返回自 1970 年 1 月 1 日午夜 UTC(协调世界时)以来的秒数。 -
保存
Upload.php
并测试file_upload.php
中修改后的类。首先为rename
添加一个命名参数,并在对Upload
构造函数的调用中将其设置为false
,如下所示: -
多次上传同一个文件。您应该会收到上传成功的消息,但是当您检查
upload_test
文件夹的内容时,应该只有该文件的一个副本。每次都会被覆盖。 -
从对构造函数的调用中移除最后一个参数:
$loader = new Upload('image', $path, mime: 'application/pdf', max: $max, rename: false);
图 9-8
这个类删除文件名中的空格,防止文件被覆盖。
-
保存
file_upload.php
并重复测试,多次上传相同的文件。每次上传文件时,您应该会看到一条消息,说明文件已被重命名。 -
通过检查
upload_test
文件夹的内容来检查结果。你应该会看到类似图 9-8 的东西。
$loader = new Upload('image', $path, mime: 'application/pdf', max: $max);
如有必要,对照ch09/Php8Solutions/File
文件夹中的Upload_04.php
检查您的代码。
Tip
在这个 PHP 解决方案中,使用命名参数不是绝对必要的,因为总是使用前两个可选参数,尽管与构造函数签名中的顺序不同。命名参数的价值在于能够跳过可选参数,同时设置函数签名中稍后列出的其他参数。命名参数只在 PHP 8 和更高版本中可用。
上传多个文件
您现在有了一个灵活的文件上传类,但是它一次只能处理一个文件。将multiple
属性添加到文件字段的<input>
标签允许在 HTML5 兼容浏览器中选择多个文件。
构建Upload
类的最后一步是让它处理多个文件。为了理解代码是如何工作的,您需要看看当一个表单允许多次上传时,$_FILES
数组会发生什么。
$_FILES 数组如何处理多个文件
因为$_FILES
是一个多维数组,它能够处理多次上传。除了向<input>
标签添加multiple
属性之外,您还需要向name
属性添加一对空方括号,如下所示:
<input type="file" name="image[]" id="image" multiple>
正如您在第六章中了解到的,向name
属性添加方括号会将多个值作为一个数组提交。您可以通过使用ch09
文件夹中的multi_upload.php
来检查这对$_FILES
数组的影响。图 9-9 显示了在支持multiple
属性的浏览器中选择三个文件的结果。
图 9-9
$_ FILES 数组可以在一次操作中上传多个文件
虽然这种结构不如将每个文件的详细信息存储在单独的子数组中方便,但是数字键可以跟踪每个文件的详细信息。比如$_FILES['image']['name'][2]
和$_FILES['image']['tmp_name'][2]
直接相关,等等。
Tip
如果你需要在旧的浏览器上支持多个文件上传,忽略multiple
属性,为你想要同时上传的任意多个文件创建单独的文件输入域。给每个<input>
标签相同的name
属性,后跟方括号。$_FILES
阵列的最终结构与图 9-9 中的相同。
PHP 解决方案 9-6:修改类以处理多次上传
这个 PHP 解决方案展示了如何修改Upload
类的构造方法来处理多个文件上传。当$_FILES
数组的结构如图 9-9 所示时,该类会自动检测,并使用一个循环来处理上传的文件。
当您从一个只处理单次上传的表单上传文件时,$_FILES
数组将文件名作为字符串存储在$_FILES['image']['name']
中。但是当你从一个能够处理多次上传的表单上传时,$_FILES['image']['name']
是一个数组。即使只上传了一个文件,其名称也存储为$_FILES['image']['name'][0]
。
因此,通过检测name
元素是否是一个数组,您可以决定如何处理$_FILES
数组。如果name
元素是一个数组,您需要将每个文件的细节提取到单独的数组中,然后使用一个循环来处理每个数组。
记住这一点,继续使用现有的类文件。或者,使用ch09/Php8Solutions/File
文件夹中的Upload_04.php
。
-
通过添加条件语句来检查
$_FILES[$field]
的name
元素是否是数组,从而修改构造函数方法。新代码位于更新$permitted
属性的部分和对checkFile()
的调用之间:if (!is_null($mime)) { $this->permitted = array_merge($this->permitted, $mime); } $uploaded = $_FILES[$field]; if (is_array($uploaded['name'])) { // deal with multiple uploads } else { if ($this->checkFile($_FILES[$field])) {
新代码首先将
$_FILES[$field]
赋给一个简单的变量$uploaded
。这避免了在稍后添加的代码中使用嵌套数组引用的需要,例如$_FILES[$field] ['name']
。如果
$uploaded['name']
是数组,需要特殊处理。对checkFile()
的现有调用现在进入一个新的else
块。 -
为了处理多个上传,挑战在于收集与单个文件相关联的五个值(
name
、type
等)。)然后将它们传递给checkFile()
、checkName()
和moveFile()
方法。如果参考图 9-9,
$uploaded
数组中的每个元素都是一个索引数组。因此,第一个文件的名称在name
子数组的索引0
处,其类型在type
子数组的索引0
处,依此类推。我们可以使用一个循环来提取索引0
处的每个值,并将这些值与相关的键组合起来。首先,我们需要找出上传了多少文件。这很容易通过将
name
子数组传递给count()
函数来完成。在多次上传注释后添加以下代码,如下所示:// deal with multiple uploads $numFiles = count($uploaded['name']);
-
接下来,通过在下一行添加以下代码来提取子数组键:
$keys = array_keys($uploaded);
这将创建一个由name
、type
、tmp_file
等组成的数组。
-
现在我们可以创建一个循环来构建每个文件细节的数组。在刚刚插入的代码后添加以下代码:
for ($i = 0; $i < $numFiles; $i++) { $values = array_column($uploaded, $i); $currentfile = array_combine($keys, $values); print_r($currentfile); }
这个循环重新组织了
$_FILES
数组的内容,这样每个文件的细节都是可用的,就好像它们是单独上传的一样。换句话说,不是所有的name
、type
和其他元素被组合在一起,$currentfile
包含一个单个文件细节的关联数组,可以使用我们已经在Upload
类中定义的方法来处理。它只用两行代码就实现了这一点。所以让我们来看看到底发生了什么。
array_column()
函数从一个多维数组中提取子数组中的所有元素,这些子数组传递给它的键或索引与第二个参数相同。在这种情况下,第二个参数是计数器$i
。当循环第一次运行时,$i
为0
。所以它在$uploaded
(换句话说就是$_FILES['image']
)的每个子数组中提取索引0
处的值。每个子阵列都有不同的键(name
、type
等)。)无关紧要;array_column()
仅在每个子数组中搜索匹配的键或索引。实际上,它获取了已上传的第一个文件的详细信息。然后,
array_combine()
函数构建一个数组,将每个值分配给其相关的键。因此,name
子阵列的索引0
处的值变为$currentfile['name']
,而type
子阵列的索引0
处的值变为$currentfile['type']
,以此类推。下一次循环运行时,
$i
递增,构建第二个文件的细节数组。循环会一直运行,直到所有文件的细节都被处理完。因为这在概念上很难理解,所以我添加了print_r()
来检查结果。 -
保存
Upload.php
。为了测试它,通过在文件字段中的name
属性的末尾添加一对方括号来更新file_upload.php
,并插入multiple
属性,如下所示:
<input type="file" name="image[]" id="image" multiple>
不需要对 DOCTYPE 声明上面的 PHP 代码做任何修改。单次和多次上传的代码是相同的。
图 9-10
每个上传文件的详细信息现在位于不同的数组中。
-
保存
file_upload.php
并在浏览器中重新加载。通过选择多个文件来测试它。当您单击上传时,每个文件的详细信息应该显示在单独的数组中。右键单击查看浏览器的源代码。您应该会看到类似图 9-10 的内容。 -
现在我们有了每个文件的单独的细节数组,我们可以像以前一样处理它们。简单的方法是从
else
块复制下面的代码块,并将其粘贴到for
循环中,代替对print_r()
的调用(将 F I L E S [ _FILES[ FILES[field]的所有实例更改为$currentfile
):if ($this->checkFile($_FILES[$field])) { $this->checkName($_FILES[$field], $rename); $this->moveFile($_FILES[$field]); }
只有四行代码,所以重复似乎没什么大不了的。但是,将来您可能需要编辑代码,也许是为了添加进一步的检查。然后,您需要对这两个块进行相同的更改——这就是代码错误开始出现的地方。这现在是一个独立的例程,应该在一个专用的内部方法中。
不要复制这段代码,而是将其剪切到剪贴板上。
-
在
Upload
类定义中创建一个新的受保护方法,并将刚刚剪切的代码粘贴到其中。将$_FILES[$field]
改为$uploaded
,以匹配函数签名中的第一个参数。新方法如下所示:protected function processUpload($uploaded, $rename) { if ($this->checkFile($uploaded)) { $this->checkName($uploaded, $renameDuplicates); $this->moveFile($uploaded); } }
-
在
for
循环和else
块中调用这个新方法。构造函数方法的完整更新版本现在如下所示:public function __construct( string $field, protected string $path, protected int $max = 51200, string|array|null $mime = null, bool $rename = true ) { if (!is_dir($this->path) && !is_writable($this->path)) { throw new \Exception("$this->path must be a valid, writable directory."); } else { $this->path = rtrim($this->path, '/\\') . DIRECTORY_SEPARATOR; if (!is_null($mime)) { $this->permitted = array_merge($this->permitted, (array) $mime); } $uploaded = $_FILES[$field]; if (is_array($uploaded['name'])) { // deal with multiple uploads $numFiles = count($uploaded['name']); $keys = array_keys($uploaded); for ($i = 0; $i < $numFiles; $i++) { $values = array_column($uploaded, $i); $currentfile = array_combine($keys, $values); $this->processUpload($currentfile, $rename); } } else { $this->processUpload($_FILES[$field], $rename); } } }
-
保存
Upload.php
并尝试上传多个文件。您应该会看到与每个文件相关的消息。符合条件的文件将被上传。那些太大或类型错误的被拒绝。该类也可以处理单个文件。
您可以对照ch09/Php8Solutions/File
文件夹中的Upload_05.php
来检查您的代码。
使用上传类
Upload
类使用起来很简单——只需导入名称空间,在脚本中包含类定义,并通过将输入字段名和文件路径传递到上传文件夹来创建一个Upload
对象,如下所示:
$path = 'C:/upload_test/';
$loader = new Upload('image', $path);
Tip
上传文件夹路径末尾的斜杠是可选的。
默认情况下,该类只允许上载图像;它将最大大小限制为 50kb;它会重命名名称中包含空格或已存在于上传文件夹中的文件。通过提交以下可选参数的值,可以覆盖默认值:
-
$size
:以字节为单位改变默认最大文件大小的整数(默认为 51200,相当于 50 KB)。 -
$permitted
:单一 MIME 类型的字符串或多种类型的数组,允许上传除图像以外的文件。 -
$rename
:设置为false
会覆盖上传文件夹中的同名文件。
该类有两个公共方法,即:
-
getMessages()
:返回报告上传状态的消息数组。 -
getMaxSize()
:返回最大允许大小,格式为千字节,四舍五入到小数点后一位。
文件上传的注意事项
PHP 解决方案 9-1 中的基本脚本表明,用 PHP 从 web 表单上传文件相当简单。失败的主要原因是没有在上传目录或文件夹上设置正确的权限,以及忘记在脚本结束之前将上传的文件移动到其目标位置。基本脚本的问题是它允许上传任何东西。这就是为什么本章花了这么多精力来构建一个更健壮的解决方案。即使在Upload
类中执行了额外的检查,您也应该仔细考虑安全性。
让其他人将文件上传到您的服务器会让您面临风险。实际上,您允许访问者自由地向您的服务器硬盘写入数据。你不会允许陌生人在你自己的电脑上做这种事,所以你应该以同样的警惕性来保护对你上传目录的访问。
理想情况下,上传应该仅限于注册的和可信的用户,所以上传表单应该在你的站点中有密码保护的部分。注册使你能够阻止那些滥用你信任的人。另外,上传文件夹不需要在你的站点根目录下,所以尽可能把它放在一个私人目录下。上传的图像可能包含隐藏脚本,因此它们不应位于具有执行权限的文件夹中。请记住,PHP 无法检查材料是否合法或体面,因此直接公开展示会带来超出技术层面的风险。您还应该记住以下安全要点:
-
在 web 表单和服务器端设置上传的最大大小。
-
通过检查
$_FILES
数组中的 MIME 类型来限制上传文件的类型。 -
用下划线或连字符替换文件名中的空格。
-
定期检查您的上传文件夹。确保里面没有不应该有的东西,时不时做点家务。即使您限制了文件上传大小,您也可能会在不知不觉中用完分配给您的空间。
第三章回顾
本章已经向你介绍了如何创建一个 PHP 类。如果你是 PHP 或编程新手,你可能会发现这很难。不要灰心。Upload
类包含超过 150 行代码,其中一些很复杂,尽管我希望描述已经解释了代码在每个阶段做什么。即使你不理解所有的代码,Upload
类也会帮你节省很多时间。它实现了文件上传所需的主要安全措施,但是使用它只需要不到十几行代码:
use Php8Solutions\File\Upload;
if (isset($_POST['upload'])) {
require_once 'Php8Solutions/File/Upload.php'; // use correct path
try {
$loader = new Upload('image', 'C:/upload_test/'); // field name and destination folder as arguments
$result = $loader->getMessages();
} catch (Throwable $t) {
echo $t->getMessage();
}
}
如果你觉得这一章很难,等你有了更多的经验后再来看,你会发现代码更容易理解。
在下一章,你将学习如何使用 PHP 的图像处理功能从大图像中生成缩略图。您还将从本章扩展Upload
类,在一次操作中上传和调整图像大小。
十、生成缩略图图像
PHP 有一系列广泛的用于处理图像的函数。你已经在第五章中见过其中之一getimagesize()
。除了提供关于图像尺寸的有用信息,PHP 还可以通过调整图像大小或旋转图像来操作图像。它还可以在不影响原始文本的情况下动态添加文本,甚至可以动态创建图像。
为了让你对 PHP 图像操作有所了解,我将向你展示如何生成一个上传图像的较小副本。大多数情况下,你会希望使用一个专门的图形程序,如 Adobe Photoshop,来生成缩略图,因为它会给你更好的质量控制。然而,如果您希望允许注册用户上传图像,同时确保它们符合最大尺寸,使用 PHP 自动生成缩略图会非常有用。您可以只保存调整大小后的副本,也可以将副本与原件一起保存。
在前一章中,您构建了一个 PHP 类来处理文件上传。在这一章中,你将创建两个类:一个用于生成缩略图,另一个用于在一次操作中上传和调整图像大小。你可以基于第九章的Upload
类来构建第二个类,而不是从零开始。使用类的一个很大的优点是它们是可扩展的——基于另一个类的类可以继承其父类的功能。构建上传图像并从中生成缩略图的类需要大量代码。但是一旦定义了类,使用它们只需要几行脚本。如果你很急,或者写了很多代码让你出了一身冷汗,你可以只使用完成的类。稍后回来学习代码是如何工作的。它使用了许多基本的 PHP 函数,您会发现这些函数在其他情况下也很有用。
在本章中,您将了解以下内容:
-
缩放图像
-
保存重新缩放的图像
-
自动调整上传图像的大小和重命名
-
通过扩展现有子类来创建子类
检查服务器的功能
在 PHP 中处理图像依赖于 gd 扩展。第二章中推荐的一体化 PHP 包默认支持 gd,但是你需要确保 gd 扩展也已经在你的远程 web 服务器上启用。和前面的章节一样,在您的网站上运行phpinfo()
来检查服务器的配置。向下滚动,直到看到下面截图中显示的部分(应该在页面的中间位置):
如果你找不到这个部分,说明 gd 扩展没有启用,所以你不能在你的网站上使用本章的任何脚本。请求将其启用或移动到不同的主机。
不要忘记删除运行phpinfo()
的文件,除非它在有密码保护的目录中。
动态处理图像
gd 扩展允许您完全从头开始生成图像或使用现有图像。无论哪种方式,基本流程总是遵循四个基本步骤:
-
在处理过程中,在服务器内存中为图像创建一个资源。
-
处理图像。
-
显示和/或保存图像。
-
从服务器内存中删除图像资源。
这个过程意味着你总是只处理内存中的图像,而不是原始图像。除非在脚本终止前将图像保存到磁盘,否则任何更改都将被丢弃。处理图像通常需要大量内存,因此一旦不再需要图像资源,就将其销毁是至关重要的。如果脚本运行缓慢或崩溃,这可能表明原始图像太大。
制作图像的较小副本
本章的目的是告诉你如何在上传时自动调整图片的大小。这涉及到从第九章扩展Upload
类。然而,为了更容易理解如何使用 PHP 的图像操作函数,我建议从使用服务器上已经存在的图像开始,然后创建一个单独的类来生成缩略图。
准备好
起点是下面这个简单的表单,它使用 PHP Solution 7-3 创建了一个images
文件夹中图片的下拉菜单。你可以在ch10
文件夹的create_thumb_01.php
中找到代码。将其复制到php8sols
站点根目录下名为gd
的新文件夹中,并将其重命名为create_thumb.php
。
页面正文中的表单如下所示:
<form method="post" action="create_thumb.php">
<p>
<select name="pix" id="pix">
<option value="">Select an image</option>
<?php
$files = new FilesystemIterator('../images');
$images = new RegexIterator($files, '/\.(?:jpg|png|gif|webp)$/i');
foreach ($images as $image) { ?>
<option value="<?= $image->getRealPath() ?>">
<?= $image->getFilename() ?></option>
<?php } ?>
</select>
</p>
<p>
<input type="submit" name="create" value="Create Thumbnail">
</p>
</form>
当加载到浏览器中时,下拉菜单应该显示images
文件夹中图片的名称。这使得快速挑选图像进行测试变得更加容易。通过调用SplFileInfo getRealPath()
方法,每个图像的完全限定路径被插入到<option>
标签的value
属性中。
在你在第九章创建的upload_test
文件夹中,创建一个名为thumbs
的新文件夹,确保它有 PHP 写入的必要权限。如果您需要刷新记忆,请参考上一章中的“建立上传目录”。
构建缩略图类
要生成缩略图,该类需要执行以下步骤:
-
获取原始图像的尺寸。
-
获取图像的 MIME 类型。
-
计算缩放比例。
-
为原始图像创建正确 MIME 类型的图像资源。
-
为缩略图创建图像资源。
-
创建调整大小的副本。
-
使用正确的 MIME 类型将调整后的副本保存到目标文件夹。
-
销毁图像资源以释放内存。
除了生成缩略图之外,该类还自动在文件扩展名前插入_thb
,但是构造函数方法的一个可选参数允许您更改该值。另一个可选参数设置缩略图的最大大小。为了简化计算,最大尺寸仅控制缩略图的较大尺寸。
为了避免命名冲突,Thumbnail
类将使用一个名称空间。因为它专门用于图像,我们将在Php8Solutions
文件夹中创建一个名为Image
的新文件夹,并使用Php8Solutions\Image
作为名称空间。
有很多事情要做,所以我将代码分成几个部分。它们都是同一个类定义的一部分,但是以这种方式表示脚本应该更容易理解,特别是如果您想在不同的上下文中使用一些代码。
PHP 解决方案 10-1:获取图像细节
这个 PHP 解决方案描述了如何获取原始图像的尺寸和 MIME 类型。
-
在
Php8Solutions
文件夹中新建一个名为Image
的文件夹。然后在文件夹中创建一个名为Thumbnail.php
的页面。该文件将只包含 PHP,所以去掉编辑程序插入的任何 HTML 代码。 -
在新文件的顶部声明命名空间:
-
该类需要跟踪相当多的属性。通过列出它们来开始类定义,如下所示:
class Thumbnail { protected $original; protected $originalWidth; protected $originalHeight; protected $basename; protected $imageType; protected $messages = []; }
namespace Php8Solutions\Image;
与在Upload
类中一样,所有的属性都被声明为 protected,这意味着它们不能在类定义之外被意外地更改。这些名字是描述性的,所以不需要解释。
-
构造函数有四个参数,其中两个是可选的。前两个必需的参数是图像的路径和创建缩略图的文件夹的路径。两个可选参数设置缩略图较长维度的最大大小和要添加到文件名的后缀。最后三个参数使用 PHP 8 的构造函数属性提升将它们设置为受保护的属性。将构造函数定义添加到上一步定义的属性列表之后,但在右花括号内:
public function __construct( string $image, protected string $path, protected int $max = 120, protected string $suffix = '_thb' ) { if (is_file($image) && is_readable($image)) { $dimensions = getimagesize($image); } else { throw new \Exception("Cannot open $image."); } if (!is_array($dimensions)) { throw new \Exception("$image doesn't appear to be an image."); } else { if ($dimensions[0] == 0) { throw new \Exception("Cannot determine size of $image."); } // check the MIME type if (!$this->checkType($dimensions['mime'])) { throw new \Exception('Cannot process that type of file.'); } } if (is_dir($path) && is_writable($path)) { $this->path = rtrim($path, '/\\') . DIRECTORY_SEPARATOR; } else { throw new \Exception("Cannot write to $path."); } $this->original = $image; $this->originalWidth = $dimensions[0]; $this->originalHeight = $dimensions[1]; $this->basename = pathinfo($image, PATHINFO_FILENAME); $this->max = abs($max); if ($suffix != '_thb') { $this->suffix = $this->setSuffix($suffix) ?? '_thb'; } }
构造函数以一个条件语句开始,该语句检查
$image
是一个文件并且是可读的。如果是,则传递给getimagesize()
,结果存储在$dimensions
中。否则,将引发异常。如前一章所述,Exception
前面有一个反斜杠,表示我们希望使用核心的Exception
类,而不是为这个命名空间类定制一个类。当您将图像传递给
getimagesize()
时,它会返回一个包含以下元素的数组:-
0
:宽度(以像素为单位) -
1
:高度 -
2
:表示图像类型的整数 -
3
:包含正确宽度和高度属性的字符串,准备插入到<img>
标签中 -
mime
:图像的 MIME 类型 -
channels
:3
用于 RGB,4
用于 CMYK 图像 -
bits
:每种颜色的位数
-
如果作为参数传递给getimagesize()
的值不是图像,它返回false
。因此,如果$dimensions
不是一个数组,就会抛出一个异常,报告该文件看起来不是一个图像。但是如果$dimensions
是一个数组,看起来我们好像在处理一个图像。但是else
块在继续之前做了两个进一步的检查。
如果$dimensions
数组中第一个元素的值为 0,则图像有问题,因此会抛出一个异常,报告图像的大小无法确定。下一个检查将报告的 MIME 类型传递给一个名为checkType()
的内部方法,该方法将在下一步中定义。如果checkType()
返回false
,则抛出另一个异常。
下一个条件语句使用与 PHP 解决方案 9–2 中相同的技术来检查将在其中创建缩略图的文件夹是否存在以及是否可写,删除任何尾随斜线并连接操作系统的适当目录分隔符。
如果图像或文件夹有问题,这一系列异常会阻止任何进一步的处理。假设脚本到此为止,图像的路径存储在$original
属性中,其宽度和高度分别存储在$originalWidth
和$originalHeight
中。
使用带有PATHINFO_FILENAME
常量的pathinfo()
提取不带文件扩展名的文件名,与 PHP 解决方案 9-5 中的方式相同。这存储在$basename
属性中,将用于构建带有后缀的缩略图名称。
$max
的值在分配给$max
属性之前被传递给abs()
函数。构造函数签名中的类型声明确保只接受整数,但是将值传递给abs()
会在它为负数的情况下将其转换为正数。
最后的条件语句检查作为$suffix
提供的参数是否不同于默认值。如果是,它将被传递给我们稍后将定义的setSuffix()
方法。这会返回一个字符串或null
。如果返回值是一个字符串,它被赋给$suffix
属性。但是如果是null
,空合并操作符(参见第四章中的“使用空合并操作符设置默认值”)会将默认值重新分配给属性。
-
checkType()
方法将 MIME 类型与一组可接受的图像类型进行比较。如果找到匹配,它将类型存储在$imageType
属性中并返回true
。否则返回false
。该方法在内部使用,因此需要声明为 protected。将以下代码添加到类定义中:protected function checkType($mime) { $mimetypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; if (in_array($mime, $mimetypes)) { // extract the characters after '/' $this->imageType = substr($mime, strpos($mime, '/')+1); return true; } return false; }
浏览器普遍支持 JPEG、PNG、GIF 我还包括了 WebP,因为它现在得到了广泛的支持。所有图像 MIME 类型都以image/
开头。为了让这个值以后更容易使用,substr()
函数提取斜杠后的字符,并将它们存储在$imageType
属性中。当与两个参数一起使用时,substr()
从第二个参数中指定的位置(从 0 开始计数)开始,并返回字符串的其余部分。我没有使用一个固定的数字作为第二个参数,而是使用了strpos()
函数来找到斜线的位置并加 1。这使得代码更加通用,因为一些专有的图像格式以application/
而不是image/
开头。strpos()
的第一个参数是要搜索的整个字符串,第二个参数是要搜索的字符串。
-
setSuffix()
方法需要确保该值不包含任何特殊字符。代码如下所示:protected function setSuffix($suffix) { if (preg_match('/^\w+$/', $suffix)) { if (!str_starts_with($suffix, '_')) { return '_' . $suffix; } else { return $suffix; } } }
这使用了preg_match()
,它将一个正则表达式作为第一个参数,并在作为第二个参数传递的值中搜索匹配项。正则表达式需要包含在一对匹配的分隔符中——通常是正斜杠,就像这里使用的一样。去掉分隔符后,正则表达式如下所示:
^\w+$
在这个上下文中,插入符号(^
)告诉正则表达式从字符串的开头开始。\w
是匹配任何字母数字字符或下划线的正则表达式标记。+
表示匹配前面的符号或字符一次或多次,$
表示匹配字符串的末尾。换句话说,正则表达式匹配只包含字母数字字符和下划线的字符串。如果字符串包含空格或特殊字符,它将不会匹配。
如果匹配失败,默认的$suffix 属性保持不变。否则,将执行以下条件语句:
if (!str_starts_with($suffix, '_') ) {
如果$suffix
的第一个字符是而不是下划线,则该条件等同于true
。它使用了 PHP 8 新增的str_starts_with()
函数。顾名思义,它检查字符串的第一个字符。因此,如果后缀不是以下划线开头,就会添加一个下划线。否则,将保留原始值。在任一情况下,都会传回值。
但是,如果提供给构造函数的参数包含除字母数字字符和下划线之外的任何字符,条件语句将失败,并且该方法将不返回任何内容—换句话说,null
。
- 在构建类时测试代码是一个好主意。及早发现错误比在长脚本中寻找问题要容易得多。为了测试代码,在类定义中创建一个名为
test()
的新公共方法。
方法在类定义中出现的顺序并不重要,但通常的做法是将所有公共方法放在构造函数之后,并将受保护的方法放在文件的底部。这使得代码更容易维护。
在构造函数和checkType()
定义之间插入以下定义:
public function test() {
$values = <<<END
<pre>
File: $this->original
Original width: $this->originalWidth
Original height: $this->originalHeight
Base name: $this->basename
Image type: $this->imageType
Max: $this->max
Path: $this->path
Suffix: $this->suffix
</pre>
END;
echo $values;
if ($this->messages) {
print_r($this->messages);
}
}
这使用带有 heredoc 语法的echo
(参见第四章中的“避免使用 Heredoc 语法转义引号的需要”)和print_r()
来显示属性的值。虽然输出中没有引号,但是使用 heredoc 语法和
-
为了测试到目前为止的类定义,保存
Thumbnail.php
并将以下代码添加到create_thumb.php
中DOCTYPE
声明上方的 PHP 块中(代码可以在ch10
文件夹的create_thumb_02.php
中找到):use Php8Solutions\Image\Thumbnail; if (isset($_POST['create'])) { require_once('../Php8Solutions/Image/Thumbnail.php'); try { $thumb = new Thumbnail($_POST['pix'] , 'C:/upload_test/thumbs', suffix: '$%^'); $thumb->test(); } catch (Throwable $t) { echo $t->getMessage(); } }
tags makes both the code and the output easier to read.
这将从Php8Solutions\Image
名称空间导入Thumbnail
类,然后添加提交表单时要执行的代码。
create_thumb.php
中提交按钮的name
属性是create
,所以这段代码只在表单提交后运行。它包括Thumbnail
类定义;创建类的一个实例,将窗体中的选定值和路径传递到 thumbs 文件夹(根据需要进行调整以匹配您自己的设置),并为有意使用非字母数字字符的后缀传递一个命名参数。然后调用test()
方法。
catch
块使用Throwable
作为类型声明,因此它将处理内部 PHP 错误和由Thumbnail
类抛出的异常。
图 10-1
显示所选图像的细节确认代码正在工作
- 保存
create_thumb.php
并将其加载到浏览器中。选择一个图像,然后单击“创建缩略图”。这产生类似于图 10-1 的输出。
Note
图 10–1 是在 Windows 上拍摄的,所以DIRECTORY_SEPARATOR
常量在路径后面附加了一个反斜杠。这没什么区别,因为 PHP 接受 Windows 路径中的正斜杠和反斜杠。
请注意,默认后缀已经替换了包含非字母数字字符的后缀。
- 使用不同的后缀值再次测试脚本,仅使用字母数字字符,以下划线开头或不加下划线。另外,尝试为
$max
属性设置不同的大小。
如有必要,对照ch10/Php8Solutions/Images
文件夹中的Thumbnail_01.php
检查您的代码。
Caution
$_POST['pix']
的值被直接传递给test()
方法,因为它直接来自我们自己的表单。在生产环境中,您应该总是检查从表单接收的值。例如,使用basename()
只提取文件名并指定允许的目录。
尽管有些属性有默认值,但您需要提供选项来更改缩略图的最大尺寸和应用于文件名基础的后缀。您还需要告诉全班在哪里创建缩略图。
PHP 解决方案 10-2:计算缩略图的尺寸
这个 PHP 解决方案向Thumbnail
类添加了一个受保护的方法,该方法将计算缩略图的尺寸。在$maxSize
属性中设置的值决定了宽度或高度,这取决于哪个更大。为了避免扭曲缩略图,您需要计算较短尺寸的缩放比例。该比率是通过将最大缩略图尺寸除以原始图像的较大尺寸来计算的。
比如金阁(kinkakuji.jpg
)原图是 270 × 346 像素。如果最大大小设置为 120,用 120 除以 346 得到的缩放比例为 0.3468。将原始图像的宽度乘以该比率会将缩略图的宽度固定为 94 像素(向上舍入为最接近的整数),从而保持正确的比例。图 10-2 显示了缩放比例是如何工作的。
图 10-2
计算缩略图的缩放比例
继续使用现有的类定义。或者,使用ch10/Php8Solutions/Image
文件夹中的Thumbnail_01.php
。
-
计算缩略图尺寸不需要任何进一步的用户输入,因此可以通过受保护的方法来处理。将以下代码添加到类定义中。
protected function calculateRatio() { if ($this->originalWidth <= $this->max && $this->originalHeight <= $this->max) { return 1; } elseif ($this->originalWidth > $this->originalHeight) { return $this->max/$this->originalWidth; } else { return $this->max/$this->originalHeight; } }
条件语句首先检查原始图像的宽度和高度是否小于或等于最大尺寸。如果是,则不需要调整图像的大小,因此该方法返回的缩放比例为 1。
elseif
块检查宽度是否大于高度。如果是,宽度用于计算缩放比例。如果高度大于或两边相等,则调用else
块。在任一情况下,高度都用于计算缩放比例。
-
为了测试新方法,修改
test()
方法如下:public function test() { $ratio = $this->calculateRatio(); $thumbWidth = round($this->originalWidth * $ratio); $thumbHeight = round($this->originalHeight * $ratio); $values = <<<END <pre> File: $this->original Original width: $this->originalWidth Original height: $this->originalHeight Base name: $this->basename Image type: $this->imageType Destination: $this->path Max size: $this->maxSize Suffix: $this->suffix Thumb width: $thumbWidth Thumb height: $thumbHeight </pre> END; // Remove the indentation of the preceding line in < PHP 7.3 echo $values; if ($this->messages) { print_r($this->messages); } }
这将调用新方法。得到的缩放比例然后用于计算缩略图的宽度和高度。计算结果传递给round()
函数,将结果转换为最接近的整数。计算需要从test()
方法中移除,但是首先检查我们是否得到了预期的结果是很重要的。
图 10-3
该类现在正在生成创建缩略图所需的所有值
- 通过选择
create_thumb.php
中的图像并点击Create Thumbnail
来测试更新的类。你应该看到屏幕上显示的数值,如图 10-3 所示。尝试缩略图最大尺寸的不同值。
如有必要,对照ch10
文件夹中的Thumbnail_02.php
检查您的代码。
使用 gd 函数创建图像的缩放副本
收集了所有必要的信息后,您可以从较大的图像生成缩略图。这包括为原始图像和缩略图创建图像资源。对于原始图像,您需要使用与图像的 MIME 类型相匹配的函数。以下每个函数都有一个参数,即文件的路径:
-
imagecreatefromjpeg()
-
imagecreatefrompng()
-
imagecreatefromgif()
-
imagecreatefromwebp()
因为缩略图还不存在,所以使用不同的函数imagecreatetruecolor()
,它有两个参数——宽度和高度(以像素为单位)。
还有一个函数创建一个图像的大小调整副本:imagecopyresampled()
。这至少需要十个参数——所有参数都是必需的。这些参数分为五对,如下所示:
-
对两个图像资源的引用—首先是副本,其次是原件
-
复制图像左上角位置的
x
和y
坐标 -
原稿左上角的
x
和y
坐标 -
副本的宽度和高度
-
要从原件复制的区域的宽度和高度
图 10-4 显示了最后四对参数如何用于提取特定区域,使用以下参数到imagecopyresampled()
:
图 10-4
imagecopyresampled()函数允许你复制图像的一部分
imagecopyresampled($thumb, $source, 0, 0, 170, 20, $thbwidth,$thbheight, 170, 102);
要复制的区域的x
和y
坐标以像素为单位,从图像的左上角开始测量。x 轴和 y 轴从左上角的 0 开始,向右下方增加。通过将要复制的区域的宽度和高度分别设置为 170 和 102,PHP 提取出白色轮廓的区域。
现在你知道网站是如何处理上传的图片的了。他们使用 JavaScript 或其他技术动态计算坐标。对于Thumbnail
类,您将使用整个原始图像来生成缩略图。
使用imagecopyresampled()
创建副本后,您需要保存它,再次使用特定于 MIME 类型的函数,即:
-
imagejpeg()
-
imagepng()
-
imagegif()
-
imagewebp()
每个函数的前两个参数是图像资源和保存它的路径。
imagejpeg()
、imagepng()
和imagewebp()
函数采用可选的第三个参数来设置图像质量。对于imagejpeg()
和imagewebp()
,您可以在 0(最差)到 100(最好)的范围内指定一个数字来设置质量。如果省略该参数,默认情况下imagejpeg()
为 75,imagewebp()
为 80。对于imagepng()
,范围是 0–9。令人困惑的是,0 产生最佳质量(无压缩)。
最后,一旦保存了缩略图,就需要通过将它们传递给imagedestroy()
来销毁图像资源。尽管其名称具有破坏性,但该功能对原始图像或缩略图没有任何影响。它只是通过销毁处理过程中所需的图像资源来释放服务器内存。
PHP 解决方案 10-3:生成缩略图
这个 PHP 解决方案通过创建图像资源、复制缩略图并将其保存在目标文件夹中来完成Thumbnail
类。
继续使用现有的类定义。或者,使用ch10/Php8Solutions/Image
文件夹中的Thumbnail_02.php
。
-
现在我们已经验证了该类正在计算正确的值来生成缩略图,我们可以重命名
test()
方法并删除显示结果的代码。将该方法的名称改为create()
,并删除除前三行之外的所有内容。你应该留下这个:public function create() { $ratio = $this->calculateRatio(); $thumbWidth = round($this->originalWidth * $ratio); $thumbHeight = round($this->originalHeight * $ratio); }
-
原始图像的图像资源需要特定于它的 MIME 类型,因此创建一个内部方法来选择正确的类型。将以下代码添加到类定义中:
protected function createImageResource() { switch ($this->imageType) { case 'jpeg': return imagecreatefromjpeg($this->original); case 'png': return imagecreatefrompng($this->original); case 'gif': return imagecreatefromgif($this->original); case 'webp': return imagecreatefromwebp($this->original); } }
您在 PHP 解决方案 10-1 中创建的checkType()
方法将 MIME 类型存储为jpeg
、png
、gif
或webp
。因此,switch
语句检查 MIME 类型,将其与适当的函数匹配,并将原始图像作为参数传递。然后,该方法返回结果图像资源。
-
create()
方法需要两个图像资源:一个用于原始图像,另一个用于缩略图。像这样更新create()
方法:public function create() { $ratio = $this->calculateRatio(); $thumbWidth = round($this->originalWidth * $ratio); $thumbHeight = round($this->originalHeight * $ratio); $resource = $this->createImageResource(); $thumb = imagecreatetruecolor($thumbWidth, $thumbHeight); }
这将调用您在步骤 2 中创建的createImageResource()
方法,然后为缩略图创建一个图像资源,将缩略图的宽度和高度传递给imagecreatetruecolor()
。
- 创建缩略图的下一个阶段包括将两个图像资源传递给
imagecopyresampled()
并设置坐标和尺寸。将下面一行代码添加到create()
方法中:
imagecopyresampled($thumb, $resource, 0, 0, 0, 0, $thumbWidth, $thumbHeight,
$this->originalWidth, $this->originalHeight);
前两个参数是您刚刚为缩略图和原始图像创建的图像资源。接下来的四个参数将副本和原件的x
和y
坐标设置为左上角。接下来是为缩略图计算的宽度和高度,接着是原始图像的宽度和高度。将参数 3–6 设置为左上角,并将两组尺寸设置为最大值,会将整个原始图像复制到整个缩略图。换句话说,它创建了原始文件的一个较小的副本。
你不需要把imagecopyresampled()
的结果赋给一个变量。缩小后的图像现在存储在$thumb
中,但是您仍然需要保存它。
-
像这样完成
create()
的定义:public function create() { $ratio = $this->calculateRatio(); $thumbWidth = round($this->originalWidth * $ratio); $thumbHeight = round($this->originalHeight * $ratio); $resource = $this->createImageResource(); $thumb = imagecreatetruecolor($thumbWidth, $thumbHeight); imagecopyresampled($thumb, $resource, 0, 0, 0, 0, $thumbWidth, $thumbHeight, $this->originalWidth, $this->originalHeight); $newname = $this->basename . $this->suffix; switch ($this->imageType) { case 'jpeg': $newname .= '.jpg'; $success = imagejpeg($thumb, $this->path . $newname); break; case 'png': $newname .= '.png'; $success = imagepng($thumb, $this->path . $newname); break; case 'gif': $newname .= '.gif'; $success = imagegif($thumb, $this->path . $newname); break; case 'webp': $newname .= '.webp'; $success = imagewebp($thumb, $this->path . $newname); break; } if ($success) { $this->messages[] = "$newname created successfully."; } else { $this->messages[] = "Couldn't create a thumbnail for " . basename($this->original); } imagedestroy($resource); imagedestroy($thumb); }
新代码的第一行将后缀连接到去掉了文件扩展名的文件名。所以,如果原始文件被称为menu.jpg
,并且使用默认的_thb
后缀,$newname
就变成了menu_thb
。
switch
语句检查图像的 MIME 类型并附加适当的文件扩展名。在menu.jpg
的情况下,$newname
变成了menu_thb.jpg
。缩小的图像然后被传递到适当的函数来保存它,使用目标文件夹和$newname
作为保存它的路径。我省略了 JPEG、PNG 和 WebP 图像的可选质量参数。默认质量对于缩略图应该足够了。
保存操作的结果存储在$success
中。根据结果,$success
或者是true
或者是false
,一个适当的消息被添加到$messages
属性中。消息是使用basename()
函数而不是$basename
属性创建的,因为文件扩展名已经从属性中去除,而函数保留了它。
最后,imagedestroy()
通过销毁用于创建缩略图的资源来释放服务器内存。
-
到目前为止,您已经使用了
test()
方法来显示错误消息。创建一个公共方法来获取消息:public function getMessages() { return $this->messages; }
-
保存
Thumbnail.php
。在create_thumb.php
中,用对create()
的调用替换对test()
方法的调用。也调用getMessages()
并将结果赋给一个变量,就像这样:$thumb->create(); $messages = $thumb->getMessages();
-
在开始的
<body>
标签后添加一个 PHP 代码块来显示任何消息:<?php if (!empty($messages)) { echo '<ul>'; foreach ($messages as $message) { echo "<li>$message</li>"; } echo '</ul>'; } ?>
您已经在前面的章节中看到了这段代码,所以它不需要解释。
保存create_thumb.php
,将其加载到浏览器中,并通过从列表中选择图像并单击创建缩略图来进行测试。如果一切顺利,你应该会看到一条消息报告缩略图的创建,你可以确认它存在于upload_test
的thumbs
子文件夹中,如图 10-5 所示。
图 10-5
缩略图已在目标文件夹中成功创建
- 如果缩略图没有创建,那么由
Thumbnail
类生成的错误消息应该可以帮助您检测问题的根源。同样,对照ch10/Php8Solutions/Image
文件夹中的Thumbnail_03.php
仔细检查你的代码。如果之前的 PHP 解决方案中的测试有效,那么错误很可能出现在create()
、createImageResource()
或createThumbnail()
方法定义中。当然,另一个需要检查的地方是您的 PHP 配置。该类依赖于正在启用的 gd 扩展。虽然 gd 得到了广泛的支持,但它并不总是默认开启。
上传时自动调整图像大小
现在你已经有了一个从更大的图像创建缩略图的类,修改第九章中的Upload
类来从上传的图像生成缩略图就相对简单了——事实上,不仅从单幅图像,也从多幅图像。
与其更改Upload
类中的代码,不如扩展该类并创建一个子类更有效。然后,您可以选择使用原始类来上传任何类型的文件,或者使用子类来在上传时创建缩略图。子类还需要提供在缩略图创建后保存或丢弃大图的选项。
在深入研究代码之前,让我们快速看一下如何创建一个子类。
扩展一个类
在“PHP 解决方案 8-8:自动构建嵌套列表”中,我们看到了一个扩展内置类RecursiveIteratorIterator
的例子扩展一个类的好处是,新的子类或子类继承了其父类的所有特性,包括属性和方法,但可以修改(或覆盖)其中一些特性并获得自己的新特性。这简化了创建一个类来执行更专门化的任务的过程。你在第九章创建的Upload
类执行基本的文件上传。在这一章中,你将扩展它来创建一个名为ThumbnailUpload
的子类,它使用其父类的基本上传功能,但是增加了创建缩略图的特殊功能。子类将被创建在Php8Solutions/Image
文件夹中,所以它将使用Php8Solutions\Image
作为它的名称空间。
像所有的子类一样,子类通常需要向父类借用。当您在子类中重写一个方法但又需要使用原始版本时,这种情况经常发生。要引用父版本,您可以在它前面加上关键字parent
,后跟两个冒号,如下所示:
parent::originalMethod();
你将在 PHP 解决方案 10-4 中看到这是如何工作的,因为子类定义了自己的构造函数来添加额外的参数,但也需要使用父构造函数。
让我们创建一个能够同时上传图像和生成缩略图的类。
PHP 解决方案 10-4:创建缩略图上传类
这个 PHP 解决方案扩展了第九章中的Upload
类,并将其与Thumbnail
类结合使用来上传和调整图像大小。它演示如何创建子类并重写父方法。要创建子类,你需要第九章的和本章的Thumbnail.php
。在ch09/Php8Solutions/File
和ch10/Php8Solutions/Image
文件夹中分别有这些文件的副本。
-
在
Php8Solutions/Image
文件夹中新建一个名为ThumbnailUpload
.php
的文件。它将只包含 PHP 代码,所以去掉脚本编辑器插入的任何 HTML,并添加以下代码:<?php namespace Php8Solutions\Image; use Php8Solutions\File\Upload; require_once __DIR__ . '/../File/Upload.php'; require_once 'Thumbnail.php'; class ThumbnailUpload extends Upload { }
这将声明Php8Solutions\Image
名称空间,并在包含Upload
和Thumbnail
类的定义之前,从Php8Solutions\File
名称空间导入Upload
类。
Note
在包含文件中使用时,__DIR__
返回包含文件的目录,不带尾随斜杠。在Upload.php
的相对路径的开头添加斜杠,允许 PHP 构建一个完整的路径,向上移动一级,在Php8Solutions/File
文件夹中找到它。Thumbnail.php
与ThumbnailUpload.php
在同一个文件夹中,所以只使用文件名来包含它。参见第五章中的“嵌套包含文件”。
然后,ThumbnailUpload
类声明它扩展了Upload
。虽然Upload
在不同的名称空间中,但是您可以简单地将其称为Upload
,因为它已经被导入。所有后续代码都需要插入到类定义的花括号之间。
-
当您扩展一个类时,您唯一需要定义构造函数方法的时候就是您想要改变构造函数的工作方式的时候。
ThumbnailUpload
类总共有七个参数,其中大部分是可选的。在本地测试时,一个Thumbnail
对象可以访问你自己硬盘上的原始映像。然而,生成缩略图是一项服务器端操作,因此如果不先将原始图像上传到服务器,它就不能在网站上工作。因此,构造函数的前三个参数与Upload
类的相同:图像输入字段($field
)、图像上传位置的路径($path
)和上传图像的最大文件大小($max
)。其余参数设置与缩略图相关的值。所有参数前面都有一个可见性声明,使用 PHP 8 的构造函数属性提升来自动使它们成为受保护的类属性。构造函数如下所示:public function __construct( protected string $field, protected string $path, protected int $max = 51200, protected int $maxDimension = 120, protected string $suffix = '_thb', protected ?string $thumbPath = null, protected bool $deleteOriginal = false, ) { $this->thumbPath = $thumbPath ?? $path; if (is_dir($this->thumbPath) && is_writable($this->thumbPath)) { $this->thumbPath = rtrim($this->thumbPath, '/\\') . DIRECTORY_SEPARATOR; } else { throw new \Exception("$this->thumbPath must be a valid, writable directory."); } parent::__construct( $this->field, $this->path, $this->max ); }
最后五个参数都有默认值,所以它们是可选参数。$maxDimension
的值设置缩略图较大尺寸的最大尺寸。这个值也在Thumbnail
类中设置。这里重复了一遍,这样你可以在上传图片和生成缩略图的时候覆盖它。$suffix
参数也是如此。
$thumbPath
参数前面是可空类型声明,这意味着该值必须是字符串或null
。构造函数体中的第一行使用零合并运算符(??)将值赋给$thumbPath
属性。如果传递给构造函数的值是null
,则使用$path
的值,在与上传图像相同的目录中创建缩略图。
最后一个参数$deleteOriginal
是一个布尔值,它确定在创建缩略图后是否应该删除全尺寸图像。默认情况下,它被设置为false
,保留图像。
构造函数体中的条件语句使用与 PHP 解决方案 9–2 中相同的技术来检查将在其中创建缩略图的文件夹是否存在以及是否可写,删除任何尾随斜线并为操作系统连接适当的目录分隔符。
最后,构造函数使用parent
关键字调用Upload
构造函数,向其传递文件输入字段的名称、上传文件夹的路径和最大上传大小。实际上,在调用父构造函数上传全尺寸版本之前,扩展类的构造函数初始化上传全尺寸图像和生成缩略图所需的所有类属性。
-
在父类中,
moveFile()
方法将上传的文件保存到它的目标位置。缩略图需要从原始图像中生成,因此您需要覆盖父方法的moveFile()
并使用它来调用一个新的受保护的方法createThumbnail()
,稍后您将定义这个方法。从Upload.php
复制moveFile()
方法,并通过添加粗体突出显示的代码进行修改:protected function moveFile($file) { $filename = $this->newName ?? $file['name']; $success = move_uploaded_file($file['tmp_name'], $this->path . $filename); if ($success) { // add a message only if the original image is not deleted if (!$this->deleteOriginal) { $result = $file['name'] . ' was uploaded successfully'; if (!is_null($this->newName)) { $result .= ', and was renamed ' . $this->newName; } $this->messages[] = $result; } // create a thumbnail from the uploaded image $this->createThumbnail($this->path . $filename); // delete the uploaded image if required if ($this->deleteOriginal) { unlink($this->path . $filename); } } else { $this->messages[] = 'Could not upload ' . $file['name']; } }
如果原始图像已经成功上传,新代码将添加一个条件语句,以便仅在$deleteOriginal
为false
时生成消息。然后它调用createThumbnail()
方法,将上传的图像作为参数传递给它。最后,如果$deleteOriginal
已经被设置为true
,它使用unlink()
删除上传的图像,只留下缩略图。
-
生成缩略图的受保护方法如下所示:
protected function createThumbnail($image) { $thumb = new Thumbnail($image, $this->thumbPath, $this->maxDimension, $this->suffix); $thumb->create(); $messages = $thumb->getMessages(); $this->messages = array_merge($this->messages, $messages); }
它接受一个参数,即图像的路径,并创建一个Thumbnail
对象,将所有四个参数传递给Thumbnail
构造函数:图像的路径、创建缩略图的路径、较大维度的最大大小以及附加到文件名的后缀。然后,它调用Thumbnail
对象上的create()
和getMessages()
方法来生成新图像,并获取作为结果创建的任何消息。
最后一行使用array_merge()
将由Thumbnail
对象生成的消息与ThumbnailUpload
类的$message
属性合并。虽然ThumbnailUpload
类没有定义自己的$messages
属性,但是子类自动从其父类继承它。
-
保存
ThumbnailUpload.php
。为了测试它,将create_thumb_upload_01.php
从ch10
文件夹复制到gd
文件夹,并保存为create_thumb_upload.php
。该文件包含一个简单的表单,带有一个文件字段和一个显示消息的 PHP 块。在DOCTYPE
声明上方添加以下 PHP 代码块:<?php use Php8Solutions\Image\ThumbnailUpload; if (isset($_POST['upload'])) { require_once('../Php8Solutions/Image/ThumbnailUpload.php'); try { $loader = new ThumbnailUpload('image', 'C:/upload_test/', thumbPath: 'C:/upload_test/thumbs'); $messages = $loader->getMessages(); } catch (Throwable $t) { echo $t->getMessage(); } } ?>
这个例子向ThumbnailUpload
构造函数提供了三个参数。前两个是必需的:文件输入字段名称和要上传全尺寸图像的文件夹的路径。最后一个参数是 PHP 8 命名的参数,它指定缩略图文件的文件夹路径,跳过中间的参数并使用默认设置。如有必要,调整构造函数中的路径。
图 10-6
缩略图是在上传图像的同时创建的
-
保存
create_thumb_upload.php
并将其载入浏览器。单击浏览或选择文件按钮并选择多个图像。当您单击上传按钮时,您应该会看到消息,通知您成功上传并创建了缩略图。检查目标文件夹,如图 10-6 所示。 -
通过再次上传相同的图像来测试
ThumbnailUpload
类。这一次,原始图像和缩略图应该按照第九章中的相同方式进行重命名,即在文件扩展名前添加一个数字。 -
尝试不同的测试,更改插入缩略图名称的后缀,或者在创建缩略图后删除原始图像。如果你遇到问题,对照
ch10/Php8Solutions/Image
文件夹中的ThumbnailUpload.php
检查你的代码。
使用 ThumbnailUpload 类
ThumbnailUpload
类很容易使用。因为它使用命名空间,所以在文件的顶层导入该类,如下所示:
use Php8Solutions\Image\ThumbnailUpload;
然后包含类定义,并将输入字段的名称和上传文件夹的路径传递给类构造函数方法:
$loader = new ThumbnailUpload('image', 'C:/upload_test/');
默认情况下,缩略图创建在与全尺寸图像相同的文件夹中;但是,您可以使用五个可选参数来更改各种默认值,或者按正确的顺序传递它们,或者使用命名参数。下列选项可用作命名参数:
-
max
:以字节为单位设置全尺寸图像的最大尺寸。默认值为 51200,相当于 50 KB。 -
maxDimension
:以像素为单位指定缩略图较大尺寸的最大尺寸。默认值为 120。 -
suffix:
追加到缩略图的文件名。它只能包含字母数字字符和下划线。如果它不是以下划线开头,就会自动添加一个下划线。默认为'_thb'
。 -
thumbPath:
指定要创建缩略图的文件夹路径。默认设置是null
,它会在与全尺寸图像相同的文件夹中创建缩略图。 -
deleteOriginal:
确定创建缩略图后是否删除全尺寸图像。默认为false
,因此保留原始图像。
该类还从父类Upload
继承了以下方法:
-
getMessages()
:检索上传生成的消息和缩略图。 -
getMaxSize()
:获取单个图像的最大上传大小。默认值为 50 KB。
因为ThumbnailUpload
类依赖于Upload
和Thumbnail
类,所以当在一个实时网站上使用这个类时,您需要将所有三个类定义文件上传到您的远程 web 服务器。
第三章回顾
这是另一个紧张的章节,不仅展示了如何从较大的图像生成缩略图,还向您介绍了扩展现有的类和重写继承的方法。设计和扩展类一开始可能会令人困惑,但是如果你把注意力集中在每个方法正在做的事情上,它会变得不那么可怕。类设计的一个关键原则是将大任务分解成小的、可管理的单元。理想情况下,一个方法应该执行一个任务,比如为原始图像创建图像资源。
使用类的真正好处是一旦你定义了它们,它们就能节省时间和精力。每次要向网站添加文件或缩略图上载功能时,不必键入几十行代码,调用该类只需要几行简单的代码。此外,不要认为本章中的代码专门用于创建和上传缩略图。类文件中的许多子程序可以在其他情况下使用。
在下一章中,您将了解 PHP 会话的所有内容,它保存与特定用户相关的信息,并在密码保护网页中发挥重要作用。