見出し画像

Patterns of Enterprise Application Architecture から学ぶ - Chapter 6

はじめに

こんにちは。ソフトウェアエンジニアのttokutakeと申します。

これはPatterns of Enterprise Application Architecureという本を読んでみて、自分が理解した内容を要約して書き起こしていくシリーズの6回目の記事です。

全8回のシリーズとなる予定です。

間違っている部分などがありましたら、ご指摘いただけますと幸いです。

注意事項

  • 対象読者は主にWebアプリケーションのエンジニアです。

  • 本の内容をそのまま記載しているわけではありません。

    • 内容のすべてを記載すると情報量が多いように感じたので、省略している部分がそれなりにあります。

    • 自分自身の見解を述べている箇所もあります。

Chapter 6. Session State

複数のリクエストをまたぐ一連の操作を一つのまとまりとして考えるのがビジネストランザクションです。並行処理について気にするのも必要ですが、ビジネストランザクションの途中の状態を表すセッションをどこに保存しておくかということも気にしないといけません。

ステートレスの価値

Webアプリケーションにおいて、ステートレスサーバーとは複数のリクエストをまたいで状態を維持しないことを意味します。

本の情報を表示するWebサイトの例を考えます。まずURLパスで指定される ISBN を用いてデータベースから本のタイトルや値段などの情報を取得します。取得した本の情報はメモリー上のオブジェクトなりに保存されます。そのオブジェクトを用いてページを生成して完了です。次に別の本の情報を表示するときにはそのメモリー上のオブジェクトはきれいさっぱり破棄されていて、別の本のための新たなオブジェクトが作成されます。これはステートレスと言えます。

次に特定のIPアドレスのクライアントが訪れたページをISBNのリストを保存することで追跡できるようにしたいと考えます。このISBNのリストをアプリケーションサーバーのメモリー上に保存するとしたら、これはステートフルになります。ステートフルという状態は多くの場合厄介以外の何物でもありません。

ステートフルなサーバーはユーザーがじっくりとページを眺めている間も状態を維持する必要があります。これにより、ロードバランサーのリクエストのルーティングに気を付けなければなりません。またアプリケーションサーバーのスケールインやスケールアウトをするときにも考えることが増えます。近年ではコンテナを用いたWebアプリケーションの運用が当たり前になってきているので、その辺とも相性が非常に悪いです。HTTP自体がステートレスなプロトコルであるので、アプリケーションサーバーはステートレスにしておくのが良いでしょう。

ただし、クライアントとのやり取りは本質的にステートフルであるという問題があります。これはショッピングカートの例を考えるとわかりやすいです。ショッピングカートの中身は買い物という一連の行動で記憶されていなければなりません。このような場合でもステートフルなセッションを導入することで、サーバーをステートレスにできます。

セッション

ショッピングカートはセッションステートです。セッションステートとはある特定のセッションにだけ関係するデータを指します。セッションステートはレコードデータとは区別されます。レコードデータはデータベースに永続化され、すべてのセッションから参照されても問題ないようなデータです。

セッションステートはビジネストランザクションに含まれるので、多くの場合システムトランザクションと同様にACID特性などが要求されます。

ただしIsolationの扱いは非常に難しいです。これについては、データベーストランザクションに任せられるようにビジネスロジックを工夫したり、楽観・悲観ロックを実装するなどの対策ができます。Isolationについての詳しい説明や対策は 前回の記事 をご覧ください。

セッションで保持されているデータがすべてセッションステートというわけではないことに注意してください。例えばキャッシュはパフォーマンスの改善を目的としたデータであり、喪失したところで問題ないという違いがあります。

セッションステートを保存する方法

セッションステートを保存する方法は3つあります。

1つ目はClient Session Stateです。この中でもいくつかの選択肢があります。本には記載されていませんが、現在では Web Storage や IndexedDB も利用できます。

  • URLにエンコードしたデータを含める

  • Cookieを使う

  • `form` に `hidden` typeのタグを含める

  • JSのオブジェクトとして保持する

Client Session Stateの実装例を紹介します。今回はWeb Storageを利用しています。すべてのコードを確認したい場合は こちら をご確認ください。

サーバー側はパスに応じてそれぞれのHTMLを返すだけのシンプルな実装です。

// main.ts

import { dirname, fromFileUrl, serve } from "../deps.ts";

const port = 8080;

const __dirname = dirname(fromFileUrl(import.meta.url));
const indexHtml = await Deno.readTextFile(
  `${__dirname}/index.html`,
);
const nextHtml = await Deno.readTextFile(
  `${__dirname}/next.html`,
);
const confirmHtml = await Deno.readTextFile(
  `${__dirname}/confirm.html`,
);
const completeHtml = await Deno.readTextFile(
  `${__dirname}/complete.html`,
);

function handler(request: Request): Response {
  const url = new URL(request.url);

  let html;
  switch (url.pathname) {
    case "/next":
      html = nextHtml;
      break;
    case "/confirm":
      html = confirmHtml;
      break;
    case "/complete":
      // NOTE: Save sent data into a database here!
      html = completeHtml;
      break;
    default:
      html = indexHtml;
  }

  return new Response(html, {
    status: 200,
    headers: { "content-type": "text/html; charset=utf-8" },
  });
}

console.log(`HTTP webserver running. Access it at: http://localhost:8080/`);
await serve(handler, { port });

次にそれぞれのHTMLについて説明します。まずはindex.htmlです。これは海賊船員の名前と懸賞金額を入力するフォームになっていて、 `Next` ボタンを押すと次のページに遷移します。ただし、次のページに遷移する前に `sessionStorage` に名前を懸賞金額を保存しておきます。

// index.html

<html>
  <head>
    <title>Client Session State</title>
  </head>
  <body>
    <form action="/next">
      <div>
        <label for="name">Name</label>
        <input type="text" id="name" name="name" placeholder="Luffy" required />
      </div>
      <div>
        <label for="bounty">Bounty</label>
        <input type="number" id="bounty" name="bounty" min="0" placeholder="1500000000" required />
      </div>
      <input type="submit" value="Next" />
    </form>
    <script>
      function save(e) {
        const nameInput = document.getElementById("name");
        const bountyInput = document.getElementById("bounty");

        window.sessionStorage.setItem("name", nameInput.value);
        window.sessionStorage.setItem("bounty", bountyInput.value);
      }

      const form = document.querySelector("form");
      form.addEventListener("submit", save);
    </script>
  </body>
</html>
名前と懸賞金額を入力するフォーム

next.htmlは海賊船員の役割を選択するフォームになっています。ここでも船員の役割を `sessionStorage` に保存するようにしています。

// next.html

<html>
  <head>
    <title>Client Session State</title>
  </head>
  <body>
    <form action="/confirm">
      <div>
        <label for="role">Role</label>
        <select id="role" name="role">
          <option value="captain" selected>Captain</option>
          <option value="swordsman">Swordsman</option>
          <option value="navigator">Navigator</option>
          <option value="sniper">Sniper</option>
          <option value="cook">Cook</option>
        </select>
      </div>
      <input type="submit" value="Confirm" />
    </form>
    <script>
      function save(e) {
        const roleSelect = document.getElementById("role");

        window.sessionStorage.setItem("role", roleSelect.value);
      }

      const form = document.querySelector("form");
      form.addEventListener("submit", save);
    </script>
  </body>
</html>
役割を入力するフォーム

次に確認ページのconfirm.htmlです。ここではフォームの値をユーザーが入力することはできないように制御しています。フォームのそれぞれの値は `sessionStorage` に保存されている値であとから埋められます。

// confirm.html

<html>
  <head>
    <title>Client Session State</title>
  </head>
  <body>
    <form action="/complete">
      <div>
        <label for="name">Name</label>
        <input type="text" id="name" name="name" readonly />
      </div>
      <div>
        <label for="bounty">Bounty</label>
        <input type="number" id="bounty" name="bounty" readonly />
      </div>
      <div>
        <label for="role">Role</label>
        <select id="role" name="role">
          <option value="captain">Captain</option>
          <option value="swordsman">Swordsman</option>
          <option value="navigator">Navigator</option>
          <option value="sniper">Sniper</option>
          <option value="cook">Cook</option>
        </select>
      </div>
      <input type="submit" value="Submit" />
    </form>
    <script>
      const name = window.sessionStorage.getItem("name");
      if (name) {
        const nameInput = document.getElementById("name");
        nameInput.value = name;
      }

      const bounty = window.sessionStorage.getItem("bounty");
      if (bounty) {
        const bountyInput = document.getElementById("bounty");
        bountyInput.value = bounty;
      }

      const role = window.sessionStorage.getItem("role");
      if (role) {
        const roleOptions = document.querySelectorAll("option");
        roleOptions.forEach((option) => {
          if (option.value == role) {
            option.selected = true;
          } else {
            option.disabled = true;
          }
        });
      }

      if (!(name && bounty && role)) {
        window.location.href = "/";
      }
    </script>
  </body>
</html>
確認ページ

最後にcomplete.htmlです。これは単に `Submitted!` と表示されるだけのページです。あとはビジネストランザクションを終えたのでセッションに保存したデータを削除するようにしています。実際には `// NOTE: Save sent data into a database here!` というコメントの箇所で、送信されたデータを使って海賊船員のデータをデータベースなどに保存したりすると思いますが、今回は省略しています。

// complete.html

<html>
  <head>
    <title>Client Session State</title>
  </head>
  <body>
    <div>Submitted!</div>
    <script>
      window.sessionStorage.clear();
    </script>
  </body>
</html>
完了ページ

2つ目はServer Session Stateです。これはリクエスト間で利用されるデータをメモリー上に保持する方法です。アプリケーションサーバー上のメモリーに保持することもできますが、欠点が多すぎるため現在はこの方法を用いることはあまりなさそうです。一般的には MemcachedRedis などの KVS として利用できるキャッシュシステムやインメモリーDBを利用します。

Server Session Stateの実装例を紹介します。すべてのコードを確認したい場合は こちら をご確認ください。すこし長ったらしいですが、Client Session Stateの実装例の画面や機能面での差はありません。

// main.ts

import { connect, getCookies, serve, setCookie } from "../deps.ts";

const port = 8080;

const redis = await connect({
  hostname: "redis",
  port: 6379,
});

const defaultSession = {
  name: "",
  bounty: "",
};

async function handler(request: Request): Promise<Response> {
  const url = new URL(request.url);

  let sessionId = getCookies(request.headers).sessionId;
  const headers = new Headers({ "content-type": "text/html; charset=utf-8" });
  if (!sessionId) {
    sessionId = crypto.randomUUID();
    setCookie(headers, { name: "sessionId", value: sessionId });
  }

  let body;
  switch (url.pathname) {
    case "/next": {
      const name = url.searchParams.get("name") || "";
      const bounty = url.searchParams.get("bounty") || "";
      await redis.set(sessionId, JSON.stringify({ name, bounty }));
      body = `
        <form action="/confirm">
          <div>
            <label for="role">Role</label>
            <select id="role" name="role">
              <option value="captain" selected>Captain</option>
              <option value="swordsman">Swordsman</option>
              <option value="navigator">Navigator</option>
              <option value="sniper">Sniper</option>
              <option value="cook">Cook</option>
            </select>
          </div>
          <input type="submit" value="Confirm" />
        </form>
      `;
      break;
    }
    case "/confirm": {
      const role = url.searchParams.get("role") || "captain";
      const sessionString = await redis.get(sessionId);
      const session = sessionString
        ? JSON.parse(sessionString)
        : defaultSession;
      const optionProps = (roleValue: string) =>
        `value="${roleValue}" ${roleValue == role ? "selected" : "disabled"}`;
      body = `
        <form action="/complete">
          <div>
            <label for="name">Name</label>
            <input type="text" id="name" name="name" value="${session.name}" readonly />
          </div>
          <div>
            <label for="bounty">Bounty</label>
            <input type="number" id="bounty" name="bounty" value="${session.bounty}" readonly />
          </div>
          <div>
            <label for="role">Role</label>
            <select id="role" name="role">
              <option ${optionProps("captain")}>Captain</option>
              <option ${optionProps("swordsman")}>Swordsman</option>
              <option ${optionProps("navigator")}>Navigator</option>
              <option ${optionProps("sniper")}>Sniper</option>
              <option ${optionProps("cook")}>Cook</option>
            </select>
          </div>
          <input type="submit" value="Submit" />
          <script>
            const nameInput = document.getElementById("name");
            const bountyInput = document.getElementById("bounty");
            if (!(nameInput.value && bountyInput.value)) {
              window.location.href = "/";
            }
          </script>
        </form>
      `;
      break;
    }
    case "/complete":
      // NOTE: Save sent data into a database here!
      await redis.del(sessionId);
      body = "<div>Submitted!</div>";
      break;
    default:
      body = `
        <form action="/next">
          <div>
            <label for="name">Name</label>
            <input type="text" id="name" name="name" placeholder="Luffy" required />
          </div>
          <div>
            <label for="bounty">Bounty</label>
            <input type="number" id="bounty" name="bounty" min="0" placeholder="1500000000" required />
          </div>
          <input type="submit" value="Next" />
        </form>
      `;
  }

  const html = `
    <html>
      <head>
        <title>Server Session State</title>
      </head>
      <body>
        ${body}
      </body>
    </html>
  `;
  return new Response(html, {
    status: 200,
    headers,
  });
}

console.log(`HTTP webserver running. Access it at: http://localhost:8080/`);
await serve(handler, { port });

まずユーザーには必ず `sessionId` を発行するようにしています。 `sessionId` はCookieに保存されるので、これによってユーザーのセッションを識別します。

セッション中のデータはRedisに保存するようにしています。今回は名前と懸賞金額をRedisに保存して、確認ページでそれらの値を取り出してHTMLを作るようにしています。

3つ目はDatabase Session Stateです。Server Session Stateと同じですが、こちらはリレーショナルDBなどの永続化を目的とするデータベースに保存する方法です。セッションステートのためのテーブルやカラムを別途用意する必要がある場合もあります。

Database Session Stateの実装例を紹介します。すべてのコードを確認したい場合は こちら をご確認ください。こちらもすこし長ったらしいですが、Server Session Stateの実装例と大差はなく、単に保存先をRedisからPostgreSQLに変更した程度です。

import { Client, getCookies, serve, setCookie } from "../deps.ts";

const port = 8080;

const client = new Client({
  user: "postgres",
  password: "password",
  database: "postgres",
  hostname: "db",
  port: 5432,
});

await client.connect();

const defaultSession = {
  name: "",
  bounty: "",
};

async function handler(request: Request): Promise<Response> {
  const url = new URL(request.url);

  let sessionId = getCookies(request.headers).sessionId;
  const headers = new Headers({ "content-type": "text/html; charset=utf-8" });
  if (!sessionId) {
    sessionId = crypto.randomUUID();
    setCookie(headers, { name: "sessionId", value: sessionId });
  }

  let body;
  switch (url.pathname) {
    case "/next": {
      const name = url.searchParams.get("name") || "";
      const bounty = url.searchParams.get("bounty") || "";
      await client.queryArray`
        INSERT INTO sessions
        VALUES (${sessionId}, ${name}, ${bounty})
        ON CONFLICT ON CONSTRAINT sessions_pkey
        DO
        UPDATE SET name=${name}, bounty=${bounty}
      `;
      body = `
        <form action="/confirm">
          <div>
            <label for="role">Role</label>
            <select id="role" name="role">
              <option value="captain" selected>Captain</option>
              <option value="swordsman">Swordsman</option>
              <option value="navigator">Navigator</option>
              <option value="sniper">Sniper</option>
              <option value="cook">Cook</option>
            </select>
          </div>
          <input type="submit" value="Confirm" />
        </form>
      `;
      break;
    }
    case "/confirm": {
      const role = url.searchParams.get("role") || "captain";
      const result = await client.queryObject<typeof defaultSession>`
        SELECT name, bounty
        FROM sessions
        WHERE id = ${sessionId}
      `;
      const session = result.rows.length ? result.rows[0] : defaultSession;
      const optionProps = (roleValue: string) =>
        `value="${roleValue}" ${roleValue == role ? "selected" : "disabled"}`;
      body = `
        <form action="/complete">
          <div>
            <label for="name">Name</label>
            <input type="text" id="name" name="name" value="${session.name}" readonly />
          </div>
          <div>
            <label for="bounty">Bounty</label>
            <input type="number" id="bounty" name="bounty" value="${session.bounty}" readonly />
          </div>
          <div>
            <label for="role">Role</label>
            <select id="role" name="role">
              <option ${optionProps("captain")}>Captain</option>
              <option ${optionProps("swordsman")}>Swordsman</option>
              <option ${optionProps("navigator")}>Navigator</option>
              <option ${optionProps("sniper")}>Sniper</option>
              <option ${optionProps("cook")}>Cook</option>
            </select>
          </div>
          <input type="submit" value="Submit" />
          <script>
            const nameInput = document.getElementById("name");
            const bountyInput = document.getElementById("bounty");
            if (!(nameInput.value && bountyInput.value)) {
              window.location.href = "/";
            }
          </script>
        </form>
      `;
      break;
    }
    case "/complete":
      // NOTE: Save sent data into a database here!
      await client.queryArray`
        DELETE FROM sessions
        WHERE id = ${sessionId}
      `;
      body = "<div>Submitted!</div>";
      break;
    default:
      body = `
        <form action="/next">
          <div>
            <label for="name">Name</label>
            <input type="text" id="name" name="name" placeholder="Luffy" required />
          </div>
          <div>
            <label for="bounty">Bounty</label>
            <input type="number" id="bounty" name="bounty" min="0" placeholder="1500000000" required />
          </div>
          <input type="submit" value="Next" />
        </form>
      `;
  }

  const html = `
    <html>
      <head>
        <title>Database Session State</title>
      </head>
      <body>
        ${body}
      </body>
    </html>
  `;
  return new Response(html, {
    status: 200,
    headers,
  });
}

console.log(`HTTP webserver running. Access it at: http://localhost:8080/`);
await serve(handler, { port });

今回は `sessions` というテーブルを用意しましたが、リレーショナルデータベースなどを使う場合はセッションの途中のデータもドメインモデルとして定義して保存するような選択もありえると思います。ビジネスとしてクリティカルな箇所では揮発しないデータベースにデータを保存するのは賢明です。そうすることでバグやエラーが発生したときに調査しやすくしておいたことで助けられたという経験が多々あります。(ちなみにデータベースじゃなくてもよくて、単にストレージにログを保存するとかでも良いです。)

それぞれの方法には注意すべき点があります。

Client Session Stateは逐一セッションステートをサーバーに送信する必要があるので、ネットワーク帯域をより多く使います。また画面の表示には利用されないようなデータも逐一送る必要があります。そのため、データサイズが大きくなるならClient Session Stateを利用するのはあまりオススメできません。悪意のあるユーザーによるデータの改ざんやサーバー側でデータマイグレーションが行われたあとにデータの整合性が取れているのかなど、気をつかわなければならない面も持ち合わせています。

ただし、Web StorageやIndexedDBを利用したり、 SPA で実装している場合はネットワーク帯域については問題になりにくいです。データの改ざんや整合性について気にするだけで済みます。

Database Session StateはIsolationに関して気をつかう必要があります。例えばあるユーザーが飛行機の予約をしようとしていても、まだ予約が確定していないなら他のユーザーに影響を与えてはいけません。このようなことが起こらないようにテーブルの設計などには十分注意する必要があります。

セッションの実装において一番バグを生みやすいのはセッションをキャンセルしてユーザーが「なかったことにして」と言うときです。特にB2Cのシステムではユーザーが明示的に「なかったことにして」と言うことがあんまりないからです。買い物をしていて途中でやめるときに、ショッピングカートを明示的に空にすることはなく、大抵はいきなりブラウザのタブを閉じたりアプリケーションを終了させます。実装にもよりますが、これはClient Session Stateでは実現しやすく、Server Session Stateではタイムアウトを用いて実現するのが一般的です。

これは個人的な見解ですが、Database Session Stateはデータがきっちり永続化されるので、ユーザーの問い合わせがあって調査が必要になる場面が出てくるときには有利です。そういった状況の分析や調査をしたい場合はDatabase Session Stateを候補として検討することは多いです。

3つのパターンは組み合わせて使って良いものです。またどのパターンを用いるとしても、Client Session Stateでセッションキーを保持する必要はあります。

さいごに

今回はChapter 6. Session Stateについての紹介をしました。

説明が不足していたり、わかりにくいようなところがありましたら、お気軽にご連絡いただければと思います。

次回はChapter 7. Distribution Strategiesを紹介します。どうぞよろしくお願いします。

この記事が気に入ったらサポートをしてみませんか?