在做核心网 DNS 数据核对时,我们拿到了华为 DNS 设备(底层是 BIND)导出的配置包:一个 zip 里塞着 named.conf、若干 view 的 include 文件,以及成百上千个 Zone 数据库文件。需求很直接——把里面的 A/AAAA/NAPTR 记录全部解析出来落库,供前端查询和统计。

这个场景很适合讲清楚三件事:配置文件的级联解析、RFC 1035 master file 的”继承”规则、解析与入库之间的 Channel 背压。下面结合真实实现逐步展开。

一、整体链路:三级级联解析

BIND 的配置是典型的树形结构:named.conf 定义 view,view 里 include 了 zone 配置,zone 配置又指向真正的记录文件(db 文件)。解析器必须按这个层级逐层展开:

flowchart TD
    A[zip 配置包] --> B[解压到临时目录]
    B --> C[named.conf<br/>提取 View + include 路径]
    C --> D[zone.conf<br/>提取 Zone + db 文件路径]
    D --> E[db 文件<br/>逐行解析 A/AAAA/NAPTR 记录]
    E --> F[Bounded Channel]
    F --> G[消费端批量入库]
    G --> H[构建统计表]

对应到代码,主入口 HwDnsParser.ParseAndPersistAsync 把三个解析器串成一条流水线:

SharpZipHelper.UnZip(parseContext.NeLogFile.FullPath, tempPath);

string rootPath = Path.Combine(tempPath, "replicated", "jail", "named");

List<DnsZoneTask> tasks = [];

// 第一级:named.conf → (ViewName, includePath)
List<(string ViewName, string IncludePath)> includes =
    await NamedConfParser.ParseAsync(rootPath, cancellationToken);

foreach ((string viewName, string includePath) in includes)
{
    // 第二级:zone.conf → (ZoneName, dbFilePath)
    List<(string ZoneName, string DbFilePath)> zones =
        await ZoneConfParser.ParseAsync(rootPath, includePath, cancellationToken);

    foreach ((string zoneName, string dbFilePath) in zones)
    {
        tasks.Add(new DnsZoneTask
        {
            ViewName = viewName,
            ZoneName = zoneName,
            DbFilePath = dbFilePath
        });
    }
}

这里的设计要点是:前两级解析不产出业务数据,只产出”解析任务”(DnsZoneTask)。真正干活的只有第三级。这样每一级职责单一、可以单独测试,也方便在展开任务后做流式处理。

二、第一级:named.conf 的 view 作用域

named.conf 里的结构大致是:

view "cmnet" {
    zone "..." { ... };
    include "/etc/zones-cmnet.conf";
};
view "ims" {
    include "/etc/zones-ims.conf";
};

关键在于 include 指令属于哪个 view。解析时用两个变量维护”当前是否在 view 块内”:

private static readonly Regex ViewRegex =
    new(@"^view\s+""([^""]+)""", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex IncludeRegex =
    new(@"^include\s+""([^""]+)""", RegexOptions.IgnoreCase | RegexOptions.Compiled);

string currentView = string.Empty;
bool insideView = false;

while (await reader.ReadLineAsync(cancellationToken) is { } line)
{
    line = line.Trim();

    Match viewMatch = ViewRegex.Match(line);
    if (viewMatch.Success)
    {
        currentView = viewMatch.Groups[1].Value;
        insideView = true;
        continue;
    }

    if (insideView && line.StartsWith('}'))
    {
        currentView = string.Empty;
        insideView = false;
        continue;
    }

    if (!insideView)
    {
        continue;   // view 块之外的 include 不关心
    }

    Match includeMatch = IncludeRegex.Match(line);
    if (!includeMatch.Success)
    {
        continue;
    }

    // 设备是 Linux,路径统一转成当前系统分隔符
    string includePath = includeMatch.Groups[1].Value;
    includePath = includePath.TrimStart('/').Replace('/', Path.DirectorySeparatorChar);
    includePath = Path.Combine(rootPath, includePath);

    result.Add((currentView, includePath));
}

两个小细节值得注意:

  1. ReadLineAsync(cancellationToken) 直接当判空用:is { } line 模式把”读到 null 即结束”和”取行”合并成一行,同时把取消令牌传了进去,大文件解析可随时中断。
  2. 路径分隔符归一化:配置包来自 Linux 设备,/ 直接拼在 Windows 上会出问题,读出来先 Replace('/', Path.DirectorySeparatorChar) 再 Path.Combine。

三、第二级:zone.conf 提取 Zone 与 db 文件

include 出来的 zone 配置文件里,每个 zone 块包含一个 file 指令指向记录文件:

Match zoneMatch = ZoneRegex.Match(line);
if (zoneMatch.Success)
{
    currentZone = zoneMatch.Groups[1].Value;
    // 根区 "." 没有业务价值,跳过
    if (!currentZone.Equals(".", StringComparison.OrdinalIgnoreCase))
    {
        insideZone = true;
    }
    continue;
}

匹配到 file "xxx.db" 后组装出 db 文件的绝对路径,一条 (ZoneName, DbFilePath) 就确定了。这里同样用 insideZone 标记避免把 zone 块之外的 file 误收进来。

四、核心:db 文件的三个”继承”规则

这才是整篇文章的技术核心。BIND 的 master file(RFC 1035 格式)长这样:

$TTL 86400
$ORIGIN mnc001.mcc460.gprs.
apn1                              IN A 10.1.1.1
    3600    IN  A   10.1.1.2
                                  IN A 10.1.1.3
apn2                              IN A 10.2.2.2

注意第二、三行——行首是空白而不是域名。这是 master file 最重要的规则:

  • owner 继承:行首空白表示该行沿用上一条记录的 owner(域名);
  • TTL 继承:$TTL 给出默认值,行内若显式写了数字 TTL 则覆盖;
  • ORIGIN 继承:$ORIGIN 改变当前域后缀,相对域名要拼上它才是完整域名。

三种状态都是”跨行”的,所以解析器必须维护跨行状态机,而不能按孤立行处理:

int defaultTtl = 86400;
string currentOrigin = string.Empty;
string currentOwner = string.Empty;

while (await reader.ReadLineAsync(cancellationToken) is { } rawLine)
{
    string line = rawLine.Trim();
    if (line.Length == 0) continue;

    // 1) $TTL 指令:更新默认 TTL
    var ttl = ParseDefaultTtl(rawLine);
    if (ttl.HasValue) { defaultTtl = ttl.Value; continue; }

    // 2) $ORIGIN 指令:更新当前域后缀
    var origin = ParseOrigin(rawLine);
    if (origin != null) { currentOrigin = origin; continue; }

    if (rawLine[0] == '$') continue;   // 其他 $ 指令($GENERATE 等)跳过

    // 3) owner 继承:注意用"原始行"判断行首是否空白,Trim 过就丢了
    bool ownerInherited = char.IsWhiteSpace(rawLine[0]);

    List<string> tokens = Tokenize(line);
    if (tokens.Count < 2) continue;

    int offset;
    if (ownerInherited)
    {
        offset = 0;
    }
    else
    {
        offset = 1;
        currentOwner = tokens[0];
    }

    // 4) 行内可选 TTL
    int currentTtl = defaultTtl;
    if (tokens[offset].IsNumber())
    {
        currentTtl = int.Parse(tokens[offset]);
        offset++;
    }

    string type = tokens[offset].ToUpperInvariant();
    switch (type)
    {
        case "A":
        case "AAAA":
            await writer.WriteAsync(new DnsARecord
            {
                ViewName = task.ViewName,
                ZoneName = task.ZoneName,
                TTL = currentTtl,
                Origin = currentOrigin,
                Domain = currentOwner,
                RecordType = type,
                Address = tokens[offset + 1]
            }, cancellationToken);
            break;

        case "NAPTR":
            // NAPTR 固定 7 列:order preference flags service regexp replacement
            break;
    }
}

最容易踩的坑有两个:

  • 判断 owner 继承必须用 rawLine 而不是 line。代码先 Trim() 得到用于分词的 line,但”行首是否空白”这个信息只在原始行上存在。如果顺手用 line[0] 判断,继承行会被误判成新 owner,整张表的数据归属全错。
  • $ORIGIN、行内 TTL、IN 的出现都是可选的。解析不能假设列位置固定,所以先判断 token 是否数字来识别 TTL,再取记录类型。这里用了一个零分配的 IsNumber 扩展:
public static bool IsNumber(this string str)
{
    if (string.IsNullOrEmpty(str)) return false;
    return str.AsSpan().IndexOfAnyExcept(Digits) == -1;
}

IndexOfAnyExcept 是 .NET 8 起提供的 API,配合 ReadOnlySpan 一次遍历完成”全是数字”判断,比 int.TryParse 语义更准(不会把超长数字串误判为 false)。

五、手写 Tokenize:注释、引号与空白

master file 的分词规则比 Split(' ') 复杂:分号 ; 后到行尾是注释、双引号内的空白不算分隔符、引号本身不作为 token 内容。手写一个基于 Span<char> 的分词器:

private static List<string> Tokenize(string line)
{
    List<string> tokens = [];
    Span<char> span = line.ToCharArray();
    bool inQuote = false;
    int start = -1;

    for (int i = 0; i < span.Length; i++)
    {
        char c = span[i];

        // 引号外的分号是注释起点,截断剩余部分
        if (!inQuote && c == ';')
        {
            if (start >= 0)
            {
                tokens.Add(line[start..i].Trim('"'));
                start = -1;
            }
            break;
        }

        if (c == '"')
        {
            inQuote = !inQuote;
            if (start < 0) start = i + 1;   // 跳过引号本身
            continue;
        }

        bool separator = !inQuote && char.IsWhiteSpace(c);
        if (!separator)
        {
            if (start < 0) start = i;
            continue;
        }

        if (start >= 0)
        {
            tokens.Add(line[start..i].Trim('"'));
            start = -1;
        }
    }

    if (start >= 0)
    {
        tokens.Add(line[start..].Trim('"'));
    }

    return tokens;
}

思路就是经典的扫描器写法:一个 inQuote 状态 + 一个 start 游标,遇到分隔符就切出一段。引号内的空白被 separator 条件挡住,保证 "SIP+D2U" 这种带正负号或空格的服务字段不会被切碎。

六、FQDN 补全与业务字段提取

拿到 owner 后还要还原完整域名。master file 里有三情况:@ 表示 zone 本身;以 . 结尾的是绝对域名(FQDN);其余是相对域名,要拼上 $ORIGIN。业务侧还要从完整域名里剥出 APN 名:

private static string GetApnName(string zoneName, string origin, string domain)
{
    if (string.IsNullOrWhiteSpace(domain)) return "无法解析";
    if (domain == "@") return string.Empty;
    if (domain.Equals(zoneName, StringComparison.OrdinalIgnoreCase)) return string.Empty;

    if (string.IsNullOrWhiteSpace(origin))
    {
        origin = zoneName + ".";
    }

    // 相对域名补上 origin,变成 FQDN
    if (!domain.EndsWith('.'))
    {
        domain += (origin == "." ? "." : "." + origin);
    }

    // 从 FQDN 中去掉 zone 后缀,剩下的就是 APN 名
    var suffix = "." + zoneName + ".";
    if (domain.EndsWith(suffix, StringComparison.OrdinalIgnoreCase))
    {
        return domain[..^suffix.Length];
    }

    return "无法解析";
}

domain[..^suffix.Length] 用范围运算符直接掐掉尾部后缀,比 Substring(0, domain.Length - suffix.Length) 更能表达意图。

七、Channel 背压:解析再快也不能压垮入库

db 文件可能有几千个、每个几万行,如果先全部解析成对象集合再入库,内存会瞬间膨胀。实现里用了一个有界 Channel 把”生产(解析)“和”消费(批量入库)“解耦:

BoundedChannelOptions channelOptions = new(BatchSize * 2)   // BatchSize = 30000
{
    FullMode = BoundedChannelFullMode.Wait,   // 满了就挂起生产者
    SingleReader = true,
    SingleWriter = true
};

Channel<INeLogRecord> channel = Channel.CreateBounded<INeLogRecord>(channelOptions);

Task consumerTask = NeLogPersistService.PersistAsync(
    channel.Reader, parseContext, parseResult, BatchSize, cancellationToken);

try
{
    foreach (var task in tasks)
    {
        await DnsDbParser.ParseAsync(parseContext, task, channel.Writer, cancellationToken);
    }
}
finally
{
    channel.Writer.Complete();   // 通知消费端:没有更多数据了
    await consumerTask;

    await DnsARecord.BuildStatisticsAsync(parseContext, parseResult, cancellationToken);
    await DnsNaptrRecord.BuildStatisticsAsync(parseContext, parseResult, cancellationToken);
}

三个配置各有讲究:

  • BoundedChannelFullMode.Wait:容量达到上限时,WriteAsync 会异步等待而不是无限入队,这就是背压——入库慢,解析自动慢下来,内存占用被 BatchSize * 2 封顶;
  • SingleReader/SingleWriter = true:关掉不必要线程安全开销,让 Channel 走最轻量路径;
  • finally 里 Complete() + await consumerTask:即使解析中途抛异常,也会通知消费端收尾并等待其退出,避免消费任务泄漏;统计表的构建也放在消费完成之后,保证算统计时数据已全部落库。

八、收尾:临时目录清理与结果标记

解析完不管成败都要删掉解压出来的临时目录;另外”文件解析成功但一条业务数据都没有”也要显式标失败,避免上游误以为任务成功:

finally
{
    if (Directory.Exists(tempPath))
    {
        Directory.Delete(tempPath, true);
    }
}

if (parseResult.Success)
{
    foreach (var r in parseResult.NeTableParseResults.Values)
    {
        if (r.Success && r.InsertedRows == null)
        {
            r.Success = false;
            r.Message = "未能在日志文件里面找到该业务表数据";
        }
    }
}

总结

回看这个案例,几个可以复用的经验:

  1. 级联解析拆成”任务生成”和”数据解析”两段,前级只产出任务,职责清晰、便于测试;
  2. 解析有”继承”语义的文本格式必须用跨行状态机,尤其要区分原始行和 Trim 后的行——行首空白是有语义的;
  3. 手写分词器处理注释、引号、空白分隔,比正则切分更可控;
  4. 有界 Channel 背压让解析速度自动适配入库速度,内存可控、结构解耦;
  5. 用 ReadLineAsync(token) is { } line 一行同时完成”取行 + 判结束 + 响应取消”。

如果你也在处理网络设备配置、日志等半结构化文本,这套”状态机解析 + Channel 流水线”的组合可以直接搬走。