基于Vue+Express+MongoDB+SocketIO的在线聊天室设计与实现

iSunwish 2022-01-17 14:50:41

零、前言

本文分享了基于Vue+Express+MongoDB+SocketIO的在线聊天室设计与实现,并给出了相关技术入门学习的个人笔记分享。本文结构:

  • 第一节为相关技术的前置学习入门

  • 第二至四节为需求和项目设计相关内容

  • 第五节为核心代码实现节选

  • 第六节为项目实际效果的动态和静态演示

img

一、前置学习

本小节放上了本项目开发过程中相关技术的个人学习笔记。

1.1 Vue CLI

1.1.1 创建快速原型

可以快速开发无后端的前端项目。在项目目录下新建App.vue文件作为入口文件,写入内容:

<template>
    Hello World!
</template>

然后在同级目录执行

vue serve

即完成初始 Hello World 程序的搭建。

但是后面使用过程中遇到了很麻烦解决的问题,快速原型目录下又没有配置文件,有诸多不便,因此快速原型到一定阶段后还是移植到标准 vue-cli 项目比较好。

使用 vue create xxx 来创建一个 vue-cli 项目,然后控制台 npm run serve 运行 vue-cli 项目。

1.1.2 极速入门简记

  1. 每个.vue文件就是一个模板,由<template>标签为根,且根内只能存在一个顶级标签,一般用<div>包裹。但若本来就只有一个标签,就不需要再用<div>包一层了,如

    <template>
        <p> 本来就一个p标签,可以这么写,但要是同级有多个标签,如多个p标签,就必须用一个div作为根包裹起来,使得 template 标签内只有一个顶级标签</p>
    </template>
    
  2. 每个.vue文件内<script>标签内包含以下基础可选内容:

    <script>
    import 某模板 from ".vue模板路径"
    let friendId = 0;
    export default {
        // 本模板直接依赖的模板,名字为 import 的名字
        components: {
            某模板 // 若有多个模板用逗号隔开
        }
        // 本模板拥有的属性
        props: {
            属性名: {
                type: 属性类型(一般为 Object 或 Array)
                required: true // 如果该属性的值应由外层使用该模板时传入,此处就写 true, 否则不写此行
            }
        },
        // 自定义本地数据,用于测试页面,例如:
        data () {
            return {
                friendList: [
                    {
                        id: friendId++,
                        name: "Sun"
                    },
                    {
                        id: friendId++,
                        name: "Tense"
                    }
                ]
            }
        },
        // 回调函数
        methods: {
            函数名 (参数) {
                // 此处使用或修改 props 或 data 内的变量,都必须使用 this 指针,
                // 相反,<script> 标签内直接 let 的变量不可加 this,应是显而易见。
            }
        }
    }
    </script>
    
  3. 此后正文中即可正常写Vue代码和层叠样式。

    <template>
        <div>
            <!-- balabala -->
        </div>
    </template>
    <script> /* 见2. */ </script>
    <style> /* balabala */ </style>
    
  4. 使用模板时,直接将模板作为标签使用,并用冒号:前缀传入对应模板需要的props,例如:

    <template>
        <!-- 如 FriendList.vue 中定义了一个名为 friends 的 props -->
        <FriendList :friends="friendList"/>
    </template>
    <script>
    export default {
        data () {
            return {
                friendList: [
                    // some object here
                ]
            }
        }
    }
    </script>
    
  5. 常规的点击事件使用@click="handlerName"即可,若出现事件触发不了的情况,请尝试使用@click.native="..."

  6. 对于编写的模板需要将事件触发抛到外层处理时,应使用@click="$emit('自己在外层定义的事件名', 参数)",并在外层模板标签上使用@自定义事件名="handlerName"

    <!-- 内层模板 Inner.vue 要将事件向外抛出 -->
    <template>
        <button @click="$emit('test', 'Hello')">
            Example
        </button>
    </template>
    <!-- 外层模板 -->
    <template>
        <Inner @test="testHandler"/>
    </template>
    <script>
    export default {
        methods: {
            test (msg) {
                console.log (msg);
            }
        }
    }
    </script>
    
  7. 其它都是正常和vue.js一样,如v-model标签使得数据双向绑定,v-bind或者简写:向前端单向绑数据,v-for循环显示注意必须分配:key等等,不再赘述。

1.1.3 生命周期钩子

beforeCreate:在实例初始化之后,数据观测 (data observer) 和 event/watcher 事件配置之前被调用。
created:在实例创建完成后被立即调用。在这一步,实例已完成以下的配置:数据观测 (data observer),属性和方法的运算,watch/event 事件回调。然而,挂载阶段还没开始,
beforeMount:在挂载开始之前被调用。
mounted:el 被新创建的 vm.$el 替换,并挂载到实例上去之后调用该钩子。

  1. created 中获取不到 DOM 元素,需要在 mounted 中获取,通过设置一个isJustCreated = 1并在 mounted 函数中置 0 以控制只在 created 后只执行一次。

  2. 要使用即时使用 mounted 后更新的数据,需要将操作也置为异步,如clickSend()中对消息列表内容进行了push,之后要马上获取 push 后存放消息列表的容器最新高度值,需要在 mounted 钩子中更新获取元素的 DOM,然后在push后以异步方式访问对应DOM:setTimeout(()=>{/* 使用 mounted 更新后的 DOM */})

beforeUpdate:数据更新时调用,发生在虚拟 DOM 打补丁之前。
updated:由于数据更改导致的虚拟 DOM 重新渲染和打补丁,在这之后会调用该钩子。
beforeDestroy:实例销毁之前调用。
destroyed:Vue 实例销毁后调用。调用后,Vue 实例指示的所有东西都会解绑定,所有的事件监听器会被移除,所有的子实例也会被销毁。

1.2 axios、express

1.2.1 axios

import axios from 'axios'
  • get:

    axios.get(url).then().catch()
    
  • post:

    axios.post(url, {}).then().catch()
    

1.2.2 express

/* index.js */
var app = require('express')();
var http = require('http').createServer(app);
// 若有跨域问题则///
// var cors = require('cors');
// app.use(cors());
/////////////////

然后控制台 node ./index.js 启动 express 服务器。

  • 处理 get/post 请求

    app.get('/', (req, res) => {
        // 对于 get 请求,url 参数通过 req.params 数组访问
        // 对于 post 请求,参数通过 req.body 对象访问
        // 返回文本
        res.send();
        // 返回 json
        res.json();
        // 返回文件
        res.sendFile(); // 参数路径,如 process.cwd() + "/imgs/avater.jpg"
    });
    app.post(同理);
    
  • 动态参数匹配

    // 如下面的代码,对于请求 *****/api/user/20 将输出 ’id: 20'
    // 好处是不用使用 res.params[0] 这种抽象的表示了
    app.get('/api/user/:id', (req, res) => {
        console.log('id: ' + res.params.id)
    })
    

1.3 Promise 转 async

如果逻辑存在嵌套,如查询某用户好友后,再查该用户某个好友的具体信息,再拿具体信息又做其他事,就会导致 Promise 的 then 方法层峦叠翠。改进方法就是改用同步写法的 async。

下面以获取所有用户的 mongodb 数据库请求为例。

  • 其回调写法为:

    exports.getAllUsers = (callback) => {
        models.userModel.find({}, callback);
    }
    // 则对应 controller 中这样使用:
    getAllUsers((err, res) => {
        // 后续进一步调用回调逻辑的方法就会在参数位置导致不断嵌套 callback 函数体,如
        // ...
        getFriends(res[0].id, (err, res) => {
            //...
            balabala(param, (err, res) => {
                // ...
            })
        })
    })
    
  • 其 Promise 写法为:

    exports.getAllUsers = () => {
        return models.userModel.find({}).exec();
    }
    // 则对应 controller 中这样使用:
    getAllUsers().then(res => {
        // 后续进一步调用 Promise 逻辑的方法就会在 .then() 参数位置不断嵌套.then(),如
        // ...
        getFrineds(res[0].id).then(res => {
            // ...
            balabala(param).then(res => {
                // ...
            }).catch(err => {
                
            })
        }).catch(err => {
            
        });
    }).catch(err => {
        
    })
    
  • 其非常棒的 async 写法为:

    exports.getAllUsers = () => {
        return models.userModel.find({}).exec()
        .then(res => {
            //...
            return [null, res];
        })
        .catch(err => {
            // ...
            return [err];
        })
    }
    // 则对应 controller 中这样使用:
    [err0, users] = await getAllUsers(); // 不存在任何嵌套,Elegant
    [err1, friends] = await getFriends(users[0].id);
    [err2, bala] = await balabala(param);
    

    如果 dao 层不处理逻辑,那么可直接简写为:

    exports.getAllUsers = () => {
        return models.userModel.find({}).exec()
        .then(res => [null, res])
        .catch(err => [err]);
    }
    

1.4 socket.io

1.4.1 初始化项目

服务端

以集成进 express 为例,直接 npm install socket.io --save 安装,然后将 http 服务器实例传给 socket.io 即可。由于当前项目是前端一个域(vue-cli),后端一个域(express),在前端通过 socket.io-express 连接到服务端的 socket.io 因此属于跨域,需要单独在服务端创建 socket.io 的时候配置 cors。

如果非跨域,即前端页面是通过后端路由返回的(req.sendFile(front-end-page)),那么就不需要配置跨域了,前端也只需直接使用相对路径 <script src="/socket.io/socket.io.js"></script> 引入并且直接 var socket = io() 就可以连上了服务端的 socket.io 了。

var app = require('express')();
var http = require('http').createServer(app);
const io = require('socket.io')(http, {
    cors: {
        origin: '*'
    }
});

客户端

以使用本地 socket.io-client,而不是直接引服务端的 socket.io.js 为例。使用 npm install socket.io-client 安装客户端包,然后通过下面的方式在前端代码中引入:

const { io } = require('socket.io-client');

注意,此将产生跨域访问,需要在服务端创建 socket.io 时进行配置,详情回看上面的服务端部分。

1.4.2 连接

服务端

io.on('connection', (socket) => {
    // 当客户端创建一个 io() 实例时就会产生一个 connection 连接并作为回调参数
    // 返回到此处,后续该客户与服务器的所有交互都通过这个 socket 连接来进行。
    console.log('A user connected.');
})

客户端

通过调用 io() 来创建一个 socket 连接实例,注意此处跨域连接,需要在服务端创建 socket.io 时进行 cors 的配置,详情见前面 1.初始化项目 中的服务端部分。

var socket = io('http://localhost:80') // 服务端地址,若非跨域则参数直接留空即可

1.4.3 单播交互

  • 客户端向服务器发送消息

    // socket.emit('消息名(自定义)', '消息内容(自定义)');
    // 例如:
    socket.emit('chat_message', 'Hello socket.io server!');
    
  • 服务端接收客户的消息

    io.on('connection', (socket) => {
        // 当客户端创建一个 io() 实例时就会产生一个 connection 连接并作为回调参数
        // 返回到此处,后续该客户与服务器的所有交互都通过这个 socket 连接来进行。
        socket.on('chat_message', (message) => {
            console.log('Received client message: ' + message);
        })
    })
    
  • 客户端接收服务器的消息

    socket.on('chat_message', (message) => {
        console.log('Received server message: ' + message);
    })
    

1.4.4 服务端广播

服务端广播有两种类型,一种是服务端向所有客户端广播信息,另一种是服务端在和某个客户端的连接中向其余客户广播信息,也就是向除了当前连接的其他连接广播。

  • 对于第一种,向所有客户端广播,不依赖于和某个客户的连接,直接独立地:

    io.emit('some event', { someProperty: 'some value', otherProperty: 'other value' }); // This will emit the event to all connected sockets
    
  • 对于第二种,向除了当前连接外的其它客户端广播:

    io.on('connection', (socket) => {
        // 依赖于与某个客户的连接,向除了该 socket 连接外的其它连接广播
        socket.broadcast.emit('hi');
        // 当然,也可以在此处使用 io 对象向包括该连接在内的所有连接广播:
        // io.emit('hi')
    });
    

二、需求分析

本项目为简单在线聊天室实现,旨在学习与熟悉相关技术栈,了解简单网络程序的基本实现原理并加以实践与实现。本项目不包含性能调优,如并发控制、压力控制、负载均衡、接口响应速度等非功能性需求。

大体上来说,该聊天室包含进入聊天室和聊天两个核心功能需求(不提供注册功能),以下是本项目用户的简单用例图。

img

三、架构设计

3.1 整体架构

本项目前端使用Vue进行模块化开发,便于项目管理,同时Vue组件内容的动态更新特性也适合于聊天室需要频繁更新消息框内容和在线用户视图的需求。项目后端采用Express+MongoDB作为服务器和数据库,并辅以Axios进行前后端交互,SocketIO实现消息广播。项目的大体架构如下图所示。(注:项目使用 express-session 实现登录记忆,该模块未在图中注出)

img

3.2 前端模块划分与界面原型

在本项目中,前端计划使用单页来实现进入聊天室、发送消息及退出聊天室的全部业务逻辑。其中聊天室页面由在线列表、在线列表项和聊天区域三个模块构成,进入聊天室的逻辑由弹窗形式实现。聊天室的前端界面原型与模板依赖关系均如下图所示。

img

3.3 后端文件模块划分与依赖

为使得项目结构更加清晰,后期维护更加方便,后端划分为dao.js、models.js、apiHandler.js和socketHandler.js几个文件模块。其中:

  • index.js 为后端入口文件,负责 HTTP 服务器的创建与启动,包含中间件配置和各模块注入;
  • socketHandler.js 负责处理 socket 相关事件,如用户退出导致的 disconnect 在该文件内捕获并处理;
  • apiHandler.js 负责路由请求处理,因为项目较为简单,因此所有 Post/Get 请求选择直接在单文件中处理;
  • dao.js 负责实现基本数据库操作,即增删改查,并向外暴露异步接口;
  • models.js 定义数据库中所有对象的概念模式。

img

四、详细设计

限于篇幅,以登录和发送消息两个功能接口为例,图解后端接口与前端交互的设计结构和调用栈。

此外,在这一小节中简述一下用户使用项目过程中大致流程和底层处理逻辑(只是口头大致描述一下,流程图活动图顺序图什么的就都略了):

  1. 首先,用户访问前端,在页面加载时,前端请求后端的 /api/whoami 接口,借助服务端 session 实现用户登录记忆,若在 session 中找到用户信息则直接返回用户信息完成登录;

  2. 若未登录或登录失效,则前端弹出登录框,用户输入正确用户名后请求登录,此时请求后端的 /api/login 接口,在后端进行存在性验证,验证通过则返回用户信息完成登陆;

  3. 登录成功后,前端 GET 请求后端的 /api/onlineUsers 接口,拉取当前在线用户;

    在线用户由后端维护一个 maintain.onlineUsers 对象数组,包含在线用户的基本信息,并通过 socket 的连接 id 号对在线用户进行唯一标识。该在线列表交付给前端展示时,前端也通过 socket 连接 id 作为键绑定 onlineListItem.vue 模板实现用户离线事件中对离线用户条目的定位与移除。

  4. 当前在线用户拉取成功后,前端 POST 请求后端的 /api/onlineUser 接口,借助服务端 socket.io 向所有用户广播自己的上线事件;

  5. 前端 GET 请求后端的 /api/chatroomMessages 接口,获取历史聊天记录,并按自身视角进行渲染呈现;

  6. 用户发送消息,POST 请求后端 /api/chatroomMessage 接口,服务端在路由请求处理中调用 socket.io 实现向所有在线用户的新消息广播;

  7. 用户在 socket.io 事件收到消息广播,将消息插入列表,Vue动态重绘显示;

  8. 用户退出登录,POST 请求后端的 /api/logout 接口,在服务端清除连接的 session。

    注:用户不论是退出登录主动发起 POST 请求,还是关闭页面,都会自动触发 socket.io 的 disconnect 断开事件,此时在服务端接收到用户断开时,向所有在线用户广播该用户下线事件,指明下线用户的连接号,使得在线用户收到事件后可在前端更新在线用户列表视图。进而 /api/logout 接口只负责清除 session,与在线用户的维护逻辑解耦。

img

五、代码实现

5.1 MongoDB Schema

var chatroomMessageSchema = mongo.Schema({
    sender: {
        type: mongo.Schema.ObjectId,
        ref: 'users'
    },
    content: String,
    time: Date
})

var userSchema = mongo.Schema({
    id: Number,
    username: String,
    avater: String
})

5.2 前端实现节选

App.vue

<template>
    <div class="app">
        <div class="FriendListDiv">
            <OnlineList :onlineUsers="onlineUsers" :displayFriendIndex="displayFriendIndex" @clickFriendItem="clickFriendItem" :socket="socket" :requireBaseURL="requireBaseURL"/>
        </div>
        <div class="MessageDiv">
            <Message :messages="messages" :self="self" :socket="socket"/>
        </div>
    </div>
</template>

<script>
/**
 * Author: Sunwish (NP398)
 * 
 * - 使用 axios 和服务端进行 api 交互,api 设计遵循 RESTful 规范
 * - 登录状态和个人信息记录在服务端 session 中,并通过页面加载时请求
 *   /api/whoami 接口实现页面刷新或短暂关闭后的登录记忆效果
 * 
 * 设计/实现思路:
 * 1. 页面加载时首先请求 /api/whoami 读取个人信息,若未登录就提示输入登录身份
 *    进行登录,登录成功后在服务端存储登录状态和用户信息,避免刷新页面后又要重新登录
 * 2. 登录后向服务器拉取在线列表并请求广播上线消息给所有在线客户端,包括自己,并更新在线列表
 * 3. 向服务端请求历史消息记录,并通过 Vue 传递给相关组件进行视图更新
 */
import axios from 'axios';
import OnlineList from "./components/OnlineList.vue"
import Message from "./components/Message.vue"
axios.defaults.withCredentials = true;

export default {
    components: {
        OnlineList, 
        Message
    },
    props: {
        requireBaseURL: {
            type: String,
            required: true
        },
        socket: {
            type: Object,
            required: true
        }
    },
    methods: {
        login: async function (promptMsg = "What's your username?") {
            let username = prompt(promptMsg);
            let res = await axios.post(this.requireBaseURL + '/api/login', {
                username: username,
                connId: this.socket.id
            });
            this.self = res.data.result;
            if(this.self == null) return 1;
            return 0;
        },
    },

    beforeCreate() {
        window.addEventListener("beforeunload", () => {
            this.saveStore();
        })
    },

    async created() {
        // Login with selfId
        this.self = (await axios.post(this.requireBaseURL + '/api/whoami')).data.result;
        console.log('this.self: ', this.self);
        if(this.self == null) {
            let loginSuccess = await this.login();
            while(loginSuccess != 0) loginSuccess = await this.login('Id not found. Please enter a valid id:');
        }
        // Pull online users maintain on server-end
        let res = await axios.get(this.requireBaseURL + '/api/onlineUsers');
        this.onlineUsers.push.apply(this.onlineUsers, res.data.result);

        // Broadcast self online event to update all online users' online list
        axios.post(this.requireBaseURL + '/api/onlineUser', {
            user: this.self,
            connId: this.socket.id
        })
        this.displayFriendIndex = 0;
        // Pull message history
        let messageResult = await axios.get(this.requireBaseURL + '/api/chatroomMessages');
        this.messages = messageResult.data.result;
    },

    data() {
        return {
            self: {},
            displayFriendIndex: -1,
            onlineUsers: [],
            messages: [],
        }
    }
}
</script>
<style>
/* CSS 略*/
</style>

OnlineList.vue

<template>
<div>
    <h1 style="text-align: center; margin: 1rem 0">Online</h1>
    <div>
        <OnlineListItem v-for="(user, index) in onlineUsers" :key="index" :index="index" :user="user" :displayFriendIndex="displayFriendIndex" @click.native="$emit('clickFriendItem', index)"/>
    </div>
    <!--lightgoldenrodyellow-->
    <button @click="logOut" style="position: absolute; bottom: 0rem; width: 100%; height: 5rem; cursor: pointer; background-color: rgb(229, 243, 255); color: rgb(68, 144, 249); font-weight: 666; border: none;">Log out</button>
</div>
</template>

<script>
/**
 * Author: Sunwish (NP398)
 * 
 * 单纯负责渲染在线列表,附带实现一个登出功能,实现就是简单的清空 sessionStorage 并刷新页面。
 * 
 * 在线列表维护逻辑:
 * 1. 用户在 App.vue 提交登录请求时附加 socket.io 的 id,若登录成功,则将该登录成功的用户
 * 信息连同其连接号一起广播给所有在线用户,这样就实现了用户加入时的列表更新。
 * 2. 用户在断开连接时,服务端知道断开者的 id,将断开者 id 广播给所有在线用户,这样就实现了
 * 用户退出时的列表更新。因为这也是为什么登录时要将自己的 socket id 给服务器广播给其它用户。
 */
import OnlineListItem from "./OnlineListItem.vue"
import axios from 'axios';
axios.defaults.withCredentials = true;

let randomId = 1;
export default {
    components: {
        OnlineListItem
    },
    props: {
        onlineUsers: {
            type: Array,
            required: true
        },
        displayFriendIndex: {
            type: Number,
            required: true
        },
        socket: {
            type: Object,
            required: true
        },
        requireBaseURL: {
            type: String,
            required: true
        }
    },
    data () {
        return { randomCount: randomId }
    },
    created () {
        this.socket.on('user_connect', (user) => {
            let userObject = JSON.parse(user);
            this.onlineUsers.push(userObject);
        })
        this.socket.on('user_disconnect', (disconnectId) => {
            const i = this.onlineUsers.findIndex(user => user.connId == disconnectId);
            i > -1 && this.onlineUsers.splice(i, 1)
            && console.log('user_disconnect: ', disconnectId);
        })
    },
    methods: {
        saveStore: function (state = this.$store.state) {
            sessionStorage.setItem('store', JSON.stringify(state));
        },
        logOut () {
            axios.post(this.requireBaseURL + '/api/logout');
            location.reload();
        }
    }
}
</script>

OnlineListItem.vue

<template>
    <div class="friend-list-item" :class="{ 'friend-list-item-display' : (index == displayFriendIndex) }">
        <div class="friend-list-item-avater"><img :src="avater"/></div>
        <div class="friend-list-item-right">
            <div class="friend-list-item-nick">
                {{ user.username }}
            </div>
            <div class="friend-list-item-lastmsg">
                Online
            </div>
        </div>
    </div>
</template>

<script>
/**
 * Author: Sunwish (NP398)
 * 
 * 单纯负责渲染在线列表表项,选中效果(friend-list-item-display)是无聊写的,没实际意义
 */
export default {
    props: {
        index: {
            type: Number,
            require: true
        },
        user: {
            type: Object,
            required: true
        },
        displayFriendIndex: {
            type: Number,
            required: true
        }
    },
    data () {
        return {
            avater: 'http://localhost/api/avater/' + this.user.avater
        }
    },
    updated () {
        // 用户退出然后新用户加入时,user 会被更新,但头像在 data() 中于
        // 初始化后就返回不变了,因此需要在更新时手动更新一下 data() 中返回的头像信息
        this.avater = 'http://localhost/api/avater/' + this.user.avater
    }
}
</script>

<style>
/* CSS 略 */
</style>

5.3 后端实现节选

index.js

////////////////////////////////////// Require modulars
var app = require('express')();
var http = require('http').createServer(app);
var cors = require('cors');
var session = require('express-session');
////////////////////////////////////// Require components
var socketHandler = require('./socketHandler');
var apiHandler = require('./apiHandler');
var dao = require('./dao');
var models = require('./models'); // just for db test
//////////////////////////////////////

const io = require('socket.io')(http, {
    cors: {
        origin: '*'
    }
});

configurateApp(app); // configurate middleware

dao.connect(app, 'mongodb://127.0.0.1:27017/WebIM')
.then(() => {
    console.log("Connect to mongodb successed.");

    let maintain = {
        onlineUsers: [],
    }
    
    apiHandler.handleApp(app, io, maintain);

    // Socket.io listening
    io.on('connection', (socket) => {
        io.emit('from_server', 'Hi client!');
        socketHandler.handleSocket(socket, maintain);
    })

    // http listening
    http.listen(80, () => {
        console.log('listening on *:80');
    })
})
.catch((err) => {
    console.log("Fail to connect to mongodb");
}); 

function configurateApp(app) {
    app.use(cors({
        credentials: true,
        origin: 'http://localhost:8080',
    }));
    app.use(session({
        secret: 'secret',
        resave: false,
        saveUninitialized: true,
        cookie: {
            maxAge: 1000 * 60 * 3
        }
    }));
}

socketHandler.js

exports.handleSocket = 
function handleSocket (socket, maintain) {
    // console.log('A user connected!');
    socket.on('disconnect', () => {
        // Remove from onlineUsers list mantained by server
        const i = maintain.onlineUsers.findIndex(user => user.connId == socket.id);
        i > -1 && maintain.onlineUsers.splice(i, 1);
        console.log('User disconnected.', socket.id, 'Online count: ', maintain.onlineUsers.length);
        // Broadcast disconnect event to all online users
        socket.broadcast.emit('user_disconnect', socket.id);
    })
}

apiHandler.js

var dao = require('./dao');

exports.handleApp =
function handleApp (app, io, maintain) {
    app.post('/api/whoami', async (req, res) => {
        if(req.session.loginUserInfo) {
            res.json({
                "result": req.session.loginUserInfo
            })
            return;
        }
        res.json({});
    })

    app.post('/api/login', async (req, res) => {
        if(req.session.loginUserInfo) {
            //console.log('session: ', req.session.loginUserInfo);
            res.json({
                "errMessage": err,
                "result": req.session.loginUserInfo
            })
            return;
        } else {
            let [err, result] = await dao.getUserByUsernameAsync(req.body.username);
            if(result) {
                // login success
                req.session.loginUserInfo = result;
                //console.log('set session: ', req.session.loginUserInfo);
                console.log('create a loginUserInfo session.');
            }
            res.json({
                "errMessage": err,
                "result": result
            })
        }
    })
    
    app.post('/api/logout', async (req, res) => {
        req.session.loginUserInfo = null;
        res.json({
            'result': ''
        })
    })

    app.post('/api/onlineUser', (req, res) => {
        if(req.body.connId == undefined) {
            res.json({
                'errMessage': 'Online connect id cannot be undefine',
            });
            return;
        }

        let userWithConnId = {};
        // Copy userObjcet to userWithConnId
        Object.assign(userWithConnId, req.body.user);
        userWithConnId.connId = req.body.connId;
        
        // Insert into onlineUser list on server
        const i = maintain.onlineUsers.findIndex(user => user.connId == userWithConnId.connId);
        i ==-1 && maintain.onlineUsers.push(userWithConnId);
        console.log('user login sucessed: ', userWithConnId.connId,
        'Online count: ', maintain.onlineUsers.length);
        
        // Broadcast online event to all online users
        io.emit('user_connect', JSON.stringify(userWithConnId));
        
        res.json({
            "errMessage": null,
            "result": null
        })
    })

    app.get('/api/onlineUsers', (req, res) => {
        res.json({
            "errMessage": null,
            "result": maintain.onlineUsers
        })
    })

    app.post('/api/chatroomMessage', async (req, res) => {
        [err, result] = await dao.insertChatroomMessageAsync(req.body.sender, req.body.content, req.body.time);
        res.json({
            'errMessage': err,
            'result': result
        });
        // Broadcast message to all socket.io connections
        if(err == null){
            io.emit('chat_message', JSON.stringify(result));
        }
    })

    app.get('/api/chatroomMessages', async (req, res) => {
        [err, result] = await dao.getChatroomMessagesAsync();
        res.json({
            'errMessage': err,
            'result': result
        });
    })

dao.js

const mongo = require('mongoose');
const models = require('./models');
var utils = require('./utils');
var bodyParser = require('body-parser');

exports.connect =
function connect(app, connectString) {
    app.use(bodyParser.urlencoded({ extended: false }));
    app.use(bodyParser.json());
    return new Promise((resolve, reject) => {
        mongo.connect(connectString, {
            useUnifiedTopology: true,
            useNewUrlParser: true
        }, (err) => {
            if (err) { return reject(err); }
            resolve(null);
        });
    })
}
////////////////////////////////////// ABOUT CHATROOM MESSAGE
exports.insertChatroomMessageAsync =
function insertChatroomMessageAsync (sender, content, time) {
    return new models.chatroomMessageModel ({
        sender: sender,
        content: content,
        time: time
    }).save()
    .then(res => {
        return res.populate('sender')
        .then(res => [null, res])
        .catch(err => [err]);
    })
    .catch(err => [err]);   
}

exports.getChatroomMessagesAsync =
function getChatroomMessagesAsync () {
    return models.chatroomMessageModel.find({}).populate('sender').exec()
    .then(res => [null, res])
    .catch(err => [err]);
}

//////////////////////////////////////// GET MESSAGES
exports.getAllMessagesAsync =
function getAllMessagesAsync () {
    return models.messageModel.find({}).exec()
    .then(result => [null, result])
    .catch(err => [err]);
}

//////////////////////////////////////// GET USER BY USERNAME
exports.getUserByUsernameAsync =
function getUserByUsernameAsync (username) {
    return models.userModel.findOne({
        username: username
    }).exec()
    .then(result => [null, result])
    .catch(err => [err]);
}

六、项目测试(效果演示)

6.1 动态图演示

img

6.2 静态图展示

登录与聊天记录拉取演示

img

多用户在线列表与聊天记录视角演示

img

发送消息与接收消息演示

img

用户退出与在线列表更新演示

img

NP398

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

571

社区成员

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

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