← Java 基础教程

第 39 / 40 课 · 免费教程

服务器急诊室

靠日志、边界与测试找到真正的 Bug

110–130 分钟环境:JDK 21、普通 Paper 1.21.11、同版本客户端。服务器插件由 Paper 加载;只有明确标注的规则测试使用 main。项目整合与交付

本课作品

24项规则检查与游戏验收记录

调试靠证据。读出堆栈中的自己的位置,复现赛外计分、晚结束和重复计时三个 Bug;用24项纯 Java 检查和十个游戏场景分别验证规则与接线。 继续第38课OreRush项目。测试使用Java21;游戏内检查沿用普通Paper1.21.11。故障片段只放在练习副本,不要覆盖可用版本。

开始前:继续第 38 课的矿石冲刺赛

01 / 12

开始前:先认清运行位置

前30课打好了普通 Java 程序的基础。这一阶段把代码接到 Paper 服务器:Minecraft 客户端负责进入世界,服务器加载插件,事件和命令把玩家动作交给你的方法。

本书固定使用 JDK 21 与 Paper 1.21.11,客户端版本也保持一致。下载页面上的最新版本可能已经变了,按版本号选择。本书采用普通 plugin.yml 与 Bukkit API;本阶段没有声称支持 Folia。

图框中的短例是局部片段,先读本节的位置说明,不要各自保存成插件。完整文件会明确给出文件名;同一文件分成多段时,按编号连接,包名、import 与外层类只出现一次。

  • 预测:先写成功条件和预期消息。
  • 运行:构建成功后,在专用本机测试服安装并重启。
  • 核对:普通玩家、管理身份与控制台分别走对应路径。

02 / 12

红色堆栈是一张定位图

服务器抛出一长串红字,阿方从最下面开始猜。侦探递来放大镜:先认异常类型和原因,再找到自己项目的第一处位置,沿调用路径追就不会在日志里迷路。

调试先重现,再缩小条件,最后修一处并重测。测试有两层:纯Java检查规则,真实服务器检查事件、命令与调度是否接对。编译通过不能代替游戏内验收。

红色堆栈是一张定位图
  1. 异常类型说明哪类失败
  2. 自己的源文件找到项目内第一处位置
  3. 复现步骤同样输入才能重测

教学示意堆栈 并非本次实测日志 · 位置见本节说明

java.lang.NullPointerException: ...
    at de.teacherma.orerush.OreRushPlugin.onCommand(OreRushPlugin.java:58)
    at org.bukkit.command.PluginCommand.execute(PluginCommand.java:...)

写一张故障记录 · 位置见本节说明

步骤: 输入/orerush score
预期: 未报名提示
实际: 红字异常
环境: Java21 Paper1.21.11 插件版本与完整异常另附

03 / 12

找不到玩家就先停止

查询在线玩家可能得到null,离线不等于“找到了一个隐形Player”。在取得结果后立刻检查,再读取名字或发消息。并非所有null都应该强行换成默认对象,有时明确告诉操作者失败更合适。

找不到玩家就先停止
  1. 查询对象在线查找可能为null
  2. 检查失败说明离线并停止
  3. 成功对象之后才调用方法

可加入管理命令的安全片段 · 位置见本节说明

Player target = getServer().getPlayerExact(name);
if (target == null) {
    sender.sendMessage("玩家不在线,请检查名字");
    return true;
}
target.sendMessage("老师邀请你参加测试");

保留异常而不是装作成功 · 位置见本节说明

catch (IOException ex) {
    getLogger().log(Level.WARNING, "读取失败", ex);
}

04 / 12

边界修复一 结束后仍然加分

坏版本只检查玩家是否报名。比赛结束后名单还在时,继续挖矿就会涨分。把“比赛中”加入加分方法的条件,即使调用者忘记检查,核心规则仍能挡住赛外加分。

停下来想一想:规则类的重复开始测试通过,就能证明服务器只创建一个定时任务吗?还要观察什么?

边界修复一 结束后仍然加分
  1. WAITING即使已报名也不加分
  2. RUNNING已报名正分才写入
  3. ENDING入口已关闭

坏版本 忘记状态 · 故意错误,先预测再修复

if (scores.containsKey(id)) scores.merge(id, points, Integer::sum);

修复后的入口 · 位置见本节说明

if (state == State.RUNNING && scores.containsKey(id) && points > 0)
    scores.computeIfPresent(id, (key, old) -> old + points);

能揭露问题的检查 · 位置见本节说明

r.add(alex, 10); // 在WAITING状态
check(r.snapshot().get(alex) == 0, "waiting cannot score");

05 / 12

边界修复二 多活一秒的比赛

若先判断remaining等于0,之后才减数,2秒的比赛会等到第三次回调才结束。用一个短到能数清的例子:开始2,第一次变1,第二次变0并结算。不用真的等一分钟。

边界修复二 多活一秒的比赛
  1. 初始2未回调,仍在比赛
  2. 第1次tick剩1,不结束
  3. 第2次tick归零并结束一次

坏版本 先判断后减数 · 故意错误,先预测再修复

if (remaining == 0) return true;
remaining--;
return false;

归零的那一次就结束 · 位置见本节说明

if (--remaining > 0) return false;
state = State.ENDING;
return true;
核对表
回调次数修复后剩余是否结束
02否
11否
20是
30否,不重复结束

06 / 12

边界修复三 两个裁判同时数秒

坏版本每收到一次start就创建任务。连按两次,秒数掉得像吃了急迫药水。先让GameRound.start验证状态,只有成功才进入调度器;停止时既cancel,也要清空任务字段。

边界修复三 两个裁判同时数秒
  1. 先检查状态拒绝重复开始
  2. 再创建任务成功路径才安排
  3. 结束清理cancel且task置null

沿箭头进入下一步前,必须满足前一步条件;被拒绝的分支提前返回。

不把创建任务放在检查之前 · 位置见本节说明

if (!round.start(duration)) {
    sender.sendMessage("无法开始");
    return true;
}
// 下面才创建任务

清理要完整 · 完整文件

if (task != null) task.cancel();
task = null;
round.reset();
names.clear();

07 / 12

自动检查程序上半部

在OreRush项目新建checks/RoundChecks.java。本页接下一页是一份完整文件,不放进插件JAR。check不通过时抛AssertionError,运行立即失败;不会像默认关闭的Java assert那样悄悄跳过。

自动检查程序上半部
  1. 空局与报名拒绝无人开始和重复报名
  2. 开始边界时长与状态都校验
  3. 计分边界赛外、外人和负分拒绝

RoundChecks第1部分 · 顺序接在同一源文件

import de.teacherma.orerush.GameRound;
import java.util.UUID;

public class RoundChecks {
    private static int checks;
    private static void check(boolean condition, String name) {
        checks++;
        if (!condition) throw new AssertionError(name);
    }
    public static void main(String[] args) {
        UUID alex = new UUID(0, 1), steve = new UUID(0, 2);
        GameRound r = new GameRound();
        check(!r.start(60), "empty cannot start");
        check(r.join(alex), "first join");
        check(!r.join(alex), "duplicate join");
        r.add(alex, 10);
        check(r.snapshot().get(alex) == 0, "waiting cannot score");
        check(!r.start(0), "zero duration rejected");
        check(!r.start(3601), "too long rejected");
        check(r.start(2), "start two seconds");
        check(!r.start(2), "duplicate start rejected");
        check(!r.join(steve), "late join rejected");
        r.add(alex, 10); r.add(steve, 10); r.add(alex, -5);
        check(r.snapshot().get(alex) == 10, "valid points only");
        check(!r.snapshot().containsKey(steve), "outsider cannot score");

08 / 12

自动检查程序下半部

继续上一页。归零后再尝试加分,再重置,检查旧成绩快照是否仍然存在。最后覆盖深板岩矿石、普通石头和null输入。成功时输出PASS 24 checks。

自动检查程序下半部
  1. 时间与结束第二次tick归零
  2. 重置与快照旧快照仍保持成绩
  3. 材料规则深板岩、普通石头及null

RoundChecks第2部分 · 顺序接在同一源文件

        check(!r.tick() && r.remaining() == 1, "first tick");
        check(r.tick(), "second tick ends");
        check(r.state() == GameRound.State.ENDING, "closed before result");
        r.add(alex, 10);
        check(r.snapshot().get(alex) == 10, "ending cannot score");
        check(!r.tick(), "cannot end twice");
        var result = r.snapshot();
        r.reset();
        check(r.state() == GameRound.State.WAITING, "reset state");
        check(r.snapshot().isEmpty(), "reset enrollment");
        check(result.get(alex) == 10, "snapshot survives reset");
        check(GameRound.points("DEEPSLATE_DIAMOND_ORE") == 10, "deep diamond");
        check(GameRound.points("GOLD_ORE") == 5, "gold");
        check(GameRound.points("DEEPSLATE_IRON_ORE") == 3, "deep iron");
        check(GameRound.points("STONE") == 0, "ordinary block");
        check(GameRound.points(null) == 0, "missing material");
        System.out.println("PASS " + checks + " checks");
    }
}

先新建 checks/sources.txt,写入下面两条相对于项目根目录的源文件路径,每行一条。然后从项目根目录运行短命令;也可以在 IDE 直接运行 RoundChecks.main。

checks/sources.txt · 完整内容

src/main/java/de/teacherma/orerush/GameRound.java
checks/RoundChecks.java

Windows项目根目录运行 · 位置见本节说明

javac -encoding UTF-8 -d checks/out @checks/sources.txt
java -cp checks/out RoundChecks

运行结果

PASS 24 checks

09 / 12

十个场景把游戏也测一遍

自动检查的是数字规则,游戏里还多了命令权限、模式、世界和其他插件。按表操作,记录“实际结果”和日志位置;发现问题时只改一个条件再试,证据才不会像末影人一样瞬移。

十个场景把游戏也测一遍
  1. 规则检查数字和状态能独立验证
  2. 游戏验收权限事件调度要接对
  3. 两份证据不能互相替代
核对表
编号与动作预期
1 无人报名start拒绝
2 同人重复join第二次拒绝
3 两人报名start只启动一局
4 立刻再start拒绝,无第二任务
5 未报名者挖矿不计分
6 创造模式或异世界挖矿不计分
7 保护取消挖矿不计分
8 自然结束后再挖不计分
9 计时中stop无后续倒计时广播
10 再报名开始后停服重启旧任务和本局数据不恢复

10 / 12

优化前先找真正的耗时

玩家说“卡”,可能是网络、世界生成,也可能是插件。先记录时间、人数、操作和复现步骤,再测量。不要看到一个for循环就宣布它有罪:循环十个分数,与每块矿石都写文件不是同一回事。

优化前先找真正的耗时
  1. 先重现记录操作、人数与时刻
  2. 再测量识别真实耗时路径
  3. 后改进复测同一个场景

只测一段纯计算 · 位置见本节说明

long started = System.nanoTime();
var result = round.snapshot();
long elapsedNs = System.nanoTime() - started;
// 单次结果受JVM预热等影响,不是正式性能结论。

有用但不过量的日志 · 位置见本节说明

getLogger().info("OreRush started, players=" + round.snapshot().size());
  • 不要在每次挖矿里下载网页、遍历所有区块或保存整个文件。
  • 在测试服可以按Paper官方Profiling指南使用性能分析器,先看耗时调用再优化。
  • 分享日志或分析报告前检查玩家名、地址和路径;未成年学生不要自行上传完整服务器资料。

11 / 12

拓展挑战与自己的验收记录

留下源代码、实际运行结果和一条问题记录。先用自己的话解释,再运行验证;结果与预测不同的地方,往往最值得讨论。

拓展任务与通关复盘
  1. 木镐解释自己的堆栈
  2. 铁镐修复三个边界Bug
  3. 钻石镐制作只读自检命令
  • 木镐:解释一段自己项目的堆栈,指出异常、方法和源文件位置。
  • 铁镐:在练习副本复现并修复本课三个Bug,提交修复前后测试结果。
  • 钻石镐:制作只读自检命令,显示版本、当前状态和报名人数;不要输出私人信息。
  • 验收要求:保留修复前复现步骤、定位依据和修复后结果;换一个同桌按记录操作,仍应得到一致结论。
核对表
我能独立完成已掌握再练一次
能用证据复现错误□□
会运行24项规则检查□□
能区分单元规则与游戏验收□□

12 / 12

把证据留给下一次冒险

把“能编译”“数字规则通过”和“游戏实际表现正确”分成三栏。没有运行的场景写待测;发现 Bug 时记录触发条件、预期、实际、最小修复与复测结果。

你的验证记录
类别你选择的操作留下的证据
正常本课的一次成功路径版本与实际输出
边界结束前一刻、最大值或满背包与合同逐项比较
拒绝错误参数、无权限或不相关事件反馈清楚,状态未误改
  • 解释本课每条箭头为什么从这里指向那里;并列卡片没有先后含义。
  • 先改一个规则,再同时修改说明和预期,用同一输入复测。

TAKE IT WITH YOU

下载本课 PDF

完整课文、代码、图解与练习,选择你需要的语言。