Skip to content

数据储存 ​

UltiTools 封装了一套数据储存 API,它支持 MySQL 数据库、SQLite 数据库(6.1.0起)与 JSON 文件储存。数据存储对于开发者来说是透明的,UltiTools将通过服主的配置判断使用哪种存储方式。

你需要的仅仅只是一个实体类。CRUD 操作将由 UltiTools 自动完成。

尽量不要嵌套对象

存储 API 把每个 @Column 字段对应到一列(JSON 文件中对应一个字段)。使用 SQLite 和 MySQL 时,非简单类型的字段会以 JSON 文本存入该列,不能按其内部字段查询(只有 JSON 存储可以),所以尽量使用简单值。

v6.3.2 之前的 SQLite 与原生 spark 分析

较早版本在 Paper 原生 spark 分析器运行时,服务器线程上名义为 3 秒的 SQLite busy 等待可能约 0.4 秒就耗尽,前提是另一线程或进程同时持有该数据库文件的写锁(#645)。 MySQL 不受影响;写入长时间持有 SQLite 锁的服务器应改用 MySQL。

自 v6.3.2 起,SQLite busy 等待按单调时钟的真实耗时计算,分析器信号仅打断一次请求的休眠时不再提前消耗等待预算。3000 ms 默认值、DEFERRED 事务和 JDBC 语句超时选择不变。请缩短 SQLite 事务,不要共享或从外部修改运行中的 SQLite 文件。读锁升级为写锁时,若等待会造成死锁,仍可能立即失败;不是每次 SQLITE_BUSY 都等待完整超时,也不要盲目重试整个事务。直接修改 SQLite busy handler 或 PRAGMA busy_timeout 不属于框架管理的等待策略。

创建实体类 ​

BaseDataEntity ​

创建一个继承 BaseDataEntity<String> 的类,并使用 @Table 和 @Column 注解来标记你的实体类。

java
package com.ultikits.docs.data;

import com.ultikits.ultitools.abstracts.data.BaseDataEntity;
import com.ultikits.ultitools.annotations.Column;
import com.ultikits.ultitools.annotations.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(callSuper = true)
@Table("user_data")
public class UserData extends BaseDataEntity<String> {
    @Column("player_name")
    private String playerName;

    @Column(value = "balance", type = "FLOAT")
    private double balance;
}

其中,@Table 注解用于标记该类对应的数据表(若使用 MySQL 数据库),@Column 注解用于标记该类的字段对应的数据表的列。

@Data、@Builder、@NoArgsConstructor、@AllArgsConstructor、@EqualsAndHashCode 则为 Lombok 注解,用于自动生成 getter、setter、builder、equals、hashCode 方法。

从 AbstractDataEntity 迁移

从 v6.2.0 开始,DataOperator、Query 和 UltiToolsPlugin.getDataOperator() 要求实体继承 BaseDataEntity<String> 而非 AbstractDataEntity。如果你的实体仍然继承 AbstractDataEntity,请改为 BaseDataEntity<String>。

BaseDataEntity<String> 提供了插入/更新/删除/加载事件的生命周期钩子:

方法说明
onCreate()在实体首次持久化之前调用
onUpdate()在实体更新之前调用
onDelete()在实体删除之前调用
onLoad()在从数据存储加载实体后调用
validate()实体有效返回 true
isNew()实体无 ID 时返回 true
copyWithoutId()创建不含 ID 的实体副本,前提是实体类自行实现 Cloneable

自 v6.3.0 起,数据操作器自行调用这些钩子,JSON、MySQL 与 SQLite 后端一致:

钩子调用方
onCreate()insert 与 insertAll,对每个实体,在写入其字段之前
onUpdate()update(entity)、updateAll、updateCounted 与 updateIf,对传入的实体,在写入其字段之前,无论随后是否写入了行
onDelete()delById 与查询 DSL 的 delete(),对已储存的实体,在删除之前;没有任何行具有该 id 时不调用
onLoad()getById、getAll、page、getLike 以及基于它们的查询 DSL 读取,对返回的每个实体调用一次

update(column, value, id)、del(conditions) 与 exist(...) 不读取实体,不调用任何钩子。v6.3.0 之前,操作器不调用这四个钩子中的任何一个。

AuditableDataEntity v6.2.0+ ​

对于需要跟踪创建和修改的实体,可以使用 AuditableDataEntity:

java
package com.ultikits.docs.data;

import com.ultikits.ultitools.abstracts.UltiToolsPlugin;
import com.ultikits.ultitools.abstracts.data.AuditableDataEntity;
import com.ultikits.ultitools.abstracts.data.BaseDataEntity;
import com.ultikits.ultitools.annotations.*;
import com.ultikits.ultitools.interfaces.DataOperator;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.bukkit.entity.Player;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(callSuper = true)
@Table("audit_log")
public class AuditEntry extends AuditableDataEntity<String> {
    @Column("action")
    private String action;
    @Column("details")
    private String details;
}

AuditableDataEntity<String> 继承了 BaseDataEntity<String>,自动管理以下字段:

字段类型说明
createdAtLocalDateTime实体创建时间(在 onCreate() 中自动设置)
updatedAtLocalDateTime上次修改时间(在 onUpdate() 中自动更新)
createdByUUID创建实体的用户 ID(从线程本地上下文获取)
updatedByUUID上次修改实体的用户 ID(从线程本地上下文获取)

所有这四个字段都已预配置 @Column 注解,子类中无需声明。

自 v6.3.0 起,操作器通过这些钩子填写四个审计列:insert 设置 created_at 与 updated_at,设置了当前用户时再设置 created_by 与 updated_by;更新时设置 updated_at,设置了当前用户时再设置 updated_by,不改动 created_at 与 created_by。由 BaseCommandExecutor 为玩家处理的命令,在命令主体运行期间把该玩家设为当前用户,因此在其中进行的写入会记录该玩家。在其他地方,请按下文自行设置上下文。v6.3.0 之前,操作器不调用这些钩子,四列始终为 NULL。

用户上下文管理 ​

要跟踪执行操作的用户,需要在数据库操作前设置当前用户:

java
import com.ultikits.ultitools.abstracts.data.AuditableDataEntity;

UUID currentUserId = player.getUniqueId();
AuditableDataEntity.setCurrentUser(currentUserId);

try {
    DataOperator<AuditEntry> op = plugin.getDataOperator(AuditEntry.class);
    AuditEntry entry = AuditEntry.builder()
        .action("login")
        .details("玩家从 192.168.1.1 登录")
        .build();
    op.insert(entry);  // createdBy 和 updatedBy 自动设置
} finally {
    AuditableDataEntity.clearCurrentUser();
}

必须清除上下文

使用 try-finally 块确保调用 clearCurrentUser(),否则 ThreadLocal 上下文会持续存在于后续请求中,可能导致用户身份泄露。

工具方法 ​

AuditableDataEntity 提供了便利的时间相关查询方法:

方法返回值说明
getAge()Duration 或 null实体自创建以来经过的时间
getTimeSinceUpdate()Duration 或 null实体自上次修改以来经过的时间
wasModified()boolean实体是否在创建后被修改过

使用示例:

java
AuditEntry entry = op.getById("some-id");
if (entry.wasModified()) {
    System.out.println("修改于 " + entry.getTimeSinceUpdate().getSeconds() + " 秒前");
}

空值安全

如果实体尚未持久化(缺少 createdAt 或 updatedAt),getAge() 和 getTimeSinceUpdate() 会返回 null。在调用返回的 Duration 上的方法前,务必检查 null。

@Table 注解 ​

@Table 注解有一个 value 属性,用于指定该类对应的数据表或文件夹的名称。

@Column 注解 ​

@Column 注解有两个属性,value 属性用于指定该字段对应的数据表的列,type 属性用于指定该字段对应的数据表的列的类型。

type 属性的默认值为 VARCHAR(255)。

可用的类型可参见 MySQL 数据类型。

CRUD 操作 ​

UltiTools 封装了一套语义化的 CRUD 操作 API,你只需要调用相应的方法,即可完成对数据的增删改查。

DataOperator ​

DataOperator 用于数据操作。

在继承了 UltiToolsPlugin 的主类中,有一个 getDataOperator 方法,用于获取数据操作器。

你需要获取插件主类的实例,然后调用 getDataOperator 方法。

java
package com.ultikits.docs.data;

import com.ultikits.ultitools.abstracts.UltiToolsPlugin;
import com.ultikits.ultitools.entities.WhereCondition;
import com.ultikits.ultitools.interfaces.DataOperator;

import java.util.List;

public class UserDataService {

    public void save(UltiToolsPlugin plugin, UserData data) {
        DataOperator<UserData> operator = plugin.getDataOperator(UserData.class);
        operator.insert(data);
    }

    public List<UserData> findByName(UltiToolsPlugin plugin, String name) {
        DataOperator<UserData> operator = plugin.getDataOperator(UserData.class);
        return operator.getAll(
                WhereCondition.builder().column("player_name").value(name).build()
        );
    }
}

请即取即用

DataOperator 不是线程安全的,请在需要的时候获取 DataOperator,不要试图保存 DataOperator 对象。

插入 ​

java
SomeEntity entity = SomeEntity.builder()
    .name("test")
    .something(42.0)
    .build();
dataOperator.insert(entity);

查询 ​

使用 WhereCondition:

java
List<SomeEntity> list = dataOperator.getAll(
    WhereCondition.builder()
        .column("name")
        .value("test")
        .build()
);

或按 ID 获取单个实体:

java
SomeEntity entity = dataOperator.getById("some-id");

获取所有实体:

java
List<SomeEntity> all = dataOperator.getAll();

分页查询:

java
List<SomeEntity> page = dataOperator.page(1, 10); // 第 1 页,每页 10 条

page() 在 JSON 后端返回空列表

在 JSON 后端上,page(int, int) 转交 getAll(WhereCondition...),零长度参数走的分支返回空列表,而同一次调用在 MySQL 或 SQLite 上会拼出普通的 LIMIT ? OFFSET ? 并返回该页数据,同一份模块代码换后端结果不同。 需要两端一致的分页时,改用 getAll() 取全量再用 subList 切片:page(1, 10, WhereCondition.empty()) 不能替代,关系型操作器不过滤空条件,会拼出 WHERE null = ?。 让 page 与 exist 在空条件上与 getAll 对齐的修法跟踪于 issue #193。

查询 DSL

从 v6.2.0 开始,你可以使用流式查询 DSL 来编写更可读的查询:

java
SomeEntity entity = dataOperator.query()
    .where("name").eq("test")
    .first();

更新 ​

更新单个字段:

java
dataOperator.update("name", "newName", entityId);

使用实体对象更新:

java
try {
    entity.setName("newName");
    dataOperator.update(entity);
} catch (IllegalAccessException e) {
    // 处理异常或继续向上抛出
}

该重载声明了受检异常 IllegalAccessException,调用方需要声明或捕获它。

自 v6.3.0 起,读取返回的每个实体(getById、getAll、page、getLike 以及查询 DSL)都是副本,insert 存下的也是传入实体的副本。修改实体后,只有把它交给 update(...),储存的数据才会改变,各个后端都是如此。v6.3.0 之前,JSON 后端返回的是它缓存在内存中的实例,因此在 JSON 后端上不调用 update(...) 的修改也会在下一次落盘时保存,而 MySQL 与 SQLite 从来不会保存这样的修改。

自 v6.3.0 起,update(T)、update(column, value, id)、delById 与 updateAll 在 id 为 null 时抛出 DataAccessException,因为没有任何一行能用它定位;updateAll 会在写入之前检查全部实体。UltiTools-API 6.2.0 在 SQLite 上写入的无 id 行,会在初始化数据表时补上 id:优先使用实体通过 getId() 给出的值,实体给不出时使用新的 UUID,前提是实体随后确实给出这个值。控制台输出一行,给出表名与行数;任何 id 都无法使其可定位的行保持原样,并在一条警告中计数。每次写入都把 getId() 存入 id 列,因此把 getId() 覆写到其他字段上的实体,可以用它给出的值定位。

自 v6.3.0 起,按一个没有任何行具有的 id 更新时,各个后端都不写入任何内容,输出一条给出表名与 id 的警告,并正常返回。需要知道更新是否写入时,调用 updateCounted(entity):写入了一行时返回 1,没有任何行具有该 id 时返回 0:

java
if (dataOperator.updateCounted(entity) == 0) {
    // 这一行已被其他写入方删除:没有写入任何内容。
}

框架之外的 DataOperator 实现如果没有覆写 updateCounted,按更新之前是否存在具有该 id 的行计数。

条件更新 v6.3.0+ ​

updateIf(entity, expected...) 只在储存的行仍然满足全部预期条件时写入实体,并返回是否写入。需要基于之前读到的值做更新、又不能覆盖其间其他写入方的修改时,使用它:

java
Account read = dataOperator.getById(accountId);
double seen = read.getBalance();
read.setBalance(seen + amount);
boolean written = dataOperator.updateIf(read,
    WhereCondition.builder().column("balance").value(seen).build());
if (!written) {
    // 其他写入方先修改了这一行:重新读取,再做决定。
}

在 MySQL 与 SQLite 上,检查与写入是同一条 UPDATE ... WHERE id = ? AND <条件> 语句,因此对共用同一个数据库的多台服务器同样成立。在 JSON 后端上,检查与写入在表锁下一起完成;JSON 储存只属于一台服务器。条件的含义与 getAll(WhereCondition...) 中相同。

没有任何一行同时具有该实体的 id 并满足全部条件时,updateIf 返回 false,不写入任何内容;id 为 null、条件为 null、条件使用了实体没有用 @Column 映射的列,或在默认 EQUAL 以外的比较下条件的值为 null 时,各个后端都抛出 DataAccessException。自 v6.3.0 起,默认比较下的 null 期望值表示 IS NULL(JSON:字段不存在或为 JSON null),因此对读取时仍未设置的列做比较后写入时,若其间另一个写入方已写入该列,本次写入不生效;这一含义只属于 updateIf(#640)。框架之外的 DataOperator 实现如果没有实现它,会抛出 UnsupportedOperationException。

v6.3.2 文档记录的精度限制:MySQL 默认文本列和 JSON 的数值 EQUAL 可能同时匹配超出 ±9,007,199,254,740,992(2^53)的相邻整数,因此 updateIf 的前提也可能匹配不同于先前读到的值。不要把这类数字用作要求精确匹配的比较后写入(CAS)标记。MySQL 需要精确整数相等时声明 type = "BIGINT";JSON 的条件求值映射已把数字转换为 double,声明数值列类型不能恢复精确匹配。

删除 ​

按 ID 删除:

java
dataOperator.delById(entityId);

按条件删除:

java
dataOperator.del(
    WhereCondition.builder()
        .column("name")
        .value("test")
        .build()
);

WhereCondition ​

WhereCondition 用于指定查询条件。

java
WhereCondition.builder().column("somecol").value(someval).build();

其中,column 属性用于指定查询的列,value 属性用于指定查询的值。

比较方式 v6.3.0+ ​

自 v6.3.0 起,EQUAL 以外的比较方式在 SQLite、MySQL 和 JSON 上,于 getAll、exist、page、del 和 updateIf 中含义一致,且不改动任何表。6.2.5 的 SQLite 与 MySQL 实现把所有条件都按相等匹配。

  • INCLUDE、STARTSWITH 和 ENDSWITH 在所有后端都把值作为文本字面匹配:%、_、! 和 \ 都是普通字符,不是通配符或转义符。getLike 和 Query#like 保持模式匹配语义。
  • 字母大小写和重音取决于后端:SQLite 只忽略 ASCII 字母的大小写;MySQL 按框架建表时使用的 utf8mb3_general_ci 排序规则比较,同时忽略大小写和重音(é 能匹配 e);JSON 精确比较。
  • GREATER 和 LESS 用于 byte、short、int、long、float 或 double 字段(或其包装类型)时,在 SQLite 和 JSON 上按该字段类型的数值比较。请以该类型的数字传值(10,而不是 "10"):在 MySQL 上字符串值按文本比较,于是 "10" 排在 "2" 之前。在 SQLite 上,列保持默认 VARCHAR(255) 类型时同样按类型比较,整数保持 64 位精度,所存的值按读回时的值比较:0.1f 这样的 float 既不大于也不小于 0.1f。这种按读回值的比较只适用于有限数字的值;无穷大、超出 double 范围的数字以及任何文本都按绑定时的值比较,因此 GREATER Float.POSITIVE_INFINITY 不匹配任何行,NaN 值也不匹配任何行。JSON 不支持无穷大或 NaN 值:含有这种值的条件在求值时会抛出 IllegalArgumentException。
  • 在 MySQL 上,留在默认文本列中的数值字段按双精度比较,在 ±9,007,199,254,740,992(2^53)以内精确,超出则只是近似。需要精确结果时,请为该列声明数值 type,例如 BIGINT。在 JSON 上按 double 比较,超出 2^53 时同样是近似的。
  • exist(...) 在 SQLite 上可用;6.2.x 在那里会抛出 ClassCastException。
  • 已知限制:在 SQLite 上,默认列中的 double 只保存 15 位有效数字,因此 1/3.0 读回和比较都按 0.333333333333333;要保留全部数字,请声明 type = "DOUBLE"。手工写入数值字段列的非数字文本,在 SQLite 上按其开头的数字比较('abc' 为 0,'12abc' 为 12),JSON 则按文本比较。在 SQLite 上,与所存 float 前 15 位有效数字相同、之后才不同的任何数值(例如 10^15 及以上的整数如 (1L << 53) + 1,或有 16 位及以上有效数字的小数)会被视为与它相等,默认列和 REAL 列都如此;对 float 字段请传 float 值。在 SQLite 上,NaN 比较值会被绑定为 NULL,因此含它的条件不匹配任何行,带它的 updateIf 也永远不会生效;请勿对 NaN 阈值重试 updateIf。

非有限条件值 v6.3.0+ ​

v6.3.2 文档记录的已知限制,行为不变:不要把 Infinity 或 NaN 作为查询条件值。SQL 与 JSON 对它们没有统一的结果或异常约定:SQL 操作可能正常结束但不匹配任何行,JSON 条件则在求值时抛出 IllegalArgumentException,EQUAL 也包括在内。JSON 的 updateIf 在行不存在时可能先返回 false,不求值该条件。框架不将这些情况统一成 DATA_ENTITY_INVALID 错误。构造条件前使用 Float.isFinite 或 Double.isFinite 校验,不要重试 NaN 阈值(#696)。

事务 v6.2.0+ ​

对于需要同时成功或同时失败的操作,请参阅事务指南。

MySQL 与 SQLite 在一个 JDBC 事务中执行这段代码

自 v6.3.0 起,MySQL 与 SQLite 储存为其交出的每个操作器注入事务管理器(#307),因此 transaction(...) 在 JDBC 事务中执行,下面两条插入一起提交或一起回滚。 自 v6.3.0 起,JSON 后端暂存回调的写入,一起提交或一起不提交。

java
dataOperator.transaction(() -> {
    dataOperator.insert(entity1);
    dataOperator.insert(entity2);
    // 全部插入或全部不插入
});

在 JSON 后端上,由 transaction(...) 开启的事务在第一次读取或写入某张表时就锁住它(与 6.2.5 对每张表串行化一致),所以其中的“先读后写”不会丢失更新;其中的读取可能为等待持有该表的另一个事务而停顿,最多 5 秒。在默认隔离级别的 @Transactional 方法中,“先读后写”则改用 updateIf 或 isolation = REPEATABLE_READ,见JSON 事务行为。

自 v6.3.0 起,事务自身的失败路径再失败时不会隐式提交(#634):回滚或提交失败时,连接被丢弃(HikariCP 连接池会将其剔除),而不是重新打开自动提交(那样会提交该事务),框架也不会重试提交。之后保存了什么,取决于失败发生在哪里:

  • 操作失败且回滚成功时,未保存任何写入。
  • SQLite 或 MySQL 的回滚失败时(包括外层提交为仅回滚的事务执行的真正回滚),框架不发出提交。
  • 提交因事务已被标记为仅回滚而抛出 UnexpectedRollbackException 时,事务已回滚,其写入均未保存,所有后端都是如此。
  • SQLite 或 MySQL 的提交本身失败(DataAccessException,TRANSACTION_FAILED)时,结果未知:数据库可能在确认丢失之前已经应用了该提交(例如 MySQL 连接断开),丢弃连接无法撤销它。不能重复写入的代码(例如支付、奖励)在重试前应先重新读取数据,或使用 updateIf。
  • 在 JSON 上,失败的提交不会提交任何内容。

调用方收到原来的异常;事务被标记为仅回滚时收到 UnexpectedRollbackException。transaction(...) 中抛出的 Error 会使事务回滚(JSON 引擎丢弃暂存的写入),并原样抛出同一个实例。

JSON 存储的取舍 v6.3.0+ ​

以下记录的是 v6.3.2 保留的行为,不是运行时改动(#694):

  • Windows 和 macOS 上的持久性覆盖服务器进程崩溃,不保证断电或操作系统崩溃后最后几次完整提交不丢失。请按需要的保证选择存储与备份,不要把 Linux 行为推广为跨平台断电保证。
  • 有改动的提交在提交线程上同步强制刷新日志,该线程常是服务器主线程。世界保存等磁盘争用可能增加 tick 延迟;频繁事务选择 JSON 时应计入这一成本。
  • Windows 的移动重试不包括删除:文件被占用会使本次刷新失败,下次刷新再尝试。应查看失败并解除外部占用,不要认为 150 毫秒移动重试覆盖所有文件操作。
  • 直接构造的操作器各有独立的存储身份及相邻的 <表目录>.ultitools-journal;共同的父目录不会把分别构造的操作器组成一个事务。需要协调同一插件的多张表时,使用框架管理的操作器。
  • 同线程的 REQUIRES_NEW 和 NOT_SUPPORTED 工作不会等待被挂起调用方持有的线程级表锁。两者写同一键时,外层提交可能替换内层结果;不要把挂起理解为对该键独立加锁。
  • JDBC 与 JSON 的超时不同:JDBC 预算耗尽后仍允许语句以一秒查询超时执行;JSON 拒绝期限后的操作,并标记仅回滚。等待锁的 JSON 读取也使用 JSON write failed ... 错误前缀。应处理失败和回滚状态,不要根据 “write” 一词认定调用就是写入。见超时配置。
  • JSON 保留原始 <id>.json 名称,没有版本化的可移植编码或自动迁移。Windows 禁用的字符在 Linux 上可能有效,因此含 : 的 UltiRemoteBag 持久占用记录 id 不能直接迁到 Windows。模块数据迁移应另作安排,不要删除已有占用记录来绕过限制。
  • 只有大小写不同的 id 在不区分大小写的文件系统上会冲突。设备名检查不在所有平台上一律禁止 COM0 或 LPT0,也不保证所有 Windows 工具或提供者均支持它们。优先使用 UUID id;不要以为字符检查能让任意名称跨平台可用。

JSON 存储的运维 v6.3.0+ ​

自 v6.3.0 起,每个插件的 JSON 表旁边都有一个隐藏的 .ultitools-journal 文件夹。已提交的事务会在 commit 返回之前写入其中并强制落盘,下次启动时重放进表文件。服务器中它位于 plugins/UltiTools/data/<插件名>/.ultitools-journal。事务提供的保证见事务指南。

  • 一个数据文件夹只运行一个服务器进程:两个服务器绝不能同时使用同一个 plugins 数据文件夹。
  • 备份时把 .ultitools-journal 文件夹与各表文件夹一起备份,或者在服务器停止时备份。cp dir/* 这类复制会跳过以点开头的文件夹。
  • 永远不要删除 .ultitools-journal,崩溃之后也不要:里面保存着尚未写入表文件的提交。
  • 崩溃之后,手工编辑任何 JSON 数据文件之前先启动一次服务器:启动会重放日志,并会覆盖你的编辑。
  • 断电后发现最新的日志记录损坏,通常说明磁盘或其控制器没有遵守刷盘请求。该插件的 JSON 数据会保持关闭,直到你把指明的文件移出日志文件夹并重启服务器。在加载时就获取数据操作器的模块(所有官方模块都是如此)会在那次启动时被拒绝加载;只有在加载完成之后才首次请求数据的插件,会在下一次请求时恢复,无需重启。
  • 某个实体文件持续无法移动(例如被其他程序一直锁着)会使该插件的每次刷新都无法清空日志:记录不断累积,启动恢复变慢,每次失败的刷新都会出现在服务器日志里。
  • 在 Windows 上,被其他程序(杀毒软件、索引器、备份工具)短暂占用的日志文件移动,最多重试 150 毫秒,之后提交失败。
  • id 中含 <、>、:、"、|、? 或 * 的 JSON 数据文件夹(UltiRemoteBag 的编辑占用记录使用 :)无法从 Linux 复制到 Windows:Windows 无法创建这些文件。id 只有大小写不同的数据也无法迁移到 Windows 或默认设置的 macOS 卷:文件会互相冲突。若含这类 id 的日志记录到了 Windows 服务器上,启动时该插件的 JSON 数据会保持关闭,错误信息指明该记录:请在写下它的 Linux 服务器上应用它,或把它移出日志文件夹并放弃其中的改动。
  • 新 id 在创建时检查,已有文件绝不会被改名,见 JSON 实体 id。UUID 字符串作为 id 总是可用;小写字母、数字、- 和 _ 仅在该 id 不是 Windows 设备名(con、nul、aux、prn、com1–com9、lpt1–lpt9)时才安全。不要用玩家名作为实体 id(Con 和 Nul 都是合法的玩家名)。这些规则会拒绝的已有 id 仍可读取、update 和删除,但删除后再插入会被拒绝:请用 update 替换。

贡献者

The avatar of contributor named as Claude Opus 5.5 Claude Opus 5.5
The avatar of contributor named as Ling Bao Ling Bao
The avatar of contributor named as a a

基于 MIT 许可发布