# 我的站点(全文) > Vabora开发极客 —— 聚焦 PHP 8、Vabora Framework、前端工程化与 AI 编程实践的个人技术博客,持续输出可落地的工程方案与源码解析。 本文件包含站点近期文章的 Markdown 全文,供生成式引擎检索与引用。请保留原文链接与作者信息。 ## 文章 ### SQLite 并发写入实测:WAL、busy_timeout 与 1000 次请求零丢失 - 原文:https://vabora.dev/posts/sqlite-wal-concurrency - 发布时间:2026-09-19 09:30:00 - 分类:技术分享 - 标签:架构设计、数据库、SQLite ## 背景 把 SQLite 用在正式站点上,被问得最多的一句是:「并发写会不会丢数据?」 这个担心不是空穴来风。SQLite 默认是 `journal_mode=delete`,写操作会锁整库;一旦有并发写入,后到的会立刻拿到 `SQLITE_BUSY`。如果你的代码用 `try/catch` 把异常吞掉(很多"访问统计"就是这种写法),表现就是**静默丢数据**——页面正常、日志干净、统计慢慢偏少,等到发现时已经无从对账。 与其争论"能不能扛",不如测出来。 ## 做法 ### 1. 开 WAL,并把等待时间交给数据库 ```php // 连接建立后立刻执行(SQLite 专用) PRAGMA journal_mode = WAL; // 读写不再互斥:读不阻塞写,写不阻塞读 PRAGMA busy_timeout = 5000; // 拿不到写锁时等 5 秒,而不是立刻抛 SQLITE_BUSY PRAGMA synchronous = NORMAL; // WAL 下的推荐值:崩溃安全性够用,写入快得多 PRAGMA foreign_keys = ON; ``` 三个参数里,**`busy_timeout` 是决定"丢不丢数据"的那一个**。WAL 让读写不互斥,但写与写之间仍然串行;有了超时等待,突发并发只是"排队变慢",而不是"直接失败"。 ### 2. 压测要带真实写入 只压 GET 静态页没有意义——要压的是「一次请求写一次库」的路径(本站每次前台访问都会写一条访问记录)。压测脚本 `tools/bench.php` 用 `curl_multi` 实现,零依赖: ```bash # 8 个 worker 的开发服务器(模拟 PHP-FPM 的多进程) PHP_CLI_SERVER_WORKERS=8 php -S 127.0.0.1:8000 -t public tools/serve-router.php php tools/bench.php http://127.0.0.1:8000/services 50 800 php tools/bench.php http://127.0.0.1:8000/posts/xxx 100 1000 ``` ### 3. 关键一步:核对写入条数 压测跑完,别只看延迟,**去数一下库里的行数对不对**: ```bash sqlite3 storage/database/vabora.sqlite 'select count(*) from v_visits;' ``` ## 结果 本机实测(PHP 8.5 + SQLite,8 worker,开启 gzip): | 场景 | 并发 | 请求 | 成功 | 吞吐 | P50 | P95 | P99 | 写入 | |---|---|---|---|---|---|---|---|---| | 首页 | 20 | 300 | 300 | 952/s | 18.3ms | 31.6ms | 39.3ms | 301/300 ✓ | | 服务列表 | 50 | 800 | 800 | 1026/s | 44.6ms | 61.6ms | 82.3ms | 801/800 ✓ | | 文章详情 | 100 | 1000 | 1000 | 1295/s | 71.4ms | 88.3ms | 108.8ms | 1001/1000 ✓ | (+1 是预热请求。写入数**不少于**请求数即为零丢失。) 100 并发下 P95 仍在 100ms 以内,没有一条 `SQLITE_BUSY`,没有一条统计数据丢失。对一台单机博客/企业官网来说,这个余量足够了。 ## 取舍 - **`synchronous=NORMAL` 而不是 `FULL`**:WAL 下 `NORMAL` 在断电时最多丢失最近若干个事务,但不会损坏数据库文件。官网的访问统计丢几条无所谓,换来的是写入吞吐明显提升;如果记的是订单,请改回 `FULL`。 - **`busy_timeout=5000` 而不是更大**:等 5 秒已经远超正常写入耗时(毫秒级)。真等到 5 秒,说明有长事务在卡锁,此时让请求失败并暴露问题,比无限等待更健康。 - **SQLite 的边界**:单机、单写者场景非常合适;一旦要跨机器横向扩展、或者有持续的高频写入(每秒几百次以上),就换成 MySQL/PostgreSQL——`php vabora database:switch` 可以把数据搬过去,但**架构该换的时候不要硬撑**。 --- ### 用 PHP 8.5 从零构建零依赖 Web 框架:路由与容器 - 原文:https://vabora.dev/posts/php85-zero-dependency-framework - 发布时间:2026-09-17 12:05:53 - 分类:技术分享 - 标签:架构设计、PHP 8、Vabora Framework 零依赖不是目的,**可控**才是。当框架的每一行代码你都能读懂,线上排障就不再是“玄学”。 ## 为什么选择零依赖 Composer 生态很繁荣,但也带来三个现实问题: | 问题 | 表现 | 零依赖的收益 | | --- | --- | --- | | 依赖膨胀 | 一个博客装进 40+ 个包 | 部署包只有几百 KB | | 版本地狱 | 升级一个包牵连一片 | 只有自己的代码要维护 | | 排障困难 | 堆栈 90% 在 vendor | 断点直接落在业务代码 | > 依赖越少,不可控的部分就越少。 ## 路由:从正则到编译缓存 最朴素的路由就是一个 `path => handler` 映射表,但动态参数必须编译成正则: ```php public static function get(string $path, mixed $action): RouteItem { $pattern = preg_replace('/\{(\w+)\}/', '(?P<$1>[^/]+)', $path); return self::collection()->add(['GET'], $path, $action, $pattern); } ``` 几个必须处理的细节: - 参数默认约束为 `[^/]+`,需要数字时用 `->where('id', '\d+')` - 静态路由优先匹配,避免动态路由“吃掉” `/posts/new` - 生产环境把编译结果缓存成 PHP 数组文件 ## 容器:只做两件事 一个够用的容器只需要支持**绑定**与**自动解析**: ```php public function make(string $class): object { if (isset($this->bindings[$class])) { return $this->bindings[$class]($this); } $reflection = new ReflectionClass($class); $constructor = $reflection->getConstructor(); if ($constructor === null) { return new $class(); } $arguments = []; foreach ($constructor->getParameters() as $parameter) { $type = $parameter->getType(); $arguments[] = $type instanceof ReflectionNamedType && !$type->isBuiltin() ? $this->make($type->getName()) : null; } return $reflection->newInstanceArgs($arguments); } ``` ## 中间件洋葱模型 中间件用闭包层层包裹,注意**反向遍历**: ```php foreach (array_reverse($middleware) as $entry) { $handler = fn (Request $request): Response => $instance->handle($request, $handler); } ``` 这样第一个中间件在最外层,既能前置处理,也能后置处理响应。 ## 小结 - 路由编译结果要可缓存 - 容器保持最小实现,够用就好 - 中间件顺序决定请求生命周期 --- ### Vue 3 + Naive UI 后台控制台实战:从 0 到可交付 - 原文:https://vabora.dev/posts/vue3-naive-ui-admin - 发布时间:2026-09-15 11:05:53 - 分类:技术分享 - 标签:架构设计、Vue 3、Naive UI 后台不需要花哨,需要的是**稳定、清晰、可维护**。这一篇记录我用 Vue 3 + Naive UI 搭博客控制台的完整思路。 ## 技术选型 - **Vue 3 组合式 API**:逻辑复用靠 `composable` 而不是 mixin - **Naive UI**:主题变量齐全,暗色模式开箱可用 - **原生 ESM**:不上构建工具,浏览器直接跑 ## 目录约定 ``` public/assets/admin/js/ ├── api.js # 请求封装(统一 CSRF / 错误提示) ├── app.js # 应用入口与路由 └── views/ ├── dashboard.js ├── posts.js └── tags.js ``` ## 请求封装 统一处理 CSRF 头与 401: ```js export async function request(path, options = {}) { const response = await fetch(path, { credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content, ...(options.headers || {}), }, ...options, }); if (response.status === 401) { location.href = '/admin/login'; throw new Error('登录已失效'); } return response.json(); } ``` ## 表格 + 分页 Naive UI 的 `n-data-table` 配合远程分页: ```js const pagination = reactive({ page: 1, pageSize: 15, itemCount: 0, showSizePicker: true, pageSizes: [10, 15, 30, 50], }); ``` ## 表单校验 用规则数组而不是模板字符串,便于复用: ```js const rules = { title: { required: true, message: '请输入标题', trigger: 'blur' }, slug: { required: true, message: '请输入 slug', trigger: 'blur' }, }; ``` ## 小结 后台的价值在于**让内容生产足够快**。先把文章与标签两条主线跑通,再谈看板与权限。 --- ### GEO 生成式引擎优化:让技术文章被 AI 正确引用 - 原文:https://vabora.dev/posts/geo-generative-engine-optimization - 发布时间:2026-09-13 10:05:53 - 分类:行业观察 - 标签:架构设计、SEO、GEO 搜索正在从「十条蓝链」变成「一段答案」。GEO(Generative Engine Optimization)的目标,是让你的内容成为 AI 引用的**那一段**。 ## SEO 与 GEO 的区别 | 维度 | SEO | GEO | | --- | --- | --- | | 目标 | 提升排名与点击 | 被模型检索、理解并引用 | | 关键信号 | 关键词、外链、体验 | 结构化数据、语义密度、可验证性 | | 结果形态 | 排名位置 | 出现在答案中并标注来源 | ## 五个可落地动作 1. **写结论前置**:每段先给答案,再给论证 2. **补结构化数据**:`BlogPosting` / `BreadcrumbList` / `Person` 3. **稳定锚点**:每个二级标题带 `id`,方便被精准引用 4. **提供 llms.txt**:给 AI 爬虫一份「内容索引」 5. **可验证**:给出版本号、运行结果与源码片段 ## llms.txt 长什么样 ```txt # Vabora开发极客 > 关于 PHP 8、Vabora Framework 与前端工程化的技术笔记。 ## 文章 - [用 PHP 8.5 从零构建零依赖 Web 框架](/posts/php85-zero-dependency-framework):路由、容器与中间件的最小实现。 ``` ## 结构化数据示例 ```json { "@context": "https://schema.org", "@type": "BlogPosting", "headline": "GEO 生成式引擎优化", "author": { "@type": "Person", "name": "Vabora" }, "datePublished": "2026-09-13T10:00:00+08:00" } ``` ## 小结 GEO 不是玄学,本质是:**把你的结论写成机器也能读懂的结构**。 --- ### 用 SSE 做流式 AI 输出:先出字,而不是先转圈 - 原文:https://vabora.dev/posts/sse-streaming-ai - 发布时间:2026-09-12 10:00:00 - 分类:技术分享 - 标签:AI、架构设计、Vue 3 ## 背景 后台用 AI 生成一篇 1500 字的文章摘要与正文,模型要跑 20~40 秒。一次性返回的体验是:点一下按钮,转圈 30 秒,然后"哗"地一下出现一整屏文字——用户既不知道它在干活,也没法中途叫停。 流式输出把这件事变得**可感知**:第一个字约 1 秒出现,之后持续往外冒,想停随时停。 ## 做法 ### 1. 后端:一个循环 + 三条响应头 ```php header('Content-Type: text/event-stream; charset=utf-8'); header('Cache-Control: no-cache'); header('X-Accel-Buffering: no'); // 关键:告诉 Nginx 不要缓冲这段响应 // 关掉 PHP 自身的输出缓冲,否则内容会攒到脚本结束才发 while (ob_get_level() > 0) { ob_end_flush(); } ob_implicit_flush(true); foreach ($chunks as $chunk) { echo 'data: ' . json_encode(['text' => $chunk], JSON_UNESCAPED_UNICODE) . "\n\n"; flush(); // 推出去 } echo "data: [DONE]\n\n"; // 显式结束,前端才知道"正常写完了" ``` 三个细节都是踩过的: - `X-Accel-Buffering: no` —— **没有它,Nginx 会先把整段响应攒起来**,你在本地 `php -S` 测试是流式的,上了服务器就变成"一次性蹦出来"。这是"明明写了流式却不流式"的头号原因。 - `ob_end_flush()` —— PHP-FPM 默认有输出缓冲,不清掉同样是攒完才发。 - 结尾发一个 `[DONE]` 标记 —— 否则前端只能靠连接关闭判断结束,无法区分"写完了"和"断了"。 ### 2. 前端:用 fetch + reader,而不是 EventSource `EventSource` 只能发 GET、不能带请求体、也不能自定义请求头,而生成请求需要 POST 一段很长的上下文。所以用 `fetch` 读流: ```js const controller = new AbortController() const response = await fetch('/admin/api/ai/generate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), signal: controller.signal, // 中断用的 }) const reader = response.body.getReader() const decoder = new TextDecoder() let buffer = '' while (true) { const { done, value } = await reader.read() if (done) break buffer += decoder.decode(value, { stream: true }) // SSE 以空行分隔事件;残缺的一段留在 buffer 等下一块 const parts = buffer.split('\n\n') buffer = parts.pop() ?? '' for (const part of parts) { const line = part.replace(/^data: /, '') if (line === '[DONE]') return appendText(JSON.parse(line).text) } } ``` `buffer` 那三行是必须的:网络分块不保证按事件边界切,少写就会出现 `JSON.parse` 报错或偶发丢字。 ### 3. 中断 `controller.abort()` 之后,浏览器会中断连接,后端那次 cURL 请求随之断开——**在上游也真的停掉**,不像轮询那样"用户走了,请求还在跑,账单还在涨"。 ## 结果 - 首字延迟从 20~40 秒降到约 1 秒,长文生成的"等待焦虑"基本消失; - 生成中可以随时停下,不满意就重来,不用等它写完; - 上游 token 消耗与用户实际等待时间对齐,浪费明显减少。 ## 取舍 | 方案 | 何时选它 | |---|---| | **SSE** | 服务端单向推、文本流、要复用现有 HTTP 鉴权与限流 —— 选它 | | WebSocket | 真正的双向实时(协同编辑、聊天室)—— 本项目不需要,引入等于多一套连接管理 | | 轮询 | 任务型异步(提交后拿任务 ID 查进度)—— 长任务且允许延迟时更简单可靠 | 流式的代价是**错误处理变复杂**:一旦开始输出,HTTP 状态码已经发出去了,中途出错无法再用 4xx/5xx 表达,只能在流里发一个错误事件让前端识别。所以上游模型调用要有超时与重试,且第一条内容发出前完成所有参数校验。 --- ### SQLite 也能扛生产:索引、事务与分页的正确姿势 - 原文:https://vabora.dev/posts/sqlite-production-practices - 发布时间:2026-09-10 09:05:53 - 分类:技术分享 - 标签:PHP 8、数据库 个人博客、内部工具、边缘站点,SQLite 往往比 MySQL 更合适。前提是你要知道它的边界。 ## 什么时候选 SQLite - 单实例部署、读多写少 - 数据量在百万行以内 - 希望零运维、备份就是一个文件 ## 必须开的三个开关 ```php $connection->exec('PRAGMA journal_mode = WAL'); $connection->exec('PRAGMA synchronous = NORMAL'); $connection->exec('PRAGMA foreign_keys = ON'); ``` WAL 让读写不互相阻塞,是性能的关键。 ## 索引只建在这三类字段上 1. 查询条件列:`status`、`published_at` 2. 唯一约束列:`slug` 3. 关联列:`post_id`、`tag_id` ## 分页别用 OFFSET 深翻 ```php // 差:OFFSET 越大越慢 SELECT * FROM posts ORDER BY id DESC LIMIT 10 OFFSET 10000; // 好:游标分页 SELECT * FROM posts WHERE id < :cursor ORDER BY id DESC LIMIT 10; ``` ## 事务包裹写入 ```php Database::transaction(function (Connection $connection): void { $post_id = Database::table('posts')->insertGetId($row); Database::table('post_tag')->insertBatch($links); }); ``` ## 小结 SQLite 的问题通常不是性能,而是**并发写入**与**连接管理**。用 WAL + 短事务,大多数场景足够。 --- ### Markdown 渲染的 XSS 边界:自研渲染器踩过的四个坑 - 原文:https://vabora.dev/posts/markdown-xss-hardening - 发布时间:2026-09-08 09:00:00 - 分类:技术分享 - 标签:PHP 8、安全、Markdown ## 背景 内容站的攻击面很集中:**所有用户可控的内容,最终都会经过 Markdown 渲染输出成 HTML**。后台只有管理员能写文章,看起来风险不高,但评论、询价留言、AI 生成的内容都会流进同一套渲染逻辑;而"只有管理员能写"这个前提,在多人协作或多角色后台里随时会变。 所以渲染器必须按"输入不可信"来写。以下是实际踩过并修掉的四个坑。 ## 四个坑 ### 1. 只转义了尖括号,漏了链接协议 ```php // 错误:链接地址直接输出 $html = preg_replace('/\[(.+?)\]\((.+?)\)/', '$1', $text); // 输入 [点这里](javascript:alert(document.cookie)) 就中招 ``` 正确做法是**协议白名单**,不在名单里的一律降级为纯文本(不要只做字符串替换,`java\nscript:` 这类变体绕得过去): ```php $href = htmlspecialchars($url, ENT_QUOTES, 'UTF-8'); $scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME)); if ($scheme !== '' && !in_array($scheme, ['http', 'https', 'mailto'], true)) { return htmlspecialchars($text, ENT_QUOTES, 'UTF-8'); // 不允许的协议:只输出文字 } ``` 相对路径(`/posts/xxx`)的 `parse_url` 取不到 scheme,属于合法情况,所以要允许 `scheme === ''`。 ### 2. 图片的 `onerror` 与 `srcset` `![x](a.png" onerror="alert(1))` 这种注入靠的是**属性值没有转义引号**。转义要覆盖 `"`、`'`、`<`、`>`、`&` 五个字符(`ENT_QUOTES`),并且自己拼接属性时不要再手工加引号: ```php $src = htmlspecialchars($url, ENT_QUOTES, 'UTF-8'); $alt = htmlspecialchars($text, ENT_QUOTES, 'UTF-8'); return '' . $alt . ''; ``` ### 3. 原始 HTML 直通 很多渲染器允许 Markdown 里嵌 HTML(方便加 `
`、`