那些年,我们踩过的PHP的坑

4,493 阅读11分钟
原文链接: zhuanlan.zhihu.com
某女:你能让这个论坛的人都吵起来,我就跟你吃饭。
PHP程序员:PHP是世界上最好的语言!
某论坛炸锅了,各种吵架……
某女:服了你了,我们走吧!
PHP程序员:今天不行,我一定要说服他们,PHP必须是最好的语言。

有人用的语言,就有人骂,骂声越大,用的人也就越多。世上没有完美的语言,最合适的语言就是最好的语言,我们要做的,就是扬长避短,少踩那些坑,下面直接进入主题。

0x01, 弱类型

==和===异同这种太过低级的坑就直接跳过了,先看一个稍微隐蔽点的坑

function translate($keyword)
{
    $trMap = [ 
        'baidu' => '百度',
        'sougou' => '搜狗',
        '360' => '360',
        'google' => '谷歌'
    ];  
    foreach ($trMap as $key => $value) {
        if (strpos($keyword, $key) !== false) {
            return $value;
        }
    }   
    return '其他';
}

echo translate("baidu") . "\n";
echo translate("360") . "\n";

期待的结果是

百度
360

实际运行结果是

百度
其他

仔细检查,没有string和int的混用,比较也都是用的 !== ,没有用==,为什么还会掉坑里?

问题出在了array上面,虽然你写的是

    $trMap = [ 
        'baidu' => '百度',
        'sougou' => '搜狗',
        '360' => '360',
        'google' => '谷歌'
    ];  

但是PHP给你处理成了

array(4) {
  ["baidu"]=>
  string(6) "百度"
  ["sougou"]=>
  string(6) "搜狗"
  [360]=>
  string(3) "360"
  ["google"]=>
  string(6) "谷歌"
}

360变成了int类型,这个时候strpos不该报错吗?不,当然是原谅它啦,它选择兼容int

If needle is not a string, it is converted to an integer and applied as the ordinal value of a character.

360的hex表示是0x168,所以当你这样调用时,它能匹配

translate("\x1\x68")

那么正确的写法是怎么样的呢?稍加改动即可

strpos($keyword, $key) //改为 strpos($keyword, (string) $key)

可怕之处在于

  • 自以为用了===就安全了,忽视了弱类型无处不在这个隐患
  • 你可能并没有仔细看每一个函数的说明,没有逐个核对每个参数的类型
  • 引发的bug不一定能重现,也有可能平时不会触发,但是留下了安全漏洞

如何100%的避免弱类型的坑?答案是换强类型语言。如果不能换呢?通过以下准则,虽然做不到100%避免,但是做到99.99%是有希望的。

  1. 能用===/!==的地方,绝不用==/!=,知道类型的情况下,先强转再用===比较
  2. 调用函数的时候,如果你知道参数类型,在调用时强制转换一下,不能嫌麻烦

我说的是弱类型,不是动态类型,两者不是一码事,不要误会。Python是动态类型强类型,PHP是动态类型弱类型,C语言是静态类型弱类型。如果可以选择,我宁可PHP放弃弱类型,因为弱类型带来的麻烦,已经超出它的便利了。提供一个strict运行模式也行,给足大家十年八年时间慢慢迁移。

0x02, 空字典json序列化成了[]

随着APP的流行,PHP很多时候不是跟浏览器端的JS交互,而是跟Java和ObjC这样的静态类型语言交互,返回值的类型定义,就很重要了,举个栗子

$ret1 = [ 
    'choices' => ['鱼香肉丝', '宫保鸡丁'],
    'answers' => [
        '张三' => 0,
        '李四' => 1,
        '赵云' => 0,
    ],  
];

$ret2 = [ 
    'choices' => [], 
    'answers' => [], 
];

echo json_encode($ret1) . "\n";
echo json_encode($ret2) . "\n";

输出

{"choices":["\u9c7c\u9999\u8089\u4e1d","\u5bab\u4fdd\u9e21\u4e01"],"answers":{"\u5f20\u4e09":0,"\u674e\u56db":1,"\u8d75\u4e91":0}}
{"choices":[],"answers":[]}

客户端在定义这个model的时候,可能是这样定义的

class ResultDTO {
	lateinit var choices: List<String>
	lateinit var answers: Map<String, Int>
}

当返回ret1的时候,一切顺风顺水,皆大欢喜。如果返回ret2呢,客户端抗议了

com.fasterxml.jackson.databind.JsonMappingException: Can not deserialize instance of java.util.LinkedHashMap out of START_ARRAY token

原因是什么呢?PHP的json_encode面对一个空的array的时候,它很为难,它不知道应该当它是list还是map,所以它只能一刀切,认为它就是list,于是客户端就不高兴了。解决办法不是没有,依然是强制转换。

$ret2 = [
    'choices' => [],
    'answers' => (object) [],
];

但是这样就带来一个问题,如果answers不是写死的,而是某个API的返回值,你并不确定它是不是会返回空的,它也没有义务帮你cast成object,因为JSON序列化是跟前端交互的事情,不应该放到后端service层面解决。那么你只能自己动手了,手动把返回值中可能出现空map的地方,全部强制转换一遍。

PHP的关联数组的确很强大,算法设计的也不错,性能也很好,但是它不是没有代价的,上面的栗子算是其中一个。如果PHP也像其它语言一样,区分map和list,可能会省事一些,毕竟区分{}和[],对程序员来说并不会增加很多学习成本。

0x03, 健忘的FPM

近些年,Swoole和WorkerMan那样的CLI部署方式,慢慢被中国人知悉和应用,然而跟FPM或者mod_php相比,CLI方式还是太过非主流,在绝对垄断的FPM/mod_php面前,CLI在缓慢成长中。FPM的缺点很明显,每个请求结束的时候,你在PHP代码里创建的对象都被清理了,你执行过的代码,就跟没执行过一样,不留痕迹。

在hello world那样的微型应用中,好像问题不大,稍微大一点的项目,我们为了DRY,为了少做重复劳动,为了提高开发效率,不得不使用框架,然后问题就来了,用PHP写的PHP框架,由于FPM的健忘,框架从init开始,到读取配置文件,到初始化各个组件,这种工作在每个请求到来的时候,都要重复的做一次,如果你需要读一个100M的元数据,那么每个HTTP请求来时,你都要读一次并解析一次,当你HTTP请求结束返回时,你解析过的100M元数据,又被销毁了,下一个请求来时,你依然要重复做。

本来PHP 5.6已经可以吊打Python 3.6的性能了,PHP 7.1都不屑于跟Python比性能了,快几倍了。但是一旦引入同体量的框架,比如PHP用Laravel,Python用Django,剧情就反转了,Django竟然可以吊打PHP7加持的Laravel了。一个百米运动员就算跑的再快,每次枪响后都要先穿鞋带,穿好鞋带再穿鞋,然后再跑,跑完了把鞋脱下,再把鞋带抽出。就算它100米只要1秒就能跑完,光穿鞋的时间就够别的选手跑个来回了。

所以包括PHP之父本人在内,都对Laravel这样的封装深的厚框架表示质疑,在需要考虑性能的时候,主流人士往往推荐不用框架,或者用极简的框架,要么就是那些C写的框架,比如yaf和phalcon。框架性能问题算是曲线解决了,那么用户自己的逻辑呢?这个就比较麻烦了。分情况探讨,简单类型,如string,可以用yaconf这个扩展,可以做到不重复读取。如果是复杂的数据结构,比如树状结构,就没法用这种方式解决了。有没有解决办法呢?也不是没有,你可以写个脚本,把数据转换成PHP代码,然后通过opcache缓存起来,也能缓解一下问题。要彻底解决,只能写个C扩展让它常驻内存了,但这就超出一般PHP开发的能力范围了。

FPM这种方式并非PHP首创,在fastcgi出现之前,CGI都是这么干的,而且还是每个请求新开一个进程,比FPM还要开销大。然而到了21世纪,还在用FPM这种健忘型运行模式的,常见语言里就只剩PHP了。可能再过十年,FPM也渐渐被Swoole这样的不健忘的给取代了。

0x04,多线程支持

这里不讨论Apache的MPM是否支持多线程,也不讨论PHP的扩展是否支持多线程,更不讨论PHP到底能不能利用多线程或者多核,这里只讨论纯粹的PHP代码,能否创建和管理线程。前几年,PHP是完全不支持多线程,现在呢?据说有了pthreads,然后打开它的文档,发现

WarningThe pthreads extension cannot be used in a web server environment. Threading in PHP should therefore remain to CLI-based applications only.

WarningThe pthreads extension can only be used with PHP 7.2+. This is due to ZTS mode being unsafe in prior PHP versions.

两个限制

  1. 只能用在CLI下面
  2. 只支持PHP 7.2+

没用过多线程的人,自然不能体会多线程的便捷之处,跟多进程相比,数据共享在进程内部要容易的多。现代语言支持多线程是很自然的事情,跟PHP对比最多的Python,早就有了原生线程的支持,虽然因为GIL做不了CPU密集型应用,但是做个IO密集型还是很方便的。多线程只是锦上添花,不是雪中送炭,好在PHP的多进程支持还算OK,咱们就用多进程好了,最多共享数据结构的时候,想办法绕开便是。线程池 + 执行队列,变成进程池 + 执行队列。

0x05, 32bit平台下,没有8字节的long类型

PHP的int是平台相关的,32位平台下是4字节,64位平台下是8字节,为了代码的健壮性和可移植性,我们只能假定int就是4字节的类型。但是我们很多时候需要8字节类型,因为

  1. 精确到毫秒的时间戳需要long
  2. 很多平台对接需要long,比如阿里巴巴

这个时候就需要GMP和BCMath这样的库了,比起语言直接支持8字节的long,麻烦了一些。

0x06, 数组函数设计的太差,使用不便

PHP提供了一大堆array_xxxx函数,而没有把这些函数作为数组的方法,这种设计,乍看之下倒也没什么问题,但是有三个函数,在这种设计之下,实用性大打折扣。这三个函数是

array array_map ( callable $callback , array $array1 [, array $... ] )
mixed array_reduce ( array $array , callable $callback [, mixed $initial = NULL ] )
array array_filter ( array $array [, callable $callback [, int $flag = 0 ]] )

举个栗子,把一个数组中的数求平方,并且把平方后大于100的数相加,用普通的写法是

$arr = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15];

function foo_a($num_arr) {
    $sum = 0;

    foreach ($num_arr as $n) {
        $v = $n * $n;
        if ($v > 100) {
            $sum += $v;
        }
    }

    return $sum;
}
echo foo_a($arr) . "\n";

如果是简单的加减乘除,这种写法倒也OK,如果是比较复杂的逻辑,每一步的操作都会提出出来封装成相应的函数。我们来试试函数式写法,

function foo_b($num_arr) {
    return array_sum(
        array_filter(
            array_map(function ($v) { return $v * $v; }, $num_arr), function($v){
                return $v > 100;
            })
    );
}

echo foo_b($arr) . "\n";

看起来可读性比较差,也比较丑陋,这都拜PHP数组函数设计不合理所赐。假如可以这么写

function foo_c($num_arr) {
    return $num_arr.map(function ($v) { return $v * $v;})
        .filter(function ($v) {return $v > 100;})
        .sum()
}

可读性和实用性是不是提高了很多?只要把map/filter/reduce这3个定义成数组的方法,并且返回数组,就可以这么写了,能不能再简洁一点呢?我们继续

function foo_c($num_arr) {
    return $num_arr.map ($v -> $v * $v)
        .filter($v -> $v > 100)
        .sum()
}

有人可能要说我抄袭了,这不就是Java 8的lambda了嘛,对的,这就是Java 8的lambda。Java 8那点儿语法糖就吃饱了吗?显然不够,我们还可以再进一步简化下去。

function foo_c($num_arr) {
    return $num_arr.map {$it * $it}
        .filter {$it > 100}
        .sum()
}

给只接受一个参数的lambda一个默认的参数,名字叫it,是不是又简洁了一些?还可以再继续吗?当然可以

function foo_c($num_arr) = $num_arr.map {$it * $it}
        .filter {$it > 100}
        .sum()

看到这里,可能已经有人看出来这是哪门语言的语法了,对的,就是它。

顺便拿PHP最喜欢比较的Python对比一下,感受一下list comprehension的魅力

sum([y for y in [x * x for x in num_arr] if y > 100])

不懂Python的人是这么写Python的,求平方写成这样,哈哈

list(map(lambda x: x * x, num_arr))

0x07, 函数命名风格太过不一致

PHP有nl2br这样的简写,还有htmlspecialchars_decode这样的长名字,据说当年PHP早期版本,用函数名字的长度作为hash,名字长度分布的均匀有助于减少hash冲突。听起来像是黑子们拿来喷PHP的,或者像PHP粉出来钓鱼的。但是看了这个

Re: Flexible function naming 我震惊了,PHP之父如是说

On 12/16/2013 07:30 PM, Rowan Collins wrote:

> The core functions which follow neither rule include C-style
> abbreviations like "strptime" which couldn't be automatically swapped to
> either format, and complete anomalies like "nl2br". If you named those
> functions as part of a consistent style, you would probably also follow
> stronger naming conventions than Rasmus did when he named
> "htmlspecialchars".

Well, there were other factors in play there. htmlspecialchars was a
very early function. Back when PHP had less than 100 functions and the
function hashing mechanism was strlen(). In order to get a nice hash
distribution of function names across the various function name lengths
names were picked specifically to make them fit into a specific length
bucket. This was circa late 1994 when PHP was a tool just for my own
personal use and I wasn't too worried about not being able to remember
the few function names.

-Rasmus

竟然是真的,太惊人了。据说后来到了PHP3的时候,替换掉了这个设计。而PHP在命名一致化的路上也一直在努力,但是考虑到兼容性,彻底解决可能还需要很多年的努力。

0x08, magic_quotes...

自动给你把GPC(GET/POST/COOKIE)变量中的特殊字符转义掉,幸好PHP 5.4已经删除这个特性了,不过有的比较传统的框架还保留着这个功能。我就想问问,你知道我要怎么用这些值吗?你知道哪些字符在我这边算特殊字符?自作主张一刀切,跟怕染HIV挥刀自宫的思路是一致的。再举个跟这个算是同类的栗子,配置对运行时行为影响过多过于复杂。

@fopen('http://example.com/not-existing-file', 'r');

很简单的一行代码,然而,它的行为却依赖诸多环境配置

如果PHP使用 --disable-url-fopen-wrapper编译, 它將不工作. (文档没有说, "不工作"是什么意思; 返回 null, 抛出异常?)
注意这点已在 PHP 5.2.5 中移除.
如果 allow_url_fopen 在 php.ini 中禁用, 也將不工作. (为什么? 无从得知.)
由于 @ , non-existent file 的警告將不打印.
但如果在php.ini中设置了scream.enabled, 它又將打印.
或者如果用 ini_set 手动设置 scream.enabled.
但, 如果 error_reporting 级别没设置, 又不同.
如果打印出来了, 精确去向依赖于 display_errors , 再一次还是在 php.ini. 或者 ini_set中.

最好的语言,隐藏了最多的黑魔法。要避开这个坑,只能尽量保证所有环境下面,编译参数一致,配置参数一致。

0x09, Error和Exception完全不同的机制

PHP 错误 (内部, 称为 trigger_error)不能被 try/catch 捕获。
同样, 异常不能通过 set_error_handler 安装的错误处理器触发错误。
作为替代, 有一个单独的 set_exception_handler 可以处理未捕获的异常。
Fatal 错误 (例如, new ClassDoesntExist()) 不能被任何东西捕获,大量的完全无害的操作会抛出 fatal 错误, 由 于一些有争议的原因被迫终结你的程序。

以上,一般框架层面会帮你解决,应用层面不需要操太多心。

0x0A, 更多的坑

eev.ee/blog/2012/0… 老外的吐槽,不过它的版本比较低,有些问题已经解决了,英文不好的看译文,五大受损, 全面解析PHP的糟糕设计 - 开源中国社区

实际上我提到的第8个和第9个坑,也在上面的文章中有提到,我就复制了来,其它的我觉得不深的坑,我倒觉得无所谓,没那么严重。

诚然,PHP的坑再多,只要用的人水平够高,也是可以写出完全正确的代码来的,然而我们大部分人都是普通人,坑的存在,或多或少都是负面影响。很多工作了5年以上的PHPer,也还会不留神掉到这些坑里。