上篇文章我们迁移了整个项目到NestJS,这篇文章我们将实现JWT验证。

一些改动

为了项目的可维护性,我对项目的目录结构进行了一些调整。

首先是把所有注册登录相关的代码都从users文件夹放到auth文件夹中,只给users文件夹留下用户信息的相关代码。

UsersModule

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { Module } from '@nestjs/common'
import { MongooseModule } from '@nestjs/mongoose'
import { User, UserSchema } from './user.schema'
import { UsersController } from './users.controller'
import { UsersService } from './users.service'

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

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
import { Injectable } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { Model } from 'mongoose'
import { User } from './user.schema'

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

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

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

async findAll(): Promise<User[]> {
return this.usersModel.find().exec()
}
}

UsersController新添了一个GET路由,用于根据用户名查询用户信息,后续会用到:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { Controller, Get, Param } from '@nestjs/common'
import { UsersService } from './users.service'
import { Public } from '../common/decorator/public.decorator'

@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}

@Public()
@Get(':username')
async findOne(@Param('username') username: string) {
return this.usersService.findByUsername(username)
}
}

假设要查询的用户的用户名是dummy,那么请求的URL就是/api/users/dummy

客户端的文件改动则是将大部分文件从components目录中拿了出来,例如Redux相关的文件被统一放到了redux目录、和components同级。

同时我还删除了上一篇文章中写的Guard组件,因为在这篇文章中我们会要写功能更为复杂的升级版路由守卫

JWT生成

JWT是一种用于在网络上传输信息的简洁方法。对比Session和Cookie,JWT的优势在于不需要在服务端存储用户信息,而是通过加密的方式将用户信息存储在Token中,然后在客户端存储这个Token。

有关JWT的更多信息,可以自行查阅资料。

JWT验证的实现很大程度上参照了稀土掘金上的一篇文章:NestJS 登录功能:基于 JWT 的身份验证

.env文件中配置:

1
2
3
4
JWT_SECRET=secret
JWT_TOKEN_AUDIENCE=localhost:4000
JWT_TOKEN_ISSUER=localhost:4000
JWT_ACCESS_TOKEN_TTL=3600

AppModule中配置ConfigModule,这里需要用到Joi包(用于验证环境变量):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { ConfigModule } from '@nestjs/config'
import * as Joi from 'joi'

@Module({
imports: [
ConfigModule.forRoot({
validationSchema: Joi.object({
JWT_SECRET: Joi.string().required(),
JWT_TOKEN_AUDIENCE: Joi.string().required(),
JWT_TOKEN_ISSUER: Joi.string().required(),
JWT_ACCESS_TOKEN_TTL: Joi.number().default(3600),
}),
}),
],
// ...
})

使用Joi包的目的是为了验证环境变量是否存在,以及是否符合预期的类型。

假设环境变量JWT_SECRET不存在,那么Joi会抛出一个错误,阻止应用程序启动。

新建一个jwt.config.ts文件,用于配置JwtModule

1
2
3
4
5
6
7
8
9
10
import { registerAs } from '@nestjs/config'

export default registerAs('jwt', () => {
return {
secret: process.env.JWT_SECRET,
audience: process.env.JWT_TOKEN_AUDIENCE,
issuer: process.env.JWT_TOKEN_ISSUER,
accessTokenTtl: parseInt(process.env.JWT_ACCESS_TOKEN_TTL ?? '3600', 10),
}
})

接着在AuthModule引入这个配置:

1
2
3
4
5
6
7
8
9
10
import { JwtModule } from '@nestjs/jwt'
import jwtConfig from '../common/config/jwt.config'

@Module({
imports: [
ConfigModule.forFeature(jwtConfig),
JwtModule.registerAsync(jwtConfig.asProvider()),
],
// ...
})

回到我们用户登陆的逻辑。当服务端验证用户信息后,我们需要生成一个Token,然后返回给客户端。

新建一个active-user-data.interface.ts文件,用于定义Token中的负载:

1
2
3
4
export interface ActiveUserData {
sub: number
name: string
}

在JWT中,sub(主题,通常是ID)和name是常见的有效载荷字段,定义这些字段有助于应用进行身份验证和授权、帮助服务器识别发送请求的用户。

AuthService中添加两个方法,用于生成Token和验证Token:

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
// ...
import { JwtService } from '@nestjs/jwt'
import { ConfigType } from '@nestjs/config'
import { User } from '../users/user.schema'
import jwtConfig from '../common/config/jwt.config'
import { ActiveUserData } from './interfaces/active-user-data.interface'

@Injectable()
export class AuthService {
constructor(
private readonly jwtService: JwtService,
@Inject(jwtConfig.KEY)
private readonly jwtConfiguration: ConfigType<typeof jwtConfig>,
) {}

async generateTokens(user: User) {
return await this.signToken<Partial<ActiveUserData>>(user._id, {name: user.username})
}

private async signToken<T>(userId: number, payload?: T) {
return await this.jwtService.signAsync(
{
sub: userId,
...payload,
},
{
secret: this.jwtConfiguration.secret,
audience: this.jwtConfiguration.audience,
issuer: this.jwtConfiguration.issuer,
expiresIn: this.jwtConfiguration.accessTokenTtl,
},
)
}

// ...
}

signToken 方法接收一个用户ID和一个负载对象,然后使用JwtServicesignAsync方法生成一个Token。这里使用的jwtConfig是我们之前配置的JWT配置。

generateTokens 方法接收一个用户对象,然后调用signToken方法生成一个Token。

login方法中调用generateTokens方法:

1
2
3
4
5
6
7
8
9
10
11
12
async login(loginUserDto: LoginUserDto) {
const user = await this.usersModel.findOne({
username: loginUserDto.username,
})
if (!user) throw new UnauthorizedException('The username is invalid')

const passwordValid = await bcrypt.compare(loginUserDto.password, user.password)
if (!passwordValid) throw new UnauthorizedException('The password is invalid')

// 返回Token
return await this.generateTokens(user)
}

AuthController也需要做一些改动:

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
@Post('login')
async login(@Body() loginUserDto: LoginUserDto, @Res() res: Response) {
try {
const resultToken = await this.authService.login(loginUserDto)

res.status(HttpStatus.OK).json({
status: '00000',
message: 'User logged in successfully',
token: resultToken, // <-- 向客户端返回Token
})
} catch (error) {
console.error(error)
if (error instanceof UnauthorizedException) {
res.status(HttpStatus.UNAUTHORIZED).json({
status: 'U0202',
message: 'The username or password is invalid',
})
} else {
res.status(HttpStatus.BAD_REQUEST).json({
status: 'U0200',
message: 'Failed to log in user due to an unknown error',
})
}
}
}

客户端接收到Token后,可以将Token存储在localStorage中,以便后续请求时使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const loginUser = (userData: User, navigate: (path: string) => void) => {
return async (dispatch: Dispatch) => {
try {
const response = await UsersService.login(userData);
const data = response.data;
if (data.status === "00000") {
localStorage.setItem("jwtToken", data.token);
dispatch(setCurrentUser({
...userData,
access_token: data.token,
}));
console.log(data);
navigate("/");
} else console.log(data.message);
} catch (error) {
console.error(error);
}
}
}

JWT验证

生成Token后,我们需要在每次请求时验证Token。

服务端新建一个access-token.guard.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
40
41
import { CanActivate, ExecutionContext, Inject, Injectable, UnauthorizedException } from '@nestjs/common'
import { ConfigType } from '@nestjs/config'
import { JwtService } from '@nestjs/jwt'
import { Reflector } from '@nestjs/core'
import { Request } from 'express'
import { REQUEST_USER_KEY } from '../../common'
import jwtConfig from '../../common/config/jwt.config'
import { IS_PUBLIC_KEY } from '../../common/decorator/public.decorator'

@Injectable()
export class AccessTokenGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly jwtService: JwtService,
@Inject(jwtConfig.KEY)
private readonly jwtConfiguration: ConfigType<typeof jwtConfig>,
) {}

async canActivate(context: ExecutionContext): Promise<boolean> {
const isPublic = this.reflector.get(IS_PUBLIC_KEY, context.getHandler())
if (isPublic) return true

const request = context.switchToHttp().getRequest()
const token = this.extractTokenFromHeader(request)
if (!token) throw new UnauthorizedException()

try {
request[REQUEST_USER_KEY] = await this.jwtService.verifyAsync(token, this.jwtConfiguration)
} catch (error) {
throw new UnauthorizedException()
}

return true
}

private extractTokenFromHeader(request: Request): string | undefined {
const authorization = request.headers['authorization']
const [, token] = authorization?.split(' ') ?? []
return token
}
}

AccessTokenGuard类实现了CanActivate接口,该接口用于验证请求是否可以通过。

extractTokenFromHeader方法用于从请求头中提取Token。

上述代码中导入的两个新文件分别是common/index.tscommon/decorator/public.decorator.ts

1
2
// common/index.ts
export const REQUEST_USER_KEY = 'user'

这个常量在request对象中用作键、用于存储已验证的JWT的解码信息。具体来说,当一个请求到达并通过canActivate方法时,会从请求的Authorization头中提取JWT。如果JWT存在且有效(也就是能够被JwtServiceverifyAsync方法验证),那么JWT的解码信息就会被存储在request[REQUEST_USER_KEY]中。

这样做的目的是为了在后续的请求处理中,可以直接通过request[REQUEST_USER_KEY]来获取JWT的解码信息,无需再次验证。

1
2
3
4
5
// common/decorator/public.decorator.ts
import { SetMetadata } from '@nestjs/common'

export const IS_PUBLIC_KEY = 'isPublic'
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true)

首先我们要清楚一个概念。在我们启用了JWT验证的情况下,会导致所有的请求都需要携带JWT Token。但是有些请求我们并不想要求用户携带Token,比如说注册和登录请求(因为用户还没有Token)。这时我们就需要一个装饰器来标记这些请求是公开的。

Public装饰器标记了一个请求是公开的,这样在AccessTokenGuard中就可以根据这个标记来判断是否需要验证Token。

有了Public装饰器后我们就可以在AuthController中标记registerlogin路由是公开的:

1
2
3
4
5
6
7
8
9
import { Public } from '../common/decorator/public.decorator'

// ...

@Public()
@Post('register')

@Public()
@Post('login')

NestJS的项目每当新建一个东西时,都需要在app.module.ts中引入。这里我们导入AccessTokenGuard

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { APP_GUARD } from '@nestjs/core'
import { AccessTokenGuard } from './guards/access-token.guard'

@Module({
// ...
providers: [
{
provide: APP_GUARD,
useClass: AccessTokenGuard,
},
// ...
],
})
export class AppModule {}

APP_GUARD用于告诉NestJS我们要使用AccessTokenGuard这个守卫。

有了这个守卫后,每次请求都会被验证Token。如果请求中没有Token或者Token无效,那么请求就会被拒绝。

客户端判断用户是否登录

未登录的用户是不能访问某些页面的,同理,已登录的用户也不能访问登录和注册页面。

我们先自定义一个Hook来检查用户是否已经进行了身份验证。新建一个useAuth文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { useEffect, useState } from 'react'

export const useAuth = () => {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [isAuthChecked, setIsAuthChecked] = useState(false);

useEffect(() => {
const token = localStorage.getItem("jwtToken");
if (token) {
setIsAuthenticated(true);
} else {
setIsAuthenticated(false);
}
setIsAuthChecked(true);
}, []);

return { isAuthenticated, isAuthChecked };
}

这个Hook会检查localStorage中是否存在jwtToken,如果存在则认为用户已经登录、设置isAuthenticatedtrue,否则设置为false

为了不重复进行这个检查,我们还设置了一个isAuthChecked状态,用于标记用户是否已经进行了身份验证。

在我们当前的项目中,身份验证无非有两种必需导向用户到不同页面的情况:

  1. 用户未登录,但是访问了首页,这时我们需要将用户导向登录页面。
  2. 用户已经登录,但是访问了登录或注册页面,这时我们需要将用户导向首页。

我们先解决第一个问题。新建一个ProtectedRoute组件(意味着只有登录用户才能访问的路由):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
export const ProtectedRoute: React.FC<RouteProps> = ({ children }) => {
const { isAuthenticated, isAuthChecked } = useAuth()
const navigate = useNavigate()

useEffect(() => {
if (!isAuthenticated && isAuthChecked) {
if (window.location.pathname === '/channels/@me') {
navigate('/login', { replace: true })
}
}
}, [isAuthenticated, isAuthChecked, navigate])

if (!isAuthChecked) return null

return <>{isAuthenticated ? children : <Navigate to="/login" replace />}</>
}

App.tsx中使用这个组件:

1
2
3
4
5
6
7
8
9
10
11
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={ <Navigate to="/channels/@me" replace /> } />
<Route path="/channels/@me/*" element={ <ProtectedRoute><Home /></ProtectedRoute> } /> {/* <--- 这里 */}
{/* ... */}
</Routes>
</BrowserRouter>
);
}

当用户尝试访问/channels/@me页面或其子页面时,如果用户未登录,那么就会被导向登录页面。

第二个问题同理,只要写一个逻辑相反的UnauthenticatedRoute组件即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
export const UnauthenticatedRoute: React.FC<RouteProps> = ({ children }) => {
const { isAuthenticated, isAuthChecked } = useAuth()
const navigate = useNavigate()

useEffect(() => {
if (isAuthenticated && isAuthChecked) {
navigate('/channels/@me', { replace: true })
}
}, [isAuthenticated, isAuthChecked, navigate])

if (!isAuthChecked) return null

return <>{!isAuthenticated ? children : <Navigate to="/channels/@me" replace />}</>
}

使用方法也是一样的:

1
2
3
4
5
6
7
8
9
10
11
function App() {
return (
<BrowserRouter>
<Routes>
// ...
<Route path="/login" element={ <UnauthenticatedRoute><Login /></UnauthenticatedRoute> } />
<Route path="/register" element={ <UnauthenticatedRoute><Register /></UnauthenticatedRoute> } />
</Routes>
</BrowserRouter>
);
}

ESLint和Prettier配置

我开发这个项目一直用的是WebStorm。最近换设备后,一进入客户端的目录后都会弹出错误,说是ESLint配置冲突,就决定重新为两个目录配置ESLint和Prettier。

服务端因为新建项目时就自带了.eslintrc.js.prettierrc文件,所以只需要在客户端新建这两个文件即可。

客户端下安装必要的包:

1
npm install --save-dev eslint-plugin-prettier eslint-config-prettier

新建.eslintrc.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
module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
project: 'tsconfig.json', // <--- 这个文件是WebStorm新建项目时自动生成的
tsconfigRootDir: __dirname,
sourceType: 'module',
},
plugins: ['@typescript-eslint/eslint-plugin', 'react'],
extends: [
'plugin:@typescript-eslint/recommended',
'plugin:react/recommended',
'plugin:prettier/recommended',
],
root: true,
env: {
browser: true,
jest: true,
es6: true,
},
rules: {
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'react/prop-types': 'off',
},
};

.prettierrc文件我直接搬的服务端的配置,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{
"singleQuote": true,
"trailingComma": "all",
"endOfLine": "lf",
"arrowParens": "always",
"bracketSameLine": true,
"bracketSpacing": true,
"embeddedLanguageFormatting": "auto",
"htmlWhitespaceSensitivity": "css",
"insertPragma": false,
"jsxSingleQuote": false,
"printWidth": 120,
"proseWrap": "never",
"quoteProps": "as-needed",
"requirePragma": false,
"semi": false,
"tabWidth": 2,
"useTabs": false,
"vueIndentScriptAndStyle": false,
"singleAttributePerLine": false
}

重启WebStorm后,错误提示消失,一切正常。

其他

我长期没有更新的原因非常简单,我于三月底的时候遇到了一个不知道是什么BUG的BUG。无论怎么修改代码,客户端都无法连接到服务端,而服务端也没有报告详细的错误信息。那时候直接摆烂了,也没有再继续开发。

最近重新看了一下代码,发现问题竟然是服务端的logger.middleware.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) { // <--- res参数被我删掉了
console.log('Request...', req.method, req.originalUrl)
next()
}
}

那么为什么这个异常没有被服务端捕获呢?因为我的全局异常过滤器也没有写好!

这是原先的全局异常过滤器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common'

@Catch()
export class AnyExceptionFilter implements ExceptionFilter {
catch(exception: any, host: ArgumentsHost) {
const ctx = host.switchToHttp()
const response = ctx.getResponse()
const request = ctx.getRequest()

const status = exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR

const message = exception instanceof HttpException ? exception.getResponse() : 'Internal server error'

response.status(status).json({
statusCode: status,
timestamp: new Date().toISOString(),
path: request.url,
message: message,
})
}
}

能发现问题没有?statusmessage都被我写死了!如果异常不是HttpException,那么这两个值就都只会是500Internal server error

这是我修改后的全局异常过滤器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common'
import { Request, Response } from 'express'

@Catch()
export class AnyExceptionFilter implements ExceptionFilter {
catch(exception: any, host: ArgumentsHost) {
const ctx = host.switchToHttp()
const response = ctx.getResponse<Response>()
const request = ctx.getRequest<Request>()

const status = exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR

const message = exception instanceof HttpException ? exception.getResponse() : exception

console.error(exception)

response.status(status).json({
statusCode: status,
timestamp: new Date().toISOString(),
path: request.url,
message: message,
})
}
}

现在,无论异常是什么,都会被正确地捕获并返回给客户端。

希望大家以我为戒,不要犯我这样的错误。

新添加但没有完成的东西

服务端新增了Socket模块,毕竟完成了注册登录、JWT生成验证等功能后,下一个就是老生常态的消息接收发送。既然我们使用的是Socket.IO,那就要新建一个Socket模块。

SocketModule

1
2
3
4
5
6
7
8
import { Module } from '@nestjs/common';
import { SocketService } from './socket.service';
import { SocketGateway } from './socket.gateway';

@Module({
providers: [SocketGateway, SocketService],
})
export class SocketModule {}

SocketService

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { Injectable } from '@nestjs/common'
import { Server } from 'socket.io'

@Injectable()
export class SocketService {
private server: Server

initialize(server: Server) {
this.server = server
}

sendMessage(event: string, message: any) {
this.server.emit(event, message)
}
}

initialize方法用于初始化Socket服务器。

sendMessage方法用于向所有连接的客户端发送消息。

这里讲一下Controller和Gateway的区别。

在传统的HTTP请求/响应模型中,Controller负责处理来自客户端的请求并返回响应。然而,我们在使用WebSocket时,不再有请求和响应的概念,而是有事件和消息的概念。这时,Gateway(网关)就是用来处理这些事件和消息的。

SocketGateway

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
import { WebSocketGateway, WebSocketServer, SubscribeMessage, MessageBody, OnGatewayInit } from '@nestjs/websockets'
import { Server, Socket } from 'socket.io'
import { SocketService } from './socket.service'

@WebSocketGateway(3001, {
allowEIO3: true,
cors: {
origin: process.env.CLIENT_ORIGIN || 'http://localhost:3000',
credentials: true,
},
})
export class SocketGateway implements OnGatewayInit {
@WebSocketServer()
server: Server

constructor(private readonly socketService: SocketService) {}

afterInit(server: Server) {
this.socketService.initialize(server)
}

@SubscribeMessage('privateMessageSent')
handlePrivateMessage(@MessageBody() data: any, client: Socket) {
console.log('Received private message:', data, 'from', client)
}
}

SocketGateway中,我们使用this.socketService.initialize(server)初始化了Socket服务器,然后使用@SubscribeMessage装饰器来监听客户端发送的消息。

我们可以在客户端发送消息时调用socket.emit('privateMessageSent', message)。来看一下我的输入框PrivateMessageTextBox组件:

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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
import React, { useState, KeyboardEvent, ChangeEvent, FormEvent } from "react";
import { Icon } from "@iconify/react";
import socket from "../../redux/actions/messageActions";
import { UsersService } from '../../redux/actions/serverConnection'

interface PrivateMessageTextBoxProps {
receiverUsername: string;
}

const PrivateMessageTextBox: React.FC<PrivateMessageTextBoxProps> = ({ receiverUsername }) => {
const [message, setMessage] = useState<string>("");

const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
}

const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
setMessage(e.target.value);
}

const handleSendMessage = async (e?: FormEvent) => {
e && e.preventDefault();

const senderId = localStorage.getItem("userId");
const response = await UsersService.getUserByUsername(receiverUsername);
const receiver = response.data;

socket.emit("privateMessageSent", {
id: `${socket.id}${Math.random()}`,
senderId: senderId,
receiverId: receiver._id,
text: message
});
setMessage("");
}

return (
<form className="px-2 m-3" onSubmit={ handleSendMessage }>
<div
className="w-100 p-0 m-0"
style={{ marginBottom: '24px', backgroundColor: 'rgba(56, 58, 64)', textIndent: '0', borderRadius: '8px' }}
>
<div
className="overflow-x-hidden overflow-y-scroll"
style={{ borderRadius: '8px', backfaceVisibility: 'hidden', scrollbarWidth: 'none' }}
>
<div className="d-flex position-relative">
<span className="position-sticky" style={{ flex: '0 0 auto', alignSelf: 'stretch' }}>
<Icon
icon="bi:plus-circle-fill"
className="position-sticky w-auto m-0"
style={{
height: '44px', padding: '10px 16px', top: '0', marginLeft: '-16px',
background: 'transparent', color: 'rgba(181, 186, 193)', border: '0'
}}
/>
</span>

<span
className="p-0 fs-6 w-100 position-relative"
style={{
background: 'transparent', resize: 'none', border: 'none', appearance: 'none',
fontWeight: '400', lineHeight: '1.375rem', height: '44px', minHeight: '44px',
boxSizing: 'border-box', color: 'rgba(219, 222, 225)',
}}
>
<textarea
autoCapitalize="none" autoComplete="off" autoCorrect="off" autoFocus={ true }
placeholder="Text @dummy" spellCheck="true"
className="position-absolute overflow-hidden"
value={ message }
onChange={ handleChange }
onKeyDown={ handleKeyDown }
style={{
border: 'none', outline: 'none', resize: 'none',
paddingBottom: '11px', paddingTop: '11px', paddingRight: '10px',
left: '0', right: '10px', background: 'transparent',
caretColor: 'rgba(219, 222, 225)', color: 'rgba(219, 222, 225)'
}}
/>
</span>
</div>
</div>
</div>
</form>
)
}

export default PrivateMessageTextBox;

这个组件是一个输入框,用户输入消息后按下回车键就会发送消息。发送的消息会被SocketGateway监听到,然后打印到控制台。

其他的地方可以忽略掉不看,只需要重点看handleSendMessage方法。这个方法会向服务器发送一个privateMessageSent事件,事件的数据是一个对象,包含了消息的ID、发送者ID、接收者ID和消息内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const handleSendMessage = async (e?: FormEvent) => {
e && e.preventDefault();

const senderId = localStorage.getItem("userId");
const response = await UsersService.getUserByUsername(receiverUsername);
const receiver = response.data;

socket.emit("privateMessageSent", {
id: `${socket.id}${Math.random()}`,
senderId: senderId,
receiverId: receiver._id,
text: message
});
setMessage("");
}

这里提一嘴userId的来处。

在服务端的AuthControllerlogin方法中,我们向客户端发送了一个Token,但我们也可以发送其他东西,比如用户的ID。

1
2
3
4
5
6
7
8
const user = await this.usersService.findByUsername(loginUserDto.username)

res.status(HttpStatus.OK).json({
status: '00000',
message: 'User logged in successfully',
token: resultToken,
userId: user._id,
})

在客户端也要写对应的接收逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const loginUser = (userData: User, navigate: (path: string) => void) => {
return async (dispatch: Dispatch) => {
try {
const response = await UsersService.login(userData)
const data = response.data
if (data.status === '00000') {
localStorage.setItem('jwtToken', data.token)
localStorage.setItem('userId', data.userId) // <--- 这里
dispatch(
setCurrentUser({
...userData,
access_token: data.token,
id: data.userId, // <--- 也把ID存储到Redux里,说不定未来会用到呢
}),
)
console.log(data)
navigate('/')
} else console.log(data.message)
} catch (error) {
console.error(error)
}
}
}

消息从客户端发送到服务端后,服务端会打印出消息的内容。

一个简单的消息发送就这样完成了。

接下来还需要完成消息的接收、存储和展示,这个就留到下一篇文章再说了。