Pull to refresh

REST клиент и сервер на Yii

Reading time 12 min
Views 35K

Введение


Все, кто использует Yii framework в разработке знают, что в качестве доступа к базам данных чаще всего в нем используется встроенный ORM компонент ActiveRecord. Однако в один прекрасный момент я столкнулся с тем, что необходимо было работать с данными, физически находящимися на нескольких удаленных серверах. Это была разработка системы централизованного управления FTP и Radius пользователями в распределенной сети компании, где я работаю, объединяющей филиалы с центральным офисом.

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

Часть исходников пришлось вставить в тело статьи, потому получилось достаточно объемно.

Приступаем


Итак, решение принято. На первый взгляд получается странная связка. Обычно REST API используется мобильными приложениями, а не сайтами. Выходит что пользователь делает HTTP запрос к моей странице управления аккаунтами, а web сервер, обслуживающий страницу, делает другой HTTP запрос дальше, на сервер где непосредственно расположены аккаунты. А также реализован REST API для управления ими.

Серверная часть

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

Как делать серверную часть самостоятельно очень подробно расписано в официальном wiki проекта. Именно это решение было взято за основу.

Главная магия REST-ификации Yii приложения происходит в настройках urlManager в protected/config/main.php:

'urlManager' => array(
    'urlFormat' => 'path',
    'showScriptName' => false,
    'rules' => array(
        // REST patterns
        array('api/list', 'pattern' => 'api/<model:\w+>', 'verb' => 'GET'),
        array('api/view', 'pattern' => 'api/<model:\w+>/<id:\d+>', 'verb' => 'GET'),
        array('api/update', 'pattern' => 'api/<model:\w+>/<id:\d+>', 'verb' => 'PUT'),
        array('api/delete', 'pattern' => 'api/<model:\w+>/<id:\d+>', 'verb' => 'DELETE'),
        array('api/create', 'pattern' => 'api/<model:\w+>', 'verb' => 'POST'),
        // Other rules
       	'<controller:\w+>/<id:\d+>'=>'<controller>/view',
        '<controller:\w+>/<action:\w+>/<id:\d+>'=>'<controller>/<action>',
        '<controller:\w+>/<action:\w+>'=>'<controller>/<action>',
    ),
),

Именно эти правила транслируют запрос вида:

POST api.domain.ru/api/users

в

POST api.domain.ru/api/create?model=users

Что в этом примере не понравилось, так это подход, когда в action-ах модель загружается в switch блоке. Это подразумевает, в случае добавления новой модели в проект, модификацию контроллера, мне хотелось сделать более универсальное решение. В итоге для создания модели в action-ах использовалась конструкция вида:

  if (isset($_GET['model']))
      $_model = CActiveRecord::model(ucfirst($_GET['model']));

Далее привожу полный листинг контроллера, который получился в моем случае (я намеренно убрал реализацию вспомогательных методов класса, которые взяты из примера по ссылке выше без изменений, к тому же в конце главы приведена ссылка на полные исходники Yii приложения):

<?php

class ApiController extends Controller
{
    Const APPLICATION_ID = 'ASCCPE';

    private $format = 'json';

    public function filters()
    {
        return array();
    }

    public function actionList()
    {
        if (isset($_GET['model']))
            $_model = CActiveRecord::model(ucfirst($_GET['model']));

        if (isset($_model))
        {
            $_data = $_model->summary($_GET)->findAll();

            if (empty($_data))
                $this->_sendResponse(200, sprintf('No items were found for model <b>%s</b>', $_GET['model']));
            else
            {
                $_rows = array();

                foreach ($_data as $_d)
                    $_rows[] = $_d->attributes;

                $this->_sendResponse(200, CJSON::encode($_rows));
            }
        }
        else
        {
            $this->_sendResponse(501, sprintf(
                'Error: Mode <b>list</b> is not implemented for model <b>%s</b>',
                $_GET['model']));
            Yii::app()->end();
        }
    }

    public function actionView()
    {
        if (isset($_GET['model']))
            $_model = CActiveRecord::model(ucfirst($_GET['model']));

        if (isset($_model))
        {
            $_data = $_model->findByPk($_GET['id']);

            if (empty($_data))
                $this->_sendResponse(200, sprintf('No items were found for model <b>%s</b>', $_GET['model']));
            else
                $this->_sendResponse(200, CJSON::encode($_data));
        }
        else
        {
            $this->_sendResponse(501, sprintf(
                'Error: Mode <b>list</b> is not implemented for model <b>%s</b>',
                $_GET['model']));
            Yii::app()->end();
        }
    }

    public function actionCreate()
    {
        $post = Yii::app()->request->rawBody;

        if (isset($_GET['model']))
        {
            $_modelName = ucfirst($_GET['model']);
            $_model = new $_modelName;
        }

        if (isset($_model))
        {
            if (!empty($post))
            {
                $_data = CJSON::decode($post, true);

                foreach($_data as $var => $value)
                    $_model->$var = $value;

                if($_model->save())
                    $this->_sendResponse(200, CJSON::encode($_model));
                else
                {
                    // Errors occurred
                    $msg = "<h1>Error</h1>";
                    $msg .= sprintf("Couldn't create model <b>%s</b>", $_GET['model']);
                    $msg .= "<ul>";
                    foreach($_model->errors as $attribute => $attr_errors)
                    {
                        $msg .= "<li>Attribute: $attribute</li>";
                        $msg .= "<ul>";
                        foreach($attr_errors as $attr_error)
                            $msg .= "<li>$attr_error</li>";
                        $msg .= "</ul>";
                    }
                    $msg .= "</ul>";
                    $this->_sendResponse(500, $msg);
                }
            }
        }
        else
        {
            $this->_sendResponse(501, sprintf(
                'Error: Mode <b>create</b> is not implemented for model <b>%s</b>',
                $_GET['model']));
            Yii::app()->end();
        }
    }

    public function actionUpdate()
    {
        $post = Yii::app()->request->rawBody;

        if (isset($_GET['model']))
        {
            $_model = CActiveRecord::model(ucfirst($_GET['model']))->findByPk($_GET['id']);
            $_model->scenario = 'update';
        }

        if (isset($_model))
        {
            if (!empty($post))
            {
                $_data = CJSON::decode($post, true);

                foreach($_data as $var => $value)
                    $_model->$var = $value;

                if($_model->save())
                {
                    Yii::log('API update -> '.$post, 'info');
                    $this->_sendResponse(200, CJSON::encode($_model));
                }
                else
                {
                    // Errors occurred
                    $msg = "<h1>Error</h1>";
                    $msg .= sprintf("Couldn't update model <b>%s</b>", $_GET['model']);
                    $msg .= "<ul>";
                    foreach($_model->errors as $attribute => $attr_errors)
                    {
                        $msg .= "<li>Attribute: $attribute</li>";
                        $msg .= "<ul>";
                        foreach($attr_errors as $attr_error)
                            $msg .= "<li>$attr_error</li>";
                        $msg .= "</ul>";
                    }
                    $msg .= "</ul>";

                    $this->_sendResponse(500, $msg);
                }
            }
            else
                Yii::log('POST data is empty');
        }
        else
        {
            $this->_sendResponse(501, sprintf(
                'Error: Mode <b>update</b> is not implemented for model <b>%s</b>',
                $_GET['model']));
            Yii::app()->end();
        }
    }

    public function actionDelete()
    {
        if (isset($_GET['model']))
            $_model = CActiveRecord::model(ucfirst($_GET['model']));

        if (isset($_model))
        {
            $_data = $_model->findByPk($_GET['id']);

            if (!empty($_data))
            {
                $num = $_data->delete();

                if($num > 0)
                    $this->_sendResponse(200, $num);    //this is the only way to work with backbone
                else
                    $this->_sendResponse(500, sprintf("Error: Couldn't delete model <b>%s</b> with ID <b>%s</b>.", $_GET['model'], $_GET['id']) );
            }
            else
                $this->_sendResponse(400, sprintf("Error: Didn't find any model <b>%s</b> with ID <b>%s</b>.", $_GET['model'], $_GET['id']));
        }
        else
        {
            $this->_sendResponse(501, sprintf('Error: Mode <b>delete</b> is not implemented for model <b>%s</b>', ucfirst($_GET['model'])));
            Yii::app()->end();
        }
    }

    private function _sendResponse($status = 200, $body = '', $content_type = 'text/html')
    {
        ...
    }

    private function _getStatusCodeMessage($status)
    {
        ...
    }

    private function _checkAuth()
    {
        ...
    }
}

При таком подходе необходима соответствующая подготовка модели. Например встроенная в ActiveRecord переменная-массив attributes формируется исключительно исходя из структуры таблицы в базе данных. Если есть необходимость включать в выборку поля из связанных таблиц или вычислимые поля — необходимо в модели перегрузить методы getAttributes и, при необходимости, hasAttribute. В качестве примера моя реализация getAttributes:

    public function getAttributes($names = true)
    {   
        $_attrs = parent::getAttributes($names);

        $_attrs['quota_limit'] = $this->limit['bytes_in_avail'];
        $_attrs['quota_used'] = $this->tally['bytes_in_used'];

        return $_attrs;
    }

Также необходимо создать named scope summary в модели для правильной работы постраничного вывода и сортировки.:

    public function summary($_getvars = null)
    {
        $_criteria = new CDbCriteria();

        if (isset($_getvars['count']))
        {
            $_criteria->limit = $_getvars['count'];
            if (isset($_getvars['page']))
                $_criteria->offset = ($_getvars['page']) * $_getvars['count'];
        }

        if (isset($_getvars['sort']))
            $_criteria->order = str_replace('.', ' ', $_getvars['sort']);

        $this->getDbCriteria()->mergeWith($_criteria);

        return $this;
    }

Полный текст модели:

<?php

/**
 * This is the model class for table "ftpuser".
 *
 * The followings are the available columns in table 'ftpuser':
 * @property string $id
 * @property string $userid
 * @property string $passwd
 * @property integer $uid
 * @property integer $gid
 * @property string $homedir
 * @property string $shell
 * @property integer $count
 * @property string $accessed
 * @property string $modified
 */
class Ftpuser extends CActiveRecord
{
    // Additional quota parameters
    public $quota_limit;
    public $quota_used;

	/**
	 * Returns the static model of the specified AR class.
	 * @return ftpuser the static model class
	 */
	public static function model($className=__CLASS__)
	{
		return parent::model($className);
	}

	/**
	 * @return string the associated database table name
	 */
	public function tableName()
	{
		return 'ftpuser';
	}

	/**
	 * @return array validation rules for model attributes.
	 */
	public function rules()
	{
		// NOTE: you should only define rules for those attributes that
		// will receive user inputs.
		return array(
			array('uid, gid, count', 'numerical', 'integerOnly' => true),
            array('userid, passwd, homedir', 'required'),
			array('userid, passwd', 'length', 'max' => 32),
			array('homedir', 'length', 'max' => 255),
			array('shell', 'length', 'max' => 16),
			array('accessed, modified, quota_limit, quota_used', 'safe'),
            //array('userid', 'unique'),
			// The following rule is used by search().
			// Please remove those attributes that should not be searched.
			array('id, userid, passwd, uid, gid, homedir, shell, count, accessed, modified', 'safe', 'on' => 'search'),
		);
	}

	/**
	 * @return array relational rules.
	 */
	public function relations()
	{
		// NOTE: you may need to adjust the relation name and the related
		// class name for the relations automatically generated below.
		return array(
            'limit' => array(self::HAS_ONE, 'FTPQuotaLimits', 'user_id'),
            'tally' => array(self::HAS_ONE, 'FTPQuotaTallies', 'user_id'),
		);
	}

	/**
	 * @return array customized attribute labels (name=>label)
	 */
	public function attributeLabels()
	{
		return array(
			'id' => 'Id',
			'userid' => 'Userid',
			'passwd' => 'Passwd',
			'uid' => 'Uid',
			'gid' => 'Gid',
			'homedir' => 'Homedir',
			'shell' => 'Shell',
			'count' => 'Count',
			'accessed' => 'Accessed',
			'modified' => 'Modified',
		);
	}

	/**
	 * Retrieves a list of models based on the current search/filter conditions.
	 * @return CActiveDataProvider the data provider that can return the models based on the search/filter conditions.
	 */
	public function search()
	{
		// Warning: Please modify the following code to remove attributes that
		// should not be searched.

		$criteria = new CDbCriteria;

		$criteria->compare('userid', $this->userid, true);
		$criteria->compare('homedir', $this->homedir, true);

		return new CActiveDataProvider('ftpuser', array(
			'criteria' => $criteria,
		));
	}

    public function summary($_getvars = null)
    {
        $_criteria = new CDbCriteria();

        if (isset($_getvars['count']))
        {
            $_criteria->limit = $_getvars['count'];
            if (isset($_getvars['page']))
                $_criteria->offset = ($_getvars['page']) * $_getvars['count'];
        }

        if (isset($_getvars['sort']))
            $_criteria->order = str_replace('.', ' ', $_getvars['sort']);

        $this->getDbCriteria()->mergeWith($_criteria);

        return $this;
    }

    public function getAttributes($names = true)
    {
        $_attrs = parent::getAttributes($names);

        $_attrs['quota_limit'] = $this->limit['bytes_in_avail'];
        $_attrs['quota_used'] = $this->tally['bytes_in_used'];

        return $_attrs;
    }

    protected function afterFind()
    {
        parent::afterFind();

        $this->quota_limit = $this->limit['bytes_in_avail'];
        $this->quota_used = $this->tally['bytes_in_used'];
    }

    protected function afterSave()
    {
        parent::afterSave();

        if ($this->isNewRecord && !empty($this->quota_limit))
        {
            $_quota = new FTPQuotaLimits();

            $_quota->user_id = $this->id;
            $_quota->name = $this->userid;
            $_quota->bytes_in_avail = $this->quota_limit;

            $_quota->save();
        }
    }

    protected function beforeValidate()
    {
        if ($this->isNewRecord)
        {
            if (empty($this->passwd))
                $this->passwd = $this->genPassword();

            $this->homedir = Yii::app()->params['baseFTPDir'].$this->userid;
        }
        elseif ($this->scenario == 'update')
        {
            if (empty($this->quota_limit))
            {
                FTPQuotaLimits::model()->deleteAllByAttributes(array('name' => $this->userid));
                FTPQuotaTallies::model()->deleteAllByAttributes(array('name' => $this->userid));
            }
            else
            {
                $_quota_limit = FTPQuotaLimits::model()->find('name = :name', array(':name' => $this->userid));

                if (isset($_quota_limit))
                {
                    $_quota_limit->bytes_in_avail = $this->quota_limit;
                    $_quota_limit->save();
                }
                else
                {
                    $_quota_limit = new FTPQuotaLimits();

                    $_quota_limit->name = $this->userid;
                    $_quota_limit->user_id = $this->id;
                    $_quota_limit->bytes_in_avail = $this->quota_limit;

                    $_quota_limit->save();
                }
            }
        }

        return parent::beforeValidate();
    }

    protected function beforeDelete()
    {
        FTPQuotaLimits::model()->deleteAllByAttributes(array('name' => $this->userid));
        FTPQuotaTallies::model()->deleteAllByAttributes(array('name' => $this->userid));

        return parent::beforeDelete();
    }

    private function genPassword($len = 6)
    {
        $chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
        $count = mb_strlen($chars);

        for ($i = 0, $result = ''; $i < $len; $i++)
        {
            $index = rand(0, $count - 1);
            $result .= mb_substr($chars, $index, 1);
        }

        return $result;
    }
}

Чего не хватает для полного счастья — нет возможности сделать обработку вложенных запросов вида /users/156/records, но на то это framework, а не CMS, если надо — допили сам. Мой случай простой, такое не потребовалось.

С серверной частью покончили, переходим к клиентской. Для заинтересовавшихся выкладываю полные исходники Yii приложения-серверной части здесь. Не знаю сколько проживет ссылка, если есть более дельные предложения куда выложить надежней — прошу отметиться в комментариях.

Клиентская часть

Чтобы не писать свой велосипед была проведена небольшая поисковая работа и найдено отличное расширение ActiveResource. Как пишет автор, источником вдохновения послужила реализация ActiveResource в Ruby on Rails. На странице есть краткое описание как установить и как пользоваться.

Однако практически сразу я наткнулся на то, что это просто компонент, совместимый интерфейсом с ActiveRecord, но для использования в виджетах Yii GridView или ListView необходим компонент, совместимый с ActiveDataProvider. Беглый поиск вывел меня на вынесенные в отдельную ветку доработки, включающие EActiveResourceDataProvider и EActiveResourceQueryCriteria, а также обсуждение их в ветке форума где участвовал сам автор расширения. Там же были опубликованы исправленные версии ESort и EActiveResourceDataProvider.

Несмотря на все изящество решения без напильника не обошлось. Проблема была в неправильной работе компонента pagination в grid-е. Беглое изучение исходников показало, что в качестве offset в расширении использовалось реальное смещение, выраженное в количестве записей, тогда как pagination в GridView использует номер страницы. Получалось, что при настройке 10 записей на страницу при переходе на страницу 2 нас перебрасывало на страницу 20. Залезаем в код и правим. Для этого в файле protected/extensions/EActiveResource/EActiveResourceQueryCriteria.php в теле метода buildQueryString делаем такую правку:

if($this->offset>0)
    // array_push($parameters, $this->offsetKey.'='.$this->offset);
    array_push($parameters, $this->offsetKey.'='.$this->offset / $this->limit);

После чего необходимо убрать перегрузку метода getOffset из EActiveResourcePagination как более ненужную.

Итак при создании приложения, использующего REST источник данных необходимо вручную создавать необходимые модели, остальное будет создаваться через GII без проблем.

Отдельно хочется отметить работу с несколькими серверами. Изначально подключение к удаленному REST API описывается в конфиге, таким образом по умолчанию мы можем использовать на своем сайте только одно подключение. Для того, чтобы информация о подключениях хранилась в таблице базы данных и использовалась компонентом ActiveResource прямо оттуда пришлось создать потомка с перегруженным методом getConnection (это мой случай с FTP пользователями, данные серверов хранятся в таблице, описанной моделью FTPServers):

abstract class EActiveResourceSelect extends EActiveResource
{
    /**
     * Returns the EactiveResourceConnection used to talk to the service.
     * @return EActiveResourceConnection The connection component as pecified in the config
     */
    public function getConnection()
    {
        $_server_id = Yii::app()->session['ftp_server_id'];
        $_db_params = array();

        if (isset($_server_id))
        {
            $_srv = FTPServers::model()->findByPk($_server_id);

            if (isset($_srv))
                $_db_params = $_srv->attributes;
            else
                Yii::log('info', "No FTP server with ID: $_server_id were found");
        }
        else
        {
            $_srv = FTPServers::model()->find();

            if (isset($_srv))
            {
                $_db_params = $_srv->attributes;
                Yii::app()->session['ftp_server_id'] = $_srv->id;
            }
            else
                Yii::log("No FTP servers were found", CLogger::LEVEL_INFO);
        }

        self::$_connection = new EActiveResourceConnection();
        self::$_connection->init();
        self::$_connection->site = $_db_params['site'];
        self::$_connection->acceptType = $_db_params['acceptType'];
        self::$_connection->contentType = $_db_params['contentType'];

        if (isset($_db_params['username']) && isset($_db_params['password']))
        {
            self::$_connection->auth = array(
                'type' => 'basic',
                'username' => $_db_params['username'],
                'password' => $_db_params['password'],
            );
        }

        return self::$_connection;
    }
}

Дальнейшая разработка клиентской части ничем особо не отличалась от разработки с использованием привычного ActiveRecord в чем, на мой взгляд, главная прелесть расширения ActiveResource.

Надеюсь статья будет полезна.
Tags:
Hubs:
+3
Comments 8
Comments Comments 8

Articles