项目地址
- 在线预览:frontend-ai-assistant-two.vercel.app/
- GitHub 源码:github.com/hewq/dify-c…
说明:当前在线版本部署在 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 严格检查和提交前校验。