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:
- Phân tích và Xây dựng User Story.
- Thiết kế cơ sở dữ liệu.
- Thiết kế REST API cho Service.
- Xây dựng REST API với Typescript.
- 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
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
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.
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.
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
{
"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 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
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
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
export class LocalDeleter {
async deleteImageById(filename: string) {
return true
}
cloudName() {
return 'local'
}
}
src/modules/images/infras/repository/delete/s3_deleter.ts
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
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
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
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
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
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
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
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
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
const ErrImageNotFound = new Error('Image Not Found')
export { ErrImageNotFound }
src/modules/images/model/image.ts
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
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
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
export enum ImageStatus {
UPLOADED = 'uploaded',
USING = 'using',
DELETED = 'deleted',
}
src/shared/error/index.ts
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
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é.
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.
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.
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).
- DELETE /v1/images/:id
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
Giới thiệu Kiến trúc Backend for Frontend (BFF)
Nov 16, 2024 • 10 min read
Flask là gì? Hướng dẫn tạo Ứng dụng Web với Flask
Nov 15, 2024 • 7 min read
Webhook là gì? So sánh Webhook và API
Nov 15, 2024 • 8 min read
Spring Boot là gì? Hướng dẫn Khởi tạo Project Spring Boot với Docker
Nov 14, 2024 • 6 min read
Two-Factor Authentication (2FA) là gì? Vì sao chỉ Mật khẩu thôi là chưa đủ?
Nov 13, 2024 • 7 min read
Test-Driven Development (TDD) là gì? Hướng dẫn thực hành TDD
Nov 13, 2024 • 6 min read