关于 migrate-static-to-wrapper
用于在 C# 项目中,将静态依赖调用机械替换为已有包装接口或内置抽象的技能。它限定文件、项目或命名空间范围,并处理依赖注入及静态类的环境接缝。
主要功能
- 01迁移指定的静态调用点
- 02限定文件、项目或命名空间范围
- 03添加构造函数注入并更新调用方
- 04为静态类使用环境接缝
- 05保留调用次数、顺序和类型
适用场景
- 01用 TimeProvider 替换 DateTime.UtcNow
- 02通过 IFileSystem 封装 File 调用
- 03在不改调用点的情况下测试静态工具类
- 04逐步迁移单个项目或命名空间
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ dotnet/ skills --skill migrate-static-to-wrapper安装后在 Claude Code 中输入/migrate-static-to-wrapper确认已加载。
详细介绍
将静态调用迁移为包装器
以机械式代码修改的方式,将静态依赖调用点替换为注入的包装器接口或内置抽象的调用。操作范围有限(单个文件、项目或命名空间),便于逐步迁移。
适用场景
- 已生成包装器(通过
generate-testability-wrappers)或已确定内置抽象后 - 在整个项目中将
DateTime.UtcNow迁移为TimeProvider.GetUtcNow() - 在整个命名空间中将
File.*迁移为IFileSystem.File.* - 为受影响的类添加构造函数注入,以注入新的抽象
- 通过添加环境接缝(步骤 3)使
static工具类可测试,同时保持现有调用点无需修改即可编译 - 逐步迁移:每次迁移一个项目或命名空间
- 当请求的迁移明确指出替代抽象时,使用伪对象更新受影响的测试
不适用场景
- 尚无包装器或抽象,需要从头设计(请先使用
generate-testability-wrappers)。TimeProvider或IFileSystem这类内置抽象始终视为已存在。 - 用户想检测静态依赖,而不是迁移(请使用
detect-static-dependencies) - 在不同测试框架之间迁移(请使用相应的迁移技能)
- 用户主要是在请求确定性行为测试,且尚未选择生产环境接缝(请使用
testability-obstacle)
类是
static,或项目没有 DI 容器,都不是跳过此技能的理由——这正是步骤 3 中环境接缝的用途。只要调用点必须保持不变且继续可编译,就应使用它。
输入
| 输入 | 必填 | 说明 |
|---|---|---|
| 静态模式 | 否 | 根据请求和发现的调用点推断(例如,DateTime.UtcNow、File.ReadAllText) |
| 替代抽象 | 否 | 根据请求和项目中已有的抽象推断;仅当没有指定或现有抽象可用时才停止 |
| 范围 | 否 | 根据请求的文件/项目/命名空间推断,否则查找最小的相关工作区范围 |
| 注入策略 | 否 | constructor(默认)、primary-constructor 或 ambient |
工作流
不可妥协的迁移边界
- 缺少抽象就必须停止。 如果指定的接口/包不存在,且请求仅授权替换调用点,则不要添加包、臆造本地的类似接口或编辑生产代码。应报告确切缺少的前置条件,以及继续所需的授权。
- 一次源代码读取对应一次替换读取。 不要提升或合并调用,即使共享捕获的时间戳看起来更简洁。
- 请求范围既全面又排他。 替换范围内所有指定的调用,不要触及任何相邻成员或文件。
- 基于仓库的请求必须实际操作仓库。 首先从当前工作区发现文件。在工作区相对路径的发现过程中未找到目标之前,不要声称仓库不可用,也不要向用户索要路径或文件内容。除非差异证明已完成,否则不要声称已实现更改。
- 发现的工作区文件在获准时必须于本轮完成处理。 仅当确认读取器不可用、传输失败或路径规范化失败后,且仅在确认规范路径仍位于当前工作区内时,才使用宿主原生 Shell 读取器(
sed/cat或Get-Content)。遇到内容排除、权限/策略、工作区边界或未知读取错误时,停止操作。仅当确认编辑器不可用、传输失败或路径规范化失败时,才使用 Shell 编辑回退方案;绝不可因上下文过期、并发更改、权限/策略拒绝或路径边界错误而回退。回退前,解析出位于当前工作区内的规范路径,重新读取文件,并要求基于锚定内容进行替换,且旧文本和精确匹配数量均符合预期;若任一项发生变化,则中止。随后重新打开文件、检查差异并验证。不要要求用户粘贴已发现且可读取的文件,也不要将拟议的补丁报告为已完成的工作。
第 1 步:验证前置条件
修改任何代码之前:
-
确认包装器/抽象已存在: 检查项目中是否提供了该接口或内置抽象。对于
TimeProvider,确认目标框架为 .NET 8+,或已引用Microsoft.Bcl.TimeProvider。对于System.IO.Abstractions,确认已引用该 NuGet 包。可能提供抽象的包,不等于项目已具备可用抽象。 -
追踪生产环境中的组合方式: 编辑前检查
Program.cs、Startup.cs、工厂和手动构造位置。迁移到可用扩展点的明确请求,包含维持该范围可运行所需的最小构造函数/调用方/注册更改,除非用户禁止这些编辑。如果新依赖尚未注册,则在现有 DI 宿主中注册TimeProvider.System;更新手动调用方,而不是向库中引入 DI。缺少包或需要设计新包装器仍须单独授权。不要只修改为可注入的类,却让其实际构造路径仍然无法运行。 -
确定范围内的所有文件: 列出将要修改的
.cs文件。排除测试项目、obj/、bin/和生成代码。 -
编辑前锁定成员集合并计数: 使用用户指定的确切成员,或根据请求和发现的调用点推断出范围最小且无歧义的集合。记录该集合,然后搜索每个成员并记录文件/行号清单。编辑期间不要更改集合,也不要根据不完整的读取推断数量。
第 2 步:规划每个文件的迁移
只迁移用户要求的内容,不涉及其他相邻内容。 如果用户指定了某个成员(DateTime.UtcNow),只迁移该成员,并保留 DateTime.Now 等同类成员不变。如果用户指定了文件,不要修改其他文件。保留注释或名称表明是有意为之的调用点(例如 // intentional local time),除非用户明确指定该调用点并要求进行保持语义的迁移。在“Remaining (out of scope)”下列出所有有意保留不动的内容,方便用户在后续请求中提出;可以给出建议,但不要悄悄扩大范围。
对于包含静态模式的每个文件,请确定:
- 哪些类包含这些调用点 — 找出类声明
- 类是否已注入依赖 — 检查构造函数中是否已有
TimeProvider、IFileSystem等参数 - 每个调用点的替换表达式
替换映射
| 类别 | 原始表达式 | DI 替换表达式 |
|---|---|---|
| 时间 | DateTime.Now | _timeProvider.GetLocalNow().LocalDateTime |
| 时间 | DateTime.UtcNow | _timeProvider.GetUtcNow().UtcDateTime |
| 时间 | DateTime.Today | _timeProvider.GetLocalNow().LocalDateTime.Date |
| 时间 | DateTimeOffset.Now | _timeProvider.GetLocalNow() |
| 时间 | DateTimeOffset.UtcNow | _timeProvider.GetUtcNow() |
| 文件 | File.ReadAllText(path) | _fileSystem.File.ReadAllText(path) |
| 文件 | File.WriteAllText(path, text) | _fileSystem.File.WriteAllText(path, text) |
| 文件 | File.Exists(path) | _fileSystem.File.Exists(path) |
| 文件 | Directory.Exists(path) | _fileSystem.Directory.Exists(path) |
| 环境变量 | Environment.GetEnvironmentVariable(name) | _env.GetEnvironmentVariable(name) |
| 控制台 | Console.WriteLine(msg) | _console.WriteLine(msg) |
| 进程 | Process.Start(info) | _processRunner.Start(info) |
同一类别中的其他成员也采用相同模式。
保留
DateTimeKind——这是最常见的隐性回归。TimeProvider.GetUtcNow()/GetLocalNow()返回DateTimeOffset。转换回DateTime时,必须保留原始的Kind,否则即使代码仍能编译,也会引入行为变化:
DateTime.UtcNow的Kind == Utc→ 使用.UtcDateTime(不要使用.DateTime,后者会得到Kind == Unspecified)。DateTime.Now的Kind == Local→ 使用.LocalDateTime(不要使用.DateTime)。- 如果调用点直接使用
DateTimeOffset(字段、参数或返回值本身已是DateTimeOffset类型),去掉.UtcDateTime/.LocalDateTime后缀,直接赋值DateTimeOffset,不要强行转换回DateTime。匹配目标成员的类型:如果外围字段或属性是
DateTime,就保留为DateTime(使用上述能正确保留Kind的属性);不要在“机械式”迁移中将其改为DateTimeOffset——那属于设计变更,而非委托替换。保留读取的数量、顺序和位置,以及值类型。 将每次原始时钟读取就地替换为一次提供程序读取。不要提前读取、缓存或合并两个读取为一个共享的
now局部变量,即使它们位于同一个对象初始化式或方法中也不行。连续两次调用DateTime.UtcNow可能观察到不同的时刻;让CreatedAt和ExpiresAt使用同一个已捕获的值,是行为变更,而非机械式迁移。只有原始代码已经捕获并重复使用某个值时,才重复使用该值。
步骤 3:添加构造函数注入
按照类现有的模式添加新依赖:
- 主构造函数(C# 12+):在主构造函数中添加参数:
public class OrderProcessor(ILogger<OrderProcessor> logger, TimeProvider timeProvider) - 传统构造函数:添加
private readonly字段和构造函数参数,并遵循现有的字段命名约定(_camelCase或m_camelCase)
静态类:使用环境上下文(不使用构造函数注入)
只有静态成员的 static 类无法接收构造函数注入——添加实例构造函数或实例字段会破坏该类。不要为了注入依赖而将它改为非静态类;这会改变其设计以及所有调用位置。相反,应使用作用域化的环境上下文接缝:默认使用真实实现,并且可以覆盖,同时不会泄漏进程级全局状态。
当用户希望保留静态类时,下面的环境上下文接缝就是解决方案——应将其作为唯一方案直接提供并实现。不要模棱两可地提出“将其改为非静态类”或“将 TimeProvider 作为方法参数传入”作为同等替代方案;这些做法会改变类的设计或公共 API,并非用户所要求的方案。先介绍接缝,再说明并行执行方面的权衡。
public static class TimestampFormatter
{
private static readonly AsyncLocal<TimeProvider?> s_clock = new();
private static TimeProvider Clock => s_clock.Value ?? TimeProvider.System;
public static string Now() => Clock.GetUtcNow().ToString("O");
public static IDisposable OverrideClock(TimeProvider clock)
{
ArgumentNullException.ThrowIfNull(clock);
var previous = s_clock.Value;
s_clock.Value = clock;
return new Scope(() => s_clock.Value = previous);
}
private sealed class Scope : IDisposable
{
private Action? _restore;
public Scope(Action restore)
{
_restore = restore;
}
public void Dispose() => Interlocked.Exchange(ref _restore, null)?.Invoke();
}
}
- 没有激活覆盖时,生产代码会读取
TimeProvider.System;无需在启动时进行修改。 - 测试会为每个异步流创建一个新的 fake/provider,并释放返回的作用域。嵌套作用域释放后会恢复外层 provider。
AsyncLocal<T>可让独立建立的测试流在await期间保持隔离。不要在该槽中存储可变的堆栈/列表,也不要修改被多个子流继承的同一个 fake。- 添加针对替换、嵌套恢复和并行异步隔离的专项测试。仅检查能否构建并不能证明这个接缝有效。
- 同样的结构也适用于其他静态类(
IFileSystem、自定义包装器):在AsyncLocal<T>中存储抽象类型的值,默认使用真实实现,并在作用域中恢复先前的值。
步骤 4:替换调用位置
机械地执行每项替换。对于每个调用位置:
- 将静态调用替换为包装器调用
- 保留周围表达式的结构和求值顺序;原有的一次依赖读取仍对应一次包装器读取
- 如果尚未添加,则添加所需的
using指令
编辑后,重复执行完全相同的搜索,并确保每个范围内的生产文件中均不再有匹配项。重新打开每个已修改文件,并将结果与编辑前的清单进行比较。如果有方法被悄悄遗漏,仅报告汇总数量并不能作为证据。
还要验证范围的排除部分:搜索或比较用户明确要求保持不变的每个文件,并确保其中原有的静态调用和内容均得以保留。对于单文件迁移,即使数字很小,也要同时报告这两个数量:已替换的范围内调用数 N/N,以及保留的范围外命名调用数 M。
添加 using 指令
| 抽象类型 | Using 指令 |
|---|---|
TimeProvider | 无(位于 System 命名空间) |
IFileSystem | using System.IO.Abstractions; |
IHttpClientFactory | using System.Net.Http;(通常已存在) |
| 自定义包装器 | using <wrapper namespace>; |
步骤 5:更新受影响的测试文件
如果迁移的类有对应的测试文件:
- 更新构造函数调用——在测试类实例化时添加新参数
- 使用测试替身:
TimeProvider→ 使用来自Microsoft.Extensions.TimeProvider.Testing的new FakeTimeProvider()IFileSystem→ 使用来自System.IO.Abstractions.TestingHelpers的new MockFileSystem()- 自定义包装器 → 使用
new Mock<IWrapperName>()或手写的 fake
保留所有依赖原始静态结果的可观察分支。例如,将 Environment.GetEnvironmentVariable(name) ?? "production" 迁移后,必须测试已配置值,以及输入为 null/缺失时选择回退值的情况。只有 fake 的成功路径不足以证明迁移过程符合预期。
已有测试时,保留其测试框架和断言风格,但要确保替换后的依赖可观察:至少包含一个针对已配置值/fake 值的断言,以及一个针对回退或错误路径的断言(如果原始静态 API 会产生这两种结果)。仅仅让旧测试通过编译,不足以证明迁移完整。
如果请求明确要求将受影响的单元测试改为不再访问真实文件或环境变量,则编辑后应搜索这些测试,确认它们不再使用临时文件、真实磁盘或环境变量修改 API。保留请求范围之外有意设置的集成测试。报告确定性 fake 的已配置值和回退/错误情况,而不应只说添加了 fake。
步骤 6:构建验证
完成当前范围内的所有更改后,构建受影响的生产项目;只要存在或修改了测试,就运行受影响范围最小的测试项目:
dotnet build <project.csproj>
dotnet test <affected-test-project.csproj>
报告实际观察到的构建结果。 只有当命令以 0 退出时,才写“构建成功”;如果失败(包括“找不到 assets 文件”等还原/NuGet 错误),请说明这一点并引用错误,然后修复问题(运行 dotnet restore、添加缺少的包),或向用户准确说明阻碍。虚报成功比迁移未完成更糟糕。
如果构建失败:
- 缺少 using:添加所需的
using指令 - 缺少 NuGet 包:仅在明确授权更改依赖项时添加;否则报告未满足的前置条件并停止
- 测试中的构造函数不匹配:更新测试实例化代码(步骤 5)
- 调用不明确:完全限定包装器调用
不要用成功构建代替所要求的测试运行。当迁移更改了构造函数调用、fake、进程全局状态或真实 I/O 时,只有目标测试才能证明完整路径。如果测试命令受阻,请报告该阻碍,不要声称迁移已完成验证。
步骤 7:报告更改
总结已完成的工作。即使只涉及一个生产文件和一个测试文件,也要列出范围内确切的替换数量、经核实未更改的范围外文件或调用,以及目标构建/测试结果:
## Migration Summary
**Pattern**: DateTime.UtcNow → TimeProvider.GetUtcNow()
**Scope**: MyProject/Services/
### Files Modified (production)
| File | Call Sites Replaced | Injection Added |
|------|--------------------:|:----------------|
| OrderProcessor.cs | 3 | Yes (constructor) |
| NotificationService.cs | 1 | Yes (primary ctor) |
### Files Modified (tests)
| File | Change |
|------|--------|
| OrderProcessorTests.cs | Added FakeTimeProvider parameter |
### Remaining (out of scope)
- MyProject/Legacy/ — 8 call sites not migrated (different namespace)
验证
- 已替换范围内的所有调用点(无遗漏)
- 通过迁移前后精确成员搜索证明范围内的出现次数已归零
- 未修改请求的成员/文件范围之外的调用点
- 有文档说明为有意保留的调用点保持不变,并已报告,除非用户明确指定对其进行保持语义不变的迁移
- 已为所有受影响的类添加构造函数注入
- 字段命名遵循现有类的约定
- 已添加必需的
using指令 - 已引用必需的 NuGet 包
- 迁移后构建成功,且报告的结果与实际命令退出代码一致
- 已使用适当的测试替身更新测试文件
- 现有的已配置、回退/空值和错误分支仍有直接测试证据
- 存在或发生变更时,受影响的目标测试均已成功运行
- 未引入行为变更(包装器直接委托给静态成员)
- 静态读取已逐一替换;未提升、缓存或合并
- 保留
DateTimeKind——原来的DateTime.UtcNow仍为Utc(.UtcDateTime),原来的DateTime.Now仍为Local(.LocalDateTime)
常见陷阱
| 陷阱 | 解决方案 |
|---|---|
| 替换测试代码中的静态成员 | 仅替换生产代码中的静态成员;测试应使用伪对象/模拟对象 |
| 破坏静态类 | 静态类不能有构造函数——使用环境上下文接缝(步骤 3),不要将其转换为非静态类 |
遗漏 FakeTimeProvider NuGet 包 | 将 Microsoft.Extensions.TimeProvider.Testing 添加到测试项目 |
使用 DateTimeOffset 上的 .DateTime 替换 DateTime 值 | DateTimeOffset.DateTime 返回 Kind == Unspecified——使用 .UtcDateTime(原来是 DateTime.UtcNow 时)或 .LocalDateTime(原来是 DateTime.Now 时),以保留原始 DateTimeKind。仅在用户要求时才将字段/返回类型更改为 DateTimeOffset。 |
| 为多个原始时钟读取捕获一次提供程序的值 | 在原处逐一替换每次读取。合并读取会改变可观察到的时序,即使这样看起来更简洁。 |
| 一次迁移过多内容 | 严格遵循定义的范围——每次运行仅处理一个项目或命名空间 |
仅请求替换 UtcNow,却迁移了 DateTime.Now | 遵循请求的字面要求;将其他调用点列为范围外的建议,而不要重写它们 |
| 还原失败后仍声称“构建成功” | 检查退出代码和输出;报告实际失败,并修复问题或将其列为阻碍项 |
| 仅迁移调用点时添加包 | 停下并请求授权,或先执行包装器/接入设置 |
| 忘记生产环境组合 | 替换调用点之前,核实 DI 注册、手动构造或生产环境的默认上下文 |