歡迎光臨
每天分享高質量文章

Java 日誌快取機制的實現

(點選上方公眾號,可快速關註)


來源:盛江濤, 李思舒, 趙海兵 ,

www.ibm.com/developerworks/cn/java/j-lo-logbuffer/index.html

概述

日誌技術為產品的質量和服務提供了重要的支撐。JDK 在 1.4 版本以後加入了日誌機制,為 Java 開發人員提供了便利。但這種日誌機制是基於靜態日誌級別的,也就是在程式執行前就需設定下來要列印的日誌級別,這樣就會帶來一些不便。

在 JDK 提供的日誌功能中,日誌級別被細化為 9 級,用以區分不同日誌的用途,用來記錄一個錯誤,或者記錄正常執行的資訊,又或是記錄詳細的除錯資訊。由於日誌級別是靜態的,如果日誌級別設定過高,低階別的日誌難以打印出來,從而導致在錯誤發生時候,難以去追蹤錯誤的發生原因,目前常常採用的方式是在錯誤發生的時候,不得不先調整日誌級別到相對低的程度,然後再去觸發錯誤,使得問題根源得到顯現。但是這種發生問題需要改動產品配置,然後重新觸發問題進行除錯的方式使得產品使用者體驗變差,而且有些問題會因為偶發性,環境很複雜等原因很難重新觸發。

相反,如果起初就把日誌級別調整到比較低,那麼日誌中間會有大量無用資訊,而且當產品比較複雜的時候,會導致產生的日誌檔案很大,掃清很快,無法及時的記錄有效的資訊,甚至成為效能瓶頸,從而降低了日誌功能對產品的幫助。

本文藉助 Java Logging 中的 MemoryHandler 類將所有級別日誌快取起來,在適當時刻輸出,來解決這個問題。主要圍繞 MemoryHandler 的定義和 logging.properties 檔案的處理而展開。

實體依附的場景如下,設想使用者需要在產品發生嚴重錯誤時,檢視先前發生的包含 Exception 的錯誤資訊,以此作為診斷問題緣由的依據。使用 Java 緩衝機製作出的一個解決方案是,將所有產品執行過程中產生的包含 Exception 的日誌條目儲存在一個可設定大小的迴圈緩衝佇列中,當嚴重錯誤(SEVERE)發生時,將緩衝佇列中的日誌輸出到指定平臺,供使用者查閱。

Java 日誌機制的介紹

Java 日誌機制在很多文章中都有介紹,為了便於後面文章部分的理解,在這裡再簡單介紹一下本文用到的一些關鍵字。

Level:JDK 中定義了 Off、Severe、Warning、Info、Config、Fine、Finer、Finest、All 九個日誌級別,定義 Off 為日誌最高等級,All 為最低等級。每條日誌必須對應一個級別。級別的定義主要用來對日誌的嚴重程度進行分類,同時可以用於控制日誌是否輸出。

LogRecord:每一條日誌會被記錄為一條 LogRecord, 其中儲存了類名、方法名、執行緒 ID、列印的訊息等等一些資訊。

Logger:日誌結構的基本單元。Logger 是以樹形結構儲存在記憶體中的,根節點為 root。com.test(如果存在)一定是 com.test.demo(如果存在)的父節點,即字首匹配的已存在的 logger 一定是這個 logger 的父節點。這種父子關係的定義,可以為使用者提供更為自由的控制粒度。因為子節點中如果沒有定義處理規則,如級別 handler、formatter 等,那麼預設就會使用父節點中的這些處理規則。

Handler:用來處理 LogRecord,預設 Handler 是可以連線成一個鏈狀,依次對 LogRecord 進行處理。

Filter:日誌過濾器。在 JDK 中,沒有實現。

Formatter:它主要用於定義一個 LogRecord 的輸出格式。

圖 1 展示了一個 LogRecord 的處理流程。一條日誌進入處理流程首先是 Logger,其中定義了可透過的 Level,如果 LogRecord 的 Level 高於 Logger 的等級,則進入 Filter(如果有)過濾。如果沒有定義 Level,則使用父 Logger 的 Level。Handler 中過程類似,其中 Handler 也定義了可透過 Level,然後進行 Filter 過濾,透過如果後面還有其他 Handler,則直接交由後面的 Handler 進行處理,否則會直接系結到 formatter 上面輸出到指定位置。

在實現日誌快取之前,先對 Filter 和 Formatter 兩個輔助類進行介紹。

Filter

Filter 是一個介面,主要是對 LogRecord 進行過濾,控制是否對 LogRecord 進行進一步處理,其可以系結在 Logger 下或 Handler 下。

只要在 boolean isLoggable(LogRecord)方法中加上過濾邏輯就可以實現對 logrecord 進行控制,如果只想對發生了 Exception 的那些 log 記錄進行記錄,那麼可以透過清單 1 來實現,當然首先需要將該 Filter 透過呼叫 setFilter(Filter)方法或者配置檔案方式系結到對應的 Logger 或 Handler。

清單 1. 一個 Filter 實體的實現

@Override 

public boolean isLoggable(LogRecord record){ 

if(record.getThrown()!=null){ 

       return true; 

}else{ 

        return false;  

}

Formatter

Formatter 主要是對 Handler 在輸出 log 記錄的格式進行控制,比如輸出日期的格式,輸出為 HTML 還是 XML 格式,文字引數替換等。Formatter 可以系結到 Handler 上,Handler 會自動呼叫 Formatter 的 String format(LogRecord r) 方法對日誌記錄進行格式化,該方法具有預設的實現,如果想實現自定義格式可以繼承 Formater 類並重寫該方法,預設情況下例如清單 2 在經過 Formatter 格式化後,會將 {0} 和 {1} 替換成對應的引數。

清單 2. 記錄一條 log

logger.log(Level.WARNING,”this log is for test1: {0} and test2:{1}”, 

    new Object[]{newTest1(), 

    new Test2()});

MemoryHandler

MemoryHandler 是 Java Logging 中兩大類 Handler 之一,另一類是 StreamHandler,二者直接繼承於 Handler,代表了兩種不同的設計思路。Java Logging Handler 是一個抽象類,需要根據使用場景建立具體 Handler,實現各自的 publish、flush 以及 close 等方法。

MemoryHandler 使用了典型的“註冊 – 通知”的觀察者樣式。MemoryHandler 先註冊到對自己感興趣的 Logger 中(logger.addHandler(handler)),在這些 Logger 呼叫釋出日誌的 API:log()、logp()、logrb() 等,遍歷這些 Logger 下系結的所有 Handlers 時,通知觸發自身 publish(LogRecord)方法的呼叫,將日誌寫入 buffer,當轉儲到下一個日誌釋出平臺的條件成立,轉儲日誌並清空 buffer。

這裡的 buffer 是 MemoryHandler 自身維護一個可自定義大小的迴圈緩衝佇列,來儲存所有執行時觸發的 Exception 日誌條目。同時在建構式中要求指定一個 Target Handler,用於承接輸出;在滿足特定 flush buffer 的條件下,如日誌條目等級高於 MemoryHandler 設定的 push level 等級(實體中定義為 SEVERE)等,將日誌移交至下一步輸出平臺。從而形成如下日誌轉儲輸出鏈:

在實體中,透過對 MemoryHandler 配置項 .push 的 Level 進行判斷,決定是否將日誌推向下一個 Handler,通常在 publish() 方法內實現。程式碼清單如下:

清單 3

// 只紀錄有異常並且高於 pushLevel 的 logRecord 

final Level level = record.getLevel();        

final Throwable thrown = record.getThrown(); 

If(level >= pushLevel){ 

   push(); 

}

MemoryHandler.push 方法的觸發條件

Push 方法會導致 MemoryHandler 轉儲日誌到下一 handler,清空 buffer。觸發條件可以是但不侷限於以下幾種,實體中使用的是預設的第一種:

  • 日誌條目的 Level 大於或等於當前 MemoryHandler 中預設定義或使用者配置的 pushLevel;

  • 外部程式呼叫 MemoryHandler 的 push 方法;

  • MemoryHandler 子類可以多載 log 方法或自定義觸發方法,在方法中逐一掃描日誌條目,滿足自定義規則則觸發轉儲日誌和清空 buffer 的操作。MemoryHanadler 的可配置屬性

使用方式

以上是記錄產品 Exception 錯誤日誌,以及如何轉儲的 MemoryHandler 處理的內部細節;接下來給出 MemoryHandler 的一些使用方式。

1. 直接使用 java.util.logging 中的 MemoryHandler

清單 4

// 在 buffer 中維護 5 條日誌資訊

// 僅記錄 Level 大於等於 Warning 的日誌條目並

// 掃清 buffer 中的日誌條目到 fileHandler 中處理

         int bufferSize = 5; 

         f = new FileHandler(“testMemoryHandler.log”); 

         m = new MemoryHandler(f, bufferSize, Level.WARNING); 

         …

         myLogger = Logger.getLogger(“com.ibm.test”); 

         myLogger.addHandler(m); 

         myLogger.log(Level.WARNING, “this is a WARNING log”);

2. 自定義

1)反射

思考自定義 MyHandler 繼承自 MemoryHandler 的場景,由於無法直接使用作為父類私有屬性的 size、buffer 及 buffer 中的 cursor,如果在 MyHandler 中有獲取和改變這些屬性的需求,一個途徑是使用反射。清單 5 展示了使用反射讀取使用者配置並設定私有屬性。

清單 5

int m_size; 

 String sizeString = manager.getProperty(loggerName + “.size”); 

 if (null != sizeString) { 

        try { 

         m_size = Integer.parseInt(sizeString); 

         if (m_size <= 0) { 

            m_size = BUFFER_SIZE; // default 1000 

         } 

// 透過 java 反射機制獲取私有屬性

         Field f; 

         f = getClass().getSuperclass().getDeclaredField(“size”); 

         f.setAccessible(true); 

         f.setInt(this, m_size); 

         f = getClass().getSuperclass().getDeclaredField(“buffer”); 

         f.setAccessible(true); 

         f.set(this, new LogRecord[m_size]); 

        } catch (Exception e) { 

        } 

 }

2)重寫

直接使用反射方便快捷,適用於對父類私有屬性無頻繁訪問的場景。思考這樣一種場景,預設環形佇列無法滿足我們儲存需求,此時不妨令自定義的 MyMemoryHandler 直接繼承 Handler,直接對儲存結構進行操作,可以透過清單 6 實現。

清單 6

public class MyMemoryHandler extends Handler{ 

 // 預設儲存 LogRecord 的緩衝區容量

 private static final int DEFAULT_SIZE = 1000; 

 // 設定緩衝區大小

 private int size = DEFAULT_SIZE; 

 // 設定緩衝區

 private LogRecord[] buffer; 

 // 參考 java.util.logging.MemoryHandler 實現其它部分

 … 

}

使用 MemoryHandler 時需關註的幾個問題

瞭解了使用 MemoryHandler 實現的 Java 日誌緩衝機制的內部細節和外部應用之後,來著眼於兩處具體實現過程中遇到的問題:Logger/Handler/LogRecord Level 的傳遞影響,以及如何在開發 MemoryHandler 過程中處理錯誤日誌。

1. Level 的傳遞影響

Java.util.logging 中有三種型別的 Level,分別是 Logger 的 Level,Handler 的 Level 和 LogRecord 的 Level. 前兩者可以透過配置檔案設定。之後將日誌的 Level 分別與 Logger 和 Handler 的 Level 進行比較,過濾無須記錄的日誌。在使用 Java Log 時需關註 Level 之間相互影響的問題,尤其在遍歷 Logger 系結了多個 Handlers 時。如圖 3 所示:

Java.util.logging.Logger 提供的 setUseParentHandlers 方法,也可能會影響到最終輸出終端的日誌顯示。這個方法允許使用者將自身的日誌條目列印一份到 Parent Logger 的輸出終端中。預設會列印到 Parent Logger 終端。此時,如果 Parent Logger Level 相關的設定與自身 Logger 不同,則列印到 Parent Logger 和自身中的日誌條目也會有所不同。如圖 4 所示:

2. 開發 log 介面過程中處理錯誤日誌

在開發 log 相關介面中呼叫自身介面列印 log,可能會陷入無限迴圈。Java.util.logging 中考慮到這類問題,提供了一個 ErrorManager 介面,供 Handler 在記錄日誌期間報告任何錯誤,而非直接丟擲異常或呼叫自身的 log 相關介面記錄錯誤或異常。Handler 需實現 setErrorManager() 方法,該方法為此應用程式構造 java.util.logging.ErrorManager 物件,併在錯誤發生時,透過 reportError 方法呼叫 ErrorManager 的 error 方法,預設將錯誤輸出到標準錯誤流,或依據 Handler 中自定義的實現處理錯誤流。關閉錯誤流時,使用 Logger.removeHandler 移除此 Handler 實體。

兩種經典使用場景,一種是自定義 MyErrorManager,實現父類相關介面,在記錄日誌的程式中呼叫 MyHandler.setErrorManager(new MyEroorManager()); 另一種是在 Handler 中自定義 ErrorManager 相關方法,示例如清單 7:

清單 7

public class MyHandler extends Handler{ 

// 在構造方法中實現 setErrorManager 方法

public MyHandler(){ 

   ……

    setErrorManager (new ErrorManager() { 

        public void  error (String msg, Exception ex, int code) { 

            System.err.println(“Error reported by MyHandler “

                             + msg + ex.getMessage()); 

        } 

    }); 

public void publish(LogRecord record){ 

    if (!isLoggable(record)) return; 

    try { 

        // 一些可能會丟擲異常的操作

    } catch(Exception e) { 

        reportError (“Error occurs in publish “, e, ErrorManager.WRITE_FAILURE); 

    } 

……

}

logging.properties

logging.properties 檔案是 Java 日誌的配置檔案,每一行以“key=value”的形式描述,可以配置日誌的全域性資訊和特定日誌配置資訊,清單 8 是我們為測試程式碼配置的 logging.properties。

清單 8. logging.properties 檔案示例

#Level 等級 OFF > SEVERE > WARNING > INFO > CONFIG > FINE > FINER > FINEST > ALL 

# 為 FileHandler 指定日誌級別

java.util.logging.FileHandler.level=WARNING 

# 為 FileHandler 指定 formatter 

java.util.logging.FileHandler.formatter=java.util.logging.SimpleFormatter 

# 為自定義的 TestMemoryHandler 指定日誌級別

com.ibm.test.MemoryHandler.level=INFO 

# 設定 TestMemoryHandler 最多記錄日誌條數

com.ibm.test.TestMemoryHandler.size=1000 

# 設定 TestMemoryHandler 的自定義域 useParentLevel 

com.ibm.test.TestMemoryHandler.useParentLevel=WARNING 

# 設定特定 log 的 handler 為 TestMemoryHandler 

com.ibm.test.handlers=com.ibm.test.TestMemoryHandler 

# 指定全域性的 Handler 為 FileHandler 

handlers=java.util.logging.FileHandler

從 清單 8 中可以看出 logging.properties 檔案主要是用來給 logger 指定等級(level),配置 handler 和 formatter 資訊。

如何監聽 logging.properties

如果一個系統對安全性要求比較高,例如系統需要對更改 logging.properties 檔案進行日誌記錄,記錄何時何人更改了哪些記錄,那麼應該怎麼做呢?

這裡可以利用 JDK 提供的 PropertyChangeListener 來監聽 logging.properties 檔案屬性的改變。

例如建立一個 LogPropertyListener 類,其實現了 java.benas.PropertyChangeListener 介面,PropertyChangeListener 介面中只包含一個 propertyChange(PropertyChangeEvent)方法,該方法的實現如清 9 所示。

清單 9. propertyChange 方法的實現

@Override 

public void propertyChange(PropertyChangeEvent event) { 

   if (event.getSource() instanceof LogManager){ 

       LogManager manager=(LogManager)event.getSource(); 

       update(manager); 

       execute(); 

       reset(); 

   } 

}

propertyChange(PropertyChangeEvent)方法中首先呼叫 update(LogManager)方法來找出 logging.properties 檔案中更改的,增加的以及刪除的項,這部分程式碼如清單 10 所示;然後呼叫 execute() 方法來執行具體邏輯,參見 清單 11;最後呼叫 reset() 方法對相關屬性儲存以及清空,如 清單 12 所示。

清單 10. 監聽改變的條目

public void update(LogManager manager){ 

 Properties logProps = null ; 

  // 使用 Java 反射機制獲取私有屬性

   try { 

     Field f = manager.getClass().getDeclaredField(“props”); 

     f.setAccessible(true ); 

     logProps=(Properties)f.get(manager); 

    }catch (Exception e){ 

       logger.log(Level.SEVERE,”Get private field error.”, e); 

        return ; 

   } 

   Set logPropsName=logProps.stringPropertyNames(); 

    for (String logPropName:logPropsName){ 

        String newVal=logProps.getProperty(logPropName).trim(); 

       // 記錄當前的屬性

       newProps.put(logPropName, newVal);   

       // 如果給屬性上次已經記錄過

       if (oldProps.containsKey(logPropName)){ 

            String oldVal = oldProps.get(logPropName); 

            if (newVal== null ?oldVal== null :newVal.equals(oldVal)){ 

           // 屬性值沒有改變,不做任何操作

        }else { 

            changedProps.put(logPropName, newVal); 

       } 

       oldProps.remove(logPropName); 

   }else {// 如果上次沒有記錄過該屬性,則其應為新加的屬性,記錄之

        changedProps.put(logPropName, newVal);               

       } 

    } 

}

程式碼中 oldProps、newProps 以及 changedProps 都是 HashMap 型別,oldProps 儲存修改前 logging.properties 檔案內容,newProps 儲存修改後 logging.properties 內容,changedProps 主要用來儲存增加的或者是修改的部分。

方法首先透過 Java 的反射機制獲得 LogManager 中的私有屬性 props(儲存了 logging.properties 檔案中的屬性資訊),然後透過與 oldProps 比較可以得到增加的以及修改的屬性資訊,最後 oldProps 中剩下的就是刪除的資訊了。

清單 11. 具體處理邏輯方法

private void execute(){ 

 // 處理刪除的屬性

 for (String prop:oldProps.keySet()){ 

   // 這裡可以加入其它處理步驟

   logger.info(“‘”+prop+”=”+oldProps.get(prop)+”‘has been removed”);           

 } 

 // 處理改變或者新加的屬性

 for (String prop:changedProps.keySet()){ 

     // 這裡可以加入其它處理步驟

     logger.info(“‘”+prop+”=”+oldProps.get(prop)+”‘has been changed or added”); 

 } 

}

該方法是主要的處理邏輯,對修改或者刪除的屬性進行相應的處理,比如記錄屬性更改日誌等。這裡也可以獲取當前系統的登入者,和當前時間,這樣便可以詳細記錄何人何時更改過哪個日誌條目。

清單 12. 重置所有資料結構

private void reset(){ 

oldProps = newProps; 

newProps= new HashMap< String,String>(); 

changedProps.clear(); 

}

reset() 方法主要是用來重置各個屬性,以便下一次使用。

當然如果只寫一個 PropertyChangeListener 還不能發揮應有的功能,還需要將這個 PropertyChangeListener 實體註冊到 LogManager 中,可以透過清單 13 實現。

清單 13. 註冊 PropertyChangeListener

// 為’logging.properties’檔案註冊監聽器

LogPropertyListener listener= new LogPropertyListener(); 

LogManager.getLogManager().addPropertyChangeListener(listener);

如何實現自定義標簽

在 清單 8中有一些自定義的條目,比如 com.ibm.test.TestMemoryHandler。

useParentLever=WARNING”,表示如果日誌等級超過 useParentLever 所定義的等級 WARNING 時,該條日誌在 TestMemoryHandler 處理後需要傳遞到對應 Log 的父 Log 的 Handler 進行處理(例如將發生了 WARNING 及以上等級的日誌背景關係快取資訊列印到檔案中),否則不傳遞到父 Log 的 Handler 進行處理,這種情況下如果不做任何處理,Java 原有的 Log 機制是不支援這種定義的。那麼如何使得 Java Log 支援這種自定義標簽呢?這裡可以使用 PropertyListener 對自定義標簽進行處理來使得 Java Log 支援這種自定義標簽,例如對“useParentLever”進行處理可以透過清單 14 實現。

清單 14

    private void execute(){ 

       // 處理刪除的屬性

        for (String prop:oldProps.keySet()){ 

            if (prop.endsWith(“.useParentLevel”)){ 

               String logName=prop.substring(0, prop.lastIndexOf(“.”)); 

               Logger log=Logger.getLogger(logName); 

                for (Handler handler:log.getHandlers()){ 

                    if (handler  instanceof TestMemoryHandler){ 

                       ((TestMemoryHandler)handler) 

                           .setUseParentLevel(oldProps.get(prop)); 

                        break ; 

                   } 

               } 

           } 

       } 

       // 處理改變或者新加的屬性

        for (String prop:changedProps.keySet()){ 

            if (prop.endsWith(“.useParentLevel”)){ 

               // 在這裡新增邏輯處理步驟

           } 

       } 

}

在清單 14 處理之後,就可以在自定義的 TestMemoryHandler 中進行判斷了,對 log 的等級與其域 useParentLevel 進行比較,決定是否傳遞到父 Log 的 Handler 進行處理。在自定義 TestMemoryHandler 中儲存對應的 Log 資訊可以很容易的實現將資訊傳遞到父 Log 的 Handler,而儲存對應 Log 資訊又可以透過 PropertyListener 來實現,例如清單 15 更改了 清單 13中相應程式碼實現這一功能。

清單 15

if (handler  instanceof TestMemoryHandler){ 

    ((TestMemoryHandler)handler).setUseParentLevel(oldProps.get(prop)); 

    ((TestMemoryHandler)handler).addLogger(log); 

      break ; 

}

具體如何處理自定義標簽的值那就看程式的需要了,透過這種方法就可以很容易在 logging.properties 新增自定義的標簽了。

自定義讀取配置檔案

如果 logging.properties 檔案更改了,需要透過呼叫 readConfiguration(InputStream)方法使更改生效,但是從 JDK 的原始碼中可以看到 readConfiguration(InputStream)方法會重置整個 Log 系統,也就是說會把所有的 log 的等級恢復為預設值,將所有 log 的 handler 置為 null 等,這樣所有儲存的資訊就會丟失。

比如,TestMemoryHandler 快取了 1000 條 logRecord,現在使用者更改了 logging.properties 檔案,並且呼叫了 readConfiguration(InputStream) 方法來使之生效,那麼由於 JDK 本身的 Log 機制,更改後對應 log 的 TestMemoryHandler 就是新建立的,那麼原來儲存的 1000 條 logRecord 的 TestMemoryHandler 實體就會丟失。

那麼這個問題應該如何解決呢?這裡給出三種思路:

1). 由於每個 Handler 都有一個 close() 方法(任何繼承於 Handler 的類都需要實現該方法),Java Log 機制在將 handler 置為 null 之前會呼叫對應 handler 的 close() 方法,那麼就可以在 handler(例如 TestMemoryHandler)的 close() 方法中儲存下相應的資訊。

2). 研究 readConfiguration(InputStream)方法,寫一個替代的方法,然後每次呼叫替代的方法。

3). 繼承 LogManager 類,改寫 readConfiguration(InputStream)方法。

這裡第一種方法是儲存原有的資訊,然後進行恢復,但是這種方法不是很實用和高效;第二和第三種方法其實是一樣的,都是寫一個替代的方法,例如可以在替代的方法中對 Handler 為 TestMemoryHandler 的不置為 null,然後在讀取 logging.properties 檔案時發現為 TestMemoryHandler 屬性時,找到對應 TestMemoryHandler 的實體,並更改相應的屬性值(這個在清單 14 中有所體現),其他不屬於 TestMemoryHandler 屬性值的可以按照 JDK 原有的處理邏輯進行處理,比如設定 log 的 level 等。

另一方面,由於 JDK1.6 及之前版本不支援檔案修改監聽功能,每次修改了 logging.properties 檔案後需要顯式呼叫 readConfiguration(InputStream)才能使得修改生效,但是自 JDK1.7 開始已經支援對檔案修改監聽功能了,主要是在 java.nio.file.* 包中提供了相關的 API,這裡不再詳述。

那麼在 JDK1.7 之前,可以使用 apache 的 commons-io 庫中的 FileMonitor 類,在此也不再詳述。

總結

透過對 MemoryHandler 和 logging.properties 進行定義,可以透過 Java 日誌實現自定義日誌快取,從而提高 Java 日誌的可用性,為產品質量提供更強有力的支援。

【關於投稿】


如果大家有原創好文投稿,請直接給公號傳送留言。


① 留言格式:
【投稿】+《 文章標題》+ 文章連結

② 示例:
【投稿】《不要自稱是程式員,我十多年的 IT 職場總結》:http://blog.jobbole.com/94148/

③ 最後請附上您的個人簡介哈~



看完本文有收穫?請轉發分享給更多人

關註「ImportNew」,提升Java技能

贊(0)

分享創造快樂