— 4 min read
本篇文章将会介绍一个 NodeJS 社区中的 ORM:Prisma。我接触它的时间不算长,但已经对它的未来发展充满信心。这篇文章其实三个月以前就写了一部分,所以文中会出现“如果你觉得它不错,不如考虑基于 Prisma 来完成你的毕设”这样的话。
在刚开始写的时候,bven爷的毕设一行都还没动,而到了我今天发的时候,他已经是优秀毕业生了...
同时,原本准备一篇搞定所有内容,但是觉得这种教程类的文章如果写的这么长,很难让人有读完的兴致。所以就拆成了两部分:
文章的大致顺序如下:
经常写 Node 应用的同学通常免不了要和 ORM 打交道,毕竟写原生 SQL 对于大部分前端同学来说真的是一种折磨。ORM 的便利性使得很多情况下我们能直观而方便的和数据库打交道(虽然的确有些情况下 ORM 搞不定),用我们熟悉的 JavaScript 来花式操作数据库。 NodeJS 社区中主流的 ORM 主要有这么几个,它们都有各自的一些特色:
Sequelize,比较老牌的一款 ORM,缺点是 TS 支持不太好,但是社区有Sequelize-TypeScript。
Sequelize 定义表结构的方式是这样的:
1const { Sequelize, Model, DataTypes } = require("sequelize");2const sequelize = new Sequelize("sqlite::memory:");3
4class User extends Model {}5
6User.init(7 {8 username: DataTypes.STRING,9 birthday: DataTypes.DATE,10 },11 { sequelize, modelName: "user" }12);13
14(async () => {15 await sequelize.sync();16 const jane = await User.create({17 username: "janedoe",18 birthday: new Date(1980, 6, 20),19 });20 console.log(jane.toJSON());21})();
(我是觉得不那么符合直觉,所以我只在入门时期简单使用过)
TypeORM,NodeJS 社区 star 最多的一个 ORM。也确实很好用,在我周围的同学里备受好评,同时也是我自己用的最多的一个 ORM。亮点在基于装饰器语法声明表结构、事务、级联等,以及很棒的 TS 支持。
TypeORM 声明表结构是这样的:
1import { Entity, PrimaryGeneratedColumn, Column } from "typeorm";2
3@Entity()4export class User {5 @PrimaryGeneratedColumn()6 id: number;7
8 @Column()9 firstName: string;10
11 @Column()12 lastName: string;13
14 @Column()15 age: number;16}
比起 Sequelize 来要直观的多,而且由于通过类属性的方式来定义数据库字段,可以很好的兼容 Mixin 以及其他基于类属性的工具库,如TypeGraphQL。
MikroORM,比较新的一个 ORM,同样大量基于装饰器语法,亮点在于自动处理所有事务以及表实体会在全局保持单例模式,暂时还没深入使用过过。
MikroORM 定义表结构方式是这样的:
1@Entity()2export class Book extends BaseEntity {3 @Property()4 title!: string;5
6 @ManyToOne()7 author!: Author;8
9 @ManyToOne()10 publisher?: IdentifiedReference<Publisher>;11
12 @ManyToMany({ fixedOrder: true })13 tags = new Collection<BookTag>(this);14}
Mongoose、Typegoose,MongoDB 专用的 ORM,这里简单放一下 TypeGoose 的使用示例:
1import { prop, getModelForClass } from "@typegoose/typegoose";2import * as mongoose from "mongoose";3
4class User {5 @prop()6 public name?: string;7
8 @prop({ type: () => [String] })9 public jobs?: string[];10}11
12const UserModel = getModelForClass(User);13
14(async () => {15 await mongoose.connect("mongodb://localhost:27017/", {16 useNewUrlParser: true,17 useUnifiedTopology: true,18 dbName: "test",19 });20
21 const { _id: id } = await UserModel.create({22 name: "JohnDoe",23 jobs: ["Cleaner"],24 } as User);25
26 const user = await UserModel.findById(id).exec();27
28 console.log(user);29})();
Bookshelf,一个相对简单一些但也五脏俱全的 ORM,基于 Knex(Strapi 底层的 Query Builder,后面会简单介绍)。它的使用方式大概是这样的:
1const knex = require("knex")({2 client: "mysql",3 connection: process.env.MYSQL_DATABASE_CONNECTION,4});5// bookshelf 基于 knex,所以需要实例化knex然后传入6const bookshelf = require("bookshelf")(knex);7
8const User = bookshelf.model("User", {9 tableName: "users",10 posts() {11 return this.hasMany(Posts);12 },13});14
15const Post = bookshelf.model("Post", {16 tableName: "posts",17 tags() {18 return this.belongsToMany(Tag);19 },20});21
22const Tag = bookshelf.model("Tag", {23 tableName: "tags",24});25
26new User({ id: 1 })27 .fetch({ withRelated: ["posts.tags"] })28 .then((user) => {29 console.log(user.related("posts").toJSON());30 })31 .catch((error) => {32 console.error(error);33 });
另外,一个比较独特的地方是 bookshelf 支持了插件机制,其他 ORM 通常通过 hook 或者 subscriber 的方式实现类似的功能,如密码存入时进行一次加密、TPS 计算、等。
如果你去看了上面列举的 ORM 文档,你会发现 MikroORM 的简介中包含这么一句话:TypeScript ORM for Node.js based on Data Mapper
,而 TypeORM 的简介中则是TypeORM supports both Active Record and Data Mapper patterns
。
先来一个问题,使用 ORM 的过程中,你是否了解过 Data Mapper 与 Active Record 这两种模式的区别?
先来看看 TypeORM 中分别是如何使用这两种模式的:
Active Record:
1import { BaseEntity, Entity, PrimaryGeneratedColumn, Column } from "typeorm";2
3@Entity()4export class User extends BaseEntity {5 @PrimaryGeneratedColumn()6 id: number;7
8 @Column()9 name: string;10
11 @Column()12 isActive: boolean;13}14
15const user = new User();16user.name = "不渡";17user.isActive = true;18
19await user.save();20
21const newUsers = await User.find({ isActive: true });
TypeORM 中,Active Record 模式下需要让实体类继承BaseEntity
类,这样实体类上就具有了各种方法,如save
remove
find
方法等。Active Record 模式最早由 Martin Fowler 在 企业级应用架构模式 一书中命名,这一模式使得对象上拥有了相关的 CRUD 方法。在RoR中就使用了这一模式来作为 MVC 中的 M,即数据驱动层。如果你对 RoR 中的 Active Record 有兴趣,可以阅读 全面理解 Active Record(我不会 Ruby,因此就不做介绍了)。
Data Mapper:
1import { Entity, PrimaryGeneratedColumn, Column } from "typeorm";2
3@Entity()4export class User {5 @PrimaryGeneratedColumn()6 id: number;7
8 @Column()9 name: string;10
11 @Column()12 isActive: boolean;13}14
15const userRepository = connection.getRepository(User);16
17const user = new User();18user.name = "不渡";19user.isActive = true;20
21await userRepository.save(user);22
23await userRepository.remove(user);24
25const newUsers = await userRepository.find({ isActive: true });
可以看到在 Data Mapper 模式中,实体类不再能够自己进行数据库操作,而是需要先获取到一个对应到表的“仓库”,然后再调用这个“仓库”上的方法。
这一模式同样由Martin Fowler最初命名,Data Mapper 更像是一层拦在操作者与实际数据之间的访问层,就如上面例子中先获取具有访问权限(即相应方法)的对象,再进行数据的操作。
对这两个模式进行比较,很容易发现 Active Record 模式要简单的多,而 Data Mapper 模式则更加严谨。那么何时使用这两种模式就很清楚了,如果你在开发比较简单的应用,直接使用 Active Record 模式就好了,因为这确实会减少很多代码。但是如果你在开发规模较大的应用,使用 Data Mapper 模式则能够帮助你更好的维护代码(实体类不再具有访问数据库权限了,只能通过统一的接口(getRepository
getManager
等)),一个例子是在 Nest、Midway 这两个 IoC 风格的 Node 框架中,均使用 Data Mapper 模式注入 Repository 实例,然后再进行操作。
最后,NodeJS 中使用 Data Mapper 的 ORM 主要包括 Bookshelf、MikroORM、objection.js以及本文主角 Prisma 等。
实际上除了 ORM 与原生 SQL 以外,还有一种常用的数据库交互方式:Query Builder(以下简称 QB)。
QB 和 ORM 其实我个人觉得既有相同之处又有不同之处,但是挺容易搞混,比如 MQuery (MongoDB 的一个 Query Builder)的方法是这样的:
1mquery().find(match, function (err, docs) {2 assert(Array.isArray(docs));3});4
5mquery().findOne(match, function (err, doc) {6 if (doc) {7 // the document may not be found8 console.log(doc);9 }10});11
12mquery().update(match, updateDocument, options, function (err, result) {});
是不是看起来和 ORM 很像?但我们再看看其他的场景:
1mquery({ name: /^match/ })2 .collection(coll)3 .setOptions({ multi: true })4 .update({ $addToSet: { arr: 4 } }, callback);
在 ORM 中,通常不会存在这样的多个方法链式调用,而是通过单个方法+多个参数的方式来操作,这也是 Query Builder 和 ORM 的一个重要差异。再来看看 TypeORM 的 Query Builder 模式:
1import { getConnection } from "typeorm";2
3const user = await getConnection()4 .createQueryBuilder()5 .select("user")6 .from(User, "user")7 .where("user.id = :id", { id: 1 })8 .getOne();
以上的操作其实就相当于userRepo.find({ id: 1 })
,你可能会觉得 QB 的写法过于繁琐,但实际上这种模式要灵活的多,和 SQL 语句的距离也要近的多(你可以理解为每一个链式方法调用都会对最终生成的 SQL 语句进行一次操作)。
同时在部分情境(如多级级联下)中,Query Builder 反而是代码更简洁的那一方,如:
1const selectQueryBuilder = this.executorRepository2 .createQueryBuilder("executor")3 .leftJoinAndSelect("executor.tasks", "tasks")4 .leftJoinAndSelect("executor.relatedRecord", "records")5 .leftJoinAndSelect("records.recordTask", "recordTask")6 .leftJoinAndSelect("records.recordAccount", "recordAccount")7 .leftJoinAndSelect("records.recordSubstance", "recordSubstance")8 .leftJoinAndSelect("tasks.taskSubstance", "substance");
以上代码构建了一个包含多张表的级联关系的 Query Builder。
级联关系如下:
- Executor
- tasks -> Task
- relatedRecord -> Record
- Task
- substances -> Substance
- Record
- recordTask -> Task
- recordAccount -> Account
- recordSubstance -> Substance
再看一个比较主流的 Query Builder knex,我是在尝鲜strapi的过程中发现的,strapi 底层依赖于 knex 去进行数据库交互以及连接池相关的功能,knex 的使用大概是这样的:
1const knex = require("knex")({2 client: "sqlite3",3 connection: {4 filename: "./data.db",5 },6});7
8try {9 await knex.schema10 .createTable("users", (table) => {11 table.increments("id");12 table.string("user_name");13 })14 .createTable("accounts", (table) => {15 table.increments("id");16 table.string("account_name");17 table.integer("user_id").unsigned().references("users.id");18 });19
20 const insertedRows = await knex("users").insert({ user_name: "Tim" });21
22 await knex("accounts").insert({23 account_name: "knex",24 user_id: insertedRows[0],25 });26
27 const selectedRows = await knex("users")28 .join("accounts", "users.id", "accounts.user_id")29 .select("users.user_name as user", "accounts.account_name as account");30
31 const enrichedRows = selectedRows.map((row) => ({ ...row, active: true }));32} catch (e) {33 console.error(e);34}
可以看到 knex 的链式操作更进了一步,甚至可以链式创建多张数据库表。
接下来就到了我们本篇文章的主角:Prisma 。Prisma 对自己的定义仍然是 NodeJS 的 ORM,但个人感觉它比普通意义上的 ORM 要强大得多。这里放一张官方的图,来大致了解下 Prisma 和 ORM、SQL、Query Builder 的能力比较:
你也可以阅读方方老师翻译的这篇Why Prisma?来了解更多。
独特的 Schema 定义方式、比 TypeORM 更加严谨全面的 TS 类型定义(尤其是在级联关系中)、更容易上手和更贴近原生 SQL 的各种操作符等,很容易让初次接触的人欲罢不能(别说了,就是我)。
简单的介绍下这些特点:
Schema 定义,我们前面看到的 ORM 都是使用 JS/TS 文件来定义数据库表结构的,而 Prisma 不同,它使用.prisma
后缀的文件来书写独特的 Prisma Schema,然后基于 schema 生成表结构,VS Code 有 prisma 官方提供的高亮、语法检查插件,所以不用担心使用负担。
同时,这也就意味着围绕 Prisma Schema 会产生一批 generator 功能的生态,如 typegraphql-prisma 就能够基于 Prisma Schema 生成 TypeGraphQL 的 Class 定义,甚至还有 CRUD 的基本 Resolver,类似的还有palJS提供的基于 Prisma Schema 生成 Nexus 的类型定义与 CRUD 方法(所以说 GraphQL 和 Prisma 这种都是 SDL-First 的工具真的是天作之合)。
TypeGraphQL、Resolver 属于 GraphQL 相关的工具/概念,如果未曾了解过也不要紧。
一个简单的schema.prisma
可能是这样的:
1datasource db {2 provider = "sqlite"3 url = env("SINGLE_MODEL_DATABASE_URL")4}5
6generator client {7 provider = "prisma-client-js"8 output = "./client"9}10
11model Todo {12 id Int @id @default(autoincrement())13 title String14 content String?15 finished Boolean @default(false)16 createdAt DateTime @default(now())17 updatedAt DateTime @updatedAt18}
是不是感觉即使你没用过,但还是挺好看懂。
TS 类型定义,可以说 Prisma 的类型定义是全覆盖的,查询参数、操作符参数、级联参数、返回结果等等,比 TypeORM 的都更加完善。
更全面的操作符,如对字符串的查询,Prisma 中甚至提供了 contains、startsWith、endsWith 这种细粒度的操作符供过滤使用(而 TypeORM 中只能使用ILike这种方法来全量匹配)。(这些操作符的具体作用我们会在后面讲到)
在这一部分的最后,我们来简单的介绍下 Prisma 的使用流程,在正文中,我们会一步步详细介绍 Prisma 的使用,包括单表、多表级联以及 Prisma 与 GraphQL 的奇妙化学反应。
环境配置在下一节,这里我们只是先感受一下使用方式
首先,创建一个名为prisma
的文件夹,在内部创建一个schema.prisma
文件
如果你使用的是 VS Code,可以安装 Prisma 扩展来获得
.prisma
的语法高亮
在 schema 中定义你的数据库类型、路径以及你的数据库表结构,示例如下:
1model Todo {2 id Int @id @default(autoincrement())3 title String4}
运行prisma generate
命令,prisma 将为你生成Prisma Client
,内部结构是这样的:
在你的文件中导入Prisma Client
即可使用:
1import { PrismaClient } from "./prisma/client";2
3const prisma = new PrismaClient();4
5async function createTodo(title: string, content?: string) {6 const res = await prisma.todo.create({7 data: {8 title,9 content,10 },11 });12 return res;13}
每张表都会被存放在prisma.__YOUR_MODEL__
的命名空间下。
如果看完简短的介绍你已经感觉这玩意有点好玩了,那么在跟着本文完成实践后,你可能也会默默把手上的项目迁移到 Prisma(毕设也可以安排上)~
你可以在 Prisma-Article-Example 找到完整的示例,以下的例子我们会从一个空文件夹开始。
创建一个空文件夹,执行npm init -y
yarn、pnpm 同理
全局安装@prisma/cli
:npm install prisma -g
@prisma/cli
包已被更名为prisma
全局安装
@prisma/cli
是为了后面执行相关命令时方便些~
安装必要的依赖:
1npm install @prisma/client sqlite3 prisma -S2npm install typescript @types/node nodemon ts-node -D
安装
prisma
到文件夹时会根据你的操作系统下载对应的 Query Engine:
执行prisma version
,确定安装成功。
执行prisma init
,初始化一个 Prisma 项目(这个命令的侵入性非常低,只会生成prisma
文件夹和.env
文件,如果.env
文件已经存在,则会将需要的环境变量追加到已存在的文件)。
查看.env
文件
1# Environment variables declared in this file are automatically made available to Prisma.2# See the documentation for more detail: https://pris.ly/d/prisma-schema#using-environment-variables3
4# Prisma supports the native connection string format for PostgreSQL, MySQL and SQLite.5# See the documentation for all the connection string options: https://pris.ly/d/connection-strings6
7DATABASE_URL="postgresql://johndoe:randompassword@localhost:5432/mydb?schema=public"
你会发现这里的数据库默认使用的是 postgresql,在本文中为了降低学习成本,我们全部使用 SQLite 作为数据库,因此需要将变量值修改为file:../demo.sqlite
如果你此前没有接触过 SQLite,可以理解为这是一个能被当作数据库读写的文件(
.sqlite
后缀),因此使用起来非常容易,也正是因为它是文件,所以需要将DATABASE_URL
这一变量改为file://
协议。
同样的,在 Prisma Schema 中我们也需要修改数据库类型为sqlite
:
1// This is your Prisma schema file,2// learn more about it in the docs: https://pris.ly/d/prisma-schema3
4datasource db {5 provider = "sqlite"6 url = env("DATABASE_URL")7}8
9generator client {10 provider = "prisma-client-js"11}
在上面的 Prisma Schema 中,我们只定义了 datasource 和 generator,它们分别负责定义使用的数据库配置和客户端生成的配置,举例来说,默认情况下 prisma 生成的 client 会被放置在 node_modules 下,导入时的路径也是import { PrismaClient } from "@prisma/client"
,但你可以通过client.output
命令更改生成的 client 位置。
1generator client {2 provider = "prisma-client-js"3 output = "./client"4}
这一命令会使得 client 被生成到prisma
文件夹下,如:
将 client 生成到对应的 prisma 文件夹下这一方式使得在 monorepo(或者只是多个文件夹的情况)下,每个项目可以方便的使用不同配置的 schema 生成的 client。
我们在 Prisma Schema 中新增数据库表结构的定义:
1datasource db {2 provider = "sqlite"3 url = env("SINGLE_MODEL_DATABASE_URL")4}5
6generator client {7 provider = "prisma-client-js"8 output = "./client"9}10
11model Todo {12 id Int @id @default(autoincrement())13 title String14 content String?15 finished Boolean @default(false)16 createdAt DateTime @default(now())17 updatedAt DateTime @updatedAt18}
简单解释下相关语法:
?
意味着这一字段是可选的。@id
意为标识此字段为主键,@default()
意为默认值,autoincrement
与now
为 prisma 内置的函数,分别代表自增主键与字段写入时的时间戳,类似的内置函数还有 uuid、cuid 等。现在你可以生成客户端了,执行prisma generate
:
还没完,我们的数据库文件(即 sqlite 文件)还没创建出来,执行prisma db push
这个命令也会执行一次
prisma generate
,你可以使用--skip-generate
跳过这里的 client 生成。
现在根目录下就出现了demo.sqlite
文件。
在根目录下创建 index.ts:
1// index.ts2import { PrismaClient } from "./prisma/client";3
4const prisma = new PrismaClient();5
6async function main() {7 console.log("Prisma!");8}9
10main();
从使用方式你也可以看出来
PrismaClient
实际上是一个类,所以你可以继承这个类来进行很多扩展操作,在后面我们会提到。
在开始使用前,为了后续学习的简洁,我们使用nodemon
+ ts-node
,来帮助我们在 index.ts 发生变化时自动重新执行。
1{2 "name": "Prisma2-Explore",3 "restartable": "r",4 "delay": "500",5 "ignore": [".git", "node_modules/**", "/prisma/*"],6 "verbose": true,7 "execMap": {8 "": "node",9 "js": "node --harmony",10 "ts": "ts-node "11 },12 "watch": ["./**/*.ts"]13}
并将启动脚本添加到 package.json:
1{2 "scripts": {3 "start": "nodemon index.ts"4 }5}
执行npm start
:
接下来就到了正式使用环节,上面的代码只是一个简单的开发工作流示范,本文接下来的部分不会使用到(但是你可以基于这个工作流自己进一步的探索 Prisma)。
在接下来,你所需要的相关环境我已经准备完毕,见Prisma-Article-Example,clone 仓库到本地,运行配置完毕的 npm scripts 即可。在这里简单的介绍下项目中的 npm scripts,如果在阅读完毕本部分内容后觉得意犹未尽,可以使用这些 scripts 直接运行其他部分如多表、GraphQL 相关的示例。简单介绍部分 scripts:
yarn flow
:从零开始完整的执行 生成客户端 - 构建项目 - 执行构建产物 的流程。yarn dev:**
:在开发模式下运行项目,文件变化后重启进程。yarn generate:**
:为项目生成 Prisma Client。yarn gen:client
来为所有项目生成 Prisma Client。yarn setup:**
:为构建完毕的项目生成 SQLite 文件。yarn invoke:**
:执行构建后的 JS 文件。yarn setup
执行所有构建后的 JS 文件。本部分(Prisma 单表示例)的代码见 single-model,相关的命令包括:
1$ yarn dev:single2$ yarn generate:single3$ yarn setup:single4$ yarn invoke:single
在开始下文的 CRUD 代码讲解时,最好首先运行起来项目。首先执行yarn generate:single
,生成 Prisma Client,然后再yarn dev:single
,进入开发模式,如下:
根据前面已经提到的使用方式,首先引入 Prisma Client 并实例化:
1import { PrismaClient } from "./prisma/client";2
3const prisma = new PrismaClient();
Prisma 将你的表类(Table Class)挂载在prisma.MODEL
下,MODEL
值直接来自于schema.prisma
中的 model 名称,如本例是Todo
,那么就可以在prisma.todo
下获取到相关的操作方法:
因此,简单的 CRUD 完全可以直接照着 API 来,
创建:
1async function createTodo(title: string, content?: string) {2 const res = await prisma.todo.create({3 data: {4 title,5 content: content ?? null,6 },7 });8 return res;9}
create 方法接受两个参数:
String?
),其类型就为string|null
,所以需要使用??
语法来照顾参数未传入的情况。select.id
为 false,那么 create 方法的返回值对象中就不会包含 id 这一属性。这一参数在大部分 prisma 方法中都包含。读取:
1async function getTodoById(id: number) {2 const res = await prisma.todo.findUnique({3 where: { id },4 });5 return res;6}
findUnique 方法类似于 TypeORM 中的 findOne 方法,都是基于主键查询,在这里将查询条件传入给 where 参数。
读取所有:
1async function getTodos(status?: boolean) {2 const res = await prisma.todo.findMany({3 orderBy: [{ id: "desc" }],4 where: status5 ? {6 finished: status,7 }8 : {},9 select: {10 id: true,11 title: true,12 content: true,13 createdAt: true,14 },15 });16 return res;17}
在这里我们额外传入了 orderBy 方法来对返回的查询结果进行排序,既然有了排序,当然也少不了分页。你还可以传入cursor
、skip
、take
等参数来完成分页操作。
cursor-based 与 offset-based 实际上是两种不同的分页方式。
类似的,更新操作:
1async function updateTodo(2 id: number,3 title?: string,4 content?: string,5 finished?: boolean6) {7 const origin = await prisma.todo.findUnique({8 where: { id },9 });10
11 if (!origin) {12 throw new Error("Item Inexist!");13 }14
15 const res = await prisma.todo.update({16 where: {17 id,18 },19 data: {20 title: title ?? origin.title,21 content: content ?? origin.content,22 finished: finished ?? origin.finished,23 },24 });25 return res;26}
这里执行的是在未查询到主键对应的数据实体时抛出错误,你也可以使用 upsert 方法来在数据实体不存在时执行创建。
批量更新:
1async function convertStatus(status: boolean) {2 const res = await prisma.todo.updateMany({3 where: {4 finished: !status,5 },6 data: {7 finished: {8 set: status,9 },10 },11 });12
13 return res;14}
注意,这里我们使用 set 属性,来直接设置 finished 的值。这一方式和直接设置其为 false 是效果一致的,如果这里是个 number 类型,那么除了 set 以外,还可以使用 increment、decrement、multiply 以及 divide 方法。
最后是删除操作:
1async function deleteTodo(id: number) {2 const res = await prisma.todo.delete({3 where: { id },4 });5 return res;6}7
8async function clear() {9 const res = await prisma.todo.deleteMany();10 return res;11}
你可以自由的在以上这些例子以外,借助良好的 TS 类型提示花式探索 Prisma 的 API,也可以提前看看其它部分的例子来早一步感受 Prisma 的强大能力。
以上使用到的 Prisma 方法(如 create)与操作符(如 set)只是一小部分,目的只是为了让你大致感受下 Prisma 与其他传统 ORM 相比新奇的使用方式。在下篇中,我们将会介绍: