Prisma:下一代ORM,不僅僅是ORM
在上一篇文章中,我們從NodeJS社區的傳統ORM講起,介紹了它們的特征以及傳統ORM的Active Record、Data Mapper模式,再到Prisma的環境配置、基本使用以及單表實踐。在這篇文章中,我們將介紹Prisma的多表、多表級聯、多數據庫實戰,以及Prisma與GraphQL的協作,在最后,我們還會簡單的收尾來展開聊一聊Prisma,幫助你建立大致的印象:Prisma的優勢在哪里?什么時候該用Prisma?
Prisma多表、多數據庫實戰
在大部分情況下我們的數據庫中不會只有一張數據表,多表下的操作(級聯、事務等)也是判斷一個ORM是否易用的重要指標。在這一方面Prisma同樣表現出色,類似於上篇文章中的單表示例,Prisma同樣提供了以簡潔語法操作級聯的能力。
Prisma 多表
本部分的示例代碼見 multi-models
我們首先在Prisma Schema中定義多張數據表,各個實體之間的級聯關系如下:
- User -> Profile 1-1
- User -> Post 1-m
- Post -> Category m-n
model Category {
id Int @id @default(autoincrement())
name String
posts Post[]
}
model Post {
id Int @id @default(autoincrement())
postUUID String @default(uuid())
title String
content String?
published Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
author User @relation(fields: [authorId], references: [id])
authorId Int
categories Category[]
}
model Profile {
id Int @id @default(autoincrement())
bio String?
profileViews Int @default(0)
user User? @relation(fields: [userId], references: [id])
userId Int? @unique
}
model User {
id Int @id @default(autoincrement())
name String @unique
age Int @default(0)
posts Post[]
profile Profile?
avaliable Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
在這里我們主要關注Prisma如何連接各個實體,明顯能看到相關代碼應該是:
posts Post[]
profile Profile?
在關系的擁有者中(在一對一、一對多關系中,通常認為只存在一方擁有者,而在多對多關系中,通常認為互為擁有者)我們只需要定義字段以及字段代表的實體,而在關系的另一方中,我們需要使用prisma的@relation
語法來標注這一字段表征的關系,如
user User? @relation(fields: [userId], references: [id])
userId Int? @unique
- fields屬性位於當前的表中,
userId
和user
必須保持一致的可選/必選,即要么同為可選,要么同為必選。 - references屬性位於關系的另一方中,表征與
userId
對應的字段。 - 除了fields與reference屬性外,還可使用name屬性來顯式指定關系名稱,這在一些情景下可以避免歧義。
- 在一對一、一對多關系中,
@relation
是必須被使用的。
在多對多關系中,如Post與Category,可以不使用@relation
來聲明級聯關系,這樣將會自動使用雙方表中的@id
來建立級聯關系。如果你覺得這種隱式指定可能會帶來歧義或者你需要額外定制,也可以使用額外的一張數據表,使用@relation
分別與Post、Category建立一對多關系。
創建完畢schema后,執行yarn generate:multi
來生成Prisma Client,便可以開始使用了。
上面的級聯關系如果以對象的形式表示,大概是這樣的:
const user = { profile: { } posts: { categories: { } } }
因此,在Prisma中我們也以類似的方式操作各張數據表:
const simpleIncludeFields = {
profile: true,
posts: {
include: {
categories: true,
},
},
};
const createUserWithFullRelations = await prisma.user.create({
data: {
name: randomName(),
age: 21,
profile: {
create: {
bio: randomBio(),
},
},
posts: {
create: {
title: randomTitle(),
content: "鴿置",
categories: {
create: [{ name: "NodeJS" }, { name: "GraphQL" }],
},
},
},
},
include: simpleIncludeFields,
});
來看看和單表操作中不同的部分:
- 默認情況下,返回結果中不會包含級聯關系,只會包含實體自身的標量,如
'prisma.user.xxx'
返回的結果只會包含User實體自身除了級聯以外的字段。 - 如果想要像上面的例子一樣,操作實體的同時操作其多個級聯關系,prisma提供了connect、create、connectOrCreate方法,分別用於連接到已有的級聯實體、創建新的級聯實體以及動態判斷。
connectOrCreate的使用方式如下:
const connectOrCreateRelationsUser = await prisma.user.create({
data: {
name: randomName(),
profile: {
connectOrCreate: {
where: {
id: 9999,
},
create: {
bio: "Created by connectOrCreate",
},
},
},
posts: {
connectOrCreate: {
where: {
id: 9999,
},
create: {
title: "Created by connectOrCreate",
},
},
},
},
select: simpleSelectFields,
});
我們為profile和post使用了不存在的ID進行查找,因此Prisma會為我們自動創建級聯實體。
看完了級聯創建,再來看看級聯的更新操作,一對一:
const oneToOneUpdate = await prisma.user.update({
where: {
name: connectOrCreateRelationsUser.name,
},
data: {
profile: {
update: {
bio: "Updated Bio",
},
// update
// upsert
// delete
// disconnect(true)
// create
// connect
// connectOrCreate
},
},
select: simpleSelectFields,
});
對於更新,prisma直接提供了一系列便捷方法,覆蓋絕大部分的case(我暫時沒發現有覆蓋不到的),create、connect、connectOrCreate同樣存在於user.update方法上,還新增了disconnect來斷開級聯關系。
一對多的更新則多了一些不同:
const oneToMnayUpdate = await prisma.user.update({
where: {
name: connectOrCreateRelationsUser.name,
},
data: {
posts: {
updateMany: {
data: {
title: "Updated Post Title",
},
where: {},
},
// set 與 many, 以及各選項類型
// set: [],
// update
// updateMany
// delete
// deleteMany
// disconnect: [
// {
// id: 1,
// },
// ],
// connect
// create
// connectOrCreate
// upsert
},
},
select: simpleSelectFields,
});
你可以使用update/delete來一把梭的進行所有級聯關系的更新,如用戶VIP等級提升,更新用戶所有文章的曝光率;也可以使用updateMany/deleteMany對符合條件的級聯實體做精細化的修改;亦或者,你可以直接使用set方法,覆蓋所有的級聯實體關系(如set為[],則用戶的所有級聯文章關系都將消失)。
至於多對多的更新操作,類似於上面一對多的批量更新,這里就不做展開了。
多表級聯進階
本部分的代碼見 multi-models-advanced。
這一部分不會做過多展開,畢竟本文還是屬於入門系列的文章。但是我在GitHub代碼倉庫中提供了相關的示例,如果你有興趣,直接查看即可。
在這里簡單的概括下代碼倉庫中的例子:
- 自關聯,我們前面的級聯關系都來自於不同實體之間,實際上相同實體之間的關聯也是常見的。如用戶邀請其他用戶時,通常會有已邀請的用戶和當前用戶的邀請人這兩個同樣屬於User實體的操作。
- 使用中間表來構建多對多的關系,就如我們上面提到的Post與Category關系,其實在Prisma中還可以定義額外的一張CategoriesOnPosts表,顯式的配置級聯信息。這種情況下又要如何進行CRUD呢?
- 細粒度的操作符,實體級別的every/some/none,標量級別的contain/startsWith/endsWith/equals/...,另外,Prisma還支持JSON Filters來直接對JSON數據進行過濾(例子中沒有體現)。
這我得來點騷操作啊
多個Prisma Client
本部分代碼見 multi-clients。
由於Prisma的獨特用法,你應該很容易想到要創建多個Prisma Client來連接不同的數據庫是非常容易地,並且和單個Prisma Client使用沒有明顯的差異。其他ORM的話則需要稍微折騰一些,如TypeORM需要創建多個連接池(TypeORM中的每個連接並不是“單個的”,而是連接池的形式)。
在這個例子中,我們使用Key-Value的形式,一個client存儲key,另一個存儲value:
model Key {
kid Int @id @default(autoincrement())
key String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Value {
vid Int @id @default(autoincrement())
key String
value String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
以上的model定義是用兩個schema文件儲存的
在npm scripts中,我已經准備好了相關的script,運行yarn generate:multi-db
即可。
實際使用也和單個Client沒差別:
import { PrismaClient as PrismaKeyClient, Key } from "./prisma-key/client";
import { PrismaClient as PrismaValueClient } from "./prisma-value/client";
const keyClient = new PrismaKeyClient();
const valueClient = new PrismaValueClient();
首先創建key(基於uuid),然后基於key創建value:
const key1 = await keyClient.key.create({
data: {
key: uuidv4(),
},
select: {
key: true,
},
});
const value1 = await valueClient.value.create({
data: {
key: key1.key,
value: "林不渡",
},
select: {
key: true,
value: true,
},
});
真的就和單個client沒區別,是吧...
Prisma與其他ORM協作
本部分的示例見 with-typeorm 與 with-typegoose
既然Prisma + Prisma沒問題,那么Prisma + 其他ORM呢?其實同樣很簡單,以TypeORM的例子為例,步驟是相同的:
- 創建Prisma連接
- 創建TypeORM連接
- 以Prisma創建key
- 使用Prisma的key創建TypeORM的value
- 查詢所有Prisma的key,用於查詢所有TypeORM的value
// 創建TypeORM連接
const connection = await createConnection({
type: "sqlite",
database: IS_PROD
? "./dist/src/with-typeorm/typeorm-value.sqlite"
: "./src/with-typeorm/typeorm-value.sqlite",
entities: [ValueEntity],
synchronize: true,
dropSchema: true,
});
// 使用Prisma存儲key
const key1 = await prisma.prismaKey.create({
data: {
key: uuidv4(),
},
select: {
key: true,
},
});
// 使用Prisma的key創建TypeORM的value
const insertValues = await ValueEntity.createQueryBuilder()
.insert()
.into(ValueEntity)
.values([
{
key: key1.key,
value: "林不渡",
}
])
.execute();
const keys = await prisma.prismaKey.findMany();
// 查詢得到所有的key,用於遍歷查詢value
for (const keyItem of keys) {
const key = keyItem.key;
console.log(`Search By: ${key}`);
const value = await ValueEntity.createQueryBuilder("value")
.where("value.key = :key")
.setParameters({
key,
})
.getOne();
console.log("Search Result: ", value);
console.log("===");
}
Prisma + GraphQL
本部分的代碼見 typegraphql-apollo-server
Prisma和GraphQL有個共同點,那就是它們都是SDL First的,Prisma Schema和GraphQL Schema在部分細節上甚至是一致的,如標量以及@
語法(雖然prisma中是內置函數,GraphQL中則是指令)等。而且,Prisma內置了DataLoader來解決GraphQL N+1問題,所以你真的不想試試Prisma + GraphQL嗎?
關於DataLoader,可以參見我之前寫的 GraphQL N+1問題到DataLoader源碼解析,其中就包含了Prisma2中內置的DataLoader源碼解析。
技術棧與要點:
- 基於TypeGraphQL構建GraphQL Schema與Resolver,這也是目前主流的一種方式,畢竟寫原生GraphQL的話其實不太好擴展(除非借助GraphQL-Modules),類似的方式還有使用Nexus來構建Schema。
- 基於ApolloServer構建GraphQL服務,它是目前使用最廣的GraphQL服務端框架之一。
我們將實例化完畢的Prisma Client掛載在Context中,這樣在Resolver中就能夠獲取到prisma實例。
這一方式其實就類似於REST API中,我們拆分應用程序架構為Controller-Service的結構,Controller對應的即是這里的Resolver,直接接受請求並處理。在GraphQL應用中你同樣可以拆分一層Service,但這里為了保持代碼精簡就沒有采用。
關於Context API,建議閱讀Apollo的官方文檔。 - 在這里為了示范GraphQL Generation系列的技術棧(其實就是為了好玩),我還引入了GraphQL-Code-Generator(基於構建完畢的GraphQL Schema生成TS類型定義)以及GenQL(基於Schema生成client,然后就可以以類似Prisma Client的方式調用各種方法了,還支持鏈式調用,很難不資瓷)
在這里我們直接看重要代碼即可:
// server.ts
const server = new ApolloServer({
schema,
context: { prisma },
});
// user.resolver.ts
@Resolver(TodoItem)
export default class TodoResolver {
constructor() {}
@Query((returns) => [TodoItem!]!)
async QueryAllTodos(@Ctx() ctx: IContext): Promise<TodoItem[]> {
return await ctx.prisma.todo.findMany({ include: { creator: true } });
}
@Query((returns) => TodoItem, { nullable: true })
async QueryTodoById(
@Arg("id", (type) => Int) id: number,
@Ctx() ctx: IContext
): Promise<TodoItem | null> {
return await ctx.prisma.todo.findUnique({
where: {
id,
},
include: { creator: true },
});
}
@Mutation((returns) => TodoItem, { nullable: true })
async MutateTodoStatus(
@Arg("id", (type) => Int) id: number,
@Arg("status") status: boolean,
@Ctx() ctx: IContext
): Promise<TodoItem | null> {
try {
return await ctx.prisma.todo.update({
where: {
id,
},
data: {
finished: status,
},
include: { creator: true },
});
} catch (error) {
return null;
}
}
@Mutation((returns) => TodoItem, { nullable: true })
async CreateTodo(
@Arg("createParams", (type) => CreateTodoInput) params: CreateTodoInput,
@Ctx() ctx: IContext
): Promise<TodoItem | null> {
try {
return await ctx.prisma.todo.create({
data: {
title: params.title,
content: params?.content ?? null,
type: params?.type ?? ItemType.FEATURE,
creator: {
connect: {
id: params.userId,
},
},
},
include: { creator: true },
});
} catch (error) {
return null;
}
}
}
很明顯和上面例子中的唯一差異就是這里推薦把prisma掛載到context上然后再調用方法,而不是為每個Resolver導入一次Prisma Client。而在Midway與Nest這一類基於IoC機制的Node框架中,推薦的使用方法是將Prisma Client注冊到容器中,然后注入到Service層中。
關於Nest與Prisma的使用,可參考倉庫README中的介紹。
尾聲:Prisma展望
在本文的最后,讓我們來擴展性的聊一聊Prisma吧:
- Prisma的出現是為了解決什么?它和其他操作數據庫的方案(SQL、ORM、Query Builder)比起來,有什么新的優勢嗎?
完整版內容參考方方老師翻譯的這篇 Why Prisma? - 手寫原生SQL:只要你的能力到位,SQL的控制力是最強的,其控制粒度也是最精細的,幾乎沒有對手。但前提是,能力到位。手寫SQL會花費大量的時間,當你花了多一倍的時間手寫SQL卻沒有得到正比的回報,我想你需要停下來思考下?
- Query Builder:在上一篇文章我們講到,Query Builder不是ORM,但它更加貼近原生的SQL,Query Builder的每一次鏈式調用都會對最終生成的SQL進行一次修改。另外,Query Builder同樣需要一定的SQL知識,如leftJoin等。
- ORM,在JavaScript中通常使用Class的方式來定義數據表模型,看起來很貼心,但實際上會導致對象關系阻抗不匹配(Object-relational impedance mismatch)。 舉例來說,我們習慣於通過User.post.category這種
.
的方式來訪問嵌套的數據實體,但實際上User、Post、Category應該是獨立的實體,它們是獨立集合而不是對象屬性的關系。在ORM中我們使用.
的方式來訪問,但底層它也是通過外鍵JOIN的方式來構造SQL的。 - 那么Prisma呢?它不再需要你一邊用Class定義一邊告訴自己牢記這是關系型數據庫了...,現在你可以直接用JS對象的模式來思考。它的控制力不如SQL與Query Builder,因為你還是直接通過已經封裝完畢的create/update方法來調用。然而,由於它的心智模型,你使用它就像是使用一個普通對象一樣,比起ORM來易用的多。
- Prisma是ORM嗎?
當然是啦!
只不過和傳統的ORM不一樣,Prisma使用的方式是“聲明式”的,在你的prisma文件中聲明數據庫結構即可,這一實現使得它可以是跨語言的(只需要配置client.provider即可,目前僅有prisma-client-js
實現)。而傳統ORM提供的使用方式則是“面向對象的”,你需要將你的數據表結構一一映射到與語言對應的模型類中去。 - Prisma和GraphQL Generation
上面說到,由於Prisma和GraphQL都是Schema First,因此二者往往能夠產生奇妙的化學反應。最容易想到的就是二者Schema的互相轉化,但目前社區似乎沒有類似的方案,原因無他,如果從Prisma Schema生成原生GraphQL Schema,並沒有太多意義,因為現在其實很少會書寫原生的Schema了。其次,如果要從Prisma得到GraphQL的類型定義,也沒有必要直接轉換到原生,完全可以轉換到高階表示,如Nexus與TypeGraphQL,所以目前社區有的也是nexus-plugin-prisma
和typegraphql-prisma
這兩個方案,前者生成Nexus Type Builders,后者生成TypeGraphQL Class以及CRUD Resolvers。
一體化框架
目前除了React這樣的前端框架,Express這樣的后端框架,其實還有一體化框架(Monolithic Framework),這一類框架最早來自於Ruby On Rails的思路。
目前在前端領域中,一體化框架的思路主要是這樣的:
- 開發時,前端直接導入后端的函數,后端函數中直接進行數據源的操作(ORM或者已有的API),而不是前端后端各起一個服務。
- 構建時,框架會自動把前端對后端的函數導入,轉換為HTTP請求,而后端的函數則會唄構建為FaaS函數或者API服務。
- BlitzJS,基於NextJS + Prisma + GraphQL,但實際上不需要有GraphQL相關的知識,作者成功把GraphQL的Query/Mutation通過Prisma轉成了普通的方法調用,你也不需要自己書寫Schema了。
- RedwoodJS,類似於Blitz,基於React + Prisma + GraphQL,但場景在JAMStack,Blitz更傾向於應用程序開發。並且和Blitz抹消了GraphQL的存在感不同,RedWoodJS采用Cells來實現前后端通信。
- Midway-Hooks,前端框架不綁定,支持React/Vue,因此對應的支持React Hooks與Composition API。開發服務器基於Vite,可部署為單獨Server或者FaaS,阿里內部都在用,很難不資瓷!