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;
}
}