在 PHP 中流式传输 ZIP 文件而不使用临时文件






4.73/5 (3投票s)
在不使用临时文件的情况下,即时流式传输 ZIP 文件中的多个文件或目录。
引言
在我尝试将“下载目录”功能添加到我的自定义 Web 应用程序时,我找到的所有解决方案都基于先创建 ZIP 文件然后发送它。在我的情况下,这可能导致生成大型临时文件(由于它们大多是图像),这些文件本身也无法压缩。
因此,我提出了一个想法,即直接在原始数据周围即时创建一个未压缩的 ZIP 存档——正如我发现的那样,这非常简单。
背景
为此,只需考虑 ZIP 存档的最低必要结构就足够了:我们不需要多部分文件,也不需要为每个文件存储额外的信息——当然,我们也不需要了解任何压缩算法。
ZIP 存档的基本结构使其可以轻松地即时组装
| ||||||
| ||||||
| ||||||
| ||||||
| ||||||
| ||||||
| ||||||
| ||||||
|
详细来说,让我们深入研究一些字节。这是一个 ZIP 文件,其中包含一个未压缩的文本文件“test.txt”,该文件包含文本“The quick brown fox jumps over the lazy dog.” 我为上面的每个区域和每个值着色,并包含每个值含义的摘要。
通用数据类型
UInt16
- 一个 2 字节、16 位数字,采用小端字节序(例如 0x1234 = [34, 12])UInt32
- 一个 4 字节、32 位数字,采用小端字节序(例如 0x12345678 = [78, 56, 34, 12])DateTime
- 一个精确到秒的时间戳,位格式为 YYYYYYYmmmmddddd HHHHHiiiiiisssss,采用小端字节序,例如 2019-01-23 22:33:44值 二进制 就地 年份 -1980 39 0b00100111 0b01001110001101111011010000110110 月 1 0b00000001 0b01001110001101111011010000110110 日 23 0b00010111 0b01001110001101111011010000110110 小时 22 0b00010110 0b01001110001101111011010000110110 分钟 33 0b00100001 0b01001110001101111011010000110110 秒/2 22 0b00010110 0b01001110001101111011010000110110 0b01001110001101111011010000110110 = 0x4E37B436 => [36, B4, 37, 4E]
CRC-32
- 文件数据的 4 字节 CRC-32 校验和,使用魔术数字0xdebb20e3
。在 PHP 中,这是名为“crc32b
”的哈希算法。
文件条目
文件条目是描述文件并包含其数据的一部分。文件条目堆叠在一起。
名称 | 长度 | 数据类型 | 描述 |
签名 | 4 | 签名 | 文件条目签名,由“PK ”后跟字节 03 和 04 组成 |
---|---|---|---|
版本 | 2 | UInt16 | 存档的宿主系统和兼容版本 - 在此,我仅使用 0x000A 表示 Windows/NTFS,但它实际上无关紧要 |
标志 | 2 | UInt16 | 读取此文件的选项 - 在此,我使用 0x0800,表示 UTF-8 编码的文件名和注释,除此之外无其他 |
压缩方法 | 2 | UInt16 | 数据压缩方法 - 在此,使用 0x0000 ,表示“未压缩” |
文件时间 | 4 | UInt32 | 文件的最后修改时间,不保存其他时间,格式见上 |
校验和 | 4 | UInt32 | 文件数据的 CRC-32 校验和,格式见上 |
压缩大小 | 4 | UInt32 | 压缩文件数据的大小 - 在此,与文件大小相同 |
未压缩大小 | 4 | UInt32 | 未压缩文件的大小 |
文件名长度 | 2 | UInt16 | 文件名的长度 |
附加数据长度 | 2 | UInt16 | 附加数据的长度 - 在此,不使用附加数据,因此始终为 0x0000 |
文件名 | * | 字符串 | UTF-8 编码的文件名 |
文件数据 | * | 字节 | 文件数据 - 通常是压缩的,但在本例中只是原始数据 |
附加数据 | * | 特殊 | 附加数据,例如创建时间、属性等 - 在此,未使用 |
中央目录条目
中央目录条目包含有关文件条目的更详细数据。中央目录条目堆叠在一起,构成一种目录。
名称 | 长度 | 数据类型 | 描述 |
签名 | 4 | 签名 | 中央目录条目签名,由“PK ”后跟字节 01 和 02 组成 |
---|---|---|---|
操作系统版本 | 2 | UInt16 | 创建存档的版本 - 在此,我仅使用 0x003F |
版本 | 2 | UInt16 | 解压所需的最低版本 - 在此,我仅使用 0x000A |
标志 | 2 | UInt16 | 读取此文件的选项 - 在此,我使用 0x0800 ,表示 UTF-8 编码的文件名和注释,除此之外无其他 |
压缩方法 | 2 | UInt16 | 数据压缩方法 - 在此,使用 0x0000 ,表示“未压缩” |
文件时间 | 4 | 日期时间 | 文件的最后修改时间,不保存其他时间,格式见上 |
校验和 | 4 | CRC32 | 文件数据的 CRC-32 校验和,格式见上 |
压缩大小 | 4 | UInt32 | 压缩文件数据的大小 - 在此,与文件大小相同 |
未压缩大小 | 4 | UInt32 | 未压缩文件的大小 |
文件名长度 | 2 | UInt16 | 文件名的长度 |
附加数据长度 | 2 | UInt16 | 附加数据的长度 - 在此,不使用附加数据,因此始终为 0x0000 |
注释长度 | 2 | UInt16 | 文件注释的长度 |
磁盘 | 2 | UInt16 | 文件所在磁盘编号 - 在此,我仅使用一个文件,因此始终为 0x0000 |
内部属性 | 2 | UInt16 | 内部使用的属性 - 在此,未使用,始终为 0x0000 |
外部属性 | 4 | UInt32 | 外部使用的属性 - 在此,未使用,始终为 0x00000000 |
文件条目偏移量 | 4 | UInt32 | 此中央目录条目开始的文件条目在文件内的偏移量 |
文件名 | * | 字符串 | UTF-8 编码的文件名 |
附加数据 | * | 特殊 | 附加数据,例如创建时间、属性等 - 在此,未使用 |
注释 | * | 字符串 | 描述文件的注释 |
中央目录条目结束
此条目仅出现一次 - 至少在此场景下 - 直接堆叠在最后一个中央目录条目之上。
名称 | 长度 | 数据类型 | 描述 |
签名 | 4 | 签名 | 中央目录条目签名,由“PK ”后跟字节 05 和 06 组成 |
---|---|---|---|
磁盘索引 | 2 | UInt16 | 此磁盘的索引 - 在此,我不使用多个磁盘,因此始终为 0x0000 |
起始磁盘 | 2 | UInt16 | 中央目录开始的磁盘索引 - 在此,我不使用多个磁盘,因此始终为 0x0000 |
文件数,磁盘 | 2 | UInt16 | 此磁盘上的文件数 - 在此,始终为包含文件的总数 |
文件数,中央目录 | 2 | UInt16 | 此中央目录中的文件数 - 在此,始终为包含文件的总数 |
大小 | 4 | UInt32 | 中央目录的大小,不包括此条目 |
偏移量 | 4 | UInt32 | 此磁盘上第一个中央目录条目的偏移量 - 在此,始终为该文件第一个中央目录条目的偏移量 |
注释长度 | 2 | UInt16 | 存档注释的长度 |
注释 | * | 字符串 | 存档注释 |
Using the Code
该代码是一个名为 BjSZipper
的 PHP 类,它根据您选择使用的功能,包含静态和实例功能。在这两种情况下,都只有文件信息存储在内存中,文件数据是即时流式传输的。
1. 收集信息然后发送(实例)
此方法使用类的实例,收集要发送的每个文件的信息(包括计算 CRC-32 校验和),然后开始发送存档。对用户的好处是他们可以看到进度条,因为客户端可以提前知道存档大小。缺点是请求后下载开始的时间稍晚——尤其是在处理大量文件或大型文件时。
方法
__construct($zipName = "download.zip", $comment = "")
BjSZipper
的构造函数。接受两个参数
$zipName
- 发送给客户端的 ZIP 存档的文件名,可选,默认为“download.zip”$comment
- 存档注释,可选,默认为空
AddDir($path, $recursive = true, $filter = null)
准备一个路径及其内容以包含在 zip 存档中。路径相对于 $path
到存档根目录。接受三个参数
$path
- 要从中获取文件的目录路径$recursive
- 一个bool
,如果为true
,则递归扫描目录,可选,默认为true
$filter
- 用于包含文件的正则表达式,可选,默认包含所有文件
AddFile($file, $name = null, $relativePath = "", $comment = "")
准备将单个文件包含在存档中。接受四个参数
$file
- 文件的完整路径$name
- 存档中文件的名称,可选,默认为文件的基本名称$relativePath
- 存档内的文件路径,可选,默认为存档根目录,使用斜杠 '/
' 作为路径分隔符$comment
- 文件注释,可选,默认为空
AddData($data, $name, $relativePath = '', $comment = '', $filetime = null)
准备从原始数据发送单个文件。接受五个参数
$data
- 文件的原始数据,存储在内存中$name
- 存档中文件的名称$relativePath
- 存档内的文件路径,可选,默认为存档根目录,使用斜杠 '/
' 作为路径分隔符$comment
- 文件注释,可选,默认为空$filetime
- 文件的最后修改时间,可选,默认为当前时间
Clear()
重置实例以重新开始。
Send()
将收集到的文件以组装好的 ZIP 存档发送给客户端。
示例
require_once('BjSZipper.php');
// Create a new instance
$zip = new BjSZipper('images.zip');
// Add files and data to send
$zip->AddDir(dirname(__FILE__), true, '/\.(jpg|jpeg)/i'); // All JPEGs recursively
$zip->AddFile('/var/www/html/testdata.bin'); // Just a normal file
$zip->AddData('All the JPEG images.', 'desc.txt'); // A raw text file
// Start sending the archive
$zip->Send();
2. 立即开始发送(静态)
此方法采用静态方法。每个文件在收集其数据后都会直接发送,文件信息存储在内存中用于最终的中央目录。好处是客户端反应更快,因为下载在处理完第一个文件后立即开始,并且内存使用也略好,因为只存储了与存档相关的数据,并且在添加原始数据时不必保留以供以后发送。缺点是脚本无法知道最终的存档大小,因此客户端将没有进度显示。
方法
static Begin($zipName = 'downlaod.zip', $unlimitedTime = true)
将下载标头发送给客户端。接受两个参数
$zipName
- 显示给客户端的存档文件名,可选,默认为 'download.zip'$unlimitedTime
- 如果为true
,则使用set_time_limit(0)
禁用 PHP 执行时间限制,可选,默认为true
static SendFile($file, $name = null, $relativePath = '', $comment = '')
将单个文件附加到存档流发送给客户端。接受四个参数
$file
- 文件的完整路径$name
- 存档中文件的名称,可选,默认为文件的基本名称$relativePath
- 文件相对于存档根目录的路径,分隔符为斜杠 '/
',可选,默认为存档根目录$comment
- 此文件的注释,可选,默认为空
static SendDir($path, $recursive, $filter = null)
将目录中的所有指定文件附加到存档流发送给客户端。所有文件都相对于 $path
添加到存档根目录。接受三个参数
$path
- 要从中获取文件的目录的完整路径$recursive
- 如果为true
,则也搜索子目录,可选,默认为true
$filter
- 过滤要添加的文件的正则表达式,可选,默认为找到的所有文件
static SendData($data, $name, $relativePath = '', $comment = '', $filetime = null)
将原始数据的文件附加到存档流发送给客户端。接受五个参数
$data
- 要附加的文件的原始数据$name
- 存档中文件的名称$relativePath
- 文件在存档中相对于存档根目录的路径,可选,默认为存档根目录$comment
- 此文件的注释,可选,默认为空$filetime
- 存档中文件的修改时间,可选,默认为当前时间
static End($comment = '')
将中央目录和结束部分发送给客户端,从而结束存档。接受一个参数
$comment
- 存档的注释
示例
require_once('BjSZipper.php');
// Send the HTTP headers
BjSZipper::Begin('images.zip');
// Add files and data to send
BjSZipper::SendDir(dirname(__FILE__), true, '/\.(jpg|jpeg)/i'); // All JPEGs recursively
BjSZipper::SendFile('/var/www/html/testdata.bin'); // Just a normal file
BjSZipper::SendData('All the JPEG images.', 'desc.txt'); // A raw text file
// Send the archive directory and end the archive
BjSZipper::End();
关注点
我编写此代码的目的是使其能够工作——基本上没有包含任何安全措施,也没有任何异常处理。请在使用此代码时注意这一点。
历史
- 版本 1.0:实例和静态功能