Chapter 15: Solidityデザインパターン | Solidity Programming Essentialsを読む
イーサリアムの知識を整理するために2022年6月発売のSolidity Programming Essentials 2nd Editionを読み進める試みです。この記事ではChapter15「Solidity Design Patterns」を読み進めます。ついに最終章です。
読書ログは以下のスクラップで逐次更新していきます。
この章で扱われるトピックは以下の通りです。
- Understanding data entities in Solidity
- Nested versus reference fields in structs
- Different types of relationships between structs
- Performing CRUD operations in contracts
- Ownership in smart contracts
- Multiownership in contracts
- MultiSig contracts
- Pausable and stoppable smart contracts
エンティティモデリング
いわゆるリレーショナルデータベースといったデータストアでは、データの読み書きの際にいちいちコストが発生するということはありません。しかしイーサリアムではデータの読み出しや書き込みにその都度無視できないコストが発生します。
Solidityで言うデータモデリングとは特定のユースケースに対応するコントラクトのストレージ設計を決めることです。
グローバルストレージとメモリストレージ
イーサリアムにおける主なコストはグローバルストレージとメモリストレージに対するアクセスのコストです。ただしグローバルストレージのコストはメモリストレージの何倍にもなります。イーサリアムにおけるデータモデリングでは、グローバルとメモリを上手く使い分ける必要があります。
グローバルストレージのコストは、32バイトの初回の書き込みに20,000wei、同じ場所を更新するのに5,000weiかかります。またストレージの読み出しには32バイトあたり200weiかかります。例えば64バイト読み出したい場合は、200wei x 2で400weiかかることになります。
対して32バイトデータの読み取りと書き込みの両方にかかるメモリストレージのコストは2weiで、グローバルストレージと比べると格段に安くなります。
コストだけを考えるとグローバルストレージ内に保存されるデータは最小限に留めたいですが、コントラクト実行パフォーマンスや機能に影響を与えるほど小さいデータでは困ります。このバランスを取ることが開発者の重要な仕事です。
イーサリアムにおけるデータ型
ここで改めて以下のデータ型について考えてみましょう。
- Mappings
- Arrays
- Structs
Mappings
マッピングは他のプログラミング言語ではHashやMapと呼ばれる、key-valueのペアを保存するデータ型です。
他のプログラミング言語ではこのkey-valueのペアをループする仕組みが備わっていますが、Solidityではできません。valueからも探索する仕組みが必要な場合は、もう一つ別のマッピングを作成してインデックス代わりにすると良いでしょう。
なお、一つのマッピングには無制限にデータを格納することができます。
Arrays
Solidityでは固定サイズの配列だけでなく、動的な配列もサポートしています。マッピングに対する配列の利点は、配列をループさせて処理できる点でしょう。
ただし配列に格納さえている各データにキーが存在しているのであれば、マッピングを利用する方がコストが小さくなります。大量の要素を含む配列をループさせるとなると、大変なコストが発生する可能性があります。
Structs
複数のプロパティをひとまとめに扱いたい場合は構造体(struct
)を利用します。例えば従業員データを管理するとして、以下のような形で関連するプロパティを別々の変数に入れてしまうと扱いにくいわけです。
contract Employee {
string[] employeeId;
string[] firstName;
string[] lastName;
string[] emailAddress;
string[] phoneNumber;
}
以下のように構造体のマッピングか配列として管理すると便利です。
struct Employee {
string employeeId;
string firstName;
string lastName;
string emailAddress;
string phoneNumber;
}
mapping (string => Employee) employeesMapping;
Employee[] employees;
また、構造体をネストさせることもできます。以下のような住所構造体があるとして、
struct Address {
string streetName;
string city;
string state;
}
従業員構造体に含める場合は以下のように書きます。
struct employee {
string employeeId;
string firstName;
string lastName;
string emailAddress;
string phoneNumber;
Address employeeAddress;
}
ただしネストさせると従業員レコードが読み書きされるたびに、住所も連動して読み書きされることになります。アクセスするデータ量が増えてしまうというわけです。
この問題に対処する場合は参照構造を利用します。以下の例ではaddressID
というint256型の変数をキーにして、2つの構造体も紐付けています。これで一度にアクセスするデータ量は削減できました。
struct employee {
string employeeId;
string firstName;
string lastName;
string emailAddress;
string phoneNumber;
int256 addressReference;
}
struct Address {
int256 addressID;
string streetName;
string city;
string state;
string employeeId;
}
データ間のリレーションシップを考える
1対1の関連
1対1の関連では識別子を用いて相互にアクセスできるように設計します。コード例は以下の通りです。
struct Main {
address customerEtherAddress;
uint256 familyId;
}
struct Related {
uint256 familyId;
string[] childNames;
}
1対多の関連
Main構造体が複数のfamilyIdを保持できるようにすることで1対多の関連を実現しています。
struct Main {
address customerEtherAddress;
uint256[] familyId;
}
struct Related {
uint256 familyId;
string[] childNames;
}
多対多の関連
多対多では相互に配列で関連する識別子を持ち合います。
struct Main {
address customerEtherAddress;
uint256[] familyId;
}
struct Related {
uint256 familyId;
string[] childNames;
address[] mainAddresses;
}
データモデリング設計方針のまとめ
データは1回のステップで読み込めるように
複数の構造体からデータをまとめて読み込む場合、1回のステップで読み込めるようにまとめるようにしましょう。そうでない場合、アプリケーションはデータを取得するためにコントラクトを複数回呼び出す必要が発生してしまいます。
入れ子構造はほどほどに
入れ子構造の数が無限に増えると、1回の読み込みの際に芋づる式に必要の無いデータまで読み出してしまう可能性があります。あまりにも大量のデータを読み込む場合、ガス欠例外が発生する可能性もあります。
入れ子構造/参照構造はデータの更新頻度で考える
入れ子構造のデータが頻繁に更新される場合は、参照構造でなく入れ子構造にする方が理に適っています。頻繁に更新されないデータを扱う場合は参照構造にしましょう。
入れ子構造にすると構造変更は難しくなる
入れ子構造そのものに変更可能性があるのであれば、入れ子にしない方が賢明です。
包含関係に着目する
データに親子関係がある場合は、その関係性を表現できるようにデータモデリングする必要があります。
リレーションシップを持たせる
構造体間に1対多のような関係がある場合、ネストして持たせるより識別子をキーとした参照構造にしましょう。
データモデリングの実装
以下のようなデータ構造がある場合の実装を考えてみます。データアクセスを単純化するため、従業員IDと構造体データをマッピングしたデータと、従業員IDの配列を用意しておきます。
struct ContactAddress {
string city;
string state;
}
struct Employee {
address identifier;
bytes32 name;
uint8 age;
bytes32 email;
ContactAddress contact;
}
mapping (address => Employee) allEmployees;
address[] employeeReference;
従業員を追加する
allEmployees
マッピングとemployeeReference
配列それぞれにデータを追加します。
function AddEmployee(
address _identifier,
string memory _name,
uint8 _age,
string memory _email,
string memory _state,
string memory _city
) external returns (bool) {
ContactAddress memory contactadd = ContactAddress(_city, _state);
allemployees[_identifier].identifier = _identifier;
allemployees[_identifier].name = _name;
allemployees[_identifier].age = _age;
allemployees[_identifier].email = _email;
allemployees[_identifier].contact = contactadd;
employeeReference.push(_identifier);
}
指定した従業員レコードを読み出す
指定した従業員レコードを読み出す場合は引数に従業員IDを取ることで可能になります。
function GetAnEmployee(address _identifier)
external
returns (
string memory _name,
uint8 _age,
string memory _email,
string memory _city,
string memory _state
)
{
Employee memory temp = allEmployees[_identifier];
_name = temp.name;
_age = temp.age;
_email = temp.email;
ContactAddress memory addr = temp.contact;
_city = addr.city;
_state = addr.state;
}
従業員レコードを更新する
新しく従業員を追加する場合とほぼ同じコードになります。
function UpdateEmployee(
address _identifier,
string memory _name,
uint8 _age,
string memory _email,
string memory _state,
string memory _city
) external returns (bool) {
ContactAddress memory contactadd = ContactAddress(_city, _state);
allemployees[_identifier].identifier = _identifier;
allemployees[_identifier].name = _name;
allemployees[_identifier].age = _age;
allemployees[_identifier].email = _email;
allemployees[_identifier].contact = contactadd;
}
全ての従業員レコードを取得する
本当に全てのレコードを返そうとすると、大量にデータが登録されている場合に現実的ではありません。この例では指定された件数範囲でデータを返すようにしています。
function GetAllEmployee(uintstartRecord, uint256 endrecord)
external
returns (
string[] memory,
uint8[] memory,
string[] memory,
address[] memory,
string[] memory,
string[] memory
)
{
uint8[] memory _age = new uint8[](employeeReference.length);
string[] memory _name = new string[](employeeReference.length);
string[] memory _email = new string[](employeeReference.length);
address[] memory _identifier = new address[](employeeReference.length);
string[] memory _state = new string[](employeeReference.length);
string[] memory _city = new string[](employeeReference.length);
for (uint256 i = startRecord; i <= endrecord; i++) {
address addressinArray = employeeReference[i - 1];
_age[i - 1] = allemployees[addressinArray].age;
_name[i - 1] = allemployees[addressinArray].name;
_email[i - 1] = allemployees[addressinArray].email;
_identifier[i - 1] = allemployees[addressinArray](10).identifier;
_state[i - 1] = allemployees[addressinArray].contact.state;
_city[i - 1] = allemployees[addressinArray].contact.city;
}
return (_name, _age, _email, _identifier, _state, _city);
}
コントラクトの設計パターン
ここでは様々な設計パターンについて解説して行きます。
パターンの大部分はコントラクトの修飾子(modifier
)を利用したものとなるので、まずは修飾子のおさらいをしておきましょう。
Modifier
修飾子はmodifier
キーワードによって宣言します。
modifier onlyOwner {
}
修飾子には関数と同じようにコードを含めることができます。
modifier onlyOwner {
require(msg.sender == owner);
_;
}
利用する場合は可視性などの指定と同じように、関数の宣言部に修飾子を記述します。
function Deposit() public onlyOwner {}
修飾子には引数を取ることができます。
modifier validAmount(uint minAmount) {
require(msg.value > minAmount);
_;
}
関数で利用する場合は以下のようにします。
function Deposit() public validAmount(2 ether) {}
単一所有権の実装
関数呼び出しはコントラクトのオーナーしかできないという所有権の制約をつける場合、修飾子でその判定ができるようにすると便利です。以下の例では所有権を保持するownable
コントラクトと、onlyOwner
修飾子で関数実行前に所有権を確認するコードを示しています。
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
contract Ownable {
address owner;
constructor() public {
owner = msg.sender;
}
modifier onlyOwner() {
require(msg.sender == owner);
_;
}
}
contract MyICO is Ownable {
uint8 _amount;
function Withdraw(uint8 amount) public onlyOwner {
_amount = amount;
}
}
複数所有権の実装
複数のアカウントが所有権を持つ場合はもう少し複雑です。以下の例では所有権を管理するOwnable
コントラクトの所有権を持つアカウントと、関数の実行権限を持つアカウントの管理を分けています。
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
contract MultiOwnable {
mapping(address => bool) isOwner;
address owner;
address[] owners;
constructor() public {
owners.push(msg.sender);
isOwner[msg.sender] = true;
owner = msg.sender;
}
function addOwners(address additionalAddresses) public onlyOwner {
owners.push(additionalAddresses);
isOwner[additionalAddresses] = true;
}
modifier onlyOwner() {
require(msg.sender == owner);
_;
}
modifier checkOwners() {
require(isOwner[msg.sender] == true);
_;
}
}
contract MyICO is MultiOwnable {
uint8 _amount;
function Withdraw(uint8 amount) public checkOwners {
_amount = amount;
}
}
所有権の移転
所有権を移転したい場合もあるかも知れません。その場合、所有権を移転できるのは現在のオーナーに限るよう制約をつけると安全です。以下はその制約も含めたコード例を示しています。
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
contract Ownable {
address owner;
constructor() public {
owner = msg.sender;
}
modifier onlyOwner() {
require(msg.sender == owner);
_;
}
function transferOwnership(address newOwner) public onlyOwner {
owner = newOwner;
}
}
contract MyICO is Ownable {
uint8 _amount;
function Withdraw(uint8 amount) public onlyOwner {
_amount = amount;
}
}
複数の承認を必要とする処理(MultiSigパターン)
MultiSigとはあるトランザクションを実行する前に複数の承認や投票、署名が既に用意されていることを意味しています。
例えばビジネスの現場ではある取引に対して財務部門やCEOを含む複数のボードメンバーが文書に署名してはじめて合法と見なされるような状況があります。このような状況をコントラクトとして記述するのに、このMultiSigパターンは役に立ちます。
次のコード例では、有権者アカウントとして2つの静的なアドレスがコンストラクタで設定されています。もちろんこのデータは動的に持つことも可能です。
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
contract MultiSigContract {
address ownerOne;
address ownerTwo;
mapping(address => bool) vote;
constructor() public {
ownerOne = 0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;
ownerTwo = 0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb;
}
function Vote() public {
require(msg.sender == ownerOne || msg.sender == ownerTwo);
require(vote[msg.sender] == false);
vote[msg.sender] = true;
}
function Execute() public {
require(vote[ownerOne] == true && vote[ownerTwo] == true);
// ここに処理を書く
vote[ownerOne] = false;
vote[ownerTwo] = false;
}
}
Vote
関数を実行すると賛成票を投じたことになります。この関数は有権者しかアクセスができません。
Execute
関数は有権者全ての賛成が得られている場合のみ実行されます。実行後は投票が初期状態に戻されます。
コントラクト内における資産の所有権の移転
このパターンでは資産を保持しているアカウントかどうかのデータを保持しておき、保持している場合は所有権を移転できるコード例を示しています。
contract AssetBank is Ownable {
struct assetHolders {
bool hasAsset;
uint256 assetValue;
}
mapping(address => assetHolders) balances;
modifier legalOwner() {
require(balances[msg.sender].hasAsset == true);
_;
}
function transferAsset(address toAddress, uint256 amount)
external
legalOwner
returns (bool)
{
require(toAddress != address(0));
require(amount > 0);
require(balances[msg.sender].assetValue - amount < amount);
require(balances[toAddress].assetValue + amount > amount);
balances[msg.sender].assetValue = balances[msg.sender].assetValue - amount;
balances[toAddress].assetValue = balances[toAddress].assetValue + amount;
if (balances[msg.sender].assetValue <= 0) {
balances[msg.sender].hasAsset = false;
}
}
}
停止可能なコントラクトの実装(stoppable/haltableパターン)
コントラクトに深刻な不具合があり、かつハッカーの攻撃に晒されている場合、緊急でコントラクトの動作を停止させたい場合があるとします。
このとき停止可能な実装にしておけば、不測の事態が発生しても損失を最小限に抑えることができます。以下にそのコード例を示します。コントラクトの開始/停止はコントラクトのオーナーアカウントのみが行えるようにしています。
contract StoppableSample is Ownable {
bool isStopped;
constructor() public payable {
isStopped = false;
}
function stopActivities() external onlyOwner {
isStopped = true;
}
function startActivities() external onlyOwner {
isStopped = false;
}
modifier stoppable() {
require(isStopped == false);
_;
}
function withdraw(uint256 amount) external stoppable returns (bool) {
msg.sender.transfer(1 ether);
return true;
}
}
この章の内容は以上です。
Discussion