如何过滤(或替换)在UTF-8中占用超过3个字节的Unicode字符?

44 投票
7 回答
34620 浏览
提问于 2025-04-16 01:05

我正在使用Python和Django,但遇到了一个问题,这个问题是由于MySQL的限制造成的。根据MySQL 5.1的文档,他们的utf8实现不支持4字节的字符。而MySQL 5.5将支持使用utf8mb4的4字节字符;未来某天,utf8也可能会支持。

但是我的服务器还没有准备好升级到MySQL 5.5,因此我只能使用最多3字节的UTF-8字符。

我的问题是:如何过滤(或替换)那些会占用超过3字节的unicode字符?

我想把所有4字节的字符替换成官方的\ufffdU+FFFD 替换字符),或者用?替代。

换句话说,我想要的行为和Python的str.encode()方法(传入'replace'参数时)非常相似。编辑:我想要的行为类似于encode(),但我不想实际对字符串进行编码。我希望在过滤后仍然保留unicode字符串。

我不想在存储到MySQL之前对字符进行转义,因为那样的话,我就需要对从数据库中获取的所有字符串进行反转义,这样非常麻烦且不可行。

另请参见:

[编辑] 添加了关于提议解决方案的测试

到目前为止,我得到了不错的答案。谢谢大家!现在,为了选择其中一个,我进行了快速测试,以找到最简单和最快的方案。

#!/usr/bin/env python
# -*- coding: utf-8 -*-
# vi:ts=4 sw=4 et

import cProfile
import random
import re

# How many times to repeat each filtering
repeat_count = 256

# Percentage of "normal" chars, when compared to "large" unicode chars
normal_chars = 90

# Total number of characters in this string
string_size = 8 * 1024

# Generating a random testing string
test_string = u''.join(
        unichr(random.randrange(32,
            0x10ffff if random.randrange(100) > normal_chars else 0x0fff
        )) for i in xrange(string_size) )

# RegEx to find invalid characters
re_pattern = re.compile(u'[^\u0000-\uD7FF\uE000-\uFFFF]', re.UNICODE)

def filter_using_re(unicode_string):
    return re_pattern.sub(u'\uFFFD', unicode_string)

def filter_using_python(unicode_string):
    return u''.join(
        uc if uc < u'\ud800' or u'\ue000' <= uc <= u'\uffff' else u'\ufffd'
        for uc in unicode_string
    )

def repeat_test(func, unicode_string):
    for i in xrange(repeat_count):
        tmp = func(unicode_string)

print '='*10 + ' filter_using_re() ' + '='*10
cProfile.run('repeat_test(filter_using_re, test_string)')
print '='*10 + ' filter_using_python() ' + '='*10
cProfile.run('repeat_test(filter_using_python, test_string)')

#print test_string.encode('utf8')
#print filter_using_re(test_string).encode('utf8')
#print filter_using_python(test_string).encode('utf8')

结果:

  • filter_using_re()0.139 CPU秒内进行了515次函数调用(在sub()内置函数中为0.138 CPU秒)
  • filter_using_python()进行了2097923次函数调用,耗时3.413 CPU秒(在join()调用中为1.511 CPU秒,评估生成器表达式时为1.900 CPU秒)
  • 我没有测试itertools,因为...嗯...那个解决方案虽然有趣,但相对较大且复杂。

结论

正则表达式解决方案是迄今为止最快的。

7 个回答

1

根据MySQL 5.1 的文档,"ucs2 和 utf8 字符集不支持位于基本多语言平面(BMP)之外的补充字符。"这意味着可能会出现与代理对(surrogate pairs)相关的问题。

需要注意的是,Unicode 标准 5.2 第三章实际上禁止将一个代理对编码为两个 3 字节的 UTF-8 序列,而应该是一个 4 字节的 UTF-8 序列……例如在第93页提到:“因为代理代码点不是 Unicode 标量值,所以任何本应映射到代码点 D800..DFFF 的 UTF-8 字节序列都是不合法的。”不过,至今为止,这个规定似乎大多数人都不知道或者忽视了。

检查 MySQL 如何处理代理对可能是个好主意。如果不保留这些代理对,这段代码可以提供一个简单的检查:

all(uc < u'\ud800' or u'\ue000' <= uc <= u'\uffff' for uc in unicode_string)

而这段代码会将任何“坏东西”替换为 u\ufffd

u''.join(
    uc if uc < u'\ud800' or u'\ue000' <= uc <= u'\uffff' else u'\ufffd'
    for uc in unicode_string
    )
6

你可以跳过解码和编码的步骤,直接查看每个字符的第一个字节(8位字符串)的值。根据UTF-8的规则:

#1-byte characters have the following format: 0xxxxxxx
#2-byte characters have the following format: 110xxxxx 10xxxxxx
#3-byte characters have the following format: 1110xxxx 10xxxxxx 10xxxxxx
#4-byte characters have the following format: 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

根据这个规则,你只需要检查每个字符的第一个字节的值,就能筛选出4字节的字符:

def filter_4byte_chars(s):
    i = 0
    j = len(s)
    # you need to convert
    # the immutable string
    # to a mutable list first
    s = list(s)
    while i < j:
        # get the value of this byte
        k = ord(s[i])
        # this is a 1-byte character, skip to the next byte
        if k <= 127:
            i += 1
        # this is a 2-byte character, skip ahead by 2 bytes
        elif k < 224:
            i += 2
        # this is a 3-byte character, skip ahead by 3 bytes
        elif k < 240:
            i += 3
        # this is a 4-byte character, remove it and update
        # the length of the string we need to check
        else:
            s[i:i+4] = []
            j -= 4
    return ''.join(s)

跳过解码和编码的部分可以节省一些时间,对于那些大部分是1字节字符的小字符串来说,这样做甚至可能比用正则表达式过滤还要快。

40

在UTF8编码中,Unicode字符的范围是从\u0000到\uD7FF和从\uE000到\uFFFF,这些字符会用3个字节(或者更少)来表示。而\uD800到\uDFFF这个范围是用来表示多字节的UTF16编码。我不太懂Python,但你应该可以设置一个正则表达式来匹配那些不在这些范围内的字符。

pattern = re.compile("[\uD800-\uDFFF].", re.UNICODE)
pattern = re.compile("[^\u0000-\uFFFF]", re.UNICODE)

编辑:在问题正文中添加Denilson Sá的Python脚本:

re_pattern = re.compile(u'[^\u0000-\uD7FF\uE000-\uFFFF]', re.UNICODE)
filtered_string = re_pattern.sub(u'\uFFFD', unicode_string)    

撰写回答