生成式AI领域的最新成果都在这里!抢 QCon 展区门票 了解详情
写点什么

架构整洁之道的实用指南

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

发布了 686 篇内容, 共 395.1 次阅读, 收获喜欢 1498 次。

关注

评论

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

架构实战营-模块5课后作业

Mr.M

涨薪50%,从小厂逆袭,坐上美团L8技术专家(面经+心得)

钟奕礼

Java Java 面试 java编程 程序员 java

万字长文!对比分析了多款存储方案,KeeWiDB最终选择自己来

腾讯云数据库

nosql 存储 NoSQL 数据库 腾讯云数据库 KeeWiDB

python数据分析-pandas基础(4)-数据映射apply

AIWeker

Python Python数据分析 11月月更

一文熟悉 Go 的分支结构(if - else-if - else、switch)

陈明勇

Go golang Switch if 11月月更

824页23种设计模式全解析,搞定设计模式各种难题

小小怪下士

Java 程序员 设计模式

先到先得!阿里淘系内传322页Java并发编程核心讲义学习笔记

钟奕礼

Java java程序员 java面试 java编程

ipv6地址概述——了解ipv6地址

初学者

网络 11月月更

Note.js框架中的cluster集群和断言测试的实战剖析

恒山其若陋兮

前端 11月月更

从零开始读源码,阿里最新JDK源码剖析笔记在架构师社区火了

程序员小毕

Java 程序员 后端 jdk源码 架构师

2022-11-26:给定一个字符串s,只含有0~9这些字符 你可以使用来自s中的数字,目的是拼出一个最大的回文数 使用数字的个数,不能超过s里含有的个数 比如 : 39878,能拼出的最大回文数是

福大大架构师每日一题

算法 rust 福大大

云原生基础组件选型出发点

穿过生命散发芬芳

11月月更 云原生落地

图神经网络之预训练大模型结合:ERNIESage在链接预测任务应用

汀丶人工智能

图神经网络 图学习 11月月更

极致性能!阿里巴巴Java性能优化实录Github首次开源

Java永远的神

JVM 设计模式 多线程 java程序员 Java性能优化

CDH5部署三部曲之一:准备工作

程序员欣宸

大数据 CDH 11月月更

【网络安全必看】如何提升自身WEB渗透能力?

网络安全学海

黑客 网络安全 信息安全 渗透测试 漏洞挖掘

python数据分析-pandas基础3-数据对齐

AIWeker

Python Python数据分析 11月月更

华为云桌面Workspace云上办公,方便得很!

路过的憨憨

一个三年Java程序员的面试总结!绝对会对你有所帮助

钟奕礼

Java java面试 java编程 程序员 java

C++---类型萃取---is_array && is_enum/is_union/is_class

桑榆

C++ STL 11月月更

华为云会议,云上办公更轻松高效

路过的憨憨

今年Java技术岗面试太难了,收藏93套BATJ等公司面试题集,已看哭

钟奕礼

java面试 java编程 Java‘’ 程序员‘

在child_process域和错误的冒泡和捕获实践【Note.js】

恒山其若陋兮

前端 11月月更

极客时间运维进阶训练营第五周作业

好吃不贵

面向大规模队列,百万并发的多优先级消费系统设计

阿里云视频云

阿里云 队列 消费系统

最近面试Java开发的感受:就以平时项目经验面试,通过估计很难

钟奕礼

Java java面试 java编程 程序员 java

支持向量机-选取与核函数相关的参数:degree & gamma & coef0

烧灯续昼2002

Python 机器学习 算法 sklearn 11月月更

python小知识-hook函数

AIWeker

Python python小知识 11月月更

信息论与编码:线性分组码与性能参数

timerring

数据通信 11月月更 线性分组码

腾讯云大神亲码“redis深度笔记”,不讲一句废话,全是精华

钟奕礼

Java java程序员 java面试 java编程

进军东南亚市场,腾讯云数据库TDSQL助力印尼BNC银行数字化转型

腾讯云数据库

金融行业 tdsql 腾讯云数据库 BNC

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