☰
上帝类重构实战:模块化拆分与异步编程的工程实践
2026/10/9 22:56:04 网站建设 项目流程

上周我把手头这个 TestChannel 类彻底拆了一遍。这个类负责产测设备的通信通道,撑着硬件交互、数据采集、状态管理和事件通知四摊事,最夸张的时候有2300多行。前脚刚改完串口波特率,后脚状态机就跟着跳错,事件通知又把界面刷得乱跳。拆之前我忍了很久,直到某次现场反馈“设备明明连着,UI 却显示离线”,我才下决心动手。这次重构用了两个核心手段:模块化设计和异步编程。把代码拆分到独立文件后,每个文件只干一件事,采集链路用 async/await 串起来,终于不再是牵一发而动全身的“上帝类”。

如果你手上也有一个跑了好几年、什么功能都往里塞的老类,这次拆解的思路和踩坑记录应该能给你一些参考。

1. 先说说我为什么一定要动这个类

1.1 两千多行一个类:改动像走钢丝

TestChannel 原本不是没写过注释,而是注释已经救不了一个结构失控的类。类里的字段、方法、回调之间互相引用,硬件收包后直接改状态,状态变更又触发事件,事件处理函数里又回头调用硬件发指令。光串口数据接收这一个地方,就牵扯了缓冲管理、粘包分包、校验、状态迁移、UI 通知五层逻辑。我试过一次小改动:把一帧数据里的温度字段从 int 改成 double,结果解析方法变了一个返回值,后续三个状态分支全部要跟着调整,光梳理调用链就花了半天。

这种“上帝类”最大的问题是职责没有边界。表面看是一个类,实际是四五个模块强行塞在一个文件里。硬件交互是 IO 密集,数据采集是算法逻辑,状态管理是业务规则,事件通知是外部接口,它们的变更频率和失败模式完全不同。硬把它们放在一起,意味着任何一处改动都可能导致另外三处出问题。我们团队后来统计过,这个文件的历史 Bug 里有将近一半是“改 A 坏 B”,根因就是耦合太深。

1.2 为什么不重写,而选择拆文件重构

我也想过推倒重写。但仔细评估后放弃了,原因很现实:这套代码已经在产线上跑了两年,很多边界行为是现场问题逼出来的,比如某些老设备握手时多回一个字节,某些采集卡在断线后要等三秒才报错。重写等于把这些隐含行为全部丢掉,回归测试成本极高。拆分重构则可以保留已验证的逻辑,只调整代码边界,每拆完一个模块就编译跑一次现有测试,风险可控得多。

所以我给自己定了三条原则:第一,拆分后的每个文件只承担一种职责;第二,模块之间只能通过接口交流,不能直接 new 来 new 去;第三,所有异步边界必须显式标注,谁启动后台任务,谁负责取消,必须清楚。后文我会具体展开这三条是怎么落地的。

2. 模块化拆分:先画边界再动手

2.1 四层职责,一个门面

动刀之前我先把原类里的字段和方法全部列出来,按依赖关系归类,结果非常清晰:硬件操作类代码是一组,数据解析与组帧是一组,运行状态与模式切换是一组,对外通知回调是一组。于是我把它们拆成四个独立模块,同时保留一个 TestChannel 门面类作为对外的统一入口。

模块文件职责提供的主要能力
IHardwarePort.cs/SerialPortHardware.cs硬件交互与串口/网口设备建立连接,读写原始字节流
DataCollector.cs数据采集读取字节流,完成粘包处理、校验、组帧,产出数据包
ChannelStateMachine.cs状态管理维护连接、就绪、采集中、故障等状态,执行状态迁移规则
ChannelEventBus.cs事件通知统一管理订阅者,发布采集完成、状态变化、异常等事件
TestChannel.cs门面聚合组装以上模块,对外提供 Start/Stop/Connect 等入口方法

拆的时候我一直在提醒自己:文件数量不是目的,依赖方向才是。门面类只做组装,不写业务;采集模块只依赖硬件接口,不感知具体设备;状态模块不直接碰串口;事件模块更不反向去操作采集逻辑。这样无论后续换硬件、改协议、加状态,影响面都控制在单个文件里。

2.2 接口先定下来,实现随后跟

模块化设计里最容易翻车的点是先写实现类,再回头抽接口。因为实现类之间很容易互相引用,抽出来的接口全是妥协,依赖关系依然混乱。我这次反过来,先定义交互接口,再把原代码里的具体逻辑迁移到接口实现类里。

硬件交互层的接口长这样:

public interface IHardwarePort : IAsyncDisposable { ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken); ValueTask WriteAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken); ValueTask ConnectAsync(CancellationToken cancellationToken); }

采集模块依赖的是IHardwarePort,而不是SerialPortHardware。好处是写单元测试时可以 mock 出一个内存端口,想回什么字节就回什么字节,不用真的接设备。后面给采集逻辑单测时,这个方法帮了大忙,很多以前没法覆盖的异常分支都能直接模拟。

2.3 目录结构与文件划分

实际拆分后的目录是这样:

Channel/ ├── TestChannel.cs // 门面,生命周期管理与模块组装 ├── Hardware/ │ ├── IHardwarePort.cs │ └── SerialPortHardware.cs ├── Collector/ │ ├── DataCollector.cs │ └── DataBatch.cs ├── State/ │ ├── ChannelState.cs │ └── ChannelStateMachine.cs └── Event/ ├── ChannelEventBus.cs └── ChannelEventArgs.cs

每个文件行数被控制在 300 行以内,注释覆盖到“为什么这么做”的层面。比如IHardwarePort的ReadAsync注释里特别写了一句:调用方必须处理返回 0 的情况,这表示对端已关闭连接,避免有人把死循环写成无限空转。这类注释在原来的巨型类里根本写不出来,因为逻辑散落在四个回调里,注释写哪儿都别扭。

3. 异步编程:让阻塞调用不再卡住整条链路

3.1 同步改异步,先找准阻塞点

原代码不是没有多线程,而是用了非常原始的SerialPort.DataReceived事件加Thread.Sleep轮询。硬件读取是同步的,一次Read可能要等几十毫秒,期间线程被白白占住;采集线程一边读数据,一边还要处理 UI 刷新,一旦数据量大,整个通道的响应直接变慢。

异步改造不是把每个方法都加上async,那样只会制造一堆无意义的线程切换。真正的切入点是 IO 边界和事件通知两个地方。IO 读取用ValueTask返回,等待期间不占线程;事件发布用异步处理方法,避免一个慢订阅者拖慢整个采集循环。这样做的结果非常明显:原来一个数据包从硬件到达 UI 要经过三个线程切换,现在采集线程读完直接入队,消费者异步批量处理,UI 拿到的延迟反而更稳定。

3.2 用 Channel 做生产消费缓冲

数据采集场景里最典型的模式是“生产者-消费者”。硬件源源不断吐字节,如果每次都立刻同步处理并通知 UI,消费速度稍微慢一点,背压就会直接打到硬件通信上,甚至引发丢包。原来的做法是开一个Queue加锁,满就丢数据,没有任何背压策略,现场经常抱怨采集数据偶发缺帧。

这次我换成了 .NET 内置的System.Threading.Channels.Channel<T>,专门解决这类生产消费队列问题。它内部是无锁并发结构,性能比手动lock高得多,同时支持容量限制和背压策略。我创建了一个有界通道:

private readonly Channel<DataBatch> _dataChannel = Channel.CreateBounded<DataBatch>( new BoundedChannelOptions(1024) { FullMode = BoundedChannelFullMode.Wait });

容量 1024 不是随便拍的。我算过现场实际数据:采样频率 10Hz,每包数据 4KB,正常情况下每秒产生 10 个包,即每秒 40KB。消费者批处理速度大约是每秒 500 包,足够覆盖突发流量。1024 这个深度在内存占用上约等于 4MB,完全可接受,同时能在消费端短暂卡顿时缓冲约 100 秒的数据量。

FullMode = BoundedChannelFullMode.Wait是关键。它表示当队列满时,生产者WriteAsync会主动等待,而不是丢弃数据。这样就把背压反馈机制建立起来了:消费变慢时,采集循环自动放慢,而不是默默丢数。以前我用过DropOldest,表面上不阻塞,但事后对账时发现丢包,排查难度特别大。所以这里宁可使用 Wait 让生产端停下来,也不允许数据悄悄消失。

3.3 取消与生命周期管理

异步重构还有一个容易忽略的点:停止通道时如何优雅退出。原来的代码里有个Stop()方法,只是把_isRunning设为 false,但正在阻塞的Read根本不会退出,线程就一直吊在那儿,最终导致重新启动时状态错乱。

新实现里,我让StartAsync和StopAsync都接收CancellationToken。停止流程是:先取消令牌,然后调用_dataChannel.Writer.TryComplete(),让消费者把队列里剩余的数据处理完,再等待采集任务结束,最后释放硬件资源。每一步都有超时保护,避免某个硬件驱动不响应导致卡死。这个细节后来救了我一次——某台设备固件异常,停止命令发出去后硬件没有任何回应,原来的写法会直接卡死 UI,现在能在 3 秒超时后强制结束并报出清晰错误。

4. 核心代码实现与踩坑记录

4.1 硬件交互层:ReadAsync/WriteAsync 的封装

硬件模块的核心是SerialPortHardware。它拿到SerialPort后,用BaseStream.ReadAsync做真正的异步读写,而不是依赖DataReceived事件。这是我总结出的第一个坑:DataReceived事件在 .NET 里是在线程池回调上触发的,而且不同版本、不同驱动下触发时机有细微差异,用它做生产数据源很容易出现竞态。改用BaseStream.ReadAsync后,所有读取行为都统一在一个循环里,逻辑完全可控。

public async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken) { try { return await _stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) { return 0; } catch (IOException ex) { RaiseError(ex); return 0; } }

这里返回 0 表示没读到数据,上层采集循环看到 0 后要再次等待或者判定为断开,不能直接退出。断线检测的逻辑我放在状态机里,硬件层只管数据读写和异常上报。这种单一职责让硬件替换变得轻松,同一个IHardwarePort后来也实现了网络 Socket 版本,采集层代码一行没改。

4.2 数据采集层:组帧、校验、入队

数据采集是这次拆分收益最大的部分。原来读字节和解析帧混在一起,改动一个解析规则就得把整个读取循环重新看一遍。现在DataCollector里只有一个后台任务,职责非常纯粹:从硬件接口读数据,找帧头,拼完整帧,做校验,封装成DataBatch,写入通道。

组帧时的粘包和半包问题是最常见的坑。我的做法是维护一个_buffer缓冲区,每次ReadAsync返回的数据先追加到缓冲区,然后循环检查里面是否有一个完整帧。找到完整帧就截取出来,剩余数据保留;如果缓冲区的数据不够一帧,就继续等下一批。这个过程不能用简单的Array.Copy硬编码,必须用可增长的缓冲结构,否则遇到大帧就会越界。我在注释里明确画了两种包的拆分示意,后续维护的人即使不懂协议,也能照着边界处理。

校验环节更值得细说。原代码在硬件接收线程里做校验,校验失败的帧直接丢,什么都不记。我重构后保留丢帧逻辑,但额外发布一个FrameInvalidEvent,带上原始长度和校验结果。这样现场再报“数据不对”时,终于有了排查依据,而不是两眼一抹黑。

4.3 状态管理:状态机独立成类

状态管理原来的实现是几个bool字段拼出来的,_isConnected、_isRunning、_isCollecting组合起来有 8 种状态,但真正合法的只有 4 种,其他组合全靠开发者自觉避免。这次我重构成一个标准状态机,只允许显式迁移。

public sealed class ChannelStateMachine { private readonly object _sync = new object(); private ChannelState _currentState = ChannelState.Disconnected; public ChannelState CurrentState { get { lock (_sync) return _currentState; } } public bool TryTransitTo(ChannelState target, out string reason) { lock (_sync) { if (!IsValidTransition(_currentState, target)) { reason = $"{_currentState} -> {target} is invalid"; return false; } _currentState = target; reason = string.Empty; return true; } } }

状态迁移表被定义成一个二维数组,IsValidTransition查表判断。比如只有Connected才能进入Collecting,只有Collecting才能回到Ready,Faulted只能跳到Disconnected。非法迁移不再靠程序员自律,而是代码层面直接拦住。事件通知模块会在状态变更时收到通知,UI 那边永远只看到合法状态值,不会出现“又连接又断开”的鬼畜界面。

lock在这里是必要的,因为状态可能在硬件事件线程、采集线程、外部控制线程三个地方被修改。有人可能想用volatile,但状态迁移不是单字段读写,它包含“检查目标态再赋值”的复合操作,必须加锁保证原子性。这个我也在注释里写清楚了,避免后来的人把lock当成性能瓶颈乱删。

4.4 事件通知:统一总线,避免回调地狱

旧的 TestChannel 对外公开了十多个委托属性,UI 可以直接给某个事件赋值。表面看很灵活,但实际使用中,经常出现“在事件 A 里订阅事件 B,又在事件 B 里调用事件 A”的循环触发。这次我把所有通知收拢到ChannelEventBus里,对外只提供Subscribe和Publish两种操作。

public sealed class ChannelEventBus { private readonly ConcurrentDictionary<Type, List<Delegate>> _handlers = new(); public IDisposable Subscribe<TEvent>(Func<TEvent, ValueTask> handler) where TEvent : ChannelEventArgs { var type = typeof(TEvent); var list = _handlers.GetOrAdd(type, _ => new List<Delegate>()); lock (list) { list.Add(handler); } return new UnsubscribeToken(this, type, handler); } public async ValueTask PublishAsync<TEvent>(TEvent eventArgs) where TEvent : ChannelEventArgs { var type = typeof(TEvent); if (!_handlers.TryGetValue(type, out var list)) return; List<Delegate> snapshot; lock (list) { snapshot = new List<Delegate>(list); } foreach (var handler in snapshot) { if (handler is Func<TEvent, ValueTask> typedHandler) { await typedHandler(eventArgs); } } } }

通过ConcurrentDictionary按事件类型存订阅者,发布时只通知对应类型,不再像以前那样把一堆事件桶全遍历一遍。Subscribe返回一个UnsubscribeToken,让订阅方可以用语句块订阅、自动取消,避免内存泄漏。

这里有个细节:为什么发布时要把订阅者列表先快照一份?因为订阅/取消订阅和事件发布可能发生在不同线程,直接遍历原列表会在某次快速刷新时抛“集合已修改”异常。快照虽然多了一点分配,但换来了发布期间的稳定性,实测在每秒 50 次事件、频繁订阅退订的场景下没有任何问题。

4.5 常见问题与排查速查表

重构不是一锤子买卖,单元测试和现场验证阶段我排掉了不少问题,整理成一张速查表:

常见问题可能原因解决思路
UI 响应越来越慢消费端处理太慢,队列塞满后生产者 Wait增加消费者数量,或把事件处理拆成多个批次异步执行
停止操作卡死超过 5 秒硬件ReadAsync没有遵守 CancellationToken给 StopAsync 加超时,超时后强制释放硬件资源
偶发数据丢帧校验失败或队列容量满后被丢弃事件总线上增加 InvalidFrameEvent,统计丢帧原因
状态显示异常外部直接改了状态字段,没有走状态机状态字段改为只读,所有状态变更必须调用 TryTransitTo
事件重复触发订阅方法被注册多次,没有正确退订使用 Subscribe 返回的 IDisposable,声明周期结束即退订

还有一个从实践中得来的避坑经验:在异步方法里千万不要调用.Result或.Wait()去同步等待异步任务。一旦线程池资源不够,就会出现死锁,程序看起来像没响应,实际是等待链被卡住。我在采集循环里吃过一次大亏,排查很久才发现某个业务模块用eventTask.AsTask().GetAwaiter().GetResult()把异步事件同步化,直接导致采集线程阻塞。统一改成await之后,采集链路就再也没出现过这种问题。

另外,把ConfigureAwait(false)用在中间层代码里是个好习惯。采集模块不关心 UI 上下文,继续等待时不需要强行回到主线程,能减少上下文切换。但 UI 层的事件处理器不要用ConfigureAwait(false),否则会出现界面线程安全问题。这个边界我至少给三处代码写过注释,后来同事接手时一眼就能看懂哪些方法能任意加await,哪些必须回到 UI 线程。

5. 重构后给我最大的几个改变

这次重构之后,TestChannel 从 2300 行变成了每个文件 200 到 300 行,单个类一眼看得到全貌。最直观的好处是测试终于能写了。以前想测采集逻辑必须真接一台设备,现在只要 mock 一个IHardwarePort,喂它一段精心构造的字节流,就能断言组帧、校验、状态迁移是否按预期执行。上周我新加的协议帧类型,整个测试用例写下来不到半小时,这在重构之前是不可想象的。

更重要的改变是心态。以前只要产品经理说“加一个采集项”,我的第一反应是“又要动那坨代码了,别炸就行”。现在每个改动点都有明确归属:改协议去采集层,改状态规则去状态机,改通知频率去事件总线。即使出了问题,也从“四处翻代码”变成了“按模块定位”,排查效率提升非常明显。

如果你也想拆一个类似的“上帝类”,我的建议很简单:先别急着动文件,把原类里的方法按依赖关系画一遍,搞清楚谁调谁、谁该依赖谁,再开始迁移代码。每次拆分提交都要保持能编译、能运行,宁可拆慢一点,也不要让中间态变成另一个维护噩梦。异步化同样如此,先找出真正的 IO 阻塞点和共享状态边界,改成 async/await 才有意义,否则只是在代码里多撒了一堆Task.Run,反而更乱。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询