在使用 Go 语言之前,PHP 一直是我工作中使用频率最高的编程语言。PHP 几乎能满足我工作的所有需求,有些时候满足不了需求也只是因为没有现成的库,自己实现起来成本又太高的,就用其它语言现成的库代替了。

记得有一次朋友找我帮他实现个端口伪装工具,就是写个程序监听所有网卡上的所有网络请求,根据协议和端口作出预设的响应,并记录所有请求记录到日志中,希望是用 Go 语言实现。在此之前对 Go 语言的了解不是很多,工作中也用得少。从那时起,见识到了用 Go 语言开发网络相关的程序时的高效,让我对编程语言有了新的认识。

使用了 Go 语言开发也有一段时间了,从这篇日志开始,说说我是怎么用 Go 语言构建一整套支持网站运行的程序的。

我学习使用 Go 语言开发时,在 awesome-go 了解了 Go 语言社区常用的一些库或者框架。使用比较多的一些框架对比其它语言常用的框架有很大区别,很少有像 Lavavel, Yii, ZendFramework, Spring, Django, Rails 这类功能完善的 MVC 框架,这也和 Go 语言的编程模型有关。但我发现 Go 语言有很丰富的库来支持日常使用,把这些库凑一块儿,基本就能满足开发需求了,还不用受框架的约束。

要把各种各样的库组在一块儿,首先需要解决它们的依赖问题(Depencency Injection)。比如操作数据库的库依赖连接参数,在线支付平台的库依赖加密证书信息,自己开发业务模块依赖数据库操作库、缓存、队列……等。最直接的办法就是把依赖的资源一个一个的创建出来,然后传给需要调用的库。对于简单程序而言这种方式是非常高效的,但对于库比较多,依赖关系链比较长的程序来讲,继续用这种方式处理依赖关系就很麻烦了。这个时候我会使用 wire 来处理依赖关系,wire 会像我们手动处理依赖的方式一样,根据我们的预定义配置直接生成处理依赖关系的代码。

对于网站项目的程序来说,或多或少都会需要支持一些命令行参数或子命令,比如设置配置文件路径的选项,启动计划任务的子命令。Go 语言的 flag 包就可以用来简单的实现命令行参数的功能,但我通常会使用 cobra 这个包来完成这类工作。

有了依赖关系和命令行参数的解决方案,就可以开始搭建基础的程序代码结构了。

初始化

通过 cobra init --pkg-name buhaoyonggo mod init buhaoyong 创建了下面几个文件。

$ tree --charset=ascii
.
|-- LICENSE
|-- cmd
|   `-- root.go
|-- go.mod
`-- main.go

创建出来的代码是可编译和运行的。这里使用 buhaoyong 作为 module 路径,对于私有项目代码,不一定需要使用类似 github.com/google/wire 这样的域名路径。main.go 文件的内容会很少,它的主要作用是调用 cmd/root.go 里的 Execute() 函数,有些时候会在 main.go 文件中增加注释供 go generate 用来生成代码。cmd/root.go 是实现所有命令功能的入口,可以在这里添加全局的参数,管理加载配置文件的方式。

现在有了最基础的样子,此后所有的功能都是在此基础上添砖加瓦。

添加子命令

先来加个新的命令,用于启动我们的网站服务吧。通过 cobra add serve 命令,创建出一个新的文件 cmd/serve.go。通过 cobra add 添加的子命令会在子命令的 init 函数中把自己添加到 root 命令中。编译运行试试

$ go build
$ ./buhaoyong serve
serve called

这里 serve 命令主要功能是运行 http 服务。修改 cmd/serve.go 中 serveCmd.Run 函数内容,使用 Go 语言自带的 net/http 包启动 http 服务

...
var serveCmd = &cobra.Command{
	Use:   "serve",
	Short: "Run http service",
	Run: func(cmd *cobra.Command, args []string) {
		listen := ":3000"
		router := http.NewServeMux()

		router.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
			w.Write([]byte("Hello, I'm buhaoyong\n"))
		})

		server := &http.Server{
			Handler: router,
			Addr:    listen,
		}

		go func() {
			log.Println("Start server on " + listen)
			if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
				log.Fatal(err)
			}
		}()

		c := make(chan os.Signal, 1)
		signal.Notify(c, syscall.SIGINT, syscall.SIGTERM)
		<-c

		ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
		defer cancel()
		server.Shutdown(ctx)
		log.Println("Shutdown...")

		os.Exit(0)
	},
}
...

URL 路由处理

Go 语言自带的 net/http 包没有提供 URL 路径正则匹配、路径变量、中间件等功能,可以使用 mux 这个包来补充这些功能。用 mux 来实现下面的 URL 路径风格的请求

GET                 /user
GET,POST,PUT,DELETE /user/{id}
@@ -24,6 +24,7 @@ import (
 	"syscall"
 	"time"
 
+	"github.com/gorilla/mux"
 	"github.com/spf13/cobra"
 )
 
@@ -33,12 +34,18 @@ var serveCmd = &cobra.Command{
 	Short: "Run http service",
 	Run: func(cmd *cobra.Command, args []string) {
 		listen := ":3000"
-		router := http.NewServeMux()
+		router := mux.NewRouter()
 
 		router.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
 			w.Write([]byte("Hello, I'm buhaoyong\n"))
 		})
 
+		router.HandleFunc("/user", func(w http.ResponseWriter, r *http.Request) {}).Methods("GET")
+		router.HandleFunc("/user/{id}", func(w http.ResponseWriter, r *http.Request) {}).Methods("GET")
+		router.HandleFunc("/user/{id}", func(w http.ResponseWriter, r *http.Request) {}).Methods("POST")
+		router.HandleFunc("/user/{id}", func(w http.ResponseWriter, r *http.Request) {}).Methods("PUT")
+		router.HandleFunc("/user/{id}", func(w http.ResponseWriter, r *http.Request) {}).Methods("DELETE")
+
 		server := &http.Server{
 			Handler: router,
 			Addr:    listen,

当前这个 http 服务只提供了一个访问路径,随着需求的增加,越来越多的访问路径需要添加进去。如果全都添加到 cmd/serve.go 文件里,以后维护起来就比较麻烦。可以把这些访问路径按照功能模块划分开,比如供前台访问的网站首页,供管理员访问的后台管理,供 app 访问的 API 等。

划分模块

在根目录中创建 app 目录(这里的目录名根据个人爱好自定义,没有特别意义),在 app 目录中划分供用户使用的功能模块,这里创建 website 和 api 两个部分,website 可以用来提供官方网站服务,api 用来提供给 iOS、Android 或其它客户端程序调用。如果这些模块是由同一个团队在维护,把各模块放在同一个代码库中,可以提高开发效率。添加一个 Makefile 文件,方便使用 make 命令编译代码

|-- Makefile
|-- app
|   |-- api
|   |   |-- api.go
|   |   `-- controller
|   |       |-- controller.go
|   |       `-- hello.go
|   `-- website
|       |-- controller
|       |   |-- controller.go
|       |   `-- hello.go
|       `-- website.go

各模块各自管理自己处理请求的方式。这里,模块有了它们自己的依赖。比如 app/website/website.go New() 函数依赖 *website.Config 和 *mux.Router 作为参数

func New(config *Config, router *mux.Router) Repository {
	impl := &repositoryImpl{
		config: config,
		router: router,
	}

	impl.setupRoutes()

	return impl
}

需要调用模块,就需要像下面 cmd/serve.go 中的方式,创建模块依赖的参数

@@ -24,6 +24,9 @@ import (
 	"syscall"
 	"time"
 
+	"buhaoyong/app/api"
+	"buhaoyong/app/website"
+
 	"github.com/gorilla/mux"
 	"github.com/spf13/cobra"
 )
@@ -36,15 +39,15 @@ var serveCmd = &cobra.Command{
 		listen := ":3000"
 		router := mux.NewRouter()
 
-		router.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
-			w.Write([]byte("Hello, I'm buhaoyong\n"))
-		})
+		apiConfig := &api.Config{
+			Prefix: "/api",
+		}
+		api.New(apiConfig, router)
 
-		router.HandleFunc("/user", func(w http.ResponseWriter, r *http.Request) {}).Methods("GET")
-		router.HandleFunc("/user/{id}", func(w http.ResponseWriter, r *http.Request) {}).Methods("GET")
-		router.HandleFunc("/user/{id}", func(w http.ResponseWriter, r *http.Request) {}).Methods("POST")
-		router.HandleFunc("/user/{id}", func(w http.ResponseWriter, r *http.Request) {}).Methods("PUT")
-		router.HandleFunc("/user/{id}", func(w http.ResponseWriter, r *http.Request) {}).Methods("DELETE")
+		websiteConfig := &website.Config{
+			Prefix: "/",
+		}
+		website.New(websiteConfig, router)
 
 		server := &http.Server{
 			Handler: router,

处理依赖

从这里开始,wire 就开始派上用场了。创建 app/wire.go, app/component.go 文件,按照 wire 的规则在 app/wire.go 文件配置依赖关系。app/component.go 用来管理全局资源,比如 mux.Router,数据库连接资源,日志组件等。wire 生成的代码和我们手写的差不多,可以直接在生成的代码中添加调试代码。


|-- app
|   |-- api/
|   |-- component.go
|   |-- website/
|   |-- wire.go
|   `-- wire_gen.go

有 wire 帮助处理依赖关系,cmd/servce.go 文件里的代码就更加简洁了。

@@ -24,10 +24,8 @@ import (
 	"syscall"
 	"time"
 
-	"buhaoyong/app/api"
-	"buhaoyong/app/website"
+	"buhaoyong/app"
 
-	"github.com/gorilla/mux"
 	"github.com/spf13/cobra"
 )
 
@@ -37,20 +35,14 @@ var serveCmd = &cobra.Command{
 	Short: "Run http service",
 	Run: func(cmd *cobra.Command, args []string) {
 		listen := ":3000"
-		router := mux.NewRouter()
 
-		apiConfig := &api.Config{
-			Prefix: "/api",
-		}
-		api.New(apiConfig, router)
+		component, _ := app.SetupComponent()
 
-		websiteConfig := &website.Config{
-			Prefix: "/",
-		}
-		website.New(websiteConfig, router)
+		app.SetupAPI(component)
+		app.SetupWebsite(component)
 
 		server := &http.Server{
-			Handler: router,
+			Handler: component.Router,
 			Addr:    listen,
 		}
 

在 app/wire.go 文件里有两个函数 apiConfigProvider() 和 websiteConfigProvider(),它们分别为 API 和 website 提供依赖支持。往后还会有更多此类 Provider 函数写在 app/wire.go 文件里。

func apiConfigProvider() (*api.Config, error) {
	return &api.Config{Prefix: "/api"}, nil
}

func websiteConfigProvider() (*website.Config, error) {
	return &website.Config{Prefix: "/"}, nil
}

从配置文件获取依赖的配置信息

通常我们可以把 Config 需要的数据放在一个单独的配置文件里,这样就可以方便的根据需要调整配置,而不用修改代码发布新版本。我喜欢使用 viper 来管理配置文件,cobra 默认也是使用 viper 来处理配置文件。

创建一个 config.yaml 文件,添加 website 和 api 需要的配置

website:
  domain: 
  prefix: /

api:
  domain: 
  prefix: /api

app/wire.go 文件中的 apiConfigProvider() 和 websiteConfigProvider() 函数可直接从配置文件获取配置信息

func apiConfigProvider() (*api.Config, error) {
	var c api.Config
	key := "api"
	if !viper.IsSet(key) {
		return nil, fmt.Errorf("missing %s config", key)
	}

	if err := viper.UnmarshalKey(key, &c); err != nil {
		return nil, fmt.Errorf("can not decode api config: %w", err)
	}
	return &c, nil
}

func websiteConfigProvider() (*website.Config, error) {
	var c website.Config
	key := "website"
	if !viper.IsSet(key) {
		return nil, fmt.Errorf("missing %s config", key)
	}

	if err := viper.UnmarshalKey(key, &c); err != nil {
		return nil, fmt.Errorf("can not decode website config: %w", err)
	}
	return &c, nil
}

⚠️ 每次修改 app/wire.go 文件都需要使用 wire 重新生成依赖关系代码,避免编译时使用的还是旧的。

重新编译后带上 --config 参数运行,效果是一样的,但代码结构更清晰了。

./buhaoyong --config config.yaml serve

至此,完整的代码结构就像下面这样了,源码可以在 https://github.com/lkebin/go-for-website 查看

.
|-- LICENSE
|-- Makefile
|-- app
|   |-- api
|   |   |-- api.go
|   |   `-- controller
|   |       |-- controller.go
|   |       `-- hello.go
|   |-- component.go
|   |-- website
|   |   |-- controller
|   |   |   |-- controller.go
|   |   |   `-- hello.go
|   |   `-- website.go
|   |-- wire.go
|   `-- wire_gen.go
|-- cmd
|   |-- root.go
|   `-- serve.go
|-- config.yaml
|-- go.mod
|-- go.sum
`-- main.go

下一次接着说数据库相关的操作,说说在不使用 ORM 工具的情况下怎样愉快的操作数据库 🍻