Server Utils 与数据库集成

Nuxt4 的 server/utils/ 目录提供了服务端的自动导入机制——放在里面的函数、变量、类型,所有 API handler 和中间件都能直接使用。这让我们可以把数据库连接、ORM 实例、鉴权工具等共用逻辑统一管理。本章讲清 server/utils 的工作原理,然后以 Drizzle ORM 为主演示数据库集成全流程。

1. server/utils/ 自动导入

1.1 工作原理

server/utils/ 下的所有具名导出export functionexport const)会被 Nitro 自动扫描并注册为全局可用,无需手动 import:

server/utils/
├── db.ts          → export const db = ...       全局可用
├── auth.ts        → export function requireAuth  全局可用
├── validation.ts  → export function validateId   全局可用
└── helpers/
    └── format.ts  → export function formatDate   全局可用(支持嵌套目录)

1.2 与客户端自动导入的区别

特性app/composables/server/utils/
运行环境客户端 + SSR仅服务端
扫描时机Nuxt 构建时Nitro 构建时
作用域组件、页面、中间件API handler、server middleware
命名空间独立(不冲突)独立(不冲突)

两者互不可见——客户端代码不能调用 server/utils/ 中的函数,反之亦然。这是天然的安全隔离

1.3 实用工具示例

// server/utils/response.ts
export function successResponse<T>(data: T, message = 'ok') {
  return { code: 0, message, data }
}
 
export function paginatedResponse<T>(items: T[], total: number, page: number, limit: number) {
  return {
    code: 0,
    data: { items, total, page, limit, totalPages: Math.ceil(total / limit) },
  }
}

2. Drizzle ORM 集成

2.1 为什么选 Drizzle

Nuxt4 生态中,Drizzle ORM 是目前的主流选择:

特性DrizzlePrisma
类型安全编译时(TypeScript 推导)代码生成
包体积~50KB~2MB(含 engine)
Edge 兼容✅ 原生支持❌ 需要 Data Proxy
SQL 风格接近原生 SQL链式查询
迁移drizzle-kitprisma migrate
学习曲线需要 SQL 基础对 SQL 新手友好

推荐 Drizzle 的原因:体积小、Edge Runtime 兼容、TypeScript 类型推导强、贴近 SQL 思维。

2.2 安装与配置

pnpm add drizzle-orm postgres
pnpm add -D drizzle-kit
// server/utils/db.ts
import { drizzle } from 'drizzle-orm/postgres-js'
import postgres from 'postgres'
import * as schema from '../database/schema'
 
const client = postgres(useRuntimeConfig().databaseUrl)
 
export const db = drizzle(client, { schema })
// nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    databaseUrl: process.env.DATABASE_URL || '',
  },
})

2.3 Schema 定义

// server/database/schema.ts
import { pgTable, text, timestamp, integer, boolean, uuid } from 'drizzle-orm/pg-core'
 
export const users = pgTable('users', {
  id: uuid('id').primaryKey().defaultRandom(),
  email: text('email').notNull().unique(),
  name: text('name').notNull(),
  avatar: text('avatar'),
  role: text('role', { enum: ['user', 'creator', 'admin'] }).default('user').notNull(),
  createdAt: timestamp('created_at').defaultNow().notNull(),
})
 
export const videos = pgTable('videos', {
  id: uuid('id').primaryKey().defaultRandom(),
  title: text('title').notNull(),
  description: text('description'),
  url: text('url').notNull(),
  thumbnail: text('thumbnail'),
  category: text('category', { enum: ['ai', 'tutorial', 'vlog'] }).notNull(),
  views: integer('views').default(0).notNull(),
  isPublished: boolean('is_published').default(false).notNull(),
  authorId: uuid('author_id').references(() => users.id).notNull(),
  createdAt: timestamp('created_at').defaultNow().notNull(),
  updatedAt: timestamp('updated_at').defaultNow().notNull(),
})

2.4 迁移管理

// drizzle.config.ts
import { defineConfig } from 'drizzle-kit'
 
export default defineConfig({
  schema: './server/database/schema.ts',
  out: './server/database/migrations',
  dialect: 'postgresql',
  dbCredentials: { url: process.env.DATABASE_URL! },
})
pnpm drizzle-kit generate    # 生成迁移文件
pnpm drizzle-kit migrate     # 执行迁移
pnpm drizzle-kit studio      # 打开可视化管理界面

3. 连接池管理与事务处理

3.1 连接池

postgres 库默认使用连接池,关键参数:

const client = postgres(useRuntimeConfig().databaseUrl, {
  max: 10,             // 最大连接数
  idle_timeout: 20,    // 空闲超时(秒)
  connect_timeout: 10, // 连接超时(秒)
})

Edge Runtime 注意:Cloudflare Workers 等无状态环境不支持持久连接池。需要使用 Neon Serverless Driver 或 PlanetScale HTTP API 等 HTTP 协议的数据库客户端。

3.2 事务处理

// server/api/videos/[id]/publish.post.ts
export default defineEventHandler(async (event) => {
  const user = await requireAuth(event)
  const id = getRouterParam(event, 'id')!
 
  // 事务:发布视频 + 通知粉丝(要么全成功,要么全回滚)
  const result = await db.transaction(async (tx) => {
    const [video] = await tx
      .update(videos)
      .set({ isPublished: true, updatedAt: new Date() })
      .where(and(eq(videos.id, id), eq(videos.authorId, user.id)))
      .returning()
 
    if (!video) throw createError({ statusCode: 404, message: '视频不存在' })
 
    // 创建通知
    await tx.insert(notifications).values({
      type: 'new_video',
      videoId: video.id,
      authorId: user.id,
    })
 
    return video
  })
 
  return result
})

4. 关联关系与查询模式

4.1 定义表关联

Drizzle 的关联关系定义独立于 schema,放在 relations.ts 中:

// server/database/relations.ts
import { relations } from 'drizzle-orm'
import { users, videos, comments, likes } from './schema'
 
export const usersRelations = relations(users, ({ many }) => ({
  videos: many(videos),
  comments: many(comments),
}))
 
export const videosRelations = relations(videos, ({ one, many }) => ({
  author: one(users, {
    fields: [videos.authorId],
    references: [users.id],
  }),
  comments: many(comments),
  likes: many(likes),
}))
 
export const commentsRelations = relations(comments, ({ one }) => ({
  video: one(videos, {
    fields: [comments.videoId],
    references: [videos.id],
  }),
  author: one(users, {
    fields: [comments.authorId],
    references: [users.id],
  }),
}))

4.2 关联查询(Relational Query)

定义关联后,可以用 with 语法一次性查询关联数据:

// 查询视频详情 + 作者 + 前 10 条评论
const video = await db.query.videos.findFirst({
  where: eq(videos.id, videoId),
  with: {
    author: {
      columns: { id: true, name: true, avatar: true },  // 只选需要的字段
    },
    comments: {
      limit: 10,
      orderBy: [desc(comments.createdAt)],
      with: {
        author: {
          columns: { id: true, name: true, avatar: true },
        },
      },
    },
  },
})

这会生成优化的 SQL JOIN 查询,比 N+1 查询(先查视频,再循环查评论)性能好得多。

4.3 复杂查询:聚合与子查询

// 查询每个分类的视频数量和总播放量
const categoryStats = await db
  .select({
    category: videos.category,
    videoCount: count(),
    totalViews: sum(videos.views),
    avgViews: avg(videos.views),
  })
  .from(videos)
  .where(eq(videos.isPublished, true))
  .groupBy(videos.category)
  .orderBy(desc(sum(videos.views)))
 
// 子查询:查找播放量高于平均值的视频
const avgViewsSq = db
  .select({ avg: avg(videos.views) })
  .from(videos)
  .as('avg_views')
 
const aboveAverage = await db
  .select()
  .from(videos)
  .where(gt(videos.views, sql`(SELECT avg FROM ${avgViewsSq})`))

5. 通用查询模式封装

5.1 分页查询 Helper

几乎每个列表接口都需要分页,封装为通用工具:

// server/utils/pagination.ts
import type { SQL } from 'drizzle-orm'
import type { PgTable } from 'drizzle-orm/pg-core'
 
interface PaginationParams {
  page?: string | number
  limit?: string | number
}
 
interface PaginatedResult<T> {
  items: T[]
  total: number
  page: number
  limit: number
  totalPages: number
}
 
export async function paginate<T>(
  query: { findMany: Function },
  countQuery: Promise<[{ count: number }]>,
  params: PaginationParams,
  options?: {
    where?: SQL
    orderBy?: any
    with?: Record<string, any>
  },
): Promise<PaginatedResult<T>> {
  const page = Math.max(1, Number(params.page) || 1)
  const limit = Math.min(100, Math.max(1, Number(params.limit) || 20))
  const offset = (page - 1) * limit
 
  const [items, [{ count: total }]] = await Promise.all([
    query.findMany({
      where: options?.where,
      orderBy: options?.orderBy,
      with: options?.with,
      limit,
      offset,
    }),
    countQuery,
  ])
 
  return {
    items: items as T[],
    total,
    page,
    limit,
    totalPages: Math.ceil(total / limit),
  }
}

5.2 软删除模式

很多场景不应真正删除数据(合规、审计、恢复需求):

// server/database/schema.ts — 添加 deletedAt 字段
export const videos = pgTable('videos', {
  // ...其他字段
  deletedAt: timestamp('deleted_at'),  // null = 未删除
})
 
// server/utils/soft-delete.ts
export function notDeleted(table: { deletedAt: any }) {
  return isNull(table.deletedAt)
}
 
export async function softDelete(table: any, id: string) {
  return db.update(table).set({ deletedAt: new Date() }).where(eq(table.id, id))
}
 
export async function restore(table: any, id: string) {
  return db.update(table).set({ deletedAt: null }).where(eq(table.id, id))
}

在所有查询中默认排除已删除数据:

// server/api/videos/index.get.ts
const items = await db.query.videos.findMany({
  where: and(notDeleted(videos), ...otherConditions),
})
 
// server/api/videos/[id].delete.ts — 软删除而非真删
export default defineEventHandler(async (event) => {
  const id = getRouterParam(event, 'id')!
  await softDelete(videos, id)
  return sendNoContent(event)
})

5.3 乐观锁(并发安全更新)

防止两个用户同时编辑同一条记录导致数据覆盖:

// Schema 中加 version 字段
export const videos = pgTable('videos', {
  // ...
  version: integer('version').default(1).notNull(),
})
 
// 更新时检查版本号
export default defineEventHandler(async (event) => {
  const { version, ...updateData } = await readBody(event)
  const id = getRouterParam(event, 'id')!
 
  const [updated] = await db
    .update(videos)
    .set({ ...updateData, version: sql`${videos.version} + 1` })
    .where(and(eq(videos.id, id), eq(videos.version, version)))  // 版本号必须匹配
    .returning()
 
  if (!updated) {
    throw createError({
      statusCode: 409,
      message: '数据已被他人修改,请刷新后重试',
    })
  }
 
  return updated
})

6. Seed 数据与开发工具

6.1 Seed 脚本

开发环境需要初始化测试数据:

// server/database/seed.ts
import { db } from '../utils/db'
import { users, videos } from './schema'
 
async function seed() {
  console.log('🌱 Seeding database...')
 
  // 创建测试用户
  const [admin] = await db.insert(users).values({
    email: '[email protected]',
    name: '管理员',
    role: 'admin',
  }).onConflictDoNothing().returning()
 
  const [creator] = await db.insert(users).values({
    email: '[email protected]',
    name: '创作者小王',
    role: 'creator',
  }).onConflictDoNothing().returning()
 
  // 创建测试视频
  if (creator) {
    const videoData = Array.from({ length: 50 }, (_, i) => ({
      title: `测试视频 ${i + 1}`,
      description: `这是第 ${i + 1} 个测试视频的描述`,
      url: `https://example.com/videos/test-${i + 1}.mp4`,
      category: ['ai', 'tutorial', 'vlog'][i % 3] as 'ai' | 'tutorial' | 'vlog',
      views: Math.floor(Math.random() * 10000),
      isPublished: i < 40,  // 前 40 个已发布
      authorId: creator.id,
    }))
 
    await db.insert(videos).values(videoData).onConflictDoNothing()
  }
 
  console.log('✅ Seed complete')
}
 
seed().catch(console.error).finally(() => process.exit())
// package.json
{
  "scripts": {
    "db:seed": "npx tsx server/database/seed.ts",
    "db:generate": "drizzle-kit generate",
    "db:migrate": "drizzle-kit migrate",
    "db:studio": "drizzle-kit studio"
  }
}

7. Nitro 插件初始化数据库

在服务启动时执行初始化逻辑(检查连接、注册关闭钩子等):

// server/plugins/database.ts
export default defineNitroPlugin(async (nitroApp) => {
  try {
    await db.execute(sql`SELECT 1`)
    console.log('✅ Database connected')
  } catch (error) {
    console.error('❌ Database connection failed:', error)
  }
 
  // 优雅关闭:释放数据库连接池
  nitroApp.hooks.hook('close', async () => {
    await db.$client.end()
    console.log('Database connection pool closed')
  })
})

8. 多数据库驱动选型

不同部署平台需要不同的数据库驱动:

部署平台推荐驱动原因
Node.js / Dockerpostgres (postgres.js)连接池支持
Vercel Serverless@vercel/postgres内置连接池代理
Cloudflare Workers@neondatabase/serverlessHTTP 协议
通用 Serverlessdrizzle-orm/neon-http无连接池需求
// server/utils/db.ts — 根据环境选择驱动
import { drizzle } from 'drizzle-orm/postgres-js'
import * as schema from '../database/schema'
 
let db: ReturnType<typeof drizzle>
 
if (import.meta.dev) {
  // 开发环境:标准 postgres 驱动
  const postgres = await import('postgres').then(m => m.default)
  db = drizzle(postgres(useRuntimeConfig().databaseUrl), { schema })
} else {
  // 生产环境:根据 preset 选择
  const postgres = await import('postgres').then(m => m.default)
  db = drizzle(postgres(useRuntimeConfig().databaseUrl, { max: 10 }), { schema })
}
 
export { db }

本章小结

  • server/utils/:服务端的自动导入目录,所有具名导出全局可用,与客户端天然隔离
  • Drizzle ORM:Nuxt4 推荐的数据库方案,体积小、Edge 兼容、TypeScript 类型推导强
  • Schema 定义:用 TypeScript 定义表结构,drizzle-kit 管理迁移
  • 关联查询relations() 定义关联,with 语法一次性查询关联数据,避免 N+1 问题
  • 通用模式:分页 Helper、软删除、乐观锁,封装为 server/utils/ 工具函数
  • 连接池:Node.js 环境使用连接池,Edge 环境用 HTTP 协议数据库客户端
  • 事务db.transaction() 保证多表操作的原子性
  • Seed 数据npx tsx 执行种子脚本,统一 package.json scripts 管理
  • 多驱动选型:根据部署平台选择合适的数据库驱动