精讀《Prisma 的使用》

黃子毅發表於2021-10-18

ORM(Object relational mappers) 的含義是,將資料模型與 Object 建立強力的對映關係,這樣我們對資料的增刪改查可以轉換為操作 Object(物件)。

Prisma 是一個現代 Nodejs ORM 庫,根據 Prisma 官方文件 可以瞭解這個庫是如何設計與使用的。

概述

Prisma 提供了大量工具,包括 Prisma Schema、Prisma Client、Prisma Migrate、Prisma CLI、Prisma Studio 等,其中最核心的兩個是 Prisma Schema 與 Prisma Client,分別是描述應用資料模型與 Node 操作 API。

與一般 ORM 完全由 Class 描述資料模型不同,Primsa 採用了一個全新語法 Primsa Schema 描述資料模型,再執行 prisma generate 產生一個配置檔案儲存在 node_modules/.prisma/client 中,Node 程式碼裡就可以使用 Prisma Client 對資料增刪改查了。

Prisma Schema

Primsa Schema 是在最大程度貼近資料庫結構描述的基礎上,對關聯關係進行了進一步抽象,並且背後維護了與資料模型的對應關係,下圖很好的說明了這一點:

<img width=400 src="https://z3.ax1x.com/2021/10/17/5YwZoF.png">

可以看到,幾乎與資料庫的定義一模一樣,唯一多出來的 postsauthor 其實是彌補了資料庫表關聯外來鍵中不直觀的部分,將這些外來鍵轉化為實體物件,讓操作時感受不到外來鍵或者多表的存在,在具體操作時再轉化為 join 操作。下面是對應的 Prisma Schema:

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

generator client {
  provider = "prisma-client-js"
}

model Post {
  id        Int     @id @default(autoincrement())
  title     String
  content   String? @map("post_content")
  published Boolean @default(false)
  author    User?   @relation(fields: [authorId], references: [id])
  authorId  Int?
}

model User {
  id    Int     @id @default(autoincrement())
  email String  @unique
  name  String?
  posts Post[]
}

datasource db 申明瞭連結資料庫資訊;generator client 申明瞭使用 Prisma Client 進行客戶端操作,也就是說 Prisma Client 其實是可以替換實現的;model 是最核心的模型定義。

在模型定義中,可以通過 @map 修改欄位名對映、@@map 修改表名對映,預設情況下,欄位名與 key 名相同:

model Comment {
  title @map("comment_title")

  @@map("comments")
}

欄位由下面四種描述組成:

  • 欄位名。
  • 欄位型別。
  • 可選的型別修飾。
  • 可選的屬性描述。
model Tag {
  name String? @id
}

在這個描述裡,包含欄位名 name、欄位型別 String、型別修飾 ?、屬性描述 @id

欄位型別

欄位型別可以是 model,比如關聯型別欄位場景:

model Post {
  id       Int       @id @default(autoincrement())
  // Other fields
  comments Comment[] // A post can have many comments
}

model Comment {
  id     Int
  // Other fields
  Post   Post? @relation(fields: [postId], references: [id]) // A comment can have one post
  postId Int?
}

關聯場景有 1v1, nv1, 1vn, nvn 四種情況,欄位型別可以為定義的 model 名稱,並使用屬性描述 @relation 定義關聯關係,比如上面的例子,描述了 CommenctPost 存在 nv1 關係,並且 Comment.postIdPost.id 關聯。

欄位型別還可以是底層資料型別,通過 @db. 描述,比如:

model Post {
  id @db.TinyInt(1)
}

對於 Prisma 不支援的型別,還可以使用 Unsupported 修飾:

model Post {
  someField Unsupported("polygon")?
}

這種型別的欄位無法通過 ORM API 查詢,但可以通過 queryRaw 方式查詢。queryRaw 是一種 ORM 對原始 SQL 模式的支援,在 Prisma Client 會提到。

型別修飾

型別修飾有 ? [] 兩種語法,比如:

model User {
  name  String?
  posts Post[]
}

分別表示可選與陣列。

屬性描述

屬性描述有如下幾種語法:

model User {
  id        Int     @id @default(autoincrement())
  isAdmin   Boolean @default(false)
  email     String  @unique

  @@unique([firstName, lastName])
}

@id 對應資料庫的 PRIMARY KEY。

@default 設定欄位預設值,可以聯合函式使用,比如 @default(autoincrement()),可用函式包括 autoincrement()dbgenerated()cuid()uuid()now(),還可以通過 dbgenerated 直接呼叫資料庫底層的函式,比如 dbgenerated("gen_random_uuid()")

@unique 設定欄位值唯一。

@relation 設定關聯,上面已經提到過了。

@map 設定對映,上面也提到過了。

@updatedAt 修飾欄位用來儲存上次更新時間,一般是資料庫自帶的能力。

@ignore 對 Prisma 標記無效的欄位。

所有屬性描述都可以組合使用,並且還存在需對 model 級別的描述,一般用兩個 @ 描述,包括 @@id@@unique@@index@@map@@ignore

ManyToMany

Prisma 在多對多關聯關係的描述上也下了功夫,支援隱式關聯描述:

model Post {
  id         Int        @id @default(autoincrement())
  categories Category[]
}

model Category {
  id    Int    @id @default(autoincrement())
  posts Post[]
}

看上去很自然,但其實背後隱藏了不少實現。資料庫多對多關係一般通過第三張表實現,第三張表會儲存兩張表之間外來鍵對應關係,所以如果要顯式定義其實是這樣的:

model Post {
  id         Int                 @id @default(autoincrement())
  categories CategoriesOnPosts[]
}

model Category {
  id    Int                 @id @default(autoincrement())
  posts CategoriesOnPosts[]
}

model CategoriesOnPosts {
  post       Post     @relation(fields: [postId], references: [id])
  postId     Int // relation scalar field (used in the `@relation` attribute above)
  category   Category @relation(fields: [categoryId], references: [id])
  categoryId Int // relation scalar field (used in the `@relation` attribute above)
  assignedAt DateTime @default(now())
  assignedBy String

  @@id([postId, categoryId])
}

背後生成如下 SQL:

CREATE TABLE "Category" (
    id SERIAL PRIMARY KEY
);
CREATE TABLE "Post" (
    id SERIAL PRIMARY KEY
);
-- Relation table + indexes -------------------------------------------------------
CREATE TABLE "CategoryToPost" (
    "categoryId" integer NOT NULL,
    "postId" integer NOT NULL,
    "assignedBy" text NOT NULL
    "assignedAt" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY ("categoryId")  REFERENCES "Category"(id),
    FOREIGN KEY ("postId") REFERENCES "Post"(id)
);
CREATE UNIQUE INDEX "CategoryToPost_category_post_unique" ON "CategoryToPost"("categoryId" int4_ops,"postId" int4_ops);

Prisma Client

描述好 Prisma Model 後,執行 prisma generate,再利用 npm install @prisma/client 安裝好 Node 包後,就可以在程式碼裡操作 ORM 了:

import { PrismaClient } from '@prisma/client'

const prisma = new PrismaClient()

CRUD

使用 create 建立一條記錄:

const user = await prisma.user.create({
  data: {
    email: 'elsa@prisma.io',
    name: 'Elsa Prisma',
  },
})

使用 createMany 建立多條記錄:

const createMany = await prisma.user.createMany({
  data: [
    { name: 'Bob', email: 'bob@prisma.io' },
    { name: 'Bobo', email: 'bob@prisma.io' }, // Duplicate unique key!
    { name: 'Yewande', email: 'yewande@prisma.io' },
    { name: 'Angelique', email: 'angelique@prisma.io' },
  ],
  skipDuplicates: true, // Skip 'Bobo'
})

使用 findUnique 查詢單條記錄:

const user = await prisma.user.findUnique({
  where: {
    email: 'elsa@prisma.io',
  },
})

對於聯合索引的情況:

model TimePeriod {
  year    Int
  quarter Int
  total   Decimal

  @@id([year, quarter])
}

需要再巢狀一層由 _ 拼接的 key:

const timePeriod = await prisma.timePeriod.findUnique({
  where: {
    year_quarter: {
      quarter: 4,
      year: 2020,
    },
  },
})

使用 findMany 查詢多條記錄:

const users = await prisma.user.findMany()

可以使用 SQL 中各種條件語句,語法如下:

const users = await prisma.user.findMany({
  where: {
    role: 'ADMIN',
  },
  include: {
    posts: true,
  },
})

使用 update 更新記錄:

const updateUser = await prisma.user.update({
  where: {
    email: 'viola@prisma.io',
  },
  data: {
    name: 'Viola the Magnificent',
  },
})

使用 updateMany 更新多條記錄:

const updateUsers = await prisma.user.updateMany({
  where: {
    email: {
      contains: 'prisma.io',
    },
  },
  data: {
    role: 'ADMIN',
  },
})

使用 delete 刪除記錄:

const deleteUser = await prisma.user.delete({
  where: {
    email: 'bert@prisma.io',
  },
})

使用 deleteMany 刪除多條記錄:

const deleteUsers = await prisma.user.deleteMany({
  where: {
    email: {
      contains: 'prisma.io',
    },
  },
})

使用 include 表示關聯查詢是否生效,比如:

const getUser = await prisma.user.findUnique({
  where: {
    id: 19,
  },
  include: {
    posts: true,
  },
})

這樣就會在查詢 user 表時,順帶查詢所有關聯的 post 表。關聯查詢也支援巢狀:

const user = await prisma.user.findMany({
  include: {
    posts: {
      include: {
        categories: true,
      },
    },
  },
})

篩選條件支援 equalsnotinnotInltltegtgtecontainssearchmodestartsWithendsWithANDORNOT,一般用法如下:

const result = await prisma.user.findMany({
  where: {
    name: {
      equals: 'Eleanor',
    },
  },
})

這個語句代替 sql 的 where name="Eleanor",即通過物件巢狀的方式表達語義。

Prisma 也可以直接寫原生 SQL:

const email = 'emelie@prisma.io'
const result = await prisma.$queryRaw(
  Prisma.sql`SELECT * FROM User WHERE email = ${email}`
)

中介軟體

Prisma 支援中介軟體的方式在執行過程中進行擴充,看下面的例子:

const prisma = new PrismaClient()

// Middleware 1
prisma.$use(async (params, next) => {
  console.log(params.args.data.title)
  console.log('1')
  const result = await next(params)
  console.log('6')
  return result
})

// Middleware 2
prisma.$use(async (params, next) => {
  console.log('2')
  const result = await next(params)
  console.log('5')
  return result
})

// Middleware 3
prisma.$use(async (params, next) => {
  console.log('3')
  const result = await next(params)
  console.log('4')
  return result
})

const create = await prisma.post.create({
  data: {
    title: 'Welcome to Prisma Day 2020',
  },
})

const create2 = await prisma.post.create({
  data: {
    title: 'How to Prisma!',
  },
})

輸出如下:

Welcome to Prisma Day 2020 
1 
2 
3 
4 
5 
6 
How to Prisma! 
1 
2 
3 
4 
5 
6

可以看到,中介軟體執行順序是洋蔥模型,並且每個操作都會觸發。我們可以利用中介軟體擴充業務邏輯或者進行操作時間的打點記錄。

精讀

ORM 的兩種設計模式

ORM 有 Active Record 與 Data Mapper 兩種設計模式,其中 Active Record 使物件背後完全對應 sql 查詢,現在已經不怎麼流行了,而 Data Mapper 模式中的物件並不知道資料庫的存在,即中間多了一層對映,甚至背後不需要對應資料庫,所以可以做一些很輕量的除錯功能。

Prisma 採用了 Data Mapper 模式。

ORM 容易引發效能問題

當資料量大,或者效能、資源敏感的情況下,我們需要對 SQL 進行優化,甚至我們需要對特定的 Mysql 的特定版本的某些核心錯誤,對 SQL 進行某些看似無意義的申明調優(比如在 where 之前再進行相同條件的 IN 範圍限定),有的時候能取得驚人的效能提升。

而 ORM 是建立在一個較為理想化理論基礎上的,即資料模型可以很好的轉化為物件操作,然而物件操作由於遮蔽了細節,我們無法對 SQL 進行鍼對性調優。

另外,得益於物件操作的便利性,我們很容易通過 obj.obj. 的方式訪問某些屬性,但這背後生成的卻是一系列未經優化(或者部分自動優化)的複雜 join sql,我們在寫這些 sql 時會提前考慮效能因素,但通過物件呼叫時卻因為成本低,或覺得 ORM 有 magic 優化等想法,寫出很多實際上不合理的 sql。

Prisma Schema 的好處

其實從語法上,Prisma Schema 與 Typeorm 基於 Class + 裝飾器的擴充幾乎可以等價轉換,但 Prisma Schema 在實際使用中有一個很不錯的優勢,即減少樣板程式碼以及穩定資料庫模型。

減少樣板程式碼比較好理解,因為 Prisma Schema 並不會出現在程式碼中,而穩定模型是指,只要不執行 prisma generate,資料模型就不會變化,而且 Prisma Schema 也獨立於 Node 存在,甚至可以不放在專案原始碼中,相比之下,修改起來會更加慎重,而完全用 Node 定義的模型因為本身是程式碼的一部分,可能會突然被修改,而且也沒有執行資料庫結構同步的操作。

如果專案採用 Prisma,則模型變更後,可以執行 prisma db pull 更新資料庫結構,再執行 prisma generate 更新客戶端 API,這個流程比較清晰。

總結

Prisma Schema 是 Prisma 的一大特色,因為這部分描述獨立於程式碼,帶來了如下幾個好處:

  1. 定義比 Node Class 更簡潔。
  2. 不生成冗餘的程式碼結構。
  3. Prisma Client 更加輕量,且查詢返回的都是 Pure Object。

至於 Prisma Client 的 API 設計其實並沒有特別突出之處,無論與 sequelize 還是 typeorm 的 API 設計相比,都沒有太大的優化,只是風格不同。

不過對於記錄的建立,我更喜歡 Prisma 的 API:

// typeorm - save API
const userRepository = getManager().getRepository(User)
const newUser = new User()
newUser.name = 'Alice'
userRepository.save(newUser)

// typeorm - insert API
const userRepository = getManager().getRepository(User)
userRepository.insert({
  name: 'Alice',
})

// sequelize
const user = User.build({
  name: 'Alice',
})
await user.save()

// Mongoose
const user = await User.create({
  name: 'Alice',
  email: 'alice@prisma.io',
})

// prisma
const newUser = await prisma.user.create({
  data: {
    name: 'Alice',
  },
})

首先存在 prisma 這個頂層變數,使用起來會非常方便,另外從 API 擴充上來說,雖然 Mongoose 設計得更簡潔,但新增一些條件時擴充性會不足,導致結構不太穩定,不利於統一記憶。

Prisma Client 的 API 統一採用下面這種結構:

await prisma.modelName.operateName({
  // 資料,比如 create、update 時會用到
  data: /** ... */,
  // 條件,大部分情況都可以用到
  where: /** ... */,
  // 其它特殊引數,或者 operater 特有的引數
})

所以總的來說,Prisma 雖然沒有對 ORM 做出革命性改變,但在微創新與 API 優化上都做得足夠棒,github 更新也比較活躍,如果你決定使用 ORM 開發專案,還是比較推薦 Prisma 的。

在實際使用中,為了規避 ORM 產生笨拙 sql 導致的效能問題,可以利用 Prisma Middleware 監控查詢效能,並對效能較差的地方採用 prisma.$queryRaw 原生 sql 查詢。

討論地址是:精讀《Prisma 的使用》· Issue #362 · dt-fe/weekly

如果你想參與討論,請 點選這裡,每週都有新的主題,週末或週一釋出。前端精讀 - 幫你篩選靠譜的內容。

關注 前端精讀微信公眾號

<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">

版權宣告:自由轉載-非商用-非衍生-保持署名(創意共享 3.0 許可證

相關文章