项目写完了,本地跑没啥问题,最终是要把它部署到服务器上,跑起来。
会用到 mysql、redis、11-Nginx基础概述,它们是这样的关系:
前端代码由 11-Nginx基础概述 托管,处理静态请求。
并且后端服务也会经过 11-Nginx基础概述 做反向代理,这样可以做多个 Nest 服务的负载均衡。
Nginx、Mysql、Redis、Nest 服务等都是通过 docker 来跑。
当然,不用一个个的跑,可以通过 Docker Compose 把它们组合成一个整体,一起跑。
开发完之后,本地把代码 push 到 github、gitlab 等代码仓库。
之后服务器上把代码 pull 下来,然后用 docker compose 跑就行。
有了 docker,根本不用考虑 mysql、redis、node 等在 linux 下怎么装,直接跑 docker 镜像就行。
是不是再次感受到了 docker 的好处?
此外,还有一点要注意,线上不能开启 typeorm 的 synchronize,这样代码一改就同步修改了表结构,容易导致数据丢失。
线上会用 migration 迁移来做。
详细的 migration 介绍看这节。
一步步来做,首先,在本地用 docker compose 跑一下:
在后端项目的根目录添加 docker-compose.yml 文件:
services:
nest-app:
build:
context: ./
dockerfile: ./Dockerfile
depends_on:
- mysql-container
- redis-container
ports:
- 3005:3005
networks:
- common-network
mysql-container:
image: mysql
volumes:
- /Users/guang/mysql-data:/var/lib/mysql
environment:
MYSQL_DATABASE: meeting_room_booking_system
MYSQL_ROOT_PASSWORD: guang
networks:
- common-network
redis-container:
image: redis
volumes:
- /Users/guang/redis-data:/data
networks:
- common-network
networks:
common-network:
driver: bridge有 3 个 docker 容器,nest-app、mysql-container、redis-container
nest-app 是通过 Dockerfile 构建出来的,暴露 3005 端口。
nest-app 依赖 redis-container 和 mysql-container
mysql-container 指定挂载的 volume 数据卷,以及 MYSQL_DATABASE(自动创建的 database) 和 MYSQL_ROOT_PASSWORD(root 用户密码) 的环境变量
redis-container 指定挂载的 volume 数据卷
然后这是三个容器放到同一个桥接网络里。
在根目录添加 Dockerfile:
FROM node:22-alpine3.20 as build-stage
WORKDIR /app
COPY package.json .
RUN npm config set registry https://registry.npmmirror.com/
RUN npm install
COPY . .
RUN npm run build
# production stage
FROM node:22-alpine3.20 as production-stage
COPY --from=build-stage /app/dist /app
COPY --from=build-stage /app/package.json /app/package.json
WORKDIR /app
RUN npm config set registry https://registry.npmmirror.com/
RUN npm install --omit=dev
EXPOSE 3005
CMD ["node", "/app/main.js"]Nest 如何写 Dockerfile 前面也讲过。
就是用多阶段构建,第一个镜像先复制 package.json,然后安装依赖。
之后执行 npm run build 生成 dist 目录。
接下来第二个镜像复制第一个镜像的 dist 目录和 package.json,然后执行 npm install 并且用 node 跑起来。
改下 .env 文件:
把 localhost 改为 redis-container 和 mysql-container。
还要改一个地方:
加载配置文件的目录要改成拼接 __dirname 和 .env 的路径。
ConfigModule.forRoot({
isGlobal: true,
envFilePath: path.join(__dirname, '.env')
}),因为 build 出来的代码没有 src 目录,是直接放在 dist 下的:
加一下 .dockerignore,也就是不会被复制到容器内的文件
node_modules/
.vscode/
.git/接下来把它跑起来(最好把本地的 mysql 和 redis 容器停掉再跑):
docker compose up它会首先根据 Dockerfile 生成 nest-app 的镜像:
然后分别跑 mysql-container 和 redis-container 容器:
最后可以看到 nest-app 也跑起来了:
访问下接口:
可以看到,接口也是没问题的。
如果你改了 Dockerfile 或者 docker-compose.yml 想重新跑的话,需要先把之前的镜像和容器删掉再跑:
docker compose down --rmi all接下来把本地代码 push 到 gitlab 或者 github:
然后在服务器上把它拉下来跑下 docker compose up 就行了。
买个服务器:
我选的是 1 核、1G 的,买了一周,大家可以根据自己的需要买。
注意要勾选公网 ipv4 地址,不然访问不了公网。
之后进入控制台就可以看到这台服务器了:
点击远程连接:
点立即登录。
用 ssh 密钥的方式登录,这里需要一个密钥文件:
在这里创建:
创建完会自动下载密钥文件:
然后点击绑定密钥对,也就是用这个密钥来登录的服务器:
这一步会重启服务器。
然后再次远程连接,就可以用这个密钥文件来登录了:
需要安装 git 和 docker,用 git 下载代码,然后用 docker compose 来跑代码。
安装 git:
yum install git -y把 github 的代码下下来:
git clone 你自己的git仓库进入项目目录,跑下 docker compose 就行:
和本地跑一样。
安装下 docker:
怎么安装直接看阿里云的文档就行。
docker 安装好了,设置下开机启动,也是复制文档的命令就行:
然后安装 docker compose,同样是直接复制:
docker compose 安装好了,就把服务跑起来:
docker compose up会先 pull mysql 和 redis 的镜像,然后 build nest 的镜像。
之后就把服务跑起来了:
过程中可以看到,会自动创建一系列的 table:
因为现在还没关掉 synchronize。
因为服务跑在 3005 端口,需要在安全组添加一下这个端口:
然后就可以用公网 ip 访问了:
当然现在的初始化数据还没加,并且创建表也不是用的 migration,用 synchronize 不够安全,这些下节再继续搞。
这节完成了阿里云的部署。
写了 docker-compose.yml 和 Nest 应用的 Dockerfile,在本地用 docker compose 跑没问题。
然后服务器上也是用同样的方式跑。
买了一台阿里云服务器,安装 git 来下载项目代码,然后安装 docker compose 把服务跑起来就行了。
部署成功之后,我们的服务就可以在世界各地访问了。
上节把后端项目部署到了阿里云,可以在任意电脑上访问。
这节来部署下前端项目。
项目跑起来是这样的架构:
在之前 docker compose 的基础上加上 11-Nginx基础概述 容器就好了。
进入 frontend-admin 项目,加一下 11-Nginx基础概述 配置文件:
upstream nest-server {
server 192.168.31.56:3005;
}
server {
listen 80;
listen [::]:80;
server_name localhost;
location ^~ /api {
rewrite ^/api/(.*)$ /$1 break;
proxy_pass http://nest-server;
}
location / {
root /usr/share/11-Nginx基础概述/html;
index index.html index.htm;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/11-Nginx基础概述/html;
}
}11-Nginx基础概述 的两个核心功能就是静态资源托管、反向代理。
配置了 /api 下的请求走反向代理,转发请求到 nest 服务。
/ 下的静态资源请求返回 index.html。
这里的 ip 是我宿主机的,你可以换成你本地的。
用 11-Nginx基础概述 做了反向代理之后,访问的 url 要改一下:
不再是直接访问 nest 服务了,而是通过 11-Nginx基础概述 反向代理到 nest 服务。
然后加一下 Dockerfile
# build stage
FROM node:22 as build-stage
WORKDIR /app
COPY package.json ./
RUN npm config set registry https://registry.npmmirror.com/
RUN npm install
COPY . .
RUN npm run build
# production stage
FROM 11-Nginx基础概述:stable as production-stage
COPY --from=build-stage /app/dist /usr/share/11-Nginx基础概述/html
COPY --from=build-stage /app/11-Nginx基础概述.conf /etc/11-Nginx基础概述/conf.d/default.conf
EXPOSE 80
CMD ["11-Nginx基础概述", "-g", "daemon off;"]用多阶段构建,第一个阶段把代码复制到容器,执行 npm run build,第二个阶段把上个阶段的产物还有 11-Nginx基础概述 配置文件复制过来,把 11-Nginx基础概述 服务跑起来。
这里的 CMD 启动命令看别的 11-Nginx基础概述 镜像的启动命令就行:
加一下 .dockerignore
node_modules/
.vscode/
.git/
dist/然后 build 下镜像:
docker build -t fe-container:first .然后在 docker desktop 里搜索这个镜像,点击 run:
进入 backend 项目,把服务跑起来:
npm run start:dev浏览器访问下:
界面正常渲染,访问接口的 url 也换成了 11-Nginx基础概述 的:
接口也正常返回了数据。
说明 11-Nginx基础概述 的反向代理和静态资源托管都成功了。
但是,当你切换到修改信息界面,会跳到 /login 的 url,这时候返回了 404:
因为用的是 browser 路由,也就是 /xxx 的方式,而不是 hash 路由,也就是 ?#/xxx 的方式。
需要在 11-Nginx基础概述 里面支持下:
location / {
root /usr/share/11-Nginx基础概述/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}加上这条 try_files,当访问 /login 的时候会先匹配 /login 然后是 /login/ 然后是 /index.html
这样就交给了前端页面来处理 /login 路由。
重新 build 一下:
把之前的 container 停止、删除,然后重新跑。
现在你就会发现所有的路由都能正常访问了:
当然,现在是单独跑的 11-Nginx基础概述 的容器,而且反向代理 nest 服务时用的是 ip。
这样肯定是不好的。
希望可以把它也放到 docker-compose.yml 的配置文件里。
直接 docker compose up 一起跑。
知道,docker compose 跑的多个容器之间可以通过容器名相互访问。
改一下 11-Nginx基础概述 配置,把 ip 换成 nest 服务的容器名:
重新 build:
然后在 backend 项目的 docker-compose.yml 里配置下:
services:
fe-app:
image: fe-container:first
ports:
- 80:80
depends_on:
- nest-app
networks:
- common-network
nest-app:
build:
context: ./
dockerfile: ./Dockerfile
depends_on:
- mysql-container
- redis-container
networks:
- common-network
mysql-container:
image: mysql
volumes:
- /Users/guang/mysql-data:/var/lib/mysql
environment:
MYSQL_DATABASE: meeting_room_booking_system
MYSQL_ROOT_PASSWORD: guang
networks:
- common-network
redis-container:
image: redis
volumes:
- /Users/guang/redis-data:/data
networks:
- common-network
networks:
common-network:
driver: bridge把 env 里的 url 改回来:
然后跑一下(最好把本地的其他 mysql 和 redis 容器停掉再跑):
docker compose up跑起来之后,浏览器访问下:
这样,就通过 docker compose 一次性跑了 nest、11-Nginx基础概述 还有 mysql 和 redis 服务。
但是 fe-container 这个镜像只存在于本地,在阿里云跑 docker compose 的话会找不到这个镜像。
所以需要这个镜像上传到阿里云的镜像仓库。
当然,这里直接在服务器上下载代码然后 build 镜像也可以,这里只是为了用一下阿里云的镜像仓库。
阿里云的容器镜像服务个人用是免费的:
进入管理控制台:
点击创建镜像仓库。
它会让你先创建命名空间。
直接说明了如何登陆阿里云镜像仓库和 push 镜像上去:
在本地 build 下镜像。
复制下你买的服务器的公网 ip:
改一下项目里的 baseURL,改成服务器的 ip:
然后 build 出镜像:
docker build -t fe-container:first .然后分别执行 docker login、docker tag、docker push 把镜像 push 到镜像仓库(直接复制命令就行):
上传之后,点击镜像版本就可以看到这个版本号的镜像:
然后改一下 backend 项目里的 docker-compose.yml 文件:
image 改成阿里云镜像仓库里的。
接下来在服务器上把它跑起来就行。
保存代码,然后 git push 到代码仓库:
然后登录服务器,把最新代码 clone 下来(如果你clone 过了,只要 git pull 就行)。
然后跑一下:
docker login --username=用户名 registry.cn-qingdao.aliyuncs.com
docker compose up之后在安全组添加 80 端口:
但是这时你用 ip 访问,会发现没返回东西:
为什么呢?
往上翻一下日志,可能会有这个报错:
说是 docker 镜像的 platform 不匹配。
因为我本地是 m1 芯片的 mac ,build 出来的镜像在 linux 上跑不了。
当然,你不一定遇到这个问题,如果没遇到这个问题下面的步骤可以跳过。
如果遇到这个问题,那就需要 build 的时候加上 platform 了:
docker build -t fe-container:first --platform linux/amd64 .我重新 build 了一下镜像,指定了目标 platform。
然后重新 docker login、docker tag、docker push 来上传镜像:
上传之后在服务器把之前镜像删掉,重新跑:
docker compose down --rmi all
docker login --username=用户名 registry.cn-qingdao.aliyuncs.com
docker compose up这时浏览器就可以看到页面正常渲染了:
如果你没遇到 platform 的问题,那直接就可以在浏览器看到结果。
请求的 url 也是对的:
只是现在没有数据,后面加一下初始数据就好了。
这样,前端部分也部署完成了。
通过 11-Nginx基础概述 部署了前端项目,用它来做静态资源托管和 nest 服务的反向代理。
通过 Dockerfile 的多阶段构建,第一个阶段 npm run build 出产物,第二个阶段把产物和 11-Nginx基础概述 配置文件复制过去跑 11-Nginx基础概述 服务。
之后用 docker build 构建出镜像,把它上传到阿里云镜像仓库。
在另一边的 docker compose 配置文件里添加这个 11-Nginx基础概述 的容器配置。
这样服务端那边就可以用 docker compose up 一次性跑起 11-Nginx基础概述、nest、mysql、redis 等容器,前后端服务一键启动。
这就是 docker compose 的作用。
过程中如果遇到 platform 不一致的问题,那就 build 的时候指定下 platform 再上传就好了。
这样,就通过 docker compose 把前后端项目都部署到了阿里云:
会议室预订系统的后端功能实现得差不多了,但是代码层面还是有很多可以优化的地方。
这节集中优化下。
在 .env 文件里保存着 redis、mysql、nodemailer、nest 服务、jwt、minio 的配置:
其实 google 登录的 client id 和 client secret 也应该在这里配置。
# google 登录配置
google_login_client_id=xxx
google_login_client_secret=xxx
google_login_callback_url=http://localhost:3005/user/callback/google然后 google.strategy.ts 里取出来:
constructor(configService: ConfigService) {
super({
clientID: configService.get('google_login_client_id'),
clientSecret: configService.get('google_login_client_secret'),
callbackURL: configService.get('google_login_callback_url'),
scope: ['email', 'profile'],
});
}测试下:
没啥问题。
其实这个 client id 是测试用的,线上的肯定要另外申请,因为线上肯定不会是 http://localhost:3005 的域名。
也就是说,dev 环境和 prod 环境的 .env 是不同的。
所以要支持多个环境的 .env 的加载。
新建一个 src/.dev.env 的配置文件:
其他配置都一样,只是把启动端口改为 3006
然后 ConfigModule 的 envFilePath 指定多个 .env 文件:
ConfigModule.forRoot({
isGlobal: true,
envFilePath: [ path.join(__dirname, '.env'), path.join(__dirname, '.dev.env')]
}),当指定多个 .env 文件时,配置会做合并,但是相同的 key 前面的配置生效。
也就是说有 .env 的时候,它的配置优先生效。
跑起来试下:
端口是 3005。
删掉 .env 再跑试试:
现在就是跑在 .dev.env 里配置的 3006 端口了,也就是 .dev.env 生效了。
而且这个 .env 也可以不提交到 git 仓库,部署的时候手动把文件复制过去。
这样就没有泄漏的风险了。
这样,本地测试的配置写在 .dev.env 里,而线上生产环境的配置写在 .env 里。
前面章节添加了 minio 做 OSS 文件存储,但是并没有把它加到 docker compose 配置文件里。
加一下:
minio-container:
image: bitnami/minio:latest
ports:
- 9000:9000
- 9001:9001
volumes:
- /Users/guang/minio:/bitnami/minio/data
environment:
MINIO_ROOT_USER: guang
MINIO_ROOT_PASSWORD: dongdong
networks:
- common-network虽然有了桥接网络,多个容器之间可以通过容器名访问,但是这个 minio 还是需要暴露端口出来的。
因为管理界面、保存在 minio 的图片都需要直接访问。
把配置文件里的连接配置都改为容器名:
注释掉 fe 的容器,并且给 nest 容器映射下 3005 端口:
这里只测试下 nest 服务和 minio 服务就好了。
跑一下:
docker compose up注意,要先把你本地的 minio 容器停掉,不然 9000、9001 是被占用的。
nest 和 minio 都跑起来了:
浏览器访问下 minio 管理页面 http://localhost:9001/
没啥问题。
访问下 minio 预签名的接口:
也没问题,说明 nest 连接 minio 也是成功的。
回过头来,再来优化下 docker-compose.yml
可以通过 command 指定容器启动时执行的命令。
在 mysql 容器启动的时候,修改默认字符集为 utf8
command: mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_general_ci # 设置默认字符集此外,每个容器都应该加上重启策略:
当容器意外停掉时重启。
在如何灵活创建 dto 那节讲过,可以用 @nestjs/mapped-types 的 PartialType、PickType、OmitType、IntersectionType 来灵活创建 dto。
PickType 是从已有 dto 类型中取某个字段。
OmitType 是从已有 dto 类型中去掉某个字段。
PartialType 是把 dto 类型变为可选。
IntersectionType 是组合多个 dto 类型。
用这些方法,可以轻松的基于已有 dto 创建出新的 dto。
改造下现有 dto:
比如 LoginUserDto:
它的 username 和 password 在 RegiseterUserDto 里就可以复用:
而 UpdateUserPasswordDto 和 UpdateUserDto 也可以复用 RegisterUserDto 的某些字段:
所以 RegisterUserDto 可以简化成这样:
import { ApiProperty } from "@nestjs/swagger";
import { IsEmail, IsNotEmpty, MinLength } from "class-validator";
import { LoginUserDto } from "./login-user.dto";
import { PickType } from "@nestjs/mapped-types";
export class RegisterUserDto extends PickType(LoginUserDto, ['username', 'password']){
@IsNotEmpty({
message: '昵称不能为空'
})
@ApiProperty()
nickName: string;
@IsNotEmpty({
message: '邮箱不能为空'
})
@IsEmail({}, {
message: '不是合法的邮箱格式'
})
@ApiProperty()
email: string;
@IsNotEmpty({
message: '验证码不能为空'
})
@ApiProperty()
captcha: string;
}继承 LoginUserDto 里的两个字段。
而 UpdateUserPasswordDto 的字段可以全部从 RegisterUserDto 继承:
import { ApiProperty, PickType } from "@nestjs/swagger";
import { IsEmail, IsNotEmpty, MinLength } from "class-validator";
import { LoginUserDto } from "./login-user.dto";
import { RegisterUserDto } from "./register-user.dto";
export class UpdateUserPasswordDto extends PickType(RegisterUserDto, ['email', 'captcha', 'username', 'password']){
}UpdateUserDto 也是可以复用 RegisterUserDto 的大部分字段:
import { ApiProperty } from "@nestjs/swagger";
import { IsEmail, IsNotEmpty } from "class-validator";
import { RegisterUserDto } from "./register-user.dto";
import { IntersectionType, PartialType, PickType } from "@nestjs/mapped-types";
export class UpdateUserDto extends PickType(RegisterUserDto, ['email', 'captcha']){
@ApiProperty()
headPic: string;
@ApiProperty()
nickName: string;
}把 frontend_user 跑起来,测试下这些功能:
注册: 修改密码: 登录:
修改个人信息:
都没问题。
现在的验证码其实有个问题:
发送了一个邮箱验证码之后下次修改信息还可以用这个验证码。
因为在 redis 里这个验证码会保存 10 分钟:
其实应该用完一次就删掉的。
在 RedisService 添加一个 del 方法:
async del(key: string) {
await this.redisClient.del(key);
}更新完信息就调用它删掉验证码:
@Post(['update', 'admin/update'])
@RequireLogin()
async update(@UserInfo('userId') userId: number, @Body() updateUserDto: UpdateUserDto) {
const res = await this.userService.update(userId, updateUserDto);
this.redisService.del(`update_user_captcha_${updateUserDto.email}`);
return res;
}@Post(['update_password', 'admin/update_password'])
async updatePassword(@Body() passwordDto: UpdateUserPasswordDto) {
const res = await this.userService.updatePassword(passwordDto);
this.redisService.del(`update_password_captcha_${passwordDto.email}`);
return res;
}测试下:
现在一个验证码就只能用一次了。
只不过提示不大对,应该提示这个:
改一下 front_user 项目里 UpdateInfo 组件的 onFinish 的处理:
状态码非 20x 的时候展示返回的错误信息。
现在就对了。
front_admin 项目里的 InfoModify 组件也要做同样的修改:
案例代码上传了
这节对后端代码做了一些优化:
开发的时候只想着完成功能,但代码中有很多可以优化的点,这些可以逐步优化。
现在我们的日志都是直接打印在 console 的,这样开发时没问题,但是线上有时候要去查日志,这时候在 console 打印的日志就不是很方便。
所以要把日志保存在文件里,或者通过 http 发送到专门的日志服务器。
而 winston 等日志框架就是做这个的。
nest 集成 winston 直接用 nest-winston 这个包,没必要自己写。
在 backend 项目里安装下依赖:
npm install --save nest-winston winston然后在 AppModule 引入下:
WinstonModule.forRootAsync({
useFactory: () => ({
level: 'debug',
transports: [
new winston.transports.File({
filename: `${process.cwd()}/log`,
}),
new winston.transports.Console({
format: winston.format.combine(
winston.format.timestamp(),
utilities.format.nestLike(),
),
}),
],
})
}),指定日志输出级别是 debug,输出到的 transport 包括 console 和 file。
然后把 winston 的 logger 设置为 Nest 的默认 Logger:
app.useLogger(app.get(WINSTON_MODULE_NEST_PROVIDER));跑下试试:
npm run start:dev可以看到,除了 console 之外, log 文件中也输出了一份:
两个 transport 都生效了。
页面刷新下:
你会发现 typeorm 的日志并没有输出到文件。
这是因为 TypeORM 有自己的 Logger。
它当然也是可以自定义的。
在 src 下新建一个 CustomTypeOrmLogger.ts 文件:
import { WinstonLogger } from 'nest-winston';
import { Logger, QueryRunner } from 'typeorm';
export class CustomTypeOrmLogger implements Logger {
constructor(private winstonLogger: WinstonLogger) {
}
log(level: 'log' | 'info' | 'warn', message: any) {
this.winstonLogger.log(message);
}
logQuery(query: string, parameters?: any[], queryRunner?: QueryRunner) {
this.winstonLogger.log({
sql: query,
parameters
});
}
logQueryError(error: string | Error, query: string, parameters?: any[], queryRunner?: QueryRunner) {
this.winstonLogger.error({
sql: query,
parameters
});
}
logQuerySlow(time: number, query: string, parameters?: any[], queryRunner?: QueryRunner) {
this.winstonLogger.log({
sql: query,
parameters,
time
});
}
logSchemaBuild(message: string, queryRunner?: QueryRunner) {
this.winstonLogger.log(message);
}
logMigration(message: string, queryRunner?: QueryRunner) {
this.winstonLogger.log(message);
}
}就是实现 typeorm 的 Logger 接口,实现各种方法就好了。
具体的实现也比较简单,就是通过构造器传入 WinstonLogger,然后分别调用 log、error 等方法打印下日志。
然后在 TypeOrmModule 用一下:
试下效果:
现在 console 和 file 里就有 typeorm 的日志了。
现在的日志都是保存在单个文件里,不断追加内容,这样文件会越来越大,不好维护。
改下 transport,换成按照日期来分割日志那种。
安装用到的包:
npm install --save winston-daily-rotate-file用一下:
new winston.transports.DailyRotateFile({
level: 'debug',
dirname: 'daily-log',
filename: 'log-%DATE%.log',
datePattern: 'YYYY-MM-DD',
maxSize: '10k'
}),指定目录为 daily-log,然后指定文件名的格式和日期格式,文件最大的大小为 10k。
试试看:
多刷新几次页面:
可以看到,产生了几个文件,每个都是 10k 左右。
当然,最重要的是它会自动按照日期分割。
改下本地时间:
然后刷新页面,可以看到,日志被记录在了新的文件里:
也就是说,日志会按照日期为维度来分割,并且超过了一定 maxSize 也会分割。
这样,查日志的时候就可以找到当天的日期,在日志文件里搜索了。
把 winston 的配置也抽到 .env 里:
winston_log_level=debug
winston_log_dirname=daily-log
winston_log_filename=log-%DATE%.log
winston_log_date_pattern=YYYY-MM-DD
winston_log_max_size=1M然后在 WinstonModule 里读取:
WinstonModule.forRootAsync({
useFactory: (configService: ConfigService) => ({
level: 'debug',
transports: [
new winston.transports.DailyRotateFile({
level: configService.get('winston_log_level'),
dirname: configService.get('winston_log_dirname'),
filename: configService.get('winston_log_filename'),
datePattern: configService.get('winston_log_date_pattern'),
maxSize: configService.get('winston_log_max_size')
}),
new winston.transports.Console({
format: winston.format.combine(
winston.format.timestamp(),
utilities.format.nestLike(),
),
}),
],
}),
inject: [ConfigService]
}),跑下试试:
没啥问题。
这样,我们的 winston 就集成成功了。
此外,如果你有单独的日志服务,那就更方便了,不需要去服务器查日志文件,可以通过日志服务来查。
假设有了这样的日志服务,那可以加一个 Http 的 transport 来上传日志。
new winston.transports.Http({
host: 'localhost',
port: 3002,
path: '/log'
})创建个 nest 项目作为日志服务来接收下:
nest new log-server改下端口:
加个 /log 的路由:
把服务跑起来:
npm run start:dev页面上触发下查询:
可以看到所有的日志都接收到了:
同时在 console、file、日志服务三个地方保存了日志,这就是 winston 的魅力。
案例代码上传了
这节集成了日志框架 winston。
没接入 winston 之前,都是在 console 打印日志,如果出了问题想查日志很不方便。
接入 winston 之后,日志会在 console 打印,还会保存到日志文件中,按照日期分割,另外还会发送到日志服务来保存。
现在想查日志可以通过日志服务,也可以在查找日期对应的日志文件里搜索。
用了 nest-winston 这个包,在 AppModule 引入了 WinstonModule 之后,在 main.ts 里把它设置为 nest 的 logger。
此外,需要自定义 TypeOrm 的 logger,使用 winston logger 来实现它的方法。
这样,日志查询就方便多了。
前端代码写的比较仓促,很多代码是可以优化的。
这节统一来优化一下:
登录之后,会在 localStorage 保存 access_token、refresh_token、user_info 的信息:
然后进入会议室列表页。
这些页面是登录后才能访问的。
但是现在没登录访问会议室列表页不会自动跳转登录页:
只有点击其他页面的时候,才会跳转登录页:
因为这些页面的接口需要登录,没登录访问会返回 401,在响应的拦截器里做了处理,如果返回 401 就跳转登录页:
但是只在接口返回 401 的时候做处理不够,应该在路由级别也做下判断,也就是路由守卫。
分析下 frontend_user 的现有路由,需要登录的这几个页面都会渲染 Index 组件:
所以在 Index 组件里做下判断就好了:
之前有从 localStorage 里取 user_info 设置 headPic 的逻辑,加个 else,跳转登录页就好了。
测试下:
现在没有 user_info 的时候,也就是没有登录的时候就会跳转登录页了。
同理,frontend_admin 项目也是这样改:
useEffect(()=> {
const userInfo = localStorage.getItem('user_info');
if(!userInfo) {
window.location.href = "/login";
}
}, []);测试下:
没登录的时候,也同样跳转了登录页。
前面代码里加了很多 useMemo 和 useCallback,其实这是没必要的。
创建个测试项目:
npx create-vite test-memo进入项目,安装依赖,然后把开发服务跑起来:
npm install
npm run dev去掉 main.tsx 里的 index.css 和 StrictMode:
然后改下 App.tsx
import { useEffect, useState } from 'react'
function Aaa(props: { text: string }) {
console.log('Aaa render...');
return <div>{props.text}</div>
}
function App() {
const [num, setNum] = useState(0);
useEffect(() => {
setInterval(() => {
setNum(num => num + 1);
}, 1000);
}, []);
return <Aaa text="aaa"></Aaa>
}
export default AppApp 组件里有一个定时器,不断的修改 setState 来触发重新渲染。
这时候 Aaa 会不会重新渲染呢?
答案是每次都会重新渲染。
很明显这里 Aaa 不需要重新渲染,因为参数没变。
这时候就可以用 memo 来做:
import { memo, useEffect, useState } from 'react'
function Aaa(props: { text: string }) {
console.log('Aaa render...');
return <div>{props.text}</div>
}
const MemoAaa = memo(Aaa);
function App() {
const [num, setNum] = useState(0);
useEffect(() => {
setInterval(() => {
setNum(num => num + 1);
}, 1000);
}, []);
return <MemoAaa text="aaa"></MemoAaa>
}
export default App现在 Aaa 就只会渲染一次了:
memo 里会对比 props 和上次有没有变化,没变就不会重新渲染。
那如果我加一个函数作为参数呢?
import { memo, useEffect, useState } from 'react'
function Aaa(props: { text: string, callback: Function }) {
console.log('Aaa render...');
return <div>{props.text}</div>
}
const MemoAaa = memo(Aaa);
function App() {
const [num, setNum] = useState(0);
useEffect(() => {
setInterval(() => {
setNum(num => num + 1);
}, 1000);
}, []);
return <MemoAaa text="aaa" callback={() => { console.log('xxx');}}></MemoAaa>
}
export default App这时候每次都会重新渲染:
因为函数每次都变,也就是参数每次都变,加了 memo 也没用。
这时候用 useCallback 就可以解决这个问题,它只有依赖数组变的时候才会返回新的函数,否则一直返回同一个函数:
import { memo, useCallback, useEffect, useState } from 'react'
function Aaa(props: { text: string, callback: Function }) {
console.log('Aaa render...');
return <div>{props.text}</div>
}
const MemoAaa = memo(Aaa);
function App() {
const [num, setNum] = useState(0);
useEffect(() => {
setInterval(() => {
setNum(num => num + 1);
}, 1000);
}, []);
const callback = useCallback(() => {
console.log('xxx');
}, []);
return <MemoAaa text="aaa" callback={callback}></MemoAaa>
}
export default App现在又只渲染一次了:
所以说,memo 和 useCallback、useMemo 是配合使用的,之前单独用 useCallback 没什么意义。
代码里的 useCallback 可以去掉。
这节做了一些前端代码优化。
之前只是做了接口返回 401 的时候跳转登录页的逻辑,现在又补充了路由守卫,在 Index 组件里读取 localStorage 的值,如果没有 user_info 就跳转登录页面。
然后分析了 React 里 memo 的作用以及和 useCallback、useMemo 的配合使用,之前单独使用并没有什么意义,可以去掉。
这样,前端代码就没有啥明显的问题了,还有细节上的优化大家可以自己去做。
做完项目之后,整体测试一下。
按照当时的分析出的功能来测:
首先是普通用户的:
进入 backend 和 frontend_user 项目,跑起来。
npm run start:devnpm run dev首先填入信息,发送验证码:
用户名要求唯一:
注册成功后登录下:
刚才注册的账号可以登录。
然后还可以 google 账号直接登录,不需要注册:
填入用户名、邮箱,点击发送验证码:
(其实当时邮箱应该添加唯一约束,也就是能唯一确定用户,这样就可以不需要填用户名了)
修改成功。
再登录下:
没问题。
继续看当时分析的普通用户的需求:
登录后可以修改个人信息。
修改下头像:
没问题。
可以查看会议室列表,根据名称、容纳人数、设备来搜索会议室:
选择好会议室之后可以提交预订申请:
填入预订时间、备注之后,可以提交预订申请:
然后在预订历史里就可以看到这次预订。
当然,你可以取消预订:
这样该预订记录就会回到已解除状态。
接下来进入管理员界面:
登录和修改密码和普通用户差不多。
来测试下各种管理功能:
进入 frontend_admin 项目,跑起来:
npm run dev然后登录下:
可以按照用户名、昵称、邮箱来搜索用户:
(这个冻结功能目前没啥用,可以去掉)
可以按照名称、人数、设备来搜索会议室:
可以添加会议室:
之后在用户端这边也可以看到这个会议室了:
当然,也可以更新信息和删除:
预订管理可以按照预订人、会议室名称、预订时间、位置等来搜索预订申请:
比如在用户端申请一个:
这时候管理端就可以看到这个申请了:
点击通过,然后在用户端就可以看到审批通过了:
这时候该会议室该时间段就不能再被预订:
这个模块就是可以查看哪些会议室在过去一段时间内被预订的频率高,哪些用户使用会议室的频率高:
按照最初的需求分析来过了一遍系统的功能。
首先测了注册、登录、修改密码、google 登录这些通用功能。
用户端可以搜索会议室、提交预订申请。
管理端可以审批预订申请,管理会议室、查看统计等。
整个流程是没问题的。
项目部署上线之后,就可以投入使用了。
学完了 Nest 基础、MySQL、TypeORM、Docker & Docker Compose、Minio、passport 等技术之后,做了这个实战项目。
这个项目是会议室预订系统,分为用户端和管理端。
用户端登录之后可以查看可预订的会议室,预订会议室,查看预订历史等。
管理端则是用户、会议室、预订记录等的管理,还有统计分析。
当然,具体做什么项目不重要,主要是通过这个实战案例把前面学过的技术综合运用一遍。
比如之前你对 Nginx 的理解只是可以做反向代理、可以托管静态资源,但不知道它在整个链路的什么位置。
而现在,你写过 11-Nginx基础概述 的配置文件,并且在阿里云上把它部署并跑了起来。
你对它的理解就更加深入和立体了。
为了聚焦在 Nest 和后端技术,前端部分尽量简化,界面都是用 antd 写的,没什么复杂的样式:
交互上也都比较单调,基本都是表格、表单。
实际上,预订会议室的功能一般都是和日历结合,比如 Google Calendar:
可以直观的查看哪些时间段是没有被预订的,然后预订。
而不是选择一个时间段预订,如果已经预订了提示该时间段已被预订:
这样体验不好。
不过不重要,我们的重点在于后端部分。
前端的交互大家可以自己去完善。
回顾下做这个项目的整个过程:
首先做了需求分析,分析了下有哪些功能,并画了原型图。
分了用户管理、会议室管理、预订管理、统计这 4 个模块。
这一步主要是明确做什么。
最后做出来的也是符合这些需求分析和原型图的。
然后设计了下技术方案,做了技术选型:
数据库设计:
分析了下接口:
接下来进入开发:
是按照每个模块先写后端,再写前端的顺序来开发的。
比如先开发用户模块后端部分,再开发用户模块前端部分。
然后开发会议室模块后端部分,再开发会议室模块前端部分。
首先创建后端项目,引入 typeorm 和 redis,实现了用户注册。
基于 @nestjs/config 包的 ConfigModule 把配置抽离到了 .env 文件里。
之后实现了登录和双 token 的无感刷新。
加上了登录和权限的 Guard 来做鉴权,通过 @RequireLogin、@RequirePermission 的自定义装饰器在 handler 上声明,只有声明的才会做鉴权。
然后添加了 interceptor 对响应格式做转换,改成 {code、message、data} 的格式。
并且还用 interceptor 实现了接口访问的日志记录。
然后实现了修改信息、修改密码的接口。
用到的 userId 通过 @UserInfo 自定义装饰器从 request.user 来取。
接下来实现了冻结用户和用户列表接口。
通过自定义 exception filter,catch 了 HttpException,返回了自定义格式的响应,统一了响应格式。
加上了 swagger 接口文档。
至此,用户管理模块的后端代码就完成了。
然后写了用户管理模块的前端代码:
引入了 React Router 来做路由,引入了 antd 来做 UI 组件库,引入了 axios 来发请求。
写了登录、注册、首页、修改密码、修改个人信息的页面。
这里还实现了头像上传的功能。
接下来又写了管理端的页面。
然后又分别实现了会议室模块、预订模块、统计模块的后端代码和前端页面。
之后加上了 docker-compose.yml 和 Nest 应用的 Dockerfile,部署到了阿里云。
买了一台阿里云服务器,在服务器上安装 git 来下载项目代码,然后安装 docker compose,在项目目录下执行 docker compose up 把服务跑起来就行了。
部署成功之后,我们的服务就可以在世界各地访问了。
然后又加上了 11-Nginx基础概述 部署了前端项目,用它来做静态资源托管和 nest 服务的反向代理。
通过 Dockerfile 的多阶段构建,第一个阶段 npm run build 出产物,第二个阶段把产物和 11-Nginx基础概述 配置文件复制过去跑 11-Nginx基础概述 服务。
之后用 docker build 构建出镜像,把它上传到阿里云镜像仓库。
在另一边的 docker compose 配置文件里添加这个 11-Nginx基础概述 的容器配置。
这样服务端那边就可以用 docker compose up 一次性跑起 11-Nginx基础概述、nest、mysql、redis 等容器,前后端服务一键启动。
这样,前后端代码就都部署完了。
生产环境会把 synchronize 关掉,用 migration 来创建表和初始化数据。
加上了 migration 的 npm scripts,然后创建了几个 migration。
在服务器上执行 migration:run 就会创建数据库表并插入初始化数据。
之后把文件上传从基于 multer 实现,保存在项目目录下,换成了基于 minio 实现的 OSS 服务。
是用前端直传 OSS,然后把文件 url 发给应用服务器的方式。
这样,文件就都保存在了 minio 服务里,可以更方便的管理。
然后又实现了 google 登录,基于 passport 的 passport-google-oauth20 的策略来实现的。
在 callback 的路由里,基于 google 返回的信息做了自动注册,如果用户已经注册过,就直接返回登录信息。
并且 callback 里会重定向到网站首页,通过 cookie 携带 userInfo、accessToken、refreshToken 等信息。
前端代码再处理下 cookie,同步登录状态就好了。
然后集成了 winston 日志框架,日志会在 console、file 打印,并且还可以发送到别的日志服务。
最后对代码做了一些优化。
至此,我们的第一个项目实战就完成了。
项目做什么不重要,重要的是把用到的技术融会贯通,知道这些技术都是干什么的。
并且知道一个全栈项目从需求分析到开发到部署的整个流程。
如果你要自己做一个全栈项目,这些技术你能够用起来了么?