Кеширование блоков с помощью nginx

    nginx + SSIМногим разработчикам знакома ситуация когда кешировать страницы сайта, скажем, на 5-10 минут нельзя всего из-за одного небольшого блочка, актуальность которого нужно поддерживать если не в реальном времени, то с временем «старения» не больше 5-10 секунд. При этом посещаемость сайта продолжает расти, растет время генерации страниц и c этим надо что-то делать…
    • Вариант решения 1: Подкрутить то, до чего не доходили руки последнее полгода. Все Вас поймут и передвинут сроки на другие задачи. Вы будете в роли «Супермена» один спасать сайт от непомерной нагрузки, решая проблему «бесплатно» (без доп. вливаний в оборудование). Вам может пригодиться статья «Тюнинг nginx».
       
    • Вариант решения 2: Улучшить техническую базу (докупить мозгов на сервер, улучшить дисковую систему, поставить под БД отдельный сервер). В принципе проблема не решена, а скорее отложена. Теперь у Вас есть время «окопаться» и подготовиться ко второй волне наплыва нагрузки, она будет больше и накроет сильнее.
       
    • Вариант решения 3: Ваш вариант, о котором я, вероятно, узнаю из комментариев.
       
    Позвольте предложить и мне проверенное и относительно простое решение на базе одной из старейших технологий в Web-разработке.


    Как это должно работать


    Cайт всегда можно разбить на некоторое число независимых блоков, генерацией которых может заниматься (при необходимости) разные сервера.

    При этом сборкой блоков в единое целое занимается некий «сборщик» и если любой из блоков по какой-то причине не создан за отведенное ему время, то это еще не повод выдавать клиенту «Gateway timeout» или «Internal Server Error». Можно собрать успешно созданные блоки, а на месте «сбойных» показывать устаревший контент из кеша.

    Для реализации такой модели нам понадобиться технология-ветеран Web-разработки: ssi. В качестве «сборщика», как ясно из названия статьи, выступает nginx. «Чудеса» станут возможны благодаря модулю fastcgi_cache.

    Итак, поехали:


    Исключаем лишнее звено


    Нам не пригодиться apache, наличие которого, как правило объясняется использованием RewriteRules. В nginx есть аналог mod_rewrite или комбинация location/alias с регулярными выражениями, возможности которых позволяют написать аналог любому RewriteRule от apache. Кроме того в современных фреймворках разбором входного URL может заниматься сам движек (например Zend_Controller_Router_Rewrite в Zend Framework)

    В качестве fastcgi-бекенда может использоваться любая платформа. Примеры будут на php, но это не означает что нельзя написать аналогичный код на python-е или perl-е.

    Запускаем php в режиме fastcgi:
    # /bin/su -m www_user -c "PHP_FCGI_CHILDREN=8 /usr/bin/php-cgi -q -b 127.0.0.1:7777 &"

    Можно еще прописать путь к лог-файлу в php.ini (error_log = /var/log/fastcgi/fastcgi.log), но при этом придется перезагружать php-cgi.

    Делаем:
    # killall php-cgi
    и запускаем все по-новой

    Более продвинутый вариант запуска fastcgi — установка php-fpm.


    Устанавливаем nginx


    Можно ставить стандартный из репозитория/портов… Но если хотите чтоб работала возможность «почистить» любой файл в кеше, придется компилировать.

    Нам понадобиться модуль: ngx_cache_purge

    Я подробно опишу, как это можно сделать для redhat-подобной системы, а вы уж по аналогии компилируйте под вашу систему.

    # cd ~/rpmbuild/SRPMS
    # yumdownloader --source nginx
    # rpm -ivh nginx-0.7.65-1.fc12.src.rpm
    

    редактируем файл nginx.spec, где-нибудь в список ./configure вставляем строчку--add-module=/root/rpmbuild/BUILD/ngx_cache_purge-1.0 \. Тут же можно удалить строчки с ненужными модулями (например --with-ipv6 \, --with-http_dav_module \, --with-mail \, --with-mail_ssl_module \ ...)

    теперь распаковываем содержимое http://labs.frickle.com/files/ngx_cache_purge-1.0.tar.gz в папку /root/rpmbuild/BUILD/ngx_cache_purge-1.0.
    Все можно компилировать:

    # cd ~/rpmbuild/SRPMS
    # rpmbuild -ba nginx.spec
    

    Это не совсем красивый способ, т.к. полученный в результате .src.rpm не будет содержать файла с модулем ngx_cache_purge. Если для вас, все же, это критично, то здесь можете загрузить «правильный» вариант nginx .src.rpm для ветки 8.xx. Правда я часть ненужных мне модулей закомментировал.

    Устанавливаем пересобранный nginx на наш сервер:

    # rpm -ivh nginx-0.7.65-1.fc12.x86_64.rpm


    Настройка nginx для проекта на php


    В файл /etc/hosts (добавляем):

    # Virtual hosts 
    127.0.0.1 myproject
    

    В основном конфиге /etc/nginx/nginx.conf в секцию http добавляем:

    fastcgi_cache_path /var/spool/nginx/cache levels=1:2 keys_zone=mycache:64m;
    include /etc/nginx/conf.d/*.conf;
    

    (Не забудьте создать папку /var/spool/nginx/cache и установить для нее пользователя, под которым запускается nginx)

    В папке /etc/nginx/conf.d/ создаем конфиги для виртуальных хостов

    Пример кофига (/etc/nginx/conf.d/myproject.conf):

    server {
            listen       80;
            server_name  myproject;
    
            root   /var/www/myproject/public;
            ssi on;
    
            # Включаем кеш если есть такая необходимость
            fastcgi_cache mycache;
            fastcgi_cache_min_uses 1;
            # Время кеширования равно нулю. кеш включен но кеширования нет
            # Время кеширования для конкретных страниц указиваем в заголовке "Cache-Control"
            fastcgi_cache_valid 200 0m;
            fastcgi_cache_valid 404 1m;
            fastcgi_cache_valid 500 0m;
            fastcgi_cache_use_stale updating error timeout invalid_header http_500; # Используем вариант из кеша (даже если он устарел) в случае ошибки
            fastcgi_cache_key $uri$is_args$args;
    
            # Раскоментируйте эту секцию если nginx собран с модулем ngx_cache_purge
    #         location ~ ^/purge(/.*) {
    #               fastcgi_cache_purge   mycache   $1$is_args$args;
    #         }
    
            location ~ /(img|css|js|assets) {
    #               access_log  off;
                    access_log  /var/log/nginx/myproject_img_access.log  main;
                    expires 1h;
            }
    
            location / {
                    access_log  /var/log/nginx/myproject_main_access.log  main;
                    error_log  /var/log/nginx/myproject_error.log;
    
                    fastcgi_pass 127.0.0.1:7777;
                    fastcgi_index    index.php;
    
                    include         fastcgi.conf;
            }
    }
    

    Устанавливаем тестовый проект на php в /var/www/myproject. Исходный код примера можно посмотреть и скачать здесь.

    Запускаем nginx. Для RedHat-подобных систем это выглядит приблизительно так:

    # service nginx start
    

    Все, система готова к работе! Пробуем запустить http://myproject/


    Учим backend управлять временем кеширования


    Дело в том, что в nginx время кеширования указывается в параметре fastcgi_cache_valid 200 0m; и распространяется на все страницы, в которых заголовком оно не переопределено.

    В конфиге «по умолчанию» время кеширования я указал равным 0, т.е. кеширование отключено. Но если бекенд сгенерирует заголовок приблизительно такого вида:

    Cache-Control: public, max-age=20
    
    либо
    Expires: Thu, 18 Mar 2010 20:57:07 GMT
    

    То страница nginx-ом будет закеширована на 20 секунд. В php заголовок можно поменять с помощью функции header() (Со слов автора nginx самым приоритетным является «X-Accel-Cache-Control», потом «Cache-Control», потом «Expires»).

    Напишем небольшую функцию. котрая будет управлять временем кеширования:
    function cacheHeaders($lifetime=0) {
    #        $date = gmdate("D, d M Y H:i:s", time() + $lifetime);
    #        header('Expires: ' . $date . ' GMT');
            header('Cache-Control: public, max-age=' . $lifetime);
    }
    



    Мастерим блоки


    Блоком будем называть любую логически выделенную часть html-кода без стандартных заголовков html-старницы, например:

    <div>
        Это простой блок
    </div>
    

    Чтоб визуально контролировать состояние свежести каждого из блоков добавим код, наших тестовых блоков вывод времени.

    <?php echo date('G:i:s')?>
    

    Смотрим рабочий пример с использованием SSI блоков.


    Удаляем страницы из кеша


    К сожалению у nginx-а пока что нету родного (штатного) способа удаления страничек из кеша. Иногда это может создавать неудобства.

    Если вы добавили при компиляции модуль ngx_cache_purge, то в конфиг (/etc/nginx/conf.d/myproject.conf) добавим приблизительно такую секцию, перед секцией «location / {...» :

    location ~ ^/purge(/.*) {
            #allow     127.0.0.1;
            #allow     10.1.1.0/24;
            #deny     all;
            fastcgi_cache_purge   mycache   $1$is_args$args;
    }
    

    Для того чтоб удалить закешированную страницу: http://myproject/mypage.php?lang=ru, мне достаточно загрузить страницу http://myproject/purge/mypage.php?lang=ru

    В php это можно сделать командой file_get_contents(«http://myproject/purge/mypage.php?lang=ru»);

    С помощью директив allow и deny можно ограничить круг хостов с которых можно «чистить» кеш.


    Тестируем


    Напоминаю, ссылка для тестов http://linux.ria.ua/SsiBlocks/src/bin/index.php.

    Обратите внимание, «каркас» страницы обновляется раз в 10 секунд, остальные блоки обновляются согласно примечаниям под временем создания блока.

    Самый большой интерес, на мой взгляд, представляет «Збойный блок». Если вы введете его в режим имитации сбоя, вы все равно будете видеть «несбойную» версию этого блока пока не очистите кеш.

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


    Делаем выводы


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

    Узким местом может быть только дисковая система, если кеш «распухнет» до больших размеров и не будет помещаться в дисковый кеш.

    PS: Если эта статья будет интересна читателям, я планирую написать вторую часть о применении описанного подхода к кешированию блоков на Zend Framework.
    Поделиться публикацией
    Похожие публикации
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама
    Комментарии 63
    • +1
      Есть еще Varnish, не пробывали его?
    • +6
      Имею мнение, что рациональнее заходить с другой стороны — встраивать через ssi отдельные блоки, и кэшировать каждый блок по отдельности. Это позволяет более гибко работать с временем кэширования, и избавиться от костыля с пуржингом кэша.
      • 0
        IMHO, это самый прозрачный способ.
        • 0
          Я с Вами согласен. В данном случае каждый блок кешируется именно по отдельности, сборка блоков в готовую страницу происходит при каждом запросе к серверу. Просто алгоритм сборки настолько заоптимизирован, что этот процес отъедает очень малую долю процессорного времени.
          • +1
            Если fastcgi_cache написал на весь location / {} — где ж тут по-отдельности?

            • 0
              Возможно вы не привыкли к стилю конфтгов nginx, в данном конкретном случае в кеш не попадает «по умолчанию» ничего, т.к. время кеширования равно 0.

              По какому принципу ложить в кеш указано в директиве fastcgi_cache_key $uri$is_args$args; Т.е., например, сранички /menu.php и /menu.php?key=value будут размещены в разных кеш-файлах.

              Кроме того в кеше не происходит подмены ssi-инструкций. Если в index.php есть "", то в кеше эта инструкция так и остается. Подмена происходит в менент запроса и если menu.php нету сейчас в кеше то происходит обращение к бекенду и размещение в кеш только menu.php, где время размещения в кеше определяется по заголовку «Cache-control».

              Объяснил как умел, если неумело, то настройте локальную копию и убедитесь в этом сами.

              А очистка кеша нужна для того что указана инструкция fastcgi_cache_use_stale, если ваш бекенд сгенерит ошибку то она не будет показана, абудет взята устаревшая копия с кеша. Ну если устаревшей копии в кеше нет, только тогда можно увидеть сбой.

              Но если запрашивается сраничка index.php, menu.php,… то в кеше создается отдельная страничка
              • 0
                Там хабракат кое-что вырезал, в скобочках было <!--# include virtual=«menu.php» -->
                • 0
                  Мама, роди меня обратно…

                  Я как бы так помягче сказать, использую nginx в продакшне уже года четыре. Примерно на 800-та серверах. Зачастую, с собственными патчами. С трафиком в пределе около 30к rps на nginx.
                  Но это так, к слову.

                  Собственно, я хотел обратить внимание на то, что существенно дешевле не делать кэш для всего с нулевым таймаутом, а кэшировать только те куски, которые вставляются через ssi. Чтобы не строить кэш-кэй по целой простыне параметров. Чтобы не городить огород с use_stale.

                  Это позволит также избавиться от «если ваш бекенд сгенерит ошибку». Можно перехватить ошибку бэкэнда для каждого отдельного блока.

                  • 0
                    Я с Вами согласен что схему можно селать более оптимальной, но согласитесь что при этом конфиг будет выглядеть сложнее для понимания схемы.
          • НЛО прилетело и опубликовало эту надпись здесь
            • НЛО прилетело и опубликовало эту надпись здесь
              • 0
                Можно поидее ж примонтировать часть ОП как папку Ж…
                • НЛО прилетело и опубликовало эту надпись здесь
              • +5
                Структура старницы
                Збойный блок
                Поправьте, глаза режет.
                • 0
                  «движек» туда же
                • +5
                  Че за херня? Тема: Кеширование блоков с помощью nginx, а в статье уже рассказывается как его заинсталить и как заинсталить РНР, че за бред?

                  • 0
                    В статье рассказывается все о том как у себя воспроизвести предложенную мной схему. Я старался максимально понизить порог вхождения в тему, спецы и так все давно знают и этим успешно пользуюются.

                    А если видите что-то лишнее для себя, переходите с следующему подзаголовку.

                  • +5
                    Поправьте, бога ради, окончания «тся» и «ться».
                    • +1
                      Не рассматривали ли вы такой случай, когда сайт реализован на каком-то относительно тяжелом фреймворке (для которого время «разогрева» сравнимо или даже превышает время создания содержимого блока), и при этом страница содержит более одного блока с нулевым или близким к нулю временем жизни? Получается, что может возникнуть ситуация, когда время двух запросов к бэкенду за содержимым некэшируемых блоков превысит время создания всей страницы без использования кэша.
                      • 0
                        В любом деле нужно действовать без фанатизма. Я с Вами согласен, что в таком случае все переводить на блоки нельзя.

                        Вобще-то можно, частично, неужели все блоки должны быть актуальны? Посто каркас страницы кешировать уже не получится, а вот ленту новостей, курсы валют, блок с счетчиками/информерами,… можно поробовать.
                      • 0
                        Способ слишком не гибкий — применяли уже кое-что подобное.
                        Почему? Потому что могут быть принципиально не кэшируемые блочки: те что зависят от текущего пользователя, например, «Спасибо, что заглянули, %username%! У вас %msg_count% новых сообщений». Каждый такой блочок будет выражаться в запросе к backend-у. Допустим таких блочков у нас 5 (вполне возможная ситуация: приветствия, формы, контролы над своими сообщениями и т.п.), это означает, что на каждую страницу на нужно делать 5 запросов к backend-у, пусть все они будут простыми, но с каждого мы получим оверхед сети, fastcgi запроса/разбора, проверки текущего пользователя. В общем, ужасно.

                        Куда эффективнее да ещё и гибче разруливать такие вещи на самом backend-е
                        • +2
                          почитайте доки по nginx ssi. там есть поддержка переменных и выражения. так что при должном старании, можно и это сделать. я так понял, автор хотел только поверхностно ознакомить с темой поэтому и не рассмотрел этот аспект.
                          • 0
                            Причём тут поддержка переменных и выражения?
                            Мне нужно прочитать сессию пользователя из, скажем, редиса, запросить сколько у него сообщений ещё откуда-нибудь (PostgreSQL) и вывести соответствующую фразу. ngnix же это за меня не сделает?
                            Или, например, мы показываем список постов, для текущего пользователя нужно показать кнопки редактирования рядом с его постами, что же мне кэшировать этот список постов отдельно для каждого юзера?
                            • +1
                              Вверху страницы делается одна ssi-вставка типа /usersettings/, которая кешируется по сессионной куке uid.
                              Эта вставка выдет список из set var с id постов (последних N постов) юзера (и любых других его данных).
                              Ниже, при выводе списка для каждого поста с помошью ssi if проверяем существование переменной с id поста среди списка постов юзера. Внутри каждого if — кнопка.

                              И не забываем про internal для локейшена /usersettings/ и параметр wait для его вставки.
                              Еще можно для него указать таймаут в несколько секунд, чтобы все хоть как-то работало, когда база сдохнет.
                          • +1
                            получаем сессию отдельным блоком, на результат накладываем xslt, который раскидает данные сессии по документу.
                            • 0
                              Во-первых, это гемор, во-вторых xslt — это не так уж быстро, в третьих остаются блочки некэшируемые по другим причинам: число сообщений у юзера, статус онлайн кого-нибудь, просто часто обновляющаяся инфа, в-четвёртых, libxml/libxslt текут, в-пятых, чтобы делать xsl-трансформацию нужно чтобы ssi-скрипты выдавали валидный xml.

                              Ну и в конце концов, что у нас получится? Бекэнд со своим движком и какими-то шаблонами (предположительно), ssi-шаблончики, xsl-трансформации. жуткий зоопарк.
                              • 0
                                1. это ни разу не гемор. гемор — это писать тучи сси-ифов
                                2. и не так уж и медленно.
                                3. какая разница по каким причинам они не кэшируются?
                                4. откуда такие данные? это ведь не какие-то левые пропиетарные библиотеки.
                                5. это так сложно выдавать валидный xml?

                                а зачем нам «какие-то шаблоны», если мы всё-равно накладываем xslt? ;-) пусть движок отдаёт чистый xml, ssi объединяет несколько xml-ек в одну и xslt приводит это дело к удобочитаемому виду. кстати, nginx случайно не поддерживает xi:include?
                                • 0
                                  1. SSI + XSLT + шаблоны в бэкенде (без последних если используем только xslt) шаблоны двух видов — гемор. xslt сам по себе не самый удобный шаблонизатор.
                                  3. Такая разница, что если блок не кэшируется принципиально, то придётся запрашивать его с бекэнда каждый раз, и если таких блоков больше одного, то вся эта солянка теряет смысл. И даже если один, она становится очень сомнительной.
                                  4. Тестировали, некоторые трансформации текут. Возможно, если просто подсовывать значения, то с этим не столкнёшься.
                                  5. Намного сложнее, чем html, любой неэкранированный символ, любая неопредённая entity, неправильный utf символ и всё, весь документ сломался.
                                  • 0
                                    1. уж по удобней, чем большинство
                                    3. одним запросом можно получить все некэшируемые блоки
                                    4. багрепорт конечно же не написали?
                                    5. любой баг вылезает сразу, а не когда его найдёт кулхацкер. это плохо?
                                    • 0
                                      1. Не используйте большинство, используйте удобный.
                                      3. Почему тогда просто не посчитать эти блоки на бекэнде всунуть их в страницу точно также как нгинкс, подтянуть что-то из кеша, тоже всунуть и выдать страницу?
                                      4. Я писал другой багрепорт для libxslt раньше, понял, что всем похуй.
                                      5. Не сразу, а когда возникнет такая ситуация, и в такой ситуации у тебя тупо перестанет отображаться вся страница.
                                      • 0
                                        1. это о чём речь?
                                        3. идея сабжа как раз и заключается в том, чтобы не изобретать велосипед, а воспользоваться кэшированием энжиникса.
                                        4. м… печальненько.
                                        5. лучше ничего не показать, чем показать xss
                                        • 0
                                          1. Выбирайте на свой вкус, мне, например, нравится jinja2.
                                          3. Если уж говорить о велосипедах, то скорее идея сабжа «а вот есть ещё такой быстрый велосипед». Но есть, то и дургие, ничего изобретать не надо.
                                          5. Да там нет никакой xss, данные могут и не от пользователей браться, в конце концов. Просто если заворачивать данные в xml простой конкатенацией строк, то неизбежно будешь ловить такие баги время от времени, а как-то хитрее уже медленнее. Вообще, необходимость все данные заворачивать в xml не особо удобна.

                                          Я не говорю же, что такой подход нельзя использовать, я говорю, что он негибкий, а значит для каких-то типов сайтов будет неэффективен, неудобен и/или порождать кучу извращений, чтобы втиснутся в рамки.
                                          • 0
                                            1. бугога х))) спасибо, такого мне не надо
                                            3. но тогда у нас не получится map-reduce архитектуры
                                            5. не надо заворачивать данные в xml конкатенацией строк. это вредная привычка. и именно благодаря ей xss и просачивается.
                                            • 0
                                              3. Захочешь — получиться, не захочешь — сделаешь такую какую захочешь.
                                              5. Чем-то приходиться жертвовать ради скорости, чтобы не было xss достаточно проверять данные на входе.
                                              • 0
                                                5. ага, типа как на хабре — каверкать любое упоминание слова javascript? x)
                                              • 0
                                                Кстати, вопрос на засыпку. Вы сами-то использовали такое кэширование с ssi?
                                                • 0
                                                  нет
                                                  • 0
                                                    Ну вот, а я гавна такого сорта уже поел, и xslt тоже.
                                                    • –1
                                                      у тебя нет вкуса
                                                      • 0
                                                        Ты даже не понял смысла моей фразы, и при этом ещё бросаешь безосновательные ощущения.
                                                        • 0
                                                          я прекрасно всё понял.
                            • 0
                              простите за глупый вопрос. а как нгикс понимает что див кешировать, а не Спан? можно для примеру кусок пхп кода, чтоб понять как страница на пхп выглядит.
                              • 0
                                nginx парсит SSI-инструкции, ему неважно какой кусок html вы упаковали в блок. В статье есть ссылка на исходный код linux.ria.ua/SsiBlocks/. Если в коде не разберетесь пишите.
                            • 0
                              я однажды от скуки померял сайт на ssi, лежащий на локалхасте, утилитой ab и обнаружил, что медленнее Server Side Includes что-то еще поискать надо! как я понял, он при каждом инклюде форкает новый процесс апача/nginx, а эта операция в юниксе чрезвычайно затратная и по времени, и по памяти
                              • –2
                                Только что проверил сабж (ваш пример, который, как я понял, особо ничего и не делает) и свой собственный сайт, на тяжелом движке с большим количеством запросов к базе, к тому же на хостинге в другой стране, — так и есть:

                                ваш сайт
                                HTML transferred: 3109309 bytes
                                Requests per second: 143.95 [#/sec] (mean)
                                Time per request: 69.468 [ms] (mean)
                                Time per request: 6.947 [ms] (mean, across all concurrent requests)
                                Transfer rate: 462.66 [Kbytes/sec] received

                                мой
                                HTML transferred: 24322000 bytes
                                Requests per second: 226.20 [#/sec] (mean)
                                Time per request: 44.209 [ms] (mean)
                                Time per request: 4.421 [ms] (mean, across all concurrent requests)
                                Transfer rate: 5452.79 [Kbytes/sec] received
                                • +1
                                  Я думаю твой тест может претендовать на звание «самый объективный тест в рунете», тут даже нету смысла говорить о чистоте эксперимента — результат очевиден. Ура!
                                  • –1
                                    Наркоман штоле?
                                • 0
                                  nginx новый процесс не форкает.
                                  • 0
                                    Тем не менее, результаты удручающие.
                                    • +1
                                      сравнение не корректное, приложения работают в разных окружениях.
                                      • 0
                                        Я бы согласился с вами, если б не огромная разница в Transfer rate.
                                        • 0
                                          Я тут протестил, оказывается хабр работает намного медленее, чем мой собственный сайт-визитка в локалке :)
                                          • 0
                                            Я кажется написал, что сайт находится в другой стране, а не в локалке, ок?
                                            • 0
                                              Да, и это полностью сводит на нет всю объективность вашего измерения…
                                  • +1
                                    при каждом инклюде форкает новый процесс апача/nginx

                                    nginx устроен по другому
                                  • 0
                                    не всегда в случае сбоя нужно показывает кэшированную версию. зачастую требуется заглушка или хотябы ремарка о том, что эти данные могут быть не актуальны…

                                    а ещё лучше делать так: habrahabr.ru/blogs/client_side_optimization/90481/
                                    • 0
                                      «There are only two hard problems in Computer Science: cache invalidation and naming things.» Phil Karlton
                                      • 0
                                        наткнулся случайно,
                                        про такую возможность писал пять лет назад
                                        habrahabr.ru/post/109050
                                        а вообще было интересно почитать, спасибо

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