关于JS序列化你需要了解这些

2,731 阅读10分钟

定义及相关概念

序列化(Serialization):把对象转化为可传输的字节序列过程。
反序列化(Deserialization):把字节序列还原为对象的过程。

为什么要序列化?

在当前,OOP已经深入人心,各种语言均把“对象”作为最小的逻辑实体。序列化最终的目的是为了对象可以跨平台存储、进行网络传输。而进行跨平台存储和网络传输的方式就是数据IO,数据IO支持的数据格式是字节数组(byte array)。序列化的目的就是将对象转换为字节数组已备跨平台存储和网络传输。

什么时候需要序列化?

需要进行“跨平台存储”或“网络传输”的数据,都需要进行序列化。 本质上存储和网络传输都需要经过把一个对象状态保存成一种跨平台识别的字节格式,之后其他的平台才可以通过字节信息解析还原对象信息。

序列化协议

为了保证数据交换,该转换过程应该是可逆的,因为单方面的只把对象转成字节数组没有意义。此时就需要在基于一定的规则进行字节数组的转换。我们是没办法把对象的本来面目还原回来的,把对象转成字节数组的时候制定的规则就是序列化协议。

序列化模型

适用于网络传输的序列化模型:

image.png

  • IDL(Interface description language)文件:参与通讯的各方需要对通讯的内容需要做相关的约定(Specifications)。为了建立一个与语言和平台无关的约定,这个约定需要采用与具体开发语言、平台无关的语言来进行描述。这种语言被称为接口描述语言(IDL),采用IDL撰写的协议约定称之为IDL文件。
  • IDL Compiler:IDL文件中约定的内容为了在各语言和平台可见,需要有一个编译器,将IDL文件转换成各语言对应的动态库。
  • Stub/Skeleton Lib:负责序列化和反序列化的工作代码。Stub是一段部署在分布式系统客户端的代码,一方面接收应用层的参数,并对其序列化后通过底层协议栈发送到服务端,另一方面接收服务端序列化后的结果数据,反序列化后交给客户端应用层;Skeleton部署在服务端,其功能与Stub相反,从传输层接收序列化参数,反序列化后交给服务端应用层,并将应用层的执行结果序列化后最终传送给客户端Stub。
  • Client/Server:指的是应用层程序代码,他们面对的是IDL所生存的特定语言的class或struct。
  • 底层协议栈和互联网:序列化之后的数据通过底层的传输层、网络层、链路层以及物理层协议转换成数字信号在互联网中传递。

常见序列化协议

互联网早期的序列化协议主要有COM和CORBA。随着技术的发展,这两项技术已逐渐消亡。
当前常用的序列化协议,包括XMLJSONThriftProtobufAvro

XML

XML历史悠久,其1.0版本早在1998年就形成标准,并被广泛使用至今。
XML的最初产生目标是对互联网文档(Document)进行标记,所以它的设计理念中就包含了对于人和机器都具备可读性。 但是,当这种标记文档的设计被用来序列化对象的时候,就显得冗长而复杂。
XML本质上是一种描述语言,并且具有自我描述(Self-describing)的属性,所以XML自身就被用于XML序列化的IDL。
标准的XML描述格式有两种:DTD(Document Type Definition)和XSD(XML Schema Definition)。作为一种人眼可读(Human-readable)的描述语言,XML被广泛使用在配置文件中,例如O/R mapping、 Spring Bean Configuration File 等。
Mybatis 的mapper文件:

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
  PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
  "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
  
<mapper namespace="com.mybatis3.mappers.StudentMapper">
  <select id="findStudentById" parameterType="int" resultType="Student">
      SELECT stud_id as studId, name, email, dob FROM Students 
      WHERE stud_id=#{studId}
  </select>
  <insert id="insertStudent" parameterType="Student"useGeneratedKeys="true" keyProperty="studId"> 
      INSERT INTO Students(name, email, phone) 
      VALUES(#{name},#{email},#{phone}) 
  </insert>
  <update id="updateStudent" parameterType="Student"> 
      UPDATE Students SET name=#{name}, email=#{email}, phone=#{phone} 
      WHERE stud_id=#{studId} 
  </update>
  <delete id="deleteStudent" parameterType="int"> 
      DELETE FROM Students 
      WHERE stud_id =#{studId} 
  </delete>
</mapper>

JSON

JSON(Javascript Object Notation)起源于Javascript, 它的产生来自于一种称之为“Associative array”的概念,其本质是就是采用“Attribute-value”的方式来描述对象。实际上在Javascript和PHP等弱类型语言中,类的描述方式就是Associative array。JSON的如下优点,使得它快速成为最广泛使用的序列化协议之一:

  1. Associative array格式非常符合工程师对对象的理解。
  2. 它保持了XML的人眼可读(Human-readable)的优点。
  3. 相对于XML而言,序列化后的数据更加简洁。 来自于的以下链接的研究表明:XML所产生序列化文件的体积更大、压缩耗时更长。

image.png

  1. 它具备Javascript的先天性支持,所以被广泛应用于Web browser的应用场景中,是Ajax的事实上的标准数据协议。
  2. 与XML相比,其协议比较简单,解析速度比较快。
  3. 松散的Associative array使得其具有良好的可扩展性和兼容性。

Thrift

Thrift是Facebook开源提供的一个高性能,轻量级RPC服务框架,其产生正是为了满足当前大数据量、分布式、跨语言、跨平台数据通讯的需求。 但是,Thrift并不仅仅是序列化协议,而是一个RPC框架。相对于JSON和XML而言,Thrift在空间开销和解析性能上有了比较大的提升,对于对性能要求比较高的分布式系统,它是一个优秀的RPC解决方案。
由于Thrift的序列化被嵌入到Thrift框架里面,Thrift框架本身并没有透出序列化和反序列化接口,这导致其很难和其他传输层协议共同使用(例如HTTP)。
代码示例:

namespace py base
namespace go base
namespace java com.bytedance.thrift.base

struct Base {
    1: string LogID = "",
    2: string Caller = "",
    3: string Addr = "",
    4: string Client = "",
    5: optional TrafficEnv TrafficEnv,
    6: optional map<string, string> Extra,
}

struct BaseResp {
    1: string StatusMessage = "",
    2: i32 StatusCode = 0,
    3: optional map<string, string> Extra,
}

Protobuf

Protobuf产生于Google,是一个纯粹的展示层协议,可以和各种传输层协议一起使用。不足是,目前Protobuf仅仅支持Java、C++、Python三种开发语言。另外,Protobuf支持的数据类型相对较少,不支持常量类型。由于其设计的理念是纯粹的展现层协议(Presentation Layer),目前并没有一个专门支持Protobuf的RPC框架。
Protobuf具备了优秀的序列化协议的所需的众多典型特征:

  1. 标准的IDL和IDL编译器,这使得其对工程师非常友好。
  2. 序列化数据非常简洁,紧凑,与XML相比,其序列化之后的数据量约为1/3到1/10。
  3. 解析速度非常快,比对应的XML快约20-100倍。
  4. 提供了非常友好的动态库,使用非常简洁,反序列化只需要一行代码。
message Address{
    required string city=1;
    optional string postcode=2;
    optional string street=3;
}
 message UserInfo
{
    required string userid=1;
    required string name=2;
    repeated Address address=3;
}

Avro

Avro的产生解决了JSON的冗长和没有IDL的问题。 Avro提供两种序列化格式:JSON格式或者Binary格式。Binary格式在空间开销和解析性能方面可以和Protobuf媲美,JSON格式方便测试阶段的调试。
Avro支持JSON格式的IDL和类似于Thrift和Protobuf的IDL(实验阶段),这两者之间可以互转。Avro的Schema可以在传输数据的同时发送,加上JSON的自我描述属性,这使得Avro非常适合动态类型语言。 Avro在做文件持久化的时候,一般会和Schema一起存储,所以Avro序列化文件自身具有自我描述属性,所以非常适合于做Hive、Pig和MapReduce的持久化数据格式。

protocol Userservice {
  record Address {
   string city;
   string postcode;
   string street;
  }  
  record UserInfo {
   string name;
   int userid;
   array<Address> address = [];
  }
} 

JavaScript的序列化实现

JSON.stringify

ECMAScript 5中提供了内置函数JSON.stringify()用来序列化JavaScript对象。

JSON.stringify(o[, filter][, indent])

参数说明:

o:要转换成JSON字符串的原始值、对象或数组
filter:可选参数,一个数组或函数(用以自定义序列化转换规则)
indent:可选参数,一个数值或一个字符串(为数字时代表序列化串的空格数,为字符串则会替换空格作为分隔字符)

序列化方法使用起来也很简单:

const pojo = {
      name: 'pojo',
      age: 18,
      email: 'pojo@163.com',
      phone: '12345678901',
    };
const pojoSerialize = JSON.stringify(pojo);

结果:

{ "name": "pojo", "age": 18, "email": "pojo@163.com", "phone": "12345678901" }

JSON.stringify的局限性

两个定义——序列化安全序列化不安全

  • 序列化安全——当某个类型变量/常量经过序列化-反序列化过程后仍能保持原类型和值,则该类型/常量是序列化安全的。
  • 序列化不安全——当某个类型变量/常量经过序列化-反序列化过程后无法保持原类型和值,则该类型/常量是序列化不安全的。
    先看一个例子:
let pojo = {
  num: 1,
  str: "这是一个字符串",
  bool: true,
  nul: null,
  undef: undefined,
  syb: Symbol("syb"),
  nan: NaN,
  infinity: Infinity,
  negativeInfinity: -Infinity,
  obj: new Object(),
  func: () => {
    alert(123);
  },
  date: new Date(),
  arr: [1, 2, 3, 4],
  sets: new Set([1, 2]),
  maps: new Map([
    ["c", 3],
    ["d", 4],
  ]),
  reg: new RegExp("[0-9]+"),
  error: new Error("this is error"),
};
Object.defineProperty(pojo, "sex", {
  value: "male",
  enumerable: false, // 不可枚举
});
console.log("pojo = ", JSON.parse(JSON.stringify(pojo)));

结果:

image.png 由结果可以看到,经过序列化后的pojo对象和原始pojo对象相比缺少了若干内容。这说明:JSON的语法是JavaScript语法的子集,它并不能表示JavaScript中的所有值,对于JSON语法不支持的属性,序列化后会将其省略。
由此可以得到JavaScript中类型和常量的序列化安全性表:

属性类型原值序列化-反序列化后值是否序列化安全
numnumber11
strstring'字符串类型''字符串类型'
boolbooleantruetrue
nulnullnullnull
undefundefinedundefinednull
sybSymbolSymbol('syb')--
nanNaNNaN--
infinityInfinityInfinitynull
ngeativeInfinity-Infinity-Infinitynull
objObject{}{}
funcFunction() => ''--
dateDatenew Date()(new Date()).toJSON()的值
arrArray[1,2,3,4][1,2,3,4]
setsSetnew Set([1,2]){}
mapsMapnew Map([['a',1],['b',2]]){}
regRegExpnew RegExp("[0-9]+"){}
errorErrornew Error('this is error'){}
sexenumerable=false"male"--

根据结果总结得到JSON.stringify方法的详细规则如下:

  1. 对于JavaScript中的六种原始类型,JSON语法支持数字、字符串、布尔值、null四种,不支持undefined,Symbol;
  2. NaN、Infinity和-Infinity序列化的结果是null;
  3. JSON语法不支持函数;
  4. 对于Set,Map,RegExp、Error对象,JSON语法无法保证序列化安全;
  5. 日期对象序列化的结果是ISO格式的字符串(调用了Date对象的toJSON方法),但JSON.parse()依然保留它们字符串形态,并不会将其还原为日期对象;
  6. JSON.stringify()只能序列化对象的可枚举的自有属性;

JSON.parse

和JSON.stringify方法对应的是JSON.parse()方法,用来反序列化对象。

const pojoObj = JSON.parse(serializeStr);

拓展

能否自己写一个增强型的stringify函数,保证尽可能多的类型/常量的序列化安全? 本文参考了Stack Overflow上大神的代码给出了优化版的自定义stringify方法代码:

function serialize(obj, name) {
  var result = "";
  function getInstanceType(o) {
    const typeStr = Object.prototype.toString.call(o);
    let type = /(?<=\s).+(?=\])/.exec(typeStr)[0];
    return type || "";
  }
  function getInstanceValue(o) {
    let val = "()";
    if (o instanceof Set) {
      val = "(['" + Array.from(o).toString() + "'])";
    } else if (o instanceof Map) {
    }
    return val;
  }
  function serializeInternal(o, path) {
    for (p in o) {
      var value = o[p];
      if (typeof value != "object") {
        if (typeof value == "string") {
          result +=
            "\n" +
            path +
            "[" +
            (isNaN(p) ? '"' + p + '"' : p) +
            "] = " +
            '"' +
            value.replace(/\"/g, '\\"') +
            '"' +
            ";";
        } else {
          result +=
            "\n" +
            path +
            "[" +
            (isNaN(p) ? '"' + p + '"' : p) +
            "] = " +
            value +
            ";";
        }
      } else {
        if (value === null) {
          result +=
            "\n" +
            path +
            "[" +
            (isNaN(p) ? '"' + p + '"' : p) +
            "]" +
            "=" +
            "null";
        } else if (value instanceof Array) {
          result +=
            "\n" +
            path +
            "[" +
            (isNaN(p) ? '"' + p + '"' : p) +
            "]" +
            "=" +
            "new Array();";
          serializeInternal(
            value,
            path + "[" + (isNaN(p) ? '"' + p + '"' : p) + "]"
          );
        } else {
          let type = getInstanceType(value);
          if (["Set", "Map", "Date", "RegExp", "Error"].includes(type)) {
            let oldVal = getInstanceValue(value);
            result +=
              "\n" +
              path +
              "[" +
              (isNaN(p) ? '"' + p + '"' : p) +
              "]" +
              "=" +
              `new ${type}${oldVal};`;
          } else {
            result +=
              "\n" +
              path +
              "[" +
              (isNaN(p) ? '"' + p + '"' : p) +
              "]" +
              "=" +
              "new Object();";
            serializeInternal(
              value,
              path + "[" + (isNaN(p) ? '"' + p + '"' : p) + "]"
            );
          }
        }
      }
    }
  }
  serializeInternal(obj, name);
  return result;
}

function A() {
  this.name = "A";
  this.num = 1;
  this.str = "这是一个字符串";
  this.bool = true;
  this.nul = null;
  this.undef = undefined;
  //   this.syb = Symbol("syb");
  this.nan = NaN;
  this.infinity = Infinity;
  this.negativeInfinity = -Infinity;
  this.obj = new Object();
  this.func = function (para) {
    this.arr[this.arr.length] = para;
  };
  this.date = new Date();
  this.arr = new Array();
  this.set = new Set(["key"]);
  this.map = new Map([["key", 1]]);
  this.reg = new RegExp("[0-9]+");
  this.error = new Error("this is error");
}

let a = new A();
let aa = serialize(a, "AA");
// console.log(aa);
let AA = new Object();
eval(aa);
console.table(AA);
console.log("序列化反序列化后值:", AA);

该方法的总体思路跟vue的script解析类似,就是对对象进行递归解析,将对象的拆解有一条条js指令,将这些指令拼接成一个在指令串,然后通过eval函数执行该指令串,还原出对象!

console.table打印结果:

image.png

完整结果:

image.png
从结果看,在支持原来类型的基础上,undefined、NaN、Infinity、Function、Set、Map、RegExp、Error都做到了类型安全。

Function、Set和Map只是部分安全,当Function依赖于其他函数时会执行失败,同样的当Set和Map内含有复杂类型时,也会还原失败