「实战」搭建完整的IM(即时通讯)应用(2)

即时通讯应用服务,整套包含服务端管理端客户端,欢迎Star支持和查看源码。

现已部署上线,欢迎体验客户端管理端

咱们书接上文,继续完成完整的即时通讯服务,这篇着重讲下Server端项目中我认为几个重要的点,大部分内容需要去我的仓库源码和 egg 官网查看。

server 端详细说明

使用脚手架npm init egg --type=simple初始化 server 项目,安装 mysql(我的是 8.0 版本),配置上 sequelize 所需的数据库链接密码等,就可以启动了
着重讲下 Server 端项目中我认为几个重要的点,大部分内容需要去 egg 官网查看。

// 目录结构说明

├── package.json // 项目信息
├── app.js // 启动文件,其中有一些钩子函数
├── app
|   ├── router.js // 路由
│   ├── controller
│   ├── service
│   ├── middleware // 中间件
│   ├── model // 实体模型
│   └── io // socket.io 相关
│       ├── controller
│       └── middleware // io独有的中间件
├── config // 配置文件
|   ├── plugin.js // 插件配置文件
|   └── config.default.js // 默认的配置文件
├── logs // server运行期间产生的log文件
└── public // 静态文件和上传文件目录

路由

Router 主要用来描述请求 URL 和具体承担执行动作的 Controller 的对应关系,即 app/router

  1. 路由使用了版本号 v1,方便以后升级,一般的增删改查直接使用 restful 的方式比较简单
  2. 除了登录和注册的接口,在其余所有 http 接口添加了对 session 的检查,校验登录状态,位置在app/middleware/auth.js
  3. 在所有管理端的接口处添加了对 admin 权限的检查,位置在app/middleware/admin.js

统一鉴权

因为本系统预设有管理员和一般通信用户的不同角色,所以需要针对管理和通信的接口路由做一下统一的鉴权处理。

比如管理端的路由/v1/admin/...,想在这个系列路由全都添加管理员的鉴权,这时候可以用中间件的方式进行鉴权,下面是在 admin router 中使用中间件的具体例子

// middware
module.exports = () => {
  return async function admin(ctx, next) {
    let { session } = ctx;

    // 判断admin权限
    if (session.user && session.user.rights.some(right => right.keyName === 'admin')) {
      await next();
    } else {
      ctx.redirect('/login');
    }
  };
};

// router
const admin = app.middleware.admin();
router.get('/api/v1/admin/rights', admin, controller.v1.admin.rightsIndex);

数据库相关

使用的 sequelize+mysql 组合,egg 也有 sequelize 的相关插件,sequelize 即是一款 Node 环境使用的 ORM,支持 Postgres, MySQL, MariaDB, SQLite 和 Microsoft SQL Server,使用起来还是挺方便的。需要先定义模型和模型直接的关系,有了关系之后便可以使用一些预设的方法了。

model 实体模型

模型的基础信息比较容易处理,需要注意的就是实体之间的关系设计,即 associate,下面是 user 的关系描述

// User.js
module.exports = app => {
  const { STRING } = app.Sequelize;

  const User = app.model.define('user', {
    provider: {
      type: STRING
    },
    username: {
      type: STRING,
      unique: 'username'
    },
    password: {
      type: STRING
    }
  });

  User.associate = function() {
    // One-To-One associations
    app.model.User.hasOne(app.model.UserInfo);

    // One-To-Many associations
    app.model.User.hasMany(app.model.Apply);

    // Many-To-Many associations
    app.model.User.belongsToMany(app.model.Group, { through: 'user_group' });
    app.model.User.belongsToMany(app.model.Role, { through: 'user_role' });
  };

  return User;
};

一对一

例如 user 和 userInfo 的关系就是一对一的关系,定义好了之后,我们在新建 user 的时候就可以使用 user.setUserInfo(userInfo)了,想获取此 user 的基础信息的时候也可以通过user.getUserInfo()

一对多

User 和 Apply(申请)的关系就是一对多,即一个用户可以对应多个自己的申请,目前只有好友申请和入群申请:

添加申请的时候可以user.addApply(apply),获取的时候可以这样获取:

const result = await ctx.model.Apply.findAndCountAll({
  where: {
    userId: ctx.session.user.id,
    hasHandled: false
  }
});

多对多

user 和 group 的关系就是多对多,即一个用户可以对应多个群组,一个群组也可以对应多个用户,这样 sequelize 会建立一个中间表 user_group 来实现这种关系。

一般我这么使用:

group.addUser(user); // 建立群组和用户的关系
user.getGroups(); // 获取用户的群组信息

需要注意的点

  1. sequelize 的所有操作都是基于 Promise 的,所有大多时候都使用 await 进行等待
  2. 修改了某个模型的实例的某个属性后,需要进行 save
  3. 当我们需要把模型的数据进行组合后返回给前端的时候,需要通过 get({plain: true})这种方式,转化成数据,然后再拼接,例如获取会话列表的时候

socketio

egg 提供了 egg-socket.io 插件,需要在安装 egg-socket.io 后在 config/plugin.js 开启插件,io 有自己的中间件和 controller

socketio 的路由

io 的路由和一般的 http 请求的不太一样,注意这里的路由不能添加中间件处理(我没成功),所以禁言处理我是在 controller 里面处理的

// 加入群
io.of('/').route('/v1/im/join', app.io.controller.im.join);
// 发送消息
io.of('/').route('/v1/im/new-message', app.io.controller.im.newMessage);
// 查询消息
io.of('/').route('/v1/im/get-messages', app.io.controller.im.getMessages);

注意:我把群组和好友关系都看做是一个 room(也就是一个会话),这样就是直接向这个 romm 里面发消息,里面的人都可以收到

socketio 的中间件

有两个默认的中间件,一个是连接和断开时候调用的 connection Middleware,这里用来校验登录状态和处理业务逻辑了;另外一个是每次发消息时候调用的 packet Middleware,这里用来打印 log

由于预设了禁言权限,在 controller 里面进行处理

// 对用户发言的权限进行判断
if (!ctx.session.user.rights.some(right => right.keyName === 'speak')) {
  return;
}

聊天

聊天分为单聊和群聊,聊天信息暂时有一般的文字、图片、视频和定位消息,可以根据业务扩展为订单或者商品等

消息

message 的结构设计参考了几家第三方服务的设计,也结合本项目自身的情况做了调整,可以随意扩展,做如下说明:

const Message = app.model.define('message', {
  /**
    * 消息类型:
    * 0:单聊
    * 1:群聊
    */
  type: {
    type: STRING
  },
  // 消息体
  body: {
    type: JSON
  },
  fromId: { type: INTEGER },
  toId: { type: INTEGER }
});

body 里面存放的是消息体,使用 json 用来存放不同的消息格式:

// 文本消息
{
  "type": "txt",
  "msg":"哈哈哈" //消息内容
}
// 图片消息
{
  "type": "img",
  "url": "http://nimtest.nos.netease.com/cbc500e8-e19c-4b0f-834b-c32d4dc1075e",
  "ext":"jpg",
  "w":360,    //宽
  "h":480,    //高
  "size": 388245
}
// 视频消息
{
  "type": 'video',
  "url": "http://nimtest.nos.netease.com/cbc500e8-e19c-4b0f-834b-c32d4dc1075e",
  "ext":"mp4",
  "w":360,    //宽
  "h":480,    //高
  "size": 388245
}
// 地理位置消息
{
  "type": "loc",
  "title":"中国 浙江省 杭州市 网商路 599号",    //地理位置title
  "lng":120.1908686708565,        // 经度
  "lat":30.18704515647036            // 纬度
}

定时任务

当前只有一个,就是更新 baidu 的 token,这里还算简单,参考官方文档即可

机器人聊天

智能对话定制与服务平台 UNIT

这个还是挺有意思的,可以在 https://ai.baidu.com/ 新建机器人和添加对应的技能,我这里是闲聊,还有智能问答等可以选择

  1. 新建机器人,管理机器人的技能,至少一个
  2. 前往百度云"应用列表"中创建、查看 API Key / Secret Key
  3. 在 config.default.js 中配置 baidu 相关参数,相关接口说明在这里

如果不想启动可以在 app.js 和 app/schedule/baidu.js 中删除 ctx.service.baidu.getToken();

上传文件

首先需要在配置文件里面进行配置,我这里限制了文件大小,饼跨站了 ios 的视频文件格式:

config.multipart = {
  mode: 'file',
  fileSize: '3mb',
  fileExtensions: ['.mov']
};

使用了一个统一的接口来处理文件上传,核心问题是文件的写入,files 是前端传来的文件列表

for (const file of ctx.request.files) {
  // 生成文件路径,注意upload文件路径需要存在
  const filePath = `./public/upload/${
    Date.now() + Math.floor(Math.random() * 100000).toString() + '.' + file.filename.split('.').pop()
  }`;
  const reader = fs.createReadStream(file.filepath); // 创建可读流
  const upStream = fs.createWriteStream(filePath); // 创建可写流
  reader.pipe(upStream); // 可读流通过管道写入可写流
  data.push({
    url: filePath.slice(1)
  });
}

我这里是存储到了 server 目录的/public/upload/,这个目录需要做一下静态文件的配置:

config.static = {
  prefix: '/public/',
  dir: path.join(appInfo.baseDir, 'public')
};

passport

这个章节的 egg 官方文档,要你的命,例子啥也没有,一定要去看源码,太坑人了,我研究了很久才弄明白是怎么回事。

因为我想更自由的控制账户密码登录,所以账号密码登录并没有使用 passport,使用的就是普通的接口认证配合 session。

下面详细说下使用第三方平台(我选用的是 GitHub)登录的过程:

  1. GitHub OAuth Apps新建你的应用,获取 key 和 secret
  2. 在项目安装 egg-passport 和 egg-passport-github

开启插件:

// config/plugin.js
module.exports.passport = {
  enable: true,
  package: 'egg-passport',
};

module.exports.passportGithub = {
  enable: true,
  package: 'egg-passport-github',
};
  1. 配置:
// config.default.js
config.passportGithub = {
  key: 'your_clientID',
  secret: 'your_clientSecret',
  callbackURL: 'http://localhost:3000/api/v1/passport/github/callback' // 注意这里非常的关键,这里需要和你在github上面设置的Authorization callback URL一致
};
  1. 在 app.js 中开启 passport
this.app.passport.verify(verify);
  1. 需要设置两个 passport 的 get 请求路由,第一个是我们在 login 页面点击的请求,第二个是我们在上一步设置的 callbackURL,这里主要是第三方平台会给我们一个可用的 code,然后根据 OAuth2 授权规则去获取用户的详细信息
const github = app.passport.authenticate('github', { successRedirect: '/' }); // successRedirect就是最后校验完毕后前端会跳转的路由,我这里直接跳转到主页了
router.get('/v1/passport/github', github);
router.get('/v1/passport/github/callback', github);
  1. 这时候在前端点击/v1/passport/github会发起 github 对这个应用的授权,成功后 github 会 302 到http://localhost:3000/v1/passport/github/callback?code=12313123123,我们的 githubPassport 插件会去获取用户在 github 上的信息,获取到详细信息后,我们需要在 app/passport/verify.js 去验证用户信息,并且和我们自身平台的用户信息做关联,也要给 session 赋值
// verify.js
module.exports = async (ctx, githubUser) => {
  const { service } = ctx;
  const { provider, name, photo, displayName } = githubUser;
  ctx.logger.info('githubUser', { provider, name, photo, displayName });

  let user = await ctx.model.User.findOne({
    where: {
      username: name
    }
  });

  if (!user) {
    user = await ctx.model.User.create({
      provider,
      username: name
    });
    const userInfo = await ctx.model.UserInfo.create({
      nickname: displayName,
      photo
    });
    const role = await ctx.model.Role.findOne({
      where: {
        keyName: 'user'
      }
    });
    user.setUserInfo(userInfo);
    user.addRole(role);
    await user.save();
  }
  const { rights, roles } = await service.user.getUserAttribute(user.id);

  // 权限判断
  if (!rights.some(item => item.keyName === 'login')) {
    ctx.body = {
      statusCode: '1',
      errorMessage: '不具备登录权限'
    };
    return;
  }

  ctx.session.user = {
    id: user.id,
    roles,
    rights
  };

  return githubUser;
};

注意看上面的代码,如果是首次授权将会创建这个用户,如果是第二次授权,那么用户已经被创建了

初始化

系统部署或者运行的时候,需要预设一些数据和表,代码在app.jsapp/service/startup.js

逻辑就是项目启动完毕后,利用 model 同步表结构到数据库中,然后开始新建一些基础数据:

  1. 新建角色和权限,并给角色分配权限
  2. 新建不同用户,分配角色
  3. 给一些用户建立好友关系
  4. 添加申请
  5. 创建群组,并添加一些人

做完以上这些就算是完成了初始数据了,可以进行正常的运转

部署

我是在腾讯云买的服务器 centos,在阿里云买的域名,装了 node(12.18.2) 、 nginx 和 mysql8.0,直接在 centos 上面启动,前端使用 nginx 进行反向代理。由于服务器资源有限,没有使用一些自动化工具 Jenkins 和 Docker,这就导致了我在更新的时候得有一些手动操作。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,332评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,508评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,812评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,607评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,728评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,919评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,071评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,802评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,256评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,576评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,712评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,389评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,032评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,798评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,026评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,473评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,606评论 2 350