海螺螺
DataFixerUpper 序列化简介


本文也发布于我的博客 https://izzel.io/2022/10/07/dfu-serialisation/



Minecraft 在 1.13 提供了模块化数据访问和序列化的类库 DataFixerUpper,本文简单介绍其序列化部分。

DataFixerUpper 中的核心类是 Codec,结合了 EncoderDecoder 两个类的功能。它们都是无状态的不可变对象,用于变换不可变数据。

其中,Codec<A> 代表 A 的数据编码,本质上是两个方法,序列化和反序列化方法的组合。

  1. // Decoder,也就是反序列化
  2. int result = parseAsInt(readAsString(decompressBytes(/* 某种数据 */)));

  3. // Encoder,也就是序列化
  4. byte[] result = compressBytes(stringToBytes(intToString(42)));
复制代码

Codec 就是上面这些方法的抽象表示。

某种数据

DFU 中使用 DynamicOps<T> 表示数据的序列化格式,在 Minecraft 环境下有 JsonOps 和 NbtOps。DynamicOps 的参数 T 用于表示数据的基本类型,对于 NbtOps 而言为 Tag 类。

一个 DynamicOps<T> 和一个 T 的实例,或者说 new Dynamic<T>(DynamicOps<T>, T),组成了反序列化的数据来源,通过 parse 和其他一系列方法进行反序列化:

  1. DataResult<Integer> result = Codec.INT.parse(JsonOps.INSTANCE, new JsonPrimitive(42));
  2. assert result.result().get() == 42;
复制代码

反序列化得到的 DataResult<A> 是反序列化的结果或者错误,分别可以用 result()error() 获取。

数据组合和变换

DFU 编写 Codec 的方式大多是通过组合和变换已有的 Codec 进行的,绝大部分情况下不需要自行实现 EncoderDecoder 中的方法。

CodecJava 类型
BOOLBoolean
BYTEByte
SHORTShort
INTInteger
LONGLong
FLOATFloat
DOUBLEDouble
STRINGString
BYTE_BUFFERByteBuffer(byte[])
INT_STREAMIntStream(int[])
LONG_STREAMLongStream(long[])

除去以上的基本类型数据,如果想自定义更复杂的数据类型就需要组合了。

Record

Record,和 Java 16 正式加入的 Records 对应,表示一个不可变的 Java 对象,以键值对编码。

  1. public record User(String id, double balance) {
  2.     static Codec<User> CODEC = RecordCodecBuilder.create(it -> it.group(
  3.         Codec.STRING.fieldOf("id").forGetter(User::id),
  4.         Codec.DOUBLE.fieldOf("balance").forGetter(User::balance)
  5.     ).apply(it, User::new));
  6. }
复制代码

如上,创建一个 Record Codec 通常通过 RecordCodecBuilder.create 进行,通过向 Instance 实例(那个 it)提供数个对应字段的 Codec 和构造方法引用,创建对应 Codec。

为了方便,通常按照构造方法参数的顺序提供字段 Codec,这样最后调用 apply 传入的构造方法就可以直接写成方法引用的形式。

fieldOf 以外,还可以用 optionalFieldOf 提供一个 Optional<A> 作为参数,或者通过 optionalFieldOf 的第二个参数提供默认值。

其他的数据类型

其他的复杂数据类型,其实也就是 List<A>Map<A, B> 了。

通过调用 listOf 就可以获得 Codec<A> 的列表形式 Codec<List<A>>

  1. Codec<List<User>> usersCodec = User.CODEC.listOf();
复制代码

通过调用 Codec.unboundedMap(Codec<A>, Codec<B>) 就可以获得一个基础的 Codec<Map<A, B>>

  1. Codec<Map<Integer, User>> usersCodec = Codec.unboundedMap(Codec.STRING, User.CODEC);
复制代码

DFU 还内置了 Either<A, B> 用于表示 A 或 B,Pair<A, B> 用于表示 A 和 B,可用 Codec 类下同名静态方法创建。

DFU 中表示“空”的值是 Unit,可以用 Codec.EMPTY.codec() 获得其 Codec。

Codec 中有一类方法 Codec.dispatch 可以根据不同键名对值提供不同 Codec,Minecraft 中类似 ParticleType 中根据不同注册 ID 提供不同 Codec 即是这样实现的。

Codec 变换

另一种创建数据 Codec 的方法是对已有的 Codec 进行变换。

对于两边互相兼容的类型,可以用 xmap 变换:

  1. public record Bank(List<User> users) {
  2.     static Codec<Bank> CODEC = User.CODEC.listOf().xmap(Bank::new, Bank::users);
  3. }
复制代码

对于可能不兼容的类型,可以用 comapFlatMap 或者 flatXmap 进行变换:

  1. Codec<UUID> UUID_CODEC = Codec.STRING.comapFlatMap(it -> {
  2.     try {
  3.         return DataResult.success(UUID.fromString(it));
  4.     } catch (IllegalArgumentException e) {
  5.         return DataResult.error(it + " is not UUID");
  6.     }
  7. }, UUID::toString);
复制代码

虽然经过组合和变换后的 Codec<A> 类型是一样的,但是不同的组合和变换方式会导致序列化后的结果不同。例如,上文 Bank 的 JSON 序列化形式可能是:

  1. [
  2.   {
  3.     "user": "zzzz",
  4.     "balance": 42.0
  5.   }
  6. ]
复制代码

如果按照 RecordCodecBuilder 来创建 Bank Codec 的话,就可能是这样的:

  1. {
  2.   "users": [
  3.     {
  4.       "user": "zzzz",
  5.       "balance": 42.0
  6.     }
  7.   ]
  8. }
复制代码

又比如,Minecraft 提供在 SerializableUUID.CODEC 的 UUID Codec 是用长度为 4 的 int 数组存储,而上文的则是字符串。

可以参照 https://docs.minecraftforge.net/en/1.19.x/datastorage/codecs/ 更多内容进行代码编写。

Minecraft 给一些常见的类都提供了 Codec,位于对应类的 CODEC 字段或者 ExtraCodecs 类中。

我自己也写了一个小工具,可以生成基于基础 Java 类型的 Record 类的 Codec。例如,上文 Bank 可以直接调用 TypeCodec.of(Bank.class) 获得对应 Codec。

什么是 App

在 DFU 中随处可见 App<F, A> 的形式,但是这个类里面没有任何方法,这是什么呢?

在 Java 中,Stream<A>Optional<A> 都有 flatMap 方法,类似:

  1. public class Optional<A> {
  2.     public <B> Optional<B> flatMap(Function<A, Optional<B>> f);
  3. }

  4. public class Stream<A> {
  5.     public <B> Stream<B> flatMap(Function<A, Stream<B>> f);
  6. }
复制代码

仅有 flatMap 方法就可以轻松实现一个功能 flatten,可以将类似 Optional<Optional<A>>Stream<Stream<A>> 拉平为 Optional<A>Stream<A>,比如:

  1. static <A> Optional<A> flatten(Optional<Optional<A>> optional) {
  2.     return optional.flatMap(x -> x);
  3. }

  4. static <A> Stream<A> flatten(Stream<Stream<A>> stream) {
  5.     return stream.flatMap(x -> x);
  6. }
复制代码

自然而然,一样的代码不应该写这么多遍,所以我们会希望有一种长成这样的代码:

  1. interface FlatMap<A> {
  2.     <B> FlatMap<B> flatMap(Function<A, FlatMap<B>> f);

  3.     static <A> FlatMap<A> flatten(FlatMap<? extends FlatMap<A>> flatMap) {
  4.         return flatMap.flatMap(x -> x);
  5.     }
  6. }

  7. class Optional<A> implements FlatMap<A> { /* ... */ }
  8. class Stream<A> implements FlatMap<A> { /* ... */ }
复制代码

但是问题出现了,经过 flatten 后类型丢失了,我们实际上想要的是这种方法:

  1. static <F extends FlatMap<?>, A> F<A> flatten(F<F<A>> flatMap) {
  2.     return flatMap.flatMap(x -> x);
  3. }
复制代码

但是在 Java 中不能这样做(这当然不是什么符合标准的 Java 语法),所以我们退而求其次,把这个 F 加在 FlatMap 上:

  1. interface FlatMap<F, A> {
  2.     <B> FlatMap<F, B> flatMap(Function<A, FlatMap<F, B>> f);

  3.     static <F, A> FlatMap<F, A> flatten(FlatMap<F, ? extends FlatMap<F, A>> flatMap) {
  4.         return flatMap.flatMap(x -> x);
  5.     }
  6. }

  7. class Optional<A> implements FlatMap<Optional<?>, A> { /* ... */ }
  8. class Stream<A> implements FlatMap<Stream<?>, A> { /* ... */ }
复制代码

这里 implements FlatMap<Optional<?>, ...> 使用 wildcard type 而不是 Optional<A> 是因为我们希望传入 flatten 的参数 F<F<A>> 中两个 F 的类型是一致的,而不是根据 A 的不同变化的(仔细想想这里的 F 到底表示什么)。

所以我们就能写出这样的代码:

  1. Optional<Optional<String>> optional = /* ... */;
  2. FlatMap<Optional<?>, String> flatten = FlatMap.flatten(optional);
复制代码

现在 Optional 的类型保留了,虽然返回的仍然是 FlatMap,但是只需要加上一个简单的工具方法转换一次就可以了:

  1. static <A> Optional<A> unbox(FlatMap<Optional<?>, A> f) {
  2.     return (Optional<A>) f;
  3. }

  4. Optional<String> flatten = unbox(FlatMap.flatten(optional));
复制代码

最后,因为 FlatMap<F, A> 中的 F 实际上仅在编译期存在,作为类型约束的代码提示(不是吗?),所以我们可以定义一个空的类 Optional.Mu 来代表 Optional

  1. class Optional<A> implements FlatMap<Optional.Mu, A> {
  2.     static class Mu {}
  3.     static <A> Optional<A> unbox(FlatMap<Optional.Mu, A> f) {
  4.         return (Optional<A>) f;
  5.     }
  6. }
复制代码

像这样的代码,我们可以在 DataFixerUpper 里面找到很多。

再回头看到 FlatMap 这个接口,它表示了两个意思:

  • 一个叫 F<A> 的类型
  • 实现了叫 flatMap 的方法

自然,我们可以单独有一个概念只表示 F<A>,比如 —— App<F, A>

最终揭秘:App<F, A> 用于表示高阶类型(higher kinded type)F<A>,这个接口是因为 Java 本身不支持高阶类型而出现的,作为一种标记使用。

在 DFU 中,它实际的签名是 App<F extends K1, A>,其中:

  • K1 用于代表类似 F<_> 一样的类型,或者说一个单参数的类型构造器(type constructor),比如说 List<_>
  • App<F, A> 代表用类型 A 应用在 F 这个类型构造器上,比如说 App<List, String> 代表 List<String>

在实际使用中,通常是让类似 FlatMap 的类去继承 App 这样的类,类似:

  1. interface FlatMap<F extends FlatMap.Mu, A> extends App<F, A> {
  2.     interface Mu extends K1 {}
  3.     <B> FlatMap<F, B> flatMap(Function<A, FlatMap<F, B>> f);
  4. }
复制代码

同样的,DFU 也有 App2<F extends K2, A, B> 这样的类型,代表了有两个参数的高阶类型 F<A, B>

结束之前,我们可以通过 Scala 这个支持高阶类型的语言来看看,为什么 App 这个接口是因为 Java 不支持高阶类型而存在的(代码来自 cats):

  1. trait FlatMap[F[_]] {
  2.   def flatMap[A, B](fa: F[A])(f: A => F[B]): F[B]

  3.   def flatten[A](ffa: F[F[A]]): F[A] = flatMap(ffa)(x => x)
  4. }
复制代码

本文没有介绍 RecordCodecBuilder.Instance 为何物(type class),虽然已经很接近了,读者可以阅读 cats 的 Applicative 文档进行了解。

本文也没有介绍 DFU 的 optics 包和其他几个包的内容,因为序列化部分并不涉及这部分代码,读者可以阅读 Pickering, M., Gibbons, J., & Wu, N. (2017). Profunctor Optics: Modular Data Accessors 进行了解。


来自群组: PluginsCDTribe

信徒233
66666666666666666