Javac黑客指南

开发 后端
这篇文章是介绍修改java编译器的。其中包含对Java编译器的介绍,以及两个例子的实现:一个简单的hello world 和一个重写AST(抽象语法树)的插件。

这篇文章英文的原文在:http://scg.unibe.ch/archive/projects/Erni08b.pdf. 做毕业设计报告时,老师要求必须翻译一篇外文,于是很认真的翻译了一下,也算是为开源做一点小贡献。翻译如下:

Javac黑客指南

David Erni and Adrian Kuhn

University of Bern, March 2008

0. 摘要:

这篇文章是介绍修改java编译器的。其中包含对Java编译器的介绍,以及两个例子的实现:一个简单的hello world 和一个重写AST(抽象语法树)的插件。

1. 介绍

随着Java 6的发布,java编译器已经有了开源的版本了。开源的编译器是OpenJDK项目的一部分,可以从Java编译器小组的网站下载 http://www.openjdk.org/groups/compiler/ 。然而就这篇文档的例子来说,任何Java 6的版本都是可用的,因为这些例子并不会重新编译编译器,他们只是扩展编译器的功能。

这篇文章介绍了Java编译器的内在实现。首先我们给出java编译器所包含的编译步骤,然后我们在编写两个例子。两个例子都使用到了编译器里面的插件机制,也就是JSR269所描述的机制。然而,这两个例子却超出了JSR269的范围。把JSR对象和编译器对接,我们实现了AST的重写。我们的例子里面,没有使用assertion(断言)语句,而使用了if-throw语句。

2. Java编译器的内核

这个部分概括了OpenJDK里面的java编译器的编译步骤和对应的注释。这个小节包含了一个简短的介绍。

编译的过程是由定义在com.sun.tools.javac.main里面的Java Compiler类来决定的。当编译器以默认的编译参数编译时,它会执行以下步骤:

a) Parse: 读入一堆*.java源代码,并且把读进来的符号(Token)映射到AST节点上去。

b) Enter: 把类的定义放到符号表(Symbol Table)中去。

c) Process annotations: 可选的。处理编译单元(compilation units)里面所找到的标记(annotation)。

d) Attribute: 为AST添加属性。这一步包含名字解析(name resolution),类型检测(type checking)和常数折叠(constant fold)。

e) Flow: 为前面得到的AST执行流分析(Flow analysis)操作。这个步骤包含赋值(assignment)的检查和可执行性(reachability)的检查。

f) Desugar: 重写AST, 并且把一些复杂的语法转化成一般的语法。

g) Generate: 生成源文件或者类文件。

wps_clip_image-12054_thumb

2.1 Parse

想要Parse文件,编译器要用到com.sun.tools.javac.parser.*里面的类。作为***步,词法分析器(lexical analyzer)把输入的字符流(character sequence)映射成一个符号流(token sequence)。然后Parser再把生成的符号流映射成一个抽象语法树(AST)

2.2 Enter

在这个步骤中,编译器会找到当前范围(enclosing scope)中发现的所有的定义(definitions),并且把这些定义注册成符号(symbols)。Enter这个步骤又分为以下两个阶段:

在***个阶段,编译器会注册所有类的符号,并且把这写符号和相应的范围(scope)联系在一起。实现方法是使用一个Visitor(访问者)类,由上而下的遍历AST,访问所有的类,包括类里面的内部类。Enter给每一个类的符号都添加了一个MemberEnter对象,这个对象是由第二个阶段来调用的

在第二个阶段中,这些类被MemberEnter对象所完成(completed,即完成类的成员变量的Enter)。首先,MemberEnter决定一个类的参数,父类和接口。然后这些符号被添加进了类的范围中。不像前一个步骤,这个步骤是懒惰执行的。类的成员只有在被访问时,才加入类的定义中的。这里的实现,是通过安装一个完成对象(member object)到类的符号中。这些对象可以在需要时调用member-enter

***,enter把所有的顶层类(top-level classes)放到一个todo-queue中,

2.3 Process Annotations

如果存在标记处理器,并且编译参数里面指定要处理标记,那么这个过程就会处理在某个编译单元里面的标记。JSR269定义了一个接口,可以用来写这种Annotation处理插件。然而,这个接口的功能非常有限,并且不能用Collective Behavior扩展这种语言。主要的限制是JSR269不提供子方法的反射调用。

2.4 Attribute

为Enter阶段生成的所有AST添加属性。应当注意,Attribte可能会需要额外的文件被解析(Parse),通过SourceCompleter加入到符号表中。

大多数的环境相关的分析都是发生在这个阶段的。这些分析包括名称解析,类型检查,常数折叠。这些都是子任务。有些子任务调用下列的一些类,但也可能调用其他的。

l Check:这是用于类型检查的类。当有完成错误(completion error)或者类型错误时,它就会报错。

l Resovle: 这是名字解析的类。如果解析失败,就会报错。

l ConstFold: 这是参数折叠类。常数折叠用于简化在编译时的常数表达式。

l Infer:类参数引用的类。

2.5 Flow

这个阶段会对添加属性后的类,执行数据流的检查。存活性分析(liveness analysis) 检查是否每个语句都可以被执行到。异常分析(Excepetion analysis) 检查是豆每个被抛出的异常都是声明过的,并且这些异常是否都会被捕获。确定行赋值(definite assignment)分析保证每个变量在使用时已经被赋值。而确定性不赋值(definite unassignment)分析保证final变量不会被多次赋值。

2.6 Desugar

除去多余的语法,像内部类,类的常数,assertion断言语句,foreach循环等。

2.7 Generate

这是最终的阶段。这个阶段生成许多源文件或者类文件。到底是生成源文件还是类文件取决于编译选项。

3. 什么是JSR 269

Annotation(标记)是java 5里面引进来的,用于在源代码里面附加元信息(meta-information).Java 6则进一步加强了标记的处理功能,即JSR269. JSR269,即插入式标记处理API,为java编译器添加了一个插件机制。有了JSR269,就有能力为java编译器写一个特定的标记处理器了。

JSR269有两组基本API,一组用于对java语言的建模,一组用于编写标记处理器。这两组API分别存在于javax.lang.model.* 和 javax.annotation.processing里面。JSR269的功能是通过以下的java编译选项来调用的。

-proc:{none,only} 是否执行Annotation处理或者编译

-processor <classes> 指定标记处理器的名字。这个选项将绕过默认的标记处理器查找过程

-processorpath <path> 指定标记处理器的位置

标记处理在javac中时默认开启的。如果要是只想处理标记,而不想编译生成类文件的话,用 –proc:only 选项既即可。

#p#

4. 如何用Javac打印出“Hello World!”

在这***个例子里面,我们些一个简单的标记处理器,用于在编译的时候打印“Hello World!”.我们用编译器的内部消息机制来打印“hello world”。

首先,我们定义如下HelloWorld标记。

  1. public @interface HelloWorld{  

添加一个Dummy类使用以上的标记

  1. @HelloWorld 
  2. public class Dummy{  

标记处理可能会发生很轮。每一轮处理器只处理特定的一些标记,并且生成的源文件或者类文件,交给下一轮来处理。如果处理器被要求只处理特定的某一轮,那么他也会处理后续的那些次,包括***一轮,就算***一轮没有可以处理的标记。处理器可能也会去处理被这个工具生成的文件。

后一个方法处理前一轮生成的标记类型,并且返回是否这些标记会声明。如果返回是True,那么后续的处理器就不会去处理它们。如果返回是false,那么后续处理器会继续处理它们。一个处理器可能总是返回同样的逻辑值,或者是根据选项改变结果。为了要写一个标记处理器,我们用一个子类来继承AbstractProcessor,并且用SupportedAnnotationTyps 和SupportedSourceVersion标记这个子类。这个子类必须要复写这两个方法:

l public synchronized void init(ProcessingEnvironment processingEnv)

l public boolean process(Set<? extends TypeElement> annotations,

RoundEnvironment roundEnv)

这两个方法都是在标记处理过程中被java编译器调用的。***个方法用来初始化插件,只被调用一次。而第二个方法每一轮标记处理都会被调用,并且在所有处理都结束后还会调用一次。

我们的简单的HelloWorldProcessors是这样生成的:

  1. import javax.annotation.processing.*;  
  2. import javax.lang.model.SourceVersion;  
  3. import javax.lang.model.element.TypeElement;  
  4. import javax.tools.Diagnostic;  
  5. @SupportedAnnotationTypes("HelloWorld")  
  6. @SupportedSourceVersion(SourceVersion.RELEASE_6)  
  7. public class HelloWorldProcessor extends AbstractProcessor {  
  8. @Override 
  9. public synchronized void init(ProcessingEnvironment processingEnv) {  
  10. super.init(processingEnv);  
  11. }  
  12. @Override 
  13. public boolean process(Set<? extends TypeElement> annotations,  
  14. RoundEnvironment roundEnv) {  
  15. if (!roundEnv.processingOver()) {  
  16. processingEnv.getMessager().printMessage(  
  17. Diagnostic.Kind.NOTE, "Hello Worlds!");  
  18. }  
  19. return true;  
  20. }  

第八行注册了HelloWorld的标记处理器。也就是说,当标记出现是,就会有一系列的程序被自动调用。第九行设置了标记所支持的源代码版本。

第12到15行复写了初始化方法, 目前为止,我们只是调用父类的方法。

第17到24行复写了处理方法。这个方法是由一些列被标记的程序元素来调用的。这个方法在每一轮处理时,都会调用,并且在***会多出一轮,用于对空集合的元素的处理。这样,我们可以由一个简单的if语句,使得***多出的那一轮什么事情都不做。在其他轮中,我们只打印一个hello world消息。我们不用System.out.print,二十使用编译器的消息框架来打印一个消息(note类型的)。其他可能的类型是警告(warning)或者错误(error)。

这个方法返回true,如果你想要声明元素已经被处理过了。

要运行这个例子,执行:

javac HelloWorldProcessor.java

javac -processor HelloWorldProcessor *.java

这个应该会输出:

Note: Hello World!

5. 如何巧妙利用JSR269来重写AST

在这个例子中,我们深入到编译器自身的实现细节中去。我们利用JSR269做一些超出它本身的事情—重写AST。这个处理器会把每一个Assertion语句替换成一个throw语句。也就是说,每当有以下语句出现时

assert cond: detail;

会被替换成:

If(!cond) throw new AssertionError(detail);

后面的这个语句不会生成assert的字节码,而是生成一个普通的if语句,带有一个throw重句。结果就算你的虚拟机没有激活assertions功能时,assertions的检查还是会被执行。这个功能对各种库是非常有用的,因为你写库的时候,是没有办法控制用户的VM设置的。

再次,我们还是先继承AbstractProcessor。然而,这次我们不会针对某一个特殊的标记,而是用“*”这个符号来表示对所有的源代码都调用处理器。

  1. @SupportedAnnotationTypes("*")  
  2. @SupportedSourceVersion(SourceVersion.RELEASE_6)  
  3. public class ForceAssertions extends AbstractProcessor {  

初始化方法如下:

  1. private int tally;  
  2. private Trees trees;  
  3. private TreeMaker make;  
  4. private Name.Table names;  
  5.  
  6. @Override 
  7. public synchronized void init(ProcessingEnvironment env) {  
  8.       super.init(env);  
  9.       trees = Trees.instance(env);  
  10.      Context context = ((JavacProcessingEnvironment)  
  11. env).getContext();  
  12.       make = TreeMaker.instance(context);  
  13.       names = Name.Table.instance(context);  
  14.       tally = 0;  
  15.  } 

我们使用处理环境(ProcessingEnvironment)来获得对编译器一些组件的引用。在编译器里面,在每次调用编译器时都会有一个处理环境(ProcessingEnvironment)。在编译器中,我们使用Component.instance(context)来获得对组件的引用。

我们使用的组件如下:

l Trees – JSR269的一个工具类,用于联系程序元素和树节点。比如,对于一个方法元素,我们可以获得这个元素对应的AST树节点。

l TreeMaker – 编译器的内部组件,是用于创建树节点的工厂类。工厂类里面方法的命名方式跟Javac源代码里面的方法是统一的。

l Name.Table – 另一个编译器的内部组件。Name类是编译器内部字符串的一个抽象。为了提高效率,Javac使用了哈希字符串。

请注意,在第39行,我们把处理环境(ProcessingEnvironment)强制转换成了编译器的内部类型。

***,我们把一个计数器初始化成0.这个计数器是用来记录发生替换的数量。

处理方法如下:

  1. @Override 
  2. 46 public boolean process(Set<? extends TypeElement> annotations,  
  3. RoundEnvironment roundEnv) {  
  4.     if (!roundEnv.processingOver()) {  
  5.        Set<? extends Element> elements = roundEnv.getRootElements();  
  6.        for (Element each : elements) {  
  7.        if (each.getKind() == ElementKind.CLASS) {  
  8.           JCTree tree = (JCTree) trees.getTree(each);  
  9.           TreeTranslator visitor = new Inliner();  
  10.           tree.accept(visitor);  
  11.       }  
  12.    }  
  13.    } else 
  14.     processingEnv.getMessager().printMessage(Diagnostic.Kind.NOTE,  
  15. tally + " assertions inlined.");  
  16.     return false;  
  17.  } 

我们遍历所有的程序元素,为每一个类都重写AST。在第51行,我们把JSR269的树节点转换成编译器内部的树节点。这两种树节点的不同之处在于,JSR269节点是停留在方法层的(即方法method是最基本的元素,不会再细分下去),而内部的AST节点,是所有元素(包括方法以下的)都可以访问的。我们要访问每一个语句,所以需要访问到AST的所有节点。

#p#

树的转换是通过继承TreeTranslator来完成的,TreeTranslator本身是继承自TreeVisitor的。这些类都不是JSR269的一部分。所以,从这里开始,我们所写的所有代码都是在编译器内部工作的。

在第57行,是else部分,用于报告处理过的assertion语句数量。这个语句只有在***一轮处理才会执行。

Inliner这个类实现了AST重写。Inliner继承了TreeTranslator,并且是标记处理器的一个内部类。注意,TreeTranslator本身是不会转换任何节点的。

private class Inliner extends TreeTranslator {

}

为了转换assertion语句,我们需要复写默认的TreeTranslator.visitAssert (JCAssert) 方法,如下所示:

  1. @Override 
  2.  public void visitAssert(JCAssert tree) {  
  3.     super.visitAssert(tree);  
  4.     JCStatement newNode = makeIfThrowException(tree);  
  5.     result = newNode;  
  6.     tally++;  
  7.  } 

正在转换的节点会被当做参数传入到方法中。在第67行,转换的结果,通过赋值给变量TreeTranslator.result而返回。

按照惯例,一个转换方法应该这样生成:

l 调用父类的转换方法,以确保转换可以被应用到自己点上面去。

l 执行真正的转换

l 把转换结果赋值给TreeTranslator.result。结果的类型不一定要和传进来的参数的类型一样。相反,只要java编译器允许,我们可以返回任何类型的节点。这里TreeTranslator本身没有限制类型,但是如果返回了错误的类型,那么就很有在后续过程中产生灾难性后果。

我们写一个私有函数来实现转换,makeIfThrowException:

  1. private JCStatement makeIfThrowException(JCAssert node) {  
  2. // make: if (!(condition) throw new AssertionError(detail);  
  3. List<JCExpression> args = node.getDetail() == null 
  4. ? List.<JCExpression> nil()  
  5. : List.of(node.detail);  
  6. JCExpression expr = make.NewClass(  
  7. null,  
  8. null,  
  9. make.Ident(names.fromString("AssertionError")),  
  10. args,  
  11. null);  
  12. return make.If(  
  13. make.Unary(JCTree.NOT, node.cond),  
  14. make.Throw(expr),  
  15. null);  

这个方法传入一个assertion语句,返回一个if语句。我们可以这样做,事因为不管是assertion还是if,他们都是语句(statement),所以在java的语法中是等价的。Java中没有明文规定,禁止用if语句来代替assertion语句。

makeIfThrowException是用于AST重写的方法。我们使用TreeMaker来创建新的树节点。如果有这样的一个表达式:

assert cond:detail;

我们就可以替换成下面的形式:

If(!cond) throw new AssertionErrror(detal);

在第73到75行,我们考虑到了detail被省略的情况。在76到81行,我们创建了一个AST节点,这个节点的作用是创建AssertionError。在第79行,我们使用Name.Table来把字符串“AssertionError”变成编译器内部的字符串。在80行,我们再传入73到75行创建的参数args。第77,78和81行传入了null值,因为这个节点既没有外部实例,也没有类型参数,也不是在匿名类内部。

在第83行,我们对assertion的条件做了一个Not操作。84行,我们创建了一个throw表达式,***,在82到85行,我们把所有的东西都放到了if语句中。

注意:List类是java编译器中另外一个令人印象深刻的实现。编译器用了它自己的数据类型来实现List,而不是使用java集合框架(Java Collection Framework)。List和Pair数据类的实现,都用到了Lisp语言里面所谓的cons。Pairs是这样实现的:

  1. public class Pair<A, B> {  
  2. public final A fst;  
  3. public final B snd;  
  4. public Pair(A fst, B snd) {  
  5. this.fst = fst;  
  6. this.snd = snd;  
  7. }  
  8. ...  

而List是这样实现的:

  1. public class List<A> extends AbstractCollection<A> implements 
  2. java.util.List<A> {  
  3. public A head;  
  4. public List<A> tail;  
  5. public List(A head, List<A> tail) {  
  6. this.tail = tail;  
  7. this.head = head;  
  8. }  
  9. ...  

并且有许多静态的方法,可以很方便的创建List:

l List.nil()

l List.of(A)

l List.of(A,A)

l List.of(A,A,A)

l List.of(A,A,A,A...)

Pair也是一样:

l Pair.of(A,B)

同样,非传统的命名方式也带来了更漂亮的代码

不像传统java中用的代码:

List list = new List();

list.add(a);

list.add(b);

list.add(c);

而现在只需要写:

List.of(a, b, c);

#p#

5.1 运行AST重写

为了展示AST重写,我们使用:

  1. public class Example {  
  2.  
  3. public static void main(String[] args) {  
  4.     String str = null;  
  5.     assert str != null : "Must not be null";  
  6. }  
  7.  

并且执行:

javac ForceAssertions.java

javac -processor ForceAssertions Example.java

就会产生这样的输出:

Note: 1 assertions inlined

现在,我们我们我们禁用assertion,再执行例子:

java -disableassertions Example

得到:

Exception in thread "main" java.lang.AssertionError: Must not be null at Example.main(Example.java:1)

利用编译器的选项 –printsource,我们甚至可以得到重写过后的AST,并且以Java源代码的方式显示出来。要注意的是,我们必须重定向输出,否者原来的源文件会被覆盖了。

执行:

javac -processor ForceAssertions -printsource -d gen Example.java

产生结果:

  1. public class Example {  
  2.  
  3. public Example() {  
  4.     super();  
  5. }  
  6.  
  7. public static void main(String[] args) {  
  8.     String str = null;  
  9.     if (!(str != null)) throw new AssertionError("Must not be null");  
  10. }  

可以发现,第9行已经被重写过了,第3到5行加入了一个默认的构造函数。

5.2 如何把标记处理器注册成服务

Java提供了一个注册服务的机制。如果一个标记处理器被注册成了一个服务,编译器就会自动的去找到这个标记处理器。注册的方法是,在classpath中找到一个叫META-INF/services的文件夹,然后放入一个javax.annotation.processing.Processor的文件。文件格式是很明显的,就是要包含要注册的标记处理器的完整名称。每个名字都要占单独的一行。

5.3 进一步的阅读

Erni在他的本科毕业设计中描述了一个更复杂的编译器修改。他不是依赖JSR269,而是直接在编译过程中的几个点进行直接修改。

参考

[1] David Erni. JAG - a Prototype for Collective Behavior in Java. Bachelors

Thesis, University of Bern. March 2008.

[2] Joseph D. Darcy. JSR-000269 Pluggable Annotation Processing API,

December 2006. http://jcp.org/en/jsr/detail?id=269.

A 在OSX下面安装Java 6

默认情况下,当前的OSX只是内置 了Java 5.0,为了安装Java6,从以下地址下载安装文件http://www.apple.com/support/downloads/javaformacosx105update1.html. 这个更新需要Mac OS X 10.5.2或者是更新的,而且要64位基于Intel的Mac。这个更新不会替换已经存在的J2SE 5.0安装,或者是改变Java的默认版本。

B ForceAssertions.java的全部源代码

  1.  import java.util.Set;  
  2.  
  3.  import javax.annotation.processing.AbstractProcessor;  
  4.  import javax.annotation.processing.ProcessingEnvironment;  
  5.  import javax.annotation.processing.RoundEnvironment;  
  6.  import javax.annotation.processing.SupportedAnnotationTypes;  
  7.  import javax.annotation.processing.SupportedSourceVersion;  
  8.  import javax.lang.model.SourceVersion;  
  9.  import javax.lang.model.element.Element;  
  10.  import javax.lang.model.element.ElementKind;  
  11.  import javax.lang.model.element.TypeElement;  
  12.  import javax.tools.Diagnostic;  
  13.  
  14.  import com.sun.source.util.Trees;  
  15.  import com.sun.tools.javac.processing.JavacProcessingEnvironment;  
  16.  import com.sun.tools.javac.tree.JCTree;  
  17.  import com.sun.tools.javac.tree.TreeMaker;  
  18.  import com.sun.tools.javac.tree.TreeTranslator;  
  19.  import com.sun.tools.javac.tree.JCTree.JCAssert;  
  20.  import com.sun.tools.javac.tree.JCTree.JCExpression;  
  21.  import com.sun.tools.javac.tree.JCTree.JCStatement;  
  22.  import com.sun.tools.javac.util.Context;  
  23.  import com.sun.tools.javac.util.List;  
  24.  import com.sun.tools.javac.util.Name;  
  25.  
  26.  @SupportedAnnotationTypes("*")  
  27.  @SupportedSourceVersion(SourceVersion.RELEASE_6)  
  28.  public class ForceAssertions extends AbstractProcessor {  
  29.  
  30.  private int tally;  
  31.  private Trees trees;  
  32.  private TreeMaker make;  
  33.  private Name.Table names;  
  34.  
  35.  @Override 
  36.  public synchronized void init(ProcessingEnvironment env) {  
  37.      super.init(env);  
  38.      trees = Trees.instance(env);  
  39.      Context context = ((JavacProcessingEnvironment)  
  40. env).getContext();  
  41.     make = TreeMaker.instance(context);  
  42.      names = Name.Table.instance(context);  
  43.      tally = 0;  
  44.  }  
  45.  
  46.  @Override 
  47.  
  48.  public boolean process(Set<? extends TypeElement> annotations,  
  49. RoundEnvironment roundEnv) {  
  50.      if (!roundEnv.processingOver()) {  
  51.      Set<? extends Element> elements =  
  52. roundEnv.getRootElements();  
  53.      for (Element each : elements) {  
  54.          if (each.getKind() == ElementKind.CLASS) {  
  55.          JCTree tree = (JCTree) trees.getTree(each);  
  56.          TreeTranslator visitor = new Inliner();  
  57.          tree.accept(visitor);  
  58.          }  
  59.      }  
  60.  } else 
  61.      processingEnv.getMessager().printMessage(  
  62. Diagnostic.Kind.NOTE, tally + " assertions  
  63. inlined.");  
  64.      return false;  
  65.  }  
  66.  
  67.  private class Inliner extends TreeTranslator {  
  68.  
  69.  @Override 
  70.  public void visitAssert(JCAssert tree) {  
  71.       super.visitAssert(tree);  
  72.       JCStatement newNode = makeIfThrowException(tree);  
  73.       result = newNode;  
  74.       tally++;  
  75.  }  
  76.  
  77.  private JCStatement makeIfThrowException(JCAssert node) {  
  78.  // make: if (!(condition) throw new AssertionError(detail);  
  79.      List<JCExpression> args = node.getDetail() == null 
  80.                       ? List.<JCExpression> nil()  
  81.                       : List.of(node.detail);  
  82.      JCExpression expr = make.NewClass(  
  83.      null,  
  84.      null,  
  85.      make.Ident(names.fromString("AssertionError")),  
  86.      args,  
  87.      null);  
  88.      return make.If(  
  89.             make.Unary(JCTree.NOT, node.cond),  
  90.             make.Throw(expr),  
  91.             null);  
  92.  }  
  93.  }  
  94.  } 

译文连接:http://my.oschina.net/superpdm/blog/129070

责任编辑:林师授 来源: guoliang的博客
相关推荐

2012-02-29 10:06:14

2009-11-09 10:15:10

2022-01-18 07:40:27

渗透测试黑客

2019-10-11 16:55:42

2019-01-18 17:07:49

2023-06-14 15:20:51

2014-08-13 19:20:56

2013-08-05 09:16:46

2011-09-05 17:05:03

2011-09-05 18:39:41

2009-04-09 23:38:20

黑客韩国大赛

2011-09-05 17:11:51

2011-08-31 13:12:39

2018-11-26 09:01:19

2011-08-02 08:59:53

2011-11-28 15:05:09

2010-09-17 08:53:01

2011-08-31 13:41:46

2011-07-08 10:15:51

2019-01-15 10:16:05

点赞
收藏

51CTO技术栈公众号