返回 multipart
1. 背景与问题
在一些“执行任务/生成内容”的接口里,返回值往往包含一个非常大的 JSON 字段(例如 json、output、std_out 等)。如果服务端统一把所有字段拼成一个超大的 JSON 字符串再返回,客户端通常要做两件事:
- 先把整个响应体一次性读完
- 再把整段 JSON 反序列化
当 JSON 特别大时,可能出现这些问题:
- 客户端读取 + 解析耗时过长,触发 readTimeout / callTimeout
- JSON 解析占用 CPU/内存明显升高(尤其移动端)
- 某些字段其实不一定每次都要用,但被迫整体解析
用 multipart 返回可以缓解: 把大字段拆成一个独立 part(甚至单独设置 Content-Type: application/json),其它小字段各自一个 part。客户端可以按需读取某些 part,或者更容易做“分段处理/延后解析”,整体体验更稳。
这里用的是
tio-boot的TioMultipartHttpResponder+TioMultipartParts,客户端用 OkHttp 获取 bytes,再用一个轻量解析器把 multipart 解析回ProcessResult。
2. 服务端:tio-boot 返回 multipart/form-data
2.1 Handler 结构
package com.litongjava.kit.handler;
import java.util.concurrent.locks.Lock;
import com.google.common.util.concurrent.Striped;
import com.litongjava.jfinal.aop.Aop;
import com.litongjava.kit.service.MotionCanvasCodeExecuteService;
import com.litongjava.kit.utils.ProcessResultMultipartMapper;
import com.litongjava.kit.vo.VideoCodeInput;
import com.litongjava.model.upload.UploadFile;
import com.litongjava.tio.boot.http.TioRequestContext;
import com.litongjava.tio.core.ChannelContext;
import com.litongjava.tio.http.common.HttpRequest;
import com.litongjava.tio.http.common.HttpResponse;
import com.litongjava.tio.http.multipart.TioMultipartHttpResponder;
import com.litongjava.tio.http.multipart.TioMultipartParts;
import com.litongjava.tio.http.server.handler.HttpRequestHandler;
import com.litongjava.tio.http.server.util.CORSUtils;
import com.litongjava.tio.utils.commandline.ProcessResult;
import com.litongjava.tio.utils.snowflake.SnowflakeIdUtils;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class DemoMultiPartHandler implements HttpRequestHandler {
private MotionCanvasCodeExecuteService srv = Aop.get(MotionCanvasCodeExecuteService.class);
private static final Striped<Lock> locks = Striped.lock(1024);
private final TioMultipartHttpResponder multipartResponder = new TioMultipartHttpResponder();
private final ProcessResultMultipartMapper mapper = new ProcessResultMultipartMapper();
@Override
public HttpResponse handle(HttpRequest request) throws Exception {
HttpResponse response = TioRequestContext.getResponse();
CORSUtils.enableCORS(response);
ChannelContext channelContext = request.getChannelContext();
String sessionIdStr = request.getParam("session_id");
String code_id = request.getParam("code_id");
String code_name = request.getParam("code_name");
String code_timeout = request.getParam("code_timeout");
Integer timeout = (code_timeout != null) ? Integer.valueOf(code_timeout) : 590;
Long sessionId = (sessionIdStr != null) ? Long.valueOf(sessionIdStr) : SnowflakeIdUtils.id();
Long id = (code_id != null) ? Long.valueOf(code_id) : SnowflakeIdUtils.id();
if (code_name == null) code_name = "Scene01";
log.info("session_id:{},code_id={},code_name={},code_timeout={}", sessionId, code_id, code_name, code_timeout);
String code;
UploadFile uploadFile = request.getUploadFile("code");
if (uploadFile != null) {
code = new String(uploadFile.getData());
} else {
code = request.getBodyString();
}
if (code == null) {
response.fail("code can not be empty");
return response;
}
VideoCodeInput input = new VideoCodeInput(sessionId, id, code_name, code, timeout);
Lock lock = locks.get(sessionId);
lock.lock();
try {
ProcessResult result = srv.executeCode(input, channelContext);
if (result != null) {
TioMultipartParts parts = mapper.toParts(result);
multipartResponder.respondFormData(response, parts);
}
} catch (Exception e) {
log.error(e.getMessage(), e);
response.setStatus(500);
response.body(e.getMessage());
} finally {
lock.unlock();
}
return response;
}
}
示例 DemoMultiPartHandler 逻辑核心是:
- 获取参数(
session_id / code_id / code_name / code_timeout) - 读取代码(上传文件
code或 body) - 执行:
srv.executeCode(input, channelContext) - 将
ProcessResult映射为 multipart parts:mapper.toParts(result) - 写入 multipart 响应:
multipartResponder.respondFormData(response, parts)
关键代码:
ProcessResult result = srv.executeCode(input, channelContext);
if (result != null) {
TioMultipartParts parts = mapper.toParts(result);
multipartResponder.respondFormData(response, parts);
}
2.2 ProcessResult -> Multipart 的映射策略
package com.litongjava.kit.utils;
import com.litongjava.tio.http.multipart.TioMultipartParts;
import com.litongjava.tio.utils.commandline.ProcessResult;
public class ProcessResultMultipartMapper {
public TioMultipartParts toParts(ProcessResult r) {
TioMultipartParts p = TioMultipartParts.create();
// 必填
p.addText("exit_code", String.valueOf(r.getExitCode()));
// 可选
p.addText("std_out", r.getStdOut());
p.addText("std_err", r.getStdErr());
p.addObject("elapsed", r.getElapsed());
p.addObject("cached", r.getCached());
p.addObject("prt", r.getPrt());
p.addObject("session_id", r.getSessionId());
p.addObject("task_id", r.getTaskId());
p.addText("sources", r.getSources());
p.addText("execute_code", r.getExecuteCode());
p.addText("output", r.getOutput());
p.addJson("json", r.getJson());
p.addText("message", r.getMessage());
p.addText("text", r.getText());
p.addText("subtitle", r.getSubtitle());
p.addText("image", r.getImage());
p.addText("audio", r.getAudio());
p.addText("video", r.getVideo());
p.addText("hls_url", r.getHlsUrl());
p.addText("ppt", r.getPpt());
p.addObject("video_length", r.getVideo_length());
return p;
}
}
示例 ProcessResultMultipartMapper 做了一个很实用的策略:
- 必填:
exit_code - 文本字段:用
addText - 数值/布尔:用
addObject(最终仍会转成文本 part,但表达更清晰) - 大 JSON 字段:单独 part,并声明内容类型
例如:
p.addJson("json", r.getJson());
这样客户端可以明确知道 json 这一段是 JSON(即使它非常大),并且不会影响其它字段的读取与解析。
2.3 推荐的字段拆分原则
实践建议:
- 小字段都放 multipart:
exit_code / elapsed / cached / session_id / task_id / message... - 可能很大的字段单独 part:
json / output / std_out / std_err - 二进制资源(如果未来需要):建议用
addBinary(...)或把资源地址(url/path)放在 part 里,避免直接把超大二进制塞进同一个 JSON
现在把
image/audio/video/ppt等当文本字段返回(看起来像 URL 或 base64),如果其中某个字段可能变得极大,也建议独立一个 part,并在客户端“按需读取”。
3. 客户端:OkHttp 接收 multipart 并解析回 ProcessResult
客户端的总体流程:
- OkHttp 发请求
- 拿到
Response - 读取
Content-Type获取boundary - 读取 body bytes
- 用 multipart 解析器把每个 part 的
name -> value提取出来 - 写入
ProcessResult
3.1 OkHttp 请求与响应读取示例
OkHttpClient client = new OkHttpClient.Builder()
// 视情况设置:大结果更建议拉长 readTimeout/callTimeout
//.readTimeout(120, TimeUnit.SECONDS)
//.callTimeout(120, TimeUnit.SECONDS)
.build();
Request request = new Request.Builder()
.url("https://your-host/your-api?session_id=123")
.post(RequestBody.create("your-code".getBytes(StandardCharsets.UTF_8)))
.build();
try (Response resp = client.newCall(request).execute()) {
if (!resp.isSuccessful()) {
throw new RuntimeException("HTTP " + resp.code() + ", " + resp.message());
}
String contentType = resp.header("Content-Type");
if (contentType == null || !contentType.toLowerCase().contains("multipart")) {
throw new IllegalStateException("Not multipart response, Content-Type=" + contentType);
}
byte[] bodyBytes = resp.body().bytes(); // 注意:这会一次性读入内存
ProcessResult r = MultipartProcessResultParser.parse(contentType, bodyBytes);
// 使用 r:exitCode / json / stdOut ...
}
示例的
MultipartProcessResultParser需要contentType和完整bodyBytes。这套做法简单直接,但会把整个响应一次性读进内存。若json超大且设备内存敏感,可以进一步改成“流式解析”(后面有建议)。
4. 解析器实现要点(MultipartProcessResultParser)
示例解析器是“手写版 multipart 解析”,关键点做得比较到位:
package com.litongjava.linux;
import java.nio.charset.StandardCharsets;
import java.util.Locale;
import com.litongjava.tio.utils.commandline.ProcessResult;
public class MultipartProcessResultParser {
public static ProcessResult parse(String contentType, byte[] bodyBytes) {
String boundary = extractBoundary(contentType);
if (boundary == null || boundary.isEmpty()) {
throw new IllegalArgumentException("Missing boundary in Content-Type: " + contentType);
}
String boundaryLine = "--" + boundary;
String endBoundaryLine = boundaryLine + "--";
// 用 ISO-8859-1 把字节映射到字符,保证 0-255 不丢失)
String raw = new String(bodyBytes, StandardCharsets.ISO_8859_1);
ProcessResult r = new ProcessResult();
int pos = 0;
while (true) {
int bStart = raw.indexOf(boundaryLine, pos);
if (bStart < 0)
break;
int lineEnd = raw.indexOf("\r\n", bStart);
if (lineEnd < 0)
break;
String firstLine = raw.substring(bStart, lineEnd);
if (firstLine.equals(endBoundaryLine))
break;
int headersEnd = raw.indexOf("\r\n\r\n", lineEnd + 2);
if (headersEnd < 0)
break;
String headers = raw.substring(lineEnd + 2, headersEnd);
String name = extractName(headers);
if (name == null || name.isEmpty()) {
pos = headersEnd + 4;
continue;
}
int partBodyStart = headersEnd + 4;
int nextBoundary = raw.indexOf("\r\n" + boundaryLine, partBodyStart);
if (nextBoundary < 0)
break;
// part body(去掉末尾 CRLF)
int partBodyEnd = nextBoundary;
if (partBodyEnd - 2 >= partBodyStart && raw.startsWith("\r\n", partBodyEnd - 2)) {
partBodyEnd -= 2;
}
String partBodyIso = raw.substring(partBodyStart, partBodyEnd);
// 服务端字段值是 UTF-8 文本,所以这里再从 ISO-8859-1 转回原字节再按 UTF-8 解
byte[] partBytes = partBodyIso.getBytes(StandardCharsets.ISO_8859_1);
String value = new String(partBytes, StandardCharsets.UTF_8);
applyField(r, name, value);
pos = nextBoundary + 2; // 跳过前面的 \r\n
}
return r;
}
private static String extractBoundary(String contentType) {
// Content-Type: multipart/mixed; boundary=xxx
String lower = contentType.toLowerCase(Locale.ROOT);
int idx = lower.indexOf("boundary=");
if (idx < 0)
return null;
String b = contentType.substring(idx + "boundary=".length()).trim();
// 可能带引号
if (b.startsWith("\"") && b.endsWith("\"") && b.length() >= 2) {
b = b.substring(1, b.length() - 1);
}
// 后面可能还有分号参数
int semi = b.indexOf(';');
if (semi > 0)
b = b.substring(0, semi).trim();
return b;
}
private static String extractName(String headers) {
// 找 Content-Disposition: ... name="xxx"
String lower = headers.toLowerCase(Locale.ROOT);
int cd = lower.indexOf("content-disposition:");
if (cd < 0)
return null;
int nameIdx = lower.indexOf("name=", cd);
if (nameIdx < 0)
return null;
String after = headers.substring(nameIdx + "name=".length()).trim();
if (after.startsWith("\"")) {
int end = after.indexOf('"', 1);
if (end > 1)
return after.substring(1, end);
} else {
int end = after.indexOf(';');
return (end > 0) ? after.substring(0, end).trim() : after.trim();
}
return null;
}
private static void applyField(ProcessResult r, String name, String value) {
switch (name) {
case "exit_code":
r.setExitCode(parseInt(value));
break;
case "std_out":
r.setStdOut(value);
break;
case "std_err":
r.setStdErr(value);
break;
case "elapsed":
r.setElapsed(parseLongObj(value));
break;
case "cached":
r.setCached(parseBoolObj(value));
break;
case "prt":
r.setPrt(parseLongObj(value));
break;
case "session_id":
r.setSessionId(parseLongObj(value));
break;
case "task_id":
r.setTaskId(parseLongObj(value));
break;
case "sources":
r.setSources(value);
break;
case "execute_code":
r.setExecuteCode(value);
break;
case "output":
r.setOutput(value);
break;
case "json":
r.setJson(value);
break;
case "message":
r.setMessage(value);
break;
case "text":
r.setText(value);
break;
case "subtitle":
r.setSubtitle(value);
break;
case "image":
r.setImage(value);
break;
case "audio":
r.setAudio(value);
break;
case "video":
r.setVideo(value);
break;
case "hls_url":
r.setHlsUrl(value);
break;
case "ppt":
r.setPpt(value);
break;
case "video_length":
r.setVideo_length(parseDoubleObj(value));
break;
default:
// 未列入的字段先忽略(以后加新字段再补)
break;
}
}
private static int parseInt(String s) {
try {
return Integer.parseInt(s.trim());
} catch (Exception e) {
return 0;
}
}
private static Long parseLongObj(String s) {
try {
return Long.valueOf(s.trim());
} catch (Exception e) {
return null;
}
}
private static Boolean parseBoolObj(String s) {
String t = s.trim().toLowerCase(Locale.ROOT);
if (t.isEmpty())
return null;
if ("true".equals(t) || "1".equals(t))
return Boolean.TRUE;
if ("false".equals(t) || "0".equals(t))
return Boolean.FALSE;
return null;
}
private static Double parseDoubleObj(String s) {
try {
return Double.valueOf(s.trim());
} catch (Exception e) {
return null;
}
}
}
4.1 boundary 提取
从 Content-Type: multipart/...; boundary=xxx 中提取 boundary:
- 忽略大小写
- 处理引号
- 处理
;后续参数
对应方法:extractBoundary(contentType)
4.2 为什么用 ISO-8859-1 做中间字符串
multipart body 本质是 bytes。用:
String raw = new String(bodyBytes, StandardCharsets.ISO_8859_1);
这是为了做到 0-255 字节不丢失 的“字节到字符一一映射”。然后在取出 part body 后:
byte[] partBytes = partBodyIso.getBytes(StandardCharsets.ISO_8859_1);
String value = new String(partBytes, StandardCharsets.UTF_8);
也就是:
- 先“无损映射”为字符串,方便用
indexOf找边界 - 再把 part 内容还原回 bytes,用 UTF-8 解码成真正文本
这对“文本内容是 UTF-8”的场景非常实用。
4.3 字段回填到 ProcessResult
applyField(r, name, value) 用 switch 把各字段写回 ProcessResult,并对数字/布尔做容错解析。这样客户端拿到的对象结构与服务端一致。
