隔壁的 Java 有 JAR 作为打包格式,其实 PHP 也是有叫做 Phar 的打包格式的。Phar 一般有以下几种用途:
- 打包网站的 PHP 代码,然后用来
require
。 - 直接当成可执行文件使用,可以用来发布 CLI 工具。Composer 的本体实际上就是一个 Phar 文件。
一些 CTF 的题目的题材不是瞎写,从客户端上传的特别构造的 Phar 包确实可以用来绕过上传检测。但是现在说的是用 Phar 部署后端代码,所以就不考虑这种情形了~
然而实际上“打包 PHP 代码”似乎并不是一个非常重要的需求。毕竟传统的用 PHP 开发网站的方法是只要把代码文件在硬盘里放好了,就可以按照文件路径访问 URL 执行那个文件(直接用文件系统作为路由),打包代码好像没什么意义。至于 CLI 工具,有多少人会闲得没事用 PHP 写 CLI 工具?事实上 Phar 也确实变成了一个比较冷门的东西,相关的资料并不多……(-_-。)
其它语言开发的网站程序,一般是使用“常驻后台”的模式:运行网站程序监听某个端口,通过一个路由表来决定访问什么 URL 将执行哪些代码(所以也不需要像 PHP 一样总是要在 URL 后面带个 .php
)。在这种“单入口模式”下,打包就有意义了,毕竟部署的时候如果需要面对一大堆代码文件那还是比较麻烦的,只有一个文件就会方便很多。比如用 Java 编写的 kiftd 的程序本体就是一个 JAR 文件,用 Go 编写的 auxpi 的程序本体就是编译出来的可执行文件,部署的时候把这个文件往服务器上一丢就完事了。
之前在一位巨佬的安利下试着学习了一下 PHP 的 Swoole 框架,这个框架也是使用了常驻后台的模式。在自己对着 Swoole 的文档摸爬滚打之后,终于试着用它做了一个在线剪贴板,等到做完了才想起来,到底要怎么发布成品呢……?
直接发布一大堆 PHP 源代码的话虽然可以,但是太不优雅了。如果能把所有的代码(甚至加上那些静态文件)用 Phar 打包,然后用 php app.phar
直接运行,那就很简洁了!(´• ᵕ •`)*
第一个 Phar 包
默认情况下 PHP 只能读取而不能写入 Phar 包,所以需要先去 php.ini
里面把 phar.readonly
的值改成 Off
。Phar 和 tar、zip 之类的打包格式很像,不过它的特性在于可以直接被 PHP 读取 / 执行。实际上,Phar 里面可以添加不限于 PHP 代码的任何文件。
先来个简单的栗子~使用 PHP 的二维码库 endroid/qr-code,在 index.php
中根据 URL 参数生成对应内容的二维码。
index.php
的代码:
require_once __DIR__ . '/vendor/autoload.php';
$qr = new \Endroid\QrCode\QrCode($_GET['text'] ?? '');
header('Content-Type: ' . $qr->getContentType());
echo $qr->writeString();
用 Composer 装好依赖以后,目录结构大概就是下面这个样子:
├─composer.json
├─composer.lock
├─index.php
└─vendor
├─autoload.php
└─……(一堆依赖包)
虽然部署的时候不需要带上 vendor
文件夹,只要现场 composer install
一下就可以了。不过这里就试试用 Phar 对 vendor
打包好了~在根目录写一个简单的打包脚本:
// 完全删除之前的Phar包
@unlink('vendor.phar');
// 新建一个Phar包,如果文件已存在的话就是在已存在的包上修改
$phar = new Phar('vendor.phar');
// 将vendor文件夹整个放进Phar包
$phar->buildFromDirectory('vendor');
// 停止缓存,将Phar包写入文件
$phar->stopBuffering();
在命令行中执行后,就可以得到一个 vendor.phar
。一般情况下引入所有的库都是用 require_once __DIR__ . '/vendor/autoload.php'
,现在把它改成从刚刚打好的 Phar 包引入:require_once 'phar://' . __DIR__ . '/vendor.phar/autoload.php'
,也是可以正常运行的~
甚至还可以对包里面的文件直接使用各种文件处理函数,比如 file_get_contents('phar://' . __DIR__ . '/vendor.phar/autoload.php')
;
这里的 phar://
表示从一个 Phar 包中读取文件(如果不写的话就不能和“读取名为 vendor.phar
的文件夹里面的某个文件”区分了),包里的文件结构和刚才的 vendor
文件夹是一样的。
将各种文件打包,然后用 require
从包中引入,这就是 Phar 的基本用法了。因为这些文件不需要通过用户访问 URL 直接获取 / 执行,所以即使它们被打包了也没什么关系。零碎文件太多的情况下,打个包还是很方便的(っ´ω`)ノ
处理 Phar 包的文件
使用 buildFromDirectory
默认是把文件夹内的所有文件都拿去打包,还可以使用一个正则表达式来筛选需要打包的文件,Phar 也支持解包操作。
// 只打包PHP文件
$phar->buildFromDirectory('vendor', '/\.php$/');
// 解包至extract文件夹
$phar->extractTo('extract');
也可以用 addFile
逐个添加文件。在上一个栗子的基础上,假设根目录里还有一个 static
文件夹,用来保存图片等静态资源:
// 添加一个文件
$phar->addFile('static/image.jpg');
// 添加一个文件,但是在包里面是放在another_static目录下
$phar->addFile('static/image.jpg', 'another_static/image.jpg');
// 直接指定文件内容,另外还可以删除文件
$phar->addFromString('test.php', '<?php echo "Hello world!";');
$phar->delete('test.php');
// 还可以使用关联数组的写法
$phar['test.php'] = '<?php echo "Hello world!";';
unset($phar['test.php']);
还可以使用递归遍历的方式将文件夹内的文件打包,在此基础上可以进行进一步的筛选,比如把不需要的测试文件(tests
)、使用文档(docs
、examples
)等等都筛选掉:
$files = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator('vendor', RecursiveDirectoryIterator::SKIP_DOTS)
);
foreach ($files as $file) {
// $file是每个文件的路径,方便起见统一使用/作为路径分隔符
$file = str_replace('\\', '/', $file);
// 排除.git文件夹里面的文件
if (in_array('.git', explode('/', $file))) continue;
$phar->addFile($file);
echo $file . "\n";
}
Phar 包的“入口”
如果只用来文件打包的话,Phar 和一般的压缩包也没什么区别嘛……
执行 Phar 包的时候会先执行一段 PHP 代码,可以设置一个“入口文件”(默认是 index.php
)用来自动执行:
$phar = new Phar('app.phar');
$phar->addFromString('hello.php', '<?php echo require "include.php";');
$phar->addFromString('include.php', '<?php return "Hello world!";');
$phar->addFile('static/image.jpg');
// createDefaultStub可以根据包里的某个文件生成一段代码
// setStub可以设定自动执行的代码,配合使用的话就可以实现“入口文件”的功能
$phar->setStub($phar->createDefaultStub('hello.php'));
$phar->stopBuffering();
这样的话,在命令行中输入 php app.phar
,或者在 PHP 代码里输入 require_once 'phar://' . __DIR__ . '/app.phar';
,都可以输出“Hello world!”,Phar 包里面的其它文件仍然可以照常读写。
做到这一步已经可以用 Phar 打包 CLI 程序了,那如果在浏览器里访问的话会怎么样呢?在没有额外配置过的 Nginx + PHP 的环境下测试一下:
- 访问
http://localhost/app.phar
,结果是浏览器询问是否要下载 Phar 文件。 - 访问
http://localhost/app.phar/
,得到了 404 错误,看来 Phar 并不会像 Nginx 一样自动配置首页,把xxx/
当成xxx/index.php
来处理。 - 访问
http://localhost/app.phar/static/image.jpg
,得到的也是 404,由此可见并不能直接从浏览器中访问 Phar 里面的静态文件。 - 访问
http://localhost/app.phar/hello.php
,输出了“Hello world!”。 - 访问
http://localhost/app.phar/include.php
,还是输出了“Hello world!”,按理来说这个文件不会输出任何东西的啊? - 访问
http://localhost/app.phar/not_exist.php
,仍然输出了“Hello world!”,为什么这回没有 404 了? - 访问
http://localhost/app.phar/path_not_exist/file_not_exist.php
,依然输出了“Hello world!”,这……?!
由此得出结论,只要在浏览器中访问时,URL 中 Phar 包后面的部分是以 .php
结尾的(不论最后的这个文件名是什么),那么就一定会执行“入口文件”。所以如果要只用一个 Phar 直接部署网站的话,有两种方法:
- 如果网站程序是“单入口模式”,也就是所有的路由集中在
index.php
上,那就可以把这个index.php
设为入口文件打包然后设置禁止访问,再另建一个index.php
,内容只有对这个包的require_once
。 - 如果是“多入口模式”,也就是把文件系统当成路由,那就需要自己写一个“入口文件”来根据 URL
require_once
包里面对应路径的文件。
另外静态文件是需要单独保存的,即使打了包也不能从浏览器中直接访问。理论上确实可以写个 PHP 文件专门用来从包里面读取并返回文件……但是在大多数情况下,这么做是没有必要的啦╮(╯_╰)╭
在打包的时候进行压缩
只要 PHP 环境附带了 zlib 或 bz2 扩展,就可以在打包的时候对包里面的文件进行压缩,当然也要附带对应的扩展才能使用被压缩过的包。将压缩后的包不经过解压直接解包的话,虽然也能得到文件,但是文件的内容是被压缩过的二进制数据。
// 默认是不使用压缩
$phar->compressFiles(Phar::NONE);
// 可以自行选择压缩算法,bz2比gz有更好的压缩效果
$phar->compressFiles(Phar::GZ);
$phar->compressFiles(Phar::BZ2);
// 还可以解压文件
$phar->decompressFiles();
如果需要更极端的压缩率,还可以像隔壁 JS 一样对代码文件进行 uglify(去掉不必要的换行、注释和空格)。比如对于 JSON 文件可以先反序列化再序列化:
$json = <<< 'JSON'
{
"foo": "bar",
"baz": 123
}
JSON;
echo json_encode(json_decode($json, true), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
// {"foo":"bar","baz":123}
那对于 PHP 文件呢?PHP 自带一个 php_strip_whitespace
函数,可以从文件中读取代码,然后返回去掉了注释和(大部分)空格的 PHP 代码。
$php = <<< 'PHP'
<?php
// 计算1到99的和
$sum = 0;
for ($i = 0; $i < 100; $i++) {
$sum += $i;
}
echo $sum;
PHP;
file_put_contents('code.php', $php);
echo php_strip_whitespace('code.php');
// <?php
// $sum = 0; for ($i = 0; $i < 100; $i++) { $sum += $i; } echo $sum;
// 理论上这段代码还能更短一些:
// <?php $sum=0;for($i=0;$i<100;$i++){$sum+=$i;}echo $sum;
在这个函数的文档下面,有人写了个可以更极端地压缩 PHP 代码的函数,通过 PHP 自带的语法分析函数 token_get_all
完全重写代码,理论上确实可以把代码压缩到最短,实际上……试着用了这个函数以后,Composer 创建的 autoload.php
就抛出了一个语法错误(°ー°〃)
(毕竟这是十二年前的代码,可能是不支持新版 PHP 的语法吧)
有关 Phar 打包的操作基本上就是这些~
另外,要获取当前正在执行的 Phar 包的路径 / 判断代码是否保存于 Phar 包中,可以使用 Phar::running()
:
// 输出当前所在的Phar包的路径
// 如果不是Phar包的话会返回空字符串
echo Phar::running();
echo Phar::running(false);
// phar://D:/app.phar
// D:/app.phar
// 可以设置一个常量判断代码是否在Phar包里运行
define('IN_PHAR', (bool)Phar::running());
本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。不允许内容农场类网站、CSDN 用户和微信公众号转载。
本文作者:✨小透明・宸✨
本文链接:https://akarin.dev/2020/04/25/phar-packaging/