mirror of
https://gitee.com/ulthon/ulthon_admin.git
synced 2026-08-30 20:55:32 +08:00
239 lines
8.8 KiB
PHP
239 lines
8.8 KiB
PHP
<?php
|
||
|
||
namespace app\admin\controller\xhprof;
|
||
|
||
use app\common\controller\AdminController;
|
||
use app\admin\service\annotation\ControllerAnnotation;
|
||
use app\admin\service\annotation\NodeAnotation;
|
||
use think\App;
|
||
|
||
/**
|
||
* XHProf 性能采样记录(只读列表)
|
||
* 数据由定时任务从 jsonl 导入,后台仅查看与函数级分析,不支持手动增删改。
|
||
*
|
||
* @ControllerAnnotation(title="XHProf 性能采样记录")
|
||
*/
|
||
class Run extends AdminController
|
||
{
|
||
|
||
use \app\admin\traits\Curd;
|
||
|
||
protected $sort = [
|
||
'request_time' => 'desc',
|
||
];
|
||
|
||
public function __construct(App $app)
|
||
{
|
||
parent::__construct($app);
|
||
|
||
$this->model = new \app\admin\model\XhprofRun();
|
||
}
|
||
|
||
/**
|
||
* @NodeAnotation(title="列表")
|
||
*/
|
||
public function index()
|
||
{
|
||
if ($this->request->isAjax()) {
|
||
if (input('selectFields')) {
|
||
return $this->selectList();
|
||
}
|
||
// wall_time 搜索下限:前端输入毫秒,换算为微秒后再构建查询条件
|
||
// 注意:Request::withGet 是整体替换,必须先取全量 GET 再合并写回
|
||
$filter = (string) $this->request->get('filter', '');
|
||
if ($filter !== '') {
|
||
$filterArr = json_decode($filter, true) ?: [];
|
||
if (isset($filterArr['wall_time']) && $filterArr['wall_time'] !== '') {
|
||
$filterArr['wall_time'] = (string) ((float) $filterArr['wall_time'] * 1000);
|
||
$this->request->withGet(array_merge($this->request->get(), [
|
||
'filter' => json_encode($filterArr, JSON_UNESCAPED_UNICODE),
|
||
]));
|
||
}
|
||
}
|
||
list($page, $limit, $where, $excludes, $request_options, $group) = $this->buildTableParames();
|
||
$count = $this->model
|
||
->where($where)
|
||
->group($group)
|
||
->count();
|
||
$list = $this->model
|
||
->where($where)
|
||
->page($page, $limit)
|
||
->order($this->sort)
|
||
->group($group)
|
||
->select();
|
||
$data = [
|
||
'code' => 0,
|
||
'msg' => '',
|
||
'count' => $count,
|
||
'data' => $list,
|
||
];
|
||
|
||
return json($data);
|
||
}
|
||
|
||
// 节点下拉选项(多节点环境区分数据来源)
|
||
$nodeIds = $this->model->distinct(true)->column('node_id');
|
||
$nodeSelect = array_combine($nodeIds, $nodeIds) ?: [];
|
||
$this->assign('select_list_node_id', $nodeSelect, true);
|
||
|
||
return $this->fetch();
|
||
}
|
||
|
||
/**
|
||
* 采样详情:页面/接口同体
|
||
*
|
||
* 页面模式(浏览器/弹层 iframe,无 Accept json):渲染详情页壳,顶部摘要在服务端渲染,
|
||
* 函数表与 SQL 摘要由 detail.js 以接口模式取同一 URL 补齐。
|
||
* 接口模式(Accept: application/json,框架 RequestBase::isAjax 判别):
|
||
* 返回函数级聚合分析 JSON(XHProf 边模型,XHGui 同款算法)。
|
||
*
|
||
* @NodeAnotation(title="详情")
|
||
*/
|
||
public function detail()
|
||
{
|
||
$id = (int) input('id', 0);
|
||
|
||
if ($this->request->isAjax()) {
|
||
return $this->detailAnalysis($id);
|
||
}
|
||
|
||
// 页面模式:渲染页面壳(get_page_data=1 时 fetch 自动转 assign 数据 JSON)
|
||
$run = $this->model->find($id);
|
||
if (!$run) {
|
||
$this->error('采样记录不存在或已被清理');
|
||
}
|
||
$runArr = $run->toArray();
|
||
$summary = [
|
||
'wall_time_ms' => round((int) $runArr['wall_time'] / 1000, 1),
|
||
'cpu_time_ms' => round((int) $runArr['cpu_time'] / 1000, 1),
|
||
'memory_peak_mb' => round((int) $runArr['memory_peak'] / 1048576, 2),
|
||
'request_time_text' => date('Y-m-d H:i:s', (int) $runArr['request_time']),
|
||
'url_display' => htmlspecialchars($runArr['url'] ?: '/', ENT_QUOTES, 'UTF-8'),
|
||
'node_display' => htmlspecialchars((string) $runArr['node_id'], ENT_QUOTES, 'UTF-8'),
|
||
];
|
||
$this->assign('run', $runArr);
|
||
$this->assign('summary', $summary);
|
||
|
||
return $this->fetch();
|
||
}
|
||
|
||
/**
|
||
* 函数级聚合分析(接口模式).
|
||
*
|
||
* XHProf 边模型(XHGui 同款):
|
||
* 1. inclusive(func) = Σ 所有 `X==>func` 边的 wt/ct/mu(callee 侧累计);
|
||
* main() 无入边,其 inclusive 由裸键 profile['main()'] 播种;
|
||
* 2. exclusive(func) = inclusive(func) - Σ 所有 `func==>callee` 边的 wt;
|
||
* 递归边(foo==>foo)一次计入 inclusive、一次扣减,对 exclusive 净影响为零;
|
||
* 3. 按 exclusive wt 降序取 top 100。
|
||
*
|
||
* 两遍 O(n) 遍历,不对 LONGTEXT 做 SQL 匹配,SQL 摘要为纯 PHP 过滤聚合结果。
|
||
*
|
||
* @param int $id 采样记录 id
|
||
*/
|
||
protected function detailAnalysis(int $id)
|
||
{
|
||
$run = $this->model->find($id);
|
||
if (!$run) {
|
||
$this->error('采样记录不存在或已被清理');
|
||
}
|
||
$detail = \app\admin\model\XhprofRunDetail::find($id);
|
||
if (!$detail) {
|
||
$this->error('采样明细数据缺失');
|
||
}
|
||
|
||
$raw = json_decode((string) $detail->profile_data, true);
|
||
$profile = (is_array($raw) && isset($raw['profile']) && is_array($raw['profile'])) ? $raw['profile'] : [];
|
||
|
||
// 第一遍:裸键播种(main())+ 边累计 callee 侧 inclusive
|
||
$inclWt = [];
|
||
$inclCt = [];
|
||
$inclMu = [];
|
||
foreach ($profile as $key => $stat) {
|
||
$wt = (int) ($stat['wt'] ?? 0);
|
||
$ct = (int) ($stat['ct'] ?? 0);
|
||
$mu = (int) ($stat['mu'] ?? 0);
|
||
if (strpos($key, '==>') === false) {
|
||
$fn = $key;
|
||
$inclWt[$fn] = ($inclWt[$fn] ?? 0) + $wt;
|
||
$inclCt[$fn] = ($inclCt[$fn] ?? 0) + $ct;
|
||
$inclMu[$fn] = ($inclMu[$fn] ?? 0) + $mu;
|
||
continue;
|
||
}
|
||
[$caller, $callee] = explode('==>', $key, 2);
|
||
$inclWt[$callee] = ($inclWt[$callee] ?? 0) + $wt;
|
||
$inclCt[$callee] = ($inclCt[$callee] ?? 0) + $ct;
|
||
$inclMu[$callee] = ($inclMu[$callee] ?? 0) + $mu;
|
||
}
|
||
|
||
// 第二遍:exclusive = inclusive - Σ 出边 wt(caller 只出现无入边记录时兜底 0)
|
||
$exclWt = $inclWt;
|
||
foreach ($profile as $key => $stat) {
|
||
if (strpos($key, '==>') === false) {
|
||
continue;
|
||
}
|
||
[$caller, $callee] = explode('==>', $key, 2);
|
||
if (!isset($exclWt[$caller])) {
|
||
$exclWt[$caller] = 0;
|
||
$inclCt[$caller] = $inclCt[$caller] ?? 0;
|
||
$inclMu[$caller] = $inclMu[$caller] ?? 0;
|
||
}
|
||
$exclWt[$caller] -= (int) ($stat['wt'] ?? 0);
|
||
}
|
||
|
||
// 汇总函数行 + SQL 摘要(think\db 前缀,大小写不敏感;独占耗时合计避免重复计数)
|
||
$rows = [];
|
||
$sqlCt = 0;
|
||
$sqlWt = 0;
|
||
$sqlFnCount = 0;
|
||
foreach ($inclWt as $fn => $wt) {
|
||
$excl = max(0, $exclWt[$fn] ?? 0);
|
||
$rows[] = [
|
||
'fn' => $fn,
|
||
'ct' => $inclCt[$fn] ?? 0,
|
||
'excl_wt' => $excl,
|
||
'incl_wt' => $wt,
|
||
'mu' => $inclMu[$fn] ?? 0,
|
||
];
|
||
if (stripos($fn, 'think\\db') === 0) {
|
||
$sqlCt += $inclCt[$fn] ?? 0;
|
||
$sqlWt += $excl;
|
||
$sqlFnCount++;
|
||
}
|
||
}
|
||
// caller-only 出现在 exclWt 但不在 inclWt 的函数(独占已兜底 0),不进入函数表
|
||
usort($rows, function ($a, $b) {
|
||
return $b['excl_wt'] <=> $a['excl_wt'];
|
||
});
|
||
$functions = array_slice($rows, 0, 100);
|
||
|
||
$wallTime = (int) $run->wall_time ?: ($inclWt['main()'] ?? 0);
|
||
$sqlPct = $wallTime > 0 ? round($sqlWt * 100 / $wallTime, 2) : 0.0;
|
||
|
||
$summary = [
|
||
'id' => (int) $run->id,
|
||
'method' => $run->method,
|
||
'url' => $run->url,
|
||
'simple_url' => $run->simple_url,
|
||
'node_id' => $run->node_id,
|
||
'wall_time' => $wallTime,
|
||
'cpu_time' => (int) $run->cpu_time,
|
||
'memory_peak' => (int) $run->memory_peak,
|
||
'request_time' => (float) $run->request_time,
|
||
'request_time_text' => date('Y-m-d H:i:s', (int) $run->request_time),
|
||
'function_total' => count($rows),
|
||
];
|
||
$payload = [
|
||
'summary' => $summary,
|
||
'sql_summary' => [
|
||
'fn_count' => $sqlFnCount,
|
||
'ct' => $sqlCt,
|
||
'wt' => $sqlWt,
|
||
'wt_pct' => $sqlPct,
|
||
],
|
||
'functions' => $functions,
|
||
];
|
||
|
||
$this->success('获取成功', $payload);
|
||
}
|
||
} |