在开发供公众使用的工具和应用程序时,必须提供完整和明确的文件。 这同样适用于智能合约。
即使是中等规模的合约,维护文档也可能成为一种负担。 文档往往会过时,它的验证需要不断的手工工作,而且结果仍然倾向于在小而重要的部分与实施相背离。
在这篇文章中,我们将考虑如何解决这个问题,并使用Lorentz,一个用于Michelson智能合约的eDSL,轻松地维护文档。
这篇文章更多的是针对Tezos的读者,所以它不会触及框架的实现或任何Haskell特定的细节。
从合约中生成
简而言之,我们的解决方案是在合同代码中直接嵌入文档片段。 最终的文档可以从合同代码中自动生成。 由此产生的内容是Markdown格式的,可以通过GitHub/GitLab设施或使用专用编辑器来呈现。
作为一个eDSL,洛伦茨可以毫不费力地提供对非米歇尔森元素的支持(事实上,它已经这样做了,因为它包括莫利扩展)。
文档的某些部分可以通过分析合同内容推导出来,而其他部分则是由开发者自己添加的。
下面,我们看看洛伦兹合约在实践中是如何被记录的。
记录我的合同
让我们考虑一个简单的反合同:
data Parameter
= Add Natural
| Sub Natural
| Get (Void_ () Natural)
type Storage = Natural
contract :: Contract Parameter Integer
contract = defaultContract $ do
unpair
entryCaseSimple
( #cAdd /-> do
add
nil; pair
, #cSub /-> do
rsub
isNat
ifSome nop (failUsing [mt|Subtracted too much|])
nil; pair
, #cGet /-> void_ $ do
drop @()
)
你也可以在autodoc例子库中看到所有的代码。
立即可以为这个契约生成Markdown文档:
dumpContractDoc :: IO ()
dumpContractDoc =
writeFile "Counter.md" $
buildMarkdownDoc (finalizedAsIs contract)
你可以在这里看到生成的文档。
这只是描述了合约的整体结构。入口点是从entryCaseSimple 调用中推导出来的,而可能的失败情况则反映在Errors 部分。
然而,我们的文档还没有解释合同的具体内容,所以我们想补充一些评论。
因为在洛伦兹文档是契约的固有部分,所以可以在它们相关的代码旁边插入描述:
contract = defaultContract $ do
doc $ DDescription [md|
A trivial counter contract.
Used for demonstration purposes.
|]
unpair
entryCaseSimple
( #cAdd /-> do
doc $ DDescription "Increase the counter by given value."
...
, #cSub /-> do
doc $ DDescription "Decrease the counter by given value."
...
, #cGet /-> do
doc $ DDescription "Get the current counter value."
...
)
这里doc 代表一个特殊的元指令,它包含一个所谓的doc项目,而DDescription 是用来记录合同不同部分的基础项目之一。这样的注释既有助于澄清代码,也可以作为所产生的合同文档的描述。
为了得到一个完美的结果,我们可以另外将合同代码包裹在顶层的docGroup "Counter" 。这将把整个文档放在一个Counter 的标题下。
现在生成的文档看起来如下:
.jpg)
注意,不需要在描述中包括技术细节(比如每个入口的参数类型),因为它们已经被自动插入了。
添加自定义类型
现在,一个新的要求出现了:我们的合同应该用令牌来操作,而不是普通的数字。 我们如何在文档中反映这一点?
由于令牌是一个语义上不同于数字的概念,我们希望为它创建一个特殊类型:
newtype Token = Token Natural
-- Parameter and Storage are updated respectively --
在这一点上,我们可以在代码中明确区分一个方法是接受一个令牌还是一个单纯的数字。 这一变化将反映在文档中。现在它将到处提到Token ,而不是一个普通的Natural 。我们唯一需要的是为我们的代码提供新类型的文档,以便编译:
instance TypeHasDoc Token where
typeDocMdDescription = "Tokens amount"
现在我们得到了以下关于我们类型的文档:
.jpg)
添加自定义错误
很明显,对于一般规模的合同,我们已经不能只用unit 或纯文本描述来承担失败了--这将给用户提供关于失败原因的足够线索。一个错误的通用表示方法是假设一个(tag, param) 格式,其中tag 是一个错误的唯一标识符,param 以机器可解析的格式表示错误细节。
让我们把这个应用到我们的小合同中。 在无效的减法中,我们希望返回一个(tag, balance - subtracted) 。
Lorentz已经提供了对这样一种错误格式的支持,我们的错误可以用它来声明,如下所示。
type instance ErrorArg "badSubtract" = Integer
instance CustomErrorHasDoc "badSubtract" where
customErrClass = ErrClassActionException -- normal user error
customErrDocMdCause = "Too few tokens for subtraction"
customErrArgumentSemantics = Just "diff between balance and subtracted amount"
现在,这个错误已经被记录下来,并且可以在代码中使用。我们的定义还确保了badSubtract 标签总是与合同中的Integer 参数相关联,这对于有可能使用我们合同的中间件代码来说应该是很方便的。
现在我们可以修改合同的实现来使用新的错误:
#cSub /-> do
rsub
dup
isnat
if IsSome
then do dip drop; coerceWrap
else failCustom #badSubtract
nil; pair
生成的文档会在全局范围内自动更新。.jpg)
和每个入口点的基础上。
.jpg)
添加自定义文档项目
autodoc的核心功能之一是用户可以定义自己的文档项目来描述代码的各种属性。 这些可能包括:
- 授权检查。
- 合同状态的特殊谓词,在执行主逻辑之前检查。
- 创建的操作。
- 对用户的预防措施。
- 一些关于代码的统计数据,如预先评估的气体消耗。
让我们考虑一个带有授权的最简单的例子:我们的合同的存储应该只被来自特定地址的调用所更新,否则就失败。
为了引入必要的合同逻辑,我们可能会写这样的东西:
-- Definition of authorization error --
authorizeUpdate :: s :-> s
authorizeUpdate = do
sender
push adminAddress
ifEq nop (failCustom_ #unauthorized)
contract =
...
entryCaseSimple
( #cAdd /-> do
authorizeUpdate
...
, #cSub /-> do
authorizeUpdate
...
, #cGet /-> do
...
)
现在我们想在文档中说明我们的add 和sub 入口需要特殊访问。为此,我们可以声明一个DAuthorization 文档项:
data DAuthorization = DAuthorization Address
instance DocItem DAuthorization where
docItemPos = 10120 -- global position, doc items of the same type
-- are grouped and then sorted according to this
docItemSectionName = Nothing
docItemToMarkdown _ (DAuthorization addr) =
mdSubsection "Authorization" $
"The sender must be " <> mdTicked (pretty adminAddress) <> "."
并将其纳入我们的检查中:
authorizeUpdate = do
doc $ DAuthorization adminAddress
...
注意,如果我们需要更新授权逻辑,就像我们只需要在一个地方更新实现一样,我们只需要更新一次文档,但它会被包含在每个相关的入口中。
事实上,只有一个允许地址的情况并不是很有趣,我们在这里可以使用仅仅是DDescription 。
更有趣的(也是很真实的)情况是有几个角色,每个入口都要求发送者属于一个特定的角色子集。 在这样的情况下,始终保持文档与实现的一致是很重要的,但用手工的方法是很麻烦的。 而在Lorentz中,这种情况的实现与上面的例子不会有明显的不同。
自定义文档项的另一个用例是元数据收集。
读者可能知道,Tezos智能合约的接口现在正在积极开发,最近批准的TZIP-16接口让合约暴露了各种信息,如描述、版本、许可等。 通常,开发者必须手动填写这些元数据,但所有相同的信息最好也包括在文档中;其中一些数据通常已经以某种方式定义在合约的源代码中。 为了避免重复的必要性,我们将使这些元数据的部分可以从合约文档中推导出来,作为我们TZIP-16支持的一部分。
顺便说一下,类型、错误和入口的文档也被实现为单纯的文档项,所以开发者可以自由地用Markdown允许的任何格式来描述它们的内容。docGroup ,也可以接受任意的文档项,将内容分组,传递一个字符串只是使用预定义的DName 对象。
对文档的测试
当涉及到手动文档编写时,开发人员有责任确保文档与代码更新保持一致。通常情况下,要花相当多的时间在这上面。
在自动生成的情况下,很多问题已经不可能了。 然而,像 "所有新的端点都有文档 "这样的最小的一致性检查仍然是必要的。
这些验证大多是常规的,同样可以通过添加测试自动进行。
编写以下测试套件:
test_Documentation :: [TestTree]
test_Documentation =
runDocTests testLorentzDoc contract
将为我们的合同添加一套基本的普通洛伦兹文档测试,其中包括以下检查:
- 所有的入口都有描述。
- 存储是有记录的。
- 所有的描述都以点结束(为了统一格式)。
一般来说,我们打算把这些测试套件不仅作为一种验证工具,而且作为一种指导开发者编写整齐和完整的文档的方式。 因此,例如,我们还没有记录合同的存储(使其成为可选项是一种设计选择),现在测试将建议我们使用dStorage doc项目,将我们的存储内容纳入文档。
遵守这些检查通常是一个好的做法。 另一方面,在某些情况下,它们可以被认为是太过严格的。 例如,我可能想隐藏契约的存储,因为它的格式可能会改变,我不希望其他开发者依赖它目前的结构。 对于这种情况,我们提供了一个排除机制,人们可以写:
test_Documentation :: [TestTree]
test_Documentation =
let tests = testLorentzDoc `excludeDocTests`
[ testStorageIsDocumented
]
in runDocTests tests contract
如果Lorentz发生了变化,并且在那里出现了额外的有用的检查,一旦我们依赖Morley的更新版本,它们将自动包含在我们的测试中。 如果有这样的需求,Morley框架也可以更新,提供按类别过滤检查的方法。 例如,它可以让用户排除所有关于格式化的测试,或者,只留下关于结果文档正确性的检查。
事实上,定义自定义检查也是可能的。
谨慎的开发者如果想确保排序符合他们的期望,可以选择添加类似的测试:
test_Documentation_ordering :: [TestTree]
test_Documentation_ordering =
[ -- authorization requirement is mentioned after
-- the entrypoint description
Proxy @DDescription `goesBefore` Proxy @DAuthorization
]
结论
在这篇文章中,我们讨论了由Lorentz提供的自动文档机制。
它遵循 "一切皆为代码 "的原则,因此提供了一些通常适用于代码的好处,甚至更多:
- 不需要文字的重复。
- 使得留下未记录的东西变得困难--测试覆盖率确保这个和其他有用的属性成立。
- 留下过时的文档是困难的--文档中的属性被定义在各自的逻辑实现旁边。
- 统一的格式和文本风格。
- 为读者提供方便的导航:交叉引用连接了文件的相关部分,每个部分都准确地提到了与它的主题相关的点。
autodoc的第一个版本出现在一年多以前,并立即对一个非常复杂的合同进行了实战测试,除了一些小问题,它满足了我们的需求。 从那时起,我们在许多项目中使用洛伦茨的文档功能,同时一点一点地改进它。 最近的变化包括增加了一个目录和git修订+一些外观的变化和修复。