JDK 24 功能:JEP 484 Class-File 類別檔案 API 解析

JEP 484: Class-File API

在 Java 開發生態系統中,類別檔案(Class File)的處理一直是重要但複雜的議題。在歷經了前兩個版本的預覽後,類別檔案 API 終於在 JDK 24JEP 484 中做為正式功能發佈,以簡化 Java 開發者處理類別檔案的工作流程,包括解析、生成和轉換等操作。

新的 JEP 484 API 不僅提供了更高層次的抽象層與更友善的介面,還透過現代 Java 語言特性的運用,為開發者帶來更直觀且更安全的類別檔案處理方式。本文將詳細介紹這項重要的 API 功能,並說明它如何改善當前的開發體驗。

前言

在 Java 中,類別檔案是 Java 生態系統中的通用語言。許多框架和工具都需要解析、生成和轉換類別檔案。我們可以使用獨立的工具和函式庫去檢查與擴展應用程式,而不會危及其原始碼的可維護性。例如,框架可以使用即時的位元組碼轉換來添加無法或很難包含在原始碼中的功能。

目前有許多用於解析和生成類別檔案的函式庫,例如 ASMBCELJavassist。它們各自有著不同的設計目標、優點和缺點。我們通常會使用前述的其中一種函式庫去存取類別檔案,但是這些常用的解決方案存在著一些明顯的問題。

為何需要新的類別檔案 API?

首先,現有的函式庫都有著其特定的設計目標和限制,導致我們需要在不同場景下根據需求而選擇不同的工具。

更關鍵的是,在 Java 平台採取每六個月的發布週期之後,類別檔案格式的演進速度比過去更快。尤其是近年來,類別檔案格式已經發展到支援密封類別動態常量巢狀成員。這種趨勢將隨著不斷推出的新功能(值類別、泛型方法特化)而持續下去。

因為類別檔案的格式每六個月就可以發展一次,框架會更頻繁地遇到尚未支援的新版類別檔案。這會導致應用程式開發人員看到錯誤,或者更糟的是,框架開發人員試圖編寫程式碼來解析未來的類別檔案,並盲目相信不會發生太嚴重的變化。因此框架開發人員需要一個他們可以信任的類別檔案函式庫,能夠與 JDK 保持同步。

此外,JDK 在 javac 編譯器內部也有自身的類別檔案處理機制,需要依賴第三方的 ASM 函式庫來實現 jar 和 jlink 等工具,並支援在運行時實現 lambda 表達式。不幸的是,JDK 使用第三方函式庫的這種依賴關係導致了新的類別檔案功能被延後支援,因為 JDK N 版本中的工具可能要等到 JDK N+1 才能完整支援 JDK N 中的新功能。

Java 平台應該定義和實現一個與類別檔案格式一起發展的標準類別檔案 API。平台的組件將能夠僅依賴於此 API,而不是永遠依賴於協力廠商開發人員更新和測試他們的類別檔案函式庫的意願。使用標準 API 的框架和工具將自動支援來自最新 JDK 的類別檔案,以便可以快速輕鬆地採用具有類別檔案表示形式的新語言和 VM 功能。

JEP 484 概觀

JEP 484 是為了要提供一個標準化的 Java API,用於解析、生成和轉換 Java 類別檔案(.class)。它可以簡化處理類別檔案的任務,使開發人員能夠更輕鬆地讀取、修改和創建類別檔案,並且同時能確保與 Java 虛擬機器規格的一致性:

  • 解析:將類別檔案中的資訊(例如常數池、欄位、方法、屬性等)表示為不可變的 Java 物件,方便開發人員進行存取和操作
  • 生成:提供建構器(Builder)來協助開發人員逐步構建類別檔案,並將其寫入輸出串流或檔案
  • 轉換:允許開發人員修改現有的類別檔案,例如替換或刪除其中的元素

相關連結

優點

  • 標準化:提供官方支援的 API,取代過去非標準或第三方函式庫,提高了程式碼的可移植性和可維護性
  • 安全性:透過使用不可變物件來表示類別檔案的元素,有助於防止意外修改和確保數據的完整性
  • 易用性:提供了更高級別的抽象和友善的 API,降低了處理類別檔案的複雜度
  • 靈活性:支援對類別檔案進行各種操作,例如解析、生成和轉換,滿足了不同開發場景的需求

缺點

  • 效能:相較於底層的位元組碼操作,使用 Class-File API 可能會引入一些額外的開銷
  • 學習曲線:對於不熟悉類別檔案結構和位元組碼的開發人員來說,會需要一定的學習成本

介紹

設計目標和原則

JEP 484 類別檔案 API 採用了下列的設計目標和原則:

  • 不可變性:所有的類別檔案實體(欄位、方法、屬性、位元組碼指令、註釋等)都是不可變物件,因此在轉換類別檔案時能夠可靠地共享
  • 樹狀結構表示法:類別檔案以樹狀結構呈現。類別包含了中繼資料(名稱、父類別等),以及欄位、方法和屬性。欄位和方法本身亦具有中繼資料和屬性,包括 Code 屬性,而 Code 屬性則包含指令、異常處理程序等。因此,類別檔案 API 應該要能夠反映出這樣的階層結構,並提供導覽與建構的能力
  • 用戶為主的導覽能力:用戶可以依需求去選擇類別檔案樹中的路徑。如果用戶只關注欄位的註釋,那麼僅需解析 field_info 結構中內部註釋屬性的層級即可,而不需檢視其他類別屬性,或方法主體,或該欄位的其他屬性。用戶可依所需將複合實體(例如方法)視為單一單元或是(包含其組成成分的)元素串流
  • 惰性(Laziness、延遲性):用戶驅動的導覽能力可以帶來明顯的效率提升。例如,僅需解析滿足用戶要求的類別檔案部分,而不用解析其他多餘內容。如果用戶不打算深入研究某方法,那麼只需要解析 method_info 結構中,足以判斷出下一個元素起始位置的部分即可。只有當用戶要求時,才延遲地展開並快取完整的內容
  • 統一的串流與實體化視圖:如同 ASM,我們希望同時支援類別檔案的串流視圖和實體化視圖。串流視圖適用於大多數的使用案例,而實體化視圖支援隨機存取故更通用。因為遵守了惰性和不可變性,我們可以用比 ASM 更低的成本去提供實體化視圖。此外,我們還可以讓串流視圖和實體化視圖保持對齊一致,以便它們使用共通的詞彙,並且能夠協同使用,以利各種使用案例的需求
  • 湧現式轉換(Emergent transformation):如果類別檔案的讀取和寫入的方式能夠一致,那麼轉換便可以視為一種自然衍生的湧現特性(emergent property),而不需要設計專屬的特殊模式或新的介面。例如,ASM 讓讀取器和寫入器一起共用訪問者結構來達成目標。如果類別、欄位、方法和程式碼主體可以作為元素串流的形式去讀取和寫入,那麼轉換就可以被視為在此串流上的 flat-map 操作,其邏輯由 lambda 函式所定義
  • 隱藏細節:類別檔案的許多部分(例如常數池、啟動方法表、堆疊映射表等)都是從類別檔案的其他部分衍生出來的。因此,要求用戶直接建構這些部分是毫無道理的;這不僅增加了用戶的工作份量,也提高了出錯的機率。本 API 會根據加入的欄位、方法和指令去自動生成與其他實體緊密耦合的類別檔案實體
  • 擁抱語言特性:在 2002 年,ASM 使用的訪問者方法非常巧妙,並且比先前的做法更易於使用。直到現在,Java 程式語言已有了巨大的改進。除了 lambda、記錄類別、密封類別和模式匹配之外,Java 目前有用於描述常數的標準 API (java.lang.constant)。因此,我們可以使用這些新特性來設計一個更靈活、更好用、更簡潔且更不易出錯的 API

自然衍生的湧現式轉換

在處理 .class 檔時,我們經常需要「轉換」某些結構,例如新增欄位、修改方法、移除註解、插入 bytecode 等。現有的轉換工具或 API 可能會將讀取與寫入的邏輯完全分離,使得轉換流程變得複雜又難以維護。

如果讀取和寫入可以有一致的處理邏輯,並使用相同的介面與結構,那麼我們就不需要為了「轉換」類別檔案而使用額外設計的 API 或轉換專用函式。 也就是說,轉換能力就會變得像是 API 設計中一個自然而然的特性(emergent property)。例如,我們只需撰寫串流的 flatMap 邏輯或是 lambda 函式,就能在讀取與寫入之間轉換元素。

因此,湧現式轉換是一種自然形成的類別檔案轉換能力,源自於精心設計的類別檔案讀取和寫入工具,能夠保持讀寫間的高度一致性與協同性,並讓它們的結構和模型、詞彙和操作方式盡可能地相似與對齊。

元素、建構器和轉換

類別檔案 API 位在 java.lang.classfile 套件與其子套件中,它定義了三個主要抽象概念:

  • 元素(Element):用來描述類別檔案某一部分的不可變物件。它可以是指令、屬性、欄位、方法,甚至是整個類別檔案。有些元素是複合元素,例如方法,它除了自己是元素之外,還包含了其他的元素。這類元素可以被視為整體而一併處理,也可以進一步分解。
  • 建構器(Builder):每個複合元素類型都有相對應的建構器,並有特定的構建方法(例如,ClassBuilder::withMethod),並且也是相應元素類型的 Consumer
  • 轉換(Transform):表示一個函式,它接受一個元素和一個建構器,並調解該元素如何(如果有的話)被轉換為其他元素。

使用模式匹配去解析類別檔案

ASM 使用了訪問者模式去處理類別檔案的串流視圖,它在未支援模式匹配的語言中是一種合適的解決方案。然而時至今日,Java 已支援了模式匹配,因此我們可以用更直接且簡潔的語法。例如,如果要訪問 Code 屬性以收集其依賴關係,我們可以簡單地迭代指令並匹配感興趣的指令。

CodeModel 描述了 Code 屬性,我們可以迭代它的 CodeElements,並處理那些引用了其他型別的元素:

CodeModel codeModel = ...;
Set<ClassDesc> deps = new HashSet<>();
for (CodeElement ce : codeModel) {
    switch (ce) {
        case FieldInstruction f  -> deps.add(f.owner());
        case InvokeInstruction i -> deps.add(i.owner());
        // ... and so on for instanceof, cast, etc ...
    }
}

使用建構器(Builder)生成類別檔案

假設我們希望用程式碼動態生成下面的 fooBar 方法:

void fooBar(boolean z, int x) {
    if (z)
        Foo.foo(x);
    else
        Foo.bar(x);
}

在 ASM 中,MethodVisitor 同時是訪問者和建構器。我們可以先創建 ClassWriter 物件,然後向 ClassWriter 物件請求一個 MethodVisitor 物件,並接著由 MethodVisitor 物件生成所需要的方法內容:

ClassWriter classWriter = ...;
MethodVisitor mv = classWriter.visitMethod(0, "fooBar", "(ZI)V", null, null);
mv.visitCode();
mv.visitVarInsn(ILOAD, 1);
Label label1 = new Label();
mv.visitJumpInsn(IFEQ, label1);
mv.visitVarInsn(ALOAD, 0);
mv.visitVarInsn(ILOAD, 2);
mv.visitMethodInsn(INVOKEVIRTUAL, "Foo", "foo", "(I)V", false);
Label label2 = new Label();
mv.visitJumpInsn(GOTO, label2);
mv.visitLabel(label1);
mv.visitVarInsn(ALOAD, 0);
mv.visitVarInsn(ILOAD, 2);
mv.visitMethodInsn(INVOKEVIRTUAL, "Foo", "bar", "(I)V", false);
mv.visitLabel(label2);
mv.visitInsn(RETURN);
mv.visitEnd();

JEP 484 的類別檔案 API 則反轉了這種習慣用法,它不使用建構函式或工廠方法去建構,而是提供一個接受建構器的 lambda。下例中,我們呼叫類別建構器物件 classBuilderwithMethod 方法去添加一個新的 fooBar 方法。

實際的建構過程不會透過 classBuilder 執行(與 MethodVisitor 物件不同),而是讓傳入的 methodBuildercodeBuilder 的 lambda 表示式去生成:

classBuilder.withMethod("fooBar", MethodTypeDesc.of(CD_void, CD_boolean, CD_int), flags,
                        methodBuilder -> methodBuilder.withCode(codeBuilder -> {
    Label label1 = codeBuilder.newLabel();
    Label label2 = codeBuilder.newLabel();
    codeBuilder.iload(1)
        .ifeq(label1)
        .aload(0)
        .iload(2)
        .invokevirtual(ClassDesc.of("Foo"), "foo", MethodTypeDesc.of(CD_void, CD_int))
        .goto_(label2)
        .labelBinding(label1)
        .aload(0)
        .iload(2)
        .invokevirtual(ClassDesc.of("Foo"), "bar", MethodTypeDesc.of(CD_void, CD_int))
        .labelBinding(label2)
        .return_();
}));

將操作步驟封裝在 lambda 中,可以讓我們獲得重做(replay)和重用(reuse)的可能性,這使得函式庫能夠完成之前必須由客戶端來做的工作。例如,分支偏移量可短可長,如果客戶端以命令式去生成指令,那麼在生成分支時就必須計算每個分支的偏移量大小,而這是個既複雜又容易出錯的事。但如果客戶端提供了一個接受建構器的 lambda,那麼函式庫就可以大膽地試著用短偏移量去生成方法。如果失敗,則丟棄已生成的狀態並使用不同的程式碼生成參數重新調用該 lambda。

除了上述的寫法之外,我們也可以用更簡便易讀的方式,去管理區塊作用域和區域變數索引計算,以及減少手動的標籤管理和分支操作。下面以另外一種方式去重寫了上面的例子,可以注意到我們省略了標籤(LabellabelBiding)和分支(ifeqgoto_)相關的程式碼,並將它包裝成 Consumer<ClassBuilder> 物件:

import static java.lang.constant.ConstantDescs.*;

int flags = ClassFile.ACC_PUBLIC;
Consumer<ClassBuilder> methodFooBar = classBuilder -> {
    classBuilder.withMethod("fooBar", MethodTypeDesc.of(CD_void, CD_boolean, CD_int), flags,
                            methodBuilder -> methodBuilder.withCode(codeBuilder -> {
        codeBuilder.iload(codeBuilder.parameterSlot(0))
                   .ifThenElse(
                       b1 -> b1.aload(codeBuilder.receiverSlot())
                               .iload(codeBuilder.parameterSlot(1))
                               .invokevirtual(ClassDesc.of("Foo"), "foo",
                                              MethodTypeDesc.of(CD_void, CD_int)),
                       b2 -> b2.aload(codeBuilder.receiverSlot())
                               .iload(codeBuilder.parameterSlot(1))
                               .invokevirtual(ClassDesc.of("Foo"), "bar",
                                              MethodTypeDesc.of(CD_void, CD_int)))
                   .return_();
    }));
}

由於區塊作用域由 API 所管理,所以我們不需要去生成標籤或分支指令。同樣地,API 也可以選擇性地管理區塊作用域內的區域變數配置,從而將客戶端從區域變數槽位的繁重工作中解放出來。

設定好 Consumer 物件之後,我們就可以產生真實類別檔案的位元組內容 classBytes,並儲存到磁碟空間中,檔名為 FooBarTest.class。也可以再反向去解析生成的內容去檢查有哪些元素:

// 生成位元組陣列內容,類別名稱為 FooBarTest
byte[] classBytes = ClassFile.of().build(ClassDesc.of("FooBarTest"), methodFooBar);

// 存檔成 FooBarTest.class
Path outputPath = Paths.get("FooBarTest.class");
Files.write(outputPath, classBytes);

// 解析
ClassModel classModel = ClassFile.of().parse(classBytes);
for (ClassElement ce : classModel) { 
    System.out.println(ce);
}

雖然我們只定義了 methodFooBar 方法並添加進 FooBarTest 類別中,不過從解析後的結果中可以看到,API 幫我們將存取限制、檔案版本、父類別、介面等相關資訊都一併設定完成了:

AccessFlags[flags=1]
ClassFileVersion[majorVersion=68, minorVersion=0]
Superclass[superclassEntry=java/lang/Object]
Interfaces[interfaces=]
MethodModel[methodName=fooBar, methodType=(ZI)V, flags=0]

如果想要檢查 methodFooBar 的內容,可以使用 MethodModel 將指令都列出來:

for (MethodModel mm : classModel.methods()) {
    for (CodeElement e : mm.code().get()) {
        System.out.println(e);
    }
}

輸出結果如下,和前述範例中預期要寫入的內容相同:

Load[OP=ILOAD_1, slot=1]
Branch[OP=IFEQ]
Load[OP=ALOAD_0, slot=0]
Load[OP=ILOAD_2, slot=2]
Invoke[OP=INVOKEVIRTUAL, m=Foo.foo(I)V]
Branch[OP=GOTO]
Label[context=CodeModel[id=1156060786], bci=12]
Load[OP=ALOAD_0, slot=0]
Load[OP=ILOAD_2, slot=2]
Invoke[OP=INVOKEVIRTUAL, m=Foo.bar(I)V]
Label[context=CodeModel[id=1156060786], bci=17]
Return[OP=RETURN]

轉換類別檔案

前面有提到,API 的解析和生成功能已設計成互相對齊,使得轉換過程可以無縫進行。最上面的解析範例中,我們訪問了整個 CodeElements 序列,並對單一元素進行匹配。建構器也同樣能接受 CodeElements,因此典型的轉換慣用法便自然而然地形成。

假設我們想要處理一個 class 檔案,只移除名稱以 debug 開頭的方法,其餘內容則保持不變。那麼,我們可以取得一個 ClassModel、建立一個 ClassBuilder、遍歷原始 ClassModel 的所有元素,然後將所有元素傳遞給建構器,除了我們想要捨棄的方法:

ClassFile cf = ClassFile.of();
ClassModel classModel2 = cf.parse(classBytes);
byte[] newBytes = cf.build(classModel2.thisClass().asSymbol(),
        classBuilder -> {
            for (ClassElement ce : classModel2) {
                if (!(ce instanceof MethodModel mm
                        && mm.methodName().stringValue().startsWith("debug"))) {
                    classBuilder.with(ce);
                }
            }
        });

相較於上例,轉換方法主體則稍微複雜一點,因為我們必須:

  1. 將類別分解成各個組成部分:欄位、方法和屬性
  2. 選擇方法元素並分解,包括程式碼屬性
  3. 將程式碼屬性分解成指令

下面的轉換範例是將類別 Foo 上的方法呼叫換成對類別 Bar 上的方法呼叫:

ClassFile cf = ClassFile.of();
ClassModel classModel2 = cf.parse(classBytes);
byte[] newBytes = cf.build(classModel2.thisClass().asSymbol(),
    classBuilder -> {
      for (ClassElement ce : classModel2) {
        if (ce instanceof MethodModel mm) {
          classBuilder.withMethod(mm.methodName(), mm.methodType(),
              mm.flags().flagsMask(), methodBuilder -> {
                for (MethodElement me : mm) {
                  if (me instanceof CodeModel codeModel) {
                    methodBuilder.withCode(codeBuilder -> {
                      for (CodeElement e : codeModel) {
                        switch (e) {
                          case InvokeInstruction i
                              when i.owner().asInternalName().equals("Foo") ->
                            codeBuilder.invoke(i.opcode(),
                                               ClassDesc.of("Bar"),
                                               i.name().stringValue(),
                                               i.typeSymbol(),
                                               i.isInterface());
                          default -> codeBuilder.with(e);
                        }
                      }
                    });
                  }
                  else
                    methodBuilder.with(me);
                }
              });
        }
        else
          classBuilder.with(ce);
      }
    });

上例以樹狀遍歷的角度出發,將類別檔案一直往下分解並解析各個元素,從 ClassModelClassElementMethodModelMethodElementCodeModelCodeElement 一路到 InvokeInstruction。程式碼中訪問了類別檔案的元素樹,並存在著多個層級重複的樣板程式碼。雖然這在過往的函式庫中是很常見的做法,但是在易讀性和維護性上並不友善。

JEP 484 簡化了這些程式樣板。它可用使用轉換方法去接受一個建構器和一個元素,去替換該元素、刪除該元素,或是將該元素傳遞給建構器。轉換是函數式介面,因此轉換邏輯可以用 lambda 表示式撰寫。我們可以將前面的例子重寫如下:

ClassFile cf = ClassFile.of();
ClassModel classModel2 = cf.parse(classBytes);
byte[] newBytes = cf.transformClass(classModel2, (classBuilder, ce) -> {
  if (ce instanceof MethodModel mm) {
    classBuilder.transformMethod(mm, (methodBuilder, me)-> {
      if (me instanceof CodeModel cm) {
        methodBuilder.transformCode(cm, (codeBuilder, e) -> {
          switch (e) {
            case InvokeInstruction i
                when i.owner().asInternalName().equals("Foo") ->
              codeBuilder.invoke(i.opcode(), 
                                 ClassDesc.of("Bar"),
                                 i.name().stringValue(),
                                 i.typeSymbol(),
                                 i.isInterface());
            default -> codeBuilder.with(e);
          }
        });
      }
      else
        methodBuilder.with(me);
    });
  }
  else
    classBuilder.with(ce);
});

迭代樣板不見了,我們用 transformXxxx 方法取代了 XxxxElement 的相關程式碼。雖然程式碼縮短了一部分,但是為了存取指令而深度嵌套多個 lambda 表示法的語法,依然令人卻步。所以更進一步,我們可以將欲執行的指令程式碼先提煉成 CodeTransform 物件 :

CodeTransform codeTransform = (codeBuilder, e) -> {
    switch (e) {
        case InvokeInstruction i when i.owner().asInternalName().equals("Foo") ->
            codeBuilder.invoke(i.opcode(),
                               ClassDesc.of("Bar"),
                               i.name().stringValue(),
                               i.typeSymbol(),
                               i.isInterface());
        default -> codeBuilder.accept(e);
    }
};

然後,我們將這個 CodeTransform 程式碼轉換物件提升為 MethodTransform 方法轉換物件。當方法轉換物件遇到 Code 屬性時,它會使用程式碼轉換物件去對其進行轉換,並將所有其他方法元素不變地傳遞下去:

MethodTransform methodTransform = MethodTransform.transformingCode(codeTransform);

繼續提升下去,我們可以將方法轉換物件提升為 ClassTransform 類別轉換物件:

ClassTransform classTransform = ClassTransform.transformingMethods(methodTransform);

現在,之前複雜的範例變得很簡單易讀:

CodeTransform codeTransform = (codeBuilder, e) -> {
    switch (e) {
        case InvokeInstruction i when i.owner().asInternalName().equals("Foo") ->
            codeBuilder.invoke(i.opcode(),
                               ClassDesc.of("Bar"),
                               i.name().stringValue(),
                               i.typeSymbol(),
                               i.isInterface());
        default -> codeBuilder.accept(e);
    }
};
MethodTransform methodTransform = MethodTransform.transformingCode(codeTransform);
ClassTransform classTransform = ClassTransform.transformingMethods(methodTransform);
ClassFile cf = ClassFile.of();
byte[] newBytes = cf.transformClass(cf.parse(classBytes), classTransform);

將生成的 newBytes 位元組陣列重新用前一節的範例程式碼讀取解析並列出指令,可以看到原本 bytecode 中呼叫 Foo.foo()Foo.bar() 的指令,已改成呼叫 Bar.foo()Bar.bar() 了。這代表 CodeTransform 物件的確有成功將 Foo 類別轉換成 Bar 類別:

Load[OP=ILOAD_1, slot=1]
Branch[OP=IFEQ]
Load[OP=ALOAD_0, slot=0]
Load[OP=ILOAD_2, slot=2]
Invoke[OP=INVOKEVIRTUAL, m=Bar.foo(I)V]
Branch[OP=GOTO]
Label[context=CodeModel[id=1674896058], bci=12]
Load[OP=ALOAD_0, slot=0]
Load[OP=ILOAD_2, slot=2]
Invoke[OP=INVOKEVIRTUAL, m=Bar.bar(I)V]
Label[context=CodeModel[id=1674896058], bci=17]
Return[OP=RETURN]
JEP 484_Parse new bytes

總結

JEP 484 接續 JEP 466 的工作,為類別檔案的解析、生成和轉換提供了極為便利的標準函式庫。透過這些具備著現代化設計理念的 API,開發人員能夠使用更好的工具來處理日益複雜的類別檔案與操作需求,並提高了程式碼的安全性、可移植性和可維護性。

類別檔案 API 已在 Java 24 中成為正式功能,接下來許多圍繞著它的新功能會陸續展開設計與實作,為未來的功能擴展提供更穩固的基礎。隨著這個 API 的成熟,可以期待看到更多強大且易用的工具和框架出現。

本篇文章的內容為老喬原創、二創或翻譯而來。雖已善盡校對、順稿與查核義務,但人非聖賢,多少仍會有疏漏之處難以避免。如果大家有任何問題、建議或指教,都歡迎在底下留言與老喬討論!

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *

twelve − eleven =

目錄
返回頂端