actix-web,tera,dieselを用いたWebアプリケーション開発

作成: 2020年12月04日

更新: 2021年03月29日

はじめに

この記事はRust 3 Advent Calendar 2020 - Qiitaの5日目です.

最近Rustを使ってWebアプリを作成しているのでactix-web,diesel,teraを用いたアプリ開発について紹介します.

actix-web とは?

actix-webはRust製のWebフレームワークでRustのWebフレームワークといえばこれかRocketだと思われる.actix-webのすごいところとして圧倒的な速さがあります.Webフレームワークのベンチマークで総合2位となっていてこれだけでも採用したくなります.

Round 19 results - TechEmpower Framework Benchmarks

ただ速さの一方でRailsやLaravelほどユーザーフレンドリーではなくかなり癖があります.この記事ではactix-webを用いた簡単なAPI,HTMLの表示を紹介したいと思います.

環境構築などについては省略しますがこの記事で紹介するコードは以下のリポジトリにあります.依存パッケージなども以下を参考にしてください.

bana118/actix-tutorial

VSCodeとDockerとRemoteコンテナ拡張があれば環境構築不要で試せるようになっています.

Hello world

以下が最も単純なactix-webのコードです.

main.rsuse actix_web::{web, App, Error, HttpResponse, HttpServer};

async fn greet() -> Result<HttpResponse, Error> {
    Ok(HttpResponse::Ok()
        .content_type("text/html")
        .body("Hello, world!"))
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    HttpServer::new(|| App::new().route("/", web::get().to(greet)))
        .bind("127.0.0.1:8080")?
        .run()
        .await
}

route("/", web::get().to(greet))からわかるように127.0.0.1:8080に対するGETリクエストに対してgreetの関数が対応し,"Hello, world!"を返します.

データベースの使用

Rustでデータベースを扱うにはORMのDieselを使います.今回の例では以下のモデルを用いてメモを保存します.Dieselのセットアップ,マイグレーションなどについてはこの記事では割愛します.bana118/actix-tutorial を参考にしてください.

models.rsuse super::schema::memos;
use serde::Serialize;

#[derive(Queryable, Serialize)]
pub struct Memo {
    pub id: i32,
    pub content: String,
}

#[derive(Insertable)]
#[table_name = "memos"]
pub struct NewMemo {
    pub content: String,
}

Memo構造体がデータベースに保存されるデータの形式をNewMemo構造体が新しいメモを追加する際に渡すデータの形式を表しています.今回idは自動で生成されるのでMemoにはあってNewMemoにはないです.

新しいメモの挿入は以下のように行います.

let new_memo = crate::models::NewMemo {
    content: String::from(&params.content),
};
let conn = pool.get().expect("couldn't get db connection from pool");
diesel::insert_into(memos::table)
    .values(&new_memo)
    .execute(&conn)
    .unwrap();

テーブルのすべてのメモの取得は以下のように行います.

let conn = pool.get().expect("couldn't get db connection from pool");
let memos = memos::table
    .load::<crate::models::Memo>(&conn)
    .expect("Error loading cards");

connはデータベースと接続するためのマネージャーです.

HTMLの表示

HTMLファイルにRustの変数を埋め込んだり,HTMLファイル内でifやforを使うときはテンプレートエンジンのTeraを使います.Rust製のテンプレートエンジンはかなりあってほかにもAskamaHandlebarsがあります.機能に大差はないので好みで選択すれば大丈夫です.

TeraではHTML内で{{}}で囲うことで変数を表示して{% for memo in memos %}のようにfor文を書ける.

今回は以下のようなフォームとテーブルでメモの登録表示を行うHTMLを描画する.見栄えのためにBootstrapを使っています.

form.png

form.html<!DOCTYPE html>
<html lang="jp">

<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">

    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.5.3/dist/css/bootstrap.min.css"
        integrity="sha384-TX8t27EcRE3e/ihU7zmQxVncDAy5uIKz4rEkgIXeMed4M0jlfIDPvg6uqKI2xXr2" crossorigin="anonymous">
    <title>
        Memo
    </title>
</head>

<body>
    <div class="container">
        <h1>actix-web example</h1>
        <h2>Memo form</h2>
        <form class="form-horizontal" id="simpleForm" method="POST" action="/form/memo">
            <div class="form-group">
                <label for="content">Content</label>
                <input type="text" name="content" id="content" class="form-control">
            </div>
            <button type="submit" class="btn btn-primary">Post</button>
        </form>
        <h2>Memo list</h2>
        <table class="table">
            <thead>
                <tr>
                    <th scope="col">id</th>
                    <th scope="col">content</th>
                </tr>
            </thead>
            <tbody>
                {% for memo in memos %}
                <tr>
                    <th scope="row">{{ memo.id }}</th>
                    <td>{{ memo.content }}</td>
                </tr>
                {% endfor %}
            </tbody>
        </table>
    </div>

    <script src="https://code.jquery.com/jquery-3.5.1.slim.min.js"
        integrity="sha384-DfXdz2htPH0lsSSs5nCTpuj/zy4C+OGpamoFVy38MVBnE+IbbVYUew+OrCXaRkfj"
        crossorigin="anonymous"></script>
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@4.5.3/dist/js/bootstrap.bundle.min.js"
        integrity="sha384-ho+j7jyWK8fNQe+A12Hb8AhRq26LrZ/JpcUGGOn+Y7RsweNrtN/tE3MoK7ZeZDyx"
        crossorigin="anonymous"></script>
</body>

</html>

このHTMLファイルの描画はRust側では以下のように行う.

async fn form(
    pool: web::Data<r2d2::Pool<ConnectionManager<SqliteConnection>>>,
    tmpl: web::Data<Tera>,
) -> Result<HttpResponse, Error> {
    let mut ctx = Context::new();
    let conn = pool.get().expect("couldn't get db connection from pool");
    let memos = memos::table
        .load::<crate::models::Memo>(&conn)
        .expect("Error loading cards");
    ctx.insert("memos", &memos);
    let view = tmpl
        .render("form.html", &ctx)
        .map_err(|e| error::ErrorInternalServerError(e))?;
    Ok(HttpResponse::Ok().content_type("text/html").body(view))
}

Dieselを用いて取得したメモ一覧をctxにインサートしてviewでHTMLに描画している.

またメモ追加時のフォーム処理は以下のように行う.

#[derive(Serialize, Deserialize)]
pub struct FormParams {
    content: String,
}

async fn memo_form(
    pool: web::Data<r2d2::Pool<ConnectionManager<SqliteConnection>>>,
    params: web::Form<FormParams>,
    tmpl: web::Data<Tera>,
) -> Result<HttpResponse, Error> {
    let new_memo = crate::models::NewMemo {
        content: String::from(&params.content),
    };
    let conn = pool.get().expect("couldn't get db connection from pool");
    diesel::insert_into(memos::table)
        .values(&new_memo)
        .execute(&conn)
        .unwrap();
    let mut ctx = Context::new();
    let memos = memos::table
        .load::<crate::models::Memo>(&conn)
        .expect("Error loading cards");
    ctx.insert("memos", &memos);
    let view = tmpl
        .render("form.html", &ctx)
        .map_err(|e| error::ErrorInternalServerError(e))?;
    Ok(HttpResponse::Ok().content_type("text/html").body(view))
}

引数のparamsからフォームデータを取得してDieselによるメモ追加処理に渡している.

メイン処理全体

処理すべてを合わせたmain.rs全体は以下のようになる.

main.rs#[macro_use]
extern crate diesel;

use crate::schema::memos;
use actix_web::{error, web, App, Error, HttpResponse, HttpServer};
use diesel::{
    prelude::*,
    r2d2::{self, ConnectionManager},
    sqlite::SqliteConnection,
};
use serde::{Deserialize, Serialize};
use std::str;
use tera::{Context, Tera};

pub mod models;
pub mod schema;

async fn greet() -> Result<HttpResponse, Error> {
    Ok(HttpResponse::Ok()
        .content_type("text/html")
        .body("Hello, world!"))
}

#[derive(Serialize, Deserialize)]
pub struct FormParams {
    content: String,
}

async fn form(
    pool: web::Data<r2d2::Pool<ConnectionManager<SqliteConnection>>>,
    tmpl: web::Data<Tera>,
) -> Result<HttpResponse, Error> {
    let mut ctx = Context::new();
    let conn = pool.get().expect("couldn't get db connection from pool");
    let memos = memos::table
        .load::<crate::models::Memo>(&conn)
        .expect("Error loading cards");
    ctx.insert("memos", &memos);
    let view = tmpl
        .render("form.html", &ctx)
        .map_err(|e| error::ErrorInternalServerError(e))?;
    Ok(HttpResponse::Ok().content_type("text/html").body(view))
}

async fn memo_form(
    pool: web::Data<r2d2::Pool<ConnectionManager<SqliteConnection>>>,
    params: web::Form<FormParams>,
    tmpl: web::Data<Tera>,
) -> Result<HttpResponse, Error> {
    let new_memo = crate::models::NewMemo {
        content: String::from(&params.content),
    };
    let conn = pool.get().expect("couldn't get db connection from pool");
    diesel::insert_into(memos::table)
        .values(&new_memo)
        .execute(&conn)
        .unwrap();
    let mut ctx = Context::new();
    let memos = memos::table
        .load::<crate::models::Memo>(&conn)
        .expect("Error loading cards");
    ctx.insert("memos", &memos);
    let view = tmpl
        .render("form.html", &ctx)
        .map_err(|e| error::ErrorInternalServerError(e))?;
    Ok(HttpResponse::Ok().content_type("text/html").body(view))
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    let templates = Tera::new("templates/**/*").unwrap();

    let database_url = "database.sqlite3";
    let db_pool = r2d2::Pool::builder()
        .build(ConnectionManager::<SqliteConnection>::new(database_url))
        .expect("failed to create db connection pool");
    HttpServer::new(move || {
        App::new()
            .data(templates.clone())
            .data(db_pool.clone())
            .route("/", web::get().to(greet))
            .route("/form", web::get().to(form))
            .route("/form/memo", web::post().to(memo_form))
    })
    .bind("127.0.0.1:8080")?
    .run()
    .await
}

最後に

RustはやっぱりPythonやPHPに比べるとかなり書き方に癖があって自分も全然把握できていませんがその速度や省メモリ性能にはロマンがあるので勉強していきたいです.
actix-webの情報はまだ少ないので勉強するときは公式のサンプル一覧actix/examples: Community showcase and examples of Actix ecosystem usage.を参考にするのがおすすめです.