写点什么

架构整洁之道的实用指南

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

发布了 736 篇内容, 共 474.8 次阅读, 收获喜欢 1548 次。

关注

评论

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

11月月更开启啦!冬天到了,不写点东西暖暖身子吗?

InfoQ写作社区官方

热门活动 11月月更

爆肝整理高频js手写题请查收

helloworld1024fd

JavaScript

深度讲解React Props

夏天的味道123

React

【C语言】前言关键字

謓泽

11月月更

看透react源码之感受react的进化

goClient1992

React

以开发之名 | 小红书:用年轻人的方式开发年轻人喜欢的应用

HarmonyOS SDK

视频超分 小红书

从react源码看hooks的原理

flyzz177

React

珠宝加工厂:我的成本下降空间在哪里

华为云开发者联盟

云计算 物联网 华为云 企业号十月 PK 榜

PaddleBox:百度基于GPU的超大规模离散DNN模型训练解决方案

百度Geek说

企业号十月 PK 榜 PaddlePaddl 模型训练框架 大规模离散模型

React-Hooks源码深度解读

goClient1992

React

怎样刷vue面试题

bb_xiaxia1998

Vue

关于“React 和 Vue 该用哪个”我真的栓Q

京东科技开发者

Vue 前端 Vue 3 VUE 3.0 源码 react rout

react的useState源码分析

flyzz177

React

前端高频手写题自测,你能做出几道

helloworld1024fd

JavaScript

聊一聊责任链模式

设计模式 责任链模式

这可能是你需要的vue考点梳理

bb_xiaxia1998

Vue

Docker不香吗?为什么还要用k8s

源字节1号

微信小程序 软件开发 前端开发 后端开发

深度理解Redux原理并实现一个redux

夏天的味道123

React

字节前端面试被问到的react问题

beifeng1996

React

Vue.$nextTick的原理是什么-vue面试进阶

bb_xiaxia1998

Vue

京东云开发者|提高IT运维效率,深度解读京东云AIOps落地实践

京东科技开发者

人工智能 异常检测 时序架构 运维‘

没想到GoFrame的gcache天然支持缓存淘汰策略

王中阳Go

Go golang 高效工作 学习方法 11月月更

Vue是怎样监听数组的变化的?

bb_xiaxia1998

Vue

高频js手写题之实现数组扁平化、深拷贝、总线模式

helloworld1024fd

JavaScript

面试官:请实现Javascript发布-订阅模式

helloworld1024fd

JavaScript

应用实践:Paddle分类模型大集成者[PaddleHub、Finetune、prompt]

汀丶人工智能

nlp 文本分类 关系抽取 命名实体识别 11月月更

案例解读华为隐私计算产品TICS如何实现城市跨部门数据隐私计算

华为云开发者联盟

云计算 华为云 隐私计算 企业号十月 PK 榜

React源码分析(一)Fiber

goClient1992

React

从recat源码角度看setState流程

flyzz177

React

华为云从入门到实战 | 负载均衡服务原理

TiAmo

华为 华为云 10月月更

探究Presto SQL引擎(4)-统计计数

vivo互联网技术

浏览器 presto 引擎

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