专业的流量统计系统能够相对真实地反应网站的访问情况。
这些数据可以在后台很好地进行分析统计,但有时我们希望在网站前端展示一些数据
最常见的情景就是:展示页面的浏览量
这简单的操作当然也可以通过简单的计数器实现,但可能会造成重复统计(比如同一个用户点击10次)
流量分析工具所提供的准确性是不可比拟的
因此这篇文章我们就来实现如何将流量分析数据搬到网站展示,做到:
为完成这些目标,需要一些前提准备:
Google Analytics
、Umami
(本文将以Umami为例)阅读量实时性并不强,我们无须(也不可能)每次页面访问都从远程分析工具获取数据
频繁访问很有可能会被禁止访问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)
{
}
}
}
本文实现需要依赖$post->ID作为唯一标识符
如果你希望实现任何页面的阅读量展示,你需要:
- 使用
url[path]
的md5 hash
作为唯一标识符- 使用自定义数据库表存储阅读量:
(url_md5, page_view)
需要做什么?
当访客来访时,需要展示阅读量,此时:
WP_Post
实例
前面提到了缓存过期是发出数据同步请求的标志,但我们不希望重复发起请求,
因此缓存未命中时需要马上再次写入缓存。
虽然数据是旧的,但不急。我们可以在数据同步时强制刷新它
大部分都好处理,异步请求比较麻烦,先卖个关子
同时我们还为阅读量定义了缓存键值和在数据库的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;
// 读取数据库记录,这将是最后能够返回的值
$pageViews = get_post_meta($post->ID, Analytics::$pageViewMetaKey, true) ?: 0;
if (is_singular()) {
// 记录更新请求
// <-- ?? async call to update ?? -->
// 重写缓存
set_transient(Analytics::pageViewsCacheKey($post->ID), $pageViews, static::$pageViewCacheTime);
}
return $pageViews;
}
为了减少不必要的请求数,我们使用is_singular()
确保当前访问的是文章页面
否则文章大概率是被输出为概要、列表,并不存在真的访问,没必要更新
这个函数用于写入本地的数据存储,包括缓存和数据库
注意,它并不包含异步更新的过程,只是异步更新的结果需要借助它写入:
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);
}
好了,该想想怎么访问远程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);
else
static::$_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
需要怎么编写
不同的provider有不同的访问逻辑,但至少有没有些共性?
还真有!
Provider负责组织后台任务,但每次请求更新都立刻组织一个后台任务还是很恐怖的。
比如:一个页面有100篇文章
如果我们需要为每篇文章组织一个任务
此时需要组织100个任务
虽然我们这里使用
is_singluar()
限制了只在文章页面发起更新请求(见上文)
但这里讨论的问题是更一般化的
我们编写的代码可不只是为了显示访问量一种数据而已
因为php无守护进程,每个后台任务其实需要通过写数据库进行任务信息持久化
因此组织100个后台任务,意味着访问数据库上百次
而组织任务这个过程,是同步的、阻塞的
用户会看着页面转十秒加载不出来
但说到底,有没有必要把它视为100个任务?不能批处理一下吗?
当然可以,而且这就是不同AnalyticsProvider
的一个共性。
在/App/Services/Analytics/
创建AnalyticsProvider.php
文件
编写Analytics
类
namespace App\Services\Analytics {
abstract class AnalyticsProvider
{
}
}
这是登记更新任务的逻辑
上文说了,我们不希望立刻生成后台任务,而是记录它:
protected array $updatesList = [];
/**
* 将目标加入浏览量更新任务队列
* @param array $args 查询需要的参数,与具体实现有关
*/
public function pushUpdatePostViews(WP_Post $post, array $args = [])
{
$this->updatesList[$post->ID] = $args;
}
$args
主要是请求API时的参数,比如:时间段?目标地址?国家?……
这与具体数据源的实现有关,但总之,我们需要把这些可能用到的数据存到$updatesList
里
$updatesList
记录了本次请求中,所有需要请求阅读量更新的文章和相应参数
但我们如何把它加到后台任务?
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)
{
// ...
if (is_singular()) {
// 记录更新请求
// <-- ?? async call to update ?? -->
static::getProvider()->pushUpdatePostViews($post);
// ...
}
// ...
}
注意:static
在上下文中就是Analytics
主要完成两件事:
以下我以
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
基本上是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无效eg_midnight_log
hookeg_midnight_log
hook的逻辑所以坑点就在于add_action( 'eg_midnight_log', 'eg_log_action_data' );
必须在执行任务时加入,在制定任务时加入是无效的
而我们的目标,则是:
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
列表
用于提交“保证任务触发时正常执行”所需的一切数据,包括:
因此它需要传入3个参数:
$handlerType
: 承载任务处理逻辑的类名
Task
,包含一个handleTask
方法$taskMeta
: 承载任务处理的元数据
$taskParams
: 任务执行所需的数据
因此可以写出这样的代码:
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
)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
: 任务处理逻辑的类名
$provider = new $handlerType();
Task::handleTask
方法$meta
: 承载任务处理的元数据
$params
: 任务执行所需的数据
当某个任务真正触发时,其对应的action hook
就会被触发,然后由监听函数转发至真正的执行逻辑
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()
是对TaskManager
提交函数的简单封装:
$taskName
,因此它可以省略TaskManager
的第一个参数meta
具体编写为以下逻辑:
public static function submitTask(int $maxRetry, array $taskParams)
{
$taskMeta = ['retry' => $maxRetry];
TaskManager::submitTask(static::class, $taskMeta, $taskParams);
}
前面也提到了,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));
} else
throw new Exception("Some of tasks failed. params are: " . var_export($pushBacks, true));
}
}
exception
将由Action Scheduler
处理并显示在控制台中
真正的功能类继承自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
就有点秀了:
所以一顿操作后,最终执行失败的参数会保留在$updateList
中
将它返回,则会触发父类的重试逻辑,再次压入后台进程队列
妙妙妙妙妙
每个远程统计工具实现不同,所以这层是必须的
这里还是以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;
}
}
}
这段代码因为比较简单,也直接给出了
需要提醒的是:
of_get_option
是装了options framework
插件$args['path']
$response
为WP_Error
时抛出异常,以示意出错
Umami
的返回写的ruaaaaaaaaaaaaaaaaaaaaa
还记得吗?之前的代码有一段空了一块
在UmamiAnalyticsProvider
提交任务时,没有给出具体的操作代码
因为当时还没引入后面的一堆
但现在,我们都是懂哥了
加入这句代码,让这个系统运作起来:
class UmamiAnalyticsProvider extends AnalyticsProvider
{
public function submitTasks()
{
if ($this->updatesList) {
// <-- ?? submit this background task ?? -->
UmamiPageViewTask::submitTask(1, $this->updatesList);
}
}
}
调用UmamiPageViewTask::submitTask()
最后,我们需要初始化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
实现懒加载
fn() => new UmamiAnalyticsProvider()
;TaskManager::registerTask
)所有可能执行的任务
init()
,否则不会进行任何实质初始化操作花了好久,写了这么多
包括代码,包括文章
这过程中不止一次问自己,至于吗?
我最终的答案是肯定的
确实绕,甚至是俄罗斯套娃
但在理解了绕之后,带来的是可拓展性、可维护性
当然也可以直接一步步写下来
实不相瞒,我第一个版本就是一步步写下去的,根本就没有一个类
但这样做,怎么进行拓展?
不同的代码混在一起,怎么维护?
所以就算是花更多时间,在把这坨屎跑起来之后,都要给它框架化、规则化
消化了这坨小屎,才能避免整个程序变成大屎
框架本身增加复杂性,但它也带来了规则性:
有了框架,就很容易借用相似的逻辑
有了框架,一切东西都井然有序
现在这个版本,你可以随意增加更多的Task,逻辑都是一样的
多舒服啊?
至于访问远程统计工具获取精准数据吗?
至于搞缓存吗?
至于搞后台进程吗?
没错,要实现“显示浏览量”可以很简单
甚至不精准的统计数据,可以增加我网站的显示访问量(草,现在全是个位数)
但当把程序当做一种艺术,它就不能容忍凑合
精益求精,才是工匠精神