Bài viết hướng dẫn cách thực thi dòng lệnh trên server bằng PHP nhưng hiển thị realtime đầu ra trên trình duyệt.

Có nhiều hàm thực thi dòng lệnh, tôi thường dùng hàm proc_open() để có thể kiểm soát được cả STDIN, STDOUTSTDERR.

Hiển thị realtime không cần xử lý gì ở client

Để hiển thị realtime trên trình duyệt mà không cần xử lý gì ở client thì phía server phải đảm bảo 2 vấn đề:

  • Tắt output buffer để mỗi khi lệnh có output thì hiện ra kịp thời chứ không bị tích lại trong output buffer.
  • Content Type phải là text/html.

Ví dụ sau hiển thị đầu ra của lệnh PING trên màn hình trình duyệt.

ping-test.php

<?php
// Example: ping -n 1000 -a 8.8.8.8
$cmd = "ping";
$args = array(
  '-n' => 1000, // 1000 packets
  '-a', // resolve IP address to hostname
  '8.8.8.8', // Target IP address
);

/// Form the command line
$cmd = escapeshellarg($cmd);
foreach ($args as $arg => $value) :
  if (is_string($arg)) :
    $cmd .= ' ' . escapeshellarg($arg) . ' ' . escapeshellarg($value);
  else :
    $cmd .= ' ' . escapeshellarg($value);
  endif;
endforeach;

$descriptorspec = array(
  0 => array("pipe", "r"), // stdin
  1 => array("pipe", "w"), // stdout
  2 => array("pipe", "w")  // stderr
);
$cwd = NULL;
$env = NULL;
$options = array('bypass_shell' => true);

disable_ob();

$process = proc_open($cmd, $descriptorspec, $pipes, $cwd, $env, $options);

if ( is_resource($process) ) :
  header("Content-type: text/html; charset=utf-8"); 
  echo "<pre>";

  while ($s = fgets($pipes[1])) {
    print htmlspecialchars( $s );
    flush(); // dump the line immediately
  }
  $retval = proc_close($process);
  echo "\nCommand returned $retval\n";

  echo "</pre>";
endif;

/**
 * Turn off Output Buffer.
 */
function disable_ob() {
  // Turn off output buffering
  ini_set('output_buffering', 'off');

  // Turn off PHP output compression
  ini_set('zlib.output_compression', false);

  // Implicitly flush the buffer(s)
  ini_set('implicit_flush', true);
  ob_implicit_flush(true);

  // Clear, and turn off output buffering
  while (ob_get_level() > 0) {
    // Get the curent level
    $level = ob_get_level();

    // End the buffering
    ob_end_clean();

    // If the current level has not changed, abort
    if (ob_get_level() == $level) break;
  }

  // Disable apache output buffering/compression
  if (function_exists('apache_setenv')) {
    apache_setenv('no-gzip', '1');
    apache_setenv('dont-vary', '1');
  }
}
?>

Nếu chú ý bạn sẽ thấy các thẻ đóng (điển hình là của <body>, <html> và thẻ chứa đầu ra của lệnh ở đây là <pre>) sẽ không được ghi ra cho đến khi lệnh thực hiện xong.

Đặc biệt nếu HTML có chứa CSS và JavaScript sẽ khó có thể thực thi chính xác khi DOM còn chưa sẵn sàng.

Vì vậy giải pháp tốt hơn là hãy sử dụng Server-sent Events để phía client xử lý đầu ra của lệnh một cách chủ động khi mọi thứ đã sẵn sàng.

Hiển thị realtime dùng Server-sent Events

Ta chỉnh lại đoạn mã trên một chút:

  • Đổi content type thành text/event-stream.
  • Ghi chuỗi đầu ra theo format của event stream. Xem thêm ›.

ping-test-sse.php

<?php
// Example: ping -n 1000 -a 8.8.8.8
$cmd = "ping";
$args = array(
  '-n' => 1000, // 1000 packets
  '-a', // resolve IP address to hostname
  '8.8.8.8', // Target IP address
);

/// Form the command line
$cmd = escapeshellarg($cmd);
foreach ($args as $arg => $value) :
  if (is_string($arg)) :
    $cmd .= ' ' . escapeshellarg($arg) . ' ' . escapeshellarg($value);
  else :
    $cmd .= ' ' . escapeshellarg($value);
  endif;
endforeach;

$descriptorspec = array(
  0 => array("pipe", "r"), // stdin
  1 => array("pipe", "w"), // stdout
  2 => array("pipe", "w")  // stderr
);
$cwd = NULL;
$env = NULL;
$options = array('bypass_shell' => true);

disable_ob();

$process = proc_open($cmd, $descriptorspec, $pipes, $cwd, $env, $options);

if ( is_resource($process) ) :
  header("Content-type: text/event-stream; charset=utf-8"); 

  while ($s = fgets($pipes[1])) :
    print "data: " . htmlspecialchars( $s ) . PHP_EOL;
    print PHP_EOL;
    flush(); // dump the line immediately
  endwhile;
  $retval = proc_close($process);

  print "id: close" . PHP_EOL; // notify end of script

  print "data: Command returned $retval" . PHP_EOL;
  print PHP_EOL;
endif;

/**
 * Turn off Output Buffer.
 */
function disable_ob() {
  // Turn off output buffering
  ini_set('output_buffering', 'off');

  // Turn off PHP output compression
  ini_set('zlib.output_compression', false);

  // Implicitly flush the buffer(s)
  ini_set('implicit_flush', true);
  ob_implicit_flush(true);

  // Clear, and turn off output buffering
  while (ob_get_level() > 0) {
    // Get the curent level
    $level = ob_get_level();

    // End the buffering
    ob_end_clean();

    // If the current level has not changed, abort
    if (ob_get_level() == $level) break;
  }

  // Disable apache output buffering/compression
  if (function_exists('apache_setenv')) {
    apache_setenv('no-gzip', '1');
    apache_setenv('dont-vary', '1');
  }
}
?>

Trong đó phần được thay đổi là

header("Content-type: text/event-stream; charset=utf-8"); 

while ($s = fgets($pipes[1])) {
  print "data: " . $s . PHP_EOL;
  print PHP_EOL;
  flush(); // dump the line immediately
}
$retval = proc_close($process);

print "id: close" . PHP_EOL; // notify end of script

print "data: Command returned $retval" . PHP_EOL;
print PHP_EOL;

Phía client chuẩn bị một nội dung HTML như sau:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Ping SSE</title>
</head>
<body>
<!-- script output goes here -->
<pre id="output"></pre>
<script type="text/javascript" src="sse.js"></script>
</body>
</html>

Đoạn mã JavaScript dùng EventSource để đọc đầu ra của lệnh, chèn vào giữa <pre></pre> mỗi khi có data mới.

sse.js

if ( !!window.EventSource ) {
  var output = document.getElementById("output");
  var source = new EventSource("ping-test-sse.php");

  source.onopen = function(e) {
    console.log("SSE connection was established");
  };

  source.onerror = function(e) {
    alert("SSE error!");
    console.error(e);
  }

  source.onmessage = function(e) {
    output.innerHTML += e.data + "\r\n";

    if (e.lastEventId === 'close') // id 'close' was found
      source.close();
  };
} else {
  alert("Your web browser does not support SSE");
}

Như vậy với cách làm này, thì phần CSS và JavaScript xử lý đã được load đầy đủ khi thực thi lệnh, giao diện hiển thị kết quả sẽ được đảm bảo.