ЧПУ для Zend Framework и кэширование

Задача:
Обрабатывать url'ы вида:

www. example.com/what/about-this-2010-09-08.html

с учетом параметров внутри, например таким шаблоном:

[module]/[controller]-[action]-[year]-[month]-[day].[ext]


Как появилась задача.

1. Понадобилось кэшировать статические и редкоменяющиеся страницы в ZF

ZF, помимо своей универсальности, обладает также приличной тяжеловесностью, и это вполне логично.
Поэтому при генерации какого-либо контента стараются применять кэширование, чтобы уменьшить частоту таких генераций.
Кэшируют блоки, страницы, объекты модели, запросы к БД — всё, что можно.

2. Отдавать генерированные страницы .html без участия PHP

Страницы, которые не предусматривают активного изменения содержимого лучше кэшировать целиком. И таких страниц, как ни странно, бывает много.
Для этого создан Zend_Cache_Frontend_Page. С первого раза у меня он не заработал, хотя нисколько не сложен. И вместо выяснения ЧЯДНТ, я решил, что такие страницы можно отдавать без участия PHP, просто как .html файл.

Чтобы не перенапрягать .htacess сложными правилами, структура папок будет соответствовать запросам и наоборот.
Также желательно чтобы запросы были очеловеченного вида и не слишком длинные.
url'ы:
/what/about/this.html
/what/about/other-12.html
/it/computing_games~page1.html
/it/computing_games~page1.html

файловая структура:
_cache/what/about/this.html
                 /other-12.html
_cache/it/computing_games~page1.html
         /computing_games~page2.html

Осталось только задать роуты для таких url'ов, захватить ob_start() вывод для определенных контроллеров-действий и сохранить в папку _cache

3. Задать для ZF маршруты, отличные от стандартных

Тут Zend предлагает мощнейшее средство Zend_Controller_Router_Route_Regex.
Но средство это чуть более, чем совсем НЕ читабельное. Да еще и reverse и map в придачу.

4. Написать свой класс для обработки

Написал.
class EXT_Controller_Router extends Zend_Controller_Router_Route_Regex
{
    protected $_defaultReqs = "\w+";
    protected $_paramStrFormat    = "name[value]";
    protected $_paramStrValueGlue = "~";
    //protected $_paramStrFormat    = "name-value-";
    //protected $_paramStrValueGlue = "!";
    protected $_regex = null;
    protected $_route = null;
    protected $_insides = array();
    protected $_quotes = array();
    protected $_defaults = array();
    protected $_values = array();
    protected $_map = array();
    protected $_reqs = array();
 
    /**
     * @param Zend_Config $config Configuration object
     */

    public static function getInstance(Zend_Config $config)
    {
        $defaults = ($config->defaults instanceof Zend_Config) ? $config->defaults->toArray() : array();
        $reqs = ($config->reqs instanceof Zend_Config) ? $config->reqs->toArray() : array();
        return new self($config->route, $defaults, $reqs);
    }
 
    public function __construct($route, $defaults = array(), $reqs = array())
    {
        $a = "(\[.*\])";
        $b = "(\{.*\})";
        $pattern = "#(.*)(" . $a . "|" . $b . ")#Ui";
        $this->_quotes  = array();
        $this->_insides = array();
        $map = array();
        $regex = '';
        if (preg_match_all($pattern, $route, $matches, PREG_SET_ORDER)) {
            foreach ($matches as $i => $match) {
                $inside = substr($match[2], 1, strlen($match[2])-2);
                $key = preg_replace('[\W]', '', $inside);
                if (!empty($reqs[$key])) {
                    $reg = $reqs[$key];
                } elseif ('paramstr' == $key) {
                    $reg = '.*';
                } else {
                    $reg = $this->_defaultReqs;
                }
                switch ($match[2][0]) {
                    case '[':
                        $end = '';
                        break;
                    case '{':
                        $end = '{0,1}';
                        break;
                }
                $regex .= preg_quote($match[1], '#') . '(' . str_replace($key, $reg, preg_quote($inside, '#')) . ')' . $end;
                $map[$key] = $i+1;
                $this->_quotes[$key] = $match[2][0];
                $this->_insides[$key] = $inside;
            }
        }
        preg_match("#[^\]\}]*$#i", $route, $lostMatch);
        $regex .= preg_quote($lostMatch[0], '#');
 
        $this->_route    = $route;
        $this->_regex    = $regex;
        $this->_defaults = (array) $defaults;
        $this->_map      = $map;
        $this->_reqs     = $reqs;
    }
 
    /**
     * Matches a user submitted path with a previously defined route.
     * Assigns and returns an array of defaults on a successful match.
     *
     * @param  string $path Path used to match against this routing map
     * @return array|false  An array of assigned values or a false on a mismatch
     */

    public function match($path, $partial = false)
    {
        $path = trim(urldecode($path), '/');
        $regex = '#^' . $this->_regex . '$#Ui';
        $res = preg_match($regex, $path, $values);
        if ($res === 0) {
            return false;
        }
        foreach ($values as $i => $value) {
            if (!is_int($i) || $i === 0 || empty($value)) {
                unset($values[$i]);
            }
        }
        $values   = $this->_getMappedValues($values);
 
        foreach ($this->_insides as $name => $ins) {
            if ($name !== $ins && isset($values[$name])) {
                $pattern = '#^' . str_replace($name, '(.*)', preg_quote($ins)) . '$#i';
                if (preg_match($pattern, $values[$name], $matches)) {
                    $values[$name] = $matches[1];
                }
            }
        }
 
        if (!empty($values['paramstr'])) {
            $values += $this->explodeParamStr($values['paramstr']);
        }
        $this->_values = $values;
 
        $defaults = $this->_getMappedValues($this->_defaults, false, true);
        $result   = $values + $defaults;
        return $result;
    }
 
    public function explodeParamStr($paramStr)
    {
        $values = array();
        $pattern = str_replace(array('name',   'value'), 
                               array('(\w+)', '(.*)'), 
                               preg_quote($this->_paramStrFormat, '#'));
 
        $pattern = '#(' . $pattern . ')#Uui';
        if (preg_match_all($pattern, $paramStr, $matches, PREG_SET_ORDER)) {
            foreach ($matches as $match) {
                if (!empty($match[3]))
                $value = explode($this->_paramStrValueGlue, $match[3]);
                if (count($value) > 1) {
                    $values[$match[2]] = $value;
                } else {
                    $values[$match[2]] = $match[3];
                }
            }
        }
        return $values;
    }
 
    public function implodeParamStr(array $params)
    {
        $paramStr = '';
        foreach($params as $name => $value) {
            if (!in_array($name, array('controller', 'module', 'paramstr', 'action')) && !array_key_exists($name, $this->_map)) {
 
                if (is_array($value)) {
                    $value = implode($this->_paramStrValueGlue, $value);
                }
                $paramStr .= str_replace(array('name', 'value'),
                                         array($name,  $value),
                                         $this->_paramStrFormat);
            }
        }
        if (empty($paramStr)) {
            $paramStr = null;
        }
        return $paramStr;
    }
 
    /**
     * Assembles a URL path defined by this route
     *
     * @param  array $data An array of name (or index) and value pairs used as parameters
     * @return string Route path with user submitted parameters
     */

    public function assemble($data = array(), $reset = false, $encode = false, $partial = false)
    {
        $replaces = array();
        $searches = array();
 
        $paramStr = $this->implodeParamStr($data);
 
        $data = $data + $this->_values;
 
        if (empty($paramStr)) {
            unset($data['paramstr']);
        } else {
            $data['paramstr'] = $paramStr;
        }
 
        foreach($this->_map as $key => $i) {
            $inside = $this->_insides[$key];
            switch ($this->_quotes[$key]) {
                case '[':
                    $searches[$i] = '[' . $inside . ']';
                    break;
                case '{':
                    $searches[$i] = '{' . $inside . '}';
                    break;
                default:
                    break;
            }
            if (!empty($data[$key])) {
                $replaces[$i] = str_ireplace($key, $data[$key], $inside);
            } else {
                $replaces[$i] = '';
            }
        }
        $url = str_replace($searches, $replaces, $this->_route);
        return $url;
    }
}
 

Использование

Задаем шаблон url'а через routes.ini
[module]/[controller]/{action}{-page}.htm
my-route.type = "EXT_Controller_Router"
my-route.route               = "[module]/[controller]/{action}{-page}.htm"
my-route.defaults.action = index

этот маршрут подходит для запросов вида:
www.notmysite.ru/what/about/this.html
www.notmysite.ru/what/about/other-12.html

В квадратных скобках обязательные параметры [param_name],
в фигурных — необязательные {my_param}
Небуквенные символы внутри скобок помимо идентификатора могут понадобиться для:
разделения параметров [action]{-page}, если не уточняется из каких символов* состоят оные
если в url'е нужны эти самые скобки
www.notmysite.ru/dogovor[123e][12].htm
, тогда маршрут будет задан так [controller][[tom]][[page]].htm
dogovor.route = "[controller][[tom]][[page]].htm"
dogovor.defaults.module     = index
dogovor.defaults.controller = dogovor
dogovor.defaults.action     = show

Волшебный параметр [paramstr]

Для передачи переменного числа параметров я зарезервировал идентификатор {paramstr}
В него включаются не указанные явно параметры.
Например, для маршрута:
[section]/{action}{~paramstr}.html
car.type = "EXT_Controller_Router"
car.route               = "[section]/{action}{~paramstr}.html"
car.defaults.module     = car
car.defaults.controller = catalog

подойдет запрос
/car/view~year[2001~2003]fuel[diesel]color[blue~red]vol[1000~1600]state[good]place[Москва].html
и в контроллере Test_CatalogController
мы получим все параметры:
'section'   => 'car' 
'action'    => 'view' 
'paramstr'  => 'year[2001~2003]fuel[diesel]color[blue~red]vol[1000~1600]state[good]place[Москва]' 
'letter'    => 'l' 
'year' 
          0 => '2001' 
          1 => '2003' 
          
'fuel' => 'diesel' 
'color'
          0 => 'blue' 
          1 => 'red' 
'vol' 
          0 => '1000' 
          1 => '1600' 
          
'state'      => 'good' 
'place'      => 'Москва' 
'module'     => 'test' 
'controller' => 'index' 

И, обратно, если передать помощнику вида эти параметры, будет сформирован соответствующий url.
$params = array('action'  => 'view', 
                'step'    => 2, 
                'year'    => array('2009', '2010'));
$nextUrl =  $this->view->url($params, 'my-route');


Детали

1. Как и в Zend_Controller_Router_Route_Regex, можно использовать шаблоны .reqs для каждого параметра
например, "ограниченный набор значений":
my-route.reqs.action  = "index|view|print" 
 
или "только цифры":
my-route.reqs.action  = "\d+" 


2. Как было показано выше, параметры {paramstr} передаются в формате key1[value1~value2]key2[value1~value2~value3]
Этот формат можно поменять в классе
protected $_paramStrFormat    = "name[value]";
protected $_paramStrValueGlue = "~";


Важно

Класс работоспособен, но написан не идеально.
В дальнейшем его можно подправить, избавиться от лишних preg_match, наследовать не от Regex, а от Abstract
Этот класс дополняет зендовские роуты, поэтому для стандартных случаев лучше использовать стандартные средства. А если вы любите сами писать регулярные выражения для url'ов, то вообще лучше не использовать этот класс.
+5
14 июля 2010, 12:57
20
VSV 41,2

комментарии (3)

0
mrmot #
А подскажите, пожалуйста, как Вы код раскрасили?
0
VSV #
habrahabr.ru/blogs/webdev/58391/
выбрал первый вариант
0
mrmot #
Спасибо большое.

Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.