Drizzle 与 Nile 数据库的结合

本教程演示了如何在 Nile 数据库 中使用 Drizzle ORM。Nile 是 Postgres 的子集,针对多租户应用进行了重新设计。

本教程将演示如何将 Drizzle 与 Nile 的虚拟租户数据库结合使用,以开发安全、可扩展的多租户应用。

我们将逐步讲解如何构建此示例应用。如果你想查看完整的示例,可以查看其 Github 仓库

This guide assumes familiarity with:
  • 你应该已经安装了 Drizzle ORM 和 Drizzle 套件。你可以通过运行以下命令来实现:
npm
yarn
pnpm
bun
npm i drizzle-orm -D drizzle-kit
  • 你应该已经安装了用于管理环境变量的 dotenv 包。了解更多关于此包 此处 的信息
npm
yarn
pnpm
bun
npm i dotenv
  • 你应该已经安装了用于连接 Postgres 数据库的 node-postgres 包。了解更多关于此包 此处 的信息
npm
yarn
pnpm
bun
npm i node-postgres
  • 你应该已经安装了用于 Web 框架的 express 包。了解更多关于 express 此处 的信息
npm
yarn
pnpm
bun
npm i express
  • 本指南使用 AsyncLocalStorage 管理租户上下文。如果你的框架或运行时不支持 AsyncLocalStorage,你可以参考 Drizzle<>Nile 文档了解其他选项。

设置 Nile 和 Drizzle ORM

注册 Nile 并创建数据库

如果你还没有注册 Nile,请注册并按照应用说明创建一个新的数据库。

获取数据库连接字符串

在左侧边栏菜单中,选择““设置””选项,点击 Postgres 徽标,然后点击““生成凭证””。复制连接字符串并将其添加到项目中的 .env 文件中:

NILEDB_URL=postgres://youruser:yourpassword@us-west-2.db.thenile.dev:5432:5432/your_db_name

将 Drizzle ORM 连接到你的数据库

src/db 目录中创建一个 db.ts 文件并设置数据库配置:

src/db/db.ts
import { drizzle } from 'drizzle-orm/node-postgres';
import dotenv from "dotenv/config";
import { sql } from "drizzle-orm";
import { AsyncLocalStorage } from "async_hooks";

export const db = drizzle(process.env.NILEDB_URL);
export const tenantContext = new AsyncLocalStorage<string | undefined>();

export function tenantDB<T>(cb: (tx: any) => T | Promise<T>): Promise<T> {
  return db.transaction(async (tx) => {
    const tenantId = tenantContext.getStore();
    console.log("executing query with tenant: " + tenantId);
    // if there's a tenant ID, set it in the transaction context
    if (tenantId) {
      await tx.execute(sql`set local nile.tenant_id = '${sql.raw(tenantId)}'`);
    }

    return cb(tx);
  }) as Promise<T>;
}

设置 Drizzle 配置文件

Drizzle 配置 - Drizzle 套件 使用的配置文件,包含有关数据库连接、迁移文件夹和模式文件的所有信息。

在项目根目录中创建一个 drizzle.config.ts 文件并添加以下内容:

drizzle.config.ts
import 'dotenv/config';
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
  out: './drizzle',
  schema: './src/db/schema.ts',
  dialect: 'postgresql',
  dbCredentials: {
    url: process.env.NILEDB_URL!,
  },
});

自检 Nile 数据库

Nile 数据库具有内置表。其中最重要的是 tenants 表,它用于创建和管理租户。为了在我们的应用中使用此表,我们将使用 Drizzle Kit CLI 生成包含此架构的架构文件。

npx drizzle-kit pull

自省的结果将是一个 schema.ts 文件、一个包含数据库架构快照的 meta 文件夹、一个包含迁移的 SQL 文件以及一个用于 关系查询relations.ts 文件。

We recommend transferring the generated code from drizzle/schema.ts and drizzle/relations.ts to the actual schema file. In this guide we transferred code to src/db/schema.ts. Generated files for schema and relations can be deleted. This way you can manage your schema in a more structured way.

 ├ 📂 drizzle
 │ ├ 📂 meta
 │ ├ 📜 migration.sql
 │ ├ 📜 relations.ts ────────┐
 │ └ 📜 schema.ts ───────────┤
 ├ 📂 src                    │ 
 │ ├ 📂 db                   │
 │ │ ├ 📜 relations.ts <─────┤
 │ │ └ 📜 schema.ts <────────┘
 │ └ 📜 index.ts         
 └ …

以下是生成的 schema.ts 文件的示例:

src/db/schema.ts
// table schema generated by introspection
import { pgTable, uuid, text, timestamp, varchar, vector, boolean } from "drizzle-orm/pg-core"
import { sql } from "drizzle-orm"

export const tenants = pgTable("tenants", {
	id: uuid().default(sql`public.uuid_generate_v7()`).primaryKey().notNull(),
	name: text(),
	created: timestamp({ mode: 'string' }).default(sql`LOCALTIMESTAMP`).notNull(),
	updated: timestamp({ mode: 'string' }).default(sql`LOCALTIMESTAMP`).notNull(),
	deleted: timestamp({ mode: 'string' }),
});

创建其他表

除了内置表之外,我们的应用还需要一些表来存储其数据。我们会将它们添加到之前生成的 src/db/schema.ts 中,因此该文件将如下所示:

src/db/schema.ts
// table schema generated by introspection
import { pgTable, uuid, text, timestamp, varchar, vector, boolean } from "drizzle-orm/pg-core"
import { sql } from "drizzle-orm"

export const tenants = pgTable("tenants", {
	id: uuid().default(sql`public.uuid_generate_v7()`).primaryKey().notNull(),
	name: text(),
	created: timestamp({ mode: 'string' }).default(sql`LOCALTIMESTAMP`).notNull(),
	updated: timestamp({ mode: 'string' }).default(sql`LOCALTIMESTAMP`).notNull(),
	deleted: timestamp({ mode: 'string' }),
});

export const todos = pgTable("todos", {
	id: uuid().defaultRandom(),
	tenantId: uuid("tenant_id"),
	title: varchar({ length: 256 }),
	estimate: varchar({ length: 256 }),
	embedding: vector({ dimensions: 3 }),
	complete: boolean(),
});

应用更改到数据库

You can directly apply changes to your database using the drizzle-kit push command. This is a convenient method for quickly testing new schema designs or modifications in a local development environment, allowing for rapid iterations without the need to manage migration files:

npx drizzle-kit push

Read more about the push command in documentation.

Tips

Alternatively, you can generate migrations using the drizzle-kit generate command and then apply them using the drizzle-kit migrate command:

Generate migrations:

npx drizzle-kit generate

Apply migrations:

npx drizzle-kit migrate

Read more about migration process in documentation.

初始化 Web 应用

现在我们已经设置了 Drizzle 连接到 Nile,并且我们的架构已经到位,我们可以在多租户 Web 应用中使用它们。本例中使用 Express 作为 Web 框架,但 Nile 和 Drizzle 可以在任何 Web 框架中使用。

为了简化示例,我们将在单个文件中实现 Web 应用。 - src/app.ts。我们将首先初始化 Web 应用:

src/app.ts
import express from "express";
import { tenantDB, tenantContext, db } from "./db/db";
import {
  tenants as tenantSchema,
  todos as todoSchema,
} from "./db/schema";
import { eq } from "drizzle-orm";

const PORT = process.env.PORT || 3001;

const app = express();
app.listen(PORT, () => console.log(`Server is running on port ${PORT}`));
app.use(express.json());

初始化租户感知中间件

接下来,我们将在示例中添加中间件。此中间件从路径参数中获取租户 ID 并将其存储在 AsyncLocalStorage 中。我们在 src/db/index.ts 中创建的 tenantDB 封装器在执行查询时使用此租户 ID 设置 nile.tenant_id,从而保证查询将针对此租户的虚拟数据库执行。

src/app.ts
// set the tenant ID in the context based on the URL parameter
app.use('/api/tenants/:tenantId/*', (req, res, next) => {
  const tenantId = req.params.tenantId;
  console.log("setting context to tenant: " + tenantId);
  tenantContext.run(tenantId, next);
});

此示例从路径参数获取租户 ID,但通常也会在标头(例如 x-tenant-id)或 Cookie 中设置租户 ID。

添加路由

最后,我们需要添加一些用于创建和列出租户和待办事项的路由。请注意,我们使用 tenantDB 封装器连接到租户的虚拟数据库。另请注意,在 app.get("/api/tenants/:tenantId/todos" 中,我们无需在查询中指定 where tenant_id=...。这是因为我们被路由到该租户的数据库,并且查询无法返回任何其他租户的数据。

src/app.ts
// create new tenant
app.post("/api/tenants", async (req, res) => {
  try {
    const name = req.body.name;
    var tenants: any = null;
    tenants = await tenantDB(async (tx) => {
        return await tx.insert(tenantSchema).values({ name }).returning();
    });
    res.json(tenants);
  } catch (error: any) {
    console.log("error creating tenant: " + error.message);
    res.status(500).json({message: "Internal Server Error",});
  }
});

// return list of tenants
app.get("/api/tenants", async (req, res) => {
  let tenants: any = [];
  try {
      tenants = await tenantDB(async (tx) => {
        return await tx.select().from(tenantSchema);
      });
    res.json(tenants);
  } catch (error: any) {
    console.log("error listing tenants: " + error.message);
    res.status(500).json({message: "Internal Server Error",});
  }
});

// add new task for tenant
app.post("/api/tenants/:tenantId/todos", async (req, res) => {
  try {
    const { title, complete } = req.body;
    if (!title) {
      res.status(400).json({message: "No task title provided",});
    }
    const tenantId = req.params.tenantId;

    const newTodo = await tenantDB(async (tx) => {
      return await tx
        .insert(todoSchema)
        .values({ tenantId, title, complete })
        .returning();
    });
    // return without the embedding vector, since it is huge and useless
    res.json(newTodo);
  } catch (error: any) {
    console.log("error adding task: " + error.message);
    res.status(500).json({message: "Internal Server Error",});
  }
});

// update tasks for tenant
// No need for where clause because we have the tenant in the context
app.put("/api/tenants/:tenantId/todos", async (req, res) => {
  try {
    const { id, complete } = req.body;
    await tenantDB(async (tx) => {
      return await tx
        .update(todoSchema)
        .set({ complete })
        .where(eq(todoSchema.id, id));
    });
    res.sendStatus(200);
  } catch (error: any) {
    console.log("error updating tasks: " + error.message);
    res.status(500).json({message: "Internal Server Error",});
  }
});

// get all tasks for tenant
app.get("/api/tenants/:tenantId/todos", async (req, res) => {
  try {
    // No need for a "where" clause here because we are setting the tenant ID in the context
    const todos = await tenantDB(async (tx) => {
      return await tx
        .select({
          id: todoSchema.id,
          tenant_id: todoSchema.tenantId,
          title: todoSchema.title,
          estimate: todoSchema.estimate,
        })
        .from(todoSchema);
    });
    res.json(todos);
  } catch (error: any) {
    console.log("error listing tasks: " + error.message);
    res.status(500).json({message: error.message,});
  }
});

试试看!

你现在可以运行新的 Web 应用了:

npx tsx src/app.ts

并使用 curl 尝试你刚刚创建的路由:

# create a tenant
curl --location --request POST 'localhost:3001/api/tenants' \
--header 'Content-Type: application/json' \
--data-raw '{"name":"my first customer"}'

# get tenants
curl  -X GET 'http://localhost:3001/api/tenants'

# create a todo (don't forget to use a real tenant-id in the URL)
curl  -X POST \
  'http://localhost:3001/api/tenants/108124a5-2e34-418a-9735-b93082e9fbf2/todos' \
  --header 'Content-Type: application/json' \
  --data-raw '{"title": "feed the cat", "complete": false}'

# list todos for tenant (don't forget to use a real tenant-id in the URL)
curl  -X GET \
  'http://localhost:3001/api/tenants/108124a5-2e34-418a-9735-b93082e9fbf2/todos'

项目文件结构

这是项目的文件结构。在 src/db 目录中,我们有与数据库相关的文件,包括 db.ts 中的连接和 schema.ts 中的模式定义。迁移和自省生成的文件位于 ./drizzle 中。

📦 <project root>
 ├ 📂 src
 │   ├ 📂 db
 │   │  ├ 📜 db.ts
 │   │  └ 📜 schema.ts
 │   └ 📜 app.ts
 ├ 📂 drizzle
 │   ├ 📂 meta
 │   │  ├ 📜 _journal.json
 │   │  └ 📜 0000_snapshot.json
 │   ├ 📜 relations.ts
 │   ├ 📜 schema.ts
 │   └ 📜 0000_watery_spencer_smythe.sql
 ├ 📜 .env
 ├ 📜 drizzle.config.ts
 └ 📜 package.json