我们走吧!构造Go代码的三种方法

哈Ha!我们最近有一本关于Golang的新书,它的成功是如此令人印象深刻,以至于我们决定在这里发表有关设计Go应用程序的方法的非常重要的文章。本文提出的思想显然不会在可预见的将来过时。也许作者甚至设法预见了使用Go的一些指导方针,这些指导方针在不久的将来可能会变得越来越普遍。



Go语言于2009年底首次发布,并于2012年正式发布,但是直到最近几年,它才开始获得广泛认可。Go是2018年增长最快的语言之一,也是2019年最受欢迎第三种编程语言



由于Go语言本身是相当新的语言,因此开发人员社区对如何编写代码不是很严格。如果我们在较旧的语言社区(例如Java)中查看类似的约定,事实证明大多数项目都具有类似的结构。在编写大型代码库时,这可能非常方便,但是,许多人可能会认为这在现代实际环境中会适得其反。随着我们继续编写微系统并维护相对紧凑的代码库,Go在构造项目方面的灵活性变得非常有吸引力。



每个人都知道Golang上带有hello world http的示例,并且可以将其与其他语言(例如Java)中的类似示例进行比较...第一个和第二个之间没有显着差异,无论是复杂度还是实现示例所需编写的代码量都没有。但是方法上有根本的区别。 Go鼓励我们“尽可能地编写简单的代码”。除了Java的面向对象方面,我认为这些代码片段最重要的方面是:Java需要为每个操作(实例HttpServer)使用一个单独的实例,而Go鼓励我们使用全局单例。



这样,您必须维护更少的代码并在其中传递更少的链接。如果您知道只需要创建一台服务器(通常会发生这种情况),那么为什么要花太多时间呢?随着代码库的增长,这种理念似乎更具吸引力。尽管如此,生活有时还是会带来很多惊喜:(。事实上,您仍然可以从多个抽象层次中进行选择,并且如果您将它们错误地结合在一起,您可能会给自己造成严重的陷阱。



这就是为什么我要引起您注意三个方面的原因组织和构造Go代码的方法,每种方法都意味着不同的抽象级别,总而言之,我将比较这三种方法,并告诉您这些方法在哪种应用案例中最合适。



我们将实现一个HTTP服务器,其中包含有关用户的信息(在下图中表示为Main DB),其中为每个用户分配了一个角色(假设是基本,主持人,管理员),并且还实现了一个附加数据库(在下图中,表示为配置数据库),该数据库指定为每个角色(例如读取,写入,编辑)保留的访问权限集。我们的HTTP服务器必须实现一个端点,该端点返回具有给定ID的用户具有的访问权限集。







接下来,假设配置数据库很少更改,并且加载时间很长,因此我们将其保留在RAM中,在服务器启动时加载它,并每小时更新一次。



所有的代码库中存放的这篇文章位于GitHub上。



方法一:单包



单包方法使用单层层次结构,其中整个服务器在单个包中实现。所有代码

警告:代码中的注释内容丰富,对于理解每种方法的原理很重要。
/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, ", "))
}


请注意:我们仍然使用不同的文件,这是为了分开考虑。这使代码更具可读性,更易于维护。



方法二:成对包装



通过这种方法,让我们学习什么是批处理。该程序包必须完全负责某些特定行为。在这里,我们允许包彼此交互-因此我们必须维护更少的代码。但是,我们需要确保我们不违反唯一责任原则,因此要确保每个逻辑都在单独的程序包中完全实现。此方法的另一个重要准则是,由于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, ", "))
}


方法三:独立包装



使用这种方法,项目也可以打包进行组织。在这种情况下,每个包都必须通过接口变量在本地集成其所有依赖项因此,它对其他软件包一无所知通过这种方法,具有前面方法中提到的定义的程序包实际上将被涂抹在所有其他程序包之间。每个程序包为每个服务声明其自己的接口。乍一看,这看起来像是令人讨厌的重复,但实际上并非如此。每个使用服务的软件包都必须声明自己的接口,该接口指定该服务需要的内容,而没有其他要求。整个代码...



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


就这样!我们研究了三个抽象级别,其中第一个是最薄的抽象级别,包含全局状态和紧密耦合的逻辑,但提供了最快的实现以及最少的编写和维护代码量。第二种选择是中等程度的混合,第三种是完全独立的并且适合重复使用,但是在支持下要付出最大的努力。



利弊



接近我:单封装



对于



  • 更少的代码,更快的实现,更少的维护工作
  • 没有数据包,这意味着您不必担心环的依赖性
  • 由于存在服务接口,因此易于测试。要测试逻辑,可以为单例指定您选择的任何实现(具体或模拟),然后运行测试逻辑。


反对



  • 唯一的软件包也不提供私有访问权限,任何地方都可以打开所有内容。结果,开发人员的责任增加了。例如,请记住,当需要构造函数执行一些初始化逻辑时,您不能直接实例化结构。
  • 全局状态(单实例)可以创建未实现的假设,例如,未初始化的单例实例可以在运行时触发空指针恐慌。
  • 由于逻辑紧密耦合,因此该项目中的任何内容都无法轻松重用,并且很难从中提取任何组件。
  • 当您没有独立管理每个逻辑的程序包时,开发人员必须非常小心并正确放置所有代码,否则可能会发生意外行为。




办法之二:成对包装







  • 打包项目时,更容易保证对包中的特定逻辑负责,这可以使用编译器强制实施。此外,我们将能够使用私有访问权限并控制向我们开放代码的哪些元素。
  • 使用带有定义的包可以使您处理单例实例,同时避免循环依赖。这样,您可以编写更少的代码,避免在管理实例时传递引用,并且避免在编译过程中可能出现的问题上浪费时间。
  • 因为有服务接口,所以这种方法也有利于测试。通过这种方法,可以对每个包装进行内部测试。


反对



  • 以包形式组织项目时会产生一些开销-例如,初始实施应比使用单个包方法花费更长的时间。
  • 通过这种方法使用全局状态(单个实例)也会引起问题。
  • 该项目分为多个包,这极大地方便了各个元素的提取和重用。但是,包并非完全独立,因为它们都与定义包交互。使用这种方法,代码提取和重用并不是完全自动的。




方法三:独立



专家



  • 使用软件包时,我们确保特定的逻辑在单个软件包中实现,并且可以完全控制访问。
  • 由于软件包是完全独立的,因此不应存在潜在的循环依赖关系。
  • 所有软件包都是高度可恢复和可重复使用的。在所有这些情况下,当我们需要另一个项目中的程序包时,我们只需将其传输到共享空间并使用它,而无需更改其中的任何内容。
  • 如果没有全局状态,那么就不会有意外行为。
  • 这种方法最适合测试。每个软件包都可以进行全面测试,而不必担心它可能会通过本地接口依赖于其他软件包。


反对



  • 与前两种方法相比,该方法的实现要慢得多。
  • 需要维护更多代码。因为正在传输链接,所以在进行重大更改后必须更新许多位置。同样,当我们有多个提供相同服务的接口时,每次更改该服务时,我们都必须更新这些接口。


结论和使用示例



由于缺乏在Go中编写代码的准则,因此它具有许多不同的形状和形式,并且每个选项都有其有趣的优点。但是,混合使用不同的设计模式可能会引起问题。为了让您对它们有个了解,我介绍了三种编写和构造Go代码的方法。



那么什么时候应该使用每种方法呢?我建议这种安排:



方法I:在需要快速结果的小型,经验丰富的小型团队中从事小型项目时,单包方法可能是最合适的。尽管需要在项目支持阶段进行认真的关注和协调,但是这种方法对于快速启动来说更简单,更可靠。



方法二:成对分组方法可以称为其他两种方法的混合综合:其优点是起步相对较快且易于支持,同时又为严格遵守规则创造了条件。它适用于相对较大的项目和大型团队,但是它的代码可重用性有限,并且在维护方面存在一定的困难。



方法III:独立包方法最适合于本身是复杂的,长期的,由大型团队开发的项目,也适用于其中创建了一些逻辑以便进一步重用的项目。这种方法需要很长时间才能实施,并且难以维护。



All Articles