基于 POI 实现一个 Excel 模板引擎

0. 效果预览

新建一个 excel 文档template.xlsx,作为模板:

template.png

用于生成 excel 文件的数据:

        Map<String, Object> data = new HashMap<>();
        Map<String, Object> cls = new HashMap<>();
        data.put("cls", cls);
        cls.put("headmaster", "李景文");
        cls.put("type", "文科班");
        List<Stu> stus = new ArrayList<>();
        Random random = new Random();
        for (int i = 0; i < 10; i++) {
            Stu stu = new Stu();
            stu.code = String.format("1490001%02d", i + 1);
            stu.name = String.format("%s%s", fName[Math.abs(random.nextInt()) % fName.length], sName[Math.abs(random.nextInt()) % sName.length]);
            stu.gender = String.format("%s", Math.abs(random.nextInt()) % 2 == 0 ? "男" : "女");
            stu.age = Math.abs(random.nextInt()) % 10 + 10;
            stu.phone = String.format("%s%s", "150", Math.abs(random.nextInt()) % 89999999 + 10000000);
            stu.donation = (int) (random.nextDouble() * 10);
            stus.add(stu);
        }
        cls.put("students", stus);

        FileOutputStream fos = new FileOutputStream(new File("template_ins.xlsx"));
        String templatePath = "template.xlsx";
        //根据模板 templatePath 和数据 data 生成 excel 文件,写入 fos 流
        ExcelTemplateUtils.process(data, templatePath, fos);

模板生成效果(数据均随机生成):
Snipaste_2018-08-28_11-32-08.png

实现
这是一个简单的置换型模板引擎,将指定模板内容(Excel文件)中的特定标记(单元格内的字符串)替换一下便生成了最终需要的业务数据。

模板分为5个部分:

  • 1~2行:标题,2018级1班学生捐赠名单
  • 3行:班级信息
  • 4行:表头
  • 5行:学生列表
  • 6~7行:合计
    模板引擎主要完成两个事情:
    (1)非列表字段的置换,例如上图中的${cls.headmaster}, ${cls.students.size()}, ${cls.type}
    (2)列表字段的展开和置换,例如上图中的学生列表,在模板中仅占第5行,而在生成文件中,按照列表长度,展开到5~14行,共十条数据。
    置换标记说明
    (1)非列表字段标记。形如${cls.headmaster},替换规则为:找到对象clsheadmaster属性值,直接替换到当前单元格。
    (2)列表字段标记。形如${cls.students[#].name},其中的[#]标志着对象students是个数组(List),替换规则为:在当前单元格所在行下面插入students.size - 1行,然后在第i个插入行的单元格中填入cls.students[i].name

1. 新建 maven 工程,添加 poi 依赖

<dependencies>
        <dependency>
            <groupId>org.apache.poi</groupId>
            <artifactId>poi-ooxml</artifactId>
            <version>3.15</version>
        </dependency>
    </dependencies>

2. 新建类 ExcelTemplateEngine.java

(1) 创建静态方法 process ,根据模板生成 excel 文件

/**
 * 根据模板生成 excel 文件
 * @param data 数据
 * @param templatePath 模板文件路径
 * @param os 生成 excel 输出流,可保存成文件或返回到前端等
 */
public static void process(Object data, String templatePath, OutputStream os) {
    if (data == null || StringUtil.isEmpty(templatePath)) {
        return;
    }
    try {
        OPCPackage pkg = OPCPackage.open(templatePath);
        XSSFWorkbook wb = new XSSFWorkbook(pkg);
        Iterator<Sheet> iterable = wb.sheetIterator();
        while (iterable.hasNext()) {
            processSheet(data, iterable.next());
        }
        wb.write(os);
        pkg.close();
    } catch (IOException | InvalidFormatException e) {
        e.printStackTrace();
    }
}

因为一个模板中可能有多个工作表(Sheet),所以遍历每一个 sheet,依次进行置换
(2)创建方法 processSheet ,处理单个工作表
处理单个工作表的流程是:
a. 遍历每个有内容的单元格,并获取到单元格的值cellValue
b. 如果 cellValue 不是字符串类型,则跳过这个单元格,处理下一个单元格
c. 如果这个单元格包含非列表型置换标记(形如${cls.headmaster}),直接对该单元格执行置换
d. 如果这个单元格包含列表型置换标记(形如${cls.students[#].name}),将单元格存入 listRecord 中备用
e. 单元格遍历完毕
f. 遍历 listRecord 中存储的单元格(包含列表型置换标记),计算出当前单元格所在行下,需要插入的行数(取决于数组的元素个数,因为一行之中可能存在多个数组,因此要去最大值)并插入;同时记录下当前单元格的样式(列表同一列的样式相同),当前单元格的置换标记(例如cls.students#name,代表这一列取 students 内元素的 name 属性)
此时:已完成非列表型字段的置换,已为列表型字段插入所需行,效果如下:

Snipaste_2018-08-28_17-12-48.png

g. 置换列表。再次遍历 listRecord 中存储的单元格,从当前单元格开始依次向下置换,并应用 f 中存储的样式。

private static void processSheet(Object data, Sheet sheet) {
    Map<Integer, Map<Integer, Cell>> listRecord = new LinkedHashMap<>();
    int lastRowNum = sheet.getLastRowNum();
    for (int i = lastRowNum; i >= 0; i--) {
        Row row = sheet.getRow(i);
        if (row == null) {
            continue;
        }
        int lastCellNum = row.getLastCellNum();
        for (int j = 0; j < lastCellNum; j++) {
            Cell cell = row.getCell(j);
            if (cell == null) {
                continue;
            }
            try {
                String cellValue = cell.getStringCellValue();
                if (cellValue.matches(".*\\$\\{[\\w.()]+}.*")) {
                    fillCell(cell, cellValue, data);
                } else if (cellValue.matches(".*\\$\\{[\\w.]+\\[#][\\w.]+}.*")) {
                    Map<Integer, Cell> rowRecord = listRecord.computeIfAbsent(i, k -> new HashMap<>());
                    rowRecord.put(j, cell);
                }
            } catch (Exception ignored) {
            
            }
        }
    }
    
    Map<String, List> listInData = new HashMap<>();
    Map<String, CellStyle> listCellStyle = new HashMap<>();
    Map<Cell, String> listCellPath = new HashMap<>();
    listRecord.forEach((rowNum, colMap) -> {
        Pattern p = Pattern.compile("\\$\\{[\\w.\\[#\\]]+}");
        Set<String> listPath = new HashSet<>();
        colMap.forEach((colNum, cell) -> {
            String cellValue = cell.getStringCellValue();
            Matcher m = p.matcher(cellValue);
            if (m.find()) {
                String reg = m.group();
                String regPre = reg.substring(2, reg.indexOf("["));
                String regSuf = reg.substring(reg.lastIndexOf("].") + 2, reg.length() - 1);
                listPath.add(regPre);
                listCellStyle.put(String.format("%s.%s", regPre, regSuf), cell.getCellStyle());
                listCellPath.put(cell, String.format("%s#%s", regPre, regSuf));
            }
        });
        int maxRow = 0;
        for (String s : listPath) {
            Object list = getAttributeByPath(data, s);
            if (list == null) {
                list = new ArrayList<>();
            }
            if (list instanceof List) {
                int len = ((List) list).size();
                maxRow = maxRow > len ? maxRow : len;
                listInData.put(s, ((List) list));
            } else {
                throw new IllegalArgumentException(String.format("%s is not a list but a %s", s, list.getClass().getSimpleName()));
            }
        }
        if (maxRow > 1) {
            int endRow = sheet.getLastRowNum();
            sheet.shiftRows(rowNum + 1, endRow + 1, maxRow - 1);
        }
    });
    
    listRecord.forEach((rowNum, colMap) -> {
        colMap.forEach((colNum, cell) -> {
            String path = listCellPath.get(cell);
            String[] pathData = path.split("#");
            List list = listInData.get(pathData[0]);
            int baseRowIndex = cell.getRowIndex();
            int colIndex = cell.getColumnIndex();
            CellStyle style = listCellStyle.get(String.format("%s.%s", pathData[0], pathData[1]));
            for (int i = 0; i < list.size(); i++) {
                int rowIndex = baseRowIndex + i;
                Row row = sheet.getRow(rowIndex);
                if (row == null) {
                    row = sheet.createRow(rowIndex);
                }
                Cell cellToFill = row.getCell(colIndex);
                if (cellToFill == null) {
                    cellToFill = row.createCell(colIndex);
                }
                cellToFill.setCellStyle(style);
                setCellValue(cellToFill, getAttribute(list.get(i), pathData[1]));
            }
        });
    });
}

3. 剩余方法实现

(1)置换单元格 fillCell(Cell, String, Object) .

/**
 * @param cell 要置换的单元格
 * @param expression 单元格内的置换标记
 * @param data 数据源
 */
private static void fillCell(Cell cell, String expression, Object data) {
    Pattern p = Pattern.compile("\\$\\{[\\w.\\[\\]()]+}");
    Matcher m = p.matcher(expression);
    StringBuffer sb = new StringBuffer();
    while (m.find()) {
        String exp = m.group();
        String path = exp.substring(2, exp.length() - 1);
        Object value = getAttributeByPath(data, path);
        m.appendReplacement(sb, value == null ? "" : value.toString());
    }
    setCellValue(cell, sb.toString());
}

(2)给单元格设置值 setCellValue(Cell, Object) .

/**
 * @param cell 单元格
 * @param value 值
 */
private static void setCellValue(Cell cell, Object value) {
    if (value == null) {
        cell.setCellValue("");
    } else if (value instanceof Date) {
        cell.setCellValue((Date) value);
    } else if (value instanceof Integer) {
        cell.setCellValue((Integer) value);
    } else if (value instanceof Long) {
        cell.setCellValue((Long) value);
    } else if (value instanceof Double) {
        cell.setCellValue((Double) value);
    } else if (value instanceof Float) {
        cell.setCellValue((Float) value);
    } else if (value instanceof Character) {
        cell.setCellValue((Character) value);
    } else if (value instanceof BigDecimal) {
        cell.setCellValue(((BigDecimal) value).doubleValue());
    } else {
        cell.setCellValue(value.toString());
    }
}

(3)通过反射获取对象的属性值 getAttributeByPath(Object, String) .

/**
 * 
 * @param obj 访问对象
 * @param path 属性路径,形如(cls.type, cls.students.size())
 * @return
 */
private static Object getAttributeByPath(Object obj, String path) {
    String[] paths = path.split("\\.");
    Object o = obj;
    for (String s : paths) {
        o = getAttribute(o, s);
    }
    return o;
}

private static Object getAttribute(Object obj, String member) {
    if (obj == null) {
        return null;
    }
    boolean isMethod = member.endsWith("()");
    if (!isMethod && obj instanceof Map) {
        return ((Map) obj).get(member);
    }
    try {
        Class<?> cls = obj.getClass();
        if (isMethod) {
            Method method = cls.getDeclaredMethod(member.substring(0, member.length() - 2));
            return method.invoke(obj);
        } else {
            Field field = cls.getDeclaredField(member);
            field.setAccessible(true);
            return field.get(obj);
        }
    } catch (NoSuchFieldException | IllegalAccessException | NoSuchMethodException | InvocationTargetException e) {
        e.printStackTrace();
    }
    return null;
}

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 160,165评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,720评论 1 298
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,849评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,245评论 0 213
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,596评论 3 288
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,747评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,977评论 2 315
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,708评论 0 204
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,448评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,657评论 2 249
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,141评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,493评论 3 258
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,153评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,108评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,890评论 0 198
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,799评论 2 277
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,685评论 2 272

推荐阅读更多精彩内容