速来报名!AICon北京站鸿蒙专场~ 了解详情
写点什么

如何优雅地编写智能合约

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

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

建设医共体,患者有“医”靠!

天翼云开发者社区

EMQX企业版正式上架华为云OSC,助力企业实现云原生MQTT Broker的全生命周期管理

EMQ映云科技

物联网 华为云 mqtt emqx 8月月更

零基础前端培训学习有用吗

小谷哥

大数据培训中心哪家比较靠谱

小谷哥

重新定义容器化 Serverless 应用的数据访问

阿里巴巴中间件

云计算 阿里云 Serverless 容器 云原生

前端培训中怎么提升开发技术水平?

小谷哥

LeaRun.Java微服务快速开发平台

力软低代码开发平台

java培训班学习后怎样才能找到工作

小谷哥

一文讲透研发,SRE,运维,DevOps 的区别

Bytebase

DevOps SRE dba database

分布式系统架构设计

C++后台开发

数据库 分布式 后端开发 C/C++后台开发 C/C++开发

如何管理您的知识库?

Geek_da0866

政企组织为什么更需要私有化的IM即时通讯平台?

WorkPlus

Gartner:云安全面临的三大挑战以及三个对应策略

WorkPlus

深度学习公式推导(2):激活函数与偏置

老崔说架构

Postman如何做接口测试:导入 swagger 接口文档

和牛

测试 Postman

开源一夏 | 使用 CSS 的仿 GitHub 登录页面

海拥(haiyong.site)

开源 8月月更

当云走向行业垂直化,企业该如何应对?

WorkPlus

如何维护您的知识库?

Geek_da0866

SPI:Java的高可扩展利器

华为云开发者联盟

Java 开发

Go-Excelize API源码阅读(十一)—— GetActiveSheetIndex()

Regan Yue

Go 开源 源码刨析 8月日更 8月月更

家电上云后,智能家居如何构建场景化应用

华为云开发者联盟

云计算 后端 IoT 智能家居

Web Service 接口怎么测试

和牛

Python 接口 测试 Web Service

盒马销量预测核心算法的技术演进

阿里技术

大数据 算法

十年数智求索路,餐饮SaaS头部企业客如云如何走向盈利

ToB行业头条

如何开发一款基于 Vite+Vue3 的在线表格系统(上)

葡萄城技术团队

Vue 前端 vite 框架 系统开发

攻克美颜、虚拟背景、眼神接触多个难题,腾讯会议技术领先的秘诀找到了

科技热闻

未来源码丨会写代码的AI开源了!C语言写得比Codex还要好,掌握12种编程语言丨CMU

MobTech袤博科技

c 开源 AI

2022 智能云边开源峰会|Kyligence 邀您“云”上相约

Kyligence

人工智能 云原生 边缘计算 开源峰会

EMAS Serverless搭建《私人云相册》小程序赢中秋好礼

移动研发平台EMAS

小程序 阿里云 Serverless 中秋节 云相册

大数据软件开发哪里的培训比较好

小谷哥

我们还需要 SRE 吗?

Bytebase

DevOps SRE developer

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