流编解码器(Stream Codecs)
流编解码器(Stream codecs)是一种序列化工具,用于描述对象如何在流(如缓冲区)中存储和读取。流编解码器主要被原版 Vanilla 的 网络系统 用于同步数据。
由于流编解码器(stream codecs)与 编解码器(codecs) 的作用大致相似,本页采用了相同的格式来展示它们之间的相似性。
使用流编解码器(Using Stream Codecs)
流编解码器通过 StreamCodec#encode 和 StreamCodec#decode 方法,将对象编码或解码到某个流中。encode 方法接收流对象和待编码的对象,并将对象编码进流中。decode 方法接收流对象,并返回解码后的对象。通常,这里的流对象是 ByteBuf、FriendlyByteBuf 或 RegistryFriendlyByteBuf。
// 假设 exampleStreamCodec 表示一个 StreamCodec<ExampleJavaObject>
// 假设 exampleObject 是一个 ExampleJavaObject
// 假设 buffer 是一个 RegistryFriendlyByteBuf
// 将 Java 对象编码进缓冲区流
exampleStreamCodec.encode(buffer, exampleObject);
// 从缓冲区流中读取 Java 对象
ExampleJavaObject obj = exampleStreamCodec.decode(buffer);
除非你需要手动处理缓冲区对象,一般情况下不会直接调用 encode 和 decode 方法。
已有的流编解码器(Existing Stream Codecs)
ByteBufCodecs
ByteBufCodecs 包含了一些针对特定基本类型和对象的静态编解码器实例。
| 流编解码器(Stream Codec) | Java 类型(Java Type) |
|---|---|
BOOL | Boolean |
BYTE | Byte |
SHORT | Short |
INT | Integer |
LONG | Long |
FLOAT | Float |
DOUBLE | Double |
BYTE_ARRAY | byte[]* |
STRING_UTF8 | String** |
TAG | Tag |
COMPOUND_TAG | CompoundTag |
VECTOR3F | Vector3f |
QUATERNIONF | Quaternionf |
GAME_PROFILE | GameProfile |
* byte[] 可以通过 ByteBufCodecs#byteArray 限定元素数量。
** String 可以通过 ByteBufCodecs#stringUtf8 限定字符数量。
此外,还有一些静态实例,采用不同方式对基本类型和对象进行编码和解码。
无符号短整型(Unsigned Shorts)
UNSIGNED_SHORT 是 SHORT 的一种变体,用于将数值视为无符号数。由于 Java 中的数字默认带符号,无符号短整型会作为 Integer 发送和接收,并屏蔽掉高两位字节。
可变长度数值(Variable-Sized Number)
VAR_INT 和 VAR_LONG 是流编解码器(stream codecs),它们会将数值编码为尽可能小的体积。这一过程是通过每次编码七位(bits),并使用最高位作为是否还有更多数据的标记来实现的。对于整数来说,数值在 0 到 2^28-1 之间,或者长整型在 0 到 2^56-1 之间时,所发送的数据长度会比普通的整数或长整型更短或相等。如果你的数值通常处于这个范围,且大多偏小,那么建议使用这些变长流编解码器。
VAR_INT 和 VAR_LONG 分别是 INT 和 LONG 的替代方案。
可信标签(Trusted Tags)
TRUSTED_TAG 和 TRUSTED_COMPOUND_TAG 分别是 TAG 和 COMPOUND_TAG 的变体,它们在解码标签时拥有无限制的堆内存,而 TAG 和 COMPOUND_TAG 的堆内存限制为 2MiB。可信标签流编解码器(trusted tag stream codecs)理想情况下只应在发往客户端的数据包中使用,比如 Vanilla 在 方块实体数据包 和 实体数据序列化 时的做法。
如果你需要使用不同的内存限制,可以通过 ByteBufCodecs#tagCodec 或 #compoundTagCodec,为其提供一个带有指定大小的 NbtAccounter。
Vanilla 与 NeoForge
Minecraft 和 NeoForge 为经常需要编解码的对象定义了许多流编解码器。例如,ResourceLocation#STREAM_CODEC 用于 ResourceLocation,而 NeoForgeStreamCodecs#CHUNK_POS 用于 ChunkPos。
大多数流编解码器可以在对象类本身,或者 StreamCodec、ByteBufCodecs、NeoForgeStreamCodecs 这几个类中找到。
创建流编解码器(Creating Stream Codecs)
流编解码器可以用于将任意对象读写到流中。本篇文档将重点介绍以缓冲区(buffer)为主的流操作,因为这是它们的主要用途。
流编解码器有两个泛型参数:B 代表缓冲区类型,V 代表对象值。B 通常是以下三种类型之一:ByteBuf、FriendlyByteBuf、RegistryFriendlyByteBuf,它们之间是继承关系。FriendlyByteBuf 增加了 Minecraft 特有的读写方法,而 RegistryFriendlyByteBuf 则提供了对注册表(registries)及其对象的访问能力。
在构造流编解码器时,B 应选择最不具体的缓冲区类型。例如,ResourceLocation 会作为字符串(string)发送。由于字符串是由普通的 ByteBuf 支持的,因此其类型应为 StreamCodec<ByteBuf, ResourceLocation>。FriendlyByteBuf 包含写入 ChunkPos 的方法,所以应为 StreamCodec<FriendlyByteBuf, ChunkPos>。Item 需要访问注册表,因此应为 StreamCodec<RegistryFriendlyByteBuf, Item>。
大多数接受流编解码器的方法,对于缓冲区类型会要求 ? super B,这意味着上述三个示例在缓冲区类型为 RegistryFriendlyByteBuf 时都可以使用。
成员编码器(Member Encoders)
StreamMemberEncoder 是 StreamEncoder 的一种替代方式,其参数顺序为:编码对象在前,缓冲区在后。通常在编码对象自身包含用于写入缓冲区的实例方法时使用。可以通过调用 StreamCodec#ofMember 使用 StreamMemberEncoder 创建 StreamCodec。
// 需要为其创建 stream codec 的某个对象
public class ExampleObject {
// 普通构造方法
public ExampleObject(String arg1, int arg2, boolean arg3) { /* ... */ }
// 用于解码的构造方法
public ExampleObject(ByteBuf buffer) { /* ... */ }
// 用于编码的实例方法
public void encode(ByteBuf buffer) { /* ... */ }
}
// Stream codec 的创建方式如下
public static StreamCodec<ByteBuf, ExampleObject> =
StreamCodec.ofMember(ExampleObject::encode, ExampleObject::new);
复合类型(Composites)
Stream codec 可以通过 StreamCodec#composite 读写对象。每个复合(composite) stream codec 定义了一组 stream codec 和 getter,并按照提供的顺序进行读写。composite 方法有多个重载版本,最多支持八个参数。
在 composite 方法中,每两个参数表示一组:第一个是用于读写字段的 stream codec,第二个是从对象中获取要编码字段的 getter。最后一个参数是一个函数,用于解码时创建对象的新实例。
// 需要为其创建 stream codec 的对象
public record SimpleExample(String arg1, int arg2, boolean arg3) {}
public record RegistryExample(double arg1, Holder<Item> arg2) {}
// 相关的 stream codec
public static final StreamCodec<ByteBuf, SimpleExample> SIMPLE_STREAM_CODEC =
StreamCodec.composite(
// stream codec 和 getter 配对
ByteBufCodecs.STRING_UTF8, SimpleExample::arg1,
ByteBufCodecs.VAR_INT, SimpleExample::arg2,
ByteBufCodecs.BOOL, SimpleExample::arg3,
SimpleExample::new
);
// 由于包含 holder,这里使用 RegistryFriendlyByteBuf
public static final StreamCodec<RegistryFriendlyByteBuf, RegistryExample> REGISTRY_STREAM_CODEC =
StreamCodec.composite(
// 注意,这里可以使用 ByteBuf 的 stream codec
ByteBufCodecs.DOUBLE, RegistryExample::arg1,
ByteBufCodecs.holderRegistry(Registries.ITEM), RegistryExample::arg2,
RegistryExample::new
);
转换器(Transformers)
Stream codec 可以通过映射(mapping)方法,转换为等价或部分等价的其他表示形式。有两种映射方法用于值(value),一种映射方法用于缓冲区(buffer)。
map 方法通过两个函数对值进行转换:一个函数用于将当前类型转换为新类型,另一个函数用于将新类型转换回当前类型。这与 codec transformers 的用法类似。
public static final StreamCodec<ByteBuf, ResourceLocation> STREAM_CODEC =
ByteBufCodecs.STRING_UTF8.map(
// String -> ResourceLocation
ResourceLocation::new,
// ResourceLocation -> String
ResourceLocation::toString
);
apply 方法会使用 StreamCodec.CodecOperation 对值进行转换。StreamCodec.CodecOperation 接收当前类型的流编解码器(stream codec ),并返回新类型的流编解码器。这类操作通常包装在 map 方法中,或者需要传入辅助方法。
public static final StreamCodec<ByteBuf, List<ResourceLocation>> STREAM_CODEC =
ResourceLocation.STREAM_CODEC.apply(ByteBufCodecs.list());
mapStream 方法允许通过一个函数来转换缓冲区类型,这个函数接收新的缓冲区类型并返回当前的缓冲区类型。通常情况下很少需要用到这个方法,因为大多数流编解码器(stream codec)的方法并不需要改变缓冲区类型。
public static final StreamCodec<RegistryFriendlyByteBuf, Integer> STREAM_CODEC =
ByteBufCodecs.VAR_INT.mapStream(buffer -> (ByteBuf) buffer);
单元(Unit)
一种在代码中提供固定值但无需编码任何内容的流编解码器(stream codec),可以用 StreamCodec#unit 表示。如果你不希望在网络上传输任何信息时,这会非常有用。
Unit 类型的流编解码器要求任何被编码的对象都必须与指定的 unit 对象相等,否则会抛出错误。因此,所有对象都必须实现 equals 方法,并且对 unit 对象返回 true,或者确保传递给流编解码器的实例始终是该 unit 对象。
public static final StreamCodec<ByteBuf, Item> UNIT_STREAM_CODEC =
StreamCodec.unit(Items.AIR);
延迟初始化(Lazy Initialized)
有时,流编解码器(stream codec)可能依赖于构造时尚未存在的数据。在这种情况下,可以使用 NeoForgeStreamCodecs#lazy,让流编解码器在第一次读/写时再进行初始化。该方法接收一个流编解码器的供应器(supplier)。
public static final StreamCodec<ByteBuf, Item> LAZY_STREAM_CODEC =
NeoForgeStreamCodecs.lazy(
() -> StreamCodec.unit(Items.AIR)
);
集合(Collections)
可以通过对象流编解码器(object stream codec)使用 collection 方法来生成集合的流编解码器。collection 方法需要传入一个用于构造空集合的 IntFunction,对象的流编解码器,以及一个可选的最大容量。
public static final StreamCodec<ByteBuf, Set<BlockPos>> COLLECTION_STREAM_CODEC =
ByteBufCodecs.collection(
HashSet::new, // 构造一个指定容量的集合
BlockPos.STREAM_CODEC,
256 // 集合最多只能包含 256 个元素
);
collection 方法的另一个重载可以与 StreamCodec#apply 一起使用。
public static final StreamCodec<ByteBuf, Set<BlockPos>> COLLECTION_STREAM_CODEC =
BlockPos.STREAM_CODEC.apply(
ByteBufCodecs.collection(HashSet::new)
);
基于 List 的集合也可以通过调用 ByteBufCodecs#list 并指定可选的最大长度,结合 StreamCodec#apply 来实现。
public static final StreamCodec<ByteBuf, List<BlockPos>> LIST_STREAM_CODEC =
BlockPos.STREAM_CODEC.apply(
// 该列表最多只能包含 256 个元素
ByteBufCodecs.list(256)
);
映射(Map)
你可以通过 ByteBufCodecs#map,结合两个流编解码器(stream codec),为键和值对象生成映射(map)的流编解码器。该函数还接收一个用于构造空映射的 IntFunction,以及可选的最大长度。
public static final StreamCodec<ByteBuf, Map<String, BlockPos>> MAP_STREAM_CODEC =
ByteBufCodecs.map(
HashMap::new, // 按指定容量构造一个映射
ByteBufCodecs.STRING_UTF8,
BlockPos.STREAM_CODEC,
256 // 该映射最多只能包含 256 个元素
);
Either
对于需要以两种不同方式读写某些对象数据的场景,可以通过 ByteBufCodecs#either 结合两个流编解码器生成一个 Either 类型的流编解码器。该方法会先读写一个布尔值,以指示接下来是使用第一个还是第二个流编解码器。
public static final StreamCodec<ByteBuf, Either<Integer, String>> EITHER_STREAM_CODEC =
ByteBufCodecs.either(
ByteBufCodecs.VAR_INT,
ByteBufCodecs.STRING_UTF8
);
Id 映射器(Id Mapper)
在大多数情况下,当需要在网络中同步某个对象的存在时,通常会传递一个整数 id 来代表该对象。使用 id 可以减少需要在网络中同步的数据量。枚举(enum)和注册表(registry)都采用了这种方式。
ByteBufCodecs#idMapper 提供了一种便捷方式来为对象发送 id。你可以传入两个函数(一个将对象转为 int,另一个将 int 转回对象),或者直接传入一个 IdMap。
// 针对某个枚举
public enum ExampleIdObject {
;
// 获取 Id -> 枚举
public static final IntFunction<ExampleIdObject> BY_ID =
ByIdMap.continuous(
ExampleIdObject::getId,
ExampleIdObject.values(),
ByIdMap.OutOfBoundsStrategy.ZERO
);
ExampleIdObject(int id) { /* ... */ }
}
// 其流编解码器如下
public static final StreamCodec<ByteBuf, ExampleIdObject> ID_STREAM_CODEC =
ByteBufCodecs.idMapper(ExampleIdObject.BY_ID, ExampleIdObject::getId);
可选值(Optional)
如果你需要为 Optional 包装的值生成流编解码器,可以将目标流编解码器传递给 ByteBufCodecs#optional。该方法会先读写一个布尔值,指示是否读写该对象。
public static final StreamCodec<RegistryFriendlyByteBuf, Optional<DataComponentType<?>>> OPTIONAL_STREAM_CODEC =
DataComponentType.STREAM_CODEC.apply(ByteBufCodecs::optional);
注册表对象(Registry Objects)
注册表对象(Registry object)可以通过三种方式在网络上传输:registry、holderRegistry 或 holder。每种方法都需要传入一个表示该注册表对象所在注册表的 ResourceKey。
自定义注册表(Custom registries)必须通过调用 RegistryBuilder#sync 并将其值设为 true 以支持同步。否则,编码器将抛出异常。
registry 和 holderRegistry 分别返回注册表对象本身,或包装在 holder 中的注册表对象。这些方法 会发送一个用于表示注册表对象的 id。
// 注册表对象
public static final StreamCodec<RegistryFriendlyByteBuf, Item> VALUE_STREAM_CODEC =
ByteBufCodecs.registry(Registries.ITEM);
// 注册表对象的 holder
public static final StreamCodec<RegistryFriendlyByteBuf, Holder<Item>> HOLDER_STREAM_CODEC =
ByteBufCodecs.holderRegistry(Registries.ITEM);
holder 方法返回包装在 holder 中的注册表对象。该方法会发送一个用于表示注册表对象的 id,如果所提供的 Holder 是直接引用,则会直接发送注册表对象本身。为此,holder 还需要传入注册表对象的流编解码器(stream codec)。
public static final StreamCodec<RegistryFriendlyByteBuf, Holder<SoundEvent>> STREAM_CODEC =
ByteBufCodecs.holder(
Registries.SOUND_EVENT, SoundEvent.DIRECT_STREAM_CODEC
);
holder 只有在 holder 不是直接引用时,才会因为自定义注册表未同步而抛出异常。
Holder 集合(Holder Sets)
标签(Tags)或 holder 包装的注册表对象集合可以通过 holderSet 进行发送。该方法同样需要传入一个表示注册表对象所在注册表的 ResourceKey。
public static final StreamCodec<RegistryFriendlyByteBuf, HolderSet<Item>> HOLDER_SET_STREAM_CODEC =
ByteBufCodecs.holderSet(Registries.ITEM);
递归(Recursive)
有时,一个对象的字段可能会引用同类型的对象。例如,MobEffectInstance 的构造器可以接受一个可选的 MobEffectInstance,用于隐藏效果。在这种情况下,可以使用 StreamCodec#recursive,通过函数方式将流编解码器作为参数传递,从而创建递归结构的流编解码器。
// 定义递归对象
public record RecursiveObject(Optional<RecursiveObject> inner) { /* ... */ }
public static final StreamCodec<ByteBuf, RecursiveObject> RECURSIVE_CODEC = StreamCodec.recursive(
recursedStreamCodec -> StreamCodec.composite(
recursedStreamCodec.apply(ByteBufCodecs::optional),
RecursiveObject::inner,
RecursiveObject::new
)
);
分派(Dispatch)
流编解码器(Stream codec)可以通过子流编解码器(sub-stream codec)对特定类型的对象进行解码,这通常通过 StreamCodec#dispatch 实现。该方法常用于表示类型的注册表对象,例如 ParticleOptions 中的 ParticleType,或 Stat 中的 StatType。
分发流编解码器(dispatch stream codec)首先尝试读写类型对象。之后,当前对象会通过方法中提供的函数之一进行读写。第一个 Function 接收当前对象,并获取用于写入该值的类型。第二个 Function 接收类型对象,并获取用于读取该值的当前对象的 StreamCodec。
// 定义我们的对象
public abstract class ExampleObject {
// 定义用于指定编码时对象 类型的方法
public abstract StreamCodec<? super RegistryFriendlyByteBuf, ? extends ExampleObject> streamCodec();
}
// 假设存在一个 ResourceKey<StreamCodec<? super RegistryFriendlyByteBuf, ? extends ExampleObject>> DISPATCH
public static final StreamCodec<RegistryFriendlyByteBuf, ExampleObject> DISPATCH_STREAM_CODEC =
ByteBufCodecs.registry(DISPATCH).dispatch(
// 从具体对象获取流编解码器
ExampleObject::streamCodec,
// 从注册表对象获取流编解码器
Function.identity()
);