How To Wrap League Flysystem with Dependency Injection
Asked Answered
P

3

6

The aim is to create a Reader class that is a wrapper on top of League Flysystem documentation

The Reader should provide convenient way of reading all files in a directory no matter the file physical form (local file, or a file in an archive)

Due to DI method a wrapper should not create instances of dependencies inside of it but rather take those dependencies as arguments into a constructor or other setter method.

Here is an example how to use League Flysystem on its own (without the mentioned wrapper) to read a regular file from a disk:

<?php
use League\Flysystem\Filesystem;
use League\Flysystem\Adapter\Local;

$adapter = new Local(__DIR__.'/path/to/root');
$filesystem = new Filesystem($adapter);
$content = $filesystem->read('path-to-file.txt');

As you can see firstly you create an adapter Local that requires path in its constructor then you create filesystem that requires instance of adapter in its constructor.

arguments for both: Filesystem and Local are not optional. They must be passed when creating objects from these classes. both classes also don't have any public setters for these arguments.

My question is how to write the Reader class that wraps Filesytem and Local by using Dependency Injection then?

I normally would do something similar to this:

<?php

use League\Flysystem\FilesystemInterface;
use League\Flysystem\AdapterInterface;

class Reader
{
    private $filesystem;
    private $adapter

    public function __construct(FilesystemInterface $filesystem, 
                                AdapterInterface $adapter)
    {
        $this->filesystem = $filesystem;
        $this->adapter = $adapter;
    }    

    public function readContents(string $pathToDirWithFiles)
    {
        /**
         * uses $this->filesystem and $this->adapter
         * 
         * finds all files in the dir tree
         * reads all files
         * and returns their content combined
         */
    }
}

// and class Reader usage
$reader = new Reader(new Filesytem, new Local);
$pathToDir = 'someDir/';
$contentsOfAllFiles = $reader->readContents($pathToDir);

//somwhere later in the code using the same reader object
$contentsOfAllFiles = $reader->readContents($differentPathToDir);

But this will not work because I need to pass a Local adapter to Filesystem constructor and in order to do that I need to pass to Local adapter path firstly which is completly against whole point of Reader's convinience of use that is just passing path to dir where all files are and the Reader does all what it needs to be done to provide content of these files with just a one method readContents().

So I'm stuck. Is it possible to acheive that Reader as a wrapper on the Filestem and its Local adapter?

I want to avoid tight coupling where I use keyword new and get dependecies' objects this way:

<?php
use League\Flysystem\Filesystem;
use League\Flysystem\Adapter\Local;

class Reader
{
    public function __construct()
    {
    }    

    public function readContents(string $pathToDirWithFiles)
    {

        $adapter = new Local($pathToDirWithFiles);
        $filesystem = new Filesystem($adapter);

        /**
         * do all dir listing..., content reading
         * and returning results.
         */
    }
}

Questions:

  1. Is there any way to write a wrapper that uses Filesystem and Local as dependencies in Dependency Injection fashion?

  2. Is there any other pattern than wrapper (adapter) that would help to build Reader class without tightly coupling to Filesystem and Local?

  3. Forgetting for a while about Reader class at all: If Filesystem requires Local instance in its constructor and Local requires string (path to dir) in its constructor, then is it possible to use these classes inside Dependency Injection Container (Symfony or Pimple) in reasonable way? DIC does not know what path arg pass to the Local adapter since the path will be evaluated somewhere later in the code.

Partner answered 14/3, 2019 at 13:43 Comment(2)
There is a contradiction between having Local as a dependency and wanting to read "[...] all files in a directory no matter the file physical form (local file, or a file in an archive)". Either you want Local as a dependency, so only Local files will be read, or you want to read files from whatever adapter source, and Local is not a dependency. Both are mutually exclusive. Can you clarify?Ironsides
@FélixGagnon-Grenier I want to read files from whatever adapter source. I used only Local as an example. The idea is to have one Reader object that once is set to work with a particular data sources (plain file, file in archive, file on remote via ftp) seeks all these sources for all the files and return them as combined content. Data sources will not change over the script execution but a passed path of a dir where to look for files at these data sources will change. This should not require initializing Reader again with all the data sources again but just use it with diff dir to seek in.Partner
P
2

You can use the Factory Pattern to generate a Filesystem on the fly, whenever your readContents method is called:

<?php

use League\Flysystem\FilesystemInterface;
use League\Flysystem\AdapterInterface;

class Reader
{
    private $factory;

    public function __construct(LocalFilesystemFactory $factory)
    {
        $this->filesystem = $factory;
    }    

    public function readContents(string $pathToDirWithFiles)
    {
        $filesystem = $this->factory->createWithPath($pathToDirWithFiles);

        /**
         * uses local $filesystem
         * 
         * finds all files in the dir tree
         * reads all files
         * and returns their content combined
         */
    }
}

Your factory is then responsible for creating the properly configured filesystem object:

<?php

use League\Flysystem\Filesystem;
use League\Flysystem\Adapter\Local as LocalAdapter;

class LocalFilesystemFactory {
    public function createWithPath(string $path) : Filesystem
    {
        return new Filesystem(new LocalAdapter($path));
    }
}

Finally, when you construct your Reader, it would look like this:

<?php

$reader = new Reader(new LocalFilesystemFactory);
$fooContents = $reader->readContents('/foo');
$barContents = $reader->readContents('/bar');

You delegate the work of creating the Filesystem to the factory, while still maintaining the goal of composition through dependency injection.

Photocomposition answered 22/3, 2019 at 0:2 Comment(1)
I think this answer is closest and simplest to ans to the question. I liked the simplicity of using Reader. However I think Reader should expect LocalFilesystemInterface with createWithPath() method implemented by LocalFilesystemFactory to be decoupled from a particular factory implementation. Otherwise you could just instantiate Local Adapter inside the Reader itself and have factory class less, while still having coupled Reader at the same level but to LocalAdapterPartner
S
1

1.You can use Filesystem and Local as dependencies in Dependency Injection fashion. You can create Adapter object and Filesystem object with a default path and pass them in Reader. In readContents method you can modify path with help setPathPrefix() method. For example:

class Reader
{
    private $filesystem;
    private $adapter;

    public function __construct(FilesystemInterface $filesystem, 
                                AdapterInterface $adapter)
    {
        $this->filesystem = $filesystem;
        $this->adapter = $adapter;
    }    

    public function readContents(string $pathToDirWithFiles)
    {
        $this->adapter->setPathPrefix($pathToDirWithFiles);
        // some code
    }
}

// usage
$adapter = new Local(__DIR__.'/path/to/root');
$filesystem = new Filesystem($adapter);
$reader = new Reader($filesystem, $adapter);

2.Reader is not the adapter pattern, because that it doesn't implement any interface from League Flysystem. It's the class for encapsulation of some logic to work with a filesystem. You can read more about the adapter pattern here. You should work with interfaces and avoid direct creation of objects in your class to reduce coupling between Reader and Filesystem.

3.Yes, you can set a default path to an adapter in DIC...

Springspringboard answered 18/3, 2019 at 8:41 Comment(1)
you're right regarding 2., the link was helpful as well. I've decided to use factory parttern, perhaps later in combination with facade.Partner
E
1

I hope I understand your question correctly. I just went through this a few weeks ago actually. To me this is some fun and interesting stuff.

Reading through this laravel snippet helped me understand how interfaces and dependency injection work so well. The article discusses contracts vs facades and why you might want to use one over the other.

It sounds like you want to be able to use one Filesystem instance that can read either remote files (S3, etc.) or local files. Since a file system can only be remote or local (not a combination) I think the correct thing would be to use an interface to interact with both the same way and then allow the user / developer to choose (through dependency injection preference) which file system (local or remote) should be used when they declare an instance of Filesystem.

// Classes used
use League\Container\Container;
use League\Container\ReflectionContainer;
use League\Flysystem\Adapter\Local;
use League\Flysystem\Filesystem;
use League\Flysystem\FilesystemInterface;
use League\Flysystem\AwsS3v3\AwsS3Adapter;

// Create your container
$container = new Container;

/**
 * Use a reflection container so devs don't have to add in every 
 * dependency and can autoload them. (Kinda out of scope of the question,
 * but still helpful IMO)
 */
$container->delegate((new ReflectionContainer)->cacheResolutions());

/**
 * Create available filesystems and adapters
 */ 
// Local
$localAdapter = new Local($cacheDir);
$localFilesystem = new Filesystem($localAdapter);
// Remote
$client = new S3Client($args); 
$s3Adapter = new AwsS3Adapter($client, 'bucket-name');
$remoteFilesystem = new Filesystem($s3Adapter);

/**
 * This next part is up to you, and many frameworks do this
 * in many different ways, but it almost always comes down 
 * to declaring a preference for a certain class, or better
 * yet, an interface. This example is overly simple.
 * 
 * Set the class in the container to have an instance of either
 * the remote or local filesystem.
*/
$container->add(
    FileSystemInterface::class,
    $userWantsRemoteFilesystem ? $remoteFilesystem : $localFilesystem
);

Magento 2 does this by compiling di.xml files and reading which classes you want to substitute by declaring a preference for another.

Symfony does this in a kinda similar fashion. They're docs were a little rough to understand for me, but after a few days of just scouring through them (along with the leagues), I finally came out the other side with a great understanding of what is going on.

Using your service:

Assuming you have dependency injection working in your application, and you want to hook up to your Filesystem with your reader class you would include your FilesystemInterface as a constructor dependency, and when it is injected it will use whatever you passed into the container via $container->add($class, $service)

use League\Flysystem\FilesystemInterface;

class Reader 
{
    protected $filesystem;

    public function __construct(FilesystemInterface $filesystem)
    {
        $this->filesystem = $filesystem;    
    }

    public function getFromLocation($location)
    {
        /**
         * We know this will work, because any instance that implements the
         * FilesystemInterface will have this read method.
         * @see https://github.com/thephpleague/flysystem/blob/dab4e7624efa543a943be978008f439c333f2249/src/FilesystemInterface.php#L27
         * 
         * So it doesn't matter if it is \League\Flysystem\Filesystem or 
         * a custom one someone else made, this will always work and 
         * will be served from whatever was declared in your container.
         */
        return $this->filesystem->read($location);
    }
}
Einhorn answered 21/3, 2019 at 23:40 Comment(3)
how about example where you use two adapters Local and ZipArchive to scan for files per one passed dir to readContents()? Is it possible with your solution to have one Reader->readContents('/bar') that returns files contents from '/bar' and if there are zips there it uses ZipArch adapter and returns all contents of files stored in zips as well?, And in next lines of code you do Reader->readContents('/foo') and it does the same for '/foo'?Partner
They're different functionality and there for should use different classes. IMO that doesn't follow the SRP. It also doesn't scale well. Unzipping all your files automatically would be a terrible thing to force your users into. If you did want to force them into it, you would just want one facade class that has a switch statement with all the options you can do. .zip, .txt, .php, etc. etc. But IMO that is a terrible idea.Einhorn
I'll stick to factory pattern, but thank you for mentioning facade with the link to some examples. I consider using both - factory and facade. Use facade for implementing ease of reading dir content with support of reading zip files in it as if they ware dirs and use factory to supply filesystem to facade. League has ability to read contents of files from zip as stream and it works quite fast. I was able to load data from csv, validate it, parse and insert into DB at a rate 350k rows per min. and consumed less than 8MB ram for it.Partner

© 2022 - 2024 — McMap. All rights reserved.