在做核心网 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));
}
两个小细节值得注意:
ReadLineAsync(cancellationToken)直接当判空用:is { } line模式把”读到 null 即结束”和”取行”合并成一行,同时把取消令牌传了进去,大文件解析可随时中断。- 路径分隔符归一化:配置包来自 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 = "未能在日志文件里面找到该业务表数据";
}
}
}
总结
回看这个案例,几个可以复用的经验:
- 级联解析拆成”任务生成”和”数据解析”两段,前级只产出任务,职责清晰、便于测试;
- 解析有”继承”语义的文本格式必须用跨行状态机,尤其要区分原始行和 Trim 后的行——行首空白是有语义的;
- 手写分词器处理注释、引号、空白分隔,比正则切分更可控;
- 有界 Channel 背压让解析速度自动适配入库速度,内存可控、结构解耦;
- 用
ReadLineAsync(token) is { } line一行同时完成”取行 + 判结束 + 响应取消”。
如果你也在处理网络设备配置、日志等半结构化文本,这套”状态机解析 + Channel 流水线”的组合可以直接搬走。