如何上傳檔案
注意
除了自行處理檔案上傳,您也可以考慮使用 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
而不是 binary
或 blob
,因為它僅儲存 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,
]);
}
}
在上述控制器的程式碼中,有一些重要事項需要考慮
- 在 Symfony 應用程式中,上傳的檔案是 UploadedFile 類別的物件。此類別提供處理上傳檔案時最常見操作的方法;
- 一個眾所周知的安全性最佳實務是永遠不要信任使用者提供的輸入。這也適用於您的訪客上傳的檔案。
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 事件、監聽器和訂閱器。