登录

在这个站点登录

保存我的登录记录

<<忘记密码?

还没有账号?点此注册>>

Jerry

PHP实现ZIP流式打包下载(Zip on the fly)

分享到:

本文已被浏览15683

最近做了个深度学习平台,遇到了一个问题:在图片服务器上将来会存储数以万计的图片资源,系统需求中要求这些图片应当能够导出。数以万计的图片单独地下载肯定不现实,需要打包,上网搜了一下,打包下载的代码很现成,甚至官方有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();
 手机扫描左边的二维码,立刻将文章收入手机!
 微信扫描左边二维码,点击右上角即可分享到朋友圈!
严禁任何非授权的采集与转载,转载须经站长同意并在文章显著位置标注本文连接,站长保留追究法律责任的权利.

评论

  1. #1

    Hi,Jerry:
    你的网盘http://disk.jerryzone.cn用的是什么代码或者CMS实现呢? 能否分享下。

    2018-08-17
    • emmmm这个disk.jerryzone.cn是我自己基于七牛云用php写的,里边功能很简单就是一个列表然后显示每个bucket的内容,说叫网盘,实际上就是个我自己用来维护CDN资源的一个比较容易到达的终端罢了,只不过另外建了几个bucket能简单当当网盘用罢了.至于源码,暂时没有开源的打算.

      2018-08-17
  2. fkuepl
    #2

    您好,我有部分数据较大,想把这个写成一个接口下供他人调用:假如这个接口返回的是ZIP的流是由:a.xml和b.xml二个文件组成的ZIP组成。。。接收方是需要将这个ZIP的流先生成出来一个ZIP文件,再去解压拿到里面的数据,,还是可以不用生成ZIP包,直接拿到我的数据流(因为这个流本身就是个ZIP)就可以直接解压拿到里面的文件呢?求指教

    2019-01-4
  3. 1135192606@qq.com
    #3

    ob_flush();//在flush();前加上这句,不然的话占用内存比ZipArchive更大。

    2019-08-7
    • 抱歉,太久没维护博客了,挖个坟.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()也不会造成什么问题.这里我决定还是参考您的建议加上这行.

      2019-10-28

 您需要 先登录 才可以回复.