App Engine と Firebase によるリアルタイム イベントの処理

この記事では、App EngineFirebase を接続し、Firebase を使用して対話型マルチプレーヤー ゲームの Tic Tac Toe でリアルタイムの更新を送信する方法を説明します。Tic Tac Toe プロジェクトで Python または Java のサンプルコードを入手できます。

Firebase Realtime Database と一緒に App Engine を使用すると、ブラウザとモバイル クライアントに更新をすぐに送信できます。サーバーにストリーミング接続をしたり、長期間ポーリングを行ったりする必要はありません。新しい情報をリアルタイムでユーザーに送信するアプリケーションでは、これは便利な機能です。たとえば、協調型のアプリ、マルチプレーヤー ゲーム、チャットルームなどで有効です。

更新を予測できない場合やスクリプトで処理できない場合(たとえば、ユーザー間で情報を交換している、イベントが体系的に生成されないなど)には、ポーリングよりも Firebase のほうが適しています。

この記事では、サーバーで次のタスクを行う方法を説明します。

  • Firebase Realtime Database を使用するようにサーバーを設定する。
  • サービスに接続するウェブ クライアントごとに一意の Firebase データベース参照を作成する。
  • 特定のデータベース参照でデータを変更し、ウェブ クライアントに更新をリアルタイムで送信する。
  • Firebase データベース セキュリティ ルールを使用して、ウェブ クライアントごとに一意のトークンを作成し、メッセージにアクセスするときの安全性を強化する。
  • ウェブ クライアントから HTTP 経由でメッセージを受信する。
  • 対戦終了後にデータベースからゲームデータを削除する。

この記事では、ウェブブラウザで次のタスクを行う方法も説明します。

  • 一意のトークンを使用して、サーバーから Firebase に接続する。
  • データベース参照の更新に合わせて、インターフェースを動的に更新する。
  • リモート クライアントに配信する更新メッセージをサーバーに送信する。

Firebase Realtime Database を使ってみる

Firebase Realtime Database を使用すると、クライアント側のコードからデータベースに直接アクセスできるので、高機能で協調型のアプリを作成できます。データはローカルに保存されます。オフラインの間でもイベントがリアルタイムで生成されるので、応答性が高くなります。端末がオンラインになると、端末のオフライン中にリモートで発生した変更とローカルの変更を Realtime Database が自動的に同期し、矛盾を解決します。

Firebase プロジェクトを作成する

  1. Firebase アカウントを作成するか、既存のアカウントにログインします。

  2. [プロジェクトを追加] をクリックします。

  3. [プロジェクト名] フィールドに名前を入力します。

  4. 残りの設定手順を行い、[プロジェクトの作成] をクリックします。

  5. ウィザードでプロジェクトがプロビジョニングされたら、[続行] をクリックします。

  6. プロジェクトの [概要] ページで [設定] の歯車をクリックし、[プロジェクトの設定] をクリックします。

  7. [ウェブアプリに Firebase を追加] をクリックします。

  8. 初期化コードのスニペットをコピーします。このコピーは、ウェブページに Firebase SDK を追加するセクションで必要です。

Realtime Database の作成

  1. Firebase コンソールの左側のメニューで、[開発] グループの [データベース] を選択します。

  2. [データベース] ページで [Realtime Database] セクションに移動し、[データベースの作成] をクリックします。

  3. [Realtime Database のセキュリティ ルール] ダイアログで、[テストモードで開始] を選択して [有効にする] をクリックします。

Firebase でデータを操作する

Firebase Realtime Database のデータは JSON オブジェクトとして保存されています。このデータベースは、クラウドにホスティングされた JSON ツリーと考えることができます。SQL データベースと異なり、テーブルもレコードもありません。データを JSON ツリーに追加すると、既存の JSON 構造のノードとして、関連するキーと一緒に保存されます。ユーザー ID やセマンティック名など、独自のキーを指定できます。

この構造ではデータの読み取りを簡単に行うことができます。クライアントは、必要なパスに移動するだけで、オブジェクト全体が JSON 形式で返されます。データベースのフィールドに特別な処理を行う必要はありません。詳細については、Firebase のドキュメントでデータベースの構築方法に関する説明をご覧ください。

Java バックエンドでは、REST API や Firebase Admin SDK を使用できます。Java 対応の Firebase Admin SDK を App Engine で使用する場合は、必ず Java 8 ランタイムを使用してください。SDK バージョン 6.0.0 以降では、App Engine Java 8 ランタイムで入手可能なマルチスレッド処理のサポートを使用します。

REST API を使用すると、Firebase は標準の HTTP PUTPATCHPOSTGETDELETE メソッドを使用してデータベース オペレーションを実行します。Firebase でのオペレーションの詳細については、Firebase ドキュメントのデータの保存データの取得をご覧ください。

データを作成してメッセージを送信する

認証済みのリクエストを実行するため、アプリは必要な権限スコープを使用して App Engine からアプリケーションのデフォルトの認証情報を取得し、それを使用して HTTP オブジェクトを作成する必要があります。この HTTP オブジェクトを使用するリクエストには、Firebase へのリクエストで必要になる Authorization ヘッダーが自動的に追加されます。詳細については、サーバー間での本番環境アプリケーションの認証の設定をご覧ください。

次の例のように、PUT HTTP メソッドを使用して特定の Firebase パスでデータの書き込みまたは置換を行うことができます。

Python

def _get_http():
    """Provides an authed http object."""
    http = httplib2.Http()
    # Use application default credentials to make the Firebase calls
    # https://firebase.google.com/docs/reference/rest/database/user-auth
    creds = GoogleCredentials.get_application_default().create_scoped(
        _FIREBASE_SCOPES)
    creds.authorize(http)
    return http
...
def firebase_put(path, value=None):
    """Writes data to Firebase.

    An HTTP PUT writes an entire object at the given database path. Updates to
    fields cannot be performed without overwriting the entire object

    Args:
        path - the url to the Firebase object to write.
        value - a json string.
    """
    response, content = _get_http().request(path, method='PUT', body=value)
    return json.loads(content)

Java

public HttpResponse firebasePut(String path, Object object) throws IOException {
  // Make requests auth'ed using Application Default Credentials
  Credential credential = GoogleCredential.getApplicationDefault().createScoped(FIREBASE_SCOPES);
  HttpRequestFactory requestFactory = httpTransport.createRequestFactory(credential);

  String json = new Gson().toJson(object);
  GenericUrl url = new GenericUrl(path);

  return requestFactory
      .buildPutRequest(url, new ByteArrayContent("application/json", json.getBytes()))
      .execute();
}

GitHub で PATCHPOST のサンプルを確認するには、前のコードの例で [GitHub で表示] ボタンをクリックします。

データを読み取るため、特定のパスに HTTP GET リクエストを送信します。リクエストした場所の JSON オブジェクトがレスポンスで返されます。

Python

def firebase_get(path):
    """Read the data at the given path.

    An HTTP GET request allows reading of data at a particular path.
    A successful request will be indicated by a 200 OK HTTP status code.
    The response will contain the data being retrieved.

    Args:
        path - the url to the Firebase object to read.
    """
    response, content = _get_http().request(path, method='GET')
    return json.loads(content)

Java

public HttpResponse firebaseGet(String path) throws IOException {
  // Make requests auth'ed using Application Default Credentials
  Credential credential = GoogleCredential.getApplicationDefault().createScoped(FIREBASE_SCOPES);
  HttpRequestFactory requestFactory = httpTransport.createRequestFactory(credential);

  GenericUrl url = new GenericUrl(path);

  return requestFactory.buildGetRequest(url).execute();
}

PATCHDELETE の使用例については、サンプルアプリ全体を見直すをご覧ください。

ウェブブラウザからリアルタイム イベントをリッスンする

リアルタイム イベントをリッスンするには、次のことを行う必要があります。

  • ウェブページに Firebase SDK を追加する。
  • データが保存されている Firebase パスの参照を追加する。
  • リスナーを追加する。
  • コールバック関数を実装する。

ウェブページに Firebase SDK を追加する

ウェブページに Firebase SDK を追加するには:

  • Firebase コンソールでプロジェクトに移動します。
  • [ウェブアプリに Firebase を追加] をクリックします。
  • 指定のスニペットをコピーし、HTML コードに貼り付けます。たとえば、このサンプル プロジェクトでは次のようにします。

    Python

    ファイル templates/_firebase_config.html の内容をスニペットで置き換えます。このファイルは、メインのテンプレート ファイル fire_index.html に含まれています。

    Java

    ファイル src/main/webapp/WEB-INF/view/firebase_config.jspf の内容をスニペットで置き換えます。このファイルは、メインのテンプレート ファイル index.jsp に含まれています。

データの変更をリッスンする

Firebase データを取得するには、非同期リスナーを firebase.database.Reference に接続します。リスナーは、データの初期状態で 1 回トリガーされ、以降はデータが変更されるたびにトリガーされます。クライアントでは、次の 2 種類の変更をリッスンできます。

  • 値イベント: パスのコンテンツ全体に対する変更を読み取り、リッスンします。値イベントは、1 つのオブジェクトに対する変更をモニタリングする場合に使用します。
  • 子イベント: 特定のパスの子要素に対する変更を読み取り、リッスンします。子イベントは、オブジェクト リスト内での変更をモニタリングする場合に使用します。リッスンできる子イベントは child_addedchild_changedchild_movedchild_removed です。

このサンプルアプリでは、サーバーとクライアントの間で 1 つのゲーム状態オブジェクトを同期しています。このため、この記事では値イベントのリッスンについて説明します。子イベントのリスナーと高度なデータ取得技術については、ウェブでデータを取得するをご覧ください。

次のコードでは、値の変更イベントをリッスンし、イベント発生時のコールバック関数を追加しています。このコードではまずデータベース参照(アプリがリッスンするオブジェクトのパス)を作成します。次に、reference.on() を使用してリスナーが参照に追加されます。ここで、コールバック関数が引数として渡されます。

この例では、リアルタイムの Tic Tac Toe アプリが、特定のチャネルでリッスンしているクライアントにゲーム情報を送信します。このクライアントがユーザーのゲームに対応し、データベースでこの情報が変更されると、UI を更新します。変数 firebase は、ウェブページに Firebase を追加するで追加した JavaScript スニペットによって自動的に作成されます。

Python

// setup a database reference at path /channels/channelId
channel = firebase.database().ref('channels/' + channelId);
// add a listener to the path that fires any time the value of the data changes
channel.on('value', function(data) {
  onMessage(data.val());
});

Java

// setup a database reference at path /channels/channelId
channel = firebase.database().ref('channels/' + channelId);
// add a listener to the path that fires any time the value of the data changes
channel.on('value', function(data) {
  onMessage(data.val());
});

コールバック関数 onMessage() の実装方法は、この記事の後半で説明します。

リスナーの接続を解除する

コールバックを削除するには、Firebase データベース参照の off() メソッドを呼び出します。

App Engine にイベントを戻す

App Engine は現在、HTTP 接続での双方向ストリーミングに対応していません。クライアントでサーバーを更新する必要がある場合には、明示的な HTTP リクエストを送信する必要があります。

データへのアクセスを制限する

Firebase では、認証トークンを使用して、特定のユーザーにデータへのアクセスを許可できます。書き込みアクセスはサーバーだけに許可し、認証を行うウェブ クライアントには読み取り専用アクセスを許可するようにしてください。以下のコードは次の処理を行います。

  • App Engine サーバーの認証情報を生成する。
  • 各クライアントのトークンを生成する。
  • データを読み取るため、ウェブ クライアントにトークンを送信する。

App Engine と Firebase 間の通信を確立する

App Engine に組み込まれているアプリケーションのデフォルト認証情報を使用すると、App Engine サーバーから Firebase データベースに対する認証済みの REST 呼び出しを行うことができます。

Python

oauth2client パッケージの GoogleCredentials オブジェクトをインスタンス化し、適切なスコープを使用して Firebase へのアクセスを許可します。次に、このオブジェクトを使用して httplib2.Http オブジェクトの認証を行います。

def _get_http():
    """Provides an authed http object."""
    http = httplib2.Http()
    # Use application default credentials to make the Firebase calls
    # https://firebase.google.com/docs/reference/rest/database/user-auth
    creds = GoogleCredentials.get_application_default().create_scoped(
        _FIREBASE_SCOPES)
    creds.authorize(http)
    return http

このオブジェクトには適切な認証トークンが自動的に組み込まれるため、httplib2.Http オブジェクトを直接使用して REST 呼び出しを行うことができます。次の関数は、_get_http() から返されたオブジェクトを使用して、PATCH または DELETE のいずれかのリクエストを送信します。この例では、return ステートメントの一部で _get_http() を呼び出し、認証済みの HTTP オブジェクトを取得しています。

def _send_firebase_message(u_id, message=None):
    """Updates data in firebase. If a message is provided, then it updates
     the data at /channels/<channel_id> with the message using the PATCH
     http method. If no message is provided, then the data at this location
     is deleted using the DELETE http method
     """
    url = '{}/channels/{}.json'.format(_get_firebase_db_url(), u_id)

    if message:
        return _get_http().request(url, 'PATCH', body=message)
    else:
        return _get_http().request(url, 'DELETE')

Java

App Engine では、バックグラウンド スレッドの作成が制限されています。このため、Firebase サーバーの SDK ではなく、Firebase API を使用する必要があります。

まず、アプリケーションのデフォルト認証情報を取得して、UrlFetchTransport オブジェクトを初期化します。

credential = GoogleCredential.getApplicationDefault().createScoped(FIREBASE_SCOPES);
      httpTransport = UrlFetchTransport.getDefaultInstance();

次に、この認証情報を使用して認証済みの HttpRequestFactory オブジェクトを作成します。App Engine が HTTP 送信リクエストで必要とする URL フェッチ サービスを使用します。Firebase データベースを呼び出すには、パスを作成して次のリクエストを行います。

public void sendFirebaseMessage(String channelKey, Game game) throws IOException {
  // Make requests auth'ed using Application Default Credentials
  HttpRequestFactory requestFactory = httpTransport.createRequestFactory(credential);
  GenericUrl url =
      new GenericUrl(String.format("%s/channels/%s.json", firebaseDbUrl, channelKey));
  HttpResponse response = null;

  try {
    if (null == game) {
      response = requestFactory.buildDeleteRequest(url).execute();
    } else {
      String gameJson = new Gson().toJson(game);
      response =
          requestFactory
              .buildPatchRequest(
                  url, new ByteArrayContent("application/json", gameJson.getBytes()))
              .execute();
    }

    if (response.getStatusCode() != 200) {
      throw new RuntimeException(
          "Error code while updating Firebase: " + response.getStatusCode());
    }

  } finally {
    if (null != response) {
      response.disconnect();
    }
  }
}

上の例では、Python 用の httplib2 ライブラリJava 用の Google HTTP クライアント ライブラリを使用して、REST リクエストを処理していますが、お好みの方法で REST リクエストを実行できます。

クライアント認証トークンを使用する

Firebase は、JSON Web Token(JWT)を使用してユーザーを認証します。JWT を作成すると、App Engine に組み込まれている App Identity サービスを利用して、App Engine のアプリケーションのデフォルト認証情報でリクエストに署名できます。

以下のメソッドでは、カスタム JWT を作成しています。

  1. App Identity サービスにより、サービス アカウントのメールアドレスが自動的に取得されます。
  2. トークンのヘッダーが作成されます。
  3. Firebase 認証のドキュメントの説明に従って、トークン ペイロードにリクエストが追加されます。このリクエストで最も重要な部分は uid です。この一意のチャネル ID により、アクセスが 1 人のユーザーに制限されます。
  4. 最後に、App Identity サービスが再度利用され、トークンが署名されます。

Python

def create_custom_token(uid, valid_minutes=60):
    """Create a secure token for the given id.

    This method is used to create secure custom JWT tokens to be passed to
    clients. It takes a unique id (uid) that will be used by Firebase's
    security rules to prevent unauthorized access. In this case, the uid will
    be the channel id which is a combination of user_id and game_key
    """

    # use the app_identity service from google.appengine.api to get the
    # project's service account email automatically
    client_email = app_identity.get_service_account_name()

    now = int(time.time())
    # encode the required claims
    # per https://firebase.google.com/docs/auth/server/create-custom-tokens
    payload = base64.b64encode(json.dumps({
        'iss': client_email,
        'sub': client_email,
        'aud': _IDENTITY_ENDPOINT,
        'uid': uid,  # the important parameter, as it will be the channel id
        'iat': now,
        'exp': now + (valid_minutes * 60),
    }))
    # add standard header to identify this as a JWT
    header = base64.b64encode(json.dumps({'typ': 'JWT', 'alg': 'RS256'}))
    to_sign = '{}.{}'.format(header, payload)
    # Sign the jwt using the built in app_identity service
    return '{}.{}'.format(to_sign, base64.b64encode(
        app_identity.sign_blob(to_sign)[1]))

Java

public String createFirebaseToken(Game game, String userId) {
  final AppIdentityService appIdentity = AppIdentityServiceFactory.getAppIdentityService();
  final BaseEncoding base64 = BaseEncoding.base64();

  String header = base64.encode("{\"typ\":\"JWT\",\"alg\":\"RS256\"}".getBytes());

  // Construct the claim
  String channelKey = game.getChannelKey(userId);
  String clientEmail = appIdentity.getServiceAccountName();
  long epochTime = System.currentTimeMillis() / 1000;
  long expire = epochTime + 60 * 60; // an hour from now

  Map<String, Object> claims = new HashMap<String, Object>();
  claims.put("iss", clientEmail);
  claims.put("sub", clientEmail);
  claims.put("aud", IDENTITY_ENDPOINT);
  claims.put("uid", channelKey);
  claims.put("iat", epochTime);
  claims.put("exp", expire);

  String payload = base64.encode(new Gson().toJson(claims).getBytes());
  String toSign = String.format("%s.%s", header, payload);
  AppIdentityService.SigningResult result = appIdentity.signForApp(toSign.getBytes());
  return String.format("%s.%s", toSign, base64.encode(result.getSignature()));
}

サーバーが先行する関数を実行し、クライアントで表示されるテンプレートにトークンを渡します。この Tic Tac Toe サンプルアプリでは、プレーヤーのユーザー ID とゲームキーから Firebase 認証の uid が生成されます。

JavaScript クライアントの認証を行う

サーバーでトークンが生成され、クライアントの HTML に渡されると、クライアントの読み取りまたは書き込みコマンドでこのトークンが使用されます。クライアントを 1 回認証するだけで、firebase オブジェクトを使用する以降の呼び出しでは認証が行われません。この処理については、次のサンプルコードをご覧ください。

Python

// sign into Firebase with the token passed from the server
firebase.auth().signInWithCustomToken(token).catch(function(error) {
  console.log('Login Failed!', error.code);
  console.log('Error message: ', error.message);
});

Java

// sign into Firebase with the token passed from the server
firebase.auth().signInWithCustomToken(token).catch(function(error) {
  console.log('Login Failed!', error.code);
  console.log('Error message: ', error.message);
});

Firebase データベース セキュリティ ルールを更新する

Firebase コンソールの [データベース] セクションで、[レポート] タブを選択します。Firebase のセキュリティ ルールを更新し、認証済みの接続にアクセスを制限します。特に、サーバーに対する書き込みを制限します。認証ペイロードのユーザー固有の ID uid がデータベース パスの一意の ID と一致した場合にのみ、読み取りを許可します。

{
    "rules": {
      ".read": false,
      ".write": false,
      "channels": {
        "$channelId": {
          // Require auth token ID to match the path the client is trying to read
          ".read": "auth.uid == $channelId",
          ".write": false
        }
      }
    }
}

サンプルアプリ全体を見直す

Firebase を使用してリアルタイム アプリを作成する方法を説明するため、サンプルの Tic Tac Toe ゲームアプリを使用します。Python アプリの完全なソースコードについては、GitHub で Fire Tac Toe のページをご覧ください。Java アプリの完全なソースコードについては、GitHub で Fire Tac Toe のページをご覧ください。

このゲームでは、URL を送信して別のプレーヤーを招待し、リアルタイムで一緒にプレイできます。このアプリは、他のプレーヤーが動作を起こすと直ちに、両方のプレーヤーのボードの表示をリアルタイムで更新します。

この記事の残りの部分では、重要な機能について説明します。

Firebase パスに接続する

初めてゲームにアクセスすると、次の 2 つの処理が行われます。

  • ゲームサーバーが、クライアントに送信された HTML ページにトークンを挿入します。クライアントは、このトークンを使用して Firebase との接続を確立し、更新をリッスンします。ユーザーがリッスンする一意のパスは /channels/[USER_ID + GAME_KEY] です。
  • ゲームサーバーがユーザーに URL を提供します。この URL をユーザー間で共有してゲームの参加者を招待します。

次のサーバー側のコードは、ゲームアプリ用の Firebase パスを作成し、このパスをクライアントの HTML に挿入します。

Python

# choose a unique identifier for channel_id
channel_id = user.user_id() + game_key
# encrypt the channel_id and send it as a custom token to the
# client
# Firebase's data security rules will be able to decrypt the
# token and prevent unauthorized access
client_auth_token = create_custom_token(channel_id)
_send_firebase_message(channel_id, message=game.to_json())

# game_link is a url that you can open in another browser to play
# against this player
game_link = '{}?g={}'.format(request.base_url, game_key)

# push all the data to the html template so the client will
# have access
template_values = {
    'token': client_auth_token,
    'channel_id': channel_id,
    'me': user.user_id(),
    'game_key': game_key,
    'game_link': game_link,
    'initial_message': urllib.unquote(game.to_json())
}

return flask.render_template('fire_index.html', **template_values)

Java

// The 'Game' object exposes a method which creates a unique string based on the game's key
// and the user's id.
String token = FirebaseChannel.getInstance().createFirebaseToken(game, userId);
request.setAttribute("token", token);

// 4. More general template values
request.setAttribute("game_key", gameKey);
request.setAttribute("me", userId);
request.setAttribute("channel_id", game.getChannelKey(userId));
request.setAttribute("initial_message", new Gson().toJson(game));
request.setAttribute("game_link", getGameUriWithGameParam(request, gameKey));
request.getRequestDispatcher("/WEB-INF/view/index.jsp").forward(request, response);

これで、ウェブ クライアントがトークンを使用して、パス /channels/[USER_ID + GAME_KEY] をリッスンできます。この処理は openChannel() 関数で実行します。パスの値の変更を追跡するため、リスナーを追加します。Firebase の <reference>.on() メソッドにより、値の変更時に実行されるコールバックを設定します。データベース パスの値が変更されると、onMessage() メソッドにより、ゲームの状態とユーザーの UI を更新します。

Python

function openChannel() {
  // sign into Firebase with the token passed from the server
  firebase.auth().signInWithCustomToken(token).catch(function(error) {
    console.log('Login Failed!', error.code);
    console.log('Error message: ', error.message);
  });

  // setup a database reference at path /channels/channelId
  channel = firebase.database().ref('channels/' + channelId);
  // add a listener to the path that fires any time the value of the data changes
  channel.on('value', function(data) {
    onMessage(data.val());
  });
  onOpened();
  // let the server know that the channel is open
}
...
function onOpened() {
  $.post('/opened');
}

Java

function openChannel() {
  // sign into Firebase with the token passed from the server
  firebase.auth().signInWithCustomToken(token).catch(function(error) {
    console.log('Login Failed!', error.code);
    console.log('Error message: ', error.message);
  });

  // setup a database reference at path /channels/channelId
  channel = firebase.database().ref('channels/' + channelId);
  // add a listener to the path that fires any time the value of the data changes
  channel.on('value', function(data) {
    onMessage(data.val());
  });
  onOpened();
  // let the server know that the channel is open
}
...
function onOpened() {
  $.post('/opened');
}

サーバーに更新を送信する

ユーザーが動作を起こすと、メソッド moveInSquare() が呼び出され、その動作が有効かどうか確認されます。その後、動作の場所を含む HTTP POST リクエストがサーバーに送信されます。

Python

function moveInSquare(e) {
  var id = $(e.currentTarget).index();
  if (isMyMove() && state.board[id] === ' ') {
    $.post('/move', {i: id});
  }
}

Java

function moveInSquare(e) {
  var id = $(e.currentTarget).index();
  if (isMyMove() && state.board[id] === ' ') {
    $.post('/move', {cell: id});
  }
}

サーバーが HTTP POST を受信します。/move パスのハンドラが呼び出されます。このハンドラがユーザーを識別し、リクエストからスクエアの場所を取得します。さらに、Game オブジェクトのメソッドを呼び出し、動作を実行します。

Python

def move():
    game = Game.get_by_id(request.args.get('g'))
    position = int(request.form.get('i'))
    if not (game and (0 <= position <= 8)):
        return 'Game not found, or invalid position', 400
    game.make_move(position, users.get_current_user())
    return ''

Java

public class MoveServlet extends HttpServlet {

  @Override
  public void doPost(HttpServletRequest request, HttpServletResponse response) throws IOException {
    String gameId = request.getParameter("gameKey");
    Objectify ofy = ObjectifyService.ofy();
    Game game = ofy.load().type(Game.class).id(gameId).safe();

    UserService userService = UserServiceFactory.getUserService();
    String currentUserId = userService.getCurrentUser().getUserId();

    int cell = new Integer(request.getParameter("cell"));
    if (!game.makeMove(cell, currentUserId)) {
      response.sendError(HttpServletResponse.SC_FORBIDDEN);
    } else {
      ofy.save().entity(game).now();
    }
  }
}

動作を実行するメソッドが、ユーザーの動作に対する最終検証を行い、ユーザーがゲームに勝ったかどうかを確認して、データストアにゲームの状態を書き込みます。最後に、Firebase でゲーム状態を更新するメソッドを呼び出します。

Python

def make_move(self, position, user):
    # If the user is a player, and it's their move
    if (user in (self.userX, self.userO)) and (
            self.moveX == (user == self.userX)):
        boardList = list(self.board)
        # If the spot you want to move to is blank
        if (boardList[position] == ' '):
            boardList[position] = 'X' if self.moveX else 'O'
            self.board = ''.join(boardList)
            self.moveX = not self.moveX
            self._check_win()
            self.put()
            self.send_update()
            return

Java

public boolean makeMove(int position, String userId) {
  String currentMovePlayer;
  char value;
  if (getMoveX()) {
    value = 'X';
    currentMovePlayer = getUserX();
  } else {
    value = 'O';
    currentMovePlayer = getUserO();
  }

  if (currentMovePlayer.equals(userId)) {
    char[] boardBytes = getBoard().toCharArray();
    boardBytes[position] = value;
    setBoard(new String(boardBytes));
    checkWin();
    setMoveX(!getMoveX());
    try {
      sendUpdateToClients();
    } catch (IOException e) {
      LOGGER.log(Level.SEVERE, "Error sending Game update to Firebase", e);
      throw new RuntimeException(e);
    }
    return true;
  }

  return false;
}

次に、更新メソッドが上で定義したメソッドを呼び出し、Firebase に対する HTTP リクエストを処理します。

Python

def send_update(self):
    """Updates Firebase's copy of the board."""
    message = self.to_json()
    # send updated game state to user X
    _send_firebase_message(
        self.userX.user_id() + self.key.id(), message=message)
    # send updated game state to user O
    if self.userO:
        _send_firebase_message(
            self.userO.user_id() + self.key.id(), message=message)

Java

public String getChannelKey(String userId) {
  return userId + id;
}

/**
 * deleteChannel.
 * @param userId .
 * @throws IOException .
 */
public void deleteChannel(String userId) throws IOException {
  if (userId != null) {
    String channelKey = getChannelKey(userId);
    FirebaseChannel.getInstance().sendFirebaseMessage(channelKey, null);
  }
}

private void sendUpdateToUser(String userId) throws IOException {
  if (userId != null) {
    String channelKey = getChannelKey(userId);
    FirebaseChannel.getInstance().sendFirebaseMessage(channelKey, this);
  }
}

/**
 * sendUpdateToClients.
 * @throws IOException if we had some kind of network issue.
 */
public void sendUpdateToClients() throws IOException {
  sendUpdateToUser(userX);
  sendUpdateToUser(userO);
}

最後に、HTTP リクエストを送信します。

Python

def _send_firebase_message(u_id, message=None):
    """Updates data in firebase. If a message is provided, then it updates
     the data at /channels/<channel_id> with the message using the PATCH
     http method. If no message is provided, then the data at this location
     is deleted using the DELETE http method
     """
    url = '{}/channels/{}.json'.format(_get_firebase_db_url(), u_id)

    if message:
        return _get_http().request(url, 'PATCH', body=message)
    else:
        return _get_http().request(url, 'DELETE')

Java

public void sendFirebaseMessage(String channelKey, Game game) throws IOException {
  // Make requests auth'ed using Application Default Credentials
  HttpRequestFactory requestFactory = httpTransport.createRequestFactory(credential);
  GenericUrl url =
      new GenericUrl(String.format("%s/channels/%s.json", firebaseDbUrl, channelKey));
  HttpResponse response = null;

  try {
    if (null == game) {
      response = requestFactory.buildDeleteRequest(url).execute();
    } else {
      String gameJson = new Gson().toJson(game);
      response =
          requestFactory
              .buildPatchRequest(
                  url, new ByteArrayContent("application/json", gameJson.getBytes()))
              .execute();
    }

    if (response.getStatusCode() != 200) {
      throw new RuntimeException(
          "Error code while updating Firebase: " + response.getStatusCode());
    }

  } finally {
    if (null != response) {
      response.disconnect();
    }
  }
}

Firebase に対する呼び出しの後で、ウェブ クライアントはサーバーのポーリングを行わず、更新後のゲーム状態をすぐに受信します。

リスナーとデータを削除する

プレーヤーがゲームに勝った場合、サーバーから送信される追加情報はありません。この場合、ストレージが必ずしも無料であるとは限らないため、Firebase からリスナーとゲームデータを削除する必要があります。この操作を行うには、クライアントがリッスンしていた Firebase 参照に off() メソッドを呼び出します。最後に、クライアントがサーバーにメッセージを送信し、エンドポイントに /delete を実行します。これにより、終了したゲームのデータが削除されます。

Python

function onMessage(newState) {
  updateGame(newState);

  // now check to see if there is a winner
  if (channel && state.winner && state.winningBoard) {
    channel.off(); //stop listening on this path
    deleteChannel(); //delete the data we wrote
  }
}
...
function deleteChannel() {
  $.post('/delete');
}

Java

function onMessage(newState) {
  updateGame(newState);

  // now check to see if there is a winner
  if (channel && state.winner && state.winningBoard) {
    channel.off(); //stop listening on this path
    deleteChannel(); //delete the data we wrote
  }
}
...
function deleteChannel() {
  $.post('/delete');
}

/delete エンドポイントのハンドラを使用して、Firebase のデータを削除します。

Python

def delete():
    game = Game.get_by_id(request.args.get('g'))
    if not game:
        return 'Game not found', 400
    user = users.get_current_user()
    _send_firebase_message(user.user_id() + game.key.id(), message=None)
    return ''

Java

public class DeleteServlet extends HttpServlet {
  @Override
  public void doPost(HttpServletRequest request, HttpServletResponse response) throws IOException {
    String gameId = request.getParameter("gameKey");
    Objectify ofy = ObjectifyService.ofy();
    Game game = ofy.load().type(Game.class).id(gameId).safe();

    UserService userService = UserServiceFactory.getUserService();
    String currentUserId = userService.getCurrentUser().getUserId();

    // TODO(you): In practice, first validate that the user has permission to delete the Game
    game.deleteChannel(currentUserId);
  }
}

次のステップ