依存関係。 ジェネリック。 Goコミュニティの問題のリストによく載っていますが、めったに思い出されない問題が1つあります。それは、パッケージのコードの編成です。
私が使用した各Goアプリケーションには、「コードをどのように整理する必要がありますか?」という質問に対する独自の回答があるようです。 一部のアプリケーションはすべてを1つのパッケージに入れますが、他のアプリケーションはロジックをタイプまたはモジュール別にグループ化します。 すべてのチームメンバーが従う優れた戦略がなければ、遅かれ早かれ、コードが多数のパッケージに非常に散らばっていることがわかります。 Goアプリケーションのコード設計には、何らかの標準が必要です。
より良いアプローチをお勧めします。 一連の単純なルールに従って、コードが無関係であり、テストしやすく、プロジェクト構造がシームレスであることを確認できます。 しかし、詳細に入る前に、Goコードを構造化するために最も一般的に使用されるアプローチを見てみましょう。
最も一般的な間違ったアプローチ
Goコードを整理するには、いくつかの一般的なアプローチがあり、それぞれに欠点があります。
アプローチ#1:モノリス
すべてのコードを1つのパッケージに入れることは、実際には小さなアプリケーションに非常に適しています。 これにより、実際にはアプリケーション内に依存関係がないため、循環依存関係の問題が発生しなくなります。
経験から、このアプローチは最大10,000行のコードサイズのアプリケーションに最適です。 さらに、モノリスコードの一部を理解して分離することは非常に困難になります。
アプローチ#2:Railsスタイル
2番目のアプローチは、機能的な目的に応じてコードをグループ化することです。 たとえば、すべてのハンドラーは1つのパッケージに入れられ、コントローラーは別のパッケージに入れられ、モデルは3番目のパッケージに入れられます。 私はこのアプローチを以前のRails開発者(私を含む)で何度も見てきました。
しかし、このアプローチには2つの問題があります。 最初に、怪しい名前を取得します。 タイプ名にパッケージ名を複製するcontroller.UserController
ような名前になります。 一般的に、私は自分自身を名前への慎重なアプローチの熱烈な支持者と考えています。 特に、あなたがコードの茂みを歩いているとき、良い名前があなたの最高のドキュメントであると確信しています。 多くの場合、名前はコード品質の指標としても機能します。これは、プログラマーが最初にコードに遭遇したときに最初に目にするものです。
しかし実際、最大の問題は循環依存です。 パッケージ化された機能タイプは、互いに必要な場合があります。 そして、これはこれらの依存関係が一方向の場合にのみ機能しますが、ほとんどの場合、アプリケーションはより複雑になります。
アプローチ3:モジュログループ化
このアプローチは、コードを関数ではなくモジュールでグループ化することを除いて、前のアプローチと似ています。 たとえば、コードを分割して、 user
パッケージとaccounts
パッケージをuser
ます。
ここにも同じ問題があります。 繰り返しになりますが、 users.User
ような恐ろしい名前と、 accounts.Controller
がusers.Controller
とやり取りするときの循環依存関係の問題、およびその逆です。
最善のアプローチ
プロジェクトで使用するコードを整理するための戦略には、4つの原則が含まれています。
- ドメインタイプのルート(メイン)パッケージ
- 依存関係によるパッケージのグループ化
- 一般的な
mock
パッケージを使用する -
main
パッケージmain
依存関係をmain
ます
これらのルールは、パッケージを分離し、アプリケーション内で明確な言語を確立するのに役立ちます。 これらの各ポイントが実際にどのように機能するかを見てみましょう。
1.ドメインタイプのルートパッケージ
アプリケーションには、データとプロセスの相互作用を記述する論理的な高レベル言語があります。 これはあなたのドメインです。 eコマースアプリケーションを作成している場合、ドメインには顧客、アカウント、クレジットカードの借方記入、在庫などの概念が含まれます。 Facebookの場合、ドメインはユーザー、いいね、関係です。 言い換えれば、これは選択された技術に依存しないものです。
メインのルートパッケージにドメインタイプを配置します。 このパッケージには、ユーザーなどの単純なデータ型のみが含まれます。ユーザーには、ユーザーデータのみ、またはユーザーデータの保存とクエリ用のUserServiceインターフェイスがあります。
次のようになります。
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 }
これにより、ルートパッケージが非常にシンプルになります。 特定のアクションを実行するタイプを含めることもできますが、それは他のドメインタイプに完全に依存している場合のみです。 たとえば、UserServiceを定期的にポーリングするタイプを追加できます。 ただし、外部サービスを呼び出したり、データベースに保存したりしないでください。 これらは実装の詳細です。
ルートパッケージは、アプリケーション内の他のパッケージに依存しないようにしてください!
2.パッケージを依存関係別にグループ化する
ルートパッケージに外部依存関係を持たせることは許可されていないため、これらの依存関係を他のサブパッケージに移動する必要があります。 このアプローチでは、ネストされたパッケージは、ドメインと実装の間のアダプターとして存在します。
たとえば、UserServiceはPostgreSQLデータベースとして実装される場合があります。 postgres.UserServiceの実装を提供するアプリケーションにpostgresパッケージを追加できます。
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キャッシュを追加します。 次に、UserServiceを実装する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.Reader、 gzip.Reader 、 multipart.Reader 。 また、複数のレイヤーで使用することもできます。 多くの場合、 os.Fileはbufio.Readerでラップされ、 gzip.Readerでラップされ、 さらにtar.Readerでラップされています 。
依存関係間の依存関係
あなたの中毒は通常、単独で生きることはありません。 ユーザーデータをPostgreSQLに保存することもできますが、金融取引データはStripeのような外部サービスに保存できます。 この場合、Stripe依存関係を論理ドメインタイプにラップします。これをTransactionService
と呼びましょう。
TransactionService
をUserService
追加することにより、2つの依存関係を分離します。
type UserService struct { DB *sql.DB TransactionService myapp.TransactionService }
現在、依存関係はドメイン言語のみを使用して通信します。 これは、PostgreSQLからMySQLに切り替えたり、Stripeから別の支払いプロセッサに切り替えたりすることができ、依存関係で何も変更する必要がないことを意味します。
外部の依存関係に限定されない
これは奇妙に聞こえるかもしれませんが、標準ライブラリへの依存関係も同じように分離します。 たとえば、 net/http
パッケージは単なる依存関係です。 埋め込まれたhttp
パッケージをアプリケーションに追加することで分離できます。
標準ライブラリと同じ名前のパッケージがあるのは奇妙に見えるかもしれませんが、これは意図的なものです。 アプリケーションの他の場所でnet / 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) UserInvoked 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
します
これらすべての依存パッケージを使用して、それらがどのように統合されるかを尋ねることができます。 それがmain
パッケージの仕事です。
パッケージ編成main
アプリケーションはいくつかのバイナリ実行可能ファイルで構成されている場合があるため、Goのcmd /サブディレクトリ内のメインパッケージの場所に関する標準合意を使用します。 たとえば、プロジェクトにはmyappctl
サーバー実行可能ファイルに加えて、ターミナルからサーバーを管理するためのmyappctl
バイナリが追加されている場合があります。 ファイルを次のように配置します。
myapp/ cmd/ myapp/ main.go myappctl/ main.go
コンパイル時の依存性注入
「依存性注入」という用語は評判が悪くなっています。 通常、人々はすぐにSpringの XMLファイルについて考え始めます。 しかし実際、この用語は、依存関係を検索するオブジェクトではなく、オブジェクトに依存関係を転送することを意味します。
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... }
また、メインパッケージもアダプターであることを理解することが重要です。 端末をドメインに接続します。
おわりに
アプリケーションの設計は複雑な問題です。 多くの決定を下す必要があり、適切な原則がなければ問題はさらに悪化します。 Goアプリケーションを構築するためのいくつかのアプローチを検討し、それらの欠点を調べました。
依存関係に基づいたコードの編成により、設計が容易になり、コードがより理解しやすくなると確信しています。 まず、ドメインの言語を決定します。 次に、依存関係を分離します。 次に、テスト用のスタブを作成します。 最後に、メインパッケージを使用してすべてを貼り付けます。
次のアプリケーションでこのアプローチを見てください。 質問がある場合、またはアプリケーションの設計について議論したい場合は、Twitter( @benbjohnsonまたはGo Slackチャンネルの benbjohnson) で入手できます。