某女: 你能让这个论坛的人都吵起来, 我就跟你吃饭.
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% 是有希望的.
能用 ===/!== 的地方, 绝不用 ==/!=, 知道类型的情况下, 先强转再用 === 比较
调用函数的时候, 如果你知道参数类型, 在调用时强制转换一下, 不能嫌麻烦
我说的是弱类型, 不是动态类型, 两者不是一码事, 不要误会. 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.
两个限制
只能用在 CLI 下面
只支持 PHP 7.2+
没用过多线程的人, 自然不能体会多线程的便捷之处, 跟多进程相比, 数据共享在进程内部要容易的多. 现代语言支持多线程是很自然的事情, 跟 PHP 对比最多的 Python, 早就有了原生线程的支持, 虽然因为 GIL 做不了 CPU 密集型应用, 但是做个 IO 密集型还是很方便的. 多线程只是锦上添花, 不是雪中送炭, 好在 PHP 的多进程支持还算 OK, 咱们就用多进程好了, 最多共享数据结构的时候, 想办法绕开便是. 线程池 + 执行队列, 变成进程池 + 执行队列.
0x05, 32bit 平台下, 没有 8 字节的 long 类型
PHP 的 int 是平台相关的, 32 位平台下是 4 字节, 64 位平台下是 8 字节, 为了代码的健壮性和可移植性, 我们只能假定 int 就是 4 字节的类型. 但是我们很多时候需要 8 字节类型, 因为
精确到毫秒的时间戳需要 long
很多平台对接需要 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 http://news.php.net/php.internals/70691 我震惊了, 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, 更多的坑
https://eev.ee/blog/2012/04/09/php-a-fractal-of-bad-design/ 老外的吐槽, 不过它的版本比较低, 有些问题已经解决了, 英文不好的看译文, 五大受损, 全面解析 PHP 的糟糕设计 - 开源中国社区 http://www.oschina.net/question/1579_49262
实际上我提到的第 8 个和第 9 个坑, 也在上面的文章中有提到, 我就复制了来, 其它的我觉得不深的坑, 我倒觉得无所谓, 没那么严重.
诚然, PHP 的坑再多, 只要用的人水平够高, 也是可以写出完全正确的代码来的, 然而我们大部分人都是普通人, 坑的存在, 或多或少都是负面影响. 很多工作了 5 年以上的 PHPer, 也还会不留神掉到这些坑里.
来源: https://juejin.im/entry/5b1e4ef4e51d4506be2684fa