将PHP程序打包成可执行的phar文件

最近写了一个命令行脚本,涉及composer安装的几个库,还有自己封装的四五个类。不希望把一堆php脚本拷来拷去,最好能像phpunit那样就直接封装成一个可执行的文件。phpunit的做法是把所有相关文件打包封装到一个phar包里去分发,我也可以这么干。

首先配置php.ini里的phar.readonly=0,默认Php解释器对phar是只读访问的,不能修改phar的内容,以免意外修改整体交付的软件包。修改配置之后才可以用来打包生成phar。改好配置之后,建立程序目录结构如下:

.
├── bin
├── generate-phar.php
└── src
    └── index.php

其中src目录里有完整的代码文件,包括composer引入的第三方库。我们用generate-phar.php程序生成phar文件,并把它放到bin目录里去。

<?php
//在bin目录下创建phar文件
$phar = new Phar(__DIR__ . DIRECTORY_SEPARATOR . 'bin/mytool.phar');

//从src目录构建phar包
$phar->buildFromDirectory('src');

//定义默认执行入口为index.php
$defStub = Phar::createDefaultStub('index.php');

//设置php解释器shell头,让phar可以自己执行
$phar->setStub("#!/usr/bin/env php\n$defStub");

//用bzip2库压缩phar包里的文件(此步要求PHP安装了zlib和bz2扩展,可以跳过)
$phar->compressFiles(Phar::BZ2);

//将phar包改名,去掉phar扩展名
rename('bin/mytool.phar', 'bin/mytool');

//授予phar包可执行权限
chmod('bin/mytool', 0755);

现在可以用bin/mytool直接执行包中的程序了,phar会自动使用系统中安装的PHP解释器,如果想在任意目录运行此程序,可以把phar包拷到$PATH路径覆盖的目录中,比如/usr/local/bin

配置nginx为php-fpm合并多个X-Forwarded-For

最近用阿里云的容器服务遇到一个问题,docker里的PHP程序用X-Forwarded-For取用户IP的时候,只能取到SLB的地址。经tcpdump抓包确认,是请求经过阿里云的acsrouting的时候,在HTTP请求头中加入了多个X-Forwarded-For。查了一下,阿里云用HAProxy做负载均衡服务,又搜了一下HAProxy的资料,发现许多人在用HAProxy时遇到了同类问题,有人给HAProxy提了issue,但对方答复是根据RFC2616,多个同名的header和单个逗号分隔列表构成的header是等价的:

Multiple message-header fields with the same field-name MAY be present in a message if and only if the entire field-value for that header field is defined as a comma-separated list [i.e., #(values)]. It MUST be possible to combine the multiple header fields into one "field-name: field-value" pair, without changing the semantics of the message, by appending each subsequent field-value to the first, each separated by a comma. The order in which header fields with the same field-name are received is therefore significant to the interpretation of the combined field value, and thus a proxy MUST NOT change the order of these field values when a message is forwarded.

所以HAProxy不处理已有的X-Forwarded-For,只是简单地在header末尾加入一个新的X-Forwarded-For。可惜php-fpm对header的处理方式是同名的header只保留最后一个,所以无法取得正确的用户IP。

我找到一个解决方案是让nginx来合并多个X-Forwarded-For记录。在docker内的nginx配置中加入以下选项(一般在location ~ \.php$部分或者在fastcgi_params配置文件里):

fastcgi_param  HTTP_X_FORWARDED_FOR $http_x_forwarded_for if_not_empty;

配置好之后docker内的nginx服务器就会预先合并多个X-Forwarded-For请求header记录为逗号分隔格式,然后再传给php-fpm。

在平行空间中运行程序

介绍

思考这样一个实例:老师答疑时要下发通知给学员的App。把老师提交的答案保存到数据库只需50ms,调用第三方的WebService接口完成推送却需要等待500ms乃至数秒!我们希望老师可以马上看到答案提交成功,并且开始为下一位学员答疑,不必等待App推送完成。x2ts框架提供了一个新的功能来达成这一目的,你可以提交一段代码,让这段代码在独立的进程中运行,不会阻塞php-fpm。用起来就像这样:

X::parallel(function($userId, $title, $message) {
    $ret = X::curl->post(...); //请求推送接口
    X::logger()->info($ret);
})->run(
    $askSubject->user_id,
    $askSubject->author . '回答了你的问题',
    $askSubject->first_ask
);

例子中的代码把调用推送接口的代码写在了一个匿名函数中,然后调用run方法把参数传过去,这个匿名函数会在另一个进程中运行。这里的“另一个进程”,就是Parallel Runner的守护进程。

使用说明

想要使用Parallel Runner首先要用composer引入库:

composer require x2ts/x2ts-parallel

然后在配置文件中加入组件配置:

'parallel'     => [
    'class'     => \x2ts\parallel\Runner::class,
    'singleton' => true,
    'conf'      => [
        'name'       => 'sk-parallel',
        'sock'       => '/var/run/sk/parallel.sock',
        'pid'        => '/var/run/sk/parallel.pid',
        'lock'       => '/var/run/sk/parallel.lock',
        'workerNum'  => 16,
        'backlog'    => 128,
        'maxRequest' => 500,
    ],
],

X类中加入parallel方法申明

/**
 * @method static x2ts\parallel\Runner parallel(Closure $func = null)
 */
class X extends x2ts\ComponentFactory {}

编写Parallel Runner启动脚本:

<?php
// cliroot/parallel.php
require_once dirname(__DIR__) . '/webroot/xts.php';
X::parallel()->start();

最后,启动Parallel Runner守护进程:

mkdir /var/run/sk
chown -R www-data:www-data /var/run/sk
php cliroot/parallel.php

现在就可以像前面的例子一样,把程序放到Parallel Runner中运行了。

限制

因为匿名函数其实是在Parallel Runner进程中运行,所以必然会有一些限制:

  1. 不能使用use关键字引入外层变量,因为在另一个进程中没有此匿名函数的执行上下文。
  2. 不能使用$this变量,理由和上一条相同,但可以在内部的匿名类定义中使用$this,此时$this指向该匿名类的实例。
  3. 不能按引用传递变量,因为变量实际会通过IPC的方式发到另一个进程,按引用传递变量是没有意义的。
  4. 不能传递不可被序列化的变量,因为要通过IPC的方式发送到另一进程,resource和不可序列化的object不能作为参数传递。
  5. 不能期待返回值,因为程序在另一个进程中运行,执行结果只能通过修改数据库、Redis等方式反映,所有return的数据都会被丢弃。
  6. 不能期待echo输出,因为程序在另一个进程中运行,echo输出的内容并不会显示在页面上,因为是守护进程,也不会输出到屏幕,需要输出信息供阅读和调试,请通过日志输出。

x2ts的事件总线

有的时候我们会需要在主线流程之外处理一些支线任务,比如用户注册的主线任务是检查和保存用户的各种资料,同时存在支线任务是给邀请此用户来注册的另一用户发送一条邀请成功通知。直观的写法是把发送通知的代码直接追加在注册程序的保存命令之后,这样的写法一个还不打紧,再多写几个就会破坏现有程序的可维护性,比如注册就赠送一个体验产品订单,还有注册成功要向统计系统发个消息通知……

基于事件模型来写这种支线程序可以让代码组织得更好。注册部分的代码只专注于注册该干的事情——检查数据、创建用户对象、设置用户资料、写入数据库,然后触发“用户注册”事件,后面的通知、馈赠、统计等支线行为,应该由监听用户注册事件的一众程序来处理,它们可以写在关联事件监听器的地方,也可以写在相应业务的模块里。

function notifyInvitor(event\user\Register $ev) {
    $user = $ev->user;
    if ($user->invitation) {
        //发送通知给邀请的用户
    }
}
X::bus()
    ->on('user.register', 'notifyInvitor')
    ->on('user.register', ['Product', 'presentTrial'])
    ->on('user.register', ['Statistic', 'userRegister'];

在用户注册的主业务代码中,只需要触发此事件即可:

//各种检查和赋值
$user->save();
X::bus()->dispatch(new event\user\Register([
    'dispatcher' => $this,
    'user'       => $user,
]));

x2ts框架的事件也已转向使用事件总线。所谓事件总线,意思是所有事件都在挂载在同一个对象上,并且所有事件都在同一个对象上触发,这样一来,开发事件处理函数的人无需了解事件触发的程序细节,只要根据文档选择适当的事件,即可开发回调函数,完成支线任务。所有的x2ts框架事件,都定义在x2ts开头的命名空间下;所有的应用层事件,都定义在event开头的命名空间下,代码保存在项目的protected/event目录中。

许多时候事件处理代码只是一些小函数,没有完整的class或者模块承载,为了更好的管理这些函数,项目加入了一个event\Setup类,位于protected/event/Setup.php里,在这个类里可以写静态方法作为事件监听器函数。还有一个静态方法setup()负责注册这些事件监听器。事件监听写的书写原则是为每一个任务编写一个静态方法,名称和要做的事情相符,比如notifyInvitor表示此方法会发送通知给邀请人,sendAskAnsweredNotify表示发送答疑回复通知等等。

x2ts日志的一些变化

这次重构x2ts框架的时候,日志模块也在大调之列。最主要的变化当属放弃了原来的直接写入日志文件的做法,改为使用monolog作为日志处理模块。monolog把记录日志这件事划分成了LoggerHandlerFormatter三个部分。Logger负责和应用程序对接,提供诸如noticewarning这样的简便接口,也提供addRecord这样的通用接口。Handler负责日志的处理和输出,可以输出到stderr,也可以把文件、网络、命名管道、syslog、消息队列、PHPConsole等等作为输出日志的目标。Formatter负责日志输出前的格式化,可以把日志文本和元信息(比如时间、进程号等)格式化成指定的样子。

以前日志函数在x2ts\Toolkit中提供,一个是log还有一个是trace,现在Logger是一个独立的x2ts组件类,它是核心组件之一,不论是否在配置中定义始终都可以使用(如果没有定义输出目标,默认会输出到stderr)。在日志消息等级方面,以前的DEBUG、NOTICE、WARNING、ERROR共4个级别被扩展成monolog所定义的8个级别:DEBUG、INFO、NOTICE、WARNING、ERROR、CRITICAL、ALERT、EMERGENCY。

调用日志的方式也改为使用logger组件:

X::logger()->info($someMessage);

为了方便日志在各种Handler中输出,原来日志中夹带的Unix Console颜色字符从日志中去掉了,现在的日志文件只有纯文本数据。如果想看带颜色的日志需要使用开发机上的colorlog脚本。

tail -f app.log | colorlog

现在日志的输出等级依然是debug模式输出DEBUG以上的日志,非debug模式输出NOTICE级别以上的日志。在新的级别体系中,INFO级别的日志可以用于区分框架输出的trace log