这篇文章也可以在我的博客中查看
关于本文
专业的流量统计系统能够相对真实地反应网站的访问情况。
这些数据可以在后台很好地进行分析统计,但有时我们希望在网站前端展示一些数据
最常见的情景就是:展示页面的浏览量
这简单的操作当然也可以通过简单的计数器实现,但可能会造成重复统计(比如同一个用户点击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,逻辑都是一样的
多舒服啊?
至于把问题想那么复杂吗?
至于访问远程统计工具获取精准数据吗?
至于搞缓存吗?
至于搞后台进程吗?
没错,要实现“显示浏览量”可以很简单
甚至不精准的统计数据,可以增加我网站的显示访问量(草,现在全是个位数)
但当把程序当做一种艺术,它就不能容忍凑合
精益求精,才是工匠精神