返回介绍

9.3 实战:自己动手实现远程执行功能

发布于 2024-08-16 21:55:05 字数 9789 浏览 0 评论 0 收藏 0

不知道读者在做程序维护的时候是否遇到过这类情形:排查问题的过程中,想查看内存中的一些参数值,却又没有方法把这些值输出到界面或日志中,又或者定位到某个缓存数据有问题,但缺少缓存的统一管理界面,不得不重启服务才能清理这个缓存。类似的需求有一个共同的特点,那就是只要在服务中执行一段程序代码,就可以定位或排除问题,但就是偏偏找不到可以让服务器执行临时代码的途径,这时候就会希望Java服务器中也有提供类似Groovy Console的功能。

JDK 1.6之后提供了Compiler API,可以动态地编译Java程序,虽然这样达不到动态语言的灵活度,但让服务器执行临时代码的需求就可以得到解决了。在JDK 1.6之前,也可以通过其他方式来做到,譬如写一个JSP文件上传到服务器,然后在浏览器中运行它,或者在服务端程序中加入一个BeanShell Script、JavaScript等的执行引擎(如Mozilla Rhino[1])去执行动态脚本。在本章的实战部分,我们将使用前面学到的关于类加载及虚拟机执行子系统的知识去实现在服务端执行临时代码的功能。

9.3.1 目标

首先,在实现“在服务端执行临时代码”这个需求之前,先来明确一下本次实战的具体目标,我们希望最终的产品是这样的:

不依赖JDK版本,能在目前还普遍使用的JDK中部署,也就是使用JDK 1.4~JDK 1.7都可以运行。

不改变原有服务端程序的部署,不依赖任何第三方类库。

不侵入原有程序,即无须改动原程序的任何代码,也不会对原有程序的运行带来任何影响。

考到BeanShell Script或JavaScript等脚本编写起来不太方便,“临时代码”需要直接支持Java语言。

“临时代码”应当具备足够的自由度,不需要依赖特定的类或实现特定的接口。这里写的是“不需要”而不是“不可以”,当“临时代码”需要引用其他类库时也没有限制,只要服务端程序能使用的,临时代码应当都能直接引用。

“临时代码”的执行结果能返回到客户端,执行结果可以包括程序中输出的信息及抛出的异常等。

看完上面列出的目标,你觉得完成这个需求需要做多少工作呢?也许答案比大多数人所想的都要简单一些:5个类,250行代码(含注释),大约一个半小时左右的开发时间就可以了,现在就开始编写程序吧!

[1]Rhino站点:http://www.mozilla.org/rhino/,Rhino已被收编入JDK 1.6中。

9.3.2 思路

在程序实现的过程中,我们需要解决以下3个问题:

如何编译提交到服务器的Java代码?

如何执行编译之后的Java代码?

如何收集Java代码的执行结果?

对于第一个问题,我们有两种思路可以选择,一种是使用tools.jar包(在Sun JDK/lib目录下)中的com.sun.tools.javac.Main类来编译Java文件,这其实和使用Javac命令编译是一样的。这种思路的缺点是引入了额外的JAR包,而且把程序“绑死”在Sun的JDK上了,要部署到其他公司的JDK中还得把tools.jar带上(虽然JRockit和J9虚拟机也有这个JAR包,但它总不是标准所规定必须存在的)。另外一种思路是直接在客户端编译好,把字节码而不是Java代码传到服务端,这听起来好像有点投机取巧,一般来说确实不应该假定客户端一定具有编译代码的能力,但是既然程序员会写Java代码去给服务端排查问题,那么很难想象他的机器上会连编译Java程序的环境都没有。

对于第二个问题,简单地一想:要执行编译后的Java代码,让类加载器加载这个类生成一个Class对象,然后反射调用一下某个方法就可以了(因为不实现任何接口,我们可以借用一下Java中人人皆知的“main()”方法)。但我们还应该考虑得更周全些:一段程序往往不是编写、运行一次就能达到效果,同一个类可能要反复地修改、提交、执行。另外,提交上去的类要能访问服务端的其他类库才行。还有,既然提交的是临时代码,那提交的Java类在执行完后就应当能卸载和回收。

最后的一个问题,我们想把程序往标准输出(System.out)和标准错误输出(System.err)中打印的信息收集起来,但标准输出设备是整个虚拟机进程全局共享的资源,如果使用System.setOut()/System.setErr()方法把输出流重定向到自己定义的PrintStream对象上固然可以收集输出信息,但也会对原有程序产生影响:会把其他线程向标准输出中打印的信息也收集了。虽然这些并不是不能解决的问题,不过为了达到完全不影响原程序的目的,我们可以采用另外一种办法,即直接在执行的类中把对System.out的符号引用替换为我们准备的PrintStream的符号引用,依赖前面学习的知识,做到这一点并不困难。

9.3.3 实现

在程序实现部分,我们主要看一下代码及其注释。首先看看实现过程中需要用到的4个支持类。第一个类用于实现“同一个类的代码可以被多次加载”这个需求,即用于解决9.3.1节中列举的第2个问题的HotSwapClassLoader,具体程序如代码清单9-3所示。

代码清单9-3 HotSwapClassLoader的实现

/**
*为了多次载入执行类而加入的加载器<br>
*把defineClass方法开放出来,只有外部显式调用的时候才会使用到loadByte方法
*由虚拟机调用时,仍然按照原有的双亲委派规则使用loadClass方法进行类加载
*
*@author zzm
*/
public class HotSwapClassLoader extends ClassLoader{
public HotSwapClassLoader(){
super(HotSwapClassLoader.class.getClassLoader());
}
public Class loadByte(byte[]classByte){
return defineClass(null,classByte,0,classByte.length);
}
}

HotSwapClassLoader所做的事情仅仅是公开父类(即java.lang.ClassLoader)中的protected方法defineClass(),我们将会使用这个方法把提交执行的Java类的byte[]数组转变为Class对象。HotSwapClassLoader中并没有重写loadClass()或findClass()方法,因此如果不算外部手工调用loadByte()方法的话,这个类加载器的类查找范围与它的父类加载器是完全一致的,在被虚拟机调用时,它会按照双亲委派模型交给父类加载。构造函数中指定为加载HotSwapClassLoader类的类加载器作为父类加载器,这一步是实现提交的执行代码可以访问服务端引用类库的关键,下面我们来看看代码清单9-3。

第二个类是实现将java.lang.System替换为我们自己定义的HackSystem类的过程,它直接修改符合Class文件格式的byte[]数组中的常量池部分,将常量池中指定内容的CONSTANT_Utf8_info常量替换为新的字符串,具体代码如代码清单9-4所示。ClassModifier中涉及对byte[]数组操作的部分,主要是将byte[]与int和String互相转换,以及把对byte[]数据的替换操作封装在代码清单9-5所示的ByteUtils中。

代码清单9-4 ClassModifier的实现

/**
*修改Class文件,暂时只提供修改常量池常量的功能
*@author zzm
*/
public class ClassModifier{
/**
*Class文件中常量池的起始偏移
*/
private static final int CONSTANT_POOL_COUNT_INDEX=8;
/**
*CONSTANT_Utf8_info常量的tag标志
*/
private static final int CONSTANT_Utf8_info=1;
/**
*常量池中11种常量所占的长度,CONSTANT_Utf8_info型常量除外,因为它不是定长的
*/
private static final int[]CONSTANT_ITEM_LENGTH={-1,-1,-1,5,5,9,9,3,3,5,5,5,5};
private static final int u1=1;
private static final int u2=2;
private byte[]classByte;
public ClassModifier(byte[]classByte){
this.classByte=classByte;
}
/**
*修改常量池中CONSTANT_Utf8_info常量的内容
*@param oldStr修改前的字符串
*@param newStr修改后的字符串
*@return修改结果
*/
public byte[]modifyUTF8Constant(String oldStr,String newStr){
int cpc=getConstantPoolCount();
int offset=CONSTANT_POOL_COUNT_INDEX+u2;
for(int i=0;i<cpc;i++){
int tag=ByteUtils.bytes2Int(classByte,offset,u1);
if(tag==CONSTANT_Utf8_info){
int len=ByteUtils.bytes2Int(classByte,offset+u1,u2);
offset+=(u1+u2);
String str=ByteUtils.bytes2String(classByte,offset,len);
if(str.equalsIgnoreCase(oldStr)){
byte[]strBytes=ByteUtils.string2Bytes(newStr);
byte[]strLen=ByteUtils.int2Bytes(newStr.length(),u2);
classByte=ByteUtils.bytesReplace(classByte,offset-u2,u2,strLen);
classByte=ByteUtils.bytesReplace(classByte,offset,len,strBytes);
return classByte;
}else{
offset+=len;
}
}else{
offset+=CONSTANT_ITEM_LENGTH[tag];
}
}
return classByte;
}
/**
*获取常量池中常量的数量
*@return常量池数量
*/
public int getConstantPoolCount(){
return ByteUtils.bytes2Int(classByte,CONSTANT_POOL_COUNT_INDEX,u2);
}
}

代码清单9-5 ByteUtils的实现

/**
*Bytes数组处理工具
*@author
*/
public class ByteUtils{
public static int bytes2Int(byte[]b,int start,int len){
int sum=0;
int end=start+len;
for(int i=start;i<end;i++){
int n=((int)b[i])&0xff;
n<<=(--len)*8;
sum=n+sum;
}
return sum;
}
public static byte[]int2Bytes(int value,int len){
byte[]b=new byte[len];
for(int i=0;i<len;i++){
b[len-i-1]=(byte)((value>>8*i)&0xff);
}
return b;
}
public static String bytes2String(byte[]b,int start,int len){
return new String(b,start,len);
}
public static byte[]string2Bytes(String str){
return str.getBytes();
}
public static byte[]bytesReplace(byte[]originalBytes,int offset,int len,byte[]replaceBytes){
byte[]newBytes=new byte[originalBytes.length+(replaceBytes.length-len)];
System.arraycopy(originalBytes,0,newBytes,0,offset);
System.arraycopy(replaceBytes,0,newBytes,offset,replaceBytes.length);
System.arraycopy(originalBytes,offset+len,newBytes,offset+replaceBytes.length,originalBytes.length-offset-len);
return newBytes;
}
}

经过ClassModifier处理后的byte[]数组才会传给HotSwapClassLoader.loadByte()方法进行类加载,byte[]数组在这里替换符号引用之后,与客户端直接在Java代码中引用HackSystem类再编译生成的Class是完全一样的。这样的实现既避免了客户端编写临时执行代码时要依赖特定的类(不然无法引入HackSystem),又避免了服务端修改标准输出后影响到其他程序的输出。下面我们来看看代码清单9-4和代码清单9-5。

最后一个类就是前面提到过的用来代替java.lang.System的HackSystem,这个类中的方法看起来不少,但其实除了把out和err两个静态变量改成使用ByteArrayOutputStream作为打印目标的同一个PrintStream对象,以及增加了读取、清理ByteArrayOutputStream中内容的getBufferString()和clearBuffer()方法外,就再没有其他新鲜的内容了。其余的方法全部都来自于System类的public方法,方法名字、参数、返回值都完全一样,并且实现也是直接转调了System类的对应方法而已。保留这些方法的目的,是为了在Sytem被替换成HackSystem之后,执行代码中调用的System的其余方法仍然可以继续使用,HackSystem的实现如代码清单9-6所示。

代码清单9-6 HackSystem的实现

/**
*为JavaClass劫持java.lang.System提供支持
*除了out和err外,其余的都直接转发给System处理
*
*@author zzm
*/
public class HackSystem{
public final static InputStream in=System.in;
private static ByteArrayOutputStream buffer=new ByteArrayOutputStream();
public final static PrintStream out=new PrintStream(buffer);
public final static PrintStream err=out;
public static String getBufferString(){
return buffer.toString();
}
public static void clearBuffer(){
buffer.reset();
}
public static void setSecurityManager(final SecurityManager s){
System.setSecurityManager(s);
}
public static SecurityManager getSecurityManager(){
return System.getSecurityManager();
}
public static long currentTimeMillis(){
return System.currentTimeMillis();
}
public static void arraycopy(Object src,int srcPos,Object dest,int destPos,int length){
System.arraycopy(src,srcPos,dest,destPos,length);
}
public static int identityHashCode(Object x){
return System.identityHashCode(x);
}
//下面所有的方法都与java.lang.System的名称一样
//实现都是字节转调System的对应方法
//因版面原因,省略了其他方法
}

至此,4个支持类已经讲解完毕,我们来看看最后一个类JavaClassExecuter,它是提供给外部调用的入口,调用前面几个支持类组装逻辑,完成类加载工作。JavaClassExecuter只有一个execute()方法,用输入的符合Class文件格式的byte[]数组替换java.lang.System的符号引用后,使用HotSwapClassLoader加载生成一个Class对象,由于每次执行execute()方法都会生成一个新的类加载器实例,因此同一个类可以实现重复加载。然后,反射调用这个Class对象的main()方法,如果期间出现任何异常,将异常信息打印到HackSystem.out中,最后把缓冲区中的信息作为方法的结果返回。JavaClassExecuter的实现代码如代码清单9-7所示。

代码清单9-7 JavaClassExecuter的实现

/**
*JavaClass执行工具
*
*@author zzm
*/
public class JavaClassExecuter{
/**
*执行外部传过来的代表一个Java类的byte数组<br>
*将输入类的byte数组中代表java.lang.System的CONSTANT_Utf8_info常量修改为劫持后的HackSystem类
*执行方法为该类的static main(String[]args)方法,输出结果为该类向System.out/err输出的信息
*@param classByte代表一个Java类的byte数组
*@return执行结果
*/
public static String execute(byte[]classByte){
HackSystem.clearBuffer();
ClassModifier cm=new ClassModifier(classByte);
byte[]modiBytes=cm.modifyUTF8Constant("java/lang/System","org/fenixsoft/classloading/execute/HackSystem");
HotSwapClassLoader loader=new HotSwapClassLoader();
Class clazz=loader.loadByte(modiBytes);
try{
Method method=clazz.getMethod("main",new Class[]{String[].class});
method.invoke(null,new String[]{null});
}catch(Throwable e){
e.printStackTrace(HackSystem.out);
}
return HackSystem.getBufferString();
}
}

9.3.4 验证

远程执行功能的编码到此就完成了,接下来就要检验一下我们的劳动成果了。如果只是测试的话,那么可以任意写一个Java类,内容无所谓,只要向System.out输出信息即可,取名为TestClass,同时放到服务器C盘的根目录中。然后,建立一个JSP文件并加入如代码清单9-8所示的内容,就可以在浏览器中看到这个类的运行结果了。

代码清单9-8 测试JSP

<%@page import="java.lang.*"%>
<%@page import="java.io.*"%>
<%@page import="org.fenixsoft.classloading.execute.*"%>
<%
InputStream is=new FileInputStream("c:/TestClass.class");
byte[]b=new byte[is.available()];
is.read(b);
is.close();
out.println("<textarea style='width:1000;height=800'>");
out.println(JavaClassExecuter.execute(b));
out.println("</textarea>");
%>

当然,上面的做法只是用于测试和演示,实际使用这个JavaExecuter执行器的时候,如果还要手工复制一个Class文件到服务器上就没有什么意义了。笔者给这个执行器写了一个“外壳”,是一个Eclipse插件,可以把Java文件编译后传输到服务器中,然后把执行器的返回结果输出到Eclipse的Console窗口里,这样就可以在有灵感的时候随时写几行调试代码,放到测试环境的服务器上立即运行了。虽然实现简单,但效果很不错,对调试问题也非常有用,如图9-4所示。

图 9-4 JavaClassExecuter的使用

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文