OpenCode 实践课:4 分钟用 Node.js 写出一个命令行 DNS 记录查询工具

项目介绍

开发过程中经常需要查 DNS 记录排查域名解析问题,每次打开命令行敲 dignslookup 虽然方便,但输出信息杂乱。本文我们用 OpenCode 写一个整洁的命令行 DNS 查询工具 dnslookup,支持 A、AAAA、MX、TXT、NS 等常见记录类型,还能指定自定义 DNS 服务器。

  • 语言:Node.js(零依赖,内置 dns 模块)
  • 最终代码:约 100 行
  • 预计用时:4 分钟

效果预览:

$ node dnslookup.js github.com -t A

  github.com
  ──────────────────────────────────────────
  A    140.82.121.3
  A    140.82.121.4

$ node dnslookup.js github.com -t MX

  github.com
  ──────────────────────────────────────────
  MX    aspmx.l.google.com. (priority: 1)
  MX    alt1.aspmx.l.google.com. (priority: 5)

准备工作

  • Node.js ≥ 16(dns.promises 需要)
  • 安装 OpenCode:npm install -g @anthropic/opencode 或参考 opencode.ai

在终端进入项目目录,opencode 启动交互会话即可开始。

实践过程

第 1 步:初始化项目骨架

打开 OpenCode,用一句话描述项目需求:

> 我对 OpenCode 说:
> "用 Node.js 写一个命令行 DNS 查询工具,叫 dnslookup.js。用内置的 dns.promises,支持参数 -t 指定记录类型(默认 A),-s 指定 DNS 服务器,-h 查看帮助。入口函数叫 main,要求能处理错误。"

OpenCode 立刻生成了带 shebang 的完整脚本骨架,包括参数解析、帮助信息和基本的 resolve 调用逻辑。它自动加了 'use strict',还贴心地对不支持的记录类型做了提示。

关键要点: 第一次提示词要覆盖核心需求——入口、参数、帮助、错误处理。OpenCode 会补全你没想到的细节,比如 --help 的长格式。

第 2 步:美化输出格式

骨架能跑了,但输出是裸 JSON,不够直观:

> 我对 OpenCode 说:
> "输出太丑了。帮我美化:顶部打印域名和下划线分隔,每条记录加记录类型的标签(如 A、MX),MX 记录显示优先级。用 ANSI 颜色区分标签(A 绿色、MX 黄色、错误红色)。"

OpenCode 分析了当前 console.log 的调用位置,重构成一个 printResults 函数,用模板字符串拼接颜色代码,还自动处理了 MX 记录的解构取 priority 和 exchange 字段。

// OpenCode 生成的颜色常量
const COLORS = {
  reset: '\x1b[0m',
  green: '\x1b[32m',
  yellow: '\x1b[33m',
  red: '\x1b[31m',
  cyan: '\x1b[36m',
};

关键要点: 告诉 OpenCode"哪个字段怎么显示",它会自己找到数据结构中的对应属性。不用写具体代码,描述视觉效果就行。

第 3 步:补充 SOA 和 TXT 格式化

测试时发现 TXT 返回的是数组数组,SOA 返回的是对象,显示成一团乱码:

> 我对 OpenCode 说:
> "TXT 记录返回的是二维数组,需要把每行的 strings 拼成一段文本。SOA 记录是一个对象,把 nsname、hostmaster、serial、refresh、retry、expire、minttl 字段逐行格式化展示。"

OpenCode 在 printResults 里加了两段专门的格式化逻辑,TXT 用 join(' ') 拼接,SOA 格式化成多行缩进。整个过程不到 30 秒。

关键要点: 遇到输出格式问题,直接把"原始数据长什么样、希望变成什么样"告诉 OpenCode,它比你手动写 forEach + join 快得多。

完整代码

以下是最终的 dnslookup.js,约 100 行,零依赖,保存后直接 node dnslookup.js baidu.com 即可使用。

#!/usr/bin/env node
'use strict';

const dns = require('dns').promises;

const VALID_TYPES = ['A', 'AAAA', 'MX', 'NS', 'CNAME', 'TXT', 'SOA', 'PTR', 'SRV'];

const COLORS = {
  reset: '\x1b[0m',
  green: '\x1b[32m',
  yellow: '\x1b[33m',
  red: '\x1b[31m',
  cyan: '\x1b[36m',
  bold: '\x1b[1m',
};

function help() {
  console.log(`dnslookup - DNS 记录查询工具

用法:
  node dnslookup.js <domain> [选项]

选项:
  -t, --type    <type>   记录类型: A, AAAA, MX, NS, CNAME, TXT, SOA (默认: A)
  -s, --server  <ip>     指定 DNS 服务器
  -h, --help             显示帮助信息

示例:
  node dnslookup.js github.com
  node dnslookup.js github.com -t MX
  node dnslookup.js baidu.com -t SOA -s 8.8.8.8`);
}

function color(type, text) {
  return `${COLORS[type] || ''}${text}${COLORS.reset}`;
}

function printResults(domain, type, records) {
  console.log(`\n  ${color('bold', domain)}`);
  console.log(`  ${'─'.repeat(40)}`);

  if (!records || records.length === 0) {
    console.log(`  ${color('red', '未查询到记录')}\n`);
    return;
  }

  if (type === 'SOA') {
    const soa = records[0];
    console.log(`  ${color('green', 'SOA')}  ${soa.nsname}`);
    console.log(`         hostmaster: ${soa.hostmaster}`);
    console.log(`         serial:     ${soa.serial}`);
    console.log(`         refresh:    ${soa.refresh}`);
    console.log(`         retry:      ${soa.retry}`);
    console.log(`         expire:     ${soa.expire}`);
    console.log(`         minttl:     ${soa.minttl}`);
  } else if (type === 'TXT') {
    records.forEach(r => {
      const text = Array.isArray(r) ? r.join(' ') : r;
      console.log(`  ${color('green', 'TXT')}  ${text}`);
    });
  } else if (type === 'MX') {
    records.forEach(r => {
      console.log(`  ${color('yellow', 'MX')}   ${r.exchange} (priority: ${r.priority})`);
    });
  } else {
    records.forEach(r => {
      const value = typeof r === 'string' ? r : r.address || JSON.stringify(r);
      console.log(`  ${color('green', type)}    ${value}`);
    });
  }
  console.log('');
}

async function main() {
  const args = process.argv.slice(2);

  if (args.length === 0 || args.includes('-h') || args.includes('--help')) {
    help();
    process.exit(0);
  }

  const domain = args[0];
  let type = 'A';
  let server = null;

  for (let i = 1; i < args.length; i++) {
    if (args[i] === '-t' || args[i] === '--type') {
      type = args[++i]?.toUpperCase() || 'A';
    } else if (args[i] === '-s' || args[i] === '--server') {
      server = args[++i];
    }
  }

  if (!VALID_TYPES.includes(type)) {
    console.error(color('red', `不支持的类型: ${type},支持: ${VALID_TYPES.join(', ')}`));
    process.exit(1);
  }

  try {
    dns.setServers(server ? [server] : []);
    const resolver = server ? new dns.Resolver() : dns;
    if (server) resolver.setServers([server]);

    let records;
    switch (type) {
      case 'A':      records = await dns.resolve4(domain); break;
      case 'AAAA':   records = await dns.resolve6(domain); break;
      case 'MX':     records = await dns.resolveMx(domain); break;
      case 'NS':     records = await dns.resolveNs(domain); break;
      case 'CNAME':  records = await dns.resolveCname(domain); break;
      case 'TXT':    records = await dns.resolveTxt(domain); break;
      case 'SOA':    records = await dns.resolveSoa(domain); break;
      case 'PTR':    records = await dns.resolvePtr(domain); break;
      case 'SRV':    records = await dns.resolveSrv(domain); break;
      default:       records = [];
    }

    printResults(domain, type, [].concat(records));
  } catch (err) {
    console.error(`\n  ${color('red', '查询失败')}: ${err.message}\n`);
    process.exit(1);
  }
}

main();

小结

全程 3 轮对话,约 4 分钟,产出一个 100 行、零依赖、开箱即用的 DNS 查询工具。如果手写的话,查文档、调格式、处理各种记录类型的差异,至少要 20-30 分钟。

OpenCode 最大的价值不在于"帮你写代码",而是省掉了查文档和调试格式化逻辑的时间。你只需要描述"想要什么效果",它负责翻译成代码。对于这种小而明确的工具类项目,效率提升尤为明显。