【ArchSummit】如何通过AIOps推动可量化的业务价值增长和效率提升?>>> 了解详情
写点什么

如何优雅地编写智能合约

  • 2019-11-18
  • 本文字数:6206 字

    阅读完需:约 20 分钟

如何优雅地编写智能合约

写在开头

众所周知,智能合约的出现,使得区块链不仅能够处理简单的转账功能,还能实现复杂的业务逻辑处理,其核心在于账户模型。


目前在众多区块链平台中,大多数集成了以太坊虚拟机,并使用 Solidity 作为智能合约的开发语言。Solidity 语言不仅支持基础/复杂数据类型操作、逻辑操作,同时提供高级语言的相关特性,比如继承、重载等。


除此之外,Solidity 语言还内置很多常用方法,比如成套的加密算法接口,使得数据加解密非常简单;提供事件 Event,便于跟踪交易的执行状态,为业务的逻辑处理、监控和运维提供便利。


然而,我们在编写智能合约代码的时候,还是会碰到各种问题,这些问题包括:代码 bug、可扩展性、可维护性、业务互操作的友好性等。同时,Solidity 语言还不完善、需要执行在 EVM 上、语言本身及执行环境也会给我们带来一些坑。


基于此,我们结合之前的项目和经验进行梳理,希望将之前碰到的问题总结下来,为后续的开发提供借鉴依据。


注:智能合约安全不在本篇文章讨论范畴,文中智能合约代码为 0.4 版本写法。

Solidity 常见问题

EVM 栈溢出

EVM 的栈深度为 1024,但是 EVM 指令集最多访问深度为 16,这给智能合约的编写带来很多限制,常见的报错为:stack overflows。


这个报错出现在智能合约编译阶段。我们知道 EVM 的栈用于存储临时变量或者局部变量,比如函数的参数或者函数内部的变量。优化一般也是从这两个方面出发。


下述代码片段可能存在栈溢出问题:


//如果课程超过 14 个,那么参数超过 16 个,则溢出


function addStudentScores(  bytes32 studentId,  bytes32 studentName,  uint8 chineseScore;  uint8 englishScore;  ...  uint8 mathScore)  public    returns (bool){    //TODO}
复制代码

BINARY 字段超长

智能合约通过 JAVA 编译器编译后会生成对应的 JAVA 合约,在 JAVA 合约中有一个重要的常量字段 BINARY,该字段为智能合约的编码,即合约代码。合约代码用于合约部署时签名,每一次合约的变更对应的 BINARY 都会不一样。


在编写智能合约时,如果单个智能合约代码很长,经过编译后的 BINARY 字段会很大。在 JAVA 合约中,BINARY 字段用 String 类型存储,String 类型的最大长度为 65534,如果智能合约代码过多,会导致 BINARY 字段的长度超过 String 类型的最大长度,导致 String 类型溢出,从而报错。


解决方案也非常简单:


1、尽可能复用代码,比如某些判断在不同的方法中多次出现,可以抽取出来,这样也便于后续的维护;


2、合约拆分,将一个合约拆分成为多个合约,一般出现 String 越界,基本上可以说明合约设计不合理。

慎用 string 类型

string 类型是一个比较特殊的动态字节数组,无法直接和定长数组进行转化,其解析和数组转化也非常复杂。


除此之外,string 类型浪费空间、非常昂贵(消耗大量 gas),且不能进行合约间传递(新的实验性 ABI 编译器除外),所以建议用 bytes 代替,特殊场景例外,比如未知长度字节数组或预留字段。


备注:string 类型可以通过在合约中添加新的实验性 ABI 编译器(如下代码)进行合约间传递。


pragma experimental ABIEncoderV2;
复制代码

智能合约编写

分层设计

网上多数智能合约的例子,比如著名的 ERC20 等,通常做法是写在一个智能合约文件中,这种写法本身没有什么问题,但面临复杂的业务,这种写法无可避免地会出现:


1、代码全部写在一个文件中,这个文件就非常大,不便于查看和理解,修改容易出错;


2、不便于多人协作和维护,尤其是业务发生变动或代码出现漏洞时,需要重新升级部署合约,导致之前的合约作废,相关业务数据或资产也就没有了。


那么,有没有一种方法可以使得智能合约升级又不影响原有账户(地址)?


先给答案:没有!(基于底层的分布式存储的 CRUD 除外,目前 FISCO BCOS 2.0 支持分布式存储,可直接通过 CRUD 操作数据库进行合约升级。)


但是!没有并不意味着不能升级,智能合约升级之后最大的问题是数据,所以只要保证数据完整就可以了。


举个例子:我们需要对学生信息上链,常规写法如下所示:


contract Students {  struct StudentInfo {        uint32 _studentId;        bytes32 _studentName;    }    mapping (uint32 => StudentInfo) private _studentMapping;    function addStudent(uint32 studentId, bytes32 studentName) public returns(bool){      //TODO:    }}
复制代码


这种写法,代码全部在一个智能合约中,如果现有的智能合约已经不能满足业务诉求,比如类型为 uint32 字段需升级为为 uint64,或者合约中添加一个新的字段,比如 sex,那这个智能合约就没有用了,需要重新部署。但因为重新部署,合约地址变了,无法访问到之前的数据。


一种做法是对合约进行分层,将业务逻辑和数据分离,如下所示:


contract StudentController {  mapping (uint32 => address) private _studentMapping;    function addStudent(uint32 studentId, bytes32 studentName) public returns(bool){      //TODO:    }}contract Student {  uint32 _studentId;  bytes32 _studentName;    //uint8 sex;}
复制代码


这种写法使得逻辑和数据分离,当需要新增一个性别 sex 字段时,原始数据可以编写两个 StudentController 合约,通过版本区分,新的 Student 采用新的逻辑,需要业务层面做兼容性处理,其最大的问题是对于原有数据的交互性操作,需要跨合约完成,非常不方便,比如查询所有学生信息。


我们再次进行分层,多出一个 map 层,专门用于合约数据管理,即使业务逻辑层和数据层都出现问题,也没有关系,只需要重新编写业务逻辑层和数据层,并对原有数据进行特殊处理就可以做到兼容。不过,这种做法需要提前在数据合约中做好版本控制(version),针对不同的数据,采用不同的逻辑。


这种做法最大的好处是数据全部保存在 StudentMap 中,数据合约和逻辑合约的变更都不会影响到数据,且在后续的升级中,可以通过一个 controller 合约做到对新老数据的兼容,如下所示:


contract StudentController {  mapping (uint32 => address) private _studentMapping;  constructor(address studentMapping) public {      _studentMapping = studentMapping;    }    function addStudent(uint version, uint32 studentId, bytes32 studentName, uint8 sex) public returns(bool){      if(version == 1){            //TODO        }else if(version == 2){            //TODO        }    }}contract StudentMap {  mapping (uint32 => address) private _studentMapping;    function getStudentMap() public constant returns(address){      return _studentMapping;    }}contract Student {  uint8 version;  uint32 _studentId;  bytes32 _studentName;    //uint8 sex;}
复制代码

统一接口

智能合约尽管具备很多高级语言的特性,但是本身还是存在很多限制。对于业务的精准处理,需要采用 Event 事件进行跟踪,对于不同的合约和方法,可以编写不同的 Event 事件,如下:


PS:你也可以采用 require 的方式进行处理,不过 require 方式不支持动态变量,每个 require 处理后需要填入特定的报错内容,在 SDK 层面耦合性太重,且不便于扩展。


contract StudentController {  //other code    event addStudentSuccessEvent(...); //省略参数,下同    event addStudentFailEvent(...);        function addStudent(bytes32 studentId, bytes32 studentName) public returns(bool){      if(add success){          addStudentSuccessEvent(...);            return true;        }else {          addStudentFailEvent(...);            return false;        }    }}
复制代码


这种做法也没有问题,不过我们需要编写大量的 Event 事件,增加了智能合约的复杂性。如果每次新增加一个方法或者处理逻辑,我们都需要编写一个专门的事件进行追踪,代码侵入性太强,容易出错。


除此之外,基于智能合约的 SDK 开发,对于每一个交易(方法)由于 Event 事件不同,需要编写大量的不可复用的代码,解析 Event 事件。这种写法,对于代码的理解和维护性都是非常差的。要解决这个问题,我们只需要编写一个基合约 CommonLib,如下所示:


contract CommonLib {  //tx code  bytes32 constant public ADD_STUDENT = "1";    bytes32 constant public MODIFY_STUDENT_NAME = "2";        //return code    bytes32 constant public STUDENT_EXIST = "1001";    bytes32 constant public STUDENT_NOT_EXIST = "1002";    bytes32 constant public TX_SUCCESS = "0000";      event commonEvent(bytes id, bytes32 txCode, bytes32 rtnCode);}
contract StudentController is CommonLib { function addStudent(bytes32 studentId, bytes32 studenntName) public returns(bool) { //process add student if(add success){ commonEvent(studentId, ADD_STUDENT, TX_SUCCESS); return true; }else { commonEvent(studentId, ADD_STUDENT, STUDENT_EXIST); return false; } } function modifyStudentName(bytes32 studentId, bytes32 studentName) public returns(bool){ //TODO: }}
复制代码


当新增一个 modifyStudentName 方法或其他合约时,原有的做法是根据方法可能出现的情况定义多个 Event 事件,然后在 SDK 中针对不同的 Event 编写解析方法,工作量很大。现在只需要在 CommonLib 中定义一对常量即可,SDK 的代码可以完全复用,几乎没有任何新增的工作。


:在上述例子中,commonEvent 包含三个参数,其中 txCode 为交易类型,即调用的哪个交易方法,rtnCode 为返回代码,表示在执行 txCode 所代表的交易方法时出现什么情况,这两个参数是必须的。在 commonEvent 中还有一个 Id 字段,用于关联业务字段 studentId,在具体的项目中,关联的业务字段可以自行定义和调整。

代码细节

代码细节能体验一个 coder 的能力和职业操守。在业务比较赶的情况下,经常会忽略代码细节,同时代码细节(风格)因人而异。对于一个多人协作的项目,统一的代码风格、代码规范,能极大提升研发效率、降低研发及维护成本、降低代码错误率。


命名规范


智能合约命名并没有一个标准,不过团队内部可以按照一个行业共识的规范执行。经过实战,推荐以下风格(不强制),如下代码块。


1、合约命名:采用驼峰命名、首字母大写、且能表达对应的业务含义;


2、方法命名:采用驼峰命名、首字母小写、且能表达对应的业务含义;


3、事件命名:采用驼峰命名、首字母小写、且能表达对应的业务含义,以 Event 结尾;


4、合约变量:采用驼峰命名、以_开头,首字母小写、且能表达对应的业务含义;


5、方法入参:采用驼峰命名、首字母小写、且能表达对应的业务含义;


6、方法出参:建议只写出参类型,无需命名,特殊情况例外;


7、事件参数:同方法入参;


8、局部变量:同方法入参。


contract Student {  bytes32 _studentId;    bytes32 _studentName;  event setStudentNameEvent(bytes32 studentId, bytes32 studentName);  function setStudentName(bytes32 studentName) public returns(bool){}    //other code}
复制代码


条件判断


在智能合约中,可以通过逻辑控制进行条件判断,比如 if 语句,也可以采用 solidity 语言提供的内置方法,比如 require 等。


两者在执行时存在一些差异,一般情况下,使用 require 没有问题,但是 require 不支持传参,如果业务需要在异常情况下给出明确的异常提示,则推荐使用 if 语句结合 Event 使用,如下。


event commonEvent(bytes id, bytes32 txCode, bytes32 rtnCode);//require(!_studentMapping.studentExist(studentId),"student does not exist");if(_studentMapping.studentExist(studentId)){  commonEvent(studentId, ADD_STUDENT, STUDENT_EXIST);  return false;}
复制代码


常量及注释


在智能合约中,常量和其他编程语言一样,需要采用大写加下划线方式命名,且命名需具备业务含义,同时需要采用 constant 关键词修饰,建议放置在合约开头。


常量也需要区分,对外接口常量采用 public 修饰,放置在基合约中。业务相关常量采用 private 修饰,放置在具体的业务逻辑合约中。如下所示:


contract CommonLib {    //tx code  bytes32 constant public ADD_STUDENT = "1";    bytes32 constant public MODIFY_STUDENT_NAME = "2";    ...}
contract StudentController is CommonLib { /** student status */ bytes32 constant private STUDENT_REGISTED = "A"; bytes32 constant private STUDENT_CANCELED = "C"; //other code}
复制代码


智能合约的注释同大部分编程语言,没有很严格的要求。对于一些特殊字段、常量、数组中的每个变量及特定逻辑,需进行说明,方法及 Event 可以使用/** comments */,特定字段及逻辑说明可采用//。如下所示:


/*** stundent controller*/contract StudentController {  /** add student */    function addStudent(      //[0]-seqNo;[1]-studentId;[2]-studentName;      bytes32[3] studentInfos)      public returns(bool){      //TODO:    }}
复制代码

兜底方案

在智能合约设计过程中,谁都无法保证自己的代码一定满足业务诉求,因为业务的变动是绝对的。同时,谁也无法保证业务及操作人员一定不会犯错,比如业务对某些字段未做校验导致链上出现非法数据,或者因为业务操作人员手误、恶意操作等,导致链上出现错误数据。


区块链系统不像其他传统系统,可以通过手动修改库或文件对数据进行修正,区块链必须通过交易对数据进行修正。


针对业务变更,在编写智能合约时可以适当增加一些保留字段,用于后续可能存在的业务变更。一般定义为一个通用化的数据类型比较合适,比如 string,一方面 string 类型存储容量大,另一方面几乎啥都可以存。


我们可以在 SDK 层面通过数据处理将扩展数据存入 string 字段,在使用时提供相应的数据处理反向操作解析数据,比如在 Student 合约中,新增 reserved 字段,如下所示。当前阶段,reserved 没有任何作用,在智能合约中为空。


contract Student {  //other code    string _reserved;        function getReserved() constant public returns(string){    return _reserved;  }
function setReserved(string reserved) onlyOwner public returns(bool){ _reserved = reserved; return true; }}
复制代码


针对手误或者非法操作导致的数据错误,务必预留相关的接口,以便在紧急情况下可以不修改合约,而通过更新 SDK 对链上数据进行修复(SDK 中可以先不实现)。比如针对 Student 合约中的 owner 字段,添加 set 操作。


contract Student {  //other code;    address _owner;  function setOwner(address owner) onlyOwner public returns(bool){    _owner = owner;    return true;  }}
复制代码


需要特别注意的是,对于预留字段和预留方法,必须确保其操作权限,防止引入更多问题。同时预留字段和预留方法都是一种非正常情况下的设计,具备超前意识,但一定要避免过度设计,这样会导致智能合约的存储空间非常浪费,同时预留方法使用不当会给业务的安全性带来隐患。

写在最后

区块链应用的开发涉及很多方面,智能合约是核心,本篇给出了开发智能合约过程中的一些建议和优化方法,但并不是完整和完美的,且本质上无法杜绝 bug 的出现,但通过优化方法,可以让代码变得更加健壮和易维护,从这点上来讲,已具备业界的基本良心要求了。


本文转载自 FISCO BCOS 开源社区


2019-11-18 21:42717

评论 35 条评论

发布
用户头像
张龙大骗子,你们还包庇他
2020-05-15 12:35
回复
用户头像
这人还是infoq的编辑,他们也不作为
2020-05-15 11:59
回复
用户头像
垃圾骗子,迟早遭报应,先让你出名,以免再有技术同行被骗
2020-05-15 11:58
回复
用户头像
该评论已删除
2020-05-15 11:49
回复
请用法律手段维权,在InfoQ灌水也不能解决问题啊。
2020-05-15 11:52
回复
你说的很轻巧
2020-05-15 12:14
回复
用户头像
该评论已删除
2020-05-15 11:40
回复
请用法律手段维权,在InfoQ灌水也不能解决问题啊。为了维护评论区环境,评论隐藏了。
2020-05-15 11:53
回复
用户头像
该评论已删除
2020-05-15 11:25
回复
受害人+1
2020-05-15 11:33
回复
用户头像
该评论已删除
2020-05-15 11:16
回复
堪比大片
2020-05-15 11:16
回复
非常理解您的心情,建议法律维权。未来InfoQ的留言环境,其他文章的留言我先隐藏了,希望理解支持。
2020-05-15 11:34
回复
作者的人品不好,也应该让公众知道吧,为什么要删除评论呢
2020-05-15 11:37
回复
查看更多回复
加载更多
发现更多内容

Midjourney|文心一格 Prompt:完整参数列表、风格汇总、文生图词典合集

汀丶人工智能

人工智能 AI绘画 MidJourney 文生图 prompt learning

斯坦福、Nautilus Chain等联合主办的 Hackathon 活动,现已接受报名

EOSdreamer111

一文盘点PoseiSwap近期的生态利好

股市老人

9款好用的项目工时软件推荐

PingCode

项目管理 工时管理

求爷爷告奶奶,阿里大佬才甩出这份Spark+Hadoop+中台实战pdf

程序知音

大数据 hadoop spark 后端技术

如何让 300 万程序员爱上 CODING?

CODING DevOps

双模齐下,提质增效:CODING 携手知微共创 BizDevOps 体系新篇章

CODING DevOps

Django笔记三十三之缓存操作

Hunter熊

Python redis django 缓存

轻松建模、编辑、分析尽在Rhino 7中文激活版~

真大的脸盆

Mac 3D Mac 软件 建模软件 3d建模

mac端好用的建模软件分享~

真大的脸盆

Mac Mac 软件 建模工具 三维建模软件

一文盘点PoseiSwap近期的生态利好

EOSdreamer111

太猛了!Github大佬那白嫖的分布式进阶宝典,啃完感觉能吊锤面试官

Java你猿哥

Java 架构 软件开发 ssm 架构设计

低代码实现探索(五十八)低代码平台

零道云-混合式低代码平台

由斯坦福、Nautilus Chain等联合主办的 Hackathon 活动,现已接受报名

鳄鱼视界

一文盘点PoseiSwap近期的生态利好

西柚子

Django笔记三十四之分页操作

Hunter熊

Python django 分页

AIGC背后的技术分析 | 机器学习中的卷积神经网络

TiAmo

卷积神经网络 AIGC

由斯坦福、Nautilus Chain等联合主办的 Hackathon 活动,现已接受报名

西柚子

2023-05-20:go语言的slice和rust语言的Vec的扩容流程是什么?

福大大架构师每日一题

golang rust 福大大

文心一言 VS 讯飞星火 VS chatgpt (19)-- go语言的slice和rust语言的Vec的扩容流程是什么?

福大大架构师每日一题

福大大 文心一言 讯飞星火

Ribbon默认负载均衡规则替换为NacosRule

越长大越悲伤

Spring Cloud

Midjourney|文心一格prompt教程[进阶篇]:Midjourney Prompt 高级参数、各版本差异、官方提供常见问题

汀丶人工智能

人工智能 AI绘画 MidJourney 文生图 prompt learning

UserDAO(UWT)会是WEB3.0中新的宠儿吗?

币离海

Web3.0 UserDAO UWT

Cloud Studio 高阶玩家:强大的 YAML 模板

CODING DevOps

专精特新 ︱ 腾讯云 CODING 助力消费电子类企业高速发展期的研运一体化

CODING DevOps

Turbo Boost Switcher Pro for mac(cpu温度监测工具) 2.10.2

真大的脸盆

Mac Mac 软件 CPU温度监测工具

斯坦福、Nautilus Chain等联合主办的 Hackathon 活动,现已接受报名

股市老人

Python潮流周刊#2:Rust 让 Python 再次伟大

Python猫

Python

使用Go语言实现工厂模式的三种方式

Jack

一文读懂大语言模型

俞凡

人工智能

开源字节 CRM 系统

源字节1号

开源 软件开发 小程序开发

如何优雅地编写智能合约_区块链_张龙_InfoQ精选文章