跳到正文

从 Node.js 到 Go Web(1):应用入口与 HTTP 处理

从 Node.js 到 Go Web

  1. 应用入口与 HTTP 处理(当前)
  2. 从 Proto 找到路由与参数
  3. 请求消息与响应转换
  4. 对象装配、Context 与登录用户
  5. 沿着创建 Memo 读完整请求
  6. 用测试区分业务与传输边界

在 Express 里,app.get() 把处理函数登记到路由表,app.listen() 开始接收连接。读 Memos 时仍然可以沿着这两个动作找,只是路由表分了几层,业务方法也不直接接收 req、res。

本文阅读 Memos 提交 b40d8a7b5737。这一版使用 Go 1.27.2、Connect Go 1.20.0 和 gRPC-Gateway 2.30.0。它的 HTTP 基础是标准库 net/http;网上基于其他版本写的 Echo、Gin 或 /api/v1/ 教程,不能直接套在这里。

先找到真正开始监听的位置

cmd/memos/main.go 用 Cobra 解析命令行,整理配置,创建数据库 Driver 和 Store,执行迁移,再调用 server.NewServer()。数据库部分可以先看作应用启动所需的准备工作,重点看准备好的对象如何传给 Server。

Server.Start() 中的这一段才开始把 HTTP 连接交给应用:

// All transports share the standard HTTP server.
	s.httpServer = &http.Server{
		Handler:           s.handler,
		ReadHeaderTimeout: readHeaderTimeout,
		IdleTimeout:       idleTimeout,
	}
	go func() {
		if err := s.httpServer.Serve(listener); err != nil && err != http.ErrServerClosed {
			slog.Error("failed to start HTTP server", "error", err)
		}
	}()

net.Listen() 已经在此前创建了 listener。http.Server.Serve(listener) 会持续处理连接,所以它被放进 go func() { ... }() 中。这是启动一个 goroutine,可以先理解成由 Go 运行时调度的并发执行单元;它不等于 Node.js 里的一个 Promise,也不保证拥有独立操作系统线程。

Start() 返回后,主流程等待退出信号。退出时调用 Shutdown(),先处理长连接和 HTTP Server,再停后台任务并关闭 Store。这里只需要先看清:创建应用对象、开始监听和退出清理是三个不同动作。Express 中它们也存在,只是常常写在同一个启动文件里。

连接由 HTTP Server 接入,Handler 再把请求交给对应的传输入口

最底层仍然有 req 和 res

标准库的 http.Handler 约定很小:对象实现 ServeHTTP(ResponseWriter, *Request) 就能处理 HTTP 请求。普通函数可以通过 http.HandlerFunc 转成 Handler。下面是独立对照示例,不是 Memos 的接口实现:

mux := http.NewServeMux()
mux.HandleFunc("GET /hello", func(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "text/plain; charset=utf-8")
    _, _ = w.Write([]byte("hello"))
})

这里的 r 很接近 Express 的 req,w 承担响应写入职责。w 是接口,r 是指向 Request 的指针;星号 * 表示指针类型。参数叫什么名字由作者决定,类型和接口约定才决定它能不能被调用。

和 Koa 给 ctx.body 赋值不同,这个底层函数直接写响应。写入 body 前应先设置 Header;一旦响应已经提交,后面再改状态码通常就来不及了。标准 Handler 没有 error 返回值,Memos 又在 internal/httphandler 上加了可返回错误的辅助封装,这是项目自己的抽象。

Memos 在 Handler 之间分发

NewServer() 创建 http.NewServeMux(),登记健康检查、前端和文件路由,再组合恢复、客户端 IP、CORS 等处理。APIService 创建自己的 Handler,内部继续选择 REST、Connect 或 SSE。一个 Handler 可以调用另一个 Handler 的 ServeHTTP(),所以这里是在组合普通对象,并不要求每一层都接入另一套服务器。

Node.js 里 app.use(middleware) 常见的是 next();Go 常见的组合形式是 func(http.Handler) http.Handler,返回一个包着下游的 Handler。它能在调用下游前检查请求,也能在下游返回后做记录。请求被拒绝时直接返回,就不会进入后面的业务逻辑。Koa 的洋葱模型可以帮助理解前后包裹,但 Go 这里是普通同步调用的嵌套,没有 await next() 这个语法要求。

路由注册不一定和业务方法写在一起。看到 CreateMemo(ctx, request) 时,已经进入更高一层的服务接口;HTTP 的解码、响应写入和错误映射由它外面的适配代码承担。下一篇会沿着生成代码把这段距离补上。

并发会影响对象的放置方式

同一个 APIService 会被多个请求共享。Go HTTP Server 会并发处理请求,不能把「当前用户」「本次请求的表单」放进共享服务字段。它们应通过参数或请求 Context 传递。共享限流器、缓存和 SSEHub 则需要各自保证并发使用的正确性。

在 Node.js 中,事件循环也不能防止两个请求在异步步骤之间交错;把用户放在全局变量同样有问题。Go 的区别是还可能出现实际并行访问,因此共享可变状态的同步更直接地成为代码审查问题。

总结

Memos 先由标准库调用 Handler,再通过路由和协议适配器,把 HTTP 请求转换成服务方法需要的参数。监听、分发、解码与业务调用各有负责的位置;服务方法返回消息和错误后,协议层继续完成响应写入。