ちょっと話題の記事

RailsライクなRustのWebフレームワーク 「Loco」

2023.12.07

Introcusion

つい先日、「Rust版のRails」ともいわれている、
Locoというフレームワークを教えてもらいました。
Railsは昔ちょっとさわった程度なのですが、
Rustで手軽にRailsライクなアプリ開発はおもしろそうなので、
試してみました。

Loco?

Locoについて簡単に説明します。
このblog記事で、Locoって何?
なんでRustなの?とか誰のためのフレームワーク?みたいなことが書いてあります。
軽く説明すると、↓です。

Locoって何?

  • Loco は、Rails からインスピレーションを得た Rust用のWebフレームワーク
  • ほぼすべての Rails 機能が含まれている
    • Controllerとaxum経由のルーティング
    • ActiveRecordライクにSeaORMでモデル操作
    • rrgenでコード生成
    • その他いろいろ。詳しくは元記事

RubyでいいならRails使ってね。Rust好きならLoco使おう!
とのことで、Loco使えば↓のような利点を得られますよ、とのこと。

  • Rust の安全性・型指定・concurrency モデル・安定したライブラリとエコシステム
  • デプロイでは単一のバイナリをサーバーにコピーしています。
  • 手間をかけずに100,000 req/secリクエスト捌ける
  • DB呼び出しでは 50,000 req/sec

どんな人向け?

Locoを理解するには、Rustの基本を知ってればとりあえずOKです。
axumとかactixなどのフレームワークを使ったことがあるとより理解しやすいかも。
※ Locoには複雑なライフタイムや黒魔法マクロはありません

また、Railsしか知らなくてRustは初めてという方でも、問題ありません。
※ Railsを知っていることを前提にはしていない

どんなプロジェクトにLocoは向いている?

公式でも「The one person framework」といっており、
サイドプロジェクト(主に個人の趣味や学習、実験など)や
スタートアップ向けフレームワークという位置付けになっています。

Locoは素早く小規模アプリを開発するのに向いています。

ある程度ルールがきまっているので、ライブラリ、ツール、lintなどを議論しなくてよいですし、
Loco CLIで素早くコード生成できます。
50,000/secのリクエストを処理できるアプリが、
約20MBのバイナリをデプロイすればすぐに動作します。
ほどほどのサイズのサーバとPostgres/Sqliteデータベース、
そしてインターネット接続があればOK。
サイドプロジェクトやスタートアップは安くすませましょう。

Environment

  • MacBook Pro (13-inch, M1, 2020)
  • OS : MacOS 13.5.2
  • Rust : 1.74.0
  • Docker : 24.0.7

Try

では、公式ドキュメントのGuideにそって試していきましょう。
まずはcargoでloco-cliをインストールします。

% cargo install loco-cli
・
・
・
% loco --version
loco-cli 0.2.0

loco newコマンドで新規locoアプリを作成できます。
アプリ名をきめたあと、「Saas app (with DB and user auth)」を選択して雛形を生成しましょう。

% loco new
✔ ❯ App name? · myapp
✔ ❯ What would you like to build? · Saas app (with DB and user auth)

🚂 Loco app generated successfully in:
/path/your/loco/myapp

次にサンプルアプリ用のpostgresをDockerで起動します。

% docker run -d -p 5432:5432 -e POSTGRES_USER=loco -e POSTGRES_DB=loco_app -e POSTGRES_PASSWORD="loco" postgres:15.3-alpine

Locoを起動してみましょう。
デフォルトでは3000番ポートで起動します。

% cd myapp
% cargo loco start

                      ▄     ▀
                                 ▀  ▄
                  ▄       ▀     ▄  ▄ ▄▀
                                    ▄ ▀▄▄
                        ▄     ▀    ▀  ▀▄▀█▄
                                          ▀█▄
▄▄▄▄▄▄▄  ▄▄▄▄▄▄▄▄▄   ▄▄▄▄▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄▄ ▀▀█
 ██████  █████   ███ █████   ███ █████   ███ ▀█
 ██████  █████   ███ █████   ▀▀▀ █████   ███ ▄█▄
 ██████  █████   ███ █████       █████   ███ ████▄
 ██████  █████   ███ █████   ▄▄▄ █████   ███ █████
 ██████  █████   ███  ████   ███ █████   ███ ████▀
   ▀▀▀██▄ ▀▀▀▀▀▀▀▀▀▀  ▀▀▀▀▀▀▀▀▀▀  ▀▀▀▀▀▀▀▀▀▀ ██▀
       ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
                https://loco.rs

environment: development
   database: automigrate
     logger: debug
      modes: server

listening on port 3000

ここで「error returned from database: role "loco" does not exist」
とエラーになった場合、既存のpostgresqlに接続しに行っている可能性があるので、
そのあたりを確認しましょう。

デフォルトで存在している/_ping、/_healthにアクセスすることで
WebサーバやDB接続が正常に動作しているか確認できます。

% curl localhost:3000/_ping
{"ok":true}

% curl localhost:3000/_health
{"ok":true}

では、generateコマンドでControllerを生成してみましょう。

% cargo loco generate controller guide
    Finished dev [unoptimized + debuginfo] target(s) in 0.93s
     Running `target/debug/myapp generate controller guide`
added: "src/controllers/guide.rs"
injected: "src/controllers/mod.rs"
injected: "src/app.rs"
added: "tests/requests/guide.rs"
injected: "tests/requests/mod.rs"

locoを再起動してコントローラにアクセスしてみます。

% curl localhost:3000/guide
hello

もちろん、手動でも追加可能です。 src/controllersに コントローラ用ファイルを作成し、mode.rsとapp.rsを修正すればOK。

追加したルートはloco routesで確認できます。

% cargo loco routes
[GET] /_health
[GET] /_ping
[POST] /auth/forgot
[POST] /auth/login
[POST] /auth/register
[POST] /auth/reset
[POST] /auth/verify
[GET] /guide
[POST] /guide/echo
[GET] /notes
[POST] /notes
[GET] /notes/:id
[DELETE] /notes/:id
[POST] /notes/:id
[GET] /user/current

私が試した限り、コード修正時に再起動が必要だったのですが、
再起動なしで反映してくれるようになるとうれしいですね。

モデルの生成

Locoにおいて、Modelはデータを表現します。通常、データはDBに保存されます。
新規モデルを追加してみましょう。Articleというモデルを追加します。

% cargo loco generate model article title:string content:text
・
・
* Migration for `article` added! You can now apply it with `$ cargo loco db migrate`.
* A test for model `Articles` was added. Run with `cargo test`.

playgroundでデータベース操作

myapp/examplesにplayground.rsというファイルがあります。
これは、モデルとアプリのロジックを試しに動かしてみる場所です。 ここでモデルの追加と取得をやってみましょう。

下記のようにarticleモデルの追加と取得を試しています。

// located in src/bin/playground.rs
// use this file to experiment with stuff
use eyre::Context;
use loco_rs::{cli::playground, prelude::*};
use myapp::{app::App, models::_entities::articles};
use myapp::models::_entities::articles::{ActiveModel, Entity, Model};

#[tokio::main]
async fn main() -> eyre::Result<()> {
    let ctx = playground::<App>().await.context("playground")?;

    // add this:
    let active_model: articles::ActiveModel = ActiveModel {
        title: Set(Some("how to build apps in 3 steps".to_string())),
        content: Set(Some("use Loco: https://loco.rs".to_string())),
        ..Default::default()
    };
    active_model.insert(&ctx.db).await.unwrap();    // add this:
    let res = articles::Entity::find().all(&ctx.db).await.unwrap();
    println!("{:?}", res);

    /*delete
    let res2 = articles::Entity::delete_many()
        .exec(&ctx.db)
        .await?;

    println!("{:?}", res2);
    */

    Ok(())
}

playgroundコマンド(cargo run --example playgroundのエイリアス)を使えば、
playground.rsが実行されます。

% cargo playground

[Model { created_at: 2023-12-07T05:31:52.021627, updated_at: 2023-12-07T05:31:52.021627, id: 24, title: Some("how to build apps in 3 steps"), content: Some("use Loco: https://loco.rs") }]

Controller追加

では次に、articlesコントローラを追加してみましょう。

% cargo loco generate controller articles

added: "src/controllers/articles.rs"
injected: "src/controllers/mod.rs"
injected: "src/app.rs"
added: "tests/requests/articles.rs"
injected: "tests/requests/mod.rs"

生成されたcontrollerに対してCRUD処理を実装します。

// this is src/controllers/articles.rs

#![allow(clippy::unused_async)]
use loco_rs::prelude::*;
use serde::{Deserialize, Serialize};

use crate::models::_entities::articles::{ActiveModel, Entity, Model};

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Params {
    pub title: Option<String>,
    pub content: Option<String>,
}

impl Params {
    fn update(&self, item: &mut ActiveModel) {
        item.title = Set(self.title.clone());
        item.content = Set(self.content.clone());
    }
}

async fn load_item(ctx: &AppContext, id: i32) -> Result<Model> {
    let item = Entity::find_by_id(id).one(&ctx.db).await?;
    item.ok_or_else(|| Error::NotFound)
}

pub async fn list(State(ctx): State<AppContext>) -> Result<Json<Vec<Model>>> {
    format::json(Entity::find().all(&ctx.db).await?)
}

pub async fn add(State(ctx): State<AppContext>, Json(params): Json<Params>) -> Result<Json<Model>> {
    let mut item = ActiveModel {
        ..Default::default()
    };
    params.update(&mut item);
    let item = item.insert(&ctx.db).await?;
    format::json(item)
}

pub async fn update(
    Path(id): Path<i32>,
    State(ctx): State<AppContext>,
    Json(params): Json<Params>,
) -> Result<Json<Model>> {
    let item = load_item(&ctx, id).await?;
    let mut item = item.into_active_model();
    params.update(&mut item);
    let item = item.update(&ctx.db).await?;
    format::json(item)
}

pub async fn remove(Path(id): Path<i32>, State(ctx): State<AppContext>) -> Result<()> {
    load_item(&ctx, id).await?.delete(&ctx.db).await?;
    format::empty()
}

pub async fn get_one(Path(id): Path<i32>, State(ctx): State<AppContext>) -> Result<Json<Model>> {
    format::json(load_item(&ctx, id).await?)
}

pub fn routes() -> Routes {
    Routes::new()
        .prefix("articles")
        .add("/", get(list))
        .add("/", post(add))
        .add("/:id", get(get_one))
        .add("/:id", delete(remove))
        .add("/:id", post(update))
}

Locoを再起動してcurlで動作確認してみましょう。

% curl localhost:3000/articles

(playgroudで実行したデータも含めて)データが返ってきます。

curlからデータを登録してみます。

% curl -X POST -H "Content-Type: application/json" -d '{
  "title": "Your Title xxx",
  "content": "Your Content xxx"
}' localhost:3000/articles

{"created_at":"2023-12-07T02:22:33.108351","updated_at":"2023-12-07T02:22:33.108351","id":11,"title":"Your Title","content":"Your Content xxx"}%

続いてupdateとpk検索してみます。
どちらも動いてます。

% curl -X POST -H "Content-Type: application/json" -d '{
  "title": "Your Title Update",
  "content": "Your Content xxx Update"
}' localhost:3000/articles:11

% curl localhost:3000/articles/11
{"created_at":"2023-12-07T02:22:33.108351","updated_at":"2023-12-07T02:22:33.108351","id":11,"title":"Your Title Update","content":"Your Content xxx Update"}%

scaffoldを使ってみよう

scaffoldもあります。
Articleと関連するCommentモデルを追加してみましょう。

% cargo loco generate scaffold comment content:text article:references

added: "src/controllers/comment.rs"
injected: "src/controllers/mod.rs"
injected: "src/app.rs"
added: "tests/requests/comment.rs"
injected: "tests/requests/mod.rs"
* Migration for `comment` added! You can now apply it with `$ cargo loco db migrate`.
* A test for model `Comments` was added. Run with `cargo test`.
* Controller `Comment` was added successfully.
* Tests for controller `Comment` was added successfully. Run `cargo run test`.

少々controllers/comment.rsのparamsを修正します。
articleとの関連を追加しましょう。

//controllers/comment.rs
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Params {
    pub content: Option<String>,
    pub article_id:i32

}

impl Params {
    fn update(&self, item: &mut ActiveModel) {
        item.content = Set(self.content.clone());
        item.article_id = Set(self.article_id);
      }
}

そしてroutesを修正。

pub fn routes() -> Routes {
    Routes::new()
        .prefix("comments")
        .add("/", post(add))
        //.add("/", get(list))
        //.add("/:id", get(get_one))
        //.add("/:id", delete(remove))
        //.add("/:id", post(update))
}

articleのControllerを修正します。
comment追加のroutesを追加しました。

// this is src/controllers/articles.rs

#![allow(clippy::unused_async)]

use loco_rs::prelude::*;
use serde::{Deserialize, Serialize};

//追加
use crate::models::_entities::{
    articles::{ActiveModel, Entity, Model},
    comments,
};

//追加
pub async fn comments(
    Path(id): Path<i32>,
    State(ctx): State<AppContext>,
) -> Result<Json<Vec<comments::Model>>> {
    let item = load_item(&ctx, id).await?;
    let comments = item.find_related(comments::Entity).all(&ctx.db).await?;
    format::json(comments)
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Params {
    pub title: Option<String>,
    pub content: Option<String>,
}

impl Params {
    fn update(&self, item: &mut ActiveModel) {
        item.title = Set(self.title.clone());
        item.content = Set(self.content.clone());
    }
}

async fn load_item(ctx: &AppContext, id: i32) -> Result<Model> {
    let item = Entity::find_by_id(id).one(&ctx.db).await?;
    item.ok_or_else(|| Error::NotFound)
}

pub async fn list(State(ctx): State<AppContext>) -> Result<Json<Vec<Model>>> {
    format::json(Entity::find().all(&ctx.db).await?)
}

pub async fn add(State(ctx): State<AppContext>, Json(params): Json<Params>) -> Result<Json<Model>> {
    let mut item = ActiveModel {
        ..Default::default()
    };
    params.update(&mut item);
    let item = item.insert(&ctx.db).await?;
    format::json(item)
}

pub async fn update(
    Path(id): Path<i32>,
    State(ctx): State<AppContext>,
    Json(params): Json<Params>,
) -> Result<Json<Model>> {
    let item = load_item(&ctx, id).await?;
    let mut item = item.into_active_model();
    params.update(&mut item);
    let item = item.update(&ctx.db).await?;
    format::json(item)
}

pub async fn remove(Path(id): Path<i32>, State(ctx): State<AppContext>) -> Result<()> {
    load_item(&ctx, id).await?.delete(&ctx.db).await?;
    format::empty()
}

pub async fn get_one(Path(id): Path<i32>, State(ctx): State<AppContext>) -> Result<Json<Model>> {
    format::json(load_item(&ctx, id).await?)
}

pub fn routes() -> Routes {
    Routes::new()
        .prefix("articles")
        .add("/", get(list))
        .add("/", post(add))
        .add("/:id", get(get_one))
        .add("/:id", delete(remove))
        .add("/:id", post(update))
        .add("/:id/comments", get(comments))
}

Locoを再起動してArticleを登録します。
ここで登録されたarticleのidは100。

% curl -X POST -H "Content-Type: application/json" -d '{
  "title": "Your Title",
  "content": "Your Content xxx"
}' localhost:3000/articles
{"created_at":"2023-12-07T02:34:29.079296","updated_at":"2023-12-07T02:34:29.079296","id":100,"title":"Your Title","content":"Your Content xxx"}%

article_idに100を設定してcommentを登録します。

% curl -X POST -H "Content-Type: application/json" -d '{
  "content": "this rocks",
  "article_id":100
}' localhost:3000/comments
{"created_at":"2023-12-07T03:07:56.625089","updated_at":"2023-12-07T03:07:56.625089","id":30,"content":"this rocks","article_id":100}%

selectしてみます。commentoモデルも追加できました。

% curl localhost:3000/articles/100/comments
[{"created_at":"2023-12-07T03:07:56.625089","updated_at":"2023-12-07T03:07:56.625089","id":30,"content":"this rocks","article_id":100}]%

とりあえず簡単にGetting Startをやってみました。
公式ではtask、認証、Configuration、デプロイなど
いろいろと解説してるので確認してみてください。

Summary

今回は軽くLocoアプリの作成とGuideにそって動かしてみました。
ドキュメントではひととおり機能の解説がしてあるので、これらを確認すれば
すぐ使えるようになるかと思います。
Rustで迅速・低コストでアプリ構築したい場合はとても良い選択肢になりそうです。

References