Файловая система Drupal: что такое Stream Wrappers и как их использовать?

PHP Stream Wrappers в Drupal

И снова, всем, привет! В предыдущем посте я рассказывал о том, как оседлать Dropbox API и OAuth. Однако самое интересное я припас для этого поста: как организовать работу файловой системы Drupal с удаленными файлами через собственный Stream Wrapper.

Предыдущий пост решал проблему взаимодействия с Dropbox API, в частности – авторизацию. Однако как организовать процесс загрузки файлов на Dropbox? Как хранить данные в Drupal об уже загруженных файлах на Dropbox? И какой интерфейс предоставлять пользователю для загрузки?

Первыми моими мыслями было:

  • завести отдельную таблицу, наподобие "file_managed";
  • создать свой элемент формы и поле, написать виджет;
  • отлавливать событие сохранения файла через какой-нибудь hook_field_presave() и дублировать загрузку на Dropbox.

Ребята, все это костыли и говнокод, запомните! Drupal в очередной раз покорил меня своей гибкостью и изяществом, когда в недрах ядра я нашел ответы на поставленные вопросы. Для реализации задач данного типа достаточно определить свой PHP Stream Wrapper, который скажет Drupal-системе куда что положить и как потом забрать. Решается все одним хуком и одним классом. Звучит круто, не правда ли?

Реализация Stream Wrappers в ядре

Думаю, что все в курсе того, что в Drupal из коробки есть Public file system и Private file system. И выбирать систему хранения вы можете отдельно для каждого созданного поля. Так вот, эти самые два варианта хранения и раздачи файлов описываются в includes/stream_wrappers.inc классами DrupalPrivateStreamWrapper и DrupalPublicStreamWrapper соответственно.

Если открыть таблицу базы данных "file_managed", то можно увидеть, что пути к файлам хранятся не в абсолютном виде, а в формате public://path/to/file или private://path/to/file . Собственно эти префиксы путей (назовем их так) ‘public’ и ‘private’ позволяют определять какой Stream Wrapper использовать для обработки файла.

Как же это все работает? Каждый зарегистрированный Stream Wrapper в Drupal имеет набор обязательных методов, определенных интерфейсом DrupalStreamWrapperInterface, который включает в себя набор стандартных методов для PHP stream wrapper, а также несколько дополнительных, обусловленных системой Drupal. Таким образом, мы наблюдаем отличный пример полиморфизма: мы говорим системе, например, «сохранить», а она уже сама решает, как ей обрабатывать файл.

Рассмотрим в качестве небольшого примера загрузку файла с использованием DrupalPublicStreamWrapper:

  1. После нажатия на «Submit» выбранный файл загружается во временную папку вашего сайта в виде «php8B98.tmp»;
  2. Опустим всяческие валидации файла и проверки директорий на право записи. Лучше обратим внимание на функцию drupal_move_uploaded_file(), где вызывается уже чистокровная PHP move_uploaded_file($filename, $uri), после которой управление переходит в руки Stream Wrapper;
  3. Вызывается метод ‘stream_open’, в котором через fopen() в указанной папке сервера создается файл и открывается для записи;
  4. В цикле идет срабатывание метода ‘stream_write’, который через fwrite() записывает данные в файл;
  5. Вызывается ‘stream_flush’, где отрабатывает fflush() для очистки буфера;
  6. Вызывается ‘stream_close’ для закрытия записываемого файла через fclose();
  7. Временный файл удаляется, данные записываются в базу данных.

Вот в таком ключе, если не вдаваться во все детали, работает Public file system при записи файла. Таким образом, нам остается лишь грамотно расписать методы своего Stream Wrapper, чтобы загружать и хранить файлы, где угодно: на стороннем сервере, на Dropbox или даже на серверах VK (скоро и до них мои руки доберутся ^^). Зачем я так извращаюсь спросите вы? На это есть свои причины – рассажу в посте о SEO как-нибудь.

Написание собственного класса Stream Wrapper

Не скажу, что написание собственного Stream Wrapper’a занятие безумно сложное, но разобраться в тонкостях все же придется. В зависимости от конкретной схемы управления файлами вам могут не потребоваться какие-то методы, другие же методы придется интерпретировать. Например, я не заморачивался с методами наподобие ‘stream_eof’, ‘stream_read’, ‘stream_lock’, так как побитового чтения у меня не подразумевалось в задаче. Просто заглушил их return TRUE / FALSE / NULL; в зависимости от логики метода.

Ну-с, пробежимся немного по самым интересным методам класса, который я написал для работы с Dropbox. Помимо стандартного свойства $uri я еще использовал $buffer – далее объясню для чего оно.

  1. class DropboxStreamWrapper implements DrupalStreamWrapperInterface {
  2.  
  3. protected $uri;
  4. private $buffer = '';
  5.  
  6. function setUri($uri) {
  7. $this->uri = $uri;
  8. }
  9.  
  10. function getUri() {
  11. return $this->uri;
  12. }
  13.  
  14. // 100500 строчек кода..
  15. }

Остановимся более подробно на методах записи файла. В ‘stream_write’ мы очищаем свойство буфера данных.

  1. public function stream_open($uri, $mode, $options, &$opened_path) {
  2. $this->uri = $uri;
  3. // Clears buffer.
  4. $this->buffer = '';
  5. return TRUE;
  6. }

Собственно для метода ‘stream_write’ и вводилось свойство $buffer. В качестве аргумента в метод приходят данные загружаемого файла. Дело в том, что информация файла считывается не вся сразу, а порциями, объем которых скорее всего определяется настройкам сервера. В случае DrupalPublicStreamWrapper данные сразу же записывались через fwrite(), однако Dropbox API при загрузке файла требует сразу всей информации по файлу. Поэтому на этом этапе мы ничего никуда не пишем, а просто собираем в буфер данные.

  1. public function stream_write($data) {
  2. $this->buffer .= $data;
  3. $data_length = strlen($data);
  4.  
  5. return $data_length;
  6. }

А вот в методе ‘stream_flush’ будем отправлять файл на Dropbox через POST-запрос:

  1. public function stream_flush() {
  2. $path = $this->getLocalPath();
  3. $Dropbox = new DropboxFileService();
  4. $result = $Dropbox->fileUpload($path, $this->buffer);
  5.  
  6. if ($result['code'] != 200) {
  7. $this->uri = '';
  8. watchdog('dropbox_file', 'Error in stream_flush(). Code: @code. Error: @error.',
  9. array('@code' => $result['code'], '@error' => $result['error']), WATCHDOG_ERROR);
  10. return FALSE;
  11. }
  12.  
  13. cache_clear_all('dropbox_file:', 'cache', TRUE);
  14.  
  15. return TRUE;
  16. }

Метод ‘stream_close’ мне оказался совсем ненужным:

  1. public function stream_close() {
  2. return FALSE;
  3. }

Для удаления файлов используется метод ‘unlink’.

  1. public function unlink($uri) {
  2. $this->uri = $uri;
  3. $path = $this->getLocalPath();
  4.  
  5. $Dropbox = new DropboxFileService();
  6. $result = $Dropbox->pathDelete($path);
  7.  
  8. if ($result['code'] != 200 || empty($result['is_deleted'])) {
  9. $this->uri = '';
  10. watchdog('dropbox_file', 'Error in unlink(). Code: @code. Error: @error.',
  11. array('@code' => $result['code'], '@error' => !empty($result['error']) ? $result['error'] : 'Empty.'), WATCHDOG_ERROR);
  12. return FALSE;
  13. }
  14.  
  15. cache_clear_all('dropbox_file:', 'cache', TRUE);
  16.  
  17. return TRUE;
  18. }

Интересным методом у меня получился ‘getExternalUrl’, который возвращает абсолютную ссылку на файл. Каждый запрос к Dropbox API сказывается на времени загрузки страницы, поэтому я решил использовать кеширование. Сразу хочу сказать, что это кеширование было сделано на скорую руку и возможно требует доработки – модуль интеграции с Dropbox я пока еще не успел обкатать на боевых сайтах.

  1. public function getExternalUrl() {
  2. $path = str_replace('\\', '/', $this->getTarget());
  3.  
  4. $url = '';
  5. if ($cache = cache_get('dropbox_file:' . $path)) {
  6. $url = $cache->data;
  7. }
  8. else {
  9. $Dropbox = new DropboxFileService();
  10. $result = $Dropbox->fileShareLink($path);
  11.  
  12. if ($result['code'] == 200) {
  13. // Добавляем параметр, чтобы получить прямую ссылку на файл Dropbox.
  14. $url = $result['url'] . '?dl=1';
  15. cache_set('dropbox_file:' . $path, $url, 'cache', REQUEST_TIME + 60 * 60 * 24);
  16. }
  17. else {
  18. watchdog('dropbox_file', 'Error in getExternalUrl(). Code: @code. Error: @error.',
  19. array('@code' => $result['code'], '@error' => $result['error']), WATCHDOG_ERROR);
  20. }
  21. }
  22.  
  23. return $url;
  24. }

Небольшие трудности возникли и с ‘url_stat’, который я поначалу также пытался заглушить. Но, как оказалось, данный метод отрабатывает с функцией is_file(), а также необходим для операции удаления. Через данный метод к тому же указывается размер файла, время обновления. Основную нагрузку несет строка:$stat[2] = $stat['mode'] = 33206; – не оставляйте ее пустой, иначе всегда будете получать ответ, что «файл не существует».

  1. public function url_stat($uri, $flags) {
  2. $this->uri = $uri;
  3. $stat = $this->_stat($uri);
  4. return $stat;
  5. }
  6.  
  7. protected function _stat($uri = NULL) {
  8. $path = $this->getLocalPath($uri);
  9.  
  10. // Prevent file request for image styles.
  11. if (preg_match('/^styles\/(.*)$/', $path)) {
  12. return FALSE;
  13. }
  14.  
  15. $Dropbox = new DropboxFileService();
  16. $metadata = $Dropbox->metadata($path);
  17.  
  18. if ($metadata['code'] == 200) {
  19. $stat = array();
  20. $stat[0] = $stat['dev'] = 0;
  21. $stat[1] = $stat['ino'] = 0;
  22. // Without this $stat['mode'] value is_file() will be empty.
  23. $stat[2] = $stat['mode'] = 33206;
  24. $stat[3] = $stat['nlink'] = 0;
  25. $stat[4] = $stat['uid'] = 0;
  26. $stat[5] = $stat['gid'] = 0;
  27. $stat[6] = $stat['rdev'] = 0;
  28. $stat[7] = $stat['size'] = 0;
  29. $stat[8] = $stat['atime'] = 0;
  30. $stat[9] = $stat['mtime'] = 0;
  31. $stat[10] = $stat['ctime'] = 0;
  32. $stat[11] = $stat['blksize'] = 0;
  33. $stat[12] = $stat['blocks'] = 0;
  34.  
  35. if (!$metadata['is_dir']) {
  36. if (!isset($metadata['bytes']) || !isset($metadata['modified'])) {
  37. return FALSE;
  38. }
  39. else {
  40. $stat[7] = $stat['size'] = $metadata['bytes'];
  41. $stat[8] = $stat['atime'] = strtotime($metadata['modified']);
  42. $stat[9] = $stat['mtime'] = strtotime($metadata['modified']);
  43. $stat[10] = $stat['ctime'] = strtotime($metadata['modified']);
  44. }
  45. }
  46. return $stat;
  47. }
  48. return FALSE;
  49. }

Подключение Stream Wrapper

Как я и говорил, подключается кастомный Stream Wrapper через хук:

  1. /**
  2.  * Implements hook_stream_wrappers().
  3.  */
  4. function MYMODULE_stream_wrappers() {
  5. return array(
  6. 'dropbox' => array(
  7. 'name' => t('Dropbox files'),
  8. 'class' => 'DropboxStreamWrapper',
  9. 'description' => t('Remote files served by Dropbox.'),
  10. 'type' => STREAM_WRAPPERS_WRITE_VISIBLE,
  11. ),
  12. );
  13. }

Если все сделано правильно, то теперь наряду с Public file system, Private file system вы сможете выбирать и Dropbox files при создании файловых полей для той же ноды или таксономии.

Выбрать файловое хранилище

Вот таким заложенным в самых недрах Drupal’a методом можно играться с файловой системой, не допиливая и не ломая архитектуру самого движка. Данная возможность для меня была, наверное, самым большим открытием за последнее время в Drupal. Все же хорошо, когда ты только подумаешь «вот было бы круто, если бы…», а оно уже в движке и заложено.

Если есть вопросы – задавайте, буду помогать по возможности. Код пока не выкладываю, так как он еще сыроват и не оттестирован в боевых условиях.

Комментарии

Аватар пользователя Rodman
Rodman

Спасибо, в закладки.

Аватар пользователя Прохожий
Прохожий

это отдельный модуль, или это пихать в dropbox_file?

Аватар пользователя angarsky
angarsky
Можно пихать в тот же модуль, но надо понимать, что это и для чего. Данная статья - не готовое решение, а скорее попытка направить разработчика в нужное русло.
Аватар пользователя Прохожий
Прохожий

ну вот - опять облом. нет в жизни щастья.

Аватар пользователя test
test

Будет ли данный функционал выложен в виде отдельного модуля?

Аватар пользователя angarsky
angarsky
Пока еще модуль обкатывается, есть огрехи. Попозже может и выложу. Это же еще оформлять все надо :)

Добавить комментарий

  .d8888b.   888    d8P   888        .d8888b.  
d88P Y88b 888 d8P 888 d88P Y88b
.d88P 888 d8P 888 888 888
8888" 888d88K 888 888
"Y8b. 8888888b 888 888
888 888 888 Y88b 888 888 888
Y88b d88P 888 Y88b 888 Y88b d88P
"Y8888P" 888 Y88b 88888888 "Y8888P"


Зарегистрируйтесь для добавления материалов без проверки.