Оптимизация для NEAR Storage On-Chain (уменьшение размера контракта)

9 min read
To Share and +4 nLEARNs

Примечание: мы оставляем вам несколько «вопросов», чтобы вы сами могли поэкспериментировать, и оценить каковы будут конечные результаты. 

Хранение в цепочке стоит дорого. На каждые 100 КБ блокируется 1 NEAR. Эти NEAR можно вернуть, если убрать используемое хранилище. Существует две основные причины использования хранилища:

  • Транзакции: каждая транзакция представляет собой квитанцию, которая сохраняется в цепочке и, следовательно, занимает некоторое хранилище. Каждая квитанция транзакции имеет разную длину (некоторые вызывают больше функций, следовательно, квитанция длиннее; другие вызывают только 1 функцию, поэтому квитанция короче), поэтому объем используемого хранилища различен. Точно так же, как вам нужно больше бумаги для печати длинной квитанции, вы используете больше места для хранения более длинной квитанции.
  • Развертывание смарт-контракта: Когда вы разворачиваете  смарт-контракт, он занимает размер смарт-контракта (он может немного отличаться от того, что вы видите в локальном файле wasm, но в целом различия невелики). Для транзакций, если вы не уменьшите количество транзакций, которые вы сделали, вы не сможете реально уменьшить объем пространства, занимаемый транзакциями.

(Вопрос: Можем ли мы удалить нашу учетную запись верхнего уровня через Near-Cli? Если мы удалим, освободит ли это стоимость хранения? (ps проверьте проводник после удаления дополнительной учетной записи.) Конечно, транзакции не будут удалены, следовательно будет ли он по-прежнему блокировать стоимость хранения, следовательно, никогда не может быть выпущен?)

Далее мы поговорим об оптимизации размера вашего смарт-контракта.

Оптимизация размера контракта

Постоянное хранение

У NEAR есть список коллекций, список находится здесь. Эти коллекции кэшируют данные, чтобы уменьшить плату за газ. Компромисс заключается в следующем: какую плату за газ вы хотите уменьшить? Какую стоимость хранения вы готовы заплатить за снижение платы за газ? Сколько уникальных постоянных хранилищ вам нужно?

Разница указана в приведенной выше ссылке (точные данные приведены ниже):

Важно иметь в виду, что при использовании std::collections (т. е. HashMap и т. д.) при каждой загрузке состояния все записи в структуре данных будут поочередно считываться из хранилища и десериализоваться. Это будет стоить дорого за любой нетривиальный объем данных, поэтому, чтобы свести к минимуму количество используемого газа, в большинстве случаев следует использовать коллекции SDK.

Рассмотрим этот пример: у нас есть основной контракт, в котором есть словарь/хеш/карта для связи ArticleId со статьей. Статья — это объект (в Rust он также называется Struct) со своими атрибутами.

use near_sdk::AccountId;
use near_sdk::collections::LookupMap;
use std::collections::HashMap;

pub struct Contract {
pub article_by_id: LookupMap<ArticleId, Article>,
}

pub struct Article {
pub owner_id: AccountId,
pub article_id: String,
pub royalty: HashMap<Account, u16>,
}
We see that Contract.article_by_id uses LookupMap and Article.royalty uses HashMap. We shall discuss why we don’t use other types.

Рассмотрим article_by_id, когда мы создаем статью, идентификатор будет уникальным и специфичным для этой статьи. Он будет храниться вечно. LookupMap кэширует результаты в памяти, поэтому нам не нужно «вычислять» (используя плату за газ) каждый раз, когда нам нужны результаты.

Как упоминалось ранее, поскольку все будет десериализовано при чтении при использовании HashMap, а LookupMap<AccountId, Article> не является тривиальным объемом данных (когда создается много статей), он должен кэшироваться в цепочке.

Итак, почему мы используем Lookup Map вместо Unordered_Map? Последний предлагает итерацию функциональности коллекции, которая нам не нужна. Если вам это нужно, используйте последний.

Затем для роялти мы используем HashMap. Дело в том, что у нас много статей, каждая со своей уникальной статьей.роялти. Для каждого уникального Article.royalty нам нужно создать новый ключ для сохранения хранилища.

Давайте проиллюстрируем тот же сценарий общего хранилища ключей. Скажем, мы создаем две статьи, Article A и Article B. Это их эквивалент Article.royalty.

// Values are percentage, 100.00% == 10_000.

// Article A
{
"alice.near": 1_000,
"bob.near": 500,
}

// Article B
{
"alice.near": 1_500,
"charlie.near": 2_000,
}
For some reason, there’s a repeat royalty for alice.near. If you use the same storage key, it will lead to error, complaining that: you already have an alice.near stored in Article A, and you are repeatingly storage another value to that key, and this is not possible. We also want them to be independent of each other; and same storage key used by 2 different HashMap means they share the same values.

(Вопрос: можете ли вы получить значения, хранящиеся в статье A, если вы инициализируете тот же ключ хранилища в статье B?)

Решение состоит в том, чтобы создать отдельный ключ хранилища для каждой коллекции. Однако, если у нас есть 1 миллион статей, нам потребуется 1 миллион различных ключей коллекции, чтобы хранить их отдельно. Это звучит глупо! Следовательно, имеет смысл хранить их скорее как HashMap. Кроме того, они тривиальны. Эти лицензионные платежи изначально предназначены для ограничения объема данных, которые они могут хранить, поэтому выборка данных невелика, а их десериализация обходится дешево. Это укрепляет наш выбор в пользу использования HashMap, а не эквивалентных коллекций SDK, несмотря на (немного) более высокое использование газа (что незначительно, поскольку коллекция настолько мала, что ею можно пренебречь).

В заключение, при разработке смарт-контракта выберите, использовать ли коллекции NEAR SDK или коллекции Rust, основываясь на тривиальности и количестве повторов, необходимых для одной и той же карты.

Сокращение кода

Первый код, который мы пишем, плохой. На самом деле они всего лишь наброски. Нам нужен рефакторинг, чтобы удалить ненужный код. Существует компромисс между более простым для понимания кодом и оптимизацией хранилища.

Например, возможно, у кого-то есть PayoutObject, и он используется только в одной функции.

use near_sdk::json_types::U128;
use near_sdk::AccountId;

pub struct Payout {
pub payout: HashMap<ArticleId, U128>,
}

impl Default for Payout {
fn default() -> Self {
Self [
payout: HashMap::new(),
]
}
}

Почему бы нам просто не определить

HashMap::new()

в конкретной функции, которая использует это? Конечно, если вы сделаете последнее, будет сложнее понять код. Первый упрощает понимание с точки зрения объектно-ориентированного подхода. Однако (значительно) больший объем кода приводит к увеличению объема памяти, используемой после компиляции в WASM. Итак, пришло время сделать некоторые компромиссы.

(Вопрос: сколько места вы сэкономите, если замените первое на второе? Если у вас есть подобный сценарий в вашей программе, попробуйте оптимизировать ее, написав меньше кода, и посмотрите, сколько места она скомпилировала, есть ли разница? (Иногда нет, иногда есть.Для тех, у кого нет, предпочтите сохранить читаемый код для более легкой отладки в будущем).

Wasm-Opt

После того, как вы скомпилировали оптимизированную версию выпуска, вы можете еще больше уменьшить размер контракта, используя wasm-opt. Для установки загрузите бинарный файл для вашей ОС здесь и разархивируйте его. Внутри есть папка «bin», из которой вы должны скопировать точный путь к этой папке и добавить его в свой путь к среде. После этого попробуйте вызвать wasm-opt из командной строки/терминала независимо от того, работает он или нет. Если нет, погуглите в интернете, как это решить (возможно, вы не добавили его в правильную переменную среды, возможно, ваш терминал уже открыт, и он не обновляет последний путь, это две самые распространенные проблемы).

Их запуск уменьшит размер файла:

#!/bin/bash
set -e

export WASM_NAME=tipping.wasm
z
mkdir res

RUSTFLAGS='-C link-arg=-s' cargo build --target wasm32-unknown-unknown --release
cp target/wasm32-unknown-unknown/release/$WASM_NAME ./res/
wasm-opt -Os -o res/output_s.wasm res/$WASM_NAME
ls res -lh

Здесь мы предполагаем, что исходный контракт скомпилирован в tipping.wasm (название Cargo.toml — tipping). Тогда оптимизированное имя — output_s.wasm. Затем мы запускаем ls (в Linux), чтобы проверить разницу в размере файла. Он должен быть меньше.

Примечание: вы также можете использовать -Oz для флагов, но оказалось, что это необязательно, поскольку для проекта, над которым вы работаете, это не приводит к уменьшению размера файла.

Важное примечание: RUSTFLAGS должен быть «link-arg=-s», если вы случайно измените его на «-z», у вас могут возникнуть большие проблемы. По крайней мере, для одного он генерирует wasm-файл гораздо большего размера. Вы должны поэкспериментировать с ним и проверить свой собственный проект.

Cargo.toml
Это обычные флаги

[profile.release]
codegen-units = 1
opt-level = "s"
lto = true
debug = false
panic = "abort"
overflow-checks = true

Вы также можете выбрать opt-level = «z», это может сгенерировать или не сгенерировать двоичный файл меньшего размера.

Другие маленькие хитрости

Избегайте форматирования строк формат! а to_string() может привести к раздуванию кода; поэтому используйте статическую строку (&str), когда это возможно. 

Удаление rlib, если не требуется 

Если вам не нужно проводить моделирование, удалите rlib.

Используйте сериализацию Borsh 

По возможности не используйте serde. Вот страница о том, как переопределить протокол сериализации. 

Избегайте стандартных утверждений Rust и макроса паники Они содержат информацию о возвращенной ошибке, что приводит к ненужному раздуванию. Вместо этого попробуйте следующие методы:

// Instead of this
assert_eq!(
contract_owner,
predecessor_account,
"ERR_NOT_OWNER"
);

// Try this (for all versions of near-sdk-rs)
if contract_owner != predecessor_account {
env::panic_str("ERR_NOT_OWNER")
}

// For near-sdk-rs v4.0 pre-release
use near_sdk::require;
require!(
contract_owner == predecessor_account,
"ERR_NOT_OWNER"
);

require! это легкий макрос, представленный в почти SDK-RS v4 (и это также один из любимых макросов), чтобы заменить assert! макрос. Это работает как утверждение! в основном, за исключением одного небольшого отличия: он не может указывать формат! как таковой.

// assert! can specify format
assert!(
some_conditions,
"{}: {}"
error_type,
error_message
);

// require! cannot
require!(
some_conditions,
format!( // you need format! yourself
"{}: {}",
error_type,
error_message
)
);

И, как мы упоминали ранее, чтобы избежать форматирования строк, лучше всего жестко закодировать сообщение. Конечно, если вам это действительно нужно, просто пожертвуйте несколькими байтами, чтобы использовать формат! все в порядке: он занимает незначительное место, если вы не используете его широко.

Не используйте .expect() 

Вместо этого используйте unwrap_or_else. Один написал вспомогательную функцию в ближайшем вспомогательном ящике, который вы, возможно, захотите проверить.

В противном случае вы всегда можете поместить это во internal.rs:

fn expect_lightweight(option: Option, message: &str) -> T {
option.unwrap_or_else(||
env::panic_str(message)
)
}

// instead of:
let owner_id = self.owner_by_id
.get(&token_id)
.expect("Token not found");

// use this:
let owner_id = expect_lightweight(
self.owner_by_id.get(&token_id),
"Token not found"
);

Избегайте паники

Вот некоторые распространенные ошибки при панике:

  • Индексирование фрагмента за пределами границ. мой_срез[я]
  • Деление нуля: делимое / 0
  • unwrap(): предпочитайте использовать unwrap_or или unwrap_or_else или другие более безопасные методы, чтобы не паниковать. В Near-sdk также есть env::panic_str (env::panic устарела) для паники, и они упомянули здесь, что это может быть предпочтительнее. Тем не менее, вы также можете использовать старомодное сопоставление для работы с вещами и посмотреть, работает ли оно лучше, чем panic_str; если нет, то используйте panic_str для облегчения понимания кода. В противном случае вы можете переключиться на матч, если оно того стоит.

Попробуйте реализовать обходной путь, чтобы он возвращал None или принудительно не паниковал при разработке контракта.

Подходы нижнего уровня

Перейдите по ссылке в справочнике, чтобы узнать о других способах уменьшения размера контракта, не упомянутых здесь. (Материалы, упомянутые здесь, также в основном здесь не упоминаются).

Имейте в виду, что этот пример больше не обновляется, поэтому вам нужно вручную получить последнее обновление.

Список здесь:

Aurora использует rjson в качестве облегченного контейнера для сериализации JSON. Он занимает меньше места, чем serde, в настоящее время упакованный с Rust SDK. Посмотрите этот пример,  читатель должен сам понять, как его использовать. Еще один вариант — ящик для мини-серий, пример здесь.

Инструмент Wasm-snip

Может быть полезно заменить функции, которые не используются, недоступной инструкцией. Обычно вам не нужно этого делать: только если вам действительно нужно сэкономить это пространство, вы можете пойти дальше.

Они упомянули, что этот инструмент также полезен для удаления инфраструктуры, вызывающей панику! 

Вы также можете запустить wasm-opt с флагом –dce после обрезки, чтобы эти отрезанные функции были удалены.

Заключение

Существует множество способов оптимизации контракта. Некоторые оптимизации выполняются легко без каких-либо изменений, другие имеют компромиссы, которые вы должны решить, стоит ли идти на компромиссы. В общем, если ваш контракт не слишком велик, что обычно является результатом написания слишком большого количества строк кода и который вам рекомендуется проверять на предмет необходимости написания кода; в противном случае должно быть достаточно простого использования, такого как wasm-opt и выбор постоянного хранилища.

NEAR SDK документация по уменьшению размера контракта

Уменьшение размера Wasm для Rust

9
Пролистать наверх