前言
在开始之前,我们先来模拟一下以下的场景:
小李:“小明,你的接口没有返回数据,麻烦帮忙看一下?”
小明:“我这边的数据也是从别人的服务器中拿到的,但是我不确定是因为逻辑处理有问题导致没有结果,还是因为我依赖的服务有问题而没有返回结果,我需要确认一下。”
小明:“哎呀,线上没有日志,我需要加个日志上个线。”
30 分钟之后……
小明:“不好意思,日志加错地方了……稍等……”
接来下隆重登场的就是本文的主角 JVM SandBox 了。基于 JVM SandBox,我们可以很容易地做到在不重新部署应用的情况下,给指定的某些类的某些方法加上日志功能。当然,动态加日志仅仅是 JVM SandBox 可以应用的一个小小的场景,JVM SandBox 的威力远不在于此。套用官方的话说就是"JVM-SANDBOX还能帮助你做很多很多,取决于你的脑洞有多大了。"
JVM SandBox 简介
AOP
在介绍 JVM SandBox 之前,我们先来回顾一下 AOP 技术。
AOP(面向切面编程,Aspect Oriented Programming)技术已被业界广泛应用,其思想是面向业务处理过程的某个步骤或阶段进行编程,这个步骤或阶段被称为切面,其目的是降低业务逻辑的各部分之间的耦合,常见的 AOP 实现基本原理有两种:代理和行为注入。
1)代理模式
在代理模式下,我们会创建一个代理对象来代理原对象的行为,代理对象拥有原对象行为执行的控制权,在这种模式下,我们基于代理对象在原对象行为执行的前后插入代码来实现 AOP。
代理模式.png
2)行为注入模式
在行为注入模式下,我们不会创建一个新的对象,而是修改原对象,在原对象行为的执行前后注入代码来实现 AOP。
行为注入模式.png
JVM SandBox
JVM SandBox 是阿里开源的一款 JVM 平台非侵入式运行期 AOP 解决方案,本质上是一种 AOP 落地形式。那么可能有同学会问:已有成熟的 Spring AOP 解决方案,阿里巴巴为什么还要“重复造轮子”?这个问题要回到 JVM SandBox 诞生的背景中来回答。在 2016 年中,天猫双十一催动了阿里巴巴内部大量业务系统的改动,恰逢徐冬晨(阿里巴巴测试开发专家)所在的团队调整,测试资源保障严重不足,迫使他们必须考虑更精准、更便捷的老业务测试回归验证方案。开发团队面临的是新接手的老系统,老的业务代码架构难以满足可测性的要求,很多现有测试框架也无法应用到老的业务系统架构中,于是需要新的测试思路和测试框架。
为什么不采用 Spring AOP 方案呢?Spring AOP 方案的痛点在于不是所有业务代码都托管在 Spring 容器中,而且更底层的中间件代码、三方包代码无法纳入到回归测试范围,更糟糕的是测试框架会引入自身所依赖的类库,经常与业务代码的类库产生冲突,因此,JVM SandBox 应运而生。
JVM SandBox 本身是基于插件化的设计思想,允许用于以“模块”的方式基于 JVM SandBox 提供的 AOP 能力开发新的功能。基于 JVM SandBox,我们不需要关心如何在 JVM 层实现 AOP 的技术细节,只需要通过 JVM SandBox 提供的编程结构告诉“沙箱”,我们希望对哪些类哪些方法进行 AOP,在切面点做什么即可,JVM SandBox 模块功能编写起来非常简单。
JVM-SANDBOX(沙箱)实现了一种在不重启、不侵入目标JVM应用的AOP解决方案
沙箱的特性
- 无侵入:目标应用无需重启也无需感知沙箱的存在
- 类隔离:沙箱以及沙箱的模块不会和目标应用的类相互干扰
- 可插拔:沙箱以及沙箱的模块可以随时加载和卸载,不会在目标应用留下痕迹
- 多租户:目标应用可以同时挂载不同租户下的沙箱并独立控制
- 高兼容:支持JDK[6,11]
实时无侵入AOP框架
在常见的AOP框架实现方案中,有静态编织和动态编织两种。
静态编织:静态编织发生在字节码生成时根据一定框架的规则提前将AOP字节码插入到目标类和方法中,实现AOP;
动态编织:动态编织则允许在JVM运行过程中完成指定方法的AOP字节码增强.常见的动态编织方案大多采用重命名原有方法,再新建一个同签名的方法来做代理的工作模式来完成AOP的功能(常见的实现方案如CgLib),但这种方式存在一些应用边界:
- 侵入性:对被代理的目标类需要进行侵入式改造。比如:在Spring中必须是托管于Spring容器中的Bean
- 固化性:目标代理方法在启动之后即固化,无法重新对一个已有方法进行AOP增强
要解决无侵入的特性需要AOP框架具备 在运行时完成目标方法的增强和替换。在JDK的规范中运行期重定义一个类必须准循以下原则
- 不允许新增、修改和删除成员变量
- 不允许新增和删除方法
- 不允许修改方法签名
JVM-SANDBOX属于基于Instrumentation的动态编织类的AOP框架,通过精心构造了字节码增强逻辑,使得沙箱的模块能在不违反JDK约束情况下实现对目标应用方法的无侵入运行时AOP拦截。
核心原理
事件驱动
在沙箱的世界观中,任何一个Java方法的调用都可以分解为BEFORE、RETURN和THROWS三个环节,由此在三个环节上引申出对应环节的事件探测和流程控制机制。
1 2 3 4 5 6 7 8 9 10 11 12 13 | // BEFORE try { /* * do something... */ // RETURN return; } catch (Throwable cause) { // THROWS } |
基于BEFORE、RETURN和THROWS三个环节事件分离,沙箱的模块可以完成很多类AOP的操作。
- 可以感知和改变方法调用的入参
- 可以感知和改变方法调用返回值和抛出的异常
- 可以改变方法执行的流程
- 在方法体执行之前直接返回自定义结果对象,原有方法代码将不会被执行
- 在方法体返回之前重新构造新的结果对象,甚至可以改变为抛出异常
- 在方法体抛出异常之后重新抛出新的异常,甚至可以改变为正常返回
类隔离策略
沙箱通过自定义的SandboxClassLoader破坏了双亲委派的约定,实现了和目标应用的类隔离。所以不用担心加载沙箱会引起应用的类污染、冲突。各模块之间类通过ModuleJarClassLoader实现了各自的独立,达到模块之间、模块和沙箱之间、模块和应用之间互不干扰
jvm-sandbox-classloader.png
类增强策略
沙箱通过在BootstrapClassLoader中埋藏的Spy类完成目标类和沙箱内核的通讯
jvm-sandbox-enhance-class.jpg
快速安装
下载并安装,开箱即用
1 2 3 4 5 | # 下载最新版本的JVM-SANDBOX wget http://ompc.oss-cn-hangzhou.aliyuncs.com/jvm-sandbox/release/sandbox-stable-bin.zip # 解压 unzip sandbox-stable-bin.zip |
挂载目标应用
1 2 3 4 5 | # 进入沙箱执行脚本 cd sandbox/bin # 目标JVM进程21815 ./sandbox.sh -p 21815 |
挂载成功后会提示
1 2 3 4 5 6 7 8 9 10 11 12 | # ./sandbox.sh -p 21815 NAMESPACE : default VERSION : 1.2.1 MODE : ATTACH SERVER_ADDR : 0.0.0.0 SERVER_PORT : 42641 UNSAFE_SUPPORT : ENABLE SANDBOX_HOME : /root/jvm_sandbox_demo/sandbox/bin/.. SYSTEM_MODULE_LIB : /root/jvm_sandbox_demo/sandbox/bin/../module USER_MODULE_LIB : /root/jvm_sandbox_demo/sandbox/sandbox-module;~/.sandbox-module; SYSTEM_PROVIDER_LIB : /root/jvm_sandbox_demo/sandbox/bin/../provider EVENT_POOL_SUPPORT : DISABLE |
卸载沙箱
1 2 | ./sandbox.sh -p 21815 -S jvm-sandbox[default] shutdown finished. |
还有一些指令,建议查阅官方提供的文档或者查阅 /sandbox/bin/sandbox.sh内的指令内容
实例
线上发生文件上传故障,异常错误提示文件存储路径异常,目的是通过jvm-sandbox在不破坏不重启的前提下获知参数中的文件存储路径是否与配置文件一致
先上代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 | package com.cn; import com.alibaba.jvm.sandbox.api.Information; import com.alibaba.jvm.sandbox.api.Module; import com.alibaba.jvm.sandbox.api.annotation.Command; import com.alibaba.jvm.sandbox.api.listener.ext.Advice; import com.alibaba.jvm.sandbox.api.listener.ext.AdviceListener; import com.alibaba.jvm.sandbox.api.listener.ext.EventWatchBuilder; import com.alibaba.jvm.sandbox.api.resource.ModuleEventWatcher; import org.kohsuke.MetaInfServices; import javax.annotation.Resource; import java.lang.reflect.Field; import java.util.logging.Logger; @MetaInfServices(Module.class) @Information(id = "my-sandbox-module")// 模块名,在指定挂载进程后通过-d指定模块,配合@Command注解来唯一确定方法 public class MySandBoxModule implements Module { //日志输出,默认采用logback,这里的日志输出到切入的服务日志中 private Logger LOG = Logger.getLogger(MySandBoxModule.class.getName()); @Resource private ModuleEventWatcher moduleEventWatcher; @Command("addLog")// 模块命令名 public void addLog() { new EventWatchBuilder(moduleEventWatcher) .onClass("cn.com.service.impl.PackageServiceImpl")// 想要对 PackageServiceImpl 这个类进行切面 .onBehavior("bathSave")// 想要对上面类的 bathSave 方法进行切面 .onWatch(new AdviceListener() { //对方法执行之前执行 @Override protected void before(Advice advice) throws Throwable { //获取方法的所有参数 Object[] parameterArray = advice.getParameterArray(); if (parameterArray != null) { for (Object po : parameterArray) { //方法参数可能为空,规避报错 if (po != null) { /** * 目标方法 * public List<UploadResult> bathSave(List<Package> pkgs, MultipartFile[] files, String hdfsDir) {} * * 这里只关心MultipartFile参数,其余参数过滤 * * po.getClass() 输出内容为 class [Lorg.springframework.web.multipart.MultipartFile; * po.getClass().getName() 输出内容为[Lorg.springframework.web.multipart.MultipartFile * * 最开始的设想是直接cast转型,但是由于类加载器不同,是不行的,所以最好反射来操作 */ if (po.getClass().getName().contains("MultipartFile")) { //目标方法参数是一个MultipartFile[] Object[] o2 = (Object[]) po; for (Object o3 : o2) { Field ff = o3.getClass().getDeclaredField("part"); ff.setAccessible(true); Object part = ff.get(o3); Field ff1 = part.getClass().getDeclaredField("location"); ff1.setAccessible(true); Object file = ff1.get(part); Field ff11 = file.getClass().getDeclaredField("path"); ff11.setAccessible(true); //最后的结果输出:打印的路径-------/app/upload,符合预期 LOG.info("打印的路径-------" + ff11.get(file)); } } } } } } }); } } |
部署
打包
1 | mvn clean package |
上传或者复制到目录
1 2 3 | /sandbox/sandbox-module # 效果如下 /sandbox/sandbox-module/my-sandbox-module-1.0-SNAPSHOT-jar-with-dependencies.jar |
挂载到对应进程
1 2 | # my-sandbox-module就是类上的模块名,addLog方法上的模块命令名 ./sandbox.sh -p 21815 -d 'my-sandbox-module/addLog' |
挂载之后可以通过指令查看是否挂载成功
1 2 3 4 5 6 7 | # ./sandbox.sh -p 4432 -l my-sandbox-module ACTIVE LOADED 1 1 UNKNOW_VERSION UNKNOW_AUTHOR sandbox-info ACTIVE LOADED 0 0 0.0.4 luanjia@taobao.com broken-clock-tinker ACTIVE LOADED 0 0 UNKNOW_VERSION UNKNOW_AUTHOR sandbox-module-mgr ACTIVE LOADED 0 0 0.0.2 luanjia@taobao.com sandbox-control ACTIVE LOADED 0 0 0.0.3 luanjia@taobao.com total=5 |
这时执行对应的api在挂载的服务日志中就能查看通过沙箱添加的日志了。
sandbox的日志路径可以在/sandbox/cfg/sandbox-logback.xml文件中进行配置(sandbox日志主要输出挂载信息,以及沙箱程序的异常错误信息)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | <?xml version="1.0" encoding="UTF-8" ?> <configuration scan="true" scanPeriod="10000"> <appender name="SANDBOX-FILE-APPENDER" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>${user.home}/logs/sandbox/sandbox.log</file> <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> <FileNamePattern>${user.home}/logs/sandbox/sandbox.log.%d{yyyy-MM-dd}</FileNamePattern> <MaxHistory>30</MaxHistory> </rollingPolicy> <encoder> <pattern>%d{yyyy-MM-dd HH:mm:ss} %SANDBOX_NAMESPACE %-5level %msg%n</pattern> <charset>UTF-8</charset> </encoder> </appender> <root level="info"> <appender-ref ref="SANDBOX-FILE-APPENDER"/> </root> </configuration> |
以上就是对Alibaba的jvm-sandbox的初体验,其他更丰富的场景还有待进一步体验
参考文档
1.https://www.infoq.cn/article/TSY4lGjvSfwEuXEBW*Gp
2.https://github.com/alibaba/jvm-sandbox