升级PHP8,博客出现“有点尴尬诶!该页无法显示”

最近迁移服务器,将 PHP 升级到 8.0.10 之后,且关闭 PHP warning 报错,博客首页、文章页显示“有点尴尬诶!该页无法显示”。

一开始以为数据库导入有问题,文章没有导入。进入后台查看,文章虽然都还在,但查看文章具体页面,均是“有点尴尬诶!该页无法显示”。

恰好点开评论,遇到函数报错,无法打开评论管理界面,猜测是受 PHP 8。

回退到 PHP 7.4.23 之后,文章正常显示。

PHP Bcrypt 更安全的密码加密机制

为了避免在服务器受到攻击,数据库被拖库时,用户的明文密码不被泄露,一般会对密码进行单向不可逆加密——哈希

常见的方式是:

哈希方式 加密密码
md5(‘123456’) e10adc3949ba59abbe56e057f20f883e
md5(‘123456’ . ($salt = ‘salt’)) 207acd61a3c1bd506d7e9a4535359f8a
sha1(‘123456’) 40位密文
hash(‘sha256’, ‘123456’) 64位密文
hash(‘sha512’, ‘123456’) 128位密文

密文越长,在相同机器上,进行撞库消耗的时间越长,相对越安全。

比较常见的哈希方式是 md5 + 盐,避免用户设置简单密码,被轻松破解。

password_hash

但是,现在要推荐的是 password_hash() 函数,可以轻松对密码实现加盐加密,而且几乎不能破解。

$password = '123456';

var_dump(password_hash($password, PASSWORD_DEFAULT));
var_dump(password_hash($password, PASSWORD_DEFAULT));

password_hash 生成的哈希长度是 PASSWORD_BCRYPT —— 60位,PASSWORD_DEFAULT —— 60位 ~ 255位。PASSWORD_DEFAULT 取值跟 php 版本有关系,会等于其他值,但不影响使用。

每一次 password_hash 运行结果都不一样,因此需要使用 password_verify 函数进行验证。

$password = '123456';

$hash = password_hash($password, PASSWORD_DEFAULT);
var_dump(password_verify($password, $hash));

password_hash 会把计算 hash 的所有参数都存储在 hash 结果中,可以使用 password_get_info 获取相关信息。

$password = '123456';
$hash = password_hash($password, PASSWORD_DEFAULT);
var_dump(password_get_info($hash));
输出
array(3) {
  ["algo"]=>
  int(1)
  ["algoName"]=>
  string(6) "bcrypt"
  ["options"]=>
  array(1) {
    ["cost"]=>
    int(10)
  }
}
注意不包含 salt

可以看出我当前版本的 PHP 使用 PASSWORD_DEFAULT 实际是使用 PASSWORD_BCRYPT。

password_hash($password, $algo, $options) 的第三个参数 $options 支持设置至少 22 位的 salt。但仍然强烈推荐使用 PHP 默认生成的 salt,不要主动设置 salt。

当要更新加密算法和加密选项时,可以通过 password_needs_rehash 判断是否需要重新加密,下面的代码是一段官方示例

$options = array('cost' => 11);
// Verify stored hash against plain-text password
if (password_verify($password, $hash))
{
    // Check if a newer hashing algorithm is available
    // or the cost has changed
    if (password_needs_rehash($hash, PASSWORD_DEFAULT, $options))
    {
        // If so, create a new hash, and replace the old one
        $newHash = password_hash($password, PASSWORD_DEFAULT, $options);
    }
    // Log user in
}

password_needs_rehash 可以理解为比较 $algo + $option 和 password_get_info($hash) 返回值。

password_hash 运算慢

password_hash 是出了名的运行慢,也就意味着在相同时间内,密码重试次数少,泄露风险降低。

$password = '123456';
var_dump(microtime(true));
var_dump(password_hash($password, PASSWORD_DEFAULT));
var_dump(microtime(true));

echo "\n";

var_dump(microtime(true));
var_dump(md5($password));
for ($i = 0; $i < 999; $i++)
{
    md5($password);
}
var_dump(microtime(true));
输出
float(1495594920.7034)
string(60) "$2y$10$9ZLvgzqmiZPEkYiIUchT6eUJqebekOAjFQO8/jW/Q6DMrmWNn0PDm"
float(1495594920.7818)

float(1495594920.7818)
string(32) "e10adc3949ba59abbe56e057f20f883e"
float(1495594920.7823)

password_hash 运行一次耗时 784 毫秒, md5 运行 1000 次耗时 5 毫秒。这是一个非常粗略的比较,跟运行机器有关,但也可以看出 password_hash 运行确实非常慢。

通过 Socket 获取网站 SSL 证书及公钥

通过 php curl 请求网页并不能获取到证书信息,此时需要使用 ssl socket 获取证书内容。

// 创建 stream context
$context = stream_context_create([
    'ssl' => [
        'capture_peer_cert' => true,
        'capture_peer_cert_chain' => true,
    ],
]);

$resource = stream_socket_client("ssl://$domain:$port", $errno, $errstr, 30, STREAM_CLIENT_CONNECT, $context);
$cert = stream_context_get_params($resource);

$ssl = $cert['options']['ssl'];
$resource = $ssl['peer_certificate'];

// 网站证书中只有公钥,通过 openssl_pkey_get_details 导出公钥

$ret = [
    'crt' => '',
    'pub' => '',
];

$pkey = openssl_pkey_get_public($resource);
$ret['pub'] = openssl_pkey_get_details($pkey)['key'];

openssl_x509_export($resource, $pem);
$ret['crt'] = $pem;

foreach ($ssl['peer_certificate_chain'] as $resource)
{
    openssl_x509_export($resource, $pem);
    $ret['crt'] .= "\n" . $pem;
}

// 保存 $ret['crt'] 为 domain.crt
// 保存 $ret['pub'] 为 domain.pub

return $ret;

验证证书中的公钥A是否正确,通过私钥导出公钥B,比较两者发现一致。

$domain = 'blog.zhengxianjun.com';
$port = '443';
// ...
$pub_a = $ret['pub'];

$private_key_path = '/conf/ssl/blog.zhengxianjun.com.key';

// 证书没有设置密码,$passphrase 为空字符串
$pkey = openssl_pkey_get_private(file_get_content($private_key_path), $passphrase = '');
$pub_b = openssl_pkey_get_details($pkey)['key'];

// 两者一致
var_dump($pub_a === $pub_b);

函数 stream_socket_client 还有一个用途是当知道服务器 IP 时,能获取到服务器可能可以使用的域名。

$resource = stream_socket_client("ssl://$ip:$port", $errno, $errstr, 30, STREAM_CLIENT_CONNECT, $context);
$cert = stream_context_get_params($resource);

// 解析 X.509 格式证书
$info = openssl_x509_parse($cert['options']['ssl']['peer_certificate']);

// 获取证书中的可信域名列表
$domain = str_replace('DNS:', '', $info['extensions']['subjectAltName']);

以上可以看到获取网站证书并不能获得私钥。

在一些使用 CDN 的站点,如果使用了 HTTPS 同时又希望使用自有域名,是否需要将自己的私钥提供给 CDN 厂商呢?实际上证书路径与使用者名称(支持 https 的域名)并不需要一致。

也就是使用自有域名并进行 CDN 加速时不需要使用自有的 ssl 证书,只需将自己的 CDN 域名加到厂商证书的域名列表即可。

PHP self 和 static 区别

PHP self 指向定义的 class。

PHP static 指向运行的 class,一般只有子类覆盖父类的 static 成员或者方法时,在父类中使用 static 会访问到子类。

class ParentClass
{
    public static function hello()
    {
        echo "ParentClass: hello\n";
    }

    public static function run()
    {
        self::hello();
        static::hello();
    }
}

class ChildClass extends ParentClass
{
    public static function hello()
    {
        echo "ChildClass: hello\n";
    }
}

ParentClass::run();

// 输出
"ParentClass: hello"
"ParentClass: hello"

ChildClass::run();

// 输出
"ParentClass: hello"
"ChildClass: hello"

在Linux上进行PHP安装configure错误小结

PHP 安装,从官网下载源码压缩包,进行 configure 遇到几个错误:

configure 命令

./configure –prefix=/usr/local/php –with-config-file-path=/usr/local/php/etc –with-bz2 –with-curl –enable-ftp –enable-sockets –disable-ipv6 –with-gd –with-jpeg-dir=/usr/local –with-png-dir=/usr/local –with-freetype-dir=/usr/local –enable-gd-native-ttf –with-iconv-dir=/usr/local –enable-mbstring –enable-calendar –with-gettext –with-libxml-dir=/usr/local –with-zlib –with-pdo-mysql=mysqlnd –with-mysqli=mysqlnd –with-mysql=mysqlnd –enable-dom –enable-xml –with-libdir=lib64 –enable-pdo –enable-fpm

BZip2 错误

configure: error: Please reinstall the BZip2 distribution

解决方案

yum install bzip2
yum install bzip2-devel

bzip2 可能已经安装过,bzip2-devel 没有安装

libcurl 错误

configure: error: Please reinstall the libcurl distribution –
easy.h should be in <curl-dir>/include/curl/

解决方案

yum install curl-devel

GD 库错误

configure: error: jpeglib.h not found

解决方案

yum install libjpeg
yum -y install libjpeg-devel

libjpeg 可能已经安装过,libjpeg-devel 没有安装

同步目录到七牛CDN

基于七牛SDK实现目录上传和同步

使用官方提供的PHP SDK实现,重新使用PHP实现目录同步,而不是使用官方提供的Windows 程序,主要是因为 qrsbox.exe 会同步目录下的所有文件,包括 .svn 文件和一些项目配置文件,如 .project

七牛的PHP SDK下载地址:http://developer.qiniu.com/code/v7/sdk/php.html

本次实现的源码有几个功能:

  1. 同步普通文件,不同步英文点号开始的文件,比如 .svn、.project
  2. 将同步日志直接存放在同步的目录下,跟随 svn 进行管理
  3. 多机使用 svn 管理目录,在多机上进行同步,不会将整个项目重新同步,qrsbox.exe 将同步日志存放在 C 盘的用户目录,每在一台机器 checkout 项目,进行同步时都会完整地同步一次
  4. 通过 bat 脚本调用 php 代码,认证信息和 bucket 信息配置在源码中,每个 bat 脚本对应各自的 bucket,不再像 qrsbox.exe 切换 bucket 需要重新配置
  5. 增量同步,基于同步日志实现

PHP脚本每次执行都会比较目录下的所有文件,以此判断是否需要同步。

同名不同内容文件上传时会提示文件已存在,先删除旧文件,再提交新文件。

没有实现的功能:

  1. 目录监控,实时上传(比较少遇到实时将开发环境代码更新到生产环境)
  2. 断点续传(CDN较多存放小文件)
  3. 没有实现同步文件删除(同 qrsbox.exe)

PHP 魔术变量

所有魔术变量表示的值均区分大小写

__CLASS__

表示代码所在类的名字

// 文件 define.php
namespace Base
{
    abstract class Fruit
    {
        public function __construct()
        {
            // 输出 Base\Fruit
            echo __CLASS__;
            echo "\n";
            // 输出 Sub\Apple
            echo get_class($this);
            echo "\n";
        }

        public function hello()
        {
            // 输出 Base\Fruit
            echo __CLASS__;
            echo "\n";
        }
    }
}

namespace Sub
{
    class Apple extends \Base\Fruit
    {
        public function say()
        {
            // 输出 Sub\Apple
            echo __CLASS__;
            echo "\n";
        }
    }
}

// 文件 test.php
require __DIR__ . '/define.php';

$Apple = new Sub\Apple();
$Apple->hello();
$Apple->say();

__DIR__

表示代码所在文件所在的完整目录名,等价于 dirname(__FILE__),不包括末尾的斜杠 /

// 文件 test.php
// 输出 test.php 所在目录 /opt/wwwroot/test
echo __DIR__;
echo "\n";

__FUNCTION__

表示代码所在函数的名称

function foo()
{
    // 输出函数名 foo
    echo __FUNCTION__;
    echo "\n";
}
foo();

__FILE__

表示代码所在文件的完整路径名

// 文件 test.php
// 输出 test.php 所在目录 /opt/wwwroot/test/test.php
echo __FILE__;

__LINE__

表示代码所在文件中的行号

// 文件 test.php
// 输出代码行号
echo __LINE__;

__METHOD__

表示代码所在函数的名称

// 文件 define.php
namespace Base;

function foo()
{
    // 输出函数名 Base\foo
    echo __FUNCTION__;
    echo "\n";
    // 输出函数名 Base\foo
    echo __METHOD__;
    echo "\n";
}

class Foo
{
    public function bar()
    {
        // 输出函数名 bar
        echo __FUNCTION__;
        echo "\n";
        // 输出方法名 Base\Foo::bar
        echo __METHOD__;
        echo "\n";
    }

    public static function staticBar()
    {
        // 输出函数名 staticBar
        echo __FUNCTION__;
        echo "\n";
        // 输出方法名 Base\Foo::staticBar
        echo __METHOD__;
        echo "\n";
    }
}

// 文件 test.php
require __DIR__ . '/define.php';

Base\foo();

$foo = new Base\Foo();
$foo->bar();
$foo->staticBar();

__TRAIT__

表示代码所在 trait 的名称,包含完整限定的 namespace

// 文件 define.php
namespace Base
{
    trait Wheel
    {
        public function roll()
        {
            // 输出 Base\Wheel
            echo __TRAIT__;
            echo "\n";
            // 输出 Sub\Car
            echo __CLASS__;
            echo "\n";
        }
    }
}

namespace Sub
{
    class Car
    {
        use \Base\Wheel;

        public function start()
        {
            $this->roll();
            // 输出空字符串
            echo __TRAIT__;
            echo "\n";
            // 输出 Sub\Car
            echo __CLASS__;
            echo "\n";
        }
    }
}

// 文件 test.php
require __DIR__ . '/define.php';

$car = new Sub\Car();
$car->start();

__NAMESPACE__

表示当前代码所在命名空间

namespace Vendor\Util;

// 输出命名空间 Vendor\Util
echo __NAMESPACE__;
echo "\n";

function foo()
{
    // 输出命名空间 Vendor\Util
    echo __NAMESPACE__;
    echo "\n";
}

foo();

PHP 超全局变量

PHP 超全局变量(Superglobal)是自动化的全局变量,在任何作用域都可以使用,不需要使用 global 关键字声明 $variable。

变量名 说明
$GLOBALS 引用了全部全局变量的数组,如 $GLOBALS[‘_GET’]、$GLOBALS[‘_POST’] 等等,以及一个对自身 GLOBALS 的引用。
$_COOKIE PHP 解析浏览器请求携带的 cookie 生成的数组
$_ENV $_ENV 包含当前的环境变量,与 phpinfo() 输出的 Environment 表格一致,但默认为空,通过 getenv() 可以获取到指定的环境变量
$_FILES $_FILES 包含当前 HTTP POST 上传的文件列表。
$_GET $_GET 包含 URL 参数列表
$_POST $_POST 包含 POST 参数列表
$_REQUEST 可能包含 $_GET、$_POST、$_COOKIE,不包含 $_FILES。 request_order 控制包含的参数列表。
$_SERVER 包含服务器和脚本执行环境信息,比如 HTTP 请求头信息、脚本信息,不同服务器信息会有不同。
$_SESSION 包含当前 SESSION 变量列表。需要先调用 session_start() 开启会话。
$argc 以命令行(CLI)形式运行 PHP 脚本,传递给脚本的参数总数。需要开启 register_argc_argv
$argv 以命令行(CLI)形式运行 PHP 脚本,传递给脚本的参数数组。需要开启 register_argc_argv
$http_response_header 保存当前作用域下最新 HTTP 请求的的响应头信息
$php_errormsg 开启 track_errors 时,PHP 执行的最新错误信息

$GLOBALS

$name = 'Seven';
function hello() {
	$name = 'Eight';
	echo "local name is $name"; // Eight
	echo "\n",
	echo "global name is "
		. $GLOBALS['name']; // Seven
}

php.ini 中的 register_globals(在 PHP 5.4.0 中被移除)表示将 globals 变量中的成员自动注册为全局变量。

// 设置 register_globals = on;
$_GET['name'] = 'Seven';
$name === $_GET['name']; // true

// 设置 register_globals = off;
// 不会将 global 变量中的成员自动注册为全局变量

$_COOKIE

$_COOKIE 包含当前请求的 cookie 列表,在请求中使用 setcookie 再调用 $_COOKIE 不会立刻获得相同的值。

// 设置 cookie
setcookie('name', 'Seven');
// 获取 cookie
$_COOKIE['name'] // 不能立刻获得设置的 cookie
// 因为 $_COOKIE 存放的是当前浏览器发送到服务器的 cookie
// 其次, setcookie 不一定设置 cookie 成功

$_ENV

默认的 $_ENV 数组为空,是因为 variables_order可能没有包含 E —— _ENV。

CEGPS 分别对应 $_COOKIE、$_ENV、$_GET、$_POST、$_SERVER,variables_order 的默认值没有包含 E,因此不会设置超全局变量 $_ENV。variables_order 为空则不会设置任何超全局变量。

$_FILES

$_FILES 包含的是文件列表,是一个二维数组,一般结合 move_uploaded_file() 将上传的文件存放到需要的位置,类似结构如下:

array(
	// myfile 是 input 表单的 name 属性
	'myfile' => array(
		'name' => '', // 文件的原名称
		'type' => '', // 文件的 MIME 类型
		'size' => '', // 文件的字节大小
		'tmp_name' => '', // 存放的包含路径的临时文件名
		'error' => '', // 上传的错误代码
	);
);
// 如果 php 对 upload_tmp_dir 没有写入权限,
// 则上传的文件无法存放在文件系统上

$_GET

$_POST

$_POST 只解析类似 URL 参数列表结构的 POST BODY 中的数据,比如将 name=Seven 解析为 $_POST[‘name’] = Seven,调用 AJAX XMLHttpRequest 的 send() 方法传递的字符串一般类似 name=Seven&age=20。
$_POST 不能解析文件流,也不能解析 send() 的 JSON 字符串,需要使用 @file_get_contents(‘php://input’) 获取 POST 的内容,再进行解析——比如 json_decode 获得 JSON,base64_decode 获得二进制流。

$_REQUEST

// request_order = 'GP'
// 表示 $_REQUEST 包含 $_GET、$_POST 内容
// 同名 key 后者覆盖前者

// request_order = 'GPC'
// 表示 $_REQUEST 包含 $_GET、$_POST、$_COOKIE 内容

$_SERVER

$_SERVER['HTTP_HOST'] // HTTP 请求的主机
$_SERVER['HTTP_USER_AGENT'] // HTTP 请求 User-Agent
$_SERVER['HTTP_COOKIE'] // HTTP 请求 COOKIE
$_SERVER['HTTP_ACCEPT_LANGUAGE'] // 可以接受的语言列表
// 与 HTTP 请求相关的信息大部分以 HTTP_ 开始

get_defined_functions

返回一个二维数组,包含定义的函数名列表:区分 internal、user。

// get_defined_functions 返回值
array (
	'internal' => array (
		'zend_version',
		'func_num_args',
		'func_get_arg',
		// ...
	),
	'user' => array (
		'get_client_ip',
		'get_client_device',
		'get_client_browser',
	),
);

get_defined_constants

返回定义的常量数组,常量名作为 key,常量值作为 value。

get_defined_vars

返回定义的变量数组。

extract

将数组的成员导入到当前作用域。

$a = array(
	'b' => 'b',
);
extract($a);
echo $b; // 输出字符串 b
// 如果数组的成员与当前变量名重名会发生什么?

phpDocumentor 使用介绍

开发 PHP 不像开发 Java 有较强的文档注释规范,通过注释可以便捷地生成接口文档。

我在寻找 PHP 接口文档工具时使用过 phpDoc,页面效果不怎么好。辗转使用了 phpDocumentor,页面效果可以接受。

phpDocumentor output

使用 phpDocumentor 步骤(仅在 Windows 系统验证通过):

首先安装 php5

下载 phpDocumentor.phar 文件

访问百度盘 http://pan.baidu.com/s/1bnErGXh 下载 phar 文件。

在 PHP5 目录(php.ini 所在的目录)下创建 phar 目录,并把 phpDocumentor.phar 移动到该目录。

进入 PHP5 目录,打开 cmd 命令窗口,执行 phpDocumentor 命令

php phar\phpDocumentor.phar -d D:\ran\framework -t D:\zhengxianjun_cdn\www\ran-api

-d 参数表示源代码目录

-t 参数表示生成的接口文档存放目录

命令执行完成之后会在 D:\zhengxianjun_cdn\www\ran-api\reports\errors.html 记录下源代码中接口描述不规范的点,逐条修改即可。

phpDocument 生成的接口文档的样式中模式会使用 Google 的字体,在国内自然打不开,导致网页打开很慢。找到 D:\zhengxianjun_cdn\www\ran-api\css\template.css,删除第一行的 @import url(https://fonts.googleapis.com/css?family=Source+Sans+Pro);。因为每次生成接口文档都会覆盖 template.css,另一个一劳永逸的办法是在 hosts 文件中加上 127.0.0.1 fonts.googleapis.com

phpDocument 生成的接口文档适配手机网页,但查看不是很便捷。

使用 phpDocument,就需要按照它的规范来抒写代码注释。

phpDocument 代码注释规范:http://www.phpdoc.org/docs/latest/index.html

支持 28 个标签,有些标签没有完全实现,但实际常用的标签就 10 个左右。

在注释文档中支持部分 html 标签,例如

phpDocument source code

对应的展示效果如下

phpDocumentor 效果

想要编写复杂的代码示例仍然不是很容易,但通过注释生成接口文档基本已满足我的需求。

想要细致了解 phpDocument.phar 文件的用法,可以通过 php phar\phpDocumentor.phar –help 命令获得帮助。

示例站点

PHP Ran Framework 的接口文档便是通过 phpDocumentor 创建的 http://ran-api.qiniudn.com

PHP 图片压缩、图片缩放

使用 PHP 进行图片压缩,首先通过 imagecreatefrom* 系列函数创建源图像 resource 对象,再通过 imagecreate 或者 imagecreatetruecolor 函数创建确定宽度和高度的目标图像对象,接着进行一系列处理,最后通过 imagecopy* 系列函数把源图像对象拷贝到目标图像对象。完成上述处理之后,调用 imagegif、imagejpeg、imagepng 把目标图像对象转换为图像字符串输出到文件或者浏览器。似乎没有直接把图像对象转换为字符串的函数?可以通过 ob_start 捕获字符串输出,并调用 ob_get_clean 取得字符串。

imagecreatefrom* 系列函数

imagecreatefromgd 从 GD 文件或 URL 创建图像对象
imagecreatefromgd2 从 GD2 文件或 URL 创建图像对象
imagecreatefromgif 从 gif 文件创建图像对象
imagecreatefromjpeg 从 jpeg/jpg 文件创建图像对象
imagecreatefrompng 从 png 文件创建图像对象
imagecreatefromwbmp 从 wbmp 文件创建图像对象
imagecreatefromwebp 从 webp 文件创建图像对象
imagecreatefromxbm 从 xbm 文件创建图像对象
imagecreatefromxpm 从 xpm 文件创建图像对象
上述所有函数全部接收一个参数 — $filename, 失败时均会返回 false
imagecreatefromstring 从字符串创建图像对象
接收一个参数 — 图像二进制字符串

imagecopy* 系列函数

imagecopy 拷贝图像的一部分
imagecopymerge 拷贝并合并图像的一部分
imagecopymergegray 用灰度拷贝并合并图像的一部分
imagecopyresampled 重采样拷贝部分图像并调整大小
imagecopyresized 拷贝部分图像并调整大小

图片压缩实现代码

$image = 'D:\TEMP\woxinfeishi-bukezhuanye.jpg';
$source = imagecreatefromjpeg($image);

$s_width = imagesx($source);
$s_height = imagesy($source);

$max_width = 200;
$max_height = 200;

if ($s_width > $max_width
	|| $s_height > $max_height)
{
	$s_rate = $s_width / $s_height;
	$t_rate = $max_width / $max_height;

	if ($s_rate > $t_rate)
	{
		$t_width = $max_width;
		$t_height = floor($t_width / $s_rate);
	}
	else
	{
		$t_height = $max_height;
		$t_width = floor($t_height * $s_rate);
	}

	$target = imagecreatetruecolor($t_width,
		$t_height);
	imagecopyresampled($target, $source, 0, 0, 0, 0,
		$t_width, $t_height,
		$s_width, $s_height);
}
else
{
	$target = $source;
}

ob_start();
imagejpeg($target);
$image_data = ob_get_clean(); // 图像数据
imagedestroy($source);
imagedestroy($target);

上述代码实现图片等比压缩,如果是缩放到固定高宽则只需要直接设置为最终高宽。