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 に保存することで、状態を保持することができます。Cookie はリクエスト / レスポンス時に送信されるため、相互にセッションデータを参照できます。
ただ、Cookie に保存できるデータ量が 4096 バイトなので多くのデータを保持することができません。また、全てのデータを持っているため攻撃を受けたときにすべての情報が盗み取られる可能性があります。
1.2 Redis にセッションデータを保持する
すべてのセッションデータを Cookie に保存する場合、4096 バイト以上のデータを保存できないというデメリットがありました。そこで、NoSQL の Redis を使ってセッション管理をすることでデータ量を気にせずにデータを保存することができます。
Redis はメモリ上で動作する(インメモリ)キーバリュー型のデータベースです。すべてのデータをメモリ上に格納するため高速なアクセスが可能になります。また、データの有効期限(expire)を設定できるのでセッションが終わったデータを自動で削除することができます。
Redis でセッション管理するとき、サーバではユニークで予測不可能なセッション ID を生成して、Cookie にセッション ID をセットしたレスポンスをクライアントに送信します。Redis のキーをセッション ID にすることでデータを保持することができます。クライアント側はセッション ID しか持っていないため、Cookie を盗み見られても住所や電話番号などの重要な情報を取られることはありません。
この方式のデメリットは、Redis を使用するので何らかの障害によって Redis サーバが停止した場合はデータが消失する可能性があります。また、多くのアクセスがあったときに処理が遅くなる恐れがあります。しかし、Redis にはレプリケーション機能やデータの永続化機能があるためうまく運用することでこれらのデメリットを解消することができます。
2. Web サーバの実装
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
[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 サーバを実装します。コードは次の通りです。
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 を使ったセッション管理機能を実装します。変更点は次の通りです。
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)によって再現不可能な乱数の配列を作成します(※ ここまでする必要はないですが)。
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
に変更します。
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/change
や POST http://0.0.0.0:8000/add
、DELETE http://0.0.0.0:8000/reset
はログイン状態のみ実行できるようにしたいので、セッションデータに user_id
がセットされていない場合は 401 Unauthorized が返るように実装します。
今回は POST http://0.0.0.0:8000/add
の実装を説明します。
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行です。
curl
で POST 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