NVIDIA 初创加速计划,免费加速您的创业启动 了解详情
写点什么

如何优雅地编写智能合约

  • 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:42715

评论 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
回复
查看更多回复
加载更多
发现更多内容

网络攻防学习笔记 Day86

穿过生命散发芬芳

网络攻防 7月日更

如何下载YouTube视频/影片(4K画质)

资源君

分享 经验分享 youtube视频下载 工具分享 教程分享

《面试八股文》之 Redis 16卷

moon聊技术

redis 面试

关于体验设计的十大重要定律

石云升

读书笔记 用户体验 商业洞察 7月日更 体验设计

架构实战营 毕业总结

竹林七贤

掌握这些核心算法,拿不到10+个offer你来找我,我锤飞你个不争气的

北游学Java

Java 算法

Baetyl邀你来战EdgeX中国挑战赛!

百度开发者中心

人工智能 最佳实践 物联网

应届女生美团Java岗4面,一次性斩offfer,我受到了万点暴击

编程菌

程序员 面试 后端 计算机

都2021年了,还在问网络安全怎么入门,气得我当场脑血栓发作

网络安全学海

网络安全 信息安全 渗透测试 WEB安全 安全漏洞

2021- iOS开发者一份你一定会被问到的面试题(附参考答案)

iOSer

ios 面试 ios开发 iOS 知识体系

Go 语言的信号式抢占原理

Xargin

后端 Go 语言 系统编程

模块三作业

king

2021年FiL最新消息:fil值得投资吗?

区块链 IPFS fil fil矿机 fil行情

学生管理系统详细架构设计文档

张文龙

#架构实战营

Java版人脸检测详解上篇:运行环境的Docker镜像(CentOS+JDK+OpenCV)

编程菌

Java 编程 程序员 后端 java技术宅

祝贺中国跳水队夺金!百度智能云挺敢做梦的人

百度大脑

人工智能 跳水队

【云厂商】部分知名云服务商名单

行云管家

云计算 公有云 云服务 私有云 云厂商

Tensorflow for Java + Spark-Scala分布式机器学习计算框架的应用实践

Qunar技术沙龙

机器学习 tensorflow spark 后端 分布式计算

15年前的3篇论文,变成了万亿大生意

百度大脑

人工智能 论文

详细架构设计文档

刘丽

时序数据库助力安全监控

greatersecurity

PancakeSwap交易所做市机器人|交易所画K线机器人

Geek_23f0c3

交易所机器人 pancakeswap 做市机器人

粉了!京东商城核心亿级流量并发Java系统架构设计方案手册

Java架构追梦

Java 架构 面试 高并发 京东

别人都在谈爱琴海,凭什么程序员就“地中海”网友:还是太优秀!

为什么双赞安卓ARM工控主板应用前景那么好?

双赞工控

加码物联网安全,熵核科技做终端安全的守护者

熵核科技

spring,springboot,底层原理解析

java小李

NGINX社区线下Meetup第三期成功举办 感受成都开发者们的NGINX热情

爱极客侠

CODING 携手 CoDesign:让设计与开发更简单

CODING DevOps

DevOps 设计 开发工具 CoDesign

一种Vue应用程序错误/异常处理机制

devpoint

Vue 异常处理 vue2 7月日更

"开放数据,蔚然成林"—浪潮助力多地获得数据开放全国标杆

浪潮云

云计算

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