使用Go清洁架构

我叫埃德加(虫族法),我在b2b和b2c的金融科技开发公司工作。当我第一次在公司工作时,我加入了一个大型金融科技项目的团队,并“承担了”一个小型微服务。我被指示研究并准备一个重构计划,以便进一步为该服务分配一个单独的支持团队。







“我的”服务是大型项目中某些模块之间的代理。乍一看,您可以在一个晚上学习它,然后开始研究更重要的事情。但是开始工作后,我意识到自己错了。该服务是在六个月前的几周内编写的,其任务是测试MVP。他一直都拒绝工作:他丢失了事件和数据,或者重写了事件和数据。这个项目在团队之间进行,因为没有人愿意这样做,即使它的创建者也是如此。现在很清楚为什么他们要为此寻找一个单独的程序员。



“我的”服务就是糟糕的架构和固有的错误设计的一个例子。大家都知道不应该这样做。但是,为什么不呢,它将导致什么后果,以及如何尝试修复所有问题,我将告诉您。



糟糕的架构如何阻碍



典型故事:



  • 做MVP;

  • 检验其假设;

  • , MVP;

  • ...;

  • PROFIT.



但这是无法做到的(我们都知道)。



急于构建系统时,保持发布新版本产品的唯一方法是“膨胀”员工。最初,开发人员显示出接近100%的生产率,但是当最初的“原始”产品因功能和依赖性而变得过长时,找出它的时间就会越来越长。



使用每个新版本,开发人员的工作效率都会下降。没有人考虑代码的整洁性,设计和体系结构。结果,一行代码的价格可能会增加40倍。







这些过程可以从Robert Martin的图中清楚地看到。尽管开发人员的数量在各个版本之间都在增加,但是产品增长的动力只是在减缓。成本在增加,收入在下降,这已经导致人员减少。



清洁架构挑战



与业务的设计和编写方式无关。对于企业而言,重要的是产品必须按照用户期望的方式运转并实现盈利。但是有时(不是有时,但经常)企业更改其解决方案和要求。由于结构较差,很难适应新要求,更改产品并添加新功能。



精心设计的系统更易于与所需行为匹配。同样,罗伯特·马丁(Robert Martin)认为,行为是次要的,如果系统设计良好,则始终可以纠正。



干净的体系结构可促进项目各层之间的通信,其中中心是业务逻辑及其所有处理已应用任务的实体。



  • 所有外层都是与外界通信的适配器。 

  • 外部元素不应渗透到项目的中心部分。



业务逻辑并不关心它是谁:桌面应用程序,Web服务器或微控制器。它不应该依赖于“标签”。她必须执行特定的任务。其他所有内容都是细节,例如数据库或桌面。



有了干净的体系结构,我们得到了独立的系统。例如,它独立于数据库或框架版本。我们可以根据服务器的需要替换桌面应用程序,而无需更改业务逻辑的内部组件。这就是业务逻辑的价值所在。



干净的体系结构降低了项目的认知复杂性,支持成本,并简化了程序员的开发和进一步的维护。 



如何识别“不良”架构



编程中没有“不良”架构的概念。对于不良建筑,有以下标准:刚度,固定性,韧性和过度的重复性。例如,这些是我用来了解微服务架构不好的标准。



刚度。这是系统无法对很小的变化做出反应的能力,当很难在不损害整个系统的情况下更改项目的各个部分时,系统就是刚性的。例如,当一个结构同时在项目的多个层中使用时,那么其微小的变化就会立即在整个项目中产生问题。



通过在每个层上进行转换可以解决该问题。当每个层仅操作它们的对象,这是由“转换”外部物体而获得,这些层成为完全独立的



不动...在构建系统时,很难将其分离(或缺乏)到可重用的模块中。固定系统很难重构。 



例如,当有关数据库的信息进入业务逻辑区域时,用另一个数据库替换将导致所有业务逻辑的重构。



粘度。当程序包之间的责任划分导致不必要的集中时。有趣的是,当粘度导致分散时,会发生相反的情况-一切都分成太小的包装。在Go中,这可能导致循环导入。例如,当适配器数据包开始接收额外的逻辑时,就会发生这种情况。



重复性过高...在Go中,流行短语“小副本胜于小依赖”。但这并不会导致依赖项更少的事实,它只会变成更多的副本。我经常在不同的Go软件包中看到其他软件包的代码副本。



例如,罗伯特·马丁(Robert Martin)在他的《清洁架构》一书中写道,过去Google要求重用任何可能的字符串,并将其分配到单独的库中。这导致更改2-3行的小型服务会影响所有其他相关服务。该公司仍在解决这种方法的问题。



渴望重构... 这是不良体系结构的奖励标准。但是有细微差别。无论您对项目的编写有多糟糕,都不要从头开始重写它,这只会带来其他问题。进行迭代重构。



如何相对正确地设计



“我的”代理服务生存了六个月,而所有这些时间都没有完成任务。他怎么活这么久?



当企业测试​​产品并显示出无效性时,该产品将被废弃或销毁。这个是正常的。当测试MVP并证明它是有效的时,它就会继续存在。但是通常,MVP不会被重写,它会“按原样”存在,并且代码和功能过多。因此,为MVP创建的“僵尸产品”是一种常见的做法。



当我发现代理服务无法正常工作时,团队决定重写它。这项业务分配给了我和一位同事,分配了两个星期:很少的业务逻辑,很少的服务。这是另一个错误。



该服务开始被完全重写。当他们剪切时,重写部分代码并将其上传到测试环境,部分平台崩溃。事实证明,该服务具有许多未记录的业务逻辑,没人知道。我和我的同事失败了,但这是服务逻辑中的错误。



我们决定从另一端进行重构:



  • 回滚到以前的版本;

  • 代码不被重写;

  • 我们将代码分为多个部分-包;

  • 每个程序包都包装在单独的接口中。



我们不了解该服务在做什么,因为没人知道它。因此,“削减”服务部分并处理每个部分负责的事情是唯一的选择。



之后,可以分别重构每个程序包。我们可以分别修复服务的每个部分和/或在项目的其他部分中实施它。同时,这项服务的工作一直持续到今天。 





原来是这样的。



如果我们从一开始就“设计得很好”,我们将如何编写类似的服务?让我以注册并授权用户的小型微服务为例向您展示。



介绍性



我们需要:系统的核心,一个通过操纵外部模块来定义和执行业务逻辑的实体。



type Core struct {
userRepo     UserRepo
sessionRepo  SessionRepo
hashing      Hasher
auth         Auth
}


接下来,您需要两个合同,这些合同将允许您使用回购层。第一份合同为我们提供了一个界面。在它的帮助下,我们将与存储有关用户信息的数据库层进行通信。




// UserRepo interface for user data repository.
type UserRepo interface {
    // CreateUser adds to the new user in repository.
    // This method is also required to create a notifying hoard.
    // Errors: ErrEmailExist, ErrUsernameExist, unknown.
    CreateUser(context.Context, User, TaskNotification) (UserID, error)
    // UpdatePassword changes password.
    // Resets all codes to reset the password.
    // Errors: unknown.
    UpdatePassword(context.Context, UserID, []byte) error
    // UserByID returning user info by id.
    // Errors: ErrNotFound, unknown.
    UserByID(context.Context, UserID) (*User, error)
    // UserByEmail returning user info by email.
    // Errors: ErrNotFound, unknown.
    UserByEmail(context.Context, string) (*User, error)
    // UserByUsername returning user info by id.
    // Errors: ErrNotFound, unknown.
    UserByUsername(context.Context, string) (*User, error)
}


第二个合同与存储有关用户会话信息的层“通信”。



// SessionRepo interface for session data repository.
type SessionRepo interface {
   // SaveSession saves the new user Session in a database.
   // Errors: unknown.
   SaveSession(context.Context, UserID, TokenID, Origin) error
   // Session returns user Session.
   // Errors: ErrNotFound, unknown.
   SessionByTokenID(context.Context, TokenID) (*Session, error)
   // UserByAuthToken returning user info by authToken.
   // Errors: ErrNotFound, unknown.
   UserByTokenID(context.Context, TokenID) (*User, error)
   // DeleteSession removes user Session.
   // Errors: unknown.
   DeleteSession(context.Context, TokenID) error
}


现在,您需要一个用于使用密码,对密码进行哈希处理和比较的界面。还有用于授权令牌的最新接口,这将允许生成和识别授权令牌。



// Hasher module responsible for working with passwords.
type Hasher interface {
   // Password returns the hashed version of the password.
   // Errors: unknown.
   Password(password string) ([]byte, error)
   // Compare compares two passwords for matches.
   Compare(hashedPassword []byte, password []byte) error
}

// Auth module is responsible for working with authorization tokens.
type Auth interface {
// Token generates an authorization auth with a specified lifetime,
// and can also use the UserID if necessary.
// Errors: unknown.
Token(expired time.Duration) (AuthToken, TokenID, error)
// Parse and validates the auth and checks that it's expired.
// Errors: ErrInvalidToken, ErrExpiredToken, unknown.
Parse(token AuthToken) (TokenID, error)
}


让我们开始编写逻辑本身。主要问题是我们希望从应用程序的业务逻辑中得到什么?



  • 用户注册。

  • 正在检查邮件和昵称。

  • 授权。



支票



让我们从简单的方法开始-检查电子邮件或昵称。我们的UserRepo没有检查方法。但是我们不会添加它们,我们可以通过向用户请求此数据来检查该数据是否繁忙。



// VerificationEmail for implemented UserApp.
func (a *Application) VerificationEmail(ctx context.Context, email string) error {
   _, err := a.userRepo.UserByEmail(ctx, email)
   switch {
   case errors.Is(err, ErrNotFound):
      return nil
   case err == nil:
      return ErrEmailExist
   default:
      return err
   }
}

// VerificationUsername for implemented UserApp.
func (a *Application) VerificationUsername(ctx context.Context, username string) error {
   _, err := a.userRepo.UserByUsername(ctx, username)
   switch {
   case errors.Is(err, ErrNotFound):
      return nil
   case err == nil:
      return ErrUsernameExist
   default:
      return err
   }
}


这里有两个细微差别。



为什么检查通过错误ErrNotFound业务逻辑的实现不应依赖于SQL或任何其他数据库,因此sql.ErrNoRows应将其转换为对我们的业务逻辑方便的错误。



我们还会通过API层引发业务逻辑层的错误,并且必须在API级别解决错误代码或其他问题。业务逻辑不应依赖于与客户端的通信协议,而应基于此做出决策。



注册和授权



// CreateUser for implemented UserApp.
func (a *Application) CreateUser(ctx context.Context, email, username, password string, origin Origin) (*User, AuthToken, error) {
   passHash, err := a.password.Password(password)
   if err != nil {
      return nil, "", err
   }
   email = strings.ToLower(email)

   newUser := User{
      Email:    email,
      Name:     username,
      PassHash: passHash,
   }

   _, err = a.userRepo.CreateUser(ctx, newUser)
   if err != nil {
      return nil, "", err
   }

   return a.Login(ctx, email, password, origin)
}

// Login for implemented UserApp.
func (a *Application) Login(ctx context.Context, email, password string, origin Origin) (*User, AuthToken, error) {
	email = strings.ToLower(email)

	user, err := a.userRepo.UserByEmail(ctx, email)
	if err != nil {
		return nil, "", err
	}

	if err := a.password.Compare(user.PassHash, []byte(password)); err != nil {
		return nil, "", err
	}

	token, tokenID, err := a.auth.Token(TokenExpire)
	if err != nil {
		return nil, "", err
	}

	err = a.sessionRepo.SaveSession(ctx, user.ID, tokenID, origin)
	if err != nil {
		return nil, "", err
	}

	return user, token, nil
}


它是简单,命令式的代码,易于阅读和维护。您可以在设计时立即开始编写此代码。将用户添加到哪个数据库,选择与客户端进行通信的协议或密码的哈希方式都无关紧要。业务逻辑对所有这些层都不感兴趣,仅对其执行其应用程序区域的任务很重要。



简单哈希层



这是什么意思?所有外部非层都不应该对与应用程序区域相关的任务做出决定。他们执行业务逻辑要求的特定且简单的任务。例如,让我们为哈希密码添加一层。



// Package hasher contains methods for hashing and comparing passwords.
package hasher

import (
   "errors"

   "github.com/zergslaw/boilerplate/internal/app"
   "golang.org/x/crypto/bcrypt"
)

type (
   // Hasher is an implements app.Hasher.
   // Responsible for working passwords, hashing and compare.
   Hasher struct {
      cost int
   }
)

// New creates and returns new app.Hasher.
func New(cost int) app.Hasher {
   return &Hasher{cost: cost}
}

// Hashing need for implements app.Hasher.
func (h *Hasher) Password(password string) ([]byte, error) {
   return bcrypt.GenerateFromPassword([]byte(password), h.cost)
}

// Compare need for implements app.Hasher.
func (h *Hasher) Compare(hashedPassword []byte, password []byte) error {
   err := bcrypt.CompareHashAndPassword(hashedPassword, password)
   switch {
   case errors.Is(err, bcrypt.ErrMismatchedHashAndPassword):
      return app.ErrNotValidPassword
   case err != nil:
      return err
   }

   return nil
}


这是用于执行密码哈希和比较任务的一些简单层。就是这样 他又瘦又简单,一无所知。而且不应该。



回购



让我们考虑一下存储交互层。



让我们声明实现并指出应该实现的接口。



var _ app.SessionRepo = &Repo{}
var _ app.UserRepo = &Repo{}

// Repo is an implements app.UserRepo.
// Responsible for working with database.
type Repo struct {
	db *sqlx.DB
}

// New creates and returns new app.UserRepo.
func New(repo *sqlx.DB) *Repo {
	return &Repo{db: repo}
}


让代码的读者可以了解该层实施了哪些合同,并可以考虑为我们的Repo设置的任务。

让我们开始实施。为了不增加文章的篇幅,我将只介绍其中一部分方法。



// CreateUser need for implements app.UserRepo.
func (repo *Repo) CreateUser(ctx context.Context, newUser app.User, task app.TaskNotification) (userID app.UserID, err error) {
   const query = `INSERT INTO users (username, email, pass_hash) VALUES ($1, $2, $3) RETURNING id`

   hash := pgtype.Bytea{
      Bytes:  newUser.PassHash,
      Status: pgtype.Present,
   }

   err = repo.db.QueryRowxContext(ctx, query, newUser.Name, newUser.Email, hash).Scan(&userID)
   if err != nil {
      return 0, fmt.Errorf("create user: %w", err)
   }

   return userID, nil
}

// UserByUsername need for implements app.UserRepo.
func (repo *Repo) UserByUsername(ctx context.Context, username string) (user *app.User, err error) {
	const query = `SELECT * FROM users WHERE username = $1`

	u := &userDBFormat{}
	err = repo.db.GetContext(ctx, u, query, username)
	if err != nil {
		return nil, err
	}

	return u.toAppFormat(), nil
}


回购层具有简单而基本的方法。除了“保存,提交,更新,删除,查找”以外,他们不知道如何做。该层的任务仅仅是为我们项目所需的任何数据库提供方便的数据提供者。



API



仍然存在用于与客户端交互的API层。



要求将数据从客户端传输到业务逻辑,将结果返回,并完全满足所有HTTP需求-转换应用程序错误。



func (api *api) handler(w http.ResponseWriter, r *http.Request) {
	params := &arg{}
	err := json.NewDecoder(r.Body).Decode(params)
	if err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}

	origin := orifinFromReq(r)

	res, err := api.app.CreateUser(
		r.Context(), 
		params.Email, 
		params.Username,
		params.Password,
		request,
	)
	switch {
	case errors.Is(err, app.ErrNotFound):
		http.Error(w, app.ErrNotFound.Error(), http.StatusNotFound)
	case errors.Is(err, app.ErrChtoto):
		http.Error(w, app.ErrChtoto.Error(), http.StatusTeapot)
	case err == nil:
			json.NewEncoder(w).Encode(res)
	default:
		http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
	}
}


至此,他的任务结束了:他带来了数据,得到了结果,并将其转换为便于HTTP使用的格式。



真正需要什么清洁架构?



这是做什么用的?为什么要实施某些架构解决方案?不是为了代码的“整洁”,而是为了可测试性。我们需要能够方便,简单和轻松地测试我们自己的代码的能力。



例如,这样的代码是不好的



func (api *api) handler(w http.ResponseWriter, r *http.Request) {
	params := &arg{}
	err := json.NewDecoder(r.Body).Decode(params)
	if err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}

	rows, err := api.db.QueryContext(r.Context(), "sql query", params.Param)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}

	var arrayRes []val
	for rows.Next() {
		value := val{}
		err := rows.Scan(&value)
		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
		arrayRes = append(arrayRes, value)
	}

	//        

	err = json.NewEncoder(w).Encode(arrayRes)
	w.WriteHeader(http.StatusOK)
}




注意:忘记指出此代码是错误的。如果您在更新之前进行了阅读,则可能会产生误导。对于那个很抱歉。



能够在没有重大问题的情况下测试代码的能力是干净架构的主要优点。


我们可以通过从数据库,服务器,协议中进行抽象来测试所有业务逻辑。对我们来说,执行应用程序的已应用任务仅是重要的。现在,遵循某些简单的规则,我们可以轻松地扩展和更改代码。



任何产品都有业务逻辑。良好的体系结构有助于例如将业务逻辑打包到一个程序包中,该程序包的任务是与外部模块一起运行以执行应用的任务。



但是干净的体系结构并不总是很好。有时它会变成邪恶,带来不必要的复杂性。如果您尝试立即撰写完美的文章,我们将浪费宝贵的时间,并使项目失败。您不必写完美-根据您的业务目标写得好。



, Golang Live 2020 14 17 . — 14 , — , .



All Articles