見出し画像

Dioxus を使って雑に Hacker News クローンを作ってみた

この記事は「株式会社メンバーズ Jamstack 研究会主催 Advent Calendar 2023」の2日目の記事です。


はじめに

Rust が好き(趣味程度)なフロントエンドエンジニアとして、今回は Dioxus という Rust 製のフレームワークを使って Hacker News の雑なクローンを作ってみたいと思います。

とりあえず動くレベルで使用感がわかればいい程度の目的で始めていますので雑な部分が多いですがご了承ください…!

コードは GitHub にありますので気になる人は見てみてください。

Dioxus とは

公式サイトトップでは次のように説明されています。

Dioxus is a Rust library for building apps that run on desktop, web, mobile, and more.

https://dioxuslabs.com/

Rust 製のデスクトップアプリや、Web アプリ、モバイルアプリを作れるライブラリです。色々なターゲットにコンパイルできますが、今回は Web アプリを作っていきたいと思います。

Dioxus is a React-inspired library for Rust focused on developer experience. Build fast, beautiful, and fully-featured apps for every platform in less time.

https://dioxuslabs.com/

React をインスパイアしているようで、ドキュメントをざっと見た感じ確かに React ぽく書けそうに見えたのと、Solid.js と同じくらい高速で React よりも堅牢と謳われたら使ってみたくなってしまいますよね。

環境構築

既に Rust そのものはインストールされている前提として話を進めていきます。
インストールしていない方は次のページを参考にインストールしてください。

はじめに、次のページを見ながら必要なツールのインストールとプロジェクトを作成します。

プロジェクトのビルドや最適化、開発サーバーの立ち上げなどを行ってくれる cli ツールをインストール。

$ cargo install dioxus-cli

Dioxus の Web アプリは WebAssembly を介して実行されるためビルドのターゲットを追加。

$ rustup target add wasm32-unknown-unknown

あとは通常のプロジェクトを作成する要領で cargo new していきます。

$ cargo new --bin dioxus-hacker-news
$ cd dioxus-hacker-news

そして Dioxus を使って Web アプリを作るのに必要なクレートを追加します。

$ cargo add dioxus
$ cargo add dioxus-web

参考ページそのままです。そして main.rs を次の内容に書き換え。

#![allow(non_snake_case)]
use dioxus::prelude::*;

fn main() {
    dioxus_web::launch(App);
}

fn App(cx: Scope) -> Element {
    cx.render(rsx! {
        div {
            "Hello, world!"
        }
    })
}

保存したら、次のコマンドを実行。

$ dx serve

するとターミナルに開発サーバーの URL が表示されるのでそこにアクセスし、ブラウザに Hello, world! が表示されていれば準備完了です。お疲れ様でした。

クローンを作る

一旦これで必要最低限のものは揃ったことになります。
書いたコード全てについて書き連ねていくのは大変なのでここでは大まかな要素について言及していきたいと思います。コードの全体像はリポジトリを参照してください。

Tailwind連携

公式ドキュメントに導入方法が記載されているのはありがたいですね。

適宜済んでいる手順は飛ばしていきましょう。Tailwind を使うので Node.js 環境が必要になります。ここでは Node.js をインストールする手順は解説しませんが、asdf や anyenv といったバージョン管理ツールや nix-shell を使って npm や yarn や pnpm を使える環境を作ってください。

環境ができたらプロジェクトルートで次のコマンドを実行して config ファイルの雛形を作ります。

npx tailwindcss init

公式サイトを参考に書き換え

module.exports = {
    mode: "all",
    content: [
        // src ディレクトリ内の .rs / .html / .css ファイルを対象にする
        "./src/**/*.{rs,html,css}",
        // ビルド時に生成される dist ディレクトリ内の .html ファイルを対象にする
        "./dist/**/*.html",
    ],
    theme: {
        extend: {},
    },
    plugins: [],
}

次に Tailwind のベースとなる input.css を作ることになっているのですが、自分は assets/css/global.css として作りました。
そしてプロジェクトルートに Dioxus.toml を作り、tailwind.css を読み込ませる設定をします。最終的に作ったものを転記すると次の通りです。

[application]

# App (Project) Name
name = "HackerNewsClone"

# Dioxus App Default Platform
# desktop, web, mobile, ssr
default_platform = "web"

# `build` & `serve` dist path
out_dir = "dist"

# resource (public) file folder
asset_dir = "public"

[web.app]

# HTML title tag content
title = "Hacker News Clone"

[web.watcher]

# when watcher trigger, regenerate the `index.html`
reload_html = true

# which files or dirs will be watcher monitoring
watch_path = ["src", "public"]

# uncomment line below if using Router
index_on_404 = true

# include `assets` in web platform
[web.resource]

# CSS style file
style = ["tailwind.css"]

# Javascript code file
script = []

[web.resource.dev]

# serve: [dev-server] only

# CSS style file
style = ["tailwind.css"]

# Javascript code file
script = []

あとは npx で tailwind を動かしてやればOKです。
公式ドキュメントでは

npx tailwindcss -i ./input.css -o ./public/tailwind.css --watch

となっていますが、public に出力しても dist ディレクトリにコピーされずスタイルがなかなか反映されなかったのと、dx serve --hot-reload も実行してあげたかったので npm script で楽をしたく、次のような package.json になりました。

{
  "name": "src",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "dev": "run-p dev:*",
    "dev:serve": "dx serve --hot-reload",
    "dev:css": "tailwindcss -i ./assets/css/global.css -o ./dist/tailwind.css --watch"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "npm-run-all": "^4.1.5",
    "tailwindcss": "^3.3.5"
  }
}

npm-run-all を使ってまとめてコマンドを実行しています。
これで npm run dev とか pnpm dev を実行すれば開発ができます。
そして最後に VSCode の設定を追加して .rs ファイル内でも Tailwind クラスの補完ができるようにします。

.vscode/settings.json を作って設定ごとリポジトリに入れちゃうのが好きなので settings.json を作って次のように設定を追記し Tailwind 連携はおしまいです。

{
    "tailwindCSS.experimental.classRegex": ["class: \"(.*)\""],
    "tailwindCSS.includeLanguages": {
        "rust": "html"
    },
}

Tailwind が使えるようになったのでぼちぼち作り始められそうです。

コンポーネント

Dioxus もコンポーネントベースで画面を組み立てていきます。どんな感じのコードになるか公式サイトから引用すると

// PartialEq は値のメモ化のために必要
#[derive(PartialEq, Props)]
struct LikesProps {
    score: i32,
}

fn Likes(cx: Scope<LikesProps>) -> Element {
    cx.render(rsx! {
        div {
            "This post has ",
            b { "{cx.props.score}" },
            " likes"
        }
    })
}

このようなコードになります。Rust ではありますが、なんとなく React の面影もあるような感じがあります。

1点注意があるとするなら、Props に PartialEq が derive を通して実装されていますが、これは値のメモ化のために必要で値が変化しない限り再レンダリングはされません。ただし、借用を用いた値を Props で受け取り使用する場合、コンポーネントは値のメモ化ができず常に再レンダリングするという挙動になるようです。

レンダリングにコストがかかるような UI の場合はこの点に注意する必要があるかもしれません。

Routing

Dioxus でも Routing を行うことができ、公式のクレートが提供されています。

上記サイトの Overview にあるものを引用すると

#![allow(non_snake_case)]

use dioxus::prelude::*;
use dioxus_router::prelude::*;
use std::str::FromStr;

#[rustfmt::skip]
#[derive(Clone, Debug, PartialEq, Routable)]
enum Route {
    #[nest("/blog")]
        #[layout(Blog)]
            #[route("/")]
            BlogList {},

            #[route("/:blog_id")]
            BlogPost { blog_id: usize },
        #[end_layout]
    #[end_nest]
    #[route("/")]
    Index {},
}


fn App(cx: Scope) -> Element {
    render! {
        Router::<Route> { }
    }
}

#[inline_props]
fn Index(cx: Scope) -> Element {
    render! {
        h1 { "Index" }
        Link {
            to: Route::BlogList {},
            "Go to the blog"
        }
    }
}

#[inline_props]
fn Blog(cx: Scope) -> Element {
    render! {
        h1 { "Blog" }
        Outlet::<Route> { }
    }
}

#[inline_props]
fn BlogList(cx: Scope) -> Element {
    render! {
        h2 { "List of blog posts" }
        Link {
            to: Route::BlogPost { blog_id: 0 },
            "Blog post 1"
        }
        Link {
            to: Route::BlogPost { blog_id: 1 },
            "Blog post 2"
        }
    }
}

#[inline_props]
fn BlogPost(cx: Scope, blog_id: usize) -> Element {
    render! {
        h2 { "Blog Post" }
    }
}

このようになり、derive を通して機能を実装した enum で Routing の設定をしていく形になります。layout やネストしたルートなんかにも対応してくれている感じとても助かります。

ちなみに今回の雑クローンのRoutingは次のようになりました。

#[rustfmt::skip]
#[derive(Clone, Routable)]
enum Route {
    #[layout(DefaultLayout)]
        #[route("/news")]
        News {},
        #[route("/")]
        Home {},
        #[route("/:..route")]
        NotFound {
            route: Vec<String>,
        },    
}

あまり多くのページは作ってないのでシンプルですが、最後に #[route("/:..route")] のように書くことでマッチしなかったルートをまとめてキャッチして 404 ページに飛ばすなんてこともできます。

Logger

フロントエンドでデバッグするなら console.log() を使うと思いますが、Dioxus では Logger を使うことで DevTools にログを吐き出せます。これも便利なクレートがあります。

このクレートは Log クレートに依存しているので追加するときはそちらも一緒に追加します。
導入方法については公式ドキュメントに従えば問題なく使えます。強いて注意点を挙げるなら Dioxus を 実行する前に Logger を実行しておく必要があるという点でしょうか。

fn main() {
    // Logger の実行を先に
    dioxus_logger::init(LevelFilter::max()).expect("failed to init logger");

    // アプリの実行
    dioxus_web::launch(App);
}

API

今回は Hacker News のクローンを作るということで Hacker News の API を使用します。

登録不要でレート制限もないのでありがたい API です。
雑クローンということで ベストストーリーと新着の2つの情報を取得して表示したいと思います。
ただし、この Hacker News API はストーリーの詳細がそのままエンドポイントから返却されるわけではなく、まずは一覧として次のようなストーリーの ID 配列を取得します。

//エンドポイント: https://hacker-news.firebaseio.com/v0/topstories.json

[ 9129911, 9129199, 9127761, 9128141, 9128264, 9127792, 9129248, 9127092, 9128367, ..., 9038733 ]

そして返ってきた ID をストーリー詳細取得の別のエンドポイントに投げ直してストーリーの情報を得るような手順を踏みます。
ストーリーの詳細エンドポイントからは次のようなレスポンスが返ってきます。

// エンドポイント: https://hacker-news.firebaseio.com/v0/item/8863.json
// 上記はストーリー ID 8863 のデータを取得する場合のエンドポイント
{
  "by" : "dhouston",
  "descendants" : 71,
  "id" : 8863,
  "kids" : [ 8952, 9224, 8917, 8884, 8887, 8943, 8869, 8958, 9005, 9671, 8940, 9067, 8908, 9055, 8865, 8881, 8872, 8873, 8955, 10403, 8903, 8928, 9125, 8998, 8901, 8902, 8907, 8894, 8878, 8870, 8980, 8934, 8876 ],
  "score" : 111,
  "time" : 1175714200,
  "title" : "My YC app: Dropbox - Throw away your USB drive",
  "type" : "story",
  "url" : "http://www.getdropbox.com/u/2/screencast.html"
}

このレスポンスで返ってくる json を Rust の型に変換する必要があるので serde を使って定義していきます。一旦次のように定義してみました。
src/types.rs

use serde::{Deserialize, Serialize};
use chrono::serde::ts_seconds;
use chrono::{DateTime, Utc};

pub type HackerNewsId = u32;

pub type HackerNewsItems = Vec<HackerNewsId>;

#[derive(Clone, Deserialize, Serialize, Debug, PartialEq)]
pub struct HackerNewsStory {
    pub by: String,
    pub descendants: u32,
    pub id: HackerNewsId,
    pub kids: Vec<HackerNewsId>,
    pub score: u32,
    #[serde(with = "ts_seconds")]
    pub time: DateTime<Utc>,
    pub title: String,
    pub r#type: String,
    pub url: String,
}

概ねシンプルですが、特筆する点があるとするなら time 部分です。API からは Unix Time が返ってくるため、chrono という時間を扱うクレートを使いタイムスタンプから DateTime 型に変換するように #[serde(with = "ts_seconds")] をつけています。

API リクエスト処理をまとめるために次のような構造体も作りました。
src/hacker_news_client.rs

use std::{time::Duration, future};

use reqwest::{self, Client};

use crate::types::{HackerNewsItems, HackerNewsId, HackerNewsStory};

use futures::future::join_all;

static API_BASE_URL: &str = "https://hacker-news.firebaseio.com/v0";

pub struct HackerNewsClient {
    client: Client,
}

impl HackerNewsClient {
    pub fn new() -> reqwest::Result<Self> {
        let client_builder = reqwest::ClientBuilder::new();
        let client = client_builder.build()?;

        Ok(Self { client })
    }

    pub async fn get_top_story_ids(&self) -> reqwest::Result<HackerNewsItems>{
        self.client.get(&format!("{}/topstories.json", API_BASE_URL)).send().await?.json::<HackerNewsItems>().await
    }

    pub async fn get_new_story_ids(&self) -> reqwest::Result<HackerNewsItems>{
        self.client.get(&format!("{}/newstories.json", API_BASE_URL)).send().await?.json::<HackerNewsItems>().await
    }

    pub async fn get_story(&self, id: HackerNewsId) -> reqwest::Result<HackerNewsStory> {
        self.client.get(&format!("{}/item/{}.json", API_BASE_URL, id)).send().await?.json::<HackerNewsStory>().await
    }

    pub async fn get_top_stories(&self) -> reqwest::Result<Vec<HackerNewsStory>> {
        let story_ids = &self.get_top_story_ids().await?[..10];

        let story_futures = story_ids[..usize::min(story_ids.len(), 10)].iter().map(|&story_id| self.get_story(story_id));

        let stories = join_all(story_futures)
            .await
            .into_iter()
            .filter_map(|story| story.ok())
            .collect();

        Ok(stories)
    }

    pub async fn get_new_stories(&self) -> reqwest::Result<Vec<HackerNewsStory>> {
        let story_ids = &self.get_new_story_ids().await?[..10];

        let story_futures = story_ids[..usize::min(story_ids.len(), 10)].iter().map(|&story_id| self.get_story(story_id));

        let stories = join_all(story_futures)
            .await
            .into_iter()
            .filter_map(|story| story.ok())
            .collect();

        Ok(stories)
    }

}

ID 一覧は全部取得しますが、ストーリー詳細は頭から10件だけ詳細を取るようにしています。取得件数がハードコードされているのは手抜きです。動けば良いのです。雑クローンなので…

そしてこれらの処理をコンポーネントでどう使うかというと、hook を使います。React インスパイアだけあって Dioxus にも hook がいくつかあり、非同期通信を扱うための hook も用意されています。

実際に使うとこのようになります。
src/page/home.rs

use dioxus::prelude::*;
use crate::hacker_news_client::HackerNewsClient;
use crate::components::story_list::StoryList;

pub fn Home(cx: Scope) -> Element {
    let stories = use_future!(
        cx,
        |()| async move {
            let client = HackerNewsClient::new().unwrap();
            client.get_top_stories().await
        }
    );

    match stories.value() {
        Some(Ok(list)) => {
            render!(rsx!( StoryList { items: list } ))
        }
        Some(Err(err)) => {
            render!(rsx! { div { "Err" } })
        }
        None => {
            render!(rsx! { div { "Loading..." } })
        }
    }
}

薄目で見れば React に見えるような感じです。Rust の match などもコンポーネント内で使えるので助かります。コンポーネントの出しわけがわかりやすく書ける気がします。

まとめ

今回は Rust 製のフレームワークである Dioxus を使って Hacker News の雑クローンを作ってみました。

React をはじめとするコンポーネントベースのフレームワークを使用したことがある人ならある程度実装時にあたりがつけやすいのかなという印象でした。

とはいえ GET しかしておらず、まだまだ使えていない機能が多くあります。今回で書き味の感覚は掴めた感じがしているので今後はもう少し込み入ったアプリも Dioxus を使って作ってみたいと思います。

Rust は所有権や、ライフタイムといった独特な概念がある言語であり、コンパイラくんが細かく口うるさいため、実行可能状態に持っていくのも大変という側面はありますが、同時にこのシステムがメモリ安全性を保証し、堅牢なシステムを作れるようにしてくれています。
何より早いし、動けば楽しい。最近は Linux のカーネル実装の一部にも Rust で書かれたものが取り込まれているようで、C/C++のようなシステムプログラミングのための言語であるというイメージもあると思いますが、Web 開発用のフレームワークも多くあります。

Web 領域でも Rust のメモリ安全性や実行速度の恩恵が受けられるし、シングルバイナリでデプロイ楽にできたりもするし、書くの楽しいからみんな Rust 書こう!🦀

#Jamstack #メンバーズ #Rust #Dioxus