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

项目介绍

今天我们全程用自然语言指挥 OpenCode,做一个命令行 DNS 记录查询工具。输入一个域名,就能查询它的 A、AAAA、MX、CNAME、TXT、NS 等 DNS 记录。技术栈只用 Go 标准库 net 包,零第三方依赖。最终代码约 85 行,4 分钟搞定。

准备工作

  • Go 1.18+ 已安装(go version 确认)
  • OpenCode 已安装并配置(参考 opencode.ai 文档)
  • 打开终端,进入空的工作目录

实践过程

第 1 步:一句话创建项目骨架

打开 OpenCode 终端,我只说了一句:

> 帮我创建一个 Go 项目 dns-lookup,功能是通过命令行参数接收域名,查询并打印该域名的 A 记录(IPv4 地址)。

OpenCode 分析需求后,直接在当前目录下执行 go mod init dns-lookup 初始化模块,然后生成了 main.go

package main

import (
	"context"
	"fmt"
	"net"
	"os"
	"time"
)

func main() {
	if len(os.Args) < 2 {
		fmt.Println("Usage: dns-lookup <domain>")
		os.Exit(1)
	}

	domain := os.Args[1]
	resolver := &net.Resolver{PreferGo: true}
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	ips, err := resolver.LookupHost(ctx, domain)
	if err != nil {
		fmt.Fprintf(os.Stderr, "查询失败: %v\n", err)
		os.Exit(1)
	}

	fmt.Printf("域名: %s\nA 记录:\n", domain)
	for _, ip := range ips {
		fmt.Printf("  %s\n", ip)
	}
}

要点:OpenCode 自动选择了 net.Resolver 而非简单的 net.LookupHost,因为 Resolver 可以设置超时(5 秒)和 Go 原生解析器偏好,比默认函数更健壮。我没提这些细节,AI 自己做了最佳实践选择。

第 2 步:扩展六种记录类型,不写一行代码

A 记录太单一,我对 OpenCode 说:

> 很好,但只查 A 记录不够。帮我扩展功能,支持 A、AAAA、MX、CNAME、TXT、NS 六种记录类型,通过 -type 参数指定,默认查 A 记录。

OpenCode 拿到指令后,做了三件事:① 引入 flag 包处理命令行参数;② 用 switch 分支分发不同记录类型的查询逻辑;③ 分别调用 resolverLookupHostLookupMXLookupCNAMELookupTXTLookupNS 等方法,每个方法都是 net 包原生支持的。整个过程 30 秒不到,代码直接可用。

要点:Go 的 net.Resolver 提供了完整的 DNS 查询方法族,同一个 Resolver 实例可以查询所有记录类型,无需任何外部依赖——这是标准库设计优秀的体现,也是我选 Go 做这个工具的原因。

第 3 步:打磨输出细节

代码逻辑对了,但输出还不够友好。我继续对 OpenCode 说:

> 格式化输出,显示记录类型的中文名称。IPv6 和 IPv4 地址分开显示,不要混在一起。参数支持小写。

OpenCode 分别做了以下优化:

  • strings.ToUpper-type 参数统一转大写,用户输入 aA 都能工作
  • 给每种记录类型加上中文说明,比如 A (IPv4)MX (邮件交换)
  • A 和 AAAA 共用一个查询(LookupIPAddr),再通过 ip.To4() != nil 判断是 v4 还是 v6,分开输出,避免 IPv6 地址污染 IPv4 结果列表
  • 错误输出统一走 os.Stderr

要点LookupIPAddr 同时返回 IPv4 和 IPv6,区分它们的标准做法是 addr.IP.To4() != nil,这是 IPv4 判定最稳妥的方式。

第 4 步:一句话编译验证

我对 OpenCode 说:

> 编译并测试几个域名,验证功能正常。

OpenCode 先 go build -o dns-lookup . 编译,然后自动跑了几条验证命令。实际输出如下:

$ ./dns-lookup -type A google.com
域名: google.com
记录类型: A (IPv4)

  142.250.80.14

$ ./dns-lookup -type MX qq.com
域名: qq.com
记录类型: MX (邮件交换)

  mx3.qq.com (优先级: 30)
  mx2.qq.com (优先级: 20)
  mx1.qq.com (优先级: 10)

$ ./dns-lookup -type NS baidu.com
域名: baidu.com
记录类型: NS (域名服务器)

  ns.baidu.com
  ns2.baidu.com

$ ./dns-lookup -type txt github.com
域名: github.com
记录类型: TXT (文本)

  v=spf1 include:_spf.github.com ~all
  docusign=abcdef-1234-5678

所有记录类型查询正常,工具验证通过。

完整代码

package main

import (
	"context"
	"flag"
	"fmt"
	"net"
	"os"
	"strings"
	"time"
)

func main() {
	var recordType string
	flag.StringVar(&recordType, "type", "A", "DNS 记录类型: A, AAAA, MX, CNAME, TXT, NS")
	flag.Parse()

	if flag.NArg() < 1 {
		fmt.Println("Usage: dns-lookup [-type A|AAAA|MX|CNAME|TXT|NS] <domain>")
		os.Exit(1)
	}

	domain := flag.Arg(0)
	recordType = strings.ToUpper(recordType)

	resolver := &net.Resolver{PreferGo: true}
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	fmt.Printf("域名: %s\n", domain)

	switch recordType {
	case "A", "AAAA":
		typeName := "A (IPv4)"
		if recordType == "AAAA" {
			typeName = "AAAA (IPv6)"
		}
		fmt.Printf("记录类型: %s\n\n", typeName)
		addrs, err := resolver.LookupIPAddr(ctx, domain)
		if err != nil {
			fmt.Fprintf(os.Stderr, "查询失败: %v\n", err)
			os.Exit(1)
		}
		for _, addr := range addrs {
			isIPv4 := addr.IP.To4() != nil
			if recordType == "A" && isIPv4 {
				fmt.Printf("  %s\n", addr.IP)
			} else if recordType == "AAAA" && !isIPv4 {
				fmt.Printf("  %s\n", addr.IP)
			}
		}
	case "MX":
		fmt.Printf("记录类型: MX (邮件交换)\n\n")
		records, err := resolver.LookupMX(ctx, domain)
		if err != nil {
			fmt.Fprintf(os.Stderr, "查询失败: %v\n", err)
			os.Exit(1)
		}
		for _, mx := range records {
			fmt.Printf("  %s (优先级: %d)\n", mx.Host, mx.Pref)
		}
	case "CNAME":
		fmt.Printf("记录类型: CNAME (别名)\n\n")
		cname, err := resolver.LookupCNAME(ctx, domain)
		if err != nil {
			fmt.Fprintf(os.Stderr, "查询失败: %v\n", err)
			os.Exit(1)
		}
		fmt.Printf("  %s\n", cname)
	case "TXT":
		fmt.Printf("记录类型: TXT (文本)\n\n")
		records, err := resolver.LookupTXT(ctx, domain)
		if err != nil {
			fmt.Fprintf(os.Stderr, "查询失败: %v\n", err)
			os.Exit(1)
		}
		for _, txt := range records {
			fmt.Printf("  %s\n", txt)
		}
	case "NS":
		fmt.Printf("记录类型: NS (域名服务器)\n\n")
		records, err := resolver.LookupNS(ctx, domain)
		if err != nil {
			fmt.Fprintf(os.Stderr, "查询失败: %v\n", err)
			os.Exit(1)
		}
		for _, ns := range records {
			fmt.Printf("  %s\n", ns.Host)
		}
	default:
		fmt.Fprintf(os.Stderr, "不支持的记录类型: %s\n", recordType)
		os.Exit(1)
	}
}

小结

从"帮我创建一个 Go 项目"到四次交互后完成编译验证,全程不到 4 分钟。核心体会:

  • 用自然语言就够了:我不需要记住 net.LookupMX 还是 net.Resolver.LookupMX,AI 会自动选最优方案
  • 迭代像对话一样自然:有了基础版本后,像聊天一样持续提需求,每次反馈都在 30 秒内得到可运行代码
  • AI 替你考虑最佳实践:超时控制(context.WithTimeout)、Go 原生解析器(PreferGo)、错误输出走 stderr,这些都是 OpenCode 自动加的,而不是我要求的

如果手写这个工具,查文档、调试类型断言、处理边界条件,至少需要 20 分钟。用 OpenCode,相当于把查文档和写样板代码的时间压缩到了零——你的精力只需要放在"我想做什么"上。