🚂

【チートシート有】rust(locoってFW)で個人開発。Rubyistには良きかも。

に公開

Railsみたいな体験×Rustみたいな安全性を追い求めて...

※ Rubyistって書きましたが、Rails好きな人というニュアンスです💎
元々、Railsを主戦場に開発していた身としては、Railsの開発体験はなかなかに変えがたいです。
実装柔軟性やgemの充実度、開発のスピード感にはいつも助けられています。

しかし、静的型付けを求めるとどうしても物足りなさがあります。
特にコードの保守性に関して課題を感じることがあります。
最近はTypescriptなど静的型付けの言語もよく触るので、
型安全なコードでバグの早期発見や保守性が上がることにもメリットを感じていました。
自分は結構抜け漏れとかやっちゃうタイプなので、ちょっとした定義ミスみたいなものを
気付けるというのは非常にありがたいです。

Rustの現場で触れて、もっと使いたいと思ったが...

以前、Rustの現場で開発をしたことがあり、そのときにRustの型システムの強力さ、
パフォーマンス、安全性もそうですが、書き心地も案外悪くないなと思っていました。
(ちょいちょい冗長な書き方になってしまうこともありますが。)
それ以来、Rustをもっと使いたいと思っていたのですが、やはりRustはまだ日本では
案件や採用しているプロダクトが少なく、関わる機会がそこまでありませんでした。

LocoというRailsっぽいフレームワークがあるじゃないか!

そんな中、RustでRailsライクな開発ができるフレームワーク「Loco」を見つけました。
Locoは、Railsの開発体験をRustの世界に持ち込んだフレームワークで、
MVC構成やルーティングなど、Railsに慣れ親しんだエンジニアなら直感的に扱えるとのこと。
以前のRustの現場で、誰か人柱になってくれ〜!って書いてたのもあり、個人的に気になっていました。

せっかくなので、Locoを使って何か作ってみることにしました。ただ作るだけではなく、実際に役立つものを作りたい。ということで、エンジニアのポートフォリオサービスを作ることにしました。

Railsの開発体験が好きなので、LocoでもRailsっぽい構成で開発を進めました。モデル、ルーティング、コントローラなど、Railsと似た構成にすることで、開発しやすさを保ちつつRustの型安全性も享受できます。

使ってみて、似ているなと思った点は以下の通りです。

  • ActiveModelやモデルの概念(インスタンスメソッド、クラスメソッド定義が直感的)
  • Railsっぽくassociationが組める(has_many, belongs_to的な)。なんならアソシエーション定義まで自動でやってくれる。これはどちらかというとsea-ormのおかげ
  • MVCの構成
  • DBマイグレーションの流れ

以下、サービスのスタックです。

  • Rust(Loco)
  • Typescript(Next.js)
  • MUI(pigment-css)
  • swagger(orval)
  • tanstack query
  • firebase(auth,storage)

今回、どうせ個人開発だしとことんチャレンジなスタックにしてみようとこれに至りました。
rustだけでもジュニアなのに、locoって、文献も少ないから公式docsと cursorとマブダチになりました。🙆‍♂️

locoは結構開発がアクティブなフェーズなので、railsのこれやりたいってのを伝えれば、比較的実現はしてくれそうです。
(自分も、DBカラムのdefaultを設定できるようにしたい!と伝えたら早いタイミングで実装してくれました。いつか自分もメンテナーとかやってみたい😺)

How To Use Loco?

もしrustがまだ入っていないという方がいたら、miseがおすすめです。
https://qiita.com/murakami-mm/items/bd5174acc554a39f7291
自分は全ての言語をこれでバージョン管理しています。

そして環境が整ったら、ここで書かれているように、sea-ormとlocoをインストールします。
https://loco.rs/docs/getting-started/tour/

自分は、ホットリロードして欲しい気持ちもあったので、こちらの方の初期設定も参考にしました。
https://zenn.dev/usagram/articles/d0172d9b770bff

この手順に沿ってある程度雛形ができたら、いったん各々のやりたいように組み込みます。
自分はapiオンリーにしてプロジェクト作成して、フロントは別途Next.jsで組みました。

理由: Next.jsのコードを書き換えただけなのに、rustのコンパイルが走っちゃって、
ちょっと止まってしまう時間ができちゃう。のがあまり個人的に良くなかった。
この辺の依存関係をなくす方法を知っている方がいたら教えてください。
一応、今は以下のように並列にプロジェクトは管理しています。
workspace
|_frontend
|_backend

locoコマンド チートシート

1️⃣ コントローラー

作成

 cargo loco g controller エンドポイント名 --kind=api 

今はまだ、controllers/〇〇.rsの配下にしか作成できないみたいです。
もしcontrollers/users/〇〇.rsみたいに作りたかったら、

一回 controllers/〇〇.rsを作って、その後にフォルダ移動する感じでやるのかな?
と思います。(アップデートに期待してます💡)

認証をカスタム

メインでfirebaseを用いているので、こちらを用いてみました。
https://github.com/trchopan/firebase-auth

認可処理はcontrollers/middleware/firebase_auth.rsというとこで定義しています。
実装方法は上のプラグインのexampleとか見ると参考になると思います。

そんでcontrollerからの呼出しに関しては、
こんな感じで引数に設定すると参照できるようになります。

#[debug_handler]
pub async fn index(
    auth: firebase_auth::FirebaseAuthUser<users::Model>,
    State(ctx): State<AppContext>,
) -> Result<Response, ErrStatus> {
    let db = &ctx.db;
    let engineer = auth.user.fetch_engineer(db).await?;
    let user = auth.user;

    OK(())
}

2️⃣ モデル

作成

シンプルな内容

# !をつけるとnot nullになる
cargo loco generate model memos title:string count:integer!

has one/many

cargo loco generate model languages name:string! country:references

many to many(中間テーブル)

cargo loco generate model --link users_languages user:references movie:references vote:int

migrationの定義方法

usersを例に。

use loco_rs::schema::*;
use sea_orm_migration::prelude::*;

#[derive(DeriveMigrationName)]
pub struct Migration;

#[async_trait::async_trait]
impl MigrationTrait for Migration {
    async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
        create_table(
            m,
            "users",
            &[
                ("id", ColType::PkAuto),
                ("pid", ColType::UuidUniq),
                ("email", ColType::StringUniq),
                ("name", ColType::String),
                // デフォルト値を設定するときの書き方
                ("is_active", ColType::BooleanWithDefault(false)),
                // nullableの時の書き方(○○Nullという書き方ができる)
                ("last_login_at", ColType::TimestampWithTimeZoneNull),
            ],
            &[],
        )
        .await?;

        // indexの作成(unique制約もこんな感じで定義可能)
        m.create_index(
            Index::create()
                .name("idx-users-email-uniq")
                .table(Users::Table)
                .col(Users::Email)
                .unique()
                .to_owned(),
        )
        .await
    }

    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
        manager
            .drop_table(Table::drop().table(Users::Table).to_owned())
            .await
    }
}

#[derive(Iden)]
pub enum Users {
    Table,
    Email,
}

DB反映&Entityの作成

cargo loco db migrate
cargo loco db entities

ロールバック

cargo loco db down 3 ←何個前のマイグレーションに遡るかを定義できる(何も書かないと直前のみ)

DB取得

一覧取得

let res = articles::Entity::find().all(&ctx.db).await.unwrap()

1件取得

let res = articles::Entity::find().one(&ctx.db).await.unwrap()

検索条件

let res = articles::Entity::find().filter(articles::Column::Title.eq("テスト"))
                                  .all(&ctx.db)
                                  .await
                                  .unwrap();

with has many

// 1件のみ
let res = article.find_related(authors::Entity)
                 .all(db)
                 .await?
// 複数件
let res = articles.find_also_related(positions::Entity)
                  .all(db)
                  .await?

with has one

let res = article.find_also_related(authors::Entity)
                 .one(db)
                 .await?

中間テーブルの先のデータまで取りたい

e.g.(article::articles_languages::languagesの場合)

 let article_languages = article.find_related(article_languages::Entity)
                                .find_also_related(languages::Entity)
                                .all(&ctx.db)
                                .await?

DBバリデーション

こんな感じに定義できます。

#[derive(Debug, Validate, Deserialize)]
pub struct Validator {
    // 文字数バリデーション
    #[validate(length(min = 2, message = "名前は2文字以上で入力してください"))]
    pub name: String,
    // バリデーション(デフォで存在する)
    #[validate(custom(function = "validation::is_valid_email"))]
    pub email: String,
    // カスタムバリデーションも定義できる
    #[validate(
        length(max = 30, message = "ユーザーUIDは30文字以下で入力してください"),
        custom(function = "is_valid_hankaku_eisu")
    )]
    // 0~5までの値
    pub user_name: Option<String>,
    #[validate(range(min = 0, max = 5, message = "正しい値で入力してください"))]
    pub motivation: Option<i32>,
}

impl Validatable for super::_entities::users::ActiveModel {
    fn validator(&self) -> Box<dyn Validate> {
        Box::new(Validator {
            name: self.name.as_ref().to_owned(),
            email: self.email.as_ref().to_owned(),
        })
    }
}

各メソッドの定義箇所

初期作成時のmodel

pub use super::_entities::messages::{ActiveModel, Entity, Model};
use sea_orm::entity::prelude::*;
pub type Messages = Entity;

// バリデーション処理はここ
#[async_trait::async_trait]
impl ActiveModelBehavior for ActiveModel {
    async fn before_save<C>(self, _db: &C, insert: bool) -> std::result::Result<Self, DbErr>
    where
        C: ConnectionTrait,
    {
        if !insert && self.updated_at.is_unchanged() {
            let mut this = self;
            this.updated_at = sea_orm::ActiveValue::Set(chrono::Utc::now().into());
            Ok(this)
        } else {
            Ok(self)
        }
    }
}

// インスタンスメソッド
impl Model {}

// 作成、更新処理が入るインスタンスメソッド?
impl ActiveModel {}

// クラスメソッド
impl Entity {}

スキーマ定義した内容をrustの構造体として用いるための方法

あと、今回自分はスキーマ駆動開発をしたかった(フロントエンド起点での実装)なので、
rustでよく使われるutoipaではなく普通にフロントでスキーマファイルを書いて、それを
フロントのスキーマ定義ツールであるorval&Openapi Generatorを使っています。
openapiとはなんぞや?という方はこの辺を参照してみて下さい。
https://zenn.dev/manabu/articles/35ea2ddfe2df3a
https://zenn.dev/manabu/articles/13e6e608c787dc

これによりReactとRustどちらの型定義も実現しています。
rustの定義ファイル追加コマンドに関しては少しカスタムしています。

openapi-generator generate -i openapi/openapi.yml -g rust -o ../プロジェクト名/generated --global-property models

こんな実行コマンドで、「rustの型定義のみ」を生成しています。
--global-property modelsを追加しないと、エンドポイントの実装も追加されて、
locoのルーティングと干渉する恐れがあったので、この形に落ち着きました。

ただ、そのままではRustの制約上、違うパッケージ?クレート?の物を参照できないので、
ルートのCargo.tomlにこちらの内容を追加します。

...(中略)
sentry = "0.37.0"
itertools = "0.14.0"
axum-extra = { version = "0.10", features = ["query"] }
reqwest = { version = "0.12.12", features = ["json"] }

generated = { path = "./generated" } ← これを追加
[workspace]
members = ["generated"]← これを追加

[[bin]]
...(後略)

こうすることでここのパッケージ内でgenerated/フォルダが参照できるようです。

あとは、
generated/src/models/mod.rs というファイルを作成して、
その中で、以下のような記述をしてください。

pub mod current_user;

pub use self::{current_user::CurrentUser};

こう書くことで、Openapi generatorで作成された
CurrentUserの構造体をパッケージ内で利用することができます。

この辺、言葉足らずな部分がありそうなので、もしチャレンジして
上手くいかない人がいたら質問してください🙏

RubyistがRustに慣れるための参考記事

https://zenn.dev/megeton/articles/fb6266bcb6aa1b
https://zenn.dev/megeton/articles/895e0547645e03

rubyでやってたあれ、どうやればいいかな?という時はこれを参考にするといいと思います。

ちなみにデプロイにはShuttleを使っています。ShuttleはRustのアプリを簡単にデプロイできるサービスで、サーバーレスで運用できるため、インフラの手間を減らせます。
まだ途中ですが、試しに...と思ってやってみたら、1時間も経たずにできました!

それでも困ったら...

大丈夫!Cursorくんがなんとかしてくれる!
自分もCursorのルールをできるだけ書き込んで、あとは適宜質問しながら実装して、
複雑そうなとこは自分でやりつつ。
助けてもらいながら、辺なコード出してくる時は、
「それ、もっとシンプルに書けない?」とか
「共通化できると思うから@user.rsに書き出して」
とかである程度はRustジュニアだけど頑張れています!

サービスはまだまだ開発途上なので、またリリースしたら記事書きます。
次はフロントのこと書いたりしようかな😀

RustでのWeb開発に興味がある方、Locoを試してみたい方、
ぜひ一緒にRust開発頑張りましょう〜〜!🚆

他の参考記事

https://zenn.dev/collabostyle/articles/45762b07bc16fb
https://qiita.com/goosys/items/c71b0e5c26b93bb16054
https://qiita.com/nikawamikan/items/1be2a142d25ff364007c

Discussion