3
\$\begingroup\$

I am just getting in to following design patterns.

I have create a class that allows for performing a couple operations on different remote storage systems: drive, dropbox, .etc (I call these drivers although there might be a better name). The main class accepts the driver whether it be one of the previous, and uses common methods to perform actions. All of the drivers conform to an interface.

Am I using the adapter pattern correctly? Are there any things I should take in to consideration to improve my code?

<?php

namespace RestorePoint\Adapters;

use RestorePoint\Adapters\RemoteFilesDriver\RemoteFilesDriverInterface;
use RestorePoint\Traits\FilesystemTrait;
use RestorePoint\Helpers\Benchmark;
use RestorePoint\Exception;
use Psr\Log\LoggerInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\Exception\InvalidArgumentException;
use Cake\Filesystem\File;

final class RemoteStorageAdapter {

    use FilesystemTrait;

    public $options;
    private $driver;
    private $driverClass;
    private $benchmark;
    private $log;

    public function __construct(RemoteFilesDriverInterface $driver, Benchmark $benchmark, LoggerInterface $log, array $options = []) {
        $this->benchmark = $benchmark;
        $this->driver = $driver;
        $this->log = $log;
        $resolver = new OptionsResolver();
        $resolver->setDefined(array_keys($options));
        $this->configureOptions($resolver);
        $this->options = $resolver->resolve($options);
        $this->driverClass = $this->driver->handle();
    }

    public function configureOptions(OptionsResolver $resolver) {
        $resolver->setDefaults([
            'uploadCheckQuota' => true,
            'cleanup' => true,
            'backupAmountRemote' => null,
            'uploadChunkSize' => 1024 * 1024 * 5,
            'uploadFileSizeLimit' => -1,
        ]);
        $resolver
                ->setRequired('backupFolderName')
                ->setAllowedTypes('backupAmountRemote', ['null', 'int'])
                ->setAllowedTypes('uploadCheckQuota', 'boolean')
                ->setAllowedTypes('cleanup', 'boolean')
                ->setAllowedTypes('backupFolderName', 'string')
                ->setAllowedTypes('uploadChunkSize', 'int');
        $resolver->setNormalizer('backupAmountRemote', function (Options $options, $value) {
            if ($options['cleanup'] == true && is_null($value)) {
                throw new InvalidArgumentException(__CLASS__ . ': When cleanup is true, backupAmountRemote cannot be null.');
            }
            return $value;
        });
    }

    /**
     * __call
     * 
     * @param string $method
     * @param array $arguments
     * @throws Exception
     * @return mixed
     */
    public function __call($method, $arguments) {
        if (method_exists($this->driver, $method)) {
            return call_user_func_array(array($this->driver, $method), $arguments);
        } else {
            throw new Exception("Method {$method} does not exist in {$this->driverClass}");
        }
    }

    /**
     * Get service folder
     * 
     * @return string
     */
    public function getFolder() {
        if (($folder = $this->driver->folderExists($this->options['backupFolderName'])) === FALSE) {
            $folder = $this->driver->makeFolder($this->options['backupFolderName']);
        }
        return $folder;
    }

    /**
     * Adds files available locally to the service
     * This WILL NOT delete old files on the server
     * 
     * @param string $localDir Full path to local files folder
     * @return boolean
     * @throws Exception
     */
    public function sync($localDir) {
        $folder = $this->getFolder();
        $localFiles = $this->listFilesByCreated($localDir);
        $remoteFiles = $this->driver->listFilesByCreated($folder);
        $localFilesCol = array_column($localFiles, 'name');
        foreach ($remoteFiles as $key => $value) {
            $index = array_search($value['name'], $localFilesCol);
            if ($index !== false) {
                unset($localFiles[$index]);
            }
        }
        $ct = count($localFiles);
        $this->log->debug("Sync determined {$ct} files should be added to {$this->driverClass}.");
        if ($ct == 0) {
            return false;
        }
        foreach ($localFiles as $file) {
            $this->log->debug($this->driverClass . ': Attempting to add ' . $file['name'] . '...');
            $this->uploadFile($localDir . $file['name']);
        }
        return $ct;
    }

    /**
     * Will remove old backup files on the server according to backupAmt
     * 
     * The oldest files are the ones furthest in the past that were uploaded to the server.
     * If you have backupAmt set to 1 and just added a backup to the remote folder from 2015,
     * while having a backup file from 2019 on the server, the file from 2015 will technically
     * be the newest file, and thus the one from 2019 will be deleted. 
     * 
     * This is due to APIs marking the time the file was created as the time they were uploaded,
     * despite that not being the time the file was actually generated.
     * 
     * Under normal circumstances, if your local and remote systems maintain sync, this point
     * is not of much concern. Just keep this in mind.
     * 
     * @return int Files removed
     * @throws Exception
     */
    public function cleanup() {
        $folder = $this->getFolder();
        $i = 0;
        if (is_null($this->options['backupAmountRemote'])) {
            $this->log->info("{$this->driverClass}: backupAmountRemote is null. Calling cleanup will have no effect.");
            return $i;
        }
        $files = $this->driver->listFilesByCreated($folder);
        $files = array_slice($files, $this->options['backupAmountRemote']);
        $ct = count($files);
        if ($ct > 0) {
            $this->log->debug("{$this->driverClass}: Cleanup determined {$ct} files should be removed remotely.");
            foreach ($files as $file) {
                $this->log->debug($this->driverClass . ': Attempting to delete ' . $file['name'] . '...');
                $this->driver->deleteFile($file['id']);
                $this->log->debug($this->driverClass . ': ' . $file['name'] . ' successfully deleted.');
                $i++;
            }
        }
        return $i;
    }

    /**
     * Upload file to service
     * 
     * @param string $file The file to upload
     * @return string Name and ext of file uploaded
     * @throws Exception
     */
    public function uploadFile($file) {
        ini_set('max_execution_time', -1);
        $folder = $this->getFolder();
        $file = new File($file);
        if ($this->driver->fileExists($file->name, $folder)) {
            throw new Exception("{$this->driverClass}: {$file->name} already exists.");
        }
        if ($this->options['uploadCheckQuota']) {
            $this->hasSpace($file->size());
        }
        if ($this->options['uploadFileSizeLimit'] > 0 && $file->size() > $this->options['uploadFileSizeLimit']) {
            throw new Exception("{$this->driverClass}: It is likely the script will take too long to complete. Backup file is too big at {$file->size()} bytes to be uploaded to the service.");
        }
        $this->benchmark->mark('uploadStart');
        $ret = $this->driver->addFile($file, $folder, $this->options['uploadChunkSize']);
        $elapsedTime = $this->benchmark->elapsedTime('uploadStart');
        $this->log->debug("{$this->driverClass}: File {$file->name} successfully added. Task took {$elapsedTime} seconds.");
        return $ret;
    }

    /**
     * Checks if a file can be added to service based on its size and the quota available
     * 
     * @param int $fileSize Bytes
     * @return void
     * @throws Exception
     */
    private function hasSpace($fileSize) {
        $spaceRemaining = $this->driver->getSpaceRemaining();
        if ($spaceRemaining === -1) {
            return; // -1 = driver doesn't support this command
        }
        if ($spaceRemaining - $fileSize <= 0) {
            throw new Exception("{$this->driverClass}: Not enough space for {$fileSize} bytes. Only {$spaceRemaining} bytes available.");
        }
    }

}

So as to avoid posting too much code, here is the interface all of the drivers follow:

<?php

namespace RestorePoint\Adapters\RemoteFilesDriver;

use RestorePoint\Adapters\Service\ServiceInterface;
use Cake\Filesystem\File;

interface RemoteFilesDriverInterface {

    public function __construct(ServiceInterface $service);

    /**
     * Should return a human name version of the driver
     * 
     * @return string
     */
    public function handle();

    /**
     * Simply checks if a file exists remotely
     * 
     * @param string $file
     * @param string $folder
     * @return boolean
     */
    public function fileExists($file, $folder);

    /**
     * Deletes a remote file
     * 
     * @param string $file
     * @throws Exception
     */
    public function deleteFile($file);

    /**
     * Check if a folder exists, if so, return ID or path
     * 
     * @param string $folder
     * @return mixed The ID or path of the folder
     */
    public function folderExists($folder);

    /**
     * Makes folder and returns folder ID or path
     * 
     * @param string $folder
     * @return mixed The ID or path of the folder
     */
    public function makeFolder($folder);

    /**
     * Gets free space - used space
     * 
     * @return int
     * @throws Exception
     */
    public function getSpaceRemaining();

    /**
     * Lists files in order from newest to oldest
     * Conforms file to an associative array with keys
     * name, id, created
     * 
     * Name: somefile.zip
     * Id: /path/to/file or 123123
     * Created: timestamp representation of date created
     * 
     * @param mixed $folder ID or path of folder
     * @return array
     * @throws Exception
     */
    public function listFilesByCreated($folder);

    /**
     * Uploads a file to remote service
     * 
     * @param File $file Instance of Cake\Filesystem\File
     * @param mixed $folder Path or ID of folder
     * @param int $chunkSizeBytes
     * @return string filename
     * @throws Exception
     */
    public function addFile(File $file, $folder, $chunkSizeBytes);

    /**
     * Not yet implemented
     * 
     * @param mixed $file
     * @param string $destination
     */
    public function download($file, $destination);
}

I would also argue, based on my understanding, that the drivers themselves are adapters for the service libraries they implement (Google Client Services, Dropbox API) to get a predefined output. But I am unsure of this.

update:

example driver: https://pastebin.com/9QEyHZ8f

\$\endgroup\$
  • \$\begingroup\$ ... or is it a strategy pattern?? \$\endgroup\$ – Alex Aug 2 at 9:56

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Browse other questions tagged or ask your own question.