Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

单枪匹马撸个聊天室, 支持Web/Android/iOS三端 #3

Open
yinxin630 opened this issue Jul 31, 2018 · 23 comments
Open

单枪匹马撸个聊天室, 支持Web/Android/iOS三端 #3

yinxin630 opened this issue Jul 31, 2018 · 23 comments

Comments

@yinxin630
Copy link
Owner

yinxin630 commented Jul 31, 2018

前排提醒, 阅读本文需要对JavaScript较为熟悉, 本文将讲解核心功能点的设计思路


源码地址: https://github.com/yinxin630/fiora
在线地址: https://fiora.suisuijiang.com/

前言

该项目起始于2015年底, 也是我刚开始学习 JavaScript 的时候, 当时仅仅是想做个练手项目. 后面随着在前端领域的深入学习, 也一直在更新技术栈, 目前已经是重构后的第五个版本


该图是使用 https://coggle.it/ 制作的

得益于 node.jsreact-native 的出现, 使得 jser 的触手伸到了服务端和APP端. 本项目服务端基于 node.js 技术, 使用了 koa 框架, 所有数据存储在 mongodb 中. 客户端使用 react 框架, 使用 redux 和 immutable.js 管理状态, 自己设计了一套简约范的UI风格, APP端基于 react-native 和 expo 开发. 项目部署在我的乞丐版阿里云ECS上, 学生机配置单核1G内存

服务端架构

服务端负责两件事:

  1. 提供基于WebSocket 的接口
  2. 提供 index.html 响应

服务端使用了 koa-socket 这个包, 它集成了 socket.io 并实现了 socket 中间件机制, 服务端基于该中间件机制, 自己实现了一套接口路由

每个接口都是一个 async 函数, 函数名即接口名, 同时也是 socket 事件名

async login(ctx) {
    return 'login success'
}

然后写了个 route 中间件, 用来完成路由匹配, 当判断路由匹配时, 以 ctx 对象作为参数执行路由方法, 并将方法返回值作为接口返回值

function noop() {}

/**
 * 路由处理
 * @param {IO} io koa socket io实例
 * @param {Object} routes 路由
 */
module.exports = function (io, _io, routes) {
    Object.keys(routes).forEach((route) => {
        io.on(route, noop); // 注册事件
    });

    return async (ctx) => {
        // 判断路由是否存在
        if (routes[ctx.event]) {
            const { event, data, socket } = ctx;
            // 执行路由并获取返回数据
            ctx.res = await routes[ctx.event]({
                event, // 事件名
                data, // 请求数据
                socket, // 用户socket实例
                io, // koa-socket实例
                _io, // socket.io实例
            });
        }
    };
};

还有一个重要中间件是 catchError, 它负责捕获全局异常, 业务流程中大量使用 assert 判断业务逻辑, 不满足条件时会中断流程并返回错误消息, catchError 将捕获业务逻辑异常, 并取出错误消息返回给客户端

const assert = require('assert');

/**
 * 全局异常捕获
 */
module.exports = function () {
    return async (ctx, next) => {
        try {
            await next();
        } catch (err) {
            if (err instanceof assert.AssertionError) {
                ctx.res = err.message;
                return;
            }
            ctx.res = `Server Error: ${err.message}`;
            console.error('Unhandled Error\n', err);
        }
    };
};

这些就是服务端的核心逻辑, 基于该架构下定义接口组成业务逻辑

另外, 服务端还负责提供 index.html 响应, 即客户端首页. 客户端的其它资源是放在 CDN 上的, 这样可以缓解服务端带宽压力, 但是 index.html 不能使用强缓存, 因为会使得客户端更新不可控, 因此 index.html 放在服务端

客户端架构

客户端使用 socket.io-client 连接服务端, 连接成功后请求接口尝试登录, 如果 localStorage 没有 token 或者接口返回 token 过期, 将会以游客身份登录, 登录成功会返回用户信息以及群组、好友列表, 接着去请求各群组、好友的历史消息

客户端需要监听 connect / disconnect / message 三个消息

  1. connect: socket 连接成功
  2. disconnect socket 连接断开
  3. message 接收到新消息

客户端使用 redux 管理数据, 需要被组件共享的数据放在 redux 中, 只有自身使用的数据还是放在组件的 state 中, 客户端存储的 redux 数据结构如下:

客户端store结构

  • user 用户信息
    • _id 用户id
    • username 用户名
    • linkmans 联系人列表, 包括群组、好友以及临时会话
    • isAdmin 是否是管理员
  • focus 当前聚焦的联系人id, 既对话中的目标
  • connect 连接状态
  • ui 客户端 UI 相关和功能开关

客户端的数据流, 主要有两条线路

  1. 用户操作 => 请求接口 => 返回数据 => 更新redux => 视图重新渲染
  2. 监听新消息 => 处理数据 => 更新redux => 视图重新渲染

用户系统

User Schema 定义:

const UserSchema = new Schema({
    createTime: { type: Date, default: Date.now },
    lastLoginTime: { type: Date, default: Date.now },

    username: {
        type: String,
        trim: true,
        unique: true,
        match: /^([0-9a-zA-Z]{1,2}|[\u4e00-\u9eff]){1,8}$/,
        index: true,
    },
    salt: String,
    password: String,
    avatar: {
        type: String,
    },
});
  • createTime: 创建时间
  • lastLoginTime: 最后一次登录时间, 用来清理僵尸号用
  • username: 用户昵称, 同时也是账号
  • salt: 加密盐
  • password: 用户密码
  • avatar: 用户头像URL地址

用户注册

注册接口需要 username / password 两个参数, 首先做判空处理

const {
    username, password
} = ctx.data;
assert(username, '用户名不能为空');
assert(password, '密码不能为空');

然后判断用户名是否已存在, 同时获取默认群组, 新注册用户要加入到默认群组

const user = await User.findOne({ username });
assert(!user, '该用户名已存在');
const defaultGroup = await Group.findOne({ isDefault: true });
assert(defaultGroup, '默认群组不存在');

存密码明文肯定是不行的, 生成随机盐, 并使用盐加密密码

const salt = await bcrypt.genSalt$(saltRounds);
const hash = await bcrypt.hash$(password, salt);

给用户一个随机默认头像, 全都是萌妹子^_^, 保存用户信息到数据库

let newUser = null;
try {
    newUser = await User.create({
        username,
        salt,
        password: hash,
        avatar: getRandomAvatar(),
    });
} catch (err) {
    if (err.name === 'ValidationError') {
        return '用户名包含不支持的字符或者长度超过限制';
    }
    throw err;
}

将用户添加到默认群组, 然后生成用户 token
token 是用来免密码登录的凭证, 存储在客户端 localStorage, token里携带用户id、过期时间、客户端信息三个数据,用户id和过期时间容易理解, 客户端信息是为了防token盗用, 之前也试过验证客户端ip一致性, 但是ip可能会有经常改变的情况, 搞得用户每次自动登录都被判定为盗用了...

defaultGroup.members.push(newUser);
await defaultGroup.save();

const token = generateToken(newUser._id, environment);

将用户id与当前 socket 连接关联, 服务端是以 ctx.socket.user 是否为 undefined 来判断登录态的
更新 Socket 表中当前 socket 连接信息, 后面获取在线用户会取 Socket 表数据

ctx.socket.user = newUser._id;
await Socket.update({ id: ctx.socket.id }, {
    user: newUser._id,
    os, // 客户端系统
    browser, // 客户端浏览器
    environment, // 客户端环境信息
});

最后将数据返回客户端

return {
    _id: newUser._id,
    avatar: newUser.avatar,
    username: newUser.username,
    groups: [{
        _id: defaultGroup._id,
        name: defaultGroup.name,
        avatar: defaultGroup.avatar,
        creator: defaultGroup.creator,
        createTime: defaultGroup.createTime,
        messages: [],
    }],
    friends: [],
    token,
}

用户登录

fiora 是不限制多登陆的, 每个用户都可以在无限个终端登录

登录有三种情况:

  • 游客登录
  • token登录
  • 用户名/密码登录

游客登录仅能查看默认群组消息, 并且不能发消息, 主要是为了降低第一次来的用户的体验成本

token登录是最常用的, 客户端首先从 localStorage 取 token, token 存在就会使用 token 登录
首先对 token 解码取出负载数据, 判断 token 是否过期以及客户端信息是否匹配

let payload = null;
try {
    payload = jwt.decode(token, config.jwtSecret);
} catch (err) {
    return '非法token';
}

assert(Date.now() < payload.expires, 'token已过期');
assert.equal(environment, payload.environment, '非法登录');

从数据库查找用户信息, 更新最后登录时间, 查找用户所在的群组, 并将 socket 添加到该群组, 然后查找用户的好友

const user = await User.findOne({ _id: payload.user }, { _id: 1, avatar: 1, username: 1 });
assert(user, '用户不存在');

user.lastLoginTime = Date.now();
await user.save();

const groups = await Group.find({ members: user }, { _id: 1, name: 1, avatar: 1, creator: 1, createTime: 1 });
groups.forEach((group) => {
    ctx.socket.socket.join(group._id);
    return group;
});

const friends = await Friend
    .find({ from: user._id })
    .populate('to', { avatar: 1, username: 1 });

更新 socket 信息, 与注册相同

ctx.socket.user = user._id;
await Socket.update({ id: ctx.socket.id }, {
    user: user._id,
    os,
    browser,
    environment,
});

最后返回数据

用户名/密码与 token 登录仅一开始的逻辑不同, 没有解码 token 验证数据这步
先验证用户名是否存在, 然后验证密码是否匹配

const user = await User.findOne({ username });
assert(user, '该用户不存在');

const isPasswordCorrect = bcrypt.compareSync(password, user.password);
assert(isPasswordCorrect, '密码错误');

接下来逻辑就与 token 登录一致了

消息系统

发送消息

sendMessage 接口有三个参数:

  • to: 发送的对象, 群组或者用户
  • type: 消息类型
  • content: 消息内容

因为群聊和私聊共用这一个接口, 所以首先需要判断是群聊还是私聊, 获取群组id或者用户id, 群聊/私聊通过 to 参数区分
群聊时 to 是相应的群组id, 然后获取群组信息
私聊时 to 是发送者和接收者二人id拼接的结果, 去掉发送者id就得到了接收者id, 然后获取接收者信息

let groupId = '';
let userId = '';
if (isValid(to)) {
    const group = await Group.findOne({ _id: to });
    assert(group, '群组不存在');
} else {
    userId = to.replace(ctx.socket.user, '');
    assert(isValid(userId), '无效的用户ID');
    const user = await User.findOne({ _id: userId });
    assert(user, '用户不存在');
}

部分消息类型需要做些处理, text消息判断长度并做xss处理, invite消息判断邀请的群组是否存在, 然后将邀请人、群组id、群组名等信息存储到消息体中

let messageContent = content;
if (type === 'text') {
    assert(messageContent.length <= 2048, '消息长度过长');
    messageContent = xss(content);
} else if (type === 'invite') {
    const group = await Group.findOne({ name: content });
    assert(group, '目标群组不存在');

    const user = await User.findOne({ _id: ctx.socket.user });
    messageContent = JSON.stringify({
        inviter: user.username,
        groupId: group._id,
        groupName: group.name,
    });
}

将新消息存入数据库

let message;
try {
    message = await Message.create({
        from: ctx.socket.user,
        to,
        type,
        content: messageContent,
    });
} catch (err) {
    throw err;
}

接下来构造一个不包含敏感信息的消息数据, 数据中包含发送者的id、用户名、头像, 其中用户名和头像是比较冗余的数据, 以后考虑会优化成只传一个id, 客户端维护用户信息, 通过id匹配出用户名和头像, 能节约很多流量
如果是群聊消息, 直接把消息推送到对应群组即可
私聊消息更复杂一些, 因为 fiora 是允许多登录的, 首先需要推送给接收者的所有在线 socket, 然后还要推送给自身的其余在线 socket

const user = await User.findOne({ _id: ctx.socket.user }, { username: 1, avatar: 1 });
const messageData = {
    _id: message._id,
    createTime: message.createTime,
    from: user.toObject(),
    to,
    type,
    content: messageContent,
};

if (groupId) {
    ctx.socket.socket.to(groupId).emit('message', messageData);
} else {
    const sockets = await Socket.find({ user: userId });
    sockets.forEach((socket) => {
        ctx._io.to(socket.id).emit('message', messageData);
    });
    const selfSockets = await Socket.find({ user: ctx.socket.user });
    selfSockets.forEach((socket) => {
        if (socket.id !== ctx.socket.id) {
            ctx._io.to(socket.id).emit('message', messageData);
        }
    });
}

最后把消息数据返回给客户端, 表示消息发送成功. 客户端为了优化用户体验, 发送消息时会立即在页面上显示新信息, 同时请求接口发送消息. 如果消息发送失败, 就删掉该条消息

获取历史消息

getLinkmanHistoryMessages 接口有两个参数:

  • linkmanId: 联系人id, 群组或者俩用户id拼接
  • existCount: 已有的消息个数

详细逻辑比较简单, 按创建时间倒序查找已有个数 + 每次获取个数数量的消息, 然后去掉已有个数的消息再反转一下, 就是按时间排序的新消息

const messages = await Message
    .find(
        { to: linkmanId },
        { type: 1, content: 1, from: 1, createTime: 1 },
        { sort: { createTime: -1 }, limit: EachFetchMessagesCount + existCount },
    )
    .populate('from', { username: 1, avatar: 1 });
const result = messages.slice(existCount).reverse();

返回给客户端

接收推送消息

客户端订阅 message 事件接收新消息 socket.on('message')

接收到新消息时, 先判断 state 中是否存在该联系人, 如果存在则将消息存到对应的联系人下, 如果不存在则是一条临时会话的消息, 构造一个临时联系人并获取历史消息, 然后将临时联系人添加到 state 中. 如果是来自自己其它终端的消息, 则不需要创建联系人

const state = store.getState();
const isSelfMessage = message.from._id === state.getIn(['user', '_id']);
const linkman = state.getIn(['user', 'linkmans']).find(l => l.get('_id') === message.to);
let title = '';
if (linkman) {
    action.addLinkmanMessage(message.to, message);
    if (linkman.get('type') === 'group') {
        title = `${message.from.username}${linkman.get('name')} 对大家说:`;
    } else {
        title = `${message.from.username} 对你说:`;
    }
} else {
    // 联系人不存在并且是自己发的消息, 不创建新联系人
    if (isSelfMessage) {
        return;
    }
    const newLinkman = {
        _id: getFriendId(
            state.getIn(['user', '_id']),
            message.from._id,
        ),
        type: 'temporary',
        createTime: Date.now(),
        avatar: message.from.avatar,
        name: message.from.username,
        messages: [],
        unread: 1,
    };
    action.addLinkman(newLinkman);
    title = `${message.from.username} 对你说:`;

    fetch('getLinkmanHistoryMessages', { linkmanId: newLinkman._id }).then(([err, res]) => {
        if (!err) {
            action.addLinkmanMessages(newLinkman._id, res);
        }
    });
}

如果当前聊天页是在后台的, 并且打开了消息通知开关, 则会弹出桌面提醒

if (windowStatus === 'blur' && state.getIn(['ui', 'notificationSwitch'])) {
    notification(
        title,
        message.from.avatar,
        message.type === 'text' ? message.content : `[${message.type}]`,
        Math.random(),
    );
}

如果打开了声音开关, 则响一声新消息提示音

if (state.getIn(['ui', 'soundSwitch'])) {
    const soundType = state.getIn(['ui', 'sound']);
    sound(soundType);
}

如果打开了语言播报开关并且是文本消息, 将消息内的url和#过滤掉, 排除长度大于200的消息, 然后推送到消息朗读队列中

if (message.type === 'text' && state.getIn(['ui', 'voiceSwitch'])) {
    const text = message.content
        .replace(/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_+.~#?&//=]*)/g, '')
        .replace(/#/g, '');
    // The maximum number of words is 200
    if (text.length > 200) {
        return;
    }

    const from = linkman && linkman.get('type') === 'group' ?
        `${message.from.username}${linkman.get('name')}说`
        :
        `${message.from.username}对你说`;
    if (text) {
        voice.push(from !== prevFrom ? from + text : text, message.from.username);
    }
    prevFrom = from;
}

更多中间件

限制未登录请求

大多数接口是只允许已登录用户访问的, 如果接口需要登录且 socket 连接没有用户信息, 则返回"未登录"错误

/**
 * 拦截未登录请求
 */
module.exports = function () {
    const noUseLoginEvent = {
        register: true,
        login: true,
        loginByToken: true,
        guest: true,
        getDefalutGroupHistoryMessages: true,
        getDefaultGroupOnlineMembers: true,
    };
    return async (ctx, next) => {
        if (!noUseLoginEvent[ctx.event] && !ctx.socket.user) {
            ctx.res = '请登录后再试';
            return;
        }
        await next();
    };
};

限制调用频率

为了防止刷接口的情况, 减轻服务器压力, 限制同一 socket 连接每分钟内最多请求 30 次接口

const MaxCallPerMinutes = 30;
/**
 * Limiting the frequency of interface calls
 */
module.exports = function () {
    let callTimes = {};
    setInterval(() => callTimes = {}, 60000); // Emptying every 60 seconds

    return async (ctx, next) => {
        const socketId = ctx.socket.id;
        const count = callTimes[socketId] || 0;
        if (count >= MaxCallPerMinutes) {
            return ctx.res = '接口调用频繁';
        }
        callTimes[socketId] = count + 1;
        await next();
    };
};

小黑屋

管理员账号可以将用户添加到小黑屋, 被添加到小黑屋的用户无法请求任何接口, 10分钟后自动解禁

/**
 * Refusing to seal user requests
 */
module.exports = function () {
    return async (ctx, next) => {
        const sealList = global.mdb.get('sealList');
        if (ctx.socket.user && sealList.has(ctx.socket.user.toString())) {
            return ctx.res = '你已经被关进小黑屋中, 请反思后再试';
        }

        await next();
    };
};

其它有意思的东东

表情

表情是一张雪碧图, 点击表情会向输入框插入格式为 #(xx) 的文本, 例如 #(滑稽). 在渲染消息时, 通过正则匹配将这些文本替换为 <img>, 并计算出该表情在雪碧图中的位置, 然后渲染到页面上
不设置 src 会显示一个边框, 需要将 src 设置为一张透明图

function convertExpression(txt) {
    return txt.replace(
        /#\(([\u4e00-\u9fa5a-z]+)\)/g,
        (r, e) => {
            const index = expressions.default.indexOf(e);
            if (index !== -1) {
                return `<img class="expression-baidu" src="${transparentImage}" style="background-position: left ${-30 * index}px;" onerror="this.style.display='none'" alt="${r}">`;
            }
            return r;
        },
    );
}

表情包搜索

爬的 https://www.doutula.com 上的搜索结果

const res = await axios.get(`https://www.doutula.com/search?keyword=${encodeURIComponent(keywords)}`);
assert(res.status === 200, '搜索表情包失败, 请重试');

const images = res.data.match(/data-original="[^ "]+"/g) || [];
return images.map(i => i.substring(15, i.length - 1));

桌面消息通知

效果如上图, 不同系统/浏览器在样式上会有区别
经常有人问到这个是怎么实现的, 其实是 HTML5 增加的功能 Notification, 更多信息查看 https://developer.mozilla.org/en-US/docs/Web/API/notification

粘贴发图

监听 paste 事件, 获取粘贴内容, 如果包含 Files 类型内容, 则读取内容并生成 Image 对象. 注意: 通过该方式拿到的图片, 会比原图片体积大很多, 因此最好压缩一下再使用

@autobind
handlePaste(e) {
    const { items, types } = (e.clipboardData || e.originalEvent.clipboardData);

    // 如果包含文件内容
    if (types.indexOf('Files') > -1) {
        for (let index = 0; index < items.length; index++) {
            const item = items[index];
            if (item.kind === 'file') {
                const file = item.getAsFile();
                if (file) {
                    const that = this;
                    const reader = new FileReader();
                    reader.onloadend = function () {
                        const image = new Image();
                        image.onload = () => {
                            // 获取到 image 图片对象
                        };
                        image.src = this.result;
                    };
                    reader.readAsDataURL(file);
                }
            }
        }
        e.preventDefault();
    }
}

语言播报

这是用的百度的语言合成服务, 感谢百度. 详情请查看 http://ai.baidu.com/tech/speech/tts

历史版本

最初的版本
image

改了下背景和样式
image

基于react重写, 定下了 fiora 名称
image

风格开始偏向二次元, 加了些新功能
image

一个没有上线过的实验版本
image

目前线上跑的版本
image

后话

如果你对 Fiora 还有什么疑问, 可以随时来 https://fiora.suisuijiang.com/ 交流, 本人每天都会在线

@yinxin630 yinxin630 changed the title 单枪匹马写个吊炸天的聊天应用, 居然还同时支持Web和App 单枪匹马写个吊炸天的聊天应用, 而且还同时支持Web和App Jul 31, 2018
@yinxin630 yinxin630 changed the title 单枪匹马写个吊炸天的聊天应用, 而且还同时支持Web和App 单枪匹马撸个聊天室, 支持Web/Android/iOS三端 Jul 31, 2018
@JamalyYao
Copy link

开源代码吗?

@yinxin630
Copy link
Owner Author

@Scorpio-nan
Copy link

mark

@Guotykaka
Copy link

666

@kevinwang04
Copy link

不错

@plh97
Copy link

plh97 commented Aug 23, 2018

awesome啊,令人惊叹的作品

@plh97
Copy link

plh97 commented Aug 23, 2018

表情包搜索
这个都没看到你发请求,
难道作者已经把表情包都爬取下来了,保存在数据库了么?
image

@Surile
Copy link

Surile commented Aug 28, 2018

@pengliheng axios 不就是 请求吗。都请求了。const res 是用来存数据的啊。

@helloforrestworld
Copy link

@yinxin630 准备参考这个,撸个vue版本的!!

@hollyDysania
Copy link

先mark 又6又萌 简直不要太优秀

@Roamin
Copy link

Roamin commented Jul 18, 2019

仰望大佬

@ghost
Copy link

ghost commented Jul 25, 2019

仰望js大佬,表情包不错~

@immortalt
Copy link

准备参考这个,撸个vue版本的

@yinxin630
Copy link
Owner Author

准备参考这个,撸个vue版本的

加油

@chihiro2014
Copy link

大佬项目可以,最近在学习angular,回头参考这个撸一个ng版的

@iamafresh
Copy link

good job

@avrinfly
Copy link

avrinfly commented Dec 9, 2019

有vue版本的吗?

@yinxin630
Copy link
Owner Author

有vue版本的吗?

没有

@koearl
Copy link

koearl commented Mar 2, 2020

不错 学习了

@whalesink
Copy link

mark

@DXJian
Copy link

DXJian commented Aug 20, 2020

大佬你好,在npm run build的时候出现:
sh: ts-node: 未找到命令
npm ERR! file sh
npm ERR! code ELIFECYCLE
npm ERR! errno ENOENT
npm ERR! syscall spawn
npm ERR! fiora@0.0.1 build: ts-node --transpile-only build/build.ts
npm ERR! spawn ENOENT
这是什么原因啊,node版本v10.7.0

@yinxin630
Copy link
Owner Author

大佬你好,在npm run build的时候出现:
sh: ts-node: 未找到命令
npm ERR! file sh
npm ERR! code ELIFECYCLE
npm ERR! errno ENOENT
npm ERR! syscall spawn
npm ERR! fiora@0.0.1 build: ts-node --transpile-only build/build.ts
npm ERR! spawn ENOENT
这是什么原因啊,node版本v10.7.0

安装依赖了么 yarn install

@CorgiTT
Copy link

CorgiTT commented Nov 30, 2020

大佬,能说明下用react hooks 下拉无限加载历史信息,每次加载滚动条位置还能不动的原理吗

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests