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并执行,可以以守护进程的方式运行在后台。

其流程如下:

  1. 将一个后台任务编写为一个独立的Class,这个Class就是一个Job。

  2. 在需要使用后台程序的地方,系统将Job Class的名称以及所需参数放入队列。

  3. 以命令行方式开启一个Worker,并通过参数指定Worker所需要处理的队列。

  4. Worker作为守护进程运行,并且定时检查队列。

  5. 当队列中有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,它运行时接收几个环境变量,参照上面的命令,环境变量有:

  1. QUEUE - 必需,队列名,resque进程会处理哪些队列

  2. APP_INCLUDE - 也是必需,引入任务类文件

  3. 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信号。

END


·End·