
如果你刚开始学 Go那我猜你在终端里第一个真正“上手”的操作除了package main和func main()多半就是fmt.Println(Hello, world)了。等写了几段代码你会发现打印这事远不止“输出一句话”这么简单要看变量的值、要看结构体的字段、要控制小数位数、要排成表格甚至还会碰到“为什么 Go 的时间格式化这么奇怪非要写 20060102”这种让新人一脸懵的问题。这些问题其实都指向同一个核心工具fmt包的格式化输出。今天我就把这块彻底讲透顺便把那个神秘的2006-01-02 15:04:05也一起说清楚。这篇内容适合 Go 零基础的同学也适合写过一阵但一直被格式符号绕晕的人。我尽量用大白话加实际例子来写不整那些绕弯子的概念。你看完以后至少能做到拿到一个变量就知道该用哪个格式化动词遇到输出需求敢直接动手写不会再去搜索引擎里一个个查。1. 为什么 Go 初学者先把 fmt 打印吃透1.1 fmt 包到底帮你干了什么fmt包是 Go 标准库里最常用的包全称是format。它的核心职责就是“格式化”也就是把数据按你指定的模板转换成字符串再输出到终端、文件、网络连接等地方。你可以把它理解成一套“数据的穿搭指南”同样的数据用不同的动词打印出来的是完全不一样的效果。平时我们见到最多的三个函数是fmt.Println、fmt.Print和fmt.Printf。很多新手只知道前两个会换行然后就开始乱用结果调试的时候浪费了大量时间。实际上这些函数各有各的分工fmt.Print把内容打印出来不带换行。fmt.Println打印内容并自动换行多个参数之间会自动加空格。fmt.Printf按格式化字符串打印内容不会自动换行。fmt.Sprintf不打印而是把格式化结果作为字符串返回非常适合拼日志内容。fmt.Fprintf把格式化结果写到一个io.Writer里比如文件、HTTP 响应。如果你只是想快速看一个变量的值fmt.Println确实够用。但当你需要输出类似用户名: Alice, 年龄: 30这种带固定结构的文本时用fmt.Printf才是最舒服的。举个简单例子name : Alice age : 30 fmt.Println(用户名:, name, 年龄:, age) fmt.Printf(用户名: %s, 年龄: %d\n, name, age)两种写法都能输出但第二种的好处是“模板清楚”后面参数一多也不容易乱。我见过不少同学把所有调试信息都用Println打一旦变量多了终端里就是一堆空格隔开的数据根本分不清谁是谁。 我自己的习惯是简单看一下用Println需要控制格式、需要拼接描述信息、需要把结果写到日志或文件的时候一律用Printf或Sprintf。1.2 格式化字符串的工作方式fmt.Printf的第一个参数是格式化字符串里面包含普通文本和占位符。占位符以%开头后面跟着一个或者多个字符表示“这里要放一个什么样的数据”。比如%s表示字符串%d表示十进制整数%f表示浮点数。当函数执行时Go 会把后面的参数按顺序填到占位符的位置上。如果参数多了会被忽略如果参数少了占位符会原样输出并带上%!标记比如%!d(MISSING)。这个机制很像 Python 里的%格式化也像 C 语言里的printf但 Go 的动词更丰富也更容易踩坑。我建议大家第一次学fmt时不要贪多先把下面这组最常用的动词记下来后面再慢慢扩fmt.Printf(字符串%s 整数%d 浮点%.2f 布尔%t\n, hello, 42, 3.14159, true)输出结果就是字符串hello 整数42 浮点3.14 布尔true我为什么强调要“先记常用”是因为fmt的动词数量确实不少有些不常用的你可以用到再查但最重要、出现频率最高的几个如果记不住写代码就会经常卡壳。下面这个表格是我自己整理的最常用动词建议你收藏。动词作用示例输出%v默认格式几乎什么都能打fmt.Printf(%v, 42)42%v结构体打印时带上字段名fmt.Printf(%v, user){Name:Alice Age:30}%#vGo 语法格式能看出类型fmt.Printf(%#v, user)main.User{Name:Alice, Age:30}%T变量的类型fmt.Printf(%T, 42)int%d十进制整数fmt.Printf(%d, 42)42%s字符串或[]bytefmt.Printf(%s, []byte(hi))hi%q双引号包裹的安全字符串fmt.Printf(%q, hi)hi%t布尔值fmt.Printf(%t, true)true%f浮点数默认 6 位小数fmt.Printf(%f, 3.14159)3.141590%.2f保留两位小数fmt.Printf(%.2f, 3.14159)3.14%x十六进制fmt.Printf(%x, 255)ff%p指针地址fmt.Printf(%p, x)0xc000012345%%输出百分号本身fmt.Printf(%d%%, 90)90%这里面最需要关注的是%v。很多人喜欢偷懒所有变量都用%v反正它什么都能打。但%v也有一个明显的缺点当结构体字段多的时候它打出来的信息不够直观看不到字段名。这时候就该%v上场了它能帮你把字段名一起打印出来调试结构体时简直是救命稻草。2. 格式化动词fmt 包的打印“语法”核心2.1 数值、字符串和布尔的正确用法先来说最常见的几个数据类型。整数的默认动词是%d表示以十进制显示。如果你想看二进制、八进制、十六进制可以用%b、%o、%x。浮点数的默认动词是%f但%f默认输出六位小数这往往不是你想要的效果。想要两位小数就写%.2f想要控制总宽度可以写成%8.2f意思是整体至少占 8 个字符小数点后保留 2 位左边用空格补齐。price : 12.3456 fmt.Printf(默认: %f\n, price) fmt.Printf(两位: %.2f\n, price) fmt.Printf(宽度8两位: %8.2f\n, price) fmt.Printf(宽度8两位左对齐: %-8.2f|\n, price)输出默认: 12.345600 两位: 12.35 宽度8两位: 12.35 宽度8两位左对齐: 12.35 |这段代码里的%-8.2f是“左对齐”的意思。默认情况下数字靠右对齐前面补空格加了-号后变成靠左后面补空格。这个技巧在打印表格、对齐菜单价格时非常有用。字符串的常用动词是%s和%q。%s直接输出字符串内容%q会加上双引号并且会对特殊字符做转义。比如字符串里有换行符时%q会输出\n看起来更安全但是如果你只是想要“能看就行”用%s就够了。布尔的格式化最简单用%t输出true或false。严格来说它不算复杂但注意不要用%d去打布尔值那样你会看到%!d(booltrue)这样的报错信息挺莫名其妙的。2.2 打印复合结构map、切片和指针除了单值日常打印更多的是切片、map、结构体、指针这些复合类型。%v依然能打但输出效果要看类型nums : []int{1, 2, 3} fmt.Printf(%v\n, nums) // [1 2 3] m : map[string]int{a: 1} fmt.Printf(%v\n, m) // map[a:1]这里有个小坑map 的打印输出顺序是随机的。Go 语言在设计时故意打乱了 map 的遍历顺序所以同一个 map 连续打印两次可能两次的顺序都不一样。如果你要在日志里对比 map 内容别只看顺序要看内容本身。切片和数组的打印就稳定得多按元素顺序输出。对于多维切片%v也能打印比如[[1 2] [3 4]]直观、够用。指针类型用%v打印时Go 会自动解引用吗这得分情况。如果你打印的是一个结构体指针比如userGo 会输出{Alice 30}看起来已经很友好了但如果你打印一个普通变量指针比如x%v输出的是0xc000012345这种地址。如果你想强制看地址就用%p如果你想看指针指向的值可以用*ptr或者直接%v打指针本身某些类型会解引用。这里我习惯的做法是调试指针变量时先fmt.Printf(%#v\n, ptr)因为它会输出类似main.User{Name:Alice, Age:30}的形式一眼就能看出它指向的是什么类型以及字段值。2.3 和 Python 格式化输出对比很多人之前学过 Python看到 Go 的%s、%d会觉得眼熟因为 Python 的%格式化也是这套写法。但两者有个非常明显的区别Python 有 f-string 和str.format()Go 没有那套“花括号占位”的机制只能老老实实写占位符。name Alice age 30 print(fName: {name}, Age: {age}) # Python f-stringname : Alice age : 30 fmt.Printf(Name: %s, Age: %d\n, name, age)如果你习惯了 Python 的 f-string初次写 Go 可能会总想着在字符串里直接写变量然后就写出了fmt.Println(name is name)这种尴尬代码打印出来还是name is name完全不是你想的值。这个坑我实在见得太多了。Go 没有字符串插值你必须通过%占位符和参数列表来告诉编译器“这里放哪个变量的值”。另外Python 的str.format()可以按名字引用参数比如{name}Go 精确对应的是%s、%d这些“位置占位”没有命名参数一说顺序错了就是错了不存在按名字匹配的魔法。所以记住Go 的格式化参数顺序非常重要别调换位置否则输出就是张冠李戴。3. 实战场景从结构体到自定义类型的格式化输出3.1 结构体打印%v、%v 和 %#v 的区别实际项目里结构体是逃不掉的数据类型。打个日志、调个接口数据很多时候都是一整个结构体往fmt里扔。这时候三个动词最有使用价值%v、%v、%#v。先看一个例子type User struct { Name string Age int } func main() { u : User{Name: Alice, Age: 30} fmt.Printf(%%v: %v\n, u) fmt.Printf(%%v: %v\n, u) fmt.Printf(%%#v: %#v\n, u) }输出%v: {Alice 30} %v: {Name:Alice Age:30} %#v: main.User{Name:Alice, Age:30}可以看到%v只输出字段值不看字段名%v带上了字段名%#v最详细不仅带字段名还带上了字段类型、字符串的双引号、包名和结构体名。我最推荐的调试组合是%#v因为它能让你快速确定这个结构体的“真实身份”尤其在处理接口类型时%#v能告诉你具体是哪个包下的哪个类型。不过要提醒一句%#v不能保证输出的内容可以直接“复制回去当 Go 代码用”。它只是尽量模拟 Go 语法并不是万能的。3.2 自定义类型的 String() 方法如果说%#v是给调试用的那String()方法就是给业务日志用的。当你给自定义类型实现一个String() string方法后fmt包在遇到%v、%s时就会自动调用它而不是打印结构体字段。这非常适合用来控制日志的可读性。type User struct { Name string Age int } func (u User) String() string { return fmt.Sprintf(%s(%d), u.Name, u.Age) } func main() { u : User{Name: Alice, Age: 30} fmt.Println(u) // Alice(30) fmt.Printf(%s\n, u) // Alice(30) }这个设计我觉得很妙你只要实现一个接口就能让这个类型在所有fmt输出场景里统一表现。Go 把这套约定称为Stringer接口很多标准库类型都实现了它。比如time.Time的String()方法返回的是2024-01-02 15:04:05 0000 UTC这样的格式所以直接打印time.Now()能看到完整时间信息。要注意一旦实现了String()你再用%v打印结构体时看到的就不是大括号包裹的字段了而是你自定义的字符串。想强制看字段用%#v或直接fmt.Printf(%v\n, u)这里有个细节如果类型实现了Stringer%v也会调用String()并不会老老实实按要求显示字段名。所以想要结构体字段名最好使用%#v或者临时把接收者改成指针再输出不过最稳妥的还是%#v。3.3 对齐、填充和宽度控制格式化输出除了能打印还能排表格。我经常把一组订单数据整理成终端表格来看用到的就是宽度控制。核心语法是%占位符: %[标志][宽度][.精度]动词。常见的标志有三个-左对齐默认右对齐。0数字前用 0 填充而不是空格。输出数字的正负号。宽度表示最小字符数如果实际内容不够就用空格或零补齐如果实际内容超过宽度那么宽度设置会被忽略不会截断内容。比如打印一个进度百分比fmt.Printf(进度: %5.1f%%\n, 87.35) // 输出: 进度: 87.4%这里%5.1f表示宽度至少 5 个字符保留 1 位小数因为“87.4”才 4 位所以左边补一个空格后面再%%输出百分号。如果你需要动态指定宽度不写死数字可以用%*d这种语法把宽度作为参数传进去fmt.Printf(%*d\n, 5, 42) // 输出: 42这种写法在生成动态报表时很实用函数内部可以自己计算宽度参数。3.4 错误和 io.Writer 输出error类型在 Go 里也是一种值它本质上是个接口。打印错误时最常见的是%v或%s效果都是输出错误信息字符串。如果某个错误包装了上下文比如用fmt.Errorf(用户 %s 不存在, name)创建的错误那么打印出来就是完整提示。正式项目里建议把错误写入日志或指定输出而不是单纯打到终端。fmt.Fprintf就是干这个的f, _ : os.Create(app.log) defer f.Close() fmt.Fprintf(f, 用户 %s 登录失败: %v\n, name, err)fmt.Fprintf的第一个参数是io.Writer接口文件、标准输出、网络连接都实现了它。这样做的好处是你的日志逻辑和打印位置解耦了想打印到终端就传入os.Stdout想写给日志文件就传入文件对象。4. 时间格式化为什么 Go 总出现 200601024.1 2006 年 1 月 2 日下午 3 点 4 分 5 秒这是所有 Go 入门者都会撞上的“玄学”。在别的语言里格式化时间可能是%Y-%m-%d %H:%M:%S或者yyyy-MM-dd HH:mm:ss但 Go 里你写的是now : time.Now() fmt.Println(now.Format(2006-01-02 15:04:05))这串2006-01-02 15:04:05不是随便写的它是 Go 的参考时间由头到尾依次对应“月、日、时、分、秒、年”。完整写法是Mon Jan 2 15:04:05 MST 2006对应关系如下2006表示年份01表示月份Jan表示缩写月份02表示日期15表示 24 小时制的小时04表示分钟05表示秒MST表示时区如果你想要 12 小时制就把小时写成03或3并加上PM。所以2006-01-02 03:04:05 PM就能输出类似2024-01-02 03:04:05 PM的结果。为什么偏偏用这组数字因为这是 Go 语言的诞生时间。记住一个口诀1 2 3 4 5 6分别对应月 日 时 分 秒 年。虽然 2006 年看起来像“年”但它在月份之前所以完整时间里的顺序不是纯递增千万别硬套“2006年”这个位置去写。4.2 常用时间布局模板既然时间格式化主打 layout 是2006-01-02 15:04:05那实际开发里有一堆变体需求。我把常用模板整理成表格方便你直接抄。需求布局字符串示例输出日期2006-01-022024-01-02完整时间2006-01-02 15:04:052024-01-02 15:04:05带毫秒2006-01-02 15:04:05.0002024-01-02 15:04:05.123带微秒2006-01-02 15:04:05.0000002024-01-02 15:04:05.12345612 小时制2006-01-02 03:04:05 PM2024-01-02 03:04:05 PM紧凑文件名20060102_15040520240102_150405带时区2006-01-02 15:04:05 -07002024-01-02 15:04:05 0800注意Format用的布局字符串是固定格式不是“从某个时间转换出来的模板”。你把2024-01-02 15:04:05当作时间内容把2006-01-02 15:04:05当作布局这两个是完全不同的东西。很多人喜欢用now.Format(2006-01-02 15:04:05)结果发现毫秒没有于是加上.000这些其实都是在套用同一个参考时间的模板规则。Go 也支持用time.Parse把字符串解析成time.Time同样要传布局字符串t, err : time.Parse(2006-01-02 15:04:05, 2024-01-02 15:04:05) if err ! nil { panic(err) } fmt.Println(t.Unix())这里关键点在于Parse和Format都依赖同一个参考时间所以布局字符串必须直接使用那几个神奇数字不能用YYYY-MM-DD之类的占位符。一旦你写YYYY-MM-DDGo 会把每个字母都当作参考时间的组成部分结果打印出来的东西会非常离谱。我最初还犯过一个错误以为2006就是年份所以在解析 “2024-01-02” 时写成2024-01-02当作布局结果当然不对。记住布局字符串永远是2006、01、02、15、04、05这些特殊记号它和你要解析/格式化的“具体时间内容”没有关系。4.3 为什么说这是 Go 的一个“小特色”很多从 Python 转 Go 的人会觉得这种设计很怪但用了几天后就会发现它有个好处布局字符串本身可读性很强一眼能看出是年月日时分秒不像%Y-%m-%d %H:%M:%S那样满屏都是百分号还得去查大写 Y 和小写 m 的区别。Python 的datetime.strftime就是典型的符号猜测游戏%Y是四位年份%y是两位年份%m是月份%M是分钟大小写差一个字母意思就变了。Go 直接用参考时间的数字和名称等于把“约定”写在了明面上。不过这个“特色”也有代价如果你是第一次见会觉得2006是一个具体年份而不是模板符号而且当你需要把字符串解析成时间时布局写错一点就全盘崩溃。所以我的建议是把上面那张常用模板表存起来用到的时候直接复制不要凭记忆手打。等以后写多了自然就记住了。5. 常见输出问题排查与性能避坑5.1 格式化匹配错误和 %! 标记新人最容易碰到的就是这种输出%!d(stringAlice) %!s(int42) %!v(MISSING)看到%!开头说明格式化字符串和参数类型对不上。比如fmt.Printf(%d, hello) // 输出: %!d(stringhello)Go 的设计是类型不匹配时它不会直接 panic而是把这个错误信息拼到输出里保证程序还能继续跑。这种“宽容”是好事也是坏事。好事是程序不容易直接崩坏事是你可能盯着输出看半天才发现原来占位符写错了。所以看到%!就赶紧检查占位符和参数顺序也不要忽略后面(MISSING)这样的提示。还有一种是参数数量不足fmt.Printf(a%d, b%d, 1) // 输出: a1, b%!d(MISSING)第二个%d找不到参数所以直接提示MISSING。解决办法就是数清楚占位符有几个参数就有几个。5.2 Println 和 Printf 混用的坑fmt.Println会自动换行fmt.Printf不会。很多人用了Printf后忘了加\n结果几条日志挤在同一行特别难读。我见过有人写fmt.Printf(name%s, name) fmt.Printf(age%d, age)输出成了nameAliceage30看着像乱码。这时候只需要在每条格式串末尾补一个\n即可。反过来有人习惯用fmt.Println打印变量却传了一个格式化串fmt.Println(name%s, name) // 输出: name%s Alice他不会把%s解析成变量而是把整个字符串和name当作两个普通参数用空格拼起来。这种情况特别容易在“随手把 Printf 改成 Println”的时候发生属于高频踩坑。5.3 浮点数精度和 %v 的坑浮点数用%v打印时Go 会用一个最紧凑的格式表示数值但有时候会丢失小数点后的精度。比如f : 0.1 0.2 fmt.Println(f) // 0.30000000000000004 fmt.Printf(%v\n, f) // 0.30000000000000004 fmt.Printf(%.2f\n, f) // 0.30这不是 Go 的问题而是二进制浮点数的天然缺陷。问题在于调试时你用%v看到一长串数字很容易分心。所以涉及金额、百分比等需要精确展示的场景一定要用%.2f控制精度或者直接用整型存储最小单位避免浮点误差传播。5.4 指针、接口打印与空值很多同学在调试接口类型时会不小心打印出一个空值或地址var p *int fmt.Println(p) // nil var r io.Reader fmt.Printf(%v\n, r) // nil如果你把一个接口变量赋值为一个类型的 nil 指针比如var p *User nil var i interface{} p fmt.Printf(%v\n, i) // nil? 实际上是 nil 吗这里有个微妙的地方接口变量i不是 nil 接口它内部有类型信息*User但值为 nil。直接fmt.Printf(%v\n, i)输出可能是nil或者{ }具体看实现。但你要记住一个概念“值为 nil 的指针赋给接口后接口 ! nil”。打印这类变量时最好用%#v看清楚类型和值fmt.Printf(%#v\n, i) // (*main.User)(nil)这样一眼就能看到指针类型和 nil而不会误以为接口本身是空的。5.5 大量循环打印时的性能问题fmt包很方便但它内部用了大量反射性能比strconv直接转换要慢得多。如果你在循环里打印几百万条日志或者把待发送的报文用fmt.Sprintf反复拼接性能会很难看。尤其是高频的 HTTP 服务里日志接口很容易成为瓶颈。推荐的做法是需要拼接数字和字符串时优先用strconv.Itoa加上strings.Builder。var b strings.Builder b.WriteString(user_) b.WriteString(strconv.Itoa(42)) s : b.String()如果你只是想输出一条带变量的调试信息fmt.Sprintf完全够用没必要为了性能牺牲可读性。但一旦发现程序运行缓慢而且 pprof 里显示fmt占用较高就要考虑替换成更轻量级的字符串拼接方案了。最后说一下并发fmt包提供了并发安全保证多个 goroutine 可以安全调用fmt.Println和fmt.Printf不会数据竞争。但输出顺序无法保证因为多条日志可能交叉显示。需要严格顺序时建议通过log包或自己加锁。6. 我的个人使用习惯供你参考最后分享几个我实际写代码时的习惯尤其适合调试公共代码、写 SDK 或者排查线上日志时用。第一用%#v代替%v来排查接口和结构体。调试时我几乎离不开%#v它能把类型和值一起显示出来哪怕是一个interface{}也能看清底层到底是什么类型。比如%#v输出[]interface {}{1, a}你立刻就知道这变量来自哪里而%v只会输出[1 a]完全丢失类型信息。第二给自定义业务类型实现String()方法。比如订单号、金额、用户信息等只要实现了String()日志里输出就统一了排查问题不用猜每个字段的含义。但要记住String()方法会对%v生效如果你还想看原始字段请用%#v。第三善用fmt.Fprintf把调试信息写到文件里而不是所有内容都打到标准输出。比如你在排查一个复杂函数想留一份完整的过程记录直接重定向终端的输出很容易弄丢不如在代码里加一个文件输出保留结构化日志。最后一个建议把2006-01-02 15:04:05这串时间模板背下来。它不但是 Go 时间格式化的核心还是很多人初次接触 Go 时的第一个“劝退点”但一旦理解它反而是最好记的。拿我自己的经验来说以前用 Python 每次写时间格式都要翻文档换成 Go 之后只要记住2006是年、01是月、02是日、15是时、04是分、05是秒就再也没查过布局表。希望这篇教程也能帮你绕开我当年踩过的那些格式化输出的坑少走一些弯路。