16 标准库:编码与日志

Go 标准库速查:encoding/json 全表与 tag 语义、自定义 Marshaler、json.RawMessage、base64 与压缩、log 与 log/slog 结构化日志

16 标准库:编码与日志

本页回答:JSON 怎么编解码才不出错、struct tag 每个选项什么意思、日志该怎么打。

基线:Go 1.27.1。所有输出为本机实跑结果。 ⚠️ Go 1.27 起 encoding/json 底层已换成 v2 实现(行为保持兼容,错误文本可能不同),同时提供显式的 encoding/json/v2 与 encoding/json/jsontext 包。


encoding/json:编解码全景

flowchart TB
    subgraph MAR["Marshal(Go → JSON)"]
        M1["json.Marshal(v) → []byte"]
        M2["json.MarshalIndent(v, prefix, indent)"]
        M3["json.NewEncoder(w).Encode(v)<br/>流式写,自带换行"]
    end
    subgraph UN["Unmarshal(JSON → Go)"]
        U1["json.Unmarshal(b, &v)"]
        U2["json.NewDecoder(r).Decode(&v)<br/>流式读,适合 HTTP body"]
        U3["dec.More() / dec.Token()<br/>流式多值"]
    end
    subgraph HOOK["自定义钩子"]
        H1["MarshalJSON / UnmarshalJSON"]
        H2["MarshalText / UnmarshalText"]
        H3["json.RawMessage<br/>延迟解析"]
    end
    MAR --> HOOK
    UN --> HOOK
    NOTE["⚠️ 只有【导出字段】参与编解码"] --- MAR

基本用法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
type User struct {
	Name    string    `json:"name"`
	Age     int       `json:"age,omitempty"`
	Email   string    `json:"email,omitempty"`
	Created time.Time `json:"created,omitzero"`
	secret  string    // ⚠️ 未导出 → 完全忽略
	Tags    []string  `json:"tags,omitempty"`
	Score   *int      `json:"score,omitempty"`
}

b, _ := json.Marshal(User{Name: "a"})
// {"name":"a"}

json.Unmarshal([]byte(`{"name":"d","age":30}`), &got)
1
2
omitempty zero: {"name":"a"}
unmarshal: d 30 true <nil>

struct tag 选项全表

选项作用示例
json:"name"改字段名json:"user_name"
json:"-"完全忽略该字段json:"-"
json:"-,"名字就是 -少见
json:",omitempty"零值时省略(有"空"概念的才算)见下表
json:",omitzero" 🆕 1.24零值时省略(用 IsZero() 或零值判断)推荐用于 time.Time 🔥
json:",string"数值编码成 JSON 字符串{"n":"42"}
json:",inline" 🚧v2 的嵌入选项(v1 靠匿名字段)v2 特性

omitempty 到底省略什么

这是最容易误判的地方:

类型omitempty 视为「空」的值会省略吗
string""✅
整数/浮点0✅
boolfalse✅
指针nil✅
切片/映射len == 0(含 nil)✅
接口nil✅
结构体永远不空❌ 总是输出 ⚠️
time.Time永远不空❌ 连零值也输出 ⚠️
数组长度固定,不空❌
1
2
3
// ⚠️ 这是经典坑:零值 time.Time 依然被输出
u2 := User{Name: "b", Created: time.Time{}}
json.Marshal(u2)     // {"name":"b","created":"0001-01-01T00:00:00Z"}
1
2
omitzero time: {"name":"b"}
with time: {"name":"c","created":"2026-09-20T00:00:00Z"}

🆕 1.24 起用 omitzero 解决这个问题:它对 time.Time 这类零值有意义的结构体也生效(优先用类型的 IsZero() bool 方法)。

需求用哪个
省略空字符串、0、nilomitempty
省略 time.Time 零值、结构体零值omitzero 🆕 1.24 🔥
两者都要同时写 omitempty,omitzero(任一满足即省略)

输出格式与流式 API

1
b, _ := json.MarshalIndent(v, "", "  ")     // 人类可读
1
2
3
4
{
  "name": "c",
  "created": "2026-09-20T00:00:00Z"
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// Encoder:写 HTTP 响应推荐用法
w.Header().Set("Content-Type", "application/json")
enc := json.NewEncoder(w)
enc.SetEscapeHTML(false)      // 不转义 < > &
enc.SetIndent("", "  ")       // 开发环境可读
enc.Encode(resp)              // ⚠️ Encode 会自动加换行

// Decoder:读 HTTP body 推荐用法(流式,不必先 ReadAll)
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()   // 严格模式:遇到未知字段报错 🔥
if err := dec.Decode(&req); err != nil {
	http.Error(w, "bad request", http.StatusBadRequest)
	return
}
选择场景
Marshal / Unmarshal数据已在内存里(配置、缓存)
Encoder / DecoderHTTP 请求响应、文件、流式数据 🔥
DisallowUnknownFields对外 API 想尽早发现客户端拼错字段名

⚠️ json.NewEncoder(w).Encode() 会在末尾追加换行符,做字节级比对时要注意。

数值精度:默认走 float64

1
2
3
var raw struct{ Big int64 }
json.Unmarshal([]byte(`{"Big":9007199254740993}`), &raw)
fmt.Println(raw.Big)     // 9007199254740993 —— 目标是 int64,精确解析 ✅

但如果目标是 map[string]any 或 any,数字会变成 float64:

1
2
3
var m map[string]any
json.Unmarshal([]byte(`{"n":9007199254740993}`), &m)
m["n"].(float64)     // 9.007199254740992e+15 ⚠️ 精度已丢
场景建议
已知结构定义 struct 用 int64 ✅
未知结构 + 大整数dec.UseNumber(),值变成 json.Number(字符串保留)🔥
金额绝不用 float;用整数分或字符串
1
2
dec := json.NewDecoder(r)
dec.UseNumber()      // 🔥 避免 float64 精度陷阱

自定义编解码:四种钩子

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
type Color int

const (
	Red Color = iota
	Green
	Blue
)

// ① MarshalJSON:完全控制输出
func (c Color) MarshalJSON() ([]byte, error) {
	return json.Marshal([...]string{"red", "green", "blue"}[c])
}

// ② UnmarshalJSON:完全控制输入
func (c *Color) UnmarshalJSON(b []byte) error {
	var s string
	if err := json.Unmarshal(b, &s); err != nil {
		return err
	}
	switch s {
	case "red":
		*c = Red
	case "green":
		*c = Green
	case "blue":
		*c = Blue
	default:
		return fmt.Errorf("unknown color %q", s)
	}
	return nil
}
1
2
custom marshal: {"C":"blue"}
custom unmarshal: 1
钩子优先级说明
json.Marshaler / Unmarshaler最高JSON 专属,完全控制字节
encoding.TextMarshaler / TextUnmarshaler次之转成 JSON 字符串;map 键必须实现它
默认可编码类型最低结构体、切片、映射、基本类型
1
2
3
4
5
6
// ③ TextMarshaler:比 MarshalJSON 更通用(YAML/XML 也能用)
type Upper string

func (u Upper) MarshalText() ([]byte, error) { return []byte(strings.ToUpper(string(u))), nil }

json.Marshal(WithText{U: "abc"})     // {"u":"ABC"}
1
textmarshaler: {"u":"ABC"}

⚠️ 实现 UnmarshalJSON 时必须用指针接收者(func (c *Color) UnmarshalJSON),否则不会生效——这是最常见的「我的自定义反序列化没被调用」原因。

json.RawMessage:延迟解析

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
type Event struct {
	Type string          `json:"type"`
	Data json.RawMessage `json:"data"`   // 先原样收下,之后再解析
}

var e Event
json.Unmarshal([]byte(`{"type":"click","data":{"x":1}}`), &e)
// e.Data 是 []byte(`{"x":1}`)

var payload struct{ X int }
json.Unmarshal(e.Data, &payload)     // 按 Type 决定解析成什么
1
2
raw: click {"x":1}
decoded payload: 1

💡 典型用途:多态事件/消息(先读类型字段再解析具体结构)、转发(不解内容直接透传)、部分解析。

常见错误与类型

错误类型触发场景怎么判
*json.SyntaxErrorJSON 语法错误errors.As
*json.UnmarshalTypeError类型不匹配errors.As,含 Field/Value/Type/Offset 🔥
*json.InvalidUnmarshalError目标不是指针errors.As
io.ErrUnexpectedEOF / io.EOF数据被截断errors.Is
1
2
type error: json: cannot unmarshal string into Go value of type int
  Value: string Type: int Offset: 5

⚠️ 忘记给 Unmarshal 传指针会得到 json: Unmarshal(non-pointer T)——这是新手最常见的一类错误。


编解码选型:先做三个判断

flowchart TB
    Q(["要把数据变成字节 / 从字节还原"]) --> Q1{"是 Go 程序之间通信吗?"}
    Q1 -- 是 --> Q2{"需要人可读 / 跨语言?"}
    Q2 -- 是 --> JSON["encoding/json ✅"]
    Q2 -- 否 --> Q3{"追求极致性能与体积?"}
    Q3 -- 是 --> BIN["gob / protobuf / 自定义二进制 🚧"]
    Q3 -- 否 --> JSON
    Q1 -- "否(跨语言/对外 API)" --> Q4{"对端要什么格式?"}
    Q4 -- "JSON" --> JSON
    Q4 -- "XML" --> XML["encoding/xml"]
    Q4 -- "表格/CSV" --> CSV["encoding/csv"]
    Q4 -- "二进制" --> BIN

    JSON --> TAG["关键是 struct tag 与<br/>omitempty/omitzero 语义 ⚠️"]
优点缺点
跨语言、人可读、生态完备体积大、解析慢
标准库零依赖 🔥无 schema,类型全靠约定
流式 Encoder/Decoder大整数要小心 float64

最常踩的三个坑:omitempty 对结构体无效、未导出字段被忽略、忘记传指针。

1
2
3
4
var buf bytes.Buffer
gob.NewEncoder(&buf).Encode(myStruct)
var out MyStruct
gob.NewDecoder(&buf).Decode(&out)
优点缺点
快、体积小、支持类型信息只有 Go 能读 ⚠️
零配置不适合对外接口、不适合长期存储

💭 适合:进程间通信、缓存、RPC(内部)。

格式包关键点
CSVencoding/csvWriter.Flush() 必须调 ⚠️;FieldsPerRecord=-1 允许变长
XMLencoding/xmltag 支持 ,attr/,chardata/,innerxml
YAML无标准库 🚧用 gopkg.in/yaml.v3
TOML无标准库 🚧用 BurntSushi/toml
1
2
3
4
5
6
7
// 写:必须 Close 才会输出完整数据 ⚠️
zw := gzip.NewWriter(w)
defer zw.Close()

// 读
zr, err := gzip.NewReader(r)
defer zr.Close()
格式何时用
gzipHTTP Content-Encoding: gzip 🔥
zlib协议规定用 zlib 时
flate不需要头部开销、自管封装
zstd标准库没有,用 klauspost/compress 🚧

其他编码格式

base64 / hex

1
2
3
base64.StdEncoding.EncodeToString([]byte("hello"))   // "aGVsbG8="
base64.StdEncoding.DecodeString("aGVsbG8=")
hex.EncodeToString([]byte("hello"))                  // "68656c6c6f"
1
2
3
aGVsbG8=
68656c6c6f
hello
变体用途
base64.StdEncoding标准(含 +/=)
base64.RawStdEncoding去 padding(JWT 用这个)
base64.URLEncodingURL 安全(-_)
base64.RawURLEncodingURL 安全 + 无 padding(JWT 场景)🔥

⚠️ 选错变体是「token 解码失败」的常见原因:JWT 用 RawURLEncoding。

压缩:记住必须 Close

1
2
3
4
5
6
7
8
9
var gz bytes.Buffer
zw := gzip.NewWriter(&gz)
zw.Write([]byte("compress me"))
fmt.Println("after Write:", gz.Len())    // 10,数据还在缓冲区 ⚠️
zw.Close()                                // 🔥 必须 Close 才写出全部数据
fmt.Println("after Close:", gz.Len())    // 36

zr, _ := gzip.NewReader(&gz)
plain, _ := io.ReadAll(zr)
1
2
3
after Write: 10
after Close: 36
roundtrip: compress me

⚠️ 压缩 Writer 不 Close 就丢数据,这是仅次于 bufio 忘 Flush 的高频事故。Close 会写入压缩尾部与页脚。

格式包
gzipcompress/gzip(HTTP Content-Encoding: gzip)
zlibcompress/zlib
flate(裸 DEFLATE)compress/flate
zstd标准库没有,用 klauspost/compress/zstd 🚧
zip / tararchive/zip / archive/tar

CSV / XML

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// encoding/csv
r := csv.NewReader(strings.NewReader("a,b\n1,2\n"))
records, _ := r.ReadAll()          // [][]string
r.FieldsPerRecord = -1             // 允许变长行
r.LazyQuotes = true                // 容忍不规范引号

w := csv.NewWriter(os.Stdout)
w.Write([]string{"a", "b"})
w.Flush()                          // ⚠️ 必须 Flush

// encoding/xml
type Person struct {
	XMLName xml.Name `xml:"person"`
	Name    string   `xml:"name"`
	Age     int      `xml:"age,attr"`
}
tag 选项(xml)含义
xml:"name"元素名
xml:",attr"作为属性
xml:",chardata"作为文本内容
xml:",innerxml"原始内部 XML
xml:"a>b>c"嵌套路径

log:标准库日志

1
2
3
4
5
6
7
8
9
log.Println("simple")
log.Printf("formatted %d", 42)
log.Fatalf("fatal: %v", err)       // 打印后 os.Exit(1),⚠️ defer 不会执行! 
log.Panicf("panic: %v", err)       // 打印后 panic

// 配置
log.SetPrefix("[app] ")
log.SetFlags(log.LstdFlags | log.Lshortfile)   // 日期时间 + 文件名:行号
log.SetOutput(io.MultiWriter(os.Stdout, f))    // 同时写多处
flag输出
log.Ldate2026/09/20
log.Ltime15:04:05
log.Lmicroseconds微秒
log.LUTC用 UTC 而非本地时间
log.Lshortfilemain.go:42
log.Llongfile完整路径
log.LstdFlagsLdate | Ltime(默认)
log.Lmsgprefix前缀放在消息前而非行首

⚠️ log.Fatal* 会调用 os.Exit(1),所有 defer 都不会执行——不要在库代码里用它,也不要在持有资源(文件、锁、连接)时用。

💭 新项目直接用 log/slog:log 的顶层函数会转发到默认 slog logger,本机实测如下——把默认 slog 换成 JSON handler 后,log.Println 的输出也变成了 JSON:

1
2
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil)))
log.Println("via log package")     // 输出 JSON!
1
2
{"time":"2026-09-20T20:07:47.931227+08:00","level":"INFO","msg":"via log package"}
{"time":"2026-09-20T20:07:47.931239+08:00","level":"INFO","msg":"via slog"}

⚠️ 这条链路是双向影响的:slog.SetDefault 会改变老 log 代码的输出格式,反过来 log.SetOutput 也会影响默认 slog 的落盘位置(实测两者都会写入同一个 io.Writer)。混用两套日志 API 时务必注意。


log/slog:结构化日志 🆕 1.21

flowchart LR
    subgraph API["调用层"]
        A1["slog.Info / Debug / Warn / Error"]
        A2["slog.LogAttrs(ctx, level, msg, attrs...)<br/>零分配,性能最好 🔥"]
        A3["logger.With(...) / WithGroup(...)"]
    end
    subgraph HANDLER["Handler 层(决定格式)"]
        H1["NewTextHandler<br/>logfmt:time=... level=... msg=..."]
        H2["NewJSONHandler<br/>结构化,生产首选 🔥"]
        H3["NewMultiHandler 🆕 1.26<br/>同时发往多个 handler"]
        H4["DiscardHandler 🆕 1.24"]
        H5["自定义 Handler<br/>脱敏、采样、染色"]
    end
    subgraph OUT["输出"]
        O1["io.Writer:文件 / stdout / 网络"]
    end
    API --> HANDLER --> OUT

三种 handler 的实测输出

1
2
3
4
5
6
7
var buf bytes.Buffer
l := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))

l.Info("user login", "user", "alice", "ip", "1.2.3.4")
l.Debug("not shown")                        // 低于 Level,被丢弃
l.Error("failed", "err", os.ErrNotExist)
l.With("service", "api").WithGroup("req").Info("handled", "status", 200, "ms", 12)
1
2
3
{"time":"2026-09-20T20:07:15.462385+08:00","level":"INFO","msg":"user login","user":"alice","ip":"1.2.3.4"}
{"time":"2026-09-20T20:07:15.46276+08:00","level":"ERROR","msg":"failed","err":"file does not exist"}
{"time":"2026-09-20T20:07:15.462775+08:00","level":"INFO","msg":"handled","service":"api","req":{"status":200,"ms":12}}
1
slog.New(slog.NewTextHandler(&tb, &slog.HandlerOptions{Level: slog.LevelDebug})).Debug("debug msg", "k", "v")
1
time=2026-09-20T20:07:15.462+08:00 level=DEBUG msg="debug msg" k=v
选择场景
NewTextHandler本地开发、人眼阅读
NewJSONHandler生产环境,被 ELK/Loki/CloudWatch 采集 🔥
NewMultiHandler 🆕 1.26同时输出到控制台和文件、或同时做脱敏审计
slog.DiscardHandler 🆕 1.24测试时静音(⚠️ 它是变量,直接传 slog.DiscardHandler,不要加括号)

类型化 Attr:性能与正确性

1
2
3
4
5
6
7
8
l.Info("attrs",
	slog.String("s", "x"),
	slog.Int("i", 1),
	slog.Bool("b", true),
	slog.Duration("d", 0),
	slog.Time("t", time.Now()),
	slog.Any("v", complexObj),      // ⚠️ 有反射开销
)
1
{"time":"...","level":"INFO","msg":"attrs","s":"x","i":1,"b":true,"d":0}
写法开销建议
l.Info("m", "k", v)键值对,会装箱方便,非热点可接受
slog.String("k", v) 等类型化,较少分配热点路径推荐
slog.LogAttrs(ctx, level, msg, attrs...)零分配 🔥高频日志
slog.Any反射只在必要时用

分组、上下文与全局

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// 派生 logger:带上公共字段,避免重复
reqLog := base.With("request_id", id, "user_id", uid)
reqLog.Info("start")
reqLog.Info("done", "ms", 12)

// 分组:字段嵌套
l.WithGroup("db").Info("query", "sql", "SELECT 1", "ms", 3)
// {"msg":"query","db":{"sql":"SELECT 1","ms":3}}

// 设置 / 读取全局默认 logger
slog.SetDefault(l)                     // 之后 log 包也会转发到这个 handler
slog.Info("via default")               // 用默认 logger
logger := slog.Default()               // 取回默认 logger

⚠️ slog.SetDefault 影响 log 包的顶层函数输出格式(因为 log 转发到默认 slog)——有时这会「意外」改变老代码的日志格式。

自定义 Handler:脱敏

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
type redactHandler struct{ slog.Handler }

func (h redactHandler) Handle(ctx context.Context, r slog.Record) error {
	r2 := slog.NewRecord(r.Time, r.Level, r.Message, r.PC)
	r.Attrs(func(a slog.Attr) bool {
		if a.Key == "password" || a.Key == "token" {
			a.Value = slog.StringValue("[REDACTED]")
		}
		r2.AddAttrs(a)
		return true
	})
	return h.Handler.Handle(ctx, r2)
}

// ⚠️ 包装型 Handler 必须同时重写这两个方法,否则 logger.With(...) 之后脱敏会失效!
func (h redactHandler) WithAttrs(as []slog.Attr) slog.Handler {
	return redactHandler{h.Handler.WithAttrs(as)}
}

func (h redactHandler) WithGroup(name string) slog.Handler {
	return redactHandler{h.Handler.WithGroup(name)}
}

🛑 这是自定义 Handler 最容易踩的坑:如果只重写 Handle,那么

1
l.With("service", "api").Info("login", "password", "s3cr3t")

会绕过你的脱敏逻辑——因为内嵌的 slog.Handler 的 WithAttrs 返回的是未被包装的内层 handler,之后的日志直接打到内层去了。本机实测:不重写时上面的输出里 password 是明文 s3cr3t;补上这两个方法后正确显示 [REDACTED]。

💡 这是 slog 设计得最好的地方:格式由 Handler 决定,业务代码只管打日志,运行期换 handler 就能切换格式、级别、脱敏策略。但要记住:任何「包装型 Handler」都要把 WithAttrs/WithGroup 一起包装,否则派生 logger 会逃出你的处理链。

日志实践清单

建议说明
用结构化字段而非拼接字符串便于检索与聚合 🔥
键名统一(全小写下划线)request_id 而非 requestID
只在边界打错误日志见 09 错误处理
别在热循环里打日志即使被 Level 过滤也有函数调用与参数求值开销
敏感信息脱敏用自定义 Handler 统一处理
生产级别设 InfoDebug 留到排查时动态开
带上下文LogAttrs(ctx, ...),配合 trace 集成

⚠️ 即使日志级别被过滤,参数表达式仍然会被求值:

1
2
3
4
5
6
7
// 🛑 即使 Debug 被禁用,expensiveString() 也会执行
slog.Debug("debug", "payload", expensiveString())

// ✅ 先判级别(或用 LogAttrs + 惰性 Value)
if slog.Default().Enabled(ctx, slog.LevelDebug) {
	slog.Debug("debug", "payload", expensiveString())
}

🆕 1.24 起可用 slog.DiscardHandler 变量在测试中静音(slog.New(slog.DiscardHandler)),配合 NewMultiHandler 就能「安静地同时验证日志内容」。


本页陷阱速查

症状实际原因正确做法
JSON 里字段不见了字段未导出(小写开头)首字母大写
零值 time.Time 也被输出omitempty 对结构体无效用 omitzero 🆕 1.24
age:0 被省略但你想保留omitempty 把 0 当空去掉 omitempty,或用 *int
自定义 UnmarshalJSON 不生效用了值接收者改成指针接收者 func (c *T)
Unmarshal(non-pointer T)传了值不是指针传 &v
大整数精度丢失any/map[string]any 里是 float64定义 struct 用 int64,或 dec.UseNumber()
未知字段被静默丢弃默认宽松模式dec.DisallowUnknownFields()
响应体末尾多了个换行Encoder.Encode 会加 \n用 Marshal 或接受它
JWT/token 解码失败base64 变体选错JWT 用 RawURLEncoding
解压后数据不完整压缩 Writer 没 Closedefer zw.Close()
CSV 少了几行Writer 没 Flushdefer w.Flush()
log.Fatal 后 defer 没执行它调用 os.Exit库代码返回 error;用 return 交给 main
日志格式突然变了有人调了 slog.SetDefault显式传入 logger,别依赖全局
Debug 日志有性能损耗参数被求值先 Enabled(ctx, level) 判断
JSON tag 写错静默失效tag 语法不合法go vet 能查出结构体 tag 问题

📘 官方参考:encoding/json、Go 1.24 — omitzero、Go 1.27 — encoding/json/v2、log/slog、Go Blog — Structured Logging with slog

➡️ 上一节:15 标准库:io·fs·time | 下一节:17 测试

最后修改 September 21, 2026: 更新 (ac821931b)