从数据库, Redis 取了些数据, 做了一些运算后, 没抛异常, 但是就是结果不对
抛了个空指针异常, 但是看代码, 感觉没问题, 是取出来就是空, 还是中间什么函数把它改坏了
发现导致一个 bug 的原因是用了 JVM 缓存, 但是怎么清理呢? 难道重启?
Redis 数据不对, 能不能悄咪咪重新拉一下
好想把某个全局变量打出来看一下? 好想执行一个数据库查询, 看看他么的结果对不对?
...
哎, 程序员的世界, 从来没有容易二字. 说实话, 我们这次要开的后门就是做上面这些事情的, 我刚鼓捣出这个时, 我感觉这个还挺 shock, 为啥大佬们不去弄呢, 后来我偶然想到, 在 周志明大佬的那本 《Java 虚拟机: JVM 高级特性与最佳实践》书里, 提到过类似的解决思路. 就在书的 9.3 节, 如下图, 这里就提到了类似的需求, 就是要在不停服务情况下, 动态执行代码, 方案其实一直都有: 将自己的调试代码写到 JSP 里, 丢到服务器上, 然后访问该 JSP.
我们要做的事情, 其实有点类似 JSP, 比它好的地方在于: 不用把文件手动丢到服务器上, 直接上传 class 就行了. 也正是因为这次的折腾, 我才知道, JSP 原来还是能做很多事情的. 但是笔者在毕业时, JSP 应用基本就很少了, 大学学了点皮毛而已, 工作后更是没用到, 但它的类加载器的思想还是值得我们学习的.
二, 大体思路与展示
1, 思路
我们的目标是, 针对一个 spring mvc 开发的部署在 tomcat 上的 war 包应用, 不重启的情况下, 动态执行一些我们的调试代码, 调试代码中, 只要是原项目能用的东西, 我们都可以用. 具体的方式是, 在项目中 增加一个 Controller, 该 Controller 的接口, 主要是接收客户端传过来的调试类的 class 文件, 或者去指定的 url 加载调试类的 class, 然后用自定义类加载器加载该 class,new 出对象, 并执行我们指定的方法.
下面我先简单介绍下演示项目:
应用是 spring MVC + spring(演示用, 就没有 db 层), 内部有一个测试用的 Controller:
- // TestController.java
- package com.remotedebug.controller;
- import com.remotedebug.service.IRedisCacheService;
- import org.springframework.beans.factory.annotation.Autowired;
- import org.springframework.Web.bind.annotation.RequestMapping;
- import org.springframework.Web.bind.annotation.RequestParam;
- import org.springframework.Web.bind.annotation.RestController;
- /**
- * desc:
- * 测试接口, 模拟从 Redis 中获取缓存. 当然, 实际场景下, 看缓存可以直接用工具的, 这里就是举个栗子
- * @author : caokunliang
- * creat_date: 2019/6/18 0018
- * creat_time: 10:13
- **/
- @RestController
- public class TestController {
- @Autowired
- private IRedisCacheService iRedisCacheService;
- /**
- * 缓存获取接口
- * @param cacheKey
- */
- @RequestMapping("getCache.do")
- public String getCache(@RequestParam String cacheKey){
- String value = iRedisCacheService.getCache(cacheKey);
- System.out.println(value);
- return value;
- }
- }
- // IRedisCacheServiceImpl.java
- package com.remotedebug.service.impl;
- import com.remotedebug.service.IRedisCacheService;
- import lombok.extern.slf4j.Slf4j;
- import org.springframework.stereotype.Service;
- import java.util.List;
- /**
- * desc:
- *
- * @author : caokunliang
- * creat_date: 2019/6/18 0018
- * creat_time: 10:17
- **/
- @Service
- @Slf4j
- public class IRedisCacheServiceImpl implements IRedisCacheService {
- @Override
- public String getCache(String cacheKey) {
- String target = null;
- // ---------------------- 前面有复杂逻辑 --------------------------
- String count = getCount(cacheKey);
- // ---------------------- 后面有复杂逻辑, 包括对 count 进行修改 --------------------------
- if (Integer.parseInt(count)> 1){
- target = "abc";
- }else {
- // 一些业务逻辑, 但是忘记给 target 赋值
- // .....
- }
- return target.trim();
- }
- @Override
- public String getCount(String cacheKey){
- // 假设是从 Redis 读取缓存, 这里简单起见, 假设 value 的值就是 cacheKey
- return cacheKey;
- }
- }
注意上面的实现类, getCache 方法, 就是简单地去调用了 getCount 方法, 然后做了一些复杂计算, 在 else 分支, 我们没给 target 赋值, 所以 在 34 行调用 target.trim 时会抛 NPE. 我们这时候排查问题时, 如果能够调用 getCount 看到返回的值是多少, 就好了!
知道了 getCount 返回值, 我们就可以接着看到底是返回的值有问题, 还是是因为后面的逻辑有问题了. 常规情况下, 我们是没办法的, 只能肉眼看了, 或者本地调试, 但本地调试, 取到的数据又不是真实环境的, 很可能不能复现.
我们现在就可以写一段下面这样的代码, 放到服务器上执行, 就可以将我们需要的信息打出来了:
- import com.remotedebug.service.IRedisCacheService;
- import com.remotedebug.utils.SpringContextUtils;
- import lombok.extern.slf4j.Slf4j;
- @Slf4j
- public class RemoteDebugTest {
- public void debug(){
- IRedisCacheService bean = SpringContextUtils.getBean(IRedisCacheService.class);
- String value = bean.getCount("user.count.userIdxxx");
- log.info("value:{}", value );
- }
- public static void main(String[] args) {
- new RemoteDebugTest().debug();
- }
- }
ps: 这里的 SpringContextUtils 只是一个简单的工具类, spring 容器会把自己赋值给 SpringContextUtils 中一个静态变量, 方便我们在一些不被 spring 管理的 bean 中获取 bean.
那要怎么才能让服务器执行我们的 RemoteDebugTest 的 debug 方法呢, 你可能想到了, 我们再加一个 Controller 就行了:
- package com.remotedebug.controller;
- import com.remotedebug.utils.LocalFileSystemClassLoader;
- import com.remotedebug.utils.MyReflectionUtils;
- import com.remotedebug.utils.UploadFileStreamClassLoader;
- import lombok.extern.slf4j.Slf4j;
- import org.springframework.stereotype.Controller;
- import org.springframework.Web.bind.annotation.RequestMapping;
- import org.springframework.Web.bind.annotation.RequestParam;
- import org.springframework.Web.bind.annotation.ResponseBody;
- import org.springframework.Web.multipart.MultipartFile;
- import java.io.InputStream;
- /**
- * desc:
- * 原理: 自定义类加载器, 根据入参加载指定的调试类, 调试类中需要引用 webapp 中的类, 所以需要把 webapp 的类加载器作为 parent 传给自定义类加载器.
- * 这样就可以执行 调试类中的方法, 调试类中可以访问 webapp 中的类, 所以通过 spring 容器的静态引用来获取 spring 中的 bean, 然后就可以执行很多业务方法了.
- * 比如获取系统的一些状态, 执行 service/dao bean 中的方法并打印结果(如果方法是 get 类型的操作, 则可以获取系统状态, 或者模拟取 Redis/MySQL 库中的数据, 如果
- * 为 update 类型的 service 方法, 则可以用来改变系统状态, 在不用重启的情况下, 进行一定程度的热修复.
- * @author : caokunliang
- * creat_date: 2018/10/19 0019
- * creat_time: 14:02
- **/
- @Controller
- @Slf4j
- public class RemoteDebugController {
- /**
- * 远程 debug, 读取参数中的 class 文件的路径, 然后加载, 并执行其中的方法
- */
- @RequestMapping("/remoteDebug.do")
- @ResponseBody
- public String remoteDebug(@RequestParam String className ,@RequestParam String filePath, @RequestParam String methodName) throws Exception {
- /**
- * 获取当前类加载器, 当前类肯定是放在 webapp 的 Web-inf 下的 classes, 这个类所以是由 webappclassloader 加载的, 所以这里获取的就是这个
- */
- ClassLoader webappClassloader = this.getClass().getClassLoader();
- log.info("webappClassloader:{}",webappClassloader);
- /**
- * 用自定义类加载器, 加载参数中指定的 filePath 的 class 文件, 并执行其方法
- */
- log.info("开始执行:{}中的方法:{}",className,methodName);
- LocalFileSystemClassLoader localFileSystemClassLoader = new LocalFileSystemClassLoader(filePath, className, webappClassloader);
- Class<?> myDebugClass = localFileSystemClassLoader.loadClass(className);
- MyReflectionUtils.invokeMethodOfClass(myDebugClass, methodName);
- log.info("结束执行:{}中的方法:{}",className,methodName);
- return "success";
- }
- /**
- * 远程 debug, 读取参数中的 class 文件的路径, 然后加载, 并执行其中的方法
- */
- @RequestMapping("/remoteDebugByUploadFile.do")
- @ResponseBody
- public String remoteDebugByUploadFile(@RequestParam String className, @RequestParam String methodName, MultipartFile file) throws Exception {
- if (className == null || file == null || methodName == null) {
- throw new RuntimeException("className,file,methodName must be set");
- }
- /**
- * 获取当前类加载器, 当前类肯定是放在 webapp 的 Web-inf 下的 classes, 这个类所以是由 webappclassloader 加载的, 所以这里获取的就是这个
- */
- ClassLoader webappClassloader = this.getClass().getClassLoader();
- log.info("webappClassloader:{}",webappClassloader);
- /**
- * 用自定义类加载器, 加载参数中指定的 class 文件, 并执行其方法
- */
- log.info("开始执行:{}中的方法:{}",className,methodName);
- InputStream inputStream = file.getInputStream();
- UploadFileStreamClassLoader myClassLoader = new UploadFileStreamClassLoader(inputStream, className, webappClassloader);
- Class<?> myDebugClass = myClassLoader.loadClass(className);
- MyReflectionUtils.invokeMethodOfClass(myDebugClass, methodName);
- log.info("结束执行:{}中的方法:{}",className,methodName);
- return "success";
- }
- /**
- * 远程 debug, 读取参数中 url 指定的 class 文件的路径, 然后加载, 并执行其中的方法
- */
- @RequestMapping("/remoteDebugByURL.do")
- @ResponseBody
- public String remoteDebugByURL(@RequestParam String className,@RequestParam String url, @RequestParam String methodName) throws Exception {
- if (className == null || url == null || methodName == null) {
- throw new RuntimeException("className,url,methodName must be set");
- }
- /**
- * 获取当前类加载器, 当前类肯定是放在 webapp 的 Web-inf 下的 classes, 这个类所以是由 webappclassloader 加载的, 所以这里获取的就是这个
- */
- ClassLoader webappClassloader = this.getClass().getClassLoader();
- log.info("webappClassloader:{}",webappClassloader);
- /**
- * 用自定义类加载器, 加载参数中指定的 class 文件, 并执行其方法
- */
- log.info("开始执行:{}中的方法:{}",className,methodName);
- UploadFileStreamClassLoader myClassLoader = new UploadFileStreamClassLoader(url, className, webappClassloader);
- Class<?> myDebugClass = myClassLoader.loadClass(className);
- MyReflectionUtils.invokeMethodOfClass(myDebugClass, methodName);
- log.info("结束执行:{}中的方法:{}",className,methodName);
- return "success";
- }
- }
- View Code
在这个 Controller 中, 一共提供了三种方式, 先说最直接的, 就是通过上传 class 文件, 这个很简单, 只要有一个接口工具 (如 postman) 就可以. Controller 中会 用自定义类加载器, 去加载 文件流 代表的 class, 然后 new 出对象, 调用方法就行了.
2. 效果展示
我的应用部署在 192.168.19.13 上, Tomcat 端口为 8081, 如下:
- [root@localhost apache-tomcat-8.0.41]# ll webapps/
- total 9336
- drwxr-xr-x. 14 root root 4096 Jun 19 11:39 docs
- drwxr-xr-x. 6 root root 4096 Jun 19 11:39 examples
- drwxr-xr-x. 5 root root 4096 Jun 19 11:39 host-manager
- drwxr-xr-x. 5 root root 4096 Jun 19 11:39 manager
- drwxr-xr-x. 4 root root 4096 Jun 19 13:48 remotedebug
- -rw-r--r--. 1 root root 9531510 Jun 19 13:47 remotedebug.war
- drwxr-xr-x. 3 root root 4096 Jun 19 11:39 ROOT
我们在本地写好一个测试文件,(可以直接在 工程 里面写, 这样才方便引用工程的类, 不然还要自己敲 import 路径, 那也太傻了), 写好后, 右键 执行下 main, 触发编译操作.
执行 main, 肯定会报错, 这是不用说的, 但我们只需要 class 而已:
我们去 target 目录下, 找到编译出来的 class, 然后用 接口工具调用, 如下:
下面我们看看执行结果:
然后我再改下测试类的 debug 方法:
- public void debug(){
- IRedisCacheService bean = SpringContextUtils.getBean(IRedisCacheService.class);
- String value = bean.getCount("123456789");
- log.info("value:{}", value );
- }
再次执行:
三, 源码解析
代码我放在交友网站了, 欢迎 fork.
https://github.com/cctvckl/remotedebug
类结构如下:
我们重点分析 remoteDebugByUploadFile :
- /**
- * 远程 debug, 读取参数中的 class 文件的路径, 然后加载, 并执行其中的方法
- */
- @RequestMapping("/remoteDebugByUploadFile.do")
- @ResponseBody
- public String remoteDebugByUploadFile(@RequestParam String className, @RequestParam String methodName, MultipartFile file) throws Exception {
- if (className == null || file == null || methodName == null) {
- throw new RuntimeException("className,file,methodName must be set");
- }
- /**
- * 获取当前类加载器, 当前类肯定是放在 webapp 的 Web-inf 下的 classes, 这个类所以是由 webappclassloader 加载的, 所以这里获取的就是这个
- */
- ClassLoader webappClassloader = this.getClass().getClassLoader();
- log.info("webappClassloader:{}",webappClassloader);
- /**
- * 用自定义类加载器, 加载参数中指定的 class 文件, 并执行其方法
- */
- log.info("开始执行:{}中的方法:{}",className,methodName);
- InputStream inputStream = file.getInputStream();
- UploadFileStreamClassLoader myClassLoader = new UploadFileStreamClassLoader(inputStream, className, webappClassloader);
- Class<?> myDebugClass = myClassLoader.loadClass(className);
- MyReflectionUtils.invokeMethodOfClass(myDebugClass, methodName);
- log.info("结束执行:{}中的方法:{}",className,methodName);
- return "success";
- }
其中, 14,15 行, 主要获取当前的 webappclassloader 加载器, 该加载器, 通俗来讲, 就是加载应用目录下的 Web-inf/lib 和 Web-inf/classes. 21 行, 主要获取文件流; 22 行, 将流, 要加载的 class 的类名, webappclassloader 作为参数, 来生成 自定义的类加载器, 其中 webappclassloader 将作为 我们自定义类加载器的 双亲加载器. 23 行, 用自定义类加载器加载我们的类; 24 行, 用加载类反射, 生成对象, 并执行 methodName 指定的方法.
重点代码在 UploadFileStreamClassLoader, 我们看一下:
- package com.remotedebug.utils;
- import lombok.extern.slf4j.Slf4j;
- import java.io.ByteArrayOutputStream;
- import java.io.IOException;
- import java.io.InputStream;
- import java.io.UnsupportedEncodingException;
- import java.NET.URL;
- import java.NET.URLConnection;
- /**
- * desc:
- *
- * @author : caokunliang
- * creat_date: 2019/6/13 0013
- * creat_time: 10:19
- **/
- @Slf4j
- public class UploadFileStreamClassLoader extends ClassLoader {
- /**
- * 要加载的 class 的类名
- */
- private String className;
- /**
- * 要加载的调试 class 的流, 可以通过客户端文件上传, 也可以通过传递 url 来获取
- */
- private InputStream inputStream;
- /**
- *
- * @param inputStream 要加载的 class 的文件流
- * @param className 类名
- * @param parentWebappClassLoader 父类加载器
- */
- public UploadFileStreamClassLoader(InputStream inputStream, String className, ClassLoader parentWebappClassLoader) {
- super(parentWebappClassLoader);
- this.className = className;
- this.inputStream = inputStream;
- }
- @Override
- protected Class<?> findClass(String name) throws ClassNotFoundException {
- byte[] data = getData();
- try {
- String s = new String(data, "utf-8");
- // log.info("class content:{}",s);
- } catch (UnsupportedEncodingException e) {
- e.printStackTrace();
- }
- return defineClass(className,data,0,data.length);
- }
- private byte[] getData(){
- try {
- ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
- byte[] bytes = new byte[2048];
- int num = 0;
- while ((num = inputStream.read(bytes)) != -1){
- byteArrayOutputStream.write(bytes, 0,num);
- }
- return byteArrayOutputStream.toByteArray();
- } catch (Exception e) {
- log.error("read stream failed.{}",e);
- throw new RuntimeException(e);
- }
- }
- }
重点关注 46 行和 54 行, 46 行主要是 从流中读取字节, 转为字节数组; 54 行主要是将字节数组代表的 class 加载到虚拟机中. 另外, 这里我们只覆盖了 findClass, 是遵循双亲委派模型的, 可以注意到, 我们的测试类中, import 了一些工程的类, 比如:
- import com.remotedebug.service.IRedisCacheService;
- import com.remotedebug.utils.SpringContextUtils;
- import lombok.extern.slf4j.Slf4j;
- @Slf4j
- public class RemoteDebugTest {
- public void debug(){
- IRedisCacheService bean = SpringContextUtils.getBean(IRedisCacheService.class);
- String value = bean.getCount("123456789");
- log.info("value:{}", value );
- }
- }
在加载这些类时, 我们自定义的类加载器会先委托给父类加载器加载, 而且, 我们自定义的类加载器自身也加载不了这些类. 这里有个关键点在于, 我们为什么要把 应用的当前类加载器传入作为自定义加载器的父加载器呢, 因为不同类加载器加载出来的 class, 不能互转, 所以我们必须用 同一个类加载器实例.
四, 使用说明
上面详细讲述了代码实现, 这里, 汇总一下, 我们这边一共提供了三个接口:
remoteDebug.do 参数:@RequestParam String className ,@RequestParam String filePath, @RequestParam String methodName
该接口, 主要是从本地文件系统加载 filepath 指定的文件, 所以这个接口, 需要先把 class 文件 上传到 服务器的某个路径下.
remoteDebugByUploadFile.do 参数: @RequestParam String className, @RequestParam String methodName, MultipartFile file
该接口, 可以直接上传 class 文件, 要支持文件上传, 需要进行以下配置:
- <bean
- class="org.springframework.web.multipart.commons.CommonsMultipartResolver">
- <property name="defaultEncoding" value="utf-8" />
- <property name="maxUploadSize" value="10485760000" />
- <property name="maxInMemorySize" value="40960" />
- </bean>
同时, 我这边的环境不知道为啥, 还需要修改 Web.xml(我们其他项目中都没配这个, 尴尬):
- <servlet>
- <servlet-name>DispatcherServlet</servlet-name>
- <servlet-class>org.springframework.Web.servlet.DispatcherServlet</servlet-class>
- <init-param>
- <param-name>contextConfigLocation</param-name>
- <param-value>classpath*:/remotedebug-servlet.xml</param-value>
- </init-param>
- <load-on-startup>1</load-on-startup>
- <multipart-config>
- <location></location>
- <max-file-size>20848820</max-file-size>
- <max-request-size>418018841</max-request-size>
- <file-size-threshold>1048576</file-size-threshold>
- </multipart-config>
- </servlet>
- remoteDebugByURL @RequestParam String className,@RequestParam String url, @RequestParam String methodName
该接口, 可接受一个网络 url, 从 url 去加载指定的 class.
五, 总结
一开始没想鼓捣这个, 只是后边学了类加载器后, 感觉是不是可以利用其来做点什么, 于是想到了这个. 因为热替换, 是不可能在同一个类加载器实例中重复加载同一个类的, 所以目前的热替换都是连根拔起, 将类加载器一起换掉. 在 Web 应用中, Web-inf 下的 classes 和 lib 都由唯一的一个类加载器加载, 要替换其中的单个类, 暂时没想到什么办法, 但是我就感觉, 可以用一个单独的类加载器去加载指定的一个位置(不同于 Web-inf 的位置), 然后每次不用这个类, 就把加载器一起丢了就行. 然后一开始不知道可行, 直到做出来试了后, 发现确实没有问题, 理论上也能解释. 后来, 我在和同事讨论的过程中, 感觉我做的这个东西, 和 JSP 很像, 然后又想到 在周志明的那本书里, 好像有过类似的案例, 去看了下, 果然如此...
哈哈, 好吧, 我还以为是很新鲜的东西, 原来大佬早就玩过了, JSP 更是出现了不知道多少年了, 只是以前没怎么玩过 JSP.
这个方法, 也是适用于 spring boot 的, 只是需要稍微修改一下, 后续我再稍微改改, 发个 spring boot 的版本出来. 类加载器这个东西还是挺有用, 后续我会继续更新这方面的文章, 包括 SPI,osgi(皮毛), 各类框架中 类加载器的应用等, 也希望和大家多多交流, 共同交流才能一起进步嘛.
源码再发一下, 在这里: https://github.com/cctvckl/remotedebug
不同于之前的文章, 这次排版改了下, 比如字体变大了, 有些段落换了颜色, 大家觉得比默认的好看还是不好看?
来源: https://www.cnblogs.com/grey-wolf/p/11051427.html