楔子
这次我们来看一下Go的Web编程,Go的生态里面也出现了很多优秀的Web框架,比如:Gin、Beego等等,但是这里我们使用的是标准库net/http。虽然它是一个标准库,但是代码本身质量非常的高,即便是使用这个内置的库也依旧可以让你实现很高的并发量,下面我们就来看看吧。
快速入门
接下来我们编写一个服务返回一个字符串,看看Go语言如何实现。
package main
import (
"fmt"
"log"
"net/http"
)
func hello(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "你好呀, 夏色祭")
}
func main() {
http.HandleFunc("/", hello)
if err := http.ListenAndServe("localhost:8889", nil); err != nil {
log.Fatal(err)
}
}
然后我们执行文件,一个服务就启动了;然后打开浏览器,输入localhost:8889 ,一个字符串就显示在网页上了。
下面我们来解释一下上面那段程序都做了些什么?
http.HandleFunc 将函数 hello 注册到 路径/ 上面;hello 函数我们也叫做处理函数(在其它语言中也叫做视图函数),它接收两个参数:
第一个参数是类型为http.ResponseWriter的接口, 响应就是通过它发送给客户端的;
第二个参数是类型为http.Request的结构体指针,客户端发送的信息都可以通过它来获取;
http.ListenAndServer 表示在指定的端口上监听请求,一旦有请求过来便会进行路由匹配,不同的路由交由不同的处理函数进行处理。
多路复用器
一个典型的 Go Web 程序结构如下:
- 客户端发送请求;
- 服务端的多路复用器收到请求;
- 多路复用器根据请求的URL找到注册的处理函数,将请求交由处理函数处理;
- 处理函数执行任务逻辑,必要时和数据库进行交互,得到处理结果;
- 处理函数调用模板引擎将指定的模板和上一步得到的结果进行组合,渲染成客户端可识别的数据格式(通常是HTML);
- 最后将数据通过响应返回给客户端;
- 客户端拿到数据,执行对应的操作,例如渲染出来呈现给用户;
net/http包内置了一个默认的多路复用器:DefaultServeMux,定义如下:
// src/net/http/server.go
// DefaultServeMux is the default ServeMux used by Serve.
var DefaultServeMux = &defaultServeMux
var defaultServeMux ServeMux
net/http 包中很多方法都在内部调用DefaultServeMux 的对应方法,如HandleFunc 。我们知道,HandleFunc 是为指定的URL注册一个处理函数,其内部实现如下:
func HandleFunc(pattern string, handler func(ResponseWriter, *Request)) {
DefaultServeMux.HandleFunc(pattern, handler)
}
实际上,http.HandleFunc 方法是将处理函数注册到DefaultServeMux 中的,所以请求过来的时候会经过多路复用器,然后多路复用器根据URL找到对应处理函数;另外我们使用 "127.0.0.1:8889" 和 nil 做为参数调用 http.ListenAndServe 时,会创建一个默认的服务器:
func ListenAndServe(addr string, handler Handler) error {
// 传递监听的地址 和 handler, 创建一个服务器
// 我们之前传递的的 handler 是 nil, 因为会有一个默认的多路复用器: DefaultServeMux, 我们注册函数的时候也是注册到这里面的
server := &Server{Addr: addr, Handler: handler}
return server.ListenAndServe()
}
这个服务器默认使用 DefaultServeMux 来处理器请求:
type serverHandler struct {
srv *Server
}
func (sh serverHandler) ServeHTTP(rw ResponseWriter, req *Request) {
handler := sh.srv.Handler
if handler == nil {
handler = DefaultServeMux
}
handler.ServeHTTP(rw, req)
}
服务器收到的每个请求都会调用多路复用器(DefaultServeMux)的 ServeHTTP 方法,在该方法中会根据URL查找我们注册的处理器函数,然后将请求交给它处理。
虽然默认的多路复用器很方便,但是在生产中不建议使用。因为 DefaultServeMux 是一个全局变量,所有代码、包括第三方代码都可以修改它,有些第三方代码会在 DefaultServeMux 上注册一些处理器,这可能和我们注册的处理器冲突。
创建多路复用器
因此好的办法是我们自己创建多路复用器,而不是使用默认的,而创建的方式也很简单,直接调用 http.NewServeMux 即可,然后在新创建的多路复用器上注册处理器。
package main
import (
"fmt"
"log"
"net/http"
)
func hello(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "你好呀, 夏色祭")
}
func main() {
// 创建新的多路复用器, 叫mux
mux := http.NewServeMux()
// 将路由和处理函数注册到我们自己创建的多路复用器中
// 而且使用的是mux.HandleFunc, 要是http.HandleFunc的话会注册到全局默认的多路复用器DefaultServeMux中
mux.HandleFunc("/", hello)
// 创建服务器, 默认创建的服务器使用DefaultServeMux这个多路复用器
// 这里我们自己创建服务器, 并将多路复用器指定为我们自己创建的mux
server := &http.Server{
Addr: "localhost:8889",
Handler: mux,
}
// 这里不通过http.ListenAndServe, 那样的话会启动默认的服务器
// 这里使用server.ListenAndServe, 调用我们创建的服务器开启监听
if err := server.ListenAndServe(); err != nil {
log.Fatal(err)
}
}
然后启动服务,依旧是可以正常访问的,并且我们在创建服务器的时候还可以定制化更多的参数,比如:
server := &http.Server{
Addr: ":8080",
Handler: mux,
ReadTimeout: 1 * time.Second,
WriteTimeout: 1 * time.Second,
}
上面便指定了一个读超时和写超时均为1s的服务器,当然这里面的参数远不止这些,有兴趣可以自己去看看。
处理器和处理器函数
处理器和处理器函数之间有什么区别呢?我们说在多路复用器会根据URL找到对应的处理器函数,那么处理器是什么呢?其实,处理器不过是一个接口罢了,我们来看一下:
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
这个接口里面只有一个函数,我们看一下这个函数,参数是不是和我们的处理器函数是一个样子呢?没错,我们看一下我们在注册路由和处理器函数的时候都做了些什么就知道了。
// 我们以http.HandleFunc为例, 当然我们自己创建的多路复用器也是一样的
func HandleFunc(pattern string, handler func(ResponseWriter, *Request)) {
// 接收一个 pattern 和 一个处理器函数
// 注册到多路复用器中
DefaultServeMux.HandleFunc(pattern, handler)
}
// 这里的ServeMux就是全局的多路复用器
func (mux *ServeMux) HandleFunc(pattern string, handler func(ResponseWriter, *Request)) {
// pattern就是路由, handler就是处理函数
if handler == nil {
panic("http: nil handler")
}
// 重点来了, mux.Handler就是具体的注册逻辑了, 不过我们不关心
// 我们重点关注一下这个 HandlerFunc, 显然这是将我们定义的处理函数转成了HandlerFunc类型
mux.Handle(pattern, HandlerFunc(handler))
}
// 我们看到这个 HandlerFunc 只不过是一个函数类型的别名罢了, 而且这个函数的格式和我们的处理函数是一样的
// 有人觉得这不是脱裤子放屁吗? 别急, 往下看
type HandlerFunc func(ResponseWriter, *Request)
// 然后这个HandlerFunc实现了一个方法, 注意: 不光结构体可以实现方法, 只要是通过type声明的类型都是可以的
// 我们看到它实现了一个ServeHTTP方法, 但是调用的时候执行的是函数f, 显然这里的f就是我们的处理函数
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
f(w, r)
}
所以我们看到了,绕了这么一圈,最终执行的还是我们自己定义的处理函数。只不过它是将我们定义的处理函数使用 HandlerFunc 包装了一下,并且实现了一个 ServeHTTP方法。这样就实现了上面的接口Handler,然后通过Handler调用ServeHTTP,也就是HandlerFunc调用ServeHTTP,最终调用的是我们定义的处理函数。
而实现接口Handler的类型,其对象就是处理器,而最终执行的函数就是处理函数。
所以这便是Go语言中的接口,定义一个接口,不管多少个处理函数,只要实现了接口中的函数,我们就可以通过接口进行调用。而实现了接口的对象,我们需要通过Handle方法注册, 而不是HandleFunc:
// 我们看到Handle的第二个参数接收的是一个Handler, 我们说它是一个接口, 只要实现了ServeHTTP的对象都可以传递给它
func (mux *ServeMux) Handle(pattern string, handler Handler) {
}
// 而HandleFunc本质上还是调用了Handle, 只不过第二个参数不一样
func (mux *ServeMux) HandleFunc(pattern string, handler func(ResponseWriter, *Request)) {
if handler == nil {
panic("http: nil handler")
}
// HandleFunc的第二个参数传入一个处理函数即可, 会自动使用HandlerFunc帮你包装, 而HandlerFunc实现了ServeHTTP方法
mux.Handle(pattern, HandlerFunc(handler))
}
我们程序中演示一下,就通过结构体的方式吧:
package main
import (
"fmt"
"log"
"net/http"
)
type Girl struct {
handlerFunc func (http.ResponseWriter, *http.Request)
}
func (g *Girl) ServeHTTP(w http.ResponseWriter, r *http.Request) {
g.handlerFunc(w, r)
}
func hello(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "你好呀, 夏色祭")
}
func main() {
mux := http.NewServeMux()
// 这里使用Handle注册, 然后将hello函数传递到Girl里面创建结构体, 因为Girl这个结构体实现了ServeHTTP方法
mux.Handle("/", &Girl{hello})
server := &http.Server{
Addr: "localhost:8889",
Handler: mux,
}
if err := server.ListenAndServe(); err != nil {
log.Fatal(err)
}
}
依旧可以正常访问的,怎么样是不是很简单呢。
这里不得不提一句,我本人既用Python、也用Go。个人的感觉是Go语言确实比Python语言要简单,虽然Go是编译型语言,但它确实很容易。
而且个人感受最深的就是查看源码的时候,你使用Goland这样的IDE(Jetbrains公司针对的其它语言的IDE也是同理),你都可以通过Ctrl加上鼠标左键跳转到对应的源码的指定位置进行查看。对于Go语言而言,不管程序多复杂,你都可以追根溯源进行跳转;而Python的话,你跳着跳着发现跳不动了,当然这不是对应的Python IDE做的不好,而是动态语言的特性使得你没办法这样。
所以没事可以多看看源码,尤其是Go标准库,写的非常的棒。比如这里的http库,虽然是标准库,但是代码质量很高,可以让你轻松获得很高的并发量。
另外,我们发现这里面的几个接口和方法名是不是很容易混淆呢?比如:Handler、Handle、HandleFunc、HandlerFunc,下面再来总结区分一下:
Handler: 处理器接口, 实现该接口的类型, 其对象可以注册到多路复用器中;
Handle: 注册处理器的方法;
HandleFunc: 注册处理器函数的方法;
HandlerFunc: 底层类型为 func (w ResponseWriter, r *Request) 的新类型, 实现了 Handler 接口, 它连接了处理器函数与处理器;
URL 匹配
⼀般的 Web 服务器会绑定非常多的 URL,不同的 URL 对应不同的处理器(处理函数)。但是服务器是怎么决定使用哪个处理器的呢?例如,我们现在绑定了 3 个 URL, /、 /hello 和 /hello/world。
显然:
如果请求的URL是/, 那么会调用/对应的处理器;
如果请求的URL是/hello, 那么会调用/hello对应的处理器;
如果请求的URL是/hello/world, 那么会调用/hello/world对应的处理器;
但是,如果请求的是 /hello/others ,那么使用哪一个处理器呢? 匹配遵循以下规则:
首先, 精确匹配; 即查找是否有/hello/others对应的处理器, 如果有, 则查找结束; 如果没有, 执行下一步;
将路径的最后一个部分去掉, 再次查找; 即查找/hello/对应的处理器, 如果有, 则查找结束; 如果没有, 继续执行下一步, 即查找/对应的处理器;
这里有一个注意点,如果注册的 URL 不是以 / 结尾的,那么它只能精确匹配请求的 URL。反之,即使请求的 URL 只有前缀与被绑定的 URL 相同, ServeMux 也认为它们是匹配的。
比如当路由是 /hello 时,那么我们请求 /hello 可以匹配,但是请求 /hello/ 则无法匹配,因为 /hello 不以 / 结尾,需要精确匹配才可以;同理当我们访问 /hello/others,那么在匹配不到时也会退而求其次寻找 /hello/,而不是/hello。
反之,如果我们绑定的URL为 /hello/,那么访问 /hello 和 /hello/ 都是可以得到返回的;当然在 /hello/others 匹配不到的时候,也会退而求其次寻找 /hello/。
所以总结一下:
URL为/hello, 访问时只能通过/hello, 不能通过/hello/; 同理/hello/others在无法匹配的时候会尝试匹配/hello/, 不会匹配匹配/hello;
URL为/hello/, 访问时既可以通过/hello/也可以通过/hello; 在/hello/others无法匹配的时候会匹配/hello/;
举个栗子:
package main
import (
"fmt"
"log"
"net/http"
)
func hello(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "你好呀, 夏色祭")
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/hello1", hello)
mux.HandleFunc("/hello2/", hello)
server := &http.Server{
Addr: "localhost:8889",
Handler: mux,
}
if err := server.ListenAndServe(); err != nil {
log.Fatal(err)
}
}
/hello1可以访问,/hello1/无法访问,/hello1/xxx无法访问;/hello2、/hello2/、/hello2/xxx、/hello2/xxx/xxx均可访问。
如果我们再给 / 定义一个处理函数,那么 /hello1/ 、/hello1/xxx、/hello1/xxx/xxx 都会执行 / 对应的处理函数,因为会不断去掉路径中的最后一个部分、向上查找。
http 请求
我们在处理函数中的第二个参数是 r *http.Request ,这个r是一个结构体,对应客户端的请求;客户端传递的数据都可以通过这个r来获取,我们看一下它的结构:
type Request struct {
Method string
URL *url.URL
Proto string // "HTTP/1.0"
ProtoMajor int // 1
ProtoMinor int // 0
Header Header
Body io.ReadCloser
GetBody func() (io.ReadCloser, error)
ContentLength int64
TransferEncoding []string
Close bool
Host string
Form url.Values
PostForm url.Values
MultipartForm *multipart.Form
Trailer Header
RemoteAddr string
RequestURI string
TLS *tls.ConnectionState
Cancel <-chan struct{}
Response *Response
ctx context.Context
}
解释一下里面的几个字段:
Method:
表示客户端调用的方法,比如:GET/POST/PUT/DELETE等等;服务端会根据不同的方法进行不同的处理, 例如GET方法是获取信息, POST方法创建新的资源等等
URL:
统一资源定位符。URL有两部分组成,一部分表示资源的名称,即:统一资源名称;另一部分表示资源的位置,即:统一资源定位符。
我们来看一下定义:
type URL struct {
Scheme string
Opaque string // encoded opaque data
User *Userinfo // username and password information
Host string // host or host:port
Path string // path (relative paths may omit leading slash)
RawPath string // encoded path hint (see EscapedPath method)
ForceQuery bool // append a query ('?') even if RawQuery is empty
RawQuery string // encoded query values, without '?'
Fragment string // fragment for references, without '#'
}
可以通过请求对象中的 URL 字段获取这些信息。
func urlHandler(w http.ResponseWriter, r *http.Request) {
URL := r.URL
fmt.Fprintf(w, "Scheme: %s\n", URL.Scheme)
fmt.Fprintf(w, "Host: %s\n", URL.Host)
fmt.Fprintf(w, "Path: %s\n", URL.Path)
fmt.Fprintf(w, "RawPath: %s\n", URL.RawPath)
fmt.Fprintf(w, "RawQuery: %s\n", URL.RawQuery)
fmt.Fprintf(w, "Fragment: %s\n", URL.Fragment)
}
此外我们还可以通过 URL 结构体得到一个 URL 字符串,以及解析出里面的查询参数:
URL := url.URL{
Scheme: "http",
Host: "example.com",
Path: "/posts",
RawQuery: "page=1&count=10",
Fragment: "main",
}
fmt.Println(URL.String()) // http://example.com/posts?page=1&count=10#main
// 返回的是一个map[string][]string
fmt.Println(URL.Query()) // map[count:[10] page:[1]]
Proto:
Proto 表示 HTTP 协议版本,如 HTTP/1.1 , ProtoMajor 表示大版本, ProtoMinor 表示小版本。
func protoFunc(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Proto: %s\n", r.Proto)
fmt.Fprintf(w, "ProtoMajor: %d\n", r.ProtoMajor)
fmt.Fprintf(w, "ProtoMinor: %d\n", r.ProtoMinor)
}
// 这里是在main函数中的, 但为了表达直观, 我直接写在外面了, 后面同理
// 当然你也可以自己随便定义一个路由
mux.HandleFunc("/proto", protoFunc)
访问对应路由会得到如下输出:
Proto: HTTP/1.1
ProtoMajor: 1
ProtoMinor: 1
Header:
Header 中存放的客户端发送过来的首部信息,键值对的形式。Header 类型底层其实是 map[string][]string :
type Header map[string][]string
注意到 Header 值为 []string 类型,存放相同的键的多个值。浏览器发起 HTTP 请求的时候,会自动添加一些首部。我们编写一个程序来看看:
func headerHandler(w http.ResponseWriter, r *http.Request) {
for key, value := range r.Header {
fmt.Fprintf(w, "%s: %v\n", key, value)
}
}
mux.HandleFunc("/header", headerHandler)
启动服务器,浏览器请求 localhost:8080/header 返回:
Accept-Enreading: [gzip, deflate, br]
Sec-Fetch-Site: [none]
Sec-Fetch-Mode: [navigate]
Connection: [keep-alive]
Upgrade-Insecure-Requests: [1]
User-Agent: [Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36(KHTML, like Gecko) Chrome/79.0.1904.108 Safari/537.36]
Sec-Fetch-User: [?1]
Accept:
[text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/
*;q=0.8,application/signed-exchange;v=b3]
Accept-Language: [zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7]
我当前使用的是 Chrome 浏览器,不同的浏览器添加的首部不完全相同,常见的首部有:
Accept: 客户端想要服务器发送的内容类型;
Accept-Charset: 表示客户端能接受的字符编码;
Content-Length: 请求主体的字节长度, 一般在POST/PUT请求中较多;
Content-Type: 当包含请求主体的时候, 这个首部用于记录主体内容的类型。在发送POST或PUT请求时, 内容的类型默认为x-www-form-urlencoded。但是在上传文件时, 应该设置类型为 multipart/form-data;
User-Agent: 用于描述发起请求的客户端信息, 比如浏览器的种类等等;
Content-Length/Body:
Content-Length 表示请求体的字节长度,请求体的内容可以从 Body 字段中读取。细心的小伙伴可能发现了 Body 字段是一个 io.ReadCloser 接口。在读取之后要关闭它,否则会有资源泄露。可以使用 defer 简化代码编写:
func bodyHandler(w http.ResponseWriter, r *http.Request) {
data := make([]byte, r.ContentLength)
r.Body.Read(data) // 忽略错误处理
defer r.Body.Close()
fmt.Fprintln(w, string(data))
}
mux.HandleFunc("/body", bodyHandler)
上面代码将客户端传来的请求体内容回传给客户端,当然还可以使用 io/ioutil 包简化读取操作:
data, _ := ioutil.ReadAll(r.Body)
直接在浏览器中输入 URL 发起的是 GET 请求,无法携带请求体。但是有很多种方式可以发起带请求体的请求,比如使用表单,一会说。关于 Body字段,我们后面也还会说。
Form:
使用 x-www-form-urlencoded 编码的请求体,在处理时首先调用请求的 ParseForm 方法解析,然后从 Form 字段中取数据:
func indexHandler(w http.ResponseWriter, r *http.Request) {
// 如果是GET请求, 那么输入表单
if r.Method == "GET" {
fmt.Fprint(w, `
<html>
<head>
<title>Go Web</title>
</head>
<body>
<form method="post" action="/body?a=123&b=456" enctype="application/x-www-form-urlencoded">
<label for="username">⽤户名:</label>
<input type="text" >
<label for="email">邮箱:</label>
<input type="text" >
<button type="submit">提交</button>
</form>
</body>
</html>
`)
} else {
// 否则是POST请求, 解析刚才在表单中输入的数据
r.ParseForm()
Form := r.Form
RawQuery := r.URL.RawQuery
fmt.Fprintf(w, "Form: %v\nRawQuery: %v\n", Form, RawQuery)
}
}
注意表单里面的action,里面自带了查询参数,这个参数会同时体现在URL和表单中。启动服务器,进入主页 localhost:8080/body,显示表单。填写完成后,点击提交。浏览器向服务器发送 POST 请求,URL 为 /body , bodyHandler 处理完成后将包体回传给客户端。上面的数据使用了 x-www-form-urlencoded 编码,这是表单的默认编码。
我们点击提交,看看结果:
我们看到表单实际上就是一个map,而且值是一个切片;另外查询参数,它除了体现在URL中,对于POST请求也会体现在表单中。
action 表示提交表单时请求的 URL, method 表示请求的方法。如果使用 GET 请求,由于 GET 方法没有请求体,参数将会拼接到 URL 尾部;
enctype 指定请求体的编码方式,默认为 application/x-www-form-urlencoded 。如果需要发 送文件,必须指定为 multipart/form-data;
PostForm:
如果一个请求,同时有 URL 键值对和表单数据,而用户只想获取表单数据,可以使用 PostForm 字段。 使用 PostForm 只会返回表单数据,不包括 URL 键值,我们在上面的例子中再输出一个PostForm对比一下就清楚了。这里代码就不贴了,只是多了一个输出而已。
Form: map[a:[123] b:[456] email:[[email protected]] username:[satori]]
RawQuery: a=123&b=456
PostForm: map[email:[[email protected]] username:[satori]]
我们看到查询参数没有体现在PostForm中。
MultipartForm:
如果要处理上传的文件,那么就必须使用 multipart/form-data 编码。与之前的 Form/PostForm 类 似,处理 multipart/form-data 编码的请求时,也需要先解析后使用。只不过使用的方法不同,解析使用的不再是 r.ParseForm,而是 r.ParseMultipartForm,之后从 MultipartForm 字段中取值。
func multipartFormHandler(w http.ResponseWriter, r *http.Request) {
// GET请求的话, 直接输入表单
if r.Method == "GET" {
fmt.Fprint(w, `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Go Web</title>
</head>
<body>
<form action="/multipartform" method="post"
enctype="multipart/form-data">
<label>name:</label>
<input type="text" name="name" />
<label>age:</label>
<input type="text" name="age" />
<label>file:</label>
<input type="file" name="uploaded" />
<button type="submit">提交</button>
</form>
</body>
</html>
`)
} else {
// 上传之后, 进行解析; 接收一个整型, 表示上传文件大小的最大值
r.ParseMultipartForm(1024)
// r.MultipartForm表示上传的文件信息, 它是一个结构体
// 里面一个 Value map[string][]string, 一个File map[string][]*FileHeader
// r.MultipartForm.Value显然是表单信息, r.MultipartForm.File是文件信息
fmt.Fprintln(w, r.MultipartForm) // 直接把信息返回
// 文件内容都在r.MultipartForm.File里面, 但是我们看到File这个map的值对应的是[]*FileHeader, 都是指针, 所以直接返回的话只是一个地址
// 但是我们通过地址是可以得到文件内容的
// 调用File["uploaded"], 获取对应的[]*FileHeader, "uploaded"是表单中的名字
// 同理因为可以上传多个文件, 所以是一个切片; 这里我们只上传一个文件, 所以获取第一个元素即可;
// 但如果不上传文件的话显然会索引越界, 因此还可以再做一层检测, 看看切片长度是否为0, 这里我就不做检测了
fileHeader := r.MultipartForm.File["uploaded"][0]
// 调用Open方法, 返回一个io.Reader 和 一个error
file, err := fileHeader.Open()
if err != nil {
fmt.Fprintf(w, "Open failed: %v", err)
return
}
// 直接读取
data, _ := ioutil.ReadAll(file)
fmt.Fprintln(w, string(data)) // 将文件内容也返回给客户端
}
}
mux.HandleFunc("/", multipartFormHandler)
我们输入内容,上传文件,然后提交:
我们看到 r.MultipartForm 显示在了页面上,里面是两个map,一个Value、一个File;Value存放的是表单数据,File存放的是文件流;然后我们将文件内容显示在了页面上,并且我们看到Fprint、Fprintf、Fprintln这些函数可以调用多次,都会返回给客户端。当然它们不是调用一个返回一个,只是将内容都写在了w http.ResponseWriter 中,等函数结束之后再一次性将全部内容交给客户端。所以第一次调用Fprintf的时候,如果结尾没有 \n,那么再写入的时候就连在了一起,和我们平时使用Printf、Print、Println是一样的,只不过我们将内容写在了w http.ResponseWriter 中。
关于上传的文件,我们说它是一个FileHeader指针,通过这个FileHeader我们还可以获取其它的文件属性。
type FileHeader struct {
Filename string // 文件名
Header textproto.MIMEHeader // 文件头部信息
Size int64 // 文件大小
content []byte // 文件内容
tmpfile string // 临时文件
}
我们看到content是文件内容,所以本来我们使用string(fileHeader.content) 也是可以获取文件内容的,只不过该成员没有被导出,所以我们无法这么做。不过还记得之前说过了unsafe包吗?它可以突破一些限制,让我们可以访问结构体中未被导出的成员,我们来试一下。
func multipartFormHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" {
fmt.Fprint(w, `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Go Web</title>
</head>
<body>
<form action="/multipartform" method="post"
enctype="multipart/form-data">
<label>name:</label>
<input type="text" name="name" />
<label>age:</label>
<input type="text" name="age" />
<label>file:</label>
<input type="file" name="uploaded" />
<button type="submit">提交</button>
</form>
</body>
</html>
`)
} else {
r.ParseMultipartForm(1024)
fileHeader := r.MultipartForm.File["uploaded"][0]
// 一个string、一个textproto.MIMEHeader、一个int64, 跳过这些字节便是content
data := *(*[]byte)(unsafe.Pointer(uintptr(unsafe.Pointer(fileHeader)) +
unsafe.Sizeof("") + unsafe.Sizeof(textproto.MIMEHeader{}) + unsafe.Sizeof(int64(0))))
fmt.Fprintln(w, string(data)) // 将文件内容返回给客户端
}
}
怎么样,是不是很有趣呢?当然最好还是不要这么做,因为把这个成员设置成未导出的,就证明不希望你访问,所以还是按照开发者提供的方法读取吧。
如果没有填写表单,那么对应的切片是空切片,注意:是空切片,不是nil。
FormValue/PostFormValue:
为了方便地获取值,net/http 包提供了 FormValue/PostFormValue 方法,它们在需要时会自动调用 ParseForm/ParseMultipartForm 方法。
FormValue 方法返回请求的 Form 字段中指定键的值,如果同一个键对应多个值,那么返回第一个。如果需要获取全部值,直接使用 Form 字段。举例说明:
fmt.Fprintln(w, r.FormValue("name"), r.Form["name"][0], r.Form["name"])
本质上是一样的,如果确定只有一个值的话可以直接使用FormValue;PostFormValue也是同理,我们说前者会同时作用在查询参数和表单上面,后者只会作用在表单中。
注意:当编码被指定为 multipart/form-data 时,FormValue / PostFormValue 将不会返回任何值, 它们读取的是 Form/PostForm 字段,而 ParseMultipartForm 将数据写入 MultipartForm 字段。
常见的字段我们就说到这里,至于其它的一些字段可以自己尝试一下。
其它格式,通过 AJAX 之类的技术可以发送其它格式的数据,例如 application/json 等。这种情况下:
首先通过Content-Type来获取数据的格式;
通过r.Body读取字节流;
解码使用;
http 响应
http.Response 对应返回给客户端的响应,http.Request 对应来自客户端的请求
// ResponseWriter是一个接口
func (w http.ResponseWriter, r *http.Request)
接下来是如何响应客户端的请求,最简单的方式是通过 http.ResponseWriter 发送字符串给客户端,但是这种方式仅限于发送字符串。我们看一下ResponseWriter的结构吧。
type ResponseWriter interface {
Header() Header
Write([]byte) (int, error)
WriteHeader(statusCode int)
}
我们响应客户端请求都是通过该接口的 3 个方法进行的,例如之前的 fmt.Fprintln,底层就是调用了 Write 方法。
收到请求后,多路复用器会自动创建一个 http.Response 对象,它实现了 http.ResponseWriter 接口,然后将该对象和请求对象作为参数传给处理器。但为什么请求对象使用的是结构体指针 *http.Request ,而响应却不使用指针呢?
实际上,请求对象使用指针是为了能在处理逻辑中更方面地获取请求信息,而响应使用接口来操作,底层也是对象指针,可以保存修改。
接口 ResponseWriter 有 3 个方法:
Write;
WriteHeader;
Header;
Write:
由于接口 ResponseWriter 拥有方法 Write([]byte) (int, error) ,所以实现了 ResponseWriter 接口的对象也实现了 io.Writer 接口:
type Writer interface {
Write(p []byte) (n int, err error)
}
这也是为什么 http.ResponseWriter 类型的变量 w 能在下面的代码中使用:
// 第一个参数接收的一个 io.Writer 接口
fmt.Fprintln(w, "Hello World")
当然我们也可以直接调用 Write 方法来向响应中写入数据:
func writeHandler(w http.ResponseWriter, r *http.Request) {
str := `<html>
<head><title>Go Web</title></head>
<body><h1>直接使⽤ Write ⽅法<h1></body>
</html>`
w.Write([]byte(str))
}
我们返回的内容会有相应的首部信息,比如:Content-Type: text/html; charset=utf8 ,当我们返回一个字符串的时候就是这样子。但是我们这里没有设置啊,说明 net/http 会自动推断,它是通过读取响应体前面的若干个字节来推断的,只是这种推断并不是百分之百准确(准确率挺高的)。
那我们如何才能自己设置响应内容的类型 以及 状态码呢?答案就是通过:WriteHeader 和 Header 两个方法。
WriteHeader:
WriteHeader 方法的名字带有一点误导性,它并不能用于设置响应首部。 WriteHeader 接收一个整数,并将这个整数作为 HTTP 响应的状态码返回。调用这个返回之后,可以继续对 ResponseWriter 进行写入,但是不能对响应的首部进行任何修改操作。如果用户在调用 Write 方法之前没有执行过 WriteHeader 方法,那么程序默认会使用 200 作为响应的状态码。
如果,我们定义了一个 API,还未定义其实现。那么请求这个 API 时,可以返回一个 501 Not Implemented 作为状态码。
package main
import (
"fmt"
"log"
"net/http"
)
func writeHeaderHandler1(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(501)
fmt.Fprintln(w, "你好呀, 这个API还没有实现呢")
}
func writeHeaderHandler2(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(280)
fmt.Fprintln(w, "这是我随便设置的一个状态码")
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/writeheader1", writeHeaderHandler1)
mux.HandleFunc("/writeheader2", writeHeaderHandler2)
server := &http.Server{
Addr: "localhost:8889",
Handler: mux,
}
if err := server.ListenAndServe(); err != nil {
log.Fatal(err)
}
}
Header:
Header 方法其实返回的是一个 http.Header 类型,该类型的底层类型为 map[string][]string:
// src/net/http/header.go
type Header map[string][]string
类型 Header 定义了 CRUD 方法,可以通过这些方法操作首部:
func headerHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Location", "http://baidu.com")
w.WriteHeader(302)
}
mux.HandleFunc("/header", headerHandler)
我们知道 302 表示重定向,浏览器收到该状态码时会再发起一个请求到首部中 Location 指向的地址。如果我们访问 /header,那么会重定向到百度首页。
接下来,我们看看如何设置自定义的内容类型。通过 Header.Set 方法设置响应的首部 Contet-Type 即可,我们编写一个返回 JSON 数据的处理器函数:
package main
import (
"encoding/json"
"log"
"net/http"
)
type Girl struct {
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Age int `json:"age"`
Hobbies []string `json:"hobbies"`
}
var g = &Girl{
FirstName: "夏色",
LastName: "祭",
Age: 16,
Hobbies: []string{"斯哈斯哈", "呼吸"},
}
func jsonHandler1(w http.ResponseWriter, r *http.Request) {
data, _ := json.MarshalIndent(g, "", "\t")
w.Write(data)
}
func jsonHandler2(w http.ResponseWriter, r *http.Request) {
data, _ := json.MarshalIndent(g, "", "\t")
w.Header().Set("Content-Type", "application/json")
w.Write(data)
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/head1", jsonHandler1)
mux.HandleFunc("/head2", jsonHandler2)
server := &http.Server{
Addr: "localhost:8889",
Handler: mux,
}
if err := server.ListenAndServe(); err != nil {
log.Fatal(err)
}
}
两者唯一的区别就是,是否设置了Content-Type,我们测试一下:
我们看到默认是纯文本类型,也就是:text/plain。
此时是我们设置的类型。
类似的Content-Type还有 xml( application/xml )、html( application/html )、pdf( application/pdf )、png( image/png )等等
设置cookie
cookie 的出现是为了解决 HTTP 协议的无状态性的,客户端通过 HTTP 协议与服务器通信,多次请求之间无法记录状态。服务器可以在响应中设置 cookie,客户端保存这些 cookie。然后每次请求时都带上 这些 cookie,服务器就可以通过这些 cookie 记录状态,辨别用户身份等。
整个计算机行业的收入都建立在 cookie 机制之上,广告领域更是如此。说法虽然有些夸张,但是可见 cookie 的重要性。
我们知道广告是互联网最常见的盈利方式。其中有一个很厉害的广告模式,叫做联盟广告。最常见的就是,刚刚在百度上搜索了某个关键字,然后打开淘宝或京东后发现相关的商品已经被推荐到首页或边栏了。这是由于这些网站组成了广告联盟,只要加入它们,就可以共享用户浏览器的 cookie 数据。
Go 中 cookie 使用 http.Cookie 结构体表示,在 net/http 包中定义:
type Cookie struct {
Name string
Value string
Path string
Domain string
Expires time.Time
RawExpires string
MaxAge int
Secure bool
HttpOnly bool
SameSite SameSite
Raw string
Unparsed []string
}
Name/Value:cookie的键值对,都是字符串类型;
没有设置 Expires 字段的 cookie 被称为 会话cookie 或 临时cookie,这种 cookie 在浏览器关闭时就会自动删除。设置了 Expires 字段的 cookie 称为 持久cookie,这种 cookie 会一直存在,直到指定的时间来临或手动删除;
HttpOnly 字段设置为 true 时,该 cookie 只能通过 HTTP 访问,不能使用其它方式操作,如 JavaScript,可以提高安全性;
Expires 和 MaxAge 都可以用于设置 cookie 的过期时间,Expires 字段设置的是 cookie 在什么 时间点过期,而 MaxAge 字段表示 cookie 自创建之后能够存活多少秒。虽然 HTTP 1.1 中废弃了 Expires ,推荐使用 MaxAge 代替。但是几乎所有的浏览器都仍然支持 Expires ;而且,微软的 IE6/IE7/IE8 都不支持 MaxAge 。所以为了更好的可移植性,可以只使用 Expires 或同时使用这两个字段。
cookie 需要通过响应的首部发送给客户端,浏览器收到 Set-Cookie 首部时,会将其中的值解析成 cookie 格式保存在浏览器中。下面我们来具体看看如何设置 cookie:
func setCookie(w http.ResponseWriter, r *http.Request) {
c1 := &http.Cookie {
Name: "name",
Value: "matsuri",
HttpOnly: true,
}
c2 := &http.Cookie {
Name: "age",
Value: "18",
HttpOnly: true,
}
w.Header().Set("Set-Cookie", c1.String())
w.Header().Add("Set-Cookie", c2.String())
fmt.Fprintln(w, c1.String())
fmt.Fprintln(w, c2.String())
}
上面构造 cookie 的代码中,有几点需要注意:
首部名称为Set-Cookie;
首部的值需要是字符串;
设置第一个cookie调用Set方法, 添加的时候调用Add方法; 如果添加的时候也调用Set方法, 那么会将同名的键覆盖掉, 也就是第一个cookie将被覆盖;
为了使用的便捷, net/http 包还提供了 SetCookie 方法。用法如下:
c1 := &http.Cookie {
Name: "name",
Value: "matsuri",
HttpOnly: true,
}
c2 := &http.Cookie {
Name: "age",
Value: "18",
HttpOnly: true,
}
http.SetCookie(w, c1)
http.SetCookie(w, c2)
如果收到的响应中有 cookie 信息,浏览器会将这些 cookie 保存下来。只有没有过期,在向同一个主机发送请求时都会带上这些 cookie。在服务端,我们可以从请求的 Header 字段读取 Cookie 属性来获得 cookie:
func setCookie(w http.ResponseWriter, r *http.Request) {
c1 := &http.Cookie {
Name: "name",
Value: "matsuri",
HttpOnly: true,
}
c2 := &http.Cookie {
Name: "age",
Value: "18",
HttpOnly: true,
}
http.SetCookie(w, c1)
http.SetCookie(w, c2)
}
func getCookie(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, r.Header["Cookie"])
}
mux.HandleFunc("/set_cookie", setCookie)
mux.HandleFunc("/get_cookie", getCookie)
先请求一次 localhost:8080/set_cookie ,然后再次请求 localhost:8080/get_cookie ,浏览器就将 cookie 传过来了。
r.Header["Cookie"] 返回一个切片,这个切片又包含了一个字符串,而这个字符串又包含了客户端发送的任意多个 cookie。如果想要取得单个键值对格式的 cookie,就需要解析这个字符串。 为 此,net/http 包在 http.Request 上提供了一些方法使我们更容易地获取 cookie:
func getCookie2(w http.ResponseWriter, r *http.Request) {
name, err := r.Cookie("name")
if err != nil {
fmt.Fprintln(w, "没有找到该cookie")
}
cookies := r.Cookies()
fmt.Fprintln(w, name)
fmt.Fprintln(w, cookies)
}
Cookie 方法返回以传入参数为键的 cookie,如果该 cookie 不存在,则返回⼀个错误;
Cookies 方法返回客户端传过来的所有 cookie。
另外需要注意的是,Cookie是和主机名绑定的,不和端口绑定。如果我们再启动一个8888端口,那么依旧可以获取到cookie,可以自己尝试一下。
流程总结
梳理一下net/http代码的执行流程:
首先调用 Http.HandleFunc:
1. 调用了DefaultServeMux的HandleFunc;
2. 调用了DefaultServeMux的Handle;
3. 往DefaultServeMux的map[string]muxEntry中增加对应的handler和路由规则;
其次调用http.ListenAndServe(":8080", nil) :
1. 实例化Server;
2. 调用Server的ListenAndServe();
3. 调用net.Listen("tcp", addr) 监听端口;
4. 启动一个for循环,在循环体中Accept请求;
5. 对每个请求实例化一个Conn,并且开启一个goroutine为这个请求进行服务,执行go c.serve();
6. 读取每个请求的内容w, err := c.readRequest() ;
7. 判断handler是否为空,如果没有设置handler(这个例子就没有设置handler),handler 就设置为DefaultServeMux;
8. 调用handler的ServeHttp;
9. 在这个例子中,下面就进入到DefaultServeMux.ServeHttp;
10. 根据request选择handler,并且进入到这个handler的ServeHTTP;
模板引擎
模板引擎是 Web 编程中必不可少的一个组件。模板能分离逻辑和数据,使得逻辑简洁清晰,并且模板可复用。
在早期的Web编程中一般都会定义大量的模板(html文件),然后后台服务将模板读取之后进行渲染、然后返回。但是随着前后端分离的流行,这种做法越来越少了;因为模板文件中需要涉及后端语法,那么这就要求前端人员懂后端逻辑,或者后端人员懂前端逻辑,从而导致耦合比较严重。但是现在前后端分离之后,后端不再关注页面样式如何。以我本人所在公司为例,个人负责编写后端服务,但是我只需要返回一个json即可,前端会根据json数据自动渲染页面。
所以这就使得分工变得明确,不然的话,当增加需求或者需求变更导致修改html文件时,那么这个文件是交给前端修改还是后端修改呢?不过话虽如此,但并不代表模板渲染引擎就没有用了,搭建一个小型的Web服务使用这种方式还是很轻松的。而且模板引擎我们不光可以用在Web上,拿来做字符串格式化也是不错的。
模板引擎按照功能可以划分为两种类型:
无逻辑模板引擎: 此类模板引擎只进行字符串的替换, 无其它逻辑;
嵌入逻辑模板引擎: 此类模板引擎可以在模板中嵌入逻辑, 实现流程控制、循环等等;
这两类模板引擎都比较极端。无逻辑模板引擎需要在处理器中额外添加很多逻辑用于生成替换的文本,而嵌入逻辑模板引擎则在模板中混入了大量逻辑,导致维护性较差。常用的模板引擎一般介于这两者之间。
在 Go 标准库中,可以通过 text/template 和 html/template 这两个库实现模板功能。
模板内容可以是 UTF-8 编码的任何内容。其中使用 {{ }} 包围的部分称为动作, {{ }} 外的其它文本在输出保持不变。模板需要应用到数据,模板中的动作会根据数据生成的响应内容来进行替换。模板解析之后可以多次执行,也可以并行执行,但是注意:使用同一个 Writer 会导致输出交替出现。
使用模板引擎一般有 3 个步骤:
定义模板: 可以是字符串或者文件;
解析模板: 使用 text/template 或 html/template 中的方法解析;
传入数据生成输出;
package main
import (
"html/template"
"log"
"os"
)
type Girl struct {
Name string
Age int64
}
func stringLiteralTemplate() {
s := "姓名: {{ .Name }}, 年龄: {{ .Age }}"
t, err := template.New("test").Parse(s)
if err != nil {
log.Fatal("解析模板失败")
}
g := Girl{"夏色祭", 16}
err = t.Execute(os.Stdout, g)
if err != nil {
log.Fatal("执行模板失败")
}
}
func main() {
stringLiteralTemplate() // 姓名: 夏色祭, 年龄: 16
}
解释一下:
首先调用 template.New 创建一个模板, 参数为模板名(相当于给模板起一个名字, 可以不用鸟它), 然后会得到一个 *Template;
调用 *Template 的 Parse 方法, 传入字符串进行解析, 会得到 *Template 和 error; 如果模板语法正确, 则返回模板对象本身 和 一个nil;
最后调用模板对象的 Execute 方法, 传入参数; 第一个参数是 接口 io.Writer, 第二个参数是 interface{}; 我们当前传递的是一个结构体, 但是很明显还可以是其它类型, 后面会说; 如果是结构体的话, 模板中的 {{ .Name }} 会被Name成员的值替换、{{ .Age }} 会被Age成员替换, 然后输出到指定的第一个参数中, 这里我们指定的是 os.Stdout, 显然还可以是 *strings.Builder、bytes.Buffer等等
package main
import (
"fmt"
"html/template"
"strings"
)
type Girl struct {
Name string
Age int64
}
func stringLiteralTemplate() {
buf := &strings.Builder{}
s := "姓名: {{ .Name }}, 年龄: {{ .Age }}"
t, _ := template.New("test").Parse(s)
_ = t.Execute(buf, Girl{"夏色祭", 16})
fmt.Println(buf.String())
}
func main() {
stringLiteralTemplate() // 姓名: 夏色祭, 年龄: 16
}
有一点需要注意:模板中一旦出现了 {{ }} ,那么结构体中必须要有成员能够进行对应,否则 t.Execute 会返回一个 error。因为在 调用 t.Execute 的时候,会从左往右扫描字符串,然后遇见了 {{ }} 会进行替换,而一旦找不到可以替换的值就会终止扫描。
如果将结构体Girl中的成员 Age 改成 Age1的话,那么 buf.String() 得到的就是 姓名: 夏色祭, 年龄: ,因为扫描的时候发现结构体中没有 Age 这个成员,所以就终止扫描了;同理如果将 Name 改成 Name1,那么 buf.String() 得到的就是姓名: ,因为扫描是从左往右扫描,而 Name 成员不存在,所以直接终止扫描了;
但结构体成员并不一定都要出现在模板文件中,{{ }} 要能在结构体成员中找到,但是结构体成员可以不出现在模板文件的 {{ }} 中。
我们上面是以字符串字面量作为模板的,模板显然也可以是一个文件。
定义一个文件,假设就叫 1.txt 吧,里面写上如下内容:
name: {{ .Name }}
age: {{ .Age }}
gender: {{ .Gender }}
然后我们看看如何渲染:
package main
import (
"fmt"
"html/template"
"strings"
)
type Girl struct {
Name string
Age int64
Gender string
}
func FileTemplate() {
buf := &strings.Builder{}
t, _ := template.ParseFiles("1.txt")
_ = t.Execute(buf, Girl{"matsuri", 16, "female"})
fmt.Println(buf.String())
}
func main() {
FileTemplate()
/*
name: matsuri
age: 16
gender: female
*/
}
模板动作
Go 模板中的动作就是一些嵌入在模板里面的命令,动作大体上可以分为以下几种类型:
点动作
在介绍其它的动作之前,我们先看一个很重要的动作,点动作{{ . }} 。它其实代表是传递给模板的数据,其他动作或函数基本上都是对这个数据进行处理,以此来达到格式化和内容展示的目的。
package main
import (
"fmt"
"html/template"
"strings"
)
type Girl struct {
Name string
Age int64
Gender string
}
func main() {
s1 := "{{ . }}"
s2 := "{{ .Name }} {{ .Age }} {{ .Gender }}"
t1, _ := template.New("test").Parse(s1)
t2, _ := template.New("test").Parse(s2)
g := Girl{Name: "夏色祭", Age: 16, Gender: "female"}
buf := new(strings.Builder)
_ = t1.Execute(buf, g)
fmt.Println(buf.String()) // {夏色祭 16 female}
// 清空缓存
buf.Reset()
_ = t2.Execute(buf, g)
fmt.Println(buf.String()) // 夏色祭 16 female
}
相信此时你已经看到区别了,单纯的 {{ . }} 的话,就代表 Execute 第二个参数整体,再举个栗子:
package main
import (
"fmt"
"html/template"
"strings"
)
type Girl struct {
Name string
Age int64
Gender string
}
func main() {
s := "{{ . }} {{ . }} {{ .Name }}"
t, _ := template.New("test").Parse(s)
g := Girl{Name: "夏色祭", Age: 16, Gender: "female"}
buf := new(strings.Builder)
_ = t.Execute(buf, g)
fmt.Println(buf.String()) // {夏色祭 16 female} {夏色祭 16 female} 夏色祭
}
注意:为了使用的方便和灵活,在模板中不同的上下问内,. 的含义可能会改变。
条件动作
Go 标准库中对动作有详细的介绍。 其中 pipeline 表示管道,后面会有详细的介绍,现在可以将它理解为一个值。 T1/T2 等形式表示语句块,里面可以嵌套其它类型的动作,最简单的语句块就是不包含任何动作的字符串。
条件动作的语法与编程语言中的 if 语句语法类似,有几种形式:
{{ if pipeline }} T1 {{ end }}
如果管道计算出来的值不为空,执行 T1。否则,不生成输出。下面都表示空值:
false、0、空指针、或接口
长度为0的数组、切片、map或字符串
{{ if pipeline }} T1 {{ else }} T2 {{ end }}
如果管道计算出来的值不为空,执行 T1。否则,执行 T2。
{{ if pipeline1 }} T1 {{ else if pipeline2 }} T2 {{ else }} T3 {{ end }}
如果管道 pipeline1 计算出来的值不为空,则执行 T1;否则看管道 pipeline2 的值是否不为空,如果不为空则执行 T2。如果都为空,执行 T3。
非常简单,就是简单的条件语句嘛。
举个栗子:
你的年龄: {{ .Age }}
{{ if .GreaterThan60}}
老人
{{ else if .GreaterrThan40 }}
中年人
{{ else }}
年轻人
{{ end }}
以上是模板文件,下面我们来随机生成一个数字测试一下:
package main
import (
"fmt"
"html/template"
"math/rand"
"strings"
)
type AgeInfo struct {
Age int
GreaterThan60 bool
GreaterThan40 bool
}
func main() {
age := rand.Intn(100)
g := AgeInfo{age, age > 60, age > 40}
t, _ := template.ParseFiles("1.txt")
buf := new(strings.Builder)
_ = t.Execute(buf, g)
fmt.Println(buf.String())
/*
你的年龄: 81
老人
*/
}
显示的81岁,打印结果为老人,かわいそうぉぉぉぉ。不过 |
请发表评论