Go 博客教程 · 第 1 课 / 10
第 1 课:Go 语法地基与项目骨架
你已经会用 PHP / Python / JS 写过东西了,所以这一课不教「什么是变量」,只教 Go 跟它们不一样的那些地方——那些让你第一天写 Go 时反复 go build 报错的地方。
1. 本课目标
学完这一课,你能独立做到:
- 从零建出
~/go-blog,说清go.mod每一行、go build/go run/go vet各干什么; - 解释「首字母大写 = 导出」这条规则,并在写
model.Post时正确决定哪个字段大写; - 说出任意 Go 类型的零值,并解释为什么 Go 没有
null/undefined(这直接决定第 3 课sql.NullString为什么必须存在); - 用
(T, error)惯例写函数,用errors.Is/errors.As判断错误,用%w包装错误链; - 判断方法该用值接收者还是指针接收者,并躲开
defer的两个经典坑; - 跑起一个 20 行的 HTTP 服务,
curl localhost:8080看到输出。
2. 前置检查
go version
预期输出(版本号必须 ≥ 1.22,本教程按 1.25 写,路由语法依赖 1.22+):
go version go1.25.0 darwin/arm64
如果报 command not found,先装 Go(brew install go)再往下读。
3. 核心概念
3.1 go mod init blog 到底做了什么
mkdir -p ~/go-blog && cd ~/go-blog
go mod init blog
它只做一件事:在当前目录生成一个 go.mod。
module blog
go 1.25.0
逐行解释:
| 行 | 含义 | 你会在什么时候感受到它 |
|---|---|---|
module blog |
声明模块路径。它是所有 import 路径的前缀 | 你写 import "blog/internal/model" 时,blog 就是这里来的 |
go 1.25.0 |
声明这份代码要求的语言版本。编译器据此决定开启哪些语言特性 | 写成 go 1.21 的话,第 2 课的 mux.HandleFunc("GET /posts/{id}") 会退化成把整个字符串当路径,路由静默失效 |
和别的语言对比:go.mod ≈ package.json / composer.json,但它不列依赖版本以外的任何东西——没有 scripts、没有构建配置、没有入口声明。Go 的构建是约定式的。
第 3 课装 MySQL 驱动时,go get 会往 go.mod 加 require 段,同时生成 go.sum(依赖内容的校验和,要提交进 git,防的是「同一个版本号被人换掉内容」)。
3.2 go build / go run / go vet 的区别
| 命令 | 做什么 | 产物 | 什么时候用 |
|---|---|---|---|
go build ./cmd/server |
编译成可执行文件 | 当前目录下一个二进制 server |
要部署、要看编译错误 |
go run ./cmd/server |
编译到临时目录 + 立刻执行 | 无(跑完就删) | 本地开发,改一行跑一次 |
go vet ./... |
静态检查,找编译器不管但几乎必错的写法 | 无,只打印警告 | 提交前跑一遍 |
go vet 抓的是这类东西:fmt.Printf("%d", "abc")(格式串和参数类型对不上)、sync.Mutex 被复制、结构体 tag 写错。编译能过 ≠ 代码对,go vet 是零成本的第二道关。
还有一个你每天都会用到的:gofmt -l . 列出格式不标准的文件,gofmt -w . 直接改写。Go 没有「代码风格之争」——gofmt 的输出就是唯一正确格式,缩进、大括号位置、结构体字段对齐全由它决定。养成保存即 gofmt 的习惯。
3.3 包与可见性:首字母大写 = 导出
这是 Go 最反直觉的一条规则,也是唯一一条访问控制规则:
标识符首字母大写 → 包外可见(导出);首字母小写 → 只有同一个包内能访问。
没有 public / private / protected 关键字。作用于一切:变量、常量、函数、类型、结构体字段、方法。
// internal/model/post.go
package model
type Post struct {
ID int64 // 大写 → 别的包能读能写
Title string // 大写 → 别的包能读能写
draft bool // 小写 → 只有 model 包内部能碰
}
func NewPost() *Post { return &Post{} } // 大写 → handler 包可以调
func normalize() {} // 小写 → 只有 model 包内部能调
在别的包里 p.draft = true 会直接编译报错:p.draft undefined (cannot refer to unexported field or method draft)。
为什么必须现在讲透:这条规则在第 2 课会以一种很隐蔽的方式坑你——encoding/json 通过反射读字段,它看不见未导出字段。把 Title 写成 title,序列化出来永远是 {},不报错、不警告,就是空的。第 2 课会实测给你看。
包名的额外约定:一个目录 = 一个包,包名通常和目录名相同;调用永远带包名前缀(model.NewPost()),Go 没有 from x import *;未使用的 import 是编译错误,不是警告。
3.4 package main + func main() 的特殊性
// cmd/server/main.go
package main
func main() {}
package main是唯一会被编译成可执行文件的包名,其他包名都编译成库。func main()是入口,必须无参数、无返回值。一个模块里可以有多个package main(cmd/server/、cmd/migrate/……),各自编译成各自的二进制——这就是cmd/目录的意义。
易错点:main 里 return 只是提前退出,进程退出码是 0。想报错退出必须 os.Exit(1) 或 log.Fatal(...)(后者打印日志再 os.Exit(1))。
3.5 变量与零值:Go 没有 null / undefined
声明的两种写法:
| 写法 | 能用在哪 | 什么时候用它 |
|---|---|---|
var x T |
包级 + 函数内 | ① 只想要零值 ② 要显式写出类型 ③ 包级变量(:= 在包级不能用) |
x := 值 |
只能在函数内 | 绝大多数情况,尤其是接收函数返回值 |
// scratch/main.go(临时试验用,不属于博客项目)
var count int // 想要 0,用 var 更清楚
var buf strings.Builder // 零值就能直接用的类型,用 var
title := "Hello Go" // 有初值,用 :=
p, err := model.NewPost(title, "正文") // 接函数返回值,用 :=
零值(zero value)是 Go 的地基。变量声明后一定有值,绝不会是「未定义」:
| 类型 | 零值 |
|---|---|
int / int64 / float64 |
0 |
string |
""(空字符串,不是 nil) |
bool |
false |
指针 *T |
nil |
slice []T |
nil(但 len() 是 0,可以直接 append) |
map map[K]V |
nil(可以读,写会 panic) |
error(接口) |
nil |
| struct | 每个字段各自的零值 |
实测 var s string; var sl []string; var m map[string]int 打出来是 string="" slice=[](nil=true) map=map[](nil=true)。
为什么这件事对博客系统至关重要:看 schema.sql 里的 summary VARCHAR(500) NULL。数据库里它有三种状态:NULL、''、'有内容'。但 Go 的 string 只有两种:"" 和 '有内容'。Go 的 string 表达不了 NULL——因为它没有 null,零值就是 ""。
这就是第 3 课 sql.NullString 存在的全部理由:
// internal/model/post.go(第 3 课才会写成这样,现在先理解)
type Post struct {
Summary sql.NullString // {String: "", Valid: false} 才是 NULL
}
Valid: false = 数据库里是 NULL,Valid: true, String: "" = 数据库里是空字符串。多出来的这个 bool,就是 Go 为「没有 null」付出的代价。同理,第 2 课要区分「JSON 里没传 summary」和「传了 summary: ""」时,答案是用 *string——nil 表示「没传」。
3.6 函数:多返回值与 (T, error) 惯例
// scratch/main.go(临时试验用,不属于博客项目)
// 多返回值:不需要包装成对象,也不需要 out 参数
func divide(a, b int) (int, error) {
if b == 0 {
return 0, errors.New("除数不能为 0")
}
return a / b, nil
}
// 命名返回值:签名自带文档,但函数体长了以后容易糊涂
func parsePage(raw string) (page int, err error) {
if raw == "" {
return 1, nil // 仍然显式写 return 值,别用裸 return
}
return strconv.Atoi(raw)
}
四条规则:① error 永远是最后一个返回值,全生态铁律,标准库无一例外;② 出错时第一个返回值给零值(0 / "" / nil),别返回半成品;③ 命名返回值只适合 2 行的小函数和「defer 里改返回值」的场景,函数超过 20 行就别用;④ 不需要的返回值用 _ 丢掉,但不要用 _ 丢掉 error,那是在埋雷。
3.7 slice 与 map 的最小必要知识
slice 是「指向底层数组的视图」,这一句话解释了它所有的坑。
// scratch/main.go(临时试验用,不属于博客项目)
base := make([]int, 3, 5) // len=3, cap=5
base[0], base[1], base[2] = 1, 2, 3
a := append(base[:2], 99) // base[:2] 的 cap 还是 5,够用 → 原地写第 3 格
fmt.Println(base, a) // 实测输出:[1 2 99] [1 2 99]
base2 := []int{1, 2, 3}
c := append(base2[:2:2], 99) // 三索引切片锁死 cap=2 → 容量不够 → 必定复制
fmt.Println(base2, c) // 实测输出:[1 2 3] [1 2 99]
错误现象:第一段里 base[2] 被 append 悄悄改成了 99。根因:append 只有容量不够时才分配新数组;容量够就原地写,会影响所有共享该底层数组的 slice。正确写法:用三索引切片 s[:2:2] 把 cap 锁死,或 slices.Clone() 复制一份。日常再记一条:预知长度就 make([]T, 0, n) 预分配。
map 的双返回值(comma ok idiom):
// scratch/main.go(临时试验用,不属于博客项目)
scores := map[string]int{"go": 0}
v1, ok1 := scores["go"] // 0, true —— 键存在,值就是 0
v2, ok2 := scores["rust"] // 0, false —— 键不存在,返回零值
var m map[string]int
fmt.Println(m["x"]) // 0,读 nil map 是安全的
m["boom"] = 1 // panic: assignment to entry in nil map
map 取不存在的键不 panic、不报错,返回值类型的零值——所以两次取值都是 0,只有 ok 能区分。这个模式在类型断言、channel 接收里会反复见到。
nil map 的坑:根因是 var m map[string]int 只声明了一个 nil 指针,没分配哈希表。正确写法:m := make(map[string]int) 或 m := map[string]int{}。
3.8 struct 与方法:值接收者 vs 指针接收者
Go 没有 class。「方法」就是一个在函数名前面多了个接收者的普通函数:
// internal/model/post.go
func (p *Post) Slugify() string { ... }
// ^^^^^^^^ 接收者,p 相当于其他语言的 this / self
接收者可以是值 (p Post) 或指针 (p *Post),区别是值接收者拿到的是一份拷贝:
// scratch/main.go(临时试验用,不属于博客项目)
type Counter struct{ N int }
func (c Counter) IncValue() { c.N++ } // 改的是副本,外面看不到
func (c *Counter) IncPointer() { c.N++ } // 改的是原件
c := Counter{}
c.IncValue() // 实测:c.N 还是 0 ← 没变!
c.IncPointer() // 实测:c.N 变成 1
注意 c.IncPointer() 里 c 是值不是指针,却能直接调指针方法——Go 自动改写成 (&c).IncPointer()。这个语法糖只对可寻址的值生效,map 元素、函数返回值不可寻址,直接调指针方法会编译错误。
什么时候用哪个——三条判断规则,按顺序问:
| 顺序 | 问题 | 答案 |
|---|---|---|
| 1 | 方法要修改接收者吗? | 要 → 指针接收者,没有例外 |
| 2 | 结构体大吗(超过几个字段 / 含 slice、map)? | 大 → 指针接收者,避免每次调用都拷贝 |
| 3 | 该类型的其他方法用了指针接收者吗? | 用了 → 全部统一用指针,混用会让方法集变得难推理 |
实践中的结论:博客系统里的 *Post、*PostRepository、*PostService 全部用指针接收者。只有 IsPublished() 这种纯查询、且你想强调「我不会改任何东西」的小方法,才值得用值接收者。
方法集的坑(第 5 课中间件会踩到):type Speaker interface{ Speak() string },方法定义成 func (d *Dog) Speak() string(指针接收者)时,var s Speaker = &Dog{} 可以,var s Speaker = Dog{} 编译报错 Dog does not implement Speaker (method Speak has pointer receiver)。
记住:方法用指针接收者定义 → 只有 *T 满足接口,T 不满足。
struct tag 先埋个伏笔:写成 Title string `json:"title"` 时,反引号包住的 json:"title" 叫 struct tag,是附在字段上的元数据字符串。编译器完全不管它,只有用反射读它的库(encoding/json、GORM)才在乎。第 2 课讲 JSON 时全面展开。
易错点:tag 必须用反引号,不能用双引号。写错了 go vet 会报 struct field tag not compatible with reflect.StructTag.Get——这就是让你养成提交前跑 go vet 习惯的理由之一。
3.9 defer:执行时机与两个坑
defer 把一个函数调用推迟到当前函数返回前执行,无论是正常 return 还是 panic。
// internal/repository/post_repo.go(第 3 课会真这么写)
rows, err := db.Query("SELECT id, title FROM posts")
if err != nil {
return nil, err
}
defer rows.Close() // 紧跟在错误检查后面,下面无论从哪个分支 return 都会关
惯用位置:拿到资源 → 检查 error → 立刻 defer 释放。三行连在一起,读者一眼看出资源不会泄漏。主要用途:rows.Close() / file.Close() / mu.Unlock() / tx.Rollback()。
坑 1:参数在 defer 那一行就求值了
// scratch/main.go(临时试验用,不属于博客项目)
x := 1
defer fmt.Println("defer 打印的 x =", x) // 此刻求值 → 记下 1
x = 2
defer func() { fmt.Println("闭包 x =", x) }() // 闭包,执行时才读 → 2
实测输出(defer 后进先出):闭包 x = 2 然后 defer 打印的 x = 1。
根因:defer f(a) 里的 a 在执行到这行时就被拷贝进延迟调用记录了,只有函数本体被推迟。正确写法:想延迟读取变量当前值,包一层闭包 defer func(){ ... }()。
坑 2:defer 在循环里
// scratch/main.go(错误示范 vs 正确写法)
for _, id := range ids {
rows, err := db.Query("SELECT * FROM posts WHERE id = ?", id)
if err != nil {
return err
}
defer rows.Close() // 错!要等整个函数结束才关
}
// 正确:把循环体抽成函数,defer 在每轮结束时触发
for _, id := range ids {
if err := loadOne(db, id); err != nil {
return err
}
}
func loadOne(db *sql.DB, id int64) error {
rows, err := db.Query("SELECT * FROM posts WHERE id = ?", id)
if err != nil {
return err
}
defer rows.Close() // 每轮结束就关
return nil
}
错误现象:循环 1000 次攒 1000 个未关闭的结果集,连接池耗尽,后续查询全部卡死或超时。根因:defer 的作用域是函数,不是代码块——循环体不是函数。
3.10 顺带一提:Go 1.22 改了循环变量语义
// scratch/main.go(临时试验用,不属于博客项目)
funcs := make([]func(), 0, 3)
for i := 0; i < 3; i++ {
funcs = append(funcs, func() { fmt.Print(i, " ") })
}
for _, fn := range funcs {
fn()
}
在 Go 1.22 之前输出 3 3 3(所有闭包共享同一个 i),1.22 及以后输出 0 1 2(每轮迭代一个新变量)。实测在 1.25 上是 0 1 2。
为什么专门说:网上大量 Go 教程还在教「循环里起 goroutine 必须 i := i 拷贝一份」——那是 1.21 及以前的写法。你的 go.mod 写了 go 1.25.0,不需要那个拷贝。看到老代码里有 i := i 也不是错,只是冗余。注意这条语义由 go.mod 的版本号决定,把 go 1.25.0 改回 go 1.21 就退回旧行为。
4. 函数逐个精讲
现在建目录,开始写真代码:
cd ~/go-blog
mkdir -p cmd/server internal/model
4.1 先定义 Post 和状态类型
// internal/model/post.go
package model
import (
"errors"
"fmt"
"strings"
"time"
"unicode"
)
// Status 是文章状态。
// 为什么不直接用 int8:定义成独立类型后,编译器能挡住"把 view_count 传进
// status 参数"这类错误——底层都是数字,但类型不同就不能互相赋值。零成本类型安全。
type Status int8
const (
StatusDraft Status = 0 // 草稿,对应 schema.sql 里 status=0
StatusPublished Status = 1 // 已发布
StatusOffline Status = 2 // 已下线
)
// Post 是文章的领域模型。
// 字段顺序刻意和 schema.sql 的 posts 表一致,第 3 课扫描结果集时对着写不容易错位。
// 注意:这里没有任何 SQL / HTTP 相关的东西——model 包既不知道数据库存在,
// 也不知道 HTTP 存在。这是第 4 课分层架构的前提。
type Post struct {
ID int64
Title string
Slug string
Summary string // 第 3 课接数据库时会换成 sql.NullString
Content string
Status Status
ViewCount int
PublishedAt *time.Time // 指针,因为 published_at 在数据库里可为 NULL
CreatedAt time.Time
UpdatedAt time.Time
}
为什么 PublishedAt 是 *time.Time 而 CreatedAt 是 time.Time:schema.sql 里 published_at DATETIME NULL(可空),created_at DATETIME NOT NULL(非空)。可空列在 Go 侧要么用指针(nil = NULL),要么用 sql.NullTime。time.Time 的零值是公元 1 年 1 月 1 日,不是 NULL,两者不能混。
4.2 两种错误类型
// internal/model/post.go
// ErrEmptyTitle 是一个哨兵错误(sentinel error)。
// 命名惯例:Err 开头 + 驼峰。它是包级变量,用 == 或 errors.Is 比较。
// 什么时候用哨兵错误:调用方只需要知道"是不是这一类错",不需要额外信息。
var ErrEmptyTitle = errors.New("post: 标题不能为空")
// ValidationError 是自定义错误类型。
// 什么时候用自定义类型:调用方需要从错误里**取出结构化信息**
// (比如 handler 要把 Field 放进 JSON 响应告诉前端哪个输入框标红)。
type ValidationError struct {
Field string // 哪个字段
Reason string // 为什么不合法
}
// Error() string 是 error 接口唯一要求的方法。
// 实现了它,*ValidationError 就自动"是"一个 error,不需要任何 implements 声明。
//
// 注意接收者是 *ValidationError(指针):
// 所以只有 &ValidationError{...} 满足 error 接口,ValidationError{...} 不满足。
// 这是上面 3.8 讲的方法集规则的直接应用。
func (e *ValidationError) Error() string {
return fmt.Sprintf("字段 %s 校验失败: %s", e.Field, e.Reason)
}
4.3 func NewPost(title, content string) (*Post, error)
签名为什么这么设计:
- 名字叫
NewPost而不是Post::__construct:Go 没有构造函数语法,惯例是写一个NewXxx函数。返回类型是*Post,所以函数名里的Post和类型名对上。 - 参数
title, content string:只收必填字段。可选字段(summary、tags)不进构造函数,构造完再赋值。这样签名不会随着需求膨胀成 8 个参数。 - 返回
*Post而不是Post:① 调用方拿到的是同一个对象,后面p.ID = 42能改到原件;② 出错时能返回nil,返回值类型Post就只能返回一个尴尬的空结构体。 - 返回
error而不是 panic:「用户输入不合法」是预期内的情况,不是程序 bug。Go 的 panic 只用于「不可能发生的事发生了」。
// internal/model/post.go
const maxTitleLen = 200 // 和 schema.sql 的 VARCHAR(200) 对齐
// NewPost 构造一篇新文章。
// 它是 Post 的唯一合法入口:只要你拿到的是 *Post 且 err == nil,
// 就保证标题非空、长度合法、slug 已生成。这叫"让非法状态无法表示"。
func NewPost(title, content string) (*Post, error) {
// 先规范化再校验。顺序反了的话," " 会被当成合法标题通过。
title = strings.TrimSpace(title)
// 校验 1:空标题 → 返回哨兵错误。
// 返回 nil 而不是 &Post{}:出错时第一个返回值必须是零值,
// 否则调用方忘了检查 err 时会拿到一个半成品对象继续跑,错误被延后暴露。
if title == "" {
return nil, ErrEmptyTitle
}
// 校验 2:长度 → 返回带字段信息的自定义错误。
//
// 注意是 len([]rune(title)) 而不是 len(title):Go 的 len(string) 返回**字节数**,
// 中文一个字 3 字节,"你好" 的 len 是 6 而不是 2。MySQL 的 VARCHAR(200)
// 数的是字符数,所以这里必须按 rune 数。中文项目里最高频的一个坑。
if n := len([]rune(title)); n > maxTitleLen {
return nil, &ValidationError{ // 注意取地址 &,因为 Error() 是指针接收者
Field: "title",
Reason: fmt.Sprintf("长度 %d 超过上限 %d", n, maxTitleLen),
}
}
// 校验 3:正文非空。
if strings.TrimSpace(content) == "" {
return nil, &ValidationError{Field: "content", Reason: "正文不能为空"}
}
// & 取地址,直接在堆上构造并返回指针。Go 有逃逸分析和 GC,
// 返回局部变量的地址**完全安全**,不像 C 会返回悬垂指针。
p := &Post{
Title: title,
Content: content,
Status: StatusDraft, // 不写也是 0,写出来是为了让读者不用去查零值
}
// 没显式赋值的字段自动取零值:ID=0(等数据库分配)、ViewCount=0、
// PublishedAt=nil、Summary=""。
p.Slug = p.Slugify()
return p, nil
}
易错点:忘了 &
// internal/model/post.go(错误示范)
return nil, ValidationError{Field: "title"} // 编译错误
报错:cannot use ValidationError{…} (value of struct type ValidationError) as error value: ValidationError does not implement error (method Error has pointer receiver)。
根因:Error() 定义在 *ValidationError 上,值类型不在方法集里。正确写法:加 &。
4.4 func (p *Post) Slugify() string
签名为什么这么设计:
- 指针接收者
*Post:这个方法其实不改p,值接收者也能跑。但按 3.8 的规则 3——Post的其他方法(第 4 课的Publish())要改状态、必须用指针——所以统一用指针。混用会让你每次调用前都得想一下方法集。 - 无参数:它要的数据(
p.Title)已经在接收者里了。 - 返回
string而不是(string, error):这个函数没有失败路径。任何输入都能产出一个字符串(哪怕是空串)。不要为了「看起来严谨」给不会失败的函数加 error 返回值,那只会逼所有调用方写无意义的if err != nil。
// internal/model/post.go
// Slugify 把标题转成 URL 友好的短名,写进 posts.slug(schema.sql 里有唯一索引)。
// "Hello Go, 你好!" → "hello-go-你好"
func (p *Post) Slugify() string {
// strings.Builder 是拼字符串的正确工具。为什么不用 s += string(r):
// Go 的 string 不可变,每次 += 都要分配新内存 + 拷贝全部旧内容,
// 循环 N 次就是 O(N²)。Builder 内部维护可增长的 []byte,全程 O(N)。
var b strings.Builder
b.Grow(len(p.Title)) // 预分配,省掉中途扩容的拷贝
// lastDash 记录"上一个写进去的是不是连字符",用来把连续的空格/标点压成一个 -。
// 初值 true 是个小技巧:让开头的标点不会产生前导 -。
lastDash := true
// for range 一个 string 时,r 的类型是 rune(int32,一个 Unicode 码点)而**不是 byte**。
// 写成 for i := 0; i < len(s); i++ { s[i] } 拿到的是字节,中文会被劈碎。
for _, r := range strings.ToLower(p.Title) {
switch {
// unicode.IsLetter 对中文也返回 true —— 这是刻意的,中文 slug 在现代
// 浏览器和 MySQL utf8mb4 下都能正常工作。要纯 ASCII slug 见练习 2。
case unicode.IsLetter(r) || unicode.IsDigit(r):
b.WriteRune(r) // 写 rune 用 WriteRune,写单字节才用 WriteByte
lastDash = false
default:
if !lastDash { // 空格、标点、emoji 全部走这里,压缩成一个 -
b.WriteByte('-')
lastDash = true
}
}
}
// Trim 的第二个参数是"字符集合"不是"子串":Trim("--a--", "-") → "a"
return strings.Trim(b.String(), "-")
}
易错点:len(s) 数的是字节。s := "你好Go" 时 len(s) 是 8(3+3+1+1 字节),len([]rune(s)) 才是 4,s[0] 拿到的是 228 这个字节而不是「你」。
根因:Go 的 string 底层是 UTF-8 字节序列,下标访问拿字节。正确写法:按字符处理一律 for range 或转 []rune。
4.5 func (p Post) IsPublished() bool
签名为什么这么设计:
- 值接收者
(p Post):这是本课唯一一个值接收者,故意的——它是纯查询,签名本身就在宣告「我保证不改任何东西」。代价是每次调用拷贝整个Post(包括那个可能很长的Content字符串——不过 Go 的 string 是「指针 + 长度」的胖指针,拷贝的是 16 字节的头,不是正文内容)。 - 返回
bool而不是(bool, error):判断不会失败。
// internal/model/post.go
// IsPublished 判断这篇文章是否该对读者可见。
//
// 为什么要两个条件而不是只判断 status:
// schema.sql 允许 status=1 但 published_at IS NULL 的中间态
// (比如定时发布任务写了 status 还没写时间就崩了)。
// 领域方法必须对这种脏数据保持防御,不能假设数据库里的数据总是自洽的。
func (p Post) IsPublished() bool {
return p.Status == StatusPublished && p.PublishedAt != nil
}
这个方法在演示零值的威力:一个刚 var p Post 出来、什么都没赋值的 Post,IsPublished() 返回 false(Status 零值 0 = StatusDraft,PublishedAt 零值 nil)。零值就是一个合理的默认状态,不需要构造函数去初始化。这是 Go 设计里非常重要的一条:让零值可用——sync.Mutex、strings.Builder、bytes.Buffer 都是这么设计的,var mu sync.Mutex 直接就能 mu.Lock()。
4.6 func main()
// cmd/server/main.go
package main
import (
"errors"
"fmt"
"log"
"net/http"
"time"
// 标准库 import 和项目内 import 之间空一行,这是 gofmt 认可的分组习惯。
// "blog" 就是 go.mod 里的 module 名。
"blog/internal/model"
)
func main() {
// —— 第一部分:把 error 处理的三种姿势各演一遍 ——
p, err := model.NewPost(" Hello Go, 你好 Go! ", "用 20 行写一个 HTTP 服务。")
if err != nil {
// log.Fatalf = 打印日志 + os.Exit(1)。
// 为什么 main 里能用 Fatal 而别处不能:Fatal 直接杀进程,
// **不执行任何 defer**。在 handler 里用它,等于一次请求出错就干掉整个服务。
// 只有 main 的启动阶段(配置读不到、数据库连不上)才该用——
// 那些情况下服务本来也跑不起来,早死早超生。
log.Fatalf("构造示例文章失败: %v", err)
}
log.Printf("示例文章: slug=%q status=%d published=%v", p.Slug, p.Status, p.IsPublished())
// 姿势 1:errors.Is —— 判断"是不是这个哨兵错误"
if _, err := model.NewPost(" ", "正文"); err != nil {
if errors.Is(err, model.ErrEmptyTitle) {
log.Printf("按预期拦下了空标题: %v", err)
}
}
// 姿势 2:errors.As —— 判断"是不是这个类型",并**取出**里面的字段
if _, err := model.NewPost("标题OK", " "); err != nil {
var ve *model.ValidationError // 必须先声明一个目标变量
if errors.As(err, &ve) { // 传 &ve(指针的指针),As 会往里写
log.Printf("按预期拦下了空正文: field=%s reason=%s", ve.Field, ve.Reason)
}
}
// 姿势 3:%w 包装 —— 加上下文的同时保留原始错误,errors.Is 依然能穿透识别
wrapped := fmt.Errorf("创建文章: %w", model.ErrEmptyTitle)
log.Printf("包装后仍可识别: %v -> errors.Is=%v", wrapped, errors.Is(wrapped, model.ErrEmptyTitle))
// —— 第二部分:20 行 HTTP 服务,第 2 课会把它扩成完整路由 ——
mux := http.NewServeMux()
// "GET /" 里的 "GET " 是 Go 1.22+ 的方法匹配语法,第 2 课细讲。
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "hello, go-blog! now=%s\n", time.Now().Format(time.RFC3339))
})
addr := ":8080"
log.Printf("服务启动,监听 %s", addr)
// ListenAndServe 是**阻塞**的:正常情况下永远不返回。
// 一旦返回一定是出错了(端口被占用、权限不够),所以标准写法是把它
// 整个塞进 log.Fatal —— 它返回 error,log.Fatal 接收 ...any,正好串起来。
//
// 千万别写成裸的 http.ListenAndServe(addr, mux) 不管返回值:
// 端口被占用时进程会静默退出,你会盯着终端想"我明明启动了啊"。
log.Fatal(http.ListenAndServe(addr, mux))
}
格式化动词速查(这四个覆盖 90% 场景):%v 默认格式什么都能塞;%q 带引号的字符串,能暴露首尾空格(%v 打出来的 a 和 a 长得一模一样,调错时优先用 %q);%d 十进制整数;%w 只能用在 fmt.Errorf 里包装 error。
5. 跑起来验证
cd ~/go-blog
gofmt -l . # 应该没有输出(没有格式问题)
go vet ./... # 应该没有输出(没有可疑写法)
go build ./... # 应该没有输出(编译通过)
go run ./cmd/server
预期输出:
2026/09/06 09:24:29 示例文章: slug="hello-go-你好-go" status=0 published=false
2026/09/06 09:24:29 按预期拦下了空标题: post: 标题不能为空
2026/09/06 09:24:29 按预期拦下了空正文: field=content reason=正文不能为空
2026/09/06 09:24:29 包装后仍可识别: 创建文章: post: 标题不能为空 -> errors.Is=true
2026/09/06 09:24:29 服务启动,监听 :8080
逐行核对你学到的东西:slug="hello-go-你好-go" 说明逗号和感叹号变成了 -、连续空格没有产生 --、中文被保留(unicode.IsLetter 对中文返回 true);status=0 是 NewPost 里设的 StatusDraft;published=false 是因为 PublishedAt 零值 nil;errors.Is=true 说明 %w 包装后错误链没断。
另开一个终端 curl -i localhost:8080,预期:
HTTP/1.1 200 OK
Content-Length: 46
Content-Type: text/plain; charset=utf-8
hello, go-blog! now=2026-09-06T09:24:32-07:00
注意 Content-Type: text/plain; charset=utf-8 不是你设的——Go 从响应体的头 512 字节自动嗅探出来的。第 2 课返回 JSON 时必须手动设 Content-Type,否则会被嗅探成 text/plain,前端拿到就不会自动 JSON.parse。Ctrl+C 停止服务。
6. TODO 练习
练习 1:给 Post 加 Publish() 方法
// internal/model/post.go
// TODO(练习1): 实现 Publish
// 要求:
// - 把 Status 置为 StatusPublished
// - 把 PublishedAt 置为当前时间(注意 PublishedAt 是 *time.Time)
// - 如果已经是已发布状态,返回一个哨兵错误 ErrAlreadyPublished
// 提示:time.Now() 返回 time.Time(值),你需要一个 *time.Time。
// 不能写 &time.Now()——Go 不允许对函数返回值取地址(不可寻址)。
func (p *Post) Publish() error {
return nil
}
验收标准:在 main 里加这几行,输出必须完全一致。
// cmd/server/main.go
p2, _ := model.NewPost("测试发布", "正文")
log.Println("发布前:", p2.IsPublished()) // false
log.Println("发布结果:", p2.Publish()) // <nil>
log.Println("发布后:", p2.IsPublished()) // true
log.Println("重复发布:", p2.Publish()) // post: 文章已发布
练习 2:写一个纯 ASCII 版的 SlugifyASCII()
验收标准:
| 输入 Title | Slugify() |
SlugifyASCII() |
|---|---|---|
Hello Go |
hello-go |
hello-go |
你好 Go 世界 |
你好-go-世界 |
go |
!!! |
(空串) | (空串) |
|
Go 1.25 |
go-1-25 |
go-1-25 |
提示:把 unicode.IsLetter(r) 换成显式的 ASCII 范围判断 (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9')。注意此时 . 也走 default 分支变成 -,所以 1.25 → 1-25。
练习 3:修掉一个 defer 循环泄漏
下面这个函数有本课 3.9 讲的坑:defer 写在循环体里,但它的作用域是整个函数,所以 N 次循环会攒 N 条延迟记录,全部等到函数返回才一起触发。
// internal/model/post.go
// TODO(练习3): 把循环体抽成独立函数,让每轮的清理动作当轮就执行
func SlugifyAll(titles []string) []string {
out := make([]string, 0, len(titles))
for i, t := range titles {
defer fmt.Printf("清理第 %d 条\n", i) // ← 问题在这
p := &Post{Title: t}
out = append(out, p.Slugify())
}
fmt.Println("循环结束")
return out
}
验收标准:SlugifyAll([]string{"A", "B"}) 的打印顺序,改之前实测是 循环结束 → 清理第 1 条 → 清理第 0 条;改对之后必须是 清理第 0 条 → 清理第 1 条 → 循环结束。
思路:把「构造 + defer 清理」整体挪进一个小函数,循环里调它。真实场景里这个 Printf 会是 rows.Close()——不改的话,第 3 课你会亲眼看到连接池被耗干。
练习 4:让 NewPost 支持可选 summary
给 Post 加 WithSummary(s string) *Post,返回 p 自身以支持链式调用 p = p.WithSummary("摘要")。
验收标准:p.Summary == "摘要",且 WithSummary("") 后 p.Summary == "" 不报错。思考题(下一课开头对照):为什么必须用指针接收者并返回 *Post?如果写成 func (p Post) WithSummary(...) Post 会发生什么?
练习 5:给 ValidationError 加 Unwrap()
// internal/model/post.go
// TODO(练习5): 让 ValidationError 能被 errors.Is 识别成某个哨兵错误
var ErrValidation = errors.New("post: 校验失败")
func (e *ValidationError) Unwrap() error {
return nil // ← 改这里
}
验收标准:_, err := model.NewPost("标题", "") 拿到的是 *ValidationError,改对后 errors.Is(err, model.ErrValidation) 打印 true;而 model.NewPost("", "正文") 走的是哨兵 ErrEmptyTitle,对它 errors.Is(err, ErrValidation) 应该是 false。
为什么有用:第 5 课的统一错误中间件会写 if errors.Is(err, model.ErrValidation) { 返回 422 },不需要枚举每一种具体错误类型。Unwrap() 就是让错误链能被 errors.Is 穿透的那个钩子。
7. 自检清单
- 我能说出
go.mod里module blog和go 1.25.0分别影响什么 - 我知道
go build/go run/go vet的区别,且每次提交前会跑gofmt -l .和go vet ./... - 我能解释为什么
type Post struct { title string }序列化成 JSON 是空对象 - 我能背出
string/[]T/map[K]V/*T/error的零值 - 我能说清为什么第 3 课需要
sql.NullString,而不是直接用string - 我知道出错时第一个返回值要返回零值,且
error永远放最后 - 我会用
errors.Is判断哨兵错误、errors.As取出自定义错误的字段、%w包装 - 我知道自定义 error 类型的
Error()用指针接收者时,返回时必须加& - 我能用 3 条规则判断值接收者还是指针接收者,并知道指针接收者的类型只有
*T满足接口 - 我知道
len("你好")是 6 不是 2,按字符处理要[]rune或for range - 我知道
append可能原地改写共享的底层数组,以及三索引切片s[:2:2]怎么防 - 我知道 nil map 能读不能写,写会 panic
- 我知道
defer的参数在声明那一行就求值,作用域是函数不是代码块 - 我知道
log.Fatal不执行defer,只该在main的启动阶段用 -
go run ./cmd/server能跑起来,curl localhost:8080有输出
下一课:把这个只会说 hello 的服务,变成一个能 GET /api/posts、GET /api/posts/{id}、POST /api/posts 的真 JSON API——我们会把 http.Handler 这个只有一个方法的接口拆开看,你会发现整个 Go Web 生态都建立在它上面。