mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-08 22:12:29 +08:00
⚡️ perf(import-export): 降低大文件导入导出内存占用
- xlsx 导出改为临时 sheet 加 zip 直写,避免整包缓冲到内存 - xlsx 导入改为 zip xml 流式解析,并将 shared strings 落到临时文件 - 大任务完成后按行数和文件大小阈值触发内存回收 - 补充导入导出流式链路的测试与基准覆盖
This commit is contained in:
@@ -12,7 +12,6 @@ import (
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"GoNavi-Wails/internal/db"
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -276,8 +275,10 @@ func streamImportFile(filePath string, consumer importFileConsumer) error {
|
||||
return streamJSONImportFile(filePath, consumer)
|
||||
case strings.HasSuffix(lower, ".csv"):
|
||||
return streamCSVImportFile(filePath, consumer)
|
||||
case strings.HasSuffix(lower, ".xlsx"), strings.HasSuffix(lower, ".xls"):
|
||||
return streamExcelImportFile(filePath, consumer)
|
||||
case strings.HasSuffix(lower, ".xlsx"):
|
||||
return streamXLSXImportFile(filePath, consumer)
|
||||
case strings.HasSuffix(lower, ".xls"):
|
||||
return streamLegacyExcelImportFile(filePath, consumer)
|
||||
default:
|
||||
return fmt.Errorf("Unsupported file format")
|
||||
}
|
||||
@@ -361,57 +362,6 @@ func streamCSVImportFile(filePath string, consumer importFileConsumer) error {
|
||||
}
|
||||
}
|
||||
|
||||
func streamExcelImportFile(filePath string, consumer importFileConsumer) error {
|
||||
workbook, err := excelize.OpenFile(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Excel Parse Error: %w", err)
|
||||
}
|
||||
defer workbook.Close()
|
||||
|
||||
sheetName := workbook.GetSheetName(0)
|
||||
if sheetName == "" {
|
||||
return fmt.Errorf("Excel file has no sheets")
|
||||
}
|
||||
|
||||
rows, err := workbook.Rows(sheetName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Excel Read Error: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
if !rows.Next() {
|
||||
if err := rows.Error(); err != nil {
|
||||
return fmt.Errorf("Excel Read Error: %w", err)
|
||||
}
|
||||
return fmt.Errorf("Excel empty or missing header")
|
||||
}
|
||||
header, err := rows.Columns()
|
||||
if err != nil {
|
||||
return fmt.Errorf("Excel Read Error: %w", err)
|
||||
}
|
||||
columns := cloneImportColumns(header)
|
||||
if !hasImportUsableColumns(columns) {
|
||||
return fmt.Errorf("Excel empty or missing header")
|
||||
}
|
||||
if err := consumer.SetColumns(columns); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for rows.Next() {
|
||||
record, err := rows.Columns()
|
||||
if err != nil {
|
||||
return fmt.Errorf("Excel Read Error: %w", err)
|
||||
}
|
||||
if err := consumer.ConsumeRow(buildImportRowFromValues(columns, record)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := rows.Error(); err != nil {
|
||||
return fmt.Errorf("Excel Read Error: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildImportInsertQuery(dbType, tableName string, columns []string, row map[string]interface{}, columnTypeMap map[string]string) (string, error) {
|
||||
quotedCols := make([]string, 0, len(columns))
|
||||
values := make([]string, 0, len(columns))
|
||||
|
||||
79
internal/app/memory_reclaim.go
Normal file
79
internal/app/memory_reclaim.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"os"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"GoNavi-Wails/internal/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
fileTransferMemoryTrimRowsThreshold int64 = 100000
|
||||
fileTransferMemoryTrimFileSizeThreshold int64 = 64 * 1024 * 1024
|
||||
fileTransferMemoryTrimMinInterval = 3 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
fileTransferMemoryTrimRunning atomic.Bool
|
||||
fileTransferMemoryTrimLastAt atomic.Int64
|
||||
|
||||
runFileTransferMemoryTrimAsync = func(fn func()) {
|
||||
go fn()
|
||||
}
|
||||
fileTransferMemoryTrimFn = func() {
|
||||
runtime.GC()
|
||||
debug.FreeOSMemory()
|
||||
}
|
||||
)
|
||||
|
||||
func maybeReleaseFileTransferMemory(reason string, rows int64, filePath string) {
|
||||
if !shouldReleaseFileTransferMemory(rows, filePath) {
|
||||
return
|
||||
}
|
||||
if !fileTransferMemoryTrimRunning.CompareAndSwap(false, true) {
|
||||
return
|
||||
}
|
||||
|
||||
runFileTransferMemoryTrimAsync(func() {
|
||||
defer fileTransferMemoryTrimRunning.Store(false)
|
||||
|
||||
if delay := nextFileTransferMemoryTrimDelay(); delay > 0 {
|
||||
time.Sleep(delay)
|
||||
}
|
||||
|
||||
logger.Infof("大文件导入导出任务结束,尝试回收进程内存:reason=%s rows=%d file=%s", strings.TrimSpace(reason), rows, strings.TrimSpace(filePath))
|
||||
fileTransferMemoryTrimFn()
|
||||
fileTransferMemoryTrimLastAt.Store(time.Now().UnixNano())
|
||||
})
|
||||
}
|
||||
|
||||
func shouldReleaseFileTransferMemory(rows int64, filePath string) bool {
|
||||
if rows >= fileTransferMemoryTrimRowsThreshold {
|
||||
return true
|
||||
}
|
||||
filePath = strings.TrimSpace(filePath)
|
||||
if filePath == "" {
|
||||
return false
|
||||
}
|
||||
info, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return info.Size() >= fileTransferMemoryTrimFileSizeThreshold
|
||||
}
|
||||
|
||||
func nextFileTransferMemoryTrimDelay() time.Duration {
|
||||
lastUnixNano := fileTransferMemoryTrimLastAt.Load()
|
||||
if lastUnixNano <= 0 {
|
||||
return 0
|
||||
}
|
||||
elapsed := time.Since(time.Unix(0, lastUnixNano))
|
||||
if elapsed >= fileTransferMemoryTrimMinInterval {
|
||||
return 0
|
||||
}
|
||||
return fileTransferMemoryTrimMinInterval - elapsed
|
||||
}
|
||||
88
internal/app/memory_reclaim_test.go
Normal file
88
internal/app/memory_reclaim_test.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestShouldReleaseFileTransferMemory_UsesRowsThreshold(t *testing.T) {
|
||||
if !shouldReleaseFileTransferMemory(fileTransferMemoryTrimRowsThreshold, "") {
|
||||
t.Fatal("达到行数阈值时应触发内存回收")
|
||||
}
|
||||
if shouldReleaseFileTransferMemory(fileTransferMemoryTrimRowsThreshold-1, "") {
|
||||
t.Fatal("未达到行数阈值时不应仅凭行数触发内存回收")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldReleaseFileTransferMemory_UsesFileSizeThreshold(t *testing.T) {
|
||||
f, err := os.CreateTemp("", "gonavi-memory-trim-*.bin")
|
||||
if err != nil {
|
||||
t.Fatalf("创建临时文件失败: %v", err)
|
||||
}
|
||||
defer os.Remove(f.Name())
|
||||
defer f.Close()
|
||||
|
||||
if err := f.Truncate(fileTransferMemoryTrimFileSizeThreshold); err != nil {
|
||||
t.Fatalf("设置临时文件大小失败: %v", err)
|
||||
}
|
||||
|
||||
if !shouldReleaseFileTransferMemory(0, f.Name()) {
|
||||
t.Fatal("达到文件大小阈值时应触发内存回收")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaybeReleaseFileTransferMemory_TriggersTrimForLargeJobs(t *testing.T) {
|
||||
originalAsync := runFileTransferMemoryTrimAsync
|
||||
originalTrim := fileTransferMemoryTrimFn
|
||||
t.Cleanup(func() {
|
||||
runFileTransferMemoryTrimAsync = originalAsync
|
||||
fileTransferMemoryTrimFn = originalTrim
|
||||
fileTransferMemoryTrimRunning.Store(false)
|
||||
fileTransferMemoryTrimLastAt.Store(0)
|
||||
})
|
||||
|
||||
fileTransferMemoryTrimRunning.Store(false)
|
||||
fileTransferMemoryTrimLastAt.Store(0)
|
||||
|
||||
triggered := 0
|
||||
runFileTransferMemoryTrimAsync = func(fn func()) {
|
||||
fn()
|
||||
}
|
||||
fileTransferMemoryTrimFn = func() {
|
||||
triggered++
|
||||
}
|
||||
|
||||
maybeReleaseFileTransferMemory("test-large-job", fileTransferMemoryTrimRowsThreshold, "")
|
||||
|
||||
if triggered != 1 {
|
||||
t.Fatalf("大任务完成后应触发一次内存回收,got=%d", triggered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaybeReleaseFileTransferMemory_SkipsSmallJobs(t *testing.T) {
|
||||
originalAsync := runFileTransferMemoryTrimAsync
|
||||
originalTrim := fileTransferMemoryTrimFn
|
||||
t.Cleanup(func() {
|
||||
runFileTransferMemoryTrimAsync = originalAsync
|
||||
fileTransferMemoryTrimFn = originalTrim
|
||||
fileTransferMemoryTrimRunning.Store(false)
|
||||
fileTransferMemoryTrimLastAt.Store(0)
|
||||
})
|
||||
|
||||
fileTransferMemoryTrimRunning.Store(false)
|
||||
fileTransferMemoryTrimLastAt.Store(0)
|
||||
|
||||
triggered := 0
|
||||
runFileTransferMemoryTrimAsync = func(fn func()) {
|
||||
fn()
|
||||
}
|
||||
fileTransferMemoryTrimFn = func() {
|
||||
triggered++
|
||||
}
|
||||
|
||||
maybeReleaseFileTransferMemory("test-small-job", 10, "")
|
||||
|
||||
if triggered != 0 {
|
||||
t.Fatalf("小任务不应触发内存回收,got=%d", triggered)
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,6 @@ import (
|
||||
"GoNavi-Wails/internal/utils"
|
||||
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
const minExportQueryTimeout = 5 * time.Minute
|
||||
@@ -2045,14 +2044,19 @@ func (a *App) ImportDataWithProgress(config connection.ConnectionConfig, dbName,
|
||||
runtime.EventsEmit(a.ctx, "import:progress", state)
|
||||
})
|
||||
if err := streamImportFile(filePath, consumer); err != nil {
|
||||
resultData := consumer.Result()
|
||||
maybeReleaseFileTransferMemory("import-stream-error", int64(resultData.Total), filePath)
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
if err := consumer.Flush(); err != nil {
|
||||
resultData := consumer.Result()
|
||||
maybeReleaseFileTransferMemory("import-flush-error", int64(resultData.Total), filePath)
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
|
||||
resultData := consumer.Result()
|
||||
if resultData.Total == 0 {
|
||||
maybeReleaseFileTransferMemory("import-empty", 0, filePath)
|
||||
return connection.QueryResult{Success: true, Message: "无可导入数据"}
|
||||
}
|
||||
|
||||
@@ -2064,6 +2068,7 @@ func (a *App) ImportDataWithProgress(config connection.ConnectionConfig, dbName,
|
||||
"errorSummary": fmt.Sprintf("Imported: %d, Failed: %d", resultData.Success, resultData.Failed),
|
||||
}
|
||||
|
||||
maybeReleaseFileTransferMemory("import-finished", int64(resultData.Total), filePath)
|
||||
return connection.QueryResult{Success: true, Data: result, Message: fmt.Sprintf("Imported: %d, Failed: %d", resultData.Success, resultData.Failed)}
|
||||
}
|
||||
|
||||
@@ -2170,6 +2175,7 @@ func (a *App) ExportTableWithOptions(config connection.ConnectionConfig, dbName
|
||||
|
||||
reporter.Finalizing(0)
|
||||
reporter.Done(0)
|
||||
maybeReleaseFileTransferMemory("export-table-sql-finished", 0, filename)
|
||||
return connection.QueryResult{Success: true, Message: "导出完成"}
|
||||
}
|
||||
|
||||
@@ -2185,9 +2191,11 @@ func (a *App) ExportTableWithOptions(config connection.ConnectionConfig, dbName
|
||||
rowCount, _, err := exportQueryResultToFile(f, dbInst, runConfig, query, options, reporter)
|
||||
if err != nil {
|
||||
reporter.Error(rowCount, "写入失败:"+err.Error())
|
||||
maybeReleaseFileTransferMemory("export-table-error", rowCount, filename)
|
||||
return connection.QueryResult{Success: false, Message: "写入失败:" + err.Error()}
|
||||
}
|
||||
reporter.Done(rowCount)
|
||||
maybeReleaseFileTransferMemory("export-table-finished", rowCount, filename)
|
||||
|
||||
return connection.QueryResult{Success: true, Message: "导出完成"}
|
||||
}
|
||||
@@ -3488,11 +3496,13 @@ func (a *App) ExportDataWithOptions(data []map[string]interface{}, columns []str
|
||||
if err != nil {
|
||||
logger.Warnf("ExportData 写入失败:file=%s err=%v", filename, err)
|
||||
reporter.Error(writtenRows, "写入失败:"+err.Error())
|
||||
maybeReleaseFileTransferMemory("export-data-error", writtenRows, filename)
|
||||
return connection.QueryResult{Success: false, Message: "写入失败:" + err.Error()}
|
||||
}
|
||||
|
||||
logger.Infof("ExportData 完成:file=%s rows=%d", filename, len(data))
|
||||
reporter.Done(writtenRows)
|
||||
maybeReleaseFileTransferMemory("export-data-finished", writtenRows, filename)
|
||||
return connection.QueryResult{Success: true, Message: "导出完成"}
|
||||
}
|
||||
|
||||
@@ -3550,11 +3560,13 @@ func (a *App) ExportQueryWithOptions(config connection.ConnectionConfig, dbName
|
||||
if err != nil {
|
||||
logger.Warnf("ExportQuery 查询失败:type=%s db=%s err=%v sql=%q", strings.TrimSpace(config.Type), strings.TrimSpace(dbName), err, sqlSnippet(query))
|
||||
reporter.Error(rowCount, err.Error())
|
||||
maybeReleaseFileTransferMemory("export-query-error", rowCount, filename)
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
|
||||
logger.Infof("ExportQuery 完成:file=%s rows=%d cols=%d", filename, rowCount, len(columns))
|
||||
reporter.Done(rowCount)
|
||||
maybeReleaseFileTransferMemory("export-query-finished", rowCount, filename)
|
||||
return connection.QueryResult{Success: true, Message: "导出完成"}
|
||||
}
|
||||
|
||||
@@ -3997,128 +4009,6 @@ func (w *htmlExportFileWriter) Close() error {
|
||||
return w.writer.Flush()
|
||||
}
|
||||
|
||||
type xlsxExportFileWriter struct {
|
||||
filename string
|
||||
workbook *excelize.File
|
||||
stream *excelize.StreamWriter
|
||||
sheet string
|
||||
columns []string
|
||||
header []interface{}
|
||||
rowBuf []interface{}
|
||||
nextRow int
|
||||
sheetNo int
|
||||
rowCount int
|
||||
maxRows int
|
||||
}
|
||||
|
||||
func newXLSXExportFileWriter(filename string, maxRowsPerSheet int) (*xlsxExportFileWriter, error) {
|
||||
workbook := excelize.NewFile()
|
||||
sheet := workbook.GetSheetName(workbook.GetActiveSheetIndex())
|
||||
stream, err := workbook.NewStreamWriter(sheet)
|
||||
if err != nil {
|
||||
_ = workbook.Close()
|
||||
return nil, err
|
||||
}
|
||||
return &xlsxExportFileWriter{
|
||||
filename: filename,
|
||||
workbook: workbook,
|
||||
stream: stream,
|
||||
sheet: sheet,
|
||||
sheetNo: 1,
|
||||
nextRow: 2,
|
||||
maxRows: normalizeXLSXRowsPerSheet(maxRowsPerSheet),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (w *xlsxExportFileWriter) SetColumns(columns []string) error {
|
||||
w.columns = append([]string(nil), columns...)
|
||||
w.rowCount = 0
|
||||
w.nextRow = 2
|
||||
w.header = make([]interface{}, len(columns))
|
||||
w.rowBuf = make([]interface{}, len(columns))
|
||||
for i, col := range columns {
|
||||
w.header[i] = col
|
||||
}
|
||||
return w.stream.SetRow("A1", w.header)
|
||||
}
|
||||
|
||||
func (w *xlsxExportFileWriter) rotateSheet() error {
|
||||
if err := w.stream.Flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
w.sheetNo++
|
||||
w.sheet = fmt.Sprintf("Sheet%d", w.sheetNo)
|
||||
if _, err := w.workbook.NewSheet(w.sheet); err != nil {
|
||||
return err
|
||||
}
|
||||
stream, err := w.workbook.NewStreamWriter(w.sheet)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.stream = stream
|
||||
w.rowCount = 0
|
||||
w.nextRow = 2
|
||||
return w.stream.SetRow("A1", w.header)
|
||||
}
|
||||
|
||||
func (w *xlsxExportFileWriter) ConsumeRow(row map[string]interface{}) error {
|
||||
if w.rowCount >= w.maxRows {
|
||||
if err := w.rotateSheet(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
values := w.rowBuf
|
||||
for i, col := range w.columns {
|
||||
val := row[col]
|
||||
if val == nil {
|
||||
values[i] = "NULL"
|
||||
continue
|
||||
}
|
||||
values[i] = formatExportCellText(val)
|
||||
}
|
||||
cell := "A" + strconv.Itoa(w.nextRow)
|
||||
w.nextRow++
|
||||
w.rowCount++
|
||||
return w.stream.SetRow(cell, values)
|
||||
}
|
||||
|
||||
func (w *xlsxExportFileWriter) ConsumeRowValues(values []interface{}) error {
|
||||
if w.rowCount >= w.maxRows {
|
||||
if err := w.rotateSheet(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
rowBuf := w.rowBuf
|
||||
for i := range w.columns {
|
||||
var value interface{}
|
||||
if i < len(values) {
|
||||
value = values[i]
|
||||
}
|
||||
if value == nil {
|
||||
rowBuf[i] = "NULL"
|
||||
continue
|
||||
}
|
||||
rowBuf[i] = formatExportCellText(value)
|
||||
}
|
||||
cell := "A" + strconv.Itoa(w.nextRow)
|
||||
w.nextRow++
|
||||
w.rowCount++
|
||||
return w.stream.SetRow(cell, rowBuf)
|
||||
}
|
||||
|
||||
func (w *xlsxExportFileWriter) Close() error {
|
||||
if err := w.stream.Flush(); err != nil {
|
||||
_ = w.workbook.Close()
|
||||
return err
|
||||
}
|
||||
saveErr := w.workbook.SaveAs(w.filename)
|
||||
closeErr := w.workbook.Close()
|
||||
if saveErr != nil {
|
||||
return saveErr
|
||||
}
|
||||
return closeErr
|
||||
}
|
||||
|
||||
type sqlInsertExportConsumer struct {
|
||||
w *bufio.Writer
|
||||
dbType string
|
||||
@@ -4304,11 +4194,7 @@ func newExportFileWriter(f *os.File, options ExportFileOptions) (exportFileWrite
|
||||
case "html":
|
||||
return newHTMLExportFileWriter(f), nil
|
||||
case "xlsx":
|
||||
filename := f.Name()
|
||||
if err := f.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newXLSXExportFileWriter(filename, options.XLSXMaxRowsPerSheet)
|
||||
return newXLSXExportFileWriter(f, options.XLSXMaxRowsPerSheet)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported format: %s", options.Format)
|
||||
}
|
||||
@@ -4756,29 +4642,23 @@ func normalizeExportJSONValue(val interface{}) interface{} {
|
||||
|
||||
// writeRowsToXlsx 使用 excelize 写入真正的 xlsx 格式文件
|
||||
func writeRowsToXlsx(filename string, data []map[string]interface{}, columns []string) error {
|
||||
xlsx := excelize.NewFile()
|
||||
defer xlsx.Close()
|
||||
|
||||
sheet := "Sheet1"
|
||||
|
||||
// 写入表头
|
||||
for i, col := range columns {
|
||||
cell, _ := excelize.CoordinatesToCellName(i+1, 1)
|
||||
xlsx.SetCellValue(sheet, cell, col)
|
||||
file, err := os.Create(filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// 写入数据行
|
||||
for rowIdx, rowMap := range data {
|
||||
for colIdx, col := range columns {
|
||||
cell, _ := excelize.CoordinatesToCellName(colIdx+1, rowIdx+2)
|
||||
val := rowMap[col]
|
||||
if val == nil {
|
||||
xlsx.SetCellValue(sheet, cell, "NULL")
|
||||
} else {
|
||||
xlsx.SetCellValue(sheet, cell, formatExportCellText(val))
|
||||
}
|
||||
writer, err := newXLSXExportFileWriter(file, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writer.SetColumns(columns); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, rowMap := range data {
|
||||
if err := writer.ConsumeRow(rowMap); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return xlsx.SaveAs(filename)
|
||||
return writer.Close()
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -44,6 +45,13 @@ type fakeValueStreamExportDB struct {
|
||||
valueHits int
|
||||
}
|
||||
|
||||
type fakeGeneratedValueStreamExportDB struct {
|
||||
streamCols []string
|
||||
rowCount int
|
||||
streamHits int
|
||||
valueHits int
|
||||
}
|
||||
|
||||
func (f *fakeExportQueryDB) Connect(config connection.ConnectionConfig) error { return nil }
|
||||
func (f *fakeExportQueryDB) Close() error { return nil }
|
||||
func (f *fakeExportQueryDB) Ping() error { return nil }
|
||||
@@ -154,6 +162,82 @@ func (f *fakeValueStreamExportDB) StreamQueryContext(_ context.Context, query st
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeGeneratedValueStreamExportDB) Connect(config connection.ConnectionConfig) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeGeneratedValueStreamExportDB) Close() error { return nil }
|
||||
|
||||
func (f *fakeGeneratedValueStreamExportDB) Ping() error { return nil }
|
||||
|
||||
func (f *fakeGeneratedValueStreamExportDB) Query(query string) ([]map[string]interface{}, []string, error) {
|
||||
return nil, nil, context.DeadlineExceeded
|
||||
}
|
||||
|
||||
func (f *fakeGeneratedValueStreamExportDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
|
||||
return nil, nil, context.DeadlineExceeded
|
||||
}
|
||||
|
||||
func (f *fakeGeneratedValueStreamExportDB) Exec(query string) (int64, error) { return 0, nil }
|
||||
|
||||
func (f *fakeGeneratedValueStreamExportDB) GetDatabases() ([]string, error) { return nil, nil }
|
||||
|
||||
func (f *fakeGeneratedValueStreamExportDB) GetTables(dbName string) ([]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeGeneratedValueStreamExportDB) GetCreateStatement(dbName, tableName string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (f *fakeGeneratedValueStreamExportDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeGeneratedValueStreamExportDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeGeneratedValueStreamExportDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeGeneratedValueStreamExportDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeGeneratedValueStreamExportDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeGeneratedValueStreamExportDB) StreamQuery(query string, consumer db.QueryStreamConsumer) error {
|
||||
return f.StreamQueryContext(context.Background(), query, consumer)
|
||||
}
|
||||
|
||||
func (f *fakeGeneratedValueStreamExportDB) StreamQueryContext(_ context.Context, query string, consumer db.QueryStreamConsumer) error {
|
||||
f.streamHits++
|
||||
if err := consumer.SetColumns(f.streamCols); err != nil {
|
||||
return err
|
||||
}
|
||||
valueConsumer, ok := consumer.(db.QueryStreamValueConsumer)
|
||||
if !ok {
|
||||
return fmt.Errorf("value stream consumer required")
|
||||
}
|
||||
for i := 0; i < f.rowCount; i++ {
|
||||
f.valueHits++
|
||||
if err := valueConsumer.ConsumeRowValues([]interface{}{
|
||||
i + 1,
|
||||
"benchmark-user",
|
||||
"plain export payload without timezone marker",
|
||||
"2026-06-17 12:34:56",
|
||||
"enabled",
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestFormatExportCellText_FloatNoScientificNotation(t *testing.T) {
|
||||
got := formatExportCellText(1.445663e+06)
|
||||
if strings.Contains(strings.ToLower(got), "e+") || strings.Contains(strings.ToLower(got), "e-") {
|
||||
@@ -730,6 +814,35 @@ func BenchmarkExportQueryResultToFile_XLSX_StreamValues_20000Rows(b *testing.B)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkExportQueryResultToFile_XLSX_StreamGenerated_50000Rows(b *testing.B) {
|
||||
streamDB := &fakeGeneratedValueStreamExportDB{
|
||||
streamCols: []string{"id", "name", "note", "created_at", "status"},
|
||||
rowCount: 50000,
|
||||
}
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
f, err := os.CreateTemp("", "gonavi-export-stream-generated-*.xlsx")
|
||||
if err != nil {
|
||||
b.Fatalf("创建临时文件失败: %v", err)
|
||||
}
|
||||
name := f.Name()
|
||||
if _, _, err := exportQueryResultToFile(
|
||||
f,
|
||||
streamDB,
|
||||
connection.ConnectionConfig{Type: "mysql", Timeout: 10},
|
||||
"SELECT * FROM users",
|
||||
ExportFileOptions{Format: "xlsx"},
|
||||
nil,
|
||||
); err != nil {
|
||||
_ = os.Remove(name)
|
||||
b.Fatalf("流式生成导出失败: %v", err)
|
||||
}
|
||||
if err := os.Remove(name); err != nil {
|
||||
b.Fatalf("删除临时文件失败: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkDumpTableSQL_SQLBackup_StreamMap_20000Rows(b *testing.B) {
|
||||
rows, columns := benchmarkExportRows(20000)
|
||||
streamDB := &fakeStreamExportDB{
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
func TestReadImportedConnectionConfigFileRejectsOversizedFiles(t *testing.T) {
|
||||
@@ -82,6 +84,94 @@ func TestBuildImportRowFromValuesPreservesPositionsWhenHeaderContainsBlankColumn
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildImportPreviewXLSXStreamSupportsInlineStrings(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "inline.xlsx")
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatalf("创建 xlsx 文件失败: %v", err)
|
||||
}
|
||||
|
||||
writer, err := newXLSXExportFileWriter(file, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("创建 xlsx writer 失败: %v", err)
|
||||
}
|
||||
if err := writer.SetColumns([]string{"id", "name"}); err != nil {
|
||||
t.Fatalf("SetColumns 失败: %v", err)
|
||||
}
|
||||
if err := writer.ConsumeRowValues([]interface{}{1, "alice"}); err != nil {
|
||||
t.Fatalf("写入第 1 行失败: %v", err)
|
||||
}
|
||||
if err := writer.ConsumeRowValues([]interface{}{2, "bob"}); err != nil {
|
||||
t.Fatalf("写入第 2 行失败: %v", err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("关闭 xlsx writer 失败: %v", err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
t.Fatalf("关闭 xlsx 文件失败: %v", err)
|
||||
}
|
||||
|
||||
preview, err := buildImportPreview(path, 5)
|
||||
if err != nil {
|
||||
t.Fatalf("buildImportPreview 返回错误: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(preview.Columns, []string{"id", "name"}) {
|
||||
t.Fatalf("unexpected columns: %#v", preview.Columns)
|
||||
}
|
||||
if preview.TotalRows != 2 {
|
||||
t.Fatalf("expected 2 rows, got %d", preview.TotalRows)
|
||||
}
|
||||
if got := preview.PreviewRows[1]["name"]; got != "bob" {
|
||||
t.Fatalf("expected second row name bob, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildImportPreviewXLSXStreamSupportsSharedStrings(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "shared.xlsx")
|
||||
workbook := excelize.NewFile()
|
||||
if err := workbook.SetCellValue("Sheet1", "A1", "id"); err != nil {
|
||||
t.Fatalf("设置表头失败: %v", err)
|
||||
}
|
||||
if err := workbook.SetCellValue("Sheet1", "B1", "name"); err != nil {
|
||||
t.Fatalf("设置表头失败: %v", err)
|
||||
}
|
||||
if err := workbook.SetCellValue("Sheet1", "A2", "1"); err != nil {
|
||||
t.Fatalf("设置数据失败: %v", err)
|
||||
}
|
||||
if err := workbook.SetCellValue("Sheet1", "B2", "alice"); err != nil {
|
||||
t.Fatalf("设置数据失败: %v", err)
|
||||
}
|
||||
if err := workbook.SetCellValue("Sheet1", "A3", "2"); err != nil {
|
||||
t.Fatalf("设置数据失败: %v", err)
|
||||
}
|
||||
if err := workbook.SetCellValue("Sheet1", "B3", "bob"); err != nil {
|
||||
t.Fatalf("设置数据失败: %v", err)
|
||||
}
|
||||
if err := workbook.SaveAs(path); err != nil {
|
||||
t.Fatalf("保存 shared-string xlsx 失败: %v", err)
|
||||
}
|
||||
if err := workbook.Close(); err != nil {
|
||||
t.Fatalf("关闭 shared-string xlsx 失败: %v", err)
|
||||
}
|
||||
|
||||
preview, err := buildImportPreview(path, 5)
|
||||
if err != nil {
|
||||
t.Fatalf("buildImportPreview 返回错误: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(preview.Columns, []string{"id", "name"}) {
|
||||
t.Fatalf("unexpected columns: %#v", preview.Columns)
|
||||
}
|
||||
if preview.TotalRows != 2 {
|
||||
t.Fatalf("expected 2 rows, got %d", preview.TotalRows)
|
||||
}
|
||||
if got := preview.PreviewRows[0]["name"]; got != "alice" {
|
||||
t.Fatalf("expected first row name alice, got %#v", got)
|
||||
}
|
||||
if got := preview.PreviewRows[1]["id"]; got != "2" {
|
||||
t.Fatalf("expected second row id 2, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeImportRowWriter struct {
|
||||
columns []string
|
||||
batchCalls int
|
||||
|
||||
544
internal/app/xlsx_import_stream.go
Normal file
544
internal/app/xlsx_import_stream.go
Normal file
@@ -0,0 +1,544 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bufio"
|
||||
"encoding/binary"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
xlsxWorkbookXMLPath = "xl/workbook.xml"
|
||||
xlsxWorkbookRelsXMLPath = "xl/_rels/workbook.xml.rels"
|
||||
xlsxSharedStringsXML = "xl/sharedStrings.xml"
|
||||
)
|
||||
|
||||
type xlsxSharedStringStore struct {
|
||||
path string
|
||||
file *os.File
|
||||
writer *bufio.Writer
|
||||
offsets []int64
|
||||
size int64
|
||||
}
|
||||
|
||||
func newXLSXSharedStringStore() (*xlsxSharedStringStore, error) {
|
||||
file, err := os.CreateTemp("", "gonavi-xlsx-shared-strings-*.bin")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &xlsxSharedStringStore{
|
||||
path: file.Name(),
|
||||
file: file,
|
||||
writer: bufio.NewWriterSize(file, 1024*256),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *xlsxSharedStringStore) Add(value string) error {
|
||||
if s == nil || s.file == nil || s.writer == nil {
|
||||
return fmt.Errorf("shared string store unavailable")
|
||||
}
|
||||
s.offsets = append(s.offsets, s.size)
|
||||
if err := binary.Write(s.writer, binary.LittleEndian, uint32(len(value))); err != nil {
|
||||
return err
|
||||
}
|
||||
written, err := s.writer.WriteString(value)
|
||||
s.size += 4 + int64(written)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *xlsxSharedStringStore) Get(index int) (string, error) {
|
||||
if s == nil {
|
||||
return "", nil
|
||||
}
|
||||
if index < 0 || index >= len(s.offsets) {
|
||||
return "", fmt.Errorf("shared string index out of range: %d", index)
|
||||
}
|
||||
if err := s.flush(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := s.file.Seek(s.offsets[index], io.SeekStart); err != nil {
|
||||
return "", err
|
||||
}
|
||||
var length uint32
|
||||
if err := binary.Read(s.file, binary.LittleEndian, &length); err != nil {
|
||||
return "", err
|
||||
}
|
||||
buf := make([]byte, int(length))
|
||||
if _, err := io.ReadFull(s.file, buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(buf), nil
|
||||
}
|
||||
|
||||
func (s *xlsxSharedStringStore) flush() error {
|
||||
if s == nil || s.writer == nil {
|
||||
return nil
|
||||
}
|
||||
return s.writer.Flush()
|
||||
}
|
||||
|
||||
func (s *xlsxSharedStringStore) Close() error {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
if s.writer != nil {
|
||||
_ = s.writer.Flush()
|
||||
}
|
||||
var err error
|
||||
if s.file != nil {
|
||||
err = s.file.Close()
|
||||
}
|
||||
if s.path != "" {
|
||||
_ = os.Remove(s.path)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func streamXLSXImportFile(filePath string, consumer importFileConsumer) error {
|
||||
reader, err := zip.OpenReader(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Excel Parse Error: %w", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
entryByPath := make(map[string]*zip.File, len(reader.File))
|
||||
for _, entry := range reader.File {
|
||||
entryByPath[entry.Name] = entry
|
||||
}
|
||||
|
||||
sheetPath, err := resolveXLSXFirstSheetPath(entryByPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Excel Parse Error: %w", err)
|
||||
}
|
||||
|
||||
sharedStrings, err := loadXLSXSharedStrings(entryByPath[xlsxSharedStringsXML])
|
||||
if err != nil {
|
||||
return fmt.Errorf("Excel Parse Error: %w", err)
|
||||
}
|
||||
if sharedStrings != nil {
|
||||
defer sharedStrings.Close()
|
||||
}
|
||||
|
||||
sheetEntry := entryByPath[sheetPath]
|
||||
if sheetEntry == nil {
|
||||
return fmt.Errorf("Excel Parse Error: worksheet not found: %s", sheetPath)
|
||||
}
|
||||
if err := streamXLSXSheetRows(sheetEntry, sharedStrings, consumer); err != nil {
|
||||
return fmt.Errorf("Excel Read Error: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func streamLegacyExcelImportFile(filePath string, consumer importFileConsumer) error {
|
||||
workbook, err := excelize.OpenFile(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Excel Parse Error: %w", err)
|
||||
}
|
||||
defer workbook.Close()
|
||||
|
||||
sheetName := workbook.GetSheetName(0)
|
||||
if sheetName == "" {
|
||||
return fmt.Errorf("Excel file has no sheets")
|
||||
}
|
||||
|
||||
rows, err := workbook.Rows(sheetName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Excel Read Error: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
if !rows.Next() {
|
||||
if err := rows.Error(); err != nil {
|
||||
return fmt.Errorf("Excel Read Error: %w", err)
|
||||
}
|
||||
return fmt.Errorf("Excel empty or missing header")
|
||||
}
|
||||
header, err := rows.Columns()
|
||||
if err != nil {
|
||||
return fmt.Errorf("Excel Read Error: %w", err)
|
||||
}
|
||||
columns := cloneImportColumns(header)
|
||||
if !hasImportUsableColumns(columns) {
|
||||
return fmt.Errorf("Excel empty or missing header")
|
||||
}
|
||||
if err := consumer.SetColumns(columns); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for rows.Next() {
|
||||
record, err := rows.Columns()
|
||||
if err != nil {
|
||||
return fmt.Errorf("Excel Read Error: %w", err)
|
||||
}
|
||||
if err := consumer.ConsumeRow(buildImportRowFromValues(columns, record)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := rows.Error(); err != nil {
|
||||
return fmt.Errorf("Excel Read Error: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveXLSXFirstSheetPath(entryByPath map[string]*zip.File) (string, error) {
|
||||
workbookEntry := entryByPath[xlsxWorkbookXMLPath]
|
||||
if workbookEntry == nil {
|
||||
return "", fmt.Errorf("workbook.xml missing")
|
||||
}
|
||||
workbookReader, err := workbookEntry.Open()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer workbookReader.Close()
|
||||
|
||||
firstSheetRelID, err := readXLSXFirstSheetRelID(workbookReader)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if firstSheetRelID == "" {
|
||||
return "", fmt.Errorf("workbook has no sheets")
|
||||
}
|
||||
|
||||
relsEntry := entryByPath[xlsxWorkbookRelsXMLPath]
|
||||
if relsEntry == nil {
|
||||
return "", fmt.Errorf("workbook rels missing")
|
||||
}
|
||||
relsReader, err := relsEntry.Open()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer relsReader.Close()
|
||||
|
||||
target, err := readXLSXWorkbookRelTarget(relsReader, firstSheetRelID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if target == "" {
|
||||
return "", fmt.Errorf("worksheet target missing for relationship %s", firstSheetRelID)
|
||||
}
|
||||
target = strings.TrimPrefix(strings.TrimSpace(target), "/")
|
||||
if strings.HasPrefix(target, "xl/") {
|
||||
return path.Clean(target), nil
|
||||
}
|
||||
return path.Clean(path.Join("xl", target)), nil
|
||||
}
|
||||
|
||||
func readXLSXFirstSheetRelID(reader io.Reader) (string, error) {
|
||||
decoder := xml.NewDecoder(reader)
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
return "", nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
start, ok := token.(xml.StartElement)
|
||||
if !ok || start.Name.Local != "sheet" {
|
||||
continue
|
||||
}
|
||||
for _, attr := range start.Attr {
|
||||
if attr.Name.Local == "id" {
|
||||
return strings.TrimSpace(attr.Value), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readXLSXWorkbookRelTarget(reader io.Reader, relID string) (string, error) {
|
||||
decoder := xml.NewDecoder(reader)
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
return "", nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
start, ok := token.(xml.StartElement)
|
||||
if !ok || start.Name.Local != "Relationship" {
|
||||
continue
|
||||
}
|
||||
var id string
|
||||
var target string
|
||||
for _, attr := range start.Attr {
|
||||
switch attr.Name.Local {
|
||||
case "Id":
|
||||
id = strings.TrimSpace(attr.Value)
|
||||
case "Target":
|
||||
target = strings.TrimSpace(attr.Value)
|
||||
}
|
||||
}
|
||||
if id == relID {
|
||||
return target, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func loadXLSXSharedStrings(entry *zip.File) (*xlsxSharedStringStore, error) {
|
||||
if entry == nil {
|
||||
return nil, nil
|
||||
}
|
||||
reader, err := entry.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
store, err := newXLSXSharedStringStore()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := parseXLSXSharedStrings(reader, store); err != nil {
|
||||
_ = store.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := store.flush(); err != nil {
|
||||
_ = store.Close()
|
||||
return nil, err
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func parseXLSXSharedStrings(reader io.Reader, store *xlsxSharedStringStore) error {
|
||||
decoder := xml.NewDecoder(reader)
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
start, ok := token.(xml.StartElement)
|
||||
if !ok || start.Name.Local != "si" {
|
||||
continue
|
||||
}
|
||||
value, err := readXLSXSharedStringItem(decoder)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := store.Add(value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readXLSXSharedStringItem(decoder *xml.Decoder) (string, error) {
|
||||
var builder strings.Builder
|
||||
depth := 1
|
||||
for depth > 0 {
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
switch typed := token.(type) {
|
||||
case xml.StartElement:
|
||||
if typed.Name.Local == "si" {
|
||||
depth++
|
||||
continue
|
||||
}
|
||||
if typed.Name.Local == "t" {
|
||||
text, err := readXMLTextNode(decoder, typed.Name.Local)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
builder.WriteString(text)
|
||||
}
|
||||
case xml.EndElement:
|
||||
if typed.Name.Local == "si" {
|
||||
depth--
|
||||
}
|
||||
}
|
||||
}
|
||||
return builder.String(), nil
|
||||
}
|
||||
|
||||
func streamXLSXSheetRows(entry *zip.File, sharedStrings *xlsxSharedStringStore, consumer importFileConsumer) error {
|
||||
reader, err := entry.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
decoder := xml.NewDecoder(reader)
|
||||
var columns []string
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
return err
|
||||
}
|
||||
start, ok := token.(xml.StartElement)
|
||||
if !ok || start.Name.Local != "row" {
|
||||
continue
|
||||
}
|
||||
values, err := readXLSXRow(decoder, sharedStrings)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if columns == nil {
|
||||
columns = cloneImportColumns(values)
|
||||
if !hasImportUsableColumns(columns) {
|
||||
return fmt.Errorf("Excel empty or missing header")
|
||||
}
|
||||
if err := consumer.SetColumns(columns); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := consumer.ConsumeRow(buildImportRowFromValues(columns, values)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if columns == nil {
|
||||
return fmt.Errorf("Excel empty or missing header")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readXLSXRow(decoder *xml.Decoder, sharedStrings *xlsxSharedStringStore) ([]string, error) {
|
||||
values := make([]string, 0, 16)
|
||||
currentColumn := 0
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch typed := token.(type) {
|
||||
case xml.StartElement:
|
||||
if typed.Name.Local != "c" {
|
||||
continue
|
||||
}
|
||||
columnIndex := currentColumn + 1
|
||||
cellType := ""
|
||||
for _, attr := range typed.Attr {
|
||||
switch attr.Name.Local {
|
||||
case "r":
|
||||
if idx := xlsxCellRefColumnIndex(attr.Value); idx > 0 {
|
||||
columnIndex = idx
|
||||
}
|
||||
case "t":
|
||||
cellType = strings.TrimSpace(attr.Value)
|
||||
}
|
||||
}
|
||||
if columnIndex <= 0 {
|
||||
columnIndex = currentColumn + 1
|
||||
}
|
||||
cellValue, err := readXLSXCell(decoder, cellType, sharedStrings)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for len(values) < columnIndex {
|
||||
values = append(values, "")
|
||||
}
|
||||
values[columnIndex-1] = cellValue
|
||||
currentColumn = columnIndex
|
||||
case xml.EndElement:
|
||||
if typed.Name.Local == "row" {
|
||||
return values, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readXLSXCell(decoder *xml.Decoder, cellType string, sharedStrings *xlsxSharedStringStore) (string, error) {
|
||||
var rawValue strings.Builder
|
||||
var inlineValue strings.Builder
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
switch typed := token.(type) {
|
||||
case xml.StartElement:
|
||||
switch typed.Name.Local {
|
||||
case "v":
|
||||
text, err := readXMLTextNode(decoder, typed.Name.Local)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
rawValue.WriteString(text)
|
||||
case "t":
|
||||
text, err := readXMLTextNode(decoder, typed.Name.Local)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
inlineValue.WriteString(text)
|
||||
}
|
||||
case xml.EndElement:
|
||||
if typed.Name.Local != "c" {
|
||||
continue
|
||||
}
|
||||
switch cellType {
|
||||
case "s":
|
||||
indexText := strings.TrimSpace(rawValue.String())
|
||||
if indexText == "" {
|
||||
return "", nil
|
||||
}
|
||||
index, err := strconv.Atoi(indexText)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return sharedStrings.Get(index)
|
||||
case "inlineStr":
|
||||
return inlineValue.String(), nil
|
||||
default:
|
||||
if inlineValue.Len() > 0 {
|
||||
return inlineValue.String(), nil
|
||||
}
|
||||
return rawValue.String(), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readXMLTextNode(decoder *xml.Decoder, endLocal string) (string, error) {
|
||||
var builder strings.Builder
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
switch typed := token.(type) {
|
||||
case xml.CharData:
|
||||
builder.Write([]byte(typed))
|
||||
case xml.EndElement:
|
||||
if typed.Name.Local == endLocal {
|
||||
return builder.String(), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func xlsxCellRefColumnIndex(ref string) int {
|
||||
ref = strings.TrimSpace(ref)
|
||||
if ref == "" {
|
||||
return 0
|
||||
}
|
||||
value := 0
|
||||
for i := 0; i < len(ref); i++ {
|
||||
ch := ref[i]
|
||||
switch {
|
||||
case ch >= 'A' && ch <= 'Z':
|
||||
value = value*26 + int(ch-'A'+1)
|
||||
case ch >= 'a' && ch <= 'z':
|
||||
value = value*26 + int(ch-'a'+1)
|
||||
default:
|
||||
if value > 0 {
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
443
internal/app/xlsx_stream_writer.go
Normal file
443
internal/app/xlsx_stream_writer.go
Normal file
@@ -0,0 +1,443 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bufio"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
xlsxMainNamespace = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
|
||||
xlsxRelationshipsNamespace = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
||||
xlsxPackageRelationshipsNS = "http://schemas.openxmlformats.org/package/2006/relationships"
|
||||
xlsxWorksheetType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet"
|
||||
xlsxStylesType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles"
|
||||
xlsxThemeType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme"
|
||||
xlsxWorkbookContentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"
|
||||
xlsxWorksheetContentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"
|
||||
xlsxStylesContentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"
|
||||
xlsxThemeContentType = "application/vnd.openxmlformats-officedocument.theme+xml"
|
||||
xlsxCoreContentType = "application/vnd.openxmlformats-package.core-properties+xml"
|
||||
xlsxAppContentType = "application/vnd.openxmlformats-officedocument.extended-properties+xml"
|
||||
)
|
||||
|
||||
var (
|
||||
xlsxTemplatePartsOnce sync.Once
|
||||
xlsxTemplateParts map[string][]byte
|
||||
xlsxTemplatePartsErr error
|
||||
)
|
||||
|
||||
type xlsxExportTempSheet struct {
|
||||
name string
|
||||
path string
|
||||
file *os.File
|
||||
writer *bufio.Writer
|
||||
closed bool
|
||||
}
|
||||
|
||||
type xlsxExportFileWriter struct {
|
||||
file *os.File
|
||||
columns []string
|
||||
columnRefs []string
|
||||
rowBuf []string
|
||||
sheets []*xlsxExportTempSheet
|
||||
current *xlsxExportTempSheet
|
||||
nextRow int
|
||||
sheetNo int
|
||||
rowCount int
|
||||
maxRows int
|
||||
}
|
||||
|
||||
func newXLSXExportFileWriter(f *os.File, maxRowsPerSheet int) (*xlsxExportFileWriter, error) {
|
||||
if f == nil {
|
||||
return nil, fmt.Errorf("file required")
|
||||
}
|
||||
return &xlsxExportFileWriter{
|
||||
file: f,
|
||||
maxRows: normalizeXLSXRowsPerSheet(maxRowsPerSheet),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (w *xlsxExportFileWriter) SetColumns(columns []string) error {
|
||||
w.columns = append([]string(nil), columns...)
|
||||
w.columnRefs = make([]string, len(columns))
|
||||
w.rowBuf = make([]string, len(columns))
|
||||
for i := range columns {
|
||||
ref, err := excelize.ColumnNumberToName(i + 1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.columnRefs[i] = ref
|
||||
}
|
||||
return w.rotateSheet()
|
||||
}
|
||||
|
||||
func (w *xlsxExportFileWriter) rotateSheet() error {
|
||||
if err := w.closeCurrentSheet(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tmpFile, err := os.CreateTemp("", "gonavi-export-sheet-*.xml")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sheet := &xlsxExportTempSheet{
|
||||
name: fmt.Sprintf("Sheet%d", w.sheetNo+1),
|
||||
path: tmpFile.Name(),
|
||||
file: tmpFile,
|
||||
writer: bufio.NewWriterSize(tmpFile, 1024*256),
|
||||
}
|
||||
if err := writeXLSXSheetHeader(sheet.writer); err != nil {
|
||||
_ = tmpFile.Close()
|
||||
_ = os.Remove(tmpFile.Name())
|
||||
return err
|
||||
}
|
||||
|
||||
w.sheetNo++
|
||||
w.current = sheet
|
||||
w.sheets = append(w.sheets, sheet)
|
||||
w.rowCount = 0
|
||||
w.nextRow = 2
|
||||
return w.writeStringRow(1, w.columns)
|
||||
}
|
||||
|
||||
func (w *xlsxExportFileWriter) ConsumeRow(row map[string]interface{}) error {
|
||||
if w.rowCount >= w.maxRows {
|
||||
if err := w.rotateSheet(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
values := w.rowBuf
|
||||
for i, col := range w.columns {
|
||||
val := row[col]
|
||||
if val == nil {
|
||||
values[i] = "NULL"
|
||||
continue
|
||||
}
|
||||
values[i] = formatExportCellText(val)
|
||||
}
|
||||
if err := w.writeStringRow(w.nextRow, values); err != nil {
|
||||
return err
|
||||
}
|
||||
w.nextRow++
|
||||
w.rowCount++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *xlsxExportFileWriter) ConsumeRowValues(values []interface{}) error {
|
||||
if w.rowCount >= w.maxRows {
|
||||
if err := w.rotateSheet(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
record := w.rowBuf
|
||||
for i := range w.columns {
|
||||
var value interface{}
|
||||
if i < len(values) {
|
||||
value = values[i]
|
||||
}
|
||||
if value == nil {
|
||||
record[i] = "NULL"
|
||||
continue
|
||||
}
|
||||
record[i] = formatExportCellText(value)
|
||||
}
|
||||
if err := w.writeStringRow(w.nextRow, record); err != nil {
|
||||
return err
|
||||
}
|
||||
w.nextRow++
|
||||
w.rowCount++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *xlsxExportFileWriter) Close() error {
|
||||
if err := w.closeCurrentSheet(); err != nil {
|
||||
w.cleanupTempSheets()
|
||||
return err
|
||||
}
|
||||
if len(w.sheets) == 0 {
|
||||
w.cleanupTempSheets()
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := w.file.Truncate(0); err != nil {
|
||||
w.cleanupTempSheets()
|
||||
return err
|
||||
}
|
||||
if _, err := w.file.Seek(0, 0); err != nil {
|
||||
w.cleanupTempSheets()
|
||||
return err
|
||||
}
|
||||
|
||||
zw := zip.NewWriter(w.file)
|
||||
defer w.cleanupTempSheets()
|
||||
|
||||
if err := writeXLSXZipFile(zw, w.sheets); err != nil {
|
||||
_ = zw.Close()
|
||||
return err
|
||||
}
|
||||
return zw.Close()
|
||||
}
|
||||
|
||||
func (w *xlsxExportFileWriter) closeCurrentSheet() error {
|
||||
if w.current == nil || w.current.closed {
|
||||
return nil
|
||||
}
|
||||
if _, err := w.current.writer.WriteString(`</sheetData></worksheet>`); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := w.current.writer.Flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := w.current.file.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
w.current.closed = true
|
||||
w.current = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *xlsxExportFileWriter) cleanupTempSheets() {
|
||||
for _, sheet := range w.sheets {
|
||||
if sheet == nil {
|
||||
continue
|
||||
}
|
||||
if !sheet.closed && sheet.file != nil {
|
||||
_ = sheet.writer.Flush()
|
||||
_ = sheet.file.Close()
|
||||
}
|
||||
if sheet.path != "" {
|
||||
_ = os.Remove(sheet.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *xlsxExportFileWriter) writeStringRow(rowNumber int, values []string) error {
|
||||
if w.current == nil {
|
||||
return fmt.Errorf("xlsx sheet not initialized")
|
||||
}
|
||||
|
||||
if _, err := w.current.writer.WriteString(`<row r="`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.current.writer.WriteString(strconv.Itoa(rowNumber)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.current.writer.WriteString(`">`); err != nil {
|
||||
return err
|
||||
}
|
||||
rowRef := strconv.Itoa(rowNumber)
|
||||
for i, value := range values {
|
||||
if err := writeXLSXInlineStringCell(w.current.writer, w.columnRefs[i], rowRef, value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err := w.current.writer.WriteString(`</row>`)
|
||||
return err
|
||||
}
|
||||
|
||||
func writeXLSXZipFile(zw *zip.Writer, sheets []*xlsxExportTempSheet) error {
|
||||
parts, err := loadXLSXTemplateParts()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := writeZipBytes(zw, "[Content_Types].xml", buildXLSXContentTypes(len(sheets))); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeZipBytes(zw, "_rels/.rels", parts["_rels/.rels"]); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeZipBytes(zw, "docProps/app.xml", parts["docProps/app.xml"]); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeZipBytes(zw, "docProps/core.xml", parts["docProps/core.xml"]); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeZipBytes(zw, "xl/workbook.xml", buildXLSXWorkbookXML(sheets)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeZipBytes(zw, "xl/_rels/workbook.xml.rels", buildXLSXWorkbookRels(sheets)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeZipBytes(zw, "xl/styles.xml", parts["xl/styles.xml"]); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeZipBytes(zw, "xl/theme/theme1.xml", parts["xl/theme/theme1.xml"]); err != nil {
|
||||
return err
|
||||
}
|
||||
for idx, sheet := range sheets {
|
||||
if err := writeZipFileFromPath(zw, fmt.Sprintf("xl/worksheets/sheet%d.xml", idx+1), sheet.path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadXLSXTemplateParts() (map[string][]byte, error) {
|
||||
xlsxTemplatePartsOnce.Do(func() {
|
||||
workbook := excelize.NewFile()
|
||||
defer workbook.Close()
|
||||
|
||||
required := []string{
|
||||
"_rels/.rels",
|
||||
"docProps/app.xml",
|
||||
"docProps/core.xml",
|
||||
"xl/styles.xml",
|
||||
"xl/theme/theme1.xml",
|
||||
}
|
||||
xlsxTemplateParts = make(map[string][]byte, len(required))
|
||||
for _, path := range required {
|
||||
value, ok := workbook.Pkg.Load(path)
|
||||
if !ok {
|
||||
xlsxTemplatePartsErr = fmt.Errorf("xlsx template part missing: %s", path)
|
||||
return
|
||||
}
|
||||
xlsxTemplateParts[path] = append([]byte(nil), value.([]byte)...)
|
||||
}
|
||||
})
|
||||
return xlsxTemplateParts, xlsxTemplatePartsErr
|
||||
}
|
||||
|
||||
func writeZipBytes(zw *zip.Writer, name string, content []byte) error {
|
||||
writer, err := zw.Create(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = writer.Write(content)
|
||||
return err
|
||||
}
|
||||
|
||||
func writeZipFileFromPath(zw *zip.Writer, name string, path string) error {
|
||||
writer, err := zw.Create(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
_, err = io.Copy(writer, file)
|
||||
return err
|
||||
}
|
||||
|
||||
func writeXLSXSheetHeader(writer *bufio.Writer) error {
|
||||
_, err := writer.WriteString(`<?xml version="1.0" encoding="UTF-8"?>` +
|
||||
`<worksheet xmlns="` + xlsxMainNamespace + `" xmlns:r="` + xlsxRelationshipsNamespace + `">` +
|
||||
`<sheetViews><sheetView workbookViewId="0"/></sheetViews>` +
|
||||
`<sheetFormatPr defaultRowHeight="15"/>` +
|
||||
`<sheetData>`)
|
||||
return err
|
||||
}
|
||||
|
||||
func writeXLSXInlineStringCell(writer *bufio.Writer, columnRef string, rowRef string, value string) error {
|
||||
if _, err := writer.WriteString(`<c r="`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := writer.WriteString(columnRef); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := writer.WriteString(rowRef); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := writer.WriteString(`" t="inlineStr"><is><t`); err != nil {
|
||||
return err
|
||||
}
|
||||
if value != strings.TrimSpace(value) {
|
||||
if _, err := writer.WriteString(` xml:space="preserve"`); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := writer.WriteString(`>`); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := xml.EscapeText(writer, []byte(value)); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := writer.WriteString(`</t></is></c>`)
|
||||
return err
|
||||
}
|
||||
|
||||
func buildXLSXContentTypes(sheetCount int) []byte {
|
||||
var builder strings.Builder
|
||||
builder.Grow(1024 + sheetCount*128)
|
||||
builder.WriteString(`<?xml version="1.0" encoding="UTF-8"?>`)
|
||||
builder.WriteString(`<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">`)
|
||||
builder.WriteString(`<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>`)
|
||||
builder.WriteString(`<Default Extension="xml" ContentType="application/xml"/>`)
|
||||
builder.WriteString(`<Override PartName="/xl/workbook.xml" ContentType="` + xlsxWorkbookContentType + `"/>`)
|
||||
builder.WriteString(`<Override PartName="/xl/styles.xml" ContentType="` + xlsxStylesContentType + `"/>`)
|
||||
builder.WriteString(`<Override PartName="/xl/theme/theme1.xml" ContentType="` + xlsxThemeContentType + `"/>`)
|
||||
builder.WriteString(`<Override PartName="/docProps/core.xml" ContentType="` + xlsxCoreContentType + `"/>`)
|
||||
builder.WriteString(`<Override PartName="/docProps/app.xml" ContentType="` + xlsxAppContentType + `"/>`)
|
||||
for i := 1; i <= sheetCount; i++ {
|
||||
builder.WriteString(`<Override PartName="/xl/worksheets/sheet`)
|
||||
builder.WriteString(strconv.Itoa(i))
|
||||
builder.WriteString(`.xml" ContentType="`)
|
||||
builder.WriteString(xlsxWorksheetContentType)
|
||||
builder.WriteString(`"/>`)
|
||||
}
|
||||
builder.WriteString(`</Types>`)
|
||||
return []byte(builder.String())
|
||||
}
|
||||
|
||||
func buildXLSXWorkbookXML(sheets []*xlsxExportTempSheet) []byte {
|
||||
var builder strings.Builder
|
||||
builder.Grow(512 + len(sheets)*96)
|
||||
builder.WriteString(`<?xml version="1.0" encoding="UTF-8"?>`)
|
||||
builder.WriteString(`<workbook xmlns="` + xlsxMainNamespace + `" xmlns:r="` + xlsxRelationshipsNamespace + `">`)
|
||||
builder.WriteString(`<bookViews><workbookView workbookViewId="0"/></bookViews>`)
|
||||
builder.WriteString(`<sheets>`)
|
||||
for i, sheet := range sheets {
|
||||
builder.WriteString(`<sheet name="`)
|
||||
builder.WriteString(sheet.name)
|
||||
builder.WriteString(`" sheetId="`)
|
||||
builder.WriteString(strconv.Itoa(i + 1))
|
||||
builder.WriteString(`" r:id="rId`)
|
||||
builder.WriteString(strconv.Itoa(i + 1))
|
||||
builder.WriteString(`"/>`)
|
||||
}
|
||||
builder.WriteString(`</sheets>`)
|
||||
builder.WriteString(`</workbook>`)
|
||||
return []byte(builder.String())
|
||||
}
|
||||
|
||||
func buildXLSXWorkbookRels(sheets []*xlsxExportTempSheet) []byte {
|
||||
var builder strings.Builder
|
||||
builder.Grow(512 + len(sheets)*160)
|
||||
builder.WriteString(`<?xml version="1.0" encoding="UTF-8"?>`)
|
||||
builder.WriteString(`<Relationships xmlns="` + xlsxPackageRelationshipsNS + `">`)
|
||||
for i := range sheets {
|
||||
builder.WriteString(`<Relationship Id="rId`)
|
||||
builder.WriteString(strconv.Itoa(i + 1))
|
||||
builder.WriteString(`" Type="`)
|
||||
builder.WriteString(xlsxWorksheetType)
|
||||
builder.WriteString(`" Target="worksheets/sheet`)
|
||||
builder.WriteString(strconv.Itoa(i + 1))
|
||||
builder.WriteString(`.xml"/>`)
|
||||
}
|
||||
builder.WriteString(`<Relationship Id="rId`)
|
||||
builder.WriteString(strconv.Itoa(len(sheets) + 1))
|
||||
builder.WriteString(`" Type="`)
|
||||
builder.WriteString(xlsxStylesType)
|
||||
builder.WriteString(`" Target="styles.xml"/>`)
|
||||
builder.WriteString(`<Relationship Id="rId`)
|
||||
builder.WriteString(strconv.Itoa(len(sheets) + 2))
|
||||
builder.WriteString(`" Type="`)
|
||||
builder.WriteString(xlsxThemeType)
|
||||
builder.WriteString(`" Target="theme/theme1.xml"/>`)
|
||||
builder.WriteString(`</Relationships>`)
|
||||
return []byte(builder.String())
|
||||
}
|
||||
Reference in New Issue
Block a user