0%

ClassViewer的介绍及实现

ClassViewer是我最近开发的一个用于展示jvm class字节码的小工具。它是一个单纯的静态网页,完全使用浏览器端的Javascript开发。之所以开发这款工具,是因为我在开发ToyJVM的时候,需要常常校验class文件某一部分的字节码, 所以如果一款工具能够很方便的显示class文件各个部分的信息和字节码,对于ToyJVM的开发将会是一个非常大的帮助。

在开始写代码之前调研了一些类似的产品,主要有jdk自带的javap、国外的Java-Class-Viewer以及国人开发的classpy,它们都是非常不错的class文件分析工具,但是也存在着一些算不上缺陷的小问题。所以最终还是决定自己写一个适合自己小工具,同时也加深下class结构的理解。

在调研了目前的产品后,我也更加清晰了自己的目标。首先它的受众应该是有兴趣研究jvm的程序员,而它应该有这些特性:

  • 不依赖于特定操作系统平台
    它应该具备基本的跨平台的能力,因为程序员的Mac和Linux使用率很高。
  • 无需复杂的安装和编译,无需用户有特定的知识背景
    我不太希望用户拿到我的代码后,还需要安装相应的环境、了解一堆无关知识。

最终实现出来的工具是这样的:

welcome
show

技术选型

基于浏览器来实现这个工具是非常符合我的需求的。首先网页跨平台能力是毋庸置疑的,只要有浏览器的电脑就可以运行这个工具。
其次,它不需要任何的编译和安装,也不需要用户有任何的背景知识才能使用。只要在Github下载好源码,在浏览器中打开index.html就可以运行使用。或者,直接访问Github Page。所以我在开发这个工具的时候完全没有使用后台,也避免使用了各种前端工具链,尽可能的降低使用的复杂度。
我在开发中大量使用了ES6的特性,比如let、模板字符串、类等和ES7中的async。这是因为我实在是对ES5及其之前的js语法提不起太多兴趣,用起来实在是不爽。好在ES6提供了许多语法糖,解决了很多问题,用起来也算顺手。
也正因为我使用了一些ES6的特性,导致这个工具在低版本的浏览器上无法work。这个问题后期也没打算解决,因为我认为程序员的浏览器应该都会支持这些特性。

使用JavaScript开发还有个问题就是,js里面没有int、short和无符号类型,所有数字都是统一使用Number类型表示了。而对jvm的分析需要严格地按读取每个字节来说,是个非常头疼的问题。
好在ES6提供了ArrayBuffer和DataView,可以方便的实现这些功能。

Class文件的解析

在官方的JVM S8标准的第四章中,给出了Class文件的格式结构。我们可以根据jvm标准来严格读取字节。

ClassFile {
u4 magic;
u2 minor_version;
u2 major_version;
u2 constant_pool_count;
cp_info constant_pool[constant_pool_count-1];
u2 access_flags;
u2 this_class;
u2 super_class;
u2 interfaces_count;
u2 interfaces[interfaces_count];
u2 fields_count;
field_info fields[fields_count];
u2 methods_count;
method_info methods[methods_count];
u2 attributes_count;
attribute_info attributes[attributes_count];
}

以上代码定义了从上到下依次每部分的字节大小。
如果仔细看下这个结构定义,发现大部分数据都以u2、u4定义。其中u4、u2分别代表该部分占据4个字节和2个字节。比如class文件的前4字节,代表了magic这部分。接下来的2个字节代表了minor_version。对于这种类型的数据,我们只要简单读取对应数目的字节就可以了。
但是还有两部分特殊的定义: cp_infoattribute_info。它们都属于复合结构,可以理解为struct
其中cp_info constant_pool[constant_pool_count-1]代表常量池,共有constant_pool_count-1项,每一项都是一个cp_info结构的数据。cp_info的结构定义如下

cp_info{
u1 tag;
data..
}

cp_info包含多种类型的数据,比如CONSTANT_Class_infoCONSTANT_String_Info等,在jvms8中定义了14种cp_info。每个cp_info的第一个字节都以1个字节的tag开头,代表了这个cp_info的类型。接下来每种cp_info各自的数据都不一样,比如CONSTANT_Class_infoCONSTANT_Integer_info的定义如下:

CONSTANT_Class_info {
u1 tag;
u2 name_index;
}

CONSTANT_Integer_info {
u1 tag;
u4 bytes;
}

CONSTANT_Class_info代表在tag后,有2个字节的name_index,就读取结束了。而CONSTANT_Integer_info在tag后有4个字节才能读取结束。
对于这种常量池的解析来说,一种最直观的方法是可以这么做:

for(int i = 1; i < constant_pool_count; i++){
u1 tag = read 1 byte
switch(tag){
case CONSTANT_Class_info:
read 2 byte
case CONSTANT_Integer_info:
read 4 byte
...
}
}

由于我们后期要用到cp_info的每个字段,所以需要把每个cp_info的定义表示为一个类。使用工厂方法来根据tag生成相应的对象,将读取的部分包含在各自类的read方法中。代码如下:

cp_info cpInfoFactory(u1 tag){
switch(tag){
case CONSTANT_Class_info:
return new ConstClassInfo();
case CONSTANT_Integer_info:
return new ConstClassInfo();
...
}
}

cp_info info = cpInfoFactory(tag)
info.read()

目前这样看起来似乎我们只需要为cp_info定义14种不同的类,然后在类中为每个不同的cp_info定义不同的读取方法即可。
我目前在ToyJVM中是这么做的,但是这么做有个问题就是太繁琐了。我们需要为每个类定义不同的属性,然后在read方法中为这些属性读取不同的字节,极易出现编写错误。一旦一个字节读取错误,就会导致后面的字节全部错误。
由于ClassViewer采用了js实现,可以使用eval动态定义变量。我采用了这么一种做法,来简化constant_pool的读取:

class BaseCpInfo {
read(reader){
for (let i = 0; i < this.properties.length; i += 2) {
let len = this.properties[i]
let property = this.properties[i + 1]
eval(`this.${property} = reader.read(${len})`)
}
}
}
class ConstClassInfo extends BaseCpInfo {
constructor() {
this.properties = [
2, 'name_index',
]
}
}

class ConstFieldRefInfo extends BaseCpInfo {
constructor() {
this.properties = [
2, "class_index",
2, "name_and_type_index",
]
}
}

首先在这里我定义了一个BaseCpInfo,作为所有cp_info的类。在子类中,只需要在this.properties定义相关字段的名称和字节长度就可以。在read的时候,使用父类公共的read方法,使用eval为每个子类读取字段内容。
这里需要注意的是,this.properties我使用了数组来实现,而非字典。是因为这些属性是必须严格有序的,不可以颠倒顺序。而字典常常使用Hash来实现,并不保证顺序。

使用这种方法子类可以不用写read方法,减少了出错的可能。对于constant_pool来说,大概可以少写14个read方法。后面attributes也采用了同样的策略,也可以少写十几个方法。这对于开发效率的提升程度还是非常客观的。

这种写法代码写起来很爽,只是有个缺点就是eval的速度实在太慢,会极大降低运行效率。但是因为写起来实在是太爽了,只要从jvm标准中把每个类型的字节信息抄过来,定义一个公共的read方法就可以了。所以在后面我也没打算把它改写成非eval方式的读写,或许有可能写一个codegen脚本,但是都是后话了。

字节显示区域

在预览图中可以看到,中间有一块区域用于显示Hex字节码信息,这是一块超大的排列整齐的方格区域,用普通的div+css显然没法很好的实现。在我的实现中,使用了canvas绘制了字节码区域。具体代码可以参考byte_painter.js
除了正常的绘制之外,还实现了部分区域高亮以及滚动到指定区域的功能。

左侧栏

左侧栏实际上就是直接调用了ztree。在class_to_ztree.js中,将读取到的class文件转换成了ztree的node节点。

为什么要特意提下左侧栏。哈哈,因为我是jetbtrains粉,特意从jetbrians官网上找了IDE中的符号图标,替换了ztree的默认样式,算是对jetbrains的一个小小的致敬吧!

TODO

目前ClassViewer的初版已经发布,可以直接通过Github Page查看页面或者直接在Github上查看源码。接下来我会继续把开发重心放在ToyJVM上,但同时也会抽时间继续优化ClassViewer的使用体验。

接下的开发方向主要会集中在以下几点:

  • Method的字节码信息展示
    当用户点击了method的时候,直接在中间区域显示对应的jvm命令。这部分需要对jvm的命令进行解析,相应的功能我在ToyJVM中做过一遍,所以这个会是首选的实现功能。
  • Index之间跳转
    jvm中很多部分都是直接给了一个index。比如this_class,就是给了一个2个字节的index,这个index表示constant_pool某一项的索引,这一项必须是CONSTANT_Class_info类型的。诸如此类,所以打算做一个可以根据index跳转对应真实数据的功能。
  • 支持jar包的解析
    故名思意,可以直接解析jar包。jar包可以理解成一个zip压缩包,里面是一堆的class文件。所以这里我可能要借助第三方的js的zip解析包来实现。
  • jvm s9的支持
    目前的ClassViewer是根据jvms8来实现的,接下来会跟进jvm s9的标准。
  • Java modified UTF8的解析
    JVM对标准UTF8进行了一些轻微的修改,称为M-UTF8。我目前的实现都是直接使用标准的UTF8来解析的,这么做可以适合大部分的场景,对于一些特定字符会有问题,接下来会对这部分进行处理。

有感兴趣的可以和我一起跟进这个项目。

总结

开发这个工具的最大目的还是自用,实现一个适合自己和大部分人的Class字节码工具,除此之外也是对JVM class的进一步的研究。这个项目如果继续深入下去,甚至可以使用js来实现一个玩具版的虚拟机。但是对我来说没必要了,我会把对JVM的实现都放在ToyJVM中。

我在开发过程中最初打算尽可能地不依赖任何三方库,以便界面和功能上更贴近自己的体验。但是个人的能力始终有限,把时间花在工具的核心内容之外,实在有些得不偿失。所以还是用了一些开源项目,比如使用ztree实现了左侧信息栏,iziModal实现了遮罩层,也用了fontawesome的一些图标来美化界面。最后也非常感谢这些项目对ClassViewer的帮助。

cyhone wechat