1
\$\begingroup\$

I've written an image processing library for downscaling, sharpening, compressing and caching images using PHP and ImageMagick.

Having recently been a chef in the kitchen of procedural and vague spaghetti code, I would appreciate feedback on:

  • Code quality and appropriateness
  • Code clarity
  • Whether the structure is suitably extensible
  • Comments

The library is called AIR (Aloof Image Resizer), as it's an internal project at a company called Aloof.

It is made to be used on the fly, by requesting a large image through the path to the script. The following would display /images/some-big-picture.jpg, resized to 100px wide, with a proportional height, sharpened and compressed:

<img src="/lib/air/w100/images/some-big-picture.jpg">

You can checkout the whole project at github, where there's also a formatted readme.

Air.php:

<?php

/* Aloof Image Resizer */
/* Requires ImageMagick */

require_once('config.php');

class Air {

    protected $path_to_air;

    protected $width = 0;
    protected $height = 0;
    protected $source_img;
    protected $source_img_sys;
    protected $sharpening;
    protected $quality;

    protected $imagick_instance;
    protected $cache_fullpath;
    protected $source_img_size;

    public function __construct() {
        if ( ! defined('__DIR__')) {
            define('__DIR__', dirname(__FILE__));
        }
        $this->path_to_air = __DIR__;
        $this->path_to_air_http = dirname($_SERVER['SCRIPT_NAME']);

        if( ! AirConfig::$air_enabled) {
            $this->set_options();
            $this->validate_img();
            $this->bypass();
        }

        if( ! class_exists('Imagick', false)) {
            $this->error('Imagick class not available on this environment. Either install ImageMagick or set AirConfig::$air_enabled to false in config.php.');
        }

        $this->cache_fullpath = $this->path_to_air . '/' . AirConfig::$cache_location;

        // Procedure
        $this->set_options();
        $this->validate_options();
        $this->validate_img();
        $this->check_bypass(); // may finish
        $this->get_or_generate_image(); // will finish
    }

    /**
     * Parses the URL to set variables.
     *
     * @return void
     */
    protected function set_options() {

        $options_url = $_GET['options'];
        $options_parts = explode('-', $options_url);
        $options = array();
        foreach($options_parts as $key => $value) {
            $options[$value[0]] = substr($value, 1);
        }

        if(isset($options['w'])) {
            $this->width = (int)$options['w'];
        }

        if(isset($options['h'])) {
            $this->height = (int)$options['h'];
        }

        $this->sharpening = AirConfig::$sharpening;
        if(isset($options['s'])) {
            $this->sharpening = (float)$options['s'];
        }

        $this->quality = AirConfig::$quality;
        if(isset($options['q'])) {

            // cap quality value at 100
            $this->quality = min( (int)$options['q'] , 100 );

        }

        // set file path
        if(isset($_GET['file'])) {
            $this->source_img = '/' . $_GET['file'];
            $this->source_img_sys = str_replace('//', '/', $_SERVER['DOCUMENT_ROOT'] . '/' . $_GET['file']);
        }
    }

    /**
     * Check that Air has enough options to work with - either width or height need to be specified as a minimun.
     * If validation fails, the script will terminate with an error message.
     *
     * @return void
     */
    protected function validate_options() {
        if($this->width === 0 && $this->height === 0) {
            $this->error('No size specified.');
        }
    }

    /**
     * Checks that Air can work with the requested image.
     *
     * @return void
     */
    protected function validate_img() {
        if(empty($this->source_img)) {
            $this->error('No source file specified.');
        }
        if( ! file_exists($this->source_img_sys)) {
            $this->error('Source file does not exist: ' . $this->source_img);
        }
        try {
            $this->source_img_size = getimagesize($this->source_img_sys);
        } catch(Exception $e) {
            $this->error('Invalid source file. ' . $e->getMessage);
        }
    }

    /**
     * If no resizing is necessary, the source image will be outputted and the script will terminate.
     *
     * @return void
     */
    protected function check_bypass() {
        if(
            ($this->width > 0 && $this->source_img_size[0] <= $this->width) ||
            ($this->height > 0 && $this->source_img_size[1] <= $this->height)
        ) {
            $this->bypass();
        }
    }

    /**
     * Output the source file without any processing.
     *
     * @return void
     */
    protected function bypass() {
        $this->output_image_file($this->source_img_sys, $this->source_img_size['mime']);
    }

    /**
     * If this image has been cached before with the same options, output it from cache and terminate the script.
     * If it doesn't exist in the cache, hand over to $this->generate.
     *
     * @return void
     */
    protected function get_or_generate_image() {
        if(file_exists($this->cache_fullpath . '/' . $this->get_cache_filename())) {
            $this->output_from_cache($this->get_cache_filename());
        } else {
            $this->generate();
        }
    }

    /**
     * The main image processing method:
     * - Resizes requested image to the requested dimensions,
     * - Applies sharpening if $this->sharpening is not 0,
     * - Saves the result in the cache,
     * - Outputs the image
     *
     * @return void
     */
    protected function generate() {
        $this->imagick_instance = new Imagick($this->source_img_sys);
        if($this->width || $this->height) {
            $this->imagick_instance->scaleImage($this->width, $this->height);
        }
        if($this->sharpening) {
            $this->imagick_instance->sharpenImage(0, $this->sharpening);
        }

        $image_format = $this->imagick_instance->getImageFormat();
        if($image_format === 'JPG' || $image_format === 'JPEG') {
            $this->imagick_instance->setImageCompression(Imagick::COMPRESSION_JPEG);
            $this->imagick_instance->setImageCompressionQuality($this->quality);
        }

        $this->cache_img();
        $this->output_from_imagick();
    }

    /**
     * Image names in the cache are an md5 hash of the source path and requested settings.
     *
     * @return  string  The cache filename for this request.
     */
    protected function get_cache_filename() {
        return md5($this->source_img . '-' . $this->width . '-' . $this->height . '-' . $this->sharpening . '-' . $this->quality);
    }

    /**
     * Writes result of ImageMagick conversion to the cache.
     *
     * @return void
     */
    protected function cache_img() {
        if( ! is_dir($this->cache_fullpath)) {
            mkdir($this->cache_fullpath, 0755);
        }

        $this->imagick_instance->writeImage($this->cache_fullpath . '/' . $this->get_cache_filename());
    }

    /**
     * Sets appropriate mime-type header, outputs result of ImageMagick conversion to the browser, and terminates the script.
     *
     * @return void
     */
    protected function output_from_imagick() {
        header('Content-Type: image/' . $this->imagick_instance->getImageFormat());
        echo $this->imagick_instance;
        die();
    }

    /**
     * Gets path and mime type for a cached file, and hands over to $this->output_image_file, where the script will be terminated.
     *
     * @param  string   $filename The filename of a file in the cache to output. Typically, this is the result of $this->get_cache_filename().
     * @return void
     */
    protected function output_from_cache($filename) {
        $cache_filepath = $this->cache_fullpath . '/' . $filename;
        $image_data = getimagesize($cache_filepath);

        $this->output_image_file($cache_filepath, $image_data['mime']);
    }

    /**
     * Outputs an image from a file and terminates the script.
     *
     * @param  string   $path The full system path to the file.
     * @param  string   $mime_type The mime type header for the file, e.g. 'image/jpg'
     * @return void
     */
    protected function output_image_file($path, $mime_type) {
        header('Content-Type: ' . $mime_type);
        echo readfile($path);
        die();
    }

    /**
     * Logs an error message to error.log, outputs the error image (depending on configuration) and terminates the script.
     *
     * @param  string   $message The error message.
     * @return void
     */
    protected function error($message = 'Unknown error') {
        error_log( date('Y-m-d h:i:s ') . '(' . $_GET['file'] . ') ' . $message . PHP_EOL , 3, 'error.log');

        if(AirConfig::$display_errors) {
            header("Cache-Control: no-cache, must-revalidate");
            header("Expires: Sat, 10 Aug 1989 23:30:00 GMT");
            header('Location: ' . $this->path_to_air_http . '/error.gif');
            exit;
        }
    }

}
?>

defaults.php:

<?php

class AirDefaults {

    // Do not edit this file to modify default values - override these default settings in config.php

    // If set to false, raw source files will be output without any processing.
    public static $air_enabled = true;

    // Relative to the air directory
    public static $cache_location = 'cache';

    // output error.gif to highlight errornous requests?
    public static $display_errors = true;

    // sharpening amount (sigma). Radius is calculated automatically.
    public static $sharpening = 0.6;

    // jpeg compression quality: 1-100.
    public static $quality = 85;

}

?>

config.php:

<?php

require_once('defaults.php');

class AirConfig extends AirDefaults {

    // paste and edit lines from defaults.php here to override settings

}

?>

index.php:

<?php
require_once('Air.php');
require_once('config.php');
$air = new Air();
?>

.htaccess:

# Prevent other scripts from interfering with AIR
php_value auto_prepend_file none
php_value auto_append_file none

# Pretty URLs
<IfModule mod_rewrite.c>
    RewriteEngine On
    RewriteRule ([0-9a-z\-\.]+)/(.*) index.php?options=$1&file=$2 [L]
</IfModule>
\$\endgroup\$
2
\$\begingroup\$

Ok, on first glance there are a couple of problems that need fixing. My review might seem harsh, but remember: my only motive is to help.

It's a bit of a hangup of mine, but please subscribe to the standards when it comes to coding style. Method names are camelCased, the opening curly's for class definitions, and methods go on the next line etc...
Also: A PHP-only script should not have a closing ?> tag, as the official documentation explains.

But on to the code: Let's look at the first bit of your (wa-ay to long constructor):

public function __construct()
{//next line
    if ( ! defined('__DIR__')) {
        define('__DIR__', dirname(__FILE__));
    }

Ok, you check to see if the __DIR__ magic constant is available (was added in PHP 5.3). By defining the constant here, you're effectively providing backwards compatibility. But let's be honest: providing backwards compatibility is a pain, and requiring PHP5.3>= isn't unreasonable, or far fetched.
Even still, I wouldn't have a class constructor define global constants, in case your project grows and starts using namespaces, at which point PHP5.3 will be a given anyway. Be that as it may, I'd simply use either the magic constant or the dirname call and assign it directly to the property:

    //$this->path_to_air = __DIR__;
    $this->pathToAir = defined('__DIR__') ? __DIR__ : dirname(__FILE__);

Next, I notice you're using super-globals inside a constructor. But a class, and the whole point of OOP, is that it is a unit of code that can be used over and over, irrespective of the environment.
At the same time, you're using AirConfig. Why not simply use that AirConfig object (and don't use statics, just pass an instance of AirConfig to the constructor!). A class should not rely on state/values of objects outside of itself or what is passed to it.

    //$this->path_to_air_http = dirname($_SERVER['SCRIPT_NAME']);
    //$this->pathToAirHttp = AirConfig::$ScriptName;//better
    $this->pathToAirHttp = $airConfigArgument->getScriptName();//preferred

Then, you set about calling 3 methods from inside a constructor, if the AirConfig::$air_enabled is set to false. But Why? Setting options (as is what set_options does) is something that is best left to the user of this class. As a user, I'd like to be able to decide when a class does what. And I want to set the options myself, and I'd like to be able to change the set options every now and again.

    /*if( ! AirConfig::$air_enabled) {
        $this->set_options();
        $this->validate_img();
        $this->bypass();
    }*/
    //Just scratch this, don't replace it.

Now, after doing all of this, you check what this class is all about: you need the PECL Imagick extension to be available. Why not check that from the off? Don't do anything until you know, for a fact, that all the dependencies are available to you.
If this Imagick class is not available, your class shouldn't merely set the $this->error property, hoping the user actually checks this propery. If the dependencies aren't there, your class should ring the alarm. Loudly. Throw an exception, forcing the user to fix the problem. If not, your code will cause problems.

    if( ! class_exists('Imagick', false)) {
        //$this->error('Imagick class not available on this environment. Either install ImageMagick or set AirConfig::$air_enabled to false in config.php.');
        //THROW AN EXCEPTION!
        throw new RuntimeException(
            'Imagick class not available on this environment. Either install ImageMagick or set AirConfig::$air_enabled to false in config.php.'
        );
    }

You then procede to call a bunch of methods which, again, I as a user would like to call myself. When I need them, where I need them.
The last method you call get_or_generate_image, can call generate, which in turn might use the non-existing Imagick extension. If that ever occurs, you're in trouble.

However, I didn't look into your class that much, and probably you've tested your code on a setup that doesn't include the Imagick extension.

I'll just add one must-fix critique for now, and I'll come back to this code if I have more time: your output_* methods.
I've often stated, and I'll keep repeating this:

A class, and its methods return values! They should never ever echo!
Methods should never, ever control the flow of an application (die or exit), as it is not aware of the application logic outside of itself!
A class should not attempt headers. The application decides when something is sent to the client, and under what header. Classes should return data, and leave it to the application to decide how and when to send it to the client.

In short, the output methods should simply return what they echo, and they certainly shouldn't call die! Personally, I'd rather see die removed from the language, or at least restrict its usage to those cases where a problem occurs

\$\endgroup\$
  • \$\begingroup\$ Thank you so much for your constructive and helpful comments. I've actioned most of your suggestions. However I'll keep the airEnabled check near the beginning, as some front end devs here may inherit this code and don't have ImageMagick, and need to disable the script whilst still seeing the original images. Also, the error method I have kept, as it writes to an error log and displays an error image too. I think that's better than a fatal exception, which wouldn't show up in pages when the images are requested through an img tag rather than directly. \$\endgroup\$ – Josh Harrison Jun 12 '14 at 15:54
  • \$\begingroup\$ @ZougenMoriver: You're welcome. Keeping the error method, and airEnabled is perfectly fine. However, the SOLID principles (especially the Single Responsibility Principle) dictates that this class should throw an exception of a particular (self-made) class, which is handled by a custom handler, that would handle the exception by logging the error, and generating the error image. That way, this class' only job is to create images - ie it has only 1 responsibility. I'll have a look at the github repo and expand on this a little in the days to come, though \$\endgroup\$ – Elias Van Ootegem Jun 13 '14 at 7:00
  • \$\begingroup\$ @ZougenMoriver: PS -> use type-hints. I've noticed your constructor now expects an instance of AirConfig. Great. But perhaps consider changing the signature to better reflect this: public function __construct( AirConfig $airConf). That way, the code will fail if the user passes anything else than an instance of AirConfig to the constructor, forcing the users' code to address the issue ASAP \$\endgroup\$ – Elias Van Ootegem Jun 13 '14 at 7:04
  • \$\begingroup\$ Thanks again for the help. I look forward to hearing more about correct error handing, but a quick glance at the SOLID principles seems way over my experience level at this point in time! :) \$\endgroup\$ – Josh Harrison Jun 26 '14 at 10:02
  • \$\begingroup\$ @ZougenMoriver: Don't focus on the CS-jargon. The SOLID principles are plain and simple common-sense guidelines. A class is one block of code, it does one job: Single Responsability. A class, because it has 1 job, can be extended to do the same job in a specific context/case/project, but it should not be extendable in a way that it can be used for another type of task. Substitution: if a base class expects an argument of a certain type, that guarantees that all code in the base class can deal with that type. Child classes aren't allowed to modify this behaviour (contract/interface) \$\endgroup\$ – Elias Van Ootegem Jun 26 '14 at 10:32

Your Answer

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

Not the answer you're looking for? Browse other questions tagged or ask your own question.