作者:rainy.im
原文地址:http://blog.rainy.im/2016/05/14/json-the-right-way/
關(guān)于本文
本文主要總結(jié)網(wǎng)站編寫以來在傳遞 JSON 數(shù)據(jù)方面遇到的一些問題,以及目前采用的解決方案褒搔。網(wǎng)站數(shù)據(jù)庫采用 MongoDB阶牍,后端是 Python,前端采用“半分離”形式的 Riot.js星瘾,所謂半分離走孽,是說第一頁數(shù)據(jù)是通過服務(wù)器端的模板引擎直接渲染到 HTML 中,從而避免首頁兩次加載的問題琳状,而其它動(dòng)態(tài)內(nèi)容則采用 Ajax 加載磕瓷。整個(gè)流程中數(shù)據(jù)都是通過 JSON 格式傳遞的,但是念逞,在不同的環(huán)節(jié)中困食,需要采用不同的方式,并遇到一些不同的問題翎承。本文主要做記錄硕盹、總結(jié)。
- What is JSON?
--
JSON(JavaScript Object Notation) 是一種由道格拉斯·克羅克福特構(gòu)想設(shè)計(jì)叨咖、輕量級的數(shù)據(jù)交換語言瘩例,它的前輩 XML 可能更早被人們所熟知。當(dāng)然 JSON 并不是為了取代 XML 而存在的甸各,只是相比于 XML 它更小巧垛贤、更適合在網(wǎng)頁開發(fā)中用作數(shù)據(jù)傳遞(JSON 之于 JavaScript 就像 XML 之于 Lisp)。從名字上可以看出趣倾,JSON 的格式符合 JavaScript 語言中“對象”的語法格式聘惦,除了 JavaScript 之外,很多其他語言中也具有類似的類型儒恋,例如 Python 中的字典(dict
)善绎,除了編程語言之外脚线,一些基于文檔存儲的 NoSQL 非關(guān)系型數(shù)據(jù)庫也選擇 JSON 作為其數(shù)據(jù)存儲格式张漂,例如 MongoDB。
總的來說侍芝,JSON 定義一種標(biāo)記格式箱锐,可以非常方便地在編程語言中的變量數(shù)據(jù)與字符串文本數(shù)據(jù)之間相互轉(zhuǎn)換。JSON 描述的數(shù)據(jù)結(jié)構(gòu)包括以下這幾種形式:
- 對象:
{key: value}
- 列表:
[obj, obj,...]
- 字符串:
"string"
- 數(shù)字:數(shù)字
- 布爾值:
true
/false
了解了 JSON 的基本概念之后劳较,下面分別針對上圖中的幾個(gè)數(shù)據(jù)交互環(huán)節(jié)進(jìn)行總結(jié)驹止。
- Python <=> MongoDB
--
Python 與 MongoDB 之間的交互主要由現(xiàn)有的驅(qū)動(dòng)庫提供支持浩聋,包括 PyMongo、Motor 等臊恋,而這些驅(qū)動(dòng)所提供的接口都是非常友好的衣洁,我們不需要了解任何底層的實(shí)現(xiàn),只要對 Python 原生的字典類型進(jìn)行操作即可:
import motor
client = motor.motor_tornado.MotorClient()
db = client['test']
user_col = db['user']
user_col.insert(dict(
name = 'Yu',
is_admin = True,
))
唯一需要注意的是 MongoDB 中的索引項(xiàng)_id
是通過ObjectId("572df0b78a83851d5f24e2c1")
存儲的抖仅,而對應(yīng)的 Python 對象為bson.objectid.ObjectId
坊夫,因此在查詢時(shí)需要以此對象的實(shí)例進(jìn)行:
from bson.objectid import ObjectId
user = db.user.find_one(dict(
_id = ObjectId("572df0b78a83851d5f24e2c1")
))
- Python <=> Ajax
--
前端與后端之間的數(shù)據(jù)交流比較常用的是通過 Ajax 完成,這時(shí)遇到了第一個(gè)不大不小的坑撤卢。在之前的一篇文章中环凿,我總結(jié)了一次 Python 編碼的坑,我們知道 HTTP 傳遞過程中肯定不存在 JSON/XML 放吩,一切都是二進(jìn)制數(shù)據(jù)智听,但是我們可以選擇讓前端用什么樣的方式解讀這些數(shù)據(jù),即通過設(shè)定 Header 中的 Content-Type
渡紫,一般傳遞 JSON 數(shù)據(jù)時(shí)將其設(shè)定為Content-Type: application/json
到推,在 Tornado 最新版本中,只需要直接寫入字典類型即可:
# Handler
async def post(self):
user = await self.db.user.find_one({})
self.write(user)
于是迎來了第一個(gè)錯(cuò)誤:TypeError: ObjectId('572df0b58a83851d5f24e2b1') is not JSON serializable
惕澎。追溯原因莉测,雖然 Tornado 幫我們簡化了操作,但在像 HTTP 中寫入字典類型時(shí)仍然需要經(jīng)歷一次json.dumps(user)
操作唧喉,而對于json.dumps
來說捣卤,ObjectId
類型是非法的。于是我選擇了最直觀的解決方案:
import json
from bson.objectid import ObjectId
class JSONEncoder(json.JSONEncoder):
def default(self, obj):
if isinstance(obj, ObjectId):
return str(obj)
return super().default(self, obj)
# Handler
async def post(self):
user = await self.db.user.find_one({})
self.write(JSONEncoder.encode(user))
這次不會(huì)再出錯(cuò)了欣喧,我們自己的JSONEncoder
可以應(yīng)對ObjectId
了腌零,但另一個(gè)問題也出現(xiàn)了:
JSONEncoder.encode
之后字典類型被轉(zhuǎn)換成字符串,寫入 HTTP 之后Content-Type
變?yōu)?code>text/html唆阿,這時(shí)前端將認(rèn)為接收的數(shù)據(jù)為字符串益涧,而不是可用的 JavaScript Object。當(dāng)然還有進(jìn)一步的彌補(bǔ)方案驯鳖,那就是前端再進(jìn)行一次轉(zhuǎn)換:
$.post(API, {}, function(res){
data = JSON.parse(res);
console.log(data._id);
})
問題暫時(shí)解決了闲询,在整個(gè)過程中 JSON 的變換是這樣的:
Python ==> json.dumps ==> HTTP ==> JavaScript ==> JSON.parse
dict ==> str ==> binary ==> string ==> Object
結(jié)果第二個(gè)問題來了,當(dāng)數(shù)據(jù)中存在一些特殊字符時(shí)浅辙,JSON.parse
將出現(xiàn)錯(cuò)誤:
JSON.parse("{'abs': '\n'}");
// VM536:1 Uncaught SyntaxError: Unexpected token ' in JSON at position 1(…)
這就是在遇到問題時(shí)扭弧,只著眼解決眼前的錯(cuò)誤,導(dǎo)致后續(xù)一連串改動(dòng)所帶來的弊病记舆。我們沿著上面 JSON 變換的鏈條向上追溯鸽捻,看有沒有更好的解決方案。很簡單,遵循傳統(tǒng)規(guī)則御蒲,出現(xiàn)特例的時(shí)候衣赶,改變自身適應(yīng)規(guī)則,而不是改變規(guī)則:
# Handler
async def post(self):
user = await self.db.user.find_one({})
user['_id'] = str(user['_id'])
self.write(user)
當(dāng)然厚满,如果是多條數(shù)據(jù)的列表形式府瞄,還需要進(jìn)一步改造:
# DB
async def get_top_users(self, n = 20):
users = []
async for user in self.db.user.find({}).sort('rank', -1).limit(n):
user['_id'] = str(user['_id'])
users.append(user)
return users
- Python <=> HTML+Riot.js
--
如果上面的問題可以通過遵守規(guī)則來解決,那么接下來這個(gè)問題就是一個(gè)挑戰(zhàn)規(guī)則的故事碘箍。除去 Ajax 動(dòng)態(tài)加載部分遵馆,網(wǎng)頁上的其他數(shù)據(jù)是通過后端模板引擎渲染得來的,也就是說是 Hard-coding 為 HTML 的丰榴。在瀏覽器加載并解析這個(gè) HTML 文件之前货邓,它們只是純文本文件,而我們需要的是直接將數(shù)據(jù)塞僅<script>
標(biāo)簽在瀏覽器運(yùn)行JavaScript
時(shí)直接可用多艇。嚴(yán)格意義上來說逻恐,這并不算是 JSON 的應(yīng)用,而是 Python 的dict
與 JavaScript 的Object
之間的直接轉(zhuǎn)換峻黍。常規(guī)的方法應(yīng)該這樣寫:
# Handler
async def get(self):
users = self.db.get_top_users()
render_data = dict(
users = users
)
self.render('users.html', **render_data)
<!-- HTML + Riot.js -->
<app></app>
<script>
riot.mount('app', {
users: [
{% for user in users %}
{ name: "{{ user['name']}}", is_admin: "{{ user['is_admin']}}" },
{% end %}
],
})
</script>
這樣寫是對的复隆,但是要解決上面提到的ObjectId()
問題還是需要一些額外的處理(尤其是引號問題)。另外為了解決ObjectId
的問題我還嘗試了一種比較蠢的方法(在上面的JSON.parse
遇到錯(cuò)誤之前):
# Handler
async def get(self):
users = self.db.get_top_users()
render_data = dict(
users = JSONEncoder.encode(users)
)
self.render('users.html', **render_data)
<!-- HTML + Riot.js -->
<app></app>
<script>
riot.mount('app', {
users: JSON.parse('{{ users }}'),
})
</script>
其實(shí)跟第 3 小節(jié)的問題一樣姆涩,模板引擎渲染過程與 HTTP 傳輸過程是類似的挽拂。不同的是,在模板中字符串變量就是純粹的值(沒有引號)骨饿,因此完全可以用生成 JavaScript 腳本文件的形式渲染變量亏栈,而無需顧慮特殊字符。(下面的 {% raw ... %}
是 Tornado 模板宏赘,用于防止特殊符號被 HTML 編碼的語法):
<!-- HTML + Riot.js -->
<app></app>
<script>
riot.mount('app', {
users: {% raw users %}),
})
</script>
總結(jié)
JSON 是很好用的數(shù)據(jù)格式绒北,但是在不同語言環(huán)境之間切換還是有很多細(xì)節(jié)問題需要注意。此外察署,遵循傳統(tǒng)規(guī)則闷游,出現(xiàn)特例的時(shí)候,改變自身適應(yīng)規(guī)則贴汪,而不是試圖改變規(guī)則脐往,這一條不一定適應(yīng)所有問題,但對于那些已被公認(rèn)的規(guī)則扳埂,請勿輕易挑戰(zhàn)业簿。