Upload Progress w PHP

Tomasz Hanc
December 16, 2015 | Software development

Jak to działa?

  1. Wysyłamy request HTTP z nagłówkiem Content-Type: multipart/form-data.
  2. PHP automatycznie zapisuje w sesji informacje o postępie w przesyłaniu pliku.
  3. W trakcie przetwarzania requestu odpytujemy serwer o jego postęp i wyświetlamy dane użytkownikowi.

Konfiguracja – plik php.ini

session.upload_progress.enabled = On

session.upload_progress.cleanup = Off

session.upload_progress.freq = "1%"
session.upload_progress.min_freq = 1

session.upload_progress.prefix = "upload_progress_"

Po dokonaniu zmian w pliku php.ini nie zapomnijmy o ponownym uruchomieniu PHP-FPM.

Formularz HTML

Jedyną różnicą w porównaniu do standardowego formularza jest ukryty input w linijce 2.

<form action="upload.php" method="POST" enctype="multipart/form-data">
    <input type="hidden" name="UPLOAD_PROGRESS" value="speednet">
    <input type="file" name="file">
    <input type="submit">
</form>

Nazwa tego ukrytego inputa jest dokładnie taka sama jak wartość session.upload_progress.name. Natomiast wartość inputa jest już dowolna, ale powinniśmy ją zapamiętać, bo będzie później nam potrzebna. W obsłudze formularza po stronie serwera nic się nie zmienia. PHP sam zaopiekuje się zapisem potrzebnych informacji o postępie ładowania pliku.

Upload – uzyskanie informacji o postępie

W trakcie wysyłania requesta z plikiem możemy zapytać serwer o jego postęp. Jednak, żeby to zrobić musimy najpierw przygotować akcję, która zwróci nam pożądaną informację. Dla uproszczenia nie będziemy używać do tego żadnego frameworka:

session_start();

require __DIR__ . '/UploadProgress.php';

$uploadProgress = new SpeednetUploadProgress();

header('Content-type: text/json');
echo json_encode([
    'progress' =&gt; $uploadProgress-&gt;progress('speednet')
]);

Prosty skrypt, który deleguje całą logikę do innego obiektu, z którego chcemy otrzymać postęp w przesyle danych dla akcji speednet. Skąd wiemy o jaką akcję zapytać? Jest to wartość z ukrytego pola (UPLOAD_PROGRESS), która została wysłana wraz z plikiem. Spójrzmy teraz, na najważniejszą część – pobieranie informacji o postępie:

final class UploadProgress
{
    /** @var string */
    private $prefix;

    /** @var string */
    private $name;

    public function __construct()
    {
        $this-&gt;prefix = ini_get("session.upload_progress.prefix");
        $this-&gt;name = ini_get("session.upload_progress.name");
    }

    /**
     * @param string $uploadName
     * @return int
     */
    public function progress($uploadName)
    {
        $key = $this-&gt;getKey($uploadName);

        if (! isset($_SESSION[$key])) {
            return 100;
        }

        $progress = $_SESSION[$key]['bytes_processed'] / $_SESSION[$key]['content_length'];

        return round($progress * 100, 2);
    }

    /**
     * @param string $uploadName
     * @return string
     */
    private function getKey($uploadName)
    {
        return sprintf('%s%s', $this-&gt;prefix, $uploadName);
    }
}

Metoda UploadProgress::progress() pobiera dane z sesji. Klucz, pod którym znajdują się interesujące nas dane, jest zbudowany ze złączenia dwóch stringów: pierwszy to wartość dla session.upload_progress.prefix, który określiliśmy w pliku php.ini, a drugi to wartość dla ukrytego parametru formularza. W naszym przypadku klucz, pod którym powinniśmy szukać danych o postępie uploadu to upload_progress_speednet.
Po pobraniu danych wyliczany zostaje procentowy postęp i zwracany jest do klienta.

Nie działa?

Czasem można napotkać problemy, które ciężko rozwiązać – szczególnie jeżeli w sesji nie ma klucza, pod którym powinny być dane o postępie uploadu. Warto wtedy sprawdzić dwie rzeczy:

  • Czy session.upload_progress.cleanup jest wyłączone? Jeżeli jest włączone to po zakończeniu uploadu dane z sesji zostaną wyczyszczone. Przy małych plikach często nie zdążymy odpytać serwera o postęp uploadu przed jego ukończeniem. Dlatego dla środowiska developerskiego zalecam wyłączenie czyszczenia sesji po ukończeniu uploadu.
  • Czy mamy aktywną sesję? Mechanizm ten wykorzystuje sesje, dlatego bardzo ważne jest, by przy obu requestach korzystać z tej samej sesji.

Upload progress w Symfony

Otrzymanie danych o uploadzie w Symfony korzystając z obiektu Session jest niestety niemożliwe – przynajmniej bez pisania różnego typu obejść. Sesja w Symfony wykorzystuje tzw. Bagi przez co nie ma dostępu do danych zdefiniowanych bezpośrednio w zmiennej globalnej $_SESSION.

Wskazówki dla REST API

By opisywany powyżej mechanizm zadziałał wymagana jest sesja. W przypadku REST API sesji najczęściej nie mamy, gdyż takie API powinno być bezstanowe. Pomimo tego i tak musimy zapewnić jakiś mechanizm umożliwiający identyfikację użytkownika. Najczęściej dokonuje się tego poprzez token, który umieszczamy w nagłówku. Taki token możemy wykorzystać do utworzenia sesji tylko w tej jednej wyjątkowej sytuacji – przy uploadzie. Pamiętając przy tym, by ustawić ID takiej sesji na wartość tokena. Następnie po stronie klienta musimy upewnić się, że request z wysyłką pliku zawiera COOKIE z ID sesji – i to wystarczy. Możemy teraz dostosować UploadProgress, by działał również w sytuacji, gdzie nie mamy sesji, a my przekażemy ID tokena, by otrzymać postęp uploadu:

final class UploadProgress
{
    /** @var string */
    private $prefix;

    /** @var string */
    private $name;

    public function __construct()
    {
        $this-&gt;prefix = ini_get("session.upload_progress.prefix");
        $this-&gt;name = ini_get("session.upload_progress.name");
    }

    /**
     * @param string $uploadName
     * @param string $token
     * @return int
     */
    public function progress($uploadName, $token = null)
    {
        $this-&gt;setSessionId($token);

        $key = $this-&gt;getKey($uploadName);

        if (! isset($_SESSION[$key])) {
            return 100;
        }

        $progress = $_SESSION[$key]['bytes_processed'] / $_SESSION[$key]['content_length'];

        return round($progress * 100, 2);
    }

    /**
     * @param string $uploadName
     * @return string
     */
    private function getKey($uploadName)
    {
        return sprintf('%s%s', $this-&gt;prefix, $uploadName);
    }

    /**
     * @param string $token
     */
    private function setSessionId($token = null)
    {
        if ($token &amp;&amp; ! $this-&gt;isSessionStarted()) {
            session_id($token);
            session_start();
        }
    }

    /**
     * @return bool
     */
    private function isSessionStarted()
    {
        if (PHP_SESSION_ACTIVE === session_status()) {
            return true;
        }

        return false;
    }
}

Zdaję sobie sprawę, że najwierniejszym wyznawcom koncepcji REST może się to nie spodobać. REST powinien być bezstanowy, a tutaj troszkę temu zaprzeczamy. Jest to oczywiście prawda, ale czasem trzeba wiedzieć kiedy pójść na kompromis. Według mnie jest to właśnie taka sytuacja. Jeżeli kompromis nie wchodzi w grę, to inną opcją może być np. wydzielenie tej funkcjonalności do mikroserwisu, który będzie obsługiwał tylko upload. Wtedy nasze API pozostaje czyste.

Przykładowy kod…

Opisany powyżej skrypt testujący progress bar podczas wgrywania pliku na serwer dostępny jest na naszym firmowym koncie GitHub.

session.upload_progress.name = "UPLOAD_PROGRESS"

Chcesz poznać nas lepiej? Dowiedz się, co nas wyróżnia.