ARTICLE DETAIL

建站实战干货

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

Turborepo 单体仓库中的 TypeORM 服务层与依赖注入:with-typeorm 示例实战指南

2026/9/19 21:37:42 拓冰建站 浏览量
Turborepo 单体仓库中的 TypeORM 服务层与依赖注入:with-typeorm 示例实战指南 Turborepo 单体仓库中的 TypeORM 服务层与依赖注入with-typeorm 示例实战指南【免费下载链接】turboBuild system optimized for JavaScript and TypeScript, written in Rust项目地址: https://gitcode.com/gh_mirrors/tu/turboexamples/with-typeorm是 Turborepo 官方仓库中的一个社区维护示例它演示了如何在 pnpm 管理的 Turborepo 单体仓库里用一个共享包repo/typeorm-service封装 TypeORM 数据访问层并通过一套基于reflect-metadata的自研依赖注入机制把服务以inject(TodoService)一行代码的形式注入到两个独立的 Next.js 应用中。读完本篇你将掌握该示例的目录结构、Entity/Repository/Service 三层组织的完整代码、自定义 DI 容器的实现原理以及数据库配置、Docker 环境准备和 vitest 测试验证的全套操作。一、with-typeorm 示例的整体结构这是一个社区维护的示例README 明确说明遇到问题请提 PR 修复而不是开 Issue。它使用 pnpm 工作区组织代码根目录 pnpm-workspace.yaml 将apps/*与packages/*全部纳入工作区# examples/with-typeorm/pnpm-workspace.yaml packages: - apps/* - packages/*示例包含的应用与包如下路径名称作用apps/docsdocs一个 Next.js 应用在服务端页面中直接查询 Todo 列表apps/webweb另一个 Next.js 应用提供 Todo 的 REST API 路由packages/uiui供web和docs共享的 React 组件库含Button、Card、Codepackages/eslint-configrepo/eslint-configESLint 配置包含eslint-config-next与eslint-config-prettierpackages/typescript-configrepo/typescript-config整个仓库复用的tsconfig.json预设base / nextjs / react-librarypackages/typeorm-servicerepo/typeorm-service核心基于 TypeORM 仓储模式的服务层通过依赖注入向两个应用提供能力根 package.json 声明了运行环境约束与常用脚本{ private: true, scripts: { build: turbo run build, dev: turbo run dev, lint: turbo run lint, check-types: turbo run check-types, format: prettier --write \**/*.{ts,tsx,md}\ }, devDependencies: { prettier: ^3.2.5, turbo: ^2.9.6 }, packageManager: pnpm8.15.5, engines: { node: 18 } }Turborepo 的任务定义在根 turbo.json 中注意build任务显式把.env与.env.*纳入哈希输入这意味着数据库凭据等环境变量变化会触发重新构建dev是持久任务且禁用缓存test任务只缓存测试文件与 vitest 配置相关变化{ $schema: https://turborepo.dev/schema.json, tasks: { build: { dependsOn: [^build], inputs: [$TURBO_DEFAULT$, .env, .env.*], outputs: [.next/**, !.next/cache/**, !.next/dev/**] }, lint: { dependsOn: [^lint] }, check-types: {}, test: { cache: false, inputs: [**/*.test.ts, **/*.test.tsx, vitest.config.ts] }, dev: { cache: false, persistent: true } } }二、repo/typeorm-serviceEntity / Repository / Service 三层结构repo/typeorm-service是整个示例的核心。它的 package.json 有一个关键设计包的入口直接指向源码而非编译产物因此消费方Next.js 应用无需构建该包即可直接以 TypeScript 源码方式引用{ name: repo/typeorm-service, exports: { .: ./src/index.ts }, scripts: { lint: eslint src/ --max-warnings 0, test:watch: vitest, test: vitest run, check-types: tsc --noEmit }, peerDependencies: { typeorm: ^0.3.20 }, dependencies: { reflect-metadata: ^0.2.2, sql.js: ^1.10.3, typeorm: ^0.3.20 } }入口 index.ts 只对外导出三样东西注入函数inject、服务类TodoService和实体类型Todoexport { inject } from ./helper/di-container; export { TodoService } from ./domain/todo/todo.service; export { type Todo } from ./domain/todo/todo.entity;2.1 实体层todo.entity.tsTodo 实体 使用装饰器声明表todo包含自增主键、内容、完成状态和两个自动维护的时间戳列import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn, } from typeorm; Entity({ name: todo, }) export class Todo { PrimaryGeneratedColumn() id: number; Column({ nullable: false, length: 100, }) content: string; Column() complete: boolean; CreateDateColumn() createdAt: string; UpdateDateColumn() updatedAt: string; }2.2 仓储层todo.repository.tsTodoRepository 用自定义的Repository装饰器标记内部持有AppDataSource.getRepository(Todo)返回的原生 TypeORM 仓储并封装了五个基础数据访问方法import { Repository } from ../../helper/di-container; import { AppDataSource } from ../../orm-config; import { Todo } from ./todo.entity; Repository export class TodoRepository { private todoRepo AppDataSource.getRepository(Todo); async findById(id: Todo[id]) { return this.todoRepo.findOneBy({ id }); } async findAll() { return this.todoRepo.find({}); } async insert(content: Todo[content]) { return this.todoRepo.save({ content, complete: false }); } async update(id: Todo[id]) { return this.todoRepo.update(id, { complete: true }); } async delete(todo: Todo[id]) { return this.todoRepo.delete(todo); } }注意这里导入的Repository并不是 TypeORM 自带的仓储基类而是本包自研 DI 容器暴露的装饰器——它是把仓储类“注册进依赖注入系统”的标记。2.3 服务层todo.service.tsTodoService 用InjectAble装饰器标记通过构造函数声明对TodoRepository的依赖——这就是 README 中展示的“构造函数注入”模式import { InjectAble } from ../../helper/di-container; import { type Todo } from ./todo.entity; import { TodoRepository } from ./todo.repository; InjectAble export class TodoService { constructor(private todoRepo: TodoRepository) {} async findById(id: Todo[id]) { return this.todoRepo.findById(id); } async findAll() { return this.todoRepo.findAll(); } async deleteById(id: Todo[id]) { return this.todoRepo.delete(id); } async add(content: Todo[content]) { return this.todoRepo.insert(content); } async complete(id: Todo[id]) { return this.todoRepo.update(id); } }Service 不直接碰数据库只编排 Repository 的方法这样职责分离清晰也便于在单元测试中用 mock 仓储替换真实实现。三、自研依赖注入机制的源码剖析DI 机制全部实现在 di-container.ts 与 reflect-factory.ts 两个文件里不到百行代码但覆盖了注册、实例化、单例缓存和数据库懒初始化四个环节。3.1 基于 reflect-metadata 的注册表reflectFactory把Reflect.getMetadata / defineMetadata / hasMetadata三个元数据 API 封装成get / set / has三件套工厂函数按 key 生成独立的“类属性存储”export const reflectFactory Value unknown, Target extends object object( key: string, ) ({ get: (target: Target, defaultValue?: Value) _getValue(key, target) ?? defaultValue, set: (target: Target, value: Value) _setValue(key, target, value), has: (target: Target) _has(key, target), });di-container.ts用它建立两张表Container reflectFactory(dependency-container)类 → 已实例化单例 的缓存InjectAbleStorage reflectFactoryFunction | true(injectable)类 → 是否可注入以及工厂函数的注册表。3.2 inject()读取构造依赖并递归解析inject(TodoService)的完整流程是校验类是否注册为可注入 → 通过 TypeScript 装饰器元数据design:paramtypes读取构造函数参数类型 → 递归注入每个依赖已缓存则直接取单例→new Target(...args)构造实例 → 写回Container实现单例语义。关键源码di-container.ts#L30-L41export const inject T(Target: ClassT): T { const use InjectAbleStorage.get(Target); if (!use) throw new Error(${Target.name || Target} is not injectable); const dependencise: Class[] Reflect.getMetadata(design:paramtypes, Target) || []; const args: unknown[] dependencise.map( (C: Class) Container.get(C) ?? inject(C), ); const Component new Target(...args); Container.set(Target, typeof use function ? use(Component) : Component); return Container.get(Target) as T; };其中design:paramtypes元数据的产生依赖编译配置。服务包的 tsconfig.json 显式开启了这两个选项这是整个 DI 机制能工作的前提{ extends: repo/typescript-config/base.json, compilerOptions: { emitDecoratorMetadata: true, experimentalDecorators: true, esModuleInterop: true, strictNullChecks: true } }3.3 ProxyForDatabaseInitialize数据库连接的懒初始化Repository装饰器的特殊之处在于它向注册表写入的不是布尔值而是一个包装函数ProxyForDatabaseInitialize。该函数用Proxy拦截实例上所有方法的调用di-container.ts#L6-L24const ProxyForDatabaseInitialize T extends object(obj: T): T { return new Proxy(obj, { get(t, k) { const value t[k as keyof typeof t]; if (typeof value ! function) return value; return new Proxy(value, { apply(fn, self, args) { const result fn.apply(self, args); if (!AppDataSource.isInitialized result instanceof Promise) return result.catch(() AppDataSource.initialize().then(() fn.apply(self, args)), ); return result; }, }); }, }); };其效果是应用代码无需显式调用AppDataSource.initialize()。第一次数据访问若因连接未初始化而失败Proxy 捕获 Promise 拒绝后调用AppDataSource.initialize()并自动重试一次。这使得在 Next.js 的 Server Component 和 API Route 里可以直接inject(...)后立刻查询而不必关心连接生命周期。export function Repository(clazz: Class) { InjectAbleStorage.set(clazz, ProxyForDatabaseInitialize); } export function InjectAble(clazz: Class) { InjectAbleStorage.set(clazz, true); }四、在 Next.js 应用中使用服务层两个应用通过workspace:*依赖引用该服务包见 apps/web/package.json 的repo/typeorm-service: workspace:*然后以inject完成注入。4.1 服务端页面docs 应用apps/docs/app/page.tsx 是一个异步 Server Component在渲染时直接查询数据库并打印结果// examples/with-typeorm/apps/docs/app/page.tsx节选 import { inject, TodoService } from repo/typeorm-service; export default async function Page(): PromiseJSX.Element { const todoService inject(TodoService); const todoList await todoService.findAll(); console.log(JSON.stringify({ todoList }, null, 2)); return ( main className{styles.main} {/* ... 页面 UI ... */} /main ); }4.2 API 路由web 应用apps/web/app/api/todo/route.ts 中TodoService在模块顶层被注入一次然后供GET返回全部 Todo和POST新增 Todo复用// examples/with-typeorm/apps/web/app/api/todo/route.ts import { inject, type Todo, TodoService } from repo/typeorm-service; const todoService inject(TodoService); export async function GET() { const list await todoService.findAll(); return Response.json(list); } export async function POST(req: Request) { const res: PickTodo, content await req.json(); const entity await todoService.add(res.content); return Response.json(entity); }在 apps/web/app/api/todo/[id]/route.ts 中同一个单例服务进一步支撑了按 ID 的查询、标记完成与删除并通过result.affected判断记录是否存在对未命中的请求返回 400export async function PUT( _: Request, { params }: { params: { id: string } }, ) { const id params.id; const result await todoService.complete(id); if (!result.affected) return new Response(Not Found Todo, { status: 400 }); return Response.json({ ok: true }); }这条调用链是Next.js Route Handler →inject(TodoService)单例→TodoService纯编排→TodoRepositoryProxy 包装→ TypeORMRepositoryTodo→AppDataSource。业务应用完全不接触DataSource这正是该示例强调的模块化分层价值。五、数据库配置orm-config.tsREADME 建议将数据库类型、用户名、密码等连接信息集中到 orm-config.ts。仓库中的实际默认配置使用了sql.js基于 SQLite 的内存/嵌入式数据库这样示例开箱即用、无需外部数据库即可演示完整 CRUD// examples/with-typeorm/packages/typeorm-service/src/orm-config.ts import reflect-metadata; import { DataSource } from typeorm; import { Todo } from ./domain/todo/todo.entity; export const AppDataSource new DataSource({ type: sqljs, synchronize: true, logging: true, entities: [Todo], autoSave: false, dropSchema: true, });逐项说明import reflect-metadata必须最先执行为装饰器元数据提供全局 PolyfillTypeORM 实体解析与 DI 的design:paramtypes都依赖它type: sqljs使用内存数据库package.json中对应的sql.js依赖即为此服务synchronize: true启动时根据实体自动同步表结构免去手写建表 SQLdropSchema: true每次初始化前清空 schema配合 sql.js 实现“每次运行都是干净数据库”的演示体验生产环境应关闭autoSave: false不启用 sql.js 的自动落盘数据仅存于内存。README 给出的是接入真实数据库以 MySQL 为例时的配置模板切换到 MySQL 时把DataSource参数改为连接信息即可export const AppDataSource new DataSource({ type: mysql, // 或你的数据库类型 host: localhost, port: 3306, username: your_username, password: your_password, database: your_database_name, synchronize: true, logging: false, entities: [Todo], migrations: [/* 迁移文件 */], });如果需要本地起一个真实的 MySQL仓库提供了 docker-compose.ymlversion: 3 volumes: database: driver: local services: mysql: platform: linux/amd64 image: mysql:8.0.32 container_name: turborepo_mysql restart: always ports: - 3306:3306 environment: MYSQL_DATABASE: root MYSQL_ALLOW_EMPTY_PASSWORD: 1 volumes: - database:/var/lib/mysql在示例目录执行docker compose up -d即可获得localhost:3306的 MySQL 8 实例随后按上面的模板修改orm-config.ts中的type、username、password、database。六、运行与验证环境要求以根package.json为准pnpm8.15.5由packageManager字段声明与 Node.js 18。# 在 examples/with-typeorm 目录下 pnpm install # 安装依赖 pnpm dev # turbo run dev持久化运行两个 Next.js 应用的开发服务器 pnpm build # turbo run build按 dependsOn ^build 拓扑顺序构建 pnpm lint # turbo run lint pnpm check-types # turbo run check-types全仓库 tsc --noEmit pnpm format # prettier 格式化服务包的测试脚本独立于 Turborepo 任务体系之外定义test脚本为vitest runpnpm --filter repo/typeorm-service test6.1 测试配置用 SWC 支持装饰器vitest 默认用 esbuild 转译不支持装饰器因此 vitest.config.ts 引入unplugin-swc插件来编译 TypeScript 装饰器import swc from unplugin-swc; import { defineConfig } from vitest/config; export default defineConfig({ plugins: [swc.vite()], });6.2 两类测试用例test目录包含两个互补的测试文件纯单元测试todo-service.test.ts用vi.fn()构造 mock 仓储后直接new TodoService(mockTodoRepo)注入验证add / findById / complete / deleteById / findAll五个方法都把调用正确透传给仓储。由于 Service 通过构造函数依赖 Repository这个测试完全不涉及数据库——这正是构造函数注入带来的可测试性收益。TypeORM 集成测试typeorm.test.ts在测试内自行new DataSource({ type: sqljs, ... })并initialize()对真实内存中的SQLite 执行 Insert / Select / Update / Delete 全链路断言验证 sqljs 驱动下实体映射行为符合预期。七、配套工具链与 README 一致该示例预置了以下工具TypeORM^0.3.20作为 peerDependency 供应用与共享包共享版本服务层 ORMTypeScript5.5.4静态类型检查配合repo/typescript-config的 base / nextjs / react-library 三套预设ESLintrepo/eslint-config按应用类型next / react-internal / library分发配置Prettier^3.2.5代码格式化vitest unplugin-swc共享包测试与装饰器编译Docker Compose可选的 MySQL 8 本地环境。八、小结examples/with-typeorm展示的模式可以概括为三点服务包以源码为入口exports: { .: ./src/index.ts }在 Turborepo 中让共享 TypeScript 包免去独立构建步骤由消费应用的 Next.js 直接编译Entity → Repository → Service 分层把数据库访问细节收敛在共享包内两个 Next.js 应用只需一行inject(TodoService)即可复用同一套数据访问逻辑自研 DI 容器用reflect-metadata的design:paramtypes自动解析构造函数依赖用 Proxy 实现“首次访问时懒初始化数据库”并用元数据缓存实现进程内单例。如果要在此基础上扩展自然的演进方向是把 sql.js 换成带连接池的生产数据库配置、为实体补充migrations、在repo/ui或新包中复用inject模式接入更多领域服务并参考 turbo.json 中test任务的 inputs 定义把测试纳入 Turborepo 缓存体系。【免费下载链接】turboBuild system optimized for JavaScript and TypeScript, written in Rust项目地址: https://gitcode.com/gh_mirrors/tu/turbo创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考