在维基百科中Web开发的定义是:Web开发是为互联网(World Wide Web
)或者内联网(专用网络)开发网站,从单一的静态页面(纯文本)到复杂的网络应用程序都在网站开发的范围内。
Go可以用于开发前端和后端,在编写Web应用中前端使用html/template
包来处理HTML模板,后端使用net/http
包实现。
本文只记录后端相关的Web实现部分。
net/http包
net/http包提供了HTTP协议的客户端和服务端的实现。大部分时候用的是服务端的实现,但是当请求其他服务端的接口时,也需要用到客户端的实现。
服务端
一个简单的返回hello world!
文本的例子:
go
package main
import (
"fmt"
"log"
"net/http"
)
func main() {
http.Handle("/hello", new(helloHandler))
http.HandleFunc("/hello/v1", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "hello world!!\n")
})
log.Fatal(http.ListenAndServe(":8080", nil))
}
type helloHandler struct{}
func (h *helloHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "hello world!\n")
}
go
func ListenAndServe(addr string, handler Handler) error
ListenAndServe
监听TCP
网络地址addr
,有连接传入的时候,用带有handler
的Serve
来处理请求。
handler
默认是空的,默认为空时会使用DefaultServeMux
,Handle
和HandleFunc
会向DefaultServeMux
添加处理程序。
DefaultServeMux
的类型是ServeMux
,ServeMux
是一个HTTP请求的多路复用器(路由器),它会将每个传入的请求的URL和已经注册的模式进行匹配,并且调用和URL最为匹配的模式对应的处理程序。
当请求接口http://localhost:8080/hello
时,会找到匹配的模式"/hello"
而不是/hello/v1
,然后使用模式"/hello"
对应的处理程序,来处理这个接口。
go
func Handle(pattern string, handler Handler)
Handle
在DefaultServeMux
中,为给定模式注册处理程序。这里的handler
需要满足接口类型Handler
:
go
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
go
func HandleFunc(pattern string, handler func(ResponseWriter, *Request))
HandleFunc
也是在DefaultServeMux
中,为给定模式注册处理程序,直接传入类型为func(ResponseWriter, *Request)
的函数。
在终端执行curl
指令请求接口查看结果:
shell
$ curl http://localhost:8080/hello
hello world!
$ curl http://localhost:8080/hello/v1
hello world!!
客户端
可以找一个地址替换一下代码中的地址,查看返回的响应数据。
go
func requestData() {
resp, err := http.Get("https://www.example.com/")
if err != nil {
panic(err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
fmt.Println(string(body), "===body===")
if err != nil {
panic(err)
}
}
httprouter包
httprouter是一个轻量的、快速的、惯用的路由器,用于在Go中构建HTTP服务。
假如需要新增一个POST
方法的"/hello"
接口,如果使用默认的路由器,需要写成这样:
go
func main() {
http.Handle("/hello", new(helloHandler))
log.Fatal(http.ListenAndServe(":8080", nil))
}
type helloHandler struct{}
func (h *helloHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
method := r.Method
switch method {
case "GET":
fmt.Fprintf(w, "hello world!\n")
case "POST":
fmt.Fprintf(w, "hello world!!!\n")
}
}
请求的结果:
shell
$ curl http://localhost:8080/hello
hello world!
$ curl -X POST http://localhost:8080/hello
hello world!!!
目前使用的例子是非常简单的例子,基本不包含任何逻辑,如果是真正的需要处理业务的接口,代码的耦合度会比较高,使用httprouter
能降低耦合度。
go
package main
import (
"fmt"
"io"
"log"
"net/http"
"github.com/nahojer/httprouter"
)
func main() {
r := httprouter.New()
r.Handle(http.MethodGet, "/hello", hello1)
r.Handle(http.MethodPost, "/hello", hello2)
log.Fatal(http.ListenAndServe(":8080", r))
}
func hello1(w http.ResponseWriter, r *http.Request) error {
fmt.Fprintf(w, "hello world!\n")
return nil
}
func hello2(w http.ResponseWriter, r *http.Request) error {
fmt.Fprintf(w, "hello world!!!\n")
return nil
}
请求接口:
shell
$ curl http://localhost:8080/hello
hello world!
$ curl -X POST http://localhost:8080/hello
hello world!!!
gin框架
gin是一个用Go语言编写的HTTP的Web框架,高性能并且用法简单。
上面的例子用gin
来写是这样的:
go
package main
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
)
func main() {
r := gin.Default()
r.GET("/hello", helloA)
r.POST("/hello", helloB)
r.Run()
}
func helloA(c *gin.Context) {
c.String(http.StatusOK, "hello world!")
}
func helloB(c *gin.Context) {
c.String(http.StatusOK, "hello world!!!")
}
在Gin的示例中选择了几个进行练习:
将请求携带的数据绑定到Go对象
query string
是GET
请求携带的数据(是URL
的一部分),post data
是POST
请求携带的数据(在请求体中)。拿到请求中携带的数据,并绑定到Go
对象上,以供后续使用。
go
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
func main() {
r := gin.Default()
r.GET("/echo", echo)
r.POST("/echo", echo)
r.Run()
}
type Person struct {
Name string `form:"name" json:"name"`
Address string `form:"address" json:"address"`
}
func echo(c *gin.Context) {
var person Person
if c.ShouldBind(&person) == nil {
c.JSON(http.StatusOK, gin.H{
"data": person,
})
return
}
c.String(http.StatusInternalServerError, "Failed")
}
c.ShouldBind
方法会根据请求的方法和Content-Type
选择一个绑定方式,比如"application/json"
使用JSON
的绑定方式,"application/xml"
就使用XML
的绑定方式。
func (c *Context) ShouldBind(obj any) error
会将请求携带的数据解析为JSON输入,然后解码JSON数据到结构体中,如果返回的error
为空,说明绑定成功。
func (c *Context) Bind(obj any) error
和ShouldBind
方法一样,也用于绑定数据,但是当输入数据无效的时候,c.Bind
会返回400错误,并将响应这种的Content-Type
头设置为text/plain
,而c.ShouldBind
不会。 请求接口:
shell
$ curl -i -X GET localhost:8080/echo?name=孙悟空&address=花果山水帘洞
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Thu, 14 Dec 2023 13:32:03 GMT
Content-Length: 60
{"data":{"name":"孙悟空","address":"花果山水帘洞"}}%
shell
$ curl -X POST -d '{"name": "孙悟空", "address": "花果山水帘洞"}' localhost:8080/echo \
> -H "Content-Type:application/json"
{"data":{"name":"孙悟空","address":"花果山水帘洞"}}%
(终端打印的内容后面有个百分号,应该是我用了zsh
的原因,打印出的内容后面如果没有空行,就会打出一个%作为提示,之后的打印内容中其实还是有这个%号的,我会手动在文章中把这个百分号去掉)
将路径中的参数绑定到Go对象
将路径localhost:8080/名称/id
中的名称和id
的部分绑定到Go对象上:
shell
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
func main() {
r := gin.Default()
r.GET("/:name/:id", echo)
r.Run()
}
type Person struct {
ID string `uri:"id" binding:"required,uuid" json:"id"`
Name string `uri:"name" binding:"required" json:"name"`
}
func echo(c *gin.Context) {
var person Person
if err := c.ShouldBindUri(&person); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"msg": err,
})
return
}
c.JSON(http.StatusOK, gin.H{
"data": person,
})
}
这里使用的路径是"/:name/:id"
,将路径中的数据绑定到Go对象使用的方法是c.ShouldBindUri
。在结构体类型声明的时候,声明了uri
相关的标签内容uri:"id"
,通过binding:"required,uuid"
声明这个字段是必须的,并且是uuid
格式。
shell
curl -v localhost:8080/孙悟空/987fbc97-4bed-5078-9f07-9141ba07c9f3
* Trying 127.0.0.1:8080...
* Connected to localhost (127.0.0.1) port 8080 (#0)
> GET /%e5%ad%99%e6%82%9f%e7%a9%ba/987fbc97-4bed-5078-9f07-9141ba07c9f3 HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.88.1
> Accept: */*
>
< HTTP/1.1 200 OK
< Content-Type: application/json; charset=utf-8
< Date: Fri, 15 Dec 2023 03:13:17 GMT
< Content-Length: 73
<
* Connection #0 to host localhost left intact
{"data":{"id":"987fbc97-4bed-5078-9f07-9141ba07c9f3","name":"孙悟空"}}
这里localhost:8080
的部分也可以写成http://0.0.0.0:8080
或者http://localhost:8080
。
在路径中少传递一个参数都无法匹配上路径:
shell
$ curl localhost:8080/孙悟空
404 page not found
路由分组
一些路径是处理同一类事务的,路径的前面部分是一致的,如果写成下面这样,当需要修改user
这个名称的时候,需要改动的地方较多,并且每次新增接口都需要重复写user
。
go
r.POST("/user/login", login)
r.POST("/user/submit", submit)
r.POST("/user/read", read)
写成下面这样会比较方便一些:
go
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
func main() {
r := gin.Default()
user := r.Group("/user")
{
user.POST("/login", login)
user.POST("/submit", submit)
user.POST("/read", read)
}
r.Run()
}
func login(c *gin.Context) {
c.String(http.StatusOK, "登录")
}
func submit(c *gin.Context) {
c.String(http.StatusOK, "提交")
}
func read(c *gin.Context) {
c.String(http.StatusOK, "读取")
}
user := r.Group("/user")
后面那对大括号的作用是直观地表示代码块,在大括号里面的代码是一组的。
请求接口:
shell
$ curl -X POST localhost:8080/user/login
登录
$ curl -X POST localhost:8080/user/submit
提交
$ curl -X POST localhost:8080/user/read
读取
自定义HTTP配置
在之前的代码中,通过r := gin.Default() r.Run()
来启动服务,里面会有一些默认的处理,比如使用8080
作为端口号。gin
提供了一些可以自定义的配置,比如设置超时时间,监听的地址之类的。
go
package main
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
)
func main() {
r := gin.Default()
user := r.Group("/user")
{
user.POST("/login", login)
user.POST("/submit", submit)
user.POST("/read", read)
}
s := &http.Server{
Addr: ":8000", // 服务监听的TCP地址
Handler: r, // 路由处理程序
ReadTimeout: 10 * time.Second, // 读整个请求的最大持续时间
WriteTimeout: 10 * time.Second, // 写响应的最大持续时间
MaxHeaderBytes: 1 << 20, // 请求头的最大字节数
}
s.ListenAndServe()
}
从代码中跳到http.Server里,能看到每个可配置字段的详细解释。
自定义中间件
中间件用于实现在主要应用程序逻辑之外的一些功能,使用中间件能拦截并且修改HTTP
请求和响应,一些通用的功能就常常用中间件实现。
上面的代码中执行gin.Default()
方法创建了一个默认的路由器,默认配置了Logger
和Recovery
中间件,用于打印日志和捕获异常:
go
// Default returns an Engine instance with the Logger and Recovery middleware already attached.
func Default() *Engine {
debugPrintWARNINGDefault()
engine := New()
engine.Use(Logger(), Recovery())
return engine
}
如果使用gin
框架的话,返回gin.HandlerFunc
类型的函数就是一个中间件。
go
package main
import (
"log"
"time"
"github.com/gin-gonic/gin"
)
func main() {
r := gin.New()
r.Use(Logger())
r.GET("/test", func(c *gin.Context) {
example := c.MustGet("example").(string)
log.Println(example)
})
r.Run(":8080")
}
// 自定义的日志中间件
func Logger() gin.HandlerFunc {
return func(c *gin.Context) {
t := time.Now()
// 在中间件中设置example变量,这个变量可以在请求处理函数中使用
c.Set("example", "12345")
log.Println("===AAA===")
// 以c.Next为分界线,前面是请求前,后面是请求后
c.Next()
log.Println("===BBB===")
latency := time.Since(t)
log.Printf("处理请求耗费时间 %v", latency)
status := c.Writer.Status()
log.Println(status)
}
}
gin.New()
和gin.Default()
的区别是,gin.New()
返回的是一个没有装载任何中间件的空的实例,而gin.Default()
返回的是装载了Logger
和Recovery
中间件的实例。
在另一个终端执行curl localhost:8080/test
请求接口,打印出的日志为:
shell
2023/12/15 15:26:58 ===AAA===
2023/12/15 15:26:58 12345
2023/12/15 15:26:58 ===BBB===
2023/12/15 15:26:58 处理请求耗费时间 233.188µs
2023/12/15 15:26:58 200
中间件实际上是一个函数调用链,比如如下代码:
go
package main
import (
"log"
"time"
"github.com/gin-gonic/gin"
)
func main() {
r := gin.New()
r.Use(Logger(), Logger1(), Logger2())
r.GET("/test", func(c *gin.Context) {
example := c.MustGet("example").(string)
log.Println(example)
})
r.Run(":8080")
}
// 自定义的日志中间件
func Logger() gin.HandlerFunc {
return func(c *gin.Context) {
t := time.Now()
// 在中间件中设置example变量,这个变量可以在请求处理函数中使用
c.Set("example", "12345")
log.Println("===AAA-before===")
// 以c.Next为分界线,前面是请求前,后面是请求后
c.Next()
log.Println("===AAA-after===")
latency := time.Since(t)
log.Printf("花费时间 %v", latency)
status := c.Writer.Status()
log.Println(status)
}
}
func Logger1() gin.HandlerFunc {
return func(c *gin.Context) {
t := time.Now()
// 在中间件中设置example变量,这个变量可以在请求处理函数中使用
c.Set("example", "12345")
log.Println("===BBB-before===")
// 以c.Next为分界线,前面是请求前,后面是请求后
c.Next()
log.Println("===BBB-after===")
latency := time.Since(t)
log.Printf("花费时间 %v", latency)
status := c.Writer.Status()
log.Println(status)
}
}
func Logger2() gin.HandlerFunc {
return func(c *gin.Context) {
t := time.Now()
// 在中间件中设置example变量,这个变量可以在请求处理函数中使用
c.Set("example", "12345")
log.Println("===CCC-before===")
// 以c.Next为分界线,前面是请求前,后面是请求后
c.Next()
log.Println("===CCC-after===")
latency := time.Since(t)
log.Printf("花费时间 %v", latency)
status := c.Writer.Status()
log.Println(status)
}
}
执行curl localhost:8080/test
之后,打印的日志是这样的:
shell
2023/12/15 15:30:56 ===AAA-before===
2023/12/15 15:30:56 ===BBB-before===
2023/12/15 15:30:56 ===CCC-before===
2023/12/15 15:30:56 12345
2023/12/15 15:30:56 ===CCC-after===
2023/12/15 15:30:56 花费时间 10.38µs
2023/12/15 15:30:56 200
2023/12/15 15:30:56 ===BBB-after===
2023/12/15 15:30:56 花费时间 37.792µs
2023/12/15 15:30:56 200
2023/12/15 15:30:56 ===AAA-after===
2023/12/15 15:30:56 花费时间 257.379µs
2023/12/15 15:30:56 200
函数调用链是这样的:
文件上传
单文件上传
通过FormFile
方法获取到文件数据,用SaveUploadedFile
将文件存储到指定位置。
go
package main
import (
"fmt"
"log"
"net/http"
"github.com/gin-gonic/gin"
)
func main() {
r := gin.Default()
r.MaxMultipartMemory = 8 << 20 // 限制 multipart/form-data 的请求的请求体最大为8M
r.POST("/upload", func(c *gin.Context) {
file, _ := c.FormFile("file")
log.Println(file.Filename)
// 将文件上传到当前目录的files文件夹下
c.SaveUploadedFile(file, "./files/"+file.Filename)
c.String(http.StatusOK, fmt.Sprintf("'%s' uploaded!", file.Filename))
})
r.Run()
}
请求接口:
shell
curl -X POST localhost:8080/upload \
> -F "file=@/Users/path/上传用的文件.txt" \
> -H "Content-Type: multipart/form-data"
'上传用的文件.txt' uploaded!
多文件上传
使用MultipartForm()
方法先解析表单数据,然后从解析后的表单数据中拿到文件数据form.File["upload"]
,遍历文件数据,使用SaveUploadedFile
将文件存储到指定位置:
go
package main
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
)
func main() {
r := gin.Default()
r.MaxMultipartMemory = 8 << 20
r.POST("/upload", func(c *gin.Context) {
form, _ := c.MultipartForm()
files := form.File["upload"]
for _, file := range files {
c.SaveUploadedFile(file, "./files/"+file.Filename)
}
c.String(http.StatusOK, fmt.Sprintf("%d files uploaded!", len(files)))
})
r.Run()
}
请求接口:
shell
$ curl -X POST localhost:8080/upload \
> -F "upload=@/Users/path/上传用的文件.txt" \
> -F "upload=@/Users/path/上传用的文件1.txt" \
> -H "Content-Type: multipart/form-data"
2 files uploaded!
(使用-F
会默认使用请求头"Content-Type: multipart/form-data"
,这里不用写-H
那句也行)
使用air实时加载项目
安装air
:
shell
$ go get -u github.com/cosmtrek/air
在项目的根目录创建一个.air.toml
文件,内容如下:
toml
root = "."
testdata_dir = "testdata"
tmp_dir = "tmp"
[build]
args_bin = []
bin = "./tmp/main"
cmd = "go build -o ./tmp/main ."
delay = 0
exclude_dir = ["assets", "tmp", "vendor", "testdata"]
exclude_file = []
exclude_regex = ["_test.go"]
exclude_unchanged = false
follow_symlink = false
full_bin = ""
include_dir = []
include_ext = ["go", "tpl", "tmpl", "html"]
include_file = []
kill_delay = "0s"
log = "build-errors.log"
poll = false
poll_interval = 0
rerun = false
rerun_delay = 500
send_interrupt = false
stop_on_error = false
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
main_only = false
time = false
[misc]
clean_on_exit = false
[screen]
clear_on_rebuild = false
keep_scroll = true
然后在项目根目录下使用air
启动项目:
shell
$ air
__ _ ___
/ /\ | | | |_)
/_/--\ |_| |_| _ , built with Go
mkdir /Users/renmo/projects/my-blog/2023-12-14-go-web/tmp
watching .
watching files
!exclude tmp
!exclude vendor
...
这样当代码有修改的时候,就会自动重新启动服务了,不用每次都手动停止服务然后执行go run .
,或者点击编辑器的运行按钮重新启动。(我个人还是更喜欢手动用编辑器的按钮重启,而不是用air
自动重启)