10 вещей, которые я хотел бы знать перед стартом проекта на Symfony

Прошло уже около двух лет с тех пор, как мы начали переписывать наш проект на Symfony2. Это было интересное время — работа с Symfony2+Doctrine2 была настоящим удовольствием после Битрикса. Конечно, оглядываясь назад, я понимаю, как не надо было делать, и сегодня делюсь этим с вами.

1. Не добавляйте файл composer.lock в .gitignore

Если вы пишете на Symfony, то наверняка используете Composer. Если нет — значит, что-то у вас в самом начале не заладилось.

Про странную манипуляцию с файлом composer.lock как-то говорил Рома Лапин на конференции PHP Frameworks Days в Киеве. Напомню, что файл composer.lock создается после последней успешной установки зависимостей и хранит последнее стабильное дерево зависимостей вашего приложения в точности до коммита. Это означает, что с помощью команды «composer install» вы можете откатиться до последнего стабильного состояния.

Многие разработчики почему то считают, что файл composer.lock лучше заигнорить. Мы не были исключением и сделали это, потому что в один прекрасный день у нас в зависимостях что-то поломалось — то ли при переходе на Symfony 2.3 (LTS), то ли при смене версии компонента symfony/icu.

Почему не надо? Может получиться забавная ситуация, когда одновременно у вас, вашего коллеги и на сервере окажется совершенно разный код с разными коммитами — и вполне возможно, приложение в каком-то месте будет работать некорректно.

2. Придерживайтесь слабой связанности бандлов и сервисов

Фреймворк Symfony построен так, чтобы дать разработчикам очень высокую степень гибкости в разработке и конфигурировании веб приложения. Symfony позволяет буквально жанглировать сервисами и зависимостями.

Одна из вещей, которую мы, на мой взгляд, сделали неправильно — пошли по пути жесткой связности бандлов, в то время как фреймворк предполагает другой подход.

История была примерно такая:

Есть популярный бандл SonataAdminBundle. Предполагается, что вы будете размещать admin-классы в своих бандлах примерно так:

src/Company/BlogBundle/
    Admin/
        ArticleAdmin.php
        CommentAdmin.php
   Entity/
   Resources/
       Config/
           admin.xml
   ...
src/Company/UserBundle/
    Admin/
        UserAdmin.php
        GroupAdmin.php
   Entity/
   ...

Преимущество этого подхода в том, что если вы добавляете в свой проект бандл, который использует SonataAdminBundle, достаточно просто добавить одну строку в app/AppKernel.php — и все ваши админ классы начинают работать, и новые сущности становятся доступны для редактирования в админ-панели.

Но мы, тогда еще непросвещенные, подумали: «Да ну, залазить в каждый бандл по отдельности и править админ классы и сервисы — это странно и неудобно!» — и решили, что будет лучше завести для этого новый бандл src/Company/AdminBundle для всех админ классов и сервисов:

src/Company/AdminBundle
    Admin/
        ArticleAdmin.php
        CommentAdmin.php
        UserAdmin.php
        GroupAdmin.php
        ...Admin.php
    Resources/config/admin.xml

В общем-то, на первый взгляд, тут ничего страшного и нет. Но впоследствии мы пожалели об этом, и вот почему.

В нашем проекте есть благотворительный фонд и медиатека (архив аудио, видео и книг). В один момент мы начали понимать, что жесткая связь между фондом и медиатекой может принести нам неприятности в будущем. Если мы выкатим обновление медиатеки с критичным багом это может задеть фонд. А для фонда не работать даже 15 минут — это весьма критично, подопечные теряют время деньги.
В этом основной минус монолитной архитектуры: очень легко поломать соседний важный кусок приложения.

Мы приняли решение разделить фонд и медиатеку на разные сервисы. Нам теперь важно, чтобы эти сервисы были в разных git-репозиториях и работали на разных инстансах.

Сейчас я понимаю, что это будет непростая задача. Если бы мы в самом начале шли по пути слабой связности бандлов и сервисов, было бы намного проще.

3. Не плодите сущности

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

«Произведение» в нашей медиатеке — это одна из главных сущностей, которая агрегирует в себе аудио, видео и книги. Однажды нам понадобилось добавить свойство «тип произведения»: аудио, видео, книги, музыка. Это свойство используется в поиске и в админке для фильтрации.

Главный нюанс состоит в том, что должна быть возможность фильтровать произведения одновременно по нескольким типам — например, отобразить те, у которых проставлен тип «аудио» и «книги».

Сразу стало понятно, что одним свойством тут не отделаешься. Мы решили добавить сущность Type и связать ее с произведением ассоциацией ManyToMany. Теперь, чтобы понять, какой тип проставлен в произведении, нам приходилось каждый раз перебирать типы через метод getTypes(). Также для удобства пришлось завести методы типа hasAudio() чтобы проверять, проставлен ли в произведении нужный тип.

Вызов метода getTypes() означал, что Doctrine будет делать запрос в базу данных, и это очень напрягало. Более того, пришлось также заводить фикстуры, чтобы при разворачивании проекта создавались эти типы. Страшно вспоминать ?

Немного позже, когда мне всё э то сильно надоело, я убрал эти странные сущности и просто добавил четыре булевых свойства audioType, videoType, bookType, musicType. Жить сразу стало легче.

Но одно такое неправильное решение сильно портило нам жизнь на протяжении длительного времени. Если вдруг вам в голову придет мысль, чтобы решить такую же проблему нашим способом, — гоните ее куда подальше!

4. Можно ли ваш проект развернуть в любом месте, быстро и без боли?

Это чем-то похоже на порядок в доме. Его можно либо поддерживать, либо в один прекрасный день ужаснуться тому, как быстро ваш дом стал похож на сарай, закатать рукава и пару дней заниматься уборкой.

Когда процесс разработки длится месяцы, очень легко перестать думать о том, что код проекта захочет запустить кто-то, кроме нас, или что нам самим придется развернуть проект в другом месте.

Часто бывало, что мы писали какой-то код, деплоили его на сервер, и всё работало. Но когда по какой-то причине приходилось разворачивать проект с нуля, внезапно начинали появляться какие то ошибки или эксепшены — и мы получали нерабочее приложение.

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

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

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

А есть ли возможность всегда поддерживать код приложения в порядке? Что делать, чтобы проект всегда можно было быстро развернуть в любом месте? Ответ оказался прост:

5. Используйте сервер непрерывной интеграции (CI)

Автотесты и сервер непрерывной интеграции — это темы, которые в последнее время не обсуждает только ленивый. Можно долго спорить и рассуждать на тему, нужны тесты или нет. Мы для себя решили, что нужны. Как минимум — Behat, а как максимум — PHPUnit и JS.

Самое большое преимущество CI в том, что мы можем автоматически запускать все тесты после каждого пуша в репозиторий и видеть, чей коммит всё поломал. Это особенно здорово, потому что спасает нас от деплоя бажного кода.

Кроме этого, благодаря CI вы можете поддерживать код постоянно в таком состоянии, чтобы в любой момент быстро и безболезненно развернуть приложение. Вы можете настроить всё так, чтобы сервер скачивал из репозитория последнюю версию кода, разворачивал проект на тестовом сервере и прогонял все тесты. CI на любом этапе скажет вам о проблеме, если что-то пойдет не так. Если вы сразу заметите проблему и исправите ее, то облегчите себе жизнь в будущем.

Мы остановили свой выбор на Jenkins. Настроили его таким образом, что после каждого пуша в GitHub Jenkins скачивает код последнего коммита и запускает скрипт bin/jenkins_scenario, который:
— С помощью Composer устанавливает все зависимости для проекта;
— Создает базу данных;
— Запускает миграции;
— Загружает в базу данных фикстуры;
— Запускает тесты.

6. Не игнорируйте JavaScript тесты

Этот пункт касается не только Symfony проектов. Тем не менее, сейчас я жалею о том, что мы пока так и не начали писать JavaScript тесты и не прикрутили их к Jenkins.
Behat и PHP Unit-тесты — это очень хорошо, но подчас JS тесты могут оказаться не менее важными.

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

Если бы CI каждый раз запускал и JS тесты, то еще на этапе Pull Request в GitHub мы увидели бы красивое сообщение о том, что сборка провалилась, и не деплоили бы такой серьезный баг на сервер.

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

7. Не пишите в контроллерах служебные вещи — парсеры, скрипты для переноса данных и тому подобное

Нам часто нужно было переносить данные между старым и новым сайтом. Иногда приходилось писать на старом сайте скрипт, который отвечал бы на запросы в JSON формате, а потом в цикле слать запросы с нового сайта, чтобы получить информацию.

Мы решили для таких целей использовать контроллер с торчащим наружу URI типа «/move/authors». Мы просто открывали в браузере URI «/move/authors», и скрипт делал перенос.

Со временем мы поняли, что это плохая идея, потому что:
— После создания такого контроллера нужно думать, как его потом закрыть или удалить, чтобы кто угодно его не дергал.
— Со временем про такой контроллер легко забыть, тем самым делая сайт уязвимым.
— Ну и вообще, это не самый удобный способ для переноса данных, если нужно перебрать много элементов в цикле и что-то с ними сделать.

К какому выводу мы пришли?
Лучшее решение — использовать для таких целей компонент Symfony Command. Просто запускаете на сервере нужную команду типа php app/console demo:dosomething — и ничего не будет торчать наружу.

Ещё один интересный момент, который я заметил: PHP-процесс, запущенный таким образом, работает стабильнее, чем вызов URI сайта через браузер.

8. Как вы делаете деплой?

В самом начале мы не особо беспокоились о том, как будем доставлять код на сервер. Когда он попал на Git, мы просто начали использовать для обновления команду git pull.

Конечно, для деплоймента Symfony приложения этого недостаточно. Ведь нам нужно каждый раз:
— Загружать код проекта из главного репозитория;
— Запускать миграции;
— Чистить/прогревать кеш;
— Устанавливать новые composer зависимости;
— Делать дамп css/js файлов;
— …

Дальше список можно продолжать, всё зависит от нужд проекта. В самом начале мы каждый раз заходили на сервер и запускали все эти команды вручную. Самое интересное — забыть одну из них, и после долго выяснять, почему ничего не работает!

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

Когда мы начали плотно сотрудничать с этими парнями, они нас познакомили с Capifony. Это замечательный инструмент деплоймента, построенный на основе Capistrano, кастомизированный для Symfony приложений.

Несмотря на то, что установить Capifony в ubuntu/windows непросто, он может сильно упростить вам жизнь: для деплоя достаточно одной лишь команды, запущенной в терминале — cap deploy.

9. Будьте прагматичны

Представьте себе связь один-ко-многим. Один Автор может иметь много Произведений, при этом Произведение может не иметь Автора.

Route для произведения с автором выглядит так: /dostoevskiy-fedor-mihaylovich/idiot/

Без автора — так:
/bez-avtora/skazki-i-skazanija/

Генерируя путь к произведению, каждый раз придется проверять, есть ли у произведения автор, и исходя из этого выбирать нужный Route. Кроме этого, в проекте есть много страниц, в которых также должна быть ссылка на произведение, а значит, надоедливых if/else не избежать.

Однажды мой коллега предложил: «Слушай, а давай создадим „неизвестного автора“, и к нему привяжем все произведения без автора — таким образом нам будет намного легче работать со связями, роутингом и так далее?».

Мне эта идея не понравилась. Я тогда подумал: «придется писать костыли, чтобы этого неизвестного автора не показывать в списках…» Так как последнее слово было за мной, еще раз подумав, я отказался. И сейчас сильно жалею об этом. На каждом углу при разработке нам приходится делать небольшие «танцы с бубном» и писать проверки типа:

{% if audio.work.author %}
   <p>
        <b class="book-col-title">Автор: </b>
        <a href="{{ path('author_show', { 'seoCode': audio.work.author.seoCode }) }}">
            {{ audio.work.author.name }}
        </a>
    </p>
{% endif %}
{% if audio.work %}
    <p>
        <b class="book-col-title">Произведение: </b>
        <a href="{{ path('work_show', {
        'authorSeoCode': audio.work.author ? audio.work.author.seoCode : unknown_author,
        'seoCode': audio.work.seoCode
        } ) }}">
            {{ audio.work.title }}
        </a>
    </p>
{% endif %}

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

Если окажетесь в подобной ситуации — подумайте, может, стоит поступить прагматичней.

10. Не пишите код для скачивания файлов на PHP

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

Обычно за день сайт посещало около 14 000 посетителей. Он не выдерживал такой нагрузки. 150-200 человек в реальном времени просто ложили веб-сервер, и сайт становился недоступным.

Вы не поверите, но на протяжении многих дней нам приходилось запускать командуsudo service httpd restart каждые полчаса или час (я уже сейчас не вспомню, что нам мешало тогда использовать Cron). Пути назад уже не было. Сайт был в продакшене. Мы просто дергали апач и пытались выяснить, почему httpd процессы съедают всю память на вполне нормальной железяке (16GB, Intel® Xeon® 3.10GHz, 4CPUs).

Подобные разговоры происходили почти каждый день:

Каких только предположений и догадок у нас не было:
— «Наверное, это точно MySQL, давайте логировать медленные запросы!»
— «Смотри! У нас тут есть древовидное меню, которое использует рекурсию. Может, тут собака зарыта?»
— «А вот на главной странице отправляются 3 одновременных Ajax-запроса, может, из-за этого оверхед?»
— «Наверное, просто Doctrine слишком медленная и жрет много памяти, давай попробуем всё закешировать!»
— «Слушай, я почти уверен, что проблема из-за страницы произведения! Тут иногда создается по тысяче PHP-объектов и вообще нет пагинации!»
— «…»

Когда уже всем было понятно, что нужно попросить совета у более опытных людей, я всё не мог сдаться и просил дать нам еще несколько дней на решение проблемы.
Парни, которые переписывали соседний проект, согласились нам помочь и пригласили хорошего админа глянуть на наш сервер.

По словам админа, наш код вообще почти не потреблял память, а более всего «стоял» в ожидании жесткий диск и забивался интернет-канал. Тут мы, кстати, начали грешить на хостинг, потому что выяснилось, что канал — всего лишь 100Mbit, и понятно, что для медиа-ресурса, с которого постоянно качают файлы, этого мало.

Тут мы решили, что вся проблема — в канале, и нам просто его не хватает. И так как мы уже тогда собирались перенести наши 3TB в облако, то решили, что после этого проблема должна уйти сама собой. Стало немного легче. Но, к нашему удивлению, сервер продолжал падать.

Как выяснялось позже, проблема была не столько в канале, сколько в тех, кто занимает пространство между ноутбуком и креслом!

We did it!

В один прекрасный день кто-то в переписке, в общем чате Skype, спросил: «А вы что-нибудь отдаете через код? Может, вы отдаете файлы для скачивания через приложение?»

Конечно! Мы отдавали файлы на скачивание через PHP. Почему? Нам нужно было, чтобы, нажимая на ссылку «скачать», пользователь получал файл, а не проигрывал его в браузере. Именно для этого мы использовали примерно такой код в контроллере:

header('Content-Type: ' . $mimeType);
header('Content-Disposition: attachment; filename=' . basename($path));
// ...
@ob_clean();
flush();
readfile($path);

Трудно поверить, но именно это сжирало всю память! Именно из-за этого много дней подряд у нас был довольно напряженный график и постоянные падения веб-сервера.

Отправляемый заголовок Content-Disposition: attachment; — обычно самый типичный способ сказать браузеру, что файл нужно скачать, а не выполнить. Но когда таким образом отдаются файлы большого размера, да еще и на нагруженном проекте — память на сервере заканчивается быстро.
Мы удалили этот несчастный код и сделали так, чтобы nginx перехватывал запросы, которые содержат подстроку /download, и посылал заголовок Content-Disposition: attachment;, чтобы браузер нормально скачивал файл.

Проблема была решена.

Вместо вывода

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

Может, вы уже давно пишете на Symfony, а может, только выбираете фреймворк (кстати, недавно я писал на тему выбора фреймворка: Laravel vs Symfony) и нашли эту статью. В любом случае, думаю, наш опыт вам пригодится.