ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C#年龄检查功能重构:从if判断到策略模式的代码优化实践

C#年龄检查功能重构:从if判断到策略模式的代码优化实践 事情是这样的前阵子一个做会员制俱乐部的朋友找我说他们系统里要加一个入场年龄检查的功能。我一听这不是一个if判断就完事的事吗结果聊完才发现这个“简单需求”背后牵扯到的边界情况、扩展性、可维护性比想象中多得多。加上他们团队一直用的是C#代码从.NET Framework一路迁到.NET 8老代码里满是魔法数字和重复逻辑正好借这个机会把C#代码优化和最佳实践梳理了一遍。这篇文章就围绕“俱乐部入场年龄检查”这个业务场景把我在实际落地过程中的思考、踩坑、重构路径全部记录下来。从第一版能跑的代码到可测试、可维护、可扩展的设计每一层优化我都会讲清楚“为什么要这么改”以及“C#里有哪些语言特性可以帮我们做得更好”。不管你是刚入门C#的初学者还是写了几年业务代码想找找优化感觉的开发者这篇文章应该都能给你一些启发。1. 需求拆解一次“简单需求”引发的重构1.1 表面需求vs真实需求朋友最初的需求描述只有一句话“进场的时候判断一下年龄满18岁就放行。”听起来确实简单但深入一问就发现不对。他们俱乐部并不是只有一种入场规则。工作日的下午场16岁以上就可以进晚上九点以后必须满18岁如果想进酒水区那就得满21岁。还有会员活动日偶尔会放开限制但需要单独配置。也就是说“年龄检查”不是一个单点判断而是一组业务规则的组合并且规则会随着运营策略频繁变化。这就是真实业务里最常见的状态需求方嘴上说的“简单判断”实际上是“一堆判断规则中当前最显眼的那一条”。如果代码只写死一个18岁那下一次需求变更时改代码的人就得去代码库里翻魔法数字改完还要担心是不是影响了别的地方。所以拆解真实需求的第一步就是识别出哪些是稳定的核心逻辑哪些是易变的业务配置。核心逻辑是“计算周岁年龄”和“年龄与门槛值比较”易变的部分是“门槛值是多少”和“哪些人群适用哪个门槛”。1.2 第一版实现能跑的代码不等于好代码我先让朋友把他们现有代码发给我看结果不出所料第一版长这样public bool CanEnter(DateTime birthDate, DateTime now) { int age now.Year - birthDate.Year; if (now.Month birthDate.Month || (now.Month birthDate.Month now.Day birthDate.Day)) { age--; } return age 18; }这段代码本身逻辑没有错周岁计算也基本正确。但它有三个典型问题第一魔法数字。18这个数字直接埋在方法里面。当酒水区需要21岁门槛时就得复制一个方法改成age 21代码就开始腐烂了。第二算法与业务耦合。CanEnter这个方法既负责计算年龄又负责业务判断。如果哪天要统计“入场者的平均年龄”你就没法复用这段年龄计算逻辑只能再写一遍。同样的算法出现在不同方法里将来修bug的时候漏改一个就很酸爽。第三日期时间来源硬编码。now这个参数虽然传进来了但调用方很可能会直接写DateTime.Now。这在测试时是个灾难——你没法稳定地测试“今天是某人生日”的场景因为测试跑的时候时间一直在变。2. 让年龄计算变得可靠核心算法的边界处理2.1 周岁计算的两种算法对比既然整个业务都建立在“周岁年龄”的计算上那第一步就是把年龄计算从业务判断里拆出来单独做成一个可靠的算法。C#里计算周岁常见的有两种思路。第一种是上面第一版代码里的“年份差再修正”int age now.Year - birthDate.Year; if (now.Month birthDate.Month || (now.Month birthDate.Month now.Day birthDate.Day)) { age--; }思路很直观先按年份相减得到一个粗略年龄再判断今年的生日过了没有没过就减一。这个算法逻辑清晰性能极高但判断条件写起来稍微有点啰嗦而且容易在“相等”这个边界上出错。比如出生日期是2005年6月1日当前日期是2023年6月1日年份相减得到18月份相等日期相等不进if年龄正好18。但如果你把条件写成now.Month birthDate.Month now.Day birthDate.Day那生日当天就会被错误地减一年变成17岁。第二种是用DateTime的AddYears方法反向计算public static int CalculateAge(DateTime birthDate, DateTime now) { int age now.Year - birthDate.Year; if (birthDate.AddYears(age) now) { age--; } return age; }这个算法更优雅先粗暴地按年份差定年龄然后看“把这个年龄加回出生日期之后是否已经超过当前日期”。如果还没到说明今年生日还没过减一。AddYears方法内部处理了闰年问题比如2月29日出生的人在平年会返回2月28日这让整个计算在极端日期下也更安全。我实际测试下来两种算法在绝大多数日期上结果一致但第二种代码更简洁也更容易让读代码的人理解“哦原来是在判断生日过没过”。所以在重构时我选了第二种作为基础算法。2.2 边界日期测试用例算法定了之后测试用例必须覆盖到位。下面这几个日期是我每次写年龄计算都会摆上桌面的生日是2005年6月1日当前是2023年5月31日年龄应为17生日是2005年6月1日当前是2023年6月1日年龄应为18生日当天生日是2005年6月1日当前是2023年6月2日年龄应为18生日是2000年2月29日当前是2023年2月28日年龄应为22闰年出生平年过生日生日是2000年2月29日当前是2023年3月1日年龄应为23按实际算法AddYears(22)在2022年2月28日AddYears(23)在2023年2月28日所以3月1日时已经算23岁生日是2005年12月31日当前是2023年12月31日年龄应为18生日是2005年12月31日当前是2024年1月1日年龄应为19实际跑一遍这些用例能帮你把算法里的各种边界都钉死。尤其是2月29日这个场景我第一次写的时候就因为没考虑闰年差了一天。3. 重构策略从if-else到策略加枚举3.1 定义年龄策略枚举年龄算法稳定了接下来处理更核心的问题一堆年龄门槛怎么管。我首先想到的是枚举。把不同类型的入场规则变成枚举值而不是散落在各个方法里的魔法数字。public enum EntryPolicy { GeneralAdmission 16, EveningEntry 18, LoungeAccess 21, StaffEntry 0 }表面上看枚举值直接等于年龄门槛很直观。但这里有个隐患如果哪天运营想调整门槛比如说晚间场从18岁改成19岁那就得改枚举源码再重新发布。对于这种会频繁变动的业务数值把门槛值挂到枚举的Attribute上或者做成配置文件会更合适。我最后采用的是“枚举加Attribute”的组合方式这样既能保持代码的强类型约束又能把业务数值从代码逻辑里相对剥离出来。public sealed class AgeLimitAttribute : Attribute { public int AgeLimit { get; } public AgeLimitAttribute(int ageLimit) AgeLimit ageLimit; } public enum EntryPolicy { [AgeLimit(16)] GeneralAdmission, [AgeLimit(18)] EveningEntry, [AgeLimit(21)] LoungeAccess, [AgeLimit(0)] StaffEntry }3.2 用Attribute给枚举挂载门槛值有了Attribute接下来就需要一个读取它的辅助方法。我封装了一个静态类PolicyAgeResolverpublic static class PolicyAgeResolver { public static int GetAgeLimit(EntryPolicy policy) { var member typeof(EntryPolicy).GetMember(policy.ToString()); var attribute member[0].GetCustomAttributeAgeLimitAttribute(); return attribute?.AgeLimit ?? 0; } }这段代码用反射读取枚举上的Attribute虽然反射在性能上比直接访问常量慢一些但考虑到年龄检查这个操作本身的频率和体量这个开销完全在可接受范围内。重要的是新增一种入场策略时只需要在枚举上加一个新成员和Attribute不用改动任何判断逻辑。不过如果你的项目对性能极端敏感比如每秒要执行百万次年龄判断那反射的代价就不能忽视。这种情况下可以用一个静态只读字典DictionaryEntryPolicy, int来做缓存第一次访问时反射一次之后直接查字典。我在示例项目里就是这么做的public static class PolicyAgeResolver { private static readonly DictionaryEntryPolicy, int Cache BuildCache(); private static DictionaryEntryPolicy, int BuildCache() { return Enum.GetValuesEntryPolicy() .ToDictionary(p p, GetAgeLimitFromAttribute); } public static int GetAgeLimit(EntryPolicy policy) Cache[policy]; }3.3 策略分发switch表达式和策略字典枚举值有了门槛值也能拿到了最后一步就是把“根据策略判断能否入场”的逻辑组织起来。这里我推荐用C# 8.0之后的switch表达式配合模式匹配代码会非常干净。public static bool CanEnter(EntryPolicy policy, DateTime birthDate, DateTime now) { int age AgeCalculator.CalculateAge(birthDate, now); int limit PolicyAgeResolver.GetAgeLimit(policy); return age limit; }就这么简单对核心判断逻辑已经短到没法再短了。但这里还有一个隐蔽的问题StaffEntry这种门槛为0的策略理论上不设年龄下限但按现在这个写法一个刚出生的婴儿也会被放行。这在会员制俱乐部里显然不合理。我处理的方式是给CanEnter增加一个minimumAllowedAge的保护参数public static bool CanEnter(EntryPolicy policy, DateTime birthDate, DateTime now, int minimumAllowedAge 0) { int age AgeCalculator.CalculateAge(birthDate, now); int limit PolicyAgeResolver.GetAgeLimit(policy); return age Math.Max(limit, minimumAllowedAge); }调用的时候业务层可以传入一个全局的最低年龄限制比如12岁这样就算某个策略的门槛是0也不会出现婴儿进场这种荒唐事。4. 防御式编程与C#现代语法的配合使用4.1 参数校验用Guard Clause代替深层if业务代码最怕的是什么是上层传了一个default(DateTime)进来或者更隐蔽的传了一个未来的出生日期进来。你计算出来的年龄是个负数然后-3 18为false看似没问题但语义完全不对劲。所以在公共方法的入口做参数校验是很必要的。我在AgeCalculator里加了这样的保护public static int CalculateAge(DateTime birthDate, DateTime now) { ArgumentOutOfRangeException.ThrowIfGreaterThan(birthDate, now); ArgumentOutOfRangeException.ThrowIfLessThan(birthDate, DateTime.MinValue); int age now.Year - birthDate.Year; if (birthDate.AddYears(age) now) { age--; } return age; }ArgumentOutOfRangeException.ThrowIfGreaterThan是.NET 8里新增的Guard Clause方法写起来比手动if (birthDate now) throw ...简洁得多。如果你还在用旧版.NET也可以自己写一个Guard静态类把校验逻辑收拢起来。这里注意一下DateTime.MinValue的检查其实永远为false因为birthDate已经是DateTime类型不可能小于DateTime.MinValue。我保留这行是为了语义上的完整性——将来如果改成接收字符串再解析日期这个校验就能拦住异常输入。实际项目里很多参数校验是在API层做掉的比如模型绑定阶段就拒绝掉非法日期类库内部只需要保证“上游犯错时不会静默产生错误结果”就行。4.2 记录类型与不可变策略对象当业务越来越复杂单一的枚举和辅助类可能不够用。比如酒水区不仅有年龄门槛还要求必须出示有效身份证件深夜场除了年龄还限制着装。这种情况下把策略建模成一个不可变对象会更灵活。C# 9.0引入了record类型非常适合这种场景。我用record定义了完整的入场策略public sealed record EntryPolicyConfig { public EntryPolicy Policy { get; init; } public int AgeLimit { get; init; } public bool RequiresIdCheck { get; init; } public IReadOnlyListstring AllowedDressCodes { get; init; } Array.Emptystring(); }record类型自带值相等性比较两个相同内容的策略对象可以直接用比较这在测试断言时特别好用。init访问器保证对象一旦创建内容就不能被修改避免多线程环境下策略被意外篡改。如果策略需求继续膨胀比如“每个人可以持有多个策略按优先级取第一个满足的”那就可以引入策略模式把每个策略实现成一个类通过接口统一调用。public interface IEntryRule { EntryPolicy Policy { get; } bool IsSatisfied(Patron patron, DateTime now); }然后分别实现GeneralAdmissionRule、EveningEntryRule、LoungeAccessRule再用一个RuleEngine按优先级顺序执行。这就是比较完整的策略模式了。不过对于大多数俱乐部的业务体量枚举加Attribute已经够用策略模式反而有点过度设计。我在代码里保留了这个扩展点但没有真的为每个策略写类因为YAGNI原则还是得记住的。4.3 扩展方法让调用点更清晰重构之后调用方拿到的API应该是非常语义化的。我当然不希望在业务代码里出现AgeCalculator.CalculateAge(patron.BirthDate, clock.Now)这样的长链调用。用扩展方法包装一下会好很多public static class PatronAgeExtensions { public static bool CanEnter(this Patron patron, EntryPolicy policy, DateTime now) { return ClubEntranceRules.CanEnter(policy, patron.BirthDate, now); } public static int GetAge(this Patron patron, DateTime now) { return AgeCalculator.CalculateAge(patron.BirthDate, now); } }有了扩展方法业务层的判断代码就变成了if (patron.CanEnter(EntryPolicy.LoungeAccess, clock.Now)) { // 放行 }读起来几乎和英文句子一样自然任何新来的开发都能一眼看懂这段代码在干什么。而且测试的时候可以直接调用CanEnter扩展方法不需要去实例化一个服务轻量很多。这里我特别想说一下扩展方法虽然好用但不要滥用。扩展方法本质上是静态方法的语法糖它不能访问对象的私有成员也不参与多态。最适合的场景就是这种“给某个类型加一个与业务相关的便捷判断”而不是把复杂的业务逻辑全塞进去。5. 单元测试把这个逻辑焊死在正确性上5.1 为什么这件事必须配测试年龄检查直接关系到俱乐部的合规运营一旦漏判后果很麻烦。这种逻辑一旦写错人工测试还很难发现因为正常情况下大多数人都会在正确的年龄段入场只有边界日期才会暴露问题。我给这套代码配了xUnit的测试项目用[Theory]和[MemberData]做了数据驱动的测试。测试用例全部来自前面列的边界日期覆盖了生日当天、前一天、后一天、闰年、跨年等场景。public class AgeCalculatorTests { [Theory] [MemberData(nameof(AgeTestData))] public void CalculateAge_ShouldReturnExpectedAge(DateTime birthDate, DateTime now, int expectedAge) { int age AgeCalculator.CalculateAge(birthDate, now); Assert.Equal(expectedAge, age); } public static IEnumerableobject[] AgeTestData() { yield return new object[] { new DateTime(2005, 6, 1), new DateTime(2023, 5, 31), 17 }; yield return new object[] { new DateTime(2005, 6, 1), new DateTime(2023, 6, 1), 18 }; yield return new object[] { new DateTime(2005, 6, 1), new DateTime(2023, 6, 2), 18 }; yield return new object[] { new DateTime(2000, 2, 29), new DateTime(2023, 2, 28), 22 }; yield return new object[] { new DateTime(2000, 2, 29), new DateTime(2023, 3, 1), 23 }; } }MemberData的好处是测试用例和测试逻辑分离新增用例时只需要在数据源里加一行测试代码完全不用动。这在维护期是极大的幸福感提升。5.2 测试时区与时间的可控性前面我反复提到不要直接使用DateTime.Now而是通过参数传入“当前时间”。这背后就是为了可测试性。测试代码里的时间是固定的才能稳定地断言边界场景。但真实项目里调用方难免会写DateTime.Now。为了从架构上杜绝这个问题我倾向于定义一个IClock接口public interface IClock { DateTime Now { get; } } public sealed class SystemClock : IClock { public DateTime Now DateTime.Now; } public sealed class FixedClock : IClock { private readonly DateTime _now; public FixedClock(DateTime now) _now now; public DateTime Now _now; }业务代码依赖IClock测试时传入FixedClock就可以把当前时间钉在任何想要的时刻。这个模式在很多场景都适用不只是年龄检查任何涉及“当前时间”的业务逻辑优惠券过期、会话超时、订阅续费都可以用它来做时间控制。另外提一句时区问题。如果俱乐部有多个分店或者客户可能来自不同时区那日期计算就得考虑时区。最稳妥的做法是统一存储UTC时间展示和判断时再转换为当地时区。年龄判断这类对日期敏感的规则一定要用“客人所在时区的当前日期”来计算而不是用服务器时区。我见过一个线上事故服务器部署在海外晚上十点国内客人的年龄被算小了一天导致生日当天入不了场客诉直接炸了。5.3 测试覆盖的另一个维度策略门槛年龄算法测试完再测策略分发。把每个枚举值的年龄门槛都验证一遍确保Attribute反射读取正确。这种测试虽然看起来简单但它是防止“手滑改错枚举值”的最后一道防线。public class PolicyAgeResolverTests { [Theory] [InlineData(EntryPolicy.GeneralAdmission, 16)] [InlineData(EntryPolicy.EveningEntry, 18)] [InlineData(EntryPolicy.LoungeAccess, 21)] [InlineData(EntryPolicy.StaffEntry, 0)] public void GetAgeLimit_ShouldReturnConfiguredLimit(EntryPolicy policy, int expectedLimit) { int limit PolicyAgeResolver.GetAgeLimit(policy); Assert.Equal(expectedLimit, limit); } }我还会加一个“禁止重复门槛”的测试比如检查枚举里是否存在两个成员的AgeLimit相同。这是为了防止运营配置时不小心把两个政策设成一样的门槛导致后续统计口径混乱。用反射和LINQ就能写几十行代码但能避免一次隐蔽的配置事故。6. 现场复盘线上真实的坑和值得坚持的习惯6.1 生日当天差点翻车这次优化的过程里我陪朋友做了好几次代码走查几乎每次都能捞出点问题。印象最深的一次是他们在老代码里发现了一处“生日当天年龄减一”的bug。老代码用的是now.Day birthDate.Day作为减龄条件表面看没问题但如果出生日期是某月31号当前是当月最后一天而不是31号比如出生在1月31日当前是2月28日这个条件就会错误地触发减龄。换成AddYears算法后这个边界就被优雅地规避掉了。这个案例也说明光看代码逻辑想当然是不够的必须把各种日期组合塞进测试用例里跑一遍。6.2 配置外置的度说到年龄门槛还有一个反复被提起的话题到底应不应该把门槛值放到配置文件或者数据库里我的观点是要看业务变化的频率。如果一年到头都不怎么改做成枚举加Attribute就够了改一次重新发布一次成本也不高。如果运营每周都在调那就应该在管理后台提供一个配置页面把年龄门槛存到数据库或配置中心代码只负责读取。过度设计最典型的体现就是明明半年才改一次的需求非要上个配置中心结果运维成本比开发成本还高。我建议遵循一个简单的原则先写成枚举当“同一个值被改了三次以上”时再考虑配置化。6.3 代码优化不是炫技最后再说点感受。很多开发者一提“C#代码优化”第一反应就是反射、泛型、异步流、性能计数好像越底层越高级。但在我实际的项目里最有价值的优化往往是结构上的把算法和业务拆开、让策略可以扩展、让时间可控、让测试能跑。这些都是很朴素的工程实践不炫技但每一层都能实实在在地降低后续的维护成本。回看这次俱乐部门禁需求最终交付的代码量比第一版还少了一些但可读性、可测试性、可扩展性完全不是一个量级。最让我欣慰的是朋友团队里的新人在接手这个模块时打开代码没问“这个数字18是啥意思”而是直接说“哦这个策略是晚间入场门槛18岁”这就是优化的意义。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进