☰
Go零基础实战:用net/http写一个带JSON接口和中间件的HTTP服务器
2026/10/6 16:37:39 网站建设 项目流程

先说个身边的事。上周有位做前端的朋友来问我,说他想给自己做的小网页配一个后端接口,结果试过几个方案都半途而废,主要是一上来就被各种框架名词劝退。我给他的回答很简单:直接用 Go,先别管框架,把标准库里的 net/http 用明白,第一个 HTTP 服务器一个下午就能跑起来。这篇就是一次完整的“我怎么做你就怎么做”的实战记录,全程零基础友好,只需要你电脑里能装软件、能开终端。我会从 Go 环境安装讲起,一路写到监听端口、处理请求、返回 JSON,最后再带上几个新人极易踩的坑和一套可以直接抄走的改进模板。看完之后,你会有一个自己写了代码、自己访问得到的服务器,也会大致明白互联网上最常见的请求响应到底是怎么回事。

1. 先把Go装明白:Windows最容易卡住的环境步骤

1.1 不用MSI,官方zip包也能把Go装好

我看很多零基础教程会直接推荐下 msi 安装包,双击下一步就完了。这个方法当然没问题,但有一个隐藏问题:很多人装完之后根本不理解“环境变量”是什么,后面一旦出问题就完全没法排查。所以我更推荐 Windows 用户用官方提供的 zip 包来装,步骤更透明,卸载也简单。

具体操作如下:

  1. 打开 Go 官方下载页 go.dev/dl,找当前正式版,Windows 那一列选文件名带amd64.zip的下载。
  2. 把 zip 解压到一个固定目录,比如C:\Go。注意路径里最好不要有空格和中文。
  3. 右键“此电脑” → 属性 → 高级系统设置 → 环境变量 → 在系统变量里找到Path,点编辑,新增一行C:\Go\bin。
  4. 保存设置后,新开一个终端窗口,输入go version。

如果输出类似go version go1.21.x windows/amd64,说明安装成功。大多数人卡在这一步,原因往往不是配置错了,而是没有新开终端。Windows 的环境变量在旧终端里不会自动刷新,必须重新开一个窗口再试。

1.2 macOS 和 Linux 的装法,以及安装后的验证命令

macOS 用户最简单,如果你装了 Homebrew,直接执行:

brew install go

Linux 用户可以下载官方 tar.gz 包,解压到/usr/local/go,然后把/usr/local/go/bin加进 PATH,也可以直接用包管理器安装,比如 Ubuntu 上的sudo apt install golang-go。包管理器装的可能不是最新版,但对第一个服务器完全够用。

装完别急着写代码,先跑几个验证命令,确认环境真的没问题:

go version go env GOROOT go env GOOS GOARCH

go version看版本,GOROOT看 Go 装在哪,GOOS和GOARCH看当前系统类型和芯片架构。对这些变量不用死记,先知道它们存在就行。出现“command not found”时,九成是 PATH 没配好,优先检查这一项。

1.3 编辑器用VS Code,再装一个官方Go插件

新手写 Go 不建议在 IDE 选择上纠结,VS Code 够用且免费。装好 VS Code 后,在扩展市场搜“Go”,选官方那个安装,我的经验是它自带语法提示、代码补全和保存自动格式化,非常省心。

第一次打开.go文件时,右下角可能会提示安装额外工具,点 Install All,等它装完。编辑器准备好之后,在本地新建一个项目目录,名字建议用hello-server,路径里不要有中文。后面所有文件都放这个目录里。

2. 动手前先弄懂HTTP服务器在干什么:请求、响应、路由

2.1 用“点餐”理解一次HTTP请求

HTTP 服务器的核心其实不复杂:客户端发出请求,服务器返回响应。我习惯拿点餐来类比:你在浏览器地址栏输入网址,相当于跟饭店说了句“我要一份菜单上的第几个菜”,这个请求会带上方法、路径、头信息;服务器收到之后,根据路径找到对应的“后厨窗口”,也就是处理函数;后厨做好菜,再通过响应端到你的桌上。

一个最简单的请求长这样:

GET /hello HTTP/1.1 Host: localhost:8080

服务器响应则可能是:

HTTP/1.1 200 OK Content-Type: text/plain; charset=utf-8 Hello, HTTP!

状态码 200 代表请求成功,404 代表没找到对应路径。能看懂这三行,你对 HTTP 的理解就已经超过很多只会调接口的初学者了。

2.2 标准库net/http替你干的三件事

如果没有标准库,想手写一个 HTTP 服务器,你需要自己创建 socket、监听端口、从 TCP 流里按 HTTP 协议规则切出请求行和请求头、再手动拼响应。这对零基础来说完全是灾难。

Go 的net/http包替你包好了这三件最脏最累的事:

  • 监听本地端口,接受浏览器或其他客户端发来的 TCP 连接;
  • 解析出Request,里面带有URL、Method、Header、Body等信息;
  • 调用你注册的处理函数,然后把结果写回ResponseWriter。

所以你会看到一个看起来很固定的函数签名:

func handler(w http.ResponseWriter, r *http.Request)

w是写给客户端的“笔”,你想返回什么内容,就往w里写;r是客户端送来的“菜单”,你想知道路径、参数、请求方法,都从r里读。

2.3 默认路由的匹配规则:精确路径和子树路径

Go 的默认路由,也就是http.DefaultServeMux,负责把请求分发给对应处理函数。注册方式有两大类,新手一定要分清:

http.HandleFunc("/hello", helloHandler) http.HandleFunc("/hello/", helloHandler)

第一个是精确匹配,只响应/hello这个地址;第二个末尾带斜杠,匹配的是/hello/开头的一切子路径,比如/hello/go、/hello/world。

我在带新人时经常看到的问题是:注册了/hello,然后访问/hello/go,结果 404,还在那怀疑代码写错了。其实不是代码错,是路由规则没匹配上。这个细节后面还会配合实例再讲一遍。

3. 写出并跑起来第一个服务器:Hello, HTTP

3.1 在项目目录里初始化Go Module

打开终端,进入刚才建好的目录:

cd hello-server go mod init hello-server

执行完,目录里会多出一个go.mod文件。它的作用相当于这个项目的“身份证”,记录模块名和依赖版本。第一个服务器只用到标准库,没有任何第三方依赖,所以环境非常干净,正好适合入门。

3.2 一个最小编码量的main.go,逐行拆开看

在目录里新建main.go,粘贴下面这段:

package main import ( "fmt" "log" "net/http" ) func main() { http.HandleFunc("/", homeHandler) log.Println("Starting server at http://localhost:8080") if err := http.ListenAndServe(":8080", nil); err != nil { log.Fatal(err) } } func homeHandler(w http.ResponseWriter, r *http.Request) { log.Printf("receive: %s %s", r.Method, r.URL.Path) fmt.Fprintf(w, "Hello, HTTP!") }

逐行解释一下:

  • package main表示这是一个可执行程序;
  • import引入标准库里的fmt、log、net/http;
  • http.HandleFunc("/", homeHandler)告诉路由:根路径交给homeHandler处理;
  • http.ListenAndServe(":8080", nil)让程序在 8080 端口开始监听;第二个参数传nil,表示使用默认路由;
  • 如果监听失败,log.Fatal(err)会打印错误并退出程序;
  • handler里我用log.Printf打印收到了一条什么请求,然后用fmt.Fprintf把内容写回给客户端。

这里你不需要改任何代码,先原样跑通。

3.3 启动、访问、停止:第一次看到自己的服务在跑

在终端里执行:

go run main.go

你会看到一行日志:

Starting server at http://localhost:8080

而且这个终端会一直“卡住”,不再弹回命令提示符。这其实是正常现象,因为服务器进程在持续监听端口,等待请求。如果你在编辑器里运行,有些新手会以为程序死掉了,其实没有。

打开浏览器,访问:

http://localhost:8080/

页面显示Hello, HTTP!,恭喜,你的第一个 Go HTTP 服务器已经跑起来了。

再回到终端,你还能看到刚才那次请求的日志:

receive: GET /

如果想停止服务,按Ctrl+C。如果启动时出现bind: Only one usage of each socket address之类的错误,说明 8080 端口已经被占用,后面我会专门讲怎么处理。

4. 不满足于Hello World:路径参数、JSON响应和静态文件

4.1 让接口响应路径里的内容,而不是永远一句Hello

第一个服务器能返回固定字符串,但真正的接口需要根据请求路径返回不同内容。很简单,直接从r.URL.Path里取就行。

在main.go里增加一个路由:

http.HandleFunc("/hello/", helloNameHandler)

对应处理函数:

func helloNameHandler(w http.ResponseWriter, r *http.Request) { name := strings.TrimPrefix(r.URL.Path, "/hello/") if name == "" { name = "world" } fmt.Fprintf(w, "Hello, %s!", name) }

这里要注意,函数里用到strings,所以import里要多加一行"strings"。然后重启服务:

go run main.go

访问:

http://localhost:8080/hello/Go

返回值是Hello, Go!。这段代码的原理很简单:路由注册的是/hello/,凡是这个前缀开头的路径都会走到同一个函数,我再用TrimPrefix把前缀去掉,剩下的部分就是路径参数。为什么用/hello/而不是/hello?因为前面说过,带斜杠才是子树匹配。如果你只想支持精确路径,那用不带斜杠的注册方式更合适。

如果你想让它支持中文参数,比如浏览器访问/hello/小明,路径里会出现编码后的字符串。如果需要还原,可以用标准库的url.PathUnescape,但第一个版本不急着做,先跑通主干。

4.2 给前端准备一个正经JSON接口

前后端分离的接口里,返回字符串没什么意义,前端希望拿到结构化数据。最通用格式就是 JSON。下面这个例子定义了一个User结构体,接口把用户信息以 JSON 形式返回:

package main import ( "encoding/json" "log" "net/http" ) type User struct { Name string `json:"name"` Age int `json:"age"` } func userHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") user := User{Name: "阿明", Age: 18} json.NewEncoder(w).Encode(user) } func main() { http.HandleFunc("/user", userHandler) log.Println("Starting server at http://localhost:8080") log.Fatal(http.ListenAndServe(":8080", nil)) }

这里最关键的有两点。第一,结构体字段上的json:"name"标签告诉 Go 编码成 JSON 时用哪个字段名;第二,必须设置响应的Content-Type为application/json; charset=utf-8,否则前端那边拿数据时可能当成纯文本,出现编码问题。

访问http://localhost:8080/user,你会看到:

{"name":"阿明","age":18}

我个人的习惯是,接口成功时返回格式统一,比如再包一层code和message,方便前端做统一处理。不过对第一个 JSON 接口来说,先把这个简单的跑通,再谈规范。

4.3 顺手加一个静态文件服务器,把HTML页面也托管了

很多人的第一个前端页面其实只是一堆静态文件,Go 也提供了现成的托管方案。在项目目录下新建一个static文件夹,里面放一个index.html,随便写点内容:

<h1>Hello from static page</h1>

然后在main.go里加一行:

http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static"))))

重启后访问:

http://localhost:8080/static/index.html

就能看到页面内容。http.FileServer会把目录里的文件映射成 URL,http.StripPrefix的作用是把 URL 里的/static/去掉,让文件服务器准确找到./static目录下的文件。

新手容易在这里遇到两个问题:一是路径写错,文件明明在static里却返回 404,先检查项目根目录是否和启动目录一致;二是访问/static不带末尾斜杠时,文件服务器会返回一个重定向到/static/,这不是 bug,是 HTTP 的目录跳转规则。

5. 运行以后最容易踩的坑:我自己全踩过一遍

5.1 端口被占用,一直报bind错误

你很可能迟早会遇到这个报错:

listen tcp :8080: bind: Only one usage of each socket address (protocol/network address/port) is normally permitted.

意思很直接:8080 端口已经被别的进程占用了。可能是你前一个服务没停,也可能是别的程序在用这个端口。

处理方式分系统:

系统查询端口占用结束进程
Windowsnetstat -ano | findstr :8080taskkill /PID 12345 /F
macOS/Linuxlsof -i :8080kill 12345

把12345替换成查出来的进程号。如果你不想杀进程,直接换一个端口也行,比如把代码里的:8080改成:8081。我前期练习时经常同时开多个服务,所以有一个固定习惯:每个项目用不同端口,比如第一个用 8080,第二个用 8081,这样就不会互相干扰。

5.2 改了代码再刷新浏览器,发现还是旧内容

这不是代码问题,而是你对 Go 的运行方式理解有偏差。Go 是编译型语言,go run main.go每次都会重新编译再启动,但它不会像某些脚本语言那样在你修改文件后自动重启。

所以正确流程是:改代码 →Ctrl+C停掉当前进程 → 重新执行go run main.go。如果你不重启,浏览器怎么刷新都是旧进程在响应。

如果嫌手动重启麻烦,可以保留一段别人写好的文件监听工具,也可以先不折腾。新手阶段手动重启次数多,反而能帮你理解进程和编译的关系。

5.3 浏览器能访问,curl却看不到内容

有时候浏览器里正常,用命令行工具curl测接口却觉得“不对”,其实多半是响应头和路径匹配的问题。推荐一个调试命令:

curl -i http://localhost:8080/hello/

-i会把响应头一起打印出来,你能直接看到状态码、Content-Type、Server等信息。比如返回HTTP/1.1 404 Not Found,那就要检查路由是否匹配;如果返回 200 但内容乱码,基本是Content-Type里没带上charset=utf-8。

调试接口我强烈建议先用 curl 而不是浏览器。浏览器会自动处理很多细节,比如缓存、编码、跳转,反而掩盖了真实响应。curl 拿到的才是服务器最原始的样子。

5.4 用日志替代瞎猜,先弄清请求到底到了没有

很多新手遇到接口不返回预期内容,第一反应是上下翻代码找 bug。我的建议是先看日志。不要只依赖fmt.Println,用标准库的log.Printf更好,因为自带时间戳,排查问题时会舒服很多。

在任意处理函数里加一行:

log.Printf("receive: %s %s from %s", r.Method, r.URL.Path, r.RemoteAddr)

这样每次请求进入处理函数,终端就会打印:

2025/01/05 14:32:11 receive: GET /hello/Go from [::1]:54321

如果这行日志没打印,说明请求根本就没进到这个函数,问题大概率在路由匹配;如果打印了但响应不对,问题才在处理逻辑。这个“先确认请求到达位置”的思路,会贯穿你以后所有的后端调试。

6. 从小玩具到能上线的HTTP服务:超时、优雅退出和中间件

6.1 用http.Server配置超时,别裸奔着监听端口

我们前面一直用http.ListenAndServe(":8080", nil),适合入门,但不适合生产。裸奔的服务器没有超时控制,如果某个客户端故意或者无意地慢慢建立连接,服务端可能一直等它,拖垮整个进程。

更稳妥的写法是显式声明一个http.Server:

srv := &http.Server{ Addr: ":8080", Handler: http.DefaultServeMux, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 60 * time.Second, } if err := srv.ListenAndServe(); err != nil { log.Fatal(err) }

这段代码需要引入time。解释一下几个超时字段:

  • ReadHeaderTimeout:读取请求头的最长等待时间,防止恶意连接一直不发完整请求头;
  • ReadTimeout:读取整个请求体(包括请求头和请求体)的超时;
  • WriteTimeout:写入响应到客户端的超时;
  • IdleTimeout:长连接空闲超时,超过时间就断开。

新手可以先不改,但心里要清楚:把端口监听这样裸露地交给默认函数,只适合本地学习。我自己的习惯是,只要服务打算让别人访问,第一件事就是把ReadHeaderTimeout和WriteTimeout设置上,成本极低,收益很高。

6.2 让服务器在Ctrl+C时优雅退出

直接用Ctrl+C结束进程,听起来没问题,但如果当时还有正在处理的请求,进程会被粗暴打断,客户端那边得到的是一个不完整响应。

优雅退出的核心思路是:收到退出信号后,停止接收新请求,同时给已有请求一个合理的时间窗口把响应写完,然后再关闭。代码可以这样写:

srv := &http.Server{Addr: ":8080", Handler: http.DefaultServeMux} go func() { if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("server error: %v", err) } }() quit := make(chan os.Signal, 1) signal.Notify(quit, os.Interrupt) <-quit ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := srv.Shutdown(ctx); err != nil { log.Fatal("forced shutdown: ", err) } log.Println("server stopped gracefully")

代码看起来多,但机制很简单:signal.Notify监听系统的中断信号;<-quit会一直阻塞,直到你按下Ctrl+C;收到信号后调用Shutdown,它只会停止接收新连接,而已经开始的请求会继续处理。5 秒后如果还没处理完,才会被强制关闭。

这一步我可以明确告诉你:以后用 gin、echo 这些框架,你会发现它们的底层服务器本质上就是http.Server,学会了这套退出逻辑,遇到更复杂的场景也不会慌。

6.3 用中间件给所有接口加耗时统计

最后一个进阶点,是 Go 后端里非常核心的“包装”模式,也就是中间件。它的作用很像电梯里的监控:每个乘客进出电梯都会经过它,它只负责拍照和计时,不影响乘客本来要去的楼层。

下面这段代码定义了一个包装函数,它接收一个http.Handler,返回一个新的http.Handler:

func loggingMiddleware(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 took %s", r.Method, r.URL.Path, time.Since(start)) }) }

然后这样启动:

http.HandleFunc("/hello/", helloNameHandler) http.HandleFunc("/user", userHandler) log.Fatal(http.ListenAndServe(":8080", loggingMiddleware(http.DefaultServeMux)))

loggingMiddleware在调用真实处理函数之前记录开始时间,处理结束后再计算耗时并打印。以后你接口数量变多,想统一加日志、加统计、加权限控制,都是用这种“包一层”的模式写的。理解了这个,你就不再只是会跑官方示例,而是已经开始理解 Go 标准库里的接口设计思想。

写完这个小服务器之后,我希望你做的第一件事不是急着学框架,而是先把自己刚写出来的接口多调用几次,观察不同路径、不同请求方式下返回什么。我最初练 Go 时就是这么过来的:第一周反复改一个 main.go,看各种请求头和响应头,后来接触框架时,发现它们只是在标准库外面帮你省掉了重复劳动。如果你碰到奇怪的问题,最好的帮手不是顺手搜答案,而是先打印日志,一步步缩小范围。动手改,动手跑,动手试,这个服务器才会真正变成你自己的一部分。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询