在上一篇文章中, 咱们用egg实现了基本的聊天室功能, 本章咱们在其基础上进行一个小升级, 添加用户登陆、注册的功能, 添加查询聊天室成员的功能。在本章中, 经过token来进行用户识别, 经过mysql来保存用户信息, 将会用到egg的对应插件。css
效果图 html
|-- 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配置
复制代码
这里要提一下, 开发的时候egg在7001端口, vue在8080端口 明显会跨域, 因此要设置一下代理 另外一个, 我想将vue打包出来后直接放到egg的public/web下, 这样就不须要手动搬运了, 因此添加了 baseUrl、outputDir两个配置, 同时在egg里面有一个默认的html模板, 直接导入打包后的vue文件, 个人整个项目构成前端
顶级目录 |-- vue-view 前端部分 |-- egg node部分vue
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/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, 很简略了。。node
// 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管理, 主要是两个全局组建的状态mysql
// 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组件, 实现路由过渡效果,linux
// 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中全局引入ios
// 这里也可使用第三方的动画库, 实现更多炫酷效果
@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一共只有三个接口 登陆 注册, 确认用户名git
// 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}
})
}
复制代码
登陆和注册的逻辑就懒得贴了github
<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>
复制代码
由于涉及到了数据库, 因此咱们须要引入新的包,
按照官方文档,一桶安装和配置。记得更新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
}
},
}
复制代码
// 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上,实现完整的登陆逻辑
经过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… 拉下来估计是跑不起来的...
溜了溜了