簡介
智慧合約是現在區塊鏈的一大特色,而不同的鏈使用的智慧合約的虛擬機器各不相同,編碼語言也有很大差異。而今天我們開始學習EOS的智慧合約,我也是從EOS初期一直開發合約至今,期間踩過無數坑,也在Stack Overflow上提過問(最後自己解決了),在實際生產中也積累了很多經驗,所以我會連續幾周分多次分享合約開發的經驗,今天先來點基礎的。
一些C++的程式設計基礎
EOS就是使用C++開發的,這也為它帶來了諸多好處,而合約也沿用C++作為開發語言,雖然合約中無法直接使用Boost等框架(你可以自己引入,但這也意味著合約會很大,會佔用大量賬號的記憶體),但是我們還是可以使用很多C++的小型庫,並伴隨著eosio.cdt的發展,融入了更多實用的合約功能。
如果你之前沒有使用C系列的開發語言做過開發,比如:C語言、C++或者是C#,那麼你需要先學習下C語言的基本語法和資料結構,這裡我不做展開,在我們的系列文章的開篇就介紹了我推薦的Learn EOS - c/c++ 教程英文版,有一定英語基礎的朋友可以直接看這個,其他朋友也可以在網上找一些C++的入門教程看下。
如果你已經有了一定的C語言基礎,那麼寫合約的話,你會發現需要的基礎也並不多,依葫蘆畫瓢就能寫出各種基礎功能了,所以,你並不需要擔心太多語言上的門檻,畢竟合約只是一個特定環境下執行的程式,你能用到的東西並不會很多。
CDT選擇
EOS的早期版本進行合約開發還沒有CDT工具,那時的合約藉助的是原始碼中的工具eosiocpp,所以你看2018年的部落格,進行合約編譯都是用它,但你現在是見不到了。隨著官方CDT的迭代,在CDT的1.4版本開始被官方推薦使用,CDT後面也經歷了幾個大的版本更新,逐步改善合約編寫方式,更加趨於簡潔、直觀。
但是不同的CDT版本,也意味著編譯器的不同,所以合約開發也會有所區別,比如一些語法變了,一些庫名稱變了,增加了一些新的標註……
我們的教程側重還是介紹最新的語法,所以推薦使用1.6以上的版本。我也會盡量在後面的介紹中補充說明老的CDT的寫法,方便大家對照網上其他老部落格的合約。
來個HelloWorld
學習任何程式設計,我們都不能少了Mr.HelloWorld,先來給大家打個招呼吧。
#include <eosio/eosio.hpp> using namespace eosio; class [[eosio::contract]] hello : public contract { public: using contract::contract; [[eosio::action]] void hi(name user) { print("Hello, ", user); } };
基本合約結構及型別
hello合約就是一個最簡單的合約了,而且還有一個可呼叫的action為hi。我們首先還是來介紹下一個合約的程式結構吧。
- 程式頭
包含了引入的標頭檔案、庫檔案等,還有全域性的名稱空間的引入等。
#include <eosio/eosio.hpp> using namespace eosio;
這裡eosio庫是我們的合約基礎庫,所有和eos相關的型別和方法,都在這個庫裡面,而這個庫裡面eosio.hpp是基礎,包含了contract等的定義,所以所有的合約都要引入。
【CDT老版本】早期cdt版本中庫名稱不是eosio,而是eosiolib
預設的,我們引入了eosio的名稱空間,因為eosio的所有內容都是在這個名稱空間下的,所以我們全域性引入,會方便我們後續的程式碼編寫。
- 合約類定義
其實就是定義了一個class,繼承contract,並通過[[eosio::contract]]
標註這個類是一個合約。使用using引入contract也是為了後續程式碼可以更簡潔。
class [[eosio::contract]] hello : public contract{ public: using contract::contract; }
【CDT老版本】早期cdt版本中直接使用了
CONTRACT
來定義合約類,比如:CONTRACT hello: public contract {}
- action定義
寫一個public的方法,引數儘量用簡單或者是eosio內建的型別定義,無返回值(合約呼叫無法返回任何結果,除非報錯),然後在用[[eosio::action]]
標註這個方法是一個合約action就行。
注意:action的名稱要求符合name型別的規則,name規則請看下面的常用型別中的說明。
[[eosio::action]] void hi( name user ) { print( "Hello, ", user); }
因為合約無法除錯,所以只能通過print來列印資訊,或者直接通過斷言丟擲異常來進行除錯。
【CDT老版本】早期cdt版本中直接使用
ACTION
來定義方法,比如:ACTION hi( name user ){}
- 常用型別
型別 | 說明 | 示例 |
---|---|---|
name | 名稱型別,賬號名、表名、action名都是該型別,只能使用26個小寫字母和1到5的數字,特殊可以使用小數點,總長不超過13。 | name("hi") 或者 "hi"_n |
asset | 資產型別,Token都是使用該型別,包含了Token符號和小數位,是一個複合型別,字元形式為1.0000 EOS |
asset(10000, symbol("TADO", 4) 就是1.0000 TADO ) |
uint64_t | 無符號64位整型,主要資料型別,表主鍵、name實質都是改型別 | uint64_t amount = 10000000; |
- 內建常用物件或方法
在合約中,contract基類提供了一些方便的內建物件。
首先是get_self()
或者是_self
,這個方法可以獲取到當前合約所在的賬號,比如你把hello合約部署到了helloworld111這個賬號,那麼get_self()
就可以獲取到helloworld111。
然後是get_code()
或者是_code
,這個方法可以獲取到當前交易請求的action方法名,這個在進行內聯action呼叫時可以用於判斷入口action。
最後是get_datastream()
或者_ds
,這個方法獲取的是資料流,如果你使用的是複雜型別,或者是自定義型別,那麼你無法在方法的引數上直接獲取到反序列化的變數值,你必須自己通過資料流來解析。
常用的還有獲取當前時間current_time_point()
,這個需要引入#include <eosio/transaction.hpp>
。
資料持久化
當然,合約裡面,我們總會有些功能需要把資料存下來,在鏈上持久化儲存。所以我們就需要定義合約表了。
合約的表存在相應的合約賬號中,可以劃分表範圍(scope),每個表都有一個主鍵,uint64_t型別的,還可以有多個其他索引,表的查詢都是基於索引的。
這裡先提一句,表資料所佔用的記憶體,預設是合約賬號的記憶體,也可以使用其他賬號的,但需要許可權,這個以後我們再介紹。
我們擴充套件一下hello合約。
#include <eosio/eosio.hpp> #include <eosio/transaction.hpp> using namespace eosio; class [[eosio::contract]] hello : public contract { public: using contract::contract; hello(name receiver, name code, datastream<const char *> ds) : contract(receiver, code, ds), friend_table(get_self(), get_self().value) { } [[eosio::action]] void hi(name user) { print("Hello, ", user); uint32_t now = current_time_point().sec_since_epoch(); auto friend_itr = friend_table.find(user.value); if (friend_itr == friend_table.end()) { friend_table.emplace(get_self(), [&](auto &f) { f.friend_name = user; f.visit_time = now; }); } else { friend_table.modify(friend_itr, get_self(), [&](auto &f) { f.visit_time = now; }); } } [[eosio::action]] void nevermeet(name user) { print("Never see you again, ", user); auto friend_itr = friend_table.find(user.value); check(friend_itr != friend_table.end(), "I don't know who you are."); friend_table.erase(friend_itr); } private: struct [[eosio::table]] my_friend { name friend_name; uint64_t visit_time; uint64_t primary_key() const { return friend_name.value; } }; typedef eosio::multi_index<"friends"_n, my_friend> friends; friends friend_table; };
可以看到,我們已經擴充了不少東西了,包括建構函式,表定義,多索引表配置,並完善了原先的hi方法,增加了nevermeet方法。
我們現在模擬的是這樣一個使用場景,我們遇到一個朋友的時候,就會和他打招呼(呼叫hi),如果這個朋友是一個新朋友,就會插入一條記錄到我們的朋友表中,如果是一個老朋友了,我們就會更新這個朋友的記錄中的訪問時間。當我們決定不再見這個朋友了,就是絕交了(呼叫nevermeet),我們就會把這個朋友的記錄刪除。
- 表定義
首先我們需要宣告我們的朋友表。定義一個結構體,然後用[[eosio::table]]
標註這個結構體是一個合約表。在結構體裡定義一個函式名primary_key,返回uint64_t型別,作為主鍵的定義。
private: struct [[eosio::table]] my_friend { name friend_name; uint64_t visit_time; uint64_t primary_key() const { return friend_name.value; } };
我們這裡宣告瞭一個my_friend的表,合約的表名不在這裡定義,所以結構體的名稱不必滿足name的規則。我們定義了兩個欄位,friend_name(朋友的名稱)和visit_time(拜訪時間),主鍵我們直接使用了friend_name,這個欄位是name型別的,而name型別的實質就是一個uint64_t的型別(所以name的規則那麼苛刻)。
【CDT老版本】早期cdt版本中直接使用
TABLE
來定義合約表,比如:TABLE my_friend{}
- 多索引表配置
合約裡的表都是通過多索引來定義的,這是合約表的結構基礎。所以這裡才是定義表名和查詢索引的地方。
typedef eosio::multi_index<"friends"_n, my_friend> friends;
我們現在只介紹最簡單的單索引的定義,以後再介紹多索引的定義方式,這裡的"friends"_n
就是定義表名,所以使用了name型別,之後my_friend
是表的結構型別,typedef
實質上就是宣告瞭一個型別別名,名字是friends
的型別。
- 建構函式
建構函式這裡並不是必須,但是為了我們能在全域性直接使用合約表,所以我們要在建構函式進行表物件的例項化。
public: hello(name receiver, name code, datastream<const char *> ds) : contract(receiver, code, ds), friend_table(get_self(), get_self().value) { } private: friends friend_table;
這一段是標準合約建構函式,hello(name receiver, name code, datastream<const char *> ds) : contract(receiver, code, ds)
,合約型別例項化時會傳入receiver也就是我們的合約賬號(一般情況下),code就是我們的action名稱,ds就是資料流。
friend_table(get_self(), get_self().value)
這一段就是對我們定義的friend_table
變數的例項化,friend_table
變數就是我們定義的多索引表的friends
型別的例項。在合約裡我們就可以直接使用friend_table
變數來進行表操作了。例項化時傳遞的兩個引數正是表所在合約的名稱和表範圍(scope),這裡都使用的是當前合約的名稱。
- 查詢記錄
查詢有多種方式,也就是多索引表提供了多種查詢的方式,預設的,使用find
和get
方法是直接使用主鍵進行查詢,下次我們會介紹使用第二、第三等索引來進行查詢。find
返回的是指標,資料是否存在,需要通過判斷指標是否是指到了表末尾,如果等於表末尾,就說明資料不存在,否則,指標的值就是資料物件。get
直接返回的就是資料物件,所以在呼叫get
時,就必須傳遞資料不存在時的錯誤資訊。
auto friend_itr = friend_table.find(user.value); if (friend_itr == friend_table.end()) { //資料不存在 }else { //資料存在 }
我們在hi方法中先查詢了user是否存在。如果不存在,我們就新增資料,如果存在了,就修改資料中的visit_time欄位的值為當前時間。
- 新增記錄
多索引的表物件新增記錄使用emplace
方法,第一個引數就是記憶體使用的物件,第二個引數就是新增表物件時的委託方法。
uint32_t now = current_time_point().sec_since_epoch(); auto friend_itr = friend_table.find(user.value); if (friend_itr == friend_table.end()) { friend_table.emplace(get_self(), [&](auto &f) { f.friend_name = user; f.visit_time = now; }); } else { //資料存在 }
這裡先定義了一個變數now來表示當前時間,正是使用的內建方法current_time_point()
,這個還是用了它的sec_since_epoch()
方法,是為了直接獲取秒單位的值。
我們查詢後發現這個user的資料不存在,所以就進行插入操作,記憶體直接使用的合約賬號的,所以使用get_self()
,然後對錶資料物件進行賦值。
- 修改記錄
多索引的表物件修改記錄使用modify
方法,第一個引數是傳遞需要修改的資料指標,第二個引數是記憶體使用的物件,第二個引數就是表物件修改時的委託方法。
friend_table.modify(friend_itr, get_self(), [&](auto &f) { f.visit_time = now; });
我們將查詢到的使用者物件的指標friend_itr
傳入,然後記憶體還是使用合約賬號的,委託中,我們只修改visit_time
的值(主鍵是不能修改的)。
- 刪除記錄
- 多索引的表物件刪除記錄使用
erase
方法,只有一個引數,就是要刪除的物件指標,有返回值,是刪除資料後的指標偏移,也就是下一條資料的指標。
auto friend_itr = friend_table.find(user.value); check(friend_itr != friend_table.end(), "I don't know who you are."); friend_table.erase(friend_itr);
我們的示例中,將查詢到的這條資料直接刪除,併為使用變數來接收下一條資料的指標,在連續刪除資料時,你會需要獲取下一條資料的指標,因為已刪除的資料的指標已經失效了。
編譯
編譯我們再之前也有過介紹,安裝了eosio.cdt後,我們就有了eosio-cpp
命令,進入到合約資料夾中,直接執行以下命令就會在當前目錄生成wasm和abi檔案。
eosio-cpp -abigen hello.cpp -o hello.wasm
注意:替換命令中使用的hello.cpp為實際合約程式碼檔名,而hello.wasm為實際合約的wasm檔名。
當然,編譯不通過的時候,你就要看看錯誤是什麼了,這可能會考驗一下你的C++功底。
釋出
決定了要釋出的賬號後,記得要購買足夠的記憶體和抵押足夠的資源。合約的記憶體消耗我們可以大致這樣估算,看下編譯好了的合約wasm檔案有多大,然後乘以10,就是你釋出到鏈上大概所需的記憶體大小了。
釋出合約我們使用cleos set contract
命令,其後跟合約賬號名和合約目錄,為了方便,我建議你把合約的目錄名保持和合約檔名一致。
cleos set contract helloworld111 ./hello -p helloworld111
這裡我們給出的程式碼是將hello目錄下的hello合約釋出到helloworld111。我這裡的資料夾是hello,裡面的abi和wasm也都是hello,這樣你不用手動指定合約檔案了。
總結
至此,我想大家應該對合約的編寫有了一個大致的瞭解了,至少你可以參照著寫個簡單的合約出來了,這其中還有很多技巧和高階用法,我會在後續的文章中繼續和大家分享。