写点什么

架构整洁之道的实用指南

  • 2020-03-06
  • 本文字数:6219 字

    阅读完需:约 20 分钟

架构整洁之道的实用指南


上周天,闲来无事,我随意浏览 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 userimport (  "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 可能是一个存储库函数,但是 login signup 是服务函数。服务是存储库之上的抽象层,因为它们不与数据库交互,而是与存储库接口交互。


package userimport (  "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 handlersfunc 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


2020-03-06 16:098612
用户头像

发布了 807 篇内容, 共 555.1 次阅读, 收获喜欢 1588 次。

关注

评论

发布
暂无评论
发现更多内容

区块链开发:链接未来的技术之桥

区块链软件开发推广运营

dapp开发 区块链开发 链游开发 NFT开发 公链开发

计算机系本科生获“火焰杯”软件测试高校就业选拔赛一等奖

测吧(北京)科技有限公司

网安学院举办第二届“火焰杯”软件测试高校就业选拔赛颁奖典礼

测吧(北京)科技有限公司

测试

长春工业大学-在2023年全国大学生“火焰杯”软件测试大赛中喜获佳绩

测吧(北京)科技有限公司

测试

MacBook触控板窗口管理:Swish for Mac

彩云

窗口管理工具 Swish for Mac

Java电子病历编辑器源码(云端SaaS服务)

源码星辰

Java 源码

华新学院在2022 年全国大学生“火焰杯”软件测试高校就业选拔赛取得佳绩

测吧(北京)科技有限公司

测试

轻量应用服务器为何是小程序开发“黄金搭档”?华为云给出完美答案

平平无奇爱好科技

“火焰杯”软件测试高校就业选拔赛获奖名单揭晓,人工智能与大数据学院两名学子上榜,奖金2万元!

测吧(北京)科技有限公司

测试

软件工程专业教师参加“火焰杯”软件测试颁奖典礼

测吧(北京)科技有限公司

测试

河南工业大学在第三届“火焰杯”软件测试开发选拔赛中 取得佳绩

测吧(北京)科技有限公司

测试

东莞理工大学-第四届“火焰杯”软件测试高校就业选拔赛颁奖典礼

测吧(北京)科技有限公司

测试

快速渡过新手期!华为云服务器让小程序开发的试错成本更低

平平无奇爱好科技

开启数字化转型新纪元,华为云引领轻量应用服务器创新

平平无奇爱好科技

以赛促教,以赛促研 ——计算机科学系举办“火焰杯”软件测试开发选拔赛颁奖仪式

测吧(北京)科技有限公司

测试

HarmonyOS位置服务开发指南

HarmonyOS开发者

HarmonyOS

高博学子参加第二届火焰杯软件测试高校就业选拔赛喜获佳绩

测吧(北京)科技有限公司

测试

第二届、第三届<火焰杯>软件测试开发选拔赛河北赛区颁奖典礼落幕

测吧(北京)科技有限公司

测试

企业如何保障跨境金融业务中的数据安全传输?

镭速

跨境数据传输

使用Terraform创建Docker镜像和容器

互联网工科生

Terraform Docker 镜像

ABBYY FineReader PDF 15 for Mac「OCR文字识别工具」

彩云

macos Abbyy FineReader OCR

计算机科学与工程学院颁奖仪式

测吧(北京)科技有限公司

测试

网安学院举办第三届“火焰杯”软件测试高校就业选拔赛颁奖典礼

测吧(北京)科技有限公司

测试

数字化红利进入“下半场” ,华为云耀云服务器L实例全力构建中小企业磁力场

平平无奇爱好科技

用二维码展示产品信息,轻松解决产品宣传难题

草料二维码

二维码 产品更新 草料二维码 产品宣传

如何选择最适合你的国外云服务器专业指南

一只扑棱蛾子

云服务器 国外云服务器

厦门理工学院五名学子获第四届“火焰杯”软件测试开发选拔赛全国奖项

测吧(北京)科技有限公司

测试

韩山师范学院-获第四届“火焰杯”软件测试开发选拔赛全国奖项

测吧(北京)科技有限公司

测试

万界星空科技QMS质量管理系统介绍

万界星空科技

QMS 质量管理系统 万界兴科科技QMS 质量管理QMS系统 生产质量管理

架构整洁之道的实用指南_文化 & 方法_Angad Sharma_InfoQ精选文章