今天介绍下使用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());
}