Загрузка файлов в Yii

от автора

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

Задача сводится к следующему. Нужен удобный механизм загрузки файлов и картинок на сервер (включая необходимые проверки), создание миниатюр для картинок и автоматическая генерация тегов img и ссылок на скачивание файла. В расширениях ничего подходящего не нашлось. Наиболее близким по смыслу является расширение upload, но и оно не предоставляет ряда необходимых функций. Так что решил писать сам. Прямо перед публикацией статьи случайно увидел рецепт, в котором рассматривается похожая идея.

Примеры кода даны только для ознакомления, я вставлял их из работающего проекта, но мог что-нибудь перепутать. Если нужен работающий код, в конце статьи есть ссылка на проект. Итак, вперед!

Первая итерация. Дополняем стандартный функционал.

Что предлагает Yii? Во-первых, класс CUploadedFile, предоставляющий информацию о загруженном файле и позволяющий сохранять его на сервер. Во-вторых, валидатор CFileValidator, выполняющий проверку загруженного файла. Вот как официальная документация рекомендует загружать файлы:

// Модель class MyModel extends CActiveRecord {     public $image;       public function rules(){         return array(             array('image', 'file', 'types'=>'jpg, gif, png'),         );     } }  // Контроллер class MyModelController extends CController {     public function actionCreate(){         $modMyModel=new MyModel;         if(isset($_POST['MyModel'])){             $modMyModel->attributes=$_POST['MyModel'];             $modMyModel->id_image=CUploadedFile::getInstance($modMyModel,'image');             if($modMyModel->save()){                 $modMyModel->id_image->saveAs('path/to/localFile');                 // перенаправляем на страницу, где выводим сообщение об                 // успешной загрузке             }         }         $this->render('create', array('model'=>$modMyModel));     } }  // Форма <?php echo CHtml::form('','post',array('enctype'=>'multipart/form-data')); ?> ... <?php echo CHtml::activeFileField($modMyModel, 'image'); ?> ... <?php echo CHtml::endForm(); ?> 

У такого подхода есть ряд недостатков:

  1. Во фреймворке нет специально выделенной папки для загрузки файлов
  2. Загрузку файлов приходится каждый раз описывать в контроллере
  3. Указанный подход не может быть перенесен на actionUpdate(), поскольку ожидает загрузки файла при каждом вызове. А с файлами было бы удобно работать как с обычными свойствами — загрузить при создании модели и при необходимости перезагрузить при ее изменении.
  4. Нет рекомендаций относительно последующего обращения к файлу. Впрочем, path/to/localFile можно хранить в свойстве модели.

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

Для начала определимся с местом для сохранения файлов. На мой взгляд, для хранения файлов лучше всего подойдет директория .../protected/data/files. Вообще говоря, для файлов, создающихся в процессе работы, существует папка .../protected/runtime, но по смыслу директория data больше подходит для этих целей. Имя файла будем генерировать случайным образом (uniqid()) и сохранять в свойстве модели $modMyModel->id_image, в следующих абзацах расскажу как. Правда у такого подхода есть один подводный камень — директория data закрыта для обращений из браузера. Как быть с этим — чуть позже. Забегая вперед, файлы для скачивания предлагаю выдавать динамически через readfile(), а картинки (точнее, миниатюры картинок) — публиковать в папке assets.

С папкой и именованием файлов разобрались. Теперь разберемся с валидацией и загрузкой. Начнем с формы. Сделаем так:

<?php echo $modMyModel->id_image ?> <?php echo CHtml::activeFileField($modMyModel, 'id_image_file'); ?> 

Так мы будем видеть, загружен ли файл. А сам файл будет грузиться с именем id_image_file. Вместо echo $modMyModel->id_image можно будет вставить ссылку для скачивания файла или миниатюру картинки.

Теперь валидация. Идея такова: валидатор должен проверить, есть ли в $_FILES загруженный файл с именем id_image_file. Если есть, то создать объект CUploadedFile и записать его в $modMyModel->id_image. После чего выполнить валидацию $modMyModel->id_image стандартным способом. Для этого создадим свой валидатор DFileValidator, унаследованный от CFileValidator. И сразу еще один — DImageValidator, унаследованный от DFileValidator, в котором укажем типы файлов по умолчанию для картинок.

И, наконец, загрузка файла и сохранение модели. После валидации загруженный файл будет находиться в $modMyModel->id_image, причем в виде CUploadedFile. Для того чтобы загрузка не была привязана к конкретному свойству, нужно перед сохранением модели проверить, являются ли какие-либо ее свойства объектами класса CUploadedFile, и если являются — загрузить их и сохранить адреса. Теперь модель будет выглядеть так:

class MyModel extends DActiveRecord {     public $id_image;       public function rules(){         return array(             array('id_image', 'DImageValidator'),         );     }  	public function beforeSave() 	{ 		foreach ($this->attributes as $key => $attribute)  			if ($attribute instanceof CUploadedFile) 			{ 				$strSource = uniqid(); 				if ($attribute->saveAs(Yii::getPathOfAlias('application.data.files') . '/' .  $strSource)) 					$this->$key = $strSource; 			} 		return parent::beforeSave(); 	} } 

Свойство image было заменено свойством id_image сознательно. Дальше будет понятно почему.

Подведем промежуточный итог

  1. Все файлы сохраняются в папке .../protected/data/files со случайными именами.
  2. Загузка файла выполняется в модели, перед сохранением в базе данных.
  3. Чтобы пометить свойство как файл, нужно:
    1. В rules() модели назначить этому свойству валидатор DFileValidator.
    2. В форме переименовать инпут для этого свойства, дописав к нему '_file'.

  4. Методы actionCreate() и actionUpdate() контроллера можно оставить без изменений.

Вторая итерация. Подключаем базу данных.

Мы разобрались, как загрузить файл. Но пока непонятно как к нему обращаться. Что писать в параметре src тэга img? Как отдавать файл для скачивания? На мой взгляд, для работы с файлами было бы удобно использовать функционал модели Yii. В самом деле, если каждому загруженному файлу будет соответствовать модель, все низкоуровневые операции, включая загрузку, можно будет поручить ей. А в свойстве $modMyModel->id_image хранить первичный ключ этой модели (теперь понятна суть имени этого свойства). Тогда для $modMyModel можно будет определить соответствующие связи и писать, например, так:

 // В MyModel: public function relations() { 	return array( 		'image' => array(self::BELONGS_TO, 'DImage', 'id_image'), 	); }  // Где угодно: $modMyModel = new MyModel; echo $modMyModel->id_image->image($htmlOptions); // Подготовит картинку к публикации и выведет тэг img echo $modMyModel->file->downloadLink(); 		// Вернет ссылку для скачивания файла 

Кроме того, при использовании модели сам собой решается вопрос хранения оригинального имени файла (которое теряется при сохранении).

Поехали.

Создадим таблицу tbl_files с полями id, source, name. Определим модель DFile, связанную с этой таблицей. В ней определим статический метод upload:

class DFile extends DActiveRecord { 	public $uploadPath; // Путь к папке загрузки   	public function init() 	{ 		$this->uploadPath = Yii::getPathOfAlias('application.data.files'); 	}  	public static function upload($objFile) 	{ 		$modFile = new DFile; 		$modFile->name = $objFile->name; 		$modFile->source = uniqid();  		if ($objFile->saveAs($modFile->uploadPath . '/' .  $modFile->source)) 			if ($modFile->save())  				return $modFile;  		return null; 	} } 

И сразу создадим пустой класс DImage extends DFile. Он понадобится нам позже.
Теперь немного изменим нашу модель. Определим обещанную связь с картинкой и немного подправим метод beforeSave():

class MyModel extends DActiveRecord {     public $id_image;       public function rules(){         return array(             array('id_image', 'DImageValidator', 'allowEmpty' => true),         );     }  	public function relations() 	{ 		return array( 			'image' => array(self::BELONGS_TO, 'DImage', 'id_image'), 		); 	}  	public function beforeSave() 	{ 		foreach ($this->attributes as $key => $attribute)  			if ($attribute instanceof CUploadedFile) 			{ 				$modFile = DFile::upload($attribute);	// Загрузку отдали DFile 				$this->$key = $modFile->id; 			} 		return parent::beforeSave(); 	} } 

В объекте $modMyModel модели можно обращаться к файлу через $modMyModel->image. Как это выгодно использовать — читайте дальше.

Третья итерация. Обращения к загруженным файлам.

До этого момента мы почти не разделяли файлы и картинки. В самом деле, их загрузка выполняется абсолютно идентично. Единственная разница — проверки перед загрузкой. Но с этим отлично справятся валидаторы DFileValidator и DImageValidator, в которых можно указать все необходимые правила.

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

Работа с файлами

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

Генерацию ссылки удобно делать в DFile. Примерно так:

class DFile extends DActiveRecord { 	public function downloadLink($htmlOptions = array()) 	{ 		return CHtml::link($this->name, array('/files/file/download', 'id' => $this->id), $htmlOptions); 	} } 

Ссылка указывает на контроллер FileController. Определим его:

class FileController extends DcController { 	public function actionDownload($id) 	{ 		$modFile = $this->loadModel($id);  		header("Content-Type: application/force-download"); 		header("Content-Type: application/octet-stream"); 		header("Content-Type: application/download"); 		header("Content-Disposition: attachment; filename=" . $modFile->name); 		header("Content-Transfer-Encoding: binary ");    		readfile($modFile->uploadPath . '/' .  $modFile->source); 	}  	public function loadModel($id) 	{ 		$modFile = DFile::model()->findByPk($id); 		if($modFile === null) 			throw new CHttpException(404,'The requested page does not exist.'); 		return $modFile; 	} }  

Думаю, здесь все понятно. Метод actionDownload() не делает никаких дополнительных проверок, но их вполне можно включить при необходимости. В модели теперь, определив соответствующую связь, можно писать $modMyModel->file->downloadLink(). Конечно, такой подход будет менее производителен, чем выдача прямых ссылок на файлы. Если производительность является критичной, можно заказчивать файлы не в защищенную директорию data, а в другую (открытую) директорию, и выдавать прямые ссылки.

Работа с картинками

С картинками ситуация немного сложнее. Картинки требуют создания миниатюр. Кроме того, с картинками мы уж точно не можем позволить себе выдавать динамические ссылки. К счастью, Yii предоставляет удобный механизм публикации ресурсов, который можно использовать в наших целях. Идея такова: миниатюры будем создавать и публиковать как ресурсы при генерации ссылки на картинку. Тут, правда, есть пара неприятностей. Во-первых, если нужен доступ к исходной картинке, ее тоже придется копировать в папку assets. Во-вторых, публикацию не получится осуществить стандартными средствами Yii. Дело в том, что для каждого опубликованного файла Yii создаст собственную папку, что будет перебором. Да и создание миниатюр сразу в папку assets стандартными средствами сделать не получится.

Первая проблема может быть не актуальна, если картинок закачивается не очень много и доступ к исходной картинке не требуется. Исходные картинки хранятся в хорошем разрешении, их можно скачать используя описанный выше механизм, а для вывода на экран используются только миниатюры. Если же такая проблема имеет место, то, как вариант, можно не копировать исходную картинку в папку assets, а создать ссылку (стандартный механизм публикации в Yii предлагает публикацию с созданием ссылок).

Что касается второй проблемы, придется писать публикацию самостоятельно. Впрочем, не так уж много писать…

Итак, поехали. Класс DImage, унаследованный от DFile у нас уже есть. Опишем создание миниатюр и публикацию:

class DImage extends DFile { 	public $assetsPath; // Путь к папке с ресурсами 	public $assetsUrl;	// URL папки с ресурсами 	public $thumbs = array( 		'min' => array('width' => 150, 'height' => 150), 		'mid' => array('width' => 250), 		'big' => array('width' => 600),          	);  	// Определим настройки 	public function init() 	{ 		$this->assetsUrl = Yii::app()->assetManager->baseUrl . '/files'; 		$this->assetsPath = Yii::app()->assetManager->basePath . '/files';  		if (!is_dir($this->assetsPath)) mkdir($this->assetsPath); 	}  	// Все миниатюры должны находиться в $this->assetsPath 	public function getIsPublished() 	{ 		foreach ($this->thumbs as $kThumb => $vThumb) 			if (!is_file($this->assetsPath . '/' . $this->source . '_' . $kThumb)) return false; 		return true; 	}  	// Публикация миниатюр 	public function publish() 	{ 		if (!$this->isPublished) 			foreach ($this->thumbs as $kThumb => $vThumb) 				$this->createThumb($this->uploadPath . '/' . $this->source,  									$this->assetsPath . '/' . $this->source . '_' . $kThumb,  									$kThumb);			  		return 	$this->assetsUrl . '/' . $this->source; 	}  	// Создание миниатюр 	function createThumb($strSrcFile, $strDstFile, $strThumb) 	{ 		// Создает миниатюру картинки $strSrcFile, сохраняет в $strDstFile 	} } 

Для публикации миниатюр предлагаю создать в папке assets подпапку files. Учитывая то, что папку assets рекомендуется периодически чистить, существование папки assets/files необходимо каждый раз проверять. И создавать если нужно. Имя миниатюры равно имени изображения, дополненному идентификатором миниатюры. Изображение считается опубликованным, если все миниатюры находятся на своих местах. Проверять совпадение даты исходного и опубликованного файлов не имеет смысла, поскольку загруженный файл не может изменяться. Функция publish() возвращает URL опубликованной картинки (правда, без указания миниатюры), что не противоречит идее публикации ресурсов в Yii.

И, наконец, рассмотрим обращения к загруженной картинке. Дополним класс DImage методом image():

public function image($strThumb = 'min', $htmlOptions = array()) { 	return CHtml::image($this->publish() . '_' . $strThumb, $this->name, $htmlOptions); } 

Теперь, по аналогии с файлами, в модели можно писать $modMyModel->image->image(). Кстати, если размер миниатюр вдруг необходимо изменить или добавить новый (у меня такое как-то раз случилось), а все файлы уже закачаны, достаточно будет поменять размер в настройках и очистить папку assets.

Последние штрихи

Все работает. Картинки загружаются, выводятся. Файлы закачиваются и скачиваются. Осталось немного причесать код. Например, метод beforeSave() можно вынести из класса MyModel в класс DActiveRecord, от которого, как Вы успели заметить, наследуются все модели. Кроме этого, отображение инпутов для файлов и картинок можно перенести в класс DActiveForm extends CActiveForm. Хранение настроек можно поручить модулю files.

Ну и, по хорошей традиции, ссылка на скачивание работающего проекта. Выкладывать отдельные файлы оказалось проблематично из-за большого количества зависимостей, поэтому выкладываю проект целиком. Дамп БД лежит в protected/data/dump.sql. Из настроек — указать путь к Yii, прописать доступ к БД. Базовые классы и валидаторы лежат в папке protected/components, все что касается файлов — в модуле files.

Заключение

Итак, вот что мы имеем на выходе:

  1. Централизованное управление загрузкой и хранением файлов
  2. Удобный интерфейс для создания в моделях свойств-файлов
  3. Высокоуровневую генерацию ссылок на скачивание файлов и тэгов IMG

Идею можно развить. Например, практически все WYCIWYG — редакторы предлагают интерфейс для загрузки файлов и изображений. Для этого требуется лишь указать адрес загрузки. Обработчик загрузки можно включить в контроллер FileController. Но как тогда публиковать миниатюры?

Или еще, можно использовать предложения, описанные в статьях Безопасная загрузка изображений на сервер. Часть первая и Безопасная загрузка изображений на сервер. Часть вторая. Можно дополнить упомянутое выше расширение upload. Можно перенести функционал в поведения.

Одним словом, считать предложенное решение готовым пока рано. Но если описанная идея окажется полезной, готов довести работу до конца и опубликовать соответствующее расширение. Спасибо всем, кто дочитал!

ссылка на оригинал статьи http://habrahabr.ru/post/156293/


Комментарии

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

Ваш адрес email не будет опубликован. Обязательные поля помечены *