跳过正文

如何组织Go语言中的程序

·493 字·3 分钟
Remy
作者
Remy
Bug的设计师,故障的制造机,P0的背锅侠。代码里的隐秘问题总能被我创造性地解锁。写代码如同解谜,有时谜底是惊喜,有时是惊吓。

前言
#

在我使用go的过程当中,其中最难的一部分就是如何组织我们的应用程序,在使用go之前,我用过rails,django,后面也用过flask,但是这些框架一般都会给你一定的项目结构,和一些编程约定,尤其像rails这样拿约定大于配置的指导原则。但是在go中,没有任何约定的项目组织结构,我将展示四种常见的约定,当然很多人可能不太认可。随时和我讨论

1. 不要使用全局变量
#

net/http 包中有一个example,展示 http.HandleFunc 是如何注册的

package main

import (
 "fmt"
 "net/http"
)

func main() {
    http.HandleFunc("/hello", hello)
    http.ListenAndServe(":8080", nil)
}

func hello(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "hi!")
}

这个例子给出了一个简单的方法来使用 net/http ,但是却教给我们一个坏习惯。hello 这个Handler是一个全局变量, 正因为如此,你可能会添加一个全局的数据库链接或全局配置。一旦你开始想编写单元测试,这些全局变量就是你的噩梦。

更好的办法是为处理程序制定特定的类型,这样他们就可以包含所需的变量

type HelloHandler struct {
    db *sql.DB
}

func (h *HelloHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    var name string
    // Execute the query.
    row := h.db.QueryRow("SELECT myname FROM mytable")
    if err := row.Scan(&name); err != nil {
        http.Error(w, err.Error(), 500)
        return
    }
    // Write it back to the client.
    fmt.Fprintf(w, "hi %s!\n", name)
}

现在我们就可以初始化我们的数据库和注册我们的Handler,且我们也不必使用全局变量了

func main() {
    // Open our database connection.
    db, err := sql.Open("postgres", "…")
    if err != nil {
        log.Fatal(err)
    }
    // Register our handler.
    http.Handle("/hello", &HelloHandler{db: db})
    http.ListenAndServe(":8080", nil)
}

这还又一个好处是在单元测试中,我们不需要一个HTTP Server

func TestHelloHandler_ServeHTTP(t *testing.T) {
    // Open our connection and setup our handler.
    db, _ := sql.Open("postgres", "...")
    defer db.Close()
    h := HelloHandler{db: db}
    // Execute our handler with a simple buffer.
    rec := httptest.NewRecorder()
    rec.Body = bytes.NewBuffer()
    h.ServeHTTP(rec, nil)
    if rec.Body.String() != "hi bob!\n" {
        t.Errorf("unexpected response: %s", rec.Body.String())
    }
}

当然如果你不喜欢创建这么多struct,我们可以使用闭包的特性,简化这个步骤

package main

import (
	"net/http"
	"database/sql"
	"fmt"
	"log"
	"os"
)

func helloHandler(db *sql.DB) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    		var name string
    		// Execute the query.
    		row := db.QueryRow("SELECT myname FROM mytable")
    		if err := row.Scan(&name); err != nil {
        		http.Error(w, err.Error(), 500)
        		return
    		}
    		// Write it back to the client.
    		fmt.Fprintf(w, "hi %s!\n", name)
    	})
}

func withMetrics(l *log.Logger, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		began := time.Now()
		next.ServeHTTP(w, r)
		l.Printf("%s %s took %s", r.Method, r.URL, time.Since(began))
	})
}

func main() {
	// Open our database connection.
	db, err := sql.Open("postgres", "…")
	if err != nil {
		log.Fatal(err)
	}
	// Create our logger
	logger := log.New(os.Stdout, "", 0)
	// Register our handler.
	http.Handle("/hello", helloHandler(db))
	// Register our handler with metrics logging
	http.Handle("/hello_again", withMetrics(logger, helloHandler(db)))
	http.ListenAndServe(":8080", nil)
}

2. 将你的二进制执行程序与你的业务逻辑分离
#

我们最常见的是将main.go的文件放到根目录下,这样当有人运行 go get 时, 我们的应用程序就会自动安装。然而,把main.go文件和我们的业务逻辑结合在同一个包里有两个后果

  1. 他使我们的应用程序无法作为一个库来使用。
  2. 我只能又一个应用程序的二进制

解决这个问题的办法是我们创建一个cmd目录,其中每个子目录都是一个应用程序的二进制文件。

app/
    cmd/
        camget/
            main.go
        camgount/
            main.go
        camgout/
            main.go
        camtool/
            main.go

用库的角度去构建你的应用程序
#

将main.go文件从根目录中移除,可以让你从库的角度来构建你的应用程序。你的应用程序二进制文件只是你的应用程序库的一个客户端。这样分离后,你的应用程序既能当二进制程序分发也能将你的应用程序当作库给他人使用

adder/
  adder.go
  cmd/
    adder/
      main.go
    adder-server/
      main.go

3. 为特定程序包装上下文类型
#

一些通用类型应该提供包装为应用程序提供上下文,一个很好的例子是包装DB和数据库事务Tx类型

func Open(dataSourceName string) (*DB, error) {
    db, err := sql.Open("postgres", dataSourceName)
    if err != nil {
        return nil, err
    }
    return &DB{db}, nil
}
// Begin starts an returns a new transaction.
func (db *DB) Begin() (*Tx, error) {
    tx, err := db.DB.Begin()
    if err != nil {
        return nil, err
    }
    return &Tx{tx}, nil
}

现在我们可以在事务中添加应用程序方法。例如,如果我们应用程序需要在创建之前验证用户,那么添加Tx.CreateUser()

func (tx *Tx) CreateUser(u *User) error {
    // Validate the input.
    if u == nil {
        return errors.New("user required")
    } else if u.Name == "" {
        return errors.New("name required")
    }

    // Perform the actual insert and return any errors.
    return tx.Exec(`INSERT INTO users (...) VALUES`, ...)
}

也许这个方法会变得很复杂,但对于调用折来说,这一切都被隔离了。

4. 不要疯狂创建子包
#

大多数语言让你按照自己的想法来组织包结构,例如java,没几个类都会塞进另一个包中,而这些包会互相包含。这就太令人头疼了。 在Go中,对包的要求只有一个,你不能循环引用,如 A 包含 B 包, B包 依赖 C 包,C 包又依赖 A 包, 这就太没有必要了。这让我意识到,除非文件太多,我没有很好的理由将包分开。譬如我最近做的项目就是疯狂拆分子包,项目后期基本上就是在拆依赖

biz/
    xxx.go
    user/
        model/
        rpc/
        handler/
.....

而我现在更喜欢单文件包组织,当然,有几个方向让我向拆分包装的方法

  1. 每个文件中把相关的类型和代码集中在一起,如果你的类型和函数组织的足够好,那么我发现文件往往在200行到500行之间,听起来挺多的,实际上如果你组织的够好其实很容易浏览。1000行我是我对但文件理解的上线
  2. 将最终要得类型组织在文件的顶部,并在文件底部按重要性倒序添加类型
  3. 一旦你的但文件超过1000行,那么应该评估如何拆分

Bolt的项目就是个很好的例子,Bolt就是但文件结构

bucket.go
cursor.go
db.go
freelist.go
node.go
page.go
tx.go

tl;dr
#

代码组织是一件非常非常困难的事情,他很少得到关注。我说的这些只是一些技巧能够帮助我们编写更容易理解和维护的代码,如果你用ruby,java等方式来写go项目,那么你可能会感到混乱