Резервное копирование веб-проектов на Яндекс.Диск

в 9:09, , рубрики: php, Яндекс.Диск, метки: ,

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

Большие проекты могут позволить себе выделить целые сервера для резервного копирования. Однако, существует огромное количество небольших проектов, работающих лишь на вашем энтузиазме. Эти проекты также нуждаются в резервном копировании.

Идея создания архивов на сервисах вроде Dropbox, Ubuntu One, Яндекс Диск, Диск Google и др. уже давно притягивала мое внимание. Десятки гигабайт бесплатного места, которое теоретически можно использовать резервирования данных.

Теперь эта идея получила мое первое воплощение. В качестве сервиса для создания архивов был выбран Яндекс Диск.

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

Не скажу, что API сервисов Яндекса имеют отличную документацию. Однако тем есть примеры и ссылки на конкретные стандарты. Этого вполне хватило.

После изучения проблемы задача резервирования данных распалась на следующие пункты:

  1. Регистрация приложения
  2. Авторизация в Яндексе при помощи OAuth
  3. Операции с Яндекс.Диском
  4. Создание и отправка резервной копии на Яндекс диск
  5. Выполнение копирования по крону

Последние два пункта — дело техники, но все же я решил включить их в описание.

Я давно использую фреймворк 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.
Теперь в моем распоряжении есть инструмент для управления диском. Пусть он немного ущербный, но все же — это инструмент.

Создание и отправка резервной копии на Яндекс диск

Резервную копию будем создавать по следующему алгоритму:

  1. Удаляем с Яндекс диска лишние бэкапы. Если на диске скопилось более n бэкапов, то старые удаляем., число n берем из конфига.
  2. В некоторой временной папке создаем дамп базы Mysql. В моем коде это выполняется вызовом команды mysqldump.
  3. В эту же папку копируем файлы которые надо сохранить.
  4. Архивируем папку с созданными файлами.
  5. Полученный архив копируем на Яндекс Диск
  6. Удаляем временные файлы

Возможны вариации последнего набора действий. Тут полет фантазии не ограничен. Мне же достаточно указанного набора.
Указанные действия можно выполнить при помощи следующего класса.

Создание архива и отправка его на диск

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: $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 до файла с классом. Имя самого класса с задачей скрипт определяет по имени файла.

Заключение

Буду рад, если кому пригодится этот код. Ссылки на полный работающий пример приведены ниже.
Конструктивная критика приветствуется. Жду ваших замечаний и отзывов.

Ссылки и источники

Автор: vasiatka

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js