使用TS+Sequelize實現更簡潔的CRUD

  • 2019 年 12 月 9 日
  • 筆記

使用TS+Sequelize實現更簡潔的CRUD

如果是經常使用Node來做服務端開發的童鞋,肯定不可避免的會操作資料庫,做一些增刪改查(CRUDCreate Read Update Delete)的操作,如果是一些簡單的操作,類似定時腳本什麼的,可能就直接生寫SQL語句來實現功能了,而如果是在一些大型項目中,數十張、上百張的表,之間還會有一些(一對多,多對多)的映射關係,那麼引入一個ORMObject Relational Mapping)工具來幫助我們與資料庫打交道就可以減輕一部分不必要的工作量,Sequelize就是其中比較受歡迎的一個。

CRUD原始版 手動拼接SQL

先來舉例說明一下直接拼接SQL語句這樣比較「底層」的操作方式:

CREATE TABLE animal (    id INT AUTO_INCREMENT,    name VARCHAR(14) NOT NULL,    weight INT NOT NULL,    PRIMARY KEY (`id`)  );

創建這樣的一張表,三個欄位,自增ID、name以及weight。 如果使用mysql這個包來直接操作資料庫大概是這樣的:

const connection = mysql.createConnection({})  const tableName = 'animal'    connection.connect()    // 我們假設已經支援了Promise    // 查詢  const [results] = await connection.query(`    SELECT      id,      name,      weight    FROM ${tableName}  `)    // 新增  const name = 'Niko'  const weight = 70  await connection.query(`    INSERT INTO ${tableName} (name, weight)    VALUES ('${name}', ${weight})  `)  // 或者通過傳入一個Object的方式也可以做到  await connection.query(`INSERT INTO ${tableName} SET ?`, {    name,    weight  })    connection.end()

看起來也還算是比較清晰,但是這樣帶來的問題就是,開發人員需要對錶結構足夠的了解。 如果表中有十幾個欄位,對於開發人員來說這會是很大的記憶成本,你需要知道某個欄位是什麼類型,拼接SQL時還要注意插入時的順序及類型,WHERE條件對應的查詢參數類型,如果修改某個欄位的類型,還要去處理對應的傳參。 這樣的項目尤其是在進行交接的時候更是一件恐怖的事情,新人又需要從頭學習這些表結構。 以及還有一個問題,如果有哪天需要更換資料庫了,放棄了MySQL,那麼所有的SQL語句都要進行修改(因為各個資料庫的方言可能有區別)

CRUD進階版 Sequelize的使用

關於記憶這件事情,機器肯定會比人腦更靠譜兒,所以就有了ORM,這裡就用到了在Node中比較流行的Sequelize

ORM是幹嘛的

首先可能需要解釋下ORM是做什麼使的,可以簡單地理解為,使用面向對象的方式,通過操作對象來實現與資料庫之前的交流,完成CRUD的動作。 開發者並不需要關心資料庫的類型,也不需要關心實際的表結構,而是根據當前程式語言中對象的結構與資料庫中表、欄位進行映射。

就好比針對上邊的animal表進行操作,不再需要在程式碼中去拼接SQL語句,而是直接調用類似Animal.createAnimal.find就可以完成對應的動作。

Sequelize的使用方式

首先我們要先下載Sequelize的依賴:

npm i sequelize  npm i mysql2    # 以及對應的我們需要的資料庫驅動

然後在程式中創建一個Sequelize的實例:

const Sequelize = require('Sequelize')  const sequelize = new Sequelize('mysql://root:[email protected]:3306/ts_test')  //                             dialect://username:[email protected]:port/db_name    // 針對上述的表,我們需要先建立對應的模型:  const Animal = sequelize.define('animal', {    id: { type: Sequelize.INTEGER, autoIncrement: true },    name: { type: Sequelize.STRING, allowNull: false },    weight: { type: Sequelize.INTEGER, allowNull: false },  }, {    // 禁止sequelize修改表名,默認會在animal後邊添加一個字母`s`表示負數    freezeTableName: true,    // 禁止自動添加時間戳相關屬性    timestamps: false,  })    // 然後就可以開始使用咯  // 還是假設方法都已經支援了Promise    // 查詢  const results = await Animal.findAll({    raw: true,  })    // 新增  const name = 'Niko'  const weight = 70    await Animal.create({    name,    weight,  })

sequelize定義模型相關的各種配置:docs

拋開模型定義的部分,使用Sequelize無疑減輕了很多使用上的成本,因為模型的定義一般不太會去改變,一次定義多次使用,而使用手動拼接SQL的方式可能就需要將一段SQL改來改去的。

而且可以幫助進行欄位類型的轉換,避免出現類型強制轉換出錯NaN或者數字被截斷等一些粗心導致的錯誤。

通過定義模型的方式來告訴程式,有哪些模型,模型的欄位都是什麼,讓程式來幫助我們記憶,而非讓我們自己去記憶。 我們只需要拿到對應的模型進行操作就好了。

這還不夠

But,雖說切換為ORM工具已經幫助我們減少了很大一部分的記憶成本,但是依然還不夠,我們仍然需要知道模型中都有哪些欄位,才能在業務邏輯中進行使用,如果新人接手項目,仍然需要去翻看模型的定義才能知道有什麼欄位,所以就有了今天要說的真正的主角兒:sequelize-typescript

CRUD終極版 裝飾器實現模型定義

Sequelize-typescript是基於Sequelize針對TypeScript所實現的一個增強版本,拋棄了之前繁瑣的模型定義,使用裝飾器直接達到我們想到的目的。

Sequelize-typescript的使用方式

首先因為是用到了TS,所以環境依賴上要安裝的東西會多一些:

# 這裡採用ts-node來完成舉例  npm i ts-node typescript  npm i sequelize reflect-metadata sequelize-typescript

其次,還需要修改TS項目對應的tsconfig.json文件,用來讓TS支援裝飾器的使用:

{    "compilerOptions": {  +   "experimentalDecorators": true,  +   "emitDecoratorMetadata": true    }  }

然後就可以開始編寫腳本來進行開發了,與Sequelize不同之處基本在於模型定義的地方:

// /modles/animal.ts  import { Table, Column, Model } from 'sequelize-typescript'    @Table({    tableName: 'animal'  })  export class Animal extends Model<Animal> {    @Column({      primaryKey: true,      autoIncrement: true,    })    id: number      @Column    name: string      @Column    weight: number  }    // 創建與資料庫的鏈接、初始化模型  // app.ts  import path from 'path'  import { Sequelize } from 'sequelize-typescript'  import Animal from './models/animal'    const sequelize = new Sequelize('mysql://root:[email protected]:3306/ts_test')  sequelize.addModels([path.resolve(__dirname, `./models/`)])    // 查詢  const results = await Animal.findAll({    raw: true,  })    // 新增  const name = 'Niko'  const weight = 70    await Animal.create({    name,    weight,  })

與普通的Sequelize不同的有這麼幾點:

  1. 模型的定義採用裝飾器的方式來定義
  2. 實例化Sequelize對象時需要指定對應的model路徑
  3. 模型相關的一系列方法都是支援Promise

如果在使用過程中遇到提示XXX used before model init,可以嘗試在實例化前邊添加一個await操作符,等到與資料庫的連接建立完成以後再進行操作

但是好像看起來這樣寫的程式碼相較於Sequelize多了不少呢,而且至少需要兩個文件來配合,那麼這麼做的意義是什麼的? 答案就是OOP中一個重要的理念:繼承

使用Sequelize-typescript實現模型的繼承

因為TypeScript的核心開發人員中包括C#的架構師,所以TypeScript中可以看到很多類似C#的痕迹,在模型的這方面,我們可以嘗試利用繼承減少一些冗餘的程式碼。

比如說我們基於animal表又有了兩張新表,dogbird,這兩者之間肯定是有區別的,所以就有了這樣的定義:

CREATE TABLE dog (    id INT AUTO_INCREMENT,    name VARCHAR(14) NOT NULL,    weight INT NOT NULL,    leg INT NOT NULL,    PRIMARY KEY (`id`)  );    CREATE TABLE bird (    id INT AUTO_INCREMENT,    name VARCHAR(14) NOT NULL,    weight INT NOT NULL,    wing INT NOT NULL,    claw INT NOT NULL,    PRIMARY KEY (`id`)  );

關於dog我們有一個腿leg數量的描述,關於bird我們有了翅膀wing和爪子claw數量的描述。 特意讓兩者的特殊欄位數量不同,省的有杠精說可以通過添加type欄位區分兩種不同的動物 :p

如果要用Sequelize的方式,我們就要將一些相同的欄位定義define三遍才能實現,或者說寫得靈活一些,將define時使用的Object抽出來使用Object.assign的方式來實現類似繼承的效果。

但是在Sequelize-typescript就可以直接使用繼承來實現我們想要的效果:

// 首先還是我們的Animal模型定義  // /models/animal.ts  import { Table, Column, Model } from 'sequelize-typescript'    @Table({    tableName: 'animal'  })  export default class Animal extends Model<Animal> {    @Column({      primaryKey: true,      autoIncrement: true,    })    id: number      @Column    name: string      @Column    weight: number  }    // 接下來就是繼承的使用了  // /models/dog.ts  import { Table, Column, Model } from 'sequelize-typescript'  import Animal from './animal'    @Table({    tableName: 'dog'  })  export default class Dog extends Animal {    @Column    leg: number  }    // /models/bird.ts  import { Table, Column, Model } from 'sequelize-typescript'  import Animal from './animal'    @Table({    tableName: 'bird'  })  export default class Bird extends Animal {    @Column    wing: number      @Column    claw: number  }

有一點需要注意的:每一個模型需要單獨佔用一個文件,並且採用export default的方式來導出 也就是說目前我們的文件結構是這樣的:

├── models  │   ├── animal.ts  │   ├── bird.ts  │   └── dog.ts  └── app.ts

得益於TypeScript的靜態類型,我們能夠很方便地得知這些模型之間的關係,以及都存在哪些欄位。 在結合著VS Code開發時可以得到很多動態提示,類似findAllcreate之類的操作都會有提示:

Animal.create<Animal>({    abc: 1,  // ^ abc不是Animal已知的屬性  })

通過繼承來複用一些行為

上述的例子也只是說明了如何復用模型,但是如果是一些封裝好的方法呢? 類似的獲取表中所有的數據,可能一般情況下獲取JSON數據就夠了,也就是findAll({raw: true}) 所以我們可以針對類似這樣的操作進行一次簡單的封裝,不需要開發者手動去調用findAll

// /models/animal.ts  import { Table, Column, Model } from 'sequelize-typescript'    @Table({    tableName: 'animal'  })  export default class Animal extends Model<Animal> {    @Column({      primaryKey: true,      autoIncrement: true,    })    id: number      @Column    name: string      @Column    weight: number      static async getList () {      return this.findAll({raw: true})    }  }    // /app.ts  // 這樣就可以直接調用`getList`來實現類似的效果了  await Animal.getList() // 返回一個JSON數組

同理,因為上邊我們的兩個DogBird繼承自Animal,所以程式碼不用改動就可以直接使用getList了。

const results = await Dog.getList()    results[0].leg // TS提示錯誤

但是如果你像上邊那樣使用的話,TS會提示錯誤的:[ts] 類型「Animal」上不存在屬性「leg」。。 哈哈,這又是為什麼呢?細心的同學可能會發現,getList的返回值是一個Animal[]類型的,所以上邊並沒有leg屬性,Bird的兩個屬性也是如此。

所以我們需要教TS認識我們的數據結構,這樣就需要針對Animal的定義進行修改了,用到了 范型。 我們通過在函數上邊添加一個范型的定義,並且添加限制保證傳入的范型類型一定是繼承自Animal的,在返回值轉換其類型為T,就可以實現功能了。

class Animal {    static async getList<T extends Animal>() {      const results = await this.findAll({        raw: true,      })      return results as T[]    }  }    const dogList = await Dog.getList<Dog>()  // 或者不作任何修改,直接在外邊手動as也可以實現類似的效果  // 但是這樣還是不太靈活,因為你要預先知道返回值的具體類型結構,將預期類型傳遞給函數,由函數去組裝返回的類型還是比較推薦的  const dogList = await Dog.getList() as Dog[]    console.log(dogList[0].leg) // success

這時再使用leg屬性就不會出錯了,如果要使用范型,一定要記住添加extends Animal的約束,不然TS會認為這裡可以傳入任意類型,那麼很難保證可以正確的兼容Animal,但是繼承自Animal的一定是可以兼容的。

當然如果連這裡的范型或者as也不想寫的話,還可以在子類中針對父類方法進行重寫。 並不需要完整的實現邏輯,只需要獲取返回值,然後修改為我們想要的類型即可:

class Dog extends Animal {    static async getList() {      // 調用父類方法,然後將返回值指定為某個類型      const results = await super.getList()      return results as Dog[]    }  }    // 這樣就可以直接使用方法,而不用擔心返回值類型了  const dogList = await Dog.getList()    console.log(dogList[0].leg) // success

小結

本文只是一個引子,一些簡單的示例,只為體現出三者(SQLSequelizeSequelize-typescript)之間的區別,Sequelize中有更多高階的操作,類似映射關係之類的,這些在Sequelize-typescript中都有對應的體現,而且因為使用了裝飾器,實現這些功能所需的程式碼會減少很多,看起來也會更清晰。

當然了,ORM這種東西也不是說要一股腦的上,如果是初學者,從個人層面上我不建議使用,因為這樣會少了一個接觸SQL的機會 如果項目結構也不是很複雜,或者可預期的未來也不會太複雜,那麼使用ORM也沒有什麼意義,還讓項目結構變得複雜起來 以及,一定程度上來說,通用就意味著妥協,為了保證多個資料庫之間的效果都一致,可能會拋棄一些資料庫獨有的特性,如果明確的需要使用這些特性,那麼ORM也不會太適合 選擇最合適的,要知道使用某樣東西的意義

最終的一個示例放在了GitHub上:notebook | typescript/sequelize

參考資料: