utf8存储emoji处理
# 背景
在 to C 业务中经常会和 emoji 打交道。例如发帖场景、答题场景、搜索场景等等,总有小可爱喜欢输入一些 emoji 表情体现个性 🤣 。
emoji 通常每字符占用 4 个字节, Mysql 的 utf8 最大支持每字符 3 字节,因此会出现不兼容的问题。除此之外,还有一些生僻字也有类似问题。
注:(新版的 Mysql utf8 默认指 utf8mb4, 文中的 utf8 均指旧版的 Mysql utf8,即 utf8mb3)
常规解决方案是,在建表时采用 utf8mb4 存储,这样就不存在兼容问题。
但是存在一些历史问题,表建立时采用的是 utf8 ,改编码又会引起业务出错。以至于不得不采用一些方法对 emoji 进行兼容处理。
兼容处理方案,一般有以下几种:
- 通过正则匹配 emoji 将其过滤。
- 将文本内容 encodeURIComponent 编码,encodeURIComponent 一般不单独使用,毕竟文本整体转码以后,丧失可读性,搜索匹配也都做不到了。
- 通过字节长度,将不兼容的字符过滤,仅理论上可行,但实操有不兼容的风险,具体原因看 原理。
# 匹配 emoji
# 解决方案
网上有很多正则匹配 emoji 的方案,但大多都是通过指定 Unicode 字符集范围实现的,我也通过调教 AI 获得了一个示例,不过实验效果很差,最后我找到另一种比较优雅的方式实现。
结论:
/(\p{So}\p{Emoji_Modifier}?)/gu
但其有个缺点,对于部分数学符号也会被匹配到,主要是 ⌒ (圆弧)、△ (三角形)。
# 测试数据
可能你也有更好的方式匹配 emoji, 下面我提供了一些示例数据,帮助你测试。
// const str = '😄笑脸❤️红心,⌨键盘⌚手表🏳️🌈彩虹旗👨👩👧👦家庭™TM⬇️箭头☝🏽肤色符号 🍇葡萄🤘😀🫡🏳️🙌🏴🛜⛓💥🛞'; // emoji 混合文本, emoji 都应该被匹配。
// const str = '~`!@#$%^&*()_+-=[]{}\\|\'\'"";:?/>.<,。,?、『』:;「」《》【】'; // 特殊符号,应该被保留
const str = '∪∩∈∉⊆⊇⊂⊃∅∧∨¬⇒⇔=≠<>≤≥≈≡+-×÷√∛∑∏∫∂∆∞∠⊥‖⌒⊙△αβγδεζηθικλμνξοπρστυφχψω≮≯∷/∮∝∵∴()【】{}ⅠⅡ⊕∥Δ'; // 数学符号,应该被保留
console.log(str); // 源数据
console.log(str.replace(/(\p{So}\p{Emoji_Modifier}?)/gu, ' ')); // 匹配后替换为空格便于对比
2
3
4
5
6
7
8
9
# 原理
Regexp: /(\p{So}\p{Emoji_Modifier}?)/gu
其中 g 代表全局, u 代表 Unicode。
而 \p{XXX} 这个语法是通过 Unicode 字符类别进行匹配的,例如 \p{N} 匹配 Number 类别。
\p{So}, 匹配的类别是 Symbol(符号类别)里的 o(other)类。So 里面包含了大部分 emoji 。
对于一些特殊的 emoji 如 ☝🏽 其是 ☝ + 🏽 (手指 + 肤色)构成的,\p{So} 只能匹配单 Unicode 字符的 emoji,即只能匹配到 ☝ ,所以引入了后半段。
\p{Emoji_Modifier}, 匹配的类别是 Emoji_Modifier(表情修饰符类别)。Emoji_Modifier 里包含了 emoji 的修饰符,如 🏻 🏼 🏽 🏾 🏿。
# 有趣的发现
通过上面的示例,我们大概知道 emoji 占用超过 3 个字节,导致 utf8 无法正常存储。还有一部分 emoji 是通过组合得来的,如 ☝🏽。
这时候我们进行一个提问 😄、☝🏽、👨👩👧👦、🏳️🌈 分别长度是多少?
你可以想一想,或者用你常用的语言试试。看看结果和我的是否一致。
有结论了吗? 首先请允许我向你道歉,我没有明确『长度』的概念,究竟是字符长度还是字节长度。
(如果你对于这个概念不太熟悉,可以看我另一篇文章 VARCHAR的长度怎么计算? VARCHAR(255)与VARCAHR(256)的区别在哪里? (opens new window))
好了,现在我们通过两种方式,去分析问题的答案。
- Mysql
- Node.js
# Mysql 获取 emoji 长度
SELECT length('你'), char_length('你');
-- 3 1
SELECT length('😄'), char_length('😄');
-- 4 1
-- inset into 😄 -> ?
SELECT length('☝🏽'), char_length('☝🏽');
-- 7 2
-- inset into ☝🏽 -> ☝?
SELECT length('👨👩👧👦'), char_length('👨👩👧👦');
-- 25 7
-- inset into 👨👩👧👦 -> ????
SELECT length('🏳️🌈'), char_length('🏳️🌈');
-- 14 4
-- inset into 🏳️🌈 -> ??
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
这里借用了 Mysql 的两个内置函数 length() 和 char_length(),分别获取字节长度和字符长度。
😄 占用了 1 个字节,且 1 个字节需要用 4 个字符标识,所以 Mysql utf8 不支持,当尝试插入时,会变成 ? 。
☝🏽、👨👩👧👦、🏳️🌈 都占用了多个字节,其中比较特别的是 ☝🏽 它居然兼容了一半...
# Node.js 获取 emoji 长度
JavaScript 中因历史原因的问题,length 本身存在一些问题。
例如: 😄
.length // 2
大家可以直接打开浏览器的控制台体验一下,这里不多赘述。
所以这里我们通过 Node 的 Buffer 对象将字符串转为 utf8 编码后再看效果。
测试代码如下,大家可以自己体验效果:
const str = '☝🏽';
console.log(str);
console.log('length: ', str.length);
console.log('buffer: ', Buffer.from(str, 'utf-8').length);
console.log('for i :');
for (let i = 0; i < str.length; i++) {
console.log(`i: ${i}, char: ${str[i]}, length: ${str[i].length}, buffer: ${Buffer.from(str[i], 'utf-8').length}`);
}
console.log('for of :');
let i = 0;
for (const el of str) {
console.log(`i: ${i++}, char: ${el}, length: ${el.length}, buffer: ${Buffer.from(el, 'utf-8').length}`);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
在示例中,我用了 for i
和 for of
两种遍历方式,因为 length 的问题,导致 for i
的结果看起来很鬼畜,但好在 for of
是正常的。
😄
for of :
i: 0, char: 😄, length: 2, buffer: 4
2
3
☝🏽
for of :
i: 0, char: ☝, length: 1, buffer: 3
i: 1, char: 🏽, length: 2, buffer: 4
2
3
4
👨👩👧👦
for of :
i: 0, char: 👨, length: 2, buffer: 4
i: 1, char: , length: 1, buffer: 3
i: 2, char: 👩, length: 2, buffer: 4
i: 3, char: , length: 1, buffer: 3
i: 4, char: 👧, length: 2, buffer: 4
i: 5, char: , length: 1, buffer: 3
i: 6, char: 👦, length: 2, buffer: 4
2
3
4
5
6
7
8
9
🏳️🌈
for of :
i: 0, char: 🏳, length: 2, buffer: 4
i: 1, char: ️, length: 1, buffer: 3
i: 2, char: , length: 1, buffer: 3
i: 3, char: 🌈, length: 2, buffer: 4
2
3
4
5
6
从输出结果中我们可以得出结论, for of
的循环次数等同于 emoji 的字符长度,而 Buffer.form().length 也可以很好的反应出,每个字符的字节长度。
同时,有部分占用 3 字节的字符,utf8 也是不支持的,而且我们看到的一个 emoji,可能对应多个字符。因此通过字节长度过滤不兼容的 emoji 不太靠谱。