Unity結合Flask實現排行榜功能

Code Cabin發表於2014-12-24

業餘做的小遊戲,排行榜本來是用PlayerPrefs儲存在本地,現在想將資料放在伺服器上。因為功能很簡單,就選擇了小巧玲瓏的Flask來實現。

閒話少敘。首先考慮URL的設計。排行榜無非是一堆分數score的集合,按照REST的思想,不妨將URL設為/scores。用GET獲得排行榜資料,用POST新增一條新紀錄到排行榜。此外,按照慣例,排行榜的資料不需要更新和刪除。

Flask自身不支援REST,但我們可以通過routemethod自己實現。下面建立一個原型版本的rank_server.py。命名沿襲了Rails的習慣:

from flask import Flask

app = Flask(__name__)

@app.route('/scores', methods=['GET'])
def index():
     return 'index'

@app.route('/scores', methods=['POST'])
def create():
     return 'create'

if __name__ == '__main__':
     app.run(debug=True)

執行python rank_server.py來啟動自帶的伺服器。下面我們安裝cURL來測試應用。

brew install curl

測試GET

`curl -i -X GET 127.0.0.1:5000/scores`

測試POST

`curl -i -X POST 127.0.0.1:5000/scores`

-i引數可以展示響應的頭部資訊,便於debug。-X引數指定請求的方法method
可以看到測試成功。

下面我們建立儲存資料的表。本地測試我們使用sqlite,之後部署使用mysql。
建表檔案create_rank.sql內容如下:

DROP TABLE IF EXISTS rank;
CREATE TABLE rank(
	id INTEGER PRIMARY KEY AUTOINCREMENT,
	name VARCHAR(255) NOT NULL,
	score INTEGER NOT NULL
);

Mac自帶sqlite。執行下面語句匯入sql檔案:

sqlite3 rank.db < create_rank.sql

然後隨便插入幾條測試資料。如:

INSERT INTO rank (name, score) VALUES ('A', 100);
INSERT INTO rank (name, score) VALUES ('B', 200);
INSERT INTO rank (name, score) VALUES ('C', 300);

針對資料庫,我們在rank_server.py中加入下面一段程式碼,用於在請求前後處理資料庫連線。

import sqlite3

DATABASE = 'rank.db'

@app.before_request
def before_request():
    g.db = sqlite3.connect(DATABASE)

@app.teardown_request
def teardown_request(exception):
    if hasattr(g, 'db'):
        g.db.close()

我們規定伺服器和客戶端使用JSON傳輸資料。
GET請求返回的JSON格式如下:

{
	"data":
	[
		{
			"id": 0,
			"name": "A",
			"score": 100
		},
		{
			"id": 1,
			"name": "B",
			"score": 200
		}
	]
}

這裡的id其實是自增主鍵,可以不必保留,但為了後面處理方便就一起保留了。

POST提交的JSON格式如下:

{
	"id": 0,
	"name": "C",
	"score": 300
}

現在我們可以著手實現index方法了:

def index():
	cur = g.db.execute('select id, name, score from rank order by score desc;')
	result = cur.fetchmany(100)
	data = []
	for row in result:
		data.append({'id': row[0], 'name': row[1], 'score': row[2]})
	return jsonify({'data': data})

(其中jsonifygflask模組內。後面不再對匯入進行說明,預設都是從flask匯入。)
在查詢時對資料做了排序,並且只返回了前100條記錄。可以用curl再測試一下。測試無誤再實現create方法:

def create():
	status = {'status': 'OK'}
	if not request.json or not 'name' in request.json or not 'score' in request.json:
		status['status'] = 'bad request'
	try:
		g.db.execute('insert into rank (name, score) values (?, ?)', [request.json['name'], request.json['score']])
		g.db.commit()
	except:
		status['status'] = 'database error'
	return jsonify(status)

我們的POST請求都是JSON型別的,所以要從request.json獲得,而不是args或者form。此外,返回了一個status變數,便於檢視出錯原因。

再用curl測試一下POST。這次,我們要向POST請求中加入資料:

curl -i -X POST -H "Content-Type: application/json" -d '{"id": 0, "name": "xyz", "score": "800"}' 127.0.0.1:5000/scores

-H引數用於指定頭部資訊,-d引數可以攜帶資料,這裡就是一條符合我們提交格式的JSON資料。

現在伺服器端就(暫時)實現完了。下面該寫C#程式碼啦。

我們需要設計一個和伺服器互動、並返回資料給UI層的類。

首先,這個類應該是單例的,要繼承MonoBehaviour(因為和伺服器互動要利用Coroutine);而且最好獨立於場景之外。關於Unity中實現單例類的集中方式,請看我的另一篇文章。單例的程式碼如下:

private static SaveLoad _instance = null;

	public static SaveLoad Instance {
		get
		{
			if (_instance == null)
			{                                   
				GameObject go = new GameObject("SaveLoadGameObject");
				DontDestroyOnLoad(go);
				_instance = go.AddComponent<SaveLoad>();
			}
			return _instance;
		}
	}

還需要定義一些常量:

const int recordsPerPage = 5;
	const string URL = "127.0.0.1:5000/scores";

定義一個資料結構:

public struct Data {
        public int id;
        public string name;
        public int score;
    }

在動手之前,還要了解兩個東西:WWW類和LitJson庫。WWW類是Unity自帶的處理HTTP請求的類;LitJson是一個C#處理JSON的開源庫。要使用LitJson,先從官網下載dll檔案,然後匯入Asset。

SaveLoad類的功能就像名字一樣,包括儲存Save和載入Load

public void Save(Data data)
    {
		var jsonString = JsonMapper.ToJson(data);
		var headers = new Dictionary<string, string> ();
		headers.Add ("Content-Type", "application/json");
		var scores = new WWW (URL, new System.Text.UTF8Encoding ().GetBytes (jsonString), headers);
		StartCoroutine (WaitForPost (scores));
    }

	IEnumerator WaitForPost(WWW www){
		yield return www;
		Debug.Log (www.text);
	}

這裡建立WWW例項,指定了URL、header和提交資料。第一行的JsonMapper可以在物件和JSON之間進行轉換,前提是物件中的屬性和JSON中的鍵要保持一致。

public void Load()
	{
		var scores = new WWW (URL);
		StartCoroutine(WaitForGet(scores));
	}

	IEnumerator WaitForGet(WWW www){
		yield return www;
		if (www.error == null && www.isDone) {
			var dataList = JsonMapper.ToObject<DataList>(www.text);
			data = dataList.data;
		}else{
			Debug.Log ("Failed to connect to server!");
			Debug.Log (www.error);
		}
	}

Load方法中是將前面index方法返回的JSON文字轉換成物件,這裡為了實現轉換,新建一個DataList類,其中的屬性是List<Data>

到這裡,客戶端的讀取和儲存資料就實現了。其餘的邏輯,比如和UI的互動,在這裡就不寫了。感興趣的可以看我的小遊戲的完整程式碼。GitHub傳送門

最後談談部署的事情。如果要部署到SAE有幾點要注意:

  • 程式碼要進行一定的修改以適應MySQLdb
  • 要注意中文的編碼。如用unicode方法轉換名字屬性,以及檔案頭部的:
# -*- coding:utf8 -*-
#encoding = utf-8

最後說說比較坑的Unity跨域訪問的限制。在我成功部署後,curl測試沒有問題了。結果Unity報了錯:

SecurityException: No valid crossdomain policy available to allow access

經過一番搜尋,原來要在伺服器的根目錄增加一個crossdomain.xml檔案。檔案內容大致如下:

<?xml version="1.0"?>
<!DOCTYPE cross-domain-policy SYSTEM
"http://www.adobe.com/xml/dtds/cross-domain-policy.dtd">
<cross-domain-policy>
	<site-control permitted-cross-domain-policies="master-only"/>
	<allow-access-from domain="*"/>
	<allow-http-request-headers-from domain="*" headers="*"/>
</cross-domain-policy>

但是SAE好像不支援上傳檔案到根目錄。只能用Flask仿冒一下了:

@app.route('/crossdomain.xml')
def fake():
	xml = """上面的那堆內容"""
	return xml, 200, {'Content-Type': 'text/xml; charset=ascii'}

OK,大功告成!

本地的rank_server.py檔案下載

部署後的rank_server.py檔案下載

相關文章