上周天,闲来无事,我随意浏览 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 user
import (
"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 user
import (
"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 handlers
func 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
更多内容推荐
抽象、低内聚、难变更,你还在用“堆栈”组织代码?
在企业代码库中,目前最流程的代码组织方式是按照技术栈的层次对所有类进行分组,即“堆栈”(stack)风格。但这种风格存在抽象不恰当、低内聚、难变更及设计选择受限等问题,从而作者提出了一种替代方案,“实体”风格的代码组织方式。
特别放送|回头看:如何更好地组织代码?
这节课,让我们重新来思考一下如何更好地组织代码。
2023-02-14
GitHub 如何从单体架构迁移到微服务架构?
本文介绍GitHub如何从单体架构迁移到微服务架构,并对其中一些最佳实践做了详细说明。
17|Koa.js:如何结合 Koa.js 开发 Node.js Web 服务?
前后端项目分离的理念要从具体的需求场景来分析,要从“技术”“管理”和“行情”这三个视角看待,不能脱离实际环境,一昧讨论技术理念的利弊优劣。
2023-01-02
如何创建可扩展和可维护的前端架构
我们需要对前端项目进行设置。要让它们变得更易于维护和扩展。那意味着我们可以对当前特性进行修改,但也可以更快地添加新特性。
使用 GraphQL 和 Ballerina 操作多个数据源
本文讨论了GraphQL和Ballerina的优势,并提供了一个从数据库和第三方API获取数据的示例应用程序。
架构实战训练营存储架构设计
架构实战训练营作业
2021-08-02
09|分层架构:怎样逃离“大泥球”?
学完这节课,希望你能理解分层架构背后的原理,然后针对自己项目中存在的痛点进行权衡,形成适合自己项目的架构规范。
2022-12-24
如何用 Go 语言构建、测试和部署可扩展的 REST API
使用gin框架创建一个简单的Go应用程序。
104|再回首:“Web 开发”单元小结
2023-02-01
详解“洋葱架构”
领域驱动设计(Domain-driven design,DDD)是一种为复杂需求开发软件的方法,它将软件的实现与不断发展的核心业务概念模型紧密地结合在一起。
java 之面向对象 2
目录
2022-09-12
Node.js 中的关注点分离
关注点分离是 Node.js 的一种架构,可以确保代码的可读性、易于重构和良好的代码协作。通过遵循关注点分离原则,你可以确保最终的系统是稳定和可维护的。
Go 中的泛型:激动人心的突破
一个特性改变一切。
为遗留 Node.js 后端编写自动化测试
让我们来探究一下为什么有些Node.js代码库比其他的更难测试。并探讨编写简单、健壮和快速检查业务逻辑的测试的几种技术。
Go 专栏|函数那些事
原文链接: Go 专栏|函数那些事
2021-09-02
10|微服务设计:微服务架构与演进
这节课,我们来拆解一下在构建微服务架构的过程中,我们面临的挑战以及需要具备的技术,让你对于微服务架构有更深入的理解。
2022-11-01
Rust 从 0 到 1- 代码组织 - 路径
用来找到我们需要使用的函数、结构体或枚举等,类似文件系统路径。
2021-04-27
架构实战营 4.1 数据库存储架构随堂练习
【判断题】
2023-01-10
44|一个程序多种功能:构建子命令与 flags
这节课,让我们打开分布式开发的大门,一起看看如何开发Master服务,实现任务的调度与故障容错。
2023-01-19
暂无签名
推荐阅读
14|基于 Flask 的推荐服务:搭建基础的 Flask 服务
2023-05-17
改变一个字符后,我的 Go 程序快了 42%
一文学会 List 函数排序操作,20 秒即可完成!
2023-08-31
Next.js + Rust 革新全栈开发,Rust 没那么难
4. JdbcTemplate 实现原理剖析
2023-09-25
如何整理自己的前端面试题库
2023-02-28
21|Web 开发(上):如何使用 Axum 框架进行 Web 后端开发?
2023-12-11
电子书
大厂实战PPT下载
换一换 金发华 | EMQ 映云科技 联合创始人兼 CPO
杨军 | 腾讯 IEG 技术运营部 SRE 总监
吴凯凯 | 字节跳动 技术专家
评论