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

Ну, а теперь перейду к делу. Тема обширная, но я надеюсь, что на выходе у меня получится донести картину целиком и вспомнить все подводные камни, которые всплыли до момента создания проекта. Я буду указывать все первоисточники, которые я использовал, чтобы помочь тем, кто хочет написать своё приложение на angular. Да, собственно, все желающие смогут найти ответы на большинство интересующих их вопросов по данной теме в одном цикле статей.

Я давно уже лелеял мысль апробировать material.angularjs.org на каком-то боевом проекте. Тут возникла идея и я решился… С виду все казалось довольно просто — набор готовых компонентов = быстрая разработка, на backend знакомый Yii и… Но я не расчитывал, что маленькое приложение окажется немного больше, чем планировалось вначале, и предстоит такая возня с веб-сервером. Как говорится, упс…

Началось все с конфигурации nginx. Получалось, что все запросы, кроме некого REST location, мне надо было перенаправлять на index.html, где у меня и начинал отрабатывать angular. Выглядела первая конфигурация примерно так:

server {
    charset utf-8;
    
    listen 80;
    server_name truemania.ru;

    root    /path/to/root;

    access_log	/path/to/root/log/access.log;
    error_log	/path/to/root/log/error.log;

    location / {
        # Angular app conf

        root    /path/to/root/frontend/web;

        try_files $uri $uri/ /index.html =404;
    }

    location ~* \.php$ {
        include fastcgi_params;
        #fastcgi_pass   127.0.0.1:9000;
        fastcgi_pass unix:/var/run/php5-fpm.sock;
        try_files $uri =404;
        fastcgi_param  SCRIPT_FILENAME  $document_root$fastcgi_script_name;
    }

    # avoid processing of calls to non-existing static files by Yii (uncomment if necessary)
    location ~* \.(css|js|jpg|jpeg|png|gif|bmp|ico|mov|swf|pdf|zip|rar)$ {
        try_files $uri =404;
    }

    location ~* \.(htaccess|htpasswd|svn|git) {
        deny all;
    }

    location /api-location {
        client_max_body_size 2000M;
        alias  /path/to/root/frontend/web;
        try_files  $uri /frontend/web/index.php?$args;

        location ~* ^/api-location/(.+\.php)$ {
            try_files  $uri /frontend/web/$1?$args;
        }
    }
}

Здесь все наше API находится по location /api-location. Конфигурация angular $routeProvider:

app.config(['$routeProvider', '$locationProvider', function ($routeProvider, $locationProvider) {
$routeProvider.
    when('/route1', {
        templateUrl: '/views/route1.html',
        controller: 'route1Ctrl'
    }).
    when('/route2', {
        templateUrl: '/views/route2.html',
        controller: 'route2Ctrl'
    }).
    when('/route3', {
        templateUrl: '/views/route3.html',
        controller: 'route3Ctrl'
    }).
    otherwise({
        redirectTo: '/route1'
    });
    // use the HTML5 History API
    $locationProvider.html5Mode({
        enabled: true,
        requireBase: false
    });
}]);


Но как angular-сайт будет индексироваться? В голову сразу пришло решение, что статику надо отдавать отдельно. Немного погуглив, нашел информацию о ?_escaped_fragment. Нужно было отдельно генерировать статику и отдавать на запросы типа http://truemania.ru/?_escaped_fragment готовые для индексации фрагменты.

При недолгом поиске наткнулся на статью, где был подробно описан механизм индексации для angular-сайтов, как раз для сервера nginx. В конфигурацию было добавлено еще несколько location:

if ($args ~ "_escaped_fragment_=(.*)") {
   rewrite ^ /snapshot${uri};
}

location /snapshot {
    proxy_pass http://help.truemania.ru/snapshot;
    proxy_connect_timeout  60s;
}

Создаём домен второго уровня, где будет происходить обработка запросов на отдачу готовых фрагментов. На запрос типа
http://truemania.ru/user/50?_escaped_fragment_=

вы получите
http://help.truemania.ru/snapshot/user/50


Остаётся только создавать все необходимые слепки, которые нужно отдавать поисковому боту. При этом я пользовался стандартами микроразметки schema.org. Кто не знаком с миром семантической разметки, советую ознакомиться с в этой статье.

Создание динамического sitemap очень подробно описано в этой статье — советую прочесть. Но жаль, что тут описано решение для первой версии Yii. Sitemap создается при каждом новом запросе заново, что может вызвать весьма высокую нагрузку на сервер. Выход — создание консольного контроллера и обновление sitemap с интервалом 10 минут, используя crontab. Совсем немного изменив исходный код, я получил годное решение для Yii2 console:

<?php
namespace console\models;

use Yii;
/**
 * @author ElisDN <mail@elisdn.ru>
 * @link http://www.elisdn.ru
 */

class DSitemap
{
    const ALWAYS = 'always';
    const HOURLY = 'hourly';
    const DAILY = 'daily';
    const WEEKLY = 'weekly';
    const MONTHLY = 'monthly';
    const YEARLY = 'yearly';
    const NEVER = 'never';

    protected $items = array();

    /**
     * @param $url
     * @param string $changeFreq
     * @param float $priority
     * @param int $lastMod
     */
    public function addUrl($url, $changeFreq=self::DAILY, $priority = 0.5, $lastMod = 0)
    {
        $host = Yii::$app->urlManager->getBaseUrl();
        $item = array(
            'loc' => $host . $url,
            'changefreq' => $changeFreq,
            'priority' => $priority
        );
        if ($lastMod)
            $item['lastmod'] = $this->dateToW3C($lastMod);

        $this->items[] = $item;
    }

    /**
     * @param \yii\db\ActiveRecord[] $models
     * @param string $changeFreq
     * @param float $priority
     */
    public function addModels($models, $changeFreq=self::DAILY, $priority=0.5)
    {
        $host = Yii::$app->urlManager->getBaseUrl();
        foreach ($models as $model)
        {
            $item = array(
                'loc' => $host . $model->getUrl(),
                'changefreq' => $changeFreq,
                'priority' => $priority
            );

            if ($model->hasAttribute('create_date'))
                $item['lastmod'] = $this->dateToW3C($model->create_date);

            $this->items[] = $item;
        }
    }

    /**
     * @return string XML code
     */
    public function render()
    {
        $dom = new \DOMDocument('1.0', 'utf-8');
        $urlset = $dom->createElement('urlset');
        $urlset->setAttribute('xmlns','http://www.sitemaps.org/schemas/sitemap/0.9');
        foreach($this->items as $item)
        {
            $url = $dom->createElement('url');

            foreach ($item as $key=>$value)
            {
                $elem = $dom->createElement($key);
                $elem->appendChild($dom->createTextNode($value));
                $url->appendChild($elem);
            }

            $urlset->appendChild($url);
        }
        $dom->appendChild($urlset);

        return $dom->saveXML();
    }

    protected function dateToW3C($date)
    {
        if (is_int($date))
            return date(DATE_W3C, $date);
        else
            return date(DATE_W3C, strtotime($date));
    }
}

Консольный action:

public function actionGetsitemap()
    {
        $sitemap = new DSitemap();

        $sitemap->addModels(Model1::find()->active()->all(), DSitemap::HOURLY);
        $sitemap->addModels(Model2::find()->all(), DSitemap::HOURLY);
        $sitemap->addModels(Model3::find()->all(), DSitemap::HOURLY);

        $path = \Yii::getAlias("@frontend/web") . DIRECTORY_SEPARATOR . "sitemap.xml";
        return file_put_contents($path, $sitemap->render());
    }

Конфигурация crontab для запуска через каждые 10 мин.

*/10 * * * * /path/to/yii cron/getsitemap >> /path/to/log/command_log/getsitemap.log;

Это решение оптимальное и весьма производительное, таким образом мы получаем довольно актуальные данные. При необходимости можно пересоздавать sitemap с более частым или более редким интервалом.

Далее пошла работа над красивым выводом ссылок в соцсетях. Для тех, кто не в теме, — это стандарт разметки http://ogp.me/. Меня постигло очень большое разочарование, что боты не понимают meta
—тега:

<meta name="fragment" content="!" />

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

Моя конфигурация для отдачи статики ботам соцсетей:

# Вот тут происходит обработка user-agent — если это бот соцсетей, отдаем статику
    if ( $http_user_agent ~* (facebookexternalhit|facebot|twitterbot|tinterest|google.*snippet|vk.com|vkshare) ){
        rewrite ^ /snapshot${uri};
    }

Естественно, осталось включить в мои слепки информацию о разметке open graph.

Далее я захотел использовать в некоторых очень выгодных моментах websocket — это отлично подходило для решения таких задач, как состояние online/offline для пользователя. Конечно, сами websocket вещь весьма нестандартная для PHP, но готовое решение быстро нашлось — http://socketo.me/.

Осталось только понять, как мне эти сокеты запустить на Yii2 в ubuntu. Собственно, создал консольный контроллер, и вот как выглядел action:

public function actionWebsocketaction()
    {
        $server = IoServer::factory(
            new HttpServer(
                new WsServer(
                    new UserOnline()
                )
            ),
            8099,
            '127.0.0.1'
        );

        $server->run();
    }

Ну, и далее прилагаю саму модель UserOnline:

<?php

namespace console\models;

use Yii;
use common\modules\core\models\User;
use Ratchet\MessageComponentInterface;
use Ratchet\ConnectionInterface;
use yii\web\ServerErrorHttpException;

class UserOnline implements MessageComponentInterface {

    /**
     * Люблю константы, не люблю цифры
     */
    const USER_OFFLINE = 0;
    const USER_ONLINE = 1;
	//При открытии нового соединения выведем в лог  resourceId
    public function onOpen(ConnectionInterface $conn) {
        echo "New connection! ({$conn->resourceId})\n";
    }
	//Если было получено сообщение, ставим данному пользователю статус online
    public function onMessage(ConnectionInterface $from, $username) {
        $model = UserOnlineConnections::findByUsername($username);
        if(empty($model))
        {
            $model = new UserOnlineConnections();
	//Параметры передаются с символом переноса строки, пришлось выпилить их регуляркой
            $model->username = preg_replace('/\\r\\n$/', '', $username);
            $model->conn_id = $from->resourceId;
            if(!($model->validate() && $model->save()))
                throw new ServerErrorHttpException(json_encode($model->getErrors()));
        }
        else
        {
            $model->conn_id = $from->resourceId;
            if(!($model->validate() && $model->save()))
                throw new ServerErrorHttpException(json_encode($model->getErrors()));
        }

        echo "New user online $model->username \n";

        self::setUserStatus($username, self::USER_ONLINE);
    }
	//Если соединение закрылось — пользователя в offline
    public function onClose(ConnectionInterface $conn) {
        echo "Close connection! ({$conn->resourceId})\n";

        $username = UserOnlineConnections::findByConnId($conn->resourceId)->username;
        if($username) {
            //Set status offline
            echo "User offline $username \n";

            self::setUserStatus($username, self::USER_OFFLINE);
        }
    }
	//Если ошибка — пользователя в offline
    public function onError(ConnectionInterface $conn, \Exception $e) {
        $username = UserOnlineConnections::findByConnId($conn->resourceId)->username;
        if($username) {
            //Set status offline
            echo "User offline $username \n";

            self::setUserStatus($username, self::USER_OFFLINE);

            echo "An error has occurred: {$e->getMessage()}\n";

            $conn->close();
        }
    }

    /**
     * Устанавливаем пользователю нужный статус
     * @param $username
     * @param $status
     * @return bool
     * @throws ServerErrorHttpException
     */
    public function setUserStatus($username, $status)
    {
        $model = User::findByUsername($username);

        if ($model) {
            $model->online = $status;

            if(!($model->validate() && $model->save()))
                throw new ServerErrorHttpException(json_encode($model->getErrors()));
            return true;
        }
        if($status == self::USER_OFFLINE) {
            UserOnlineConnections::deleteAll(
                "username=".$username
            );
        }
    }
}

Осталось только все это запустить. Нужно было сделать вывод stderr в stdout, но &> почему-то не хотел работать. Решение пришло с помощью nohup. Запуск сокета выглядел вот так:

nohup /path/to/yii ws/useronline >> /path/to/log/command_log/useronline.log; 

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

Далее надо websocet проксировать с помощью nginx. И тут в конфигурацию были добавлены следующие строки:

upstream useronline {
        server 127.0.0.1:8099;
}

map $http_upgrade $connection_upgrade {
	default upgrade;
	''      close;
}
# Добавка в секцию server
server {
	#ws proxy

    location /useronline {
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;

        proxy_pass http://useronline;
    }
}

Вот теперь наш веб сокет будет доступен по адресу ws://truemania.ru/useronline.

И последнее, с чем я столкнулся (из настроек веб-сервера) в процессе разработки — это переход на протокол https. Проблема была в следующем — facebook и google+ хотели, чтобы картинки отдавались по http, и упорно не хотели выводить в превью картинку. Для этого пришлось изменить конфигурацию, а именно — заставить сервер отдавать медиафайлы по http:

server {
    listen 80;
    server_name truemania.ru;
    root    /path/to/frontend/web;
    location / {
        return 301 https://$server_name$request_uri;  # enforce https
    }
#отдать статику по http
    location ~* \.(css|js|jpg|jpeg|png|gif|bmp|ico|mov|swf|pdf|zip|rar)$ {
        try_files $uri =404;
    }
}

server {
    charset utf-8;

    listen 443 ssl;
    ssl_certificate /path/to/ssl/truemania.crt;
    ssl_certificate_key /path/to/ssl/truemania.key;
}

Также после того, как протокол поменялся, обращение к socet происходит по адресу wss://truemania.ru/useronline.

Ну и если вам понравилось, то в следующей статье я расскажу, как писал само веб приложение + backend, опишу интересные решения на angular — такие, как авторизация, разрешения для авторизованных и не авторизованных пользователей, применение requireJS.

Комментарии (23)


  1. SerafimArts
    11.01.2016 15:12

    Я делал иначе:
    1) есть команда создания соединения, она поднимает само соединение и отправляет на роутер.
    2) Внутри роутера валидируется реквест и формат сообщения (в моём случае jsonrpc)
    3) Далее реквест отправляется в нужный контроллер, в зависимости от поля «method» в jsonrpc.
    4) При наличии ошибок или ответа из контроллера — можно сопоставлять «id» сообщений для псевдосинхронного выполнения (опять же кусок jsonrpc стандарта)

    Плюс с помощью DI в контроллеры подсовываются пул соединений, таймеры и прочие хелперы.

    Т.е. в моём случае контроллеры выполняют действия на запросы клиента (в т.ч. могут инициировать свои), а модели являются некими сущностями, изолированными от респонза. Вроде это в MVC называется «пассивная модель».


    1. Fesor
      15.01.2016 18:52

      Вроде это в MVC называется «пассивная модель».


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


      1. SerafimArts
        15.01.2016 20:03

        Почему же нет отделения? Обычная классическая MVC модель из Laravel или Symfony, но переведённая в немного другую стезю. Вместо http — jsonrpc, плюс асинхронная работа.

        Фиг знает, правильно ли это в связи с вебсокетами, но в моём случае контроллер из статьи является альтернативой обычному веб-серверу.


        1. Fesor
          15.01.2016 20:26

          Обычная классическая MVC модель из Laravel или Symfony


          классический MVC имеет смысл на клиенте, где у нас контекст приложение живет дольше чем один запрос.

          image

          А в Laravel и Symfony классический MVA

          image

          Роль адаптера выполняют GRASP-контроллеры.

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

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

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


  1. vitaly_KF
    11.01.2016 17:25
    -3

    Отлично, ставлю плюс.

    Тоже пилю небольшой амбициозный проект, но там правда API буду использовать не для фронтенда, а для мобильных приложений. Тоже в своё время малость игрался с настройкой nginx, так как нужно было разделить по поддоменам основной сайт и его api.


    1. master-7
      11.01.2016 17:28
      -4

      Спасибо за поддержку. А так же все кому интересно ссылка на мой проект


      1. getId
        12.01.2016 09:24

        Я рекомендую вам не делать ссылки на главной туда, куда у пользователя нет доступа (просмотр гостем примеров других в главном списке)


  1. Artima
    11.01.2016 18:23

    Очень интересно почитать про разработку Frontend и Backend. Ради любопытства решил небольшой проектец сделать — рейтинг игроков по настольной игре. То есть ничего сложного, в принципе. А в Yii насмерть, оказывается, зашит Boostrap. Хочется как-то использовать тот же Angular UI, а колхозить с самого начала не хочется.


    1. master-7
      11.01.2016 19:18
      +1

      Так используйте Yii в качестве REST сервера, тогда вам будет совсем не важно какой frontend использовать.


      1. Artima
        11.01.2016 23:49
        -1

        И готовить страницу на клиенте полностью? С REST придется возиться со всякими индексациями поисковиками как раз. И я как-то пока не очень верю в SEO-безоблачность такого подхода.

        С другой стороны, не очень хочется на столько глубоко разделять технологии fronend и backend. У меня же не HighLoad предполагается.

        Хотя за мысль все равно спасибо. Может в конечном итоге использую как раз такой вариант. Я просто по началу посмотрел как Yii генерит поля для формы в шаблонах. Подумал, что здорово было бы также сгенерировать некий интерфейс на frontend, но только с Angular UI. А для этого, похоже, немало усилий нужно приложить.


        1. master-7
          12.01.2016 08:36

          Если думать о мобильных приложениях, да и любых других клиентах, то REST подход подходящий. Думаете использовать AngularJS — у него как раз все под это заточено — $resource, $http. Нужнен будет в любом случае сервер, отдающий данные.


          1. Artima
            12.01.2016 23:44

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


    1. AlexGx
      11.01.2016 21:01
      +2

      >в Yii насмерть, оказывается, зашит Boostrap
      это не так, вы можете использовать бутстрап, можете не использовать. Boostrap используется в утилитах yii, и в некоторых виджетах.


      1. Artima
        11.01.2016 23:53

        Чуть выше написал про некоторое любопытство к генерации форм. Но в целом понятно, что можно прописать вручную все нужные интерфейсы хоть c Angular, хоть с jQuery. Но там видимо придется еще и за форматом передаваемых данных следить внимательно.


    1. fredrsf
      11.01.2016 22:11

      Отключить bootstrap можно в assetManager


      1. fredrsf
        11.01.2016 22:16

        А так же можно тащить пакеты из bower/npm через composer. Либо отдельно поставить npm, немного инфы тут


  1. Dreyk
    11.01.2016 22:34
    +1

    > if ( $http_user_agent ~* (...) ){
    > rewrite ^ /snapshot${uri};
    > }
    Очень похоже на cloaking. Google за такое по рукам не бьет?

    UPD: А, не, отдаются ведь снепшоты тех же страниц, тогда все ок


  1. oaons
    12.01.2016 06:18
    +2

    Так же в случае падения надо перезапустить данный процесс. Не нашёл решение более элегантного, как через каждую минуту запускать команду в crontab
    Supervisor: A Process Control System


  1. summerwind
    15.01.2016 16:40

    Создание снапшотов хорошо работает, если у вас небольшой сайт. А если у вас на сайте тысячи страниц, генерируемых и обновляемых пользователями?


    1. master-7
      15.01.2016 16:46

      Есть вот такой подход https://github.com/steeve/angular-seo


  1. Fesor
    15.01.2016 18:50

    Немного погуглив, нашел информацию о ?_escaped_fragment


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

    Ну и да, это автоматически решает проблемы с ботами соц сетей и т.д.

    обновление sitemap с интервалом 10 минут, используя crontab


    ну так себе решение на самом деле, было бы интереснее очереди сообщений и обновление по требованию или грамотная инвалидация кэша…

    но готовое решение быстро нашлось — socketo.me.


    опять же, ratchet это прикольно но он довольно давно не обновляется. Есть более качественные реализации WS серверов. Отдельно стоит заметить проект amphp и тамошнюю реализацию HTTP/WS сервера aerys

    Ну и там же в amphp есть асинхронные драйвера для работы с mysql или postgres, что круто в условия event loop. В вашем же случае ORM хоть работает и быстро, но на это время блокируется весь сервер. Что не ок.

    Не нашёл решение более элегантного, как через каждую минуту запускать команду в crontab


    supervisord


    1. master-7
      15.01.2016 20:45

      Спасибо за замечания, обязательно учту.
      Возник вопрос — я использую html5mode. У меня есть страница, которая открывается по адресу example.com/news/22
      Ну а как мне понять что отдавать — статику или динамику? Можно пример конфигурации?


      1. Fesor
        15.01.2016 21:39

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

        Из вариантов посложнее и интереснее — серверный пререндер (для React, Angualr2, Ember и других штук поддерживающих server-side рендринг), который чаще используется для улучшения UX, но и для поисковиков норм. В этом случае алгоритм будет одинаков для всех. Засылаем запрос в express-сервер какой-нибудь, тот запускает рендринг первоначальный приложения и выплевывает на клиент. Когда подтягиваются JS либы приложение оживает, но пользователь уже видит в принципе оное с каким-то состоянием, что собственно и улучшает UX.

        Конечно же этот подход намного сложнее так как вводит ограничения на то, что мы можем делать и как примерно все это должно работать (например stateless UI, проход данных сверху вних и т.д. при таком варианте все это можно организовать относительно малой кровью).