前言
在之前的一个项目中,后来的新需求是要求有实现一个实时通迅的聊天室,由于之前的技术栈的选型,后端是用Koa2 + TypeScript3 + MongoDB4,前端是用Angular10 + ng-zorro-antd10组成的一个前后端分离项目,所用只能在此基础上进行开发。
说到实时通迅,首先就想到的是用WebSocket来做,但由于原生的WebSocket服务端实现起来较为繁琐,所以刚开始就用第三方封装好的koa-socket来实现可是出现各种问题,后来又用koa-socket-2也是四处碰壁,也是各种如无法共用一个服务端口问题、前端请求API路由、socket请求跨域问题、命名空间等等,经过几翻折腾,最终在用socket.io(koa-socket 和 koa-socket-2也是在socket.io在基础上进行二次封装的[socket.io的官方文档])后解决了之前遇到的一系列问题,而前端也是用第三方封装好的socket.io-client来实现。
聊天室简易版效果图
就说这里吧,直接上代码,代码中详细的注释和说明,小伙伴们一看便知!
后端实例代码 server.ts
import Http from 'http';
import Koa, { Context, Next } from 'koa';
import path from 'path';
import { bootstrapControllers } from 'koa-ts-controllers';
import cors from 'koa2-cors';
import KoaRouter from 'koa-router';
import KoaBody from 'koa-body';
import KoaStatic from 'koa-static-cache';
import render from 'koa-art-template';
import jwt from 'jsonwebtoken';
// 相关文件配置
import config from './config';
// 后端页面路由
import admin from './routers/admin';
// 执行异步处理
; (async ({ app, server, router, socket }) => {
// API路由、控制器、API管理(注:这里需要异步处理 await)
await bootstrapControllers(server, {
router, // 绑定路由模块
basePath: '/api', // 访问规则 // http://localhost:8080/api/v1/控制器/接口
versions: [1], // 版本号
// versions: { // 版本号的另一种写法
// 1: '此版本已弃用,不久将被删除!请尽快迁移到v2版本', // 可以同时开多个版本,这个是虽然能用,但是有警告信息
// 2: true, // 正常访问
// dangote: true // 非常适合定制,业务客户端特定的端点版本
// },
controllers: [ // 指定控制器类、接口存放目录,
__dirname + '/controllers/**/*' // 可直接添加到此数组中,也可以添加全局字符串(匹配controllers目录下的所以文件分析类指定到路由对象中)
// path.resolve(__dirname, 'controllers/**/*')
],
// 可选的统一错误处理程序
errorHandler: async (err: any, ctx: Context) => {
console.log('err', err);
let status = 500;
let body: any = {
statusCode: status,
error: 'Internal Server Error',
message: '后台数据库未启动 或 控制器、Api接口内部发生错误!',
errorDetails: '内部服务器错误!'
};
if (err && err.output) { // 如果控制器类、接口中有错抛出时的返回处理
let { statusCode, payload } = err.output;
status = statusCode || 200; // HTTP状态代码(通常4 xx或5 xx)
body = payload;
if (err.data) {
body.errorDetails = err.data; // 如果有错误详情时一并返回
}
};
ctx.body = { error: err };
ctx.status = 500;
}
})
// 配置 koa-art-template 模板引擎
;render(server, {
root: path.join(__dirname, './views'), // 视图的位置
extname: '.html', // 后缀名 .art
debug: process.env.NODE_ENV !== 'production' // 是否开启调试模式
})
// 后端管理页路由
;router
// 配置子路由(层级路由) page视图模块
// .use('', view.routes())
// 配置子路由(层级路由) page视图 后台管理模块
.use('/admin', admin.routes());
// 聊天用户数
const chatUser: any[] = [];
let chatName: String = '';
// socket服务,/mupiao为命名空间
;socket.of("/mupiao").on("connection", (io: any) => {
// 捕获客户端自定义信息
io.on('login', (msg: any) => {
if (chatUser.indexOf(msg.user) > -1) {
io.emit('respond', {
user: '🏡系统',
content: `😥对不起:${msg.user}昵称已存在!`
});
} else {
chatName = msg.user;
// 统计连接数
chatUser.push(msg.user);
// 私发:发送给自己 在线人数
io.emit('users', {
users: chatUser
});
// 私发:发送给自己 消息
io.emit('respond', {
user: '🏡系统',
content: `🔊嗨:${msg.user} 欢迎你进入聊天室!`
});
// 广播:发送给所有人 在线人数
io.broadcast.emit('users', {
users: chatUser
});
// 广播:发送给所有人 消息
io.broadcast.emit('respond', {
user: '🏡系统',
content: `👏欢迎${msg.user}进入了聊天室!`
});
}
})
// 捕获客户端send信息
io.on('message', (msg: any) => {
const message = {
user: msg.user,
content: msg.content || '666'
}
// 私发:发送给自己 消息
io.emit('respond', message);
// 广播:发送给所有人 消息
io.broadcast.emit('respond', message);
});
// 监听客户端断开连接
io.on('disconnect', (msg: any) => {
chatUser.splice(chatUser.indexOf(chatName), 1);
// 广播:发送给所有人 在线人数
io.broadcast.emit('users', {
users: chatUser
});
// 广播:发送给所有人 消息
io.broadcast.emit('respond', {
user: '🏡系统',
content: `😥${chatName}离开了聊天室!`
});
});
});
server
// koa2允许跨域访问
.use(cors())
// 监听所有路由入口 所有请求拦截器
.use(async (ctx: Context, next: Next) => {
const token = ctx.headers['userAuth'];
if (token) {
// 通过jwt校验前端传过来的token,并且挂载到ctx对象上,方便全局使用
ctx.myToken = jwt.verify(token, config.jwt.key);
}
// 并且这里要转换一下状态码
if (ctx.request.method === 'OPTIONS') {
ctx.response.status = 200;
}
try {
console.log('\n监听到新请求:--> ', new Date().toString());
await next();
// 当没有匹配到路由时 的错误处理
if (404 === ctx.status) {
// ctx.body = '404';
ctx.render('404');
} else {
console.log('\n请求URL:--> ', ctx.url);
}
} catch (err) {
ctx.response.status = err.statusCode || err.status || 500;
ctx.response.body = {
message: err.message
}
}
})
// 注册接收post参数、上传二进制文件等模块(使用中间件处理 post 传参 和上传图片)
.use(KoaBody({
multipart: true, // 开启上传二进制文件处理,解析多部分实体,默认值false
strict: false, // 如果启用,则不解析GET,HEAD,DELETE请求,默认为true
encoding: 'utf-8', // 设置传入表单字段的编码,默认值utf-8
// parsedMethods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'],
parsedMethods: ['*'],
formidable: {
maxFields: 100, // 上传最大文件个数(整数)
maxFieldsSize: 2 * 1024 * 1024, // 上传最大文件大小(整数) 1MB = (1 * 1024 * 1024)
uploadDir: config.storage.upload, // 文件上传目录,默认os.tmpDir(), path.join(__dirname, 'upload')
// uploadDir: `${config.storage.upload}/${getUploadDirName()}`, // 文件上传目录,默认os.tmpDir(), path.join(__dirname, 'upload')
keepExtensions: true, // 开启文件写入uploadDir包括原始文件的扩展名, 默认false
hash: 'md5', // 如果你想计算校验和传入的文件, 设置这个要么'sha1'或'md5'、默认false
multiples: true, // 开启多文件上传
onFileBegin: (name, file) => { // 文件上传前的设置
// console.log(name, file.name, file.type);
}
}
}))
// 注册静态资源代理1(上传资源托管)
.use(KoaStatic({
dir: config.storage.upload, // 静态资源存储路径 path.join(__dirname, '/upload')
prefix: '/', // 静态资源访问前缀(名字自定义,前面一定要加/)
gzip: true, // 是否启用压缩
dynamic: true // 是否启用缓存
}))
// 注册静态资源代理2(静态资源托管)
.use(KoaStatic({
dir: config.storage.static, // 静态资源存储路径 path.join(__dirname, '/static')
prefix: config.storage.prefix, // 静态资源访问前缀(名字自定义,前面一定要加/)
gzip: true, // 是否启用压缩
dynamic: true // 是否启用缓存
}))
// 注册并启动路由
.use(router.routes())
// 请求出错时的处理逻辑
.use(router.allowedMethods());
// 启动koa应用服务和socket服务(共用端口)
app.listen(config.server.port, config.server.host, () => {
console.log(`\n*** 服务端启动成功:--> http://${config.server.host}:${config.server.port}`);
});
})(((server, router, socket) => {
// 这里是将socket服务挂载到koa服务上,从而实现共用一个服务端口
const app = Http.createServer(server.callback());
return {
app, // app应用服务
server, // new Koa()
router, // new KoaRouter()
socket: socket(app, { cors: true }) // { cors: true } 为开启socket跨域访问
};
})(new Koa(), new KoaRouter(), require('socket.io')));
前端实例代码 chat.component.ts
import { Component, ElementRef, OnInit } from '@angular/core';
import * as io from 'socket.io-client';
import { NzTabPosition } from 'ng-zorro-antd/tabs';
import express from './chat.phizbrow';
var window: Window & typeof globalThis
@Component({
selector: 'app-chat',
templateUrl: './chat.component.html',
styleUrls: ['./chat.component.less']
})
export class ChatComponent implements OnInit {
public express: any[] = express;
public position: NzTabPosition = 'bottom';
public socket: any = Object;
public user: any = Date.now();
public users: any[] = [];
public message: string = '';
public respond: any[] = [];
constructor(private el: ElementRef) {
};
ngOnInit(): void {
this.scrollToBottom();
};
// 消息置底
public scrollToBottom(): void {
try {
this.el.nativeElement.querySelector('#main-message').scrollTop = this.el.nativeElement.querySelector('#main-message').scrollHeight;
} catch (err) {
};
};
ngAfterViewInit(): void {
//Called after ngAfterContentInit when the component's view has been initialized. Applies to components only.
//Add 'implements AfterViewInit' to the class.
let $ = (ele: any, doc = document) => {
let dom = doc.querySelectorAll(ele);
return dom.length > 1 ? dom : dom[0];
};
// 创建socket链接
this.socket = io.connect('ws://localhost:3000/mupiao');
this.socket.on("connect", () => {
// console.log("socket链接成功");
// 链接登录
this.socket.emit("login", {
user: this.user,
});
// 收到消息 在线人数
this.socket.on("users", (msg: any) => {
this.users = msg.users;
});
// 收到消息
this.socket.on('respond', (msg: any) => {
msg.mine = (Math.random() > 0.5) ? true : false;
this.respond.push(msg);
// this.scrollToBottom();
});
});
// 按回车键发送消息
document.addEventListener('keydown', (event: any) => {
this.messageSend && event.ctrlKey && (13 == event.keyCode) && this.messageSend();
});
};
// 添加表情
public expressPush(brow: any): void {
this.message = `${this.message}${brow.icon}`;
}
// 发送消息
public messageSend(): void {
this.socket.emit('message', {
user: this.user,
content: this.message || "我是打酱油的!"
});
this.message = '';
};
ngAfterViewChecked(): void {
//Called after every check of the component's view. Applies to components only.
//Add 'implements AfterViewChecked' to the class.
this.scrollToBottom();
};