Why
MinnanoBeta 的 Restful API 是承襲 Paaaack 的架構用 Express.js 寫的,沒有任何的規範每個 endpoint 裡想怎麼寫就怎麼寫。
而在工作上因為有機會看過跟寫過 RoR、NestJS based API 的 code,一直都知道有更有架構、規範跟效率的 API 寫法,所以為了自己的職涯以及MinnanoBeta 的開發著想,就決定來把 Express.js migrate 成 NestJS 了!
How
みんなのBeta 的 API 中有一支 ListGyms 的 API 用來列出岩館的清單,我決定以 migrate 這支 API 的方式來當作實驗。
Before (Express.js):在 Express.js 底下寫的 API 就是這麼單純 😆
require('dotenv').config();
const { PrismaClient } = require('@prisma/client');
const express = require('express');
const cors = require('cors');
const bodyParser = require('body-parser');
const prisma = new PrismaClient();
const app = express();
app.use(cors());
app.use(bodyParser.json());
app.get('/climbing/gyms', async (req, res) => {
let gyms = [];
try {
gyms = await prisma.gyms.findMany({orderBy: [{ name: 'asc' }] });
} catch (err) {
console.log(err);
} finally {
res.json(gyms);
}
});
After (NestJS)
在 NestJS 裡要寫 API 的話最基本的應該是要 follow Module, Controller, Service 的架構,所以以 ListGyms API 來說,folder structure 大致如下:
src
| .env
| main.ts
| app.module.ts
| prisma
| prisma.service.ts
| gyms
| gyms.module.ts
| gyms.controller.ts
| gyms.service.ts
// main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(3001);
}
bootstrap();
// app.modules.ts
import { GymsModule } from '@/src/gyms/gyms.module';
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
@Module({
imports: [ConfigModule.forRoot(), GymsModule],
})
export class AppModule {}
// gyms.module.ts
import { Module } from '@nestjs/common';
import { GymsController } from './gyms.controller';
import { GymsService } from './gyms.service';
import { PrismaService } from '@/src/prisma/prisma.service';
@Module({
controllers: [GymsController],
providers: [GymsService, PrismaService],
})
export class GymsModule {}
// gyms.controller.ts
import { Controller, Get } from '@nestjs/common';
import { GymsService } from './gyms.service';
@Controller('climbing')
export class GymsController {
constructor(private readonly gymsService: GymsService) {}
@Get('gyms')
async listGyms() {
return this.gymsService.findAll();
}
}
// gyms.service.ts
import { Injectable } from '@nestjs/common';
import { PrismaService } from '@/src/prisma/prisma.service';
@Injectable()
export class GymsService {
constructor(private prisma: PrismaService) {}
async findAll() {
try {
return await this.prisma.gyms.findMany({ orderBy: [{ name: 'asc' }] });
} catch (err) {
return [];
}
}
}
// prisma.service.ts
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
}
- 在 Controller 裡定 API endpoint
- 在 Service 裡實作 API 的 business logic
- 再透過 Module 做 import / export
這是我大致上的理解。
做到這樣後就能在 local 測試 API 了。
Deployment
接著,在 deploy 的部分,Express.js 跟 NextJS 也有一點不一樣的地方是,NextJS 的 production 需要多一個 build 的步驟而 Express.js 不用,也因此相對應的就要做一點 Dockerfile 和 pm2 的 ecosystem.config.js 的設定調整。
// Dockerfile.prod
FROM node:20-slim
WORKDIR /app
COPY package.json pnpm-lock.yaml* ./
RUN npm install -g pnpm pm2
RUN apt-get update -y && apt-get install -y openssl
RUN pnpm install --frozen-lockfile
COPY . .
RUN npx prisma generate
RUN pnpm run build
EXPOSE 3001
CMD ["pm2-runtime", "start", "ecosystem.config.js", "--only", "api-nestjs-prod"]
// ecosystem.config.js
module.exports = {
apps: [
{
name: 'api-nestjs-prod',
script: 'dist/main.js',
watch: false,
env: {
NODE_ENV: 'production',
},
},
{
name: 'api-nestjs-dev',
script: 'pnpm',
args: 'run start:dev',
watch: ['src'],
ignore_watch: ['node_modules', 'dist'],
env: {
NODE_ENV: 'development',
},
},
],
};
最後
- 在 Linode 上把 NestJS based production API 跑起來
- 調整 Cloudflare 上的 DNS 多開一組 CNAME
api-v2.{domain-name}
- Nginx 新增 reverse proxy 把 api-v2 endpiont 和 API 連起來
Migration 完成!