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

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

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

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

// Encoder,也就是序列化
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 和其他一系列方法进行反序列化:

DataResult<Integer> result = Codec.INT.parse(JsonOps.INSTANCE, new JsonPrimitive(42));
assert result.result().get() == 42;

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

数据组合和变换

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

Codec Java 类型
BOOL Boolean
BYTE Byte
SHORT Short
INT Integer
LONG Long
FLOAT Float
DOUBLE Double
STRING String
BYTE_BUFFER ByteBuffer(byte[])
INT_STREAM IntStream(int[])
LONG_STREAM LongStream(long[])

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

Record

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

public record User(String id, double balance) {
    static Codec<User> CODEC = RecordCodecBuilder.create(it -> it.group(
        Codec.STRING.fieldOf("id").forGetter(User::id),
        Codec.DOUBLE.fieldOf("balance").forGetter(User::balance)
    ).apply(it, User::new));
}

如上,创建一个 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>>

Codec<List<User>> usersCodec = User.CODEC.listOf();

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

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 变换:

public record Bank(List<User> users) {
    static Codec<Bank> CODEC = User.CODEC.listOf().xmap(Bank::new, Bank::users);
}

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

Codec<UUID> UUID_CODEC = Codec.STRING.comapFlatMap(it -> {
    try {
        return DataResult.success(UUID.fromString(it));
    } catch (IllegalArgumentException e) {
        return DataResult.error(it + " is not UUID");
    }
}, UUID::toString);

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

[
  {
    "user": "zzzz",
    "balance": 42.0
  }
]

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

{
  "users": [
    {
      "user": "zzzz",
      "balance": 42.0
    }
  ]
}

又比如,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 方法,类似:

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

public class Stream<A> {
    public <B> Stream<B> flatMap(Function<A, Stream<B>> f);
}

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

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

static <A> Stream<A> flatten(Stream<Stream<A>> stream) {
    return stream.flatMap(x -> x);
}

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

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

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

class Optional<A> implements FlatMap<A> { /* ... */ }
class Stream<A> implements FlatMap<A> { /* ... */ }

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

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

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

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

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

class Optional<A> implements FlatMap<Optional<?>, A> { /* ... */ }
class Stream<A> implements FlatMap<Stream<?>, A> { /* ... */ }

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

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

Optional<Optional<String>> optional = /* ... */;
FlatMap<Optional<?>, String> flatten = FlatMap.flatten(optional);

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

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

Optional<String> flatten = unbox(FlatMap.flatten(optional));

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

class Optional<A> implements FlatMap<Optional.Mu, A> {
    static class Mu {}
    static <A> Optional<A> unbox(FlatMap<Optional.Mu, A> f) {
        return (Optional<A>) f;
    }
}

像这样的代码,我们可以在 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 这样的类,类似:

interface FlatMap<F extends FlatMap.Mu, A> extends App<F, A> {
    interface Mu extends K1 {}
    <B> FlatMap<F, B> flatMap(Function<A, FlatMap<F, B>> f);
}

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

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

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

  def flatten[A](ffa: F[F[A]]): F[A] = flatMap(ffa)(x => x)
}

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

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