跳转至

💾 数据存储进阶:连接数据库

本章解决一个问题:数据量大了 / 要跨服共享 / 要让外部程序(网站、后台)也能读写时,怎么把 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 程序执行。

游戏(KubeJS) ──HTTP+JSON──▶ 你的后端(api.php) ──PDO──▶ MySQL / SQLite
        ◀──JSON────────────

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 等价实现。