Написав ряд проектов на 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(); ?>
У такого подхода есть ряд недостатков:
- Во фреймворке нет специально выделенной папки для загрузки файлов
- Загрузку файлов приходится каждый раз описывать в контроллере
- Указанный подход не может быть перенесен на
actionUpdate()
, поскольку ожидает загрузки файла при каждом вызове. А с файлами было бы удобно работать как с обычными свойствами — загрузить при создании модели и при необходимости перезагрузить при ее изменении. - Нет рекомендаций относительно последующего обращения к файлу. Впрочем,
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 сознательно. Дальше будет понятно почему.
Подведем промежуточный итог
- Все файлы сохраняются в папке
.../protected/data/files
со случайными именами. - Загузка файла выполняется в модели, перед сохранением в базе данных.
- Чтобы пометить свойство как файл, нужно:
- В
rules()
модели назначить этому свойству валидаторDFileValidator
. - В форме переименовать инпут для этого свойства, дописав к нему
'_file'
.
- В
- Методы
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.
Заключение
Итак, вот что мы имеем на выходе:
- Централизованное управление загрузкой и хранением файлов
- Удобный интерфейс для создания в моделях свойств-файлов
- Высокоуровневую генерацию ссылок на скачивание файлов и тэгов IMG
Идею можно развить. Например, практически все WYCIWYG — редакторы предлагают интерфейс для загрузки файлов и изображений. Для этого требуется лишь указать адрес загрузки. Обработчик загрузки можно включить в контроллер FileController
. Но как тогда публиковать миниатюры?
Или еще, можно использовать предложения, описанные в статьях Безопасная загрузка изображений на сервер. Часть первая и Безопасная загрузка изображений на сервер. Часть вторая. Можно дополнить упомянутое выше расширение upload. Можно перенести функционал в поведения.
Одним словом, считать предложенное решение готовым пока рано. Но если описанная идея окажется полезной, готов довести работу до конца и опубликовать соответствующее расширение. Спасибо всем, кто дочитал!
Автор: DekaWeb