Skip to content

返回 multipart ​

1. 背景与问题 ​

在一些“执行任务/生成内容”的接口里,返回值往往包含一个非常大的 JSON 字段(例如 json、output、std_out 等)。如果服务端统一把所有字段拼成一个超大的 JSON 字符串再返回,客户端通常要做两件事:

  1. 先把整个响应体一次性读完
  2. 再把整段 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 结构 ​

java
package nexus.io.kit.handler;

import java.util.concurrent.locks.Lock;

import com.google.common.util.concurrent.Striped;
import nexus.io.jfinal.aop.Aop;
import nexus.io.kit.service.MotionCanvasCodeExecuteService;
import nexus.io.kit.utils.ProcessResultMultipartMapper;
import nexus.io.kit.vo.VideoCodeInput;
import nexus.io.model.upload.UploadFile;
import nexus.io.tio.boot.http.TioRequestContext;
import nexus.io.tio.core.ChannelContext;
import nexus.io.tio.http.common.HttpRequest;
import nexus.io.tio.http.common.HttpResponse;
import nexus.io.tio.http.multipart.TioMultipartHttpResponder;
import nexus.io.tio.http.multipart.TioMultipartParts;
import nexus.io.tio.http.server.handler.HttpRequestHandler;
import nexus.io.tio.http.server.util.CORSUtils;
import nexus.io.tio.utils.commandline.ProcessResult;
import nexus.io.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)

关键代码:

java
ProcessResult result = srv.executeCode(input, channelContext);
if (result != null) {
  TioMultipartParts parts = mapper.toParts(result);
  multipartResponder.respondFormData(response, parts);
}

2.2 ProcessResult -> Multipart 的映射策略 ​

java
package nexus.io.kit.utils;

import nexus.io.tio.http.multipart.TioMultipartParts;
import nexus.io.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,并声明内容类型

例如:

java
p.addJson("json", r.getJson());

这样客户端可以明确知道 json 这一段是 JSON(即使它非常大),并且不会影响其它字段的读取与解析。

2.3 推荐的字段拆分原则 ​

实践建议:

  1. 小字段都放 multipart:exit_code / elapsed / cached / session_id / task_id / message...
  2. 可能很大的字段单独 part:json / output / std_out / std_err
  3. 二进制资源(如果未来需要):建议用 addBinary(...) 或把资源地址(url/path)放在 part 里,避免直接把超大二进制塞进同一个 JSON

现在把 image/audio/video/ppt 等当文本字段返回(看起来像 URL 或 base64),如果其中某个字段可能变得极大,也建议独立一个 part,并在客户端“按需读取”。


3. 客户端:OkHttp 接收 multipart 并解析回 ProcessResult ​

客户端的总体流程:

  1. OkHttp 发请求
  2. 拿到 Response
  3. 读取 Content-Type 获取 boundary
  4. 读取 body bytes
  5. 用 multipart 解析器把每个 part 的 name -> value 提取出来
  6. 写入 ProcessResult

3.1 OkHttp 请求与响应读取示例 ​

java
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 解析”,关键点做得比较到位:

java
package nexus.io.linux;

import java.nio.charset.StandardCharsets;
import java.util.Locale;

import nexus.io.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。用:

java
String raw = new String(bodyBytes, StandardCharsets.ISO_8859_1);

这是为了做到 0-255 字节不丢失 的“字节到字符一一映射”。然后在取出 part body 后:

java
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,并对数字/布尔做容错解析。这样客户端拿到的对象结构与服务端一致。


MIT Licensed