「飲食店のスマホオーダーシステム」にキッチン側画面を追加しました

客側の画面だけじゃだめだよね

前回、飲食店のスマホオーダーシステムを作ってみましたが、作ったのは客が使うスマホ画面だけで、店舗側、というかキッチンで注文を見る画面を作ってなかった。というわけで作りました。今回はGitHub CopilotとCodex併用です(ほとんどCodex)。

画面

私は飲食店で働いているわけではないので、この画面は想像で作っています。客が注文した料理とかドリンクとかが、ここに表示されます。キッチンにタブレットあたりを置いて、表示するのかな、と思っています。

新しい注文が上に出るようになってますが、それが正しいのかよくわからない。Twitterとかでも新しいものが上に出るのでこうしたのですが、あれは「古いものは見なくていい」前提だしな。飲食店で古いオーダーが無視されたら私なら怒るわけで、逆がいいのかな。

料理を提供したら、「提供済みにする」を押すと提供済み一覧画面に移動します。提供済み一覧画面はこんな感じ。

会社の近くの中華料理屋でよく昼飯を食べるのですが、そこはスマホオーダーなのに、しょっちゅう「ご注文は何でしたっけ?」と聞きに来ます。あれは料理人が提供したつもりで「提供済みにする」を押すと消えてしまって何だったかわからなくなってしまうのかな、と想像して、提供済み一覧画面から簡単に戻せるように「未提供に戻す」ボタンを付けました。

WebSocket

キッチン側の注文一覧画面は、客が注文したら、料理人が手を触れなくても勝手に表示される必要があると思います。手は濡れていたり汚れていたりするでしょうし、タブレットの画面に触ってリロードとかできないでしょうから。つまり、サーバに客から注文が届いたら、キッチンのタブレットにプッシュ型の通知を行う必要があるわけで、そういう場合に使うのがWebSocketです。

WebSocketというのはその名の通り、Webで使えるソケットです。ソケットというのは以前こちらにもちょっと書いた通り(本にはもうちょっと詳しく書いた)、2台のコンピュータ間に張られる双方向の通信路の両端を指します。つまりWebSocketの通信経路を一度張ってしまえば、以後はサーバからクライアントに向けて通信することも、もちろんクライアントからサーバに向けて通信することも可能になるわけです。

WebSocketは以下のように使います。

  1. サーバ側で、WebSocketの待ち受けのエンドポイントを作る

    Javaの場合、Java API for WebSocket(JSR356)という規格があって、Tomcatもこれを実装しています(サーブレットAPIの範疇ではないらしい)。クラスを作って、それに@ServerEndpointというアノテーションを付けることで、Tomcatがこれを検索しクライアントからの接続を待ち受けしてくれます。

    // 「/ws/endpoint」というURLで待ち受けるエンドポイントを作る
    @ServerEndpoint("/ws/endpoint")
    public class WebSocketEndpoint {
        @OnOpen
        public synchronized void onOpen(Session session) {
            // クライアントから接続されたら呼び出される。
            // 実際のアプリケーションでは認証等もしなければいけないので、
            // 今回、ここでは何もしない。
        }
    
        @OnMessage
        public void onMessage(String message, Session session) throws Exception {
            // クライアントからメッセージが送られてきたら呼び出される
        }
    
        @OnClose
        public void onClose(Session session) {
            // クライアントが接続を切断したら呼び出される
        }
    }
    

    上の例では@OnOpen@OnMessage@OnCliseというアノテーションのついたメソッドを実装していて、だいたい想像は付くでしょうがクライアントから接続されたときにonOpen()が、クライアントから何らかのメッセージが送られたときにonMessage()が、クライアントが接続を切断したらonClose()が呼び出されます。

    なお、このWebSocketEndpointクラスは、クライアントからの接続に対してひとつずつ作られます。なのでクライアントごとの情報をここに置いてもかまいません。

    また、今回の例なら、キッチンタブレットが複数接続していた場合、注文があったら全タブレットに通知を送らなければいけませんが、そのためには「すべての接続をリストか何かで保持しておく」必要があります。そのような場合は、onOpen()onMessage()の引数にあるSessionオブジェクトを、どの接続かわかるような付属情報とともにリストに入れることになるでしょう。メッセージを送るためのメソッドはSessionから(間接的に)生えているためです。そしてその一覧を持つリストはstaticフィールドあたりに保持することになります。

    ――ということは@WebSocketEndpointのインスタンスとSessionのインスタンスは寿命が(ほぼ)同じなわけで、なんで分けてあるのかな、と私は思うのですが……

  2. クライアントから接続する

    上で作ったエンドポイントのURLを指定して、クライアントから接続します。今回の例で言えば、キッチン側の画面を開いた時点で、/ws/endpointに向けて接続します。

    この場合のURLは、たとえばkmaebashi.com以下のsporderアプリケーションなら、「wss://kmaebashi.com/sporder/ws/endpoint」になります。これをべた書きにしてよいなら簡単なのですが、もちろん別サーバに配置するかもしれず、テスト中はws://localhost:8080/~にしたいし(「wss://」部分はhttpなら「ws://」になる)、もちろんsporderというアプリケーション名だって固定にはしたくありません。そこで、現在のプロトコルがhttpがhttpsか見分けたり、ホスト名を取得したり、サーバからコンテキストパスをもらったりする必要があります。

    以下はTypeScriptのサンプルです。

    // コンテキストパス(URLのsporder部分)をサーバから取得する
    declare const APP_CONTEXT_PATH: string;
    
    // http://ならws://、https://ならwss://
    const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
    const url = `${protocol}//${window.location.host}${APP_CONTEXT_PATH}/ws/endpoint`;
    
    const socket = new WebSocket(url);
    
    socket.addEventListener("open", () => {
        // 接続確立時の処理。JSONを組み立てて送っている。
        const connectInfo: KitchenConnectInfo = {
            type: "kitchenconnect",
            rtId: getWebSocketParam("rt_id"),
            tokenId: getWebSocketParam("token_id"),
            token: getWebSocketParam("token")
        };
        // これによりサーバ側のエンドポイントのonMessage()が呼び出される
        socket.send(JSON.stringify(connectInfo));
    }
    
    socket.addEventListener("message", (event: MessageEvent) => {
        // サーバからメッセージが届いたら呼び出される。
        // スマホオーダーのキッチンタブレットなら、ここで客から受けた注文を表示する。
        const data = JSON.parse(event.data);
        // …
    });
    

    6行目まででURLを組み立て、8行目でそのURLに接続してソケットを作ります。これにより、サーバとクライアントで双方向に通信可能な通信経路が作られます。

    接続が成功したら10行目からで設定しているイベントハンドラが呼び出されるので、そこでsocket.send()によりサーバにメッセージを送ります。送るのは単なる文字列ですが、ここではJSONを送っています。これにより、上に載せたサーバ側のonMessage()が呼び出されます。この例では、キッチン側タブレットがサーバに接続したので、レストランIDとか認証用のトークンとかを渡しています。サーバ側ではこれを受け取って、クライアントごとの接続を管理するわけです。

    逆に、サーバからメッセージが送られたら、22行目で設定したイベントハンドラが呼び出されます。スマホオーダーシステムのキッチン端末の場合、ここで、客から受けた注文を表示することになります。

一連の流れを図にすると、こんな感じになります。

PostgreSQLのLISTEN/NOTIFY

上で書いた通りの方法で、キッチン側のタブレットに通知を送ることはできます。APサーバが1台ならこれで問題ないのですが、ロードバランサを入れて複数台のAPサーバを立てるとなるとこれだけではうまくいきません。

いまどきのロードバランサは、WebSocketの通信も中継してくれます。WebSocketは、HTTPのようなステートレスな通信とは違い、「一度接続したらその接続を維持する」通信なので、ロードバランサは最初に接続したAPサーバとの通信を維持します(維持するそうです、と言うべきか。私が今個人で使える環境にロードバランサはないので試してはいません)。つまり、APサーバが2台あったとして、キッチン側タブレットはそのうちのどちらか1台と接続します。客の注文もロードバランサで振り分けられてどちらか1台のサーバに届きます。客の注文を受けたAPサーバから、そのAPサーバにWebSocketでつながっているキッチンタブレットすべてに通知を送ったところで、他のAPサーバにつながっているキッチンタブレットには届きません。

ぶっちゃけうちのサーバ(kmaebashi.com)は1台しかないので今からAPサーバ複数台運用を考えても意味はないでしょうが(それを言うならこのスマホオーダーシステムに顧客がいるわけではないので今作っているものすべてに意味はないですが)、APサーバ1台を前提にするのは癪なのでChatGPTに聞いてみたら、PostgreSQLのLISTEN/NOTYFYというのを教えてくれて、うちのDBはPostgreSQLなのでこれを採用しました。

LISTEN/NOTIFYというのは、LISTENによりチャンネルの「購読」登録をしておくと、NOTIFYでチャンネルにペイロードを送ったとき「購読」していた側すべてにそれが通知される、という仕組みです。図にするとこんな感じ。

この機能をJavaで使う最小のサンプルプログラムも載せておきます。まずは通知を受ける側、Listener.javaから。

import java.sql.*;
import org.postgresql.PGConnection;
import org.postgresql.PGNotification;

public class Listener {
    public static void main(String[] args) throws SQLException {
        String url = "jdbc:postgresql://localhost/sporderdb?currentSchema=sporderschema";
        String user = "sporderuser";
        String password = "XXXXXXXX";

        try (Connection con = DriverManager.getConnection(url, user, password)) {
            Statement stmt = con.createStatement();
            // LISTENでhogechannelを購読登録する
            stmt.execute("LISTEN hogechannel");
            stmt.close();

            // getNotifications()を呼ぶために、JDBC標準のConnectionから
            // PostgreSQL専用の接続であるPGConnectionを取得する。
            PGConnection pgCon = con.unwrap(PGConnection.class);
            while (true) {
                // 通知を待ち受ける。ここで実行はブロックされる。
                PGNotification[] notifications = pgCon.getNotifications(0);

                // 通知が届くとここに来る。getParameter()でペイロードを取得できる。
                for (int i = 0; i < notifications.length; i++) {
                    System.out.println("notification[" + i + "].." + notifications[i].getParameter());
                }
            }
        }
    }
}

14行目で「LISTEN hogechannel」というSQLを発行し、hogechannelというチャンネルを購読するように登録します。そのうえで、22行目のgetNotifications()の呼び出しで、通知待ちに入ります。この呼び出しは通知が届くまで戻ってきません(getNotifications()の引数はタイムアウト時間で、今回は0を渡しているので永久に待ちます)。そして、通知が届くとgetNotifications()を抜けて、届いた通知が戻り値のPGNotification[]に格納されてきます。192行目からのforループでそれを表示しています(今回の使い方では、このforループはたいてい1回しか回らない)。

なお、LISTEN/NOTIFYの仕組みはPostgreSQLに特有のものなので、標準のJDBCのConnectionにはgetNotification()などというメソッドは付いてません。そこで19行目でConnectionからPGConnectionを取得しています。unwrap()とかいう妙なメソッドを呼ばなくても、PGConnectionConnectionを継承しているならダウンキャストで済むのでは、と思うかもしれませんが(そして実際このケースではそれでも動きますが)、コネクションプールなどを使うと利用者に見えるConnectionはもともとの接続をラップしたものであることがあり、そのような場合でも指定したクラスの接続を返してくれる、というのがunwrap()の機能です。

次は通知を送る方、Notifier.javaです。

import java.sql.*;

public class Notifier {
    public static void main(String[] args) throws SQLException {
        String url = "jdbc:postgresql://localhost/sporderdb?currentSchema=sporderschema";
        String user = "sporderuser";
        String password = "XXXXXXXX";

        try (Connection con = DriverManager.getConnection(url, user, password)) {
            Statement stmt = con.createStatement();
            // hogechannelに「payload! payload!」というペイロードの通知を送る。
            stmt.execute("NOTIFY hogechannel, 'payload! payload!'");
            stmt.close();
        }
    }
}

12行目で、hogechannelに「payload! payload!」というペイロードの通知を送っています。ペイロードの文字数制限はデフォルトで8000バイトです。ちょっとしたJSONくらいならこれを使って送れるでしょう。

上のListener.javaとNotifier.javaの試し方も書いておきます(Windows想定)。

まず、Listener.javaとNotifier.javaを同一フォルダに置き、さらにPostgreSQLのJDBCドライバ(ここからダウンロードできます。今ならpostgresql-42.7.13.jar)も同じフォルダに置いて、以下のようにコンパイルします。

C:\test>javac -classpath ".;postgresql-42.7.13.jar" *.java

その上で、コマンドプロンプトをふたつ開いて、片方でListenerを実行します。この時点では、何も表示されず、固まったように見えます。

C:\test>java -classpath ".;postgresql-42.7.13.jar" Listener

もう片方のコマンドプロンプトで、Notifierを実行します。

C:\test>java -classpath ".;postgresql-42.7.13.jar" Notifier

これにより、Listener側に通知が届いて、こんな表示になります(以下はNotifierを2回実行した状態)。

C:\test>java -classpath ".;postgresql-42.7.13.jar" Listener
notification[0]..payload! payload!
notification[0]..payload! payload!

ところで、実用に使うには、ペイロード部分は可変になることでしょう。そこで、ペイロード部分をパラメタにしようと以下のように書くと、

PreparedStatement stmt
    = con.prepareStatement("NOTIFY hogechannel, ?")) {

stmt.setString(1, "payload! payload!");
stmt.execute();
ERROR: syntax error at or near "$1"」というエラーになります。どうもNOTIFYのペイロードはパラメタにできないようです。

そこで、実用的には、NOTIFYを直接使うのではなく、関数pg_notify()を使うことになります。

PreparedStatement stmt
    = con.prepareStatement("SELECT pg_notify('hogechannel', ?)")) {

stmt.setString(1, "payload payload payload!");
stmt.execute();

pg_notify()だと、チャンネル名もパラメタにできます(あまり意味はないかもですが)。

音を出す

客からの注文が入ってキッチンタブレットに表示が増えるとき、音ぐらいは鳴らしたいものです。そこで、AIに音も作ってもらいました。下のボタンを押すと鳴ります。

これを鳴らすJavaScriptは簡単で、以下のように音声ファイルのファイル名を引数にAudioのインスタンスを作って、そのplay()を呼ぶだけです。このページでは、上の「音が出るよ」ボタンのonclickplayNotice()を呼んでいます。

const noticeAudio = new Audio("kitchen-notice.wav");
function playNotice() {
  noticeAudio.play();

――このように、ボタンを押すとかの、ユーザの操作で音を鳴らすなら簡単なのですが。

いまどきのブラウザでは、ただWebページを表示したりとか、WebSocketで通知を受けたりしただけでは音は鳴らないように設定されています。たとえばChromeの場合、以下のページのような設定になっているようです。

Chromeの自動再生ポリシー

これによると「ユーザーがドメインを操作した(クリック、タップなど)」の場合は音が出ると書いてあるわけで、上の「音が出るよ」ボタンで音が出るのはそのためです。一度ボタンを押すなどして音が出るようになってしまえば、リロードするまでは、プログラムから音を鳴らしてもちゃんと鳴ります。

――という理由で、キッチン側注文一覧画面では、右上に「音声通知を有効にする」ボタンが付いているわけです。これを押すと音が鳴るようになり、ボタンは非表示になります。

左には「リロード」ボタンがついていますが、これは、普通にブラウザの機能でリロードしてしまうと、「音声機能を有効にする」を押したことがリセットされてしまうためです。この「リロード」ボタンは、ブラウザのリロードではなく、中身だけJSONでサーバから取り寄せて内容を再表示します。

サーバ設定

うちのサーバ(kmaebashi.com)は、WebサーバがApacheで、サーブレットコンテナはTomcat、ブログとか幹事郎とかスマホオーダーシステムとかのWebアプリケーションにリクエストが来たときは、Apacheがいったん受けてAJP(Apache JServ Protocol)でTomcatを呼び出す、という設定になっています。昔はこれが定番でしたが、いまとなっては多分古臭い構成なのだと思います※1

ApacheがTomcatにリクエストを流すための設定が、うちのサーバの場合/etc/httpd/conf/extra/httpd-proxy.confというファイルに書いてありました。

ProxyPass /sporder ajp://localhost:8009/sporder

これは、/sporder以下のURLにリクエストがあったら、8009番ポートでローカルで動いているTomcatをAJPで呼び出す、という設定です。

ただ、WebSocketの場合、「最初はHTTP(またはHTTPS)で接続を作り、以後はWebSocketで通信する」という動きをするわけですが、この最初の接続がAJPではできないので、/ws/以下の通信はhttpで送る必要があるようです(ChatGPTに聞いた)。というわけで、/etc/httpd/conf/extra/httpd-proxy.confをこうしたらつながりました。

ProxyPass /sporder/ws/ http://localhost:8080/sporder/ws/ upgrade=websocket
ProxyPass /sporder ajp://localhost:8009/sporder

/ws/以下への設定を先に書くのがミソです。

実際の画面

注文用ページ

前回の記事に載せてあるのと同じですが、このURLまたはQRコードから注文用ページに飛べます。

カウンター1
https://kmaebashi.com/sporder/qr?rt_id=nonbe_republic&table_code=A0BoJKiIQbtTSt5xVJn6uQ

キッチン用ページ

こちらがキッチンタブレット用のページです。

https://kmaebashi.com/sporder/kitchenorderlist?rt_id=nonbe_republic&token_id=ZjvFxK0Ah4LMOReawsllLA&token=BaszQfD001Bxa7KAzhNNtg

公開日: 2026/08/13



間違い等ありましたら、掲示板にご連絡願います。

ひとつ前 | ひとつ上のページへ戻る | トップページへ戻る