Node JS Image Delivery Microservice Challenge [Refactoring #1]

Continuation from here: http://justcodesnippets.durlut.ro/index.php/2019/12/06/node-js-image-delivery-microservice-challenge-setup/

First of all we need to arrange the image information. So we create a ServedImage class (and adjacent classes) to store the image information.

export class ServedImage {
    public fullName: string;
    public fileName: string;
    public extension: string;
    public absolutePath: string;
    public directoryAbsolutePath: string;
    public existsOnFileSystem: boolean | null;
    public resolution: ServedImageResolution;
}

export class ResizedServedImage extends ServedImage {
    public originalImage: ServedImage;
}

export class ServedImageResolution {

    public static AreResolutionsEqual(resolutionA: ServedImageResolution, resolutionB: ServedImageResolution): boolean {
        return resolutionA.height === resolutionB.height && resolutionA.width === resolutionB.width;
    }
    public width: number;
    public height: number;

}

 

We need to validate the call received from the client. For this purpose we create the RequestImageValidatorService.

export class RequestImageValidatorService {

    public errors: string[];

    private imageExtensionRegExp: RegExp = RegExp(/\.(gif|jpg|jpeg|tiff|png)$/i); // https://stackoverflow.com/questions/10473185/regex-javascript-image-file-extension
    private imageResolutionRegExp: RegExp = RegExp(/(^([\d ]{2,5})[x]([\d ]{2,5})$)/i); // https://regex101.com/

    constructor(private imageName: string, private imageResolution: string) {
    }

    public validateImage(): boolean {
        let isOk: boolean = true;
        this.errors = new Array<string>();
        if (!this.imageExtensionRegExp.test(this.imageName)) {
            isOk = false;
            this.errors.push(`Image does not have correct extension(gif|jpg|jpeg|tiff|png)`);
        }
        if (!this.imageResolutionRegExp.test(this.imageResolution)) {
            isOk = false;
            this.errors.push(`Image does not have correct resolution format; example 300x400 or 600x1024`);
        }
        return isOk;
    }
}

 

We need a service to retrieve an existing image as a ServedImage object and also retrive a ResizedServedImage. For this we create ServedImageService.

We make the 2 methods getServedImage() and getResizedServedImage() async to enable file processing. We avoid resizing the same image twice by checking for file existence beforehand.

https://medium.com/@Abazhenov/using-async-await-in-express-with-node-8-b8af872c0016

import fs from "fs";
import path from "path";
import sharp, { Metadata } from "sharp";
import { ResizedServedImage } from "../models/resized-served-image";
import { ServedImage } from "../models/served-image";
import { ServedImageResolution } from "../models/served-image-resolution";

export class ServedImageService {

    // public servedImage: ServedImage;
    // public resizedServedImage: ResizedServedImage;
    private imagesFolder: string = path.join(  __dirname , `/../images/`);
    private imageResolutionHeightRegExp: RegExp = RegExp(/^([\d ]{2,4})/i); // https://regex101.com/
    private imageResolutionWidthRegExp: RegExp = RegExp(/([\d ]{2,4})$/i); // https://regex101.com/

    public async getServedImage(imageName: string): Promise<ServedImage> {

        console.log(`ServedImageService: processing image named ${imageName} in folder ${this.imagesFolder}`);

        const fileAbsolutePath: string = path.join( this.imagesFolder , imageName);
        const fileExists: boolean = fs.existsSync(fileAbsolutePath);
        const fileExtension: string = path.extname(fileAbsolutePath);
        const fileName: string = path.basename(fileAbsolutePath).replace(fileExtension, "");
        let height: number | null = null;
        let width: number | null = null;

        if (fileExists) {
            await sharp(fileAbsolutePath).metadata().then((metadata: Metadata) => {
                height = metadata.height;
                width = metadata.width;
            }).catch((err) => {
                console.log(err);
            });
        }

        // typescript field initializer (maintains "type" definition)
        const servedImage = Object.assign(new ServedImage(), {
            fullName: imageName,
            fileName,
            extension: fileExtension,
            absolutePath: fileAbsolutePath,
            directoryAbsolutePath: this.imagesFolder,
            existsOnFileSystem: fileExists,
            resolution: Object.assign(new ServedImageResolution(), {
                width,
                height
            })
        });

        return servedImage;

    }

    public async getResizedServedImage(servedImage: ServedImage, imageResolution: string): Promise<ResizedServedImage> {

        const resizedFileName = `${servedImage.fileName}_${imageResolution}${servedImage.extension}`;
        const resizedFileAbsolutePath = `${this.imagesFolder}${resizedFileName}`;
        const resizedImageResolution = this.getResolution(imageResolution);
        const areResolutionsEqual: boolean = ServedImageResolution.AreResolutionsEqual(resizedImageResolution, servedImage.resolution);
        if (areResolutionsEqual) {
            const originalServedImage = Object.assign(new ResizedServedImage(), {
                fullName: servedImage.fullName,
                fileName: servedImage.fileName,
                extension: servedImage.extension,
                absolutePath: servedImage.absolutePath,
                directoryAbsolutePath: servedImage.directoryAbsolutePath,
                existsOnFileSystem: servedImage.existsOnFileSystem,
                resolution: servedImage.resolution,
                isSameAsOriginalImage: true,
                originalImage: servedImage
            });
            return Promise.resolve<ResizedServedImage>(originalServedImage);
        } else {
            const resizedServedImage = Object.assign(new ResizedServedImage(), {
                fullName: resizedFileName,
                fileName: servedImage.fileName,
                extension: servedImage.extension,
                absolutePath: resizedFileAbsolutePath, // TODO: make path with other folder
                directoryAbsolutePath: this.imagesFolder, // TODO: make path with other folder
                existsOnFileSystem: null, // TODO: set below
                resolution: resizedImageResolution,
                isSameAsOriginalImage: false,
                originalImage: servedImage
            });
            const fileExists: boolean = fs.existsSync(resizedServedImage.absolutePath);
            if (!fileExists) {
                await sharp(servedImage.absolutePath).resize(resizedServedImage.resolution.width, resizedServedImage.resolution.height).toFile(resizedServedImage.absolutePath);
            }
            return Promise.resolve<ResizedServedImage>(resizedServedImage);
        }
    }

    private getResolution(imageResolution: string): ServedImageResolution {
        const height: number = parseInt(this.imageResolutionHeightRegExp.exec(imageResolution)[0], 10);
        const width: number = parseInt(this.imageResolutionWidthRegExp.exec(imageResolution)[0], 10);

        console.log(`height: ${height} | width: ${width}`);

        const servedImageResolution = Object.assign(new ServedImageResolution(), {
            width,
            height
        });
        return servedImageResolution;
    }
}

 

In order to serve images faster we try to use expressjs static middleware.

app.use(express.static("images"));

 

More info on expressjs middleware here: https://expressjs.com/en/guide/writing-middleware.html

More info on how to use static files cache https://expressjs.com/en/starter/static-files.html

The index.ts file looks a lot slimmer now.

import express from "express";
import { RequestImageValidatorService } from "./services/request-image-validator-service";
import { ServedImageService } from "./services/served-image-service";
const app = express();
const port = 8080; // default port to listen

app.use(express.static("images")); // https://expressjs.com/en/starter/static-files.html

// define a route handler for the default home page
app.get("/", (req, res) => {
    res.send("Hello world!");
});

// define a route handler for the image processing
app.get("/image/:imageName/:imageResolution", async (req, res, next) => {
    // res.send(`image named ${req.params.imageName} and resolution ${req.params.imageResolution}`);
    const imageName = req.params.imageName;
    const imageResolution = req.params.imageResolution;
    console.log(`requested image named ${imageName} and resolution ${imageResolution}`);
    const imageValidatorService: RequestImageValidatorService = new RequestImageValidatorService(imageName, imageResolution);
    if (!imageValidatorService.validateImage()) {
        res.status(404).send({ errors: imageValidatorService.errors });
    }

    const serverImageService: ServedImageService = new ServedImageService();
    const servedImage = await serverImageService.getServedImage(imageName); // https://medium.com/@Abazhenov/using-async-await-in-express-with-node-8-b8af872c0016
    if (servedImage.existsOnFileSystem) {// if image exists we can proceed to try and serve the resized image
        const resizedImage = await serverImageService.getResizedServedImage(servedImage, imageResolution);
        res.status(200).sendFile(resizedImage.absolutePath);
    } else {
        res.status(404).send({ error: `File ${imageName} does not exist on file system at ${servedImage.absolutePath}` });
    }

});

// start the Express server
app.listen(port, () => {
    // tslint:disable-next-line:no-console
    console.log(`server started at http://localhost:${port}`);
});

Leave a Reply

Your email address will not be published. Required fields are marked *