背景
问题的起因是一位同学反馈在查看日志的时候会莫名奇妙出现乱码的情况,而且出现得乱码很规律都是同一个符号。下面就是一种情况的截图,可以看到 socket 接收到的消息这里就自动转成了乱码,而且经过多个帧的分析发现出现问题的帧基本都处于每一帧的开头位置或者是结尾(理论上是会有两帧成对出现),其他位置未发现错误。(因为没有特别大的文本,就在网上找了个小说,别在意这个细节了,-_-||)
问题分析
排除转码的问题
首先应该不会考虑是转码的问题了,因为之前转码是在 server 端做的,第一时间就想过是不是转码的原因,但是后来转码功能放到了 renderer 端做之后仍然有问题,就不考虑是转码这个原因了。并且因为乱码只发生在某些帧的开头位置,那么和转码就关系不大了。
排除 node-pty 依赖的问题
因为 socket 的消息是直接转发的经由 node-pty 创建伪终端,那么出现乱码的情况下和 node-pty 的关系还是有点大的。但是经过对比多个使用 node-pty 的应用发现,部分应用是没有问题,但也有部分是有问题的。这个结果说明一个问题,node-pty 发送的消息确实是有问题的但是没有问题的应用应该是单独处理过了。
汉字导致的问题
既然知道问题的原因是 node-pty 导致的,那么就可以对比一下为什么会有问题,并且尝试看看有没有解决的方案?
首先发现出现问题的基本都是汉字,英文和字符没有受到影响,但是为什么汉字会有影响,仔细想想也差不多明白了。中间验证的过程其实也是费点时间的,主要是会把 socket 消息转换成 字符串做对比。
问题的原因:
之前分享过一片文章:编码的发展史,这里就有关于 utf8 编码规范下的汉字的表示方法,一个汉字需要 3 个字节来表示,这样在 node-pty 返回消息的时候,如果消息发生了截断,那么很有可能一个完整的汉字被分到前后两帧里去。这样在转码的时候就会发生错误,从而造成乱码。
当然了不仅仅是汉字这样会导致乱码,其他的例如表情符号也会造成乱码,其实只要是一个内容需要两个字节来展示的话,基本都会有这个问题发生的可能性。
解决问题
第一种解决方案
既然知道了原因是因为截断导致的问题,那么我们就想办法不让这个内容出现截断,大致的思路就是在发送消息帧的时候,如果检测到当前帧最后一个内容表示出现了截断,那么我们就保留这一帧到缓冲区里,等待下一帧的判断结果,一直到判断出没有问题的帧后一并发送。或者判断帧有问题的时候截断有问题的那个汉字放置到下一帧去?
具体的代码逻辑:
term.onData((data) => {
// 判断是否是完备中文
if (data.length >= 2) {
const lastTwoBytes = data.slice(-2);
const lastBytes = lastTwoBytes[1];
const secondLastByte = lastTwoBytes[0];
// console.log(lastBytes, secondLastByte);
// console.log(byteType(lastBytes), byteType(secondLastByte));
// 如果最后一个字节是中文的第一个字节,或者倒数第二个字节是中文的第一个字节,那么就认为这个被截断了,放到 buffer 中去
if (byteType(lastBytes) === 'mbLeadByte' || byteType(secondLastByte) === 'mbLeadByte') {
// console.log('截断');
buffer = Buffer.concat([buffer, data]);
return;
}
}
buffer = Buffer.concat([buffer, data]);
ws.send(Buffer.from(buffer));
// 清空缓冲区
buffer = Buffer.alloc(0);
});
但是上面这个逻辑其实也不算是特别好,一种 case 就是如果不断出现截断帧的话,那么 buffer 的内容就会一直扩大同时也不会向 socket 发送消息帧,这就会出现一定的肉眼可见的延迟,效果体验上不会太好。
第二种解决方案
第二种方案采取的是截取的操作,也就是如果最后一个字节是汉字的第一个字节的话就截取最后一个字节放到下一帧。同理,如果倒数第二个字节符合汉字的第一个字节那就截取后两位放到下一帧。
let buffer = Buffer.alloc(0); // 初始化一个空的 Buffer
term.onData((data) => {
if (buffer.length > 0) {
data = Buffer.concat([buffer, data]);
buffer = Buffer.alloc(0);
}
if (data.length >= 2) {
const lastTwoBytes = data.slice(-2);
const lastBytes = lastTwoBytes[1];
const secondLastByte = lastTwoBytes[0];
if (byteType(lastBytes) === 'mbLeadByte') {
// 最后一个字节是中文的第一个字节
buffer = data.slice(-1);
data = data.slice(0, -1);
} else if (byteType(secondLastByte) === 'mbLeadByte') {
// 倒数第二个字节是中文的第一个字节
buffer = data.slice(-2);
data = data.slice(0, -2);
}
}
ws.send(data);
});
判断是否是汉字的第一字节?
三字节汉字规律: 1110xxxx 10xxxxxx 10xxxxxx
function byteType(byte) {
// 三字节汉字规律: 1110xxxx 10xxxxxx 10xxxxxx
if (byte >= 0xe0 && byte <= 0xef) {
/**
* 汉字的第一个字节
* e0 11100000
* ef 11101111
*/
return 'mbLeadByte';
} else if (byte >= 0x80 && byte <= 0xfe) {
/**
* 汉字的第二个字节
* 80 10000000
* bf 10111111
*/
return 'mbTrialByte';
} else {
return 'mbSingleByte'; // 单字节(数字或字符)
}
}
为什么最多只判断到倒数第二个字节?
因为一个汉字是由三个字节组成的,那么如果倒数第三个字节符合汉字的第一个字节,那么说明这一帧就是正常的也无需做任何操作。
是否能够 cover 住所有的汉字?
并不能,一些特殊的汉字是由四个字节组成的,那么就必须要判断倒数第三个字节了,但是看了下这些四字节汉字都是一些生僻字,那么我们暂时先不做这个检测。