Я часто пишу PHP AJAX коннекторы для простых задач на разных сайтах или в составе сложного API среднего проекта. Со временем у меня выработалась система с типовыми решениями.

Серверные коннекторы я часто пишу на PHP, редко на Node.js. Использую по сути 2 протокола, которые вполне схожи, это: RESTful и JSON-RPC.

Основная разница между ними в типах запросов:

RESTful

JSON-RPC

Ввиду своей простоты JSON-RPC использую чаще, хотя честно говоря, ещё чаще получается гибридный подход, простой и понятный:

GET / POST

AJAX коннекторы / API с таким типом запросов пишет я думаю подавляющее большинство.

Типовые задачи


Типовые задачи, которые выполняются в коннекторах почти всегда:

  1. Принять данные входящего запроса, санитизировать их (вычистить попыти инъекций, взлома)
  2. Подключиться к базе, сделать выборку данных, что-то обновить, изменить
  3. Если сервис подразумевает наличие профилей, авторизации — авторизовать пользователя
  4. Если сервис подразумевает наличие персональных настроек по профилям, то вытащить набор настроек для данного профиля/контекста
  5. Сформировать ответ успешно/не успешно, возможно отдать порцию данных
  6. Отформатировать и вывести в ответ
  7. Неплохо бы засекать время, если на один входящий запрос выполняется пакет действий, ставить ограничения по времени

И как я уже сказал, на эти случаи у меня есть наработанные решения, которые я держу отдельными проектами в GitHub и периодически дописываю, стараясь придерживаться сложившейся структуры, в общем, ссылки на проекты с соответствующими решениями в виде PHP классов (инкапсулируя всю логику):

Номера пунктов — соответственно:

  1. Принять данные входящего запроса, санитизировать их: https://github.com/ershov-ilya/restful.class.php
  2. Работа с Базой, решение типовых задач: https://github.com/ershov-ilya/database.class.php
  3. Авторизация: Это пишем сами, либо ищем готовые решения
  4. Настройки профилей/контекстов: https://github.com/ershov-ilya/config.class.php
  5. Успешно/не успешно: решает логика Вашего скрипта, все данные для ответа кидаем в один массив
  6. Отформатировать в требуемом виде (чаще всего JSON): https://github.com/ershov-ilya/format.class.php
  7. Засекать время: https://github.com/ershov-ilya/timer.class.php

Если ваш проект под системой контроля версий Git, то вы легко можете добавить любой из этих проектов к себе, как подмодуль:

В корне своего проекта пишите:

git submodule add https://github.com/ershov-ilya/restful.class.php.git папка/от/корня/проекта/куда/класть

Обновить потом все подмодули проекта, в корне проекта пишем (в консоли):

git submodule foreach git pull

Подробнее об остальных действиях команды submodule:

git submodule --help

или в гугле.
Если вы не умеете пользоваться git'ом, нажимайте кнопку «Download ZIP», скачивайте архив и распаковывайте к себе в проект.

Теперь детально


Образец кода готового решения на базе этих классов


<?php
header('Content-Type: text/plain; charset=utf-8');
require_once('../core/config/api.config.private.php');
session_start();

if(DEBUG){
    error_reporting(E_ERROR | E_WARNING);
    ini_set("display_errors", 1);
}

$response=array(
    'response' => '204 No Content'
);
$format='json';

try {
    // API includes
    require_once(API_CORE_PATH.'/class/restful/restful.class.php');
    require_once(API_CORE_PATH.'/class/auth/auth.class.php');
    require_once(API_CORE_PATH.'/class/permissions/permissions.class.php');
    require_once(API_CORE_PATH.'/class/database/database.class.php');
    require_once(API_CORE_PATH.'/class/config/config.class.php');
    require_once(API_CORE_PATH.'/modules/smsc/send.func.php');
    require_once(API_CORE_PATH.'/config/smsc.config.private.php');

    // restful
    $rest=new RESTful('lead', array('id', 'name', 'email', 'phone', 'card_id', 'site', 'sum', 'message', 'utm_source', 'utm_medium', 'utm_campaign', 'utm_content'), array(
        // Опции санитизации (чистки) данных входящего запроса
        // Варианты: ключ для функции filter_var, регулярка или Closure функция (можно объект с методом __invoke)
        'id'    => FILTER_SANITIZE_NUMBER_INT,
        'sum'    => FILTER_SANITIZE_NUMBER_FLOAT,
        'utm_source'  => '/[^0-9a-zA-Z_\-]/',
        'utm_medium'  => '/[^0-9a-zA-Z_\-]/',
        'utm_campaign'  => '/[^0-9a-zA-Z_\-]/',
        'utm_content'  => '/[^0-9a-zA-Z_\-]/',
        'card_id' =>  function($val=null){
            if(!empty($val) && $val>10000) return $val;
            return '';
        }
    ));

    // Модуль авторизации - здесь используется класс из конкретного проекта
    if(empty($rest->scope['scope'])) throw new Exception('403 Auth needed');
    $auth=new Auth($rest->scope); // Получение из БД настроек пространства

    // Модуль прав доступа - здесь используется класс из конкретного проекта
    $permissions=new Permissions($rest->scope, $auth);
    $permissions->CORS();

    // API определения города
    $ip=$rest->scope['ip'];
    $memcache = new Memcache;
    $memcache->addServer("127.0.0.1");
    $city=$memcache->get($ip);
    if(empty($city)) {
        $link='http://api.sypexgeo.net/json/';
        $curl=curl_init();
        curl_setopt($curl,CURLOPT_RETURNTRANSFER,true);
        curl_setopt($curl,CURLOPT_URL,$link.$ip);
        curl_setopt($curl,CURLOPT_HEADER,false);
        $out=curl_exec($curl); // Инициируем запрос к API и сохраняем ответ в переменную
        $code=curl_getinfo($curl,CURLINFO_HTTP_CODE);
        curl_close($curl); // Завершаем сеанс cURL
        $res_arr=json_decode($out);
        $city=$res_arr->city->name_ru.', '.$res_arr->country->name_ru;
        $memcache->set($ip, $city, false, 2592000);
    }


    if(DEBUG){
        // При включённой константе DEBUG
        // Здесь можно посмотреть 2 массива объекта класса RESTful
        // $rest->scope - данные для авторизации
        // $rest->data - данные запроса
        print "Scope:\n";
        print_r($rest->scope);
        print "Data:\n";
        print_r($rest->data);
    }

    // В принципе можно выполнить
    // extract($rest->data);

    // Образец массива с настройками подключения есть в проекте database.class.php
    $pdoconfig = array(
        'dbhost'    =>  'localhost',
        'dbuser'    =>  'username',
        'dbpass'    =>  'password',
        'dbname'    =>  'database',
        'dbtype'    =>  'mysql'
    );
    // Модуль подключения к БД
    $db = new Database($pdoconfig);
    if(!$db) throw new Exception('500 DB connect error');

    // Объект класса Config
    $config=new Config($db);

    if ($rest->scope['METHOD'] == 'POST') {
        // Данные для записи в БД
        $data=$rest->data;
        $data['city']=$city;

        // Проверка прав
        if(!$permissions->ask('append')) throw new Exception('500 Not allowed');

        // Составление переменного набора полей
        $fields = array();
        $placeholders = array();
        foreach ($data as $key => $val) {
            $fields[] = $key;
            $placeholders[] = ':' . $key;
        }
        $fields = implode(', ', $fields);
        $placeholders = implode(', ', $placeholders);

        // Подготовка Запроса
        $sql = "INSERT INTO leads ($fields) VALUES ($placeholders)";

        $stmt = $db->dbh->prepare($sql);
        if(!$stmt) throw new Exception('500 DB statement error');
        foreach ($data as $key => $val) {
            $stmt->bindParam(':' . $key, $data[$key]);
        }

        //  Запись в БД
        $res = $stmt->execute();
        $lastInsertId=$db->dbh->lastInsertId('id');

        if($res){
            $response=array(
                'response' => '200 Done'
            );
        }
        if(DEBUG) {
            $response['Запись строки в scopes'] = $res;
        }

        $send_lead_notify_sms=$config('send_lead_notify_sms', 0, true);
        if(!empty($send_lead_notify_sms)) {
            // Отправка СМС
            $sms = array(
                'mes' => 'Новая заявка',
                'phones' => $config('report_sms_phone', '79008887766', true),
                'sender' => 'MY SERVICE'
            );
            if (!empty($rest->data['name'])) $sms['mes'] .= ':' . PHP_EOL . $rest->data['name'];
            if (!empty($rest->data['phone'])) $sms['mes'] .= PHP_EOL . $rest->data['phone'];
            if (!empty($rest->data['sum'])) $sms['mes'] .= PHP_EOL . 'Сумма: ' . $rest->data['sum'];
            if (!empty($rest->data['site'])) $sms['mes'] .= PHP_EOL . 'Сайт: ' . $rest->data['site'];
            if (!empty($rest->data['campaign'])) $sms['mes'] .= PHP_EOL . '"' . $rest->data['campaign'] . '"';
            if (!empty($rest->data['utm_source'])) $sms['mes'] .= PHP_EOL . 'Источник: ' . $rest->data['utm_source'];
            if (!empty($rest->data['message'])) $sms['mes'] .= PHP_EOL . 'Сообщение: ' . $rest->data['message'];
            $res = send_sms($sms, $smsc_config);
            $response['sms'] = $res['response']['cnt'];
        }
    }

} catch(Exception $e) {
    // При ошибке
    $response['response']=$e->getMessage();
}

// Форматирование и вывод ответа
require_once(API_CORE_PATH . '/class/format/format.class.php');
print Format::parse($response, $format);
?>

Санитизация входящего запроса restful.class.php


Инициализация:

<?php
$rest=new RESTful('promocode', 'id,code,order', array(
    // Sanitize options
    'id'    => FILTER_SANITIZE_NUMBER_INT,
    'code'  => '/[^0-9a-zA-Z]/',
    'order' =>  function($val=null){
        if(!empty($val)) return $val;
        return null;
    }
));
?>

Конструктор класса принимает входящий запрос, санитизирует его (чистит от инъекций) и разбивает на 2 массива: $rest->scope (информация о запросе: IP источника, user-agent и информация для авторизации) и $rest->data (сами параметры запроса).

Первый параметр — это по сути имя коннектора / действия, будет доступно в $rest->scope['ACTION'], может быть полезен для определения прав и разрешений.

Список полей, которые нужно принять в массив $rest->data — строка через запятую, либо массив.

Третий необязательный параметр — массив с определениями методов санитизации в формате:. Default is FILTER_SANITIZE_STRING for the filter_var().

«имя поля» => метод санитизации (Флаг для filter_var, строка с регуляркой, либо Closure функция или объект с методом __invoke($raw_value)).

Четвёртый необязательный параметр: Список полей которые нужно принять в массив $rest->scope.

print_r($rest->scope); // Смотрим массив информации о запросе
print_r($rest->data); // Смотрим параметры запроса
print $rest('code'); // Смотрим значение конкретного входящего параметра (будет доступен, только если указан в списке полей во втором параметре конструктора)

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

database.class.php


Класс Database — обёртка для PDO, с реализацией типовых задач:

  • Вытащить / добавить строку в таблицу
  • Сделать выборку нескольких строк, по условию
  • Добавить несколько строк (загрузить массив данных в БД)

Инициализация:

$db = new Database($pdoconfig);

В качестве параметра конструктор принимает массив вида:

    $pdoconfig = array(
        'dbhost'    =>  'localhost',
        'dbuser'    =>  'username',
        'dbpass'    =>  'password',
        'dbname'    =>  'database',
        'dbtype'    =>  'mysql'
    );

или путь к файлу содержащему такой массив (с именем $pdoconfig).

Имеет методы:

$db->getOneSQL($sql) // Вытащить одну (первую) строку из ответа на запрос $sql, возвращает ассоциированный массив "имя столбца" => "значение"
$db->getOneWhere($table, $where='1', $filter='')  // Вытащить одну (первую) строку из ответа на запрос $sql, возвращает ассоциированный массив "имя столбца" => "значение"
// От предыдущей отличается только тем, что можно написать простое условие поиска, например $db->getOneWhere("task", "status='new'");

$db->exec($sql) // Исполнить запрос $sql, вернуть количество затронутых строк
$db->getOne($table, $id, $id_field_name='id', $filter='') // Вытащить одну строку с айдишником равным $id, возвращает ассоциированный массив "имя столбца" => "значение"

Первый параметр "$table" — имя таблицы для выборки. Если столбец с айдишниками называется не «id», третьим параметром можно указать имя столбца по которому искать. Четвёртый параметр "$filter" — набор столбцов, который нужно вытащить. Возвращает ассоциированный PHP массив «имя столбца» => «значение».

Остальные функции работают по аналогии, смыслы параметров повторяются.

$db->get($sql) // Произвольная SQL выборка из базы
$db->getTable($table, $columns='', $from=0, $limit=-1) // Получить целую таблицу, возвращает двумерный ассоциативный запрос
$db->getTableWhere($table, $columns='', $where='1')  // Получить таблицу с условием WHERE, возвращает двумерный ассоциативный запрос
$db->getTableByKey($table, $key, $keyColumnName='scope')  // Получить таблицу, где поле $keyColumnName равно значению $key, возвращает двумерный ассоциативный запрос
$db->Count($table, $where) // Получить количество строк в таблице $table, удовлетворяющих условию $where
$db->putOne($table, $data, $flags=0) // Добавить одну строку в таблицу, принимает ассоциативный массив
$db->put($table, $fields, $data, $flags=0, $overlay=array(), $default=NULL) // Добавить много строк в таблицу, принимает двумерный ассоциативный массив
$db->updateOne($table, $id, $data, $id_field_name='id') // Обновить одну строку в таблице, принимает ассоциативный массив
$db->updateMass($table, $data, $props=array()) // Обновить много строк в таблице, принимает двумерный ассоциативный массив

Все функции класса Database сохраняют полученный ответ в массиве $this->last[$storage], где $storage совпадает с именем метода:

$db->toKeyValue($storage='getTable') // Получить ассоциативный массив "ключ" => "значение" из хранилища последних результатов, принимает параметр имя выполненного метода, например 'getTable' или 'getTableByKey'
$db->toValueArray($storage='getTable', $column=1) // Получить простой массив занчений из хранилища (результат последнего запроса), принимает параметр имя выполненного метода, например 'getTable' или 'getTableByKey'
$db->pick($columns=NULL, $map=array(), $storage='getOne') // Вытащить несколько колонок  из хранилища (результат последнего однострочного запроса)
$db->pickLine($field, $key, $map=array(), $filter=array(), $storage='getTableByKey') // Вытащить одну строку из хранилища (результат последнего многострочного запроса)
$db->transpose($storage='getTable') // Транспонирование (поворот) таблицы, также принимает в качестве параметра имя метода класса например (результат последнего многострочного запроса) 'getTable'

config.class.php


Инициализация:

$config=new Config($db); // Принимает в качестве параметра вышеупомянутый массив $pdoconfig, объект класса Database или объект класса PDO
// Либо можно указать путь к текстовому файлу вида: ключ значение
$config=new Config($db);

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

$config=array(
    'preload'       =>  true,
    'table'         =>  'config', // имя таблицы
    'keyfield'      =>  'key',    // имя столбца с именами ключей
    'valuefield'    =>  'value',  // имя столбца со значениями ключей
    'file_delimiter'     =>  ' ', // разделитель в файле, который разделяет строку на ключ и значение
    'file_strip_first_line'  =>  false
);
$config=new Config($db, $config);

format.class.php


Класс, все методы которого статичные. Умеет форматировать массивы PHP в форматы:

  • JSON
  • plain
  • строки GET/POST запроса
  • phpArray
  • SQL

Следующим образом:

print Format::parse($array, 'json');
print Format::parse($array, 'php');
print Format::parse($array, 'get');
print Format::parse($array, 'plain');

Мультитаймер timer.class.php


Пример кода: простой


require_once('timer.class.php');
$timer=new Timer();
$timer->start('mysql');
sleep(1);
print $timer('mysql');
$timer->stop('mysql');
print_r($timer->data()); // Массив с результатами
print $timer; // Вывести отчёт

Этот класс может создавать таймеры с зависимостями, счётчики будут храниться в структуре в виде дерева. Стартовать и останавливать таймеры можно сколько угодно раз, в итоге будет показана сумма отрезков времени. __invoke функция показывает текущее время по конкретному счётчику НЕ останавливая его, например: print $timer('mysql');.

Соответственно имеются методы.

$timer->start('mysql:sql:query:response:parsing', true); // Второй параметр true говорит, что нужно стартануть все таймеры-родители
$timer->stopTree('mysql'); // Остановить всё дерево mysql

Пример кода: посложнее


<?php
require_once('timer.class.php');
$timer=new Timer(array(
'debug'=>true
));
$timer->start('mysql:sql:query:response:parsing',true);
$timer->start('postgres:sql:query:response:parsing');
sleep(1);
print_r($timer->data());
print $timer;
$timer->start('file:read');
sleep(1);
$timer->stopTree('mysql');
print_r($timer->data());
?>

Разделитель родительский: дочерний можно заменить с помощью массива конфигурации параметром конструтора.

<?php
$config=array(
    'query_delimiter'   =>  ':',
    'output_delimiter'  =>  '=',
    'add_children_time' => true,
    'debug'             => false
);
$timer=new Timer($config);
?>

Комментарии (2)


  1. Borro
    07.12.2015 22:18
    +2

    У вас не хватает:

    • в каждом из проектов composer.json и публикации на Packagist
    • Оформление по стандартам PSR
    • Тестов в одном из известных форматов, например: PHPUnit, Codeception

    А вообще, мой вам совет: уберите этот пост, сделайте изменения описанные мной, а потом посмотрите в сторону микрофреймворков с поддержкой REST или компонент Symfony


    1. ershov-ilya
      08.12.2015 08:48

      Вот именно таких подсказок я и ждал от этого поста. Спасибо :)