[See How]全栈Node TS框架TSRPC实践教程(一)

博客和公众号

此文已同步到因卓诶博客,请大家关注同名公众号

[See How]全栈Node TS框架TSRPC实践教程(一)www.yinzhuoei.com

前言

某个普通的一天的早晨,水友群的小姐姐和我聊前端架构,因为她们组最近要筹备一些新项目,在做架构的中途出现了很多问题,所以我拿到了她们的架构项目脚手架代码。拿到代码之后我发现深圳那边的前端团队普遍做的很好,有先进的架构思想,也把ts用的很纯粹,最后没帮人家解决问题,反倒是自己学到了不少。最后我们聊到了前后端全栈开发,如何动态校验协议参数等问题,因为熟悉我开源项目(剑指题解)的朋友都知道,我的后端代码尤其是动态校验那块写的是真差,为了ts而用ts,这也是目前很多用ts的小伙伴的通病,所以我一直打算重构我的一部分后端代码,这个时候见多识广的小姐姐就推荐给我了一个框架,这个框架也是[see how]系列第一篇教程的主角,这个框架就叫做TSRPC

关于专栏

关于see how是什么,说来很巧,这也是TSRPC作者王大大对我的seho这个名字的猜测,其实我的一个名字也没那么多深意,然后被大佬解读成了see how,所以我感觉这是一个不错的idea,那么本来就是想要出一个tsrpc的系列教程,和大家一起学习这个优秀的框架,就正好作为see how 专栏的第一篇文章吧。

关于TSRPC

在正文开始之前,我希望大家可以去自行先去简单快速的浏览相关知识,tsrpc是一个ts的开源rpc框架,它是为了全栈项目而生的,从我上手的第一天开始,我就对这个框架有了以下的第一印象:

  1. 天然二进制传输
  2. 纯粹的ts,规避了极大部分开发中的错误
  3. 强大的运行时复杂检测
  4. 这种前后端开发模式,我闻所未闻

官方文档
视频教程

前期准备

学习tsrpc需要你有一些前置知识和其他准备:

  1. 熟悉typescript基本语法
  2. 准备一个mongodb数据库

开发

使用tsrpc开发全栈应用简单到没朋友,可以从官方提供的cli快速创建前后端一体项目:

npx create-tsrpc-app@latest

按照指引选择浏览器应用,等待完成安装之后,你的目录中会出现2个目录:

- backend 后端- frontend 前端

我们直接一睹为快,在前端项目根目录运行

官方的脚手架为我们准备了一个简单的todolist应用

整个前后端的目录结构(摘抄官网)

|- backend --------------------------- 后端项目    |- src        |- shared -------------------- 前后端共享代码(同步至前端)            |- protocols ------------- 协议定义        |- api ----------------------- API 实现        index.ts|- frontend -------------------------- 前端项目    |- src        |- shared -------------------- 前后端共享代码(只读)            |- protocols        |- index.ts

诶,你可能会疑问了,为啥会有一个莫名其妙的shared目录,还要给前端项目去分享这个目录。是因为在shared这个目录我们要定义协议,啥玩意是协议呢?我们通过一个小小的接口来给大家解释什么是协议;

export interface ReqAddPost {  newPost: {     name: string;  };}export interface ResAddPost {  insertedId: string;}

我们可以在shared/protocols中新建了一个文件PtlAddPost.ts,我们必须以Ptl进行开头定义协议,协议是用来描述一个接口的请求和响应的结构体的文件,你可以这么理解。协议文件通过shared目录共享到前端,你知道会发生什么事情吗?造成了我们前端在对接口的时候,全程代码提示以及严格和请求和返回类型校验。

那么我们接着后端继续聊,协议定义之后该如何做呢?

npm run proto 每当协议更改后,需要重新运行这个命令

tsrpc的设计是协议和api分离,我们必须要清楚,api在我的认知里就是一个异步函数,tsrpc可以帮助我们根据我们刚刚写的协议生成api,比如刚刚我们实现的PtlAddPost.ts,我们运行

在api目录中会多出一个ApiAddPost.ts

import { ApiCall } from "tsrpc";import { ReqAddPost, ResAddPost } from "../shared/protocols/PtlAddPost";export async function ApiAddPost(call: ApiCall<ReqAddPost, ResAddPost>) {  }

我们通过call这个方法获取请求参数以及响应给客户端一些信息,我们来一个简单的例子:

export async function ApiAddPost(call: ApiCall<ReqAddPost, ResAddPost>) {  if(call.req.newpost.name){     call.success({         msg: "hello," + call.req.newpost.name     })  }else{     call.error('Invalid name');  }}

我们的第一个api已经写完了,我们需要正常的过一次test,然后我们在让前端去调用。

tsrpc使用的是mocha这个测试框架。

import { HttpClient } from "tsrpc";import { serviceProto } from "../../src/shared/protocols/serviceProto";describe("api 测试", async function () {  let client = new HttpClient(serviceProto, {    server: "http://127.0.0.1:3000",    logger: console,  });  let ret = await client.callApi("AddTest", {    newPost: {      name: "seho"    },  });});

这是我们后端的一个简单的测试用例,在运行这个测试用例之前,您必须要开启后端的服务:

然后可以再开启一个窗口运行npm run test,如果一切正常,你可以看到下面的控制台输出:

粗略计算了一下,我们从开始定义协议到api测试完成,一个简单的接口不到5分钟就已经完成。

这个时候我们可以把这个接口放到前端再继续测试一下。

当然在此之前,我们需要运行以下命令:

我们之前提到过,前后端有一个共享的目录,运行此命令我们就可以把协议等信息同步过来,这个时候我们可以在前端的index.ts文件中,可以获得非常完善的代码提示。

import { HttpClient } from "tsrpc-browser";import { serviceProto } from "./shared/protocols/serviceProto";let client = new HttpClient(serviceProto, {  server: "http://127.0.0.1:3000",  logger: console,});client.callApi("AddTest", {  newPost: {    name: "hello, seho"  },});

当我们回到浏览器前端页面上时,这个请求就会发出,如果你仔细观察控制台,会看到以下的场景:

我们的请求体被二进制序列化了,这也是tsrpc的特点之一,我们会在稍后的段落中对tsrpc各个特性做介绍,但是此时此刻我们已经完成了一个api的后端开发->test测试->前端调用。

完善我们的程序

上一个部分相信大家已经学会了如何使用tsrpc开发第一个api,这个部分结合了tsrpc的视频教程中的案例,我们需要做一个简单的CRUD,使用mongoDB。

我们需要在本地启动我们的mongoDB服务,然后我们需要添加一些代码到后端backend项目中。

代码开始之前,我们需要安装mongoDB的依赖,我们可以更方便的引入类型定义以及各种数据库方法。

为了和视频教程统一,我们的工具类/架构方式,将直接挪用视频教程中的代码:

  • 写一个数据库的表模型,名为Post.ts
export interface Post {  _id: string;  author: string;  title: string;  content: string;  visitedNum: number;  create: {    uid: string;    time: Date;  };  update?: {    uid: string;    time: Date;  };}

我们的数据库模型是需要共享到前端的,方便前端工程能够复用,但是为了确保后端的类型安全,我们需要在模型上多做一层处理。mongodb的id属性不是string,而是ObjectID,所以我们需要在后端对模型进行类型重写(只重写id字段)。

关于为什么要在后端多做一层封装是因为不可能在前端引入mongodb中的objectID

import { ObjectID } from "mongodb";import { Overwrite } from "tsrpc";import { Post } from "../Post";export type DbPost = Overwrite<Post, {    _id: ObjectID}>

我们使用tsrpc提供的Overwrite泛型对刚刚写的Post类型进行改写,将mongodb中的objectID类型引入进来进行替换,然后我们后端工程就要使用这个Dbpost类型,而不是刚刚我们写的Post类型。

  • 数据库相关配置
export const BackConfig = {    mongoDb: "mongodb://localhost:27017/test",};
  • 定义数据库初始化类
import { Collection, Db, MongoClient } from "mongodb";import { Logger } from "tsrpc";import { BackConfig } from "./BackConfig";import { DbPost } from "./dbItems/DbPost";export class Global {  static db: Db;  static async init(logger?: Logger) {    logger?.log(`Start connecting db...`);    const client = await new MongoClient(BackConfig.mongoDb).connect();    logger?.log(`Db connected successfully...`);    this.db = client.db();  }  static collection<T extends keyof DbCollectionType>(    col: T  ): Collection<DbCollectionType[T]> {    return this.db.collection(col);  }}export interface DbCollectionType {  Post: DbPost;}
  • 改写后端index.ts
import { Global } from "../src/shared/protocols/models/Global";async function main() {        await server.autoImplementApi(path.resolve(__dirname, 'api'));        await Global.init(server.logger);    await server.start();};

ok,截止到目前,我们把第一张表的相关配置已经搞定了,请确保数据库已打开且配置正确,然后我们直接运行一下服务器:

如果你运气好(狗头),那么你应该是成功开启这个服务器,并且控制台能看到连接成功的信息:

然后我们快速开发一下新增API,其他的更新和删除API,希望能大家举一反三,自行开发。

import { Post } from "./models/Post";export interface ReqAddPost {  newPost: Omit<Post, "_id" | "create" | "update" | "visitedNum">;}export interface ResAddPost {  insertedId: string;}

我们规定的请求类型是只能让客户端传递除了id,create,update,visitedNum的Post类型。然后我们还是运行那几个熟悉的命令:

npm run protonpm run apiimport { ApiCall } from "tsrpc";import { Global } from "../shared/protocols/models/Global";import { ReqAddPost, ResAddPost } from "../shared/protocols/PtlAddPost";export async function ApiAddPost(call: ApiCall<ReqAddPost, ResAddPost>) {  let op = await Global.collection("Post").insertOne({    ...call.req.newPost,    create: {      uid: "xxx",      time: new Date(),    },    visitedNum: 0,  });  call.succ({    insertedId: op.insertedId.toHexString(),  });}

这一part完成~

如何做到动态类型校验

之前我们就提到过,前端在调用后端的api时候,会给出完整的代码提示,从api名称到api的请求体类型等等,那么这一定程度上杜绝了开发中常见的接口联调不细心的问题。在传统的前后端开发中,尤其是分离模式,有一个非常常见的问题就是动态类型校验。每个语言/框架都有自己类型校验的手段,比如springmvc我们可以通过注解的方式来校验(下面展示了控制器中的校验,还有其他校验手段):

@Controller@RequestMapping("valid")@Slf4jpublic class ValidateController { private static final String BASE_PATH = "/valid/"; @RequestMapping("index") public String index(@Validated() Student student,BindingResult result){         return BASE_PATH + "index";    }}

那么tsrpc是如何保证数据传输的正确性的呢,首先我们如果在前端使用tsrpc的浏览器请求包,我们调用api时候不仅会在开发中提示开发者这个字段是错误的,而且会在请求发出之前做前端方面的遏制。在后端请求到达异步函数之前,也会去做第三次校验;所以我们在后端异步函数中使用到的参数一定是类型安全,完全不需要担心安全问题。

市面上有很多js领域解决动态校验的方案;最常见应该就是json schema,可以基于json自己实现一套校验方法可以在运行时来做校验。但是仍然有很多缺点,比如不能在前端进行运行时提示且可能重复写很多类型定义。那么tsrpc核心中使用到了一个库(这个库也是同个作者开发的):

tsrpc-buffer

为了实现ts动态类型校验,不可能把整个ts加进去,因为那有足足60m多,这是不现实的。所以作者开发了这个库。tsrpc依赖了这个库,它对ts的语法进行了兼容,目前支持了大部分的ts的写法,包括我们常用的string,number等,还支持一些复杂的泛形。

如果你想细细了解这方面,可以看一下文档支持的ts类型有哪些

当然,随着ts的更新,这个buffer也会支持更多的ts类型,可以做更完善的全栈应用。而且我们可以使用tsrpc进行原汁原味的ts开发,市面上的第三方工具/框架需要借助另外编程语言/DSL,tsrpc-buffer完全让你使用ts,你不会感觉到一丝违和感。

二进制序列化

tsrpc的二进制序列化机制是由我们上文中提到的tsrpc-buffer中实现的,那么这个特性带给我们的是比json更小的传输体积且支持更多的数据类型,ArrayBuffer, Date等。这意味着使用tsrpc的全栈应用在应对上传图片这种业务的时候简直就像是小儿科,我们可以用一个例子来证明。

export interface ReqUpload {    fileName: string,    fileData: Uint8Array}export interface ResUpload {    url: string;}

我们通过刚刚学到的一些命令,来生成协议以及api

npm run protonpm run apiimport { ApiCall } from "tsrpc";import { ReqUpload, ResUpload } from "../shared/protocols/PtlUpload";import fs from "fs/promises";export async function ApiUpload(call: ApiCall<ReqUpload, ResUpload>) {  await fs.writeFile("uploads/" + call.req.fileName, call.req.fileData);  call.succ({    url: "http://127.0.0.1:3000/uploads/" + call.req.fileName,  });}

为了让前端调用,同步shared下的协议

写一个简单的file选择器在index.html中

<input type="file" id="fileInput">import { HttpClient } from "tsrpc-browser";import { serviceProto } from "./shared/protocols/serviceProto";let client = new HttpClient(serviceProto, {  server: "http://127.0.0.1:3000",  logger: console,});const input = document.getElementById("fileInput") as HTMLInputElement;input.addEventListener("change", async () => {  if (input.files) {    const fileData = await loadFile(input.files?.[0]);    upload(fileData, input.files?.[0].name);  }});const upload = async (fileData: Uint8Array, fileName: string) => {  const fr = new FileReader();  client.callApi("Upload", {    fileData,    fileName,  });};function loadFile(file: File): Promise<Uint8Array> {  return new Promise((rs) => {    let reader = new FileReader();    reader.onload = (e) => {      rs(new Uint8Array(e.target!.result as ArrayBuffer));    };    reader.readAsArrayBuffer(file);  });}

开发完毕,我们可以仔细看一下控制台:

尽管我们在日常开发中会用到一些组件库,组件库帮助我们做了上传的大部分工作,所以我们写原生的上传可能在代码量上更多,但是省去了前后端转换Formdata的时间。

向后兼容http(json)和WebSocket

tsrpc也向后支持json,我们可以在客户端进行一个简单的配置,发送的请求就是json啦:

let client = new HttpClient(serviceProto, {  server: "http://127.0.0.1:3000",  json: true,  logger: console,});

我其实暂时没有想到非要使用json的场景,使用二进制序列化比json体积更小传输更快,本地开发的日志也在控制台随时打印,所以我还是建议大家使用默认的二进制序列化的传输模式。

tsrpc设计之初是为了游戏,因为传输特性能让websocket更高效,我们可以用tsrpc简单做一个websocket-demo,具体实现我参考了官网的实现,如果你想直接了解官网的这一part的内容,直接移步:

websocket实时服务-tsrpc

tsrpc的实现和协议无关,意味着咱们之前写的代码都可以用,仅仅做一个简单的调整替换即可。

websocket的消息是tsrpc传输中最小单元,我们需要用另外一个方法去定义协议,我们的websocket例子如下:

客户端发起一个请求,服务端接收并且向所有客户端发送一个消息

首先我们需要定义一个MsgHello.ts这样的协议:

export interface MsgHello {  time: Date;  content: string;}

这个协议规定了前后端通讯的请求体。

我们需要改写后端backend中的index.ts,将原先的HTTP服务,改成Websocket服务

import { HttpServer, WsServer } from "tsrpc";export const server = new WsServer(serviceProto, {    port: 3000,    logMsg: true});

这里导出server是有用意的,我们将在之后的代码中会用到这个server。

改写frontend前端中的index.ts

import { HttpClient, WsClient } from "tsrpc-browser";let ws = new WsClient(serviceProto, {  server: "ws://127.0.0.1:3000",  logger: console,});const init = async () => {    let result = await ws.connect();};init();

我们需要一个api来触发后端给client发送websocket消息:

export interface ReqSend {  content: string;}export interface ResSend {  time: Date;}

定义成功后,我们运行以下几个命令:

npm run protonpm run api

运行成功,我们可以在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>) {  const time = new Date();  call.succ({    time,  });    server.broadcastMsg("Hello", {    content: call.req.content,    time,  });}

我们的后端逻辑写完了,我们运行以下命令,将协议同步到前端

我们进一步改写前端frontend/src/index.ts:

import { HttpClient, WsClient } from "tsrpc-browser";import { serviceProto } from "./shared/protocols/serviceProto";let ws = new WsClient(serviceProto, {  server: "ws://127.0.0.1:3000",  logger: console,});const init = async () => {  let result = await ws.connect();  console.log(result)  if (result.isSucc) {    // ws.callApi    ws.callApi("Send", {      content: "hello websocket",    });  }};init();

我们在页面初始化的时候,向后端发送刚刚写好的SendApi,这个时候我们既能收到api的返回,也能收到websocket的消息推送。

可以看到websocket传输也是二进制的,我们在开发中,也能发现,无论是callApi和发送websocket通知,从始至终都有类型推导,永远不会在传输中出现类型上的错误,这就是tsrpc的强大之处。

多平台

tsrpc支持多个平台,支持浏览器/小程序/原生ios 安卓/nodejs,甚至它还支持serverless,可以使用tsrpc开发基于阿里云/腾讯云的云函数;在后续我也会对tsrpc生态开发更多插件,使其兼容uniapp&unicloud,让他严格严格意义上跨多端,我相信tsrpc可以改变unicloud的开发习惯,让全栈应用更简单。

结语

本篇文章所有的知识点均在官网&视频教程有体现,视频教程在文章开始之前就有链接,非常希望大家能够先去看一下那个视频。tsrpc的教程还会出,下一篇关于tsrpc文章主要还是讲一下如何和serverless(unicloud)融合。这篇文章正在写大纲,相信也会在这个月之内能和大家见到。

本文使用 文章同步助手 同步

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,362评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,330评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,247评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,560评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,580评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,569评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,929评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,587评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,840评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,596评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,678评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,366评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,945评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,929评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,165评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,271评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,403评论 2 342

推荐阅读更多精彩内容