Zend_Cache 實戰筆記

一般為了減少伺服器在資料存取上的負擔,多數系統都會使用快取機制來暫時把已經處理好的資訊保存下來,供下一個使用者取用。在 Zend Framework 中, Zend_Cache 就是扮演這個角色的套件。

快取原理

首先我們先瞭解一下快取的基本原理。

  • 從快取存放的媒體中尋找是否有符合條件的快取資料?

  • 如果有找到資料,則先看看資料是否已經過期?已過期的資料視同沒有找到資料。

  • 如果找不到符合條件的資料,就進入重新建立資料,然後再次存入快取中。

  • 如果有找到資料,而且沒有過期,就將資料返回給呼叫的程式。

而 Zend_Cache 大致上的用法也是如此。

Zend_Cache 原理

Zend_Cache 的架構基本上分成了 Frontend 及 Backend ,其作用如下:

Frontend

指的是要快取的對象。

Backend

指的是儲存快取的容器。

基本架構圖如下:

Zend_Cache 基本架構圖

Zend_Cache 採用了典型的 Bridge Pattern 來實作整個套件的架構,而 Frontend 及 Backend 剛好就分處於 Bridge 的兩端。其中 Core 是預設的 Frontend ,可以儲存大部份能被 serialize 的資源;而 Backend 則是抽象類別,其下的子類別則採用了 Adapter Pattern 來轉接各種儲存媒體的 API 。

基本的資料快取

最常用的資料快取,就是利用 Zend_Cache_Core 搭配 Zend_Cache_Backend_File 。以下引用官方手冊上的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 設定 Frontend 選項
$frontendOptions = array(
   'lifetime' => 7200, // 快取時間
   'automatic_serialization' => true, // 自動 serialization
);
// 設定 Backend 選項
$backendOptions = array(
    'cache_dir' => APPLICATION_PATH . '/cache/', // 快取存放路徑
);
// 建立一個快取物件
$cache = Zend_Cache::factory('Core',
                             'File',
                             $frontendOptions,
                             $backendOptions);
// 試著從快取中取得資料
if(($result = $cache->load('myresult')) === false) { // 找不到資料
    $db = Zend_Db::factory( [...] );
    $result = $db->fetchAll('SELECT * FROM huge_table');
    $cache->save($result, 'myresult');
} else { // 有找到資料
    echo "This one is from cache!\n\n";
}
// 輸出資料
print_r($result);

上面的例子,大致包含了一般在單獨使用 Zend_Cache 的基本模式:

  1. 先決定要快取的資料內容及要存放快取的媒介。

  2. 透過 Zend_Cache::factory() 方法建立一個快取物件。

  3. 透過 Zend_Cache_Core::load() 載入指定的快取。

  4. 如果沒有快取的話,就重新讀取資料,並用 Zend_Cache_Core::save() 來建立快取。

  5. 最後輸出取得的資料。

其中 load() 的第一個參數及 save() 的第二個都必須指定成同樣的 id ,在上面的例子就是 myresult 。

假設現在我們要在專案的 Action Controller 裡的很多地方使用同一個快取物件時,重覆地複製 Zend_Cache::factory() 方法就顯得很沒效率;這時我們可以將建立快取物件的程式區段獨立出來,變成一個內部方法。上例如果改寫在 Action Controller 的話,大致是這樣:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<?php
class IndexController extends Zend_Controller_Action
{
    protected function _getCache()
    {
        // 設定 Frontend 選項
        $frontendOptions = array(
            'lifetime' => 7200,
            'automatic_serialization' => true,
        );
        // 設定 Backend 選項
        $backendOptions = array(
            'cache_dir' => APPLICATION_PATH . '/cache/',
        );
        // 回傳一個快取物件
        return Zend_Cache::factory('Core', 'File', $frontendOptions, $backendOptions);
    }
    public function indexAction()
    {
        $cache = $this->_getCache();
        if (($result = $cache->load('myresult')) === false) {
            $db = Zend_Db_Table::getDefaultAdapter(); // 從設定檔取得預設的資料庫連線物件
            $result = $db->fetchAll('SELECT * FROM huge_table');
            $cache->save($result, 'myresult');
        }
        // 輸出資料
        $this->view->result = $result;
    }
}

如此一來 _getCache() 就可以用在其他 Action 裡了。

快取管理員

前面的例子裡,是把快取的設定寫死在 Action Controller 裡;但實際開發時,我們希望只要更改 application.ini 就可以更換快取的設定,這時候可以透過內建的 Cache Manager 來取得 application.ini 中所定的快取設定。

首先在 application.ini 我們可以加入以下設定:

1
2
3
4
5
resources.cachemanager.coreToFile.frontend.name = Core
resources.cachemanager.coreToFile.frontend.options.lifetime = 7200
resources.cachemanager.coreToFile.frontend.options.automatic_serialization = true
resources.cachemanager.coreToFile.backend.name = File
resources.cachemanager.coreToFile.backend.options.cache_dir = APPLICATION_PATH "/cache"

其中 coreToFile 是自訂的名稱,稍後會透過它來讓 Cache Manager 知道要取用哪一組設定。而因為有快取名稱的機制,我們可以同時加入多組快取設定,以定義不同的快取方式。

接著我們要改寫原來 Action Controller 裡的 _getCache() :

1
2
3
4
5
6
7
8
9
10
11
12
13
protected function _getCache($name) // 可以指定名稱,以便用對應到該名稱之設定來建立快取物件
    {
        $bootstrap = $this->getFrontController()->getParam('bootstrap'); // 取得 Bootstrap
        /* @var $bootstrap Zend_Application_Bootstrap_Bootstrap */
        if ($bootstrap->hasResource('cachemanager')) { // 判斷是否有設定 Cache Manager
            $cacheManager = $bootstrap->getResource('cachemanager');
            /* @var $cacheManager Zend_Cache_Manager */
            if ($cache = $cacheManager->getCache($name)) { // 如果有指定的快取就回傳該快取物件
                return $cache;
            }
        }
        throw new Exception('Can not get cache object with name: ' . $name . '.'); // 找不到的話就丟出 Exception
    }

跟前面的例子不一樣的地方在於,這裡我們透過 Front Controller 與 Application Bootstrap 來取得 Cache Manager 這個 Application Resource ,其回傳的物件的類型為 Zend_Cache_Manager 。而取得的 Zend_Cache_Manager 物件裡面已經包含了剛剛在 application.ini 中所設定好的快取選項,我們只要用 Zend_Cache_Manager::getCache() 這個方法即可將對應的快取物件建立起來。

原來使用快取物件的部份,只要再指定快取的名稱即可:

1
2
3
4
5
6
7
8
9
10
11
public function indexAction()
    {
        $cache = $this->_getCache('coreToFile');
        if (($result = $cache->load('myresult')) === false) {
            $db = Zend_Db_Table::getDefaultAdapter(); // 從設定檔取得預設的資料庫連線物件
            $result = $db->fetchAll('SELECT * FROM huge_table');
            $cache->save($result, 'myresult');
        }
        // 輸出資料
        $this->view->result = $result;
    }

不過這樣一來如果有多個 Action Controller 都需要快取時,就必須要在每個 Action Controller 裡加上 _getCache() 方法。

還好 Zend Framework 提供了一個非常有用的 Cache Action Helper ,用法如下:

1
$cache = $this->_helper->cache->getManager()->getCache('coreToFile');

這樣一來就不需要在 Action Controller 中多寫一個 _getCache() 方法了。

頁面快取

除了資料的快取, Zend_Cache 也提供頁面的快取機制,只要我們的 Frontend 設定成 Page 即可。以下取自官方手冊上的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
$frontendOptions = array(
    'lifetime' => 7200,
    'debug_header' => true, // 輸出 Debug 訊息
    'regexps' => array( // 設定要快取的網址
        '^/$' => array('cache' => true), // 快取首頁
        '^/index/' => array('cache' => true), // 快取 IndexController 下的所有 Action
        '^/article/' => array('cache' => false), // 不對 ArticleController 做快取
        '^/article/view/' => array(
            'cache' => true, // 但快取 ArticleController::viewAction
            'cache_with_post_variables' => true, // 且針對 $_POST 資料做快取
            'make_id_with_post_variables' => true, // 儲存的 id 也要針對 $_POST 編碼
        ),
    ),
);
$backendOptions = array(
    APPLICATION_PATH . '/cache/',
);
$cache = Zend_Cache::factory('Page', 'File', $frontendOptions, $backendOptions); // 建立快取物件
$cache->start(); // 開始快取

註:其他有關 Zend_Cache_Frontend_Page 的選項,請參考官方手冊

在這裡 Page Cache 使用的方法就跟 Core Cache 不太一樣,我們必須改用 start() 方法來進行頁面快取。

要注意快取的內容只會跟著網址變化而不同,如果在要快取的頁面執行過程中,有使用到不會因為網址而改變的輸入資訊 (例如 Session ) ,那麼快取的內容就有可能會是錯誤的。

上面的用法只適用在網站位址是固定的專案,但如果是 Portable 的專案時,直接以網址來對應快取就很不方便了。還好前面提到的 Cache Action Helper 也提供了讓我們直接在 Action Controller 指定要快取哪些 Action 的方法,範例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?php
class IndexController extends Zend_Controller_Action
{
    public function init()
    {
        $this->_helper->addHelper(new Zend_Controller_Action_Helper_Cache()); // 註冊 Cache Action Helper
        $this->_helper->cache(array(
            'index', // 指定要快取的 Action
        ));
    }
    public function indexAction()
    {
        $db = Zend_Db_Table::getDefaultAdapter();
        $result = $db->fetchAll('SELECT * FROM huge_table');
        $this->view->result = $result;
    }
}

不過這樣程式還不能進行快取,因為我們還沒設定好快取的選項;請在 application.ini 中加入以下設定:

1
resources.frontController.params.disableOutputBuffering = true ; 關閉程式預設的 Output Buffer ,避免快取失效。

上述的設定是因為 Dispatcher 會預先用 ob_start() 來取得程式輸出的結果,這樣會造成快取物件無法正確取得頁面內容;因此要先在 Front Controller 設定 disableOutputBuffering 這個參數,讓快取物件自行啟用 ob_start() 來做頁面快取。

另外 Cache Action Helper 所使用的快取名稱固定為小寫的 page ,因此我們要再加上以下設定:

1
2
3
4
5
6
resources.cachemanager.page.frontend.name = Page
resources.cachemanager.page.frontend.options.lifetime = 7200
resources.cachemanager.page.frontend.options.debug_header = true
resources.cachemanager.page.frontend.options.default_options.cache_with_post_variables = true ; 有需要對 $_POST 的頁面做快取<br />resources.cachemanager.page.frontend.options.default_options.make_id_with_post_variables = true ; 才加上這兩行
resources.cachemanager.page.backend.name = File
resources.cachemanager.page.backend.options.cache_dir = APPLICATION_PATH "/cache"

這樣一來 Cache Action Helper 才能正確的建立快取物件,並進行頁面的快取。

註:可惜 Cache Action Helper 在頁面快取的部份,只能對所有頁面快取指定同一組設定,在實用性上顯得弱了許多。這部份我們可以改用我們自訂的快取機制來完成,有機會我會再做介紹。

還要注意的一點是, Zend_Cache 的頁面快取是快取一整頁;因此如果需要對某個區塊保持動態產生內容的話,就必須透過其他方式來額外處理了。我的解決方法是使用 jQuery 的 load() 方法來額外載入動態內容,雖然需要多一些處理,但還是很實用。

替快取上標籤

通常我們需要一起處理同一類型的快取,例如商品內容更新後,要清除有關該商品的所有快取;所以 Zend_Cache 便提供了標籤的概念,讓我們可以將同一類型的快取群組起來處理。

標籤可以在儲存快取時給定,例如呼叫 Zend_Cache_Core::save() 方法時,在第三個參數給定一個 tag 陣列:

1
$cache->save($data, 'custom_key', array('my_tag'));

有需要的話, tag 可以一次設定多組。

當然頁面也可以上標籤,但並不是在呼叫 Zend_Cache_Frontend_Page::start() 方法時下。我們可以在 application.ini 裡加上:

1
2
resources.cachemanager.page.frontend.options.default_options.tags[] = "my_tag"
; resources.cachemanager.page.frontend.options.default_options.tags[] = "my_tag2" ; 需要的話可以設定多組

這樣頁面的快取就直接會上好標籤了,在稍後介紹刪除快取時我們就會用到它。

刪除快取

通常我們在後台更新資料後,也希望一併清除該資料相關的快取。因此 Zend_Cache 也提供相關的方法,讓我們能刪除特定的快取。

刪除快取的流程如下:

  • 建立快取物件,此快取物件的設定必須與當初建立快取的設定一致,也就是:誰建立的快取就由誰來刪除。

  • 利用快取物件刪除特定 id 的快取,或是特定標籤的快取。

透過 remove() 方法,我們可以刪除特定 id 快取:

1
2
$cache = $this->_helper->cache->getManager()->getCache('coreToFile'); // 找出當初建立快取的快取物件
$cache->remove('custom_key'); // 刪除 id 為 custom_key 的快取

如果要刪除指定標籤的快取,則可以用 clean() 方法:

1
2
$cache = $this->_helper->cache->getManager()->getCache('coreToFile');
$cache->clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG, array('my_tag'));

clean() 方法的第一個參數是告知快取物件我們要怎麼處理快取,共有以下五種方式:

  • Zend_Cache::CLEANING_MODE_ALL :清除所有快取,此為預設值。

  • Zend_Cache::CLEANING_MODE_OLD :清除過期的快取。

  • Zend_Cache::CLEANING_MODE_MATCHING_TAG :清除已經上標籤的快取,該快取必須含有所有指定的標籤才能被刪除。

  • Zend_Cache::CLEANING_MODE_NOT_MATCHING_TAG :清除指定標籤以外的快取。

  • Zend_Cache::CLEANING_MODE_MATCHING_ANY_TAG :清除已經上標籤的快取,該快取只要含有其中一個指定的標籤就會被刪除。

而 clean() 方法的第二個參數就是一個標籤陣列,可以搭配 CLEANING_MODE_MATCHING_TAG 、 CLEANING_MODE_NOT_MATCHING_TAG 及 CLEANING_MODE_MATCHING_ANY_TAG 三種模式使用。

其他常用的 Backend

除了將快取存放在檔案中之外, Zend_Cache 還支援多種不同的快取存取媒介,詳細資訊請參考官方手冊有關 Backend 的說明

註:目前 1.11.x 版的官方手冊還是有好幾個新的 Backend 沒提到,所以大家有興趣的話可能要直接參考原始碼來瞭解它們的用途了。

以下介紹兩個比較常用的 Backend 的設定方式:

Memcached

Memcached 是很常見的快取媒介,因此它也被納入了 Zend_Cache 中。在使用 Zend_Cache_Backend_Memcached 前,要先確定 PHP 已經安裝好了 memcached 這個 extension 。

我們可以設定一至多組 memcached 供快取使用,只有連結到單一伺服器的範例如下:

1
2
3
4
5
6
7
8
resources.cachemanager.singleMemcached.backend.name = Memcached
resources.cachemanager.singleMemcached.backend.options.servers.host = "192.168.1.1"
resources.cachemanager.singleMemcached.backend.options.servers.port = "11211"
resources.cachemanager.singleMemcached.backend.options.servers.persistent = true
resources.cachemanager.singleMemcached.backend.options.servers.weight = 1
resources.cachemanager.singleMemcached.backend.options.servers.timeout = 5
resources.cachemanager.singleMemcached.backend.options.servers.retry_interval = 15
resources.cachemanager.singleMemcached.backend.options.servers.status = true

多組伺服器時:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
resources.cachemanager.multiMemcached.backend.name = Memcached
; 第一台伺服器
resources.cachemanager.multiMemcached.backend.options.servers.0.host = "192.168.1.1"
resources.cachemanager.multiMemcached.backend.options.servers.0.port = "11211"
resources.cachemanager.multiMemcached.backend.options.servers.0.persistent = true
resources.cachemanager.multiMemcached.backend.options.servers.0.weight = 1
resources.cachemanager.multiMemcached.backend.options.servers.0.timeout = 5
resources.cachemanager.multiMemcached.backend.options.servers.0.retry_interval = 15
resources.cachemanager.multiMemcached.backend.options.servers.0.status = true
; 第二台伺服器
resources.cachemanager.multiMemcached.backend.options.servers.1.host = "192.168.1.2"
resources.cachemanager.multiMemcached.backend.options.servers.1.port = "11211"
resources.cachemanager.multiMemcached.backend.options.servers.1.persistent = true
resources.cachemanager.multiMemcached.backend.options.servers.1.weight = 2
resources.cachemanager.multiMemcached.backend.options.servers.1.timeout = 5
resources.cachemanager.multiMemcached.backend.options.servers.1.retry_interval = 15
resources.cachemanager.multiMemcached.backend.options.servers.1.status = true

大部份可以存到 File 的快取,都可以用 Memcached 來取代。

BlackHole

顧名思義,這個 Backend 其實是個黑洞,它並沒有辦法儲存任何快取; Zend_Cache_Backend_BlackHole 主要的目的是用來關閉快取用的,是一個很典型的 Null Object Pattern 。

BlackHole 不需要任何設定,通常會在開發的設定中用它來取代上線環境的 Backend :

1
2
3
[development : production]
; ...
resources.cachemanager.coreToFile.backend.name = BlackHole

就是這麼簡單。

小結

Zend_Cache 是一個很棒的套件,在使用上也不會太麻煩。然而官方的手冊在 Zend_Cache 的說明上,比較少提到如何將它應用到實際專案架構上,這點就花了我不少時間研究。

希望這篇心得能幫助大家瞭解 Zend_Cache 應該如何使用,如果有任何更好的想法,也歡迎大家留言討論。

參考