1. AOP 基础原理与传统代理模式代码;
伪代码分析
<?php
// 代码直接写在页面里
// 编辑新闻
if($_POST["edit"]){
isLogin(); // 判断用户是否登录
isOwner($newsID); // 判断用户是否该新闻所有者
edit($newsID); // 这里编辑新闻(实际业务逻辑)
setLog(); // 记录日志
}
// 删除新闻
if($_POST["del"]){
isLogin(); // 判断用户是否登录
isOwner($newsID); // 判断用户是否该新闻所有者
del($newsID); // 这里删除新闻(实际业务逻辑)
setLog(); // 记录日志
}
// 不符合当前编程理念,没有面向对象的痕迹在里面
// 一般会把部分方法抽取出来,写成新的方法
// 所以可能会写一个 class,包含两方法,一个编辑,一个删除
class News{
// 编辑新闻方法
function editNews($newsID){
isLogin(); // 判断用户是否登录
isOwner($newsID); // 判断用户是否该新闻所有者
edit($newsID); // 这里编辑新闻
setLog(); // 记录日志
}
// 删除新闻方法
function delNews($newsID){
isLogin(); // 判断用户是否登录
isOwner($newsID); // 判断用户是否该新闻所有者
del($newsID); // 这里删除新闻
setLog(); // 记录日志
}
}
// 从方法抽取到抽象成类,这种做法叫做“纵向抽取”
// 接下去提到的 AOP 就是 “横向抽取”
// 就是抽取 isLogin(),isOwner($newsID) 和 setLog()
// 然后方法里只有业务代码 edit($newsID) 和 del($newsID)
// 然后通过一定的设计模式,比如代理模式、动态代理模式,然后注入到实际的代码调用过程
// 如下图,**判断登录、判断所有者和记录日志**是被横向切面切出来的,也叫做切面编程
// 通过一定的模式把**判断登录、判断所有者和记录日志**切入到具体业务代码的执行前或后
// 同样,执行有异常或者出错的时候都可以开进行切入,需要去定义一些切入点和切入时间
AOP 的理念
- 就是将分散在各个业务逻辑代码中相同的代码通过横向切割的方式抽取到一个独立的模块中
- 动态的将代码切入到类的指定方法、指定位置上的编程思想
- 原理和动态代理有关系
传统代理模式
- 代理模式:常用于对象之间的调用,当 A 对象不方便或无法直接调用 B 对象(有权限问题),在两者之间创建一个代理对象,起到桥梁或者中介的作用
- 创建接口文件
INews.php
<?php
interface INews{
function editNews(int $newsID);
function delNews(int $newsID);
}
- 创建
MyNews.php
<?php
// 防止交叉引入
require_once("INews.php");
class MyNews implements INews {
// 实现抽象方法
function editNews(int $newsID){
echo "编辑新闻";
}
function delNews(int $newsID){
echo "删除新闻";
}
}
- 创建代理类
NewsProxy.php
<?php
// Alt + Enter 自动生成两个方法
require_once("INews.php");
// 引入 MyNews.php 仅仅是在构造函数中获取类型 MyNews
require_once("MyNews.php");
// 需要对实际业务 MyNews 类去进行一个传参
class NewsProxy implements INews {
private $newsObject;
function __construct(MyNews $news){
$this->newsObject = $news;
}
// 代理类不执行真正的 editNews()
// 而是让 NewsProxy.php 调用 MyNews.php
// test.php 调用 NewsProxy.php,NewsProxy.php 调用 MyNews.php
function editNews(int $newsID){
$this->preInvoke();
// 代理执行
$this->newsObject->editNews($newsID);
$this->afterInvoke();
}
function delNews(int $newsID){
$this->preInvoke();
$this->newsObject->delNews($newsID);
$this->afterInvoke();
}
// 实际要通过动态方式写入
// 这次先写死
private function preInvoke(){
echo "执行前判断";
}
private function afterInvoke()
{
echo "执行后";
}
}
- 创建客户端代码
test.php
<?php
// 执行客户端代码的时候并不知道代理类里面有两个方法
//
require("NewsProxy.php");
$p = new NewsProxy(new MyNews());
$p->editNews(123);
2. 动态代理模式、简易 AOP 雏形;
回顾代理模式代码
- INews 为业务接口,MyNews 为实现类,需要去实现业务接口的两个方法。去编辑或者删除新闻需要有一定的权限判断(是否登录、是否有权限是当前新闻所有者、删除完成记日志),这个部分如果在函数里反复冗余的去写代码,不是很美观,会造成代码的可维护性降低
- 所以需要写一个代理类 NewsProxy,实际运行的时候只需要初始化 NewsProxy,把新的对象 new MyNews() 传入并复制给 newsObject。在执行编辑新闻和删除新闻的时候,可以在代理类的方法里面去执行一些权限判断(preInvoke 和 afterInvoke)
- 客户端只需要调用 editNews() 和 delNews(),不需要知道前面做了哪些判断、执行完后做了哪些处理,只需要在代理类 NewsProxy 做相关处理
- 这是一个静态代理,相关的执行逻辑和执行前后处理都是写在代理类 NewsProxy 里的、直接去继承了 INews,这其中会有两个问题
- 和代理类一对一,不能一对多。代理类本身继承了 INews,如果这个代理类需要去执行其它一些业务接口(比如 IUser),就需要重新写一个 UserProxy 的代理类,
- 依然没有解决代码冗余, preInvoke() 和 afterInvoke() 在编辑和删除新闻里各写了一遍。如果业务类里有十个方法,就要写十遍
- 所以需要引入动态代理,是在静态代理的基础上做了一些升级,需要了解下 PHP 反射机制:https://www.php.net/manual/zh/book.reflection.php
代码修改
- 修改新增代理类
MyProxy.php
<?php
require_once("INews.php");
require_once("MyNews.php");
// 普通 class,存入切入的“前”和“后”
// 为了让代码更有逼格
class PointCut{
const before = "before";
const after = "after";
}
// 不能继承原有的业务接口,只能执行新闻相关内容
class MyProxy {
private $inputObject;
// 用来存储切入方法(因为可以有很多方法)
private $beforeList = [];
private $afterList = [];
// 传入类
function __construct($obj){
$this->inputObject = $obj;
}
// 执行增加切入点(切入到哪)
//
public function addPoint(callable $func, String $point){
if($point == PointCut::before){
$this->beforeList[] = $func;
}
if($point == PointCut::after){
$this->afterList[] = $func;
}
}
// 使用魔术方法,对传入的对象做一个反射的调用,获取它的方法和传过来的 name 是否一致,如果一致,就进行反射调用
function __call($methodName, $arguments){
// 获取反射类
$c = new ReflectionClass($this->inputObject);
// 判断对象里面是否有这个方法
// $methodName 就是 test.php 调用的 editNews
if($c->hasMethod($methodName)){
// 可以做进一步判断,比如方法是否 public,是否非静态,这里先忽略
// 如果有这个方法,就去执行这个方法
// getMethod() 得到方法
$method = $c->getMethod($methodName);
// 执行前判断
$this->preInvoke();
// 调用方法,传入对象 $this->inputObject, 字符串或者数组参数 arguments
// 打上三个点扩展一下,表示可变数量的参数
// test.php 调用 editNew() 相当于调用了魔术方法 __call(),这就是动态代理的基本模型
$ret = $method->invoke($this->inputObject,...$arguments);
// 执行后判断
$this->afterInvoke();
}
return false;
}
// preInvoke() 应该是动态执行的,并不是把执行切入的业务逻辑写在方法里面,否则无法进行动态灵活切入
// preInvoke() 和 afterInvoke() 执行什么,全部在外部 test.php 定义
private function preInvoke(){
foreach ($this->beforeList as $p){
$p();
}
}
private function afterInvoke(){
foreach ($this->afterList as $p){
$p();
}
}
}
- 修改
test.php
<?php
// 写匿名函数
$checkuserlogin = function (){
echo "(判断用户是否登录)";
};
$checkuserrole = function (){
echo "(判断用户角色)";
};
$setlog = function (){
echo "记录执行日志";
};
require("MyProxy.php");
$p = new MyProxy(new MyNews());
$p->addPoint($checkuserlogin, PointCut::before);
$p->addPoint($checkuserrole, PointCut::before);
$p->addPoint($setlog, PointCut::after);
$p->editNews(123);
3. 动态代理模式实现 AOP 切入点、切面,模拟方法“注解”;
回顾动态代理模式代码
一些技术术语
- Advice(通知):beforeInvoke 和 afterInvoke 就是 。常见的可分为前置通知(Before)、后置通知(AfterReturning,有返回值)、异常通知(AfterThrowing)、最终通知(After)与环绕通知(Around,业务执行前后都去执行) 。从 MyProxy 类中可以看到,就是一堆已经定义好的执行顺序,遍历、执行
- 连接点(JoinPoint):譬如一个业务类,有5个业务方法(public),为这就是5个连接点。上面的通知(before、after等)可以在这些连接点方法执行的前、后、环绕执行
- 切入点(Pointcut):有了连接点(方法)的概念,我们就需要设定哪些方法需要执行 Advice。总不是所有方法都强制执行所有的 Advice 方法(比如所有用户都能读取新闻,不需要做用户判断。哪怕加了通知,也是不需要去执行的)。其中,Pointcut 和 Advice 组成一个业务切面,就是 Aspect。不包含 JoinPoint、那是外部业务类
代码修改
- 新增切面类
NewsAspect.php
<?php
// 新闻业务逻辑切面
class NewsAspect {
// 连接点
// 假如有一些方法 editNews(),getNews(),如何去判断哪个方法需要切入,哪个方法不需要
// 可以使用正则方式
private $joinPoint;
// 设置 joinpoint 的基本规则
public function setPointCut(string $joinpoint){
// 正则
$this->joinPoint = $joinpoint;
}
// 判断方法是否需要切入,通过正则,满足匹配直接返回
public function isJoinPoint($methodName){
return preg_match($this->joinPoint, $methodName);
}
/**
* 注释是自己约定的,可以解析就行
* before()
*/
public function checkUserLogin(){
echo "[判断用户登录]";
}
/**
* after()
*/
public function setLog(){
echo "[写入日志]";
}
/**
* before()
*/
public function checkOwner(){
echo "[判断所有者]";
}
}
- 修改代理类
MyProxy.php
<?php
class MyProxy{
private $inputObject;
private $inputAspect; // 传入的切面类
function __construct($obj, $asp){
$this->inputObject = $obj;
$this->inputAspect = $asp;
}
function __call($methodName, $arguments){
$c = new ReflectionClass($this->inputObject);
if($c->hasMethod($methodName)){
// 获取的业务方法(比如 editNews())
$method = $c->getMethod($methodName);
$this->invokeAdvice($methodName, "before");
// 如果有返回值,可以追加代码判断
$ret = $method->invoke($this->inputObject,...$arguments);
$this->invokeAdvice($methodName, "after");
}
return false;
}
// 运行通知
function invokeAdvice(string $methodName, string $advice){
if(!$this->inputAspect->isJoinPoint($methodName)){
return;
}
// 切面反射对象
$asp = new ReflectionClass($this->inputAspect);
// 获取所有 public 方法
$aspMethods = $asp->getMethods(ReflectionMethod::IS_PUBLIC);
foreach ($aspMethods as $aspMethod){
// 获取注释
if(preg_match("/$advice/i", $aspMethod->getDocComment())) {
// 执行 before 方法
$aspMethod->invoke($this->inputAspect);
}
}
}
}
- 修改 test.php
<?php
require("MyNews.php");
require("MyProxy.php");
require_once("NewsAspect.php");
$newsasp = new NewsAspect();
$newsasp->setPointCut("/^edit.*/i");
$p=new MyProxy(new MyNews(), $newsasp);
$p->editNews(123);
// $p->getNews();
4. Swoft 实现 AOP。
官方文档:https://www.swoft.org/documents/v2/basic-components/aop/
定义切面类:
-
@Aspect()
:定义一个类为切面类 -
@PointBean()
:定义 Bean 切入点 - 这个 Bean 类里的方法执行都会经过此切面类的代理include
:定义需要切入的实体名称集合exclude
:定义需要排除的实体名称集合
-
@PointExecution()
:定义匹配切入点 - 指明要代理目标类的哪些方法include
:定义需要切入的匹配集合、匹配的类方法、支持正则表达式exclude
:定义需要排除的匹配集合、匹配的类方法、支持正则表达式
-
正则表达式:通过正则匹配需要代理的方法
// 举例
<?php
namespace App\Aspect;
use Swoft\Aop\Annotation\Mapping\After;
use Swoft\Aop\Annotation\Mapping\AfterReturning;
use Swoft\Aop\Annotation\Mapping\Around;
use Swoft\Aop\Annotation\Mapping\Aspect;
use Swoft\Aop\Annotation\Mapping\Before;
use Swoft\Aop\Annotation\Mapping\PointBean;
use Swoft\Aop\Annotation\Mapping\PointExecution;
use Swoft\Aop\Point\JoinPoint;
use Swoft\Aop\Point\ProceedingJoinPoint;
use Swoft\Http\Message\Request;
/**
* @Aspect()
* // @PointExecution(
* include={
* "App\\Http\\Controller\\TestController::index.*",
* "App\\Http\\Controller\\TestController::test.*"
* }
* )
* // @PointBean(
* include={
* "App\\Model\\Logic\\TestLogic::class"
* }
* )
*/
class TestAspect{
/**
* @Before()
* @param JoinPoint $joinPoint
*/
public function before(JoinPoint $joinPoint){
// 对应 Controller 方法 public function index(Request $request) {}
// 获取方法的参数(可以做类似中间件鉴权,参数校验处理)
$arg = $joinPoint->getArgs();
/** @var $request Request */
$request = $arg[0];
print_r($request->getQueryParams());
echo "before\n";
}
/**
* @After()
*/
public function after(){
echo "after\n";
}
/**
* @AfterReturning()
* @param JoinPoint $joinPoint
* @return array|mixed
*/
public function afterReturning(JoinPoint $joinPoint){
echo "afterReturn\n";
}
}
-
@PointAnnotation()
:定义注解切入点 - 所有包含使用了对应注解的方法都会经过此切面类的代理include
:定义需要切入的注解名称集合exclude
:定义需要排除的注解名称集合- 涉及:Annotation 注解类、Aspect 切面、Collector 注解收集类、Parser 注解解析类、Wrapper 注解封装类
-
举例:创建注解类
App\Aop\Annotation\Hello.php
<?php
namespace App\Aop\Annotation;
use Doctrine\Common\Annotations\Annotation;
use Doctrine\Common\Annotations\Annotation\Target;
/**
* 注解类
* @Annotation
* Target 表示注解在哪一个级别有效
* 分为 ALL,CLASS,METHOD,PROPERTY,ANNOTATION
* Controller 的 Target 就是 CLASS 级别
* @Target("ALL")
*/
class Hello
{
private $name;
public function __construct($value)
{
if (isset($value['name'])) {
$this->name = $value['name'];
print_r($value);
}
}
/**
* @return mixed
*/
public function getName()
{
return $this->name;
}
/**
* @param mixed $name
*/
public function setName($name): void
{
$this->name = $name;
}
}
- 调用
<?php
namespace App\Http\Controller;
use App\Aop\Annotation\Hello;
use Swoft\Http\Message\Request;
use Swoft\Http\Server\Annotation\Mapping\Controller;
use Swoft\Http\Server\Annotation\Mapping\RequestMapping;
use Swoft\Http\Server\Annotation\Mapping\RequestMethod;
/**
* Class TestController
* @package App\Http\Controller
* @Controller(prefix="/test")
* @Hello(name="aaaa")
*/
class TestController
{
/**
* @RequestMapping(route="/test/index", method={RequestMethod::GET})
* @Hello(name="bbbb")
*/
public function index() {
return time();
}
}
- 待续
实例代码:新建 App/Http/Controller/NewsController.php
<?php
namespace App\Http\Controller;
use Swoft\Http\Server\Annotation\Mapping\Controller;
use Swoft\Http\Server\Annotation\Mapping\RequestMapping;
use Swoft\Http\Server\Annotation\Mapping\RequestMethod;
/**
* @Controller("/news")
*/
class NewsController{
/**
* @RequestMapping(route="get/{newsid}",method={RequestMethod::GET})
* @param int $newsid
* @return array
*/
function getNews(int $newsid){
return ["显示新闻, id=" . $newsid];
}
/**
* @RequestMapping(route="edit/{newsid}",method={RequestMethod::GET})
* @param int $newsid
* @return array
*/
function editNews(int $newsid){
return ["编辑新闻, id=" . $newsid];
}
}
- 新建
App/Aspect/NewsAspect.php
<?php
namespace App\Aspect;
use Swoft\Aop\Annotation\Mapping\After;
use Swoft\Aop\Annotation\Mapping\AfterReturning;
use Swoft\Aop\Annotation\Mapping\Aspect;
use Swoft\Aop\Annotation\Mapping\Before;
use Swoft\Aop\Annotation\Mapping\PointExecution;
use Swoft\Aop\Point\JoinPoint;
// 声明切点:https://www.swoft.org/documents/v2/basic-components/aop/#heading5
// 推荐用 PointExecution 定义确切的目标类方法
/**
* @Aspect()
* @PointExecution(
* include={
* "App\\Http\\Controller\\NewsController::edit.*",
* }
* )
*/
// 内置动态代理模式来进行切入
class NewsAspect{
/**
* @Before()
*/
public function checkLogin(){
var_dump("检查登录");
}
/**
* @After()
*/
public function setLog(){
var_dump("记录日志");
}
/**
* @AfterReturning()
*/
public function afterReturn(JoinPoint $joinPoint){
$result = $joinPoint->getReturn();
$result[] = "加入的值";
return $result;
}
}