PHP7
2015年正式发版php7.0,随后每年都有发布新版,截止本文时,php7.4已经出了,不过官网上稳定版还是7.3。
PHP7是趋势,不过我之前只是简单了解php语法,这次为了接入tdi,再捡起来索性放弃5.x,而且php7.1都快超过安全支持期了,7.2、7.3才比较靠谱。
Tdi-php
这个小应用是用来下载大量图片的,基于php7,开发版本php7.2,但是并没有用其新特性,所以支持7.0+,另外测试过的操作系统也只有CentOS、Ubuntu。
本文即记录开发这个小应用时的一些工具,本人菜鸟,文中有误万请指点!
1. 系统负载、磁盘、内存等
这些函数是从几个探针程序中剥离,并添加一些其他的,不过数据来源于系统运行时产生的/proc文件,Windows并不适用。
1.1 获取系统内存使用率
//返回内存使用率百分比,类型float
function memRate()
{
$res = array();
// MEMORY
if (false === ($str = @file('/proc/meminfo'))) {
return false;
}
$str = implode('', $str);
preg_match_all('/MemTotal\s{0,}\:+\s{0,}([\d\.]+).+?MemFree\s{0,}\:+\s{0,}([\d\.]+).+?Cached\s{0,}\:+\s{0,}([\d\.]+).+?SwapTotal\s{0,}\:+\s{0,}([\d\.]+).+?SwapFree\s{0,}\:+\s{0,}([\d\.]+)/s', $str, $buf);
preg_match_all('/Buffers\s{0,}\:+\s{0,}([\d\.]+)/s', $str, $buffers);
$res['memTotal'] = round($buf[1][0]*1024, 2);
$res['memFree'] = round($buf[2][0]*1024, 2);
$res['memBuffers'] = round($buffers[1][0]*1024, 2);
$res['memCached'] = round($buf[3][0]*1024, 2);
$res['memUsed'] = $res['memTotal']-$res['memFree'];
$res['memPercent'] = (floatval($res['memTotal'])!=0)?round($res['memUsed']/$res['memTotal']*100, 2):0;
return $res['memPercent'];
}
1.2 获取系统负载
//返回五分钟负载,类型float
function loadStat()
{
// LOAD AVG
if (false === ($str = @file('/proc/loadavg'))) {
return false;
}
$load = explode(' ', implode('', $str));
return floatval($load[1]);
}
1.3 获取目录所在磁盘的使用率
//返回path目录所在磁盘的使用率百分比,类型int;传参ret不为percent时,返回目录所在磁盘的数据,类型array
function diskRate(string $path=null, string $ret='percent')
{
//硬盘
$checkPath = $path ? $path : __DIR__;
$dt = round(@disk_total_space($checkPath), 3); //总
$df = round(@disk_free_space($checkPath), 3); //可用
$du = $dt-$df; //已用
$hdPercent = (floatval($dt)!=0)?round($du/$dt*100, 2):0;
return $ret === 'percent' ? $hdPercent : array('total'=>$dt, 'available'=>$df, 'used'=>$du, 'percent'=>$hdPercent);
}
2. 统计目录中所有文件总字节(不包括子目录)
//用来统计一个目录的大小,返回字节,exclude允许排除统计某些后缀,比如$exclude = ['zip','txt']
function getDirSize(string $dir, array $exclude=array())
{
$sizeResult = 0;
$handle = opendir($dir);
while (false!==($FolderOrFile = readdir($handle))) {
if ($FolderOrFile != "." && $FolderOrFile != "..") {
$f = "$dir/$FolderOrFile";
if (is_file($f) && !in_array(pathinfo($f, PATHINFO_EXTENSION), $exclude)) {
$sizeResult += filesize($f);
}
}
}
closedir($handle);
return $sizeResult;
}
3. 彻底删除目录(文件和子目录,等同rm -rf)
function delDir(string $directory)
{
if (file_exists($directory)) {//判断目录是否存在,如果不存在rmdir()函数会出错
if ($dir_handle=@opendir($directory)) {//打开目录返回目录资源,并判断是否成功
while ($filename=readdir($dir_handle)) {//遍历目录,读出目录中的文件或文件夹
if ($filename!='.' && $filename!='..') {//一定要排除两个特殊的目录
$subFile=$directory."/".$filename;//将目录下的文件与当前目录相连
if (is_dir($subFile)) {//如果是目录条件则成了
delDir($subFile);//递归调用自己删除子目录
}
if (is_file($subFile)) {//如果是文件条件则成立
unlink($subFile);//直接删除这个文件
}
}
}
closedir($dir_handle);//关闭目录资源
rmdir($directory);//删除空目录
}
}
}
4. Zip压缩目录
注释里有参考,整理并改进了一下,其效果是:压缩某个目录所有文件,但不包含子目录,也可以排除压缩某些后缀的文件。
不过注意:要压缩的文件(压缩时文件名放入待删除数组to_be_unlinked )在完成压缩后会删除,如果不想删除,参考下面代码第50行(foreach ($to_be_unlinked as $v)
)的注释提示。
/*
// 生成zip压缩文件的函数,参考:https://blog.csdn.net/zhao_teng/article/details/84941828
@param $dir string 需要压缩的文件夹名
@param $filename string 压缩后的zip文件名 包括zip后缀
@param $exclude array 不需要压缩的文件后缀,后缀不包含点
@return 成功时返回压缩文件的绝对路径,否则返回false
*/
class MakeZip
{
public function zip(string $dir, string $filename, array $exclude=array())
{
if (!is_dir($dir)) {
die('can not exists dir '.$dir);
}
//判断是否为zip后缀
if (pathinfo($filename, PATHINFO_EXTENSION) != 'zip') {
die('only Support zip files');
}
$dir = str_replace('\\', '/', $dir);
$filename = str_replace('\\', '/', $filename);
$filename = iconv('utf-8', 'gb2312', $filename);
if (is_file($filename)) {
die('the zip file '.$filename.' has exists !');
}
//目录中的所有文件
$files = array();
$this->getfiles($dir, $files);
$files = $this->array_iconv($files);
if (empty($files)) {
die(' the dir is empty');
}
//执行压缩
$zip = new ZipArchive();
$res = $zip->open($filename, ZipArchive::CREATE);
if ($res === true) {
foreach ($files as $v) {
// 设定在压缩包内文件名
$_in_zip_filename = str_replace($dir.'/', '', $v);
// 依据文件后缀判断是否排除(即不压缩)
if (is_file($v) && !in_array(pathinfo($_in_zip_filename, PATHINFO_EXTENSION), $exclude)) {
$zip->addFile($v, $_in_zip_filename);
$to_be_unlinked[] = $v;
}
}
$zip->close();
//由于zip需要close后才执行压缩操作,所以只能在这里删除压缩的文件,如果不想删除,请注释下面三行
foreach ($to_be_unlinked as $v) {
unlink($v);
}
return realpath($filename);
} else {
return false;
}
}
//定义图片字符集
protected function array_iconv($data, $in_charset='GBK', $out_charset='UTF-8')
{
if (!is_array($data)) {
$output = iconv($in_charset, $out_charset, $data);
} elseif (count($data) === count($data, 1)) {//判断是否是二维数组
foreach ($data as $key => $value) {
$output[$key] = iconv($in_charset, $out_charset, $value);
}
} else {
eval_r('$output = ' . iconv($in_charset, $out_charset, var_export($data, true)) . ';');
}
return $output;
}
//获取目录中文件赋值给$files
private function getfiles($dir, &$files=array())
{
if (!is_dir($dir)) {
return false;
}
if (substr($dir, -1)=='/') {
$dir = substr($dir, 0, strlen($dir)-1);
}
$_files = scandir($dir);
foreach ($_files as $v) {
if ($v != '.' && $v!='..') {
if (is_file($dir.'/'.$v)) {
$files[] = $dir.'/'.$v;
}
}
}
return $files;
}
}
示例:
<?php
require_once 'tool.php';
//压缩test_dir目录,生成test.zip压缩文件,但压缩时不会压缩目录中.zip结尾的文件。
$zip = new MakeZip;
$zipfilepath = $zip->zip('test_dir', 'test.zip', ['zip']);
5. 简单地日志记录
由于是个小应用,但是也有些信息需要记录,所以直接使用file_put_contents写入到文件中,利用debug_backtrace获取写日志的文件和行号。
/*
* 日志类
* 当文件超过指定大小则备份日志文件并重新生成新的日志文件
* 使用时,直接调用三个静态方法,info、error、debug
*/
class Log
{
//日志文件名,放到了logs目录了
private static $filename = __DIR__.'/logs/sys.log';
//最大文件大小10M
private static $maxsize = 10240000;
//写入日志
protected function _log($msg, $level='INFO')
{
if (!is_dir(__DIR__.'/logs')) {
mkdir(__DIR__.'/logs');
}
$filename = self::$filename;
// 来源文件与行号,回溯2条
$trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, $limit=2);
$file = $trace[1]['file'];
$line = $trace[1]['line'];
// 格式化消息
if (is_array($msg)) {
$msg = json_encode($msg);
}
$content = sprintf("[ %s ] %s %s:%s %s\n", $level, date('Y-m-d H:i:s', time()), str_replace(__DIR__.'/', '', $file), $line, $msg);
//如果日志文件超过了指定大小则备份日志文件
if (file_exists($filename) && (abs(filesize($filename)) > self::$maxsize)) {
$newfilename = dirname($filename).'/'.time().'-'.basename($filename);
rename($filename, $newfilename);
}
//往日志文件内容后面追加日志内容
file_put_contents($filename, $content, FILE_APPEND);
}
public static function debug($msg)
{
$log = new self();
$log->_log($msg, 'DEBUG');
}
public static function info($msg)
{
$log = new self();
$log->_log($msg, 'INFO');
}
public static function error($msg)
{
$log = new self();
$log->_log($msg, 'ERROR');
}
}
示例:
# cat test.php
<?php
require_once 'tool.php';
Log::info('info');
Log::error('error');
Log::debug('debug');
# cat logs/sys.log
[ INFO ] 2019-06-19 17:50:30 test.php:5 info
[ ERROR ] 2019-06-19 17:50:31 test.php:6 error
[ DEBUG ] 2019-06-19 17:50:31 test.php:7 debug
6. curl的封装
/*
//使用curl扩展发起http请求,支持https,可以发起get、post请求
@param $url string: 请求地址
@param $data array: 如果存在,则为post请求
@param $timeout int: 超时秒数
@param $parse_json boolean: 是否将响应进行json解码
@return array
*/
function request(string $url, array $data=null, int $timeout=10, bool $parse_json=true)
{
$useragent = 'Tdi-PHP/v1';
$curl = curl_init();
curl_setopt($curl, CURLOPT_URL, $url);
curl_setopt($curl, CURLOPT_HEADER, 1); //设置返回响应头
curl_setopt($curl, CURLOPT_USERAGENT, $useragent);
curl_setopt($curl, CURLOPT_TIMEOUT, $timeout);
curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($curl, CURLOPT_SSL_VERIFYHOST, false);
curl_setopt($curl, CURLOPT_FOLLOWLOCATION, 1);
curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1);
if (!empty($data)) {
curl_setopt($curl, CURLOPT_POST, 1);
curl_setopt($curl, CURLOPT_POSTFIELDS, $data);
}
$output = curl_exec($curl);
if (curl_getinfo($curl, CURLINFO_HTTP_CODE) === 200) {
list($header, $body) = explode("\r\n\r\n", $output, 2);
}
curl_close($curl);
return $parse_json ? json_decode($body, true) : $body;
}
7. php-resque的使用
前面有说,本文就是在写一个小应用时的使用记录,觉得有用的几个函数、类分享出来,而这个应用在下载大量图片时,采用了后台队列的方式,将下载功能独立一个类,并采用php-resque这个轻量级队列程序将下载放到队列中,另行处理。
php-resque是resque(ruby)的php实现,原作者仓库是https://github.com/chrisboulton/php-resque, 但是我用composer安装这个仓库代码时无法运行,少了些东西;原仓库已经几年没更新了,作者将项目交给了resque社区维护,仓库地址是:https://github.com/resque/php-resque
以下步骤会运行一个简单的示例,但不是官方给的demo,可以参考示例集成到你的项目中。
此示例假设,项目目录是demo,redis密码abc,端口默认6379,使用0库,其DSN-style连接串是redis://:abc@127.0.0.1
7.1 安装
使用命令:composer require resque/php-resque
解释说明:composer是php的包管理命令,如果没有安装,参考官方文档,也可以用操作系统的包管理器安装,比如Ubuntu用apt-get install composer
,RHEL/CentOS系用yum install composer
7.2 创建文件
上面安装了resque/php-resque后,会在当前demo目录下生成composer.json、composer.lock文件和vender目录:
# tree -L 3
.
├── composer.json
├── composer.lock
└── vendor
├── autoload.php
├── bin
│ └── resque -> ../resque/php-resque/bin/resque
├── colinmollenhour
│ └── credis
├── composer
│ ├── autoload_classmap.php
│ ├── autoload_namespaces.php
│ ├── autoload_psr4.php
│ ├── autoload_real.php
│ ├── autoload_static.php
│ ├── ClassLoader.php
│ ├── installed.json
│ └── LICENSE
├── psr
│ └── log
└── resque
└── php-resque
关于composer生成的这些文件,可以只保留composer.json
,也可以全都要,更多了解请自行Google,现在了解下php-resque。
在Resque中,一个后台任务被抽象为由三种角色共同完成:
Job | 任务 : 一个Job就是一个需要在后台完成的任务,比如发邮件、下载图片,就可以抽象为一个Job。在Resque中一个Job就是一个Class。
Queue | 队列 : 在Resque中,队列则是由Redis实现的。Resque还提供了一个简单的队列管理器,可以实现将Job插入/取出队列等功能。
Worker | 执行者 : 负责从队列中取出Job并执行,可以以守护进程的方式运行在后台。
其流程如下:
将一个后台任务编写为一个独立的Class,这个Class就是一个Job。
在需要使用后台程序的地方,系统将Job Class的名称以及所需参数放入队列。
以命令行方式开启一个Worker,并通过参数指定Worker所需要处理的队列。
Worker作为守护进程运行,并且定时检查队列。
当队列中有Job时,Worker取出Job并运行,即实例化Job Class并执行Class中的方法。
php-resque是resque的php实现,所以角色和流程是一致的,不过php-resque没有web的队列管理器等。
7.2.1 编写任务类文件:job.php
任务类要实现perform
方法,参考文档,这个方法所需参数可以用$this->args['参数']
获取。
<?php
class TestJob
{
public function perform()
{
$name = $this->args['name'];
echo 'Hello '.$name;
}
}
7.2.2 编写调用任务代码:index.php
上面job.php中的TestJob类,在项目中不会直接调用,项目代码中需要主动往队列Queue中扔任务,参考文档
以下模拟项目代码index.php:
<?php
// 执行成功,将任务放到队列中
require_once './vendor/autoload.php';
// 设置resque的redis连接信息
Resque::setBackend('redis://:abc@127.0.0.1');
$args = array(
'name'=> 'saintic.com'
);
// 入列
Resque::enqueue('default', 'TestJob', $args);
注意入列这行:default是队列名,可以设置为其他名称;TestJob是上面job.php任务类的类名;$args就是任务类perform
方法所需参数。
7.2.3 启动Worker进程等待处理任务
使用php-resque单独启动worker处理进程在后台处理任务,参考文档,启动进程一条命令可以实现:
# QUEUE=default APP_INCLUDE=job.php REDIS_BACKEND=redis://:abc@127.0.0.1 php vendor/bin/resque
[notice] Starting worker your_hostname:499:default
解释下,启动resque队列处理进程主要是靠vender/bin/resque,它运行时接收几个环境变量,参照上面的命令,环境变量有:
QUEUE - 必需,队列名,resque进程会处理哪些队列
APP_INCLUDE - 也是必需,引入任务类文件
REDIS_BACKEND - redis连接信息,可以使用DSN-style,默认localhost:6379
这命令会在前台启动,新开终端可以看到resque进程。
7.2.4 测试执行任务
现在在新终端以cli方法执行index.php,会向default队列扔一个TestJob任务,然后worker检测到任务后会执行,worker那个终端会输出"Hello saintic.com"
# QUEUE=default APP_INCLUDE=job.php REDIS_BACKEND=redis://:abc@127.0.0.1 php vendor/bin/resque
[notice] Starting worker taochengwei:499:default
[notice] Starting work on (Job{default} | ID: 71ef15f4246b450abe9fd6436758e73f | TestJob | [{"name":"saintic.com"}])
Hello saintic.com
[notice] (Job{default} | ID: 71ef15f4246b450abe9fd6436758e73f | TestJob | [{"name":"saintic.com"}])
has finished
PS:以上输出内容我手动换行了。
当前demo目录文件结构如下:
# tree -L 1
.
├── composer.json
├── composer.lock
├── index.php
├── job.php
└── vendor
以上php-resque示例中,php版本7.2,操作系统Ubuntu 18.04.1 LTS
附录:一个启动、停止php-resque worker进程的脚本,github这里,这个脚本中任务类文件写死是qf.php,供参考,自行修改哦。
注意:停止进程最好使用signals,参考文档,上面脚本用的是QUIT信号。