二月份的时候我写了一篇 React + Express + Socket.io 之间的实时通信【2】:注册登录,那时候我还在用 Express 作为后端框架。

因为中途想到使用 TypeScript,所以我决定迁移到 NestJS。

前端

先说一下我对前端的改动。

因为是想要整个项目都用 TypeScript,所以我把 src 目录下的所有 .js 文件都改成了 .tsx

很多文件都不需要改动,例如 App.js

js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";

import "./App.css";
import "bootstrap/dist/css/bootstrap.min.css";
import Home from "./components/Home";
import Login from "./components/Login";
import Register from "./components/Register";
import PrivateMessageHomepage from "./components/private_message_homepage/Private_Message_Homepage";
import PrivateMessageChatpage from "./components/private_message_chatpage/Private_Message_Chatpage";
import socket from "./components/utils/actions/authActions";

function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={ <Navigate to="/channels/@me" replace /> } />
<Route path="/channels/@me" element={ <Home /> }>
<Route path="" element={ <PrivateMessageHomepage style={{ flex: '1 1 auto' }} /> } />
<Route path="dummy" element={ <PrivateMessageChatpage style={{ flex: '1 1 auto' }} /> }/>
</Route>

<Route path="/login" element={ <Login socket={ socket } /> } />
<Route path="/register" element={ <Register /> } />
</Routes>
</BrowserRouter>
);
}

export default App;

App.tsx

tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import "bootstrap/dist/css/bootstrap.min.css";
import "./App.css";
import Home from "./components/Home";
import Login from "./components/Login";
import Register from "./components/Register";
import Guard from "./components/utils/guard";
// import PrivateMessageHomepage from "./components/private_message_homepage/Private_Message_Homepage";
import PrivateMessageChatPage from "./components/private_message_chat_page/Private_Message_Chat_Page";

function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={ <Navigate to="/channels/@me" replace /> } />
<Route path="/channels/@me" element={ <Guard /> }>
<Route path="" element={ <Home />} />
<Route path="dummy" element={ <PrivateMessageChatPage style={{ flex: '1 1 auto' }} /> }/>
</Route>

<Route path="/login" element={ <Login /> } />
<Route path="/register" element={ <Register /> } />
</Routes>
</BrowserRouter>
);
}

export default App;

对比一下会发现其实没有变化,PrivateMessageHomepage 改成了 Guard 只是因为业务逻辑的改动,跟 TypeScript 无关。

Redux

涉及到 Redux 的文件多多少少都有一些改动。

store.js

js
1
2
3
4
5
6
7
8
import { configureStore } from "@reduxjs/toolkit";
import authSlice from "./reducers/authSlice";

export default configureStore({
reducer: {
auth: authSlice
}
});

store.ts

ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { configureStore } from "@reduxjs/toolkit";
import { useDispatch } from "react-redux";
import authSlice from "./reducers/authSlice";

const store = configureStore({
reducer: {
auth: authSlice,
}
})

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
export const useAppDispatch = () => useDispatch<AppDispatch>();

export default store;

原先的 store.js 是直接导出了 configureStore 的返回值;store.ts 先是导出了 RootStateAppDispatch 这两个类型,然后道出了 useAppDispatch 这个自定义 Hook、以替代 useDispatch

authSlice.js

js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { createSlice } from "@reduxjs/toolkit";

export const authSlice = createSlice({
name: "auth",
initialState: {
isAuthenticated: false,
user: {},
error: null
},
reducers: {
setCurrentUser: (state, action) => {
state.isAuthenticated = true;
state.user = action.payload;
},
setError: (state, action) => {
state.error = action.payload;
}
}
});

export const { setCurrentUser, setError } = authSlice.actions;
export default authSlice.reducer;

authSlice.ts

ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { User } from '../interfaces';

interface AuthState {
isAuthenticated: boolean;
user: User | null;
error: string | null;
}

const initialState: AuthState = {
isAuthenticated: false,
user: null,
error: null,
};

export const authSlice = createSlice({
name: 'auth',
initialState,
reducers: {
setCurrentUser: (state, action: PayloadAction<User>) => {
state.isAuthenticated = true;
state.user = action.payload;
},
setError: (state, action: PayloadAction<string>) => {
state.error = action.payload;
},
},
});

export const { setCurrentUser, setError } = authSlice.actions;
export default authSlice.reducer;

authSlice.jsauthSlice.ts 的区别在于 action 的类型声明。TypeScript 是 JavaScript 的超集,目的是为了更好地进行静态类型检查,以避免各种各样的错误。要知道 JavaScript 是弱类型语言,这意味着你可以在不同的地方使用不同的类型而不报错。

interface 关键字用于定义一个接口,接口是一种抽象的结构、定义了一个对象应该具有的属性和方法。AuthState 接口被定义后,有三个属性:isAuthenticatedusererror。然后设置 initialStateAuthState 类型。

也就是说 initialState 无论怎么改动,都必须符合 AuthState 的结构。假设我在 initialStateisAuthenticated 属性后面加了一个 isRegistered 属性,那么在 reducers 中的 state 就会报错,因为 isRegistered 属性并不在 AuthState 中。

setCurrentUsersetErroraction 参数都是 PayloadAction 类型,PayloadAction 是一个泛型接口,接受一个类型参数,这个类型参数就是 action.payload 的类型。这样一来,action.payload 的类型就被限制了,不会出现不符合预期的情况。

比方说 setErroraction 参数就被限制为 string 类型。

我还新建了一个 interfaces.ts 文件来存放会被多个文件引用的接口:

ts
1
2
3
4
5
6
7
export interface User {
id?: number;
emailAddress?: string;
username: string;
password?: string;
access_token?: string;
}

属性后面的 ? 表示这个属性是可选的。想象一下,User 接口会被注册、登录以及登出的组件引用:

  • 注册时,用户传来的数据中不会有 idaccess_token 这两个属性
  • 登录时,用户传来的数据中不会有 emailAddress 这个属性
  • 登出时,用户传来的数据中不会有 password 这个属性

所以这些属性都是可选的。

Axios

我本来是全程使用 Socket.io 来进行通信,这也包括了登录、注册等操作。但是 Socket.io 并不适合用来做这些操作,所以我还是用了 Axios。

比方说注册用户,使用 Socket.io 的话就是这样:

js
1
2
3
4
5
6
7
8
9
const registerUser = (userData, navigate) => {
return dispatch => {
socket.emit("register", userData);

socket.on("newRegisteredUser", data => {
data.status === "00000" ? navigate("/login") : console.log(data.message);
});
}
};

先发送 register 事件,然后接收从服务端发来的 newRegisteredUser 事件,根据 data.status 的值来决定跳转到登录页面还是打印错误信息。

换成 Axios 的话,要先创建一个服务:

ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import axios from 'axios';
import { User } from "../interfaces";

const api = axios.create({
baseURL: 'http://localhost:4000/api',
});

export const UsersService = {
register: (data: User) => {
return api.post('/users/register', data);
},

login: (data: User) => {
return api.post('/users/login', data);
},
}

使用 baseURL 的好处是如果后端地址改变、只需要改动一次就行了。

UsersService 对象中有两个方法:registerlogin,分别用于注册和登录。当我们需要向后端发送请求时,只需要调用这些方法即可:

ts
1
2
3
4
5
6
7
8
9
10
11
const registerUser = (userData: User, navigate: (path: string) => void) => {
return async (dispatch: Dispatch) => {
try {
const response = await UsersService.register(userData);
const data = response.data;
if (data.status === "00000") navigate("/login");
} catch (error) {
console.error(error);
}
}
}

我还添加了一个 try...catch 语句,用于捕获请求失败的情况。

登录和注册

在写函数组件时,需要定义函数组件的类型:

tsx
1
2
3
4
5
import React from "react";
// ...
const Register: React.FC = () => {
// ...
}

如果该函数组件还有 props,那么就需要定义 props 的类型:

tsx
1
2
3
4
5
6
7
8
9
import React from 'react';
// ...
interface PrivateMessageHomepageProps {
style: React.CSSProperties;
}

const PrivateMessageHomepage: React.FC<PrivateMessageHomepageProps> = ({ style }) => {
// ...
}

例如这里的 PrivateMessageHomepage 组件有一个 style 属性,所以需要定义其类型为 React.CSSProperties,毕竟是一个 CSS 样式对象嘛。

之前讲到共用的 User 接口,但对于注册来说还需要 4 个必需的属性:emailAddressbirthYearbirthMonthbirthDay。其中 emailAddress 虽在 User 接口中,但是是可选的,不符合注册的要求。

所以我们可以使用 & 运算符来合并两个接口:

ts
1
2
3
4
5
6
7
8
import { User } from "./utils/interfaces";
// ...
type RegisterUser = User & {
emailAddress: string;
birthYear: string;
birthMonth: string;
birthDay: string;
}

RegisterUser 接口继承了 User 接口,并添加了 4 个必需的属性。后加的属性会覆盖前面的属性,所以 User 接口中的 emailAddress 属性被覆盖了。

之后再使用 useState 来定义 userData

tsx
1
2
3
4
5
6
7
8
const [userData, setUserData] = useState<RegisterUser>({
username: "",
emailAddress: "",
password: "",
birthYear: "",
birthMonth: "",
birthDay: ""
});

登录时我们不需要 User 接口中其他的属性,只需要 usernamepassword 属性。所以我们可以挑选出需要的属性:

tsx
1
type LoginUser = Pick<User, "username" | "password">;

Pick 的用处是从一个对象中挑选出一些属性,返回一个新的对象。这意味着 LoginUser 接口只包含 User 接口中的 usernamepassword 属性。

像是需要展现出用户名的地方我们也可以这样写:

tsx
1
type UserProfile = Pick<User, "username">;

页面跳转

最前面提及到的 Guard 组件是用来判断用户是否登录的,如果用户没有登录,那么就跳转到登录页面:

tsx
1
2
3
4
5
6
7
8
9
10
11
import React from "react";
import { Navigate, Outlet } from "react-router-dom";

const Guard: React.FC = () => {
const auth = localStorage.getItem("auth");

if (auth) return <Outlet/>;
else return <Navigate to="/login" />;
}

export default Guard;

如果 localStorage 中有 auth 这个键,那么就渲染 Outlet 组件,否则就跳转到登录页面。

Outlet 组件是用来渲染子路由的。回到 App.tsx,能看到 /channels/@me 路由被 Guard 组件保护,子路由分别是默认的 Home 组件和 dummy 子路由。

也就是说用户在登陆后跳转到 /channels/@me 路由,会被 Guard 组件验证,然后渲染 Home 组件。

/channels/@me/dummy 路由是用来测试私聊的,但是它也在 Guard 组件的保护之下。

其他

我的项目中有一些按钮在被鼠标悬停时会有一些样式变化,原先的逻辑是这样的:

jsx
1
2
3
4
const [hoverStates, setHoverStates] = useState({});
const updateHoverState = (item, isHovered) => {
setHoverStates(prev => ({ ...prev, [item]: isHovered }));
}

转换到 TypeScript 后:

tsx
1
2
3
4
5
6
7
const [hoverStates, setHoverStates] = useState<Record<string, boolean>>({});
const updateHoverState = (item: string, isHovered: boolean) => {
setHoverStates({
...hoverStates,
[item]: isHovered
});
}

Record 的第一个参数定义了键的类型,第二个参数定义了值的类型。hoverStates 被定义为一个字典,键和值再被定义为 stringboolean 类型。

输入框组件里分别有着:

  • 判断用户是否按下了回车键的 handleKeyDown 函数
  • 处理用户输入信息的 handleChange 函数
  • 处理用户提交表单的 handleSendMessage 函数

这些函数都需要定义类型:

tsx
1
2
3
4
5
const handleKeyDown = (e: KeyboardEvent) => {}

const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) => {}

const handleSendMessage = (e: FormEvent) => {}

后端

NestJS 是一个基于 Node.JS 的后端框架,它使用 TypeScript 编写,提供了一些装饰器来简化开发。

我目前有的依赖:

json
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
"@nestjs/common": "^10.3.3",
"@nestjs/core": "^10.3.3",
"@nestjs/jwt": "^10.2.0",
"@nestjs/mapped-types": "*",
"@nestjs/mongoose": "^10.0.4",
"@nestjs/platform-express": "^10.3.3",
"@nestjs/platform-socket.io": "^10.3.3",
"@nestjs/typeorm": "^10.0.2",
"@nestjs/websockets": "^10.3.3",
"bcrypt": "^5.1.1",
"class-validator": "^0.14.1",
"cookie-parser": "^1.4.6",
"dotenv": "^16.4.5",
"mongoose": "^8.2.1",
"morgan": "^1.10.0",
"reflect-metadata": "^0.2.1",
"rxjs": "^7.8.1",
"typeorm": "^0.3.20"
}

NestJS 的入口文件是 main.ts

ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { NestFactory } from '@nestjs/core'
import { AppModule } from './app.module'
import * as dotenv from 'dotenv'
import { Server } from 'socket.io'

dotenv.config()

async function bootstrap() {
const app = await NestFactory.create(AppModule)
app.enableCors({
origin: process.env.CLIENT_ORIGIN || 'http://localhost:3000',
credentials: true,
})

app.setGlobalPrefix('api')

const server = app.getHttpServer()
new Server(server)

await app.listen(process.env.PORT || 4000)
}

bootstrap().catch((err) => console.error(err))

dotenv 是用来读取 .env 文件的,.env 文件用来存放环境变量。CLIENT_ORIGIN 是前端的地址,PORT 是后端的端口。

因为前后端分离的项目中,前端和后端是不同的域名,所以会有跨域问题。app.enableCors 方法用来解决跨域问题,origin 参数是前端的地址,credentials 参数是 true 表示允许携带 cookie。

app.setGlobalPrefix 方法用来设置全局前缀,所有的路由都会加上这个前缀。比方说后面设置的 /users/register 路由会变成 /api/users/register

app.getHttpServer 方法返回一个 http.Server 实例,new Server(server) 用来创建一个 Socket.io 服务器。

最后调用 app.listen 方法来启动服务器。

NestJS 概念

NestJS 目前支持两个 HTTP 平台:Express 和 Fastify。这是因为 NestJS 的开发团队认为 NestJS 立志于成为一个模块化的框架,不单单是一个 HTTP 框架。只要创建了适配器,NestJS 就可以在任何平台上运行。

NestJS 的核心概念有:

  • 控制器(Controller)
  • 服务(Service)
  • 模块(Module)

控制器

控制器是处理传入请求的地方,它们会调用服务来完成请求。控制器的方法可以使用装饰器来定义路由。

app.controller.tsts
1
2
3
4
5
6
7
8
9
10
11
12
import { Controller, Get } from '@nestjs/common'
import { AppService } from './app.service'

@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}

@Get()
getHello(): string {
return this.appService.getHello()
}
}

@Controller 装饰器用来定义一个控制器。假设我们的后端地址是 http://localhost:4000,那么 @Controller() 装饰器的参数就是 http://localhost:4000

@Get() 装饰器用来定义一个 GET 请求,这个请求的路径就是控制器的路径,也就是请求 http://localhost:4000

假设我想要请求 http://localhost:4000/api/users/register,那么就要写成:

ts
1
2
3
@Controller('users')

@Get('register')

为什么不写成 @Controller('api/users') 呢?因为全局前缀已经被我们设置为 api 了。

服务

刚才的控制器中有一个 AppService 服务,服务是处理业务逻辑的地方。服务可以被控制器调用,也可以被其他服务调用。

app.service.tsts
1
2
3
4
5
6
7
8
import { Injectable } from '@nestjs/common'

@Injectable()
export class AppService {
getHello(): string {
return 'Welcome to HotaruTS!!'
}
}

@Injectable 装饰器用来定义一个服务。服务中的方法可以被其他服务调用,也可以被控制器调用。

刚才调用的是 getHello 方法,返回的是一个字符串。如果使用 Postman 请求 http://localhost:4000,响应的内容就是 Welcome to HotaruTS!!

模块

模块是一个用来组织应用程序的地方,每个应用程序至少有一个根模块。模块中可以包含控制器、服务、提供器等。

根模块可以看成是 Express 里的 app 对象,它是所有模块的入口。

先来看一下我 Express 项目中的 app.js

js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
const createError = require('http-errors');
const express = require('express');
const path = require('path');
const cookieParser = require('cookie-parser');
const logger = require('morgan');

const indexRouter = require('./routes/index');
const usersRouter = require('./routes/users');

const app = express();

app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'pug');

app.use(logger('dev'));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));

app.use('/', indexRouter);
app.use('/users.js', usersRouter);

app.use(function(req, res, next) {
next(createError(404));
});

app.use(function(err, req, res, next) {
res.locals.message = err.message;
res.locals.error = req.app.get('env') === 'development' ? err : {};

res.status(err.status || 500);
res.render('error');
});

module.exports = app;
  1. 导入依赖
  2. 创建路由器 indexRouterusersRouter
  3. 创建 app 对象,也就是 Express 的实例
  4. 设置视图引擎和视图路径
  5. 使用中间件,分别是:
    1. logger:记录请求日志,dev 参数表示开发环境
    2. express.json:解析 JSON 格式的请求体
    3. express.urlencoded:解析 URL 编码的请求体,extended 参数表示是否使用 qs
    4. cookieParser:解析 cookie
    5. express.static:设置静态文件目录,也就是 public 目录
  6. 配置路由,让 indexRouterusersRouter 分别处理 //users 路径
  7. 处理 404 错误
  8. 处理其他错误
  9. 导出 app 对象

得知了这些,我们就可以仿照 Express 的写法来写 NestJS 的模块。

首先导入依赖:

ts
1
2
3
4
5
6
7
8
9
10
11
12
import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common'
import { APP_FILTER } from '@nestjs/core'
import { MongooseModule } from '@nestjs/mongoose'
import * as express from 'express'
import * as cookieParser from 'cookie-parser'
import * as morgan from 'morgan'
import { AppController } from './app.controller'
import { AppService } from './app.service'
import { AnyExceptionFilter } from './any-exception.filter'
import { LoggerMiddleware } from './common/middleware/logger.middleware'
import { SocketModule } from './socket/socket.module'
import { UsersModule } from './users/users.module'

@Module 装饰器可以定义一个模块,参数分别是:

  • imports:导入其他模块
  • controllers:控制器
  • providers:提供器

因为这个项目的数据库是 MongoDB,所以我导入了 MongooseModule 模块。SocketModuleUsersModule 是自定义的模块。

UsersModule 后面会详细讲解,SocketModule 等未来写到消息传递时再讲。

控制器就不用多说了,模块本来就是用来组织控制器的。AppModule 中只有一个控制器 AppController

提供器是一个用来提供服务的地方,服务可以被控制器调用。AppService 就是一个提供器。除此之外,还有一个全局异常过滤器 AnyExceptionFilter

ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Module({
imports: [
MongooseModule.forRoot(process.env.DATABASE_URL || 'mongodb://localhost:27017/hotaru'),
SocketModule,
UsersModule,
],
controllers: [AppController],
providers: [
AppService,
{
provide: APP_FILTER,
useClass: AnyExceptionFilter,
},
],
})

AppModule 添加了一个 configure 方法,这个方法是 NestModule 接口的一个方法,作用是添加中间件。

consumer.apply 方法用来添加中间件,参数是一个或多个中间件。这里添加了 morganexpress.jsonexpress.urlencodedcookieParserexpress.staticLoggerMiddleware 中间件。

forRoutes('*') 表示所有路由都会使用这些中间件。

最终导出 AppModule

ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(
morgan('dev'),
express.json(),
express.urlencoded({ extended: false }),
cookieParser(),
express.static('public'),
LoggerMiddleware,
)
.forRoutes('*')
}
}

这样,我们就完成了一个 NestJS 的模块。

日志中间件

LoggerMiddleware 中间件是一个自定义的中间件,用来记录请求日志:

ts
1
2
3
4
5
6
7
8
9
10
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';

@Injectable()
export class LoggerMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
console.log('Request...', req.method, req.originalUrl);
next();
}
}

目前这个中间件只是简单地打印请求方法和请求路径。

用户模块

用户模块是一个用来处理用户注册、登录、登出的模块。

在 NestJS 里创建一个模块可以使用 CLI:

bash
1
nest g module users

这个命令会在 src 目录下创建一个 users 目录,里面有一个 users.module.ts 文件。

首先我们得知道原先的 Express 项目里,用户注册的逻辑是怎么写的:

js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
socket.on("register", async userData => {
const existingUserEmail = await User.findOne({
emailAddress: userData.emailAddress
});
const existingUsername = await User.findOne({
username: userData.username
});

if (existingUserEmail || existingUsername) {
console.log(`[U0102] User already exists: ${userData.username}`);
User.find({}).then((docs) => {
console.log(docs);
}).catch((err) => {
console.error(err);
});

socket.emit("newRegisteredUser", {
status: "U0102",
message: "User already exists."
});
return;
}

await User.create({
emailAddress: userData.emailAddress,
username: userData.username,
password: userData.password,
DOBYear: userData.birthYear,
DOBMonth: MonthToNumber[userData.birthMonth],
DOBDay: userData.birthDay
})
console.log(`[00000] User registered: ${userData.username}`);

socketIO.emit("newRegisteredUser", {
status: "00000",
token: generateJWT(userData.username)
});
});

因为使用的是 Socket.io,所以要监听 register 事件,然后验证用户填写的信息。如果用户已经存在,就返回错误信息;如果用户不存在,就创建一个新用户。

其中的状态码是自定义的,采用的是类似于阿里巴巴代码规约的状态码。

在 NestJS 中,鉴于客户端已经改为使用 Axios 这样的 HTTP 库,我们就不再使用 Socket.io 了。先创建一个路径为 /users 的控制器:

ts
1
2
3
4
@Controller('users')
export class UsersControllers {
constructor(private readonly usersService: UsersService) { }
}

constructor 方法中注入了一个私有且只读的 usersService 服务,这意味着这个服务只能在这个控制器中使用。

ts
1
2
@Post('register')
async register(@Body() registerUserDto: RegisterUserDto, @Res() res: Response) {}

@Post('register') 装饰器用来定义一个 POST 请求,请求路径是 /users/register

@Body() 装饰器用来获取请求体,registerUserDto 是一个数据传输对象,包含了用户注册时需要的信息,也就是客户端传来的数据:

  • emailAddress
  • username
  • password
  • birthYear
  • birthMonth
  • birthDay

@Res() 装饰器用来获取响应对象。

ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const existingUser = await this.usersService.findByEmail(registerUserDto.emailAddress)
if (existingUser) {
throw new HttpException('Email address already in use', HttpStatus.BAD_REQUEST)
}

if (!registerUserDto.emailAddress) {
throw new HttpException('Email address is required', HttpStatus.BAD_REQUEST)
}

try {
const user = this.usersService.register(registerUserDto)

res.status(HttpStatus.OK).json({
status: '00000',
message: 'User registered successfully',
user: user,
})
} catch (error) {
res.status(HttpStatus.BAD_REQUEST).json({
status: 'U0100',
message: 'Failed to register user',
})
}

首先调用 this.usersService.findByEmail 方法来查找用户是否已经存在,如果存在就返回错误信息。接着判断用户填写的信息是否完整,如果不完整就返回错误信息。

最后调用 this.usersService.register 方法来注册用户,如果注册成功就返回成功信息,否则返回错误信息。

每次返回响应时都要设置状态码,比方说请求成功时写的 HttpStatus.OK,请求失败时写的 HttpStatus.BAD_REQUEST。尽管已经自定义了一套状态码,但是还是要遵循 HTTP 协议的状态码,谁叫我们是用 HTTP 协议的呢。

那么 UsersService 里到底是什么样的呢?

ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import { Injectable, UnauthorizedException } from '@nestjs/common'
import { JwtService } from '@nestjs/jwt'
import { InjectModel } from '@nestjs/mongoose'
import { v4 as uuidv4 } from 'uuid'
import * as bcrypt from 'bcrypt'
import { Model } from 'mongoose'
import { RegisterUserDto, LoginUserDto } from './dto'
import { User, UserDocument } from './user.schema'

@Injectable()
export class UsersService {
constructor(
@InjectModel(User.name) private usersModel: Model<UserDocument>,
private jwtService: JwtService,
) {
}

async register(registerUserDto: RegisterUserDto): Promise<void | User> {
const id = uuidv4()
const hashedPassword = await bcrypt.hash(registerUserDto.password, 10)
const newUser = new this.usersModel({
id,
...registerUserDto,
password: hashedPassword,
})

let savedUser: void | User
try {
savedUser = await newUser.save().then(() => console.log('User registered successfully'))
} catch (err) {
console.error(err)
}
return savedUser
}

async findByEmail(emailAddress: string): Promise<User | null> {
return this.usersModel.findOne({ emailAddress }).exec()
}
}

@InjectModel(User.name) 注入了一个 Mongoose 模型,用来操作数据库。下面的 jwtService 是用来生成 JWT 的服务,以后再聊聊 JWT。

register 方法中我们先用 uuidv4 方法生成一个唯一的 ID,然后用 bcrypt 库对密码进行加密。接着使用 new 关键字创建一个用户实例、传入所有创建用户时需要的信息。 最后调用 save 方法保存用户信息,如果保存成功就返回用户信息,否则返回 void

findByEmail 方法用来查找用户是否已经存在,如果存在就返回用户信息,否则返回 null

现在已经看到了很多次 ...Dto,这是什么呢?

DTO 的全程为 Data Transfer Object,数据传输对象。它是一个用来传输数据的对象,通常用来传输数据给服务端或者从服务端传输数据给客户端。在 NestJS 中,DTO 是一个用来定义数据结构的类,用来规范数据的传输。

例如 RegisterUserDto 类用来定义用户注册时需要的信息:

ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { IsEmail, IsNotEmpty, IsString } from 'class-validator'

export class RegisterUserDto {
@IsEmail()
emailAddress: string

@IsString()
@MinLength(6)
username: string

@IsString()
@MinLength(8)
password: string

@IsString()
birthYear: string

@IsString()
birthMonth: string

@IsString()
birthDay: string
}

这里使用了多个装饰器来定义每个属性的类型,有助于进行数据验证。

UserSchema 则是用来定义用户模型的,是 MongoDB 要求的数据结构:

ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
import { Document } from 'mongoose'

export type UserDocument = User & Document

@Schema()
export class User {
@Prop({ required: true, unique: true })
username: string

@Prop({ required: false, unique: true })
emailAddress: string

@Prop({ required: true })
password: string

@Prop({ required: false })
birthYear: string

@Prop({ required: false })
birthMonth: string

@Prop({ required: false })
birthDay: string
}

export const UserSchema = SchemaFactory.createForClass(User)

@Prop 装饰器用来定义一个属性,@Schema 装饰器用来定义一个模式。UserDocument 是一个用户文档,继承了 UserDocument

这里我们定义的属性和 RegisterUserDto 中的属性是一样的,但 UserSchema 注重于定义会被存储在数据库中的数据结构,RegisterUserDto 注重于定义会在客户端和服务端之间传输的数据结构。

最终,我们在 UsersModule 中导入这些模块:

ts
1
2
3
4
5
6
7
8
9
10
11
12
13
import { Module } from '@nestjs/common'
import { MongooseModule } from '@nestjs/mongoose'
import { JwtService } from '@nestjs/jwt'
import { User, UserSchema } from './user.schema'
import { UsersControllers } from './users.controllers'
import { UsersService } from './users.service'

@Module({
imports: [MongooseModule.forFeature([{ name: User.name, schema: UserSchema }])],
controllers: [UsersControllers],
providers: [UsersService, JwtService],
})
export class UsersModule {}

不要忘了,新建的模块要在 AppModule 中导入:

ts
1
@Module({ imports: [UsersModule] })