
项目开篇:项目介绍 & 当前开发成果总结
接口变动:从零开始的Minecraft - Nbt序列化库开发:嵌套类型Nbt开发(1) - 顶层接口改动
摘要:由于灵活性的需求以及java/kotlin不完善的类型系统,我不得不将一部分上层接口变成一种奇怪的形状……我称之为“野接口”。关于“野接口”是什么,会在本文详细阐释
前置知识:本项目相关的上层接口的理解(包括开篇和接口变动一篇),以及本文涉及到对java 流式接口的应用,所以需要对流式IO(InputStream/OutputStream)有了解
在把接口变成奇怪的形状之前,先简单总结一下目前接口是长什么样的,比如开篇的这张图:

基本框架
到目前为止所有的开发都是基于这个基本框架:让Tag类型一一对应每个nbt二进制类型,Serdes负责序列化/反序列化数据,Converter负责将其他类型java对象转成Tag对象。
接着就是对Converter/Serdes接口的设计,应该如何满足需求,在 《从零开始的Minecraft - Nbt序列化库开发:嵌套类型Nbt开发(1) - 顶层接口改动》(以下简称《嵌套Nbt开发》) 这一篇,接口最终是这样的:
interface Serdes<NbtRelatedType:Any, I:SerdesCallerIntent> {
fun defaultIntent():I
fun serialize(tag:Tag<NbtRelatedType>, intent:I = defaultIntent()):ByteArray
fun deserialize(data:ByteArray, start:Int, intent:I = defaultIntent()):Pair<Tag<NbtRelatedType>, Int>
val id:Byte
val valueTypeToken:TypeToken<NbtRelatedType>
}
interface TagConverter<NbtRelatedType:Any, I:ConverterCallerIntent>{
fun defaultIntent():I
fun <V:Any> createTag(name: String?, value:V, typeToken: TypeToken<V>, intent: I = defaultIntent()): Tag<NbtRelatedType>?
fun <V:Any> toValue(tag: Tag<out Any>, typeToken: TypeToken<V>, intent: I = defaultIntent()): Pair<String?, V>?
} 接口中每个函数及其参数的含义就不重复了,详见上面贴的文章链接。重点在于两个核心参数:ConverterCallerIntent和SerdesCallerIntent。
在《嵌套Nbt开发》中,我做了初步的设计以满足当时的灵活性需求:
class ConverterCallerIntent(ignoreTypeToken:Boolean = false)
class HierarchicalConverterIntent(
var parents:Deque<Any>,
ignoreTypeToken:Boolean = false
):ConverterCallerIntent(ignoreTypeToken)
class SerdesCallerIntent(var hasHead:Boolean)
class HierarchicalCallerIntent(hasHead:Boolean, parents:Deque<Tag<out Any>):SerdesCallerIntent(hasHead)
class ProxyCallerIntent(
hasHead: Boolean,
parents: Deque<Tag<out Any>>,
var id:Byte? = null,
)
:HierarchicalSerdesCallerIntent(hasHead, parents) 但很快地,我就发现这种设计……挺乱的,多层继承结构,并且是类型继承,因为单继承的关系,有些灵活性无法很好表达。所以我开始着手改,在尝试了多种方案(以及令人头秃的代码改动)后,最终决定为下面的样子:
ConverterCallerIntent:
interface ConverterCallerIntent
interface recordParents:ConverterCallerIntent {
//这个值对应HierarchicalConverterIntent.parents
val parents:Deque<Any>
}
// 所有传入createTag的intent都要继承该接口
interface createTagIntent:ConverterCallerIntent
// 所有传入toValue的intent都要继承该接口
interface toValueIntent:ConverterCallerIntent
interface ToValueTypeToken:ToValueIntent {
// 这个值对应HierarchicalConverterIntent.ignoreValueTypeToken
val ignore:Boolean
} 首先,最明显的变动是所有的class改为interface,原因在于只有interface可以实现多继承,这样每种intent都可以灵活组合在一起。(顺便补充以下:kotlin接口中看起来好像在接口声明变量,实际上对应java声明的是一个get方法而已)
这个接口层次可以用下面一张图表示:

这里你会发现RecordParents接口并不符合上面的图,它实际上直接继承于ConverterCallerIntent。这里我表达的含义是RecordParents接口既可以被合并用于createTagIntent,也可以和toValueIntent一起用。该接口功能并不专属于toValueIntent或CreateTagIntent中的任何一个。
然后是SerdesCallerIntent:
interface SerdesCallerIntent
interface SerdesRecordParents:SerdesCallerIntent
interface SerializeIntent:SerdesCallerIntent
// serializeHead这个interface本身就代表了原有的hasHead
interface SerializeHead:SerializeIntent
interface DeserializeIntent:SerdesCallerIntent
// deseializeHead 和 serializeHead一样的含义,只不过是用于deserialize
interface DeserializeHead:DeserializeIntent {
// 因为tag head包含id和name两个部分,这里便是标识是否检查id
// 也意味这当前输入的数据不包含id信息,只包含name
val checkId:Boolean
}
interface SpecifyId:DeserializeIntent {
val id:Byte // 这个值和之前proxyIntent的id代表相同的含义
} 同样我们可以用一张图表示:

简而言之,Intent接口根据不同的功能扁平化并分成多个子接口,实际调用时则根据需要自行组合这些接口;例如在Serdes.serialize时我们希望记录父类信息并且序列化tag head,那么我们需要传入一个同时实现SerializeHead和SerdesRecordParents的对象,例如一个匿名对象:
object: SerializeHead,SerdesRecordParents {
override val parents = ArrayDeque()
} 如果我们并不需要记录父类信息,那么选择不继承该接口即可。
这种设计的缺点是,由于语言限制,Converter和Serdes并不能显式声明接受哪些类型的intent组合,对外的api接口只能声明一个父类CallerIntent,然后在方法内部自行检查/转换intent为自己需要的类型,例如:
override fun serialize(tag:Tag<out Any>, intent:SerdesCallerIntent):ByteArray {
intent as RecordParents // 该serdes需要RecordParents intent
if (intent is SerailizeHead) {
// do something
...
}
...
} 同样的,接口调用者也不知道需要指定哪些intent进去。在调用者看来,intent就像是一堆“野接口”,除非去看内部实现或者内部是否抛出异常,才能知道intent传的对不对。
标题回归。没错我说的“野接口”指的就是这两类intent。“野接口”取自“野指针”这一概念,用来代表一种不确定性:你无法在编译期确定这个接口是什么样的,只有在运行时才能确定。这一设计只能说是当前为了灵活性而做出的妥协。随着开发进程,新的需求不断出现,导致这一层接口需要不停的改动。为此不得已引入这种“野接口”的形式,以暂时满足灵活性需求。
到目前为止我还没说过有些什么新的需求,导致不得不使用这一设计,下面我举个开发时碰到的一个问题:现在在Serdes端有一个新需求:我们希望能够将序列化/反序列化结果输出/输入到java IO流中,但同时,我希望能保留直接输入输出到byte数组的功能,因为java 不涉及到IO操作的流,性能似乎低于直接创建/写入写出数组,我需要保留原有的实现以方便日后的性能测试。
那么在“野接口”框架下,只要加个两个接口就行了(当然,原有Serdes.serialize/deserialize中的ByteArray参数和返回值也可以移动到intent中):
interface SerializeOnStream:SerializeIntent {
val outputStream:OutputStream
}
interface DeserializeOnStream:DeserializeIntent {
val inputStream:InputStream
} 在没有用“野接口”实现时,我是直接把Serdes的接口参数和返回值改掉了,结果发现ByteArrayOutputStream某些操作巨慢无比,这下就悲剧了,当我想改回去时,所有的Serdes代码都变成了只接受IO流的形式,改动变得巨费时费力。而且也只有这种“野接口”的形式,我可以同时在一个框架下兼容IO流Serdes和byte数组Serdes;再设想如果用之前的类继承形式而非接口组合,那整个类结构迟早会变得极其臃肿复杂,OnByteSerdes和OnStreamSerdes下要分别继承功能语义几乎一致的子类。
因此,虽然“野接口”不得不面临和“野指针”相似的问题,但在序列化库设计还未完善,可能会有很多变动时,我也只找到这么一种方法。或许在以后能找到更合适的方法让intent可以在编译时做到限制规范,但目前来说这是能满足需要、最简单的设计了。