原文地址: github.com/yinxin630/b…html
前排提醒, 阅读本文须要对JavaScript较为熟悉, 本文将讲解核心功能点的设计思路前端
该项目起始于2015年末, 也是我刚开始学习 JavaScript 的时候, 当时仅仅是想作个练手项目. 后面随着在前端领域的深刻学习, 也一直在更新技术栈, 目前已是重构后的第五个版本node
得益于 node.js
和 react-native
的出现, 使得 jser 的触手伸到了服务端和APP端. 本项目服务端基于 node.js 技术, 使用了 koa 框架, 全部数据存储在 mongodb 中. 客户端使用 react 框架, 使用 redux 和 immutable.js 管理状态, 本身设计了一套简约范的UI风格, APP端基于 react-native 和 expo 开发. 项目部署在个人乞丐版阿里云ECS上, 学生机配置单核1G内存react
服务端负责两件事:ios
服务端使用了 koa-socket 这个包, 它集成了 socket.io 并实现了 socket 中间件机制, 服务端基于该中间件机制, 本身实现了一套接口路由git
每一个接口都是一个 async 函数, 函数名即接口名, 同时也是 socket 事件名github
async login(ctx) {
return 'login success'
}
复制代码
而后写了个 route
中间件, 用来完成路由匹配, 当判断路由匹配时, 以 ctx
对象做为参数执行路由方法, 并将方法返回值做为接口返回值mongodb
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);
}
};
};
复制代码
这些就是服务端的核心逻辑, 基于该架构下定义接口组成业务逻辑express
另外, 服务端还负责提供 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 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登陆是最经常使用的, 客户端首先从 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;
},
);
}
复制代码
爬的 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
对象. 注意: 经过该方式拿到的图片, 会比原图片体积大不少, 所以最好压缩一下再使用
@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();
}
}
复制代码
这是用的百度的语言合成服务, 感谢百度. 详情请查看 ai.baidu.com/tech/speech…
最初的版本
改了下背景和样式
基于react重写, 定下了 fiora
名称
风格开始偏向二次元, 加了些新功能
一个没有上线过的实验版本
目前线上跑的版本
若是你对 Fiora 还有什么疑问, 能够随时来 fiora.suisuijiang.com/ 交流, 本人天天都会在线