做半导体可靠性测试机上位机那段时间,我最怕听到的一句话就是“下周再加三种测试项”。不是因为写代码难,而是主流程里那个巨大的 switch-case 已经被塞了几十个测试类型分支,每次新增逻辑都要从几百行的流程代码里找到正确的位置,小心翼翼地把新分支挂上去,还得祈祷不要碰坏旁边正在跑的高温老化流程。后来我下定决心,用策略模式(Strategy Pattern)把这套基于 .NET Core 的上位机系统重构成“策略即组件”的结构,把不同的应力测试方案、设备通信协议、失效判定规则全部拆成独立策略,运行时根据测试编码动态装配。重构完成之后,系统连续稳定跑了好几个月,新测试项基本只需要新增一个类、注册一行配置,主流程完全不用动。这篇文章就围绕这次重构,聊聊策略模式在半导体可靠性测试机上位机中的完整落地思路,包括接口设计、依赖注入、运行时解析、组合策略、安全联动,以及那些只有踩过坑才会知道的细节。适合正在做测试系统、工业上位机、或者想把设计模式真正用进业务的朋友参考。
1. 先交代战场:上位机系统那些“必须按批次切换”的变化点
1.1 可靠性测试机上位机到底在管什么
很多人一听“半导体可靠性测试”,以为就是拿个万用表测一测。真实场景比这复杂得多。测试机通常由老化箱或温控腔体、多路电源、源测量单元(SMU)、采集板卡组成,一次可以同时测试几十甚至几百颗器件。上位机软件的职责是:根据配方控制温箱升温或降温,按照测试项要求给器件施加偏压,周期性采集漏电流、阈值电压、工作电流等电参数,根据判据判断器件是否失效,把数据入库,并在异常时触发保护动作。
这里天然存在大量变化点。同一台设备可能要执行 HTOL(高温工作寿命)、HTGB(高温栅偏)、THB(高温高湿偏压)、TC(温度循环)等不同测试项。不同客户、不同批次对失效定义不一样,有的要求超限立即判废,有的要求连续三次超限才判失效。温控设备可能来自不同供应商,有走 Modbus RTU 的,有走 Modbus TCP 的,还有走私有帧协议的。这些“不同”放在同一个人上位机里,就是一堆需要管理的变化。
1.2 最初的代码为什么越改越重
我接手的时候,这套上位机的核心是一个叫作“流程管理器”的类,里面除了主流程,还有一个按测试代码分发的 switch-case,每个 case 下面跟一大段执行逻辑。一开始只有两三种测试项,这样写确实简单直接。但测试项慢慢增加到十几种以后,问题就来了。
首先是分支之间互相污染。某个流程里顺手改了公共的采样标志位,另一个测试项运行时就踩到了。最典型的一次,有人在 HTOL 分支里加了一个“先预热再开始采集”的逻辑,结果 THB 测试跑到一半突然多了一段不明来源的等待时间,查了半天才发现是共享变量在作怪。其次是主流程被改得没法读。升温、保温、采参、判定、数据落库,这些步骤和具体的测试项逻辑紧紧耦合在一起,想抽出公共框架,根本无从下手。最要命的是回归验证范围越来越大,每次改动都感觉全系统随时可能崩。
这类问题的本质,是算法族之间没有隔离。“测试执行方案”作为一个整体,被按测试类型硬切成几十个分支,每加一种方案就要改主流程。当变化维度和变化频率都明显时,就该考虑用策略模式了。
1.3 为什么是策略模式而不是模板方法或者观察者
当时也有同事建议用模板方法,把“升温-保温-施压-采集-降温-判级”作为固定骨架,让子类去重写每一步。模板方法确实可以解决一部分问题,但实际操作中,不同测试项的骨架差异比想象中大。有的测试项需要先做预处理,有的中间要切换温区,有的数据采集是连续采样,有的只是定时快照。强行把它们套进同一个骨架,要么抽象类里堆满空实现,要么就会冒出大量钩子方法。
策略模式的好处在于,它把整套执行方案当作一个可替换的算法族。一个测试项对应一个策略对象,运行时按需取用,并不强制所有策略共享同一个骨架。执行方案内部到底怎么组织,是策略自己的事。至于有没有公共步骤,完全可以通过后续引入模板方法去优化,这叫策略模式和非模板方法是组合关系而不是替代关系。
观察者模式当时也被提到,但我们需要的不是“状态改变时通知多个对象”,而是“针对不同情况选择不同算法”,显然策略模式更贴合。说白了,选择模式的依据是变化点长什么样,策略模式解决的是“同一件事有不同做法、希望运行时自由切换”的问题,这正是测试机上位机最真实的需求。
2. 先把领域语言翻译成接口:测试项、上下文、策略边界的第一次划定
2.1 IStressTestStrategy 接口的取舍
重构第一步不是写实现,而是定义策略接口。我当时定义的接口非常简单,直接就动手了:
public interface IStressTestStrategy { string Name { get; } Task<StressResult> ExecuteAsync(StressContext context, CancellationToken cancellationToken); }Name 用来标识一个策略,对应配方表里的测试编码;ExecuteAsync 接收上下文和取消令牌,返回测试结果。之所以用异步方法,是因为实际上位机里所有设备操作都是 IO 密集型,温控、源表读写、通信都要异步执行,否则 UI 线程或者工位调度线程会被卡死。
方法签名没有设计成一长串参数,而是传一个 StressContext 对象。这是很关键的一个设计决策。测试项执行过程中要接触的东西太多了:配方参数、温控设备代理、电源代理、采样器、数据收集器、日志服务。如果全部作为参数传进去,接口会非常脆弱,每次增加一个依赖都要改所有实现,策略模式反而成了维护负担。用一个上下文对象把这些运行时依赖装起来,接口就稳定了。
2.2 StressContext 应该装什么、不应该装什么
我设计的 StressContext 大致长这样:
public sealed class StressContext { public TestRecipe Recipe { get; set; } public IChamberProtocolStrategy Chamber { get; set; } public IPowerSupplyStrategy PowerSupply { get; set; } public ISamplingStrategy Measurer { get; set; } public IFailureJudgementStrategy Judgement { get; set; } public IDataCollector Collector { get; set; } public ILogger Logger { get; set; } }每个工位在执行测试之前,都会先创建一个独立的 StressContext,把当前工位的配方和设备代理放进去。这样不同工位之间天然隔离,策略对象本身保持无状态。这里有一个很重要的原则:上下文可以放服务接口,但不要把 IServiceProvider 塞进去。一旦上下文变成服务定位器,策略内部就会隐藏大量依赖关系,测试和排错都会变得困难。我在重构初期犯过这个错误,某个策略直接从上下文里取出服务容器自己解析数据库服务,导致单元测试完全没法做,后来全部改成了显式属性。
StressResult 也是重构中专门定义的返回类型,包含测试项编码、开始结束时间、是否通过、采样摘要、失效信息。这里要避免让策略直接返回裸布尔值,因为上位机后续要追溯完整的测试过程,失败原因、关键样本、耗时指标都很有价值。
2.3 策略模式和模板方法在这个场景下的结合
虽然前面说了模板方法不适合作为主结构,但它可以成为某个策略内部的实现手段。举个例子,我后来发现很多测试项的流程轮廓确实是一样的:配置参数、执行老化过程、采集数据、清理设备。于是我为这类策略写了一个抽象基类:
public abstract class BaseStressStrategy : IStressTestStrategy { public abstract string Name { get; } public async Task<StressResult> ExecuteAsync(StressContext context, CancellationToken ct) { await ConfigureAsync(context, ct); await RunAsync(context, ct); await CollectAsync(context, ct); await CleanupAsync(context, ct); return BuildResult(context); } protected abstract Task ConfigureAsync(StressContext context, CancellationToken ct); protected abstract Task RunAsync(StressContext context, CancellationToken ct); protected virtual Task CollectAsync(StressContext context, CancellationToken ct) => Task.CompletedTask; protected virtual Task CleanupAsync(StressContext context, CancellationToken ct) => Task.CompletedTask; protected virtual StressResult BuildResult(StressContext context) => new StressResult(); }这样做的好处是:策略接口作为统一入口保持不变,框架层只依赖接口;具体策略内部可以用模板方法管理流程,子类只需要实现差异化步骤。当时我为了避免策略类里出现大段重复代码,才补了这层抽象。后来维护起来确实舒服很多,不是所有策略都需要继承这个基类,比如某些特殊测试项根本没有 Configure 和 Collect 阶段,直接实现接口就行。
3. .NET Core依赖注入下的策略注册与运行时切换:从 switch-case 到配置驱动
3.1 策略的注册方式和生命周期怎么选
.NET Core 自带的依赖注入容器天然适合做策略管理。把所有策略实现注册为同一接口的不同实现,再用一个工厂类负责按名称解析,switch-case 就可以彻底删除。注册代码很简单:
services.AddScoped<IStressTestStrategy, HtolStressStrategy>(); services.AddScoped<IStressTestStrategy, HtgbStressStrategy>(); services.AddScoped<IStressTestStrategy, ThbStressStrategy>();这里我特别想提醒一点:策略的生命周期选择会直接影响系统稳定性。最安全的做法是把策略注册为 Transient 或 Scoped,然后通过 IServiceProvider 在运行时解析,保证每个工位拿到的是独立的策略实例。如果把策略注册为 Singleton,同时策略内部又存了可变状态,那多个工位并发跑测试时会互相污染,出现漏电流数据串到别的工位这种严重问题。
一开始,我还真在工厂里用构造函数注入了一堆具体策略,比如:
public StressStrategyFactory(HtolStressStrategy htol, HtgbStressStrategy htgb, ...)这种写法的问题有三个:策略数量多了以后构造函数会变得不可维护;工厂和具体策略强耦合,失去了“新增策略不动现有代码”的意义;如果以后想用配置文件驱动策略解析,这种写法完全做不到。所以工厂必须依赖 IServiceProvider。
3.2 用策略工厂替代 switch-case 的具体实现
我实现的工厂类大致是这样:
public interface IStressStrategyFactory { IStressTestStrategy GetStrategy(string testCode); } public sealed class StressStrategyFactory : IStressStrategyFactory { private readonly IServiceProvider _serviceProvider; private readonly IReadOnlyDictionary<string, Type> _strategyMap; public StressStrategyFactory(IServiceProvider serviceProvider) { _serviceProvider = serviceProvider; _strategyMap = new Dictionary<string, Type>(StringComparer.OrdinalIgnoreCase) { ["HTOL_125C_10V"] = typeof(HtolStressStrategy), ["HTGB_150C_20V"] = typeof(HtgbStressStrategy), ["THB_85C_85RH"] = typeof(ThbStressStrategy) }; } public IStressTestStrategy GetStrategy(string testCode) { if (!_strategyMap.TryGetValue(testCode, out var type)) { throw new KeyNotFoundException($"找不到测试策略: {testCode}"); } return (IStressTestStrategy)_serviceProvider.GetRequiredService(type); } }工厂在启动时构建一个“测试编码到实现类型”的字典。运行时传入的 testCode 来自配方数据,查字典找到类型,再从容器解析实例。这样做的好处是,新增策略根本不需要修改工厂类和主流程,只需要在注册代码里加一行,并在工厂的映射字典里加一条记录。
如果嫌字典里的映射太死板,还可以用命名约定:策略类的名称本身就带有测试编码前缀,或者通过特性标注。比如:
[StrategyCode("HTOL_125C_10V")] public sealed class HtolStressStrategy : IStressTestStrategy然后利用反射在程序集里扫描所有带 StrategyCodeAttribute 的策略类,自动注册并建映射。这样连工厂的字典都不用手动维护了。我在项目里最终用了反射扫描,当时完全是为了减少新策略上线时的遗漏风险。反射扫描省事,但也会让代码可读性下降,第一次做的话建议先把字典方式跑通,再加扫描。
3.3 从数据库配置到策略实例的完整链路
策略模式真正的价值在运行时切换。体现在这套系统里,就是“配方驱动策略”。配方表里有个测试编码字段,比如 HTOL_125C_10V,上位机调度线程读取配方后,会把这个编码传给 StressStrategyFactory,工厂解析出对应策略,再交给工位执行。
伪代码如下:
public async Task RunTestAsync(int stationId, TestRecipe recipe, CancellationToken ct) { var context = BuildContextForStation(stationId, recipe); var strategy = _strategyFactory.GetStrategy(recipe.TestCode); var result = await strategy.ExecuteAsync(context, ct); await _resultStore.SaveAsync(stationId, result, ct); }现场调试时,工程师只需要在数据库里把某个工位的配方改成新的测试编码,不需要停掉整个系统再重新编译,下一次调度就会自动加载新策略。这种体验和使用旧的 if-else 流程完全不同,改动风险从“改主流程影响所有测试项”下降到了“只影响新增的那个策略类”。
4. 从单个策略到一组策略:协议适配、采集、存储的拆分与组合
4.1 设备协议适配器为什么要做成策略
上位机对接的设备五花八门,温控器是重灾区。有的设备用 Modbus RTU,有的用 Modbus TCP,还有的自己定义帧格式。最开始我把设备驱动写成一套类,内部用协议类型字段分支处理,结果每加一台设备就要修改驱动类,而且测试环境里没有实物设备时根本没法验证。
后来我抽了一个接口:
public interface IChamberProtocolStrategy { Task ConnectAsync(CancellationToken ct); Task<double> ReadTemperatureAsync(CancellationToken ct); Task SetTemperatureAsync(double target, CancellationToken ct); Task StartRampAsync(double target, double rate, CancellationToken ct); }每个协议对应一个实现。Modbus RTU 一个类,Modbus TCP 一个类,私有协议一个类。StressContext 里的 Chamber 属性就是这个接口类型。这样测试策略本身完全不需要关心温控器到底是串口还是网口连的,只调用接口方法就行。新接入一台设备,等于新加一个协议策略,主流程和测试策略都不动。这个改动让现场接入新设备的时间从一两周缩短到一两天,主要工作变成了纯通信调试。
4.2 数据采集和存储策略的异步组合
数据采集也是个明显的策略点。不同测试项对采样频率的要求差别很大。有些高温老化测试每五分钟采一次参数就行,有些动态测试需要毫秒级连续采集。于是我把“怎么采”抽成了 ISamplingStrategy:
public interface ISamplingStrategy { Task<SampleBatch> SampleAsync(StressContext context, CancellationToken ct); }连续采集和定时快照就是两个不同的实现。
存储也一样。一开始所有采样点都实时写数据库,工位一多数据库压力非常大,写库经常成为瓶颈。我抽了 IDataCollector 接口,实现分为直写数据库和先写本地缓冲、批量落库两种模式。测试策略里只调用 collector.Add(sample),具体是立刻入库还是攒一批再入库,由数据收集策略自己决定。后来压力测试发现批量落库模式更合适,我直接改配置切换存储策略,测试流程代码一行都没动。
4.3 组合策略时最容易搞乱的依赖方向
单个策略好写,策略组合起来容易乱。比如 HTOL 策略执行时,要看温控策略,要用采样策略,还要用判定策略。一种做法是把这些依赖全部塞进 StressContext,让策略自己取,这会导致上下文膨胀。另一种做法是在工厂里组装,策略之间只通过构造函数传递所需子策略:
public sealed class HtolStressStrategy : IStressTestStrategy { private readonly IChamberProtocolStrategy _chamber; private readonly ISamplingStrategy _sampling; private readonly IFailureJudgementStrategy _judgement; public HtolStressStrategy(IChamberProtocolStrategy chamber, ISamplingStrategy sampling, IFailureJudgementStrategy judgement) { _chamber = chamber; _sampling = sampling; _judgement = judgement; } }不过要注意,这种方式必须保证所有子策略都注册在 DI 容器里。而且组装关系一旦写在构造函数里,就要求策略之间相对稳定,不能随便变。如果组合关系频繁变动,建议用抽象工厂或者构建器模式来管理。我在实际项目中,把“判断哪些子策略组合在一起”的逻辑放在了专门的策略组装器里,Htol 使用 Htol 组合,Htgb 使用 Htgb 组合,这样既清晰又方便测试。组装配器是工厂模式的延伸,和策略模式并不冲突,反而让整套结构更完整。对于中小型项目来说,直接通过 DI 和构造函数组装已经够用了,组装器可以等组合数量确实多起来再引入。
5. 失效判定与安全联动的策略化:保护通道必须单独走
5.1 三种失效判定策略的设计思路
失效判定是可靠性测试机最核心的业务逻辑之一。判据通常包括漏电流上限、阈值电压漂移范围、工作电流偏差等等。但不同客户对“失效”的定义完全不同,有的要求某一项超限就立即判废,有的要求连续三次采样都超限才算失效,还有的要求看趋势,比如漏电流在若干小时内持续抬升也要报失效,即使绝对值还没超限。
针对这些差异,我定义了判定策略接口:
public interface IFailureJudgementStrategy { FailureVerdict Check(SampleRecord record, LimitRule rule, IReadOnlyList<SampleRecord> history); }实现了三种策略:固定阈值判定、滑动窗口趋势判定、复合判定。固定阈值最简单,超了就判废。趋势判定会拿最近 N 个样本做线性拟合,斜率超阈值就报警。复合判定把多个判据组合起来,按优先级返回最终结论。测试策略每采到一个样本,就把当前记录和历史数据交给判定策略,由策略决定继续测、判废、还是仅记录警告。
5.2 保护联动为什么不能当成普通策略
上位机还有一条不能乱的线:安全保护。过温、过流、通信中断这些情况属于保护通道,必须立刻响应,不能等技术任的某个采集循环跑完才被发现。我单独定义了保护策略接口,并让它在执行时拥有更高的优先级:
public interface IProtectionStrategy { int Priority { get; } Task ExecuteAsync(StressContext context, ProtectionTrigger trigger, CancellationToken ct); }保护策略包括超温断电、过流保护停机、通信中断后的安全归位等。它和业务测试策略是两条执行线。当监控线程发现温箱温度超出安全区间,会直接触发保护策略,立即切断对应工位的电源施加,而不是等着正在执行的测试策略自己处理。这个设计是因为曾经踩过真实教训:某个策略在长时间循环里等待设备返回数据,温度已经超过上限却没人干预,后来把保护逻辑独立出来才解决。
5.3 多工位并发下的状态隔离和实例安全问题
上位机最重要的并发场景是多工位同时测试。不同工位跑不同策略,每个工位有独立的采样数据。如果策略实例被多个工位共享,而且策略内部保存了“当前样本列表”之类的状态,那就会出现数据串线。实际中就发生过工位 A 的漏电流曲线突然混进工位 B 的数据,排查了两天才定位到是某个策略类里意外写了一个实例字段,保存了上一个工位遗留的采样数组。
解决方案其实很简单:策略类保持无状态,所有运行期数据都放到 StressContext 里。每个工位创建自己的 StressContext,策略实例从 DI 按作用域解析,或者通过工厂每次都返回新实例。只要做到“状态跟着工位走,策略只是算法”,并发问题就会消失。这一点听起来容易,执行时却很容易破戒,我在代码评审时经常提醒团队,看到策略类里出现非只读实例字段,就要认真检查是不是有状态泄漏。
另外一个细节是,保护策略通常要注册成 Singleton,因为保护逻辑无状态,而且需要全局共享,比如某个设备级别的过温保护,一个信号要影响多个工位。如果保护策略的作用域搞错了,反而会漏掉联动。
6. 重构后的收益清单与让人血压升高的踩坑清单
6.1 重构前后的直观对比
重构这套上位机系统,我最直观的感受是“改代码不害怕了”。把主流程里的 switch-case 移除、把测试项按策略拆分之后,主流程代码量大约减少了百分之七八十,剩下的主要是任务调度、上下文创建和结果处理。新增测试项的模式从“在长函数里插一段逻辑”变成了“新增类+注册一行配置”,改动点非常集中。
| 项目 | 重构前 | 重构后 |
|---|---|---|
| 新增一种测试项 | 修改主流程类、修改 switch-case、验证其他测试项回归 | 新增策略类、注册一行、配置测试编码 |
| 主流程代码量 | 数百行的单一大类 | 只保留调度和上下文构建 |
| 测试项之间耦合 | 共享变量,互相影响 | 策略独立,状态隔离在上下文 |
| 新设备接入 | 修改设备驱动类 | 新增协议策略 |
| 回归范围 | 全流程回归 | 只验证新增策略和涉及工位 |
| 定位问题难度 | 在长函数里翻逻辑 | 直接定位具体策略类 |
表格里的数字是近似值,但方向非常明确。真正让我觉得值得的,是后来新增了三个测试项,整个改动只花了一个下午,这在重构之前不敢想。
6.2 策略化过程中最容易踩的坑
踩坑清单大概是这些:第一,策略对象里悄悄存了状态,导致并发工位数据串线,防不胜防;第二,工厂通过构造函数注入具体策略,新增策略时需要改工厂,等于没有解除耦合;第三,把全部代码都策略化,哪怕只有一个实现的“未来可能变化”也硬造一个接口,类数量飞速膨胀,反而不好维护;第四,异常处理散落在策略内部,监控日志不统一,故障排查困难;第五,策略编码拼写不一致,数据库里写错一个字符就抛 KeyNotFoundException,生产环境没有好的错误提示会很崩溃;第六,没有给策略执行加超时控制,某台设备突然卡死,整个工位任务就会悬挂到天荒地老。
针对第五点,我后来加了启动时的策略映射预校验,系统启动时扫描所有注册的策略名称,和数据库里用到的测试编码比对,不匹配直接报错阻止启动,避免跑了一半才发现策略不存在。第六点则是用 CancellationTokenSource 的 WithTimeout 包装策略执行,超时就触发保护流程并向外抛出超时异常,由调度层处理。
6.3 策略模式适合用在哪一类变化点上
总结一句话,策略模式适合用在“同一目标有不同的实现路径,而且需要在运行时动态选择”的变化点上。测量测试项的执行方案是,设备协议适配是,失效判据是,存储策略也是。但不适合因为“以后可能变化”去给所有类套接口,这种过度设计会给维护者带来额外的理解成本。我的建议是,当第二次出现类似的变化需求时,再把那个变化点抽象成策略,第一次可以先用一个简单的 if,第二次再重构也不晚。测试机这类系统,设备越来越杂、测试项越来越多,变化点很容易观察出来,所以策略模式在里面天然有价值。
还有一个很实际的小建议:策略命名要用可读的字符串编码,比如 HTOL_125C_10V,而不是枚举或者数字。字符串编码可以直接存在配方表里,现场工程师看着配置就能知道当前工位在跑什么测试。而用枚举的话,新增策略就要改枚举类型,还得重新编译,完全违背了配置驱动的初衷。我们后来把策略名往往直接作为配方表字段,新测试项上线时,只在数据库里填新编码,系统就能自动解析到新策略,这一点在调试现场非常受用。
如果让我重新来一遍这套重构,我会先把变化点清单画出来,再动手写接口。策略模式本身不复杂,复杂的是找到正确的边界。现在这套系统最让我满意的不是类数量从少变多,也不是 UML 图画得漂亮,而是新增测试项不再心惊肉跳。调试设备、分析数据、处理异常的时候,我能很快定位到对应的策略类,而不是在那个几百行的主流程里上下翻找。最后再分享一个经验:写完策略工厂之后,建议在界面上留一个“策略预览”入口,输入测试编码就能看到该策略会用哪些设备协议、哪种采样方式、哪套失效判定规则,这个功能在现场联调时能省下一大半沟通成本。