翻译:Scissors 校对:Saku
三年前,当我们开始开发全链上游戏时,开发体验还非常糟糕。因此,我们创建了MUD引擎,以减少开发过程中遇到的各种难题。
接下来,我将详细介绍我们是如何在开发过程中逐步完善,并最终实现了当前的存储架构。
回看2021年(即MUD成立的前一年),我们按照当时的“最佳实践”在自定义的结构体(custom struct)、映射(mapping)和数组(array)中保存链上状态,通过自定义的查看函数(view function)来获取状态,对每一次状态变化编写自定义的事件(custom event),并在客户端和索引器上实现自定义事件处理的reducer函数。
这种方法对于小规模的链上应用是可行的,但是全链游戏需要与客户端同步大量的状态数据。很快,我们发现自己大部分时间都在处理数据模型的变更,并通过他们自定义的网络堆栈来实现这些变更,而不是将精力集中在游戏机制的开发上。
当开发过程中遇到的困难和挑战变得难以忍受时,我们决定暂停一下,重新思考理想中的开发体验应该是什么样的。
我们唯一的愿望是在智能合约上设置一个状态变量,并且能够自动地与客户端同步,不用自定义getter、 event和reducer,只需简单地读写状态。
解决方案的第一步是实现一种泛型事件(generic event),他能在每次状态变更时被触发,使得索引器或客户端能够自动同步链上的状态。
但问题是,在Solidity中并没有现成的“泛型事件”。不过,我们还是找到了替代方案。
本质上,类型(Type)不过是对字节(Byte)的一层封装。因此,我们通过使用原始字节(raw byte)作为事件数据,实现了一种能够覆盖所有状态变更的通用事件机制。
接下来,我们需要一个能够在每次状态变化时触发事件的泛型库(generic library),从而避免自定义setter函数的需求。
虽然Solidity并没有提供这样的泛型库,但我们采用了类似的策略来实现这一目标。我们没有使用泛型类型(generic type),而是选用了所有类型(type)的共同基础——字节,作为函数签名的参数。
这带来了一个新的挑战:如何将各种类型的数据转换成字节,再传递给这个库呢?最直接的方法是使用Solidity内置的 abi.encode
函数。然而,它因为到处添加填充而不适用于存储编码后的值。
一个更好的选择是使用 abi.encodePacked
函数,它能够紧凑地打包数据,避免了冗余填充。不过这个方法不能应用于数组(array)类型。
为此,我们不得不在Solidity中自行实现数组的紧凑编码方法。这种方法类似于提案中的abi.encodeTightlyPacked
(https://github.com/ethereum/solidity/issues/8441…)。
深入一步,我们如何实现一个Solidity函数,使其能够接受任何类型的数组并返回其紧凑打包的字节形式呢?我们首先为所有基本类型数组的共同基础——bytes32[]
实现打包逻辑。
然后,我们为Solidity支持的98种基本类型数组(如uint8[], uint16[], ..., bytes32[])各添加了一个特定的处理逻辑。这样,我们便拥有了一个能接受任意基本类型数组并返回其紧凑打包字节的函数。
我们越来越接近目标了。
最后的挑战是如何从存储中读取并解码这些值。我们需要一个类似于 abi.decode
的函数,但是要适用于我们自定义的紧凑编码方式:一个能够根据给定的编码字节和一个“模式(schema)”来返回解码值及其原类型的函数。
由于Solidity不支持通用返回类型,我们也无法像之前一样将其转化为通用类型。于是,我们转而采用了代码生成的方式。您只需要在一个配置文件中定义您的数据结构,MUD就能为你生成具有类型信息的读写库。
至此,我们实现了目标!
无需任何自定义的getter、event和reducer。只需简单地进行读写操作。每一次状态的写入操作都会触发一个事件,该事件用于自动将链上状态同步至索引器和客户端。
虽然Solidity本身不支持泛型类型,但通过一些巧妙的技巧,我们仍能实现理想中的开发体验。
我们投入大量时间开发MUD引擎,旨在提升构建可扩展的全链上应用的开发体验。如今,MUD已通过OpenZeppelin的全面审计,稳定版2.0.0即将发布。
感谢你的阅读。这篇文章与我在2023年Solidity峰会上的这个演讲内容相同:https://www.youtube.com/watch?v=gQzZyWw71bo。感兴趣的也可以直接观看视频。
关于我们:
FunBlocks是一家聚焦全链游戏(Fully On-Chain Game)与自主世界(Autonomous World)最新发展动态的媒体。我们相信为玩家创造快乐才是区块链游戏的价值所在!