CryptoZombies是個在編游戲的過程中學習Solidity智能合約語言的互動教程。本教程是為了Solidity初學者而設計的,會從最基礎開始教起蟀给,即便你從來沒有接觸過Solidity也可以學泌辫,CryptoZombies會手把手地教你。
今天我們來學習第3課高級Solidity理論靖避,這堂課比之前要少些特效潭枣,但是會學一些非常重要的基礎理論,編寫真正的DApp時必知的:智能協(xié)議的所有權(quán)幻捏,Gas的花費盆犁,代碼優(yōu)化,和代碼安全篡九。
1. 智能協(xié)議的永固性
到現(xiàn)在為止谐岁,我們講的Solidity和其他語言沒有質(zhì)的區(qū)別,它長得也很像 JavaScript榛臼。但是伊佃,在有幾點以太坊上的DApp跟普通的應用程序有著天壤之別。
第一個例子沛善,在你把智能協(xié)議傳上以太坊之后航揉,它就變得不可更改,這種永固性意味著你的代碼永遠不能被調(diào)整或更新路呜。你編譯的程序會一直迷捧、永久的、不可更改地存在以太網(wǎng)上胀葱。這就是Solidity代碼的安全性如此重要的一個原因漠秋。如果你的智能協(xié)議有任何漏洞,即使你發(fā)現(xiàn)了也無法補救抵屿。你只能讓你的用戶們放棄這個智能協(xié)議庆锦,然后轉(zhuǎn)移到一個新的修復后的合約上。
但這恰好也是智能合約的一大優(yōu)勢轧葛。 代碼說明一切搂抒。 如果你去讀智能合約的代碼艇搀,并驗證它,你會發(fā)現(xiàn)求晶,一旦函數(shù)被定義下來焰雕,每一次的運行,程序都會嚴格遵照函數(shù)中原有的代碼邏輯一絲不茍地執(zhí)行芳杏,完全不用擔心函數(shù)被人篡改而得到意外的結(jié)果矩屁。
2. Ownable Contracts
上一章中,你有沒有發(fā)現(xiàn)任何安全漏洞呢爵赵?對申明為“外部的”(external)方法吝秕,是任何人都可以調(diào)用它的,這種情況下可能出現(xiàn)安全漏洞空幻。要對付這樣的情況烁峭,通常的做法是指定合約的“所有權(quán)”——就是說,給它指定一個主人秕铛,只有主人對它享有特權(quán)约郁。
OpenZeppelin庫的Ownable合約
下面是一個Ownable合約的例子:來自OpenZeppelin Solidity庫的 Ownable合約。OpenZeppelin是主打安保和社區(qū)審查的智能合約庫如捅,您可以在自己的DApps中引用棍现。這一課學完,可以看看OpenZeppelin镜遣,保管您會學到很多東西!
把樓下這個合約讀通士袄,是不是還有些沒見過代碼悲关?別擔心,隨后會解釋娄柳。
/**
* @title Ownable
* @dev The Ownable contract has an owner address, and provides basic authorization control
* functions, this simplifies the implementation of "user permissions".
*/
contract Ownable {
address public owner;
event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);
/**
* @dev The Ownable constructor sets the original `owner` of the contract to the sender
* account.
*/
function Ownable() public {
owner = msg.sender;
}
/**
* @dev Throws if called by any account other than the owner.
*/
modifier onlyOwner() {
require(msg.sender == owner);
_;
}
/**
* @dev Allows the current owner to transfer control of the contract to a newOwner.
* @param newOwner The address to transfer ownership to.
*/
function transferOwnership(address newOwner) public onlyOwner {
require(newOwner != address(0));
OwnershipTransferred(owner, newOwner);
owner = newOwner;
}
}
上面面有沒有你沒學過的東東寓辱?
構(gòu)造函數(shù):function Ownable()
是一個constructor
(構(gòu)造函數(shù)),構(gòu)造函數(shù)不是必須的赤拒,它與合約同名秫筏,構(gòu)造函數(shù)一生中唯一的一次執(zhí)行,就是在合約最初被創(chuàng)建的時候挎挖。
函數(shù)修飾符:modifier onlyOwner()
这敬。 修飾符跟函數(shù)很類似,不過是用來修飾其他已有函數(shù)用的蕉朵, 在其他語句執(zhí)行前崔涂,為它檢查下先驗條件。 在這個例子中始衅,我們就可以寫個修飾符onlyOwner檢查下調(diào)用者冷蚂,確保只有合約的主人才能運行本函數(shù)缭保。我們下一章中會詳細講述修飾符,以及那個奇怪的_;
蝙茶。
indexed關(guān)鍵字:別擔心艺骂,我們還用不到它。
所以Ownable合約基本都會這么干:
合約創(chuàng)建隆夯,構(gòu)造函數(shù)先行钳恕,將其owner設置為msg.sender(其部署者)
為它加上一個修飾符onlyOwner,它會限制陌生人的訪問吮廉,將訪問某些函數(shù)的權(quán)限鎖定在 owner 上苞尝。
允許將合約所有權(quán)轉(zhuǎn)讓給他人。
onlyOwner簡直人見人愛宦芦,大多數(shù)人開發(fā)自己的Solidity DApps宙址,都是從復制/粘貼 Ownable開始的,從它再繼承出的子類调卑,并在之上進行功能開發(fā)抡砂。
3.onlyOwner 函數(shù)修飾符
函數(shù)修飾符
函數(shù)修飾符看起來跟函數(shù)沒什么不同,不過關(guān)鍵字modifier告訴編譯器恬涧,這是個modifier(修飾符)注益,而不是個function(函數(shù))。它不能像函數(shù)那樣被直接調(diào)用溯捆,只能被添加到函數(shù)定義的末尾丑搔,用以改變函數(shù)的行為。咱們仔細讀讀onlyOwner
:
/**
* @dev 調(diào)用者不是‘主人’提揍,就會拋出異常
*/
modifier onlyOwner() {
require(msg.sender == owner);
_;
}
onlyOwner
函數(shù)修飾符是這么用的:
contract MyContract is Ownable {
event LaughManiacally(string laughter);
//注意啤月! `onlyOwner`上場 :
function likeABoss() external onlyOwner {
LaughManiacally("Muahahahaha");
}
}
注意likeABoss
函數(shù)上的onlyOwner
修飾符。 當你調(diào)用likeABoss
時劳跃,首先執(zhí)行onlyOwner
中的代碼谎仲, 執(zhí)行到onlyOwner
中的_;
語句時,程序再返回并執(zhí)行likeABoss
中的代碼刨仑≈E担可見,盡管函數(shù)修飾符也可以應用到各種場合杉武,但最常見的還是放在函數(shù)執(zhí)行之前添加快速的require檢查辙诞。
因為給函數(shù)添加了修飾符`onlyOwner,使得唯有合約的主人(也就是部署者)才能調(diào)用它艺智。
注意:主人對合約享有的特權(quán)當然是正當?shù)奶纫贿^也可能被惡意使用。比如,萬一封拧,主人添加了個后門志鹃。所以非常重要的是,部署在以太坊上的DApp泽西,并不能保證它真正做到去中心曹铃,你需要閱讀并理解它的源代碼,才能防止其中沒有被部署者惡意植入后門捧杉;作為開發(fā)人員陕见,如何做到既要給自己留下修復bug的余地,又要盡量地放權(quán)給使用者味抖,以便讓他們放心你评甜,從而愿意把數(shù)據(jù)放在你的 DApp中,這確實需要個微妙的平衡仔涩。
4.Gas
現(xiàn)在我們懂了如何在禁止第三方修改我們合約的同時忍坷,留個后門給咱們自己去修改。讓我們來看另一種使得Solidity編程語言與眾不同的特征:Gas——驅(qū)動以太坊DApps的能源熔脂。
在Solidity中佩研,你的用戶想要每次執(zhí)行你的DApp都需要支付一定的gas,gas可以用以太幣購買霞揉,因此旬薯,用戶每次跑DApp都得花費以太幣。一個DApp收取多少gas取決于功能邏輯的復雜程度适秩。每個操作背后绊序,都在計算完成這個操作所需要的計算資源,(比如秽荞,存儲數(shù)據(jù)就比做個加法運算貴得多)政模, 一次操作所需要花費的gas等于這個操作背后的所有運算花銷的總和。
由于運行你的程序需要花費用戶的真金白銀蚂会,在以太坊中代碼的編程語言,比其他任何編程語言都更強調(diào)優(yōu)化耗式。同樣的功能胁住,使用笨拙的代碼開發(fā)的程序,比起經(jīng)過精巧優(yōu)化的代碼來刊咳,運行花費更高彪见,這顯然會給成千上萬的用戶帶來大量不必要的開銷。
為什么要用 gas 來驅(qū)動娱挨?
以太坊就像一個巨大余指、緩慢、但非常安全的電腦。當你運行一個程序的時候酵镜,網(wǎng)絡上的每一個節(jié)點都在進行相同的運算碉碉,以驗證它的輸出——這就是所謂的“去中心化”,由于數(shù)以千計的節(jié)點同時在驗證著每個功能的運行淮韭,這可以確保它的數(shù)據(jù)不會被被監(jiān)控垢粮,或者被刻意修改。
可能會有用戶用無限循環(huán)堵塞網(wǎng)絡靠粪,抑或用密集運算來占用大量的網(wǎng)絡資源蜡吧,為了防止這種事情的發(fā)生,以太坊的創(chuàng)建者為以太坊上的資源制定了價格占键,想要在以太坊上運算或者存儲昔善,你需要先付費。
注意:如果你使用側(cè)鏈畔乙,倒是不一定需要付費君仆。你不會想要在以太坊主網(wǎng)上玩“魔獸世界”吧?所需要的gas可能會買到你破產(chǎn)啸澡。但是你可以找個算法理念不同的側(cè)鏈來玩它袖订。我們將在以后的課程中咱們會討論到,什么樣的 DApp應該部署在太坊主鏈上嗅虏,什么又最好放在側(cè)鏈洛姑。
省gas的招數(shù):結(jié)構(gòu)封裝(Struct packing)
在第1課中,我們提到除了基本版的 uint 外皮服,還有其他變種uint:uint8楞艾,uint16,uint32等龄广。通常情況下我們不會考慮使用uint變種硫眯,因為無論如何定義uint的大小,Solidity為它保留256位的存儲空間择同。例如两入,使用uint8而不是uint(uint256)不會為你節(jié)省任何 gas。除非敲才,把uint綁定到struct里面裹纳。
如果一個struct中有多個uint,則盡可能使用較小的uint紧武,Solidity會將這些 uint打包在一起剃氧,從而占用較少的存儲空間。例如:
struct NormalStruct {
uint a;
uint b;
uint c;
}
struct MiniMe {
uint32 a;
uint32 b;
uint c;
}
因為使用了結(jié)構(gòu)打包阻星,mini
比 normal
占用的空間更少
NormalStruct normal = NormalStruct(10, 20, 30);
MiniMe mini = MiniMe(10, 20, 30);
所以朋鞍,當uint 定義在一個struct中的時候,盡量使用最小的整數(shù)子類型以節(jié)約空間。 并且把同樣類型的變量放一起(即在 struct 中將把變量按照類型依次放置)滥酥,這樣Solidity可以將存儲空間最小化更舞。例如,有兩個 struct:
uint c; uint32 a; uint32 b; 和 uint32 a; uint c; uint32 b;
前者比后者需要的gas更少恨狈,因為前者把uint32放一起了疏哗。
5. 時間單位
時間單位
Solidity使用自己的本地時間單位。變量now將返回當前的unix時間戳(自1970年1月1日以來經(jīng)過的秒數(shù))禾怠。
注意:Unix時間傳統(tǒng)用一個32位的整數(shù)進行存儲返奉。這會導致“2038年”問題,當這個32位的unix時間戳不夠用吗氏,產(chǎn)生溢出芽偏,使用這個時間的遺留系統(tǒng)就麻煩了。所以弦讽,如果我們想讓我們的DApp跑夠20年污尉,我們可以使用64位整數(shù)表示時間,但為此我們的用戶又得支付更多的gas往产。真是個兩難的設計被碗!
Solidity還包含秒(seconds),分鐘(minutes)仿村,小時(hours)锐朴,天(days),周(weeks) 和年(years) 等時間單位蔼囊。它們都會轉(zhuǎn)換成對應的秒數(shù)放入uint 中焚志。所以1分鐘就是 60,1小時是 3600(60秒×60分鐘)畏鼓,1天是86400(24小時×60分鐘×60秒)酱酬,以此類推。
下面是一些使用時間單位的實用案例:
uint lastUpdated;
// 將‘上次更新時間’ 設置為 ‘現(xiàn)在’
function updateTimestamp() public {
lastUpdated = now;
}
// 如果到上次`updateTimestamp` 超過5分鐘云矫,返回 'true'
// 不到5分鐘返回 'false'
function fiveMinutesHavePassed() public view returns (bool) {
return (now >= (lastUpdated + 5 minutes));
}
有了這些工具膳沽,我們可以為僵尸設定”冷靜時間“功能。
6. 公有函數(shù)和安全性
你必須仔細地檢查所有聲明為public和external的函數(shù)让禀,一個個排除用戶濫用它們的可能贵少,謹防安全漏洞。請記住堆缘,如果這些函數(shù)沒有類似onlyOwner這樣的函數(shù)修飾符,用戶能利用各種可能的參數(shù)去調(diào)用它們普碎。檢查完這個函數(shù)吼肥,用戶就可以直接調(diào)用這個它。想要防止漏洞,最簡單的方法就是設其可見性為internal缀皱。
7.進一步了解函數(shù)修飾符
接下來斗这,我們將添加一些輔助方法。進一步學習什么是“函數(shù)修飾符”啤斗。
帶參數(shù)的函數(shù)修飾符
之前我們已經(jīng)讀過一個簡單的函數(shù)修飾符了:onlyOwner表箭。函數(shù)修飾符也可以帶參數(shù)。例如:
// 存儲用戶年齡的映射
mapping (uint => uint) public age;
// 限定用戶年齡的修飾符
modifier olderThan(uint _age, uint _userId) {
require(age[_userId] >= _age);
_;
}
必須年滿16周歲才允許開車 (至少在美國是這樣的)钮莲,我們可以用如下參數(shù)調(diào)用olderThan
修飾符:
function driveCar(uint _userId) public olderThan(16, _userId) {
// 其余的程序邏輯
}
看到了吧免钻, olderThan修飾符可以像函數(shù)一樣接收參數(shù),是“宿主”函數(shù) driveCar把參數(shù)傳遞給它的修飾符的崔拥。
8.利用 'View' 函數(shù)節(jié)省Gas
“view”函數(shù)不花“gas”
當玩家從外部調(diào)用一個view函數(shù)极舔,是不需要支付gas的。這是因為view函數(shù)不會真正改變區(qū)塊鏈上的任何數(shù)據(jù)——它們只是讀取链瓦。因此用view標記一個函數(shù)拆魏,意味著告訴web3.js,運行這個函數(shù)只需要查詢你的本地以太坊節(jié)點慈俯,而不需要在區(qū)塊鏈上創(chuàng)建一個事務(事務需要運行在每個節(jié)點上渤刃,因此花費 gas)。
稍后我們將介紹如何在自己的節(jié)點上設置 web3.js贴膘。但現(xiàn)在卖子,你關(guān)鍵是要記住,在所能只讀的函數(shù)上標記上表示只讀的“external view”聲明步鉴,就能為你的玩家減少在DApp中g(shù)as用量揪胃。
注意:如果一個view函數(shù)在另一個函數(shù)的內(nèi)部被調(diào)用,而調(diào)用函數(shù)與view 函數(shù)的不屬于同一個合約氛琢,也會產(chǎn)生調(diào)用成本喊递。這是因為如果主調(diào)函數(shù)在以太坊創(chuàng)建了一個事務,它仍然需要逐個節(jié)點去驗證阳似。所以標記為view的函數(shù)只有同一個合約的外部調(diào)用時才是免費的骚勘。
9.存儲非常昂貴
Solidity使用storage(存儲)是相當昂貴的,”寫入“操作尤其貴撮奏。這是因為俏讹,無論是寫入還是更改一段數(shù)據(jù), 這都將永久性地寫入?yún)^(qū)塊鏈畜吊。需要在全球數(shù)千個節(jié)點的硬盤上存入這些數(shù)據(jù)泽疆,隨著區(qū)塊鏈的增長,拷貝份數(shù)更多玲献,存儲量也就越大殉疼。這是需要成本的梯浪!
為了降低成本,不到萬不得已瓢娜,避免將數(shù)據(jù)寫入存儲挂洛。這也會導致效率低下的編程邏輯——比如每次調(diào)用一個函數(shù),都需要在memory(內(nèi)存) 中重建一個數(shù)組眠砾,而不是簡單地將上次計算的數(shù)組給存儲下來以便快速查找虏劲。
在大多數(shù)編程語言中,遍歷大數(shù)據(jù)集合都是昂貴的褒颈。但是在Solidity中柒巫,使用一個標記了external view的函數(shù),遍歷比storage要便宜太多哈肖,因為view函數(shù)不會產(chǎn)生任何花銷吻育。
在內(nèi)存中聲明數(shù)組
在數(shù)組后面加上memory關(guān)鍵字, 表明這個數(shù)組是僅僅在內(nèi)存中創(chuàng)建淤井,不需要寫入外部存儲布疼,并且在函數(shù)調(diào)用結(jié)束時它就解散了。與在程序結(jié)束時把數(shù)據(jù)保存進storage的做法相比币狠,內(nèi)存運算可以大大節(jié)省gas開銷——把這數(shù)組放在view里用游两,完全不用花錢。以下是申明一個內(nèi)存數(shù)組的例子:
function getArray() external pure returns(uint[]) {
// 初始化一個長度為3的內(nèi)存數(shù)組
uint[] memory values = new uint[](3);
// 賦值
values.push(1);
values.push(2);
values.push(3);
// 返回數(shù)組
return values;
}
這個小例子展示了一些語法規(guī)則漩绵,下一章中贱案,我們將通過一個實際用例,展示它和for循環(huán)結(jié)合的做法止吐。
注意:內(nèi)存數(shù)組必須用長度參數(shù)(在本例中為3)創(chuàng)建宝踪。目前不支持 array.push()之類的方法調(diào)整數(shù)組大小,在未來的版本可能會支持長度修改碍扔。
10.For 循環(huán)
在上面我們提到過瘩燥,函數(shù)中使用的數(shù)組是運行時在內(nèi)存中通過for循環(huán)實時構(gòu)建,而不是預先建立在存儲中的不同。for循環(huán)的語法在Solidity和JavaScript中類似厉膀。來看一個創(chuàng)建偶數(shù)數(shù)組的例子:
function getEvens() pure external returns(uint[]) {
uint[] memory evens = new uint[](5);
// 在新數(shù)組中記錄序列號
uint counter = 0;
// 在循環(huán)從1迭代到10:
for (uint i = 1; i <= 10; i++) {
// 如果 `i` 是偶數(shù)...
if (i % 2 == 0) {
// 把它加入偶數(shù)數(shù)組
evens[counter] = i;
//索引加一, 指向下一個空的‘even’
counter++;
}
}
return evens;
}
這個函數(shù)將返回一個形為 [2,4,6,8,10] 的數(shù)組二拐。
總結(jié)
本章了解了智能合約一旦部署則不可修改服鹅,所以在編寫完智能合約后,對智能合約的審查是非常重要的事情百新,如果有任何差池企软,將不可逆轉(zhuǎn)且不再受控。不過也不要太過擔心饭望,因為有像OpenZeppelin類似的合約庫澜倦,專門做智能合約安保和審查聚蝶,可以減輕我們不少工作。
另外本課還有一個重要的信息就是:信息的修改存儲到區(qū)塊鏈上是需要花費gas的藻治。這就要求我們在編寫智能合約時將一些中間信息盡量在本地內(nèi)存中處理,同時solidity也提供了像view
這樣的函數(shù)以節(jié)省gas巷挥。
從本課知道桩卵,編寫智能合約不是一件簡單的事情,每一行代碼都涉及到真金白銀倍宾,所以除了編寫者要細致外雏节,還需要多人和專業(yè)的工具做檢查和測試,才能產(chǎn)出一份合格的智能合約高职。
系列文章:
【CryptoZombies|編寫區(qū)塊鏈游戲?qū)W智能合約】Lesson1: 搭建僵尸工廠
【CryptoZombies|編寫區(qū)塊鏈游戲?qū)W智能合約】Lesson2: 僵尸攻擊人類
【CryptoZombies|編寫區(qū)塊鏈游戲?qū)W智能合約】Lesson3: 搭建僵尸工廠
【CryptoZombies|編寫區(qū)塊鏈游戲?qū)W智能合約】Lesson4: 僵尸作戰(zhàn)系統(tǒng)
【CryptoZombies|編寫區(qū)塊鏈游戲?qū)W智能合約】Lesson5: ERC721標準和加密收藏品