DLX Dev Everything Engineer

服务端数据导出到excel:导出无限量的数据的方案

2019-12-11
web

web应用开发(尤其是xxx管理系统)经常遇到这样的需求:将当前列表中的数据导出到excel。往往这种需求很容易实现,但是要是导出的数据量特别大呢?本文介绍一种简单的大量数据导出的实现方案。

效果展示

如何解决大量数据导出内存oom的问题?

使用SXSSFWorkbook解决excel导出大量数据时占用内存大的问题。关键原理是:Streaming version of XSSFWorkbook implementing the “BigGridDemo” strategy. This allows to write very large files without running out of memory as only a configurable portion of the rows are kept in memory at any one time. 另外,服务端可以使用分页式从数据库中查询要导出的数据,而不是一次查询所有要导出的数据,节省内存开销。注意,这时候会不会有可重复读,幻读的问题呢?mysql默认支持的事务级别是可重复读,因此可以解决分页式多批次查询数据的不一致问题,而幻读的问题还是可能存在,但是在导出的应用场景中,往往是导出某一个条件区间的数据(比如过去某一时间区间),因此并发的区间插入导致的幻读问题发生概率较小。 但是分页查询相比一批次查询全部数据性能上会慢很多,分页查询时经常用到的limit语句,及时只查询最后一页的数据但是还是会扫描前面页的数据。 关键代码(使用easypoi):

Workbook workbook = ExcelExportUtil.exportBigExcel(new ExportParams(), Response.class,
new ExcelExportServer(), condition);
workbook.write(response.getOutputStream());

增加进度条功能

一方面防止请求超时,一方面增加了用户体验。

并发控制

增加并发导出席位控制,因为导出大量数据到excel,比较耗cpu,io。

实现

以下代码片段实现了全局的并发席位控制,并且提供了对导出任务的管理: 1、开始导出时,通过调用addExportServer方法将一个导出任务加入到并发席位。 2、导出完成时,通过removeExportServer方法从并发席位移除。 3、导出过程中,可以通过getProgress获取当前session下所有导出任务的导出进度。

public class ExportManager {

    // 导出并发槽位
    private final int capacity;
    private final List<ExportMonitor> slots = new ArrayList<ExportMonitor>();
    private final static int MAX_CAPACITY = 5;

    private final static Logger logger = LoggerFactory.getLogger(ExportManager.class);

    public ExportManager(int capacity) {
        this.capacity = capacity;
    }

    /**
     * 把导出server添加到并发槽位
     * 
     * @param exportServer
     * @return 如果并发槽位已满,返回false;如果重复添加也返回false;否则加入到并发槽位
     */
    public synchronized boolean addExportServer(ExportMonitor exportServer) {
        if (slots.size() >= capacity) {
            return false;
        }

        for (ExportMonitor server : slots) {
            if (server == exportServer) {
                return false;
            }
        }
        slots.add(exportServer);
        logger.info("[并发槽位+] " + this.toString());
        return true;
    }

    /**
     * 导出完成时移除导出监控管理类
     * 
     * @param exportServer
     */
    public synchronized void removeExportServer(ExportMonitor exportServer) {
        slots.removeAll(Lists.newArrayList(exportServer));
        logger.info("[并发槽位-] " + this.toString());
    }

    /**
     * 获取当前session下的所有的导出任务的导出进度
     * 
     * @return
     */
    public List<ExportProgressVo> getProgress() {
        List<ExportProgressVo> list = Lists.newArrayList();

        String sessionId = LoginUtils.getSessionId();
        for (ExportMonitor server : slots) {
            // 返回同一个session的所有的export server
            if (Objects.equal(server.getSessionId(), sessionId)) {
                list.add(server.getProgress());
            }
        }

        return list;
    }

    @Override
    public String toString() {
        return "ExportManager [capacity=" + capacity + ", slots=" + slots + "]";
    }

    // 单例
    private static class SingletonHolder {
        private final static ExportManager instance = new ExportManager(MAX_CAPACITY);
    }

    public static ExportManager getSingletonInstance() {
        return SingletonHolder.instance;
    }

导出的clint需要实现ExportMonitor接口,以便自己被ExportManager管理。

/**
 * 导出大量数据到excel的过程监控
 * 
 * @author dinglingxiang
 *
 */
interface ExportMonitor {

    /**
     * 获取当前导出进度
     * 
     * @return
     */
    ExportProgressVo getProgress();

    /**
     * 取消导出
     */
    void cancel();

    /**
     * 获取sessionId
     * 
     * @return
     */
    String getSessionId();
}

使用示例

以下通用基类实现了ExportMonitor接口(用户监控和查询进度),和IExcelExportServer(easypoi接口用于分页导出)。 具体的导出任务只需要继承这个基类并给pageQueryFunction函数赋值。

// 分页查询函数
    private Function<PageQueryCond<xxQueryCond>, PageInfo<xxResponse>> pushFunction = cond -> xxService
            .getXxByCondition(cond);

    class XxExcelExportServer extends ExcelExportServerBase<xxQueryCond, xxResponse> {
        public XxExcelExportServer(
                Function<PageQueryCond<xxQueryCond>, PageInfo<xxResponse>> pageQueryFunction) {
            super(pageQueryFunction, "xx导出");
        }
    }
/**
 * export server
 * 
 * @author dinglingxiang
 *
 */
public class ExcelExportServerBase<Q, R> implements ExportMonitor, IExcelExportServer {

    // 当前导出行
    private long currentRowNum;
    // 总共数据行数
    private long total;

    // 开始导出时间
    private Date startTime;

    // session id
    private final String sessionId;

    // 下载任务描述
    private final String description;

    // 分页查询函数
    private final Function<PageQueryCond<Q>, PageInfo<R>> pageQueryFunction;

    public ExcelExportServerBase(Function<PageQueryCond<Q>, PageInfo<R>> pageQueryFunction, String description) {
        this.pageQueryFunction = pageQueryFunction;
        this.startTime = new Date();
        this.sessionId = LoginUtils.getSessionId();
        this.description = description;

        // 开始下载,添加到并发槽位中
        if (false == ExportManager.getSingletonInstance().addExportServer(this)) {
            throw new RuntimeException("导出任务并发已满,请稍后再试");
        }
    }

    /**
     * 获取sessionId
     * 
     * @return
     */
    @Override
    public String getSessionId() {
        return sessionId;
    }

    @Override
    public List<Object> selectListForExcelExport(Object queryParams, int page) {
        @SuppressWarnings("unchecked")
        PageQueryCond<Q> cond = (PageQueryCond<Q>) queryParams;
        PageCond pageCond = cond.getPageCond();
        pageCond.setPage(page);
        pageCond.setSize(1000);

        PageInfo<R> res = pageQueryFunction.apply(cond);
        this.total = res.getTotal();
        this.currentRowNum = res.getEndRow();

        List<Object> list = Lists.newArrayList();
        res.getList().forEach((o) -> list.add(o));

        // 数据已经完全导出,释放并发槽位
        if (CollectionUtils.isEmpty(list)) {
            ExportManager.getSingletonInstance().removeExportServer(this);
        }

        return list;
    }

    @Override
    public ExportProgressVo getProgress() {
        ExportProgressVo progress = new ExportProgressVo();
        progress.setCurrent(this.currentRowNum);
        progress.setTotal(this.total);
        progress.setTimeUsed(DateUtils.secondsBetween(startTime, new Date()));
        progress.setDescription(this.description);
        return progress;
    }

    @Override
    public void cancel() {
        // do nothing
    }

    @Override
    public String toString() {
        return "ExcelExportServerBase [startTime=" + startTime + ", sessionId=" + sessionId + ", description="
                + description + "]";
    }

前端进度条展示

前端基于angularJS和bootstrap实现了进度条展示功能。

<!--导出进度条-->
<div class="loading-mask" ng-if="loading != null">
    <div class="loading-content">
        <div data-ng-repeat="x in exportTasks">
            <div class="progress-span">
                <span >{ {x.description}}  已用时{ {x.timeUsed}}秒  </span>
            </div>

            <div ng-class="{progress: true, 'progress-striped': x.striped}">

                <div ng-class="['progress-bar', 'progress-bar-info']" ng-style="{width: x.percent + '%'}"  style="transition: width 1s ease;">
                    <div >{ {x.percent}}%</div>
                </div>
            </div>
        </div>

    </div>

</div>

通过定时器查询导出进度:

// 开始loading
    $scope.startLoading = function() {
      $scope.loading = $interval(function () {
        $scope.getExportProgress();
      }, 1000);
    };

    // 结束loading
    $scope.endLoading = function() {
      //Cancel the Timer.
      if (angular.isDefined($scope.loading)) {
        $interval.cancel($scope.loading);
      }
      $scope.loading = null;
    };

    // 查询服务端获取导出进度
    $scope.getExportProgress = function() {
      $http.get(ApiConfig.baseUrl + "/v1/export/progress").then(function (result) {
        $scope.exportTasks = result.data.content;
        $scope.exportTasks.forEach(o => o.percent = parseInt(o.current * 100 /o.total));
      });
    };

Comments

Content