PHPStorm Xdebug 断点调试 Docker 环境下 PHP教程

最详细的保姆级别的Dcoker环境想配置PHPStorm Xdebug 断点调试教程,图文并茂

为什么

PHP程序报错,肉眼review了多少遍都觉得自己的写得没问题;有个switch代码分支判断,为什么进不了我想要的case; 调试PHP代码还在用 var_dump($re); die();。试试Xdebug断点调试吧。

他能做什么

开启Debug监听,一步步顺着代码走进程序的最深处。你会了解到真实的代码运行步骤,以及调用关系。你还能知道每个变量在程序运行时,值的多少,以及变化。同时避免了 var_dump 代码植入,(如果忘了删除,提交到线上,那这个耻辱柱会狠狠地钉在你身上)

好的来介绍下我们的这期主角 Xdebug

Xdebug 是

Xdebug是一个PHP扩展,提供了调试和性能分析功能。[1]它使用DBGp调试协议。

Xdebug可以提供的调试信息包括以下内容:

  • 错误消息[2]中的堆栈和函数跟踪具有:

  • 用户定义功能的全参数显示

  • 函数名称,文件名和行指示
  • 支持成员功能

  • 内存分配

  • 保护无限递归

Xdebug还提供:

  • PHP脚本的概要分析信息[3]
  • 代码覆盖率分析
  • 调试器前端交互地调试脚本的功能。[4]

以上摘自wiki

开始动手

环境介绍

这篇文章主要围绕Docker环境下PHP的Xdebug调试展开。

先说下笔者演示环境:Mac环境,安装Virtual Box,跑了一个Ubuntu虚拟机,再装了Docker ce软件。看到这,大伙儿先别急着关网页啊,我的环境估计有些奇葩,但大致流程是差不多的。

这套PHP Docker环境这篇文章有详细介绍: [Docker快速搭建一套PHP、Nginx、MySQL、Redis、Xdebug、Memcached 开发环境

安装软件

为 PHP 安装 Xdebug

Xdebug 官方文档已介绍。

  • Linux 或 Mac 环境通过命令行:
$ pecl install xdebug
  • Windows 用户通过 Xdebug Download 页面,根据自己的PHP版本可直接下载 *.dll 放入到拓展目录即可。
$ php -v    // 查看 PHP 版本
  • 手动编译安装

对于网络不那么畅通,pecl不能用时。

## 获取源码
$ git clone https://github.com/xdebug/xdebug.git
## 进入目录
$ cd xdebug
## php检查
$ phpize
## 编译前配置,一般来说 phpize 已经准备好了大部分工作,以及配置
$ ./configure
## 编译
$ make
## 测试并安装
$ make test && make install
  • Docker 环境为PHP安装PHP拓展

Docker 环境下提供了两种方式,第一种是通过pecl在线下载安装,受限于网络状况,大概了下会失败。

那么可以尝试通过其他途径下载好pecl-Xdebug的压缩包,Add到docker容器中安装。

FROM php:7.3-fpm-buster

...

# 方法1 pecl 安装
RUN yes | pecl install xdebug \
    && echo "zend_extension=$(find /usr/local/lib/php/extensions/ -name xdebug.so)" > /usr/local/etc/php/conf.d/xdebug.ini \
    && echo "xdebug.remote_enable=on" >> /usr/local/etc/php/conf.d/xdebug.ini \
    && echo "xdebug.remote_autostart=off" >> /usr/local/etc/php/conf.d/xdebug.ini

## ------------------------------------------------
# 方法2 下载安装 xdebug
ADD ./xdebug-2.6.1.tgz /tmp/php7-xdebug
RUN cd /tmp/php7-xdebug/xdebug-2.6.1/ && phpize && ./configure && \
    make && make install

配置 xdebug

找到我们的配置文件.

## 文件: /etc/php/conf.d/xdebug.ini

zend_extension=xdebug.so            // 启用xdebug拓展!

xdebug.remote_connect_back=1
xdebug.remote_enable = 1            // 启用远程调试
xdebug.remote_mode = "req"
xdebug.remote_handler = "dbgp"
xdebug.remote_host = "10.0.2.2"     // 这是你PHPStorm运行环境的IP地址
xdebug.remote_port = 9000           // 默认9000,最好别动
xdebug.idekey="PHPSTORM"            // PHPStormIDE用的默认值
xdebug.remote_autostart = 1

关于这个 xdebug.remote_host 这个ip,不是想当然就填个 127.0.0.1, 你要看看你PHPStorm所运行的环境,和PHP运行环境是否在一个环境,如果是,你填 127.0.0.1 是没问题。是Docker那么就肯定不在一个环境了,怎么看我们IDE所处环境的IP呢,先编辑下入口文件 index.php, 获取SERVER变量的 REMOTE_ADDR 属性。

## 文件: ~/public/index.php
<?php
print_r($_SERVER['REMOTE_ADDR']);die();

打开浏览器访问下php-fpm服务。这里是多少,xdebug.remote_host 就填多少。

配置 Docker

Docker环境下,要用IDE去debug代码,IDE还需要和Docker打交道。就是在PHPStorm配置好Dcoker的API入口。

这个大家可以参考这片文章 Docker开启Remote API 访问 2375端口 来配置下Docker环境。

转载注明出处

配置 phpstorm

以上准备好了后,就开始我们的重头戏,配置PHPStorm.

1.将Docker Remote API配置在PHPStorm上.

由于我的环境装了Vbox,用了NAT,所以端口是42376,你要自己的环境配置为准。

下面的 Connection Successful 就表示配置成功了。这里的 Name: 名字要记下来,后面要用。

2.新增PHP Cli Interpreter.

搜索: php language,点击最右侧 ··· 按钮

选择 From Dcoker...

这里的 Server: 名字选择 第 1 步 的 Dcoker Server 的名字。 Image name: 选择你的php容器。

点击确认后,PHPStorm会检查 PHP容器的版本,Xdebug拓展是否启用。有如下提示,没有报错,则表示环境无误,可进行下一步。

留意下红框里面的 PHP version:

这里就是配置好之后的展示效果,PHP language level: 最好和 PHP 容器内一致.

3.配置 Deployment

这个配置是用来让IDE知道PHP代码运行环境,以及本地PHP代码之间的目录映射关系。

这里选择 Local or mounted flolder,首先会让你输入 New server name:

我输入的是 bs。你也可以输入别的,但都要记录下来,后面会用到这个名字。

这里Folder:配置PHPStorm运行环境的文件路径。(也就是Mac下文件路径。)

这里我们查看下 PHP 容器内代码路径.

切到 Deployemnt 配置的 Mapping Tab,在 Deploment path: 填上容器内代码路径, Local path: 选择本地环境代码路径。

4.配置PHP > Server, 这里点击圆圈内的 导入 按钮。在弹框中选择刚刚新增的 Deployment 配置。(就是刚刚输入的 New server name

File/Direcotry 你检查下,一般都没问题。Absolute path on the server 是你代码运行的Docker容器内的根目录。调整好之后,点击 OK.

先别急,关闭弹框后,再确认下 Absolute path on the server

这里估计是 PHPStorm 的一个Bug 还是产品的需求,弹框里外都要配置一次才好。

好PHPStorm的配置就完成了。

Docker runtime 配置

别急

Docker 运行时容器内,还需配置两个环境变量 PHP_IDE_CONFIG, XDEBUG_CONFIG.

  1. serverNameDeployment 配置的名字。

  2. remote_host 填PHPStorm环境的IP,也就是PHP中 $_SERVER['REMOTE_ADDR'] 的值。

  3. remote_port 默认 900.

  • 需要对 docker-compose.yaml 配置文件中的 php 容器新增环境变量:
## 文件: ~/docker-compose.yaml

version: "2"
services:
    php:
        image: paulxu/php:5.6-fpm-jessis-pdo-xdebug-mysqli-gd-mb-zip-2
        volumes:
            ...
        ports:
            ...
        environment: 
            PHP_IDE_CONFIG: "serverName=bs"
            XDEBUG_CONFIG: "remote_host=10.0.2.2 remote_port=9000"
  • 如果你用的 docker run 拉起的容器,加上如下参数
$ docker run ... --env PHP_IDE_CONFIG="serverName=dealman" --env PHP_IDE_CONFIG="serverName=bs" ... php /bin/bash

开始Debug

1.启用Xdebug listening,点击以下红色电话。

现在就是开始侦听状态。

2.找到入口文件 ~/index.php,选择第一行代码,打一个断点,(不知道点哪,按Commond+F8)

3.开始访问Web站点。

这里第一个断点是红色箭头处,通过点击绿色按钮 Step over,代码一步步执行到了蓝色箭头处,这行代码也被IDE蓝色高亮标底,此时还能从 Variables 看到 已定义变量 $conf 的值。

结语

以前的大佬还在炫耀用notepad手撕代码,一遍过。在越来越快的开发节奏中,使用高级的工具,以及详尽的仪表盘来开发调试代码。希望大家看了,都能配置成功。

有问题欢迎提问。

参考

vscode用xdebug调试php多进程程序
Mac下基于Docker在PhpStorm中配置Xdebug
使用 Xdebug 在 PHPStorm 中调试 PHP 程序(框架/原生均适用)

Docker快速搭建一套PHP、Nginx、MySQL、Redis、Xdebug、Memcached 开发环境并演进

又来一个新项目,又要配置一套新环境,什么你继续用原来的,不怕互相干扰么。来看看这,一步拉起一套全新的Docker,PHP,Nginx,Redis,MySQL,Memcached开发环境

痛点

如何简单迅速地初始化一个全新的PHP开发环境呢?

笔者在尝试起一个新web项目时,往往会陷入重新建立一套Docker环境的繁琐事当中。我想大家在开始做一个新项目时,或者快速为了和以前的项目做完全的隔离,也会起一套新docker环境。

从这篇文章你会得到

采用docker-compose容器编排技术,一步启动全部服务。php容器在官方的基础上还打包了一些常用拓展(附带build源码,动手能力强可以自己打包),php,nginx,mysql都附带有自定义配置文件(便于开发测试的配置调整),都供下载。

架构

本片文章搭建出来的环境如上图。

环境说明

运行环境

  • Mac OS 10.13.6
  • Virtual Box 5.2.14
  • Ubuntu 16.04.4
  • Docker 17.07.0-ce

容器

  • Nginx 1.12.1-alpine
  • PHP-fpm 5.6
  • MySQL 5.7
  • Redis 3.2
  • Memcached 1.4.27

这里说明一下笔者运行环境,采用了在Mac机上安装vbox虚拟机,在虚拟机里面的debian环境安装了Docker环境,文件放在Mac环境,通过文件挂载方式,达到Mac环境编辑,Docker环境实时更新效果。你要问为啥不在Mac下直接装Docker,历史原因~

运行材料

.
├── build                                   ## docker 镜像build 材料
│   ├── php5.6                              ## php 5.6
│   │   ├── Dockerfile
│   │   ├── imagick-3.4.3.tgz
│   │   ├── memcached-2.2.0.tgz
│   │   ├── redis-4.0.2.tgz
│   │   ├── sources.list
│   │   └── xdebug-XDEBUG_2_5_5.tar.gz
│   └── php-cli                             ## php cli 的自建镜像
│       ├── amqp-1.9.3.tgz
│       ├── Dockerfile
│       ├── Dockerfile-php-swoole
│       ├── memcached-3.0.4.tgz
│       ├── redis-4.1.1.tgz
│       ├── sources.list.jessie
│       ├── swoole-4.0.4.tgz
│       ├── swoole-4.2.6.tgz
│       ├── xdebug-2.6.1.tgz
│       └── yaf-3.0.7.tgz
├── config                                  ## 配置文件
│   ├── apt                                 ## 由于使用的debian系列,所以命名apt
│   │   └── sources.list                    ## 更新为国内源
│   ├── memcached                           ## memchached的配置
│   │   └── memcached.conf
│   ├── mysql                            ## mysql 的配置
│   │   ├── conf.d
│   │   │   └── mysqld_safe_syslog.cnf
│   │   └── my.cnf
│   ├── nginx                            ## nginx 的配置
│   │   ├── conf.d
│   │   │   ├── default
│   │   │   └── xiuno
│   │   ├── mime.types
│   │   ├── nginx.conf
│   │   └── sites-enabled
│   ├── php                            ## php的配置,分cli,和fpm配置
│   │   ├── cli
│   │   │   ├── conf.d
│   │   │   │   ├── 05-opcache.ini
│   │   │   │   ├── 10-pdo.ini
│   │   │   │   ├── 20-curl.ini
│   │   │   │   ├── 20-gd.ini
│   │   │   │   ├── 20-imagick.ini
│   │   │   │   ├── 20-json.ini
│   │   │   │   ├── 20-memcache.ini
│   │   │   │   ├── 20-mysqli.ini
│   │   │   │   ├── 20-mysql.ini
│   │   │   │   ├── 20-pdo_mysql.ini
│   │   │   │   ├── 20-readline.ini
│   │   │   │   ├── 20-redis.ini
│   │   │   │   ├── 20-xdebug.ini
│   │   │   │   └── swoole.ini
│   │   │   └── php.ini
│   │   ├── fpm
│   │   │   ├── conf.d
│   │   │   │   ├── docker.conf
│   │   │   │   ├── www.conf
│   │   │   │   └── zz-docker.conf
│   │   │   ├── docker-php-fpm.conf
│   │   │   ├── php-fpm.conf
│   │   │   ├── php.ini
│   │   │   └── pool.d
│   │   │       └── www.conf
│   │   └── mods-available
│   │       ├── curl.ini
│   │       ├── gd.ini
│   │       ├── imagick.ini
│   │       ├── json.ini
│   │       ├── memcache.ini
│   │       ├── mysqli.ini
│   │       ├── mysql.ini
│   │       ├── opcache.ini
│   │       ├── pdo.ini
│   │       ├── pdo_mysql.ini
│   │       ├── readline.ini
│   │       ├── redis.ini
│   │       └── xdebug.ini
│   └── redis                            ## redis 配置
│       ├── redis.conf
│       ├── redis-server.sh
│       └── sentinel.conf
├── data                                ## 为了数据持久化
│   ├── mysql                            ## mysql 文件挂载
│   └── redis                            ## redis 持久化数据文件夹
├── docker-compose.yaml                 ## 容器编排 !!!
├── log                                 ## 各个容器产生的日志
│   ├── mysql                            ## mysql日志
│   │   ├── error.log
│   │   └── mysql.log
│   ├── nginx                            ## nginx 日志
│   │   ├── access.log
│   │   ├── access_xiuno.log
│   │   ├── error.log
│   │   └── error_xiuno.log
│   └── php
├── php                                    ## php代码存放目录
│   └── xiunobbs                            ## php项目
└── tool                                ## php 一些工具
    ├── composer.phar
    └── phpunit-4.8.36.phar

配置文件下载

git clone https://gitee.com/xupaul/docker_fast_init

配置说明

目前笔者还没有写相关自动化脚本自动适配用户的使用环境,所以这里需要先调整一下下载后的配置文件。

docker-compose.yml 文件调整

这个文件有大量的文件挂载配置,这块就需要调整,拿一个举例。

version: "2"

services:

    http:
        image: nginx:1.12.1-alpine
        volumes:
            - ./config/nginx/nginx.conf:/etc/nginx/nginx.conf:rw
        ports:
            - "8080:80"
        environment: 
            TZ : "Asia/Shanghai"
        networks:
            tasker_net:
                ipv4_address: "192.63.0.11"

在例子中最后volumes的下一行,是将宿主机的nginx.conf配置文件挂载并覆盖容器内的nginx.conf文件,./文件路径就是clone git项目后的目录。

如果你也使用了vbox虚拟机,那么你需要./要替换为docker的运行环境的路径!

ports这里,http服务为了不和本机的80端口冲突,以及sudo权限才能申请的小于1024的端口,这里我以8080作为默认值。其他容器的端口配置也遵循以上的规则,为的是能一个命令就能启动起来。

为每个容器都配置了时区。

网络ip这部分,为了便于配置就手动分配ip了。

Nginx 配置

配置好了php-fpm之间tcp通讯,以及php路由的rewrite。配置了静态资源缓存。

MySQL 配置

开启记录了一般性的查询日志,便于debug程序。注意这里挂载的配置文件读写权限要配置只读!

PHP 配置

    php:
        image: paulxu/php:5.6-fpm-jessis-pdo-xdebug-mysqli-gd-mb-zip-2
        volumes:
            - ./config/php/cli/php.ini:/usr/local/etc/php/php.ini/:rw
        ports:
            - "9000"
        environment: 
            PHP_IDE_CONFIG: "serverName=bs"
            XDEBUG_CONFIG: "remote_host=10.0.2.2 remote_port=9000"

我为php一起打包了一些常用拓展,其中xdebug需要调整一下remote_host的的ip(docker-compose.yaml, ~/config/php/cli/20-xdebug.ini 两个文件内的都需要调整). server_name 需要结合开发IDE一起调整,如PHPStorm里Preferences | Languages & Frameworks | PHP | Servers配置路径下有什么可用的记录,那么这里填什么。

Redis 配置

没有特殊配置

Memecached 配置

没有特殊配置

镜像构建

php镜像

可以通过如下命令拉取:

docker pull paulxu/php:5.6-fpm-jessis-pdo-xdebug-mysqli-gd-mb-zip-2

或者在到 ~/build/php5.6 下自行打包:

docker build -t paulxu/php:5.6-fpm-jessis-pdo-xdebug-mysqli-gd-mb-zip-2 -f dockerfile .

如果自定义tag name,需要一并调整docker-compose.yml

启动

  1. 从gitee下载配置
  2. 根据自己的环境调整配置
  3. 准备好镜像,镜像也可直接拉,我已上传了官方了。
  4. 启动

启动命令

## 当前线程运行,要停止时,键入`Ctrl+C`
docker-comose -f ./docker-compose.yaml up

## 后台运行
docker-comose -f ./docker-compose.yaml up -d

摧毁

docker-comose -f ./docker-compose.yaml down

常见问题

MySQL 挂载的配置文件被忽略,不起作用

MySQL 要求配置文件的权限为只能读,你需要已只读方式挂载

不知道remote_host怎么办

禁用xdebug,搭建好环境并启动,在IDE运行的环境(因为xdebug需要和IDE通讯)访问以http(在浏览器访问,注意端口映射)的方式访问php容器内代码,在Nginxaccess.log里查看访问的ip就是remote_host.

docker build 时报错导致打包镜像失败

遇到这种情况,如果时一些不要紧的报错,可以选择忽略他们。可以在命令后面追加 exit 0 来屏蔽错误

最后

祝愿大家能一步到位,有问题留言,遇到疑难杂可以试试本地起虚拟机里跑docker容器的那种方案。

PHP Session 并发写 覆盖 问题

问题:如题

php session 默认采用文件保存的session,带来的问题是并发锁,并且不能让PHP多点部署。

这个时候一般就是注册PHP sessionhandler,比如用redis,memcache保存session,这样就不会有并发锁了。(当然你可以在设计sessionhandler时,把并发写考虑进去,但我不推荐这样做),没有锁,带来的另一个问题就是并发写,
——导致写覆盖。如下图:

就是说在 并发时,先来了 耗时操作,又来了一个不耗时的操作,两个请求都读取并改动了seesion,那么耗时操作的写入的数据就会覆盖之前写入。

这个问题在其他Web服务(不论是Java,C#,Python)都会出现。

解决否

已解决

方案

我的方案:首先,PHP文件session又并发锁,是不推荐使用的。再一个,用户运行时数据,不应该放到php session 里保存,session 应该只保存用户的登陆信息,也就是登陆的那一次请求写一次session,后续的操作都应只有读,不能再写。因为当你用php代码修改了session的值,session并不是立马写到磁盘(或者刷到redis上),而是等到该http请求结束时,再一并写入。

所以,建议就是session只能在登录时写一次,登出时删除。如果应用又存用户运行时数据的需求,建议使用redis,memcached,mysql 实时储存,也就是又编辑请求时,立马发出操作数据库的请求,而不是等到http请求结束时在做,避免并发写覆盖问题。使用redis,memcached保存数据的key可借用session id作为该用户的数据key。

网友的方案:https://blog.csdn.net/wuxing26jiayou/article/details/78961830

ThinkPHP5 引入 Go AOP PHP AOP编程

ThinkPHP5,PHP7,要用AOP切面编程,没有现成的,一步到位的命令,需要怎么配置呢?什么你不知道什么是AOP,那还不来看看

项目背景

目前开发的WEB软件里有这一个功能,PHP访问API操作数据仓库,刚开始数据仓库小,没发现问题,随着数据越来越多,调用API时常超时(60s)。于是决定采用异步请求,改为60s能返回数据则返回,不能则返回一个异步ID,然后轮询是否完成统计任务。由于项目紧,人手不足,必须以最小的代价解决当前问题。

方案选择

  1. 重新分析需求,并改进代码
  2. 采用AOP方式改动程序
    从新做需求分析,以及详细设计,并改动代码,需要产品,架构,前端,后端的支持。会惊动的人过多,在资源紧张的情况下是不推荐的。
    采用AOP方式,不改动原有代码逻辑,只需要后端就能完成大部分任务了。后端用AOP切入请求API的方法,通过监听API返回的结果来控制是否让其继续运行原有的逻辑(API在60s返回了数据),或者是进入离线任务功能(API报告统计任务不能在60s内完成)。

之前用过AOP-PHP拓展,上手很简单,不过后来在某一个大项目中引入该拓展后,直接爆了out of memory,然后就研究其源码发现,它改变了语法树,并Hook了每个被调用的方法,也就是每个方法被调用是都会去询问AOP-PHP,这个方法有没有切面方法。所以效率损失是比较大的。而且这个项目距离现在已经有8年没更新了。所以不推荐该解决方案。

实际环境

Debian,php-fpm-7.0,ThinkPHP-5.10。

引入AOP

作为一门zui好的语言,PHP是不自带AOP的。那就得安装AOP-PHP拓展,当我打开pecl要下载时,傻眼了,全是bate版,没有显示说明支持php7。但我还是抱着侥幸心理,找到了git,发现4-5年没更新了,要不要等一波更新,哦,作者在issue里说了有时间就开始兼容php7。
好吧,狠话不多说,下一个方案:Go!AOP.看了下git,作者是个穿白体恤,喜欢山峰的大帅哥,基本每个issue都会很热心回复。

composer require goaop/framework

ThinkPHP5 对composer兼容挺不错的哦,(到后面,我真想揍ThinkPHP5作者)这就装好了,怎么用啊,git上的提示了简单用法。我也就照着写了个去切入controller。

<?PHP
namespace app\tests\controller;

use think\Controller;

class Test1 extends Controller
{
    public function test1()
    {
        echo $this->aspectAction();
    }

    public function aspectAction()
    {
        return 'hello';
    }
}

定义aspect

<?PHP
namespace app\tests\aspect;

use Go\Aop\Aspect;
use Go\Aop\Intercept\FieldAccess;
use Go\Aop\Intercept\MethodInvocation;
use Go\Lang\Annotation\After;
use Go\Lang\Annotation\Before;
use Go\Lang\Annotation\Around;
use Go\Lang\Annotation\Pointcut;

use app\tests\controller\Test1;

class MonitorAspect implements Aspect
{

    /**
     * Method that will be called before real method
     *
     * @param MethodInvocation $invocation Invocation
     * @Before("execution(public|protected app\tests\controller\Test1->aspectAction(*))")
     */
    public function beforeMethodExecution(MethodInvocation $invocation)
    {
        $obj = $invocation->getThis();
        echo 'Calling Before Interceptor for method: ',
             is_object($obj) ? get_class($obj) : $obj,
             $invocation->getMethod()->isStatic() ? '::' : '->',
             $invocation->getMethod()->getName(),
             '()',
             ' with arguments: ',
             json_encode($invocation->getArguments()),
             "<br>\n";
    }
}

启用aspect

<?PHP
// file: ./application/tests/service/ApplicationAspectKernel.php

namespace app\tests\service;

use Go\Core\AspectKernel;
use Go\Core\AspectContainer;

use app\tests\aspect\MonitorAspect;

/**
 * Application Aspect Kernel
 *
 * Class ApplicationAspectKernel
 * @package app\tests\service
 */
class ApplicationAspectKernel extends AspectKernel
{

    /**
     * Configure an AspectContainer with advisors, aspects and pointcuts
     *
     * @param AspectContainer $container
     *
     * @return void
     */
    protected function configureAop(AspectContainer $container)
    {
        $container->registerAspect(new MonitorAspect());
    }
}

go-aop 核心服务配置

<?PHP
// file: ./application/tests/behavior/Bootstrap.php
namespace app\tests\behavior;

use think\Exception;
use Composer\Autoload\ClassLoader;
use Go\Instrument\Transformer\FilterInjectorTransformer;
use Go\Instrument\ClassLoading\AopComposerLoader;
use Doctrine\Common\Annotations\AnnotationRegistry;

use app\tests\service\ApplicationAspectKernel;
use app\tests\ThinkPhpLoaderWrapper;

class Bootstrap
{
    public function moduleInit(&$params)
    {
        $applicationAspectKernel = ApplicationAspectKernel::getInstance();
        $applicationAspectKernel->init([
            'debug' =>  true,
            'appDir'    =>  __DIR__ . './../../../',
                'cacheDir'  =>  __DIR__ . './../../../runtime/aop_cache',
                'includePaths'  =>  [
                    __DIR__ . './../../tests/controller',
                    __DIR__ . './../../../thinkphp/library/think/model'
                ],
                'excludePaths'  =>  [
                    __DIR__ . './../../aspect',
                ]
            ]);
        return $params;
    }
}

配置模块init钩子,让其启动 go-aop

<?PHP
// file: ./application/tests/tags.php
// 由于是thinkphp5.10 没有容器,所有需要在module下的tags.php文件里配置调用他

return [
    // 应用初始化
    'app_init'     => [],
    // 应用开始
    'app_begin'    => [],
    // 模块初始化
    'module_init'  => [
        'app\\tests\\behavior\\Bootstrap'
    ],
    // 操作开始执行
    'action_begin' => [],
    // 视图内容过滤
    'view_filter'  => [],
    // 日志写入
    'log_write'    => [],
    // 应用结束
    'app_end'      => [],
];

兼容测试

好了,访问 http://127.0.0.1/tests/test1/test1 显示:

hello

这不是预期的效果,在aspect定义了,访问该方法前,会输出方法的更多信息信息。
像如下内容才是预期

Calling Before Interceptor for method: app\tests\controller\Test1->aspectAction() with arguments: []

上他官方Doc看看,是一些更高级的用法。没有讲go-aop的运行机制。
上git上也没看到类似issue,额,发现作者经常在issue里回复:试一试demo。也许我该试试demo。

Run Demos

我采用的是LNMP技术栈。

  1. 假设这里有台Ubuntu你已经配置好了LNMP环境
  2. 下载代码
  3. 配置nginx
# file: /usr/share/etc/nginx/conf.d/go-aop-test.conf
server {
    listen 8008;
#    listen 443 ssl;
    server_name 0.0.0.0;
    root "/usr/share/nginx/html/app/vendor/lisachenko/go-aop-php/demos";
    index index.html index.htm index.php;
    charset utf-8;

    access_log /var/log/nginx/go-aop-access.log;
    error_log  /var/log/nginx/go-aop-error.log notice;

    sendfile off;
    client_max_body_size 100m;

    location ~ \.php(.*)$ {
        include                         fastcgi_params;
        fastcgi_pass                    127.0.0.1:9000;
        fastcgi_index                   index.php;

        fastcgi_param                   PATH_INFO   $fastcgi_path_info;
#        fastcgi_param                   SCRIPT_FILENAME /var/www/html/app/vendor/lisachenko/go-aop-php/demos$fastcgi_script_name;  #docker的配置
        fastcgi_param                   SCRIPT_FILENAME /usr/share/nginx/html/api/vendor/lisachenko/go-aop-php/demos$fastcgi_script_name;
        fastcgi_param                   PATH_TRANSLATED $document_root$fastcgi_path_info;
        fastcgi_split_path_info         ((?U).+\.php)(/?.+)$;
    }
}

接下来要调整下代码

  1. 访问 http://127.0.0.1:8008 试试,(估计大家都遇到了这个)

  1. 这个报错信息提示找不到这个类。来到报错的文件里。这文件使用了use找不到类,就是autoload出问题了,看到 vendor/lisachenko/go-aop-php/demos/autoload.php 这个文件。
<?PHP
···
if (file_exists(__DIR__ . '/../vendor/autoload.php')) {
    /** @var Composer\Autoload\ClassLoader $loader */
    $loader = include __DIR__ . '/../vendor/autoload.php';
    $loader->add('Demo', __DIR__);
}

可以看到这个代码第一行没找到vendor下的autoload。我们做如下调整

<?PHP
$re = __DIR__ . '/../../../vendor/autoload.php';
if (file_exists(__DIR__ . '/../../../autoload.php')) {
    /** @var Composer\Autoload\ClassLoader $loader */
    $loader = include __DIR__ . '/../../../autoload.php';
    $loader->add('Demo', __DIR__);
}

再试试,demo运行起来了。


尝试了下,运行成功


通过以上的输出,可以得出demo里是对方法运行前成功捕获。为什么在thinkphp的controller里运行就不成功呢。我决定采用断点进行调试。

通过断点我发现了这个文件

<?PHP
// file: ./vendor/lisachenko/go-aop-php/src/Instrument/ClassLoading/AopComposerLoader.php

public function loadClass($class)
{
    if ($file = $this->original->findFile($class)) {
        $isInternal = false;
        foreach ($this->internalNamespaces as $ns) {
            if (strpos($class, $ns) === 0) {
                $isInternal = true;
                break;
            }
        }

        include ($isInternal ? $file : FilterInjectorTransformer::rewrite($file));
    }
}

这是一个autoload,每个类的载入都会经过它,并且会对其判断是否为内部类,不是的都会进入后续的操作。通过断点进入 FilterInjectorTransformer,发现会对load的文件进行语法解析,并根据注册的annotation对相关的类生成proxy类。说道这,大家就明白了go-aop是如何做到切入你的程序了吧,生成的proxy类,可以在你配置的cache-dir(我配置的是./runtime/aop_cache/)里看到。

同时./runtime/aop_cache/ 文件夹下也生成了很多东西,通过查看aop_cache文件内产生了与Test1文件名相同的文件,打开文件,发现它代理了原有的Test1控制器。这一系列信息,可以得出,Go!AOP 通过”劫持” composer autoload 让每个类都进过它,根据aspect的定义来决定是否为其创建一个代理类,并植入advice。
额,ThinkPHP5是把composer autoload里的东西copy出来,放到自己autoload里,然后就没composer啥事了。然后go-aop一直等不到composer autoload下发的命令,自然就不能起作用了,so,下一步

改进ThinkPHP5

在ThinkPHP5里,默认有且只会注册一个TP5内部的 Loader,并不会把include请求下发给composer的autoload。所以,为其让go-aop起作用,那么必须让让include class的请求经过 AopComposerLoad.
我们看看这个文件

<?PHP
// ./vendor/lisachenko/go-aop-php/src/Instrument/ClassLoading/AopComposerLoader.php:57

public static function init()
{
    $loaders = spl_autoload_functions();

    foreach ($loaders as &$loader) {
        $loaderToUnregister = $loader;
        if (is_array($loader) && ($loader[0] instanceof ClassLoader)) {
            $originalLoader = $loader[0];

            // Configure library loader for doctrine annotation loader
            AnnotationRegistry::registerLoader(function ($class) use ($originalLoader) {
                $originalLoader->loadClass($class);

                return class_exists($class, false);
            });
            $loader[0] = new AopComposerLoader($loader[0]);
        }
        spl_autoload_unregister($loaderToUnregister);
    }
    unset($loader);

    foreach ($loaders as $loader) {
        spl_autoload_register($loader);
    }
}

这个文件里有个类型检测,检测autoload callback是否为Classloader类型,然而ThinkPHP5不是,通过断点你会发现ThinkPHP5是一个字符串数组,so,这里也就无法把go-aop注册到class loader的callback当中了。

这里就要提一下PHP autoload机制了,这是现代PHP非常重要的一个功能,它让我们在用到一个类时,通过名字能自动加载文件。我们通过定义一定的类名规则与文件结构目录,再加上能实现以上规则的函数就能实现自动加载了。在通过 spl_autoload_register 函数的第三个参数 prepend 设置为true,就能让其排在在TP5的loader前面,先一步被调用。

依照如上原理,就可以做如下改进
这个是为go-aop包装的新autoload,本质上是在原来的ThinkPHP5的loader上加了一个壳而已。

<?PHP
// file: ./application/tests 

namespace app\tests;

require_once __DIR__ . './../../vendor/composer/ClassLoader.php';

use think\Loader;
use \Composer\Autoload\ClassLoader;
use Go\Instrument\Transformer\FilterInjectorTransformer;
use Go\Instrument\ClassLoading\AopComposerLoader;
use Doctrine\Common\Annotations\AnnotationRegistry;


class ThinkPhpLoaderWrapper extends ClassLoader
{
    static protected $thinkLoader = Loader::class;

    /**
     * Autoload a class by it's name
     */
    public function loadClass($class)
    {
        return Loader::autoload($class);
    }

    /**
     * {@inheritDoc}
     */
    public function findFile($class)
    {
        $allowedNamespace = [
            'app\tests\controller'
        ];
        $isAllowed = false;
        foreach ($allowedNamespace as $ns) {
            if (strpos($class, $ns) === 0) {
                $isAllowed = true;
                break;
            }
        }
        // 不允许被AOP的类,则不进入AopComposer
        if(!$isAllowed)
            return false;

        $obj = new Loader;
        $observer = new \ReflectionClass(Loader::class);

        $method = $observer->getMethod('findFile');
        $method->setAccessible(true);
        $file = $method->invoke($obj, $class);
        return $file;
    }
}
<?PHP
// file: ./application/tests/behavior/Bootstrap.php 在刚刚我们新添加的文件当中
// 这个方法 \app\tests\behavior\Bootstrap::moduleInit 的后面追加如下内容

// 组成AOPComposerAutoLoader
$originalLoader = $thinkLoader = new ThinkPhpLoaderWrapper();
AnnotationRegistry::registerLoader(function ($class) use ($originalLoader) {
    $originalLoader->loadClass($class);

    return class_exists($class, false);
});
$aopLoader = new AopComposerLoader($thinkLoader);
spl_autoload_register([$aopLoader, 'loadClass'], false, true);

return $params;

在这里我们做了一个autload 并直接把它插入到了最前面(如果项目内还有其他autloader,请注意他们的先后顺序)。

最后

现在我们再访问一下 http://127.0.0.1/tests/test1/test1 你就能看到来自 aspect 输出的信息了。
最后我们做个总结:

  1. PHP7目前没有拓展实现的 AOP
  2. ThinkPHP5 有着自己的 Autoloader
  3. Go!AOP 的AOP实现依赖Class Autoloadcallback,通过替换原文件指向Proxy类实现。
  4. ThinkPHP5 整合 Go!AOP 需要调整 autoload