Facebook Pixel

Hướng dẫn Upload Images lên S3 sử dụng Typescript

27 Aug, 2024

Tran Thuy Vy

Frontend Developer

Hướng dẫn upload images lên S3 sử dụng Typescript với 5 bước: Phân tích và Xây dựng User Story, Thiết kế cơ sở dữ liệu, Thiết kế REST API, Xây dựng REST API với Typescript, Sử dụng Postman để test API

Hướng dẫn Upload Images lên S3 sử dụng Typescript

Mục Lục

Cũng tương tự như bài Typescript Tutorial mình viết trước đó, các bạn nên thực hiện theo các bước như sau:

  1. Phân tích và Xây dựng User Story.
  2. Thiết kế cơ sở dữ liệu.
  3. Thiết kế REST API cho Service.
  4. Xây dựng REST API với Typescript.
  5. Sử dụng Postman để test API.

Bài viết này mình sẽ focus vào phần code typescript để upload image lên s3, bỏ qua bước 1 (Phân tích và Xây dựng User Story) mà bắt đầu từ bước số 2 luôn. Nếu các bạn chưa tự tin, cảm thấy khó khăn khi học các kiến thức nâng cao, đừng lo lắng 200Lab sẽ đồng hành cùng với bạn tại khóa học Typescript.

1. Thiết kế cơ sở dữ liệu

Đối với "Images" thì mình sẽ cần lưu trữ các thông tin: path, cloud, width, height, size, status, nên phần cơ sở dữ liệu cho Database MySQL mình sẽ thiết kế như sau:

  • Id (Primary Key): định danh cho từng hình ảnh, mình sẽ sử dụng uuid v7 tương ứng với kiểu dữ liệu varchar.
  • Cloud: nơi lưu trữ hình ảnh (varchar).
  • Width, Height, Size: thông số của hình ảnh (number)
  • Status: trạng thái của hình ảnh, vì có 3 giá trị: uploaded, using, deleted nên mình để kiểu Enum, về sau dễ dàng mở rộng hơn.
  • Created At: thời gian hình ảnh được tạo trên hệ thống, đây là cột tuỳ chọn, mình thêm để tiện quản lý về sau.
  • Updated At: thời gian hình ảnh được update lần cuối trên hệ thống, cũng là cột tuỳ chọn.

Đây là phần code tạo bảng trong MySQL

Sql
CREATE TABLE `image` (
  `id` varchar(36) NOT NULL,
  `path` TEXT NOT NULL,
  `cloud_name` varchar(100) CHARACTER SET utf8 NOT NULL,
  `width` int NOT NULL,
  `height` int NOT NULL,
  `size` int NOT NULL,
  `status` enum('Uploaded','Using', 'Deleted') DEFAULT 'Uploaded',
  `createdAt` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `updatedAt` timestamp NOT NULL ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

Sau khi chạy lệnh bạn sẽ có được table như hình

upload image sql

2. Thiết kế REST API cho Service

Đây sẽ là bước cực kỳ quan trọng, bạn có thể tham khảo thêm về cách thiết kế REST API ở bài viết bên dưới.

REST API là gì? Cách thiết kế RESTful API bạn chưa biết
REST API là gì? Làm thế nào để thiết kế RESTful API hiệu quả? Cập nhật những thông tin mới nhất về REST API nhé!

Mình sẽ thiết kế REST API như sau:

  • POST /v1/images tạo mới image với dữ liệu cần là file . Thuộc tính status mình sẽ để mặc định là Uploaded. API này mình sẽ trả về ID của image sau khi upload thành công. Duy nhất một ràng buộc là cần phải upload ít nhất một file, không bỏ trống là ổn.
  • GET /v1/images/:id lấy toàn bộ thông tin chi tiết của một Image thông qua ID của nó.
  • DELETE /v1/images/:id xoá một Image thông qua ID của nó. Trong ví dụ này, mình sẽ xoá luôn trong table, và xoá luôn trên S3 nhé. Trong thực tế, các bạn không nên xoá mà chỉ chuyển đổi trạng thái thành deleted như phần thiết kế cơ sở dữ liệu ban nãy mình đã đề cập.

3. Xây dựng REST API với Typescript

Phía trên là tất cả phần chuẩn bị của mình, bây giờ thì vào code thôi.

Bash
npm init -y
npm install uuid @types/uuid sequelize dotenv @aws-sdk/client-s3 @aws-sdk/lib-storage image-size express typescript ts-node mysql2 @types/node @types/express module-alias @types/module-alias cors @types/cors multer @types/multer --save-dev
npx tsc --init

Đây là nội dung file tsconfig.json

Typescript
{
  "compilerOptions": {
    "target": "es2016",
    "module": "commonjs",
    "esModuleInterop": true,                             
    "forceConsistentCasingInFileNames": true,
    "strict": true,                        
    "skipLibCheck": true,
    "baseUrl": "./src",
    "paths": {
      "@/*":["*"],
    },  
  },
}

Bạn có thể dùng Docker để chạy container MySQL với câu lệnh:

Docker
docker run -d --name mysql -p 3306:3306 -e MYSQL_ROOT_PASSWORD=my-root-pass -e MYSQL_DATABASE=todo_db mysql:8.3.1
  • src/index.ts
Typescript
import 'module-alias/register';
import dotenv from 'dotenv';
import express, { type Express, type Request, type Response } from 'express';
import { sequelize } from './infras/sequelize';
import { initImages } from '@/modules/images/infras/repository/dto/image';
import { imageService } from '@/modules/images/module';

import cors from 'cors';

dotenv.config();

const app: Express = express();
const port = process.env.PORT || 8080;
app.use(cors());
(async () => {
  try {
    // check connection to database
    await sequelize.authenticate();
    console.log('Connection successfully.');
    initImages(sequelize);

    app.get('/', (req: Request, res: Response) => {
      res.send('200lab Server');
    });

    app.use(express.json());

    app.use('/v1', (req: Request, res: Response) =>
      imageService.setupRoutes(req, res)
    );

    app.listen(port, () => {
      console.log(`Server is running at http://localhost:${port}`);
    });
  } catch (error) {
    console.error('Unable to connect to the database:', error);
    process.exit(1);
  }
})();
  • src/infra/sequelize.ts
Typescript
import { Sequelize } from 'sequelize'
import { config } from 'dotenv'

config()

export const sequelize = new Sequelize({
  database: process.env.DB_NAME || '',
  username: process.env.DB_USERNAME || '',
  password: process.env.DB_PASSWORD || '',
  host: process.env.DB_HOST || '',
  port: parseInt(process.env.DB_PORT as string),
  dialect: process.env.DB_TYPE || 'mysql',
  pool: {
    max: 10,
    min: 0,
    acquire: 30000,
    idle: 10000
  },
  logging: false
})
  • src/modules/images/infras/repository/delete/local_deleter.ts
Typescript
export class LocalDeleter {
  async deleteImageById(filename: string) {
    return true
  }
  cloudName() {
    return 'local'
  }
}
  • src/modules/images/infras/repository/delete/s3_deleter.ts
Typescript
import { DeleteObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { IImageDeleter } from '@/modules/images/interfaces/usecase';
import { config } from 'dotenv';

config();

export class S3Deleter implements IImageDeleter {
  constructor() {}

  async deleteImage(filename: string): Promise<boolean> {
    const deleteParams = {
      Bucket: process.env.AWS_S3_BUCKET_NAME,
      Key: filename,
    };

    //delete file on s3
    try {
      s3.send(new DeleteObjectCommand(deleteParams));
    } catch (error) {
      console.error(error);
      return false;
    }

    return true;
  }

  cloudName(): string {
    return 'aws-s3';
  }
}

export const s3 = new S3Client({
  region: process.env.AWS_S3_REGION as string,
  credentials: {
    secretAccessKey: process.env.AWS_S3_SECRET_ACCESS_KEY as string,
    accessKeyId: process.env.AWS_S3_ACCESS_KEY_ID as string,
  },
});
  • src/modules/images/infras/repository/dto/image.ts
Typescript
import { DataTypes, Model, type Sequelize } from 'sequelize'
import { ImageStatus } from '@/shared/dto/status'

export class ImagePersistence extends Model {}

export function initImages(sequelize: Sequelize) {
  ImagePersistence.init(
    {
      id: {
        type: DataTypes.UUID,
        primaryKey: true,
        defaultValue: DataTypes.UUIDV4
      },

      path: {
        type: DataTypes.STRING,
        allowNull: false
      },

      cloudName: {
        type: DataTypes.STRING,
        allowNull: false,
        field: 'cloud_name'
      },

      width: {
        type: DataTypes.INTEGER,
        allowNull: true
      },

      height: {
        type: DataTypes.INTEGER,
        allowNull: true
      },

      size: {
        type: DataTypes.INTEGER,
        allowNull: false
      },

      status: {
        type: DataTypes.ENUM(ImageStatus.UPLOADED, ImageStatus.USING, ImageStatus.DELETED),
        allowNull: true
      }
    },
    {
      sequelize,
      modelName: 'Image',
      timestamps: true,
      tableName: 'images'
    }
  )
}
  • src/modules/images/infras/repository/uploader/local_uploader.ts
Typescript
import { IImageUploader } from '@/modules/images/interfaces/usecase';

export class LocalUploader implements IImageUploader {
  constructor() {}

  async uploadImage(
    filename: string,
    filesize: number,
    contentType: string
  ): Promise<boolean> {
    return true;
  }

  cloudName(): string {
    return 'local';
  }
}
  • src/modules/images/infras/repository/uploader/s3_uploader.ts
Typescript
import fs from 'fs';
import { config } from 'dotenv';
import { S3Client } from '@aws-sdk/client-s3';
import { Upload } from '@aws-sdk/lib-storage';
import { IImageUploader } from '@/modules/images/interfaces/usecase';

config();

export class S3Uploader implements IImageUploader {
  constructor() {}

  async uploadImage(
    filename: string,
    filesize: number,
    contentType: string
  ): Promise<boolean> {
    const parallelUploads3 = new Upload({
      client: s3,
      params: {
        Bucket: process.env.AWS_S3_BUCKET_NAME as string,
        Key: filename,
        Body: fs.readFileSync(filename),
        ContentType: contentType,
        ContentLength: filesize,
      },
      tags: [
        /*...*/
      ],
      queueSize: 4,
      partSize: 1024 * 1024 * 5,
      leavePartsOnError: false,
    });

    await parallelUploads3.done();
    return true;
  }

  cloudName(): string {
    return 'aws-s3';
  }
}

//set up new S3 client
export const s3 = new S3Client({
  region: process.env.AWS_S3_REGION as string,
  credentials: {
    secretAccessKey: process.env.AWS_S3_SECRET_ACCESS_KEY as string,
    accessKeyId: process.env.AWS_S3_ACCESS_KEY_ID as string,
  },
});
  • src/modules/images/infras/repository/mysql_image_repository.ts
Typescript
import { Sequelize } from 'sequelize';
import { IImageRepository } from '../../interfaces/repository';
import { Image } from '../../model/image';
import { ImagePersistence } from './dto/image';
import { ImageDetailDTO } from '../transport/dto/image_detail';

export class MySQLImagesRepository implements IImageRepository {
  constructor(readonly sequelize: Sequelize) {}

  async insertImage(data: Image): Promise<string> {
    try {
      const imageData = {
        id: data.id,
        path: data.path,
        cloudName: data.cloud_name,
        width: data.width,
        height: data.height,
        size: data.size,
      };

      const result = await ImagePersistence.create(imageData);

      return result.getDataValue('id');
    } catch (error: any) {
      throw new Error(`Error inserting image: ${error.message}`);
    }
  }

  async findById(id: string): Promise<ImageDetailDTO | null> {
    try {
      const image = await ImagePersistence.findByPk(id);

      return image ? image.get({ plain: true }) : null;
    } catch (error: any) {
      throw new Error(`Error finding image: ${error.message}`);
    }
  }

  async findByPath(path: string): Promise<ImageDetailDTO | null> {
    try {
      const image = await ImagePersistence.findOne({ where: { path } });

      return image ? image.get({ plain: true }) : null;
    } catch (error: any) {
      throw new Error(`Error finding image by path: ${error.message}`);
    }
  }

  async deleteImageById(id: string): Promise<boolean> {
    try {
      const image = await ImagePersistence.destroy({ where: { id } });
      return image ? true : false;
    } catch (error: any) {
      throw new Error(`Error deleting image: ${error.message}`);
    }
  }

  async updateStatus(id: string, status: string): Promise<boolean> {
    try {
      const image = await ImagePersistence.update(
        { status },
        { where: { id } }
      );
      return image ? true : false;
    } catch (error: any) {
      throw new Error(`Error updating image status: ${error.message}`);
    }
  }
}
  • src/modules/images/infras/transport/dto/image_detail.ts
Typescript
import { ImageStatus } from '@/shared/dto/status'

export class ImageDetailDTO {
  constructor(
    readonly id: string,
    readonly path: string,
    readonly cloudName: string,
    readonly width: number,
    readonly height: number,
    readonly size: number,
    readonly status: ImageStatus
  ) {}
}
  • src/modules/images/infras/transport/rest/routes.ts
Typescript
import { NextFunction, Router, type Request, type Response } from 'express';
import multer from 'multer';

import { IImageUseCase } from 'modules/images/interfaces/usecase';
import { ErrImageType } from 'shared/error';
import { ErrImageNotFound } from 'modules/images/model/image.error';
import { ensureDirectoryExistence } from 'shared/utils/fileUtils';

export class ImageService {
  constructor(readonly imageUseCase: IImageUseCase) {}

  async insert_image(req: Request, res: Response) {
    try {
      const file = req.file as Express.Multer.File;

      //check image type
      if (!file.mimetype.startsWith('image')) {
        res.status(400).send({ error: ErrImageType.message });
        return;
      }

      const imageId = await this.imageUseCase.uploadImage(
        file.destination + '/' + file.filename,
        file.size,
        file.mimetype
      );

      res.status(201).send({
        code: 201,
        message: imageId,
      });
    } catch (error: any) {
      res.status(400).send({ error: error.message });
    }
  }

  async detail_image(req: Request, res: Response) {
    try {
      const { id } = req.params;

      const image = await this.imageUseCase.detailImage(id);

      if (!image) {
        return res.status(404).json({ code: 404, message: ErrImageNotFound });
      }

      const fullPath = process.env.URL_PUBLIC + '/' + image.path;

      return res.status(200).json({
        code: 200,
        message: 'image',
        data: { ...image, path: fullPath },
      });
    } catch (error: any) {
      return res.status(400).json({ error: error.message });
    }
  }

  async delete_image(req: Request, res: Response) {
    try {
      const { id } = req.params;

      const image = await this.imageUseCase.detailImage(id);

      if (!image) {
        return res.status(404).json({ code: 404, message: ErrImageNotFound });
      }

      try {
        await this.imageUseCase.deleteImage(image.path);
        return res
          .status(200)
          .json({ code: 200, message: 'delete image successful' });
      } catch (error: any) {
        return res.status(400).json({ error: error.message });
      }
    } catch (error: any) {
      res.status(400).send({ error: error.message });
    }
  }

  async upload_images(req: Request, res: Response) {
    try {
      const files = req.files as Express.Multer.File[];

      const imageIds = await this.imageUseCase.uploadImages(files);

      res.status(201).send({
        code: 201,
        message: imageIds,
      });
    } catch (error: any) {
      res.status(400).send({ error: error.message });
    }
  }

  setupRoutes(req: Request, res: Response): Router {
    const router = Router();

    //multer
    const storage = multer.diskStorage({
      destination: function (req, file, cb) {
        const uploadPath = process.env.UPLOAD_PATH || 'uploads';
        ensureDirectoryExistence(uploadPath);
        cb(null, uploadPath);
      },
      filename: function (req, file, cb) {
        const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
        cb(null, uniqueSuffix + '-' + file.originalname);
      },
    });

    const upload = multer({
      storage: storage,
      fileFilter: (
        req: Request,
        file: Express.Multer.File,
        cb: multer.FileFilterCallback
      ) => {
        if (file.mimetype.startsWith('image/')) {
          cb(null, true);
        } else {
          cb(null, false);
        }
      },
    });

    router.post('/images', upload.single('image'), this.insert_image.bind(this));

    router.post(
      '/images/multi',
      upload.array('image', 5),
      this.upload_images.bind(this)
    );

    router.get('/images/:id', this.detail_image.bind(this));

    router.delete('/images/:id', this.delete_image.bind(this));

    return router;
  }
}
  • src/modules/images/interfaces/repository.ts
Typescript
import { ImageDetailDTO } from '../infras/transport/dto/image_detail';
import { Image } from '../model/image';

export interface IImageRepository {
  insertImage(image: Image): Promise<string>;

  findById(id: string): Promise<ImageDetailDTO | null>;

  findByPath(path: string): Promise<ImageDetailDTO | null>;

  deleteImageById(id: string): Promise<boolean>;

  updateStatus(id: string, status: string): Promise<boolean>;
}
  • src/modules/images/interfaces/usecase.ts
Typescript
import { ImageDetailDTO } from '../infras/transport/dto/image_detail';

export interface IImageUseCase {
  uploadImage(
    filename: string,
    filesize: number,
    contentType: string
  ): Promise<string>;

  uploadImages(files: Express.Multer.File[]): Promise<string[]>;

  detailImage(id: string): Promise<ImageDetailDTO | null>;

  deleteImage(filename: string): Promise<boolean>;

  changeStatus(id: string, status: string): Promise<boolean>;
}

export interface IImageUploader {
  uploadImage(
    filename: string,
    filesize: number,
    contentType: string
  ): Promise<boolean>;
  cloudName(): string;
}

export interface IImageDeleter {
  deleteImage(filename: string): Promise<boolean>;
  cloudName(): string;
}
  • src/modules/images/model/image.error.ts
Typescript
const ErrImageNotFound = new Error('Image Not Found')

export { ErrImageNotFound }
  • src/modules/images/model/image.ts
Typescript
import type { ImageStatus } from '@/shared/dto/status'

export class Image {
  constructor(
    readonly id: string,
    readonly path: string,
    readonly cloudName: string,
    readonly width: number,
    readonly height: number,
    readonly size: number,
    readonly status: ImageStatus
  ) {}
}
  • src/modules/images/usecase/image_usecase.ts
Typescript
import { v7 as uuidv7 } from 'uuid';
import {
  IImageDeleter,
  IImageUploader,
  IImageUseCase,
} from '../interfaces/usecase';
import { IImageRepository } from '../interfaces/repository';
import { ImageStatus } from 'shared/dto/status';
import { ImageDetailDTO } from '../infras/transport/dto/image_detail';
import { ErrImageNotFound } from '../model/image.error';
import sizeOf from 'image-size';
import fs from 'fs';

export class ImageUseCase implements IImageUseCase {
  constructor(
    readonly imageRepository: IImageRepository,
    readonly imageUploader: IImageUploader,
    readonly imageDeleter: IImageDeleter
  ) {}

  async uploadImage(
    filename: string,
    filesize: number,
    contentType: string
  ): Promise<string> {
    const dimensions = sizeOf(filename);

    this.imageUploader.uploadImage(filename, filesize, contentType);

    const imageId = uuidv7();

    const uploadImages = {
      id: imageId,
      path: filename,
      cloud: this.imageUploader.cloudName(),
      width: dimensions.width as number,
      height: dimensions.height as number,
      size: filesize,
      status: ImageStatus.UPLOADED,
    };

    await this.imageRepository.insertImage(uploadImages);

    if (this.imageUploader.cloudName() !== 'local') {
      //xóa file
      fs.unlink(filename, (err) => {
        if (err) {
          console.error(err);
          return;
        }
      });
    }

    return imageId;
  }

  async detailImage(id: string): Promise<ImageDetailDTO | null> {
    try {
      return await this.imageRepository.findById(id);
    } catch (error: any) {
      throw ErrImageNotFound;
    }
  }

  async deleteImage(filename: string): Promise<boolean> {
    try {
      const image = await this.imageRepository.findByPath(filename);
      if (!image) {
        throw ErrImageNotFound;
      }

      if (this.imageDeleter.cloudName() !== 'local') {
        this.imageDeleter.deleteImage(filename);
      } else {
        fs.unlink(filename, (err) => {
          if (err) {
            console.error(err);
            return false;
          }
        });
      }

      await this.imageRepository.deleteImageById(image.id);

      return true;
    } catch (error: any) {
      throw new Error(error.message);
    }
  }

  async changeStatus(id: string, status: string): Promise<boolean> {
    try {
      return await this.imageRepository.updateStatus(id, status);
    } catch (error: any) {
      throw new Error(error.message);
    }
  }

  async uploadImages(files: Express.Multer.File[]): Promise<string[]> {
    const uploadPromises = files.map((file) =>
      this.uploadImage(
        file.destination + '/' + file.filename,
        file.size,
        file.mimetype
      )
    );

    return Promise.all(uploadPromises);
  }
}
  • src/modules/images/module.ts
Typescript
import { sequelize } from 'infras/sequelize';
import { MySQLImagesRepository } from './infras/repository/mysql_image_repository';
import { ImageService } from './infras/transport/rest/routes';
import { ImageUseCase } from './usecase/image_usecase';
import { S3Uploader } from './infras/repository/uploader/s3_uploader';
import { S3Deleter } from './infras/repository/delete/s3_deleter';

export const imageService = new ImageService(
  new ImageUseCase(
    new MySQLImagesRepository(sequelize),
    new S3Uploader(),
    new S3Deleter()
  )
);
  • src/shared/dto/status.ts
Typescript
export enum ImageStatus {
  UPLOADED = 'uploaded',
  USING = 'using',
  DELETED = 'deleted',
}
  • src/shared/error/index.ts
Typescript
const ErrImageStatusPattern = new Error(
  'Image status must be UPLOADED, USING, DELETED'
);
const ErrImageType = new Error('Invalid image type');

const ErrSystem = new Error('System error');

const ErrCloudNameEmpty = new Error('Cloud name is required');

export { ErrImageStatusPattern, ErrImageType, ErrSystem, ErrCloudNameEmpty };
  • src/shared/utils/fileUtils.ts
Typescript
import fs from 'fs'

export const ensureDirectoryExistence = (dirPath: string): void => {
  if (!fs.existsSync(dirPath)) {
    fs.mkdirSync(dirPath, { recursive: true })
  }
}
  • .env: Bạn điền các trường tương ứng với hệ thống của mình nhé.
Env
DB_HOST=''
DB_USERNAME=''
DB_PASSWORD=''
DB_NAME=''
DB_TYPE=''
PORT=''
AWS_S3_ACCESS_KEY_ID=''
AWS_S3_SECRET_ACCESS_KEY=''
AWS_S3_REGION=''
AWS_S3_BUCKET_NAME=''
URL_PUBLIC=''
UPLOAD_PATH=''

4. Sử dụng Postman test API

  • POST /v1/images: Bạn chỉ upload mỗi lần 1 hình ảnh duy nhất.
api upload image s3

Sau khi upload image thành công thì database và trên s3 sẽ như hình bên dưới

  • POST /v1/images/multi: Cho phép bạn upload nhiều hình ảnh cùng một lúc.
Upload Images S3 Typescript
  • GET: /v1/images/:id: Lấy thông tin chi tiết hình ảnh (lúc này khi trả về bạn sẽ gắn URL_PUBLIC vào trước path hình ảnh để trở thành link image sử dụng trong dự án).
Get image detail
  • DELETE /v1/images/:id
delete image s3

5. Kết luận

Kết thúc bài viết này, hy vọng rằng bạn đã cảm thấy việc xây dựng REST API với upload image, storing in S3 bằng Typescript không còn khó khăn với bạn.

Một vài chủ đề khác, bạn có thể sẽ hứng thú tại 200Lab:

Bài viết liên quan

Lập trình backend expressjs

xây dựng hệ thống microservices
  • Kiến trúc Hexagonal và ứng dụngal font-
  • TypeScript: OOP và nguyên lý SOLIDal font-
  • Event-Driven Architecture, Queue & PubSubal font-
  • Basic scalable System Designal font-

Đăng ký nhận thông báo

Đừng bỏ lỡ những bài viết thú vị từ 200Lab