尝试一些更改并回滚到以前的状态以防 Java 失败

Try some changes and rollback to previous state in case of failure in Java

在我写的游戏中,有一个玩家在棋盘上移动。在此板上还有其他对象。所以我有一堆对象,每个对象都保持着它的状态。可以要求玩家移动到特定的相邻单元格。它的移动由三个独立的部分组成:退出当前单元格;从当前单元格遍历到目标单元格;进入新牢房。这些阶段中的每一个都可能触发板上对象的一些变化。玩家要么执行完整的动作,要么根本不动。在三个阶段中的每一个阶段,都可能存在阻止玩家到达目标单元格的障碍。问题是判断玩家是否可以执行移动,并且当且仅当可以移动时才移动它。

我首先想到我可以检查是否没有障碍物阻止玩家退出当前单元格,是否没有障碍物阻止它遍历目标单元格,以及是否没有障碍物阻止它进入目标单元格。如果所有这些条件都符合,那么我移动玩家;如果没有,那么我不移动玩家,棋盘的状态没有改变。

但这并没有那么简单。事实上,即使这三个测试成功,玩家也可能无法移动。例如,当它退出它的单元格时,可能会触发棋盘的变化,这使得它以后不可能过渡到目标单元格。在这种情况下,一切都必须保持不变:玩家不应该移动(因为它不能执行完整的移动)并且棋盘应该保持不变,这意味着不应触发任何事件。

由于事先无法知道玩家是否可以移动,只能尝试移动,所以我想到了另一种方法。我可以尝试在不事先检查任何内容的情况下移动玩家,并在三个移动步骤中的任何一个失败的情况下回滚到之前的状态。回滚是必要的,因为在尝试移动时,可能已触发事件,因此板可能已相应更改。

问题如下。我怎样才能尝试执行一些可能会改变某些对象状态的代码,然后在发生某些事情时回滚到以前的状态?更具体地说,假设我有一个方法 move returns 一个布尔值,它改变了一些对象的状态。如果方法 returns 为真,我想保留更改,否则回滚所有更改。如何实施这种行为?

或者您有更好的办法来解决所描述的问题吗?

这是一个很好的方法。数据库一直这样做。

不幸的是,这个问题有很多很多个答案,在这些答案中最合适的选择取决于您的具体场景和数据存储机制。

使用数据库

最简单的可用数据库。

这涉及以下设置:

  1. 确保您始终使用事务来处理所有事情。最好连只读操作。
  2. 确保你有一个非白痴数据库。例如,MySQL MyISAM 算作一个愚蠢的数据库;不要用那个。大多数基于 SQL 的数据库在这方面都是理智的。避免 mongodb 和其他此类基于文档的数据库引擎用于此特定用例。您正在寻找一个支持 SERIALIZABLE 级别事务并且实际上遵守它的规则的数据库(与许多表现得好像支持它但实际上并没有给您 SERIALIZABLE 描述的保证)的数据库引擎相比)。
  3. 设置您的连接以使用 TransactionLevel.SERIALIZABLE。
  4. 这本身就意味着所谓的'retry'。要管理这一点,您需要将所有与数据库交互的代码包装在 lambda 中,以便底层框架可以在必要时重新 运行 您的代码。一般来说,不要直接使用 JDBC ,而是使用建立在它之上的更好的抽象。你有两个主流选择:JOOQ and JDBI。这两个优秀库的教程都包含有关如何正确操作的详细信息。

一旦你拥有所有这些,'fail the operation and change nothing at all'就像退出你的'do these data operations'一样简单(这是一个代码块,充满了数据库查询语句(sql INSERT/UPDATE/DELETE/SELECT),作为例外交给了 jdbi/jooq)。该事务将被回滚,您实际上在数据库中没有任何更改 'sticks'。也很有用:所有其他代码都可以在一瞬间看到您开始 'move the player' 歌舞例程之前的情况,然后在下一瞬间,就好像一切都应用了一样。换句话说,玩家的移动最终表现得好像它是原子的,这大概是这个模型正常工作的 要求 。任何其他代码,如果你将其设为多线程设置,都不应执行半步操作!

如果您目前没有使用数据库,那么它会变得非常非常复杂。您或多或少正在注册编写您自己的 MVCC 风格的数据库引擎,这不是一件容易的事。

有一些更简单的答案。像往常一样简单的答案,它们对于您的特定用例可能完全不可行。

不可变/克隆

如果描述您的游戏状态的数据结构是 100% 不可变的(零设置器),或者即使不是:只要您的 'move the player' 代码根本没有真正修改任何东西,而是在 /该过程的每一步都会产生新的略微修改的克隆,然后 'aborting' 是微不足道的:只是.. return.

游戏状态实际更新的唯一方式是更新指向代表整个游戏状态的对象的字段结尾。更改 java 中的引用(任何非原始变量都是引用)是原子的。

如果您的游戏状态非常大,这可能不是一个好主意,因为它必然涉及 完整 副本。

回滚日志

另一种选择是,您可以对游戏状态执行的每个操作都与撤消它的镜像实现配对。您对状态所做的每个 'change' 都以 Operation 对象的形式进行描述,并且每个 Operation 内部都有代码,它们都知道如何将更改应用到基础游戏状态,并且它知道如何取消应用它.应用可以通过向队列中添加新的操作来实现。

例如:

public class HitCreature extends Operation {
    private Player actor;
    private Creature target;
    private Weapon weapon;

    public void apply() {
       target.health -= actor.getStrength();
        if (weapon.hasKnockBack()) {
          //calculate direction...
          queue.addOperation(new KnockbackCreature(target, direction));
        }
        if (target.isDead()) {
          queue.addOperation(new RemoveCreature(target));
          queue.addOperation(new AddSkeleton(target));
        }
    }

    public void unapply() {
        target.health += actor.getStrength();
    }
}

任何给定的移动都是简单地分解为多个操作的单个操作,并且您维护一个列表,每个操作都按顺序排列。要么你没有任何错误地到达这个列表的末尾,在这种情况下,很好,一切都成功了。或者,其中一个操作失败,在这种情况下,回到其中的 0 索引,调用 unapply,这应该 完全 撤消它所做的一切(以及未应用代码不能添加新操作,它只是撤消它直接更改的内容而不会导致任何进一步的副作用)。

不过,如果您使用的是多线程代码,这就不是什么好事了。

其他选项

还有更多的策略,但这些策略希望能为您提供一些想法,让您从这里开始。