Server Utils 与数据库集成
Nuxt4 的
server/utils/目录提供了服务端的自动导入机制——放在里面的函数、变量、类型,所有 API handler 和中间件都能直接使用。这让我们可以把数据库连接、ORM 实例、鉴权工具等共用逻辑统一管理。本章讲清 server/utils 的工作原理,然后以 Drizzle ORM 为主演示数据库集成全流程。
1. server/utils/ 自动导入
1.1 工作原理
server/utils/ 下的所有具名导出(export function、export 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 是目前的主流选择:
| 特性 | Drizzle | Prisma |
|---|---|---|
| 类型安全 | 编译时(TypeScript 推导) | 代码生成 |
| 包体积 | ~50KB | ~2MB(含 engine) |
| Edge 兼容 | ✅ 原生支持 | ❌ 需要 Data Proxy |
| SQL 风格 | 接近原生 SQL | 链式查询 |
| 迁移 | drizzle-kit | prisma 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 / Docker | postgres (postgres.js) | 连接池支持 |
| Vercel Serverless | @vercel/postgres | 内置连接池代理 |
| Cloudflare Workers | @neondatabase/serverless | HTTP 协议 |
| 通用 Serverless | drizzle-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.jsonscripts 管理 - 多驱动选型:根据部署平台选择合适的数据库驱动