はじめに
ステートフル、ステートレスについて、意外とちゃんと勉強したことないな、と思ったので、今回まとめます。
ステートフルとステートレスとは
まず「ステート」とは「状態」です。
ステートフルは「状態を保持し」、ステートレスは「状態を保持しません」。
次のコードは「ステートフル」です。
public class StateFullCounter {
private int count = 0;
public void increment() {
count++;
}
public int get() {
return count;
}
}
インスタンス変数としてcountを持っていますね。countは「状態」です。上記のクラスは「状態」を保持しています。
さて、次のコードは「ステートレス」です。
public class StateLessCounter {
public int increment(int value) {
return value + 1;
}
}
インスタンス変数を持ちません。つまり、「状態」を持ちません。
ステートフルの問題点
ステートレスはステートフルより優れているそうです。
その理由をまとめます。
副作用がある
ステートフルなクラスの実装を見てわかる通り、increment()はメソッド外の値を変更しますから、「副作用のあるメソッド」です。
以下もいくつかステートフルの問題点を記載していますが、ほとんど全て「副作用がある」ことに通じます。
結果が予測しにくい
それぞれの使い方を見てみましょう。
public static void main(String[] args) {
StateFullCounter full = new StateFullCounter();
full.increment();
System.out.println(full.get());
StateLessCounter less= new StateLessCounter();
int x =0;
x = less.increment(x);
System.out.println(x);
}
違いは、「状態が見えるか否か」です。
ステートフルの場合、状態がずっと(get()するまで)隠れていますね。対して、ステートレスの場合は状態xが常に見えています。
これの何がいいのでしょうか?
「他の処理がある場合」を見てみましょう。
public static void main(String[] args) {
StateFullCounter full = new StateFullCounter();
full.increment();
doSomething(full);
System.out.println(full.get());
StateLessCounter less= new StateLessCounter();
int x =0;
x = less.increment(x);
x = doSomething(less, x);
System.out.println(x);
}
ステートフルの場合、「他の処理」によって状態が更新されたのかどうかが読み取れません。
対して、ステートレスの場合は「他の処理」の結果で状態xを更新していることが明確です。
ステートフルの場合は「どこで値が更新されたかわからない」ため、結果の予測が難しくなります。
テストが書きにくい
ステートレスなクラスのメソッドは「関数」として書けています。
つまり、とある入力に対しては、いつでも同じ出力となるのです。
対してステートフルの場合は「事前準備」と「実行順序」が重要です。
テストの初期状態を何にするかで結果が変わります。実行順序を一つ入れ替えるだけで結果が変わります。
テストには「仕様書」としての役割があります。
ステートフルなクラスに対するテストは使用の本質ではない「事前準備」が大多数となり、仕様書としての役割を果たせなくなることもあります。
同時アクセス(並列処理)に向かない
ステートフルは「スレッドセーフではありません」。
スレッドセーフについては以下書きましたのでご参考に。
【Java】スレッドセーフの話
簡単に例を出すと、同時にアクセスした場合「increment()を5回呼んだはずなのに状態は「3」だった」ということがおきます。
Webの世界のステートレス
ここまではJavaのクラスを中心に書いてきたのですが、次はWebでの話をしたいと思います。
HTTPというプロトコルは「ステートレス」な設計だよ、っていう話をします。
HTTPはステートレス
HTTP通信は「リクエスト」と「レスポンス」で完結します。
HTTP通信自体は「一つ前のリクエスト」や「一つ前のレスポンス」を覚えていません。つまり状態を持たない、ステートレスなものというわけです。
例えば、次のコードはSpringのControllerの例です。
@RestController
public class HelloController {
@GetMapping("/hello")
public String hello() {
return "Hello!";
}
}
状態を持っていませんね。リクエストが飛んできたらメソッドを実行して結果(レスポンス)を返すのみです。
Webサーバーもステートレスに設計するべき
先ほどのControllerはステートレスな設計です。
一度ステートフルなクラスに書き換えて、問題点を見てみましょう。
@RestController
public class HelloController {
private int count = 0;
@GetMapping("/counter")
public String counter() {
count++;
return count + "回目の訪問";
}
}
状態countを持つようにしました。
リクエストされるごとにcountの回数が増えていきます。
ここで注意したいのが、上記クラスがシングルトンであることです。
ただし、リクエスト回数を正確に記録できるものではありません。スレッドセーフではありませんから、「複数の同時リクエスト」がきた時には、意図通りにカウントアップされません。
また、サーバーが複数台になったことを考えましょう。状態countはサーバーごとに保持・変更されていきます。
サーバーがスケールアウトした場合、新たに起動したサーバーのカウントは0から始まります。
「100回目の訪問」をした直後、「1回目の訪問」になることがあるのです。
ステートフルの場合、サーバーの冗長化にもマッチしません。
状態を保持したい時はどうするのか
HTTP通信はステートレスだし、サーバーもステートレスにするべき。
では、「ログイン状態」とか「カートの状態」って、どうやって保持しているのか?という疑問が浮かびますね。
Webサーバーをステートレス(状態を持たない)設計にした場合、状態をどこに置くべきか、という話です。
DB
一つ目の案はDBに永続化することです。
サーバーを落としても残しておきたいデータ(状態)はDBに格納しましょう。
CookieやLocalStorage
クライアント側に持たせる、という案です。
Cookieはリクエストとともに自動的に送信されます。
「ログイン済み」「ユーザID」などの認証情報、または「カートに何を入れたか」などのアプリケーションで扱いたい「状態」をクライアント側で持っておき、リクエストのたびに合わせて送信します。
サーバー側はそのユーザがついさっきログインしたことを覚えていなくても、リクエストについさっきログインしたことが記載されているため、「ログイン済み」として次の処理を続けることができます。
LocalStorageは自動的には送信されません。
毎回サーバーに送る必要のない情報はここに置いておき、必要に応じて値を取り出してブラウザの処理内で使ったり、リクエストとして送ったりします。
Cookieと合わせて、状態を保持するにはうってつけの箱です。
JWTなどトークン
とりわけ認証情報は改竄されないことが重要ですから、トークンという形でリクエストヘッダーに載せることがあります。
サーバー側では「その人が何者か」「ログインしているのか」という情報は覚えていませんが、ヘッダーに記載されたトークンをリクエストごとに検証することで、認証済みであることを保証できます。
なぜ昨今はステートレスなのか
歴史的な話は得意でないので別のソースも合わせて確認いただきたいです。
ステートレスが推されている理由について、私の理解は以下です。
クラウド化によるスケールアウトの一般化
オンプレミスでは「サーバーを増やす」という行為は労力と時間がかかるものでした。
物理的なものを用意するので当たり前です。
対してクラウドでは、サーバーの個数を動的に変動させることができます。同じユーザーからのリクエストでも同じサーバーが捌くとは限りません。
上で触れた通り、ステートレスはサーバーの冗長構成に強いです。というか、ステートフルの場合、ステートフルであるばっかりに冗長構成を取れないのです。
クラウド化のうまみを活かせない、残念なアプリケーションです。
コンテナ化の台頭
Docker、Kubernetesが普及して、アプリケーションはコンテナ上で動くものとなりました。
コンテナは必要とあらばすぐ生まれ、そしてすぐ死んでいきます。
計画通りに死ぬこともあれば、予期せぬ死を迎えることも往々にしてあります。
でも、「突然死んでも大丈夫、冗長化してるからね」という思想です。
クラウド化の話と同じく、ステートフルな設計はこの思想に合わないのです。
マイクロサービス化の普及
マイクロサービスは「小さい単位が独立している」ことが原則です。
サービス間で状態を持ってしまうと、独立性が失われ、マイクロサービスとしての意義を失ってしまいます。
では、一つのサービス内で状態を持つのはいいのか?というと、もちろんNoです。
各サービスはスケールします。「よく使われるサービス」が使われる分だけスケールする(必要な分スケール、不要な部分はスケールしない)のもマイクロサービス化する利点の一つですので、ここでもやはり冗長化です。
レジリエンスの重要視
「アプリケーションがいつでも使えることが望まれている」ということです。
上記いろいろと冗長化の話をしていますが、冗長化の目的は「24365」を目指すためと言えます。
冗長化のための技術が出揃ってきたことで、より一層のレジリエンスが求められている状況なのだと理解しています。
ステートレスの注意点
もてはやされているステートレスですが、注意するべき点もあります。
リクエストが太りがち
サーバーが状態を持たない分、リクエストに「必要な情報をすべて」詰める必要があります。
認証情報はもちろんのこと、例えば画面遷移を伴うようなアプリケーションでは「前の画面に戻る」ために「前の画面の入力内容」と「今の画面の入力内容」をすべて送る必要があります。
1リクエストが持つデータ量が大きくなり、帯域を圧迫することにもつながります。
フロント側の処理が複雑になる
サーバー側が状態を持たないので、フロント側で状態の管理をうまくやる必要があります。
つまり、フロント側のコードが肥大化、複雑化しやすくなります。
状態を持たないことによるオーバーヘッド
リクエストごとに認証情報を検証したり、リクエストごとに必要な情報をとってきたり、いろいろな場面でオーバーヘッドが気になります。
キャッシュをうまく活用しましょう。
ただし、キャッシュもまた「状態」。
例えばMap型の変数を作ったりしてサーバー内でキャッシュするのは避けましょう。
世の中ではRedisがよく使われているらしいです。
おわりに
古くから続くシステムの場合、「ステートフル」な実装が残っていることがよくあると思います。
これを安全に「ステートレス」にしていくのって、とっても難しいですよね。
フロントとバックエンドの責務を丸ごと変えるような大工事が必要なのでは。という感覚です。