一个 Minecraft 插件开发者绕不开的困境
写过 Bukkit 插件的人都遇到过这种情况:你想改的逻辑就在那里,源码摆在 Spigot 仓库里,每个人都看得到,但你改不动。
服务端给你的接口是一套事件 API。当某件事发生时——玩家加入、方块破坏、实体受伤——你能监听到一个事件对象,能取消、能修改部分字段。但事件覆盖的范围永远是有限的:它是设计者认为"开发者可能想干预的地方"开出来的窗口。
很多需求不在这些窗口里。你想在某个内部方法第 17 行的某次 inventory.update() 调用之前插点东西,你想把某段过时的实现整段替换掉,你想观察一个根本没暴露事件的 NMS 字段。这些场景里,事件 API 帮不了你。
更难受的是:服务端代码也在不断变。Mojang 改了字段名,Spigot 改了实现路径,每个 Minecraft 版本都要重写一遍兼容层。
字节码织入:跳过 API、直接改实现
绕开这个困境的标准答案是字节码织入——不依赖任何 API,直接修改 JVM 在跑的方法体。模组生态有 Mixin,企业 Java 有 AspectJ,云原生有 Java Agent,思路都一样:类被加载到 JVM 之前或之后,把字节码改了。
但把这套技术搬到插件场景,每条路都不通畅:
Mixin 深度绑定 Fabric/Forge 的模组加载器和 Mixin 配置文件,Bukkit 启动流程里没这些东西
AspectJ 需要编译期织入或 LTW agent,每个使用者都得改启动参数
Java Agent 必须在 JVM 启动时通过
-javaagent:xxx.jar注入,服务器管理员通常不愿意为单个插件改启动脚本ByteBuddy 提供了 self-attach 但仍然要求拿到
Instrumentation实例,过程涉及 tools.jar、JDK 模块导出、JVMTI 等多个层面的兼容性问题
incision 模块要做的事,是把这堆问题打包解决——让插件作者用一个注解或一段 DSL,就能在运行时改任意已加载类的字节码,不需要管底下 attach 的是哪条路径,不需要管目标类是 NMS 还是普通 POJO,不需要管 JDK 版本和模块化策略。
设计核心:把字节码织入抽象成"做手术"
整个模块的术语体系沿用了一套外科手术的隐喻。这不是装饰,是一种有意识的概念分层——每个层次只承担它自己的角色,互相之间通过明确的协议交互:
这套隐喻有两个实际意义。第一个是可读性——suture.heal() 比 transformer.unregister() 直接得多,意图一眼就能看懂。第二个更重要:它强制了职责分离。当你试图给 Theatre 加一个 attach 相关的方法时,你会立刻意识到不对——手术室的医生不该管手术刀怎么消毒。这种命名层面的语义压力,会反过来约束代码不越界。
技术上模块按八个包组织:annotation(声明) / api(公开接口) / dsl(构建器) / runtime(调度) / weaver(字节码改写) / loader(attach 后端) / remap(NMS 名称转译) / diagnostic(诊断与错误)。这八个包的依赖方向严格单向——api 不依赖 runtime,runtime 不依赖 weaver,weaver 不依赖 loader。这条规则让任何一层都可以替换:把 weaver 从 ASM 换成 Javassist 不需要动 runtime;把 loader 加一个 GraalVM 后端不需要动 weaver。

Advice 类型体系:用控制力分级
字节码织入领域的工具,往往把"切到哪里"和"做什么"揉在一起。@Around 一个注解干所有事,能力最强,但也意味着每次写 advice 都要思考"我会不会破坏控制流"。
incision 把这件事拆成了七种 advice,按控制力强度排成一条递增的阶梯:
观察类 → 追加类 → 替换类 → 覆盖类
↓ ↓ ↓ ↓
@Lead @Graft @Bypass @Excise
@Trail @Splice
最弱的是 @Lead 和 @Trail——只能观察方法的入口和出口,不能阻断、不能替换返回值。它们对应到字节码上是在 HEAD 和 RETURN 锚点前插一行 dispatcher 调用,原方法体一行不动。这种切入兼容性最好,跟其他人的 advice 叠加也不会冲突。
中间层是 @Graft——在方法内的某个调用点之前/之后追加逻辑。它能感知"原方法第 3 个 Player.kickPlayer() 调用即将发生",但只是追加,不改变行为。
再往上是 @Splice(环绕)和 @Bypass(重定向)。@Splice 完整包裹原方法,handler 必须显式决定是 proceed() 放行还是 override() 接管返回值——没有缺省行为。这个强制约束很重要:它让"原方法到底会不会执行"这件事变成显式决策,不会出现写错 advice 导致原方法被静默吞掉的事故。@Bypass 则是在方法内替换某条具体调用——这就是 Mixin 用户熟悉的 @Redirect。
最强的是 @Excise,直接覆盖整个方法体。任何同目标的其他 advice 在它面前都失效。incision 对这一类做了唯一性约束——同一目标只允许一个 @Excise,否则在启动检查阶段直接抛 Trauma.Conflict.MultipleExcise。这不是技术限制,是设计限制:两个插件同时覆盖同一个方法,逻辑上就是互相覆盖,不存在合理的合并策略,与其让它运行起来产生不可预测的结果,不如启动时就拒绝。
这套分级有一个隐含的引导意图:让大多数场景用最轻量的 advice 解决。打日志用 @Lead,做统计用 @Trail,触发联动用 @Graft——这三种叠加再多都不会出问题。只有真正需要控制流接管的场景才升级到 @Splice 或 @Excise,而升级的代价是更严的约束、更高的兼容风险。这种"权力越大、责任越大"的设计,远比"提供一个万能 @Around 让用户自己选"更友好。

Surgeon 与 SurgeryDesk:声明的两种形态
incision 提供两种声明 advice 的方式。
注解式——把 handler 写在一个 Kotlin object 里,加上 @Surgeon,然后在内部方法上标注 @Lead/@Trail/@Splice 等。框架扫描这些 object,把里面的方法注册成 advice。这种方式适合结构化、长期存在的织入:声明一次、随插件生命周期常驻。
DSL 式——通过 Scalpel { ... } 块在运行时构建 advice,按需 arm 或 heal。这种方式适合临时性、条件性的织入:测试期开启、生产期关闭,或者根据某个事件动态启用。
两者在底层共享同一套 runtime——都生成 AdviceEntry 注册到 SurgeryRegistry,都走相同的 weaver 和 dispatcher。差别只在前端:注解式是声明性的,DSL 式是命令性的。
值得注意的是 @Surgeon 强制要求标注在 object 上,而不是普通 class——如果标错了,启动期直接抛 Trauma.Declaration.InvalidHolder。这个限制是有意为之:advice handler 不该有实例状态,多个调用之间共享的状态应该是显式的全局状态而不是隐式的对象字段。Kotlin object 在语言层面就保证了单例语义,比靠文档约定更可靠。
DSL 这边还提供了几种生命周期变体:
scalpel { }——属性级,跟随声明类常驻Scalpel.deferred { }——惰性,第一次访问才物理织入Scalpel.scoped { }——作用域,块结束自动 healScalpel.threadLocal { }——线程局部,按线程激活Scalpel.armOn(eventClass) { }——事件触发,监听到指定事件时才 armScalpel.exclusive(...) { body }——互斥块,进入时挂起同目标的其他 advice,退出时恢复
这些生命周期变体的存在意义是把"什么时候织入"也变成可声明的。否则用户得自己写一堆 register() / unregister() / try-finally,状态管理散落在用户代码里。incision 把常见模式预制成 DSL 方法,让常见生命周期成为一行代码,错误的状态转换在 API 层面就被排除掉。
选择器:Scope 选对象,Site 选指令
字节码织入面对的第一个工程问题是:怎么描述要切的目标。一种朴素的答案是用反射 API——给个 Class 对象、给个 Method 名。但这种方式有两个缺陷:一是反射依赖目标类已加载,启动期声明很难做到;二是表达能力有限,没法描述"所有以 set 开头的方法"或"所有返回 Player 的方法"这类批量目标。
incision 把目标描述拆成两个正交维度:
Scope(范围)—— 选哪些对象。Scope 是一种 DSL 字符串,运行时被解析成谓词。基本形态是冒号分隔的语义类型加描述符:class:com.foo.Bar 选类、method:Foo#bar(*) 选方法、field:Foo#name:String 选字段。再加上 &(与)|(或)!(非)三个组合运算符,构成一套小型的目标筛选语言。
Scope 的设计选择是用字符串而非类型化构建器。Java 阵营有 ByteBuddy 那种 ElementMatchers.named("foo").and(returns(Player.class)),类型安全但冗长。incision 用字符串选择器,体积更小、可序列化、能写在注解参数里。代价是失去编译期检查——但因为 Scope 本身就要面对运行时不存在的类(NMS 类要等服务器启动),编译期检查从一开始就是不可能的,那不如索性用字符串换语法简洁。
Site(位点)—— 选方法里的哪条指令。Scope 选出来的是方法,但方法可能很长,advice 想切到方法内的某个调用前面、某个字段访问后面、某个 new 表达式之前。Site 注解描述这些精细位点:
@Site(anchor = INVOKE, target = "Player#kickPlayer(*)", shift = BEFORE, ordinal = 0)
anchor 是锚点类型,对应字节码指令的种类——HEAD(方法入口)、RETURN(出口)、INVOKE(方法调用)、FIELD_GET/FIELD_PUT(字段访问)、NEW(对象创建)。target 进一步限定具体调用什么。shift 控制在锚点之前还是之后插入。ordinal 选第几个匹配——一个方法里可能有十几次 kickPlayer 调用,ordinal 让你精确定位到第三次而不是其他。offset 在选定锚点的基础上再偏移几条指令。
这种细粒度选择器是 incision 区别于传统 AOP 的关键。AspectJ 和 Spring AOP 的最小切入粒度是方法,方法内部细节看不见;incision 把字节码的 InsnNode 一级直接暴露成可选择对象。这意味着你能干一些方法级 AOP 干不了的事——比如把某个内部 socket.write() 替换成自己的实现而不动周围逻辑、在某次 inventory.update() 之前注入校验、在某个 throw new RuntimeException(...) 出现之前打日志。
两个维度独立——Scope 决定"作用在哪些方法上",Site 决定"在每个方法里的什么位置"。同一个 Site 模式可以套用在 Scope 选出的任意多个方法上,而不需要为每个方法单独写。这种正交性让选择器的表达能力以乘法增长。
InsnPattern:用字节码形态做精筛
Scope 和 Site 还不够。考虑这种场景:你想切的不是"所有名叫 foo 的方法",而是"所有名叫 foo、并且方法体里包含某段特定指令序列的方法"。同样叫 foo,不同 Minecraft 版本的实现可能完全不一样,你只想切那个特定形态的实现。
incision 的回答是 InsnPattern——给注解再加一层字节码指令序列约束。它不是简单的字符串匹配,而是结构化的指令序列描述:可以列出"必须包含一个 GETFIELD 取 entityId、紧接着一个 ICONST_0、再一个 IF_ICMPEQ"这种模式。
InsnPattern 的实际意义有两个。第一个是版本兼容性:Minecraft 升级换代时,方法名往往保留但实现变化,靠 InsnPattern 能让一份 advice 自动适配多个版本——目标方法长什么样就切什么样,长得不对就跳过。第二个是反混淆鲁棒性:混淆器会改名字,但很难改字节码骨架,pattern 比 name 更稳定。
这是从 Mixin 借来的想法(Mixin 也有 @Inject(at = ...) 的 ordinal/by/desc 精筛),但 incision 把它变成了所有 advice 注解都能用的可选参数,而不是某种特殊用法。
名称转译:让一份代码跑遍 NMS 版本
Bukkit 插件最棘手的部分是 NMS(net.minecraft.server)。Mojang 的混淆名、Spigot 的反混淆名、不同 Minecraft 版本的包名前缀(net.minecraft.server.v1_16_R3 vs net.minecraft.server)——同一个类在不同环境下叫不同的名字。
如果让用户自己处理,每个 advice 都得写一堆 if-else 选名字。incision 把这件事抽象成 NameResolver 接口:用户写的目标名(不管是 Mojang 名、Spigot 名还是版本化名)经过 resolver 转译成当前运行环境下真实存在的 internal name。
转译的策略很务实:默认走 NoopResolver,原样返回——非 NMS 场景不付出任何代价。当检测到 module:bukkit-nms 在 classpath 时(TabooLib 自带的 NMS 适配模块),bootstrap 会把 resolver 切换成 TabooLibNmsResolver,对 net/minecraft/、com/mojang/、org/bukkit/craftbukkit/ 这些前缀自动走 NMS 映射。
RemapRouter 在最外层做按需路由——每个 owner 进来时先看是不是 NMS 类,是的话才让 NMS resolver 处理。这种按需重映射比无差别全部转译要快得多,绝大多数普通类直接放行,只有真正需要的才走映射表。
转译结果会缓存,缓存键是"原描述符 + 环境签名"。环境签名包含 Minecraft 版本、Spigot 版本等会影响映射结果的因素——版本变了缓存自动失效。这一层缓存让 NMS 名称解析的开销从每次都查变成一次性查询,对启动时大量的 advice 注册影响显著。
@Version 注解是另一个版本兼容工具——把版本范围作为 advice 的过滤条件。@Version(start = "1.20", end = "1.21.4") 让 advice 只在该区间生效,超出范围直接不注册。这避免了"在不支持的版本上注册一个会失败的 advice"——失败发生在声明阶段而不是运行阶段,错误前置。
Theatre:handler 拿到的是一个手术室
字节码织入的运行时核心,是 advice handler 真正被调用时拿到什么。这个抽象决定了 handler 能干什么、写起来有多痛苦。
最朴素的方案是把目标方法的参数原样传过来。但这样就有问题:handler 里没有办法访问 self(this 指针),没有办法读 self 上的私有字段,没有办法替换返回值。要解决这些就得给 handler 传更多东西——参数列表、self 引用、返回值容器、控制流句柄……传得越多,handler 签名越乱,每种 advice 类型还得有自己的特殊签名。
incision 把这一切收拢到一个对象:Theatre。所有 advice handler 的统一签名都是 (Theatre) -> Any?。一个 facade 暴露所有运行时能力:
target—— 当前命中的方法坐标self—— this 引用(静态方法为 null)args—— 实参数组,可读写——直接修改会反映到放行的调用上resume—— 控制流句柄override(value)—— 强制覆盖返回值并终止后续 advicefield(name)/setField(name, value)—— 读写 self 上的字段invoke(name, args...)—— 调用 self 上的方法staticField(...)/invokeStatic(...)—— 静态版本
这个设计的核心意图是:让 handler 完全不需要知道目标类的 Java 类型。Theatre 是一个 façade,handler 通过字符串名字访问字段和方法,不需要 import 目标类、不需要写反射模板、不需要管 setAccessible。这对 NMS 场景尤其重要——目标类可能是 net.minecraft.server.v1_16_R3.EntityPlayer,类名带版本号,写在 import 里根本不能跨版本。
底层实现这个 facade 的是 IncisionAccessor,它统一处理字段读写和方法调用,按可用性自动降级:能用 JVMTI native 就用 JVMTI(无视所有 Java 访问控制),不行就走反射 setAccessible(true),连反射都被模块化拒绝时(JDK 17+ 的某些场景)就用 sun.misc.Unsafe 兜底写 final 字段。三条路径用同一个接口暴露,handler 不感知底下走的是哪条。
解析结果缓存在 ConcurrentHashMap 里——同一个字段名第二次访问不再做反射查表。这一点对 hot path 上的 advice 很关键:handler 可能在每个 tick 跑一次,反射开销累积起来不容忽视。
Resume:把控制流变成显式状态机
@Splice 这类环绕 advice 必须能控制原方法是否执行、用什么参数执行、返回什么结果。Spring AOP 是通过 ProceedingJoinPoint.proceed() 表达,AspectJ 是通过 around advice 的 proceed 关键字。Mixin 用 CallbackInfo.cancel() 和 setReturnValue。
incision 的 Resume 接口是一个简化的状态机:
proceed()—— 放行到下游 advice 或原方法proceed(args...)—— 放行并替换实参proceedResult(value)—— 把一个结果继续传给下游 adviceskip(value)—— 跳过原方法,直接用给定值终止链proceeded—— 是否已显式放行过
这套接口最有意思的设计是它的强制约束:@Splice 的 handler 如果既没有 proceed() 也没有 override(),框架抛 Trauma.Runtime.ResumeMissing。
为什么这么严?因为"忘记 proceed"是 AOP 编程里最常见的事故。Spring AOP 里你写一个 around advice,忘了调 pjp.proceed(),原方法就被静默吞掉,bug 可能要好几天才发现。incision 的强制约束是一种故意制造编译失败的等价物——既然语言层面没法在编译期检查,那就让运行期的第一次执行就抛异常,问题在第一次触发时立刻暴露。
多个 @Splice 叠加时形成调用链。chain 顺序由 priority 决定(数值越大越先执行),每个 advice 的 proceed() 触发下一个 advice,最后才是原方法。这种链式语义让多插件协作变得可能——A 插件做权限校验、B 插件做日志、C 插件做缓存,按 priority 串起来,每一个都能选择放行或短路。
Suture:织入产物的句柄
字节码改完了不算结束。运行时还要能问:这个 advice 现在还活着吗?能不能临时停一下?我不想要它了能不能干净地撤销?这些都得有一个把手。
Suture 就是这个把手——织入产物的运行时句柄,状态机由五个状态组成:
ARMED 已织入并启用,正常工作中
TRIGGERED 已被触发过(统计用,不影响行为)
SUSPENDED 挂起,字节码保留,dispatcher 跳过 handler
HEALED 已撤回,字节码已回滚或仅剩占位
INACTIVE_UNRESOLVED 解析失败,访问会再次抛 Trauma
最有意思的是 SUSPENDED 状态。完全卸载一个 advice(HEALED)需要重新生成字节码、调一次 retransform、清掉 dispatcher 表,开销不小。但很多场景只是"临时关一下"——单元测试里禁用某个 advice、debug 模式下绕开某个 hook、A/B 测试期间切换实现。SUSPENDED 让这些场景不需要真的撤回字节码,只是让 dispatcher 在调度时跳过这个 entry。开销是一次 atomic check,几乎为零。
BatchSuture 是 Suture 的复合版本——一次 DSL 调用可能注册多条 advice,BatchSuture 统一持有这一组的句柄,支持按谓词部分撤回(heal { it.targets.first().className == "..." })。
Suture.close() 默认调 heal(),所以 Suture 可以被 try-with-resources 管理。配合 Scalpel.scoped { ... },作用域内织入、作用域外自动撤销,状态绝不泄漏到块外。
这种显式句柄 + 状态机的设计,把字节码织入这种重操作的资源管理变成了和 InputStream 一样可预测的东西。开发者不需要担心"我注册了忘记取消怎么办",因为生命周期在 API 层面就被结构化地表达了。
Weaver:把 advice 翻译成字节码
到这里前端的故事讲完了——开发者写出声明,runtime 把它存好,等待真正发生织入。织入的引擎在 weaver 包,这是整个模块技术含量最高的部分。
字节码织入的直觉做法是直接在原方法体里塞指令。比如 @Splice,做法似乎是这样:在方法开头插一段调 dispatcher 的指令,根据返回值决定是 jump 到方法尾还是继续执行原指令流。听起来简单,但一旦真做下去,会撞上一面墙:StackMapFrame。
JVM 字节码校验器从 Java 6 开始要求每个分支汇合点都有显式的栈帧描述(StackMapFrame)。栈帧记录的是该位置局部变量的类型和操作数栈的内容。一旦你在方法体里插指令——尤其涉及条件跳转、try-catch、多入口——栈帧就要重算。算错一点点,整个类加载失败,抛 VerifyError,而且错误信息几乎没法直接对应到你插了哪条指令。
ASM 提供 COMPUTE_FRAMES 标志可以让 ClassWriter 自动算栈帧,但前提是给它的 InsnList 本身合法。如果你插入的指令序列在概念上就是错的(比如栈高度对不上),COMPUTE_FRAMES 也救不了。
Side-car Bodies:把方法体搬出去
incision 对强织入采用了一种巧妙策略——Side-car Bodies:
不在原方法体里改,而是把原方法体整段复制到一个伴生类的静态方法里,原方法体替换成一个极简的 wrapper。

具体来说,对于目标类 Foo,weaver 生成一个伴生类 Foo$$IncisionBodies,把 Foo.bar() 的方法体复制到 Foo$$IncisionBodies.Foo$bar$body(Object self, Object[] args) 这样一个统一签名的静态方法里。原方法 Foo.bar() 的方法体被改成几行指令:
1. 把 self 和 args 装箱
2. 调用 dispatcher
3. dispatcher 决定要么调 body 要么直接返回
4. 把结果拆箱返回
这个设计有四个层面的好处。
第一,wrapper 极简,栈帧极易计算。原方法体可能有几百条指令、复杂的 try-catch、几十个局部变量、各种类型的栈帧汇合点。但替换后的 wrapper 只有十几条指令,控制流单一,COMPUTE_FRAMES 几乎不会出问题。
第二,原方法体作为独立单元保留。body 方法是普通的静态方法,不被任何后续织入污染。其他 advice 想再切这个方法时,weaver 总是从 body 重新生成 wrapper,不会层层叠加。这一点对插件场景特别重要——多个插件先后对同一方法注册 advice,每个都看到相同的"原始"输入,互相之间不会因为织入顺序不同而表现不一样。
第三,参数和返回值统一为 Object/Object[]。无论目标方法的真实签名多么复杂,进入 dispatcher 的协议都是 (String dispatchSig, Object self, Object[] args) -> Object。dispatcher 不需要为每种签名生成专门的调用桩,只要做泛型化的装箱/拆箱。这让 dispatcher 实现极其简洁。
第四,try-catch 表通过 label map 一并迁移。复制方法体时,新方法的 label 用一个映射表逐个对应到原 label,try-catch handler 跟着 label 一起搬过去。这样异常处理逻辑保持原样,不会因为复制丢失。
代价是空间——伴生类增加一份字节码副本。但插件场景里被织入的类通常是有限几个,多一倍字节码完全可接受。换来的是安全可预测的织入,这笔交易非常划算。
FrameVerifier:写入 JVM 之前先自检
即使有了 Side-car Bodies 策略,weaver 仍然在做大量字节码改写——wrapper 的指令、site 锚点的 dispatcher 注入、多种 advice 类型的混合。任何一处出错都会让目标类加载失败。
更糟的是,VerifyError 发生在 JVM 内部,错误信息通常只有"方法 X 的字节码无效"这种程度,定位到 incision 哪一步插错了几乎不可能。
incision 的解法是预检:在 ClassWriter 输出前跑一遍 FrameVerifier。
FrameVerifier 用 ASM 的 Analyzer + BasicVerifier 在内存中模拟一遍方法体,验证每条指令的栈和局部变量类型一致性。选 BasicVerifier 而不是 BasicInterpreter 是因为前者更严格——它会校验 INVOKE 的参数类型、GETFIELD 的 owner 类型,更接近 JVM 真实校验器的行为。
如果 verify 失败,weaver 直接回退原字节码,把 verify 的错误转成 Trauma.Weaving.FrameMismatch,附带方法坐标和首错位置。这样:
用户拿到的是结构化的 incision 错误,不是 JVM 的 VerifyError
错误信息直接指向"哪个 advice 织在哪个方法上失败了"
目标类不会进入坏字节码状态,老的字节码还在继续工作
预检的成本是每次织入多解析一次方法体,但换来的是可控的失败模式。织入失败本来是悲剧——类加载坏了整个服务器都起不来。预检把它变成可恢复的工程问题——某个 advice 不可用,其他一切照常。
Dispatcher:从字节码到 handler 的桥
字节码插入的所有 advice 调用最终都汇聚到一个入口:TheatreDispatcher.dispatch。这个静态方法接收三个参数——一个签名字符串、self 引用、装箱后的参数数组——返回一个 Object。整个 advice 调度协议就是这一个方法签名。
签名字符串编码了"我是哪个目标方法的哪个 phase"。DispatchSignatureCodec 处理这一层:phase 入口(LEAD/TRAIL/SPLICE)签名形如 baseSig@PHASE,site 入口(GRAFT/BYPASS)形如 baseSig#encodedAdviceId。phase 路由查询 chain 表里所有符合条件的 advice,site 路由直接定位到单条 entry。
为什么要区分这两种路由?因为它们的语义不同。phase 入口是"方法的入口被触发了,所有注册到入口的 advice 都要跑一遍";site 入口是"方法第 7 条指令被触发了,跑这条指令对应的那一个 advice"。前者是一对多调度,后者是精确匹配。两套路由独立,避免互相干扰。
advice id 本身可能含 @/# 字符(比如来自 DSL 的自动生成 id),所以 encodedAdviceId 走 URL-safe Base64 编码后再拼接,避免被分隔符逻辑误拆。这种自描述的协议格式让一条字符串就承载了完整路由信息,dispatcher 不需要查表反查就能知道该跑什么。
dispatcher 内部维护 advice chain——每个目标方法对应一条按 priority 排序的 advice 列表。运行时按顺序调用每个 handler,每个 handler 拿到自己的 Theatre 实例,控制流通过 Resume 在 chain 内部传递。
BridgeClassLoader:跨越 ClassLoader 隔离
这里有一个绕不过去的工程问题:dispatcher 必须能被任意 ClassLoader 加载的目标类访问到。
Bukkit 服务器的 ClassLoader 拓扑很复杂——服务端的核心类在 system ClassLoader,每个插件有自己的 PluginClassLoader,TabooLib 自己也有专门的 IsolatedClassLoader。目标类可能被任何 ClassLoader 加载。如果 dispatcher 的字节码静态引用 taboolib/module/incision/runtime/TheatreDispatcher,而该类对目标类的 ClassLoader 不可见,调用直接 NoClassDefFoundError。
最朴素的方案是把 dispatcher 安装到 system ClassLoader——但服务器管理员通常不允许、也没法保证 system ClassLoader 在所有部署形态下都可写。
incision 的方案是 BridgeClassLoader——一个 parent 为 null 的特殊 ClassLoader,作为系统级桥梁。dispatcher 入口(IncisionGate)注入到这个 BridgeClassLoader 里,所有从目标类发出的 INVOKESTATIC 调用都指向 BridgeClassLoader 中的桥接类。
BridgeClassLoader 的关键能力是 delegate 列表——它内部维护一个弱引用列表,每个 delegate 是一个真正持有 incision runtime 的 ClassLoader(通常是 TabooLib 自己的 ClassLoader)。当 BridgeClassLoader 被请求加载某个类时,它依次尝试 delegate,找到就返回。
这种设计让 BridgeClassLoader 本身几乎是空的——它只持有桥接 stub,真正的逻辑通过 delegate 转发到 TabooLib runtime。多个插件同时使用 incision 时,每个插件的 ClassLoader 注册到 BridgeClassLoader 的 delegate 列表里,调用通过 delegate 找到对应的 runtime 实例。
桥接类是不可见的实现细节,开发者完全不需要感知它的存在——但它是让 incision 在复杂 ClassLoader 拓扑里能稳定工作的关键。
Loader:三种 attach 路径的降级链
字节码生成出来还得让它真正生效。在 JVM 里改一个已加载类的字节码,唯一的官方途径是 java.lang.instrument.Instrumentation.retransformClasses()。但拿到 Instrumentation 实例需要 JVM 启动时通过 -javaagent 注入——这是插件场景做不到的,服务器启动脚本不归插件控制。
incision 提供三条 attach 路径,按可用性自动降级。

第一条:InstrumentationBackend(主力)。运行时通过 self-attach 拿到 Instrumentation 实例。JDK 8 走 tools.jar 里的 com.sun.tools.attach.VirtualMachine,反射加载它,动态生成一个最小 agent jar(含 Agent-Class 和 Can-Retransform-Classes 的 manifest),attach 到自己进程,让 agent 的 agentmain 把 Instrumentation 写到一个静态字段。JDK 9+ 用内置的 jdk.attach 模块,连 tools.jar 都不需要。
self-attach 在 JDK 9 之后被默认禁用,需要 -Djdk.attach.allowAttachSelf=true。incision 的处理是——先 set 系统属性再 attach。这个 trick 不是所有 JVM 都生效,但在 HotSpot 上工作得很好。
如果 classpath 里有 byte-buddy-agent,优先反射调用 ByteBuddyAgent.install()——byte-buddy 处理了大量边界情况,比 incision 自己手写的更鲁棒。byte-buddy 是 ByteBuddy 框架的 agent 部分,社区维护成熟。ManualSelfAttach 是没有该依赖时的兜底实现。这种优先用成熟库、自己实现作兜底的策略,平衡了功能性和依赖最小化。
第二条:JvmtiBackend(备选)。JVMTI 是 JVM 的 C/C++ native 接口,比 Java Instrumentation 更底层。它能干一些 Java 层做不到的事——直接读写 final 字段、绕过模块化访问检查。incision 在 jar 内 bundle 了 Windows/Linux/macOS 三个平台的 native 库,运行时按平台加载。
JVMTI 后端的优势是不依赖 self-attach 和 tools.jar,对 JDK 17+ 的模块化更友好。劣势是要维护 native 代码,跨平台测试成本更高。
第三条:ClassLoaderHookBackend(兜底)。前两条都不可用时的最后手段——反射劫持 PluginClassLoader 的 findClass/defineClass 方法,在类被定义之前插入 weaver 转译。
这条路有严重限制:只能命中 hook 注册之后才被加载的类,对已加载类无效。所以它实际更多是个"标记",转译动作仍要靠 InstrumentationBackend 真正生效。它存在的意义是优雅降级——主力后端不可用时,至少启动早期的织入还能工作,不至于完全崩盘。
三条路径的优先级是 Instrumentation > JVMTI > ClassLoaderHook。installWeaver 时按这个顺序选第一个 available 的后端。这种多后端 + 自动降级让 incision 在不同 JDK 版本、不同操作系统、不同插件加载环境下都能找到一条可用的织入路径——这是它真正能在生产环境跑起来的关键工程支撑。
诊断:把"哪里出了什么问题"变成结构化数据
字节码织入复杂度极高,失败模式也极多——目标类找不到、方法签名不匹配、字节码插入后栈帧错位、handler 抛异常、多个 advice 互相冲突……每一种都需要不同的处理策略。
如果错误用 RuntimeException 一把抓,用户拿到一个堆栈面对一片字节码相关的术语,几乎没法定位是自己 advice 写错了还是框架 bug。incision 在诊断这件事上花了大量心思。
Trauma:分阶段、分维度的错误体系
错误的根类是 Trauma,它不是简单的异常类,而是一个携带丰富元数据的密封类层级。每个 Trauma 实例至少包含:
phase —— 出错的阶段:DECLARATION(声明扫描)/ RESOLUTION(目标解析)/ WEAVING(字节码织入)/ RUNTIME(dispatch 执行)/ ATTACH(loader 后端)/ CONFLICT(advice 冲突)
incisionId —— 哪条 advice 出问题了
target —— 目标方法坐标
rawDescriptor / translatedDescriptor —— 原始目标描述符和经过 NMS 转译后的描述符
resolverName / resolverEnv —— 哪个 resolver 处理的、当时的环境签名
每个阶段下又有具体子类。比如 Declaration 下有 InvalidHolder(标错位置)、DuplicateId(id 重复);Weaving 下有 FrameMismatch(栈帧不匹配)、UnsupportedConstruct(不支持的语言构造)、AsmVerifyError;Runtime 下有 ResumeMissing(@Splice 没有 proceed)、HandlerThrew(handler 抛异常)、ArgCoercionFailed(参数类型转换失败)。
这套设计的意图是让错误本身就是结构化诊断。看到 Trauma 的类型就知道发生在哪个阶段、哪类问题;看到附加字段就知道具体上下文。日志聚合系统(比如 ELK)可以直接按 phase 分组、按 incisionId 索引、按 resolverEnv 分桶——错误不再是字符串,而是可查询的事件。
模块的注释里有一条原则:禁止抛 NullPointerException 或裸 RuntimeException。每个失败路径必须用对应的 Trauma 子类。这是一种强制结构化的工程纪律——零散的 NPE 是可观测性的敌人,强制类型化让所有失败模式在代码层面可枚举。
ConflictAnalyzer:注册期的相容性检查
多个 advice 注册到同一目标时,可能存在已知风险的组合。比如:
同一目标上有两个
@Excise—— 它们互相覆盖,结果取决于注册顺序,几乎不可能正确@Excise+ 任意非 LEAD/TRAIL —— EXCISE 替换整个方法体,链中后续 advice 多半不可达多个
@Bypass切到同一锚点 —— 行为依赖优先级,容易出错
ConflictAnalyzer 在每次 advice 注册时跑一遍这些规则,根据严重程度输出 ERROR / WARN / INFO。ERROR 直接抛 Trauma.Conflict.MultipleExcise,注册失败;WARN 在日志里出现但允许继续;INFO 只是观测。
这种注册期的相容性检查是 incision 给开发者最有价值的反馈之一。Mixin 的 @Overwrite 冲突是运行时偶发的——你跑了一千次发现一次行为奇怪,溯源到底是哪个 Mixin 在生效几乎全靠经验。incision 直接让冲突在注册阶段就拒绝,问题在最早的时机暴露。
Checkup:启动一次性体检
启动期所有 SurgeryDesk 注册完之后,Checkup.runStartupCheckup() 跑一次汇总——列出所有已注册 suture、目标方法、当前状态,输出一张表格到日志。
这件事的价值不在功能本身,而在可见性。对插件开发者而言,看一眼日志就知道 incision 在我这台服务器上到底切了什么、切到了哪、有没有解析失败的。运维做 hotfix 时这张表格直接告诉他"这个进程上挂了哪些字节码补丁"。
设计回顾
incision 的整套架构,与其说是字节码织入框架,不如说是把字节码织入这件复杂的事系统化封装的工程产物。它解决的不是"如何插入一行字节码"——那是 ASM 一两行代码的事——而是"如何让一个普通插件作者在不懂字节码的前提下安全地、可调试地、跨版本地、可撤销地把字节码插入这件事用起来"。
回看几个关键的设计选择,每一个都在做同一种取舍:用更复杂的内部实现,换更简单的外部表达。
Side-car Bodies vs 直接修改方法体——内部多生成一个伴生类,换原方法栈帧极易计算、字节码层层叠加不会乱。
Theatre facade vs 多种 handler 签名——内部维护一个 facade 对象做泛型化调用,换所有 advice 类型同一个 (Theatre) -> Any? 签名。
多后端 + 自动降级 vs 单一 attach 路径——内部维护三套实现,换不同 JDK 和操作系统都能跑。
结构化 Trauma vs 通用异常——内部多写几十个异常子类,换错误信息可查询、可分类、可聚合。
Scope + Site 二维选择器 vs 单一选择器——内部维护两套独立的解析逻辑,换表达能力以乘法增长。
Suture 状态机 vs 简单注册/注销——内部多维护几个状态,换 suspend/resume/heal/scoped 这些常见生命周期都能直接表达。
这种复杂度内化是好工具的共同特征。糟糕的工具把复杂度甩给用户——给你一个 Instrumentation 实例,剩下的自己处理。好的工具承担复杂度,让用户面对的接口和领域概念匹配——你想干什么,API 就长什么样。
incision 用了"做手术"的隐喻不是炫技,是因为这套隐喻比技术名词更接近用户的真实意图。用户想做的事是"在这个方法这里插一段逻辑、出问题能撤回",不是"调用 Instrumentation 的 retransformClasses 方法、传入一个 ClassFileTransformer"。当 API 用 surgeon、scalpel、suture 这套词汇时,开发者想到的就是手术的语义——切口、动作、缝合、愈合——而不是 ASM 的指令操作。这种语义层面的对齐,是任何运行时元编程框架都应该追求的目标。
字节码织入永远是一项危险的技术。但 incision 让这种危险变得有边界、可观测、可恢复——这才是它真正的价值所在。

参与讨论
(Participate in the discussion)
参与讨论