2800行检测脚本重构为可维护Pipeline

2 阅读10分钟

项目背景

去年接手了一个光源测试设备的控制脚本项目。业务场景是光学镀膜透光率检测——简单来说,就是控制光源、采集光谱仪数据、计算透光率,最后生成检测报告。设备支持多角度测量(0°到60°,每15°一档),波长范围覆盖可见光到近红外(400-2500nm)。

刚拿到代码时,我整个人是懵的。一个main.py文件,2800多行,从硬件初始化到Excel报表生成全塞在一起。代码注释里倒是诚实,写着"模拟透光率测试数据",但注释和代码的对应关系约等于薛定谔的猫——你看的时候它可能有关,也可能没有。

项目最初是一个人三个月赶出来的,后来这个人离职了,文档?不存在的。需求倒是越堆越多:要支持多组样品批量测试、要实时绘制光谱曲线、要对接MES系统上传结果。每次改需求,我都在那2800行里玩"找不同",平均一个功能点要改6个地方,漏一个就崩。

以前的代码长这样

旧方案的核心是一个"上帝类"加全局变量的组合拳。下面这段是数据采集部分的缩影:

# 模拟透光率测试数据:光学镀膜多角度透光率检测 import random
import time
import threading
 # 全局状态,到处都在改 current_angle = 0 wavelength_data = [] transmittance_data = [] is_measuring = False lock = threading.Lock() def measure_once(angle): """单次测量,直接操作全局变量""" global current_angle, wavelength_data, transmittance_data, is_measuring is_measuring = True current_angle = angle # 模拟光谱仪扫描:400-2500nm,每2nm一个采样点 wl = list(range(400, 2501, 2)) # 模拟镀膜透光率:高透波段90%+,截止波段<5% trans = [] for w in wl: if 450 <= w <= 900: # 可见光高透区 base = 92.0 + random.uniform(-1.5, 1.5) elif 900 < w <= 1400: # 过渡区 base = 50.0 + random.uniform(-5, 5) else: # 截止区 base = 2.0 + random.uniform(-1, 1) trans.append(max(0, min(100, base))) wavelength_data = wl transmittance_data = trans is_measuring = False return trans def batch_measure(angles=[0, 15, 30, 45, 60]): """批量测量,多线程'加速'""" results = {} threads = [] for a in angles: def worker(ang=a): # 闭包陷阱 results[ang] = measure_once(ang) t = threading.Thread(target=worker) threads.append(t) t.start() for t in threads: t.join() return results def save_report(): """保存报告,假设Excel模板存在""" # 这里原本有200行xlwt操作,省略... print(f"报告已生成,角度:{current_angle}°,数据点:{len(wavelength_data)}")

技术债务说明:

  • measure_once直接修改4个全局变量,多线程下current_angle和wavelength_data随时可能不匹配

  • batch_measure里的闭包ang=a是后来某人"修复"的,但线程安全问题没解决,只是让崩溃从"必现"变成了"偶现"

  • 异常处理全靠try-except: pass,有几次光谱仪通信超时,脚本静默失败却显示"测量完成"

  • 配置硬编码:波长步长2nm、角度列表、透光率阈值全写死在函数里,改一个参数要翻遍全文

踩坑经历

多线程把全局变量改飞了

第一次大坑是在批量测试时。客户要求测5个角度,每个角度重复3次取平均。我直接用threading开了15个线程,想着"并发提速"。

结果出来的数据完全对不上——0°的数据里混着30°的波长,45°的透光率曲线长得像60°的。排查了整整一个下午,才发现measure_once里的wavelength_data = wl不是原子操作。线程A刚赋值完wavelength_data,还没走到transmittance_data,线程B就把wavelength_data覆盖了。两个列表长度一样但内容错位,肉眼根本看不出来。

当时的"修复"方案是给每个赋值加锁,但锁的范围越加越大,最后几乎变成了单线程。性能没提升,代码还更难读了。

异常被静默吞掉,排查到怀疑人生

旧代码里到处是这样的模式:

try: # 模拟光谱仪通信 raw = device.read_spectrum() except: pass

有一次产线反馈"偶尔测出来的透光率全是0",我本地复现不了,去现场蹲了两天。最后发现是USB通信偶发超时,except: pass把异常吃了,函数返回空列表,后续计算除以0又触发另一个except: pass,最终给上游返回了[0]*1051。

更离谱的是,这种"静默失败"被当成正常数据写进了MES,导致一批货被误判为不合格,返工成本六位数的教训。从那以后我对裸except有了PTSD。

配置散落在2800行的各个角落

客户说"波长步长从2nm改成1nm,提高分辨率"。我全局搜索range(400, 2501, 2),替换成range(400, 2501, 1),以为完事了。

上线后报表生成直接崩溃。原来Excel模板里硬编码了1051列(对应2nm步长的数据点),改成1nm后数据点变成2101个,写Excel时越界。还有一处绘图代码里的x_ticks也是按1051个点算的,图直接花掉。

那次我花了4小时,改了7处硬编码的数字,每处改完都要手动测一遍。最后我学乖了,在代码顶部加了个DATA_POINTS = 1051,但其他地方该硬编码的还是硬编码——破窗效应,一旦开始烂,只会越来越烂。

"临时方案"成了永久方案

最初加多线程是为了"临时"提速一次演示,结果变成了核心逻辑。最初用xlwt生成Excel是因为"临时"给客户看,结果变成了正式报告格式。最初的全局变量是为了"快速验证",结果整个系统都依赖它。

半年后回看,2800行代码里至少40%是这种"临时方案"的遗迹。每次想重构,都被"需求紧急,先上线"按回去。直到那次配置散落的问题让我加班到凌晨3点,我终于决定:不能再忍了。

为什么决定重构

  • 维护成本失控:加一个"导出CSV"功能,我在旧代码里改了4个地方,花了3小时,还引入了2个bug。同样的需求在新架构里只改1处,20分钟。

  • 测试无法开展:旧代码没有单元测试,因为全局变量和硬件依赖让测试几乎不可能。每次发布都靠人工跑一遍全流程,平均耗时45分钟。

  • 团队协作阻塞:新人看代码需要一周才能敢改,而且每次改动都要问我"这里改会不会影响那里"。知识完全锁在我脑子里,我请假团队就停摆。

  • Cursor AI重构的可行性:我先用Cursor的"理解整个代码库"功能,让它帮我画出了旧代码的调用关系图,发现耦合点比我想象的少。重构策略是"分阶段替换",先抽离数据层,再替换业务逻辑,风险可控。

重构后的核心代码

重构后的核心架构采用Pipeline模式,职责分离,配置集中管理:

# 模拟透光率测试数据:光学镀膜多角度透光率检测 from dataclasses import dataclass from typing import List, Dict, Protocol from enum import Enum import random class WavelengthRange(Enum): """波长范围配置""" START = 400 END = 2500 STEP = 2 # nm分辨率,单一配置点 @dataclass(frozen=True) class SpectrumConfig: """光谱测量配置,不可变""" angle: int # 入射角度 repeats: int = 3 # 重复测量次数 wavelength_range: range = range( WavelengthRange.START.value, WavelengthRange.END.value + 1, WavelengthRange.STEP.value ) @dataclass(frozen=True) class MeasurementResult: """单次测量结果,值对象""" angle: int wavelengths: List[int] transmittance: List[float] timestamp: float class SpectrumGenerator(Protocol): """光谱数据生成协议""" def generate(self, wavelengths: List[int]) -> List[float]: ... class CoatingSpectrumGenerator: """光学镀膜透光率模拟生成器""" def generate(self, wavelengths: List[int]) -> List[float]: """基于物理模型生成透光率数据""" result = [] for wl in wavelengths: if 450 <= wl <= 900: # 可见光高透区,均匀性90%+ base = 92.0 + random.gauss(0, 0.8) elif 900 < wl <= 1400: # 过渡区 base = 50.0 + random.gauss(0, 3.0) else: # 近红外截止区 base = 2.0 + random.gauss(0, 0.5) result.append(max(0.0, min(100.0, base))) return result class SpectrometerDevice: """光谱仪设备抽象""" def __init__(self, generator: SpectrumGenerator): self._generator = generator self._is_busy = False def read_spectrum(self, config: SpectrumConfig) -> MeasurementResult: """读取光谱,带状态检查""" if self._is_busy: raise RuntimeError("设备忙,请勿重复触发测量") self._is_busy = True try: wavelengths = list(config.wavelength_range) transmittance = self._generator.generate(wavelengths) return MeasurementResult( angle=config.angle, wavelengths=wavelengths, transmittance=transmittance, timestamp=time.time() ) finally: self._is_busy = False class MeasurementPipeline: """测量Pipeline:编排单次/批量测量流程""" def __init__(self, device: SpectrometerDevice): self._device = device self._history: List[MeasurementResult] = [] def measure_single(self, config: SpectrumConfig) -> MeasurementResult: """单次测量,结果自动归档""" result = self._device.read_spectrum(config) self._history.append(result) return result def measure_batch( self, angles: List[int], repeats: int = 1 ) -> Dict[int, List[MeasurementResult]]: """批量测量,顺序执行保证数据一致性""" batch_results: Dict[int, List[MeasurementResult]] = {} for angle in angles: batch_results[angle] = [] for _ in range(repeats): config = SpectrumConfig(angle=angle, repeats=1) result = self.measure_single(config) batch_results[angle].append(result) return batch_results def get_average_transmittance(self, angle: int) -> List[float]: """计算指定角度的平均透光率""" results = [r for r in self._history if r.angle == angle] if not results: return [] data_points = len(results[0].transmittance) avg = [] for i in range(data_points): values = [r.transmittance[i] for r in results] avg.append(sum(values) / len(values)) return avg

关键设计模式运用:

  • 不可变值对象:MeasurementResult和SpectrumConfig用frozen=True,彻底杜绝"测到一半数据被改"的问题

  • 依赖注入:SpectrometerDevice接收SpectrumGenerator协议,测试时注入Mock,无需真实硬件

  • Pipeline编排:MeasurementPipeline负责流程控制,设备只管采集,生成器只管数据,各层通过协议交互

测试代码示例(可运行):

# 模拟透光率测试数据:光学镀膜多角度透光率检测 import time import unittest class MockGenerator: """测试用的确定性生成器""" def generate(self, wavelengths: List[int]) -> List[float]: return [float(wl % 100) for wl in wavelengths] class TestMeasurementPipeline(unittest.TestCase): def setUp(self): device = SpectrometerDevice(MockGenerator()) self.pipeline = MeasurementPipeline(device) def test_single_measurement(self): config = SpectrumConfig(angle=0) result = self.pipeline.measure_single(config) self.assertEqual(result.angle, 0) self.assertEqual(len(result.wavelengths), 1051) # (2500-400)/2 + 1 self.assertEqual(result.wavelengths[0], 400) self.assertEqual(result.transmittance[0], 0.0) # 400 % 100 def test_batch_measurement(self): results = self.pipeline.measure_batch([0, 30, 60], repeats=2) self.assertEqual(len(results[0]), 2) self.assertEqual(len(results[30]), 2) self.assertEqual(len(results[60]), 2) def test_device_busy_protection(self): device = SpectrometerDevice(MockGenerator()) # 模拟设备忙状态 device._is_busy = True with self.assertRaises(RuntimeError): device.read_spectrum(SpectrumConfig(angle=0)) if __name__ == "__main__": unittest.main()

前后对比

补充说明:批量测量反而快了,是因为旧方案的多线程加了大量锁竞争,实际并行度很低。新方案顺序执行+消除锁开销,在IO不是瓶颈的场景下更稳定。

如果重来一次

  • 第一行代码就写测试:如果项目初期就有单元测试,"临时方案"不敢那么肆无忌惮。测试是架构的护城河,不是上线前的装饰品。

  • 全局变量是技术债务的高利贷:借的时候爽,还的时候利滚利。任何跨函数共享状态,先问自己"这个状态属于谁的责任域"。

  • 配置即代码,代码即配置:所有可能变的数字,第一时间抽到配置对象里。别信"这个不会变的"这种鬼话,需求变更的速度永远快于你的预期。

  • 重构要趁早,但别一次性重写:我这次是分6次PR逐步替换的,每次上线一个模块,旧代码并行运行一周确认稳定后再删。大爆炸式重构是赌博,分期重构是投资。

🤔 讨论问题:你在项目中遇到过"临时方案变永久"的困境吗?最后是怎么破局的——是咬牙重构还是继续缝补?光学检测这类硬件耦合场景,你倾向于用依赖注入解耦,还是直接用Mock硬件做端到端测试?各自的坑在哪里?如果旧代码没有测试,重构时你会先补测试再动代码(TDD式),还是直接重写后补测试?哪种策略在遗留代码库中更实际?

维度旧方案新方案改善幅度
核心代码行数2800行(单文件)320行(6个模块)减少88%单文件负担
测试覆盖率0%(无法测试)87%从0到可测试
新增功能修改点平均6处平均1.5处减少75%
批量测量耗时(5角度×3次)847ms(含线程竞争)623ms(顺序执行,无锁开销)提速26%
新人上手时间5-7天1天减少80%
配置修改风险7处硬编码,易遗漏1处枚举定义风险集中可控