架构整洁之道的实用指南
上周天,闲来无事,我随意浏览 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 是这样的:
复制代码
packageuser 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"` Addressstring `json:"address,omitempty"` DisplayPic string `json:"display_pic,omitempty"` }
实体用在 Repository interface 中,可以针对任何数据库进行实现。在本例中,我们针对 Postgre 数据库进行了实现,在文件 postgres.go 中。由于存储库(repository)可以针对任何数据库进行实现,因此,它们与所有实现细节都无关。
复制代码
packageuser import ( "context" ) type Repositoryinterface{ 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 可能是一个存储库函数,但是 login 或 signup 是服务函数。服务是存储库之上的抽象层,因为它们不与数据库交互,而是与存储库接口交互。
复制代码
packageuser import( "context" "crypto/md5" "encoding/hex" "errors" ) typeServiceinterface{ Register(ctx context.Context, email, password, phoneNumberstring) (*User, error) Login(ctx context.Context, email, passwordstring) (*User, error) ChangePassword(ctx context.Context, email, passwordstring) error BuildProfile(ctx context.Context, user *User) (*User, error) GetUserProfile(ctx context.Context, emailstring) (*User, error) IsValid(user *User) (bool, error) GetRepo() Repository } typeservicestruct{ repo Repository } funcNewService(r Repository)Service{ return&service{ repo: r, } } func(s *service)Register(ctx context.Context, email, password, phoneNumberstring)(u *User, err error){ exists, err := s.repo.DoesEmailExist(ctx, email) iferr !=nil{ returnnil, err } ifexists { returnnil, errors.New("User already exists") } hasher := md5.New() hasher.Write([]byte(password)) returns.repo.CreateMinimal(ctx, email, hex.EncodeToString(hasher.Sum(nil)), phoneNumber) } func(s *service)Login(ctx context.Context, email, passwordstring)(u *User, err error){ hasher := md5.New() hasher.Write([]byte(password)) returns.repo.FindByEmailAndPassword(ctx, email, hex.EncodeToString(hasher.Sum(nil))) } func(s *service)ChangePassword(ctx context.Context, email, passwordstring)(err error){ hasher := md5.New() hasher.Write([]byte(password)) returns.repo.ChangePassword(ctx, email, hex.EncodeToString(hasher.Sum(nil))) } func(s *service)BuildProfile(ctx context.Context, user *User)(u *User, err error){ returns.repo.BuildProfile(ctx, user) } func(s *service)GetUserProfile(ctx context.Context, emailstring)(u *User, err error){ returns.repo.FindByEmail(ctx, email) } func(s *service)IsValid(user *User)(okbool, err error){ returnok, err } func(s *service)GetRepo()Repository{ returns.repo }
服务在用户接口级实现。
接口适配器
每个用户接口都有自己独立的目录。在我们例子中,由于有一个 API 作为接口,所以我们有一个名为 api 的目录。
由于每个用户接口以不同的方式侦听请求,所以接口适配器都有自己的 main.go 文件,其任务如下:
- 创建存储库
- 将存储库封装到服务中
- 将服务封装到处理器中
这里,处理器只是请求 – 响应模型的用户接口级实现。每个服务都有自己的处理器,见 user.go :
复制代码
packagehandler 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" ) funcregister(svc user.Service)http.Handler{ returnhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request){ ifr.Method != http.MethodPost { views.Wrap(views.ErrMethodNotAllowed, w) return } varuser user.User iferr := 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) iferr !=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"))) iferr !=nil{ views.Wrap(err, w) return } json.NewEncoder(w).Encode(map[string]interface{}{ "token": tokenString, "user": u, }) return }) } funclogin(svc user.Service)http.Handler{ returnhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request){ ifr.Method != http.MethodPost { views.Wrap(views.ErrMethodNotAllowed, w) return } varuser user.User iferr := json.NewDecoder(r.Body).Decode(&user); err !=nil{ views.Wrap(err, w) return } u, err := svc.Login(r.Context(), user.Email, user.Password) iferr !=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"))) iferr !=nil{ views.Wrap(err, w) return } json.NewEncoder(w).Encode(map[string]interface{}{ "token": tokenString, "user": u, }) return }) } funcprofile(svc user.Service)http.Handler{ returnhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request){ // @protected // @description build profile ifr.Method == http.MethodPost { varuser user.User iferr := json.NewDecoder(r.Body).Decode(&user); err !=nil{ views.Wrap(err, w) return } claims, err := middleware.ValidateAndGetClaims(r.Context(),"user") iferr !=nil{ views.Wrap(err, w) return } user.Email = claims["email"].(string) u, err := svc.BuildProfile(r.Context(), &user) iferr !=nil{ views.Wrap(err, w) return } json.NewEncoder(w).Encode(u) return }elseifr.Method == http.MethodGet { // @description view profile claims, err := middleware.ValidateAndGetClaims(r.Context(),"user") iferr !=nil{ views.Wrap(err, w) return } u, err := svc.GetUserProfile(r.Context(), claims["email"].(string)) iferr !=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 } }) } funcchangePassword(svc user.Service)http.Handler{ returnhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request){ ifr.Method == http.MethodPost { varu user.User iferr := json.NewDecoder(r.Body).Decode(&u); err !=nil{ views.Wrap(err, w) return } claims, err := middleware.ValidateAndGetClaims(r.Context(),"user") iferr !=nil{ views.Wrap(err, w) return } iferr := 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 funcMakeUserHandler(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") ) varErrHTTPStatusMap = 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 ifcode==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 状态码。
小结
整洁架构是结构化代码的好方法,不必在意敏捷迭代或快速原型所带来的复杂性,并且与数据库、用户接口以及框架无关。
英文原文: