Ad

前の記事はこちら。Google App Engine for Python

ブラウザ上のカジュアルゲームデータの保存の概要 anchor.png Edit

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

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

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

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

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

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

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

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

  • ユーザーID
  • ユーザーデータ

ユーザーIDテーブルの方では通番のユーザーIDを作成するために最後のユーザーIDを保存しておきます。 ユーザーデータの方はユーザーIDとパスワードとゲームのデータを保存します。

Page Top

処理の流れ anchor.png Edit

全体の処理の流れは以下の通りです。

Page Top

登録処理 anchor.png Edit

  1. ユーザーが新規登録を選んだ場合、サーバーで登録処理を行う
  2. サーバーから戻されたIDとパスワードをクライアントはSharedObjectEditで保存する
Page Top

ログイン処理 anchor.png Edit

  1. ShareObjectEditでローカルにIDとパスワードが保存されている場合や、ユーザーが手動でIDとパスワードを入力した場合、サーバーでログイン処理を行う
  2. サーバーから戻されたゲームデータでゲームを開始する
  3. ユーザーがIDとパスワードを入力していた場合は、それをSharedObjectEditに保存しておく
Page Top

データの保存 anchor.png Edit

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

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

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

Page Top

URL構成 anchor.png Edit

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

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

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

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

Page Top

静的ファイルの取り扱い anchor.png Edit

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サーバーを用意した方が便利で安くつきます。

Page Top

フレームワークの導入 anchor.png Edit

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というオブジェクトが呼び出されることになります。そのオブジェクトの中で"/"にはHelloWorldEditクラスを対応させているので、ルートへのアクセスに対して、HelloWorldEditクラスの処理が呼び出されます。

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

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

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

Page Top

GAEのデータ保存 anchor.png Edit

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のUserDataEdit型の配列として取り出せます。

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

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

Page Top

データモデル anchor.png Edit

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

  • ユーザー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()

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

Page Top

IDの加算と排他処理 anchor.png Edit

ユーザーIDを取得して加算する処理は以下のようになります。GAEには連番を生成する機能が見当たらないので自作します。

@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つの処理が同時に走ったとき、UserIdCounterEditへの値の加算と取得がたとえば、

  1. UserIdCounterEditの取得
  2. UserIdCounterEditの加算
  3. UserIdCounterEditの取得
  4. UserIdCounterEditの加算

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

  1. UserIdCounterEditの取得
  2. UserIdCounterEditの取得
  3. UserIdCounterEditの加算
  4. UserIdCounterEditの加算

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

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

Page Top

ユーザ-登録処理 anchor.png Edit

上記ユーザー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が変更されないならそれをidとしてデータを検索する必要があります。今回は変更はしないのでこれで問題ありません。

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

Page Top

ログイン処理 anchor.png Edit

ログイン処理ではユーザー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")

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

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

セーブ処理 anchor.png Edit

最後に保存処理です。ユーザー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")

ここでdeepJsonCopyEdit関数は以下のように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つ同時にブラウザを立ち上げてそれぞれで矛盾するようなデータを送信すると、両方サーバーで保存されてしまいます。このような場合にどうするかを考慮する必要が出てきます。

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

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

Page Top

クライアントサイド anchor.png Edit

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);
}

です。

オブジェクトが入れ子になっていても大丈夫です。

注意する必要があるのは、日本語を送るとGAE側でエラーになる場合があると言うことです。pythonはあまり日本語処理などが得意ではありません。どういうやり方が一番良いかわかりませんが、手っ取り早い方法として、日本語を送る場合はurlエンコードしておくとascii文字だけになり安心です。データをサーバーから取得したときにはクライアント側でurlデコードします。

Page Top

さらに改良するには anchor.png Edit

これで最低限のデータの保存と呼び出しが出来るようになりました。しかし、実際に使うにはまだ問題が生ずる場合があります。

Page Top

値のチェック anchor.png Edit

IDが数値かどうかやパスワードが数値かどうかなどはここでは省略していますが、本来チェックした方が良いでしょう。しなくてもGAEでエラーになり処理が止まるのであまり問題では無いですが、無意味なエラーログが多く残ると問題が発生したときに調査しづらくなります。

Page Top

チート対策 anchor.png Edit

もしランキングなどを実装するならチート対策が必須となります。このままでは送信データを容易に書き換えられるため、すぐにランキングにおかしなデータを登録されるでしょう。経験的には対策しなかった場合、ランキングのためにサーバーに送られるデータの数パーセント程度がチートを行ったものです。数百程度のデータ数でも上位10位ぐらいすぐにチートで埋まってしまいます。 クライアント側での対策は「ActionScriptでチート検出」こちらに書きました。

これに加えてデータ送信時にも署名を行うなどしてデータの改ざんを防ぐ必要があります。 たとえば、jsonデータを送信するときに、そのmd5ハッシュを同時に送信し、サーバー側でチェックするなどです。その場合、サーバーとクライアントで共通する送信しない余分の文字列や類推されにくくするために乱数などを加える必要があります。

Page Top

2重登録チェック anchor.png Edit

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

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

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

Page Top

パスワード暗号化 anchor.png Edit

ここではパスワードを平文で保存していますが、個人情報などを扱う場合はパスワードを暗号化するのが一般的です。たとえばパスワード忘れ処理のためにメールアドレスを保存するなどした場合は、パスワードは暗号化した方が良いでしょう。しかし、カジュアルゲームでは個人情報取得すること自体がユーザーがいやがる可能性があるのでそこまで実装するかどうかは微妙です。

Page Top

費用 anchor.png Edit

費用についてはゲームによってセーブの回数や転送量が違うのでなんとも言えませんが、一日数百PV程度なら無料範囲でおさまるでしょう。

それ以上の場合で課金されるのは、ほぼ「Datastore Write Operations」という課金枠です。 これは名前の通りデータを保存した回数で、レコード毎に課金されます。これが2015/8/15現在、「$0.60/ Million Ops」となっています。十万回で0.6ドルですので、1万PVで1PV平均10回保存するゲームなら、一日60円、つまり月1800円程度となります。 次に課金されやすい項目は「Datastore Read Operations」です。こちらはデータの読み込みで、一操作あたりの課金額はWrite Operationsと同じです。しかし、読み込みは最初のデータロード時のみのはずなので、Write Operationsに比べるとかなり少なく、上記のように1PVで10回保存するゲームならWrite Operationsの1/10です。 カジュアルゲームのデータ保存程度ではこれ以外ではほぼ課金されることは無いです。まれに転送量とインスタンスの立ち上げ時間が無料枠を少し越えるかどうか程度です。

よって、一日1万PV以上が一年以上続くような場合では月々定額のレンタルサーバーを借りた方が安くなるでしょう。一方数ヶ月で数百~数千PVにまで落ちそうなら、従量課金のGAEのほうが安くなります。

Page Top

サンプルコード anchor.png Edit

ここで解説したサンプルコードはこちらです。クライアント側のURLやcrossdomain.xmlの中のURLは環境に合わせて書き換えてください。 filegaeWikiSample.zip


Front page   Edit Freeze Diff Backup Upload Copy Rename ReloadPrint View   New Page Page list Search Recent changes   Help   RSS of recent changes (RSS 1.0) RSS of recent changes (RSS 2.0) RSS of recent changes (RSS Atom) Powered by xpWiki
Counter: 3444, today: 1, yesterday: 0
Princeps date: 2015-07-01 (Wed) 08:09:21
Last-modified: 2015-08-15 (Sat) 08:34:45 (JST) (2549d) by njf
MenuBar
広告

ログイン

ユーザー名:


パスワード:





パスワード紛失

Portuguese | English | German | Greek | Japanese | Korean | Russian | T-Chinese top
NJF