[譯] 標準化的包佈局(Standard Package Layout)

通常來講是使用 Vendoring 做爲包管理工具。在 Go 社區已經能夠看到一些重要的問題,可是有一個問題在社區中不多被說起,即應用的包佈局。前端

我曾經參與編寫過的每個 Go 應用對這個問題彷佛都有不一樣的答案, 我該如何組織個人代碼? 。一些應用會把全部的東西都放到一個包裏,而其它應用則會選擇按照類型或模塊來組織代碼。若是沒有一個適用於整個團隊的策略,你將發現代碼會散佈在你應用不一樣包裏面。對於 Go 應用程序包佈局的設計咱們須要一個更好的標準。android

我提議有一個更好的方式。經過遵循一些簡單的規則咱們就能夠解耦咱們的代碼,使之更易於測試而且可使咱們的項目有一致的結構,在深刻探討這個方式以前,讓咱們來看下目前人們組織項目一些最多見的方式。ios


更新:我收到了不少關於這種方式很是棒的反饋,其中最多的是想要看到一個使用這種方式構建的應用。因而我已經開始從新寫一系列文章記錄使用這種包佈局方式來構建應用,叫作 Building WTF Dial.git

常見的有缺陷的方式

如今彷佛有幾種通用的 Go 應用組織方式,它們都有各自的缺陷。github

方法 #1: 單個包

把你全部的代碼都扔進一個包,對於一個小的應用來講這樣就能夠很好的工做。它消除了產生循環依賴問題的可能,由於在你的應用代碼中並無任何依賴。golang

我曾經看到過使用這種方式構建超過 10K 行代碼的應用 SLOC。可是一旦代碼量超過這個數量,定位和獨立你的代碼將會變得很是困難。spring

方法 #2: Rails 風格佈局

另外一種組織你代碼的方式是根據它的功能類型。好比說,把全部你的 處理器,控制器,模型代碼都分別放在獨立的包中。我以前看到不少前 Rails 開發者(包括我本身)都使用這種方式來組織代碼。sql

可是使用這種方式有兩個問題。首先你的命名將會變得糟糕透頂,你最終會獲得相似 controller.UserController 這樣的命名,在這種命名中你重複了包名和類型名。對於命名,我是一個有執念的人。我相信當你在去除無用代碼時名稱是你最好的文檔。好的名稱也是高質量代碼的表明,當其餘人讀代碼時老是最早注意到這個。數據庫

更大的問題在於循環依賴。你不一樣的功能類型也許須要互相引用對方。只有當你維護單向依賴關係時,這個應用纔可以工做,可是在不少時候維護單向依賴並不簡單。後端

方法 #3:根據模塊組織代碼

這個方式相似於前面的 Rails 風格佈局,可是咱們是使用模塊來組織代碼而不是功能。好比說,你或許會有一個 user 包和一個 account 包。

咱們發現使用這種方式也會遇到以前一樣的問題。咱們最後也會遇到像 users.User. 這樣可怕的命名。若是咱們的 accounts.Controller 須要和 users.Controller 進行交互,那麼咱們一樣會遇到相同的循環依賴問題,反之亦然。

一個更好的方式

我在項目使用的包組織策略涉及到如下4個簡單的原則:

  1. Root 包是用於域類型的
  2. 經過依賴關係來組織子包
  3. 使用一個共享的 mock 子包
  4. Main 包將依賴關係聯繫到一塊兒

這些規則幫助隔離咱們的包而且在整個應用中定義了一個清晰的領域語言。讓咱們來看看這些規則在實踐中是如何使用的。

#1. Root 包是用於域類型的

你的應用有一種用於描述數據和進程是如何交互的邏輯層面的高級語言。這就是你的域。若是你有一個電子商務應用,那你的域就會涉及到客戶,帳戶,信用卡支付,以及存貨等內容。若是你的應用是 Facebook,你的域就會是用戶,點贊以及用戶間的關係。這些是不依賴於你基礎技術的東西。

我把個人域類型放在 root 保存。這個包只包含了簡單的數據類型,好比說包含用戶信息的 User 結構或者是獲取和保存用戶數據的 UserService 接口。

這個 root 包會像如下這樣:

package myapp

type User struct {
	ID      int
	Name    string
	Address Address
}

type UserService interface {
	User(id int) (*User, error)
	Users() ([]*User, error)
	CreateUser(u *User) error
	DeleteUser(id int) error
}
複製代碼

這使你的 root 包變的很是簡單。你也能夠在這個包裏放包含執行操做的類型,可是它們應該只依賴於其它的域類型。好比說,你能夠在這個包加一個按期輪詢 UserService 的類型。可是,它不該該調用外部服務或者將數據保存到數據庫。這些是實現細節。

root 包不該該依賴於你應用中的其它任何包

#2. 經過依賴關係來組織子包

若是你的 root 包並不容許有外部依賴,那麼咱們就必須把這些依賴放到子包裏。在這種包佈局的方式中,子包就至關於你域和實現之間的適配器。

好比說,你的 UserService 多是由 PostgreSQL 數據庫提供支持。你能夠在應用中引入一個叫作 postgres 的子包用來提供 postgres.UserService 的實現。

package postgres

import (
	"database/sql"

	"github.com/benbjohnson/myapp"
	_ "github.com/lib/pq"
)

// UserService represents a PostgreSQL implementation of myapp.UserService.
type UserService struct {
	DB *sql.DB
}

// User returns a user for a given id.
func (s *UserService) User(id int) (*myapp.User, error) {
	var u myapp.User
	row := db.QueryRow(`SELECT id, name FROM users WHERE id = $1`, id)
	if row.Scan(&u.ID, &u.Name); err != nil {
		return nil, err
	}
	return &u, nil
}

// implement remaining myapp.UserService interface...
複製代碼

這樣就隔離了咱們對 PostgreSQL 的依賴關係,從而簡化了測試,併爲咱們未來遷移到其它數據庫提供了一種簡單的方法。若是你打算支持像 BoltDB 這種數據庫的實現,就能夠把它看做是一個可插拔體系結構。

這也爲你實現層級提供了一種方式。好比說你想要在 Postgresql 前面加一個內存緩存 LRU cache。你能夠添加一個 UserCache 類型來包裝你的 Postgresql 實現。

package myapp

// UserCache wraps a UserService to provide an in-memory cache.
type UserCache struct {
        cache   map[int]*User
        service UserService
}

// NewUserCache returns a new read-through cache for service.
func NewUserCache(service UserService) *UserCache {
        return &UserCache{
                cache: make(map[int]*User),
                service: service,
        }
}

// User returns a user for a given id.
// Returns the cached instance if available.
func (c *UserCache) User(id int) (*User, error) {
	// Check the local cache first.
        if u := c.cache[id]]; u != nil {
                return u, nil
        }

	// Otherwise fetch from the underlying service.
        u, err := c.service.User(id)
        if err != nil {
        	return nil, err
        } else if u != nil {
        	c.cache[id] = u
        }
        return u, err
}
複製代碼

咱們也能夠在標準庫中看到使用這種方式組織代碼。io. Reader 是一個用於讀取字節的域類型,它的實現是經過組織依賴關係 tar.Readergzip.Readermultipart.Reader 來實現的。在標準庫中也能夠看到層級方式,常常能夠看到 os.Filebufio.Readergzip.Readertar.Reader 這樣一個個層級封裝。

依賴之間的依賴

依賴關係並非孤立的。你能夠把 User 數據保存在 Postgresql 中,而把金融交易數據保存在像 Stripe 這樣的第三方服務。在這種狀況下咱們用一個邏輯上的域類型來封裝對 Stripe 的依賴,讓咱們把它叫作 TransactionService

經過把咱們的 TransactionService 添加到 UserService ,咱們解耦了咱們的兩個依賴。

type UserService struct {
        DB *sql.DB
        TransactionService myapp.TransactionService
}
複製代碼

如今咱們的依賴只經過共有的領域語言交流。這意味着咱們能夠把 Postgresql 切換爲 MySQL 或者把 Strip 切換爲另外一個支付的內部處理器而不用擔憂影響到其它的依賴。

不要只對第三方的依賴添加這個限制

這聽起來雖然有點奇怪,可是我也使用這種方式來隔離對標準庫的依賴關係。例如 net/http 包只是另外一種依賴。咱們能夠經過在應用中包含一個 http 子包來隔離對它的依賴。

有一個名稱與它所包裝依賴相同的包看起來彷佛很奇怪,可是這只是內部實現。除非你容許你應用的其它部分使用 net/http ,不然在你的應用中就不會有命名衝突。複製 http 名稱的好處在於它要求你把全部 HTTP 相關代碼都隔離到 http 包中。

package http

import (
        "net/http"
        
        "github.com/benbjohnson/myapp"
)

type Handler struct {
        UserService myapp.UserService
}

func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
        // handle request
}
複製代碼

如今,你的 http.Handler 就像是一個在域和 HTTP 協議以前的適配器。

#3. 使用一個共享的 mock 子包

由於咱們的依賴經過域接口已經和其它的依賴隔離了,因此咱們可使用這些鏈接點來注入模擬實現。

這裏有幾個像 GoMock 的模擬庫來幫你生成模擬數據,可是我我的更喜歡本身寫。我發現許多的模擬工具都過於複雜了。

我使用的模擬很是簡單。好比說,一個對 UserService 的模擬就像下面這樣:

package mock

import "github.com/benbjohnson/myapp"

// UserService represents a mock implementation of myapp.UserService.
type UserService struct {
        UserFn      func(id int) (*myapp.User, error)
        UserInvoked bool

        UsersFn     func() ([]*myapp.User, error)
        UsersInvoked bool

        // additional function implementations...
}

// User invokes the mock implementation and marks the function as invoked.
func (s *UserService) User(id int) (*myapp.User, error) {
        s.UserInvoked = true
        return s.UserFn(id)
}

// additional functions: Users(), CreateUser(), DeleteUser()
複製代碼

這個模擬讓我能夠注入函數到任何使用 myapp.UserService 的接口來驗證參數,返回預期的數據或者注入失敗。

假設咱們想測試咱們上面構建的 http.Handler

package http_test

import (
	"testing"
	"net/http"
	"net/http/httptest"

	"github.com/benbjohnson/myapp/mock"
)

func TestHandler(t *testing.T) {
	// Inject our mock into our handler.
	var us mock.UserService
	var h Handler
	h.UserService = &us

	// Mock our User() call.
	us.UserFn = func(id int) (*myapp.User, error) {
		if id != 100 {
			t.Fatalf("unexpected id: %d", id)
		}
		return &myapp.User{ID: 100, Name: "susy"}, nil
	}

	// Invoke the handler.
	w := httptest.NewRecorder()
	r, _ := http.NewRequest("GET", "/users/100", nil)
	h.ServeHTTP(w, r)
	
	// Validate mock.
	if !us.UserInvoked {
		t.Fatal("expected User() to be invoked")
	}
}
複製代碼

咱們的模擬徹底隔離了咱們的單元測試,讓咱們只測試 HTTP 協議的處理。

#4. Main 包將依賴關係聯繫到一塊兒

當全部這些依賴包獨立維護時,你可能想知道如何把它們聚合到一塊兒。這就是 main 包的工做。

Main 包佈局

一個應用可能會產生多個二進制文件, 因此咱們使用 Go 的慣例把咱們的 main 包做爲 cmd 包的子目錄。 好比,咱們的項目中可能有一個 myapp 服務二進制文件,還有一個用於在終端管理服務 的 myappctl 客戶端二進制文件。咱們的包將像這樣佈局:

myapp/
    cmd/
        myapp/
            main.go
        myappctl/
            main.go
複製代碼

在編譯時注入依賴

"依賴注入"這個詞已經成了一個很差的說法,它讓人聯想到 Spring 冗長的XML文件。然而,這個術語所表明的真正含義只是要把依賴關係傳遞給咱們的對象,而不是要求對象構建或者找到這個依賴關係自己。

main 包中咱們能夠選擇哪些依賴注入到哪些對象中。由於 main 包只是簡單的鏈接了各部分,因此 main 中的代碼每每是比較小和瑣碎的。

package main

import (
	"log"
	"os"
	
	"github.com/benbjohnson/myapp"
	"github.com/benbjohnson/myapp/postgres"
	"github.com/benbjohnson/myapp/http"
)

func main() {
	// Connect to database.
	db, err := postgres.Open(os.Getenv("DB"))
	if err != nil {
		log.Fatal(err)
	}
	defer db.Close()

	// Create services.
	us := &postgres.UserService{DB: db}

	// Attach to HTTP handler.
	var h http.Handler
	h.UserService = us
	
	// start http server...
}
複製代碼

注意到你的 main 包也是一個適配器很重要。他把全部終端鏈接到你的域。

結論

應用設計是一個難題。儘管作出了這麼多的設計決策,若是沒有一套堅實的原則來指導,那你的問題只會變的更糟。咱們已經列舉了 Go 應用佈局設計的幾種方式,而且咱們也看到了不少它們的缺陷。

我相信從依賴關係的角度來看待設計會使代碼組織的更簡單,更加容易理解。首先咱們設計咱們的領域語言,而後咱們隔離咱們的依賴關係,以後介紹了使用 mock 來隔離咱們的測試,最後咱們把全部東西都在 main 包中綁了起來。

能夠在下一個你設計的應用中考慮下這些原則。若是有您有任何問題或者想討論這個設計,請在 Twitter 上 @benbjohnson與我聯繫,或者在Gopher slack 查找 benbjohnson 來找到我。


掘金翻譯計劃 是一個翻譯優質互聯網技術文章的社區,文章來源爲 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章
相關標籤/搜索