JSON 的正确用法:Python、MongoDB、JavaScript与Ajax

作者:rainy.im
原文地址:http://blog.rainy.im/2016/05/14/json-the-right-way/

关于本文

本文主要总结网站编写以来在传递 JSON 数据方面遇到的一些问题,以及目前采用的解决方案。网站数据库采用 MongoDB,后端是 Python,前端采用“半分离”形式的 Riot.js,所谓半分离,是说第一页数据是通过服务器端的模板引擎直接渲染到 HTML 中,从而避免首页两次加载的问题,而其它动态内容则采用 Ajax 加载。整个流程中数据都是通过 JSON 格式传递的,但是,在不同的环节中,需要采用不同的方式,并遇到一些不同的问题。本文主要做记录、总结。

json
  1. What is JSON?
    --

JSON(JavaScript Object Notation) 是一种由道格拉斯·克罗克福特构想设计、轻量级的数据交换语言,它的前辈 XML 可能更早被人们所熟知。当然 JSON 并不是为了取代 XML 而存在的,只是相比于 XML 它更小巧、更适合在网页开发中用作数据传递(JSON 之于 JavaScript 就像 XML 之于 Lisp)。从名字上可以看出,JSON 的格式符合 JavaScript 语言中“对象”的语法格式,除了 JavaScript 之外,很多其他语言中也具有类似的类型,例如 Python 中的字典(dict),除了编程语言之外,一些基于文档存储的 NoSQL 非关系型数据库也选择 JSON 作为其数据存储格式,例如 MongoDB。

总的来说,JSON 定义一种标记格式,可以非常方便地在编程语言中的变量数据与字符串文本数据之间相互转换。JSON 描述的数据结构包括以下这几种形式:

  1. 对象:{key: value}
  2. 列表:[obj, obj,...]
  3. 字符串:"string"
  4. 数字:数字
  5. 布尔值:true/false

了解了 JSON 的基本概念之后,下面分别针对上图中的几个数据交互环节进行总结。

  1. Python <=> MongoDB
    --

Python 与 MongoDB 之间的交互主要由现有的驱动库提供支持,包括 PyMongo、Motor 等,而这些驱动所提供的接口都是非常友好的,我们不需要了解任何底层的实现,只要对 Python 原生的字典类型进行操作即可:

import motor  
client = motor.motor_tornado.MotorClient()  
db     = client['test']

user_col = db['user']  
user_col.insert(dict(  
  name = 'Yu',
  is_admin = True,
))

唯一需要注意的是 MongoDB 中的索引项_id是通过ObjectId("572df0b78a83851d5f24e2c1")存储的,而对应的 Python 对象为bson.objectid.ObjectId,因此在查询时需要以此对象的实例进行:

from bson.objectid import ObjectId  
user = db.user.find_one(dict(  
  _id = ObjectId("572df0b78a83851d5f24e2c1")
  ))
  1. Python <=> Ajax
    --

前端与后端之间的数据交流比较常用的是通过 Ajax 完成,这时遇到了第一个不大不小的坑。在之前的一篇文章中,我总结了一次 Python 编码的坑,我们知道 HTTP 传递过程中肯定不存在 JSON/XML ,一切都是二进制数据,但是我们可以选择让前端用什么样的方式解读这些数据,即通过设定 Header 中的 Content-Type,一般传递 JSON 数据时将其设定为Content-Type: application/json,在 Tornado 最新版本中,只需要直接写入字典类型即可:

# Handler
async def post(self):  
  user = await self.db.user.find_one({})
  self.write(user)

于是迎来了第一个错误:TypeError: ObjectId('572df0b58a83851d5f24e2b1') is not JSON serializable。追溯原因,虽然 Tornado 帮我们简化了操作,但在像 HTTP 中写入字典类型时仍然需要经历一次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))

这次不会再出错了,我们自己的JSONEncoder可以应对ObjectId了,但另一个问题也出现了:

JSONEncoder.encode之后字典类型被转换成字符串,写入 HTTP 之后Content-Type变为text/html,这时前端将认为接收的数据为字符串,而不是可用的 JavaScript Object。当然还有进一步的弥补方案,那就是前端再进行一次转换:

$.post(API, {}, function(res){
  data = JSON.parse(res);
  console.log(data._id);
})

问题暂时解决了,在整个过程中 JSON 的变换是这样的:

Python ==> json.dumps ==> HTTP   ==> JavaScript  ==> JSON.parse  
dict   ==> str        ==> binary ==> string      ==> Object

结果第二个问题来了,当数据中存在一些特殊字符时,JSON.parse将出现错误:

JSON.parse("{'abs': '\n'}");  
// VM536:1 Uncaught SyntaxError: Unexpected token ' in JSON at position 1(…)

这就是在遇到问题时,只着眼解决眼前的错误,导致后续一连串改动所带来的弊病。我们沿着上面 JSON 变换的链条向上追溯,看有没有更好的解决方案。很简单,遵循传统规则,出现特例的时候,改变自身适应规则,而不是改变规则

# Handler
async def post(self):  
  user = await self.db.user.find_one({})
  user['_id'] = str(user['_id'])
  self.write(user)

当然,如果是多条数据的列表形式,还需要进一步改造:

# 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
  1. Python <=> HTML+Riot.js
    --

如果上面的问题可以通过遵守规则来解决,那么接下来这个问题就是一个挑战规则的故事。除去 Ajax 动态加载部分,网页上的其他数据是通过后端模板引擎渲染得来的,也就是说是 Hard-coding 为 HTML 的。在浏览器加载并解析这个 HTML 文件之前,它们只是纯文本文件,而我们需要的是直接将数据塞仅<script>标签在浏览器运行JavaScript时直接可用。严格意义上来说,这并不算是 JSON 的应用,而是 Python 的dict与 JavaScript 的Object之间的直接转换。常规的方法应该这样写:

# 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遇到错误之前):

# 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>  

其实跟第 3 小节的问题一样,模板引擎渲染过程与 HTTP 传输过程是类似的。不同的是,在模板中字符串变量就是纯粹的值(没有引号),因此完全可以用生成 JavaScript 脚本文件的形式渲染变量,而无需顾虑特殊字符。(下面的 {% raw ... %}是 Tornado 模板,用于防止特殊符号被 HTML 编码的语法):

<!-- HTML + Riot.js -->  
<app></app>  
<script>  
  riot.mount('app', {
    users: {% raw users %}),
  })
</script>  

总结

JSON 是很好用的数据格式,但是在不同语言环境之间切换还是有很多细节问题需要注意。此外,遵循传统规则,出现特例的时候,改变自身适应规则,而不是试图改变规则,这一条不一定适应所有问题,但对于那些已被公认的规则,请勿轻易挑战。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,444评论 6 496
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,421评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 160,036评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,363评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,460评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,502评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,511评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,280评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,736评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,014评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,190评论 1 342
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,848评论 5 338
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,531评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,159评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,411评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,067评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,078评论 2 352

推荐阅读更多精彩内容