[全程干货] TSRPC 和 TypeScript 全栈开发实践:前后端共享代码 / 运行时类型检测 / 二进制序列化
发布于 3 年前 作者 k8w 11986 次浏览 来自 分享

image.png ↑↑↑ 全程 110 分钟干货分享视频 ↑↑↑

TSRPC 是什么

TSRPC 是一个 TypeScript 的 RPC 框架,适用于浏览器 Web 应用、WebSocket 实时应用、NodeJS 微服务等场景。 是目前世界上唯一支持 TypeScript 复杂类型运行时检测和二进制序列化的 RPC 框架

中文文档:https://tsrpc.cn GitHub:https://github.com/k8w/tsrpc 视频教程:https://www.bilibili.com/video/BV1hM4y1u7B4

目前,大多数项目仍在使用传统的 Restful API 进行前后端通信,这存在一些痛点。

  1. 依赖文档进行协议定义,前后端联调常被低级错误困扰(如字段名大小写错误,字段类型错误等)。
  2. 一些框架虽然实现了协议定义规范,但需要引入 Decorator 或第三方 IDL 语言。
  3. 一些框架虽然实现了类型校验,但无法支持 TypeScript 的高级类型,例如业务中常见的 Union Type
// 用户信息
interface UserInfo {
  // 来源渠道
  from: { type: '老用户邀请', fromUserId: string }
    | { type: '推广链接', url: string }
    | { type: '直接进入' },
  // 注册时间
  createTime: Date
}
  1. JSON 支持的类型有限,例如不支持 ArrayBuffer,实现文件上传会非常麻烦。
  2. 请求和响应都是明文,破解门槛太低,字符串加密方式有限且强度不够。
  3. 等等…

我们无法找到一个能完美解决这些问题的现成框架,于是我们全新设计和创造了 TSRPC

概览

一个名为 Hello 的协议,从定义、实现到浏览器调用。

协议定义

直接使用 typeinterface 定义协议,无需 Decorator 和第三方 IDL 语言。

export interface ReqHello {
  name: string;
}

export interface ResHello {
  reply: string;
}

服务端实现

运行时自动校验类型,请求参数一定类型安全。

import { ApiCall } from "tsrpc";

export async function ApiHello(call: ApiCall<ReqHello, ResHello>) {
  call.succ({
    reply: 'Hello, ' + call.req.name
  });
}

客户端调用

跨项目复用协议定义,全程代码提示,不需要接口文档。

let ret = await client.callApi('Hello', {
    name: 'World'
});
console.log(ret); // { isSucc: true, res: { reply: 'Hello, World' } }

code-hint.gif

特性

TSRPC 具有一些前所未有的强大特性,给您带来极致的开发体验。

  • 🥤 原汁原味 TypeScript
    • 直接基于 TypeScript typeinterface 定义协议
    • 无需额外注释,无需 Decorator,无需第三方 IDL 语言
  • 👓 自动类型检查
    • 在编译时刻和运行时刻,自动进行输入输出的类型检查
    • 总是类型安全,放心编写业务代码
  • 💾 二进制序列化
    • 比 JSON 更小的传输体积
    • 比 JSON 更多的数据类型:如 Date, ArrayBuffer, Uint8Array
    • 方便地实现二进制加密
  • 🔥 史上最强大的 TypeScript 序列化算法
  • 多协议
    • 同时支持 HTTP / WebSocket
  • 💻 多平台
    • NodeJS / 浏览器 / App / 小程序
  • ⚡️ 高性能
    • 单核单进程 5000+ QPS 吞吐量(测试于 Macbook Air M1, 2020)
    • 单元测试、压力测试、DevOps 方案齐备
    • 经过数个千万用户级项目验证

兼容性

完全可以在 Server 端使用 TSRPC,同时兼容传统前端。

  • 兼容 JSON 形式的 Restful API 调用
    • 可自行使用 XMLHttpRequestfetch 或其它 AJAX 框架以 JSON 方式调用接口
  • 兼容纯 JavaScript 的项目使用
    • 可在纯 JavaScript 项目中使用 TSRPC Client,也能享受类型检查和序列化特性
  • 兼容 Internet Explorer 10 浏览器
    • 浏览器端兼容至 IE 10 ,Chrome 30

运行时类型检测的实现原理

众所周知,TypeScript 的类型检测仅发生在编译时刻,这是因为类型信息(如 typeinterface)会在编译时刻被抹除。而 TSRPC 竟然能在运行时刻也检测这些被抹除的类型信息? 况且 TypeScript 编译器有大几 MB,而 TSRPC 才几十 KB……

其实,这是因为我们遵循 TypeScript 类型系统,独立实现了一套轻量的类型系统,可以在运行时完成类型检测,甚至是二进制序列化。它支持了绝大多数常用的 TypeScript 类型。

支持的类型清单

上手试试

使用 create-tsrpc-app 工具,可以快速创建 TSRPC 项目。

npx create-tsrpc-app@latest

创建过程是交互式的,在菜单上选择相应的配置,即可轻松创建包含前后端的 TSRPC 全栈应用项目。

create-tsrpc-app.gif

如果你选择创建 HTTP 短连接服务,则会创建一个留言板的演示项目;如果选择 WebSocket 长连接服务,则会创建一个实时聊天室的演示项目。

参考资料

GitHub:https://github.com/k8w/tsrpc 中文文档:https://tsrpc.cn 视频教程:https://www.bilibili.com/video/BV1hM4y1u7B4

15 回复

vue2.x都行?? 2.x ts支持的也不行啊,,

是自己操纵AST实现的吗,认可你的能力

但实现成这样,实在是太花里胡哨了,难以推广,大概率只能自用了。

TypeScript的类型系统好就好在它是可选的,运行时可以不按他来, 现在搞得这么严格,失去了灵活性,也就没意义了; 和GraghQL那套一样,兼具了js的动态缺点和java的静态缺点,完蛋。

@nomagick 运行时不按它来,如果是后端,会有多少安全的风险呀!

举个例子,你有一个用户表:

interface User {
  id: string,
  nickname: string,
  avatar: string, 
  // 余额:敏感字段
  money: number
}

然后你实现了一个,更新用户信息接口 UpdateUser,请求参数为:

interface ReqUpdateUser {
    id: string,
  	update: {
		nickname?: string,
  		avatar?: string, 
	}
}

接口实现为:

// 更新数据库
db.User.updateOne({
    where: {
	    id: req.id
	},
	set: req.update
})

在编译时刻,如果你在 UpdateUser 时传了 money,编译器会自动报错,OK 这当然没问题。

但问题在于,这个类型,在运行时是不安全的,这就埋下了很大的安全隐患。

如果前端构造了一个恶意的请求呢:

{
  id: 'xxx',
  nickname: 'xxx',
  money: 99999
}

由于在运行时缺少类型安全机制,则导致敏感字段没有被严格检查,用户可以直接更新自己的余额! 今天一个小坑,明天一个小坑,串联起来就是系统性的风险。

当然,你可以手动编写逻辑代码,去保证类型安全,但是:

墨菲定律:可能出错的一定会出错

是人总会出错的,所以更好的办法是,在运行时也有一套自动运行的机制,确保输入、输出的类型一定安全。 这可以从根本上,无代价的规避很多系统性的风险。 这也是 TSRPC 出现的根本意义。

@ganshiqingyuan Vue 2.x 可以的哈,不用 TS,纯 JS 项目也能用哈 (只是少了代码提示而已),但仍然有运行时类型检测。

@k8w 你这偷换概念了啊, 我可没说运行时类型检查或者说参数验证是没意义的;

你这个轮子,机巧是足够的,但有几个基本的点是没法被接受的,这和实现关系不大;

TypeScript的定位是开发辅助工具,尤其是类型系统,它的影响应当止于设计时,但你这个用法,使得TypeScript的类型系统对运行时产生了重大影响,但这部分代码,又不在项目的管理范围之内;即程序员不能清楚地意识到何时何地,以何种方式进行了参数验证,这是一个巨坑。

JS的动态性是优势而不是需要去除的缺点。

这还不要说,TypeScript的类型系统还会向前发展,持续变动。

像你回复的举例,是应用系统,是程序员需要解决的问题,而不是编译器和运行时,甚至通讯库需要解决的问题。

再说,参数验证就说参数验证,但你项目不叫RPC么, 这完全就是两码事啊。

这个RPC协议,不要为了二进制而二进制, 包括base128也好,压缩也好,都是为了提升性能,要么就是省点CPU时间要么省点流量,这些都是在超大规模实施的时候才会凸显出来的成本,但它们的代价却是需要在设计时支付的。

而且二进制协议也没必要静态决定结构,静态结构就会涉及到协议版本,这都是处理起来很棘手的问题。

如果说是TypeScript全栈开发的情况,大部分都是原型期的项目,没有必要为了性能,过早支付这些设计时成本。 额外支付着成本,却又享受不到显著的好处,同时又收集到一系列的掣肘,实在是本末倒置,毫无必要。

这就是为什么说,你这个轮子,难以推广。

@nomagick

即程序员不能清楚地意识到何时何地,以何种方式进行了参数验证,这是一个巨坑。

只会在 RPC 的请求和响应时刻,针对输入输出做参数验证。


JS的动态性是优势而不是需要去除的缺点。

运行时类型检测并没有去除 JS 的动态性。

TypeScript 本身的类型系统就已经兼顾到了 JS 的动态性,例如 any 类型,又例如:

interface XXX {
    a: string,
	b: number,
	// 允许扩展任意字段
	[key: string]: any
}

TS 类型系统本身的灵活性,让你可以在需要动态的时候动态,需要严谨的时候严谨。运行时类型检测同样也遵循了这一规则。


像你回复的举例,是应用系统,是程序员需要解决的问题,而不是编译器和运行时,甚至通讯库需要解决的问题。再说,参数验证就说参数验证,但你项目不叫RPC么, 这完全就是两码事啊。

类型安全对于跨项目调用,确实是一个需要解决的问题。 例如 NestJS 通过 Decorator 来解决类似的问题。 是否应当在 RPC 框架内完成,这个可能见仁见智,对于二进制序列化的场景它会打包在框架内(例如 gRPC)。


这个RPC协议,不要为了二进制而二进制, 包括base128也好,压缩也好,都是为了提升性能,要么就是省点CPU时间要么省点流量。

您说的没错,没有必要为了二进制而二进制,当在游戏、金融等一些需要进行传输加密的场景会更有用。 所以序列化是一个可选项,它兼容 HTTP/JSON 调用。 但类型安全是刚需所以它是一个必选项。(否则现有的其它框架已经能很好的满足需要)


这些都是在超大规模实施的时候才会凸显出来的成本,但它们的代价却是需要在设计时支付的。而且二进制协议也没必要静态决定结构,静态结构就会涉及到协议版本,这都是处理起来很棘手的问题。

如果使用 ProtoBuf、Thrift 这样的第三方 IDL 静态类型语言定义,确实会引入相当的额外成本。 如果使用 Decorator 来注解类型,也会引入一些额外的成本。 但 TSRPC 不需要这些,只是需要通过 TS 源代码定义协议就可以(协议总是需要定义的)。 当然代价就是需要手动生成一下(也可以通过 watch 来自动生成更新),至于协议版本的问题,也在生成协议的工具里自动处理,大部分时间不需要关心的。 例如 ProtoBuf 中需要关心协议、接口的 ID 序号。 TSRPC 是不需要关心的这些细节的,已经由工具自动完成了。大部分情况下,只需要像没有序列化那样关注新旧类型本身是否兼容,而不需要考虑编码细节。

例如,旧协议:

interface Request {
    a: string,
	b: number
}
interface Response {
	m: string,
	n: number
}

新协议:

interface Request {
    a: string,
	b: number,
	c?: boolean,
	d?: string[]
}
interface Response {
	m: string,
	n: number,
	p: boolean,
	q: string[]
}

类似上面的例子,只要新旧协议在类型层面兼容,则编码层面是自动兼容的。 即便前端是旧版协议,也可以连接新版协议的后端服务正常使用。

一点点自动生成协议的成本,换来输入输出的类型安全,提升安全性并且可以放心的开发业务逻辑,这个交换是值得的。


非常感谢您的反馈,您的建议很具有代表性。 我们在推进的过程中也确实受到了很多类似的担忧和疑问,文档也不够详尽,对这些问题缺少足够的预见。 这也让我们思考,应该如何更好的包装概念,包括 “RPC” 是不是一个很适应的名词。

❤ 感恩的心 ❤

@nomagick 我理解它这个是一个RPC框架,自然就会包含系列化和运行时强类型检查,其他RPC框架也都是这么做的。而用TS来做IDL,恰恰就是为了屏蔽掉使用RPC框架的复杂性,无需额外编写服务定义代码

@k8w 我要对参数在类型以外进行额外的验证的话,通信层这步检查就更鸡肋了, 问题挺多的啊。 我觉得还是装饰器方案,并且专门做参数验证最靠谱

@leizongmin 哪里自然了老哥,不能gRPC是这么干的,别的RPC也得这么干啊, 毕竟单单RPC并不代表二进制协议和静态结构啊 对于RPC来讲,强类型不是它的特性而是它所受的的限制,是为了性能而做出的牺牲啊

@nomagick 你说得没错,无法反驳

没看明白 被调方? 远端怎么实现?

@netwjx 远端就是 NodeJS 上的一个异步函数。

@k8w 不用抽象到那个高度 -_-b

介绍应倾向于 技术方案评估者, 而不是技术使用者

因为新人多数不会主动探索不知名的东西, 只有资深的同学才会探索新的可能, 而且资深的同学在自己的工作领域是有决定权的

我想了解的是

  • 调用远端是通过 HTTP协议/WebSocket? 为什么这么选型, 会有什么优劣
  • 类型检测 发现问题后, 如何做失败处理?
  • body部分的编码形式?
  • 压缩比率? 定量对比?
  • 编解码性能测试? 定量对比?
  • 如何在现有服务做小规模实验?
  • 如何让现有服务平滑迁移?
  • 集群如何容灾 防止单点故障?

上面这些都是基本功能, 还有什么吸引力特性/ 计划?

@netwjx

调用远端是通过 HTTP协议/WebSocket? 为什么这么选型, 会有什么优劣

  • HTTP 协议可以应付大多数 Web 场景。
  • WebSocket 主要适用于有实时需求的场景
    • 例如实时游戏、股票实时行情、实时通知推送、实时聊天等
    • 对于一些后端之间频繁的微服务调用,WebSocket 长连接有更高的连接利用率,能承载更高的 QPS
  • TSRPC 从一开始就设计为协议无关的
    • 可以实现一份 API 接口,同时支持短连接和长连接,这对于游戏非常实用
    • 想让它支持原生 TCP 甚至 UDP 都不需要太大的代价,只需要在二进制传输层做一点点改动。

类型检测 发现问题后, 如何做失败处理?

框架会自动返回类型错误信息给调用方(错误信息类似 TS 的类型错误信息),处理方式就跟处理普通网络错误、业务错误一致。

let ret = await client.callApi('XXX', { ... });

// 错误处理
if(!ret.isSucc){
  console.log(ret.err.message);   // 人类可读的错误信息
  console.log(ret.err.type);  // 错误类型:如网络错误、业务错误、还是类型错误等
}

body部分的编码形式?

可选择为 二进制(application/octet-stream) 或 JSON(application/json)

压缩比率? 定量对比?

  • 没有进行压缩(即还可以自行通过例如 gzip 等方式进一步减少包体)。
  • 编码算法参照了 Google 的 ProtoBuf,编码效率与 ProtoBuf 几乎一致。

编解码性能测试? 定量对比?

  • 编解码性能与 Protobuf.JS 近似,没有进行过很详细的定量对比(受实际类型不同有所差异)。
  • 阿里云低配服务器,纯编解码吞吐量测试,1 CPU 核在 2500 QPS 左右。
  • Macbook Air 2020 M1 款,纯编解码吞吐量测试, 1 CPU 核 5000 QPS左右。
  • 目前经过验证的最高并发的线上服务大概是 10 万QPS量级,有一些 MongoDB 和 Redis 的操作,压测结果稳定在 1000 QPS / CPU 以上。

如何在现有服务做小规模实验? 通过 npx create-tsrpc-app@latest 创建出的工程,已经带了一些简单例子。

https://github.com/k8w/tsrpc-examples 和文档里也有少量示例。

近期会整理一下我们的过往项目,公开一些较完整的项目例子(例如带权限验证的管理后台)

如何让现有服务平滑迁移?

就跟前端从 jQuery 过渡到 ReactVue 一样,旧项目对于新的技术框架总是一个包袱。 新框架往往最早是从新项目中开始使用的。

如果说衡量利弊后,真的要将旧的后台服务重构一份……

后端:

  1. 按照 TSRPC 协议规范整理所有协议
  2. API 实现部分
    • 修改获取输入、返回输出的部分
    • 移除类型保护的相关代码(框架会自动检测)
  3. 为了旧的前端项目更好的兼容性,可以开启 JSON 兼容模式

前端:

  1. 如果是 TS 项目,可以安装 tsrpc-browser 客户端,如此调用协议处有强类型代码提示,重构更容易
  2. 对于纯 JS 项目,可以开启后端的 JSON 兼容模式,然后仍然使用 HTTP/JSON 的方式调用新接口

集群如何容灾 防止单点故障?

这个部署层面的问题,与使用其它框架如 ExpressJS、EggJS 一致。

如果服务是无状态服务,那么就非常简单:

  • 单机部署,可以使用 PM2 来保证高可用
  • 多机器部署,使用云厂商提供的负载均衡即可
  • 我们更常用 Kubernetes 集群,更灵活的扩缩容和更自动化的运维,各种方案都比较成熟齐备
  • 或者,部署到阿里云或腾讯云的 Serverless 平台也是可以的

有状态服务:

  • 目前还有很大的发挥空间
  • 云厂商的负载均衡基本都支持 HTTP 协议下的会话保持

上面这些都是基本功能, 还有什么吸引力特性/ 计划?

主要吸引力特性其实是 3 点:

1. 直接基于 TS 源码定义协议

  • 支持 TS 复杂类型(例如 Union Type、Pick、Omit 等等),这对于减少类型冗余、避免低级错误非常有用
  • 相比于 NestJS,不需要写 Decorator
  • 相比于 ProtoBuf、Thrift,省去了第三方语言的学习和维护成本
  • 相比于用文档定义协议然后各自实现,代码层面的协议带来的是类型安全,几乎不需要为了低级错误而联调了(如大小写、拼写错误等)
  • 协议文件跨项目、跨端共享,全程代码提示

2. 运行时,自动校验参数类型

  • 参数类型的校验,对于后端服务的安全性来说毋庸置疑
  • TS 在编译时刻的类型检查,无法满足输入不可靠场景下的类型安全(例如 3 楼中的安全漏洞)
  • 现在所有其它基于 Decorator 的校验方案,都只能校验简单的基础类型
  • TSRPC 是目前唯一支持校验 Union Type、Intersection Type、Pick、Omit、Partial 等这些高级复杂类型的库,它们对于定义协议减少类型冗余非常实用

使用这些复杂 TS 类型,并自动校验类型,能带来什么收益? 可以参考这个主题分享:

https://www.bilibili.com/video/BV1hM4y1u7B4 ↑↑↑ 跳转到 35:00 :《以 MongoDB 下的 CRUD 为例,如何更高效的定义 TS 类型》 ↑↑↑

3. 二进制序列化

  • 也是目前唯一的,不需要第三方 DTO 语言,并且支持 TS 复杂类型的序列化方案
  • 序列化对于有减少包体大小需求(例如实时游戏、实时股票数据),以及有防破解需求(例如游戏、反爬虫)的场景非常实用
  • 二进制序列化是一个可选项,可以关闭来实用传统的 JSON 方式

二进制序列化不只是为了减少包体,它比 JSON 支持更多类型,能满足更丰富的业务需要

  • 二进制序列化比 JSON 支持更多的类型,这使你可以直接在请求/响应参数中,使用 DateUint8Array 等类型。
  • 极大的便利就是实现上传文件这样的接口变得非常简单,跟普通 API 接口一样,而不需要关注传输协议细节。
  • 例如实现一个 “按键发送语音” 的功能,如果是 Express 的话,需要涉及到 HTTP 处理 multipart/form-data 的繁琐细节;但如果你只是像调用一个本地异步函数一样,传递一个 Uint8ArrayArrayBuffer 呢?

例如:

export interface Req发送语音 {
	uid: string,
	time: Date,
	data: ArrayBuffer	
}
let ret = await client.callApi('发送语音', {
	uid: 'xxx',
	time: new Date(),
	data: buffer
})

别再用强弱类型了,用静态类型或者动态类型:

FYI:

作为 RPC,调用两端确定输入输出类型是基本,动态性也可以通过 any 做平衡

沿用 TS 的类型,可以不必维护两份类型描述,OP 这样的实现常见且优雅(王者荣誉ID?)。知不知道的问题通过文档和团队内贯宣,这个成本是大家都一样的,选什么方案都得贯宣

回到顶部