xyOps 备份格式(XYBK v1.0)完全指南:基于 NDJSON 的批量数据导出与导入机制

原创2026-10-09 17:21:111,401 阅读

xyOps 备份格式(XYBK v1.0)完全指南:基于 NDJSON 的批量数据导出与导入机制

XYBK(xyOps Backup Format)是 xyOps 系统用于批量导出与导入数据的开放文件格式规范,支撑着管理界面中的 "Export Data" 与 "Import Data" 功能,可实现跨实例的数据迁移、备份归档与灾难恢复。读完本文,你将掌握 XYBK 的文件结构、三种 NDJSON 记录类型、各数据分区(Lists / 数据库索引 / 用户数据 / Buckets / Secrets / 任务与工单文件 / 监控时间线)的完整格式,以及导入导出的底层实现原理与安全特性。

XYBK 是什么

XYBK 是 xyOps 官方定义的备份格式,当前版本为 v1.0,发布日期为 2025 年 12 月 12 日,作者为 Joseph Huckaby(PixlCore)。其设计目标非常明确:

  • 批量导出:管理员可从运行中的 xyOps 实例中按需选择数据类别(Lists、数据库索引、Extras)导出,或直接导出全部数据;
  • 批量导入:将导出的数据文件恢复到同一实例或全新实例中,用于迁移、备份、审计或系统重建;
  • 开放可读:文件本质是 UTF-8 文本(NDJSON 格式 + 注释行),并可选 Gzip 压缩传输,任何文本编辑器或命令行工具都能直接查看内容。

XYBK 主要被 Admin 的 "Export Data" 与 "Import Data" 两个功能消费:导出器将 Gzip 压缩的 NDJSON 文件流式发送给客户端;导入器则同时接受纯 NDJSON(.txt)或 Gzip 压缩的 NDJSON(.txt.gz)。

在 xyOps 的整体架构中,XYBK 是数据平面(存储与数据库)的"可移植快照协议":它统一了底层两类存储——pixl-server-storage 的 api_admin_export_data 与 api_admin_import_data 两个 API 处理器中。

文件结构:NDJSON 与三类行

一个 XYBK 文件是一行接一行的 UTF-8 文本序列,只允许出现三种行:

行类型 规则 导入器行为
注释 以 # 开头 忽略
空行 空行或仅含空白字符 忽略
记录 一行一个完整 JSON 对象(NDJSON) 按顺序处理

文件开头是一段由导出器写出的短头部注释块(仅用于人类可读,导入器不会解析):

# xyOps Data Export v1.0
# Hostname: [host]
# Date/Time: [string]
# Format: NDJSON

在头部之后,文件包含一个或多个带标签的分区(注释行 + NDJSON 记录)。分区标题(Section Header)同样只是提示性信息,导入器会忽略它们——导入器只处理以 { 开头的 JSON 行。

这意味着:你可以手动编辑、裁剪、拼接 XYBK 文件而不破坏其语义,只要保证 JSON 记录行的顺序与结构正确即可。

三种记录类型(Record Types)

每一行 NDJSON 记录必须是且只能是以下三种形式之一,导入器据此分流处理。

Storage Put(键值写入)

{ "key": "<storage_key>", "value": /* json_or_base64 */ }
  • 直接以 key/value "put" 方式写入 pixl-server-storage;
  • 对于二进制键(binary key),value 是 Base64 字符串;导入时通过 key 模式自动识别二进制键,并解码回原始字节(详见下文导入实现的 isBinaryKey 处理);
  • 对于 JSON 键,value 是 JSON 对象,原样存储。

Storage Command(存储命令)

{ "cmd": "<method>", "args": [ /* arg1, arg2, ... */ ] }
  • 调用 pixl-server-storage 上的某个存储 API 方法,例如 listDelete;
  • 参数原样传递,导入器在内部自行追加回调;
  • 典型用途:导出器在重建列表前先删除目标列表("先删后建"),保证导入结果与导出源一致、不产生残留数据。

Database Record(数据库记录)

{ "index": "<index_id>", "id": "<record_id>", "record": { /* ... */ } }
  • 通过 unbase.insert(index, id, record) 向 Unbase 数据库索引插入一条记录;
  • 语义为"按 ID 创建或替换"(create or replace)——同一 ID 的记录被重复导入时,后者覆盖前者。

分区(Sections)与各类数据组织

导出器会以注释行添加分区标题,把相关记录分组,便于人工阅读。可遇到的分区标题如下:

  • # List: <key>
  • # Database Index: <index> (<query>)
  • # User Data:
  • # Bucket Data
  • # Bucket Files
  • # Encrypted Secret Data
  • # Job Files (<query>)
  • # Ticket Files (<query>)
  • # Monitor Timeline Data (<query>)

Lists(列表)

xyOps 的许多子系统都被建模为存储"Lists"(分页数组),因此列表导出是 XYBK 文件中最常见的组成部分。导出内容包括列表元数据 + 所有分页,其格式固定为三步:

  1. 先删除目标列表,为导入的新列表腾出空间:
{ "cmd": "listDelete", "args": [ "<key>", false ] }
  1. 写入列表头(key/value 形式,包含分页元数据):
{ "key": "<key>", "value": { "page_size": 100, "first_page": 1, "last_page": 5, "length": 500, "type": "list" } }
  1. 依次写入每个分页(从 first_page 到 last_page 含端点):
{ "key": "<key>/<page>", "value": { "type": "list_page", "items": [ /* ... */ ] } }

其中 items 数组即该页的实际列表条目。在导出实现中(lib/api/admin.js),导出器先用 storage.get(item.key) 取到列表头,随即写出 listDelete 命令与头记录,再以 async.whilst 从 list.first_page 迭代到 list.last_page,逐个读取 item.key + '/' + page_idx 分页键并写出。

在 global/ 命名空间下,典型的列表键包括:

alerts、api_keys、buckets、categories、channels、events、groups、monitors、plugins、secrets、tags、users、roles、web_hooks

这与 internal/ui.json 中 Admin UI 的 list_list 配置一一对应(该配置还包含每个列表的图标)。

注意:用户账户记录本身并不存储在 global/users 列表中——该列表保存的是名册(roster)。真正的用户记录以 users/<username> 键导出,见下文"User Data"。

Database Indexes(数据库索引)

导出可包含完整的 Unbase 索引,且可选地通过查询(query)过滤。每条记录形如:

{ "index": "<index_id>", "id": "<record_id>", "record": { /* ... */ } }

常见的索引 ID 包括:alerts、jobs、servers、snapshots、activity、tickets,与 internal/ui.json 中 database_list 配置一致。

从源码看,索引导出使用底层索引器 API(self.storage.searchRecords(item.query, index_config, ...))以便获得更多控制权,结果按 ID 从新到旧排序,并支持 max_rows 截断(lib/api/admin.js)。默认查询为 '*'(匹配全部)。

User Data(用户数据)

用户账户记录以存储键导出:

  • users/<normalized_username> → { ...user record... }

其中 <normalized_username> 是用户名的小写化、去除非单词字符的结果。导出实现(lib/api/admin.js)遍历 global/users 名册列表,为每个用户计算 user_path = 'users/' + username.toLowerCase().replace(/\W+/g, ''),然后写出用户记录。

若选择了 "User Avatars" extra,还可能包含以下二进制键(Base64 值):

  • users/<normalized_username>/avatar/64.png
  • users/<normalized_username>/avatar/256.png

用户记录中的密码以加盐 bcrypt 哈希存储,并原样导出——绝不导出明文密码。

Buckets(Buckets 桶数据)

选择 Buckets 列表后,导出器还会包含桶数据,并视 extras 决定是否包含文件载荷:

  • key: buckets/<bucket_id>/data(JSON):每个桶的元数据/数据对象;
  • key: buckets/<bucket_id>/files(JSON):以存储路径为键的每个文件载荷清单,文件载荷本身为 Base64。

在源码中,bucketData 遍历 global/buckets 列表读取 buckets/<id>/data;bucketFiles 则先读取 buckets/<id>/files 清单,逐个校验大小(默认上限 1 MB,max_size = 1024 * 1024),超限文件跳过,最后先写各文件载荷、再写清单(lib/api/admin.js)。

Secrets(加密机密数据)

Secret 保险库的元数据存放在 global/secrets 列表中(像普通列表一样导出)。而机密载荷本身导出为:

  • key: secrets/<secret_id> → 值即存储的加密 blob

内容是 Base64 编码的加密数据——机密绝不会以明文导出(lib/api/admin.js)。

Job 与 Ticket 文件 / 日志

通过 extras 选择后,任务(job)与工单(ticket)附件按 key 导出,载荷为 Base64。任务还支持导出压缩日志:

  • Job 文件:key: <file_path>,即任务 files[] 列表中的每个文件(受大小上限约束);
  • Job 日志:key: logs/jobs/<job_id>/log.txt.gz,若存在且低于大小上限;
  • Ticket 文件:key: <file_path>,即工单 files[] 列表中的每个文件(受大小上限约束)。

源码细节(lib/api/admin.js):jobFiles 默认查询为 tags:_files(仅扫描带文件的任务),ticketFiles 默认查询为 '*';两者都按 1 MB 大小过滤;日志导出要求 job.log_file_size < max_size 且任务无 output 内联输出。

Monitor Timeline Data(监控时间线数据)

服务器监控的时间序列数据以列表形式存储在 timeline/<server_id>/<system_id> 键下。选中 monitor_data 后,导出器对每台服务器、每个监控系统(self.systems)构造时间线键,并复用列表导出逻辑(exportTypes.list)逐条导出,因此时间线数据在文件中表现为普通 List(lib/api/admin.js)。

数据选择:Lists、Indexes 与 Extras

Admin UI 提供三个选择分组,映射到导出的条目类型:

Lists(列表):global/ 下的一个或多个标准列表(见上文列表清单)。选择 users 会同时触发 users/<username> 的 "User Data" 导出;选择 buckets 触发 "Bucket Data";选择 secrets 触发 "Encrypted Secret Data"。

Indexes(索引):按 ID 选择一个或多个 Unbase 索引(可选查询过滤),如 jobs、servers、tickets 等。

Extras(附加项):可选的载荷与时间序列:

  • job_files — 任务附件
  • job_logs — 任务压缩日志
  • bucket_files — 桶文件载荷
  • ticket_files — 工单附件
  • monitor_data — 监控时间线
  • stat_data — 统计历史(内部映射为 global/stats 列表导出)
  • user_avatars — 用户头像

导出器可被指示在任一分组中选择 "all"。内部实现中(lib/api/admin.js),这些选择会被展开成一个由上述记录类型构成的条目流(items 数组),逐个执行。例如:选择列表 users + extra user_avatars 会生成 { type: 'users', avatars: true } 条目;选择 extras 中的 job_files/job_logs 会生成 { type: 'jobFiles', logs, files } 条目。

值得注意的是,导出的 API 还支持单次传输令牌(single-use transfer token):携带 params.token 时,服务端会校验 transferTokens 中对应的令牌,将其携带的会话与参数合并进本次请求后立即销毁,用于跨实例的安全数据传输(lib/api/admin.js)。同时,导出操作要求用户拥有 bulk_export 特权(lib/api/admin.js),该特权定义于 internal/ui.json 的权限清单中。

压缩与传输

导出文件以 Gzip 流式输出,文件名形如:

xyops-data-export-YYYY-MM-DD-<id>.txt.gz

例如 xyops-data-export-2025-12-12-aBcDeF.txt.gz。导入器则接受纯 .txt NDJSON 文件或 Gzip 压缩的 .txt.gz 文件均可。

源码证实:导出响应设置 Content-Type: application/gzip 与 Content-Disposition: attachment 头,通过 zlib.createGzip() 流管道直接写入 HTTP 响应对象,真正做到边读边导、边压边传(lib/api/admin.js);导入器则根据上传文件的文件名(\.gz$)或 MIME 类型(gzip)自动判断是否先 gunzip 解压到临时文件再逐行处理(lib/api/admin.js)。

导出结束时会写出 # End of file 注释标记文件结尾(lib/api/admin.js)。

安全特性

XYBK 在格式层面刻意保证敏感材料的"加密或哈希化":

  • API Keys:只导出加盐哈希。明文 API 密钥材料永远不会出现在导出文件中;key 字段是创建时存储的加盐 SHA-256 摘要。
  • Secrets:机密载荷以加密 blob(Base64)导出,明文永不导出。
  • Users:密码以加盐 bcrypt 哈希存储与导出,不含任何明文密码。

这意味着 XYBK 文件本身是可以相对放心分发/归档的——即便拿到备份文件,也无法直接读取到密钥明文或密码原文。

完整示例

以下片段展示列表导出、API 密钥与一条数据库记录的组合(截取自文档示例):

# xyOps Data Export v1.0
# Hostname: joemax.xyops.io
# Date/Time: Tue Nov 18 2025 12:01:27 GMT-0800 (Pacific Standard Time)
# Format: NDJSON

# List: global/alerts
{"cmd":"listDelete","args":["global/alerts",false]}
{"key":"global/alerts","value":{"page_size":100,"first_page":0,"last_page":0,"length":5,"type":"list"}}
{"key":"global/alerts/0","value":{"type":"list_page","items":[{"id":"load_avg_high","title":"High CPU Load", ...}]}}

# List: global/api_keys
{"cmd":"listDelete","args":["global/api_keys",false]}
{"key":"global/api_keys","value":{"page_size":100,"first_page":0,"last_page":0,"length":2,"type":"list"}}
{"key":"global/api_keys/0","value":{"type":"list_page","items":[{"key":"<salted_sha256>","active":1, ...}]}}

# Database Index: tickets (*)
{"index":"tickets","id":"tmhzbmbagig","record":{"subject":"Alert: High Active Jobs on raspberrypi", ...}}

解析规则与导入实现细节

导入器的解析规则可总结为:

  • 注释行与空行被忽略,只有以 { 开头的行才会被解析;
  • 行按顺序处理——命令可能先准备状态(如删除列表),其后的 put 再写入数据;
  • key/value 记录通过 storage.put(key, value) 写入;二进制键在导入时自动 Base64 解码;
  • 数据库记录通过 unbase.insert(index, id, record) 插入;
  • 存储命令会以给定参数调用存储引擎上的对应方法;
  • 导入器逐行流式校验,最多收集 100 个错误用于报告,遇到非致命错误会继续处理。

在 lib/api/admin.js 的 api_admin_import_data 中,这些规则被完整落地:

  1. 单任务限制:同一时刻只允许一个维护任务(type: 'maint')运行,否则拒绝导入;
  2. 解压/移动:.gz 文件先解压到 os.tmpdir() 下的临时文件(xyops-import-temp-<id>.txt),防止处理过程中上传文件被清理;也支持 params.format == 'cronicle' 时的旧版 Cronicle 数据格式转换;
  3. 预计数:先用 readline 预扫文件,统计以 { 开头的行数,用于报告导入进度;
  4. Danger 模式:传入 params.danger 时(主要用于与外部系统同步的增量更新),跳过"停调度器"步骤;否则导入会自动:关闭调度器(scheduler.enabled 状态置 false,警告提示需手动重新启用)、静默冲刷所有 queued 任务、中止所有运行中任务,并等待其全部结束;
  5. 逐行处理:Tools.fileEachLine 以 32 KB 缓冲逐行读取,仅解析 ^\s*\{ 的行,并按三种记录类型分流:
    • row.index && row.id && row.record → self.unbase.insert(...);
    • row.key && row.value → 若 self.storage.isBinaryKey(row.key) 为真,则 Buffer.from(row.value, 'base64') 解码,再 self.storage.put(...);
    • row.cmd && self.storage[row.cmd] && row.args → 校验命令在白名单内后调用:put|copy|rename|delete|listCreate|listPush|listUnshift|listCopy|listRename|listDelete|hashCreate|hashPut|hashUpdate|hashCopy|hashRename|hashDelete|hashDeleteAll——白名单之外的命令被记作错误并跳过;
    • 其余行记 "Unknown format" 错误;
  6. 状态刷新:导入完成后,重新加载 ui.globalMemoryLists 配置的所有全局列表进内存、重编译监控与告警表达式、重建 Secret 加密缓存、重新准备插件(Plugins)、向所有监控 Worker 广播更新,并强制所有已认证用户重连以获取新数据;
  7. 报告:任务详情(job.details)中包含导入统计——处理行数(Lines Processed)、数据库记录数(DB Records Imported)、存储键写入数(Storage Keys Written)、命令执行数(Commands Executed),以及错误与警告清单(各最多展示 100 条),同时附上文件名、大小、MIME 类型与格式。

导出实现:从选择到流式文件

api_admin_export_data(lib/api/admin.js)的流程要点:

  1. 参数归一化:支持三种调用形态——items 数组(直接指定 { type, key|index, query? })、lists/indexes/extras 数组、或将三者置为字符串 "all"(此时分别展开为 ui.list_list、ui.database_list、ui.extra_list 的全部 ID);
  2. 展开选择:按上文"数据选择"一节的映射关系,把 lists/indexes/extras 展开为具体条目类型(list、index、users、bucketData、bucketFiles、secretData、jobFiles、ticketFiles、monitorData),并做数组类型校验;
  3. 权限与会话:要求有效用户且具备 bulk_export 特权;
  4. 流式输出:启动内部任务(title "Custom bulk data export"),设置 HTTP 流响应头,zlib.createGzip() 管道直连响应对象,先写出四行头部注释(v1.0 / Hostname / Date/Time / Format);
  5. 顺序迭代:async.eachSeries 按条目顺序执行各 exportTypes.<type> 处理器,每个处理器内部又以 async.eachSeries/async.whilst 串行写出该类型的所有记录,保证记录顺序稳定、内存占用可控(文件载荷按需加载为 Buffer 后转 Base64);
  6. 中断处理:响应对象监听 error/close/finish 事件——若客户端断连或出错,立即销毁 gzip 流、结束内部任务并停止后续迭代(export_aborted 标志);
  7. 收尾:全部条目完成后写出 # End of file 并结束任务。

版本与兼容性

本规范定义了 XYBK v1.0。导出器在文件头部注释中始终写入 # xyOps Data Export v1.0 作为版本标识。

未来版本可能新增分区标题与记录形态,但导入器始终忽略注释,只依赖这里定义的三种 NDJSON 记录类型——Storage Put、Storage Command 与 Database Record。这意味着 XYBK v1.0 文件具备很强的向后兼容性:即便未来导出器写出新的分区与记录结构,v1.0 及之后的导入器也能正确处理既有文件,不会因新增内容而崩溃。这为跨版本迁移与长期归档提供了格式层面的保障。

延伸阅读

  • 规范原文:docs/xybk.md
  • 导入实现(含命令白名单、Danger 模式、报告统计):lib/api/admin.js
  • 导出实现(含选择展开、流式 gzip、各类导出处理器):lib/api/admin.js
  • UI 数据选择配置(Lists / Indexes / Extras 清单与图标):internal/ui.json
  • 底层存储与数据库参考:pixl-server-storage(含 Lists 分页数组语义)与 pixl-server-unbase(Unbase 索引),其 Lists 内部结构决定了本文所述的"头记录 + 分页记录"导出形态。
登录后查看全文
xyops