服务器推送事件的while循环导致页面冻结

25

我目前正在开发一个聊天功能,使用服务器发送事件接收消息。然而,我遇到了一个问题。由于页面没有加载,服务器发送事件永远无法连接并保持在挂起状态。

例如:

<?php
    while(true) {
        echo "data: This is the message.";
        sleep(3);
        ob_flush();
        flush();
    }
?>

我希望每三秒会输出 "data: This is the message." ,但实际上页面并没有加载。然而,我需要这种行为来进行服务器发送事件。有没有办法修复这个问题?

编辑:

完整代码:

<?php
   session_start();

    require "connect.php";
    require "user.php";

    session_write_close();

    echo $data["number"];

    header("Content-Type: text/event-stream\n\n");
    header('Cache-Control: no-cache');

    set_time_limit(1200);

    $store = new StdClass(); // STORE LATEST MESSAGES TO COMPARE TO NEW ONES
    $ms = 200; // REFRESH TIMING (in ms)
    $go = true; // MESSAGE CHANGED

    function formateNumber ($n) {
            $areaCode = substr($n, 0, 3);
            $part1 = substr($n, 3, 3);
            $part2 = substr($n, 6, 4);
            return "($areaCode) $part1-$part2";
    }

    function shorten ($str, $mLen, $elp) {
        if (strlen($str) <= $mLen) { 
            return $str;
        } else {
            return rtrim(substr($str, 0, $mLen)) . $elp;
        }
    }

   do {
    $number = $data["number"];
        $sidebarQ = "
            SELECT * 
            FROM (
                SELECT * 
                FROM messages 
                WHERE deleted NOT LIKE '%$number%' 
                AND (
                    `from`='$number' 
                    OR 
                    `to`='$number'
                ) 
                ORDER BY `timestamp` DESC
            ) as mess 
            GROUP BY `id` 
            ORDER BY `timestamp` DESC";
        $query = $mysqli->query($sidebarQ);

        if ($query->num_rows == 0) {
            echo 'data: null' . $number;
            echo "\n\n";
        } else {

            $qr = array();
            while($row = $query->fetch_assoc()) {
                $qr[] = $row;
            }

            foreach ($qr as $c) {
                $id = $c["id"];
                if (!isset($store->{$id})) {
                    $store->{$id} = $c["messageId"];
                    $go = true;
                } else {
                    if ($store->{$id} != $c["messageId"]) {
                        $go = true;
                        $store->{$id} = $c["messageId"];
                    }
                }
            }

            if($go == true) {
                $el = $n = "";

                foreach ($qr as $rows) {
                    $to = $rows["to"];
                    $id = $rows["id"];
                    $choose = $to == $number ? $rows["from"] : $to;
                    $nameQuery = $mysqli->query("SELECT `savedname` FROM `contacts` WHERE `friend`='$choose' AND `number`='$number'");
                    $nameGet = $nameQuery->fetch_assoc();
                    $hasName = $nameQuery->num_rows == 0 ? formateNumber($choose) : $nameGet["savedname"];

                    $new = $mysqli->query("SELECT `id` FROM `messages` WHERE `to`='$number' AND `tostatus`='0' AND `id`='$id'")->num_rows;
                    if ($new > 0) {
                        $n = "<span class='new'>" . $new . "</span>";
                    }

                    $side = "<span style='color:#222'>" . ($to == $number ? "To you:" : "From you:") . "</span>";
                    $el .= "<div class='messageBox sBox" . ($nameQuery->num_rows == 0 ? " noname" : "") . "' onclick=\"GLOBAL.load($id, $choose)\" data-id='$id'><name>$hasName</name><div>$side " . shorten($rows["message"], 25, "...") . "</div>$n</div>";
                }
                echo 'data: '. $el;
                echo "\n\n";

                $go = false;
            }
        }

        echo " ";

        ob_flush();
        flush();
        sleep(2);
    } while(true);
?>

我还想指出的是,这个无限循环不应该导致这种情况发生。这通常是SSE的设置方式,甚至在MDN网站上也是如此。


6
你为什么要用无限循环来做这件事?这就是页面加载不出来的原因,这还能怎么加载呢? - adeneo
5
你看,在 MDN 网站上他们使用了一个无限循环。 - Shawn31313
3
在设置Content-Type头部之前,请删除echo $data["number"];。如果在设置Content-Type头部之前打印了内容,则会得到默认的内容类型,可能是text/html。 - developerwjk
1
@sjagr 浏览器可能不支持,但服务器发送事件不使用浏览器来加载内容。就像PHP CLI一样,输出可以在脚本执行的任何时候发送。 - scrowler
2
在这种情况下,它应该返回数据,因为它正在使用输出缓冲,但是对于每个服务器来说,这有时可能会有些棘手。值得用更简单的页面测试您的输出缓冲代码,然后查看是否仍然适用。 - Angry 84
显示剩余14条评论
10个回答

12

毫无疑问,你现在已经明白了这一点,但万一你还没有搞清楚,我在几个sse脚本中使用了以下类似的代码,并且效果非常好。下面的代码是通用的,不包含您的sql或记录集处理,但想法是正确的(!)

<?php
    set_time_limit( 0 );
    ini_set('auto_detect_line_endings', 1);
    ini_set('mysql.connect_timeout','7200');
    ini_set('max_execution_time', '0');

    date_default_timezone_set( 'Europe/London' );
    ob_end_clean();
    gc_enable();



    header('Content-Type: text/event-stream');
    header('Cache-Control: no-cache');
    header('Access-Control-Allow-Credentials: true');
    header('Access-Control-Allow-Methods: GET');
    header('Access-Control-Expose-Headers: X-Events');  




    if( !function_exists('sse_message') ){
        function sse_message( $evtname='chat', $data=null, $retry=1000 ){
            if( !is_null( $data ) ){
                echo "event:".$evtname."\r\n";
                echo "retry:".$retry."\r\n";
                echo "data:" . json_encode( $data, JSON_FORCE_OBJECT|JSON_HEX_QUOT|JSON_HEX_TAG|JSON_HEX_AMP|JSON_HEX_APOS );
                echo "\r\n\r\n";    
            }
        }
    }

    $sleep=1;
    $c=1;

   $pdo=new dbpdo();/* wrapper class for PDO that simplifies using PDO */

    while( true ){
        if( connection_status() != CONNECTION_NORMAL or connection_aborted() ) {
            break;
        }
        /* Infinite loop is running - perform actions you need */

        /* Query database */
        /*
            $sql='select * from `table`';
            $res=$pdo->query($sql);
        */

        /* Process recordset from db */
        /*
        $payload=array();
        foreach( $res as $rs ){
            $payload[]=array('message'=>$rs->message);  
        }
        */

        /* prepare sse message */
        sse_message( 'chat', array('field'=>'blah blah blah','id'=>'XYZ','payload'=>$payload ) );

        /* Send output */
        if( @ob_get_level() > 0 ) for( $i=0; $i < @ob_get_level(); $i++ ) @ob_flush();
        @flush();

        /* wait */
        sleep( $sleep );
        $c++;

        if( $c % 1000 == 0 ){/* I used this whilst streaming twitter data to try to reduce memory leaks */
            gc_collect_cycles();
            $c=1;   
        }
    }



    if( @ob_get_level() > 0 ) {
        for( $i=0; $i < @ob_get_level(); $i++ ) @ob_flush();
        @ob_end_clean();
    }
?>

1
您将使用VPS或专用服务器,其中set_time_limit和其他ini_set默认启用,在某些托管中,这些功能将无法工作,因为系统管理员已由于共享环境而禁用了它们。 - Touqeer Shafi

6
虽然这不是问题的直接答案,但是尝试使用这种方法来查找错误。你没有收到报错信息,但是这种方法也许可以帮助你找到错误?
基本上,你需要有一个简单的PHP脚本来包含你的主要脚本,但这个页面可以启用错误信息... 以下是示例。
index.php / 简单的错误包含器:
<?php
    ini_set('display_errors',1);
    ini_set('display_startup_errors',1);
    error_reporting(-1);
    require "other.php";
?> 

other.php / 你的主要脚本

<?php
    ini_set('display_errors',1);
    ini_set('display_startup_errors',1);
    error_reporting(-1);
 weqwe qweqeq
 qweqweqweqwe

?> 

如果您创建了这样的设置,如果查看index.php,则会看到以下错误Parse error: syntax error, unexpected 'qweqeq' (T_STRING) in /var/www/html/syntax_errors/other.php on line 5因为它在主页上没有无效的语法并允许任何包含被错误检查。
但是,如果您查看other.php,则只会得到一个白色/空白页面,因为它无法验证整个页面/脚本。
我在我的项目中使用此方法,以便无论我在other.php或任何链接的php页面中做什么,都将看到有关它们的错误报告。 请在发表评论之前理解代码。 说这会禁用错误控制意味着您没有翻阅手册。 填充缓冲区 过去我记得另一个问题是在输出到浏览器之前填充缓冲区。因此,请在循环之前尝试类似于以下内容的东西。
echo str_repeat("\n",4096);  // Exceed the required browser threshold 
for($i=0;$i<70;$i++)     {
    echo "something as normal";
    flush();
    sleep(1);
}

请参考http://www.sitepoint.com/php-streaming-output-buffering-explained/ 上的示例,该网页涉及到IT技术相关内容,解释了PHP流输出缓冲的作用。


我不确定这是否是原帖作者想要的。而且,那是完全错误的。你暂时将错误设置为不显示,所以你看不到它们。同样的错误仍然存在。这与两个文件无关,也不是一种“技巧”。关闭所有错误提示是一个非常糟糕的想法。 - Evan Carslake
第一句话说明这不是一个直接的答案,这是在 PHP 脚本中显示错误的最常见方法。它不会禁用错误控制。error_reporting -1 表示显示所有错误...请参考 http://php.net/manual/en/function.error-reporting.php ... 至于这个“技巧”,它实际上非常方便。很抱歉你不同意...我会让大多数人决定这个问题 ;) - Angry 84
“填充[浏览器的!]缓冲区”建议实际上是一个救命稻草!它完全不明显,在示例中很少提到,并且SO充满了试图避免第一条消息延迟的巫术...所以,至少要感谢这个建议!” - Sz.

5

看起来sleep函数正在干扰输出。将sleep函数放在之后确实有效:

<?php
while(true) {
    echo "data: This is the message.";
    ob_flush();
    flush();
    sleep(3);
    }

正如其他人所建议的,我鼓励使用AJAX而不是无限循环,但这不是你的问题。

请注意,脚本很可能在第30次迭代之后才会显示任何输出(在我的环境中是第37次)。如果您将睡眠时间减少到1,则可以在刷新之前放置睡眠以使其正常工作。 - galfrano
这听起来很简单,你似乎没有考虑到脚本执行的最长时间为30秒。 - Angry 84
30秒只是默认值。即使不更改php.ini,该指令也可以通过ini_set或.htaccess文件进行覆盖。对于这个问题,我认为暗示这样的限制并不是问题所在。 - galfrano
当然,这是针对您的第一条评论而言。不过现在想想,这只是缓冲区没有满。当使用输出缓冲时,最好发送几百或几千个空格来填充初始缓冲区。这是OB的常见做法。这也取决于浏览器。 - Angry 84

5
以下代码在这里运行良好,并使用Mayhem的str_repeat函数添加4k的数据(通常最小的tcp数据包被php刷新)

echo str_repeat(' ', 4096);
while(true)
{
    echo "data: This is the message.";
    flush();
    sleep(3);
}


2
通常情况下,您不需要在循环中使用4096个字符,只需要最初使用一次即可。之后,您可以根据需要使用小的内容/输出进行刷新。我会将str_repeat函数移动到while /loop上方。 - Angry 84
1
是的,这里有一个常见的混淆:有(至少...)_两个_缓冲区:PHP的OB和浏览器自己的输入缓冲区!驯服PHP方面相对容易。_初始_4(甚至8,例如在这里的Firefox中)KB是为了_浏览器_,以使其实际呈现“未完成”的页面。 - Sz.

4

不要使用循环,尝试使用下面给出的代码,根据你的要求,我已经测试过它的可行性。

echo "data: This is the message.";
$url1="<your-page-name>.php";
header("Refresh: 5; URL=$url1");

这将会每5秒钟调用一次自己(在您的情况下将其设置为3而不是5),并输出内容。

4
我注意到这里有一个与 ob_start() 结合使用的 sleep() 函数,但是在完整的代码示例中没有出现 ob_start() ,而有出现 flush()ob_flush() .. 你究竟要刷新什么?为什么不直接使用 ob_end_flush() 呢?
问题在于当输出缓冲打开时,sleep() 然后 echo(),再 sleep(),再 echo()等等,都没有效果。当输出缓冲未开启时,睡眠函数按预期工作 - 在中间。事实上,它可能会(并且确实会)产生相当意外的结果,而且这些结果不是我们想看到的结果。

我知道在这里我也会使用flush(),以尝试将数据发送到浏览器,而无需填充PHP和Apache缓冲区。这些缓冲区与您提到的代码空间输出缓冲区不同。 - creuzerm
你的代码中 ob_start() 在哪里?我之所以问“你到底要清除什么?”是因为这个。你在哪里开始 output_buffering?在使用其他缓冲函数之前,你需要在某个地方开始它。我看到的唯一一个带有“start”的是 session_start(),但它与 output_buffering 没有任何关系...两个不同的世界。 - Spooky
如果没有输出缓冲区需要刷新,ob_flush()在这里将不起作用。这是一种粗糙的编码方式,但也有可能解决一个库在其他地方打开输出缓冲区的问题(不在提供的示例中)。 - creuzerm
关于编码本身,调用header()函数后使用set_time_limit()也是一个不好的主意。此外,ob_flush()ob_end_flush()并不相同。flush()ob_flush()更像ob_end_flush()..就像$var = ob_get_clean();$var = ob_get_contents(); ob_end_clean();是一样的。从您的角度来看,我可以告诉您,您仍然没有习惯查看php最可靠的来源*(http://php.net - 手册)为我们提供了简要的解释。包括每个php武器库中非常有价值的注释。 - Spooky

4

我曾经遇到同样的问题,最终在Kevin Choppin的博客上找到了简单快速的解决方案:

会话锁定
首先,如果您出于任何原因使用会话,您需要在流中将它们设置为只读。如果它们是可写的,这将在其他所有地方锁定它们,因此任何页面加载都将挂起,而服务器将等待它们再次变为可写。这很容易通过调用session_write_close();来解决。


谢谢!你救了我的一天!运行得非常好! - Frugan

3

我会尝试进行直白的陈述,假设你可以每3秒向服务器发出查询请求,并让客户端等待...

这可以很容易地通过javascript实现

例如,请尝试以下代码,文件名为file.php

<?php
$action='';
if (array_key_exists('action',$_GET))
{$action=$_GET['action'];}
if ($action=='poll')
{
 echo "this message will be sent every 3 sec";
}
else
{
?><HTML><HEAD>
<SCRIPT SRC="http://code.jquery.com/jquery-2.1.3.min.js"></SCRIPT>
<SCRIPT>
function doPoll()
{
    $('#response').append($.get("file.php?action=poll"));
    setTimeout(doPoll, 3000);
}
doPoll();
</SCRIPT>
</HEAD><BODY><DIV id="response"></DIV></BODY></HTML><?php
}

3
这是完全错误的答案。你所做的是长轮询,这违背了服务器推送事件的初衷,并且在对服务器和客户端的压力方面,比OP尝试实现的方式(websocket、服务器推送事件、forever frame以及这种长轮询技术)至少高出3到4个数量级。请参考以下教程:http://www.howopensource.com/2014/12/introduction-to-server-sent-events/ - BMac

3

这是否仅仅是脚本超时的简单问题?

如果 PHP 脚本运行时间过长,它们最终会自动终止。如果您不希望发生这种情况,解决方法是不断地重置超时时间。

所以,您可能只需要添加一个简单的代码:

<?php
    while(true) {
        echo "data: This is the message.";
        set_time_limit(30);
        sleep(3);
        ob_flush();
        flush();
    }
?>

当然,也许不是这个问题,但我的直觉告诉我这可能是问题所在。

更新:我在评论中注意到您正在使用一些免费的托管服务。如果他们在运行PHP时处于“安全模式”,则无法重置超时时间。


1
这不能仅通过 PHP 实现。更新需要页面重新加载或 AJAX。 - Evan Carslake
1
@EvanCarslake 这可以通过纯PHP完成,这就是输出缓冲的酷部分...如果您有一个加载带有输出缓冲的PHP的iframe,您可以使用PHP代码触发更新...虽然它并不总是一个干净的解决方案,但它确实有效,并且是一个非常著名的方法。 - Angry 84

0
我建议使用if()语句而不是while循环。在你的情况下,你的条件总是为真,所以会导致无限循环。

1
他的脚本的目的是...将循环替换为IF语句后,程序只会执行一次然后结束。使用循环/while true意味着它将始终运行而不需要虚假条件。这是始终返回true条件检查的最简单方法。 - Angry 84

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