跳到正文
知识库 / Go 博客教程 / 第 1 课返回主站 ↗

Go 博客教程 · 第 1 课 / 10

第 1 课:Go 语法地基与项目骨架

你已经会用 PHP / Python / JS 写过东西了,所以这一课不教「什么是变量」,只教 Go 跟它们不一样的那些地方——那些让你第一天写 Go 时反复 go build 报错的地方。

教程日期:实测环境:Go 1.25.0 · MySQL 8.4.5 · Redis← 上一课:课程总览下一课:第 2 课:net/http 与路由 →

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.modpackage.json / composer.json,但它不列依赖版本以外的任何东西——没有 scripts、没有构建配置、没有入口声明。Go 的构建是约定式的。

第 3 课装 MySQL 驱动时,go get 会往 go.modrequire 段,同时生成 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 maincmd/server/cmd/migrate/……),各自编译成各自的二进制——这就是 cmd/ 目录的意义。

易错点mainreturn 只是提前退出,进程退出码是 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.TimeCreatedAttime.Timeschema.sqlpublished_at DATETIME NULL(可空),created_at DATETIME NOT NULL(非空)。可空列在 Go 侧要么用指针(nil = NULL),要么用 sql.NullTimetime.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 出来、什么都没赋值的 PostIsPublished() 返回 falseStatus 零值 0 = StatusDraftPublishedAt 零值 nil)。零值就是一个合理的默认状态,不需要构造函数去初始化。这是 Go 设计里非常重要的一条:让零值可用——sync.Mutexstrings.Builderbytes.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 打出来的 aa 长得一模一样,调错时优先用 %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=0NewPost 里设的 StatusDraftpublished=false 是因为 PublishedAt 零值 nilerrors.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.parseCtrl+C 停止服务。

6. TODO 练习

练习 1:给 PostPublish() 方法

// 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.251-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

PostWithSummary(s string) *Post,返回 p 自身以支持链式调用 p = p.WithSummary("摘要")

验收标准p.Summary == "摘要",且 WithSummary("")p.Summary == "" 不报错。思考题(下一课开头对照):为什么必须用指针接收者并返回 *Post?如果写成 func (p Post) WithSummary(...) Post 会发生什么?

练习 5:给 ValidationErrorUnwrap()

// 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.modmodule bloggo 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,按字符处理要 []runefor range
  • 我知道 append 可能原地改写共享的底层数组,以及三索引切片 s[:2:2] 怎么防
  • 我知道 nil map 能读不能写,写会 panic
  • 我知道 defer 的参数在声明那一行就求值,作用域是函数不是代码块
  • 我知道 log.Fatal 不执行 defer,只该在 main 的启动阶段用
  • go run ./cmd/server 能跑起来,curl localhost:8080 有输出

下一课:把这个只会说 hello 的服务,变成一个能 GET /api/postsGET /api/posts/{id}POST /api/posts 的真 JSON API——我们会把 http.Handler 这个只有一个方法的接口拆开看,你会发现整个 Go Web 生态都建立在它上面。