第 13 篇:服务端也要工程化:Express 项目分层实践

0 阅读6分钟

项目地址

说明:当前在线版本部署在 Vercel,国内网络访问可能不稳定。如果打不开,可以直接查看 GitHub 源码并在本地启动。


前言

上一篇我们对前端做了一次状态管理重构,把 App.tsx 里越来越复杂的逻辑拆成了两个 Hook:

useChatSessions
useDifyStreamChat

这样前端代码结构变清晰了:

App.tsx 负责页面组装
useChatSessions 负责会话状态
useDifyStreamChat 负责流式聊天
components 负责 UI 展示

但是项目里还有另一个地方也开始变胖了:

server/index.ts

到目前为止,服务端文件里可能同时包含:

读取环境变量
初始化 Express
配置 cors / json
定义 health 接口
定义 /api/chat
定义 /api/chat/stream
调用 Dify API
处理 streaming
处理错误
监听端口

一开始这样写没有问题,能快速跑通。

但如果继续扩展,比如后面要加:

数据库
用户登录
会话接口
文件上传
日志
限流
错误码

一个 server/index.ts 很快就会失控。

所以这一篇我们来做服务端工程化,把 Express 服务端拆成更清晰的分层结构。


本篇目标

这一篇完成后,服务端目录会变成:

server/
  config/
    env.ts
  routes/
    chat.ts
  services/
    dify.ts
  types/
    dify.ts
  utils/
    errors.ts
  index.ts

每一层职责如下:

config/env.ts       读取和校验环境变量
routes/chat.ts      定义 Express 路由
services/dify.ts    封装 Dify API 调用
types/dify.ts       Dify 相关类型
utils/errors.ts     统一错误处理工具
index.ts            初始化和启动服务

重构完成后,外部功能不变:

GET  /health
POST /api/chat
POST /api/chat/stream

前端也不需要改。


为什么服务端也要分层?

很多前端同学写 Node 服务时,容易把它当成“脚本”写。

比如所有逻辑都放在一个文件里:

const app = express()

app.post('/api/chat', async (req, res) => {
  // 校验参数
  // 调 Dify
  // 处理错误
  // 返回结果
})

app.post('/api/chat/stream', async (req, res) => {
  // 校验参数
  // 调 Dify streaming
  // 转发流
  // 处理错误
})

项目小的时候还行。

一旦接口变多,就会出现:

1. 文件过长
2. 逻辑重复
3. 环境变量到处读
4. 错误处理不统一
5. 外部 API 调用散落在路由里
6. 类型定义混乱
7. 后续测试和复用困难

所以即使是一个小 Express 服务,也应该有基本分层。

不是为了复杂,而是为了可维护。


当前服务端的问题

在重构前,我们的 server/index.ts 大概承担了这些职责:

1. dotenv.config()
2. 读取 DIFY_API_KEY
3. 创建 Express app
4. 配置中间件
5. 定义 /health
6. 定义 /api/chat
7. 定义 /api/chat/stream
8. 拼 Dify 请求 body
9. 发 fetch 请求
10. 处理 Dify 错误
11. 转发 SSE 流
12. 监听端口

这些职责混在一起,就像之前的 App.tsx 一样。

我们要把它拆开。


第一步:创建环境变量配置

先把环境变量读取逻辑抽出来。

新建:

server/config/env.ts

写入:

import dotenv from 'dotenv'

dotenv.config()

function requireEnv(name: string) {
  const value = process.env[name]

  if (!value) {
    throw new Error(`Missing required environment variable: ${name}`)
  }

  return value
}

export const env = {
  port: Number(process.env.PORT || 3001),
  difyApiKey: requireEnv('DIFY_API_KEY'),
  difyApiUrl:
    process.env.DIFY_API_URL || 'https://api.dify.ai/v1/chat-messages',
  difyUser: process.env.DIFY_USER || 'vite-demo-user',
}

这里做了两件事:

1. 统一读取环境变量
2. 对必须存在的 DIFY_API_KEY 做校验

这样其他文件不用直接读 process.env

如果缺少 Key,服务启动时就会直接报错,而不是等请求来了才失败。


第二步:更新 .env.example

项目里应该有一份 .env.example,告诉别人需要配置哪些变量。

可以写成:

PORT=3001
DIFY_API_KEY=your_dify_app_api_key
DIFY_API_URL=https://api.dify.ai/v1/chat-messages
DIFY_USER=vite-demo-user

注意:

.env.example 可以提交到 GitHub
.env 不能提交到 GitHub

.gitignore 里应该有:

.env
.env.local

第三步:定义 Dify 类型

新建:

server/types/dify.ts

写入:

export type DifyChatRequest = {
  message: string
  conversationId?: string
}

export type DifyBlockingResponse = {
  answer: string
  conversation_id: string
  [key: string]: unknown
}

export type DifyStreamEvent = {
  event?: string
  answer?: string
  conversation_id?: string
  message?: string
  metadata?: {
    retriever_resources?: Array<{
      dataset_name?: string
      document_name?: string
      content?: string
    }>
  }
}

虽然现在服务端没有大量使用这些类型,但先定义出来有几个好处:

1. 代码意图更明确
2. 后续服务端解析 Dify 事件时更安全
3. 避免到处写 any
4. 为后续类型收敛打基础

第四步:封装 Dify Service

路由层不应该直接写 Dify fetch 细节。

我们把调用 Dify 的逻辑放到 service 里。

新建:

server/services/dify.ts

写入:

import { env } from '../config/env'
import type { DifyBlockingResponse } from '../types/dify'

type SendBlockingParams = {
  message: string
  conversationId?: string
}

type SendStreamingParams = {
  message: string
  conversationId?: string
  signal?: AbortSignal
}

function createDifyBody(message: string, conversationId?: string) {
  return {
    inputs: {},
    query: message,
    conversation_id: conversationId || '',
    user: env.difyUser,
  }
}

export async function sendDifyBlocking({
  message,
  conversationId,
}: SendBlockingParams): Promise<DifyBlockingResponse> {
  const response = await fetch(env.difyApiUrl, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${env.difyApiKey}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      ...createDifyBody(message, conversationId),
      response_mode: 'blocking',
    }),
  })

  if (!response.ok) {
    const errorText = await response.text()
    throw new Error(`Dify request failed: ${errorText}`)
  }

  return response.json() as Promise<DifyBlockingResponse>
}

export async function sendDifyStreaming({
  message,
  conversationId,
  signal,
}: SendStreamingParams) {
  const response = await fetch(env.difyApiUrl, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${env.difyApiKey}`,
      'Content-Type': 'application/json',
    },
    signal,
    body: JSON.stringify({
      ...createDifyBody(message, conversationId),
      response_mode: 'streaming',
    }),
  })

  if (!response.ok || !response.body) {
    const errorText = await response.text()
    throw new Error(`Dify stream request failed: ${errorText}`)
  }

  return response
}

这个文件只负责一件事:

调用 Dify

它不关心 Express,也不关心前端。

这样以后如果要换成其他 AI 平台,比如 OpenAI、通义、Gemini、LangChain 服务,也可以在 service 层替换。


第五步:统一错误工具

新建:

server/utils/errors.ts

写入:

import type { Response } from 'express'

export function sendError(
  res: Response,
  statusCode: number,
  message: string,
  detail?: unknown
) {
  return res.status(statusCode).json({
    error: message,
    detail,
  })
}

export function getErrorMessage(error: unknown) {
  if (error instanceof Error) {
    return error.message
  }

  return 'Unknown error'
}

这样路由里处理错误时,不需要重复写:

error instanceof Error ? error.message : 'Unknown error'

虽然这只是一个很小的工具,但统一错误处理是服务端工程化的基础。


第六步:拆 chat 路由

新建:

server/routes/chat.ts

写入:

import { Router } from 'express'
import { sendDifyBlocking, sendDifyStreaming } from '../services/dify'
import { getErrorMessage, sendError } from '../utils/errors'

export const chatRouter = Router()

chatRouter.post('/chat', async (req, res) => {
  try {
    const { message, conversationId } = req.body

    if (!message || typeof message !== 'string') {
      return sendError(res, 400, 'message is required')
    }

    const data = await sendDifyBlocking({
      message,
      conversationId,
    })

    return res.json({
      answer: data.answer,
      conversationId: data.conversation_id,
      raw: data,
    })
  } catch (error) {
    console.error('[POST /api/chat]', error)

    return sendError(res, 500, 'Internal server error', getErrorMessage(error))
  }
})

chatRouter.post('/chat/stream', async (req, res) => {
  const controller = new AbortController()

  req.on('close', () => {
    controller.abort()
  })

  try {
    const { message, conversationId } = req.body

    if (!message || typeof message !== 'string') {
      return sendError(res, 400, 'message is required')
    }

    const difyResponse = await sendDifyStreaming({
      message,
      conversationId,
      signal: controller.signal,
    })

    res.setHeader('Content-Type', 'text/event-stream; charset=utf-8')
    res.setHeader('Cache-Control', 'no-cache, no-transform')
    res.setHeader('Connection', 'keep-alive')

    const reader = difyResponse.body!.getReader()
    const decoder = new TextDecoder()

    while (true) {
      const { done, value } = await reader.read()

      if (done) break

      const chunk = decoder.decode(value, { stream: true })
      res.write(chunk)
    }

    res.end()
  } catch (error) {
    if (controller.signal.aborted) {
      return
    }

    console.error('[POST /api/chat/stream]', error)

    if (!res.headersSent) {
      return sendError(
        res,
        500,
        'Internal server error',
        getErrorMessage(error)
      )
    }

    res.write(
      `data: ${JSON.stringify({
        event: 'error',
        message: getErrorMessage(error),
      })}\n\n`
    )
    res.end()
  }
})

现在路由层只负责:

1. 参数校验
2. 调用 service
3. 设置响应头
4. 返回响应
5. 错误处理

Dify 请求细节已经被移动到了 services/dify.ts


第七步:精简 server/index.ts

现在 server/index.ts 可以非常干净。

改成:

import express from 'express'
import cors from 'cors'
import { env } from './config/env'
import { chatRouter } from './routes/chat'

const app = express()

app.use(cors())
app.use(express.json())

app.get('/health', (_req, res) => {
  res.json({
    ok: true,
  })
})

app.use('/api', chatRouter)

app.listen(env.port, () => {
  console.log(`API server running at http://localhost:${env.port}`)
})

现在 index.ts 只负责:

1. 创建 Express app
2. 注册中间件
3. 注册路由
4. 启动服务

这才是入口文件应该做的事。


第八步:确认前端不需要改

前端请求的接口仍然是:

/api/chat/stream

而服务端现在是:

app.use('/api', chatRouter)
chatRouter.post('/chat/stream', ...)

最终路径还是:

/api/chat/stream

所以前端不需要改。

这是一次纯服务端重构。


第九步:启动测试

启动项目:

npm run dev:all

先访问健康检查:

http://localhost:3001/health

应该返回:

{"ok":true}

然后打开前端:

http://localhost:5173

测试:

1. 提问是否正常
2. 流式输出是否正常
3. 引用来源是否正常
4. 停止生成是否正常
5. Dify 报错时页面是否有反馈

如果这些都没问题,说明服务端重构成功。


重构后的服务端结构

现在服务端结构是:

server/
  config/
    env.ts
  routes/
    chat.ts
  services/
    dify.ts
  types/
    dify.ts
  utils/
    errors.ts
  index.ts

相比之前的单文件,职责清晰很多。

config/env.ts

负责环境变量:

PORT
DIFY_API_KEY
DIFY_API_URL
DIFY_USER

services/dify.ts

负责调用 Dify:

sendDifyBlocking
sendDifyStreaming

routes/chat.ts

负责 HTTP 接口:

POST /api/chat
POST /api/chat/stream

utils/errors.ts

负责错误格式化。

index.ts

负责启动应用。


这一步的工程价值

这篇文章没有新增用户功能,但对工程质量很重要。

它带来的价值是:

1. server/index.ts 变薄
2. Dify 调用逻辑可复用
3. 路由和服务分离
4. 环境变量统一校验
5. 错误处理更一致
6. 后续加接口更容易
7. 后续接数据库更自然

比如下一阶段要加数据库会话接口:

GET    /api/sessions
POST   /api/sessions
PATCH  /api/sessions/:id
DELETE /api/sessions/:id

我们只需要新增:

server/routes/sessions.ts
server/services/session.ts

不会把 index.ts 写爆。


常见问题

1. 启动时报 Missing DIFY_API_KEY

说明 .env 没有配置:

DIFY_API_KEY=xxx

或者当前工作目录不是项目根目录,导致 dotenv 没读取到 .env

2. 路由 404

检查:

app.use('/api', chatRouter)

以及 chatRouter 里是否是:

chatRouter.post('/chat/stream', ...)

最终路径应该是:

/api/chat/stream

3. TypeScript 报找不到 fetch

如果 Node 版本太低,可能没有全局 fetch。

建议使用 Node 18+,最好 Node 20。

4. 停止生成失效

检查 req.on('close')signal: controller.signal 是否都存在。

前端 abort 只能断开浏览器请求,后端也要把 abort 继续传给 Dify fetch。


当前版本还有哪些不足?

现在前端和服务端都做了基础工程化。

但项目还缺少团队协作中常见的质量门禁:

代码格式统一
Lint 检查
TypeScript 严格检查
提交前自动校验

现在如果不同人维护项目,可能会出现:

有人加分号,有人不加
有人用双引号,有人用单引号
未使用变量没人发现
类型 any 到处飞
提交前才发现构建失败

所以下一篇我们会给项目加上:

ESLint
Prettier
TypeScript strict
Husky
lint-staged

让个人项目也具备团队级质量门禁。


本篇总结

这一篇我们完成了服务端工程化重构。

主要做了:

1. 抽出 config/env.ts 管理环境变量
2. 新增 server/types/dify.ts 定义 Dify 类型
3. 抽出 services/dify.ts 封装 Dify 调用
4. 新增 utils/errors.ts 统一错误处理
5. 抽出 routes/chat.ts 管理聊天接口
6. 精简 server/index.ts
7. 保持前端接口不变

现在项目的前后端结构都更清晰了。

下一篇继续提升工程质量:

给项目加上 ESLint、Prettier、TypeScript 严格检查和提交前校验。