如上图,本文用TSRPC
框架实现一个简单的实时聊天室,关于TSRPC
的知识,这里不再阐述,有兴趣可以看下传送门:
- GitHub:github.com/k8w/tsrpc
- 中文文档:tsrpc.cn
- 视频教程:www.bilibili.com/video/BV1hM…
初始化项目
先初始化一个名为chat-room的Web全栈项目:
npx create-tsrpc-app@latest chat-room --presets browser
# 或者
yarn create tsrpc-app chat-room --presets browser
执行指令后,选择前端(无框架)+ 后端的选项
由于要实现一个实时聊天室,并且需要快速地传递消息,因此传输协议选择WebSocket
创建完后,会生成2个项目文件夹,分别是后端项目backend和前端项目frontend
如上图,分别进入前后端目录执行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
定义
- 通过 TypeScript 的
-
API 接口的实际请求路径为
{协议路径}/{接口名}
- 协议路径:协议文件于协议目录的相对路径
这里我们想要定义一个请求路径为 Send
发送聊天内容的接口,则:
- 在协议目录protocols中创建文件
PtlSend.ts
- 定义请求类型
ReqSend
- 定义响应类型
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框架,适用于小型到中等规模的应用程序。由于其轻量级、高效、易用的特点,越来越多的开发者开始使用它进行远程调用,从而提高开发效率和代码质量。