likes
comments
collection
share

Go Web开发

作者站长头像
站长
· 阅读数 4

维基百科中Web开发的定义是:Web开发是为互联网(World Wide Web)或者内联网(专用网络)开发网站,从单一的静态页面(纯文本)到复杂的网络应用程序都在网站开发的范围内。

Go可以用于开发前端和后端,在编写Web应用中前端使用html/template包来处理HTML模板,后端使用net/http包实现。

本文只记录后端相关的Web实现部分。

net/http包

net/http包提供了HTTP协议的客户端和服务端的实现。大部分时候用的是服务端的实现,但是当请求其他服务端的接口时,也需要用到客户端的实现。

服务端

一个简单的返回hello world!文本的例子:

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")
}
  1. ListenAndServe
func ListenAndServe(addr string, handler Handler) error

ListenAndServe监听TCP网络地址addr,有连接传入的时候,用带有handlerServe来处理请求。

handler默认是空的,默认为空时会使用DefaultServeMuxHandleHandleFunc会向DefaultServeMux添加处理程序。

DefaultServeMux的类型是ServeMuxServeMux是一个HTTP请求的多路复用器(路由器),它会将每个传入的请求的URL和已经注册的模式进行匹配,并且调用和URL最为匹配的模式对应的处理程序。

当请求接口http://localhost:8080/hello时,会找到匹配的模式"/hello"而不是/hello/v1,然后使用模式"/hello"对应的处理程序,来处理这个接口。

  1. Handle
func Handle(pattern string, handler Handler)

HandleDefaultServeMux中,为给定模式注册处理程序。这里的handler需要满足接口类型Handler

type Handler interface {
  ServeHTTP(ResponseWriter, *Request)
}
  1. HandleFunc
func HandleFunc(pattern string, handler func(ResponseWriter, *Request))

HandleFunc也是在DefaultServeMux中,为给定模式注册处理程序,直接传入类型为func(ResponseWriter, *Request)的函数。

在终端执行curl指令请求接口查看结果:

$ curl http://localhost:8080/hello
hello world!
$ curl http://localhost:8080/hello/v1
hello world!!

客户端

可以找一个地址替换一下代码中的地址,查看返回的响应数据。

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"接口,如果使用默认的路由器,需要写成这样:

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")
  }
}

请求的结果:

$ curl http://localhost:8080/hello   
hello world!
$ curl -X POST http://localhost:8080/hello
hello world!!!

目前使用的例子是非常简单的例子,基本不包含任何逻辑,如果是真正的需要处理业务的接口,代码的耦合度会比较高,使用httprouter能降低耦合度。

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
}

请求接口:

$ curl http://localhost:8080/hello   
hello world!
$ curl -X POST http://localhost:8080/hello
hello world!!!

gin框架

gin是一个用Go语言编写的HTTP的Web框架,高性能并且用法简单。

上面的例子用gin来写是这样的:

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 stringGET请求携带的数据(是URL的一部分),post dataPOST请求携带的数据(在请求体中)。拿到请求中携带的数据,并绑定到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) errorShouldBind方法一样,也用于绑定数据,但是当输入数据无效的时候,c.Bind会返回400错误,并将响应这种的Content-Type头设置为text/plain,而c.ShouldBind不会。 请求接口:

$ 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":"花果山水帘洞"}}%                                           
$ curl -X POST -d '{"name": "孙悟空", "address": "花果山水帘洞"}' localhost:8080/echo \
>  -H "Content-Type:application/json"
{"data":{"name":"孙悟空","address":"花果山水帘洞"}}% 

(终端打印的内容后面有个百分号,应该是我用了zsh的原因,打印出的内容后面如果没有空行,就会打出一个%作为提示,之后的打印内容中其实还是有这个%号的,我会手动在文章中把这个百分号去掉)

将路径中的参数绑定到Go对象

将路径localhost:8080/名称/id中的名称id的部分绑定到Go对象上:

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格式。

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

在路径中少传递一个参数都无法匹配上路径:

$ curl localhost:8080/孙悟空
404 page not found             

路由分组

一些路径是处理同一类事务的,路径的前面部分是一致的,如果写成下面这样,当需要修改user这个名称的时候,需要改动的地方较多,并且每次新增接口都需要重复写user

r.POST("/user/login", login)
r.POST("/user/submit", submit)
r.POST("/user/read", read)

写成下面这样会比较方便一些:

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")后面那对大括号的作用是直观地表示代码块,在大括号里面的代码是一组的。

请求接口:

$ 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提供了一些可以自定义的配置,比如设置超时时间,监听的地址之类的。

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()方法创建了一个默认的路由器,默认配置了LoggerRecovery中间件,用于打印日志和捕获异常:

// 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类型的函数就是一个中间件。

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()返回的是装载了LoggerRecovery中间件的实例。

在另一个终端执行curl localhost:8080/test请求接口,打印出的日志为:

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

中间件实际上是一个函数调用链,比如如下代码:

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之后,打印的日志是这样的:

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

函数调用链是这样的:

Go Web开发

文件上传

单文件上传

通过FormFile方法获取到文件数据,用SaveUploadedFile将文件存储到指定位置。

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()
}

请求接口:

curl -X POST localhost:8080/upload \ 
> -F "file=@/Users/path/上传用的文件.txt" \
> -H "Content-Type: multipart/form-data"
'上传用的文件.txt' uploaded! 

多文件上传

使用MultipartForm()方法先解析表单数据,然后从解析后的表单数据中拿到文件数据form.File["upload"],遍历文件数据,使用SaveUploadedFile将文件存储到指定位置:

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()
}

请求接口:

$ 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

$ go get -u github.com/cosmtrek/air

在项目的根目录创建一个.air.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启动项目:

$ 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自动重启)