开工福利|免费学 2200+ 精品线上课,企业成员人人可得! 了解详情
写点什么

架构整洁之道的实用指南

  • 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:098548
用户头像

发布了 760 篇内容, 共 507.9 次阅读, 收获喜欢 1569 次。

关注

评论

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

Charles for Mac:高效过滤与重发请求,让调试工作事半功倍

Rose

After Effects 2024 for Mac(AE2024视频特效)v24.1中文激活版

Rose

教程:通过 API 接口实现代码的自动生成

Apifox

程序员 前端 后端 代码 API

Infuse 强大的iOS和tvOS视频播放器应用程序

Rose

职场<火焰杯>测试开发大赛决赛倒计时:仅剩5天!

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

测试

阿里云获评AI基础设施服务产品力全球第二!微软、苹果卸任OpenAI董事会观察员!|AI日报

可信AI进展

人工智能

华为音乐与一样音乐达成版权合作,李荣浩经典歌曲强势登录

最新动态

MacOS停靠在菜单栏的系统监视工具Stats for mac

Rose

昆仑万维方汉:Scaling Law放缓,细分领域SOTA红利凸显

新消费日报

飞舞在化工企业的AI大模型梦想

白洞计划

AI

全渠道AI智能商品管理软件平台 助力零售品牌占领技术高地

第七在线

Sentieon应用教程:本地使用-Quick_start

INSVAST

基因数据分析 生信服务

【YashanDB知识库】YashanDB 开机自启

YashanDB

yashandb 崖山数据库 开机自启

异步日志:性能优化的金钥匙

阿里技术

性能优化 日志 故障分析 异步 故障排查

火山引擎数智平台赋能火花思维,A/B测试加速创新

字节跳动数据平台

大数据 A/B测试 对比实验 数字化增长

阿里巴巴搜索API助力电商精准营销:返回值的力量

技术冰糖葫芦

API 安全 API 文档 API 开发 API 协议

万界星空科技MES:磷酸铁锂正极新材料生产管理系统

万界星空科技

mes 万界星空科技 新材料mes ​磷酸铁锂MES ​磷酸铁锂行业

职场<火焰杯>测试开发大赛决赛倒计时:仅剩5天!

测试人

软件测试

Proxyman Premium for Mac:解锁网络调试与监控的新境界

Rose

Microsoft Word 2019 for mac (word mac)v16.78.3中文激活版

Rose

文献解读-液体活检-第十九期|《不同 DNA 测序平台的标准化比较》

INSVAST

基因数据分析 生信服务 液体活检

MySQL中为什么要使用索引合并(Index Merge)?

华为云开发者联盟

MySQL 数据库 华为云 华为云开发者联盟 企业号2024年7月PK榜

解密星辰大模型·软件工厂 软件开发迈入智能化全流程新阶段

科技热闻

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