请时刻记住 PHP 变量的类型

我们都知道 PHP 变量类型是弱类型,这样很方便,当你需要一个数字时,变量就表现得像个 Int,当你需要字符串时,变量就表现得像个 String。但方便的同时,如果不注意也会带来坑,并且这种情况带来的 bug 还很不好排查。今天我就遇到了一个。

游戏新注册用户的时候需要生成一个 uid,一般来说,在数据量小且低并发的时候直接使用 MySQL 的自增 ID 就可以生成得到唯一的 uid,但如果数据量大且并发很高,使用 MySQL 的自增 ID 就不能满足需求了。我们使用了一个叫 ukg 的东西,该服务的功能就一个,每次调用它,就给你返回一个唯一的数字,嗯,很好,刚好能满足我们生成 uid 的需求。

代码写完后,测试的过程中发现了一个 bug,设备第一次登录的时候会登录失败,第二次及以后就登录成功。排查了近 1 个小时才找到问题所在,设备第一次登录的时候,我们访问 ukg 得到一个数字,当做玩家的 uid,并且存入 MySQL,后来发现我们从 ukg 返回得到的实际上是一个 数字+空格+回车 组成的一个字符串,而空格和回车都是不可见字符串,很容易就忽略了,存入 MySQL 时,MySQL 进行了转换将空格和回车去掉然后正确存进了数据库。这样就能解释为什么第一次登录失败,第二次及以后的请求就没问题了。

定位 bug 后,解决就很容易了,从 ukg 取到数据后,将其强制转换为 Int 就 OK 了。

虽然,PHP 的语法并不要求一个变量强制其数据类型,但我们在写代码的过程中,还是需要时刻注意变量的数据类型。常见的错误还有 0 == false 相等导致的逻辑出错。如果不嫌麻烦的话,所有 == 的地方都改成 === 就不会出现这类型的 bug 了。

PHP 使用 Redis 来做队列服务

为客户端开发 Api,采用 PHP,我们使用了一个叫做 Swoole_framework 的框架,同时使用了 swoole 扩展。在开发接口时使用了框架提供的模型类,但是,服务端除了接口,不可避免还有一些后台脚本,比如每日任务初始化,用户分数结算,后台脚本直接 require 相关配置文件,然后手动 update 数据库数据。这两天做需求的时候就发现,有些业务逻辑在服务端 api 层需要实现,在后台脚本里面也要实现,而这两处的代码不能复用,因此同样地逻辑处理了两遍,随着后续功能的增加,重复的代码还会增加。上周因为比较急就先采用这种方法完成了代码,实现了功能,这两天测试联调,总感觉同样地逻辑在多个地方出现,不仅写代码时麻烦,测试时也麻烦,下午灵光一现想到可以使用任务队列来完成这个工作。

简单来说,就是在原先逻辑处理的地方,只是简单增加一个任务,不再进行具体的业务逻辑,后台脚本和 api 都如此,然后再单独跑一个脚本,拉取任务,进行业务逻辑处理。这个的思路好处很明显,同样地逻辑归到了一处,开发以及调试找错时会清晰很多。

需要实现一个任务队列,使用 redis ,简单封装了一个队列:

<?php

class Queue
{
    protected $redis;
    protected $key;

    public function __construct(\Redis $redis, $key)
    {
        $this->redis = $redis;
        $this->key = $key;
    }

    public function pop()
    {
        return $this->redis->lPop($this->key); // 左边出
    }

    public function push($task)
    {
        return $this->redis->rPush($this->key, $task); // 右边入
    }
}

队列的一个特点就是先进先出(FIFO),很显然,先产生的任务需要被先处理,redis 的 List 可以保证这一点。

晚上将代码重新组织,用任务队列加单独脚本的方式实现了需要的业务功能,顿时感觉浑身舒畅了许多。

nginx php-fpm 捕获记录 php 的错误日志

请确认 php-fpm.conf 的如下配置为:

catch_workers_output = yes # 捕获进程的输出,这个值默认为 no
error_log = log/php-fpm.log # 这个是默认值,指定 php-fpm 输出 log 的文件路径

确认 php.ini 的如下配置为:

log_errors = On # 开启记录错误日志,这个值默认为 Off
error_reporting=E_ALL&~E_NOTICE # 默认就是这个值,php 的 error_reporting 级别

参考网址:http://www.nginx.cn/666.html

腾讯 PHP 笔试题,写一个 is_writable 函数的替代函数

今年三月份去腾讯面试过。笔试题有一题是,PHP 中的 is_writable 函数不可信,请写一个函数来替代这个函数。

拿到这个题目,我便想起之前读 CI 框架实现的时候看到 CI 里面写了个 is_really_writable 函数,当时没有注意,只看这个函数的功能,至于 CI 为什么要自己实现一个 is_writable 函数的功能没有去深究。当时,我的解题思路是这样的,既然 is_writable 不可信,那么我就实实在在的尝试去那个文件夹里进行写入,如果可以写则返回 true,否则返回 false,思路是对的,但代码并不是完全无误,有瑕疵。

回到家后我便开始搜索,同时重新仔细查看 CI 的那个函数实现以及说明,原来是因为在 Windows 服务器上如果一个文件夹有只读属性,is_writable 这个函数还是会返回 true,而实际上 php 是不能进行写入的,另外一种情况是,在 unix 服务器上,如果 safe_mode 选项打开,那么 is_writable 函数也是不可信的。

这个事情提醒我,当遇到一个问题时,一定要追查到底,搞明白它的原理以及那样做的理由。

附上 CI 里面 is_really_writable 函数的实现:

function is_really_writable($file)
{
    // If we're on a Unix server with safe_mode off we call is_writable
    if (DIRECTORY_SEPARATOR == '/' AND @ini_get("safe_mode") == false) {
        return is_writable($file);
    }

    // For windows servers and safe_mode "on" installations we'll actually
    // write a file then read it.  Bah...
    if (is_dir($file)) {
        $file = rtrim($file, '/') . '/' . md5(mt_rand(1, 100) . mt_rand(1, 100));

        if (($fp = @fopen($file, FOPEN_WRITE_CREATE)) === false) {
            return false;
        }

        fclose($fp);
        @chmod($file, DIR_WRITE_MODE);
        @unlink($file);
        return true;
    } elseif (!is_file($file) OR ($fp = @fopen($file, FOPEN_WRITE_CREATE)) === false) {
        return false;
    }

    fclose($fp);
    return true;
}

 

珍爱生命,拥抱 PHPUnit

在公司负责服务端 Api 开发,为安卓客户端提供相应的接口,在开发的过程中,免不了要测试完成的接口是否可用。最开始的测试手段是使用 POSTMAN 等工具发送请求。使用这类工具的流程一般是,先构造 url 请求接口进行登录,登录后拿到服务器返回的一个标识(可以看做是一个 session,但这个 session 的概念和传统的网站开发中的 session 概念不一样),在后续的接口调用中,都需要在请求 header 头里面加上那个标识。这个流程会比较繁琐,至少需要多次鼠标点击以及复制粘贴的过程才能完成。偶尔这样做还好,这种事情做多了便让我觉得烦躁无比。

了解了下 PHPUnit,先读了读官网的 manual,大体明白怎么写测试了就开始动手。

具体到我们的业务,首先写了一个 Client 类,这个类主要作用是模仿客户端发一个请求,当然这个类会进行一些简单的封装,比如 curl 请求的时候带上我们的标识(上文说的 session),还有其它的模拟安卓客户端的一些请求头。刚开始我在每个测试方法中都 new 一个 Client 对象,后来想想不对,既然是模仿客户端,而一个客户端永远是一个实例,每次 new 一个出来显然不符合现实逻辑,另外一个问题是,测试登录接口后,session 标识需要在后续的接口测试中反复使用。想到这,于是将 Client 类改为了单例实现,这样就能保证在多个测试方法的 Client 为同一个实例对象,并能共享 session 标识状态。

下面是 Client 的实现及一个简单的测试用例文件示例:

class Client
{
    private function __construct()
    {
    }

    private static $instance;

    public $session;

    public function getInstance()
    {
        if (is_null(static::$instance)) {
            static::$instance = new static();
        }
        return static::$instance;
    }

    public function curl($url)
    {
        $ch = curl_init();
        if (!is_null($this->session)) {
            // 如果 session 不为空,则在 curl 请求时加上保存 session 信息的 header 头
        }
        // 使用 curl 发送请求
        $response = curl_exec($ch);
        return $response;
    }
}

class Test extends PHPUnit_Framework_TestCase
{
    public function testLogin()
    {
        $client = Client::getInstance();
        $response = $client->curl($login_url);
        // 下面就可以进行一些 assert 啦

        // 登录成功后设置 session 为服务器返回的 session
        $client->session = $session_get_from_response;
    }

    /**
     * @depends testLogin
     */
    public function testGetUserInfo()
    {
        $client = Client::getInstance();
        $response = $client->curl($get_user_info_url);
        // 接下来的代码进行后续处理
    }
}

要测试新的接口,只需要增加新的方法就好了,多次测试多次运行单元测试命令就 OK 了,可以节约大量时间啊。

另外,我发现在单元测试类里面的不同方法好像是单独运行的,比如下面的这个代码:

class Test2 extends PHPUnit_Framework_TestCase
{
    public function testOne()
    {
        $GLOBALS['count'] += 1;
        echo $GLOBALS['count'];
    }

    public function testTwo()
    {
        $GLOBALS['count'] += 2;
        echo $GLOBALS['count'];
    }
}

输出结果为 1 2 ,并不是想象中的 1 3,这是为什么呢?

Ubuntu Server 14.04 编译安装 PHP

每次编译安装 PHP 过程中出现了一些错误,都是直接谷歌相应的解决方案。刚才又在 Linode 上编译了一遍最新的 PHP,记录过程如下,以做备忘。

首先安装 build-essential 包:

apt-get install build-essential

进入 php 源码目录,我的 configure 命令选项如下:

./configure --with-bz2 --with-curl --with-jpeg-dir --with-gd --enable-shared --enable-mbstring --with-mcrypt --with-mysql=mysqlnd --with-pdo-mysql=mysqlnd --with-mysqli=mysqlnd --enable-fpm --enable-phar --enable-bcmath --with-zlib --enable-zip --enable-ftp --with-gettext --enable-sockets --with-freetype-dir --with-fpm-user=www-data --with-fpm-group=www-data --with-config-file-path=/usr/local/etc/php/php.ini --with-config-file-scan-dir=/usr/local/etc/php/conf.d

配置过程中,会出现若干错误,提示各种头文件找不到,下面分别给出错误提示和对应的需要安装的包:

#Cannot find libz
apt-get install zlib1g-dev

#Please reinstall the BZip2 distribution
apt-get install libbz2-dev

#Please reinstall the libcurl distribution -
#    easy.h should be in <curl-dir>/include/curl/
apt-get install libcurl4-gnutls-dev

#jpeglib.h not found
apt-get install libjpeg-dev

#png.h not found
apt-get install libpng12-dev

#freetype-config not found
apt-get install libfreetype6-dev

#mcrypt.h not found. Please reinstall libmcrypt
apt-get install libmcrypt-dev

直到没有错误为止。最后:

make && make install

至此编译安装完成。

 

PHP 的 print_r 函数并不是仅能输出数组或对象

大家都知道 PHP 中,print_r 函数能够打印输出数组,有些公司还会这样的笔试题:print 和 print_r 的区别,一般的回答便是:print 输出字符串,print_r 输出数组。前两天 coding 时偶然发现给 print_r 传了字符串,竟然正常输出,没有报 warning 和 notice,我就有点奇怪了,print_r 不是只能输出数组和对象的吗,怎么传个字符串也正常输出了。于是看手册,手册说

print_r() displays information about a variable in a way that’s readable by humans. … If given a stringinteger or float, the value itself will be printed. If given an array, values will be presented in a format that shows keys and elements. Similar notation is used for objects.

并没有任何地方说明 print_r 只能用于输出数组和对象,对于普通的 string integer float 型参数直接输出,相当于 print。

结论?结论就是完全没有理由使用 print 这个函数了。

configure: error: Cannot locate header file libintl.h 错误的解决方法

MAC OS 上编译 PHP 时,在 configure 配置阶段出现如题所示错误。找不到 libintl.h 头文件。

解决方法如下:

1. 安装 gettext:

brew install gettext

2. 编辑 configure 文件:

将:

for i in $PHP_GETTEXT /usr/local /usr ; do

更改为:

for i in $PHP_GETTEXT /usr/local /usr /usr/local/opt/gettext; do

3. 重新运行 ./configure 即可

深入理解 PHP 之 count 函数

曾有一次面试时面试官问我

count('abc')

会返回多少。当时我一下子懵了,不带这样玩的啊,count 函数不是用来计算数组元素个数的么,你传个字符串进去是几个意思啊。然后我满脸疑问并不自信的回答,是 3 吗,因为我觉得 PHP 的字符串也有时候可以表现得像数组,比如你可以用下标来取到字符串中对应位置的字符。面试官说是 1,然后我说没这样用过,一般都是用 count 来计算数组元素个数,面试官说,这就说明你代码量不够了。

写了简单文件,我们来看看给 count 函数传入非数组的参数时会返回什么:

<?php

var_dump(count('abc'));
var_dump(count(''));
var_dump(count(0));
var_dump(count(false));
var_dump(count(null));

$books = array(
	array(
		'name' => 'Pairs',
		'price' => 30,
	),
	array(
		'name' => 'Apple',
		'price' => 20,
	),
);

var_dump(count($books));
var_dump(count($books, COUNT_RECURSIVE));

上面代码输出如下:

int(1)
int(1)
int(1)
int(1)
int(0)
int(2)
int(6)

可以看到 count 在作用于非数组的变量时,除了 null,其它都返回 1,为什么会这样呢,我们来看看 PHP 有关于 count 函数的 C 源代码:

/* {{{ proto int count(mixed var [, int mode])
   Count the number of elements in a variable (usually an array) */
PHP_FUNCTION(count)
{
	zval *array;
	long mode = COUNT_NORMAL;

	if (zend_parse_parameters(ZEND_NUM_ARGS() TSRMLS_CC, "z|l", &array, &mode) == FAILURE) {
		return;
	}

	switch (Z_TYPE_P(array)) {
		case IS_NULL:
			RETURN_LONG(0);
			break;
		case IS_ARRAY:
			RETURN_LONG (php_count_recursive (array, mode TSRMLS_CC));
			break;
		case IS_OBJECT: {
#ifdef HAVE_SPL
			zval *retval;
#endif
			/* first, we check if the handler is defined */
			if (Z_OBJ_HT_P(array)->count_elements) {
				RETVAL_LONG(1);
				if (SUCCESS == Z_OBJ_HT(*array)->count_elements(array, &Z_LVAL_P(return_value) TSRMLS_CC)) {
					return;
				}
			}
#ifdef HAVE_SPL
			/* if not and the object implements Countable we call its count() method */
			if (Z_OBJ_HT_P(array)->get_class_entry && instanceof_function(Z_OBJCE_P(array), spl_ce_Countable TSRMLS_CC)) {
				zend_call_method_with_0_params(&array, NULL, NULL, "count", &retval);
				if (retval) {
					convert_to_long_ex(&retval);
					RETVAL_LONG(Z_LVAL_P(retval));
					zval_ptr_dtor(&retval);
				}
				return;
			}
#endif
		}
		default:
			RETURN_LONG(1);
			break;
	}
}
/* }}} */

源码很明显,如果传入的参数为 null,则返回 0,如果传入的数组,则计算数组元素个数,如果是对象,则按对象进行相应处理(这个过程我不是太明白),重点在于最后的 default,也就是说其它任何类型都返回 1.

完。

PHP 中 array_replace 和 array_merge 区别

PHP 5.3.0 新增了一个函数 array_replace,和 array_merge 作用很相似。

先看下面代码示例:

<?php

$base = array(
	'name' => 'Lily',
	'age' => 20,
	'No index value',
);

$replace = array(
	'name' => 'Lucy',
	'addres' => 'Hubei',
	'No index value in array $replace',
);

print_r(array_merge($base, $replace));
print_r(array_replace($base, $replace));

上面代码输出为:

Array
(
    [name] => Lucy
    [age] => 20
    [0] => No index value
    [addres] => Hubei
    [1] => No index value in array $replace
)
Array
(
    [name] => Lucy
    [age] => 20
    [0] => No index value in array $replace
    [addres] => Hubei
)

很明显,array_replace 和 array_merge 的区别表现在处理数字索引的数据时,array_merge 会认为是不同的索引,不会进行覆盖,而 array_replace 则进行了覆盖。实际上 array_replace 和数组的 + 法运算的处理比较类似,但是参数的顺序得调换一下位置,下面的代码输出一致:

<?php

/* .....  数组定义同上 */

print_r(array_replace($base, $replace));
print_r($replace + $base);