Symfony — популярный фреймворк, использующий язык PHP, для ускорения процесса разработки web-приложений. Благодаря гибкой и продуманной структуре классов Symfony, программировать под этот фреймворк становится одним удовольствием, это действительно очень удобно.

Однако, плату за универсальность никто не отменял. И, хотя Symfony предоставляет обширные возможности для кэширования, оно не всегда спасает, особенно на нагруженных проектах и особенно на страницах, которые состоят из часто меняющихся блоков.

В этой статье я постараюсь описать минусы кэширования в Symfony и приемы их обхода.

Представьте ситуацию, когда есть страница, на которой данные могут меняться только раз в 1-2 суток, тогда мы смело можем ее закэшировать на 24 часа. В Symfony это делается очень просто установкой параметров в файле cache.yml для модулей:

1
2
3
enabled: true
with_layout: true
lifetime: 86400

Это означает, что раз в 24 часа при заходе одного пользователя страница будет перестраиваться, т. е. выполнять запросы к базе данных, исполнять код PHP, еще какие-то действия, после этого результат будет записан в кэш и отдан клиенту. После этого все последующие 24 часа каждый пользователь будет получать эту страницу из кэша — никаких запросов к базе, минимальное исполнение только самого необходимого кода. Красота!

И вот в один прекрасный момент руководство решает добавить на эту страницу блок с последними комментариями пользователей. Все, счастливая жизнь закончилась, пришло время пораскинуть мозгами. Думаю, очень многие веб-разработчики сталкивались с подобной историей.

Проблема в том, что блок с последними комментариями может обновиться как через минуту, так и через 2-3 часа, поэтому кэшировать всю страницу мы не можем, т. к. не понятно какое время жизни выставлять для кэша. Вообще это типичный случай, когда следует применять блочное кэширование, т. е. делим страницу на блоки (или компоненты в Symfony), каждому блоку задаем свои параметры кэширования.

Но такой тип кэширования далек от идеала, т. к. в этом случае Symfony придется выполнить большую часть своего кода. Код исполняется, как обычно, просто перед выполнением каждого блока будет проверка на наличие блока в кэше и, если он там есть и время жизни не истекло, то вместо выполнения этого блока будет просто вставлен html-код из кэша.
При таком блочном кэшировании будет исполняться код Symfony при каждом http-запросе, кроме того, будет как минимум один запрос к базе данных, если пользователь авторизован - Symfony получает данные о пользователе из базы еще до подключения блоков.

Однако, есть другой способ применения кэширования для случая, описанного выше. Для этого нам понадобится (я также привожу описание установки нужного ПО, для тех, кому оно понадобится, все примеры я проверял у себя на Linux SUSE 11.3):

  • Nginx в качестве web-сервера

    Как установить и настроить Nginx

  • Фреймворк Symfony

    Как установить Symfony и настроить связку Nginx+Symfony

  • Memcached и расширение для PHP

    Как установить memcached и расширение PHP к нему.

Сейчас после открытия в браузере http://localhost мы видим страницу нашего проекта на Symfony.

Итак, у нас все есть для работы, начнем ускорять наш проект. За основу берется то, что Nginx умеет обращаться к memcached по ключу и отдавать значение, которое содержится по этому ключу, в качестве ответа web-сервера.

Memcached — это демон, который хранит данные в оперативной памяти, обращаться к этим данным мы можем по определенному ключу.

Воспользуемся всеми вышеописанными возможностями memcached и nginx. В качестве ключа в memcached мы будем использовать URL-адрес страниц нашего сайта, а в качестве содержимого HTML страницы. Теперь нам нужно настроить nginx так, чтобы он при наличии в memcached значения (html) по ключу (url страницы) отдавал его сразу же в ответе клиенту, в противном случае запрос должен передаваться к Symfony, который его обрабатывает, сохраняет в memcached и отдает ответ для клиента. Таким образом, если в данный момент страница не в кэше, то его добавит в кэш Symfony, и при следующем запросе к этой странице ответ будет получен еще на стороне nginx, не доходя даже до Symfony.

Итак, добавим в наш проект возможность записи ответа сервера в memcached. Для этого воспользуемся механизмом фильтров в Symfony. Создадим в директории lib нашего проекта поддиректорию cache, а в ней создадим файл sfHtmlCacheFilter.class.php. Содержимое этого файла будет следующее:

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
<?php
 
class sfHtmlCacheFilter extends sfFilter
{
    private static $replacement = array();
    private static $multi_replacement = array();
    private static $pageLimit = array();
    private static $host = '';
 
    /**
     * Метод вызывается после того, как страница полностью сформирована,
     * здесь содержимое страницы записывается в memcached, если она указана в конфиге html_cache.yml.
     *
     * @param <Object> $filterChain
     * @return <NULL>
     */
    public function execute ($filterChain)
    {
        $filterChain->execute();
 
        $response = $this->getContext()->getResponse();
        $request = $this->getContext()->getRequest();
 
	// Если в наших конфигах прописано не использовать кэширование или мы находимся в debug-режиме, то прекращем выполнение.
	// Здесь также проверяется код ответа - если он не равен 200, то прекращем выполнение.
        if (
          (!sfConfig::get('sf_cache') || count($request->getGetParameters()) || count($request->getPostParameters()))
          ||
          (sfConfig::get('sf_debug') || !sfConfig::get('sf_no_script_name') || $response->getStatusCode() != 200)
        )
        {
          return;
        }
 
        $uri = $this->getContext()->getRouting()->getCurrentInternalUri();
        self::$host = 'http://'.$request->getHost();
	// Получаем запрошенный URI - будущий ключ для memcached
        $webUri = str_replace(self::$host, '', $request->getUri());
        $uriCacheParams = null;
	// Получаем из конфига страницы, которые подлежат кэшированию
        $config = self::getConfig();
        foreach($config as $item)
        {
            foreach($item as $key=>$uriParams)
            {
                $routing_uri = preg_replace('/^(.*)\?.*$/i', '$1', $uri);
		// Сравниваем запрошенный URI с записями в конфиге
                if(preg_match('#^'.$key.'$#', $webUri))
                {
                    $uriCacheParams = $uriParams;
                    break;
                }
            }
 
	    // Если запрошенная страница есть в конфиге, то прекращаем цикл
            if($uriCacheParams)
            {
                break;
            }
        }
        $webUri = self::_decodeUri($webUri);
        if ($uriCacheParams && $response->getStatusCode() == '200')
        {
	    // Записываем содержимое ответа в кэш
            self::setUri(self::$host.$webUri, $response->getContent(), $uriCacheParams['lifetime']);
        }
 
        return;
    }
 
    /**
     * Получить конфиг тэгов
     *
     * @return <Array>
     */
    private static function getConfig() {
        $conf = sfYaml::load(sfConfig::get('sf_config_dir') . '/html_cache.yml');
 
        return $conf;
    }
 
    /**
     * Вызывается из методов save() моделей,
     * удаляет из кэша все страницы, зависящие от заданной модели
     *
     * @param <Object> $model - объект, для которого вызывается метод save в модели
     */
    public static function deleteCache($model)
    {
        $request = sfContext::getInstance()->getRequest();
        $model_name = get_class($model);
        $config = self::getConfig();
 
        $arUri = array();
 
        // Получаем страницы из конфига по этой модели
        $arCacheParam = $config[$model_name];
        self::$host = 'http://'.$request->getHost();
        // Обходим в цикле страницы, строим из них ключи для memcache
        foreach($arCacheParam as $uri=>$param)
        {
            self::$replacement = array();
            self::$multi_replacement = array();
            self::$pageLimit = array();
 
            $pageLimit = null;
            $attr = (isset($param['attributes']) ? $param['attributes'] : null);
            settype($attr, 'array');
            foreach($attr as $prop)
            {
                $method = 'get'.$prop;
                $value = false;
                /**
                 * method_exists не работает для Doctrine,
                 * но работает для Propel, is_callable всегда будет
                 * возвращать true для Doctrine, поэтому существование
                 * метода проверяется таким образом.
                 */
                try {
                        $value = $model->$method();
                }
                catch (Exception $e) {}
 
                if($value !== false)
                {
                    if (is_string($value) && strpos($value, ' ') !== false) {
                        $value = self::_decodeUri($value, false);
                    }
                    self::$replacement[] = $value;
                }
                elseif(preg_match('/^pageLimit:(\d+)$/', $prop, $matches))
                {
                    $pageLimit = $matches[1];
                    self::$replacement['pageLimit'] = range(1, $pageLimit);
                }
            }
 
            $isCachable = true;
            $beforeDeleteCache = (isset($param['beforeDeleteCache']) ? $param['beforeDeleteCache'] : null);
            settype($beforeDeleteCache, 'array');
            foreach($beforeDeleteCache as $callback)
            {
                $method = $callback;
                $isCachable = $model->$method();
            }
 
            if($isCachable)
            {
                $uri = preg_replace_callback('/\(.*\)/U', array('self', 'replace_attr_callback'), $uri);
                if(self::$multi_replacement)
                {
                    self::replace_multi_attr(self::$multi_replacement, $uri, $arUri);
                }
                else
                {
                    $arUri[] = self::$host.$uri;
                }
            }
        }
 
        // Удаляем массив страниц из memcached
        if($arUri)
        {
            self::deleteUri($arUri);
        }
    }
 
    /**
     * Используется для парсинга конфига при удалении страницы из memcached
     *
     * @param <Array> $matches
     * @return <String>
     */
    private static function replace_attr_callback($matches)
    {
        $expr = $matches[0];
        $arKeys = array_keys(self::$replacement);
        $key = $arKeys[0];
        if(!is_array(self::$replacement[$key]) && $key !== 'pageLimit' && isset(self::$replacement[$key]) && preg_match('/'.$expr.'/', self::$replacement[$key]))
        {
            $expr = self::$replacement[$key];
        }
        else
        {
            self::$multi_replacement[] = self::$replacement[$key];
            $expr = '%MULTI_REPLACEMENT_'.(count(self::$multi_replacement) - 1).'%';
        }
 
        unset(self::$replacement[$key]);
 
        return $expr;
    }
 
    /**
     * Используется для парсинга конфига при удалении страницы из memcached
     *
     * @param <Array> $attr
     * @param <String> $uri
     * @param <Array> $arUri
     * @param <Integer> $key
     */
    private static function replace_multi_attr($attr, $uri, &$arUri, $key = 0)
    {
        if(isset($attr[$key]))
        {
            foreach($attr[$key] as $ind=>$i)
            {
                self::replace_multi_attr($attr,
                                str_replace('%MULTI_REPLACEMENT_'.$key.'%', $i, $uri),
                                $arUri,
                                $key+1);
            }
        }
        if($key == count($attr))
        {
            $arUri[] = self::$host.$uri;
        }
    }
 
    /**
     * Возвращает объект для работы с memcached.
     *
     * @return <Object>
     */
    private static function getMemcache()
    {
        $memcache = new Memcache();
        $con = $memcache->connect('127.0.0.1', 11211);
	return $memcache;
    }
 
    /**
     * Записываем содержимое страницы в memcached
     *
     * @param <String> $uri - ключ в memcached
     * @param <String> $data - значение в memcached
     * @param <Integer> $lifetime - время жизни кэша
     */
    private static  function setUri($uri, $data, $lifetime)
    {
	$memcache = self::getMemcache();
	$memcache->set($uri, $data, 0, $lifetime);
    }
 
    /**
     * Удаляет страницу из memcached
     *
     * @param <Array> $arUri - массив ключей memcached,
     * которые следует удалить
     */
    private static function deleteUri($arUri)
    {
        settype($arUri, 'array');
        $memcache = self::getMemcache();
        foreach($arUri as $uri)
        {
            $memcache->delete($uri);
        }
    }
 
 
    /**
     * Метод декодирует URI через rawurldecode,
     * но символ пробела оставляет в прежнем виде,
     * т.к. nginx его не декодирует в своей переменной $uri
     *
     * @param <string> $uri
     * @param <boolean> $decode
     * @return <string>
     */
    private static function _decodeUri($uri, $decode = true)
    {
        $url = ($decode ? rawurldecode($uri) : $encodedUri);
 
        return str_replace(' ', '%20', $url);
    }
}

Пока нас будет интересовать только первый метод класса execute. Он вызывается после того, как закончено формирование страницы, в этом методе у нас есть возможность обратиться к содержимому страницы, еще до того, как она отправлена клиенту.

Все, что делается в методе execute — это получение текущего адреса страницы, проверка его в конфиге html_cache.yml и, если он там есть, записываем содержимое в memcached.

Теперь создадим сам конфиг html_cache.yml и положим его в папку config нашего проекта:

1
2
Index:
  /: { lifetime: 86400 }

Здесь мы задаем адрес запрошенной страницы («/» - запрос главной страницы сайта), в фигурных скобках перечислены параметры кэширования, мы указали время жизни кэша 86400 секунд, т. е. сутки. Параметр Index сейчас используется только лишь в качестве группировки адресов страниц, но здесь можно также указывать названия моделей Symfony, об этом будет рассказано ниже.

По части Symfony у нас почти все готово, осталось включить использование кэширования и активировать наш фильтр.

Чтобы включить кэширование для секции prod прописываем в файле apps/frontend/config/settings.yml (изменения выделены жирным шрифтом):

1
2
3
4
5
prod:
  .settings:
    no_script_name:         true
    cache:               true
    logging_enabled:        true

А теперь активируем фильтр, добавляем в файл apps/frontend/config/filters.yml пару строчек:

1
2
3
4
5
6
7
8
9
10
rendering: ~
security:  ~
 
# insert your own filters here
 
htmlcache:
    class: sfHtmlCacheFilter
 
cache:     ~
execution: ~

Тем самым мы активировали наш класс-фильтр.

Теперь сбрасываем кэш Symfony:

1
# php symfony cache:clear

И заходим на страницу http://localhost/ . Если все правильно сделали главная страница нашего сайта должна оказаться в memcached, проверяем:

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
# telnet localhost 11211
Trying ::1...
telnet: connect to address ::1: Connection refused
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
get http://localhost/
VALUE http://localhost/ 0 2026
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">

<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
 
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />

<title></title>
<link rel="stylesheet" type="text/css" media="screen" href="/css/main.css" />

<link rel="stylesheet" type="text/css" media="screen" href="/sf/sf_default/css/screen.css" />
 
<link rel="shortcut icon" href="/favicon.ico" />

 
<!--[if lt IE 7.]>
<link rel="stylesheet" type="text/css" media="screen" href="/sf/sf_default/css/ie.css" />

<![endif]-->
 
</head>
<body>
<div class="sfTContainer">
  <a href="http://www.symfony-project.org/"><img alt="symfony PHP Framework" class="sfTLogo" src="/sf/sf_default/images/sfTLogo.png" height="39" width="186" /></a>

<div class="sfTMessageContainer sfTMessage">
  <img alt="ok" class="sfTMessageIcon" src="/sf/sf_default/images/icons/ok48.png" height="48" width="48" />  <div class="sfTMessageWrap">

    <h1>Symfony Project Created</h1>
    <h5>Congratulations! You have successfully created your symfony project.</h5>
  </div>
</div>
<dl class="sfTMessageInfo">

  <dt>Project setup successful</dt>
  <dd>This project uses the symfony libraries. If you see no image in this page, you may need to configure your web server so that it gains access to the <code>symfony_data/web/sf/</code> directory.</dd>
 
  <dt>This is a temporary page</dt>

  <dd>This page is part of the symfony <code>default</code> module. It will disappear as soon as you define a <code>homepage</code> route in your <code>routing.yml</code>.</dd>

 
  <dt>What's next</dt>
  <dd>
    <ul class="sfTIconList">
      <li class="sfTDatabaseMessage">Create your data model</li>

      <li class="sfTColorMessage">Customize the layout of the generated templates</li>
      <li class="sfTLinkMessage"><a href="http://www.symfony-project.org/doc">Learn more from the online documentation</a></li>
    </ul>

  </dd>
</dl>
</div>
</body>
</html>
 
END
quit
Connection closed by foreign host.

Отлично! Теперь наш проект может перед отдачей ответа клиенту записывать в memcached HTML страниц.

Ну а теперь осталось настроить nginx, чтобы он мог сам отвечать клиенту, если запрошенная страница присутствует в memcached.

Для этого нам надо добавить еще один upstream для memcached в конфиг nginx и задать правила для проксирования в него запросов. Вот каким должен получиться конфиг для Nginx (изменения выделены жирным шрифтом):

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
worker_processes  1;
 
 
events {
    worker_connections  1024;
}
 
 
http {

    include       mime.types;
    default_type  application/octet-stream;
 
    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '

                      '"$http_user_agent" "$http_x_forwarded_for"';
 
    access_log  logs/access.log  main;
    error_log  logs/error.log info;
 
    sendfile        on;
    keepalive_timeout  65;

 
    upstream memcached_backend {
	server 127.0.0.1:11211;
    }
 
    upstream fcgi_www {
	server 127.0.0.1:9000;
    }

 
    server {
        listen       80;
        server_name  localhost;
 
        error_page   500 502 503 504  /50x.html;
        location = /50x.html {

            root   html;
        }
 
	root /srv/www/symfony/web; #%ПУТЬ_ДО_ВАШЕГО_ПРОЕКТА%/web
 

	location ~ "^(/css/|/images/|/js/|/uploads/)" {
	    expires 3d;
	}
 
	location /sf/ {
	    root /srv/www/symfony/lib/vendor/symfony/data/web; #%ПУТЬ_ДО_ВАШЕГО_ПРОЕКТА%/lib/vendor/symfony/data/web

	}
 
	location / {
	    fastcgi_param REQUEST_URI            $request_uri;
	    fastcgi_param DOCUMENT_URI           $document_uri;
	    fastcgi_param DOCUMENT_ROOT          $document_root;
	    fastcgi_param GATEWAY_INTERFACE      CGI/1.1;
	    fastcgi_param SERVER_SOFTWARE        nginx/$nginx_version;
	    fastcgi_param REMOTE_ADDR            $remote_addr;
	    fastcgi_param REMOTE_PORT            $remote_port;
	    fastcgi_param SERVER_ADDR            $server_addr;
	    fastcgi_param SERVER_PORT            $server_port;
	    fastcgi_param SERVER_NAME            $server_name;
	    fastcgi_param SERVER_PROTOCOL        $server_protocol;
	    fastcgi_param CONTENT_TYPE           $content_type;
	    fastcgi_param CONTENT_LENGTH         $content_length;
	    fastcgi_param HTTP_ACCEPT_ENCODING   gzip,deflate;
	    fastcgi_param QUERY_STRING           $query_string;
	    fastcgi_param REDIRECT_STATUS        200;
	    fastcgi_param REQUEST_METHOD         $request_method;
	    fastcgi_param SCRIPT_FILENAME        /srv/www/symfony/web/index.php; #%ПУТЬ_ДО_ВАШЕГО_ПРОЕКТА%/web/index.php

	    fastcgi_param SCRIPT_NAME            /index.php;
	    fastcgi_param PATH_INFO              $request_uri;
 
            # Если это POST-запрос, то передаем обработку сразу в Symfony
	    if ($request_method = POST) {

		fastcgi_pass  fcgi_www;
		break;
	    }
 
           # Если на предыдущем шаге мы не вышли из location'а, то это GET-запрос.
           # Устанавливаем ключ для memcached, по которому будем искать значение
	    set $memcached_key "http://$host$uri";
           # Задаем upstream для memcached

	    memcached_pass  memcached_backend;
 
           # Устанавливаем тип содержимого, полученного от memcached, у нас хранится HTML
	    default_type text/html;
           # Указываем nginx'у переходить в именованный location @symfony в случае отсутствия ключа 
           # в memcached или в случае возникновении ошибки при запросе к memcached
	    proxy_intercept_errors  on;
	    error_page 404 502 = @symfony;
	}

 
       # Здесь описан именованный location, куда nginx переходит в случае отсутствия ключа или                                          
       # возникновении ошибки при обращении к memcached. Здесь просто перечислены параметры 
       # fastcgi так же, как было сделано выше.
	location @symfony {
	    fastcgi_param REQUEST_URI            $request_uri;
	    fastcgi_param DOCUMENT_URI           $document_uri;
	    fastcgi_param DOCUMENT_ROOT          $document_root;
	    fastcgi_param GATEWAY_INTERFACE      CGI/1.1;
	    fastcgi_param SERVER_SOFTWARE        nginx/$nginx_version;
	    fastcgi_param REMOTE_ADDR            $remote_addr;
	    fastcgi_param REMOTE_PORT            $remote_port;
	    fastcgi_param SERVER_ADDR            $server_addr;
	    fastcgi_param SERVER_PORT            $server_port;
	    fastcgi_param SERVER_NAME            $server_name;
	    fastcgi_param SERVER_PROTOCOL        $server_protocol;
	    fastcgi_param CONTENT_TYPE           $content_type;
	    fastcgi_param CONTENT_LENGTH         $content_length;
	    fastcgi_param HTTP_ACCEPT_ENCODING   gzip,deflate;
	    fastcgi_param QUERY_STRING           $query_string;
	    fastcgi_param REDIRECT_STATUS        200;
	    fastcgi_param REQUEST_METHOD         $request_method;
	    fastcgi_param SCRIPT_FILENAME        /srv/www/symfony/web/index.php; #%ПУТЬ_ДО_ВАШЕГО_ПРОЕКТА%/web/index.php

	    fastcgi_param SCRIPT_NAME            /index.php;
	    fastcgi_param PATH_INFO              $request_uri;
	    fastcgi_pass  fcgi_www;
	}
    }
 
}

Теперь проверяем нет ли ошибок в конфигах Nginx после внесения изменений:

1
2
3
# /etc/init.d/nginx configtest
the configuration file /usr/local/nginx/conf/nginx.conf syntax is ok
configuration file /usr/local/nginx/conf/nginx.conf test is successful

И говорим Nginx, чтобы он перечитал конфиг:

1
2
# /etc/init.d/nginx reload
Reloading nginx:  reloaded

Проверяем! Заходим на страницу http://localhost/ , она у нас уже должна лежать в memcached. Лично я в своем браузере Mozilla Firefox даже на примере такой простой странички вижу, что она стала загружаться гораздо быстрее!

Но не будем доверять всему тому, что видим. Проведем небольшой бенчмарк, в этом нам поможет утилита ab2 — инструмент от разработчиков web-сервера Apache.

Сначала закомментируем наши настройки в Nginx, чтобы он перенаправлял все на Symfony.

Вносим следующие изменения в наш конфиг для Nginx, начиная с 71-й строки:

71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
	    fastcgi_param SCRIPT_NAME            /index.php;
	    fastcgi_param PATH_INFO              $request_uri;
	    fastcgi_pass  fcgi_www;
 
#	    if ($request_method = POST) {

#		fastcgi_pass  fcgi_www;
#		break;
#	    }
 
#	    set $memcached_key "http://$host$uri";
#	    memcached_pass  memcached_backend;
 
#	    default_type text/html;
#	    proxy_intercept_errors  on;

#	    error_page 404 502 = @symfony;
	}
 
	location @symfony {

Затем стандартные команды:

1
2
3
4
5
# /etc/init.d/nginx configtest
the configuration file /usr/local/nginx/conf/nginx.conf syntax is ok
configuration file /usr/local/nginx/conf/nginx.conf test is successful
# /etc/init.d/nginx reload
Reloading nginx:  reloaded

И запускаем утилиту от Apache, отправим на наш сайт 1000 запросов, причем 10 будем выполнять параллельно:

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
# ab2 -n 1000 -c 10 http://127.0.0.1:80/
This is ApacheBench, Version 2.3 <$Revision: 655654 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/
 
Benchmarking 127.0.0.1 (be patient)
Completed 100 requests
Completed 200 requests
Completed 300 requests
Completed 400 requests
Completed 500 requests
Completed 600 requests
Completed 700 requests
Completed 800 requests
Completed 900 requests
Completed 1000 requests
Finished 1000 requests
 
Server Software:        nginx/0.8.54
Server Hostname:        127.0.0.1
Server Port:            80
 
Document Path:          /
Document Length:        2026 bytes
 
Concurrency Level:      10
Time taken for tests:   26.412 seconds
Complete requests:      1000
Failed requests:        646
   (Connect: 0, Receive: 0, Length: 646, Exceptions: 0)
Write errors:           0
Non-2xx responses:      646
Total transferred:      1142110 bytes
HTML transferred:       964622 bytes
Requests per second:    37.86 [#/sec] (mean)
Time per request:       264.116 [ms] (mean)
Time per request:       26.412 [ms] (mean, across all concurrent requests)
Transfer rate:          42.23 [Kbytes/sec] received
 
Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   0.1      0       1
Processing:     0  264 349.9      3     827
Waiting:        0  264 349.9      3     810
Total:          1  264 349.9      3     827
 
Percentage of the requests served within a certain time (ms)
  50%      3
  66%    703
  75%    713
  80%    716
  90%    748
  95%    788
  98%    790
  99%    796
 100%    827 (longest request)

У меня после таких тестов отваливался через некоторое время fastcgi. Во время выполнения ab2 попробуйте проверить загрузку страницы в браузере — можно наблюдать постепенное нарастание тормозов при загрузке страницы.

Итак, если вас настигла беда с fastcgi, как и у меня, то запускаем его снова:

1
php-cgi -a -b 127.0.0.1:9000 -c /etc/php5/fastcgi/php.ini

Далее убираем наши комментарии, сделанные перед запуском теста, релоадим nginx и запускаем ab2 с теми же параметрами:

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
# /etc/init.d/nginx configtest
the configuration file /usr/local/nginx/conf/nginx.conf syntax is ok
configuration file /usr/local/nginx/conf/nginx.conf test is successful
# /etc/init.d/nginx reload
Reloading nginx:  reloaded
# ab2 -n 1000 -c 10 http://127.0.0.1:80/
This is ApacheBench, Version 2.3 <$Revision: 655654 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/
 
Benchmarking 127.0.0.1 (be patient)
Completed 100 requests
Completed 200 requests
Completed 300 requests
Completed 400 requests
Completed 500 requests
Completed 600 requests
Completed 700 requests
Completed 800 requests
Completed 900 requests
Completed 1000 requests
Finished 1000 requests
 
Server Software:        nginx/0.8.54
Server Hostname:        127.0.0.1
Server Port:            80
 
Document Path:          /
Document Length:        2026 bytes
 
Concurrency Level:      10
Time taken for tests:   0.337 seconds
Complete requests:      1000
Failed requests:        0
Write errors:           0
Total transferred:      2170000 bytes
HTML transferred:       2026000 bytes
Requests per second:    2971.65 [#/sec] (mean)
Time per request:       3.365 [ms] (mean)
Time per request:       0.337 [ms] (mean, across all concurrent requests)
Transfer rate:          6297.35 [Kbytes/sec] received
 
Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   0.4      0       3
Processing:     1    3   0.6      3       5
Waiting:        0    3   0.6      3       4
Total:          1    3   0.5      3       6
 
Percentage of the requests served within a certain time (ms)
  50%      3
  66%      3
  75%      4
  80%      4
  90%      4
  95%      4
  98%      4
  99%      4
 100%      6 (longest request)

Тут даже говорить нечего, все сказано в первом и втором выводах ab2. Достаточно взглянуть хотя бы на параметр «Time taken for tests», который показывает общее время проведения тестов. В первом случае он равен 26.412 секунд, во втором - 0.337 секунд. Прирост производительности почти в 80 раз!

Конечно, тестирование на локальном компьютере — это не есть тестирование в условиях, приближенных к реальным, однако какое-то представление оно все же дает.

А теперь вернемся к примеру, который был описан в самом начале статьи. Есть страница с постоянным контентом и есть блок с последними комментариями.

Как кэширование, которое мы только что внедрили в Symfony, поможет нам с кэшированием такой страницы? Пока никак. Наш кэш живет только какое-то определенное время, указанное в параметре lifetime, поэтому блок с комментариями будет обновляться только, когда истечет время жизни кэша. Но нас это не устраивает, нужно, чтобы страница обновлялась каждый раз после того, как добавился комментарий. Хорошо бы после добавления комментария удалять из кэша страницу.

Зададимся вопросом: «В какой момент у нас обычно должен обновляться кэш?». Обычно в момент, когда что-то меняется в базе данных. При чем, если изменения производятся в таблице, где хранятся комментарии, то обновляться должны страницы, где выводятся блоки, связанные с комментариями.

Для начала подключим к нашему проекту на Symfony базу данных. Не имеет значения какую базу данных использовать. Если вы знакомы с Symfony, то без труда сможете подключить свою базу данных. В своих же примерах я использую MySQL, пользователь root с паролем secretpass, имя базы данных — symfony.

1
2
# mysqladmin -uroot -psecretpass create symfony
# php symfony configure:database "mysql:host=localhost;dbname=symfony" root secretpass

Теперь опишем нашу будущую базу данных в Symfony, для этого отредактируем файл config/doctrine/schema.yml:

1
2
3
Comment:
  columns:
    text: { type: string(255), notnull: true }

Далее воспользуемся готовыми инструментами Symfony и сгенерируем код для моделей, SQL для базы данных.

1
2
3
# php symfony doctrine:build --model
# php symfony doctrine:build --sql
# php symfony doctrine:insert-sql

Последняя команда выполняет SQL, чтобы создать необходимые таблицы в базе данных. Вместо этих трех команд можно было бы выполнить php symfony doctrine:build —all, которая выполняет те же 3 действия, но за один раз.

Теперь сгенерируем стандартную функциональность для добавления, удаления, редактирования и просмотра списка объектов модели:

1
# php symfony doctrine:generate-module --with-show --non-verbose-templates frontend comment Comment

Здесь мы говорим сгенерировать код для нового модуля comment, код генерируется для существующей модели Comment.

Далее необходимо переопределить метод save() для моделей, участвующих в кэшировании. У нас это одна модель Comment, поэтому открываем файл lib/model/doctrine/Comment.class.php и добавляем в класс Comment метод save():

1
2
3
4
5
6
7
class Comment extends BaseComment
{
    public function save(Doctrine_Connection $conn = null) {
        parent::save($conn);
        sfHtmlCacheFilter::deleteCache($this);
    }
}

Мы добавили метод save(), который переопределяет метод save() класса Doctrine_Record. В нашем методе мы вызываем метод save() родительского класса, которым и окажется Doctrine_Record::save().

Далее вызывается метод нашего класса-фильтра sfHtmlCacheFilter::deleteCache($this), которому в качестве параметра передается сохраняемый объект. Таким образом, этот новый метод не делает ничего нового, кроме как добавляется метод для очистки кэша при каждом сохранении объекта.

Удаляем кэш Symfony, чтобы наши изменения вступили в силу.

1
# php symfony cache:clear

Все, теперь зайдем по адресу http://localhost/comment и увидим сгенерированную Symfony страницу. Нажимаем на ссылку «New», добавляем первую запись, возвращаемся назад к списку и видим только что добавленный комментарий.

Выше уже упоминалось, что в конфиге html_cache.yml можно прописывать зависимости страниц от моделей. Пришло время рассказать об этом подробнее.
Настройки в файле html_cache.yml используется следующим образом.

Перед отдачей любой страницы клиенту проверяется наличие адреса этой страницы в конфиге. Если он есть, то происходит запись содержимого страницы в кэш.
При сохранении модели вызывается метод класса-фильтра для удаления из кэша страниц, зависящих от текущей модели. Модели перечислены, как элементы первого уровня, на втором уровне перечислены зависящие от этих моделей адреса страниц.

Сейчас у нас в файле html_cache.yml на первом уровне прописана несуществующая модель Index, на втором уровне прописана главная страница. Т. к. модели Index у нас нет, то эта настройка просто говорит о том, что неоходимо записывать в кэш содержимое главной страницы, но никакого удаления ее из кэша не происходит.

Для такой главной страницы это и не нужно, т. к. там выводится всегда одна и та же информация. А вот со страницей списка комментариев ситуация иная — если для нее мы применяем кэширование, то необходимо удалять кэш этой страницы при каждом добавлении комментария.

Добавим такую настройку. Страница со списком комментариев располагается по адресу http://localhost/comment. Добавляем в файл config/html_cache.yml следующую запись:

1
2
Comment:
  /comment: { lifetime: 86400 }

Теперь наша страница http://localhost/comment будет отдаваться клиенту еще на стороне nginx при наличии ее в кэше. В противном случае nginx передаст управление Symfony, который сформирует и запишет содержимое страницы в кэш.

Таким образом, мы организовали зависимость страниц от изменяющихся моделей — при изменении определенных моделей очищается кэш зависящих от этих моделей страниц.

В файле html_cache.yml можно использовать регулярные выражения, а также подставлять в адреса страниц результаты выполнения методов объектов. Применим кэширование к детальным страницам комментариев, которые располагаются по адресам вида - http://localhost/comment/show/id/1 .

Добавим к модели Comment следующую зависимость:

1
2
3
4
Comment:
  /comment: { lifetime: 86400 }
  /comment/show/id/(\d+): { lifetime: 86400, attributes: [Id] }

Здесь мы указали в адресе регулярное выражение, которое пропускает только числовые значения ID комментариев. Мы также указали новый параметр attributes, который является массивом. В этом массиве объявляются методы модели, возвращаемые значения которых будут подставлены по порядку вместо регулярных выражений, заключенных в скобки. Причем методы могут возвращать не только обычные значения, но и массивы, в этом случае для удаления из кэша будет сформирован массив адресов страниц.

В данном примере вместо регулярного выражения (\d+) будет подставлен результат вызова метода getId() для объекта модели Comment. Это справедливо только при сохранении объекта и удалении кэша для него. При добавлении страницы в кэш будет произведено лишь сравнение текущего адреса страницы с регулярным выражением /comment/show/id/(\d+) и, если адрес пройдет проверку, то страница добавится в кэш.

После произведенных изменений в файле html_cache.yml страницы вида http://localhost/comment/show/id/1 постигнет участь предыдущих рассматриваемых страниц — они будут писаться в кэш.

Помимо параметров lifetime и attributes можно использовать параметр beforeDeleteCache, в который передается название метода модели (полное название). Этот метод должен возвращать true или false. При получении false кэш для записи с этим параметром удаляться не будет.

На этом все. Теперь наш проект на Symfony стал не просто быстрым, а мегабыстрым. Ответы клиент получает не от php-скриптов на сервере и даже не с диска, а из памяти сервера.

Спасибо за внимание! Чтобы ваши сайты всегда летали!

UPD: Спасибо Sergei за найденную ошибку. Все описанное работало только с латинскими буквами в url'е, но не работало с русскими. Внесены нужные изменения в класс-фильтр для Symfony, а также в конфиге nginx в качестве ключа для memcached теперь используется переменная $uri, а не $request_uri