用TSRPC实现一个简单的web实时聊天室

242 阅读8分钟

chatroom.gif

如上图,本文用TSRPC框架实现一个简单的实时聊天室,关于TSRPC的知识,这里不再阐述,有兴趣可以看下传送门:

初始化项目

先初始化一个名为chat-room的Web全栈项目:

npx create-tsrpc-app@latest chat-room --presets browser  
# 或者  
yarn create tsrpc-app chat-room --presets browser

执行指令后,选择前端(无框架)+ 后端的选项

image.png

由于要实现一个实时聊天室,并且需要快速地传递消息,因此传输协议选择WebSocket

image.png

创建完后,会生成2个项目文件夹,分别是后端项目backend和前端项目frontend

image.png

如上图,分别进入前后端目录执行npm run dev就可以开启前后端的服务器, 执行指令后,tsrpc提供了一个开发环境,可以在其中实时编译和调试代码,同时还支持热模块替换,可以让您在不刷新浏览器的情况下更新代码,并立即看到结果。

由于创建的项目本来就是个聊天室的demo,因此这里只是对聊天室进行修改下。

API接口

概念

使用 TSRPC 开发 API 接口前,必须先了解几个重要的概念。

  • API 接口

    • API 接口就相当于一个实现在远端的异步函数
    • 这个函数的输入参数叫做 请求(Request) ,返回值叫做 响应(Response)
  • 协议(Protocol)

    • API 接口的类型定义,包括它的请求类型和响应类型
  • 实现函数(Implementation)

    • API 接口的功能实现,接收请求并返回响应
  • 服务端(Server)

    • API 接口的实现端,NodeJS 12 以上
  • 客户端(Client)

    • API 接口的调用端,支持多个平台,如浏览器、小程序前端,或是 NodeJS 后端微服务调用

实现一个 API 接口,只需要 3 个步骤: 定义协议 -> 服务端实现 -> 客户端调用

定义协议

1 个接口对应 1 个协议文件,TSRPC 按照命名来识别,规则如下:

  • 协议文件命名为 Ptl{接口名}.ts,统一放置在 backend/src/shared/protocols 下,允许子目录嵌套

  • 协议包含请求类型 Req{接口名} 及响应类型 Res{接口名}

    • 通过 TypeScript 的 interface 或 type 定义
  • API 接口的实际请求路径为 {协议路径}/{接口名}

    • 协议路径:协议文件于协议目录的相对路径

这里我们想要定义一个请求路径为 Send 发送聊天内容的接口,则:

  1. 在协议目录protocols中创建文件 PtlSend.ts
  2. 定义请求类型 ReqSend
  3. 定义响应类型 ResSend
// backend/src/shared/protocols/PltSend.ts
// 发送数据请求
export interface ReqSend {
    // 发送内容
    content: string;
    // 玩家名字,当然这里也可以是uid,这里为简单期间,直接用userName
    userName: string;
}
// 发送数据响应
export interface ResSend {
    time: Date
}

服务端实现

1 个接口对应 1 个实现函数文件,TSRPC 按照命名来识别,规则如下:

  • 实现函数文件命名为 Api{接口名}.ts,统一放置在 实现目录 下

    • 实现目录默认 backend/src/api
  • 实现文件中包含名为 Api{接口名} 的异步函数

后端 npm run dev 运行期间,当你创建新的协议文件后,会自动生成对应的实现文件。 实现与协议具有相同的子目录结构:

|- backend/src
    |- shared/protocols <协议目录>
        |- PtlSend.ts   协议定义
    |- api              <实现目录>
        |- ApiSend.ts   接口实现
  • 实现函数中,通过参数 call: ApiCall 来处理请求和响应

    • 通过 call.req 来获取请求参数,即协议中定义的 ReqSend,框架会确保此处类型一定合法(非法请求被自动拦截)
    • 通过 call.succ(res) 来返回响应,即协议中定义的 ResSend
    • 通过 call.error('错误消息', { ...错误参数 }) 来返回错误

例如上面的Send接口,对应的实现函数如下:

// backend/src/api/ApiSend.ts
import { ApiCall } from "tsrpc";
import { server } from "..";
import { ReqSend, ResSend } from "../shared/protocols/PtlSend";

export async function ApiSend(call: ApiCall<ReqSend, ResSend>) {
    // Error
    if (call.req.content.length === 0) {
        call.error('Content is empty')
        return;
    }

    // Success
    let time = new Date();
    call.succ({
        time: time
    });

    // Broadcast
    server.broadcastMsg('Chat', {
        content: call.req.content,
        time: time,
        userName: call.req.userName
    })
}

上面代码中服务端接收到客户端发送的聊天信息后,会将消息内容广播给房间内容所有的用户,broadCastMsg用的是TSRPC提供了另一个种基于发布/订阅的模型:消息服务--Message Service。

消息 Message 是 TSRPC 端到端通讯的最小单元。 我们可以使用 TypeScript 定义一种消息类型,它可以在服务端和客户端之间双向传递,也享有自动类型检测和二进制序列化特性。

定义消息和 API 一样,1 种消息对应 1 个定义文件,存放在协议目录 backend/src/shared/protocols 下:

  • 消息文件命名为 Msg{消息名}.ts
  • 消息类型命名为 Msg{消息名},可以是 interface 或 type

因此上面broadCastMsg发送的数据模型定义如下:

export interface MsgChat {
    // 消息内容
    content: string,
    // 日期
    time: Date,
    // 用户名称
    userName: string;
}

后端的入口类如下:

import * as path from "path";
import { WsServer } from "tsrpc";
import { serviceProto } from './shared/protocols/serviceProto';

// Create the Server
export const server = new WsServer(serviceProto, {
    port: 3000
});

// Entry function
async function main() {
    await server.autoImplementApi(path.resolve(__dirname, 'api'));

    // TODO
    // Prepare something... (e.g. connect the db)

    await server.start();
};

main().catch(e => {
    // Exit if any error during the startup
    server.logger.error(e);
    process.exit(-1);
});

自此服务端的工作就结束了,由于框架已经把很多细节都实现了,因此我们只需要关心业务的细节就好,按照框架规则创建协议和接口实现就好了,其他都交给了框架,当然数据库的调用,中间件的使用还需我们自己动手处理,这里只是实现了一个简单的服务器的消息广播功能,因此没用到数据库和中间件等流程,具体可以查看官方文档。

客户端实现

使用现成的 TSRPC 客户端,你无需关注 HTTP 请求细节,只需像调用本地异步函数那样调用远端接口,并且享有完整的代码提示和类型检查:

// 创建TSRPC websocket
this.client = new WsClient(serviceProto, {
    server: 'ws://127.0.0.1:3000',
    logger: console
})

...

// 调用发送数据接口
let ret = await this.client.callApi('Send', {
    content: this.input.value,
    userName: this.props.userName
});

... 

// 监听消息
this.client.listenMsg('Chat', v => { this.onChatMsg(v) })

前端的功能就不再叙述了,直接贴代码,聊天室代码如下:

import { WsClient } from "tsrpc-browser";
import { MsgChat } from "./shared/protocols/MsgChat";
import { serviceProto } from './shared/protocols/serviceProto';

export interface ChatroomProps {
    title: string;
    userName: string;
}

export class Chatroom {

    elem: HTMLDivElement;
    props: ChatroomProps;

    input: HTMLInputElement;
    list: HTMLUListElement;
    header: HTMLElement;

    client = new WsClient(serviceProto, {
        server: 'ws://127.0.0.1:3000',
        logger: console
    })

    constructor(elem: HTMLDivElement, props: ChatroomProps) {
        this.elem = elem;
        this.props = props;

        this.elem.innerHTML = `
<header></header>
<ul class="list"></ul>
<div class="send">
    <input placeholder="Say something..." />
    <button>Send</button>
</div>`;
        this.input = this.elem.querySelector('.send>input')!;
        this.list = this.elem.querySelector('ul.list')!;
        this.header = this.elem.querySelector('header')!;

        // Connect at startup
        this.connect();

        // Listen Msg
        this.client.listenMsg('Chat', v => { this.onChatMsg(v) })

        // Bind Event
        this.elem.querySelector('button')!.onclick = () => { this.send() };
        this.input.onkeypress = e => {
            if (e.key === 'Enter') {
                this.send();
            }
        }

        // When disconnected
        this.client.flows.postDisconnectFlow.push(v => {
            // Retry after 2 seconds
            this.header.innerText = `🔴  Disconnected`;
            setTimeout(() => {
                this.connect();
            }, 2000)
            return v;
        })
    }

    async connect(): Promise<void> {
        this.header.innerText = `🟡 Connecting...`;
        let res = await this.client.connect();
        if (!res.isSucc) {
            this.header.innerText = `🔴 Disconnected`;

            // Retry after 2 seconds
            await new Promise(rs => { setTimeout(rs, 2000) });
            await this.connect();
        }

        this.header.innerText = '🟢 ' + this.props.title;
    }

    async send() {
        let ret = await this.client.callApi('Send', {
            content: this.input.value,
            userName: this.props.userName
        });

        // Error
        if (!ret.isSucc) {
            alert(ret.err.message);
            return;
        }

        // Success
        this.input.value = '';
    }

    // 更新界面聊天信息
    onChatMsg(msg: MsgChat) {
        let li = document.createElement('li');
        if (msg.userName === this.props.userName) {
            li.innerHTML = `<div class="user-group">
            <div class="user-msg">
                  <span class="user-reply">${msg.content}</span>
                  <i class="triangle-user"></i>
            </div>
             <img class="user-img" src="${msg.userName === "user1" ? "./user1.png" : "./user2.png"}"/></img>
       </div>`;
        } else {
            li.innerHTML = `<div class="admin-group">
            <img class="admin-img" src="${msg.userName === "user1" ? "./user1.png" : "./user2.png"}"/>
            <div class="admin-msg">
                 <i class="triangle-admin"></i>
                 <span class="admin-reply">${msg.content}</span>
            </div>
      </div>`;
        }       

        this.list.appendChild(li);
        this.list.scrollTo(0, this.list.scrollHeight);
    }
}

前端入口类如下:

import { Chatroom } from "./Chatroom";

document.querySelectorAll('.chat-room').forEach((v, i) => {
    new Chatroom(v as HTMLDivElement, {
        title: `user${i + 1}`,
        userName: `user${i + 1}`
    });
});

export { };

public目录下的入口index.html:

<!DOCTYPE html>
<html>

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>TSRPC Browser</title>
    <link rel="stylesheet" href="index.css" />
</head>

<body>
    <h1>聊天室</h1>

    <div class="app">
        <div class="chat-room">
            <header>Client #1</header>

            <ul class="list"></ul>
            <div class="send">
                <input placeholder="Say something..." />
                <button>Send</button>
            </div>
        </div>

        <div class="chat-room">
            <header>Client #2</header>
            <ul class="list"></ul>
            <div class="send">
                <input placeholder="Say something..." />
                <button>Send</button>
            </div>
        </div>
    </div>
</body>

</html>

public下index.css聊天内容的样式仿了微信的风格,代码如下:

.app {
    display: flex;
    justify-content: center;
}

.list {
    flex: 1;
    overflow-y: auto;
    list-style: none;
    border-radius: 5px;
    padding: 10px;
    padding-bottom: 20px;
    background: #f2f2f2;
}

.list>li {
    margin-bottom: 10px;
    padding: 10px;
    line-height: 1.5em;
}

.list>li:last-child {
    border-bottom: none;
    margin-bottom: 0;
}

body>h1 {
    text-align: center;
    margin-top: 20px;
} 
body,h2,h3,h4,p,ul,ol,li,form,button,input,textarea,th,td {
    margin:0;
    padding:0
}
body,button,input,select,textarea {
    font:12px/1.5 Microsoft YaHei UI Light,tahoma,arial,"\5b8b\4f53";
    *line-height:1.5;
    -ms-overflow-style:scrollbar
}
h1,h2,h3,h4{
    font-size:100%
}
ul,ol {
    list-style:none
}
a {
 text-decoration:none
}
a:hover {
    text-decoration:underline
}
img {
    border:0
}
button,input,select,textarea {
    font-size:100%
}
table {
    border-collapse:collapse;
    border-spacing:0
}

/*rem*/
html {
       font-size:62.5%;
 }
 body {
       font:16px/1.5 "microsoft yahei", 'tahoma';
 }
 body .chat-room {
       font-size: 1.6rem;
 }

 /*浮动*/
.fl{
    float: left;
 }
.fr{
    float: right;
 }
.clearfix:after{
    content: '';
    display: block;
    height: 0;
    clear: both;
    visibility: hidden;
}

body{
 background-color: #F5F5F5;
}
.chat-room{
 max-width: 600px;
 display: flex;
flex-direction: column;
width: 460px;
height: 480px;
margin: 20px;
background: #f7f7f7;
border: 1px solid #454545;
border-radius: 5px;
overflow: hidden;
}
.chat-room>header {
    background: #454545;
    color: white;
    text-align: center;
    padding: 10px;
}

.send {
    flex: 0 0 40px;
    display: flex;
    border-top: 1px solid #454545;
}

.send>* {
    border: none;
    outline: none;
    height: 100%;
    font-size: 16px;
}

.send>input {
    flex: 1;
    background: #fff;
    padding: 0 10px;
}

.send>button {
    flex: 0 0 100px;
    background: #215fa4;
    color: white;
    cursor: pointer;
}

.send>button:hover {
    background: #4b80bb;
}
.chat-room .admin-img, .chat-room .user-img{
 width: 45px;
 height: 45px;
}
i.triangle-admin,i.triangle-user{
 width: 0;
     height: 0;
     position: absolute;
     top: 10px;
 display: inline-block;
     border-top: 10px solid transparent;
     border-bottom: 10px solid transparent;
}
.chat-room i.triangle-admin{
 left: 4px;
 border-right: 12px solid #fff;
}
.chat-room i.triangle-user{
 right: 4px;
     border-left: 12px solid #9EEA6A;
}
.chat-room .admin-group, .chat-room .user-group{
 padding: 6px;
 display: flex;
 display: -webkit-flex;
}
.chat-room .admin-group{
 justify-content: flex-start;
 -webkit-justify-content: flex-start;
}
.chat-room .user-group{
 justify-content: flex-end;
 -webkit-justify-content: flex-end;
}
.chat-room .admin-reply, .chat-room .user-reply{
 display: inline-block;
 padding: 8px;
 border-radius: 4px;
 background-color: #fff;
 margin:0 15px 12px;
}
.chat-room .admin-reply{
 box-shadow: 0px 0px 2px #ddd;
}
.chat-room .user-reply{
 text-align: left;
 background-color: #9EEA6A;
 box-shadow: 0px 0px 2px #bbb;
}
.chat-room .user-msg, .chat-room .admin-msg{
 width: 75%;
 position: relative;
}
.chat-room .user-msg{
 text-align: right;
}

另外public目录中,包含了user1.png和user2.png

自此,在后端执行npm run dev后,前端在运行npm run dev后,浏览器会自动打开聊天室的页面,如开头所见。

总体来说,TSRPC是一款比较优秀的RPC框架,适用于小型到中等规模的应用程序。由于其轻量级、高效、易用的特点,越来越多的开发者开始使用它进行远程调用,从而提高开发效率和代码质量。