打造一個簡單的Java字節碼反編譯器


簡介

本文示范了一種反編譯Java字節碼的方法,首先通過解析class文件,然后將解析的結果轉成java代碼。但是本文並沒有覆蓋所有的class文件的特性和指令,只針對部分規范進行解析。

所有的代碼代碼都是示范性的,追求功能實現,沒有太多的軟件工程方面的考量。

Class文件格式

一個Java類或者接口被javac編譯后會生成一個class文件,class文件可以用下面代碼來描述,u2,u4分表表示2個字節的無符號數和4個字節的無符號數。

    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];
    }
  • magic是固定值0xCAFEBABE
  • minor_version和major_version分別代表副版本號和主版本號。
  • constant_pool_count表示接下來常量池中包含的常量項數量。
  • constant_pool表示常量池,常量池中包含了各種不同類型的常量池項,如:字符串常量,類或接口名,方法引用等,每個常量池項的第一個字節表示tag,在解析常量池時,需要先讀取tag,然后根據不同的tag類型繼續往后面讀取固定字節的數據。每個常量池項都有一個編號,外部可以使用這個編號來訪問常量池項。
  • access_flags表示類或者接口標志,如PUBLIC,FINAL等。
  • this_class指向常量池的一個索引號,最終解析出來的是一個類或者接口的名稱。
  • super_class指向父類,jvm只支持單繼承。
  • interfaces_count,interfaces分別表示實現的接口數和實現的接口。
  • fields_count,fields表示一個類的域。
  • methods_count,methods表示一個類或接口包含的方法。
  • attributes_count,attributes表示對類的屬性。

用Java解析Class文件

本節定義一系列數據結構用來將二進制class數據用java代碼來描述。並簡述一些基本概念,由於class文件定義項非常多,如果要詳細了解,請查看《ava虛擬機規范》 [https://docs.oracle.com/javase/specs/jvms/se8/html/]。

ClassFile

public class ClassFile {
    private int magic;
    private int minorVersion;
    private int majorVersion;
    private ConstantPool constantPool;
    private AccessFlags accessFlags;
    private int thisClass;
    private int superClass;
    private int interfacesCount;
    private int interfaces[];
    private int fieldsCount;
    private FieldInfo fields[];
    private int methodsCount;
    private MethodInfo methods[];
    private int attributesCount;
    private Attribute attributes[];
}

ConstantPool

常量池中包含了類的所有符號信息,包括類名,方法名,常量等。常量池項包含了多種類型,每項使用一個tag來識別是哪個常量。定義基類如下:

public abstract class CPInfo {
    protected ConstantPool constantPool;
}

具體常量池定義如下:

public class ConstantUtf8Info extends CPInfo {
    private String value;
}

public class ConstantStringInfo extends CPInfo {
    private int stringIndex;
}

public class ConstantClassInfo extends CPInfo {
    private int nameIndex;
}

在解析常量池時,需要先讀取一個字節的tag來判斷這個常量池項是什么類型,然后按類型來讀取接下來的數據,因為每個不同類型的項所包含的數據是不定長的,所以這里顯然是需要一個大大的switch了。

由於常量池類型多達10幾種,這里不一一列出。具體參考《Java虛擬機規范》。定義一個ConstantPool類來簡化對常量池的操作,這個類包含了常量池項的數量和常量池項的數組。

public class ConstantPool {
    private int poolCount;
    private CPInfo[] pool;

    public ConstantPool(DataInputStream dataInputStream) throws IOException {
        this.poolCount = dataInputStream.readUnsignedShort();
        this.pool = new CPInfo[this.poolCount];
		
		//注意,從下表為1開始訪問常量池
        for (int i = 1; i < this.poolCount; i++) {
            int tag = dataInputStream.readUnsignedByte();
            this.pool[i] = CPInfoFactory.getInstance().createCPInfo(tag, dataInputStream, this);
        }
    }

    public int getPoolCount() {
        return poolCount;
    }

    public <T extends CPInfo> T getCPInfo(int index) {
        return (T) pool[index];
    }

    public ConstantUtf8Info getUtf8Info(int index) {
        return (ConstantUtf8Info) pool[index];
    }
}

FieldInfo

FieldInfo用來描述類里的Field,定義如下:

class FieldInfo {
    private int accessFlags; //修飾符號
    private int nameIndex; //field名稱常量在常量池中的索引
    private int descriptorIndex;
    private int attributesCount;
    private Attribute attributeInfo[];
}

MethodInfo

用於描述類中方法的數據結構,methodInfo里面包含了一系列的attribute,方法的實際字節碼指令就放在CodeAttribute里面。

class MethodInfo {
    private AccessFlags accessFlags;
    private int nameIndex;
    private int descriptorIndex;
    private int attributesCount;
    private Attribute attributes[];
}

Attribute

在ClassFile,FieldInfo,MethodInfo里面都定義了一個Attribute數組,Attribute類型也不少,本文只關注MethodInfo里面的CodeAttribute類型。這個類型包含了一個方法的操作數棧大小,本地變量表大小,指令碼:

public class CodeAttribute extends Attribute {
    private int maxStack;
    private int maxLocals;
    private int codeLength;
    private byte code[];
    private int exceptionTableLength;
    private ExceptionData exceptionTable[];
    private int attributeCount;
    private Attribute attributes[];
}

Descriptor

Descriptor是一個字符串,可以用來描述一個方法的參數和返回類型。如下:

(Ljava/lang/Object;[Ljava/lang/Object;)I
(II)I

括號中表述參數,括號外表示返回類型。這個Descriptor可以解析成 :

int XXX(Object,Object[]);
int XXX(int,int);

L表示引用類型,I表示int類型,[表示數組,具體對應如下:

char2TypeMap.put('B', "byte");
char2TypeMap.put('C', "char");
char2TypeMap.put('D', "double");
char2TypeMap.put('F', "float");
char2TypeMap.put('I', "int");
char2TypeMap.put('J', "long");
char2TypeMap.put('S', "short");
char2TypeMap.put('Z', "boolean");

class文件的解析不復雜,但是比較繁瑣,本文不全部列出,更多class文件的定義還是要參考《Java虛擬機規范》。

將ClassFile解析成Java代碼

ClassFile對象解析出來后,可以開始生成Java代碼了。首先構造class的頭部:

	//生成class頭部:public class Test extends Base 
    private String generateClassHead() {
        StringBuilder javaCode = new StringBuilder();

        if (classFile.getAccessFlags().hasFlag(AccessFlags.ACC_PUBLIC)) {
            javaCode.append("public ");
        } else if (classFile.getAccessFlags().hasFlag(AccessFlags.ACC_PRIVATE)) {
            javaCode.append("private ");
        } else {
            javaCode.append("protected ");
        }

        if (classFile.getAccessFlags().hasFlag(AccessFlags.ACC_INTERFACE)) {
            javaCode.append("interface ");
        } else {
            javaCode.append("class ");
        }

        javaCode.append(this.className).append(" ");

        //解析實現的接口名
        if (classFile.getInterfaces().length > 0) {
            javaCode.append(" implements ");
        }

        for (int i = 0; i < classFile.getInterfaces().length; i++) {
            ConstantClassInfo interfaceClassInfo = classFile.getConstantPool().getCPInfo(classFile.getInterfaces()[i]);
            javaCode.append(interfaceClassInfo.getName());

            boolean isLast = i == (classFile.getInterfaces().length - 1);
            if (!isLast) {
                javaCode.append(",");
            }
        }

        return javaCode.toString();
    }

class的頭部代碼構造比較簡單,最復雜的在於class body部分,本文只實現了MethodInfo的解析,也就是只生成方法,在構造class body之前,要先看一下,如果為MethodInfo生成代碼,
首先解析方法頭部,方法頭部解析比較簡單,就是拼湊方法modifiers,方法名稱,方法參數,返回等,方法的頭部的解析和類解析的頭部有一定的相同點,主要區別就是方法頭部解析需要根據Descriptor來解析方法的返回類型,方法的參數列表。並根據ExceptionsAttribute來解析這個方法可能拋出的異常(本文不考慮解析Excpetion),比較簡單,這里不貼代碼了。

接下來解析方法body,方法body里面包含了具體的指令碼。需要解析CodeAttribute,只有方法才包含CodeAttribute,CodeAttribute里才包含字節碼指令,下面是一個方法用字節碼表示的示范:

  public int sum(int, int);
    descriptor: (II)I
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=3
         0: iload_1
         1: iload_2
         2: iadd
         3: ireturn
      LineNumberTable:
        line 13: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       4     0  this   Lcom/mypackage/Test;
            0       4     1     i   I
            0       4     2     j   I

Code節點就是CodeAttribute,里面包含了stack,locals,args_size,以及幾條指令。stack表示執行這個方法所需要的棧大小,這個值在編譯時已經確定了,locals表示本地變量表的大小,args_size表示方法參數的數量,由於這個方法是個實例方法,所以第一個傳進來的參數是實例自身,也就是this,然后才是方法的兩個參數,所以參數為3。

JVM用線程來執行方法,每個線程都包含一個線程棧,線程棧存放的是棧幀,每個棧幀都有自己的操作數棧和本地變量表。當一個方法被執行時候,首先會在棧頂push一個棧幀,棧幀的創建就需要指定操作數棧和本地變量表的大小。接下來方法的參數會被傳入一個方法的本地變量表,本地變量表的訪問采用下表索引的方式來訪問,第0個位置會傳入this,第1個位置會傳入int,第2個位置會傳入int。

在棧幀完全准備好后,就可以開始執行執行字節碼指令了,上述iload_1,i_load_2分別將本地變量表的1,2位置的數據push到操作數棧中,iadd隨后會pop兩個值用來做加法操作,並將結果push到操作數棧,最后ireturn將棧頂數據返回。

在理解字節碼的執行方式后,可以開始將字節碼一條一條的轉化成java代碼。這里依然需要借助stack。可以用一個stack來暫時存儲轉換后的java代碼,還是用上面代碼做示范:
首先聲明一個stack:

Stack<String> javaCodeStack=new Stack<String>();

然后依次翻譯上述指令,實際上就是模擬指令的行為:

iload_1 -> javaCodeStack.push("var1"); 
iload_2 -> javaCodeStack.push("var2");
iadd	-> javaCodeStack.push(javaCodeStack.pop() + "+" javaCodeStack.pop());
ireturn -> javaCodeStack.push("return "+javaCodeStack.pop());

指令執行完畢后,java代碼也就翻譯完成了,所有有效的java代碼都已經放在javaCodeStack里,接下來就是遍歷一下javaCodeStack,把里面的字符串打印出來就行了,遍歷后得到的結果只有一行java代碼,如下:

return var2+var1;

上面只闡述了幾種簡單指令的翻譯方式,但是即使對於復雜的指令,也只需要按照上面的方式來做轉換就可以得到java代碼。下面代碼實現了更多的指令翻譯,基本流程就是先得到CodeAttribute,然后准備好操作數棧和本地變量表,接下來就是每次讀取一個指令,然后根據指令類型在讀取指令的參數(一般是訪問常量池的索引號),最后將壓入棧的java代碼拼接成一個字符串,得到的就是方法體的代碼了。

    private String generateMethodBodyCode(MethodInfo methodInfo, List<TypeDescriptor> parametersTypeDescriptors,
                                          TypeDescriptor returnTypeDescriptor) throws IOException {
        StringBuilder javaCode = new StringBuilder();

        String currentMethodName = classFile.getConstantPool().getUtf8Info(methodInfo.getNameIndex()).getValue();

        //尋找CodeAttribute
        CodeAttribute codeAttribute = findAttribute(methodInfo, Attribute.Code);
        if (codeAttribute == null) {
            throw new RuntimeException("無法在Method里找到CodeAttribute");
        }

        Stack<Object> opStack = new Stack<>(/*codeAttribute.getMaxStack()*/);
        List<String> localVariableNames = new ArrayList<>(codeAttribute.getMaxLocals());

        //初始化本地變量表名,首先如果是實例方法,需要把this放入第一個,然后依次將方法參數名放入
        boolean isStaticMethod = methodInfo.getAccessFlags().hasFlag(AccessFlags.ACC_STATIC);
        if (!isStaticMethod) {
            localVariableNames.add("this");
        }
        for (int x = 0; x < parametersTypeDescriptors.size(); x++) {
            localVariableNames.add("var" + (x + 1));
        }

        DataInputStream byteCodeInputStream = new DataInputStream(new ByteArrayInputStream(codeAttribute.getCode()));
        while (byteCodeInputStream.available() > 0) {
            int opCode = byteCodeInputStream.readByte() & 0xff;
            switch (opCode) {
                case OP_aload_0:
                    System.out.println("aload_0");
                    opStack.push(localVariableNames.get(0));
                    break;
                case OP_invokevirtual: {
                    int methodRefIndex = byteCodeInputStream.readUnsignedShort();
                    System.out.println("invokevirtual #" + methodRefIndex);

                    ConstantMethodRefInfo methodRefInfo = classFile.getConstantPool().getCPInfo(methodRefIndex);
                    ConstantNameAndTypeInfo nameAndTypeInfo = classFile.getConstantPool().getCPInfo(methodRefInfo.getNameAndTypeIndex());
                    String methodName = classFile.getConstantPool().getUtf8Info(nameAndTypeInfo.getNameIndex()).getValue();
                    String typeDescriptor = classFile.getConstantPool().getUtf8Info(nameAndTypeInfo.getDescriptorIndex()).getValue();
                    int methodParameterSize = new DescriptorParser(typeDescriptor).getParameterTypeDescriptors().size();
                    Object targetClassName;
                    Object parameterNames[] = new Object[methodParameterSize];

                    for (int x = 0; x < methodParameterSize; x++) {
                        parameterNames[methodParameterSize - x - 1] = opStack.pop();
                    }
                    targetClassName = opStack.pop();

                    StringBuilder line = new StringBuilder();
                    line.append(targetClassName).append(".").append(methodName).append("(");
                    for (int x = 0; x < methodParameterSize; x++) {
                        line.append(parameterNames[x]);
                        if ((x != methodParameterSize - 1)) {
                            line.append(",");
                        }
                    }
                    line.append(");");

                    opStack.push(line.toString());
                    break;
                }
                case OP_invokespecial: {
                    int methodRefIndex = byteCodeInputStream.readUnsignedShort();
                    System.out.println("invokespecial #" + methodRefIndex);

                    ConstantMethodRefInfo methodRefInfo = classFile.getConstantPool().getCPInfo(methodRefIndex);
                    ConstantNameAndTypeInfo nameAndTypeInfo = classFile.getConstantPool().getCPInfo(methodRefInfo.getNameAndTypeIndex());
                    String typeDescriptor = classFile.getConstantPool().getUtf8Info(nameAndTypeInfo.getDescriptorIndex()).getValue();
                    int methodParameterSize = new DescriptorParser(typeDescriptor).getParameterTypeDescriptors().size();
                    Object targetClassName;
                    Object parameterNames[] = new Object[methodParameterSize];

                    if (methodParameterSize > 0) {
                        for (int x = 0; x < methodParameterSize; x++) {
                            parameterNames[methodParameterSize - x - 1] = opStack.pop();
                        }
                    }
                    targetClassName = opStack.pop();

                    StringBuilder line = new StringBuilder();
                    if (currentMethodName.equals("<init>") && targetClassName.equals("this")) {
                        line.append("super");
                    } else {
                        line.append("new ").append(targetClassName);
                    }
                    line.append("(");
                    for (int x = 0; x < methodParameterSize; x++) {
                        line.append(parameterNames[x]);
                        if ((x != methodParameterSize - 1)) {
                            line.append(",");
                        }
                    }
                    line.append(");");

                    opStack.push(line.toString());
                    break;
                }
                case OP_getstatic:
                    System.out.println("getstatic");
                    break;
                case OP_return:
                    System.out.println("return");
                    break;
                case OP_new: {
                    int classIndex = byteCodeInputStream.readUnsignedShort();
                    System.out.println("new #" + classIndex);
                    ConstantClassInfo classInfo = classFile.getConstantPool().getCPInfo(classIndex);
                    opStack.push(classInfo.getName());
                    break;
                }
                case OP_dup:
                    System.out.println("dup");
                    Object top = opStack.pop();
                    opStack.push(top);
                    opStack.push(top);
                    break;
                case OP_ldc:
                    int stringIndex = byteCodeInputStream.readByte() & 0xff;
                    System.out.println("ldc #" + stringIndex);
                    ConstantStringInfo stringInfo = classFile.getConstantPool().getCPInfo(stringIndex);
                    String value = classFile.getConstantPool().getUtf8Info(stringInfo.getStringIndex()).getValue();
                    opStack.push(value);
                    break;
                case OP_iload_1:
                    System.out.println("iload_1");
                    opStack.push(localVariableNames.get(1));
                    break;
                case OP_iload_2:
                    System.out.println("iload_2");
                    opStack.push(localVariableNames.get(2));
                    break;
                case OP_iadd:
                    System.out.println("iadd");
                    opStack.push(opStack.pop() + "+" + opStack.pop());
                    break;
                case OP_ireturn:
                    System.out.println("ireturn");
                    opStack.push("return " + opStack.pop());
                    break;
                case OP_iconst_0:
                    System.out.println("iconst_0");
                    opStack.push("0");
                    break;
                case OP_iconst_1:
                    System.out.println("iconst_1");
                    opStack.push("1");
                    break;
                case OP_iconst_2:
                    System.out.println("iconst_2");
                    opStack.push("2");
                    break;
                case OP_astore_1: {
                    System.out.println("astore_1");
                    String obj = opStack.pop().toString();
                    String className = opStack.pop().toString();
                    localVariableNames.add(1, "localVar1");
                    opStack.push(className + " localVar1=" + obj);
                    break;
                }
                case OP_astore_2: {
                    System.out.println("astore_2");
                    String obj = opStack.pop().toString();
                    String className = opStack.pop().toString();
                    localVariableNames.add(1, "localVar2");
                    opStack.push(className + " localVar2=" + obj);
                    break;
                }
                case OP_astore_3: {
                    System.out.println("astore_3");
                    String obj = opStack.pop().toString();
                    String className = opStack.pop().toString();
                    localVariableNames.add(1, "localVar3");
                    opStack.push(className + " localVar3=" + obj);
                    break;
                }
                case OP_aload_1:
                    System.out.println("aload_1");
                    opStack.push(localVariableNames.get(1));
                    break;
                case OP_pop:
                    System.out.println("pop");
                    //opStack.pop();
                    break;
                default:
                    throw new RuntimeException("Unknow opCode:0x" + opCode + " " + currentMethodName);
            }
        }

        for (Object s : opStack) {
            javaCode.append("       ").append(s).append("\r\n");
        }

        return javaCode.toString();
    }

最后就是組裝一個class了,將類頭部,方法頭部,方法body,全部拼接后,就是最終的java代碼了。

總結

本文涉及的代碼只實現了class規范的一部分,並不能反編譯所有的class文件(需要補全未識別的指令),下面的字節碼通過了測試:

public class com.mypackage.Test
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #6.#34         // java/lang/Object."<init>":()V
   #2 = Class              #35            // com/mypackage/Test
   #3 = String             #36            // hello
   #4 = Methodref          #2.#37         // com/mypackage/Test."<init>":(Ljava/lang/String;)V
   #5 = Methodref          #2.#38         // com/mypackage/Test.sum:(II)I
   #6 = Class              #39            // java/lang/Object
   #7 = Utf8               <init>
   #8 = Utf8               (Ljava/lang/String;)V
   #9 = Utf8               Code
  #10 = Utf8               LineNumberTable
  #11 = Utf8               LocalVariableTable
  #12 = Utf8               this
  #13 = Utf8               Lcom/mypackage/Test;
  #14 = Utf8               s
  #15 = Utf8               Ljava/lang/String;
  #16 = Utf8               sum
  #17 = Utf8               (II)I
  #18 = Utf8               i
  #19 = Utf8               I
  #20 = Utf8               j
  #21 = Utf8               search
  #22 = Utf8               (Ljava/lang/Object;[Ljava/lang/Object;)I
  #23 = Utf8               o
  #24 = Utf8               Ljava/lang/Object;
  #25 = Utf8               objects
  #26 = Utf8               [Ljava/lang/Object;
  #27 = Utf8               main
  #28 = Utf8               ([Ljava/lang/String;)V
  #29 = Utf8               args
  #30 = Utf8               [Ljava/lang/String;
  #31 = Utf8               test
  #32 = Utf8               SourceFile
  #33 = Utf8               Test.java
  #34 = NameAndType        #7:#40         // "<init>":()V
  #35 = Utf8               com/mypackage/Test
  #36 = Utf8               hello
  #37 = NameAndType        #7:#8          // "<init>":(Ljava/lang/String;)V
  #38 = NameAndType        #16:#17        // sum:(II)I
  #39 = Utf8               java/lang/Object
  #40 = Utf8               ()V
{
  public com.mypackage.Test(java.lang.String);
    descriptor: (Ljava/lang/String;)V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=2, args_size=2
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 8: 0
        line 10: 4
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lcom/mypackage/Test;
            0       5     1     s   Ljava/lang/String;

  public int sum(int, int);
    descriptor: (II)I
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=3
         0: iload_1
         1: iload_2
         2: iadd
         3: ireturn
      LineNumberTable:
        line 13: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       4     0  this   Lcom/mypackage/Test;
            0       4     1     i   I
            0       4     2     j   I

  public int search(java.lang.Object, java.lang.Object[]);
    descriptor: (Ljava/lang/Object;[Ljava/lang/Object;)I
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=3, args_size=3
         0: iconst_0
         1: ireturn
      LineNumberTable:
        line 17: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       2     0  this   Lcom/mypackage/Test;
            0       2     1     o   Ljava/lang/Object;
            0       2     2 objects   [Ljava/lang/Object;

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=3, locals=3, args_size=1
         0: new           #2                  // class com/mypackage/Test
         3: dup
         4: ldc           #3                  // String hello
         6: invokespecial #4                  // Method "<init>":(Ljava/lang/String;)V
         9: astore_1
        10: aload_1
        11: iconst_1
        12: iconst_2
        13: invokevirtual #5                  // Method sum:(II)I
        16: pop
        17: new           #6                  // class java/lang/Object
        20: dup
        21: invokespecial #1                  // Method java/lang/Object."<init>":()V
        24: astore_2
        25: return
      LineNumberTable:
        line 21: 0
        line 22: 10
        line 23: 17
        line 24: 25
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      26     0  args   [Ljava/lang/String;
           10      16     1  test   Lcom/mypackage/Test;
           25       1     2     o   Ljava/lang/Object;
}
SourceFile: "Test.java"

用這個簡單的反編譯器來執行反編譯的結果如下:

public class Test  { 
   public Test(java.lang.String var1) { 
       super();
   }
   public int sum(int var1, int var2) { 
       return var2+var1
   }
   public int search(java.lang.Object var1, java.lang.Object[] var2) { 
       return 0
   }
   public static void main(java.lang.String[] var1) { 
       com.mypackage.Test localVar1=new com.mypackage.Test(hello);
       localVar1.sum(1,2);
       java.lang.Object localVar2=new java.lang.Object();
   }
}


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM