科學甩鍋技術: Typescript 執行時資料校驗

七月流螢發表於2019-04-19

本文首發於 github 部落格
如文章對你有幫助,你的 star 是對我最大的支援

背景

大家出來寫 Bug 程式碼的,難免會出 Bug。

文章背景就發生在一個 Bug 身上,

有一天,測試慌張中帶著點興奮衝過來: 測試:"xxx系統前端線上出 Bug 了,點進xx頁面一片空白啊"。 我:"納尼?我寫的Bug怎麼會出現程式碼呢?"。

image

雖然大腦一片空白,但是鍋還是要背的。 進入頁面一看,哦豁,完蛋,cannot read the property 'xx' of undefined。確實是前端常見的報錯呀。

背鍋王,我當定了?

NO!

我眉頭一皺,發現事情並不是那麼簡單,經過一番猛如虎的操作之後,最終定位到問題是:後端介面響應的 JSON 資料中,一個巢狀比較深的欄位沒有返回,即前端只讀到了 undefined

我們按章程辦事,後端提供的介面文件指定了資料結構,那你沒有返回正確資料結構,這就是你後端的鍋,雖然嚴謹點前端也能捕獲到錯誤進行處理,但歸根到底,是你後端資料介面處理有問題,這鍋,我不背。

甩鍋又是一門扯皮的事情,殺敵一千自傷八百,鍋已經扣下來了,想甩出去就難咯,。

唉,要是在介面出錯的時候,能立刻知道介面資料出問題,先發制人,馬上把鍋甩出去那就好咯。

這就是本文即將要講述的 "Typescript 執行時資料校驗"。

為什麼要執行時校驗資料?

眾所周知,TypescriptJavaScript 超集,可以給我們的專案程式碼提供靜態型別檢查,避免因為各種原因而未及時發現的程式碼錯誤,在編譯時就能發現隱藏的程式碼隱患,從而提高程式碼質量。

但是,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 有什麼區別。

但是,既然我們同時知道 typescriptjson-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:
  --refs                Create shared ref definitions.                               [boolean] [default: true]
  --aliasRefs           Create shared ref definitions for the type aliases.          [boolean] [default: false]
  --topRef              Create a top-level ref definition.                           [boolean] [default: false]
  --titles              Creates titles in the output schema.                         [boolean] [default: false]
  --defaultProps        Create default properties definitions.                       [boolean] [default: false]
  --noExtraProps        Disable additional properties in objects by default.         [boolean] [default: false]
  --propOrder           Create property order definitions.                           [boolean] [default: false]
  --required            Create required array for non-optional properties.           [boolean] [default: false]
  --strictNullChecks    Make values non-nullable by default.                         [boolean] [default: false]
  --useTypeOfKeyword    Use `typeOf` keyword (https://goo.gl/DC6sni) for functions.  [boolean] [default: false]
  --out, -o             The output file, defaults to using stdout
  --validationKeywords  Provide additional validation keywords to include            [array]   [default: []]
  --include             Further limit tsconfig to include only matching files        [array]   [default: []]
  --ignoreErrors        Generate even if the program has errors.                     [boolean] [default: false]
  --excludePrivate      Exclude private members from the schema                      [boolean] [default: false]
  --uniqueNames         Use unique names for type symbols.                           [boolean] [default: false]
  --rejectDateType      Rejects Date fields in type definitions.                     [boolean] [default: false]
  --id                  Set schema id.                                               [string] [default: ""]
複製程式碼

github 上也有所有型別轉換的 測試用例,可以對比看看 typescript 和 轉換出的 json-schema 結果

json-schema 校驗庫

利用 typescript-json-schema 工具生成了 json-schema 檔案後,我們需要根據該檔案進行資料校驗。

json-schema 資料校驗的庫很多,ajvjsonschema 之類的,這裡用 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"' ]
複製程式碼

完全例子請看 github

配合上前端上報系統,當線上系統介面返回了非預料的資料,導致出 bug,就可以實時知道到底錯在哪了,並且及時甩鍋給後端啦。

commit 時自動更新 json-schema

前面提到,我們需要執行 typescript-json-schema <path-to-typescript-files-or-tsconfig> <type> 命令來宣告 typescript 對應的 json-schema 檔案。

那麼,這裡就有個問題,介面數量有可能增加,介面資料也有可能變動,那也就代表著,我們每次變更介面資料結構,都要重新跑一下 typescript-json-schema ,時刻保持 json-schema 和 typescript一一對應。

這我們就可以用 huskyprecommit , 加上 lint-staged 來實現每次更新提交程式碼時,自動執行 typescript-json-schema,無需時刻關注 typescript 介面定義的變更。

完全例子請看 github

總結

綜上,我們實現了

  1. typescript 宣告檔案 轉換生成 json-schema 檔案
  2. 程式碼介面層攔截校驗資料,如校驗失敗,通過前端上報系統(如:sentry)進行相關上報
  3. 通過 husky + lint-staged 每次提交程式碼自動執行 步驟1,保持git 倉庫的程式碼 typescript 宣告 和 json-schema 時刻保持一致。

那麼,當 Bug 出現的時候,你甚至可以在測試都還沒發現這個 Bug之前,就已經把鍋甩了出去。

只要你跑得足夠快,Bug 就會追不上你。

image

相關文章