限时领|《AI 百问百答》专栏课+实体书(包邮)! 了解详情
写点什么

架构整洁之道的实用指南

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

发布了 817 篇内容, 共 562.6 次阅读, 收获喜欢 1590 次。

关注

评论

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

行云洞见|为什么说云端IDE代表未来趋势?

行云创新

程序员 云原生 CloudIDE 集成开发环境 云端IDE

如何集中式管理多个客户端节点传输任务-镭速

镭速

带你熟悉3种AQS的线程并发工具的用法

华为云开发者联盟

后端 开发 华为云 企业号 1 月 PK 榜 华为云开发者联盟

2022年中国跨境支付行业年度专题分析

易观分析

金融 跨境支付 市场

一种前端无源码定制化开发能力专利解读

元年技术洞察

低代码 数字化转型 无源码 元年方舟

年度技术盘点:水稻、韦伯、脑机接口、AI预测及创作、快速充电

B Impact

国家先进计算产业创新(宜昌)中心来了!

SENSORO

人工智能 大数据

软件测试/测试开发 | app自动化测试(Android)--高级定位技巧

测试人

软件测试 自动化测试 测试开发 appium

软件测试/测试开发 | app自动化测试(Android)--App 控件定位

测试人

软件测试 自动化测试 测试开发 appium

CleanMyMac X2023完整版本MAC电脑系统清理工具

茶色酒

CleanMyMac

2023最新版本水果FL Studio宿主软件安装包下载

茶色酒

FL Studio21

在人间vpn搭建

阿呆

vpn

移动开发平台真的能提升App开发效率吗?

Onegun

移动应用开发 移动端开发

Github霸榜!竟是阿里技术官的微服务分布式项目实战笔记总结

程序知音

Java 分布式 微服务 java架构 后端技术

由浅入深,聊聊 LeackCanary 的那些事

Petterp

android 内存泄漏 LeakCanary

软件测试/测试开发 | app自动化测试(Android)--元素定位方式与隐式等待

测试人

软件测试 自动化测试 测试开发 appium

各大互联网公司面经分享:Java全栈知识+1500道大厂面试真题

程序知音

Java java面试 java架构 大厂面试题 八股文

推荐几款实用的移动开发平台

FinFish

移动开发 开发平台 移动开发平台 移动端开发 小程序技术

2022年12月视频行业用户洞察:世界杯后半程看球热度不减,优质IP创新与开发助力用户留存

易观分析

音视频 视频

MixMIM 创建混合图像,提出新型掩码方案

Zilliz

计算机视觉

架构实战 5 -微博评论高性能高可用计算架构

架构实战营 「架构实战营」

尚硅谷Spring6发布视频教程

小谷哥

阿里、腾讯、蚂蚁金服Java技术面试及经验总结(文末分享答案)

程序知音

Java java面试 后端技术 春招 八股文

太狠了,Spring全家桶笔记,一站式通关全攻略,已入职某厂涨薪18K

程序知音

Java spring ssm java架构 后端技术

模块五计算架构模式

程序员小张

「架构实战营」

网易游戏实时 HTAP 计费风控平台建设

Apache Flink

大数据 flink 实时计算

从零到一,臻于至善|网易邮箱基于StarRocks 开发大数据平台的实践

StarRocks

数据库

混合式APP开发框架

力软低代码开发平台

C4D和3dmax有什么区别?

Finovy Cloud

3DMAX C4D

运维百家讲坛第 1 期:井源 - 运维几何

巴辉特

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