Day 3 - 导入导出
建议用时:180-210 分钟
你将学会什么
- 导入和导出分别是什么
- CSV 文件格式应该先约定清楚
- 为什么导入要逐行校验
- 为什么一行错误不应该让全部数据没说明地失败
- 怎么记录错误行
- 怎么导出成功数据
导入不是把文件读进来就结束。真正重要的是:字段数量对不对、数字能不能转换、业务规则过不过、失败行有没有说明、成功和失败数量有没有汇总。
本页固定顺序
- 先学第一部分:弄懂今天最小、最重要的知识,并运行短例子。
- 再学第二部分:把刚学的知识组合成一个完整例子。
- 然后做第三部分:自己跟着敲,再完成重复训练和每日小测。
- 最后做第四部分:先独立完成作业,再用完整答案检查。
学习衔接
上一页学习的是“审计日志”,今天继续学习“导入导出”。先使用上一页已经会的写法,再只增加今天这个新知识点;如果前置内容还不能独立敲出,先回上一页复习,不要硬跳。
今天的最低通过线
第一次学习不要求背完整页。完成下面 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 件事:
- 生成一个示例
products-import.csv。 - 读取 CSV。
- 正确行变成
Product。 - 错误行写入错误清单。
- 把成功商品导出到
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 步:运行后检查
运行后检查:
- 控制台显示成功 2 行。
- 控制台显示失败 4 行。
products-export.csv只包含成功商品。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}";
}
}验收结果
运行后必须看到:
- 成功导入 2 行。
- 失败行数 4 行。
- 错误里包含空名称。
- 错误里包含价格不是数字。
- 错误里包含 Id 重复。
- 错误里包含库存小于 0。
- 导出文件只包含成功商品。
- 错误文件包含行号、原因和原文。
为什么这个答案是对的
这个答案把导入导出拆成了清楚的流程:
| 步骤 | 代码 | 作用 |
|---|---|---|
| 定格式 | Id,Name,Price,Stock | 让文件结构稳定 |
| 读文件 | ReadAllLinesAsync | 获取所有行 |
| 校验行 | TryParse、空值判断 | 拦住错误数据 |
| 收集错误 | ImportError | 不让错误行没说明地消失 |
| 返回汇总 | ImportResult | 同时返回成功和失败 |
| 导出成功 | CsvProductExporter | 生成稳定 CSV |
导入导出的重点是可解释:成功多少,失败多少,哪一行失败,为什么失败,都要能说清楚。