用node實現一個簡單的聊天室—— 升級版

xanggang發表於2018-08-29

在上一篇文章中, 我們用egg實現了基本的聊天室功能, 本章我們在其基礎上進行一個小升級, 新增使用者登入、註冊的功能, 新增查詢聊天室成員的功能。在本章中, 通過token來進行使用者識別, 通過mysql來儲存使用者資訊, 將會用到egg的對應外掛。

前端部分

效果圖

用node實現一個簡單的聊天室—— 升級版
簡化了專案, 一共只有三個頁面, 但是我會按照完整的專案來調整規範, 專案結構

|-- src
    |-- assets  // 需要打包的靜態資源
    |-- components // 公共組建
    |-- service // service層, 處理api和資料
    |-- stores // vuex管理
    |-- style
        |-- animate.scss  // 過渡動畫
        |-- button.scss  // 基礎組建樣式, 全域性覆蓋vant的樣式
        ...
        |-- index.scss  // 收集全部的樣式, 同意匯入
        |-- mixin.scss  // 公共的mixin
        |-- var.scss    // 樣式宣告
    |-- util 
        |-- ajax 
        |-- io
    |-- views
        ... // 具體的路由
    app.vue  // vue入口
    main.ts  // 入口檔案
    router.ts //vue-router
    store.ts  // vuex 全域性方法,入口
    vue.config.js // vue配置
複製程式碼

vue.config.js

這裡要提一下, 開發的時候egg在7001埠, vue在8080埠 明顯會跨域, 所以要設定一下代理 另一個, 我想將vue打包出來後直接放到egg的public/web下, 這樣就不需要手動搬運了, 所以新增了 baseUrl、outputDir兩個配置, 同時在egg裡面有一個預設的html模板, 直接匯入打包後的vue檔案, 我的整個專案構成

頂級目錄 |-- vue-view 前端部分 |-- egg node部分

module.exports = {
  lintOnSave: false,
  devServer: {
    proxy: {
      '/api': {
        target: 'http://127.0.0.1:7001/',
        changeOrigin: true
      }
    }
  },
  baseUrl: 'public/web/',
  outputDir: '../websocket/app/public/web', // 指向後端的public目錄,
  filenameHashing: false
}

複製程式碼

封裝的util

 // util/ajax
import axios from 'axios'
// 通過匯入store, 更改全域性的變數來實現在vue外更改vue元件的狀態 
import store from '../store'
const CONFIG = {
  // 此處應該根據vue環境切換baseUrl, 通過process.env.VUE_APP_SECRET 來獲取當前環境
  // baseURL: 'http://127.0.0.1:7001',
  timeout: 5000
}
const axiosInstance = axios.create(CONFIG)

// ts宣告
interface options {
  url: string,
  method: 'get' | 'post' | 'put' | 'delete',
  showLoading?: boolean,
  head?: object,
  data?: any,
  showErr?: boolean
}

export default function (options: options) {
  // 預設展示loading
  options.showLoading = options.hasOwnProperty('showLoading') ? options.showLoading : true;
  // 預設展示err
  options.showErr = options.hasOwnProperty('showErr') ? options.showErr : true;
  options.showLoading && store.commit('changeLoading', true);
  // 從localStorage獲取token
  const token: string | null = window.localStorage.getItem('token');
  const showErr = options.showErr

  let arg :any = {}
  arg.url = options.url
  arg.method = options.method
  arg.method === 'get'
    ? arg.params = options.data
    : arg.data = options.data
  arg.headers = {
    // 'Accept': 'application/json, text/plain, */*',
    'Content-Type': 'application/json'
  }
  if (token) {
    arg.headers = Object.assign(arg.headers, {token: token})
  }
  if (options.head) {
    arg.headers = Object.assign(arg.headers, options.head)
  }
  return axiosInstance(arg)
    .then(res => {
    // 更改組建狀態
      options.showLoading && store.commit('changeLoading', false)
      return res.data.detail
    })
    .catch(err => {
      options.showLoading && store.commit('changeLoading', false)
      const message = err.response.data.message
      // 更改組建狀態
      showErr && store.dispatch('changeToast', {message: message, type: 'err'})
      return Promise.reject(err)
    })
}
複製程式碼

封裝的IO, 很簡略了。。

// util/io
import io from 'socket.io-client';
const uri = '/'
const socket = function (token: string):any {
  const _io = io(uri, {
    query: {
      token
    }
  });
  _io.on('connect', function(){
    const id = _io.id;
    _io.on(id, (msg: any) => {
      console.log('#receive,', msg);
    });
  });
  _io.on('disconnect', function(){
    console.log('斷開連級');
  });
  _io.on('connect_error', function (e: any) {
    console.log(e, 'reconnect_error');
    _io.close()
  })
  return _io
}


export default socket

複製程式碼

store管理, 主要是兩個全域性組建的狀態

// store.ts
import Vue from 'vue'
import Vuex from 'vuex'
import Home from './stores/Home'
Vue.use(Vuex)

export default new Vuex.Store({
  modules: {
    // vue 模組化
    Home
  },
  state: {
    // 全域性狀態,
    loading: false,
    toast: {
      message: '',
      status: false,
      type: 'toast'
    }
  },
  mutations: {
    changeLoading(state, bool: boolean) {
      state.loading = bool
    },
    changeToast(state, obj: {message: string, status: boolean, type: 'success' | 'err' | 'toast'}) {
      state.toast = obj
    }
  },
  actions: {
    通過呼叫這個方法, 在全域性管理loading和toast
    changeToast({commit}, obj: {message: string, type: 'success' | 'err' | 'toast'}) {
      commit('changeToast', {message: obj.message, status: true, type: obj.type})
      setTimeout(() => {
        commit('changeToast', {message: '', status: false, type: 'toast'})
      }, 2000)
    }
  }
})

複製程式碼

在router-view外面 我們通過vue的transition元件, 實現路由過渡效果,

// App.vue
<template>
  <div id="app">
    <Loading v-if="loading"/> 
    <Toast v-if="toast.status" :message="toast.message" :type="toast.type"/>
    <transition appear name="fade">
        <router-view></router-view>
    </transition>
  </div>
</template>
<script lang="ts">
  import {Component, Vue} from 'vue-property-decorator';
  import Loading from './components/Loading.vue'
  import Toast from './components/Toast.vue'
  @Component({
    name: 'App',
    components: { Loading, Toast }
  })
  export default class Index extends Vue {
    get loading() {
      return this.$store.state.loading
    }
    get toast() {
      return this.$store.state.toast
    }

    mounted() {
    }
  }
</script>
複製程式碼

對應的動畫效果, 在main中全域性引入

// 這裡也可以使用第三方的動畫庫, 實現更多炫酷效果
@keyframes animatIn {
  0% {
    transform: translate(-100%, 0);
  }
  100% {
    transform: translate(0, 0);
  }
}
@keyframes animatOut {
  0% {
    transform: translate(0, 0);
  }
  100% {
    transform: translate(100%, 0);
  }
}
.fade-enter {
  transform: translate(-100%, 0);
  position: absolute!important;
  z-index: 999;
  top: 0;
  left: 0;
}
.fade-enter-active {
  animation: animatIn 0.2s;
  position: absolute!important;
  z-index: 999;
  top: 0;
  left: 0;
  width: 100%;
}
.fade-leave {
  transform: translate(0, 0);
}
.fade-leave-active {
  animation: animatOut 0.2s;
}
複製程式碼

service一共只有三個介面 登入 註冊, 確認使用者名稱

// service/account.ts
import ajax from '../utils/ajax'

interface registerRule {
  userName: string,
  nick: string,
  passWord: string,
  // gender: string
}
export function register(data: registerRule) {
  return ajax({
    url: './api/register',
    method: 'post',
    data: data
  })
}

export function login(data: { userName: string, passWord: string}) {
  return ajax({
    url: './api/login',
    method: 'post',
    data: data
  }).then(res => {
    window.localStorage.setItem('token', res)
  }).catch(err => {
    return Promise.reject(err)
  })
}

export function checkUserName(data: string): Promise<boolean> {
  return ajax({
    url: './api/checkUserName',
    method: 'post',
    data: {userName: data}
  })
}


複製程式碼

登入和註冊的邏輯就懶得貼了

<template>
  <div class="container">
    <Header :onlineUser="onlineUser"/> // 頭部顯示頭銜的元件, 背景是由ajax請求過來的
    <MsgItem v-for="(msg, index) in msgList"
             :msg="msg.data.message"
             :user="msg.user"
             :key="index" />
    <Send @sendMsg="sendMsg"/>
  </div>
</template>

<script lang="ts">
  import {Component, Vue} from 'vue-property-decorator';
  import MsgItem from '../../components/MsgItem.vue'
  import Header from '../../components/Header.vue'
  import Send from '../../components/Send.vue'
  import io from '../../utils/io'
  let socket: any = undefined
  @Component({
    name: 'room',
    components: {
      MsgItem,
      Header,
      Send
    },
  })
  export default class Index extends Vue {
    msgList: Array<object> = []
    onlineUser: Array<object> = []

    // 點選傳送, 觸發sendMsg事件, 在node監聽
    sendMsg(msg: string): void {
      socket.emit('sendMsg', msg)
    }

    mounted() {
    // 在連線websocket時, 需要將token傳過去, 用於使用者識別
      const token = localStorage.getItem('token')
      if (!token) {
        this.$toast('請先登入')
        setTimeout(() => {
          this.$router.push('./login')
        }, 1000)
        return
      }

      socket = io(token)
      socket.on('online', (data: Array<object>) => {
      // 由後端發起的online事件, 更新所有的線上使用者
        console.log(data);
        this.onlineUser = data
      })
      socket.on('broadcast', (data: object) => {
      // 訊息廣播
        console.log(data);
        this.msgList.push(data)
      })
    }
  }
</script>
複製程式碼

後端部分

因為涉及到了資料庫, 所以我們需要引入新的包,

  • Sequelize -> sequelize 是一個廣泛使用的 ORM 框架,它支援 MySQL、PostgreSQL、SQLite 和 MSSQL 等多個資料來源。
  • randomcolor -> 隨機生成顏色, 用於頭像背景, 代替上傳頭像的功能
  • sequelize-cli -> 資料庫初始化表,生成model
  • bcrypt-nodejs -> 密碼加密解密工具, 將使用者密碼加密後存入資料庫,加強資料安全
  • egg-jwt -> 基於token的驗證體系, 將資料加密成token傳給前端。
  • egg-validate -> 引數驗證

按照官方文件,一桶安裝和配置。記得更新plugin和config.default

// config.default 新新增的配置
  config.sequelize = {
    dialect: 'mysql',
    hostname: 'localhost',
    port: 3306,
    database: 'chatroom',
    password: 'password'
  };

  config.jwt = {
    secret: "123456"
  };

複製程式碼

Sequelize的使用, 包括生成model, 初始化表,在egg文件都有, 不在贅述

相關的中介軟體、幫助函式

中介軟體

// app/middleware/error_handler
// 這就就是修改了一下官方的error_handler
export default function () {
  return async function errorHandler(ctx, next) {
    try {
      await next();
    } catch (err) {
      // 所有的異常都在 app 上觸發一個 error 事件,框架會記錄一條錯誤日誌
      ctx.app.emit('error', err, ctx);

      const status = err.status || 500;
      // 生產環境時 500 錯誤的詳細錯誤內容不返回給客戶端,因為可能包含敏感資訊
      const error = status === 500 && ctx.app.config.env === 'prod'
        ? 'Internal Server Error'
        : err.message;

      // 從 error 物件上讀出各個屬性,設定到響應中
      ctx.body = { message: error };
      if (status === 422) {
        ctx.body.detail = err.errors;
      }
      ctx.status = status;
    }
  };
}
複製程式碼
// app/middleware/auth
// 許可權中介軟體, 如果未登入, 直接返回err, 如果已經登入,將user解密後放在ctx, 便於後面的方法使用

export default async function auth(ctx, next) {
  const token = ctx.get('token')
  if (!token) {
    ctx.body = {
      msg: '未登入'
    }
    return
  }
  // 解密token 獲取使用者資訊。 實現在下面
  const user = ctx.service.account.decrypt(token)
  ctx.user = user
  await next()
};

複製程式碼
// app/extend/helper
// 使用者Socket傳送資料格式化
export default {
  parseMsg(action, message: string, metadata = {}, user = {}) {
    const meta = Object.assign({}, {
      timestamp: Date.now(),
    }, metadata);
    return {
      meta,
      data: {
        action,
        message
      },
      user
    };
  },
}
複製程式碼
// app/extend/context
// 這裡的方法可以在其他地方通過ctx呼叫
module.exports = {
  success(status, message, detail) {
    this.status = status
    this.body = {
      message: message,
      detail: detail
    }
  },
  err(status, message, detail) {
    this.status = status
    this.body = {
      message: message,
      detail: detail
    }
  },
}
複製程式碼

首頁, 建立使用者model

// app/model/Account
const randomColor = require('randomcolor');

interface userInfo {
  userName: string,
  nick: string,
  passWord: string,
  gender: string,
  avatarUrl: string,
}

const AccountModel = (app) => {
  const { INTEGER, BIGINT, CHAR} = app.Sequelize;

  const Account = app.model.define('accounts', {
    id: { type: BIGINT, primaryKey: true, autoIncrement: true },
    userName: CHAR(255),
    nick: CHAR(255),
    gender: CHAR(1),
    avatarUrl: CHAR(255),
    passWord: CHAR(255),
    whenCreated: INTEGER,
    whoCreated: CHAR(255),
    deleted: INTEGER,
    defaultColor: CHAR
  }, {
    timestamps: false,
    tablseName: 'accounts',
  });

  Account.createAccount = async (userInfo): Promise<userInfo> => {
    return await app.model.Account.create({
      userName: userInfo.userName,
      nick: userInfo.nick,
      passWord: userInfo.passWord,
      gender: userInfo.gender | 0,
      avatarUrl: userInfo.avatarUrl,
      whenCreated: new Date().getTime(),
      whoCreated: 'admin',
      deleted: 0,
      defaultColor: randomColor() // 生成隨機背景顏色, 代替頭像
    })
  }

  return Account;
}

export default AccountModel

複製程式碼

使用migrations初始刷資料庫

'use strict';

module.exports = {
  up: async (queryInterface, Sequelize) => {
    const { INTEGER, DATE, STRING } = Sequelize;
    await queryInterface.createTable('online_users', {
      id: { type: INTEGER, primaryKey: true, autoIncrement: true },
      socketId: STRING,
      userId: INTEGER,
      room: STRING,
      online_at: INTEGER,
    });
  },

  down: (queryInterface, Sequelize) => {

  }
};

複製程式碼

開始寫controller了

app/router 
  router.get('/', controller.home.index);
  router.post('/api/register', controller.account.register);
  router.post('/api/checkUserName', controller.account.checkUserName)
  router.post('/api/login', controller.account.login);
  router.post('/api/test', auth, controller.account.test);

  io.of('/').route('sendMsg', io.controller.nsp.sendMsg);
複製程式碼

一個一個來

// router
 router.get('/', controller.home.index);
 
// controller/home
import { Controller } from 'egg';
export default class HomeController extends Controller {
  public async index() {
    const { ctx } = this;
    await ctx.render('index.html');
  }
}
// 訪問首頁的時候, 將html模板丟出去, 對應的模板位置在view/inndex.html
複製程式碼
// app/views/inde.html
// 注意檔案路徑
// vue打包後會放在app/public/web資料夾下
<!DOCTYPE html>
<html lang=en>
<head>
  <meta charset=utf-8>
  <meta http-equiv=X-UA-Compatible content="IE=edge">
  <meta name=viewport content="width=device-width,initial-scale=1">
  <script src=/public/web/flexible.js></script>
  <title>vue-view</title>
  <link href=/public/web/css/app.css rel=preload as=style>
  <link href=/public/web/css/chunk-vendors.css rel=preload as=style>
  <link href=/public/web/js/app.js rel=preload as=script>
  <link href=/public/web/js/chunk-vendors.js rel=preload as=script>
  <link href=/public/web/css/chunk-vendors.css rel=stylesheet>
  <link href=/public/web/css/app.css rel=stylesheet>
</head>
<body>
<noscript><strong>We're sorry but vue-view doesn't work properly without JavaScript enabled. Please enable it to
  continue.</strong></noscript>
<div id=app></div>
<script src=/public/web/js/chunk-vendors.js></script>
<script src=/public/web/js/app.js></script>
</body>
</html>
複製程式碼

註冊

// router 
  router.post('/api/register', controller.account.register);
  router.post('/api/checkUserName', controller.account.checkUserName)
  // 註冊的時候回用到兩個介面, 確認使用者名稱是否存在
複製程式碼

檢查使用者名稱是否存在

// controller/account/checkUserName
const registerRules = {
  userName: {
    type: 'string',
    max: 11,
    min: 1
  },
  nick: 'string',
  passWord: {
    type: 'string',
    max: 11,
    min: 6
  },
  gender: {
    type: 'string',
    required: false
  },
  avatarUrl: {
    type: 'string',
    required: false
  }
}
  public async checkUserName() {
    const body = this.ctx.request.body
    this.ctx.validate(checkUserNameRules, body)
    const oldName = await this.service.account.checkUserName(body.userName)
    this.ctx.success(200, 'success', !oldName)
  }
複製程式碼
// service
  public async checkUserName(userName: string) {
    const user = await this.app.model.Account.findOne({
      where: {
        userName: userName
      }
    });
    if (user) {
      return user.dataValues
    } else {
      return null
    }
  }
複製程式碼

建立賬戶

// router
router.post('/api/checkUserName', controller.account.register)
複製程式碼
// controller/account
  public async register() {
    const body = this.ctx.request.body
    this.ctx.validate(registerRules, body)
    const oldName = await this.service.account.checkUserName(body.userName)
    if (oldName) {
      this.ctx.err(422, '使用者名稱已存在', null)
      return
    }
    // 如果使用者名稱已存在, 丟422, 否則呼叫service/createUser
    const token = await this.service.account.createUser(body)
    this.ctx.body = token
  }
複製程式碼
// app/service/account
  /**
   * 加密密碼
   */
  public encryptPassWord(passWord: string): string {
    // 使用bcrypt加鹽
    const salt = bcrypt.genSaltSync(10)
    // 返回加密後的密碼
    return bcrypt.hashSync(passWord, salt)
  }

  /**
   * 建立賬戶
   */
  public async createUser(userInfo: userInfo): Promise<string> {
    // 呼叫上面的encryptPassWord方法加密密碼
    const passWord = this.service.account.encryptPassWord(userInfo.passWord)
    const tmp = Object.assign({}, userInfo, {passWord: passWord})
    // 呼叫model.Account.createAccount 在資料庫生成新的使用者
    const user = await this.ctx.model.Account.createAccount(tmp);
    // 呼叫下面的getToken方法, 將使用者資訊加密成token並且返回
    const token = await this.service.account.getToken(user.dataValues)
    return token
  }

  /**
   * 生成token
   */
  public async getToken(userInfo: userInfo): Promise<string> {
    // this.app.config.jwt.secret是配置檔案裡的,現在是寫死的, 所以生成的token都是一樣的
    // 預期的是根據當前時間戳生成secret, 然後將token和secret存在另一張表裡, 
    // 這樣就可以實現重新整理token,‘擠下線’之類的操作
    const token = this.app.jwt.sign({ user: userInfo }, this.app.config.jwt.secret);
    return token
  }
複製程式碼

登入

// router 
  router.post('/api/checkUserName', controller.account.login
複製程式碼
// controller.account.login
  public async login() {
    const body = this.ctx.request.body
    // 檢查入參
    this.ctx.validate(loginRules, body)
    // 檢查使用者是否存在
    const user = await this.service.account.checkUserName(body.userName)
    if (!user) {
      this.ctx.err(422, '使用者不存在', null)
      return
    }
    // 核對密碼
    const isMatch = await this.service.account.checkPassWord(user.passWord, body.passWord)
    if (isMatch) {
    // 生成token
      const token = await this.service.account.getToken(user)
      this.ctx.success(200, 'success', token)
    } else {
      this.ctx.err(422, '密碼錯誤', null)
    }
  }
複製程式碼
// service.account.checkPassWord
  public async checkPassWord(passWord: string, hashPassWord: string) {
    return new Promise((resolve, reject) => {
      bcrypt.compare(hashPassWord, passWord, (err, isMatch) => {
        if (!err) {
          resolve(isMatch)
        } else {
          reject(err)
        }
      })
    })
  }

複製程式碼
// service.account.decrypt
// 中介軟體用到的, 解碼token獲取使用者
  public decrypt(token: string): user {
    const { user } = this.app.jwt.verify(token, this.app.config.jwt.secret);
    return {
      id: user.id,
      userName: user.userName,
      nick: user.nick,
      defaultColor: user.defaultColor,
      avatarUrl: user.avatarUrl
    }
  }
複製程式碼

前端請求這個藉口之後, 就會存在本地,每次傳送ajax的時候,在header裡帶上token,實現登入的功能。 node在中介軟體中, 取出token通過中介軟體獲取使用者, 然後掛載在ctx上,實現完整的登入邏輯

Socket相關

通過middleware/connection中介軟體, 在每次連線的時候,獲取連線者使用者資訊,更新線上列表 connection中介軟體只會在每次連線的時候出發

// app/io/middleware/connection
import { Context } from 'egg';
import { uniqBy } from 'lodash';

export default function connectionMiddleware() {
  return async (ctx: Context, next: any) => {
    const { app, socket } = ctx;
    const nsp = app.io.of('/');
    const id = socket.id;
    // query是客戶度連線的時候傳進來的
    const query = socket.handshake.query;
    const token = query.token
    // 解碼使用者
    const user = ctx.service.account.decrypt(token)

    // 如果沒有登入, 直接踢出去
    if (!user) {
      socket.emit(id, {msg: token});
      socket.disconnect()
      return
    }
    // 將使用者掛載到連線上
    socket.user = user

    // 查詢房間內的所有客戶端
    nsp.clients((err, clients) => {
      if (err) throw err;
      // 獲取 client 資訊
      const clientsDetail: Array<object> = [];
      clients.forEach(client => {
        const _client = app.io.sockets.sockets[client];
        const _user = _client.user;
        // 這個_user就是房間內使用者的資訊
        clientsDetail.push(_user)
      });
      // uniqBy是lodash的方法, 陣列去重
      // 同一個使用者可以同時又多個連線的
      const userList = uniqBy(clientsDetail, 'id')
      // 更新線上使用者列表
      nsp.emit('online', userList)
    });
    await next();
  };
}

複製程式碼

傳送資訊, 客戶端觸發sendMsg事件

// router
io.of('/').route('sendMsg', io.controller.nsp.sendMsg);
複製程式碼
// app/io/controller/nsp
  public async sendMsg() {
    const { ctx, app } = this;
    const socket = ctx.socket;
    const nsp = app.io.of('/');
    const user = socket.user
    const message = ctx.args[0] || {};
    const client = socket.id;
    // 先前封裝的helper
    const msg = ctx.helper.parseMsg('broadcast', message, { client }, user);

    nsp.emit('broadcast', msg);
  }

複製程式碼

這樣基本業務就完成了, 中間還遇到幾個坑, Socket和普通的http走的不同的邏輯, 所以Socket無法使用全域性的中介軟體。 在打包vue的時候, 注意檔案路徑。 ts的egg專案要先執行ci, 將ts編譯之後再start ts對egg外掛的支援並不是很好,經常會遇到沒有定義外掛而無法編譯的情況。 我偷懶直接改了node_module裡的型別檔案。 linux上面安裝sql、遠端資料庫踩到的坑就不提了。。程式碼2小時, 環境要折騰幾天。。 最後, 上專案程式碼 github.com/xanggang/we… 拉下來估計是跑不起來的...

溜了溜了

相關文章