Category: PHP

简单实用的PHPMonitor:运行错误监控

所有运行在线网环境的程序应该都被监控,对线网而言,无论是 Fatal error、E_NOTICE 还是 E_STRICT,都应该被消灭。对开发者而言,线网发生任何异常或潜在bug时,应该第一时间修复、优化,而不是等反馈之后才知道,然后临时去排查问题!

这篇文章要讲啥?

  1. 了解PHP错误机制
  2. 注册PHP的异常处理函数、错误处理函数、脚本退出函数
  3. 配置PHP预加载(重要)
  4. 搭建日志中心(ELK,ElasticSearch + Logstash + Kibana)
  5. 基于 ElasticSearch RESTful 实现告警

先看一张系统全局图

要达到的目的?

所有PHP程序里的错误或潜在错误(支持自定义)都全部写入日志并告警,包含错误的所在文件名、行号、函数(如果有)、错误信息等等
PHP程序不需要做任何修改或接入!

一、了解PHP错误机制

截止PHP7.1,PHP共有16个错误级别:
https://php.net/manual/zh/errorfunc.constants.php

如果你对PHP错误机制还不太了解,网上有很多关于PHP错误机制的总结,因为这个不是文章的重点,这里不再详细介绍!

二、注册PHP的异常处理函数、错误处理函数、脚本退出函数

PHP有三个很重要的注册回调函数的函数
- register_shutdown_function 注册PHP退出时的回调函数
- set_error_handler 注册错误处理函数
- set_exception_handler 注册异常处理函数

我们先定义一个日志处理类,专门用于写日志(也可以用于用户日志哦)

<?php
/**
 * 日志接口
 * @filename Loger.php
 * @since 2016-12-08 12:13:50
 * @author 979137.com
 * @version $Id$
 */
class Loger {

    // 日志目录,建议独立于Web目录,这个目录将作用于 Logstash 日志收集
    protected static $log_path = '/data/logs/';

    // 日志类型
    protected static $type = array('ERROR', 'INFO', 'WARN', 'CRITICAL');

    /**
     * 写入日志信息
     * @param mixed  $_msg  调试信息
     * @param string $_type 信息类型
     * @param string $file_prefix 日志文件名默认取当前日期,可以通过文件名前缀区分不同的业务
     * @param array  $trace TRACE信息,如果为空,则会从debug_backtrace里获取
     * @return bool
     */
    public static function write($_msg, $_type = 'info', $file_prefix = '', $trace = array()) {
        $_type = strtoupper($_type);
        $_msg  = is_string($_msg) ? $_msg : var_export($_msg, true);
        if (!in_array($_type, self::$type)) {
            return false;
        }
        $server = isset($_SERVER['SERVER_ADDR']) ? $_SERVER['SERVER_ADDR'] : '0.0.0.0';
        $remote = isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : '0.0.0.0';
        $method = isset($_SERVER['REQUEST_METHOD']) ? $_SERVER['REQUEST_METHOD'] : 'CLI';
        if (!is_array($trace) || empty($trace)) {
            $dtrace = debug_backtrace();
            $trace  = $dtrace[0];
            if (count($dtrace) == 1) {
                //不是在类或函数内调用
                $trace['function'] = '';
            } else {
                if ($dtrace[1]['function'] == '__callStatic') {
                    $trace['file'] = $dtrace[2]['file'];
                    $trace['line'] = $dtrace[2]['line'];
                    $trace['function'] = empty($dtrace[3]['function']) ? '' : $dtrace[3]['function'];
                } else {
                    $trace['function'] = $dtrace[1]['function'];
                }
            }
        }
        $ace = $trace;
        $now = date('Y-m-d H:i:s');
        $pre = "[{$now}][%s][{$ace['file']}][{$ace['line']}][{$ace['function']}][{$remote}][{$method}][{$server}]%s";
        $msg = sprintf($pre, $_type, $_msg);

        $filename = 'phplog_' . ($file_prefix ?: 'netbar') . '_' . date('Ymd') . '.log';
        $destination = self::$log_path . $filename;
        is_dir(self::$log_path) || mkdir(self::$log_path, 0777, true);
        //文件不存在,则创建文件并加入可写权限
        if (!file_exists($destination)) {
            touch($destination);
            chmod($destination, 0777);
        }
        return error_log($msg.PHP_EOL, 3, $destination) ?: false;
    }

    /**
     * 静态魔术调用
     * @param $method
     * @param $args
     * @return mixed
     *
     * @method void error($msg) static
     * @method void info($msg) static
     * @method void warn($msg) static
     */
    public static function __callStatic($method, $args) {
        $method = strtoupper($method);
        if (in_array($method, self::$type)) {
            $_msg = array_shift($args); 
            return self::write($_msg, $method);
        }
        return false;
    }
}

接下来,再写一个系统处理类,定义回调函数和注册回调函数

<?php
/**
 * 注册系统处理函数
 * @filename Handler.php
 * @since 2016-12-08 12:13:50
 * @author 979137.com
 * @version $Id$
 */
class Handler {

    const LOG_FILE_PREFIX = 'handler';
    const LOG_TYPE = 'CRITICAL';

    /**
     * 函数注册
     * @return none
     */
    static public function set() {
        //注册致命错误处理方法
        register_shutdown_function(array(__CLASS__, 'fatalError'));
        //注册自定义错误处理方法
        set_error_handler(array(__CLASS__, 'appError'));
        //注册异常处理方法
        set_exception_handler(array(__CLASS__, 'appException'));
    }

    /**
     * 致命错误捕获,PHP错误级别预定义常量参考:
     * http://php.net/manual/zh/errorfunc.constants.php
     * @return none
     */
    static public function fatalError() {
        $error = error_get_last() ?: null;
        if (!is_null($error) && in_array($error['type'], array(E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR))) {
            $error['class'] = $error['function'] = '';
            Loger::write($error['message'], self::LOG_TYPE, self::LOG_FILE_PREFIX, $error);
            self::halt($error);
        }
    }
    /**
     * 自定义错误处理
     * @param int $errno 错误类型
     * @param string $errstr 错误信息
     * @param string $errfile 错误文件
     * @param int $errline 错误行数
     * @return void
     */
    static public function appError($errno, $errstr, $errfile, $errline) {
        $error['message'] = "[$errno] $errstr";
        $error['file'] = $errfile;
        $error['line'] = $errline;
        $error['class'] = $error['function'] = '';
        if (!in_array($errno, array(E_STRICT, E_DEPRECATED))) {
            Loger::write($error['message'], self::LOG_TYPE, self::LOG_FILE_PREFIX, $error);
            if (in_array($errno, array(E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR))) {
                self::halt($error);
            }
        }
    }

    /**
     * 自定义异常处理
     * @param mixed $e 异常对象
     * @return void
     */
    static public function appException($e) {
        $error = array();
        $error['message']  = $e->getMessage();
        $error['file'] = $e->getFile();
        $error['line'] = $e->getLine();
        $trace = $e->getTrace();
        if(empty($trace[0]['function']) && $trace[0]['function'] == 'exception') {
            $error['file'] = $trace[0]['file'];
            $error['line'] = $trace[0]['line'];
        }
        //$error['trace']  = $e->getTraceAsString();
        $error['function'] = $error['class'] = '';
        Loger::write($error['message'], self::LOG_TYPE, self::LOG_FILE_PREFIX, $error);
        self::halt($error);
    }

    /**
     * 错误输出
     * @param mixed $error 错误
     * @return void
     */
    static public function halt($error) {
        ob_get_contents() && ob_end_clean();
        $e = array();
        if (IS_DEBUG || IS_CLI) {
            //调试模式下输出错误信息
            $e = $error;
            if(IS_CLI){
                $e_message  = $e['message'].' in '.$e['file'].' on line '.$e['line'].PHP_EOL;
                if (isset($e['treace']) ) {
                    $e_message .= $e['trace'];
                }
                exit($e_message);
            }
        } else {
            //线网不显示错误信息,显示固定字符串,保护系统安全
            //TODO:比较友好的做法是重定向到一个漂亮的错误页面
            exit('Sorry, the system error');
        }
        // 包含异常页面模板
        $exceptionFile = __DIR__ . '/exception.tpl';
        include $exceptionFile;
        exit(0);
    }
}

二、自动加载脚本(auto_append_file)

以上两个脚本准备就绪以后,我们可以把它们合并到一个文件,并增加两个重要常量:

<?php
//是否CLI模式
define('IS_CLI', PHP_SAPI == 'cli' ? true : false);
//当前是否开发模式,用于区分线网和开发模式,默认false
//开发模式下,所有错误会打印出来。非开发模式下,不会打印到页面,但会记录日志
define('IS_DEBUG', isset($_SERVER['DEV_ENV']) ? true : false);
//注册系统处理函数
Handler::set();

假设合并后的文件名叫:auto_prepend_file.php
我们把这个文件进行预加载(即自动包含进所有PHP脚本)
这时候到了很重要的一步就是,就是配置 php.ini

auto_prepend_file = /your_path/auto_prepend_file.php

重启你的Web服务器如 ApacheNginx
写一个测试脚本 test.php

<?php
var_dump($tencent);

因为 $tencent 未定义,所以这时候就会回调我们注册的函数,可以看到已经有错误日志了

php -f test.php
tail -f phplog_handler_20161209.log 

[2016-12-09 16:01:05][CRITICAL][/data/logs/test.php][2][][0.0.0.0][CLI][0.0.0.0][8] Undefined variable: tencent

三、搭建日志中心(ELK,ElasticSearch + Logstash + Kibana)

因为我们的Web服务器一般都是分布式的,线网可能有N台服务器,我们的日志又是写本地,所以这时候就需要一个日志中心了,
日志系统目前已经有很多成熟的解决方案了,这里推荐 ELK 部署,
ElasticSearch,一个基于 Lucene 的搜索服务器,它提供了一个分布式多用户能力的全文搜索引擎,基于 RESTful 架构。
Logstash,分布式日志收集、分析、存储工具
Kibana,基于 ElasticSearch 的前端展示工具
因为网上已经很多 ELK 的搭建教程了,这里就不重复写了!

下面是一个基于ELK搭建好的日志中心,好不好用,用了就知道了!^_^

四、利用 ElasticSearch RESTful 实现告警功能

前面我们提到 ElasticSearch 是基于 RESTful 架构!
ElasticSearch 提供了非常多的接口,包括索引的增删改查,文档的增删改查,各种搜索

ElasticSearch官方提供了一个PHP版本的SDK:Elasticsearch-PHP
官方文档(全英文,目前没有中文版,看起来可能吃力,英文就英文吧,认真啃):
https://www.elastic.co/guide/en/elasticsearch/client/php-api/current/index.html

我们要做告警要用到的就是搜索接口
说白了,就是定时去把前面写的那些日志,全扫出来,然后告警给关系人!

Elasticsearch-PHP 安装很简单,通过 Composer 来安装,我这里以 1.0 为例子
1、编写一个 composer.json 文件

{
    "require": {
        "elasticsearch/elasticsearch": "~1.0"
    }
}

2、cd到你的项目里,下载安装包

curl -s http://getcomposer.org/installer | php

3、安装 Elasticsearch-PHP 及其依赖

php composer.phar install --no-dev

完事之后,就可以写告警脚本了!下面是我写的一个示例

<?php
//ElasticSearch Server,这是你的ELK服务器和端口
$es_server = array(
    '127.0.0.1:9200',
);

//接受告警人员
$alert_staff = array(
    'shiliangxie' => 18666665940,
    'xieshiliang' => 18600005940,
);

//需要告警的错误级别
$alert_level = array('ERROR', 'WARN', 'CRITICAL');

//告警周期,单位:分钟,这个需要和 cron 设置成一样的时间
$alert_cycle = 10;

###################################################################

//搜索最近 $alert_cycle 的错误日志
$q['index'] = 'logstash-'.date('Y.m.d');
$q['ignore_unavailable'] = true;
$q['type'] = 'php';
$q['size'] = 500;
$q['sort'] = array('@timestamp:desc');
$range_time['lte'] = sprintf('%.3f', microtime(true)) * 1000;
$range_time['gte'] = $range_time['lte'] - $alert_cycle * 60 * 1000;
$range = array('@timestamp' => $range_time);
$match = array('level' => implode(' OR ', $alert_level));
$q['body']['query']['filtered'] = array(
    'filter' => array('range' => $range),
    'query'  => array('match' => $match),
);
$params['hosts'] = $es_server;
require __DIR__.'/vendor/autoload.php';
$client = new Elasticsearch\Client($params);
$ret = $client->search($q);
$hit = $ret['hits']['hits'];
$hit = is_array($hit) && count($hit) ? $hit : array();

//组织告警内容
$alerts = array();
foreach($hit as $h) {
    $source = $h['_source'];
    $tag = md5($source['level'] . $source['file'] . $source['line']. $source['msg']);
    if (isset($alerts[$tag])) {
        $alerts[$tag]['num']++;
    } else {
        $alerts[$tag]['num'] = 1;
        $content = '[%s][%s][%s][%s]%s';
        $alerts[$tag]['content'] = sprintf($content, date('Y-m-d H:i:s', strtotime($source['@timestamp'])),
            $source['level'], $source['file'], $source['line'], $source['msg']
        );
    }
}
//var_dump($alerts);exit;
//发送告警
if (is_array($alerts) && count($alerts)) {
    array_walk($alerts, function($alert, $tag) use($alert_staff) {
        $msg = sprintf('[Repeat:%d]%s', $alert['num'], $alert['content']);
        array_map(function($mobile) use($msg) {
            return send_sms($mobile, $msg);
        }, $alert_staff);
        return ;
    });
}

然后我们把告警脚本加到 crontab 即可

*/10 * * * * /usr/local/bin/php /data/cron/PHPMonitor.php

坐等告警吧!

对于报警方案,目前市场上也有很多实现方案,如 Yelp 公司的 ElastAlert,用 Python 写的一个基于 ELKElasticsearch RESTful 报警框架

PHP实现MySQL并发查询

一般的,一个看似很简单的页面,一次http请求后,到达服务端,穿过Cache层,落到后台后,实际可能会有很多很多的数据查询逻辑!而这些查询实际是不相互依赖的,也即可以同时查询。比如各种用户信息,用户的APP列表,每个APP对应的流量数据、消耗记录、服务状态,平台运行状态,消息通知,新闻资讯等等。
这篇文章主要介绍了数据查询层,如何把串行变并行,提高查询效率、提升应用性能。实现方式包括:mysqlnd异步查询,cURL并发请求,Swoole异步非阻塞!

PHP脚本是按文档流的形式来执行的,所以我们在编写PHP程序时,代码基本都是串行的,尤其是SQL,比如:
倒流’s Bolg
这种方式,是每次查询都需要等待结果返回之后再开始下一次的查询,
运行时间 = (第1次发送请求时间 + 0.01 + 第1次返回时间)+(第2次发送请求时间 + 0.05 + 第2次返回时间)+(第3次发送请求时间 + 0.03 + 第3次返回时间)

如果是并发查询,那么流程就成了:
倒流’s Bolg
运行时间 = 发送请求时间 + 0.05 + 返回时间

显然,并发查询要比串行查询快!

那么PHP可以实现并发查询吗?答案是肯定的!

一、利用MySQL的异步查询功能

目前 MySQL 的异步查询,只在 MySQLi 扩展提供,查询方法分别是:

  1. 使用 MYSQLI_ASYNC 模式执行 mysqli::query
  2. 获取异步查询结果:mysqli::reap_async_query

需要注意的是,使用异步查询,需要使用 mysqlnd 作为PHP的MySQL数据库驱动,
mysqlnd(MySQL Native Driver) 是 Zend 公司开发的 MySQL 数据库驱动,采用PHP开源协议,用于代替旧版的由 MySQL AB公司(现在的Oracle)开发的 libmysql,PHP 5.3 及以上版本开始提供,PHP5.4 之后的版本 mysqlnd 为默认配置选项,
如果 PHP 小于 5.4,编译时需要指定编译参数:

--with-mysqli=mysqlnd --with-pdo-mysql=mysqlnd

MySQL异步查询示例脚本:

<?php
/**
 * MySQL异步查询示例脚本:
 * @filename p_async.php
 * @url http://test.979137.com/ParallelSQL/p_async.php
 */

//期望结果集:获取以下用户的每个月每个APP的消费统计
$top = array('979137', '555555', '666666', '888888', '999999');
$ret = array_fill_keys($top, array());

//组织结构查询
$cmd = $resources = array();
$sql = "SELECT access_key,SUM(amount) sum_amount FROM consume_2016%s WHERE uid=%d AND product='SAE' GROUP BY access_key";
foreach($top as $uid) {
    for($i = 1; $i <= 12; $i++) {
        $ret[$uid][$i] = $tmp = array();
        $tmp['uid'] = $uid;
        $tmp['month'] = $i;
        $tmp['resource'] = $resources[] = new \mysqli('localhost', 'root', '123456', 'sae', '3306');
        $tmp['resource']->set_charset('utf8');
        $tmp['resource']->query(sprintf($sql, sprintf('%02d', $i), $uid), MYSQLI_ASYNC);
        $tag = spl_object_hash($tmp['resource']);
        $cmd[$tag] = $tmp;
    }
}

$total = $query_times = count($resources);

//获取结果
do {
    $read = $error = $reject = $resources;
    //等待查询结束
    if (!\mysqli::poll($read, $error, $reject, 1)) {
        continue;
    }
    //批量获取结果
    foreach($read as $resource) {
        $result = $resource->reap_async_query();
        if ($result) {
            $tag = spl_object_hash($resource);
            $uid = $cmd[$tag]['uid'];
            $month = $cmd[$tag]['month'];
            while(($row = $result->fetch_assoc()) != false) {
                $ret[$uid][$month][$row['access_key']] = $row['sum_amount'];
            }
            $result->free();
            $total--;

        } else die('MySQLi error: '.$resource->error);
    }
} while ($total > 0);

var_dump($ret);

二、cURL实现并发请求

先解释下 cURL 和 SQL 怎么就扯上关系了呢!

我们知道在很多系统架构里,PHP是不会直接操作DB的,而是 RESTful 架构,这时候所有操作都接口化了,
这时上述所讲的 SQL 就演变成 接口调用 了,
因为 API,所以 cURL!

以下是PHP中cURL多线程相关函数:

curl_multi_add_handle — 向curl批处理会话中添加单独的curl句柄
curl_multi_close — 关闭一组cURL句柄
curl_multi_exec — 运行当前 cURL 句柄的子连接
curl_multi_getcontent — 如果设置了CURLOPT_RETURNTRANSFER,则返回获取的输出的文本流
curl_multi_info_read — 获取当前解析的cURL的相关传输信息
curl_multi_init — 返回一个新cURL批处理句柄
curl_multi_remove_handle — 移除curl批处理句柄资源中的某个句柄资源
curl_multi_select — 等待所有cURL批处理中的活动连接
curl_multi_setopt — 为 cURL 并行处理设置一个选项
curl_multi_strerror — 返回描述错误码的字符串文本

我们可以利用这些多线程函数,实现 cURL 并发请求,从而实现并发 SQL!

cURL并发请求,服务端接口示例脚本:

<?php
/**
 * cURL并发请求,服务端接口示例脚本
 * @filename consume.php
 * @url http://test.979137.com/ParallelSQL/consume.php
 */
$resource = new \mysqli('localhost', 'root', '123456', 'sae', '3306');
$resource->set_charset('utf8');

$sql = "SELECT access_key,SUM(amount) sum_amount FROM consume_%d WHERE uid=%d AND product='%s' GROUP BY access_key";
$sql = sprintf($sql, $_GET['ym'], $_GET['uid'], $_GET['product']);
$res = $resource->query($sql);

$out['code'] = $resource->errno;
$out['message'] = $resource->error;
$data = array();
if (is_object($res)) {
    while(($row = $res->fetch_assoc()) != false) {
        $data[$row['access_key']] = $row['sum_amount'];
    }
}
$out['data'] = $data;

header('Content-Type: application/json; charset=utf-8');
echo json_encode($out);

cURL并发请求,客户端调用示例脚本:

<?php
/**
 * cURL并发请求,客户端调用示例脚本
 * @filename p_curl.php
 * @url http://test.979137.com/ParallelSQL/p_curl.php
 */

//期望结果集:获取以下用户的每个月每个APP的消费统计
$top = array('979137', '555555', '666666', '888888', '999999');
$ret = array_fill_keys($top, array());

$mch = curl_multi_init();
$opt[CURLOPT_HEADER] = 0;
$opt[CURLOPT_CONNECTTIMEOUT] = 60;
$opt[CURLOPT_RETURNTRANSFER] = true;
$opt[CURLOPT_HTTPHEADER] = array('Host: api.979137.com');

//生成句柄并加入到批处理
$cmd = array();
foreach($top as $uid) {
    for($i = 1; $i <= 12; $i++) {
        $ret[$uid][$i] = $tmp = array();
        $tmp['url'] = sprintf('http://127.0.0.1/ParallelSQL/consume.php?ym=2016%02d&uid=%d&product=SAE', $i, $uid);
        $tmp['uid'] = $uid;
        $tmp['month'] = $i;
        $tmp['resource'] = curl_init($tmp['url']);
        curl_setopt_array($tmp['resource'], $opt);
        curl_multi_add_handle($mch, $tmp['resource']);
        $cmd[] = $tmp;
    }
}

//并发执行,直到全部结束
do {
    curl_multi_exec($mch, $active); 
} while ($active > 0);

//获取全部结果
foreach($cmd as $c) {
    $res = curl_multi_getcontent($c['resource']);
    $http_code = curl_getinfo($c['resource'], CURLINFO_HTTP_CODE);
    if ($res === false || $http_code != 200) {
        die(curl_error($c['resource']));
    }
    $res = json_decode($res, true);
    $res['code'] && die($res['message']);
    $ret[$c['uid']][$c['month']] = $res['data'];
}
curl_multi_close($mch);

var_dump($ret);

查询效率对比

1、数据表机构:

CREATE TABLE `consume_201612` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `uid` int(10) unsigned NOT NULL,
  `product` enum('UID','SAE','SC2','SCE','SEM','SCS','SLS') NOT NULL DEFAULT 'SAE',
  `access_key` varchar(255) NOT NULL,
  `service_code` varchar(255) NOT NULL,
  `amount` int(10) unsigned NOT NULL,
  `remark` varchar(255) NOT NULL,
  `data` text NOT NULL,
  `times` int(10) unsigned NOT NULL,
  PRIMARY KEY (`id`),
  KEY `uid_pa` (`uid`,`product`,`access_key`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;

2、数据量:总共分12张表,每张表的数量在 500万~1100万 之间,全量数据在1亿以上

3、MySQL异步查询结果:

$ mysql.server restart
$ php p_async.php
Query times: 60
Run time: 2.4453s
(CPU)User time: 0.087966s
(CPU)System time: 0.117625s

4、cURL并发请求查询结果

$ mysql.server restart
$ php p_curl.php
Query times: 60
Run time: 2.173035s
(CPU)User time: 0.40652s
(CPU)System time: 0.869902s

5、普通串行查询结果

$ mysql.server restart
$ php p_sync.php
Query times: 60
Run time: 20.485623s
(CPU)User time: 0.083185s
(CPU)System time: 0.036566s
Memory usage: 304.72kb

并发执行时,我们可以在 MySQL 服务器看到所有的正在执行的SQL:

总结

1、在并发查询下,查询效率提高了近10倍
2、使用 MySQL 异步查询,因为需要给所有查询都创建一个新的连接,而 MySQL 服务端会为每个连接创建一个单独的线程进行处理,如果创建的线程数过多,会给系统造成负担,请谨慎使用
3、使用 cURL 并发请求后端接口时,CPU负载明显上升,所以并发请求后端接口,一定程度上会增加后端压力,这和前端大流量下的高并发原理是一样的
4、使用 cURL 并发请求,还需要考虑一个网络延时的问题,网络延时越小,查询效率提升越明显。如果你是想代替类方法或函数调用,在条件允许的情况,建议直接连接服务器本机即127.0.0.1
5、在并发请求下,因为需要一次性接收全部返回结果,所以会占用更多的内存资源

需要说明的是,在实际应用中 cURL 的并发请求,一般不只单用于数据查询,而是为了完成更多的后台业务逻辑,
所以,在服务器负载能力允许的情况下,推荐使用 cURL 并行转发的形式,提升前端响应速度!

最后说一下 Swoole,

Swoole 是 PHP 的异步、并行、高性能网络通信引擎,使用纯C语言编写,提供了PHP语言的异步多线程服务器,异步TCP/UDP网络客户端,异步MySQL,异步Redis,数据库连接池,AsyncTask,消息队列,毫秒定时器,异步文件读写,异步DNS查询!
其中的异步MySQL,其原理是通过 MYSQLI_ASYNC 模式查询,然后获取 mysql 连接的 socket,加入到 epoll 时间循环中,当数据库返回结果时会回调指定函数。
这个过程是完全异步非阻塞的,不浪费CPU,具体实现方式这里不再详细介绍!

php –with-mysqli=mysqlnd –with-pdo-mysql=mysqlnd

1、什么是mysqlnd驱动?

PHP手册上的描述:

MySQL Native Driver is a replacement for the MySQL Client Library (libmysql).
MySQL Native Driver is part of the official PHP sources as of PHP 5.3.0

mysqldnd即MySQL Native Driver简写,即是由PHP源码提供的mysql驱动连接代码,它的目的是代替旧的libmysql驱动。

传统的安装php的方式中,我们在编译PHP时,一般需要指定以下几项:

--with-mysql=/usr/local/mysql
--with-mysqli=/usr/local/mysql
--with-pdo-mysql=/usr/local/mysql

这实际上就是使用了MySQL官方自带的libmysql驱动,这是比较老的驱动,PHP5.3开始已经不建议使用它了,而建议使用mysqlnd

2、PDO与mysqlnd, libmysql又是何种关系?

PDO是一个应用层抽象类,底层和mysql server连接交互需要mysql驱动的支持。也就是说无论你使用了何种驱动,都可以使用PDO.
PDO提供了PHP应用程序层API接口,而mysqlnd,libmysql则负责与mysql server进行网络协议交互(它并不提供php应用程序层API功能)

3、为何要使用mysqlnd驱动?

PHP官方手册描述:

A.libmysql驱动是由mysql AB公司(现在是oracle公司)编写, 并按mysql license许可协议发布,所以在PHP中默认是被禁用的.
而mysqlnd是由php官方开发的驱动,以php license许可协议发布,故就规避了许可协议和版权的问题

B.因为mysqlnd内置于PHP源代码,故你在编译安装php时就不需要预先安装mysql server也可以提供mysql client API (mysql_connect, pdo , mysqli), 这将减化一些工作量.

C. mysqlnd是专门为php优化编写的驱动,它使用了PHP本身的特性,在内存管理,性能上比libmysql更有优势. php官方的测试是:libmysql将每条记录在内存中保存了两份,而mysqlnd只保存了一份

D. 一些新的或增强的功能
增强的持久连接
引入特有的函数mysqli_fetch_all()
引入一些性能统计函数mysqli_get_cache_stats(), mysqli_get_client_stats(),
mysqli_get_connection_stats(),
使用上述函数,可很容易分析mysql查询的性能瓶颈!
SSL支持(从php 5.3.3开始有效)
压缩协议支持
命名管道支持(php 5.4.0开始有效)

4、看到这里,你可能跃跃欲试,很想使用mysqlnd驱动

提示: 如果使用mysqlnd,并不需要预先安装mysql
编译php时,修改以下几个项参数即可
[code lang="shell"]--with-mysql=mysqlnd
--with-mysqli=mysqlnd
--with-pdo-mysql=mysqlnd [/code]

PHP也支持多线程:cURL并发请求

PHP cURL所有函数列表:
https://secure.php.net/manual/zh/ref.curl.php

以下是PHP中cURL多线程相关函数:

  • curl_multi_add_handle — 向curl批处理会话中添加单独的curl句柄
  • curl_multi_close — 关闭一组cURL句柄
  • curl_multi_exec — 运行当前 cURL 句柄的子连接
  • curl_multi_getcontent — 如果设置了CURLOPT_RETURNTRANSFER,则返回获取的输出的文本流
  • curl_multi_info_read — 获取当前解析的cURL的相关传输信息
  • curl_multi_init — 返回一个新cURL批处理句柄
  • curl_multi_remove_handle — 移除curl批处理句柄资源中的某个句柄资源
  • curl_multi_select — 等待所有cURL批处理中的活动连接
  • curl_multi_setopt — 为 cURL 并行处理设置一个选项
  • curl_multi_strerror — Return string describing error code

一般来说,想到要用这些函数时,目的显然应该是要同时请求多个URL,而不是一个一个依次请求,否则不如自己循环去调curl_exec好了。

步骤总结如下:

1、调用 curl_multi_init,初始化一个批处理handle
2、循环调用 curl_multi_add_handle,往1中的批处理handle 添加curl_init来的子handle
3、持续调用 curl_multi_exec,直到所有子handle执行完毕。
4、根据需要循环调用 curl_multi_getcontent 获取结果
5、调用 curl_multi_remove_handle,并为每个字handle调用curl_close
6、调用 curl_multi_close

以下是一个通过并发请求抓取网页的demo

$urls = array(
    'https://979137.com/',
    'http://www.sina.com.cn/',
    'http://www.sohu.com/',
    'http://www.163.com/'
);

//1、初始化一个批处理handle
$mh = curl_multi_init();

//2、往批处理handle 添加curl_init来的子handle
foreach ($urls as $i => $url) {
    $conn[$i] = curl_init($url);
    curl_setopt($conn[$i], CURLOPT_HEADER, 0);   
    curl_setopt($conn[$i], CURLOPT_CONNECTTIMEOUT, 60);
    curl_setopt($conn[$i], CURLOPT_RETURNTRANSFER, true);
    curl_multi_add_handle($mh, $conn[$i]);
}

//3、并发执行,直到全部结束。
do {
    curl_multi_exec($mh, $active);
} while ($active);

//4、获取结果
foreach ($urls as $i => $url) {
    $data = curl_multi_getcontent($conn[$i]);
    echo ($data);
}

//5、移除子handle,并close子handle
foreach ($urls as $i => $url) {
    curl_multi_remove_handle($mh,$conn[$i]);
    curl_close($conn[$i]);
}

//6、关闭批处理handle
curl_multi_close($mh);

隐藏字符串中部分字符的PHP函数,如:姓名、用户名、身份证、IP、手机号等

<?php
/**
 * 将一个字符串部分字符用$re替代隐藏
 * @param string    $string   待处理的字符串
 * @param int       $start    规定在字符串的何处开始,
 *                            正数 - 在字符串的指定位置开始
 *                            负数 - 在从字符串结尾的指定位置开始
 *                            0 - 在字符串中的第一个字符处开始
 * @param int       $length   可选。规定要隐藏的字符串长度。默认是直到字符串的结尾。
 *                            正数 - 从 start 参数所在的位置隐藏
 *                            负数 - 从字符串末端隐藏
 * @param string    $re       替代符
 * @return string   处理后的字符串
 */
function hidestr($string, $start = 0, $length = 0, $re = '*') {
    if (empty($string)) return false;
    $strarr = array();
    $mb_strlen = mb_strlen($string);
    while ($mb_strlen) {
        $strarr[] = mb_substr($string, 0, 1, 'utf8');
        $string = mb_substr($string, 1, $mb_strlen, 'utf8');
        $mb_strlen = mb_strlen($string);
    }
    $strlen = count($strarr);
    $begin  = $start >= 0 ? $start : ($strlen - abs($start));
    $end    = $last   = $strlen - 1;
    if ($length > 0) {
        $end  = $begin + $length - 1;
    } elseif ($length < 0) {
        $end -= abs($length);
    }
    for ($i=$begin; $i<=$end; $i++) {
        $strarr[$i] = $re;
    }
    if ($begin >= $end || $begin >= $last || $end > $last) return false;
    return implode('', $strarr);
}

测试:
//隐藏手机号中间4位
hidestr('18600005940', 3, 4); //186****5940

//只保留姓名里的最后一个字,常见与ATM,网银等
hidestr('云厉', 0, -1); //*厉

//隐藏邮箱部分内容,常见网站帐号,如支付宝等
list($name, $domain) = explode('@', '979137@qq.com');
hidestr($name, 1, -1) . '@' . hidestr($domain, 0, 2); // 9****7@**.com

此函数用法和PHP系统函数函数substr原理和用法是一样的。只不过substr是用于截取你想要的字符串,而hidestr是隐藏你想要的字符串,用*号代替

如何保存一个网页至桌面上成为快捷方式,PHP生成桌面快捷方式

PHP生成桌面快捷方式,最核心就是通过header函数设置头部信息!

<?php
header("Content-Type: application/octet-stream; charset=utf8");
header("Content-Disposition: attachment; filename=云厉的博客.url");
$shortcut = array(
    '[InternetShortcut]',
    //链接地址
    'URL=979137.com',
    'IDList=',
    //ICON文件地址,必须是HTTP绝对地址
    'IconFile=(http://cdn.979137.com/favicon.ico)',
    'IconIndex=1',
    //注册表值
    '[{000214A0-0000-0000-C000-000000000046}]',
    'Prop3=19,2',
);
echo implode(PHP_EOL, $shortcut);

新浪云SAE支持直接安装 ThinkPHP3.2 啦

ThinkPHP3.2 核心内置了对SAE平台的支持(采用了应用模式的方式),
具有自己的独创特性,能够最大程度的使用ThinkPHP的标准特性,让开发人员感受不到SAE和普通环境的差别。

了解更多:http://document.thinkphp.cn/manual_3_2/sae.html

1、在应用仓库直接安装:

http://sae.sina.com.cn/?m=apps&a=detail&aid=166

2、点击右上角安装框架

输入自己的喜欢域名主体(创建后可以在后台绑定自己的域名)

目前 SAE 最新版 ThinkPHP 是 3.2.3,
并且使用的是ThinkPHP官方提供的远程安装包,代码和官方保持同步

不依赖扩展,PHP生成二维码类

PHP QR Code 是一个PHP二维码生成类库,利用它可以轻松生成二维码,注:请确保你的PHP支持GD2
项目地址:http://phpqrcode.sourceforge.net

  • 该类库原生不支持生成固定大小的二维码图片的(默认是根据二维码数据内容的长度而变化大小)
  • 该类库不支持生成带有icon的二维码

我对该类库进行了修改,同时封装了一个QRcode类,简化操作的同时,实现了上述两个功能:

<?php
/**
 * 二维码服务.
 *
 * @author 979137@qq.com
 * @copyright ©2015, Sina App Engine.
 * @version $Id$
 */
class SaeQRcode {
    private $errMsg = 'success';
    private $errNum = 0;

    //二维码配置参数
    public $data    = '';
    public $level   = 'M';
    public $width   = 200;
    public $height  = 200;
    public $margin  = 0;
    public $icon    = '';
    public $saveUrl = '';

    //生成的二维码文件
    private $code   = '';

    /**
     * 生成二维码图片 
     * 
     * @desc
     * 
     * @access public
     * @return void
     * @exception none
     */
    public function build() {
        static $qrcode = false;
        if (!$qrcode) {
            include(__DIR__.'/phpqrcode.php');
            $qrcode = true;
        }
        if (trim($this->data) == '') {
            $this->errNum = -1;
            $this->errMsg = 'data cannot be empty!';
            return false;
        } elseif (!in_array($this->level, array('L','M','Q','H'))) {
            $this->errNum = -2;
            $this->errMsg = 'level optional values: L, M, Q, H';
            return false;
        } elseif (!is_numeric($this->width) || !is_numeric($this->height)) {
            $this->errNum = -3;
            $this->errMsg = 'width and height parameter error';
            return false;
        }
        $this->code = $this->$saveUrl . md5((microtime(true)*10000).uniqid(time())) . '.png';
        try {
            defined('QRCODE_IMG_W') or define('QRCODE_IMG_W', $this->width);
            defined('QRCODE_IMG_H') or define('QRCODE_IMG_H', $this->height);
            QRcode::png($this->data, $this->code, $this->level, 3, $this->margin);
        } catch(Exception $e) {
            $this->errNum = -4;
            $this->errMsg = $e->getMessage();
            return false;
        }
        if (trim($this->icon) != '') {
            return $this->iconCover() ? $this->code : false;
        }
        return $this->code;
    }

    /**
     * icon覆盖
     * 
     * @desc
     * 
     * @access public
     * @return boolean
     * @exception none
     */
    public function iconCover() {
        if (!is_file($this->code) || $this->fileType($this->code) != 'png') {
            $this->errNum = -10;
            $this->errMsg = 'QRcode file does not exist or file type is not supported(Only allow PNG)';
            return false;
        }
        //远程icon,先下载到本地
        if (filter_var($this->icon, FILTER_VALIDATE_URL)) {
            //TODO..
        }
        if (!is_file($this->icon) || !in_array($this->fileType($this->icon), array('png','jpg','gif'))) {
            $this->errNum = -11;
            $this->errMsg = 'icon file does not exist or file type is not supported(Only allow PNG,JPG,GIF)';
            return false;
        }
        $codeData = file_get_contents($this->code);
        $iconData = file_get_contents($this->icon);
        $code = imagecreatefromstring($codeData);
        $icon = imagecreatefromstring($iconData);
        list($code_w, $code_h) = array(imagesx($code), imagesy($code));
        list($icon_w, $icon_h) = array(imagesx($icon), imagesy($icon));
        //目标宽高(等比例缩小)
        $icon_code_w = $code_w / 5;
        $scale = $icon_w / $icon_code_w;
        $icon_code_h = $icon_h / $scale;
        //目标XY坐标(将icon置于二维码正中间)
        $dst_x = ($code_w - $icon_code_w) / 2;
        $dst_y = ($code_h - $icon_code_h) / 2;
        imagecopyresampled($code, $icon, $dst_x, $dst_y, 0, 0, $icon_code_w, $icon_code_h, $icon_w, $icon_h);
        return imagepng($code, $this->code);
    }

    /**
     * 取二进制文件头快速准确判断文件类型
     * 
     * @desc
     * 
     * @access public
     * @params $file 要判断的文件,支持相对和绝对路径
     * @return void
     * @exception none
     */
    public function fileType($file) {
        $filepath = realpath($file);
        $filetype = array(
            7790=>'exe', 7784=>'midi',
            8075=>'zip', 8297=>'rar',
            7173=>'gif', 6677=>'bmp', 13780=>'png', 255216=>'jpg'
        );
        if (!($fp = @fopen($filepath, 'rb'))) return false;
        $bin = fread($fp, 2);
        fclose($fp);
        $str_info = @unpack('C2chars', $bin);
        $str_code = intval($str_info['chars1'].$str_info['chars2']);
        return isset($filetype[$str_code]) ? $filetype[$str_code] : false;
    }

    /**
     * 获取错误信息 
     * 
     * @desc
     * 
     * @access public
     * @return string
     * @exception none
     */
    public function errmsg() {
        $ret = $this->errMsg;
        $this->errMsg = 'Success';
        return $ret;
    }

    /**
     * 获取错误码 
     * 
     * @desc
     * 
     * @access public
     * @return int
     * @exception none
     */
    public function errno() {
        $ret = $this->errNum;
        $this->errNum = 0;
        return $ret;
    }
}

调用示例:

<?php
//二维码名片,格式参考:http://en.wikipedia.org/wiki/VCard
$vCard  = 'BEGIN:VCARD'.PHP_EOL;
$vCard .= 'VERSION:4.0'.PHP_EOL;
$vCard .= 'FN:倒流'.PHP_EOL;
$vCard .= 'ORG:SINA Inc'.PHP_EOL;
$vCard .= 'TITLE:攻城师'.PHP_EOL;
$vCard .= 'TEL;WORK;VOICE:(010)62676155'.PHP_EOL;
$vCard .= 'TEL;HOME;VOICE:(010)88889999'.PHP_EOL;
$vCard .= 'TEL;TYPE=cell:18600005940'.PHP_EOL;
$vCard .= 'ADR;TYPE=work;LABEL="Office":理想国际大厦17层;北四环西路58号;海淀区;北京市;中国;100089'.PHP_EOL;
$vCard .= 'EMAIL:979137@qq.com'.PHP_EOL;
$vCard .= 'END:VCARD';
//注:不同的扫描工具解码方式不一样,所以不是所有的二维码扫描工具都能唤起相关的功能
$types  = array(
    'vCard'   => $vCard,
    'url'     => 'http://sae.sina.com.cn',
    'tel'     => 'tel:18600005940',
    'smsto'   => 'smsto:18600005940:晚上继续嗨皮',
    'mailto'  => 'mailto:979137@qq.com?subject='.urlencode('恭喜发财').'&body='.urlencode('红包拿来'),
    'skype'   => 'skype:'.urlencode('Skype用户名').'?call',
    'chinese' => '中文二维码内容',
);

$qr = new QRcode();
//设置二维码生成参数
//二维码内容数据
$qr->data   = $types['vCard'];
//校正级别(容错率):L(7%)、M(15%)、Q(25%)、H(30%),了解:http://baike.baidu.com/view/4144600.htm
$qr->level  = 'L';
//二维码宽高(包含间距),为保证二维码更易识别,请尽量保持二维码为正方形,即长宽大致相等,默认200*200
$qr->width  = 300;
$qr->height = 300;
//二维码图片边缘间距值,值越大,间距越宽,可自由调整,默认0
$qr->margin = 1;
//在二维码正中间放置icon,默认为空,即不放置,支持绝对与相对地址
$qr->icon   = __DIR__ . '/logo.png';
$qr->icon   = 'logo.png';
//图片保存路径
$qr->saveUrl = SAE_TMP_PATH;
//生成二维码图片,成功返回文件绝对地址,失败返回false
$file = $qr->build();
if (!$file) {
    var_dump($qr->errno(), $qr->errmsg());
    exit;
}

//直接输出图片
//header('Content-Type: image/png');
//exit(file_get_contents($file));

//根据实际需求,可上传至Storage(这里以SAE为例)
$name = 'test/'.pathinfo($file, PATHINFO_BASENAME);
$domain = 'public';
$st = new SaeStorage();
$st->upload($domain, $name, $file);
$url = sprintf('http://%s-%s.stor.sinaapp.com/%s', $_SERVER['HTTP_APPNAME'], $domain, $name);
echo '<img src="'.$url.'">';

云厉技术博客

PHP数组转XML函数:array2xml

<?php
 * 数组转XML函数
 * @author 979137@qq.com
 * @param array $arr 要转换的数组 
 * @param string $item 默认节点名称
 * @param object $xml XML节点对象
 * @return string XML格式的字符串
 */
function array2xml($arr, $item='item', $xml=NULL) {
    is_null($xml) && $xml = new \SimpleXMLElement('<xml></xml>');
    foreach ($arr as $key=>$val) {
        is_numeric($key) && $key = $item;
        if (is_array($val) || is_object($val)) {
            $child = $xml->addChild($key);
            array2xml($val, $item, $child);
        } elseif (is_numeric($val)) {
            $child = $xml->addChild($key, $val);
        } else {
            $child = $xml->addChild($key);
            $node = dom_import_simplexml($child);
            $_val = $node->ownerDocument->createCDATASection($val);
            $node->appendChild($_val);
        }
    }
    return $xml->asXML();
}

在新浪云SAE上实现PHP锁机制,简单解决并发问题

很多时候需要让系统的某些操作串行化,如抢购(先到先得)、报名等,这个时候就要对这些操作来加上一把锁。(好比上厕所,需要挨个来,待你用完之后把门打开,别人再进去,保证厕所永远只有1个人, 上厕所的过程是串行化的)

也许通过文件(加锁创建一个文件/解锁再删掉,或直接使用flock函数) 或者 MySQL结合InnoDB引擎 去实现,方法也比较多。但个人感觉都不是很优秀,尤其在新浪云SAE这种不支持IO的PaaS上,文件锁的实现方式是不能用的。

在SAE,其实有非常多的方法、很简单的实现这个需求,这里写个利用 Counter 服务模拟锁的实现方法:

<?php

class Lock {

    const LKEY = 'GOLD_LOCK';

    static private $ct = NULL;

    //初始化操作,取得计数器实例,如果计数器不存在则创建
    static public function init() {
        is_null(self::$ct) && self::$ct = new SaeCounter();
        self::$ct->exists(self::LKEY) || self::$ct->create(self::LKEY);
    }   

    //加锁
    static public function add() {
        self::init();
        return self::$ct->set(self::LKEY, 1); 
    }   

    //开锁
    static public function del() {
        self::init();
        return self::$ct->set(self::LKEY, 0); 
    }   

    //获取锁状态
    static public function get() {
        self::init();
        return self::$ct->get(self::LKEY) > 0;
    }   
}

//1、判断是否已加锁
if (Lock::get()) {
    /*
     * 这里可以直接exit
     * 也可以while写个死循环结合sleep,实现类似小米抢购的那种等待状态,在服务器内部进行排队抢购
     * ....
     * ....
     */   
} 

//2、加锁
Lock::add();

/*
 * 这里就是你业务逻辑代码
 * ....
 * ....
 */

//3、开锁
Lock::del();

Counter是SAE为开发者提供的计数器服务,用来实现高并发情景下的计数功能。用户可以在控制面板或程序中创建计数器,通过API对计数器进行设置,加减和统计。
Counter简化了计数应用的开发.开发者可以轻松实现高并发情景下的计数功能,实现如兔年春晚投票,广告渠道访问计数等应用,同时可以使用Counter的统计功能轻松实现数据汇总。

你可以通过SAE的KVDB、Memcache 等服务,轻松实现串行,当然还有专门为队列而生的 TaskQueue,这里就不一一介绍了

Return Top