基于Koa+socket.io 与 Angular+socket.io-client 前后端分离(socket端口共用、跨域处理、命名空间等)搭建聊天室

前言

在之前的一个项目中,后来的新需求是要求有实现一个实时通迅的聊天室,由于之前的技术栈的选型,后端是用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();
  };

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值