4层架构
目前,你的代码可以跑起来。但是,细观之下,不够“洁净”。
不够“洁净”
如下是代码不够洁净的几个点:
// 数据库实例
var db *gorm.DB
- 不应该显式地使用全局变量来承载数据库实例并到处使用。
// 初始化数据库
func initDB() {
var err error
db, err = gorm.Open(sqlite.Open("test.db"), &gorm.Config{})
if err != nil {
panic("failed to connect database")
}
// 自动迁移
db.AutoMigrate(&model.Book{})
}
- 业务代码中去初始化数据库不是最佳实践。数据库操作属于“基础架构”,而不是“业务”。它们属于不同的关注点,你应该将其分开到不同地方。
// Get single book
func getBook(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var book model.Book
if err := db.First(&book, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Record not found"})
return
}
c.JSON(http.StatusOK, book)
}
- 不推荐在业务代码中直接依赖三方库。
db.First
是来自GORM
三方包的方法。 要不然将来想切换到其他 ORM 库的话,业务改动成本极高。
关注点分离
来自 https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html
整洁架构(The Clean Architecture) 是 Robert C. Martin 在他的书《整洁架构:软件结构与设计的工匠指南》中引入的软件架构模式。 它强调了软件系统内的关注点分离,其主要目标是使系统在其生命周期内更易于维护、可扩展和可测试。
4层架构在细节上有所不同,但与整洁架构类似。它们都有相同的目标,即关注点分离。它们都通过将软件划分为层来实现这种分离。
-
Adapter 适配器层:负责路由和适配框架、协议和前端显示(Web、移动等)。
-
Application 应用层:主要负责获取输入、组装上下文、参数验证,调用领域层进行业务处理。该层是开放式的,应用层可以绕过领域层并直接访问基础设施层。
-
Domain 领域层:封装核心业务逻辑,并通过领域服务和领域实体的方法向应用层提供业务实体和业务逻辑操作。领域是应用程序的核心,不依赖于任何其他层。
-
Infrastructure 基础设施层:主要处理诸如数据库上的CRUD操作、搜索引擎、文件系统、用于分布式服务的RPC等技术细节。外部依赖项需要通过此处的网关进行转换,然后才能被上面的应用层和领域层使用。
重构
让我们基于4层架构来重构。
新的目录结构如下所示:
projects/lr_rest_books_go
├── LICENSE
├── README.md
├── adaptor
│ └── router.go
├── application
│ ├── executor
│ │ └── book_operator.go
│ └── wire_helper.go
├── domain
│ ├── gateway
│ │ └── book_manager.go
│ └── model
│ └── book.go
├── go.mod
├── go.sum
├── infrastructure
│ ├── config
│ │ └── config.go
│ └── database
│ └── sqlite.go
├── main.go
└── test.db
移动 model/book.go 到 domain/model/book.go:
package model
import "time"
type Book struct {
ID uint `json:"id"`
Title string `json:"title"`
Author string `json:"author"`
PublishedAt time.Time `json:"published_at"`
Description string `json:"description"`
ISBN string `json:"isbn"`
TotalPages int `json:"total_pages"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
domain
目录存放核心业务规则。非业务代码远离该目录。
创建 domain/gateway/book_manager.go:
package gateway
import (
"context"
"literank.com/rest-books/domain/model"
)
// BookManager 管理所有图书
type BookManager interface {
CreateBook(ctx context.Context, b *model.Book) (uint, error)
UpdateBook(ctx context.Context, id uint, b *model.Book) error
DeleteBook(ctx context.Context, id uint) error
GetBook(ctx context.Context, id uint) (*model.Book, error)
GetBooks(ctx context.Context) ([]*model.Book, error)
}
BookManager
接口定义领域实体 Book
的业务能力。
此处只是接口,具体实现交由基础架构层去完成。
另外,这些方法使得应用层可以调用它执行业务逻辑。
创建 infrastructrue/database/sqlite.go:
/*
Package database does all db persistence implementations.
*/
package database
import (
"context"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"literank.com/rest-books/domain/model"
)
type SQLitePersistence struct {
db *gorm.DB
}
func NewSQLitePersistence(fileName string) (*SQLitePersistence, error) {
db, err := gorm.Open(sqlite.Open(fileName), &gorm.Config{})
if err != nil {
return nil, err
}
return &SQLitePersistence{db}, nil
}
func (s *SQLitePersistence) CreateBook(ctx context.Context, b *model.Book) (uint, error) {
if err := s.db.WithContext(ctx).Create(b).Error; err != nil {
return 0, err
}
return b.ID, nil
}
func (s *SQLitePersistence) UpdateBook(ctx context.Context, id uint, b *model.Book) error {
var book model.Book
if err := s.db.WithContext(ctx).First(&book, id).Error; err != nil {
return err
}
return s.db.WithContext(ctx).Model(book).Updates(b).Error
}
func (s *SQLitePersistence) DeleteBook(ctx context.Context, id uint) error {
return s.db.WithContext(ctx).Delete(&model.Book{}, id).Error
}
func (s *SQLitePersistence) GetBook(ctx context.Context, id uint) (*model.Book, error) {
var book model.Book
if err := s.db.WithContext(ctx).First(&book, id).Error; err != nil {
return nil, err
}
return &book, nil
}
func (s *SQLitePersistence) GetBooks(ctx context.Context) ([]*model.Book, error) {
books := make([]*model.Book, 0)
if err := s.db.WithContext(ctx).Find(&books).Error; err != nil {
return nil, err
}
return books, nil
}
如你所见,结构体 SQLitePersistence
实现了接口 BookManager
中所有方法。
创建 infrastructrue/config/config.go:
package config
type Config struct {
App ApplicationConfig `json:"app" yaml:"app"`
DB DBConfig `json:"db" yaml:"db"`
}
type DBConfig struct {
FileName string `json:"file_name" yaml:"file_name"`
}
type ApplicationConfig struct {
Port int `json:"port" yaml:"port"`
}
结构体 Config
将硬编码的配置项拎出。一般而言,配置“拎出来”都是好的工程实践。
创建 application/executor/book_operator.go:
package executor
import (
"context"
"literank.com/rest-books/domain/gateway"
"literank.com/rest-books/domain/model"
)
type BookOperator struct {
bookManager gateway.BookManager
}
func NewBookOperator(b gateway.BookManager) *BookOperator {
return &BookOperator{bookManager: b}
}
func (o *BookOperator) CreateBook(ctx context.Context, b *model.Book) (*model.Book, error) {
id, err := o.bookManager.CreateBook(ctx, b)
if err != nil {
return nil, err
}
b.ID = id
return b, nil
}
func (o *BookOperator) GetBook(ctx context.Context, id uint) (*model.Book, error) {
return o.bookManager.GetBook(ctx, id)
}
func (o *BookOperator) GetBooks(ctx context.Context) ([]*model.Book, error) {
return o.bookManager.GetBooks(ctx)
}
func (o *BookOperator) UpdateBook(ctx context.Context, id uint, b *model.Book) (*model.Book, error) {
if err := o.bookManager.UpdateBook(ctx, id, b); err != nil {
return nil, err
}
return b, nil
}
func (o *BookOperator) DeleteBook(ctx context.Context, id uint) error {
return o.bookManager.DeleteBook(ctx, id)
}
应用层封装所有底层的逻辑,并提供接口给顶层的适配层。
创建 application/wire_helper.go:
package application
import (
"literank.com/rest-books/domain/gateway"
"literank.com/rest-books/infrastructure/config"
"literank.com/rest-books/infrastructure/database"
)
// WireHelper 是依赖注入的辅助器
type WireHelper struct {
persistence *database.SQLitePersistence
}
func NewWireHelper(c *config.Config) (*WireHelper, error) {
db, err := database.NewSQLitePersistence(c.DB.FileName)
if err != nil {
return nil, err
}
return &WireHelper{persistence: db}, nil
}
func (w *WireHelper) BookManager() gateway.BookManager {
return w.persistence
}
WireHelper
帮助实现 DI(依赖注入)。
如果想要更全面的 DI 支持,考虑使用 wire 包。
有了这些准备之后,router 终于可以“整洁地”完成工作了。 它不用感知底层数据库的存在。此刻,关注点被分离了。
创建 adaptor/router.go:
package adaptor
import (
"fmt"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"literank.com/rest-books/application"
"literank.com/rest-books/application/executor"
"literank.com/rest-books/domain/model"
)
// RestHandler 处理所有 restful 请求
type RestHandler struct {
bookOperator *executor.BookOperator
}
func MakeRouter(wireHelper *application.WireHelper) (*gin.Engine, error) {
rest := &RestHandler{
bookOperator: executor.NewBookOperator(wireHelper.BookManager()),
}
r := gin.Default()
r.GET("/", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
})
})
r.GET("/books", rest.getBooks)
r.GET("/books/:id", rest.getBook)
r.POST("/books", rest.createBook)
r.PUT("/books/:id", rest.updateBook)
r.DELETE("/books/:id", rest.deleteBook)
return r, nil
}
// Get all books
func (r *RestHandler) getBooks(c *gin.Context) {
books, err := r.bookOperator.GetBooks(c)
if err != nil {
fmt.Printf("Failed to get books: %v", err)
c.JSON(http.StatusNotFound, gin.H{"error": "Failed to get books"})
return
}
c.JSON(http.StatusOK, books)
}
// Get single book
func (r *RestHandler) getBook(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Invalid id"})
return
}
book, err := r.bookOperator.GetBook(c, uint(id))
if err != nil {
fmt.Printf("Failed to get the book with %d: %v", id, err)
c.JSON(http.StatusNotFound, gin.H{"error": "Failed to get the book"})
return
}
c.JSON(http.StatusOK, book)
}
// Create a new book
func (r *RestHandler) createBook(c *gin.Context) {
var reqBody model.Book
if err := c.ShouldBindJSON(&reqBody); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
book, err := r.bookOperator.CreateBook(c, &reqBody)
if err != nil {
fmt.Printf("Failed to create: %v", err)
c.JSON(http.StatusNotFound, gin.H{"error": "Failed to create"})
return
}
c.JSON(http.StatusCreated, book)
}
// Update an existing book
func (r *RestHandler) updateBook(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Invalid id"})
return
}
var reqBody model.Book
if err := c.ShouldBindJSON(&reqBody); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
book, err := r.bookOperator.UpdateBook(c, uint(id), &reqBody)
if err != nil {
fmt.Printf("Failed to update: %v", err)
c.JSON(http.StatusNotFound, gin.H{"error": "Failed to update"})
return
}
c.JSON(http.StatusOK, book)
}
// Delete an existing book
func (r *RestHandler) deleteBook(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Invalid id"})
return
}
if err := r.bookOperator.DeleteBook(c, uint(id)); err != nil {
fmt.Printf("Failed to delete: %v", err)
c.JSON(http.StatusNotFound, gin.H{"error": "Failed to delete"})
return
}
c.JSON(http.StatusNoContent, nil)
}
替换 main.go 中内容,使其整洁:
package main
import (
"fmt"
"literank.com/rest-books/adaptor"
"literank.com/rest-books/application"
"literank.com/rest-books/infrastructure/config"
)
func main() {
c := &config.Config{
App: config.ApplicationConfig{
Port: 8080,
},
DB: config.DBConfig{
FileName: "test.db",
},
}
// 准备依赖项
wireHelper, err := application.NewWireHelper(c)
if err != nil {
panic(err)
}
// 构建主路由器
r, err := adaptor.MakeRouter(wireHelper)
if err != nil {
panic(err)
}
// 指定端口运行服务器
if err := r.Run(fmt.Sprintf(":%d", c.App.Port)); err != nil {
panic(err)
}
}
重构完成。🎉赞!