REST клієнт і сервер на Yii

  

Введення

Всі, хто використовує 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 http://api.domain.ru/api/users

 
в
 
 
POST http://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 .
 
Сподіваюся стаття буде корисна.
  
Джерело: Хабрахабр
  • avatar
  • 0

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.