我记得 那年,
秋叶黄了,
人也走了,
远远看去,
落一片白茫茫之虚空之地
走快的唯一方式是放慢速度先走好
唯一不变的就是变化,这句话适合现实世界,同时也适合软件世界。在一个可以正常运行的代码里面加入新的代码,常常会给我们带来新的bug,并且会花费很多时间去定位问题和解决问题,有时候也会对突然出现的问题感到束手无策。
软件行业已经有几十年的发展。从代码层面去应对上面的问题一般有2类方式:一类是使用重构,一类是使用设计模式。从架构方面来把我这个问题一般会有一些经典的架构模式:比如微服务架构,DDD架构,整洁架构等。
可以发现,从代码和架构2个角度去应对变化的方式的本质都是解耦和关注点分离。把变化频繁的代码和比较稳定的代码分离开,把实现不同功能的代码分离开。
整洁架构简介
整洁架构是由Bob大叔提出来的。出自他的《整洁架构之道》。如果你想提高自己的代码质量,让自己的代码更加整洁,可以读Bob的另一本书《代码整洁之道》。《代码整洁之道》是Bob大叔40多年功力写的,《整洁架构之道》是Bob大叔50多年功力写的。
下面我们通过一个案例来学习如何写一个整洁架构项目。
小王是一个电影迷,喜欢收藏各种有趣的电影。为了自己在无聊的时候可以找到一部电影来打发周末时间,他想做一个管理自己喜欢的电影的系统。这个系统包含如下功能:
第一:可以查看自己所有的电影
第二:可以查看某一部电影的介绍
第三:可以更新某一部电影的信息
第四:可以删除一部电影,当自己觉得这部电影没那么有趣的时候
第五:可以添加自己喜欢的电影
以上的功能可以简称CRUD。为了说明整洁架构是如何应对变化的代码问题,这里我们只引入相对简单的业务逻辑。
接下来开始实战写代码。
第一步:按照go创建一个项目的习惯,我们先创建一个目录movie来表示项目名称,然后创建一个cmd文件夹,里面存放项目的入口文件main.go。
package main
func main(){
}
创建internal文件夹,业务相关的代码放在internal
domain层
接下来在 internal 下面创建一个 domain 文件夹,这里的domain和DDD里面的domain比较类似,但是比DDD的概念要简单些。
在domain里面创建一个movie的文件夹,存放entity和value object。创建一个movie.go
//Movie Model
type Movie struct {
ID uuid.UUID
Name string
Desc string
Country string
CreatedAt time.Time
}
接着给这个Movie创建一个Repository。
// Repository 定义与存储相关的方法,和DDD的repository一样
type Repository interface {
GetByID(id uuid.UUID) (*Movie, error)
GetAll() ([]Movie, error)
Add(movie Movie) error
Update(movie Movie) error
Delete(id uuid.UUID) error
}
domain层只包含了简单的domain对应的entity,vo和repository代码。没有其他额外的依赖和功能。使得看起来很简洁。
app层(应用层)
解析来我们在项目目录下创建app,app层(应用层)是用来实现具体的业务逻辑。app层下面会有很多的使用案例(use case)。每个使用案例描述一个业务功能。
前面我们列举了5个use case。实际一个系统可能会有很多的模块,模块下面会有很多的use case。为了让项目结构很整洁,我们对所有的use case有一个分类。这个分类会体现在后面的项目布局和文件命名上。
一共分为2类。
第一类:查询类(queries) 比如查询所有的电影GetAll,根据id查询一部电影信息GetById。
第二类:命令类(commands) 接受到数据然后回产生数据写入/修改/删除操作。比如AddMovie,UpdateMovie,RemoveMovie。
每个 use case或者说需求功能都有下面三个组件
第一个:请求参数(request data)
第二个:返回结果 (response data)
第三个:handler 处理业务逻辑
接着在app下面创建movie文件夹,在下面创建2个文件夹:queries和commands。在queries 里面创建2个文件getmoviebyid.go和getallmovie.go
package queries
import (
"github.com/google/uuid"
"github.com/myname/movie/internal/domain/movie"
"time"
)
//GetMovieRequest 请求参数
type GetMovieRequest struct {
MovieID uuid.UUID
}
// GetMovieResult 请求结果
type GetMovieResult struct {
ID uuid.UUID
Name string
Desc string
Country string
CreatedAt time.Time
}
//GetMovieRequestHandler handler用来处理具体的业务逻辑
type GetMovieRequestHandler interface {
Handle(query GetMovieRequest ) (*GetMovieResult, error)
}
type getMovieRequestHandler struct {
repo movie.Repository
}
//创建 handler
func NewGetMovieRequestHandler(repo movie.Repository) GetMovieRequestHandler {
return getMovieRequestHandler{repo: repo}
}
//实现查询
func (h getMovieRequestHandler) Handle(query GetMovieRequest) (*GetMovieResult, error) {
movie, err := h.repo.GetByID(query.MovieID)
var result *GetMovieResult
if movie != nil && err == nil {
result = &GetMovieResult{ID: movie.ID, Name: movie.Name,
Desc: movie.Desc, Country: movie.Country, CreatedAt: movie.CreatedAt}
}
return result, err
}
注意:这里我们把请求数据,返回结果和handler写在了同一个文件,是因为请求数据,返回结果的结构体比较简单。如果请求数据,返回结果的结构体字段比较多,建议为它们建立一个独立的包来存放。比如请求数据放在request包,返回结果放在response包。
同理,在commands包下面我们需要创建addmovie.go,updatemovie.go,removemovie.go。
当我们写好了handler之后,这些handler在哪里被使用呢?这里就需要引出app层下面还需要service组件,service组件来对query类型的handler和command类型的handler进行组合使用。
service层
我们先在app下面创建service包,然后创建一个movieservice.go
package service
import (
"github.com/myname/movie/internal/app/movie/commands"
"github.com/myname/movie/internal/app/movie/queries"
"github.com/myname/movie/internal/domain/movie"
"github.com/myname/movie/internal/pkg/time"
"github.com/myname/movie/internal/pkg/uuid"
)
//Queries 整合所有的 query 类型的handler
type Queries struct {
GetAllMoviesHandler queries.GetAllMoviesRequestHandler
GetMovieHandler queries.GetMovieRequestHandler
}
//Commands 整合所有的 command 类型的handler
type Commands struct {
CreateMovieHandler commands.CreateMovieRequestHandler
UpdateMovieHandler commands.UpdateMovieRequestHandler
DeleteMovieHandler commands.DeleteMovieRequestHandler
}
//MovieServices 包含对应的 Queries Commands
type MovieServices struct {
Queries Queries
Commands Commands
}
// NewServices
func NewMovieService(movieRepo movie.Repository, up uuid.Provider, tp time.Provider) Services {
return MovieServices{
Queries: Queries{
GetAllMoviesHandler: queries.NewGetAllMoviesRequestHandler(movieRepo),
GetMovieHandler: queries.NewGetMovieRequestHandler(movieRepo),
},
Commands: Commands{
CreateMovieHandler: commands.NewAddMovieRequestHandler(up, tp, movieRepo),
UpdateMovieHandler: commands.NewUpdateMovieRequestHandler(movieRepo),
DeleteMovieHandler: commands.NewDeleteMovieRequestHandler(movieRepo),
},
}
现在我们用内存的方式实现Repository,因为Repository可能有多个实现,所以我们专门把实现的代码放在internal下面的interfaceadapter。
interfaceadapter:存放可适配的接口实现,比如数据库存储实现,可以有mysql实现和redis实现,第三方接口的实现,可以有支付宝支付和微信支付的实现。
然后在interfaceadapter下面创建store包,用来存放存储的各类实现,在store包下面创建一个memeory包。
package memory
import (
"fmt"
"github.com/google/uuid"
"github.com/myname/movie/internal/domain/movie"
)
//Repo
type Repo struct {
mobvies map[string]movie.Movie
}
//NewRepo
func NewRepo() Repo {
mobvies := make(map[string]movie.Movie)
return Repo{mobvies}
}
//GetByID
func (m Repo) GetByID(id uuid.UUID) (*movie.Movie, error) {
mobvie, ok := m.mobvies[id.String()]
if !ok {
return nil, nil
}
return &mobvie, nil
}
controller层:使用定义好的service
最后一步:用net/http定义路由处理的handler,使用service
package movie
import (
"encoding/json"
"fmt"
"github.com/google/uuid"
"github.com/gorilla/mux"
"github.com/myname/movie/internal/app"
"github.com/myname/movie/internal/app/movie/commands"
"github.com/myname/movie/internal/app/movie/queries"
"net/http"
)
//Handler 处理http请求 需要包含 movieServices
type Handler struct {
movieServices app.MovieServices
}
//NewHandler
func NewHandler(app app.MovieServices) *Handler {
return &Handler{movieServices: app}
}
//CreateMovieRequestModel
type CreateMovieRequestModel struct {
Name string `json:"name"`
Desc string `json:"desc"`
Country string `json:"country"`
}
//create movie
func (c Handler) Create(w http.ResponseWriter, r *http.Request) {
var movieToAdd CreateMovieRequestModel
decodeErr := json.NewDecoder(r.Body).Decode(&movieToAdd)
if decodeErr != nil {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprint(w, decodeErr.Error())
return
}
//使用services下面对应的command的方法
err := c.movieServices.Commands.CreateMovieHandler
.Handle(commands.AddMovieRequest{
Name: movieToAdd.Name,
Desc: movieToAdd.Desc,
Country: movieToAdd.Country,
})
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprint(w, err.Error())
return
}
w.WriteHeader(http.StatusOK)
}
实现步骤:domain(entity和repository)-> app(定义use case的接口,分为queries和commands 2类定义)-> app(定义service,封装了queries和commands的接口)-> interfacceadapter(定义repository的实现)-> controller(定义处理http请求的handler)
最后用一张图来描述我们上面做的事情
总体分为四层:实体层,用户案例层(app层),表现层(controller),基础设施层(repository的实现)。
对于每一层,我们可以进行自己的分层,比如前面提到的queries和commands分层;domain层下面我们可以分为entity,valueobject,repository等
无论是大的分层还是我们自己的扩展分层,其目的都是为了关注点分离(separation of concerns)
要做到关注点分离需要做到单一职责原则,这个原则也是Bob大叔提出来的,原意是一个类应该只负责自己应该负责的职责,这样做的目的是让类只有一个变化的原因。
单一职责原则不仅针对类来讲,也可以扩展到编程中的变量,函数,文件,模块。一个变量应该只表达一个意思,一个函数应该专注完成一件事,一个模块应该只专注完成自己的功能。
