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

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

前端

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

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

很多文件都不需要改动,例如App.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

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

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

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

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

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文件来存放会被多个文件引用的接口:

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的话就是这样:

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的话,要先创建一个服务:

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,分别用于注册和登录。当我们需要向后端发送请求时,只需要调用这些方法即可:

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语句,用于捕获请求失败的情况。

登录和注册

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

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

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

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接口中,但是是可选的,不符合注册的要求。

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

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

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

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

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

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

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

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

页面跳转

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

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组件的保护之下。

其他

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

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

转换到TypeScript后:

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函数

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

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

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

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

后端

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

我目前有的依赖:

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

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)

控制器

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

1
2
3
4
5
6
7
8
9
10
11
12
13
// app.controller.ts
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,那么就要写成:

1
2
3
@Controller('users')

@Get('register')

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

服务

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

1
2
3
4
5
6
7
8
9
// app.service.ts
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

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的模块。

首先导入依赖:

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

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

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中间件是一个自定义的中间件,用来记录请求日志:

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:

1
nest g module users

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

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

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的控制器:

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

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

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()装饰器用来获取响应对象。

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里到底是什么样的呢?

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类用来定义用户注册时需要的信息:

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要求的数据结构:

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中导入这些模块:

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中导入:

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