ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

个人项目五件套:Monorepo、pnpm、Turborepo、Prisma与Docker实战

2026/9/20 14:23:08 拓冰建站 浏览量
个人项目五件套:Monorepo、pnpm、Turborepo、Prisma与Docker实战 我做过不少个人项目早期最头疼的永远是那几件事代码分散在好几个仓库里改个接口要两边同步依赖版本一多就乱构建从零开始慢得让人不想动。后来我把整套技术栈理了一遍用 Monorepo 把代码统一收口pnpm workspace 管依赖Turborepo 做任务调度和缓存Prisma 管数据库层再用 Docker 把交付环境固定下来才算找到一个真正省心的组合。这篇内容不是科普五件套分别是什么而是聚焦它们如何在同一个个人项目里协作哪些事情该谁来干、边界画在哪、配置怎么落、Dockerfile 怎么写才不踩 Prisma 二进制和 workspace 链接的坑。我会连带把实际踩过的坑和排查过程一起写出来项目结构可以照着抄改一改就能用。1. 为什么是这五件套先理清边界1.1 各自解决什么问题别把它们当成一个整体很多教程喜欢把 Monorepo、pnpm、Turbo、Prisma、Docker 打包成一个概念来讲结果新手搭完以后根本分不清哪个报错该找谁。我自己一开始也是这状态直到把它们的职责边界在脑子里划清楚才真正用顺了。Monorepo代码组织策略解决“多个项目放哪里、怎么共享代码”的问题。pnpm workspace依赖管理工具解决“多个包之间如何安装依赖、如何本地联动”的问题。Turborepo任务编排工具解决“哪些任务需要先跑、哪些可以跳过缓存”的问题。Prisma数据访问层解决“数据模型定义在哪、迁移怎么做、客户端怎么生成”的问题。Docker交付运行环境解决“本地能跑不算数别的地方也能一键跑起来”的问题。从依赖关系看是层层向下的Monorepo 提供目录和包结构pnpm 在这个结构上装依赖Turborepo 基于这些包执行脚本并生成缓存Prisma 是其中一个工作区的核心依赖Docker 再把最终的构建产物和运行环境一起打镜像。任何一个环节出问题都能通过这个分工定位。1.2 我为什么放弃多个仓库和过度拆分个人项目最初用多仓库最大的痛点是共享代码靠复制粘贴改动一个公共类型要在 API 和 Web 两个仓库里各改一遍版本号不一致还容易翻车。后来拆微服务又发现单人项目根本不需要那么重的服务治理光维护多个服务的部署配置就够累的。所以我把项目收敛成一个 Monorepo内部按“应用 包”来分。应用是真正能跑起来的服务比如 API 服务、Web 前端、后台任务包则是被应用共享的代码单元比如数据库访问层、公共配置、类型定义。这样一个仓库里改一次数据库层所有应用都能同步拿到新逻辑不需要发版等同步。要特别说明的是Monorepo 不是银弹它适合“关联性强、共享需求多、单人维护成本可控”的项目。如果你的多个子项目之间毫无关联塞进一个仓库反而会让 CI 变慢。对我来说一个中大型个人项目正好在甜区里收益远大于成本。2. 从零搭一个可运行的仓库骨架2.1 目录划分和 package 配置我的推荐目录结构是这样的my-project/ ├── apps/ │ ├── api/ # API 服务Express / Fastify / Nest 等 │ └── web/ # 前端Next.js / Vite ├── packages/ │ ├── db/ # Prisma schema、迁移文件、生成的 Client │ └── config/ # 共享的 eslint、tsconfig 等 ├── package.json ├── pnpm-workspace.yaml ├── turbo.json ├── docker-compose.yml └── .npmrcapps放应用packages放库这是最经典的分法。刚上手不需要整太复杂先把这两层立住后续再加新应用的时候往apps里加目录就行。每个包的package.json里的name建议用项目名/包名的格式例如myproject/db、myproject/api。这个命名有讲究后续pnpm --filter myproject/api ...可以直接用名字精确定位到包Turborepo的dependsOn也依赖这个命名来识别依赖关系。2.2 pnpm workspace 的配置与踩坑记录pnpm-workspace.yaml是整个依赖管理的锚点内容不长但特别关键packages: - apps/* - packages/*上面告诉 pnpmapps和packages下的一级子目录都是 workspace 包。这样执行pnpm add lodash --filter myproject/apipnpm 只给这个子包安装而公共依赖可以提升到根目录从根节点统一管理。这里有一个特别容易踩的坑pnpm 默认会对依赖的安装脚本做白名单校验。比如 Prisma 这类依赖需要在postinstall阶段自动执行prisma generate生成客户端的操作一旦没放行脚本安装完以后你会发现 Prisma Client 根本没生成应用一跑就报“Cannot find module prisma/client”。在 pnpm 新版本下可以在根目录package.json添加{ pnpm: { onlyBuiltDependencies: [ prisma/client, prisma, esbuild ] } }要理解这个机制可以把它想象成pnpm 默认有保留设置只有白名单里的依赖才允许跑安装脚本否则就是“装进来但不执行附带动作”。Prisma 编译生成原生查询引擎恰好就在这类依赖里。如果发现prisma generate没自动跑优先检查这里。3. Turborepo 接管任务调度缓存是最大红利3.1 pipeline 配置到底在表达什么Turborepo 的配置集中在turbo.json核心是一个叫pipeline的字段用于定义“任务怎么执行、哪些任务能缓存”。我先给一个实际用过的版本{ $schema: https://turbo.build/schema.json, globalDependencies: [.env], globalEnv: [NODE_ENV, DATABASE_URL], pipeline: { build: { dependsOn: [^build], outputs: [dist/**, .next/**, !.next/cache/**] }, dev: { cache: false, persistent: true }, lint: {}, test: { dependsOn: [build] } } }dependsOn里的^build是个非常有威力的标记。^表示“上游依赖包的 build 任务”。假设apps/api依赖packages/db当 Turborepo 执行apps/api的 build 时会先确保packages/db的 build 已经执行过。这个调度关系是自动推导的不用你手动写死顺序。outputs表示这个任务会产生哪些产物。Turborepo 会缓存这些产物以及运行日志下次如果输入没变直接跳过任务、恢复缓存。比如packages/db的 build 产物是dist/**只要 schema 和代码没变化后续所有依赖它的应用构建都不用重新执行数据层编译。globalEnv则声明哪些环境变量会影响缓存如果DATABASE_URL变了相关任务会破坏缓存重新执行这地方不小心漏了配置就会出现“改了环境变量但一直命中旧缓存”的诡异问题。3.2 开发、构建、部署三个阶段 turbo 的用法开发阶段通常不需要缓存所以我上面的dev设置了cache: false并且persistent: true。开发时热更新本来就快缓存反而可能拿旧结果关闭是明智的。如果启动多个服务的 watch 模式persistent让它不会因为“没有退出”而被 Turborepo 判定为异常。构建阶段才是 Turbo 的主场。跑全量构建的时候我通常只跑需要的应用pnpm turbo run build --filtermyproject/api这样只构建 API 应用及其依赖链不会把整个仓库全都编译一遍。在 CI 上全量构建也可以用pnpm turbo run build lint testTurbo 会自动按依赖拓扑排序比如先执行packages/db的 build再并行执行两个应用的 build比串行快很多。部署阶段更简单直接构建完镜像就行。配合 Docker 时Turborepo 还支持--dry-run查看任务依赖关系写得不对随时能发现。换包管理器或构建工具的场景下Turborepo 也兼容得很好我同事的项目还把它接到 npm workspaces 上照样能跑灵活性够高。4. Prisma 作为数据层模块化才是正解4.1 把 schema 放到 packages/db 里的意义很多人用 Prisma 时直接把 schema 放在某个业务应用里短期能跑但 Monorepo 场景下会很尴尬两个应用都要查用户表总不能各自维护一份 schema 吧把 Prisma 相关文件摘出来放到packages/db本身就是在做“数据层和应用层解耦”。在packages/db里目录结构大致是packages/db/ ├── prisma/ │ ├── schema.prisma │ └── migrations/ ├── src/ │ └── index.ts ├── package.json └── tsconfig.jsonschema.prisma定义数据模型和数据库连接。模型改动后在数据库里同步的方式是迁移本地开发用prisma migrate dev自动创建一次迁移文件并应用到数据库生产环境用prisma migrate deploy只应用已有迁移文件不会自动创建新的避免在交付环境里改动模型导致不可控。src/index.ts导出 PrismaClient 实例这个实例是跨模块共享的。如果每个文件都new PrismaClient()连接数会很高开发期没问题生产容易把数据库连接池打满。这个点在单体应用里也成立但在 Monorepo 里更容易被忽略因为不同应用各自有入口。4.2 generate 与 migrate 分开理解Prisma 有两个经常被混淆的动作generate和migrate。用生活化的类比来说generate是“根据数据模型生成代码客户端”类似前端根据接口文档自动生成了类型定义和调用函数它不碰数据库migrate是“根据模型改动去改数据库表结构”类似真的去执行 DDL 建表改字段。所以典型流程是改schema.prisma。本地执行pnpm --filter myproject/db exec prisma migrate dev --name add_user_table这一步同时完成“文件迁移 客户端生成”。把生成的客户端提交到 Git 或镜像中生产环境一定再跑一次prisma generate避免库里没带客户端导致运行时报错。生产环境启动前执行prisma migrate deploy只应用迁移文件不动代码生成。generate是一个偏构建期的操作所以它非常适合被写进 Docker 的构建阶段migrate deploy则偏运行时操作适合放在容器启动前执行。这两个动作千万别都放在启动命令里否则每次启动都生成客户端不仅慢还可能覆盖构建期生成好的版本。4.3 和 Docker 结合时最容易踩的坑原生二进制Prisma 不是纯 JS默认引擎类型包含了查询引擎二进制文件。这个二进制和 Node 版本、操作系统、CPU 架构强相关。本地是 macOS ARM生产是 Linux x64不重新安装对应引擎就会在容器启动时报找不到引擎或架构不匹配。在 Docker 里我通常明确配置engineType。如果是服务端要求轻量部署可以使用binary引擎它会把二进制文件独立出来不需要占用过多内存加载 WASM。如果是简单部署、追求速度用默认的library也可以但要注意镜像里必须存在对应系统依赖。一个更现实的问题是基础镜像选择。Prisma 二进制运行时依赖 OpenSSL如果直接用精简到极致的alpine镜像里面可能没有 Prisma 需要的那部分系统库典型报错是“Unable to find OpenSSL”或“libssl.so.3 not found”。我踩过一次之后老老实实改用node:20-bookworm-slim一次性解决了。此外在 Monorepo 里给 Prisma 打镜像不能把整个仓库塞进去那样镜像会非常大。正确思路是多阶段构建下一部分细说。5. 用 Docker 把整套东西封装成“点餐式”服务5.1 单个服务镜像的多阶段构建范式多阶段构建的意义就像“饭店点餐”前台只负责端菜上桌后厨的锅碗瓢盆和备菜过程不能全都摆到餐厅里。在 Docker 里构建阶段的“锅碗瓢盆”是编译工具链、源码、依赖缓存运行阶段只需要已经构建好的代码和运行时依赖这样镜像体积能小很多。下面是apps/api/Dockerfile的一个可参考版本# 第一阶段安装依赖并构建 FROM node:20-bookworm-slim AS builder WORKDIR /repo # 安装 pnpm 和构建所需工具 RUN corepack enable corepack prepare pnpmlatest --activate # 复制 monorepo 的清单文件 COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ COPY turbo.json ./ COPY apps/api/package.json ./apps/api/ COPY packages/db/package.json ./packages/db/ COPY packages/config/package.json ./packages/config/ # 安装依赖利用缓存层 RUN pnpm install --frozen-lockfile # 复制源码 COPY apps/api ./apps/api COPY packages/db ./packages/db COPY packages/config ./packages/config # 先对 db 包执行 prisma generate RUN pnpm --filter myproject/db exec prisma generate # 构建应用 RUN pnpm turbo run build --filtermyproject/api # 第二阶段精简运行环境 FROM node:20-bookworm-slim AS runner WORKDIR /app ENV NODE_ENVproduction # 复制构建产物和运行所需依赖 COPY --frombuilder /repo/apps/api/dist ./dist COPY --frombuilder /repo/node_modules/.pnpm ./node_modules/.pnpm COPY --frombuilder /repo/apps/api/node_modules ./node_modules COPY --frombuilder /repo/packages/db/prisma ./prisma COPY --frombuilder /repo/packages/db/node_modules/.prisma ./node_modules/.prisma # 启动前执行迁移再启动服务 CMD [sh, -c, pnpm --filter myproject/db exec prisma migrate deploy node dist/index.js]细看这个 Dockerfile有几个故意写成这样的地方。先复制package.json、pnpm-lock.yaml、pnpm-workspace.yaml再装依赖是为了充分利用 Docker 的层缓存。只要依赖清单没变后面哪怕源码改了pnpm install这层不会被重新执行构建会快非常多。运行阶段我保留了packages/db/prisma目录是因为生产环境执行prisma migrate deploy时必须读取迁移文件。如果只复制了编译后的客户端迁移文件缺失启动时讲道理会失败。5.2 Docker Compose 编排数据库与应用单应用镜像搞定后数据库、缓存和服务之间的互相配合我建议直接用docker-compose.yml串起来。下面是一个简化的组合version: 3.8 services: postgres: image: postgres:16-alpine restart: unless-stopped environment: POSTGRES_USER: app POSTGRES_PASSWORD: app POSTGRES_DB: app_db ports: - 5432:5432 volumes: - pgdata:/var/lib/postgresql/data healthcheck: test: [CMD-SHELL, pg_isready -U app] interval: 5s timeout: 5s retries: 10 api: build: context: . dockerfile: apps/api/Dockerfile environment: DATABASE_URL: postgresql://app:apppostgres:5432/app_db depends_on: postgres: condition: service_healthy ports: - 3000:3000这里有一个从真实事故里学到的点服务启动顺序不能单靠depends_on的简单行为因为那只是“先启动容器”不代表数据库已经准备好。给数据库上healthcheck并让 API 应用等到service_healthy状态再启动能很大程度减少“应用启动时数据库还没就绪”的报错也就是前面提到的depends_on条件。DATABASE_URL里用的是服务名postgres而不是localhost在容器网络中这是固定规则服务之间通过 compose 的服务名互相访问localhost指的是容器自己不是你本机。我第一次在容器里连接数据库时用localhost:5432卡了很长时间排查到最后才发现是这个问题。5.3 本地开发 vs 生产交付的两种组合开发和生产环境的跑法不一样可以分开处理本地开发时我推荐只把基础设施放进 Docker应用代码留在宿主机跑。这样改动代码不用重新打镜像热更新速度也快。命令大概是# 启动数据库等依赖服务 docker compose up -d postgres # 宿主机上跑应用开发模式 pnpm turbo run dev --filtermyproject/api本地开发时DATABASE_URL可以写成localhost:5432因为你跑的是宿主机进程不是容器进程。生产交付时执行整栈构建并启动docker compose up -d --buildCompose 会依次构建镜像、启动数据库健康检查、等服务就绪后再启动 API 应用整个过程是“点餐式”的服务员把菜端到桌上你只管吃完签字。相比手工执行docker run一长串参数这样的封装清晰可靠得多。6. 实操现场我的整个开发与部署流程6.1 从新建功能到上线的一次完整操作一次完整迭代的流程比单独看某个配置更直观。我以“给文章表增加一个 slug 字段”为例第一步在packages/db/prisma/schema.prisma中修改模型model Post { id Int id default(autoincrement()) title String slug String unique content String? createdAt DateTime default(now()) }第二步生成迁移并同步到本地数据库pnpm --filter myproject/db exec prisma migrate dev --name add_post_slug这条命令会生成一个新的迁移文件到packages/db/prisma/migrations/同时更新本地的 Prisma Client 和数据库表结构。第三步因为slug是unique如果表里已有历史数据migrate 时可能触发唯一约束冲突。Prisma 会生成一个包含CREATE UNIQUE INDEX的 SQL如果失败可以在生成的迁移文件里先写一段数据去重逻辑再执行prisma migrate dev。第四步在 API 应用里写代码通过新客户端查询import { prisma } from myproject/db; export async function getPostBySlug(slug: string) { return prisma.post.findUnique({ where: { slug } }); }第五步本地验证通过后提交代码构建镜像并发布docker compose up -d --build api容器启动命令会自动执行prisma migrate deploy应用新迁移然后启动服务。整个流程端到端跑下来不需要额外手工操作数据库。6.2 变量与密钥的传递在 Monorepo Docker 的组合里环境变量容易变成一个大坑因为不同工具的变量读取位置不完全一样。我现在的策略是分三层本地开发.env文件按需放在各个包目录下Prisma 默认读取packages/db/.envAPI 应用可以读取apps/api/.env。Docker Compose在 compose 文件里的environment直接注入例如数据库地址会设置为postgresql://app:apppostgres:5432/app_db。生产密钥不要写进 Git使用部署平台的密钥管理或者.env文件挂载到容器里。特别注意DATABASE_URL这个变量在本地和容器里指向不同地址。本地是localhost容器里是 compose 的服务名postgres。如果你同时有.env和 compose 环境变量优先搞清楚哪个在生效我在这个问题上踩过好几次最后统一规则本地统一用.env容器统一走 compose 的environment。7. 常见问题与排查技巧实录7.1 错误一览表错误现象原因解决办法PrismaClient is not a constructorprisma/client 未正确生成执行pnpm --filter myproject/db exec prisma generate检查onlyBuiltDependencies是否包含 prismaQuery engine library for current platform debian-openssl-3.0.x could not be found.基础镜像缺少 Prisma 原生二进制运行环境或引擎未重新安装使用node:20-bookworm-slim构建阶段务必重新prisma generateCant reach database server at db:5432应用启动太早数据库还没就绪或DATABASE_URL地址有误加 healthcheck depends_on.condition检查数据库服务名和端口Error: P3014 Prisma Migrate could not create the shadow databasemigrate dev需要 shadow database但数据库账号没有创建权限给开发用数据库账号足够权限或配置shadowDatabaseUrlpackage.json中找不到 workspace 包pnpm-workspace.yaml 的 glob 没写对检查packages/*是否匹配到目标目录仓库存放位置是否越界Docker 构建特别慢代码改动也要重装依赖依赖层缓存被破坏先复制 lockfile 和 package.json再安装依赖最后复制源码容器内执行 pnpm 提示 command not found运行阶段镜像没有 pnpm在 Dockerfile 里启用 corepack或者启动命令改用npx prisma migrate deploy也可以把迁移命令放到 entrypoint 脚本里Turborepo 缓存一直命中但产物是旧的outputs配置缺失或inputs没包含相关源码按实际产物目录补充outputs必要时声明对应inputs7.2 独家避坑技巧第一个技巧Docker 构建阶段一定不要省略prisma generate。我知道有人觉得本地构建过了镜像里带一份生成的客户端就行结果容器平台一换就崩。Prisma 生成器绑定的平台和二进制在 CI/容器里经常是不同的在 Dockerfile 的 builder 阶段显式执行一次成本低、效果直观。第二个技巧Compose 中启动命令不要直接写旧的package.jsonscripts 里的 launch 脚本。更好的做法是单独写一个 entrypoint 脚本比如#!/bin/sh set -e npx prisma migrate deploy node dist/index.js这个脚本可以挂载到容器里也可以 COPY 进镜像。好处是启动逻辑和 Dockerfile 解耦想在生产环境临时改东西也不用重新打镜像。第三个技巧如果数据库迁移时间较长应用容器启动可能因为等待时间过长而 fail可以让 Prisma 迁移脚本带重试逻辑。比如在 entrypoint 里做有限次重试for i in 1 2 3 4 5; do npx prisma migrate deploy break echo migrate failed, retry $i/5 sleep 3 done exec node dist/index.js这是我被线上环境坑过一次后加进来的后来再没出现过迁移阶段偶发失败导致整体启动失败的情况。第四个技巧尽量把packages/db作为独立包构建而不是在应用里直接引用相对路径../packages/db。这样每个包都能独立构建、独立测试、独立发版Turborepo 也能更好地判断缓存是否生效。如果图省事用了相对路径代码是能跑但构建依赖和缓存优化都打了折扣。第五个技巧数据库迁移文件一定要纳入版本管理。prisma/migrations目录里的文件记录的是数据库演进历史多人协作或换机器时可以无缝恢复这是数据库层最重要的资产之一。我见过不少人把 migrations 目录加进.gitignore后面环境重建时完全不知道表结构是怎么来的等于把项目的“数据库族谱”丢了。写在最后的一点实际体会这套组合用下来我最直观的感受是构建速度上来了环境问题变少了。Turborepo 在最顺的时候能跳过几乎所有无关任务pnpm 让依赖安装快且磁盘占用小Prisma 把数据模型的改动变成可追踪的迁移记录Docker 又让交付配置和环境彻底固定下来。中途踩坑不少尤其是 Prisma 二进制和 pnpm 脚本执行这两块但把它们逐个击破之后整个仓库变得非常顺。如果你也是单人维护一个多端项目我建议可以从“Monorepo pnpm Turbo”先起步等应用要部署了再把 Docker 加进来。Prisma 作为数据层越早模块化越好别等业务代码写多了再重构。后面如果你想接 CI/CD这套结构和缓存机制也能直接平移过去扩展空间留得很足。