Categories
程式開發

架構整潔之道的實用指南


架構整潔之道的實用指南 1

上週天,閒來無事,我隨意瀏覽GitHub時,偶然發現一個非常流行的庫,它有超過10k的commits。我不打算說出其“真名”。即使我了解項目的技術棧,但代碼本身在我看起來還是有點糟糕。一些特性被隨意放在名為”utils”或”helpers”目錄裡,淹沒在大量低內聚的函數中。

大型項目的問題在於,隨著時間發展,它們變得愈加複雜,以至於重寫它們實際上比培訓新人讓他們真正理解代碼並做出貢獻的成本更低。

這讓我想起一件事,關於Clean Architecture。本文會包含一些Go代碼,但不要擔心,即使你不熟悉這門語言,一些概念也很容易理解。

什麼讓Clean Architecture如此清晰?

架構整潔之道的實用指南 2

簡而言之,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可能是一個存儲庫函數,但是loginsignup是服務函數。服務是存儲庫之上的抽象層,因為它們不與數據庫交互,而是與存儲庫接口交互。

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)))
}

錯誤處理

架構整潔之道的實用指南 3

整潔架構中的錯誤流

整潔架構中錯誤處理的基本原則如下:

存儲庫錯誤應該是統一的,並且應該針對每個接口適配器以不同的方式封裝和實現。

這實際上意味著所有數據庫級別的錯誤都應該由用戶接口以不同的方式處理。例如,如果有問題的用戶接口是一個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