跳转至

原文:MinecraftForge/Documentation · 目标版本:Forge 1.20.1

编解码器(Codecs)

编解码器(Codec)是来自 Mojang DataFixerUpper 的序列化工具,用于描述对象如何在不同的格式之间转换,例如用于 JSON 的 JsonElement 和用于 NBT 的 Tag

使用编解码器(Using Codecs)

编解码器主要用于将 Java 对象编码(encode,即序列化)为某种数据格式类型,以及将格式化数据对象解码(decode,即反序列化)回其关联的 Java 类型。这通常分别通过 Codec#encodeStartCodec#parse 完成。

DynamicOps

为了确定编码和解码所采用的中间文件格式,#encodeStart#parse 都需要一个 DynamicOps 实例来在该格式内定义数据。

DataFixerUpper 库包含用于编解码存储在 GsonJsonElement 实例中的 JSON 数据的 JsonOpsJsonOps 支持两种 JsonElement 序列化版本:JsonOps#INSTANCE 定义标准 JSON 文件,JsonOps#COMPRESSED 允许将数据压缩为单个字符串。

// 假设 exampleCodec 表示 Codec<ExampleJavaObject>
// 假设 exampleObject 是 ExampleJavaObject
// 假设 exampleJson 是 JsonElement

// 将 Java 对象编码为普通 JsonElement
exampleCodec.encodeStart(JsonOps.INSTANCE, exampleObject);

// 将 Java 对象编码为压缩后的 JsonElement
exampleCodec.encodeStart(JsonOps.COMPRESSED, exampleObject);

// 将 JsonElement 解码为 Java 对象
// 假设 JsonElement 是正常解析的
exampleCodec.parse(JsonOps.INSTANCE, exampleJson);

Minecraft 还提供了用于编解码存储在 Tag 实例中的 NBT 数据的 NbtOps。可以通过 NbtOps#INSTANCE 引用它。

// 假设 exampleCodec 表示 Codec<ExampleJavaObject>
// 假设 exampleObject 是 ExampleJavaObject
// 假设 exampleNbt 是 Tag

// 将 Java 对象编码为 Tag
exampleCodec.encodeStart(JsonOps.INSTANCE, exampleObject);

// 将 Tag 解码为 Java 对象
exampleCodec.parse(JsonOps.INSTANCE, exampleNbt);

格式转换(Format Conversion)

DynamicOps 也可以单独用于在两种不同的编码格式之间转换。这可以通过 #convertTo 并传入目标 DynamicOps 格式和要转换的编码对象来完成。

// 将 Tag 转换为 JsonElement
// 假设 exampleTag 是 Tag
JsonElement convertedJson = NbtOps.INSTANCE.convertTo(JsonOps.INSTANCE, exampleTag);

DataResult

使用编解码器编码或解码的数据会返回一个 DataResult,它根据转换是否成功,持有转换后的实例或一些错误数据。当转换成功时,#result 提供的 Optional 将包含成功转换的对象。如果转换失败,#error 提供的 Optional 将包含 PartialResult,它根据编解码器持有错误消息和一个部分转换的对象。

此外,DataResult 上还有许多方法可用于将结果或错误转换为所需格式。例如,#resultOrPartial 在成功时返回包含结果的 Optional,在失败时返回包含部分转换对象的 Optional。该方法接收一个字符串消费者(consumer),用于决定如何报告错误消息(如果存在)。

// 假设 exampleCodec 表示 Codec<ExampleJavaObject>
// 假设 exampleJson 是 JsonElement

// 将 JsonElement 解码为 Java 对象
DataResult<ExampleJavaObject> result = exampleCodec.parse(JsonOps.INSTANCE, exampleJson);

result
  // 获取结果,或在出错时获取部分结果并报告错误消息
  .resultOrPartial(errorMessage -> /* 对错误消息做些什么 */)
  // 如果结果或部分结果存在,做些什么
  .ifPresent(decodedObject -> /* 对解码后的对象做些什么 */);

现有编解码器(Existing Codecs)

基本类型(Primitives)

Codec 类包含某些已定义基本类型的编解码器静态实例。

Codec Java 类型
BOOL Boolean
BYTE Byte
SHORT Short
INT Integer
LONG Long
FLOAT Float
DOUBLE Double
STRING String
BYTE_BUFFER ByteBuffer
INT_STREAM IntStream
LONG_STREAM LongStream
PASSTHROUGH Dynamic<?>*
EMPTY Unit**

* Dynamic 是持有以受支持的 DynamicOps 格式编码的值的对象。它们通常用于将一种编码对象格式转换为另一种编码对象格式。

** Unit 是用于表示 null 对象的对象。

原版与 Forge(Vanilla and Forge)

Minecraft 和 Forge 为许多经常被编码和解码的对象定义了编解码器。一些示例包括:用于 ResourceLocationResourceLocation#CODEC、用于以 DateTimeFormatter#ISO_INSTANT 格式表示的 InstantExtraCodecs#INSTANT_ISO8601,以及用于 CompoundTagCompoundTag#CODEC

Warning

CompoundTag 无法使用 JsonOps 从 JSON 解码数字列表。JsonOps 在转换时会将数字设置为其最窄的类型。ListTag 会强制其数据使用特定类型,因此类型不同的数字(例如 64 会是 byte384 会是 short)会在转换时抛出错误。

原版和 Forge 的注册表(Registry)也有针对注册表所含对象类型的编解码器(例如 Registry#BLOCKForgeRegistries#BLOCKS 提供 Codec<Block>)。Registry#byNameCodecIForgeRegistry#getCodec 会将注册表对象编码为其注册表名称,若压缩则编码为整数标识符。原版注册表还有一个 Registry#holderByNameCodec,它编码为注册表名称,解码为包装在 Holder 中的注册表对象。

创建编解码器(Creating Codecs)

可以为任何对象创建用于编码和解码的编解码器。为便于理解,下文将展示等价的编码后 JSON。

记录(Records)

编解码器可以通过记录(record)来定义对象。每个记录编解码器都以显式命名的字段定义对象。创建记录编解码器的方法有很多,最简单的是通过 RecordCodecBuilder#create

RecordCodecBuilder#create 接收一个函数,该函数定义一个 Instance 并返回对象的 application(App)。这与创建类实例(instance)以及用于将类应用(apply)到构造对象的构造函数之间存在类比关系。

// 要为某个对象创建编解码器的对象
public class SomeObject {

  public SomeObject(String s, int i, boolean b) { /* ... */ }

  public String s() { /* ... */ }

  public int i() { /* ... */ }

  public boolean b() { /* ... */ }
}

字段(Fields)

Instance 可以使用 #group 定义最多 16 个字段。每个字段必须是一个 application,定义要构造的对象所属的实例以及对象的类型。满足这一要求的最简单方法是:取一个 Codec,设置要解码的字段名称,并设置用于编码该字段的 getter。

字段可以从 Codec 创建:如果字段是必需的,使用 #fieldOf;如果字段包装在 Optional 中或有默认值,使用 #optionalFieldOf。两种方法都需要一个包含编码对象中字段名称的字符串。然后可以使用 #forGetter 设置用于编码该字段的 getter,它接收一个函数:给定对象后返回字段数据。

之后,可以通过 #apply 应用所得结果,以定义实例应如何为 application 构造对象。为了方便起见,分组字段应按其在构造函数中出现的顺序排列,这样该函数就可以简单地是构造函数的方法引用。

public static final Codec<SomeObject> RECORD_CODEC = RecordCodecBuilder.create(instance -> // 给定一个 instance
  instance.group( // 定义 instance 中的字段
    Codec.STRING.fieldOf("s").forGetter(SomeObject::s), // String
    Codec.INT.optionalFieldOf("i", 0).forGetter(SomeObject::i), // Integer,字段不存在时默认为 0
    Codec.BOOL.fieldOf("b").forGetter(SomeObject::b) // Boolean
  ).apply(instance, SomeObject::new) // 定义如何创建对象
);
// 编码后的 SomeObject
{
  "s": "value",
  "i": 5,
  "b": false
}

// 另一个编码后的 SomeObject
{
  "s": "value2",
  // i 被省略,默认为 0
  "b": true
}

转换器(Transformers)

编解码器可以通过映射方法转换为等价或部分等价的表示。每个映射方法接收两个函数:一个将当前类型转换为新类型,另一个将新类型转换回当前类型。这通过 #xmap 函数完成。

// 一个类
public class ClassA {

  public ClassB toB() { /* ... */ }
}

// 另一个等价类
public class ClassB {

  public ClassA toA() { /* ... */ }
}

// 假设存在某个编解码器 A_CODEC
public static final Codec<ClassB> B_CODEC = A_CODEC.xmap(ClassA::toB, ClassB::toA);

如果类型是部分等价的,即转换过程中存在一些限制,则有返回 DataResult 的映射函数——当遇到异常或无效状态时,可以用它返回错误状态。

A 与 B 是否完全等价 B 与 A 是否完全等价 转换方法
#xmap
#flatComapMap
#comapFlatMap
#flatXMap
// 给定一个字符串编解码器以转换为整数
// 并非所有字符串都能变成整数(A 与 B 不完全等价)
// 所有整数都能变成字符串(B 与 A 完全等价)
public static final Codec<Integer> INT_CODEC = Codec.STRING.comapFlatMap(
  s -> { // 返回包含错误的数据结果
    try {
      return DataResult.success(Integer.valueOf(s));
    } catch (NumberFormatException e) {
      return DataResult.error(s + " is not an integer.");
    }
  },
  Integer::toString // 普通函数
);
// 将返回 5
"5"

// 将报错,不是整数
"value"

范围编解码器(Range Codecs)

范围编解码器是 #flatXMap 的一种实现:如果值不在设定的最小值和最大值之间(含边界),则返回错误的 DataResult。超出边界时,该值仍会作为部分结果提供。整数、浮点数和双精度浮点数分别通过 #intRange#floatRange#doubleRange 实现。

public static final Codec<Integer> RANGE_CODEC = Codec.intRange(0, 4); 
// 有效,在 [0, 4] 内
4

// 报错,在 [0, 4] 外
5

默认值(Defaults)

如果编码或解码的结果失败,可以通过 Codec#orElseCodec#orElseGet 提供默认值作为替代。

public static final Codec<Integer> DEFAULT_CODEC = Codec.INT.orElse(0); // 也可以通过 #orElseGet 提供值
// 不是整数,默认为 0
"value"

单位(Unit)

提供代码内值并编码为空的编解码器可以用 Codec#unit 表示。如果编解码器在数据对象中使用了不可编码的条目,这很有用。

public static final Codec<IForgeRegistry<Block>> UNIT_CODEC = Codec.unit(
  () -> ForgeRegistries.BLOCKS // 也可以是原始值
);
// 这里什么都没有,将返回方块注册表编解码器

列表(List)

对象列表的编解码器可以通过 Codec#listOf 从对象编解码器生成。

// BlockPos#CODEC 是 Codec<BlockPos>
public static final Codec<List<BlockPos>> LIST_CODEC = BlockPos.CODEC.listOf();
// 编码后的 List<BlockPos>
[
  [1, 2, 3], // BlockPos(1, 2, 3)
  [4, 5, 6], // BlockPos(4, 5, 6)
  [7, 8, 9]  // BlockPos(7, 8, 9)
]

使用列表编解码器解码出的列表对象存储在不可变(immutable)列表中。如果需要可变列表,应对列表编解码器应用转换器

映射(Map)

键和值对象的映射编解码器可以通过 Codec#unboundedMap 从两个编解码器生成。无界映射(unbounded map)可以指定任何基于字符串或可转换为字符串的值作为键。

// BlockPos#CODEC 是 Codec<BlockPos>
public static final Codec<Map<String, BlockPos>> MAP_CODEC = Codec.unboundedMap(Codec.STRING, BlockPos.CODEC);
// 编码后的 Map<String, BlockPos>
{
  "key1": [1, 2, 3], // key1 -> BlockPos(1, 2, 3)
  "key2": [4, 5, 6], // key2 -> BlockPos(4, 5, 6)
  "key3": [7, 8, 9]  // key3 -> BlockPos(7, 8, 9)
}

使用无界映射编解码器解码出的映射对象存储在不可变映射中。如果需要可变映射,应对映射编解码器应用转换器

Warning

无界映射只支持编码/解码为字符串的键。可以使用键值配对列表编解码器来绕过这一限制。

配对(Pair)

对象配对的编解码器可以通过 Codec#pair 从两个编解码器生成。

配对编解码器解码对象时,首先解码配对中的左对象,然后取编码对象的剩余部分,从中解码右对象。因此,编解码器要么在解码后表达编码对象的某些信息(例如记录),要么必须增强为 MapCodec 并通过 #codec 转换为普通编解码器。这通常可以通过将编解码器设为某个对象的字段来完成。

public static final Codec<Pair<Integer, String>> PAIR_CODEC = Codec.pair(
  Codec.INT.fieldOf("left").codec(),
  Codec.STRING.fieldOf("right").codec()
);
// 编码后的 Pair<Integer, String>
{
  "left": 5,       // fieldOf 查找 'left' 键作为左对象
  "right": "value" // fieldOf 查找 'right' 键作为右对象
}

Tip

具有非字符串键的映射编解码器,可以通过应用了转换器的键值对列表进行编码/解码。

二选一(Either)

针对同一对象数据的两种不同编码/解码方法的编解码器,可以通过 Codec#either 从两个编解码器生成。

二选一编解码器先尝试使用第一个编解码器解码对象。如果失败,则尝试使用第二个编解码器解码。如果也失败了,DataResult 将只包含第二个编解码器失败的错误。

public static final Codec<Either<Integer, String>> EITHER_CODEC = Codec.either(
  Codec.INT,
  Codec.STRING
);
// 编码后的 Either$Left<Integer, String>
5

// 编码后的 Either$Right<Integer, String>
"value"

Tip

这可以与转换器结合使用,从两种不同的编码方法中获得特定对象。

分发(Dispatch)

编解码器可以拥有子编解码器,通过 Codec#dispatch 根据某个指定的类型解码特定对象。这通常用于包含编解码器的注册表,例如规则测试(rule test)或方块放置器(block placer)。

分发编解码器首先尝试从某个字符串键(通常是 type)获取编码的类型。然后解码该类型,调用 getter 获取用于解码实际对象的特定编解码器。如果用于解码对象的 DynamicOps 会压缩其映射,或者对象编解码器本身没有增强为 MapCodec(例如记录或带字段的基本类型),则对象需要存储在 value 键中。否则,对象将与其余数据在同一层级解码。

// 定义我们的对象
public abstract class ExampleObject {

  // 定义用于在编码时指定对象类型的方法
  public abstract Codec<? extends ExampleObject> type();
}

// 创建存储字符串的简单对象
public class StringObject extends ExampleObject {

  public StringObject(String s) { /* ... */ }

  public String s() { /* ... */ }

  public Codec<? extends ExampleObject> type() {
    // 一个已注册的注册表对象
    // "string":
    //   Codec.STRING.xmap(StringObject::new, StringObject::s)
    return STRING_OBJECT_CODEC.get();
  }
}

// 创建存储字符串和整数的复杂对象
public class ComplexObject extends ExampleObject {

  public ComplexObject(String s, int i) { /* ... */ }

  public String s() { /* ... */ }

  public int i() { /* ... */ }

  public Codec<? extends ExampleObject> type() {
    // 一个已注册的注册表对象
    // "complex":
    //   RecordCodecBuilder.create(instance ->
    //     instance.group(
    //       Codec.STRING.fieldOf("s").forGetter(ComplexObject::s),
    //       Codec.INT.fieldOf("i").forGetter(ComplexObject::i)
    //     ).apply(instance, ComplexObject::new)
    //   )
    return COMPLEX_OBJECT_CODEC.get();
  }
}

// 假设存在一个 IForgeRegistry<Codec<? extends ExampleObject>> DISPATCH
public static final Codec<ExampleObject> = DISPATCH.getCodec() // 获取 Codec<Codec<? extends ExampleObject>>
  .dispatch(
    ExampleObject::type, // 从特定对象获取编解码器
    Function.identity() // 从注册表获取编解码器
  );
// 简单对象
{
  "type": "string", // 用于 StringObject
  "value": "value" // Codec 类型没有从 MapCodec 增强而来,需要字段
}

// 复杂对象
{
  "type": "complex", // 用于 ComplexObject

  // Codec 类型是从 MapCodec 增强而来的,可以内联
  "s": "value",
  "i": 0
}

待复核清单

  • 原文 NBT 示例代码中存在两处疑似笔误:使用 JsonOps.INSTANCE 处理 Tag/exampleNbt(应为 NbtOps.INSTANCE)。为忠实原文未改动代码,仅注释译文,建议主会话确认是否修正。
  • 分发(Dispatch)一节代码中 public static final Codec<ExampleObject> = ... 缺少变量名,为原文笔误,按原文保留。
  • 编解码器体系(Codec/DynamicOps/DataResult/RecordCodecBuilder)为 Mojang 库,与 1.20.1 一致(Forge 47.x 使用 com.mojang.serialization),无需版本改动。
  • 表格「Codec | Java 类型」表头中的 Java 类型按原文保留英文。