跳到内容

如何上传文件

编辑此页

注意

除了自行处理文件上传,您可以考虑使用 VichUploaderBundle 社区扩展包。此扩展包提供了所有常用操作(例如文件重命名、保存和删除),并与 Doctrine ORM、MongoDB ODM、PHPCR ODM 和 Propel 紧密集成。

假设您的应用程序中有一个 Product 实体,并且您想为每个产品添加一个 PDF 宣传册。为此,请在 Product 实体中添加一个名为 brochureFilename 的新属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// src/Entity/Product.php
namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

class Product
{
    // ...

    #[ORM\Column(type: 'string')]
    private string $brochureFilename;

    public function getBrochureFilename(): string
    {
        return $this->brochureFilename;
    }

    public function setBrochureFilename(string $brochureFilename): self
    {
        $this->brochureFilename = $brochureFilename;

        return $this;
    }
}

请注意,brochureFilename 列的类型是 string 而不是 binaryblob,因为它只存储 PDF 文件名而不是文件内容。

下一步是将一个新字段添加到管理 Product 实体的表单中。这必须是一个 FileType 字段,以便浏览器可以显示文件上传小部件。使其工作的技巧是将表单字段添加为“未映射”,这样 Symfony 就不会尝试从相关实体获取/设置其值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// src/Form/ProductType.php
namespace App\Form;

use App\Entity\Product;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\File;

class ProductType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            // ...
            ->add('brochure', FileType::class, [
                'label' => 'Brochure (PDF file)',

                // unmapped means that this field is not associated to any entity property
                'mapped' => false,

                // make it optional so you don't have to re-upload the PDF file
                // every time you edit the Product details
                'required' => false,

                // unmapped fields can't define their validation using attributes
                // in the associated entity, so you can use the PHP constraint classes
                'constraints' => [
                    new File([
                        'maxSize' => '1024k',
                        'mimeTypes' => [
                            'application/pdf',
                            'application/x-pdf',
                        ],
                        'mimeTypesMessage' => 'Please upload a valid PDF document',
                    ])
                ],
            ])
            // ...
        ;
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class' => Product::class,
        ]);
    }
}

现在,更新渲染表单的模板以显示新的 brochure 字段(要添加的确切模板代码取决于您的应用程序用于自定义表单渲染的方法)

1
2
3
4
5
6
7
8
{# templates/product/new.html.twig #}
<h1>Adding a new product</h1>

{{ form_start(form) }}
    {# ... #}

    {{ form_row(form.brochure) }}
{{ form_end(form) }}

最后,您需要更新处理表单的控制器的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
// src/Controller/ProductController.php
namespace App\Controller;

use App\Entity\Product;
use App\Form\ProductType;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\File\Exception\FileException;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\String\Slugger\SluggerInterface;

class ProductController extends AbstractController
{
    #[Route('/product/new', name: 'app_product_new')]
    public function new(
        Request $request,
        SluggerInterface $slugger,
        #[Autowire('%kernel.project_dir%/public/uploads/brochures')] string $brochuresDirectory
    ): Response
    {
        $product = new Product();
        $form = $this->createForm(ProductType::class, $product);
        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            /** @var UploadedFile $brochureFile */
            $brochureFile = $form->get('brochure')->getData();

            // this condition is needed because the 'brochure' field is not required
            // so the PDF file must be processed only when a file is uploaded
            if ($brochureFile) {
                $originalFilename = pathinfo($brochureFile->getClientOriginalName(), PATHINFO_FILENAME);
                // this is needed to safely include the file name as part of the URL
                $safeFilename = $slugger->slug($originalFilename);
                $newFilename = $safeFilename.'-'.uniqid().'.'.$brochureFile->guessExtension();

                // Move the file to the directory where brochures are stored
                try {
                    $brochureFile->move($brochuresDirectory, $newFilename);
                } catch (FileException $e) {
                    // ... handle exception if something happens during file upload
                }

                // updates the 'brochureFilename' property to store the PDF file name
                // instead of its contents
                $product->setBrochureFilename($newFilename);
            }

            // ... persist the $product variable or any other work

            return $this->redirectToRoute('app_product_list');
        }

        return $this->render('product/new.html.twig', [
            'form' => $form,
        ]);
    }
}

在上述控制器的代码中,有一些重要事项需要考虑

  1. 在 Symfony 应用程序中,上传的文件是 UploadedFile 类的对象。此类提供了处理上传文件时最常用操作的方法;
  2. 一个众所周知的安全最佳实践是永远不要信任用户提供的输入。这也适用于您的访问者上传的文件。UploadedFile 类提供了获取原始文件扩展名 (getClientOriginalExtension())、原始文件大小 (getSize())、原始文件名 (getClientOriginalName()) 和原始文件路径 (getClientOriginalPath()) 的方法。但是,它们被认为是不安全的,因为恶意用户可能会篡改该信息。因此,最好始终生成唯一的名称,并使用 guessExtension() 方法让 Symfony 根据文件 MIME 类型猜测正确的扩展名;

注意

如果上传了目录,则 getClientOriginalPath() 将包含浏览器提供的 webkitRelativePath。否则,此值将与 getClientOriginalName() 相同。

7.1

getClientOriginalPath() 方法在 Symfony 7.1 中引入。

您可以使用以下代码链接到产品的 PDF 宣传册

1
<a href="{{ asset('uploads/brochures/' ~ product.brochureFilename) }}">View brochure (PDF)</a>

提示

当创建用于编辑已持久化项的表单时,文件表单类型仍然需要 File 实例。由于持久化实体现在仅包含相对文件路径,因此您首先必须将配置的上传路径与存储的文件名连接起来,并创建一个新的 File

1
2
3
4
5
6
use Symfony\Component\HttpFoundation\File\File;
// ...

$product->setBrochureFilename(
    new File($brochuresDirectory.DIRECTORY_SEPARATOR.$product->getBrochureFilename())
);

创建上传服务

为了避免控制器中的逻辑过多,使其变得臃肿,您可以将上传逻辑提取到单独的服务中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// src/Service/FileUploader.php
namespace App\Service;

use Symfony\Component\HttpFoundation\File\Exception\FileException;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\String\Slugger\SluggerInterface;

class FileUploader
{
    public function __construct(
        private string $targetDirectory,
        private SluggerInterface $slugger,
    ) {
    }

    public function upload(UploadedFile $file): string
    {
        $originalFilename = pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME);
        $safeFilename = $this->slugger->slug($originalFilename);
        $fileName = $safeFilename.'-'.uniqid().'.'.$file->guessExtension();

        try {
            $file->move($this->getTargetDirectory(), $fileName);
        } catch (FileException $e) {
            // ... handle exception if something happens during file upload
        }

        return $fileName;
    }

    public function getTargetDirectory(): string
    {
        return $this->targetDirectory;
    }
}

提示

除了通用的 FileException 类之外,还有其他异常类用于处理文件上传失败:CannotWriteFileException, ExtensionFileException, FormSizeFileException, IniSizeFileException, NoFileException, NoTmpDirFileException, 和 PartialFileException.

然后,为此类定义一个服务

1
2
3
4
5
6
7
# config/services.yaml
services:
    # ...

    App\Service\FileUploader:
        arguments:
            $targetDirectory: '%brochures_directory%'

现在您可以准备在控制器中使用此服务了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// src/Controller/ProductController.php
namespace App\Controller;

use App\Service\FileUploader;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

// ...
public function new(Request $request, FileUploader $fileUploader): Response
{
    // ...

    if ($form->isSubmitted() && $form->isValid()) {
        /** @var UploadedFile $brochureFile */
        $brochureFile = $form->get('brochure')->getData();
        if ($brochureFile) {
            $brochureFileName = $fileUploader->upload($brochureFile);
            $product->setBrochureFilename($brochureFileName);
        }

        // ...
    }

    // ...
}

使用 Doctrine 监听器

本文的先前版本解释了如何使用 Doctrine 监听器处理文件上传。但是,不再建议这样做,因为 Doctrine 事件不应用于您的域逻辑。

此外,Doctrine 监听器通常依赖于内部 Doctrine 行为,这在未来版本中可能会发生变化。而且,它们可能会无意中引入性能问题(因为您的监听器持久化实体,这会导致其他实体被更改和持久化)。

作为替代方案,您可以使用 Symfony 事件、监听器和订阅器

本作品(包括代码示例)在 Creative Commons BY-SA 3.0 许可下获得许可。
目录
    版本