Prisma:下一代ORM,不僅僅是ORM(下篇)


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屬性位於當前的表中,userIduser必須保持一致的可選/必選,即要么同為可選,要么同為必選。
  • 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,阿里內部都在用,很難不資瓷!


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM