9.4 使用JSON进行转储和加载

    JSON是什么?摘自www.json.org网页中的一段描述:

    JSON(JavaScript Object Notation)是一种轻量级的数据交换格式,易于人阅读和编写。同时也易于机器解析和生成。它基于JavaScript Programming Language,Standard ECMA-262 3rd Edition-December 1999的一个子集。JSON采用完全独立于语言的文本格式,但是也使用了类似于C语言家族的习惯(包括C、C++、C#、Java、JavaScript、Perl和Python等)。这些属性使JSON成为理想的数据交换语言。

    这种格式被广泛地用于各种语言和框架,而且像 CouchDB 这样的数据库会直接保存JSON对象,简化了应用程序间数据的转化。JSON文档具有模糊查找的优势,在这点上,和Python中的listdict是相似的。它们都具有很强的可读性,并且修改起来很容易。

    json模块可以与Python中内置的类型有效地工作。可它不能与自定义的类一起工作,除非做一些额外的工作。接下来会介绍有关这些扩展的技巧,对于如下几种Python类型,都对应了JSON中所使用的javascript类型。

    Python类型

    JSON

    dict

    object

    list, tuple

    array

    str

    string

    int, float

    number

    True

    true

    False

    false

    None

    null
    除了以上定义的几种类型,其他类型都不支持,并且必须使用扩展函数将其转换为以上的一种类型,这些函数可以使用插件式设计来实现转储和加载功能。我们可以通过将微博对象转换为Python中的listsdicts类型来探究这些内置类型。当把视线转移到PostBlog的类定义时,会发现已经定义了asdict()方法,用于将自定义的对象转化为Python中内置的对象。以下代码基于博客数据生成JSON串。
    import json
    print( json.dumps(travel.asdict(), indent=4) )
    以下是输出:
    {
      "entries": [
        {
          "title": "Hard Aground",
          "underline": "——————",
          "tagtext": "#RedRanger #Whitby42 #ICW",
          "rsttext": "Some embarrassing revelation. Including \
    u2639 and \u2693",
          "date": "2013-11-14 17:25:00"
        },
        {
          "title": "Anchor Follies",
          "underline": "———————",
          "tagtext": "#RedRanger #Whitby42 #Mistakes",
          "rsttext": "Some witty epigram. Including < & >
    characters.",
          "date": "2013-11-18 15:30:00"
        }
      ],
      "title": "Travel"
    }
    以上输出演示了不同的对象是如何从Python对象转化为JSON格式的。以上代码优雅的部分是,Python对象被写成了标准化的格式,可以与其他应用共享,也可以将它们写入磁盘文件并保存。此外,JSON这种数据表现形式也有几点不方便的地方。 - 如果必须将Python对象重写为字典,应该提供一种更好的转换方式,不必额外地创建字典对象。 - 当我们加载这个JSON数据时,并不能够轻易地恢复之前的Blog和Post对象。当我们使用json.load()时,并不会得到Blog或Post对象,仅仅能够得到dict和集合对象。在创建Blog和Post对象时,我们需要额外的信息。 - 对于对象内dict中的一些值我们并不希望保存,例如Post中下划线的文字。 除了内置的JSON编码外,还需要做些更复杂的工作。 9.4.1 在类中支持JSON 为了正确地支持JSON,在使用类之前,需要先考虑编码与解码。为了将对象编码为JSON,需要提供一个函数,将对象转换为Python中的基本类型。这个函数被称为default函数,为已知类的对象提供了默认的编码方式。 为了将对象从JSON中解码,需要提供一个函数,用于将由Python基本类型组成的字典转换为一个对象。这个函数被称为object hook函数,用于将dict转换为一个自定义对象。 json模块文档中包含了如何使用类的提示。Python文档包含了一个对JSON-RPC第1版本的参考引用。可以参见http://json-rpc.org/wiki/specification。这个建议是将自定义类的对象编码为字典形式,如下所示。
    {"jsonclass": ["class name", [param1,…]] }
    "jsonclass"值相关的值包含了两项:类名称和所需的参数列表,它们用于创建类的实例。还可以包含更多的功能,但都与Python无关。 为了将一个对象从JSON字典中解码,作为一种提示,我们可以查找"jsonclass"键,用于创建自定义类中的其中一个,不是Python内置的对象。类名可被映射为类对象,而参数序列可用于创建实例。 当讨论更复杂的JSON编码器(例如其中一个内置于Django Web框架)时,可以看到它们为自定义类提供了一些更复杂的编码。它们包括了类、数据库的主键以及属性值。这些规则将表现为一些简单的函数,并以插件的形式加入JSON的编码与解码的函数中。 9.4.2 自定义JSON编码 类的提示可以提供3部分信息:一个class键,作为目标类的命名;args键,将提供一个位置参数值的序列;一个kw值,将提供一个关键字参数值的字典,包含了init()的所有选项。以下是这种设计的编码器的一个实现示例。
    def blogencode( object ):
      if isinstance(object, datetime.datetime):
        return dict(
          class= "datetime.datetime",
          args= [],
          kw= dict(
            year= object.year,
            month= object.month,
            day= object.day,
            hour= object.hour,
            minute= object.minute,
            second= object.second,
          )
        )
      elif isinstance(object, Post):
        return dict(
          class= "Post",
          args= [],
          kw= dict(
            date= object.date,
            title= object.title,
            rsttext= object.rsttext,
            tags= object.tags,
          )
        )
      elif isinstance(object, Blog):
        return dict(
          class= "Blog",
          args= [
            object.title,
            object.entries,
          ],
          kw= {}
        )
      else:
        return json.JSONEncoder.default(o)
    这个函数演示了对3个类对象编码操作的两种不同风格。 - 将datetime.datetime对象编码为一个字典,其中包含了独立的字段值。 - 将Post对象编码为一个字典,也包含了独立的字段值。 - 将Blog实例编码为由标题和文章组成的一个序列。 如果不能处理这个类,将使用默认的编码器进行编码。这将能够有效地处理内置的类。可以使用这个函数来像如下这样来编码。
    text= json.dumps(travel, indent=4, default=blogencode)
    为了调用json.dumps()函数,提供了我们的函数,blogencode()作为default=关键字的参数。这个函数由JSON编码器用来决定对象的编码方式。这个编码器的使用导致JSON对象的结构将如下代码所示。
    {
      "args": [
        "Travel",
        [
          {
            "args": [],
            "kw": {
              "tags": [
                "#RedRanger",
                "#Whitby42",
                "#ICW"
              ],
              "rsttext": "Some embarrassing revelation.
    Including \u2639 and \u2693",
              "date": {
                "args": [],
                "kw": {
                  "minute": 25,
                  "hour": 17,
                  "day": 14,
                  "month": 11,
                  "year": 2013,
                  "second": 0
                },
                "class": "datetime.datetime"
              },
              "title": "Hard Aground"
            },
            "class": "Post"
          },
    .
    .
    .
      "_kw
    ": {},
      "__class
    ": "Blog"
    }
    我们拿掉了第2条博客记录,因为输出结果太长了。一个Blog对象由dict完成封装,它提供了类和两个位置参数值。相似地,Postdatetime对象的类名和关键字参数值也被封装了起来。 9.4.3 自定义JSON解码 为了对一个JSON对象进行解码,我们需要在JSON结构的解析上下工夫。自定义类的对象被编码为简单的dicts。这意味着每个由JSON解码器解码的dict都可能是自定义类中的一个,或者它只是一个dict。 JSON解码器中“对象钩子”是一个被dict调用的函数,用于检验是否被正确地表达成了自定义对象。如果dict没有被hook函数识别,那么它仅是一个字典并且应当被直接返回。以下是一个对象钩子函数。
    def blog_decode( some_dict ):
      if set(some_dict.keys()) == set( ["__class", "__args", "
    kw"] ):
        class= eval(somedict['class'])
        return class
    ( somedict['_args'], *somedict['kw'] )
      else:
        return somedict
    每当这个函数被调用时,它检查所有用于编码的键。如果有3个键存在,则调用这个函数,并传入它的参数和关键字。可使用对象钩子完成JSON对象的解析,如下代码所示。
    blog_data= json.loads(text, object_hook= blog_decode)
    这将完成JSON格式文本块的解码,使用了blog_decode()函数来将dict转换为适当的BlogPost对象。 9.4.4 安全性和eval() 一些程序员会反对在blog_decode()函数中使用eval()函数,声称这是一个普遍的安全问题。认为eval()是一个普遍问题的说法是愚蠢的。如果有恶意的天才程序员(Evil Genius Programmer,EGP)将恶意代码写入对象的JSON表达式中,这的确是一个潜在的安全问题。一个EGP是可以获取Python源代码的,但为什么仅仅是折腾JSON文件,而不去直接编辑Python源代码? 实际上,我们需要关注JSON文件在网络上的传输过程;这是一个实际的安全性问题。然而,这个过程一般不会注意到eval()。 有时,一个文件在网络传输过程中可能被中间人篡改成为不可靠的文件,对于这样的场景需要制定一些规则。这样的话,每当一个JSON文件在通过一个Web接口时就需要被验证,它有可能是一个不可靠的服务器代理。SSL通常是解决这类问题的首选方案。 如果有必要,可以将eval()替换为一个字典,完成从名字到类的映射。我们可以将eval(some_dict['__class
    '])改为{"Post":Post, "Blog":Blog, "datetime. datetime":datetime.datetime:
    }[somedict['_class']]

    这样就可以避免当JSON文件传输时使用的是非SSL加密的连接。这个做法也需要增加相应的维护成本,当应用的设计改变时,这里的映射也要改变。

    9.4.5 重构编码函数

    理想情况下,我们会希望重构编码函数,为了提高对每个定义的类进行编码的职责的内聚性。我们当然不希望将大量的编码规则分布在不同的函数中。

    如果要修改类库中类的编码行为,例如datetime,就需要在应用程序中对datetime. datetime进行扩展。这样一来,就需要确定在我们的应用中使用了扩展版本而不是类库版本,而完全避免使用内置的datetime类并不是非常容易。通常情况下,需要在自定义与类库之间找到一个平衡点。以下是两个类的扩展,用于创建带有JSON编码能力的类。可以为Blog添加一个特性。

    @property
    def json( self ):
      return dict( class= self.class.name,
        kw= {},
        _args
    = [ self.title, self.entries ]
      )

    这个特性被用来提供解码函数所使用的初始化参数。可以向Post添加这两个特性。

    @property
    def json( self ):
      return dict(
        class= self.class.name,
        kw= dict(
          date= self.date,
          title= self.title,
          rsttext= self.rst_text,
          tags= self.tags,
        ),
        __args
    = []
      )

    对于 Blog,这个特性将用来提供初始化参数。它们会被解码函数使用,可以修改编码器,使它更简洁一些,以下是修改后的版本。

    def blogencode2( object ):
      if isinstance(object, datetime.datetime):
        return dict(
          class= "datetime.datetime",
          args= [],
          __kw
    = dict(
            year= object.year,
            month= object.month,
            day= object.day,
            hour= object.hour,
            minute= object.minute,
            second= object.second,
          )
        )
      else:
        try:
          encoding= object._json()
        except AttributeError:
          encoding= json.JSONEncoder.default(o)
        return encoding

    你或许纠结于是否要使用类库中的datetime模块。在本例中,选择不引入子类,而是将编码作为一个特例。

    9.4.6 日期字符串的标准化

    日期格式化并没有使用被广泛使用的ISO标准文本格式的日期。为了更好地与其他语言兼容,应当用标准字符串对datetime对象进行适当的编码,并能够解析一个标准字符串。

    因为把日期当作了特例,从扩展性上来说,这样做是明智的。无需对编码和解码进行太多的改动就能够完成。如下是对编码改动后的一种实现:

    if isinstance(object, datetime.datetime):
      fmt= "%Y-%m-%dT%H:%M:%S"
      return dict(
        class= "datetime.datetime.strptime",
        args= [ object.strftime(fmt), fmt ],
        kw= {}
      )

    经过编码的输出中包含了静态方法datetime.datetime.strptime()并提供了与datetime编码的参数,以及用于解码的格式。一篇文章的输出可能如下代码段所示。

    {
      "args": [],
      "class": "PostJ",
      "kw": {
        "title": "Anchor Follies",
        "tags": [
          "#RedRanger",
          "#Whitby42",
          "#Mistakes"
        ],
        "rsttext": "Some witty epigram.",
        "date": {
          "args": [
            "2013-11-18T15:30:00",
            "%Y-%m-%dT%H:%M:%S"
          ],
          "class": "datetime.datetime.strptime",
          "__kw
    ": {}
        }
      }
    }

    这意味着我们现在使用的是ISO格式的日期,而不是独立的字段,并且不再使用类名来创建对象。class的值被扩展成为了一个类名或静态方法名。

    9.4.7 将JSON写入文件

    当我们写JSON文件时,通常会像如下代码这样实现。

    with open("temp.json", "w", encoding="UTF-8") as target:
      json.dump( travel3, target, separators=(',', ':'), default=blog_
    j2_encode )

    以所需的编码格式打开文件,给json.dump()方法传入文件对象。当读JSON文件时,可以使用一个类似以下这样的技巧。

    with open("some_source.json", "r", encoding="UTF-8") as source:
      objects= json.load( source, object_hook= blog_decode)

    思路是,将JSON作为文本的表示方式与字节转换的过程进行分离。在JSON中有一些格式化的方式可以选择。在之前的例子中缩进了4个空格是为了生成更美观的JSON输出。还有一种选择,可使得输出更紧凑而不使用缩进。可以使分隔符更简洁,从而对其进一步压缩。如下是temp.json生成的输出。

    {"class":"BlogJ","args":["Travel",[{"class":"PostJ","
    args
    ":[],"kw":{"rsttext":"Some embarrassing revelati
    on.","tags":["#RedRanger","#Whitby42","#ICW"],"title":"Hard
    Aground","date":{"class":"datetime.datetime.strptime","
    args
    ":["2013-11-14T17:25:00","%Y-%m-%dT%H:%M:%S"],"
    kw
    ":{}}}},{"class":"PostJ","_args":[],"__kw
    ":{"rst

    text":"Some witty epigram.","tags":["#RedRanger","#Whitby42","#Mistak
    es"],"title":"Anchor Follies","date":{"class":"datetime.datetime.
    strptime","args":["2013-11-18T15:30:00","%Y-%m-%dT%H:%M:%S"],"
    kw
    ":{}}}}]],"kw":{}}