Web 与数据库
这一课你将能做到
- 用标准库
net/http 写出带路由参数、JSON 收发、日志中间件的 HTTP 服务
- 理解
http.Handler 接口与 Go 1.22 新路由语法(方法匹配 + {id} 通配符)
- 用
database/sql 完成建表与参数化查询 CRUD,说清 SQL 注入与驱动机理
为什么要学
Go 最主流的应用形态就是后端服务。标准库 net/http 强到什么程度?不引入任何框架就能跑生产——API 网关 Caddy、监控系统 Prometheus 的内核都基于它。Go 1.22 又补齐了路由短板(方法匹配、路径通配符),标准库写 REST API 已经完全够用。
数据库方面,Go 只内置了统一的接口层 database/sql,具体数据库由驱动包接入。本课用纯 Go 的 SQLite 驱动跑通完整流程(无需装数据库、无需 CGO,Windows 也零障碍),套路与 MySQL/PostgreSQL 完全一致,换驱动即可无缝切换。
先建立图像
HTTP 请求
│
▼
┌─────────────────────── http.Server ───────────────────────┐
│ ServeMux(路由器) │
│ "GET /hello/{name}" ──▶ handlerFunc │
│ "POST /users" ──▶ handlerFunc │
│ │ 每个请求一个 goroutine(天然高并发) │
│ ▼ │
│ middleware(日志) ──▶ handler ──▶ json.NewEncoder(w) ──▶ 响应│
└────────────────────────────────────────────────────────────┘
│ database/sql(统一接口层)
▼
驱动(sqlite / go-sql-driver/mysql / pgx)
│ SQL(参数化占位符 ? 或 $1)
▼
数据库文件 / 服务
三个关键认知:Handler 是接口(一个 ServeHTTP 方法);每个请求独立 goroutine(不用你写线程池);database/sql 只管接口,驱动才管实现。
跟着做
步骤 1:十行 HTTP 服务器(Go 1.22 路由)
package main
import (
"encoding/json"
"log"
"net/http"
)
func main() {
mux := http.NewServeMux()
// Go 1.22 新语法:方法 + 路径通配符,{name} 用 PathValue 取
mux.HandleFunc("GET /hello/{name}", func(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
w.Header().Set("Content-Type", "application/json; charset=utf-8")
json.NewEncoder(w).Encode(map[string]string{"msg": "你好, " + name})
})
log.Println("服务已启动: http://localhost:8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
$ go run .
$ curl http://localhost:8080/hello/gopher
{"msg":"你好, gopher"}
步骤 2:理解 http.Handler 接口
路由能挂任何「实现了 ServeHTTP 的东西」:
package main
import (
"fmt"
"net/http"
)
// 任何有 ServeHTTP(w, r) 的类型都是一个 handler
type greeter struct {
greeting string
}
func (g greeter) ServeHTTP(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, g.greeting)
}
func main() {
mux := http.NewServeMux()
mux.Handle("/", greeter{greeting: "Hello, Handler 接口!"})
// http.HandlerFunc 把普通函数适配成 handler
mux.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "pong")
})
http.ListenAndServe(":8081", mux)
}
Handle 收接口(对象),HandleFunc 收函数——两者等价,函数版写起来更顺手。
步骤 3:JSON 收发的规范写法
package main
import (
"encoding/json"
"net/http"
)
type User struct {
ID int `json:"id"`
Name string `json:"name"`
}
func main() {
mux := http.NewServeMux()
// 接收 JSON 请求体
mux.HandleFunc("POST /users", func(w http.ResponseWriter, r *http.Request) {
var u User
if err := json.NewDecoder(r.Body).Decode(&u); err != nil {
http.Error(w, "请求体不是合法 JSON", http.StatusBadRequest)
return
}
u.ID = 1
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusCreated) // 201
json.NewEncoder(w).Encode(u)
})
// 查询参数:/users?id=1
mux.HandleFunc("GET /users", func(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get("id") // 拿不到时是空串
if id == "" {
http.Error(w, "缺少 id 参数", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
json.NewEncoder(w).Encode(User{Name: "user-" + id})
})
http.ListenAndServe(":8082", mux)
}
$ curl -X POST http://localhost:8082/users -d '{"name":"张三"}'
{"id":1,"name":"张三"}
步骤 4:中间件——函数套函数
中间件就是「包一层的 handler」:进站前、出站后各有机会做事(日志、鉴权、超时、恢复 panic):
package main
import (
"log"
"net/http"
"time"
)
// 接收 handler,返回增强后的 handler——这就是 Go 中间件的全部
func logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r) // 调用下一层
log.Printf("%s %s 耗时 %s", r.Method, r.URL.Path, time.Since(start))
})
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("hello"))
})
log.Fatal(http.ListenAndServe(":8083", logging(mux))) // 整个 mux 被包一层
}
中间件可以链式叠加:logging(recovery(auth(mux))),洋葱模型,和任何框架同构。
步骤 5:database/sql + SQLite 实战
先装纯 Go 驱动(无需 CGO、无需装数据库):
$ go mod init example.com/webdemo
$ go get modernc.org/sqlite
package main
import (
"database/sql"
"fmt"
"log"
_ "modernc.org/sqlite" // 空白导入:只为触发驱动注册(init 副作用)
)
func main() {
// 打开连接池(lazy,真正的连接在首次使用时建立)
db, err := sql.Open("sqlite", "file:demo.db")
if err != nil {
log.Fatal(err)
}
defer db.Close()
// 生产数据库的常见连接池设置
db.SetMaxOpenConns(10)
db.SetMaxIdleConns(5)
// 建表
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL
)`)
if err != nil {
log.Fatal(err)
}
// 插入:占位符 ? 防注入,值绝不拼进 SQL 字符串
result, err := db.Exec("INSERT INTO users (name) VALUES (?)", "张三")
if err != nil {
log.Fatal(err)
}
id, _ := result.LastInsertId()
fmt.Println("新用户 id:", id)
// 单行查询:QueryRow + Scan
var name string
err = db.QueryRow("SELECT name FROM users WHERE id = ?", id).Scan(&name)
if err != nil {
log.Fatal(err)
}
fmt.Println("查到:", name)
// 查不存在的行:返回 sql.ErrNoRows(正常业务错误,不是 bug)
err = db.QueryRow("SELECT name FROM users WHERE id = ?", 999).Scan(&name)
fmt.Println("err == sql.ErrNoRows:", err == sql.ErrNoRows) // true
// 多行查询:Query 返回 rows,必须 Close!
rows, err := db.Query("SELECT id, name FROM users")
if err != nil {
log.Fatal(err)
}
defer rows.Close()
for rows.Next() {
var uid int
var uname string
if err := rows.Scan(&uid, &uname); err != nil {
log.Fatal(err)
}
fmt.Println(uid, uname)
}
if err := rows.Err(); err != nil { // 迭代中发生的错误在这里浮出
log.Fatal(err)
}
// 更新与删除
db.Exec("UPDATE users SET name = ? WHERE id = ?", "张三丰", id)
db.Exec("DELETE FROM users WHERE id = ?", id)
}
切换到 MySQL 只需两步:go get github.com/go-sql-driver/mysql、sql.Open("mysql", "user:pass@tcp(127.0.0.1:3306)/dbname"),业务代码一行不改。
步骤 6:框架概览——gin 长什么样
// go get github.com/gin-gonic/gin
package main
import "github.com/gin-gonic/gin"
func main() {
r := gin.Default() // 自带日志与 recover 中间件
r.GET("/hello/:name", func(c *gin.Context) {
c.JSON(200, gin.H{"msg": "你好, " + c.Param("name")})
})
r.Run(":8084")
}
选型一句话:gin 生态最大、资料最多;echo/chi 轻快,chi 路由几乎就是标准库风格。学透标准库后,任何框架半小时上手。
讲清楚要点
- http.Handler 是一切的核心:
ServeHTTP(ResponseWriter, *Request) 一个方法定义了整个 Web 世界;中间件、路由、框架全是它的排列组合。
- 每请求一个 goroutine:handler 里可以放心阻塞(如查数据库),不会拖垮其他请求;但共享数据要自己加锁(第 6 课)。
- Go 1.22 路由:
"GET /users/{id}" 支持方法匹配与通配符,r.PathValue("id") 取值;{id...} 还能匹配剩余路径。
- SQL 永远参数化:占位符交给驱动转义,杜绝拼接注入;
?(sqlite/mysql)与 $1(postgres)只是方言差异。
- rows 必须Close,rows 迭代完查 Err:不 Close 会占用连接池连接,最终服务卡死。
- database/sql 是连接池管理器:SetMaxOpenConns/SetMaxIdleConns 是服务上量前的必修参数。
你可能会懵的地方
-
为什么标准库不内置 ORM?
Go 社区偏好显式 SQL:查询可读、可控、可调优。要 ORM 生态有 GORM(活跃度最高)、sqlc(SQL 生成代码)等,但都建立在 database/sql 之上,接口层不变。
-
import _ "modernc.org/sqlite" 的下划线是干嘛的?
空白导入:代码不直接使用该包,只为执行其 init()——驱动在 init 里调用 sql.Register 注册自己,database/sql 才认识 "sqlite" 这个名字。
-
http.ListenAndServe 为什么要 log.Fatal 包着?
它会一直阻塞,只有出错(如端口被占)才返回;Fatal 打印错误并退出进程,否则错误被静默忽略,服务“看似启动实则没起”。
-
w.WriteHeader 之前写了 Header 就不生效?
隐式 200 的规则:第一次 Write 或 WriteHeader 时响应头定格。所以先 Header().Set(...),再 WriteHeader(code),最后写 body,顺序不能反。
-
SQLite 适合生产吗?
单机小流量(嵌入式、CLI、单文件场景)完全够,WAL 模式下并发也不差;多实例 Web 服务通常上 MySQL/PostgreSQL。本课用它是因为零环境成本,套路通用。
错法对照
| 错法 | 问题 | 正解 |
|---|
"SELECT ... WHERE id = " + id 拼接 | SQL 注入 | db.Query("... id = ?", id) |
| 先 Write body 再设 Content-Type | 头已定格,Content-Type 丢失 | 先设头,再写 body |
| rows 忘 Close | 连接池耗尽,服务假死 | defer rows.Close() |
路由注册 "GET /users" 请求 /users/ | 404(尾斜杠不匹配) | 统一客户端路径或注册两条 |
| handler 里共享 map 不加锁 | 并发写 fatal error | Mutex 保护或改设计 |
| 忽略 QueryRow 的 ErrNoRows | 把「没有数据」当系统故障 | errors.Is(err, sql.ErrNoRows) 特判 |
卡点急救
- 404 但代码看着没错:方法没匹配(POST 打到 GET 路由)、路径大小写/尾斜杠不一致;打印 mux 注册项逐一核对。
sql: unknown driver "sqlite":忘了空白导入驱动包。
bind: address already in use:端口被占,换端口或杀掉旧进程。
- 数据库文件被锁(sqlite: database is locked):并发写冲突,开 WAL(
PRAGMA journal_mode=WAL)或控制连接数。
面试常问
-
net/http 为什么高性能?
每请求一个 goroutine(轻量、百万级并发)、内置连接池复用、零拷贝优化与超时控制齐备;相比线程池模型省去线程调度与内存开销。
-
Go 中间件的实现原理?
闭包包装:func(next http.Handler) http.Handler,前置逻辑 → next.ServeHTTP → 后置逻辑,天然支持洋葱模型与链式组合,与框架无关。
-
SQL 注入的原理与防御?
注入 = 用户输入被当作 SQL 语法执行(引号逃逸改写语句)。防御核心是参数化查询(预编译 + 占位符转义),辅以最小权限账号、输入校验。ORM 的绑定参数同样基于此。
-
GORM 和裸 SQL 怎么选?
CRUD 密集、模型稳定的业务用 GORM 提效;复杂查询、报表、性能敏感路径写原生 SQL(或 sqlc 生成类型安全代码)。两者共存很常见,关键是不让 ORM 生成失控的 N+1 查询。
自测三问
- Go 1.22 如何注册「DELETE /todos/{id}」并取出 id?写出两行代码。
- 写出防注入的参数化查询写法,并解释为什么拼接字符串有风险。
- rows.Next() 循环结束后还要做哪两件事?各防止什么问题?
小结一句话
Go 的 Web = http.Handler 一个接口派生出的路由、中间件与框架;数据库 = database/sql 统一接口 + 驱动实现 + 参数化查询——掌握这两层,换任何框架、任何数据库都是换个壳。
延伸