基於Base64編/解碼演算法的Spring Boot檔案上傳技術解析

譚朝紅發表於2019-04-11

檔案上傳時Web應用最為常見的功能之一,傳統的檔案上傳需要定製一個特殊的form表單來上傳檔案,以上傳圖片為例,常規的做法是先上傳圖片,然後回傳圖片地址,最後在使用圖片。這無疑會帶來一個嚴重的問題:如果在接下來使用圖片的過程中web請求中斷了或者其他原因導致請求關閉,那麼在伺服器上就會遺留下未被使用的髒資料,還需要通過其他的方式進行清理。我將這種設計模式稱之為“粗獷型經濟”模式,不管市場(業務)是否消費,先生產(上傳)了再說,最後會導致資源的極度浪費。而本次分享要談的是另外一種設計模式,我稱之為“節約型經濟”模式,將生產活動(上傳)以“責任承包”制度承包(下方)給具體的業務,採用Base64解碼演算法的方式,通過二進位制文字同步傳輸到業務方法,最後將檔案解碼儲存,以達到節約資源的效果。

基本術語

1. Base64編碼

Base64編碼是從二進位制到字元的過程,可用於在HTTP環境下傳遞較長的標識資訊。例如,在Java Persistence系統Hibernate中,就採用Base64來將一個較長的唯一識別符號(一般為128-bit的UUID)編碼成一個字串,用作HTTP表單和HTTP GET URL中的引數。在其他的應用場景中,也常常需要把二進位制資料編碼為合適放在URL(包括隱藏表單域)中的形式。此時,採用Base64編碼具有不可讀性,需要解碼後才能閱讀。

2. 檔案上傳

檔案上傳就是將資訊從個人計算機(本地計算機)傳送到中央伺服器(遠端計算機)系統上,讓網路上的其他使用者可以進行訪問。檔案上傳又分為Web上傳和FTP上傳,前者直接通過點選網頁上的連線即可操作,後者需要專門的FTP工具進行操作。

案例解析

以新增文章的需求為一個案例,一篇文章需要有ID,標題,封面,簡介,正文等資訊。針對文章封面的設定,通常的做法是在新增文章的頁面中通過非同步的方式先將圖片上傳至伺服器,然後回傳圖片儲存地址(URL或者URI)繫結到一個隱藏域中和一個用於預覽的IMG節點上。此時,文章主體資訊是沒有提交到伺服器的,但與文章相關的圖片已經先於文章到達了伺服器,這就好比你想要去洗手間放翔,結果翔還沒有出來,先從嘴裡嘔吐了一些東東。雖然看起來都是一個“異化”過程,但總覺得讓人“噁心”。原本放完翔(提交請求)衝一下馬桶(提交事務)就完事了,你現在還需要額外的擦拭一下地上的嘔吐物(清理垃圾檔案)。

基於上述的一個應用背景,提出了採用Base64編/解碼的方式同步上傳檔案,讓文章的圖片隨文章主體資訊一起到達服務端,如果在請求的過程中服務意外終止,那麼在伺服器上也不會產生任何髒資料。需求和出發點就聊這麼多,接下來進入本次分享的正題,看看如何實現同步上傳檔案的功能。

功能實現

1. 解碼器

我們需要定義一個解碼器對前端傳入的二進位制的圖片資料進行解碼,對於前端如何將圖片檔案採用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);
    }
}
複製程式碼

2. 轉換器

現在,需要定義一個轉換器,將前端傳入的圖片字元資訊轉換成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物件。

3. 前端的Base64編碼

後端的核心邏輯已經完成,接下來將介紹前端如何將一張圖片採用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快速的演示本次分享的內容。

新增文章服務元件#檔案上傳

1. 新增文章的服務元件

接一開始的需求背景,圖片資訊屬於文章物件的一個屬性值,所以處理檔案上傳的邏輯後置到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使用方法,可以在文章末尾給我留言。

2. 文章控制器

最後,定義一個控制器,提供給前端新增文章時進行呼叫,文章控制器主要工作是獲得前端傳入的文章資訊,然後呼叫文章服務元件,完成新增文章工作。核心程式碼如下:

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);
        }
    }
    
}
複製程式碼

結束語

本次分享只給出了核心部位的實現,其中涉及到的如CDN、HTML、JS等的知識沒有展開,如果給你帶來了困惑,可以在評論區給我留言,我們再一起討論。再次感謝大家賞光拜讀,謝謝~~~

轉載請保留版權資訊,勿做商業用途

作者:譚朝紅,原文標題:基於Base64編/解碼演算法的Spring Boot檔案上傳技術解析

相關文章