一、前言
在开始之前,我们先来模拟一下以下的场景:
- 小李:“小明,你的接口没有返回数据,麻烦帮忙看一下?”
- 小明:“我这边的数据也是从别人的服务器中拿到的,但是我不确定是因为逻辑处理有问题导致没有结果,还是因为我依赖的服务有问题而没有返回结果,我需要确认一下。”
- 小明:“哎呀,线上没有日志,我需要加个日志上个线。”
- 30 分钟之后……
- 小明:“不好意思,日志加错地方了……稍等……”
接来下隆重登场的就是本文的主角 JVM SandBox 了。基于 JVM SandBox,我们可以很容易地做到在不重新部署应用的情况下,给指定的某些类的某些方法加上日志功能。当然,动态加日志仅仅是 JVM SandBox 可以应用的一个小小的场景,JVM SandBox 的威力远不在于此。套用官方的话说就是"JVM-SANDBOX还能帮助你做很多很多,取决于你的脑洞有多大了。"
二、JVM SandBox 简介
2.1、AOP
在介绍 JVM SandBox 之前,我们先来回顾一下 AOP 技术。
AOP(面向切面编程,Aspect Oriented Programming)技术已被业界广泛应用,其思想是面向业务处理过程的某个步骤或阶段进行编程,这个步骤或阶段被称为切面,其目的是降低业务逻辑的各部分之间的耦合,常见的 AOP 实现基本原理有两种:代理和行为注入。
1)代理模式
在代理模式下,我们会创建一个代理对象来代理原对象的行为,代理对象拥有原对象行为执行的控制权,在这种模式下,我们基于代理对象在原对象行为执行的前后插入代码来实现 AOP。
2)行为注入模式
在行为注入模式下,我们不会创建一个新的对象,而是修改原对象,在原对象行为的执行前后注入代码来实现 AOP。
2.2、JVM SandBox
JVM SandBox 是阿里开源的一款 JVM 平台非侵入式运行期 AOP 解决方案,本质上是一种 AOP 落地形式。
为什么不采用 Spring AOP 方案呢?Spring AOP 方案的痛点在于不是所有业务代码都托管在 Spring 容器中,而且更底层的中间件代码、三方包代码无法纳入到回归测试范围,更糟糕的是测试框架会引入自身所依赖的类库,经常与业务代码的类库产生冲突,因此,JVM SandBox 应运而生。
JVM SandBox 本身是基于插件化的设计思想,允许用于以“模块”的方式基于 JVM SandBox 提供的 AOP 能力开发新的功能。基于 JVM SandBox,我们不需要关心如何在 JVM 层实现 AOP 的技术细节,只需要通过 JVM SandBox 提供的编程结构告诉“沙箱”,我们希望对哪些类哪些方法进行 AOP,在切面点做什么即可,JVM SandBox 模块功能编写起来非常简单。下面是一个示例模块代码:
@MetaInfServices(Module.class)
@Information(id = "my-sandbox-module")// 模块名
public class MySandBoxModule implements Module {
private Logger LOG = Logger.getLogger(MySandBoxModule.class.getName());
@Resource
private ModuleEventWatcher moduleEventWatcher;
@Command("addLog")// 模块命令名
public void addLog() {
new EventWatchBuilder(moduleEventWatcher)
.onClass("com.float.lu.DealGroupService")// 想要对 DealGroupService 这个类进行切面
.onBehavior("loadDealGroup")// 想要对上面类的 loadDealGroup 方法进行切面
.onWatch(new AdviceListener() {
@Override
protected void before(Advice advice) throws Throwable {
LOG.info(" 方法名: " + advice.getBehavior().getName());// 在方法执行前打印方法的名字
}
});
}
}
JVM-SANDBOX(沙箱)实现了一种在不重启、不侵入目标JVM应用的AOP解决方案
沙箱的特性:
- 无侵入:目标应用无需重启也无需感知沙箱的存在
- 类隔离:沙箱以及沙箱的模块不会和目标应用的类相互干扰
- 可插拔:沙箱以及沙箱的模块可以随时加载和卸载,不会在目标应用留下痕迹
- 多租户:目标应用可以同时挂载不同租户下的沙箱并独立控制
- 高兼容:支持JDK[6,11]
实时无侵入AOP框架
在常见的AOP框架实现方案中,有静态编织和动态编织两种。
静态编织:静态编织发生在字节码生成时根据一定框架的规则提前将AOP字节码插入到目标类和方法中,实现AOP;
动态编织:动态编织则允许在JVM运行过程中完成指定方法的AOP字节码增强.常见的动态编织方案大多采用重命名原有方法,再新建一个同签名的方法来做代理的工作模式来完成AOP的功能(常见的实现方案如CgLib),但这种方式存在一些应用边界:
- 侵入性:对被代理的目标类需要进行侵入式改造。比如:在Spring中必须是托管于Spring容器中的Bean
- 固化性:目标代理方法在启动之后即固化,无法重新对一个已有方法进行AOP增强
要解决无侵入的特性需要AOP框架具备 在运行时完成目标方法的增强和替换。在JDK的规范中运行期重定义一个类必须准循以下原则
- 不允许新增、修改和删除成员变量
- 不允许新增和删除方法
- 不允许修改方法签名
我们知道Java对象的行为(函数,方法)是存储在方法区的,从下图可以看到,方法区的数据是由类加载器把编译好的class文件加载到jvm方法区的。所以我们可以得出简单思路是:
- 在对应类Java代码中新增日志代码,并重新编译得到新的class文件。
- 让jvm重新加载这个类的class文件到方法区
第一步倒是挺好实现,但是第二步,如何让jvm加载一个已经加载过的类? 答案是“java.lang.instrument.Instrumentation”
instrument 是 JVM 提供的一个可以修改已加载类的类库,专门为 Java 语言编 写的插桩服务提供支持。Instrumentation中有两个方法都可以实现重新替换已经存在的class文件,它们是:redefineClasses 和 retransformClasses。区别是redefineClasses 是自己提供字节码文件替换 掉已存在的 class 文件,retransformClasses 是在已存在的字节码文件上修改后再替换之。它需要依赖 JVMTI的 Attach API 机制实现。JVM TI(JVM TOOL INTERFACE,JVM 工具接口)是 JVM 提供的一套对 JVM 进行操作的工具接口。通过JVMTI,可以实现对 JVM 的多种操作,它通过接口注册 各种事件勾子,在 JVM 事件触发时,同时触发预定义的勾子,以实现对各个 JVM 事件的响应,事件包括类文件加载、异常产生与捕获、线程启动和结束、进入和退 出临界区、成员变量修改、GC开始和结束、方法调用进入和退出、临界区竞争与等 待、VM 启动与退出等等。
JVM SandBox 容器的启动依赖 Java Agent,Java Agent(Java 代理)是 JDK 1.5 之后引入的技术。Agent 就是 JVMTI 的一种实现,Agent 有两种启动方式,一是随 Java 进 程启动而启动;二是运行时载入,通过 attach API,将模块(jar 包)动态地 Attach 到指定进程 id 的 Java 进程内。开发一个 Java Agent 有两种方式,一种是实现一个 premain 方法,但是这种方式实现的 Java Agent 只能在 JVM 启动的时候被加载;另一种是实现一个 agentmain 方法,这种方式实现的 Java Agent 可以在 JVM 启动之后被加载。JVM SandBox Agent 对于这两种方式都有实现,用户可以自行选择使用,因为在 JVM 层这两种方式底层的实现原理大同小异,下面先通过两行代码,来看看基于 agentmain 方式实现的 Java Agent 是如何被加载的:
VirtualMachine vmObj = VirtualMachine.attach(targetJvmPid);//targetJvmPid 为目标 JVM 的进程 ID
vmObj.loadAgent(agentJarPath, cfg); // agentJarPath 为 agent jar 包的路径,cfg 为传递给 agent 的参数
JVM SandBox 可插拔至少有两层含义:一层是 JVM 沙箱本身是可以被插拔的,可被动态地挂载到指定 JVM 进程上和可以被动态地卸载;另一层是 JVM 沙箱内部的模块是可以被插拔的,在沙箱启动期间,被加载的模块可以被动态地启用和卸载。 一个典型的沙箱使用流程如下:
$./sandbox.sh -p 33342 #将沙箱挂载到进程号为 33342 的 JVM 进程上
$./sandbox.sh -p 33342 -d 'my-sandbox-module/addLog' #运行指定模块, 模块功能生效
$./sandbox.sh -p 33342 -S #卸载沙箱
JVM 沙箱可以被动态地挂载到某个正在运行的目标 JVM 进程之上(前提是目标 JVM 没有禁止 attach 功能),沙箱工作完之后还可以被动态地从目标 JVM 进程卸载掉,沙箱被卸载之后,沙箱对对目标 JVM 进程产生的影响会随即消失(这是沙箱的一个重要特性),沙箱工作示意图如下:
客户端通过 Attach 将沙箱挂载到目标 JVM 进程上,沙箱的启动实际上是依赖 Java Agent,上文已经介绍过,启动之后沙箱会一直维护着 Instrument 对象引用,在沙箱中 Instrument 对象是一个非常重要的角色,它是沙箱访问和操作 JVM 的唯一通道,后续修改字节码和重定义类都要经过 Instrument。另外,沙箱启动之后同时会启动一个内部的 Jetty 服务器,这个服务器用于外部进程和沙箱进行通信,上面看到的./sandbox.sh -p 33342 -d ‘my-sandbox-module/addLog’ 这行代码,实际上就是通过 HTTP 协议来告诉沙箱执行 my-sanbox-module 这个模块的 addLog 这个功能的。
sandbox的代码主要分为几个过程:启动、模块加载、类增强实现
启动
上面我们提到,使用Instrumentation进行字节码增强有2种模式(attach模式和java-agent模式),sandbox-jvm的启动有这2种方式,入口都在AgentLauncher中,分别对应着agentmain和premain,它们都调用了install方法,以agent main为例
public static void agentmain(String featureString, Instrumentation inst) {
LAUNCH_MODE = LAUNCH_MODE_ATTACH;
final Map<String, String> featureMap = toFeatureMap(featureString);
writeAttachResult(
getNamespace(featureMap),
getToken(featureMap),
install(featureMap, inst)
);
}
install函数的作用是在目标jvm上安装sandbox,创建独立的classloader,通过classloader加载JettyCoreServer.class,并且反射生成实例,建立httpserver监听请求
// CoreServer类定义
final Class<?> classOfProxyServer = sandboxClassLoader.loadClass(CLASS_OF_PROXY_CORE_SERVER);
// 获取CoreServer单例
final Object objectOfProxyServer = classOfProxyServer
.getMethod("getInstance")
.invoke(null);
// CoreServer.isBind()
final boolean isBind = (Boolean) classOfProxyServer.getMethod("isBind").invoke(objectOfProxyServer);
// 如果未绑定,则需要绑定一个地址
if (!isBind) {
try {
classOfProxyServer
.getMethod("bind", classOfConfigure, Instrumentation.class)
.invoke(objectOfProxyServer, objectOfCoreConfigure, inst);
} catch (Throwable t) {
classOfProxyServer.getMethod("destroy").invoke(objectOfProxyServer);
throw t;
}
}
启动jetty server,监听http请求,并且调用coreModuleManager.reset进行模块的加载,在下面一节介绍。
public synchronized void bind(final CoreConfigure cfg, final Instrumentation inst) throws IOException {
this.cfg = cfg;
try {
initializer.initProcess(()->{
logger.info("initializing server. cfg={}", cfg);
jvmSandbox = new JvmSandbox(cfg, inst);
initHttpServer();
initJettyContextHandler();
httpServer.start();
}
});
// 初始化加载所有的模块
try {
jvmSandbox.getCoreModuleManager().reset();
} catch (Throwable cause) {
logger.warn("reset occur error when initializing.", cause);
}
final InetSocketAddress local = getLocal();
logger.info("initialized server. actual bind to {}:{}",
local.getHostName(),
local.getPort()
);
} catch (Throwable cause) {
// 对外抛出到目标应用中
throw new IOException("server bind failed.", cause);
}
}
模块加载
模块是什么?sandbox将不同的业务进行模块划分,不同的模块使用不同的classloader进行加载,例如如果我们想实现流量录制,我们可以自定义一个模块通过字节码增强实现流量入口的监听并进行录制,这就是我们后面会介绍的repeater。 先来看下CoreModuleManager.reset() 的工作: 加载过程是先卸载再加载,首先根据cfg(配置存储对象)中的的module包路径配置得到moduleLibDirArray(需要加载的模块路径:系统模块+用户模块), 每个模块独立加载。
for (final File moduleLibDir : moduleLibDirArray) {
// 用户模块加载目录,加载用户模块目录下的所有模块
// 对模块访问权限进行校验
if (moduleLibDir.exists() && moduleLibDir.canRead()) {
new ModuleLibLoader(moduleLibDir, cfg.getLaunchMode())
.load(new InnerModuleJarLoadCallback(), new InnerModuleLoadCallback());
}
}
通过阅读sandbox.sh,主要功能分为两个:
- attach目标JVM
- 发送HTTP请求,控制jvm-sandbox的运行 由于我需要在远程控制JVM-Sandbox,而attach必须在目标主机上运行,所以我主要关注如何发送HTTP请求。
HTTP请求的格式如下:
http://${host}:${port}/sandbox/${namespace}/module/http/${module-name}/${command-name}?V1=K1&...
- ${host} 为JVM-Sandbox运行的节点
- ${port} 为JVM-Sadnbox监听的端口
- ${namespace} 默认为default,用于区分不同的JVM-Sandbox
- ${module-name} 是实现Module时,@Information注解的值
- ${command-name} 是定义Module方法是,@Command注解的值
利用上述URL,我们就可以远程操控JVM-Sandbox了。
三、核心原理
3.1、事件驱动
在沙箱的世界观中,任何一个Java方法的调用都可以分解为BEFORE、RETURN和THROWS三个环节,由此在三个环节上引申出对应环节的事件探测和流程控制机制。
// BEFORE
try {
/*
* do something...
*/
// RETURN
return;
} catch (Throwable cause) {
// THROWS
}
基于BEFORE、RETURN和THROWS三个环节事件分离,沙箱的模块可以完成很多类AOP的操作。
- 可以感知和改变方法调用的入参
- 可以感知和改变方法调用返回值和抛出的异常
- 可以改变方法执行的流程
- 在方法体执行之前直接返回自定义结果对象,原有方法代码将不会被执行
- 在方法体返回之前重新构造新的结果对象,甚至可以改变为抛出异常
- 在方法体抛出异常之后重新抛出新的异常,甚至可以改变为正常返回
3.2、类隔离策略
沙箱通过自定义的SandboxClassLoader破坏了双亲委派的约定,实现了和目标应用的类隔离。所以不用担心加载沙箱会引起应用的类污染、冲突。各模块之间类通过ModuleJarClassLoader实现了各自的独立,达到模块之间、模块和沙箱之间、模块和应用之间互不干扰
3.3、类增强策略
沙箱通过在BootstrapClassLoader中埋藏的Spy类完成目标类和沙箱内核的通讯
四、快速安装
4.1、下载并安装,开箱即用
# 下载最新版本的JVM-SANDBOX
wget http://ompc.oss-cn-hangzhou.aliyuncs.com/jvm-sandbox/release/sandbox-stable-bin.zip
# 解压
unzip sandbox-stable-bin.zip
4.2、挂载目标应用
# 进入沙箱执行脚本
cd sandbox/bin
# 目标JVM进程21815
./sandbox.sh -p 21815
4.3、挂载成功后会提示
# ./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
4.4、卸载沙箱
./sandbox.sh -p 21815 -S
jvm-sandbox[default] shutdown finished.
还有一些指令,建议查阅官方提供的文档或者查阅 /sandbox/bin/sandbox.sh内的指令内容
五、实例
线上发生文件上传故障,异常错误提示文件存储路径异常,目的是通过jvm-sandbox在不破坏不重启的前提下获知参数中的文件存储路径是否与配置文件一致 先上代码
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));
}
}
}
}
}
}
});
}
}
六、部署
6.1、打包
mvn clean package
6.2、上传或者复制到目录
/sandbox/sandbox-module
# 效果如下
/sandbox/sandbox-module/my-sandbox-module-1.0-SNAPSHOT-jar-with-dependencies.jar
6.3、挂载到对应进程
# my-sandbox-module就是类上的模块名,addLog方法上的模块命令名
./sandbox.sh -p 21815 -d 'my-sandbox-module/addLog'
6.4、挂载之后可以通过指令查看是否挂载成功
# ./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日志主要输出挂载信息,以及沙箱程序的异常错误信息)
<?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的初体验,其他更丰富的场景还有待进一步体验
版权声明:本文为CSDN博主「山间浓雾有路灯,风雨漂泊有归舟」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。 原文链接:https://blog.csdn.net/m0_38115840/article/details/99453672
注意:本文归作者所有,未经作者允许,不得转载