Как развернуть несколько версий сайтов на одном инстансе YII

в 7:20, , рубрики: php, web-разработка, yii, Блог компании «Alawar Entertainment», Веб-разработка, метки: , ,

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

Часть 1

Введение

Многие компании поддерживают работу нескольких сайтов для продвижения своих товаров на разных рынках.
Так делаем и мы. У нас есть сайты для русского, американского, европейского и других рынков, отдельные сайты для mobile-устройств, сайты партнерских программ, которые также различны для разных стран.
В разработке мы используем фреймворк yii, на который мы в прошлом году перевели наш главный сайт Alawar.ru, а в этом году также Alawar.com, Alawar.pl и сайты iOS-устройств.
Одна из особенностей деплоймента наших сайтов на yii заключается в том, что все они работают на одном инстансе этого замечательного фреймворка.
Проблемы в решении этой задачи нет, мы рассмотрим одну конкретную реализацию.

Различия сайтов

Чтобы понять, как устроить несколько сайтов на одной платформе, нужно разобраться, что у них общего и чем они отличаются.
Сразу хочется отметить, что под разными сайтами мы подразумеваем различие не только лишь в локализации, что разруливается элементарным Yii::t('messageFile', 'messageCode') во вьюхах и разными messageFile для разных локалей.
А разное у сайта может быть следующее:

  • наполнение;
  • функционал модулей фронтенда;
  • темы/скины;
  • язык/локализация.

Разберем подробно каждый пункт.

Наполнение

Под наполнением мы понимаем различный контент для разных сайтов.
Так как сайты различаются для разных стран, а значит продают товары на разных рынках, то товары, предлагаемые для продажи на одном рынке, иногда требуется снять с продажи на другом.
Разные сайты могут иметь разные специальные предложения и разные скидки для своих посетителей.
В конце концов, разными могут быть даже статические страницы информации.
Таким образом, мы должны иметь возможность одним инстансом фреймворка разруливать данные расхождения.

Функционал отдельных блоков/модулей фронтенда

Например, на сайте Alawar.com присутствует функционал Unlimited — подписки на все игры на выбранное пользователем время за соответствующую стоимость.
Alawar.pl также предоставляет этот функционал пользователям, но варианты подписок предлагает отличные от английского сайта.
На русском же сайте работа Unlimited вообще не предусмотрена.

Темы/скины

В этом пункте возможны разные варианты задач:

  1. Разные темы сайтов (например, мобильный сайт имеет отдельную от основного тему).
  2. Одинаковые темы сайтов, но разные варианты оформления (например, праздничный скин на 9 мая на Alawar.ru или скин 4 июля на Alawar.com).

Научиться создавать темы для движка Yii — дело не хитрое.
Разделением тем структурно во фреймворке решается вопрос разделения дизайна на сайтах.

Язык/локализация

И лишь на последнем месте стоит локализация, о которой было сказано выше.

Сходства сайтов

А теперь посмотрим на то, что общего может быть у всех сайтов.
Самое главное сходство и единственно важная и достаточная причина, почему стоит делать разные сайты на одной платформе — это одинаковый бэкенд. Если на ваших сайтах бэкенд разный, то, возможно, не стоит смешивать их в один проект.
Да, мы выбираем разные товары для продажи на разных сайтах. Но все товары лежат в одной базе и доступны в одной админке.
Мы рисуем разные скины для разных сайтов, но все они могут быть прикручены к одной теме.
Особенно, если бэкенд не ограничивается mysql-админкой.

В нашем случае под бэкендом понимается:

  • сложная mysql-админка, расположенная на отдельном сервере, написанная на другой CMS;
  • отправка данных из этой админки через rabbitmq в бэкенд yii-проекта;
  • формирование бэкендом yii шардируемых и в mysql и в nosql данных;
  • индексация данных сфинксом.

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

Поставленные задачи

Подводя итог сравнению сходств и различий наших сайтов, определим, какие задачи должно решать разделение сайтов.

  1. Отображение различного и/или уникального контента на разных сайтах при сохранении общего бэкенда.
  2. Возможность кастомизировать функционал разных модулей сайтов отдельно друг от друга.
  3. Поддержка разных тем или разных шкур тем, разных текстовых локализаций.

Часть 2

Конфиг для определения сайта

В данной статье покажем разделение приложения на два сайта: русский и английский. В реальности таким способом можно разделить столько сайтов, сколько потребуется.

Определить, в какой ситуации какой сайт показывать, мы можем по следующим параметрам:

  • по домену, с которого пришел пользователь;
  • по user-agent устройства;
  • по истории переходов по сайтам из данных в COOKIE/сессии.

Создадим конфиг файл protected/config/sites.php со следующим содержанием:

<?php
return array(
    'mywebsite.ru' => array(
        'host' => array(
            'mywebsite.ru',
            'www.mywebsite.ru',
        ),
        'userAgent' => false,
    ),
    'mywebsite.com' => array(
        'host' => array(
            'mywebsite.com',
            'www.mywebsite.com',
        ),
        'userAgent' => false,
    ),
);

В данном примере мы разберем определение сайта только по домену. В нашем случае определение по юзер агенту не используется, поэтому указываем 'userAgent' => false. Этот ключ задается для случая, когда по одному домену требуется показывать разные версии сайтов.

Отдельные конфиги для отдельных сайтов

Сделаем для каждого сайта отдельный конфигурационный файл с его уникальными настройками. И дадим этим файлам имена, соответствующие ключам в вышеописанном массиве.
То есть, создадим файлы:
protected/config/mywebsite.ru.php
protected/config/mywebsite.com.php

Приведем ниже содержание файла protected/config/mywebsite.ru.php:

<?php
return CMap::mergeArray(
    array(
		'basePath'=>dirname(__FILE__).DIRECTORY_SEPARATOR.'..',
        'name'=>'Application name',
        'theme' => 'mywebsite',
        'language' => 'ru',

        'modules'=>array(
        ),

        'controllerMap'=>array(
            'site'=>'application.sites.common.controllers.SiteController',
            'promo'=>array(
                'class' => 'application.sites.mywebsite-ru.controllers.PromoController',
                'viewPrefix' => '/mywebsite-ru/promo/',
			),
            'support'=>array(
                'class' => 'application.sites.common.controllers.SupportController',
                'viewPrefix' => '/mywebsite-ru/support/',
            ),
		),	

        'components'=>array(
            'urlManager'=>array(
                'urlFormat'=>'path',
                'showScriptName' => false,
                'urlSuffix' => '/',
                'rules' => array(
                    ''			=> 'site/index',
                    'promo/'	=> 'promo/index',
                    'support/'	=> 'support/index',
                ),
            ),
            'errorHandler'=>array(
                'errorAction'=>'site/error',
            ),

        ),
        'params'=>array(
            'adminEmail'=>'admin@mywebsite.ru',
            'arCustomParams' => array(
                'customBannerPath' => '/promo/mywebsite.ru/promo-banner.png'
			),
        ),
	),
	require_once(dirname(__FILE__).'/main.php')
);

Как видите, мы вынесли все сайтозависимые настройки в отдельные файлы, являющиеся конфигами конкретных сайтов.
Имя сайта, тема, локаль определяются в этом конфиге.

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

Определим пути, по которым будут доступны общие для всех сайтов и специфичные для конкретных сайтов контроллеры.
Контроллеры, которые использует данный сайт, указанные в параметре controllerMap:

        'controllerMap'=>array(
            'site'=>'application.sites.common.controllers.SiteController',
            'promo'=>array(
                'class' => 'application.sites.mywebsite-ru.controllers.PromoController',
                'viewPrefix' => '/mywebsite-ru/promo/',
			),
            'support'=>array(
                'class' => 'application.sites.common.controllers.SupportController',
                'viewPrefix' => '/mywebsite-ru/support/',
            ),
		),	

Здесь мы видим, что mywebsite.ru использует контроллер SiteController, общий для обоих сайтов. Путь к отображению для данного контроллера будет стандартный, дополнительно указывать его не требуется.
PromoController, напротив, относится только к данному сайту, т.к. может содержать данные о различных акциях, применимых только на определенном сайте.
SupportController используется общий для обоих сайтов, а вот отображения для этого контроллера используются кастомные.

Чтобы добиться корректной обработки свойства viewPrefix во всех контроллерах фронтенда без определения этого свойства с дальнейшим переопределением функцией render в каждом, создадим класс protected/components/AController.php, отнаследованный от класса CController:

<?php
/**
 * Controller is the customized base controller class.
 * All controller classes for this application should extend from this base class.
 */
class AController extends CController
{
    /**
     * @var string the default layout for the controller view. Defaults to '//layouts/column1',
     * meaning using a single column layout. See 'protected/views/layouts/column1.php'.
     */
    public $layout='//layouts/main';
    public $baseHref=false;
    public $viewPrefix='';
    /**
     * @var array context menu items. This property will be assigned to {@link CMenu::items}.
     */
    public $menu=array();
    /**
     * @var array the breadcrumbs of the current page. The value of this property will
     * be assigned to {@link CBreadcrumbs::links}. Please refer to {@link CBreadcrumbs::links}
     * for more details on how to specify this property.
     */
    public $breadcrumbs=array();

    /**
     * @var string page description
     */
    public $pageDescription = '';

    
    public function render($view,$data=null,$return=false)
    {
        return parent::render($this->viewPrefix.$view,$data,$return);
    }
}

Все контроллеры фронтенда в нашем приложении будем наследовать от этого класса.
В нем мы определили свойство viewPrefix и с его учетом переопределили метод render($view,$data=null,$return=false), что позволит нам структурно разделять отображения контроллеров по заданному префиксу.

Это гибкая система, которая позволяет выносить только те отображения/контроллеры, которые различаются настолько, что стоит использовать для них отдельные файлы.
Так как все наши сайты используют один бэкенд, то мы должны разделять только фронтенд логику, т.е. контроллеры и вьюхи, а модели, формы, компоненты, экстеншены и все остальное — всегда общие для всех сайтов.
Также диспетчиризация урлов будет отличаться на разных сайтах. Поэтому выносим urlManager в отдельный конфиг.
Ну а все остальные, общие для всех сайтов настройки, хранятся, как обычно, в файле main.php, который мержится с текущим через CMap::mergeArray

SiteDispatcher

Создадим все контроллеры и отображения, которые будут использоваться нашими сайтами.
Создаем следующие файлы и требуемые для них папки:
protected/sites/common/controllers/SiteController.php
protected/sites/common/controllers/SupportController.php
protected/sites/mywebsite-ru/controllers/PromoController.php
protected/sites/mywebsite-com/controllers/PromoController.php
public/themes/mywebsite/views/site/index.php
public/themes/mywebsite/views/mywebsite-ru/promo/index.php
public/themes/mywebsite/views/mywebsite-ru/support/index.php
public/themes/mywebsite/views/mywebsite-com/promo/index.php
public/themes/mywebsite/views/mywebsite-com/support/index.php

Каждый созданный контроллер должен быть унаследован от класса AController.
Таким образом мы немного нарушим стандартную структуру YII, но в этом нет ничего страшного. Логичность структуры проекта мы сохранили.

Чтобы научить yii по созданным нами конфигам запускать нужные контроллеры, создадим отдельный класс-компонент protected/сomponents/SiteDispatcher, задача которого в том, чтобы выбрать нужный конфиг-файл под конкретную ситуацию:

<?php

class SiteDispatcher
{
    // сохраняем найденный конфиг в сессию
    public static function setCurrentSiteConfig( $configName )
    {
        @session_start();
        $_SESSION['CURRENT_SITE_CONFIG'] = array(
            'host' => $_SERVER['HTTP_HOST'],
            'configName' => $configName
        );
        @session_write_close();
        @session_destroy();
    }

    // выбираем ранее заданный конфиг из сессии
    public static function getCurrentSiteConfig()
    {
        @session_start();
        $res = isset( $_SESSION['CURRENT_SITE_CONFIG'] )
            ? $_SESSION['CURRENT_SITE_CONFIG']
            : false;
        @session_write_close();
        @session_destroy();
        return $res;
    }

    public static function getConfigPath()
    {
        $arSites = self::getAvailableConfigs();

        /*
        если в сессии есть выбранный сайт, то смотрим на совпадение хостов, если они совпали, 
        то проверяем значение-имякконфига на валидность и возращем его или идём по правилам
        */
        if ( ($arCurrent = self::getCurrentSiteConfig())
            && $arCurrent['host'] == $_SERVER['HTTP_HOST']
            && isset($arSites[$arCurrent['configName']]) )
        {
            return 'protected/config/' . $arCurrent['configName'] . '.php';
        }

        foreach ( $arSites as $configName => $arSiteConfig )
        {
            $res = true;
            $res &= in_array( $_SERVER['HTTP_HOST'], $arSiteConfig['host'] );
            if ( $res && $arSiteConfig['userAgent'] && isset( $_SERVER['HTTP_USER_AGENT'] ) )
            {
                $m = false;
                $res &= preg_match( $arSiteConfig['userAgent'], $_SERVER['HTTP_USER_AGENT'], $m);
            }
            if ( $res )
            {
                return 'protected/config/' . $configName . '.php';
            }
        }

        error_log('Can't determine config to site: ' . var_export( array(
            'host' => $_SERVER['HTTP_HOST'],
            'userAgent' => $_SERVER['HTTP_HOST'],
        ), 1));
        throw new Exception('Can't determine config to site');
    }


    /**
     *
     * @static
     * @return mixed
     */
    protected static function getAvailableConfigs()
    {
        return require( dirname(dirname(__FILE__)) . '/config/sites.php' );
    }

}

Теперь мы умеем определять, какой конфиг в каком случае запускать.
Метод getConfigPath() возвращает путь к выбранному конфигу. При этом, выбранный конфиг сохраняется в сессию для удобства дальнейшего доступа.

Чтобы вызвать данный метод, в начале файла index.php заменим строку

$config=dirname(__FILE__).'/../protected/config/main.php';

на

require dirname(__FILE__).'/../protected/components/SiteDispatcher.php';
$config=dirname(__FILE__).'/../'.SiteDispatcher::getConfigPath();

Таким образом, разделением конфигов, некоторых контроллеров и отображений для разных сайтов мы решили первые две задачи, поставленных в первой части.

Разделение скинов

Использование собственных скинов для разных сайтов решается указанием локали в конфиге сайта.
Как правило, скин – это отдельные css- и js- файлы темы и изображения.
В своем проекте мы используем некоторые глобальные файлы css и js, которые определяют структурную верстку темы, общую для всех сайтов.
А также отдельные локальные css и js, которые определяют дизайн этой структуры.
Все эти стили и скрипты собираются в итоге один файл нашим собственным минификатором и подключаются в лэйауте следующим образом:

<link rel="stylesheet" type="text/css" href="<?php echo Yii::app()->theme->baseUrl . '/css/'. Yii::app()->getLanguage() .'/main.minified.css'; ?>" />
<script type="text/javascript" src="<?php echo Yii::app()->theme->baseUrl . '/js/' . Yii::app()->getLanguage() . '/main_minified.js'; ?>"></script>

Так как Yii::app()->getLanguage() возвращает локаль, определенную в отдельном конфиг файле конкретного сайта, то, создав одноименные папки в структуре темы, мы можем сохранять туда наши css и js файлы.
Осталось только доработать минификатор, который собирает все файлы в один, чтобы он поддерживал работу с локалью.
Но это тема для отдельной статьи.

Отдельно хочется отметить вопрос о размещении счетчиков на наших сайтах.
Существует множество разных скриптов, google analytics, yandex metrika, addthis и другие. Для разных сайтов разные используются разные счетчики.
Чтобы избавиться от костылей со свитчем Yii::app()->getLanguage() во вьюхах для корректного отображения нужного счетчика на определенном сайте, мы решили вынести счетчики в файлы локализаций.

Создадим файлы:
protected/messages/ru/scripts.php
protected/messages/en/scripts.php

Добавим в них такой код:

<?php return array (

  'GoogleAnalitics' => '
<!-- GoogleAnalitics begin -->
<script type="text/javascript">
// ваш код здесь RU
</script>
<!-- GoogleAnalitics end -->
',
); 

Теперь в файлах отображений достаточно вызвать echo Yii::t('scripts', 'GoogleAnalitics'), чтобы отобразить нужный код для нужного сайта.

Заключение

Будем рады услышать комментарии к нашему методу разделения сайтов, а также другие способы, которыми пользуетесь вы сами.
Спасибо за внимание!

Автор: Bolotnikov

Источник

Поделиться

* - обязательные к заполнению поля