Leptos – интересное пополнение в ряду веб-фреймворков для Rust. Помимо того, что Leptos может обеспечить быстрые обновления на стороне браузера через клиентскую часть WebAssembly, а также детализированные отклики на сигналы в ходе реактивной коммуникации, он ещё и чрезвычайно удобен для взаимодействия с серверными службами через изоморфные серверные функции. Таким образом, выполнять удаленные вызовы к API оказывается не сложнее, чем вызывать функции Rust. Именно благодаря интеграции с серверной частью Leptos так привлекателен для использования совместно со Spin. Если вам интересно, как это выглядит, или же вы хотите погоревать над весьма неказистым пользовательским интерфейсом, то читайте дальше.

Знакомство с Leptos


Если вы когда-либо писали сайты с использованием React, Dioxus или подобных фреймворков, то многие элементы интерфейса Leptos покажутся вам знакомыми: компоненты, написанные в виде функций, синтаксис угловых скобок и т. д. Но это не все достоинства Leptos.

Leptos характеризуется как «полностековый изоморфный веб-фреймворк Rust, использующий детализированный реактивный подход для создания декларативных пользовательских интерфейсов». Может показаться, что звучит слишком сложно, но разработчики отлично всё объясняют. Грубо говоря, Leptos включает в себя как фронтенд, так и бэкенд. Помимо умной оптимизации обновления фронтенда при изменении данных, Leptos позволяет записывать точки соприкосновения между фронтендом и бэкендом таким образом, чтобы решение выглядело единообразно по обоим краям стека.

Клиентский и серверный рендеринг


Существует два способа создания сайтов на Leptos: рендеринг на стороне клиента (сlient-side rendering, CSR) и рендеринг на стороне сервера (server-side rendering, SSR).

При рендеринге на стороне клиента вся ​​приложение, за исключением небольшого загрузочного файла, создается в виде модуля WebAssembly (Wasm), который выполняется в браузере. Этот модуль создает пользовательский интерфейс, обрабатывает события и обновляет страницу при поступлении событий или изменении данных. С точки зрения сервера, сайт с CSR – просто набор статических файлов: HTML-файл, файл Wasm, несколько CSS-файлов и JavaScript. Независимо от того, насколько динамичным является приложение, как только сервер доставил эти файлы клиенту – его работа завершена.

При рендеринге на стороне сервера Leptos всё равно выполняет в браузере Wasm-приложение, через которое управляет пользовательским интерфейсом и интерактивностью. Но также можно запускать серверные функции, к которым браузер может обратиться. Серверные функции изоморфны, то есть они имеют одинаковую «форму» как на стороне клиента, так и на стороне сервера. Например, предположим, что нужно сохранить результат взаимодействия в базе данных. Конечно, можно вручную создать API и написать код для его вызова. Но Leptos позволяет написать функцию Rust для сохранения данных и при этом вызывать эту функцию клиентской стороне. Благодаря некоторому вмешательству на уровне макросов одна и та же функция, собранная для сервера, превращается в API для записи в базу данных, а при сборке для клиента – в вызов этого серверного API.

Leptos и Spin


Второй случай, который мы здесь рассмотрим, интересен с точки зрения Spin. Конечно, Spin может выдавать приложения с CSR, поскольку они состоят из статических файлов, а Spin очень хорош при выдаче статических файлов. Но в случае с SSR, Spin не только динамически выдает страницы, но и выполняет функции обратного вызова. Таким образом, ваше приложение Leptos может пользоваться встроенными возможностями, такими как хранилище ключей-значений или логический вывод с применением ИИ.

Давайте посмотрим, как это выглядит.

На момент написания этой статьи интеграция Leptos-Spin находится на ранней экспериментальной стадии. Я поступлю как проще и покажу простое приложение.

Как освоить Leptos


Самый простой способ создать серверное приложение Leptos – при помощи инструмента Cargo-Leptos. Хотя он пока не собирает серверы Spin, в нём инкапсулировано всё необходимое для создания внешнего интерфейса. (Да, мы используем инструмент сборки на стороне сервера для сборки клиентской части). Установите его с помощью cargo install cargo-leptos.

Далее идет шаблон Spin для приложения Leptos, отвечающий за рендеринг на стороне сервера. Установите его, используя spin templates install --git https://github.com/fermyon/leptos-spin --upgrade.

Наконец, поскольку Leptos создает модуль Wasm для браузера, вам понадобится указать в качестве целевой платформы компиляции Rust для Wasm (браузерный), а также обычную цель для WASI. Установите всё это при помощи rustup target add wasm32-unknown-unknown.

Hello World или традиционный пример – счетчик


Шаблон Spin для приложений, отображаемых на стороне сервера, называется leptos-ssr. Запустите его следующим кодом:

spin new -t leptos-ssr leptos-test --accept-defaults
cd leptos-test

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

spin up –build

Если вы видите ошибки сборки, убедитесь, что у вас установлен cargo-leptos и что заданы цели Rust wasm32-wasi и wasm32-unknown-unknown. Если вы наблюдаете ошибки во время выполнения, убедитесь, что порт 3000 больше никем не прослушивается.

Для приложений Leptos SSR spin build выполняет две команды. Приведённый здесь синтаксис работает в Linux и Mac, но не в Windows. Если вы пользователь Windows, вам необходимо скопировать две команды (разделенные &&) из spin.toml и запустить их отдельно вручную.


В противном случае откройте ссылку на localhost:3000 в своем браузере, где вы должны увидеть традиционный «Hello, world» реактивных веб-фреймворков:


Нажмите кнопку несколько раз, чтобы увидеть, как растут значения на счетчике… но при этом следите за логами Spin. Вы увидите, как выводятся сообщения, указывающие, что кнопка не только обновляет пользовательский интерфейс, но и отправляет запросы обратно на сервер:

Saving value 1
Saving value 2
Saving value 3

Внутри приложения


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

├── Cargo.toml
├── README.md
├── spin.toml
├── src
│   ├── app.rs
│   ├── lib.rs
│   ├── main.rs
│   └── server.rs
└── style
    └── main.scss

Два основных файла, которые нас интересуют – это app.rs и server.rs. (lib.rs – это связующий код, а main.rs нужен только для того, чтобы cargo leptos build не сходила с ума.)

server.rs содержит фактический компонент Spin. Как и lib.rs, это в основном шаблон, и вам не нужно делать с ним слишком многого. Его основная задача – зарегистрировать некоторые данные, которые Leptos не может автоматически зарегистрировать в среде Wasm, после чего он передает библиотечной функции для выполнения фактической обработки. Главное, что вам нужно знать, это вот эта строка:

crate::app::SaveCount::register_explicit().unwrap();

Этот код касается тех функций сервера, о которых я упоминал ранее. Фреймворку Leptos необходима информация обо всех ваших серверных функциях, чтобы можно было сопоставить с ними маршруты API. В машинном коде это происходит автоматически благодаря магии Rust. К сожалению, библиотека, которую использует для этого Leptos, не работает с Wasm, поэтому нам приходится вручную вызывать register_explicit() для каждой такой функции. Именно поэтому, как правило, вообще приходится иметь дело с server.rs.

Файл app.rs более интересен. Здесь лежит логика приложения. (разумеется, для более крупных приложений предусматривается несколько таких файлов вместо одного app.rs. С точки зрения Rust и Leptos, в этом файле нет ничего особенного.

Точкой входа в приложение является компонент App Leptos. (Термин «компонент» многозначный. Все приложение образует единый компонент Spin. Этот компонент Spin содержит несколько компонентов Leptos, которые представляют собой функции с определенными сигнатурами.) App задаёт некоторый контекст, а затем передает его маршрутизатору:

#[component]
pub fn App() -> impl IntoView {
    provide_meta_context();

    view! {
        <Stylesheet id="leptos" href="/pkg/leptos_test.css"/>
        <Title text="Welcome to Leptos"/>
        <Router>
            <main>
                <Routes>
                    <Route path="" view=HomePage/>
                    <Route path="/*any" view=NotFound/>
                </Routes>
            </main>
        </Router>
    }
}

Мы почти справились. HomePage и NotFound также являются компонентами Leptos, находящиеся в файле app.rs. Вот HomePage, тот самый, который фактически отображает кнопку, нажимать на которую раньше было так приятно:

#[component]
fn HomePage() -> impl IntoView {
    let (count, set_count) = create_signal(0);
    let on_click = move |_| {
        set_count.update(|count| *count += 1);
        spawn_local(async move {
            save_count(count.get()).await.unwrap();
        });
    };

    view! {
        <h1>"Welcome to Leptos - served from Spin!"</h1>
        <button on:click=on_click>"Click Me: " {count}</button>
    }
}

Читая снизу вверх, видим, что эта функция возвращает HTML, определенный макросом view!. Этот HTML-код ссылается на пару вещей, определенных ранее в функции. Подпись к кнопке count является сигналом, который содержит значение и срабатывает, когда содержащееся в нём значение изменяется. Это позволяет объектам, наблюдающим за сигналом, реагировать на изменения и обновлять свои пользовательские интерфейсы. Событие нажатия на кнопку связано с on_click, замыканием, которое приращивает счётчик count и вызывает save_count с новым значением. (Там еще есть некоторая поддержка для асинхронной работы с помощью spawn_local; что помогает организовать асинхронность правильно.)

В результате имеем всё то, что уже было показано ранее: кнопка, которая отображает текущий счетчик, увеличивает счетчик при нажатии и сохраняет обновленный счетчик – save_count.

Но подождите! Счет сохраняется на сервере. Похоже, нам нужно покопаться в save_count.

Серверные функции и изоморфизм


on_click – это обработчик событий кнопки, поэтому он запускается в браузере. Но сохранение счетчика происходит на сервере. Как save_count передает данные на сервер?

#[server(SaveCount, "/api")]
pub async fn save_count(count: u32) -> Result<(), ServerFnError> {
    println!("Saving value {count}");
    let store = spin_sdk::key_value::Store::open_default()?;
    store.set_json("leptos_test_count", &count).map_err(|e| ServerFnError::ServerError(e.to_string()))?;
    Ok(())
}

Но погодите-ка, это код Spin SDK! Его невозможно запустить в браузере, все же там нет сетевого вызова. Каким-то образом в ходе вызова функции save_count мы перешли от клиента к серверу.

В этом и заключается магия изоморфизма – функции имеют одинаковую форму и на клиентской, и на стороне сервера. Макрос #[server] раскрывается по-разному, в зависимости от того, идёт ли сборка в браузере или на сервере. На сервере он сохраняет записанное содержимое, операторы print и вызовы Spin SDK и встраивает их в API для обработки маршаллинга и демаршаллинга HTTP-данных, в частности, JSON. На клиенте он отбрасывает тело функции и заменяет его кодом, который отправляет HTTP-запрос… к тому же API, в которое сервер был раскрыт. Поэтому, когда on_click вызывает браузерную сборку save_count, она вызывает серверную сборку save_count и код ключ-значение Spin выполняется. Но единый исходный код для клиента и сервера обеспечивает проверку типов на этапе компиляции как на клиенте, так и на сервере, и между ними.

Как создается приложение


Есть два файла, которые мы еще не обсудили. (Ладно, на самом деле их четыре.) По своей форме они тоже немного сложнее, чем стандартное приложение Spin.

Прежде всего, это spin.toml:

[[trigger.http]]
route = "/..."
component = "leptos-test"

[component.leptos-test]
source = "target/wasm32-wasi/release/leptos_test.wasm"
allowed_outbound_hosts = []
key_value_stores = ["default"]
[component.leptos-test.build]
command = "cargo leptos build --release && LEPTOS_OUTPUT_NAME=leptos_test cargo build --lib --target wasm32-wasi --release --no-default-features --features ssr"
watch = ["src/**/*.rs", "Cargo.toml"]

[[trigger.http]]
route = "/pkg/..."
component = "pkg"

[component.pkg]
source = { url = "https://github.com/fermyon/spin-fileserver/releases/download/v0.1.0/spin_static_fs.wasm", digest = "sha256:96c76d9af86420b39eb6cd7be5550e3cb5d4cc4de572ce0fd1f6a29471536cb4" }
files = [{ source = "target/site/pkg", destination = "/" }]

В отличие от стандартного шаблона Rust, здесь два компонента, а не один! Первый – это просто «программа на Wasm», которая содержит логику нашего пользовательского приложения. Но второй компонент обслуживает статические файлы из каталога target/site/pkg. Что же там находится?

target/site/pkg/
├── leptos_test.css
├── leptos_test.js
└── leptos_test.wasm


Откуда они взялись? Зачем еще один leptos_test.wasm?

Помните, как приложения Leptos компилируются в браузерный Wasm? Что ж, это по-прежнему так для приложений с рендерингом на стороне сервера. Начальные страницы могут быть отображены на сервере, но все обновления, вся интерактивность – это все Wasm. Также очевидно, почему здесь расширение .js: браузеру необходим JavaScript для загрузки Wasm и работы в качестве посредника с любыми данными, кроме примитивов, в частности, со строками. cargo leptos build создает эти файлы на стороне клиента. (Для удобства он также создает файл style/main.scss, который я упорно игнорировал, поскольку фронтенды меня пугают.) Spin выдает их по хорошо известному пути /pkg.

Команда cargo leptos build запускается в рамках spin build, но это не единственная ее часть. Командная строка в [component.leptos-test.build] содержит две команды: cargo leptos build для создания файлов на стороне клиента и cargo build --target wasm32-wasi --features ssr для создания приложения на стороне сервера. Обычно cargo leptos build собирает и клиентскую, и серверную часть, но на данный момент она еще не знает о Spin, поэтому на момент написания кода мы всё организуем в виде двух отдельных команд. Инфраструктура, контролирующая различные сборки для клиента и сервера, прописана в файле Cargo.toml через csr, hydrate и ssr а также указывает, как они распространяются на уровень крейтов Leptos.

На момент подготовки оригинала этой статьи в файле Cargo.toml есть ссылки на репозиторий git для крейтов Leptos вместо ссылок crates.io. Это исправление, которое добавлено в main, но пока не попало в релиз.

Что дальше?


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

Остается только одно. Запустите приложение еще раз и…


Что такое? Счетчик снова равен нулю! Но мы были настолько уверены, что сохранили его! Написали код и всё такое!

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



Возможно, захочется почитать и это:


Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале

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


  1. n0isy
    16.05.2024 20:53

    Мне непонятно. На реакте есть управление удалённой командой. Request/complete/error. Это позволяет нарисовать state в статусе progress. Показать спин. Мало ли что с сетью. Как это реализуется тут, когда к on click фактически прибита функция на сервере?


  1. n0isy
    16.05.2024 20:53

    Также много других вопросов: есть ли что-то типа websocket с двусторонний обменом? Error bounding? Ui на может нарисоваться только на ssr. Мы не знаем язык, разрешение и прочие фишки браузера. Как происходит дорисовка?

    Архитектура чем то похожа на 1с 8))


    1. domix32
      16.05.2024 20:53

      Там вроде client <> server по-умолчанию поверх ws работает.


  1. itmind
    16.05.2024 20:53

    Зачем нужен Spin?

    В документации Leptos он не указан (раздел 14. Part 2: Server Side Rendering) и SSR работает без Spin