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

в 12:15, , рубрики: 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. Можно перенести функционал в поведения.

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

Автор: DekaWeb

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


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