Spring Boot下POI生成word并下载

2,816 阅读5分钟

一、整体概述

1.1 环境

后端:Spring Boot 2.x

前端:Vue 2.x

前后端分离项目

1.2 任务要求

要求在前台能够下载word报告,报告中包含图片和表格。

在网上搜索了很多解决方案,大部分是利用freemark模板生成word,作者也试过这种方法,有几个缺点比较明显:

  1. 需要在word模板中写${name}这种变量用于freemark替换,但是在word模板转换为.xml格式后${name}可能不会连在一起,而是中间有xml中的代码,需要手动解决。

  2. word模板转换为.xml后代码量很大,动辄上千行,比较容易出错,一旦出错就是文档打不开的结果。

  3. 如果生成的模板中带有图片,通常解决方案是在springboot后台将图片转换为base64格式然后替换掉.xml模板中的图片代码,但是这对图片格式以及base64图片数据中的空格等等都有很严格的要求,需要非常仔细,同时也非常麻烦。作者遇到最常见的就是图片代码被替换掉,但是图片显示不出来,需要一步一步的测试,非常麻烦,可能也与作者经验不足有关。

最后作者决定使用poi在后端生成word文档,然后前台进行下载的方法解决。

1.3 涉及到的知识点

  1. 动态创建form表单并且向后端传参。
  2. form表单如何传递对象中的数组。
  3. 后端接收form表单传递的对象。
  4. java中使用poi生成word文档。
  5. word文档中创建图片。
  6. word文档的下载。
  7. 该文档下载接口的权限校验。
  8. echarts中获取base64格式的图片。

二、数据结构

2.1 后端接收的数据结构

使用VO类接收数据

import lombok.Data;

import java.util.List;

/**
 * 接受数据的Vo类
 */
@Data
public class TestVo {

    /**
     * key
     */
    private String key;

    /**
     * value
     */
    private String value;

    /**
     * chList
     */
    private List<TestChildrenVo> chList;
}

import lombok.Data;

/**
 * 子类型Vo
 *
 */
@Data
public class TestChildrenVo {

    /**
     * 图片Base64
     */
    private String img;

    /**
     * 分页大小
     */
    private Integer pageSize;

    /**
     * 当前页
     */
    private Integer pageNo;
}

2.2 前端的数据结构

let params = {
    key: '111',
    value: '222',
    chList: [{
        img: 'base64图片',
        pageSize: 10,
        pageNo: 1
    }, {
        img: 'base64图片2',
        pageSize: 10,
        pageNo: 2
    }]
}

三、前端数据发送

首先为什么要用js创建form表单的方法请求,因为我们要下载word文件,如果使用ajax则下载下的是文件流,需要进行转换,作者曾使用ajax然后下载转blob类型下载word文件,但是这种方式下载后的word文件内容错误,无法打开,所以使用form表单post的方式下载。

然后要把数据组装为2.2.2的那种数据结构

3.1 base64图片的获取

作者生成图片使用的是echarts,具体方法为:

      /**
       * 生成图片
       */
      getImageBase64() {
        return this.$refs.myChart.getImageBase64()
      },

其中myChart为echarts的实例

3.2 组建参数

这个就是postForm方法的第二个接收参数

let params = {
    key: '111',
    value: '222',
    chList: [{
        img: 'base64图片',
        pageSize: 10,
        pageNo: 1
    }, {
        img: 'base64图片2',
        pageSize: 10,
        pageNo: 2
    }]
}

3.3 创建form表单

   	 /**
       * 模拟post请求
       *  url: 请求地址
       *  params:参数
       */
      postForm(url, params) {
        // 创建form元素
        let temp_form = document.createElement('form')
        // 设置form属性
        temp_form.action = url
        temp_form.target = '_self'
        temp_form.method = 'post'
        temp_form.style.display = 'none'
        // 处理需要传递的参数
        for (let x in params) {
          if (params.hasOwnProperty(x)) {
            // 判断是否为数组,动态生成form数组实例
            if (x === 'chList') {
              for (let i = 0; i < params[x].length; i++) {
                for (let y in params[x][i]) {
                  let opt = document.createElement('textarea')
                  opt.name = 'chList[' + i + '].' + y
                  // 替换null和undefined为空字符串
                  opt.value = params[x][i][y] ? params[x][i][y] : ''
                  temp_form.appendChild(opt)
                }
              }
            } else {
              let opt = document.createElement('textarea')
              opt.name = x
              // 替换null和undefined为空字符串
              opt.value = params[x] ? params[x] : ''
              temp_form.appendChild(opt)
            }
          }
        }

        document.body.appendChild(temp_form)
        // 提交表单
        temp_form.submit()
        // 移除表单
        document.body.removeChild(temp_form)
      },

以上代码组建的form大致为:

    <form action="xxx" method="post">
      <input name="key"/>
      <input name="value"/>
      <input name="chList[0].img"/>
      <input name="chList[0].pageSize"/>
      <input name="chList[0].pageNo"/>
      <input name="chList[1].img"/>
      <input name="chList[1].pageSize"/>
      <input name="chList[1].pageNo"/>
      <input name="chList[2].img"/>
      <input name="chList[2].pageSize"/>
      <input name="chList[2].pageNo"/>
    </form>

关于form中如何提交数组的知识点:数组属性名称[索引].属性值

其中数组中的属性和TestChildrenVo中的属性相对应

注意,如果某一个属性为undefined,后台接收到的值为字符串的undefined,所以所有的参数不能为null或者undefined,可以为空字符串。

四、后端处理

POI版本

 		<!--数据导出excel-->
        <!-- https://mvnrepository.com/artifact/org.apache.poi/poi -->
        <dependency>
            <groupId>org.apache.poi</groupId>
            <artifactId>poi</artifactId>
            <version>3.17</version>
        </dependency>
        <!-- https://mvnrepository.com/artifact/org.apache.poi/poi-ooxml -->
        <dependency>
            <groupId>org.apache.poi</groupId>
            <artifactId>poi-ooxml</artifactId>
            <version>3.17</version>
        </dependency>
        <!-- https://mvnrepository.com/artifact/org.apache.poi/poi-ooxml-schemas -->
        <dependency>
            <groupId>org.apache.poi</groupId>
            <artifactId>poi-ooxml-schemas</artifactId>
            <version>3.17</version>
        </dependency>

4.1 数据接收

TestVo详见2.2.1数据结构

关于form提交数据的一些看法:本案例因为参数数据量较大,无法使用get请求,如果请求接口传递的参数不多并且数据量不是很大,则完全可以使用get请求传递参数,具体方式为:

location.href = url + '?token=' + this.token + '&参数key=参数value'

其中token用于该接口的权限验证,需要后台手动验证,如果不需要验证则可以忽略。

后端数据接收方法:

  	/**
     * word报告导出
     */
    @PostMapping(value = "/export-word")
    public void export(TestVo testVo,
                       HttpServletResponse response) throws Exception {
        System.out.println(testVo.toString());
    }

4.2 创建word文件

  		XWPFDocument doc = new XWPFDocument();// 创建Word文件

        // 标题
        XWPFParagraph p = doc.createParagraph();// 新建段落
        p.setAlignment(ParagraphAlignment.CENTER);// 设置段落的对齐方式
        XWPFRun r = p.createRun();//创建标题
        r.setText("我是标题");
        r.setBold(true);//设置为粗体
        r.setColor("000000");//设置颜色
        r.setFontSize(21); //设置字体大小
        r.addCarriageReturn();//回车换行

        // 段落
        createParagraph(doc, "段落一");
        createParagraph(doc, "段落一");
        createParagraph(doc, "段落二");


        List<TestChildrenVo> imgs = testVo.getChList();
        for (TestChildrenVo img : imgs) {

            // 插入图片
            XWPFParagraph pImg = doc.createParagraph();
            pImg.setAlignment(ParagraphAlignment.CENTER);
            XWPFRun rImg = pImg.createRun();//创建标题
            rImg.addCarriageReturn();//回车换行
            String imgData = img.getImg().substring(img.getImg().indexOf(",") + 1);
            // 转为二进制
            byte[] bytes = new BASE64Decoder().decodeBuffer(imgData);
            // 向段落中插入图片
            rImg.addPicture(new ByteArrayInputStream(bytes), Document.PICTURE_TYPE_PNG, "123.png", Units.toEMU(400), Units.toEMU(180));

            // 表格
            XWPFTable table = doc.createTable(imgs.size() + 1, 2);
            //列宽自动分割
            CTTblWidth infoTableWidth = table.getCTTbl().addNewTblPr().addNewTblW();
            infoTableWidth.setType(STTblWidth.DXA);
            infoTableWidth.setW(BigInteger.valueOf(9072));

            setTableFonts(table.getRow(0).getCell(0), "当前页");
            setTableFonts(table.getRow(0).getCell(1), "分页大小");

            for (int i = 1; i <= imgs.size(); i++) {

                setTableFonts(table.getRow(i).getCell(0), imgs.get(i - 1).getPageNo() + "");
                setTableFonts(table.getRow(i).getCell(1), imgs.get(i - 1).getPageSize() + "");

            }
        }

其中 String imgData = img.getImg().substring(img.getImg().indexOf(",") + 1);这段代码为了截取base64的数据后半段,data:image/png;base64,这些数据被截取掉,看下图:

微信截图_20210325145917.png

4.3 文件下载

      String fileName = "word导出测试_" + System.currentTimeMillis() + ".doc";
        String fileNameURL = URLEncoder.encode(fileName, "UTF-8");
        response.setCharacterEncoding("UTF-8");
        response.setHeader("Content-disposition", "attachment;filename=" + fileNameURL + ";" + "filename*=utf-8''" + fileNameURL);
        response.setContentType("application/octet-stream");
        //刷新缓冲
        response.flushBuffer();
        OutputStream ouputStream = response.getOutputStream();
        doc.write(ouputStream);
        ouputStream.flush();
        ouputStream.close();

4.4 word文件展示

微信截图_20210325150710.png

微信截图_20210325150733.png

4.5 代码合并


import lombok.extern.slf4j.Slf4j;
import org.apache.poi.util.Units;
import org.apache.poi.xwpf.usermodel.*;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.*;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import sun.misc.BASE64Decoder;

import javax.servlet.http.HttpServletResponse;
import java.io.ByteArrayInputStream;
import java.io.OutputStream;
import java.math.BigInteger;
import java.net.URLEncoder;
import java.util.List;

@Slf4j
@RestController
@RequestMapping("/test")
public class WordExportTest {

    /**
     * word报告导出
     */
    @PostMapping(value = "/export-word")
    public void export(TestVo testVo,
                       HttpServletResponse response) throws Exception {

        System.out.println(testVo.toString());
        XWPFDocument doc = new XWPFDocument();// 创建Word文件

        // 标题
        XWPFParagraph p = doc.createParagraph();// 新建段落
        p.setAlignment(ParagraphAlignment.CENTER);// 设置段落的对齐方式
        XWPFRun r = p.createRun();//创建标题
        r.setText("我是标题");
        r.setBold(true);//设置为粗体
        r.setColor("000000");//设置颜色
        r.setFontSize(21); //设置字体大小
        r.addCarriageReturn();//回车换行

        // 段落
        createParagraph(doc, "段落一");
        createParagraph(doc, "段落一");
        createParagraph(doc, "段落二");


        List<TestChildrenVo> imgs = testVo.getChList();
        for (TestChildrenVo img : imgs) {

            // 插入图片
            XWPFParagraph pImg = doc.createParagraph();
            pImg.setAlignment(ParagraphAlignment.CENTER);
            XWPFRun rImg = pImg.createRun();//创建标题
            rImg.addCarriageReturn();//回车换行
            String imgData = img.getImg().substring(img.getImg().indexOf(",") + 1);
            byte[] bytes = new BASE64Decoder().decodeBuffer(imgData);
            rImg.addPicture(new ByteArrayInputStream(bytes), Document.PICTURE_TYPE_PNG, "123.png", Units.toEMU(400), Units.toEMU(180));


            // 表格
            XWPFTable table = doc.createTable(imgs.size() + 1, 2);
            //列宽自动分割
            CTTblWidth infoTableWidth = table.getCTTbl().addNewTblPr().addNewTblW();
            infoTableWidth.setType(STTblWidth.DXA);
            infoTableWidth.setW(BigInteger.valueOf(9072));

            setTableFonts(table.getRow(0).getCell(0), "当前页");
            setTableFonts(table.getRow(0).getCell(1), "分页大小");

            for (int i = 1; i <= imgs.size(); i++) {

                setTableFonts(table.getRow(i).getCell(0), imgs.get(i - 1).getPageNo() + "");
                setTableFonts(table.getRow(i).getCell(1), imgs.get(i - 1).getPageSize() + "");

            }
        }

        String fileName = "word导出测试_" + System.currentTimeMillis() + ".doc";
        String fileNameURL = URLEncoder.encode(fileName, "UTF-8");
        response.setCharacterEncoding("UTF-8");
        response.setHeader("Content-disposition", "attachment;filename=" + fileNameURL + ";" + "filename*=utf-8''" + fileNameURL);
        response.setContentType("application/octet-stream");
        //刷新缓冲
        response.flushBuffer();
        OutputStream ouputStream = response.getOutputStream();
        doc.write(ouputStream);
        ouputStream.flush();
        ouputStream.close();
    }


    /**
     * 创建段落
     *
     * @param text
     */
    private void createParagraph(XWPFDocument doc, String text) {
        XWPFParagraph paragraph = doc.createParagraph();// 新建段落
        //paragraph.setAlignment(ParagraphAlignment.LEFT);// 设置段落的对齐方式
        paragraph.setFontAlignment(1);//字体对齐方式:1左对齐 2居中3右对齐
        XWPFRun run = paragraph.createRun();//创建标题
        run.setText(text);
        run.setColor("000000");//设置颜色
        run.setFontSize(14); //设置字体大小
        run.addCarriageReturn();//回车换行
    }

    /**
     * 设置表格中字体
     *
     * @param cell
     * @param cellText
     */
    private static void setTableFonts(XWPFTableCell cell, String cellText) {
        CTP ctp = CTP.Factory.newInstance();
        XWPFParagraph p = new XWPFParagraph(ctp, cell);
        p.setAlignment(ParagraphAlignment.CENTER);
        XWPFRun run = p.createRun();
        run.setFontSize(14);
        run.setText(cellText);
        CTRPr rpr = run.getCTR().isSetRPr() ? run.getCTR().getRPr() : run.getCTR().addNewRPr();
        CTFonts fonts = rpr.isSetRFonts() ? rpr.getRFonts() : rpr.addNewRFonts();
        fonts.setAscii("仿宋");
        fonts.setEastAsia("仿宋");
        fonts.setHAnsi("仿宋");

        cell.setParagraph(p);
    }

}

五、接口权限校验

SpringBoot后端权限校验使用的是jwt,需要在请求头中添加token;但是form表单无法添加请求头,则token要添加到post的参数中,然后在后端接收token进行手动校验用户是否登陆或者其他权限。如果该接口不需要进行认证,则可以直接放行。