OpenCode 实践课:4 分钟用 Go 写出一个命令行终端表格渲染器

项目介绍

在日常开发中,我们经常需要查看 CSV、TSV 等分隔符数据文件。直接 cat 出来的效果难以阅读,而打开 Excel 又太重。今天我们用 OpenCode 写一个命令行表格渲染工具——从标准输入读取分隔符数据,渲染成带边框的格式化表格。

最终效果如下:

$ echo "name,age,city\n张三,28,北京\n李四,32,上海" | ./table
+------+-----+------+
| name | age | city |
+------+-----+------+
| 张三 | 28  | 北京 |
| 李四 | 32  | 上海 |
+------+-----+------+

技术栈:Go 语言,零外部依赖,仅用标准库。代码约 100 行。

功能特性

  • 支持多种分隔符(逗号、制表符、管道、分号)
  • 支持 ascii / 轻线框 / 双线框三种边框样式
  • 自动计算列宽并对齐
  • 可指定首行是否为表头

准备工作

确保本地已安装:

  • Go 1.18+(用于编译运行)
  • OpenCode(安装方法见 opencode.ai)

> 如果没有 OpenCode,也可以用 VS Code + GitHub Copilot 替代,交互思路是一样的。

实践过程

第一步:初始化项目

直接告诉 OpenCode 我们要什么,不需要手写一行代码:

> 我对 OpenCode 说
> 帮我创建一个 Go 项目 table,功能是从标准输入读取分隔符数据,渲染成格式化表格输出到终端。包含 -d 参数指定分隔符类型,自动根据列宽对齐。

OpenCode 先执行 go mod init table 初始化项目,然后生成了第一版代码。它自己判断了关键逻辑——读取 stdin、按分隔符切分、计算列宽、画 ASCII 边框——然后直接写出了完整可用的程序。

关键点:描述需求时,说清楚"输入是什么、输出是什么、参数有什么",OpenCode 就能一次性生成接近可用的完整代码。

第二步:添加边框样式

基础 ASCII 表格能跑了,但看起来比较朴素。我们让它支持几种不同的边框风格:

> 我对 OpenCode 说
> 再加一个 -s 参数控制边框样式,支持 asciilightdouble 三种。light 用 Unicode 轻线框字符(┼├─│┤),double 用双线框(╬╠═║╣)。

OpenCode 理解了需求,用 map[string]struct{} 定义了三种边框样式配置,然后修改了画分隔线和画数据行的函数来使用对应字符。这次对话里 OpenCode 展现了一个很好的习惯——它先给出 diff 对比让我确认范围,再执行编辑。

关键点:当需求涉及"多个变体"时,用列表形式把每个变体说清楚,OpenCode 会准确映射。

第三步:支持无表头模式

> 我对 OpenCode 说
> 加一个 -n 参数,当传入 -n 时认为输入没有表头行,所有行都当作数据行平等对待。

OpenCode 在渲染阶段根据 -n 标志决定:有表头时第一行用分隔线隔开,无表头时所有行统一处理。改动精准、没有破坏已有逻辑。

关键点:单次对话聚焦一个功能点。需求粒度越小,OpenCode 出错的概率越低。

第四步:完善边界处理

功能完成后,加上健壮性:

> 我对 OpenCode 说
> 处理三种边界情况:1)空行跳过;2)列数不一致的行自动补空;3)没有任何数据输入时输出错误提示。

OpenCode 在读取循环中加了 if line == "" { continue },在渲染时为缺失列做空字符串兜底,数据为空时输出错误并退出。

这一步体现了 AI 编程的优势:人工写代码时,边界情况往往被忽略;而 OpenCode 收到明确指令后,会一次性把所有边界处理到位。

第五步:编译与验证

> 我对 OpenCode 说
> 帮我编译项目,然后用几条测试数据跑一下验证。

OpenCode 执行了 go build -o table 和几条测试命令,一切正常后给出了完整的使用示例。

从第一个需求到可运行工具,全程 4 次自然语言对话。没有手动写一行 flag.Stringbufio.Scanner,也没有查一次 Go 文档。

完整代码

package main

import (
	"bufio"
	"flag"
	"fmt"
	"os"
	"strings"
)

func main() {
	delimiter := flag.String("d", "comma", "delimiter: comma, tab, pipe, or semicolon")
	noHeader := flag.Bool("n", false, "no header row")
	style := flag.String("s", "ascii", "border style: ascii, light, or double")
	flag.Parse()

	delimiters := map[string]string{
		"comma":     ",",
		"tab":       "\t",
		"pipe":      "|",
		"semicolon": ";",
	}

	sep, ok := delimiters[*delimiter]
	if !ok {
		fmt.Fprintf(os.Stderr, "unsupported delimiter: %s\n", *delimiter)
		os.Exit(1)
	}

	borders := map[string]struct{ cross, left, mid, right, h, v string }{
		"ascii":  {"+", "+", "+", "+", "-", "|"},
		"light":  {"┼", "├", "┼", "┤", "─", "│"},
		"double": {"╬", "╠", "╬", "╣", "═", "║"},
	}

	bd, ok := borders[*style]
	if !ok {
		fmt.Fprintf(os.Stderr, "unsupported style: %s\n", *style)
		os.Exit(1)
	}

	scanner := bufio.NewScanner(os.Stdin)
	var rows [][]string
	maxCols := 0
	for scanner.Scan() {
		line := scanner.Text()
		if line == "" {
			continue
		}
		cols := strings.Split(line, sep)
		for i := range cols {
			cols[i] = strings.TrimSpace(cols[i])
		}
		rows = append(rows, cols)
		if len(cols) > maxCols {
			maxCols = len(cols)
		}
	}

	if len(rows) == 0 {
		fmt.Fprintln(os.Stderr, "no input data")
		os.Exit(1)
	}

	colWidths := make([]int, maxCols)
	for _, row := range rows {
		for i, cell := range row {
			runeLen := len([]rune(cell))
			if runeLen > colWidths[i] {
				colWidths[i] = runeLen
			}
		}
	}

	printSep := func(left, mid, right string) {
		fmt.Print(left)
		for i, w := range colWidths {
			if i > 0 {
				fmt.Print(mid)
			}
			fmt.Print(strings.Repeat(bd.h, w+2))
		}
		fmt.Println(right)
	}

	printRow := func(row []string) {
		fmt.Print(bd.v)
		for i := range colWidths {
			cell := ""
			if i < len(row) {
				cell = row[i]
			}
			spaces := colWidths[i] - len([]rune(cell))
			fmt.Printf(" %s%s %s", cell, strings.Repeat(" ", spaces), bd.v)
		}
		fmt.Println()
	}

	if !*noHeader {
		printSep(bd.cross, bd.cross, bd.cross)
		printRow(rows[0])
		printSep(bd.left, bd.mid, bd.right)
		rows = rows[1:]
	} else {
		printSep(bd.cross, bd.cross, bd.cross)
	}

	for _, row := range rows {
		printRow(row)
	}
	printSep(bd.cross, bd.cross, bd.cross)
}

使用示例

# 编译
go build -o table

# 默认 CSV 输入
echo "name,age,city\n张三,28,北京\n李四,32,上海" | ./table

# 管道分隔符 + Unicode 轻线框
echo "CPU|负载|温度\ni7|45%|72°C" | ./table -d pipe -s light

# 无表头模式
echo "成功,200\n错误,500" | ./table -n

小结

这个 100 行的表格渲染工具,从想法到可运行,全程只用了 4 次自然语言对话。OpenCode 的实际价值在于:

省去样板代码——项目初始化、flag 解析、错误处理这些机械工作,OpenCode 秒级完成。

即时的上下文理解——加边框样式时,OpenCode 自动关联到已有的 printSepprintRow 函数,无需手动指定位。

迭代友好——每次只提一个改动点,OpenCode 能精确编辑,不会破坏已写好的逻辑。

传统的"打开 IDE → 写代码 → 查文档 → 调试"循环,在这个项目里被压缩成了"说需求 → 看结果 → 说下一个需求"。对于这类百行级小工具,AI 编程的效率提升是数量级的。

如果你也在用 OpenCode,欢迎分享你的实践心得。