Начну с предыстории самого проекта. Мысль пришла в голову совершенно случайно — мне явно не хватало для работы над своими проектами какой-то дополнительной ответственности. Вот и решил создать портал, где я смог бы стимулировать собственную мотивацию, публично рискуя репутацией и деньгами.
Ну, а теперь перейду к делу. Тема обширная, но я надеюсь, что на выходе у меня получится донести картину целиком и вспомнить все подводные камни, которые всплыли до момента создания проекта. Я буду указывать все первоисточники, которые я использовал, чтобы помочь тем, кто хочет написать своё приложение на angular. Да, собственно, все желающие смогут найти ответы на большинство интересующих их вопросов по данной теме в одном цикле статей.
Я давно уже лелеял мысль апробировать material.angularjs.org на каком-то боевом проекте. Тут возникла идея и я решился… С виду все казалось довольно просто — набор готовых компонентов = быстрая разработка, на backend знакомый Yii и… Но я не расчитывал, что маленькое приложение окажется немного больше, чем планировалось вначале, и предстоит такая возня с веб-сервером. Как говорится, упс…
Началось все с конфигурации nginx. Получалось, что все запросы, кроме некого REST location, мне надо было перенаправлять на index.html, где у меня и начинал отрабатывать angular. Выглядела первая конфигурация примерно так:
Здесь все наше API находится по location /api-location. Конфигурация angular $routeProvider:
Но как angular-сайт будет индексироваться? В голову сразу пришло решение, что статику надо отдавать отдельно. Немного погуглив, нашел информацию о ?_escaped_fragment. Нужно было отдельно генерировать статику и отдавать на запросы типа
При недолгом поиске наткнулся на статью, где был подробно описан механизм индексации для angular-сайтов, как раз для сервера nginx. В конфигурацию было добавлено еще несколько location:
Создаём домен второго уровня, где будет происходить обработка запросов на отдачу готовых фрагментов. На запрос типа
вы получите
Остаётся только создавать все необходимые слепки, которые нужно отдавать поисковому боту. При этом я пользовался стандартами микроразметки schema.org. Кто не знаком с миром семантической разметки, советую ознакомиться с в этой статье.
Создание динамического sitemap очень подробно описано в этой статье — советую прочесть. Но жаль, что тут описано решение для первой версии Yii. Sitemap создается при каждом новом запросе заново, что может вызвать весьма высокую нагрузку на сервер. Выход — создание консольного контроллера и обновление sitemap с интервалом 10 минут, используя crontab. Совсем немного изменив исходный код, я получил годное решение для Yii2 console:
Консольный action:
Конфигурация crontab для запуска через каждые 10 мин.
Это решение оптимальное и весьма производительное, таким образом мы получаем довольно актуальные данные. При необходимости можно пересоздавать sitemap с более частым или более редким интервалом.
Далее пошла работа над красивым выводом ссылок в соцсетях. Для тех, кто не в теме, — это стандарт разметки http://ogp.me/. Меня постигло очень большое разочарование, что боты не понимают meta
—тега:
На данном этапе я немного застопорился, так как элементарно в лоб решения не нашлось. Я хотел заставить ботов понимать, что за страницей скрывается реальный фрагмент. Погуглив, я принял решение отдавать фрагменты по user-agent. Пришлось изучить документацию для соответствующего сервиса, чтобы получить примерные user-agent, которые можно было бы извлечь, пользуясь регулярными выражениями.
Моя конфигурация для отдачи статики ботам соцсетей:
Естественно, осталось включить в мои слепки информацию о разметке open graph.
Далее я захотел использовать в некоторых очень выгодных моментах websocket — это отлично подходило для решения таких задач, как состояние online/offline для пользователя. Конечно, сами websocket вещь весьма нестандартная для PHP, но готовое решение быстро нашлось — http://socketo.me/.
Осталось только понять, как мне эти сокеты запустить на Yii2 в ubuntu. Собственно, создал консольный контроллер, и вот как выглядел action:
Ну, и далее прилагаю саму модель UserOnline:
Осталось только все это запустить. Нужно было сделать вывод stderr в stdout, но &> почему-то не хотел работать. Решение пришло с помощью nohup. Запуск сокета выглядел вот так:
Так же в случае падения надо перезапустить данный процесс. Не нашёл решение более элегантного, как через каждую минуту запускать команду в crontab. В случае, если порт занят, то ничего не произойдет (выйдет ошибка), но если порт свободен, процесс будет запущен заново.
Далее надо websocet проксировать с помощью nginx. И тут в конфигурацию были добавлены следующие строки:
Вот теперь наш веб сокет будет доступен по адресу ws://truemania.ru/useronline.
И последнее, с чем я столкнулся (из настроек веб-сервера) в процессе разработки — это переход на протокол https. Проблема была в следующем — facebook и google+ хотели, чтобы картинки отдавались по http, и упорно не хотели выводить в превью картинку. Для этого пришлось изменить конфигурацию, а именно — заставить сервер отдавать медиафайлы по http:
Также после того, как протокол поменялся, обращение к socet происходит по адресу wss://truemania.ru/useronline.
Ну и если вам понравилось, то в следующей статье я расскажу, как писал само веб приложение + backend, опишу интересные решения на angular — такие, как авторизация, разрешения для авторизованных и не авторизованных пользователей, применение requireJS.
Ну, а теперь перейду к делу. Тема обширная, но я надеюсь, что на выходе у меня получится донести картину целиком и вспомнить все подводные камни, которые всплыли до момента создания проекта. Я буду указывать все первоисточники, которые я использовал, чтобы помочь тем, кто хочет написать своё приложение на 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. Нужно было отдельно генерировать статику и отдавать на запросы типа
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.