吐槽

近日在写微信小程序的一个表单时遇到一个问题,就是小程序原生的一些接口:wx.uploadFile、wx.request(multipart/form-data)无法直接一次性上传多张图片(or文件)。

wx.uploadFile就不用我多说了,一次只能上传一个。

而wx.request就比较特殊,并不是说真的不行,而是不能自动封装multipart/form-data格式的数据,若在使用wx.request时直接将Content-Type改成multipart/form-data,后端会报错,会提示缺乏boundary字段的错误。原因就在于小程序的wx.request不会自动封装multipart/form-data格式的数据,需要你手动封装。如果你自己手动封装,表单参数多起来的话代码就会变得十分难看。

那既然wx.uploadFile和wx.request设置multipart/form-data都不太管用,那是不是就没办法了?虽然我可以让图片先上传,然后保存服务器回传的url,但我并不想这么做,因为每次上传都是耗费流量(💰)的,要是用户不停上传->删除->上传文件,但就是不提交表单,我离破产就不远了= =、

之后想了半天,可算是给我想到一个解决方案:将图片转base64再发送,且发送Content-Typeapplication/json

想法源自我用某云sdk的时候,它要求我将文件编码成base64再发送,于是我就想微信小程序是不是也能这么做。

有个地方不得不吐槽,wx.uploadFile明明就是以multipart/form-data格式发送的,说明微信小程序官方是完全有能力让wx.request也原生支持multipart/form-data的,但却一直在这留了坑,难道我不上传文件就一定要自己封装multipart/form-data请求体?实在让人费解。


实验环境

IDE: 微信开发者工具、IDEA
调试基础库Version: 2.9.5
JDK Version: 1.8
SpringBoot Version: 2.2.2.RELEASE

微信小程序将文件转base64

如果是普通的网页开发,我们可能需要借助一些base64编码的工具类,但在微信小程序就不需要这么麻烦了,基础库为我们提供了足够贴心的服务,有自带的转码接口(包含base64等多种编码),就大大地方便了我们操作,这个接口就是FileSystemManager.readFileSync

下面来整个表单大概是什么样的:

image.png

来看看提交表单的js(在此处转base64):

  /**
   * 表单提交
   */
  submitApply: function(e) {
    let params = e.detail.value;
    // 校验表单参数
    let res = this.checkForm(params);
    if (res) {
      wx.showModal({
        title: '提示',
        content: res,
      })
      return;
    }
    let imageList = this.data.imgList;
    // 图片转base64
    const credentialsPhotoFront = 'data:image/jpg;base64,' + wx.getFileSystemManager().readFileSync(imageList[0], 'base64');
    const credentialsPhotoReverse = 'data:image/jpg;base64,'+ wx.getFileSystemManager().readFileSync(imageList[1], 'base64');
    let realSize1 = credentialsPhotoFront.length - (credentialsPhotoFront.length/8)*2;
    let realSize2 = credentialsPhotoReverse.length - (credentialsPhotoReverse.length/8)*2;
    // 限制文件大小不超过10Mb
    if (realSize1 > app.uploadFileMaxSize || realSize2 > app.uploadFileMaxSize){
      wx.showModal({
        title: '错误',
        content: '单张图片不得超过10Mb',
      });
      return;
    }
    params['credentialsPhotoFront'] = credentialsPhotoFront;
    params['credentialsPhotoReverse'] = credentialsPhotoReverse;
    console.log(params);
    let url = app.serverUrl + 'user/applyForCertification';
    let token = wx.getStorageSync('token');
    let that = this;
    wx.showLoading({
      title: '提交申请ing'
    });
    request.requestPostApi(url, token, params, 'application/json', that, that.submitSucc, app.failFun, app.completeFun);
  },
  • 当然校验文件大小的代码,也可以写到checkForm方法中,我这里是故意贴出来告诉你如何限制文件大小的。

提交表单时,我们会看到控制台有如下输出:

控制台输出.png

看到这个,基本确认我们的图片成功转码成base64了,剩下的就是后端接收到这些数据,将base64转回图片再上传了,下面我会以SpringBoot做后端框架为例子展示一下我是如何处理的。

java将base64字符转MultipartFile

某个controller部分代码如下:


    @Value("${spring.servlet.multipart.max-file-size}")
    private DataSize dataSize;

    @Override
    @PostMapping("applyForCertification")
    @CheckLogin
    public BaseResponse<UserInfoDTO> applyForCertification(@Valid @RequestBody UserInfoParam userInfoParam) throws Exception {
        userInfoParam.setUid(getUid());
        long size1 = userInfoParam.getCredentialsPhotoFront().length();
        long size2 = userInfoParam.getCredentialsPhotoReverse().length();
        long realSize1 = size1 - (size1 / 8) * 2;
        long realSize2 = size2 - (size2 / 8) * 2;
        long maxFileSize = dataSize.toBytes();
        if (realSize1 > maxFileSize || realSize2 > maxFileSize) {
            throw new BadRequestException("文件大小不可超过10Mb");
        }

        return userInfoService.applyForCertification(userInfoParam);
    }
  • 注意限制文件大小,因为这里使用的是文件的base64编码,而不是直接传文件二进制流,所以spring.servlet.multipart.max-file-size参数对这里是没有拦截作用的。
  • 其实判断文件大小的代码还不是最严谨的,应该把base64的header和"="号去掉再判断,不过这样做代码是在太难看了,我宁愿把限制的文件大小+1MB😂
  • 另外,在网关层我们应该限制请求体的大小,因为上面的代码是拿到文件的base64码之后才能判断。

UserInfoParam包含两个图片的base64字符串:

    // 其余参数略	
    @ApiModelProperty(value = "正面照base64编码")
    @NotBlank(message = "正面照不能为空")
    private String credentialsPhotoFront;

    @ApiModelProperty(value = "反面照base64编码")
    @NotBlank(message = "反面照不能为空")
    private String credentialsPhotoReverse;

这两个base64编码后的字符串在业务层处理,图片处理核心代码如下:

        MultipartFile credentialsPhotoFront = BASE64DecodedMultipartFile.base64ToMultipart(userInfoParam.getCredentialsPhotoFront());
        MultipartFile credentialsPhotoReverse = BASE64DecodedMultipartFile.base64ToMultipart(userInfoParam.getCredentialsPhotoReverse());

        UserInfo userInfo = userInfoParam.convertTo();
        UploadResult upload1 = fileHandlers.upload(credentialsPhotoFront);
        UploadResult upload2 = fileHandlers.upload(credentialsPhotoReverse);

最后再给出BASE64DecodedMultipartFile代码,其实就是将base64编码的字符串重新转回字节流再封装成MultipartFile(代码我也是抄别人的):


import org.springframework.web.multipart.MultipartFile;
import sun.misc.BASE64Decoder;

import java.io.*;

/**
 * base64转MultipartFile
 * 
 * @author wenjie
 */
public class BASE64DecodedMultipartFile implements MultipartFile {

    private final byte[] imgContent;
    private final String header;

    public BASE64DecodedMultipartFile(byte[] imgContent, String header) {
        this.imgContent = imgContent;
        this.header = header.split(";")[0];
    }

    @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 imgContent == null || imgContent.length == 0;
    }

    @Override
    public long getSize() {
        return imgContent.length;
    }

    @Override
    public byte[] getBytes() throws IOException {
        return imgContent;
    }

    @Override
    public InputStream getInputStream() throws IOException {
        return new ByteArrayInputStream(imgContent);
    }

    @Override
    public void transferTo(File dest) throws IOException, IllegalStateException {
        new FileOutputStream(dest).write(imgContent);
    }

    /**
     * base64转MultipartFile文件
     */
    public static MultipartFile base64ToMultipart(String base64) {
        try {
            String[] baseStrs = base64.split(",");

            BASE64Decoder decoder = new BASE64Decoder();
            byte[] b = new byte[0];
            b = decoder.decodeBuffer(baseStrs[1]);

            for (int i = 0; i < b.length; ++i) {
                if (b[i] < 0) {
                    b[i] += 256;
                }
            }

            return new BASE64DecodedMultipartFile(b, baseStrs[0]);
        } catch (IOException e) {
            e.printStackTrace();
            return null;
        }
    }
}

再补几张断点调试的图:

确保转字节流没问题了
image.png

上传到七牛云也成功了,返回的地址拼装一下再访问,也确实是显示图片
image.png

image.png


好了,到此为止总算是将一开始的想法都落地实现了,如果还有什么问题欢迎补充。