赞
踩
今天介绍下使用Spring RestTemplate上传图片到云存储的重构过程,了解Http协议中Multipart/Form-data的使用,以及RestTemplate对协议的封装,展示适当的业务沉淀对业务开发效率的提升效果
重构源头是这样的,私有云存储提供Rest接口供各业务方上传图片,对图片进行统一访问管理,在开发中发现这上传对接过程是一大串祖传代码,在各个团队之间各个应用之间来回拷贝,可读性与可维护性都难以恭维;一不做二不休,对整个图片上传对接部分抽象出来单独封装成starter供各部门使用,本文仅介绍其中一部分涉及Multipart body的重构
开发背景交代下:
1.小图片上传,图片大小不超过1M,基本集中在30-100K之间,业务体量不需要考虑异步并发
2.云存储使用http协议+私有认证协议完成图片接收,提供REST接口得到图片下载地址
3.由于基础服务提供脚手架统一封装各种RestTemplate完成各个基础服务调用,因此遵循各业务部门开发习惯也提供RestTemplate完成云存储上传协议封装
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"}
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; }
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();
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 = 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;
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);
@Resource
private CloudStorageRestTemplate restTemplate;
ImageUrl imageUrl = restTemplate.postForObject(URI.create(buildUploadUrl(bestImageUploadNode)), uploadImageHttpEntity, ImageUrl.class);
我们跑一下测试用例,发现上传失败;捕获下报文,发现了异常:
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 {
}
}
FormHttpMessageConverter formHttpMessageConverter = new FormHttpMessageConverter();
formHttpMessageConverter.setPartConverters(Lists.newArrayList(
stringConverterWithoutDefaultHeaders(),
byteArrayConverterWithoutDefaultHeaders(),
new ResourceHttpMessageConverter())
);
// 我们最后就用这个restTemplate对象完成图片上传
restTemplate.setMessageConverters(Lists.newArrayList(
formHttpMessageConverter,
new ResourceHttpMessageConverter(),
jacksonSupportOctetStream())
);
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"}
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());
}
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。