最近 Mojang 给 DataFixerUpper 库增加了编解码器系统,用于实现一种简单易行的数据序列化结构创建方式,使之既支持序列化,又支持反序列化。这同时也是一种能够同时处理 JSON 和 NBT 数据序列化的手段。
然而编解码器并不像它表面上看起来那样简单。好在这篇教程会把它讲明白。
如果你有任何关于编解码器或者更深入的问题需要反馈,可以在 这下面 评论,或者在推特上 @Drullkus
。
我希望能够把这篇文章写成一个完整全面的教程,可供开发时速查;但我最基本的目标,是让其他开发者能够跟上节奏。
说实在的,我也不完全知道。目前,这个东西同时用于数据的序列化和反序列化,同时也具备处理 JSON 数据和 NBT 的能力。
它似乎是一种能够“用单独一个库处理上述 4 种问题”的抽象化的手段。我个人认为它是一个很有趣的库。
在 Minecraft 1.16.2 的更新中,我找不到任何一段不使用编解码器的世界生成代码。
它似乎也被隐约地用于新的 Minecraft 生物 AI 的记忆相关代码中,我也想了解一下这一块。
假设我们有一个类 Foobar
,它包含以下字段: boolean
"foo
", List<Integer>
"bar
", BlockState
"blockState
",以及一个类构造函数,其参数表与这些字段有相同的顺序。同时,我们相应地有下列 JSON 文件:
{
"foo": false,
"bar": [ 0, 1, 2 ],
"blockstate_example": {
"Name": "minecraft:dark_oak_log",
"Properties": {
"axis": "y"
}
}
}
为支持此数据结构的处理,我们的编解码器应当如下设计:
Codec<Foobar> CODEC = RecordCodecBuilder.create(
instance -> instance.group(
Codec.BOOL.fieldOf("foo").forGetter((Foobar o) -> o.foo), // Basic boolean Codec
// Codec for building a list
Codec.INT.listOf().fieldOf("bar").forGetter((Foobar o) -> o.bar),
// Example usage of using a different class's Codec
BlockState.CODEC.fieldOf("blockstate_example").forGetter((Foobar o) -> o.blockState)
).apply(instance, (fooC, barC, blockStateC) -> new Foobar(fooC, barC, blockStateC))
);
这里已经有一些需要展开讲的内容了。同时,它用 lambda 表达式取代了简化的方法引用,这需要大家对这个过程有更深入的了解。
Codec.BOOL.fieldOf("foo").forGetter((Foobar o) -> o.foo) // Basic boolean Codec
一个编解码器是由编解码器们组成的,就像一个 JSON 对象是由 JSON 对象们组成的,无论它是整数、字符串还是另一个 JSON 对象。
第一行代码中的 instance.group
定义了一个编解码器原型 Codec.BOOL.fieldOf("foo").forGetter((Foobar o) -> o.foo)
。
fieldOf
规定了数据对的键,而 forGetter
接受一个 Function<Foobar, Boolean>
作为实参,提取 Foobar.foo
的值以进行序列化。
这行代码本身就是一个编解码器。不仅如此,这一行还为我们初始化器中的第一个参数设置了 Boolean
类型。这可真够古怪的!
// Codec for building a list
Codec.INT.listOf().fieldOf("bar").forGetter((Foobar o) -> o.bar),
为了处理整数组成的数组,我们使用与前述相同的处理流程,但我们在方法链中调用 .listOf()
,从而将我们的编解码器封装在一个列表编解码器中。
其余的部分使用 fieldOf
和接受 Function<Foobar, List<Int>>
的 forGetter
简单处理。
这个编解码器的存在也设定了该对象的初始化器的第二个参数的数据类型为 List<Int>
。
BlockState.CODEC.fieldOf("blockstate_example").forGetter((Foobar o) -> o.blockState)
该结构的 BlockState
部分更清晰地展示了“编解码器是由编解码器组成的”。
事实上,我们没有做任何比以前更花哨的事情,你只需要引用 net.minecraft.block.BlockState.CODEC
然后用 fieldOf
分配结构的键、用包含 Function<Foobar, BlockState>
的 forGetter
作为获取值的 getter 即可。
.apply(instance, (fooC, barC, blockStateC) -> new Foobar(fooC, barC, blockStateC))
我们向 .apply()
传入我们的类初始化器。
因为它有 3 个参数,需要 3 个值,所以我们的初始化 lambda 表达式可以简单地写成 (fooC, barC, blockStateC) -> new Foobar(fooC, barC, blockStateC)
。
但是,所有的这些参数顺序都相同,我们可以不使用 lambda 表达式,而传入一个方法引用 Foobar::new
。
.apply(instance, Foobar::new)
实际上就这样就行了!它不像 ABC 那么简单,但是写起来和 ABC 一样容易。
未完待续
编解码器最基本的功能,是表征一个对象的规范。
它的泛型设置了数据类型,从而可以将对象编码(序列化)为数据,或将数据解码(反序列化)成对象。
如同你刚才在上文 [速成](#Quick Crash Course with Codecs 编解码器速成) 部分所见的一样,在编解码器中调用 .fieldOf
会给它分配一个字段,从而它现在变为了一个 MapCodec
键值对,在这里键是字段自身,而值是这个编解码器。
相对直接地,我们在方法链中,除了 .forGetter
的后面以外的任何地方,加入 .orElse
。
Codec.BOOL.orElse(false).fieldOf("foo").forGetter((Foobar o) -> o.foo)
如果你想使用一个 supplier 对象,那么你可以使用 .orElseGet
。
如果你希望反序列化过程中,日志为每个丢失的条目记录一条报错,你可以放入一个 Consumer<String>
或 UnaryOperator<String>
。
注意,你的 IDE 有可能因为模糊的方法调用而报错,所以你可能不得不对你的 lambda 或方法引用作强制类型转换。
BlockState.CODEC.fieldOf("blockstate_example").orElseGet((Consumer<String>) System.out::println, Blocks.ACACIA_WOOD::getDefaultState).forGetter(o -> o.blockState)
使用 optionalFieldOf
代替 fieldOf
可以将你的编解码器的数据类型设为 Optional,从而你的 BlockState
也会变成 Optional<BlockState>
。
如果你实际上只是想要设置一个默认值,上文提到的 orElse
能应对大多数的使用场景。
但是,任何时候,只要你想要一个实际上不存在的对象,将编解码器设为 Optional 就会是最好的写法。
未完待续
这实际上是对本文早期版本的一次大改,但这里面仍然有一些我想谈一谈的,值得讨论的要点。
由于编解码器正是用来同时实现数据序列化/反序列化的,我们的主要想法,就是利用它实现 IntStream
into int[]
的相互转换。
这种手段让我们可以把编解码器预处理成我们更需要的另一种数据类型。
Codec.INT_STREAM.xmap( // IntStream 编码成数组
IntStream::toArray, // 反序列化
Arrays::stream // 序列化
).fieldOf("bar").forGetter((Foobar o) -> o.bar)
如果你希望对类型映射作更精确的控制,可以借助比 Optional 对象更复杂的 DataResult
:它们要么是一个对象封装的成功结果,要么会在顶部放置一条错误信息。映射存在于两个方向,其中,comapping 将底层的编解码器对象转为新类型的对象,而 mapping 将新类型变成底层的编解码器对象。
有一些可供替代 Codec#xmap
(comap/map) 的方法:
Codec#comapFlatMap
(comap/flat map): 改为反序列化成DataResult
。Codec#flatComapMap
(flat comap/map): 改为序列化成DataResult
。Codec#flatXmap
(flat comap/flat map) 双向的 flatmapping,结果为DataResult
。
如果你希望对可能出现的异常有更精细的控制,特别是涉及反序列化的场景,例如从一个字符串中解析一个 Instant
时,或者从字符串格式的 NBT 中反序列化出一个物品时,都很适合使用这个手段。
Codec<Instant> FORMATTED_INSTANT = Codec.STRING.comapFlatMap(this::parseInstant, DateTimeFormatter.ISO_INSTANT::format);
DataResult<Instant> parseInstant(String instantString) {
try {
return DataResult.success(Instant.from(DateTimeFormatter.ISO_INSTANT.parse(instantString)));
} catch (DateTimeParseException e) {
return DataResult.error(e.getMessage());
}
}
你可以将两个由数据结构组成、元素不重复的集合,用 .and
结合起来。如果你在编写世界生成,并且你想要在结构的顶部加入一些额外的方法,例如向 AbstractTrunkPlacer
的结构添加额外方法,你只需要调用 AbstractTrunkPlacer
的编解码器构造器,向它传入它的实例 Instance
。你可以选择将它和另一个编解码器在 .and()
中合并起来,也可以创建一个新的实例组作为 .and()
的参数。(func_236915_a_
在 Yarn 中的混淆名是 method_28904
)
RecordCodecBuilder.create((instance) ->
AbstractTrunkPlacer.func_236915_a_(instance).and(instance.group(
Codec.BOOL.fieldOf("foo").forGetter((Foobar o) -> o.foo),
BlockState.CODEC.fieldOf("blockstate_example").forGetter((Foobar o) -> o.blockState)
).apply(instance, ImaginaryConstructor::new)
我们的初始化器函数的输出总共包含 5 个键值对,包括从 AbstractTrunkPlacer.func_236915_a_
映射而来的 base_height
height_rand_a
height_rand_b
三个整数,以及我们的布尔值 foo
和 BlockState blockstate_example
。它可以方法引用一个具有如下方法签名的构造函数:ImaginaryConstructor(int, int, int, boolean, BlockState)
。
PairMapCodec
是一种有趣的编解码器,它与其他的编解码器不同的是,它作为一个单独的 MapCodec
,实际上却代表着一次性编码两个数据。我个人认为它的应用场景是,表征那些可以仅用两个数据值所代表,而无需创建一个新的对象元素的对象。
例如,你想存储一个 UUID。它是一个 128 位的对象,你想用两个 long
而不是 String
来存储他,你可以简单地写成:
Codec.mapPair(Codec.LONG.fieldOf("most_sig_bits"), Codec.LONG.fieldOf("least_sig_bits")).xmap(
pair -> new UUID(pair.getFirst(), pair.getSecond()),
uuid -> new Pair<>(uuid.getMostSignificantBits(), uuid.getLeastSignificantBits())
);
如果我们用下面的代码来测试上面的编解码器:
JsonOps.INSTANCE
.<UUID>withEncoder(RecordCodecBuilder.create(inst -> inst
.group(this.UUID_MAPPED.forGetter(o -> o))
.apply(inst, u -> u)
)) // Function<UUID, DataResult<JsonElement>>
.apply(UUID.randomUUID()) // DataResult<JsonElement>
.result() // Optional<JsonElement>
.map(JsonElement::toString) // Optional<String>
.ifPresent(System.out::println);
则控制台中的随机 UUID 输出为:
{"least_sig_bits":-7906069608818915914,"most_sig_bits":-3340978081725788219}
如果你有一个映射表,并且想要对它进行序列化。我们可以不把它序列化成一个包含两个值的持有人对象数组,而是把它序列化成一个 Codec.compoundList
。它会将这两个值正确地存储为键值对的形式。
下面是用常规的列表存储的形式,元素是把键和值都存储起来的对象:
[{
"k": <key1>,
"v": <value1>
}, {
"k": <key2>,
"v": <value2>
}]
下面是使用复合列表,直接将键值对按字面存储的形式:
{
<key1>: <value1>,
<key2>: <value2>
}
例如,假设我们要存储一张 UUID 的列表,使用包括字符串数组在内的数据结构。不幸的是,常规的 Java 编程,特别是 Java 的 Stream 中,这样非常容易写出糟糕的代码。
所以在这里,我们实际上必须首先创建一个可选的 long
编解码器。因为键进行编解码的编解码器必须最终编码出一个 String
,我们构造如下的 LONG_S
编解码器:
Codec<Long> LONG_S = Codec.STRING.comapFlatMap(string -> {
try {
return DataResult.success(Long.parseLong(string));
} catch (NumberFormatException e) {
return DataResult.error(e.getMessage());
}
}, String::valueOf);
利用这个编解码器,我们可以这样写:
Codec<List<UUID>> UUID_MULTIMAPPED = Codec.compoundList(this.LONG_S, Codec.LONG.listOf()).xmap(
pairList -> pairList
.stream()
// 幸运的是,将这个多表结构扁平化非常简单
.flatMap(pair -> pair
.getSecond()
.stream()
.map(leastSigBits -> new UUID(pair.getFirst(), leastSigBits))
)
.collect(ImmutableList.toImmutableList()),
uuidList -> uuidList
.stream()
// ... 然而不幸的是,构建多表结构就完全不简单了。只好写一段不好看的代码
.<HashMap<Long, List<Long>>>collect(
HashMap::new,
(multiMap, uuid) -> multiMap.compute(uuid.getMostSignificantBits(), (aLong, longList) -> {
(longList == null ? (longList = new LongArrayList()) : longList).add(aLong);
return longList;
}),
(multiMap, multiMap2) -> multiMap2.forEach((keyFrom2, listToDump) -> {
// 你通常不需要这个结合器函数,除非你在写并行流。不过我还是写给你看一下
multiMap.compute(keyFrom2, (v, listReceiving) -> {
(listReceiving == null ? (listReceiving = new LongArrayList()) : listReceiving).addAll(listToDump);
return listReceiving;
});
})
)
.entrySet()
.stream() // And here we go again
.map(e -> Pair.of(e.getKey(), e.getValue()))
.collect(ImmutableList.toImmutableList())
);
呀。我很抱歉我写了这种东西!
实际上,我最初写这个代码例子时,使用的是 [ProjectReactor](https://projectreactor.io/docs/core/release/reference/)。因为我写这个太舒服了,不得不写了上面看到的可怕的代码。这里是使用 ProjectReactor 的原始代码例子。
Codec<Long> LONG_S = Codec.STRING.comapFlatMap(string -> {
try {
return DataResult.success(Long.parseLong(string));
} catch (NumberFormatException e) {
return DataResult.error(e.getMessage());
}
}, String::valueOf);
Codec<List<UUID>> UUID_MULTIMAPPED = Codec.compoundList(this.LONG_S, Codec.LONG.listOf()).xmap(
pairList -> Flux.fromIterable(pairList)
.flatMap(pair -> Flux.fromIterable(pair.getSecond())
.map(leastSigBits -> new UUID(pair.getFirst(), leastSigBits))
)
.collect(ImmutableList.toImmutableList())
.block(),
uuidList -> Flux.fromIterable(uuidList)
.groupBy(UUID::getMostSignificantBits, UUID::getLeastSignificantBits)
.flatMap(bitFlux -> bitFlux.collectList()
.map(leastSigBits -> Pair.of(bitFlux.key(), leastSigBits))
)
.collect(ImmutableList.toImmutableList())
.block()
);
可读性明显要比第一段示例代码高得多了。
这里还有一个利用 Guava 的多表结构的实现。
Codec<List<UUID>> UUID_MULTIMAPPED = Codec.compoundList(this.LONG_S, Codec.LONG.listOf()).xmap(
pairList -> pairList
.stream()
// 幸运的是,将这个多表结构扁平化非常简单
.flatMap(pair -> pair
.getSecond()
.stream()
.map(leastSigBits -> new UUID(pair.getFirst(), leastSigBits))
)
.collect(ImmutableList.toImmutableList()),
uuidList -> uuidList
.stream()
.collect(Multimaps.toMultimap(UUID::getMostSignificantBits, UUID::getLeastSignificantBits, HashMultimap::create))
.asMap()
.entrySet()
.stream() // 然后这里我们再来一次
.<Pair<Long, List<Long>>>map(e -> Pair.of(e.getKey(), ImmutableList.copyOf(e.getValue())))
.collect(ImmutableList.toImmutableList())
);
目前为止,我们只从 JSON 的方面讨论了编解码器。DataFixerUpper 提供了 DynamicOps
接口的实现。然而,Minecraft 本身也提供了一个实现,称为 NBTDynamicOps
,能够创建一个 INBT
,而非 JsonOps
的 JsonElement
.
不幸的是,RecordCodecBuilder
只允许一个给定的编解码器具有最多 16 个字段。如果你用到了这么多字段,你可能想把代码拆分成较低级别的编解码器来写,否则可读性可能会受到影响。然而,如果你使用了 .and
,字段上限会进一步降低到 8。如果的确产生了这样的需求,你可以把自定义接口结合起来使用以满足需要。