Skip to main content
Version: 1.21.4

编解码器(Codecs)

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

使用编解码器(Using Codecs)

编解码器主要用于将 Java 对象编码(序列化)为某种数据格式类型,或将格式化的数据对象解码(反序列化)回其对应的 Java 类型。通常分别使用 Codec#encodeStartCodec#parse 方法(method)来完成这些操作。

动态操作(DynamicOps)

为了确定要编码和解码到哪种中间文件格式,#encodeStart#parse 都需要一个 DynamicOps 实例,用于定义该格式中的数据。

DataFixerUpper 库内置了 JsonOps,用于对存储在 GsonJsonElement 实例中的 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 类为一些已定义的基本类型提供了静态编解码器实例。

CodecJava 类型
BOOLBoolean
BYTEByte
SHORTShort
INTInteger
LONGLong
FLOATFloat
DOUBLEDouble
STRINGString*
BYTE_BUFFERByteBuffer
INT_STREAMIntStream
LONG_STREAMLongStream
PASSTHROUGHDynamic<?>**
EMPTYUnit***

* String 可以通过 Codec#stringCodec#sizeLimitedString 限制字符数量。

** Dynamic 是一个对象,用于保存以受支持的 DynamicOps 格式编码的值。通常用于在不同编码对象格式之间进行转换。

*** Unit 是一个用于表示 null 对象的类型。

原版与 NeoForge(Vanilla and NeoForge)

Minecraft 和 NeoForge 为经常需要编码和解码的对象定义了许多编解码器。例如,ResourceLocation#CODEC 用于 ResourceLocationExtraCodecs#INSTANT_ISO8601 用于以 DateTimeFormatter#ISO_INSTANT 格式编码的 InstantCompoundTag#CODEC 用于 CompoundTag

caution

CompoundTag 无法通过 JsonOps 从 JSON 解码数字列表。JsonOps 在转换时,会将数字设置为其最窄的数据类型。而 ListTag 要求其内部数据类型必须一致,因此如果数字类型不同(例如 64 会被视为 byte384 会被视为 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。

如果字段是必需的,可以通过 #fieldOfCodec 创建字段;如果字段被包裹在 Optional 或有默认值,则使用 #optionalFieldOf。这两种方式都需要传入一个字符串,作为编码对象中该字段的名称。然后可以通过 #forGetter 设置用于编码该字段的 getter,它接受一个函数,传入对象后返回字段数据。

warning

如果解析时遇到抛出错误的元素,#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#stringResolverflatXmap 的一种实现,用于将字符串映射为某种对象。

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#orElseCodec#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 编码器应用一个 转换器

caution

非限定映射只支持可以编码/解码为字符串的键。如果需要绕过这个限制,可以使用键值 列表编码器。

对(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' 键
}
tip

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

二选一(Either)

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

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

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

异或(Xor)

Codec#xoreither 编解码器(codec)的一种特殊情况,只有当两个方法中的一个成功处理时,结果才会被视为成功。如果两个编解码器都能被处理,则会抛出错误。

public static final Codec<Either<Integer, String>> XOR_CODEC = Codec.xor(
Codec.INT.fieldOf("number").codec(),
Codec.STRING.fieldOf("text").codec()
);
// 编码后的 Either.Left<Integer, String>
{
"number": 4
}

// 编码后的 Either.Right<Integer, String>
{
"text": "value"
}

// 如果两者都能被解码,则抛出错误
{
"number": 4,
"text": "value"
}

备选方案(Alternative)

Codec#withAlternativeeither 编解码器(codec)的一种特殊情况,其中两个编解码器会尝试以不同的格式解码同一个对象。首先会用主编解码器(primary codec)尝试解码对象,如果失败,则使用第二个编解码器。编码时始终使用主编解码器。

public static final Codec<BlockPos> ALTERNATIVE_CODEC = Codec.withAlternative(
BlockPos.CODEC,
RecordCodecBuilder.create(instance -> instance.group(
Codec.INT.fieldOf("x").forGetter(BlockPos::getX),
Codec.INT.fieldOf("y").forGetter(BlockPos::getY),
Codec.INT.fieldOf("z").forGetter(BlockPos::getZ)
), BlockPos::new)
);
// 解码 BlockPos 的常规方法
[ 1, 2, 3 ]

// 解码 BlockPos 的备选方法
{
"x": 1,
"y": 2,
"z": 3
}

递归(Recursive)

有时,一个对象的某个字段可能会引用同类型的对象。例如,EntityPredicate 会为载具、乘客和目标实体分别接收一个 EntityPredicate。在这种情况下,可以使用 Codec#recursive,通过将编解码器作为函数参数来创建递归编解码器。

// 定义递归对象
public record RecursiveObject(Optional<RecursiveObject> inner) { /* ... */ }

public static final Codec<RecursiveObject> RECURSIVE_CODEC = Codec.recursive(
RecursiveObject.class.getSimpleName(), // 用于 toString 方法
recursedCodec -> RecordCodecBuilder.create(instance -> instance.group(
recursedCodec.optionalFieldOf("inner").forGetter(RecursiveObject::inner)
).apply(instance, RecursiveObject::new))
);
// 编码后的递归对象
{
"inner": {
"inner": {}
}
}

分发(Dispatch)

编解码器(codec)可以拥有子编解码器(subcodec),通过 Codec#dispatch,可以根据某种指定类型对特定对象进行解码。这通常用于包含编解码器的注册表(registry),例如规则测试(rule tests)或方块放置器(block placers)。 分发编解码器(dispatch codec)首先会尝试从某个字符串键(通常为 type)获取编码类型。接下来,根据解码得到的类型,会调用相应的 getter 方法,获取用于解码实际对象的特定编解码器(codec)。如果用于解码对象的 DynamicOps 会对其 map 进行压缩,或者对象的编解码器本身没有被增强为 MapCodec(例如记录类型或带字段的基础类型),那么该对象需要存储在 value 键下。否则,对象会与其他数据处于同一层级进行解码。

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

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

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

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

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

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

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

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

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

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

public MapCodec<? extends ExampleObject> type() {
// 注册的注册表对象
// "complex":
// RecordCodecBuilder.mapCodec(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();
}
}

// 假设有一个 Registry<MapCodec<? extends ExampleObject>> DISPATCH
public static final Codec<ExampleObject> = DISPATCH.byNameCodec() // 获取 Codec<MapCodec<? extends ExampleObject>>
.dispatch(
ExampleObject::type, // 从具体对象获取编解码器
Function.identity() // 从注册表获取编解码器
);
// 简单对象
{
"type": "string", // 对应 StringObject
"value": "value" // 编解码器类型未被 MapCodec 增强,需要用字段
}

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

// 编解码器类型已被 MapCodec 增强,可以内联
"s": "value",
"i": 0
}