您当前的位置: 首页  >  博文日记

dcat admin添加大表异步导出功能,解决数据表太大导出超时问题

作者:总管理员 时间:2023-02-20 16:16:39 阅读数:832人阅读

dcat admin是一个优秀的laravel后台框架,在使用过程中,发现导出表格数据大于2万条以上时,容易耗时太长导出失败,页面失去响应。下面是我的修改过程:

一、在原导出下拉按钮中,添加一个“导出大表”选项:

1. 修改文件:vendor/dcat/laravel-admin/src/Grid/Exporter.php

class Exporter
{
    const SCOPE_LARGE = 'large';//添加一项
    const SCOPE_ALL = 'all';
    const SCOPE_CURRENT_PAGE = 'page';
    const SCOPE_SELECTED_ROWS = 'selected';
    protected $options = [
        'show_export_large'           => true, //添加一项
        'show_export_all'           => true,
        'show_export_current_page'  => true,
        'show_export_selected_rows' => true,
        'chunk_size'                => 5000,
    ];
//添加方法
    //大表导出
    public function disableExportLarge(bool $value = true)
    {
        return $this->option('show_export_large', ! $value);
    }

再添加一个方法:

    public function formatExportQuery($scope = '', $args = null)
    {
        $query = '';
        
        if ($scope == static::SCOPE_LARGE) {
            $query = $scope;//?id=133&_export_=large
        }

2. 修改文件:vendor/dcat/laravel-admin/src/Grid/Tools/ExportButton.php

后面添加

添加一个方法:

    protected function renderExportLarge()
    {
        if (! $this->grid->exporter()->option('show_export_large')) {
            return;
        }
        return "<li class='dropdown-item'><a href=\"{$this->grid->exportUrl('large')}\" target=\"_blank\">导出大表</a></li>";
    }

修改文件vendor/dcat/laravel-admin/src/Grid/Exporters/ExcelExporter.php:

    public function export()
    {
        
        $filename = $this->getFilename().'.'.$this->extension;
        
        if ($this->scope === Grid\Exporter::SCOPE_LARGE) {
            //大表格导出
            exit();
        }

        $exporter = Excel::export();

        if ($this->scope === Grid\Exporter::SCOPE_ALL) {
            $exporter->chunk(function (int $times) {

在导出表格的控制器的index方法中,对get参数进行判断:

        //大数据导出
        if(isset($_GET['_export_']) && $_GET['_export_'] == 'large'){
            $view = view('admin.outexcel',compact('bgsx'));
            return $content->header($zwbm)->description($bm)->body($view);
        }
        return $content->header($zwbm)->description($bm)->body($this->grid($bm,$zwbm));

resources/views/admin目录中添加模板文件outexcel.blade.php:

<div class="card">
  <div class="card-body">
    <h5 class="card-title">大数据表格导出需要一定时间,请耐心等待</h5>
    <p class="card-text">
    <div class="progress" style="height: 20px;">
        <div id="jd" class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" aria-valuemin="0" aria-valuemax="100" style="width: 0%">0%</div>
    </div>
    </p>
    <a id="url" target="_blank" href="" style="pointer-events: none;" class="btn btn-primary">等待中...</a>
  </div>
</div>

<script>
    var jd = "0%";
    Dcat.confirm('确认导出?', '导出可能需要一些时间', function () {
         $("#url").text("导出中...");
        //首次请求,生成缓存rediskey,session保存
        let url = "{{ config('admin.largeapi') }}/index";
        $.ajax({
            headers: {'X-CSRF-TOKEN': Dcat.token},
            url:"/admin/largeout",
            data:{"id":"{{$bgsx->id}}"},
            type:"POST",
            dataType:"json",
            success:function(res){
                if (res.code == 200){
                    ajaxpost(res.rediskey);
                }else{
                    toastr.error(res.msg);
                }
                //Dcat.reload();
            },
            error: function(result) {  },
        });
    });
    
    //轮询进度,从rediskey中获取
    function ajaxpost(rediskey){
        let timer = null;
        let url = "/api/getajax/" + rediskey;
        timer = setInterval(() => {
            $.ajax({
                url:url,
                type:"get",
                success:function(res){
                    if (res.code == 200){
                        $("#url").text("下载表格");
                        $("#url").attr("href",res.data);
                        $("#url").attr("disabled",false).css("pointer-events","");//启用跳转
                        clearInterval(timer);//清除轮询
                        location.href = res.data;
                    }else if(res.code == 500){
                        toastr.error(res.msg);
                    }
                    $('#jd').text(res.jd + "%");
                    $("#jd").attr("style","width: " + res.jd + "%");
                    //Dcat.reload();
                },
                error: function(result) {  },
            });
        }, 2000);
    }
</script>

dcat admin这边的修改就完成了。接下来go后端,非常简单的,只需创建创建一个项目目录,如excelapi,进入excelapi目录,命令行初始化项目:

go mod init excelapi

然后创建一个mian.go文件,内容如下:

package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io/ioutil"
	"log"
	"net/http"
	"os"
	"strconv"
	"time"

	"github.com/gin-contrib/cors"
	"github.com/gin-gonic/gin"
	"github.com/patrickmn/go-cache"
	"github.com/xuri/excelize/v2"
)

// go-cache缓存
var Rdb *cache.Cache

func main() {
	Rdb = cache.New(1*time.Minute, 3*time.Minute)
	//打包用goreleaser --rm-dist --snapshot
	CreadDir("./uploads") //创建文件夹
	CreadDir("./excel")   //导出的表格
	//gin.SetMode(gin.DebugMode) //开发模式
	gin.SetMode(gin.ReleaseMode) //生产模式
	app := gin.Default()
	app.Use(cors.Default())                         //解决跨域
	app.StaticFS("/uploads", http.Dir("./uploads")) //上传文件存储目录
	app.StaticFS("/excel", http.Dir("./excel"))     //导出表格存放目录
	app.POST("/", Index)
	app.GET("/ajax/:rediskey", OutExcelJd)
	app.Run(":3100")
}

// 第一次请求
type FirstRe struct {
	RedisKey string                 `json:rediskey`
	Bm       string                 `json:bm`
	Url      string                 `json:url`
	Max      int                    `json:max`
	Zd       map[string]interface{} `json:"zd"`
}

// 前端请求第一次发送key、zd和总数max,缓存key并创建excel
func Index(ctx *gin.Context) {
	var data FirstRe
	if err := ctx.ShouldBindJSON(&data); err != nil {
		ctx.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error(), "result": nil})
		return
	}
	if data.RedisKey == "" || data.Url == "" {
		ctx.JSON(http.StatusOK, gin.H{"code": 1, "message": "参数不完整", "result": nil})
		return
	}
	if data.Max == 0 {
		ctx.JSON(http.StatusOK, gin.H{"code": 1, "message": "数据为空", "result": nil})
		return
	}
	go WriteExcel(data.Url, data.Bm, data.Zd, data.RedisKey, data.Max) //前面加go异步执行
	ctx.JSON(http.StatusOK, gin.H{"code": 0, "message": "创建excel文件成功", "result": data.RedisKey})
}

// 流式写入excel
func WriteExcel(url, bm string, zd map[string]interface{}, rediskey string, max int) {
	Rdb.Set(rediskey+"max", max, cache.NoExpiration) //缓存最大值,无过期时间
	lienum := len(zd)                                //列数量
	var keys []string                                //提取keys,为保证顺序,使用for i
	for i := 0; i < lienum; i++ {
		keys = append(keys, "k"+strconv.Itoa(i))
	}
	file := excelize.NewFile()
	streamWriter, err := file.NewStreamWriter("Sheet1")
	if err != nil {
		fmt.Println(err)
	}
	//设置默认列宽
	if err = streamWriter.SetColWidth(1, lienum, 10); err != nil {
		fmt.Println(err)
	}
	//写入表头,注意go遍历时是无序的,要指定顺序
	for i := 0; i < lienum; i++ {
		row := make([]interface{}, lienum)
		for i := 0; i < lienum; i++ {
			row[i] = zd[keys[i]]
		}
		cell, _ := excelize.CoordinatesToCellName(i+1, 1)
		if err := streamWriter.SetRow(cell, row, excelize.RowOpts{Height: 20}); err != nil {
			fmt.Println(err)
		}
	}
	//遍历数据库api,类while循环
	h := 2          //总计数,从第二行开始
	page := 1       //第几页
	pageSize := 600 //每页600条
	for h <= max {
		gtable := ChuckTable(url, bm, keys, page, pageSize)
		if len(gtable) == 0 {
			Rdb.Set(rediskey, max, cache.DefaultExpiration)
			fmt.Println("数据读取失败,跳出循环", gtable)
			break
		}
		for x := 0; x < len(gtable); x++ {
			Rdb.Set(rediskey, h, cache.DefaultExpiration) //用存储进度,10秒清除
			row := make([]interface{}, lienum)
			for i := 0; i < lienum; i++ {
				nr := gtable[x][keys[i]] //内容
				if keys[i] == "created_at" || keys[i] == "updated_at" {
					nr = nr.(time.Time).Format("2006-01-02 15:04:05")
				}
				row[i] = nr
			}
			cell, _ := excelize.CoordinatesToCellName(1, h)
			//fmt.Println(cell, row)
			if err := streamWriter.SetRow(cell, row, excelize.RowOpts{Height: 20}); err != nil {
				fmt.Println(err)
			}
			h++
		}
		page++
	}
	if err := streamWriter.Flush(); err != nil {
		fmt.Println(err)
	}
	if err := file.SaveAs("excel/" + rediskey + ".xlsx"); err != nil {
		fmt.Println(err)
	}
}

// 请求数据库API接口
func ChuckTable(url, bm string, keys []string, page, pageSize int) (response []map[string]interface{}) {
	data := make(map[string]interface{})
	data["bm"] = bm
	data["keys"] = keys
	data["page"] = page
	data["pagesize"] = pageSize
	bytesData, _ := json.Marshal(data)
	h, _ := http.Post(url, "application/json", bytes.NewBuffer([]byte(bytesData)))
	defer h.Body.Close()
	body, err := ioutil.ReadAll(h.Body)
	if err != nil {
		log.Printf("请求api失败: %v\n", err)
		return
	}
	//fmt.Println(string(body))
	if err := json.Unmarshal([]byte(body), &response); err != nil {
		log.Printf("请求api失败: %v\n", err)
		return
	}
	return
}

// 轮询进度,注意,nginx反代要禁用缓存,不然无法更新数据
func OutExcelJd(ctx *gin.Context) {
	fmt.Println("轮询中...")
	rediskey := ctx.Param("rediskey")
	val, found := Rdb.Get(rediskey)
	if !found {
		ctx.JSON(http.StatusOK, gin.H{"code": 1, "message": "rediskey错误,读取中断", "result": nil})
		return
	}
	max, foundmax := Rdb.Get(rediskey + "max")
	if !foundmax || max == 0 {
		ctx.JSON(http.StatusOK, gin.H{"code": 1, "message": "数据为空", "result": nil})
		return
	}
	//由于是异步并发执行,可能文件还没写入完成,在此判断文件是否存在
	jd := val.(int) * 100 / max.(int)
	if val.(int) >= max.(int) {
		fmt.Println("导出完成")
		if !CheckFileIsExist("excel/" + rediskey + ".xlsx") {
			fmt.Println("未写入")
			jd = 99
		}
		Rdb.Delete(rediskey + "max") //删除缓存
		url := "excel/" + rediskey + ".xlsx"
		fmt.Println("文件", url)
		ctx.JSON(http.StatusOK, gin.H{"code": 200, "message": "导出完成", "result": url, "jd": jd})
		return
	}
	fmt.Println("进度:", jd)
	ctx.JSON(http.StatusOK, gin.H{"code": 0, "message": "请求成功", "jd": jd})
}

// 判断文件是否存在
func CheckFileIsExist(filename string) bool {
	var exist = true
	if _, err := os.Stat(filename); os.IsNotExist(err) {
		exist = false
	}
	return exist
}

// 创建文件夹
func CreadDir(path string) {
	exist, err := PathExists(path)
	if err != nil {
		panic(err)
	}
	if !exist {
		fmt.Println("创建文件夹", path)
		// 创建文件夹
		if err := os.Mkdir(path, os.ModePerm); err != nil {
			panic(err)
		} else {
			fmt.Printf("mkdir success!\n")
		}
	}
}

// 判断文件夹是否存在
func PathExists(path string) (bool, error) {
	_, err := os.Stat(path)
	if err == nil {
		return true, nil
	}
	if os.IsNotExist(err) {
		return false, nil
	}
	return false, err
}

然后执行命令安装相应插件:

go mod tidy

启动试试看是否正常:

go run main.go

如果正常,就可以打包了:

go build main.go

将打包生成的main文件放在站点目录下,用堡塔应用管理器或者命令行运行。nginx反代一下端口即可:

注意不要开启缓存,不然轮询时进度不更新。

本站所有文章、数据、图片均来自互联网,一切版权均归源网站或源作者所有。

如果侵犯了你的权益请来信告知我们删除。邮箱: 2554509967@qq.com

标签: laravel

需要 登录 才能发表评论
热门评论
0条评论

暂时没有评论!