Minecraft 在 1.13 提供了模块化数据访问和序列化的类库 DataFixerUpper,本文简单介绍其序列化部分。
DataFixerUpper 中的核心类是 Codec,结合了 Encoder 和 Decoder 两个类的功能。它们都是无状态的不可变对象,用于变换不可变数据。
其中,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 进行的,绝大部分情况下不需要自行实现 Encoder 和 Decoder 中的方法。
| 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 进行了解。