前言
我们废话不多说,今天主要和大家分享下我昨天修复的关于 chatGPT 流数据处理的一个bug,这个 bug 其实是一个偶现的 bug,因此一开始我就没管,(又不是不能用对吧?),但是后面发现这个 bug 出现的频率还比较高,所以还是决定解决一下,下面是解决过程。
复现 bug
下面是这个 bug 的截图。首先做下知识铺垫,使用 SseEmitter 给浏览器发送消息时,浏览器拿到的数据是会带着 data: 你发的内容,比如你发送的内容是 'hello',那么浏览器收到的消息就是 'data:hello'.
因为为了实现打字机的效果,后端是一个字一个字返回的,前端也是一个字一个字处理,下面是具体的代码。
后端逐字发送代码如下:
java
private boolean printMessage(String reply) {
try {
char[] messages = reply.toCharArray();
for (char message : messages) {
TimeUnit.MILLISECONDS.sleep(20);
if (!sendData2Client(String.valueOf(message))){
return false;
}
}
return true;
} catch (InterruptedException e) {
log.error("ResponseBody读取错误");
e.printStackTrace();
return false;
}
}
具体使用 SseEmitter 的发送代码如下,这个代码后面有用,会细讲,大家先混个眼熟。
java
private boolean sendData2Client(String data) {
try {
String text = "{" + data + "}";
emitter.send(SseEmitter.event().data(text));
return true;
} catch (IOException e) {
log.error("向客户端发送消息时出现异常");
e.printStackTrace();
}
return false;
}
前端逐字处理代码:
js
export async function requestChatStream(
req: ChatRequest,
options?: {
onMessage: (message: string, done: boolean) => void;
onError: (error: Error, statusCode?: number) => void;
}
) {
try {
const res = await fetch(`${baseURL}/chat/${req.type}/legacy`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
userId: `${Cookies.get('openbytecode-chat-id')}`,
}),
});
let responseText = '';
if (res.ok) {
const reader = res.body?.getReader();
const decoder = new TextDecoder();
while (true) {
const content = await reader?.read();
if (!content || !content.value) {
break;
}
const text = decoder.decode(content.value, { stream: true });
console.log('sssssssssssssss', text);
const value = extractData(text);
responseText += value;
const done = content.done;
options?.onMessage(responseText, false);
if (done) {
break;
}
}
} else if (res.status === 401) {
console.error('Unauthorized');
options?.onError(new Error('Unauthorized'), res.status);
} else {
console.error('Stream Error', res.body);
options?.onError(new Error('Stream Error'), res.status);
}
} catch (err) {
console.error('NetWork Error', err);
options?.onError(err as Error);
}
}
下面是抽取 data 的代码,对应后端的 String text = "{" + data + "}";,至于为什么后端要在返回数据中加入 {},现在你只需要记住这个 {} 有大用处,我们一会再说。
js
/**
* 抽取 data
* @param content
*/
export const extractData = (content: string) => {
let index1 = content.indexOf('{');
let index2 = content.lastIndexOf('}');
return content.substring(index1 + 1, index2);
};
所以接下来就是复现这个的 bug,通过日志来找出问题。下面是通过打印日志得到的截图。
分析 bug
根据我们输出的日志,可以明显发现,decoder.decode(content.value, { stream: true }) 这玩意得到的内容并不是我们想的那样每次解析只有一个字,而是有时会出现多个字一起返回,这不就是出现了粘包嘛,由于后端数据的粘包,导致前端在处理时只截取了开头和结尾的 {}, 所以就出现了这个 bug,好了,问题找到了,下面我们就考虑如何解决。
解决 bug
其实,这个是后端粘包的问题,但是,我要说但是了,我们前端也能处理(因为后端还不知道该如何处理,头大),前端处理其实也比较简单,因为我们知道后端有时会出现粘包,那我们就将解码之后将内容拆成数组,代码如下。
js
export async function requestChatStream(
req: ChatRequest,
options?: {
onMessage: (message: string, done: boolean) => void;
onError: (error: Error, statusCode?: number) => void;
}
) {
try {
const res = await fetch(`${baseURL}/chat/${req.type}/legacy`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
userId: `${Cookies.get('openbytecode-chat-id')}`,
}),
});
let responseText = '';
if (res.ok) {
const reader = res.body?.getReader();
const decoder = new TextDecoder();
while (true) {
const content = await reader?.read();
if (!content || !content.value) {
break;
}
const text = decoder.decode(content.value, { stream: true });
const values = text.split('data:').filter((v) => v);
for (let i = 0; i < values.length; i++) {
const data = extractData(values[i]);
responseText += data;
}
const done = content.done;
options?.onMessage(responseText, false);
if (done) {
break;
}
}
} else if (res.status === 401) {
console.error('Unauthorized');
options?.onError(new Error('Unauthorized'), res.status);
} else {
console.error('Stream Error', res.body);
options?.onError(new Error('Stream Error'), res.status);
}
} catch (err) {
console.error('NetWork Error', err);
options?.onError(err as Error);
}
}
好了,最后来说下为什么要加 {},其实就是为了方便处理返回数据的空格与换行问题,作用很大,如果仅仅是将数据处理拆成数据,那么最后你可能看到就是一竖排内容。最后再说下为什么不直接使用 fetch-event-source,因为通过查看 fetch-event-source 的源码,他底层使用的也是 fetch,索性我们也想到了解决办法,所以就先不换了,还有人可能会问,为什么不用 EventSource,因为 EventSource 不支持 post 请求😂。
最后
如果喜欢我的文章,欢迎关注我,同时也欢迎关注我的个人网站,里面有多个我一直迭代的项目, 还有我的 b 站,我会定期在 b 站分享一些你意想不到的知识。