Skip to Content
Week 11Day 3 - 导入导出

Day 3 - 导入导出

建议用时:180-210 分钟

你将学会什么

  • 导入和导出分别是什么
  • CSV 文件格式应该先约定清楚
  • 为什么导入要逐行校验
  • 为什么一行错误不应该让全部数据没说明地失败
  • 怎么记录错误行
  • 怎么导出成功数据

导入不是把文件读进来就结束。真正重要的是:字段数量对不对、数字能不能转换、业务规则过不过、失败行有没有说明、成功和失败数量有没有汇总。

本页固定顺序

  1. 先学第一部分:弄懂今天最小、最重要的知识,并运行短例子。
  2. 再学第二部分:把刚学的知识组合成一个完整例子。
  3. 然后做第三部分:自己跟着敲,再完成重复训练和每日小测。
  4. 最后做第四部分:先独立完成作业,再用完整答案检查。

学习衔接

上一页学习的是“审计日志”,今天继续学习“导入导出”。先使用上一页已经会的写法,再只增加今天这个新知识点;如果前置内容还不能独立敲出,先回上一页复习,不要硬跳。

今天的最低通过线

第一次学习不要求背完整页。完成下面 3 项,就可以继续:

  • 能用自己的话说明“导入导出”解决什么问题。
  • 把第一部分的短例子亲手敲完,并确认每个例子都能运行。
  • 不看完整答案完成第三部分至少前 3 个例子,再主动改一个值观察结果。

第一部分:先学原理和最小知识

这一部分从最小知识开始。先读解释,再把紧跟着的短例子敲一遍。导入导出的核心是格式和校验,不是单纯读文件。

导入和导出的区别

动作数据方向例子
导入文件进入系统CSV -> Product 列表
导出系统生成文件Product 列表 -> CSV

导入要更谨慎,因为外部文件可能有错误。

导出要稳定,因为别人可能拿你的文件继续处理。

为什么先定义格式

导入前必须说明文件长什么样。

例如本页规定:

Id,Name,Price,Stock

如果格式不明确,使用者不知道怎么准备文件,程序也不知道每一列应该对应什么字段。

为什么要逐行处理

导入文件可能有 100 行,其中 95 行正确,5 行错误。

如果一行错就直接停止,用户不知道哪些行成功,哪些行失败。

本页做法是:

  • 正确行加入 products
  • 错误行加入 errors
  • 最后统一输出成功数量和失败数量。

为什么用 TryParse

不要直接写:

decimal price = decimal.Parse(parts[2]);

如果 parts[2]abc,程序会抛异常。

本页用:

decimal.TryParse(parts[2], out decimal price)

这样可以把错误记录下来,然后继续处理下一行。

为什么要记录行号

错误文件只写“价格错误”不够。

用户需要知道哪一行错了。

所以本页使用:

new ImportError(lineNumber, line, "Price 必须是大于 0 的数字。")

这样错误文件能直接指向原始行。

为什么导出字段顺序要稳定

导出 CSV 时,字段顺序要固定。

本页固定为:

Id,Name,Price,Stock

如果今天导出是 Name,Price,明天变成 Price,Name,别人用脚本读取时会出问题。

导入导出常用操作速查

需求写法说明
读 CSV 行File.ReadAllLines(path)文件导入
写 CSV 行File.WriteAllLines(path, lines)文件导出
拆字段line.Split(',')基础 CSV
清理字段Trim()去空格
检查列数parts.Length防越界
转金额decimal.TryParse价格
转整数int.TryParse库存
错误报告errors.Add(...)记录失败行
导出行string.Join(",", fields)拼成 CSV

导入流程:

读文件 -> 跳过表头 -> 拆字段 -> 校验字段 -> 成功加入数据 -> 失败记录错误

第二部分:把知识组合成完整例子

今天做一个 CSV 导入导出程序。

它会做 5 件事:

  1. 生成一个示例 products-import.csv
  2. 读取 CSV。
  3. 正确行变成 Product
  4. 错误行写入错误清单。
  5. 把成功商品导出到 products-export.csv

CSV 格式

第一版先约定简单格式:

Id,Name,Price,Stock 1,Keyboard,199,10 2,Mouse,59,30

字段说明:

含义规则
Id商品编号必须是整数,不能重复
Name商品名称不能为空
Price价格必须是大于 0 的数字
Stock库存必须是大于或等于 0 的整数

第一版不处理名称里包含英文逗号的复杂 CSV。先把主流程跑通。

完整 Program.cs

using System; using System.Collections.Generic; using System.IO; using System.Linq; string folder = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "ProductImportExport"); Directory.CreateDirectory(folder); string importPath = Path.Combine(folder, "products-import.csv"); string exportPath = Path.Combine(folder, "products-export.csv"); string errorPath = Path.Combine(folder, "products-errors.txt"); string[] sampleLines = { "Id,Name,Price,Stock", "1,Keyboard,199,10", "2,,59,30", "3,Monitor,abc,5", "4,Mouse,59,20", "4,Duplicate Mouse,69,10", "5,Laptop Stand,129,-1" }; await File.WriteAllLinesAsync(importPath, sampleLines); var importer = new CsvProductImporter(); ImportResult result = await importer.ImportAsync(importPath); var exporter = new CsvProductExporter(); await exporter.ExportAsync(exportPath, result.Products); await File.WriteAllLinesAsync(errorPath, result.Errors.Select(error => error.ToString())); Console.WriteLine($"导入文件:{importPath}"); Console.WriteLine($"成功导入:{result.Products.Count}"); Console.WriteLine($"失败行数:{result.Errors.Count}"); Console.WriteLine($"导出文件:{exportPath}"); Console.WriteLine($"错误文件:{errorPath}"); foreach (ImportError error in result.Errors) { Console.WriteLine(error); } public sealed class CsvProductImporter { public async Task<ImportResult> ImportAsync(string path) { string[] lines = await File.ReadAllLinesAsync(path); var products = new List<Product>(); var errors = new List<ImportError>(); var usedIds = new HashSet<int>(); for (int index = 1; index < lines.Length; index++) { int lineNumber = index + 1; string line = lines[index]; if (string.IsNullOrWhiteSpace(line)) { continue; } string[] parts = line.Split(','); if (parts.Length != 4) { errors.Add(new ImportError(lineNumber, line, "列数必须是 4。")); continue; } if (!int.TryParse(parts[0], out int id)) { errors.Add(new ImportError(lineNumber, line, "Id 必须是整数。")); continue; } if (!usedIds.Add(id)) { errors.Add(new ImportError(lineNumber, line, "Id 重复。")); continue; } string name = parts[1].Trim(); if (string.IsNullOrWhiteSpace(name)) { errors.Add(new ImportError(lineNumber, line, "Name 不能为空。")); continue; } if (!decimal.TryParse(parts[2], out decimal price) || price <= 0) { errors.Add(new ImportError(lineNumber, line, "Price 必须是大于 0 的数字。")); continue; } if (!int.TryParse(parts[3], out int stock) || stock < 0) { errors.Add(new ImportError(lineNumber, line, "Stock 必须是大于或等于 0 的整数。")); continue; } products.Add(new Product(id, name, price, stock)); } return new ImportResult(products, errors); } } public sealed class CsvProductExporter { public async Task ExportAsync(string path, IReadOnlyList<Product> products) { var lines = new List<string> { "Id,Name,Price,Stock" }; lines.AddRange(products .OrderBy(product => product.Id) .Select(product => $"{product.Id},{product.Name},{product.Price},{product.Stock}")); await File.WriteAllLinesAsync(path, lines); } } public sealed record Product(int Id, string Name, decimal Price, int Stock); public sealed record ImportResult(IReadOnlyList<Product> Products, IReadOnlyList<ImportError> Errors); public sealed record ImportError(int LineNumber, string Line, string Reason) { public override string ToString() { return $"第 {LineNumber} 行:{Reason} 原文:{Line}"; } }

正常运行结果

成功导入:2 失败行数:4 ... 第 3 行:Name 不能为空。 原文:2,,59,30 第 4 行:Price 必须是大于 0 的数字。 原文:3,Monitor,abc,5 第 6 行:Id 重复。 原文:4,Duplicate Mouse,69,10 第 7 行:Stock 必须是大于或等于 0 的整数。 原文:5,Laptop Stand,129,-1

成功导入的是:

1,Keyboard,199,10 4,Mouse,59,20

第三部分:跟着敲代码

从这里开始动手。新建控制台项目。

第 1 步:创建项目

dotnet new console -n ImportExportDemo cd ImportExportDemo

打开 Program.cs,清空原内容。

第 2 步:准备文件路径

string folder = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "ProductImportExport"); Directory.CreateDirectory(folder); string importPath = Path.Combine(folder, "products-import.csv"); string exportPath = Path.Combine(folder, "products-export.csv"); string errorPath = Path.Combine(folder, "products-errors.txt");

解释:

  • 导入文件、导出文件、错误文件都放在同一个目录。

第 3 步:生成示例导入文件

string[] sampleLines = { "Id,Name,Price,Stock", "1,Keyboard,199,10", "2,,59,30", "3,Monitor,abc,5", "4,Mouse,59,20", "4,Duplicate Mouse,69,10", "5,Laptop Stand,129,-1" }; await File.WriteAllLinesAsync(importPath, sampleLines);

解释:

  • 有正确行,也有错误行。
  • 这样能验证错误处理。

第 4 步:写 Product 和错误类型

public sealed record Product(int Id, string Name, decimal Price, int Stock); public sealed record ImportResult(IReadOnlyList<Product> Products, IReadOnlyList<ImportError> Errors); public sealed record ImportError(int LineNumber, string Line, string Reason) { public override string ToString() { return $"第 {LineNumber} 行:{Reason} 原文:{Line}"; } }

解释:

  • Product 是成功导入的数据。
  • ImportError 是失败行。
  • ImportResult 同时返回成功和失败。

第 5 步:读取 CSV 行

string[] lines = await File.ReadAllLinesAsync(path); var products = new List<Product>(); var errors = new List<ImportError>(); var usedIds = new HashSet<int>(); for (int index = 1; index < lines.Length; index++) { int lineNumber = index + 1; string line = lines[index]; }

解释:

  • index = 1 是为了跳过表头。
  • lineNumber = index + 1 是真实文件行号。
  • usedIds 用来检查重复 Id。

第 6 步:校验字段数量和 Id

string[] parts = line.Split(','); if (parts.Length != 4) { errors.Add(new ImportError(lineNumber, line, "列数必须是 4。")); continue; } if (!int.TryParse(parts[0], out int id)) { errors.Add(new ImportError(lineNumber, line, "Id 必须是整数。")); continue; } if (!usedIds.Add(id)) { errors.Add(new ImportError(lineNumber, line, "Id 重复。")); continue; }

解释:

  • 列数不对不能导入。
  • Id 不是整数不能导入。
  • Id 重复不能导入。

第 7 步:校验名称、价格、库存

string name = parts[1].Trim(); if (string.IsNullOrWhiteSpace(name)) { errors.Add(new ImportError(lineNumber, line, "Name 不能为空。")); continue; } if (!decimal.TryParse(parts[2], out decimal price) || price <= 0) { errors.Add(new ImportError(lineNumber, line, "Price 必须是大于 0 的数字。")); continue; } if (!int.TryParse(parts[3], out int stock) || stock < 0) { errors.Add(new ImportError(lineNumber, line, "Stock 必须是大于或等于 0 的整数。")); continue; }

解释:

  • 每个字段都有明确失败原因。
  • 失败后 continue,继续处理下一行。

第 8 步:导出成功商品

var lines = new List<string> { "Id,Name,Price,Stock" }; lines.AddRange(products .OrderBy(product => product.Id) .Select(product => $"{product.Id},{product.Name},{product.Price},{product.Stock}")); await File.WriteAllLinesAsync(path, lines);

解释:

  • 导出时保留表头。
  • 按 Id 排序,输出更稳定。

第 9 步:运行后检查

运行后检查:

  1. 控制台显示成功 2 行。
  2. 控制台显示失败 4 行。
  3. products-export.csv 只包含成功商品。
  4. products-errors.txt 包含错误行号和原因。

常见错误和修法

错误为什么错修法
导入时遇到一行错就全部失败用户无法知道哪些行成功每行返回成功或失败结果
不检查 CSV 列数少列会导致解析错位先判断列数量,再解析字段
价格库存直接 Parse非法输入会中断导入使用 TryParse 并记录错误行
导出缺少表头文件给别人看不清字段第一行写 Name,Price,Stock
导入后不刷新列表数据保存了但界面没变化导入成功后重新加载或更新集合

小白重复敲写训练

导入导出先用两条数据练,确认格式再处理大文件。

训练 1:导出 CSV 一行

var product = new Product { Code = "P001", Name = "Keyboard", Price = 199m }; string line = $"{product.Code},{product.Name},{product.Price}"; Console.WriteLine(line);

训练 2:导出多行

var lines = products.Select(product => $"{product.Code},{product.Name},{product.Price}"); await File.WriteAllLinesAsync("products.csv", lines);

打开文件确认每条商品占一行。

训练 3:导入并收集错误

var errors = new List<string>(); foreach (string line in await File.ReadAllLinesAsync("products.csv")) { string[] parts = line.Split(','); if (parts.Length != 3 || !decimal.TryParse(parts[2], out _)) errors.Add(line); } Console.WriteLine($"错误行数: {errors.Count}");

第三遍故意写一行错误价格。

每日小测

做完本页后,用这 5 题检查是否真的掌握。

1. 判断题

本页的目标不是只把代码运行起来,还要能说清楚“为什么这样写”。

答案:对。能运行只是第一步,能解释原理、常用操作和常见错误,才说明本页内容进入了可复用能力。

2. 填空题

本页主题是:导入导出。今天至少要掌握的 3 个点是:

1. 导入和导出分别是什么 2. CSV 文件格式应该先约定清楚 3. 为什么导入要逐行校验

答案:以上 3 点必须能用自己的代码跑通,不能只停留在阅读。

3. 流程题

遇到本页相关功能时,先按什么顺序处理?

答案:先看完整例子,确认最终效果;再读原理和名词;然后跟着第三部分从空项目敲代码;最后对照作业答案检查。

4. 找错误题

如果本页代码运行失败,第一步应该做什么?

答案:先看终端或 IDE 里的第一条错误,找到文件名和行号;不要同时改很多地方。再回到本页的“常见错误和修法”表格,对照错误类型逐项排查。

5. 改需求题

在本页完整例子跑通后,至少改一个小需求。

可选改法:

  • 改一个字段名称。
  • 多加一个校验条件。
  • 多输出一行结果。
  • 把固定数据改成用户输入。
  • 把一次处理改成多条数据处理。

答案标准:修改后能重新运行,并能说明这次修改影响了哪一段逻辑。重点检查:导入和导出分别是什么。

上位机专项练习

点位数量多时用 CSV 批量导入导出,但必须逐行验证并报告错误,不能坏一行就全失败。

下面 3 个例子都要亲手敲。先运行原代码,再完成每个例子后面的改动任务。

专项例子 1:点位 CSV 格式

DeviceId,TagName,Address,Unit,AlarmLimit D001,Temperature,40001,C,80 D001,Pressure,40002,MPa,1.5

运行结果或界面效果:

一行代表一个设备点位

改动任务: 增加转速点位。

专项例子 2:逐行解析点位

foreach (string line in lines.Skip(1)) { string[] cells = line.Split(','); if (cells.Length != 5) { errors.Add($"列数错误: {line}"); continue; } tags.Add(new TagImportRow(cells[0], cells[1], cells[2], cells[3], cells[4])); }

运行结果或界面效果:

正确行进入 tags,错误行进入 errors

改动任务: 再检查 AlarmLimit 是否为数字。

专项例子 3:导出当前点位

var lines = new List<string> { "DeviceId,TagName,Address,Unit,AlarmLimit" }; lines.AddRange(Tags.Select(x => $"{x.DeviceId},{x.Name},{x.Address},{x.Unit},{x.AlarmLimit}")); await File.WriteAllLinesAsync("tags.csv", lines);

运行结果或界面效果:

生成可再次导入的 tags.csv

改动任务: 导出后显示记录数量和路径。

第四部分:作业完整答案

作业要求:写一个 CSV 导入导出程序,支持字段校验、重复 Id 校验、错误行记录、成功数据导出。

答案文件:Program.cs

using System; using System.Collections.Generic; using System.IO; using System.Linq; string folder = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "ProductImportExport"); Directory.CreateDirectory(folder); string importPath = Path.Combine(folder, "products-import.csv"); string exportPath = Path.Combine(folder, "products-export.csv"); string errorPath = Path.Combine(folder, "products-errors.txt"); string[] sampleLines = { "Id,Name,Price,Stock", "1,Keyboard,199,10", "2,,59,30", "3,Monitor,abc,5", "4,Mouse,59,20", "4,Duplicate Mouse,69,10", "5,Laptop Stand,129,-1" }; await File.WriteAllLinesAsync(importPath, sampleLines); var importer = new CsvProductImporter(); ImportResult result = await importer.ImportAsync(importPath); var exporter = new CsvProductExporter(); await exporter.ExportAsync(exportPath, result.Products); await File.WriteAllLinesAsync(errorPath, result.Errors.Select(error => error.ToString())); Console.WriteLine($"导入文件:{importPath}"); Console.WriteLine($"成功导入:{result.Products.Count}"); Console.WriteLine($"失败行数:{result.Errors.Count}"); Console.WriteLine($"导出文件:{exportPath}"); Console.WriteLine($"错误文件:{errorPath}"); foreach (ImportError error in result.Errors) { Console.WriteLine(error); } public sealed class CsvProductImporter { public async Task<ImportResult> ImportAsync(string path) { string[] lines = await File.ReadAllLinesAsync(path); var products = new List<Product>(); var errors = new List<ImportError>(); var usedIds = new HashSet<int>(); for (int index = 1; index < lines.Length; index++) { int lineNumber = index + 1; string line = lines[index]; if (string.IsNullOrWhiteSpace(line)) { continue; } string[] parts = line.Split(','); if (parts.Length != 4) { errors.Add(new ImportError(lineNumber, line, "列数必须是 4。")); continue; } if (!int.TryParse(parts[0], out int id)) { errors.Add(new ImportError(lineNumber, line, "Id 必须是整数。")); continue; } if (!usedIds.Add(id)) { errors.Add(new ImportError(lineNumber, line, "Id 重复。")); continue; } string name = parts[1].Trim(); if (string.IsNullOrWhiteSpace(name)) { errors.Add(new ImportError(lineNumber, line, "Name 不能为空。")); continue; } if (!decimal.TryParse(parts[2], out decimal price) || price <= 0) { errors.Add(new ImportError(lineNumber, line, "Price 必须是大于 0 的数字。")); continue; } if (!int.TryParse(parts[3], out int stock) || stock < 0) { errors.Add(new ImportError(lineNumber, line, "Stock 必须是大于或等于 0 的整数。")); continue; } products.Add(new Product(id, name, price, stock)); } return new ImportResult(products, errors); } } public sealed class CsvProductExporter { public async Task ExportAsync(string path, IReadOnlyList<Product> products) { var lines = new List<string> { "Id,Name,Price,Stock" }; lines.AddRange(products .OrderBy(product => product.Id) .Select(product => $"{product.Id},{product.Name},{product.Price},{product.Stock}")); await File.WriteAllLinesAsync(path, lines); } } public sealed record Product(int Id, string Name, decimal Price, int Stock); public sealed record ImportResult(IReadOnlyList<Product> Products, IReadOnlyList<ImportError> Errors); public sealed record ImportError(int LineNumber, string Line, string Reason) { public override string ToString() { return $"第 {LineNumber} 行:{Reason} 原文:{Line}"; } }

验收结果

运行后必须看到:

  1. 成功导入 2 行。
  2. 失败行数 4 行。
  3. 错误里包含空名称。
  4. 错误里包含价格不是数字。
  5. 错误里包含 Id 重复。
  6. 错误里包含库存小于 0。
  7. 导出文件只包含成功商品。
  8. 错误文件包含行号、原因和原文。

为什么这个答案是对的

这个答案把导入导出拆成了清楚的流程:

步骤代码作用
定格式Id,Name,Price,Stock让文件结构稳定
读文件ReadAllLinesAsync获取所有行
校验行TryParse、空值判断拦住错误数据
收集错误ImportError不让错误行没说明地消失
返回汇总ImportResult同时返回成功和失败
导出成功CsvProductExporter生成稳定 CSV

导入导出的重点是可解释:成功多少,失败多少,哪一行失败,为什么失败,都要能说清楚。