返回首页

C# 中的 Elvis 访问修饰符 — 静态访问控制

Elvis 访问修饰符 — 一组用于 C# 的属性和 Roslyn 分析器,可声明性地允许严格指定的类型访问私有和 internal 成员。该解决方案在编译时检查,无需运行时反射,并集成到 CI/CD 中。

C# 中的 Elvis:不破坏封装的安全 friend
Advertisement 728x90

C# 中的 Elvis 访问修饰符:如何在不破坏封装性的情况下实现对私有成员的受控访问

Elvis 访问修饰符是一个专为 C# 设计的自定义属性系统与 Roslyn 分析器,它通过严格限定的“友好”类型,以声明式的方式允许对 privateinternal 类成员进行受控访问。与 C++ 的 friend 不同,Elvis 在添加新的可信消费者时无需修改目标类,并且提供严格的编译时验证。这一解决方案主要面向中高级开发人员,尤其适用于高负载、多线程或依赖框架的项目,因为在这些场景中,默认不可变和局部可变性的原则对于确保正确性至关重要。

Elvis 的工作原理:架构与关键组件

Elvis 由三个相互关联的层组成:

  • 编译时属性[Elvis][OnlyType][OnlyNs][ExcludeType][ExcludeNs]。这些属性用于标注成员(属性、方法、字段),指定哪些类型或命名空间被允许访问。
  • Roslyn 分析器 — 在 IDE 和编译过程中执行语义检查:分析访问调用,确定访问上下文(“吸引者”),并与属性规则进行比对,一旦发现违规则发出诊断信息。
  • VSIX 扩展与 NuGet 包 — 确保与开发工作流的无缝集成:自动补全属性、快速应用模板,以及在决策树中可视化允许的关系。

值得注意的是,Elvis 不会修改 IL 代码、使用运行时反射,也不依赖 InternalsVisibleTo。所有验证均在静态阶段完成——即编译时。这保证了即使在调试版本中,禁止的访问也不会漏过。

Google AdInline article slot

使用场景:从基础到高级

考虑一个基本任务:Me 类包含 private decimal Money,但需要允许另一个类 MyFriend 安全地修改其值,而无需授予该类对整个程序集内所有代码的访问权限。

class Me
{
    [Elvis(typeof(MyFriend))]
    private decimal _money = 100;
    public decimal Money => _money;

    public void SetMoney(decimal value) => _money = value;
}

class MyFriend
{
    public void TakeHalf(Me me)
    {
        var half = me.Money / 2;
        me.SetMoney(me.Money - half); // ✅ 允许:MyFriend 已在 [Elvis] 中指定
        // me._money = 0; // ❌ 编译时错误:直接访问私有字段被禁止
    }
}

2026-03 和 2026-04 版本新增的功能包括:

  • 通过字符串名称指定类型[Elvis("MyNamespace.MyFriend")]——当无法引用某个类型时,此功能在跨程序集连接中非常有用。
  • 正则表达式[Elvis(@"^MyNamespace\.Tests\.*$")]——允许访问命名空间内的所有测试类。
  • 排除属性[ExcludeType(typeof(ObsoleteHelper))]——即使某类型符合通用的 [OnlyNs] 规则,也会被阻止访问。
  • 命名空间[OnlyNs("MyCompany.Core")]——仅限制对指定命名空间中的类型访问。

组合逻辑与层次结构中的行为

Elvis 支持复合规则。当多个属性应用于同一个成员时,默认采用 AND 逻辑,但优先级更高的规则会覆盖默认设置:

Google AdInline article slot
  • 如果同时指定了 [OnlyNs("A")][ExcludeNs("A.Sub")],则允许访问 A.,但不包括 A.Sub.
  • 在继承关系中:如果基类标记了 [Elvis(typeof(BaseFriend))],而派生类标记了 [Elvis(typeof(DerivedFriend))],则两个类型均可访问基类的成员。然而,派生类并不会继承对其自身 private 成员的访问权限——这些成员必须单独标注。
  • 对于重载方法,属性会分别应用于每个签名。

值得注意的是,Elvis 不影响 protected 成员——它们仍按标准继承规则保持可访问。属性仅作用于 privateinternal 成员。

重要事项

  • Elvis 并非 DI 或接口的替代品;它是额外的一层封装控制,适用于接口使用不便的场景(例如处理 DTO、配置对象或内部框架机制时)。
  • 所有规则均在静态阶段检查——零运行时开销,完全兼容 AOT 编译与裁剪。
  • 对正则表达式和基于字符串的类型名称的支持,使该方案适用于编译时无法获取类型信息的场景(如插件架构、代码生成)。
  • 排除属性([Exclude*])能够创建“带例外的白名单”,这在迁移大型代码库时尤为重要。
  • Elvis 不违反 SOLID 原则:它不会削弱 DIP,而是对其进行补充,允许在不创建人工包装接口的情况下正式化组件之间的契约。

实际局限与建议

Elvis 并不能解决所有访问问题。它应在以下情况下使用:

  • 需要对 private 状态进行精确、可预测的访问,而不通过 public/internal API 暴露出去。
  • 架构要求严格隔离责任区域(例如框架核心与其扩展之间)。
  • 当模式中各类逻辑上紧密耦合,但在编译层面必须保持独立时(例如 BuilderBuilderResult)。

应避免在以下情况下使用 Elvis:

Google AdInline article slot
  • 需要重构设计不佳的依赖关系——如果一个 private 字段需要 15 个 friend 类,那么这种逻辑很可能应该放在单独的服务中。
  • CI/CD 流水线缺失,导致分析器未纳入构建流程——规则将在 IDE 外被忽略。
  • 对于 public 成员——属性会直接忽略它们,因为访问权限早已开放。

在现有项目中实施时,应从 DTO 类和内部工厂中的 private 字段开始,因为这些地方违反不变量的风险最高,而重构成本也最低。

— Editorial Team

Advertisement 728x90

继续阅读