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 进行前后端通信,这存在一些痛点。
- 依赖文档进行协议定义,前后端联调常被低级错误困扰(如字段名大小写错误,字段类型错误等)。
- 一些框架虽然实现了协议定义规范,但需要引入 Decorator 或第三方 IDL 语言。
- 一些框架虽然实现了类型校验,但无法支持 TypeScript 的高级类型,例如业务中常见的 Union Type:
// 用户信息
interface UserInfo {
// 来源渠道
from: { type: '老用户邀请', fromUserId: string }
| { type: '推广链接', url: string }
| { type: '直接进入' },
// 注册时间
createTime: Date
}
- JSON 支持的类型有限,例如不支持
ArrayBuffer
,实现文件上传会非常麻烦。 - 请求和响应都是明文,破解门槛太低,字符串加密方式有限且强度不够。
- 等等…
我们无法找到一个能完美解决这些问题的现成框架,于是我们全新设计和创造了 TSRPC 。
概览
一个名为 Hello
的协议,从定义、实现到浏览器调用。
协议定义
直接使用 type
或 interface
定义协议,无需 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' } }
特性
TSRPC 具有一些前所未有的强大特性,给您带来极致的开发体验。
- 🥤 原汁原味 TypeScript
- 直接基于 TypeScript
type
和interface
定义协议 - 无需额外注释,无需 Decorator,无需第三方 IDL 语言
- 直接基于 TypeScript
- 👓 自动类型检查
- 在编译时刻和运行时刻,自动进行输入输出的类型检查
- 总是类型安全,放心编写业务代码
- 💾 二进制序列化
- 比 JSON 更小的传输体积
- 比 JSON 更多的数据类型:如
Date
,ArrayBuffer
,Uint8Array
等 - 方便地实现二进制加密
- 🔥 史上最强大的 TypeScript 序列化算法
- 无需任何注解,直接实现将 TypeScript 源码中的类型定义序列化
- 首个也是目前唯一支持 TypeScript 高级类型的二进制序列化算法,包括:
- ☎ 多协议
- 同时支持 HTTP / WebSocket
- 💻 多平台
- NodeJS / 浏览器 / App / 小程序
- ⚡️ 高性能
- 单核单进程 5000+ QPS 吞吐量(测试于 Macbook Air M1, 2020)
- 单元测试、压力测试、DevOps 方案齐备
- 经过数个千万用户级项目验证
兼容性
完全可以在 Server 端使用 TSRPC,同时兼容传统前端。
- 兼容 JSON 形式的 Restful API 调用
- 可自行使用
XMLHttpRequest
、fetch
或其它 AJAX 框架以 JSON 方式调用接口
- 可自行使用
- 兼容纯 JavaScript 的项目使用
- 可在纯 JavaScript 项目中使用 TSRPC Client,也能享受类型检查和序列化特性
- 兼容 Internet Explorer 10 浏览器
- 浏览器端兼容至 IE 10 ,Chrome 30
运行时类型检测的实现原理
众所周知,TypeScript 的类型检测仅发生在编译时刻,这是因为类型信息(如 type
、interface
)会在编译时刻被抹除。而 TSRPC 竟然能在运行时刻也检测这些被抹除的类型信息?
况且 TypeScript 编译器有大几 MB,而 TSRPC 才几十 KB……
其实,这是因为我们遵循 TypeScript 类型系统,独立实现了一套轻量的类型系统,可以在运行时完成类型检测,甚至是二进制序列化。它支持了绝大多数常用的 TypeScript 类型。
上手试试
使用 create-tsrpc-app
工具,可以快速创建 TSRPC 项目。
npx create-tsrpc-app@latest
创建过程是交互式的,在菜单上选择相应的配置,即可轻松创建包含前后端的 TSRPC 全栈应用项目。
如果你选择创建 HTTP 短连接服务,则会创建一个留言板的演示项目;如果选择 WebSocket 长连接服务,则会创建一个实时聊天室的演示项目。
参考资料
GitHub:https://github.com/k8w/tsrpc 中文文档:https://tsrpc.cn 视频教程:https://www.bilibili.com/video/BV1hM4y1u7B4
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全栈开发的情况,大部分都是原型期的项目,没有必要为了性能,过早支付这些设计时成本。 额外支付着成本,却又享受不到显著的好处,同时又收集到一系列的掣肘,实在是本末倒置,毫无必要。
这就是为什么说,你这个轮子,难以推广。
即程序员不能清楚地意识到何时何地,以何种方式进行了参数验证,这是一个巨坑。
只会在 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部分的编码形式?
- 压缩比率? 定量对比?
- 编解码性能测试? 定量对比?
- 如何在现有服务做小规模实验?
- 如何让现有服务平滑迁移?
- 集群如何容灾 防止单点故障?
上面这些都是基本功能, 还有什么吸引力特性/ 计划?
调用远端是通过 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
过渡到 React
和 Vue
一样,旧项目对于新的技术框架总是一个包袱。
新框架往往最早是从新项目中开始使用的。
如果说衡量利弊后,真的要将旧的后台服务重构一份……
后端:
- 按照 TSRPC 协议规范整理所有协议
- API 实现部分
- 修改获取输入、返回输出的部分
- 移除类型保护的相关代码(框架会自动检测)
- 为了旧的前端项目更好的兼容性,可以开启 JSON 兼容模式
前端:
- 如果是 TS 项目,可以安装
tsrpc-browser
客户端,如此调用协议处有强类型代码提示,重构更容易 - 对于纯 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 支持更多的类型,这使你可以直接在请求/响应参数中,使用
Date
、Uint8Array
等类型。 - 极大的便利就是实现上传文件这样的接口变得非常简单,跟普通 API 接口一样,而不需要关注传输协议细节。
- 例如实现一个 “按键发送语音” 的功能,如果是 Express 的话,需要涉及到 HTTP 处理
multipart/form-data
的繁琐细节;但如果你只是像调用一个本地异步函数一样,传递一个Uint8Array
、ArrayBuffer
呢?
例如:
export interface Req发送语音 {
uid: string,
time: Date,
data: ArrayBuffer
}
let ret = await client.callApi('发送语音', {
uid: 'xxx',
time: new Date(),
data: buffer
})
别再用强弱类型了,用静态类型或者动态类型:
FYI:
- What is the difference between a strongly typed language and a statically typed language?
- Strong and weak typing
作为 RPC,调用两端确定输入输出类型是基本,动态性也可以通过 any
做平衡
沿用 TS 的类型,可以不必维护两份类型描述,OP 这样的实现常见且优雅(王者荣誉ID?)。知不知道的问题通过文档和团队内贯宣,这个成本是大家都一样的,选什么方案都得贯宣