行こう!Goコードを構造化するための3つのアプローチ

こんにちは、Habr!最近、Golang outに関する新しい本があり、その成功は非常に印象的であるため、Goアプリケーションの設計方法に関する非常に重要な記事をここに公開することにしました。この記事で提示されたアイデアは、予見可能な将来に時代遅れになることはないでしょう。おそらく、著者はGoを使用するためのいくつかのガイドラインを予測することさえできましたが、これは近い将来普及する可能性があります。



Go言語は、2009年末に最初に発表され、2012年に正式にリリースされましたが、この数年で本格的に認知され始めました。Goがの一つだった2018年で最も急速に成長している言語2019年で3番目に人気のあるプログラミング言語



Go言語自体はかなり新しいため、開発者コミュニティはコードの記述方法についてそれほど厳密ではありません。 Javaなどの古い言語のコミュニティで同様の規則を見ると、ほとんどのプロジェクトが同様の構造を持っていることがわかります。これは、大規模なコードベースを作成するときに非常に便利ですが、現代の実用的なコンテキストでは逆効果になると多くの人が主張するかもしれません。マイクロシステムの作成と比較的コンパクトなコードベースの維持に進むにつれて、プロジェクトの構造化におけるGoの柔軟性は非常に魅力的になります。



誰もがGolangのhelloworld httpの例を知っており、Javaなどの他の言語の同様の例と比較できます。..。最初の例と2番目の例の間に大きな違いはなく、例を実装するために記述する必要のあるコードの複雑さや量にも違いはありません。しかし、アプローチには根本的な違いがあります。 Goは、「可能な限り簡単なコードを書くことを推奨しています。 Javaのオブジェクト指向の側面は別として、これらのコードスニペットからの最も重要なポイントは次のとおりです。Javaでは操作(インスタンスHttpServerごとに個別のインスタンスが必要ですが、Goではグローバルシングルトンの使用を推奨しています。



このようにして、維持するコードを減らし、渡すリンクを減らす必要があります。サーバーを1つだけ作成する必要があることがわかっている場合(これは通常発生します)、なぜあまりにも多くのことを気にする必要がありますか?この哲学は、コードベースが大きくなるにつれてますます説得力があるように思われます。それにもかかわらず、人生は時々驚きを投げかけます:(。実際には、選択できる抽象化のレベルがまだいくつかあり、それらを誤って組み合わせると、深刻な罠に陥る可能性があります。



そのため、3つに注意を向けたいと思います。 Goコードを整理および構造化するためのアプローチ。これらのアプローチはそれぞれ、異なるレベルの抽象化を意味します。結論として、3つすべてを比較し、これらのアプローチのそれぞれが最も適切なアプリケーションケースを示します。



ユーザーに関する情報(次の図ではメインDBとして示されている)を含むHTTPサーバーを実装し、各ユーザーに役割(たとえば、基本、モデレーター、管理者)が割り当てられ、追加のデータベース(次の図では次のように示されている)も実装します。構成DB)。各役割(読み取り、書き込み、編集など)に予約されているアクセス権のセットを指定します。 HTTPサーバーは、指定されたIDを持つユーザーが持つアクセス権のセットを返すエンドポイントを実装する必要があります。







次に、構成データベースの変更頻度が低く、ロードに時間がかかると仮定します。そのため、構成データベースをRAMに保持し、サーバーの起動時にロードして、1時間ごとに更新します。



すべてのコードは、GitHubにあるこの記事のリポジトリにあります



アプローチI:単一パッケージ



単一パッケージアプローチでは、サーバー全体が単一パッケージ内に実装される兄弟階層を使用します。すべてのコード

警告:コード内のコメントは有益であり、各アプローチの原則を理解するために重要です。
/main.go
package main

import (
	"net/http"
)

//    ,         
//    ,   -,
//  ,         .
var (
	userDBInstance   userDB
	configDBInstance configDB
	rolePermissions  map[string][]string
)

func main() {
	// ,      
	// ,     
	// .
	//        
	// ,   ,     ,
	//    .
	userDBInstance = &someUserDB{}
	configDBInstance = &someConfigDB{}
	initPermissions()
	http.HandleFunc("/", UserPermissionsByID)
	http.ListenAndServe(":8080", nil)
}

//    ,   ,   .
func initPermissions() {
	rolePermissions = configDBInstance.allPermissions()
	go func() {
		for {
			time.Sleep(time.Hour)
			rolePermissions = configDBInstance.allPermissions()
		}
	}()
}
/database.go
package main

//          ,
//         .
type userDB interface {
	userRoleByID(id string) string
}

//     `someConfigDB`.    
//          
// ,   MongoDB,     
// `mongoConfigDB`.         
//   `mockConfigDB`.
type someUserDB struct {}

func (db *someUserDB) userRoleByID(id string) string {
	//     ...
}

type configDB interface {
	allPermissions() map[string][]string //       
}

type someConfigDB struct {}

func (db *someConfigDB) allPermissions() map[string][]string {
	// 
}
/handler.go
package main

import (
	"fmt"
	"net/http"
	"strings"
)

func UserPermissionsByID(w http.ResponseWriter, r *http.Request) {
	id := r.URL.Query()["id"][0]
	role := userDBInstance.userRoleByID(id)
	permissions := rolePermissions[role]
	fmt.Fprint(w, strings.Join(permissions, ", "))
}


注意:私たちはまだ異なるファイルを使用しています。これは懸念を分離するためです。これにより、コードが読みやすくなり、保守が容易になります。



アプローチII:ペアパッケージ



このアプローチでは、バッチ処理とは何かを学びましょう。パッケージは、特定の動作に対して単独で責任を負う必要があります。ここでは、パッケージが相互に対話できるようにします。したがって、維持するコードを少なくする必要があります。ただし、単独の責任の原則に違反しないようにする必要があります。したがって、ロジックの各部分が個別のパッケージに完全に実装されていることを確認する必要があります。このアプローチのもう1つの重要なガイドラインは、Goではパッケージ間の循環依存関係が許可されていないため、ベアインターフェイス定義シングルトンインスタンスのみを含むニュートラルパッケージを作成する必要があるということです。これにより、リングの依存関係がなくなります。コード全体..。



/main.go
package main

//  :  main – ,  
//      .
import (
	"github.com/myproject/config"
	"github.com/myproject/database"
	"github.com/myproject/definition"
	"github.com/myproject/handler"
	"net/http"
)

func main() {
	//       , ,
	//  ,    ,  
	//  .
	definition.UserDBInstance = &database.SomeUserDB{}
	definition.ConfigDBInstance = &database.SomeConfigDB{}
	config.InitPermissions()
	http.HandleFunc("/", handler.UserPermissionsByID)
	http.ListenAndServe(":8080", nil)
}
/definition/database.go
package definition

//  ,       , 
//         . 
// ,        ; 
//    , ,    ,
//      .
var (
	UserDBInstance   UserDB
	ConfigDBInstance ConfigDB
)

type UserDB interface {
	UserRoleByID(id string) string
}

type ConfigDB interface {
	AllPermissions() map[string][]string //      
}
/definition/config.go
package definition

var RolePermissions map[string][]string
/database/user.go
package database

type SomeUserDB struct{}

func (db *SomeUserDB) UserRoleByID(id string) string {
	// 
}
/database/config.go
package database

type SomeConfigDB struct{}

func (db *SomeConfigDB) AllPermissions() map[string][]string {
	// 
}
/config/permissions.go
package config

import (
	"github.com/myproject/definition"
	"time"
)

//         ,
//      config.
func InitPermissions() {
	definition.RolePermissions = definition.ConfigDBInstance.AllPermissions()
	go func() {
		for {
			time.Sleep(time.Hour)
			definition.RolePermissions = definition.ConfigDBInstance.AllPermissions()
		}
	}()
}
/handler/user_permissions_by_id.go
package handler

import (
	"fmt"
	"github.com/myproject/definition"
	"net/http"
	"strings"
)

func UserPermissionsByID(w http.ResponseWriter, r *http.Request) {
	id := r.URL.Query()["id"][0]
	role := definition.UserDBInstance.UserRoleByID(id)
	permissions := definition.RolePermissions[role]
	fmt.Fprint(w, strings.Join(permissions, ", "))
}


アプローチIII:独立したパッケージ



このアプローチでは、プロジェクトもパッケージで編成されます。この場合、各パッケージはインターフェイス変数を介しすべての依存関係をローカルに統合する必要がありますしたがって、他のパッケージについてはまったく何も知りませんこのアプローチでは、前のアプローチで説明した定義を持つパッケージが、実際には他のすべてのパッケージに分散されます。各パッケージは、サービスごとに独自のインターフェイスを宣言します。一見、これは煩わしい重複のように見えるかもしれませんが、実際にはそうではありません。サービスを使用する各パッケージは、独自のインターフェイスを宣言する必要があります。このインターフェイスは、このサービスから必要なものだけを指定し、他には何も指定しません。コード全体..。



/main.go
package main

//  :   – ,  
//   .
import (
	"github.com/myproject/config"
	"github.com/myproject/database"
	"github.com/myproject/handler"
	"net/http"
)

func main() {
	userDB := &database.SomeUserDB{}
	configDB := &database.SomeConfigDB{}
	permissionStorage := config.NewPermissionStorage(configDB)
	h := &handler.UserPermissionsByID{UserDB: userDB, PermissionsStorage: permissionStorage}
	http.Handle("/", h)
	http.ListenAndServe(":8080", nil)
}
/database/user.go
package database

type SomeUserDB struct{}

func (db *SomeUserDB) UserRoleByID(id string) string {
	// 
}
/database/config.go
package database

type SomeConfigDB struct{}

func (db *SomeConfigDB) AllPermissions() map[string][]string {
	// 
}
/config/permissions.go
package config

import (
	"time"
)

//    ,    ,
//    ,  ,
//  `AllPermissions`.
type PermissionDB interface {
	AllPermissions() map[string][]string //     
}

//    ,   
//    , ,    ,  
//     
type PermissionStorage struct {
	permissions map[string][]string
}

func NewPermissionStorage(db PermissionDB) *PermissionStorage {
	s := &PermissionStorage{}
	s.permissions = db.AllPermissions()
	go func() {
		for {
			time.Sleep(time.Hour)
			s.permissions = db.AllPermissions()
		}
	}()
	return s
}

func (s *PermissionStorage) RolePermissions(role string) []string {
	return s.permissions[role]
}
/handler/user_permissions_by_id.go
package handler

import (
	"fmt"
	"net/http"
	"strings"
)

//         
type UserDB interface {
	UserRoleByID(id string) string
}

// ...          .
type PermissionStorage interface {
	RolePermissions(role string) []string
}

//        ,
//     ,   .
type UserPermissionsByID struct {
	UserDB             UserDB
	PermissionsStorage PermissionStorage
}

func (u *UserPermissionsByID) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	id := r.URL.Query()["id"][0]
	role := u.UserDB.UserRoleByID(id)
	permissions := u.PermissionsStorage.RolePermissions(role)
	fmt.Fprint(w, strings.Join(permissions, ", "))
}


それで全部です!3つのレベルの抽象化について説明しました。最初のレベルは最も薄く、グローバル状態と緊密に結合されたロジックを含みますが、最速の実装と最小限のコードで記述および保守できます。2番目のオプションは適度にハイブリッドであり、3番目のオプションは完全に自己完結型であり、繰り返し使用するのに適していますが、サポートにより最大限の努力が必要です。



長所と短所



私はアプローチ:単一のパッケージ



の場合を



  • コードの削減、実装の高速化、メンテナンス作業の削減
  • パケットがないため、リングの依存関係について心配する必要はありません。
  • サービスインターフェイスが存在するため、テストが簡単です。ロジックの一部をテストするには、シングルトンに対して任意の実装(コンクリートまたはモック)を指定してから、テストロジックを実行します。


に対して



  • 唯一のパッケージはプライベートアクセスも提供していません。すべてがどこからでも開かれています。その結果、開発者の責任が増大します。たとえば、初期化ロジックを実行するためにコンストラクター関数が必要な場合、構造を直接インスタンス化できないことに注意してください。
  • グローバル状態(シングルトンインスタンス)は、満たされていない仮定を作成する可能性があります。たとえば、初期化されていないシングルトンインスタンスは、実行時にnullポインタパニックを引き起こす可能性があります。
  • ロジックは緊密に結合されているため、このプロジェクトでは簡単に再利用できるものはなく、そこからコンポーネントを抽出することは困難です。
  • 各ロジックを個別に管理するパッケージがない場合、開発者は細心の注意を払い、すべてのコードを正しく配置する必要があります。そうしないと、予期しない動作が発生する可能性があります。




アプローチII:ペアパッケージ



ごと



  • プロジェクトをパッケージ化する場合、パッケージ内の特定のロジックに対する責任を保証する方が便利であり、これはコンパイラーを使用して実施できます。さらに、プライベートアクセスを使用して、コードのどの要素を公開するかを制御できるようになります。
  • 定義付きのパッケージを使用すると、循環依存関係を回避しながら、シングルトンインスタンスを操作できます。このようにして、記述するコードを減らし、インスタンスを管理するときに参照を渡すことを回避し、コンパイル中に発生する可能性のある問題に時間を浪費することを回避できます。
  • このアプローチは、サービスインターフェイスがあるため、テストにも役立ちます。このアプローチでは、各パッケージの内部テストが可能です。


に対して



  • プロジェクトをパッケージに編成する場合、いくつかのオーバーヘッドがあります。たとえば、最初の実装は、単一パッケージのアプローチよりも時間がかかるはずです。
  • このアプローチでグローバル状態(シングルトンインスタンス)を使用すると、問題が発生する可能性もあります。
  • プロジェクトはパッケージに分割されているため、個々の要素の抽出と再利用が非常に容易になります。ただし、パッケージはすべて定義パッケージと相互作用するため、完全に独立しているわけではありません。このアプローチでは、コードの抽出と再利用は完全に自動ではありません。




アプローチIII:独立した



プロパッケージ



  • パッケージを使用する場合、特定のロジックが単一のパッケージ内に実装され、完全なアクセス制御が行われるようにします。
  • パッケージは完全に自己完結型であるため、潜在的な循環依存関係はありません。
  • すべてのパッケージは、高度に回復可能で再利用可能です。別のプロジェクトでパッケージが必要な場合はすべて、パッケージを共有スペースに転送し、何も変更せずに使用します。
  • グローバル状態がない場合、意図しない動作はありません。
  • このアプローチはテストに最適です。各パッケージは、ローカルインターフェイスを介して他のパッケージに依存する可能性があることを心配することなく、完全にテストできます。


に対して



  • このアプローチは、前の2つよりも実装にはるかに時間がかかります。
  • より多くのコードを維持する必要があります。リンクが渡されているため、大きな変更を加えた後、多くの場所を更新する必要があります。また、同じサービスを提供する複数のインターフェイスがある場合、そのサービスに変更を加えるたびにそれらのインターフェイスを更新する必要があります。


結論と使用例



Goでコードを作成するためのガイドラインがないことを考えると、さまざまな形や形式を取り、各オプションには独自の興味深いメリットがあります。ただし、異なるデザインパターンを混在させると、問題が発生する可能性があります。それらのアイデアを提供するために、Goコードを記述および構造化するための3つの異なるアプローチについて説明しました。



では、各アプローチはいつ使用する必要がありますか?この配置をお勧めします。



アプローチI:迅速な結果が要求される小規模なプロジェクトで、経験豊富な小規模なチームで作業する場合は、単一パッケージのアプローチがおそらく最も適切です。このアプローチは、プロジェクトサポートの段階で真剣な注意と調整が必要ですが、クイックスタートにはよりシンプルで信頼性があります。



アプローチII:ペアパケットアプローチは、他の2つのアプローチのハイブリッド合成と呼ぶことができます。その利点の中には、比較的迅速な開始とサポートの容易さがあり、同時に、ルールを厳密に順守するための条件が作成されます。比較的大規模なプロジェクトや大規模なチームに適していますが、コードの再利用性が制限されており、保守が困難です。



アプローチIII:独立パッケージアプローチは、それ自体が複雑で、長期的で、大規模なチームによって開発されたプロジェクト、およびさらに再利用することを目的として作成されたロジックの一部を持つプロジェクトに最適です。このアプローチは実装に時間がかかり、維持するのが困難です。



All Articles