架构整洁之道的实用指南

上周天,闲来无事,我随意浏览GitHub时,偶然发现一个非常流行的库,它有超过10k的commits。我不打算说出其“真名”。即使我了解项目的技术栈,但代码本身在我看起来还是有点糟糕。一些特性被随意放在名为"utils"或"helpers"目录里,淹没在大量低内聚的函数中。

大型项目的问题在于,随着时间发展,它们变得愈加复杂,以至于重写它们实际上比培训新人让他们真正理解代码并做出贡献的成本更低。

这让我想起一件事,关于Clean Architecture。本文会包含一些Go代码,但不要担心,即使你不熟悉这门语言,一些概念也很容易理解。

什么让Clean Architecture如此清晰?

简而言之,Clean Architecture可以带来以下好处:

  • 与数据库无关:你的核心业务逻辑并不关心你是使用Postgres、MongoDB还是Neo4J。
  • 与客户端接口无关:核心业务逻辑不关心你是使用CLI、REST API还是gRPC。
  • 与框架无关:使用普通的nodeJS、express、fastify?你的核心业务逻辑也不必关心这些。

如果你想进一步了解Clean Architecture的工作原理,你可以阅读Bob叔的博文

现在,让我们跳到实现部分。为了让你能跟上我的思路,请点击这里查看存储库。下面是整洁架构示例:

├── api
│   ├── handler
│   │   ├── admin.go
│   │   └── user.go
│   ├── main.go
│   ├── middleware
│   │   ├── auth.go
│   │   └── cors.go
│   └── views
│       └── errors.go
├── bin
│   └── main
├── config.json
├── docker-compose.yml
├── go.mod
├── go.sum
├── Makefile
├── pkg
│   ├── admin
│   │   ├── entity.go
│   │   ├── postgres.go
│   │   ├── repository.go
│   │   └── service.go
│   ├── errors.go
│   └── user
│       ├── entity.go
│       ├── postgres.go
│       ├── repository.go
│       └── service.go
├── README.md

实体

实体是可以由函数识别的核心业务对象。在MVC术语中,它们是整洁架构的模型层。所有的实体和服务都包含在一个名为pkg的目录中。

比如用户实体entity.go是这样的:

package user

import "github.com/jinzhu/gorm"

type User struct {
	gorm.Model
	FirstName   string `json:"first_name,omitempty"`
	LastName    string `json:"last_name,omitempty"`
	Password    string `json:"password,omitempty"`
	PhoneNumber string `json:"phone_number,omitempty"`
	Email       string `json:"email,omitempty"`
	Address     string `json:"address,omitempty"`
	DisplayPic  string `json:"display_pic,omitempty"`
}

实体用在Repository interface中,可以针对任何数据库进行实现。在本例中,我们针对Postgre数据库进行了实现,在文件postgres.go中。由于存储库(repository)可以针对任何数据库进行实现,因此,它们与所有实现细节都无关。

package user
import (
	"context"
)
type Repository interface {
	FindByID(ctx context.Context, id uint) (*User, error)
	BuildProfile(ctx context.Context, user *User) (*User, error)
	CreateMinimal(ctx context.Context, email, password, phoneNumber string) (*User, error)
	FindByEmailAndPassword(ctx context.Context, email, password string) (*User, error)
	FindByEmail(ctx context.Context, email string) (*User, error)
	DoesEmailExist(ctx context.Context, email string) (bool, error)
	ChangePassword(ctx context.Context, email, password string) error
}

服务

服务包含针对更高级业务逻辑函数的接口。例如,FindByID可能是一个存储库函数,但是loginsignup是服务函数。服务是存储库之上的抽象层,因为它们不与数据库交互,而是与存储库接口交互。

package user
import (
	"context"
	"crypto/md5"
	"encoding/hex"
	"errors"
)
type Service interface {
	Register(ctx context.Context, email, password, phoneNumber string) (*User, error)
	Login(ctx context.Context, email, password string) (*User, error)
	ChangePassword(ctx context.Context, email, password string) error
	BuildProfile(ctx context.Context, user *User) (*User, error)
	GetUserProfile(ctx context.Context, email string) (*User, error)
	IsValid(user *User) (bool, error)
	GetRepo() Repository
}
type service struct {
	repo Repository
}
func NewService(r Repository) Service {
	return &service{
		repo: r,
	}
}
func (s *service) Register(ctx context.Context, email, password, phoneNumber string) (u *User, err error) {
	exists, err := s.repo.DoesEmailExist(ctx, email)
	if err != nil {
		return nil, err
	}
	if exists {
		return nil, errors.New("User already exists")
	}
	hasher := md5.New()
	hasher.Write([]byte(password))
	return s.repo.CreateMinimal(ctx, email, hex.EncodeToString(hasher.Sum(nil)), phoneNumber)
}
func (s *service) Login(ctx context.Context, email, password string) (u *User, err error) {
	hasher := md5.New()
	hasher.Write([]byte(password))
	return s.repo.FindByEmailAndPassword(ctx, email, hex.EncodeToString(hasher.Sum(nil)))
}
func (s *service) ChangePassword(ctx context.Context, email, password string) (err error) {
	hasher := md5.New()
	hasher.Write([]byte(password))
	return s.repo.ChangePassword(ctx, email, hex.EncodeToString(hasher.Sum(nil)))
}
func (s *service) BuildProfile(ctx context.Context, user *User) (u *User, err error) {
	return s.repo.BuildProfile(ctx, user)
}
func (s *service) GetUserProfile(ctx context.Context, email string) (u *User, err error) {
	return s.repo.FindByEmail(ctx, email)
}
func (s *service) IsValid(user *User) (ok bool, err error) {
	return ok, err
}
func (s *service) GetRepo() Repository {
	return s.repo
}

服务在用户接口级实现。

接口适配器

每个用户接口都有自己独立的目录。在我们例子中,由于有一个API作为接口,所以我们有一个名为api的目录。

由于每个用户接口以不同的方式侦听请求,所以接口适配器都有自己的main.go文件,其任务如下:

  • 创建存储库
  • 将存储库封装到服务中
  • 将服务封装到处理器中

这里,处理器只是请求-响应模型的用户接口级实现。每个服务都有自己的处理器,见user.go

package handler

import (
	"encoding/json"
	"net/http"

	"github.com/L04DB4L4NC3R/jobs-mhrd/api/middleware"
	"github.com/L04DB4L4NC3R/jobs-mhrd/api/views"
	"github.com/L04DB4L4NC3R/jobs-mhrd/pkg/user"
	"github.com/dgrijalva/jwt-go"
	"github.com/spf13/viper"
)

func register(svc user.Service) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.Method != http.MethodPost {
			views.Wrap(views.ErrMethodNotAllowed, w)
			return
		}

		var user user.User
		if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
			views.Wrap(err, w)
			return
		}

		u, err := svc.Register(r.Context(), user.Email, user.Password, user.PhoneNumber)
		if err != nil {
			views.Wrap(err, w)
			return
		}
		w.WriteHeader(http.StatusCreated)
		token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
			"email": u.Email,
			"id":    u.ID,
			"role":  "user",
		})
		tokenString, err := token.SignedString([]byte(viper.GetString("jwt_secret")))
		if err != nil {
			views.Wrap(err, w)
			return
		}
		json.NewEncoder(w).Encode(map[string]interface{}{
			"token": tokenString,
			"user":  u,
		})
		return
	})
}

func login(svc user.Service) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.Method != http.MethodPost {
			views.Wrap(views.ErrMethodNotAllowed, w)
			return
		}
		var user user.User
		if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
			views.Wrap(err, w)
			return
		}

		u, err := svc.Login(r.Context(), user.Email, user.Password)
		if err != nil {
			views.Wrap(err, w)
			return
		}

		token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
			"email": u.Email,
			"id":    u.ID,
			"role":  "user",
		})
		tokenString, err := token.SignedString([]byte(viper.GetString("jwt_secret")))
		if err != nil {
			views.Wrap(err, w)
			return
		}
		json.NewEncoder(w).Encode(map[string]interface{}{
			"token": tokenString,
			"user":  u,
		})
		return
	})
}

func profile(svc user.Service) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

		// @protected
		// @description build profile
		if r.Method == http.MethodPost {
			var user user.User
			if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
				views.Wrap(err, w)
				return
			}

			claims, err := middleware.ValidateAndGetClaims(r.Context(), "user")
			if err != nil {
				views.Wrap(err, w)
				return
			}
			user.Email = claims["email"].(string)
			u, err := svc.BuildProfile(r.Context(), &user)
			if err != nil {
				views.Wrap(err, w)
				return
			}

			json.NewEncoder(w).Encode(u)
			return
		} else if r.Method == http.MethodGet {

			// @description view profile
			claims, err := middleware.ValidateAndGetClaims(r.Context(), "user")
			if err != nil {
				views.Wrap(err, w)
				return
			}
			u, err := svc.GetUserProfile(r.Context(), claims["email"].(string))
			if err != nil {
				views.Wrap(err, w)
				return
			}

			json.NewEncoder(w).Encode(map[string]interface{}{
				"message": "User profile",
				"data":    u,
			})
			return
		} else {
			views.Wrap(views.ErrMethodNotAllowed, w)
			return
		}
	})
}

func changePassword(svc user.Service) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.Method == http.MethodPost {
			var u user.User
			if err := json.NewDecoder(r.Body).Decode(&u); err != nil {
				views.Wrap(err, w)
				return
			}

			claims, err := middleware.ValidateAndGetClaims(r.Context(), "user")
			if err != nil {
				views.Wrap(err, w)
				return
			}
			if err := svc.ChangePassword(r.Context(), claims["email"].(string), u.Password); err != nil {
				views.Wrap(err, w)
				return
			}
			return
		} else {
			views.Wrap(views.ErrMethodNotAllowed, w)
			return
		}
	})
}

// expose handlers
func MakeUserHandler(r *http.ServeMux, svc user.Service) {
	r.Handle("/api/v1/user/ping", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
		return
	}))
	r.Handle("/api/v1/user/register", register(svc))
	r.Handle("/api/v1/user/login", login(svc))
	r.Handle("/api/v1/user/profile", middleware.Validate(profile(svc)))
	r.Handle("/api/v1/user/pwd", middleware.Validate(changePassword(svc)))
}

错误处理

整洁架构中的错误流

整洁架构中错误处理的基本原则如下:

存储库错误应该是统一的,并且应该针对每个接口适配器以不同的方式封装和实现。

这实际上意味着所有数据库级别的错误都应该由用户接口以不同的方式处理。例如,如果有问题的用户接口是一个REST API,那么错误应该以HTTP状态码的形式出现,在本例中是500代码。然而,如果是一个CLI,那么它应该使用状态码1退出。

在整洁架构中,存储库错误的根源可以放在pkg中,这样,存储库函数就可以在控制流出错时调用它们,如下所示:

package errors

import (
	"errors"
)

var (
	ErrNotFound     = errors.New("Error: Document not found")
	ErrNoContent    = errors.New("Error: Document not found")
	ErrInvalidSlug  = errors.New("Error: Invalid slug")
	ErrExists       = errors.New("Error: Document already exists")
	ErrDatabase     = errors.New("Error: Database error")
	ErrUnauthorized = errors.New("Error: You are not allowed to perform this action")
	ErrForbidden    = errors.New("Error: Access to this resource is forbidden")
)

然后,可以根据特定的用户接口实现相同的错误,并且通常能在处理器级封装在视图中,如下所示:

package views

import (
	"encoding/json"
	"errors"
	"net/http"

	log "github.com/sirupsen/logrus"

	pkg "github.com/L04DB4L4NC3R/jobs-mhrd/pkg"
)

type ErrView struct {
	Message string `json:"message"`
	Status  int    `json:"status"`
}

var (
	ErrMethodNotAllowed = errors.New("Error: Method is not allowed")
	ErrInvalidToken     = errors.New("Error: Invalid Authorization token")
	ErrUserExists       = errors.New("User already exists")
)

var ErrHTTPStatusMap = map[string]int{
	pkg.ErrNotFound.Error():     http.StatusNotFound,
	pkg.ErrInvalidSlug.Error():  http.StatusBadRequest,
	pkg.ErrExists.Error():       http.StatusConflict,
	pkg.ErrNoContent.Error():    http.StatusNotFound,
	pkg.ErrDatabase.Error():     http.StatusInternalServerError,
	pkg.ErrUnauthorized.Error(): http.StatusUnauthorized,
	pkg.ErrForbidden.Error():    http.StatusForbidden,
	ErrMethodNotAllowed.Error(): http.StatusMethodNotAllowed,
	ErrInvalidToken.Error():     http.StatusBadRequest,
	ErrUserExists.Error():       http.StatusConflict,
}

func Wrap(err error, w http.ResponseWriter) {
	msg := err.Error()
	code := ErrHTTPStatusMap[msg]

	// If error code is not found
	// like a default case
	if code == 0 {
		code = http.StatusInternalServerError
	}

	w.WriteHeader(code)

	errView := ErrView{
		Message: msg,
		Status:  code,
	}
	log.WithFields(log.Fields{
		"message": msg,
		"code":    code,
	}).Error("Error occurred")

	json.NewEncoder(w).Encode(errView)
}

每个存储库级错误(或其他情况)都封装在映射中,它会返回对应相应错误的HTTP状态码。

小结

整洁架构是结构化代码的好方法,不必在意敏捷迭代或快速原型所带来的复杂性,并且与数据库、用户接口以及框架无关。

英文原文:

Clean Architecture, the right way

  • 发表于:
  • 本文为 InfoQ 中文站特供稿件
  • 首发地址https://www.infoq.cn/article/KCX40i6JVMQSKrv9V63p
  • 如有侵权,请联系 yunjia_community@tencent.com 删除。

扫码关注云+社区

领取腾讯云代金券