黑马程序员技术交流社区
标题:
【上海校区】单枪匹马撸个聊天室, 支持Web/Android/iOS三端
[打印本页]
作者:
不二晨
时间:
2018-8-3 10:20
标题:
【上海校区】单枪匹马撸个聊天室, 支持Web/Android/iOS三端
原文地址:
github.com/yinxin630/b…
前排提醒, 阅读本文需要对JavaScript较为熟悉, 本文将讲解核心功能点的设计思路
源码地址:
github.com/yinxin630/f…
在线地址:
fiora.suisuijiang.com/
前言
该项目起始于2015年底, 也是我刚开始学习 JavaScript 的时候, 当时仅仅是想做个练手项目. 后面随着在前端领域的深入学习, 也一直在更新技术栈, 目前已经是重构后的第五个版本
得益于 node.js 和 react-native 的出现, 使得 jser 的触手伸到了服务端和APP端. 本项目服务端基于 node.js 技术, 使用了 koa 框架, 所有数据存储在 mongodb 中. 客户端使用 react 框架, 使用 redux 和 immutable.js 管理状态, 自己设计了一套简约范的UI风格, APP端基于 react-native 和 expo 开发. 项目部署在我的乞丐版阿里云ECS上, 学生机配置单核1G内存
服务端架构
服务端负责两件事:
提供基于WebSocket 的接口
提供 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 三个消息
connect: socket 连接成功
disconnect socket 连接断开
message 接收到新消息
客户端使用
redux
管理数据, 需要被组件共享的数据放在 redux 中, 只有自身使用的数据还是放在组件的 state 中, 客户端存储的 redux 数据结构如下:
user 用户信息
_id 用户id
username 用户名
linkmans 联系人列表, 包括群组、好友以及临时会话
isAdmin 是否是管理员
focus 当前聚焦的联系人id, 既对话中的目标
connect 连接状态
ui 客户端 UI 相关和功能开关
客户端的数据流, 主要有两条线路
用户操作 => 请求接口 => 返回数据 => 更新redux => 视图重新渲染
监听新消息 => 处理数据 => 更新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;}复制代码将用户添加到默认群组, 然后生成用户 tokentoken 是用来免密码登录的凭证, 存储在客户端 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;" alt="${r}">`; } return r; }, );}复制代码
表情包搜索
爬的
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, 更多信息查看
developer.mozilla.org/en-US/docs/…
粘贴发图
监听 paste 事件, 获取粘贴内容, 如果包含 Files 类型内容, 则读取内容并生成 Image 对象. 注意: 通过该方式拿到的图片, 会比原图片体积大很多, 因此最好压缩一下再使用
@autobindhandlePaste(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(); }}复制代码
语言播报
这是用的百度的语言合成服务, 感谢百度. 详情请查看
ai.baidu.com/tech/speech…
作者:碎碎酱
链接:
https://juejin.im/post/5b626a096fb9a04fdd7d7433
作者:
不二晨
时间:
2018-8-16 17:26
奈斯
欢迎光临 黑马程序员技术交流社区 (http://bbs.itheima.com/)
黑马程序员IT技术论坛 X3.2