图灵完备的一个重要特性是计算机程序可以生成另一个程序1,很多人可能认为生成代码在软件中并不常见,但是实际上它在很多场景中都扮演了重要的角色。Go 语言中的测试就使用了代码生成机制,go test 命令会扫描包中的测试用例并生成程序、编译并执行它们,我们在这一节中就会介绍 Go 语言中的代码生成机制。
元编程(Metaprogramming)是计算机编程中一个非常重要、也很有趣的概念,维基百科上将元编程描述成一种计算机程序可以将代码看待成数据的能力2。
Metaprogramming is a programming technique in which computer programs have the ability to treat programs as their data.
如果能够将代码看做数据,那么代码就可以像数据一样在运行时被修改、更新和替换;元编程赋予了编程语言更加强大的表达能力,能够让我们将一些计算过程从运行时挪到编译时、通过编译期间的展开生成代码或者允许程序在运行时改变自身的行为。总而言之,元编程其实是一种使用代码生成代码的方式,无论是编译期间生成代码,还是在运行时改变代码的行为都是『生成代码』的一种。
现代的编程语言大都会为我们提供不同的元编程能力,从总体来看,根据『生成代码』的时机不同,我们将元编程能力分为两种类型,其中一种是编译期间的元编程,例如:宏和模板;另一种是运行期间的元编程,也就是运行时,它赋予了编程语言在运行期间修改行为的能力,当然也有一些特性既可以在编译期实现,也可以在运行期间实现。
Go 语言作为编译型的编程语言,它提供了比较有限的运行时元编程能力,例如:反射特性,然而由于性能的问题,反射在很多场景下都不被推荐使用。当然除了反射之外,Go 语言还提供了另一种编译期间的代码生成机制 — go generate,它可以在代码编译之前根据源代码生成代码。
Go 语言的代码生成机制会读取包含预编译指令的注释,然后执行注释中的命令读取包中的文件,它们将文件解析成抽象语法树并根据语法树生成新的 Go 语言代码和文件,生成的代码会在项目的编译期间与其他代码一起编译和运行。
//go:generate command argument...
go generate 不会被 go build 等命令自动执行,该命令需要显式的触发,手动执行该命令时会在文件中扫描上述形式的注释并执行后面的执行命令,需要注意的是 go:generate 和前面的 // 之间没有空格,这种不包含空格的注释一般是 Go 语言的编译器指令,而我们在代码中的正常注释都应该保留这个空格4。
代码生成最常见的例子就是官方提供的 stringer5,这个工具可以扫描如下所示的常量定义,然后为当前常量类型 Piller 生成对应的 String() 方法:
// pill.go
package painkiller
//go:generate stringer -type=Pill
type Pill int
const (
Placebo Pill=iota
Aspirin
Ibuprofen
Paracetamol
Acetaminophen=Paracetamol
)
当我们在上述文件中加入 //go:generate stringer -type=Pill 注释并调用 go generate 命令时,在同一目录下会出现如下所示的 pill_string.go 文件,该文件中包含两个函数,分别是 _ 和 String:
// Code generated by "stringer -type=Pill"; DO NOT EDIT.
package painkiller
import "strconv"
func _() {
// An "invalid array index" compiler error signifies that the constant values have changed.
// Re-run the stringer command to generate them again.
var x [1]struct{}
_=x[Placebo-0]
_=x[Aspirin-1]
_=x[Ibuprofen-2]
_=x[Paracetamol-3]
}
const _Pill_name="PlaceboAspirinIbuprofenParacetamol"
var _Pill_index=[...]uint8{0, 7, 14, 23, 34}
func (i Pill) String() string {
if i < 0 || i >=Pill(len(_Pill_index)-1) {
return "Pill(" + strconv.FormatInt(int64(i), 10) + ")"
}
return _Pill_name[_Pill_index[i]:_Pill_index[i+1]]
}
这段生成的代码很值得我们学习,它通过编译器的检查提供了非常健壮的 String 方法。我们在这里不展示具体的使用过程,本节将重点分析从执行 go generate 到生成对应 String 方法的整个过程,帮助各位理解代码生成机制的工作原理,代码生成的过程可以分成以下两个部分:
当我们在命令行中执行 go generate 命令时,它会调用源代码中的 cmd/go/internal/generate.runGenerate 函数扫描包中的预编译指令,该函数会遍历命令行传入包中的全部文件并依次调用 cmd/go/internal/generate.generate:
func runGenerate(cmd *base.Command, args []string) {
...
for _, pkg :=range load.Packages(args) {
...
pkgName :=pkg.Name
for _, file :=range pkg.InternalGoFiles() {
if !generate(pkgName, file) {
break
}
}
pkgName +="_test"
for _, file :=range pkg.InternalXGoFiles() {
if !generate(pkgName, file) {
break
}
}
}
}
cmd/go/internal/generate.generate 函数会打开传入的文件并初始化一个用于扫描 cmd/go/internal/generate.Generator 的结构体:
func generate(pkg, absFile string) bool {
fd, err :=os.Open(absFile)
if err !=nil {
log.Fatalf("generate: %s", err)
}
defer fd.Close()
g :=&Generator{
r: fd,
path: absFile,
pkg: pkg,
commands: make(map[string][]string),
}
return g.run()
}
结构体 cmd/go/internal/generate.Generator 的私有方法 cmd/go/internal/generate.Generator.run 会在对应的文件中扫描指令并执行,该方法的实现原理很简单,我们在这里简单展示一下该方法的简化实现:
func (g *Generator) run() (ok bool) {
input :=bufio.NewReader(g.r)
for {
var buf []byte
buf, err=input.ReadSlice('\n')
if err !=nil {
if err==io.EOF && isGoGenerate(buf) {
err=io.ErrUnexpectedEOF
}
break
}
if !isGoGenerate(buf) {
continue
}
g.setEnv()
words :=g.split(string(buf))
g.exec(words)
}
return true
}
上述代码片段会按行读取被扫描的文件并调用 cmd/go/internal/generate.isGoGenerate 判断当前行是否以 //go:generate 注释开头,如果该行确定以 //go:generate 开头,那么就会解析注释中的命令和参数并调用 cmd/go/internal/generate.Generator.exec 运行当前命令。
stringer 充分利用了 Go 语言标准库对编译器各种能力的支持,其中包括用于解析抽象语法树的 go/ast、用于格式化代码的 go/fmt 等,Go 通过标准库中的这些包对外直接提供了编译器的相关能力,让使用者可以直接在它们上面构建复杂的代码生成机制并实施元编程技术。
作为二进制文件,stringer 命令的入口就是如下所示的 main 函数,在下面的代码中,我们初始化了一个用于解析源文件和生成代码的 Generator,然后开始拼接生成的文件:
func main() {
types :=strings.Split(*typeNames, ",")
...
g :=Generator{
trimPrefix: *trimprefix,
lineComment: *linecomment,
}
...
g.Printf("// Code generated by \"stringer %s\"; DO NOT EDIT.\n", strings.Join(os.Args[1:], " "))
g.Printf("\n")
g.Printf("package %s", g.pkg.name)
g.Printf("\n")
g.Printf("import \"strconv\"\n")
for _, typeName :=range types {
g.generate(typeName)
}
src :=g.format()
baseName :=fmt.Sprintf("%s_string.go", types[0])
outputName=filepath.Join(dir, strings.ToLower(baseName))
if err :=ioutil.WriteFile(outputName, src, 0644); err !=nil {
log.Fatalf("writing output: %s", err)
}
}
从这段代码中我们能看到最终生成文件的轮廓,最上面的调用的几次 Generator.Printf 会在内存中写入文件头的注释、当前包名以及引入的包等,随后会为待处理的类型依次调用 Generator.generate,这里会生成一个签名为 _ 的函数,通过编译器保证枚举类型的值不会改变:
func (g *Generator) generate(typeName string) {
values :=make([]Value, 0, 100)
for _, file :=range g.pkg.files {
file.typeName=typeName
file.values=nil
if file.file !=nil {
ast.Inspect(file.file, file.genDecl)
values=append(values, file.values...)
}
}
g.Printf("func _() {\n")
g.Printf("\t// An \"invalid array index\" compiler error signifies that the constant values have changed.\n")
g.Printf("\t// Re-run the stringer command to generate them again.\n")
g.Printf("\tvar x [1]struct{}\n")
for _, v :=range values {
g.Printf("\t_=x[%s - %s]\n", v.originalName, v.str)
}
g.Printf("}\n")
runs :=splitIntoRuns(values)
switch {
case len(runs)==1:
g.buildOneRun(runs, typeName)
...
}
}
随后调用的 Generator.buildOneRun 会生成两个常量的声明语句并为类型定义 String 方法,其中引用的 stringOneRun 常量是方法的模板,与 Web 服务的前端 HTML 模板比较相似:
func (g *Generator) buildOneRun(runs [][]Value, typeName string) {
values :=runs[0]
g.Printf("\n")
g.declareIndexAndNameVar(values, typeName)
g.Printf(stringOneRun, typeName, usize(len(values)), "")
}
const stringOneRun=`func (i %[1]s) String() string {
if %[3]si >=%[1]s(len(_%[1]s_index)-1) {
return "%[1]s(" + strconv.FormatInt(int64(i), 10) + ")"
}
return _%[1]s_name[_%[1]s_index[i]:_%[1]s_index[i+1]]
}
整个生成代码的过程就是使用编译器提供的库解析源文件并按照已有的模板生成新的代码,这与 Web 服务中利用模板生成 HTML 文件没有太多的区别,只是最终生成的文件的用途稍微有一些不同,
Go 语言的标准库中暴露了编译器的很多能力,其中包含词法分析和语法分析,我们可以直接利用这些现成的解析器编译 Go 语言的源文件并获得抽象语法树,有了识别源文件结构的能力,我们就可以根据源文件对应的抽象语法树自由地生成更多的代码,使用元编程技术来减少代码重复、提高工作效率。
下文章来源于GoUpUp ,作者dj
最近在整理我们项目代码的时候,发现有很多活动的代码在结构和提供的功能上都非常相似。为了方便今后的开发,我花了一点时间编写了一个生成代码框架的工具,最大程度地降低重复劳动。代码本身并不复杂,且与项目代码关联性较大,这里就不展开介绍了。在这个过程中,我发现 Go 标准的模板库text/template和html/template使用起来比较束手束脚,很不方便。我从 GitHub 了解到quicktemplate这个第三方模板库,功能强大,语法简单,使用方便。今天我们就来介绍一下quicktemplate。
本文代码使用 Go Modules。
先创建代码目录并初始化:
$ mkdir quicktemplate && cd quicktemplate
$ go mod init github.com/darjun/go-daily-lib/quicktemplate
quicktemplate会将我们编写的模板代码转换为 Go 语言代码。因此我们需要安装quicktemplate包和一个名为qtc的编译器:
$ go get -u github.com/valyala/quicktemplate
$ go get -u github.com/valyala/quicktemplate/qtc
首先,我们需要编写quicktemplate格式的模板文件,模板文件默认以.qtpl作为扩展名。下面我编写了一个简单的模板文件greeting.qtpl:
All text outside function is treated as comments.
{% func Greeting(name string, count int) %}
{% for i :=0; i < count; i++ %}
Hello, {%s name %}
{% endfor %}
{% endfunc %}
模板语法非常简单,我们只需要简单了解以下 2 点:
将greeting.qtpl保存到templates目录,然后执行qtc命令。该命令会生成对应的 Go 文件greeting.qtpl.go,包名为templates。现在,我们就可以使用这个模板了:
package main
import (
"fmt"
"github.com/darjun/go-daily-lib/quicktemplate/get-started/templates"
)
func main() {
fmt.Println(templates.Greeting("dj", 5))
}
调用模板函数,传入参数,返回渲染后的文本:
$ go run .
Hello, dj
Hello, dj
Hello, dj
Hello, dj
Hello, dj
{%s name %}执行文本替换,{% for %}循环生成重复文本。输出中出现多个空格和换行,这是因为函数内除了语法结构,其他内容都会原样保留,包括空格和换行。
需要注意的是,由于quicktemplate是将模板转换为 Go 代码使用的,所以如果模板有修改,必须先执行qtc命令重新生成 Go 代码,否则修改不生效。
quicktemplate支持 Go 常见的语法结构,if/for/func/import/return。而且写法与直接写 Go 代码没太大的区别,几乎没有学习成本。只是在模板中使用这些语法时,需要使用{%和%}包裹起来,而且if和for等需要添加endif/endfor明确表示结束。
上面我们已经看到如何渲染传入的参数name,使用{%s name %}。由于name是 string 类型,所以在{%后使用s指定类型。quicktemplate还支持其他类型的值:
先编写模板:
{% func Types(a int, b float64, c []byte, d string) %}
int: {%d a %}, float64: {%f.2 b %}, bytes: {%z c %}, string with quotes: {%q d %}, string without quotes: {%j d %}.
{% endfunc %}
然后使用:
func main() {
fmt.Println(templates.Types(1, 5.75, []byte{'a', 'b', 'c'}, "hello"))
}
运行:
$ go run .
int: 1, float64: 5.75, bytes: abc, string with quotes: "hello", string without quotes: hello.
quicktemplate支持在模板中调用模板函数、标准库的函数。由于qtc会直接生成 Go 代码,我们甚至还可以在同目录下编写自己的函数给模板调用,模板 A 中也可以调用模板 B 中定义的函数。
我们先在templates目录下编写一个文件rank.go,定义一个Rank函数,传入分数,返回评级:
package templates
func Rank(score int) string {
if score >= 90 {
return "A"
} else if score >= 80 {
return "B"
} else if score >= 70 {
return "C"
} else if score >= 60 {
return "D"
} else {
return "E"
}
}
然后我们可以在模板中调用这个函数:
{% import "fmt" %}
{% func ScoreList(name2score map[string]int) %}
{% for name, score :=range name2score %}
{%s fmt.Sprintf("%s: score-%d rank-%s", name, score, Rank(score)) %}
{% endfor %}
{% endfunc %}
编译模板:
$ qtc
编写程序:
func main() {
name2score := make(map[string]int)
name2score["dj"] = 85
name2score["lizi"] = 96
name2score["hjw"] = 52
fmt.Println(templates.ScoreList(name2score))
}
运行程序输出:
$ go run .
dj: score-85 rank-B
lizi: score-96 rank-A
hjw: score-52 rank-E
由于我们在模板中用到fmt包,需要先使用{% import %}将该包导入。
在模板中调用另一个模板的函数也是类似的,因为模板最终都会转为 Go 代码。Go 代码中有同样签名的函数。
quicktemplate常用来编写 HTML 页面的模板:
{% func Index(name string) %}
<html>
<head>
<title>Awesome Web</title>
</head>
<body>
<h1>Hi, {%s name %}
<p>Welcome to the awesome web!!!</p>
</body>
</html>
{% endfunc %}
下面编写一个简单的 Web 服务器:
func index(w http.ResponseWriter, r *http.Request) {
templates.WriteIndex(w, r.FormValue("name"))
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", index)
server := &http.Server{
Handler: mux,
Addr: ":8080",
}
log.Fatal(server.ListenAndServe())
}
qtc会生成一个Write*的方法,它接受一个io.Writer的参数。将模板渲染的结果写入这个io.Writer中,我们可以直接将http.ResponseWriter作为参数传入,非常便捷。
运行:
$ qtc
$ go run .
浏览器输入localhost:8080?name=dj查看结果。
quicktemplate至少有下面 3 个优势:
从我个人的实际使用情况来看,确实很方便,很实用。感兴趣的还可以去看看qtc生成的 Go 代码。
大家如果发现好玩、好用的 Go 语言库,欢迎到 Go 每日一库 GitHub 上提交 issue
文翻译自 https://github.com/evrone/go-clean-template,由于本人翻译水平有限,翻译不当之处烦请指出。希望大家看了这篇文章能有所帮助。感谢捧场。
概括
模板的作用 :
模版使用了 Robert Martin ( 也叫 Bob 叔叔 ) 的原则[1]。
Go-clean-template[2] 此仓库由 Evrone[3] 创建及维护。
目录内容
本地开发
# Postgres, RabbitMQ
$ make compose-up
# Run app with migrations
$ make run
集成测试 ( 可以在 CI 中运行 )
# DB, app + migrations, integration tests
$ make compose-up-integration-test
├── cmd
│ └── app
│ └── main.go
├── config
│ ├── config.go
│ └── config.yml
├── docs
│ ├── docs.go
│ ├── swagger.json
│ └── swagger.yaml
├── go.mod
├── go.sum
├── integration-test
│ ├── Dockerfile
│ └── integration_test.go
├── internal
│ ├── app
│ │ ├── app.go
│ │ └── migrate.go
│ ├── delivery
│ │ ├── amqp_rpc
│ │ │ ├── router.go
│ │ │ └── translation.go
│ │ └── http
│ │ └── v1
│ │ ├── error.go
│ │ ├── router.go
│ │ └── translation.go
│ ├── domain
│ │ └── translation.go
│ └── service
│ ├── interfaces.go
│ ├── repo
│ │ └── translation_postgres.go
│ ├── translation.go
│ └── webapi
│ └── translation_google.go
├── migrations
│ ├── 20210221023242_migrate_name.down.sql
│ └── 20210221023242_migrate_name.up.sql
└── pkg
├── httpserver
│ ├── options.go
│ └── server.go
├── logger
│ ├── interface.go
│ ├── logger.go
│ └── zap.go
├── postgres
│ ├── options.go
│ └── postgres.go
└── rabbitmq
└── rmq_rpc
├── client
│ ├── client.go
│ └── options.go
├── connection.go
├── errors.go
└── server
├── options.go
└── server.go
cmd/app/main.go
配置和日志实例的初始化,main 函数中调用internal/app/app.go 文件中 的 Run 函数,main 函数将会在此 "延续"。
config
配置。首先读取 config.yml,然后用环境变量覆盖相匹配的 yaml 配置。配置的结构体在 config.go 文件中。env-required: true 结构体标签强制您指定一个值 ( 在 yaml 或在环境变量中 )。
对于配置读取,我们选择 cleanenv[4] 库。它在 GitHub 上没有很多 star,但很简单且满足所有的需求。
从 yaml 中读取配置违背了12 要素,但在实践中,它比从环境变量中读取整个配置更方便。假设默认值定义在 yaml 中,敏感的变量定义在环境变量中。
docs
Swagger 文档。可以由 swag[5] 库自动生成。而你不需要自己改正任何事情。
integration-test
集成测试。它们作为单独的容器启动,紧挨着应用程序容器。使用 go-hit[6] 测试 REST API 非常方便。
internal/app
在 app.go 文件中一般会有一个 Run 函数,它“延续”了main函数。
这是创建所有主要对象的地方。依赖注入通过“ New...”构造函数 ( 参见依赖注入 ) 。这种技术允许我们使用依赖注入原则对应用程序进行分层,使得业务逻辑独立于其他层。
接下来,为了优雅的完成,我们启动服务并在select中等待特定的信号。如果 app.go 代码越来越多,可以将其拆分为多个文件。
对于大量的注入,可以使用 wire[7] 库 ( wire 是一个代码生成工具,它使用依赖注入自动连接组件)。
migrate.go 文件用于数据库自动迁移。如果指定了 migrate 标签的参数,则会包含它。例如 :
$ go run -tags migrate ./cmd/app
internal/delivery
服务的handler层 ( MVC 控制器 )。模板展示了两个服务:
服务的路由也以同样的风格编写 :
internal/delivery/http
简单的 REST 版本控制。对于 v2,我们需要添加具有相同内容的 http/v2 文件夹。在 internal/app 程序文件中添加以下行 :
handler :=gin.New()
v1.NewRouter(handler, translationService)
v2.NewRouter(handler, translationService)
你可以使用任何其他的 HTTP 框架甚至是标准的 net/http 库来代替 Gin。
在 v1/router.go 和上面的 handler 方法中,有一些注释是用 swag库来生成 swagger 文档的。
internal/domain
业务逻辑的实体 ( 模型 ) 可以在任何层中使用。也可以有方法,例如,用于验证。
internal/service
业务逻辑
Repositories、 webapi、 rpc 和其他业务逻辑结构被注入到业务逻辑结构中 ( 见依赖注入 )。
internal/service/repo
repository 是业务逻辑使用的抽象存储 ( 数据库 )。
internal/service/webapi
它是业务逻辑使用的抽象 web API。例如,它可能是业务逻辑通过 REST API 访问的另一个微服务。包的名称根据用途而变化。
pkg/rabbitmq
RabbitMQ RPC 模式 :
为了消除业务逻辑对外部包的依赖,使用了依赖注入。
例如,通过 NewService 构造函数,我们将依赖注入到业务逻辑的结构中。这使得业务逻辑独立 ( 便于移植 )。我们可以重写接口的实现,而不需要对 service 包进行更改。
package service
import (
// Nothing!
)
type Repository interface {
Get()
}
type Service struct {
repo Repository
}
func NewService(r Repository) *Service{
return &Service{r}
}
func (s *Service) Do() {
s.repo.Get()
}
它还允许我们自动生成模拟参数 ( 例如使用 mockery[8] ) 和轻松地编写单元测试。
我们可以不受特定实现的约束,来将一个组件更改为另一个组件。如果新组件实现了该接口,则业务逻辑中不需要进行任何更改。
关键点
程序员在编写了大量代码后才意识到应用程序的最佳架构。
一个好的架构允许尽可能推迟决策。
主要原则
Dependency Inversion ( 与 SOLID 相同 ) 是依赖倒置的原则。依赖关系的方向是从外层到内层。由于这个原因,业务逻辑和实体仍然独立于系统的其他部分。
因此,应用程序分为内部和外部两个层次 :
Clean Architecture
业务逻辑的内层应该是整洁的,它应该 :
业务逻辑对 Postgres 或详细的 web API 一无所知。业务逻辑应该具有一个用于处理抽象数据库或抽象 web API 的接口。
外层还有其他限制 :
例如,你需要从 HTTP ( 控制器 ) 访问数据库。HTTP 和数据库都在外层,这意味着它们对彼此一无所知。它们之间的通信是通过 service ( 业务逻辑 ) 进行的 :
HTTP > service
service > repository (Postgres)
service < repository (Postgres)
HTTP < service
符号 > 和 < 通过接口显示层与层边界的交集,如图所示 :
Example
或者更复杂的业务逻辑 :
HTTP > service
service > repository
service < repository
service > webapi
service < webapi
service > RPC
service < RPC
service > repository
service < repository
HTTP < service
层级
Example
整洁架构的术语
业务逻辑直接交互的层通常称为基础设施层。它们可以是存储库 internal/service/repo、web API internal/service/webapi、任何pkg,以及其他微服务。在模板中,_ infrastructure 包位于 internal/service 中。
你可以根据需要去选择如何调用入口点。选项如下 :
附加层
经典版本的 整洁架构之道[9] 是为构建大型单体应用程序而设计的,它有4层。
在最初的版本中,外层被分为两个以上的层,两层之间也存在相互依赖关系倒置 ( 定向内部 ),并通过接口进行通信。
在逻辑复杂的情况下,内层也分为两个( 接口分离 )。
复杂的工具可以被划分成更多的附加层,但你应该在确实需要时再添加层。
替代方法
除了整洁架构之道,洋葱架构和六边形架构 ( 端口适配器模式 ) 是类似的。两者都是基于依赖倒置的原则。端口和适配器模式非常接近于整洁架构之道,差异主要在术语上。
写在最后
Freemen App是一款专注于IT程序员求职招聘的一个求职平台,旨在帮助IT技术工作者能更好更快入职及努力协调IT技术者工作和生活的关系,让工作更自由!
程序员专属求职平台
[1]原则: https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html
[2]Go-clean-template: https://evrone.com/go-clean-template?utm_source=github&utm_campaign=go-clean-template
[3]Evrone: https://evrone.com/?utm_source=github&utm_campaign=go-clean-template
[4]cleanenv: https://github.com/ilyakaznacheev/cleanenv
[5]swag: https://github.com/swaggo/swag
[6]go-hit: https://github.com/Eun/go-hit
[7]wire: https://github.com/google/wire
[8]mockery: https://github.com/vektra/mockery
[9]整洁架构之道: https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html
[10]整洁架构之道: https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html
[11]12 要素: https://12factor.net/ru/
本文转载自Go招聘
*请认真填写需求信息,我们会在24小时内与您取得联系。