7 Go语言中的模板编程
# 7 Go语言中的模板编程
Go语言中的模板编程允许终端用户编写Go模板,以生成、操作和运行Go程序。Go语言具有清晰的静态依赖关系,这有助于进行元编程。Go语言中的模板编程,包括生成的二进制文件、命令行界面(CLI,Command-Line Interface )工具和模板库,都是该语言的核心要素,有助于我们编写可维护、可扩展且高性能的Go代码。
在本章中,我们将涵盖以下主题:
- Go generate命令
- Protobuf代码生成
- 链接工具链
- 使用Cobra和Viper进行配置元编程
- 文本和HTML模板
- 用于Go模板的Sprig库
所有这些主题都将帮助你更快、更高效地编写Go代码。在下一节中,我们将讨论Go generate命令,以及它在Go编程语言中的用途。
# 理解Go generate命令
从Go 1.4版本开始,该语言包含了一个有助于代码生成的工具,名为Go generate。Go generate会扫描源代码,查找要运行的通用命令。它独立于go build
命令运行,因此必须在构建代码之前执行。Go generate由代码作者运行,而不是由编译后的二进制文件的用户运行。这个工具的运行方式与通常使用Makefile和Shell脚本的方式类似,但它是Go工具的一部分,我们无需引入任何其他依赖项。
Go generate会在代码库中搜索符合以下模式的行://go:generate command argument
。
为了表明代码是生成的,生成的源文件应包含如下一行内容:
^// Code generated .* DO NOT EDIT\.$
Go generate在运行生成器时会使用一组变量:
$GOARCH
:执行平台的架构$GOOS
:执行平台的操作系统$GOFILE
:文件名$GOLINE
:包含该指令的源文件的行号$GOPACKAGE
:包含该指令的文件所在的包名$DOLLAR
:字面值$
在Go语言中,我们可以将Go generate命令用于各种不同的用例。它们可以被视为Go语言内置的构建机制。虽然使用Go generate执行的操作也可以通过其他构建工具(如Makefile)来完成,但有了Go generate,就意味着在构建环境中无需其他依赖项。
这意味着所有的构建产物都包含在Go文件中,以确保项目之间的一致性。
# Protobuf的代码生成
在Go语言中,代码生成的一个实际用例是使用gRPC生成Protocol Buffers(一种结构化数据序列化方法)。Protocol Buffers是一种用于序列化结构化数据的新方法,通常用于在分布式系统中的服务之间传递数据,因为它比JSON或XML更高效。Protocol Buffers还可以在多个平台上的多种语言中扩展使用。它们带有结构化数据定义;一旦构建好了数据结构,就会生成可以读取和写入数据源的源代码。
首先,我们需要获取Protocol Buffers的最新版本:https://github.com/protocolbuffers/protobuf/releases。
在撰写本文时,该软件的稳定版本是3.8.0。安装此软件包后,我们需要使用go get github.com/golang/protobuf/protoc-gen-go
命令拉取所需的Go依赖项。接下来,我们可以生成一个非常通用的协议定义:
syntax = "proto3";
package userinfo;
service UserInfo {
rpc PrintUserInfo (UserInfoRequest) returns (UserInfoResponse) {}
}
message UserInfoRequest {
string user = 1;
string email = 2;
}
message UserInfoResponse {
string response = 1;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
之后,我们可以使用Go generate生成proto文件。在与.proto
文件相同的目录中创建一个文件,其内容如下:
package userinfo
//go:generate protoc -I ../userinfo --go_out=plugins=grpc:../userinfo ../userinfo/userinfo.proto
2
这样,我们仅通过使用Go generate就可以生成一个Protobuf定义。在该目录中执行Go generate后,我们会得到一个userinfo.pb.go
文件,其中包含所有Go格式的Protobuf定义。在使用gRPC生成客户端和服务器架构时,我们可以使用这些信息。
接下来,我们可以创建一个服务器,以使用之前添加的gRPC定义:
package main
import (
"context"
"log"
"net"
pb "github.com/HighPerformanceWithGo/7-metaprogramming-in-go/grpcExample/userinfo/userinfo"
"google.golang.org/grpc"
)
type userInfoServer struct{}
func (s *userInfoServer) PrintUserInfo(ctx context.Context, in *pb.UserInfoRequest) (*pb.UserInfoResponse, error) {
log.Printf("%s %s", in.User, in.Email)
return &pb.UserInfoResponse{Response: "User Info: User Name: " + in.User + " User Email: " + in.Email}, nil
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
在初始化服务器结构体并拥有一个返回用户信息的函数后,我们可以设置gRPC服务器,使其在标准端口上监听并注册我们的服务器:
func main() {
l, err := net.Listen("tcp", ":50051")
if err != nil {
log.Fatalf("Failed to listen %v", err)
}
s := grpc.NewServer()
pb.RegisterUserInfoServer(s, &userInfoServer{})
if err := s.Serve(l); err != nil {
log.Fatalf("Couldn't create Server: %v", err)
}
}
2
3
4
5
6
7
8
9
10
11
设置好服务器定义后,我们可以将重点放在客户端上。客户端包含所有常规导入,以及几个默认常量声明,如下所示:
package main
import (
"context"
"log"
"time"
pb "github.com/HighPerformanceWithGo/7-metaprogramming-in-go/grpcExample/userinfo/userinfo"
"google.golang.org/grpc"
)
const (
defaultGrpcAddress = "localhost:50051"
defaultUser = "Gopher"
defaultEmail = "Gopher@example.com"
)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
设置好导入和常量后,我们可以在主函数中使用它们,将这些值发送到服务器。我们设置一个默认超时时间为1秒的上下文,发起一个PrintUserInfo
的Protobuf请求,获取响应并记录下来。以下是我们的Protobuf示例:
func main() {
conn, err := grpc.Dial(defaultGrpcAddress, grpc.WithInsecure())
if err != nil {
log.Fatalf("did not connect: %v", err)
}
defer conn.Close()
c := pb.NewUserInfoClient(conn)
user := defaultUser
email := defaultEmail
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
r, err := c.PrintUserInfo(ctx, &pb.UserInfoRequest{User: user, Email: email})
if err != nil {
log.Fatalf("could not greet: %v", err)
}
log.Printf("%s", r.Response)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
我们可以看到这个Protobuf示例在实际运行。Protobuf是在分布式系统中发送消息的强大方式。谷歌经常提到Protobuf对其大规模系统的稳定性有多重要。我们将在下一节讨论Protobuf代码的运行结果。
# Protobuf代码的运行结果
有了协议定义、服务器和客户端后,我们可以一起执行它们,查看实际效果。首先,启动服务器:
接下来,执行客户端代码。我们可以看到在客户端代码中创建的默认用户名和电子邮件地址:
在服务器端,我们可以看到所做请求的日志:
gRPC是一种非常高效的协议:它使用HTTP/2和Protocol Buffers来快速序列化数据。客户端到服务器的单个连接可以用于多个调用,从而减少延迟并提高吞吐量。
在下一节中,我们将讨论链接工具链。
# 链接工具链
Go语言的链接工具中包含许多实用工具,允许我们将相关数据传递给可执行函数。使用这个工具,程序员能够为具有特定名称和值对的字符串设置值。在Go语言中,使用cmd/link
包可以在链接时将信息传递给当前的Go程序。从工具链向可执行文件传递信息的方法是使用构建参数:
go build -ldflags '-X importpath.name=value'
例如,如果我们想从命令行获取程序的序列号,可以这样做:
package main
import (
"fmt"
)
var SerialNumber = "unlicensed"
func main() {
if SerialNumber == "ABC123" {
fmt.Println("Valid Serial Number!")
} else {
fmt.Println("Invalid Serial Number")
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
如上述输出所示,如果我们在不传入序列号的情况下尝试执行此程序,程序会提示我们序列号无效:
如果传入错误的序列号,我们会得到相同的结果:
如果传入正确的序列号,程序会提示我们的序列号有效:
在排查大型代码库问题时,能够在链接时向程序传递数据会很有用。当你需要部署已编译的二进制文件,但之后可能需要以非确定性的方式更新某个常用值时,这也很有帮助。
在下一节中,我们将讨论两个常用于配置编程的工具——Cobra和Viper。
# 介绍用于配置编程的Cobra和Viper
两个常用的Go语言库,即spf13/cobra和spf13/viper,用于配置编程。这两个库一起使用,可以创建具有许多可配置选项的命令行界面(CLI)二进制文件。Cobra可用于生成应用程序和命令文件,而Viper有助于为遵循12要素原则的Go应用程序读取和维护完整的配置解决方案。Cobra和Viper在一些最常用的Go项目中都有应用,包括Kubernetes和Docker。
要将这两个库一起用于创建一个命令行工具库,我们需要确保按如下方式嵌套项目目录:
创建好嵌套目录结构后,我们就可以开始设置主程序了。在main.go
文件中,我们定义了date
命令。为了便于调用cmd
目录中编写的函数(这是Go语言的常见习惯用法),Cobra和Viper的main.go
函数特意写得很简单。主包代码如下:
package main
import (
"fmt"
"os"
"github.com/HighPerformanceWithGo/7-metaprogramming-in-go/clitooling/cmd"
)
func main() {
if err := cmd.DateCommand.Execute(); err != nil {
fmt.Println(err)
os.Exit(1)
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
定义好主函数后,我们可以开始设置命令行工具的其余部分。首先导入所需的包:
package cmd
import (
"fmt"
"time"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var verbose bool
2
3
4
5
6
7
8
9
10
接下来,设置根date
命令:
var DateCommand = &cobra.Command{
Use: "date",
Aliases: []string{"time"},
Short: "Return the current date",
Long: "Returns the current date in a YYYY-MM-DD HH:MM:SS format",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("Current Date :\t", time.Now().Format("2006.01.02 15:04:05"))
if viper.GetBool("verbose") {
fmt.Println("Author :\t", viper.GetString("author"))
fmt.Println("Version :\t", viper.GetString("version"))
}
},
}
2
3
4
5
6
7
8
9
10
11
12
13
设置好这个命令后,我们还可以设置一个子命令来显示许可信息,如以下代码示例所示。子命令是CLI工具的第二个参数,用于为CLI提供更多信息:
var LicenseCommand = &cobra.Command{
Use: "license",
Short: "Print the License",
Long: "Print the License of this Command",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("License: Apache-2.0")
},
}
2
3
4
5
6
7
8
最后,设置init()
函数。在Go语言中,init()
函数有多种用途:
- 向用户显示初始信息
- 初始化变量声明
- 初始化与外部的连接(数据库连接池或消息代理初始化)
我们可以利用新学到的init()
函数知识,在代码的最后部分初始化之前定义的Viper和Cobra命令:
func init() {
DateCommand.AddCommand(LicenseCommand)
viper.SetDefault("Author", "bob")
viper.SetDefault("Version", "0.0.1")
viper.SetDefault("license", "Apache-2.0")
DateCommand.PersistentFlags().BoolP("verbose", "v", false, "Date Command Verbose")
DateCommand.PersistentFlags().StringP("author", "a", "bob", "Date Command Author")
viper.BindPFlag("author", DateCommand.PersistentFlags().Lookup("author"))
viper.BindPFlag("verbose", DateCommand.PersistentFlags().Lookup("verbose"))
}
2
3
4
5
6
7
8
9
10
11
上述代码片段展示了Viper中常用的一些默认、持久和绑定标志。
# Cobra/Viper的运行结果
现在,我们已经实例化了所有功能,可以实际运行新代码了。
如果在调用新的main.go
时不传入任何可选参数,只会看到最初在DateCommand
运行块中定义的日期输出,如下代码输出所示:
如果在输入中添加其他标志,我们可以获取详细信息,并使用命令行标志更改包的作者,如下所示:
我们还可以通过添加为许可信息创建的子命令作为参数来查看它,如下所示:
我们已经了解了spf13 Cobra和Viper包的一小部分功能,但理解它们的基本原理很重要,它们用于在Go语言中构建可扩展的CLI工具。在下一节中,我们将讨论文本模板(text templating)。
# 文本模板
Go语言有一个内置的模板语言text/template
,它可以结合数据实现模板,并生成基于文本的输出。我们使用结构体来定义想要在模板中使用的数据。与Go语言中的所有内容一样,输入文本定义为UTF - 8编码,可以以任何格式传入。我们使用双花括号{{}}
来表示要对数据执行的操作。用.
表示的游标,可用于向模板中添加数据。这些特性相结合,形成了一种强大的模板语言,使我们能够在许多代码中复用模板。
首先,初始化包,导入必要的依赖项,并定义要传入模板的数据结构体:
package main
import (
"fmt"
"os"
"text/template"
)
func main() {
type ToField struct {
Date string
Name string
Email string
InOffice bool
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
现在,我们可以使用前面提到的text/template
定义来设置模板和输入结构:
const note = `
{{/* we can trim whitespace with a {- or a -} respectively */}}
Date: {{- .Date}}
To: {{- .Email | printf "%s"}}
{{.Name}},
{{if .InOffice }}
Thank you for your input yesterday at our meeting. We are going to go ahead with what you've suggested.
{{- else }}
We were able to get results in our meeting yesterday. I've emailed them to you. Enjoy the rest of your time Out of Office!
{{- end}}
Thanks,
Bob
`
var tofield = []ToField{
{"07-19-2019", "Mx. Boss", "boss@example.com", true},
{"07-19-2019", "Mx. Coworker", "coworker@example.com", false},
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
最后,执行模板并打印输出。我们的示例输出到标准输出,但也可以打印到文件、写入缓冲区或自动发送电子邮件:
t := template.Must(template.New("Email Body").Parse(note))
for _, k := range tofield {
err := t.Execute(os.Stdout, k)
if err != nil {
fmt.Print(err)
}
}
2
3
4
5
6
7
利用Go语言的文本模板系统,我们可以复用这些模板来生成高质量且一致的内容。由于有新的输入,我们可以调整模板并相应地得出结果。在下一节中,我们将讨论HTML模板。
# HTML模板
在Go语言中,我们还可以使用HTML模板,其方式类似于文本模板,用于为HTML页面生成动态结果。为此,我们需要初始化包,导入适当的依赖项,并设置一个数据结构来保存计划在HTML模板中使用的值,如下所示:
package main
import (
"html/template"
"net/http"
)
type UserFields struct {
Name string
URL string
Email string
}
2
3
4
5
6
7
8
9
10
11
12
接下来,创建userResponse
HTML模板:
var userResponse = `
<html>
<head></head>
<body>
<h1>Hello {{.Name}}</h1>
<p>You visited {{.URL}}</p>
<p>Hope you're enjoying this book!</p>
<p>We have your email recorded as {{.Email}}</p>
</body>
</html>
`
2
3
4
5
6
7
8
9
10
11
然后,创建一个HTTP请求处理程序:
func rootHandler(w http.ResponseWriter, r *http.Request) {
requestedURL := string(r.URL.Path)
userfields := UserFields{"Bob", requestedURL, "bob@example.com"}
t := template.Must(template.New("HTML Body").Parse(userResponse))
t.Execute(w, userfields)
log.Printf("User " + userfields.Name + " Visited : " + requestedURL)
}
2
3
4
5
6
7
之后,初始化HTTP服务器:
func main() {
s := http.Server{
Addr: "127.0.0.1:8080",
}
http.HandleFunc("/", rootHandler)
s.ListenAndServe()
}
2
3
4
5
6
7
然后,使用go run htmlTemplate.go
调用我们的Web服务器。当在这个域名上请求页面时,会看到以下结果:
上述输出来自我们HTML模板的代码。这个示例可以进一步扩展,例如通过X-Forwarded-For
头解析传入的IP地址请求,根据用户代理字符串获取终端用户的浏览器信息,或者使用任何其他特定的请求参数,以便为客户端提供更丰富的响应。在下一节中,我们将讨论Sprig,一个用于Go模板函数的库。
# 探索Sprig
Sprig是一个用于定义Go模板函数的库。该库包含许多扩展Go模板语言功能的函数。Sprig库遵循一些原则,这些原则有助于确定哪些函数可用于增强模板:
- 仅支持简单数学运算
- 仅处理传递给模板的数据,从不从外部源检索数据
- 利用模板库中的函数构建最终布局
- 从不覆盖Go核心模板功能
在以下小节中,我们将更深入地了解Sprig的功能。
# 字符串函数
Sprig有一组字符串函数,能够在模板中操作字符串。
在我们的示例中,将处理字符串" - bob smith"
(注意其中的空格和短横线)。我们将进行以下操作:
- 使用
trim()
函数去除空白字符 - 将单词
smith
替换为strecansky
- 去除
-
前缀 - 将字符串转换为标题格式,即从
bob strecansky
转换为Bob Strecansky
- 将字符串重复10次
- 创建一个宽度为14个字符(我的名字的长度)的自动换行,并使用换行符分隔每一行
Sprig库可以在一行代码中完成这些操作,类似于bash shell中函数的管道操作。
首先,初始化包并导入必要的依赖项:
package main
import (
"fmt"
"os"
"text/template"
"github.com/Masterminds/sprig"
)
2
3
4
5
6
7
8
接下来,将字符串映射设置为接口类型,执行转换,并将模板渲染到标准输出:
func main() {
inStr := map[string]interface{}{"Name": " - bob smith"}
transform := `{{.Name | trim | replace "smith" "strecansky" | trimPrefix "-" | title | repeat 10 | wrapWith 14 "\n"}}`
functionMap := sprig.TxtFuncMap()
t := template.Must(template.New("Name Transformation").Funcs(functionMap).Parse(transform))
err := t.Execute(os.Stdout, inStr)
if err != nil {
fmt.Printf("Couldn't create template: %s", err)
return
}
}
2
3
4
5
6
7
8
9
10
11
执行程序后,会看到字符串操作按预期进行:
像在示例中那样在模板中操作字符串,有助于我们快速处理传入模板中可能存在的问题,并即时进行调整。
# 字符串切片函数
正如我们在前面章节中看到的,能够操作字符串切片很有帮助。Sprig库帮助我们执行一些字符串切片操作。在我们的示例中,我们将根据“.”字符分割一个字符串。
首先,我们导入必要的库:
package main
import (
"fmt"
"os"
"text/template"
"github.com/Masterminds/sprig"
)
2
3
4
5
6
7
8
接下来,我们使用“.”作为分隔符分割模板字符串:
func main() {
tpl := `{{$v := "Hands.On.High.Performance.In.Go" | splitn "." 5}}{{$v._3}}`
functionMap := sprig.TxtFuncMap()
t := template.Must(template.New("String Split").Funcs(functionMap).Parse(tpl))
fmt.Print("String Split into Dict (word 3): ")
err := t.Execute(os.Stdout, tpl)
if err != nil {
fmt.Printf("Couldn't create template: %s", err)
return
}
2
3
4
5
6
7
8
9
10
我们还可以使用sortAlpha
函数将模板列表按字母顺序排序,如下所示:
alphaSort := `{{ list "Foo" "Bar" "Baz" | sortAlpha}}`
s := template.Must(template.New("sortAlpha").Funcs(functionMap).Parse(alphaSort))
fmt.Print("\nAlpha Tuple: ")
alphaErr := s.Execute(os.Stdout, tpl)
if alphaErr != nil {
fmt.Printf("Couldn't create template: %s", err)
return
}
fmt.Print("\nString Slice Functions Completed\n")
}
2
3
4
5
6
7
8
9
10
这些字符串操作可以帮助我们整理模板函数中包含的字符串列表。
# 默认函数
Sprig的默认函数为模板函数返回默认值。我们可以检查特定数据结构的默认值,以及它们是否为空。每种数据类型对“空”的定义如下:
数据类型 | 空值定义 |
---|---|
数值型 | 0 |
字符串型 | ""(空字符串) |
列表型 | [](空列表) |
字典型 | {}(空字典) |
布尔型 | false |
其他情况 | Nil(也称为null) |
结构体 | 没有空值定义;永远不会返回默认值 |
我们先进行导入:
package main
import (
"fmt"
"os"
"text/template"
"github.com/Masterminds/sprig"
)
2
3
4
5
6
7
8
接下来,我们设置空的和非空的模板变量:
func main() {
emptyTemplate := map[string]interface{}{"Name": ""}
fullTemplate := map[string]interface{}{"Name": "Bob"}
tpl := `{{empty .Name}}`
functionMap := sprig.TxtFuncMap()
t := template.Must(template.New("Empty String").Funcs(functionMap).Parse(tpl))
2
3
4
5
6
然后,我们验证空模板和非空模板:
fmt.Print("empty template: ")
emptyErr := t.Execute(os.Stdout, emptyTemplate)
if emptyErr != nil {
fmt.Printf("Couldn't create template: %s", emptyErr)
return
}
fmt.Print("\nfull template: ")
fullErr := t.Execute(os.Stdout, fullTemplate)
if emptyErr != nil {
fmt.Printf("Couldn't create template: %s", fullErr)
return
}
fmt.Print("\nEmpty Check Completed\n")
}
2
3
4
5
6
7
8
9
10
11
12
13
14
当我们需要验证模板输入不为空时,这很有用。我们的输出结果符合预期:空模板被标记为true,而完整模板被标记为false:
我们还可以将JSON字面量编码为JSON字符串并进行格式化输出。如果你需要将一个由HTML创建的模板返回给终端用户一个JSON数组,这特别有用:
package main
import (
"fmt"
"os"
"text/template"
"github.com/Masterminds/sprig"
)
func main() {
jsonDict := map[string]interface{}{"JSONExamples": map[string]interface{}{"foo": "bar", "bool": false, "integer": 7}}
tpl := `{{.JSONExamples | toPrettyJson}}`
functionMap := sprig.TxtFuncMap()
t := template.Must(template.New("String Split").Funcs(functionMap).Parse(tpl))
err := t.Execute(os.Stdout, jsonDict)
if err != nil {
fmt.Printf("Couldn't create template: %s", err)
return
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
在我们的输出结果中,可以看到基于jsonDict
输入的格式化后的JSON数据块:
在与内置的HTML/template
和添加的content - encoding:json
HTTP头一起使用时,这非常有用。
Sprig库有很多功能,我们将在本书的这部分内容中讨论其中一些。Sprig提供的完整功能列表可以在http://masterminds.github.io/sprig/上找到。
# 总结
在本章中,我们讨论了生成Go代码。我们介绍了如何生成最常见的Go代码之一——gRPC Protobuf。然后,我们讨论了使用链接工具链添加命令行参数,以及使用spf13/cobra
和spf13/viper
创建元编程的命令行界面(CLI)工具。最后,我们讨论了使用text/template
、HTML/template
和Sprig库进行模板编程。使用所有这些包将帮助我们编写可读、可复用、高性能的Go代码。从长远来看,这些模板还能为我们节省大量工作,因为它们往往具有可复用性和可扩展性。
在下一章中,我们将讨论如何优化内存资源管理。