博客和公眾號
此文已同步到因卓誒博客,請大家關(guān)注同名公眾號
[See How]全棧Node TS框架TSRPC實踐教程(一)www.yinzhuoei.com前言
某個普通的一天的早晨,水友群的小姐姐和我聊前端架構(gòu)涉馁,因為她們組最近要籌備一些新項目江咳,在做架構(gòu)的中途出現(xiàn)了很多問題返咱,所以我拿到了她們的架構(gòu)項目腳手架代碼氮帐。拿到代碼之后我發(fā)現(xiàn)深圳那邊的前端團(tuán)隊普遍做的很好,有先進(jìn)的架構(gòu)思想洛姑,也把ts用的很純粹上沐,最后沒幫人家解決問題,反倒是自己學(xué)到了不少楞艾。最后我們聊到了前后端全棧開發(fā)参咙,如何動態(tài)校驗協(xié)議參數(shù)等問題,因為熟悉我開源項目(劍指題解)的朋友都知道硫眯,我的后端代碼尤其是動態(tài)校驗?zāi)菈K寫的是真差蕴侧,為了ts而用ts,這也是目前很多用ts的小伙伴的通病两入,所以我一直打算重構(gòu)我的一部分后端代碼净宵,這個時候見多識廣的小姐姐就推薦給我了一個框架,這個框架也是[see how]系列第一篇教程的主角裹纳,這個框架就叫做TSRPC
關(guān)于專欄
關(guān)于see how是什么择葡,說來很巧,這也是TSRPC作者王大大對我的seho這個名字的猜測剃氧,其實我的一個名字也沒那么多深意敏储,然后被大佬解讀成了see how,所以我感覺這是一個不錯的idea朋鞍,那么本來就是想要出一個tsrpc的系列教程已添,和大家一起學(xué)習(xí)這個優(yōu)秀的框架,就正好作為see how 專欄的第一篇文章吧滥酥。
關(guān)于TSRPC
在正文開始之前更舞,我希望大家可以去自行先去簡單快速的瀏覽相關(guān)知識,tsrpc是一個ts的開源rpc框架坎吻,它是為了全棧項目而生的缆蝉,從我上手的第一天開始,我就對這個框架有了以下的第一印象:
- 天然二進(jìn)制傳輸
- 純粹的ts禾怠,規(guī)避了極大部分開發(fā)中的錯誤
- 強(qiáng)大的運行時復(fù)雜檢測
- 這種前后端開發(fā)模式返奉,我聞所未聞
前期準(zhǔn)備
學(xué)習(xí)tsrpc需要你有一些前置知識和其他準(zhǔn)備:
- 熟悉typescript基本語法
- 準(zhǔn)備一個mongodb數(shù)據(jù)庫
開發(fā)
使用tsrpc開發(fā)全棧應(yīng)用簡單到?jīng)]朋友,可以從官方提供的cli快速創(chuàng)建前后端一體項目:
npx create-tsrpc-app@latest
按照指引選擇瀏覽器應(yīng)用吗氏,等待完成安裝之后,你的目錄中會出現(xiàn)2個目錄:
- backend 后端- frontend 前端
我們直接一睹為快雷逆,在前端項目根目錄運行
官方的腳手架為我們準(zhǔn)備了一個簡單的todolist應(yīng)用
整個前后端的目錄結(jié)構(gòu)(摘抄官網(wǎng))
|- backend --------------------------- 后端項目 |- src |- shared -------------------- 前后端共享代碼(同步至前端) |- protocols ------------- 協(xié)議定義 |- api ----------------------- API 實現(xiàn) index.ts|- frontend -------------------------- 前端項目 |- src |- shared -------------------- 前后端共享代碼(只讀) |- protocols |- index.ts
誒弦讽,你可能會疑問了,為啥會有一個莫名其妙的shared目錄,還要給前端項目去分享這個目錄往产。是因為在shared這個目錄我們要定義協(xié)議被碗,啥玩意是協(xié)議呢?我們通過一個小小的接口來給大家解釋什么是協(xié)議仿村;
export interface ReqAddPost { newPost: { name: string; };}export interface ResAddPost { insertedId: string;}
我們可以在shared/protocols中新建了一個文件PtlAddPost.ts锐朴,我們必須以Ptl進(jìn)行開頭定義協(xié)議,協(xié)議是用來描述一個接口的請求和響應(yīng)的結(jié)構(gòu)體的文件蔼囊,你可以這么理解焚志。協(xié)議文件通過shared目錄共享到前端,你知道會發(fā)生什么事情嗎畏鼓?造成了我們前端在對接口的時候酱酬,全程代碼提示以及嚴(yán)格和請求和返回類型校驗。
那么我們接著后端繼續(xù)聊云矫,協(xié)議定義之后該如何做呢膳沽?
npm run proto 每當(dāng)協(xié)議更改后,需要重新運行這個命令
tsrpc的設(shè)計是協(xié)議和api分離让禀,我們必須要清楚挑社,api在我的認(rèn)知里就是一個異步函數(shù),tsrpc可以幫助我們根據(jù)我們剛剛寫的協(xié)議生成api巡揍,比如剛剛我們實現(xiàn)的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這個方法獲取請求參數(shù)以及響應(yīng)給客戶端一些信息,我們來一個簡單的例子:
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已經(jīng)寫完了吼肥,我們需要正常的過一次test录平,然后我們在讓前端去調(diào)用。
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" }, });});
這是我們后端的一個簡單的測試用例斗这,在運行這個測試用例之前,您必須要開啟后端的服務(wù):
然后可以再開啟一個窗口運行npm run test啤斗,如果一切正常表箭,你可以看到下面的控制臺輸出:
粗略計算了一下,我們從開始定義協(xié)議到api測試完成钮莲,一個簡單的接口不到5分鐘就已經(jīng)完成免钻。
這個時候我們可以把這個接口放到前端再繼續(xù)測試一下。
當(dāng)然在此之前崔拥,我們需要運行以下命令:
我們之前提到過极舔,前后端有一個共享的目錄,運行此命令我們就可以把協(xié)議等信息同步過來链瓦,這個時候我們可以在前端的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" },});
當(dāng)我們回到瀏覽器前端頁面上時,這個請求就會發(fā)出渤刃,如果你仔細(xì)觀察控制臺拥峦,會看到以下的場景:
我們的請求體被二進(jìn)制序列化了,這也是tsrpc的特點之一卖子,我們會在稍后的段落中對tsrpc各個特性做介紹略号,但是此時此刻我們已經(jīng)完成了一個api的后端開發(fā)->test測試->前端調(diào)用。
完善我們的程序
上一個部分相信大家已經(jīng)學(xué)會了如何使用tsrpc開發(fā)第一個api洋闽,這個部分結(jié)合了tsrpc的視頻教程中的案例玄柠,我們需要做一個簡單的CRUD,使用mongoDB喊递。
我們需要在本地啟動我們的mongoDB服務(wù)随闪,然后我們需要添加一些代碼到后端backend項目中。
代碼開始之前骚勘,我們需要安裝mongoDB的依賴铐伴,我們可以更方便的引入類型定義以及各種數(shù)據(jù)庫方法。
為了和視頻教程統(tǒng)一俏讹,我們的工具類/架構(gòu)方式当宴,將直接挪用視頻教程中的代碼:
- 寫一個數(shù)據(jù)庫的表模型,名為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; };}
我們的數(shù)據(jù)庫模型是需要共享到前端的泽疆,方便前端工程能夠復(fù)用户矢,但是為了確保后端的類型安全,我們需要在模型上多做一層處理殉疼。mongodb的id屬性不是string梯浪,而是ObjectID,所以我們需要在后端對模型進(jìn)行類型重寫(只重寫id字段)瓢娜。
關(guān)于為什么要在后端多做一層封裝是因為不可能在前端引入mongodb中的objectID
import { ObjectID } from "mongodb";import { Overwrite } from "tsrpc";import { Post } from "../Post";export type DbPost = Overwrite<Post, { _id: ObjectID}>
我們使用tsrpc提供的Overwrite泛型對剛剛寫的Post類型進(jìn)行改寫挂洛,將mongodb中的objectID類型引入進(jìn)來進(jìn)行替換,然后我們后端工程就要使用這個Dbpost類型眠砾,而不是剛剛我們寫的Post類型虏劲。
- 數(shù)據(jù)庫相關(guān)配置
export const BackConfig = { mongoDb: "mongodb://localhost:27017/test",};
- 定義數(shù)據(jù)庫初始化類
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,截止到目前,我們把第一張表的相關(guān)配置已經(jīng)搞定了,請確保數(shù)據(jù)庫已打開且配置正確,然后我們直接運行一下服務(wù)器:
如果你運氣好(狗頭)褒颈,那么你應(yīng)該是成功開啟這個服務(wù)器柒巫,并且控制臺能看到連接成功的信息:
然后我們快速開發(fā)一下新增API,其他的更新和刪除API谷丸,希望能大家舉一反三堡掏,自行開發(fā)。
import { Post } from "./models/Post";export interface ReqAddPost { newPost: Omit<Post, "_id" | "create" | "update" | "visitedNum">;}export interface ResAddPost { insertedId: string;}
我們規(guī)定的請求類型是只能讓客戶端傳遞除了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完成~
如何做到動態(tài)類型校驗
之前我們就提到過游两,前端在調(diào)用后端的api時候,會給出完整的代碼提示漩绵,從api名稱到api的請求體類型等等贱案,那么這一定程度上杜絕了開發(fā)中常見的接口聯(lián)調(diào)不細(xì)心的問題。在傳統(tǒng)的前后端開發(fā)中止吐,尤其是分離模式宝踪,有一個非常常見的問題就是動態(tài)類型校驗。每個語言/框架都有自己類型校驗的手段碍扔,比如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是如何保證數(shù)據(jù)傳輸?shù)恼_性的呢,首先我們?nèi)绻谇岸耸褂胻srpc的瀏覽器請求包不同,我們調(diào)用api時候不僅會在開發(fā)中提示開發(fā)者這個字段是錯誤的厉膀,而且會在請求發(fā)出之前做前端方面的遏制。在后端請求到達(dá)異步函數(shù)之前二拐,也會去做第三次校驗服鹅;所以我們在后端異步函數(shù)中使用到的參數(shù)一定是類型安全,完全不需要擔(dān)心安全問題百新。
市面上有很多js領(lǐng)域解決動態(tài)校驗的方案企软;最常見應(yīng)該就是json schema,可以基于json自己實現(xiàn)一套校驗方法可以在運行時來做校驗饭望。但是仍然有很多缺點仗哨,比如不能在前端進(jìn)行運行時提示且可能重復(fù)寫很多類型定義。那么tsrpc核心中使用到了一個庫(這個庫也是同個作者開發(fā)的):
為了實現(xiàn)ts動態(tài)類型校驗铅辞,不可能把整個ts加進(jìn)去厌漂,因為那有足足60m多,這是不現(xiàn)實的巷挥。所以作者開發(fā)了這個庫桩卵。tsrpc依賴了這個庫,它對ts的語法進(jìn)行了兼容倍宾,目前支持了大部分的ts的寫法雏节,包括我們常用的string,number等高职,還支持一些復(fù)雜的泛形钩乍。
如果你想細(xì)細(xì)了解這方面,可以看一下文檔支持的ts類型有哪些
當(dāng)然怔锌,隨著ts的更新寥粹,這個buffer也會支持更多的ts類型变过,可以做更完善的全棧應(yīng)用。而且我們可以使用tsrpc進(jìn)行原汁原味的ts開發(fā)涝涤,市面上的第三方工具/框架需要借助另外編程語言/DSL媚狰,tsrpc-buffer完全讓你使用ts,你不會感覺到一絲違和感阔拳。
二進(jìn)制序列化
tsrpc的二進(jìn)制序列化機(jī)制是由我們上文中提到的tsrpc-buffer中實現(xiàn)的崭孤,那么這個特性帶給我們的是比json更小的傳輸體積且支持更多的數(shù)據(jù)類型,ArrayBuffer, Date等糊肠。這意味著使用tsrpc的全棧應(yīng)用在應(yīng)對上傳圖片這種業(yè)務(wù)的時候簡直就像是小兒科辨宠,我們可以用一個例子來證明。
export interface ReqUpload { fileName: string, fileData: Uint8Array}export interface ResUpload { url: string;}
我們通過剛剛學(xué)到的一些命令货裹,來生成協(xié)議以及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, });}
為了讓前端調(diào)用嗤形,同步shared下的協(xié)議
寫一個簡單的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); });}
開發(fā)完畢,我們可以仔細(xì)看一下控制臺:
盡管我們在日常開發(fā)中會用到一些組件庫弧圆,組件庫幫助我們做了上傳的大部分工作赋兵,所以我們寫原生的上傳可能在代碼量上更多,但是省去了前后端轉(zhuǎn)換Formdata的時間墓阀。
向后兼容http(json)和WebSocket
tsrpc也向后支持json毡惜,我們可以在客戶端進(jìn)行一個簡單的配置,發(fā)送的請求就是json啦:
let client = new HttpClient(serviceProto, { server: "http://127.0.0.1:3000", json: true, logger: console,});
我其實暫時沒有想到非要使用json的場景斯撮,使用二進(jìn)制序列化比json體積更小傳輸更快经伙,本地開發(fā)的日志也在控制臺隨時打印,所以我還是建議大家使用默認(rèn)的二進(jìn)制序列化的傳輸模式勿锅。
tsrpc設(shè)計之初是為了游戲帕膜,因為傳輸特性能讓websocket更高效,我們可以用tsrpc簡單做一個websocket-demo溢十,具體實現(xiàn)我參考了官網(wǎng)的實現(xiàn)垮刹,如果你想直接了解官網(wǎng)的這一part的內(nèi)容,直接移步:
tsrpc的實現(xiàn)和協(xié)議無關(guān)张弛,意味著咱們之前寫的代碼都可以用荒典,僅僅做一個簡單的調(diào)整替換即可。
websocket的消息是tsrpc傳輸中最小單元吞鸭,我們需要用另外一個方法去定義協(xié)議寺董,我們的websocket例子如下:
客戶端發(fā)起一個請求,服務(wù)端接收并且向所有客戶端發(fā)送一個消息
首先我們需要定義一個MsgHello.ts這樣的協(xié)議:
export interface MsgHello { time: Date; content: string;}
這個協(xié)議規(guī)定了前后端通訊的請求體刻剥。
我們需要改寫后端backend中的index.ts遮咖,將原先的HTTP服務(wù),改成Websocket服務(wù)
import { HttpServer, WsServer } from "tsrpc";export const server = new WsServer(serviceProto, { port: 3000, logMsg: true});
這里導(dǎo)出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來觸發(fā)后端給client發(fā)送websocket消息:
export interface ReqSend { content: string;}export interface ResSend { time: Date;}
定義成功后麦箍,我們運行以下幾個命令:
npm run protonpm run api
運行成功,我們可以在api文件夾下的ApiSend.ts中寫入以下內(nèi)容:
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, });}
我們的后端邏輯寫完了陶珠,我們運行以下命令挟裂,將協(xié)議同步到前端
我們進(jìn)一步改寫前端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();
我們在頁面初始化的時候,向后端發(fā)送剛剛寫好的SendApi背率,這個時候我們既能收到api的返回话瞧,也能收到websocket的消息推送嫩与。
可以看到websocket傳輸也是二進(jìn)制的寝姿,我們在開發(fā)中,也能發(fā)現(xiàn)划滋,無論是callApi和發(fā)送websocket通知饵筑,從始至終都有類型推導(dǎo),永遠(yuǎn)不會在傳輸中出現(xiàn)類型上的錯誤处坪,這就是tsrpc的強(qiáng)大之處根资。
多平臺
tsrpc支持多個平臺,支持瀏覽器/小程序/原生ios 安卓/nodejs同窘,甚至它還支持serverless玄帕,可以使用tsrpc開發(fā)基于阿里云/騰訊云的云函數(shù);在后續(xù)我也會對tsrpc生態(tài)開發(fā)更多插件想邦,使其兼容uniapp&unicloud裤纹,讓他嚴(yán)格嚴(yán)格意義上跨多端,我相信tsrpc可以改變unicloud的開發(fā)習(xí)慣丧没,讓全棧應(yīng)用更簡單鹰椒。
結(jié)語
本篇文章所有的知識點均在官網(wǎng)&視頻教程有體現(xiàn),視頻教程在文章開始之前就有鏈接呕童,非常希望大家能夠先去看一下那個視頻漆际。tsrpc的教程還會出,下一篇關(guān)于tsrpc文章主要還是講一下如何和serverless(unicloud)融合夺饲。這篇文章正在寫大綱奸汇,相信也會在這個月之內(nèi)能和大家見到。
本文使用 文章同步助手 同步