Lass uns gehen! Drei Ansätze zur Strukturierung von Go-Code

Hallo Habr! Wir haben kürzlich ein neues Buch über Golang herausgebracht , und sein Erfolg ist so beeindruckend, dass wir beschlossen haben, hier einen sehr wichtigen Artikel über Ansätze zum Entwerfen von Go-Anwendungen zu veröffentlichen. Die in diesem Artikel vorgestellten Ideen werden auf absehbare Zeit offensichtlich nicht überholt sein. Vielleicht hat der Autor es sogar geschafft, einige Richtlinien für die Arbeit mit Go vorwegzunehmen, die in naher Zukunft weit verbreitet sein könnten.



Die Go-Sprache wurde erstmals Ende 2009 angekündigt und 2012 offiziell veröffentlicht, aber erst in den letzten Jahren hat sie ernsthafte Anerkennung gefunden. Go war 2018 eine der am schnellsten wachsenden Sprachen und 2019 die drittbeliebteste Programmiersprache .



Da die Go-Sprache selbst ziemlich neu ist, ist die Entwickler-Community beim Schreiben von Code nicht sehr streng. Wenn wir ähnliche Konventionen in den Communities älterer Sprachen wie Java betrachten, stellt sich heraus, dass die meisten Projekte eine ähnliche Struktur haben. Dies kann sehr praktisch sein, wenn große Codebasen geschrieben werden. Viele könnten jedoch argumentieren, dass dies in modernen praktischen Kontexten kontraproduktiv wäre. Während wir Mikrosysteme schreiben und relativ kompakte Codebasen beibehalten, wird Go's Flexibilität bei der Strukturierung von Projekten sehr attraktiv.



Jeder kennt ein Beispiel mit Hallo Welt http auf Golang , und es kann mit ähnlichen Beispielen in anderen Sprachen verglichen werden, zum Beispiel in Java... Es gibt keinen signifikanten Unterschied zwischen dem ersten und dem zweiten, weder in der Komplexität noch in der Menge an Code, die geschrieben werden muss, um das Beispiel zu implementieren. Es gibt jedoch einen grundlegenden Unterschied in der Herangehensweise. Go ermutigt uns, " wann immer möglich einfachen Code zu schreiben ". Abgesehen von den objektorientierten Aspekten von Java denke ich, dass die wichtigste Erkenntnis aus diesen Codefragmenten folgende ist: Java benötigt für jede Operation (Instanz HttpServer) eine separate Instanz , während Go uns ermutigt, den globalen Singleton zu verwenden.



Auf diese Weise müssen Sie weniger Code pflegen und weniger Links darin übergeben. Wenn Sie wissen, dass Sie nur einen Server erstellen müssen (und dies geschieht normalerweise), warum sollten Sie sich dann mit zu viel beschäftigen? Diese Philosophie scheint überzeugender zu sein, wenn Ihre Codebasis wächst. Trotzdem wirft das Leben manchmal Überraschungen auf: (Tatsache ist, dass Sie immer noch mehrere Abstraktionsebenen zur Auswahl haben und wenn Sie sie falsch kombinieren, können Sie sich ernsthafte Fallen stellen.



Deshalb möchte ich Ihre Aufmerksamkeit auf drei lenken Ansätze zur Organisation und Strukturierung von Go-Code. Jeder dieser Ansätze impliziert eine andere Abstraktionsebene. Abschließend werde ich alle drei vergleichen und Ihnen sagen, in welchen Anwendungsfällen jeder dieser Ansätze am besten geeignet ist.



Wir werden einen HTTP-Server implementieren, der Informationen zu Benutzern enthält (in der folgenden Abbildung als Hauptdatenbank bezeichnet), wobei jedem Benutzer eine Rolle zugewiesen wird (z. B. Basic, Moderator, Administrator), und eine zusätzliche Datenbank implementieren (in der nächsten Abbildung als bezeichnet) Konfigurations-DB), die den Satz von Zugriffsrechten angibt, die jeder der Rollen zugewiesen sind (z. B. Lesen, Schreiben, Bearbeiten). Unser HTTP-Server muss einen Endpunkt implementieren, der die Zugriffsrechte zurückgibt, über die der Benutzer mit der angegebenen ID verfügt.







Nehmen wir als Nächstes an, dass sich die Konfigurationsdatenbank nur selten ändert und das Laden lange dauert. Daher behalten wir sie im RAM, laden sie beim Start des Servers und aktualisieren sie stündlich.



Der gesamte Code ist im Repository für diesen Artikel befindet sich auf GitHub.



Ansatz I: Einzelpaket



Der Einzelpaketansatz verwendet eine Geschwisterhierarchie, in der der gesamte Server in einem einzelnen Paket implementiert ist. Alle Code .

Warnung: Kommentare im Code sind informativ und wichtig für das Verständnis der Prinzipien jedes Ansatzes.
/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, ", "))
}


Bitte beachten Sie: Wir verwenden immer noch unterschiedliche Dateien, dies dient der Trennung von Bedenken. Dies macht den Code lesbarer und leichter zu pflegen.



Ansatz II: Gepaarte Pakete



In diesem Ansatz lernen wir, was Batching ist. Das Paket muss allein für ein bestimmtes Verhalten verantwortlich sein. Hier erlauben wir Paketen, miteinander zu interagieren - daher müssen wir weniger Code pflegen. Wir müssen jedoch sicherstellen, dass wir nicht gegen das Prinzip der alleinigen Verantwortung verstoßen, und daher sicherstellen, dass jede Logik vollständig in einem separaten Paket implementiert ist. Eine weitere wichtige Richtlinie für diesen Ansatz ist, dass Sie ein neutrales Paket erstellen müssen , das nur bloße Schnittstellendefinitionen und Singleton-Instanzen enthält , da Go keine zirkulären Abhängigkeiten zwischen Paketen zulässt . Dadurch werden die Ringabhängigkeiten beseitigt. Der ganze Code...



/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, ", "))
}


Ansatz III: Unabhängige Pakete



Bei diesem Ansatz ist das Projekt auch in Paketen organisiert. In diesem Fall muss jedes Paket alle seine Abhängigkeiten lokal über Schnittstellen und Variablen integrieren . Somit weiß es absolut nichts über andere Pakete . Bei diesem Ansatz wird das Paket mit den im vorherigen Ansatz erwähnten Definitionen tatsächlich zwischen allen anderen Paketen verschmiert. Jedes Paket deklariert für jeden Dienst eine eigene Schnittstelle. Auf den ersten Blick mag dies wie eine nervige Vervielfältigung erscheinen, in Wirklichkeit ist dies jedoch nicht der Fall. Jedes Paket, das einen Dienst verwendet, muss eine eigene Schnittstelle deklarieren, die nur angibt , was von diesem Dienst benötigt wird, und sonst nichts. Der ganze Code...



/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, ", "))
}


Das ist alles! Wir haben uns drei Abstraktionsebenen angesehen, von denen die erste die dünnste ist, die den globalen Status und die eng gekoppelte Logik enthält, aber die schnellste Implementierung und die geringste Menge an Code zum Schreiben und Verwalten bietet. Die zweite Option ist ein Mild-Hybrid, und die dritte Option ist vollständig in sich geschlossen und für den wiederholten Gebrauch geeignet, bietet jedoch maximale Unterstützung.



Dafür und dagegen



Ansatz I: Einzelpaket



für



  • Weniger Code, viel schnellere Implementierung, weniger Wartungsarbeiten
  • Keine Pakete, was bedeutet, dass Sie sich keine Gedanken über Ringabhängigkeiten machen müssen
  • Einfach zu testen, da Serviceschnittstellen vorhanden sind. Um eine Logik zu testen, können Sie eine beliebige Implementierung Ihrer Wahl (konkret oder verspottet) für den Singleton angeben und anschließend die Testlogik ausführen.


Gegen



  • Das einzige Paket bietet auch keinen privaten Zugang, alles ist von überall offen. Infolgedessen steigt die Verantwortung der Entwickler. Denken Sie beispielsweise daran, dass Sie eine Struktur nicht direkt instanziieren können, wenn eine Konstruktorfunktion erforderlich ist, um eine Initialisierungslogik auszuführen.
  • Der globale Status (Singleton-Instanzen) kann zu unerfüllten Annahmen führen. Beispielsweise kann eine nicht initialisierte Singleton-Instanz zur Laufzeit eine Nullzeiger-Panik auslösen.
  • Da die Logik eng miteinander verbunden ist, kann nichts in diesem Projekt leicht wiederverwendet werden, und es wird schwierig sein, Komponenten daraus zu extrahieren.
  • Wenn Sie nicht über Pakete verfügen, die jede Logik unabhängig verwalten, muss der Entwickler sehr vorsichtig sein und alle Codeteile korrekt platzieren, da sonst unerwartete Verhaltensweisen auftreten können.




Ansatz II: Gepaarte Pakete



pro



  • Beim Packen eines Projekts ist es bequemer, die Verantwortung für eine bestimmte Logik innerhalb des Pakets zu gewährleisten. Dies kann mithilfe des Compilers erzwungen werden. Darüber hinaus können wir den privaten Zugriff nutzen und steuern, welche Elemente des Codes für uns offen sind.
  • Wenn Sie ein Paket mit Definitionen verwenden, können Sie mit Singleton-Instanzen arbeiten und dabei zirkuläre Abhängigkeiten vermeiden. Auf diese Weise können Sie weniger Code schreiben, das Übergeben von Referenzen beim Verwalten von Instanzen vermeiden und keine Zeit mit Problemen verschwenden, die möglicherweise während der Kompilierung auftreten können.
  • Dieser Ansatz ist auch zum Testen förderlich, da es Serviceschnittstellen gibt. Mit diesem Ansatz ist ein internes Testen jedes Pakets möglich.


Gegen



  • Bei der Organisation eines Projekts in Paketen fallen einige Gemeinkosten an. Beispielsweise sollte die anfängliche Implementierung länger dauern als bei einem einzelnen Paketansatz.
  • Die Verwendung des globalen Status (Singleton-Instanzen) mit diesem Ansatz kann ebenfalls Probleme verursachen.
  • Das Projekt ist in Pakete unterteilt, was die Extraktion und Wiederverwendung einzelner Elemente erheblich erleichtert. Pakete sind jedoch nicht vollständig unabhängig, da sie alle mit einem Definitionspaket interagieren. Bei diesem Ansatz erfolgt die Codeextraktion und -wiederverwendung nicht vollständig automatisch.




Ansatz III: Unabhängige



Pro- Pakete



  • Bei der Verwendung von Paketen stellen wir sicher, dass bestimmte Logik in einem einzelnen Paket implementiert ist und wir über eine vollständige Zugriffskontrolle verfügen.
  • Es sollten keine potenziellen zirkulären Abhängigkeiten bestehen, da die Pakete vollständig in sich geschlossen sind.
  • Alle Pakete sind in hohem Maße wiederherstellbar und wiederverwendbar. In all den Fällen, in denen wir ein Paket in einem anderen Projekt benötigen, übertragen wir es einfach in einen gemeinsam genutzten Bereich und verwenden es, ohne etwas daran zu ändern.
  • Wenn es keinen globalen Status gibt, gibt es keine unbeabsichtigten Verhaltensweisen.
  • Dieser Ansatz eignet sich am besten zum Testen. Jedes Paket kann vollständig getestet werden, ohne befürchten zu müssen, dass es über lokale Schnittstellen von anderen Paketen abhängt.


Gegen



  • Dieser Ansatz ist viel langsamer zu implementieren als die beiden vorherigen.
  • Es muss viel mehr Code gepflegt werden. Da Links übergeben werden, müssen viele Stellen aktualisiert werden, nachdem größere Änderungen vorgenommen wurden. Wenn wir mehrere Schnittstellen haben, die denselben Dienst bereitstellen, müssen wir diese Schnittstellen jedes Mal aktualisieren, wenn wir Änderungen an diesem Dienst vornehmen.


Schlussfolgerungen und Anwendungsbeispiele



Angesichts des Fehlens von Richtlinien zum Schreiben von Code in Go gibt es viele verschiedene Formen und Formen, und jede Option hat ihre eigenen interessanten Vorteile. Das Mischen verschiedener Entwurfsmuster kann jedoch Probleme verursachen. Um Ihnen eine Vorstellung davon zu geben, habe ich drei verschiedene Ansätze zum Schreiben und Strukturieren von Go-Code behandelt.



Wann sollte jeder Ansatz angewendet werden? Ich schlage diese Anordnung vor:



Ansatz I : Der Einzelpaketansatz ist möglicherweise am besten geeignet, wenn Sie in kleinen, sehr erfahrenen Teams an kleinen Projekten arbeiten, bei denen schnelle Ergebnisse erforderlich sind. Dieser Ansatz ist für einen schnellen Start einfacher und zuverlässiger, erfordert jedoch ernsthafte Aufmerksamkeit und Koordination in der Phase der Projektunterstützung.



Ansatz II: Der Paired-Packet-Ansatz kann als Hybridsynthese der beiden anderen Ansätze bezeichnet werden: Zu seinen Vorteilen zählen ein relativ schneller Start und eine einfache Unterstützung, während gleichzeitig Bedingungen für die strikte Einhaltung der Regeln geschaffen werden. Es ist für relativ große Projekte und große Teams geeignet, hat jedoch eine begrenzte Wiederverwendbarkeit des Codes und es gibt bestimmte Schwierigkeiten bei der Wartung.



Ansatz III : Der Ansatz für unabhängige Pakete eignet sich am besten für Projekte, die an sich komplex und langfristig sind und von großen Teams entwickelt werden, sowie für Projekte, bei denen logische Elemente erstellt werden, um sie weiter zu verwenden. Die Implementierung dieses Ansatzes dauert lange und ist schwierig aufrechtzuerhalten.



All Articles