浏览知识库目录

C#

文件、JSON、正则与时间

使用 Path、System.Text.Json、正则和 TimeProvider 安全处理文件、结构化数据与时间。

文件、JSON、正则与时间

路径、文本、JSON 和时间都位于系统边界。边界代码必须明确根目录、编码、格式、时区、写入原子性和错误策略,不能依赖当前工作目录或机器区域设置的偶然默认值。


一、学习目标

  • 使用 PathFileDirectory 处理跨平台路径
  • 限制用户路径不逃逸允许目录
  • 以明确编码和原子替换写入文本
  • 用 System.Text.Json 在 DTO 与领域对象之间转换
  • 适度、安全地使用正则表达式
  • 使用 DateTimeOffsetTimeProviderTimeZoneInfo

二、构造路径

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 午夜当作事件发生时间。


八、安全导入流程

  1. 解析和限制输入路径。
  2. 检查文件大小与编码。
  3. 反序列化为 DTO。
  4. 校验每条记录和跨记录唯一性。
  5. 生成完整领域对象集合。
  6. 在一个数据库事务中写入。
  7. 成功后记录数量,不记录完整敏感内容。

任何一步失败都不修改现有任务。若允许跳过坏行,应在产品契约中明确部分成功结果,而不是悄悄忽略。


九、常见错误

用字符串前缀判断安全路径

路径分隔、大小写和相邻目录会绕过简单前缀。使用完整路径与相对路径检查,并配合文件系统权限。

JSON 能解析就直接入库

语法正确不等于 ID、状态和时间合法。DTO 到领域对象必须验证。

保存本地时间却不保存时区

之后无法可靠还原唯一时刻。事件使用 DateTimeOffset 或 UTC。

正则没有超时

恶意或意外输入可能造成长时间回溯。限制输入长度并配置超时。


十、练习与自测

练习:

  1. 实现只能写入指定导出目录的路径解析器。
  2. 为任务 JSON 加入未知字段拒绝和状态验证。
  3. 模拟写入中途异常,确认旧文件仍完整。
  4. 使用假的 TimeProvider 测试“超过 30 天”规则。

自测:

  • 为什么当前工作目录不适合作为默认数据目录?
  • JSON 反序列化成功后还要验证什么?
  • 原子写入为何要求临时文件位于同一文件系统?
  • DateOnlyDateTimeOffset 分别表达什么?

十一、官方资料

上一篇:LINQ、迭代器与惰性查询 | 下一篇:命令行、配置、依赖注入与日志