rustとvueの勉強としてtodoアプリを作っています.jsonサーバーを作成するwebフレームワークにactix-webを利用しており,todoに対するコメントのデータベースにmongodbを利用しています.
actix-webでmongodbを利用しているサンプルプログラムがあまりなかったので,今回はactix-webでmongodbのrust版ドライバであるmongo-rust-driverを利用してREST apiを作成します.
バージョン
actix-webの安定版ではactix_web::main
内でmongo-rust-driverが何故か動かなかった(パニックしてしまった)ため,最新版である4.0.0-beta.9
を利用します.安定版で利用したい場合はactix_web::main
の代わりにactix_rt::main
を検討してみて下さい.またactix-webでは非同期に関数を実行するためCargo.tomlには以下のように追加します.
[dependencies]
actix-web = "4.0.0-beta.9"
[dependencies.mongodb]
version = "2.0.0"
default-features = false
features = ["tokio-runtime"]
以降で利用するプログラムは以下のファイル構造をとっています.
src
├ comment_models.rs
├ comment_actions.rs
├ error.rs
├ lib.rs
└ bin
└ build_server.rs
データ構造体
利用するデータの構造体は以下であり,comment_models.rs
で定義されています.TodoComments
がmongodbのコレクションに入るドキュメント,TodoComment
がそのうちの一つのコメントであり,UpserTodoCommentForm
がinsertとupsertのときに送られてくるjsonのデータ構造です.TodoComments
はmongodbで利用するためSerialize, Deserialize
トレイトを実装し,サーバーでコレクションをcloneして利用するためClone
トレイトを実装しています.UpsertTodoComentForm
ではjsonデータとして取得するためDeserialize
を,テストコードの時にjsonを作成するためSerialize
を実装しています.
# [derive(Debug, Serialize, Deserialize, Clone)]
pub struct TodoComment {
pub comment: String,
pub user_name: Option<String>,
pub reply_id: Option<i32>,
pub timestamp: NaiveDateTime
}
# [derive(Debug, Serialize, Deserialize, Clone)]
pub struct TodoComments {
pub todo_id: i32,
pub comments: Vec<TodoComment>
}
# [derive(Debug, Deserialize, Serialize)]
pub struct UpsertTodoCommentForm { // jsonで渡される構造体
pub todo_id: i32,
pub comment_id: i32,
pub comment: String,
pub user_name: Option<String>,
pub reply_id: Option<i32>
}
データのinsert,upsert,delete,get
データのinsertに利用する関数は以下であり, comments_actions.rs
で定義されています.actix-webではデータを利用するときはactix_web::web::Data
でラップしますが,actix_web::web::Data
は内部でArc
を利用しているため引数をArc
でラップしています.この関数はドキュメントのTodoComments
部分のcomments
にTodoComment
部分を追加するためのものですが,存在しない場合はTodoComments
ごと追加するためにTodoComments
を引数としています.挿入あるいはコメントを変更したTodoComments
を返すためにfind_one_and_update
を利用していますが,これはデフォルトでは変更する前のデータを返すためオプションを変更する必要があります.find_one_and_update
部分ではTodoComment
に対応する部分を配列に挿入するという処理を行うためmongodb::bson::to_document
を利用してmongodbで利用可能な形にしています.
use mongodb::Collection;
use mongodb::bson::{doc, to_document};
use mongodb::options::{FindOptions, FindOneAndUpdateOptions, ReturnDocument};
use std::sync::Arc;
use anyhow::{Context, anyhow};
use crate::comments_models::TodoComments;
use crate::error::AppError;
pub async fn insert_comment(
collection: Arc<Collection<TodoComments>>,
todo_comments: TodoComments
) -> Result<TodoComments, AppError> {
let todo_id = todo_comments.todo_id;
let todo_comment = &todo_comments.comments[0];
if let None = collection.find_one(doc! {"todo_id": todo_id}, None).await? { // todo_idが該当するものが存在しなかった場合
collection.insert_one(&todo_comments, None).await?;
return Ok(todo_comments);
} else {
let fined_one_and_update_options = FindOneAndUpdateOptions::builder()
.return_document(Some(ReturnDocument::After))
.build();
let res = collection.find_one_and_update(
doc!{"todo_id": todo_id},
doc!{"$push":{"comments": to_document(todo_comment).context("bson encode error")?}},
fined_one_and_update_options
).await?;
match res {
Some(res_todo_comments) => { return Ok(res_todo_comments); },
None => { return Err(anyhow!("There is contradiction in logic").into()); }
}
}
}
データをupsertする関数は以下です.ほとんどinsertと一緒ですが,引数のcomment_id
に対応するインデックスの要素が存在する場合に$set
,存在しない場合に$push
しています.
pub async fn upsert_comment(
collection: Arc<Collection<TodoComments>>,
comment_id: i32,
todo_comments: TodoComments
) -> Result<TodoComments, AppError> {
let todo_id = todo_comments.todo_id;
let todo_comment = &todo_comments.comments[0];
let comment = &todo_comments.comments[0].comment;
if let None = collection.find_one(doc! {"todo_id": todo_id}, None).await? { // todo_idが該当するものが存在しなかった場合
collection.insert_one(&todo_comments, None).await?;
return Ok(todo_comments);
} else {
let fined_one_and_update_options = FindOneAndUpdateOptions::builder()
.return_document(Some(ReturnDocument::After))
.build();
match collection.find_one_and_update(
doc! {"todo_id": todo_id, format!("comments.{}",comment_id):{"$exists":true}},
doc! { "$set":{format!("comments.{}.comment", comment_id): comment}},
fined_one_and_update_options.clone()
).await? {
Some(res_todo_comments) => {return Ok(res_todo_comments);},
None => {
let res_todo_comments = collection.find_one_and_update(
doc! {"todo_id": todo_id},
doc!{"$push":{"comments": to_document(todo_comment).context("bson encode error")?}},
fined_one_and_update_options.clone()
).await?
.ok_or(anyhow!("There is contradiction in logic"))?;
return Ok(res_todo_comments);
}
}
}
}
テータをdeleteする関数は以下です.mongodbでインデックスを指定した配列の要素の削除は2段階必要であり,$unset
で要素をnullにした後$pull
で要素がnullのものを削除しています.該当するコメントが存在しない場合は独自に定義したAppError::CommentNotFoundError
を返します.
use mongodb::bson::Bson::Null;
pub async fn delete_comment(
collection: Arc<Collection<TodoComments>>,
todo_id: i32,
comment_id: i32
) -> Result<(), AppError> {
if let None = collection.find_one(doc! {"todo_id": todo_id}, None).await? { // todo_idが該当するものが存在しなかった場合
return Err(AppError::CommentNotFoundError{todo_id, comment_id});
} else {
let res = collection.update_one(
doc! {"todo_id": todo_id, format!("comments.{}", comment_id):{"$exists":true}},
doc! {"$unset": {format!("comments.{}", comment_id): 1}},
None
).await?; // 該当コメントをnullにする
if res.matched_count == 0 { // todo_id, comment_idが該当するものが存在しなかった場合
return Err(AppError::CommentNotFoundError{todo_id, comment_id});
} else {
collection.update_one(
doc! {"todo_id": todo_id},
doc!{"$pull": {"comments": Null}},
None
).await?; // nullのコメントを削除する
collection.delete_one(
doc! {"todo_id": todo_id, "comments": {"$size": 0}},
None
).await?; // comments配列の要素が全て削除された場合にドキュメントごと削除
}
return Ok(());
}
}
データをgetする関数は以下です.try_collect
でStreamからVecを作成します.
use futures::stream::TryStreamExt;
pub async fn load_todo_comments(
collection: Arc<Collection<TodoComments>>,
todo_ids: Vec<i32>
) -> Result<Vec<TodoComments>, AppError> {
let find_options = FindOptions::builder().sort(doc! {"todo_id" : 1}).build();
let cursor = collection.find(
doc!{"todo_id":{"$in": todo_ids}},
find_options
).await?;
let todo_comments_vec: Vec<TodoComments> = cursor.try_collect().await?;
Ok(todo_comments_vec)
}
リクエストハンドラの定義
actix_web::App
にサービスとして登録する関数を定義します.insert, update(upsert), delete, getに対応するリクエストハンドラを定義します.以下はlib.rs
で定義されています.
insertに対応するリクエストハンドラは以下となります.リクエストボディにUpsertTodoCommentForm
のjsonデータを含めるため,引数に追加しています.actix-webではこのように引数にデータペース,json,クエリストリング, パスパラメータを渡すことで対応するサービスを定義します.内部でどのように処理しているか気になりますが,詳細はこちらにあります.insert_comment_hundler
では,時間を取得した後insert_comment
関数を実行しています.関数が成功した場合はjsonポディを持ったステータスコード200のレスポンスを返し,失敗した場合はErrorInternalServerError
でエラーをラップして返します.ErrorInternalServerError
は任意のエラーをステータスコード500のレスポンスに対応するエラーに変換するヘルパー関数であり,エラーの文字列はレスポンスのテキストから参照できます.
use mongodb::Collection;
use actix_web::{get, post, put, delete, web, Error, HttpResponse};
use actix_web::error::{
ErrorInternalServerError,
ErrorNotFound,
ErrorBadRequest
};
# [post("/api/comment")]
pub async fn insert_comment_hundler(
collection: web::Data<Collection<TodoComments>>,
inserted_comment_form: web::Json<UpsertTodoCommentForm>
) -> Result<HttpResponse, Error> {
let collection = collection.into_inner();
let inserted_comment_form = inserted_comment_form.0;
let register_datetime = Utc::now().naive_utc();
let (_, inserted_comment) = convert_comment_upsert_form(inserted_comment_form, register_datetime);
insert_comment(collection, inserted_comment)
.await
.map(|todo_comments|HttpResponse::Ok().json(todo_comments))
.map_err(|some_err|ErrorInternalServerError(some_err))
}
upsert(update)に対応するリクエストハンドラは以下となります.ほとんどinsertのものと同じです.
# [put("/api/comment")]
pub async fn upsert_comment_hundler(
collection: web::Data<Collection<TodoComments>>,
upserted_comment_form: web::Json<UpsertTodoCommentForm>
) -> Result<HttpResponse, Error> {
let collection = collection.into_inner();
let upserted_comment_form = upserted_comment_form.0;
let upserted_datetime = Utc::now().naive_utc();
let (comment_id, upserted_comment) = convert_comment_upsert_form(upserted_comment_form, upserted_datetime);
upsert_comment(collection, comment_id, upserted_comment)
.await
.map(|todo_comments|HttpResponse::Ok().json(todo_comments))
.map_err(|some_err|ErrorInternalServerError(some_err))
}
deleteに対応するリクエストハンドラは以下となります.コメントを指定するtodo_id
とcomment_id
はパスパラメータ―として指定しています.AppError::CommentNotFoundError
が起こった場合ErrorNotFond
でステータスコード404のレスポンスに対応するエラーに変換します.成功した場合は特にポディに入れるデータもないのでfinish
とします.
# [delete("/api/comment/{todo_id}/{comment_id}")]
pub async fn delete_comment_hundler(
collection: web::Data<Collection<TodoComments>>,
path: web::Path<(i32, i32)>
) -> Result<HttpResponse, Error> {
let collection = collection.into_inner();
let path = path.into_inner();
let todo_id = path.0;
let comment_id = path.1;
delete_comment(collection, todo_id, comment_id)
.await
.map(|_|HttpResponse::Ok().finish())
.map_err(|some_err|{
match some_err {
AppError::CommentNotFoundError{todo_id: _, comment_id: _} => ErrorNotFound(some_err),
_ => ErrorInternalServerError(some_err)
}
})
}
getに対するリクエストハンドラは以下となります.取得したいコメントのtodo_ids
を配列で指定するため,String
のtodo_ids
を持つ構造体をクエリとして引数に渡します.クエリストリングは/api/comment?todo_ids=3,4,5
のように指定できます.
use anyhow::Context;
# [derive(Deserialize, Clone)]
pub struct TodoCommentsQeuery{
todo_ids: String
}
pub fn parse_todo_ids_str(todo_ids_str: String) -> Result<Vec<i32>, AppError>{
let todo_ids = todo_ids_str.split(",").map(|id_str|{
id_str.parse::<i32>().context("url parse_error")
.map_err(|some_err|some_err.into())
}).collect::<Result<Vec<i32>, AppError>>()?;
Ok(todo_ids)
}
# [get("/api/comment")]
pub async fn load_todo_comments_multi_hundler(
collection: web::Data<Collection<TodoComments>>,
query: web::Query<TodoCommentsQeuery>
) -> Result<HttpResponse, Error> {
let collection = collection.into_inner();
let todo_ids = parse_todo_ids_str(query.todo_ids.clone())
.map_err(|some_err|ErrorBadRequest(some_err))?;
load_todo_comments(collection, todo_ids)
.await
.map(|todo_comments_vec|HttpResponse::Ok().json(todo_comments_vec))
.map_err(|some_err|ErrorInternalServerError(some_err))
}
サーバーの作成
以下はbuild_server.rs
にあります.
actix_web::main
内でサーバーを作成します.mongodbのコレクションはアプリケーションビルダーのapp_data
メソッドで与えます.
use actix_web::{App, HttpServer};
use actix_web::web::Data;
use mongodb::Client;
# [actix_web::main]
async fn main() -> std::io::Result<()> {
let mongo_db_url = std::env::var("MONGODB_URL").expect("MONGODB_URL must be set");
let client = Client::with_uri_str(&mongo_db_url).await.expect("Failed to connect client");
let db = client.database("todo_app");
let collection = db.collection::<TodoComments>("todo_comments");
let bind = "127.0.0.1:8080";
HttpServer::new(move ||{
App::new()
.app_data(Data::new(collection.clone()))
.service(insert_comment_hundler)
.service(upsert_comment_hundler)
.service(delete_comment_hundler)
.service(load_todo_comments_multi_hundler)
})
.bind(&bind)?
.run()
.await
}
mongodを起動した後に以下を実行すれば,サーバーが起動します.
cargo run --bin build_server