Go 语言构建网站之基础代码结构
在使用 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 buhaoyong
和 go 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 工具的情况下怎样愉快的操作数据库 🍻