V1项目交付那天,我在发布验证通过后做的第一件事,不是开香槟,而是把半年的代码从头翻了一遍,边看边记。这个动作看起来很笨,但后来证明它比加班写新功能更值钱——因为它产出了一套可以被V2直接使用的“封装”。这篇文章就是我对这个V1项目封装与总结的完整复盘。先声明一下,这里的“封装”不是芯片封装、PCB封装那一类硬件术语,而是软件工程里对请求、组件、工具函数、消息中间件的二次封装。重点会讲清楚:我在请求层、流式输出、组件库、后端中间件各自封装了什么、为什么这样封装、踩了哪些坑。如果你也在做AI交互类应用,或者手头有一个需求叠加到快要失控的项目,这篇内容应该能帮你省下几个晚上的排查时间。
1. 项目背景:V1为什么需要一次“封装式复盘”
1.1 V1阶段最容易出现的“代码半成品”状态
V1阶段最大的特点是赶节奏。做原型验证的时候,功能是“先跑通再说”的逻辑:接口请求散落在各个页面里,有的用axios,有的用fetch,有的甚至是从某个旧项目里复制来的一段带token的请求代码;错误提示要么不弹,要么每个页面弹各自的弹窗;SSE流式输出的解析逻辑在聊天页面里写了一大坨,改一次崩三处。
这种状态我叫它“代码半成品”:功能能用,但任何一次改动都在积累技术债。比如后端把接口返回结构从{ code, message, data }改成{ status, data },散落各处的请求代码就要挨个改;比如登录态过期后,有的页面会白屏,有的会弹出两个登录框,有的干脆卡住不动。这些问题在V1阶段不会立刻爆发,因为页面少、改动小,但等到V2要加新模块、新交互时,这些散装的代码会以几何级数拖慢开发速度。
所以,我赶在V1功能冻结、业务需求还没大规模涌进来的窗口期,专门腾出时间做了一次“封装式复盘”。复盘不是重写代码,而是把重复的、脆弱的、隐藏约定全部抽象出来,变成团队看得见、用得上的公共层。
1.2 复盘时界定的封装边界
封装这件事,最怕的是没有边界,什么都想塞进去。我这次做的第一件事不是写代码,而是列边界清单。
我明确要封装的:请求层(统一入口、统一鉴权、统一错误码处理)、SSE流式客户端、通用UI组件、工具函数、消息队列的发布订阅服务、第三方SDK的适配层。明确不封装的:具体业务页面、跟业务强耦合的格式化逻辑、一次性脚本。
判断标准很简单:如果一个函数或组件会被两个以上地方以“几乎一样的方式”调用,并且调用方式在未来一年内不会频繁变化,就值得封装;如果只有一个页面用,而且连你自己都说不清它会不会改,就先不封装。过度封装是另一个常见的坑,我见过有人把两行代码也抽成Utils,结果一个项目几十个util文件,每个文件一两行,找起来比写起来还累。封装应该发生在第三个重复出现的时候,而不是第一次出现的时候。
2. 请求层封装:从axios到统一请求入口
2.1 统一实例与拦截器设计
我实测下来,axios二次封装是投入产出比最高的一件事。最基础的做法是创建一个request模块,内部用axios.create生成实例,配置baseURL和超时时间;请求拦截器里统一从store或本地缓存取token,加进Authorization头;响应拦截器里统一处理HTTP状态码和业务错误码。
这样做的直接效果是:所有页面请求的鉴权逻辑从几十个文件里消失了,后续换登录方案(比如从JWT换到SSO)只需要改一个文件。先看代码:
import axios from 'axios' const service = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 15000 }) service.interceptors.request.use( (config) => { const token = localStorage.getItem('access_token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }, (error) => Promise.reject(error) ) service.interceptors.response.use( (response) => { const res = response.data if (res.code !== 0) { ElMessage.error(res.message || '请求失败') return Promise.reject(new Error(res.message)) } return res.data }, (error) => { if (error.response?.status === 401) { // 跳登录页 } return Promise.reject(error) } ) export default service这里有几个细节值得注意。第一,不要把整个response返回给页面,拦截器里判断code === 0之后直接返回res.data,页面里写起来会非常干净。第二,401的处理一定要放在响应拦截器里,否则每个页面都要自己判断“要不要跳登录”。第三,超时时间不要全局一个值,下载接口和普通查询接口的超时应单独配置,流式接口甚至不应该设置固定超时,我后面在常见问题里再讲。
2.2 封装多域名与多环境的切换
V1项目里有个特殊需求:同一个前端应用需要同时调用内网服务和公网服务,而且H5端要支持指向两个不同域名。这个问题在开发期不明显,一到联调就乱了,一会儿这个接口跨域,一会儿那个接口404。解决办法是把域名配置统一收口。微信小程序请求封装、uniapp封装H5如何指向2个域名,本质是同一件事:环境与域名的映射表不能散落在业务代码里。
我用的方案是:维护一份配置文件,里面按环境维护多套域名映射。小程序端可以用process.env.NODE_ENV或自定义编译模式来区分;H5端则更简单,直接用构建环境变量。核心思路是先定义一个基于环境变量的配置对象,再根据请求标识选择对应的实例。
// config.js const domainMap = { development: { apiBase: 'http://192.168.1.10:8080', h5Base: 'http://dev.example.com' }, production: { apiBase: 'https://api.example.com', h5Base: 'https://h5.example.com' } } export const getBaseURL = (type) => domainMap[import.meta.env.MODE || 'development'][type]这样页面里不出现任何硬编码的域名。请求封装层再维护两个axios实例:一个走业务API,一个走H5域名,请求时根据config.mark自动路由到对应实例。现在遇到新环境、新域名,只需要在domainMap里加一行配置,不需要动业务代码。
还有一个补充经验:如果同一个环境需要动态切换多个域名,比如灰度环境按用户分流,建议把映射表放到后端配置中心或启动时拉取,而不是再维护一份前端配置。前端配置一旦膨胀,就成了新的技术债。
2.3 SSE流式输出与大模型接口封装
V1项目是做AI对话助手的,核心交互是大模型回答需要打字机式实时渲染。这个功能最容易翻车,也最值得封装。先说结论:用SSE流式输出时,不要自己手写EventSource去解析每个数据块,要把“建立连接、接收流、解析JSON、处理错误、取消请求”封装成一个独立的流式请求模块。
我最终封装了一个带AbortController的请求函数,核心逻辑是:
export async function fetchChatStream({ messages, onMessage, signal }) { const response = await fetch('/v1/responses', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` }, body: JSON.stringify({ messages }), signal }) if (!response.ok) { throw new ApiStreamError(response.status, await response.text()) } const reader = response.body.getReader() const decoder = new TextDecoder('utf-8') let buffer = '' while (true) { const { value, done } = await reader.read() if (done) break buffer += decoder.decode(value, { stream: true }) const lines = buffer.split('\n') buffer = lines.pop() for (const line of lines) { const trimmed = line.trim() if (!trimmed.startsWith('data:')) continue const data = trimmed.slice(5).trim() if (data === '[DONE]') return try { const json = JSON.parse(data) onMessage?.(json) } catch (e) { // 说明数据半包,留在buffer里等下一次读取 } } } }这里有个关键点:后端如果用的是POST + SSE(大多数大模型接口都是这样),不能直接用浏览器原生EventSource,因为EventSource只支持GET。所以我才用fetch+ReadableStream来读流。另一个关键点是半包合并,服务端下发的数据可能会被TCP拆成多段,必须在客户端做buffer拼接,按换行符切分后再解析,否则会频繁出现JSON.parse报错。
调用方再用AbortController控制取消。用户点击“停止生成”时调用controller.abort(),fetch的signal会直接中断连接。这块的完整逻辑我会在组件封装部分继续展开。
3. 组件与逻辑封装:让业务代码变薄
3.1 通用组件封装原则
页面多了以后,最先失控的不是请求,而是UI组件。我在V1项目里维护了一套基础组件:SubmitButton带loading防重复提交、ConfirmDialog二次确认、EmptyState空状态、Skeleton加载骨架。
封装原则是:组件只做通用交互,不做业务判断。比如SubmitButton接收一个async onClick,内部自己处理loading和禁用,但它不关心提交流程里是登录、下单还是发消息。组件内部只负责“点击后变loading、Promise结束恢复”。
一个常见的反面案例:把具体的业务字段写进组件props里,比如一个TopicList组件里直接写死了topic.category的取值和颜色映射。等第二个业务要用时,你不得不复制一份再改。正确做法是:把可变的字段映射通过render prop或插槽暴露出来,组件只负责布局和加载态。
封装组件时我还坚持一条:props宁可少不要多。如果一个组件的props超过8个,说明它的边界没划好,要么拆成几个子组件,要么把配置集中到一个options对象里。V1后期我们团队的新人上手速度明显加快,很大程度就是靠这套收敛好的基础组件——他们不需要关心内部实现,看一遍示例就能拼出页面。
3.2 基于组合式函数封装AI交互逻辑
V1项目里聊天页面的交互逻辑非常重:发送消息、维护消息列表、滚动到底部、重试、停止生成。如果这些全靠一个组件方法堆,基本没法维护。我的做法是抽成useChat这个组合式函数(在Vue里叫composable,在React里叫hook)。
核心设计是:对外暴露messages、sendMessage、stop、retry、status;对内管理流式请求、AbortController、消息历史。页面组件只需要调用useChat,然后渲染messages即可。这样“基于什么技术栈封装AI交互逻辑”的答案就很清楚了:逻辑封装在组合式函数里,UI封装在组件里,两者只通过状态和事件通信。
export function useChat() { const messages = ref([]) const status = ref('idle') let controller = null async function sendMessage(content) { const userMessage = { role: 'user', content } messages.value.push(userMessage) status.value = 'loading' controller = new AbortController() let fullText = '' await fetchChatStream({ messages: messages.value, signal: controller.signal, onMessage: (chunk) => { fullText += chunk.delta || '' const last = messages.value[messages.value.length - 1] if (last?.role === 'assistant') { last.content = fullText } else { messages.value.push({ role: 'assistant', content: fullText }) } } }).catch((err) => { if (err.name !== 'AbortError') { status.value = 'error' } }) status.value = 'done' } function stop() { controller?.abort() } return { messages, status, sendMessage, stop } }这个useChat可以直接被Web页面、小程序页面复用,只要请求层不变。这也是我这次封装总结里最满意的一部分。实际开发中还补了两个能力:重试时自动把最后一条assistant消息弹出再请求;切走页面时自动stop(),避免组件卸载后流还在跑。
3.3 生成器封装迭代与轮询
还有一个比较偏门但很实用的封装:用generator迭代器封装函数来处理分批请求和轮询。V1里有个数据同步功能,要从后端分批拉取大量数据直到全部拉完。一般写法是写一个while循环,中间夹一堆状态变量,读起来很费劲。我用生成器把“每次取下一页”变成可以for await...of遍历的序列:
async function* fetchAllPages(fetchPage) { let page = 1 let hasMore = true while (hasMore) { const { list, totalPages } = await fetchPage(page) yield list hasMore = page < totalPages page += 1 } } for await (const list of fetchAllPages((page) => api.getList({ page, size: 100 }))) { // 每页结果直接消费 }这样写的好处是调用侧不用关心分页状态,逻辑也更好测试。生成器把“迭代”这个行为抽象出来,循环结构在生成器内部维护,业务代码只需要消费结果。不过要提醒一句:generator封装适合“顺序消费”的场景,如果要做并发拉取,还是用Promise.all配合批次控制更合适,别为了优雅牺牲性能。
4. 后端与中间件的封装实践
4.1 接口层与消息队列封装
V1项目的后端有一部分是.NET WebAPI,业务里要发RabbitMQ消息。第一版代码里每个业务方法都自己new一个Connection,结果就是连接数爆炸、性能奇差。后来我把RabbitMQ封装成一个独立的服务类,对外只暴露PublishAsync、SubscribeAsync等语义化方法,连接管理和重连逻辑全部收进内部。
public class RabbitMqService : IDisposable { private IConnection _connection; private readonly object _lock = new object(); public void EnsureConnection() { if (_connection != null && _connection.IsOpen) return; lock (_lock) { if (_connection != null && _connection.IsOpen) return; var factory = new ConnectionFactory { HostName = configuration["rabbitmq:Host"], Port = int.Parse(configuration["rabbitmq:Port"]), UserName = configuration["rabbitmq:UserName"], Password = configuration["rabbitmq:Password"], AutomaticRecoveryEnabled = true }; _connection = factory.CreateConnection(); } } public Task PublishAsync(string exchange, string routingKey, object message) { EnsureConnection(); using var channel = _connection.CreateModel(); var body = JsonSerializer.SerializeToUtf8Bytes(message); channel.BasicPublish(exchange, routingKey, body: body); return Task.CompletedTask; } }要点有三个:连接单例化,用锁保证并发安全;开启AutomaticRecoveryEnabled,依赖方不会感知到网络抖动;channel按需创建而不是长期持有,因为channel不是线程安全的。这样封装之后,业务代码里调用RabbitMqService.PublishAsync一行就完事。后来团队打算换Kafka,也只是替换这个服务类的内部实现,业务代码完全不用动,这就是封装带来的可替换性。
4.2 API文档路由的坑:Swagger 404
V1项目发布后遇到一个很典型的问题:WebAPI部署到IIS后,访问/swagger/v1/swagger.json返回404。第一反应是Swagger没配置对,折腾半天发现是路由前缀的问题。IIS部署时如果站点有虚拟目录,Swagger的路径会带着目录名,浏览器访问路径不对自然404。
解决办法是改SwaggerEndpoint的相对路径:
app.UseSwaggerUI(c => { c.SwaggerEndpoint("v1/swagger.json", "My API V1"); });或者全局配置一个相对路径的基地址。这个问题本质和“封装”有什么关系?关系在于:如果API文档访问入口被封装成标准约定,团队就不需要每次部署都排查一遍这个坑。所以我在总结里特意把这类部署期问题单独拉出来,写进排坑手册。另外还要注意,如果项目用了多个版本控制,比如v1和v2,SwaggerEndpoint里的v1指的是Swagger文档名,而不是版本号,别混了。
5. 常见问题与排查技巧实录
5.1 SSE请求返回502 Bad Gateway怎么办
V1联调期间我收到过最多的报错是unexpected status 502 bad gateway,而且出现在/v1/responses这个流式接口上。502本身是网关错误,意味着请求已经到代理层但上游服务没正常响应。SSE场景下特别容易误判:你看到页面发请求,等了很久,然后502,以为是自己代码问题,其实可能是网关超时或服务端在流式过程中主动断连。
排查路径很关键。先看网关超时时间,大模型流式接口的TTFB(首包时间)通常比普通接口长,网关默认的读超时如果只有60秒,很可能不够。接着用curl直接打后端服务,跳过网关看是否正常:
curl -N --no-buffer http://127.0.0.1:15721/v1/responses \ -H "Content-Type: application/json" \ -d '{"messages":[]}'如果直连正常而经过网关502,问题基本在网关配置或代理层;如果直连也502,再看后端日志,可能是模型服务token超限或响应体过大被中断。建议把这类现象整理成速查表:
| 现象 | 可能原因 | 处理办法 |
|---|---|---|
| 直接请求后端正常,走网关502 | 网关读超时阈值太短 | 调大proxy_read_timeout |
| 请求一开始就502 | 网关连不上上游或上游启动失败 | 检查上游端口与健康检查 |
| 流式输出到一半502 | 上游进程异常退出或响应流中断 | 抓后端日志,查内存/异常堆栈 |
| 日志无异常但客户端收到502 | 上游返回了非200状态 | 观察上游实际status code |
还有一个容易踩的:本地调试时把后端地址写成127.0.0.1,前端页面跑在另一个端口,跨域没配好时浏览器会报CORS错误而不是502,别把两者搞混。CORS错误通常在Network面板看不到完整响应头,而502能看到网关返回的状态码,两者特征差异很大。
5.2 封装后的接口报“invalid url”或“unexpected endpoint”
V1中有人图方便直接在代码里拼接API路径,结果出现类似invalid url (get /v1)或者unexpected endpoint or method. (options /v1/models)的问题。这说明请求的URL路径和HTTP方法跟服务端路由对不上。
常见原因有两个:一是封装层把baseURL和url重复拼接,导致/api/v1/v1/xxx这种路径;二是后端路由配置里方法不一致,比如前端POST,后端只接受GET,于是出现了options /v1/models这种预检请求直接404的情况。把请求统一收口进request模块之后,这类问题可以快速定位:直接打印最终请求URL,区分是封装层拼错还是后端路由问题。
我的另一个经验是给请求模块加一个debug模式,开启后自动在控制台打印method、url、params。上线后可以把debug模式关闭,联调时打开,能省很多沟通成本。
5.3 AbortController取消后组件还在更新状态
这是前端最容易踩的坑。用户点击“停止生成”后,流被abort了,但onMessage回调里可能还在往状态里塞数据,导致组件出现“已停止但内容还在跳”的诡异现象。原因是abort只是中断网络,没能阻止后续回调继续触发。
解决办法:在abort时设置一个标志位,回调里先判断:
let isAborted = false controller.signal.addEventListener('abort', () => { isAborted = true }) function handleMessage(data) { if (isAborted) return // 更新UI }另外要注意,onMessage里更新如果用的是引用类型,比如直接改messages数组中最后一个对象的content,在React里可能不触发重渲染,要记得用不可变更新。在Vue里改引用类型没问题,因为Vue的响应式是基于代理的,但React的setState要求新引用。这个差异很容易让同时写两个框架的人犯迷糊。
还有一个细节:abort之后fetch会抛一个AbortError,如果在useChat里没有判断错误类型就直接把status置为'error',页面会闪现一个错误状态。我后来统一在catch里判断err.name !== 'AbortError'再置error,才算把这个坑填平。
写到这里,其实“封装和总结”这件事已经不需要再堆新内容了。我想分享几个在V1项目提交后自己最认同的验收标准,也当是给这篇文章收个尾。
第一,调用方不需要知道底层实现。拿RabbitMQ来说,业务同事看到PublishAsync就能直接写,不需要理解Connection、Channel这些概念,这就是封装成功。如果调用方还得翻源码才能用,说明封装层设计得不够。第二,封装层要有明确的所有者。V1项目里最怕的是“公共模块”没人认领,谁都能提改动,坏味道一堆。我做总结时给每个封装模块指定了review负责人,V2一开始就有了明确的技术责任人。第三,不要过度封装。封装应该发生在第三个重复出现的时候,而不是第一次出现的时候,这是我反复强调的一点。
最后分享一个保留习惯:每次封装完,我会顺手写一份“调用方示例”放在examples目录下,别人看一遍就会用。写文档的投入是固定的,但能省下N倍的答疑时间。V1的总结不是一字一句去重读代码,而是把这半年里那些“只可意会”的约定,变成V2团队看得见、用得上的资产。这是我认为做一次项目封装总结最值得的原因。