diff --git a/README.md b/README.md index a78b758..86efc4a 100644 --- a/README.md +++ b/README.md @@ -101,3 +101,22 @@ class GoogleCloudStorageLazyStreamFactory } } ``` + +### Reading lazily a stream with `LazyStreamReader` + +Files are already read lazily by default: when you call `fread()`, you only fetch the number of bytes you asked for, not more. +`LazyStreamReader` does the same thing, but it also allows you to keep the stream open or not between reading operations. + +For example, you may want to read a file 1MB by 1MB, and do some processing that may take some time each time. By setting the `autoClose` option to `true` when creating a new `LazyStreamReader` object, you ask to close the stream after each reading operation and open it again when the next reading operation is triggered. You'll be resumed at the same position you were in the stream before closing it. + +```php +// The stream is not opened yet, in case you never need it +$stream = new \LazyStream\LazyStreamReader('https://user:pass@example.com/my-file.png', chunkSize: 1024, autoClose: true, binary: true); + +// Use the stream directly in the loop +foreach ($stream as $str) { + // With auto-closing, the stream is already closed here. You can + // do any long operation, and the stream will be opened again when + // you get in the next loop iteration +} +``` diff --git a/src/AbstractLazyStream.php b/src/AbstractLazyStream.php new file mode 100644 index 0000000..b00cf73 --- /dev/null +++ b/src/AbstractLazyStream.php @@ -0,0 +1,70 @@ +closeStream(); + } + + protected function openStream(): void + { + if (!\is_resource($this->handle)) { + $this->handle = @\fopen($this->uri, $this->openingMode); + + if ($this->handle === false) { + throw new LazyStreamOpenException($this->uri, $this->openingMode); + } + + $this->metadata = \stream_get_meta_data($this->handle); + } + } + + protected function closeStream(): void + { + if (\is_resource($this->handle)) { + \fclose($this->handle); + } + + $this->handle = null; + } + + /** + * @return resource|null + */ + public function getStreamHandle() + { + return $this->handle; + } + + /** + * @return array Stream meta-data array indexed by keys given in https://www.php.net/manual/en/function.stream-get-meta-data.php. + */ + public function getMetadata(): array + { + if ($this->metadata === null) { + // If metadata is null, then we never opened the stream yet + $this->openStream(); + $this->closeStream(); + } + + return $this->metadata; + } +} diff --git a/src/Exception/LazyStreamWriterOpenException.php b/src/Exception/LazyStreamOpenException.php similarity index 72% rename from src/Exception/LazyStreamWriterOpenException.php rename to src/Exception/LazyStreamOpenException.php index 251af3c..64ec1db 100644 --- a/src/Exception/LazyStreamWriterOpenException.php +++ b/src/Exception/LazyStreamOpenException.php @@ -2,7 +2,7 @@ namespace LazyStream\Exception; -class LazyStreamWriterOpenException extends AbstractLazyStreamWriterException +class LazyStreamOpenException extends AbstractLazyStreamWriterException { public function __construct(string $uri, string $mode) { diff --git a/src/LazyStreamReader.php b/src/LazyStreamReader.php new file mode 100644 index 0000000..bdaacc3 --- /dev/null +++ b/src/LazyStreamReader.php @@ -0,0 +1,71 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace LazyStream; + +class LazyStreamReader extends AbstractLazyStream implements LazyStreamReaderInterface +{ + private int $position = 0; + + /** + * @param string $uri A valid stream URI. + * @param bool $autoClose Whether the stream should be closed between reading operations. + */ + public function __construct( + string $uri, + private int $chunkSize, + private bool $autoClose = true, + private bool $binary = false, + ) { + parent::__construct($uri, $this->binary ? 'rb' : 'r'); + } + + public function getStreamPosition(): int + { + return $this->position; + } + + public function isAutoClose(): bool + { + return $this->autoClose; + } + + public function setAutoClose(bool $autoClose): void + { + $this->autoClose = $autoClose; + } + + public function getIterator(): \Generator + { + yield from $this->read(); + } + + private function read(): \Generator + { + $this->openStream(); + + while (($data = \fread($this->handle, $this->chunkSize)) !== false && \strlen($data) !== 0) { + $this->position += $this->chunkSize; + + if ($this->autoClose) { + $this->closeStream(); + yield $data; + + $this->openStream(); + \fseek($this->handle, $this->position); + + continue; + } + + yield $data; + } + + $this->closeStream(); + } +} diff --git a/src/LazyStreamReaderInterface.php b/src/LazyStreamReaderInterface.php new file mode 100644 index 0000000..25113bd --- /dev/null +++ b/src/LazyStreamReaderInterface.php @@ -0,0 +1,14 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace LazyStream; + +interface LazyStreamReaderInterface extends \IteratorAggregate +{ +} diff --git a/src/LazyStreamWriter.php b/src/LazyStreamWriter.php index 0f2fd52..9e0a69c 100644 --- a/src/LazyStreamWriter.php +++ b/src/LazyStreamWriter.php @@ -9,7 +9,7 @@ namespace LazyStream; -use LazyStream\Exception\LazyStreamWriterOpenException; +use LazyStream\Exception\LazyStreamOpenException; use LazyStream\Exception\LazyStreamWriterTriggerException; /** @@ -17,15 +17,8 @@ * the `trigger()` method is called. Data to write are provided by a * generator, allowing data to be generated on the fly if possible. */ -class LazyStreamWriter implements LazyStreamWriterInterface +class LazyStreamWriter extends AbstractLazyStream implements LazyStreamWriterInterface { - /** - * @param resource|null $handle - */ - private $handle; - - private ?array $metadata = null; - /** * @param string $uri A valid stream URI. * @param \Iterator $dataProvider The data provider that will be written to the stream. @@ -33,16 +26,12 @@ class LazyStreamWriter implements LazyStreamWriterInterface * @param bool $autoClose Whether the stream should be closed once the `trigger` method is done. */ public function __construct( - private string $uri, + string $uri, private \Iterator $dataProvider, - private string $openingMode = 'w', + string $openingMode = 'w', private bool $autoClose = true, ) { - } - - public function __destruct() - { - $this->closeStream(); + parent::__construct($uri, $openingMode); } public function trigger(): void @@ -66,14 +55,6 @@ public function trigger(): void } } - /** - * @return resource|null - */ - public function getStreamHandle() - { - return $this->handle; - } - public function unlink(): bool { if (!\is_resource($this->handle)) { @@ -85,20 +66,6 @@ public function unlink(): bool return \unlink($this->uri); } - /** - * @return array Stream meta-data array indexed by keys given in https://www.php.net/manual/en/function.stream-get-meta-data.php. - */ - public function getMetadata(): array - { - if ($this->metadata === null) { - // If metadata is null, then we never opened the stream yet - $this->openStream(); - $this->closeStream(); - } - - return $this->metadata; - } - public function equals(self $other): bool { return $this->dataProvider === $other->dataProvider && $this->uri === $other->uri; @@ -114,26 +81,12 @@ public function setAutoClose(bool $autoClose): void $this->autoClose = $autoClose; } - private function openStream(): void - { - if (!\is_resource($this->handle)) { - $this->handle = @\fopen($this->uri, $this->openingMode); - - if ($this->handle === false) { - throw new LazyStreamWriterOpenException($this->uri, $this->openingMode); - } - } - - $this->metadata = \stream_get_meta_data($this->handle); - } - - private function closeStream(): void + protected function closeStream(): void { if (\is_resource($this->handle)) { \fflush($this->handle); - \fclose($this->handle); - - $this->handle = null; } + + parent::closeStream(); } } diff --git a/src/LazyStreamWriterInterface.php b/src/LazyStreamWriterInterface.php index 34d6aec..414eb36 100644 --- a/src/LazyStreamWriterInterface.php +++ b/src/LazyStreamWriterInterface.php @@ -12,9 +12,4 @@ interface LazyStreamWriterInterface { public function trigger(): void; - - /** - * @return bool True if the stream has been unlinked, false otherwise. - */ - public function unlink(): bool; } diff --git a/tests/LazyStreamReaderTest.php b/tests/LazyStreamReaderTest.php new file mode 100644 index 0000000..e2d487a --- /dev/null +++ b/tests/LazyStreamReaderTest.php @@ -0,0 +1,90 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use LazyStream\Exception\LazyStreamOpenException; +use LazyStream\LazyStreamReader; +use LazyStream\LazyStreamWriter; +use PHPUnit\Framework\TestCase; + +/** + * @covers \LazyStream\LazyStreamReader + */ +class LazyStreamReaderTest extends TestCase +{ + public function testStreamIsLazilyOpened(): void + { + $lazyStream = new LazyStreamReader('php://memory', 2); + + $this->assertNull($lazyStream->getStreamHandle()); + } + + public function testReadStreamWithoutAutoclose(): void + { + $handle = tmpfile(); + $uri = \stream_get_meta_data($handle)['uri']; + \fwrite($handle, 'chunk'); + + $lazyStream = new LazyStreamReader($uri, 2, autoClose: false); + + $finalStr = ''; + foreach ($lazyStream as $str) { + $finalStr .= $str; + + $this->assertLessThanOrEqual(2, \strlen($str)); + $this->assertNotNull($lazyStream->getStreamHandle()); + } + + $this->assertSame('chunk', $finalStr); + \unlink($uri); + } + + public function testReadStreamWithAutoclose(): void + { + $handle = tmpfile(); + $uri = \stream_get_meta_data($handle)['uri']; + \fwrite($handle, 'chunky'); + + $lazyStream = new LazyStreamReader($uri, 2, autoClose: true); + + $finalStr = ''; + foreach ($lazyStream as $str) { + $finalStr .= $str; + + $this->assertLessThanOrEqual(2, \strlen($str)); + $this->assertNull($lazyStream->getStreamHandle()); + } + + $this->assertSame('chunky', $finalStr); + \unlink($uri); + } + + public function testReadStreamAndGetPosition(): void + { + $handle = tmpfile(); + $uri = \stream_get_meta_data($handle)['uri']; + \fwrite($handle, 'chunk'); + + $lazyStream = new LazyStreamReader($uri, 3, autoClose: false); + + $this->assertSame('chu', $lazyStream->getIterator()->current()); + $this->assertSame(3, $lazyStream->getStreamPosition()); + $this->assertNotNull($lazyStream->getStreamHandle()); + + \unlink($uri); + } + + public function testInvalidStream(): void + { + $lazyStream = new LazyStreamWriter('php://invalid', new \ArrayIterator([])); + + $this->expectException(LazyStreamOpenException::class); + $this->expectExceptionMessage('Unable to open "php://invalid" with mode "w".'); + $lazyStream->trigger(); + } +} diff --git a/tests/LazyStreamWriterTest.php b/tests/LazyStreamWriterTest.php index 8e8fce0..4324d7b 100644 --- a/tests/LazyStreamWriterTest.php +++ b/tests/LazyStreamWriterTest.php @@ -9,11 +9,10 @@ namespace LazyStream\Tests; -use LazyStream\Exception\LazyStreamWriterOpenException; +use LazyStream\Exception\LazyStreamOpenException; use LazyStream\Exception\LazyStreamWriterTriggerException; use LazyStream\LazyStreamWriter; use PHPUnit\Framework\TestCase; -use Traversable; /** * @covers \LazyStream\LazyStreamWriter @@ -80,7 +79,7 @@ public function testInvalidStream(): void { $lazyStream = new LazyStreamWriter('php://invalid', new \ArrayIterator([])); - $this->expectException(LazyStreamWriterOpenException::class); + $this->expectException(LazyStreamOpenException::class); $this->expectExceptionMessage('Unable to open "php://invalid" with mode "w".'); $lazyStream->trigger(); }