From 07bf87af8e1be580f23f19d64eeaf81eb2a7784d Mon Sep 17 00:00:00 2001 From: Bertrand Dunogier Date: Wed, 28 May 2014 08:48:27 +0200 Subject: [PATCH] Implemented dispatcher --- README.md | 71 +++++ classes/ezdfsfilehandlerdfsdispatcher.php | 225 +++++++++++++++ classes/ezdfsfilehandlerdfsregistry.php | 93 +++++++ .../ezdfsfilehandlerdfsregistryinterface.php | 25 ++ settings/file.ini.append.php | 13 + .../ezdfsfilehandlerdfsdispatcher_test.php | 263 ++++++++++++++++++ .../ezdfsfilehandlerdfsregistry_test.php | 57 ++++ 7 files changed, 747 insertions(+) create mode 100644 README.md create mode 100644 classes/ezdfsfilehandlerdfsdispatcher.php create mode 100644 classes/ezdfsfilehandlerdfsregistry.php create mode 100644 classes/ezdfsfilehandlerdfsregistryinterface.php create mode 100644 settings/file.ini.append.php create mode 100644 tests/classes/ezdfsfilehandlerdfsdispatcher_test.php create mode 100644 tests/classes/ezdfsfilehandlerdfsregistry_test.php diff --git a/README.md b/README.md new file mode 100644 index 0000000..4375876 --- /dev/null +++ b/README.md @@ -0,0 +1,71 @@ +# DFS Cluster DFS Dispatcher + +## What is it ? +*This extension by itself won't provide any new end-user feature.* + +This extension provides a custom DFSBackend that dispatches calls to any DFS backend to a backend from a custom list, +based on the path of the file. + +It makes it possible to store some storage subdirectories to custom handlers, such as a cloud based one. + +## Status +Working prototype, close from first release. + +## Requirements +- eZ Publish installed from git, with the EZP-22960-configurable_dfs_backend branch checked out. +- eZ DFS configured (NFS itself doesn't matter, a local directory will work just as fine) + +## Installation +It can be installed via composer from eZ Publish, new stack or legacy: +``` +composer require "ezsystems/ezdfs-fsbackend-dispatcher:dev-master" +``` + +Or by adding `"ezsystems/ezdfs-fsbackend-dispatcher": "dev-master"` to your project's composer.json. + +It can also be manually checked out from http://github.com/ezsystems/ezsystems/ezdfs-fsbackend-dispatcher.git into the +legacy `extension` directory. + +## Configuration +Due to INI settings loading order limitations, some settings can't be stored into extension INI files but in a global override. + +The contents of the settings/file.ini.append.php file must be copied to `settings/override/file.ini.append.php`, or +obviously merged into it, since it should already exist. + +Backends are configured by adding their class name to the DFSBackends array in file.ini. This will make the dispatcher send +operations on files with a path starting with `var/ezdemo_site/storage/images` to MyCustomBackend. + +``` +PathBackends[var/ezdemo_site/storage/images]=MyCustomBackend +``` + +Priority is a simple first-come, first-served. Path that aren't matched by any path in `PathBackends` are handled by +`DispatchableDFS.DefaultBackend`, by default set to the native `eZDFSFileHandlerDFSBackend`. + +## Backends initialization +By default, backends are instanciated with a simple "new $class". But if a backend implements +`eZDFSFileHandlerDFSFactoryBackendInterface` interface, it will be built by calling the static `build()` method. + +If any kind of initialization or injection is required, it can be done in this method. + +A typical settings/override/file.ini.append.php with a custom handler enabled would look like this: +```ini +[ClusteringSettings] +FileHandler=eZDFSFileHandler + +[eZDFSClusteringSettings] +MountPointPath=/media/nfs +DFSBackend=eZDFSFileHandlerDFSDispatcher +DBHost=cluster_server +DBName=db_cluster +DBUser=root +DBPassword= +MetaDataTableNameCache=ezdfsfile_cache + +[DispatchableDFS] +DefaultBackend=eZDFSFileHandlerDFSBackend + +PathBackends[var/site/storage/images]=MyCustomBackend +``` + +Remember that `DefaultBackend` *must* be explicitly configured in the global override to be taken into account. diff --git a/classes/ezdfsfilehandlerdfsdispatcher.php b/classes/ezdfsfilehandlerdfsdispatcher.php new file mode 100644 index 0000000..3750a7e --- /dev/null +++ b/classes/ezdfsfilehandlerdfsdispatcher.php @@ -0,0 +1,225 @@ +fsHandlersRegistry = $fsHandlersRegistry; + } + + /** + * Instantiates the dispatcher + * @return self + */ + public static function build() + { + return new self( eZDFSFileHandlerDFSRegistry::build() ); + } + + /** + * Returns the FSHandler for $path + * @param $path + * @return eZDFSFileHandlerDFSBackendInterface + */ + private function getHandler( $path ) + { + return $this->fsHandlersRegistry->getHandler( $path ); + } + + /** + * Returns all the fs handlers + * @return eZDFSFileHandlerDFSBackendInterface[] + */ + private function getAllHandlers() + { + return $this->fsHandlersRegistry->getAllHandlers(); + } + + /** + * Creates a copy of $srcFilePath from DFS to $dstFilePath on DFS + * + * @param string $srcFilePath Local source file path + * @param string $dstFilePath Local destination file path + * + * @return bool + */ + public function copyFromDFSToDFS( $srcFilePath, $dstFilePath ) + { + $srcHandler = $this->getHandler( $srcFilePath ); + $dstHandler = $this->getHandler( $dstFilePath ); + + if ( $srcHandler === $dstHandler ) + { + return $srcHandler->copyFromDFSToDFS( $srcFilePath, $dstFilePath ); + } + else + { + return $dstHandler->createFileOnDFS( $dstFilePath, $srcHandler->getContents( $srcFilePath ) ); + } + } + + /** + * Copies the DFS file $srcFilePath to FS + * + * @param string $srcFilePath Source file path (on DFS) + * @param string|bool $dstFilePath Destination file path (on FS). If not specified, $srcFilePath is used + * + * @return bool + */ + public function copyFromDFS( $srcFilePath, $dstFilePath = false ) + { + return $this->getHandler( $srcFilePath )->copyFromDFS( $srcFilePath, $dstFilePath ); + } + + /** + * Copies the local file $filePath to DFS under the same name, or a new name + * if specified + * + * @param string $srcFilePath Local file path to copy from + * @param bool|string $dstFilePath + * Optional path to copy to. If not specified, $srcFilePath is used + * + * @return bool + */ + public function copyToDFS( $srcFilePath, $dstFilePath = false ) + { + return $this->getHandler( $dstFilePath ?: $srcFilePath )->copyToDFS( $srcFilePath, $dstFilePath ); + } + + /** + * Deletes one or more files from DFS + * + * @param string|array $filePath Single local filename, or array of local filenames + * + * @return bool true if deletion was successful, false otherwise + */ + public function delete( $filePath ) + { + return $this->getHandler( $filePath )->delete( $filePath ); + } + + /** + * Sends the contents of $filePath to default output + * + * @param string $filePath File path + * @param int $startOffset Starting offset + * @param bool|int $length Length to transmit, false means everything + * + * @return bool true, or false if operation failed + */ + public function passthrough( $filePath, $startOffset = 0, $length = false ) + { + return $this->getHandler( $filePath )->passthrough( $filePath, $startOffset, $length ); + } + + /** + * Returns the binary content of $filePath from DFS + * + * @param string $filePath local file path + * + * @return string|bool file's content, or false + */ + public function getContents( $filePath ) + { + return $this->getHandler( $filePath )->getContents( $filePath ); + } + + /** + * Creates the file $filePath on DFS with content $contents + * + * @param string $filePath + * @param string $contents + * + * @return bool + */ + public function createFileOnDFS( $filePath, $contents ) + { + return $this->getHandler( $filePath )->createFileOnDFS( $filePath, $contents ); + } + + /** + * Renamed DFS file $oldPath to DFS file $newPath + * + * @param string $oldPath + * @param string $newPath + * + * @return bool + */ + public function renameOnDFS( $oldPath, $newPath ) + { + $oldPathHandler = $this->getHandler( $oldPath ); + $newPathHandler = $this->getHandler( $newPath ); + + // same handler, normal rename + if ( $oldPathHandler === $newPathHandler ) + { + return $oldPathHandler->renameOnDFS( $oldPath, $newPath ); + } + // different handlers, create on new, delete on old + else + { + if ( $newPathHandler->createFileOnDFS( $newPath, $oldPathHandler->getContents( $oldPath ) ) !== true ) + return false; + + return $oldPathHandler->delete( $oldPath ); + } + } + + /** + * Checks if a file exists on the DFS + * + * @param string $filePath + * + * @return bool + */ + public function existsOnDFS( $filePath ) + { + return $this->getHandler( $filePath )->existsOnDFS( $filePath ); + } + + /** + * Returns size of a file in the DFS backend, from a relative path. + * + * @param string $filePath The relative file path we want to get size of + * + * @return int + */ + public function getDfsFileSize( $filePath ) + { + return $this->getHandler( $filePath )->getDfsFileSize( $filePath ); + } + + /** + * Returns an AppendIterator with every handler's iterator + * + * @param string $basePath + * + * @return Iterator + */ + public function getFilesList( $basePath ) + { + $iterator = new AppendIterator(); + foreach ( $this->getAllHandlers() as $handler ) + { + $iterator->append( $handler->getFilesList( $basePath ) ); + } + return $iterator; + } +} diff --git a/classes/ezdfsfilehandlerdfsregistry.php b/classes/ezdfsfilehandlerdfsregistry.php new file mode 100644 index 0000000..ec694b8 --- /dev/null +++ b/classes/ezdfsfilehandlerdfsregistry.php @@ -0,0 +1,93 @@ + $handler ) + { + if ( !$handler instanceof eZDFSFileHandlerDFSBackendInterface ) + { + throw new InvalidArgumentException( get_class( $handler ) . " does not implement eZDFSFileHandlerDFSBackendInterface" ); + } + } + + $this->defaultHandler = $defaultHandler; + $this->pathHandlers = $pathHandlers; + } + + /** + * Returns the FSHandler for $path + * @param $path + * @return eZDFSFileHandlerDFSBackendInterface + * @throws OutOfRangeException If no handler supports $path + */ + public function getHandler( $path ) + { + foreach ( $this->pathHandlers as $supportedPath => $handler ) + { + if ( strstr( $path, $supportedPath ) !== false ) + { + return $handler; + } + } + + return $this->defaultHandler; + } + + public function getAllHandlers() + { + $handlers = array_values( $this->pathHandlers ); + $handlers[] = $this->defaultHandler; + return $handlers; + } + + /** + * Builds a registry using either the provided configuration, or settings from self::getConfiguration + * @return self + */ + public static function build() + { + $ini = eZINI::instance( 'file.ini' ); + $defaultHandler = eZDFSFileHandlerBackendFactory::buildHandler( + $ini->variable( 'DispatchableDFS', 'DefaultBackend' ) + ); + + $pathHandlers = array(); + foreach ( $ini->variable( 'DispatchableDFS', 'PathBackends' ) as $supportedPath => $backendClass ) + { + // @todo Make it possible to use a Symfony2 service + $pathHandlers[$supportedPath] = eZDFSFileHandlerBackendFactory::buildHandler( $backendClass ); + } + + return new static( $defaultHandler, $pathHandlers ); + } +} diff --git a/classes/ezdfsfilehandlerdfsregistryinterface.php b/classes/ezdfsfilehandlerdfsregistryinterface.php new file mode 100644 index 0000000..51b1e9f --- /dev/null +++ b/classes/ezdfsfilehandlerdfsregistryinterface.php @@ -0,0 +1,25 @@ +customHandler = $this->getMock( 'eZDFSFileHandlerDFSBackendInterface' ); + $this->defaultHandler = $this->getMock( 'eZDFSFileHandlerDFSBackendInterface' ); + + $this->dispatcher = new eZDFSFileHandlerDFSDispatcher( + new eZDFSFileHandlerDFSRegistry( + $this->defaultHandler, + array( 'one://' => $this->customHandler ) + ) + ); + } + + public function testCopyFromDFSToDFSSameHandler() + { + $srcPath = 'one://source_file'; + $dstPath = 'one://dest_file'; + + $this->customHandler + ->expects( $this->once() ) + ->method( 'copyFromDFSToDFS' ) + ->with( $srcPath, $dstPath ) + ->will( $this->returnValue( true ) ); + + self::assertTrue( $this->dispatcher->copyFromDFSToDFS( $srcPath, $dstPath ) ); + } + + public function testCopyFromDFSToDFSDifferentHandler() + { + $srcPath = "one://src_file"; + $dstPath = "two://dst_file"; + $contents = __FILE__; + + $this->customHandler + ->expects( $this->once() ) + ->method( 'getContents' ) + ->with( $srcPath ) + ->will( $this->returnValue( $contents ) ); + + $this->defaultHandler + ->expects( $this->once() ) + ->method( 'createFileOnDFS' ) + ->with( $dstPath, $contents ) + ->will( $this->returnValue( true ) ); + + self::assertTrue( + $this->dispatcher->copyFromDFSToDFS( $srcPath, $dstPath ) + ); + } + + public function testCopyFromDFS() + { + $srcPath = 'one://source_file'; + + $this->customHandler + ->expects( $this->once() ) + ->method( 'copyFromDFS' ) + ->with( $srcPath ) + ->will( $this->returnValue( true ) ); + + self::assertTrue( + $this->dispatcher->copyFromDFS( $srcPath ) + ); + } + + public function testCopyToDFS() + { + $srcPath = '/tmp/source_file'; + $dstPath = 'one://source_file'; + + $this->customHandler + ->expects( $this->once() ) + ->method( 'copyToDFS' ) + ->with( $srcPath, $dstPath ) + ->will( $this->returnValue( true ) ); + + self::assertTrue( + $this->dispatcher->copyToDFS( $srcPath, $dstPath ) + ); + } + + public function testDelete() + { + $path = 'one://file'; + + $this->customHandler + ->expects( $this->once() ) + ->method( 'delete' ) + ->with( $path ) + ->will( $this->returnValue( true ) ); + + self::assertTrue( + $this->dispatcher->delete( $path ) + ); + } + + public function testPassthrough() + { + $path = 'one://file'; + + $this->customHandler + ->expects( $this->once() ) + ->method( 'passthrough' ) + ->with( $path ) + ->will( $this->returnValue( true ) ); + + self::assertTrue( + $this->dispatcher->passthrough( $path ) + ); + } + + public function testGetContents() + { + $path = 'one://file'; + + $this->customHandler + ->expects( $this->once() ) + ->method( 'getContents' ) + ->with( $path ) + ->will( $this->returnValue( __FILE__ ) ); + + self::assertEquals( + __FILE__, + $this->dispatcher->getContents( $path ) + ); + } + + public function testCreateFileOnDFS() + { + $path = 'one://file'; + $contents = __FILE__; + + $this->customHandler + ->expects( $this->once() ) + ->method( 'createFileOnDFS' ) + ->with( $path, $contents ) + ->will( $this->returnValue( true ) ); + + self::assertTrue( + $this->dispatcher->createFileOnDFS( $path, $contents ) + ); + } + + public function testRenameOnDFSSameHandler() + { + $oldPath = "one://old_file"; + $newPath = "one://new_file"; + + $this->customHandler + ->expects( $this->once() ) + ->method( 'renameOnDFS' ) + ->with( $oldPath, $newPath ) + ->will( $this->returnValue( true ) ); + + $this->dispatcher->renameOnDFS( $oldPath, $newPath ); + } + + public function testRenameOnDFSDifferentHandler() + { + $oldPath = "one://old_file"; + $newPath = "two://new_file"; + $contents = __FILE__; + + $this->customHandler + ->expects( $this->once() ) + ->method( 'getContents' ) + ->with( $oldPath ) + ->will( $this->returnValue( $contents ) ); + + $this->defaultHandler + ->expects( $this->once() ) + ->method( 'createFileOnDFS' ) + ->with( $newPath, $contents ) + ->will( $this->returnValue( true ) ); + + $this->customHandler + ->expects( $this->once() ) + ->method( 'delete' ) + ->with( $oldPath ) + ->will( $this->returnValue( true ) ); + + $this->dispatcher->renameOnDFS( $oldPath, $newPath ); + } + + public function testGetDfsFileSize() + { + $path = 'one://file'; + $size = 12345; + + $this->customHandler + ->expects( $this->once() ) + ->method( 'getDfsFileSize' ) + ->with( $path ) + ->will( $this->returnValue( $size ) ); + + self::assertEquals( + $size, + $this->dispatcher->getDfsFileSize( $path ) + ); + } + + public function testExistsOnDFS() + { + $path = 'one://file'; + + $this->customHandler + ->expects( $this->once() ) + ->method( 'existsOnDFS' ) + ->with( $path ) + ->will( $this->returnValue( true ) ); + + self::assertTrue( + $this->dispatcher->existsOnDFS( $path ) + ); + } + + /** + * @return eZDFSFileHandlerDFSBackendInterface[]|PHPUnit_Framework_MockObject_MockObject + */ + private function getHandlerMock( $index ) + { + if ( isset( $this->fsHandlerMocks[$index] ) ) + { + return $this->fsHandlerMocks[$index]; + } + throw new OutOfBoundsException( "No handler at index #$index" ); + } +} diff --git a/tests/classes/ezdfsfilehandlerdfsregistry_test.php b/tests/classes/ezdfsfilehandlerdfsregistry_test.php new file mode 100644 index 0000000..dbbcc90 --- /dev/null +++ b/tests/classes/ezdfsfilehandlerdfsregistry_test.php @@ -0,0 +1,57 @@ +defaultHandler = $this->getMock( 'eZDFSFileHandlerDFSBackendInterface' ); + $this->customHandler1 = $this->getMock( 'eZDFSFileHandlerDFSBackendInterface' ); + $this->customHandler2 = $this->getMock( 'eZDFSFileHandlerDFSBackendInterface' ); + + $this->registry = new eZDFSFileHandlerDFSRegistry( + $this->defaultHandler, + array( + 'path/to/' => $this->customHandler1, + 'otherpath/to/' => $this->customHandler2 + ) + ); + } + + public function testGetHandlerOne() + { + self::assertSame( $this->customHandler1, $this->registry->getHandler( 'path/to/file' ) ); + } + + public function testGetHandlerTwo() + { + self::assertSame( $this->customHandler1, $this->registry->getHandler( 'otherpath/to/file' ) ); + } + + public function testGetDefaultHandler() + { + self::assertSame( $this->customHandler1, $this->registry->getHandler( 'differentpath/to/file' ) ); + } +}