Spring RestTemplate封装Multipart重构图片上传举例

一、迭代0:重构背景

  • 今天介绍下使用Spring RestTemplate上传图片到云存储的重构过程,了解Http协议中Multipart/Form-data的使用,以及RestTemplate对协议的封装,展示适当的业务沉淀对业务开发效率的提升效果

  • 重构源头是这样的,私有云存储提供Rest接口供各业务方上传图片,对图片进行统一访问管理,在开发中发现这上传对接过程是一大串祖传代码,在各个团队之间各个应用之间来回拷贝,可读性与可维护性都难以恭维;一不做二不休,对整个图片上传对接部分抽象出来单独封装成starter供各部门使用,本文仅介绍其中一部分涉及Multipart body的重构

  • 开发背景交代下:

    • 1.小图片上传,图片大小不超过1M,基本集中在30-100K之间,业务体量不需要考虑异步并发

    • 2.云存储使用http协议+私有认证协议完成图片接收,提供REST接口得到图片下载地址

    • 3.由于基础服务提供脚手架统一封装各种RestTemplate完成各个基础服务调用,因此遵循各业务部门开发习惯也提供RestTemplate完成云存储上传协议封装

二、领域知识储备:Multipart/Form-data

  • 提到图片上传就要提到文件上传,早在1995年通过的RFC1867实验性文件上传议案后,文件上传已经成了Http协议的重要应用部分,也是浏览器及前端编程语言的基础功能
  • 文件编码:前端提交form表单(必须为POST请求),由enctype属性负责解析表单数据发送到服务端之前的编码(均写入请求头中)
    • 1.application/x-www-form-urlencoded
      • 默认编码方式
      • 所有字符均被编码,如空格 -> +,特殊字符 -> ASCII HEX值等
      • 对非ASCII字符编码效率较低
    • 2.multipart/form-data
      • 不对字符编码
      • 适用于文件上传,将文件按照表单项拆分成若干part
    • 3.text/plain
      • 只对空格编码,不处理特殊字符
      • 适用于JSON等文本数据发送
  • 在RFC2046,Multipart/Form-data的基本使用方式为:
    • 1.HTTP请求头中指定Content-Type: multipart/form-data;boundary= xezORrWt32WJeSPBSIlMiVFTTRH9NpIymUOCE3
      • 其中boundary要求符合7bit ASCII并且不超过70字符,可以任意指定,用以分割各个表单项
    • 2.boundary分隔符
      • 使用–boundary作为表单开始及中间各项分隔符
      • 使用–boundary–作为表单结尾
    • 3.每个表单项按需组装, Content-Disposition 头为必选
      • Content-Disposition作为消息主体(Response body)中的请求头中只有两个值inline(默认值,浏览器内联展示)或attachment(作为附件下载)
      • Content-Disposition作为Multipart body请求头就需要为每个表单项提供信息
      • 3.1 第一个参数固定不变为form-data
      • 3.2 附加参数有name(key与value间\r\n分隔),filename(要上传的文件名)和filename*(符合RFC5987的编码规则的文件名,优先级高于filename)三个可选值
      • 3.3 其他请求头按需提供,如Content-Type,Content-Length等
  • 下面给出一个Multipart/Form-data上传文件的示例报文:
POST /test.html HTTP/1.1
Host: example.org
Content-Type: multipart/form-data;boundary=xezORrWt32WJeSPBSIlMiVFTTRH9NpIymUOCE3

--xezORrWt32WJeSPBSIlMiVFTTRH9NpIymUOCE3
Content-Disposition: form-data; name="field1"

value1
--xezORrWt32WJeSPBSIlMiVFTTRH9NpIymUOCE3
Content-Disposition: form-data; name="field2"; filename="example.txt"

value2
--xezORrWt32WJeSPBSIlMiVFTTRH9NpIymUOCE3--

三、测试覆盖:没有测试就没有重构

  • 由于云存储后端返回的结果不能直接判断纠正类型,除了补充单元测试验证最终上传效果外,还需要抓取重构前的报文,有利于调试过程,毕竟这是涉及网络协议的
  • 这就是重构前可以成功完成图片上传的报文(已删除无关数据)
POST /xxx/Picture/Write HTTP/1.1
Authorization: xxxxxx
Date: 01 Jun 2022 01:26:24 GMT
Content-Type: multipart/form-data;boundary=7e02362550dc4
Accept-Language: zh-cn
User-Agent: Java/1.8.0_181
Host: xxxx
Accept: text/html, image/gif, image/jpeg, *; q=.2, */*; q=.2
Connection: keep-alive
Content-Length: 32543

--7e02362550dc4
Content-Disposition:form-data;name="SerialID"

12345
--7e02362550dc4
Content-Disposition:form-data;name="PoolID"

xxx
--7e02362550dc4
Content-Disposition:form-data;name="TimeStamp"

1654046784796
--7e02362550dc4
Content-Disposition:form-data;name="PictureType"

1
--7e02362550dc4
Content-Disposition:form-data;name="Token"

1654046694986980
--7e02362550dc4
Content-Disposition:form-data;name="PictureLength"

31977
--7e02362550dc4
Content-Disposition:form-data;name="Picture"
Content-Type:image/jpeg

# 此处省略图片二进制数据
--7e02362550dc4--

HTTP/1.1 200 OK
Content-Length: 152
Date: Wed, 01 Jun 2022 01:24:55 GMT
Connection: keep-alive

{"PictureUrl":"/pic?xxxxx"}
  • 除了认证参数,请求头主要就是Multipart body,描述各个文件表单项;而返回结果就是图片下载地址或错误信息
  • 有了报文做参考,重构网络协议对接就会踏实许多,因为不管应用层实现如何变化在网络层需要保证一致

四、识别代码坏味道:面向过程组装的Form Data

  • 翻了翻祖传代码,下面将图片上传云存储的核心实现拎出来,HttpURLConnection处理图片上传而且是字符串组装的Content-Disposition,品品代码坏味道
private String uploadImageFile(ImageStoreBestNode bestNode, InputStream is, String serialId, String poolId, String fileType) throws Exception {
    // 。。。此处省略若干行无关此文的校验逻辑

    String uri = ImageStoreConstant.IMAGE_UPLOAD_URL;
    String contentType = ContentType.MULTIPART_FORM_DATA.getMimeType();

    // 。。。此处省略若干行上传路径获取的过程
    URL url = new URL(bestNodeUrl + uri);
    HttpURLConnection conn = (HttpURLConnection) url.openConnection();
    conn.setRequestMethod("POST");
    String date = DateGMCUtil.getGMTString(new Date());

    // 。。。此处省略若干行获取认证的过程
    conn.setRequestProperty("Authorization", authorization);
    conn.setRequestProperty("Date", date);
    conn.setRequestProperty("Content-Type", contentType + ";boundary=" + SEPARATOR_BOUNDARY);
    conn.setRequestProperty("Accept-Language", "zh-cn");
    conn.setRequestProperty("Connection", ImageStoreConstant.CONNECTION_KEEP_ALIVE);

    byte[] buffer = new byte[is.available()];

    int len = is.read(buffer);

    String data = this.getPicData(serialId, poolId, bestNode.getToken(), buffer, fileType);

    String endData = SEPARATOR_TWO_DASH + SEPARATOR_BOUNDARY + SEPARATOR_TWO_DASH + SEPARATOR_END + SEPARATOR_END;

    int contentLenth = data.getBytes(Charset.defaultCharset()).length + buffer.length + endData.length();

    conn.setRequestProperty("Content-Length", String.valueOf(contentLenth));
    conn.setDoInput(true);
    conn.setDoOutput(true);

    OutputStream os = conn.getOutputStream();
    os.write(data.getBytes(Charset.defaultCharset()));
    os.write(buffer, 0, len);
    os.write(endData.getBytes(Charset.defaultCharset()));
    os.flush();
    InputStream responseInputStream = null;
    BufferedReader bufferedReader = null;
    try {
        // 跨度很大的一个try-catch
        int responseCode = conn.getResponseCode();
        if (responseCode >= 400) {
            responseInputStream = conn.getErrorStream();
        } else {
            responseInputStream = conn.getInputStream();
        }

        // 手动处理图片流,面向过程编码真的不好维护
        bufferedReader = new BufferedReader(new InputStreamReader(responseInputStream, Charset.defaultCharset()));
        StringBuilder revBuf = new StringBuilder();
        String line;
        while ((line = bufferedReader.readLine()) != null) {
            revBuf.append(line).append("\n");
        }

        String responseStr = revBuf.toString();

        if (responseCode >= 400) {
            throw new Exception(responseStr);
        }

        // 祖传代码业务耦合过高,解析上传结果都不舍得分离的
        Map<String, Object> reponse = JsonUtil.json2map(responseStr);
        return reponse.get("PictureUrl") != null ? reponse.get("PictureUrl").toString() : "";

    } catch (Exception e) {
        log.error("图片上传失败", e);
        throw new Exception();
    } finally {
        // 噩梦一样的关闭流,其他同事万一拷贝漏了点啥呢
        if (responseInputStream != null) {
            responseInputStream.close();
        }
        if (bufferedReader != null) {
            bufferedReader.close();
        }

        is.close();
        os.close();

        conn.disconnect();
    }
}

private String getPicData(String serialID, String poolID, String token, byte[] picBuff, String fileType) throws Exception {
    int fileTypeFlag = 0;
    String contentType = "Content-Type:";
    // 。。。此处省略若干行图片类型判断的校验逻辑,校验是不是为时尚晚呐

    // 又一个噩梦,字符串拼接的Content-Disposition
    String data = SEPARATOR_TWO_DASH + SEPARATOR_BOUNDARY + SEPARATOR_END
        + "Content-Disposition:form-data;name=\"SerialID\"" + SEPARATOR_END + SEPARATOR_END
        + serialID + SEPARATOR_END
        + SEPARATOR_TWO_DASH + SEPARATOR_BOUNDARY + SEPARATOR_END
        + "Content-Disposition:form-data;name=\"PoolID\"" + SEPARATOR_END + SEPARATOR_END
        + poolID + SEPARATOR_END
        + SEPARATOR_TWO_DASH + SEPARATOR_BOUNDARY + SEPARATOR_END
        + "Content-Disposition:form-data;name=\"TimeStamp\"" + SEPARATOR_END + SEPARATOR_END
        + System.currentTimeMillis() + SEPARATOR_END
        + SEPARATOR_TWO_DASH + SEPARATOR_BOUNDARY + SEPARATOR_END
        + "Content-Disposition:form-data;name=\"PictureType\"" + SEPARATOR_END + SEPARATOR_END
        + fileTypeFlag + SEPARATOR_END
        + SEPARATOR_TWO_DASH + SEPARATOR_BOUNDARY + SEPARATOR_END
        + "Content-Disposition:form-data;name=\"Token\"" + SEPARATOR_END + SEPARATOR_END
        + token + SEPARATOR_END
        + SEPARATOR_TWO_DASH + SEPARATOR_BOUNDARY + SEPARATOR_END
        + "Content-Disposition:form-data;name=\"PictureLength\"" + SEPARATOR_END + SEPARATOR_END
        + picBuff.length + SEPARATOR_END
        + SEPARATOR_TWO_DASH + SEPARATOR_BOUNDARY + SEPARATOR_END
        + "Content-Disposition:form-data;name=\"Picture\"" + SEPARATOR_END
        + contentType + SEPARATOR_END + SEPARATOR_END;
    return data;
}

五、利用技术先进性整洁代码:RestTemplate封装的Http请求

  • 使用MultipartBodyBuilder构造Multipart body
MultipartBodyBuilder multipartBodyBuilder = new MultipartBodyBuilder();

multipartBodyBuilder.part("SerialID", SERIAL_ID);
multipartBodyBuilder.part("PoolID", cloudStorageProperties.getPoolId());
multipartBodyBuilder.part("TimeStamp", String.valueOf(System.currentTimeMillis()));
multipartBodyBuilder.part("PictureType", String.valueOf(convertImageType(imageType)));
multipartBodyBuilder.part("Token", xxx);
multipartBodyBuilder.part("PictureLength", String.valueOf(imageBytes.length));
multipartBodyBuilder.part("Picture", imageBytes).contentType(MediaType.IMAGE_JPEG);

return multipartBodyBuilder.build();
  • 使用ZonedDateTime构造GMT时间戳
private static final String GMT_DATE_FORMATTER = "dd MMM yyyy HH:mm:ss z";

DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern(GMT_DATE_FORMATTER, Locale.UK);
return zonedDateTime.format(dateTimeFormatter);
  • 使用HttpHeaders构造消息主体请求头
HttpHeaders httpHeaders = new HttpHeaders();

httpHeaders.set("Authorization", xxx);
// Http标准GMT时间
httpHeaders.set("Date", HpspHeaderDate.currentGmtTime());
httpHeaders.set("Accept-Language", "zh-cn");
httpHeaders.set("Content-Type", contentType.toString());
httpHeaders.setConnection("keep-alive");

return httpHeaders;
  • 使用HttpEntity构造请求对象
final MultiValueMap<String, HttpEntity<?>> imageParts = HpspImagePart.buildDownloadPart(bestImageUploadNode, cloudStorageProperties, imageType, imageBytes);
HttpHeaders httpHeaders = HpspHeader.postForHpspHeader(buildUploadUri(), MediaType.MULTIPART_FORM_DATA, cloudStorageProperties);
return new HttpEntity<>(imageParts, httpHeaders);
  • 使用自定义RestTemplate发起Http请求
@Resource
private CloudStorageRestTemplate restTemplate;

ImageUrl imageUrl = restTemplate.postForObject(URI.create(buildUploadUrl(bestImageUploadNode)), uploadImageHttpEntity, ImageUrl.class);
  • 当然,这个自定义RestTemplate需要我们注册一下到Spring容器里
  • 最后,我们就形成了这样一种结构,借助RestTemplate的良好Http封装以及领域划分,代码的可读性与可维护性都得到了极大提升

Spring RestTemplate封装Multipart重构图片上传举例_第1张图片

六、魔鬼在细节:处理Multipart Body默认添加的Header

  • 我们跑一下测试用例,发现上传失败;捕获下报文,发现了异常:

    • Multipart Body的每个part都的多了Content-Type和Content-Length请求头
  • debug跟踪下RestTemplate请求头组装过程,很容易找到在AbstractHttpMessageConverter#addDefaultHeaders添加了默认请求头

  • 查看Spring Web源码跟其注释是吻合的,对于Content-Length和Content-Type为空的情况下会默认添加上这两个请求头,并不会管是消息主体body还是Multipart body

    Add default headers to the output message.
    This implementation delegates to getDefaultContentType(Object) if a content type was not provided, set if necessary the default character set, calls getContentLength, and sets the corresponding headers.
    Since: 4.2

  • 这也就是前后报文比对中Multipart body每个part出现多余Content-Length和Content-Type的原因,解决也就不麻烦了

  • 针对Multipart body支持的消息格式,定义消息转换器覆盖掉抽象类的addDefaultHeaders,我们不需要添加默认请求头就实现空方法

/**
 * 覆盖AbstractHttpMessageConverter对byte[]类型添加默认请求头方法
 * 避免对Content-Disposition添加请求头
 */
static class SimpleByteArrayHttpMessageConverter extends ByteArrayHttpMessageConverter {
    @Override
    protected void addDefaultHeaders(HttpHeaders headers, byte[] bytes, MediaType contentType) throws IOException {
    }
}
/**
 * 覆盖AbstractHttpMessageConverter对String类型添加默认请求头方法
 * 避免对Content-Disposition添加请求头
 */
static class SimpleStringHttpMessageConverter extends StringHttpMessageConverter {
    @Override
    protected void addDefaultHeaders(HttpHeaders headers, String s, MediaType type) throws IOException {
    }
}
  • 最后将我们重新定义的消息转换器递交给我们专属的RestTemplate
FormHttpMessageConverter formHttpMessageConverter = new FormHttpMessageConverter();
	formHttpMessageConverter.setPartConverters(Lists.newArrayList(
	stringConverterWithoutDefaultHeaders(),
	byteArrayConverterWithoutDefaultHeaders(),
	new ResourceHttpMessageConverter())
);
// 我们最后就用这个restTemplate对象完成图片上传
restTemplate.setMessageConverters(Lists.newArrayList(
	formHttpMessageConverter,
	new ResourceHttpMessageConverter(),
	jacksonSupportOctetStream())
);
  • 再次捕获报文验证结果:
    • 1.Multipart body的PictureLength值和重构前是一样的
    • 2.Multipart body不再有Content-Length和Content-Type头了
    • 3.消息体的Content-Length稍微增大,那是因为使用了Spring生成的boundary,消息体大小略有增加
    • 4.从报文结构来看,重构是OK的,下面再去验证上传结果也就是能够打开图片了
POST /xxx/Picture/Write HTTP/1.1
Accept: text/plain, */*
Authorization: xxxxx
Date: 06 Jun 2022 08:30:25 GMT
Accept-Language: zh-cn
Content-Type: multipart/form-data;boundary=xezORrWt32WJeSPBSIlMiVFTTRH9NpIymUOCE3
Connection: keep-alive
Content-Length: 32758
Host: xxxx
User-Agent: Apache-HttpClient/4.5.13 (Java/1.8.0_181)
Accept-Encoding: gzip,deflate

--xezORrWt32WJeSPBSIlMiVFTTRH9NpIymUOCE3
Content-Disposition: form-data; name="SerialID"

12345
--xezORrWt32WJeSPBSIlMiVFTTRH9NpIymUOCE3
Content-Disposition: form-data; name="PoolID"

xxx
--xezORrWt32WJeSPBSIlMiVFTTRH9NpIymUOCE3
Content-Disposition: form-data; name="TimeStamp"

1654504225463
--xezORrWt32WJeSPBSIlMiVFTTRH9NpIymUOCE3
Content-Disposition: form-data; name="PictureType"

1
--xezORrWt32WJeSPBSIlMiVFTTRH9NpIymUOCE3
Content-Disposition: form-data; name="Token"

1654504117160330
--xezORrWt32WJeSPBSIlMiVFTTRH9NpIymUOCE3
Content-Disposition: form-data; name="PictureLength"

31977
--xezORrWt32WJeSPBSIlMiVFTTRH9NpIymUOCE3
Content-Disposition: form-data; name="Picture"
Content-Type: image/jpeg

# 此处省略图片二进制数据
--xezORrWt32WJeSPBSIlMiVFTTRH9NpIymUOCE3--
HTTP/1.1 200 OK
Content-Length: 152
Date: Mon, 06 Jun 2022 08:28:51 GMT
Connection: keep-alive

{"PictureUrl":"/pic?xxxxx"}

七、回归验证:重构后的效果提升

  • 对图片存储调用者来说,就很简洁了——传入图片参数MultipartFile类型的image,然后得到图片存储或下载地址
  • 上传过程都被基础服务CloudStorageImageService封装掉了,无需关注协议组装、参数转换、文件流读取与关闭等上传细节
  • 中间服务还提供了CloudStorageException这类UncheckedException供调用者自行把握异常处置
return cloudStorageImageService.uploadImage(image).getXxxUrl();
  • 回到单元测试,比对重构前后图片上传结果,这里很容易了,查看图片上传后是否可以直接访问到
@SneakyThrows
@Test
@DisplayName("测试上传图片到云存储")
public void testUploadImage2BestNode() {
	final MultipartFile multipartFile = TestFileUtils.readFileAsMultipartFile("image/xxx.jpg");
	final ImageUrl imageUrl = imageUploadService.uploadImageBytes(multipartFile.getBytes());
	Assertions.assertNotNull(imageUrl.getXxxUrl());
}

你可能感兴趣的:(Java知识体系,spring,重构,http)