在过去几周里,我花了很多时间用 Go 开发一个可能永远不会面世的网站,但我在这个过程中学到了一些东西,想把它们记录下来。以下是我的一些笔记:
go 1.22 现在有了更好的路由
我从来没有动力去学习任何 Go 的路由库(如 gorilla/mux、chi 等),所以我一直在手动处理所有的路由,像这样:
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) return } fmt.Fprintf(w, "Welcome to the home page!") }) http.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "Hello!") })
但显然 从 Go 1.22 开始[1] ,Go 在标准库中对路由有了更好的支持,所以上面的代码可以重写成这样:
http.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "Welcome to the home page!") }) http.HandleFunc("GET /hello", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "Hello!") })
不过它还需要一个登录中间件,所以可能更像这样,使用一个 requireLogin
中间件:
mux := http.NewServeMux() mux.HandleFunc("GET /", requireLogin(homePage)) mux.HandleFunc("GET /hello", requireLogin(helloPage))
内置路由器的一个陷阱:带尾斜杠的重定向
我遇到的一个烦人的陷阱是:如果我为 /records/
创建一个路由,那么对 /records
的请求 会被重定向[2] 到 /records/
。
我遇到了一个问题,向 /records
发送 POST 请求会重定向到对 /records/
的 GET 请求,这破坏了 POST 请求,因为它移除了请求体。幸运的是, Xe Iaso 写了一篇关于完全相同问题的博客文章[3] ,这使得调试变得更容易。
我认为解决这个问题的方法是使用像 POST /records
这样的 API 端点,而不是 POST /records/
,这似乎也是一种更正常的设计。
sqlc 自动为我的数据库查询生成代码
我有点厌倦了为 SQL 查询写那么多样板代码,但我并不想学习 ORM,因为我知道我想写什么 SQL 查询,而且我不想学习 ORM 将事物转换为 SQL 查询的约定。
但后来我发现了 sqlc[4] ,它可以将这样的查询:
-- name: GetAuthor :one SELECT * FROM authors WHERE id = $1 LIMIT 1; -- name: ListAuthors :many SELECT * FROM authors ORDER BY name; -- name: CreateAuthor :one INSERT INTO authors ( name, bio ) VALUES ( $1, $2 ) RETURNING *; -- name: DeleteAuthor :exec DELETE FROM authors WHERE id = $1;
编译成这样的 Go 代码:
type Author struct { ID int64 Name string Bio string } func (q *Queries) GetAuthor(ctx context.Context, id int64) (Author, error) { row := q.db.QueryRowContext(ctx, "SELECT id, name, bio FROM authors WHERE id = $1 LIMIT 1", id) var i Author err := row.Scan(&i.ID, &i.Name, &i.Bio) return i, err } func (q *Queries) ListAuthors(ctx context.Context) ([]Author, error) { rows, err := q.db.QueryContext(ctx, "SELECT id, name, bio FROM authors ORDER BY name") if err != nil { return nil, err } defer rows.Close() var items []Author for rows.Next() { var i Author if err := rows.Scan(&i.ID, &i.Name, &i.Bio); err != nil { return nil, err } items = append(items, i) } if err := rows.Close(); err != nil { return nil, err } if err := rows.Err(); err != nil { return nil, err } return items, nil } func (q *Queries) CreateAuthor(ctx context.Context, arg CreateAuthorParams) (Author, error) { row := q.db.QueryRowContext(ctx, "IN SERT INTO authors (name, bio) VALUES ($1, $2) RETURNING id, name, bio", arg.Name, arg.Bio) var i Author err := row.Scan(&i.ID, &i.Name, &i.Bio) return i, err } func (q *Queries) DeleteAuthor(ctx context.Context, id int64) error { _, err := q.db.ExecContext(ctx, "DELETE FROM authors WHERE id = $1", id) return err }
我喜欢这种方式的原因是,如果我不确定对给定的 SQL 查询应该写什么 Go 代码,我可以直接写我想要的查询,阅读生成的函数,它会告诉我exactly该如何调用它。对我来说,这比试图翻阅 ORM 的文档来弄清楚如何构造我想要的 SQL 查询要容易得多。
阅读 Brandur 2024 年的 sqlc 笔记[5] 也让我对这种方法在我的小程序中是可行的有了一些信心。这篇文章给出了一个非常有用的例子,说明如何使用 CASE 语句有条件地更新表中的字段(例如,如果你有一个有 20 列的表,而你只想更新其中的 3 列)。
sqlite 技巧
Mastodon 上有人给我发了一篇名为 优化服务器上的 sqlite[6] 的文章链接。我的项目很小,我不太关心性能,但我的主要收获是:
- 有一个专门用于写入数据库的对象,并在其上运行
db.SetMaxOpenConns(1)
。我通过艰难的方式学到,如果不这样做,当两个线程同时尝试写入数据库时,我会得到SQLITE_BUSY
错误。 - 如果我想让读取更快,我可以有 2 个独立的数据库对象,一个用于写入,一个用于读取
那篇文章中还有更多看起来有用的技巧(比如"COUNT 查询很慢"和"使用 STRICT 表"),但我还没有尝试过这些。
另外,有时如果我有两个表,我知道我永远不需要在它们之间做 JOIN
,我就会把它们放在单独的数据库中,这样我就可以独立地连接它们。
Go 1.19 引入了设置 GC 内存限制的方法
我在内存相对较少的虚拟机中运行我所有的 Go 项目,比如 256MB 或 512MB。我遇到了一个问题,我的应用程序不断被 OOM 杀死,这很令人困惑 - 我是不是有内存泄漏?怎么回事?
经过一些 Google 搜索,我意识到也许我没有内存泄漏,也许我只是需要重新配置垃圾收集器!原来默认情况下(根据 Go 垃圾收集器指南[7] ),Go 的垃圾收集器会让应用程序分配的内存最多达到当前堆大小的2 倍。
Mess With DNS[8] 的基本堆大小约为 170MB,虚拟机上当前可用的内存约为 160MB,所以如果它的内存翻倍,它就会被 OOM 杀死。
在 Go 1.19 中,他们添加了一种方法来告诉 Go "嘿,如果应用程序开始使用这么多内存,就运行一次 GC"。所以我将 GC 内存限制设置为 250MB,这似乎导致应用程序被 OOM 杀死的频率降低了:
import "runtime/debug" func main() { debug.SetMemoryLimit(250 * 1024 * 1024) // 250MB // ... }
我喜欢用 Go 制作网站的一些原因
在过去的 4 年左右的时间里,我断断续续地用 Go 制作小型网站(比如 nginx playground[9] ),这真的很适合我。我想我喜欢它是因为:
- 只有 1 个静态二进制文件,我需要做的就是复制这个二进制文件。如果有静态文件,我可以用 embed[10] 把它们嵌入到二进制文件中。
- 有一个内置的可以在生产环境中使用的 web 服务器,所以我不需要配置 WSGI 或其他东西来让它工作。我可以直接把它放在 Caddy[11] 后面或者在 fly.io 上运行它。
- Go 的工具链非常容易安装,我可以直接
apt-get install golang-go
或其他方式,然后go build
就可以构建我的项目 - 感觉开始发送 HTTP 响应需要记住的东西很少 - 基本上只有像
Serve(w http.ResponseWriter, r *http.Request)
这样的函数,它们读取请求并发送响应。如果我需要记住具体是如何完成的某些细节,我只需要阅读函数就行了! - 而且
net/http
在标准库中,所以你可以在不安装任何库的情况下开始制作网站。我真的很感激这一点。 - Go 是一种相当系统化的语言,所以如果我需要运行
ioctl
或类似的东西,那很容易做到
总的来说,它的一切都感觉让项目变得容易上手 5 天,放弃 2 年,然后再回来继续编写代码而不会遇到太多问题。
相比之下,我尝试学习 Rails 几次,我真的很想喜欢上 Rails - 我用 Rails 做了几个玩具网站,感觉总是一种神奇的体验。但最终当我回到这些项目时,我记不起任何东西是如何工作的,我最后总是放弃。对我来说,回到我那些充满重复样板代码的 Go 项目感觉更容易,因为至少我可以阅读代码并弄清楚它是如何工作的。
我还没有弄清楚的事情
一些我在 Go 中还没有做过很多的事情:
- 渲染 HTML 模板:通常我的 Go 服务器只是 API,我用 Vue 制作单页应用作为前端。我在 Hugo 中大量使用了
html/template
(我在过去 8 年里一直用它来做这个博客),但我仍然不确定我对它的感觉如何。 - 我从未制作过真正的登录系统,通常我的服务器根本没有用户。
- 我从未尝试过实现 CSRF
总的来说,我不确定如何实现安全敏感的功能,所以我不会开始需要登录/CSRF 等功能的项目。我想这就是框架会有帮助的地方。
看到 Go 一直在添加新功能很酷
我在这篇文章中提到的两个 Go 功能(GOMEMLIMIT
和路由)都是在过去几年中新增的,我在它们发布时并没有注意到。这让我觉得我应该更密切地关注新 Go 版本的发布说明。
参考链接
- 从 Go 1.22 开始: https://go.dev/blog/routing-enhancements
- 会被重定向: https://pkg.go.net/net/http#hdr-Trailing_slash_redirection-ServeMux
- Xe Iaso 写了一篇关于完全相同问题的博客文章: https://xeiaso.net/blog/go-servemux-slash-2021-11-04/
- sqlc: https://sqlc.dev/
- Brandur 2024 年的 sqlc 笔记: https://brandur.org/fragments/sqlc-2024
- 优化服务器上的 sqlite: https://kerkour.com/sqlite-for-servers
- Go 垃圾收集器指南: https://tip.golang.org/doc/gc-guide
- Mess With DNS: https://messwithdns.net/
- nginx playground: https://nginx-playground.wizardzines.com/
- embed: https://pkg.go.dev/embed
- Caddy: https://caddyserver.com/