掘金 后端 ( ) • 2024-04-22 15:53

之前的一篇文章,介绍了在Nestjs中如何引入Prisma ORM, 如何使用Docker部署。介绍了两种方式Docker部署的方式,即源码构建部署和pkg打包二进制部署。

Nestjs+Prisma ORM+pkg(一)—— Prisma的使用及Docker部署 - 掘金 (juejin.cn)

这篇文章主要讲解一下prisma采用的migration机制,如何来保持schema和数据库一致。以及在生产环境如何执行迁移脚本(migrations)以更好的部署和运维。

以下示例源码:mobiusy/prisma-example (github.com)

Migration

作用

为什么要有migration机制?

我们在日常开发过程中,随着版本的迭代,数据库会发生表的新增,修改等操作,这些操作如果由人工去管理变更,和去数据库执行,就太过于繁琐,且容易发生错误。

Getting started | Prisma Migrate

因此才需要由migration机制来维护这些变更,并且能智能的应用的数据库。这个机制能做到:

  1. 自动创建数据库
  2. 数据库增量更新
  3. 检查数据库定义和实际数据库是否一致
  4. 等...

演练

  1. 这次对之前的prisma.schema稍作改动, 新增字段birth, 在这里我们定义为可空字段:
model User {
  id       Int       @id @default(autoincrement())
  name     String
  email    String
  birth    String?   // 新增
  posts    Post[]
  comments Comment[]
}
  1. 生成迁移脚本
    prisma migrate dev命令通常在dev, staging环境中执行,它能够跟踪你的对数据库的改动,并自动的生成SQL迁移文件,然后应用到目标数据库。当一个迁移文件(migration)被应用到数据库的时候,_prisma_migrations表会被同时更新。
$ yarn prisma migrate dev                                                                                                                                                                          
yarn run v1.22.22
Environment variables loaded from .env
Prisma schema loaded from src\db\postgresql\schema.prisma
Datasource "db": PostgreSQL database "prisma-example", schema "public" at "192.168.3.2:5432"

√ Enter a name for the new migration: ... add_birth_to_user
Applying migration `20240422015141_add_birth_to_user`

The following migration(s) have been created and applied from new schema changes:

migrations/
  └─ 20240422015141_add_birth_to_user/
    └─ migration.sql

Your database is now in sync with your schema.

✔ Generated Prisma Client (v5.12.1) to .\node_modules@prisma\client in 128ms

生成的迁移脚本在目录src\db\postgresql\migrations\20240422015141_add_birth_to_user下,内容为:

-- AlterTable
ALTER TABLE "User" ADD COLUMN     "birth" TEXT;
  1. 将脚本应用到生产环境
    修改.env文件,将地址指向生产环境数据库
$ yarn prisma migrate deploy

此时数据库的改动已经应用到了生产数据库。

如何在生产环境使用migrate deploy

通过前面的演练我们知道,可以通过yarn prisma migrate deploy命令来应用迁移脚本(migrations),但这样直接在命令行中执行真的好吗?

考虑以下几个问题:

  1. 在哪里执行yarn prisma migrate deploy命令?在本地直连生产数据库,还是远程到生产环境服务器,并进入到代码环境执行?
  2. 若Docker化部署,是否具备条件执行上述命令?pkg打包成二进制可执行文件,是否能够执行?
  3. 谁来执行上述命令?开发还是运维?

官方给出的一种解决方案是在CI/CD中执行上述命令。那在不具备CI/CD的环境中怎么办?

我的方案

这里给出大家一种普适应更强的方案,即通过增加resetful接口,调用我们的服务代码,完成上述命令的执行。迁移脚本本身就包含在服务代码中,因此这种方式可以保证迁移脚本和服务的版本一致,并且能跟着服务(docker镜像)移动。

  1. 增加restful接口

执行命令yarn nest generate resource db

$ yarn nest generate resource db
yarn run v1.22.22
? What transport layer do you use? REST API
? Would you like to generate CRUD entry points? No
CREATE src/db/db.controller.ts (199 bytes)
CREATE src/db/db.controller.spec.ts (556 bytes)
CREATE src/db/db.module.ts (236 bytes)
CREATE src/db/db.service.ts (90 bytes)
CREATE src/db/db.service.spec.ts (450 bytes)
UPDATE package.json (2433 bytes)
UPDATE src/app.module.ts (381 bytes)
✔ Packages installed successfully.
Done in 97.22s.
  1. db/db.service.ts中增加方法migrateSchema
import { Injectable, Logger } from '@nestjs/common';
import { promisify } from 'node:util';
import { exec as execCb } from 'node:child_process';
import os from 'os';
import path from 'node:path';
import {
  copyFileSync,
  existsSync,
  mkdirSync,
  readdirSync,
  rmSync,
} from 'node:fs';
import { SchemaMigrationResult } from './dto/db.dto';

@Injectable()
export class DbService {
  private readonly logger = new Logger(DbService.name);
  private readonly PG_SCHEMA = 'schema.prisma';
  private readonly DB_SOURCE = 'postgresql';
  private readonly DB_DEST = 'db-tmp';
  constructor() {}

  /**
   * Run schema migration
   * @returns
   */
  async migrateScheme(): Promise<SchemaMigrationResult> {
    const exec = promisify(execCb);
    // 检查`${__dirname}//db`目录是否存在,如果存在,则复制到脚本到`${process.cwd()}/db-tmp`目录下
    const souceDir = path.join(__dirname, this.DB_SOURCE);
    const distDir = path.join(process.cwd(), this.DB_DEST);
    try {
      if (existsSync(distDir)) {
        rmSync(distDir, { recursive: true });
      }

      if (existsSync(souceDir)) {
        this.copyDir(souceDir, distDir);
      } else {
        const message = `souceDir ${souceDir} not exists!`;
        this.logger.error(message);
        throw new Error(message);
      }
    } catch (error) {
      throw new Error(`copyDir error: ${error.message}`);
    }

    const schemaFile = path.join(distDir, this.PG_SCHEMA);

    try {
      const { stdout, stderr } = await exec(
        `prisma migrate deploy --schema=${schemaFile}`,
        {
          env: {
            ...process.env,
          },
        },
      );

      this.logger.log({
        message: 'db migrate deploly result:',
        stdout,
        stderr,
      });

      return {
        stdout: stdout.split(os.EOL),
        stderr: stderr.split(os.EOL),
      };
    } finally {
      // clean up
      if (existsSync(distDir)) {
        rmSync(distDir, { recursive: true });
      }
    }
  }

  private copyDir(src, dest) {
    mkdirSync(dest, { recursive: true });

    const entries = readdirSync(src, { withFileTypes: true });

    for (const entry of entries) {
      const srcPath = path.join(src, entry.name);
      const destPath = path.join(dest, entry.name);

      if (entry.isDirectory()) {
        this.copyDir(srcPath, destPath);
      } else {
        copyFileSync(srcPath, destPath);
      }
    }
  }
}

新增dto\db.dto.ts文件

export class SchemaMigrationResult {
  stdout: string[];

  stderr: string[];
}
  1. 修改nest-cli.json文件,将sql、schema类型的文件在构建时放入dist目录
{
  "$schema": "https://json.schemastore.org/nest-cli",
  "collection": "@nestjs/schematics",
  "sourceRoot": "src",
  "compilerOptions": {
    "deleteOutDir": true,
    "assets": ["**/*.sql", "**/*.prisma"],
    "watchAssets": true
  }
}
  1. 重新打包成镜像,并运行
$ docker-compose up -d --build prisma-example
  1. 调用新增的接口测试

为了展示脚本执行的结果,调用接口前删除了prisma-example数据库。

以上流程走通了非pkg打包的场景,针对pkg打包成二进制文件的场景,还有一些额外的工作.

  1. 修改pkg.Dockerfile, 这是pkg打包所用的Dockerfile
FROM node:18.14-slim as builder
LABEL Author="mobiusy"

WORKDIR /build

# ENV PKG_CACHE_PATH /build/.pkg-cache
# RUN mkdir -p ${PKG_CACHE_PATH}/v3.4
# COPY fetched-v18.5.0-linux-x64 ${PKG_CACHE_PATH}/v3.4

RUN yarn config set registry https://registry.npmmirror.com

COPY package.json ./package.json
COPY yarn.lock ./yarn.lock

RUN yarn

COPY ./ ./

RUN yarn prisma generate && yarn build
RUN yarn pkg package.json

FROM node:18.14-slim as prod

LABEL Author="mobiusy"

RUN apt-get update
RUN apt-get install inetutils-ping -y
RUN apt-get install jq -y

# 设置时区
RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime  && echo 'Asia/Shanghai' >/etc/timezone

WORKDIR /opt/application

COPY --from=builder /build/package.json ./
# 提取nodejs项目中pakcage.json安装的prisma版本号,然后安装到全局
RUN prismaVersion=$(cat package.json | jq '.dependencies["prisma"]' | sed 's/"//g')
RUN npm install -g prisma@$prismaVersion
# 删除package.json文件
RUN rm -rf package.json

# 将生成的可执行文件copy到当前工作目录下
COPY --from=builder /build/nestjs-prisma-example ./


# 容器启动时执行的命令,类似npm run start
CMD ["./nestjs-prisma-example"]
  1. 重新打包成镜像,并运行
$ docker-compose up -d --build prisma-example-pkg
  1. 接口测试
    为了展示脚本执行的结果,调用接口前删除了prisma-example数据库。注意,这次端口已经变成了33200,通过二进制文件启动的容器服务端口

总结

文章介绍了:

  1. migrate devmigrate deploy命令的使用。
  2. 如何将migrate deploy命令集成到容器服务中,并以restful接口的形式暴露出来,方便部署运维。