从PHP输出缓冲区中写入并下载CSV文件。

10

我需要将CSV文件写入PHP输出缓冲区,然后在完成写入后将该文件下载到客户端计算机。(我原本想在服务器上编写并下载它,但事实证明我在生产服务器上没有写入权限)。

我有以下PHP脚本:

$basic_info = fopen("php://output", 'w');
$basic_header = array(HEADER_ITEMS_IN_HERE);

@fputcsv($basic_info, $basic_header);

while($user_row = $get_users_stmt->fetch(PDO::FETCH_ASSOC)) {
    @fputcsv($basic_info, $user_row);
}

@fclose($basic_info);

header('Content-Description: File Transfer');
header('Content-Type: application/csv');
header('Content-Disposition: attachment; filename=test.csv');
header('Expires: 0');
header('Cache-Control: must-revalidate');
header('Pragma: public');
header('Content-Length: ' . filesize("php://output"));
ob_clean();
flush();
readfile("php://output");

我不确定该怎么做。CSV文件下载了,但是没有显示任何内容。我猜这可能与我的ob_clean()和flush()命令的顺序有关,但我不确定最好的排序方式是什么。

感谢任何帮助。


1
ob_clean() 没有 ob_start() 的前提下是毫无意义的。请注意,readfile 写入输出,但您正在从该输入中读取。除非在所有这些代码之前都执行了 ob_start(),否则您的 fputcsv() 已经写入输出并在任何头代码被执行之前已经传输到浏览器了。 - Marc B
@WebnetMobile 我猜是 mail() - Halcyon
@WebnetMobile,实际上我在代码中有错误处理,但我只是把它放在那里,因为我不想包含任何那些代码。 - Jon Rubins
@datasage 我想在写入输出之前移动我的标题。我的标题是否正确? - Jon Rubins
@MarcB,如果我将头文件移到开头,那么我是否还需要使用ob_clean()、flush()或readfile()函数呢? - Jon Rubins
显示剩余4条评论
3个回答

12

你做了有点过头了。把脚本的唯一目的设为输出CSV。直接将其打印到屏幕上,不必担心标题、缓冲区或 php://output 等内容。

一旦确认适当地将数据打印到屏幕上,只需在开头添加这些标题:

<?php
header("Content-disposition: attachment; filename=test.csv");
header("Content-Type: text/csv");
?>

...确认下载文件的正确性,然后您可以添加其他标头(我上面包含的标头是我自己使用的,没有额外的混杂物使其正常工作。其他标头基本上是为了效率和缓存控制而设计的,其中一些可能已经由您的服务器适当处理,并且可能对您特定的应用程序重要或不重要)。

如果需要,可以使用输出缓冲,使用ob_start()ob_get_clean()将输出内容获取到字符串中,然后使用该字符串来填写Content-Length


7
谢谢您真正地帮助我,不像其他人只是抱怨我做错了什么。 - Jon Rubins

4

正如我在 Edson 回答的评论中提到的,我预期在代码的最后一行会出现“头部已发送”的警告:

header('Content-Length: '.$streamSize);

由于输出是在发送此标头之前编写的,但他的示例可以正常工作。

一些调查得出以下结论:

当您使用输出缓冲区(无论是用户缓冲区还是默认的PHP缓冲区)时,您可以以所需的方式发送HTTP标头和内容。您知道任何协议都要求在主体之前发送标头(因此称为“标头”),但是当您使用输出缓冲层时,PHP将为您处理此问题。任何与输出标头相关的PHP函数(header()、setcookie()、session_start())实际上都将使用内部sapi_header_op()函数,该函数只是填充标头缓冲区。然后,当您编写输出时,例如printf(),它会写入输出缓冲区(假设有一个)。当要发送输出缓冲区时,PHP首先开始发送标头,然后是主体。PHP为您处理所有内容。如果您不喜欢这种行为,则除了禁用任何输出缓冲层外,别无选择。

在大多数配置下,PHP缓冲区的默认大小为4096字节(4KB),这意味着PHP缓冲区可以容纳高达4KB的数据。 一旦超过此限制或PHP代码执行完成,缓冲的内容将自动发送到所使用的任何后端PHP(CGI、mod_php、FastCGI)。在PHP-CLI中始终关闭输出缓冲。

Edson的代码之所以有效,是因为输出缓冲区没有自动刷新,因为它没有超过缓冲区大小(并且脚本在最后一个标头发送之前显然没有终止)。

一旦输出缓冲区中的数据超过缓冲区大小,警告将被引发。或者在他的示例中,当

$get_users_stmt->fetch(PDO::FETCH_ASSOC)

文件过大。

为了防止这种情况发生,您应该使用ob_start()和ob_end_flush()来手动管理输出缓冲区,如下所示:

// Turn on output buffering
ob_start();

// Define handle to output stream
$basic_info   = fopen("php://output", 'w');

// Define and write header row to csv output 
$basic_header = array('Header1', 'Header2');  
fputcsv($basic_info, $basic_header);  

$count = 0; // Auxiliary variable to write csv header in a different way

// Get data for remaining rows and write this rows to csv output
while($user_row = $get_users_stmt->fetch(PDO::FETCH_ASSOC)) {  
    if ($count == 0) {
        // Write select column's names as CSV header
        fputcsv($basic_info, array_keys($user_row));  
    } else {
        //Write data row
        fputcsv($basic_info, $user_row);
    }
    $count++;
}  

// Get size of output after last output data sent  
$streamSize = ob_get_length();

//Close the filepointer
fclose($basic_info);  

// Send the raw HTTP headers
header('Content-Type: text/csv');
header('Content-Disposition: attachment; filename=test.csv');  
header('Expires: 0');  
header('Cache-Control: no-cache');
header('Content-Length: '. ob_get_length());

// Flush (send) the output buffer and turn off output buffering
ob_end_flush();

不过,您仍然受到其他限制的约束。


0

我对你的代码进行了一些调整。

  • 按照PHP文档建议,将头文件移到任何输出之前;

请记住,在发送任何实际输出之前,必须调用header(),无论是通过正常的HTML标记、文件中的空行还是来自PHP。

  • 删除了一些没有太大变化的头文件;
  • 注释了使用选择列名称编写csv标题的另一个选项;
  • 现在内容长度有效;
  • 不需要回显$basic_info,因为它已经在输出缓冲区中,并且我们通过头文件将其重定向到文件中;
  • 删除了@(PHP错误控制运算符),因为它可能会导致额外开销,现在没有链接可以给你展示,但如果你搜索一下,你可能会找到。在消除错误之前,应该三思而后行,大多数情况下应该修复而不是消除错误。

header('Content-Type: text/csv');
header('Content-Disposition: attachment; filename=test.csv');  
header('Expires: 0');  
header('Cache-Control: no-cache'); 

$basic_info   = fopen("php://output", 'w');  
$basic_header = array(HEADER_ITEMS_IN_HERE);  

fputcsv($basic_info, $basic_header);  
$count = 0; // auxiliary variable to write csv header in a different way

while($user_row = $get_users_stmt->fetch(PDO::FETCH_ASSOC)) {  
    // Write select column's names as CSV header
    if ($count == 0) {
        fputcsv($basic_info, array_keys($user_row));  
    }

    fputcsv($basic_info, $user_row);  
    $count++;
}  

// get size of output after last output data sent  
$streamSize = ob_get_length(); 
fclose($basic_info);  

header('Content-Length: '.$streamSize);

既然你之前已经向php://output写入了内容,那么在header('Content-Length: '.$streamSize);中不是已经发送了头信息吗? - DigiLive
@dnFer 是的,但只有在写入完成后我才能知道文件大小,这个方法可行,浏览器在下载弹出窗口中显示文件大小。 - Edson Horacio Junior
我明白。但我有点想知道为什么“头已发送”警告没有弹出。 - DigiLive
当我按照你上面描述的添加Content-Length头部时,我遇到了“headers already sent”错误消息。通过在csv输出之前使用ob_start()并在Content-Length头部之后使用ob_end_flush(),我成功解决了这个问题。根据脚本处理下载所需的时间长短,使用输出缓冲区可能会比不使用输出缓冲区稍晚一些开始下载,因为整个文件将首先准备好,然后发送给客户端。好处是用户可以看到下载的大小,浏览器可以更好地估计剩余的下载时间。 - Lucas

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接