Содержание
В далекие детские годы я не понимал важность резервного копирования данных. Но, как говорится, понимание приходит с опытом. Зачастую опыт бывает очень горький. В моем случае хостинг два раза убивал базу сайта MathInfinity, созданного еще в студенческие годы.
Большие проекты могут позволить себе выделить целые сервера для резервного копирования. Однако, существует огромное количество небольших проектов, работающих лишь на вашем энтузиазме. Эти проекты также нуждаются в резервном копировании.
Идея создания архивов на сервисах вроде Dropbox, Ubuntu One, Яндекс Диск, Диск Google и др. уже давно притягивала мое внимание. Десятки гигабайт бесплатного места, которое теоретически можно использовать для резервирования данных.
Теперь эта идея получила мое первое воплощение. В качестве сервиса для создания архивов был выбран Яндекс Диск.
На гениальность идеи я не претендую. И, конечно, изобретение велосипеда началось с поиска готовых решений в Интернете. Весь найденный код либо уже не работал, либо имел совершенно нечитаемый вид. Я же предпочитаю понимать как работают мои приложения.
Не скажу, что API сервисов Яндекса имеют отличную документацию. Однако там есть примеры и ссылки на конкретные стандарты. Этого вполне хватило.
Я давно использую фреймворк Limb. И чтобы не изобретать колес к своему велосипеду ниже будут приводиться коды классов
с использованием данного фреймворка. Все классы и функции с префиксом lmb являются стандартными классами и функциями Limb.
Регистрация приложения
Сначала необходимо зарегистрировать свое приложение. Процесс регистрации приложения очень прост. Данная процедура описана в Документации Яндекса.
От вас требуется заполнить простую форму, в которой среди всего прочего необходимо дать разрешение на использование вашего Яндекс диска приложением. В результате заполнения полей формы вам будут выданы id приложения и пароль приложения. Их необходимо использовать для получения токена. У меня данный процесс занял 3 минуты.
Авторизация в Яндексе при помощи OAuth
Для выполнения операций с диском, необходимо указывать OAuth токен. В стандарте OAuth описано несколько вариантов получения токена. Ту решено идти самым простым путем. В соответствии со стандартом OAuth п.4.3.2 токен можно получить прямым запросом к сервису с использованим логина и пароля от учетной записи Яндекса (учетная запись может быть любой).
Небольшой поиск по документации, позволил написать следующий класс:
Код класса получения токена
class YaAuth { protected $token; protected $error; protected $create_time; protected $ttl; protected $app_id; protected $conf; protected $logger; function __construct($conf,$logger) { $this->logger = $logger; $this->app_id = $conf->get('oauth_app_id'); $this->clear(); $this->conf = $conf; } function getToken() { if($this->checkToken()) return $this->token; $url = $this->conf->get('oauth_token_url'); $curl = lmbToolkit::instance()->getCurlRequest(); $curl->setOpt(CURLOPT_HEADER,0); $curl->setOpt(CURLOPT_REFERER,$this->conf->get('oauth_referer_url')); $curl->setOpt(CURLOPT_URL,$url); $curl->setOpt(CURLOPT_CONNECTTIMEOUT,1); $curl->setOpt(CURLOPT_FRESH_CONNECT,1); $curl->setOpt(CURLOPT_RETURNTRANSFER,1); $curl->setOpt(CURLOPT_FORBID_REUSE,1); $curl->setOpt(CURLOPT_TIMEOUT,4); $curl->setOpt(CURLOPT_SSL_VERIFYPEER,false); $post = 'grant_type=password&client_id='.$this->conf->get('oauth_app_id'). '&client_secret='.$this->conf->get('oauth_app_secret'). '&username='.$this->conf->get('oauth_login'). '&password='.$this->conf->get('oauth_password'); $header = array(/*'Host: oauth.yandex.ru',*/ 'Content-type: application/x-www-form-urlencoded', 'Content-Length: '.strlen($post) ); $curl->setOpt(CURLOPT_HTTPHEADER,$header); $json = $curl->open($post); if(!$json) { $this->error = $curl->getError(); $this->logger->log('','ERROR', $this->error); return false; } $http_code = $curl->getRequestStatus(); if(($http_code!='200') && ($http_code!='400')) { $this->error = "Request Status is ".$http_code; $this->logger->log('','ERROR', $this->error); return false; } $result = json_decode($json, true); if (isset($result['error']) && ($result['error'] != '')) { $this->error = $result['error']; $this->logger->log('','ERROR', $this->error); return false; } $this->token = $result['access_token']; $this->ttl = (int)$result['expires_in']; $this->create_time = (int)time(); return $this->token; } function clear() { $this->token = ''; $this->error = ''; $this->counter_id = ''; $this->create_time = 0; $this->ttl = -1; } function checkToken() { if ($this->ttl <= 0) return false; if (time()>($this->ttl+$this->create_time)) { $this->error = 'token_outdated'; $this->logger->log('','ERROR', $this->error); return false; } return true; } function getError() { return $this->error; } }
Все параметры требуемые для авторизации выносим в конфиг. В качестве конфига может выступать любой объект поддерживающий get и set методы.
Для возможности ведения лога выполняемых действий в конструктор класса передается объект для ведения лога работы. Его код можно найти в архиве с примером.
Собственно у класса два основных метода getToken и checkToken. Первый выполняет cUrl запрос на получение токена, а второй проверяет не устарел ли токен.
Операции с Яндекс.Диском
После получения токена, можно выполнять операции с Яндекс диском.
Яндекс диск позволяет выполнять много различных запросов. Для моих целей необходимы следующие операции:
- Создание папки
- Загрузка файла на Яндекс диск
- Удаление файла с Яндекс диска
- Скачивание файла с Яндекс диска
- Получение списка объектов содержащихся в папке
- Определение существования объекта на диска и его тип
Все операции выполняем с использование cUrl. Конечно, все это можно сделать с использованием сокетов, однако мне важно простота кода. Все операции с Яндекс диском соответствуют протоколу WebDav. В документации API Яндекс диска подробно расписаны примеры выполнения запросов и ответов на эти запросы. Код класса для работы с диском приведен ниже:
Код класса выполнения операций с диском
class YaDisk { protected $auth; protected $config; protected $error; protected $token; protected $logger; protected $url; function __construct($token,$config,$logger) { $this->auth = $auth; $this->config = $config; $this->token = $token; $this->logger = $logger; } function getCurl($server_dst) { $curl = lmbToolkit::instance()->getCurlRequest(); $curl->setOpt(CURLOPT_SSL_VERIFYPEER,false); $curl->setOpt(CURLOPT_PORT,$this->config->get('disk_port')); $curl->setOpt(CURLOPT_CONNECTTIMEOUT,2); $curl->setOpt(CURLOPT_RETURNTRANSFER,1); $curl->setOpt(CURLOPT_HEADER, 0); $curl->setOpt(CURLOPT_HTTP_VERSION,CURL_HTTP_VERSION_1_1); $uri = new lmbUri($this->config->get('disk_server_url')); $uri = $uri->setPath($server_dst)->toString(); $curl->setOpt(CURLOPT_URL,$uri); $header = array('Accept: */*', "Authorization: OAuth {$this->token}" ); $curl->setOpt(CURLOPT_HTTPHEADER,$header); return $curl; } function getResult($curl, $codes = array()) { if($curl->getError()) { $this->error = $curl->getError(); echo $this->error; $this->logger->log('','ERROR', $this->error); return false; } else { if (!in_array($curl->getRequestStatus(),$codes)) { $this->error = 'Response http error:'.$curl->getRequestStatus(); $this->logger->log('','ERROR', $this->error); return false; } else { return true; } } } function mkdir($server_dst) { $curl = $this->getCurl($server_dst); $curl->setOpt(CURLOPT_CUSTOMREQUEST,"MKCOL"); $response = $curl->open(); return $this->getResult($curl, array(201,405));//405 РєРѕРґ коЕвращается если папка уже есть РЅР° сервере } function upload($local_src,$server_dst) { $local_file = fopen($local_src,"r"); $curl = $this->getCurl($server_dst); //$curl->setOpt(CURLOPT_CUSTOMREQUEST,"PUT"); $curl->setOpt(CURLOPT_PUT, 1); $curl->setOpt(CURLOPT_INFILE,$local_file); $curl->setOpt(CURLOPT_INFILESIZE, filesize($local_src)); $header = array('Accept: */*', "Authorization: OAuth {$this->token}", 'Expect: ' ); $curl->setOpt(CURLOPT_HTTPHEADER,$header); $response = $curl->open(); fclose($local_file); return $this->getResult($curl, array(200,201,204)); } function download($server_src,$local_dst) { $local_file = fopen($local_dst,"w"); $curl = $this->getCurl($server_src); $curl->setOpt(CURLOPT_HTTPGET, 1); $curl->setOpt(CURLOPT_HEADER, 0); $curl->setOpt(CURLOPT_FILE,$local_file); $response = $curl->open(); fclose($local_file); return $this->getResult($curl, array(200)); } function rm($server_src) { $curl = $this->getCurl($server_src); $curl->setOpt(CURLOPT_CUSTOMREQUEST,"DELETE"); $response = $curl->open(); return $this->getResult($curl, array(200)); } function ls($server_src) { $curl = $this->getCurl($server_src); $curl->setOpt(CURLOPT_CUSTOMREQUEST,"PROPFIND"); $header = array('Accept: */*', "Authorization: OAuth {$this->token}", 'Depth: 1', ); $curl->setOpt(CURLOPT_HTTPHEADER,$header); $response = $curl->open(); if($this->getResult($curl, array(207))) { $xml = simplexml_load_string($response,"SimpleXMLElement" ,0,"d",true); $list = array(); foreach($xml as $item) { if(isset($item->propstat->prop->resourcetype->collection)) $type = 'd'; else $type = 'f'; $list[]=array('href'=>(string)$item->href,'type'=>$type); } return $list; } return false; } //Ugly. function exists($server_src) { $path = dirname($server_src); $list = $this->ls($path); if($list === false) { $this->error = 'Не могу получить список файлов'; $this->logger->log('','ERROR', $this->error); return false; } foreach($list as $item) if(rtrim($item['href'],'/')==rtrim($server_src,'/')) return true; return false; } //Ugly. function is_file($server_src) { $path = dirname($server_src); $list = $this->ls($path); if($list === false) { $this->error = 'Не могу получить список файлов'; $this->logger->log('','ERROR', $this->error); return false; } foreach($list as $item) if( (rtrim($item['href'],'/')==rtrim($server_src,'/') ) && ($item['type']=='f') ) return true; return false; } //Ugly. function is_dir($server_src) { $path = dirname($server_src); $list = $this->ls($path); if($list === false) { $this->error = 'Не могу получить список файлов'; $this->logger->log('','ERROR', $this->error); return false; } foreach($list as $item) if( (rtrim($item['href'],'/')==rtrim($server_src,'/') ) && ($item['type']=='d') ) return true; return false; } }
Все методы классов имеют говорящие имена mkdir, upload, download, ls, rm, поэтому подробно останавливаться на них не будем. Все сводятся формированию и выполнению запроса с помощью cUrl. К каждому запросу необходимо добавлять токен, полученный выше.
Делать полный разбор ответа, честно говоря делать было лень. Поэтому в ответе просто проверяется статус запроса, если он совпадает с ожидаемым, то считаем операцию выполненной успешно. В противном случае записываем ошибку в лог.
Реализация методов is_dir, is_file, exists ужасна, но я не собираюсь работать с папками в который больше 10 файлов. Именно поэтому они реализованы с использованием метода ls.
Теперь в моем распоряжении есть инструмент для управления диском. Пусть он немного ущербный, но все же — это инструмент.
Создание и отправка резервной копии на Яндекс диск
Резервную копию будем создавать по следующему алгоритму:
- Удаляем с Яндекс диска лишние бэкапы. Если на диске скопилось более n бэкапов, то старые удаляем., число n берем из конфига.
- В некоторой временной папке создаем дамп базы Mysql. В моем коде это выполняется вызовом команды mysqldump.
- В эту же папку копируем файлы которые надо сохранить.
- Архивируем папку с созданными файлами.
- Полученный архив копируем на Яндекс Диск
- Удаляем временные файлы
Возможны вариации последнего набора действий. Тут полет фантазии не ограничен. Мне же достаточно указанного набора.
Указанные действия можно выполнить при помощи следующего класса.
Создание архива и отправка его на диск
class YaBackup { protected $disk; protected $db; protected $logger; protected $backup_number; function __construct($backupconfig) { $config = lmbToolkit::instance()->getConf('yandex'); $this->logger = YaLogger::instance(); $auth = new YaAuth($config,$this->logger); $token = $auth->getToken(); if($token == '') throw Exception('Не могу получить токен'); $this->disk = new YaDisk($token,$config,$this->logger); $this->db = $backupconfig->get('db'); $this->folders = $backupconfig->get('folders'); $this->tmp_dir = $backupconfig->get('tmp_dir'); $this->project = $backupconfig->get('project'); $this->backup_number = $backupconfig->get('stored_backups_number'); $this->server_dir = $backupconfig->get('dir'); $time = time(); $this->archive = date("Y-m-d",$time).'-'.$time; } function execute() { $this->logger->log("Начат бекап проекта ".$this->project,"START_PROJECT"); $this->_clean(); $this->logger->log("Удаление старых копий"); $this->_deleteOld(); $this->logger->log("Создание дампа базы"); $this->_makeDump(); $this->logger->log("Копирование необходимых файлов"); $this->_copyFolders(); $this->logger->log("Создание архива"); $this->_createArchive(); $this->logger->log("Копирование на Яндекс.Диск"); $this->_upload(); $this->logger->log("Удаление временных файлов"); $this->_clean(); $this->logger->log("Бекап проекта ".$this->project." завершен", "END_PROJECT"); } protected function _clean() { lmbFs::rm($this->getProjectDir()); } protected function _deleteOld() { $list = $this->disk->ls($this->server_dir.'/'.$this->project); $paths=array(); $n=0; foreach($list as $item) { //Имена архивов имеют вид Y-m-d-timestamp.tar.gz. В качестве ключа массива используем timestamp. $parts = explode('-',basename(rtrim($item['href'],'/'))); if(isset($parts[3]) && ($item['type']=='f')) { $tm = explode('.',$parts[3]); $paths[(integer)$tm[0]] = $item['href']; $n++; } } ksort($paths);//сортируем массив по ключам от меньшего к большему for($i=$n;$i>$this->backup_number-1;$i--) { $item = array_shift($paths); $this->logger->log("Удаление ".$item); $this->disk->rm($item); } } protected function _upload() { $archive = $this->archive.'.tar.gz'; //создаем дирректории на яндекс диске $this->logger->log("Создаем папки на Яндекс.Диске"); $this->disk->mkdir($this->server_dir); $res = $this->disk->mkdir($this->server_dir.'/'.$this->project); //Копируем архив $this->logger->log("Копируем архив на Яндекс.Диск"); $this->disk->upload($this->getProjectDir().'/'.$archive,$this->server_dir.'/'.$this->project.'/'.$archive); if($res) $this->logger->log("Копирование на Яндекс.Диск завершено успешно"); else $this->logger->log("Копирование на Яндекс.Диск завершено завершено с ошибкой"); } protected function getProjectDir() { return $this->tmp_dir.'/'.$this->project; } protected function _copyFolders() { lmbFs:: mkdir($this->getProjectDir() . '/folders'); $folders = $this->folders; foreach($folders as $key => $value) { lmbFs:: mkdir($this->getProjectDir() . '/folders/' . $key); lmbFs:: cp($value, $this->getProjectDir() . '/folders/' . $key); } } protected function _createArchive() { $archive = $this->archive; $dir = $this->getProjectDir(); //переписать через system `cd $dir && find . -type f -exec tar rvf "$archive.tar" '{}' \;`; `cd $dir && gzip $archive.tar`; } protected function _makeDump() { $host = $this->db['host']; $user = $this->db['user']; $password = $this->db['password']; $database = $this->db['database']; $charset = $this->db['charset']; lmbFs:: mkdir($this->getProjectDir() . '/base'); $sql_schema = $this->getProjectDir() . '/base/schema.mysql'; $sql_data = $this->getProjectDir() . '/base/data.mysql'; //создаем дамп $this->mysql_dump_schema($host, $user, $password, $database, $charset, $sql_schema); $this->mysql_dump_data($host, $user, $password, $database, $charset, $sql_data); } //Следующие методы лучше вынести в отдельный файл protected function mysql_dump_schema($host, $user, $password, $database, $charset, $file, $tables = array()) { $password = ($password)? '-p' . $password : ''; $cmd = "mysqldump -u$user $password -h$host " . "-d --default-character-set=$charset " . "--quote-names --allow-keywords --add-drop-table " . "--set-charset --result-file=$file " . "$database " . implode('', $tables); $this->logger->log("Начинаем создавать дамп базы в '$file' file..."); system($cmd, $ret); if(!$ret) $this->logger->log("Дамп базы создан (" . filesize($file) . " bytes)"); else $this->logger->log("Ошибка создания дампа базы");; } protected function mysql_dump_data($host, $user, $password, $database, $charset, $file, $tables = array()) { $password = ($password)? '-p' . $password : ''; $cmd = "mysqldump -u$user $password -h$host " . "-t --default-character-set=$charset " . "--add-drop-table --create-options --quick " . "--allow-keywords --max_allowed_packet=16M --quote-names " . "--complete-insert --set-charset --result-file=$file " . "$database " . implode('', $tables); $this->logger->log("Начинаем создавать дамп данных в '$file' file..."); system($cmd, $ret); if(!$ret) $this->logger->log("Дамп данных создан! (" . filesize($file) . " bytes)"); else $this->logger->log("Ошибка создания дампа базы");; } }
Причесывать код последнего класса не стал. Думаю заинтересованный читатель сам сможет добавить, убрать или изменить методы под свои нужды. Работа с сводится к загрузке конфига в класс через конструктор и выполнению метода execute
Выполнение копирования по крону
Так сложилось, что все задачи крона я реализую в виде наследников класса:
CronJob
abstract class CronJob { abstract function run(); }
Комментарии тут излишни.
Для каждого проекта я создаю класс примерно такого содержания:
Класс запуска задачи по расписанию
class YaBackupJob extends CronJob { protected $conf; protected $conf_name = 'adevelop'; function __construct() { $this->conf = lmbToolkit::instance()->getConf($this->conf_name); } function run() { $backup = new YaBackup($this->conf); $backup->execute(); } }
Здесь как и везде выше используется стандартный механизм файлов конфигурации из Limb. В принципе класс можно сделать абстрактным, но это кому как удобно.
Остался вопрос запуска. Сама задача запускается при помощи скрипта cron_runner.php. Который подключает файл с классом задания, создает объект этого класса и следит, чтобы одновременно одно и то же задание не выполнялось двумя процессами (последнее реализовано на основе файловых локов).
cron_runner.php
set_time_limit(0); require_once(dirname(__FILE__) . '/../setup.php'); lmb_require('limb/core/src/lmbBacktrace.class.php'); lmb_require('limb/fs/src/lmbFs.class.php'); lmb_require('ya/src/YaLogger.class.php'); new lmbBacktrace; function write_error_in_log($errno, $errstr, $errfile, $errline) { global $logger; $back_trace = new lmbBacktrace(10, 10); $error_str = " error: $errstr\nfile: $errfile\nline: $errline\nbacktrace:".$back_trace->toString(); $logger->log($error_str,"ERROR",$errno); } set_error_handler('write_error_in_log'); error_reporting(E_ALL); ini_set('display_errors', true); if($argc < 2) die('Usage: php cron_runner.php cron_job_file_path(starting from include_file_path)' . PHP_EOL); $cron_job_file_path = $argv[1]; $logger = YaLogger::instance(); $lock_dir = LIMB_VAR_DIR . '/cron_job_lock/'; if(!file_exists($lock_dir)) lmbFs :: mkdir($lock_dir, 0777); $name = array_shift(explode('.', basename($cron_job_file_path))); $lock_file = $lock_dir . $name; if(!file_exists($lock_file)) { file_put_contents($lock_file, ''); chmod($lock_file, 0777); } $fp = fopen($lock_file, 'w'); if(!flock($fp, LOCK_EX + LOCK_NB)) { $logger->logConflict(); return; } flock($fp, LOCK_EX + LOCK_NB); try { lmb_require($cron_job_file_path); $job = new $name; if(!in_array('-ld', $argv)) $logger->log('',"START"); ob_start(); echo $name . ' started' . PHP_EOL; $result = $job->run(); $output = ob_get_contents(); ob_end_clean(); if(!in_array('-ld', $argv)) $logger->log($output,"END",$result); } catch (lmbException $e) { $logger->logException($e->getNiceTraceAsString()); throw $e; } flock($fp, LOCK_UN); fclose($fp); if(in_array('-v', $argv)) { echo $output; var_dump($logger->getRecords()); }
В кронтаб прописывается команда:
php /path/to/cron_runner.php ya/src/YaBackupJob.class.php
В качестве аргумента скрипту передаем путь относительно include_path до файла с классом. Имя самого класса с задачей скрипт определяет по имени файла.
Заключение
Буду рад, если кому пригодится этот код. Ссылки на полный работающий пример приведены ниже.
Конструктивная критика приветствуется. Жду ваших замечаний и отзывов.
Ссылки и источники
- Последняя версия архива c примером может быть найдена тут или тут
- Репозиторий на github YaBackup
- Документация Яндекс OAuth и Диск
Стандарт OAuth
Стандарт WebDav
Фреймворк Limb