Maneira mais rápida de servir um arquivo usando PHP


98

Estou tentando montar uma função que recebe um caminho de arquivo, identifica o que é, define os cabeçalhos apropriados e serve-o como faria o Apache.

Estou fazendo isso porque preciso usar o PHP para processar algumas informações sobre a solicitação antes de servir o arquivo.

Velocidade é crítica

virtual () não é uma opção

Deve trabalhar em um ambiente de hospedagem compartilhada onde o usuário não tem controle do servidor web (Apache / nginx, etc)

Aqui está o que tenho até agora:

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
Por que você não está deixando o Apache fazer isso? Sempre vai ser consideravelmente mais rápido do que iniciar o interpretador PHP ...
Billy ONeal

4
Preciso processar a solicitação e armazenar algumas informações no banco de dados antes de enviar o arquivo.
Kirk Ouimet

3
Posso sugerir uma maneira de obter a extensão, sem as expressões regulares mais caros: $extension = end(explode(".", $pathToFile))ou você pode fazê-lo com substr e strrpos: $extension = substr($pathToFile, strrpos($pathToFile, '.')). Além disso, como alternativa mime_content_type(), você pode tentar uma chamada de sistema:$mimetype = exec("file -bi '$pathToFile'", $output);
Fanis Hatzidakis de

O que você quer dizer com mais rápido ? Tempo de download mais rápido?
Alix Axel

Respostas:


140

Minha resposta anterior foi parcial e não bem documentada, aqui está uma atualização com um resumo das soluções dela e de outros na discussão.

As soluções são ordenadas da melhor solução para a pior, mas também da solução que precisa de mais controle sobre o servidor web para a que precisa de menos. Não parece haver uma maneira fácil de ter uma solução que seja rápida e funcione em qualquer lugar.


Usando o cabeçalho X-SendFile

Conforme documentado por outros, é realmente a melhor maneira. A base é que você faz o controle de acesso em php e, em vez de enviar o arquivo você mesmo, diz ao servidor da web para fazê-lo.

O código php básico é:

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

Onde $file_nameestá o caminho completo no sistema de arquivos.

O principal problema desta solução é que ela precisa ser permitida pelo servidor web e não está instalada por padrão (apache), não está ativa por padrão (lighttpd) ou precisa de uma configuração específica (nginx).

Apache

No apache, se você usar mod_php, você precisa instalar um módulo chamado mod_xsendfile e então configurá-lo (na configuração do apache ou .htaccess se você permitir)

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

Com este módulo, o caminho do arquivo pode ser absoluto ou relativo ao especificado XSendFilePath.

Lighttpd

O mod_fastcgi suporta isso quando configurado com

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

A documentação para o recurso está no wiki lighttpd eles documentam o X-LIGHTTPD-send-filecabeçalho, mas o X-Sendfilenome também funciona

Nginx

No Nginx você não pode usar o X-Sendfilecabeçalho, você deve usar o seu próprio cabeçalho nomeado X-Accel-Redirect. Ele é habilitado por padrão e a única diferença real é que seu argumento deve ser um URI, não um sistema de arquivos. A conseqüência é que você deve definir um local marcado como interno em sua configuração para evitar que os clientes encontrem a url do arquivo real e acessem diretamente a ela, o wiki contém uma boa explicação disso.

Links simbólicos e cabeçalho de localização

Você pode usar links simbólicos e redirecionar para eles, basta criar links simbólicos para seu arquivo com nomes aleatórios quando um usuário estiver autorizado a acessar um arquivo e redirecionar o usuário para ele usando:

header("Location: " . $url_of_symlink);

Obviamente, você precisará de uma maneira de podá-los quando o script para criá-los for chamado ou via cron (na máquina se você tiver acesso ou via algum serviço webcron caso contrário)

No apache, você precisa ser capaz de habilitar FollowSymLinksem um .htaccessou na configuração do apache.

Controle de acesso por IP e cabeçalho de localização

Outro hack é gerar arquivos de acesso apache a partir do php, permitindo o IP explícito do usuário. No apache, significa usar comandos mod_authz_host( mod_access) Allow from.

O problema é que bloquear o acesso ao arquivo (já que vários usuários podem querer fazer isso ao mesmo tempo) não é trivial e pode fazer com que alguns usuários esperem muito. E você ainda precisa remover o arquivo de qualquer maneira.

Obviamente, outro problema seria que várias pessoas por trás do mesmo IP poderiam acessar o arquivo.

Quando tudo mais falhar

Se você realmente não tem como obter ajuda do seu servidor web, a única solução restante é readfile, que está disponível em todas as versões de php atualmente em uso e funciona muito bem (mas não é realmente eficiente).


Combinando soluções

Em suma, a melhor maneira de enviar um arquivo realmente rápido se você quiser que seu código php possa ser usado em qualquer lugar é ter uma opção configurável em algum lugar, com instruções sobre como ativá-lo dependendo do servidor web e talvez uma detecção automática em sua instalação roteiro.

É muito semelhante ao que é feito em muitos softwares para

  • Limpar urls ( mod_rewriteno apache)
  • Funções criptográficas ( mcryptmódulo php)
  • Suporte a strings multibyte ( mbstringmódulo php)

Há algum problema em fazer alguns trabalhos de PHP (verificar cookie / outros parâmetros GET / POST no banco de dados) antes de fazer header("Location: " . $path);?
Afriza N. Arief,

2
Não há problema para tal ação, o que você precisa ter cuidado é com o envio de conteúdo (imprimir, echo) já que o cabeçalho deve vir antes de qualquer conteúdo e fazer as coisas após o envio deste cabeçalho, não é um redirecionamento imediato e o código depois dele será executado na maioria das vezes, mas você não tem garantias de que o navegador não cortará a conexão.
Julien Roncaglia,

Jords: Eu não sabia que o Apache também suportava isso, vou adicionar isso à minha resposta quando tiver tempo. O único problema com isso é que eu não é unificado (X-Accel-Redirect nginx, por exemplo), então uma segunda solução é necessária se o servidor também não oferecer suporte. Mas devo acrescentar à minha resposta.
Julien Roncaglia,

Onde posso permitir que o .htaccess controle o XSendFilePath?
Keyne Viana 18/11/11

1
@Keyne Eu não acho que você pode. tn123.org/mod_xsendfile não lista .htaccess no contexto para a opção XSendFilePath
cheshirekow

33

A maneira mais rápida: não faça isso. Olhe no cabeçalho x-sendfile para nginx , há coisas semelhantes para outros servidores da web também. Isso significa que você ainda pode fazer o controle de acesso etc em php, mas delegar o envio real do arquivo a um servidor web projetado para isso.

PS: Eu fico arrepiado só de pensar em como é mais eficiente usar isso com o nginx, em comparação com ler e enviar o arquivo em php. Basta pensar se 100 pessoas estão baixando um arquivo: Com php + apache, sendo generoso, isso é provavelmente 100 * 15mb = 1,5 GB (aprox, atire em mim), de RAM bem ali. O Nginx apenas transfere o envio do arquivo para o kernel e, em seguida, é carregado diretamente do disco para os buffers de rede. Veloz!

PPS: E, com este método, você ainda pode fazer todo o controle de acesso, coisas de banco de dados que quiser.


4
Deixe-me apenas acrescentar que isso também existe para o Apache: jasny.net/articles/how-i-php-x-sendfile . Você pode fazer com que o script detecte o servidor e envie os cabeçalhos apropriados. Se nenhum existir (e o usuário não tiver controle sobre o servidor de acordo com a pergunta), volte ao normalreadfile()
Fanis Hatzidakis

Agora, isso é simplesmente incrível - eu sempre odiei aumentar o limite de memória em meus hosts virtuais apenas para que o PHP servisse um arquivo, e com isso eu não deveria fazer isso. Estarei experimentando muito em breve.
Greg W

1
E para dar crédito a quem o crédito é devido, Lighttpd foi o primeiro servidor web a implementar isso (E o resto o copiou, o que é bom, pois é uma ótima ideia. Mas dê crédito a quem o crédito é devido) ...
ircmaxell

1
Essa resposta continua recebendo votos positivos, mas não funcionará em um ambiente onde o servidor da web e suas configurações estão fora do controle do usuário.
Kirk Ouimet

Você realmente adicionou isso à sua pergunta depois que eu postei esta resposta. E se o desempenho for um problema, o servidor da web deve estar sob seu controle.
Jords,

23

Aqui vai uma solução PHP pura. Eu adaptei a seguinte função da minha estrutura pessoal :

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;
}

O código é tão eficiente quanto pode ser, ele fecha o manipulador de sessão para que outros scripts PHP possam ser executados simultaneamente para o mesmo usuário / sessão. Ele também oferece suporte a downloads em intervalos (que também é o que o Apache faz por padrão, eu suspeito), para que as pessoas possam pausar / retomar downloads e também se beneficiar de velocidades de download mais altas com aceleradores de download. Ele também permite que você especifique a velocidade máxima (em Kbps) na qual o download (parte) deve ser servido por meio do $speedargumento.


2
Obviamente, esta é apenas uma boa idéia se você não puder usar o X-Sendfile ou uma de suas variantes para que o kernel envie o arquivo. Você deve ser capaz de substituir o loop feof () / fread () acima pela chamada [ php.net/manual/en/function.eio-sendfile.php](Para eio_sendfile ()] do PHP, que realiza a mesma coisa no PHP. Isso não é tão rápido quanto fazer diretamente no kernel, já que qualquer saída gerada em PHP ainda precisa voltar pelo processo do servidor da web, mas será muito mais rápido do que fazê-lo em código PHP.
Brian C.

@BrianC: Claro, mas você não pode limitar a velocidade ou a capacidade multiparte com o X-Sendfile (que pode não estar disponível) e eionem sempre está disponível. Ainda assim, +1, não sabia sobre essa extensão pecl. =)
Alix Axel

Seria útil oferecer suporte à codificação de transferência: fragmentada e codificação de conteúdo: gzip?
skibulk de

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

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

Deixe o Apache fazer o trabalho por você.


12
Isso é mais simples do que o método x-sendfile, mas não funcionará para restringir o acesso a um arquivo, para dizer apenas pessoas conectadas. Se você não precisa fazer isso, é ótimo!
Jords

Também adicione uma verificação de referenciador com mod_rewrite.
sanmai

1
Você pode autenticar antes de passar o cabeçalho. Dessa forma, você também não bombeia toneladas de coisas pela memória do PHP.
Brent de

7
@UltimateBrent A localização ainda tem que ser acessível a todos .. E um cheque de referência não é nenhuma segurança visto que vem do cliente
Øyvind Skaar

@Jimbo Um token de usuário que você vai verificar como? Com PHP? De repente, sua solução é recorrente.
Mark Amery

1

Uma melhor implementação, com suporte de cache, cabeçalhos http personalizados.

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 você tem a possibilidade de adicionar extensões PECL ao seu php, você pode simplesmente usar as funções do pacote Fileinfo para determinar o tipo de conteúdo e então enviar os cabeçalhos apropriados ...


/ bump, você mencionou essa possibilidade? :)
Andreas Linden

0

A Downloadfunção PHP mencionada aqui estava causando algum atraso antes de o arquivo realmente começar a ser baixado. Não sei se isso foi causado pelo uso de cache de verniz ou o quê, mas para mim ajudou a remover sleep(1);completamente e definir $speedcomo 1024. Agora funciona sem problemas e é rápido como o inferno. Talvez você possa modificar essa função também, pois vi que ela é usada em toda a internet.


0

Codifiquei uma função muito simples para servir arquivos com PHP e detecção automática de 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);
}

Uso

serve_file("/no_apache/invoice243.pdf");
Ao utilizar nosso site, você reconhece que leu e compreendeu nossa Política de Cookies e nossa Política de Privacidade.
Licensed under cc by-sa 3.0 with attribution required.