DFA 最小化(DFA Minimization)是形式语言与自动机理论中极其优美的一部分。

如果说 子集构造 解决了 “从不确定到确定”,那么 DFA 最小化 解决的就是 “从冗余到精简”。

下面为你系统梳理 DFA 最小化的全套理论知识体系。


一、核心理论基石:为什么一定能最小化?

1. Myhill-Nerode 定理(极小 DFA 唯一性定理)

定理:对于任意一个正则语言 $L$,存在且仅存在一个状态数最少的 DFA(在状态重命名的同构意义下唯一)。

这意味着:无论你的 NFA 有多乱,转换出来的原始 DFA 有多么庞大,只要经过最小化,它们最终都会殊途同归,缩减成那个结构完全相同的、最完美的极小自动机。

2. 不可区分性(Indistinguishability)与状态等价

怎么知道两个状态 $p$ 和 $q$ 能不能合并?
理论上采用 “黑盒测试实验”:

假设你站在状态 $p$,另一个人站在状态 $q$。我们给你们两个人输入同一个任意字符串 $w$:

  • 如果你走完 $w$ 到达了接受状态,而他走完 $w$ 到达了拒绝状态,那么字符串 $w$ 就把你们两个人区分开来了,称 $w$ 为 区分串(Distinguishing String)。
  • 如果没有任何字符串 $w \in \Sigma^$* 能够把你们区分开来——也就是说,无论喂什么后续字符串,你们要么同时接受,要么同时拒绝,那么状态 $p$ 和 $q$ 就是 等价的(Equivalent),记作:
    $$p \sim q$$

等价状态的核心结论:

如果两个状态是等价的,外界任何输入都无法探测出它们俩的区别。因此,把它们合并为一个状态,绝不会改变自动机对任何语言的识别结果。


二、从无限到有限:$k$-等价性($k$-Equivalence)理论

我们不可能用无穷多个字符串去逐一测试状态等价性。理论上是如何将其转化为有限步计算的?

答案是:按测试字符串的长度 $k$ 进行分层归纳($k$-等价)。

等价层级 数学定义 直观含义 分组判据
$0$-等价 ($\sim_0$) 无法被长度为 0 的字符串(即空串 $\epsilon$)区分 还没输入任何字符就露出破绽 同为接受状态,或同为非接受状态
$1$-等价 ($\sim_1$) 无法被任何长度 $\le 1$ 的字符串区分 消耗任意单个字符后,行为依然一致 在任何单字符转移后,落入同一个 $0$-等价组
$k$-等价 ($\sim_k$) 无法被任何长度 $\le k$ 的字符串区分 消耗任意 $\le k$ 长度的字符串,行为依然一致 在任何单字符转移后,落入同一个 $(k-1)$-等价组

$k$-等价的递推核心公式:

状态 $p$ 与 $q$ 是 $k$-等价的,当且仅当:

  1. $p$ 和 $q$ 本身是 $(k-1)$-等价的;
  2. 对于字母表中的任意输入字符 $a \in \Sigma$:
    $$\delta(p, a) \sim_{k-1} \delta(q, a)$$

通俗理解:如果我知道你们在测试长度为 $k-1$ 的字符串时是分不出区别的,那么要测长度为 $k$ 的串,只需要让你们每人先走一步(吃进一个字符 $a$),看你们走到的新状态是不是属于同一伙人($(k-1)$-等价组)。如果是,那在长度 $k$ 上你们依然无法被区分!


三、算法推演机制:分区细化(Partition Refinement)

这个算法在学术上通常被称为 Moore 状态最小化算法(也是 dfa-minimizer.js 源码所实现的机制)。

它的运作方式是:从最粗的粗粒度划分开始,不断用转移去向把集合“切碎”,直到切不动为止。

graph TD
    A["初始全集 Q"] --> B["【第 0 轮:0-等价划分 P₀】\n切成两块:[非终态组, 终态组]"]
    B --> C["【第 1 轮:1-等价划分 P₁】\n检查每组内状态在输入字符后,目标是否在 P₀ 的同一块中?\n若去向不同 -> 细分切开"]
    C --> D["【第 k 轮:k-等价划分 P_k】\n检查每组内状态在输入字符后,目标是否在 P_{k-1} 的同一块中?\n若去向不同 -> 细分切开"]
    D --> E{"P_k 是否与 P_{k-1} 完全相同?\n(划分达到不动点 Fixed Point)"}
    E -- "否 (仍有集合被切开)" --> D
    E -- "是 (再也切不开了)" --> F["【算法收敛】\n每个最终子块就是一个等价类\n合并为一个新状态,重建极小 DFA"]

算法必定终止的数学证明(收敛性):

  1. 单调性:集合只能被拆分(Refine),绝不可能发生两个集合重新合并的情况。
  2. 有限性:总状态数 $|Q| = n$ 是有限的。一个拥有 $n$ 个元素的集合,最多只能被切成 $n$ 个单元素子集。
  3. 步数上界:每一轮至少要切出一刀,如果不发生拆分就会立即终止。因此算法最多在 $n - 1$ 轮内必定收敛停机!

四、最终重构:商自动机(Quotient Automaton)

当分区细化终止后,我们得到了一组最终的互不相交的等价类子集:
$$\mathcal{P} = { [q_1], [q_2], \dots, [q_m] }$$

如何用这组集合构建最小化 DFA?
形式语言理论中称此为构建 **商自动机(Quotient Automaton) $M / \sim$**:

  1. **新状态集 $Q’$**:每个等价类作为一个新状态(例如类 ${2, 3, 4}$ 变成单个新状态 2)。
  2. **新起始状态 $q_0’$**:包含原起始状态 $q_0$ 的那个等价类 $[q_0]$。
  3. **新终态集 $F’$**:所有由原终态组成的等价类。
  4. **新转移函数 $\delta’$**:
    $$\delta’([q], a) = [\delta(q, a)]$$
    • 理论良定义(Well-defined)保证:从等价类 $[q]$ 中随便挑出哪一个代表状态,计算其在字符 $a$ 下的转移,其结果落入的等价类都绝对是同一个!这是等价类数学性质严格保证的。

五、经典算法对比:三大最小化流派

在编译原理与计算理论中,DFA 最小化主要有三种经典算法:

算法名称 核心思路 时间复杂度 特点
Moore 算法
(即我们的代码实现)
自顶向下细化
从 ${F, Q \setminus F}$ 开始逐步切分集合
$O( \Sigma
Hopcroft 算法
(工业界首选)
反向划分优化
“只用较小的子集去分割其他集合”(利用了 $\log$ 技巧)
$O( \Sigma
填表法 (Table-Filling)
(又称 Myhill-Nerode 算法)
自底向上标记
维护一个下三角矩阵,逐对寻找“可区分状态对 $(p, q)$”并打勾
$O( \Sigma

总结六字诀

理解 DFA 最小化,只需记住六个字:

  • 区分(Distinguish):只要有任意后续输入能让一个接受、一个拒绝,就必须分开;
  • 细化(Refine):按转移目标一步步细分,能分则分;
  • 合并(Merge):既然无论怎么输入都完全分不开,就彻底打包成一个状态。
    在 dfa-minimizer.js 中,DFA 最小化采用的是经典编译原理中的 $k$-等价性状态划分算法($k$-Equivalence Partition Refinement / Moore 算法)。

其核心思路是:寻找行为完全等价的状态并将它们合并为一个超级状态。如果两个状态在面对任意后续字符输入时,走向的接受/拒绝结论完全一致,那么它们就是等价的,保留多个只是冗余。


一、整体算法架构概览

整个算法流程可以划分为三大核心步骤:

flowchart TD
    A["输入:原始 DFA (包含转换表 table 和终态集 accepting)"] --> B["【阶段 0:0-等价划分】\n根据是否为接受状态,将状态分为两组:终态组 与 非终态组"]
    B --> C["【阶段 1:循环细化 (1..N-等价)】\nwhile (!sameRow(current, previous))"]
    C --> D{"遍历当前每个集合\n检查状态对在所有字符上的转移\n是否都落在同一集合?"}
    D -- "转移不同组" --> E["拆分为更细的子集合"]
    D -- "转移相同组" --> F["保持在同一个子集合中"]
    E --> G["生成新的划分集合列表"]
    F --> G
    G --> H{"分区是否与上一轮完全一致?\nsameRow(current, previous)"}
    H -- "否" --> C
    H -- "是 (收敛)" --> I["【阶段 2:重建最小化表】\n为每个最终等价集合重新分配 ID (1, 2, ...)\n重映射转移目标并构建 minimizedTable"]

二、核心数据结构

在阅读代码前,先理解这几个核心变量的作用:

  1. **currentTransitionMap**(全局/闭包映射字典):
    • 结构:{ [stateNumber]: Set }
    • 作用:记录每个原始状态当前属于哪一个集合(划分块)。
    • 例如状态 2 和状态 3 同在终态组 S_accept,则 currentTransitionMap[2] === S_accept,currentTransitionMap[3] === S_accept。
  2. **all**(历史划分栈):
    • all[0]:0-等价的分组结果 [nonAccepting, accepting]
    • all[1]:1-等价的分组结果
    • all[N]:最终稳定的分组结果
  3. **remaped**(Map<Set, number>):
    • 将最终合并后的每一个 Set(等价集合),重新赋予一个精简的状态编号(如 1, 2, …)。

三、源码逐段详细解析

1. 阶段 0:构建 0-等价初始划分(行 20 ~ 51)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 行 20-40: 初始化分类
function minimize(dfa) {
const table = dfa.getTransitionTable();
const allStates = Object.keys(table);
const alphabet = dfa.getAlphabet();
const accepting = dfa.getAcceptingStateNumbers();

currentTransitionMap = {};
const nonAccepting = new Set();

allStates.forEach(state => {
state = Number(state);
const isAccepting = accepting.has(state);

if (isAccepting) {
currentTransitionMap[state] = accepting;
} else {
nonAccepting.add(state);
currentTransitionMap[state] = nonAccepting;
}
});

// 行 46-50: 0-等价集合放入 all 数组
const all = [
[nonAccepting, accepting].filter(set => set.size > 0),
];
  • 原理解析:
    • 0-等价 代表消耗空串 $\epsilon$ 后的行为:终态直接接受,非终态不接受。
    • 所以最粗粒度的分类就是将状态划分为 非终态组 nonAccepting 与 **终态组 accepting**。
    • currentTransitionMap 建立了 状态 ID -> 所在 Set 的引用映射。

2. 阶段 1:循环细化(1..N-等价)(行 53 ~ 109)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
let current = all[all.length - 1];
let previous = all[all.length - 2];

// 直到本轮与上一轮分区完全一致(即不再发生拆分)
while (!sameRow(current, previous)) {
const newTransitionMap = {};

// 遍历当前这一轮中的每一个集合(例如当前的非终态集合)
for (const set of current) {
const handledStates = {};

// 取出第一个状态作为当前子组的基准
const [first, ...rest] = set;
handledStates[first] = new Set([first]);

// 遍历集合中剩余的其他状态,尝试看它们是否与已处理的状态等价
restSets: for (const state of rest) {
for (const handledState of Object.keys(handledStates)) {
// 如果当前状态与某个已处理状态等价:加入该状态所在的 Set
if (areEquivalent(state, handledState, table, alphabet)) {
handledStates[handledState].add(state);
handledStates[state] = handledStates[handledState];
continue restSets;
}
}
// 如果与现有子组都不等价,为它独立新建一个集合
handledStates[state] = new Set([state]);
}

// 将该集合细化后的状态映射合并到 newTransitionMap 中
Object.assign(newTransitionMap, handledStates);
}

currentTransitionMap = newTransitionMap;

// 利用 Set 对相同引用的 Set 实例去重,得到本轮所有的独立分区
let newSets = new Set(
Object.keys(newTransitionMap).map(state => newTransitionMap[state])
);

all.push([...newSets]);
current = all[all.length - 1];
previous = all[all.length - 2];
}
  • 细化过程机制:
    1. 对当前集合 set = [A, B, C]:
    2. 先把 A 放入新组:Group1 = {A}。
    3. 检查 B:调用 areEquivalent(B, A, ...)。
      • 如果等价,Group1.add(B),变成 {A, B};
      • 如果不等价,为 B 创建新组 Group2 = {B}。
    4. 检查 C:分别与已有的代表状态对比,决定并入 Group1、Group2 还是新建 Group3。
    5. 每一轮结束时,如果分区不再变化(sameRow(current, previous) === true),说明已经达到不可再分的极小状态集,跳出循环。

3. 核心判定函数:areEquivalent 与 goToSameSet(行 186 ~ 218)

两个状态在当前轮次下被判定为等价的前提是:在字母表的所有字符上,它们的目标去向必须一致。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
function areEquivalent(s1, s2, table, alphabet) {
for (const symbol of alphabet) {
if (!goToSameSet(s1, s2, table, symbol)) {
return false; // 只要有一个字符去向不同,就不是等价状态
}
}
return true;
}

function goToSameSet(s1, s2, table, symbol) {
if (!currentTransitionMap[s1] || !currentTransitionMap[s2]) {
return false;
}

const originalTransitionS1 = table[s1][symbol];
const originalTransitionS2 = table[s2][symbol];

// 情况 1:两者在该字符上都没有出边,视为行为一致
if (!originalTransitionS1 && !originalTransitionS2) {
return true;
}

// 情况 2:检查转移目标是否在有效集合中
return currentTransitionMap[s1].has(originalTransitionS1) &&
currentTransitionMap[s2].has(originalTransitionS2);
}

[!NOTE]
代码实现细节探讨:
在经典的 Moore 算法中,两个状态在字符 $c$ 上的转移 $\delta(s_1, c) = t_1$ 与 $\delta(s_2, c) = t_2$ 等价的充要条件是 $t_1$ 和 $t_2$ 落在同一个划分块中,即 currentTransitionMap[t1] === currentTransitionMap[t2]。

在本源码中:

  • 终态无出边时通过行 211 !originalTransitionS1 && !originalTransitionS2 判定为 true(例如 /a|b|c/ 的多个接受状态);
  • 自环或内部闭包转移通过 currentTransitionMap[s1].has(originalTransitionS1) 判断。

4. 终止条件检查:sameRow(行 161 ~ 184)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function sameRow(r1, r2) {
if (!r2) return false;
if (r1.length !== r2.length) return false;

for (let i = 0; i < r1.length; i++) {
const s1 = r1[i];
const s2 = r2[i];

if (s1.size !== s2.size) return false;
// 将 Set 内部元素排序转为字符串进行比对,如 "2,3" === "2,3"
if ([...s1].sort().join(',') !== [...s2].sort().join(',')) {
return false;
}
}
return true;
}

比较相邻两轮(如第 $k$ 轮与第 $k-1$ 轮)的分区数组。当两轮中集合个数相同、每个集合大小相同且包含的状态 ID 完全一致时,算法达到不动点(Fixed-point),终止细化。


5. 阶段 2:重构最小化转换表(行 111 ~ 159)

此时每个等价状态集合 Set 就是最小化 DFA 的一个节点。代码在这里做状态重编号与表重建:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// 1. 给每个最终的等价集合分配新的状态编号 (1, 2, 3...)
const remaped = new Map();
let idx = 1;
current.forEach(set => remaped.set(set, idx++));

const minimizedTable = {};
const minimizedAcceptingStates = new Set();

// 辅助函数:如果集合内包含原接受状态,则新 ID 也为接受状态
const updateAcceptingStates = (set, idx) => {
for (const state of set) {
if (accepting.has(state)) {
minimizedAcceptingStates.add(idx);
}
}
};

// 2. 构建最小化转换表
for (const [set, idx] of remaped.entries()) {
minimizedTable[idx] = {};
for (const symbol of alphabet) {
updateAcceptingStates(set, idx);

// 从原集合中任选一个代表状态,取出该字符的原始转移目标
let originalTransition;
for (const originalState of set) {
originalTransition = table[originalState][symbol];
if (originalTransition) {
break;
}
}

// 如果有转移,通过 currentTransitionMap 找到目标所在的集合,再映射到新的 idx
if (originalTransition) {
minimizedTable[idx][symbol] = remaped.get(
currentTransitionMap[originalTransition]
);
}
}
}

// 3. 写回 DFA 实例
dfa.setTransitionTable(minimizedTable);
dfa.setAcceptingStateNumbers(minimizedAcceptingStates);

return dfa;

四、实战推演:以 /a|b|c|d/ 的测试用例为例

来看 dfa-minimizer-test.js 中的真实用例:

1. 原始 DFA 表:

  • 状态 1:非终态,{ a: 5, b: 4, c: 3, d: 2 }
  • 状态 2, 3, 4, 5:全是接受状态,且没有出边 {}

2. 执行过程追踪:

步骤 操作 结果
0-等价划分 区分非终态与终态 nonAccepting = {1}
accepting = {2, 3, 4, 5}
1-等价细化 1. 检查 {1}
2. 检查 {2, 3, 4, 5}
1. 只有单状态,无需拆分。
2. 以 2 为基准;3, 4, 5 在所有字符上都无转移,goToSameSet 均返回 true,全部与 2 等价。保持为同一个集合 {2, 3, 4, 5}。
收敛判断 比较 all[1] 与 all[0] 两个集合完全相同,sameRow 返回 true,循环结束!
重映射 (Remap) 分配新 ID {1} $\to$ 新状态 1
{2, 3, 4, 5} $\to$ 新状态 2(接受状态)
构建新表 重建边转移 状态 1 对 a,b,c,d 均转移到 {2,3,4,5},即 新状态 2
状态 2 无出边 {}

3. 最终最小化表:

1
2
3
4
5
minimizedTable = {
1: { a: 2, b: 2, c: 2, d: 2 },
2: {}
}
minimizedAcceptingStates = Set { 2 }

原 DFA 从 5 个状态精简为 2 个状态,完全消除了冗余状态。

Claude Code

自我检查是成功的最高杠杆

  • 提供验证标准
    实现有一个邮箱验证函数,测试示例: user@example.com 为真,user@.com 为假

  • 直观验证UI
    实现 xx 文件夹下截图的设计,并截图和源文件比较差异,列出差异点并修复

  • 构建失败,出现了以下错误:[粘贴错误]。修复后确认构建成功。解决根本原因,不要掩盖错误。

探索/计划/实现/提交 四步走

  • 切换到计划模式,让 Claude 阅读指定文件,了解现在的实现方式,让他问问题并告诉他想知道的内容,这期间不做任何修改
    模糊的探索有时候很有用,例如:你想改进这个文件中的什么功能。
  • 说明想要实现的功能,询问可能修改的文件,需要添加的测试,以及实现流程, 让 Claude 列出详细的计划, 通过 CTRL + G 可以打开编辑
  • 切回普通模式,让 Claude 根据计划一步步实现功能,并生成测试文件,然后执行验证并解决错误。
  • 让 Claude 生成提交信息并创建 PR。

提供明确的上下文

  • 明确修改的场景,文件。 例如: 为 login.ts 添加测试, 需要覆盖第三方登录的场景。
  • 引导 Claude 能找出文件的源头,例如: 查看 login.ts 的 commit 历史,总结一下登录功能的变化过程。
  • 参考你希望的模式,例如: 查看首页表格组件, 仿照那个模式实现,产品列表,从零构建,不使用第三方库,需要实现双击单元格可修改的功能。
  • 错误描述带有引导性,例如: login.ts 存在以下错误,检查 auth 下的相关文件,在修改前写一个期待失败的错误来复现后在修改。

提供丰富的内容

  • 使用@引用这个文件
  • 直接复制粘贴图片,或让 Cladue 读取文件夹中的图片
  • 提供URL,使用 /permissions 来允许常用域名。
  • 通过运行 cat error.log | claude 直接发送文件内容,导入数据 。
  • 明确让 Claude 用指定 bash 或 MPC 去获取必要的上下文。

环境配置

CLAUDE.md

运行 /init 生成基于当前项目结构的起始 CLAUDE.md 文件,然后逐步细化。CLAUDE.md 在每一次对话都会被使用,逐渐丰富它的 Bash 命令、代码样式和工作流程规则,这是一个无法用代码推断出的持久上下文。

写 CLAUDE.md 时问自己 Claude 能从代码分析出某个问题么,如果能就删掉。

包括 删除
bash 命令,Claude 从代码中推断不出来的内容 删除 Claude 可以通过代码理解的内容
与默认规则不同的代码风格 删除 Cladue 已经知道的标准约定
测试说明和首选测试工具 删除 Api 文档,直接用官方的链接
协作习惯,分支命名, PR命名 删除 经常变化的信息
针对当前项目的具体架构 删除 长篇的解释或教程
环境相关的配置 删除 代码目录/文件 功能描述
独特的个人习惯 删除 ‘保持代码简洁’ 这种默认遵循的习惯

保持 Cladue.md 文件简洁,定期修改来修正 Claude 的方向。

使用 IMPORTANT YOU MUST 来提高信息的权重。

可以使用 - Git workflow: @docs/git-instructions.md 类似的语句来导入文件

权限配置

使用 /permissions 来允许安全的命令。

CLI

可以直接说明让 Claude 使用 gh 等 CLI 工具,也可以说:使用 gh -help 学习命令使用,并解决 x 问题。

MCP
Hooks

Hooks 可以确保在某个操作后执行动作,比如在每次修改文件后都运行测试。

使用 /hooks 交互式添加 hook, 或直接修改 .claude/settings.json

Wordpress 全流程

主题安装

轻量/性能

  • GeneratePress
    特点: 全球开发者公认的“最干净”主题。核心文件只有几十 KB。
    它不会产生冗余代码。可以使用它的 Elements 功能(类似于 Hook),在不改主题源码的情况下,在任何地方插入你的自定义代码。

  • Astra
    特点: 市场占有率第一。它有非常多的“一键导入”模板(Starter Templates)。
    它对 LearnDash(课程插件)有原生优化,能自动把课程页面美化得非常专业,省去写 CSS 的时间。

  • Kadence
    特点: 近两年崛起的黑马,自定义程度比 Astra 更高,且不依赖任何页面构建器。
    为什么选它: 它的 Header/Footer Builder 非常直观,适合快速搭建独立站的品牌感。

课程/社区

  • BuddyBoss Theme
    特点: 目前做课程+社交的最强主题。它把 WordPress 变成了一个像 Facebook 一样的社区。
    适用场景: 课程不仅是卖视频,还想要学生之间互动、发动态、做小组,这是唯一选择。

  • Eduma (Education WP)
    特点: ThemeForest 上销量第一的教育主题。
    适用场景: 一个“开箱即用”的学校网站感觉。它内置了 LearnPress(一个免费课程插件),对新手非常友好。

  • 配合插件
    LearnDash 老牌、功能最稳、扩展极多。闭源但生态极其丰富,适合严肃教育。
    TutorLMS UI 非常现代、中文支持好。后台操作非常丝滑,更有“互联网产品”的感觉。

FSE 主题 : Frost 或 Twenty Twenty-Five
特点: 完全基于区块(Blocks)。没有传统的 PHP 模板概念,全部通过 JSON 和 HTML 预定义。这种模式更接近于“组件化”开发的逻辑,你可以完全控制页面的 DOM 结构。

产品类目的三种方式

图片排列

OpenFaas 部署/应用

安装

OpenFaaS 依赖 Kubernetes 集群, 参考 kubernetes 安装文档, 首先关闭 swap

1
2
3
4
5
# 删除swap所在行
sudo vi /etc/fstab
sudo swapoff -a
# 验证
free -h

安装一个 container runtime 官方推荐 containerd,安装成功后,初始化控制节点

1
2
3
4
5
6
7
8
sudo kubeadm init --pod-network-cidr=10.244.0.0/16

# 如果报错 [ERROR FileContent--proc-sys-net-ipv4-ip_forward]: /proc/sys/net/ipv4/ip_forward contents are not set to 1
# Kubernetes 节点需要开启 IPv4 转发(IP forwarding),这样 Node 可以把 Pod 的网络流量转发到其他节点或外部网络。

sudo nano /etc/sysctl.conf
net.ipv4.ip_forward=1 # 添加或打开注释
sudo sysctl -p # 保存

初始化成功后参照控制台输出执行 配置命令

安装一个 网络插件 以便于容器间通信,或访问外部网络

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
kubectl apply -f https://raw.githubusercontent.com/flannel-io/flannel/master/Documentation/kube-flannel.yml

# 如果报错Failed to check br_netfilter: stat /proc/sys/net/bridge/bridge-nf-call-iptables: no such file or directory
# 说明 内核缺少 br_netfilter 模块或相关配置没有启用,这是 Flannel 启动 VXLAN 网络所必须的。

sudo modprobe br_netfilter # 加载 br_netfilter 模块
lsmod | grep br_netfilter # 确认模块加载

# 开启 bridge-nf-call-iptables
echo "br_netfilter" | sudo tee /etc/modules-load.d/k8s.conf
echo "net.bridge.bridge-nf-call-iptables = 1" | sudo tee /etc/sysctl.d/k8s.conf
echo "net.bridge.bridge-nf-call-ip6tables = 1" | sudo tee -a /etc/sysctl.d/k8s.conf
sudo sysctl --system

# 重新部署
kubectl delete -f https://raw.githubusercontent.com/flannel-io/flannel/master/Documentation/kube-flannel.yml
kubectl apply -f https://raw.githubusercontent.com/flannel-io/flannel/master/Documentation/kube-flannel.yml

OpenFaaS 安装参考 官方文档, 安装 openfaas-ce 后,命令行会返回一段信息, 依次执行这些命令

1
2
3
4
5
6
7
8
# 获取 faas-cli(OpenFaaS 的命令行工具),用来操作 OpenFaaS
arkade get faas-cli

# 安装后查看版本
faas-cli version

# 等待 OpenFaaS Gateway 部署完成,gateway 是 OpenFaaS 的 核心入口 / UI / API Server。
kubectl rollout status -n openfaas deploy/gateway

如果命令 kubectl rollout status -n openfaas deploy/gateway 不成功, 检查 coredns 的 forward 配置

1
2
3
4
5
kubectl edit configmap coredns -n kube-system

forward . 192.168.48.1 # 修改为宿主机ip

kubectl rollout restart deployment coredns -n kube-system # 重启服务

UI 界面访问

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 查看端口 
kubectl get svc -n openfaas
# gateway-external NodePort 10.103.137.90 <none> 8080:31112/TCP 39m
# 此时就可以通过 31112 端口访问 ui 界面了


# 登录
echo -n $PASSWORD | faas-cli login --gateway http://192.168.48.133:31112 --username admin --password-stdin
# Calling the OpenFaaS server to validate the credentials...
# WARNING! You are not using an encrypted connection to the gateway, consider using HTTPS.
# credentials saved for admin http://192.168.48.133:31112

echo $PASSWORD

# 修改密码
echo -n "NewPassword123" | base64

kubectl get secret basic-auth -n openfaas -o yaml
# 替换 basic-auth-password

# 重启服务
kubectl rollout restart deployment gateway -n openfaas

系统重启准备

1
2
3
4
5
6
7
8
9
10
kubectl drain openfass --ignore-daemonsets --force --delete-emptydir-data

# --ignore-daemonsets → 不驱逐 DaemonSet Pod
# --force → 强制在单节点上驱逐 Pod
# --delete-emptydir-data → 删除使用 emptyDir 的 Pod
sudo reboot

# 把节点状态改回 可调度
kubectl uncordon openfass
kubectl get nodes

Puppeteer + 火山 OCR 实现自动登录和爬虫操作

火山 OCR Api 接入

需要在用户中心获取 AK,SK 密钥

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
// request.js

"use strict";

const crypto = require("crypto");
const util = require("util");
const url = require("url");
const qs = require("querystring");
const fs = require("fs");

/**
* 不参与加签过程的 header key
*/
const HEADER_KEYS_TO_IGNORE = new Set([
"authorization",
"content-type",
"content-length",
"user-agent",
"presigned-expires",
"expect",
]);

// do request example
function request(signParams) {
signParams.headers = {
...signParams.headers,
["X-Date"]: getDateTimeNow(),
};
signParams.bodySha = getBodySha(signParams.body);

// 正规化 query object, 防止串化后出现 query 值为 undefined 情况
for (const [key, val] of Object.entries(signParams.query)) {
if (val === undefined || val === null) {
signParams.query[key] = "";
}
}
const authorization = sign(signParams);
return fetch(
`https://iam.volcengineapi.com/?${qs.stringify(signParams.query)}`,
{
headers: {
...signParams.headers,
Authorization: authorization,
},
method: signParams.method,
body: signParams.body,
}
);
}

function sign(params) {
const {
headers = {},
query = {},
region = "",
serviceName = "",
method = "",
pathName = "/",
accessKeyId = "",
secretAccessKey = "",
needSignHeaderKeys = [],
bodySha,
} = params;

const datetime = headers["X-Date"];
const date = datetime.substring(0, 8); // YYYYMMDD
// 创建正规化请求
const [signedHeaders, canonicalHeaders] = getSignHeaders(
headers,
needSignHeaderKeys
);
const canonicalRequest = [
method.toUpperCase(),
pathName,
queryParamsToString(query) || "",
`${canonicalHeaders}\n`,
signedHeaders,
bodySha || hash(""),
].join("\n");

const credentialScope = [date, region, serviceName, "request"].join("/");
// 创建签名字符串
const stringToSign = [
"HMAC-SHA256",
datetime,
credentialScope,
hash(canonicalRequest),
].join("\n");
// 计算签名
const kDate = hmac(secretAccessKey, date);
const kRegion = hmac(kDate, region);
const kService = hmac(kRegion, serviceName);
const kSigning = hmac(kService, "request");
const signature = hmac(kSigning, stringToSign).toString("hex");

return [
"HMAC-SHA256",
`Credential=${accessKeyId}/${credentialScope},`,
`SignedHeaders=${signedHeaders},`,
`Signature=${signature}`,
].join(" ");
}

function hmac(secret, s) {
return crypto.createHmac("sha256", secret).update(s, "utf8").digest();
}

function hash(s) {
return crypto.createHash("sha256").update(s, "utf8").digest("hex");
}

function queryParamsToString(params) {
return Object.keys(params)
.sort()
.map((key) => {
const val = params[key];
if (typeof val === "undefined" || val === null) {
return undefined;
}
const escapedKey = uriEscape(key);
if (!escapedKey) {
return undefined;
}
if (Array.isArray(val)) {
return `${escapedKey}=${val
.map(uriEscape)
.sort()
.join(`&${escapedKey}=`)}`;
}
return `${escapedKey}=${uriEscape(val)}`;
})
.filter((v) => v)
.join("&");
}

function getSignHeaders(originHeaders, needSignHeaders) {
function trimHeaderValue(header) {
return header.toString?.().trim().replace(/\s+/g, " ") ?? "";
}

let h = Object.keys(originHeaders);
// 根据 needSignHeaders 过滤
if (Array.isArray(needSignHeaders)) {
const needSignSet = new Set(
[...needSignHeaders, "x-date", "host"].map((k) => k.toLowerCase())
);
h = h.filter((k) => needSignSet.has(k.toLowerCase()));
}
// 根据 ignore headers 过滤
h = h.filter((k) => !HEADER_KEYS_TO_IGNORE.has(k.toLowerCase()));
const signedHeaderKeys = h
.slice()
.map((k) => k.toLowerCase())
.sort()
.join(";");
const canonicalHeaders = h
.sort((a, b) => (a.toLowerCase() < b.toLowerCase() ? -1 : 1))
.map((k) => `${k.toLowerCase()}:${trimHeaderValue(originHeaders[k])}`)
.join("\n");
return [signedHeaderKeys, canonicalHeaders];
}

function uriEscape(str) {
try {
return encodeURIComponent(str)
.replace(/[^A-Za-z0-9_.~\-%]+/g, escape)
.replace(
/[*]/g,
(ch) => `%${ch.charCodeAt(0).toString(16).toUpperCase()}`
);
} catch (e) {
return "";
}
}

function getDateTimeNow() {
const now = new Date();
return now.toISOString().replace(/[:-]|\.\d{3}/g, "");
}

// 获取 body sha256
function getBodySha(body) {
const hash = crypto.createHash("sha256");
if (typeof body === "string") {
hash.update(body);
} else if (body instanceof url.URLSearchParams) {
hash.update(body.toString());
} else if (util.isBuffer(body)) {
hash.update(body);
}
return hash.digest("hex");
}

module.exports = request;

puppeteer 爬虫

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
const puppeteer = require("puppeteer");
const fs = require("fs");

const request = require("./request");

// const { createWorker } = require("tesseract.js");

// const worker = await createWorker("eng");
// const ret = await worker.recognize(fs.readFileSync("./authCode.jpg"));
// console.log(ret.data.text);

(async () => {
const browser = await puppeteer.launch({
headless: true,
defaultViewport: { width: 1400, height: 900 },
});
const page = await browser.newPage();

// 设置导航的默认超时时间为永不超时
page.setDefaultNavigationTimeout(0);

// 如果添加请求拦截器,需要添加下面一句代码
// 如果使用响应拦截器,则不需要添加,添加会导致代码不向下执行
// await page.setRequestInterception(true);

page.on("response", async (response) => {
const url = response.url();

// 拦截验证码图片,保存到本地
if (url.includes("image.php")) {
const img = await response.buffer();
fs.writeFileSync(`./authCode.jpg`, img);
}
});

await Promise.all([
page.waitForNavigation(),
page.goto("https://hdsky.me/login.php"),
]);

await page.type("input[name='username']", "username");
await page.type("input[name='password']", "password");

// 调用 OCR api 识别验证码
const AccessKeyId = "___YOUR_ACCESS_KEY___";
const SecretKey = "___YOUR_SECRET_KEY___";

const img = fs.readFileSync("./authCode.jpg");
const imgBase64 = img.toString("base64");

const body = new URLSearchParams();
body.append("image_base64", imgBase64);

const {
data: {
line_texts: [authCode],
},
} = await request({
headers: {
// 火山文档提示必传,但是 sdk 会过滤掉
"content-type": "application/x-www-form-urlencoded",
},
method: "POST",
// api 文档必填参数
query: {
Action: "OCRNormal",
Version: "2020-08-26",
},
accessKeyId: AccessKeyId,
secretAccessKey: SecretKey,
serviceName: "cv",
region: "cn-north-1",
body,
}).then((res) => res.json());

console.log(authCode);

await page.type("input[name='imagestring']", authCode);

// 点击事件导致页面导航,需要等待导航结束
await Promise.all([
page.waitForNavigation(),
page.click("input[type='submit']"),
]);

await Promise.all([
page.waitForNavigation(),
page.click("a[href='torrents.php']"),
]);

const loop = async (startPage) => {
const list = await page.$$("table tr[class*=progresstr]");

for (let i = 0; i < list.length; i++) {
const item = list[i];

// 监听新页面创建事件,并获取详情页的 page 对象
const detailPromise = new Promise((resolve) => {
browser.once("targetcreated", async (target) => {
if (target.type() === "page") {
const newPage = await target.page();
resolve(newPage);
}
});
});

const a = await item.$(
"td:nth-child(2) table > tbody > tr > td:nth-child(1) > a"
);
await a.click();

const detailPage = await detailPromise;

await detailPage.waitForSelector("#saythanks", {
timeout: 0,
});

await detailPage.click("#saythanks");

console.log(`第 ${startPage} 页,第 ${i + 1} 条已点赞`);

await new Promise((resolve) => setTimeout(resolve, 4000));

await detailPage.close();
}
};

const loop2 = async () => {
const startPage = fs.readFileSync("./skypage.txt", "utf8");

const search = await page.$("input[type=number]:nth-child(2)");
await search.click({ clickCount: 3 });
await search.type(startPage);

await Promise.all([
page.waitForNavigation(),
page.click("input[type=submit]:nth-child(3)"),
]);

await loop(startPage);

if (startPage > 0) {
fs.writeFileSync("./skypage.txt", (startPage - 1).toString(), "utf8");
loop2();
}
};

loop2();

// await browser.close();
})();

debian 执行任务报错

Failed to launch the browser process: error while loading shared libraries: libnss3.so: cannot open shared object file

可能是没想相关的执行环境安装以下依赖

1
2
3
sudo apt-get install libpangocairo-1.0-0 libx11-xcb1 libxcomposite1 libxcursor1 libxdamage1 libxi6 libxtst6 libnss3 libcups2 libxss1 libxrandr2 libasound2 libatk1.0-0 libgtk-3-0

sudo apt-get install -y libgbm-dev

Dify 工作流

下载 Dify 源码

1
git clone https://github.com/langgenius/dify.git

启动 Dify

进入 Dify 源代码的 docker 目录,执行一键启动命令:

1
2
cd dify/docker
docker compose up -d

如果您的系统安装了 Docker Compose V2 而不是 V1,请使用 docker compose 而不是 docker-compose。通过$ docker compose version 检查版本号:Docker

1
2
3
4
5
6
7
8
[+] Running 7/7
✔ Container docker-web-1 Started 1.0s
✔ Container docker-redis-1 Started 1.1s
✔ Container docker-weaviate-1 Started 0.9s
✔ Container docker-db-1 Started 0.0s
✔ Container docker-worker-1 Started 0.7s
✔ Container docker-api-1 Started 0.8s
✔ Container docker-nginx-1 Started

检查容器是否正常运行,包括 3 个业务服务 api / worker / web,以及 4 个基础组件 weaviate / db / redis / nginx,都要处于启动状态。注意下面每一个服务的 status 都要是 Up 状态才算真正启动成功。

1
2
3
4
5
6
7
8
9
10
docker compose ps

NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS
docker-api-1 langgenius/dify-api:0.3.2 "/entrypoint.sh" api 4 seconds ago Up 2 seconds 80/tcp, 5001/tcp
docker-db-1 postgres:15-alpine "docker-entrypoint.s…" db 4 seconds ago Up 2 seconds 0.0.0.0:5432->5432/tcp
docker-nginx-1 nginx:latest "/docker-entrypoint.…" nginx 4 seconds ago Up 2 seconds 0.0.0.0:80->80/tcp
docker-redis-1 redis:6-alpine "docker-entrypoint.s…" redis 4 seconds ago Up 3 seconds 6379/tcp
docker-weaviate-1 semitechnologies/weaviate:1.18.4 "/bin/weaviate --hos…" weaviate 4 seconds ago Up 3 seconds
docker-web-1 langgenius/dify-web:0.3.2 "/entrypoint.sh" web 4 seconds ago Up 3 seconds 80/tcp, 3000/tcp
docker-worker-1 langgenius/dify-api:0.3.2 "/entrypoint.sh" worker 4 seconds ago Up 2 seconds 80/tcp, 5001/tcp

升级 Dify

进入 Dify 源代码的 docker 目录,按顺序执行以下命令:

1
2
3
4
5
cd dify/docker
git pull origin main
docker compose down
docker compose pull
docker compose up -d

FAQ

  • 首页正常显示,当点击设置管理员用户页面一直在加载中,浏览器控制台报错,
1
/console/api/setup 502

检查服务的状态 status 栏并是不是 Up 状态

1
docker compose ps

查看日志

1
docker logs docker-api-1

这是由于 Docker 运行权限不够,需要被赋予更高的权限。

在 docker-compose.yaml 文件中,给 services 下每一个 service 最后一行都加上 privileged: true,然后重新启动 Dify 即可。

  • 由于在第一个问题中,Ubuntu 系统中,本地修改了 docker-compose.yaml 文件,在使用 git pull origin main 会提示本地有文件没有提交:
1
2
3
4
5
6
7
8
9
10
error: Your local changes to the following files would be overwritten by merge:
docker/docker-compose.yaml
Please, commit your changes or stash them before you can merge.

# 需要提交修改
git config --global user.email "xxx"
git config --global user.name "xxx"

git add docker-compose.yaml
git commit -m "local change"
  • Docker 无法拉取镜像
1
2
3
4
5
6
7
8
9
10
// daemon.json可能会不存在,直接 vi /etc/docker/daemon.json可以创建
sudo vi /etc/docker/daemon.json

{
"registry-mirrors": ["新的镜像源地址"]
}

systemctl restart docker //重启docker

docker info //查看镜像源有没有配置成功

React Router v7 概览

v7 可以让 React Router 作为框架来使用,利用提供的 cli 工具创建项目,提供了相关的构建包,以 React Router 的视角开发项目。以下是官方提供的特性说明:

  • Vite 捆绑器和开发服务器集成
  • 热模块替换
  • 代码分割
  • 带类型安全的路由约定
  • 文件系统或基于配置的路由
  • 带类型安全的数据加载
  • 带类型安全的操作
  • 操作后页面数据的自动重新验证
  • SSR、SPA 和静态呈现策略
  • 待定状态和乐观 UI 的 api
  • 部署适配器

客户端路由相关知识

Location API (获取与操作 URL)

window.location:

  • assign(url): 导航到新地址(产生历史记录)。

  • replace(url): 导航到新地址(不产生历史记录,直接替换当前页)。

  • reload(): 刷新当前页。

History API

  • history.state 只读属性,返回当前历史条目关联的状态对象。

    当页面刷新后,或者通过代码逻辑回到某个历史点时,你可以直接通过 history.state 拿到之前存进去的那个 JSON 对象,而不需要等待 popstate 事件。

  • history.pushState(state, title, url) 增加一个历史记录条目,但是不会触发 popstate 事件, 也不会刷新页面。

    state 参数:它可以是任何可以被序列化的对象(即可以用 JSON.stringify 处理的对象)。

    存储位置:这个数据是存储在浏览器进程中的。即使你刷新页面,只要这个历史条目还在,这个 state 就不会丢失。

    位置,状态,类型,行为
    1,http://localhost:3000?tag=m,Document 1,初始加载
    2,http://localhost:3000?tag=m1,Document 1,pushState (无刷新)
    3,http://localhost:3000?tag=m2,Document 1,pushState (无刷新)
    4,http://localhost:3000?tag=d,Document 2,手动输入 (全页面刷新)
    5,http://localhost:3000?tag=d1,Document 2,pushState (无刷新)
    6,http://localhost:3000?tag=d2,Document 2,pushState (无刷新)

    通过 前进,后退 按钮,或者 history.back(), history.forward(), history.go() 方法导航时,会触发 popstate 事件。

    特殊逻辑

    • 浏览器的“无交互记录”跳过机制(User Activation)

      如果一个历史记录是通过 pushState 创建的,但用户在那个状态下没有进行任何实质性的交互(比如点击、滚动、按键),浏览器在点击后退时,可能会认为这些状态是“无意义”的。

      当你手动输入 ?tag=d 后,如果紧接着连续运行了两次 pushState 而没有点击页面任何地方,浏览器在后退时可能会直接把 D1 和 D2 视为 中间过程 直接跳过,一步退回到手动输入的那个 “有来源” 的页面起点。

    • 只有 URL 变化,没有“历史条目”

      在某些特殊的本地测试环境下(比如某些版本的 Live Server 或 Vite HMR 期间),如果你连续快速执行 pushState,而浏览器认为页面内容完全没有变化(DOM 没动),它有时会合并这些连续的 URL 变更。

    • “手动输入”触发了替换(Replace)而不是推送(Push)

      当你从 M2 手动输入 ?tag=d 时,浏览器会认为这是一个全新的导航起点。 如果你在 D 页面加载后,脚本立即执行了 pushState(例如在 onload 或是组件挂载时自动执行),浏览器有时会为了防止死循环,将这种“刚打开就改 URL”的行为处理为 replaceState 效果,导致 D 被 D1 覆盖,D1 被 D2 覆盖。

    • history.replaceState(state, title, url) 修改当前历史记录条目,不会触发 popstate 事件,也不会刷新页面。

    • history.popstate 事件 当活动历史记录条目发生变化时触发(例如用户点击浏览器的后退按钮,或者调用 history.back() 方法)。

特殊文件

react-router.config.ts

可选,全局的配置文件。

root.ts

必须,唯一的必须路由,是 routes 目录有所有路由的父路由,也用于描述 HTML 文档。

可以把 React Router 提供的全局组件写在这里,只会渲染一次。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import type { LinksFunction } from "react-router";
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router";

import "./global-styles.css";

export default function App() {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />

{/* 所有路由上的所有meta导出都会在这里渲染 */}
<Meta />

{/* 所有路由上的所有link导出都会在这里渲染 */}
<Links />
</head>
<body>
{/* 子路由*/}
<Outlet />

{/* 管理客户端渲染时候的滚动条位置 */}
{/* If you use a nonce-based content security policy for scripts, you must provide the `nonce` prop. Otherwise, omit the nonce prop as shown here. */}

<ScrollRestoration />

<Scripts />
</body>
</html>
);
}

Content Security Policy (CSP)
CSP 是一种浏览器安全机制,用于防止跨站脚本攻击(XSS)等安全问题。通过配置 CSP,开发者可以限制页面加载的资源(如脚本、样式等)的来源和执行方式。

Nonce
nonce 是 CSP 中的一个概念,表示一个随机生成的字符串(一次性值)。通过在 CSP 中配置 nonce,可以允许特定的内联脚本或动态加载的脚本执行,而不会违反 CSP 规则。

如果你使用了基于 nonce 的 CSP 策略:
如果你的 CSP 配置中使用了 nonce 来允许脚本执行,那么你需要在 React Router 的相关组件中传递 nonce 属性。这是因为 React Router 可能会动态加载或执行一些脚本,这些脚本需要符合你的 CSP 策略。

如果你没有使用基于 nonce 的 CSP 策略:
如果你的 CSP 配置不涉及 nonce,或者你不需要对脚本进行特殊限制,那么你可以忽略 nonce 属性(如示例中所示).

假设你的 CSP 配置如下:

1
Content-Security-Policy: script-src 'nonce-abc123';

这意味着只有带有 nonce=”abc123” 的脚本才能执行。在这种情况下,你需要在 React Router 中传递 nonce 属性:

1
<Router nonce="abc123">{/* Your routes here */}</Router>

layout-export

一个单独导出的组件,可以避免在 Root Component,HydrateFallback, ErrorBoundary 重复声明 app 的框架元素。

routes.ts

必须, 统一的路由配置文件,它将自动对每个路由进行代码拆分,为参数和数据提供类型安全,并在用户导航到数据时自动加载具有挂起状态访问权的数据。

1
2
3
4
5
6
import type { RouteConfig } from "@react-router/dev/routes";
import { route } from "@react-router/dev/routes";

export default [
route("contacts/:contactId", "routes/contact.tsx"),
] satisfies RouteConfig;
index route

当路由没有匹配任何路径的时候,他会在 Outlet 中显示空白,index 可以当做是默认路由的视图。

1
2
3
4
5
6
7
8
// app/routes.ts
import type { RouteConfig } from "@react-router/dev/routes";
import { index, route } from "@react-router/dev/routes";

export default [
index("routes/home.tsx"),
route("contacts/:contactId", "routes/contact.tsx"),
] satisfies RouteConfig;
layout route

可以在路由的配置文件中,描述组件的嵌套关系.

1
2
3
4
5
6
7
8
9
10
11
12
import type { RouteConfig } from "@react-router/dev/routes";
import { index, layout, route } from "@react-router/dev/routes";

export default [
layout("layouts/sidebar.tsx", [
index("routes/home.tsx"),

// 会渲染到 layouts/sidebar.tsx 这个 layout 下的 Outlet 里面。
route("contacts/:contactId", "routes/contact.tsx"),
]),
route("about", "routes/about.tsx"),
] satisfies RouteConfig;

特殊组件

用于展示路由

1
2
3
4
5
6
7
8
9
10
11
// app/root.tx
import { Outlet } from "react-router";
export default function App() {
return (
<>
<div id="detail">
<Outlet />
</div>
</>
);
}

核心方法

clientLoader

只在浏览器中调用,为路由组件提供数据

1
2
3
4
5
6
7
8
9
10
11
// app/root.tsx

export async function clientLoader() {
const contacts = await getContacts();
return { contacts };
}

export default function App({ loaderData }: any) {
const { contacts } = loaderData;
// ...
}
HydrateFallback

如果通过 react-router.config.ts 配置为客户端渲染,那么在 app 根文件执行之前是没有任何内容。

提供一个 HydrateFallback 方法,在 app 被渲染前提供能内容。他会被直接添加到 index.html 文件中。

1
2
3
4
5
6
7
8
// app/root.tsx
export function HydrateFallback() {
return (
<div id="loading-splash">
<p>Loading, please wait...</p>
</div>
);
}
URL Params Loader

在 loader 方法中获取

1
2
3
4
5
6
7
8
9
10
11
12
13
// existing imports
import type { Route } from "./+types/contact";

export async function loader({ params }: Route.LoaderArgs) {
const contact = await getContact(params.contactId);
return { contact };
}

export default function Contact({ loaderData }: Route.ComponentProps) {
const { contact } = loaderData;

// existing code
}

在组件中获取

1
2
3
4
export default function SidebarLayout({ params }: Route.ComponentProps) {
console.log(params);
return; //...
}
useNavigation

返回当前导航信息,包括导航状态。 导航需要等到 loader 结束时才会渲染页面,因此可以使用导航状态展示 loading 信息。

1
2
3
4
5
6
7
8
9
10
11
12
import { useNavigation } from "react-router";

export default function SidebarLayout() {
const navigation = useNavigation();

return (
<div
id="detail"
className={navigation.state === "loading" ? "loading" : ""}
></div>
);
}

类型安全

React Router 自动为每个路由生成类型文件存放在 +types/<route file>.d.ts.

1
2
3
4
5
6
7
// app/root.tsx
import type { Route } from "./+types/root";

export default function App({ loaderData }: Route.ComponentProps) {
const { contacts } = loaderData;
// ...
}

预渲染静态路由

对于没有内容的页面,希望在加载的时候不展示 loading,而是直接展示内容。

指定哪些页面需要预渲染,他们会在打包阶段,打包为静态文件。

1
2
3
4
5
6
7
//react-router.config.ts
import { type Config } from "@react-router/dev/config";

export default {
ssr: false,
prerender: ["/about"],
} satisfies Config;

抛出错误

在 loader 中抛出错误, 会被 root.tsx 的 ErrorBoundary 捕获

1
2
3
4
5
6
7
export async function clientLoader({ params }: Route.LoaderArgs) {
const contact = await getContact(params.contactId);
if (!contact) {
throw new Response("Not Found", { status: 404 });
}
return { contact };
}

表单提交

默认情况下,表单的提交会触发 history 的修改,因此使用 Form 组件,它会就近拦截表单的提交,并将请求通过 fetch 发送到最近的 action 中处理。

Form 组件上的 action 会作为请求提交的地址,用于匹配路由。

action 的规则是如果路由匹配那么对应的路由文件中必须存在 action 处理函数否则报错,如果没有对应的路由文件(例如写在 layout 文件中),会使用 root 中的 action 处理。

需要注意的是使用 action 不能将 react-router.config.ts 中的 ssr 设置为 false,因为这是服务端的功能。

clientLoader 也需要修改为 loader 从服务端获取数据。

1
2
3
4
5
6
7
// root.tsx

// 如果在路由上没有其他的文件写 action 就会到root的action中处理
export async function action() {
const contact = await createEmptyContact();
return { contact };
}
1
2
3
4
5
6
7
8
9
import { Form } from "react-router";

export default function SidebarLayout() {
return (
<Form method="post">
<button type="submit">New</button>
</Form>
);
}

删除信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 当前文件 app/routes/contact.tsx 对应的路由是 contacts/:contactId
// 因此表但会提交到 contacts/:contactId/destroy
// 需要为此路由创建对应的文件

<Form
action="destroy"
method="post"
onSubmit={(event) => {
const response = confirm("Please confirm you want to delete this record.");
if (!response) {
event.preventDefault();
}
}}
>
<button type="submit">Delete</button>
</Form>
1
2
3
4
5
6
7
8
9
10
// app/routes/destroy-contact.tsx
import { redirect } from "react-router";
import type { Route } from "./+types/destroy-contact";

import { deleteContact } from "../data";

export async function action({ params }: Route.ActionArgs) {
await deleteContact(params.contactId);
return redirect("/");
}
1
2
3
4
5
// app/routes.ts

export default [
route("contacts/:contactId/destroy", "routes/destroy-contact.tsx"),
] satisfies RouteConfig;

Server Components 基础

SPA 应用渲染流程

简单服务器组件渲染流程

想要使用 RSC 必须要添加 condition 环境变量

1
node --conditions=react-server --watch server/app.js

在 RSC 架构中,组件被分为两类:

  • 服务端组件 (RSC):在服务器运行,直接读数据库,禁止使用 useState 或 useEffect。

  • 客户端组件 (CC):在浏览器运行,可以使用所有 Hook。

在编写服务端组件时,由于 Node.js 环境里依然能引用到完整的 react 包,可能会不小心写下 useState。这时程序不会报错,但会导致逻辑混乱。React 团队通过 exports 条件导出 解决了这个问题: 当 Node.js 开启了 react-server 条件时, import React from ‘react’ 拿到的其实是一个 阉割版的 React,它根本没有 useState 这些导出。一旦你误用,代码在服务器编译阶段就会直接报错,从而保证了 RSC 的纯净性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// React package.json 条件导出定义
{
"exports": {
".": {
"react-server": "./react.react-server.js",
"default": "./index.js"
},
"./package.json": "./package.json",
"./jsx-runtime": {
"react-server": "./jsx-runtime.react-server.js",
"default": "./jsx-runtime.js"
},
"./jsx-dev-runtime": "./jsx-dev-runtime.js"
}
}

创建一个 Server Component

当服务器拦截到客户端页面请求之后, 并不是像 SSR 渲染一样,直接返回渲染后的HTML字符串。而是会通过一个特定的API以流的方式返回给浏览器。

1
2
3
4
5
6
7
8
import { renderToPipeableStream } from 'react-server-dom-esm/server';

app.get('/rsc/:id', async (req, res) => {
const { pipe } = renderToPipeableStream(<App/>);
pipe(res);
})

// transfer-encoding chunked

在服务端 (Server Side) 任务是 序列化 (Serialization)。 它将 React 组件树(JSX)转化成一种特殊的、可流式传输的文本格式,最终返回的流的格式如下:

1
2
3
4
5
6
7
8
9
2:"$Sreact.suspense"
1:{"name":"App","env":"Server","owner":null}
0:D"$1"
4:{"name":"SearchResultsFallback","env":"Server","owner":"$1"}
3:D"$4"
3:[["$","li","0",{"children":["$","a",null,{"href":"#","children":[["$","img",null,{"src":"/img/fallback-ship.png","alt":"loading"},"$4"],"... loading"]},"$4"]},"$4"],["$","li","1",{"children":["$","a",null,{"href":"#","children":[["$","img",null,{"src":"/img/fallback-ship.png","alt":"loading"},"$4"],"... loading"]},"$4"]},"$4"],["$","li","2",{"children":["$","a",null,{"href":"#","children":[["$","img",null,{"src":"/img/fallback-ship.png","alt":"loading"},"$4"],"... loading"]},"$4"]},"$4"],["$","li","3",{"children":["$","a",null,{"href":"#","children":[["$","img",null,{"src":"/img/fallback-ship.png","alt":"loading"},"$4"],"... loading"]},"$4"]},"$4"],["$","li","4",{"children":["$","a",null,{"href":"#","children":[["$","img",null,{"src":"/img/fallback-ship.png","alt":"loading"},"$4"],"... loading"]},"$4"]},"$4"],["$","li","5",{"children":["$","a",null,{"href":"#","children":[["$","img",null,{"src":"/img/fallback-ship.png","alt":"loading"},"$4"],"... loading"]},"$4"]},"$4"],["$","li","6",{"children":["$","a",null,{"href":"#","children":[["$","img",null,{"src":"/img/fallback-ship.png","alt":"loading"},"$4"],"... loading"]},"$4"]},"$4"],["$","li","7",{"children":["$","a",null,{"href":"#","children":[["$","img",null,{"src":"/img/fallback-ship.png","alt":"loading"},"$4"],"... loading"]},"$4"]},"$4"],["$","li","8",{"children":["$","a",null,{"href":"#","children":[["$","img",null,{"src":"/img/fallback-ship.png","alt":"loading"},"$4"],"... loading"]},"$4"]},"$4"],["$","li","9",{"children":["$","a",null,{"href":"#","children":[["$","img",null,{"src":"/img/fallback-ship.png","alt":"loading"},"$4"],"... loading"]},"$4"]},"$4"],["$","li","10",{"children":["$","a",null,{"href":"#","children":[["$","img",null,{"src":"/img/fallback-ship.png","alt":"loading"},"$4"],"... loading"]},"$4"]},"$4"],["$","li","11",{"children":["$","a",null,{"href":"#","children":[["$","img",null,{"src":"/img/fallback-ship.png","alt":"loading"},"$4"],"... loading"]},"$4"]},"$4"]]
6:{"name":"SearchResults","env":"Server","owner":"$1"}
5:D"$6"
8:{"name":"ShipFallback","env":"Server","owner":"$1"}

通过 RSC Parser 查看被解析后的格式

在客户端 (Client Side),任务是 反序列化与重建 (Reconstruction)。 它通过 createFromFetch 方法接收服务端传来的流,并将其重新“缝合”到浏览器当前的 React 树中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { Suspense, createElement as h, startTransition, use } from 'react'
import { createRoot } from 'react-dom/client'
import { createFromFetch } from 'react-server-dom-esm/client'

const initialContentFetchPromise = fetch(`/rsc/something`)
const initialContentPromise = createFromFetch(initialContentFetchPromise)

function Root() {
const content = use(initialContentPromise)
return content
}

startTransition(() => {
createRoot(document.getElementById('root')).render(<Root/>)
})

数据获取

RSC的组件只会在服务端执行,所以它甚至可以直接在组件内部访问数据库,所有的数据获取直接写在组件中,就像是正常的后端代码一样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// ShipList.tsx (这是一个服务端组件)
import { db } from './db';

// 1. 注意:这是一个异步函数组件
export async function ShipList({ search }: { search: string }) {

// 2. 直接访问数据库!没有 fetch,没有 API 路由,没有加载状态管理
// 这段代码永远不会发送到浏览器,数据库凭证也不会泄露
const ships = await db.ship.findMany({});

return (
<SomeComponent/>
);
}

使用 Suspense

享用 RSC 组件带来的优点,必须要配额使用 Suspense 组件,下面这个代码示例中只有根组件使用了 Suspense

1
2
3
4
5
6
7
8
9
10
11

// 客户端代码,请求服务器接口,获取入口组件
import { createFromFetch } from 'react-server-dom-esm/client'
const initialContentFetchPromise = fetch(`/rsc/${initialLocation}`)
const initialContentPromise = createFromFetch(initialContentFetchPromise)

function Root() {
const content = use(initialContentPromise)
return content
}
createRoot(document.getElementById('root')).render(<Suspense fallback={"loading..."}><Root/></Suspense>)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 入口服务器组件 app.js
export function App({ shipId, search }){
return <div>
<List/>
<Detail/>
<div>
}

export function List(){
const data = await fetch("/data")
return <ul></ul>
}

export function Detail(){
const data = await fetch("/detail")
return <p></p>
}

它的渲染流程是:

  • root.render 触发,React 进入 Root 组件。

  • use(initialContentPromise) 被调用。此时 fetch 刚刚开始,连第一个 RSC 数据块(Chunk)可能还没解析完。

  • initialContentPromise 处于 Pending 状态。use 钩子会直接“抛出(throw)”这个 Promise。

  • React 捕获到异常,立刻中断 Root 的渲染,转而渲染 loading…

  • 服务器传回了第一批数据(比如 App 的外壳)。

  • createFromFetch 解析了这部分数据,initialContentPromise 的状态被更新。React 被“唤醒”,重新尝试渲染 Root。

  • 这次 use 返回了内容(即包含了两个异步服务端组件占位符的 content)。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    {
    "type": "ul",
    "key": null,
    "props": {
    "children": {
    "_payload": {
    "status": "pending",
    },
    }
    },
    }

    但很快发现:内容里那两个服务端组件因为没用 Suspense 包裹,它们共用了根部的挂起逻辑。如果这两个组件的数据还没到,React 会再次因为它们而挂起,所以继续显示根组件的 loading…,导致 App 外壳必须等到所有子组件加载成功之后才能显示。

所以对于异步组件尽可能的细粒度的控制加载,通过 Suspense 及时渲染已经加载的组件

1
2
3
4
5
6
7
export function App({ shipId, search }){
return <div>
<Suspense fallback={"loading..."}><List/></Suspense>
<Suspense fallback={"loading..."}><Detail/></Suspense>
<Detail/>
<div>
}

组件共享数据

因为 createContext API只能在客户端执行,所以在服务器组件中想要组件共享数据需要使用 nodeJS 中的一个API

1
2
3
4
5
6
7
8
9
10
//list-storage.js
import { AsyncLocalStorage } from 'node:async_hooks'

export const listData = new AsyncLocalStorage()

// 回调函数执行作用域中可以直接访问到保存的storage
listData.run(data, () => {
const { pipe } = renderToPipeableStream(<App/>)
pipe(res)
})
1
2
3
4
5
6
7
8
// List.js 
// 在其他组件中,消费数据
import { listData } from 'list-storage.js';

export default List() {
const data = listData.getStore();
return <ul></ul>
}

Client Component

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { EditableText } from './edit-text.js'
export async function ShipDetails() {
const { shipId } = shipDataStorage.getStore()
const ship = await getShip({ shipId })
const shipImgSrc = getImageUrlForShip(ship.id, { size: 200 })
return (
<div>
<EditableText text={ship.name} />
<img src={shipImgSrc} alt={ship.name} />
<h2>{ship.name}</h2>
<p>{ship.description}</p>
</div>
)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
'use client'
export function EditableText({ id, shipId, initialValue = '' }) {
const [edit, setEdit] = useState(false)
const [value, setValue] = useState(initialValue)
const inputRef = useRef(null)
const buttonRef = useRef(null)
return (
<div>
{edit ? (
<input ref={inputRef} value={value} onChange={e => setValue(e.target.value)} />
) : (
<span>{value}</span>
)}
<button ref={buttonRef} onClick={() => setEdit(!edit)}>
{edit ? 'Save' : 'Edit'}
</button>
</div>
)
}

当一个服务器组件需要交互的时候,必然会产生一些状态或是函数,如果想要实时验证这些状态,最好的办法是让这些操作发生在客户端而不是服务端,虽然客户端通可以过某种通讯机制与服务端通信,但是服务器是无状态的,这会非常浪费资源。

虽然状态与函数可以序列换,但是 RSC 的设计原则是:服务器组件不应该暴露任何状态或函数给客户端组件。 这就要求我们必须把这些状态和函数放在客户端组件中,可以通过 'use client' 标识一个组件是客户端组件, React 会在将这个组件发送给客户端前,把他处理为可以序列化的代码,并在 RSC Payload 中引用。

b:I[“/edit-text.js”, “EditableText”]
那如何把一个有 use client 标识的组件标记为我们能知道加载路径的状态呢

使用 react-server-dom-esm/node-loader, 需要配合 import { register } from 'node:module register是node中的一个特殊的api,可以在引用组件的文件的时候,和代码被执行之前,提供钩子做一些处理

获取所有的 export 并删除所有的import , 用 import {registerClientReference} from 'react-server-dom-es/server 包装,第一个参数是一个函数, 直接抛出错误,说明不能在服务器调用,因为这个一个 use client 组件,第二个参数是一个字符串,是这个组件在服务器的绝对路径, 第三个参数是组件的名称

registerClientReference 函数会返回一个react 组件说明

1
2
3
4
5
{
"$$type": "Symbol(react.client.reference)",
// 用#标识 组件名称
"id": "/absolute/path/to/edit-text.js#EditableText",
}

在客户端使用的时候,不可以在 客户端组件内直接 import 服务端组件,因为服务端组件可以访问数据库等敏感资源

1
2
3
4
5
6
// 错误写法
'use client'
import { ShipDetails } from './ShipDetails.js'
export function Detail() {
return <ShipDetails/>
}

正确的写法是使用组合的模式

1
2
3
4
'use client'
export function Detail({ShipDetails}) {
return ShipDetails
}

TrueNas 应用

MinIO

参考安装文档, 挂载点配置需要开启 ACL, 文件默认保存 export 目录下。

Nginx 配置参考MinIO 配置文档, 需要添加chunked_transfer_encoding off;

NextCloud

  • dataSet 创建
    父文件夹需要有以下权限 user:www-data,group:www-data,user:netdata,group:docker,user:root,group:root,user:apps
    user 文件夹需要有以下权限 user:www-data,group:www-data,user:apps
    data 文件夹需要有以下权限 user:www-data,group:www-data,user:apps
    db 文件夹需要有以下权限 user:netdata,group:docker,user:root,group:root,user:apps

  • APT Packages 需要添加 ffmpeg smbclient

  • host 需要填写真实访问的域名

  • Database Password 注意不要有 #@?& 等特殊符号

  • Certificate ID 选择默认证书,避免 nextCloud 提示警告,如果前端有 nginx 代理,需要使用 https

  • 在 config/config.php 中添加 maintenance_window_start 消除调度任务的警告

  • nginx配置

虚拟机配置

Nas 服务器开机虚拟化功能,配置以下字段,其余字段可以保持默认

Boot Method : UEFI

CPU Mode : Host Passthrough

Select Disk Type : VirtIO
virtIO 是一个优化的虚拟磁盘驱动程序,专为虚拟化环境设计,提供更高的性能和较低的开销。它支持更高的数据传输速率,特别适用于 I/O 密集型的应用场景。

Adapter Type:VirtIO

如果安装后提示 CD-ROM 加载错误,可以删除 CD-ROM 设备后重试

Jellyfin

nginx 代理按照官方文档操作

利用 sanoid 做快照

Truenas 快照,安装软件

1
2
3
cd /mnt/STORAGE/User/sunzhiqi 

git clone https://github.com/jimsalterjrs/sanoid.git

添加配置文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
cd sanoid
vi sanoid.conf

# ==============================================================================
# 1. 数据集绑定策略
# ==============================================================================

[STORAGE]
use_template = template_low_frequency
recursive = no

# 核心重要文档/代码(高频精细保护)
[STORAGE/Document]
use_template = template_production
recursive = yes

# 其它常规/大体积数据集(低频保护:每天 2 个,保留 14 天)
# 示例:你可以根据实际路径把下面的名字换成你的目标数据集
[STORAGE/Docker]
use_template = template_low_frequency
recursive = yes

[STORAGE/Jellyfin]
use_template = template_low_frequency
recursive = yes

[STORAGE/Media]
use_template = template_low_frequency
recursive = yes

[STORAGE/MinIO]
use_template = template_low_frequency
recursive = yes

[STORAGE/NextCloud]
use_template = template_low_frequency
recursive = yes

[STORAGE/PhotoPrism]
use_template = template_low_frequency
recursive = yes

[STORAGE/QBitTorrent]
use_template = template_low_frequency
recursive = yes

[STORAGE/User]
use_template = template_low_frequency
recursive = yes

# ==============================================================================
# 2. 排除目录列表(显式关闭自动快照和清理,防止空间被垃圾锁死)
# ==============================================================================
[STORAGE/QBitTorrent/data]
autosnap = no
autoprune = no

[STORAGE/Media/Porn]
autosnap = no
autoprune = no

[STORAGE/Media/Music]
autosnap = no
autoprune = no

[STORAGE/PhotoPrism/temp]
autosnap = no
autoprune = no


# ==============================================================================
# 3. 模板定义区域
# ==============================================================================

# ---------------- 模板 A:高频生产级模板 (核心数据防手抖/误删) ----------------
[template_production]
autosnap = yes
autoprune = yes

# 1. 高频快照:每 2 分钟 1 次,保留 30 个(刚好覆盖最近 1 小时)
frequent_period = 2
frequently = 30

# 2. 小时级:保留最近 24 小时
hourly = 24

# 3. 天级:保留最近 14 天
daily = 14

# 4. 周级:保留最近 4 周
weekly = 4

# 5. 月级:保留最近 12 个月
monthly = 12

# 6. 年级:保留最近 3 年
yearly = 3


# ---------------- 模板 B:低频经济型模板 (每天 2 个,保留 14 天) ----------------
[template_low_frequency]
autosnap = yes
autoprune = yes

# 关闭分钟级高频快照
frequently = 0

# 每天打 2 个快照(相当于每 12 小时 1 个),保留 14 天(共保留 14天 × 2个 = 28 个小时级快照)
hourly = 28

# 关闭其它长周期归档,保持轻量
daily = 0
weekly = 0
monthly = 0
yearly = 0

在 ZFS 文件系统和快照的机制下,当你“删除”一个文件时,你在文件管理器里确实看不到了,但只要这个文件被任何一个还存活的快照记录了,它所占用的物理磁盘空间就不会被真正释放,直到引用了该文件的最后一个快照被销毁。

但是,这并不意味着所有被删除的文件都会强制保留三年。文件的“物理寿命”取决于它被哪个级别的快照捕捉到了。

我们可以分情况来看:

  1. 昙花一现的文件(寿命极短)
    假设你刚刚新建了一个测试文件,30 分钟后就把它删除了。

这个文件只会被你的 frequently(2 分钟级) 快照捕捉到。

根据你的配置,frequently = 30 意味着高频快照只保留最近 1 小时。

结果: 在你删除该文件 1 小时后,包含了该文件的所有快照都被修剪(Prune)掉了,此时该文件占用的物理空间会真正释放。

  1. 存活了几天或几周的文件
    假设你下载了一部电影,放在那里看了半个月,然后觉得不好看删掉了。

这个文件会被你的 hourly、daily,甚至 weekly 快照捕捉到。

结果: 当它存活在 daily 和 weekly 快照中时,即便你删除了它,它的物理空间也要等到包含它最久远的那个 weekly 快照被删除时(也就是约 4 周后)才会被真正释放。

  1. 被“定格”在年度快照里的文件(寿命最长)
    假设你有一个文档,在你的硬盘里存了很久。Sanoid 的 yearly 快照通常会在每年的 1 月 1 日 00:00 触发。

如果这个文件在某年的 1 月 1 日刚好存在于硬盘中,它就会被写入年度快照。

即使你在 1 月 2 日把这个文件删除了,由于 yearly = 3 的规则,这个 1 月 1 日的快照会被保留整整三年。

结果: 没错,这个文件占用的物理磁盘空间,在未来的三年内绝对不会被释放。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 运行测试命令 
/mnt/STORAGE/User/sunzhiqi/sanoid/sanoid --cron --configdir=/mnt/STORAGE/User/sunzhiqi/sanoid --debug --readonly

# 如果报错
# Can't locate Config/IniFiles.pm in @INC (you may need to install the Config::IniFiles module) (@INC contains: /etc/perl /usr/local/lib/x86_64-linux-gnu/perl/5.36.0 /usr/local/share/perl/5.36.0 /usr/lib/x86_64-linux-gnu/perl5/5.36 /usr/share/perl5 /usr/lib/x86_64-linux-gnu/perl-base /usr/lib/x86_64-linux-gnu/perl/5.36 /usr/share/perl/5.36 /usr/local/lib/site_perl) at /mnt/STORAGE/User/sunzhiqi/sanoid/sanoid line 12.
# BEGIN failed--compilation aborted at /mnt/STORAGE/User/sunzhiqi/sanoid/sanoid line 12.

# 这个报错是因为 TrueNAS 系统底层的 Perl 环境缺少了 Sanoid 运行所依赖的解析模块 Config::IniFiles(Sanoid 用它来解析 .conf 配置文件)。

# 通过 cpanm 将 Perl 依赖安装到存储池中
cd /mnt/STORAGE/User/sunzhiqi/sanoid
curl -L https://cpanmin.us -o cpanm
chmod +x cpanm

# 将 Sanoid 依赖的 Perl 模块直接安装到本地目录
./cpanm -l /mnt/STORAGE/User/sunzhiqi/perl5 Config::IniFiles Capture::Tiny Data::Dumper Getopt::Long

# 如果报错,说明缺少必要的编译环境, 参考下面添加编译环境的配置后,重新运行安装命令
# ! Can't configure the distribution. You probably need to have 'make'. See /mnt/STORAGE/User/sunzhiqi/.cpanm/work/1786777437.2084762/build.log for details.

# 运行时通过环境变量声明模块路径:
PERL5LIB=/mnt/STORAGE/User/sunzhiqi/perl5/lib/perl5 /mnt/STORAGE/User/sunzhiqi/sanoid/sanoid --cron --configdir=/mnt/STORAGE/User/sunzhiqi/sanoid --debug --readonly

Perl安装配置编译环境

1
2
3
4
5
6
# 1. 临时开启 TrueNAS 的 apt 包管理器权限(如果之前没开过的话)
sudo install-dev-tools

# 2. 更新软件源并安装 make 和 gcc 等基础构建工具
sudo apt update
sudo apt install -y build-essential

添加到 Truenas 定时任务

1
2
3
4
# Description: Sanoid ZFS Snapshot Manager
# Run as user: root

PERL5LIB=/mnt/STORAGE/User/sunzhiqi/perl5/lib/perl5 /mnt/STORAGE/User/sunzhiqi/sanoid/sanoid --cron --configdir=/mnt/STORAGE/User/sunzhiqi/sanoid >/dev/null 2>&1

清除所有的快照文件

1
2
3
4
5
6
7
8
# 彻底清除 STORAGE 存储池下的【所有】快照
zfs destroy -r STORAGE@%

# 查看剩余快照数量
zfs list -t snapshot

# 停止正在进行的任务
midclt call core.job_abort 460487

快照被占用

虽然你前端终止了任务,但在底层系统中,这个快照正被某个进程持有了引用计数(Hold),或者底层还有残留的 zfs send/recv 或挂载(Mount/Clone)占用未完全释放。

Replication 底层调用的 zfs send 进程虽然被强行中断,但 Linux/ZFS 内核的管道(Pipe)和套接字缓存(Socket Buffer)往往需要几秒甚至十几秒来彻底关闭网络连接与文件描述符。在这个短暂的释放窗口期内,直接执行普通 zfs destroy 就会报 dataset is busy。

检查是否有快照“持有锁(ZFS Hold)”并释放,Replication 任务在传输时,为了防止快照被其他任务误删,会在快照上打一个叫做 user hold 的标记。任务异常中断时,这个标记可能残留。

1
2
3
4
5
zfs holds STORAGE/Docker/stack@auto-2026-08-16_10-00

# 输出
# NAME TAG TIMESTAMP
# 说明没有被打上 hold 锁

如果没有锁,通常是被容器挂载占用、存在未释放的 Clone 克隆卷,或者被 .zfs 隐藏目录缓存占用。请按以下顺序快速定位并强制销毁:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# 1. 检查该快照是否被克隆成了其他运行中的数据集
zfs get clones STORAGE/Docker/stack@auto-2026-08-16_10-00

# 如果 clones 有输出:说明有 Docker 卷或数据集是基于该快照克隆出来的,必须先销毁对应的克隆卷,才能删除父快照。
# NAME PROPERTY VALUE SOURCE
# STORAGE/Docker/stack@auto-2026-08-16_10-00 clones - -

# 使用 -d 标记推迟销毁(最快、无需停服务) -d 参数的作用机制:
# 加上 -d 后,ZFS 会立即把这个快照从系统的活跃索引中剔除(你再查快照列表就看不到了);
# 等内核完全关闭刚才 Replication 残留的连接和句柄时,内核会在后台自动触发物理销毁并释放磁盘空间,无需你一直等待或反复重试。

zfs destroy -d STORAGE/Docker/stack@auto-2026-08-16_10-00
# cannot destroy snapshot STORAGE/Docker/stack@auto-2026-08-16_10-00: dataset is busy

# 如果还是报错,快照被 ZFS 内核标记为了克隆源(Clone Parent)、或者底层存在挂载/传输死锁。
# 检查并清理底层残留的 zfs send / recv 僵尸进程
# Replication 被强杀后,后台的 SSH 通道或 ZFS 传输进程可能变成了 Defunct/D 状态(不响应普通 kill):

# 1. 查找所有与该快照或 zfs 相关的残留进程
ps aux | grep -E "zfs send|zfs recv|zettarepl|ssh" | grep -v grep
# 强行终止所有 zfs 传输进程
pkill -9 -f "zfs send"
pkill -9 -f "zfs recv"
pkill -9 -f "zettarepl"

# 2.检查快照内部是否被自动挂载,ZFS 有时会在访问或发送快照时将其挂载到隐藏目录,未自动卸载会导致 busy:
mount | grep "auto-2026-08-16_10-00"
# 强制懒卸载(Lazy Unmount)所有涉及该快照的路径
umount -l /mnt/STORAGE/Docker/stack/.zfs/snapshot/auto-2026-08-16_10-00 2>/dev/null

# 3.如果上述命令都查不到进程,说明是 TrueNAS 的中间件驱动(zettarepl / middlewared)在内存中卡住了该资源句柄,重启中间件服务即可瞬间释放内核占用,不会中断正在读写的存储池数据:
systemctl restart middlewared
1
2
3
# 2. 检查是否有容器或系统进程正在访问该快照
lsof | grep "auto-2026-08-16_10-00"
fuser -v /mnt/STORAGE/Docker/stack/.zfs/snapshot/auto-2026-08-16_10-00 2>/dev/null

Replication Tasks

不要选择向导模式,否则会自动生成快照任务,直接选择高级模式。

Properties Override;Properties Exclude;Periodic Snapshot Tasks;留空

Matching regular expression: ^autosnap_.*

Destination Dataset Read-only Policy

修改 Replication 任务目标端的只读策略,将默认的 REQUIRE 改为 IGNORE(忽略只读检查)或 SET(自动将其强制设为只读)

Replicate Specific Snapshots 不勾选

在生产环境中,本地源端往往为了防误删设置了极高频率的快照(例如 Sanoid 的 frequently 每 2 分钟一次、每小时一次、每天一次,一天能打几百上千个快照)。

不开启此选项:Replication 会把这几百个 2 分钟级的密集快照全部推送到目标端;

开启此选项:你可以指定一个时间窗口粒度(如 Hourly 每小时、Daily 每天、Weekly 每周)。

Only Replicate Snapshots Matching Schedule 不勾选,没有勾选这个选项时的行为(默认)

假设本地用 Sanoid 每 2 分钟打一次快照(frequently),每天产生几百个快照;而 Replication 任务设定为每天凌晨 2 点执行一次:

默认行为:凌晨 2 点任务启动时,系统会把你今天白天产生的几百个 2 分钟级、1 小时级的所有零碎快照,一股脑全量打包发送到目标 NAS。

结果:目标 NAS 会完全镜像你的所有高频快照,导致目标机也存了大量密集快照。

勾选了 Only Replicate Snapshots Matching Schedule 后的行为

过滤行为:Replication 引擎在同步时,会严格比对快照的生成时间点与你 Replication 任务的 Schedule(调度计划)。

结果:如果复制任务设定为“每天凌晨 2 点(Daily)”,那么它只会同步对应在 Daily 时间节点生成的快照;

平时每 2 分钟、每小时产生的零碎快照会被直接过滤掉,不发送到远端。

不要勾选(推荐):如果你希望目标机是主机的完整镜像,或者希望借助 ZFS 增量传输把过去一整天的历史快照全带过去,保持不勾选。
建议勾选:如果本地打了极其密集的高频快照(如 1 分钟/2 分钟级),但目标 NAS 是异地低速带宽或容量较小,你只想每天/每周同步一个大版本归档快照,则勾选此项。

快照回滚

比“直接回滚”更安全优雅的 2 种找回方式

只找回单个文件:利用隐藏的快照目录(最推荐)

ZFS 在每个数据集的挂载点根目录下都提供了一个隐藏的只读目录:.zfs/snapshot/。

在 SSH 或终端中进入该目录:

1
2
cd /mnt/STORAGE/Document/.zfs/snapshot/
ls -la

你会看到所有历史快照的文件夹,点进对应的快照目录,直接把被误删的文件 cp(复制)回原来的位置即可。

无损恢复:从快照克隆出新数据集,如果你想彻底回到过去的某个完整状态,但又不想破坏之后生成的新快照:

在 TrueNAS Web UI 的快照列表中,找到目标快照,选择 “Clone to new dataset(克隆为新数据集)”。

系统会以零空间占用的方式瞬间生成一个新的独立 Dataset(例如 STORAGE/Document_backup)。

你可以进去核对、提取所需的数据,确认无误后再决定是否替换原数据集。

关闭网卡节能

1
ethtool --set-eee enp1s0 eee off 

System Settings (系统设置) -> Advanced (高级) -> Init/Shutdown Scripts (初始化/关闭脚本)。

点击 Add (添加):

Type (类型): Command

Command (命令): ethtool --set-eee enp1s0 eee off

When (执行时机): 非常关键:必须选 Post Init(系统与网络初始化完成后执行),避免过早执行时网口未就绪

Babel 源码与插件

调式源码

1
2
3
4
5
6
7
8
git clone https://github.com/<your-github-username>/babel
cd babel
make bootstrap

# 在想要调试文件的入口处添加断点
# -i 指定测试package
# -t 指定测试用例 fixtures
yarn run jest -i packages/babel-parser -t 'es2016/simple parameter list/arrow function'

插件开发最小化环境

相关依赖包

1
2
3
4
5
6
7
{
"babel-plugin-tester": "^11.0.4",
"jest": "^29.7.0",
"ts-jest": "^29.2.6",
"ts-node": "^10.9.2",
"typescript": "^5.8.2"
}

测试文件入口与 fixtures 用例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//__test__/index.ts

import { pluginTester } from "babel-plugin-tester";
import insertLogPlugin from "../plugins/babel-plugin-insert-log";
import path from "path";

pluginTester({
plugin: insertLogPlugin,

babelOptions: {
plugins: ["@babel/plugin-syntax-jsx"],
},
fixtures: path.join(__dirname, "fixtures"),
});
1
2
3
4
5
6
7
// __tests__/fixtures/in-arrow-function/code.ts
const a = () => console.log(1);

// __tests__/fixtures/in-arrow-function/output.ts
const a = () => {
console.log(1);
};

jest 配置文件, 排除 fixtures 目录下的测试用例,避免多次执行

1
2
3
4
5
6
7
8
/** @type {import('ts-jest').JestConfigWithTsJest} **/
module.exports = {
testEnvironment: "node",
transform: {
"^.+.tsx?$": ["ts-jest", {}],
},
testMatch: ["**/__tests__/**/*", "!**/fixtures/**/*"],
};

babel-parser

使用多个类继承,完善 parser 功能

1
2
3
4
5
6
7
8
export default class Parser extends StatementParser{}
abstract class StatementParser extends ExpressionParser{}
abstract class ExpressionParser extends LValParser{}
abstract class LValParser extends NodeUtils{}
abstract class NodeUtils extends UtilParser{}
abstract class UtilParser extends Tokenizer{}
abstract class Tokenizer extends CommentsParser{}
class CommentsParser extends BaseParser{}

解析 le\\u0074 x = 5 流程:

  • 实例化 Parser,调用 parse 方法开始解析

  • 初始化 file, grogram 节点

  • 尝试解析 token

    • 跳过空白符,注释等

    • 根据第一个字符判断要如何解析, 比如 l 为一个小写字母, 会被当作 Identifier 解析

    • 尝试读取完成的标识符

      this.state.pos += str <= 0xffff ? 1 : 2; 如果字符 charCode 大于 0xffff 例如 龘, 则向后移动两个字符

      如果匹配到 \, 则判断是否是一个 Unicode 转义序列,后面三位必须是 \\u 开头,如果不是则标记错误

      如果是一个转移字符,尝试读取这个转义字符并返回, 因此第一个 token 会是 let

    • 解析 Program 节点,let 作为 ExpressionStatement 加入 Program 节点,保存在 body 数组中

      继续尝试解析 x = 5 这个表达式作为 AssignmentExpression,x 和 5 作为 Identifier 和 NumericLiteral 加入 AssignmentExpression 分别作为 left 和 right

最终形成的树结构就是 ast.

  • Copyrights © 2015-2026 SunZhiqi

此时无声胜有声!

支付宝
微信