手把手教你学会写一个整洁架构(Clean Architecture)项目

我记得 那年,

秋叶黄了,

人也走了,

远远看去,

落一片白茫茫之虚空之地

走快的唯一方式是放慢速度先走好


唯一不变的就是变化,这句话适合现实世界,同时也适合软件世界。在一个可以正常运行的代码里面加入新的代码,常常会给我们带来新的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)

最后用一张图来描述我们上面做的事情

picture.image

总体分为四层:实体层,用户案例层(app层),表现层(controller),基础设施层(repository的实现)。

对于每一层,我们可以进行自己的分层,比如前面提到的queries和commands分层;domain层下面我们可以分为entity,valueobject,repository等

无论是大的分层还是我们自己的扩展分层,其目的都是为了关注点分离(separation of concerns)

要做到关注点分离需要做到单一职责原则,这个原则也是Bob大叔提出来的,原意是一个类应该只负责自己应该负责的职责,这样做的目的是让类只有一个变化的原因。

单一职责原则不仅针对类来讲,也可以扩展到编程中的变量,函数,文件,模块。一个变量应该只表达一个意思,一个函数应该专注完成一件事,一个模块应该只专注完成自己的功能。

0
0
0
0
评论
未登录
暂无评论