# 我的站点(全文)
> 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`
`)` 这种注入靠的是**属性值没有转义引号**。转义要覆盖 `"`、`'`、`<`、`>`、`&` 五个字符(`ENT_QUOTES`),并且自己拼接属性时不要再手工加引号:
```php
$src = htmlspecialchars($url, ENT_QUOTES, 'UTF-8');
$alt = htmlspecialchars($text, ENT_QUOTES, 'UTF-8');
return '';
```
### 3. 原始 HTML 直通
很多渲染器允许 Markdown 里嵌 HTML(方便加 `