Zenn
🍜

【Shuttle/Axum/Yew】Rust製WebサイトでZennRSSを公開する

2025/03/21に公開

ほぼRustRustだけで自分のサイトを作る

そもそもZennZennには、ユーザー毎にRSSRSSがあるようです。https://zenn.dev/ユーザー名/feedで取得することができます。即ち私(ユーザー名:amenaruya)のRSSRSShttps://zenn.dev/amenaruya/feedです。

より詳しい仕様はこちらにあります。せっかくならばと、上のURLURL?all=1と続け、全てのRSSRSSを取得することにしました。

https://zenn.dev/zenn/articles/zenn-feed-rss

ところでRSSRSSを取得できるとはいうものの、XMLXML型式のデータが取得できるだけです。人間が見やすいものではありませんので、自分でWebWebサイトを作って見やすくまとめました。

述べるに先んじて、完成したサイトを示しおきます。

https://zenn-rss-9dwu.shuttle.app/

使用技術一覧

使った技術は実質RustRustだけとなりました。HTMLHTMLCSSCSSで画面を作った程度です。

ShuttleShuttle:公開

https://www.shuttle.dev/

WebWebサイトに限らず、インターネット上に何らかの「コンテンツ」を自力で構築・公開すること自体、迚も容易とは言い難いものです。そこで既存のサービスを活用して、面倒な作業を省略する方法が行われます。

ShuttleShuttleもその一つで、RustRustで記述されたWebWebアプリケーションを公開できるサービスです。

AxumAxum:バックエンド

https://github.com/tokio-rs/axum

AxumAxumは、WebWebアプリケーションの所謂「バックエンド」を実装するものの一つです。いくら華めく画面があろうとも、「バックエンド」が無ければただ其処には画面があるのみ。通信を主として、画面以外の様々な処理を担います。

必ずしもAxumAxumである必要はありません。とは言え個人的に慣れていることと、ShuttleShuttleにテンプレートがあること、この二点からAxumAxumを取り挙げています。

https://docs.shuttle.dev/examples/axum

YewYew:フロントエンド

https://yew.rs/ja/

AxumAxumで処理を実装したところで、それらが人に見えることはありません。人に見える画面は「フロントエンド」となります。

簡単に済ます場合、「フロントエンド」にはHTMLHTMLCSSCSSJavaScriptJavaScriptTypeScriptTypeScriptが使われます。しかし折角なので、これらもRustRustで実装することとしました。

YewYewは、そんなRustRustで「フロントエンド」を実装するものの一つです。

演行手順

本記事では、二手間に分けて実装しています。

YewYewAxumAxumはそれぞれ別者です。異なる二者の連携がサポートされている様子はないため、手作業でどうにかしています。これに絞った概説はこちら。

https://zenn.dev/amenaruya/articles/2da53729e574c7

一括する方法もあるのでしょうが、私は存ぜぬので悪しからず。

フロントエンドの実装

先ずはYewYewで画面を作ります。

パッケージ構築

rss_yewの名前でパッケージを作ります。

cargo new rss_yew

それぞれフォルダーとファイルを作成、編集します。

ファイル類
  • Cargo.toml
Cargo.toml
[package]
name = "rss_yew"
version = "0.1.0"
edition = "2024"

[dependencies]
gloo-net = "0.6.0"
rss = "2.0.12"
wasm-bindgen-futures = "0.4.50"
# cargo add yew --git https://github.com/yewstack/yew/ --features csr
yew = { git = "https://github.com/yewstack/yew/", version = "0.21.0", features = ["csr"] }

  • main.rs
src/main.rs
use gloo_net::http::Request;
use rss::{Channel, Item};
use wasm_bindgen_futures::spawn_local;
use yew::prelude::*;

const URL: &str = "/feed";

// RSSを app() → app_contents() と渡すための構造体
#[derive(Properties, PartialEq)]
struct AppContentsProps {
    content_items: Vec<Item>
}

#[function_component(App)]
fn app() -> Html {
    // 画面に埋め込む可変要素(state)
    let feed_items: UseStateHandle<Vec<Item>>  = use_state(|| {vec![]});
    {
        // 複製
        let feed_items: UseStateHandle<Vec<Item>> = feed_items.clone();
        // stateを変える処理
        use_effect_with(
            (),
            move |_| {
                let feed_items: UseStateHandle<Vec<Item>> = feed_items.clone();
                // 非同期処理(RSS取得の通信)
                spawn_local(
                    async move {
                        // HTTPのGETリクエストでRSSを取得する
                        let content: Vec<u8> = Request::get(URL).send().await.unwrap().binary().await.unwrap();
                        // RSS(バイナリー)をRSS(構造体)に変換する
                        let items: Vec<Item> = Channel::read_from(&content[..]).unwrap().into_items();
                        // stateに反映する
                        feed_items.set(items);
                    }
                );
                || {}
            }
        );
    }
    // HTML
    html!(
        <div class="container">
            // app_header()
            <AppHeader/>
            <div class="grid">
                // app_contents()
                <AppContents content_items={(*feed_items).clone()} />
            </div>
            // app_footer()
            <AppFooter/>
        </div>
    )
}

#[function_component(AppHeader)]
fn app_header() -> Html {
    html!(
        <header class="rss-header">
            <h1>{"📡 𝓩𝓮𝓷𝓷 𝓡𝓢𝓢"}</h1>
            <nav>
                // Zenn、GitHub、Xのリンク
                <a href="https://zenn.dev/amenaruya">{"𝒰𝓈ℯ𝓇 ℋℴ𝓂ℯ"}</a> {"|"} <a href="https://github.com/amenaruya">{"𝒢𝒾𝓉ℋ𝓊𝒷"}</a> {"|"} <a href="https://x.com/daikonkansatsu">{"𝕏"}</a> {"|"} <a href="https://amenaruya.github.io/Japanese_Kana_App/index.html">{"ℳ𝓎 𝒲ℯ𝒷"}</a>
            </nav>
        </header>
    )
}

#[function_component(AppContents)]
fn app_contents(
    // RSSを受け取る
    AppContentsProps {content_items}: &AppContentsProps
) -> Html {
    content_items
    .iter()
    .map(
        |content_item: &Item| {
            html!{
                <div class="card">
                    // サムネイル
                    <img src={content_item.enclosure.clone().unwrap().url} alt="記事画像"/>
                    <div class="card-content">
                        // タイトル
                        <h3>{content_item.title.clone().unwrap()}</h3>
                        // 日付
                        <p class="date">{content_item.pub_date.clone().unwrap()}</p>
                        // 冒頭
                        <p>{content_item.description.clone().unwrap()}</p>
                        // 記事へのリンク
                        <a href={content_item.link.clone().unwrap()}>{"記事を読む"}</a>
                    </div>
                </div>
            }
        }
    )
    .collect::<Html>()
}

#[function_component(AppFooter)]
fn app_footer() -> Html {
    html!(
        <footer>
            <p>{"𝓩𝓮𝓷𝓷 𝓡𝓢𝓢 | 𝒶𝓂ℯ𝓃𝒶𝓇𝓊𝓎𝒶"}</p>
            // 夷振
            <p>{"あめなるや おとたなはたの うなかせる たまのみすまる みすまるに"}</p>
            <p>{"あなたまはや みたに ふたわたらす あちすきたかひこねのかみそ"}</p>
            <nav>
                <a href="https://zenn.dev/amenaruya">{"𝒰𝓈ℯ𝓇 ℋℴ𝓂ℯ"}</a> {"|"} <a href="https://github.com/amenaruya">{"𝒢𝒾𝓉ℋ𝓊𝒷"}</a> {"|"} <a href="https://x.com/daikonkansatsu">{"𝕏"}</a> {"|"} <a href="https://amenaruya.github.io/Japanese_Kana_App/index.html">{"ℳ𝓎 𝒲ℯ𝒷"}</a>
            </nav>
        </footer>
    )
}

fn main() {
    // app()
    yew::Renderer::<App>::new().render();
}

  • index.html
static/index.html
<!DOCTYPE html>
<html lang="ja">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>RSS</title>
        <!-- Trunk使用時のCSS参照 -->
        <link data-trunk rel="css" href="styles.css">
        <!-- Trunk使用時のRust参照 -->
        <link data-trunk rel="rust" href="../Cargo.toml">
    </head>
    <body>
        <div class="container"></div>
    </body>
</html>
  • styles.css
static/styles.css
body {
    font-family: Arial, sans-serif;
    background-color: #f5f5f5;
    margin: 0;
    padding: 0;
    display: flex;
    flex-direction: column;
    align-items: center;
}
.rss-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 10px 20px;
    background: #fff;
    box-shadow: 0px 2px 4px rgba(0,0,0,0.1);
    border-radius: 12px;
    margin: 5px;
}
.rss-header nav a {
    margin: 0 10px;
    color: #007bff;
    text-decoration: none;
}
.rss-header nav a:hover {
    text-decoration: underline;
}
.container {
    width: 80%;
    max-width: 1200px;
    padding: 20px;
}
.grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
    gap: 20px;
}
.card {
    background: white;
    border-radius: 12px;
    box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
    overflow: hidden;
    transition: transform 0.2s ease-in-out;
}
.card:hover {
    transform: translateY(-5px);
}
.card img {
    width: 100%;
    height: 200px;
    object-fit: cover;
}
.card-content {
    padding: 15px;
}
.card h3 {
    margin: 0;
    font-size: 1.2em;
}
.card p {
    color: #666;
    font-size: 0.9em;
    margin-top: 8px;
}
.card .date {
    color: #999;
    font-size: 0.8em;
    margin-top: 5px;
}
.card a {
    display: inline-block;
    margin-top: 10px;
    color: #007bff;
    text-decoration: none;
}
.card a:hover {
    text-decoration: underline;
}
footer {
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    padding-top: 10px;
    padding-left: 5px;
    padding-right: 5px;
    background: #fff;
    box-shadow: 0px -2px 4px rgba(0,0,0,0.1);
    width: 100%;
    margin-top: 20px;
}
footer p {
    margin: 5px 0;
    font-size: 0.9em;
    color: #666;
}
footer nav {
    align-self: flex-end;
}
footer nav a {
    margin: 0 10px;
    color: #007bff;
    text-decoration: none;
}
footer nav a:hover {
    text-decoration: underline;
}
  • Trunk.toml

TrunkTrunkの設定を次の通り記述します。

Trunk.toml
[build]
# テンプレートとなるHTMLの位置
target      = "static/index.html"

[serve]
# IPアドレス
addresses   = ["127.0.0.1"]
# ポート番号
port        = 8080
# ブラウザー自動起動
open        = true

# [[proxy]]
# ZennへのURL
# backend     = "https://zenn.dev/amenaruya" # 単体で動作確認する場合
# リバースプロキシーへのURL(後述)
# backend     = "http://127.0.0.1:8000/feed" # リバースプロキシーで動作確認する場合

実行確認

一度、画面の実装を確認します。

Yewの前提

次の二つが済んでいることを前提とします。

rustup target add wasm32-unknown-unknown
cargo install --locked trunk

YewYewの動作確認はTrunkTrunkを使います。但し、Trunk.tomlのコメントを解除します。

Trunk.toml (zennへリクエストする場合)
[build]
target      = "static/index.html"

[serve]
addresses   = ["127.0.0.1"]
port        = 8080
open        = true

[[proxy]]
backend     = "https://zenn.dev/amenaruya"

後にも触れることですが、ZennZennからRSSRSSを取得する、つまり「外部APIAPIを使用する」場合、リバースプロキシーが必要になるようでした。TrunkTrunkを使う場合に於いては、TrunkTrunkの持つプロキシーを利用すれば済みます。

動作確認には次のコマンドを実行します。

trunk serve

生成物

distフォルダーと共に、HTMLHTMLCSSCSSJavaScriptJavaScriptWebAssemblyWebAssemblyが生成されます。このdistフォルダーを次に持ち込みます。

rss_yewの内容
PS C:\\rss_yew> tree /F
Folder PATH listing for volume OS
Volume serial number is ⋯
C:.
│   .gitignore
│   Cargo.lock
│   Cargo.toml
│   Trunk.toml
│
├───dist
│       index.html
│       rss_yew-976a95fd13bd18db.js
│       rss_yew-976a95fd13bd18db_bg.wasm
│       styles-a27c6295e687963d.css
│
├───src
│       main.rs
│
└───static
        index.html
        styles.css

バックエンドの実装

次に舞台を整えます。

ShuttleShuttleログイン

ShuttleShuttleを使うにはアカウントがなければなりません。私はGoogleGoogleでログインしました。アカウント登録自体は無料で行えます。

パッケージ構築

新たなパッケージをzenn-rssの名で作ります。

shuttle init --template axum
Shuttleパッケージの作成方法
  • 汎用コマンド
  1. パッケージ名入力
PS C:\> shuttle init
? Project name ›

ここではdummy1と入力します。

  1. フォルダー位置確認
PS C:\> shuttle init
✔ Project name · dummy1

Where should we create this project?
? Directory (C:\\dummy1)

位置を変える必要がなければそのまま決定します。

  1. テンプレートを選ぶ
PS C:\> shuttle init
✔ Project name · dummy1

Where should we create this project?
✔ Directory · C:\\dummy1

What type of project template would you like to start from?
❯ A Hello World app in a supported framework
  Browse our full library of templates

ここではAを選びます。

PS C:\> shuttle init
✔ Project name · dummy1

Where should we create this project?
✔ Directory · C:\\dummy1

What type of project template would you like to start from?
❯ A Hello World app in a supported framework
  Browse our full library of templates

? Select template ›
❯ Actix Web - Powerful and fast web framework
  Axum - Modular web framework from the Tokio ecosystem
  Bevy - Data driven game engine that compiles to WASM
  Loco - Batteries included web framework based on Axum
  Poem - Full-featured and easy-to-use web framework
  Poise - Discord Bot framework with good slash command support
  Rocket - Simple and easy-to-use web framework
  Salvo - Full-featured and easy-to-use web framework
  Serenity - Discord Bot framework
  Thruster - Web framework
  Tide - Web framework
  Tower - Modular service library
  Warp - Web framework
  No framework - An empty implementation of the Shuttle Service trait

ここではAxumを選びます。

PS C:\> shuttle init
✔ Project name · dummy1

Where should we create this project?
✔ Directory · C:\\dummy1

What type of project template would you like to start from?
❯ A Hello World app in a supported framework
  Browse our full library of templates

? Select template ›
  Actix Web - Powerful and fast web framework
❯ Axum - Modular web framework from the Tokio ecosystem
  Bevy - Data driven game engine that compiles to WASM
  Loco - Batteries included web framework based on Axum
  Poem - Full-featured and easy-to-use web framework
  Poise - Discord Bot framework with good slash command support
  Rocket - Simple and easy-to-use web framework
  Salvo - Full-featured and easy-to-use web framework
  Serenity - Discord Bot framework
  Thruster - Web framework
  Tide - Web framework
  Tower - Modular service library
  Warp - Web framework
  No framework - An empty implementation of the Shuttle Service trait

決定すると、パッケージが作られます。

  1. ShuttleShuttleprojectprojectとして登録するか選ぶ
PS C:\> shuttle init
✔ Project name · dummy1

Where should we create this project?
✔ Directory · C:\\dummy1

What type of project template would you like to start from?
❯ A Hello World app in a supported framework
  Browse our full library of templates

✔ Select template · Axum - Modular web framework from the Tokio ecosystem

Creating project "dummy1" in "C:\⋯\dummy1"

? Create a project on Shuttle with the name "dummy1"? (y/n)yes

そのまま決定すると、ShuttleShuttleprojectprojectとして登録されます。登録したくない場合はnを入力すれば済みます。

  • AxumAxumテンプレートを使う場合

初めからテンプレートを指定すると、テンプレート選択を省略できます。

  1. パッケージ名入力
PS C:\> shuttle init --template axum
? Project name ›

ここではdummy-axumと入力します。

  1. フォルダー位置確認
PS C:\> shuttle init --template axum
✔ Project name · dummy-axum

Where should we create this project?
? Directory (C:\\dummy-axum)
  1. ShuttleShuttleprojectprojectとして登録するか選ぶ
PS C:\> shuttle init --template axum
✔ Project name · dummy-axum

Where should we create this project?
✔ Directory · C:\\dummy-axum

Creating project "dummy-axum" in "C:\⋯\dummy-axum"

? Create a project on Shuttle with the name "dummy-axum"? (y/n)yes

ShuttleShuttleprojectprojectとして登録されたものは、consoleconsole(console.shuttle.dev)の画面に表示されます。無課金の場合、このprojectproject33つまでしか登録できないようです。

Shuttle console

作成されたzenn-rssフォルダーに、rss-yewからdistフォルダーをコピーします。

ファイル類
  • Cargo.toml
Cargo.toml
[package]
name = "zenn-rss"
version = "0.1.0"
edition = "2021"

[dependencies]
# cargo add axum --features http2
axum = { version = "0.8.1", features = ["http2"] }
http = "1.3.1"
reqwest = "0.12.15"
rss = "2.0.12"
shuttle-axum = "0.53.0"
shuttle-runtime = "0.53.0"
# cargo add tokio --features full
tokio = { version = "1.28.2", features = ["full"] }
# cargo add tower-http --features fs --features trace --features cors
tower-http = { version = "0.6.2", features = ["cors", "fs", "trace"] }

  • main.rs

サンプルを参考に構成します。

src/main.rs
use http::{header, HeaderValue};
use tower_http::{cors::CorsLayer, services::ServeDir};
use axum::{response::{IntoResponse, Redirect}, routing::get, Router};
use reqwest::Client;

#[shuttle_runtime::main]
async fn main() -> shuttle_axum::ShuttleAxum {
    const STATIC_DIR: &str = "./dist";
    let serve_dir: ServeDir = ServeDir::new(STATIC_DIR);
    let router: Router =
        Router::new()
        .route("/", get(root)) // https://zenn-rss-9dwu.shuttle.app/
        .route("/feed", get(fetch)) // https://zenn-rss-9dwu.shuttle.app/feed
        .nest_service("/dist", serve_dir) // https://zenn-rss-9dwu.shuttle.app/dist
        .layer(CorsLayer::permissive());

    Ok(router.into())
}

// /distにリダイレクトする
async fn root() -> Redirect {
    Redirect::permanent("/dist")
}

// リバースプロキシーの働きを再現する
async fn fetch() -> impl IntoResponse {
    // URL
    const URL: &str = "https://zenn.dev/amenaruya/feed?all=1";
    // client
    let client: Client = Client::new();
    // request
    let content: reqwest::Request = client.get(URL).build().unwrap();

    // body
    let body: axum::body::Bytes = client.execute(content).await.unwrap().bytes().await.unwrap();
    let head: [(http::HeaderName, HeaderValue); 1] = [(header::CONTENT_TYPE, HeaderValue::from_static("application/xml"))];
    let res: http::Response<axum::body::Body> = (head, body).into_response();
    res
}

  • index.html

各ファイルのリンクを編集します。./dist/~~のように、フォルダー名を明記しなければなりません。また、<script>部はTrunkTrunkのプロキシーに関するものですから、コメントアウトしても問題ありません。寧ろ消さずに置くと無駄なエラーが生じます。

dist/index.html 編集部抜粋
    <link rel="stylesheet" href="./dist/styles-a27c6295e687963d.css"
        integrity="sha384-8vBv26R9PluQSmbzT300zTdLMxE0sHKm/yQVgO8L84RfFO0LzebZcDYa81iHucEH" />
        import init, * as bindings from './dist/rss_yew-976a95fd13bd18db.js';
        const wasm = await init({ module_or_path: './dist/rss_yew-976a95fd13bd18db_bg.wasm' });
    <link rel="modulepreload" href="./dist/rss_yew-976a95fd13bd18db.js" crossorigin="anonymous"
        integrity="sha384-VhtA4N1HRlDXDUFUzUwSx6aMUwbEq4oLv7BYbAcbyde6INjVsQu1Rf+GGX86wo2X">
    <link rel="preload" href="./dist/rss_yew-976a95fd13bd18db_bg.wasm" crossorigin="anonymous"
        integrity="sha384-Fg3HlV2ikzNtSyFh4mAW+7iSRVn/60z59oRlqKzTBRAMI6yVzViwvx74GLEmhlph" as="fetch"
        type="application/wasm">

セキュリティー問題:外部APIAPICrossCross-OriginOrigin ResourceResource SharingSharing

本記事で作成したWebWebアプリケーションでは、外部APIAPIに相当するhttps://zenn.dev/ユーザー名/feedにアクセスします。しかし、どこからアクセスするかが問題となります。

本記事で作成したWebWebアプリケーションは「フロントエンド」、つまりブラウザーでAPIAPIにアクセスします。画面にRSSRSSを表示しようとしている以上、これは避けられません。しかしこれは危険を伴うものであり、実際にはブラウザーによって阻止されるため実行できません。

ブラウザーがインターネットから攻撃文を取得してしまった場合、その場で攻撃文が実行される等の虞があります。

https://developer.mozilla.org/ja/docs/Web/Security/Types_of_attacks

上図の通り、この問題への対処として、通信にはWebWebサーバーを牙保とする必要があります。本来アクセスする対象(ここではZennZenn)へ、WebWebサーバーが代わりにアクセスします。このように通信を代わる働きをするものは、リバースプロキシーと言えます。

これが、TrunkTrunkで動作確認する際にリバースプロキシーを使用した道理です。しかしShuttleShuttleではTrunkTrunkが使えません。つまり、リバースプロキシーに相当するものを実装する必要がありました。

ところで、AxumAxumでリバースプロキシーを実装することには失敗しました。HTTPHTTPHTTPSHTTPSの矛盾が解消できませんでした。そこで、固定のURLURLから取得したデータをそのまま流すことで妥協しました。

https://zenn.dev/ユーザー名/feedから返されるデータはXMLXMLであるため、XMLXMLを扱うこととなります。しかしそのような情報を探すこともできなかったため、苦心惨憺の末こちらのcratecrateを参考にしていたところ動きました。

https://github.com/LightQuantumArchive/axum-xml/blob/master/

動作確認

ShuttleShuttleで公開する前に、手元で動作確認するのが無難です。

shuttle run

公開

動作に問題なければ、ShuttleShuttleに公開します。少々時間を要します。

shuttle deploy

私の環境では、このように表示されました。

PC表示
PCPCでの表示

Android表示
AndroidAndroidでの表示

この記事の前にYewYewのチュートリアルを試す記事を公開しています。こうもチュートリアルに立ち返らざるを得ない攸まで追い込まれたのは、リバースプロキシーの問題に直面したためでした。結局リバースプロキシーを実装するには至らず、蟠りの解消されない感はあるものの、一応動作するものは作ることができました。結局どうすればよかったのでしょうか。

Discussion

ログインするとコメントできます