科學甩鍋技術: Typescript 執行時資料校驗
大家出來寫
Bug
程式碼的,難免會出 Bug。
文章背景就發生在一個 Bug 身上,
有一天,測試慌張中帶著點興奮衝過來: 測試:"xxx系統前端線上出 Bug 了,點進xx頁面一片空白啊"。 我:"納尼?我寫的Bug怎麼會出現程式碼呢?"。

雖然大腦一片空白,但是鍋還是要背的。 進入頁面一看,哦豁,完蛋, cannot read the property 'xx' of undefined
。確實是前端常見的報錯呀。
背鍋王,我當定了?
NO!
我眉頭一皺,發現事情並不是那麼簡單,經過一番猛如虎的操作之後,最終定位到問題是:後端介面響應的 JSON 資料中,一個巢狀比較深的欄位沒有返回,即前端只讀到了 undefined
。
咱按章程辦事, 後端提供的介面文件指定了資料結構,那你沒有返回正確資料結構,這就是你後端的鍋 ,雖然嚴謹點前端也能捕獲到錯誤進行處理,但歸根到底,是你後端資料介面處理有問題, 這鍋,我不背。
甩鍋又是一門扯皮的事情,殺敵一千自傷八百,鍋已經扣下來了,想甩出去就難咯,。
唉,要是在接口出錯的時候,能立刻知道介面資料出問題,先發制人,馬上把鍋甩出去那就好咯。
這就是本文即將要講述的 " Typescript 執行時資料校驗 "。
為什麼要執行時校驗資料?
眾所周知, Typescript
是 JavaScript
超集,可以給我們的專案程式碼提供靜態型別檢查,避免因為各種原因而未及時發現的程式碼錯誤,在 編譯時 就能發現隱藏的程式碼隱患,從而提高程式碼質量。
但是, TypeScript
專案的一個常見問題是: 如何驗證來自外部源的資料並將驗證的資料與TypeScript型別聯絡起來。 即,如何避免 後端 API 返回的資料與 Typescript
型別定義不一致導致的執行時錯誤。
Typescript
能用於執行時校驗資料型別,那麼有沒有一種方法,能讓我們在 執行時 也進行 Typescript
資料型別校驗呢?
io-ts 解決方案?
業界開源了一個執行時校驗的工具庫: io-ts 。
//io-ts 例子 import * as t from 'io-ts' // ts 定義 interface Category { name: string categories: Array<Category> } // 對應上述ts定義的 io-ts 實現 const Category: t.Type<Category> = t.recursion('Category', () => t.type({ name: t.string, categories: t.array(Category) }) ) 複製程式碼
但是,如上面的程式碼所示,這工具看起來就 有點囉嗦有點難用 ,對程式碼的 侵入性非常強 ,要全盤依據它的語法來重寫程式碼。這對於一個團隊來說,存在一定的遷移成本。
而我們更希望做到的理想方案是:
寫好介面的資料結構 typescript
定義,不需要做太多的額外變動,直接就能校驗後端介面響應的資料結構是否符合 typescript
介面定義
理想方案探索
首先,我們瞭解到,後端響應的資料介面一般為 JSON
,那麼,拋開 Typescript
,如果要校驗一個 JSON 的資料結構,我們可以怎麼做到呢?
答案是 JSON schema
。
JSON schema
JSON schema 是一種描述 JSON 資料格式的模式。
例如 typescript 資料結構:
type TypeSex = 1 | 2 | 3 interface UserInfo { name: string age?: number sex: TypeSex } 複製程式碼
等價於以下的 json schema :
{ "$id": "api", "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { "UserInfo": { "properties": { "age": { "type": "number" }, "name": { "type": "string" }, "sex": { "enum": [ 1, 2, 3 ], "type": "number" } }, "required": [ "name", "sex" ], "type": "object" } } } 複製程式碼
根據已有 json-schema
校驗庫,即可校驗資料物件
someValidateFunc(jsonSchema, apiResData) 複製程式碼
這裡大家可能就又會困惑:這 json-schema
寫起來也太費勁了?還不一樣要學習成本,那和 io-ts
有什麼區別。
但是,既然我們同時知道 typescript
和 json-schema
的語法定義規則,那麼就兩者必然能夠互相轉換。
也就是說,即便我們不懂 json-schema
的規範與語法,我們也能通過 typescript
轉化生成 json-schema
。
那麼,在以上的前提下,我們的思路就是: 既然 typescript
本身不支援執行時資料校驗,那麼我們可以將 typescript
先轉化成 json schema
, 然後用 json-schema
校驗資料結構
typescript -> json-schema
要將 typescript
宣告轉換成 json-schema
,這裡推薦使用 typescript-json-schema 。
我們可以直接使用它的命令列工具,這裡就不仔細展開說明了,感興趣的可以看下官方文件:
Usage: typescript-json-schema <path-to-typescript-files-or-tsconfig> <type> Options: --refsCreate shared ref definitions.[boolean] [default: true] --aliasRefsCreate shared ref definitions for the type aliases.[boolean] [default: false] --topRefCreate a top-level ref definition.[boolean] [default: false] --titlesCreates titles in the output schema.[boolean] [default: false] --defaultPropsCreate default properties definitions.[boolean] [default: false] --noExtraPropsDisable additional properties in objects by default.[boolean] [default: false] --propOrderCreate property order definitions.[boolean] [default: false] --requiredCreate required array for non-optional properties.[boolean] [default: false] --strictNullChecksMake values non-nullable by default.[boolean] [default: false] --useTypeOfKeywordUse `typeOf` keyword (https://goo.gl/DC6sni) for functions.[boolean] [default: false] --out, -oThe output file, defaults to using stdout --validationKeywordsProvide additional validation keywords to include[array][default: []] --includeFurther limit tsconfig to include only matching files[array][default: []] --ignoreErrorsGenerate even if the program has errors.[boolean] [default: false] --excludePrivateExclude private members from the schema[boolean] [default: false] --uniqueNamesUse unique names for type symbols.[boolean] [default: false] --rejectDateTypeRejects Date fields in type definitions.[boolean] [default: false] --idSet schema id.[string] [default: ""] 複製程式碼
github 上也有所有型別轉換的 測試用例 ,可以對比看看 typescript
和 轉換出的 json-schema
結果
json-schema 校驗庫
利用 typescript-json-schema
工具生成了 json-schema
檔案後,我們需要根據該檔案進行資料校驗。
json-schema
資料校驗的庫很多, ajv , jsonschema 之類的,這裡用 jsonschema
作為示例。
import { Validator } from 'jsonschema' import schema from './json-schema.json' const v = new Validator() // 繫結schema,這裡的 `api` 對應 json-schema.json 的 `$id` v.addSchema(schema, '/api') const validateResponseData = (data: any) => { // 校驗響應資料 const result = v.validate(data, { // SomeInterface 為 ts 定義的介面 $ref: `api#/definitions/SomeInterface` }) // 校驗失敗,資料不符合預期 if (!result.valid) { console.log('data is ', data) console.log('errors', result.errors.map((item) => item.toString())) } return data } 複製程式碼
當我們校驗以下資料時:
// 宣告檔案 interface UserInfo { name: string sex: string age: number phone?: number } // 校驗結果 validateResponseData({ name: 'xxxx', age: 'age應該是數字' }) // 得出結果 // data is{ name: 'xxxx', age: 'age應該是數字' } // errors [ 'instance.age is not of a type(s) number', //'instance requires property "sex"' ] 複製程式碼
配合上前端上報系統,當線上系統介面返回了非預料的資料,導致出 bug,就可以實時知道到底錯在哪了,並且及時甩鍋給後端啦。
commit 時自動更新 json-schema
前面提到,我們需要執行 typescript-json-schema <path-to-typescript-files-or-tsconfig> <type>
命令來宣告 typescript 對應的 json-schema
檔案。
那麼,這裡就有個問題,介面數量有可能增加,介面資料也有可能變動,那也就代表著,我們每次變更介面資料結構,都要重新跑一下 typescript-json-schema
,時刻保持 json-schema
和 typescript一一對應。
這我們就可以用 husky 的 precommit
, 加上 lint-staged 來實現每次更新提交程式碼時,自動執行 typescript-json-schema
,無需時刻關注 typescript 介面定義的變更。
總結
綜上,我們實現了
-
typescript
宣告檔案 轉換生成json-schema
檔案 - 程式碼介面層攔截校驗資料,如校驗失敗,通過前端上報系統(如:sentry)進行相關上報
- 通過
husky
+lint-staged
每次提交程式碼自動執行 步驟1,保持git 倉庫的程式碼typescript
宣告 和json-schema
時刻保持一致。
那麼,當 Bug 出現的時候,你甚至可以在測試都還沒發現這個 Bug之前,就已經把鍋甩了出去。
只要你跑得足夠快,Bug 就會追不上你。
