Handling images in a scalable, multi-platform service with high load

A Necessary function

Uploading images is a function necessary in many different systems, whether as avatars, pictures in the news or for a host of other reasons. For a simple site it’s easy to achieve: we upload a picture, it lies in a folder on the server, if necessary it can be resized and everything works. But what happens when we need to make a scalable service with high load?

Suddenly the job is more complicated and there are a whole collection of potential problems to be solved:
– where to store it
– how to save it if it’s needed in different size
– what to do if old pictures are needed in a new size
– what to do if the CDN network is used
– how to update a picture if it has been cached
– how to make it cheaply
– how we can solve problems be solved future
– how to add all these functions easily to a different system

Looking through this list of requirements, adding a picture suddenly doesn’t seem like such an easy task, but never fear, all is well.

How many pictures do we need?

As a rule, if we need pictures in different sizes, we divide those sizes by type. By way of example let’s suppose we want to place a picture in a news item, at first it seems we need only 2 sizes, a main image to go in the news item itself and an icon for the news list. For a simple website this would be enough, but if we need to make a mobile app as well, we need to generate a large number of copies of this picture: one for the site, several for the android app in different sizes (mdpi, hdpi, xhdp, xxhdpi, xxxhdpi), for iphone (both retina and non retina) and so on. Here we can see a clear structure:

  • Image
  • Main image
  • website
  • ios_retina
  • ios_regular
  • mdpi
  • hdpi
  • xhdpi
  • xxhdpi
  • xxxhdpi
  • List icon
  • website
  • ios_retina
  • ios_regular
  • mdpi

It looks fairly clear and structured, if we needed another type of picture it would be easy to add, by just describing the necessary size.
For saving we’ll use Amazon s3, where we immediately create 2 buckets for storing original pictures and resized pictures.
We will use the popular Symfony2 framework. So let’s start writing code.
First of all let’s describe the entity:

<?php
use DemoAppSomeBundleTraitsImage;
use….
class Article implements ImageUploadable{
// connect trait
use Image;
//the name of the picture's size
const IMAGE_MAIN = 'main';
const IMAGE_ICON = 'icon';
/**
 * @var SomeVar
 *
 * @ORMColumn()
 */
 private $title
/**
 * @var SomeVar
 *
 * @ORMColumn()
 */
private $text;
/**
 * @var SomeVar
 *
 * @ORMColumn()
 */
private $date;
// getters...
// setters...
// this function returns a list of picture types, their sizes and the method of resizing to a given size 
public function getImageSizes() {
return [
 self::IMAGE_ICON => [
 'sizes' => [
 self::IMAGE_SIZE_HDPI => [
 'w' => 80, 'h' => 80
 ],
 self::IMAGE_SIZE_XXDPI => [
 'w' => 165, 'h' => 165
 ],
 self::IMAGE_SIZE_XXXDPI => [
 'w' => 220, 'h' => 220
 ]
 ],
 'type' => ImagineImageImageInterface::THUMBNAIL_OUTBOUND,
 ],
 self::IMAGE_MAIN => [
 'sizes' => [
 self::IMAGE_SIZE_HDPI => [
 'w' => 400, 'h' => 200
 ],
 self::IMAGE_SIZE_XXDPI => [
 'w' => 650, 'h' => 325
 ],
 self::IMAGE_SIZE_XXXDPI => [
 'w' => 1000, 'h' => 500
 ]
 ],
 'type' => ImagineImageImageInterface::THUMBNAIL_OUTBOUND,
 ]
 ];
 }
}

Now, so as not to write the same code in several different locations we’ll describe the “trait” for the pictures:

trait Image {
private $imageBaseUrl;
public function getImageBaseUrl($return) {
 if (!$return || $this->checkIfImage()) {
 return;
 }
 return $this->imageBaseUrl;
}
public function setImageBaseUrl($imageBaseUrl) {
 $this->imageBaseUrl = $imageBaseUrl;
}
private function checkIfImage() {
 return $this->getImage() == null;
}
private function getClassName() {
static $class = null;
if ($class == null) {
 $class = strtolower(pathinfo(str_replace('\', '/', __CLASS__), PATHINFO_BASENAME)); 
 }
return $class;
}
public function getImageOriginalFileName() {
 return $this->getClassName() . '/' . $this->getId() . '/original';
}
public function getImageWithSize($type, $size, $url = true) {
 if ($url && $this->checkIfPhoto())
 return '';
 return $this->getImageBaseUrl($url) . $this->getClassName() . '/' . $this->getId() . '/' . $type . '/' . $size . '/' . $this->getImageVersion();
}
private $uploadedImage;
/**
* @return UploadedFile
*/
public function getUploadedImage() {
 return $this->uploadedImage;
}
/**
* @param $image
*/
public function setUploadedImage(UploadedFile $image) {
 $this->uploadedImage = $image;
}
/**
* @var DateTime
*
* @ORMColumn(name="image", type="string", nullable=true)
*/
private $picture;
/**
* @var integer
*
* @ORMColumn(name="imageVersion", type="integer", nullable=true)
*/
private $pictureVersion = 1;
public function getImage() {
 return empty($this->image) ? false : $this->image ;
}
/**
* Set picture
*
* @param string $image
*/
public function setImage($image) {
 $this->image = $image;
return $this;
}
public function getImagesList() {
 $result = [];
 foreach ($this->getImageSizes() as $typeName => $type) {
 foreach ($type['sizes'] as $sizeName => $size) {
 $result[$typeName][$sizeName] = $this->getImageWithSize($typeName, $sizeName);
 }
 }
 return $result;
}
private function getImageVersion() {
 return (int) $this->imageVersion;
}
public function setImageVersion($imageVersion) {
 $this->image = (int) $imageVersion;
}
public function increasePictureVersion() {
 $this->setImageVersion($this->getImageVersion() + 1);
}
}

This describes different auxiliary functions. Here 2 fields are described but now we’ll take a closer look at the important public methods:

getPictureOriginalFileName() – displays a link to the original image
getPictureWithSize() – displays a link to the picture with a particular type and size
getPicturesList() – displays a list of supported picture sizes
increasePictureVersion – increases the version of a picture

Let’s describe a picture interface:

interface ImageUploadable
{

const IMAGE_SIZE_XXXDPI = ‘xxxdpi’;
const IMAGE_SIZE_XXDPI = ‘xxdpi’;
const IMAGE_SIZE_HDPI = ‘hdpi’; public function getImageOriginalFileName(); public function getImageWithSize($type, $size, $url = true); public function getUploadedImage(); public function setUploadedImage(UploadedFile $iamge); public function getImage(); public function setImage($photo);

public function getImageBaseUrl($return);

public function setImageBaseUrl($url); public function getImageSizes();

public function increaseImageVersion();

public function setImageVersion($version);

Describe services:

app.subscriber.set_parameter:
 class: DemoAppSomeBundleListenerSetParameterSubscriber
 arguments:
 - %amazon_s3_base_url%
 tags:
 - { name: doctrine.event_subscriber }
 app.file_uploader:
 class: DemoAppSomeBundleServicesS3
 arguments:
 - @app.amazon_s3
 - %amazon_s3_base_url%
 - %amazon_s3_bucket_name%
 - @doctrine.orm.entity_manager

The subscriber who sets a pictures base url:

class SetParameterSubscriber implements EventSubscriber{

protected $s3BaseUrl; public function __construct($s3BaseUrl)
{
$this->s3BaseUrl = $s3BaseUrl;
} public function getSubscribedEvents()
{
return array(
Events::postLoad
);
} public function postLoad(LifecycleEventArgs $args)
{
$entity = $args->getEntity();
if ($entity instanceof ImageUploadable) {
$entity->setImageBaseUrl($this->s3BaseUrl);
}
}

}

The service which uploads pictures to S3:

class S3 {
private $s3;
 private $bucketName;
 private $baseUrl;
 private $em;
const BUCKET_ORIGINAL = 'original';
 const BUCKET_RESIZED = 'resized';
function __construct(S3Client $s3, $baseUrl, $bucketName, EntityManager $entityManager) {
 $this->em = $entityManager;
 $this->bucketName = $bucketName;
 $this->baseUrl = $baseUrl;
 $this->s3 = $s3;
 }
public function upload($path, $filename, $headers = []) {
$config = [
 'Bucket' => $this->bucketName . '-' . self::BUCKET_ORIGINAL,
 'Key' => $filename,
 'Body' => fopen($path, 'r'),
 'ACL' => 'public-read'
 ];
foreach ($headers as $key => $header) {
 $config[$key] = $header;
 }
$result = $this->s3->putObject($config);
return $result['ObjectURL'];
 }
public function delete($filename) {
 $this->s3->deleteObject([
 'Bucket' => $this->bucketName,
 'Key' => $filename,
 ]);
 }
public function uploadPhoto(PhotoUploadable $entity) {
 if ($entity->getUploadedPhoto()) {
 $file = $entity->getUploadedPhoto()->getRealPath();
 $finfo = new finfo(FILEINFO_MIME_TYPE);
 $mime = $finfo->file($file);

$result = $this->s3->putObject([
‘Bucket’ => $this->bucketName . ‘-‘ . self::BUCKET_ORIGINAL,
‘Key’ => $entity->getPhotoOriginalFileName(),
‘ContentType’=> $mime,
‘Body’ => fopen($file, ‘r’),
‘ACL’ => ‘public-read’
]); $entity->setImage($result[‘ObjectURL’]);
$entity->increaseImageVersion();// increase a pictures version
$this->em->flush();

//get all picture sizes, resize them and upload
foreach ($entity->getImageSizes() as $typeName => $type) {
foreach ($type[‘sizes’] as $sizeName => $size) {
$imagine = new Imagine();
$image = $imagine->open($file);
$tmpfname = tempnam(“/tmp”, “pict”);
$image->thumbnail(new Box($size[‘w’], $size[‘h’]), $type[‘type’])
->save($tmpfname);
$this->uploadResizedImage($tmpfname, $entity->getImageWithSize($typeName, $sizeName, false),$mime);
unlink($tmpfname);
}
}
}
} public function uploadResizedImage($file, $filename, $mime) {
$result = $this->s3->putObject([
‘Bucket’ => $this->bucketName . ‘-‘ . self::BUCKET_RESIZED,
‘Key’ => $filename,
‘ContentType’=> $mime,
‘CacheControl’ => ‘max-age=172800’,
‘Body’ => fopen($file, ‘r’),
‘ACL’ => ‘public-read’
]);

return $result[‘ObjectURL’];
} }

What does the above code do?
1. Checks that the picture was uploaded
2. Uploads the new picture in the bucket of original pictures structured “entity type/entity id”

blogimages2

3. Increases the version of the image
4. Uploads new versions of pictures with the structure:
entity type / entity id / image type / size name / image version

a.blogimages3

b.blogimages4

As a result, all pictures are resized and uploaded with a cache header, which will cache for long enough to reduce traffic. In the case of a picture being updated it will simply be re-uploaded with a new version name and the user downloads it again. There is no need to have an additional server for resizing a picture, we don’t need to resize everything straight away, but only when the user request (though it will take a bit longer for the user).

This code doesn’t solve the problem of adding new picture sizes though, that’s why we need to customize the web-hosting in S3:blogimages5

Customizing the redirect:

<RoutingRules>
 <RoutingRule>
 <Condition>
 <HttpErrorCodeReturnedEquals>404</HttpErrorCodeReturnedEquals>
 </Condition>
 <Redirect>
 <HostName>test.com</HostName>
 <ReplaceKeyPrefixWith>images/resize?image=</ReplaceKeyPrefixWith>
 <HttpRedirectCode>302</HttpRedirectCode>
 </Redirect>
 </RoutingRule>
 </RoutingRules>

Now if we add a new type of size and this size gets requested for use with old pictures, the user request will be forwarded to the url http://test.com/images/resize?image=classname/id/type/sizeNmae/version
From this url we can take all the information necessary for creating pictures.
Let’s describe the route for processing this request (which makes a picture and forwards the user to the new url):

 /**
 * @Route("/resize", name="site_resize")
 */
 public function indexAction(SymfonyComponentHttpFoundationRequest $request) {
$image = $request->get('image');
 $imageData = explode('/', $image);
if (count($imageData) == 5) {
 list($entityType, $id, $type, $sizeNmae, $version) = $imageData;
 $doctrine = $this->getDoctrine();
if ($entityType == “article”) {
 $entity = $doctrine->getRepository("DemoAppSomeBundle:Article")->find($id);
 }
 $newUrl = null;
if ($entity) {
 $sizes = $entity->getImageSizes();
if (array_key_exists($type, $sizes) && array_key_exists($sizeNmae, $sizes[$type]['sizes'])) {
 $uploader = $this->get('ekreative_food_hacker_core.file_uploader');
$originalTmpfname = tempnam("/tmp", "originalpict");
 try {
 $client = new GuzzleHttpClient();
 $client->get($entity->getPhoto(), [
 'save_to' => $originalTmpfname,
 ]);
 $finfo = new finfo(FILEINFO_MIME_TYPE);
 $mime = $finfo->file($originalTmpfname);
 $entity->setPhotoVersion($version);
$size = $sizes[$type]['sizes'][$sizeNmae];
$imagine = new ImagineImagickImagine();
 $image = $imagine->open($originalTmpfname);
 $tmpfname = tempnam("/tmp", "pict");
 $image->thumbnail(new ImagineImageBox($size['w'], $size['h']), $sizes[$type]['type'])
 ->save($tmpfname);
 $newUrl = $uploader->uploadResizedPhoto($tmpfname, $entity->getPhotoWithSize($type, $sizeNmae, false), $mime);
 unlink($tmpfname);
 unlink($originalTmpfname);
 return new SymfonyComponentHttpFoundationRedirectResponse($newUrl);
 } catch (Exception $e) {
 $this->get('logger')->error('No Image ' . $image . ' (' . $entity->getPhoto() . ')');
 }
 }
 }
 }
return new SymfonyComponentHttpKernelExceptionNotFoundHttpException();
 }

Now, after writing all of this code we can add picture support to a different entity with only 3 lines of code!

We need to add:
1. the interface “ImageUploadable”
2. Connect the “Image” trait
3. Describe “getImageSizes()”

And after creating or updating an entity, in controller we call:

// if new
$em->persist($entity);
$em->flush();
//if its existing entity
$this->get('app.file_uploader')->uploadImage(); after method

In future we can change the format for saving images, change the slider for resizing and add amazon CloudFront. Using this method is very simple and when we use getImagesList in the jsonSerialize function, we get a link to all sizes of the image, so the final application can use whatever picture size it needs.