В этом туториале мы подробно разберём, как именно происходит процесс разработки канистеров на Internet Computer. Мы пройдём полный путь от hello-world проекта, сгенерированного dfx автоматически до полноценного Todo-app с бекендом и фронтендом. Разберём какие файлы для чего нужны, какие команды использовать, как тестировать и дебажить приложение. Туториал расчитан на новичков в Internet Computer и блокчейн-сетях в целом, но мы ожидаем, что небольшой опыт Rust и React у читателя уже имеется. Полный код проекта из этого туториала можно найти здесь.


Вступление

Internet Computer (IC) - это немножко необычный блокчейн. Благодаря своим технологическим особенностям, он позволяет делать различные интересные штуки, которые раньше сделать было нельзя. Например, в Internet Computer нам не нужны базы данных, облачные виртуальные машинки, docker, балансировщики нагрузки и прочая инфраструктура, которая обычно необходима в любом проекте сложнее лендинг-страницы. Все данные можно довольно недорого хранить on-chain. Фронтенд можно раздавать прямо из смарт-контракта (канистера). Весь код и так компилируется в wasm, поэтому дополнительная виртуализация через docker излишня. А балансировка нагрузки производится автоматически самой сетью (это верно лишь отчасти - есть детали).

Другими словами, IC забирает на себя почти все инфраструктурные проблемы, оставляя нас разбираться лишь с бизнес-логикой.

Материала на русском языке по IC не так много, поэтому мы начнём с самой простой задачи - попробуем разработать полноценное Todo-веб-приложение и запустить его на локальной реплике сети (dfx). Если будет интерес, то мы выпустим и другие материалы практического толка по IC: поработаем с авторизацией через Internet Identity, попробуем горизонтальное масштабирование, применим криптографию для ограничения периметра нашего приложения и многое другое.

Помимо материалов направленных на практическую сторону разработки на Internet Computer, мы также ведём телеграм канал, где простыми словами рассказываем о теоретической составляющей этой технологии.

Ну что же, давайте приступим!

Инициализация и проверка проекта

Нам понадобятся:

Создаем новый проект:

dfx new --type=rust todo_app

Сгенерированный проект будет иметь следующую структуру файлов:

todo_app/
	src/
		todo_app_backend/
			<исходники бекенд-канистера на Rust>
		todo_app_frontend/
			<исходники фронтенд-канистера на JS>
	.env
	.gitignore
	Cargo.lock
	Cargo.toml
	dfx.json
	package-lock.json
	package.json
	README.md
	webpack.config.js

Другими словами, dfx сгенерирует для нас проект из двух канистеров, где все конфигурационные файлы будут лежать на самом верхнем уровне, а все исходники - в директории src. Запустим проект на этом этапе, чтобы убедиться, что мы точно установили все зависимости. Для этого нам нужно сделать две вещи: запустить локальную реплику и развернуть канистеры в ней. Для запуска реплики используем следующую команду:

todo_app$ dfx start --clean

Eсли видим вот такой output - значит, все в порядке:

Running dfx start for version 0.14.3
Using shared network 'local' defined in /home/username/.config/dfx/networks.json
Initialized replica.
Dashboard: <http://localhost:40115/_/dashboard>

Т.к. реплика занимает наш предыдущий терминал, пока не будет отключена, нам нужно открыть новый, чтобы продолжить. В новом окне терминала используем эту команду, чтобы развернуть канистеры:

todo_app$ dfx deploy

После этой команды терминал много чего напишет, потому что будет собирать исходники, логгируя все подряд, но в конце концов мы должны увидеть вот эти несколько строк:

Deployed canisters.
URLs:
  Frontend canister via browser
    todo_app_frontend: http://127.0.0.1:8080/?canisterId=bd3sg-teaaa-aaaaa-qaaba-cai
  Backend canister via Candid interface:
    todo_app_backend: http://127.0.0.1:8080/?canisterId=be2us-64aaa-aaaaa-qaabq-cai&id=bkyz2-fmaaa-aaaaa-qaaaq-cai

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

Если же мы перейдём по второй ссылке, то увидим страницу с автоматически-сгенерированным candid интерфейсом:

Этот веб-интерфейс очень удобно использовать для предварительной отладки бекенда. Вы можете думать, что это такой swagger, но для канистеров. Из него мы видим, что наш бекенд экспортирует только одну публичную query-функцию - greet(). Эта функция на вход принимает имя, а на выход выдает строку “Hello, <имя>!”:

Бекенд

Было

Это все, конечно, хорошо, но это не Todo-приложение, а просто “hello world”. Теперь нам нужно превратить его в Todo, и начнём мы с бекенда. Давайте для начала посмотрим на то, из чего он состоит:

todo_app_backend/
	src/
		lib.rs
	Cargo.toml
	todo_app_backend.did

Как видно из структуры, бекенд состоит всего из трёх файлов:

  • файл с исходным кодом канистера - lib.rs

  • файл описания проекта и его зависимостей - Cargo.toml

  • файл описания candid-интерфейса - todo_app_backend.did

Давайте заглянем в каждый из этих файлов, чтобы понять, как все это работает. Если вы уже имели дело с Rust, то заметите, что файл Cargo.toml не содержит ничего особенного:

# src/Cargo.toml

[package]
name = "todo_app_backend"
version = "0.1.0"
edition = "2021"

See more keys and their definitions at <https://doc.rust-lang.org/cargo/reference/manifest.html>

[lib]
crate-type = ["cdylib"]

[dependencies]
candid = "0.8"
ic-cdk = "0.7"
ic-cdk-timers = "0.1" # Feel free to remove this dependency if you don't need timers

Обычные описание пакета и списка зависимостей. Если вы также ранее компилировали Rust в wasm, то заметите знакомую строку crate-type = ["cdylib"], которая просто говорит компилятору, что на выходе мы хотим получить динамическую системную библиотеку, а не что-то иное. Канистеры на Internet Computer - это обычные wasm-модули; их можно разрабатывать на любом языке, который можно скомпилировать в Web Assembly. Поэтому в конфигурации и нет ничего необычного. Теперь давайте посмотрим на саму логику бекенда:

// src/lib.rs

#[ic_cdk::query]
fn greet(name: String) -> String {
  format!("Hello, {}!", name)
}

Как видим, в самом коде wasm-модуля тоже ничего необычного - только одна функция, которая берет на вход строку, а на выходе тоже выдает строку, но немного другую. Единственное новшество - эта функция аннотирована неким процедурным макросом ic_cdk::query, суть которого пока нам не ясна, но очень проста - на этапе компиляции он создает дополнительную функцию с именем формата __canister_query_<имя-функции> (которая внутри просто вызывает первую функцию), аннотированную макросом #[no_mangle], чтобы узлы исполняющие данный канистер, поняли, что эту функцию нужно исполнять в query-режиме.

В Internet Computer функции канистеров могут быть исполнены в двух режимах:

  • реплицируемом (update) - когда все узлы выполняют одну и ту же функцию и обновляют стейт канистера, в соответствии с алгоритмом консенсуса;

  • нереплицируемом (query) - когда только один случайно-выбранный узел выполняет эту функцию, но не обновляет стейт канистера.

update функции позволяют менять стейт, но долго работают (~2 секунды), query функции позволяют только читать стейт, но работают очень быстро (~20 миллисекунд).

И последний файл, который лежит в директории с бекендом выглядит так:

// src/todo_app_backend.did

service : {
  "greet": (text) -> (text) query;
}

Это файл на языке candid. Candid - это IDL, т.е. язык описания интерфейса (Interface Description Language). С помощью него мы описываем, какие функции данного канистера могут быть вызваны клиентами или другими канистерами и типы данных сигнатур этих функций. В данном случае, клиенты могут вызвать одну единственную функцию - greet, которой на вход нужно подать строку (text) и на выходе нужно тоже ожидать строку. Еще есть пометка, что это функцию следует вызывать в нереплицируемом (query) режиме.

Для людей незнакомых с другими IDL (например, protobuf) стоит объяснить, зачем он вообще нужен. Дело в том, что канистеры, как мы уже сказали выше, могут быть имплементированы на абсолютно разных языках программирования, которым нужно каким-то образом уметь общаться друг с другом. То же самое касается клиентского кода - он может быть написан на всем, что может посылать http-запросы и ему необходимо уметь кодировать запросы (и декодировать ответы) к канистерам так, чтобы последние их понимали. Candid - это как раз такой язык (и алгоритм кодирования/декодирования) для общения канистеров между собой и клиентов с канистерами. Помимо этого, candid-файлы с интерфейсами удобно использовать для того чтобы автоматически генерировать клиентский код, вместо того, чтобы писать его вручную. Мы увидим пример подобной генерации ниже.

Стало

Интерфейс

Теперь, когда мы разобрали анатомию простейшего бекенд-канистера, давайте имплементируем логику Todo-приложения. Наше приложение будет позволять делать следующие вещи:

  1. Создавать и удалять элементы todo-списка.

  2. Редактировать элементы todo-списка.

  3. Отображать все добавленные элементы todo-списка.

Хорошей практикой является начинать программировать с описания интерфейсов, поэтому мы тоже с этого и начнём:

// src/todo_app_backend.did

type Index = nat64;

type Element = record {
  title : text;
  status : Status;
};

type Status = variant {
  Todo;
  Done;
};

type Result = variant {
  Ok;
  IndexOutOfBounds;
};

service : {
  "add_element_at" : (Index, Element) -> (Result);
  "remove_element_at" : (Index) -> (Result);
  "update_element_at" : (Index, Element) -> (Result);

  "list_all" : () -> (vec Element) query;
}

Как мы видим, candid-файл стал немного сложнее: теперь он не только описывает функции, которые данный канистер предоставляет, но и типы данных, в которых происходит обмен информацией. Наш канистер будет предоставлять возможность вызывать 4 функции:

  1. add_element_at - вставляет новый элемент в заданную позицию todo-списка;

  2. remove_element_at - удаляет элемент из todo-списка по заданной позиции;

  3. update_element_at - заменяет элемент стоящий в определенной позиции todo-списка на переданный;

  4. list_all - возвращает весь todo-список целиком.

Элементы списка будут храниться в обычном массиве (вернее Vec<Element>). Сам же элемент списка - это простая запись (struct) с двумя полями: title - текст элемента и status - enum показывающий был ли элемент todo-списка помечен как выполненный. Некоторые функции возвращают Result после выполнения (Ok или ошибка IndexOutOfBounds). Вообще, в нашем случае, это не обязательно - мы всегда можем просто вызывать panic, когда что-то идёт не так, и передавать ошибку строкой, но в качестве упражнения давайте попробуем и из update-функций возвращать результат выполнения.

Логика канистера

Отлично, интерфейс описан, теперь можно приступить к имплементации функционала. Начнём с того, что просто перенесём интерфейсы в Rust, пока заменив логику функций заглушками:

// src/lib.rs

use candid::{CandidType, Deserialize};
use ic_cdk::{query, update};

type Index = usize;

#[derive(CandidType, Deserialize)]
struct Element {
  title: String,
  status: Status,
}

#[derive(CandidType, Deserialize)]
enum Status {
  Todo,
  Done,
}

#[derive(CandidType)]
enum Result {
  Ok,
  IndexOutOfBounds,
}

#[update]
fn add_element_at(idx: Index, elem: Element) -> Result {
  todo!()
}

#[update]
fn remove_element_at(idx: Index) -> Result {
  todo!()
}

#[update]
fn update_element_at(idx: Index, updated_elem: Element) -> Result {
  todo!()
}

#[query]
fn list_all() -> Vec<Element> {
  todo!()
}

Как видим, интерфейс переносится почти один-к-одному. Единственное отличие состоит в том, что вместо candid-типов мы используем встроенные типы Rust - например, вместо text мы используем String, а вместо nat64 мы используем usize или u64. record превратился в struct, а variant - в enum.

Еще мы помечаем пользовательские типы (Element, Status и Result) с помощью derive-макросов CandidType и Deserialize. Первым макросом нужно помечать все типы, которые наш канистер может отправить наружу, вторым - все типы которые канистер может получить извне. Другими словами, эти трейты отвечают за кодирование и декодирование данных в формат candid. Кстати, обратите внимание, что Rust имплементация candid написана на serde, поэтому чтобы использовать трейт Deserialize, нужно добавить в Cargo.toml следующую строку:

# src/Cargo.toml

...

[dependencies]

...

serde = "1.0"

Теперь определимся с тем, как мы будем хранить стейт (элементы todo-списка). Вообще, мы могли бы обойтись простой статической переменной, типа этой:

// src/lib.rs

static mut STATE: Option<Vec<Element>> = None;

Затем мы могли бы использовать эту переменную в наших функциях вот таким образом:

// src/lib.rs

#[update]
fn add_element_at(idx: Index, elem: Element) -> Result {
  unsafe { STATE.as_mut().unwrap().insert(idx, elem) }  // <- используем unsafe блок, чтобы получить возможность менять статическую переменную
  
  Result::Ok
}

Ничего страшного в таком подходе нет - канистеры все равно однопоточные и гарантированно исполняют все функции последовательно, поэтому никаких race-conditions или других подобных проблем, от которых нас защищает unsafe-блок — не будет. Однако, мало того, что такой подход считается анти-паттерном, но с ним также неудобно будет тестировать наш канистер (да, его можно тестировать обычными unit-тестами, ведь wasm-модуль - это просто библиотека), потому что unit-тесты в Rust по умолчанию запускаются в несколько потоков, а значит в тестах race-condition все-таки возникнет.

Правильным решением будет использовать какой-то синхронизационный примитив, например thread_local! и RefCell:

// src/lib.rs

thread_local! {
  static STATE: RefCell<Vec<Element>> = RefCell::new(Vec::new());
}

И использовать эту переменную можно с помощью хелпера .with():

// src/lib.rs

#[update]
fn add_element_at(idx: Index, elem: Element) -> Result {
  STATE.with(|it| it.borrow_mut().insert(idx, elem));
  
  Result::Ok
}

Таким образом мы теряем совсем немного производительности, зато взамен получаем решение, которое будет одинаково хорошо работать и в тестах, и в продакшене.

Итак, со стейтом определились. Теперь осталось только заменить todo!() на настоящую логику. Это довольно просто, поэтому мы не будем останавливаться на каждой функции - вот полный файл lib.rs:

// src/lib.rs

use std::cell::RefCell;
use candid::{CandidType, Deserialize};
use ic_cdk::{query, update};

type Index = usize;

#[derive(CandidType, Deserialize, Clone)]
struct Element {
  title: String,
  status: Status,
}

#[derive(CandidType, Deserialize, Clone, Copy)]
enum Status {
  Todo,
  Done,
}

#[derive(CandidType)]
enum Result {
  Ok,
  IndexOutOfBounds,
}

thread_local! {
  static STATE: RefCell<Vec<Element>> = RefCell::new(Vec::new());
}

#[update]
fn add_element_at(idx: Index, elem: Element) -> Result {
  STATE.with(|it| {
    let mut elements = it.borrow_mut();
    if elements.len() &lt; idx {
        return Result::IndexOutOfBounds;
    }

    elements.insert(idx, elem);

    Result::Ok
  })
}

#[update]
fn remove_element_at(idx: Index) -> Result {
  STATE.with(|it| {
    let mut elements = it.borrow_mut();
    if elements.len() &lt;= idx {
        return Result::IndexOutOfBounds;
    }

    elements.remove(idx);

    Result::Ok
  })
}

#[update]
fn update_element_at(idx: Index, updated_elem: Element) -> Result {
  STATE.with(|it| {
    let mut elements = it.borrow_mut();
    if elements.len() &lt;= idx {
        return Result::IndexOutOfBounds;
    }

    elements[idx] = updated_elem;

    Result::Ok
  })
}

#[query]
fn list_all() -> Vec<Element> {
  STATE.with(|it| it.borrow().clone())
}

Проверяем результат

Наш бекенд готов, давайте проверим его. Для этого нам достаточно всего лишь обновить wasm-модуль нашего канистера (да, в Internet Computer можно обновлять код смарт-контрактов) с помощью следующей команды:

todo_app$ dfx deploy todo_app_backend

Dfx перестроит wasm-модуль и, прежде чем обновлять код канистера, сравнит старый и новый candid-интерфейсы. Если они отличаются не обратно-совместимым образом (как в нашем случае), то dfx выведет следующее сообщение:

WARNING!
Candid interface compatibility check failed for canister 'todo_app_backend'.
You are making a BREAKING change. Other canisters or frontend clients relying on your canister may stop working.

Method greet is only in the expected type
Do you want to proceed? yes/No

Это нормально. Таким образом dfx защищает нас от изменений, которые могут сломать интеграции с другими сервисами и клиентским кодом. В нашем случае, мы переписали весь код с нуля, поэтому нам не очень важно это сообщение; можно смело отвечать yes.

После этого мы увидим уже знакомое нам сообщение о том, что канистеры развернуты и доступны в браузере:

Backend canister via Candid interface:
    todo_app_backend: http://127.0.0.1:8080/?canisterId=be2us-64aaa-aaaaa-qaabq-cai&id=bkyz2-fmaaa-aaaaa-qaaaq-cai

Давайте перейдем на страницу с автоматически-сгенерированным candid-интерфейсом и посмотрим, что изменилось:

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

Для пущей уверенности, давайте напишем unit-тест, который будет проверять всю логику, что мы реализовали:

// src/lib.rs

...

#[cfg(test)]
mod tests {

  // чтобы импорты работали, каждое импортируемое имя нужно пометить словом pub
  use crate::{add_element_at, list_all, remove_element_at, update_element_at, Element, Result, Status};

  #[test]
  fn works_fine() {
    // создаём 5 элементов

    let elem_1 = Element {
        title: "First".to_string(),
        status: Status::Todo,
    };
    let elem_2 = Element {
        title: "Second".to_string(),
        status: Status::Todo,
    };
    let elem_3 = Element {
        title: "Third".to_string(),
        status: Status::Todo,
    };
    let elem_4 = Element {
        title: "Forth".to_string(),
        status: Status::Todo,
    };
    let elem_5 = Element {
        title: "Fifth".to_string(),
        status: Status::Todo,
    };

    // вставляем их последовательно в список

    let res = add_element_at(0, elem_1.clone());
    assert!(matches!(res, Result::Ok));

    let res = add_element_at(1, elem_2.clone());
    assert!(matches!(res, Result::Ok));

    let res = add_element_at(2, elem_3.clone());
    assert!(matches!(res, Result::Ok));

    let res = add_element_at(3, elem_4.clone());
    assert!(matches!(res, Result::Ok));

    let res = add_element_at(4, elem_5.clone());
    assert!(matches!(res, Result::Ok));

    // проверяем, что все элементы на своих местах

    let list = list_all();

    assert_eq!(list.len(), 5);
    assert_eq!(list[0].title, elem_1.title);
    assert_eq!(list[1].title, elem_2.title);
    assert_eq!(list[2].title, elem_3.title);
    assert_eq!(list[3].title, elem_4.title);
    assert_eq!(list[4].title, elem_5.title);

    // удаляем четверный элемент

    let res = remove_element_at(3);
    assert!(matches!(res, Result::Ok));

    // проверяем, что остальные элементы на своих местах

    let list = list_all();

    assert_eq!(list.len(), 4);
    assert_eq!(list[0].title, elem_1.title);
    assert_eq!(list[1].title, elem_2.title);
    assert_eq!(list[2].title, elem_3.title);
    assert_eq!(list[3].title, elem_5.title);

    // вставляем новый элемент на место старого

    let res = add_element_at(3, elem_3.clone());
    assert!(matches!(res, Result::Ok));

    // обновляем элемент так, чтобы его содержимое соответствовало четвертому элементу

    let res = update_element_at(3, elem_4.clone());
    assert!(matches!(res, Result::Ok));

    // проверяем, что все элементы снова вставлены в список в оригинальном порядке

    let list = list_all();

    assert_eq!(list.len(), 5);
    assert_eq!(list[0].title, elem_1.title);
    assert_eq!(list[1].title, elem_2.title);
    assert_eq!(list[2].title, elem_3.title);
    assert_eq!(list[3].title, elem_4.title);
    assert_eq!(list[4].title, elem_5.title);
  }
}

Запустим тест с помощью cargo:

todo_app$ cargo test

Должны увидеть вот такой результат, который сообщает нам, что тест завершился успешно:

running 1 test
  test tests::works_fine ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Фронтенд

Было

Отлично, с бекендом покончено, давайте сделаем то же самое и с фронтендом. И начнём мы с осмотра того, что для нас сгенерировал dfx. Как видно из структуры файлов, фронтенд - это обычный javascript-проект, сборкой которого занимается webpack. Нет ни каких-то особенных плагинов в webpack-конфигурации, ни каких-то особенных зависимостей в package.json - все крайне обычное.

Магия происходит на другом уровне. Фронтенд-канистер (asset canister), на самом деле, это такой же Rust канистер, который просто умеет хранить файлы (статику) и отдавать их наружу по http-интерфейсу. Этот канистер поставляется уже в скомпилированном виде вместе с самим dfx, а исходники его можно посмотреть здесь. Т.е. наша задача, на самом деле, не в том, чтобы использовать какую-то специальную библиотеку или сборщик, а просто в том, чтобы собрать всю статику, что мы хотим раздавать, в одном месте, а затем загрузить её в этот канистер. Все это dfx делает за нас, потому что знает, что фронтенд-канистер - это особый канистер, с которым нужно работать особым образом. Нам остается только в package.json правильным образом описать build скрипт.

Знает он это благодаря файлу dfx.json, лежащему в корне проекта:

// dfx.json

{
  "canisters": {
    "todo_app_backend": {
      "candid": "src/todo_app_backend/todo_app_backend.did",
      "package": "todo_app_backend",
      "type": "rust"
    },
    "todo_app_frontend": {
      "dependencies": [
        "todo_app_backend"
      ],
      "frontend": {
        "entrypoint": "src/todo_app_frontend/src/index.html"
      },
      "source": [
        "src/todo_app_frontend/assets",
        "dist/todo_app_frontend/"
      ],
      "type": "assets"
    }
  },
  "defaults": {
    "build": {
      "args": "",
      "packtool": ""
    }
  },
  "output_env_file": ".env",
  "version": 1
}

Этот файл описывает какие вообще канистеры есть в проекте, какого они типа, из каких исходников их нужно собирать и в каком порядке. Другими словами, это такой аналог docker-compose.yml, но для канистеров.

Это все означает, что наш фронтенд вообще ни от чего не зависит. Мы можем смело удалить и файл с webpack-конфигурацией, и предыдущую версию исходного кода фронтенда, и вместо этого воспользоваться чем-то простым и понятным, типа create-react-app. Давайте так и поступим. Удаляем webpack-конфиг, node_modules, package.json, package-lock.json и папку src/todo_app_frontend целиком. Должна получиться вот такая структура файлов:

todo_app/
	src/
		todo_app_backend/
			...
	.env
	.gitignore
	Cargo.lock
	Cargo.toml
	dfx.json
	README.md

Теперь создадим todo_app_frontend заново, используя create-react-app:

todo_app/src$ npx create-react-app todo_app_frontend

Получаем следующую структуру файлов:

todo_app/
	src/
		todo_app_backend/
			...
		todo_app_frontend/
			...
	.env
	.gitignore
	Cargo.lock
	Cargo.toml
	dfx.json
	README.md

Т.к. package.json теперь находится внутри папки с фронтендом, а не на верхрнем уровне, и create-react-app по умолчанию собирает артифакты в папку build, а не в папку dist, нужно сказать об этом dfx, чтобы он знал, как собирать статику и где её потом искать:

// dfx.json

{
  ...
  
  "canisters": {
    ...
    
    "todo_app_frontend": {
      "dependencies": [
        "todo_app_backend"
      ],
      "frontend": {
        "entrypoint": "src/todo_app_frontend/public/index.html"
      },
      "source": [
        "src/todo_app_frontend/build"
      ],
      "type": "assets",
      "build": [
        "npm --prefix src/todo_app_frontend run build"
      ]
    }
  },

  ...
  
}

Проверим, что все работает:

todo_app$ dfx deploy todo_app_frontend

Теперь, при переходе на веб-страницу с фронтендом видим дефолтную страницу create-react-app:

Все отлично! Можно наконец приступать к имплементации веб-интерфейса нашего Todo-приложения.

Стало

Мы не будем описывать процесс верстки Todo-списка на React - в интернете полно обучающих материалов на эту тему. В результате должна получиться веб-страничка похожая на эту:

Вот код этой веб-странички на React:

Большеват, чтобы без спойлера показывать
// src/App.js

import './App.css';
import { createActor } from 'declarations/todo_app_backend';
import { useState, useEffect } from 'react';

const backend = createActor(process.env.REACT_APP_CANISTER_ID_TODO_APP_BACKEND);

function Element(props) {
  const [blocked, setBlocked] = useState(false);
  const [title, setTitle] = useState(props.title);

  useEffect(() => {
    setTitle(props.title);
  }, [props.title]);
  

  const handleCheck = () => {
    if (blocked) {
      return;
    }

    setBlocked(true);

    let newStatus;
    if (props.status.hasOwnProperty("Done")) {
      newStatus = { Todo: null };
    } else {
      newStatus = { Done: null };
    }

    backend
      .update_element_at(props.idx, { title: props.title, status: newStatus })
      .then(res => {
        if (!res.hasOwnProperty("Ok")) {
          throw new Error("Index out of bounds");
        }
    
        console.log("Successful element status change");
        setBlocked(false);
    
        props.fetchElems();
    });
  };
  

  const handleDelete = () => {
    if (blocked) {
      return;
    }
    setBlocked(true);
    
    backend.remove_element_at(props.idx).then(res => {
      if (!res.hasOwnProperty("Ok")) {
        throw new Error("Index out of bounds");
      }
    
      console.log("Successful element deletion");
    
      setBlocked(false);
    
      props.fetchElems();
    })
  };
  
  const handleChange = (event) => {
    setTitle(event.target.value);
  };
  
  const handleKeyPressed = (event) => {
    if (event.key !== 'Enter') {
      return;
    }
    
    if (blocked) {
      return;
    }
    
    setBlocked(true);
    
    const elem = { title, status: props.status };
    
    backend.update_element_at(props.idx, elem).then(res => {
      if (!res.hasOwnProperty("Ok")) {
        throw new Error("Index out of bounds");
      }
    
      console.log("Successful element update");
    
      setBlocked(false);
    
      props.deactivate();
      props.fetchElems();
    })
  };
  
  let text;
  if (props.isActive) {
    text = <input className='text' disabled={blocked} type={text} value={title} onKeyDown={handleKeyPressed} onChange={handleChange} />
  } else {
    text = <span className='text' onClick={props.onClick}>{title}</span>
  }
    
  return (
    <div className="Element">
      <input className='checkbox' type="checkbox" disabled={blocked} checked={props.status.hasOwnProperty("Done")} onClick={handleCheck} />
      {text}
      <button disabled={blocked} onClick={handleDelete}>-</button>
    </div>
  );
}

function CreateElementInput(props) {
  const [title, setTitle] = useState("");
  const [blocked, setBlocked] = useState(false);
  
  const handleChange = (event) => {
    setTitle(event.target.value);
  }

  const handleAdd = () => {
    if (blocked) {
      return;
    }

    setBlocked(true);

    const elem = { title, status: { Todo: null } };

    backend.add_element_at(props.len, elem).then(res => {
      if (!res.hasOwnProperty("Ok")) {
        throw new Error("Index out of bounds");
      }
    
      console.log("Successful adding of new element");
    
      setBlocked(false);
      setTitle("");
    
      props.fetchElems();
    });
  }
  
  const handleKeyPressed = (event) => {
    if (event.key !== 'Enter') {
      return;
    }

    handleAdd();
  }
  
  return (
    <div className='CreateElementInput'>
      <input placeholder='Добавить задачу' disabled={blocked} onKeyDown={handleKeyPressed} type="text" value={title} onChange={handleChange} />
      <button onClick={handleAdd} disabled={blocked}>+</button>
    </div>
  );
}

function App() {
  const [elements, setElements] = useState([]);
  const [activeElem, setActiveElem] = useState(null);
  
  const fetchElems = () => {
    backend.list_all().then(elems => {
      console.log("Fetched elements:", elems);
      
      setElements(elems);
    });
  };
  
  useEffect(fetchElems, []);
  
  const handleElemClick = (event, idx) => {
    event.stopPropagation();
    setActiveElem(idx);
  };
  
  const handleDeactivate = () => {
    setActiveElem(null);
  };
  
  const elems = elements.map((it, idx) => <Element key={idx} deactivate={handleDeactivate} onClick={ev => handleElemClick(ev, idx)} isActive={idx == activeElem} {...it} idx={idx} fetchElems={fetchElems} />);

  return (
    <div className="App">
      <h2>Список дел</h2>
      <CreateElementInput len={elements.length} fetchElems={fetchElems} />
      <div className='elements'>{elems}</div>
    </div>
  );
}

export default App;

Само фронтенд-приложение не отличается ничем особенным. Особенным является только взаимодействие с бекендом, которое происходит с помощью автоматически-сгенерированного клиентского кода. Как показано в самом начале этого сниппета, мы создаём некий actor-объект, с помощью идентификатора канистера, который записан в переменной окружения:

// src/App.js

import { createActor } from 'declarations/todo_app_backend';

const backend = createActor(process.env.REACT_APP_CANISTER_ID_TODO_APP_BACKEND);

При этом, мы импортируем функцию создания актора из некоего модуля declarations, о котором ранее ничего не было сказано.

declarations - это автоматически-сгенерированный из candid-интерфейса клиентский код, который облегчает взаимодействие с канистерами из фронтенда. Чтобы его сгенерировать, нужно запустить следующую команду:

todo_app$ dfx generate

После её выполнения, в директории todo_app/src/declarations вы обнаружите несколько новых файлов, с логикой описания интерфейсов и подключения нашего бекенд-канистера. Оттуда мы и импортируем функцию createActor. Этой функции на вход нужен только идентификатор бекенд-канистера, а на выход она даст специальный объект, который будет содержать все те же функции, что и наш канистер. Т.е. если наш канистер содержит 4 функции:

service : {
  "add_element_at" : (Index, Element) -> (Result);
  "remove_element_at" : (Index) -> (Result);
  "update_element_at" : (Index, Element) -> (Result);

  "list_all" : () -> (vec Element) query;
}

то и объект актора тоже будет содержать те же самые функции, вызвав которые, клиент вызовет соответствующую функцию на канистере. Говоря иначе, с помощью этого кода мы можем выполнять RPC вызовы на бекенд-канистере.

Однако ни модуль declarations, ни переменная окружения в нашем javascript коде не оказались сами по себе. Чтобы их туда добавить, необходимо сделать следующее:

  1. Создать symlink в node_modules, потому что create-react-app не позволяет импортировать код вне директории src.

  2. Добавить переменную окружения с идентификатором канистера, которую dfx положил в файл .env в корне проекта, в белый список переменных окружения create-react-app.

Обе эти вещи можно сделать с помощью пары изменений в package.json:

// package.json
{
  ...
  "dependencies": {
    ...
    "declarations": "file:../declarations",
    ...
  },
  ...
  "scripts": {
    ...
    "build": "REACT_APP_CANISTER_ID_TODO_APP_BACKEND=$CANISTER_ID_TODO_APP_BACKEND react-scripts build",
    ...
  },
  ...
}

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

Чтобы обновить код фронтенд-канистера, нужно запустить уже знакомую нам команду:

todo_app$ dfx deploy todo_app_frontend

Заключение

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

Комментарии (2)


  1. romeo555555
    22.08.2023 05:22

    Отличная статья. А что вас мотивирует делать подобный контент? И появится ли в тг канале чат?


    1. SeniorJoinu Автор
      22.08.2023 05:22

      Спасибо.

      > А что вас мотивирует делать подобный контент?
      Если вы о том, спонсируется ли этот контент Dfinity или кем-то еще - нет не спонсируется. В остальном, это просто такой способ собрать всю информацию из головы в одном месте.

      > И появится ли в тг канале чат?
      Пока мы не видим необходимости в этом. Если возникнет широкий запрос, это решение может быть пересмотрено.