前阵子 V1 项目终于走到提测收尾,看着测试同学开的问题单越来越少,我反而开始有点慌。功能虽然都能跑,但代码里那些到处重复的请求逻辑、散落在各个页面的 EventSource、还有每个模块各自写死的消息队列连接,全像没归档的文件一样堆着。后面马上就要排 V2,不趁现在把这一层收口,新增需求的成本只会越来越高。所以 V1 项目封装这件事,本质上不是一次“代码洁癖”,而是下一阶段开发前必须做的技术还债。这篇文章想把我在这轮封装专项里的决策、操作和踩坑完整记录下来,给正在做同类工作的团队一个参考。
如果你现在的项目也出现了这些迹象——同一个接口在十几个地方重复调、改一个通用错误码要全局搜索、新增页面总要从老页面里复制粘贴请求代码,那你恰好可以接着往下看。我会把请求层、SSE 流式接口、RabbitMQ 客户端这些常见封装场景一步步拆开讲,也会把那些“看起来该封装,实际不该封装”的边界讲清楚。
1. V1项目的封装背景:不整理就没法继续
我们这套 V1 项目表面上是“一个后台管理系统”,实际上包含管理端 Web、微信小程序端、后端服务三块。业务范围则是常规的权限、订单、数据看板,外加一个对接第三方平台的 AI 对话流式接口。一期排期紧,开发节奏快,前期几乎把所有逻辑都平铺在页面和 Controller 里。等到第二个迭代结束,代码已经出现很明显的重复坏味道。
1.1 最初代码里暴露的三个典型症状
第一个症状是“同一个接口被复制了 N 遍”。拿最简单的用户信息接口来说,在用户中心页、订单页、评论管理页里各写了一份完整的请求逻辑,每份还都自己处理 loading 和错误提示。后来后端调整了返回结构,前端要改的地方居然有十多个文件。第二个症状是“401 处理各写各的”。有的页面收到 401 会跳转登录页并清掉 token,有的页面只是弹一条“登录已过期”,甚至有的页面直接白屏,没有任何反馈。第三个症状是“长连接没人管”。AI 对话页里每打开一次对话就 new 一个 EventSource,退出页面时基本不调用 close,连接泄漏问题在测试阶段反复出现。
这三个症状的共同点,其实都指向同一个问题:公共逻辑没有收口。业务逻辑和基础设施耦合在一起,谁都用到了,谁也不愿意维护。封装不是把这些代码藏起来,而是把“用什么服务”和“服务怎么实现”拆开,让页面只关心业务,不关心连接怎么管理、超时怎么处理、错误码怎么翻译。
1.2 封装目标与范围界定
V1 项目封装专项,不是一次性全部重构。我们给了自己一条非常明确的边界:凡是两个以上场景都要用的逻辑,才值得封装;一次性业务代码,哪怕再丑,也先不动。这样做的原因是避免在这个阶段引入大规模修改带来的回归风险。
最终确定的范围是四块:请求层封装、流式接口封装、消息队列客户端封装、通用工具类封装。业务组件层面我们不碰,只在调用入口上做统一。整个专项用了一周时间,前三天梳理调用点和统一规范,后四天写封装和改造调用方。越往后越清楚,封装本身没有想象中难,难的是把散落的调用点全部摸清楚,并且保证改造后行为不变。
2. 封装设计思路:先定边界再动手
写代码的时候可以边写边抽象,但做项目级封装不能靠感觉。V1 项目这轮,我们严格按“使用场景 → 职责边界 → 接口定义 → 实现细节”的顺序走,先在文档里把边界画清楚,再落代码。
2.1 封装继承多态不是口号,是工具
很多人一提封装就想到“封装继承多态”这三板斧,但真正落地时往往容易跑偏。我们这次没有刻意堆砌继承,而是先把“隐藏细节”做到位,再考虑复用。对于一个内部管理系统来说,真正需要复用的其实是统一鉴权、统一错误处理、统一连接生命周期,这些不适合靠“继承”去强制,更适合靠“组合”和“代理”完成。
不过在某些场景下,继承和多态也确实帮上了忙。比如后端对多个第三方渠道做适配时,我们定义了一个抽象基类 ChannelClient,里面有一个 SendAsync 虚方法,子类各自实现。调用方只需要拿到基类实例,调用同一个方法,完全不需要关心具体渠道的鉴权差异和参数差异。这就是多态带来的好处。封装、继承、多态三者不是并列关系,封装是前提,继承和多态是手段。先把每个模块的对外接口收窄,继承和多态才能发挥价值。
2.2 分层的封装模型
我们内部把封装层分成了三层:接入层、业务封装层、使用层。接入层处理的是和框架、驱动、连接相关的细节,比如 axios 实例、RabbitMQ 连接工厂、EventSource 封装;业务封装层把接入层的能力组合成具体业务语义,比如“获取用户信息”“订阅订单状态消息”;使用层则是页面和接口的调用入口,只负责传入参数和接收结果。
这三层的关系有点像餐厅后厨。客人(使用层)到前台点一份“番茄炒蛋”,不需要知道后厨(业务封装层)怎么洗菜切菜,更不需要知道燃气灶(接入层)怎么点火。如果每个客人都自己跑进后厨去洗菜,后厨一定会乱套。代码也一样,页面里放过多的连接细节,只会让业务逻辑越来越难读。
2.3 约定的力量:统一返回格式和错误码
封装前,我们花了半天时间统一约定。后端返回格式统一为{ code, message, data },前端拿到响应后只处理data,只要code !== 200就进统一错误提示。401 由请求层统一拦截处理。消息队列的消费约定是显式确认,消费失败要重试三次,三次失败记录日志。
这些约定看起来不复杂,但少了它,封装层根本无法设计。如果没有统一的返回结构,axios 拦截器只能做到“把响应原样返回”,错误处理还是得交给调用方;没有统一的错误码定义,SSE 断线重连、RabbitMQ 消费失败这些场景就没法做标准化决策。封装到一半才去统一约定是最痛苦的,还不如一开始就花时间把约定钉死。
3. 关键封装模块的实操记录
这一部分是我们 V1 项目封装专项里最耗时的几个模块。我按实际改造顺序来写,每个模块都先说封装前的痛点,再给核心代码和解释为什么这么设计。
3.1 请求层:axios二次封装与微信小程序请求封装
前端 Web 端用的是 Vue3 + Vite,接口请求基于 axios。封装前,页面里直接axios.get(url, config),然后各自处理 loading、错误提示和 token 拼接。改造后,我们做了一个统一的请求封装,所有接口调用都走这一个入口。
// request.js import axios from 'axios' import { getToken, clearToken } from '@/utils/auth' const service = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 15000 }) service.interceptors.request.use(config => { const token = getToken() 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 !== 200) { // 这里可以接统一的错误提示组件 return Promise.reject(new Error(res.message)) } return res.data }, error => { if (error.response?.status === 401) { clearToken() window.location.href = '/login' } return Promise.reject(error) }) export default service这段代码的核心价值是“把通用逻辑从调用方手里拿过来”。页面里不再需要判断res.code,因为它已经处理完;也不再需要为 401 单独写跳转逻辑。需要注意的是,返回的是res.data而不是整个response,这会让调用方拿到的直接是业务数据,减少一层嵌套。如果后端哪天改了返回结构,只要调整拦截器这一处就行。
微信小程序端的情况不太一样。原生wx.request没有拦截器机制,我们只能封装成一个 Promise 函数,统一处理登录态和错误码。
// utils/request.js const request = (options) => { return new Promise((resolve, reject) => { const token = wx.getStorageSync('token') wx.request({ url: `${getApp().globalData.baseUrl}${options.url}`, method: options.method || 'GET', data: options.data || {}, header: { 'content-type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), ...options.header }, success(res) { if (res.statusCode === 200) { const body = res.data if (body.code === 200) { resolve(body.data) } else { wx.showToast({ title: body.message, icon: 'none' }) reject(body) } } else if (res.statusCode === 401) { wx.removeStorageSync('token') wx.reLaunch({ url: '/pages/login/index' }) } else { reject(res) } }, fail: reject }) }) } module.exports = request小程序的封装天然只能用“统一函数”而不是“实例拦截器”,这要求我们把错误处理逻辑完整写进 Promise 里。做的时候我踩过一个坑:wx.request在 statusCode 为 401 时也会走 success 回调,必须显式判断res.statusCode而不是简单看res.data.code。这一点和 Web 端 axios 默认行为不一样,团队里第一次接手的人很容易漏掉。
3.2 流式接口封装:SSE的接收、解析与断线重连
V1 项目里有个 AI 对话功能,后端通过 SSE(Server-Sent Events)向浏览器和服务端推送流式消息。最开始每个页面各自 new EventSource,解析逻辑也各写各的,切页面后连接没人关,漏消息、重复连接的问题特别多。后来我们封装了一个独立的连接类。
// sseClient.js export class SSEConnection { constructor(url, options = {}) { this.url = url this.options = options this.eventSource = null this.manualClose = false this.reconnectTimes = 0 this.maxReconnect = options.maxReconnect ?? 5 } connect(onMessage, onError) { this.manualClose = false this.eventSource = new EventSource(this.url) this.eventSource.onopen = () => { this.reconnectTimes = 0 } this.eventSource.onmessage = (event) => { try { const data = JSON.parse(event.data) onMessage(data) } catch (e) { onError?.(new Error('SSE数据解析失败')) } } this.eventSource.onerror = (err) => { if (this.manualClose) return onError?.(err) this.eventSource?.close() if (this.reconnectTimes < this.maxReconnect) { this.reconnectTimes++ setTimeout(() => this.connect(onMessage, onError), 3000 * this.reconnectTimes) } } } close() { this.manualClose = true this.eventSource?.close() } }这里最关键的地方是manualClose标志。EventSource 在主动调用close()之后,浏览器也可能会触发一次 error。如果不加这个标志,主动关闭就会被误判成断线,然后触发重连,造成新连接不断创建。我们之前没有这个标志时,测试同学一离开 AI 对话页面,后台就会看到一堆“幽灵连接”。
另外,onerror里先close再重连是必要的。直接靠 EventSource 的自动重连虽然也能连上,但重连间隔我们无法控制,而且可能带着旧的错误状态。手动关闭再重建,能保证每次重连都是干净状态,也方便记录重连次数。
3.3 C# RabbitMQ 封装:从连接工厂到快捷API
后端用了 .NET Core,消息队列是 RabbitMQ。封装前面临的问题很典型:各个服务直接使用ConnectionFactory创建连接,用完不关闭,导致连接数一路涨到对方上限,服务重启才恢复。另外,消费逻辑分散在各业务类里,Ack 行为不一致,有的自动确认,有的手动确认,出问题时很难追溯。
我们封装了一个RabbitMqClient,把连接创建、消息发布、消息订阅统一收口。
public sealed class RabbitMqClient : IDisposable { private readonly IConnection _connection; private readonly IModel _channel; public RabbitMqClient(string connectionString) { var factory = new ConnectionFactory { Uri = new Uri(connectionString) }; _connection = factory.CreateConnection(); _channel = _connection.CreateModel(); _channel.BasicQos(prefetchSize: 0, prefetchCount: 10, global: false); } public void Publish<T>(string exchange, string routingKey, T message) { var body = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(message)); _channel.BasicPublish(exchange, routingKey, mandatory: false, basicProperties: null, body: body); } public void Subscribe<T>(string queue, Func<T, Task> handler) { _channel.QueueDeclare(queue: queue, durable: true, exclusive: false, autoDelete: false, arguments: null); var consumer = new AsyncEventingBasicConsumer(_channel); consumer.Received += async (model, ea) => { try { var message = JsonSerializer.Deserialize<T>(Encoding.UTF8.GetString(ea.Body.ToArray())); await handler(message!); _channel.BasicAck(ea.DeliveryTag, multiple: false); } catch (Exception ex) { _channel.BasicNack(ea.DeliveryTag, multiple: false, requeue: false); // 记录异常日志 } }; _channel.BasicConsume(queue: queue, autoAck: false, consumer: consumer); } public void Dispose() { _channel.Dispose(); _connection.Dispose(); } }封装时有两个细节值得提。一是BasicQos设置了prefetchCount = 10,避免大量消息一次性推给消费者导致内存被打满。二是在Received回调里统一用BasicAck和BasicNack,让所有消费逻辑都遵循“失败不自动重入队”的规则。之前不同业务模块有的用自动确认,消息一收到就丢了,根本接不到补偿逻辑;现在只要 handler 抛异常,消息就会被 Nack 并且不再重新入队,方便后续通过补偿机制处理。
使用Dispose统一释放连接也很重要。在 .NET 的依赖注入容器里,这个对象注册成单例,应用停止时由容器调用释放,不会再出现连接泄漏。
3.4 配置与通用工具封装
除了接口和连接,我们还把散落各处的公共工具收进utils目录。包括 token 存储、路由跳转、日期格式化、防抖节流、金额单位转换等。这些属于“看起来简单,但改起来最烦”的模块。举一个例子,日期格式化在 Web 端、小程序端、后端各有一套实现,而且格式还不一样。统一后只保留一个入口,内部再按平台判断,对外接口完全一致。
// utils/format.js export function formatDateTime(value, pattern = 'YYYY-MM-DD HH:mm:ss') { const date = value ? new Date(value) : new Date() const map = { YYYY: date.getFullYear(), MM: String(date.getMonth() + 1).padStart(2, '0'), DD: String(date.getDate()).padStart(2, '0'), HH: String(date.getHours()).padStart(2, '0'), mm: String(date.getMinutes()).padStart(2, '0'), ss: String(date.getSeconds()).padStart(2, '0') } return pattern.replace(/YYYY|MM|DD|HH|mm|ss/g, (match) => map[match]) }通用工具封装的原则是“无状态、无副作用”,不要在里面偷偷写 log、弹窗,否则调用方很难排查问题。
3.5 接口封装后的调用方式对比
封装改完,最直观的收益可以用一个表格对比出来。
| 对比维度 | 封装前 | 封装后 |
|---|---|---|
| 新增页面调用接口 | 复制一大堆请求代码并自行处理 token | 一行代码调用封装函数 |
| 修改接口超时时间 | 全局搜索逐个改 | 只改一个配置文件 |
| 处理 401 登录态 | 每个页面各自判断,容易漏 | 拦截器统一处理 |
| 排查消息未确认问题 | 需要深入业务代码找 Ack | 统一在封装层排查 |
| 新增消息队列消费 | 自己建连接、写 Ack 逻辑 | 传入 handler 即可 |
这张表也回答了一个常见问题:封装到底带来什么好处?不是代码变短就一定好,而是把重复逻辑集中到一处,让变化的成本大幅下降。V1 项目后期,后端有两三次调整返回结构,前端改一个文件就全量生效。这在封装前几乎不可想象。
4. 封装过程中的踩坑与排查
封装不是一蹴而就的,我们在这一周里踩了不少坑,有些问题直到写完才发现。
4.1 二次封装把原始错误信息吞了
最开始做 axios 封装时,我在 response 拦截器里直接Promise.reject(new Error(res.message)),把整个原始响应丢掉了。结果页面里只能拿到一串提示文字,“状态码是多少”“哪个接口报的错”全丢了,前端排查问题两眼一抹黑。
后来换成了自定义错误对象,至少保留code、statusCode和原始响应。
export class HttpError extends Error { constructor(code, message, response) { super(message) this.code = code this.statusCode = response?.status this.response = response } }封装层可以简化调用方的写法,但绝不能把排查问题的必要信息挡住。错误对象设计时一定要考虑“调试友好”。
4.2 SSE断线重连:主动关闭和异常断开难区分
这个问题前面已经提到,这里再补充一个排查案例。有次测试反馈,AI 对话页面切走后后台仍在收到连接请求。第一次检查看到close()确实调了,以为问题不在前端。后来打了日志才发现,close()之后 EventSource 仍然触发了onerror,紧接着走了重连分支。原因正是因为manualClose这个标志没有在close()里先设置。加上标志之后,主动关闭和异常断开就能完全区分开了。
处理连接类封装,永远要把“主动退出”和“被动异常”两个状态分清楚,这是血泪教训。
4.3 RabbitMQ 封装的连接泄漏控制
另一个典型问题是连接/信道泄漏。我们最开始在Publish方法里每次CreateModel(),但没有显式关闭,数量一多就把连接池耗尽。后来改成在构造时只创建一个IModel,并用Dispose统一释放。虽然牺牲了一点并发度上的“整洁”,但换来的是连接生命周期完全可控。
另外,使用QueueDeclare时,durable 参数必须和创建队列时保持一致。如果有一次声明为durable: false,后续改成durable: true,RabbitMQ 会直接报错提示“队列已经存在,参数不一致”。这类问题属于“封装解决不了的”,只能靠约定和文档来规范。
4.4 过度封装带来的“暗扣”
我也踩过另一个方向的坑。为了让请求封装“万能”,我一开始给 request 函数设计了非常多的参数:是否显示 loading、是否穿透错误、是否跳过鉴权、重试次数……看起来功能强大,实际调用时传参混乱,很多参数在调用方被固定传死,根本没人改。
后来把“万能函数”拆成了几个固定语义的函数,比如getUserInfo、submitOrder,对绝大多数场景只暴露少量参数,特殊场景再单独走底层的通用请求。封装不是把参数变量变多,而是让调用方不需要关心不该关心的参数。如果不能简化调用,那封装就是在帮倒忙。
4.5 快速排查清单
把几个高频问题整理成一个速查表,项目成员可以直接对着查。
| 问题现象 | 可能原因 | 处理方式 |
|---|---|---|
| 接口总是返回未登录,但 token 存在 | 请求头没带上 Authorization | 检查 request 拦截器 token 获取位置 |
| 页面不跳登录页但接口报 401 | response 拦截器未判断 error.response.status | 在 error 分支补 401 逻辑 |
| SSE 页面退出后仍有连接 | 没有调用 close 或 manualClose 未生效 | 组件卸载钩子里先置 manualClose 再 close |
| RabbitMQ 消息堆积不消费 | 消费者未 BasicAck,或 prefetchCount 过小 | 检查消费回调是否 Ack 和 Qos 配置 |
| 封装函数改了,线上行为突然变化 | 封装层吞掉原始错误信息 | 改用自定义错误对象保留现场 |
5. 测试与文档沉淀
封装做完,还差最后两件事:测试和文档。如果只封装不写测试,后续改动还是会心虚;只封装不写文档,团队成员不会用,最终又会回到各写各的老路。
5.1 为封装模块写单元测试
我给几个关键的封装模块都补了单元测试。axios 拦截器可以用 mock adapter 来测,重点是验证“code 非 200 时 reject、401 时清 token、正常时返回 data”。SSE 连接类因为依赖浏览器 EventSource,测试时使用一个 mock 对象,手动触发onopen、onmessage、onerror来验证逻辑。
RabbitMqClient 的测试稍微麻烦一点,但我们通过引入一个虚拟连接工厂,将消息发布和消费的回调行为分离出来,也能覆盖大部分业务语义。写测试不求覆盖所有分支,但至少要保证“错误码处理”“断线重连”“消息确认”这三个关键链路不会在后续迭代中被改坏。
5.2 接口文档和调用demo:封装必须配使用说明
封装写完后的那个周五,我花了半天时间写了一份内部文档,内容包括每个封装模块的功能说明、参数表、最小可用示例、常见错误处理方式。文档不长,但对团队来说价值很大。
尤其值得一提的是最小 demo。我们为每个封装模块都准备了一个可运行的示例页面或控制台程序。新同事接手时直接看 demo 就能调用,不需要再翻代码。文档内容不要写“设计思想”这种虚的东西,就写“怎么用”和“有问题找谁”。跑得通的 demo 比一百行设计说明更有用。
6. 一点心得:封装是手段,不是目的
这轮 V1 项目封装专项做下来,我最大的体会是:封装带来的价值,不是让代码看起来更“高级”,而是让维护和扩展变得更安全。判断一个封装是否成功,可以问两个问题:调用方是不是只需要关心业务?公共逻辑改动时,是不是只需要改一个文件?如果两个答案都是“是”,那这个封装基本是合格的。
最后再分享一个小技巧:封装完不要急着全量替换调用方,先选一两个页面做试点。V1 项目里我们先是拿“AI 对话页”试点了 SSE 封装,确认行为和原来一致,再推广到其他所有页面。同时,给每个封装模块配一个演示用例,后续版本迭代时,跑一遍这些用例就能快速验证改动影响。封装不是一次性的,V2 项目开始前,我们还会重新审视这些边界,该下沉的下沉,该外放的外放。守住边界比堆功能更重要,这件事我踩过坑之后,终于信了。