4: 2015-07-13 (月) 06:40:05 njf |
現: 2015-08-15 (土) 16:34:45 njf |
| *URL構成 [#r4de93f6] | | *URL構成 [#r4de93f6] |
| 以上のことを踏まえて、URLの構成は以下のようになります。 | | 以上のことを踏まえて、URLの構成は以下のようになります。 |
| + | |
| /crossdomain.xml | | /crossdomain.xml |
| /api/entry | | /api/entry |
| runtime: python | | runtime: python |
| api_version: 1 | | api_version: 1 |
| + | |
| handlers: | | handlers: |
| - url: /.* | | - url: /.* |
| | | |
| import webapp2 | | import webapp2 |
| + | |
| class HelloWorld(webapp2.RequestHandler): | | class HelloWorld(webapp2.RequestHandler): |
| def get(self): | | def get(self): |
| self.response.headers['Content-Type'] = "text/plain; charset=utf-8" | | self.response.headers['Content-Type'] = "text/plain; charset=utf-8" |
| self.response.write("Hello, world!") | | self.response.write("Hello, world!") |
| + | |
| application = webapp2.WSGIApplication( | | application = webapp2.WSGIApplication( |
| [('/', HelloWorld), | | [('/', HelloWorld), |
| *GAEのデータ保存 [#v7706534] | | *GAEのデータ保存 [#v7706534] |
| | | |
- | GAEではデータはBigtableというものに保存されます。通常のリレーショナルデータベースに似ていますが、集計や表の結合などは自分でスクリプトを書かないと基本的には出来ません。1レコードごとに検索のためのキーがついているだけのデータの集まりの様な物で、どちらかというとファイルに近いかも知れません。公式ではリレーショナルデータベースと区別するためか1データの事をエンティティと呼んでいます。リレーショナルデータベースに慣れている人には分かりづらいと思うので、このWikiでは基本的にデータはレコードと呼んでいます。他の資料に当たるときには注意してください。 | + | GAEではデータはBigtableというものに保存されます。通常のリレーショナルデータベースに似ていますが、集計や表の結合などは自分でスクリプトを書かないと基本的には出来ません。1レコードごとに検索のためのキーがついているだけのデータの集まりの様な物で、どちらかというとファイルに近いかも知れません。公式ではリレーショナルデータベースと区別するためか1データの事をエンティティと呼んでいます。また、テーブル定義に当たる物はモデルと呼びます。リレーショナルデータベースに慣れている人には分かりづらいと思うので、このWikiでは基本的に1データはレコードと呼んでいます。モデルもテーブルと呼ぶかも知れません。他の資料に当たるときには注意してください。 |
| | | |
| カジュアルゲームのデータ保存の場合、集計や表結合は普通無いのであまり問題ないと思います。しかし、ランキングだけはちょっと面倒です。これについては別途機会を設けて解説する予定です。 | | カジュアルゲームのデータ保存の場合、集計や表結合は普通無いのであまり問題ないと思います。しかし、ランキングだけはちょっと面倒です。これについては別途機会を設けて解説する予定です。 |
| また、GAEはデータの排他処理があまり得意ではありません。同時に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を取得して加算する処理は以下のようになります。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つの処理が同時に走ったとき、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が変更されないならそれを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); |
| + | } |
| + | です。 |
| + | |
| + | オブジェクトが入れ子になっていても大丈夫です。 |
| + | |
| + | 注意する必要があるのは、日本語を送るとGAE側でエラーになる場合があると言うことです。pythonはあまり日本語処理などが得意ではありません。どういうやり方が一番良いかわかりませんが、手っ取り早い方法として、日本語を送る場合はurlエンコードしておくとascii文字だけになり安心です。データをサーバーから取得したときにはクライアント側でurlデコードします。 |
| + | |
| + | *さらに改良するには [#kf41b5d8] |
| + | これで最低限のデータの保存と呼び出しが出来るようになりました。しかし、実際に使うにはまだ問題が生ずる場合があります。 |
| + | |
| + | **値のチェック [#db045494] |
| + | IDが数値かどうかやパスワードが数値かどうかなどはここでは省略していますが、本来チェックした方が良いでしょう。しなくてもGAEでエラーになり処理が止まるのであまり問題では無いですが、無意味なエラーログが多く残ると問題が発生したときに調査しづらくなります。 |
| + | |
| + | **チート対策 [#q662c5f9] |
| + | もしランキングなどを実装するならチート対策が必須となります。このままでは送信データを容易に書き換えられるため、すぐにランキングにおかしなデータを登録されるでしょう。経験的には対策しなかった場合、ランキングのためにサーバーに送られるデータの数パーセント程度がチートを行ったものです。数百程度のデータ数でも上位10位ぐらいすぐにチートで埋まってしまいます。 |
| + | クライアント側での対策は「[[ActionScriptでチート検出]]」こちらに書きました。 |
| + | |
| + | これに加えてデータ送信時にも署名を行うなどしてデータの改ざんを防ぐ必要があります。 |
| + | たとえば、jsonデータを送信するときに、そのmd5ハッシュを同時に送信し、サーバー側でチェックするなどです。その場合、サーバーとクライアントで共通する送信しない余分の文字列や類推されにくくするために乱数などを加える必要があります。 |
| + | |
| + | **2重登録チェック [#y2834953] |
| + | 前述のように、データを差分で送るとブラウザを2つあげてそれぞれ違うデータを登録すると矛盾したデータが保存されてしまう可能性があります。これはプレイヤー側が意図的に行わなくてもいつでも起こりうる不具合です。 |
| + | |
| + | サーバー側でそれぞれの値の整合性チェックを行うと他の不具合も防げて確実ですが、実装は大変です。単に2重登録を防ぐためだけなら他の方法もあります。たとえばサーバー側で保存回数を数えておいて、その値をクライアントに戻すようにし、クライアントはその値も保存時にサーバーに送信するようにしておきます。サーバー側ではクライアントから送られてきた値がサーバー側の値と等しければそのまま保存し、保存回数をインクリメントしますが、違う場合はエラーにします。 |
| + | |
| + | すると2つのクライアントから別々に保存しようとしても、後から保存する方は保存回数が1つ前になってしまうので、エラーになります。 |
| + | |
| + | **パスワード暗号化 [#b678ed71] |
| + | ここではパスワードを平文で保存していますが、個人情報などを扱う場合はパスワードを暗号化するのが一般的です。たとえばパスワード忘れ処理のためにメールアドレスを保存するなどした場合は、パスワードは暗号化した方が良いでしょう。しかし、カジュアルゲームでは個人情報取得すること自体がユーザーがいやがる可能性があるのでそこまで実装するかどうかは微妙です。 |
| + | |
| + | *費用 [#z4eebd4a] |
| + | 費用についてはゲームによってセーブの回数や転送量が違うのでなんとも言えませんが、一日数百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のほうが安くなります。 |
| | | |
- | 準備中 | + | *サンプルコード [#ucddcda4] |
| + | ここで解説したサンプルコードはこちらです。クライアント側のURLやcrossdomain.xmlの中のURLは環境に合わせて書き換えてください。 |
| + | &ref(gaeWikiSample.zip); |