Created
December 7, 2017 20:40
-
-
Save jszobody/e10feecb4b8b7ceae2588850ee75ff47 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
namespace App\Services; | |
use App\Models\ZipFile; | |
use Illuminate\Support\Collection; | |
use ZipStream\Exception\FileNotFoundException; | |
use ZipStream\ZipStream as ZipStreamBase; | |
class ZipStream extends ZipStreamBase | |
{ | |
/** | |
* @var array | |
*/ | |
protected $fileList = []; | |
/** | |
* @var int | |
*/ | |
protected $fileSizes = 0; | |
/** | |
* @var int | |
*/ | |
protected $filepathLengths = 0; | |
/** | |
* @var int | |
*/ | |
protected $bytesSent = 0; | |
/** | |
* @var | |
*/ | |
protected $cacheStream; | |
/** | |
* @param null $name | |
* @param array $opt | |
*/ | |
public function __construct($name = null, $opt = []) | |
{ | |
// We want all files treated like large files, which uses 'store' and no compression | |
$opt['large_file_size'] = 1; | |
parent::__construct($name, $opt); | |
} | |
/** | |
* @param $name | |
* | |
* @return $this | |
*/ | |
public function setName($name) | |
{ | |
$this->output_name = $name; | |
$this->need_headers = $name || $this->opt[self::OPTION_SEND_HTTP_HEADERS]; | |
return $this; | |
} | |
/** | |
* Hand me a writable output stream to this method, and we'll save the zip data as we stream it out. | |
* | |
* @param $stream | |
*/ | |
public function cacheZip($stream) | |
{ | |
$this->cacheStream = $stream; | |
} | |
/** | |
* @param String $str | |
*/ | |
protected function send($str) { | |
$this->bytesSent += strlen($str); | |
if(is_resource($this->cacheStream)) { | |
fwrite($this->cacheStream, $str); | |
} | |
parent::send($str); | |
} | |
/** | |
* @param Collection $fileList | |
* | |
* @return $this | |
*/ | |
public function queue(Collection $fileList) | |
{ | |
$fileList->each(function ($file) { | |
$this->queueFile($file); | |
}); | |
return $this; | |
} | |
/** | |
* @param ZipFile $file | |
*/ | |
public function queueFile(ZipFile $file) | |
{ | |
if(array_key_exists($file->getAbsolutePath(), $this->fileList)) { | |
return; | |
} | |
$this->fileList[$file->getAbsolutePath()] = $file; | |
$this->fileSizes += max(2, $file->getFilesize()); | |
$this->filepathLengths += strlen($file->getZipPath()); | |
} | |
/** | |
* @param string $cachePath | |
* | |
* @return $this | |
*/ | |
public function process($cachePath = null) | |
{ | |
if(is_null($cachePath) || !is_writable(dirname($cachePath))) { | |
$this->buildAndSend(); | |
return $this; | |
} | |
$this->cacheStream = fopen($cachePath, 'w'); | |
$this->buildAndSend(); | |
fclose($this->cacheStream); | |
return $this; | |
} | |
/** | |
* | |
*/ | |
protected function buildAndSend() | |
{ | |
$this->sendContentLengthHeader(); | |
foreach($this->fileList AS $file) { | |
$this->processFile($file); | |
} | |
$this->finish(); | |
} | |
/** | |
* @param $file | |
* | |
* @throws FileNotFoundException | |
* @throws \ZipStream\Exception\FileNotReadableException | |
*/ | |
protected function processFile(ZipFile $file) | |
{ | |
if(starts_with($file->getAbsolutePath(), "s3://")) { | |
$this->addFileFromStream($file->getZipPath(), $file->getHandle()); | |
fclose($file->getHandle()); | |
} else { | |
$this->addFileFromPath($file->getZipPath(), $file->getAbsolutePath()); | |
} | |
} | |
/** | |
* @return int | |
*/ | |
public function getBytesSent() | |
{ | |
return $this->bytesSent; | |
} | |
/** | |
* We can do this! | |
*/ | |
protected function sendContentLengthHeader() | |
{ | |
header("Content-Length: " . $this->calculateZipSize()); | |
} | |
/** | |
* See http://stackoverflow.com/a/19380600/660694 | |
* | |
* @return int | |
*/ | |
public function calculateZipSize() | |
{ | |
return count($this->fileList) * (30 + 46) + (2 * $this->filepathLengths) + $this->fileSizes + 22; | |
} | |
/** | |
* @param callable $errorCallback | |
* | |
* @return $this | |
*/ | |
public function sanityCheckFileSize(callable $errorCallback) | |
{ | |
if($this->calculateZipSize() != $this->getBytesSent()) { | |
$errorCallback($this->calculateZipSize(), $this->getBytesSent()); | |
} | |
return $this; | |
} | |
} |
Very insightful! Thank you very much!
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Some notes:
I have a
ZipFile
class, pretty basic (provides absolute path, filesize, can setup a stream, etc). You can guess what that looks like just by how it is used above.I wanted the ability to optionally cache the zip, we have situations where the exact same zip might be requested multiple times by different users. I checked for a cached zip before using the above class to build a new one.
I mostly use this for local files on disk, though you can see I've been working on pulling in source files from S3. That's a bit sketchy at the moment.
Note that I am NOT compressing the zip. This is the only way I can accurately generate a filesize in advance, send a
Content-Length
header, so that browsers/clients can tell the user the size of the download, and provide progress. The alternative is to compress, but not send aContent-Length
header, with your users ignorant of how long it will take. Our use case is zipping files that don't compress much, so it was a much better user experience to provide download progress with slightly larger zip filesizes.I call the above class like this (with a Facade):
Obviously that last part is optional, I wanted to make sure my filesize calculations were correct.