💾 数据存储进阶:连接数据库¶
本章解决一个问题:数据量大了 / 要跨服共享 / 要让外部程序(网站、后台)也能读写时,怎么把 KubeJS 接到数据库上? 前置阅读:配置文件与数据储存(JSON /
persistentData)、可操作 UI(把数据画成界面)
0. 先判断:你到底需不需要数据库?¶
| 你的情况 | 该用什么 |
|---|---|
| 玩家余额、击杀数、开关状态(一人一份、跟人走) | ✅ player.persistentData(NBT,最省事) |
| 价目表、活动配置、排行榜快照(要手改、好备份) | ✅ JsonIO 读写 JSON 文件 |
| 几万行以上、要随手筛选/排序/join | ⬆️ 数据库(本章) |
| 多个服务器/多个程序同时读写同一份数据 | ⬆️ 数据库(本章) |
| 想让网站后台改数据、游戏里立刻生效 | ⬆️ 数据库 + HTTP 接口(本章) |
💡 一句话:JSON 是"文件",数据库是"服务"。JSON 够用就别上库;要用库,先想清楚谁连它、怎么连。
1. 三条路线(先选路线,再抄代码)¶
| 路线 | 怎么连 | 优点 | 代价 |
|---|---|---|---|
| A. HTTP 桥(推荐) | KJS 用 HttpClient 调你自己的后端(PHP/Node),后端连数据库 |
不用改客户端、不用装驱动、跨服天然可用、能限流/审计;后端改 SQL 不用重开服 | 要多写一个后端接口(几十行) |
| B. JDBC 直连 | KJS 里 Java.loadClass('java.sql.DriverManager') 直连 |
链路短、不用额外服务 | 必须把驱动 jar 弄进 classpath(1.20.1 下最麻烦的一步);脚本里会出现连接串/密码;阻塞 IO 要自己控制 |
| C. 现成前置 mod | 用第三方"数据库 mod"提供的 API | 可能开箱即用 | 需要你自行确认版本/维护状态/是否与你的整合包冲突 |
✅ 本章重点讲 A(能直接跑)和 B(讲清楚坑在哪),C 只提思路,不替你选 mod。
2. 路线 A:HTTP 桥(推荐)——KJS 调后端接口¶
思路:KubeJS 不碰数据库,只发 HTTP 请求;真正的 SQL 由你服务器上的 PHP/Node 程序执行。
2.1 后端最小示例(PHP + PDO,放宝塔网站目录即可)¶
<?php
// /www/wwwroot/你的站/kjs/db.php
// 作用:KJS 的"数据网关"。只暴露需要的操作,不要写成万能 SQL 执行器!
header('Content-Type: application/json; charset=utf-8');
const TOKEN = '换成你自己的长随机串'; // ② 简单 token 校验(能挡住外部乱调)
const DSN = 'mysql:host=127.0.0.1;dbname=serverdata;charset=utf8mb4';
const DB_USER = 'dbuser';
const DB_PASS = 'dbpass';
// ① 校验 token(KJS 那边要带同样的值)
if (($_SERVER['HTTP_X_TOKEN'] ?? '') !== TOKEN) {
http_response_code(403);
echo json_encode(['ok' => false, 'error' => 'bad token']);
exit;
}
$in = json_decode(file_get_contents('php://input'), true) ?: [];
$op = $in['op'] ?? '';
$pdo = new PDO(DSN, DB_USER, DB_PASS, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_EMULATE_PREPARES => false, // ③ 真预处理,防 SQL 注入
]);
try {
switch ($op) {
// 读余额
case 'getMoney':
$st = $pdo->prepare('SELECT money FROM tdm_players WHERE uuid = ?');
$st->execute([$in['uuid']]);
$row = $st->fetch(PDO::FETCH_ASSOC);
echo json_encode(['ok' => true, 'money' => $row ? (int)$row['money'] : 0]);
break;
// 改余额(delta 可以是负数)
case 'addMoney':
$st = $pdo->prepare(
'INSERT INTO tdm_players (uuid, name, money) VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE name = VALUES(name), money = money + VALUES(money)');
$st->execute([$in['uuid'], $in['name'] ?? '', (int)$in['delta']]);
echo json_encode(['ok' => true]);
break;
// 排行榜前 20
case 'top':
$st = $pdo->query('SELECT name, money FROM tdm_players ORDER BY money DESC LIMIT 20');
echo json_encode(['ok' => true, 'list' => $st->fetchAll(PDO::FETCH_ASSOC)]);
break;
default:
http_response_code(400);
echo json_encode(['ok' => false, 'error' => 'unknown op']);
}
} catch (Throwable $e) {
http_response_code(500);
echo json_encode(['ok' => false, 'error' => $e->getMessage()]);
}
建表(跑一次就行):
CREATE TABLE tdm_players (
uuid CHAR(36) PRIMARY KEY,
name VARCHAR(32) NOT NULL DEFAULT '',
money BIGINT NOT NULL DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
2.2 KJS 侧:封装成一个"数据网关"函数¶
// server_scripts/db.js —— KubeJS 侧(1.20.1 / KubeJS 6.1)
const API = 'https://你的域名/kjs/db.php' // 后端地址
const TOKEN = '和后端一致的 token'
const TIMEOUT = 5 // 秒:绝不要让它无限等,否则会卡服
/**
* 发一个请求给后端,返回解析后的 JSON 对象(失败返回 null)
* @param {object} body 要发的数据,例如 {op:'getMoney', uuid:'…'}
*/
function dbCall(body) {
try {
const HttpClient = Java.loadClass('java.net.http.HttpClient') // JDK 自带,KJS 白名单允许
const HttpRequest = Java.loadClass('java.net.http.HttpRequest')
const BodyPublishers = Java.loadClass('java.net.http.HttpRequest$BodyPublishers')
const URI = Java.loadClass('java.net.URI')
const Duration = Java.loadClass('java.time.Duration')
const client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(TIMEOUT))
.build()
const req = HttpRequest.newBuilder()
.uri(URI.create(API))
.timeout(Duration.ofSeconds(TIMEOUT))
.header('Content-Type', 'application/json; charset=utf-8')
.header('X-Token', TOKEN) // 对应后端校验
.POST(BodyPublishers.ofString(JSON.stringify(body))) // JS 对象 → JSON 字符串
.build()
const res = client.send(req, Java.loadClass('java.net.http.HttpResponse$BodyHandlers').ofString())
if (res.statusCode() !== 200) {
console.warn(`[DB] HTTP ${res.statusCode()}:${res.body()}`)
return null
}
return JSON.parse(res.body()) // JSON 字符串 → JS 对象
} catch (e) {
console.warn('[DB] 请求失败:' + e) // 后端挂了也绝不能崩脚本
return null
}
}
2.3 用它:读余额 / 加钱 / 排行榜¶
// ① 读余额(带本地兜底:拿不到就用 persistentData 里的缓存)
function getMoney(player) {
const r = dbCall({ op: 'getMoney', uuid: String(player.uuid) })
if (r && r.ok) {
player.persistentData.moneyCache = r.money // 缓存一份,后端不可用时能顶
return r.money
}
return player.persistentData.moneyCache || 0
}
// ② 加钱(先本地记账,再推给后端;后端失败不清账,下次补推)
function addMoney(player, delta) {
player.persistentData.moneyCache = (player.persistentData.moneyCache || 0) + delta
const r = dbCall({ op: 'addMoney', uuid: String(player.uuid), name: player.username, delta: delta })
if (!r || !r.ok) player.persistentData.moneyDirty = true // 标记待补推
return player.persistentData.moneyCache
}
// ③ 排行榜(用界面展示,配合自动排版)
ItemEvents.rightClicked('kubejs:rank_wand', event => {
const r = dbCall({ op: 'top' })
const list = (r && r.ok ? r.list : []).map((e, i) => ({
icon: i === 0 ? 'minecraft:golden_helmet' : 'minecraft:paper',
name: `#${i + 1} ${e.name} — ${e.money}`
}))
event.player.openChestGUI('金钱排行榜', 6, gui => {
layoutChest(gui, list, { rows: 6, reserve: 1 }) // 布局函数见「可操作 UI」章第 8 节
})
})
2.4 补推与限流(很重要,别省)¶
// 玩家进服时把"脏数据"补推上去;顺便限流,避免每次操作都请求
PlayerEvents.loggedIn(event => {
const p = event.player
if (p.persistentData.moneyDirty) {
const r = dbCall({ op: 'addMoney', uuid: String(p.uuid), name: p.username,
delta: p.persistentData.moneyCache || 0 })
if (r && r.ok) p.persistentData.moneyDirty = false
}
})
// 只读数据缓存 30 秒,别每秒都打后端
let topCache = { at: 0, list: [] }
function topCached() {
if (Date.now() - topCache.at < 30000) return topCache.list
const r = dbCall({ op: 'top' })
if (r && r.ok) topCache = { at: Date.now(), list: r.list }
return topCache.list
}
⚠️ 安全要点(血泪版) - 后端只暴露固定 op,绝不接受"任意 SQL 字符串"(否则等于把数据库交出去)。 - 一定用预处理(
prepare/execute),别拼 SQL。 - token 要长且随机;能用 HTTPS 就用 HTTPS;后端再加 IP 白名单/限流。 - 密码放后端,别写进 KJS 脚本(脚本会跟着整合包发给玩家!)。
3. 路线 B:JDBC 直连(讲清坑)¶
KubeJS 白名单默认只禁 java.lang、Forge/FML 内部包这类东西,所以 java.sql、javax.sql 是能用的(源码 BuiltinKubeJSPlugin#registerClasses / ClassFilter)。
但:JDBC 需要驱动类在 classpath 上。1.20.1 Forge 里没有"把任意 jar 丢进 mods 就加载"的机制——裸 JDBC 驱动 jar 不是 mod,会被忽略。所以先用路线 A会更省心。
如果你确实要走直连,常见做法是:
1. 用一个前置 mod(把驱动打包在里面的那种)或自己做个只含驱动的小 mod;
2. 确认驱动类名可被加载:Java.loadClass('org.sqlite.JDBC') / Java.loadClass('com.mysql.cj.jdbc.Driver');
3. 拿连接、执行、记得关:
// server_scripts/jdbc_demo.js —— 仅在"驱动已在 classpath"时可用
function sqliteDemo() {
const DriverManager = Java.loadClass('java.sql.DriverManager')
let conn = null
try {
conn = DriverManager.getConnection('jdbc:sqlite:kubejs/data/demo.db') // 或 jdbc:mysql://host:3306/db?user=..&password=..
const st = conn.prepareStatement('SELECT name, money FROM players ORDER BY money DESC LIMIT 10')
const rs = st.executeQuery()
const out = []
while (rs.next()) out.push({ name: rs.getString('name'), money: rs.getLong('money') })
rs.close(); st.close()
return out
} catch (e) {
console.warn('[JDBC] 失败:' + e)
return []
} finally {
if (conn) conn.close() // ★ 必须关,否则连接泄漏
}
}
⚠️ JDBC 是阻塞调用。别在高频事件(
tick、每次攻击)里直接查库;要么缓存,要么用Utils起个线程再回主线程处理结果。 ⚠️ 连接别每次新建(很贵);但 KJS 脚本里长连接容易泄漏 → 实践中还是建议走路线 A。
4. 表结构 & 常见坑清单¶
| 坑 | 怎么办 |
|---|---|
| 中文变问号 | 连接/表都用 utf8mb4(charset=utf8mb4 + COLLATE utf8mb4_general_ci) |
| 时间差 8 小时 | 存 UTC 时间戳(BIGINT)或用 TIMESTAMP + serverTimezone=Asia/Shanghai |
| 金额用浮点算丢精度 | 用 BIGINT 存"最小单位"(如分),或 DECIMAL(18,2) |
| 玩家改名后对不上 | 主键永远用 UUID,名字只当显示字段 |
| 主键冲突 | INSERT … ON DUPLICATE KEY UPDATE(MySQL)/ INSERT OR REPLACE(SQLite) |
| 后端挂了脚本崩 | KJS 侧全部 try/catch + 本地缓存兜底(见 2.3) |
| 数据被外挂/刷接口 | 后端校验 token + 限流;关键操作在服务端二次校验数值范围 |
| 想给网站后台用 | 同一张表,后台直接读写;游戏侧只负责发操作请求 |
推荐的两个最小表:
-- 玩家数据(跨服共享)
CREATE TABLE tdm_players (
uuid CHAR(36) PRIMARY KEY,
name VARCHAR(32) NOT NULL DEFAULT '',
money BIGINT NOT NULL DEFAULT 0,
data JSON NULL -- MySQL 5.7+ 支持原生 JSON 列,塞自定义字段很方便
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 操作流水(审计/回滚/防刷)
CREATE TABLE tdm_money_log (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
uuid CHAR(36) NOT NULL,
delta BIGINT NOT NULL,
reason VARCHAR(64) NOT NULL DEFAULT '',
ts BIGINT NOT NULL, -- 毫秒时间戳
INDEX (uuid), INDEX (ts)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
💡 有流水表,出问题能查、能回滚;没有流水表的余额系统就是黑盒。
5. 什么时候该从 JSON 迁到数据库?¶
出现下面任意一条,就该迁了:
- JSON 文件超过 几 MB,每次 JsonIO.read 明显卡顿
- 需要 按条件查("前 100 名"、"最近 7 天活跃")而不是全量遍历
- 多个服/多个程序同时写同一个文件(JSON 没有锁,必坏)
- 数据要给网站后台改,改完还要游戏里立刻生效
迁移套路(不用停机): 1. 后端建表 → 写一个"导入"接口; 2. 游戏开服时读旧 JSON,调接口批量导入一次; 3. 之后所有读写走接口(路线 A),JSON 只留作本地缓存/备份。
6. 速查表¶
| 想做什么 | 写法 |
|---|---|
| 发请求给后端(推荐路线) | HttpClient.newBuilder().build().send(req, BodyHandlers.ofString()) |
| JS 对象 → 请求体字符串 | JSON.stringify(obj) / BodyPublishers.ofString(...) |
| 响应 → JS 对象 | JSON.parse(res.body()) |
| 加载 JDK 类 | Java.loadClass('java.net.http.HttpClient')(java.sql 也可用) |
| 直连数据库(需驱动在 classpath) | DriverManager.getConnection('jdbc:mysql://…') |
| 防注入 | 后端 prepare/execute,绝不拼 SQL |
| 兜底 | KJS 侧 try/catch + 本地缓存,后端不可用也不崩 |
| 流水/审计 | 单独一张 *_log 表(uuid/delta/reason/ts) |
💡 记忆口诀:能 JSON 就别上库;上库优先走 HTTP 桥——游戏只发"操作请求",SQL、密码、限流全放后端;KJS 侧永远 try/catch + 缓存兜底,后端挂了游戏照常跑。
本章为 KubeJS 1.20.1 中文文档原创篇目(2026-09-25)。类白名单依据 KubeJS 6.1 源码 dev.latvian.mods.kubejs.util.ClassFilter / BuiltinKubeJSPlugin#registerClasses(默认禁 java.lang、Forge/FML 内部包;java.sql / java.net.http 可用)。后端示例为 PHP + PDO,也可用 Node/Python 等价实现。