Go Web开发

维基百科中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")
}
  1. ListenAndServe
go 复制代码
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
go 复制代码
func Handle(pattern string, handler Handler)

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

go 复制代码
type Handler interface {
  ServeHTTP(ResponseWriter, *Request)
}
  1. HandleFunc
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 stringGET请求携带的数据(是URL的一部分),post dataPOST请求携带的数据(在请求体中)。拿到请求中携带的数据,并绑定到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) errorShouldBind方法一样,也用于绑定数据,但是当输入数据无效的时候,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()方法创建了一个默认的路由器,默认配置了LoggerRecovery中间件,用于打印日志和捕获异常:

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

在另一个终端执行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自动重启)

相关推荐
摇滚侠2 小时前
Spring Boot 3零基础教程,IOC容器中组件的注册,笔记08
spring boot·笔记·后端
程序员小凯5 小时前
Spring Boot测试框架详解
java·spring boot·后端
你的人类朋友5 小时前
什么是断言?
前端·后端·安全
程序员小凯6 小时前
Spring Boot缓存机制详解
spring boot·后端·缓存
i学长的猫7 小时前
Ruby on Rails 从0 开始入门到进阶到高级 - 10分钟速通版
后端·ruby on rails·ruby
用户21411832636027 小时前
别再为 Claude 付费!Codex + 免费模型 + cc-switch,多场景 AI 编程全搞定
后端
茯苓gao7 小时前
Django网站开发记录(一)配置Mniconda,Python虚拟环境,配置Django
后端·python·django
Cherry Zack7 小时前
Django视图进阶:快捷函数、装饰器与请求响应
后端·python·django
爱读源码的大都督8 小时前
为什么有了HTTP,还需要gPRC?
java·后端·架构
码事漫谈8 小时前
致软件新手的第一个项目指南:阶段、文档与破局之道
后端