使用PHP和Phalcon作daemon程式

idealities發表於2015-12-03

某些情況下,我們除了提供web介面給使用者,還需要執行一些後臺任務。這些任務可能是由使用者觸發的(比如使用者提交了一個請求,而這種請求很特殊,例如從github克隆一個專案並執行構建,至少需要幾分鐘才能執行完成,這種情況不適合阻塞的方式讓瀏覽器等待結果返回);也可能是一些常規性的系統任務(比如將日誌進行歸檔,轉移到統一的地方進行備份)。前者一般是引入訊息佇列,使用者的請求只是增加了一條待構建的訊息到訊息佇列,然後有一個專門的訂閱者讀取訊息,排程分發執行這個任務。後者最簡單的方式便是crontab,但缺點是每個機器需要單獨進行設定,不易維護;當然也可以通過一個統一的排程器,分發任務到多個任務節點的方式來執行。

這裡只說第一種情況,業務背景是許可範圍內按照使用者的配置生成移動端APP。對於Android,專案構建可以使用Ant或者Gradle,這樣可以通過命令列呼叫,在程式中就可以fork一個程式,設定相應的引數來執行了。

對於不同的Android APP,package name需要是不同的;除此之外,對於使用者的一些配置,也會體現到最終待構建的專案檔案中。因此,只能生成一套APP的模板程式碼,按照使用者的輸入,修改java程式碼和資原始檔,最後再執行構建產生apk。這個構建過程可能需要幾分鐘。

這裡我們採用的方案是有一個單獨的daemon程式,讀取訊息佇列,得到前端寫入的待構建的訊息,fork程式執行Ant,完成構建後(成功或失敗)將狀態寫入資料庫,前端採用定期查詢的方式以便獲知任務是否結束。這樣daemon程式的邏輯相對簡單,構建的過程都是發生在其他程式空間的,不會對daemon程式產生影響。

程式碼採用了類似 Phalcon 示例Multiple的形式。

啟動程式

命令列的入口檔案,參考 @guweigang 的falcon,通過一些引數指定執行哪個模組,哪個action,以及傳遞給action的引數。這裡通過getopt解析命令列引數,-d決定了是否變成daemon程式。

<?php

/**
 * Usage:
 * php console.php -d -m module-name -t task-name -a action-name -p parameters
 *
 * If you has multiple parameters to pass to an action, use it like this:
 * -p first -p second
 *
 */

error_reporting(E_ALL);
ini_set("memory_limit", "4G");

$HELP_TEXT = <<<EOT
appcreator console

usage: php console.php [OPTION]... [PARAMS]...

    -h, --help show help message
    -d, --daemon running as daemon
    -m, --module specify module
    -t, --task specify task
    -a, --action specify action
    -p, --param parameter passed to action

:)

EOT;

try {
    $opts = "dm:t:a:p:h";
    $longopts = array(
                "module:",
                "task:",
                "action:",
                "param:",
                "help",
                "daemon",
                );
    $options = getopt($opts, $longopts);
    if (isset($options[`h`]) || isset($options[`help`])) {
        print($HELP_TEXT);
        exit(0);
    }
    if (isset($options[`d`]) || isset($options[`daemon`])) {
        $pid = pcntl_fork();
        if ($pid == -1) {
            error_log(`fork process failed`);
            exit(1);
        } elseif ($pid) {
            // if in parent process, exit
            exit(0);
        }

        fclose(STDIN);
        fclose(STDOUT);
        fclose(STDERR);

        // TODO
        $STDIN = fopen(`/dev/null`, `r`);
        $STDOUT = fopen(`/dev/null`, `w`);
        $STDERR = fopen(`/dev/null`, `w`);

        posix_setsid();
    }

    $args = array();
    // 處理傳遞給action的引數
    foreach(array(`p`, `param`) as $p) {
        if (isset($options[$p])) {
            if (is_array($options[$p])) {
                $args = array_merge($options[$p]);
            } else {
                $args[] = $options[$p];
            }
        }
    }

    if (!isset($options[`a`]) && !isset($options[`action`])) {
        error_log(`Please specify the action you want to run with -a or --action`);
        exit(1);
    }

    /*
     * set cli parameters
     */
    $args[`module`] = (isset($options[`m`]) || isset($options[`module`])) ? (
                       isset($options[`m`]) ? $options[`m`] : $options[`module`]) : `appcreator`;
    $args[`task`] = (isset($options[`t`]) || isset($options[`task`])) ? (
                       isset($options[`t`]) ? $options[`t`] : $options[`task`]) : `package`;
    $args[`action`] = isset($options[`a`]) ? $options[`a`] : $options[`action`];

    /*
     * 這裡例項化PhalconCLIConsole並載入服務和模組
     */
    $application = new PhalconCLIConsole();
    
    /* load services and modules */
    ...

    /* Finally handle args */
    $application->handle($args);
} catch (Exception $e) {
    error_log(`[AppCreator]` . $e->getMessage() . ` @ ` . $e->getFile() . `:` . $e->getLine());
    echo $e->getMessage();
    echo "

Backtrace:" . $e->getTraceAsString()."
";
}

迴圈讀取訊息,執行任務

這裡是真正的daemon程式執行的程式碼。目前的邏輯比較簡單,就是定期獲取前端寫入的待構建的訊息,建立程式執行一次構建任務。

<?php

public function daemonAction()
{
    // change to working dir
    chdir(WORKING_DIR);

    // install signal handlers
    $this->_setupSignals();

    $nextWakeUp = 0;

    // 每隔時間檢查一次當前需不需要生成APP
    while(1) {
        $this->roused = FALSE;
        if ($nextWakeUp <= time()) {
            $nextWakeUp = time() + self::SLEEP_SECONDS;
        }

        sleep($nextWakeUp - time());
        pcntl_signal_dispatch();
        if ($this->roused) {
            continue;
        }

        $limit = self::MAX_PROCESS - count(self::$childs);
        // exceeds process limit
        if ($limit <= 0) {
            continue;
        }
        
        // find todo tasks
        $tasks = ...;
        foreach ($tasks as $task) {
            $pid = $this->_runTask($task);
        }
    }
}

通過子程式執行任務

_runTask()其實就是呼叫一個新的PHP程式,執行特定的action。我們在這個action再去fork程式執行Ant或者Gradle

<?php

private function _runTask($task)
{
    $pid = pcntl_fork();

    if ($pid == -1) {
        return FALSE;
    } else if ($pid) {
        return $pid;
    } else {
        pcntl_exec($_SERVER[`_`], $this->_getTaskCommand($task));
        // fork succeed but exec failed,
        // need to be _exit(). actually exit() will cause problem
        exit(1);
    }
}

signal處理

這裡daemon程式處理的signal比較簡單,主要就是需要退出和需要wait子程式結束的資訊,以便OS清理其留在程式表中的資訊。但是pcntl_signal_dispatch()有個問題,有可能兩個接近同時退出的子程式,會導致可能當時只發了一個SIGCHLD的訊號,另一個訊號下一次dispatch才會發出,所以只能通過迴圈來wait。

signal handler需要注意一些問題,這篇文章有很詳盡的介紹,非常值得一讀。也可以看看Nginx中是怎麼做的。signal是古老UNIX的產物,signal handler是非同步觸發的,這導致如果signal handler不是可重入的話,很可能會出現問題,雖然一般情況可能不會發生。更現代的處理方式是signalfd(類似的東西還有eventfd,timerfd),這個fd可以通過selectepoll來監聽,有時候和程式的事件機制就結合在一起了。

<?php

private function _setupSignals()
{
    pcntl_signal(SIGTERM, array($this, "signalHandler"));
    pcntl_signal(SIGINT,  array($this, "signalHandler"));
    pcntl_signal(SIGCHLD, array($this, "signalHandler"));
}

protected function signalHandler($signo)
{
    $this->roused = TRUE;
    switch($signo) {
    case SIGTERM:
    case SIGINT:
        exit(1);
    case SIGCHLD:
        while(1) {
            $pid = pcntl_wait($status, WNOHANG);
            if ($pid == 0) {
                break;
            }
            if ($pid > 0) {
                // logs
            } else {
                break;
            }
            if (!pcntl_wifexited($status)) {
                // error happened
            } else if (($code = pcntl_wexitstatus($status)) != 0) {
                // error happened
            } else {
                // normal exit
            }
        }
        break;
    default:
        break;
    }
}


相關文章