编解码器(Codecs)
编解码器(Codecs)是一种来自 Mojang 的 DataFixerUpper 序列化工具,用于描述对象如何在不同格式之间转换,例如用于 JSON 的 JsonElement 和用于 NBT 的 Tag。
使用编解码器(Using Codecs)
编解码器主要用于将 Java 对象编码(序列化)为某种数据格式类型,或将格式化的数据对象解码(反序列化)回其对应的 Java 类型。通常分别使用 Codec#encodeStart 和 Codec#parse 方法(method)来完成这些操作。
动态操作(DynamicOps)
为了确定要编码和解码到哪种中间文件格式,#encodeStart 和 #parse 都需要一个 DynamicOps 实例,用于定义该格式中的数据。
DataFixerUpper 库内置了 JsonOps,用于对存储在 Gson 的 JsonElement 实例中的 JSON 数据进行编解码。JsonOps 支持两种 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 还提供了 NbtOps,用于对存储在 Tag 实例中的 NBT 数据进行编解码。可以通过 NbtOps#INSTANCE 来引用。
// 假设 exampleCodec 表示一个 Codec<ExampleJavaObject>
// 假设 exampleObject 是一个 ExampleJavaObject
// 假设 exampleNbt 是一个 Tag
// 将 Java 对象编码为 Tag
exampleCodec.encodeStart(NbtOps.INSTANCE, exampleObject);
// 将 Tag 解码为 Java 对象
exampleCodec.parse(NbtOps.INSTANCE, exampleNbt);
为了处理注册表(Registry)条目,Minecraft 提供了 RegistryOps,它包含一个查找提供者(lookup provider),用于获取可用的注册表元素。可以通过 RegistryOps#create 方法创建,这个方法需要传入带有特定类型的 DynamicOps(用于存储数据)以及包含可用注册表访问权限的查找提供者。NeoForge 扩展了 RegistryOps,创建了 ConditionalOps:一种可以处理加载条目的条件的注册表编解码器查找方式。
// 假设 lookupProvider 是一个 HolderLookup.Provider
// 假设 exampleCodec 表示一个 Codec<ExampleJavaObject>
// 假设 exampleObject 是一个 ExampleJavaObject
// 假设 exampleJson 是一个 JsonElement
// 获取用于 JsonElement 的注册表操作对象
RegistryOps<JsonElement> ops = RegistryOps.create(JsonOps.INSTANCE, lookupProvider);
// 将 Java 对象编码为 JsonElement
exampleCodec.encodeStart(ops, exampleObject);
// 将 JsonElement 解码为 Java 对象
exampleCodec.parse(ops, exampleJson);
格式转换(Format Conversion)
DynamicOps 也可以单独用来在两种不同的编码格式之间进行转换。这可以通过 #convertTo 方法实现,你只需提供目标的 DynamicOps 格式和待转换的编码对象。
// 将 Tag 转换为 JsonElement
// 假设 exampleTag 是一个 Tag
JsonElement convertedJson = NbtOps.INSTANCE.convertTo(JsonOps.INSTANCE, exampleTag);
DataResult
使用编解码器(codec)进行编码或解码数据时,会返回一个 DataResult,它根据转换是否成功,包含已转换的实例或一些错误数据。当转换成功时,通过 #result 提供的 Optional 会包含成功转换后的对象。如果转换失败,通过 #error 提供的 Optional 会包含 PartialResult,其中保存了错误信息和一个根据编解码器规则部分转换的对象。
此外,DataResult 上还提供了许多方法,可以将结果或错误转换为你期望的格式。例如,#resultOrPartial 方法会在成功时返回一个包含结果的 Optional,在失败时则返回部分转换的对象。该方法需要传入一个字符串消费者,用于决定如何报告错误信息(如果有的话)。
// 假设 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*** |
* String 可以通过 Codec#string 或 Codec#sizeLimitedString 限制字符数量。
** Dynamic 是一个对象,用于保存以受支持的 DynamicOps 格式编码的值。通常用于在不同编码对象格式之间进行转换。
*** Unit 是一个用于表示 null 对象的类型。
原版与 NeoForge(Vanilla and NeoForge)
Minecraft 和 NeoForge 为经常需要编码和解码的对象定义了许多编解码器。例如,ResourceLocation#CODEC 用于 ResourceLocation,ExtraCodecs#INSTANT_ISO8601 用于以 DateTimeFormatter#ISO_INSTANT 格式编码的 Instant,CompoundTag#CODEC 用于 CompoundTag。
CompoundTag 无法通过 JsonOps 从 JSON 解码数字列表。JsonOps 在转换时,会将数字设置为其最窄的数据类型。而 ListTag 要求其内部数据类型必须一致,因此如果数字类型不同(例如 64 会被视为 byte,384 会被视为 short),在转换时会抛出错误。
原版 Minecraft 和 NeoForge 的注册表(Registry)也为注册表中包含的对象类型提供了编解码器(codec),例如 BuiltInRegistries#BLOCK 拥有一个 Codec<Block>。Registry#byNameCodec 会将注册表对象编码为其注册表名称。原版注册表还提供了 Registry#holderByNameCodec,它会将对象编码为注册表名称,并在解码时返回一个被 Holder 包裹的注册表对象。
创建编解码器(Codecs)
可以为任意对象创建编解码器(codec),用于编码和解码。为了便于理解,下面会展示等效的编码 JSON。
记录类型(Records)
编解码器可以通过记 录类型(record)来定义对象。每个记录类型的编解码器都通过显式命名字段来定义对象。创建记录类型编解码器的方法有很多,但最简单的方式是使用 RecordCodecBuilder#create。
RecordCodecBuilder#create 接受一个函数,该函数定义了一个 Instance 并返回一个对象的应用(App)。可以类比为创建一个类的 实例,以及用构造函数 应用 该类来构造对象。
// 需要为其创建编解码器的某个对象
public class SomeObject {
public SomeObject(String s, int i, boolean b) { /* ... */ }
public String s() { /* ... */ }
public int i() { /* ... */ }
public boolean b() { /* ... */ }
}
字段(Fields)
一个 Instance 最多可以用 #group 定义 16 个字段。每个字段都必须是一个应用,定义了要为其创建实例的对象和对象的类型。最简单的做法是,直接使用一个 Codec,指定要解码的字段名称,并设置用于编码该字段的 getter。
如果字段是必需的,可以通过 #fieldOf 从 Codec 创建字段;如果字段被包裹在 Optional 或有默认值,则使用 #optionalFieldOf。这两种方式都需要传入一个字符串,作为编码对象中该字段的名称。然后可以通过 #forGetter 设置用于编码该字段的 getter,它接受一个函数,传入对象后返回字段数据。
如果解析时遇到抛出错误的元素,#optionalFieldOf 会抛出错误。如果希望忽略该错误,应改用 #lenientOptionalFieldOf。
之后,可以通过 #apply 应用生成的产物,定义实例在应用时应如何构造对象。为了方便,建议分组字段的顺序与构造函数中的参数顺序一致,这样函数就可以直接使用构造方法引用。
public static final Codec<SomeObject> RECORD_CODEC = RecordCodecBuilder.create(instance -> // 给定一个实例
instance.group( // 定义实例中的字段
Codec.STRING.fieldOf("s").forGetter(SomeObject::s), // 字符串
Codec.INT.optionalFieldOf("i", 0).forGetter(SomeObject::i), // 整数,如果字段不存在则默认为 0
Codec.BOOL.fieldOf("b").forGetter(SomeObject::b) // 布尔值
).apply(instance, SomeObject::new) // 定义如何创建对象
);
// 编码后的 SomeObject
{
"s": "value",
"i": 5,
"b": false
}
// 另一个编码后的 SomeObject
{
"s": "value2",
// i 被省略,默认为 0
"b": true
}
// 另一个编码后的 SomeObject
{
"s": "value2",
// 由于没有使用 lenientOptionalFieldOf,会抛出错误
"i": "bad_value",
"b": true
}
转换器(Transformers)
编解码器(Codec)可以通过映射方法(mapping methods)转换为等价或部分等价的其他表示形式。每个映射方法都需要两个函数:一个用于将当前类型转换为新类型,另一个用于将新类型转换回当前类型。这可以通过 #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 的映射函数(mapping functions),这样在遇到异常或无效状态时可以返回错误状态。
| 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 + " 不是一个整数。");
}
},
Integer::toString // 普通函数
);
// 将返回 5
"5"
// 会报错,不是一个整数
"value"
范围编解码器(Range Codecs)
区间编解码器(Range codecs)是 #flatXMap 的一种实现,它会在数值没有包含在设定的最小值和最大值之间时,返回一个错误的 DataResult。如果数值超出边界,依然会作为部分结果返回。目前对整数、浮点数和双精度浮点数分别提供了实现,分别通过 #intRange、#floatRange 和 #doubleRange。
public static final Codec<Integer> RANGE_CODEC = Codec.intRange(0, 4);
// 合法,位于 [0, 4] 区间内
4
// 非法,超出 [0, 4] 区间
5
字符串解析器(String Resolver)
Codec#stringResolver 是 flatXmap 的一种实现,用于将字符串映射为某种对象。
public record StringResolverObject(String name) { /* ... */ }
// 假设存 在一个 Map<String, StringResolverObject> OBJECT_MAP
public static final Codec<StringResolverObject> STRING_RESOLVER_CODEC = Codec.stringResolver(StringResolverObject::name, OBJECT_MAP::get);
// 会将该字符串映射为其关联的对象
"example_name"
默认值(Defaults)
如果编码或解码的结果失败,可以通过 Codec#orElse 或 Codec#orElseGet 提供一个默认值。
public static final Codec<Integer> DEFAULT_CODEC = Codec.INT.orElse(
errorMessage -> /* 处理错误信息 */,
0 // 也可以通过 #orElseGet 提供
);
// 不是整数,将会返回默认值 0
"value"
单元(Unit)
如果需要一个仅在代码中提供值、但编码时不输出任何内容的编解码器,可以使用 Codec#unit。当数据对象中包含无法编码的条目时,这种方式非常有用。
public static final Codec<IEventBus> UNIT_CODEC = Codec.unit(
() -> NeoForge.EVENT_BUS // 也可以直接传原始值
);
// 这里什么都没有,会返回 NeoForge 的事件总线
延迟初始化(Lazy Initialized)
有时,编解码器可能依赖于其构造时尚未存在的数据。在这种情况下,可以使用 Codec#lazyInitialized,让编解码器在首次编码/解码时自行构造。该方法需要传入一个提供编解码器的函数。
public static final Codec<IEventBus> LAZY_CODEC = Codec.lazyInitialized(
() -> Codec.Unit(NeoForge.EVENT_BUS)
);
// 这里什么都没有,会返回 NeoForge 的事件总线
// 编码/解码方式与普通编解码器一致
列表(List)
可以通过对象编解码器使用 Codec#listOf 生成对象列表的编解码器。listOf 还可以接收整数参数,指定列表的最小和最大长度。sizeLimitedListOf 只指定最大长度。
// 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)
]
使用 list 编码器(list codec)解码得到的 List 对象会被存储为不可变列表。如果你需要一个可变列表,应当对 list 编码器应用一个 转换器。
映射(Map)
可以通过两个编码器,使用 Codec#unboundedMap 生成一个键和值对象的映射(map)的编码器。非限定映射(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)
}
使用非限定映射编码器解码得到的 Map 对象会被存储为不可变映射。如果你需要一个可变映射,应当对 map 编码器应用一个 转换器。
非限定映射只支持可以编码/解码为字符串的键。如果需要绕过这个限制,可以使用键值 对 列表编码器。
对(Pair)
可以通过两个编码器,使用 Codec#pair 生成一对对象的编码器。
对(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' 键
}
具有非字符串键的 map 编码器可以通过应用 转换器 的键值对列表来进行编码/解码。