566
社区成员
基于React+Nodejs+Mongodb的实时多人聊天室,主要完成了以下几个功能:
前端:react+antd+mobx
后端:nodejs+typescript+express+websocket
数据库:mongodb
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利用js的语法提供了一组原生平台功能的Api,可以利用这些Api提供浏览器环境无法实现的功能,如:web服务器。
使用Nodejs可以方便的和前端开发统一语言,避免前后端切换编程语言。Web框架使用express,也可以方便的结合websocket。
由于js太过动态,弱类型,代码多起来之后很难维护,所以这里选择使用Javascript的超集Typescript,其在js的基础之上添加了类型的支持,可以很方便的进行数据建模和类型限制,IDE也可以更加准确、智能的提示。
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;
登录页面:
代码实现
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
聊天页面:
代码:
全部以组件化的形式进行分割,页面只是组件的组合。
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连接和用户绑定。
当收到消息时,需要对根据发送的群检索出在线的用户,通过对应的socket的发送消息:
其中还会涉及一些代码的持久化,对应群聊的消息会被储存到数据库中,下次上线仍然可以看到,下面是部分代码:
当服务器收到消息后,先验证消息格式,然后调用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