Rust/ActixWeb で CRUD 処理を書いてみた
IT技術
はじめに
Rust のお勉強.下記の続きです.
前回までで DB の設定準備が完了しました。
今回は CRUD 処理 / REST API を構築します。
今回の動作環境
今回は下記バージョンで確認してます。
バージョン(カッコ内は前回の) | |
Rust | 1.83.0(1.81.0) |
actix-web | 4.9.0(同じ) |
REST APIの仕様
リレーション
dept 1 : n emp
※ 前回、削除制限を cascade としてましたが、 restrict に変更しました。
(dept(部署)消したらemp(社員)も強制的に消える、に違和感あったので)
メソッドとURL
メソッド | dept | emp | 補足 |
GET | /dept | /emp | 全件 |
GET | /dept/{キー} | /emp/{キー} | キー指定 |
POST | /dept | /emp | |
PATCH | /dept/{キー} | /emp/{キー} | |
DELETE | /dept/{キー} | /emp/{キー} |
追加
下記仕様としてます。
- emp 登録時
- emp.mgr(上司)
- emp.empno に登録済みなこと。登録されてない場合は不可
- emp.mgr(上司)
- emp 削除時
- 削除対象の empno を emp.mgr に設定してるレコードが存在する場合は削除不可
※ いずれもプログラムで制御します
ではやっていきます。
REST APIを実装
DBコネクション
まずはDBコネクションの確立をエントリポイントに追加します。
確立の際にコネクション数やタイムアウトなどなど指定できるようです。
下記は SQL ログを出力する設定の例です。
1let mut opt = ConnectOptions::new("postgres://user:pass@host/dbname");
2opt.sqlx_logging(true);
3let conn = Database::connect(opt).await.unwrap();
コネクションは各ルートの処理で使用するために ステート (状態)として設定します。
下記を参考に記載してみます。
1// 構造体: ステート
2#[derive(Debug, Clone)]
3pub struct AppState {
4 pub conn: sea_orm::DatabaseConnection,
5}
6
7〜
8
9// コネクションを構造体にセットする
10let state = AppState { conn };
11
12// エントリポイント
13HttpServer::new(move || {
14 App::new()
15 // ここ
16 .app_data(web::Data::new(state.clone()))
17 .service(hello)
18 〜
コネクション定義はここまで。
エンティティ
ビジネスロジックを書くのに先立ち、sea-orm で自動生成したエンティティのモデル構造体を少し修正します。
レスポンスや登録/更新での利用で必要な対応となります。
1#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
2#[sea_orm(table_name = "dept")]
3pub struct Model {
4 #[sea_orm(primary_key)]
5 pub deptno: i32,
6 pub dname: String,
7 pub loc: String,
8}
9
10↓↓↓
11
12// 「serde::Serialize, serde::Deserialize」を追加
13#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, serde::Serialize, serde::Deserialize)]
14#[sea_orm(table_name = "dept")]
15pub struct Model {
16 #[sea_orm(primary_key)]
17 #[serde(skip_deserializing)] // 追加
18 pub deptno: i32,
19 pub dname: String,
20 pub loc: String,
21}
※ emp もです。
ビジネスロジック
続いてビジネスロジック。
sea-orm で生成したエンティティを利用します。(前回の記事参照)
それを利用する定義を追加します。
1use crate::entities::prelude::{Dept, Emp};
2use crate::entities::{dept, emp};
では dept から。
dept
get(全件)
1#[get("/dept")]
2async fn get_dept_all(data: web::Data) -> Result<HttpResponse, ApiCustomError> {
3 // レコード取得(全件)
4 let depts = Dept::find()
5 .order_by_asc(dept::Column::Deptno)
6 .all(&data.conn)
7 .await?;
8
9 // レスポンス
10 if depts.is_empty() {
11 return Ok(HttpResponse::NoContent().finish());
12 }
13 Ok(HttpResponse::Ok().json(depts))
14}
sea-orm で生成したエンティティを利用してクエリを組み立てる。
上記は SELECT * FROM dept ORDER BY deptno ASC; と同義。ORM あるあるな感じな定義なので簡単に想像つくと思います。
.all() は全件取得。 コネクションはここの引数としてセットする。
.await? で結果取得を待ちます、非同期に。と、?付きなのでエラー発生時はここで終了( レスポンスはカスタムエラー ApiCustmoError にお任せ)
レスポンスは DB からの取得件数に応じて変えてます。取得件数が 0 件の時は 204、1 件以上の場合は 200。
get(キー指定)
1#[get("/dept/{deptno}")]
2async fn get_dept_by_key(
3 path: Result<actix_web::web::Path, actix_web::Error>,
4 data: web::Data,
5) -> Result<HttpResponse, ApiCustomError> {
6 // クエリストリング取得(+型チェックと変換)
7 let deptno: i32 = path?.into_inner().try_into().unwrap();
8
9 // キーに該当するレコードを取得する
10 let dept = Dept::find_by_id(deptno).one(&data.conn).await?;
11
12 // レスポンス
13 match dept {
14 Some(dept) => Ok(HttpResponse::Ok().json(dept)),
15 _ => Err(ApiCustomError::NotFound),
16 }
17}
path?の箇所 → キーはクエリストリング {dpetno}で u32 型で受ける。u32型以外は 404。
からの i32型に変換。これは sea-orm で自動生成したエンティティ情報 deptno が i32 型で定義されているため。
u32 (=unsigned) がいいなぁと思ったですが、PostgreSQL に unsigned は無いし、unsigned に変更してエンティティ再生成してみたらエラーになった。仕方なし。
.unwrap()は path? を通過した時点で u32 型なのは保証されてるのでパニック(異常終了)しないと踏んで使用。しかしあまり使いたくは無い。
レコード取得は SELECT * FROM dept WHERE deptno = ? LIMIT 1; と同義。
レスポンスはレコード取得できたら 200、できなかったら 404。
post
リクエストボディの構造体を定義します(併せてバリデータ定義を追加します)。
1/// 構造体: dept リクエストJson
2#[derive(validator::Validate, serde::Deserialize, serde::Serialize, Debug)]
3struct DeptRequestJson {
4 // dname
5 #[validate(length(min = 1, max = 14, message = "1~14文字で入力してください."))]
6 pub dname: String,
7
8 // loc
9 #[validate(length(min = 1, max = 13, message = "1~13文字で入力してください."))]
10 pub loc: String,
11}
続いてロジック。
1#[post("/dept")]
2async fn post_dept(
3 form: Result<actix_web::web::Json, actix_web::Error>,
4 data: web::Data,
5) -> Result<HttpResponse, ApiCustomError> {
6 // バリデート
7 let form = form?.into_inner();
8 form.validate()?;
9
10 // DB登録
11 let dept = dept::ActiveModel::from_json(json!(form))?
12 .insert(&data.conn)
13 .await?;
14
15 // レスポンス
16 Ok(HttpResponse::Created().json(dept))
17}
form?.into_inner() でリクエストボディ値を構造体で取得。
form.validate()? でバリデート実施。? 付いてるのでエラー時はここで終了(レスポンスはやはり ApiCustomErrorにお任せ)
dept::ActiveModel::from_json(json!(form))? はリクエスト値(構造体)を json に変換してアクティブモデルにセットする。
json リクエストを構造体に変換し再度 json に変換してモデルにセットするっていうのがちょっとあれですが、項目ごとに値をちまちま設定しなくて済むのが素晴らしいと思いました。しんどいじゃないですか、ちまちまセットするの。
.inset() で登録。
レスポンスは 201。
patch
put では無く patch なのは、更新対象のレコードが存在しない場合 404 のが良いかな、と思ったからです。以上。
リクエスト構造体は post のと共用です。
1#[patch("/dept/{deptno}")]
2async fn patch_dept(
3 path: Result<actix_web::web::Path, actix_web::Error>,
4 form: Result<actix_web::web::Json, actix_web::Error>,
5 data: web::Data,
6) -> Result<HttpResponse, ApiCustomError> {
7 // クエリストリング取得(+型チェックと型変換)
8 let deptno: i32 = path?.into_inner().try_into().unwrap();
9
10 // バリデート
11 let form = form?;
12 form.validate()?;
13
14 // 更新
15 let dept = Dept::find_by_id(deptno).one(&data.conn).await?;
16 let updated_dept = match dept {
17 Some(dept) => {
18 let mut dept_active_model = dept.into_active_model();
19 dept_active_model.set_from_json(json!(form))?;
20 dept_active_model.update(&data.conn).await?
21 }
22 None => return Err(ApiCustomError::NotFound),
23 };
24
25 // レスポンス
26 Ok(HttpResponse::Ok().json(updated_dept))
27}
クエリストリング、バリデートは get、post と同じ。
まず更新対象レコードを取得します。
対象レコードが存在した場合は取得レコード(モデル)をアクティブモデルに変換し、登録時同様に変更リクエスト値を json に再変換してセットする。そして .update() で更新。
未存在時は404。
delete
物理削除としました。容赦しません。
dept テーブルは親テーブルなので参照制約のチェックを考慮します。
削除制限を RESTRICT としているので子テーブルに関連レコードが残っている場合は何もしなくてもエラーとなりレコード削除は行われません。なんですがレスポンスが 500 になるので考慮した次第です。
1#[delete("/dept/{deptno}")]
2async fn delete_dept(
3 path: Result<actix_web::web::Path, actix_web::Error>,
4 data: web::Data,
5) -> Result<HttpResponse, ApiCustomError> {
6 // クエリストリング取得(+型チェックと型変換)
7 let deptno: i32 = path?.into_inner().try_into().unwrap();
8
9 // 参照制約チェック
10 if exists_references(&data.conn, deptno).await? {
11 return Err(ApiCustomError::UnporcessibleEntity(format!(
12 "deptno [{}] can not delete.",
13 deptno
14 )));
15 }
16
17 // キーに該当するレコードを削除する
18 let result = Dept::delete_by_id(deptno).exec(&data.conn).await?;
19
20 // レスポンス
21 if result.rows_affected == 0 {
22 return Err(ApiCustomError::NotFound);
23 }
24 Ok(HttpResponse::NoContent().finish())
25}
26
27// 参照制約チェック
28async fn exists_references(conn: &DatabaseConnection, deptno: i32) -> Result<bool, DbErr> {
29 Ok(Emp::find()
30 .filter(emp::Column::Deptno.eq(deptno))
31 .one(conn)
32 .await?
33 .is_some())
34}
emp テーブルに削除対象の deptno が存在した場合は 422 。
削除は対象レコード有無のチェックがちょっとあれなのでチェックせずに実行。
実行結果に応じてレスポンスを変えました(.row_affected が 0(=削除レコード未存在)の場合は 404、以外は204)。
dept の実装完了。
emp
つづいてemp。
と思ったですが、
dept と対して変わらないので割愛します。
ご了承ください。
コード GitHub に上げときます。
まとめ
sea-orm で自動生成したエンティティを使用し簡単に CRUD 処理を書くことができました。
また今回の対応を通じて Result 型と Option 型と仲良くなれた気がします。
あと以前に記載しましたがやっぱりバリデーション実行ををいちいちビジネスロジック内でするのが冗長と思いました。
おまけ
今回 CRUD 処理を書くにあたり前回と比べてかなり追加/変更してます。
- dotenv 追加
- カスタムエラー定義追加(DB(sea-orm)と 422 用の)
- ログ変更
- tracing、tracing-subscriber を利用。機能が多い。なんだか難しい。軽い気持ちで導入してみましたが全然活用できそうに無い感
- ミドルウェア追加(アクセスログを出力してみました)
などなどです。
興味ある方は GitHub 見てみてください。
ライトコードでは、エンジニアを積極採用中!
ライトコードでは、エンジニアを積極採用しています!社長と一杯しながらお話しする機会もご用意しております。そのほかカジュアル面談等もございますので、くわしくは採用情報をご確認ください。
採用情報へ