跳到主要内容

Tabular Import & Export

VEF 在 tabular 包中提供统一的表格引擎,并通过 csvexcel 两个轻量驱动包暴露具体格式。三者拥有完全对称的工厂函数,所有读写都经由同一个 RowAdapter 抽象。这意味着:

  • 静态行(用 tabular 标签描述的 Go 结构体)和
  • 动态行(运行期定义列、数据是 map[string]any

共用同一条 importer / exporter 流水线。你只需选择合适的适配器,格式驱动负责剩下的工作。

架构

tabular/ // schema、列、适配器、formatter / parser、错误
├── adapter.go // RowAdapter, RowReader, RowView, RowWriter, RowBuilder
├── schema.go // Schema, Column
├── struct_adapter.go // StructAdapter(结构体 + 框架 validator)
├── map_adapter.go // MapAdapter(map + Required / Validators / RowValidator)
├── spec.go // ColumnSpec, NewSchemaFromSpecs, NewMapAdapterFromSpecs
├── resolver.go // FormatterFn / Formatter / 默认实现的优先级解析
├── mapping.go // Header → schema 列映射
├── parse_row.go // ParseRow, IsEmptyRow
├── import_rows.go // ImportRows(驱动共享的 core)
├── typed.go // TypedImporter[T] / TypedExporter[T] 包装器
└── errors.go // 共享错误(ErrRequiredMissing、ErrSchemaMismatch 等)

csv/ // CSV 驱动:NewImporter、NewExporter、NewImporterFor、NewExporterFor、
// NewMapImporter、NewMapExporter、NewTyped*For
excel/ // Excel 驱动,与 csv 对称,外加 sheet 等 Excel 专属选项

csv / excel 不持有任何 model 相关的反射或私有错误类型。常用错误统一定义在 tabularErrDataMustBeSliceErrRequiredMissingErrUnknownColumnErrSchemaMismatch 等),仅 excel.ErrSheetIndexOutOfRange 是驱动专属的。

何时使用哪种用法

场景推荐工厂
已有 Go 结构体(例如某个 model)描述了所有列csv.NewImporterFor[T] / csv.NewExporterFor[T]excel.NewImporterFor[T] / excel.NewExporterFor[T](以及 *Typed* 变体)
列在运行期才确定——多租户表格、用户自定义模板、动态表单csv.NewMapImporter / csv.NewMapExporterexcel.NewMapImporter / excel.NewMapExporter,由 []tabular.ColumnSpec 驱动
行数据自带特殊来源(channel、自定义业务类型等)自行实现 tabular.RowAdapter,传入 csv.NewImporter / csv.NewExporterexcel.NewImporter / excel.NewExporter

导入返回类型由适配器决定:

  • 结构体适配器 → []T
  • map 适配器 → []map[string]any

选定适配器之后,再选择具体格式:

特性CSVExcel
文件格式.csv(纯文本).xlsx(二进制)
多工作表不支持支持
列宽度忽略应用
分隔符可配置不适用
注释行支持不适用
空白修剪可配置不适用
换行符LF 或 CRLF不适用
Native typed cell(数字/日期在文件中仍可排序)不适用(文本格式)默认支持
依赖Go 标准库excelize

两个包都实现了 tabular.Importer / tabular.Exporter 接口,因此可以在不更改模型定义的情况下互换使用。

tabular 标签

使用结构体标签定义字段如何映射到列:

type Employee struct {
orm.FullAuditedModel `tabular:"-"`

Name string `tabular:"姓名,width=20"`
Email string `tabular:"邮箱,width=30"`
Department string `tabular:"name=部门,order=2,width=15"`
JoinDate timex.Date `tabular:"入职日期,format=2006-01-02,width=15"`
Salary decimal.Decimal `tabular:"薪资,width=12,formatter=money"` // 形如 "#,##0.00" 这种含逗号的 format 无法通过 tag 配置 —— 改注册一个 formatter
Status string `tabular:"状态,default=active,formatter=status"`
}

标签属性

属性类型说明
(默认值)string列标题名称
namestring显式列名(默认值的替代写法)
orderint列显示顺序(从 0 开始,默认:字段声明顺序)
widthfloat64列宽度提示(Excel 导出时使用)
defaultstring导入时空单元格的默认值
formatstring格式化模板(日期格式、数字格式)
formatterstring导出时的自定义格式化器名称
parserstring导入时的自定义解析器名称

特殊标签

标签含义
tabular:"-"完全忽略该字段
tabular:"dive"递归进入嵌入结构体字段

tag parser 使用逗号分隔的 key=value 对。分号不是分隔符;tabular:"name=ID;order=1" 会被当作一个 name 值。

dive 只会递归进入结构体或指向结构体的指针字段;作用在其他 kind 上时,该字段既不会被递归也不会被输出,框架会记录一条警告,而不是悄悄丢弃它。

上面每个属性键和哨兵值都同时以 tabular 常量的形式导出,供以编程方式构建或检查标签的调用方使用,而不必手写字符串字面量:TagTabular(结构体标签名本身,值为 "tabular")、IgnoreField"-" 忽略哨兵值)、AttrNameAttrOrderAttrWidthAttrDefaultAttrFormatAttrFormatterAttrParser 以及 AttrDive

Schema

Schema 在初始化时预解析表格元数据——可以来自结构体类型(NewSchemaFor[T] / NewSchema),也可以来自动态列描述(NewSchemaFromSpecs):

schema := tabular.NewSchemaFor[Employee]()

columns := schema.Columns() // []*Column — 所有解析出的列
names := schema.ColumnNames() // []string{"姓名", "邮箱", ...}
count := schema.ColumnCount() // 6
col, ok := schema.ColumnByKey("Name") // 按逻辑 key(结构体字段名)查找
col, ok = schema.ColumnByName("姓名") // 按 header 名称查找

列会按 order 属性自动排序。未显式指定 order 的字段使用其声明顺序。

NewSchemaFromSpecs 会在构造期校验动态 schema:缺 Key 返回 ErrMissingColumnKey,缺 Type 返回 ErrMissingColumnType,重复 key 返回 ErrDuplicateColumnKey,解析后的 header name 重复返回 ErrDuplicateHeaderName

Column

无论是结构体解析出来的列,还是动态列,都用同一个 Column 结构体表示:

字段含义
Key逻辑标识:结构体字段名,或动态 schema 中的 map key
Name导出时显示的表头文本,导入时用于匹配表头;默认等于 Key
Type解析单元格值所用的 reflect.Type
Order列顺序的稳定排序键
Width列宽提示(Excel)
Default导入时,源单元格为空时使用的默认值
Format默认 Formatter/ValueParser 使用的格式模板
Formatter / Parser从导出器 / 导入器注册表按名字查找
FormatterFn / ParserFn直接绑定在列上的 Formatter / ValueParser 实例(最高优先级)
Required导入时空单元格触发 ErrRequiredMissing(动态 schema)
Validators解析后执行的 []CellValidator(动态 schema)
IndexStructAdapter 使用的结构体字段 index path;动态列为 nil

接口

Importer

type Importer interface {
RegisterParser(name string, parser ValueParser)
ImportFromFile(filename string) (any, []ImportError, error)
Import(reader io.Reader) (any, []ImportError, error)
}

Exporter

type Exporter interface {
RegisterFormatter(name string, formatter Formatter)
ExportToFile(data any, filename string) error
Export(data any) (*bytes.Buffer, error)
}

Formatter(导出)

type Formatter interface {
Format(value any) (string, error)
}

// 便捷适配器
tabular.FormatterFunc(func(value any) (string, error) { ... })

ValueParser(导入)

type ValueParser interface {
Parse(cellValue string, targetType reflect.Type) (any, error)
}

// 便捷适配器
tabular.ParserFunc(func(cellValue string, targetType reflect.Type) (any, error) { ... })

Header 映射与行导入

两个驱动都通过同一套共享 core 解析 header:

  • BuildHeaderMapping(headerRow, schema, opts)Column.Name 匹配 header 单元格。启用 MappingOptions.TrimSpace 时,会先修剪 header name 再匹配。空 header 单元格会跳过;未知 header 单元格会跳过(不会因为多余列失败)。重复的非空 header 是致命错误:ErrDuplicateHeaderName
  • Importer 配置为 WithoutHeader() 时,driver 会回退到 DefaultPositionalMapping(schema)——源文件第 0 列对应 schema 第一列,依此类推。
  • ParseRow(cells, mapping, schema, builder, parsers, rowNumber, opts) 会先应用 Column.Default 再解析单元格;默认值替换后仍为空的 cell 会被跳过;parse 和 Set failure 会作为行级 ImportError 返回。如果返回了 row error,row builder 不会提交 partial row。
  • IsEmptyRow(cells, trimSpace) 判断一行的所有单元格是否都为空(用于自动跳过空行)。
  • tabular.ImportRows(rows, adapter, parsers, opts) 会通过 RowAdapter 解析一个已经 materialized 的 [][]string 表格。ImportRowsOptions 控制 SkipRowsHasHeader(是否按 header 映射,还是按位置映射)以及 TrimSpace。CSV 和 Excel importer 读取各自格式后,都会委托给这个共享 core。

BuildHeaderMappingDefaultPositionalMapping 都返回原始的 map[int]int(源列 index → schema 列 index);ImportRows 每次导入只调用一次 NewColumnMapping 把它包装成 ColumnMapping,预先排好源 index,避免 ParseRow 每行都重新排序。ParseRow 本身接受一个 ParseRowOptions{TrimSpace bool}ParseRowOptions.TrimSpace)控制单元格级 trim,它和 header 级的 MappingOptions 是两个不同的类型。

静态结构体用法

在结构体字段上打 tabular 标签,然后用 csv.NewImporterFor[T] / excel.NewImporterFor[T](或对应的 exporter)为该结构体创建类型化的 importer/exporter。校验委托给框架的 validator 包——继续使用 validate:"…" 标签即可,提交每行时会自动执行。

导出

exp := csv.NewExporterFor[Employee]()
buf, err := exp.Export(employees) // employees: []Employee 或 []*Employee
// 或者直接写入磁盘:
err = exp.ExportToFile(employees, "employees.csv")

Excel:

exp := excel.NewExporterFor[Employee](excel.WithSheetName("Employees"))
buf, err := exp.Export(employees)

导入

imp := csv.NewImporterFor[Employee]()
result, rowErrors, err := imp.Import(reader)
if err != nil {
return err // 顶层失败(例如文件损坏)
}
employees := result.([]Employee)
for _, ie := range rowErrors {
log.Warnf("row %d column %s: %v", ie.Row, ie.Column, ie.Err)
}

行级失败(解析错误、结构体校验失败、adapter commit 失败)会聚合到 []tabular.ImportError不会中断整体导入。顶层 error 仅在出现致命问题(无法读取文件、Header 行损坏、没有数据行)时返回。

Typed 包装器

any 返回值有时不便使用,两个包都提供了泛型包装器替你做类型断言:

imp := csv.NewTypedImporterFor[Employee]()
employees, rowErrors, err := imp.Import(reader) // employees 直接是 []Employee,无需类型断言

exp := csv.NewTypedExporterFor[Employee]()
buf, err := exp.Export(employees) // 直接接受 []Employee

TypedImporter[T] / TypedExporter[T] 包裹底层的 tabular.Importer / tabular.Exporter。如需直接调用 RegisterParser / RegisterFormatter,可以使用 TypedImporter.Inner / TypedExporter.Inner 取出内部实例。如果被包装的 importer 返回的行元素类型与 T 不匹配,typed 包装器会返回 ErrTypedRowMismatch

csv.NewTypedImporterFor[T] / excel.NewTypedImporterFor[T](以及对应的 exporter)是对 tabular.NewTypedImporter[T](inner) / tabular.NewTypedExporter[T](inner) 的便捷封装。如果你包装的是自己构造的 Importer/Exporter(例如 csv.NewImporter(adapter, ...) 搭配自定义 RowAdapter,而不是结构体适配器的 *For 变体),直接调用 tabular 的构造器即可。

动态 Map 用法

动态列允许在运行期构造 schema,无需预先声明结构体。每列由 tabular.ColumnSpec 描述:

import (
"reflect"
"time"

"github.com/coldsmirk/vef-framework-go/csv"
"github.com/coldsmirk/vef-framework-go/excel"
"github.com/coldsmirk/vef-framework-go/tabular"
)

specs := []tabular.ColumnSpec{
{Key: "id", Name: "用户ID", Type: reflect.TypeFor[int](), Required: true, Order: 1},
{Key: "name", Name: "姓名", Type: reflect.TypeFor[string](), Required: true, Order: 2},
{Key: "birthday", Name: "生日", Type: reflect.TypeFor[time.Time](), Format: "2006-01-02", Order: 3},
{Key: "active", Name: "激活", Type: reflect.TypeFor[bool](), Default: "false", Order: 4},
}

ColumnSpec 字段:

字段必填说明
Key逻辑标识,也是读写时使用的 map key,必须唯一
Type解析目标类型,使用 reflect.TypeFor[T]()
NameHeader 名称,默认等于 Key
Order列顺序的稳定排序键
WidthExcel 列宽提示
DefaultColumnSpec.Default源单元格为空时的默认值
Format默认 formatter / parser 的模板(日期、浮点等)
Formatter / Parser名字,从 importer / exporter 的注册表查找
FormatterFn / ParserFn直接绑定的 tabular.Formatter / tabular.ValueParser 实例
Required导入时空值会触发 ErrRequiredMissing
Validators解析后执行的 []CellValidator

NewSchemaFromSpecs 会立即校验输入:缺 Key、缺 TypeKey 重复、解析后的 Name 重复都会在构造期返回错误(ErrMissingColumnKeyErrMissingColumnTypeErrDuplicateColumnKeyErrDuplicateHeaderName)。

导出

exp, err := excel.NewMapExporter(specs, excel.WithSheetName("Users"))
if err != nil { return err }

buf, err := exp.Export([]map[string]any{
{"id": 1, "name": "张三", "birthday": time.Now(), "active": true},
{"id": 2, "name": "李四", "birthday": time.Now(), "active": false},
})

CSV 完全对称:

exp, err := csv.NewMapExporter(specs)
buf, err := exp.Export(rows)

导入

imp, err := csv.NewMapImporter(specs, nil) // 第二参数 nil 表示不附加 MapAdapter 选项
if err != nil { return err }

result, rowErrors, err := imp.Import(reader)
if err != nil { return err }

rows := result.([]map[string]any)

行为细节:

  • 源文件中未知的 header 直接跳过——多余的列不会让导入失败。
  • schema 中存在但源文件没有的列,不会出现在解析结果的 map 中(key 不存在,而不是零值)。这样 Required 与行级校验可以区分「缺失」和「显式零值」。
  • 单元格经 TrimSpace(CSV 与 Excel 默认均开启,可用 WithoutTrimSpace() 关闭)+ Default 兜底后,仍为空字符串时会跳过 Set:结构体保持零值,map 保留 key 缺失。
  • 单元格解析错误、行 Commit 错误、校验错误都会聚合到 []tabular.ImportError,文件其余部分继续处理。

行级校验

MapOption 作为 NewMapImporter 的第二个参数传入:

imp, err := csv.NewMapImporter(specs,
[]tabular.MapOption{
tabular.WithRowValidator(func(row map[string]any) error {
if row["name"] == "" {
return errors.New("name must not be empty")
}
return nil
}),
},
)

单元格级校验在每列的 Validators 中配置:

specs := []tabular.ColumnSpec{
{
Key: "email",
Name: "邮箱",
Type: reflect.TypeFor[string](),
Validators: []tabular.CellValidator{
func(col *tabular.Column, value any) error {
s, _ := value.(string)
if !strings.Contains(s, "@") {
return fmt.Errorf("invalid email: %q", s)
}
return nil
},
},
},
}

ColumnSpec.Required、每列的 Validators、以及 map 级 RowValidator 都会在 map-row commit 时执行。RequiredValidatorsRowValidator 的错误会通过 errors.Join 合并成单行的一个错误。要枚举所有叶子错误:

for _, ie := range rowErrors {
if errors.Is(ie.Err, tabular.ErrRequiredMissing) {
// …
}
if multi, ok := ie.Err.(interface{ Unwrap() []error }); ok {
for _, leaf := range multi.Unwrap() {
log.Warn(leaf)
}
}
}

自定义 Formatter 与 Parser

实现 tabular 中的小接口,或者用 tabular.FormatterFunc / tabular.ParserFunc 把普通函数适配进去。每列按以下三级优先级解析(见 tabular/resolver.go):

  1. Column.FormatterFn / Column.ParserFn——直接绑定在列上的实例(最高优先级)
  2. Column.Formatter / Column.Parser——根据名字到 importer / exporter 注册表查找,使用 RegisterFormatter / RegisterParser 注册
  3. 默认 formatter / parser——使用 Column.Format 处理日期、浮点等

ResolveFormatter(col, registry) / ResolveParser(col, registry) 对单列执行这套优先级;ResolveFormatters / ResolveParsers 一次性为每一列都执行一遍,结果与 schema.Columns() 对齐,driver 因此不需要在逐个单元格处理时重复查表。tabular.IsDefaultFormatter(col, registry) 判断某列是否解析到内置默认实现(没有 FormatterFn,也没有注册命名 Formatter);Excel 导出器用它来决定是写 native typed cell 还是写格式化字符串(见 Excel → Native Typed Cell)。

直接绑定(最高优先级)示例:

yenFormatter := tabular.FormatterFunc(func(v any) (string, error) {
return fmt.Sprintf("¥%.2f", v), nil
})

specs := []tabular.ColumnSpec{
{
Key: "price",
Name: "Price",
Type: reflect.TypeFor[float64](),
FormatterFn: yenFormatter,
},
}

命名注册表示例(结构体与 map 适配器都适用):

exp := csv.NewExporterFor[Order]()
exp.RegisterFormatter("currency", currencyFormatter)
// 标签上写了 `formatter=currency` 的列会使用它
importer := csv.NewImporterFor[Employee]()

importer.RegisterParser("date", tabular.ParserFunc(func(cellValue string, targetType reflect.Type) (any, error) {
return time.Parse("01/02/2006", cellValue)
}))

自定义 RowAdapter

任何数据源都可以通过实现 tabular.RowAdapter 接入引擎:

type RowAdapter interface {
Schema() *Schema
Reader(data any) (RowReader, error)
Writer(capacity int) RowWriter
}

type RowReader interface {
All() iter.Seq2[int, RowView]
}

type RowView interface {
Get(column *Column) (any, error)
}

type RowWriter interface {
NewRow() RowBuilder
Commit(row RowBuilder) error
Build() any
}

type RowBuilder interface {
Set(column *Column, value any) error
Validate() error
Value() any
}

tabular.NewStructAdapter(typ) / tabular.NewStructAdapterFor[T]()tabular.NewMapAdapter(schema, opts...) / tabular.NewMapAdapterFromSpecs(specs, opts...) 是两个内置实现。自定义 adapter 可以直接接入:

adapter := myStreamingAdapter()
imp := csv.NewImporter(adapter)
exp := excel.NewExporter(adapter)

适合用于 channel 流式数据源、JOIN 视图、或既不是结构体也不是 map 的业务类型。

默认类型支持

内置的 DefaultParserDefaultFormattertabular.NewDefaultParser(format) / tabular.NewDefaultFormatter(format))会自动处理以下类型,CSV 与 Excel 都一样:

Go 类型导入(解析)导出(格式化)
string直接赋值直接输出
intint8int64整数解析整数格式化
uintuint8uint64无符号整数解析整数格式化
float32float64浮点数解析浮点数格式化
booltrue/false1/0布尔格式化
decimal.DecimalDecimal 字符串解析Decimal 格式化
time.Time使用 format 属性(默认 time.DateTime使用 format 属性(默认 time.DateTime
timex.Date / timex.DateTime / timex.Time使用 format 属性使用 format 属性
*T(指针类型)空值为 nil,否则解析优雅处理 nil

错误类型

ImportErrorExportError 都实现了标准的 Unwrap() error 方法,因此标准库的 errors.Unwraperrors.Iserrors.As 都能直接作用于它们,无需任何 tabular 专属的解包辅助函数。

ImportError

type ImportError struct {
Row int // 基于 1 的行号(包含表头行)
Column string // 列标题名称
Field string // 结构体字段名
Err error // 底层错误
}

ImportError 实现了 errorUnwrap() errorImportError.Unwrap)。当单行产生多个失败(例如多个 Required miss,或者 cell validator 与 row validator 同时失败)时,Err 本身可能通过 errors.Join 携带多个叶子错误——对 ImportError 使用 errors.Is 可以匹配特定原因;如果要枚举所有叶子错误,把 Err 断言为 interface{ Unwrap() []error }

导入错误按行返回,不会中断导入过程。这允许批量处理:有效行被导入,无效行被报告。

ExportError

type ExportError struct {
Row int // 基于 0 的数据行索引
Column string // 列标题名称
Field string // 结构体字段名
Err error // 底层错误
}

ExportError 同样实现了 errorUnwrap() errorExportError.Unwrap)。

共享错误哨兵

tabular 暴露一组两个驱动共享的错误。请使用 errors.Is 判断,不要做字符串匹配:

错误触发场景
ErrDataMustBeSlice导出参数不是切片
ErrSchemaMismatch元素类型与适配器 schema 不匹配(结构体 / map 不一致)
ErrUnknownColumn调用方引用了 schema 中不存在的列
ErrRequiredMissing动态导入时 Required 单元格为空
ErrNoDataRowsFound经 skip-rows 与可选 header 处理后没有数据行
ErrDuplicateHeaderNameHeader 行存在重复非空名,或动态 schema 中两列解析出同一个 name
ErrDuplicateColumnKey动态 ColumnSpec 切片中有两个条目 Key 相同
ErrUnsetField结构体字段不可写(通常是未导出字段)
ErrMissingColumnKey / ErrMissingColumnTypeColumnSpec 缺关键字段
ErrTypedRowMismatchTypedImporter[T] / TypedExporter[T] 收到的元素类型不是 T
ErrUnsupportedType默认 parser 被要求解析成一个它不认识的 Go 类型

excel.ErrSheetIndexOutOfRange 是唯一的驱动专属 sentinel——见 Excel → 错误处理

CRUD 集成

ExportImport CRUD 构建器内部使用 tabular

// 导出构建器
crud.NewExport[Employee, EmployeeSearch]().
WithDefaultFormat("excel")

// 导入构建器
crud.NewImport[Employee]().
WithDefaultFormat("excel").
WithPreImport(func(ctx context.Context, models []Employee) error {
// 插入前验证或转换
return nil
})

WithDefaultFormat 接受一个 crud.TabularFormatcrud.FormatExcel / crud.FormatCsv,或等价的字符串常量 "excel" / "csv"),当请求未显式指定格式时使用。完整的构建器 API(包括 WithExcelOptionsWithCsvOptionsWithPreExportWithFilenameBuilder)参见 CRUD → 导出与导入 Builder

格式后端

格式后端基于本核心实现具体的文件类型,两者 API 相互对应:

  • CSV — 分隔符文件,支持有/无表头两种模式
  • Excel.xlsx 工作簿、工作表与样式选项

速查

// 静态结构体往返
imp := csv.NewTypedImporterFor[User]()
exp := csv.NewTypedExporterFor[User]()
buf, _ := exp.Export(users)
imported, errs, _ := imp.Import(buf)

// 动态 map 往返
specs := []tabular.ColumnSpec{
{Key: "id", Name: "ID", Type: reflect.TypeFor[int](), Required: true},
{Key: "name", Name: "Name", Type: reflect.TypeFor[string]()},
}
exp, _ := excel.NewMapExporter(specs, excel.WithSheetName("Data"))
imp, _ := excel.NewMapImporter(specs, nil)
buf, _ := exp.Export([]map[string]any{{"id": 1, "name": "Alice"}})
rows, errs, _ := imp.Import(buf)

// 动态 + 行级校验
imp, _ := csv.NewMapImporter(specs,
[]tabular.MapOption{tabular.WithRowValidator(func(r map[string]any) error {
if r["id"].(int) <= 0 { return errors.New("id must be positive") }
return nil
})},
)

crud.NewExport / crud.NewImport 在以上工厂之上做了进一步封装,参见 CRUD → 导出与导入 Builder