Il modo più veloce per servire un file usando PHP


98

Sto cercando di mettere insieme una funzione che riceve un percorso di file, identifica di cosa si tratta, imposta le intestazioni appropriate e lo serve proprio come farebbe Apache.

Il motivo per cui lo faccio è perché ho bisogno di utilizzare PHP per elaborare alcune informazioni sulla richiesta prima di servire il file.

La velocità è fondamentale

virtual () non è un'opzione

Deve funzionare in un ambiente di hosting condiviso in cui l'utente non ha il controllo del server web (Apache / nginx, ecc.)

Ecco cosa ho ottenuto finora:

File::output($path);

<?php
class File {
static function output($path) {
    // Check if the file exists
    if(!File::exists($path)) {
        header('HTTP/1.0 404 Not Found');
        exit();
    }

    // Set the content-type header
    header('Content-Type: '.File::mimeType($path));

    // Handle caching
    $fileModificationTime = gmdate('D, d M Y H:i:s', File::modificationTime($path)).' GMT';
    $headers = getallheaders();
    if(isset($headers['If-Modified-Since']) && $headers['If-Modified-Since'] == $fileModificationTime) {
        header('HTTP/1.1 304 Not Modified');
        exit();
    }
    header('Last-Modified: '.$fileModificationTime);

    // Read the file
    readfile($path);

    exit();
}

static function mimeType($path) {
    preg_match("|\.([a-z0-9]{2,4})$|i", $path, $fileSuffix);

    switch(strtolower($fileSuffix[1])) {
        case 'js' :
            return 'application/x-javascript';
        case 'json' :
            return 'application/json';
        case 'jpg' :
        case 'jpeg' :
        case 'jpe' :
            return 'image/jpg';
        case 'png' :
        case 'gif' :
        case 'bmp' :
        case 'tiff' :
            return 'image/'.strtolower($fileSuffix[1]);
        case 'css' :
            return 'text/css';
        case 'xml' :
            return 'application/xml';
        case 'doc' :
        case 'docx' :
            return 'application/msword';
        case 'xls' :
        case 'xlt' :
        case 'xlm' :
        case 'xld' :
        case 'xla' :
        case 'xlc' :
        case 'xlw' :
        case 'xll' :
            return 'application/vnd.ms-excel';
        case 'ppt' :
        case 'pps' :
            return 'application/vnd.ms-powerpoint';
        case 'rtf' :
            return 'application/rtf';
        case 'pdf' :
            return 'application/pdf';
        case 'html' :
        case 'htm' :
        case 'php' :
            return 'text/html';
        case 'txt' :
            return 'text/plain';
        case 'mpeg' :
        case 'mpg' :
        case 'mpe' :
            return 'video/mpeg';
        case 'mp3' :
            return 'audio/mpeg3';
        case 'wav' :
            return 'audio/wav';
        case 'aiff' :
        case 'aif' :
            return 'audio/aiff';
        case 'avi' :
            return 'video/msvideo';
        case 'wmv' :
            return 'video/x-ms-wmv';
        case 'mov' :
            return 'video/quicktime';
        case 'zip' :
            return 'application/zip';
        case 'tar' :
            return 'application/x-tar';
        case 'swf' :
            return 'application/x-shockwave-flash';
        default :
            if(function_exists('mime_content_type')) {
                $fileSuffix = mime_content_type($path);
            }
            return 'unknown/' . trim($fileSuffix[0], '.');
    }
}
}
?>

10
Perché non lasci che Apache lo faccia? Sarà sempre notevolmente più veloce dell'avvio dell'interprete PHP ...
Billy ONeal

4
Devo elaborare la richiesta e memorizzare alcune informazioni nel database prima di inviare il file.
Kirk Ouimet

3
Posso suggerire un modo per ottenere l'estensione senza i più costosi espressioni regolari: $extension = end(explode(".", $pathToFile))o lo si può fare con substr e strrpos: $extension = substr($pathToFile, strrpos($pathToFile, '.')). Inoltre, come alternativa a mime_content_type(), puoi provare una chiamata di sistema:$mimetype = exec("file -bi '$pathToFile'", $output);
Fanis Hatzidakis

Cosa intendi per veloce ? Tempo di download più veloce?
Alix Axel

Risposte:


140

La mia risposta precedente era parziale e non ben documentata, ecco un aggiornamento con un riepilogo delle soluzioni da essa e da altre nella discussione.

Le soluzioni vengono ordinate dalla migliore alla peggiore, ma anche dalla soluzione che richiede il massimo controllo sul server web a quella che necessita di meno. Non sembra esserci un modo semplice per avere una soluzione che sia veloce e funzioni ovunque.


Utilizzando l'intestazione X-SendFile

Come documentato da altri, in realtà è il modo migliore. La base è che esegui il controllo degli accessi in php e poi invece di inviare il file tu stesso dici al server web di farlo.

Il codice php di base è:

header("X-Sendfile: $file_name");
header("Content-type: application/octet-stream");
header('Content-Disposition: attachment; filename="' . basename($file_name) . '"');

Dove si $file_nametrova il percorso completo nel file system.

Il problema principale con questa soluzione è che deve essere consentita dal server web e non è installata di default (apache), non è attiva di default (lighttpd) o necessita di una configurazione specifica (nginx).

Apache

Sotto apache se usi mod_php devi installare un modulo chiamato mod_xsendfile e poi configurarlo (o in apache config o .htaccess se lo consenti)

XSendFile on
XSendFilePath /home/www/example.com/htdocs/files/

Con questo modulo il percorso del file può essere assoluto o relativo a quello specificato XSendFilePath.

Lighttpd

Il mod_fastcgi lo supporta quando configurato con

"allow-x-send-file" => "enable" 

La documentazione per la funzionalità è sul wiki di lighttpd , documentano l' X-LIGHTTPD-send-fileintestazione ma il fileX-Sendfile nome funziona

Nginx

Su Nginx non è possibile utilizzare l' X-Sendfileintestazione, è necessario utilizzare la propria intestazione denominata X-Accel-Redirect. È abilitato per impostazione predefinita e l'unica vera differenza è che il suo argomento dovrebbe essere un URI e non un file system. La conseguenza è che è necessario definire una posizione contrassegnata come interna nella configurazione per evitare che i client trovino l'URL del file reale e accedano direttamente ad esso, il loro wiki ne contiene una buona spiegazione .

Collegamenti simbolici e intestazione della posizione

È possibile utilizzare collegamenti simbolici e reindirizzarli, basta creare collegamenti simbolici al file con nomi casuali quando un utente è autorizzato ad accedere a un file e reindirizzare l'utente ad esso utilizzando:

header("Location: " . $url_of_symlink);

Ovviamente avrai bisogno di un modo per potarli o quando viene chiamato lo script per crearli o tramite cron (sulla macchina se hai accesso o tramite qualche servizio di webcron altrimenti)

Sotto apache devi essere in grado di abilitare FollowSymLinksin a .htaccesso nella configurazione di apache.

Controllo degli accessi tramite IP e intestazione Posizione

Un altro trucco è generare file di accesso apache da php consentendo l'IP utente esplicito. Sotto apache significa usare i comandi mod_authz_host( mod_access) Allow from.

Il problema è che bloccare l'accesso al file (poiché più utenti potrebbero volerlo fare contemporaneamente) non è banale e potrebbe portare alcuni utenti ad aspettare molto tempo. E comunque devi sfoltire il file.

Ovviamente un altro problema sarebbe che più persone dietro lo stesso IP potrebbero potenzialmente accedere al file.

Quando tutto il resto fallisce

Se davvero non hai alcun modo per farti aiutare dal tuo server web, l'unica soluzione rimasta è readfile , è disponibile in tutte le versioni php attualmente in uso e funziona abbastanza bene (ma non è veramente efficiente).


Soluzioni combinate

In definitiva, il modo migliore per inviare un file molto velocemente se vuoi che il tuo codice php sia utilizzabile ovunque è avere un'opzione configurabile da qualche parte, con istruzioni su come attivarla a seconda del server web e forse un rilevamento automatico nella tua installazione script.

È abbastanza simile a ciò che viene fatto in molti software per

  • URL puliti ( mod_rewritesu apache)
  • Funzioni crittografiche ( mcryptmodulo php)
  • Supporto stringhe multibyte ( mbstringmodulo php)

C'è qualche problema con l'esecuzione di alcuni lavori PHP (controlla cookie / altri parametri GET / POST sul database) prima di farlo header("Location: " . $path); ?
Afriza N. Arief

2
Nessun problema per tale azione, la cosa a cui devi stare attento è l'invio di contenuto (stampa, eco) poiché l'intestazione deve venire prima di qualsiasi contenuto e fare le cose dopo aver inviato questa intestazione, non è un reindirizzamento immediato e il codice dopo sarà eseguito la maggior parte del tempo ma non si ha la garanzia che il browser non interrompa la connessione.
Julien Roncaglia

Jords: Non sapevo che anche Apache lo supportasse, lo aggiungerò alla mia risposta quando avrò tempo. L'unico problema è che i non è unificato (ad esempio X-Accel-Redirect nginx), quindi è necessaria una seconda soluzione se il server non la supporta. Ma dovrei aggiungerlo alla mia risposta.
Julien Roncaglia

Dove posso consentire a .htaccess di controllare XSendFilePath?
Keyne Viana

1
@ Keyne, non credo che tu possa. tn123.org/mod_xsendfile non elenca .htaccess nel contesto dell'opzione XSendFilePath
cheshirekow

33

Il modo più veloce: non farlo. Guarda nell'intestazione x-sendfile per nginx , ci sono cose simili anche per altri server web. Ciò significa che puoi ancora eseguire il controllo degli accessi, ecc. In php, ma delegare l'invio effettivo del file a un server web progettato per questo.

PS: mi vengono i brividi solo pensando a quanto sia più efficiente usare questo con nginx, rispetto alla lettura e all'invio del file in php. Pensa se 100 persone stanno scaricando un file: con php + apache, essendo generosi, probabilmente è 100 * 15 MB = 1,5 GB (circa, sparami), di ram proprio lì. Nginx passerà semplicemente l'invio del file al kernel, quindi verrà caricato direttamente dal disco nei buffer di rete. Speedy!

PPS: E, con questo metodo puoi ancora fare tutto il controllo degli accessi, le cose del database che desideri.


4
Lasciatemi solo aggiungere che questo esiste anche per Apache: jasny.net/articles/how-i-php-x-sendfile . È possibile fare in modo che lo script fiuti il ​​server e invii le intestazioni appropriate. Se nessuno esiste (e l'utente non ha alcun controllo sul server come da domanda), torna alla normalitàreadfile()
Fanis Hatzidakis

Ora questo è semplicemente fantastico: ho sempre odiato aumentare il limite di memoria nei miei host virtuali solo in modo che PHP potesse servire un file, e con questo non avrei dovuto farlo. Lo proverò molto presto.
Greg W

1
E per il credito a cui è dovuto il credito, Lighttpd è stato il primo server web a implementarlo (e il resto lo ha copiato, il che va bene poiché è un'ottima idea. Ma dai credito dove è dovuto il credito) ...
ircmaxell

1
Questa risposta continua a essere votata, ma non funzionerà in un ambiente in cui il server Web e le sue impostazioni sono fuori dal controllo dell'utente.
Kirk Ouimet

L'hai effettivamente aggiunto alla tua domanda dopo che ho pubblicato questa risposta. E se le prestazioni sono un problema, il server web deve essere sotto il tuo controllo.
Jords

23

Ecco una soluzione PHP pura. Ho adattato la seguente funzione dal mio quadro personale :

function Download($path, $speed = null, $multipart = true)
{
    while (ob_get_level() > 0)
    {
        ob_end_clean();
    }

    if (is_file($path = realpath($path)) === true)
    {
        $file = @fopen($path, 'rb');
        $size = sprintf('%u', filesize($path));
        $speed = (empty($speed) === true) ? 1024 : floatval($speed);

        if (is_resource($file) === true)
        {
            set_time_limit(0);

            if (strlen(session_id()) > 0)
            {
                session_write_close();
            }

            if ($multipart === true)
            {
                $range = array(0, $size - 1);

                if (array_key_exists('HTTP_RANGE', $_SERVER) === true)
                {
                    $range = array_map('intval', explode('-', preg_replace('~.*=([^,]*).*~', '$1', $_SERVER['HTTP_RANGE'])));

                    if (empty($range[1]) === true)
                    {
                        $range[1] = $size - 1;
                    }

                    foreach ($range as $key => $value)
                    {
                        $range[$key] = max(0, min($value, $size - 1));
                    }

                    if (($range[0] > 0) || ($range[1] < ($size - 1)))
                    {
                        header(sprintf('%s %03u %s', 'HTTP/1.1', 206, 'Partial Content'), true, 206);
                    }
                }

                header('Accept-Ranges: bytes');
                header('Content-Range: bytes ' . sprintf('%u-%u/%u', $range[0], $range[1], $size));
            }

            else
            {
                $range = array(0, $size - 1);
            }

            header('Pragma: public');
            header('Cache-Control: public, no-cache');
            header('Content-Type: application/octet-stream');
            header('Content-Length: ' . sprintf('%u', $range[1] - $range[0] + 1));
            header('Content-Disposition: attachment; filename="' . basename($path) . '"');
            header('Content-Transfer-Encoding: binary');

            if ($range[0] > 0)
            {
                fseek($file, $range[0]);
            }

            while ((feof($file) !== true) && (connection_status() === CONNECTION_NORMAL))
            {
                echo fread($file, round($speed * 1024)); flush(); sleep(1);
            }

            fclose($file);
        }

        exit();
    }

    else
    {
        header(sprintf('%s %03u %s', 'HTTP/1.1', 404, 'Not Found'), true, 404);
    }

    return false;
}

Il codice è il più efficiente possibile, chiude il gestore della sessione in modo che altri script PHP possano essere eseguiti contemporaneamente per lo stesso utente / sessione. Supporta anche il servizio di download in intervalli (che è anche ciò che Apache fa per impostazione predefinita, sospetto), in modo che le persone possano mettere in pausa / riprendere i download e beneficiare anche di velocità di download più elevate con gli acceleratori di download. Consente inoltre di specificare la velocità massima (in Kbps) alla quale il download (parte) deve essere servito tramite l' $speedargomento.


2
Ovviamente questa è solo una buona idea se non puoi usare X-Sendfile o una delle sue varianti per far sì che il kernel invii il file. Si dovrebbe essere in grado di sostituire il / fread ciclo feof () () di cui sopra con [ php.net/manual/en/function.eio-sendfile.php](PHP's eio_sendfile ()] chiamata, che compie la stessa cosa in PHP. Non è veloce come farlo direttamente nel kernel, poiché qualsiasi output generato in PHP deve ancora tornare indietro attraverso il processo del server web, ma sarà molto più veloce che farlo in codice PHP.
Brian C

@BrianC: Certo, ma non puoi limitare la velocità o l'abilità multiparte con X-Sendfile (che potrebbe non essere disponibile) e eioinoltre non è sempre disponibile. Tuttavia, +1, non sapeva di quell'estensione pecl. =)
Alix Axel

Sarebbe utile supportare la codifica del trasferimento: chunked e la codifica del contenuto: gzip?
skibulk

Perché $size = sprintf('%u', filesize($path))?
Svish

14
header('Location: ' . $path);
exit(0);

Lascia che Apache faccia il lavoro per te.


12
È più semplice del metodo x-sendfile, ma non funzionerà per limitare l'accesso a un file, per dire solo alle persone che hanno effettuato l'accesso. Se non hai bisogno di farlo, allora è fantastico!
Jords

Aggiungi anche un controllo referrer con mod_rewrite.
sanmai,

1
Puoi autenticare prima di passare l'intestazione. In questo modo non stai nemmeno pompando tonnellate di cose nella memoria di PHP.
Brent

7
@UltimateBrent La posizione deve ancora essere accessibile a tutti .. E un controllo di riferimento non è affatto una sicurezza poiché proviene dal client
Øyvind Skaar

@Jimbo Un token utente di cui controllerai come? Con PHP? All'improvviso la tua soluzione ricorre.
Mark Amery

1

Una migliore implementazione, con supporto cache, intestazioni http personalizzate.

serveStaticFile($fn, array(
        'headers'=>array(
            'Content-Type' => 'image/x-icon',
            'Cache-Control' =>  'public, max-age=604800',
            'Expires' => gmdate("D, d M Y H:i:s", time() + 30 * 86400) . " GMT",
        )
    ));

function serveStaticFile($path, $options = array()) {
    $path = realpath($path);
    if (is_file($path)) {
        if(session_id())
            session_write_close();

        header_remove();
        set_time_limit(0);
        $size = filesize($path);
        $lastModifiedTime = filemtime($path);
        $fp = @fopen($path, 'rb');
        $range = array(0, $size - 1);

        header('Last-Modified: ' . gmdate("D, d M Y H:i:s", $lastModifiedTime)." GMT");
        if (( ! empty($_SERVER['HTTP_IF_MODIFIED_SINCE']) && strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']) == $lastModifiedTime ) ) {
            header("HTTP/1.1 304 Not Modified", true, 304);
            return true;
        }

        if (isset($_SERVER['HTTP_RANGE'])) {
            //$valid = preg_match('^bytes=\d*-\d*(,\d*-\d*)*$', $_SERVER['HTTP_RANGE']);
            if(substr($_SERVER['HTTP_RANGE'], 0, 6) != 'bytes=') {
                header('HTTP/1.1 416 Requested Range Not Satisfiable', true, 416);
                header('Content-Range: bytes */' . $size); // Required in 416.
                return false;
            }

            $ranges = explode(',', substr($_SERVER['HTTP_RANGE'], 6));
            $range = explode('-', $ranges[0]); // to do: only support the first range now.

            if ($range[0] === '') $range[0] = 0;
            if ($range[1] === '') $range[1] = $size - 1;

            if (($range[0] >= 0) && ($range[1] <= $size - 1) && ($range[0] <= $range[1])) {
                header('HTTP/1.1 206 Partial Content', true, 206);
                header('Content-Range: bytes ' . sprintf('%u-%u/%u', $range[0], $range[1], $size));
            }
            else {
                header('HTTP/1.1 416 Requested Range Not Satisfiable', true, 416);
                header('Content-Range: bytes */' . $size);
                return false;
            }
        }

        $contentLength = $range[1] - $range[0] + 1;

        //header('Content-Disposition: attachment; filename="xxxxx"');
        $headers = array(
            'Accept-Ranges' => 'bytes',
            'Content-Length' => $contentLength,
            'Content-Type' => 'application/octet-stream',
        );

        if(!empty($options['headers'])) {
            $headers = array_merge($headers, $options['headers']);
        }
        foreach($headers as $k=>$v) {
            header("$k: $v", true);
        }

        if ($range[0] > 0) {
            fseek($fp, $range[0]);
        }
        $sentSize = 0;
        while (!feof($fp) && (connection_status() === CONNECTION_NORMAL)) {
            $readingSize = $contentLength - $sentSize;
            $readingSize = min($readingSize, 512 * 1024);
            if($readingSize <= 0) break;

            $data = fread($fp, $readingSize);
            if(!$data) break;
            $sentSize += strlen($data);
            echo $data;
            flush();
        }

        fclose($fp);
        return true;
    }
    else {
        header('HTTP/1.1 404 Not Found', true, 404);
        return false;
    }
}

0

se hai la possibilità di aggiungere estensioni PECL al tuo php puoi semplicemente usare le funzioni dal pacchetto Fileinfo per determinare il tipo di contenuto e quindi inviare le intestazioni corrette ...


/ bump, hai accennato a questa possibilità? :)
Andreas Linden

0

La Downloadfunzione PHP qui menzionata causava un certo ritardo prima che il download del file iniziasse effettivamente. Non so se questo è stato causato da utilizzando la cache di vernice o cosa, ma per me ha aiutato a rimuovere il sleep(1);completamente e insieme $speeda 1024. Ora funziona senza problemi perché è veloce come l'inferno. Forse potresti modificare anche quella funzione, perché l'ho vista usata su Internet.


0

Ho codificato una funzione molto semplice per servire file con PHP e rilevamento automatico del tipo MIME:

function serve_file($filepath, $new_filename=null) {
    $filename = basename($filepath);
    if (!$new_filename) {
        $new_filename = $filename;
    }
    $mime_type = mime_content_type($filepath);
    header('Content-type: '.$mime_type);
    header('Content-Disposition: attachment; filename="downloaded.pdf"');
    readfile($filepath);
}

Utilizzo

serve_file("/no_apache/invoice243.pdf");
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.