尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Ruby Box 深度指南:Ruby 进程内的类与模块隔离机制

Ruby Box 深度指南:Ruby 进程内的类与模块隔离机制 Ruby Box 深度指南Ruby 进程内的类与模块隔离机制【免费下载链接】rubyThe Ruby Programming Language项目地址: https://gitcode.com/GitHub_Trending/ru/rubyRuby Box 是 Ruby 语言运行时提供的一套进程内隔离方案允许在同一个 Ruby 进程中创建相互隔离的盒子空间用于隔离应用代码、第三方库与猴子补丁monkey patch是解决依赖地狱与全局污染问题的底层基础设施。本文将以 doc/language/box.md 为核心结合 box.c、internal/box.h、test/ruby/test_box.rb 等源码与测试完整讲解 Ruby Box 的启用方式、API 用法、隔离语义、实现原理与已知限制读完即可上手用RUBY_BOX1体验这一实验性特性。Ruby Box 是什么Ruby Box 的设计目标非常明确在单个 Ruby 进程内提供分离的空间separated spaces以隔离应用代码、库和猴子补丁。传统 Ruby 进程中所有require进来的库共享同一份全局命名空间Object的常量表、类的方法表、全局变量等。一旦某个库对String、Array等内建类做了猴子补丁整个进程都会受影响不同版本的同一库也无法共存。Ruby Box 通过在运行时层面对类/模块的定义空间、方法解析、常量解析、全局变量进行按盒隔离让不同盒子内可以各自持有互不可见的定义。Ruby::Box类是 Ruby Box 的入口类entrypoint它在 C 层被定义为Module的子类// box.c rb_cBox rb_define_class_under(mRuby, Box, rb_cModule);也就是说Ruby::Box的实例本身是一种Module可以像模块一样通过::访问其常量。启用 Ruby BoxRuby Box 默认是关闭的必须在 Ruby 进程启动时设置环境变量RUBY_BOX1 ruby main.rb关键约束如下唯一合法值是1源码 box.c 中的Init_enable_box函数只认strlen(env) 1 env[0] 1这一种情况其他任何值包括未设置都表示禁用 Ruby Box。必须在进程启动前设置文档明确说明在 Ruby 程序启动之后再设置无效因为该判定发生在运行时初始化Init_enable_box阶段ruby_box_enabled一旦确定就不会再改变。启用后可通过Ruby::Box.enabled?查询状态对应源码rb_box_s_getenabled见 box.c关闭状态下返回false未启用时调用Ruby::Box.new会抛出RuntimeErrorRuby Box is disabled. Set RUBY_BOX1 environment variable to use Ruby::Box.见 box.c。测试 test/ruby/test_box.rb 对默认关闭与启用两种情况都有验证def test_box_availability_in_default assert_separately([RUBY_BOXnil], __FILE__, __LINE__, #{~begin;}\n#{~end;}, ignore_stderr: true) begin; assert_nil ENV[RUBY_BOX] assert_not_predicate Ruby::Box, :enabled? end; end def test_box_availability_when_enabled assert_separately([ENV_ENABLE_BOX], __FILE__, __LINE__, #{~begin;}\n#{~end;}, ignore_stderr: true) begin; assert_equal 1, ENV[RUBY_BOX] assert_predicate Ruby::Box, :enabled? end; end实验性警告启用 Ruby Box 后Ruby 启动时会打印一条实验性警告对应 box.c 中通过rb_category_warn(RB_WARN_CATEGORY_EXPERIMENTAL, ...)输出的 Ruby::Box is experimental, and the behavior may change in the future!。文档建议使用-W:no-experimental选项将其隐藏RUBY_BOX1 ruby -W:no-experimental main.rb测试文件 test/ruby/test_box.rb 中同样校验了警告文本的匹配模式。基本用法创建盒子并加载代码创建盒子与加载文件的最小用法box Ruby::Box.new box.require(something) # 或 require_relative、loadrequire、require_relative、load都可用对应 box.c 中的rb_box_load/rb_box_require/rb_box_require_relative它们分别透传到rb_load_entrypoint、rb_require_string、rb_require_relative_entrypoint。被加载的文件无论是.rb还是.so/.dll/.bundle原生扩展都在该盒子中加载其递归依赖的文件也会被加载进同一个盒子。以文档中的示例说明隔离效果。假设有something.rb# something.rb X 1 class Something def self.x X def x ::X end在主程序中X 2 p X # 2 p ::X # 2 p box::Something.x # 1 p box::X # 1可以看到盒内定义的常量X 1与主程序main box中的X 2互不干扰通过box::Something、box::X可以从盒子外部访问盒内定义实例方法同样遵循盒内定义执行s box::Something.new p s.x # 1测试 test/ruby/test_box.rb 验证了require的隔离性box.require(...)之后box::BOX_A可访问而全局的BOX_A仍抛NameError。同一库的多个版本共存由于不同盒子互相隔离同一库的不同版本可以在一个进程内并存。测试test_require_rb_2versioboxtest/ruby/test_box.rb演示了这一点box.require(File.join(__dir__, box, a.1_2_0)) assert_equal 1.2.0, box::BOX_A::VERSION n2 Ruby::Box.new n2.require(File.join(__dir__, box, a.1_1_0)) assert_equal 1.1.0, n2::BOX_A::VERSION # 之前盒子的版本不受影响 assert_equal 1.2.0, box::BOX_A::VERSION盒子嵌套盒子内还可以再创建盒子。测试辅助文件 test/ruby/box/box.rb 展示了box in box的场景对应测试test_box_in_boxtest/ruby/test_box.rbBOX1 Ruby::Box.new BOX1.require_relative(a.1_1_0) def yay BOX1::BOX_B::yay end yay三种盒子类型Ruby Box 将进程内的空间划分为三类文档Ruby Box types一节配合源码 internal/box.h 中的BOX_*宏可以印证类型说明标识Master box母版盒所有盒子的母版拷贝来源没有任何代码在其中运行仅作为创建其他盒子的模板!is_root !is_userRoot box根盒进程中唯一的盒子所有内建类/模块都在根盒中定义并运行is_rootUser boxes用户盒运行用户程序及其加载的库。用户主程序在main box中执行-r选项指定的文件在 main box 中被 requireis_user层次结构示意[master] | |----[root] | |----[main] | |----[user box 1] | |----[user box 2] ...细节要点main box是 Ruby 引导bootstrap结束时自动创建的一个用户盒。用户通过命令行指定的主脚本在 main box 中执行-r命令行选项指定的文件在 main box 中被 require。optional box调用Ruby::Box.new创建的是可选的用户盒非 main技术上与 main box 等价。对应 box.c 中box_entry_initialize设置box-is_user true; box-is_optional true;而 main box 由box_value_initialize(false, true, false)创建box.c。master box只在 Ruby Box 启用时才被创建为可见对象box.c禁用时box_id固定为 1box_object为Qnil。从 Ruby 侧可用Ruby::Box.root、Ruby::Box.main获取 root/main box 对象Ruby::Box.new的实例上还可调用master?、root?、main?判断身份box.c。隔离语义详解盒内类与模块的定义与访问在一个盒子box中新定义的类和模块通过box::A从盒子外部访问在盒子内部A与::A均可直接引用。内建类的重开与猴子补丁隔离这是 Ruby Box 最有价值的场景。在盒子中内建类/模块是可见且可以重开的可以用class/module子句修改定义。修改后的定义只在当前盒子内可见其他盒子中的内建类不受影响。文档示例foo.rb# foo.rb class String BLANK_PATTERN /\A\s*\z/ def blank? self.match?(BLANK_PATTERN) end end module Foo def self.foo foo def self.foo_is_blank? foo.blank? end end Foo.foo.blank? # false foo.blank? # false# main.rb box Ruby::Box.new box.require_relative(foo) box::Foo.foo_is_blank? # false (#blank? 在 box 中调用) foo.blank? # NoMethodError String::BLANK_PATTERN # NameErrormain box 与box是不同盒子main 中的猴子补丁在box中同样不可见——隔离是双向的。测试 test/ruby/test_box.rb 验证了盒内新增方法对全局不可见def test_methods_added_in_box_are_invisible_globally setup_box box.require_relative(box/string_ext) assert_equal yay, box::Bar.yay assert_raise(NoMethodError){ String.new.yay } end配套的 test/ruby/box/string_ext.rb 定义了String#yay并在盒内自检调用成功。内建类/模块的定义在盒子的语境下内建builtin类/模块指在用户脚本中无需任何require即可访问的类/模块在任何用户程序运行之前已定义好的类/模块。内建类在所有盒子中都可加载但在 root box 中运行Builtin classes and modules are loaded in all boxes, and run in the root box。这意味着内建方法内部互相调用时走的是 root box 中的定义详见下文讨论一节对Hash#map/Hash#each的分析。例外非内建但默认启用的类/模块以下类/模块默认可用但并不属于内建类在每个盒子中独立加载RubyGemsErrorHighlightDidYouMeanSyntaxSuggest这些属于 default gems。如果用户盒中的代码调用 RubyGems调用的是盒子自己内部的 RubyGems而不是 root box 的那一份。这一点在源码 box.c 中可以看到新建盒子时若box_gem_flags-gem为真会通过rb_vm_call_cfunc_in_box在盒子内执行rb_define_gem_modules并调用rb_load_gem_prelude从而为每个盒子独立装载 gem 基础设施internal/box.h 中的rb_box_gem_flags_t正是记录这四个开关位。通过盒子对象引用内建类box::String是合法的引用且String与box::String是同一个对象String box::String # true String.object_id box::String.object_id # true但需要注意box::String这种引用返回的是当前盒子中的String其定义解析基于当前盒子而不是基于box。文档示例foo.rb中为String添加self.foo# foo.rb class String def self.foo foo end # main.rb box Ruby::Box.new box.require_relative(foo) box::String.foo # NoMethodError原因在于String是内建类其prime classext主类扩展结构属于 root box盒内对String的修改发生在盒子的独立 classext 副本上通过box::String拿到的仍然是当前盒子视角的String本体因此看不到foo这个在box中追加的类方法。类实例变量、类变量与常量内建类在不同盒子之间可以持有不同的类实例变量、类变量和常量集合。文档示例foo.rb# foo.rb class Array v foo v _foo_ V FOO end Array.instance_variable_get(:v) # foo Array.class_variable_get(:v) # _foo_ Array.const_get(:V) # FOO# main.rb box Ruby::Box.new box.require_relative(foo) Array.instance_variable_get(:v) # nil Array.class_variable_get(:v) # NameError Array.const_get(:V) # NameError测试test_class_variables_in_root_are_invisible_in_other_boxestest/ruby/test_box.rb从另一个方向验证root box 中定义的类变量x在新建盒子中执行x 1会抛出NameError: uninitialized class variable。全局变量盒子内的全局变量修改同样被隔离只在所属盒子内生效。文档示例foo.rb# foo.rb $foo foo $VERBOSE nil puts This appears: #{$foo}# main.rb p $foo # nil p $VERBOSE # false box Ruby::Box.new box.require_relative(foo) # 输出 This appears: foo p $foo # nil p $VERBOSE # false从源码看每个rb_box_t都持有独立的gvar_tbl全局变量表哈希见 internal/box.h 与 box.c 中的box-gvar_tbl rb_hash_new()这正是全局变量按盒隔离的数据基础。顶层常量通常顶层常量是Object的常量。在盒子中顶层常量是盒子内Object的常量且盒子对象box的常量与Object的常量严格相等。文档示例foo.rb# foo.rb FOO 100 FOO # 100 Object::FOO # 100# main.rb box Ruby::Box.new box.require_relative(foo) box::FOO # 100 FOO # NameError Object::FOO # NameError在实现上box_initialize会为每个新建盒子把Object的常量表const table设置到盒子对象上box.cobject_classext RCLASS_EXT_WRITABLE_IN_BOX(rb_cObject, box); RCLASS_SET_CONST_TBL(box_value, RCLASSEXT_CONST_TBL(object_classext), true);顶层方法顶层方法是Object的私有实例方法且按盒子隔离。文档示例foo.rb# foo.rb def yay foo class Foo def self.say yay end Foo.say # foo yay # foo# main.rb box Ruby::Box.new box.require_relative(foo) box::Foo.say # foo yay # NoMethodError目前没有任何方式把盒子中的顶层方法暴露给其他盒子相关设计讨论见文末。测试test_continuous_top_level_method_in_a_boxtest/ruby/test_box.rb验证了全局视角foo不可见而test_top_level_methods_in_box则被标记为pendTODO修复 loading/current box 检测说明顶层方法在盒内连续加载场景下仍有待完善。作用域规则文件级file scopeRuby Box 的作用域是文件级一个.rb文件运行在单个盒子中。一旦文件在盒子box中加载该文件中定义/创建的所有方法、Proc 都运行在box中。这一规则由 VM 层的当前盒机制保证vm.c中的current_box_on_cfp从当前控制帧control frame的cme-def-box方法定义归属的盒子或环境块的VM_ENV_BOX取出盒子见 vm.c。因此方法调用链、Proc 执行时都能一致地回到定义它的盒子中解析方法、常量与全局变量。测试 test/ruby/test_box.rb 用一组用例验证了Proc 携带定义它的盒子这一语义box1.require(#{here}/box/proc_callee) proc_v box1::Foo.callee # 在 box1 中创建的 Proc assert_raise(NameError) { Target } assert box1::Target assert_equal fooooo, proc_v.call # 引用的是 box1 中的 Target实用工具方法以下方法可用于试验与测试 Ruby Box对应 box.c 中Init_Box注册的 API方法行为源码位置Ruby::Box.new创建一个新的可选用户盒box.cRuby::Box.current返回当前盒未启用时返回nilbox.cRuby::Box.enabled?返回是否以RUBY_BOX1启动box.cRuby::Box.root返回 root boxbox.cRuby::Box.main返回 main boxbox.cRuby::Box.master返回 master boxbox.cRuby::Box#eval在接收者盒子中求值 Ruby 代码字符串等价于用文件调用#loadbox.cRuby::Box#require/#require_relative/#load在盒子中加载文件支持.rb与原生扩展box.cRuby::Box#load_path返回盒子本地的$LOAD_PATHbox.cRuby::Box#inspect展示盒子的 id 与类型标签如#Ruby::Box:3,user,optionalbox.cbox.master?/box.root?/box.main?查询盒子身份box.cRuby::Box#eval的实现box.c先编译字符串为 iseq再以目标盒作为执行上下文运行static VALUE rb_box_eval(VALUE box_value, VALUE str) { const rb_iseq_t *iseq; const rb_box_t *box; StringValue(str); iseq rb_iseq_compile_iseq(str, rb_str_new_cstr(eval)); box (const rb_box_t *)rb_get_box_t(box_value); return rb_iseq_eval(iseq, box); }inspect的输出格式box.c会依次追加master、root、user、main、optional等标签例如#Ruby::Box:3,user,optionalRuby::Box.current.inspect在 main box 中会显示main测试test_raising_errors_in_require中对此有断言见 test/ruby/test_box.rb。实现细节与底层原理ISeq 内联方法/常量缓存由于一个.rb文件整体运行在单个盒子中方法/常量解析在盒内自洽。这意味着ISeq 的内联缓存inline cache在盒子的场景下依然有效——如果内联缓存失效或串盒那就是 bug。这是盒子机制能够保持性能的重要前提。方法调用全局缓存gccct与性能代价C 层的rb_funcall()引用全局调用缓存表gccctglobal cc cache table且缓存键的计算包含当前盒子。因此启用 Ruby Box 后rb_funcall()调用会因缓存键复杂化而带来性能损耗。这是文档明确记录的已知代价。当前盒current box与加载盒loading box当前盒正在执行的代码所在的盒子Ruby::Box.current返回的就是它。加载盒内部管理的、用于决定新 require/load 文件归属的盒子。例如调用box.require(foo)时box就是加载盒。vm.c分别实现了二者的推导rb_vm_current_box直接取当前控制帧vm.c而rb_vm_loading_box需要沿控制帧栈向上寻找第一个非 master 盒的加载者vm.c。classext 写时复制CoW与性能优化方向每个盒子对被修改的内建类持有自己的rb_classext_t类扩展结构副本这依赖classext 写时复制机制rb_box_t中的classext_cow_classes表记录了发生 CoW 的类集合internal/box.h。文档讨论一节指出了当前实现的优化空间rb_classext_t中包含cc_tbl方法调用缓存表、callable_m_tbl已解析的互补方法表与cvc_tbl类变量缓存表三类缓存数据仅仅调用方法或引用类变量就会改写这三张表从而频繁触发 classext CoW次数远超预期若能把这三种表移出rb_classext_trb_classext_t的复制次数将大幅减少。原生扩展的盒子本地副本启用 Ruby Box 后加载原生扩展.so/.dll/.bundle时实现会先在进程私有的临时目录_ruby_box_前缀、0700 权限、随机命名的目录见 box.c中生成扩展的盒子本地副本再加载rb_box_local_extensionbox.c以避免不同盒子加载同一扩展时的冲突并在进程退出或 Windows 卸载 DLL 时清理rb_box_unload_local_extensionsbox.c。这也是文档已知问题中安装原生扩展可能在RUBY_BOX1下因 extconf.rb 栈溢出而失败这一现象的背景。已知问题与限制文档明确列出的已知问题包括实验性警告RUBY_BOX1启动时会打印实验性警告可用-W:no-experimental隐藏前文已述。原生扩展安装可能失败RUBY_BOX1下extconf.rb可能因栈深度过深stack level too deep导致安装原生扩展失败。require active_support/core_ext可能失败该库的全局性猴子补丁与盒式隔离存在冲突。盒内定义的方法可能无法被 Ruby 编写的内建方法引用盒内追加的方法对 root box 中运行的内建方法不可见详见下文讨论。文档 TODO 清单中的计划项包括在 iseq 上记录其加载盒以检测其他盒子试图运行该 iseq 的情况仅在VM_CHECK_MODE下加字段、为盒子分配各自的TOPLEVEL_BINDING、修复盒内warn对$VERBOSE与Warning.warn的引用、让内部数据容器类Ruby::Box::Entry不可见当前Entry类是Ruby::Box下的可见类见 box.c、补充更多关于$LOAD_PATH与$LOADED_FEATURES的测试用例。设计讨论与未来方向文档Discussions一节记录了该特性的几个关键设计权衡理解它们有助于把握 Ruby Box 的边界与演进方向。更多用 Ruby 编写的内建方法如果 Ruby Box 成为默认特性内建方法就可以放心地用 Ruby 编写——因为用户无法通过猴子补丁覆盖它们。Ruby 编写的内建方法可以被 JIT 编译从而带来性能收益。被内建方法调用的猴子补丁方法内建方法之间也会互相调用。例如Hash#map会调用Hash#each来获取待映射的条目。在传统 Ruby 中用户覆盖Hash#each后Hash#map的行为会随之改变但有了盒子后Hash#map运行在 root box 中用户只能在用户盒中定义Hash#each无法借此改变Hash#map的行为。想要达到目的用户必须同时覆盖Hash#map与Hash#each或只覆盖Hash#map。这对依赖这类间接猴子补丁的既有代码是破坏性变更。用户虽然可以用Ruby::Box.root.eval(...)实现但文档明确指出这显然不是理想的 API。被内建方法使用的全局变量与猴子补丁同理在盒子中为全局变量赋的新值与 root box 隔离root box 中引用该全局变量的内建方法无法读到重新赋值后的值。$LOAD_PATH与$LOADED_FEATURES的上下文$LOAD_PATH与$LOADED_FEATURES控制require的行为因此这两个全局变量由加载盒决定而非当前盒。这可能与用户的直觉相冲突目前仍在寻找解决方案。从源码看每个rb_box_t都持有独立的load_path、expanded_load_path、loaded_features等数组box.c初始化时从 master box 复制这正是按盒管理加载路径的实现基础。将顶层方法暴露为盒子对象的方法目前盒子的顶层方法无法从盒子外部访问但存在调用其他盒子顶层方法的潜在用例值得进一步设计。结语Ruby Box 为 Ruby 提供了一条在进程内部实现应用、库与猴子补丁相互隔离的新路径通过RUBY_BOX1开启用Ruby::Box.new创建隔离空间用require/require_relative/load/eval装载代码实现类定义、常量、全局变量与顶层方法的按盒隔离甚至在单进程内并行加载同一库的多个版本。它是一个标记为 experimental 的底层特性在性能gccct 缓存键、classext CoW 频率与兼容性内建方法调用的间接补丁、原生扩展安装、$LOAD_PATH语义上仍有明确的已知问题与待办事项。对隔离、沙箱与依赖治理感兴趣的读者可以继续研读 doc/language/box.md 原始设计文档、box.c 与 internal/box.h 的实现以及 test/ruby/test_box.rb 与 test/ruby/box 目录下的完整测试用例。【免费下载链接】rubyThe Ruby Programming Language项目地址: https://gitcode.com/GitHub_Trending/ru/ruby创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表