JSON Eval Trick in PHP

PHP从5.2版本开始提供json_encode()json_decode()函数,分别用于JSON的序例化和反序列化。不幸的是,现在仍然有许多主机运行着PHP5.2之前的版本,所以我就不得不自己动手、丰衣足食了。

JSON Encode

JSON的编码并没有什么难度,要点就两个:

下面是JSON编码方法的实现:

/**
 * JSON Encode
 * @warn Any input string must be UTF-8 encoding
 * @param {Any} $data Any type object to serialize.
 * @return {String} serialized json string.
 */
function json_stringify($data) {
  if (is_null($data)) return 'null';
  if (is_scalar($data)) return json_stringify_scalar($data);
  if (empty($data)) return '[]';
  if (is_object($data)) {
    $data=get_object_vars($data);
    if (empty($data)) return '{}';
  }
  $keys=array_keys($data);
  if ($keys===array_keys($keys)) {
    $data=array_map(__FUNCTION__,$data);
    return '['.join(',',$data).']';
  } else {//不是有序数字下标的数组即视为关联数组
    $a=array();
    foreach ($data as $k=>$v) {
      $a[]=json_stringify_scalar(strval($k)).':'.json_stringify($v);
    }
    return '{'.join(',',$a).'}';
  }
}

function json_stringify_scalar($v) {
  if (is_bool($v)) {
    $v = $v?'true':'false';
  } else if (is_string($v)) {
    $v=addcslashes($v,"\t\n\r\"\/\\");//转义特殊字符
    //将所有非ASCII字符转换成Unicode Escape格式
    $v='"'.preg_replace_callback(
             '|[^\x00-\x7F]+|','_unicode_escape',$v).'"';
  }
  return (string)$v;
}

function _unicode_escape($s) {
  //Warning:源字符串必须为UTF-8编码
  $s=str_split(iconv('UTF-8','UCS-2BE',$s[0]),2);
  foreach ($s as $i=>$c) {
    $s[$i]=sprintf('\u%02x%02x',ord($c{0}),ord($c{1}));
  }
  return join('',$s);
}

JSON Decode

而将JSON转换成PHP中的对象就没有这么简单了,手写Parser是个累人的体力活。反观在JavaScript中实现JSON Parse就简单多了,因为JSON本身即是JavaScript语言的子集,直接使用eval方法,就能将JSON字符串转换成JS中的对象。

其实,看到JavaScript中的 eval 方法,实在不能不联想到PHP也有一个eval,它们的功能是类似的,都是将字符串当作代码运行。不同的是,JS中的eval执行JS代码,PHP的eval执行PHP代码。Trick就在这里:要让PHP能直接用eval方法解析JSON,只要将JSON代码转换成PHP格式的代码就行了!如对于下面的JSON:

{
  "name":"CJ",
  "age":18,
  "tags":["PHP","JavaScript","Python","Haskell"],
  "life":{
    "summary":"Too complex!"
  }
}

只要将其转换成这样的PHP代码就能直接 eval 了:

(object)array(
  "name"=>"CJ",
  "age"=>18,
  "tags"=>array("PHP","JavaScript","Python","Haskell"),
  "life"=>(object)array(
    "summary"=>"Too complex!"
  )
)

而将JSON转换成PHP代码则只需很少的步骤与注意点:

  1. 先提取出字符串,然后就可安心地做一些类似将[]替换成array()的操作了。怎样用正则表达式匹配出一个完整的字符串字面量呢?很明显双引号匹配字符串开始,JSON中字符串必定是用双引号引起的。而匹配字符串结束则需要一个前面有偶数个后向转义斜线的双引号,因为字符串中也可以包含双引号,但它前面一定有奇数个后向转义斜线,如"Quotes here:\"..."。使用正则表达式的否定式后瞻断言可以一步做到这样的匹配。
  2. 再将[1,2]{"k":"v"}转换成array(1,2)(object)array("k"=>"v")这样的格式。
  3. Number、Boolean、Null值保持原样,直接交给PHP的eval方法。
  4. 因为PHP不支持Unicode转义序列,所以需要将String中的Unicode转义序列转换成对应的字符。而匹配出一个Unicode转义序列,也要注意“\u”前面只能有偶数个后向转义斜线。如“\\u5409”是不需要转换成“吉”这个字符的。
  5. 不过,因为使用了非常危险的eval方法,所以必须要进行一些安全性检查,防止恶意代码被执行。

最终的实现代码出人意料的简单:

/**
 * JSON Decode
 * @param {String} $s The string json data.
 * @param {Boolean} [$assoc=false] Return assoc array if $assoc is true.
 * @return decode result corresponding object.
 */
function json_parse($s,$assoc=false) {
  static $strings,$count=0;
  if (is_string($s)) {
    $s=trim($s);
    $strings=array();
    //匹配字符串结束引号应该确保前面只能有偶数个'\'
    //如 "ab\"c"中 \" 不能被视为字符串结束引号
    $s=preg_replace_callback('/"([\s\S]*?(?<!\\\\)(?:\\\\\\\\)*)"/',
                              __FUNCTION__,$s);
    //去除特殊字符后只需作简单的安全检测即可
    $clean=str_replace(array('true','false',
                             'null','{','}',
                             '[',']',',',':','#','.'),'',$s);
    if ($clean && !is_numeric($clean)) {//可能是格式不正确的JSON或恶意代码
      return NULL;
    }
    $s=str_replace(
         array('{','[',']','}',':','null'),
         //通过'(object)'类型转换将关联数组转换成stdClass instance
         array(($assoc?'':'(object)').'array(',
               'array(',')',')','=>','NULL')
         ,$s);
    $s=preg_replace_callback('/#\d+#/',__FUNCTION__,$s);
    //抑制错误,如{3##}能通过上面的安全检测但却无法转换成正确的PHP代码
    @$data=eval("return $s;");
    $strings=$count=0;//GC
    return $data;
  } elseif (count($s)>1) {//存储字符串
    $strings[]=_unicode_unescape(
                 str_replace(array('$','\\/'),array('\\$','/'),$s[0]));
    return '#'.($count++).'#';
  } else {//读取存储的值
    $index=substr($s[0],1,strlen($s[0])-2);
    return $strings[$index];
  }
}

function _unicode_unescape($data) {
  if (is_string($data)) {
    //匹配 Unicode escape时,需要注意匹配'\u'前面只能有偶数个'\'
    //    '\\u5409'不应被匹配
    return preg_replace_callback(
           '/(?<!\\\\)((?:\\\\\\\\)*)\\\\u([a-f0-9]{2})([a-f0-9]{2})/i',
           __FUNCTION__,$data);
  }
  return $data[1].iconv("UCS-2BE","UTF-8",
                        chr(hexdec($data[2])).chr(hexdec($data[3])));
}

性能

和PHP 5.2之后C语言实现的JSON方法相比,这里的实现自然要慢很多,根本不是一个数量级上的。 PEAR上有一个纯PHP实现的JSON库:Services_JSON。作为比较,我做了一个简单的性能对比测试,结果发现Services_JSON的encode方法比json_stringify方法慢三四倍,而其decode方法更是比json_parse方法慢几十倍。 对于json_stringify方法,应该是主要得益于使用iconv先将UTF-8字符串转换成UCS-2BE,再转换到Unicode转义序列,自然要比纯PHP实现UTF-8到Unicode转义序列的性能更高。对于json_parse方法,虽然一般的递归下降Parser只需扫描一次,这里的实现要扫描多次,但由于都是使用Native函数,再加上eval这个非常规手段,最终性能上反而胜出也就可以理解了。