JvmSandbox原理分析03-代理守护&沙箱通信机制

1,530 阅读10分钟

上一篇我们聊到了sandbox-agent会通过自定义类加载器SandboxClassloader加载并反射JettyCoreServer实例,然后反射调用该实例的JettyCoreServer#bind方发启动JettyServer。目前的启动流程可以由下图展示:

image-1650382056612.png

这里再多嘴一句,注意反射调用JettyCoreServer#bind方法时,传递了Instrumentation这个参数,这个可是实现Sandbox增强能力的核心。

这一篇我们主要讲解bind之后的流程,包括JettyServer的启动、沙箱的启动以及模块的加载。

老样子,这里先给出bind方法的全貌,并且给出了简单注释。因为每行被注释的代码都包含了其他逻辑,因此会在下面分开讲解,下面会主要讲解4个部分的内容:

  • 初始化沙箱
  • 初始化 Jetty 服务器
  • 初始化 Jetty context handler
  • 初始化加载所有的沙箱模块
 // JettyCoreServer.java
 @Override
 public synchronized void bind(final CoreConfigure cfg, final Instrumentation inst) throws IOException {
     this.cfg = cfg;
     try {
         initializer.initProcess(new Initializer.Processor() {
             @Override
             public void process() throws Throwable {
                 LogbackUtils.init(cfg.getNamespace(), cfg.getCfgLibPath() + File.separator + "sandbox-logback.xml");
                 logger.info("initializing server. cfg={}", cfg);
                 // 初始化沙箱
                 jvmSandbox = new JvmSandbox(cfg, inst);
                 // 初始化 Jetty 服务器
                 initHttpServer();
                 // 初始化 Jetty context handler
                 initJettyContextHandler();
                 // 启动 Jetty 服务器
                 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) {
         // 这里会抛出到目标应用层,所以在这里留下错误信息
         logger.warn("initialize server failed.", cause);
         // 对外抛出到目标应用中
         throw new IOException("server bind failed.", cause);
     }
     logger.info("{} bind success.", this);
 }

1、初始化沙箱

初始化沙箱的代码就是调用了JvmSandbox的构造方法,并且将Instrumentation传入了沙箱。注意,这里又透传了Instrumentation对象,足以见得该对象的重要性。

 // JettyCoreServer.java
 jvmSandbox = new JvmSandbox(cfg, inst);

构造方法如下所示,它首先通过创建代理的方式初始化了沙箱管理器,从其中可以看出默认的实现为DefaultCoreModuleManager,它负责持有并管理各个模块,后期模块的各种操作都委托给该管理器来完成。

 // JvmSandbox.java
 public JvmSandbox(final CoreConfigure cfg, final Instrumentation inst) {
     EventListenerHandler.getSingleton();
     this.cfg = cfg;
     this.coreModuleManager = SandboxProtector.instance.protectProxy(CoreModuleManager.class, new DefaultCoreModuleManager(
             cfg,
             inst,
             new DefaultCoreLoadedClassDataSource(inst, cfg.isEnableUnsafe()),
             new DefaultProviderManager(cfg)
     ));
     init();
 }

1.1、守护代理

这里有一个比较有意思的创建保护代理的方式,SandboxProtector.instance.protectProxy,官方对其称呼为 【守护接口定义的所有方法】它是在代理方法的前后计算Threadlocal中的引用计数,判断进入方法前和退出方法后引用计数是否相同。如果相同,说明是同一个线程的正常进入和退出,则代理正常返回;如果不同,说明是同一个线程多次进入了代理方法(虽然我也不太清楚为什么会存在这种情况)。针对这种情况,官方的策略是仅仅打印了异常日志,并未做其它处理。 这里我也咨询了我的大佬同事,他猜想这里可能是为了保护某种极端场景下的线程问题,记个日志,便于后期问题定位。

 // SandboxProtector.java
 private final ThreadLocal<AtomicInteger> isInProtectingThreadLocal = new ThreadLocal<AtomicInteger>() {
     @Override
     protected AtomicInteger initialValue() {
         return new AtomicInteger(0);
     }
 };
 ​
 /**
  * 守护接口定义的所有方法
  *
  * @param protectTargetInterface 保护目标接口类型
  * @param protectTarget          保护目标接口实现
  * @param <T>                    接口类型
  * @return 被保护的目标接口实现
  */
 @SuppressWarnings("unchecked")
 public <T> T protectProxy(final Class<T> protectTargetInterface, final T protectTarget) {
     return (T) Proxy.newProxyInstance(getClass().getClassLoader(), new Class<?>[]{protectTargetInterface}, new InvocationHandler() {
         @Override
         public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
             final int enterReferenceCount = enterProtecting();
             try {
                 return method.invoke(protectTarget, args);
             } finally {
                 final int exitReferenceCount = exitProtecting();
                 // 这里判断两次获得的引用计数是否相同
                 // 由于Java规定return和finally的执行顺序为:先执行return后的表达式,再执行finally块,最后退出。
                 // 这就说明无论引用计数是否相同,代理方法依旧会先执行,作者应该只是在这里判断下,做个异常日志的记录,并未针对这种异常现象做其它处理
                 if (enterReferenceCount != exitReferenceCount) {
                     logger.warn("thread:{} exit protecting with error!, expect:{} actual:{}", Thread.currentThread(), enterReferenceCount, exitReferenceCount);
                 }
             }
         }
     });
 }
 ​
 public int enterProtecting() {
     // 进入守护区前(也就是执行代理方法前)获得ThreadLocal中的引用计数 cnt,然后 cnt++
     final int referenceCount = isInProtectingThreadLocal.get().getAndIncrement();
     // ... 中间是一些日志打印
     return referenceCount;
 }
 ​
 public int exitProtecting() {
     // 退出守护区后(也就是执行完代理方法之后)先让ThreadLocal中的引用计数cnt--,然后再get
     final int referenceCount = isInProtectingThreadLocal.get().decrementAndGet();
     // ... 中间是一些日志打印
     return referenceCount;
 }

这里用一张图也许能够更好的解释下(这里解释的或许有些问题,有了解的小伙帮可以在评论区告知下哈,感激 ψ(`∇´)ψ ):

image-2.png

1.2、初始化模块管理器

由上述的代码可知,我们在创建守护代理时,代理的是DefaultCoreModuleManager这个对象,创建这个对象是直接调用构造方法来实例化:

 new DefaultCoreModuleManager(cfg, inst, new DefaultCoreLoadedClassDataSource(inst, cfg.isEnableUnsafe()), new DefaultProviderManager(cfg))

这个构造方法里面并没有什么好说的,就是一些赋值操作和目录合并,我们应该关注在调用这个构造方法时创建的两个对象:DefaultCoreLoadedClassDataSource和DefaultProviderManager。

  • DefaultCoreLoadedClassDataSource

DefaultCoreLoadedClassDataSource抽象表示所有已加载类的数据池,其实它可以看成是对Instrumentation的封装,构造该对象时传递了inst这个Instrumentation这个对象,DefaultCoreLoadedClassDataSource是通过Instrumentation#getAllLoadedClasses方法获取所有已加载的类集合的,整个类逻辑比较简单,大家可以自行查看。

  • DefaultProviderManager

DefaultProviderManager抽象表示默认服务提供管理器,这里我就总结性的说下初始化DefaultProviderManager做了什么,具体细节大家可以参考注释和源码。DefaultProviderManager通过JDK SPI的方式加载了ModuleJarLoadingChain和ModuleLoadingChain的实例,并对这些实例里面所有被标记了@Resource注解的字段注入CoreConfigure属性。这个过程也是想要将cfg注入到ModuleJarLoadingChain和ModuleLoadingChain实例当中。

 // DefaultProviderManager.java
 public DefaultProviderManager(final CoreConfigure cfg) {
     this.cfg = cfg;
     try {
         // 透传cfg
         init(cfg);
     } // ... 异常处理
 }
 ​
 private void init(final CoreConfigure cfg) {
     final File providerLibDir = new File(cfg.getProviderLibPath());
     // ... 一些校验
     for (final File providerJarFile : FileUtils.listFiles(providerLibDir, new String[]{"jar"}, false)) {
         try {
             final ProviderClassLoader providerClassLoader = new ProviderClassLoader(providerJarFile, getClass().getClassLoader());
             // SPI加载
             inject(moduleJarLoadingChains, ModuleJarLoadingChain.class, providerClassLoader, providerJarFile);
             inject(moduleLoadingChains, ModuleLoadingChain.class, providerClassLoader, providerJarFile);
         } // ... 日志和异常处理
     }
 }
 ​
 private <T> void inject(final Collection<T> collection, final Class<T> clazz, final ClassLoader providerClassLoader,
                         final File providerJarFile) throws IllegalAccessException {
     // JDK SPI 加载 ModuleJarLoadingChain 或者 ModuleLoadingChain
     final ServiceLoader<T> serviceLoader = ServiceLoader.load(clazz, providerClassLoader);
     for (final T provider : serviceLoader) {
         // 注入 cfg 属性
         injectResource(provider);
         collection.add(provider);
         // ... 日志处理
     }
 }
 ​
 private void injectResource(final Object provider) throws IllegalAccessException {
     // 获取所有被标记了 @Resource 注解的 field
     final Field[] resourceFieldArray = FieldUtils.getFieldsWithAnnotation(provider.getClass(), Resource.class);
     // ... 校验
     for (final Field resourceField : resourceFieldArray) {
         // 遍历field,将cfg写入到field当中
         final Class<?> fieldType = resourceField.getType();
         if (ConfigInfo.class.isAssignableFrom(fieldType)) {
             final ConfigInfo configInfo = new DefaultConfigInfo(cfg);
             FieldUtils.writeField(resourceField, provider, configInfo, true);
         }
     }
 }

1.3、Spy初始化

初始化沙箱的最后一步就是init方法了,我们来看看init方法做了什么:

  • 提前加载了两个类:SandboxClassUtils、ClassStructureImplByAsm,后者是通过ASM增强业务方代码的重要类
  • 通过SpyUtils将EventListenerHandler注入到Spy类中。前面我们说过了,Spy类最终会通过ASM框架写入到业务方代码中,而此时被注入的EventListenerHandler也是沙箱与业务代码通信的重要媒介,后面我们会重点介绍这个handler。
 // JvmSandbox.java
 private final static List<String> earlyLoadSandboxClassNameList = new ArrayList<String>();
 static {
     earlyLoadSandboxClassNameList.add("com.alibaba.jvm.sandbox.core.util.SandboxClassUtils");
     earlyLoadSandboxClassNameList.add("com.alibaba.jvm.sandbox.core.util.matcher.structure.ClassStructureImplByAsm");
 }
 private void init() {
     doEarlyLoadSandboxClass();
     SpyUtils.init(cfg.getNamespace());
 }
 private void doEarlyLoadSandboxClass() {
     // 提前加载一些类
     for(String className : earlyLoadSandboxClassNameList){
         try {
             Class.forName(className);
         } catch (ClassNotFoundException e) {
             //加载sandbox内部的类,不可能加载不到
         }
     }
 }
 ​
 // SpyUtils.java
 public synchronized static void init(final String namespace) {
     if (!Spy.isInit(namespace)) {
         // 注入 EventListenerHandler
         Spy.init(namespace, EventListenerHandler.getSingleton());
     }
 }
 ​
 // Spy.java
 public static void init(final String namespace, final SpyHandler spyHandler) {
     // 缓存 EventListenerHandler
     namespaceSpyHandlerMap.putIfAbsent(namespace, spyHandler);
 }

1.4、小结

沙箱初始完成后,整体流程便可以由下图展示了:

image-20220420235302654.png

2、初始化Jetty服务器

初始化Jetty服务器的代码非常容易,主要逻辑是调用Jetty包中的一些接口,初始化Server和Server使用的ThreadPool,大家可以自行查看源码。

3、初始化JettyContextHandler

这里我们稍微花一点篇幅来聊一下,因为这个ContextHandler关系到我们如何通过脚本与沙箱进行http通信

 // JettyCoreServer.java
 initJettyContextHandler();
 ​
 private void initJettyContextHandler() {
     final String namespace = cfg.getNamespace();
     final ServletContextHandler context = new ServletContextHandler(NO_SESSIONS);
     // 这里namespace一般为default(如果没有设置的话),因此 contextPath = /sandbox/default
     final String contextPath = "/sandbox/" + namespace;
     context.setContextPath(contextPath);
     context.setClassLoader(getClass().getClassLoader());
 ​
     // WebSocketAcceptorServlet 被废弃了,不再维护了,因此这里就不考虑了
     final String wsPathSpec = "/module/websocket/*";
     logger.info("initializing ws-http-handler. path={}", contextPath + wsPathSpec);
     context.addServlet(new ServletHolder(new WebSocketAcceptorServlet(jvmSandbox.getCoreModuleManager())), wsPathSpec);
 ​
     // 这个 Servlet 是 http 通信的关键
     final String pathSpec = "/module/http/*";
     logger.info("initializing http-handler. path={}", contextPath + pathSpec);
     // 这里设置的是 Servlet 绑定的 path,再加上前面的 contextPath,我们可以知道 ModuleHttpServlet 最终会处理 /sandbox/default/module/http/* 这个路径
     context.addServlet(new ServletHolder(new ModuleHttpServlet(cfg, jvmSandbox.getCoreModuleManager())), pathSpec);
     httpServer.setHandler(context);
 }

注释描述的非常清楚了,初始化这个handler后,ModuleHttpServlet将会绑定/sandbox/default/module/http/*这个路径。

ModuleHttpServlet中核心处理方法为doMethod,该方法的核心逻辑如下:

  • 解析请求url,获取moduleId,进而从缓存中拿到id对应的CoreModule。
  • 判断请求路径的请求方法是否在对应的CoreModule中被@Command@Http注解所标记。
  • 如果方法被标记了,则反射调用该方法,实现外界与沙箱(模块)的通信。

doMethod关联的代码太多了,但具体逻辑就是上面说的,这里就不多余贴代码了,避免被怀疑凑字数。

3.1、shell脚本的http请求组装

通过上面描述的,我们知道了沙箱是如何通过Jetty服务器处理http请求的(即服务器端的处理),现在我们来看看sandbox.sh是如何组装http请求的(客户端发起请求的过程)。

前面的脚本分析过了,在执行main方法时,会轮训执行脚本时紧跟的参数,然后针对参数进行不同的处理,这里我们针对v这个参数进行分析,这个参数的作用是让沙箱在bash中打印出运行时信息。针对v参数处理的全链路源码如下:

  • 设置OP_VERSION标志位,待后续处理
  • 通过一系列的sandbox_curl_*方法组装curl请求,根据源码我们清楚的知道,最终组装的curl请求为:curl -N -s "http://host:port/sandbox/default/module/http/sandbox-info/version?1=1",怎么样,是不是和上面的ModuleHttpServlet处理的路径对应上了。
 v) OP_VERSION=1 ;;
 ​
 [[ -n ${OP_VERSION} ]] &&
     sandbox_curl_with_exit "sandbox-info/version"
     
 function sandbox_curl_with_exit() {
   sandbox_curl "${@}"
   exit
 }
 ​
 function sandbox_curl() {
   sandbox_debug_curl "module/http/${1}?1=1${2}"
 }
 ​
 function sandbox_debug_curl() {
   local host=${SANDBOX_SERVER_NETWORK%;**}
   local port=${SANDBOX_SERVER_NETWORK#**;}
   if [[ "$host" == "0.0.0.0" ]]; then
     host="127.0.0.1"
   fi
   curl -N -s "http://${host}:${port}/sandbox/${TARGET_NAMESPACE}/${1}" ||
     exit_on_err 1 "target JVM ${TARGET_JVM_PID} lose response."
 }

之后在doMethod方法中,会解析出sandbox-infoversion两个信息,前者代表待处理的模块,后者代表模块中待处理的方法。

现在让我们来看看sandbox-info这个模块的内容吧,该模块在sandbox-mgr-module这个module下的com.alibaba.jvm.sandbox.module.mgr包中。模块的作用注释描述的非常清楚了,大家可以自行挑选感兴趣的继续深入学习。

 // 该注解能够在打包时,添加JDK SPI加载所需要的文件
 @MetaInfServices(Module.class)
 // @Information注解中的id信息和请求路径匹配
 @Information(id = "sandbox-info", version = "0.0.4", author = "luanjia@taobao.com")
 public class InfoModule implements Module {
     // 注入的全局配置信息
     @Resource
     private ConfigInfo configInfo;
     // @Command注解标记,最终在doMethod中会反射调用该方法,方法内容比较简单粗暴,直接打印配置信息
     @Command("version")
     public void version(final PrintWriter writer) throws IOException {
         final StringBuilder versionSB = new StringBuilder()
                 .append("                    NAMESPACE : ").append(configInfo.getNamespace()).append("\n")
                 .append("                      VERSION : ").append(configInfo.getVersion()).append("\n")
                 .append("                         MODE : ").append(configInfo.getMode()).append("\n")
                 .append("                  SERVER_ADDR : ").append(configInfo.getServerAddress().getHostName()).append("\n")
                 .append("                  SERVER_PORT : ").append(configInfo.getServerAddress().getPort()).append("\n")
                 .append("               UNSAFE_SUPPORT : ").append(configInfo.isEnableUnsafe() ? "ENABLE" : "DISABLE").append("\n")
                 .append("                 SANDBOX_HOME : ").append(configInfo.getHome()).append("\n")
                 .append("            SYSTEM_MODULE_LIB : ").append(configInfo.getSystemModuleLibPath()).append("\n")
                 .append("              USER_MODULE_LIB : ").append(configInfo.getUserModuleLibPath()).append("\n")
                 .append("          SYSTEM_PROVIDER_LIB : ").append(configInfo.getSystemProviderLibPath()).append("\n")
                 .append("           EVENT_POOL_SUPPORT : ").append(configInfo.isEnableEventPool() ? "ENABLE" : "DISABLE");
         writer.println(versionSB.toString());
         writer.flush();
 ​
     }
     // ...
 }

有了上面的分析,我们便能够非常清楚脚本与沙箱的通信原理,就是通过请求路径匹配沙箱和其中的方法,然后通过反射调用方法实现沙箱的通信了

这里用一张图作为这一小节的结尾吧(这里只展示通信过程,删除掉了无关信息):

image-20220421001018977.png

4、总结

我们这一篇主要分析了沙箱与Sandbox内置Jetty服务器初始化的主要内容,并且深入讲解了沙箱模块管理器的守护代理机制和沙箱的http通信原理。

讲到这里,大家就已经对Sandbox里面大部分核心对象有了一定的认知,Sandbox目前的全貌也如图所示,后面将会进入到各个模块的加载流程中。

image-20220420235302654.png

ps:原本这一篇还想分析沙箱是如何加载所有模块的,但上面几步分析的有些多,并且模块加载的逻辑也挺多的,放在一篇话怕内容过于堆积,因此咱们就期待下一篇吧。