背景
当系统遇到异常情况时,想要加上日志打印下关键信息,或者改下逻辑代码,但又不想重启,因为重启太麻烦太耗时且可能会破坏现场
,甚至有些场景在测试环境无法模拟出来导致无法复现。这时候就希望能在不重启的情况下更新代码并立即生效。
目标:对代码的增删改查,并且实时热更新。
增
:插入代码。删
:删除代码。改
:替换代码。查
:下载指定类的class
文件 ,如果是修改过的,那下载的就是修改后的class
文件。还原
:还原回修改前的代码。
阿里有个Arthas
支持在线问题诊断,也支持在线修改代码,但是仍然有局限性:修改代码的步骤很繁琐,需要登录服务器操作,并且一次只能更新一个服务节点的代码,如果部署了多个节点,就需要每台都登录操作。
概念
Instrumentation
使用 Insrumentation
,开发者可以构建一个独立于应用程序的代理程序(Agent
),监测和协助运行在JVM上的程序,甚至可以替换和修改某些类的定义。简单的来说开发者使用Instrumentation
可以实现一种虚拟机级别的AOP
实现。Instrumentation
的最大作用,就是类定义动态改变和操作
。程序运行时,通过-javaagent
参数指定一个特定的jar文件
来启动Instrumentation的代理程序
。其实这个对很多人来说不陌生:xmind
, idea
永久破解都使用了agent
,Mockito
等Mock
类库也用到了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-Class
或Agent-Class
。
4. 打包&运行
通过Maven的org.apache.maven.plugins
和maven-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字节码的处理,有很多类库,如bcel
,asm
。不过这些都需要直接跟虚拟机指令
打交道。如果你不想了解虚拟机指令,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
这四个类:ClassPool
是CtClass
对象的容器,一张保存CtClass
信息的HashTable
,key为类名
,value为CtClass对象
,它按需读取类文件来构造CtClass
对象,并且缓存CtClass
对象以便以后使用。CtClass
是一个class文件在代码中的抽象表现形式
,对CtClass的修改相当于对class文件的修改。CtMethod
、CtField
对应的是类中的方法和属性。
配合前面的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. 备份原始的类字节
这一步是为了后续还原
做准备,有时候我们可能只是临时增加下调试代码,调试完之后还要还原代码。
ClassFileTransformer
的transform
的方法如果返回null
,即表示不做增强,也会将class
的字节码还原,但是这种做法会有误伤
,会将class还原到最原始
的状态,如果有其他类/插件
也做了增强,比如有个自定义的agent
,这些增强也都会被还原。Javassist
的CtClass
类的detach
方法,也会清除Javassist
对代码的修改,detach
会从ClassPool
中清理掉CtClass
的缓存,而Javassist
中CtClass
就对应一个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;
,代码会插在原先代码的前面,并且不会换行,同样的清理行代码,也只是把清理的那一行变成空行
,下一行代码并不会上移。
以上代码是底层操作字节的代码,现在需要提供一个在线修改代码的入口,这里采用了提供接口的方案。
方案需要考虑几个点:
- 安全:接口不能随便被调用。
- 多节点:业务服务部署在多个节点上,接收到变更请求的节点要把数据分发到其他节点。
接口
先看下接口代码:
@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); }"
}
接口内容分三步:
- 安全检查。
- 修改字节。
- 分发。
安全
安全主要从两方面入手:
- 开关:常规时间将开关配置成
关闭
,也就是禁止修改,要修改时再打开
,修改完之后再关闭
。 - 鉴权令牌:在
开关
打开的前提下,调用接口还要传一个令牌来比对,令牌放在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;
}
}
ClassByteController
的setOptCode
方式上之所以加上@Value(value = "${classByte.optCode:}")
注解,是因为我在设计系统时,不仅支持通过接口修改
,还支持通过修改配置文件来修改
(系统目前使用的配置文件系统,是支持热更新配置的),由于配置文件修改时,每个节点都会收到修改内容,所以处理时broadcase为false
,即不分发
。
通过配置文件修改的方式,需要考虑一种场景:上次配置了数据,修改之后未(忘记)清理,下次启动时,@Value
注入被执行,方法就会立刻执行(属于预期以外的修改),由于Spring
是先注入属性
,再初始化
,所以在@Value
生效执行setOptCode
时init
方法还没被执行,也就是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; } }
前面讲过,
Javassist
的CtClass
对应一个Class
,所以可以通过字节数组构造出CtClass
对象,并替换掉ClassPool
里缓存的CtClass
对象,Javassist
的ClassPool
的makeClass
方法可以满足以上需求。还原所有被修改的类
从缓存里取出所有的类,再一一循环执行还原
。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
的增删改查
。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。