From c03183793b96ce73828c3310f82d88f9bf1a383f Mon Sep 17 00:00:00 2001 From: Joel Wurtz Date: Thu, 4 Aug 2016 23:57:08 +0200 Subject: [PATCH 1/4] Add buffered stream --- spec/Stream/BufferedStreamSpec.php | 183 ++++++++++++++++++++ src/Stream/BufferedStream.php | 264 +++++++++++++++++++++++++++++ 2 files changed, 447 insertions(+) create mode 100644 spec/Stream/BufferedStreamSpec.php create mode 100644 src/Stream/BufferedStream.php diff --git a/spec/Stream/BufferedStreamSpec.php b/spec/Stream/BufferedStreamSpec.php new file mode 100644 index 0000000..bc5a05c --- /dev/null +++ b/spec/Stream/BufferedStreamSpec.php @@ -0,0 +1,183 @@ +beConstructedWith($stream); + + $stream->getSize()->willReturn(null); + } + + public function it_is_castable_to_string(StreamInterface $stream) + { + $eofCounter = 0; + $stream->eof()->will(function () use(&$eofCounter) { + return (++$eofCounter > 1); + }); + + $stream->read(8192)->willReturn('foo'); + + $this->__toString()->shouldReturn('foo'); + } + + public function it_detachs(StreamInterface $stream) + { + $stream->eof()->willReturn(true); + $stream->read(8192)->willReturn(''); + + $this->detach()->shouldBeResource(); + $this->detach()->shouldBeNull(); + } + + public function it_gets_size(StreamInterface $stream) + { + $stream->eof()->willReturn(false); + $this->getSize()->shouldReturn(null); + + $eofCounter = 0; + $stream->eof()->will(function () use(&$eofCounter) { + return (++$eofCounter > 1); + }); + + $stream->read(8192)->willReturn('foo'); + + $this->getContents()->shouldReturn('foo'); + $this->getSize()->shouldReturn(3); + } + + public function it_tells(StreamInterface $stream) + { + $this->tell()->shouldReturn(0); + + $eofCounter = 0; + $stream->eof()->will(function () use(&$eofCounter) { + return (++$eofCounter > 1); + }); + + $stream->read(8192)->willReturn('foo'); + $this->getContents()->shouldReturn('foo'); + $this->tell()->shouldReturn(3); + } + + public function it_eof(StreamInterface $stream) + { + // Case when underlying is false + $stream->eof()->willReturn(false); + $this->eof()->shouldReturn(false); + + // Case when sync and underlying is true + $stream->eof()->willReturn(true); + $this->eof()->shouldReturn(true); + + // Case not sync but underlying is true + $eofCounter = 0; + $stream->eof()->will(function () use(&$eofCounter) { + return (++$eofCounter > 1); + }); + + $stream->read(8192)->willReturn('foo'); + + $this->getContents()->shouldReturn('foo'); + $this->seek(0); + + $stream->eof()->willReturn(true); + $this->eof()->shouldReturn(false); + } + + public function it_is_seekable(StreamInterface $stream) + { + $this->isSeekable()->shouldReturn(true); + } + + public function it_seeks(StreamInterface $stream) + { + $this->seek(0); + $this->tell()->shouldReturn(0); + + $eofCounter = 0; + $stream->eof()->will(function () use(&$eofCounter) { + return (++$eofCounter > 1); + }); + + $stream->read(8192)->willReturn('foo'); + + $this->getContents()->shouldReturn('foo'); + $this->seek(2); + $this->tell()->shouldReturn(2); + } + + public function it_rewinds(StreamInterface $stream) + { + $this->rewind(); + $this->tell()->shouldReturn(0); + + $eofCounter = 0; + $stream->eof()->will(function () use(&$eofCounter) { + return (++$eofCounter > 1); + }); + + $stream->read(8192)->willReturn('foo'); + + $this->getContents()->shouldReturn('foo'); + $this->tell()->shouldReturn(3); + $this->rewind(); + $this->tell()->shouldReturn(0); + } + + public function it_is_not_writable(StreamInterface $stream) + { + $this->isWritable()->shouldReturn(false); + $this->shouldThrow('\RuntimeException')->duringWrite('foo'); + } + + public function it_is_readable(StreamInterface $stream) + { + $this->isReadable()->shouldReturn(true); + } + + public function it_reads(StreamInterface $stream) + { + $eofCounter = 0; + $stream->eof()->will(function () use(&$eofCounter) { + return (++$eofCounter > 1); + }); + + $stream->read(3)->willReturn('foo'); + $this->read(3)->shouldReturn('foo'); + + $stream->read(3)->willReturn('bar'); + $this->read(3)->shouldReturn('bar'); + + $this->rewind(); + $this->read(4)->shouldReturn('foob'); + + $stream->read(3)->willReturn('baz'); + $this->read(5)->shouldReturn('arbaz'); + } + + public function it_get_contents(StreamInterface $stream) + { + $eofCounter = 0; + $stream->eof()->will(function () use(&$eofCounter) { + return (++$eofCounter > 1); + }); + + $stream->read(8192)->willReturn('foo'); + + $this->getContents()->shouldReturn('foo'); + $this->eof()->shouldReturn(true); + } + + public function it_get_metadatas(StreamInterface $stream) + { + $this->getMetadata()->shouldBeArray(); + $this->getMetadata('unexistant')->shouldBeNull(); + $this->getMetadata('stream_type')->shouldReturn('TEMP'); + } +} diff --git a/src/Stream/BufferedStream.php b/src/Stream/BufferedStream.php new file mode 100644 index 0000000..8f24766 --- /dev/null +++ b/src/Stream/BufferedStream.php @@ -0,0 +1,264 @@ +stream = $stream; + $this->size = $stream->getSize(); + + if ($useFileBuffer) { + $this->resource = fopen('php://temp/maxmemory:'.$memoryBuffer, 'rw+'); + } else { + $this->resource = fopen('php://memory', 'rw+'); + } + + if (false === $this->resource) { + throw new \RuntimeException('Cannot create a resource over temp or memory implementation'); + } + } + + /** + * {@inheritdoc} + */ + public function __toString() + { + try { + $this->rewind(); + + return $this->getContents(); + } catch (\Throwable $throwable) { + return ''; + } catch (\Exception $exception) { + return ''; + } + } + + /** + * {@inheritdoc} + */ + public function close() + { + if (null === $this->resource) { + throw new \RuntimeException('Cannot close on a detached stream'); + } + + $this->stream->close(); + fclose($this->resource); + } + + /** + * {@inheritdoc} + */ + public function detach() + { + if (null === $this->resource) { + return null; + } + + // Force reading the remaining data of the stream + $this->getContents(); + + $resource = $this->resource; + $this->stream = null; + $this->resource = null; + + return $resource; + } + + /** + * {@inheritdoc} + */ + public function getSize() + { + if (null === $this->size && $this->stream->eof()) { + if (null === $this->resource) { + return null; + } + + return $this->writed; + } + + return $this->size; + } + + /** + * {@inheritdoc} + */ + public function tell() + { + if (null === $this->resource) { + throw new \RuntimeException('Cannot tell on a detached stream'); + } + + return ftell($this->resource); + } + + /** + * {@inheritdoc} + */ + public function eof() + { + if (null === $this->resource) { + throw new \RuntimeException('Cannot call eof on a detached stream'); + } + + // We are at the end only when both our resource and underlying stream are at eof + return $this->stream->eof() && (ftell($this->resource) === $this->writed); + } + + /** + * {@inheritdoc} + */ + public function isSeekable() + { + if (null === $this->resource) { + return false; + } + + return true; + } + + /** + * {@inheritdoc} + */ + public function seek($offset, $whence = SEEK_SET) + { + if (null === $this->resource) { + throw new \RuntimeException('Cannot seek on a detached stream'); + } + + fseek($this->resource, $offset, $whence); + } + + /** + * {@inheritdoc} + */ + public function rewind() + { + if (null === $this->resource) { + throw new \RuntimeException('Cannot rewind on a detached stream'); + } + + rewind($this->resource); + } + + /** + * {@inheritdoc} + */ + public function isWritable() + { + return false; + } + + /** + * {@inheritdoc} + */ + public function write($string) + { + throw new \RuntimeException('Cannot write on this stream'); + } + + /** + * {@inheritdoc} + */ + public function isReadable() + { + return (null !== $this->resource); + } + + /** + * {@inheritdoc} + */ + public function read($length) + { + if (null === $this->resource) { + throw new \RuntimeException('Cannot read on a detached stream'); + } + + $read = ""; + + // First read from the resource + if (ftell($this->resource) !== $this->writed) { + $read = fread($this->resource, $length); + } + + $bytesRead = strlen($read); + + if ($bytesRead < $length) { + $streamRead = $this->stream->read($length - $bytesRead); + + // Write on the underlying stream what we read + $this->writed += fwrite($this->resource, $streamRead); + $read .= $streamRead; + } + + return $read; + } + + /** + * {@inheritdoc} + */ + public function getContents() + { + if (null === $this->resource) { + throw new \RuntimeException('Cannot read on a detached stream'); + } + + $read = ""; + + while (!$this->stream->eof()) { + $read .= $this->read(8192); + } + + return $read; + } + + /** + * {@inheritdoc} + */ + public function getMetadata($key = null) + { + if (null === $this->resource) { + if (null === $key) { + return []; + } + + return null; + } + + $metadata = stream_get_meta_data($this->resource); + + if (null === $key) { + return $metadata; + } + + if (!array_key_exists($key, $metadata)) { + return null; + } + + return $metadata[$key]; + } +} From 15330e6d1a8e6ff194b4a894808882a2bedc718c Mon Sep 17 00:00:00 2001 From: Joel Wurtz Date: Fri, 5 Aug 2016 00:11:26 +0200 Subject: [PATCH 2/4] Fix cs --- src/Stream/BufferedStream.php | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/Stream/BufferedStream.php b/src/Stream/BufferedStream.php index 8f24766..f090a8f 100644 --- a/src/Stream/BufferedStream.php +++ b/src/Stream/BufferedStream.php @@ -15,7 +15,7 @@ class BufferedStream implements StreamInterface /** @var resource The buffered resource used to seek previous data */ private $resource; - /** @var integer size of the stream if available */ + /** @var int size of the stream if available */ private $size; /** @var StreamInterface The underlying stream decorated by this class */ @@ -75,7 +75,7 @@ public function close() public function detach() { if (null === $this->resource) { - return null; + return; } // Force reading the remaining data of the stream @@ -95,7 +95,7 @@ public function getSize() { if (null === $this->size && $this->stream->eof()) { if (null === $this->resource) { - return null; + return; } return $this->writed; @@ -186,7 +186,7 @@ public function write($string) */ public function isReadable() { - return (null !== $this->resource); + return null !== $this->resource; } /** @@ -198,7 +198,7 @@ public function read($length) throw new \RuntimeException('Cannot read on a detached stream'); } - $read = ""; + $read = ''; // First read from the resource if (ftell($this->resource) !== $this->writed) { @@ -227,9 +227,9 @@ public function getContents() throw new \RuntimeException('Cannot read on a detached stream'); } - $read = ""; + $read = ''; - while (!$this->stream->eof()) { + while (!$this->eof()) { $read .= $this->read(8192); } @@ -246,7 +246,7 @@ public function getMetadata($key = null) return []; } - return null; + return; } $metadata = stream_get_meta_data($this->resource); @@ -256,7 +256,7 @@ public function getMetadata($key = null) } if (!array_key_exists($key, $metadata)) { - return null; + return; } return $metadata[$key]; From 0b5ce24a28d0b82dc4a93141096256ad7ceeb83d Mon Sep 17 00:00:00 2001 From: Joel Wurtz Date: Fri, 5 Aug 2016 12:39:55 +0200 Subject: [PATCH 3/4] Fix edge case and better documentation --- spec/Stream/BufferedStreamSpec.php | 1 + src/Stream/BufferedStream.php | 40 +++++++++++++++++------------- 2 files changed, 24 insertions(+), 17 deletions(-) diff --git a/spec/Stream/BufferedStreamSpec.php b/spec/Stream/BufferedStreamSpec.php index bc5a05c..c1cbba6 100644 --- a/spec/Stream/BufferedStreamSpec.php +++ b/spec/Stream/BufferedStreamSpec.php @@ -30,6 +30,7 @@ public function it_detachs(StreamInterface $stream) { $stream->eof()->willReturn(true); $stream->read(8192)->willReturn(''); + $stream->close()->shouldBeCalled(); $this->detach()->shouldBeResource(); $this->detach()->shouldBeNull(); diff --git a/src/Stream/BufferedStream.php b/src/Stream/BufferedStream.php index f090a8f..0714d62 100644 --- a/src/Stream/BufferedStream.php +++ b/src/Stream/BufferedStream.php @@ -5,10 +5,12 @@ use Psr\Http\Message\StreamInterface; /** - * A buffered stream allow to buffer over an existing. + * Decorator to make any stream seekable. * - * You should use this decorator when you want to seek over a not seekable stream. - * This stream is however read-only. + * Internally it buffers an existing StreamInterface into a php://temp resource (or memory). By default it will use + * 2 megabytes of memory before writing to a temporary disk file. + * + * Due to this, very large stream can suffer performance issue (i/o slowdown). */ class BufferedStream implements StreamInterface { @@ -22,8 +24,15 @@ class BufferedStream implements StreamInterface private $stream; /** @var int How many bytes were written */ - private $writed = 0; + private $written = 0; + /** + * @param StreamInterface $stream Decorated stream + * @param bool $useFileBuffer Whether to use a file buffer (write to a file, if data exceed a certain size) + * by default, set this to false to only use memory + * @param int $memoryBuffer In conjunction with using file buffer, limit (in bytes) from which it begins to buffer + * the data in a file + */ public function __construct(StreamInterface $stream, $useFileBuffer = true, $memoryBuffer = 2097152) { $this->stream = $stream; @@ -82,6 +91,7 @@ public function detach() $this->getContents(); $resource = $this->resource; + $this->stream->close(); $this->stream = null; $this->resource = null; @@ -93,12 +103,12 @@ public function detach() */ public function getSize() { - if (null === $this->size && $this->stream->eof()) { - if (null === $this->resource) { - return; - } + if (null === $this->resource) { + return; + } - return $this->writed; + if (null === $this->size && $this->stream->eof()) { + return $this->written; } return $this->size; @@ -126,7 +136,7 @@ public function eof() } // We are at the end only when both our resource and underlying stream are at eof - return $this->stream->eof() && (ftell($this->resource) === $this->writed); + return $this->stream->eof() && (ftell($this->resource) === $this->written); } /** @@ -134,11 +144,7 @@ public function eof() */ public function isSeekable() { - if (null === $this->resource) { - return false; - } - - return true; + return null !== $this->resource; } /** @@ -201,7 +207,7 @@ public function read($length) $read = ''; // First read from the resource - if (ftell($this->resource) !== $this->writed) { + if (ftell($this->resource) !== $this->written) { $read = fread($this->resource, $length); } @@ -211,7 +217,7 @@ public function read($length) $streamRead = $this->stream->read($length - $bytesRead); // Write on the underlying stream what we read - $this->writed += fwrite($this->resource, $streamRead); + $this->written += fwrite($this->resource, $streamRead); $read .= $streamRead; } From b103e8574dd545a2922c373ad189310bd292e370 Mon Sep 17 00:00:00 2001 From: Joel Wurtz Date: Fri, 5 Aug 2016 16:05:01 +0200 Subject: [PATCH 4/4] Add comment for BC layer on PHP5 --- src/Stream/BufferedStream.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Stream/BufferedStream.php b/src/Stream/BufferedStream.php index 0714d62..1eac974 100644 --- a/src/Stream/BufferedStream.php +++ b/src/Stream/BufferedStream.php @@ -60,7 +60,7 @@ public function __toString() return $this->getContents(); } catch (\Throwable $throwable) { return ''; - } catch (\Exception $exception) { + } catch (\Exception $exception) { // Layer to be BC with PHP 5, remove this when we only support PHP 7+ return ''; } }