2026 年 7 月 14 日 · 阅读时长 8 分钟

后端开发,直接用 Go

原作者:Blain Smith。原文:Just Fucking Use Go

原文采用 CC BY-SA 4.0 许可。本文基于原文观点和结构进行中文改编,调整了表达,并补全了部分示例中的错误处理与工程边界;本文同样采用 CC BY-SA 4.0 许可。

很多后端服务并没有特殊到需要一套特殊技术方案。它们接收 HTTP 请求,校验数据,读写数据库,再返回 HTML 或 JSON。业务本身通常并不复杂,额外复杂度常来自为了启动业务而引入的 framework、build tool、容器编排和内部平台。

Blain Smith 在原文里用了很重的语气批评这种倾向。他的结论很直接:大部分常规后端服务,先用 Go。它编译快,能生成单个 binary,standard library 足够完成大量基础工作,部署和维护需要记住的东西也少。

这不是说 Go 适合所有项目。更实际的说法是,当一个团队准备为普通 HTTP 服务引入复杂技术栈时,Go 应该成为需要被认真排除的默认选项。

Go 的克制是有意设计的

Go 的语法不追求表达力上限。语言里没有 decorator、macro、class hierarchy 或复杂的类型体操,常用构件主要是 struct、function、interface、goroutine 和 channel。代码因此会显得重复,甚至有点笨,但阅读成本通常更稳定。

这种稳定性对长期维护很重要。新成员读两年前的代码,不需要先判断项目采用了哪一套 metaprogramming 规则;团队也很难在 format 上形成派别,因为 gofmt 已经给出了唯一结果。资深开发者仍然可以写出复杂代码,只是语言不会主动奖励这种复杂度。

Go 的限制也会带来代价。显式的 if err != nil 很啰嗦,缺少某些高级抽象时会产生样板代码,interface 用错后同样会形成难懂的间接层。选择 Go 是把复杂度尽量留在业务和数据模型中,避免藏进语言技巧。

Standard library 足以启动一个 Web 服务

Go 项目不需要先选 Web framework。net/httphtml/templateembed 已经可以组成一个带内嵌模板的服务:

package main

import (
	"embed"
	"html/template"
	"log"
	"net/http"
	"time"
)

//go:embed templates/*.html
var templateFiles embed.FS

func main() {
	tmpl := template.Must(template.ParseFS(templateFiles, "templates/*.html"))
	mux := http.NewServeMux()

	mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
		data := map[string]string{"Name": "Go"}
		if err := tmpl.ExecuteTemplate(w, "index.html", data); err != nil {
			log.Printf("render index: %v", err)
		}
	})

	server := &http.Server{
		Addr:              ":8080",
		Handler:           mux,
		ReadHeaderTimeout: 5 * time.Second,
	}

	log.Fatal(server.ListenAndServe())
}

模板会编进 binary,部署时不需要单独搬运 template directory。JSON 可以用 encoding/json,数据库访问从 database/sql 开始,HTTP client 仍然来自 net/http,测试由 testing 提供。项目确实需要 router、middleware、validation 或 structured logging 时,再按具体需求引入 package,不必在第一天接受一个 framework 的全部约定。

Standard library 的价值更多来自一致的 interface。io.Readerio.Writer 都只有一个 method,却把 file、network response、compressor、hash 和 encoder 接到了一起。一个 component 只要接收 io.Reader,就不必关心数据来自 memory、disk 还是 HTTP response。

context.Context 负责传递 deadline、cancellation signal 和 request-scoped value。浏览器断开连接后,request context 可以继续取消 database query 和 downstream HTTP call。前提是每一层都把 context.Context 作为第一个参数传下去,并调用支持 context 的 API。

这些 interface 很小,组合后却能覆盖大部分后端数据流。Go 所谓的「简单」并不等于 standard library 很浅,它只是把复用放在少数稳定约定上。

并发写起来简单,边界仍然要明确

Goroutine 由 Go runtime 调度,初始 stack 很小,并且会按需增长。启动一个 goroutine 的成本通常远低于创建一个 OS thread,因此每个 request、task 或 connection 使用 goroutine 是常见做法。

Channel 可以在 goroutine 之间传递 typed value,sync.Mutex 适合保护 shared state,sync.WaitGroup 用来等待一组任务结束。go test -race 还能在测试运行时检查 data race。这套能力由语言、runtime 和 toolchain 一起提供,不需要先选择 async framework。

简单不代表可以无限启动 goroutine。批量请求下游服务时,仍然要设置 concurrency limit、timeout 和 cancellation;写入 channel 时要考虑 receiver 是否退出;长期运行的 goroutine 需要明确由谁停止。原文用十几行代码展示 parallel HTTP fetch 的方向没有问题,生产代码还要关闭 response body、处理 error,并限制同时发出的请求数量。

数据库代码可以保持从上到下阅读

下面是一段读取 PostgreSQL 并渲染 HTML 的 handler。它直接使用 database/sql,request context 会传给 query:

type Post struct {
	ID    int
	Title string
	Body  string
}

func postsHandler(db *sql.DB, tmpl *template.Template) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		rows, err := db.QueryContext(
			r.Context(),
			"SELECT id, title, body FROM posts ORDER BY id DESC LIMIT 50",
		)
		if err != nil {
			log.Printf("query posts: %v", err)
			http.Error(w, "failed to load posts", http.StatusInternalServerError)
			return
		}
		defer rows.Close()

		posts := make([]Post, 0, 50)
		for rows.Next() {
			var post Post
			if err := rows.Scan(&post.ID, &post.Title, &post.Body); err != nil {
				log.Printf("scan post: %v", err)
				http.Error(w, "failed to load posts", http.StatusInternalServerError)
				return
			}
			posts = append(posts, post)
		}
		if err := rows.Err(); err != nil {
			log.Printf("iterate posts: %v", err)
			http.Error(w, "failed to load posts", http.StatusInternalServerError)
			return
		}

		if err := tmpl.ExecuteTemplate(w, "posts.html", posts); err != nil {
			log.Printf("render posts: %v", err)
		}
	}
}

这里没有 ORM、dependency injection container 或 controller hierarchy。代码不一定要永远放在一个 file 里,但拆分应该来自真实变化:query 开始复用时抽成 repository,business rule 变复杂时放进 service,HTTP 和 background job 需要共享能力时再划分 package。

提前复制一套 enterprise architecture,只会让简单 request 穿过更多目录。Go 更适合从小 module 开始,等依赖方向和变化频率出现后再定边界。

Module 和 toolchain 减少了项目配置

go mod init 会创建 go.mod,记录 module path、Go version 和 direct dependency。go.sum 保存下载过的 module 内容校验值,用来验证 dependency 没有在不同环境中悄悄变化。Go 的 module proxy 和 checksum database 还会提高公开 module 的可用性与完整性。

这不等于供应链问题已经消失。项目仍然需要审查 dependency、跟踪 vulnerability,并控制升级节奏。需要 offline build 或更强可复现性时,可以运行 go mod vendor,把依赖复制到项目的 vendor/ directory。它比把 dependency 安全交给网络和远端仓库更可控。

常用工程能力和 compiler 一起安装:

gofmt -w .
go vet ./...
go test ./...
go test -race ./...
go test -cover ./...
go test -bench=. ./...
go tool pprof

项目仍然可能增加 linter、code generator 和 migration tool,但基础 format、test、benchmark、coverage、race detection 和 profiling 不依赖第三方 build system。团队可以先用内置能力交付,再为已经出现的问题补工具。

部署可以只复制一个 binary

当项目只依赖 pure Go package,并且静态资源已经通过 embed 编入程序时,可以直接 cross-compile:

CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
  go build -trimpath -o myapp ./cmd/myapp

scp myapp user@server:/usr/local/bin/
ssh user@server 'sudo systemctl restart myapp'

这种部署只需要一个 binary 和少量 runtime configuration。VPS 加 systemd 足以运行许多内部工具、API 和中小型产品,不必为了形式完整而先引入 Kubernetes、Helm、service mesh 或内部 deployment platform。

容器仍然有明确用途。团队已有统一的 container runtime,应用依赖系统动态库,或部署平台只接收 image 时,Docker 是合理选择。Go binary 也能放进很小的 image。单文件部署让 artifact 保持简单,运行环境仍可按组织约束选择。

Modular monolith 通常是更好的起点

普通业务系统可以先用一个 Go process、一套 PostgreSQL,再按需要加入 Redis。HTML page、JSON API 和 background job 可以共享同一组 domain package。一个 repository、一次 build 和一套 observability,会比一开始维护多套 service contract 更省事。

Monolith 不等于所有代码放进一个 package。按 domain 拆 package,限制 dependency direction,把 network、database 和 external service 放在清晰边界上,已经能得到大部分模块化收益。等独立部署、资源隔离、故障隔离或团队 ownership 成为真实需求,再拆 service。

把 package 移到另一个 repository 只是拆分的开始。跨 process 后还会增加 network failure、serialization、authentication、distributed tracing 和 data consistency 等问题。Go 能让拆分后的 service 保持轻量,但不会消除 distributed system 的成本。

Go 的缺点都摆在明面上

Go 的 error handling 会重复出现,缺少 exception 意味着每个可能失败的调用都要立即决定如何处理。这样的代码更长,但 control flow 也更清楚。随手忽略 error,或者每一层只写 return err,同样会得到难以排查的系统,显式并不自动等于可靠。

Generics 从 Go 1.18 开始可用,适合 container、algorithm 和 type-safe helper。它没有把 Go 变成一门追求类型表达力的语言,也不应该用来重新制造 inheritance hierarchy。大部分业务代码仍然会以具体 type 和小 interface 为主。

复杂 authentication、schema-driven API、large routing table 或成熟 middleware 需求,都可能让 framework 更划算。是否采用 framework,取决于 dependency 带来的能力能否抵消升级、Debug 和迁移成本。

哪些项目不该默认用 Go

已有系统和团队能力通常比语言偏好更重要。一个成熟的 Rails 或 Django 应用不会因为 Go 部署简单就值得重写;强依赖 Python data science 生态的产品,继续使用 Python 往往更自然;需要精细 memory control、embedded runtime 或特定 platform API 时,Rust、C、Swift 等语言也可能更合适。

Go 最适合的范围很宽:HTTP API、CLI、network service、background worker、data pipeline、internal platform tool,以及需要长期运行和简单部署的 server program。需求落在这些范围内,又没有明确的 ecosystem constraint 时,可以先做一个最小版本,再用实际问题决定是否增加 framework、container 和 service 数量。

原文最后要求开发者打开 editor,运行 go mod init,写一个 main.go,编译后直接发布。去掉刻意挑衅的语气,建议仍然成立:先让一个简单 binary 解决问题,复杂度等确实需要时再加。

CO

我是 Cooper,一名生活在中国的开发者,多年来一直在家远程工作。

主要使用 Go、PHP 和 Rust,从事移动端、Web、跨境支付与资金系统开发。目前主要采用 Vibe Coding 构建产品,并以真实运行结果完成验证与交付。