无需重启-在线修改代码

Posted noname

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了无需重启-在线修改代码相关的知识,希望对你有一定的参考价值。

背景

当系统遇到异常情况时,想要加上日志打印下关键信息,或者改下逻辑代码,但又不想重启,因为重启太麻烦太耗时且可能会破坏现场,甚至有些场景在测试环境无法模拟出来导致无法复现。这时候就希望能在不重启的情况下更新代码并立即生效。
目标:对代码的增删改查,并且实时热更新。

  1. :插入代码。
  2. :删除代码。
  3. :替换代码。
  4. :下载指定类的class文件 ,如果是修改过的,那下载的就是修改后的class文件。
  5. 还原:还原回修改前的代码。
阿里有个Arthas支持在线问题诊断,也支持在线修改代码,但是仍然有局限性:修改代码的步骤很繁琐,需要登录服务器操作,并且一次只能更新一个服务节点的代码,如果部署了多个节点,就需要每台都登录操作。

概念

Instrumentation

使用 Insrumentation,开发者可以构建一个独立于应用程序的代理程序(Agent),监测和协助运行在JVM上的程序,甚至可以替换和修改某些类的定义。简单的来说开发者使用Instrumentation可以实现一种虚拟机级别的AOP实现。
Instrumentation的最大作用,就是类定义动态改变和操作。程序运行时,通过-javaagent参数指定一个特定的jar文件来启动Instrumentation的代理程序。其实这个对很多人来说不陌生:xmind, idea永久破解都使用了agentMockitoMock类库也用到了agent,一些监控软件(如skywalking)也用了。
在java中如何实现Instrumentation?

1. 创建代理类

Java Agent支持目标JVM启动时加载,也支持在目标JVM运行时加载,这两种不同的加载模式会使用不同的入口函数。
如果需要在目标JVM启动的同时加载Agent,那么可以选择实现下面的方法:

public class MyAgent {
    // 方式一
    public static void premain(String options, Instrumentation instrumentation)  {
        System.out.println("Java Agent premain");
        instrumentation.addTransformer(new MyTransformer());
    }
    // 方式二
    public static void premain(String options){
        System.out.println("Java Agent premain");
        instrumentation.addTransformer(new MyTransformer());
    }
}

如果希望在目标JVM运行时加载Agent,则需要实现下面的方法:

public class MyAgent {
    // 方式一
    public static void agentmain(String options, Instrumentation instrumentation)  {
        System.out.println("Java Agent agentmain");
        instrumentation.addTransformer(new MyTransformer());
    }
    // 方式二
    public static void agentmain(String options){
        System.out.println("Java Agent agentmain");
        instrumentation.addTransformer(new MyTransformer());
    }
}

第一个参数options是通过命令行传递给agent的参数,第二个参数是用JVM提供的用于注册类转换器(ClassTransformer)的Instrumentation实例。
方式一的优先级比方式二高,当方式一方式二两个方法同时存在时,方式二方法将被忽略。

转换发生在premain函数执行之后,main函数执行之前,这时每装载一个类,transform方法就会执行一次,所以在transform方法中,可以用 className.equals(myClassName)来判断当前的类是否需要转换,return null即表示当前字节不需要转换

2. 创建类转换器

对Java类文件的操作,可以理解为对java二进制字节数组的操作,修改原始的字节数组,返回修改后的字节数组。
ClassFileTransformer接口只有一个transform方法,参数传入包括该类的类加载器,类名,原字节码字节流等,返回被转换后的字节码字节流。

public class MyTransformer implements ClassFileTransformer {

    @Override
    public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
            ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {

        //className是以/分割
        if (!className.equals("com/xxx/AgentTester")){
            return null;
        }
        // 业务操作
        ......
    } 
}

3. 创建MANIFEST.MF文件

resource目录下新建META-INF/MANIFEST.MF文件,其中Premain-Class的值是包含包名的类名

Mainfest-Version: 1.0
Premain-Class: com.xxx.AgentTester
Can-Redefine-Classes: true
Can-Retransform-Classes: true

根据不同的加载方式,选择配置Premain-ClassAgent-Class

4. 打包&运行

通过Maven的org.apache.maven.pluginsmaven-assembly-plugin插件生成jar文件,MANIFEST.MF文件也可以通过以上插件自动生成。
启动命令上加入javaagent,

java -javaagent:/文件路径/myAgent.jar -jar myProgram.jar

例如:

java -javaagent:/usr/local/dev/MyAgent.jar -jar /usr/local/dev/MyApplication.jar

我们还可以在位置路径上设置可选的agent参数。

java -javaagent:/usr/local/dev/MyAgent.jar=Hello -jar /usr/local/dev/MyApplication.jar

Javassist

Java字节码以二进制的形式存储在.class文件中,每一个.class文件包含一个 Java 类或接口。关于java字节码的处理,有很多类库,如bcelasm。不过这些都需要直接跟虚拟机指令打交道。如果你不想了解虚拟机指令,javassist是一个不错的选择。Javassist可以在一个已经编译好的类中添加新的方法,或者是修改已有的方法,并且不需要对字节码方面有深入的了解。
以下例子,是修改MyApp类的fun方法,进入方法时先打印一行before

ClassPool pool = ClassPool.getDefault();
CtClass cc = pool.get("test.MyApp");
CtMethod m = cc.getDeclaredMethod("fun");
m.insertBefore("{ System.out.println(\\"before\\"); }");
cc.writeFile();

Javassist最重要的是ClassPool、CtClass、CtMethod、CtField这四个类:
ClassPoolCtClass对象的容器,一张保存CtClass信息的HashTable,key为类名,value为CtClass对象,它按需读取类文件来构造CtClass对象,并且缓存CtClass对象以便以后使用。
CtClass是一个class文件在代码中的抽象表现形式,对CtClass的修改相当于对class文件的修改。
CtMethodCtField对应的是类中的方法和属性。

配合前面的Instrumentation,可以在ClassFileTransformer内对类代码做转换:

public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
        ProtectionDomain protectionDomain, byte[] classfileBuffer) {
    try {
        ClassPool pool = ClassPool.getDefault();
        CtClass cc = pool.get(className.replace("/", "."));
        CtMethod m = cc.getDeclaredMethod("fun");
        m.insertBefore("{ System.out.println(\\"before\\"); }");
        return cc.toBytecode();
    } catch (Exception e) {
        throw new RuntimeException(e);
    }
}

因为JAVA Agent需要通过在命令行上加上-javaagent来执行,这就提高了组件引入成本,在做公共组件时,使用简单也是要考虑的一个点。

Byte Buddy

Byte Buddy提供了更简化的API,如下的示例展现了如何生成一个简单的类,这个类是Object的子类,并且重写了toString方法,返回Hello World!

Class<?> dynamicType = new ByteBuddy()
  .subclass(Object.class)
  .method(ElementMatchers.named("toString"))
  .intercept(FixedValue.value("Hello World!"))
  .make()
  .load(getClass().getClassLoader())
  .getLoaded();
 
assertThat(dynamicType.newInstance().toString(), is("Hello World!"));

在选择字节码操作库时,还要考虑库本身的性能,官网对库进行了性能测试,给出以下结果图:

从性能报告中可以看出,Byte Buddy的主要侧重点在于以最少的运行时生成代码,需要注意的是,这些衡量Java代码性能的测试,都由Java虚拟机即时编译器优化过,如果你的代码只是偶尔运行,没有得到虚拟机的优化,可能性能会有所偏差。

遗憾的是Byte Buddy不支持修改已有方法内的代码,例如删除一行代码这种需求是无法通过Byte Buddy来实现的。但是Byte Buddy在项目运行时,可以通过以下方法获取到Instrumentation对象,无需配置Agent

Instrumentation instrumentation = ByteBuddyAgent.install();

开发

基础代码

1. 转换处理类

以下代码是变更执行工具类:

public class Instrumentations {

    private final static Instrumentation instrumentation;
    private final static ClassPool classPool;

    static {
        instrumentation = ByteBuddyAgent.install();
        classPool = ClassPool.getDefault();
        // 指定路径,否则可能会出现javassist.NotFoundException的问题
        classPool.insertClassPath(new LoaderClassPath(Thread.currentThread().getContextClassLoader()));
    }

    private Instrumentations() {
    }

    /**
     * @param classFileTransformer
     * @param classes
     * @author 
     * @date 
     */
    public static void transformer(ClassFileTransformer classFileTransformer, Class<?>... classes) {
        try {
            //添加.class文件转换器
            instrumentation.addTransformer(classFileTransformer, true);
            int size = classes.length;
            Class<?>[] classArray = new Class<?>[size];
            //复制字节码到classArray
            System.arraycopy(classes, 0, classArray, 0, size);
            if (classArray.length > 0) {
                instrumentation.retransformClasses(classArray);
            }
        } catch (Exception e) {
            throw new RuntimeException(e);
        } finally {
            //增强完毕,移除transformer
            instrumentation.removeTransformer(classFileTransformer);
        }
    }

    /**
     * @return java.lang.instrument.Instrumentation
     * @author 
     * @date 
     */
    public static Instrumentation getInstrumentation() {
        return instrumentation;
    }

    /**
     * @return javassist.ClassPool
     * @author 
     * @date 
     */
    public static ClassPool getClassPool() {
        return classPool;
    }
}

2. 转换器

-转换器的代码会有一些公共逻辑,所以先抽取出公共代码。

@Slf4j
@AllArgsConstructor
public abstract class AbstractResettableTransformer implements ClassFileTransformer {
    protected String fullClassName;
    @Override
    public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
            ProtectionDomain protectionDomain, byte[] classfileBuffer) {
        try {
            className = convertClassName(className);
            // 必须做这一层过滤, 实践发现即使调用ClassFileTransformer时指定了类,但还是会有其他类也被执行了transformer
            if (!fullClassName.equals(className)) {
                return classfileBuffer;
            }
            logTransform(loader, className, classBeingRedefined, protectionDomain, classfileBuffer);
            CtClass cc = Instrumentations.getClassPool().get(className);
            saveInitialSnapshot(className, classBeingRedefined, classfileBuffer);
            defrost(cc);
            return doTransform(loader, className, classBeingRedefined, protectionDomain, classfileBuffer, cc);
        } catch (Exception e) {
            logTransformError(loader, className, classBeingRedefined, protectionDomain, classfileBuffer, e);
            throw new RuntimeException(e);
        }
    }
}    

分步解析以上的代码:
1. 转换类名称,ClassFileTransformer的包路径是以/分隔的。

protected String convertClassName(String sourceClassName) {
    return sourceClassName.replace("/", ".");
}

2. 这种重要的操作,一定要记录日志。

protected void logTransform(ClassLoader loader, String className, Class<?> classBeingRedefined,
        ProtectionDomain protectionDomain, byte[] classfileBuffer) {
    log.info("[{}]增强类[{}]代码!", this.getClass().getName(), className);
}

protected void logTransformError(ClassLoader loader, String className, Class<?> classBeingRedefined,
        ProtectionDomain protectionDomain, byte[] classfileBuffer, Exception e) {
    log.error("[{}]增强类[{}]代码异常!", this.getClass().getName(), className, e);
}

3. 备份原始的类字节
这一步是为了后续还原做准备,有时候我们可能只是临时增加下调试代码,调试完之后还要还原代码。

  • ClassFileTransformertransform的方法如果返回null,即表示不做增强,也会将class的字节码还原,但是这种做法会有误伤,会将class还原到最原始的状态,如果有其他类/插件也做了增强,比如有个自定义的agent,这些增强也都会被还原。
  • JavassistCtClass类的detach方法,也会清除Javassist对代码的修改,detach会从ClassPool中清理掉CtClass的缓存,而 JavassistCtClass就对应一个class的字节,所以对class字节的修改都直接表现在对CtClass的修改,如果清理掉CtClass,那就相当于重置了Javassist对代码的修改。这种做法跟上面一样会有误伤

综上所述,还原采用了保存修改前的字节数组,还原时通过字节数组重新构造class的方案,

@Slf4j
public abstract class AbstractResettableTransformer implements ClassFileTransformer {

    final static ConcurrentHashMap<String, ByteCache> INITIAL_CLASS_BYTE = new ConcurrentHashMap<>();
    
    protected void saveInitialSnapshot(String className, Class<?> classBeingRedefined, byte[] classfileBuffer) {
        if (!INITIAL_CLASS_BYTE.containsKey(className)) {
            INITIAL_CLASS_BYTE.putIfAbsent(className, new ByteCache(classBeingRedefined, classfileBuffer));
        }
    }   

    @Data
    @AllArgsConstructor
    public static class ByteCache {

        private Class<?> clazz;
        private byte[] bytes;

    }
} 

4. 解冻对象
如果一个CtClass对象通过writeFile()toClass()toBytecode()被转换成一个类文件,此CtClass对象会被冻结起来,不允许再修改,通过defrost方法可以解冻

protected void defrost(CtClass ctClass) {
    if (ctClass.isFrozen()) {
        ctClass.defrost();
    }
}

“改”:修改指定行的代码

这里先以为例,因为使用Javassist来实现的话,实际上就是先的代码都可以从中抽取(拷贝)出来。
ClassFileTransformer类实现父类的doTransform方法。

@Slf4j
@Data
public class ReplaceLineCodeTransformer extends AbstractResettableTransformer {
    public ReplaceLineCodeTransformer(String fullClassName, String methodName, Integer lineNumber) {
        super(fullClassName);
        this.methodName = methodName;
        this.lineNumber = lineNumber;
        this.code = code;
    }

    @Override
    public byte[] doTransform(ClassLoader loader, String className, Class<?> classBeingRedefined,
            ProtectionDomain protectionDomain, byte[] classfileBuffer, CtClass cc) throws Exception {
        CtMethod m = cc.getDeclaredMethod(getMethodName());
        clearline(m);
        m.insertAt(getLineNumber(), code);
        return cc.toBytecode();
    }

    /**
     * @param m
     * @author
     * @date 
     */
    protected void clearline(CtMethod m) throws Exception {
        CodeAttribute codeAttribute = m.getMethodInfo().getCodeAttribute();
        LineNumberAttribute lineNumberAttribute = (LineNumberAttribute) codeAttribute
                .getAttribute(LineNumberAttribute.tag);
        int startPc = lineNumberAttribute.toStartPc(lineNumber);
        int endPc = lineNumberAttribute.toStartPc(lineNumber + 1);
        byte[] code = codeAttribute.getCode();
        for (int i = startPc; i < endPc; i++) {
            code[i] = CodeAttribute.NOP;
        }
    }
}

ReplaceLineCodeTransformer需要指定要要修改的类方法要替换的行要替换的代码块,这里分两步:

  • 清理指定行的代码:将指定行的字节都改为CodeAttribute.NOP,即没有任何操作。
  • 在指定行插入代码:如果是多句代码,插入的代码需要用{}包起来,例如:{ int i = 0; System.out.println(i); },如果是单句,则不需要。

要注意Javassist并不会改变原先代码的行数,例如原先代码第10行int i = 0;,这时候如果执行insertAt(10, "int j = 0;"),那第10行的代码会变成int j = 0;int i = 0;,代码会插在原先代码的前面,并且不会换行,同样的清理行代码,也只是把清理的那一行变成空行,下一行代码并不会上移。

以上代码是底层操作字节的代码,现在需要提供一个在线修改代码的入口,这里采用了提供接口的方案。
方案需要考虑几个点:

  1. 安全:接口不能随便被调用。
  2. 多节点:业务服务部署在多个节点上,接收到变更请求的节点要把数据分发到其他节点。

接口

先看下接口代码:

@RestController
@RequestMapping("/classByte")
@Slf4j
public class ClassByteController {

    @PostMapping(value = "/replaceLineCode")
    public void replaceLineCode(@Validated @RequestBody ReplaceLineCodeReq replaceLineCodeReq,
            @RequestHeader("auth") String auth,
            @RequestParam(required = false, defaultValue = "true") boolean broadcast) {
        auth(auth);
        try {
            Instrumentations.transformer(
                    new ReplaceLineCodeTransformer(replaceLineCodeReq.getMethodName(),
                            replaceLineCodeReq.getLineNumber(), replaceLineCodeReq.getCode()),
                    Class.forName(replaceLineCodeReq.getClassName()));

            if (broadcast) {
                broadcast(replaceLineCodeReq, auth, ByteOptType.REPLACE_LINE);
            }
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}

@Data
public class BaseCodeReq {

    /**
     * @author 
     * @date 
     */
    public void check() {
    }
}

@Data
@NoArgsConstructor
@AllArgsConstructor
public class ReplaceLineCodeReq extends BaseCodeReq {

    @NotBlank
    private String className;
    @NotBlank
    private String methodName;
    @NotNull
    @Min(1)
    private Integer lineNumber;
    @NotBlank
    private String code;

    @Override
    public void check() {
        PredicateUtils.ifTrueThrowRuntimeException(StringUtils.isBlank(className), "className不能为空");
        PredicateUtils.ifTrueThrowRuntimeException(StringUtils.isBlank(methodName), "methodName不能为空");
        PredicateUtils.ifTrueThrowRuntimeException(lineNumber == null, "lineNumber不能为空");
        PredicateUtils.ifTrueThrowRuntimeException(StringUtils.isBlank(code), "code不能为空");
    }
}

请求JSON串示例:

{
    "className":"com.xxxxx.controller.MonitorController",
    "methodName":"health",
    "lineNumber":30,
    "code":"{ int i = 0; System.out.println(i); }"
}

接口内容分三步:

  1. 安全检查。
  2. 修改字节。
  3. 分发。

安全

安全主要从两方面入手:

  1. 开关:常规时间将开关配置成关闭,也就是禁止修改,要修改时再打开,修改完之后再关闭
  2. 鉴权令牌:在开关打开的前提下,调用接口还要传一个令牌来比对,令牌放在HTTP Header上。

之所以接口内容没有加密传递,是考虑到修改字节时,大部分时候是手动调用接口(比如用PostMan),这样会影响操作效率,且在开关+令牌的方案下基本已经满足安全需求。

public class ClassByteController {

    @Value("${byte.canOpt:false}")
    private boolean classByteCanOpt;
    @Value("#{\'${byte.auth:}\'.isEmpty() ? T(com.xxxxx.common.util.UUIDGenerator).generateString() : \'${byte.auth:}\'}")
    private String auth;
    
    @PostConstruct
    public void init() {
        log.info("ClassByteController auth : " + auth);
    }
    
    /**
     * @param auth
     * @author 
     * @date 
     */
    private void auth(String auth) {
        if (!classByteCanOpt || !this.auth.equals(auth)) {
            throw new BusinessException("unsupport!");
        }
    }    
}      

如果没有配置令牌值,则默认会随机生成字符串,可以通过日志查到随机生成的令牌。

分发

接口在接收请求后,发布Redis 事件,所有节点都监听该事件,在收到事件之后也更新自身的代码。为了防止分发事件的节点监听到事件之后再次修改类字节,系统启动时给每个节点生成一个唯一的节点ID(UUID),分发的数据里带上当前节点ID,收到数据时,如果数据里节点ID当前节点的ID一致,则忽略事件。

public class ClassByteController {
    @Autowired
    private Broadcaster broadcaster;

    /**
     * 广播通知其他节点
     *
     * @param baseCodeReq
     * @param auth
     * @param optType
     * @author 
     * @date 
     */
    private void broadcast(BaseCodeReq baseCodeReq, String auth, ByteOptType optType) {
        broadcaster.pubEvent(baseCodeReq, auth, optType);
    }
}    

@Slf4j
public class Broadcaster {
    public static final String BYTE_BROADCAST_CHANNEL = "BYTE_BROADCAST_CHANNEL";
    private String nodeUniqCode;
    private RedisTemplate redisTemplate;
    @Autowired
    private ClassByteController classByteController;

    public Broadcaster(RedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
        nodeUniqCode = UUIDGenerator.generateString();
    }

    /**
     * @param baseCodeReq
     * @param auth
     * @param byteOptType
     * @author 
     * @date 
     */
    public void pubEvent(BaseCodeReq baseCodeReq, String auth, ByteOptType byteOptType) {
        String message = JSON.toJSONString(buildEventData(baseCodeReq, auth, byteOptType));
        redisTemplate.publish(BYTE_BROADCAST_CHANNEL, message);
        log.info("完成发送字节变更消息[{}]!", message);
    }

    /**
     * @param baseCodeReq
     * @param auth
     * @param byteOptType
     * @return com.xxxxx.common.byt.Broadcaster.EventData
     * @author 
     * @date 
     */
    private EventData buildEventData(BaseCodeReq baseCodeReq, String auth, ByteOptType byteOptType) {
        EventData eventData = (EventData) new EventData().setNodeUniqCode(nodeUniqCode)
                .setOptType(byteOptType)
                .setAuth(auth);
        BeanUtils.copyProperties(baseCodeReq, eventData);
        return eventData;
    }
}  
  
public enum ByteOptType {
    INSERT_LINE,
    REPLACE_LINE,
    CLEAR_LINE,
    RESET_CLASS,
    RESET_ALL_CLASSES;

    /**
     * @param value
     * @return com.xxxxx.common.byt.model.ByteOptType
     * @author 
     * @date 
     */
    public static ByteOptType getType(String value) {
        if (StringUtils.isBlank(value)) {
            return null;
        }
        for (ByteOptType e : ByteOptType.values()) {
            if (e.toString().equals(value)) {
                return e;
            }
        }
        return null;
    }

    /**
     * @param value
     * @return boolean
     * @author 
     * @date 
     */
    public static boolean isType(String value) {
        return getType(value) != null;
    }
}

@Configuration
@ConditionalOnClass(JedisTemplate.class)
public class JedisConfiguration {

    /**
     * @param jedisTemplate
     * @return com.xxxxx.common.byt.Broadcaster
     * @author 
     * @date 
     */
    @Bean
    public Broadcaster getJedisBroadcaster(@Autowired JedisTemplate jedisTemplate) {
        return new Broadcaster(jedisTemplate);
    }
}

订阅

节点在监听到事件之后,根据事件类型和内容分别做不同的处理:

public class ClassByteController {
    private Map<ByteOptType, Consumer<OptCode>> optHandler;
    
    @PostConstruct
    public void init() {
        log.info("ClassByteController auth : " + auth);
        optHandler = Maps.newHashMap();
        optHandler.put(ByteOptType.INSERT_LINE, optCode -> {
            InsertLineCodeReq req = new InsertLineCodeReq(optCode.getClassName(), optCode.getMethodName(),
                    optCode.getLineNumber(),
                    optCode.getCode());
            req.check();
            insertLineCode(req, optCode.getAuth(), false);
        });
        optHandler.put(ByteOptType.REPLACE_LINE, optCode -> {
            ReplaceLineCodeReq req = new ReplaceLineCodeReq(optCode.getClassName(), optCode.getMethodName(),
                    optCode.getLineNumber(),
                    optCode.getCode());
            req.check();
            replaceLineCode(req, optCode.getAuth(), false);
        });
        optHandler.put(ByteOptType.CLEAR_LINE, optCode -> {
            ClearLineCodeReq req = new ClearLineCodeReq(optCode.getClassName(), optCode.getMethodName(),
                    optCode.getLineNumber());
            req.check();
            clearLineCode(req, optCode.getAuth(), false);
        });
        optHandler.put(ByteOptType.RESET_CLASS, optCode -> {
            ResetClassCodeReq req = new ResetClassCodeReq(optCode.getClassName());
            req.check();
            resetClassCode(req, optCode.getAuth(), false);
        });
        optHandler.put(ByteOptType.RESET_ALL_CLASSES, optCode -> {
            resetAllClasses(optCode.getAuth(), false);
        });
    }  
    
    /**
     * @param optCode
     * @return com.xxxxx.common.byt.controller.ClassByteController
     * @author 
     * @date 
     */
    @Value(value = "${classByte.optCode:}")
    public void setOptCode(String optCode) {
        if (optHandler == null) {
            // 系统启动时注入的内容,忽略不处理,因为是历史处理过的
            return;
        }
        log.info("接收到操作码:{}", optCode);
        if (StringUtils.isBlank(optCode) || !StringUtil.simpleJudgeJsonObjectContent(optCode)) {
            return;
        }
        OptCode optCodeValue = JSONObject.parseObject(optCode, OptCode.class);
        if (StringUtils.isBlank(optCodeValue.getAuth())) {
            log.error("[" + optCode + "]auth不能为空!");
            return;
        }
        if (optCodeValue.getOptType() == null) {
            log.error("[" + optCode + "]操作类型异常!");
            return;
        }
        optHandler.get(optCodeValue.getOptType()).accept(optCodeValue);
    }  
} 

@Slf4j
public class Broadcaster {
    /**
     * @param message
     * @author minchin
     * @date 2021-04-29 10:22
     */
    public void subscribe(String message) {
        EventData eventData = JSON.parseObject(message, EventData.class);
        if (nodeUniqCode.equals(eventData.getNodeUniqCode())) {
            log.info("收到的字节变更消息[{}]是当前节点自己发出的,忽略掉!", message);
            return;
        }
        classByteController.setOptCode(message);
    }
}

@Configuration
@ConditionalOnClass(JedisTemplate.class)
public class JedisConfiguration {

    /**
     * @param jedisTemplate
     * @param broadcaster
     * @return com.xxxxx.common.redis.event.BaseRedisPubSub
     * @author 
     * @date 
     */
    @Bean
    public RedisPubSub getJedisBroadcasterPubSub(
            @Autowired JedisTemplate jedisTemplate,
            @Autowired Broadcaster broadcaster) {
        return new RedisPubSub(Broadcaster.BYTE_BROADCAST_CHANNEL, jedisTemplate) {
            @Override
            public void onMessage(String channel, String message) {
                logger.info("BroadcasterPubSub channel[{}] receive message[{}]", channel, message);
                broadcaster.subscribe(message);

            }
        };
    }
}

@Slf4j
public abstract class RedisPubSub implements BaseRedisPubSub {

    protected ExecutorService pool;
    private String channelName;
    private RedisTemplate redisTemplate;
    protected static final Logger logger = LoggerFactory.getLogger(RedisPubSub.class);

    public RedisPubSub(String channelName, RedisTemplate redisTemplate) {
        if (StringUtils.isBlank(channelName)) {
            throw new IllegalArgumentException("channelName required!");
        }
        Assert.notNull(redisTemplate, "redisTemplate required!");
        this.channelName = channelName;
        this.redisTemplate = redisTemplate;
    }

    public RedisPubSub(String channelName, RedisTemplate redisTemplate, ExecutorService pool) {
        this(channelName, redisTemplate);
        this.pool = pool;
    }

    @PostConstruct
    public void init() {
        if (getPool() == null) {
            setPool(Executors.newSingleThreadExecutor(
                    new ThreadFactoryBuilder().setNameFormat("redis-" + channelName + "-notify-pool-%d").build()));
        }
        getPool().execute(() -> {
            //堵塞,内部采用轮询方式,监听是否有消息,直到调用unsubscribe方法
            getRedisTemplate().subscribe(this, channelName);
        });
    }

    @PreDestroy
    public void destroy() {
        ThreadUtils.shutdown(pool, 10, TimeUnit.SECONDS);
    }

    /**
     * @return the pool
     */
    public ExecutorService getPool() {
        return pool;
    }

    /**
     * @param pool the pool to set
     */
    public void setPool(ExecutorService pool) {
        this.pool = pool;
    }


    public RedisTemplate getRedisTemplate() {
        return redisTemplate;
    }
}         

ClassByteControllersetOptCode方式上之所以加上@Value(value = "${classByte.optCode:}")注解,是因为我在设计系统时,不仅支持通过接口修改,还支持通过修改配置文件来修改(系统目前使用的配置文件系统,是支持热更新配置的),由于配置文件修改时,每个节点都会收到修改内容,所以处理时broadcase为false,即不分发
通过配置文件修改的方式,需要考虑一种场景:上次配置了数据,修改之后未(忘记)清理,下次启动时,@Value注入被执行,方法就会立刻执行(属于预期以外的修改),由于Spring是先注入属性,再初始化,所以在@Value生效执行setOptCodeinit方法还没被执行,也就是optHandler还未初始化,所以可以通过optHandler == null来过滤掉启动时的事件。

还原:还原到修改前的代码

当调试完之后,如果需要还原到修改前的代码,先从缓存里取出初始字节数组,再通过字节数组构造出class
还原分两种:

  • 还原指定的类

    public class ClassByteController {
      @PostMapping(value = "/resetClassCode")
      public void resetClassCode(@Validated @RequestBody ResetClassCodeReq resetClassCodeReq,
              @RequestHeader("auth") String auth,
              @RequestParam(required = false, defaultValue = "true") boolean broadcast) {
          auth(auth);
          try {
              Instrumentations.transformer(
                      new ResetClassCodeTransformer(),
                      Class.forName(resetClassCodeReq.getClassName()));
              if (broadcast) {
                  broadcast(resetClassCodeReq, auth, ByteOptType.RESET_CLASS);
              }
          } catch (Exception e) {
              throw new RuntimeException(e);
          }
      }
    }
    
    @Data
    @NoArgsConstructor
    @AllArgsConstructor
    public class ResetClassCodeReq extends BaseCodeReq {
      @NotBlank
      private String className;
      @Override
      public void check() {
          PredicateUtils.ifTrueThrowRuntimeException(StringUtils.isBlank(className), "className不能为空");
      }
    }
    
    
    @Slf4j
    @AllArgsConstructor
    public class ResetClassCodeTransformer extends AbstractResettableTransformer {
      @Override
      public byte[] doTransform(ClassLoader loader, String className, Class<?> classBeingRedefined,
              ProtectionDomain protectionDomain, byte[] classfileBuffer, CtClass cc) throws Exception {
          if (!INITIAL_CLASS_BYTE.containsKey(className)) {
              return null;
          }
          Instrumentations.getClassPool()
                  .makeClass(new ByteArrayInputStream(INITIAL_CLASS_BYTE.get(className).getBytes()));
          INITIAL_CLASS_BYTE.remove(className);
          return null;
      }
    }     

    前面讲过,JavassistCtClass对应一个Class,所以可以通过字节数组构造出CtClass对象,并替换掉ClassPool里缓存的CtClass对象,JavassistClassPoolmakeClass方法可以满足以上需求。

  • 还原所有被修改的类
    从缓存里取出所有的类,再一一循环执行还原

    public class ClassByteController {
      @PostMapping(value = "/resetAllClasses")
      public String resetAllClasses(@RequestHeader("auth") String auth,
              @RequestParam(required = false, defaultValue = "true") boolean broadcast) {
          auth(auth);
          try {
              String ret = AbstractResettableTransformer.resetAllClasses();
              if (broadcast) {
                  broadcast(new BaseCodeReq(), auth, ByteOptType.RESET_ALL_CLASSES);
              }
              return ret;
          } catch (Exception e) {
              throw new RuntimeException(e);
          }
      }
    }
    
    public abstract class AbstractResettableTransformer implements ClassFileTransformer {
      public static String resetAllClasses() {
          if (INITIAL_CLASS_BYTE.isEmpty()) {
              return Strings.EMPTY;
          }
          Class<?>[] classes = INITIAL_CLASS_BYTE.entrySet().stream()
                  .map(v -> v.getValue().clazz)
                  .collect(Collectors.toList())
                  .toArray(new Class<?>[INITIAL_CLASS_BYTE.size()]);
          String caches = StringUtils.join(INITIAL_CLASS_BYTE.keySet(), ",");
          Instrumentations.transformer(new ResetClassCodeTransformer(), classes);
          INITIAL_CLASS_BYTE.clear();
          return caches;
      }
    }   

“查”:下载class文件

修改之后,如果想看修改后的代码内容,可以将CtClass转为二进制数组,再将数组下载为文件。

public class ClassByteController {
    @GetMapping(value = "/getCode")
    public ResponseEntity<ByteArrayResource> getCode(HttpServletResponse response,
            @RequestParam String className,
            @RequestParam String auth) {
        auth(auth);
        byte[] bytes = Instrumentations.getClassBytes(className);
        String fileName = className.substring(className.lastIndexOf(".") + 1);
        ByteArrayResource resource = new ByteArrayResource(bytes);
        return ResponseEntity.ok()
                .header(HttpHeaders.CONTENT_DISPOSITION,
                        "attachment;filename=" + fileName + ".class")
                .contentType(MediaType.APPLICATION_OCTET_STREAM)
                .contentLength(bytes.length)
                .body(resource);                
    }
}

public class Instrumentations {
    public static byte[] getClassBytes(String className) {
        try {
            CtClass cc = getClassPool().get(className);
            return cc.toBytecode();
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}

总结

在Java5引入Instrumentation之后,Java允许运行时改变字节,但是原始的Instrumentation Api需要开发者对字节码方面有深入的了解,ASM、Javassist和ByteBuddy等字节码操作库,提供了更简化的API,让开发者不再需要对字节码方面有深入的了解,本项目基于以上组件实现了在线热更新代码功能,功能包含对Class增删改查

以上是关于无需重启-在线修改代码的主要内容,如果未能解决你的问题,请参考以下文章

spring boot在Eclipse中,修改代码后无需重启就生效的配置

代码在线比较

无需重启 在Linux下热添加或移除SCSI硬盘

定时任务报表平台

定时任务报表平台

Eclipse中Tomcat 修改后台代码即时生效