dcat admin添加大表异步导出功能,解决数据表太大导出超时问题
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