5

无需重启-在线更新代码

 3 years ago
source link: https://segmentfault.com/a/1190000040027690
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

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

  1. :插入代码。
  2. :删除代码。
  3. :替换代码。
  4. :下载指定类的class文件 ,如果是修改过的,那下载的就是修改后的class文件。
  5. 还原:还原回修改前的代码。

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();
    }

    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
public abstract class AbstractResettableTransformer implements ClassFileTransformer {
    @Override
    public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
            ProtectionDomain protectionDomain, byte[] classfileBuffer) {
        try {
            className = convertClassName(className);
            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
@AllArgsConstructor
@Data
public class ReplaceLineCodeTransformer extends AbstractResettableTransformer {

    private String methodName;
    private Integer lineNumber;
    private String 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. 修改字节。

安全主要从两方面入手:

  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增删改查


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK