文件上传时Web应用最为常见的功能之一,传统的文件上传需要定制一个特殊的form表单来上传文件,以上传图片为例,常规的做法是先上传图片,然后回传图片地址,最后在使用图片。这无疑会带来一个严重的问题:如果在接下来使用图片的过程中web请求中断了或者其他原因导致请求关闭,那么在服务器上就会遗留下未被使用的脏数据,还需要通过其他的方式进行清理。我将这种设计模式称之为“粗犷型经济”模式,不管市场(业务)是否消费,先生产(上传)了再说,最后会导致资源的极度浪费。而本次分享要谈的是另外一种设计模式,我称之为“节约型经济”模式,将生产活动(上传)以“责任承包”制度承包(下方)给具体的业务,采用Base64解码算法的方式,通过二进制文本同步传输到业务方法,最后将文件解码存储,以达到节约资源的效果。
Base64编码是从二进制到字符的过程,可用于在HTTP环境下传递较长的标识信息。例如,在Java Persistence系统Hibernate中,就采用Base64来将一个较长的唯一标识符(一般为128-bit的UUID)编码成一个字符串,用作HTTP表单和HTTP GET URL中的参数。在其他的应用场景中,也常常需要把二进制数据编码为合适放在URL(包括隐藏表单域)中的形式。此时,采用Base64编码具有不可读性,需要解码后才能阅读。
文件上传就是将信息从个人计算机(本地计算机)传送到中央服务器(远程计算机)系统上,让网络上的其他用户可以进行访问。文件上传又分为Web上传和FTP上传,前者直接通过点击网页上的连接即可操作,后者需要专门的FTP工具进行操作。
以添加文章的需求为一个案例,一篇文章需要有ID,标题,封面,简介,正文等信息。针对文章封面的设置,通常的做法是在添加文章的页面中通过异步的方式先将图片上传至服务器,然后回传图片存储地址(URL或者URI)绑定到一个隐藏域中和一个用于预览的IMG节点上。此时,文章主体信息是没有提交到服务器的,但与文章相关的图片已经先于文章到达了服务器,这就好比你想要去洗手间放翔,结果翔还没有出来,先从嘴里呕吐了一些东东。虽然看起来都是一个“异化”过程,但总觉得让人“恶心”。原本放完翔(提交请求)冲一下马桶(提交事务)就完事了,你现在还需要额外的擦拭一下地上的呕吐物(清理垃圾文件)。
基于上述的一个应用背景,提出了采用Base64编/解码的方式同步上传文件,让文章的图片随文章主体信息一起到达服务端,如果在请求的过程中服务意外终止,那么在服务器上也不会产生任何脏数据。需求和出发点就聊这么多,接下来进入本次分享的正题,看看如何实现同步上传文件的功能。
我们需要定义一个解码器对前端传入的二进制的图片数据进行解码,对于前端如何将图片文件采用Base64算法编码,在接下来的内容当中单独介绍。此时解码器的做用主要是获取Base64编码的二进制文本中header信息(编码方式)和文件类型信息。然后对数据域进行解码。完成解码工作后,再讲字节码转换成我们熟悉的MultipartFile类型对象。解码器的实现代码如下:
package com.ramostear.jfast.common.utils; import org.springframework.web.multipart.MultipartFile; import java.io.*; /** * @author ramostear|谭朝红 * @create-time 2019/3/19 0019-23:54 * @modify by : * @since: */ public class Base64Decoder implements MultipartFile{ private final byte[] IMAGE; private final String HEADER; private Base64Decoder(byte[]image,String header){ this.IMAGE = image; this.HEADER = header; } public static MultipartFile multipartFile(byte[]image,String header){ return new Base64Decoder(image,header); } @Override public String getName() { return System.currentTimeMillis()+Math.random()+"."+HEADER.split("/")[1]; } @Override public String getOriginalFilename() { return System.currentTimeMillis()+(int)Math.random()*10000+"."+HEADER.split("/")[1]; } @Override public String getContentType() { return HEADER.split(":")[1]; } @Override public boolean isEmpty() { return IMAGE == null || IMAGE.length == 0; } @Override public long getSize() { return IMAGE.length; } @Override public byte[] getBytes() throws IOException { return IMAGE; } @Override public InputStream getInputStream() throws IOException { return new ByteArrayInputStream(IMAGE); } @Override public void transferTo(File file) throws IOException, IllegalStateException { new FileOutputStream(file).write(IMAGE); } } 复制代码
现在,需要定义一个转换器,将前端传入的图片字符信息转换成Base64编码的字节数组,然后调用解码器获得最终的MultipartFile类型对象。转换器的实现比较简单,器代码如下:
package com.ramostear.jfast.common.utils; import org.springframework.web.multipart.MultipartFile; import java.util.Base64; /** * @author ramostear|谭朝红 * @create-time 2019/3/20 0020-0:00 * @modify by : * @since: */ public class Base64Converter { public static MultipartFile converter(String source){ String [] charArray = source.split(","); Base64.Decoder decoder = Base64.getDecoder(); byte[] bytes = new byte[0]; bytes = decoder.decode(charArray[1]); for (int i=0;i<bytes.length;i++){ if(bytes[i]<0){ bytes[i]+=256; } } return Base64Decoder.multipartFile(bytes,charArray[0]); } } 复制代码
重点介绍一下转换器的方法:
首先我们先看看基于Base64算法编码后的图片二进制字符的格式:
data:image/png;base64,iVBOR....Px1yGQ9EOFXNAAAAAE1FTkSuQmcc 复制代码
因此,先通过“,”分割字符串,拿到数据的头部信息***data:image/png;base64*** ,再将数据的主体部分通过Base64进行转码,获得一个byte数组,最后调用解码器的解码方法获取MultipartFile对象。
后端的核心逻辑已经完成,接下来将介绍前端如何将一张图片采用Base64算法进行编码。
首先,需要有一个添加文章的form表单,同时将图片域设置为隐藏状态,提供一个图片预览的dom节点和一个浏览本地图片的input输入框,表单的核心代码如下:
... <form action="/articles" method="POST"> ... <div class="file-preview"> <div class="file-upload-zone"> <div class="file-upload-zone-title">Upload & preview img here …</div> </div> </div> <div class="clearfix"></div> <input type="hidden" name="cover" id="cover"/> <div class="input-group-btn"> <button class="btn btn-blue" type="button" id="upload-btn"> <i class="fa fa-folder-open"></i> <input id="upload-cover" name="upload-cover" multiple="multiple" onchange="fileChange(this)" type="file" accept="image/*"/> </button> </div> ... </form> ... 复制代码
然后是定义一个fileChange方法来处理文件编码的工作,代码如下:
function fileChange(obj){ try{ var file = obj.files[0]; var reader = new FileReader(); var fileName=""; if(typeof(fileName) != "undefined"){ fileName = $(obj).val().split("//").pop(); } reader.onload = function(){ var img = new Image(); img.src = reader.result; img.onload = function(){ var w = img.width,h = img.height; var canvas = document.createElement("canvas"); var ctx = canvas.getContext("2d"); $(canvas).attr({ width:w, height:h }); ctx.drawImage(img,0,0,w,h); var base64 = canvas.toDataURL("image/png",0.5); var result = { url:window.URL.createObjectURL(file), base64:base64, clearBase64:base64.substr(base64.indexOf(',')+1), suffix:base64.substring(base64.indexOf(',')+1,base64.indexOf(';')) }; $(".file-upload-zone-title").hide(); $(".file-upload-zone").empty(); $("#cover").val(result.base64); $("<img src=/""+result.base64+"/" class=/"img img-responsive center-block/">").appendTo(".file-upload-zone"); $(".file-upload-zone").trigger("create"); $(".file-name").val(fileName); } } reader.readAsDataURL(obj.files[0]); }catch(e){ layer.msg("error"); } }; 复制代码
关于这段代码的核心逻辑,其实与后端的解码过程刚好相反,这里不再赘述。
到现在,通过Base64编码方式同步上传文件的核心功能已经完成,在接下来的内容中,使用Spring Boot 2.0快速的演示本次分享的内容。
接一开始的需求背景,图片信息属于文章对象的一个属性值,所以处理文件上传的逻辑后置到service中,在本次测试代码中,最终的文件存储采用的是七牛云的CDN服务,关于CDN部分的代码不进行展开,可以上传到本地,两者操作的对象都是MultipartFile,关于如何存储不是本次分享的重点。文章服务组件主要代码如下:
package com.ramostear.jfast.domain.service.impl; import com.ramostear.jfast.common.ext.Translate; import com.ramostear.jfast.domain.repo.ArticleRepo; import com.ramostear.jfast.domain.service.ArticleService; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; /** * @author ramostear|谭朝红 * @create-time 2019/3/19 0019-23:37 * @modify by : * @since: */ @Service(value = "articleService") @Transactional(readOnly = true) public class ArticleServiceImpl implements ArticleService { @Autowired private ArticleRepo articleRepo; @Override @Transactional public void save(ArticleVo vo) { Article article = Translate.toArticle(vo); articleRepo.save(article); } 复制代码
在ArticleService服务组件中,涉及到一个Translate类,它的作用主要是讲前端传输过来的ValueObject映射到POJO类中,同时将文件存储的逻辑也封装进去了,主要代码如下:
package com.ramostear.jfast.common.ext; import com.ramostear.jfast.common.factory.CdnFactory; import com.ramostear.jfast.common.factory.cdn.CdnRepository; import com.ramostear.jfast.common.utils.Base64Converter; import com.ramostear.jfast.domain.model.Article; import com.ramostear.jfast.domain.vo.ArticleVo; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.BeanUtils; import org.springframework.web.multipart.MultipartFile; import java.util.Date; /** * @author ramostear|谭朝红 * @create-time 2019/3/18 0018-3:39 * @modify by : * @since: */ public class Translate { private static CdnRepository cdnRepo = CdnFactory.builder(CdnFactory.CdnType.Qiniu); public static Article toArticle(ArticleVo vo){ Article article = new Article(); BeanUtils.copyProperties(vo,article); if(StringUtils.isNotBlank(vo.getCover())){ MultipartFile file = Base64Converter.converter(vo.getCover()); article.setCover(cdnRepo.save(file)); } return article; } } 复制代码
此处由于使用的是七牛云的CDN服务,所以通过一个CND的工厂类获取一个CND仓储实例,用于将文件写入到仓储中,并回传一个文件访问地址。除了上述的方法,还可以调用file.transferTo()方法将文件写入到本地(应用服务器)磁盘中。
这里的CND工厂类实现细节由于篇幅原因不再展开。需要了解更多关于CDN SDK使用方法,可以在文章末尾给我留言。
最后,定义一个控制器,提供给前端添加文章时进行调用,文章控制器主要工作是获得前端传入的文章信息,然后调用文章服务组件,完成添加文章工作。核心代码如下:
package com.ramostear.jfast.domain.controller; @RestController public class ArticleController{ @Autowired ArticleService articleService; @Postmapping(value="/articles") public ResponseEntity<Object> createArticle(@RequestBody ArticleVo vo){ try{ articleService.save(vo); return new ResponseEntity<>("已经成功将文字写入数据库",HttpStatus.CREATED); }catch(Exception e){ return new ResponseEntity<>(e.getMessage(),HttpStatus.INTERNAL_SERVER_ERROR); } } } 复制代码