這篇文章也可以在我的博客中查看
關于本文
專業的流量統計系統能夠相對真實地反應網站的訪問情況。
這些數據可以在后臺很好地進行分析統計,但有時我們希望在網站前端展示一些數據
最常見的情景就是:展示頁面的瀏覽量
這簡單的操作當然也可以通過簡單的計數器實現,但可能會造成重復統計(比如同一個用戶點擊10次)
目標
流量分析工具所提供的準確性是不可比擬的
因此這篇文章我們就來實現如何將流量分析數據搬到網站展示,做到:
- 同步流量分析工具數據到網站前端
- 顯示頁面的閱讀量
- 不影響頁面加載
- 用戶不會感知到同步任務進行
- 不頻繁訪問分析工具API
- 減少網絡資源、API次數消耗
準備
為完成這些目標,需要一些前提準備:
- 配置好帶有數據訪問API的流量分析工具
- 如
Google Analytics
、Umami
(本文將以Umami為例) - 這是我們的真實數據來源
- 如
- 配置好WordPress后臺進程(Background Process)支持
- 如Action-Scheduler(本文將以此為例)
- 這是我們非阻塞運行的基礎
分析問題
Analytics類
分析問題
API訪問頻率
閱讀量實時性并不強,我們無須(也不可能)每次頁面訪問都從遠程分析工具獲取數據
頻繁訪問很有可能會被禁止訪問API,(自建的相當于DDoS攻擊自己😅)
在獲取數據后,應該在短時間內緩存起來
WordPress中的跨請求緩存API是
transient
處理緩存未命中
但如果緩存未命中怎么辦?是立刻訪問遠程分析工具嗎?
不可能,這樣同步執行會使頁面加載阻塞
特別是:如果你一次展示多篇文章,你需要等待它們全部完成才能加載出頁面!
因此我們必須在本地數據庫也持久化存儲閱讀量
這個冗余數據是緩存未命中時的唯一可行數據來源
在WordPress中,我們可以使用
post_meta
存儲它
與此同時,這也可作為數據過時的標志:
我們應該觸發更新閱讀量的后臺進程
非阻塞地將第三方分析工具的數據同步到本地上
小結
Analytics.php
的是用于頁面獲取數據的接口。它的數據來源是:
- 內存緩存
- 減少短期重復訪問,減少服務器壓力
- 本地數據庫
- 緩存未命中時的保底數據
- 遠程分析工具
- 數據更新的途徑
它的職責是:
- 讀寫本地數據
- 發出更新請求
實現
注意組織文件結構,本文將
/App
文件夾作為根目錄
在/App/Services/Analytics/
創建Analytics.php
文件
編寫Analytics
類,它主要包含一些靜態函數
namespace App\Services\Analytics {class Analytics{public static function getPageViews(WP_Post|int $post){}public static function setPageViews(WP_Post|int $postId, $newViews){}}
}
getPageViews
本文實現需要依賴$post->ID作為唯一標識符
如果你希望實現任何頁面的閱讀量展示,你需要:
- 使用
url[path]
的md5 hash
作為唯一標識符- 使用自定義數據庫表存儲閱讀量:
(url_md5, page_view)
需要做什么?
當訪客來訪時,需要展示閱讀量,此時:
- 我們需要獲取目標地址的
WP_Post
實例- 以獲取url等信息
- 有緩存讀緩存
- 無緩存讀數據庫
- (不阻塞執行)請求第三方流量分析API,更新記錄
- 馬上使用舊數據刷新緩存
前面提到了緩存過期是發出數據同步請求的標志,但我們不希望重復發起請求,
因此緩存未命中時需要馬上再次寫入緩存。
雖然數據是舊的,但不急。我們可以在數據同步時強制刷新它
大部分都好處理,異步請求比較麻煩,先賣個關子
同時我們還為閱讀量定義了緩存鍵值和在數據庫的meta鍵值:
protected static string $pageViewMetaKey = 'page_views';
protected static int $pageViewCacheTime = HOUR_IN_SECONDS;
protected static function pageViewsCacheKey(int $postId)
{return static::$pageViewMetaKey . '_' . $postId;
}public static function getPageViews(WP_Post|int $post)
{if (!($post instanceof WP_Post))$post = get_post($post);if (empty($post)) return 0;// 嘗試獲取緩存$pageViews = get_transient(Analytics::pageViewsCacheKey($post->ID));if ($pageViews !== false) return $pageViews;// 記錄更新請求// <-- ?? async call to update ?? -->// 讀取數據庫記錄,這將是最后能夠返回的值$pageViews = get_post_meta($post->ID, Analytics::$pageViewMetaKey, true) ?: 0;// 重寫緩存set_transient(Analytics::pageViewsCacheKey($post->ID), $pageViews, static::$pageViewCacheTime);return $pageViews;
}
setPageViews
這個函數用于寫入本地的數據存儲,包括緩存和數據庫
注意,它并不包含異步更新的過程,只是異步更新的結果需要借助它寫入:
public static function setPageViews(WP_Post|int $postId, $newViews)
{if ($postId instanceof WP_Post)$postId = $postId->ID;// 更新緩存set_transient(Analytics::pageViewsCacheKey($postId), $newViews, static::$pageViewCacheTime);// 寫到數據庫update_post_meta($postId, Analytics::$pageViewMetaKey, $newViews);
}
Provider
好了,該想想怎么訪問遠程API了
Analytics
因為大多為固定操作,我們實現為靜態
但是更新數據來源的邏輯呢?
不同的流量分析工具會提供不同的API,因此我們也需要為它們編寫各自的處理邏輯
我們需要根據設置為Analytics
注入一個恰當的數據來源實例,這里稱為Provider
先關注Analytics
類中需要如何支持注入Provider
沒使用任何框架,我只能純手工注入
以下代碼是額外增加內容,需要與上文合并
class Analytics
{private static Closure|AnalyticsProvider $_provider;public static function setProvider(callable|AnalyticsProvider $provider){if (is_callable($provider))static::$_provider = Closure::fromCallable($provider);elsestatic::$_provider = $provider;}protected static function getProvider(): AnalyticsProvider{if (static::$_provider instanceof Closure)static::$_provider = (static::$_provider)();return static::$_provider;}
}
我們需要先setProvider
設置使用的數據源,后續使用getProvider
獲取它
因為某些provider
可能會很沉重,這里支持傳入一個返回AnalyticsProvider
的Closure
以實現懶加載,只有需要使用它的時候才會生成
接下來再看看provider
需要怎么編寫
AnalyticsProvider類
不同的provider有不同的訪問邏輯,但至少有沒有些共性?
還真有!
需要未雨綢繆的問題
Provider負責組織后臺任務,但每次請求更新都立刻組織一個后臺任務還是很恐怖的。
比如:一個頁面有100篇文章
每當Analytics::getPageViews
緩存未命中時,就組織后臺任務
此時需要組織100個任務
因為php無守護進程,每個后臺任務其實需要通過寫數據庫進行任務信息持久化
因此組織100個后臺任務,意味著訪問數據庫上百次
而組織任務這個過程,是同步的、阻塞的
用戶會看著頁面轉十秒加載不出來
但說到底,有沒有必要把它視為100個任務?不能批處理一下嗎?
當然可以,而且這就是不同AnalyticsProvider
的一個共性。
實現
在/App/Services/Analytics/
創建AnalyticsProvider.php
文件
編寫Analytics
類
namespace App\Services\Analytics {abstract class AnalyticsProvider{}
}
pushUpdatePostViews
這是登記更新任務的邏輯
上文說了,我們不希望立刻生成后臺任務,而是記錄它:
protected array $updatesList = [];/*** 將目標加入瀏覽量更新任務隊列* @param array $args 查詢需要的參數,與具體實現有關*/
public function pushUpdatePostViews(WP_Post $post, array $args = [])
{$this->updatesList[$post->ID] = $args;
}
$args
主要是請求API時的參數,比如:時間段?目標地址?國家?……
這與具體數據源的實現有關,但總之,我們需要把這些可能用到的數據存到$updatesList
里
$updatesList
記錄了本次請求中,所有需要請求閱讀量更新的文章和相應參數
但我們如何把它加到后臺任務?
submitTasks()
submitTasks由子類負責給出任務提交的邏輯
父類只需要給出約束
abstract public function submitTasks();
沒完,我們需要有人在最后調用這個函數,才能完成所有任務一次性提交
可以利用WordPress的shutdown
hook
public function __construct()
{add_action('shutdown', [$this, 'submitTasks']);
}
因為shutdown是WordPress最后一個hook,因此不用擔心之后還會有新的任務提交請求
注意,WordPress hook的回調必須是
public
函數
調用
還記得Analytics::getPageViews
的空缺位置嗎?
它應該調用AnalyticsProvider
!
public static function getPageViews(WP_Post|int $post)
{// ...// <-- ?? async call to update ?? -->static::getProvider()->pushUpdatePostViews($post);// ...
}
注意:static
在上下文中就是Analytics
具體的AnalyticsProvider
主要完成兩件事:
- 完成任務提交邏輯
- 封裝處理參數
以下我以
Umami
為例
在/App/Services/Analytics/Umami
創建UmamiAnalyticsProvider.php
文件
編寫UmamiAnalyticsProvider
類:
namespace App\Services\Analytics\Umami {use WP_Post;use App\Services\Analytics\AnalyticsProvider;class UmamiAnalyticsProvider extends AnalyticsProvider{public function submitTasks(){if ($this->updatesList) {// <-- ?? submit this background task ?? -->}}public function pushUpdatePostViews(WP_Post $post, array $args = []){$args['path'] = parse_url(get_permalink($post))['path'];parent::pushUpdatePostViews($post, $args);}}
}
Umami API
獲取閱讀量必須提供頁面的path
,因此我重寫pushUpdatePostViews
并按id
獲取了它的path
submitTask
先檢測了是否真有待提交任務數據,如有,提交
- 具體提交邏輯見下文
后臺任務
萬事俱備,只欠東風
我們只剩下后臺任務需要解決了,但你先別急
這篇文章目前只到一半
本文將使用Action Scheduler
作為后臺任務的驅動
但不管你是否使用它,后文的task
結構都可以給你一點靈感
Action-Scheduler
Action Scheduler
基本上是WordPress中支持后臺進程的唯一選擇了
它的官方例子如下:
require_once( plugin_dir_path( __FILE__ ) . '/libraries/action-scheduler/action-scheduler.php' );/*** Schedule an action with the hook 'eg_midnight_log' to run at midnight each day* so that our callback is run then.*/
function eg_schedule_midnight_log() {if ( false === as_has_scheduled_action( 'eg_midnight_log' ) ) {as_schedule_recurring_action( strtotime( 'tomorrow' ), DAY_IN_SECONDS, 'eg_midnight_log', array(), '', true );}
}
add_action( 'init', 'eg_schedule_midnight_log' );/*** A callback to run when the 'eg_midnight_log' scheduled action is run.*/
function eg_log_action_data() {error_log( 'It is just after midnight on ' . date( 'Y-m-d' ) );
}
add_action( 'eg_midnight_log', 'eg_log_action_data' );
這個例子將在每天午夜輸出一個log
但這例子其實有個坑,Action Scheduler
的執行機制事實上跨越了2次php執行:
- 第一次,制定任務
- 使用
as_schedule_recurring_action
制定任務 - 此時
eg_midnight_log
hook無效
- 使用
- 第二次,午夜時執行任務(可能由cron或其它機制觸發)
- 它從數據庫中檢測到預定的任務,生成
eg_midnight_log
hook - 執行
eg_midnight_log
hook的邏輯
- 它從數據庫中檢測到預定的任務,生成
所以坑點就在于add_action( 'eg_midnight_log', 'eg_log_action_data' );
必須在執行任務時加入,在制定任務時加入是無效的
而我們的目標,則是:
- 把2次php執行的代碼盡可能地透明化,封裝起來
- 使用面向對象的思想處理任務,使其模塊化
TaskManager類
TaskManager
主要用于負責所有任務的提交和觸發,我的實現主要針對Action Scheduler
,如果使用其它后臺任務庫,該類需要做對應修改。
在閱讀前,建議先了解
Action Scheduler
的基本操作
實現
在/App/Services/Task
創建TaskManager.php
文件
編寫TaskManager
類:
namespace App\Services\Task {class TaskManager{protected static array $taskList;public static function init(){}public static function registerTask($taskName){static::$taskList[] = $taskName;}public static function submitTask(string $handlerType, array $taskMeta, array $taskParams): int{}}
}
registerTask
用于記錄所有需要管理的任務名,它的作用只是將名字加入$taskList
列表
submitTask
用于提交“保證任務觸發時正常執行”所需的一切數據,包括:
- 交給誰處理(給誰處理)
- 執行處理的指引(怎么處理)
- 需要處理的數據(處理什么)
因此它需要傳入3個參數:
$handlerType
: 承載任務處理邏輯的類名- 后文會詳細介紹,它的基類是
Task
,包含一個handleTask
方法
- 后文會詳細介紹,它的基類是
$taskMeta
: 承載任務處理的元數據- 比如任務時限?重試次數?
- 反正是與任務相關,但與任務執行主體無關的
$taskParams
: 任務執行所需的數據- 比如我們需要訪問api,那可能就是api參數等等
因此可以寫出這樣的代碼:
public static function submitTask(string $handlerType, array $taskMeta, array $taskParams): int
{if (!$handlerType) return 0;$args = ['handler' => $handlerType, 'meta' => $taskMeta, 'params' => $taskParams];return as_enqueue_async_action($handlerType::$taskName, $args, md5(json_encode($args)), true);
}
- 使用
Action Scheduler
提供的as_enqueue_async_action
,將任務數據移交至其托管。 - 所有
$args
參數將被Action Scheduler
存儲于數據庫,當執行時取出- 有點像序列化
$taskName
是Task
類的靜態變量,表示任務名- 因為
Task
與任務直接關聯,因此任務名就存在它那了
- 因為
- 防止完全重復任務
- 標記為唯一任務(第四個參數
unique:true
) - 計算參數的md5作為分組,用于識別重復任務
- 標記為唯一任務(第四個參數
init
init需要在每次執行、所有registerTask
調用結束后調用,它用于監聽后臺任務是否已觸發,如果是,則分配到相應的處理函數
public static function init()
{require_once(get_template_directory() . '/vendor/woocommerce/action-scheduler/action-scheduler.php');/*** 監聽事件觸發并轉交給handler*/foreach (static::$taskList as $taskName) {add_action($taskName, function (string $handlerType, array $meta, array $params) {$provider = new $handlerType();$provider->handleTask($meta, $params);}, 10, 3);}
}
首先需要引入Action Scheduler
文件,然后對每個注冊的任務名,都使用監聽函數(這里實現為匿名函數)訂閱它的action hook
當事件觸發時,這個函數將獲得我們從TaskManager::submitTaask()
中傳入的3個參數:
$handlerType
: 任務處理邏輯的類名- 用于動態生成負責處理事件的handler對象
$provider = new $handlerType();
- 調用它的
Task::handleTask
方法
- 用于動態生成負責處理事件的handler對象
$meta
: 承載任務處理的元數據- 將其轉交給handler
$params
: 任務執行所需的數據- 將其轉交給handler
當某個任務真正觸發時,其對應的action hook
就會被觸發,然后由監聽函數轉發至真正的執行邏輯
Task類-任務處理類
Task代表了一個任務,它包括:
任務名、任務提交邏輯、任務執行邏輯
實現
在/App/Services/Task
創建Task.php
文件
編寫Task
類:
namespace App\Services\Task {use Exception;abstract class Task{public static string $taskName;/*** 提交一個該類型的任務,需要提供必要元數據和執行參數*/public static function submitTask(int $maxRetry, array $taskParams){}/*** 對應任務觸發時的執行邏輯* @param mixed $taskMeta 任務元數據* @param mixed $taskParams 任務處理數據* @throws Exception 若任務未全部完成,拋出異常*/public function handleTask(array $taskMeta, array $taskParams){// ...$this->handle($taskParams);// ...}/*** 任務邏輯主體* @param mixed $taskParams 傳入給該任務的參數* @return mixed */protected abstract function handle($taskParams);}
}
submitTask
submitTask()
是對TaskManager
提交函數的簡單封裝:
- 因為自身存儲了
$taskName
,因此它可以省略TaskManager
的第一個參數 - 元數據可以明確限定
- 比如我只需要重試次數,我就只把它當做輸入參數,然后封裝成
meta
- 比如我只需要重試次數,我就只把它當做輸入參數,然后封裝成
具體編寫為以下邏輯:
public static function submitTask(int $maxRetry, array $taskParams)
{$taskMeta = ['retry' => $maxRetry];TaskManager::submitTask(static::class, $taskMeta, $taskParams);
}
handleTask
前面也提到了,handleTask
是最終用于處理任務的邏輯
它其實有兩個作用:
- 準備、善后處理
- 接受任務元數據,先進行準備
- 處理任務
- 接受任務參數,真正處理任務
在這里,“準備、善后”部分我只用作處理重試邏輯
處理任務的邏輯我把它分割到另一個handle
方法,由子類實現
handleTask
應在成功時返回假,失敗時返回需要任務再次執行所需的參數
public function handleTask(array $taskMeta, array $taskParams)
{$pushBacks = $this->handle($taskParams);/*** 任務失敗了,需要重新push任務:* 1. 有需要執行的東西* 2. 有retry的定義且不為0*/if (!empty($pushBacks)) {if (!empty($taskMeta['retry'])) {$taskMeta['retry'] -= 1;TaskManager::submitTask(static::class, $taskMeta, $pushBacks);throw new Exception("Retries have been scheduled for some uncompleted tasks. params are: " . var_export($pushBacks, true));} elsethrow new Exception("Some of tasks failed. params are: " . var_export($pushBacks, true));}
}
exception
將由Action Scheduler
處理并顯示在控制臺中
PageViewTask-具體的任務類
真正的功能類繼承自Task類,這里需要編寫訪問遠程分析工具,并返回頁面瀏覽量的邏輯
因此命名為PageViewTask
同樣地,具體的PageViewTask
依靠于具體的遠程分析工具API
但在這層抽象中,我們只關注它們的共性:都需要失敗重試
實現
在/App/Services/Analytics
創建PageViewTask.php
文件
編寫PageViewTask
類:
namespace App\Services\Analytics {use App\Services\Task\Task;use Excecption;abstract class PageViewTask extends Task{public static string $taskName = 'nova_page_view_task';protected function handle($updatesList){foreach ($updatesList as $postId => $args) {try {$views = $this->getPostView($args);Analytics::setPageViews($postId, $views);// 刪掉unset($updatesList[$postId]);} catch (\Exception $e) {// 無視}}return $updatesList;}abstract protected function getPostView($args): int;}
}
首先別忘了我們需要給任務起名$taskName
php的靜態多態太爽了
C#什么時候能站起來()
handle()
這段邏輯呼應了我們遠古時代實現的AnalyticsProvider::$updatesList
邏輯
我們為了節省開銷,將多次閱讀量更新捆綁成一次提交
因此$updatesList
包含的是一個列表的待更新文章
我們在foreach循環中分割成單個更新,再次踢皮球到getPostView
交給子類處理
然后更新過程中的try ctach
就有點秀了:
- 如果沒出意外,我們把它從列表中移除,意為不再需要
- 如果出了意外,將被catch,并跳轉到foreach下個循環
所以一頓操作后,最終執行失敗的參數會保留在$updateList
中
將它返回,則會觸發父類的重試邏輯,再次壓入后臺進程隊列
妙妙妙妙妙
具體的PageViewTask
每個遠程統計工具實現不同,所以這層是必須的
這里還是以Umami
為例,其它的也差不多,只是需要修改訪問的參數
在/App/Services/Analytics/Umami
創建UmamiPageViewTask.php
文件
編寫UmamiPageViewTask
類:
namespace App\Services\Analytics\Umami {use Exception;use App\Services\Analytics\PageViewTask;class UmamiPageViewTask extends PageViewTask{protected function getPostView($args): int{// 獲取secret$baseUrl = of_get_option('analytics_api_domain', '');$authToken = of_get_option('analytics_api_token', '');// header$headers = array('Authorization' => "Bearer $authToken",'Content-Type' => 'application/json','Accept' => 'application/json',);// 向umami發送請求$umami_url = trailingslashit($baseUrl) . 'stats' . '?' . http_build_query(['startAt' => '0','endAt' => time() . '000','url' => $args['path'],]);$response = wp_remote_get($umami_url, ["headers" => $headers]);if (is_wp_error($response))throw new Exception($response->get_error_message());if (!empty($response['body']))$data = json_decode($response['body'], true);return \intval($data['uniques']['value']) ?? 0;}}
}
這段代碼因為比較簡單,也直接給出了
需要提醒的是:
- 重要數據不要硬編碼在代碼中,在WordPress中可以使用控制臺的設置功能
- 不過這里用到的
of_get_option
是裝了options framework
插件
- 不過這里用到的
- 大部分參數都可以自身構造而來,真正從外部接受的參數其實就只有:
$args['path']
- 我們在
$response
為WP_Error
時拋出異常,以示意出錯- 出錯的主要原因是網絡連接不佳,因此我們需要拋出錯誤,并重試
- 返回401,404等不算出錯,有返回的情況反而沒有重試的必要
- 因為試幾次都是一樣的
- 返回的處理取決于返回數據,這里是順著
Umami
的返回寫的
化身為神的最后一塊拼圖!
ruaaaaaaaaaaaaaaaaaaaaa
還記得嗎?之前的代碼有一段空了一塊
在UmamiAnalyticsProvider
提交任務時,沒有給出具體的操作代碼
因為當時還沒引入后面的一堆
但現在,我們都是懂哥了
加入這句代碼,讓這個系統運作起來:
class UmamiAnalyticsProvider extends AnalyticsProvider
{public function submitTasks(){if ($this->updatesList) {// <-- ?? submit this background task ?? -->UmamiPageViewTask::submitTask(1, $this->updatesList);}}
}
調用UmamiPageViewTask::submitTask()
- 參數1:重試1次
- 參數2:更新若干文章的必要數據
初始化
最后,我們需要初始化TaskManager
,如果不初始化,沒有任務會被監聽
不管需不需要加入新任務,請確保每次php執行都會執行以下語句:
use App\Services\Analytics as Analytics;
use App\Services\Task\TaskManager;Analytics\Analytics::setProvider(new Analytics\Umami\UmamiAnalyticsProvider());
TaskManager::registerTask(Analytics\PageViewTask::$taskName);
TaskManager::init();
- 記得設置
Provider
,當然你也可以傳入Closure
實現懶加載- e.g.
fn() => new UmamiAnalyticsProvider()
;
- e.g.
- 記得注冊(
TaskManager::registerTask
)所有可能執行的任務- 注冊開銷并不大,不要省
- 省了任務絕對執行不了
- 在最后,記得調用
init()
,否則不會進行任何實質初始化操作
小結
花了好久,寫了這么多
包括代碼,包括文章
這過程中不止一次問自己,至于嗎?
我最終的答案是肯定的
至于把東西封裝到類里嗎?多繞啊
確實繞,甚至是俄羅斯套娃
但在理解了繞之后,帶來的是可拓展性、可維護性
當然也可以直接一步步寫下來
實不相瞞,我第一個版本就是一步步寫下去的,根本就沒有一個類
但這樣做,怎么進行拓展?
不同的代碼混在一起,怎么維護?
所以就算是花更多時間,在把這坨屎跑起來之后,都要給它框架化、規則化
消化了這坨小屎,才能避免整個程序變成大屎
框架本身增加復雜性,但它也帶來了規則性:
有了框架,就很容易借用相似的邏輯
有了框架,一切東西都井然有序
現在這個版本,你可以隨意增加更多的Task,邏輯都是一樣的
多舒服啊?
至于把問題想那么復雜嗎?
至于訪問遠程統計工具獲取精準數據嗎?
至于搞緩存嗎?
至于搞后臺進程嗎?
沒錯,要實現“顯示瀏覽量”可以很簡單
甚至不精準的統計數據,可以增加我網站的顯示訪問量(草,現在全是個位數)
但當把程序當做一種藝術,它就不能容忍湊合
精益求精,才是工匠精神