亨元模式

亨元(flyweight)模式是一種用於性能優化的模式, 「fly」 在這裏是蒼蠅的意思,覺得蠅量級。亨元模式的可行是運用共享技術來有效支持大量細粒度的對象。前端

若是系統中由於建立了大量相似的對象而致使內存佔用太高,亨元模式就很是有用了。在 JavaScrip t中,瀏覽器特別是移動端的瀏覽器分配的內存並不過,如何節省內存就成了一件很是有意義的事情。數據庫

1. 初識亨元模式

假設有個內衣工廠,目前的產品有 50 種男士內衣和 50 種女士內衣,爲了推銷產品,工廠決定生產一些塑料模特來穿上他們的內衣拍成廣告照片。正常狀況下須要 50 個男模特和 50 個女模特,而後讓他們每人分別穿上一件內衣來拍照。不使用亨元模式的狀況下,在程序裏也許會這樣寫:設計模式

var Model = function (sex, underwear) {
    this.sex = sex;
    this.underwear = underwear;
};  
Model.prototype.takePhoto = function () {
    console.log("sex= " + this.sex + ' underwear= ' + this.underwear);
};

for (var i = 1; i <= 50; i++){
    var maleModel = new Model('mal', 'underwear' + i);
};
for (var j = 1; j <= 50; j++){
    var femaleModel = new Model('female', 'underwear' + j);
};

要獲得一張照片,每次都須要傳入 sex 和 underwear 參數,如上所述,如今一共有 50 種男內衣和 50 種女內衣,因此一共會產生 100 個對象。若是未來生產了 10000 種內衣,那這個程序可能會由於存在如此多的對象已經提早崩潰。數組

下面咱們來考慮一下如何優化這個場景。雖然有 100 種內衣,但很顯然並不須要 50 個男模特和 50 個女模特。其實男模特和女模特各自有一個就足夠了,他們能夠分別穿上不一樣的內衣來拍照。瀏覽器

如今來改寫一下代碼,既然只須要區別男女模特,那咱們先把 underwear 參數從構造函數中移除,構造函數只接收 sex 參數:性能優化

var Model = function ( sex ) {
    this.sex = sex;
};  
Model.prototype.takePhoto = function () {
    console.log("sex= " + this.sex + ' underwear= ' + this.underwear);
};

分別建立一個男模特對象和女模特對象:閉包

var maleModel = new Model('mal'),
    femealeModel = new Model('female');

給模特依次穿上全部的內衣,並進行拍照:app

for (var i = 1; i <= 50; i++){
    maleModel.underwear = 'underwear' + i;
    maleModel.takePhoto();
};  
for (var j = 1; j <= 50; j++){
    femealeModel.underwear = 'underwear' + j;
    femealeModel.takePhoto();
};

能夠看到,改進以後的代碼,只須要兩個對象便完成了一樣的功能。dom

2. 內部狀態與外部狀態

第 1 節中的例子即是亨元模式的雛形,亨元模式要求將對象的屬性劃分爲內部狀態與外部狀態(狀態在這裏一般指屬性)。亨元模式的目標是儘可能減小共享對象的數量,關於如何劃份內部狀態和外部狀態,下面的幾條經驗提供了一些指引。函數

  • 內部狀態存儲於對象內部
  • 內部狀態能夠被一些對象共享
  • 內部狀態獨立於具體的場景,一般不會改變
  • 外部狀態取決於具體的場景,並根據場景而變化,外部狀態不能被共享

這樣一來,咱們即可以把全部內部狀態相同的對象都指定爲同一個共享的對象。而外部狀態能夠從對象身上剝離出來,並儲存在外部。

剝離了外部狀態的對象成爲共享對象,外部狀態在必要時被傳入共享對象來組裝成一個完整的對象。雖然組裝外部狀態成爲一個完整對象的過程須要花費必定的時間,但卻能夠大大減小系統中的對象數量,相比之下,這點時間或許是微不足道的。所以,亨元模式是一種用時間換空間的優化模式。

在上面的例子中,性別是內部狀態,內衣是外部狀態,經過區分這兩種狀態,大大減小了系統中的對象數量。一般來說,內部狀態有多少種組合,系統中便最多存在多少個對象,由於性別一般只有男女兩種,因此該內衣廠最多隻須要 2 個對象。

使用亨元模式的關鍵是如何區別內部狀態和外部狀態。能夠被對象共享的屬性一般被劃分爲內部狀態,如同無論什麼樣式的衣服,均可以按照性別的不一樣,穿在同一個男模特或者女模特身上,模特的性別就能夠做爲內部狀態存儲在共享對象的內部。而外部狀態取決於具體的場景,並根據場景而變化,就像例子中的每件衣服都是不一樣的,它們不能被一些對象共享,所以只能被劃分爲外部狀態。

3. 亨元模式的通用結構

第 1 節的示例初步展現了亨元模式的威力,但這還不是一個完整的亨元模式,在這個例子中還存在如下這兩個問題。

  1. 咱們經過構造函數顯示 new 出了男女兩個 model 對象,在其餘系統中,也許並非一開始就須要全部的共享對象。
  2. 給 model 對象手動設置了 underwear 外部狀態,在更復雜的系統中,這不是一個最好的方式,由於外部狀態可能會至關複雜,它們與共享對象的聯繫會變得困難。

咱們經過一個對象工廠來解決第一個問題,只有當某種共享對象被真正須要時,它才從工廠中被建立出來。對於第二個問題,能夠用一個管理器來記錄對象相關的外部狀態,使這些外部狀態經過某個鉤子和共享對象聯繫起來。

4. 文件上傳的例子

在微雲上傳模塊的開發中,咱們曾經藉助亨元模式提高了程序的性能。下面咱們就講述這個例子。

4. 1 對象爆炸

在微雲上傳模塊的開發中,我(本書做者)曾經經歷過對象爆炸的問題。微雲的文件上傳功能雖然能夠選擇依照隊列,一個一個地排隊上傳,但也支持同時選擇 2000 個文件。每個文件都對應着一個 JavaScript 上傳對象的建立,在初版開發中,的確往程序裏同時 new 了 2000 個 upload 對象,結果可想而知, Chrome 中還勉強可以支撐, IE 下直接進入假死狀態。

微雲支持好幾種上傳方式,好比瀏覽器插件, Flash 和表單上傳等,爲了簡化例子,咱們先假設只有插件和 Flash 這兩種。不管是插件上傳,仍是 Flash 上傳,原理都是同樣的,當用戶選擇了文件以後,插件和 Flash 都會通知調用 Window 下的一個全局 JavaScript 函數,它的名字是 StartUpload,用戶選擇的文件列表被組合成一個數組 files 塞進該函數的參數列表裏,代碼以下:

var id = 0;
window.startUpload = function (uploadType, files) { // uploadType 區分是控件仍是 flash
    for(var i = 0, file; file = files[i++]; ){
        var uploadObj = new Upload(uploadType, file.fileName, file.fileSize);
        uploadObj.init(id++);   //給 upload 對象設置一個惟一的 id         
    }
};

當用戶選擇完文件以後, startUpload 函數會遍歷 files 數組來建立對應的 upload 對象。接下來定義 Upload 構造函數,它接受 3 個參數,分別是插件類型,文件名和文件大小。這些信息都已經被插件組裝在 files 數組裏返回,代碼以下:

var Upload = function (uploadType, fileName, fileSize) {
    this.uploadType = uploadType;
    this.fileName = fileName;
    this.fileSize = fileSize;
    this.dom = null;
};

Upload.prototype.init = function (id) {
    var that = this;
    this.id = id;
    this.dom = document.createElement('div');
    this.dom.innerHTML = 
        '<span>文件名稱:' + this.fileName + ', 文件大小:' + this.fileSize + '</span>' + 
        '<button class="delFile">刪除</button>';
    document.body.appendChild(this.dom);
    this.dom.querySelector('.delFile').onclick = function () {
        that.delFile();
    }
}

一樣爲了簡化示例,咱們暫且去掉了 upload 對象的其餘功能,只保留刪除文件的功能,對應的方法是 Upload.prototype.delFile 。該方法中有一個邏輯:當被刪除的文件小於 3000KB 時,該文件將直接被刪除。不然頁面中會彈出一個提示框,提示用戶是否確認要刪除文件,代碼以下:

Upload.prototype.delFile = function () {
    if (this.fileSize < 3000){
        return this.dom.parentNode.removeChild(this.dom);
    }
    if (window.confirm('肯定要刪除該文件嗎? ' + this.fileName)){
        return this.dom.parentNode.removeChild(this.dom);
    }
};

接下來分別建立 3 個插件上傳對象和 3 個 Flash 上傳對象:

startUpload('plugin', [
    {
        fileName: '1.txt',
        fileSize: 1000
    },
    {
        fileName: '2.txt',
        fileSize: 2000
    },
    {
        fileName: '3.txt',
        fileSize: 3000
    }
]);

startUpload('flash', [
    {
        fileName: '4.txt',
        fileSize: 1000
    },
    {
        fileName: '5.txt',
        fileSize: 2000
    },
    {
        fileName: '6.txt',
        fileSize: 3000
    }
]);

當點擊刪除 3000KB 的文件時,能夠看到彈出了是否確認刪除的提示,如圖所示:

4. 2 亨元模式重構文件上傳

上一節代碼是初版的文件上傳,在這段代碼裏有多少個須要上傳的文件,就一共建立了多少個 upload 對象,接下來咱們用亨元模式重構它。

首先,咱們須要確認插件類型 uploadType 是內部狀態,那麼爲何單單 uploadType 是內部狀態呢?前面講過,劃份內部狀態和外部狀態的關鍵主要有如下幾點:

  • 內部狀態存儲於對象內部
  • 內部狀態能夠被一些對象共享
  • 內部狀態獨立於具體的場景,一般不會改變
  • 外部狀態取決於具體的場景,並根據場景而變化,外部狀態不能被共享

在文件上傳的例子裏,upload 對象必須依賴 uploadType 屬性才能工做,這是由於插件上傳,Flash 上傳,表單上傳的實際工做原理有很大的區別,它們各自調用的接口也是徹底不同的,必須在對象建立之初就明確它是什麼類型的插件,才能夠在程序的運行過程當中,讓它們分別調用各自的 start, pause, cancel, del 等方法。

實際上在微雲的真實代碼中,雖然插件和 Flash 上傳對象最終建立自一個大的工廠類,但它們實際上根據 uploadType 值的不一樣,分別是來自兩個不一樣類的對象。(在目前的例子中,爲了簡化代碼,咱們把插件和 Flash 的構造函數合併成了一個)。

一旦明確了 uploadType ,不管咱們使用什麼方式上傳,這個上傳對象都是能夠被任何文件共用的。而 fileName 和 fileSize 是根據場景而變化的,每一個文件的 fileName 和 fileSize 都不同, fileName 和 fileSize 沒有辦法被共享,它們只能被劃分爲外部狀態。

4. 3 剝離外部狀態

明確了 uploadType 做爲內部狀態以後,咱們再把其餘的外部狀態從構造函數中抽離出來, Upload 構造函數中只保留了 uploadTYpe 參數:

var Upload = function (uploadType) {
    this.uploadType = uploadType;
}

Upload.prototype.init 函數也再也不須要,由於 upload 對象初始化的工做被放在了 uploadManager.add 函數裏面,接下來只須要定義 Upload.prototype.del 函數便可:

Upload.prototype.delFile = function (id) {
    uploadManager.setExternalState(id, this);
    
    if(this.fileSize < 3000){
        return this.dom.parentNode.removeChild(this.dom);
    }
    if(window.confirm('肯定要刪除該文件嗎? ' + this.fileName)){
        return this.dom.parentNode.removeChild(this.dom);
    }
};

在開始刪除文件以前,須要讀取文件的實際大小,而文件的實際大小被儲存在外部管理器 uploadManager 中,因此在這裏須要經過 uploadManager.setExternalState 方法給共享對象設置正確的 fileSize ,上段代碼中的(1)處表示把當前 id 對應的對象的外部狀態都組裝到共享對象中。

4. 4 工廠進行對象實例化

接下來定義一個工廠來建立 upload 對象,若是某種內部狀態對應的共享對象已經被建立過,那麼直接返回這個對象,不然建立一個新的對象:

var UploadFactory = (function () {
    var createdFlyWeightObjs = {};      
    return {
        create: function (uploadType){
            if (createdFlyWeightObjs[uploadType]){
                return createdFlyWeightObjs[uploadType];
            }
            return createdFlyWeightObjs[uploadType] = new Upload(uploadType);
        }
    };
})();

4. 5 管理器封裝外部狀態

如今咱們來完善前面提到的 uploadManager 對象,他負責向 UploadFactory 提交建立對象的請求,並用一個 uploadDatabase 對象保存全部的 upload 對象的外部狀態,以便在程序運行過程當中給 upload 共享對象設置外部狀態,代碼以下:

var uploadManager = (function () {
    var uploadDatabase = {};        
    return {
        add: function(id, uploadType, fileName, fileSize){
            var flyWeightObj = UploadFactory.create(uploadType);
            var dom = document.createElement('div');
            dom.innerHTML = '<span>文件名稱:' + fileName + '文件大小:' + fileSize + '</span>' + 
                            '<button class = "delFile">刪除</button>';
            dom.querySelector('.delFile').onclick = function () {
                flyWeightObj.delFile(id);
            }
            document.body.appendChild(dom);
            uploadDatabase[id] = {
                fileName: fileName,
                fileSize: fileSize,
                dom: dom
            };
            return flyWeightObj;
        },
        setExternalState: function (id, flyWeightObj){
            var uploadData = uploadDatabase[id];
            for (var i in uploadData){
                flyWeightObj[i] = uploadData[i];
            }
        }
    }
})();

而後是開始出發上傳動做的 startUpload 函數:

var id = 1;
window.startUpload = function (uploadType, files){
    for (var i = 0, file; file = files[i++]; ){
        var uploadObj = uploadManager.add(id++, uploadType, file.fileName, file.fileSize);
    }
}

最後是測試時間,運行下面的代碼後,能夠發現運行結構跟用亨元模式重構以前一致:

startUpload('plugin', [
    {
        fileName: '1.txt',
        fileSize: 1000
    },
    {
        fileName: '2.txt',
        fileSize: 2000
    },
    {
        fileName: '3.txt',
        fileSize: 3000
    }
]);

startUpload('flash', [
    {
        fileName: '4.txt',
        fileSize: 1000
    },
    {
        fileName: '5.txt',
        fileSize: 2000
    },
    {
        fileName: '6.txt',
        fileSize: 3000
    }
]);

亨元模式重構以前的代碼裏一共建立了 6 個 upload 對象,而經過亨元模式重構以後,對象的數量減小爲 2 ,更幸運的是,就算如今同時上傳 2000 個文件,須要建立的 upload 對象數量依然是 2 。

5. 亨元模式的適用性

亨元模式是一種很好的性能優化方案,但它也會帶來一些複雜性的問題,從前面兩組代碼的比較能夠看到,使用了亨元模式以後,咱們須要分別多維護一個 factory 對象和一個 manager 對象,在大部分沒必要要使用亨元模式的環境下,這些開銷是能夠避免的。

亨元模式帶來的好處很大程度上取決於如何使用以及什麼時候使用,通常來講,如下狀況發生時即可以使用亨元模式。

  • 一個程序中使用了大量的類似對象。
  • 因爲使用了大量對象,形成很大的內存開銷。
  • 對象的大多數狀態均可以變爲外部狀態。
  • 剝離出對象的外部狀態以後,能夠用相對較少的共享對象取代大量對象。

能夠看到,文件上傳的例子徹底符合這 4 點。

6. 再談內部狀態和外部狀態

若是順利的話,經過前面的例子咱們已經瞭解了內部狀態和外部狀態的概念以及亨元模式的工做原理。咱們知道,實現亨元模式的關鍵是把內部狀態和外部狀態分離開來。有多少種內部狀態的組合,系統中便最多存在多少個共享對象,而外部狀態儲存在共享對象的外部,在必要時被傳入共享對象來組裝成一個完整的對象。先來來考慮兩種極端的狀況,即對象沒有外部狀態和沒有內部狀態的時候。

6. 1 沒有內部狀態的亨元

在文件上傳的例子中,咱們分別進行過插件調用和 Flash 調用,即 startUpload('plugin', []) 和 startUpload('flash', []) ,致使程序中建立了內部狀態不一樣的兩個共享對象。也許你會奇怪,在文件上傳程序裏,通常都提早經過特性檢測來選擇一種上傳方式,若是瀏覽器支持插件就用插件上傳,若是不支持插件,就用 Flash 上傳。那麼,什麼狀況下既須要插件上傳又須要 Flash 上傳呢?

實際上這個需求是存在的,不少網盤都提供了極速上傳(控件)與普通上傳(Flash)兩種模式,若是極速上傳很差使(多是沒有安裝控件或者控件損壞),用戶還能夠隨時切換到普通上傳模式,因此這裏確實是須要同時存在兩個不一樣的 upload 共享對象。

但不是每一個網站都必須作得如此複雜,不少小一些的網站就只支持單一的上傳方式。假設咱們是這個網站的開發者,不須要考慮極速上傳與普通上傳之間的切換,這意味着在以前的代碼中做爲內部狀態的 uploadType 屬性是能夠刪除的。

在繼續使用亨元模式的前提下,構造函數 Upload 就變成了無參數的形式:

var Upload = function () {};

其餘屬性如 fileName, fileSize, dom 依然能夠做爲外部狀態保存在共享對象外部。在 uploadType 做爲內部狀態的時候,它可能爲控件,也可能爲 Flash ,因此當時最多能夠組合出兩個共享對象。而如今已經沒有了內部狀態,這意味着只須要惟一的一個共享對象。如今咱們要改寫建立亨元對象的工廠,代碼以下:

var UploadFactory = (function () {
    var uploadObj;
    return {
        create: function () {
            if (uploadObj){
                return uploadObj;
            }
        return uploadObj = new Upload();
        }
    }
})();

管理器部分的代碼不須要改動,仍是負責剝離和組裝外部狀態。能夠看到,當對象沒有內部狀態的時候,生產共享對象的工廠實際上變成了一個單例工廠。雖然這時候的共享對象沒有內部狀態的區分,但仍是有剝離外部狀態的過程,咱們依然傾向於稱之爲亨元模式。

6. 2 沒有外部狀態的亨元

網上許多資料中,常常把 Java 或者 C# 的字符串當作亨元,這種說法是否正確呢?咱們看看下面這段 Java 代碼,來分析一下:

public class Test {
    public static void main (String args[]){
        String a1 = new String("a").intern();
        String a2 = new String("a").intern();
        System.out.println(a1 == a2);   //true
    }
}

在這段 Java 代碼裏,分別 new 了兩個字符串對象 a1 和 a2 。intern 是一種對象池技術, new String("a").intern() 的含義以下。

  • 若是值爲 a 的字符串對象已經存在於對象池中,則返回這個對象的引用。
  • 反之,將字符串 a 的對象添加進對象池,並返回這個對象的引用。

因此 a1 == a2 的結果是 true, 但這並非使用了亨元模式的結果,亨元模式的關鍵是區別內部狀態和外部狀態。亨元模式的過程是剝離外部狀態,並把外部狀態保存在其餘地方,在合適的時刻再把外部狀態組裝進共享對象。這裏並無剝離外部狀態的過程, a1 和 a2 指向的徹底就是同一個對象,因此若是沒有外部狀態的分離,即便這裏使用了共享的技術,但並非一個純粹的亨元模式。

7. 對象池

咱們在前面已經提到了 Java 中 String 的對象池,下面就來學習這種共享的技術。對象池維護一個裝載空閒對象的池子,若是須要對象的時候,不是直接 new, 而是轉從對象池裏獲取。若是對象池裏沒有空閒對象,則建立一個新的對象,當獲取出的對象完成它的職責以後,再進入池子等待被下次獲取。

對象池的原理很好理解,好比咱們組人手一本《JavaScript權威指南》,從節約的角度來說,這並非很划算,由於大部分時間這些書都被閒置在各自的書架上,因此咱們一開始就只買一本,或者一塊兒創建一個小型圖書館(對象池),須要看書的時候就從圖書館裏借,看完了以後再把書還會圖書館。若是同時有三我的要看這本書,而如今圖書館裏只有兩本,那咱們再立刻去書店買一本放入圖書館。

對象池技術的應用很是普遍, HTTP 鏈接池和數據庫鏈接池都是其表明應用。在 Web 前端開發中,對象池使用最多的場景大概就是跟 DOM 有關的操做。不少空間和時間都消耗在了 DOM 節點上,若是避免頻繁地建立和刪除 DOM 節點就成了一個有意義的話題。

7. 1 對象池實現

假設咱們在開發一個地圖應用,地圖上常常會出現一些標誌地名的小氣泡(如手機或PC上的地圖應用),咱們叫它 toolTip。

在搜索我家附近地圖的時候,頁面裏出現了 2 個小氣泡。當我再搜索附近的某某時,頁面中出現了 6 個小氣泡。按照對象池的思想,在第二次搜索開始以前,並不會把第一次建立的 2 個小氣泡刪除掉,而是把它們放進對象池。這樣在第二次的搜索結束頁面裏,咱們只須要再建立 4 個小氣泡而不是 6 個。

先定義一個獲取小氣泡節點的工廠,做爲對象池的數組成爲私有屬性被包含在工廠閉包裏,這個工廠有兩個暴露對外的方法, create 表示獲取一個 div 節點, recover 表示回收一個 div 節點:

var toolTipFactory = (function () {
    var toolTipPool = [];   // toolTip 對象池
    return {
        create: function () {
            if (toolTipPool.length === 0){  //若是對象池爲空
                var div = document.createElement('div');    //建立一個 dom
                document.body.appendChild(div);
                return div;
            }else{  //若是對象池裏不爲空
                return toolTipPool.shift(); //則從對象池中取出一個 dom
            }
        },
        recover: function (tooltipDom){
            return toolTipPool.push(tooltipDom);    //對象池回收 dom
        }
    }
})();

如今把時鐘撥回進行第一次搜索的時刻,目前須要建立 2 個小氣泡節點,爲了方便回收,用一個數組 ary 來記錄它們:

var ary = [];
for (var i = 0, str; str = ['A', 'B'][i++]; ){
    var toolTip = toolTipFactory.create();
    toolTip.innerHTML = str;
    ary.push(toolTip);
}

若是你願意稍稍測試一下,能夠看到頁面中出現了 innerHTML 分別爲 A 和 B 的兩個 div 節點。

接下來假設地圖須要從新繪製,在此以前要把這兩個節點回收進對象池:

for ( var i = 0, toolTip; toolTip = ary[i++]; ){
    toolTipFactory.recover(toolTip);
}

再建立 6 個小氣泡:

for (var i = 0, str; str = ['A', 'B', 'C', "D", "E", "F"][i++]; ){
    var toolTip = toolTipFactory.create();
    toolTip.innerHTML = str;
};

如今再測試一番,頁面中出現了內容分別爲 A, B, C, D, E, F 的 6 個節點,上一次建立好的節點被共享給了下一次的操做。對象池跟亨元模式的思想有點類似,雖然 innerHTML 的值 A, B, C, D 等也能夠當作節點的外部狀態,但在這裏咱們並無主動分離內部狀態和外部狀態的過程。

7. 2 通用對象池實現

咱們還能夠在對象池工廠裏,把建立對象的具體過程封裝起來,實現一個通用的對象池:

var objectPoolFactory = function (createObjFn) {
    var objectPool = [];
    return {
        create: function () {
            var obj = objectPool.length === 0 ? 
                createObjFn.apply(this, arguments) : objectPool.shift();
            return obj;
        },
        recover: function (obj) {
            objectPool.push(obj);
        }
    };
};

如今利用 objectPoolFactory 來建立一個轉載一些 iframe 的對象池:

var iframeFactory = objectPoolFactory(function () {
    var iframe = document.createElement('iframe');
    document.body.appendChild(iframe);
    iframe.onload = function () {
        iframe.onload = null;   //防止 iframe 重複加載的 bug
        iframeFactory.recover(iframe);  // iframe 加載完成以後回收節點
    }
    return iframe;
});

var iframe1 = iframeFactory.create();
iframe1.src = 'http://baidu.com';

var iframe2 = iframeFactory.create();
iframe2.src = 'http://QQ.com';

setTimeout(function () {
    var iframe3 = iframeFactory.create();
    iframe3.src = 'http://163.com';
}, 10000);

對象池是另一種性能優化方案,它跟亨元模式有一些類似之處,但沒有分離內部狀態和外部狀態這個過程。本章用亨元模式完成了一個文件上傳的程序,其實也能夠用對象池 + 事件委託來代替實現。

8. 小結

亨元模式是爲解決性能問題而生的模式,這跟大部分模式的誕生緣由都不同。在一個存在大量類似對象的系統中,亨元模式能夠很好地解決大量對象帶來的性能問題。


參考書目:《JavaScript設計模式與開發實踐》

相關文章
相關標籤/搜索