四种编码
1. 网页文件存储时使用的编码,比如我们使用vim作为编码时,可以通过设置fileencoding属性来设置存储的文件使用的编码,set filetype=utf-8,即将文件保存为UTF-8编码。
2. meta标签当中设置的编码,有http-equiv=”Content-Type”content=”text/html; charset=utf-8”这样的属性,则表示设置当前页的编码为utf-8。
3. 浏览器查看时使用的编码,即浏览器当中的查看=>为编码选项当中选中的编码。
4. 浏览器端提交用户数据时使用的编码。
原理
1. 网页文件存储编码,是网页最为重要的编码。如果网页文件为静态的HTML文件,则Web Server将直接发送该文件至客户端的浏览器;如果网页文件为动态生成的HTML文件,则Web Server会根据动态脚本文件存储的编码来生成相应编码的数据,而这些数据将成为发送到Client Browser的HTML文件。
例如在一个以gbk编码存放的PHP脚本当中,使用echo ‘我爱你’,则会产生数据CE D2 B0 AE C4 E3六个字节的数据,这六个字节的数据是‘我爱你’的GBK编码,而如果在一个以utf-8编码存放的PHP脚本当中,执行echo ‘我爱你’,则会产生数据E6 88 91 E7 88 B1 E4 BD A0九个字节的数据,这九个字节的数据是‘我爱你’的UTF-8编码。
2. HTML 4.01 Specification当中说明:在META标签的Content-Type值当中使用的charset用于表明当前是传送的HTML文档的编码,并且说明一个conforming的browser正确的处理这个属性。但是实际上的情况是,大部分的browser并不会把这个属性当会事,经测试Firefox 10, Chrome 17并不会follow这个属性,所以会出现一个明明是UTF-8编码的HTML文件,并且在meta charset当中设计成为了UTF-8,但是还是会出现乱码的原因。现在(2012-4-2)作出的测试(本地,使用FILE或者HTTP协议打开)发会现browser(IE 9.0+FF11+Chrome17)会按照这个属性来解析HTML文件。
3. 浏览器查看编码是浏览器将Web Server传输过来的数据解码来使用的编码。出来乱码的原因即在于此,如果一个HTML发过来的是GBK编码的,而Web Browser使用UTF-8去解码这个文件,那么如果该文件当中含有中文等字符,则会产生乱码。
4. 经过测试发现,浏览器端提交用户数据时使用的编码,只取决于当前浏览器查看网页使用的编码,与HTML网页本身的文件的编码没有任何关系。
总结
服务端传输过来的HTML文件的编码主要由服务端HTML文件或者脚本文件的存储编码决定,浏览器端传输的数据的编码只由浏览器端的查看编码决定。
另外,HTTP包头当中的Content-Type属性当中的charset也可以表明服务端传输的数据的编码,但是一般的Web Server并不会发送这个charset属性。
最后,一般的现代的浏览器都具备一个编码自动检查功能,浏览器会根据当前收到的数据来检查编码。
应用
理解为什么php的move_uploaded_file有时候不支持中文文件名?
问题再现
如果存在这样一个图片上传的后台处理模块,
<?php
//这里假设客户端是以UTF-8编码发送的数据,即查看上传文件网页时,使用的是UTF-8
$old_name = $_FILES['file']['name'];
$new_name = 'e:\web\\' . $old_name;
;move_uploaded_file($_FILES['file']['tmp_name'], $new_name);
move_uploaded_file($_FILES['file']['tmp_name'], 'e:\web\study\哈哈.jpg');
?>
如果该模块(upload.php)使用UTF-8作为文件存储编码,那么代码中的两种上传文件的方式都应该避免,最好是能够随机生成上传后的新文件名,并且新生成的文件名最好不包括中文。
因为:
第一,一般的Web Server(如httpd)并不能够很好的处理包括中文的路径名处理,也就是说其实apache httpd对于上传上来的utf-8编码的文件名并不能够作出正确的处理,其结果是$_FILES['file']['name']会出现乱码。这样,首先旧文件名就是错的。
其次,php的move_uploaded_file并不能够根据当前的文件存储编码来处理文件路径。move_uploaded_file会把转入的参数作为当前locale编码来处理(即GBK),这样的结果就是再次出现乱码。
例如 'e:\web\study\哈哈.jpg'本身作为UTF-8的编码是
65 3a 5c 77 65 62 5c 73 74 75 64 79 5c e5 93 88 e5 93 88 2e 6a 70 67
而move_uploaded_file会将其作为GBK编码来看待,其结果就是"e:\web\study\鍝堝搱.jpg",这样的结果,因为e5 93是'鍝',接下来'堝'是88 e5,最后'搱'是93 88,也就是说'哈哈'的六个UTF-8字节,被解释为三个GBK编码的汉字。其结果就是上传的文件变成了"e:\web\study\鍝堝搱.jpg",而不是原本的哈哈.jpg。更有时候,会出现UTF-8编码的字不能够被解释为GBK时,系统会默认的添加一个?号作为默认字符,其结果就是上传失败。
所以,如果你一定想使用中文文件名,那么在UTF-8编码存储的PHP文件当中,一定要先使用iconv将utf-8编码的路径转换为当前locale(gbk)编码,然后再调用move_uploaded_file。
深入探索
那么为什么move_uploaded_file会将utf-8编码的文件路径,认为是gbk编码的呢?这是不是PHP的一个bug呢?
我们来实际看一下php的源代码,
/* {{{ proto bool move_uploaded_file(string path, string new_path)
Move a file if and only if it was created by an upload */
PHP_FUNCTION(move_uploaded_file)
{
char *path, *new_path;
int path_len, new_path_len;
zend_bool successful = 0;
#ifndef PHP_WIN32
int oldmask; int ret;
#endif
if (!SG(rfc1867_uploaded_files)) {
RETURN_FALSE;
}
if (zend_parse_parameters(ZEND_NUM_ARGS() TSRMLS_CC, "ss", &path, &path_len, &new_path, &new_path_len) == FAILURE) {
return;
}
if (!zend_hash_exists(SG(rfc1867_uploaded_files), path, path_len + 1)) {
RETURN_FALSE;
}
if (PG(safe_mode) && (!php_checkuid(new_path, NULL, CHECKUID_CHECK_FILE_AND_DIR))) {
RETURN_FALSE;
}
if (php_check_open_basedir(new_path TSRMLS_CC)) {
RETURN_FALSE;
}
if (strlen(path) != path_len) {
RETURN_FALSE;
}
if (strlen(new_path) != new_path_len) {
RETURN_FALSE;
}
VCWD_UNLINK(new_path);
if (VCWD_RENAME(path, new_path) == 0) {
successful = 1;
#ifndef PHP_WIN32
oldmask = umask(077);
umask(oldmask);
ret = VCWD_CHMOD(new_path, 0666 & ~oldmask);
if (ret == -1) {
php_error_docref(NULL TSRMLS_CC, E_WARNING, "%s", strerror(errno));
}
#endif
} else if (php_copy_file_ex(path, new_path, STREAM_DISABLE_OPEN_BASEDIR TSRMLS_CC) == SUCCESS) {
VCWD_UNLINK(path);
successful = 1;
}
if (successful) {
zend_hash_del(SG(rfc1867_uploaded_files), path, path_len + 1);
} else {
php_error_docref(NULL TSRMLS_CC, E_WARNING, "Unable to move '%s' to '%s'", path, new_path);
}
RETURN_BOOL(successful);
}
/* }}} */
可以看到做主要工作的是VCWD_RENAME,
#define VCWD_RENAME(oldname, newname) virtual_rename(oldname, newname TSRMLS_CC)
而virtual_name,
CWD_API int virtual_rename(char *oldname, char *newname TSRMLS_DC) /* {{{ */
{
cwd_state old_state;
cwd_state new_state;
int retval;
int cch, cb;
LPWSTR wstr;
LPSTR mbstr;
CWD_STATE_COPY(&old_state, &CWDG(cwd));
if (virtual_file_ex(&old_state, oldname, NULL, CWD_EXPAND)) {
CWD_STATE_FREE(&old_state);
return -1;
}
oldname = old_state.cwd;
CWD_STATE_COPY(&new_state, &CWDG(cwd));
if (virtual_file_ex(&new_state, newname, NULL, CWD_EXPAND)) {
CWD_STATE_FREE(&old_state);
CWD_STATE_FREE(&new_state);
return -1;
}
newname = new_state.cwd;
/* rename on windows will fail if newname already exists.
MoveFileEx has to be used */
#ifdef TSRM_WIN32
/* MoveFileEx returns 0 on failure, other way 'round for this function */
retval = (MoveFileEx(oldname, mbstr, MOVEFILE_REPLACE_EXISTING|MOVEFILE_COPY_ALLOWED) == 0) ? -1 : 0;
if (retval == -1) {
php_error_docref(NULL TSRMLS_CC, 2, "movefileex failed");
}
#else
retval = rename(oldname, newname);
#endif
CWD_STATE_FREE(&old_state);
CWD_STATE_FREE(&new_state);
return retval;
}
/* }}} */
可以看到最终move_uploaded_file是调用MoveFileEx来实现文件的上传,也就是将UTF-8编码的路径名当成GBK编码来处理是的MoveFileEx函数,那么为什么会出现这种结果呢?
因为PHP默认的所有Windows API的调用都是使用的ANSI版本,也就是说MoveFileEx即MoveFileExA,其参数自然就是GBK编码的字符串(最终系统通过MultiByteToWideChar来将GBK编码的字符串转换成为UTF-16 LE字符串,来调用MoveFileExW)。
所以,如果要在底层通过硬编码来解决这个问题,可以在MoveFileEx之前添加如下代码:
/* first convert utf-8 to utf16-le */
cch = MultiByteToWideChar(CP_UTF8, 0, (LPCSTR)newname, strlen(newname) + 1, NULL, 0);
wstr = (LPWSTR)malloc(cch * sizeof(wchar_t));
MultiByteToWideChar(CP_UTF8, 0, (LPCSTR)newname, strlen(newname) + 1, wstr, cch * sizeof(wchar_t));
/* then convert utf16-le to gbk */
cb = WideCharToMultiByte(CP_ACP, 0, wstr, cch, NULL, 0, NULL, NULL);
mbstr = (LPSTR)malloc(cb);
WideCharToMultiByte(CP_ACP, 0, wstr, cch, mbstr, cb, NULL, NULL);
free(wstr);
将UTF-8编码的字符串,转换为GBK。
从这里可以看出来,在PHP或者其他系统当中尽量不要使用中文作为文件名称(如果可以的话),因为中文编码的文件操作很容易出现兼容性问题。