PHP实现ZIP流式打包下载(Zip on the fly)
最近做了个深度学习平台,遇到了一个问题:在图片服务器上将来会存储数以万计的图片资源,系统需求中要求这些图片应当能够导出。数以万计的图片单独地下载肯定不现实,需要打包,上网搜了一下,打包下载的代码很现成,甚至官方有ZipArchive这样的扩展。但是这里边有一个问题,ZipArchive虽然好,但是会遇到一个问题,就是需要将所有文件打包完后,才能输出到响应流中,这样在正式输出前会有一段明显的白屏卡顿阶段。于是考虑到使用流式打包下载。
对于流式打包下载(Zip on the fly)这种操作并不是首创,在GitHub的仓库打包下载为Zip中,就用到了这种技术。在下载前,服务器也不清楚到底仓库有多大,有多少文件,在下载的时候,服务器一边压缩,一遍向客户端输出压缩的结果,造成的效果就是浏览器立即开始下载,但是并没有显示进度或者最终文件的大小。流式下载除了解决大批量数据压缩过程中出现的明显延迟,还解决了大数据量压缩过程中对内存的消耗。在传统的先压缩后下载的方案中,压缩后的每一个文件的数据必须保存在内存中,这样将会消耗大量的内存空间,在另外一个流式下载的案例(Mybatis中使用流式查询避免数据量过大导致OOM)中,找到了这样一张图:
我们可以看到,在这样一个百万数据量的情况下,在不使用流式下载的情况下,内存一直飙升,最终耗掉了数GB的内存,这显然对于服务器端是不可接受的。当采用了流式输出,对于已经输出的内容不再保留,最终内存甚至不升反降,只消耗了数十MB的内存,资源的利用率有了明显的提升。
既然没有扩展能帮助我们完成这个工作,那就只能自行实现zip的算法了。不过天无绝人之路,在phpMyAdmin中有使用了一个类ZipFile,恰巧它还提供了一个流式输出的开关,那么我们就可以使用这个类来实现流式输出:
/**
* Zip file creation class.
* Makes zip files.
*
* @access public
* @package PhpMyAdmin
* @see Official ZIP file format: https://www.pkware.com/support/zip-app-note
*/
class ZipFile{
/**
* Whether to echo zip as it's built or return as string from -> file
*
* @var boolean $doWrite
*/
var $doWrite = false;
/**
* Array to store compressed data
*
* @var array $datasec
*/
var $datasec = array();
/**
* Central directory
*
* @var array $ctrl_dir
*/
var $ctrl_dir = array();
/**
* End of central directory record
*
* @var string $eof_ctrl_dir
*/
var $eof_ctrl_dir = "\x50\x4b\x05\x06\x00\x00\x00\x00";
/**
* Last offset position
*
* @var integer $old_offset
*/
var $old_offset = 0;
/**
* Comments
*
* @var string $comments
*/
var $comments='';
/**
* Sets member variable this -> doWrite to true
* - Should be called immediately after class instantiation
* - If set to true, then ZIP archive are echo'ed to STDOUT as each
* file is added via this -> addfile(), and central directories are
* echoed to STDOUT on final call to this -> file(). Also,
* this -> file() returns an empty string so it is safe to issue a
* "echo $zipfile;" command
*
* @access public
*
* @return void
*/
function setDoWrite() {
$this->doWrite = true;
} // end of the 'setDoWrite()' method
function setComments($comments){
$this->comments=$comments;
}
/**
* Converts an Unix timestamp to a four byte DOS date and time format (date
* in high two bytes, time in low two bytes allowing magnitude comparison).
*
* @param integer $unixtime the current Unix timestamp
*
* @return integer the current date in a four byte DOS format
*
* @access private
*/
function unix2DosTime($unixtime = 0) {
$timearray = ($unixtime == 0) ? getdate() : getdate($unixtime);
if ($timearray['year'] < 1980) {
$timearray['year'] = 1980;
$timearray['mon'] = 1;
$timearray['mday'] = 1;
$timearray['hours'] = 0;
$timearray['minutes'] = 0;
$timearray['seconds'] = 0;
} // end if
return (($timearray['year'] - 1980) << 25)
| ($timearray['mon'] << 21)
| ($timearray['mday'] << 16)
| ($timearray['hours'] << 11)
| ($timearray['minutes'] << 5)
| ($timearray['seconds'] >> 1);
} // end of the 'unix2DosTime()' method
/**
* Adds "file" to archive
*
* @param string $data file contents
* @param string $name name of the file in the archive (may contains the path)
* @param integer $time the current timestamp
*
* @access public
*
* @return void
*/
function addFile($data, $name, $time = 0){
$name = str_replace('\\', '/', $name);
$hexdtime = pack('V', $this->unix2DosTime($time));
$fr = "\x50\x4b\x03\x04";
$fr .= "\x14\x00"; // ver needed to extract
$fr .= "\x00\x00"; // gen purpose bit flag
$fr .= "\x08\x00"; // compression method
$fr .= $hexdtime; // last mod time and date
// "local file header" segment
$unc_len = strlen($data);
$crc = crc32($data);
$zdata = gzcompress($data);
$zdata = substr(substr($zdata, 0, strlen($zdata) - 4), 2); // fix crc bug
$c_len = strlen($zdata);
$fr .= pack('V', $crc); // crc32
$fr .= pack('V', $c_len); // compressed filesize
$fr .= pack('V', $unc_len); // uncompressed filesize
$fr .= pack('v', strlen($name)); // length of filename
$fr .= pack('v', 0); // extra field length
$fr .= $name;
// "file data" segment
$fr .= $zdata;
// echo this entry on the fly, ...
if ($this->doWrite) {
echo $fr;
} else { // ... OR add this entry to array
$this->datasec[] = $fr;
}
// now add to central directory record
$cdrec = "\x50\x4b\x01\x02";
$cdrec .= "\x00\x00"; // version made by
$cdrec .= "\x14\x00"; // version needed to extract
$cdrec .= "\x00\x00"; // gen purpose bit flag
$cdrec .= "\x08\x00"; // compression method
$cdrec .= $hexdtime; // last mod time & date
$cdrec .= pack('V', $crc); // crc32
$cdrec .= pack('V', $c_len); // compressed filesize
$cdrec .= pack('V', $unc_len); // uncompressed filesize
$cdrec .= pack('v', strlen($name)); // length of filename
$cdrec .= pack('v', 0); // extra field length
$cdrec .= pack('v', 0); // file comment length
$cdrec .= pack('v', 0); // disk number start
$cdrec .= pack('v', 0); // internal file attributes
$cdrec .= pack('V', 32); // external file attributes
// - 'archive' bit set
$cdrec .= pack('V', $this->old_offset); // relative offset of local header
$this->old_offset += strlen($fr);
$cdrec .= $name;
// optional extra field, file comment goes here
// save to central directory
$this->ctrl_dir[] = $cdrec;
} // end of the 'addFile()' method
/**
* Echo central dir if ->doWrite==true, else build string to return
*
* @return string if ->doWrite {empty string} else the ZIP file contents
*
* @access public
*/
function file(){
$ctrldir = implode('', $this->ctrl_dir);
$header = $ctrldir .
$this->eof_ctrl_dir .
pack('v', sizeof($this->ctrl_dir)) . //total #of entries "on this disk"
pack('v', sizeof($this->ctrl_dir)) . //total #of entries overall
pack('V', strlen($ctrldir)) . //size of central dir
pack('V', $this->old_offset) . //offset to start of central dir
pack('v',strlen($this->comments)).//.zip file comment length
$this->comments;
if ($this->doWrite) { // Send central directory & end ctrl dir to STDOUT
echo $header;
return ""; // Return empty string
} else { // Return entire ZIP archive as string
$data = implode('', $this->datasec);
return $data . $header;
}
} // end of the 'file()' method
} // end of the 'PMA\libraries\ZipFile' class
上面的代码与原版略有更改。原版不支持为最终的zip文件添加注释,通过使用010Editor和相关文档的说明,我添加了对注释的支持。当我们需要执行流式下载时,可以使用下边的代码:
<?php
include_once("ZipFile.php");
header("Content-Disposition: attachment; filename=文件名.zip");
//创建生成器
$zip=new ZipFile();
//开启流式压缩
$zip->setDoWrite();
//遍历数据
foreach ($datas as $data){
//添加文件
//这里需要注意一下,如果文件名或者注释中涉及中文,那么要注意字符串保存使用的编码不是GBK时要改为GBK
//(对于字符串常量就是php文档的编码,数据库中得到的数据就是数据库的编码等等)
$zip->addFile($data,iconv("utf-8","gbk",'文件名'));
//感谢1135192606@qq.com提醒,对于output_buffering为On的场景,需要手动调用ob_flush()防止输出数据堆积在PHP输出缓存中
ob_flush();
//每次调用完压缩算法把输出缓存挤出去
flush();
}
//设置注释,此处可以省略
$zip->setComments(iconv("utf-8","gbk","一些注释"));
//输出文件尾部
$zip->file();
Hi,Jerry:
你的网盘http://disk.jerryzone.cn用的是什么代码或者CMS实现呢? 能否分享下。
emmmm这个disk.jerryzone.cn是我自己基于七牛云用php写的,里边功能很简单就是一个列表然后显示每个bucket的内容,说叫网盘,实际上就是个我自己用来维护CDN资源的一个比较容易到达的终端罢了,只不过另外建了几个bucket能简单当当网盘用罢了.至于源码,暂时没有开源的打算.
您好,我有部分数据较大,想把这个写成一个接口下供他人调用:假如这个接口返回的是ZIP的流是由:a.xml和b.xml二个文件组成的ZIP组成。。。接收方是需要将这个ZIP的流先生成出来一个ZIP文件,再去解压拿到里面的数据,,还是可以不用生成ZIP包,直接拿到我的数据流(因为这个流本身就是个ZIP)就可以直接解压拿到里面的文件呢?求指教
ob_flush();//在flush();前加上这句,不然的话占用内存比ZipArchive更大。
抱歉,太久没维护博客了,挖个坟.ob_flush()这个清理的是PHP的缓冲区,大小是由php.ini中output_buffer设置(默认4KB),当缓存空间满后会自动调用ob_flush().对于这个output_buffer有三种值,on/off/整数.设置为On表示开启缓存并且不限制缓存大小,这种情况下需要手动调用ob_flush()使得缓冲区的内容输出;设置为Off表示关闭缓冲区,即输出的内容立刻输出至客户端,相当于echo后立刻调用ob_flush();设置为整数表示开启缓存但缓存区限制为整数大小(字节),当缓冲区满后自动调用ob_flush().所以比较保守的写法是加上ob_flush(),防止output_buffering被改成了On,但是大多数默认配置场景下不加ob_flush()也不会造成什么问题.这里我决定还是参考您的建议加上这行.