如何在不阻塞服务器和客户端的情况下实时读取并输出上传文件正在写入的文件大小?

10

问题:

如何实时读取和回显正在服务器上写入的已上传文件的文件大小,而不会在服务器和客户端两端阻塞?

背景:

从由fetch()发起的POST请求将文件上传进服务器,并且body设置为BlobFileTypedArrayArrayBuffer对象。

当前的实现是将File对象设置为传递给fetch()的第二个参数中的body对象。

要求:

text/event-stream格式读取并向客户端echo正在写入到服务器文件系统的文件大小。当所有字节被写入脚本作为查询字符串参数提供的变量后停止。文件的读取目前在一个单独的脚本环境中进行,其中在向服务器写文件的脚本之后,对应该读取文件的脚本进行GET调用。

尚未处理与将文件写入服务器或读取当前文件大小的潜在问题的错误处理,但这将是完成文件大小部分的echo后的下一步。

目前尝试使用php来满足要求。但也对cbashnodejspython或其他可用于执行相同任务的语言或方法感兴趣。

客户端的javascript部分不是问题。只是在php方面的经验不足,这是全球最常见的服务器端语言之一,因此需要实现该模式而不包括不必要的部分。

动机:

fetch进度指示器?

相关问题:

使用ReadableStream进行fetch

问题:

PHP Notice:  Undefined index: HTTP_LAST_EVENT_ID in stream.php on line 7

在终端上执行命令

此外,如果要替换

while(file_exists($_GET["filename"]) 
  && filesize($_GET["filename"]) < intval($_GET["filesize"]))

对于

while(true)

EventSource处出现错误。

没有sleep()调用时,上传一个大小为 3.3MB 的文件,正确的文件大小3321824被分发到message事件中,而控制台分别打印了619212621438093次。当上传同一文件三次时,这些数字会重复出现。期望的结果是随着文件写入,文件大小逐渐增加。

stream_copy_to_stream($input, $file);

而不是上传文件对象的文件大小。在stream.php中,fopen()stream_copy_to_stream()是否会阻塞其他不同的php进程?

到目前为止尝试过的:

php归功于

php

// can we merge `data.php`, `stream.php` to same file?
// can we use `STREAM_NOTIFY_PROGRESS` 
// "Indicates current progress of the stream transfer 
// in bytes_transferred and possibly bytes_max as well" to read bytes?
// do we need to call `stream_set_blocking` to `false`
// data.php
<?php

  $filename = $_SERVER["HTTP_X_FILENAME"];
  $input = fopen("php://input", "rb");
  $file = fopen($filename, "wb"); 
  stream_copy_to_stream($input, $file);
  fclose($input);
  fclose($file);
  echo "upload of " . $filename . " successful";

?>

// stream.php
<?php

  header("Content-Type: text/event-stream");
  header("Cache-Control: no-cache");
  header("Connection: keep-alive");
  // `PHP Notice:  Undefined index: HTTP_LAST_EVENT_ID in stream.php on line 7` ?
  $lastId = $_SERVER["HTTP_LAST_EVENT_ID"] || 0;
  if (isset($lastId) && !empty($lastId) && is_numeric($lastId)) {
      $lastId = intval($lastId);
      $lastId++;
  }
  // else {
  //  $lastId = 0;
  // }

  // while current file size read is less than or equal to 
  // `$_GET["filesize"]` of `$_GET["filename"]`
  // how to loop only when above is `true`
  while (true) {
    $upload = $_GET["filename"];
    // is this the correct function and variable to use
    // to get written bytes of `stream_copy_to_stream($input, $file);`?
    $data = filesize($upload);
    // $data = $_GET["filename"] . " " . $_GET["filesize"];
    if ($data) {
      sendMessage($lastId, $data);
      $lastId++;
    } 
    // else {
    //   close stream 
    // }
    // not necessary here, though without thousands of `message` events
    // will be dispatched
    // sleep(1);
    }

    function sendMessage($id, $data) {
      echo "id: $id\n";
      echo "data: $data\n\n";
      ob_flush();
      flush();
    }
?>

javascript

<!DOCTYPE html>
<html>
<head>
</head>
<body>
<input type="file">
<progress value="0" max="0" step="1"></progress>
<script>

const [url, stream, header] = ["data.php", "stream.php", "x-filename"];

const [input, progress, handleFile] = [
        document.querySelector("input[type=file]")
      , document.querySelector("progress")
      , (event) => {
          const [file] = input.files;
          const [{size:filesize, name:filename}, headers, params] = [
                  file, new Headers(), new URLSearchParams()
                ];
          // set `filename`, `filesize` as search parameters for `stream` URL
          Object.entries({filename, filesize})
          .forEach(([...props]) => params.append.apply(params, props));
          // set header for `POST`
          headers.append(header, filename);
          // reset `progress.value` set `progress.max` to `filesize`
          [progress.value, progress.max] = [0, filesize];
          const [request, source] = [
            new Request(url, {
                  method:"POST", headers:headers, body:file
                })
            // https://dev59.com/1J_ha4cB1Zd3GeqPwUvs#42330433/
          , new EventSource(`${stream}?${params.toString()}`)
          ];
          source.addEventListener("message", (e) => {
            // update `progress` here,
            // call `.close()` when `e.data === filesize` 
            // `progress.value = e.data`, should be this simple
            console.log(e.data, e.lastEventId);
          }, true);

          source.addEventListener("open", (e) => {
            console.log("fetch upload progress open");
          }, true);

          source.addEventListener("error", (e) => {
            console.error("fetch upload progress error");
          }, true);
          // sanity check for tests, 
          // we don't need `source` when `e.data === filesize`;
          // we could call `.close()` within `message` event handler
          setTimeout(() => source.close(), 30000);
          // we don't need `source' to be in `Promise` chain, 
          // though we could resolve if `e.data === filesize`
          // before `response`, then wait for `.text()`; etc.
          // TODO: if and where to merge or branch `EventSource`,
          // `fetch` to single or two `Promise` chains
          const upload = fetch(request);
          upload
          .then(response => response.text())
          .then(res => console.log(res))
          .catch(err => console.error(err));
        }
];

input.addEventListener("change", handleFile, true);
</script>
</body>
</html>

你正在使用什么服务器设置? - Alex Blex
@AlexBlex 目前只有 $ php -S,这可能会导致在文件写入时读取文件大小的问题?_"Web服务器运行一个单线程进程,因此如果请求被阻塞,PHP应用程序将停止。"_ http://php.net/manual/en/features.commandline.webserver.php - guest271314
是的,您需要设置一个Web服务器以允许多个并发请求。 - Alex Blex
@AlexBlex 在 apache 中配置了 php,但是 filesize() 仍然只返回上传文件的总大小。在 stream.php 中调用 filesize() 是否是获取正在写入的文件大小的正确 php 函数? - guest271314
文件必须非常小,或者已经存在于之前的测试中。请查看我的回答。 - Alex Blex
2个回答

7
您需要使用clearstatcache方法获取真实的文件大小。如果进行少量修改,您的stream.php文件可能如下所示:
<?php

header("Content-Type: text/event-stream");
header("Cache-Control: no-cache");
header("Connection: keep-alive");
// Check if the header's been sent to avoid `PHP Notice:  Undefined index: HTTP_LAST_EVENT_ID in stream.php on line `
// php 7+
//$lastId = $_SERVER["HTTP_LAST_EVENT_ID"] ?? 0;
// php < 7
$lastId = isset($_SERVER["HTTP_LAST_EVENT_ID"]) ? intval($_SERVER["HTTP_LAST_EVENT_ID"]) : 0;

$upload = $_GET["filename"];
$data = 0;
// if file already exists, its initial size can be bigger than the new one, so we need to ignore it
$wasLess = $lastId != 0;
while ($data < $_GET["filesize"] || !$wasLess) {
    // system calls are expensive and are being cached with assumption that in most cases file stats do not change often
    // so we clear cache to get most up to date data
    clearstatcache(true, $upload);
    $data = filesize($upload);
    $wasLess |= $data <  $_GET["filesize"];
    // don't send stale filesize
    if ($wasLess) {
        sendMessage($lastId, $data);
        $lastId++;
    }
    // not necessary here, though without thousands of `message` events will be dispatched
    //sleep(1);
    // millions on poor connection and large files. 1 second might be too much, but 50 messages a second must be okay
    usleep(20000);
}

function sendMessage($id, $data)
{
    echo "id: $id\n";
    echo "data: $data\n\n";
    ob_flush();
    // no need to flush(). It adds content length of the chunk to the stream
    // flush();
}

注意事项:

安全性。我指的是缺乏安全性。据我所知,这只是一个概念验证,安全性是最不需要担心的,但免责声明应该在那里。这种方法本质上存在缺陷,仅当您不关心DOS攻击或文件信息外泄时才应使用。

CPU。没有usleep,脚本将占用100%的单个核心。使用长时间的睡眠会使您冒着在单个迭代中上传整个文件且退出条件永远无法满足的风险。如果您在本地测试它,则应完全删除usleep,因为在本地上传MB只需要几毫秒。

打开连接。Apache和Nginx / FPM都有有限数量的php进程可以处理请求。单个文件上传将花费2倍于上传文件所需时间。如果带宽较慢或请求被伪造,则此时间可能相当长,并且Web服务器可能开始拒绝请求。

客户端部分。您需要分析响应并在文件完全上传后停止侦听事件。

编辑:

为了使其更加适合生产环境,您需要像redis或memcache这样的内存存储来存储文件元数据。

在进行POST请求时,请添加一个唯一标识文件和文件大小的令牌。

在您的JavaScript中:

const fileId = Math.random().toString(36).substr(2); // or anything more unique
...

const [request, source] = [
    new Request(`${url}?fileId=${fileId}&size=${filesize}`, {
        method:"POST", headers:headers, body:file
    })
    , new EventSource(`${stream}?fileId=${fileId}`)
];
....

在 data.php 中注册令牌并按块报告进度:
....

$fileId = $_GET['fileId'];
$fileSize = $_GET['size'];

setUnique($fileId, 0, $fileSize);

while ($uploaded = stream_copy_to_stream($input, $file, 1024)) {
    updateProgress($id, $uploaded);
}
....


/**
 * Check if Id is unique, and store processed as 0, and full_size as $size 
 * Set reasonable TTL for the key, e.g. 1hr 
 *
 * @param string $id
 * @param int $size
 * @throws Exception if id is not unique
 */
function setUnique($id, $size) {
    // implement with your storage of choice
}

/**
 * Updates uploaded size for the given file
 *
 * @param string $id
 * @param int $processed
 */
function updateProgress($id, $processed) {
    // implement with your storage of choice
}

所以你的stream.php根本不需要访问磁盘,只要在用户体验可接受的情况下休眠即可。
....
list($progress, $size) = getProgress('non_existing_key_to_init_default_values');
$lastId = 0;

while ($progress < $size) {
    list($progress, $size) = getProgress($_GET["fileId"]);
    sendMessage($lastId, $progress);
    $lastId++;
    sleep(1);
}
.....


/**
 * Get progress of the file upload.
 * If id is not there yet, returns [0, PHP_INT_MAX]
 *
 * @param $id
 * @return array $bytesUploaded, $fileSize
 */
function getProgress($id) {
    // implement with your storage of choice
}

如果你不放弃EventSource,那么2个开放连接的问题是无法解决的。除非你需要每秒更新数百次,否则保持stream.php的响应时间在毫秒级别且不使用循环来保持连接是非常浪费的。


“存储”是什么意思?你能否创建一个完整实现的要点? - guest271314
注意,我只有一点点的 php 经验。 - guest271314
存储与Apache一样不是外部的,只是在同一系统上运行的另一个守护程序。可以是MySQL、SQLite,甚至是文件存储,但它们相当沉重。 - Alex Blex
1
是的,但这甚至比直接检查文件大小更低效。想想对服务器的不同请求,就像浏览器中的不同标签页一样。您无法直接在不同选项卡中共享JavaScript之间的数据,并使用localstorage,它顺便说一下是SQLite数据库。很抱歉,但SO评论的格式并不适合解释整个LAMP堆栈的工作方式。希望我回答了文件锁定没有问题的问题。 - Alex Blex
虽然您的原始答案回答了关于php实现需求的问题,但我还没有完全理解以下编辑的内容。 - guest271314
显示剩余3条评论

1
你需要使用JavaScript将文件分成块并发送这些块,当块上传时,你会确切知道发送了多少数据。
这是唯一的方法,顺便说一下,它并不难。
file.startByte  += 100000;
file.stopByte   += 100000;

var reader = new FileReader();

reader.onloadend = function(evt) {
    data.blob = btoa(evt.target.result);
    /// Do upload here, I do with jQuery ajax
}

var blob = file.slice(file.startByte, file.stopByte);
reader.readAsBinaryString(blob);

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