いろんなライブラリを触りたい。ナナオです。

今回はPythonのHTMLテンプレートエンジンで有名なJinjaに影響を受けたTeraを使って、簡単な動的サイトを作ってみたいと思います。

プロジェクトの作成

とりあえず実験用のプロジェクトを作成します。

cargo new rust-ssr-playground

必要なライブラリをインストールしてあげます。

今回はWebサーバーとしてAxum、HTMLのテンプレートエンジンとしてTeraを使用します。

[dependencies]
axum = "0.7.1"
serde = { version = "1.0.193", features = ["derive"] }
tera = "1.19.1"
tokio = { version = "1.34.0", features = ["full"] }

じゃあとりあえずaxumを起動できるところまで実装します。

use axum::{Router, routing::get};

#[tokio::main]
async fn main() {
    let app = Router::new().route("/", get(|| async {
        "hello world"
    }));
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

起動してアクセスしてみましょう。

ちゃんと起動しましたね。

次にプロジェクトにHTMLを追加してみましょう。

templatesというディレクトリを作成して、その中にindex.htmlを作成しましょう。

作成したHTMLは以下の通りです。

(Teraのテンプレート構文についてはこちらを参照してください)

hello {{ name }}!

nameの部分はTeraで入れるようにしました。

HTMLの準備ができたので、これをTeraで描画できるようにします。

use axum::{Router, response::Html, routing::get};
use tera::{Context, Tera};
use serde::Serialize;


#[derive(Serialize)]
struct Index {
    name: String
}

#[tokio::main]
async fn main() {
    let tera = match Tera::new("templates/**/*.html") {
        Ok(t) => t,
        Err(e) => {
            println!("Parsing error(s): {}", e);
            ::std::process::exit(1);
        }
    };

    let index = Index { name: String::from("test") };
    let page = tera.render("index.html", &Context::from_serialize(&index).unwrap()).unwrap();

    let app = Router::new().route("/", get(|| async move {
        Html(page.to_owned())
    }));
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

できました。nameにはtestと描画できるようにします。

では起動して確認してみましょう。

いいですね!これでTeraによる描画ができるところまで確認できました。

あとはAxumのwith_stateを使って各エンドポイントでteraを持ちまわれるようにします。

use axum::{Router, extract::State, response::Html, routing::get};
use tera::{Context, Tera};
use serde::Serialize;


#[derive(Serialize)]
struct Index {
    name: String
}

#[derive(Clone)]
struct ServiceState {
    tera: Tera,
}

async fn index(State(state): State<ServiceState>) -> Html<String> {
    let index = Index { name: String::from("test") };
    let page = state.tera.render("index.html", &Context::from_serialize(&index).unwrap()).unwrap();
    Html(page.to_owned())
}

#[tokio::main]
async fn main() {
    let tera = match Tera::new("templates/**/*.html") {
        Ok(t) => t,
        Err(e) => {
            println!("Parsing error(s): {}", e);
            ::std::process::exit(1);
        }
    };

    let app = Router::new()
        .route("/", get(index))
        .with_state(ServiceState { tera }); // teraを入れておく
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

TODOアプリの構築

とりあえず動かすというところはできたので、何か動くものを作ってみましょう。

postgresを使ったTODOアプリでも作ってみましょうか。

Postgresはdockerで起動できるようにdocker-compose-local-db.yamlを実装しておきます。

version: '3'

services:
  db:
    image: postgres:15
    container_name: postgres
    ports:
        - 5432:5432
    environment:
        - POSTGRES_PASSWORD=example

ついでに起動もしておきましょう。

docker compose -f docker-compose-local-db.yaml up -d

次にテーブル作成です。

とりあえずtodoテーブルを追加しておきます。

sqlx-cliがあれば以下のようにマイグレーションSQLを作成できます。

sqlx migrate add init

マイグレーション用SQLにtodoテーブルを追加します。

CREATE EXTENSION "uuid-ossp";

CREATE TABLE "todo" (
    id UUID primary key,
    description varchar(100) not null,
    deadline_at timestamp not null
);

はい、UUIDを使用するのとTODOの説明、あとはTODOの期日だけ設定できるようにしておきます。

ほいでsqlx-cliでマイグレーションを適用します。

sqlx migrate run --database-url "postgresql://postgres:example@localhost:5432/postgres"

これでDBの準備ができました。

実装側では、まず依存関係を追加します。

Postgresの操作には最近よく使っているSqlXを使用します。

[dependencies]
chrono = "0.4.31"
sqlx = { version = "0.7.2", features = ["runtime-tokio-native-tls", "postgres", "chrono", "uuid"] }
uuid = { version = "1.6.1", features = ["v4", "serde"] }

次にPostgresのコネクションプールをServiceStateの中に入れておきましょう。

#[derive(Clone)]
struct ServiceState {
    tera: Tera,
    pool: PgPool, // これを追加
}

// ...中略...

#[tokio::main]
async fn main() {
    // コネクションプールの初期化処理
    let pool = PgPool::connect("postgresql://postgres:example@localhost:5432/postgres").await.unwrap();

    // ...中略...

    let app = Router::new()
        .route("/", get(index))
        .with_state(ServiceState { tera, pool }); // ここにもpoolを追加

    // ...中略...
}

これで各エンドポイントにコネクションプールを持ちまわれるようになりました。

あとはTodo作成用のエンドポイントを作成します。

まずはhtmlファイルから実装しましょう。

<!-- TODOの作成(create_todo.html) -->
<form action="/create_todo" method="post">
    <label for="description">description</label>
    <textarea id="description" name="description" cols="30" rows="10"></textarea>
    <label for="deadline_at">deadline at</label>
    <input type="datetime-local" id="deadline_at" name="deadline_at">
    <button type="submit">submit</button>
</form>

次に、この画面に遷移するためのエンドポイントを実装します。

async fn get_create_todo(State(state): State<ServiceState>) -> Html<String> {
    let page = state.tera.render("create_todo.html", &Context::new()).unwrap();
    Html(page.to_owned())
}

// ...中略...

#[tokio::main]
async fn main() {
    // ...中略...

    let app = Router::new()
        .route("/", get(index))
        .route("/create_todo", get(get_create_todo)) // TODO作成画面に遷移するエンドポイントを追加
        .with_state(ServiceState { tera, pool });
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

では表示してみましょう。

う、うーん、、我ながらひどいデザインだ。。

続けて、Todoの一覧を表示するHTMLを作成します。

<!-- todos.html -->
<a href="/create_todo">TODOを作成</a>
<table>
    <thead>
        <tr>
            <th scope="col">description</th>
            <th scope="col">deadline at</th>
        </tr>
    </thead>
    <tbody>
        {% for todo in todos %}
        <tr id="{{ todo.id }}">
            <td>{{ todo.description }}</td>
            <td>{{ todo.deadline_at }}</td>
        </tr>
        {% endfor %}
    </tbody>
</table>

こんな感じで実装したら、あとはこれをレンダリングしてあげる関数を作ってあげます。

/// デシリアライザ(フォームから受け取る期日のフォーマットが%Y-%m-%dT%H:%Mになっているため必要)
pub fn deserialize_date<'de, D: serde::Deserializer<'de>>(
    deserializer: D,
) -> Result<NaiveDateTime, D::Error> {
    let s = String::deserialize(deserializer)?;
    NaiveDateTime::parse_from_str(&s, "%Y-%m-%dT%H:%M")
        .map_err(serde::de::Error::custom)
}

/// TODO構造体
#[derive(Debug, Serialize, FromRow)]   
struct Todo {
    id: Uuid,
    description: String,
    deadline_at: NaiveDateTime,
}

/// 作成用のTODO構造体
#[derive(Debug, Deserialize)]
struct CreateTodo {
    description: String,
    #[serde(deserialize_with = "deserialize_date")]
    deadline_at: NaiveDateTime,
}

/// TODOの作成後、TODO一覧にリダイレクトする
async fn post_create_todo(
    State(state): State<ServiceState>,
    Form(todo): Form<CreateTodo>,
) -> Redirect {
    let todo = Todo {
        id: Uuid::new_v4(),
        description: todo.description,
        deadline_at: todo.deadline_at,
    };

    sqlx::query("INSERT INTO todo VALUES ($1, $2, $3);")
        .bind(todo.id)
        .bind(todo.description)
        .bind(todo.deadline_at)
        .execute(&state.pool)
        .await
        .expect("todoの取得に失敗しました");

    Redirect::to("/todos")
}

/// TODOの一覧を表示するHTMLを生成する関数
async fn get_todos(
    State(state): State<ServiceState>,
) -> Html<String> {
    let todos = sqlx::query_as::<_, Todo>("SELECT * FROM todo")
        .fetch_all(&state.pool)
        .await
        .expect("todoの取得に失敗しました");
    let mut context = Context::new();
    context.insert("todos", &todos);

    let page = state.tera.render("todos.html", &context).expect("todosの描画に失敗しました");
    Html(page)
}

エンドポイントも追加しておきましょう。

    let app = Router::new()
        .route("/", get(index))
        .route("/todos", get(get_todos)) // TODO一覧ページ
        .route("/create_todo", get(get_create_todo).post(post_create_todo)) // TODO作成ページと実際作成するエンドポイント
        .with_state(ServiceState { tera, pool });

動作検証してみます。

作成画面に移動します。

必要項目を入力して、submitを押します。

作成できました!

まとめ

Rustでかんたんに動的サイトができました。

簡単なサイトの提供だけであればTera + Axumで十分ですね。

YawとかでSSRするのも面白そうですが、デザイン関連の知識が全然なのでやっても出来上がりが微妙なんですよね~(´;ω;`)

今回のコードはこちらに上がっているので、よければこちらも参考にしてみてください。

GitHub - satodaiki/rust-todo-management-sample

ではまた。

参考にしたリポジトリ