简介:面向ASP.NET Core WebAPI开发者的一份Excel读写示例工程,聚焦服务端表格数据的读取与导出,适合需要在无Office环境中处理Excel、并希望减少Office Interop依赖的后端人员参考。压缩包共208个文件,约21.89MB,以149个dll运行时依赖、12个C#源码、11个json配置为主,另含缓存、调试符号及跨平台原生库,整体呈现可运行的WebAPI项目结构。已有400人学习下载。源码部分覆盖ExcelDataReader流式读取、EPPlus工作簿与工作表创建、单元格数据写入,以及通过HTTP响应返回xlsx文件等关键环节;配合项目文件与配置,可帮助读者理解OpenXML格式导出、MIME类型设置和文件流处理的实现方式,也便于在此基础上扩展数据校验、分批导出与性能优化。
1. 某个管理系统要把 Excel 当接口用,这套 ASP.NET Core WebAPI 就是干这个的
某个管理系统的商品表要批量导入,运营发来一张两千行的 Excel,要求上传后能校验,再把不合格的行导回去。这类需求看着是体力活,真动手就知道麻烦不在业务上:.xls 和 .xlsx 的解析路径不一样,中文表头会变问号,订单号会被 Excel 自己吃掉精度。excel-handler 把这些琐碎事包成一套 ASP.NET Core WebAPI 的读写服务,底层用 ExcelDataReader 负责读、EPPlus 负责写,一个 Controller 两个端点,上传接口回强类型列表,导出接口回一个浏览器能直接下载的文件流。适合手上有个管理系统、正要补导入导出功能的后端同学,也适合想弄清 EPPlus 和 ExcelDataReader 各自边界在哪的开发者。
2. 项目骨架与请求链路:三个工程把 WebAPI 和 Excel 库隔开
第一次拿到这种小项目,容易图省事把 EPPlus 和 ExcelDataReader 直接using在 Controller 里,结果一周后业务方要换导出模板,你得在 Controller 里翻半天。excel-handler 的分层不复杂,但把「协议适配」和「Excel 处理」这两件事掰开了,后面改起来手不抖。
2.1 目录结构:Excel 相关代码只出现在 Infrastructure 一层
目录长这样,三个工程各管一摊:
excel-handler/ ├── ExcelHandler.sln ├── src/ │ ├── ExcelHandler.Api/ # WebAPI 宿主,只做协议适配 │ │ ├── Controllers/ExcelController.cs │ │ ├── Program.cs │ │ └── appsettings.json │ ├── ExcelHandler.Application/ # 接口契约、DTO、列特性 │ │ ├── Abstractions/IExcelReader.cs │ │ ├── Abstractions/IExcelExporter.cs │ │ ├── Attributes/ExcelColumnAttribute.cs │ │ └── Models/ProductRow.cs │ └── ExcelHandler.Infrastructure/ # 第三方库真正被引用的地方 │ ├── Excel/ExcelDataReaderService.cs │ └── Excel/EpplusExcelExporter.cs └── tests/ └── ExcelHandler.Tests/ExcelRoundTripTests.csApi 层只认IFormFile和byte[],不知道 EPPlus 存在;Application 层定义「读一个流、返回IReadOnlyList<T>」这种契约;Infrastructure 才是EPPlus和ExcelDataReader两个 NuGet 包唯一被PackageReference的地方。
这么切的好处很实在:哪天 EPPlus 的授权条款让你公司过不了法务(这事不少见),你只换EpplusExcelExporter.cs一个文件,Controller 一行不动。反过来如果一个项目里到处using OfficeOpenXml,那替换成本就够你喝一壶。
接口定义我一般写成这样,泛型方法,读和写各一个:
// Application/Abstractions/IExcelReader.cs public interface IExcelReader { // sheetIndex 默认读第一个工作表,命名工作表用 string 重载 IReadOnlyList<T> Read<T>(Stream stream, int sheetIndex = 0) where T : new(); // 只取表头,用于导入前的「模板校验」 IReadOnlyList<string> ReadHeaders(Stream stream, int sheetIndex = 0); }where T : new()这个约束是为了让映射代码能new T(),省掉反射构造函数的开销。ReadHeaders看着多余,其实很实用——前端上传前先打一次这个接口,把模板里缺的列名直接提示出来,比等整表解析完再报错体验好得多。
2.2 注册依赖与暴露接口:Program.cs 和 Controller 的全部
依赖注入这段很短,但没有它后面的服务都没法注入:
// Program.cs var builder = WebApplication.CreateBuilder(args); // Excel 读写实现注册为 Scoped:内部没有共享状态,Scoped 足够 builder.Services.AddScoped<IExcelReader, ExcelDataReaderService>(); builder.Services.AddScoped<IExcelExporter, EpplusExcelExporter>(); builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); // 表单上传的大小上限,默认值往往不够用 builder.Services.Configure<FormOptions>(o => { o.MultipartBodyLengthLimit = 50L * 1024 * 1024; // 单个 multipart 段 50MB o.ValueLengthLimit = int.MaxValue; }); var app = builder.Build(); app.MapControllers(); app.Run();MultipartBodyLengthLimit是FormOptions上的属性,控制单个 multipart 段的长度;它和 Kestrel 的MaxRequestBodySize是两个层级的限制,后者是整个请求体的上限,常被忽略。两个都放开,上传才能真的过大文件。
Controller 这边保持很薄,参数校验 + 调服务 + 包返回值,业务判断一律不下沉:
[ApiController] [Route("api/[controller]")] public class ExcelController : ControllerBase { private readonly IExcelReader _reader; private readonly IExcelExporter _exporter; public ExcelController(IExcelReader reader, IExcelExporter exporter) { _reader = reader; _exporter = exporter; } [HttpPost("import")] [RequestSizeLimit(50 * 1024 * 1024)] // 特性级限制,优先级高于全局 public IActionResult Import(IFormFile file, CancellationToken ct) { if (file is null || file.Length == 0) return BadRequest("请选择要上传的 Excel 文件"); var ext = Path.GetExtension(file.FileName).ToLowerInvariant(); if (ext is not (".xls" or ".xlsx" or ".xlsb")) return BadRequest("仅支持 .xls / .xlsx / .xlsb 三种格式"); // 注意 using:IFormFile 的流在读完后要释放,否则大并发下句柄会堆积 using var stream = file.OpenReadStream(); var rows = _reader.Read<ProductRow>(stream); return Ok(new { total = rows.Count, valid = rows.Count(r => r.IsValid()), data = rows }); } [HttpGet("export")] public IActionResult Export() { var rows = ProductRow.Sample(); // 真实项目里换成仓储查询 var bytes = _exporter.Export(rows, "商品明细"); var fileName = $"products-{DateTime.Now:yyyyMMddHHmmss}.xlsx"; return File(bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", fileName); } }[RequestSizeLimit]写在 Action 上,作用范围只到这一个端点,比全局放开安全。File(bytes, contentType, fileName)这个重载在 ASP.NET Core 里会自动把非 ASCII 文件名按 RFC 5987 编码成filename*=UTF-8''...,中文文件名不会乱码,不用自己拼Content-Disposition。
ProductRow.IsValid()这种校验方法放在模型上,而不是塞进 Controller,是为了让导入和「重新校验历史数据」这两条路径复用同一套规则。这个习惯我是踩过坑才养成的:同一套校验逻辑在导入接口和定时任务里各写一份,半年后规则改了只改了一处,数据就开始对不上。
2.3 appsettings 与请求大小:别让默认值把你卡在 30MB
上传接口本地测得好好的,一上生产就返回 413,八成是请求体大小没配够。这个项目的配置项集中放在appsettings.json的一个节点里:
{ "Excel": { "MaxFileSizeMb": 50, "MaxRowCount": 100000, "AllowedExtensions": [ ".xls", ".xlsx", ".xlsb" ] }, "Kestrel": { "Limits": { "MaxRequestBodySize": 52428800 } } }MaxRequestBodySize单位是字节,50MB 就是52428800。这个值写在Kestrel:Limits下会被配置系统自动绑定;如果前面还有一层反向代理网关,网关自己也有独立的请求体上限,需要一并调整,否则请求到不了 Kestrel 就被掐了。
MaxRowCount不是框架自带的,是读服务里自己判断的:超过这个行数直接抛业务异常,返回「单次导入不超过 10 万行」。这个限制看着粗暴,但比让服务器 OOM 然后整个进程重启温柔得多——Excel 导入这种接口,最怕的就是某个人传了个几十万行的表,把内存吃满。
Swagger 里想直接传文件调试,AddSwaggerGen那边要配一下IFormFile的参数描述,否则界面上只有一个文本框。这一步不难,但漏了的话联调时会浪费不少时间在 Postman 和 Swagger 之间来回切。
3. 读取模块拆解:ExcelDataReader 的三行核心和一张映射表
读取这块的代码量不大,坑却最集中。ExcelDataReader 本身的 API 设计得挺克制:建 reader、读成 DataSet 或者逐行Read(),剩下的全靠你自己。这也意味着表头处理、类型转换、缺列容错这些事,都得自己补。
3.1 三行核心调用,外加一行容易被忘的编码注册
最短的可用路径是这样:
// .xls 走的是二进制 BIFF 格式,内部编码依赖代码页 // 不注册这个 provider,中文表头在某些环境下会变成问号 Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); using var reader = ExcelReaderFactory.CreateReader(stream, new ExcelReaderConfiguration { FallbackEncoding = Encoding.GetEncoding(936), // GBK 兜底,老 .xls 常见 LeaveOpen = false // 读完顺手关流 });CreateReader会按文件头自动判断走CreateBinaryReader(.xls)还是CreateOpenXmlReader(.xlsx),调用方不用关心扩展名。这一点比自己按扩展名分支省事得多,也避免了「用户把 .xlsx 改名成 .xls」这种玄学情况导致的解析失败。
CodePagesEncodingProvider那一行是静态构造函数里注册一次就够,但漏了它,在某些容器镜像里就会出现「本地好好的、线上表头全是问号」的经典场面。这个坑我见过不止一次,排查起来还挺费劲,因为错误不抛异常,就是数据静默变错。
FallbackEncoding指定 GBK,是因为早期 Excel 导出的 .xls 文件经常不带完整的编码信息,库拿不到就只能猜。指定一个符合国内使用习惯的兜底编码,比让它自己猜靠谱。
3.2 表头到 DTO:用特性描述列名,缺列不炸
先看 DTO 长什么样。列名和属性名的对应关系不写死在代码里,而是挂在特性上:
// Application/Attributes/ExcelColumnAttribute.cs [AttributeUsage(AttributeTargets.Property)] public sealed class ExcelColumnAttribute : Attribute { public ExcelColumnAttribute(string name) => Name = name; public string Name { get; } public bool Required { get; init; } // 是否必填列 } // Application/Models/ProductRow.cs public class ProductRow { [ExcelColumn("商品编码", Required = true)] public string? Code { get; set; } [ExcelColumn("商品名称", Required = true)] public string? Name { get; set; } [ExcelColumn("单价")] public decimal? Price { get; set; } [ExcelColumn("上架日期")] public DateTime? OnlineDate { get; set; } }Required这个标记用于导入前做「模板完整性校验」:读一遍表头,比对所有Required = true的列是否都在,缺了就直接返回缺哪几列,不用等整表解析完才报错。
映射代码是整套读取里最长的一段,但逻辑很直白:
public IReadOnlyList<T> Read<T>(Stream stream, int sheetIndex = 0) where T : new() { using var reader = ExcelReaderFactory.CreateReader(stream, new ExcelReaderConfiguration { FallbackEncoding = Encoding.GetEncoding(936), LeaveOpen = false }); var dataSet = reader.AsDataSet(new ExcelDataSetConfiguration { ConfigureDataTable = _ => new ExcelDataTableConfiguration { UseHeaderRow = true, // 首行当列名,DataTable 的 ColumnName 就是表头文字 EmptyColumnNamePrefix = "Col" // 空表头自动补名,避免列名冲突 } }); if (sheetIndex >= dataSet.Tables.Count) throw new InvalidOperationException($"工作表索引 {sheetIndex} 超出范围"); var table = dataSet.Tables[sheetIndex]; return MapTo<T>(table); } private static List<T> MapTo<T>(DataTable table) where T : new() { // 只处理带 ExcelColumn 特性的属性,其余忽略 var props = typeof(T).GetProperties() .Select(p => (Prop: p, Attr: p.GetCustomAttribute<ExcelColumnAttribute>())) .Where(x => x.Attr is not null) .ToList(); var list = new List<T>(table.Rows.Count); foreach (DataRow row in table.Rows) { var item = new T(); foreach (var (prop, attr) in props) { if (!table.Columns.Contains(attr!.Name)) continue; // 缺列跳过,不抛异常 var raw = row[attr.Name]; if (raw is null || raw is DBNull) continue; var target = Nullable.GetUnderlyingType(prop.PropertyType) ?? prop.PropertyType; try { prop.SetValue(item, Convert.ChangeType(raw, target)); } catch (Exception) { // 单行单列出错不影响整表,记录到错误集里由业务层统一暴露 item.AddError(attr.Name, raw.ToString()); } } list.Add(item); } return list; }几个点值得说清楚。UseHeaderRow = true让 DataTable 直接把首行当列名,省掉自己维护列索引的麻烦;代价是如果表头有重复列名,DataTable 会自动加后缀(名称、名称_1),特性里写的名字就对不上了,所以导入模板里列名唯一是前提,这个约束在接口文档里得写明。
缺列用continue跳过而不是抛异常,是为了兼容「导出模板加了几列但用户没删」的情况。真正影响业务的是必填列,靠Required标记在表头校验阶段拦掉,比在逐行解析阶段炸掉更友好。
Convert.ChangeType对可空类型要显式拆一层Nullable.GetUnderlyingType,否则传typeof(decimal?)进去会抛异常。这个细节网上不少示例是错的,照抄会翻车。
3.3 多 Sheet 与大文件:AsDataSet 好用,但不该无脑用
AsDataSet会把整张表全部读进内存,五千行、五万行都没问题,到了五十万行就是灾难。项目里给了一个逐行读取的备用实现,用在明确知道文件会很大的场景:
public IEnumerable<ProductRow> StreamRead(Stream stream, string sheetName) { using var reader = ExcelReaderFactory.CreateReader(stream, new ExcelReaderConfiguration { FallbackEncoding = Encoding.GetEncoding(936) }); do { // reader.Name 是当前工作表名,可以用中文名精确匹配 if (!string.Equals(reader.Name, sheetName, StringComparison.Ordinal)) continue; var headerRead = false; var map = new Dictionary<int, PropertyInfo>(); while (reader.Read()) // 每次 Read 前进一行,内存里只留当前行 { if (!headerRead) { // 第一行建列索引到属性的映射,后面不再重复反射匹配 for (var i = 0; i < reader.FieldCount; i++) { var header = reader.GetValue(i)?.ToString(); var prop = MatchProperty(typeof(ProductRow), header); if (prop is not null) map[i] = prop; } headerRead = true; continue; } var item = new ProductRow(); foreach (var (idx, prop) in map) { var raw = reader.GetValue(idx); if (raw is null || raw is DBNull) continue; // 这里同样要做类型转换,省略 } yield return item; } } while (reader.NextResult()); // 切到下一个工作表 }NextResult()是切换到下一个 sheet 的开关,不写这个do...while就只能读第一张表。有些业务方习惯把数据放第二、第三个 sheet,只读第一张会得到空结果,还查不出原因。
逐行读取的收益是内存恒定,代价是只能单向遍历,不能像 DataTable 那样回头再做一次全表统计。所以项目里的策略是:默认走AsDataSet,行数预估超过阈值时切到StreamRead。这个阈值放在配置里,别写死。
Reader.GetValue返回的是object,对日期列它可能给你一个double(OLE Automation 日期序列号),也可能给DateTime,取决于单元格的原始格式。这也是后面避坑那章要讲的一条。
4. 导出模块拆解:EPPlus 把 List 变成一个能直接下载的文件
写入侧比读取侧省心,因为数据源是强类型对象,不存在「猜表头」这种问题。EPPlus 的 API 设计偏「操作单元格」,写起来像在填一张 A4 纸上画格子。
4.1 初始化:LicenseContext 必须显式声明,而且只声明一次
EPPlus 从版本 5 开始要求调用方明确授权模式,不设会直接抛InvalidOperationException。这个坑几乎每个第一次用的人都会踩:
// Program.cs 里设置一次即可,这是静态属性,不是每个 package 设一次 ExcelPackage.LicenseContext = LicenseContext.NonCommercial;LicenseContext.NonCommercial表示非商业用途。商业项目要买授权并设置ExcelPackage.License,具体条款以官方为准,这里不展开。要提醒的是:这个设置是进程级的静态状态,写在Program.cs启动阶段最稳妥;如果写在某个方法里,容易出现在单元测试中忘记设、跑到别的代码路径又没设的情况。
ExcelPackage本身是IDisposable,每次导出都要using,不要做成单例复用。它内部持有不少非托管资源,复用会出问题。
4.2 列特性驱动写入:一次把表头、样式、筛选、冻结全配齐
导出方法和读取的映射逻辑是对称的,同样读ExcelColumn特性,保证「导入模板」和「导出文件」的列顺序、列名天然一致:
public byte[] Export<T>(IEnumerable<T> rows, string sheetName = "Sheet1") { using var package = new ExcelPackage(); // 每次导出新建,不要复用 var ws = package.Workbook.Worksheets.Add(sheetName); var props = typeof(T).GetProperties() .Select(p => (Prop: p, Attr: p.GetCustomAttribute<ExcelColumnAttribute>())) .Where(x => x.Attr is not null) .ToList(); // 表头行 for (var c = 0; c < props.Count; c++) ws.Cells[1, c + 1].Value = props[c].Attr!.Name; using (var header = ws.Cells[1, 1, 1, props.Count)) { header.Style.Font.Bold = true; header.Style.Fill.PatternType = ExcelFillStyle.Solid; header.Style.Fill.BackgroundColor.SetColor(Color.FromArgb(0x1F, 0x4E, 0x79)); header.Style.Font.Color.SetColor(Color.White); header.Style.HorizontalAlignment = ExcelHorizontalAlignment.Center; } // 数据行 var r = 2; foreach (var item in rows) { for (var c = 0; c < props.Count; c++) { var cell = ws.Cells[r, c + 1]; var value = props[c].Prop.GetValue(item); if (value is DateTime dt) { cell.Value = dt; cell.Style.Numberformat.Format = "yyyy-mm-dd hh:mm:ss"; // 不设格式会显示成数字 } else { cell.Value = value; } } r++; } var lastRow = Math.Max(r - 1, 1); ws.Cells[1, 1, lastRow, props.Count].AutoFilter = true; // 表头自动筛选 ws.View.FreezePanes(2, 1); // 冻结首行,滚动时表头不动 ws.Cells.AutoFitColumns(10, 40); // 列宽自适应,限制最小/最大值 return package.GetAsByteArray(); }ws.Cells[行, 列]是一基索引,不是零基,[1, 1]是 A1。刚上手容易按数组习惯写[0, 0],然后看到一个空表。
日期那段单独判断是必须的。EPPlus 写DateTime时如果没设Numberformat,Excel 打开会显示成一串五位数,用户第一反应是「你们这个导出有 bug」。设成yyyy-mm-dd hh:mm:ss就正常了,如果是纯日期列改成yyyy-mm-dd更清爽。
FreezePanes(2, 1)的参数是「冻结到第几行、第几列」,传 2 表示前 1 行冻结。AutoFitColumns(10, 40)带两个参数是设最小和最大列宽,只传空的话,遇到一列超长备注能把整张表撑到没法看。
导出模板里想加下拉限制,EPPlus 提供了数据验证:
// 在状态列加下拉,范围留到最后一行的下面 500 行,方便用户手工追加 var dv = ws.DataValidations.AddListValidation($"E2:E{lastRow + 500}"); dv.Formula.Values.Add("待处理"); dv.Formula.Values.Add("已完成"); dv.ShowErrorMessage = true; dv.Error = "只能选择下拉里的值";数据验证的公式里直接塞中文字面量是可行的,AddListValidation接受的是「值列表」而不是公式串,这一点和 Excel 手工操作里的「序列来源于区域」不太一样,别混。
4.3 返回给浏览器:内存流、Content-Type 和文件名的三个细节
GetAsByteArray()直接把整个包读成字节数组,适合几千到几万行的量级。几十万行时它会吃掉大量内存,那就改成package.SaveAs(stream)写到一个临时流里,再用FileStreamResult返回:
[HttpGet("export/stream")] public IActionResult ExportLarge() { var stream = new MemoryStream(); _exporter.ExportTo(ProductRow.Sample(), stream, "商品明细"); stream.Position = 0; // 回绕到开头,否则浏览器下载到空文件 return File(stream, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", $"products-{DateTime.Now:yyyyMMdd}.xlsx"); }stream.Position = 0这行漏掉的概率极高。写完之后位置停在末尾,直接返回给FileResult,浏览器下载下来的是一个 0 字节或者残缺的 xlsx,用 Excel 打开提示「文件已损坏」。这个现象和文件本身无关,纯纯是流位置问题。
Content-Type 用application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,别图省事写application/octet-stream。前者会让浏览器识别成 Excel 文件,有些环境能直接唤起本地 Excel;后者只会弹一个「另存为」。
文件名带上时间戳,是为了避免用户反复下载后本地文件互相覆盖。这个细节不起眼,但导出按钮点两次就有两个不同文件,体验上顺畅很多。
5. 避坑与排查:五个让我返工过的真实问题
下面这几条都是我在这类项目里真碰过的,按「现象 → 原因 → 解决」写,能少走一段弯路。
5.1 编码与依赖:中文变问号、首次调用就崩
坑一:本地表头正常,线上所有中文表头变成???
现象是同一份 .xls 文件,开发机解析出来表头是「商品编码」,部署到容器后全变成问号,数据行倒是正常的。原因在于 .xls 是二进制格式,内部的字符编码依赖代码页,而精简过的容器镜像里没有注册CodePagesEncodingProvider,库拿不到 GBK/GB2312 这些编码,只能退化处理。
解决办法是在服务启动时注册一次,位置放在Program.cs最前面或者读取服务的静态构造函数里都行:
// 静态构造函数保证类型首次被使用时执行,且只执行一次 static ExcelDataReaderService() { Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); }同时给ExcelReaderConfiguration.FallbackEncoding显式指定Encoding.GetEncoding(936),双保险。这类问题最难受的地方是不抛异常,数据静默出错,等业务方反馈时已经导了几批脏数据进去。
坑二:本地调试好好的,一部署就抛InvalidOperationException
异常信息大意是「请先设置 LicenseContext」。原因很简单,EPPlus 5 以后必须显式声明授权模式,而本地调试时可能之前某次试验在别的入口设过,正式部署时那条路径没走到。
解决就是老老实实在Program.cs里设一次:
ExcelPackage.LicenseContext = LicenseContext.NonCommercial;注意这个属性是静态的,设一次全局生效。如果项目里有多处入口(比如还有个控制台任务也导出 Excel),那个入口也得设,别以为 Web 端设了就能覆盖。
5.2 流与内存:读到空表、进程被 OOM 干掉
坑三:上传接口返回 200,但rows.Count永远是 0
现象很迷惑,接口没报错,返回的 JSON 里total是 0。原因通常有两个:一是流的位置不在开头,前面某个中间件(比如请求日志、审计)已经把流读过一遍,CreateReader从当前位置开始读,自然读不到内容;二是IFormFile.OpenReadStream()返回的流不可 seek,某些库内部会先 seek 到 0 再读。
解决方式是把流复制到一个MemoryStream里再交给读取服务:
using var origin = file.OpenReadStream(); using var buffer = new MemoryStream(); origin.CopyTo(buffer); buffer.Position = 0; // 复制完必须手动回绕 var rows = _reader.Read<ProductRow>(buffer);代价是文件内容在内存里存了两份,所以这个做法要配合前面说的文件大小上限。超过 50MB 的文件,建议直接落临时文件再读,别硬扛内存。
坑四:几十万行的表一传,整个进程重启
现象是接口超时,然后健康检查挂掉,容器被重启。原因是AsDataSet把整张表读进了DataTable,每个单元格都是object,加上DataRow的开销,几十万行乘十几列就是几百 MB 的瞬时内存。
解决办法前面提过,超过阈值切到逐行reader.Read()的流式路径,内存占用恒定在一行的大小。另一个辅助手段是在读取前判断reader.RowCount(ExcelDataReader 提供了这个属性),超限直接拒绝,别等它跑完再报错。
提示:
RowCount对某些格式可能返回 -1,表示未知,这时候只能靠逐行计数并在超限时主动中断。
5.3 数据与格式:订单号变科学计数法、日期变成五位数
坑五:导出的订单号在 Excel 里显示成4.28E+17,末几位还变了
现象是后台数据是对的,导出到 Excel 一打开,长数字列全变成科学计数法,而且末尾几位被抹成 0。原因是 Excel 的数字类型是双精度浮点,超过 15 位有效数字就丢精度,19 位的订单号必然中招。
解决办法是写单元格时把格式设成文本,并且值也按字符串写:
var cell = ws.Cells[r, codeColIndex]; cell.Value = item.Code; // Code 是 string,不要转成 long cell.Style.Numberformat.Format = "@"; // @ 是 Excel 的文本格式两个细节:Value必须给字符串,给数字再设文本格式也没用,Excel 会把它当数字存;"@"这个格式串在 EPPlus 里就是文本格式的简写,等价于 Excel 界面里的「文本」。
同样的道理,导入时如果用户先在 Excel 里把订单号列设成了数字,读回来的值已经丢精度了,再怎么处理也救不回来。所以导入模板里长数字列要预设成文本格式,这一点在导出模板生成时就该配好。
坑六:日期读回来是个五位数,比如 45231
现象是 Excel 里明明是2024-01-15,读出来变成45231。原因是这个单元格的原始存储是 OLE Automation 日期序列号(从 1899-12-30 开始的天数),单元格的显示格式是日期,但底层还是数字。ExcelDataReader 的GetValue返回的是原始值,不会替你按显示格式转换。
解决是在映射阶段做一次判断:如果目标属性是DateTime而原始值是double,就按 OLE 日期转换:
if (target == typeof(DateTime) && raw is double oaDate) { prop.SetValue(item, DateTime.FromOADate(oaDate)); continue; }DateTime.FromOADate是框架自带的,不用自己算天数。反过来,如果原始值已经是DateTime,直接用就行。两种都可能出现,判断顺序别搞反。
还有一个相关问题:合并单元格。表头里常见的「商品信息」横跨三列,AsDataSet读出来只有第一列有值,后两列是空字符串。如果你的列映射依赖表头文字,那后两列就匹配不上。解决要么是要求模板不做合并,要么是在读表头时向后填充空列名,填充逻辑写在ExcelDataTableConfiguration之前会更好维护一些。
6. 二次开发:把它改成你自己业务里的导入导出
拿到这套代码,真正要动的就几处,剩下的都是通用逻辑。
6.1 四个必改的扩展点
| 扩展点 | 改哪个文件 | 说明 |
|---|---|---|
| 业务模型 | Application/Models/ProductRow.cs | 换成你自己的实体,属性上加[ExcelColumn("列名")] |
| 校验规则 | 模型上的IsValid()或独立的IValidator<T> | 必填、范围、枚举值,按业务补 |
| 工作表定位 | ExcelDataReaderService.Read的sheetIndex参数 | 业务方习惯用命名工作表时改成按名匹配 |
| 数据来源 | ExcelController.Export里的ProductRow.Sample() | 换成仓储查询,注意分页导出和内存 |
第一处和第四处是必改的,第二处八成也要改,第三处看业务方习惯。改完之后,导入和导出用的是同一套列特性,列顺序和列名天然对齐,不会出现「导入模板和导出模板列顺序不一样」这种低级问题。
如果业务需要「只导出筛选后的数据」,在导出接口上加查询参数,别在前端过滤完再回传——前端拿到的数据往往已经被分页截断了。导出接口单独走查询,不走列表接口的复用逻辑,这一点分清楚能省很多沟通成本。
6.2 上线前跑一遍验证清单
我自己的习惯是上线前跑这几条,一条不过就不发:
- 传一个只有表头、没有数据行的空模板,接口返回 200 加空数组,不是 500
- 传一个 UTF-8 BOM 的 CSV 改名成 .xlsx 的文件,接口返回明确的格式错误,不是堆栈
- 传一个 45MB 的文件,确认不被网关拦掉,返回结果的耗时在可接受范围
- 导出一万行,用 Excel 打开,检查日期列、长数字列、表头样式、冻结窗格、筛选按钮都在
- 把导出的文件原样传回导入接口,
valid计数等于导出条数,这条叫往返一致性,最能暴露映射问题
最后一条尤其值得做。我在一个项目里就是因为导出用了一列叫「状态」,导入模板里那列叫「当前状态」,导出再导入直接少了一列数据,业务方用了两周才发现,然后回头补了三千条历史数据的映射规则。从那以后我每次加导入导出功能,都强制走一遍「导出→原样导入→比对条数」的往返测试,哪怕多花十分钟。
这套代码的读写两条链路都不长,改起来门槛不高,难的是把边界情况想全。希望帮到你。
本文还有配套的精品资源,点击获取