「實(shí)戰(zhàn)」搭建完整的IM(即時(shí)通訊)應(yīng)用(2)

即時(shí)通訊應(yīng)用服務(wù)奏属,整套包含服務(wù)端伶丐、管理端客戶(hù)端悼做,歡迎Star支持和查看源碼。

現(xiàn)已部署上線(xiàn)哗魂,歡迎體驗(yàn)客戶(hù)端管理端

咱們書(shū)接上文肛走,繼續(xù)完成完整的即時(shí)通訊服務(wù),這篇著重講下Server端項(xiàng)目中我認(rèn)為幾個(gè)重要的點(diǎn)录别,大部分內(nèi)容需要去我的倉(cāng)庫(kù)源碼和 egg 官網(wǎng)查看朽色。

server 端詳細(xì)說(shuō)明

使用腳手架npm init egg --type=simple初始化 server 項(xiàng)目,安裝 mysql(我的是 8.0 版本)组题,配置上 sequelize 所需的數(shù)據(jù)庫(kù)鏈接密碼等葫男,就可以啟動(dòng)了
著重講下 Server 端項(xiàng)目中我認(rèn)為幾個(gè)重要的點(diǎn),大部分內(nèi)容需要去 egg 官網(wǎng)查看崔列。

// 目錄結(jié)構(gòu)說(shuō)明

├── package.json // 項(xiàng)目信息
├── app.js // 啟動(dòng)文件梢褐,其中有一些鉤子函數(shù)
├── app
|   ├── router.js // 路由
│   ├── controller
│   ├── service
│   ├── middleware // 中間件
│   ├── model // 實(shí)體模型
│   └── io // socket.io 相關(guān)
│       ├── controller
│       └── middleware // io獨(dú)有的中間件
├── config // 配置文件
|   ├── plugin.js // 插件配置文件
|   └── config.default.js // 默認(rèn)的配置文件
├── logs // server運(yùn)行期間產(chǎn)生的log文件
└── public // 靜態(tài)文件和上傳文件目錄

路由

Router 主要用來(lái)描述請(qǐng)求 URL 和具體承擔(dān)執(zhí)行動(dòng)作的 Controller 的對(duì)應(yīng)關(guān)系,即 app/router

  1. 路由使用了版本號(hào) v1,方便以后升級(jí)盈咳,一般的增刪改查直接使用 restful 的方式比較簡(jiǎn)單
  2. 除了登錄和注冊(cè)的接口耿眉,在其余所有 http 接口添加了對(duì) session 的檢查,校驗(yàn)登錄狀態(tài)鱼响,位置在app/middleware/auth.js
  3. 在所有管理端的接口處添加了對(duì) admin 權(quán)限的檢查鸣剪,位置在app/middleware/admin.js

統(tǒng)一鑒權(quán)

因?yàn)楸鞠到y(tǒng)預(yù)設(shè)有管理員和一般通信用戶(hù)的不同角色,所以需要針對(duì)管理和通信的接口路由做一下統(tǒng)一的鑒權(quán)處理热押。

比如管理端的路由/v1/admin/...西傀,想在這個(gè)系列路由全都添加管理員的鑒權(quán)斤寇,這時(shí)候可以用中間件的方式進(jìn)行鑒權(quán)桶癣,下面是在 admin router 中使用中間件的具體例子

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

    // 判斷admin權(quán)限
    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);

數(shù)據(jù)庫(kù)相關(guān)

使用的 sequelize+mysql 組合,egg 也有 sequelize 的相關(guān)插件娘锁,sequelize 即是一款 Node 環(huán)境使用的 ORM牙寞,支持 Postgres, MySQL, MariaDB, SQLite 和 Microsoft SQL Server,使用起來(lái)還是挺方便的莫秆。需要先定義模型和模型直接的關(guān)系间雀,有了關(guān)系之后便可以使用一些預(yù)設(shè)的方法了。

model 實(shí)體模型

模型的基礎(chǔ)信息比較容易處理镊屎,需要注意的就是實(shí)體之間的關(guān)系設(shè)計(jì)惹挟,即 associate,下面是 user 的關(guān)系描述

// 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;
};

一對(duì)一

例如 user 和 userInfo 的關(guān)系就是一對(duì)一的關(guān)系缝驳,定義好了之后连锯,我們?cè)谛陆?user 的時(shí)候就可以使用 user.setUserInfo(userInfo)了,想獲取此 user 的基礎(chǔ)信息的時(shí)候也可以通過(guò)user.getUserInfo()

一對(duì)多

User 和 Apply(申請(qǐng))的關(guān)系就是一對(duì)多用狱,即一個(gè)用戶(hù)可以對(duì)應(yīng)多個(gè)自己的申請(qǐng)运怖,目前只有好友申請(qǐng)和入群申請(qǐng):

添加申請(qǐng)的時(shí)候可以user.addApply(apply),獲取的時(shí)候可以這樣獲认囊痢:

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

多對(duì)多

user 和 group 的關(guān)系就是多對(duì)多摇展,即一個(gè)用戶(hù)可以對(duì)應(yīng)多個(gè)群組,一個(gè)群組也可以對(duì)應(yīng)多個(gè)用戶(hù)溺忧,這樣 sequelize 會(huì)建立一個(gè)中間表 user_group 來(lái)實(shí)現(xiàn)這種關(guān)系咏连。

一般我這么使用:

group.addUser(user); // 建立群組和用戶(hù)的關(guān)系
user.getGroups(); // 獲取用戶(hù)的群組信息

需要注意的點(diǎn)

  1. sequelize 的所有操作都是基于 Promise 的,所有大多時(shí)候都使用 await 進(jìn)行等待
  2. 修改了某個(gè)模型的實(shí)例的某個(gè)屬性后鲁森,需要進(jìn)行 save
  3. 當(dāng)我們需要把模型的數(shù)據(jù)進(jìn)行組合后返回給前端的時(shí)候捻勉,需要通過(guò) get({plain: true})這種方式,轉(zhuǎn)化成數(shù)據(jù)刀森,然后再拼接踱启,例如獲取會(huì)話(huà)列表的時(shí)候

socketio

egg 提供了 egg-socket.io 插件,需要在安裝 egg-socket.io 后在 config/plugin.js 開(kāi)啟插件,io 有自己的中間件和 controller

socketio 的路由

io 的路由和一般的 http 請(qǐng)求的不太一樣埠偿,注意這里的路由不能添加中間件處理(我沒(méi)成功)透罢,所以禁言處理我是在 controller 里面處理的

// 加入群
io.of('/').route('/v1/im/join', app.io.controller.im.join);
// 發(fā)送消息
io.of('/').route('/v1/im/new-message', app.io.controller.im.newMessage);
// 查詢(xún)消息
io.of('/').route('/v1/im/get-messages', app.io.controller.im.getMessages);

注意:我把群組和好友關(guān)系都看做是一個(gè) room(也就是一個(gè)會(huì)話(huà)),這樣就是直接向這個(gè) romm 里面發(fā)消息冠蒋,里面的人都可以收到

socketio 的中間件

有兩個(gè)默認(rèn)的中間件羽圃,一個(gè)是連接和斷開(kāi)時(shí)候調(diào)用的 connection Middleware,這里用來(lái)校驗(yàn)登錄狀態(tài)和處理業(yè)務(wù)邏輯了抖剿;另外一個(gè)是每次發(fā)消息時(shí)候調(diào)用的 packet Middleware朽寞,這里用來(lái)打印 log

由于預(yù)設(shè)了禁言權(quán)限,在 controller 里面進(jìn)行處理

// 對(duì)用戶(hù)發(fā)言的權(quán)限進(jìn)行判斷
if (!ctx.session.user.rights.some(right => right.keyName === 'speak')) {
  return;
}

聊天

聊天分為單聊和群聊斩郎,聊天信息暫時(shí)有一般的文字脑融、圖片、視頻和定位消息缩宜,可以根據(jù)業(yè)務(wù)擴(kuò)展為訂單或者商品等

消息

message 的結(jié)構(gòu)設(shè)計(jì)參考了幾家第三方服務(wù)的設(shè)計(jì)肘迎,也結(jié)合本項(xiàng)目自身的情況做了調(diào)整,可以隨意擴(kuò)展锻煌,做如下說(shuō)明:

const Message = app.model.define('message', {
  /**
    * 消息類(lèi)型:
    * 0:單聊
    * 1:群聊
    */
  type: {
    type: STRING
  },
  // 消息體
  body: {
    type: JSON
  },
  fromId: { type: INTEGER },
  toId: { type: INTEGER }
});

body 里面存放的是消息體妓布,使用 json 用來(lái)存放不同的消息格式:

// 文本消息
{
  "type": "txt",
  "msg":"哈哈哈" //消息內(nèi)容
}
// 圖片消息
{
  "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":"中國(guó) 浙江省 杭州市 網(wǎng)商路 599號(hào)",    //地理位置title
  "lng":120.1908686708565,        // 經(jīng)度
  "lat":30.18704515647036            // 緯度
}

定時(shí)任務(wù)

當(dāng)前只有一個(gè),就是更新 baidu 的 token宋梧,這里還算簡(jiǎn)單匣沼,參考官方文檔即可

機(jī)器人聊天

智能對(duì)話(huà)定制與服務(wù)平臺(tái) UNIT

這個(gè)還是挺有意思的,可以在 https://ai.baidu.com/ 新建機(jī)器人和添加對(duì)應(yīng)的技能捂龄,我這里是閑聊释涛,還有智能問(wèn)答等可以選擇

  1. 新建機(jī)器人,管理機(jī)器人的技能跺讯,至少一個(gè)
  2. 前往百度云"應(yīng)用列表"中創(chuàng)建枢贿、查看 API Key / Secret Key
  3. 在 config.default.js 中配置 baidu 相關(guān)參數(shù),相關(guān)接口說(shuō)明在這里

如果不想啟動(dòng)可以在 app.js 和 app/schedule/baidu.js 中刪除 ctx.service.baidu.getToken();

上傳文件

首先需要在配置文件里面進(jìn)行配置刀脏,我這里限制了文件大小局荚,餅跨站了 ios 的視頻文件格式:

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

使用了一個(gè)統(tǒng)一的接口來(lái)處理文件上傳,核心問(wèn)題是文件的寫(xiě)入愈污,files 是前端傳來(lái)的文件列表

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); // 創(chuàng)建可讀流
  const upStream = fs.createWriteStream(filePath); // 創(chuàng)建可寫(xiě)流
  reader.pipe(upStream); // 可讀流通過(guò)管道寫(xiě)入可寫(xiě)流
  data.push({
    url: filePath.slice(1)
  });
}

我這里是存儲(chǔ)到了 server 目錄的/public/upload/,這個(gè)目錄需要做一下靜態(tài)文件的配置:

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

passport

這個(gè)章節(jié)的 egg 官方文檔暂雹,要你的命首装,例子啥也沒(méi)有,一定要去看源碼杭跪,太坑人了仙逻,我研究了很久才弄明白是怎么回事驰吓。

因?yàn)槲蚁敫杂傻目刂瀑~戶(hù)密碼登錄,所以賬號(hào)密碼登錄并沒(méi)有使用 passport系奉,使用的就是普通的接口認(rèn)證配合 session檬贰。

下面詳細(xì)說(shuō)下使用第三方平臺(tái)(我選用的是 GitHub)登錄的過(guò)程:

  1. GitHub OAuth Apps新建你的應(yīng)用,獲取 key 和 secret
  2. 在項(xiàng)目安裝 egg-passport 和 egg-passport-github

開(kāi)啟插件:

// 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' // 注意這里非常的關(guān)鍵缺亮,這里需要和你在github上面設(shè)置的Authorization callback URL一致
};
  1. 在 app.js 中開(kāi)啟 passport
this.app.passport.verify(verify);
  1. 需要設(shè)置兩個(gè) passport 的 get 請(qǐng)求路由翁涤,第一個(gè)是我們?cè)?login 頁(yè)面點(diǎn)擊的請(qǐng)求,第二個(gè)是我們?cè)谏弦徊皆O(shè)置的 callbackURL萌踱,這里主要是第三方平臺(tái)會(huì)給我們一個(gè)可用的 code葵礼,然后根據(jù) OAuth2 授權(quán)規(guī)則去獲取用戶(hù)的詳細(xì)信息
const github = app.passport.authenticate('github', { successRedirect: '/' }); // successRedirect就是最后校驗(yàn)完畢后前端會(huì)跳轉(zhuǎn)的路由,我這里直接跳轉(zhuǎn)到主頁(yè)了
router.get('/v1/passport/github', github);
router.get('/v1/passport/github/callback', github);
  1. 這時(shí)候在前端點(diǎn)擊/v1/passport/github會(huì)發(fā)起 github 對(duì)這個(gè)應(yīng)用的授權(quán)并鸵,成功后 github 會(huì) 302 到http://localhost:3000/v1/passport/github/callback?code=12313123123鸳粉,我們的 githubPassport 插件會(huì)去獲取用戶(hù)在 github 上的信息,獲取到詳細(xì)信息后能真,我們需要在 app/passport/verify.js 去驗(yàn)證用戶(hù)信息赁严,并且和我們自身平臺(tái)的用戶(hù)信息做關(guān)聯(lián)扰柠,也要給 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);

  // 權(quán)限判斷
  if (!rights.some(item => item.keyName === 'login')) {
    ctx.body = {
      statusCode: '1',
      errorMessage: '不具備登錄權(quán)限'
    };
    return;
  }

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

  return githubUser;
};

注意看上面的代碼粉铐,如果是首次授權(quán)將會(huì)創(chuàng)建這個(gè)用戶(hù),如果是第二次授權(quán)卤档,那么用戶(hù)已經(jīng)被創(chuàng)建了

初始化

系統(tǒng)部署或者運(yùn)行的時(shí)候蝙泼,需要預(yù)設(shè)一些數(shù)據(jù)和表,代碼在app.jsapp/service/startup.js

邏輯就是項(xiàng)目啟動(dòng)完畢后劝枣,利用 model 同步表結(jié)構(gòu)到數(shù)據(jù)庫(kù)中汤踏,然后開(kāi)始新建一些基礎(chǔ)數(shù)據(jù):

  1. 新建角色和權(quán)限,并給角色分配權(quán)限
  2. 新建不同用戶(hù)舔腾,分配角色
  3. 給一些用戶(hù)建立好友關(guān)系
  4. 添加申請(qǐng)
  5. 創(chuàng)建群組溪胶,并添加一些人

做完以上這些就算是完成了初始數(shù)據(jù)了,可以進(jìn)行正常的運(yùn)轉(zhuǎn)

部署

我是在騰訊云買(mǎi)的服務(wù)器 centos稳诚,在阿里云買(mǎi)的域名哗脖,裝了 node(12.18.2) 、 nginx 和 mysql8.0扳还,直接在 centos 上面啟動(dòng)才避,前端使用 nginx 進(jìn)行反向代理。由于服務(wù)器資源有限氨距,沒(méi)有使用一些自動(dòng)化工具 Jenkins 和 Docker桑逝,這就導(dǎo)致了我在更新的時(shí)候得有一些手動(dòng)操作。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末俏让,一起剝皮案震驚了整個(gè)濱河市楞遏,隨后出現(xiàn)的幾起案子茬暇,更是在濱河造成了極大的恐慌,老刑警劉巖寡喝,帶你破解...
    沈念sama閱讀 207,113評(píng)論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件而钞,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡拘荡,警方通過(guò)查閱死者的電腦和手機(jī)臼节,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,644評(píng)論 2 381
  • 文/潘曉璐 我一進(jìn)店門(mén),熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái)珊皿,“玉大人网缝,你說(shuō)我怎么就攤上這事◇ǎ” “怎么了粉臊?”我有些...
    開(kāi)封第一講書(shū)人閱讀 153,340評(píng)論 0 344
  • 文/不壞的土叔 我叫張陵,是天一觀(guān)的道長(zhǎng)驶兜。 經(jīng)常有香客問(wèn)我扼仲,道長(zhǎng),這世上最難降的妖魔是什么抄淑? 我笑而不...
    開(kāi)封第一講書(shū)人閱讀 55,449評(píng)論 1 279
  • 正文 為了忘掉前任屠凶,我火速辦了婚禮,結(jié)果婚禮上肆资,老公的妹妹穿的比我還像新娘矗愧。我一直安慰自己,他們只是感情好郑原,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,445評(píng)論 5 374
  • 文/花漫 我一把揭開(kāi)白布唉韭。 她就那樣靜靜地躺著,像睡著了一般犯犁。 火紅的嫁衣襯著肌膚如雪属愤。 梳的紋絲不亂的頭發(fā)上,一...
    開(kāi)封第一講書(shū)人閱讀 49,166評(píng)論 1 284
  • 那天酸役,我揣著相機(jī)與錄音住诸,去河邊找鬼。 笑死簇捍,一個(gè)胖子當(dāng)著我的面吹牛只壳,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播暑塑,決...
    沈念sama閱讀 38,442評(píng)論 3 401
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼吼句,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來(lái)了事格?” 一聲冷哼從身側(cè)響起惕艳,我...
    開(kāi)封第一講書(shū)人閱讀 37,105評(píng)論 0 261
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤搞隐,失蹤者是張志新(化名)和其女友劉穎,沒(méi)想到半個(gè)月后远搪,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體劣纲,經(jīng)...
    沈念sama閱讀 43,601評(píng)論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,066評(píng)論 2 325
  • 正文 我和宋清朗相戀三年谁鳍,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了癞季。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 38,161評(píng)論 1 334
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡倘潜,死狀恐怖绷柒,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情涮因,我是刑警寧澤废睦,帶...
    沈念sama閱讀 33,792評(píng)論 4 323
  • 正文 年R本政府宣布,位于F島的核電站养泡,受9級(jí)特大地震影響嗜湃,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜澜掩,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,351評(píng)論 3 307
  • 文/蒙蒙 一购披、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧输硝,春花似錦今瀑、人聲如沸程梦。這莊子的主人今日做“春日...
    開(kāi)封第一講書(shū)人閱讀 30,352評(píng)論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)屿附。三九已至郎逃,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間挺份,已是汗流浹背褒翰。 一陣腳步聲響...
    開(kāi)封第一講書(shū)人閱讀 31,584評(píng)論 1 261
  • 我被黑心中介騙來(lái)泰國(guó)打工, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留匀泊,地道東北人优训。 一個(gè)月前我還...
    沈念sama閱讀 45,618評(píng)論 2 355
  • 正文 我出身青樓,卻偏偏與公主長(zhǎng)得像各聘,于是被迫代替她去往敵國(guó)和親揣非。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,916評(píng)論 2 344