

上周天,闲来无事,我随意浏览 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
更多内容推荐
Go 专栏|函数那些事
原文链接: Go 专栏|函数那些事
2021-09-02
20|大型项目:源码越来越多,项目该如何扩展?
这节课我们会继续讨论React应用的整体逻辑,看看大中型React项目在代码增多后,整体扩展上会遇到的挑战,以及如何应对这些挑战。
2022-10-15
使用 GraphQL 和 Ballerina 操作多个数据源
本文讨论了GraphQL和Ballerina的优势,并提供了一个从数据库和第三方API获取数据的示例应用程序。
架构实战营 4.1 数据库存储架构随堂练习
【判断题】
2023-01-10
【译】JavaScript 代码整洁之道 - 函数篇
在这篇文章中,我们将介绍书写整洁代码的基本技巧和建议,并重点关注于可复用的代码单元 -- 函数。
2021-06-05
28|追本溯源:从第一版 React Native 开始学会读懂源码
今天,我们以第一版React Native源码为例,教你如何通过读源码,一步一步理解React Native新架构。
2022-09-01
Node.js 中的关注点分离
关注点分离是 Node.js 的一种架构,可以确保代码的可读性、易于重构和良好的代码协作。通过遵循关注点分离原则,你可以确保最终的系统是稳定和可维护的。
一个测试工程师走进一家酒吧……
在软件工程中,测试是极其重要的一环,比重通常可以与编码相同,甚至大大超过。那么在 Golang 里,怎么样把测试写好,写正确?
Linux 之常见的存储架构
Linux之常见的存储架构
2021-09-28
09|即学即练:构建一个 Web 服务就是这么简单
用Go语言构建一个Web服务就是这么简单!
2021-11-01
实战:将 ToDo 应用扩展为一个 REST 服务
这一讲,我们就把前面已经写好的 ToDo 应用扩展一下,让它变成一个 REST 服务。
2021-08-30
10|微服务设计:微服务架构与演进
这节课,我们来拆解一下在构建微服务架构的过程中,我们面临的挑战以及需要具备的技术,让你对于微服务架构有更深入的理解。
2022-11-01
详解“洋葱架构”
领域驱动设计(Domain-driven design,DDD)是一种为复杂需求开发软件的方法,它将软件的实现与不断发展的核心业务概念模型紧密地结合在一起。
闭包与内存泄露
● 函数
2021-11-25
设计方法:怎么写出优雅的 Go 项目?
今天,我会给你一套“写出优雅Go项目”的方法论,让你区别于绝大部分的Go开发者,从而在职场上建立自己的核心竞争力。
2021-06-15
Flutter 嵌套深?扩展函数了解一下,面试字节跳动 Android 工程师该怎么准备
buildItem("billy"),
2021-11-05
【LeetCode】实现 strStr()Java 题解
实现 strStr() 函数。
2021-04-20
Go 中的泛型:激动人心的突破
一个特性改变一切。
如何创建可扩展和可维护的前端架构
我们需要对前端项目进行设置。要让它们变得更易于维护和扩展。那意味着我们可以对当前特性进行修改,但也可以更快地添加新特性。
SQL 优化(一):慎用 SQL 函数
SQL 优化系列文章之慎用 SQL 函数,本文介绍了 MySQL 在索引字段上使用函数操作对 SQL 执行效率的影响,分析了原因并给出对应的解决方案
技术选型
暂无签名
推荐阅读
10|代码实现(上):要“贫血”还是要“充血”?
2022-12-27
黄东旭:The Future of Database,掀开 TiDB Serverless 的引擎盖
2023-07-26
JAVA 中的函数接口,你都用过吗
2023-11-20
09|分层架构:怎样逃离“大泥球”?
2022-12-24
函数式编程如何帮助你编写高效、优雅的 Web 应用程序
编程语言软件测试 / 测试开发丨面向对象编程学习笔记分享
2023-07-04
特别放送|回头看:如何更好地组织代码?
2023-02-14
电子书

大厂实战PPT下载
换一换 
徐振中 | Claypot AI 联合创始人兼 CTO
巴川 | 竞技世界 首席数据科学家
郭智勇 | 美的集团 AIIC云平台高级架构师
评论