一、迭代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等文本数据发送
- 1.application/x-www-form-urlencoded
- 在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等
- 1.HTTP请求头中指定Content-Type: multipart/form-data;boundary= xezORrWt32WJeSPBSIlMiVFTTRH9NpIymUOCE3
- 下面给出一个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封装以及领域划分,代码的可读性与可维护性都得到了极大提升
六、魔鬼在细节:处理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());
}