标签PHP下的文章

Jerry Bendy 发布于 11月09, 2016

docker 启动多个 PHP-FPM 容器并配置 nginx 负载均衡

我的 API 服务已经迁到 docker 以及美国服务器有一周的时间了,不知道是网络的问题还是 docker 的问题,迁到美国的服务器后明显感觉并发时不如之前在阿里云时稳定。之前在阿里云部署时一个页面 40 个请求毫无压力(之前也没用 docker,直接 LNMP 架构部署),但在迁移之后只要并发数量一高,FPM 进程准会挂掉。我自己使用的一个工具页面上有四十多个小图标需要调用这个 API 服务,只要一刷新 FPM 必挂。

尝试过调整 docker 内 FPM 进程的子进程数量,效果并不明显,加上服务器配置低,单个 FPM 进程子进程数不能调太高,否则容易影响其它服务(我猜的)。于是乎想到一个办法:启动两个 FPM 容器,两个容器拥有相同的配置以及子进程数,两者共同承担后端的请求。

一般来说单台服务器上都是配置一个 nginx 进程以及一个 FPM 进程分别处理静态及动态请求,但单台机器上多个 upstream 后端比单个后端进程能够带来更高的吞吐量。例如你想支持最大 1000 个 PHP-FPM 子进程,可以将这 1000 个子进程平均分配到两个 upstream 后端,各自处理 500 个子进程。

修改之前的 Nginx 配置是这样的:

server
{
    # .....

    location ~ [^/]\.php(/|$)
    {
        try_files $uri =404;
        fastcgi_pass  127.0.0.1:9000;
        fastcgi_index index.php;
        fastcgi_param  SCRIPT_FILENAME  $document_root$fastcgi_script_name;
        include fastcgi_params;
    }
}

现在只需要修改 docker-compose.yml 文件,添加一个 PHP-FPM 进去,并将容器内的 9000 端口分别映射到宿主机不同的端口上(9000 和 9001)。Nginx 的设置比较简单,在配置的 http 段中添加一个 upstream,并把原来的 fastcgi_pass 地址改到这个 upstream 即可。

Nginx 的 http 段添加:

upstream phpFpm
{
    server 127.0.0.1:9000;
    server 127.0.0.1:9001;
}

网站配置稍微修改一下:

server
{
    # .....

    location ~ [^/]\.php(/|$)
    {
        try_files $uri =404;
        fastcgi_pass  phpFpm;
        fastcgi_index index.php;
        fastcgi_param  SCRIPT_FILENAME  $document_root$fastcgi_script_name;
        include fastcgi_params;
    }
}

修改完成后重启 docker-compose 和 Nginx 即可生效。至于效果嘛~~亲测果然有很大的提升,修改之前四十多个并发的请求一般会挂掉一大半,现在最多就挂七八个。。。下不步要不要考虑分配三个 FPM 容器做负载处理 ^_^ 当然最主要还是赶快把国内的服务器搞定,两台机器来做负载,而不是两个容器,毕竟大部分请求是在国内。

阅读全文 »

Jerry Bendy 发布于 10月19, 2016

如何在 docker 中使用 PHP FPM

已经有段时间没写过东西了,最近在着手把之前的 PHP 服务 docker 化,以方便在两台服务器之间部署。整个学习和使用 docker 的过程还算顺利吧,但在部署 PHP FPM 的过程中遇到了一些问题,以下作为记录供遇到同样问题的人参考吧。

系统架构

因为我可能会经常修改 Nginx 配置,加上我是自己编译的最新版的 Nginx + Openssl (为了启用 HTTP/2),所以就懒得把 Nginx 打包成 docker 镜像了,而是直接将 Nginx 装在了宿主机,并开放 80 和 443 端口。

系统所需的除 Nginx 以外的其它服务全部由 docker 提供服务,如 PHP 和 Redis。每一个服务使用一个容器,均为官方镜像。Redis 的使用就不说了,比较简单,说下在使用 PHP 时遇到的一些问题吧,主要是 Nginx 与 PHP 通信的问题。

附上 docker hub 官方的 PHP 首页地址:https://hub.docker.com/_/php/

失败的就不多说了,说下成功的两种方式:

方法一:Nginx 反向代理 + php-apache

这种方式使用 docker hub 官方提供的 php-apache 镜像。

$ docker pull

因为镜像内已经安装好了 PHP 和 Apache,所以只需在容器上开放端口,使用 Nginx 反向代理就可以了,比较简单。

例如下面的将容器内的 80 端口映射到宿主机的 9090 端口:

$ docker run -d -p 9090:80 -v /home/www/:/var/www/html  php:7.0-apache

Nginx 配置下反向代理即可:

location / {
    proxy_pass   127.0.0.1:9090;
}

方法二:Nginx + php-fpm

这种方式只使用 PHP 的容器提供 FPM 服务供宿主机的 Nginx 调用,会比多一个 Apache 来得更轻量一些。只是这种方式一直比较困扰我的是如何在 Nginx 里面调用容器内的 FPM 服务,毕竟容器内外的环境是不一样的,和直接宿主机安装 LNMP 还是有不少差距的。更纠结的是,docker hub 官方文档中并没有说 php-fpm 怎么用。

经过多次尝试最终发现 Nginx 调用 fpm 服务是通过 fastcgi 参数进行的。如通过 SCRIPT_FILENAME 参数指定要加载的文件路径。

一般 LNMP 环境 Nginx 的配置可能是这样的(部分):

location ~ [^/]\.php(/|$) {
   try_files $uri =404;
   fastcgi_pass   127.0.0.1:9000;
   fastcgi_index  index.php;
   fastcgi_param  SCRIPT_FILENAME  $document_root$fastcgi_script_name;
   include        fastcgi_params;
}

fastcgi_pass 倒是很好理解,指定 fpm 服务的调用地址,只需要把容器中的 9000 端口映射到宿主机的 9000 端口上就可以了。

fastcgi_index 也好理解,就是默认文件嘛。

重点在 fastcgi_param,每一个 fastcgi_param 指令都定义了一个会发送给 cgi 进程的参数,打开 Nginx 配置目录中的 fastcgi_params 文件可以看到里面定义了很多参数。其中,SCRIPT_FILENAME 对我们来说算是最重要的。

SCRIPT_FILENAME 指令指定了 cgi 进程需要加载的文件路径。例如用户访问 http://xxx.com/a.php,Nginx 中将会处理此次请求。Nginx 判断后缀名是 .php 的请求后将会把此次请求转发给 cgi 进程处理,即 fastcgi_pass;转发的过程中会携带一些和访问相关的参数或其它预设的参数(fastcgi_param),然而这个 cgi 进程(PHP FPM)并不知道要加载的文件在哪里,这便是 SCRIPT_FILENAME 的作用了。

简单的说,配置 SCRIPT_FILENAME 的值就是要做到 FPM 进程能找到这个文件就可以了。例如代码目录存放在宿主机的 /home/www 目录下,我们使用 -v 命令启动 docker 时把代码目录映射到了容器内部的 /var/www/html 目录下:

$ docker run -d -p 9000:9000 -v /home/www:/var/www/html php:7.0-fpm

因为 fpm 进程是运行在容器里面的,所以 SCRIPT_FILENAME 查找的路径一定是在容器内能找到的,即:

fastcgi_param  SCRIPT_FILENAME  /var/www/html/$fastcgi_script_name;

至此应该全明白了吧,Nginx 配置中的 SCRIPT_FILENAME 要和容器中保持一致才行。当然也可以让容器中的目录结构保持与宿主机中一致,即 -v /home/www:/home/www,这样配置的时候可能会方便一些,不会出现因目录不一致而出错的机率。

总结

学习就是踩坑的过程,踩着踩着就学会了。。。

阅读全文 »

Jerry Bendy 发布于 05月21, 2016

Laravel 5 自定义环境变量

Laravel 5 中提供了一种通过.env文件定义环境变量的方式,根据官方文档的说明应该在不同的环境下使用不同的.env文件,并且此文件不应该提交到版本控制中去。如此设定自然是为了方便不同的环境或者多名开发人员完全可以使用自已的.env环境变量。

但是官方文档中对于如何自定义.env文件中的环境变量却提的很少。文档中只提及了在需要自定义环境变量时最好是在.env.example文件中写一份变量的定义,以方便其他开发人员配置。

下面就以我的“遭遇”来讲下 Laravel 5 中如何自定义环境变量。

(内容比较啰嗦,可直接跳到最后环境变量的正确用法部分)

问题

因为我需要路由里面根据不同的二级域名选择不同的控制器,而测试环境和生产环境中的顶级域名不同,于是我打算把顶级域名作为一项环境变量写在.env文件中。

```conf .env APP_BASE_URL=test.com


路由中的写法(为了演示我把路由的处理直接写成了闭包,而实际用于生产环境的代码是不能写成闭包的,原因就是路由缓存不支持闭包,不打算使用路由缓存的可以无视):

```php route.php

$_app_base_url = env('APP_BASE_URL');

Route::group(['domain' => "u.{$_app_base_url}"], function() {
    Route::get('/', function(){
        return "TEST";
    });
});

恶梦就此开始。

env()函数与$_ENV超全局变量

这种写法本身是没有任何问题的,访问u.test.com,在开发环境中一切正常。然后使用命令php artisan config:cache生成配置缓存后却出现了找不到控制器的错误。经过调试发现$_app_base_url的输出值是null

env()函数的作用是从$_ENV超全局变量中取出对应的值,而 Laravel 在启动的时候又会自动加载.env文件中的信息到$_ENV超全局变量中,所以如果没问题的话在$_ENV环境变量中应该能找到刚才定义的环境变量。

var_dump($_ENV);

输出了很多环境变量的信息,但。。。没有看到任何在.env里面定义的信息。难道信息没有被加载到$_ENV?于是尝试清除配置缓存:php artisan config:clear后再尝试,发现.env里面的信息确实被加载到了$_ENV超全局变量中。。。

看来这个问题和配置缓存脱不了关系了。

配置缓存

Laravel 中为了加快程序的执行效率做了很多缓存优化的工作,其中就包括配置缓存、路由缓存等,通过把多个零碎的配置文件合并成一个大的配置文件来减少加载的文件数量,从而加快运行速度(如果你研究过PHP的性能的话就会知道IO操作其实占了很大一部分开销)。

Laravel 的配置缓存被保存在bootstrap/cache/config.php文件中。打开这个文件可以看到这个文件就是把config文件夹的所有文件合并成了一个大的配置文件。config.php直接返回一个数组,数组的键名对应config文件夹下的文件名,数组的值对应config文件夹下文件返回的配置信息。

找遍整个配置文件发现没有任何和.env文件里面的定义相关的内容。

难道env()函数会从配置缓存中读取数据,因为这个文件里面没有对应的数据所以才返回null?抱着这个想法去查看env()的源码,发现这个函数和配置缓存没任何关系。。。

.env文件的加载

这时我产生一个想法:有没有可能是框架检测存在配置缓存文件时就不去加载.env了呢?

如果是这样的话框架源码里面肯定会有地方去判断bootstrap/cache/config.php文件是否存在。

直接在vendor里面搜cache/config.php,果然找到在vendor/laravel/framework/src/Illuminate/Foundation/Application.php的第836行(关于文件和行的信息都是基本我现在使用的 Laravel 5.2.31,版本号不同具体位置也可能不同):

```php Application.php /**

 * Get the path to the configuration cache file.
 *
 * @return string
 */
public function getCachedConfigPath()
{
    return $this->bootstrapPath().'/cache/config.php';
}

`getCachedConfigPath()`函数返回了这个配置缓存文件的路径。继续查找这个函数,发现除了控制台部分外共有两个地方使用了这个函数,分别是`Illuminate\Foundation\Bootstrap\LoadConfiguration::LoadConfiguration`和`Illuminate\Foundation\Application::configurationIsCached`。前者是判断如果配置缓存文件存在就包含它,并不再从`config`文件夹下加载配置文件;后者是定义了一个`configurationIsCached()`函数用于返回配置缓存文件是否存在。

根据线索继续查找`configurationIsCached()`函数,找到了唯一的调用方:`vendor/laravel/framework/src/Illuminate/Foundation/Bootstrap/DetectEnvironment.php`的第19行:

```php DetectEnvironment.php
class DetectEnvironment
{
    /**
     * Bootstrap the given application.
     *
     * @param  \Illuminate\Contracts\Foundation\Application  $app
     * @return void
     */
    public function bootstrap(Application $app)
    {
        if (! $app->configurationIsCached()) {
            $this->checkForSpecificEnvironmentFile($app);

            try {
                (new Dotenv($app->environmentPath(), $app->environmentFile()))->load();
            } catch (InvalidPathException $e) {
                //
            }
        }
    }

    // ......
}

bootstrap()中可以看到,这里会检查配置缓存文件是否存在,如果不存在就会去加载.env文件,否则就什么都不做。正好验证了前面的猜测:在生成配置缓存之后就不会去加载.env文件了。

(通过上面代码中的$this->checkForSpecificEnvironmentFile($app);往下跟踪,发现还可以使用.env.APP_ENV的方式定义跟随环境的配置信息,例如.env中如果定义了APP_ENV=local的话,在加载环境变量时也会尝试加载.env.local文件)。

.env应该什么时候被加载?

至此应该是真相大白了。那么.env应该什么时候被加载呢?

当然开发环境中不需要生成配置缓存,所以每次请求都会重新加载和解析.env文件并设置到$_ENV超全局变量中。生产环境中呢?

这时另一个猜想产生了:既然bootstrap/cache/config.php缓存文件中没有关于环境变量的信息,并且系统没有尝试加载.env文件,会不会有可能是已经把环境变量保存到了config.php缓存文件中了呢?如果真的是这样的话,那么env()函数就只能在config/*.php中的配置文件里面被调用(因为生成了配置缓存后就不再加载环境变量,程序的其它地方再去访问环境变量是得不到.env里面的信息的)。

全局搜索env(,猜对了,果然只在config文件夹里面的文件中使用这个函数,其它地方是没有调用过的。

综合整理一下上面的过程,也就是说如果在.env里面自定义了一个环境变量,就需要在config文件夹下的任意一个配置文件中把这个环境变量添加进去,这样生成的配置缓存中才会包含这个信息。

环境变量的正确用法

好吧,只能说明是我认为是正确的用法。

首先肯定是要在自己的.env文件中定义这个环境变量:

```code .env APP_BASE_URL=test.com


然后还需要把这个环境变量的定义写到`.env.example`文件中,以方便团队协作时其他成员能更好的理解你定义的这个变量。

然后很重要的一步,你还需要把这个环境变量写到配置文件中去。因为生成配置缓存时加载配置文件的过程是遍历整个`config`文件夹,所以你可以在`config`文件中任意新建一个PHP文件用来保存自己定义的环境变量,或者修改现有的任一配置文件。

就以新建配置文件为例吧,在`config`文件夹下新建`demo.php`文件:

```php demo.php
<?php

return [
    'app_base_url'  => env('APP_BASE_URL', 'default value'),
];

是的,我们是在这个配置文件中调用的env()函数。这样在生成配置缓存时就会在这里读取环境变量。

命令行执行;

php artisan config:cache

然后再打开bootstrap/cache/config.php文件,会发现其中多了一部分:

```php config.php //....... 'demo' => array( 'app_base_url' => 'test.com', ),


至此自定义环境变量的过程已经圆满结束。当然因为使用了配置缓存,所以在程序中需要读取自定义环境变量的时候也就不能使用`env()`函数。内容存储在配置中,自然要用`config()`函数。

上面的例子:

```php route.php

$_app_base_url = config('demo.app_base_url');

Route::group(['domain' => "u.{$_app_base_url}"], function() {
    Route::get('/', function(){
        return "TEST";
    });
});

config()函数使用点号作为分隔符,点号前面部分是配置文件名(例子中配置文件是demo.php,所以是demo),点号后面是配置项的键名(app_base_url)。

总结

总结就是如果你想同时使用自定义环境变量和配置缓存的话,你就需要自定义一个配置项来读取环境变量的值。

最后记得不要忘了把创建配置缓存命令写到你的构建脚本或自动部署中。

阅读全文 »

Jerry Bendy 发布于 03月24, 2016

前端AJAX请求跨域时遇到的一些坑

这两天在做公司的PC站时因为需要使用angular$http服务存取数据,而且接口又在另一个域名下面,不得不研究下跨域的问题. 以下把这两天遇到的一些问题总结下.(都是我自己遇到的一些问题, 所以可能不太全面)

Access-Control-Allow-Origin的问题

跨域遇到的第一个问题就是Access-Control-Allow-Origin的错误, Chrome报错Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.. 即当前发出请求的域名不在服务器的白名单中, 怎么办呢?

当然,最简单的方法就是在被访问的服务端返回的内容上面加上Access-Control-Allow-Origin响应头, 值为*或是当前网站的域名. 使用*的话虽然方便, 但容易被别的网站乱用,总归有些不太安全; 设置为当前网站的域名的话又只能设置一个. 我的解决办法是设置一个允许的域名白名单, 判断当前请求的refer地址是否在白名单里,如果是,就设置这个地址到Access-Control-Allow-Origin中去,否则就不设置这个响应头.

以下是整理后的代码(实际的白名单列表是写在配置文件中的):

/**
 * API扩展
 *
 * Class ApiTrait
 */
trait ApiTrait
{
    /**
     * 设置允许跨域访问的域名白名单
     */
    protected $_ALLOWED_ORIGINS = [
        'test.icewingcc.com'
    ];


    /**
     * 通过指定的参数生成并显示一个特定格式的JSON字符串
     *
     * @param int|array $status 状态码, 如果是数组,则为完整的输出JSON数组
     * @param array     $data
     * @param string    $message
     */
    protected function render_json($status = 200, $data = [], $message = '')
    {

        /*
         * 判断跨域请求,并设置响应头
         */
        $cross_origin = $this->_parse_cross_origin_domain();

        if($cross_origin){
            @header("Access-Control-Allow-Origin: {$cross_origin}");
        }


        /*
         * 输出格式化后的内容
         */
        echo json_encode([
            'status'  => $status,
            'data'    => $data,
            'message' => $message
        ]);
    }

    /**
     * 解析跨域访问, 如果访问来源域名在 config.inc.php 中预定义的允许的列表中,
     * 则返回完整的跨域允许域名 , 否则将返回FALSE
     *
     * @return bool|string
     */
    private function _parse_cross_origin_domain()
    {
        $refer = isset($_SERVER['HTTP_REFERER']) ? $_SERVER['HTTP_REFERER'] : '';

        $refer = strtolower($refer);

        /*
         * 没有来源地址时直接返回false
         */
        if(! $refer){
            return FALSE;
        }

        /*
         * 解析引用地址, 取出 host 部分
         */
        $refer_parts = parse_url($refer);

        if(! $refer_parts){
            return FALSE;
        }

        $host = isset($refer_parts['host']) ? $refer_parts['host'] : '';
        $scheme = isset($refer_parts['scheme']) ? $refer_parts['scheme'] : 'http';

        if(! $host){
            return FALSE;
        }

        /*
         * 检查引用地址是否在预配置的允许跨域域名列表中,如果不在,返回 FALSE
         */
        if(in_array($host, $this->_ALLOWED_ORIGINS)){

            return ($scheme ? : 'http') . '://' . $host;

        }

        return $host;

    }
}

Access-Control-Allow-Headers的问题

以过上面的代码已经实现了跨域中的第一步,GET请求一切正常. 可是需要POST请求发送数据时又出问题了, Chrome报错Request header field Content-Type is not allowed by Access-Control-Allow-Headers in preflight response. 查了下资料,大致意思是请求头中的Content-Type字段内容没有在Access-Control-Allow-Headers中被设置为允许.

这个简单,只需要把这个内容加在Access-Control-Allow-Headers上面就行了,顺便也把其它常用的头都加进去吧.

    @header('Access-Control-Allow-Headers: Origin, X-Requested-With, Content-Type, Accept, Connection, User-Agent, Cookie');

搞定.

用户登录时的POST表单发送问题解决了,紧接着又出现了另一个问题: 系统是通过cookie与后端交互的,而这样跨域时每次请求都是独立的,都会生成不同的cookie. 而cookie里面保存了PHP的session id的信息,自然就无法顺畅的与后端进行交互.

这个处理起来似乎比较麻烦,过程就不说了,最终找到的解决方案是在PHP中再加一个header, 同时JS里也要设置一下:

    @header('Access-Control-Allow-Credentials: true');

JS里面也要设置Credentials, 下面是angular的代码, jQuery类似:

$http({
    // ....参数们...

    withCredentials: true
});

如此一来便解决了跨域时cookie的问题.

OPTIONS请求

以上问题都解决了, 基本上跨域已经搞定, 但仔细看Chrome的Network日志, 发现有些请求会出现两次: 第一次是OPTIONS请求方式, 第二次才是正常的POST. 这个OPTIONS是干嘛的呢?

查了些资料并且测试了下, 发现OPTIONS就是相当于在正式请求接口之前去获取以下header, 自然就是我们前面所设置的那些header. 如果在这次OPTIONS请求中服务器有返回正确的header, 这时才会执行后面真正的请求; 否则请求将会被拒绝, 并抛出错误.

即然这次OPTIONS请求仅仅是为了获取header的, 那么给它一个空的返回就行了呗, 不需要做任何实际的操作.

/*
 * 判断 OPTIONS 请求,如果 请求方式为
 * OPTIONS ,输出头部直接返回
 */
if(isset($_SERVER['REQUEST_METHOD']) && $_SERVER['REQUEST_METHOD'] == 'OPTIONS'){
    $this->render_json([]);
    exit();
}

完整代码

下面贴上修改后的完整PHP部分代码, JS就不贴了,加一个参数而已. 仅供参考:

/**
 * API扩展
 *
 * Class ApiTrait
 */
trait ApiTrait
{
    /**
     * 设置允许跨域访问的域名白名单
     */
    protected $_ALLOWED_ORIGINS = [
        'test.icewingcc.com'
    ];


    /**
     * 通过指定的参数生成并显示一个特定格式的JSON字符串
     *
     * @param int|array $status 状态码, 如果是数组,则为完整的输出JSON数组
     * @param array     $data
     * @param string    $message
     */
    protected function render_json($status = 200, $data = [], $message = '')
    {

        /*
         * 判断跨域请求,并设置响应头
         */
        $cross_origin = $this->_parse_cross_origin_domain();

        if($cross_origin){
            @header("Access-Control-Allow-Origin: {$cross_origin}");
        }


        @header('Access-Control-Allow-Headers: Origin, X-Requested-With, Content-Type, Accept, Connection, User-Agent, Cookie');
        @header('Access-Control-Allow-Credentials: true');

        @header('Content-type: application/json');
        @header("Cache-Control: no-cache, must-revalidate");


        /*
         * 输出格式化后的内容
         */
        echo json_encode([
            'status'  => $status,
            'data'    => $data,
            'message' => $message
        ]);
    }

    /**
     * 解析跨域访问, 如果访问来源域名在 config.inc.php 中预定义的允许的列表中,
     * 则返回完整的跨域允许域名 , 否则将返回FALSE
     *
     * @return bool|string
     */
    private function _parse_cross_origin_domain()
    {
        $refer = isset($_SERVER['HTTP_REFERER']) ? $_SERVER['HTTP_REFERER'] : '';

        $refer = strtolower($refer);

        /*
         * 没有来源地址时直接返回false
         */
        if(! $refer){
            return FALSE;
        }

        /*
         * 解析引用地址, 取出 host 部分
         */
        $refer_parts = parse_url($refer);

        if(! $refer_parts){
            return FALSE;
        }

        $host = isset($refer_parts['host']) ? $refer_parts['host'] : '';
        $scheme = isset($refer_parts['scheme']) ? $refer_parts['scheme'] : 'http';

        if(! $host){
            return FALSE;
        }

        /*
         * 检查引用地址是否在预配置的允许跨域域名列表中,如果不在,返回 FALSE
         */
        if(in_array($host, $this->_ALLOWED_ORIGINS)){

            return ($scheme ? : 'http') . '://' . $host;

        }

        return $host;

    }
}



/**
 * 基础API访问类
 *
 * Class BaseApiControl
 */
 abstract class BaseApiControl
 {

    use ApiTrait;

    protected function __construct()
    {
        /*
         * 判断 OPTIONS 请求,如果 请求方式为
         * OPTIONS ,输出头部直接返回
         */
        if(isset($_SERVER['REQUEST_METHOD']) && $_SERVER['REQUEST_METHOD'] == 'OPTIONS'){
            $this->render_json([]);
            exit();
        }

    }


    // ...

 }

目前为止接口运行良好, 再发现新的坑时将会更新此文章. (ps. 虽然这是篇前端分享的文章, 却是用一大堆PHP解决问题...)

阅读全文 »

Jerry Bendy 发布于 12月02, 2015

【分享】PHP中的并发

今天看到一篇讲PHP并发的文章,感觉不错,于是便Copy了下来。原文如下:

周末去北京面了两个公司,认识了几位技术牛人,面试中聊了很多,感觉收获颇丰。认识到了自己的不足之处,也坚定了自己对计算机学习的信心。本文是对其中一道面试题的总结。 面试中有一个问题没有很好的回答出来,题目为:并发3个http请求,只要其中一个请求有结果,就返回,并中断其他两个。

当时考虑的内容有些偏离题目原意, 一直在考虑如何中断http请求,大概是在 client->recv() 之前去判断结果是否已经产生,所以回答的是用 socket 去发送一个 http 请求,把 socket 加入 libevent 循环监听,在callback中判断是否已经得到结果,如果已经得到结果,就直接 return。

后来自己越说越觉得不对,既然已经recv到结果,就不能算是中断http请求。何况自己从来没用过libevent。后来说了还说了两种实现,一个是用 curl_multi_init, 另一个是用golang实现并发。 golang的版本当时忘了close的用法,结果并不太符合题意。

这题没答上来,考官也没为难我。但是心里一直在考虑,直到面试完走到楼下有点明白什么意思了,可能考的是并发,进程线程的应用。所以总结了这篇文章,来讲讲PHP中的并发。 本文大约总结了PHP编程中的五种并发方式,最后的Golang的实现纯属无聊,可以无视。如果有空,会再补充一个libevent的版本。

curl_multi_init

文档中说的是 Allows the processing of multiple cURL handles asynchronously. 确实是异步。这里需要理解的是select这个方法,文档中是这么解释的Blocks until there is activity on any of the curl_multi connections.。了解一下常见的异步模型就应该能理解,select, epoll,都很有名,这里引用一篇非常好的文章,有兴趣看下解释吧。

<?php
// build the individual requests as above, but do not execute them
$ch_1 = curl_init('http://www.baidu.com/');
$ch_2 = curl_init('http://www.baidu.com/');
curl_setopt($ch_1, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch_2, CURLOPT_RETURNTRANSFER, true);

// build the multi-curl handle, adding both $ch
$mh = curl_multi_init();
curl_multi_add_handle($mh, $ch_1);
curl_multi_add_handle($mh, $ch_2);

// execute all queries simultaneously, and continue when all are complete
$running = null;
do {
    curl_multi_exec($mh, $running);
    $ch = curl_multi_select($mh);
    if($ch !== 0){
        $info = curl_multi_info_read($mh);
        if($info){
            var_dump($info);
            $response_1 = curl_multi_getcontent($info['handle']);
            echo "$response_1 \n";
            break;
        }
    }
} while ($running > 0);

//close the handles
curl_multi_remove_handle($mh, $ch_1);
curl_multi_remove_handle($mh, $ch_2);
curl_multi_close($mh);

这里我设置的是,select得到结果,就退出循环,并且删除 curl resource, 从而达到取消http请求的目的。

swoole_client

swoole_client提供了异步模式,我竟然把这个忘了。这里的sleep方法需要swoole版本大于等于1.7.21, 我还没升到这个版本,所以直接exit也可以。

<?php
$client = new swoole_client(SWOOLE_SOCK_TCP, SWOOLE_SOCK_ASYNC); //设置事件回调函数
$client->on("connect", function($cli) {
    $req = "GET / HTTP/1.1\r\n
    Host: www.baidu.com\r\n
    Connection: keep-alive\r\n
    Cache-Control: no-cache\r\n
    Pragma: no-cache\r\n\r\n";

    for ($i=0; $i < 3; $i++) { $cli->send($req);
    }
});
$client->on("receive", function($cli, $data){
    echo "Received: ".$data."\n";
    exit(0);
    $cli->sleep(); // swoole >= 1.7.21
});
$client->on("error", function($cli){
    echo "Connect failed\n";
});
$client->on("close", function($cli){
    echo "Connection close\n";
});
//发起网络连接
$client->connect('183.207.95.145', 80, 1);

process

哎,竟然忘了 swoole_process, 这里就不用 pcntl 模块了。但是写完发现,这其实也不算是中断请求,而是哪个先到读哪个,忽视后面的返回值。

<?php

$workers = [];
$worker_num = 3;//创建的进程数
$finished = false;
$lock = new swoole_lock(SWOOLE_MUTEX);

for($i=0;$i<$worker_num ; $i++){ $process = new swoole_process('process'); //$process->useQueue();
    $pid = $process->start();
    $workers[$pid] = $process;
}

foreach($workers as $pid => $process){
    //子进程也会包含此事件
    swoole_event_add($process->pipe, function ($pipe) use($process, $lock, &amp;$finished) {
        $lock->lock();
        if(!$finished){
            $finished = true;
            $data = $process->read();
            echo "RECV: " . $data.PHP_EOL;
        }
        $lock->unlock();
    });
}

function process(swoole_process $process){
    $response = 'http response';
    $process->write($response);
    echo $process->pid,"\t",$process->callback .PHP_EOL;
}

for($i = 0; $i < $worker_num; $i++) {
    $ret = swoole_process::wait();
    $pid = $ret['pid'];
    echo "Worker Exit, PID=".$pid.PHP_EOL;
}

pthreads

编译pthreads模块时,提示php编译时必须打开ZTS, 所以貌似必须 thread safe 版本才能使用. wamp中多php正好是TS的,直接下了个dll, 文档中的说明复制到对应目录,就在win下测试了。 还没完全理解,查到文章说 php 的 pthreads 和 POSIX pthreads是完全不一样的。代码有些烂,还需要多看看文档,体会一下。

<?php
class Foo extends Stackable {
    public $url;
    public $response = null;

    public function __construct(){
        $this->url = 'http://www.baidu.com';
    }
    public function run(){}
}

class Process extends Worker {
    private $text = "";
    public function __construct($text,$object){
        $this->text = $text;
        $this->object = $object;
    }
    public function run(){
        while (is_null($this->object->response)){
            print " Thread {$this->text} is running\n";
            $this->object->response = 'http response';
            sleep(1);
        }
    }
}

$foo = new Foo();

$a = new Process("A",$foo);
$a->start();

$b = new Process("B",$foo);
$b->start();

echo $foo->response;

yield

yield生成的generator,可以中断函数,并用send向 generator 发送消息。 稍后补充协程的版本。还在学习中。

Golang

用Go实现比较简单, 回家后查了查 close,处理一下 panic就ok了。代码如下:

package main

import (
    "fmt"
)

func main() {
    var result chan string = make(chan string, 1)
    for index := 0;  index< 3; index++ {
        go doRequest(result)
    }

    res, ok := <-result
    if ok {
        fmt.Println("received ", res)
    }

}

func doRequest(result chan string)  {
    response := "http response"
    defer func() {
        if x := recover(); x != nil {
            fmt.Println("Unable to send: %v", x)
        }
    }()
    result <- response
    close(result)
}

上面的几个方法,除了 curl_multi_* 貌似符合题意外(不确定,要看下源码),其他的方法都没有中断请求后recv()的操作, 如果得到response后还有后续操作,那么是有用的,否则并没有什么意义。想想可能是PHP操作粒度太大, 猜测用 C/C++ 应该能解决问题。

写的时候没有注意到一个问题,有些方式是返回值,有些直接打印了,这样不好,应该统一使用返回值得到请求结果。能力有限,先这样吧。

最后要做个广告,计蒜客是一家致力于计算机科学高端教育的公司,如果你对编程或者计算机底层有兴趣,不妨去他们网站学习学习。 同时,公司也一直在招人,如果你对自己的能力有信心,可以去试试。公司非常自由开放,90后为主。牛人也有不少,ACM世界冠军,知乎大牛。 公司主做教育,内部学习资料必须给力,我只看到了一些关于操作系统的测试题,涉及到的知识面很广,可见公司平均技术能力有多厉害。

如果文章中有疏漏,错误,还请大神们不吝指出,帮助菜鸟进步,谢谢。

原文地址:http://segmentfault.com/a/1190000004069411

阅读全文 »

Jerry Bendy 发布于 11月28, 2015

mysqli使用localhost问题 Warning: mysqli::mysqli(): (HY000/2002): No such file or directory

今天在使用PHP的CLI方式访问mysql数据库时出现了一个 No such file or directory的错误,查找资料并在最终解决后记录一下。

这个问题应该也会存在于非CLI方式访问,简单的代码是这样的:

<?php
$mysqli = new mysqli('localhost', 'root', 'root', 'test');

如果上面的连接地址是 localhost 就会报此错误,改成 127.0.0.1 后正常。

当主机填写为localhost时MySQL会采用 unix domain socket连接,当主机填写为127.0.0.1时MySQL会采用TCP/IP的方式连接。使用Unix socket的连接比TCP/IP的连接更加快速与安全。这是MySQL连接的特性,可以参考官方文档的说明4.2.2. Connecting to the MySQL Server

这个问题有以下几种解决方法:

使用TCP/IP代替Unix socket。即在连接的时候将localhost换成127.0.0.1。 修改MySQL的配置文件my.cnf,指定mysql.socket的位置:

/var/lib/mysql/mysql.sock (你的mysql.socket路径)。

直接在php建立连接的时候指定my.socket的位置(官方文档:mysqli_connect)。比如:

$db = new MySQLi('localhost', 'root', 'root', 'my_db', '3306', '/var/run/mysqld/mysqld.sock')

通常意义上localhost和127.0.0.1是等价的,只是mysql在处理这个名词的问题上有一些不同,是根据不同的地址来采取的不同的通信手段。

问题的最终解决方案是在连接的时候手动指定了 sock 文件的路径

原因呢,我猜大概是为了本地应用能获得更好的性能。而且localhost这个地址在mysql中也不会做匹配。即user@'%'不能匹配到user@'localhost'

阅读全文 »

Jerry Bendy 发布于 09月10, 2015

MAMP PRO安装PHP扩展的方法

这几天因为需要使用MAC做PHP开发,安装了一个MAMP PRO的试用版,整体感觉非常好用,还可以自由选择PHP版本、Apache扩展、很方便的创建网站并自动修改系统hosts文件等。

MAMP和MAMP PRO是两个独立软件,MAMP可以单独运行,支持简单的操作如PHP版本切换、Apache/Nginx切换、启动/停止服务等。MAMP PRO不能独立运行,它需要依赖MAMP软件,其实MAMP PRO就是MAMP的一个功能强大的控制面板,并提供了一些MAMP本身不支持的功能(如修改hosts、DDNS、方便的修改PHP运行方式以及Apache模块、方便的添加和管理虚拟网站等),售价不到400元,你值得拥有~

2015-09-10 13.19.26

因为我的项目很多都依赖Redis做缓存和队列,偶尔也有使用MongoDB的需求,或者安装Phalcon框架等,而MAMP不支持这些扩展,也没有提供任何安装这些扩展的方法,只能去折腾~~ 各种查资料,各种看文档 。。。。

以下以安装Redis为例,其它扩展方法一样。

首先需要先安装Redis,我比较懒,直接使用brew安装的

brew install redis

接下来安装php-redis扩展,这个可以自己下载源码编译,或者寻找对应自己所用PHP版本的redis.so。

因为MAMP里面集成的PHP版本都没有包含头文件,自己下载编译的话肯定会出错,具体解决方法可自行去查找资料。当然,我比较懒,于是:

brew install homebrew/php/php56-redis

因为我用的是PHP5.6,所以是php56-redis,用这种方法非常方便、简单,但带来的问题是brew会自动安装一个PHP5.6.19到/usr/local/Cellar目录下。

而安装好的redis扩展被放到了/usr/local/Cellar/php56-redis/2.2.7_1/redis.so

接下来就是修改PHP配置文件的问题了。前面说到MAMP里面每个PHP版本对应的目录下面都有一个 conf/php.ini 文件,自然就是PHP的配置文件了,于是修改这个文件,在扩展的部分添加以下代码

extension=/usr/local/Cellar/php56-redis/2.2.7_1/redis.so

完后后经测试,php -m 表示已加载redis扩展,并且使用MAMP启动服务能正常加载redis扩展,但使用MAMP PRO却无法加载扩展。

这个总是着实头疼了一阵,最后想到phpinfo查看加载的是哪个配置文件,最终定位到配置文件的位置在/Library/Application Support/appsolute/MAMP PRO/conf/php.ini。而且发现使用module方式和使用CGI方式加载的配置文件也不一样,但都在这个目录下面。于是尝试去修改这个文件添加redis扩展的路径。

问题出现了,还是没能加载redis扩展,再去查看刚刚修改的那个php.ini文件,发现修改全部都不见了(可见MAMP PRO每次启动服务时都会重新生成这个配置文件,所以刚才的修改不见了)。

实在没办法,只能去官网上面找出路,英文文档各种翻,甚至基本的操作都看了一遍,终于还是找到方法了~~ —— 模板~

虽然官网没说这个模板能干什么用,但显而易见这是每次启动服务的时候用来重新生成php.ini和httpd.conf文件的模板。

修改方法:

点击菜单 --> File --> Edit Template --> PHP --> PHP 5.6.10 php.ini

2015-09-10 13.05.02

接下来会弹出一个警告并打开一个php.ini这样的文件,可以看到这个文件里面很多关键的地方都被换成了MAMP的变量。直接和前面一样在扩展的部分加上加载redis扩展的内容,保存并重启MAMP服务。

再次phpinfo发现终于成功加载了redis扩展。。。真是一路折腾,不过也算是对MAMP PRO这个软件有了更深一层的了解,以后在使用的时候也会方便很多。

安装MongoDB等基它扩展的方法也与此相似,简单总结一下:

  • 如果不使用MAMP PRO的话可以直接在 /Application/MAMP/bin/php/php-x.x.x/conf 目录下修改php.ini,并且会生效
  • 如果使用MAMP PRO必须要修改模板才能生效

一版来说是如下几个过程:

  1. 下载或编译扩展(一般是.so文件)
  2. 修改MAMP PRO的php.ini模板
  3. 没了

知道了模板这个东西,以后有需要也可以很方便的个性Apache的模板啦~

阅读全文 »

Jerry Bendy 发布于 07月21, 2015

迁移服务器遇到的蛋疼问题:Nginx PHP “No input file specified”

昨天因为服务器到期把网站迁移到另一台服务器,使用的LNMP架构,网站各部分迁移完成后发现了一个蛋疼的问题:很多网页打开都提示”No input file specified”,甚至直接404,而且时好时坏。

去网上搜了下资料,大概意思如下:

任何对.php文件的请求,都简单地交给php-cgi去处理,但没有验证该php文件是否存在。PHP文件不存在,没办法返回普通的404错误,它返回 一个404,并带上一句”No input file specified”

另外,还可能跟 路径或者 权限有关系,或者SCRIPT_FILENAME 变量没有被正确的设置(这在nginx是最常见的原因)。

因为Nginx的PATHINFO设置是直接复制的之前服务器的设置,所以这点肯定不会有错,那就检查PHP配置文件。

一、把cgi.fix_pathinfo=0改为cgi.fix_pathinfo=1

二、把;cgi.force_redirect=1改为cgi.force_redirect=0

然后重启LNMP,发现还不好,就随便试了下:

# cd /home/wwwroot
# chown -R www:www ./*
# chmod -R 755 ./*
# lnmp restart

然后。。就好了。。。。搞半天居然是简单的权限的问题,晕了~~~

记录下,以后有朋友遇到同样的问题不访试试看是不是权限不对。(By the way,我用的是网上的LNMP一键安装包1.2版本,所以有lnmp这个命令,自己编译安装LNMP或者是LAMP的自行参考重新Nginx和PHP的命令)

阅读全文 »

Jerry Bendy 发布于 04月06, 2015

后台任务和PHP-Resque的使用(五) 创建任务

到目前为止已经让 Worker 运行了,我们需要创建并添加任务。这一节主要了解什么是任务(Job),以及如何使用任务。

简单的说,任务就是传递给 Worker 要执行的内容。我们需要把 Job 依次添加到 Queue 来执行。

要把任务添加到队列,程序必须要包含 php-resque 库以及 Redis。

使用 require_once '/path/to/php-resque/lib/Resque.php';包含 php-resque 的库文件,它会自动连接到 Redis 服务器,如果你的 Redis 服务器不是默认的localhost:6379,你需要使用Resque::setBackent('192.168.1.56:3680'); 这样的格式来设置你的 Redis 服务器的地址,同样 setBackent 支持可选的第二个参数为使用的 Redis 数据库名,默认为 0 。

现在 php-resque 已经准备好了,使用以下代码添加一个任务到队列:

Resque::enqueue('default', 'Mail', array('dest@mail.com', 'hi!', 'this is a test content'));
  • 第一个参数,'default'是指队列的名字,示例中将会把任务推送到名为 default 的队列中
  • 第二个参数是 Job 的类名,表示要执行哪个 Job
  • 第三个参数是要发送给 Job 的参数也可以使用关联数组的形式

传递给 Job 的参数(上面第三个参数)可以是普通数组、关联数组的形式,也可以是一个字符串,但使用数组可以很方便的传递更多的信息给 Job。所有的参数在推送到队列前都会经过 json_encode 处理。

创建一个 Job

如上面的例子中,第一个参数是队列的名字(还记得上一节里面启动 php resque.php 时传递的 QUEUE 环境变量吗?),第二个参数是 Job 的类名,即要执行的 Job。Mail 类就是一个 Job 类。

所有的Job类都应该包含一个 perform() 方法,使用 Resque::enqueue() 传递的第三个参数可以在 perform() 方法中使用 $this->args 来得到。一个典型的 Job 类如下所示:

class Mail{
    public function perform(){
        var_dump($this->args);
    }
}

Job 类也可以包含 setUp()tearDown() 方法,可选的这两个方法分别会在 perform() 方法之前和之后运行。

class Mail{
    public function setUp(){
        # 这个方法会在perform()之前运行,可以用来做一些初始化工作
        # 如连接数据库、处理参数等
    }

    public function perform(){
        # 执行Job
    }

    public function tearDown(){
        # 会在perform()之后运行,可以用来做一些清理工作
    }
}

包含 Job 类

在实例化 Job 类之前,必须让 Worker 找到并包含这个类。有很多种方法可以做到。

使用 include_path

当 PHP 运行于 Apache model 方式的时候可以使用 .htaccess 设置包含:

php_value include_path ".:/already/existing/path:/path/to/job-classes"

或者通过 php.ini

include_path = ".:/php/includes:/path/to/job-classes"

使用 APP_INCLUDE 包含

上一节说了使用 APP_INCLUDE 指定 Worker 执行时要包含的PHP文件的路径,如:

QUEUE=default APP_INCLUDE=/path/to/loader.php php resque.php

loader.php 的内容可以是下面的那样:

include '/path/to/Mail.php';
include '/path/to/AnotherJobClass.php';
include '/path/to/somewhere/AnotherJobClass.php';
include '/JobClass.php';

当然也可以使用 PHP 的 autoloader 方法 —— sql_autoloader

在你的项目中使用后台任务

以下面的代码为例,把耗时较多的工作交给后台任务来做。

class User{
    # functions(){}  // 其它函数

    public function updateLocation($location) {
        $db->updateUserTable($this->userId, 'location', $location);
        $this->recomputeNewFriends(); # 此操作耗时较长
    }

    public function recomputeNewFriends() {
        # 查找新的朋友
    }
}

把以上代码改成:

class User {
    # functions(){}  // 其它函数

    public function updateLocation($location) {
        $db->updateUserTable($this->userId, 'location', $location);
        # 把任务添加到队列
        # 这里的队列名为 'queueName'
        # 任务名为 'FriendRecommendator'
        Resque::enqueue('queueName', 'FriendRecommendator', array('id' => $this->userId));
    }
}

以下是任务 FriendRecommendator 类的实现代码:

class FriendRecommendator {
    function perform() {
        # 这里没有User类,需要创建一个User类对象
        $user = new User($this->args['id']);
        # 查找新朋友的操作
    }
}

简单的说,你只需要把你的执行任务的代码放到 Job 类中并改名为perform()即可,只要你愿意甚至可以将普通类改成 Job 类,但并不推荐这样做。

perform() 方法有个缺点,即一个 Job 类只能包含一个 perform() 方法,也就是说一个 Job 类只能执行一种后台任务。例如你有一个发送通知信息的后台任务,但又有发送给用户和发送给管理员两个不同的需求,一般来说就得需要两个 Job 类才能实现。不过这里有个小小的 Hack 可以使一个 Job 能执行多个类型的任务。

首先就是给你的 Job 分类,把相似工作的 Job 放在同一个 Job 类中,因为完全不相关的 Job 即使放在同一个类中也没有任何意义。然后通过给 Resque::enqueue() 方法传递一个表示不同 Job 的参数过去。

# Job类中的写法
class Notification{
    function sentToUser(){
        # Code..
    }

    function sentToAdmin{
        # code..
    }

    function perform(){
        $action = $this->{array_shift($this->args)};
        if(method_exists($this, $action)){
            $this->$action();
        }
    }
}

# 添加任务时的写法
Resque::enqueue('default', 'Notification', array('sendToAdmin', 'this is content'));

也可以使用其它类继承 Job 类以获取相同的 perform() 方法,但要注意必须同时包含这些类文件。

另外需要注意的是使用这种 Hack 的方法 Resque::enqueue() 的第三个参数必须是一个数组,并且它的第一个元素是要执行的任务的方法名,并且这个元素会在执行时从 $args 数组中移除。

必须在每次修改 Job 类后重新启动你的Worker


本文由冰翼翻译自Kamisama.me

阅读全文 »

Jerry Bendy 发布于 04月05, 2015

后台任务和PHP-Resque的使用(四) 使用Worker

注意,这篇教程仅适用于 Linux 和 OS X 的系统,Windows 并不适用。

理解 Worker 的本质

技术上讲一个 Worker 就是一个不断运行的PHP进程,并且不断监视新的任务并运行。

一个简单的 Worker 的代码如下:

while (true) {
    $jobs = pullData(); // 从队列中拉取任务

    foreach ($jobs as $class => $args) { // 循环每个找到的任务
        $job = new $class();
        $job->perform($args); // 执行任务
    }
    sleep(300); // 等待5分钟后再次尝试拉取任务
}

以上这些代码的具体实现都可以交给 php-resque。创建一个 Worker,php-resque 需要以下参数:

  • QUEUE: 需要执行的队列的名字
  • INTERVAL:在队列中循环的间隔时间,即完成一个任务后的等待时间,默认是5秒
  • APP_INCLUDE:需要自动载入 PHP 文件路径,Worker 需要知道你的 Job 的位置并载入 Job
  • COUNT:需要创建的 Worker 的数量。所有的 Worker 都具有相同的属性。默认是创建1个Worker
  • REDIS_BACKEND:Redis 服务器的地址,使用 hostname:port 的格式,如 127.0.0.1:6379,或 localhost:6379。默认是 localhost:6379
  • REDIS_BACKEND_DB:使用的 Redis 数据库的名称,默认是 0
  • VERBOSE:啰嗦模式,设置 1 为启用,会输出基本的调试信息
  • VVERBOSE:设置“1”启用更啰嗦模式,会输出详细的调试信息
  • PREFIX:前缀。在 Redis 数据库中为队列的 KEY 添加前缀,以方便多个 Worker 运行在同一个Redis 数据库中方便区分。默认为空
  • PIDFILE:手动指定 PID 文件的位置,适用于单 Worker 运行方式

以上参数中只有QUEUE是必须的。如果让 Worker 监视执行多个队列,可以用逗号隔开多个队列的名称,如:queue1,queue2,queue3,队列执行是有顺序的,如上 queue2queue3 总是会在 queue1 后面被执行。

也可以设置QUEUE*让 Worker 以字母顺序执行所有的队列。

Worker 必须以CLI方式启动。你不可以从浏览器启动 Worker,因为:

  • 你无法从浏览器执行后台任务
  • PCNTL 扩展只能运行在 CLI 模式

启动Worker

可以从resque.php启动 Worker,这个位置位于 php-resque/bin 目录下(也可能不带.php后缀)。

在终端中执行:

cd /path/to/php-resque/bin/

php resque.php

很显然 Worker 不会被启动,因为缺少必须的参数 QUEUE,程序将会返回如下错误:

Set QUEUE env var containing the list of queues to work.

php-resque 通过getenv获取参数,所以在启动 Worker 的时候应该传递环境变量过去。所以应该以下面的方式启动 Worker:

QUEUE=notification php resque.php

如果启用VVERBOSE模式:

QUEUE=notification VVERBOSE=1 php resque.php

终端将会输出:

*** Starting worker KAMISAMA-MAC.local:84499:notification
** [23:48:18 2012-10-11] Registered signals
** [23:48:18 2012-10-11] Checking achievement
** [23:48:18 2012-10-11] Checking notification
** [23:48:18 2012-10-11] Sleeping for 5
** [23:48:23 2012-10-11] Checking achievement
** [23:48:23 2012-10-11] Checking notification
** [23:48:23 2012-10-11] Sleeping for 5
... etc ...

Worker 会自动被命名为KAMISAMA-MAC.local:84499:notification,命名的规则是hostname:process-id:queue-names

如果觉得这种启动方式太麻烦且难记,可以自己手动写一个 bash 脚本来帮助你启动 Resque,如:

EXPORT QUEUE=notifacation
EXPORT VERBOSE=1

php resque.php

后台运行Worker

通过上面的方法成功启动了 Worker,但只有在终端开启的状态下,关闭终端或按下 Ctrl+C 时 Worker 就会停止运行。我们可以在命令后面添加一个 & 来使其后台运行。

QUEUE=notification php resque.php &

这样就可以让 resque 在后台运行。但如果你开启了 VERBOSE 模式,所有的输出信息将会丢失。所以我们需要在 resque 后台运行时把输出的信息保存起来。

我们可以使用 nohup 来保持 resque 后台运行,即使是在用户登出后。

nohup QUEUE=notification php resque.php &

当然,如果安装了 node 和 pm2 也可以使用 pm2 启动来保证 resque 后台的运行。

记录下 Worker 的输出

可以使用管道操作的方式重定向输出到文件:

nohup QUEUE=notification php resque.php >> /path/to/your/logfile.log 2>&1 &

这样一来所有的标准及错误输出都会被写入到 logfile.log 文件中。如果需要监视这个文件的内容:

tail -f /path/to/your/logfile.log

Worker 的执行权限

无论何时你在终端中执行命令都是以当前登录用户的权限来执行。如果你登录的 jerry 的账户,php-resque将会运行于 jerry 的权限下。以 root 用户登录时也一样。

如果需要避开当前登录账户以其它用户的权限运行,如 Apache 通常运行在 www-data 用户下,让 php-resque 运行于 www-data 账户:

nohup sudo -u www-data QUEUE=notification php resque.php >> /path/to/your/logfile.log 2>&1 &

操作执行权限时需要注意:

  • 通过 Worker 生成的文件无法被其它用户的php代码读取
  • Worker 没有权限创建或编辑其它用户的文件

Let's play

前面已经讲了如何启动、如何后台运行、以及记录运行日志,下面就用一些例子结束本节的内容。

创建一个执行 default 队列的 Worker,并且每隔 10 秒检索一次任务:

INTERVAL=10 QUEUE=default php resque.php

创建5个执行 default 队列的 Worker,每隔 5 秒检索一次任务:

QUEUE=default COUNT=5 php resque.php

INTERVAL 参数没有被指定,因为默认值是 5 秒。

创建一个执行 achievementnotification 队列的 Worker(需要注意队列名的顺序):

QUEUE=achievement,notification php resque.php

创建一个执行所有队列的 Worker:

QUEUE=* php resque.php

如果你的 Redis 服务器在别的地址:

QUEUE=default REDIS_BACKENT=192.168.1.56:6380 php resque.php

使用自动载入 php 文件:

QUEUE=default APP_INCLUDE=/path/to/autoloader.php php resque.php

确认你的 Worker 成功运行了

通过管道操作无法知道 Worker 是否成功启动,当前通过查看log文件中有没有输出 *** Starting worker ..... 的内容也可以知道是否启动。

也可以通过查看系统进程的方法确认 Worker 是否正在运行。

ps -ef|grep resque.php

将会输出名称中包含resque.php的进程,其中第二列是进程的 PID。

使用这个方法可以很好的知道 Worker 是否正在运行,以及有没有意外终止。

暂停和停止 Worker

要停止一个Worker,直接 kill 掉它的进程就行了。可以通过 ps -ef|grep resque.php 查看 Worker进程的 PID。当然通过这个命令你无法知道哪个 PID 代码的哪个 Worker。

如果要结束一个 PID 是 86681 的进程:

kill 86681

这个命令将会立即结束掉 PID 为 86681 的进程及子进程。如果 Worker 正在执行一个任务也不会等待任务执行完成(未完成的部分将会丢失)。

有一个可以平滑的停止 Worker 的方法,可以通过给 kill 命令发送一个 SIGSPEC 信号来告诉 kill 应该怎么做,这需要 PCNTL 扩展的支持。

当然下面所讲述的所有命令都需要 PCNTL 扩展支持。

通过 PCNTL 扩展,Worker 可以支持以下信号:

  • QUIT - 等待子进程结束后再结束
  • TERM / INT - 立即结束子进程并退出
  • USR1 - 立即结束子进程,但不退出
  • USR2 - 暂停Worker,不会再执行新任务
  • CONT - 继续运行Worker

当没有信号发出时默认是 TERM / INT 信号。

如果想在所有当前正在运行的任务都完成后再停止,使用 QUIT 信号:

kill -QUIT YOUR-WORKER-PID

结束所有子进程,但保留 Worker:

kill -USR1 YOUR-WORKER-PID

暂停和继续执行 Worker:

kill -USR2 YOUR-WORKER-PID

kill -CONT YOUR-WORKER-PID

本文由冰翼翻译自Kamisama.me

阅读全文 »