mirror of
https://github.com/2234839/web-font.git
synced 2026-09-04 23:02:27 +08:00
feat(stats): 统计计数跨重启持久化 + 依赖升级
- 新增 stats_store.ts:防抖写盘 font/.stats.json,热路径零磁盘 IO - shared.ts 暴露 initStats/markStatsDirty/snapshotStats - app.ts 启动恢复累计计数 + 退出前尽力落盘 - 路由热路径打脏标记(防抖 30s 批量落盘) - 清理未用变量/导入/常量 - 升级依赖:vue 3.6.0-beta.17、typescript 7.0.2、vite 8.1.5 等 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f7a2a4dc86
commit
d68551d23e
@ -1,8 +1,9 @@
|
||||
import { mimeTypes } from "./server/mime_type";
|
||||
import type { cMiddleware } from "./server/req_res";
|
||||
import { SimpleHttpServer } from "./server/server";
|
||||
import { path_join, readFile, stat, readdir, mkdir } from "./interface";
|
||||
import { parseUrl, jsonResponse, stats } from "./shared";
|
||||
import { path_join, readFile, stat, mkdir } from "./interface";
|
||||
import { parseUrl, jsonResponse, stats, initStats, markStatsDirty } from "./shared";
|
||||
import { flushStatsSyncSafe } from "./stats_store";
|
||||
import { enableTempUpload, adminApiKey } from "./config";
|
||||
import { handleListFonts } from "./routes/fonts";
|
||||
import { handleGetConfig } from "./routes/config";
|
||||
@ -27,6 +28,7 @@ async function ensureDirectories() {
|
||||
|
||||
const logMiddleware: cMiddleware = async (req, res, next) => {
|
||||
stats.totalRequests++;
|
||||
markStatsDirty();
|
||||
const t1 = Date.now();
|
||||
const r = await next(req, res);
|
||||
const t2 = Date.now();
|
||||
@ -35,7 +37,7 @@ const logMiddleware: cMiddleware = async (req, res, next) => {
|
||||
return r;
|
||||
};
|
||||
|
||||
const staticFileMiddleware: cMiddleware = async function (req, res, next) {
|
||||
const staticFileMiddleware: cMiddleware = async function (req, _res, next) {
|
||||
let newRes: Response;
|
||||
if (req.method === "GET") {
|
||||
const url = parseUrl(req);
|
||||
@ -149,6 +151,15 @@ const uploadSizeMiddleware: cMiddleware = async (req, res, next) => {
|
||||
};
|
||||
|
||||
async function main() {
|
||||
/** 最早期恢复累计计数:之后的请求计数会累加在历史值之上 */
|
||||
await initStats();
|
||||
|
||||
/** 优雅退出时尽力落盘最后一次增量(SIGKILL 时由定时器兜底) */
|
||||
globalThis.process?.on?.("beforeExit", () => {
|
||||
markStatsDirty();
|
||||
flushStatsSyncSafe();
|
||||
});
|
||||
|
||||
await ensureDirectories();
|
||||
|
||||
const server = new SimpleHttpServer({ port: 8087 });
|
||||
|
||||
@ -24,7 +24,6 @@ const LT_SINGLE = 1;
|
||||
const LT_MULTIPLE = 2;
|
||||
const LT_ALTERNATE = 3;
|
||||
const LT_LIGATURE = 4;
|
||||
const LT_REVERSE_CHAIN = 5;
|
||||
const LT_CHAIN = 6;
|
||||
const LT_EXTENSION = 7;
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@ import { jsonResponse } from "../shared";
|
||||
import { enableTempUpload, adminApiKey } from "../config";
|
||||
|
||||
/** GET /api/config — 返回公开配置 */
|
||||
export async function handleGetConfig(req: Request, res: Response) {
|
||||
export async function handleGetConfig(req: Request, _res: Response) {
|
||||
return {
|
||||
req,
|
||||
res: jsonResponse({
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { jsonResponse, parseUrl } from "../shared";
|
||||
import { readdir, stat } from "../interface";
|
||||
import { jsonResponse } from "../shared";
|
||||
import { readdir } from "../interface";
|
||||
import { fontDirs } from "../config";
|
||||
|
||||
/** GET /api/fonts — 列出所有可用字体 */
|
||||
export async function handleListFonts(req: Request, res: Response) {
|
||||
export async function handleListFonts(req: Request, _res: Response) {
|
||||
const allFonts: Array<{ name: string; temporary: boolean }> = [];
|
||||
|
||||
for (const dir of fontDirs) {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { jsonResponse, stats, subsetCache, fontBufferCache } from "../shared";
|
||||
|
||||
/** GET /api/stats — 返回运行时统计 */
|
||||
export async function handleStats(req: Request, res: Response) {
|
||||
export async function handleStats(req: Request, _res: Response) {
|
||||
return {
|
||||
req,
|
||||
res: jsonResponse({
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { fontSubset } from "../font_util/font";
|
||||
import type { FontEditor } from "../../vendor/fonteditor-core/lib/ttf/font.js";
|
||||
import { parseUrl, jsonResponse, stats, subsetCache, findFontPath, readFontBuffer } from "../shared";
|
||||
import { parseUrl, stats, subsetCache, findFontPath, readFontBuffer, markStatsDirty } from "../shared";
|
||||
|
||||
/**
|
||||
* 进程启动时戳(模块加载时取一次,进程重启即变化)
|
||||
@ -53,9 +53,11 @@ export async function handleFontSubset(req: Request, res: Response) {
|
||||
const cacheKey = `${SUBSET_CACHE_KEY}:${fontPath}:${outType}:${text}`;
|
||||
stats.subsetRequests++;
|
||||
stats.totalChars += text.length;
|
||||
markStatsDirty();
|
||||
const cached = subsetCache.get(cacheKey);
|
||||
if (cached) {
|
||||
stats.subsetCacheHits++;
|
||||
markStatsDirty();
|
||||
const contentTypes: Record<string, string> = { ttf: "font/ttf", woff2: "font/woff2" };
|
||||
return {
|
||||
req,
|
||||
|
||||
@ -3,7 +3,7 @@ import { parseMultipart } from "../multipart";
|
||||
import { handleTempUpload, handleAdminUpload } from "../upload";
|
||||
|
||||
/** POST /api/upload?mode=temp|admin — 上传字体 */
|
||||
export async function handleUpload(req: Request, res: Response) {
|
||||
export async function handleUpload(req: Request, _res: Response) {
|
||||
const url = parseUrl(req);
|
||||
const mode = url.searchParams.get("mode") ?? "temp";
|
||||
|
||||
|
||||
@ -175,7 +175,7 @@ async function connectionHandle(
|
||||
(rawReq as Request & { _bodyBuffer?: ArrayBuffer })._bodyBuffer = bodyArrayBuffer;
|
||||
const rawRes = new Response();
|
||||
|
||||
const { req, res } = await handle(rawReq, rawRes);
|
||||
const { res } = await handle(rawReq, rawRes);
|
||||
const resWriter = connection.writable.getWriter();
|
||||
let headerText: string[] = [];
|
||||
res.headers.forEach((value, key) => {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { fontDirs, subsetCacheMaxSize } from "./config";
|
||||
import { LruCache } from "./lru_cache";
|
||||
import { path_join, readFile, stat, readdir } from "./interface";
|
||||
import { loadPersistedStats, scheduleStatsFlush, type PersistedStats } from "./stats_store";
|
||||
|
||||
/** 解析请求 URL(req.url 只有路径,需要补全协议和主机才能用 URL API) */
|
||||
export function parseUrl(req: Request): URL {
|
||||
@ -15,20 +16,55 @@ export function jsonResponse(data: unknown, status = 200) {
|
||||
});
|
||||
}
|
||||
|
||||
/** 运行时统计 */
|
||||
/** 运行时统计(累计计数跨重启持久化,startTime 为本次进程启动时刻) */
|
||||
export const stats = {
|
||||
/** 服务启动时间戳 */
|
||||
/** 服务启动时间戳(进程级,不持久化) */
|
||||
startTime: Date.now(),
|
||||
/** 总请求数 */
|
||||
/** 总请求数(持久化累计) */
|
||||
totalRequests: 0,
|
||||
/** 字体裁剪请求次数(含缓存命中) */
|
||||
/** 字体裁剪请求次数(含缓存命中;持久化累计) */
|
||||
subsetRequests: 0,
|
||||
/** 字体裁剪缓存命中次数 */
|
||||
/** 字体裁剪缓存命中次数(持久化累计) */
|
||||
subsetCacheHits: 0,
|
||||
/** 累计裁剪文字字符数 */
|
||||
/** 累计裁剪文字字符数(持久化累计) */
|
||||
totalChars: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* 从持久化文件恢复累计计数
|
||||
*
|
||||
* 在 main() 最早期 await 一次:让计数器从历史值起步,实现跨重启持久化。
|
||||
* startTime 始终重置为当前时刻——uptime 度量的是本次进程。
|
||||
*/
|
||||
export async function initStats(): Promise<void> {
|
||||
const persisted = await loadPersistedStats();
|
||||
stats.startTime = persisted.startTime;
|
||||
stats.totalRequests = persisted.totalRequests;
|
||||
stats.subsetRequests = persisted.subsetRequests;
|
||||
stats.subsetCacheHits = persisted.subsetCacheHits;
|
||||
stats.totalChars = persisted.totalChars;
|
||||
}
|
||||
|
||||
/** 取出需要持久化的累计计数字段 */
|
||||
export function snapshotStats(): PersistedStats {
|
||||
return {
|
||||
totalRequests: stats.totalRequests,
|
||||
subsetRequests: stats.subsetRequests,
|
||||
subsetCacheHits: stats.subsetCacheHits,
|
||||
totalChars: stats.totalChars,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记统计已变更,触发防抖落盘
|
||||
*
|
||||
* 请求热路径调用:仅做一次对象赋值 + 幂等的定时器检查,绝不阻塞。
|
||||
* 真正的磁盘 IO 由 stats_store 内部按 FLUSH_INTERVAL 批量执行。
|
||||
*/
|
||||
export function markStatsDirty(): void {
|
||||
scheduleStatsFlush(snapshotStats());
|
||||
}
|
||||
|
||||
/** 字体文件 LRU 缓存,最多保留 3 个最近使用的字体 buffer(按条目数淘汰) */
|
||||
export const fontBufferCache = new LruCache<ArrayBuffer>({ maxSize: 3 });
|
||||
|
||||
|
||||
102
backend/stats_store.ts
Normal file
102
backend/stats_store.ts
Normal file
@ -0,0 +1,102 @@
|
||||
import { readFile, writeFile, mkdir } from "./interface";
|
||||
|
||||
/**
|
||||
* 统计数据持久化
|
||||
*
|
||||
* 目标:累计计数(totalRequests 等)跨进程重启保留,且不拖慢请求热路径。
|
||||
*
|
||||
* 性能设计:
|
||||
* - 启动时读一次历史值作为计数起点(一次 await,仅在 main() 初始化阶段)
|
||||
* - 请求热路径只增内存计数器,绝不触碰磁盘
|
||||
* - 写盘走防抖定时器:累积变更后每隔 FLUSH_INTERVAL 落盘一次;
|
||||
* 即使容器被 SIGKILL,最多丢失 FLUSH_INTERVAL 内的增量
|
||||
* - 无脏数据时不写盘(脏标记),避免空转 IO
|
||||
*/
|
||||
|
||||
/** 持久化文件位置:font/* 已在 .gitignore,且为运行时可写目录 */
|
||||
const STATS_FILE = "font/.stats.json";
|
||||
|
||||
/** 持久化的累计计数字段(不含 startTime——uptime 是进程级,不可叠加) */
|
||||
export interface PersistedStats {
|
||||
totalRequests: number;
|
||||
subsetRequests: number;
|
||||
subsetCacheHits: number;
|
||||
totalChars: number;
|
||||
}
|
||||
|
||||
/** 落盘防抖间隔(毫秒):在请求热路径之外的最低频写盘,平衡丢失量与 IO */
|
||||
const FLUSH_INTERVAL = 30_000;
|
||||
|
||||
/** 待落盘的快照;写入完成后才清空,避免写入期间的增量丢失 */
|
||||
let pending: PersistedStats | null = null;
|
||||
|
||||
/** 防抖定时器句柄 */
|
||||
let flushTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
/**
|
||||
* 启动时加载历史累计计数
|
||||
*
|
||||
* 文件不存在或损坏时安全降级为 0——统计不是关键路径,宁可丢历史也不让服务起不来。
|
||||
* 返回的 startTime 始终取当前时刻:uptime 度量的是「本次进程」的运行时长。
|
||||
*/
|
||||
export async function loadPersistedStats(): Promise<PersistedStats & { startTime: number }> {
|
||||
try {
|
||||
const raw = await readFile(STATS_FILE);
|
||||
const parsed = JSON.parse(new TextDecoder().decode(raw)) as Partial<PersistedStats>;
|
||||
return {
|
||||
totalRequests: parsed.totalRequests ?? 0,
|
||||
subsetRequests: parsed.subsetRequests ?? 0,
|
||||
subsetCacheHits: parsed.subsetCacheHits ?? 0,
|
||||
totalChars: parsed.totalChars ?? 0,
|
||||
startTime: Date.now(),
|
||||
};
|
||||
} catch {
|
||||
/** 首次启动 / 文件缺失 / JSON 损坏:从 0 起步 */
|
||||
return { totalRequests: 0, subsetRequests: 0, subsetCacheHits: 0, totalChars: 0, startTime: Date.now() };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记一次需要落盘的快照
|
||||
*
|
||||
* 由 stats 模块在计数变化后调用。仅在尚未排队时启动防抖定时器,
|
||||
* 避免高频请求每次都重排定时器(保证最长 FLUSH_INTERVAL 内必落盘)。
|
||||
*/
|
||||
export function scheduleStatsFlush(snapshot: PersistedStats): void {
|
||||
pending = snapshot;
|
||||
if (flushTimer !== null) return;
|
||||
flushTimer = setInterval(flushStats, FLUSH_INTERVAL);
|
||||
}
|
||||
|
||||
/** 取快照并写盘;无变更时直接跳过 */
|
||||
async function flushStats(): Promise<void> {
|
||||
if (pending === null) return;
|
||||
/** 先取走快照再写:写盘期间新到的增量会进下一次 pending,不会丢 */
|
||||
const snapshot = pending;
|
||||
pending = null;
|
||||
try {
|
||||
/** font/ 目录通常已由 ensureDirectories 创建,这里兜底以防万一 */
|
||||
await mkdir("font");
|
||||
await writeFile(STATS_FILE, new TextEncoder().encode(JSON.stringify(snapshot)));
|
||||
} catch (err) {
|
||||
/** 写盘失败不影响服务:把快照放回 pending,等下个周期重试 */
|
||||
console.log("[stats] flush failed:", err);
|
||||
pending = snapshot;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 进程退出前的同步落盘
|
||||
*
|
||||
* LLRT/容器被 SIGKILL 时不会触发,定时器才是主力。
|
||||
* 此钩子仅在优雅退出时减少最后一次增量丢失。
|
||||
*/
|
||||
export function flushStatsSyncSafe(): void {
|
||||
if (flushTimer !== null) {
|
||||
clearInterval(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
/** markStatsDirty() 已把最新计数放进 pending;此处尽力触发最后一次异步落盘。
|
||||
* LLRT/容器被 SIGKILL 时不会到达,定时器才是落盘主力。 */
|
||||
flushStats();
|
||||
}
|
||||
20
package.json
20
package.json
@ -17,21 +17,21 @@
|
||||
"release": "pnpm build && pnpm build_backend && pnpm docker_build && pnpm docker_push"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "3.6.0-beta.10",
|
||||
"web-streams-polyfill": "^4.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.9.1",
|
||||
"@vitejs/plugin-vue": "^6.0.7",
|
||||
"@types/node": "^26.1.1",
|
||||
"@vitejs/plugin-vue": "^6.0.8",
|
||||
"@xmldom/xmldom": "^0.9.10",
|
||||
"jsdom": "^29.1.1",
|
||||
"pngjs": "^7.0.0",
|
||||
"puppeteer": "^25.1.0",
|
||||
"tsdown": "^0.22.1",
|
||||
"typescript": "^6.0.3",
|
||||
"undici": "^8.3.0",
|
||||
"vite": "^8.0.14",
|
||||
"vite-plugin-pilot": "^1.0.31",
|
||||
"vitest": "^4.1.7"
|
||||
"puppeteer": "^25.3.0",
|
||||
"tsdown": "^0.22.13",
|
||||
"typescript": "^7.0.2",
|
||||
"undici": "^8.8.0",
|
||||
"vite": "^8.1.5",
|
||||
"vite-plugin-pilot": "^1.0.35",
|
||||
"vitest": "^4.1.10",
|
||||
"vue": "3.6.0-beta.17"
|
||||
}
|
||||
}
|
||||
1768
pnpm-lock.yaml
generated
1768
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user