news 2026/9/10 0:40:04

Langfuse 后端路由与控制器架构指南:Next.js + tRPC 分层设计与最佳实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Langfuse 后端路由与控制器架构指南:Next.js + tRPC 分层设计与最佳实践

Langfuse 后端路由与控制器架构指南:Next.js + tRPC 分层设计与最佳实践

【免费下载链接】langfuse🪢 Open source AI engineering platform: LLM evals, observability, metrics, prompt management, playground, datasets. Integrates with OpenTelemetry, LangChain, OpenAI SDK, LiteLLM, and more. 🍊YC W23项目地址: https://gitcode.com/GitHub_Trending/la/langfuse

Langfuse 是一个开源的 AI 工程平台,其 Web 端采用 Next.js + tRPC 技术栈构建。本文以.agents/skills/backend-dev-guidelines/references/routing-and-controllers.md为核心,系统讲解 Langfuse 内部的路由模式与关注点分离(Separation of Concerns)架构:从 tRPC 内部 UI 接口到公共 REST API 的完整调用链、Service 与 Repository 的分层职责,以及必须避免的三大反模式。读完本文,你将掌握如何在 Langfuse 仓库中正确新增一条路由/接口,并理解其"路由薄、服务厚、仓储纯"的分层设计哲学。

架构总览:从入口到数据库的四层结构

Langfuse 使用分层架构(Layered Architecture)组织后端代码,每一层职责单一、边界清晰。从源码目录结构可以明确看到这条完整链路:

┌─────────────────────────────────────────────────────────────┐ │ ENTRY POINTS │ │ ┌──────────────────────┐ ┌─────────────────────────┐ │ │ │ tRPC Procedures │ │ Public REST API Routes │ │ │ │ (Internal UI API) │ │ (SDK/External API) │ │ │ └──────────────────────┘ └─────────────────────────┘ │ └─────────────────────────────────────────────────────────────┘ ▼ ┌─────────────────────────────────────────────────────────────┐ │ SERVICE LAYER │ │ Business logic, orchestration, validation │ │ web/src/features/*/server/ or packages/shared/services/ │ └─────────────────────────────────────────────────────────────┘ ▼ ┌─────────────────────────────────────────────────────────────┐ │ REPOSITORY LAYER │ │ Complex queries, data transformation │ │ packages/shared/src/server/repositories/ │ └─────────────────────────────────────────────────────────────┘ ▼ ┌─────────────────────────────────────────────────────────────┐ │ DATABASE LAYER │ │ PostgreSQL (Prisma) + ClickHouse (Direct Client) │ └─────────────────────────────────────────────────────────────┘

两层入口(tRPC Procedures 与 Public REST API Routes)共享同一套 Service / Repository / 数据库底座。这正是 Langfuse 能够在同一套业务逻辑之上同时支撑 Web 控制台(tRPC)与 SDK 外部集成(REST API)的原因。

各层职责清单

入口层(Routes / Procedures)应当:

  • ✅ 定义路由与 procedure 签名
  • ✅ 通过 middleware 处理认证 / 授权
  • ✅ 使用 Zod schema 校验输入
  • ✅ 委托给 Service 执行
  • ✅ 返回响应

入口层绝不应当:

  • ❌ 包含业务逻辑
  • ❌ 直接访问数据库
  • ❌ 执行复杂的数据转换
  • ❌ 直接调用 Repository(必须经由 Service)

Service 层:

  • ✅ 承载业务逻辑
  • ✅ 编排多个操作(orchestration)
  • ✅ 调用 Repository 或 Prisma / ClickHouse
  • ✅ 处理复杂工作流
  • ❌ 不应感知 HTTP、tRPC 或 request/response 对象

Repository 层:

  • ✅ 复杂数据库查询
  • ✅ 数据转换(DB → 领域模型)
  • ✅ ClickHouse 查询构建器
  • ✅ 可复用的查询逻辑
  • ❌ 不应包含业务逻辑

这套职责划分在仓库中体现得极为一致:路由文件位于web/src/server/api/routers/web/src/pages/api/public/,业务逻辑集中在web/src/features/*/server/packages/shared/src/server/services/,查询逻辑则下沉到 packages/shared/src/server/repositories/,其中 README.md 还记录了跨表关联查询的时间窗口约束(如基于 Score 时间戳回溯 1 小时 cutoff)等数据关联保证。

tRPC Routers:内部 UI 的类型安全接口

tRPC 为前端提供类型安全的远程调用,Langfuse 的内部 UI 全部通过 tRPC 与后端通信。所有内部 router 位于web/src/server/api/routers/

Router 结构示例:scores.ts

以 web/src/server/api/routers/scores.ts(共 1141 行)为例,一个典型的 router 通过createTRPCRouter聚合多个 procedure:

import { z } from "zod/v4"; import { createTRPCRouter, protectedProjectProcedure, } from "@/src/server/api/trpc"; import { paginationZod, singleFilter, orderBy } from "@langfuse/shared"; import { getScoresUiTable, getScoresUiCount, upsertScore, } from "@langfuse/shared/src/server"; const ScoreAllOptions = z.object({ projectId: z.string(), filter: z.array(singleFilter), orderBy: orderBy, ...paginationZod, }); export const scoresRouter = createTRPCRouter({ /** * Get all scores for a project */ all: protectedProjectProcedure .input(ScoreAllOptions) .query(async ({ input, ctx }) => { // Delegate to repository for data fetching const clickhouseScoreData = await getScoresUiTable({ projectId: input.projectId, filter: input.filter ?? [], orderBy: input.orderBy, limit: input.limit, offset: input.page * input.limit, }); // Delegate to Prisma for related data const [jobExecutions, users] = await Promise.all([ ctx.prisma.jobExecution.findMany({ where: { jobOutputScoreId: { in: clickhouseScoreData.map((score) => score.id), }, }, }), ctx.prisma.user.findMany({ where: { id: { in: clickhouseScoreData .map((s) => s.authorUserId) .filter((id): id is string => id !== null), }, }, }), ]); // Transform and combine data return clickhouseScoreData.map((score) => ({ ...score, jobConfigurationId: jobExecutions.find((j) => j.jobOutputScoreId === score.id) ?.jobConfigurationId ?? null, authorUserImage: users.find((u) => u.id === score.authorUserId)?.image ?? null, authorUserName: users.find((u) => u.id === score.authorUserId)?.name ?? null, })); }), /** * Create or update score */ createAnnotationScore: protectedProjectProcedure .input(CreateAnnotationScoreData) .mutation(async ({ input, ctx }) => { // Validation validateConfigAgainstBody(input); // Delegate to repository await upsertScore({ id: input.id ?? randomUUID(), traceId: input.traceId, projectId: input.projectId, name: input.name, value: input.value, source: ScoreSource.ANNOTATION, authorUserId: ctx.session.user.id, comment: input.comment, }); // Audit log await auditLog({ session: ctx.session, resourceType: "score", resourceId: input.id, action: "create", }); return { success: true }; }), });

从这个例子可以总结出编写 tRPC router 的关键要点

  • 选择恰当的 procedure 类型(protectedProjectProcedureauthenticatedProcedure等)
  • 用 Zod 定义输入 schema(.input()
  • 读操作使用.query(),写操作使用.mutation()
  • 数据访问一律委托给 Service / Repository
  • 保持 procedure 精简——不含业务逻辑
  • 全程类型安全(TypeScript 从 Zod schema 自动推断类型)

注册 Router:root.ts

所有 router 必须在 web/src/server/api/root.ts 中手动注册,该文件定义了appRouter这一总入口:

import { createTRPCRouter } from "@/src/server/api/trpc"; import { scoresRouter } from "./routers/scores"; import { tracesRouter } from "./routers/traces"; import { dashboardRouter } from "@/src/features/dashboard/server/dashboard-router"; export const appRouter = createTRPCRouter({ scores: scoresRouter, traces: tracesRouter, dashboard: dashboardRouter, // ... other routers }); export type AppRouter = typeof appRouter;

从源码结构看,web/src/server/api/root.ts 目前注册了 50+ 个 router,覆盖 annotation queues、batch export、traces、sessions、generations、evals、monitors、slack、搜索、标注队列等几乎所有功能域,其中大量 feature 级 router 由各功能目录自行导出(如web/src/features/dashboard/server/dashboard-routerweb/src/features/evals/server/router),体现了"路由按功能域就近组织、统一在 root 汇总"的模式。

tRPC 基础设施:trpc.ts

所有 procedure 的基座定义在 web/src/server/api/trpc.ts(799 行),其核心内容包括:

  • Context 构建createTRPCContext通过getServerAuthSession获取 next-auth session,并注入prisma实例,供所有 procedure 通过ctx.prisma/ctx.session使用。
  • 初始化与错误格式化:使用superjson作为 transformer,errorFormatter会将 ZodError 扁平化(z.flattenError)返回给前端,并针对ClickHouseResourceError隐藏堆栈。
  • 全局中间件withErrorHandling统一拦截异常,将BaseError映射为对应的 tRPC error code,5xx 错误不暴露内部堆栈;withOtelInstrumentation通过 OpenTelemetry 注入 baggage(包含 userId、projectId、clickhouse surface/route 标记),实现全链路可观测。
  • procedure 层级:从publicProcedure(未认证)到enforceUserIsAuthed中间件包装出的认证 procedure,再到项目级保护 procedure,逐级增强安全约束。

前端调用方式

由于 tRPC 的类型推断,前端可以获得与后端完全一致的类型安全体验:

// Type-safe client call const { data, isLoading } = api.scores.all.useQuery({ projectId: "proj_123", page: 0, limit: 50, filter: [], orderBy: null, });

任何对后端 schema 的修改都会在前端编译期暴露,这是 tRPC 相比手写 REST client 的核心优势。

Public REST API Routes:面向 SDK 与外部集成的文件式路由

与内部 tRPC 不同,公共 API 面向 Python/JS SDK 与外部集成,采用Next.js 文件式路由,目录位置为web/src/pages/api/public/

文件式路由约定

Next.js 用文件系统天然表达 URL 结构:

web/src/pages/api/public/ ├── scores/ │ ├── index.ts → GET/POST /api/public/scores │ └── [scoreId].ts → GET/PATCH/DELETE /api/public/scores/:scoreId ├── traces/ │ ├── index.ts → GET /api/public/traces │ └── [traceId].ts → GET /api/public/traces/:traceId └── datasets/ └── [name]/ ├── index.ts → GET/POST /api/public/datasets/:name └── items/ └── index.ts → GET /api/public/datasets/:name/items

动态路由规则:

  • [param].ts→ 单个动态段(如/api/public/scores/[scoreId].ts
  • [...param].ts→ 通配路由(catch-all,如/api/public/[...path].ts

从仓库现状看,web/src/pages/api/public/下已有 scores、traces、datasets、dataset-items、observations、sessions、generations、prompts、ingestion、health、media、models、metrics、scim、organizations 以及 v2/v3 版本目录等数十个端点,并且/api/public/v2/下还有 14 个文件,体现了公共 API 的版本化演进。

REST API 标准模式:scores/index.ts

以 web/src/pages/api/public/scores/index.ts 为模板,一个标准端点由withMiddlewares包裹、按 HTTP 方法分派处理函数:

import { v4 } from "uuid"; import { createAuthedProjectAPIRoute } from "@/src/features/public-api/server/createAuthedProjectAPIRoute"; import { withMiddlewares } from "@/src/features/public-api/server/withMiddlewares"; import { GetScoresQueryV1, GetScoresResponseV1, PostScoresBodyV1, PostScoresResponseV1, } from "@langfuse/shared"; import { eventTypes, processEventBatch } from "@langfuse/shared/src/server"; import { ScoresApiService } from "@/src/features/public-api/server/scores-api-service"; export default withMiddlewares({ // POST /api/public/scores POST: createAuthedProjectAPIRoute({ name: "Create Score", bodySchema: PostScoresBodyV1, responseSchema: PostScoresResponseV1, fn: async ({ body, auth, res }) => { const event = { id: v4(), type: eventTypes.SCORE_CREATE, timestamp: new Date().toISOString(), body, }; if (!event.body.id) { event.body.id = v4(); } const result = await processEventBatch([event], auth); if (result.errors.length > 0) { const error = result.errors[0]; res.status(error.status).json({ message: error.error ?? error.message, }); return { id: "" }; } return { id: event.body.id }; }, }), // GET /api/public/scores GET: createAuthedProjectAPIRoute({ name: "Get Scores", querySchema: GetScoresQueryV1, responseSchema: GetScoresResponseV1, fn: async ({ query, auth }) => { const scoresApiService = new ScoresApiService("v1"); const [items, count] = await Promise.all([ scoresApiService.generateScoresForPublicApi({ projectId: auth.scope.projectId, page: query.page, limit: query.limit, userId: query.userId, name: query.name, }), scoresApiService.getScoresCountForPublicApi({ projectId: auth.scope.projectId, userId: query.userId, name: query.name, }), ]); return { data: items, meta: { page: query.page, limit: query.limit, totalItems: count, totalPages: Math.ceil(count / query.limit), }, }; }, }), });

关键要点:

  • 所有公共 API 路由必须使用withMiddlewares(提供 CORS、统一错误处理、OpenTelemetry 埋点)
  • 认证端点必须使用createAuthedProjectAPIRoute(处理鉴权、限流、参数校验)
  • 每个 HTTP 方法定义独立的 handler
  • 输入 / 输出用 Zod schema 校验
  • 业务逻辑委托给 Service

createAuthedProjectAPIRoute 的能力矩阵

从 web/src/features/public-api/server/createAuthedProjectAPIRoute.ts(473 行)的配置项可以看出该高阶函数的能力边界:

配置项作用
name路由名称,用于可观测性
action策略核(policy core)授权的项目操作,必填,确保路由不可能"无授权上线"
querySchema/bodySchema/responseSchemaZod 请求/响应校验
successStatusCode自定义成功状态码
rateLimitResource限流资源(默认public-api),结合RateLimitService使用
rateLimitUpgradePath限流升级路径提示
isAdminApiKeyAuthAllowed自托管实例是否允许ADMIN_API_KEY认证(默认false,且仅当未设置NEXT_PUBLIC_LANGFUSE_CLOUD_REGION时可用)
allowedAccessLevels接受的访问级别,如["project", "scores"]表示允许 public key 的 Bearer 认证
allowInAppAgentKey是否允许 in-app agent key 调用(仅建议对非变更型 GET 路由开启)
errorContract结构化错误契约(如structuredPublicApiErrorContract

withMiddlewares 的统一错误处理

web/src/features/public-api/server/withMiddlewares.ts 是公共 API 的"最后防线",它:

  • 先执行 CORS 中间件(runMiddleware(req, res, cors)
  • 对未声明的 HTTP 方法统一抛出MethodNotAllowedError(默认 handler)
  • ClickHouseResourceError返回 422,并附带资源使用建议(如提示迁移到 v2 Observations/Metrics API)
  • BaseError依据httpCode返回结构化错误,5xx 同时上报traceException
  • 对 Prisma 异常与 ZodError 分别映射为 500 / 400

无需鉴权的简单路由

对于健康检查这类无需认证的路由,只需withMiddlewares即可:

// web/src/pages/api/public/health.ts import { withMiddlewares } from "@/src/features/public-api/server/withMiddlewares"; export default withMiddlewares({ GET: async (req, res) => { res.status(200).json({ status: "ok" }); }, });

版本化 API 类型的位置约定

当某个功能域存在多版本公共 API 类型时,必须放置在packages/shared/下对应功能的interfaces/api/目录中,每个版本一个子目录。scores 功能是标准范例(packages/shared/src/features/scores/interfaces/api/),其中GetScoresQueryV1/GetScoresResponseV1等符号即源于此,该目录下实际包含v1/v2/v3/三个版本,每个版本都由schemas.ts(Zod 请求/响应形状)、endpoints.ts(组合后的请求/响应类型)、validation.ts(跨字段校验辅助)组成。

明确禁止的做法:

  • 创建扁平文件(如<domain>-api-v2.ts
  • 将版本化类型放到web/src/features/public-api/types/
packages/shared/src/features/<domain>/interfaces/api/ ├── v1/ │ ├── schemas.ts # Zod schemas for request/response shapes │ ├── endpoints.ts # Composed request/response types │ └── validation.ts # Cross-field validation helpers ├── v2/ │ └── ... └── vN/ └── ...

Fern API Definitions:与 OpenAPI 规范的同步

Langfuse 通过 Fern 中对应的 Fern 定义(该目录下已有 scores.yml、traces.yml、ingestion.yml、observations.yml、metrics.yml、models.yml 等 30+ 个定义文件)。

工作流:

  1. 编辑 Fern 源文件(fern/apis/server/definition/*.yml
  2. 运行pnpm run openapi:export
  3. 提交web/public/generated/**生成产物
  4. 改动fern/**的 PR 会通过pnpm run openapi:check在 CI 中校验服务端规范是否漂移

Zod 到 Fern 类型映射表

Zod TypeFern Type示例
.nullish()optional<nullable<T>>z.string().nullish()optional<nullable<string>>
.nullable()nullable<T>z.string().nullable()nullable<string>
.optional()optional<T>z.string().optional()optional<string>
始终存在Tz.string()string

每个 Fern 类型顶部应添加引用 TypeScript 源文件的注释,保证双向可追踪:

# Source: web/src/features/public-api/types/traces.ts - APITrace Trace: properties: id: string name: type: nullable<string>

Service Layer:业务逻辑的归属地

Service 层是业务逻辑与操作编排的载体,位置约定为web/src/features/*/server/packages/shared/src/server/services/。tRPC procedure 与 API route 只负责"接线",真正干活的是 Service。

Service 模式示例:ScoresApiService

以 web/src/features/public-api/server/scores-api-service.ts 为例,Service 通过构造函数注入 API 版本,实现版本感知的行为差异:

import { _handleGenerateScoresForPublicApi, _handleGetScoresCountForPublicApi, type ScoreQueryType, } from "@/src/features/public-api/server/scores"; import { _handleGetScoreById } from "@langfuse/shared/src/server"; export class ScoresApiService { constructor(private readonly apiVersion: "v1" | "v2") {} /** * Get a specific score by ID */ async getScoreById({ projectId, scoreId, source, }: { projectId: string; scoreId: string; source?: ScoreSourceType; }) { return _handleGetScoreById({ projectId, scoreId, source, scoreScope: this.apiVersion === "v1" ? "traces_only" : "all", preferredClickhouseService: "ReadOnly", }); } /** * Get list of scores with version-aware filtering */ async generateScoresForPublicApi(props: ScoreQueryType) { return _handleGenerateScoresForPublicApi({ props, scoreScope: this.apiVersion === "v1" ? "traces_only" : "all", }); } /** * Get count of scores with version-aware filtering */ async getScoresCountForPublicApi(props: ScoreQueryType) { return _handleGetScoresCountForPublicApi({ props, scoreScope: this.apiVersion === "v1" ? "traces_only" : "all", }); } }

关键要点:

  • Service 承载业务逻辑,而非路由逻辑
  • Service不导入tRPC 或 Next.js 类型(协议无关)
  • Service 可以直接调用 Repository、Prisma、ClickHouse
  • Service 负责编排多个操作
  • Service 可同时被 tRPC 与公共 API 复用

Service 的放置位置

功能专属 Service(与具体功能强相关):

web/src/features/ ├── datasets/ │ └── server/ │ └── dataset-service.ts ├── evals/ │ └── server/ │ └── eval-service.ts └── public-api/ └── server/ └── scores-api-service.ts

共享 Service(跨功能复用,位于 packages/shared/src/server/services/):

packages/shared/src/server/services/ ├── SlackService.ts ├── DashboardService/ ├── StorageService.ts └── DefaultEvaluationModelService/

从仓库现状看,共享服务目录还包含BufferedStreamUploader.tsInMemoryFilterService.tstraces-ui-table-service.tsTableViewService/PromptService/等,进一步印证了"通用逻辑上提到 shared 包、功能逻辑留在 feature 内"的布局原则。

Repository Layer:数据访问的封装

Repository 层位于 packages/shared/src/server/repositories/,负责复杂数据库查询、数据转换(DB → 领域模型)并提供可复用的查询逻辑。

目录结构

packages/shared/src/server/repositories/ ├── traces.ts # Trace queries (ClickHouse) ├── observations.ts # Observation queries (ClickHouse) ├── scores.ts # Score queries (ClickHouse) ├── clickhouse.ts # Core ClickHouse helpers └── definitions.ts # Type definitions

仓库中该目录实际包含 40+ 个文件,除 traces/observations/scores 外,还有datasets.tsdataset-items.tsevents.tsexperiments.tsdashboards.tsdaily-metrics.tscomments.tsautomation-repository.ts以及配套的*_converters.ts转换器与*.test.ts测试文件。

Repository 模式示例:traces.ts

import { queryClickhouse, upsertClickhouse } from "./clickhouse"; import { TraceRecordReadType } from "./definitions"; import { convertClickhouseToDomain } from "./traces_converters"; /** * Get traces by IDs */ export const getTracesByIds = async ( projectId: string, traceIds: string[], ): Promise<TraceRecordReadType[]> => { const rows = await queryClickhouse<TraceRecordReadType>({ query: ` SELECT * FROM traces WHERE project_id = {projectId: String} AND id IN ({traceIds: Array(String)}) ORDER BY event_ts DESC LIMIT 1 BY id, project_id `, params: { projectId, traceIds }, tags: { feature: "tracing", type: "trace" }, }); return rows.map(convertClickhouseToDomain); }; /** * Upsert trace to ClickHouse */ export const upsertTrace = async ( trace: TraceRecordInsertType, ): Promise<void> => { await upsertClickhouse({ table: "traces", records: [trace], eventBodyMapper: (body) => ({ id: body.id, name: body.name, user_id: body.user_id, // ... map fields }), tags: { feature: "ingestion", type: "trace" }, }); };

关键要点:

  • 读查询使用queryClickhouse
  • 写入使用upsertClickhouse
  • DDL(ALTER TABLE 等)使用commandClickhouse
  • 包含数据转换器(convertClickhouseToDomain
  • 添加 OpenTelemetry tags 保证可观测性
  • Repository不包含业务逻辑

何时使用 Repository

应当使用 Repository:

  • 含 CTE、JOIN、聚合的复杂 ClickHouse 查询
  • 在多处复用的查询(DRY 原则)
  • 从 DB 类型到领域模型的数据转换
  • 流式返回大结果集

可以直接使用 Prisma / ClickHouse:

  • 简单 CRUD 操作
  • 一次性查询
  • 原型开发(后续可重构为 Repository)

关注点分离的正确示范

把三个层次组合起来,就得到了 Langfuse 后端代码的标准形态:

tRPC Procedure(入口,保持精简):

// web/src/server/api/routers/scores.ts export const scoresRouter = createTRPCRouter({ all: protectedProjectProcedure .input(ScoreFilterOptions) .query(async ({ input }) => { // ✅ Thin procedure - delegates to repository return await getScoresUiTable({ projectId: input.projectId, filter: input.filter, orderBy: input.orderBy, }); }), create: protectedProjectProcedure .input(CreateScoreInput) .mutation(async ({ input, ctx }) => { // ✅ Delegates to service for orchestration return await createScoreWithValidation({ scoreData: input, userId: ctx.session.user.id, projectId: ctx.session.projectId, }); }), });

Service(业务逻辑与编排):

// web/src/features/scores/server/score-service.ts export async function createScoreWithValidation({ scoreData, userId, projectId, }: { scoreData: CreateScoreInput; userId: string; projectId: string; }) { // ✅ Business logic: validation const config = await prisma.scoreConfig.findUnique({ where: { id: scoreData.configId }, }); if (!config) { throw new LangfuseNotFoundError("Score config not found"); } validateConfigAgainstBody(config, scoreData); // ✅ Business logic: orchestration const scoreId = randomUUID(); await Promise.all([ // Create score in ClickHouse upsertScore({ id: scoreId, projectId, traceId: scoreData.traceId, name: scoreData.name, value: scoreData.value, authorUserId: userId, }), // Audit log in PostgreSQL auditLog({ userId, resourceType: "score", resourceId: scoreId, action: "create", }), ]); return { id: scoreId }; }

Repository(纯数据访问):

// packages/shared/src/server/repositories/scores.ts export const upsertScore = async (score: ScoreInsertType): Promise<void> => { // ✅ Pure data access - no business logic await upsertClickhouse({ table: "scores", records: [score], eventBodyMapper: (body) => ({ id: body.id, trace_id: body.traceId, name: body.name, value: body.value, author_user_id: body.authorUserId, }), tags: { feature: "scoring" }, }); };

为什么这套分层有效

  1. tRPC Procedure:足够薄,只负责委托
  2. Service:承载全部业务逻辑(校验、编排)
  3. Repository:纯数据访问,可复用
  4. Service 协议无关:可被 tRPC、公共 API 甚至 worker 进程共同调用
  5. 边界清晰:易于测试、维护与扩展

一个直观的佐证是:写入端的upsertScore同时被 tRPC router(创建标注分数)与 ingestion 事件处理链路使用,而读取端的getScoresUiTable被 tRPC 与公共 API 共用——这正是"一次实现、多入口复用"的分层红利。

三大反模式(Anti-Patterns)

理解了正确范式之后,识别并规避反模式同样重要。以下是文档明确列出的三类典型错误。

反模式 1:把业务逻辑写进路由

错误示范:

// ❌ BAD: Business logic in tRPC procedure export const scoresRouter = createTRPCRouter({ create: protectedProjectProcedure .input(CreateScoreInput) .mutation(async ({ input, ctx }) => { // ❌ Validation logic in route const config = await ctx.prisma.scoreConfig.findUnique({ where: { id: input.configId }, }); if (!config) { throw new TRPCError({ code: "NOT_FOUND" }); } if (config.dataType === "NUMERIC" && typeof input.value !== "number") { throw new TRPCError({ code: "BAD_REQUEST" }); } // ❌ Direct database access await ctx.prisma.score.create({ data: { id: randomUUID(), projectId: ctx.session.projectId, traceId: input.traceId, name: input.name, value: input.value, }, }); // ❌ More business logic await auditLog({ ... }); return { success: true }; }), });

为什么糟糕:

  • 业务逻辑被 tRPC 绑定,公共 API 无法复用
  • 难以测试(需要 mock tRPC context)
  • 没有关注点分离
  • 难以维护

正确示范:

// ✅ GOOD: Thin procedure, delegates to service export const scoresRouter = createTRPCRouter({ create: protectedProjectProcedure .input(CreateScoreInput) .mutation(async ({ input, ctx }) => { return await createScoreWithValidation({ scoreData: input, userId: ctx.session.user.id, projectId: ctx.session.projectId, }); }), });

反模式 2:在路由中直接访问数据库

错误示范:

// ❌ BAD: Direct database access in route export default withMiddlewares({ GET: createAuthedProjectAPIRoute({ name: "Get Scores", fn: async ({ auth }) => { // ❌ Direct ClickHouse query in route const scores = await queryClickhouse({ query: "SELECT * FROM scores WHERE project_id = {projectId: String}", params: { projectId: auth.scope.projectId }, }); return { data: scores }; }, }), });

正确示范:

// ✅ GOOD: Delegates to service or repository export default withMiddlewares({ GET: createAuthedProjectAPIRoute({ name: "Get Scores", fn: async ({ auth, query }) => { const scoresService = new ScoresApiService("v1"); return await scoresService.generateScoresForPublicApi({ projectId: auth.scope.projectId, page: query.page, limit: query.limit, }); }, }), });

反模式 3:把业务逻辑写进 Repository

错误示范:

// ❌ BAD: Business logic in repository export const upsertScore = async ( score: ScoreInsertType ): Promise<void> => { // ❌ Validation in repository if (!score.name) { throw new Error("Score name is required"); } // ❌ Authorization check in repository const project = await prisma.project.findUnique({ where: { id: score.projectId }, }); if (!project) { throw new Error("Project not found"); } // ❌ Side effects in repository await auditLog({ ... }); await upsertClickhouse({ ... }); };

正确示范:

// ✅ GOOD: Pure data access, no business logic export const upsertScore = async (score: ScoreInsertType): Promise<void> => { await upsertClickhouse({ table: "scores", records: [score], eventBodyMapper: (body) => ({ id: body.id, trace_id: body.traceId, name: body.name, value: body.value, }), tags: { feature: "scoring" }, }); };

实践总结:新增一条接口的完整清单

综合本文内容,在 Langfuse 中新增一条后端接口应遵循以下步骤:

  1. 判断归属:内部 UI 功能 → tRPC router;SDK/外部集成 →web/src/pages/api/public/下的 Next.js 文件路由。
  2. 定义类型:若为公共 API,将版本化 Zod 类型放入packages/shared/src/features/<domain>/interfaces/api/<version>/
  3. 编写 Service:业务逻辑放入web/src/features/<domain>/server/或共享的packages/shared/src/server/services/
  4. 编写 Repository:复杂查询放入packages/shared/src/server/repositories/,保持纯数据访问。
  5. 组装入口:tRPC 用createTRPCRouter+ procedure;公共 API 用withMiddlewares+createAuthedProjectAPIRoute,配置好action与 schema。
  6. 同步 Fern 定义:修改公共类型后更新 fern/apis/server/definition/ 并运行pnpm run openapi:export,确保 CI 的pnpm run openapi:check通过。

延伸阅读

本文相关的基础设施与实践规范,可继续阅读仓库中的以下文件:

  • .agents/skills/backend-dev-guidelines/SKILL.md — 后端开发总纲
  • .agents/skills/backend-dev-guidelines/references/architecture-overview.md — 系统架构全景
  • .agents/skills/backend-dev-guidelines/references/middleware-guide.md — 中间件模式
  • .agents/skills/backend-dev-guidelines/references/database-patterns.md — 数据访问模式

【免费下载链接】langfuse🪢 Open source AI engineering platform: LLM evals, observability, metrics, prompt management, playground, datasets. Integrates with OpenTelemetry, LangChain, OpenAI SDK, LiteLLM, and more. 🍊YC W23项目地址: https://gitcode.com/GitHub_Trending/la/langfuse

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/10 0:39:51

接口测试完全指南:从HTTP协议到自动化实战

1. 接口测试到底在测什么 很多刚接触接口测试的朋友&#xff0c;第一个困惑就是&#xff1a;接口测试和功能测试到底有什么区别&#xff1f;我直接用UI点点点不也一样能发现问题吗&#xff1f;这个问题的答案&#xff0c;得从接口测试的定位说起。 接口测试测的是系统内部模块…

作者头像 李华
网站建设 2026/9/10 0:39:05

STM32F103 CAN总线Bootloader设计与实现详解

简介&#xff1a;面向STM32F103的CAN总线Bootloader完整源码包&#xff0c;专为需要在CAN网络中远程升级固件的嵌入式工程师与学生设计。内容贯穿Bootloader全流程&#xff1a;CAN控制器初始化、标准帧与扩展帧分离收发、通信错误处理、固件分帧接收与CRC校验、Flash编程与烧写…

作者头像 李华
网站建设 2026/9/10 0:38:00

STM32 PWM呼吸灯实战:从TIM3配置到引脚重映射的完整解析

简介&#xff1a;这是一个面向STM32F1微控制器的双极性SPWM波形生成代码工程&#xff0c;适用于逆变器、电机驱动、开关电源等需要正弦波输出的应用场景&#xff0c;也适合正在学习电力电子与嵌入式实时控制的开发者参考&#xff0c;可作为高校相关课程设计与毕业设计的参考模板…

作者头像 李华