Как я писал web app angular + material и REST на Yii2 + webserver nginx

от автора

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

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

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

Началось все с конфигурации 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_=</сode> вы получите <code>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 * * * * sh /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.

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

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

public function actionWebsocetaction()     {         $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.

И последнее, с чем я столкнулся (из настроек web — сервера) в процессе разработки — это переход на протокол 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.

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

ссылка на оригинал статьи http://habrahabr.ru/post/274867/