以太坊智能協議學習筆記【3】- Solidity

學習來源:https://cryptozombies.io/zh/lesson/3

第1章: 智能協議的永固性

到現在爲止,我們講的 Solidity 和其他語言沒有質的區別,它長得也很像 JavaScript。

但是,在有幾點以太坊上的 DApp 跟普通的應用程序有着天壤之別。

第一個例子,在你把智能協議傳上以太坊之後,它就變得不可更改, 這種永固性意味着你的代碼永遠不能被調整或更新。

你編譯的程序會一直,永久的,不可更改的,存在以太坊上。這就是 Solidity 代碼的安全性如此重要的一個原因。如果你的智能協議有任何漏洞,即使你發現了也無法補救。你只能讓你的用戶們放棄這個智能協議,然後轉移到一個新的修復後的合約上。

但這恰好也是智能合約的一大優勢。代碼說明一切。如果你去讀智能合約的代碼,並驗證它,你會發現,一旦函數被定義下來,每一次的運行,程序都會嚴格遵照函數中原有的代碼邏輯一絲不苟地執行,完全不用擔心函數被人篡改而得到意外的結果。

外部依賴關係

在第2課中,我們將加密小貓(CryptoKitties)合約的地址硬編碼到 DApp 中去了。有沒有想過,如果加密小貓出了點問題,比方說,集體消失了會怎麼樣? 雖然這種事情幾乎不可能發生,但是,如果小貓沒了,我們的 DApp 也會隨之失效 – 因爲我們在 DApp 的代碼中用“硬編碼”的方式指定了加密小貓的地址,如果這個根據地址找不到小貓,我們的殭屍也就喫不到小貓了,而按照前面的描述,我們卻沒法修改合約去應付這個變化!

因此,我們不能硬編碼,而要採用“函數”,以便於 DApp 的關鍵部分可以以參數形式修改。

比方說,我們不再一開始就把獵物地址給寫入代碼,而是寫個函數 setKittyContractAddress, 運行時再設定獵物的地址,這樣我們就可以隨時去鎖定新的獵物,也不用擔心加密小貓集體消失了。

第2章: Ownable Contracts

上一章中,您有沒有發現任何安全漏洞呢?

呀!setKittyContractAddress 可見性居然申明爲“外部的”(external),豈不是任何人都可以調用它! 也就是說,任何調用該函數的人都可以更改 CryptoKitties 合約的地址,使得其他人都沒法再運行我們的程序了。

我們確實是希望這個地址能夠在合約中修改,但我可沒說讓每個人去改它呀。

要對付這樣的情況,通常的做法是指定合約的“所有權” - 就是說,給它指定一個主人(沒錯,就是您),只有主人對它享有特權。

OpenZeppelin庫的Ownable 合約

下面是一個 Ownable 合約的例子: 來自 _ OpenZeppelin _ Solidity 庫的 Ownable 合約。 OpenZeppelin 是主打安保和社區審查的智能合約庫,您可以在自己的 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;
  }
}

下面有沒有您沒學過的東東?

  • 構造函數:function Ownable()是一個 _ constructor_ (構造函數),構造函數不是必須的,它與合約同名,構造函數一生中唯一的一次執行,就是在合約最初被創建的時候。
  • 函數修飾符:modifier onlyOwner()。 修飾符跟函數很類似,不過是用來修飾其他已有函數用的, 在其他語句執行前,爲它檢查下先驗條件。 在這個例子中,我們就可以寫個修飾符 onlyOwner 檢查下調用者,確保只有合約的主人才能運行本函數。我們下一章中會詳細講述修飾符,以及那個奇怪的_;。
  • indexed 關鍵字:別擔心,我們還用不到它。

所以Ownable 合約基本都會這麼幹:

  1. 合約創建,構造函數先行,將其 owner 設置爲msg.sender(其部署者)
  2. 爲它加上一個修飾符 onlyOwner,它會限制陌生人的訪問,將訪問某些函數的權限鎖定在 owner 上。
  3. 允許將合約所有權轉讓給他人。

onlyOwner 簡直人見人愛,大多數人開發自己的 Solidity DApps,都是從複製/粘貼 Ownable 開始的,從它再繼承出的子類,並在之上進行功能開發。

既然我們想把 setKittyContractAddress 限制爲 onlyOwner ,我們也要做同樣的事情。

第3章: onlyOwner 函數修飾符

現在我們有了個基本版的合約 ZombieFactory 了,它繼承自 Ownable 接口,我們也可以給 ZombieFeeding 加上 onlyOwner 函數修飾符。

這就是合約繼承的工作原理。記得:

ZombieFeeding 是個 ZombieFactory
ZombieFactory 是個 Ownable

因此 ZombieFeeding 也是個 Ownable, 並可以通過 Ownable 接口訪問父類中的函數/事件/修飾符。往後,ZombieFeeding 的繼承者合約們同樣也可以這麼延續下去。

函數修飾符

函數修飾符看起來跟函數沒什麼不同,不過關鍵字modifier 告訴編譯器,這是個modifier(修飾符),而不是個function(函數)。它不能像函數那樣被直接調用,只能被添加到函數定義的末尾,用以改變函數的行爲。

咱們仔細讀讀 onlyOwner:

/**
 * @dev 調用者不是‘主人’,就會拋出異常
 */
modifier onlyOwner() {
  require(msg.sender == owner);
  _;
}

onlyOwner 函數修飾符是這麼用的:

contract MyContract is Ownable {
  event LaughManiacally(string laughter);

  //注意! `onlyOwner`上場 :
  function likeABoss() external onlyOwner {
    LaughManiacally("Muahahahaha");
  }
}

注意 likeABoss 函數上的 onlyOwner 修飾符。 當你調用 likeABoss 時,首先執行 onlyOwner 中的代碼, 執行到 onlyOwner 中的 _; 語句時,程序再返回並執行 likeABoss 中的代碼。

可見,儘管函數修飾符也可以應用到各種場合,但最常見的還是放在函數執行之前添加快速的 require檢查。

因爲給函數添加了修飾符 onlyOwner,使得唯有合約的主人(也就是部署者)才能調用它。

注意:主人對合約享有的特權當然是正當的,不過也可能被惡意使用。比如,萬一,主人添加了個後門,允許他偷走別人的殭屍呢?
所以非常重要的是,部署在以太坊上的 DApp,並不能保證它真正做到去中心,你需要閱讀並理解它的源代碼,才能防止其中沒有被部署者惡意植入後門;作爲開發人員,如何做到既要給自己留下修復 bug 的餘地,又要儘量地放權給使用者,以便讓他們放心你,從而願意把數據放在你的 DApp 中,這確實需要個微妙的平衡。

第4章: Gas

厲害!現在我們懂了如何在禁止第三方修改我們的合約的同時,留個後門給咱們自己去修改。

讓我們來看另一種使得 Solidity 編程語言與衆不同的特徵:

Gas - 驅動以太坊DApps的能源

在 Solidity 中,你的用戶想要每次執行你的 DApp 都需要支付一定的 gas,gas 可以用以太幣購買,因此,用戶每次跑 DApp 都得花費以太幣。

一個 DApp 收取多少 gas 取決於功能邏輯的複雜程度。每個操作背後,都在計算完成這個操作所需要的計算資源,(比如,存儲數據就比做個加法運算貴得多), 一次操作所需要花費的 gas 等於這個操作背後的所有運算花銷的總和。

由於運行你的程序需要花費用戶的真金白銀,在以太坊中代碼的編程語言,比其他任何編程語言都更強調優化。同樣的功能,使用笨拙的代碼開發的程序,比起經過精巧優化的代碼來,運行花費更高,這顯然會給成千上萬的用戶帶來大量不必要的開銷。

爲什麼要用 gas 來驅動?

以太坊就像一個巨大、緩慢、但非常安全的電腦。當你運行一個程序的時候,網絡上的每一個節點都在進行相同的運算,以驗證它的輸出 —— 這就是所謂的“去中心化” 由於數以千計的節點同時在驗證着每個功能的運行,這可以確保它的數據不會被被監控,或者被刻意修改。

可能會有用戶用無限循環堵塞網絡,抑或用密集運算來佔用大量的網絡資源,爲了防止這種事情的發生,以太坊的創建者爲以太坊上的資源制定了價格,想要在以太坊上運算或者存儲,你需要先付費。

注意:如果你使用側鏈,倒是不一定需要付費,比如咱們在 Loom Network 上構建的 CryptoZombies 就免費。你不會想要在以太坊主網上玩兒“魔獸世界”吧? - 所需要的 gas 可能會買到你破產。但是你可以找個算法理念不同的側鏈來玩它。我們將在以後的課程中咱們會討論到,什麼樣的 DApp 應該部署在以太坊主鏈上,什麼又最好放在側鏈。

省 gas 的招數:結構封裝 (Struct packing)

在第1課中,我們提到除了基本版的 uint 外,還有其他變種 uint:uint8,uint16,uint32等。

通常情況下我們不會考慮使用 uint 變種,因爲無論如何定義 uint的大小,Solidity 爲它保留256位的存儲空間。例如,使用 uint8 而不是uint(uint256)不會爲你節省任何 gas。

除非,把 uint 綁定到 struct 裏面。

如果一個 struct 中有多個 uint,則儘可能使用較小的 uint, Solidity 會將這些 uint 打包在一起,從而佔用較少的存儲空間。例如:

struct NormalStruct {
  uint a;
  uint b;
  uint c;
}

struct MiniMe {
  uint32 a;
  uint32 b;
  uint c;
}

// 因爲使用了結構打包,`mini` 比 `normal` 佔用的空間更少
NormalStruct normal = NormalStruct(10, 20, 30);
MiniMe mini = MiniMe(10, 20, 30); 

所以,當 uint 定義在一個 struct 中的時候,儘量使用最小的整數子類型以節約空間。 並且把同樣類型的變量放一起(即在 struct 中將把變量按照類型依次放置),這樣 Solidity 可以將存儲空間最小化。例如,有兩個 struct:

uint c; uint32 a; uint32 b; 和 uint32 a; uint c; uint32 b;

前者比後者需要的gas更少,因爲前者把uint32放一起了。

第5章: 時間單位

level 屬性表示殭屍的級別。以後,在我們創建的戰鬥系統中,打勝仗的殭屍會逐漸升級並獲得更多的能力。

readyTime 稍微複雜點。我們希望增加一個“冷卻週期”,表示殭屍在兩次獵食或攻擊之之間必須等待的時間。如果沒有它,殭屍每天可能會攻擊和繁殖1,000次,這樣遊戲就太簡單了。

爲了記錄殭屍在下一次進擊前需要等待的時間,我們使用了 Solidity 的時間單位。

時間單位

Solidity 使用自己的本地時間單位。

變量 now 將返回當前的unix時間戳(自1970年1月1日以來經過的秒數)。我寫這句話時 unix 時間是 1515527488。

注意:Unix時間傳統用一個32位的整數進行存儲。這會導致“2038年”問題,當這個32位的unix時間戳不夠用,產生溢出,使用這個時間的遺留系統就麻煩了。所以,如果我們想讓我們的 DApp 跑夠20年,我們可以使用64位整數表示時間,但爲此我們的用戶又得支付更多的 gas。真是個兩難的設計啊!

Solidity 還包含秒(seconds),分鐘(minutes),小時(hours),天(days),周(weeks) 和 年(years) 等時間單位。它們都會轉換成對應的秒數放入 uint 中。所以 1分鐘 就是 60,1小時是 3600(60秒×60分鐘),1天是86400(24小時×60分鐘×60秒),以此類推。

注意要加s

下面是一些使用時間單位的實用案例:

uint lastUpdated;

// 將‘上次更新時間’ 設置爲 ‘現在’
function updateTimestamp() public {
  lastUpdated = now;
}

// 如果到上次`updateTimestamp` 超過5分鐘,返回 'true'
// 不到5分鐘返回 'false'
function fiveMinutesHavePassed() public view returns (bool) {
  return (now >= (lastUpdated + 5 minutes));
}

有了這些工具,我們可以爲殭屍設定“冷靜時間”功能。

第6章: 將結構體作爲參數傳入

由於結構體的存儲指針可以以參數的方式傳遞給一個 private 或 internal 的函數,因此結構體可以在多個函數之間相互傳遞。

遵循這樣的語法:

function _doStuff(Zombie storage _zombie) internal {
  // do stuff with _zombie
}

這樣我們可以將某殭屍的引用直接傳遞給一個函數,而不用是通過參數傳入殭屍ID後,函數再依據ID去查找。

第7章: 公有函數和安全性

現在來修改 feedAndMultiply ,實現冷卻週期。

回顧一下這個函數,前一課上我們將其可見性設置爲public。你必須仔細地檢查所有聲明爲 public 和 external的函數,一個個排除用戶濫用它們的可能,謹防安全漏洞。請記住,如果這些函數沒有類似 onlyOwner 這樣的函數修飾符,用戶能利用各種可能的參數去調用它們。

檢查完這個函數,用戶就可以直接調用這個它,並傳入他們所希望的 _targetDna 或 species 。打個遊戲還得遵循這麼多的規則,還能不能愉快地玩耍啊!

仔細觀察,這個函數只需被 feedOnKitty() 調用,因此,想要防止漏洞,最簡單的方法就是設其可見性爲 internal。

第8章: 進一步瞭解函數修飾符

相當不錯!我們的殭屍現在有了“冷卻定時器”功能。

接下來,我們將添加一些輔助方法。我們爲您創建了一個名爲 zombiehelper.sol 的新文件,並且將 zombiefeeding.sol 導入其中,這讓我們的代碼更整潔。

我們打算讓殭屍在達到一定水平後,獲得特殊能力。但是達到這個小目標,我們還需要學一學什麼是“函數修飾符”。

帶參數的函數修飾符

之前我們已經讀過一個簡單的函數修飾符了:onlyOwner。函數修飾符也可以帶參數。例如:

// 存儲用戶年齡的映射
mapping (uint => uint) public age;

// 限定用戶年齡的修飾符
modifier olderThan(uint _age, uint _userId) {
  require(age[_userId] >= _age);
  _;
}

// 必須年滿16週歲才允許開車 (至少在美國是這樣的).
// 我們可以用如下參數調用`olderThan` 修飾符:
function driveCar(uint _userId) public olderThan(16, _userId) {
  // 其餘的程序邏輯
}

看到了吧, olderThan 修飾符可以像函數一樣接收參數,是“宿主”函數 driveCar 把參數傳遞給它的修飾符的。

來,我們自己生產一個修飾符,通過傳入的level參數來限制殭屍使用某些特殊功能。

第9章: 利用 ‘View’ 函數節省 Gas

“view” 函數不花 “gas”

當玩家從外部調用一個view函數,是不需要支付一分 gas 的。

這是因爲 view 函數不會真正改變區塊鏈上的任何數據 - 它們只是讀取。因此用 view 標記一個函數,意味着告訴 web3.js,運行這個函數只需要查詢你的本地以太坊節點,而不需要在區塊鏈上創建一個事務(事務需要運行在每個節點上,因此花費 gas)。

稍後我們將介紹如何在自己的節點上設置 web3.js。但現在,你關鍵是要記住,在所能只讀的函數上標記上表示“只讀”的“external view 聲明,就能爲你的玩家減少在 DApp 中 gas 用量。

注意:如果一個 view 函數在另一個函數的內部被調用,而調用函數與 view 函數的不屬於同一個合約,也會產生調用成本。這是因爲如果主調函數在以太坊創建了一個事務,它仍然需要逐個節點去驗證。所以標記爲 view 的函數只有在外部調用時纔是免費的。

第10章: 存儲非常昂貴

Solidity 使用storage(存儲)是相當昂貴的,”寫入“操作尤其貴。

這是因爲,無論是寫入還是更改一段數據, 這都將永久性地寫入區塊鏈。”永久性“啊!需要在全球數千個節點的硬盤上存入這些數據,隨着區塊鏈的增長,拷貝份數更多,存儲量也就越大。這是需要成本的!

爲了降低成本,不到萬不得已,避免將數據寫入存儲。這也會導致效率低下的編程邏輯 - 比如每次調用一個函數,都需要在 memory(內存) 中重建一個數組,而不是簡單地將上次計算的數組給存儲下來以便快速查找。

在大多數編程語言中,遍歷大數據集合都是昂貴的。但是在 Solidity 中,使用一個標記了external view的函數,遍歷比 storage 要便宜太多,因爲 view 函數不會產生任何花銷。 (gas可是真金白銀啊!)。

我們將在下一章討論for循環,現在我們來看一下看如何如何在內存中聲明數組。

在內存中聲明數組

在數組後面加上 memory關鍵字, 表明這個數組是僅僅在內存中創建,不需要寫入外部存儲,並且在函數調用結束時它就解散了。與在程序結束時把數據保存進 storage 的做法相比,內存運算可以大大節省gas開銷 – 把這數組放在view裏用,完全不用花錢。

以下是申明一個內存數組的例子:

function getArray() external pure returns(uint[]) {
  // 初始化一個長度爲3的內存數組
  uint[] memory values = new uint[](3);
  // 賦值
  values.push(1);
  values.push(2);
  values.push(3);
  // 返回數組
  return values;
}

這個小例子展示了一些語法規則,下一章中,我們將通過一個實際用例,展示它和 for 循環結合的做法。

注意:內存數組 必須 用長度參數(在本例中爲3)創建。目前不支持 array.push()之類的方法調整數組大小,在未來的版本可能會支持長度修改。

第11章: For 循環

在之前的章節中,我們提到過,函數中使用的數組是運行時在內存中通過 for 循環實時構建,而不是預先建立在存儲中的。

爲什麼要這樣做呢?

爲了實現 getZombiesByOwner 函數,一種“無腦式”的解決方案是在 ZombieFactory 中存入”主人“和”殭屍軍團“的映射。

mapping (address => uint[]) public ownerToZombies

然後我們每次創建新殭屍時,執行 ownerToZombies [owner] .push(zombieId) 將其添加到主人的殭屍數組中。而 getZombiesByOwner 函數也非常簡單:

function getZombiesByOwner(address _owner) external view returns (uint[]) {
  return ownerToZombies[_owner];
}

這個做法有問題

做法倒是簡單。可是如果我們需要一個函數來把一頭殭屍轉移到另一個主人名下(我們一定會在後面的課程中實現的),又會發生什麼?

這個“換主”函數要做到:

1.將殭屍push到新主人的 ownerToZombies 數組中, 2.從舊主的 ownerToZombies 數組中移除殭屍, 3.將舊主殭屍數組中“換主殭屍”之後的的每頭殭屍都往前挪一位,把挪走“換主殭屍”後留下的“空槽”填上, 4.將數組長度減1。

但是第三步實在是太貴了!因爲每挪動一頭殭屍,我們都要執行一次寫操作。如果一個主人有20頭殭屍,而第一頭被挪走了,那爲了保持數組的順序,我們得做19個寫操作。

由於寫入存儲是 Solidity 中最費 gas 的操作之一,使得換主函數的每次調用都非常昂貴。更糟糕的是,每次調用的時候花費的 gas 都不同!具體還取決於用戶在原主軍團中的殭屍頭數,以及移走的殭屍所在的位置。以至於用戶都不知道應該支付多少 gas。

注意:當然,我們也可以把數組中最後一個殭屍往前挪來填補空槽,並將數組長度減少一。但這樣每做一筆交易,都會改變殭屍軍團的秩序。

由於從外部調用一個 view 函數是免費的,我們也可以在 getZombiesByOwner 函數中用一個for循環遍歷整個殭屍數組,把屬於某個主人的殭屍挑出來構建出殭屍數組。那麼我們的 transfer 函數將會便宜得多,因爲我們不需要挪動存儲裏的殭屍數組重新排序,總體上這個方法會更便宜,雖然有點反直覺。
使用 for 循環

for循環的語法在 Solidity 和 JavaScript 中類似。

來看一個創建偶數數組的例子:

function getEvens() pure external returns(uint[]) {
  uint[] memory evens = new uint[](5);
  // 在新數組中記錄序列號
  uint counter = 0;
  // 在循環從1迭代到10:
  for (uint i = 1; i <= 10; i++) {
    // 如果 `i` 是偶數...
    if (i % 2 == 0) {
      // 把它加入偶數數組
      evens[counter] = i;
      //索引加一, 指向下一個空的‘even’
      counter++;
    }
  }
  return evens;
}

這個函數將返回一個形爲 [2,4,6,8,10] 的數組。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章