在上一篇文章中, 我們用egg實現了基本的聊天室功能, 本章我們在其基礎上進行一個小升級, 新增使用者登入、註冊的功能, 新增查詢聊天室成員的功能。在本章中, 通過token來進行使用者識別, 通過mysql來儲存使用者資訊, 將會用到egg的對應外掛。
前端部分
效果圖
簡化了專案, 一共只有三個頁面, 但是我會按照完整的專案來調整規範, 專案結構|-- 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… 拉下來估計是跑不起來的...
溜了溜了