現(xiàn)代 Nodejs ORM 庫 Prisma 的使用詳解
大廠技術(shù)??高級前端??Node進階
點擊上方?程序員成長指北,關(guān)注公眾號
回復(fù)1,加入高級Node交流群
ORM(Object relational mappers) 的含義是,將數(shù)據(jù)模型與 Object 建立強力的映射關(guān)系,這樣我們對數(shù)據(jù)的增刪改查可以轉(zhuǎn)換為操作 Object(對象)。
Prisma 是一個現(xiàn)代 Nodejs ORM 庫,根據(jù) Prisma 官方文檔 可以了解這個庫是如何設(shè)計與使用的。
概述
Prisma 提供了大量工具,包括 Prisma Schema、Prisma Client、Prisma Migrate、Prisma CLI、Prisma Studio 等,其中最核心的兩個是 Prisma Schema 與 Prisma Client,分別是描述應(yīng)用數(shù)據(jù)模型與 Node 操作 API。
與一般 ORM 完全由 Class 描述數(shù)據(jù)模型不同,Primsa 采用了一個全新語法 Primsa Schema 描述數(shù)據(jù)模型,再執(zhí)行 prisma generate 產(chǎn)生一個配置文件存儲在 node_modules/.prisma/client 中,Node 代碼里就可以使用 Prisma Client 對數(shù)據(jù)增刪改查了。
Prisma Schema
Primsa Schema 是在最大程度貼近數(shù)據(jù)庫結(jié)構(gòu)描述的基礎(chǔ)上,對關(guān)聯(lián)關(guān)系進行了進一步抽象,并且背后維護了與數(shù)據(jù)模型的對應(yīng)關(guān)系,下圖很好的說明了這一點:

可以看到,幾乎與數(shù)據(jù)庫的定義一模一樣,唯一多出來的 posts 與 author 其實是彌補了數(shù)據(jù)庫表關(guān)聯(lián)外鍵中不直觀的部分,將這些外鍵轉(zhuǎn)化為實體對象,讓操作時感受不到外鍵或者多表的存在,在具體操作時再轉(zhuǎn)化為 join 操作。下面是對應(yīng)的 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 申明了鏈接數(shù)據(jù)庫信息;generator client 申明了使用 Prisma Client 進行客戶端操作,也就是說 Prisma Client 其實是可以替換實現(xiàn)的;model 是最核心的模型定義。
在模型定義中,可以通過 @map 修改字段名映射、@@map 修改表名映射,默認情況下,字段名與 key 名相同:
model Comment {
title @map("comment_title")
@@map("comments")
}
字段由下面四種描述組成:
字段名。 字段類型。 可選的類型修飾。 可選的屬性描述。
model Tag {
name String? @id
}
在這個描述里,包含字段名 name、字段類型 String、類型修飾 ?、屬性描述 @id。
字段類型
字段類型可以是 model,比如關(guān)聯(lián)類型字段場景:
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?
}
關(guān)聯(lián)場景有 1v1, nv1, 1vn, nvn 四種情況,字段類型可以為定義的 model 名稱,并使用屬性描述 @relation 定義關(guān)聯(lián)關(guān)系,比如上面的例子,描述了 Commenct 與 Post 存在 nv1 關(guān)系,并且 Comment.postId 與 Post.id 關(guān)聯(lián)。
字段類型還可以是底層數(shù)據(jù)類型,通過 @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[]
}
分別表示可選與數(shù)組。
屬性描述
屬性描述有如下幾種語法:
model User {
id Int @id @default(autoincrement())
isAdmin Boolean @default(false)
email String @unique
@@unique([firstName, lastName])
}
@id 對應(yīng)數(shù)據(jù)庫的 PRIMARY KEY。
@default 設(shè)置字段默認值,可以聯(lián)合函數(shù)使用,比如 @default(autoincrement()),可用函數(shù)包括 autoincrement()、dbgenerated()、cuid()、uuid()、now(),還可以通過 dbgenerated 直接調(diào)用數(shù)據(jù)庫底層的函數(shù),比如 dbgenerated("gen_random_uuid()")。
@unique 設(shè)置字段值唯一。
@relation 設(shè)置關(guān)聯(lián),上面已經(jīng)提到過了。
@map 設(shè)置映射,上面也提到過了。
@updatedAt 修飾字段用來存儲上次更新時間,一般是數(shù)據(jù)庫自帶的能力。
@ignore 對 Prisma 標記無效的字段。
所有屬性描述都可以組合使用,并且還存在需對 model 級別的描述,一般用兩個 @ 描述,包括 @@id、@@unique、@@index、@@map、@@ignore。
ManyToMany
Prisma 在多對多關(guān)聯(lián)關(guān)系的描述上也下了功夫,支持隱式關(guān)聯(lián)描述:
model Post {
id Int @id @default(autoincrement())
categories Category[]
}
model Category {
id Int @id @default(autoincrement())
posts Post[]
}
看上去很自然,但其實背后隱藏了不少實現(xiàn)。數(shù)據(jù)庫多對多關(guān)系一般通過第三張表實現(xiàn),第三張表會存儲兩張表之間外鍵對應(yīng)關(guān)系,所以如果要顯式定義其實是這樣的:
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 后,執(zhí)行 prisma generate,再利用 npm install @prisma/client 安裝好 Node 包后,就可以在代碼里操作 ORM 了:
import?{?PrismaClient?}?from?'@prisma/client'
const?prisma?=?new?PrismaClient()
CRUD
使用 create 創(chuàng)建一條記錄:
const?user?=?await?prisma.user.create({
??data:?{
????email:?'[email protected]',
????name:?'Elsa?Prisma',
??},
})
使用 createMany 創(chuàng)建多條記錄:
const?createMany?=?await?prisma.user.createMany({
??data:?[
????{?name:?'Bob',?email:?'[email protected]'?},
????{?name:?'Bobo',?email:?'[email protected]'?},?//?Duplicate?unique?key!
????{?name:?'Yewande',?email:?'[email protected]'?},
????{?name:?'Angelique',?email:?'[email protected]'?},
??],
??skipDuplicates:?true,?//?Skip?'Bobo'
})
使用 findUnique 查找單條記錄:
const?user?=?await?prisma.user.findUnique({
??where:?{
????email:?'[email protected]',
??},
})
對于聯(lián)合索引的情況:
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:?'[email protected]',
??},
??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:?'[email protected]',
??},
})
使用 deleteMany 刪除多條記錄:
const?deleteUsers?=?await?prisma.user.deleteMany({
??where:?{
????email:?{
??????contains:?'prisma.io',
????},
??},
})
使用 include 表示關(guān)聯(lián)查詢是否生效,比如:
const?getUser?=?await?prisma.user.findUnique({
??where:?{
????id:?19,
??},
??include:?{
????posts:?true,
??},
})
這樣就會在查詢 user 表時,順帶查詢所有關(guān)聯(lián)的 post 表。關(guān)聯(lián)查詢也支持嵌套:
const?user?=?await?prisma.user.findMany({
??include:?{
????posts:?{
??????include:?{
????????categories:?true,
??????},
????},
??},
})
篩選條件支持 equals、not、in、notIn、lt、lte、gt、gte、contains、search、mode、startsWith、endsWith、AND、OR、NOT,一般用法如下:
const?result?=?await?prisma.user.findMany({
??where:?{
????name:?{
??????equals:?'Eleanor',
????},
??},
})
這個語句代替 sql 的 where name="Eleanor",即通過對象嵌套的方式表達語義。
Prisma 也可以直接寫原生 SQL:
const?email?=?'[email protected]'
const?result?=?await?prisma.$queryRaw(
??Prisma.sql`SELECT?*?FROM?User?WHERE?email?=?${email}`
)
中間件
Prisma 支持中間件的方式在執(zhí)行過程中進行拓展,看下面的例子:
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
可以看到,中間件執(zhí)行順序是洋蔥模型,并且每個操作都會觸發(fā)。我們可以利用中間件拓展業(yè)務(wù)邏輯或者進行操作時間的打點記錄。
精讀
ORM 的兩種設(shè)計模式
ORM 有 Active Record 與 Data Mapper 兩種設(shè)計模式,其中 Active Record 使對象背后完全對應(yīng) sql 查詢,現(xiàn)在已經(jīng)不怎么流行了,而 Data Mapper 模式中的對象并不知道數(shù)據(jù)庫的存在,即中間多了一層映射,甚至背后不需要對應(yīng)數(shù)據(jù)庫,所以可以做一些很輕量的調(diào)試功能。
Prisma 采用了 Data Mapper 模式。
ORM 容易引發(fā)性能問題
當數(shù)據(jù)量大,或者性能、資源敏感的情況下,我們需要對 SQL 進行優(yōu)化,甚至我們需要對特定的 Mysql 的特定版本的某些內(nèi)核錯誤,對 SQL 進行某些看似無意義的申明調(diào)優(yōu)(比如在 where 之前再進行相同條件的 IN 范圍限定),有的時候能取得驚人的性能提升。
而 ORM 是建立在一個較為理想化理論基礎(chǔ)上的,即數(shù)據(jù)模型可以很好的轉(zhuǎn)化為對象操作,然而對象操作由于屏蔽了細節(jié),我們無法對 SQL 進行針對性調(diào)優(yōu)。
另外,得益于對象操作的便利性,我們很容易通過 obj.obj. 的方式訪問某些屬性,但這背后生成的卻是一系列未經(jīng)優(yōu)化(或者部分自動優(yōu)化)的復(fù)雜 join sql,我們在寫這些 sql 時會提前考慮性能因素,但通過對象調(diào)用時卻因為成本低,或覺得 ORM 有 magic 優(yōu)化等想法,寫出很多實際上不合理的 sql。
Prisma Schema 的好處
其實從語法上,Prisma Schema 與 Typeorm 基于 Class + 裝飾器的拓展幾乎可以等價轉(zhuǎn)換,但 Prisma Schema 在實際使用中有一個很不錯的優(yōu)勢,即減少樣板代碼以及穩(wěn)定數(shù)據(jù)庫模型。
減少樣板代碼比較好理解,因為 Prisma Schema 并不會出現(xiàn)在代碼中,而穩(wěn)定模型是指,只要不執(zhí)行 prisma generate,數(shù)據(jù)模型就不會變化,而且 Prisma Schema 也獨立于 Node 存在,甚至可以不放在項目源碼中,相比之下,修改起來會更加慎重,而完全用 Node 定義的模型因為本身是代碼的一部分,可能會突然被修改,而且也沒有執(zhí)行數(shù)據(jù)庫結(jié)構(gòu)同步的操作。
如果項目采用 Prisma,則模型變更后,可以執(zhí)行 prisma db pull 更新數(shù)據(jù)庫結(jié)構(gòu),再執(zhí)行 prisma generate 更新客戶端 API,這個流程比較清晰。
總結(jié)
Prisma Schema 是 Prisma 的一大特色,因為這部分描述獨立于代碼,帶來了如下幾個好處:
定義比 Node Class 更簡潔。 不生成冗余的代碼結(jié)構(gòu)。 Prisma Client 更加輕量,且查詢返回的都是 Pure Object。
至于 Prisma Client 的 API 設(shè)計其實并沒有特別突出之處,無論與 sequelize 還是 typeorm 的 API 設(shè)計相比,都沒有太大的優(yōu)化,只是風(fēng)格不同。
不過對于記錄的創(chuàng)建,我更喜歡 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:?'[email protected]',
})
//?prisma
const?newUser?=?await?prisma.user.create({
??data:?{
????name:?'Alice',
??},
})
首先存在 prisma 這個頂層變量,使用起來會非常方便,另外從 API 拓展上來說,雖然 Mongoose 設(shè)計得更簡潔,但添加一些條件時拓展性會不足,導(dǎo)致結(jié)構(gòu)不太穩(wěn)定,不利于統(tǒng)一記憶。
Prisma Client 的 API 統(tǒng)一采用下面這種結(jié)構(gòu):
await?prisma.modelName.operateName({
??//?數(shù)據(jù),比如?create、update?時會用到
??data:?/**?...?*/,
??//?條件,大部分情況都可以用到
??where:?/**?...?*/,
??//?其它特殊參數(shù),或者?operater?特有的參數(shù)
})
所以總的來說,Prisma 雖然沒有對 ORM 做出革命性改變,但在微創(chuàng)新與 API 優(yōu)化上都做得足夠棒,github 更新也比較活躍,如果你決定使用 ORM 開發(fā)項目,還是比較推薦 Prisma 的。
在實際使用中,為了規(guī)避 ORM 產(chǎn)生笨拙 sql 導(dǎo)致的性能問題,可以利用 Prisma Middleware 監(jiān)控查詢性能,并對性能較差的地方采用 prisma.$queryRaw 原生 sql 查詢。
討論地址是:精讀《Prisma 的使用》· Issue #362 · dt-fe/weekly
我組建了一個氛圍特別好的 Node.js 社群,里面有很多 Node.js小伙伴,如果你對Node.js學(xué)習(xí)感興趣的話(后續(xù)有計劃也可以),我們可以一起進行Node.js相關(guān)的交流、學(xué)習(xí)、共建。下方加 考拉 好友回復(fù)「Node」即可。

???“分享、點贊、在看” 支持一波??
