动态报表

BaskReport的模板文件是以xml存储到数据库的,实现动态报表的关键是在预览报表的时候我们可以提供自定义的xml给报表渲染器,让报表根据我们提供的模板和数据展示报表。

实现步骤

  1. 实现自定义FileLoader扩展loadContent方法
  2. 拦截默认的报表预览请求,并将需要实现动态报表的报表文件的缓存关闭
  3. 创建模板信息实体类和工具类
  4. 创建前端页面用于配置和提交动态报表

一、实现自定义的FileLoader

如类:com.basksoft.baskreport.demo.dynaview.MockFileLoader

将该类通过SPI配置文件配置到项目中: FileLoader

然后定义这个类,继承BaskServer默认提供的com.basksoft.core.database.service.file.DefaultFileLoader

public class MockFileLoader extends DefaultFileLoader {
    /**
     * 加载文件内容
     * 
     * 优先使用动态模板生成XML内容,如果没有动态模板则使用默认的文件内容
     * 
     * @param fileId 文件ID
     * @return 文件内容(XML格式)
     */
    public String loadContent(long fileId) {
        String content = super.loadContent(fileId);

        // 检查是否有动态模板需要处理
        TemplateInfo template = TemplateHolder.getTemplate();
        if (template != null) {
            try {
                content = TemplateUtils.buildDynaViewTemplate(
                    template.getTitle(), 
                    template.getFields(), 
                    template.getDatas()
                );
            } catch (Exception e) {
                // 发生异常时返回原始内容,避免报表完全不可用
                logger.error("生成动态报表XML失败: fileId={}", fileId, e);
            }
        }

        return content;
    }
}

关键特性

  • 日志记录:使用SLF4J记录调试和错误信息
  • 异常处理:生成XML失败时返回原始内容,确保报表可用性
  • 回退机制:没有动态模板时使用默认文件内容

二、拦截默认的分页预览

如类:com.basksoft.baskreport.demo.dynaview.MockPageServletHandler

将该类通过SPI配置文件配置到项目中: ServletHandler

然后定义这个类,继承BaskServer默认提供的com.basksoft.report.console.report.preview.PageServletHandler

public class MockPageServletHandler extends PageServletHandler {
    private static final Logger logger = LoggerFactory.getLogger(MockPageServletHandler.class);

    // 动态报表的文件ID,可根据实际需求配置
    private static final long DYNAMIC_REPORT_FILE_ID = 52001;

    @Override
    @BaskAuthFile()
    public void execute(BaskServletRequestWrapper req, BaskServletResponseWrapper resp) {
        FileIdentity identity = ContextHolder.getFile();

        try {
            long fileId = identity.getId();

            // 判断是否为动态报表文件
            if (fileId == DYNAMIC_REPORT_FILE_ID) {
                handleDynamicReport(identity, req);
            }

            // 执行默认的预览逻辑
            super.execute(req, resp);

        } catch (Exception e) {
            logger.error("报表预览处理失败: fileId={}", 
                identity != null ? identity.getId() : "unknown", e);
            throw new RuntimeException("报表预览处理失败", e);
        } finally {
            // 清理ThreadLocal,避免内存泄漏
            try {
                TemplateHolder.clear();
                logger.debug("清理ThreadLocal完成");
            } catch (Exception e) {
                logger.error("清理ThreadLocal失败", e);
            }
        }
    }

    /**
     * 处理动态报表的特殊逻辑
     */
    private void handleDynamicReport(FileIdentity identity, BaskServletRequestWrapper req) {
        // 禁用缓存:设置修改时间为当前时间
        BaskFile baskFile = identity.getBaskFile();
        if (baskFile != null) {
            baskFile.setModifyDate(new Date());
        }

        // 从请求参数中解析动态报表配置
        String parameters = req.getParameter("parameters");
        if (StringUtils.isNotBlank(parameters)) {
            parseAndSetTemplate(parameters);
        }
    }

    /**
     * 解析并设置模板信息
     */
    private void parseAndSetTemplate(String parameters) {
        // URL解码
        String decodedParameters = URLDecoder.decode(parameters, "UTF-8");

        // 反序列化为TemplateInfo对象
        TemplateInfo templateInfo = JacksonUtils.getObjectMapper()
                .readValue(decodedParameters, TemplateInfo.class);

        // 参数验证
        validateTemplateInfo(templateInfo);

        // 存入ThreadLocal
        TemplateHolder.setTemplate(templateInfo);
    }

    /**
     * 验证模板信息的有效性
     */
    private void validateTemplateInfo(TemplateInfo templateInfo) {
        if (templateInfo == null) {
            throw new IllegalArgumentException("模板信息不能为空");
        }
        if (StringUtils.isBlank(templateInfo.getTitle())) {
            throw new IllegalArgumentException("报表标题不能为空");
        }
        if (templateInfo.getFields() == null || templateInfo.getFields().isEmpty()) {
            throw new IllegalArgumentException("字段列表不能为空");
        }
        if (templateInfo.getDatas() == null) {
            throw new IllegalArgumentException("数据列表不能为null");
        }
        // 验证字段配置
        for (int i = 0; i < templateInfo.getFields().size(); i++) {
            Map<String, Object> field = templateInfo.getFields().get(i);
            String fieldName = (String) field.get("name");
            if (StringUtils.isBlank(fieldName)) {
                throw new IllegalArgumentException("第" + (i + 1) + "个字段的name不能为空");
            }
        }
    }
}

关键特性

  • 模块化设计:将大方法拆分为多个私有方法,提高可维护性
  • 常量提取:将动态报表文件ID提取为常量,便于配置
  • 参数验证:完整的输入参数验证,提供友好的错误提示
  • 日志记录:详细的日志记录,便于问题排查
  • 异常处理:完善的异常处理机制
  • 资源清理:确保ThreadLocal在finally块中被清理

三、创建模板信息实体类

创建TemplateInfo类来存储动态报表的配置信息:

/**
 * 动态报表模板信息实体类
 * 
 * 功能说明:
 * 1. 封装动态报表的配置信息,包括标题、字段定义和数据
 * 2. 提供getter和setter方法用于属性访问
 * 3. 支持序列化和反序列化(通过JSON)
 */
public class TemplateInfo {

    /**
     * 报表标题
     * 显示在报表第一行
     */
    private String title;

    /**
     * 字段列表
     * 每个字段包含以下属性:
     * - name: 字段名称(数据字段名)
     * - label: 字段标签(表头显示名称)
     * - type: 字段类型(string/number/boolean)
     * - width: 列宽度(像素)
     */
    private List<Map<String, Object>> fields;

    /**
     * 数据列表
     * 报表要展示的数据,每个元素代表一行数据
     */
    private List<Map<String, Object>> datas;

    // getter和setter方法
    public String getTitle() { return title; }
    public void setTitle(String title) { this.title = title; }

    public List<Map<String, Object>> getFields() { return fields; }
    public void setFields(List<Map<String, Object>> fields) { this.fields = fields; }

    public List<Map<String, Object>> getDatas() { return datas; }
    public void setDatas(List<Map<String, Object>> datas) { this.datas = datas; }

    @Override
    public String toString() {
        return "TemplateInfo{" +
                "title='" + title + '\'' +
                ", fields=" + (fields != null ? fields.size() : 0) + " items" +
                ", datas=" + (datas != null ? datas.size() : 0) + " items" +
                '}';
    }
}

关键特性

  • 完整注释:详细的类和属性注释,说明每个字段的用途
  • toString方法:便于调试和日志输出
  • 序列化支持:支持JSON序列化和反序列化

四、创建ThreadLocal模板持有器

创建TemplateHolder类,使用ThreadLocal在当前线程中存储模板信息:

/**
 * 动态报表模板持有器
 * 
 * 功能说明:
 * 1. 使用ThreadLocal在当前线程中存储动态报表模板信息
 * 2. 在整个请求处理周期中传递模板信息,从请求拦截到模板生成
 * 3. 提供模板信息的设置、获取和清理方法
 * 4. 确保在请求结束后清理ThreadLocal,避免内存泄漏
 * 
 * ThreadLocal使用场景:
 * - 请求到达时,MockPageServletHandler解析参数并设置模板
 * - 文件加载时,MockFileLoader从ThreadLocal获取模板生成XML
 * - 请求结束时,清理ThreadLocal释放内存
 */
public class TemplateHolder {
    private static final Logger logger = LoggerFactory.getLogger(TemplateHolder.class);

    /**
     * 使用ThreadLocal存储模板信息,确保线程安全
     */
    private static final ThreadLocal<TemplateInfo> template = new ThreadLocal<>();

    /**
     * 设置当前线程的模板信息
     * 
     * 通常在请求拦截阶段调用,将解析后的模板信息存入ThreadLocal
     * 
     * @param templateInfo 模板信息对象
     */
    public static void setTemplate(TemplateInfo templateInfo) {
        if (templateInfo == null) {
            logger.warn("尝试设置null模板信息,操作已忽略");
            return;
        }
        template.set(templateInfo);
        logger.debug("模板信息已设置到ThreadLocal: {}", templateInfo);
    }

    /**
     * 获取当前线程的模板信息
     * 
     * 通常在文件加载阶段调用,从ThreadLocal获取模板信息用于生成XML
     * 
     * @return 当前线程的模板信息,如果未设置则返回null
     */
    public static TemplateInfo getTemplate() {
        TemplateInfo templateInfo = template.get();
        if (templateInfo != null) {
            logger.debug("从ThreadLocal获取模板信息: {}", templateInfo);
        }
        return templateInfo;
    }

    /**
     * 清理当前线程的模板信息
     * 
     * 必须在finally块中调用,确保请求结束后清理ThreadLocal
     * 避免线程复用时导致的数据污染和内存泄漏
     * 
     * 建议在MockPageServletHandler的finally块中调用
     */
    public static void clear(){
        try {
            template.remove();
            logger.debug("ThreadLocal模板信息已清理");
        } catch (Exception e) {
            logger.error("清理ThreadLocal模板信息失败", e);
        }
    }
}

关键特性

  • null检查:防止设置null值导致的问题
  • 日志记录:记录模板的设置、获取和清理操作
  • 异常处理:清理操作的异常处理
  • 使用场景说明:详细说明了ThreadLocal的使用时机

五、创建XML模板工具类

创建TemplateUtils类,用于生成动态报表的XML模板。该类包含以下核心方法:

/**
 * 动态报表XML模板工具类
 * 
 * 功能说明:
 * 1. 根据报表标题、字段列表和数据列表生成BaskReport规范的XML模板
 * 2. 支持动态生成表格结构,包括标题行、列头、数据行
 * 3. 自动生成Excel风格的列名(A, B, C, ... AA, AB, ...)
 * 4. 提供参数验证和异常处理
 * 5. 内置预览配置和导出功能配置
 */
public class TemplateUtils {
    private static final Logger logger = LoggerFactory.getLogger(TemplateUtils.class);

    // 默认配置常量
    private static final String DEFAULT_DATASET_NAME = "employee";
    private static final int DEFAULT_TITLE_HEIGHT = 42;
    private static final int DEFAULT_ROW_HEIGHT = 20;
    private static final int DEFAULT_MIN_COL_WIDTH = 20;
    private static final int DEFAULT_MAX_COL_WIDTH = 500;

    /**
     * 构建动态报表XML模板
     * 
     * @param title 报表标题
     * @param fieldList 字段列表,每个字段包含name、label、type、width属性
     * @param dataList 数据列表
     * @return 完整的XML报表模板
     * @throws IllegalArgumentException 参数验证失败时抛出
     */
    public static String buildDynaViewTemplate(String title, 
                                               List<Map<String, Object>> fieldList, 
                                               List<Map<String, Object>> dataList) {
        // 参数验证
        validateParameters(title, fieldList, dataList);

        logger.info("开始构建动态报表XML模板: title={}, fields={}, datas={}", 
                title, fieldList.size(), dataList != null ? dataList.size() : 0);

        StringBuilder sb = new StringBuilder();

        // XML头部
        sb.append(XML_HEADER);
        sb.append(XML_ROOT);

        // 标题行
        sb.append(buildHeaderTemplate(title, fieldList.size()));
        // 标题行空白单元格(用于隐藏标题行的后续单元格)
        for (int i = 1; i < fieldList.size(); i++) {
            String colName = buildColName(i + 1) + "1";
            sb.append(buildHeaderBlankTemplate(i + 1, colName));
        }

        // 列头行
        for (int i = 0; i < fieldList.size(); i++) {
            String colName = buildColName(i + 1) + "2";
            sb.append(buildColumnHeaderTemplate(i + 1, colName, fieldList.get(i).get("label").toString()));
        }

        // 数据行
        for (int i = 0; i < fieldList.size(); i++) {
            String colName = buildColName(i + 1) + "3";
            sb.append(buildColumnDataTemplate(i + 1, colName, DEFAULT_DATASET_NAME, fieldList.get(i).get("name").toString()));
        }

        // 列配置
        for (int i = 0; i < fieldList.size(); i++) {
            int width = getValidColWidth(fieldList.get(i).get("width"));
            sb.append(buildColTemplate(i + 1, width));
        }

        // 行配置
        sb.append(ROW_HEADER_TEMPLATE);
        sb.append(buildRowTemplate(2));
        sb.append(buildRowTemplate(3));

        // 数据集
        sb.append(buildDataset(fieldList, dataList));

        // 设置
        sb.append(SETTING_TEMPLATE);

        // XML尾部
        sb.append(XML_FOOTER);

        logger.info("动态报表XML模板构建完成,长度: {} 字符", sb.length());
        return sb.toString();
    }

    /**
     * 验证输入参数
     */
    private static void validateParameters(String title, List<Map<String, Object>> fieldList, 
                                           List<Map<String, Object>> dataList) {
        if (StringUtils.isBlank(title)) {
            throw new IllegalArgumentException("报表标题不能为空");
        }
        if (fieldList == null || fieldList.isEmpty()) {
            throw new IllegalArgumentException("字段列表不能为空");
        }
        if (dataList == null) {
            throw new IllegalArgumentException("数据列表不能为null");
        }
        // 验证字段配置
        for (int i = 0; i < fieldList.size(); i++) {
            Map<String, Object> field = fieldList.get(i);
            if (StringUtils.isBlank((String) field.get("name"))) {
                throw new IllegalArgumentException("第" + (i + 1) + "个字段的name不能为空");
            }
            if (StringUtils.isBlank((String) field.get("label"))) {
                throw new IllegalArgumentException("第" + (i + 1) + "个字段的label不能为空");
            }
            if (StringUtils.isBlank((String) field.get("type"))) {
                throw new IllegalArgumentException("第" + (i + 1) + "个字段的type不能为空");
            }
        }
    }

    /**
     * 获取有效的列宽度
     */
    private static int getValidColWidth(Object widthObj) {
        if (widthObj == null) {
            return DEFAULT_MIN_COL_WIDTH;
        }
        try {
            int width = Integer.parseInt(widthObj.toString());
            return Math.max(DEFAULT_MIN_COL_WIDTH, Math.min(DEFAULT_MAX_COL_WIDTH, width));
        } catch (NumberFormatException e) {
            logger.warn("列宽度格式错误: {}, 使用默认值: {}", widthObj, DEFAULT_MIN_COL_WIDTH);
            return DEFAULT_MIN_COL_WIDTH;
        }
    }

    /**
     * 构建数据集部分
     */
    private static String buildDataset(List<Map<String, Object>> fieldList, List<Map<String, Object>> dataList) {
        // 构建字段定义
        StringBuilder fields = new StringBuilder();
        for (Map<String, Object> field : fieldList) {
            fields.append(buildFieldTemplate(
                field.get("name").toString(),
                field.get("label").toString(),
                field.get("type").toString()
            ));
        }

        // 序列化数据
        String data = "[]";
        try {
            data = JacksonUtils.getObjectMapper().writeValueAsString(dataList);
        } catch (JsonProcessingException e) {
            logger.error("数据序列化失败", e);
        }

        return buildDatasetTemplate(DEFAULT_DATASET_NAME, data, fields.toString());
    }

    // 私有辅助方法
    private static String buildColName(int colNum) {
        // 根据colNum生成excel风格的列名称 (1->A, 26->Z, 27->AA, ...)
        if (colNum <= 0) return "";
        StringBuilder sb = new StringBuilder();
        int num = colNum;
        while (num > 0) {
            num--; // 转换为0-based索引
            int remainder = num % 26;
            sb.insert(0, (char) ('A' + remainder));
            num /= 26;
        }
        return sb.toString();
    }

    /**
     * XML特殊字符转义
     */
    private static String escapeXml(String text) {
        if (text == null) return "";
        return text.replace("&", "&amp;")
                   .replace("<", "&lt;")
                   .replace(">", "&gt;")
                   .replace("\"", "&quot;")
                   .replace("'", "&apos;");
    }
}

buildDynaViewTemplate方法生成的XML结构包含:

  • XML头部和根节点
  • 标题行单元格(使用colspan合并单元格)
  • 标题行空白单元格(隐藏多余的单元格)
  • 列头单元格(显示字段标签)
  • 数据单元格(使用expand="down"实现向下扩展)
  • 列配置(宽度,范围20-500像素)
  • 行配置(高度,标题行42px,数据行20px)
  • 内置数据集(包含字段定义和JSON数据)
  • 页面设置和预览配置(支持导出为Excel、Word、PDF、CSV)

关键特性

  • 参数验证:完整的输入参数验证
  • 列宽度验证:自动修正超出范围的列宽度
  • XML转义:自动转义XML特殊字符
  • 异常处理:数据序列化失败的异常处理
  • 日志记录:详细的日志记录
  • 常量管理:使用常量管理默认配置

六、创建前端配置页面

创建HTML页面(如dynareport.html),用于配置动态报表的字段和数据。页面功能包括:

1. 报表配置

Report Config

配置报表的基本信息:

  • Tenant ID(租户ID):指定报表所属的租户,例如:basksoft
  • File ID(文件ID):指定报表文件的ID,例如:15501
  • Report Title(报表标题):设置报表的显示标题,例如:动态列示例报表

这些配置会在表单提交时作为URL参数传递给预览接口。

2. 字段列表管理

在字段列表表格中可以:

  • 添加字段:点击"Add Field"按钮添加新的字段行
  • 删除字段:点击每行的"Remove"按钮删除该字段
  • 编辑字段属性
    • Name:字段名称(数据库字段名,如:id, username, active)
    • Label:字段标签(显示在报表表头的名称,如:ID, Username, Active)
    • Type:字段类型(支持:String、Number、Boolean)
    • Width:列宽度(单位:像素,范围:20-500)
  • 生成Mock数据:点击"Regenerate Mock Data"按钮,根据字段配置自动生成测试数据
  • 设置行数:通过"Row Count"输入框控制生成的数据行数

3. 数据编辑器

  • JSON格式编辑:在文本框中直接编辑JSON格式的报表数据
  • 自动格式化:数据自动格式化为缩进的JSON格式,便于阅读和编辑
  • 手动修改:可以手动编辑数据,系统会在提交时验证JSON格式

4. 提交预览

点击"Preview & Save"按钮:

  • 将配置信息(标题、字段列表、数据)打包为JSON对象
  • 将JSON对象进行URL编码作为parameters参数
  • 通过表单POST提交到预览接口
  • 提交URL格式:/baskserver/baskreport/preview?file=${fileId}&tenantId=${tenantId}
  • 在新窗口中打开报表预览页面

5. 核心JavaScript功能

// 状态管理
let fields = [];           // 字段列表
let reportConfig = {       // 报表配置
    fileId: 15501,
    tenantId: 'basksoft',
    title: '动态列示例报表'
};

// 生成Mock数据
function generateMock() {
    const rowCount = parseInt(document.getElementById('rowCount').value) || 5;
    const mockData = [];
    for (let i = 0; i < rowCount; i++) {
        const row = {};
        fields.forEach(field => {
            if (field.type === 'number') {
                row[field.name] = Mock.Random.integer(1, 1000);
            } else if (field.type === 'boolean') {
                row[field.name] = Mock.Random.boolean();
            } else {
                row[field.name] = Mock.Random.string(5, 10);
            }
        });
        mockData.push(row);
    }
    document.getElementById('jsonEditor').value = JSON.stringify(mockData, null, 2);
}

// 保存并预览
function saveData(event) {
    event.preventDefault();
    const jsonContent = document.getElementById('jsonEditor').value;
    let parsedData = JSON.parse(jsonContent);

    const requestData = {
        title: reportConfig.title,
        fields: fields,
        datas: parsedData
    };

    const parameters = encodeURIComponent(JSON.stringify(requestData));
    document.getElementById('parameters').value = parameters;

    const form = document.getElementById('reportForm');
    form.action = `/baskserver/baskreport/preview?file=${reportConfig.fileId}&tenantId=${reportConfig.tenantId}`;
    form.submit();
}

七、完整的数据流程

  1. 前端配置:用户通过HTML页面配置报表标题、字段列表和数据
  2. 提交请求:表单提交到/baskserver/baskreport/preview,携带parameters参数(JSON格式)
  3. 拦截请求MockPageServletHandler拦截请求
    • 判断是否为动态报表文件(通过fileId判断)
    • 设置BaskFile的修改时间为当前时间,禁用缓存
    • 解析parameters参数(URL解码 + JSON反序列化)
    • 验证参数有效性
    • 创建TemplateInfo对象并存入ThreadLocal
  4. 加载模板MockFileLoaderloadContent方法被调用
    • ThreadLocal获取模板信息
    • 调用TemplateUtils.buildDynaViewTemplate生成XML
    • 如果生成失败,返回原始内容(回退机制)
  5. 生成XMLTemplateUtils.buildDynaViewTemplate方法执行
    • 验证输入参数
    • 构建XML头部和根节点
    • 生成标题行、列头行、数据行
    • 配置列宽度和行高度
    • 构建数据集(字段定义 + JSON数据)
    • 添加页面设置和预览配置
  6. 渲染报表:报表渲染器使用生成的XML和数据进行渲染
  7. 清理资源:在finally块中清理ThreadLocal,避免内存泄漏

流程图

前端页面
    ↓ 提交配置
MockPageServletHandler (拦截)
    ↓ 解析参数
TemplateHolder (存入ThreadLocal)
    ↓ 禁用缓存
MockFileLoader (加载文件)
    ↓ 获取模板
TemplateUtils (生成XML)
    ↓ 生成模板
报表渲染器 (渲染)
    ↓ 清理资源
TemplateHolder (清理ThreadLocal)

八、关键要点

  1. ThreadLocal使用

    • 用于在当前线程的请求处理过程中传递模板信息
    • 请求开始时设置,请求结束时清理
    • 必须在finally块中清理,避免内存泄漏
  2. 缓存控制

    • 通过设置BaskFile的修改时间来禁用缓存
    • 确保每次都重新加载模板,使用最新的动态配置
  3. SPI配置

    • 通过SPI机制注册自定义的FileLoader和PageServletHandler
    • 配置文件路径:META-INF/services/com.basksoft.core.database.service.file.FileLoader
    • 配置文件路径:META-INF/services/com.basksoft.report.console.report.preview.PageServletHandler
  4. XML生成

    • 动态生成符合BaskReport规范的XML模板
    • 支持参数验证和异常处理
    • 提供回退机制,确保报表可用性
  5. 资源清理

    • 必须在finally块中清理ThreadLocal
    • 避免线程复用时导致的数据污染和内存泄漏
    • 清理操作也要进行异常处理
  6. 数据传递

    • 通过URL参数传递JSON格式的配置信息
    • 参数需要进行URL编码和解码
    • 支持JSON序列化和反序列化
  7. 异常处理

    • 所有关键操作都有异常处理
    • 提供友好的错误提示信息
    • 记录详细的日志便于问题排查
  8. 日志记录

    • 使用SLF4J进行日志记录
    • 记录关键操作和异常信息
    • 便于调试和问题排查
  9. 参数验证

    • 完整的输入参数验证
    • 提供友好的验证错误提示
    • 确保数据的有效性和完整性
  10. 常量管理

    • 使用常量管理配置值
    • 便于统一管理和修改
    • 提高代码可维护性

results matching ""

    No results matching ""