C#
文件、JSON、正则与时间
使用 Path、System.Text.Json、正则和 TimeProvider 安全处理文件、结构化数据与时间。
发布于 2026年7月23日
文件、JSON、正则与时间
路径、文本、JSON 和时间都位于系统边界。边界代码必须明确根目录、编码、格式、时区、写入原子性和错误策略,不能依赖当前工作目录或机器区域设置的偶然默认值。
一、学习目标
- 使用
Path、File、Directory处理跨平台路径 - 限制用户路径不逃逸允许目录
- 以明确编码和原子替换写入文本
- 用 System.Text.Json 在 DTO 与领域对象之间转换
- 适度、安全地使用正则表达式
- 使用
DateTimeOffset、TimeProvider和TimeZoneInfo
二、构造路径
string dataRoot = Path.Combine(
Environment.GetFolderPath(
Environment.SpecialFolder.LocalApplicationData),
"StudyTasks");
Directory.CreateDirectory(dataRoot);
string databasePath = Path.Combine(dataRoot, "tasks.db");
使用 Path.Combine,不要手工拼接 / 或 \。Environment.CurrentDirectory 会随启动方式改变,不适合默认数据目录;配置文件相对路径则要明确相对于内容根还是当前目录。
取得完整路径:
string fullPath = Path.GetFullPath(databasePath);
显示路径前考虑隐私:用户目录名、挂载位置和网络共享可能不应进入普通日志。
三、文本与编码
var utf8 = new UTF8Encoding(
encoderShouldEmitUTF8Identifier: false,
throwOnInvalidBytes: true);
string text = await File.ReadAllTextAsync(
path,
utf8,
cancellationToken);
明确 UTF-8,并在无效字节时失败,避免静默替换损坏数据。写入:
await File.WriteAllTextAsync(
path,
content,
utf8,
cancellationToken);
超大文件不要一次读入内存,改用 StreamReader 按行或流式 JSON。限制最大文件大小和单行长度,避免不可信输入耗尽内存。
四、原子写入
直接覆盖目标文件时,进程崩溃可能留下截断内容。先在同一目录写临时文件,再替换:
static async Task WriteAtomicallyAsync(
string path,
string content,
CancellationToken cancellationToken)
{
string directory = Path.GetDirectoryName(path)
?? throw new ArgumentException("目标必须包含目录", nameof(path));
Directory.CreateDirectory(directory);
string temporary = Path.Combine(
directory,
$".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp");
try
{
await File.WriteAllTextAsync(
temporary,
content,
Encoding.UTF8,
cancellationToken);
File.Move(temporary, path, overwrite: true);
}
finally
{
File.Delete(temporary);
}
}
临时文件与目标放在同一文件系统,重命名才更可能具备原子语义。多个进程同时写仍需要锁或更合适的数据库。
五、JSON DTO
外部格式与领域对象分开:
internal sealed record TaskDto(
int Id,
string? Title,
string? Status,
DateTimeOffset CreatedAt,
DateTimeOffset? CompletedAt);
选项:
private static readonly JsonSerializerOptions JsonOptions =
new(JsonSerializerDefaults.Web)
{
WriteIndented = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow
};
反序列化成功只表示 JSON 语法和基本类型可转换,不代表业务数据合法:
TaskDto[] dtos =
JsonSerializer.Deserialize<TaskDto[]>(json, JsonOptions)
?? throw new JsonException("根值不能为 null");
StudyTask[] tasks = dtos.Select(MapAndValidate).ToArray();
MapAndValidate 应检查 ID、标题、状态和时间关系。先验证全部数据,再写数据库,避免半批导入。
六、正则表达式
正则适合格式清晰的局部匹配,不适合解析任意嵌套语言。
internal static partial class TaskText
{
[GeneratedRegex(
@"^#(?<id>[1-9]\d*)\s+(?<title>.+)$",
RegexOptions.CultureInvariant,
matchTimeoutMilliseconds: 200)]
internal static partial Regex TaskLine();
}
Match match = TaskText.TaskLine().Match(input);
if (!match.Success)
{
throw new FormatException("格式应为 #<id> <title>");
}
对不可信输入设置超时,避免灾难性回溯长期占用 CPU。GeneratedRegex 在编译期生成实现,既集中模式又减少运行初始化成本。
七、时间与时区
记录事件时刻优先使用 DateTimeOffset:
DateTimeOffset now = timeProvider.GetUtcNow();
string wireValue = now.ToString("O", CultureInfo.InvariantCulture);
测试通过 TimeProvider 控制当前时间,不直接散落 DateTimeOffset.UtcNow。
显示本地时间:
TimeZoneInfo zone = TimeZoneInfo.FindSystemTimeZoneById(zoneId);
DateTimeOffset local = TimeZoneInfo.ConvertTime(createdAt, zone);
时区 ID 在现代 .NET 中具备跨平台映射能力,但部署前仍应验证目标环境。不要只保存本地 DateTime 后猜测偏移;夏令时会产生不存在或重复的本地时间。
DateOnly 适合无时刻含义的日期,例如到期日;不要把它强行转换成 UTC 午夜当作事件发生时间。
八、安全导入流程
- 解析和限制输入路径。
- 检查文件大小与编码。
- 反序列化为 DTO。
- 校验每条记录和跨记录唯一性。
- 生成完整领域对象集合。
- 在一个数据库事务中写入。
- 成功后记录数量,不记录完整敏感内容。
任何一步失败都不修改现有任务。若允许跳过坏行,应在产品契约中明确部分成功结果,而不是悄悄忽略。
九、常见错误
用字符串前缀判断安全路径
路径分隔、大小写和相邻目录会绕过简单前缀。使用完整路径与相对路径检查,并配合文件系统权限。
JSON 能解析就直接入库
语法正确不等于 ID、状态和时间合法。DTO 到领域对象必须验证。
保存本地时间却不保存时区
之后无法可靠还原唯一时刻。事件使用 DateTimeOffset 或 UTC。
正则没有超时
恶意或意外输入可能造成长时间回溯。限制输入长度并配置超时。
十、练习与自测
练习:
- 实现只能写入指定导出目录的路径解析器。
- 为任务 JSON 加入未知字段拒绝和状态验证。
- 模拟写入中途异常,确认旧文件仍完整。
- 使用假的
TimeProvider测试“超过 30 天”规则。
自测:
- 为什么当前工作目录不适合作为默认数据目录?
- JSON 反序列化成功后还要验证什么?
- 原子写入为何要求临时文件位于同一文件系统?
DateOnly与DateTimeOffset分别表达什么?
十一、官方资料
上一篇:LINQ、迭代器与惰性查询 | 下一篇:命令行、配置、依赖注入与日志