16
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Redisでセッション管理を行うRust製のWebサーバ 🦀

Posted at

ISL Advent Calendar 2020 の9日目の記事です。

本記事ではセッション管理を NoSQL の Redis で行うメリットについて説明し、Redis にセッション管理を任せた Web サーバをプログラミング言語 Rust で実装したいと思っております。
ソースコードは https://github.com/pyama2000/advent2020 にあります。

1. セッション管理

HTTP はステートレスなので状態を持つことができないため、ログイン状態やカートに追加された商品を保持することができません。そこで、ウェブストレージ(LocalStrage, SessionStrage)や Cookie などで状態を保持します。また、React.js や Vue.js などのフレームワークには状態を管理する機能があります。
この記事では Cookie を使った2種類のセッション(状態)管理について説明します。

1.1 Cookie にセッションデータを保持する

cookie.png

セッションデータを Cookie に保存することで、状態を保持することができます。Cookie はリクエスト / レスポンス時に送信されるため、相互にセッションデータを参照できます。
ただ、Cookie に保存できるデータ量が 4096 バイトなので多くのデータを保持することができません。また、全てのデータを持っているため攻撃を受けたときにすべての情報が盗み取られる可能性があります。

1.2 Redis にセッションデータを保持する

cookie-redis.png

すべてのセッションデータを Cookie に保存する場合、4096 バイト以上のデータを保存できないというデメリットがありました。そこで、NoSQL の Redis を使ってセッション管理をすることでデータ量を気にせずにデータを保存することができます。

Redis はメモリ上で動作する(インメモリ)キーバリュー型のデータベースです。すべてのデータをメモリ上に格納するため高速なアクセスが可能になります。また、データの有効期限(expire)を設定できるのでセッションが終わったデータを自動で削除することができます。

Redis でセッション管理するとき、サーバではユニークで予測不可能なセッション ID を生成して、Cookie にセッション ID をセットしたレスポンスをクライアントに送信します。Redis のキーをセッション ID にすることでデータを保持することができます。クライアント側はセッション ID しか持っていないため、Cookie を盗み見られても住所や電話番号などの重要な情報を取られることはありません。

この方式のデメリットは、Redis を使用するので何らかの障害によって Redis サーバが停止した場合はデータが消失する可能性があります。また、多くのアクセスがあったときに処理が遅くなる恐れがあります。しかし、Redis にはレプリケーション機能やデータの永続化機能があるためうまく運用することでこれらのデメリットを解消することができます。

2. Web サーバの実装 :crab:

Webフレームワーク actix-web を使って、Redis でセッション管理をする Web サーバを実装します。この記事では実装の説明を途中で終えているので、残りの部分は自分で実装するかリポジトリをみてください。

2.1 実装する Web サーバの概要

Web サーバの機能と実行に必要な環境変数について説明します。

2.1.1 機能

エンドポイントとその機能には以下の通りになっています。

  • POST /login: セッションに user_id のデータがなければ user_id を作成して、セッションにセットする
  • PATCH /change: 新規に user_id を作成し、セッションの user_id と置き換える
  • POST /add: セッションの count に 1 を追加する
  • DELETE /reset: セッションの count を 0 にする

2.1.2 環境変数

Web サーバはデフォルトのままだと http://0.0.0.0:8000 でリクエストを待ち受ける事になっています。ホストを変更したい場合は環境変数に HOST を、ポートを変更したい場合は PORT を追加してください。同様に Redis のアドレスを変更するには、環境変数 REDIS_URL に Redis の URL を指定してください。

Key Default
HOST 0.0.0.0
PORT 8000
REDIS_URL 0.0.0.0:6379

2.2 依存関係

advent2020/advent2020_api/Cargo.toml

Cargo.toml
[dependencies]
actix-cors = "0.5"
actix-redis = "0.9.1"
actix-session = "0.4"
actix-web = "3"
env_logger = "0.7"
rand = "0.7"
rand_chacha = "0.2"
serde_json = "1"
uuid = { version = "0.8", features = ["serde", "v4"] }

actix-web はメジャーアップデートで破壊的な変更があるパッケージで、バージョン2の場合は非同期を使用するために actix-rt が必要でしたがバージョン3にアップデートされたことで actix-web だけで非同期に実行できるようになりました。actix-webに限らず、actix 関連のパッケージはアップデートによって大幅な変更があるので最新の状況を確認してください。

2.3 実装

それでは実際に実装していきたいと思います。

2.3.1 サーバを起動する

まず、GET http://0.0.0.0:8000/ というリクエストがあったら、Hello, world! と返す Web サーバを実装します。コードは次の通りです。

src/main.rs
use std::env;

use actix_web::{middleware::Logger, web, App, Error, HttpServer, Responder};

async fn index() -> Result<impl Responder, Error> {
    Ok("Hello, world!")
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    env::set_var("RUST_LOG", "actix_web=debug");
    env_logger::init();

    let host = env::var("HOST").unwrap_or_else(|_| "0.0.0.0".to_string());
    let port = env::var("PORT").unwrap_or_else(|_| "8000".to_string());

    HttpServer::new(move || {
        App::new()
            .wrap(Logger::default())
            .route("/", web::get().to(index))
    })
    .bind(format!("{}:{}", host, port))?
    .run()
    .await
}

main 関数から見ていくと、main 関数に actix を非同期で利用するために #[actix_web::main] マクロを記述します。
main 関数の中では、まず actix_web のログを出力するための設定を記述しています。ログレベルは debug, error, info, warn, trace と5つあるので必要に応じて変更してください。
次に、Web サーバを起動に必要なホストとポート情報を、環境変数に指定されていたら環境変数から取得し、環境変数になかった場合は、ホストを 0.0.0.0、 ポートを 8000 に指定します。
最後に、ログを出力するミドルウェアとGET http://0.0.0.0:8000/のリクエストを処理する関数を持った Appインスタンスを実行する、HttpServer を立ち上げます。

cargo run で 実行ファイルを起動したら、curl コマンドで http://0.0.0.0:8000/ にリクエストを送ってみましょう。次のような結果が返ってきたら成功です。

$ curl -v http://0.0.0.0:8000/
*   Trying 0.0.0.0...
* TCP_NODELAY set
* Connected to 0.0.0.0 (127.0.0.1) port 8000 (#0)
> GET / HTTP/1.1
> Host: 0.0.0.0:8000
> User-Agent: curl/7.64.1
> Accept: */*
>
< HTTP/1.1 200 OK
< content-length: 13
< content-type: text/plain; charset=utf-8
< date: Sun, 29 Nov 2020 07:39:57 GMT
<
* Connection #0 to host 0.0.0.0 left intact
Hello, world!* Closing connection 0

2.3.2 Redis を使ったセッション管理

いよいよ Redis を使ったセッション管理機能を実装します。変更点は次の通りです。

src/main.rs
  use std::env;
  

+ use actix_redis::RedisSession;
+ use actix_session::Session;
  use actix_web::{middleware::Logger, web, App, Error, HttpServer, Responder};


- async fn index() -> Result<impl Responder, Error> {
+ async fn index(_session: Session) -> Result<impl Responder, Error> {
      Ok("Hello, world!")
  }
  
  #[actix_web::main]
  async fn main() -> std::io::Result<()> {
-     env::set_var("RUST_LOG", "actix_web=debug");
+     env::set_var("RUST_LOG", "actix_web=debug,actix_redis=info");
      env_logger::init();
  
      let host = env::var("HOST").unwrap_or_else(|_| "0.0.0.0".to_string());
      let port = env::var("PORT").unwrap_or_else(|_| "8000".to_string());
+     let redis_url = env::var("REDIS_URL").unwrap_or_else(|_| "0.0.0.0:6379".to_string());
  
      HttpServer::new(move || {
          App::new()
              .wrap(Logger::default())
+             .wrap(RedisSession::new(&redis_url, &[0u8; 32]))
              .route("/", web::get().to(index))
      })
      .bind(format!("{}:{}", host, port))?
      .run()
      .await
  }

main 関数では、まず、actix で Redis を利用するための actix_redis パッケージのログを出力するように変更しています。
次に、ホストやポートと同様に、 Redis のアドレスを環境変数で指定されていたら取得し、環境変数になかった場合は 0.0.0.0:6379 と指定します。
最後に App インスタンスにミドルウェアとして RedisSession インスタンスを追加しています。

index 関数でセッションを利用したいため、引数に actix_session::Session を追加しましたが、index 関数ではまだセッションを使わないため、変数名を _session としています。


RedisSession インスタンスを初期化する際に、0が32個並んだ配列を渡していますが、ランダムな配列を渡したほうがいいので、そのように変更します。このとき、暗号論的疑似乱数生成器(CSPRNG)によって再現不可能な乱数の配列を作成します(※ ここまでする必要はないですが)。

src/main.rs
  use std::env;
  
  use actix_redis::RedisSession;
  use actix_session::Session;
  use actix_web::{middleware::Logger, web, App, Error, HttpServer, Responder};
+ use rand::prelude::*;
+ use rand_chacha::ChaCha20Rng;
  
  async fn index(_session: Session) -> Result<impl Responder, Error> {
      Ok("Hello, world!")
  }
  
  #[actix_web::main]
  async fn main() -> std::io::Result<()> {
      env::set_var("RUST_LOG", "actix_web=debug,actix_redis=info");
      env_logger::init();
  
      let host = env::var("HOST").unwrap_or_else(|_| "0.0.0.0".to_string());
      let port = env::var("PORT").unwrap_or_else(|_| "8000".to_string());
      let redis_url = env::var("REDIS_URL").unwrap_or_else(|_| "0.0.0.0:6379".to_string());
  

+     let mut csp_rng = ChaCha20Rng::from_entropy();
+     let mut data = [0u8; 32];
+     csp_rng.fill_bytes(&mut data);
  
      HttpServer::new(move || {
          App::new()
              .wrap(Logger::default())
-             .wrap(RedisSession::new(&redis_url, &[0u8; 32]))
+             .wrap(RedisSession::new(&redis_url, &data))
              .route("/", web::get().to(index))
      })
      .bind(format!("{}:{}", host, port))?
      .run()
      .await
  }

curl で確認すると、ランダムな文字列をもった actix-session をクッキーにセットするようにレスポンスが返ってきます。

$ curl -v http://0.0.0.0:8000/
*   Trying 0.0.0.0...
* TCP_NODELAY set
* Connected to 0.0.0.0 (127.0.0.1) port 8000 (#0)
> GET / HTTP/1.1
> Host: 0.0.0.0:8000
> User-Agent: curl/7.64.1
> Accept: */*
>
< HTTP/1.1 200 OK
< content-length: 13
< content-type: text/plain; charset=utf-8
< set-cookie: actix-session=kc3Tt2AXplWf0jmdi8PbkOB7TgZbZusuh/canRMiK8A=XAKamRLydJ6v4HGYGt66ds52rKk0yB5K; HttpOnly; Path=/; Max-Age=604800
< date: Sun, 29 Nov 2020 07:55:29 GMT
<
* Connection #0 to host 0.0.0.0 left intact
Hello, world!* Closing connection 0

2.3.3 セッションにデータをセットする

セッションストレージとして Redis を使用する実装ができたので、次は user_id をセッションデータに追加する実装を行います。

変更点は次のようになっていて、GET http://0.0.0.0:8000/ の代わりに、ログイン処理を行うエンドポイント POST http://0.0.0.0:8000/login に変更します。

src/main.rs
  use std::env;
  
  use actix_redis::RedisSession;
  use actix_session::Session;
- use actix_web::{middleware::Logger, web, App, Error, HttpServer, Responder};
+ use actix_web::{middleware::Logger, web, App, Error, HttpResponse, HttpServer, Responder};
  use rand::prelude::*;
  use rand_chacha::ChaCha20Rng;
+ use serde_json::json;
+ use uuid::Uuid;
  

- async fn index(_session: Session) -> Result<impl Responder, Error> {
-     Ok("Hello, world!")
+ async fn login(session: Session) -> Result<impl Responder, Error> {
+     let json = match session.get::<Uuid>("user_id")? {
+         Some(user_id) => json!({ "user_id": &user_id }),
+         None => {
+             let user_id = Uuid::new_v4();
+             session.set("user_id", &user_id)?;
+ 
+             json!({ "user_id": &user_id })
+         }
+     };
+ 
+     Ok(HttpResponse::Ok().json(&json))
  }
  
  #[actix_web::main]
  async fn main() -> std::io::Result<()> {
      env::set_var("RUST_LOG", "actix_web=debug,actix_redis=info");
      env_logger::init();
  
      let host = env::var("HOST").unwrap_or_else(|_| "0.0.0.0".to_string());
      let port = env::var("PORT").unwrap_or_else(|_| "8000".to_string());
      let redis_url = env::var("REDIS_URL").unwrap_or_else(|_| "0.0.0.0:6379".to_string());
  
      let mut csp_rng = ChaCha20Rng::from_entropy();
      let mut data = [0u8; 32];
      csp_rng.fill_bytes(&mut data);
  
      HttpServer::new(move || {
          App::new()
              .wrap(Logger::default())
              .wrap(RedisSession::new(&redis_url, &data))
-             .route("/", web::get().to(index))
+             .route("/login", web::post().to(login))
      })
      .bind(format!("{}:{}", host, port))?
      .run()
      .await
  }

login 関数ではまず、セッションデータに user_id が存在したらそのデータを取得し JSON 形式に変更して変数の値として返します。セッションデータに user_id が存在しなかった場合は user_id を新たに作成したものをセッションデータにセットし、そのデータを JSON 形式にして変数の値として返しています。
最終的にuser_id を JSON 形式のデータとして持った変数をレスポンスのデータとして返します。


curlコマンドで POST http://0.0.0.0:8000/login にリクエストすると、JSON の形で user_id が受け取れていることが確認できます。

$ curl -v -X POST http://0.0.0.0:8000/login
*   Trying 0.0.0.0...
* TCP_NODELAY set
* Connected to 0.0.0.0 (127.0.0.1) port 8000 (#0)
> POST /login HTTP/1.1
> Host: 0.0.0.0:8000
> User-Agent: curl/7.64.1
> Accept: */*
>
< HTTP/1.1 200 OK
< content-length: 50
< content-type: application/json
< access-control-allow-origin: *
< access-control-allow-credentials: true
< set-cookie: actix-session=0XMjt9kEglh39LrXtbSQfpSd+o0dyKY5WbkwxlquXwQ=0jkFuRnGJRAfceiGGbzYd6DIRVNxsuAd; HttpOnly; Path=/; Max-Age=604800
< date: Sun, 29 Nov 2020 10:18:45 GMT
<
* Connection #0 to host 0.0.0.0 left intact
{"user_id":"436f3732-77c1-42ea-842e-4845821004e0"}* Closing connection 0

セッションストレージとして利用している Redis にどのようなデータが保存されているか確認するために redis-cli を実行します。
keys * でセッションキーを確認でき、そのセッションキーから get <セッションキー> でセッションデータを確認できます。

127.0.0.1:6379> keys *
1) "session:0jkFuRnGJRAfceiGGbzYd6DIRVNxsuAd"

127.0.0.1:6379> get session:0jkFuRnGJRAfceiGGbzYd6DIRVNxsuAd
"{\"user_id\":\"\\\"436f3732-77c1-42ea-842e-4845821004e0\\\"\"}"

2.3.4 ログインしていない場合は 401 Unauthorized を返す

PATCH http://0.0.0.0:8000/changePOST http://0.0.0.0:8000/addDELETE http://0.0.0.0:8000/reset はログイン状態のみ実行できるようにしたいので、セッションデータに user_id がセットされていない場合は 401 Unauthorized が返るように実装します。
今回は POST http://0.0.0.0:8000/add の実装を説明します。

src/main.rs
  use std::env;
  
  use actix_redis::RedisSession;
  use actix_session::Session;
  use actix_web::{middleware::Logger, web, App, Error, HttpResponse, HttpServer, Responder};
  use rand::prelude::*;
  use rand_chacha::ChaCha20Rng;
  use serde_json::json;
  use uuid::Uuid;
  
  async fn login(session: Session) -> Result<impl Responder, Error> {
      let json = match session.get::<Uuid>("user_id")? {
          Some(user_id) => json!({ "user_id": &user_id }),
          None => {
              let user_id = Uuid::new_v4();
              session.set("user_id", &user_id)?;
  
              json!({ "user_id": &user_id })
          }
      };
  
      Ok(HttpResponse::Ok().json(&json))
  }
  

+ async fn add(session: Session) -> Result<impl Responder, Error> {
+     if session.get::<Uuid>("user_id")?.is_none() {
+         return Ok(HttpResponse::Unauthorized().finish());
+     }
+ 
+     let count = session.get::<u32>("count")?.unwrap_or(0) + 1;
+     session.set("count", &count)?;
+ 
+     Ok(HttpResponse::Ok().json(json!({ "count": &count })))
+ }
  
  #[actix_web::main]
  async fn main() -> std::io::Result<()> {
      env::set_var("RUST_LOG", "actix_web=debug,actix_redis=info");
      env_logger::init();
  
      let host = env::var("HOST").unwrap_or_else(|_| "0.0.0.0".to_string());
      let port = env::var("PORT").unwrap_or_else(|_| "8000".to_string());
      let redis_url = env::var("REDIS_URL").unwrap_or_else(|_| "0.0.0.0:6379".to_string());
  
      let mut csp_rng = ChaCha20Rng::from_entropy();
      let mut data = [0u8; 32];
      csp_rng.fill_bytes(&mut data);
  
      HttpServer::new(move || {
          App::new()
              .wrap(Logger::default())
              .wrap(RedisSession::new(&redis_url, &data))
              .route("/login", web::post().to(login))
+             .route("/add", web::post().to(add))
      })
      .bind(format!("{}:{}", host, port))?
      .run()
      .await
  }

user_id がセッションデータにない場合 401 Unauthorized を返す部分を表現したのが、add 関数の最初の3行です。


curlPOST http://0.0.0.0:8000/add にリクエストを送ると 401 Unauthorized が返っているのが確認できます。

$ curl -v -X POST http://0.0.0.0:8000/add
*   Trying 0.0.0.0...
* TCP_NODELAY set
* Connected to 0.0.0.0 (127.0.0.1) port 8000 (#0)
> POST /add HTTP/1.1
> Host: 0.0.0.0:8000
> User-Agent: curl/7.64.1
> Accept: */*
>
< HTTP/1.1 401 Unauthorized
< content-length: 0
< access-control-allow-origin: *
< access-control-allow-credentials: true
< set-cookie: actix-session=FsIFXHGEjeFB1LQwmrYI5GFazBYkhVRszjvcaAcbZ3g=y91fxpmT65XxkdUvkYfzluBYHMcrwtCA; HttpOnly; Path=/; Max-Age=604800
< date: Sun, 29 Nov 2020 08:37:01 GMT
<
* Connection #0 to host 0.0.0.0 left intact
* Closing connection 0

POST http://0.0.0.0:8000/add のリクエストを成功させるためには、POST http://0.0.0.0:8000/login にリクエストして、レスポンスのクッキー情報をリクエストにセットしたリクエストを POST http://0.0.0.0:8000/add に送る必要があります。

$ curl -v -X POST http://0.0.0.0:8000/login
*   Trying 0.0.0.0...
* TCP_NODELAY set
* Connected to 0.0.0.0 (127.0.0.1) port 8000 (#0)
> POST /login HTTP/1.1
> Host: 0.0.0.0:8000
> User-Agent: curl/7.64.1
> Accept: */*
>
< HTTP/1.1 200 OK
< content-length: 50
< content-type: application/json
< access-control-allow-origin: *
< access-control-allow-credentials: true
< set-cookie: actix-session=Ul1VoNATeBbsZLOFXoUficMGP2NGte4PdBdDLEY0NAk=6aLlzVKZqc9lBDvoM2NrK6Jrnbf3zCuE; HttpOnly; Path=/; Max-Age=604800
< date: Sun, 29 Nov 2020 08:40:54 GMT
<
* Connection #0 to host 0.0.0.0 left intact
{"user_id":"bb6e25e4-f9aa-4a2b-be5b-f6587967cae3"}* Closing connection 0
$ curl -v -X POST 0.0.0.0:8000/add --cookie actix-session=Ul1VoNATeBbsZLOFXoUficMGP2NGte4PdBdDLEY0NAk=6aLlzVKZqc9lBDvoM2NrK6Jrnbf3zCuE
*   Trying 0.0.0.0...
* TCP_NODELAY set
* Connected to 0.0.0.0 (127.0.0.1) port 8000 (#0)
> POST /add HTTP/1.1
> Host: 0.0.0.0:8000
> User-Agent: curl/7.64.1
> Accept: */*
> Cookie: actix-session=Ul1VoNATeBbsZLOFXoUficMGP2NGte4PdBdDLEY0NAk=6aLlzVKZqc9lBDvoM2NrK6Jrnbf3zCuE
>
< HTTP/1.1 200 OK
< content-length: 11
< content-type: application/json
< access-control-allow-origin: *
< access-control-allow-credentials: true
< date: Sun, 29 Nov 2020 08:41:19 GMT
<
* Connection #0 to host 0.0.0.0 left intact
{"count":1}* Closing connection 0

3. おわりに

この記事では、セッション管理に関する説明と Redis を利用したセッション管理機能を備えた Web サーバを Rust で実装する方法を説明しました。Web サーバの実装は途中で終わっていますが、完全版は https://github.com/pyama2000/advent2020/tree/main/advent2020_api にあるのでそちらを確認してください。
また、Web サーバにリクエストを送る Nuxt.js 製のアプリケーションも作成したので、curl コマンドではなく GUI で確認したい場合は以下のコマンドで Docker を立ち上げて http://0.0.0.0:3000/ から操作してください。

docker-compose up --build

16
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
16
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?