Tránh những gánh nặng trong việc xử lý file uploads

Xử lý file uploads là một công việc khá nhàm chán. Về mặt kỹ thuật, nó là một công việc tương đối đơn giản, các file được gửi đi bằng một POST request và hiện diện bên server dưới dạng biến toàn cục - $_FILES super global. Framework mà bạn lựa chọn trên thực tế còn có thể cung cấp một cách thuận tiện hơn để xử lý những file này, hầu hết dựa trên Symfony's UploadedFile. Tuy nhiên trên thực tế mọi việc không hề đơn giản như vậy, bạn sẽ phải thay đổi một số giá trị cấu hình của PHP như post_max_sizeupload_max_filesize, việc này phức tạp hóa quá trình thiết lập và triển khai hạ tầng của bạn. Việc xử lý những file upload lớn cũng dẫn đến việc xử lý I/O ở mức độ cao và tiêu tốn băng thông, nó đồng thời cũng làm cho các web servers phải hoạt động với tần suất cao hơn và nhiều khả năng bạn sẽ phải mất nhiều chi phí hơn.

Hầu hết chúng ta đều biết đến Amazone S3, một dịch vụ lưu trữ đám mây được thiết kế để lưu trữ một lượng không giới hạn các loại dữ liệu với tính dư thừa và sẵn sàng cao. Trong hầu hết các trường hợp, việc sử dụng S3 không đòi hỏi quá nhiều kiến thức cũng như việc nghiên cứu (vì mọi thứ khá rõ ràng và dễ hiểu - no-brainer), nhưng phần lớn các nhà phát triển đều chuyển các tệp được tải lên bởi người dùng sang S3 sau khi chúng được tiếp nhận bởi server. Điều này là không cần thiết, trình duyệt web của người dùng có thể gửi file trực tiếp đến một S3 bucket. Bạn cũng không cần phải public bucket ra bên ngoài.

Cách làm trên mang lại hai lợi ích riêng biết:

  • Bạn không cần phải phức tạp hóa cấu hình của server để xử lý các file upload.
  • Người dùng của bạn cũng sẽ có những trải nghiệm tốt hơn khi tải tệp trực tiếp lên S3 thay vì "proxying" qua web server của bạn.

Generating The Upload Request

Với sự giúp đỡ aws-sdk-php package (phiên bản 3.18.14 tại thời điểm của bài viết này) việc cài đặt trở lên khá đơn giản:

// Những options này chỉ định bucket mà tệp sẽ được upload lên cũng như giá trị prefix cho object key (used for matching).
// Trong trường hợp này, key có thể nhận bất kỳ giá trị nào, và sẽ giả định là tên của tệp đang được tải lên.
$options = [
    ['bucket' => 'bucket-name'],
    ['starts-with', '$key', '']
];

$postObject = new PostObjectV4(
    $this->client,  // Một thể hiện của Aws\S3\S3Client.
    'bucket-name', // Bucket file sẽ được tải lên.
    [], // Những form inputs bổ sung, ở đây chúng ta sẽ để trống.
    $options,
    '+1 minute' // Thơi gian mà phía client phải bắt đầu thực hiện việc upload file.
);

$formAttributes = $postObject->getFormAttributes();
$formData = $postObject->getFormInputs();

Đoạn logic ngắn phía trên cung cấp cho bạn một tập các form attributesform inputs được sử dụng để thiết lập upload request đến S3. Biến $formAttributes sẽ chứa một action, một method và một ectype được sử dụng để gán cho các attribute của HTML form. Biến $formData là một mảng chứa các form inputs sẽ được gửi cùng POST request đến S3.

Trình duyệt của người dùng có thể bắt đầu tải một file đến URL chưa bên trong $formAttributes['action'] và tệp đó sẽ được tải trực tiếp đến một S3 bucket mà không cần thông qua server của bạn. Việc đặt một khoảng thời gian giới hạn hợp ký cho presigned request là rất quan trọng. Như trọng logic phía trên, việc chỉ định +1 minute có nghĩa là người dùng của bạn sẽ có một phút để bắt đầu việc gửi tệp, nếu tệp đó mất 30 phút để tải lên, kết nối của người dùng sẽ không bị đóng lại.

Tuy nhiên, có một số thiết lập cho bucket mà bạn cần phải thực hiện. Do một vấn đề khá cổ điển đó là CORS (Cross-origin Resource Sharing), người dùng của bạn sẽ không được phép truy cập đến các file upload trong bucket một cách trực tiếp do họ thực hiện việc đó từ một domain khác với domain mà các file upload được lưu trữ. Thay đổi vấn đề này khá đơn giản. Bên trong các thuộc tính của bucket trong AWS console, trong mục "Permissions", click vào nút "Edit CORS Configuration".

AWS CORS Configuration

Sử dụng cấu hình dưới đây và lưu thông tin của bucket sau khi đã update.

<?xml version="1.0" encoding="UTF-8"?>  
<CORSConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">  
    <CORSRule>
        <AllowedOrigin>*</AllowedOrigin>
        <AllowedMethod>HEAD</AllowedMethod>
        <AllowedMethod>PUT</AllowedMethod>
        <AllowedMethod>POST</AllowedMethod>
        <AllowedHeader>*</AllowedHeader>
    </CORSRule>
</CORSConfiguration> 

Trình duyệt của người dùng sẽ bắt đầu bằng việc gửi một OPTIONS request đến một presigned URL và theo sau đó là một POST request.

Typing this into Laravel

Như các bạn đã biết, tôi là một người sử dụng Laravel Framework. Trên thực tế, tôi đã cài đặt những công việc trên trên trong một ứng dụng Laravel, tôi sẽ hướng dẫn các bạn thực hiện điều đó:

Do chúng ta sẽ sử dụng AWS SKD cho PHP, chúng ta cần cài đặt package đó cho Laravel project.

composer require aws/aws-sdk-php

Trong khi khởi tạo S3 client chúng ta cần cung cấp một số giá trị cấu hình, chúng ta sẽ lưu những giá trị đó bên trong file .env (chúng ta cũng cần thêm những giá trị mẫu vào file .env.example để những người phát triển khác có thể biết và định nghĩa giá trị thực cho riêng họ).

S3_KEY=your-key  
S3_SECRET=your-secret  
S3_REGION=your-bucket-region  
S3_BUCKET=your-bucket  

Tất nhiên, bạn cũng nên tạo một file configuration cho S3, và file này sẽ được sử dụng để chứa reference đến các giá trị lưu trữ bên trong file .env. Laravel đã cung cấp sẵn cho chúng ta một file config cho các services bên thứ ba - services.php, và file confige này cũng đã chứa sẵn template cho các cấu hình liên quan đến S3, chúng ta sẽ chỉnh sửa sao cho phù hợp với những giá trị mà chúng ta đã định nghĩa trong file .env. Mở file config services.php và bổ sung thêm những trường sau:

's3' => [  
    'key'    => env('S3_KEY'),
    'secret' => env('S3_SECRET'),
    'region' => env('S3_REGION'),
    'bucket' => env('S3_BUCKET')
],

Tuyệt vời! Chúng ta đã có những điều kiện cần thiết bên trong Laravel để khởi tạo một S3 client với cấu hình chính xác và bắt đầu các bước tiếp theo. Việc chúng ta cần làm tiếp theo là tạo một service provider với nhiệm vụ chính là khởi tạo S3 client và bind nó với IoC container; điều này cho phép chúng ta sử dụng dependency injection để lấy ra một instance của S3 client bất cứ khi nào cần thiết mà không cần phải lặp lại quá trình khởi tạo nhiều lần.

Tạo một service provider app/Providers/S3ServiceProvider.php và paste logic sau vào:

<?php

namespace App\Providers;

use Aws\S3\S3Client;  
use Illuminate\Support\ServiceProvider;

class S3ServiceProvider extends ServiceProvider  
{
    /**
     * Bind the S3Client to the service container.
     *
     * @return void
     */
    public function boot()
    {
        $this->app->bind(S3Client::class, function() {
            return new S3Client([
                'credentials' => [
                    'key'    => config('services.s3.key'),
                    'secret' => config('services.s3.secret')
                ],
                'region' => config('services.s3.region'),
                'version' => 'latest',
            ]);
        });
    }

    public function register() { }
}

Nếu bạn không quen thuộc với cách mà các service providers hoạt động, những gì chúng ta thực hiện phía trên sẽ cho Laravel biết rằng, mỗi khi chúng ta typehint một dependency với kiểu là Aws\S3\S3Client, Laravel sẽ check qua một loạt các service providers, nếu chúng ta đã định nghĩa một binding trong container như trên, Laravel sẽ khởi tạo một instance mới của S3 client sử dụng anonymous function (function này sẽ trả về một instance của S3 client với những giá trị cấu hình được lấy ra từ file config phía trên). Để Laravel biết đến binding phía trên, bạn cần cho phép framework load service provider mà chúng ta vừa tạo trong quá trình booting up, chúng ta thực hiện việc đó bằng cách thêm tên (FQN) của service providers vào providers array bên trong file configuration config/app.php

App\Providers\S3ServiceProvider::class,

Bạn cũng sẽ cần định nghĩa một route mà client sẽ sử dụng để xử lý presigned request.

$router->get('/upload/signed', '[email protected]');

Và cuối cùng chúng ta cần tạo UploadController

<?php

namespace App\Http\Controllers;

use Aws\S3\PostObjectV4;  
use Aws\S3\S3Client;

class UploadController extends Controller  
{
    protected $client;

    public function __construct(S3Client $client)
    {
        $this->client = $client;
    }

    /**
     * Generate a presigned upload request.
     *
     * @return \Illuminate\Http\JsonResponse
     */
    public function signed()
    {
        $options = [
            ['bucket' => config('services.s3.bucket')],
            ['starts-with', '$key', '']
        ];

        $postObject = new PostObjectV4(
            $this->client,
            config('services.s3.bucket'),
            [],
            $options,
            '+1 minute'
        );

        return response()->json([
            'attributes' => $postObject->getFormAttributes(),
            'additionalData' => $postObject->getFormInputs()
        ]);
    }
}

The JavaScript

Dự án mà tôi đã viết sử dụng một thư viện khá phổ biến DropzoneJS để xử lý việc uploading file. Logic sau đây sẽ là một ví dụ về việc configure và cài đặt Dropzone.

var dropzone = new Dropzone('#dropzone', {  
    url: '#',
    method: 'post',
    autoQueue: false,
    autoProcessQueue: false,
    init: function() {
        this.on('addedfile', function(file) {
            fetch('/upload/signed?type='+file.type, {
                method: 'get'
            }).then(function (response) {
                return response.json();
            }).then(function (json) {
                dropzone.options.url = json.attributes.action;
                file.additionalData = json.additionalData;

                dropzone.processFile(file);
            });
        });

        this.on('sending', function(file, xhr, formData) {
            // Add the additional form data from the AWS SDK to the HTTP request.
            for (var field in file.additionalData) {
                formData.append(field, file.additionalData[field]);
            }
        });

        this.on('success', function(file) {
            // The file was uploaded successfully. Here you might want to trigger an action, or send another AJAX
            // request that will tell the server that the upload was successful. It's up to you.
        });
    }
});

Trong logic phía trên, chúng ta chỉ định Dropzone lắng nghe các files đang được thêm vào upload queue (bằng việc drag-and-drop hoặc việc chọn file thông thường). Khi một file được thêm vào, một AJAX request sẽ được khởi tạo để lấy ra thông tin về signed request từ server và configuration của Dropzone sẽ được cập nhật để upload file đến URL chính xác. Chúng ta cũng gán kèm những dữ liệu form bổ sung được sử dụng khi thực hiện POST request cùng với file đã upload, việc này sẽ được thực hiện bằng cách sử dụng sending event của Dropzone. Trong sending event, chúng ta sẽ thêm những dữ liệu form cần thiết. Dropzone sẽ thực hiện việc upload file lên S3.

Bạn có thể thấy trong logic phía trên chúng ta còn sử dụng success event của Dropzone. Bạn không nhất thiết phải sử dụng nó, nhưng trong trường hợp của tôi, event này sẽ hữu ích cho việc thông báo quá trình upload file lên S3 đã hoàn tất, do tôi cần thực hiện một số việc sau thời điểm đó.

References