基于React+Nodejs的多人聊天室

qq_38425902 2022-01-19 12:06:37

概览

项目简介

基于React+Nodejs+Mongodb的实时多人聊天室,主要完成了以下几个功能:

  1. 登录/注册功能
  2. 编辑个人信息
  3. 搜索查找聊天室
  4. 添加聊天室
  5. 服务器端保存群聊信息,新加入的用户可以查看之前的聊天信息
  6. 未读消息提醒
  7. 发送群聊消息

技术栈选型

前端:react+antd+mobx

后端:nodejs+typescript+express+websocket

数据库:mongodb

React

React是Facebook开发的一款声明式的UI库。目前来说React是使用人数最多的前端View层框架,其不仅仅局限于浏览器环境的React-dom,还有用于客户端的跨平台UI库React-native。经过多年的沉淀,React的生态已经非常成熟,社区非常庞大,拥有大量的库可供使用。

与Vue的双向绑定相比,React是单项数据流,更加强调函数式编程,提倡无副作用的Pure Function,UI=F(State)的思想很容易组织和管理状态。

双向绑定虽然更加方便,但状态多起来之后,双向绑定很容易导致状态的变化无法追踪,状态管理也会越来越混乱。引入vuex之后,通过action来追踪状态变更,但复杂度瞬间上升,且双向绑定方便的优势也没那么大。比较之下,单向数据流可以本身就使状态的变更可追踪,然后通过observe模式关注自己关心的数据变更即可。

同时,基于React的各种UI框架也很多,如:material-ui、antd

综上,最终选择了React作为UI层框架,antd为组件库。

Nodejs

Nodejs利用js的语法提供了一组原生平台功能的Api,可以利用这些Api提供浏览器环境无法实现的功能,如:web服务器。

使用Nodejs可以方便的和前端开发统一语言,避免前后端切换编程语言。Web框架使用express,也可以方便的结合websocket。

由于js太过动态,弱类型,代码多起来之后很难维护,所以这里选择使用Javascript的超集Typescript,其在js的基础之上添加了类型的支持,可以很方便的进行数据建模和类型限制,IDE也可以更加准确、智能的提示。

Mongodb

MongoDB是一个基于分布式文件存储 [1] 的数据库。由C++语言编写。旨在为WEB应用提供可扩展的高性能数据存储解决方案。

MongoDB是一个介于关系数据库和非关系数据库之间的产品,是非关系数据库当中功能最丰富,最像关系数据库的。它支持的数据结构非常松散,是类似json的bson格式,因此可以存储比较复杂的数据类型。Mongo最大的特点是它支持的查询语言非常强大,其语法有点类似于面向对象的查询语言,几乎可以实现类似关系数据库单表查询的绝大部分功能,而且还支持对数据建立索引。

相比于Mysql,Mongodb可以提供更高的查询性能和更加灵活的组织数据的能力。传统的关系型数据库不支持对象形式的组织数据,只能通过表的关联来表达复杂的对象属性。而Mongodb通过类json的数据组织方式提供了强大的数据组织能力,可以组合复杂的对象结构,灵活的查询能力等。

同时,Mongodb会把数据优先存放在内存之中,可以提供更加快速、强大的查询能力,只不过相比Mysql会占用更多的内存。

前端设计

前端采用antd组件库,由于我们使用的是SPA(single page application),这就意味着我们的整个功能都是在一个页面中完成的,所以就要采用一种页面内切换组件的路由方法。这里用的是react-router:

const App = (props)=> {
    return (
        <Router>
            <div className="App">
                <Routes>
                    <Route path="/" element={<Home/>}/>
                    <Route path="/login" element={<LoginPage />} />
                    <Route path="*" element={<NotFoundPage/>}/>
                </Routes>
            </div>
        </Router>
    );
}

export default App;

登录页面:

img

代码实现

import { Row, Col, Form, Input, Button, Checkbox, Card, Alert } from "antd"
import { useState } from "react"
import { useLocation, useNavigate } from "react-router-dom";
import { ApiException } from "../api/http";

import { loadAccessToken } from "../api/UserApi";

const LoginForm = (prop) => {

    const navigate = useNavigate();
    const location = useLocation();

    const [alertInfo, setAlertInfo] = useState({
        msg: '账号或密码错误',
        show: false
    });
    const user = {
        username: '',
        password: ''
    }

    const onFinish = async () => {
        try {
            const token = await loadAccessToken(user.username, user.password)
            navigate(location.state ? location.state : '/',
                {replace: true});
        } catch (e) {
            if(e instanceof ApiException){
                setAlertInfo({
                    msg: e.msg,
                    show: true
                })
            }
        }
    }

    return <Form
        name="basic"
        labelCol={{ span: 8 }}
        wrapperCol={{ span: 16 }}
        initialValues={{ remember: true }}
        onFinish={onFinish}
        autoComplete="off"
    >
        {alertInfo.show ? <Alert message={alertInfo.msg} type="error"
            closable onClose={() => setAlertInfo({ ...alertInfo, show: false })} />
            : <></>}

        <Form.Item
            label="Username"
            name="username"
            style={{
                marginTop: '10px'
            }}
            rules={[{ required: true, message: 'Please input your username!' }]}
        >
            <Input onChange={(e) => user.username = e.target.value} />
        </Form.Item>

        <Form.Item
            label="Password"
            name="password"
            rules={[{ required: true, message: 'Please input your password!' }]}
        >
            <Input.Password onChange={(e) => user.password = e.target.value} />
        </Form.Item>

        <Form.Item name="remember" valuePropName="checked" wrapperCol={{ offset: 8, span: 16 }}>
            <Checkbox>Remember me</Checkbox>
        </Form.Item>

        <Form.Item wrapperCol={{ offset: 8, span: 16 }}>
            <Button type="primary" htmlType="submit">
                登录
            </Button>
        </Form.Item>
    </Form>
}

const LoginPage = (props) => {
    return <Row justify="center" style={{
        minHeight: '100vh',
        backgroundColor: '#f2f2f2'
    }}>
        <Col span={8} style={{
            marginTop: '20vh',
            minWidth: '300px'
        }}>
            <Card style={{
                paddingTop: '20px'
            }}>
                <LoginForm />
            </Card>
        </Col>
    </Row>
}

export default LoginPage

聊天页面:

img

代码:

全部以组件化的形式进行分割,页面只是组件的组合。

const HomePage = (props) => {
    useEffect(()=>{
        initServerConnect();
    });

    const sideWidth = 350;
    return <Layout>
        <Sider theme='light' width={sideWidth} style={{
            backgroundColor: 'rgb(224,208,233)',
            height: '100vh',
            overflowY: 'hidden',
            overflowX: 'hidden'
        }}>
            <div style={{
                padding: '10px',
                overflowY: 'auto',
                maxHeight: '100vh',
                width: sideWidth
            }}>
                <Brand />
                <FriendList />
            </div>
        </Sider>
        <Content>
            <RightContent/>
        </Content>
    </Layout>
}

const Messager = (props) => {
    return <Layout style={{ height: '100vh' }}>
        <Header style={{
            backgroundColor: 'white'
        }}>
            <Top />
        </Header>
        <Content>
            <MessageContent/>
        </Content>
        <Footer style={{
            padding: 0,
        }}>
            <InputArea />
        </Footer>
    </Layout>
}

后端设计

使用typescript进行数据建模,这里和数据库的表是对应的,简单的列举几个:

/**
 * 对应数据库的数据类型
 */

export interface Group{
    _id: string;
    name: string;
    //人数
    people: number;
    //账户id
    ownerId: string;
    createTime: string;
    modifyTime: string;
    //简介
    description: string;
}

export interface Message{
    //主键
    _id: string;
    //发送者id
    sender: string;
    //接收者id,群聊消息即为群的id
    reciver: string;
    //聊天消息类型
    type: number;
    content: string;
    //创建时间
    createTime: string;
}

//用户资料
export interface User{
    _id: string;
    name: string;
    sex: number;
    age: number;
    //头像url
    avater: string;
    description: string;
    modifyTime: string;
}

//账户
export interface Account{
    _id: string;
    username: string;
    password: string;
    createTime: string;
    modifyTime: string;
    //用来表示一些身份,禁用启用的flag位
    flag: number;
}

路由设计

处理跨域:

app.all("*", (req, res, next) => {
    res.header('Access-Control-Allow-Origin', '*');
    res.header('Access-Control-Allow-Headers', '*');
    res.header('Access-Control-Allow-Methods', '*');
    res.header('Content-Type', 'application/json;charset=utf-8');
    next();
})

认证设计

前后端分离,跨域时不好携带cookie,所以我们采用token的形式,直接用jwt(jsonwebtoken)储存一些必要的信息,通过请求头直接携带过来,用于后端鉴权。

//签发jwt
export async function signJwt(payload: any) {
    return new Promise((resolve,reject)=>{
        jwt.sign(payload,privateKey,(error: Error|null, token: string|undefined)=>{
            if(error == null){
                resolve(token);
            } else {
                reject(error);
            }
        })
    });
}
//验证jwt
export async function verify<T>(token: string): Promise<T>{
    return new Promise((resolve,reject)=>{
        jwt.verify(token, privateKey, (error: Error | null, decoded: any)=>{
            if(error == null){
                resolve(decoded);
            } else {
                reject(error);
            }
        });
    });
}

登录签发token:

export async function login(req: Request, res: Response) {
    const { username, password } = req.body as { username: string, password: string };

    const account = await findAccountByUsername(username);

    if (account === null) {
        res.json(ResponseBody.failure("账号不存在"))
        return
    }
    if (!(await pwdMatch(password, account.password))) {
        res.json(ResponseBody.failure("账号或密码不正确"))
        return
    }
    res.json(ResponseBody.success({
        token: await signJwt({
            id: account._id,
            username: account.username
        })
    }));
}

查询账户密码:

其中,为了避免频繁创建数据库连接,自己做了池化操作:

export async function findAccountByUsername(username: string): Promise<Account | null> {
    const account = await execute((database: Db) => {
        return database.collection<Account>("account").findOne({
            username: username
        })
    });
    return account;
}

连接池:

class MongoClientWrapper extends MongoClient {

    private pool: ConnectionPool

    using: boolean = false

    next?: MongoClientWrapper;

    prev?: MongoClientWrapper;

    constructor(pool: ConnectionPool, url: string) {
        super(url);
        this.pool = pool;
    }

    //重写close方法
    close(): Promise<void>;
    close(force: boolean): Promise<void>;
    close(force?: any, callback?: any): void | Promise<void> {
        return this.pool.release(this)
    }

    public realClose(force?: any, callback?: any): void | Promise<void> {
        return super.close(force, callback);
    }

}

type Factory = (pool: ConnectionPool) => Promise<MongoClientWrapper>

class ConnectionPool {
    //最大连接数量
    readonly maxPoolSize: number
    //最小链接数量
    readonly minPoolSize: number

    //当前线程池链接数量
    private count: number = 0;

    private conHead?: MongoClientWrapper

    private factory: Factory

    private requestQueue: Array<(client: MongoClientWrapper) => void> = []

    constructor(
        maxPoolSize: number,
        minPoolSize: number,
        factory: Factory
    ) {
        this.maxPoolSize = maxPoolSize
        this.minPoolSize = minPoolSize
        this.factory = factory;
    }

    release(client: MongoClientWrapper) {
        client.using = false;
        const con = client;
        client.next!.prev = client.prev;
        client.prev!.next = client.next;
        this.add(con);
        if (this.requestQueue.length > 0) {
            this.requestQueue.shift()!(this.useUnChecked());
        }
    }

    async acquireConnection(): Promise<MongoClient> {
        return new Promise(async (resolve, reject) => {
            //无空闲数量
            if (this.conHead == undefined || this.conHead.using) {
                if (this.count == this.maxPoolSize) {
                    this.requestQueue.push(client => resolve(client));
                } else {
                    try {
                        const con = await this.factory(this);
                        this.add(con);
                        resolve(this.useUnChecked());
                    } catch (e) {
                        reject(e);
                    }
                }
            } else {
                resolve(this.useUnChecked());
            }
        });
    }

    private useUnChecked(): MongoClientWrapper {
        this.conHead!.using = true;
        this.conHead = this.conHead!.next;
        return this.conHead!;
    }

    private add(client: MongoClientWrapper) {
        this.count++;
        if (this.conHead === undefined) {
            this.conHead = client;
            client.prev = (client.next = client);
            return;
        }

        client.next = this.conHead;
        this.conHead.prev = client;
        this.conHead = client;
    }
}

聊天消息服务设计:

为了区分多个群组,聊天的消息必须以Message格式进行发送,服务器端接收到之后根据在线用户进行消息发送。客户通过websocket连接时必须先告诉服务器自身身份,以便将socket连接和用户绑定。

img

当收到消息时,需要对根据发送的群检索出在线的用户,通过对应的socket的发送消息:

img

其中还会涉及一些代码的持久化,对应群聊的消息会被储存到数据库中,下次上线仍然可以看到,下面是部分代码:

当服务器收到消息后,先验证消息格式,然后调用service层处理消息。

//服务器消息
function initMsgServer() {
    ioServer.on('connection', socket => {
        //用户上线事件
        socket.on("online", (data) => {
            //绑定账户和socket
            userOnline(data.account, socket);
        })
        //客户端接收消息
        socket.on("send", (msg) => {
            //所有的消息都是Message格式的
            try{
                const message: Message = verifyMsg(msg);
                sendMsg(message);
            } catch(e){
                socket.emit('send-error',{
                    type: 1,
                    msg: '消息格式错误'
                });
            }
        })

        //处理离线逻辑
        socket.on("disconnect", () => {
            userOffline(socket);
        })
    });
}

转发消息逻辑:

export async function sendMsg(msg: Message){
    const group = await findGroupById(msg.reciver);
    if(group == null){
        throw "群组不存在"
    }
    (await retriveOnlieSockets(group)).forEach(item=>{
        if(item.account != msg.sender){
            item.socket.emit('send', msg);
        }
    })
    //持久化消息
    saveMsg(msg);
}

数据库设计

账户库设计:

{
    _id: ObjectId(),
    username: string,
    password: string,
    createTime: datetime,
    modifyTime: datetime,
    flag: 1
}

群信息库设计:

{
    _id: ObjectId(),
    name: string,
    pepole: int,
    members: [],
    avatar: string,    //头像链接
    createDate: datetime,
    modifyDate: datetime,
    owner: string    //账户组件id
}

账户-群组库设计:

为了避免查询信息群列表使会带出大量的其它信息,这里我们将用户和群组的关联独立出来。

{
    _id: ObjectId(),
    account: string,    //账户id
    groups:[]
}

同理,还有群成员列表,这里不再赘述。

聊天消息库设计:

为了避免数据量的增长导致的查询速度变慢,我们可以进行分库分表。得益于非关系型数据库的灵活性,我们可以很快的为一个群组动态的创建一个Collection,当群组成员为0时可以删除该Collection。当数据量更大时我们还可以按照时间对Collection更加的细分,这样就可以避免查询速度的下降。

group_message_${id},其中id为group的主键id。

{
    _id: ObjectId(),
    //发送者id
    sender: string;
    //接收者id,群聊消息即为群的id
    reciver: string;
    type: number;
    //聊天消息
    content: string;
    //创建时间
    createTime: string;
}

总结

本文章主要介绍了一个简单的多人实时聊天程序的设计,从前端、后端到数据库,使用的语言都是js系的,这也侧面证明目前javascript应用场景越来越多,基本上已经覆盖了从前端到后端全栈。

前端使用React,用mobx管理状态,antd的组件库,axios的网络请求库。后端使用express、websocket、typescript。整体下来极大的扩展了自身技术栈,也能学到很多其它知识,如:jwt、跨域、websocket等,其中也自己对数据库连接做了池化操作等,增加了学习知识。

作业:NP194

...全文
394 回复 打赏 收藏 转发到动态 举报
写回复
用AI写文章
回复
切换为时间正序
请发表友善的回复…
发表回复

566

社区成员

发帖
与我相关
我的任务
社区描述
软件工程教学新范式,强化专项技能训练+基于项目的学习PBL。Git仓库:https://gitee.com/mengning997/se
软件工程 高校
社区管理员
  • 码农孟宁
加入社区
  • 近7日
  • 近30日
  • 至今

试试用AI创作助手写篇文章吧