В далекие детские годы я не понимал важность резервного копирования данных. Но, как говориться, понимание приходит с опытом. Зачастую опыт бывает очень горький. В моем случае
Большие проекты могут позволить себе выделить целые сервера для резервного копирования. Однако, существует огромное количество небольших проектов, работающих лишь на вашем энтузиазме. Эти проекты также нуждаются в резервном копировании.
Идея создания архивов на сервисах вроде Dropbox, Ubuntu One, Яндекс Диск, Диск Google и др. уже давно притягивала мое внимание. Десятки гигабайт бесплатного места, которое теоретически можно использовать резервирования данных.
Теперь эта идея получила мое первое воплощение. В качестве сервиса для создания архивов был выбран Яндекс Диск.
На гениальность идеи я не претендую. И, конечно, изобретение велосипеда началось с поиска готовых решений в Интернете. Весь найденный код либо уже не работал, либо имел совершенно нечитаемый вид. Я же предпочитаю понимать как работают мои приложения.
Не скажу, что API сервисов Яндекса имеют отличную документацию. Однако тем есть примеры и ссылки на конкретные стандарты. Этого вполне хватило.
После изучения проблемы задача резервирования данных распалась на следующие пункты:
- Регистрация приложения
- Авторизация в Яндексе при помощи OAuth
- Операции с Яндекс.Диском
- Создание и отправка резервной копии на Яндекс диск
- Выполнение копирования по крону
Последние два пункта — дело техники, но все же я решил включить их в описание.
Я давно использую фреймворк 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
Выполнение копирования по крону
Так сложилось, что все задачи крона я реализую в виде наследников класса:
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. Который подключает файл с классом задания, создает объект этого класса и следит, чтобы одновременно одно и то же задание не выполнялось двумя процессами (последнее реализовано на основе файловых локов).
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: $errstrnfile: $errfilenline: $errlinenbacktrace:".$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 примером может быть найдена тут или тут
- Документация Яндекс OAuth и Диск
Стандарт OAuth
Стандарт WebDav
Фреймворк Limb
Автор: vasiatka