Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How To Wrap League Flysystem with Dependency Injection

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.

like image 578
Jimmix Avatar asked Mar 14 '19 13:03

Jimmix


3 Answers

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.

like image 84
samrap Avatar answered Nov 01 '22 17:11

samrap


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...

like image 21
Maksym Fedorov Avatar answered Nov 01 '22 15:11

Maksym Fedorov


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);
    }
}
like image 45
domdambrogia Avatar answered Nov 01 '22 17:11

domdambrogia