ページへ戻る

− Links

 印刷 

Google App Engine for Python その2 のバックアップソース(No.9) :: NJF Wiki

xpwiki:Google App Engine for Python その2 のバックアップソース(No.9)

« Prev[5]  Next »[6]
前の記事はこちら。[[Google App Engine for Python]]

*ブラウザ上のカジュアルゲームデータの保存の概要 [#u36cbcbd]

Flashで動作するゲームのユーザーデータは以前はSharedObjectで保存できましたが、セキュリティなどの問題から現在はSharedObjectはデフォルトでは定期的に消えるようになりました。

そこで、サーバー上で保存するわけですが、そのために必要なデータは最低限以下の物が必要です。

-ユーザーIDとパスワード
-ゲームデータ

ユーザーIDとパスワードはユーザーを区別するために必要です。メールアドレスや他のサービスのオープンIDを使ってパスワード忘れ対策などを行うと良いかも知れませんが、カジュアルゲーム程度でメールアドレスや他サービスのIDを必要とすると、ユーザーがいやがる場合があります。また、そこまでしても特に個人情報などの重要データがあるわけでも無くそもそもセキュリティ的なリスクはありません。さらにほとんどのユーザーは一通りゲームをプレイすると再び同じゲームをプレイすることはなく、SharedObjectが消える期間を超えてプレイする人は少数派です。どの程度まで実装するかは微妙なところです。

ここでは最も簡単な、サーバー側でIDとパスワードを発行してユーザー側でメモしてもらう方法をとります。パスワード変更処理やパスワード忘れ処理は無しにします。IDは通番にし、パスワードはランダムな整数にします。

ゲームデータはjson形式で送ることにします。FlashだとAMFという優れたデータシリアライズ方法があり、GAEでも利用できるのですが、Flash以外ではそのままでは使えないという欠点があります。たとえばアプリ版は他の言語で作る必要が出たときに面倒なことになります。Flashがだんだんと使われなくなってきている状況を鑑みて、汎用性からjsonでやり取りすることにします。XMLでも良いです。ただjsonだと数値や論理型が型付きで送れたり大抵データの容量が少なくなるので私はこちらを使っています。また、GAEにはjsonデータをそのまま保存するデータ型が用意されているのでこちらの方が便利です。

GAEのデータ設計において注意しないといけないのは、1レコード読み込みあたりに課金されると言うことです。また、複数レコードを読み込むと処理も重くなります。よって一度の処理で読み込むレコード数は少ない方が望ましいことから、1ユーザー1レコードが理想です。通常のリレーショナルデータベースとは異なり、なるべく正規化はしないようにしましょう。

すると必要なテーブルは次の二つです。

-ユーザーID
-ユーザーデータ

ユーザーIDテーブルの方では通番のユーザーIDを作成するために最後のユーザーIDを保存しておきます。
ユーザーデータの方はユーザーIDとパスワードとゲームのデータを保存します。
*処理の流れ [#z08eead8]
全体の処理の流れは以下の通りです。

**登録処理 [#n8d8c653]
+ユーザーが新規登録を選んだ場合、サーバーで登録処理を行う
+サーバーから戻されたIDとパスワードをクライアントはSharedObjectで保存する

**ログイン処理 [#vfb97bf8]
+ShareObjectでローカルにIDとパスワードが保存されている場合や、ユーザーが手動でIDとパスワードを入力した場合、サーバーでログイン処理を行う
+サーバーから戻されたゲームデータでゲームを開始する
+ユーザーがIDとパスワードを入力していた場合は、それをSharedObjectに保存しておく

**データの保存 [#n64bc982]
+IDとパスワードとゲームデータをサーバーへ送信する
+サーバーではデータの整合性をチェックし、保存して結果を戻す


よって、ゲーム側には新規登録画面、ユーザーIDとパスワードを表示する画面、ユーザーIDとパスワードを入力してログインする画面が最低限必要です。もちろん、エラー表示画面なども必要に応じて作成します。

また、サーバー側には登録処理、ログイン処理、セーブ処理をするAPIが必要です。

*URL構成 [#r4de93f6]
以上のことを踏まえて、URLの構成は以下のようになります。

 /crossdomain.xml
 /api/entry
 /api/save
 /api/load

crossdomain.xmlはflashを別サーバーにWebで公開するときに必要です。それ以外のアプリなどでは必要ありません。
上記のものは最低限必要な構成で、実際にはルート以下にfavicon.icoやrobot.txtなどいろいろと入れたくなるので、煩雑にならないようapiは別ディレクトリにまとめます。

URLと実ファイルの対応は別途定義できるので、実際にはapi以下のURLは1つのファイルで処理します。

*静的ファイルの取り扱い [#p9129989]
crossdomain.xmlのような静的ファイルはapp.yamlの中でURLと実ファイルの対応を定義します。
具体的には、/crossdomain.xmlにアクセスがあった場合、static/crossdomain.xmlというテキストファイルが読み込まれるようにするには、app.yamlの中で以下のように定義します。

 - url: /crossdomain.xml
   mime_type: text/xml
   static_files: static/crossdomain.xml
   upload: static/crossdomain.xml

他にもいろいろな割り振り方がありますが、ゲーム保存用のAPIサーバーには静的ファイルはあまりないはずなので、これだけ覚えておけば十分かと思います。GAEでも静的ファイルは取り扱えますが、大量にある場合はそれを置くために通常のWebサーバーを用意した方が便利で安くつきます。

*フレームワークの導入 [#w185f5fa]

HTMLの入出力部分などを自分で一から作るのは面倒なので、GAEで用意されているフレームワークを使います。いろいろな物が用意されていますが、ここではwebapp2を使います。これを選んだのにはそれほど深い意味はありません。Googleの公式チュートリアルでこれが採用されていて資料が見つかりやすいのが理由です。ゲームデータを保存するだけで実際のWebページを作成するわけでは無いので、フレームワークについてはどれを使ってもそれほど差は出ないと思います。

これを使って前回の記事で作ったHello Worldを作り直すと以下のようになります。
まず、app.yamlです。

 application: hello-world-app
 version: 1
 runtime: python
 api_version: 1

 handlers:
 - url: /.*
   script: helloworld.application

ここで大きく変わったのはscriptのところがファイル名では無く、ファイル名+「.」+変数名となっているところです。これに対応したhelloworld.pyは

 import webapp2

 class HelloWorld(webapp2.RequestHandler):
     def get(self):
         self.response.headers['Content-Type'] = "text/plain; charset=utf-8"
         self.response.write("Hello, world!")

 application = webapp2.WSGIApplication(
                                      [('/', HelloWorld),
                                      ],
                                      debug=True)

となります。app.yamlでhelloworld.applicationと指定したせいで、helloworld.py内のapplicationというオブジェクトが呼び出されることになります。そのオブジェクトの中で"/"にはHelloWorldクラスを対応させているので、ルートへのアクセスに対して、HelloWorldクラスの処理が呼び出されます。

HelloWorldクラスにはgetメソッドが定義してあり、GETリクエストに対してはこれが呼び出されることになります。もちろんpostメソッドを定義することも可能です。

getメソッドの中は単にヘッダと本文をクライアント向けに書き出しているだけです。

元のHello Worldのサンプルに比べて複雑になったように見えますが、GETやPOST、ヘッダーの指定などをフレームワークでやってくれる上に、最後のapplicationの定義を変えていけばapp.yamlを変更せずにURLと処理の対応を変更していけるので、ある程度規模が大きくなるとこちらのやり方の方がずっと楽になります。

*GAEのデータ保存 [#v7706534]

GAEではデータはBigtableというものに保存されます。通常のリレーショナルデータベースに似ていますが、集計や表の結合などは自分でスクリプトを書かないと基本的には出来ません。1レコードごとに検索のためのキーがついているだけのデータの集まりの様な物で、どちらかというとファイルに近いかも知れません。公式ではリレーショナルデータベースと区別するためか1データの事をエンティティと呼んでいます。また、テーブル定義に当たる物はモデルと呼びます。リレーショナルデータベースに慣れている人には分かりづらいと思うので、このWikiでは基本的に1データはレコードと呼んでいます。モデルもテーブルと呼ぶかも知れません。他の資料に当たるときには注意してください。

カジュアルゲームのデータ保存の場合、集計や表結合は普通無いのであまり問題ないと思います。しかし、ランキングだけはちょっと面倒です。これについては別途機会を設けて解説する予定です。

また、GAEはデータの排他処理があまり得意ではありません。同時に1つのデータに大量にアクセスするような、たとえばアクセスカウンターのような物だとエラーが出る場合があります。これを回避する方法もまた別途解説する予定です。ただ、こちらもカジュアルゲームではそれほど使わないと思うのでさほど問題では無いでしょう。

一方、リレーショナルデータベースと違って、後からテーブルの列を加えたりするのは簡単にできます。また、データが分散されていて互いにバックアップされているので、サーバーの不具合でテーブルまるごと消えたりすることはまずありません。テーブルのサイズにも制限はありません。このあたりが1データがあたかもファイル的に扱われている所です。

では、テーブル定義にあたるモデルの定義を見てみましょう。ユーザーIDとパスワードとユーザーデータを保存するためのモデルは以下のようになります。

 from google.appengine.ext import ndb

 class UserData(ndb.Model):
     userId = ndb.IntegerProperty()
     password = ndb.IntegerProperty()
     data = ndb.JsonProperty()

これでユーザーIDとパスワードが整数、dataがJSONであるようなモデルとなります。
どんな型が使えるかはGAEのリファレンスで調べられます。
(
https://cloud.google.com/appengine/docs/python/ndb/properties
)
よく使うのは上記の整数とJson型、あとは以下の日付とString型ぐらいです。

 ndb.DateTimeProperty()
 ndb.StringProperty()

使い方は、保存はインスタンス化して値を設定してputするだけです。

 userData = UserData() #インスタンス作成
 userData.userId = 1
 userData.password = 12345
 userData.data = jsonData
 userData.put() #保存

読み出しはいろいろな方法がありますが、IDが1のものを1つだけ取り出すなら、

 userDatas = UserData.query(UserData.userId==1).fetch(1)

とすると長さ最大1のUserData型の配列として取り出せます。

集計や結合などをしないなら、ほぼ通常のリレーショナルデータベースと同じです。

以前にも書きましたが、GAEでは1レコード取り出す毎に課金されます。また、取り出すレコードの数が多いとパフォーマンスも悪くなります。なるべく少ないレコード数で処理が終わるように、正規化などはせず少々重複があっても1レコードにデータを多く詰め込んだ方が良いです。そのようなことを踏まえてデータ設計を行いましょう。

*データモデル [#g6b90e90]

では、ゲームデータの保存に必要なモデルを考えます。
[[ブラウザ上のカジュアルゲームデータの保存の概要>#u36cbcbd]]
で解説したように、必要なモデルは次の二つです。

-ユーザーID
-ユーザーデータ

ユーザーIDはIDをカウントしていくために必要で、ユーザーデータはユーザーのIDとパスワードを整数で、ゲームデータをjsonで保存します。するとそのモデルは以下のようになります。

 class UserIdCounter(ndb.Model):
     userId = ndb.IntegerProperty()

 class UserData(ndb.Model):
     userId = ndb.IntegerProperty()
     password = ndb.IntegerProperty()
     data = ndb.JsonProperty()

実際には登録日や更新日時、ゲームのバージョンなど、管理用のデータを加えることになると思いますが、最低限必要なのはこれだけです。

*IDの加算と排他処理 [#g6b90e90]

ユーザーIDを取得して加算する処理は以下のようになります。

 @ndb.transactional
 def getUserId():
     userIdIndex = "userIdIndex"
     lastId = UserIdCounter.get_by_id(userIdIndex)
     if lastId is None:
         lastId = UserIdCounter(id=userIdIndex)
         lastId.userId = 0

     nowId = lastId.userId

     lastId.userId = lastId.userId + 1
     lastId.put()

     return nowId

@ndb.transactionalを指定すると以下に続く処理が排他的に行われます。もし2つの処理が同時に走ったとき、UserIdCounterへの値の加算と取得がたとえば、

+UserIdCounterの取得
+UserIdCounterの加算
+UserIdCounterの取得
+UserIdCounterの加算

となればもちろん問題ないのですが、

+UserIdCounterの取得
+UserIdCounterの取得
+UserIdCounterの加算
+UserIdCounterの加算

となると同じ値をとって最後に2加算することになり、同じ値が返されることになります。@ndb.transactionalを指定することでこれを防ぐことが出来ます。

データモデルは初期化の時にidというパラメータを指定することで、次からget_by_idというメソッドでデータを取得することが可能になり、一意のidがある場合はこれでデータの取り出しを行うと便利です。今回はデータは1つしか無いので固定でidを与えています。

*ユーザ-登録処理 [#a1528de1]

上記ユーザーIDの生成処理を使うと、ユーザー登録処理は以下のようになります。
 class NjfEntry(webapp2.RequestHandler):
     def post(self):
         self.response.headers['Content-Type'] = "text/plain; charset=utf-8"
         userId = getUserId()
         password = random.randint(1000,1000000)
         userData = UserData(id = str(userId) + "_" + str(password))
         userData.userId = userId
         userData.password = password
         userData.data = {}
         userData.put()
         returnData = {}
         returnData["userId"] = userId
         returnData["password"] = password
         self.response.write(json.dumps(returnData))

パスワードは乱数にしています。桁数があまり小さいと偶然正解する確率が高くなるので1000以上1000000未満としています。
ここでuserDataにユーザーIDとパスワードを連結した物をIDとして与えています。これはユーザーIDとパスワードが変更されない事を前提としています。パスワード変更をユーザーに許すならこの方法はあまり良くないでしょう。その場合はidは使わずにデータを検索する必要があります。今回は変更はしないのでこれで問題ありません。

戻り値はユーザーIDとパスワードをjsonで送っています。

*ログイン処理 [#q19f2920]

ログイン処理ではユーザーIDとパスワードをチェックして正しければデータを返し、そうでなければ-1を返します。

 class NjfLogin(webapp2.RequestHandler):
     def post(self):
         self.response.headers['Content-Type'] = "text/plain; charset=utf-8"
         userId = self.request.get('userId')
         password = self.request.get('password')

         userData = getUserData(userId, password)
         if userData:
             self.response.write(json.dumps(userData.data))
         else:
             self.response.write("-1")

ここでgetUserDataは以下のような、ユーザーIDとパスワードからユーザーデータを返す関数です。idにユーザーIDとパスワードを連結した物を使っているのでこのようにシンプルになります。

 def getUserData(userId,password):
     ud = UserData.get_by_id(id=userId+"_"+password)
     return ud

*セーブ処理 [#j2390dc0]
最後に保存処理です。ユーザーIDとパスワードをチェックしてデータを保存します。成功した場合は空のjsonデータを返しています。失敗した場合は-1を返しています。

 class NjfSave(webapp2.RequestHandler):

     def post(self):
         self.response.headers['Content-Type'] = "text/plain; charset=utf-8"
         userId = self.request.get('userId')
         password = self.request.get('password')
         data = self.request.get('data')
         userData = getUserData(userId, password)
         if userData:
             jsonData = json.loads(data)
             deepJsonCopy(jsonData, userData.data)
             userData.put()
             self.response.write("{}")
         else:
             self.response.write("-1")

ここでdeepJsonCopy関数は以下のようにjsonデータを再帰的にコピーする関数です。

 def deepJsonCopy(fromData,toData):
     for key, value in fromData.iteritems():
         if isinstance(value,dict):
             if not toData.has_key(key):
                 toData[key] = {}
             deepJsonCopy(fromData[key],toData[key])
         else:
             toData[key] = fromData[key]

pythonのjsonライブラリにはディープコピー関数があるのですが、入れ子になったオブジェクトには対応していないようなので自作しています。
この関数を使うことによって、必要なデータだけをサーバーに送って保存することが可能になります。

GAEでは転送量も課金対象になります。また、そうで無くてもやはり転送量は少なければ少ないほどユーザーの待ち時間は少なくなります。保存データが多い場合にはなるべく差分だけを送信した方が良いでしょう。
しかし、差分だけを送信するとクライアント側とサーバー側で同期が狂いやすくなります。たとえば2つ同時にブラウザを立ち上げてそれぞれで矛盾するようなデータを送信すると、両方サーバーで保存されてしまいます。このような場合にどうするかを考慮する必要が出てきます。

毎回全てのデータを上書きするのならその心配はありません。もしデーター量が少ないならつねに全データを送るのをお勧めします。

以上でサーバーサイドは終わりです。次にクライアントサイドを解説します。

*クライアントサイド [#z1744dce]
Flash側では単にJSONデータをパースしたりダンプするだけであとは通常の送信処理です。

送信データをダンプするには

 var data:Object = new Object();
 data["key"] = "value";
 JSON.stringify(data);

受信データをパースするには
 var jsonData:Object;
 try
 {
 	jsonData = JSON.parse(data);
 }catch (e:Error) {
 	trace(e,data);
 }
です。

*さらに改良するには [#kf41b5d8]
これで最低限のデータの保存と呼び出しが出来るようになりました。しかし、実際に使うにはまだ問題が生ずる場合があります。
**チート対策 [#q662c5f9]
もしランキングなどを実装するならチート対策が必須となります。このままでは送信データを容易に書き換えられるため、すぐにランキングにおかしなデータを登録されるでしょう。経験的には対策しなかった場合、ランキングに送られるデータの数パーセント程度がチートを行ったものです。数百程度のデータ数でも上位10位ぐらいすぐにチートで埋まってしまいます。
クライアント側での対策は「[[ActionScriptでチート検出]]」こちらに書きました。

これに加えてデータ送信時にも署名を行うなどしてデータの改ざんを防ぐ必要があります。
たとえば、jsonデータを送信するときに、そのmd5ハッシュを同時に送信し、サーバー側でチェックするなどです。

**2重登録チェック [#y2834953]
前述のように、データを差分で送るとブラウザを2つあげてそれぞれ違うデータを登録すると矛盾したデータが保存されてしまう可能性があります。これはプレイヤー側が意図的に行わなくてもいつでも起こりうる不具合です。

サーバー側でそれぞれの値の整合性チェックを行うと他の不具合も防げて確実ですが、単に2重登録を防ぐためだけなら他の方法もあります。たとえばサーバー側で保存回数を数えておいて、その値をクライアントに戻すようにし、クライアントはその値も保存時にサーバーに送信するようにしておきます。サーバー側ではクライアントから送られてきた値がサーバー側の値と等しければそのまま保存し、保存回数をインクリメントしますが、違う場合はエラーにします。

すると2つのクライアントから別々に保存しようとしても、後から保存する方は保存回数が1つ前になってしまうので、エラーになります。

*サンプルコード [#ucddcda4]
ここで解説したサンプルコードはこちらです。クライアント側のURLやcrossdomain.xmlの中のURLは環境に合わせて書き換えてください。
&ref(gaeWikiSample.zip);

« Prev[5]  Next »[6]