Загрузка файлов в Symfony
Загрузка файлов в Symfony идейно ничем не отличается от других php-платформ, но все же имеет свои особенности вследствие наличия дополнительных инструментов, предоставляемых фреймворком.
Вначале стоит отметить, что существуют готовые решения, решающие поставленную задачу. Настоятельно рекомендую ознакомится с ними, и только после этого, если решите, что они вам не подходят, делать свою реализацию.
В этой заметке попытаемся показать возможные пути решения поставленной задачи, как с помощью готовых решений (VichUploaderBundle, IphpFileStoreBundle), так и с помощью собственной реализации (в контроллерах Symfony и админ классах SonataAdminBundle).
1. VichUploaderBundle
Использование предельно простое.
Установка:
composer require vich/uploader-bundle
Добавляем в AppKernel:
// app/AppKernel.php
class AppKernel extends Kernel
{
public function registerBundles()
{
$bundles = [
// ...
new Vich\UploaderBundle\VichUploaderBundle(),
// ...
];
}
}
Базовая конфигурация:
# app/config/config.yml
vich_uploader:
db_driver: orm # or mongodb or propel or phpcr
Дальше, нужно сконфигурировать типы загружаемых файлов. Например, сконфигурируем загрузку аватаров пользователей:
# app/config/config.yml
vich_uploader:
db_driver: orm
mappings:
user_avatar:
uri_prefix: /images/avatars
upload_destination: '%kernel.root_dir%/../web/images/avatars'
После этого, добавляем необходимые поля в ентити:
<?php
namespace Acme\DemoBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\HttpFoundation\File\File;
use Vich\UploaderBundle\Mapping\Annotation as Vich;
use File|\Symfony\Component\HttpFoundation\File\UploadedFile
/**
* @ORM\Entity
* @Vich\Uploadable
*/
class User
{
// ..... entity fields
/**
* NOTE: This is not a mapped field of entity metadata, just a simple property.
*
* @Vich\UploadableField(mapping="user_avatar", fileNameProperty="avatarFileName")
*
* @var File
*/
private $avatarFile;
/**
* @ORM\Column(type="string", length=255)
*
* @var string
*/
private $avatarFileName;
/**
* @param File|UploadedFile $avatarFile
*
* @return User
*/
public function setAvatarFile(File $avatarFile = null)
{
$this->avatarFile = $avatarFile;
return $this;
}
/**
* @return File|null
*/
public function getAvatarFile()
{
return $this->avatarFile;
}
/**
* @param string $avatarFileName
*
* @return User
*/
public function setAvatarFileName($avatarFileName)
{
$this->avatarFileName = $avatarFileName;
return $this;
}
/**
* @return string|null
*/
public function getAvatarFileName()
{
return $this->avatarFileName;
}
}
Дальше создаем форму и используем один из форм тайпов https://github.com/dustin10/VichUploaderBundle/tree/master/Form/Type
Например:
<?php
namespace Acme\DemoBundle\Form\Type;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Vich\UploaderBundle\Form\Type\VichImageType;
class UserType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options = [])
{
$builder
// ... other fields
->add('avatarFile', VichImageType::class, [
'required' => true,
])
;
}
}
2. IphpFileStoreBundle
Форк предыдущего бандла. Из ключевых отличий - улучшена интеграция с SonataAdminBundle, а также расширенная сохраняемая информация о файле (в поле хранится массив метаинформации, а не путь до файла).
Использование очень похоже на VichUploaderBundle, более подробно в документации.
3. Symfony
Если по какой-то причине вам не подошли предыдущие бандлы, можно реализовать загрузку самому.
Покажем на примере того же User'а:
1) Ентити:
<?php
// src/AppBundle/Entity/User.php
namespace AppBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
class User
{
// ...
/**
* @ORM\Column(type="string")
*
* @Assert\NotBlank()
* @Assert\Image()
*/
private $avatar;
public function getAvatar()
{
return $this->avatar;
}
public function setAvatar($avatar)
{
$this->avatar = $avatar;
return $this;
}
}
2) Форма:
<?php
// src/AppBundle/Form/Type/UserType.php
namespace AppBundle\Form\Type;
use AppBundle\Entity\User;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Form\Extension\Core\Type\FileType;
class UserType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
// ...
->add('avatar', FileType::class)
// ...
;
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults(array(
'data_class' => User::class,
));
}
}
Дальше рендерим форму. Самое интересное - обработка данных. Для упрощения, покажем, как это сделать в контроллере (пример частично позаимствован с документации Symfony). В идеале, логика обработки загрузки файла должна быть вынесена в отдельный сервис. Так же можно вынести логику в ивенты Доктрины.
<?php
// src/AppBundle/Controller/UserController.php
namespace AppBundle\Controller;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request;
use AppBundle\Entity\User;
use AppBundle\Form\Type\UserType;
class UserController extends Controller
{
/**
* @Route("/user/register", name="app_user_new")
*/
public function newAction(Request $request)
{
$form = $this->createForm(UserType::class);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$user = $form->getData();
// $file stores the uploaded file
/** @var Symfony\Component\HttpFoundation\File\UploadedFile $file */
$file = $user->getAvatar();
// Generate a unique name for the file before saving it
$fileName = md5(uniqid()).'.'.$file->guessExtension();
// Move the file to the directory where user avatars are stored
$file->move(
// This parameter should be configured
$this->getParameter('avatars_directory'),
$fileName
);
// Update the 'avatar' property to store file name
// instead of its contents
$user->setAvatar($fileName);
// ... persist the $user variable or any other work and redirect
return $this->redirect($this->generateUrl('homepage'));
}
return $this->render('user/new.html.twig', [
'form' => $form->createView(),
]);
}
}
4. SonataAdminBundle
Напоследок хотелось бы показать пример загрузки файла в SonataAdminBundle.
Простейший вариант - перенести логику с предыдущего примера в prePersist/preUpdate хуки - напрашивается сам собой. (Если вы все же решили сделать этот вариант и не поленились выполнить последнюю рекомендацию и перенести логику обработки загрузки в отдельный сервис - это очень облегчит задачу). (На самом деле, я немного лукавлю, можно еще проще: если вынести логику загрузки в ивенты Доктрины, то никакой дополнительной обработки в хуках Sonata вообще не нужно добавлять.)
Покажем немного более "продвинутый" вариант загрузки изображений с помощью ckeditor и библиотекой изображений. (Например, для вставки в заметки, а не просто как поле ентити).
Для этих целей создадим Контроллер для Sonata:
<?php
namespace Harentius\BlogBundle\Controller;
use Sonata\AdminBundle\Controller\CRUDController as BaseCRUDController;
use Symfony\Component\Finder\Finder;
use Symfony\Component\Finder\SplFileInfo;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
class CRUDController extends BaseCRUDController
{
/**
* @param Request $request
* @return Response
*/
public function uploadAction(Request $request)
{
$manager = $this->get('harentius_blog.assets.manager');
$assetFile = $manager->handleUpload($request->files->get('upload'));
return $this->render('HarentiusBlogBundle:Admin:ck_upload.html.twig', [
'func_num' => $request->query->get('CKEditorFuncNum'),
'uri' => $assetFile->getUri(),
]);
}
/**
* @param Request $request
* @param string $type
* @return Response
*/
public function browseAction(Request $request, $type = 'image')
{
if (!in_array($type, ['image', 'audio'])) {
throw new \InvalidArgumentException(sprintf("Unknown files type '%s", $type));
}
$resolver = $this->get('harentius_blog.assets.resolver');
$directory = $resolver->assetPath($type);
$files = [];
$finder = new Finder();
$finder
->files()
->in($directory)
->ignoreDotFiles(true)
;
/** @var SplFileInfo $file */
foreach ($finder as $file) {
$uri = $resolver->pathToUri($file->getPathname());
if ($uri) {
$files[$uri] = pathinfo($uri, PATHINFO_BASENAME);
}
}
return $this->render(sprintf("HarentiusBlogBundle:Admin:ck_browse_%ss.html.twig", $type), [
'func_num' => $request->query->get('CKEditorFuncNum'),
'files' => $files,
]);
}
}
Первый метод использует сервис для загрузки файлов (посмотреть на реализацию), а второй формирует список файлов для отображения в ckeditor.
Темплейты, используемые этим контроллером:
{# src/Resources/views/Admin/ck_upload.html.twig #}
<script type="text/javascript">window.parent.CKEDITOR.tools.callFunction("{{ func_num }}", "{{ uri }}", "{{ message|default('') }}");</script>
{# src/Resources/views/Admin/ck_browse_images.html.twig #}
{% for uri, name in files %}
<div class="clickable pull-left" onclick="ckEditorChoose('{{ func_num }}', '{{ uri }}')">
<img width="300px" src="{{ uri }}" />
</div>
{% endfor %}
<script>
function ckEditorChoose(funcNum, uri) {
window.opener.CKEDITOR.tools.callFunction(funcNum, uri);
window.close();
}
</script>
Инициализация ckeditor:
{% extends 'SonataAdminBundle:CRUD:edit.html.twig' %}
{% block javascripts %}
{{ parent() }}
<script src="{{ asset('assets/vendor/ckeditor/ckeditor.js') }}"></script>
<script src="{{ asset('assets/vendor/ckeditor/adapters/jquery.js') }}"></script>
<script>
'use strict';
jQuery(function ($) {
{# extra plugins, probably, you don't need all of them #}
CKEDITOR.plugins.addExternal("youtube", "{{ asset('assets/vendor/ckeditor-youtube-plugin/youtube/plugin.js') }}");
CKEDITOR.plugins.addExternal("wpmore", "{{ asset('assets/vendor/ckeditor-more-plugin/wpmore/plugin.js') }}");
CKEDITOR.plugins.addExternal("audio", "{{ asset('assets/vendor/ckeditor-audio-plugin/audio/plugin.js') }}");
CKEDITOR.plugins.addExternal("wordcount", "{{ asset('assets/vendor/ckeditor-wordcount-plugin/wordcount/plugin.js') }}");
CKEDITOR.config.allowedContent = true;
$('.blog-page-edit').ckeditor({
height: 700,
extraPlugins: "youtube,justify,div,wpmore,codesnippet,audio,image2,wordcount",
removePlugins: 'image,forms',
toolbarGroups: [
{ name: "basicstyles", groups: [ "basicstyles", "cleanup" ] },
{ name: "editing", groups: [ "find", "selection" ] },
{ name: "align" },
{ name: "paragraph", groups: [ "list", "indent", "blocks" ] },
{ name: "links" },
{ name: "insert" },
{ name: "styles" },
{ name: "colors" },
{ name: "forms" },
{ name: "clipboard", groups: [ "clipboard", "undo" ] },
{ name: "document", groups: [ "mode", "document", "doctools" ] },
{ name: "tools" },
{ name: "others" }
],
wordcount: {
showParagraphs: false
},
{# Your point of interest: #}
filebrowserUploadUrl: "{{ admin.generateUrl('upload') }}",
filebrowserBrowseUrl: "{{ admin.generateUrl('browse', {'type': 'image'}) }}",
filebrowserAudioBrowseUrl: "{{ admin.generateUrl('browse', {'type': 'audio'}) }}"
});
});
</script>
{% endblock %}
Скорее всего, вам не нужна вся конфигурация, но для видения полной картины привел полный пример.
Темплейт можно перебить при регистрации админ сервиса:
services:
...
harentius_blog.admin.article:
class: Harentius\BlogBundle\Admin\ArticleAdmin
tags:
- { name: sonata.admin, manager_type: orm, group: Content, label: Articles, show_in_dashboard: true }
arguments:
- null
- Harentius\BlogBundle\Entity\Article
- HarentiusBlogBundle:CRUD
calls:
- [ setTranslationDomain, [ 'HarentiusBlogBundle' ] ]
- [ setTemplate, [ 'edit', 'HarentiusBlogBundle:Admin/CRUD:edit_post.html.twig'] ]
- [ setEntityManager, [ '@doctrine.orm.entity_manager' ] ]
....