【Shuttle/Axum/Yew】Rust製WebサイトでZennRSSを公開する
だけで自分のサイトを作る
ほぼそもそもhttps://zenn.dev/ユーザー名/feed
で取得することができます。即ち私(ユーザー名:amenaruya
)のhttps://zenn.dev/amenaruya/feed
です。
より詳しい仕様はこちらにあります。せっかくならばと、上の?all=1
と続け、全ての
ところで
述べるに先んじて、完成したサイトを示しおきます。
使用技術一覧
使った技術は実質
:公開
:バックエンド
必ずしも
:フロントエンド
簡単に済ます場合、「フロントエンド」には
演行手順
本記事では、二手間に分けて実装しています。
一括する方法もあるのでしょうが、私は存ぜぬので悪しからず。
フロントエンドの実装
先ずは
パッケージ構築
rss_yew
の名前でパッケージを作ります。
cargo new rss_yew
それぞれフォルダーとファイルを作成、編集します。
ファイル類
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
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
<!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
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
[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
Trunk.toml
のコメントを解除します。
[build]
target = "static/index.html"
[serve]
addresses = ["127.0.0.1"]
port = 8080
open = true
[[proxy]]
backend = "https://zenn.dev/amenaruya"
後にも触れることですが、
動作確認には次のコマンドを実行します。
trunk serve
生成物
dist
フォルダーと共に、dist
フォルダーを次に持ち込みます。
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
バックエンドの実装
次に舞台を整えます。
ログイン
パッケージ構築
新たなパッケージをzenn-rss
の名で作ります。
shuttle init --template axum
Shuttleパッケージの作成方法
- 汎用コマンド
- パッケージ名入力
PS C:\⋯> shuttle init
? Project name ›
ここではdummy1
と入力します。
- フォルダー位置確認
PS C:\⋯> shuttle init
✔ Project name · dummy1
Where should we create this project?
? Directory (C:\⋯\dummy1) ›
位置を変える必要がなければそのまま決定します。
- テンプレートを選ぶ
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
決定すると、パッケージが作られます。
-
の として登録するか選ぶ
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
そのまま決定すると、n
を入力すれば済みます。
-
テンプレートを使う場合
初めからテンプレートを指定すると、テンプレート選択を省略できます。
- パッケージ名入力
PS C:\⋯> shuttle init --template axum
? Project name ›
ここではdummy-axum
と入力します。
- フォルダー位置確認
PS C:\⋯> shuttle init --template axum
✔ Project name · dummy-axum
Where should we create this project?
? Directory (C:\⋯\dummy-axum) ›
-
の として登録するか選ぶ
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
console.shuttle.dev
)の画面に表示されます。無課金の場合、この
作成されたzenn-rss
フォルダーに、rss-yew
からdist
フォルダーをコピーします。
ファイル類
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
サンプルを参考に構成します。
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>
部は
<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">
と -
セキュリティー問題:外部本記事で作成したhttps://zenn.dev/ユーザー名/feed
にアクセスします。しかし、どこからアクセスするかが問題となります。
本記事で作成した
ブラウザーがインターネットから攻撃文を取得してしまった場合、その場で攻撃文が実行される等の虞があります。
上図の通り、この問題への対処として、通信には
これが、
ところで、
https://zenn.dev/ユーザー名/feed
から返されるデータは
動作確認
shuttle run
公開
動作に問題なければ、
shuttle deploy
私の環境では、このように表示されました。
跋
この記事の前に
Discussion