Начну с предыстории самого проекта. Мысль пришла в голову совершенно случайно — мне явно не хватало для работы над своими проектами какой-то дополнительной ответственности. Вот и решил создать портал, где я смог бы стимулировать собственную мотивацию, публично рискуя репутацией и деньгами.
Ну, а теперь перейду к делу. Тема обширная, но я надеюсь, что на выходе у меня получится донести картину целиком и вспомнить все подводные камни, которые всплыли до момента создания проекта. Я буду указывать все первоисточники, которые я использовал, чтобы помочь тем, кто хочет написать своё приложение на 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 consolemodels;
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 yiidbActiveRecord[] $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 consolemodels;
use Yii;
use commonmodulescoremodelsUser;
use RatchetMessageComponentInterface;
use RatchetConnectionInterface;
use yiiwebServerErrorHttpException;
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.
Автор: master-7