上篇文章我们迁移了整个项目到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和一个负载对象,然后使用JwtService
的signAsync
方法生成一个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' ) 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, }) } 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.ts
和common/decorator/public.decorator.ts
:
1 2 export const REQUEST_USER_KEY = 'user'
这个常量在request
对象中用作键、用于存储已验证的JWT的解码信息。具体来说,当一个请求到达并通过canActivate
方法时,会从请求的Authorization
头中提取JWT。如果JWT存在且有效(也就是能够被JwtService
和verifyAsync
方法验证),那么JWT的解码信息就会被存储在request[REQUEST_USER_KEY]
中。
这样做的目的是为了在后续的请求处理中,可以直接通过request[REQUEST_USER_KEY]
来获取JWT的解码信息,无需再次验证。
1 2 3 4 5 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
中标记register
和login
路由是公开的:
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
,如果存在则认为用户已经登录、设置isAuthenticated
为true
,否则设置为false
。
为了不重复进行这个检查,我们还设置了一个isAuthChecked
状态,用于标记用户是否已经进行了身份验证。
在我们当前的项目中,身份验证无非有两种必需导向用户到不同页面的情况:
用户未登录,但是访问了首页,这时我们需要将用户导向登录页面。
用户已经登录,但是访问了登录或注册页面,这时我们需要将用户导向首页。
我们先解决第一个问题。新建一个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' , 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 ) { 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, }) } }
能发现问题没有?status
和message
都被我写死了!如果异常不是HttpException
,那么这两个值就都只会是500
和Internal 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
的来处。
在服务端的AuthController
的login
方法中,我们向客户端发送了一个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 , }), ) console .log (data) navigate ('/' ) } else console .log (data.message ) } catch (error) { console .error (error) } } }
消息从客户端发送到服务端后,服务端会打印出消息的内容。
一个简单的消息发送就这样完成了。
接下来还需要完成消息的接收、存储和展示,这个就留到下一篇文章再说了。