一些 Go Web 开发笔记

文摘   2024-10-08 15:36   湖南  

在过去几周里,我花了很多时间用 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 版本的发布说明。

参考链接

  1. 从 Go 1.22 开始: https://go.dev/blog/routing-enhancements
  2. 会被重定向: https://pkg.go.net/net/http#hdr-Trailing_slash_redirection-ServeMux
  3. Xe Iaso 写了一篇关于完全相同问题的博客文章: https://xeiaso.net/blog/go-servemux-slash-2021-11-04/
  4. sqlc: https://sqlc.dev/
  5. Brandur 2024 年的 sqlc 笔记: https://brandur.org/fragments/sqlc-2024
  6. 优化服务器上的 sqlite: https://kerkour.com/sqlite-for-servers
  7. Go 垃圾收集器指南: https://tip.golang.org/doc/gc-guide
  8. Mess With DNS: https://messwithdns.net/
  9. nginx playground: https://nginx-playground.wizardzines.com/
  10. embed: https://pkg.go.dev/embed
  11. Caddy: https://caddyserver.com/

幻想发生器
图解技术本质
 最新文章