Codec 编解码器

Codec 编解码器

Codec 是 Mojang 的 DataFixerUpper 提供的序列化工具,描述对象如何在不同格式之间转换,例如 JSON 对应 JsonElement,NBT 对应 Tag。

使用 Codec

Codec 主要用于将 Java 对象编码,即序列化为某种数据格式,并将格式化的数据对象解码,即反序列化回对应 Java 类型。通常分别通过 Codec#encodeStart 和 Codec#parse 完成。

DynamicOps

#encodeStart 和 #parse 都需要 DynamicOps 实例,指定中间数据格式以及其中数据的操作方式。

DataFixerUpper 提供 JsonOps,用于处理保存在 Gson JsonElement 中的 JSON 数据。它支持两种表示:JsonOps#INSTANCE 使用普通 JSON,JsonOps#COMPRESSED 允许将数据压缩为一个字符串。

// Let exampleCodec represent a Codec<ExampleJavaObject>
// Let exampleObject be a ExampleJavaObject
// Let exampleJson be a JsonElement

// Encode Java object to regular JsonElement
exampleCodec.encodeStart(JsonOps.INSTANCE, exampleObject);

// Encode Java object to compressed JsonElement
exampleCodec.encodeStart(JsonOps.COMPRESSED, exampleObject);

// Decode JsonElement into Java object
// Assume JsonElement was parsed normally
exampleCodec.parse(JsonOps.INSTANCE, exampleJson);

Minecraft 还提供 NbtOps,处理保存在 Tag 实例中的 NBT 数据,通过 NbtOps#INSTANCE 访问。

// Let exampleCodec represent a Codec<ExampleJavaObject>
// Let exampleObject be a ExampleJavaObject
// Let exampleNbt be a Tag

// Encode Java object to Tag
exampleCodec.encodeStart(NbtOps.INSTANCE, exampleObject);

// Decode Tag into Java object
exampleCodec.parse(NbtOps.INSTANCE, exampleNbt);

处理注册表条目时,Minecraft 提供包含查找提供者的 RegistryOps。使用 RegistryOps#create,传入目标数据格式的 DynamicOps,以及能访问相应注册表的查找提供者即可创建。NeoForge 进一步提供 ConditionalOps,使注册表 codec 查找支持条目加载条件。

// Let lookupProvider be a HolderLookup.Provider
// Let exampleCodec represent a Codec<ExampleJavaObject>
// Let exampleObject be a ExampleJavaObject
// Let exampleJson be a JsonElement

// Get the registry ops for JsonElement
RegistryOps<JsonElement> ops = RegistryOps.create(JsonOps.INSTANCE, lookupProvider);

// Encode Java object to JsonElement
exampleCodec.encodeStart(ops, exampleObject);

// Decode JsonElement into Java object
exampleCodec.parse(ops, exampleJson);

格式转换

DynamicOps 也可以单独用于转换两种已编码格式。调用 #convertTo,传入目标格式的 DynamicOps 和要转换的已编码对象即可。

// Convert Tag to JsonElement
// Let exampleTag be a Tag
JsonElement convertedJson = NbtOps.INSTANCE.convertTo(JsonOps.INSTANCE, exampleTag);

DataResult

Codec 编解码返回 DataResult。转换成功时,它包含转换后的实例;失败时包含错误数据。成功结果可从 #result 返回的 Optional 获取。失败时,#error 返回的 Optional 包含 PartialResult,其中有错误信息,并可能包含 codec 提供的部分转换结果。

DataResult 还提供多种方法,将结果或错误转换为需要的形式。例如,#resultOrPartial 在成功时返回包含完整结果的 Optional,失败时返回部分转换结果。它接受一个字符串消费者,用于报告存在的错误消息。

// Let exampleCodec represent a Codec<ExampleJavaObject>
// Let exampleJson be a JsonElement

// Decode JsonElement into Java object
DataResult<ExampleJavaObject> result = exampleCodec.parse(JsonOps.INSTANCE, exampleJson);

result
    // Get result or partial on error, report error message
    .resultOrPartial(errorMessage -> /* Do something with error message */)
    // If result or partial is present, do something
    .ifPresent(decodedObject -> /* Do something with decoded object */);

现有 Codec

基本类型

Codec 类为部分基础类型提供了静态 codec 实例。

Codec Java 类型
BOOL Boolean
BYTE Byte
SHORT Short
INT Integer
LONG Long
FLOAT Float
DOUBLE Double
STRING String*
BYTE_BUFFER ByteBuffer
INT_STREAM IntStream
LONG_STREAM LongStream
PASSTHROUGH Dynamic<?>**
EMPTY Unit***

* String 的字符数量可以通过 Codec#string 或 Codec#sizeLimitedString 限制。

** Dynamic 保存以受支持的 DynamicOps 格式编码的值,通常用于将已编码对象转换为其他编码格式。

*** Unit 用于表示空对象。

原版和 NeoForge

Minecraft 和 NeoForge 为经常编解码的对象提供多种 codec。例如,Identifier#CODEC 对应 Identifier,ExtraCodecs#INSTANT_ISO8601 对应采用 DateTimeFormatter#ISO_INSTANT 格式的 Instant,CompoundTag#CODEC 对应 CompoundTag。

注意 CompoundTag 无法通过 JsonOps 解码 JSON 中某些数字列表。JsonOps 转换时会选择能容纳数值的最窄类型,而 ListTag 要求元素类型一致。例如,64 会变成 byte,384 会变成 short,混在一起转换就会报错。

原版和 NeoForge 的注册表也为其包含的对象类型提供 codec,例如 BuiltInRegistries#BLOCK 提供 Codec<Block>。Registry#byNameCodec 将注册对象编码为注册名称。原版注册表还有 Registry#holderByNameCodec,编码为注册名称,解码为 Holder 包装的注册对象。

创建 Codec

可以为任意对象创建编解码器。为便于理解,以下示例也会展示对应 JSON。

记录结构

可以通过记录结构定义对象 codec,为每个字段明确命名。创建记录 codec 有多种方式,最简单的是 RecordCodecBuilder#create。

RecordCodecBuilder#create 接受一个函数,函数定义 Instance 并返回对象的应用形式 App。可以将其类比为创建类实例,以及使用构造方法将类应用到构造出的对象。

// Some object to create a codec for
public class SomeObject {

    public SomeObject(String s, int i, boolean b) { /* ... */ }

    public String s() { /* ... */ }

    public int i() { /* ... */ }

    public boolean b() { /* ... */ }
}

字段

Instance 可通过 #group 定义最多 16 个字段。每个字段都必须是描述目标实例和字段类型的应用形式。最简单的做法是从一个 Codec 出发,指定解码时使用的字段名称,以及编码时读取数据的 getter。

必填字段使用 Codec#fieldOf;由 Optional 包装或具有默认值的字段使用 #optionalFieldOf。两者都要求提供编码对象中的字段名称。然后调用 #forGetter,传入从对象中读取字段数据的函数。

警告 如果元素解析报错,#optionalFieldOf 也会报错。若希望忽略该错误,改用 #lenientOptionalFieldOf。

之后通过 #apply 指定如何用这些字段构造对象。为方便使用构造方法引用,分组字段最好按构造方法参数顺序排列。

public static final Codec<SomeObject> RECORD_CODEC = RecordCodecBuilder.create(instance -> // Given an instance
    instance.group( // Define the fields within the instance
        Codec.STRING.fieldOf("s").forGetter(SomeObject::s), // String
        Codec.INT.optionalFieldOf("i", 0).forGetter(SomeObject::i), // Integer, defaults to 0 if field not present
        Codec.BOOL.fieldOf("b").forGetter(SomeObject::b) // Boolean
    ).apply(instance, SomeObject::new) // Define how to create the object
);
// Encoded SomeObject
{
    "s": "value",
    "i": 5,
    "b": false
}

// Another encoded SomeObject
{
    "s": "value2",
    // i is omitted, defaults to 0
    "b": true
}

// Another encoded SomeObject
{
    "s": "value2",
    // Will throw an error as lenientOptionalFieldOf is not used
    "i": "bad_value",
    "b": true
}

转换器

可以通过映射方法将 codec 转换为等价或部分等价的表示。每个映射方法接受两个函数:一个从当前类型转换为新类型,另一个从新类型转换回当前类型。完全等价时使用 #xmap。

// A class
public class ClassA {

    public ClassB toB() { /* ... */ }
}

// Another equivalent class
public class ClassB {

    public ClassA toA() { /* ... */ }
}

// Assume there is some codec A_CODEC
public static final Codec<ClassB> B_CODEC = A_CODEC.xmap(ClassA::toB, ClassB::toA);

若只是部分等价,也就是转换存在限制,可以使用返回 DataResult 的映射函数,在遇到异常或无效状态时返回错误。

A 能否完全转换为 B B 能否完全转换为 A 转换方法
是 是 #xmap
是 否 #flatComapMap
否 是 #comapFlatMap
否 否 #flatXMap
// Given an string codec to convert to a integer
// Not all strings can become integers (A is not fully equivalent to B)
// All integers can become strings (B is fully equivalent to A)
public static final Codec<Integer> INT_CODEC = Codec.STRING.comapFlatMap(
    s -> { // Return data result containing error on failure
        try {
            return DataResult.success(Integer.valueOf(s));
        } catch (NumberFormatException e) {
            return DataResult.error(s + " is not an integer.");
        }
    },
    Integer::toString // Regular function
);
// Will return 5
"5"

// Will error, not an integer
"value"

范围 Codec

范围 codec 是 #flatXMap 的应用:数值不在包含端点的最小值、最大值范围内时,返回错误 DataResult,但仍将该数值保留为部分结果。整数、单精度和双精度分别使用 #intRange、#floatRange 和 #doubleRange。

public static final Codec<Integer> RANGE_CODEC = Codec.intRange(0, 4); 
// Will be valid, inside [0, 4]
4

// Will error, outside [0, 4]
5

字符串解析器

Codec#stringResolver 是 flatXmap 的应用,将字符串映射为某类对象。

public record StringResolverObject(String name) { /* ... */ }

// Assume there is some Map<String, StringResolverObject> OBJECT_MAP
public static final Codec<StringResolverObject> STRING_RESOLVER_CODEC = Codec.stringResolver(StringResolverObject::name, OBJECT_MAP::get);
// Will map this string to its associated object
"example_name"

默认值

编解码失败时,可以通过 Codec#orElse 或 Codec#orElseGet 提供默认值。

public static final Codec<Integer> DEFAULT_CODEC = Codec.INT.orElse(
    errorMessage -> /* Do something with the error message */,
    0 // Can also be a supplied value via #orElseGet
); 
// Not an integer, defaults to 0
"value"

Unit

提供代码中的固定值、但不编码任何数据的 codec,可通过 MapCodec#unitCodec 表示。当数据对象使用不能编码的条目时,这很有用。

public static final Codec<IEventBus> UNIT_CODEC = MapCodec.unitCodec(
    () -> NeoForge.EVENT_BUS // Can also be a raw value
);
// Nothing here, will return the NeoForge event bus

延迟初始化

有时 codec 依赖的数据在构造时尚不可用。可以使用 Codec#lazyInitialized,在首次编解码时才构造实际 codec;该方法接受一个提供 codec 的 supplier。

public static final Codec<IEventBus> LAZY_CODEC = Codec.lazyInitialized(
    () -> MapCodec.unitCodec(NeoForge.EVENT_BUS)
);
// Nothing here, will return the NeoForge event bus
// Encodes/decodes the same way as the normal codec

列表

通过对象 codec 的 Codec#listOf 可以创建对象列表 codec。listOf 还可以接受表示最小和最大长度的整数;sizeLimitedListOf 类似,但只指定最大长度。

// BlockPos#CODEC is a Codec<BlockPos>
public static final Codec<List<BlockPos>> LIST_CODEC = BlockPos.CODEC.listOf();
// Encoded List<BlockPos>
[
    [1, 2, 3], // BlockPos(1, 2, 3)
    [4, 5, 6], // BlockPos(4, 5, 6)
    [7, 8, 9]  // BlockPos(7, 8, 9)
]

列表 codec 解码得到的是不可变列表。如果需要可变列表,应为列表 codec 应用转换器。

映射

使用 Codec#unboundedMap,可以通过键和值的两个 codec 创建映射 codec。键可以是任何直接表示为字符串,或可转换为字符串的值。

// BlockPos#CODEC is a Codec<BlockPos>
public static final Codec<Map<String, BlockPos>> MAP_CODEC = Codec.unboundedMap(Codec.STRING, BlockPos.CODEC);
// Encoded Map<String, BlockPos>
{
    "key1": [1, 2, 3], // key1 -> BlockPos(1, 2, 3)
    "key2": [4, 5, 6], // key2 -> BlockPos(4, 5, 6)
    "key3": [7, 8, 9]  // key3 -> BlockPos(7, 8, 9)
}

无界映射 codec 解码得到的是不可变映射。需要可变映射时,应应用转换器。

注意 无界映射只支持可编解码为字符串的键。可以通过键值对组成的列表 codec 绕过这一限制。

对

Codec#pair 接受两个 codec,创建对象对的 codec。

对象对 codec 先解码左侧对象,再从剩余编码数据中解码右侧。因此,codec 必须在解码后提供关于已编码对象的结构信息,例如记录结构;或者先扩展为 MapCodec,再通过 #codec 转回普通 codec。通常可以将 codec 定义为对象的一个字段。

public static final Codec<Pair<Integer, String>> PAIR_CODEC = Codec.pair(
    Codec.INT.fieldOf("left").codec(),
    Codec.STRING.fieldOf("right").codec()
);
// Encoded Pair<Integer, String>
{
    "left": 5,       // fieldOf looks up 'left' key for left object
    "right": "value" // fieldOf looks up 'right' key for right object
}

建议 对于非字符串键的映射,可使用键值对列表,并应用转换器进行编解码。

Either

Codec#either 接受两个 codec,为同一数据提供两种不同的编解码方式。

Either codec 先尝试第一个 codec;失败后尝试第二个。若第二个也失败,DataResult 只包含第二次失败的错误。

public static final Codec<Either<Integer, String>> EITHER_CODEC = Codec.either(
    Codec.INT,
    Codec.STRING
);
// Encoded Either.Left<Integer, String>
5

// Encoded Either.Right<Integer, String>
"value"

建议 结合转换器,可以从两种编码形式得到同一种具体对象。

Xor

Codec#xor 是 Either 的特殊情况:只有两个 codec 中恰好一个成功才算成功。如果两者都能成功处理,反而会报错。

public static final Codec<Either<Integer, String>> XOR_CODEC = Codec.xor(
    Codec.INT.fieldOf("number").codec(),
    Codec.STRING.fieldOf("text").codec()
);
// Encoded Either.Left<Integer, String>
{
    "number": 4
}

// Encoded Either.Right<Integer, String>
{
    "text": "value"
}

// Throws an error as both can be decoded
{
    "number": 4,
    "text": "value"
}

Alternative

Codec#withAlternative 也是 Either 的特殊情况,两个 codec 解码同一种对象的不同存储格式。先尝试主要 codec,失败时改用第二个;编码始终使用主要 codec。

public static final Codec<BlockPos> ALTERNATIVE_CODEC = Codec.withAlternative(
    BlockPos.CODEC,
    RecordCodecBuilder.create(instance -> instance.group(
        Codec.INT.fieldOf("x").forGetter(BlockPos::getX),
        Codec.INT.fieldOf("y").forGetter(BlockPos::getY),
        Codec.INT.fieldOf("z").forGetter(BlockPos::getZ)
    ), BlockPos::new)
);
// Normal method to decode BlockPos
[ 1, 2, 3 ]

// Alternative method to decode BlockPos
{
    "x": 1,
    "y": 2,
    "z": 3
}

递归

有时对象的字段会引用同类型对象。例如,EntityPredicate 的载具、乘客、目标实体条件也都是 EntityPredicate。可以通过 Codec#recursive,在创建 codec 的函数中取得它自身的引用。

// Define our recursive object
public record RecursiveObject(Optional<RecursiveObject> inner) { /* ... */ }

public static final Codec<RecursiveObject> RECURSIVE_CODEC = Codec.recursive(
    RecursiveObject.class.getSimpleName(), // This is for the toString method
    recursedCodec -> RecordCodecBuilder.create(instance -> instance.group(
        recursedCodec.optionalFieldOf("inner").forGetter(RecursiveObject::inner)
    ).apply(instance, RecursiveObject::new))
);
// An encoded recursive object
{
    "inner": {
        "inner": {}
    }
}

分派

通过 Codec#dispatch,可以按指定类型选择子 codec 解码对象。这常用于包含 codec 的注册表,例如规则测试和方块放置器。

分派 codec 先从某个字符串键,通常为 type,取得编码类型,再解码类型并获取用于处理实际对象的具体 codec。如果所用 DynamicOps 压缩映射,或对象 codec 本身不是 MapCodec,例如不是记录结构或带字段名的基本类型,那么对象需要放在 value 键下;否则对象直接在其余数据的同一层级解码。

// Define our object
public abstract class ExampleObject {

    // Define the method used to specify the object type for encoding
    public abstract MapCodec<? extends ExampleObject> type();
}

// Create simple object which stores a string
public class StringObject extends ExampleObject {

    public StringObject(String s) { /* ... */ }

    public String s() { /* ... */ }

    public MapCodec<? extends ExampleObject> type() {
        // A registered registry object
        // "string":
        //   Codec.STRING.xmap(StringObject::new, StringObject::s).fieldOf("string")
        return STRING_OBJECT_CODEC.get();
    }
}

// Create complex object which stores a string and integer
public class ComplexObject extends ExampleObject {

    public ComplexObject(String s, int i) { /* ... */ }

    public String s() { /* ... */ }

    public int i() { /* ... */ }

    public MapCodec<? extends ExampleObject> type() {
        // A registered registry object
        // "complex":
        //   RecordCodecBuilder.mapCodec(instance ->
        //     instance.group(
        //       Codec.STRING.fieldOf("s").forGetter(ComplexObject::s),
        //       Codec.INT.fieldOf("i").forGetter(ComplexObject::i)
        //     ).apply(instance, ComplexObject::new)
        //   )
        return COMPLEX_OBJECT_CODEC.get();
    }
}

// Assume there is an Registry<MapCodec<? extends ExampleObject>> DISPATCH
public static final Codec<ExampleObject> = DISPATCH.byNameCodec() // Gets Codec<MapCodec<? extends ExampleObject>>
    .dispatch(
        ExampleObject::type, // Get the codec from the specific object
        Function.identity() // Get the codec from the registry
    );
// Simple object
{
    "type": "string", // For StringObject
    "value": "value" // Codec type is not augmented from MapCodec, needs field
}

// Complex object
{
    "type": "complex", // For ComplexObject

    // Codec type is augmented from MapCodec, can be inlined
    "s": "value",
    "i": 0
}

来源: NeoForged/Documentation(26.1)。中文翻译依据提交 4a680cdd4d15。