PHP UTF8 BOM和session_start

PHP的老问题, utf8和BOM, 又让我给碰到了, 已经不记得是多久以前处理过与之有关的问题了.

首先是莫名其妙的session_start问题, 在代码里找了半天根本找不到哪里有内容在session start之前发送出去了.

第二个症状是只有在Linux操作系统上才能看的到, 如果你的服务器是Linux, 本地是Windows系统, 你在本地测试的时候观察不到这个错误.

第三个症状是观察不到究竟提前输出了什么, 从错误提示中一般可以找到session_start被调用的地方, 我们可以在这个调用之前加一句exit();, 如果我们真的打印了什么东西, 刷新的时候就可以看的到, 如果是BOM的问题, 则输出中没有任何可见字符.

用Emacs的hexl-mode查看文本文件, 如下图:

0xFEFF是一个16bit UNICODE code point, 这个数字没有用来表示字符, 而是用来表明字节序列. 这个数字在Big Endian系统中的存储格式是第一个字节0xFE, 第二个字节0xFF, 在Little Endian系统中是反过来的:

那么efbb bf又是什么? 这是0xFEFF这个16bit UNICODE code point经过UTF8编码之后的字节流. UNICODE是数字流, 16位或者32位, 我们可以把UNICODE 的类型看成 WORD [] 或 DWORD [], 而UTF8则是BYTE []. 而BYTE []是没有字节序问题的. 如果数据的基本单位包括2个或者以上的字节才会有字节序的问题. 在存储每个这样的数据单元的时候, 里面的字节以什么顺序存放, 如果每个单元只包含一个字节, 自然没有顺序的问题.

什么情况下文件会带有BOM

最大的BOM来源就是你在某个时候用Windows记事本编辑并保存了某个文件.

BOM和微软

下面我们就要讨论一下记事本或者记事本的发明者微软为什么要干这种事情. 首先要声明的一点是无论是否加上efbb bf来标记UTF8都是符合标准的, 微软并没有做错什么, 只是有些另类. 考虑到微软要面对那么多的消费者和兼容性问题, 这是完全可以理解的.

要知道对于一个ASCII文件来说, 无论是ANSI还是utf8编码, 他们的内容都是一样的, 如果用户在保存这个文件的时候以UTF8保存, 那么下一次再打开的时候, 记事本是把他当成ANSI呢还是UTF8呢, 如果有了BOM这个问题就解决了.

真正的问题在于当这个文件是XML或者HTML的时候, 这些文件不是用BOM来标记的而是直接用charset说明的. 浏览器在显示一个网页的时候, 是通过读取charset来决定用那个编码的, 而不是BOM.

BOM和PHP

从PHP解释器的角度看, 他没有编码的概念, PHP解释器只认ASCII, 其余一切都会当成binary, 在PHP解释器的眼里, 文件中的一切要么是ASCII, 要么就是无意义的字节. ASCII已经足够做语法分析和词法分析了, 其余怎么进来就怎么出去. 选择编码的问题留给浏览器来处理. 理论上不存在任何一种方法可以可靠的判断文件的编码, 只能靠各家浏览器各显神通.

所以PHP文件如果包含BOM, 那么这三个字节会被当成 , 或者任意被渲染器所认为的字符串. 而如果你把PHP保存为UNICODE, 则会被当成纯文本, 因为解释器连<?php这个标签都无法识别, 因为从ASCII的角度看<?php这5个字符每个相邻的位置都加了一个0x00.

去除BOM

如果是Emacs的话, 下面的命令可以解决问题.

 
M-x set-buffer-file-coding-system [enter]
utf-8 [enter]
 

PHP脚本

 
<?php
$auto = 1;//是否自动去除bom标记,1表示是
//去除utf-8文件的bom标记,如果有的话.
function checkBOM($filename)
{
  global $auto;
  $contents = file_get_contents($filename);
  $charset[1] = substr($contents,0,1);
  $charset[2] = substr($contents,1,1);
  $charset[3] = substr($contents,2,1);
 
  if(ord($charset[1]) == 239 && ord($charset[2]) == 187 && ord($charset[3]) == 191)
  {
    $rest = substr($contents,3);
    rewrite($filename,$rest);
    echo $filename."中bom标记存在,已删除\n";
  }
  else
  {
    echo $filename."中没有发现BOM标记,exit now\n";
  }
}
function rewrite($filename,$data)
{
  $filenum = fopen($filename,"w");
  flock($filenum,LOCK_EX);
  fwrite($filenum,$data);
  fclose($filenum);
}
$filename = $argv[1];
if (!file_exists($filename))
{
  echo $filename."不存在,exit now\n";
  return;
}
checkBOM($filename);
 
?>
 
 

Clojure实现

 
(defn remove-bom [path]    
  (with-open [in (clojure.java.io/input-stream (clojure.java.io/file path))
             ]
     (let [len  (int (.length (clojure.java.io/file path)))
           pushback-input-stream (java.io.PushbackInputStream. (java.io.BufferedInputStream. in) 3)
           bom-buf (byte-array 3)
           rest-buf (byte-array (- len 3)) ; only used when there is a bom
          ]
       (.read pushback-input-stream bom-buf)
       (if (and (= (byte (unchecked-byte 0xef)) (first bom-buf)) (= (byte (unchecked-byte 0xbb)) (second bom-buf)) (= (byte (unchecked-byte 0xbf)) (get bom-buf 2)) )
         (do (println "bom exist dont pushback")
             (.read pushback-input-stream rest-buf)
             (with-open [out (clojure.java.io/output-stream (clojure.java.io/file path))] (.write out rest-buf))
         )
         (do (println "bom dont exist and do nothing") )
       )
     )
  )
)